← BackSwift30 Q

Swift

Value types, optionals, and structured concurrency

30 questions | entry to principal | answers written to be said out loud, not read

Entry

What's the difference between a struct and a class in Swift, and which do you reach for first?

A struct is a value type, so assigning it or passing it into a function hands over a copy, while a class is a reference type where 2 variables can point at the same instance. I reach for a struct first because copies mean nobody mutates my data behind my back, and I only pick a class when I genuinely need identity, inheritance, deinit, or Objective-C interop. The gotcha people miss is that a struct holding a class property still shares that inner reference, so the copy is shallow, not deep.

When do you use let versus var, and does it matter as much as people say?

let declares a constant binding and var declares a mutable one, and I default to let everywhere until the compiler tells me I actually need to mutate. It matters more than it looks, because with a value type like a struct let makes the whole thing immutable including its properties, while with a class let only pins the reference and the object's own var properties can still change. That distinction catches people out in code review all the time.

What can an extension do in Swift, and what can it not do?

An extension adds methods, computed properties, initializers, and protocol conformances to a type I do not own, including types from the standard library or a framework. I use them to keep a file organized, for example putting each protocol conformance in its own extension so the type body stays about the data. The limit is that an extension cannot add stored properties, because that would change the memory layout of a type someone else already compiled.

Why does Swift have optionals at all?

An optional is a type that either holds a value or holds nil, and Swift makes that possibility part of the type system instead of letting any reference secretly be null. The payoff is that the compiler forces me to handle the empty case at the exact spot where it can happen, so I do not ship the null pointer crashes that dominate crash reports in languages without it. Under the hood Optional is just an enum with a some case and a none case, which is why pattern matching works on it.

What's the difference between if let and guard let?

if let unwraps an optional into a new constant that only exists inside the branch, while guard let unwraps it into the surrounding scope and forces me to return or throw when it is nil. I reach for guard at the top of a function for preconditions because it keeps the happy path unindented instead of building a pyramid of nested ifs. Swift also lets me shadow the name now, so guard let user else { return } reads clean without inventing a second variable name.

What do people mean by protocol oriented programming in Swift?

A protocol declares the methods and properties a type has to provide, and any struct, class, or enum can conform to it, which is how Swift gets polymorphism without a class hierarchy. Protocol oriented programming means I start from small protocols describing capabilities and compose them, instead of inheriting a fat base class that drags along state I do not need. The practical win is that a value type like a struct can participate, and class inheritance simply cannot give me that.

How does Codable work, and where do you end up writing custom code?

Codable is just Encodable and Decodable together, and for most types the compiler synthesizes the whole implementation as long as every stored property is itself codable. When the JSON does not match my property names I add a CodingKeys enum, and when the shape is genuinely different I write init(from decoder:) by hand. The thing I always set first is a decoding strategy for dates and snake case keys, because that removes most of the custom code people write by reflex.

How does ARC differ from a tracing garbage collector?

ARC is automatic reference counting: the compiler inserts retain and release calls at compile time, so an object is deallocated the instant its last strong reference goes away. A tracing garbage collector instead pauses periodically to walk the object graph and find what is unreachable, which costs unpredictable pause times but handles cycles for free. The tradeoff is that ARC gives me deterministic deallocation and no stop the world pause, but it cannot break a reference cycle on its own, so that part is my job.

Junior

What are associated values on an enum, and when have you actually used them?

A Swift enum can carry data on each case, so instead of an enum plus a separate payload struct I model something like a network state as loading, loaded with items, and failed with an error. That makes illegal states unrepresentable, because there is no way to be in the loaded case with no data attached. I use associated values constantly for view state, and the compiler forces me to handle every case in a switch.

How far does Swift's pattern matching go beyond a plain switch on a value?

Swift's switch has to be exhaustive, and its patterns go well past matching a single value. I can bind associated values with case .failed(let error), match ranges, match tuples, and attach a where clause to add a condition to any case. I also use if case and for case let when I only care about 1 pattern and do not want a whole switch, which keeps unwrapping code short.

Walk me through Swift's access control levels.

Swift has 5 levels: private for the enclosing declaration and its extensions in the same file, fileprivate for the whole file, internal which is the default and means the whole module, public for other modules to use, and open which additionally lets other modules subclass or override. The one people forget is that public on a class does not allow subclassing outside the module, that is exactly what open is for. In app code I mostly live in private and internal, and I only think hard about public and open when I am shipping a framework.

