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
A recorded source review is available for 2 of 12 answers. Each reviewed answer lists its date and scope.
01What are the real differences between a struct and a class in Swift?
junior
Short answer: A struct is a value type; a class is a reference type with identity. An ordinary copyable struct has copy semantics, while assigning a class reference shares the instance. Inheritance and === belong to classes. Since Swift 5.9, noncopyable ~Copyable structs can have deinit, but do not allow ordinary copying.
In depth:
struct |
class |
|
|---|---|---|
| Semantics | a value; normally Copyable |
a shared reference |
| Inheritance | no; protocols are supported | yes |
Identity === |
no | yes |
deinit |
for noncopyable structs | yes |
| Mutation | changing stored fields requires var and mutating methods |
var properties can change through a let reference |
| ARC | may apply to reference fields | manages instance lifetime |
| Initializer | memberwise when synthesis conditions hold | automatic default/inherited initializers only under specific conditions |
- Structs suit independent values. Copies may be optimized, for example with copy-on-write; reference fields can still share mutable state.
- Classes suit shared identity or inheritance. Neither
structnorclassautomatically guarantees thread safety. - A noncopyable resource can use
struct Resource: ~Copyable { deinit {} }: ownership transfers without copying.
⚠️ Common mistake: “structs always live on the stack.” Value semantics do not determine memory placement: a struct can live inside a heap object or be boxed in an existential.
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: In a function result, some P hides one concrete type chosen by the implementation; <T: P> accepts a type chosen by the caller. any P stores a value of a conforming type through an existential. These are type-model differences, not unconditional promises about static dispatch or allocations.
In depth:
| Form | Who chooses the concrete type | Typical use |
|---|---|---|
some P result |
implementation; fixed for a particular generic substitution | hide a concrete result while preserving its type identity |
some P parameter |
caller; shorthand for a generic parameter | a simple generic argument |
<T: P> |
caller | relate argument/result types and express constraints |
any P |
the concrete value can change at runtime | store different implementations behind one interface |
- Generics — Swift supports unspecialized code using metadata and witness tables. When the body and concrete type are visible, the optimizer may specialize and inline calls; a code copy at every call site is not guaranteed.
- Opaque result —
some Ppreserves a concrete type identity even though the caller cannot name it. This enables optimization, but calls across a module boundary need not become direct. - Existential — stores the value plus type/conformance information. Small values often fit inline; larger values may require a heap box. Layout depends on protocol constraints and ABI. Optimization can sometimes remove indirection.
- Associated types —
any Perases some information, but modern constrained existentials can retain explicit constraints. Do not reduce the distinction to “associated types are always lost.”
⚠️ Common mistake: promising that some/generics always use static dispatch with no allocations, while any always allocates. Choose by API needs and type relationships; measure hot code in a release build.
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
Source links provide further reading. Where a recorded technical review is available, the answer shows its date and scope. Tool selections use direct provider links and contain no affiliate placements. Sources, features, and hiring formats can change.
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.