CodeGym /Courses /Swift SELF /POP pitfalls: where dispatch surprises begin

POP pitfalls: where dispatch surprises begin

Swift SELF
Level 41, Lesson 4
Available

1. Where the "surprises" in POP come from and what dispatch is

The word dispatch sounds like we are about to call in the compiler's special forces (and somewhere nearby DispatchQueue is already smoking nervously). In practice, it is simpler: dispatch is the rule Swift uses to choose which exact method implementation to run. And the "surprise" starts when we expect behavior like inheritance, but write code in the style of protocols and extensions, where some calls are selected differently.

When you write POP code, you usually have three "places" where a method can live:

  1. as a protocol requirement;
  2. as a default implementation of a requirement in a protocol extension;
  3. as a method that exists only in the extension, but is not declared in the protocol.

And that third option is the main character in the series "why does my code do something else than I expect."

2. Concrete type vs any Protocol

The most common real-world situation looks like this: you have a concrete type Book, it has a method, and everything is fine. Then you decide to "generalize" and store different entities in an array of any Something so that you can handle them uniformly. And suddenly the method starts behaving differently - or disappears altogether. This is not a compiler bug; it is a consequence of what exactly you put into the protocol: requirements or just "convenience features" in an extension.

It is important to remember: any Protocol is an existential (a type container) that "erases" the concrete type and leaves only the contract on the outside. In Swift this is deliberately emphasized with the keyword any, so you do not forget: abstraction has a price - including restrictions and behavioral quirks.

Now let's look at the classic POP pitfall: two methods with the same name, one in the type, one in the extension, and different call results.

3. Pitfall #1: the method exists in the extension, but not in the protocol

Imagine a minimal example. We define a protocol that "knows how to say goodbye." We require nothing in the protocol (it is empty), and then add a method bye() in the extension - seemingly convenient: "any Farewell can bye." Then in the concrete type we also write bye(), expecting it to "override" the extension version.

Example: an "override" that does not exist

import Foundation

protocol Farewell { }

extension Farewell {
    func bye() -> String { "Bye from extension" }
}

struct Person: Farewell {
    func bye() -> String { "Bye from Person" }
}

let concrete = Person()
let asProtocol: any Farewell = concrete

print(concrete.bye())      // Bye from Person
print(asProtocol.bye())    // Bye from extension

If you just said "uhh... what?" - you have seen the main POP surprise.

Why is that? Because bye() is not a protocol requirement. It is just a method added in the extension. Such methods do not participate in polymorphic implementation selection when called through any P (that is, they do not behave like "virtual" methods from the OOP world).

Put more simply: when called through any Farewell, the compiler "sees" only what the protocol guarantees. And the Farewell protocol guarantees... nothing. So bye() is taken from the extension as a "known function", and Person.bye() is not treated as an "override".

4. How to fix it: the method must be a requirement

For a call to be polymorphic through any Protocol, the method must be a protocol requirement. Then Swift builds a mechanism that allows it to find the "right" implementation for the concrete type (conceptually, you can think of it as a "mapping table" between protocol requirements and type functions).

Example: making bye() a requirement

import Foundation

protocol Farewell {
    func bye() -> String
}

extension Farewell {
    func bye() -> String { "Bye from extension" }  // default for those who did not implement it
}

struct Person: Farewell {
    func bye() -> String { "Bye from Person" }
}

let concrete = Person()
let asProtocol: any Farewell = concrete

print(concrete.bye())      // Bye from Person
print(asProtocol.bye())    // Bye from Person

Now everything works "as expected": the method is a requirement, so when called through any Farewell, Swift picks the concrete type's implementation (or the default if the type does not provide its own).

Important idea: a default implementation is not just an "extension method." A default implementation only works when the method is declared as a protocol requirement.

5. How Swift chooses an implementation

Let's structure this a bit, but without diving into "compiler jungles." Think of it as a map, not an exam on Swift internals.

