Tie implementation or design choices to platform behavior, user feedback, lifecycle, accessibility, and the trade-off the team must maintain.
Question set
12 detailed answers
01What are the real differences between a struct and a class in Swift?
junior
Short answer: A struct is a value type: it is copied on assignment and when passed to a function. A class is a reference type: variables share one instance. Inheritance, deinit and identity (===) exist only for classes; mutating a struct requires var and mutating methods.
In depth:
struct |
class |
|
|---|---|---|
| Semantics | copy of the value | shared reference |
| Inheritance | ✗ (protocols only) | ✓ |
deinit / === |
✗ | ✓ |
| Mutation | mutating + var |
always, even via let |
| ARC | no retain/release (unless it holds references) | yes |
| Initializer | memberwise for free | you write it |
- Default to struct — predictable copying, no races on shared state, cheaper for ARC.
- Class — when you need shared identity (a cache, a service), inheritance from the NSObject world, or
deinitto release a resource.
⚠️ Common mistake: "structs always live on the stack." No: a struct stored in a class property, captured by an escaping closure, or boxed in an existential with a large payload lives on the heap. Value type defines copy semantics, not memory placement.
02What does this code print: an array of structs vs an array of class instances?
junior
Short answer: Assigning an array of structs yields an independent copy — mutating the copy doesn't touch the original. Assigning an array of class instances copies only the references — the objects are shared, so the mutation is visible through both variables.
In depth:
struct Point { var x = 0 }
final class Box { var x = 0 }
var a = [Point(), Point()]
var b = a
b[0].x = 99
print(a[0].x) // 0 — b has its own copy of the struct array
let c = [Box(), Box()]
let d = c
d[0].x = 99
print(c[0].x) // 99 — only references were copied, Box is shared
- Array is itself a struct —
b = ais semantically a copy; physically the buffer is shared until the first mutation (CoW), but the observable behavior is a full copy. - Class elements — pointers get copied;
d[0]andc[0]are the same heap object. let ddoesn't protect you —letpins the reference, not the object's state: class fields can still be mutated.
⚠️ Common mistake: answering "99 and 99", forgetting that the array's value semantics extends to its elements: b[0].x = 99 mutates an element inside the copy, not a shared object.
03How does Copy-on-Write work in Swift collections, and does your own struct get it automatically?
middle
Short answer: CoW is an optimization of value semantics: assignment copies only the reference to a shared buffer, and the real copy happens on mutation, and only if the buffer is shared (isKnownUniquelyReferenced). Array/String/Dictionary implement it by hand. Your own struct does not get CoW automatically.
In depth:
- Assignment — O(1): both variables point at one heap buffer, reference count = 2.
- Mutation — a uniqueness check; if the buffer is shared, copy first, then write.
- Your own struct — if it holds a reference to class-backed storage, only the pointer is copied, and a mutation "leaks" into every copy. You write the check yourself:
final class Storage { var values: [Int] = [] }
struct Buffer {
private var storage = Storage()
mutating func set(_ v: Int, at i: Int) {
if !isKnownUniquelyReferenced(&storage) {
storage = storage.copy() // copy only when the buffer is shared
}
storage.values[i] = v
}
}
⚠️ Common mistake: "all value types in Swift copy via CoW." No: a plain struct is copied bitwise immediately; CoW is a manual pattern — out of the box only the standard collections have it.
04What is an Optional under the hood, and when is force unwrap acceptable?
junior
Short answer: An Optional is a plain generic enum with two cases: .some(Wrapped) and .none. T? is syntactic sugar for Optional<T>, and nil is a literal for .none. Force unwrap (!) is tolerable for invariants the programmer guarantees, but in business-logic flow it's a code smell.
In depth:
// Literally in the standard library:
enum Optional<Wrapped> {
case none
case some(Wrapped)
}
guard let user = findUser(id) else { return } // early exit
if let name = user.nickname { greet(name) } // local unwrap
let count = user.nickname?.count ?? 0 // chaining + nil-coalescing
The unwrapping ladder — from preferred to last resort:
guard let— an invariant for the rest of the function, early exit.if let/?./??— local work with the value and defaults.!— only for programmer-guaranteed invariants: anIBOutletafter the view loads, a bundled resource, a fixture in tests. A crash here is a build-time signal, not a user scenario.
⚠️ Common mistake: "never use !." The mature answer is about invariants: URL(string: "https://api.example.com")! with a constant literal is more honest than a silent ?? with a meaningless default that masks a bug.
05Why can't you just put values of a protocol with an associatedtype into an array, and what are the alternatives?
middle
Short answer: A protocol with an associatedtype is not a concrete type but a template of requirements: the compiler doesn't know what T is. Before Swift 5.7 such a protocol couldn't be used as a type at all; now you can write any P — an existential box with dynamic dispatch, but the associated type comes out erased. The alternatives are generics or type erasure.
In depth:
protocol Repository {
associatedtype Entity
func all() -> [Entity]
}
// let repos: [Repository] = [] // ✗ before 5.7: 'Repository' can only be used
// // as a generic constraint
let repos: [any Repository] = [] // ✓ boxed, but Entity is erased outside
func sync<R: Repository>(_ repo: R) -> [R.Entity] {
repo.all() // ✓ generic: Entity is known to the compiler
}
any P— a heterogeneous collection becomes possible, but the result ofall()loses its concreteEntityand goes through witness-table indirection.- Generic
<R: Repository>— full access toR.Entity, static dispatch; but one call means one concrete type. - Type erasure — wrappers like
AnyPublisher,AnySequence: they hide the concrete type while keeping the associated one (AnyPublisher<Int, Never>).
⚠️ Common mistake: fixing the compiler error by slapping on any without thinking. If the collection is homogeneous, a generic is both faster and type-preserving.
06some P, any P, and a generic <T: P>: what does the compiler do differently?
senior
Short answer: some P is an opaque type: behind it stands exactly one concrete type known to the compiler ("reverse generics") — static dispatch, no boxing. any P is an existential box: the value is stored with witness-table indirection, and a large payload moves to the heap. <T: P> is a generic, monomorphized per call site.
In depth:
some P |
any P |
<T: P> |
|
|---|---|---|---|
| Concrete type | one, hidden from the caller | any, can change at runtime | one per instantiation |
| Dispatch | static | witness table | static |
| Memory | no box | 3-word inline buffer, bigger — heap | no box |
| When | returning "some" type: some View |
heterogeneous collections, plugin-like fields | hot code, reusable algorithms |
some— the implementation picks the type, not the caller; the type is stable across calls.any— honest runtime polymorphism:[any Shape]with circles and squares mixed together.- Generic — the caller picks the type; the compiler clones and optimizes the code for each
T.
⚠️ Common mistake: writing any everywhere "for convenience." That's a silent cost: boxing, indirect calls, loss of associated types. Rule of thumb: start with a generic or some; reach for any when you genuinely need heterogeneity.
07Why does @escaping exist, and what does it change for a closure?
junior
Short answer: @escaping marks a closure that outlives the function call — it gets stored in a property or handed to asynchronous work. This changes two things: captures move to the heap (the closure lives longer than the stack frame), and the compiler requires explicit self, making capture semantics visible.
In depth:
final class Loader {
var completion: (() -> Void)?
func load(_ done: @escaping () -> Void) {
completion = done // stored → escaping is mandatory
DispatchQueue.global().async { done() } // goes async → escaping too
}
func forEachItem(_ body: () -> Void) {
body() // non-escaping: dies before return
}
}
- Non-escaping (the default) — the closure is guaranteed to run before the function returns: captures can stay on the stack,
selfis implicit. - Escaping — lifetime is unknown: the capture context is heap-allocated, and the explicit
self.is a signal saying "a retain cycle is possible here, consider[weak self]". - Where you meet it — network completion handlers, stored callbacks,
DispatchQueue.async.
⚠️ Common mistake: saying @escaping "makes the closure asynchronous." No: it's only about lifetime. An escaping closure may run synchronously — or never at all.
08How does an enum with associated values help model a screen's state?
junior
Short answer: Each state is a case and its data are associated values: .loaded([Item]) physically cannot exist without the array, .error(Error) — without an error. Impossible states become unrepresentable, and switch forces you to handle every case.
In depth:
enum ScreenState {
case loading
case loaded([Item])
case error(Error)
}
switch state {
case .loading: showSpinner()
case .loaded(let items): render(items)
case .error(let e): showRetry(e)
} // add a case — the compiler flags every site
- Versus bool flags —
isLoading+items: [Item]?+error: Error?gives 8 combinations of which 3 are valid: "loading and error at the same time" has to be banned by discipline. The enum bans them with types. - Data is bound to the case — you can't reach
itemsuntil aswitchproves the state is.loaded. indirect— for recursive enums (a tree, JSON):indirect case node(Tree, Tree)— Swift boxes the recursion behind a pointer.
⚠️ Common mistake: putting default: in a switch over your own enum. It turns off the main benefit — exhaustiveness checking: a new case silently falls into default and the compiler stays quiet.
09What kinds of method dispatch does Swift have, and what does the classic protocol-extension example print?
senior
Short answer: Three mechanisms: direct (static) — the address is known at compile time; table-based — a vtable for classes and a witness table for protocols; message dispatch — objc_msgSend for @objc dynamic. The famous trap: a method from a protocol extension that is NOT declared as a protocol requirement dispatches statically — by the variable's type.
In depth:
- Direct — struct methods,
finalmethods, methods in extensions. Fastest, can be inlined. - Vtable / witness table — regular class methods; protocol requirements when called through
any P. - Message —
@objc dynamic:objc_msgSend, needed for KVO and swizzling.
protocol Greeter { func hello() }
extension Greeter {
func hello() { print("protocol hello") }
func bye() { print("protocol bye") } // NOT a protocol requirement
}
struct Person: Greeter {
func hello() { print("Person hello") }
func bye() { print("Person bye") }
}
let g: any Greeter = Person()
g.hello() // "Person hello" — requirement → witness table → the type's impl
g.bye() // "protocol bye" — not a requirement → static, by the any Greeter type
let p = Person()
p.bye() // "Person bye" — concrete type is known
⚠️ Common mistake: expecting "Person bye" from g.bye(). The method isn't in the requirements list — the witness table doesn't know about it, so the compiler hardwires the extension version. Second trap: a class method defined in an extension can't be overridden in a subclass without @objc dynamic — extension methods dispatch directly.
10What's the difference between static and class type members?
middle
Short answer: Both are members of the type, not of an instance. static is available everywhere (struct, enum, class) and forbids overriding — it is literally final class. class exists only in classes and allows override in subclasses. Stored class properties don't exist — computed only.
In depth:
class Animal {
static func kind() -> String { "animal" } // = final class func
class func sound() -> String { "..." } // can be overridden
// class var name = "x" // ✗ class stored properties not supported
class var legs: Int { 4 } // ✓ computed — allowed
static let planet = "Earth" // ✓ stored — static only
}
class Dog: Animal {
override class func sound() -> String { "woof" }
// override static func kind() ... // ✗ cannot override static method
}
Dog.sound() // "woof" — dispatched on the metatype via the vtable
static— static dispatch, works in every type; stored type properties come only this way (and they initialize lazily and thread-safely).class— participates in dynamic dispatch on the metatype:type(of: obj).sound()respects overrides.- Choosing — default to
static; useclasswhen type-level polymorphism is genuinely needed (factories,reuseIdentifier).
⚠️ Common mistake: "static is for structs, class is for classes." No: static lives happily in classes and means final class there; the difference is overridability, not where you can use it.
11When is a lazy property initialized, and is it thread-safe?
junior
Short answer: On first access: it computes once, gets stored, and every later read returns the stored value. And no, it is not atomic: two threads reading an uninitialized lazy var simultaneously can run the initializer twice. Unlike global let and static let, which have dispatch_once semantics.
In depth:
final class ViewModel {
lazy var formatter: DateFormatter = {
let f = DateFormatter() // runs on first access
f.dateFormat = "dd.MM.yyyy"
return f
}()
}
// static let shared = ViewModel() // this one IS lazy AND thread-safe
- Why — defer expensive initialization (formatters, values computed from other properties);
selfis already available inside the closure. - Always
var— the value is written after init, solazy letdoesn't exist. - Threads — the compiler adds no lock: a race between two threads can double-initialize. Need laziness plus thread safety — use
static letor explicit synchronization. - In a struct — the first read mutates storage: you need a
varinstance, and reading it on aletstruct is a compile error.
⚠️ Common mistake: assuming lazy var is thread-safe "like a singleton." The dispatch_once semantics belongs to globals and static constants — not to instance lazy properties.
12Property wrappers: what does the compiler generate, and how does wrappedValue differ from projectedValue?
middle
Short answer: For @Wrapper var x the compiler synthesizes hidden storage private var _x: Wrapper and rewrites accesses to x as _x.wrappedValue. projectedValue is the wrapper's second, optional value, reachable via $x: in SwiftUI, $text on a @State is a Binding.
In depth:
@propertyWrapper
struct Clamped {
private var value: Int
let range: ClosedRange<Int>
var wrappedValue: Int {
get { value }
set { value = min(max(newValue, range.lowerBound), range.upperBound) }
}
var projectedValue: Bool { value == range.upperBound } // this is what $x is
init(wrappedValue: Int, _ range: ClosedRange<Int>) {
self.range = range
self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
}
}
struct Player {
@Clamped(0...100) var health = 100
// the compiler generated: private var _health: Clamped
}
var p = Player()
p.health = 250
print(p.health, p.$health) // 100 true
- Why — reusable access logic: validation,
UserDefaults, thread safety — without copy-pasting it into every property. - Well-known wrappers —
@Published($ is a Publisher),@State/@Binding($ is a Binding),@AppStorage.
⚠️ Common mistake: confusing $x with _x. _x is the wrapper storage itself (visible inside the type), $x is its projectedValue; if projectedValue isn't declared, the $x syntax simply doesn't exist.
Source notes
References and review policy
RecallDeck’s interview answers are editorial material, reviewed against maintained official documentation where a primary reference is available. Tool selections use direct provider links and contain no affiliate placements. Features can change after the review date.
From reading to recall
Practice the full interview loop.
RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.