Skip to content

Instantly share code, notes, and snippets.

@tyrchen
Forked from GeorgeLyon/Rust vs. Swift.md
Created June 26, 2021 20:44
Show Gist options
  • Save tyrchen/7aa6eab75a4c6e864ec05358d25cb783 to your computer and use it in GitHub Desktop.
Save tyrchen/7aa6eab75a4c6e864ec05358d25cb783 to your computer and use it in GitHub Desktop.
A list of advantages Swift has over Rust

Note This is a little out of date. Rust has made some progress on some points, however many points still apply.

Philosophy

Progressive disclosure

Swift shares Rust's enthusiasm for zero-cost abstractions, but also emphasizes progressive disclosure. Progressive disclosure requires that language features should be added in a way that doesn't complicate the rest of the language. This means that Swift aims to be simple for simple tasks, and only as complex as needed for complex tasks.

The compiler works for you

Unlike Rust, which requires everything to be explicitly specified, Swift recognizes that the compiler is a powerful tool that can make good decisions on your behalf. Rust's hard edges discourage developers from using its more powerful features. In fact, the closing keynote to RustConf 2018 lauded the Borrow Checker for discouraging people from using the borrow checker! This results in less performant code much of the time. Even though Rust is capable of producing very performant code, I'd wager that Swift code is often more performant simply because the compiler is designed to optimize the abstractions that developers actually use.

No macros / build-time code generation

The escape hatch of macros and build-time source generation has allowed Rust to sweep some of its usability issues under the rug (Just use a macro!). Swift strives to support all of the use cases developer have in the language itself, to the point where if you are reaching for something like source generation you are probably doing something wrong. Over time, this has caused Swift to become much more flexible and feel natural in many domains.

Package Management

Coarse imports

In Rust, imports are extremely fine-grained. I'm not sure why this is the case, or what benefit this provides. In Swift, you can import the full standard library with just import Foundation. Compiler features like dead code elimination make this fairly low cost.

No build-time code execution

Swift has a focus on security. NPM is currently the punching bag of package managers with security issues, but there is nothing fundamentally different about cargo with the possible exception that it is currently being a much lower value target.

Tooling

First party IDE

Say what you want about Xcode (believe, me I have more war stories than most), but in its current iteration it is a stable, performant, standard development environment with many features missing from the VSCodes and Sublimes of the world. With the current state of RLS, developers often forsake modern IDE features even though reliable code completion, refactoring tools and diagnostics greatly benefit developers.

Out-of-the-box debugging experience

LLDB is deeply integrated into Xcode, has thorough Swift support, and works out of the box.

Type System

In Rust, if you call a function foo(), it resolved which foo you meant and performs type checking only after resolving the function. In Swift, a function call foo() will resolve to a set of function you may be referring to. From there, intuitive and deterministic rules select which specific function implementation to use. Below, I've listed a few advantages of Swift which result from this distinction. Rust is currently trying to address this problem piecemeal, but I think the ship has sailed in terms of adopting a more general solution that works in all cases.

Function-level polymorphism

One of the more powerful uses of a type system is to have functions perform different processing based on the type of its arguments.

In Rust, this is achived via the From and Into mechanic. The first unfortunate consequence of this is that you often have to create a new type and trait pair per argument you want to be polymorphic. This includes quite a bit of boilerplate:

pub enum Argument {
    BehaviorA(i64),
    BehaviorB(f64),
}
pub trait IntoArgument: Sized {
    fn into_argument(self) -> Argument;
}
impl IntoArgument for f64 {
    fn into_argument(self) -> Argument { … }
}
impl IntoArgument for i64 {
    fn into_argument(self) -> Argument { … }
}
fn polymorphic<T: IntoArgument>(t: T) }
    let argument = t.into();
    …
}

This become MUCH more complicated when traits are involved, because you can effectively only ever have one generic implementation of your custom into Into… for a trait, since otherwise the type checker complains there can at some point in the future be a collison. For instance, the following implementations will conflict, because there may be a type that is both Into and Iterator:

impl<T: Iterator<Item = f64>> IntoArgument for T { … }
impl<T: Into<f64>> IntoArgument for T { … }