When you write:

  • value.method() on a concrete type - the compiler usually knows which method to call.
  • p.method() where p: any P - the compiler must follow the "protocol as contract" rules.

Roughly, it looks like this:

flowchart TD
    A["Call on a concrete type Person().bye()"] --> B["The compiler sees Person"]
    B --> C["Take Person.bye()"]

    D["Call through any Protocol (asProtocol).bye()"] --> E["The compiler sees only the Farewell contract"]
    E --> F{"Is bye() a requirement?"}
    F -->|yes| G["Choose the implementation through the protocol mechanism (conceptually: witness table)"]
    F -->|no| H["Take the implementation from the Farewell extension"]

And this is exactly what we observed: if the method is not a requirement, any P is not obligated to "respect" the identically named method of the concrete type.

6. Other traps: helper methods and Self

Helper methods in an extension can "fire" if they conflict by name

When you add a helper method in an extension, it is often tempting to name it something "logical": print(), describe(), format(), render(). And sometimes such a helper accidentally matches the name of a method in the concrete type. After that, you get the same situation as in the bye() example.

What matters here is not so much "never do this" (sometimes it is fine), but remembering the rule: same name does not mean override. In Swift, overriding is about classes and override, while protocol extensions live by different rules.

To avoid turning code into a detective story about "why did the wrong method get called?", a good style is this: name helper methods so it is clear they are helpers, not the "contract." For example, not render(), but renderLine() or renderDebugLine(). And keep the key behavior in protocol requirements.

Self in methods: why some API is unavailable on any Protocol

There is a separate category of "surprises" when you seem to have added a method in an extension, but when you try to call it through any P, the compiler says: "not allowed." This is often related to the method using Self in its parameters (that is, it takes a value of the same concrete type as the receiver). Then for an existential (any P) this can be unsafe: inside the box there may be one type, while you try to pass another.

Below is a simple "teaching" version without going deep into generics.

Example: a method with Self and why any may forbid the call

Example (the example compiles because we do not make the forbidden call - the prohibition is shown as a comment):

import Foundation

protocol Taggable { }

extension Taggable {
    func merge(with other: Self) -> String {
        "Merged"
    }
}

struct Book: Taggable { }
struct Movie: Taggable { }

let b: any Taggable = Book()

// b.merge(with: Book()) // you cannot write this: Self is unknown for any Taggable

The idea is this: any Taggable is a box containing "some Taggable." But the method merge(with:) requires exactly the same type as the one inside the box. And from the outside, we do not know that.

7. Practice: Renderable in LibraryCLI

Imagine that we have a simple CLI app, "library": we store books and print results to the user. At the application-concept level, it is convenient to make a protocol that knows how to "draw a line" for output.

And here the POP pitfall can hit in a very real way: you want to store different entities in an array of any Renderable, and each entity has its own formatting. If you implement this as a "method only in extension," you risk getting the same output for everything.

Wrong design: method only in extension

import Foundation

protocol Renderable { }

extension Renderable {
    func render() -> String { "[unknown]" }
}

struct Book: Renderable {
    let title: String
    func render() -> String { "Book: \(title)" }
}

let items: [any Renderable] = [Book(title: "Swift 6.2")]

print(items[0].render())   // [unknown]

From a beginner's perspective this looks like "well, why?!" From the rules' perspective, everything is fair: render() is not a requirement, so when called through any Renderable, the extension version is used.

Correct design: make render() a requirement

import Foundation

protocol Renderable {
    func render() -> String
}

extension Renderable {
    func render() -> String { "[unknown]" } // default logic, if needed
}

struct Book: Renderable {
    let title: String
    func render() -> String { "Book: \(title)" }
}

let items: [any Renderable] = [Book(title: "Swift 6.2")]

print(items[0].render())   // Book: Swift 6.2

Now LibraryCLI can safely store elements as any Renderable and print them in the same way, but with different implementations.

8. The "requirement vs helper" rule

In a real project, you will almost always mix two approaches:

