2.
Protocols, Dispatch, Existential Types
Written by Aaqib Hussain
A protocol is one of the most effective ways to achieve polymorphism in Swift. Protocols work flawlessly with both classes and structs. Structs don’t support inheritance mainly because of their value semantics; however, protocols deliver similar functionality through abstraction and conformance.
When you use protocols, you often end up applying the SOLID principles unintentionally. And that’s a good thing. Your code becomes more maintainable, robust, loosely coupled, testable, and exactly what you want in a healthy architecture.
However, protocols aren’t all sunshine and rainbows. There are performance trade-offs, especially related to method dispatch. Not all dispatch types are costly; some are lightning-fast.
And what about Generics and Existentials? What’s their purpose? How do they differ? When should you choose one over the other? You’ll explore all of that in this chapter.
Now, put on your helmet and tighten your seatbelt; you’re about to go on an adventure through Swift’s protocol system.
Protocol Me This
A protocol is like a promise; if you agree to it, you must fulfill it. In Swift, you call this conformance. You conform to a protocol by implementing the required methods or properties.
Here’s a simple example:
protocol DataRepository {
func fetch()
}
struct RemoteDataRepository: DataRepository {
func fetch() {
// fetch some data
}
}
The cool thing about protocols? You can provide default implementations.
protocol Decoder {
func decode()
}
extension Decoder {
func decode() {
print("Some Default Decoding")
}
}
struct DefaultDecoder: Decoder {}
Here, DefaultDecoder conforms to the Decoder protocol, but it doesn’t need to implement decode() because the protocol extension provides a default.
So if you do:
let defaultDecoder = DefaultDecoder()
defaultDecoder.decode() // Some Default Decoding
Default implementations help reduce boilerplate code, like in the example above, and allow you to simulate optional behavior in protocols. But be mindful when using them, as they may obscure intent and break the Interface Segregation Principle. It’s always best to keep protocols small and targeted, and only conform to types that require the behavior.
Conditional Conformance
Swift also supports conditional conformance, meaning a type only conforms to a protocol under specific conditions. For example:
protocol Summable { // 1
func sum() -> Int
}
extension Array: Summable where Element == Int { // 2
func sum() -> Int {
return self.reduce(0, +)
}
}
let numbers: [Int] = [1, 2, 3, 4, 5] // 3
let total = numbers.sum() // 4
To understand what is happening here:
- A protocol
Summablewith a methodsum(). -
Arrayconforms to theSummableprotocol and implements thesum()method only when theElementof the array is anInt. - Initialize an array of integers, named numbers.
- The
sum()method is available for arrays of integers.
This is a powerful way of extending behavior only where it’s appropriate, keeping your code expressive and type-safe.
Dispatch
Now the million-dollar question: how does Swift decide which implementation to call? That’s what dispatch is all about. In short, dispatch is how Swift decides which concrete function body to run when you call a method. Swift uses three primary dispatch mechanisms:
- Static Dispatch
- Dynamic Dispatch
- V-Table (Virtual Table) Dispatch
- Protocol Witness Table
- Message Dispatch
Each dispatch type comes with its performance trade-offs. Understanding when Swift uses which can help you write code that’s fast, efficient, and predictable.
Static Dispatch
It’s one of the fastest method call mechanisms Swift offers. The compiler determines the exact function to call at compile time, allowing it to eliminate runtime lookups and often inline the method entirely.
It’s like saying:
I’m fast. I’m very fast.
It comes into play when you invoke a method from within:
- A value type, i.e,
structorenum. - A
finalclass or afinalmethod.
Since Swift knows everything at compile time, it can optimize everything inline. It eliminates indirection, resulting in faster machine code.
Inline: A compiler optimization that replaces a method call with the method’s body to reduce call overhead and enable additional optimizations.
Indirection: Occurs when your code passes through an intermediate layer, such as a reference, container, or dispatch table, before reaching the actual implementation.
struct Size {
var width, height: Double
func area() -> Double {
return width * height
}
}
let size = Size(width: 10, height: 10)
size.area() // Static Dispatch
Similarly, with classes:
final class Size {
var width, height: Double
func area() -> Double {
return width * height
}
}
// OR
class Size {
var width, height: Double
final func area() -> Double {
return width * height
}
}
let size = Size(width: 10, height: 10)
size.area() // Static Dispatch
For protocols:
protocol Animal {}
extension Animal {
func sleep() {
print("Sleeping soundly.")
}
}
struct Dog: Animal {}
// OR
class Dog: Animal {}
let animal: Animal = Dog()
animal.sleep() // "Sleeping soundly."
A protocol with a default implementation for a method that isn’t declared in the requirement list uses static dispatch, because conforming types aren’t required to implement it — regardless of whether it’s a class, struct, or actor.
By design, fileprivate, private, and static methods also follow static dispatch. Swift knows at compile time that these methods cannot be overridden, so there’s no need for another dispatch mechanism.
Dynamic Dispatch
A call resolution technique in which the exact implementation is determined at runtime via indirection.
Here’s a simple analogy to give you a better understanding:
Sometimes I’ll start a method call, and I don’t even know where it’s going. I just hope I find it along the way - like an improv conversation. An improversation.
Swift uses dynamic dispatch when the compiler isn’t sure at compile time which method to invoke. Depending on the context, one of the following approaches steps in:
- V-Table dispatch for a class inheritance or when a subclass overrides a method from the superclass.
- Protocol Witness tables for protocols to find the correct implementation to invoke.
- Message dispatch for interoperability with Objective-C.
Virtual Table Dispatch
Virtual dispatch occurs for all members of a class or actor unless specified otherwise.
Each class has a v-table, a list of method pointers. When a method is overridden in a subclass, that subclass updates its v-table entry for that method to point to the new implementation. When a method is called, Swift checks the instance’s runtime type, looks up the correct entry in the v-table, and then calls it.
class Animal {
func sleep() {
print("Sleeping...")
}
}
class Dog: Animal {
override func sleep() {
print("Dog is sleeping.")
}
}
let animal: Animal = Dog()
animal.sleep() // V-Table Dispatch
At compile time, Swift knows that sleep() might be overridden, so it avoids static dispatch. Instead, it generates code that performs a v-table lookup at runtime to determine which method to call.
This adds a bit of indirection but enables dynamic behavior like overriding, which is crucial for polymorphism.
Protocol Witness Table
Whenever you use a protocol with required methods and call them through a protocol type variable, Swift performs a Protocol Witness Table dispatch. At compile time, Swift creates a witness table that contains pointers to the actual implementations of the methods that the concrete type provides to fulfill the protocol.
protocol Animal {
func sleep()
}
class Dog: Animal {
func sleep() {
print("Dog is sleeping.")
}
}
let animal: Animal = Dog()
animal.sleep() // PW-Table Dispatch
When you assign a value to a protocol-typed variable, Swift creates an existential container. This container holds the value, a pointer to the concrete type’s metadata, and a pointer to the protocol witness table.
If you were to do:
let animal: Dog = Dog()
animal.sleep() // V-Table Dispatch
It reverts to using v-table dispatch because you’re no longer using the protocol-typed variable.
It resembles v-table dispatch, but it’s not the same. The main difference is that it includes one extra step. In the case of v-table dispatch, the existential container isn’t present. This results in slightly higher overhead than v-table dispatch.
Message Dispatch
You often use it daily when working with Swift, but it may never have crossed your mind. Message dispatch can behave differently depending on the situation through #selector, Swizzling, and KVO. Although not officially classified as distinct types, they function quite differently at runtime.
Take a look at the example below:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let button = UIButton()
//...
button.addTarget(self, action: #selector(buttonAction), for: .touchUpInside)
}
@objc func buttonAction() {
// some action
}
}
A #selector sends the method’s name as a message to the button object. The Objective-C runtime uses this method name to find the correct implementation in the object’s class and executes it. The @objc keyword ensures that the method is visible to the Objective-C runtime.
Objective-C Runtime:
It’s a framework that provides the core functionality for Objective-C. It’s linked to your Swift app whenever you use things like
@objc,#selector, or KVO. Internally, it handles critical runtime tasks, including message dispatch, method swizzling, and dynamic method resolution. It also plays a key role when building bridge layers between Objective-C and other languages or when you’re working with low-level debugging.
When it comes to method swizzling, the Objective-C runtime alters a class’s method table by swapping the implementation pointer of the original method with that of the new method, effectively redirecting the dispatch.
extension UIViewController {
@objc func track_viewDidLoad() {
print("View Did Load: \(String(describing: type(of: self)))")
self.track_viewDidLoad()
}
static func swizzleViewDidLoad() {
let originalSelector = #selector(viewDidLoad)
let swizzledSelector = #selector(track_viewDidLoad)
guard let originalMethod = class_getInstanceMethod(self, originalSelector),
let swizzledMethod = class_getInstanceMethod(self, swizzledSelector) else {
return
}
method_exchangeImplementations(originalMethod, swizzledMethod)
}
}
In Key-Value-Observing (KVO), the Objective-C runtime generates a hidden subclass of the observed object’s class. At runtime, the object’s class is replaced with this hidden subclass, which overrides the property’s setter to add KVO logic.
class ViewModelObserver {
let viewModel: ViewModel
private var valueObservation: NSKeyValueObservation?
init(viewModel: ViewModel) {
self.viewModel = viewModel
self.valueObservation = viewModel.observe(\.value, options: [.old, .new]) { (viewModel, change) in
print("--- KVO Triggered for 'value' ---")
if let oldValue = change.oldValue, let newValue = change.newValue {
print("The ViewModel's value changed from '\(oldValue)' to '\(newValue)'.")
}
}
}
}
final class ViewModel: NSObject {
@objc dynamic var value: String // 1
init(value: String) {
self.value = value
super.init()
}
}
The property inside the ViewModel is marked with the dynamic keyword. This explicitly instructs the Objective-C runtime to use message dispatch. Otherwise, by default, Swift would use v-table dispatch here because it’s inside a class.
Message dispatch is considerably slower than v-table and static dispatch, but it is the foundation of many dynamic features inherited from Objective-C.
Flowchart for Dispatch
Here’s a simple flowchart to visualize how Swift decides which dispatch mechanism to use based on the context of the method call:
Dispatch Types: Comparison Table
Here is a small table showing the main differences between the dispatch types:
Existential a.k.a Boxed Type Protocol
An existential is when you use any Protocol as a type in Swift. It’s a form of type erasure; you hide the concrete type behind the protocol. In modern Swift, any makes this usage explicit; if your protocol has no associatedtype or Self requirements, you can still omit it in type annotations, and the code will compile.
You use an existential when you don’t know the type at compile time, and at runtime, it could be anything that conforms to the protocol. This gives you flexibility: you can pass around “anything that logs” without caring how it logs.
Swift stores the value in an existential container and calls methods via the protocol witness table, resulting in dynamic dispatch.
If the value fits in the existential container’s inline buffer, Swift stores it there; otherwise, it stores a pointer to a heap allocation. Either way, existential types use dynamic dispatch, which is slower than static dispatch.
associatedtype: A placeholder type within a protocol, where the actual type is determined by the conforming concrete type. This enhances the protocol’s flexibility, allowing it to adapt to various use cases.
Check the example below:
protocol Logger {
func log(_ message: String)
}
struct ConsoleLogger: Logger {
func log(_ message: String) {
print("Writing to console: \(message)")
}
}
struct FileLogger: Logger {
func log(_ message: String) {
print("Writing to file: \(message)")
}
}
Now you pass around the existential as a type, like:
func runLogger(_ logger: any Logger) { // 'any' is optional here
logger.log("Hello from an existential Logger!")
}
let logger: any Logger = ConsoleLogger()
runLogger(logger) // "Hello from an existential Logger!"
It works perfectly, even if you don’t know what concrete type might be coming in the logger.
If your protocols require associatedtype or Self, then you can’t directly use it as an existential type. Look at this code:
protocol Logger {
associatedtype Message
func log(_ message: Message)
}
Now, if you do this:
let logger: Logger = ConsoleLogger() // Use of protocol 'Logger' as a type must be written 'any Logger'
You fix it by adding any:
let logger: any Logger = ConsoleLogger()
The code compiles now, but there’s another catch — runLogger() will throw this error:
func runLogger(_ logger: any Logger) {
logger.log("Hello from an existential Logger!") // Member 'log' cannot be used on value of type 'any Logger'; consider using a generic constraint instead
}
That’s because the compiler doesn’t know the concrete type of the associated type at compile time. After all, it’s erased.
You can think of this error message as the compiler saying, “I have trust issues.” It’s aware that you probably mean well, but since you erased the type, it prevents you from accessing the associated data. It’s not being mean, it’s just being very, very careful.
But if your protocol method doesn’t take the associated type as a parameter, like this:
protocol Logger {
associatedtype Message
func placeHolder() -> Message
}
struct ConsoleLogger: Logger {
// ...
func placeHolder() -> String {
"Writing to console:"
}
}
func printLoggerPlaceHolder(_ logger: any Logger) {
print(logger.placeHolder()) // Compiles, without error
}
This compiles fine, but why? The call compiles because the usage of the return value doesn’t require the compiler to know the concrete type of Message. The print() method takes an argument of type Any, and any value can be converted to Any. The compiler doesn’t need to know if Message is a String or an Int; it just knows it’s some value that it can pass to print().
Pro Tip: Be explicit with types whenever possible. It avoids unnecessary runtime work and can make your code run faster.
When to Use Existential Types?
Here are some of the most common cases where existential types shine:
-
Heterogeneous Collections: When you want to store instances of unrelated objects that conform to the same protocol.
For example, you have an
AppsFlyerTrackerand anAdjustTrackerthat conform to theTrackerprotocol.
protocol Tracker {
func track(_ key: String, parameters: [String: Any])
}
struct AppsFlyerTracker: Tracker {
func track(_ key: String, parameters: [String : Any]) {
// some AppsFlyer tracking code.
}
}
struct AdjustTracker: Tracker {
func track(_ key: String, parameters: [String : Any]) {
// some Adjust tracking code.
}
}
You can store them in an array like:
let trackers: [any Tracker] = [AppsFlyerTracker(), AdjustTracker()]
And then use them interchangeably:
for tracker in trackers {
tracker.track("some_event", parameters: ["some_key": "some_value"])
}
-
SwiftUI’s
any View: A common SwiftUI pattern. Suppose you have one view that displays a list of user data, and another that shows an empty state.
func showRelevantView(_ hasNoUserData: Bool) -> any View {
hasNoUserData ? UserDataView() : EmptyStateView()
}
Trade-offs: Always remember that existentials come with costs: performance overhead from dynamic dispatch and the loss of compile-time type information. Using
anyindiscriminately is like ordering the Surprise Special at a restaurant. Sure, it’s flexible, but you pay extra for it and might end up with something heavy that takes a long time to digest (because of dynamic dispatch overhead).
Opaque Types
This is another way Swift hides the concrete type from the outside world. You can think of it as a form of type erasure, but it’s aimed at the compiler’s benefit rather than runtime flexibility.
The key difference from any is that with some, the compiler still knows the concrete type at compile time, so it can skip the existential container and generate more optimized code. You still get abstraction in your API, but without paying the boxing cost you get with existentials.
Here’s a working example:
protocol Logger {
func log(_ message: String)
}
struct ConsoleLogger: Logger {
func log(_ message: String) {
print("[LOG]: \(message)")
}
}
func makeLogger() -> some Logger {
ConsoleLogger()
}
let logger = makeLogger()
logger.log("Hello from an opaque return type!")
The some keyword in makeLogger() makes the return type opaque.
From the caller’s perspective, they only know it’s “something that conforms to Logger.” They don’t know (or care) that it’s a ConsoleLogger.
Opaque types are like that friend who says, “I know a guy who can fix your car.” When you ask, “Who is it?” they just say, “Don’t worry about it. It’s a mechanic you can trust.” You don’t get the name, but you get the guarantee that the car will be fixed.
some avoids the existential container, but it doesn’t magically make everything statically dispatched. It guarantees that for a given method call, one specific concrete type is always returned because:
-
The compiler knows this single concrete type at the call site, it can perform significant optimizations.
-
If the concrete type is a
struct,enum, or afinal class, the compiler can often devirtualize the call and dispatch it statically. This is a major performance win. -
If the concrete type is a non-final
class, the method call will be dispatched dynamically through the class’s v-table, not the protocol’s witness table. -
The PWT is involved at compile time to ensure the type conforms, but the runtime dispatch can often be more direct (static or v-table) than the PWT-based dispatch required by an existential
any.
Devirtualization: A compiler optimization technique that replaces a slower dynamic method call with a faster, direct method call.
some vs any
Take a look at the following comparison between some and any:
Uncommon Dispatch Scenarios
At this point, you might think you know everything about dispatch, but Swift can surprise you in different situations. Dispatch sometimes behaves the opposite of what you expect. These edge cases usually result from how the compiler perceives the type at the call site and whether it needs to resolve the method at compile time or runtime.
Let’s explore some less common but important cases.
The dynamic Keyword
If you mark a method as dynamic even if you aren’t using it with Objective-C or any Objective-C runtime features, Swift defers its resolution until runtime. Check the following snippet:
class Example: NSObject {
dynamic func greet() {
print("Hello")
}
}
Even if the method could have been resolved with v-table dispatch, it will now be forced to use message dispatch, adding overhead.
Chained Dispatch
In some scenarios, you may encounter a situation where both an existential container and a PWT lookup occur. Example:
func callLog<T: Logger>(_ logger: T) where T: AnyObject { // 1
let existential: any Logger = logger // 2
existential.log("Hello") // 3
}
Here’s the chain:
- Generic dispatch to get into
callLog()(static). - Creating an existential.
- PWT lookup to call
log().
This is rare, but if you accidentally introduce existential inside generic contexts, you lose some of the generics’ performance benefits.
Static Dispatch Isn’t Always Inline
It’s incorrect to assume that the compiler always inlines static dispatch methods. That’s not always true. Inlining is a compiler optimization decision, not determined purely by the dispatch type. Therefore, the compiler might choose not to inline a large method.
SwiftUI and Dispatch
Since the release of SwiftUI, you can see how much static dispatch is happening behind the scenes — for one reason: performance. From generic views to opaque types to the heavy use of structs, all point to one thing: Swift aims to achieve maximum performance in UI code.
Class Methods vs. Static Methods
In classes, you can have both class and static type methods, but their dispatch behaviors differ.
-
A
static funcon a class is implicitlyfinal. It cannot be overridden by a subclass and is always called using static dispatch. -
A
class funccan be overridden by subclasses. It executes using dynamic dispatch through the v-table.
class Vehicle {
static func vehicleType() -> String {
return "Generic Vehicle"
}
class func maxSpeed() -> Int {
return 100
}
}
class Car: Vehicle {
// SUCCESS: Can override class method
override class func maxSpeed() -> Int {
return 250
}
}
// Static Dispatch: The compiler calls Vehicle.vehicleType() directly.
print(Car.vehicleType()) // Prints: "Generic Vehicle"
// Dynamic Dispatch: The compiler does a v-table lookup to find Car's implementation.
print(Car.maxSpeed()) // Prints: "250"
The key takeaway is that static guarantees a single implementation for better performance, while class allows for polymorphic behavior at the type level.
Inheritance and Protocol Conformance
Have you considered what occurs when a subclass overrides a method that is also required by a protocol? Does Swift use the v-table or the PWT? The answer is: both, in a sense.
Take a look at the following code:
protocol Heatable {
func heat()
}
class Appliance: Heatable {
func heat() {
print("The appliance is heating up.")
}
}
class Toaster: Appliance {
override func heat() { // 1
print("The toaster is toasting bread.")
}
}
let heater: any Heatable = Toaster() // 2
heater.heat() // Prints: "The toaster is toasting bread."
Breaking down the code above:
-
Toasteroverrides theheat()method from its superclassAppliance. - You store a
Toasterinstance in an existentialany Heatable.
When heater.heat() is called, here’s what happens:
- The call involves an existential question, so Swift begins a PWT lookup to find the implementation of
heat()forToaster. - The PWT entry for a class method doesn’t point directly to the implementation. Instead, it points to a small adapter function (a thunk) that then performs a v-table lookup.
- The v-table lookup correctly resolves to the most specific implementation, which is the
overridein theToastersubclass.
This ensures class inheritance and method overriding work correctly, even when the object is wrapped inside a protocol existential.
Generics vs Existentials
It’s one of Swift’s most powerful features. It helps you achieve reusability, flexibility, and type safety. With this, you can write code that avoids duplication.
Swift’s Array, Dictionary, and Set are generic collections. You can also create your own custom generic types in Swift.
Swift allows you to create custom types if needed. Check out the following:
struct Queue<Element> {
private var elements: [Element]
init(elements: [Element]) {
self.elements = elements
}
mutating func enqueue(_ element: Element) {
self.elements.append(element)
}
mutating func dequeue() -> Element? {
guard !elements.isEmpty else { return nil }
return elements.removeFirst()
}
}
The code above shows you how to write a generic queue. Now, if you were to do:
var idsQueue = Queue<Int>(elements: []) // A queue of Ids
idsQueue.enqueue(1)
idsQueue.enqueue(2)
var peopleQueue = Queue<String>(elements: []) // A queue of People
peopleQueue.enqueue("Steve")
peopleQueue.enqueue("Jobs")
The code above demonstrates reusability. You can use the same code for Int, String, or any other types that Swift provides.
Generics may seem similar to Existentials, but they are entirely different. Generics are more aware of the type during compile time. You can think of them as: “Tell me exactly which type you’ll use, and I’ll optimize for it.” Meanwhile, with Existentials, it’s runtime-based, more like: “It could be anything, I’ll figure it out later.”
Consider the following example:
protocol Animal {
func sleep()
}
class Dog: Animal {
func sleep() {
print("Dog is sleeping.")
}
}
func makeItSleep<T: Animal>(_ animal: T) {
animal.sleep()
}
When the compiler encounters a call to a generic method like makeItSleep(myDog), it generates a specialized version of that method for the Dog type - almost as if you had written makeItSleep_Dog(_ animal: Dog).
- Within this specialized method, the compiler knows the exact type is Dog
- Since
Dogis a class, thesleep()method call is dispatched through its v-table. - If you had called this function with a struct conforming to
Animal, the specialized method would use static dispatch because the implementation is known at compile time.
Generics behave more like opaque types than like existential types. Both preserve type information at compile time; the key difference is who controls the concrete type.
A generic parameter and an opaque type parameter serve a similar purpose — both allow callers to supply any concrete type that satisfies a constraint.
However, a generic return type is set by the caller’s context and can resolve to any type that satisfies the constraint. On the other hand, an opaque return type can only be one specific concrete implementation, while hiding the type from outside visibility.
Protocol Composition
Sometimes, you may want a type to conform to multiple protocols. Protocol composition lets you express this by using the & operator.
Think of it as:
I don’t care what you are, as long as you can do this and that.
Here is a quick example:
protocol Flyable {
func fly()
}
protocol Swimmable {
func swim()
}
struct Duck: Flyable, Swimmable {
func fly() { print("Flapping wings!") }
func swim() { print("Paddling in the pond!") }
}
func makeItMove(_ creature: Flyable & Swimmable) { // 1
creature.fly() // 2
creature.swim() // 3
}
makeItMove(Duck()) // 4
So, what exactly is going on here?
- The protocol composition of
Flyable&Swimmable. -
fly()is available. - Because of the protocol composition, the method
swim()is also available. - Prints:
- Flapping wings!
- Paddling in the pond!
Protocol composition does not merge methods into a new type. Instead, it acts as a type constraint that enforces conformance to all listed protocols.
Dispatch works as if you were calling methods from each protocol separately.
Additionally, you can specify protocol composition with either any or some to enforce an existential or opaque type.
Tip: When creating a protocol composition with many protocols, it’s better to define a protocol that inherits from all of them to keep your code organized and clean.
Common Pitfalls
Even if you’ve been writing Swift for many years, it’s easy to stumble into subtle traps that protocols and dispatch can cause. Some of these incorrect behaviors and reduced performance may leave you scratching your head, wondering why they behave a certain way, why the compiler won’t let you do something that seems perfectly reasonable.
Below, you’ll go through the common traps programmers often get stuck in.
Default Methods in Protocol Extensions
The default implementation using extensions is a powerful way to leverage Swift’s flexibility. However, extensions behave differently in certain scenarios.
-
If you provide a default implementation for a protocol requirement method, then the conforming type’s implementation executes each time.
-
If the method exists only in the extension and not in the protocol definition, then the compiler uses the extended version, even when a conforming type implements it.
protocol Greeter {
func greet()
}
extension Greeter {
func greet() { print("Hello from default!") }
}
struct Person: Greeter {
func greet() { print("Hello from Person!") }
}
let john = Person()
john.greet() // Hello from Person!
let greeter: Greeter = Person()
greeter.greet() // Hello from Person!
But watch this twist:
protocol Greeter {}
extension Greeter {
func greet() { print("Hello from default!") }
}
struct Person: Greeter {
func greet() { print("Hello from Person!") }
}
let greeter: Greeter = Person()
greeter.greet() // Hello from default!
This happens because greet() isn’t a protocol requirement, so existential calls are statically dispatched to the default method.
If you want the version from the conforming type to be used dynamically, do one of the following:
- Make the method a protocol requirement.
- Use the concrete type directly rather than the protocol existential.
Existentials with associatedtype
Even though Swift now allows you to create existentials with protocols having associatedtype or Self, an existential removes the type information tied to the associated type, which means you cannot directly call methods that depend on it.
Take a look at the following snippet:
protocol Logger {
associatedtype Message
func log(_ message: Message)
}
func testLogger(_ logger: any Logger) {
logger.log("Hello") // Error — Message type is erased
}
To make it work, you can:
Option 1: Use generics so the compiler preserves the type:
func testLogger<T: Logger>(_ logger: T, message: T.Message) {
logger.log(message)
}
Option 2: Type-erase manually with something like AnyLogger.
It’s easy to forget this subtle point, which can lead to perfectly valid-looking code that doesn’t compile.
Guide your Compiler
Don’t let your compiler struggle with simple tasks; guide it where possible. If you’re not using existentials, opt for opaque types instead.
Don’t do this:
func makeLogger() -> any Logger {
ConsoleLogger()
}
Prefer this:
func makeLogger() -> some Logger {
ConsoleLogger()
}
It seems like a small change, but for the compiler, it’s a significant performance advantage. It not only accelerates the program but also enables more compile-time checks.
Most pitfalls come down to one idea:
What does the compiler know at compile time, and what must it resolve at runtime?
If you can recognize when type information is maintained or erased, and when dispatch is static or dynamic, you can avoid silent performance issues and unexpected behaviors.
Once you develop the habit of thinking this way before writing code, you’ll stop just “using Swift” and start bending Swift to your will.
Key Points
-
Protocols facilitate polymorphism for classes and value types, enabling shared behavior without inheritance.
-
Default method implementations in protocol extensions can minimize boilerplate but may also lead to subtle differences in dispatch behavior.
-
Conditional conformance lets a type conform to a protocol only when certain compile-time constraints are met.
-
Static dispatch happens for value types, final classes, and private/fileprivate/static methods, allowing compile-time inlining.
-
Dynamic dispatch is used when the specific implementation is unknown until runtime, and it occurs in various forms.
-
V-table dispatch enables method overriding in class inheritance by referencing the method pointer in a virtual table.
-
An existential container occurs when assigning a concrete value to a protocol-typed variable that stores metadata, the value, and the witness table pointer.
-
The PWT links each protocol requirement to its specific implementation, facilitating method resolution for existentials.
-
V-table dispatch works directly with class inheritance; PWT dispatch introduces existential container indirection, making it slightly slower.
-
Message dispatch via the Objective-C runtime enables features like
#selector, KVO, and method swizzling, and is the slowest form of dispatch. -
The
dynamickeyword enforces Objective-C style message dispatch even when v-table dispatch could be used. -
Opaque types can hide the specific type from the caller while still allowing the compiler to identify it at compile time for optimization.
-
Existentials (
any) conceal the concrete type from both the caller and compiler, requiring PWT-based dispatch at runtime. -
somevs.any: Both use PWT for protocol requirements, butsomeavoid existential containers and can be inlined more often. -
Generics are resolved at compile time, avoiding boxing and enabling specialization; existentials are resolved at runtime.
-
Protocol composition (
A&B) enforces multiple constraints without creating a new type, and it works with bothanyandsome. -
Default methods in extensions that are not required use static dispatch for existential calls, even if a conforming type implements them.
-
Existentials with
associatedtypeorSelflose compile-time type information, preventing direct calls to dependent methods. -
Manual type erasure (e.g.,
AnyLogger) can regain flexibility when protocols withassociatedtypeneed to be stored as existentials. -
Always consider what the compiler knows at compile time versus runtime to avoid hidden dispatch costs.
Challenge
AnyLogger Wrapper: Existential & Generic Versions
Create a wrapper called AnyLogger that can accept any type conforming to the Logger protocol defined earlier in the chapter. Your task is to implement two versions: one using an Existential, and the other using Generics.
Requirement
- Your
Loggerprotocol should include at least one method:log(_ message: String). -
AnyLoggershould work with bothConsoleLoggerandFileLoggerwithout modifying their implementations.
Example Usage
let consoleLogger = ConsoleLogger()
let fileLogger = FileLogger()
// Existential version
let anyExistentialLogger: AnyLogger = AnyLogger(consoleLogger)
anyExistentialLogger.log("Hello Existential!")
// Generic version
let anyGenericLogger = AnyLogger(consoleLogger) // Generic<T: Logger>
anyGenericLogger.log("Hello Generic!")
Let the logger game begin!
Where to Go From Here?
Now that you’ve explored dispatch, existentials, opaque types, and generics, you should have a clear understanding of how they operate and behave in different scenarios.
This chapter not only guided you through practical examples but also aimed to shape how you think about writing code in real-world situations.
The goal is to help you intentionally choose the right approach for each case, so you can write code that is not only correct but also high-performing and optimized.