Is force unwrapping ever defensible, or is it always a bug waiting to happen?

Force unwrapping with ! crashes the app if the value is nil, so my default is never to reach for it in code that handles user input or network data. It is defensible when the value is genuinely guaranteed by the program's structure, like an IBOutlet after the view loaded or a URL built from a string literal I control, because a crash there would mean a programming error, not a runtime condition. When I do use it I treat it as an assertion, and I make sure the invariant is obvious to whoever reads the line next.

When do you use throws versus returning a Result?

A throwing function marks failure in its signature with throws, and the caller has to write try inside a do catch or propagate it upward, which keeps the happy path readable. Result wraps success and failure into a value I can store, pass around, or hand to a completion handler, which is why it showed up mostly in callback based APIs before async existed. My rule is throws for code I call directly and Result when the outcome has to be carried somewhere else before anyone looks at it.

What does defer do, and when do you actually use it?

defer schedules a block to run when the current scope exits, no matter how it exits, whether that is a normal return, an early guard return, or a thrown error. I use it to put cleanup right next to the acquisition, like closing a file handle or ending a background task, so I cannot forget it in the 1 path I did not think about. Multiple defer blocks in the same scope run in reverse order, so the last one registered runs first.

How do generics and their constraints work in Swift?

Generics let me write one function or type that works across many types while keeping full type safety, so a Stack<Element> is checked at compile time instead of holding Any and casting later. Constraints are how I say what the placeholder is allowed to be, like where Element: Equatable or requiring Comparable so I can actually order the values inside. Swift specializes generic code at compile time, so unlike type erasure in some other languages I do not pay a boxing cost at runtime.

Explain strong, weak, and unowned references.

A strong reference keeps the object alive and bumps the count, and that is the default for every reference I write. A weak reference does not keep it alive and is automatically set to nil when the object goes away, so it has to be an optional var, and I use it for a delegate or a pointer back to a parent. An unowned reference also does not retain, but it is neither optional nor zeroed, so touching it after the object is gone crashes, which means I only use it when the lifetime is strictly shorter.

Senior

What is an implicitly unwrapped optional and why do they still exist?

An implicitly unwrapped optional, written with ! in the type, is still an optional but the compiler unwraps it for me at every use site, so it crashes the moment it is nil. They survive for 2 reasons mainly: IBOutlet properties that get wired up after init runs, and Objective-C APIs imported before nullability annotations existed. In new Swift code I treat them as a smell and prefer a real optional or a proper initializer, because an implicitly unwrapped optional trades a compile time check for a runtime crash.

What is the trap with default implementations in a protocol extension?

A protocol extension lets me write a default implementation for a requirement, so every conforming type gets the behavior for free and only overrides it when it needs something different. The trap is static dispatch: if I add a method in the extension that is not declared in the protocol itself, a call through a variable typed as the protocol always runs the extension version, even when the concrete type defines its own. Anything I expect to be overridden has to be listed as a requirement in the protocol body.

What's the difference between some Protocol and any Protocol?

some Protocol means there is 1 specific concrete type that the compiler knows and I do not have to name, so it stays statically dispatched and free of boxing. any Protocol is an existential, a box that can hold any conforming type and can differ from call to call, which costs a level of indirection and dynamic dispatch. My rule is some by default for parameters and return values, and any only when I genuinely need a heterogeneous collection of different conforming types.

What causes a retain cycle in an iOS app, and how do you find one?

A retain cycle happens when 2 objects hold strong references to each other, so neither count ever reaches 0 and neither one is deallocated. The classic case in app code is a closure that captures self strongly while self also owns the closure, for example a view model holding a callback that calls back into itself. I break it with a capture list, usually [weak self], and I find them with the memory graph debugger in Xcode or by adding a deinit print and noticing it never fires.

What exactly does a closure capture list do?

A closure captures the variables it references, and by default it captures them strongly, which is exactly how retain cycles get created. A capture list at the top of the closure lets me say [weak self] so the closure does not keep self alive, or [unowned self] when I am certain the closure cannot outlive the object. A capture list also takes a snapshot of a value at the moment the closure is created rather than reading it later, which matters when the variable changes between creation and execution.

What does @escaping mean on a closure parameter?

A non escaping closure is guaranteed to run before the function returns, so the compiler can keep it on the stack and never worry about it outliving the call. Marking a parameter @escaping says the closure may be stored and called later, like a completion handler saved for a network response, and that is when capture semantics start to matter and retain cycles become possible. Non escaping is the default for function parameters precisely because the safe, cheap case should not need extra syntax.

What does async await actually change compared to completion handlers?

async marks a function that can suspend, and await marks the exact point where it might suspend and give the thread back to the system instead of blocking it. The big shift from completion handlers is that the code reads top to bottom, errors flow through normal try and catch, and I cannot forget to call the callback on some branch. The thing to internalize is that a suspension point is not a thread switch: the function can resume on a different thread, and any state I read before the await may have changed underneath me.

What is structured concurrency, and when do you use a task group?

Structured concurrency means every child task has a parent scope that cannot exit until its children finish, so cancellation and errors propagate down the tree automatically and nothing leaks. async let is the simple form for a fixed number of parallel calls, and withTaskGroup is what I use when the number is dynamic, like fetching 1 image per row. The payoff is that when the parent is cancelled every child is cancelled too, which I would have had to wire up by hand in the old callback style.

Staff

Why does SwiftUI return some View everywhere?

An opaque return type, written some View or some Collection, tells the caller that a single concrete type is coming back and which protocol it satisfies, while hiding the actual type from the API surface. That is what makes SwiftUI's deeply nested generic view types tolerable, since I return some View instead of spelling out a type name 200 characters long. The constraint is that every return path has to produce the same underlying type, which is why a function with 2 different view branches needs AnyView or a result builder to reconcile them.

If arrays are value types, isn't passing a big array around expensive?

Swift's arrays, dictionaries, sets, and strings are value types, but they do not actually duplicate their storage on assignment. They share the same buffer until someone writes, and at that point the standard library checks whether the reference is uniquely held and only then makes a real copy. That is copy on write, and it means passing a big array around is cheap, but if I hold a second reference and then mutate, I pay the full copy right there, which is where surprise allocations in a hot loop come from.

What is an actor, and what is the gotcha with actor isolation?

An actor is a reference type that protects its own mutable state by letting only 1 task inside at a time, so I get a data race free type without hand writing a lock. Everything inside is actor isolated, which means calls from outside have to await and can suspend while they wait their turn. The gotcha is reentrancy: the actor is free to run another task while mine is suspended at an await, so I re-check any state I read before the suspension instead of assuming it still holds.

What does @MainActor do, and how does SwiftUI state compare to UIKit?

@MainActor is a global actor that pins work to the main thread, and I put it on view models and anything touching UI so the compiler enforces the rule instead of me remembering DispatchQueue.main.async. In SwiftUI it pairs with the state model: @State for a view's own value, @Binding for a 2 way handle to a parent's value, and @Observable for a reference type whose reads the view tracks automatically. Compared to UIKit, where I imperatively set properties and drive the update myself, SwiftUI is declarative, so my job is to describe the view for a given state and let the framework diff it.

Principal

What is Sendable, and what does adopting strict concurrency checking cost a real codebase?

Sendable is the compiler's way of marking a type as safe to hand across concurrency boundaries, and with strict checking on, passing a non Sendable value into another isolation domain is a compile error rather than a race somebody finds in production 6 months later. A value type made of Sendable parts gets it for free, a final class with only immutable let properties can conform, and a class with mutable state needs its own lock plus @unchecked Sendable where I take responsibility explicitly. Adopting this on a mature app is a migration project, not a flag flip, so I stage it module by module and treat every warning as a real question about who owns that state.

You have a codebase full of GCD. How does the old model map onto the new one, and where does the mapping break?

Grand Central Dispatch gave me queues, and I thought in terms of dispatching blocks: a serial queue was my mutual exclusion, a concurrent queue with a barrier was my reader writer lock, and DispatchQueue.main.async was how I got back to the UI. The new model maps over that fairly cleanly, since a serial queue becomes an actor, the main queue becomes @MainActor, and a dispatch group becomes a task group with real cancellation. What actually changes is the thread model, because the cooperative pool expects tasks to suspend rather than block, so a semaphore wait inside an async function can deadlock the pool in a way the old queues would have survived.

Fast recall

optional = a value or nil | guard let = unwrap or leave scope | struct = value type, copied on assign | class = reference type, shared instance | copy on write = copies only when mutated | ARC = compile time reference counting | weak = no retain, zeroed to nil | unowned = no retain, never zeroed | @escaping = closure outlives the call | some = 1 hidden concrete type | any = boxed existential value | actor = serialized mutable state | Sendable = safe across isolation domains | @MainActor = pinned to the main thread

BH·Swift·github.com/bunlongheng/study