Requirement methods are needed when you want polymorphism: different types behave differently, and that should work through any Protocol.

Helper methods in an extension are good when they are a wrapper around requirements. That is, the helper does not try to be "overridable"; it just builds convenience around what is already part of the contract.

This is the key to predictability: the helper should rely on requirements, not compete with them.

Example: a "safe helper" - the extension calls a requirement

import Foundation

protocol Renderer {
    func render() -> String
}

extension Renderer {
    func renderLine() -> String {
        "• " + render()
    }
}

struct Book: Renderer {
    let title: String
    func render() -> String { "Book: \(title)" }
}

let r: any Renderer = Book(title: "Algorithms")
print(r.renderLine())      // • Book: Algorithms

Here renderLine(), although not a requirement, calls render() - a requirement - internally. Therefore the final behavior still depends on the concrete type, and there are usually fewer surprises.

9. Cheat sheet: where the "wrong behavior" starts

Sometimes it helps to see it in a table once and then avoid stepping on the rake every Tuesday.

What you wrote Where the method is declared Call on Book() Call on any P Predictability
func f() - requirement in protocol calls Book.f() (if present) calls Book.f() (if present) high
default implementation of a requirement in extension P if the type has no own version - the default is used if the type has no own version - the default is used high
func helper() only in an extension only in extension P may match the type's method by name, but this is not override calls the extension version (if available) often surprising
func g(_ x: Self) in an extension in extension P may work on a concrete type often cannot / does not work on any P compiler surprise

The line about Self is like a sign that says "caution, wet floor": you do not have to slip, but the chance is noticeable.

10. Typical mistakes and why they happen

Mistake #1: expecting a method from an extension to be "overridden" in the type.
This usually grows out of OOP habits: "if I wrote a method with the same name in the type, then it replaces the default." In protocol extensions, that is not the case: a matching name does not make the method "virtual." If the method is not declared as a requirement, a call through any Protocol can absolutely choose the extension implementation, which looks like substitution.

Mistake #2: hiding key behavior in an extension helper method and then storing values as any Protocol.
As long as you call methods on concrete types, everything seems normal. But once you pass the value as any P, your "important" method may either become unavailable or start working according to the extension version. If the method is truly important for external code, it should be a protocol requirement; otherwise the contract exists only "on paper."

Mistake #3: making the protocol empty and trying to put all the meat into the extension, then wondering why polymorphism is missing.
An empty protocol is fine as a "marker," but if you expect polymorphic behavior from it, it needs requirements. Otherwise any P is just a box without promises. From the outside, you cannot expect different types to behave differently if you did not lock that down with a contract.

Mistake #4: writing methods with a Self parameter and then trying to call them on any Protocol.
On concrete types, such methods look natural: "merge two values of the same type." But for an existential, this is dangerous: from the outside we do not know which concrete type is inside the box, and Swift will often prohibit such a call. As a result, you get the compiler error member cannot be used on value of protocol type.... This is not nitpicking; it is type safety protecting you from invalid calls.

Mistake #5: giving helper methods overly generic names and accidentally conflicting with methods of concrete types.
When an extension adds describe() or print() (meaning "return a string") and the type also has describe(), you create a ticking time bomb: on the concrete type the call may go to one place, on any P - to another. The usual fix is simple: either make the method a requirement, or rename the helper so it is clearly a helper (for example, renderLine() instead of render()), or move the shared logic into a helper that calls the requirement.

1
Task
Swift SELF, level 41, lesson 4
Locked
Protocol masking
Protocol masking
1
Task
Swift SELF, level 41, lesson 4
Locked
Farewell by the rule
Farewell by the rule
1
Task
Swift SELF, level 41, lesson 4
Locked
List marker
List marker
1
Task
Swift SELF, level 41, lesson 4
Locked
Merging entities
Merging entities
1
Survey/quiz
Extension Protocols, level 41, lesson 4
Unavailable
Extension Protocols
Protocol extensions and conditional conformance
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION