PONY λ M2 Modula-2

C#.CodeCompared.To/Swift

An interactive executable cheatsheet comparing C# and Swift

C# 14 (.NET 10) Swift 6.3
Hello World & Basics
Hello, World
Console.WriteLine("Hello, World!");
print("Hello, World!")
Both languages run top-level statements with no Main ceremony. print replaces Console.WriteLine, and semicolons are optional (and omitted by convention).
let — the read-only local C# never had
var counter = 0; // mutable — the only kind of local counter += 1; const int limit = 10; // const covers compile-time constants only Console.WriteLine($"{counter} {limit}");
let fixed = 10 // read-only local, any runtime expression var counter = 0 // mutable local counter += 1 print(fixed, counter)
let is a genuine read-only binding — assignable once, from any runtime value — which C# locals cannot express (const handles only compile-time constants). Swift style is let everywhere; the compiler suggests demoting any var that is never mutated.
Inference & numeric strictness
var price = 4.5; // double var quantity = 3; // int var total = price * quantity; // int implicitly widens to double Console.WriteLine(total);
let price = 4.5 // Double let quantity = 3 // Int let total = price * Double(quantity) // NO implicit conversion — ever print(total)
Inference looks the same, but Swift performs no implicit numeric conversions at all — not even Int to Double. Every mixed-type expression needs an explicit constructor call like Double(quantity), a strictness beyond anything in C#.
Optionals — Enforced This Time
string? that actually stops you
#nullable enable string? nickname = null; // nickname.Length compiles anyway — CS8602 is just a WARNING, // and the ? annotation is erased at runtime. Console.WriteLine(nickname ?? "none");
var nickname: String? = nil // print(nickname.count) // compile ERROR — must unwrap first print(nickname ?? "none")
The shared headline with Kotlin, and the biggest safety upgrade from C#: String? is a distinct type (Optional<String>, a real enum), and using it without unwrapping refuses to compile. C#’s nullable annotations warn and vanish at runtime; Swift’s optionals are values the runtime actually represents.
if let — unwrapping as a binding
#nullable enable string? stored = "token"; if (stored is not null) { Console.WriteLine($"found {stored}"); } else { Console.WriteLine("empty"); }
let stored: String? = "token" if let stored { print("found \(stored)") // stored is a plain String in here } else { print("empty") }
if let unwraps and binds in one step — inside the braces, stored is a non-optional String. The shorthand if let stored (no right-hand side) shadows the optional with its unwrapped self, the closest analog to C#’s flow-narrowing after is not null.
guard let — early exit, then certainty
Console.WriteLine(Describe("hello")); Console.WriteLine(Describe(null)); static string Describe(string? input) { if (input is null) { return "nothing"; } return $"got {input}"; }
func describe(_ input: String?) -> String { guard let input else { return "nothing" } return "got \(input)" // input is non-optional for the REST of the function } print(describe("hello")) print(describe(nil))
guard is the early-return check made a language feature: its else block must exit the scope, and in exchange the unwrapped binding survives for the rest of the function — the inverse scoping of if let, and the backbone of idiomatic Swift function bodies.
Optional chaining & ??
#nullable enable var customer = new Customer(null); Console.WriteLine(customer.Address?.City ?? "unknown"); record Customer(Address? Address); record Address(string City);
struct Address { let city: String } struct Customer { let address: Address? } let customer = Customer(address: nil) print(customer.address?.city ?? "unknown")
The operators you already use daily transfer verbatim: ?. short-circuits on absence, ?? supplies the fallback. The difference is that the compiler forces the chain to end in a fallback or an unwrap — the optional never leaks silently.
The ! that fails honestly
#nullable enable string? configured = "value"; string forced = configured!; // suppresses the warning; does NOTHING at runtime Console.WriteLine(forced);
let configured: String? = "value" let forced: String = configured! // traps immediately if nil print(forced)
C#’s null-forgiving ! is erased; a wrong one lets null flow on to crash somewhere else. Swift’s force-unwrap performs a real check and stops the program at that line with a clear message. Both are last resorts — Swift’s at least fails at the scene of the crime.
Value Types Are the Culture
struct is the default choice
var original = new Point { X = 1, Y = 1 }; var copy = original; // struct — copied (C# structs are the niche case) copy.X = 99; Console.WriteLine($"{original.X} {copy.X}"); struct Point { public int X; public int Y; }
struct Point { var x: Int var y: Int } var original = Point(x: 1, y: 1) var copy = original // structs copy — and structs are the DEFAULT here copy.x = 99 print(original.x, copy.x) // 1 99
C# has structs but reserves them for small, niche types; in Swift the culture invertsstruct is the default modeling tool and class the special case for identity and sharing. Note the free memberwise initializer Point(x:y:): no constructor boilerplate.
String, Array, Dictionary — all value types
var first = new List<int> { 1, 2, 3 }; var second = first; // reference — the same list second.Add(4); Console.WriteLine($"{first.Count} {second.Count}"); // 4 4
var first = [1, 2, 3] var second = first // value — copied (copy-on-write under the hood) second.append(4) print(first.count, second.count) // 3 4
The standard collections — String, Array, Dictionary, Set — are all structs. Assignment copies (lazily, via copy-on-write), so the aliasing bugs C# collections invite simply cannot happen. Sharing must be chosen explicitly with a class.
mutating methods
var counter = new Counter(); counter.Increment(); // C# struct methods may mutate freely (a footgun) counter.Increment(); Console.WriteLine(counter.Count); struct Counter { public int Count { get; private set; } public void Increment() => Count += 1; }
struct Counter { private(set) var count = 0 mutating func increment() { count += 1 } } var counter = Counter() counter.increment() counter.increment() print(counter.count)
A struct method that changes state must be declared mutating — and calling it on a let struct is a compile error. C# lets struct methods mutate silently (the classic source of "modified a copy" bugs); Swift makes value mutation visible in the signature and impossible on immutable bindings.
let is deep for value types
var numbers = new List<int> { 1, 2, 3 }; // reference in a readonly field numbers.Add(4); // ...would still be mutable Console.WriteLine(numbers.Count);
let numbers = [1, 2, 3] // numbers.append(4) // compile error: let + value type = deeply immutable print(numbers.count) var editable = numbers // a mutable COPY editable.append(4) print(editable.count)
Because arrays are value types, let freezes the whole value — there is no C#-style "readonly reference to a mutable list" gap. Immutability is decided at the binding, not the type: the same Array type is frozen under let and mutable under var.
Strings
String interpolation
var name = "Ada"; var count = 3; Console.WriteLine($"{name} has {count} items ({count * 2} shoes)");
let name = "Ada" let count = 3 print("\(name) has \(count) items (\(count * 2) shoes)")
Every Swift string literal interpolates — no $ prefix to remember. The delimiter is \(expression), and any expression fits inside. Formatting alignment/precision uses String(format:) or formatted values rather than C#’s {value:F2} inline syntax.
Raw & multiline strings
var json = """ { "name": "Ada" } """; Console.WriteLine(json);
let json = """ { "name": "Ada" } """ print(json)
Triple-quoted strings work almost identically — including the indentation stripping keyed off the closing delimiter, which C# 11 borrowed from Swift’s design. Swift’s version still interpolates \(…); for regex-heavy content, #"raw"# delimiters disable escaping.
Length counts characters, not code units
var family = "👨‍👩‍👧‍👦"; Console.WriteLine(family.Length); // 11 — UTF-16 code units var enumerator = System.Globalization.StringInfo.GetTextElementEnumerator(family); var graphemes = 0; while (enumerator.MoveNext()) graphemes += 1; Console.WriteLine(graphemes); // 1 — needs StringInfo
let family = "👨‍👩‍👧‍👦" print(family.count) // 1 — user-perceived characters, by default print(family.unicodeScalars.count) // 7 — the scalars underneath
C#’s Length counts UTF-16 code units — the family emoji is 11 of them. Swift’s count counts grapheme clusters (what a person sees) by default, with unicodeScalars/utf8/utf16 views when you need the underlying encodings. The price: Swift strings are not integer-indexable (text[0] does not compile).
Collections
Arrays
var fruits = new List<string> { "apple", "banana" }; fruits.Add("cherry"); fruits.RemoveAt(0); Console.WriteLine(string.Join(", ", fruits)); Console.WriteLine(fruits.Count);
var fruits = ["apple", "banana"] fruits.append("cherry") fruits.remove(at: 0) print(fruits) print(fruits.count)
The literal syntax […] replaces the new List<string> { } ceremony, and printing an array shows its contents (no string.Join needed). Note the argument label in remove(at: 0) — labels are part of the method name.
Dictionaries return optionals
var stock = new Dictionary<string, int> { ["apples"] = 5 }; stock["pears"] = 2; // stock["plums"] would THROW KeyNotFoundException: Console.WriteLine(stock.TryGetValue("plums", out var quantity) ? quantity : 0);
var stock = ["apples": 5] stock["pears"] = 2 // Lookup returns Int? — absence is an optional, never an exception: print(stock["plums"] ?? 0)
A dictionary lookup returns an optional, so the missing-key case is handled with ?? or if let instead of C#’s exception-throwing indexer and the TryGetValue/out dance. The optional system absorbs a whole API pattern.
Where/Select → filter/map
var numbers = new[] { 1, 2, 3, 4, 5, 6 }; var result = numbers .Where(number => number % 2 == 0) .Select(number => number * 10) .Sum(); Console.WriteLine(result);
let numbers = [1, 2, 3, 4, 5, 6] let result = numbers .filter { $0.isMultiple(of: 2) } .map { $0 * 10 } .reduce(0, +) print(result)
The LINQ verbs exist under FP names — Wherefilter, Selectmap, Aggregatereduce. Trailing closures drop the parentheses, $0 is the implicit parameter, and operators like + pass directly as functions (reduce(0, +)).
Eager by default; .lazy is the LINQ
var firstThree = Enumerable.Range(1, 1000000) .Where(number => number % 7 == 0) .Select(number => number * 2) .Take(3); // deferred — nothing runs until enumeration Console.WriteLine(string.Join(", ", firstThree));
let firstThree = (1...1_000_000).lazy .filter { $0.isMultiple(of: 7) } .map { $0 * 2 } .prefix(3) print(Array(firstThree))
Swift’s collection operators are eager — each step builds an array — where LINQ defers by default. The .lazy view opts back into LINQ-style element-at-a-time evaluation; prefix is Take. The instinct to keep: reach for .lazy on long chains with early exits.
Control Flow
Conditions & the surviving ternary
var temperature = 30; var description = temperature > 25 ? "hot" : "mild"; if (temperature > 25) { Console.WriteLine(description); }
let temperature = 30 let description = temperature > 25 ? "hot" : "mild" if temperature > 25 { print(description) }
Two small changes: no parentheses around the condition, and braces are required even for one-liners (the classic dangling-else and goto-fail bugs are unrepresentable). The ternary operator survives unchanged — unlike Kotlin, Swift kept it.
The C-style for loop is gone
for (var count = 1; count <= 5; count++) { Console.Write($"{count} "); } Console.WriteLine(); foreach (var fruit in new[] { "apple", "banana" }) { Console.WriteLine(fruit); }
for count in 1...5 { print(count, terminator: " ") } print() for fruit in ["apple", "banana"] { print(fruit) }
Swift removed the C-style for (;;) loop entirely — iteration is always for‑in over a sequence, with ranges (1...5 inclusive, 1..<5 half-open) covering the counting cases. stride(from:to:by:) handles arbitrary steps.
guard beyond optionals
Console.WriteLine(Validate("")); Console.WriteLine(Validate("Ada")); static string Validate(string name) { if (string.IsNullOrEmpty(name)) { return "invalid"; } return $"welcome, {name}"; }
func validate(_ name: String) -> String { guard !name.isEmpty else { return "invalid" } return "welcome, \(name)" } print(validate("")) print(validate("Ada"))
guard works with any condition, not just unwrapping: state the requirement positively, and the compiler enforces that the else branch exits. Function bodies become a flat list of preconditions followed by the happy path — no arrow-shaped nesting.
switch & Pattern Matching
switch: exhaustive, no breaks
var status = 404; var message = status switch { 200 => "ok", 301 or 302 => "redirect", >= 400 and < 500 => "client error", _ => "other", }; Console.WriteLine(message);
let status = 404 switch status { case 200: print("ok") case 301, 302: print("redirect") case 400..<500: print("client error") default: print("other") }
Swift’s switch is the statement form of C#’s switch expression: no fallthrough (no break needed), comma-separated alternatives, range patterns, and compiler-enforced exhaustiveness. Where C# still carries the legacy fallthrough statement form, Swift has only the safe one.
Value binding & where
var point = (X: 3, Y: -3); var described = point switch { (0, 0) => "origin", var (x, y) when x == -y => $"on the anti-diagonal at {x}", var (x, _) => $"somewhere with x = {x}", }; Console.WriteLine(described);
let point = (x: 3, y: -3) switch point { case (0, 0): print("origin") case let (x, y) where x == -y: print("on the anti-diagonal at \(x)") case let (x, _): print("somewhere with x = \(x)") }
The pattern vocabularies have converged remarkably: tuple patterns, binding (case let vs var (x, y)), guards (where vs when), and wildcards. Swift checks tuple-pattern exhaustiveness here without a default because the bound case matches anything.
is-pattern → if case
object value = 42; if (value is int number && number > 40) { Console.WriteLine($"big int: {number}"); }
enum Measurement { case temperature(Double) case humidity(Int) } let reading = Measurement.temperature(39.5) if case .temperature(let degrees) = reading, degrees > 38 { print("fever: \(degrees)") }
if case applies one pattern outside a switch — the analog of C#’s is-pattern in an if. The comma chains further conditions the way C# uses && after a pattern. Most commonly used to pluck one enum case’s payload without a full switch.
Enums Are Sum Types
Enums carry payloads
// A C# enum is a named integer — payload-carrying cases need a // record hierarchy instead. Shape shape = new Circle(2); var area = shape switch { Circle circle => Math.PI * circle.Radius * circle.Radius, Rectangle rectangle => rectangle.Width * rectangle.Height, _ => throw new InvalidOperationException("unknown shape"), }; Console.WriteLine($"{area:F2}"); abstract record Shape; record Circle(double Radius) : Shape; record Rectangle(double Width, double Height) : Shape;
enum Shape { case circle(radius: Double) case rectangle(width: Double, height: Double) } let shape = Shape.circle(radius: 2) switch shape { case .circle(let radius): let area = (Double.pi * radius * radius * 100).rounded() / 100 print("circle area \(area)") case .rectangle(let width, let height): print("rectangle area \(width * height)") }
The single biggest upgrade over C# enums: Swift enum cases carry typed payloads, making the enum a true sum type — what C# fakes with an abstract record hierarchy. The switch is exhaustive with no defensive _ arm, and adding a case breaks every non-exhaustive switch at compile time.
Raw values & CaseIterable
foreach (var level in Enum.GetValues<LogLevel>()) { Console.WriteLine($"{level} = {(int)level}"); } enum LogLevel { Debug = 1, Info = 2, Warning = 3 }
enum LogLevel: Int, CaseIterable { case debug = 1, info, warning } for level in LogLevel.allCases { print(level, level.rawValue) }
C#’s named-integer style still exists as raw values, opted into per enum. CaseIterable synthesizes allCases (C#’s Enum.GetValues), and LogLevel(rawValue: 2) converts back — returning an optional, naturally, since the integer might match nothing.
Functions & Closures
Argument labels are required
// C# named arguments are OPTIONAL sugar at the call site: Console.WriteLine(Resize(800, 600)); Console.WriteLine(Resize(width: 800, height: 600)); // same call static string Resize(int width, int height) => $"{width}x{height}";
func resize(width: Int, height: Int) -> String { "\(width)x\(height)" } // Labels are part of the method's NAME — omitting them will not compile: print(resize(width: 800, height: 600)) // print(resize(800, 600)) // compile error
The polarity flips: C# names arguments when the caller feels like it; Swift requires labels unless the declaration opts out with _ (func resize(_ width: Int, …)). Labels are part of the function’s identity — resize(width:height:) — which is why Swift APIs read like sentences.
Defaults & variadics
Console.WriteLine(Total(1, 2, 3)); static int Total(params int[] values) => values.Sum();
func total(_ values: Int..., bonus: Int = 0) -> Int { values.reduce(0, +) + bonus } print(total(1, 2, 3)) print(total(1, 2, 3, bonus: 10))
params becomes the Int... variadic, arriving as an array in the body. Because labels disambiguate, a variadic does not have to be last — a labeled parameter can follow it, which C#’s params cannot allow.
Lambdas → closures & trailing syntax
var numbers = new[] { 3, 1, 2 }; var ordered = numbers.OrderBy(number => number).ToArray(); Console.WriteLine(string.Join(", ", ordered)); Func<int, int> doubler = number => number * 2; Console.WriteLine(doubler(21));
let numbers = [3, 1, 2] let ordered = numbers.sorted { $0 < $1 } // trailing closure print(ordered) let doubler = { (number: Int) -> Int in number * 2 } print(doubler(21))
The closure literal is braces with an in separating signature from body; $0/$1 are the implicit parameters. When the last argument is a closure, it moves outside the parentheses (trailing-closure syntax) — the shape SwiftUI’s entire DSL is built on.
Tuples & multiple returns
var (minimum, maximum) = Bounds(new[] { 3, 1, 4 }); Console.WriteLine($"{minimum}..{maximum}"); static (int Minimum, int Maximum) Bounds(int[] values) => (values.Min(), values.Max());
func bounds(_ values: [Int]) -> (minimum: Int, maximum: Int) { (values.min()!, values.max()!) } let (minimum, maximum) = bounds([3, 1, 4]) print("\(minimum)..\(maximum)")
Named tuples rhyme almost perfectly with C#’s ValueTuple — labels in the return type, destructuring at the call site. The ! after min()/max() force-unwraps the optional those methods return for a possibly-empty array; production code would guard instead.
Classes, Protocols & Extensions
Classes — when identity matters
var account = new BankAccount("Ada"); account.Deposit(100); Console.WriteLine($"{account.Owner}: {account.Balance}"); class BankAccount(string owner) { public string Owner { get; } = owner; public int Balance { get; private set; } public void Deposit(int amount) => Balance += amount; }
class BankAccount { let owner: String private(set) var balance = 0 init(owner: String) { self.owner = owner } func deposit(_ amount: Int) { balance += amount } } let account = BankAccount(owner: "Ada") account.deposit(100) print("\(account.owner): \(account.balance)")
Classes are reference types in both languages — but in Swift you reach for one only when identity or sharing is the point. init replaces the constructor, there is no new, and note that a let holding a class instance still permits mutating its state (the reference is constant, not the object — exactly like C# readonly).
Interfaces → protocols
ISpeaker[] speakers = { new Dog(), new Robot() }; foreach (var speaker in speakers) { Console.WriteLine(speaker.Speak()); } interface ISpeaker { string Speak(); } class Dog : ISpeaker { public string Speak() => "Woof"; } class Robot : ISpeaker { public string Speak() => "Beep"; }
protocol Speaker { func speak() -> String } struct Dog: Speaker { func speak() -> String { "Woof" } } struct Robot: Speaker { func speak() -> String { "Beep" } } let speakers: [any Speaker] = [Dog(), Robot()] for speaker in speakers { print(speaker.speak()) }
Protocols are interfaces without the I prefix — and structs conform as readily as classes, so polymorphism does not require reference types. The any Speaker spelling marks an existential (heterogeneous) collection explicitly, a distinction C# leaves implicit.
Protocol extensions — default implementations
Console.WriteLine(new Dog().Describe()); interface ISpeaker { string Speak(); // C# 8 default interface methods exist, but are callable only // through the interface reference and rarely used. string Describe() => $"I say {Speak()}"; } class Dog : ISpeaker { public string Speak() => "Woof"; public string Describe() => $"I say {Speak()}"; }
protocol Speaker { func speak() -> String } extension Speaker { // Every conforming type inherits this for free: func describe() -> String { "I say \(speak())" } } struct Dog: Speaker { func speak() -> String { "Woof" } } print(Dog().describe())
Protocol extensions are Swift’s signature move — shared behavior attached to the protocol itself, inherited by every conforming type and callable directly on the concrete value (no interface-reference restriction like C#’s default interface methods). Large parts of the standard library are protocol extensions on Sequence and Collection.
Retroactive conformance
// C# cannot make SOMEONE ELSE'S type implement YOUR interface — // extension methods add behavior, never conformance. The workaround // is a wrapper class. Console.WriteLine(new PrettyInt(42).Pretty()); interface IPretty { string Pretty(); } record PrettyInt(int Value) : IPretty { public string Pretty() => $"<{Value}>"; }
protocol Pretty { func pretty() -> String } // Conform a type you don't own — even Int — to a protocol you do: extension Int: Pretty { func pretty() -> String { "<\(self)>" } } print(42.pretty())
An extension can add a protocol conformance to any existing type, including the standard library’s — no wrapper, no ownership required. This is the capability C# extension methods approach but never reach: they add methods, but the type never becomes an implementer of the interface.
Properties
Computed properties
var rectangle = new Rectangle(3, 4); Console.WriteLine(rectangle.Area); class Rectangle(double width, double height) { public double Width { get; } = width; public double Height { get; } = height; public double Area => Width * Height; }
struct Rectangle { var width: Double var height: Double var area: Double { width * height } } let rectangle = Rectangle(width: 3, height: 4) print(rectangle.area)
A read-only computed property is just a braced body after the type — the analog of C#’s expression-bodied => member. Add explicit get/set blocks for read-write computed properties; stored and computed properties are otherwise declared identically.
willSet / didSet observers
var thermostat = new Thermostat(); thermostat.Celsius = 25; thermostat.Celsius = 30; class Thermostat { private double celsius; public double Celsius { get => celsius; set { Console.WriteLine($"changing {celsius} -> {value}"); celsius = value; // observer logic hand-rolled into the setter } } }
struct Thermostat { var celsius: Double = 0 { willSet { print("changing \(celsius) -> \(newValue)") } didSet { print("changed (was \(oldValue))") } } } var thermostat = Thermostat() thermostat.celsius = 25 thermostat.celsius = 30
Property observers attach to a stored property — no backing-field ceremony, no custom setter. willSet sees the incoming newValue, didSet the departed oldValue. C# expresses the same thing only by expanding the auto-property into a manual one.
Error Handling
try is visible at every call site
// Nothing at the call site says Parse can throw: try { var value = ParseQuantity("many"); Console.WriteLine(value); } catch (FormatException exception) { Console.WriteLine($"caught: {exception.Message}"); } static int ParseQuantity(string text) => int.TryParse(text, out var value) ? value : throw new FormatException($"not a number: {text}");
enum ParseError: Error { case notANumber(String) } func parseQuantity(_ text: String) throws -> Int { guard let value = Int(text) else { throw ParseError.notANumber(text) } return value } do { let value = try parseQuantity("many") // try is REQUIRED here print(value) } catch ParseError.notANumber(let text) { print("caught: not a number: \(text)") }
Two honesty upgrades over C#: a function that can throw says so in its signature (throws), and every call to it must be marked try — the invisible exception paths that plague C# codebases are syntactically impossible. Catch clauses pattern-match on error cases, payloads included.
try? and try!
// C#'s Try-pattern is a separate method returning bool + out: Console.WriteLine(int.TryParse("42", out var parsed) ? parsed : 0); Console.WriteLine(int.TryParse("many", out var failed) ? failed : 0);
enum ParseError: Error { case notANumber(String) } func parseQuantity(_ text: String) throws -> Int { guard let value = Int(text) else { throw ParseError.notANumber(text) } return value } print((try? parseQuantity("42")) ?? 0) // error -> nil, then ?? print((try? parseQuantity("many")) ?? 0)
try? converts any thrown error into nil, folding the whole TryParse pattern into the optional system — one throwing function serves both styles, instead of C#’s parallel Parse/TryParse method pairs. try! asserts no error will occur and traps if one does.
using/finally → defer
try { Console.WriteLine("working"); throw new InvalidOperationException("failed mid-way"); } catch (InvalidOperationException) { Console.WriteLine("caught"); } finally { Console.WriteLine("cleanup"); // one finally per try block }
func work() { defer { print("cleanup") } // runs on EVERY exit from this scope print("working") } work()
defer schedules cleanup for scope exit — return, throw, or fall-through — right next to the acquisition it balances, with multiple defers unwinding in reverse order. It covers what C# splits between finally and using, without tying cleanup to a try block or an IDisposable.
ARC, not GC
Deterministic deinit
// A C# finalizer runs whenever the GC gets around to it — // deterministic cleanup needs IDisposable + using. using (var session = new Session()) { Console.WriteLine("working"); } Console.WriteLine("after the using block"); class Session : IDisposable { public Session() => Console.WriteLine("opened"); public void Dispose() => Console.WriteLine("closed"); }
class Session { init() { print("opened") } deinit { print("closed") } } func work() { let session = Session() print("working") } // last reference gone -> deinit runs HERE, deterministically work() print("after work()")
Swift uses automatic reference counting, not a tracing GC: the moment the last reference disappears, deinit runs — deterministically, like C++ RAII. The whole IDisposable/using apparatus exists because C# finalizers cannot promise that; in Swift the destructor alone suffices.
Retain cycles are your problem now
// Under a tracing GC, two objects referencing each other still // get collected — cycles are a non-issue in C#. var parent = new Node("parent"); var child = new Node("child"); parent.Next = child; child.Next = parent; // harmless in C# Console.WriteLine("cycle built; the GC will reclaim both"); class Node(string name) { public string Name { get; } = name; public Node? Next { get; set; } }
class Node { let name: String var next: Node? weak var previous: Node? // weak breaks the cycle init(name: String) { self.name = name } deinit { print("\(name) deallocated") } } func buildChain() { let first = Node(name: "first") let second = Node(name: "second") first.next = second second.previous = first // weak — no cycle, both deinit } buildChain() print("after buildChain()")
The price of ARC’s determinism: reference cycles leak, because each object keeps the other’s count above zero. Back-references are declared weak (an optional that becomes nil when the target dies) or unowned. The same discipline applies to closures capturing self — the [weak self] capture list you will meet everywhere in UI code.
Concurrency
async/await — the direct rhyme
var greeting = await FetchGreetingAsync(); Console.WriteLine(greeting); static async Task<string> FetchGreetingAsync() { await Task.Delay(50); return "hello from a task"; }
func fetchGreeting() async throws -> String { try await Task.sleep(for: .milliseconds(50)) return "hello from an async function" } let greeting = try await fetchGreeting() print(greeting)
The closest concurrency rhyme on this page: async/await transfer almost keyword-for-keyword, and Swift’s return type stays unwrapped (String, not Task<string>). Note async throws composing with try await — effects stack explicitly in the signature and at the call site.
Task.WhenAll → async let
var results = await Task.WhenAll(SquareAsync(3), SquareAsync(4)); Console.WriteLine(string.Join(", ", results)); static async Task<int> SquareAsync(int value) { await Task.Delay(10); return value * value; }
func square(_ value: Int) async -> Int { value * value } async let first = square(3) async let second = square(4) let results = await [first, second] print(results)
async let starts child tasks that run concurrently and must be awaited before the scope ends — structured concurrency the compiler enforces, where Task.WhenAll is a convention the programmer maintains. For a dynamic number of children, withTaskGroup is the scaling version.
lock → actor
var account = new BankAccount(); account.Deposit(10); account.Deposit(5); Console.WriteLine(account.Balance); class BankAccount { private readonly object gate = new(); public int Balance { get; private set; } public void Deposit(int amount) { lock (gate) { Balance += amount; } // discipline, not enforcement } }
actor BankAccount { private var balance = 0 func deposit(_ amount: Int) -> Int { balance += amount // no lock — the actor serializes access return balance } } let account = BankAccount() print(await account.deposit(10)) print(await account.deposit(5))
An actor is a reference type whose state is isolated: all access is serialized by the runtime, and cross-actor calls must be awaited. Where C#’s lock is a discipline the compiler never checks, Swift 6’s strict concurrency makes unsynchronized shared mutation a compile error — the data race, not the deadlock, becomes impossible.