In Swift, the function is selected at the call site based on the types of its arguments. Functions are also selected in order of increasing generality, and you can always select the specific function to call by casting the argument.

fn polymorphic(_ floats: [Float]) { … }
fn polymorphic(_ ints: [Int]) { … }
fn polymorphic<T: Sequence>(_ int_sequence: T) where T.Element == Int {
    polymorphic(Array(int_sequence));
}

// calls the `[Int]` variant
polymorphic([3])

Generic methods in protocols/traits

In Rust, you cannot create trait objects out of certain types of traits. Often, you could use trait objects for this type of thing, but I haven't figured out a way to do this with more complex constraints. For instance:

trait Bar {}
trait Baz {}
trait Foo {
    // This is invalid
    fn foo(t: &(Bar + Baz)) -> ();
}

Making it generic disallows using Foo as a trait object:

trait Foo {
    fn foo<T: Bar + Baz>(t: T) -> ();
}
// This is invalid
fn do_work(f: &Foo) -> () { … }

In Swift, protocols can just have generic methods. Under the hood, the function is called with the equivalent of a trait object.

protocol Foo {
    // This is fine
    func foo<T: Bar, Baz>(t: T) -> ()
}

Sugar

Multiple if let cases

In Rust, if you want to bind multiple arguments in an if let you have to use a tuple:

if let (Some(a), Some(b)) = (option_a, option_b) {
    …
}

In Swift, you can write multiple let statements together:

if let a = optionA,
   let b = optionB
{
    …
}

This becomes even more powerful when you include conditions:

if let a = optionA,
   a > 3,
   let b = optionB
{
    …
}

Even without let bindings, the fact that the , has the highest precedence makes it great for multiline expressions:

if a || b,
   c || d
{
    …
}

In Rust, this would be expressed like so:

if (a || b) &&
   (c || d)
{
    …
}

Rust's representation becomes significantly more complicated as more expressions are added.

Guard Statements

As part of the effort to combat deep nesting, Swift introduced guard which is like an if statement where the body must return or throw, and binds values to the outer scope:

guard let a = option_a else {
    return
}
a.do_stuff()

Explicit try

In Swift, a function or method cannot panic unless it is explicitly marked as throws. In Rust, anything can panic(). In complex scenarios, invariants can be broken in subtle ways when arbitrary statements panic.

Rust: i64::parse(s).unwrap(); (no indication of a potential panic, except knowing that unwrap can panic)

Swift: try Int(s)

Swift will also force you to handle the error, which you could conveniently convert to an Option with try?:

do {
    try Int(s)
} catch e {
    …
}

Rethrows

In Swift, you can define a function which throws only if a closure argument throws. map is a good example:

// Simple case
let x = [1].map { $0 + 1 }

// Requires `try`
let y = try [1].map { i in
    if i > 3 {
        throw Error
    } else {
        i + 1
    }
}

In Rust, the latter case requires using collect for Vec, or the unstable transpose for Option

vec![1].iter()
    .map(|i| 
        if i > 3 {
            Err(Error)
        } else {
            OK(i + 1)
        })
    .collect::<Result<Vec<_>,_>()?
#![feature(transpose_result)]
Some(1).map(|i| 
    if i > 3 {
        Err(Error)
    } else {
        OK(i + 1)
    })
    .transpose()?

Ternary Operator

Swift: let x = a > b ? a : b Rust: let x = if a > b { a } else { b }

Custom Operators

Along with overloading operators, Swift allows defining custom operators. This is particularly useful when creating DSLs.

String Interpolation

Rust uses old C-style format strings (albeit with inferred types), which can get a little unweildly:

println!("{} some text {} some more text {} more text {}: {}", a, b, c, d, e);

In Swift, interpolations are included inline:

print("\(a) some text \(b) some more text \(c) more text \(d): \(e)")

This is also about to get a whole lot more useful for custom types (like SQL expressions)

Named arguments

Rust has discussed this already but decided to postpone. Its my opinion that named arguments make code much more readable at the point of use.

  • Rust: dog.walk(…, true)
  • Swift: dog.walk(…, onLeash: true)

Default arguments

Rust has discussed this already but decided that while it is "oft-requested") because they can do it later, they are not going to do it.

