Protocol Extension Method Dispatching in Swift
Protocol Extension Method Dispatching in Swift
Introduction to Method Dispatch Types
Method dispatch is the mechanism a program uses to determine which implementation of a function to execute when that function is called. Swift employs four types of method dispatch, each with trade-offs between speed and flexibility:
-
Inlining: This is the fastest and least flexible type of dispatch. It involves replacing a function call with the function's code, eliminating the overhead associated with making the call. This optimization is most effective when the compiler has all the information it needs to determine the result of the function call at compile time, such as when working with hardcoded values or simple operations.
-
Static Dispatch: Also known as direct dispatch, static dispatch is the second fastest and second least flexible type of dispatch. It occurs when the function's memory address is known at compile time. This allows the compiler to directly jump to that address without needing to perform a runtime lookup. Static dispatch is typically used for functions within structs, enums, and final classes, where the compiler can guarantee which implementation will be used.
-
Table Dispatch: This type of dispatch is used to implement polymorphism, which is the ability of a single type to have multiple forms. In table dispatch, the function to be called is determined at runtime by looking up its address in a table. Swift implements two flavors of table dispatch: virtual table dispatch for class hierarchies and protocol witness table dispatch for protocols.
-
Virtual Table Dispatch: Virtual tables are used to dispatch methods on class hierarchies. When a class is created, it includes a hidden data structure called a virtual table, which contains pointers to the implementations of all the methods defined in the class and its superclasses. When a method is called on an object, the runtime uses the object's virtual table to find the correct implementation of the method to execute. This allows subclasses to override methods from their superclasses and have their implementations called even when the method is called on a variable typed as the superclass. This is because the specific type of the object is not known until runtime.
-
Protocol Witness Table Dispatch: Protocol witness tables work similarly to virtual tables but are used for protocols. When a type conforms to a protocol, it creates a witness table that contains pointers to the implementations of all the required methods of the protocol. When a method is called on a variable typed as a protocol, the runtime uses the variable's witness table to find the correct implementation of the method to execute.
-
-
Message Dispatch: This is the slowest and most flexible type of dispatch. It uses the Objective-C runtime and allows for dynamic method lookup at runtime. This means that the implementation of a method can be changed at runtime, even after the code has been compiled. Message dispatch is invoked using the @objc and dynamic keywords in Swift. This type of dispatch is useful for interoperability with Objective-C code and for implementing features like Key-Value Observing (KVO).
In this article, we will focus on Protocol Witness Table Dispatch. For further information on method dispatch in Swift, please refer to those great articles:
- The Swift Method Dispatch Deep Dive
- Swift Protocol Extensions Method Dispatch
- Method Dispatch in Swift — RaizException — Raizlabs Developer Blog
- Method Dispatch in Protocol Extensions — Ole Begemann
Protocol Witness Table Dispatch
Protocol witness table dispatch is the mechanism by which Swift implements polymorphism for protocols. When a type conforms to a protocol, it generates a witness table that includes pointers to the implementations of the protocol's required methods. During runtime, when a protocol requirement is invoked on an instance of a conforming type, Swift references the corresponding function pointer in the witness table and directs the execution flow to that memory location.
Points to remember:
- Abstract vs. Concrete Types: Protocol witness table dispatch is employed when interacting with abstract protocol types, such as any MyProtocol. When a concrete type adhering to the protocol is used, the compiler often opts for static dispatch due to the compile-time knowledge of the implementation.
Explanation:
-
The speak() method is a requirement of the Speaker protocol, so it will use dynamic dispatch when called on a variable of type any Speaker, even though there is a default implementation in the extension.
-
When genericSpeaker.speak() is called, the runtime checks the protocol witness table associated with the concrete type (Human) to find the correct implementation of speak(), which results in printing "Hello there!".
-
When humanSpeaker.speak() is called, the compiler knows at compile time that the type is Human, so it can use static dispatch directly to the Human implementation of speak()
-
- Default Implementations in Protocol Extensions: Methods defined within protocol extensions, but not explicitly declared as requirements in the protocol's definition, leverage static dispatch. Consequently, invoking these methods on an abstract protocol type will directly execute the implementation from the extension. This is in contrast to requirement methods defined in protocol extensions, which use dynamic dispatch and can be customized by conforming types.
Explanation:
-
makeSound() is a protocol requirement, so it uses dynamic dispatch, and the Dog implementation overrides the default extension implementation.
- eat() is not a requirement. It's only defined in the extension, so it uses static dispatch. Regardless of the concrete type of animal, calling eat() will always execute the code in the extension
Example and Dispatch Flow
Explanation of the dispatch flow:
-
myProtocolImplement is declared as an abstract protocol type (any MyProtocol).
-
myProtocolImplement.foo(i1: 1, i2: "2") is invoked. Because myProtocolImplement is an abstract type, the runtime will use protocol witness table dispatch.
-
The runtime inspects the witness table associated with the concrete type of myProtocolImplement, which is MyStruct in this case, to locate the implementation of foo(i1:i2:).
-
Within the witness table, the runtime discovers the function pointer corresponding to MyStruct's implementation of foo(i1:i2:).
-
Execution then jumps to this memory address, leading to the execution of print("MyStruct foo").
Outcome: "MyStruct foo" is printed.
Why this happens:
Despite the protocol extension offering a default implementation for foo(i1:i2:), MyStruct also provides its own implementation. When a conforming type provides a specific implementation for a protocol requirement, this implementation supersedes the default implementation in the extension. This principle underscores a fundamental concept in protocol-oriented programming: conforming types retain the ability to customize their behavior, even when default implementations are available.
The Perils of Default Parameters in Protocol Extensions
While protocol extensions in Swift offer a powerful mechanism for extending functionality, using default parameters within them requires careful consideration. The compiler's inability to safeguard against potential pitfalls, coupled with the possibility of infinite recursion, necessitates a cautious approach when employing this pattern.
Let's illustrate this with an example:
Here, invoking myProtocolImplement.foo() is intended to execute the .foo(i1: Int, i2: String) method of MyStruct with the default values of 1 for i1 and "2" for i2. However, this seemingly convenient approach can lead to dangerous runtime behavior.
Recursive Calls and Their Consequences:
The compiler provides no safeguards to prevent potential issues arising from this pattern. Consider the following scenarios:
- Missing Implementation: If we forget to implement the foo(i1: Int, i2: String) method inside MyStruct, the program will still compile.
- Parameter Mismatch: If we modify the parameters of the foo method in MyStruct, such as changing the type of i2 to Int, resulting in foo(i1: Int, i2: Int), the program will again compile without errors.
In both of these cases, we encounter a critical runtime problem – an infinite loop of recursive calls.
The Root of the Problem:
The method dispatch mechanism in this context solely searches for the method implementation within the protocol extension. The foo(i1: Int = 1, i2: String = "2") method ends up repeatedly calling itself, ultimately resulting in a crash. This behavior arises because when the variable's inferred type is the protocol, the dynamically dispatched implementation from the extension is always invoked. If the conforming type doesn't explicitly override the default parameter, the default implementation from the protocol extension will be used, leading to the recursive call.
Strategies to Mitigate the Risks:
To avoid these potentially disastrous situations, consider the following strategies:
-
Avoidance: The safest course of action is to completely avoid the use of default parameters within protocol extensions.
-
Renaming: Alternatively, we can rename the method that has default parameters to differentiate it from the required protocol method. For example, instead of foo(i1: Int = 1, i2: String = "2"), we could use fooDefault(i1: Int = 1, i2: String = "2"). This prevents the recursive call issue, as the default implementation in the extension is now clearly distinct from the required method in the protocol.
In Conclusion
Swift's protocol extensions are a potent tool for expanding functionality. However, exercising caution when incorporating default parameters within them is crucial. The absence of compiler-enforced safeguards and the potential for infinite recursion demand a mindful approach. By acknowledging these risks and implementing appropriate mitigation strategies, we can ensure the robustness and reliability of our code.