Method dispatch mechanisms in Swift: static and dynamic dispatch
Method dispatch refers to the process of determining which method implementation to execute when a method is called. In Swift, this can be either dynamic or static, each with distinct implications for performance and flexibility.
Dynamic dispatch is resolved at runtime based on the actual type of the object, enabling features like polymorphism but introducing runtime overhead due to method lookup. At a low level, this is implemented using virtual tables (V-Tables) for classes, which store pointers to method implementations. When a method is called through a base class reference, the V-Table of the actual instance type is used to determine the correct implementation at runtime. For protocols, Swift uses witness tables, which map protocol requirements to the implementations provided by conforming types. When a method is called through a protocol-typed value, the witness table for the underlying type is used to locate and invoke the appropriate implementation.
Static dispatch, on the other hand, is resolved at compile time based on the declared type of the variable. This allows the compiler to determine exactly which method to call before the program runs, avoiding the overhead of runtime lookup. At a low level, static dispatch, used by value types (structs and enums) and in non-overridable contexts like final classes, involves direct addressing: the compiler embeds the method’s memory address directly into the compiled code. Since there are no inheritance hierarchies in value types and no overriding in final classes, the call target is guaranteed to be fixed. This enables further optimizations such as inlining, where the method call is replaced with its body for improved performance.
# Dynamic dispatch: cases and examples
In Swift, only specific types of method calls use dynamic dispatch, such as those involving overridable class methods, protocol requirements, and Objective-C or virtual C++ methods. These scenarios leverage runtime resolution to provide the flexibility needed for dynamic behavior.
# Overridable class methods
Methods in classes not marked as final can be overridden by subclasses, enabling polymorphic behavior where different types respond uniquely to the same method call. When such a method is called through a base class reference, dynamic dispatch resolves the call at runtime based on the actual object type, using the virtual table of the instance to locate the correct implementation.
class Vehicle {
func startEngine() {
print("Vehicle engine initiated")
}
}
class Car: Vehicle {
override func startEngine() {
print("Car engine initiated")
}
}
class Truck: Vehicle {
override func startEngine() {
print("Truck engine initiated")
}
}
let vehicles: [Vehicle] = [Car(), Truck()]
for vehicle in vehicles {
// Dynamic dispatch via V-Table at runtime
vehicle.startEngine()
}
In this example, the vehicles array contains a Car
and a Truck
, both referenced as Vehicle
. Iterating over the array and calling startEngine()
on each triggers dynamic dispatch: the V-Table for each instance is consulted at runtime, ensuring Car
’s startEngine()
prints "Car engine initiated" and Truck
’s prints "Truck engine initiated," despite the reference type being Vehicle
.
# Protocol requirements
Methods declared in a protocol’s main body are requirements that must be implemented by any conforming type, enabling polymorphic behavior across different implementations. When called through a value of the protocol type, these methods use dynamic dispatch, relying on a witness table to map each requirement to the correct implementation. This runtime resolution is necessary because the concrete type is not known until runtime when the protocol type is used as an abstraction.
protocol Drivable {
func drive()
}
struct Truck: Drivable {
func drive() {
print("Truck drive initiated")
}
}
struct Car: Drivable {
func drive() {
print("Car drive initiated")
}
}
let drivables: [Drivable] = [Truck(), Car()]
for drivable in drivables {
// Dynamic dispatch via witness table
drivable.drive()
}
In this scenario, each call to drivable.drive()
uses dynamic dispatch to invoke the correct implementation based on the underlying type, either Truck
or Car
. At runtime, Swift consults the witness table for each instance to resolve the method, ensuring the appropriate implementation is executed for the actual type.
# Objective-C or virtual C++ methods
When interacting with methods from Objective-C or C++ that inherently use dynamic dispatch, such as Objective-C message passing or virtual C++ methods, Swift adopts dynamic dispatch to handle these calls appropriately, ensuring compatibility with these languages’ runtime systems.
@objc protocol LegacyVehicleProtocol {
func logMaintenance()
}
class LegacyVehicleSystem: NSObject, LegacyVehicleProtocol {
@objc func logMaintenance() {
print("Logging maintenance for vehicle in legacy system")
}
}
let legacySystem: LegacyVehicleProtocol = LegacyVehicleSystem()
// Dynamic dispatch via Objective-C message passing
legacySystem.logMaintenance()
In this case, legacySystem.logMaintenance()
is dispatched dynamically using Objective-C’s message passing system, which resolves the method at runtime.
# Static dispatch: cases and examples
Static method dispatch in Swift is used for all cases not listed in the dynamic dispatch section, for example with value types, final classes, private methods, and non-requirement methods in protocol extensions. These scenarios leverage compile-time resolution to ensure efficient execution without runtime overhead.
# Methods on value types
Since value types like structs and enums cannot be subclassed, their methods are always statically dispatched based on their known types at compile-time, eliminating the need for runtime lookups due to the absence of inheritance hierarchies.
struct VehicleRecord {
let id: Int
let mileage: Double
func calculateFuelEfficiency() -> Double {
// Simplified calculation
return mileage / 30.0
}
}
let record = VehicleRecord(id: 101, mileage: 300.0)
// Static dispatch - method resolved at compile time
print(record.calculateFuelEfficiency())
Here, record.calculateFuelEfficiency()
employs static dispatch to call VehicleRecord
’s implementation. Since VehicleRecord
is a struct, its type is known at compile-time, allowing the compiler to directly embed the method’s address into the compiled code for efficient execution.
# Final classes and methods
Marking a class or method as final
prevents overriding, ensuring that method calls are statically dispatched since the compiler can guarantee no subclass will provide a different implementation, eliminating the need for runtime resolution.
final class ElectricCar {
let batteryLevel: Double
init(batteryLevel: Double) {
self.batteryLevel = batteryLevel
}
func checkBattery() {
print("Battery level is \(batteryLevel)%")
}
}
let electricCar = ElectricCar(batteryLevel: 85.0)
// Static dispatch - final class prevents overriding
electricCar.checkBattery()
With this setup, electricCar.checkBattery()
uses static dispatch to call ElectricCar
’s implementation. Since ElectricCar
is marked as final
, the compiler knows no subclass can override checkBattery()
, allowing it to resolve the call at compile-time and embed the method’s address directly into the compiled code.
# Private class declarations
Applying the private or fileprivate keywords to a declaration restricts its visibility to the scope or file in which it is defined. This allows the compiler to determine all other potentially overriding declarations. In the absence of such declarations within the file, the compiler can treat method calls as non-overridable and eliminate indirect dispatch.
private class VehicleManager {
private var vehicleCount = 0
func addVehicle() {
vehicleCount += 1
}
}
private let manager = VehicleManager()
// Static dispatch - no visible subclass in file
manager.addVehicle()
Since VehicleManager
is a private
class and no subclass is declared in the same file, the compiler can guarantee that addVehicle()
is not overridden. As a result, the call to addVehicle()
can be devirtualized and replaced with a direct call, avoiding the overhead of dynamic dispatch.
# Protocol extension methods (non-requirements)
Methods defined only in protocol extensions and not declared as requirements are always resolved using static dispatch. Even if a conforming type provides its own implementation, calling these methods through a value of the protocol type will consistently use the version from the extension, as they do not participate in dynamic dispatch through the witness table.
protocol Vehicle {
func startEngine()
}
extension Vehicle {
func stopEngine() {
print("Vehicle engine stopped")
}
}
struct Sedan: Vehicle {
func startEngine() {
print("Sedan engine started")
}
func stopEngine() {
print("Sedan engine stopped")
}
}
struct Van: Vehicle {
func startEngine() {
print("Van engine started")
}
}
let vehicles: [Vehicle] = [Sedan(), Van()]
for vehicle in vehicles {
// Dynamic dispatch via witness table
vehicle.startEngine()
// Static dispatch to protocol extension
vehicle.stopEngine()
}
Here, the vehicles
array contains a Sedan
and a Van
, both conforming to Vehicle
. Iterating over the array, each vehicle.startEngine()
call uses dynamic dispatch to invoke the type’s specific implementation, while each vehicle.stopEngine()
call uses static dispatch to the extension’s implementation, printing "Vehicle engine stopped" in both cases, even though Sedan
defines its own version.
# Performance considerations
The performance differences between static and dynamic dispatch aren't always obvious, but understanding how these mechanisms work helps us make targeted optimizations when needed. To encourage static dispatch and reduce runtime overhead, we can mark classes as final
when inheritance isn’t required. This allows the compiler to safely resolve method calls at compile time without relying on V-Table lookups. Similarly, declaring a class as private
or fileprivate
limits its visibility to the current file, giving the compiler full knowledge of any subclassing or overriding that may occur. If no subclass is present in the file, the compiler can treat method calls as non-overridable and emit direct calls, avoiding dynamic dispatch.
Enabling Whole Module Optimization (WMO) may improve performance further. By analyzing the entire module as a unit, the compiler can replace dynamic dispatch with static calls for internal declarations that aren't overridden elsewhere, eliminating unnecessary indirection.
When working with protocols, we can reduce dynamic dispatch by limiting method requirements to those essential for conformance-based polymorphism. Methods declared in the protocol body become requirements and are dispatched via witness tables at runtime, enabling dynamic behavior across conforming types. In contrast, methods defined only in protocol extensions are not included in the witness table and are resolved through static dispatch at compile-time, resulting in more efficient calls.
In performance-sensitive code, it also helps to avoid unnecessary Objective-C interoperability, as its message-passing system is slower than Swift’s native dispatch model.
Finally, profiling with Instruments can reveal dispatch-related overhead, helping us focus our optimization efforts where they have the most impact.
If you're an experienced Swift developer looking to level up your skills, take a look at my book Swift Gems. It offers 100+ advanced Swift tips, from optimizing collections and leveraging generics, to async patterns, powerful debugging techniques, and more - each designed to make your code clearer, faster, and easier to maintain.