In Swift, one can:

func walk_dog(onLeash: Bool = false) { … };

walk_dog();
walk_dog(onLeash: true);

Function-level polymorphism

This was covered in a previous section, but it also affects the usability of the language. In Rust, a function name is its identity. In Swift, the compiler takes into account the type of the arguments.

Rust:

let dog: Dog = ...;
let cat: Cat = ...;

fn walk_dog(dog: Dog) { … }
fn walk_cat(cat: Cat) { … }

walk_dog(dog);
walk_cat(cat);

Swift:

let dog: Dog = ...
let cat: Cat = ...

func walk(_ dog: Dog) { … }
func walk(_ cat: Cat) { … }

walk(dog)
walk(cat)

This is also useful for operators (in Swift, + is a polymorphic function)

func +(lhs: Int, rhs: Int) -> Int { … }
func +(lhs: Float, rhs: Float) -> Float { … }

Implicit Namespacing

Swift's compiler can figure out what namespace you mean, which is useful in switch/match.

Rust:

enum MyDescriptiveEnum {
    Foo, 
    Bar
}
let e: MyDescriptiveEnum = …;
match e {
    MyDescriptiveEnum::Foo => …,
    MyDescriptiveEnum::Bar => …,
}
call_fn(MyDescriptiveEnum::Foo);

Alternatively, you could use use:

use MyDescriptiveEnum as E;
match e {
    E::Foo => …,
    E::Bar => …,
}

Swift:

enum MyDescriptiveEnum {
    case foo, bar
}
let e: MyDescriptiveEnum = …
switch e {
case .foo:
    …
case .bar:
    …
}
call_fn(.foo);

Raw types for enumerations

In Swift, you can specify a raw type for enumerations, which synthesizes init?(rawValue: Raw) and var rawValue: Raw. In Rust you would need a #[derive(…)] directive or two match statements.

Custom Initializers

Rust's enum and struct initializers all use different syntax. In Swift, there is a single syntax:

Rust:

let e_1 = MyEnum::X(0);
let e_2 = MyEnum::Y{ name: 3 };
let s = MyStruct{ x, y: 3 };

Swift:

let e1 = MyEnum.x(o)
let e2 = MyEnum.y(name: o)
let s = MyStruct(x: x, y: 3)

Trailing Closures

In Swift, if the last argument to a function is a closure, it can be added after the close paren. If it is the only argument, the parens can be omitted entirely

// async(_ block: @escaping () -> ()) is a function on DispatchQueue
queue.async {
    …
}
// asyncAfter(delay: DispatchTimeInterval, _ block: @escaping () -> ()) is a function on DispatchQueue
queue.asyncAfter(.seconds(3)) {
    …
}

Default Closure Arguments

A small, but usefull feature Swift appropriated from scripting languages is default closure arguments: [1].map({ $0 + 3 })

Extensions

In Rust, if you want to add functionality to a type which is defined outside your current crate, you need to either wrap it or create a custom trait. If you want to add that functionality to multiple types, it gets a little messy:

trait MyAdditionalFunctionality<'a, T: Constraint> {
    fn do_work();
}
impl<'a, T: Constraint> MyAdditionalFunctionality<'a, T> for f64 {
    fn do_work() { … }
}
impl<'a, T: Constraint> MyAdditionalFunctionality<'a, T> for i64 {
    fn do_work() { … }
}

In Swift, you can simply create an extension:

extension Float {
    func do_work() { … }
}
extension Int {
    func do_work() { … }
}

Type Inference for Associated Types

In Rust, associated types must always be explicitly set in the implementation:

impl Iterator for MyStruct {
    type Item = i64;
    fn next() -> Option<i64> {
        …
    }
}

In Swift, the type checker can infer them:

extension MyStruct: IteratorProtocol {
    // `typealias Item = Int` is implicit
    func next() -> Option<Int> {
        …
    }
}

Custom Patterns

In Swift, you can define custom patterns using the ~= operator, which you can then use in switch

func ~=(value: Int, pattern: MyCustomPattern) -> Bool {
    …
}
switch 5 {
case MyCustomPattern():
    …
default:
    …
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment