Technology

Understanding `some`: The Opaque Type’s Promise

Swift, a language celebrated for its clarity and safety, consistently evolves to give developers more precise tools for type management. If you’ve been working with Swift for a while, you’ve likely encountered a few head-scratching moments when dealing with protocols and generics. Two keywords that have emerged as central to this discussion are some and any. While they both deal with protocols, their underlying mechanics, use cases, and performance implications are distinctly different.

Introduced in Swift 5.1 and 5.6 respectively, some and any aren’t just syntactic sugar; they represent fundamental shifts in how we express type relationships in Swift. Grasping the nuances between them isn’t just about passing a compiler check; it’s about writing more efficient, readable, and robust code. Let’s peel back the layers and understand what each of these keywords brings to the Swift table.

Understanding `some`: The Opaque Type’s Promise

When you see some, think of an “opaque type.” This might sound a bit counterintuitive at first – opaque usually means unclear, right? But in Swift, an opaque return type is actually a powerful form of clarity. It tells the caller, “I’m returning a value that conforms to this protocol, and it will always be the *same specific concrete type* every time this function is called, even if you don’t know what that specific type is.”

Consider it like this: a function declaring func f() -> some P is essentially saying, “I’m returning *a* type that conforms to protocol P. Internally, I know exactly what that type is, and it’s fixed. You, the caller, just need to know it’s a P.” This “implicit generic placeholder,” as the Swift documentation might call it, is satisfied by the implementation itself.

protocol P {}
struct S1 : P {} func f() -> some P { return S1() // Returns a specific concrete type S1
}

The crucial part here is the “singular, concrete type” guarantee. If your function tries to return different types that conform to the protocol based on some conditional logic, the compiler will swiftly (pun intended!) slap you with an error. Why? Because the opaque type promises one underlying type, and changing that type violates the promise.

struct F1: P {}
struct F2: P {} // error: Function declares an opaque return type, but the return
// statements in its body do not have matching underlying types.
func f(_ value: Bool) -> some P { if value { return F1() // Returns F1 } else { return F2() // Returns F2 - Uh oh, different types! }
}

Benefits of Opaque Types

Opaque types, represented by some, bring several significant advantages to our Swift toolkit:

Working with PATs (Protocols with Associated Types)

One of the long-standing frustrations in Swift was the inability to use protocols with associated types (PATs) as return types. If you tried to return Collection (which has an Element associated type), it just wouldn’t compile. Opaque types elegantly sidestep this limitation. Since some Collection still guarantees a concrete, singular type under the hood, the compiler has all the necessary information, making it compatible with PATs.

// protocol Collection : Sequence
func collection() -> some Collection { return ["1", "2", "3"] // Returns a concrete type (e.g., Array)
}

Retaining Type Identity

Because an opaque type guarantees a single underlying type, the compiler knows that subsequent calls to a function returning some P will always yield values of the same specific type. This allows for powerful operations like comparing two instances returned by the same opaque function, provided the underlying type conforms to a protocol like Equatable.

func method() -> some Equatable { return "method" // Always returns a String
} let x = method()
let y = method() print(x == y) // true - because both x and y are String

Composing with Generics

Unlike traditional protocol-typed values, opaque result types integrate seamlessly with standard generic placeholders. This means you can pass values returned by `some` functions into other generic functions, and the compiler will understand and respect their underlying types.

protocol P { var message: String { get }
} struct M: P { var message: String
} func makeM() -> some P { return M(message: "message") // Returns a concrete type M
} func bar(_ p1: T, _ p2: U) -> Bool { return p1.message == p2.message
} let m1 = makeM()
let m2 = makeM() print(bar(m1, m2)) // Works perfectly because m1 and m2 are both of type M

In essence, some allows you to hide the specific implementation detail of a return type while still providing the compiler with enough information to perform optimizations and enforce type safety. It’s about abstraction without losing concrete type knowledge where it matters most: at compile time.

Demystifying `any`: The Existential Type’s Flexibility

Now, let’s pivot to any. Where some hides a *known, singular* concrete type, any explicitly declares an “existential type.” This means it can hold *any value* that conforms to a specified protocol, and crucially, the concrete type of that value can change dynamically at runtime. If some is about compile-time certainty with hidden specifics, any is about runtime flexibility with explicit type erasure.

Think of any P as a box that can hold *any* object that satisfies the requirements of protocol P. The contents of this box can literally be swapped out for a different conforming type later.

protocol Drawable { func draw()
} struct Line: Drawable { let x1, y1, x2, y2: Int func draw() { print("Draw Line") }
} struct Point: Drawable { let x, y: Int func draw() { print("Draw Point") }
} var p1: any Drawable = Line(x1: 0, y1: 0, x2: 5, y2: 5) // p1 holds a Line
p1.draw() // "Draw Line" p1 = Point(x: 0, y: 0) // Now p1 holds a Point
p1.draw() // "Draw Point"

This runtime flexibility is powerful. Imagine an array where you want to store a heterogeneous collection of objects, all conforming to a common protocol but potentially having wildly different underlying types and memory footprints. This is where any shines.

let array: [any Drawable] = [ Line(x1: 0, y1: 0, x2: 5, y2: 5), Point(x: 0, y: 0), Line(x1: 1, y1: 1, x2: 2, y2: 2)
]

The Existential Container: How `any` Works

This brings up an interesting question: if an array needs all its elements to be of a consistent size for constant-time access, and Line (32 bytes) and Point (16 bytes) have different sizes, how does [any Drawable] work? The answer lies in the “existential container.”

An existential container is a fixed-size wrapper (typically 5 machine words, or 40 bytes on a 64-bit system) that Swift creates whenever you use an existential type. This container is the same size regardless of the actual concrete type it holds. It comprises:

  • **Value Buffer (3 words):** This space is used to store the instance directly if it’s small enough. If the value is larger than 3 machine words, an ARC-managed box is dynamically allocated on the heap, the value is copied into it, and a pointer to this box is stored in the value buffer.
  • **Value Witness Table (VWT) Pointer:** This pointer directs the runtime to a table of function pointers for operations like allocation, deallocation, copying, and destruction of the underlying concrete type. It tells Swift *how* to manage the memory for the type held within the container.
  • **Protocol Witness Table (PWT) Pointer(s):** This pointer (or pointers, if multiple protocols are conformed to) points to a table that contains function pointers for all the requirements defined by the protocol (e.g., the draw() method in our Drawable example). This is how dynamic dispatch works – when you call p1.draw(), Swift looks up the correct draw() implementation in the PWT for the type currently stored in p1.

This boxing mechanism allows for the runtime flexibility of `any`. However, this flexibility comes at a cost. Using existential types involves:

  • **Dynamic Memory Allocation:** For larger types, heap allocation is required, which adds overhead.
  • **Pointer Indirection:** Accessing the actual value or calling a method requires dereferencing pointers (to the VWT, PWT, and potentially the heap-allocated value).
  • **Dynamic Dispatch:** Method calls are resolved at runtime via the PWT, preventing static optimization by the compiler. This can lead to noticeably higher costs compared to working with concrete types or opaque types.

In Swift 6, existential types are explicitly required to be spelled with the any keyword, reinforcing their distinct nature and the performance implications. This is a deliberate design choice to make developers aware of the runtime characteristics of using such flexibility.

Choosing Your Tool: `some` vs. `any`

The choice between some and any boils down to your specific needs and performance considerations:

  • When to use `some`

    Opt for some when you want to return a value that conforms to a protocol, and you know there will always be *one specific concrete type* returned by that function, but you want to hide that type from the API consumer. It’s excellent for API stability, refactoring flexibility, and crucial for working with PATs as return types. Since the compiler knows the underlying type, it’s generally more performant and enables optimizations.

  • When to use `any`

    Choose any when you genuinely need to store or pass around values of *different concrete types* that all conform to a common protocol, and you need that variability at runtime. This is ideal for heterogeneous collections, scenarios where the type of an object needs to change dynamically, or when working with objects whose concrete type isn’t known until runtime. Be mindful that this flexibility comes with a performance penalty due to dynamic allocation, pointer indirection, and dynamic dispatch.

Think of `some` as a carefully wrapped gift where you know *exactly* what’s inside (even if the recipient doesn’t), ensuring it perfectly fits its intended purpose. `any` is like a generic storage box; it can hold anything, but you might need to handle it with more care, and it takes up more space and effort to manage what’s inside.

Conclusion

Swift’s some and any keywords, while superficially similar in their interaction with protocols, serve fundamentally different purposes. some (opaque types) offers type abstraction while retaining compile-time type information, leading to better performance and the ability to work with PATs. any (existential types) provides powerful runtime flexibility by allowing you to store and manipulate values of diverse concrete types conforming to a protocol, albeit with a performance overhead due to its dynamic nature.

Mastering these keywords is more than just learning new syntax; it’s about understanding Swift’s sophisticated type system and making informed decisions that lead to more efficient, maintainable, and robust applications. By choosing the right tool for the job – be it the compile-time certainty of some or the runtime dynamism of any – you unlock new levels of expressiveness and optimization in your Swift code. Keep exploring, keep building, and keep refining your Swift expertise!

Swift, some keyword, any keyword, opaque types, existential types, Swift programming, protocols, generics, Swift performance, Swift 5.1, Swift 5.6, Swift 6

Related Articles

Back to top button