Swift for Rust Developers: The Surprising Parts

Swift shares several syntactic similarities with Rust, making it easy to assume that familiar constructs behave the same way, even when their semantics differ.

This article documents the differences that caught me off guard while learning Swift and developing a custom app launcher for macOS.

Rather than providing an exhaustive comparison of the two languages, it focuses on the differences worth knowing to make the transition from Rust to Swift smoother.

Computed properties and property observers

In Swift, property access can execute arbitrary logic.

swift
var area: Double {
    get { width * height }
    set {
        // The setter's implicit `newValue` is the value assigned to `area`.
        width = newValue / height
    }
}

Reading area executes the getter, while assigning area = 100 executes the setter.

Even a var declaration can be read-only if it defines only a getter:

swift
var id: String {
    return "hello"
}

Additionally, you can attach observers to monitor changes:

swift
var count = 0 {
    // Swift provides `newValue` and `oldValue`.
    willSet { print("About to become \(newValue)") }
    didSet { print("Was \(oldValue)") }
}

// Prints one line from willSet and one from didSet.
count = 5

Opaque types (some) and Existential types (any)

The some keyword in Swift has nothing to do with optionals. Instead, it is roughly equivalent to the impl keyword in Rust.

Similarly, any is roughly equivalent to dyn.

Package manifest directives in comments

The Swift Package Manager requires a comment like // swift-tools-version: 6.0 at the very top of Package.swift. Removing it breaks compilation entirely because the parser relies on it to determine the toolchain version.

Documentation generation (DocC)

To get something roughly equivalent to cargo doc, Swift uses DocC.

First, add the DocC plugin to your Package.swift dependencies:

swift
.package(
    url: "https://github.com/swiftlang/swift-docc-plugin",
    from: "1.4.0"
),

This does not add any runtime dependencies to your library.

Then, you can preview your documentation locally by running:

sh
swift package --disable-sandbox \
    preview-documentation --product <NAME_OF_LIBRARY>

Modules and visibility

In Swift, a "module" is roughly equivalent to a crate, rather than an individual source file as it typically is in Rust. All files within the same Swift module can access each other's internal declarations without mod or use statements. The public keyword in Swift exposes declarations to other Swift modules.

Top-level statements

Swift allows top-level executable statements in an entry-point file, commonly, but not always named main.swift.

Semicolons

Semicolons are optional in Swift and are only required when writing multiple statements on the same line.

Idiomatic Swift omits semicolons unless needed.

Trailing Closure Syntax

Imagine a function that takes a closure:

swift
func run(_ action: () -> Void) { /* ... */ }

A closure is an expression that can look like a block:

swift
{ print("Hello") } // This is a closure.

Swift allows placing closure arguments outside the parentheses of a function call!

Thus, the following three are equivalent:

swift
run({ print("Hello") })  // Inline
run { print("Hello") }   // Trailing without parentheses
run() { print("Hello") } // Trailing with parentheses

Swift also supports multiple trailing closures using argument labels:

swift
func perform(task: () -> Void, onSuccess: () -> Void) { /* ... */ }

perform {
    print("Task")
} onSuccess: {
    print("Success")
}

Now imagine a function that takes an ordinary argument followed by a closure:

swift
func repeatAction(times: Int, action: () -> Void) { /* ... */ }

You will commonly see it expressed like this:

swift
repeatAction(times: 3) {
    print("Hello")
}

Result builders

Swift has a compiler-supported feature called @resultBuilder, which allows multiple expressions inside a closure to be combined into a single value.

For example, in SwiftUI, you can write:

swift
VStack(spacing: 24) {
    Text("Hello")
    Image(systemName: "star")
    Button("Click me") {}
}

At first glance, this looks like a closure containing multiple expressions, without an array, commas, or even an explicit return statement.

This works because VStack takes a closure annotated with @ViewBuilder, which is itself implemented using @resultBuilder.

@resultBuilder is built into the Swift compiler, but the builders themselves are ordinary Swift types. SwiftUI's @ViewBuilder is one such builder.

if let and guard let

Swift's if let is conceptually similar to Rust's if let, and guard let corresponds to Rust's let else statements.

Unlike Rust—which uses pattern matching on enums like Some(T), Swift has built-in optionals.

Here is if let for conditional unwrapping within a block:

swift
func greet(_ name: String?) {
    if let name {
        print(name) // `name` is unwrapped here
    }

    // `name` is again an optional here
}

And here is guard let, which exits early and leaves the unwrapped variable in scope for the rest of the function:

swift
func greet(_ name: String?) {
    guard let name else {
        return
    }

    // `name` is unwrapped for the rest of the function scope
}

For more complex enum unpacking with associated values closer to Rust's full pattern matching, Swift provides if case let and guard case let:

swift
enum Result {
    case success(String)
    case failure(Int)
}

func handle(_ result: Result) {
    if case let .success(message) = result {
        print(message)
    }
}

Mutability with let

In Rust, let bindings are immutable by default, requiring let mut for mutation. In Swift, let also declares an immutable binding, while var declares a mutable one.

For structs (value types), let behaves as expected:

swift
struct Configuration {
    var activates = false
}

let configuration = Configuration()
configuration.activates = true // Error!

However, classes in Swift are reference types, and let only prevents reassigning the reference, not mutating the object it refers to:

swift
class Configuration {
    var activates = false
}

let configuration = Configuration()
configuration.activates = true  // OK!
configuration = Configuration() // Error!

Swift's let does not guarantee that an object is immutable; it only guarantees that the binding cannot be reassigned.