Beyond copy vs share, a struct has no inheritance, deinit or identity, and a class has no memberwise initializer, mutating or automatic Sendable.
struct: no inheritance, a free memberwise initializer, methods that change stored properties must be mutating, and it is implicitly Sendable when it is not public and all its properties are Sendable.
class: inheritance, deinit, identity checks with ===, weak references, Objective-C interop. Always allocated on the heap.
enum and actor: an enum is a value type for a closed set of cases; an actor is a reference type that serializes access to its mutable state, the safe choice for shared state under Swift 6.
iOSJunior
Swift OOP & Protocols
Classes, structs, enums, protocols, extensions, protocol-oriented programming
Questions closed
0 / 24
- 0Review
- 11Asked often
Questions
Encapsulation, inheritance, polymorphism and abstraction; Swift supports all four, but leans on protocols and value types where classic OOP languages lean on class hierarchies.
Encapsulation: hide state behind an API with access control: private storage, private(set) for read-only access from outside, a small public surface for a module.
Inheritance: only classes inherit, and only from one superclass. Overrides need the mandatory override keyword, and final closes a class or a member.
Polymorphism: one call, different behaviour: subclass overrides dispatched through the vtable, and protocol conformance, which also works for structs and enums. Generics add compile-time polymorphism.
Abstraction: Swift has no abstract classes; a protocol plays that role, describing what a type can do without saying how, and a protocol extension supplies shared defaults.
The Swift twist interviewers listen for: prefer composing small protocols over deep inheritance, and keep class hierarchies for framework types such as UIView.
Overloading is several functions with the same name and different signatures, chosen by the compiler at the call site; overriding is a subclass replacing an inherited implementation, chosen at runtime by the object's actual class.
Overloading: differs by parameter types, argument labels, number of parameters, or even return type. Works for functions, methods, initializers and operators, in structs as well as classes.
Overriding: classes only, and override is mandatory, so you never override by accident. Call the parent's version with super.
Properties: a subclass may override an inherited property with a computed getter and setter or add willSet/didSet, but the storage stays in the superclass. It can turn a read-only property into read-write, never the reverse, and the type must match exactly unless the property is read-only, where a subtype is allowed.
What cannot be overridden: final and static members, and methods declared in an extension unless they are @objc.
A protocol is a contract of methods, properties, initializers and associated types that classes, structs and enums can all adopt, even types you do not own; unlike an abstract class it holds no stored state.
Property requirements: always written as var with { get } or { get set }. A let satisfies { get }; { get set } needs a var or a computed property with a setter.
Default implementations: a protocol extension supplies method bodies, so conformers implement only what differs.
Inheritance and composition: protocol Greeter: Named refines another protocol; Named & Codable combines several at a use site. A type can conform to any number of protocols.
Associated types: associatedtype Item makes a protocol generic over a type each conformer chooses, as Collection does with Element.
vs an abstract class: no stored properties, no initializer code, no super; shared state has to live in the conforming type.
A raw value is one fixed constant of the same type for every case, known at compile time; an associated value is a payload attached to a case when you create it, and each case can carry different types.
Raw values: declared as enum HTTPStatus: Int. You get rawValue and a failable init?(rawValue:), which returns nil for an unknown value (handy for JSON and UserDefaults).
Associated values: case failed(Error). They make an enum the right model for screen state such as idle / loading / loaded / failed, where each state has its own data.
An enum with associated values cannot also have a raw type.
**Exhaustive switch**: add a case and every switch that misses it stops compiling. Patterns can bind payloads and add where conditions.
**CaseIterable**: synthesizes allCases for enums without payloads, useful for pickers and tests.
**indirect**: lets a case contain the enum itself, for recursive shapes like trees.
An extension adds computed properties, methods, initializers, subscripts, nested types and protocol conformances to any type, even one you do not own; it cannot add stored properties or override existing members.
Stored properties: not allowed: the type's memory layout is fixed by its original declaration. Use a computed property or a wrapper type.
Overrides: an extension adds behaviour, it never replaces it. A subclass also cannot override a non-@objc method declared in an extension.
Initializers: a struct can gain an init in an extension and keep its memberwise initializer; a class can only gain convenience initializers this way.
Constrained extensions: extension Array where Element: Numeric adds methods only where the constraint holds.
Retroactive conformance: conforming a type you do not own to a protocol you do not own triggers a Swift 6 warning; writing @retroactive acknowledges that the owner may add the same conformance later.
Typical uses: split a large type into conformance-sized pieces and keep helpers out of the main declaration.
Swift has six levels: open, public, package, internal (the default), fileprivate and private; public and open are both visible to other modules, but only open lets those modules subclass a class or override a member.
package: visible to every module in the same Swift package, hidden from apps that depend on it.
internal: the whole module; what you get when you write nothing.
fileprivate: the current source file.
private: the enclosing declaration plus its extensions in the same file.
**private(set)**: a narrower level for the setter only: public private(set) var balance is readable everywhere and writable only inside the type.
Two rules catch people out. A member is capped by its type: a public method on an internal type is effectively internal. And synthesized initializers are internal, so a public struct in a framework needs an explicit public init before other modules can create it.
A lazy var runs its initializer on first read and caches the result, while willSet/didSet run code around every assignment; lazy computes once, unless two threads hit the first read at the same time.
lazy: must be a var, can use self in its initializer, and suits expensive objects you may never need (formatters, heavy views). It is not thread-safe, and on a struct the first read is a mutation, so it fails on a let instance.
willSet / didSet: willSet sees newValue, didSet sees oldValue. They fire on every assignment, even of an equal value, so compare with oldValue before expensive work. They do not fire for assignments in the type's own initializer.
Computed property: no storage at all; it is recalculated on every read. Use it when the value can always be derived from other state.
mutating marks a struct or enum method that changes self: self is passed inout, so the change is written back into the caller's variable. A class method changes an object through a reference, and the reference itself never changes.
You cannot call a mutating method on a let constant or on a temporary value such as a function's result; there is no storage to write back into.
Reassigning self outright, as an enum's advance() does, also needs mutating.
A protocol method meant for structs must be declared mutating func; class conformers simply drop the keyword.
Common trap: for item in items { item.bump() } does not compile, because the loop variable is an immutable copy. Mutate through the index: items[i].bump().
Equatable requires ==, and Hashable adds hash(into:) with one rule: equal values must produce equal hashes. Two different values sharing a hash is a normal collision; Set and Dictionary fall back to == to tell them apart, just a little slower.
Synthesis: a struct or enum gets both for free when all its stored properties or payloads conform. Classes get nothing; write both by hand.
The real bug: the reverse case: equal values with different hashes. Hash a field that == ignores, or mutate a hashed property of a class instance while it sits in a Set or is a dictionary key, and lookups silently miss.
Comparable: adds < for sorting; the other comparison operators come for free.
Identifiable: not about equality: it gives SwiftUI and diffable data sources a stable id, and two values with the same id may still differ.
For classes, == is the equality you define, while === asks whether two references point to the same instance.
weak works only with class instances, and nothing stops a struct from conforming to this protocol, so the compiler reports 'weak' must not be applied to non-class-bound 'any DownloaderDelegate'. Make the protocol class-bound: protocol DownloaderDelegate: AnyObject.
**Why weak at all: the delegate usually owns the object that calls it (a screen owns its downloader), so a strong reference back would form a retain cycle and neither object would be freed.
Why optional**: a weak reference becomes nil when the delegate is deallocated, so it has to be an optional var.
Under strict concurrency: if the callbacks touch UI, mark the protocol @MainActor, so a call from a background thread becomes a compile error.
AnyObject is the current spelling; the old protocol X: class is deprecated.
deinit is a class's cleanup method, called automatically just before ARC frees the instance, when the last strong reference goes away. It runs synchronously on whichever thread released that last reference, not necessarily the main thread.
Classes and actors have it, and so do noncopyable (~Copyable) structs and enums; an ordinary struct has nothing to clean up.
Use it to cancel tasks, invalidate timers, close files and remove observers. Never call it yourself, and do not let self escape from it.
Never called? That almost always means a retain cycle: a closure, a Task or a strong delegate still holds the object.
Swift 6: a plain deinit of a @MainActor class is nonisolated and cannot touch main-actor state. Since Swift 6.2 you can write isolated deinit, which runs the cleanup on the class's actor.
Both declare members on the type rather than on an instance, but a class member can be overridden by a subclass and a static one cannot: in a class, static means class final.
static: works in structs, enums, classes and protocols. Stored type properties are always static, initialized lazily and once.
class: only in classes, only for methods and computed properties; a subclass replaces it with override class.
Protocols: type-level requirements are written with static; a class can satisfy one with class to keep it overridable.
final: on a class it forbids subclassing, on a member it forbids overriding. It also lets the compiler replace vtable dispatch with a direct call and inline it. With whole-module optimization the compiler infers final for internal classes nobody subclasses, but writing it documents intent.
Swift 6: a mutable static var is global shared state, so it is a compile error unless it is isolated to a global actor such as @MainActor, moved into an actor, or marked nonisolated(unsafe) with your own synchronization.
A designated initializer gives every stored property of its class a value and then calls a designated initializer of the superclass; a convenience initializer must call another initializer of the same class. Together they guarantee nothing reads self before the whole chain is initialized.
The two phases
Phase 1, upward: each class sets its own stored properties, then calls super.init. Until the root class is reached you cannot call methods or read self; that is the error "'self' used before all stored properties are initialized".
Phase 2, downward: once the root is done, each initializer may customize: call methods, change inherited properties.
Inheritance rules
A subclass inherits its superclass's designated initializers only if it defines none of its own, and the convenience ones once it implements all the designated ones.
required init forces every subclass to provide it, which is the reason UIView subclasses keep writing init?(coder:).
Other forms
**init?**: failable, returns nil for invalid input.
**init() throws**: fails with an error that says why, e.g. throw ValidationError.empty.
Memberwise init: structs get one, classes do not, because a class also has to initialize its superclass. A private stored property makes the memberwise init private, and writing your own init in the struct body removes it unless that init lives in an extension.
Protocol-oriented programming builds behaviour from small protocols with default implementations in extensions instead of a tree of base classes. A type picks exactly the capabilities it needs, and structs and enums can take part, not only classes.
Composition over inheritance: a type conforms to many protocols but inherits from at most one class, so you avoid a BaseViewController that every feature bolts methods onto.
No fragile base class: changing a superclass can break every subclass; a protocol only promises an interface.
Value types: POP works with structs, which keeps data models copy-safe and Sendable.
Testability: a protocol is a natural seam for a fake in tests.
Constrained defaults: extension Loggable where Self: Identifiable gives a default only to types that also have an id.
Inheritance still fits framework hierarchies such as UIView and UIViewController, and template methods that call overridable hooks. The cost of POP is indirection: too many tiny protocols make code hard to navigate, and calls through any are dynamically dispatched.
It prints beep and then default shout. greet() is declared in the protocol, so it is a requirement dispatched through the witness table and the type's version wins; shout() exists only in the extension, so the compiler picks it statically from the declared type any Greeter.
Through a variable of type Robot, both calls would print the robot's versions: the same object gives two answers depending on the static type.
Generic code (func f<T: Greeter>(_ x: T)) behaves like the existential here: requirements are dynamic, extension-only methods are static.
Fix: declare shout() in the protocol body and keep the extension version as the default. Anything a conformer may customize belongs in the protocol itself.
The same rule explains why a subclass cannot override a non-@objc method from an extension of its superclass: extension members get no vtable slot.
Only an @objc protocol can mark requirements optional, and then only classes can conform; in pure Swift you get the same effect with a default implementation in a protocol extension.
**@objc optional**: the method may be missing, so the caller must check: source.title?() returns an optional. This is how UITableViewDelegate works. The costs: only classes conform, every type in the signature must be representable in Objective-C (no custom structs, generics or enums with payloads), and calls go through objc_msgSend.
Default implementation: the extension supplies a body. Structs and enums can conform, any Swift type works in the signature, and calls stay fast. The caller cannot tell whether the conformer implemented the method, which is usually fine.
Other options: split the protocol into a required part and an optional one (ChartSource and ChartStyling), or expose an optional closure property.
Use @objc optional only when you have to interoperate with Objective-C.
A struct is stored inline, so a Node containing an optional Node would need infinite size; the compiler reports "value type 'Node' cannot have a stored property that recursively contains it". Optional does not help, because it is also an inline value type.
**indirect enum: the payload of a recursive case is boxed on the heap, so the enum has a fixed size. Natural for immutable lists and expression trees.
A class**: a reference is one pointer wide, so var next: ListNode? is fine. Choose it when nodes need identity or shared mutation, and make back-pointers weak to avoid cycles.
A collection: var children: [TreeNode] compiles in a struct, because an array keeps its elements in a separate heap buffer. Often the simplest choice for trees.
Use an enum when you own the whole set of cases and want the compiler to find every place that must change; use a protocol when new variants must be added without touching existing code, for example by another module.
Enum: a closed set. switch is exhaustive, so a new case breaks every switch that forgot it at compile time. Value semantics, synthesized Equatable/Hashable/Codable, no allocation.
Protocol: an open set. A new conformer needs no edits elsewhere, which suits plugins and SDK extension points. You lose exhaustiveness, and calls through any use dynamic dispatch.
The trade-off: with an enum, adding a case is invasive and adding an operation is easy; with a protocol it is the opposite, since a new requirement must be implemented by every conformer.
In practice: screen state, payment methods in your own app and API results are enums, with behaviour as computed properties on the enum. Content blocks that third parties can add are a protocol.
A public enum in a library built with library evolution is non-frozen, so client switches need @unknown default.
Implicit Int raw values are positions: inserting draft at the top shifts every case by one, so a cached 1 that meant shipped in 2.2 now decodes as paid, and old delivered orders show as shipped. Nothing crashes; the data is silently wrong.
Why: without explicit values Swift numbers the cases 0, 1, 2… in declaration order, and Codable stores that number. Reordering, inserting or deleting a case changes the meaning of data already on disk, in a server payload or in a push.
The fix: pin every raw value and give a new case a new number (draft = 3). Old caches decode exactly as before, whatever order the cases are written in.
For new enums: prefer String raw values: the JSON reads "shipped", and the order of cases stops mattering. Switching an existing Int enum to String breaks old caches, so it needs a migration or a custom init(from:).
Unknown values: a removed case or a value from a newer app version makes decode throw; decode through a custom init(from:) with a fallback case if one bad order must not fail the whole list.
Catch it: a test that decodes a JSON fixture saved by the previous release; it fails as soon as a raw value moves.
Every subclass inherits the conformance, so every subclass must also have that initializer, and required is how the compiler enforces it. A final class, a struct or an enum has no subclasses and needs no keyword.
Why it matters: generic code such as func make<T: Buildable>(_ type: T.Type) -> T can be called with any subclass, and the type.init(id:) call inside must succeed for it.
If the initializer also overrides a superclass one, write both: required override init().
Codable: the same rule in practice: Decodable declares init(from:), so a non-final class writes required init(from:). A subclass that adds stored properties must write both init(from:) and encode(to:) itself; forgetting encode(to:) silently drops the new fields.
Ways out: make the class final, use a struct, or inject a factory object instead of calling init through a metatype.
Swift uses the cheapest dispatch a declaration allows: direct calls for value types and final members, a vtable for overridable class methods, a witness table for protocol requirements, and Objective-C messaging only for @objc dynamic.
The four kinds
Static (direct): struct and enum methods, final and private class members, static methods, protocol-extension-only methods. The target is known at compile time, so the optimizer can inline and specialize. Fastest.
vtable: non-final class methods. One indexed load from the class's table, then an indirect call. Cheap, but it blocks inlining.
Witness table: protocol requirements called through any P or unspecialized generic code. A similar cost to a vtable, plus existential boxing and retain/release for any. When the optimizer specializes a generic, the call becomes direct again.
**Message (objc_msgSend)**: @objc dynamic members and Objective-C classes. A selector lookup through a method cache; the slowest, but the only kind that can be changed at runtime, which is what KVO and method swizzling rely on.
What follows from it
A non-@objc method in a class extension has no vtable slot, so subclasses cannot override it; marking it @objc in an NSObject subclass makes it overridable through messaging.
final, private and whole-module optimization turn vtable calls into direct ones. It matters in hot loops, not in a view controller's viewDidLoad.
some P and generics keep the concrete type known and can be specialized; any P always goes through the witness table.
In a profile the cost rarely shows up as the dispatch itself; it shows up as lost inlining and extra retain/release traffic.
BaseViewController conforms using the extension's empty default, so the protocol's witness table points at that default. FeedViewController.refresh() is just a new method, so refreshAll never calls it and pull-to-refresh does nothing on the feed.
Why: a conformance is resolved once, where it is declared. BaseViewController has no refresh() of its own, so the compiler records the extension default as the witness. Subclasses inherit that conformance with its witness table and cannot replace the witness.
Why it compiles silently: the base class has no refresh() in its vtable, so the subclass method needs no override. It runs only when called through a FeedViewController-typed variable, never through any Refreshable or a generic T: Refreshable.
The fix: implement refresh() in the base class. The witness becomes a class method dispatched through the vtable, and subclasses now must write override, which also documents the intent.
What does not work: repeating Refreshable on the subclass fails with "redundant conformance of 'FeedViewController' to protocol 'Refreshable'".
Catch it: test the behaviour through the protocol type, the way the app calls it; in review, a subclass method that matches a protocol requirement but has no override is a red flag.
If neither default is more specific, Swift does not guess: either the conformance fails or the call is ambiguous, and the fix is to implement refresh() on the type itself.
Two shapes of the conflict
**Both protocols require refresh(): each extension offers a witness and neither wins, so the type fails with "type 'Feed' does not conform to protocol". Once the type implements it, that one method serves both protocols.
Members only in extensions** (emit() in Logging and Tracing): conformance compiles, but Both().emit() fails with "ambiguous use of 'emit()'". A cast picks one, (Both() as any Logging).emit(), because extension-only members are chosen by static type.
How the compiler breaks ties when it can
A member on the concrete type beats any extension.
A constrained extension (where Self: Identifiable) beats an unconstrained one.
An extension of a refining protocol beats one of the protocol it refines.
Design lesson
Swift has no super<A>.refresh() as Kotlin does, so your own implementation cannot call one of the defaults. Treat a default as a convenience, not a mixin, and name members by responsibility (reloadFromCache(), pollServer()): a collision usually means two protocols claim the same job.