ylliX - Online Advertising Network
My process for learning new languages

What’s the ‘any’ keyword? Understanding Type Erasure in Swift


The concept of Type Erasure is not new to Swift, but was radically improved in Swift 5.7 with the addition of the any prefix keyword (not to be confused with the capitalized Any type!) and improvements to the already existing some Opaque Type keyword. In this article, we’ll explain the concept of type erasure, how it used to be done, what’s different in Swift 5.7, and how these changes work under the hood.

What the hell is Type Erasure?

In programming languages with support for generic types, Type Erasure is the process of abstracting constrained generic types inside an unconstrained non-generic type that can be passed around freely.

If you don’t know why that’s necessary, consider the following example where we define a protocol and a few conforming types:

protocol Shape {}
struct Circle: Shape {}
struct Triangle: Shape {}

Because the Shape protocol is unconstrained, Swift allows us to quite easily refer to Circle and Triangle as their bare conformances:

var userShapes = [Shape]()
func userDidCreate(shape: Shape)
    userShapes.append(shape)
}
userDidCreate(shape: Circle())
userDidCreate(shape: Triangle())
// No problems will arise here. It's fine to refer to these types as just "Shape".

This will not be the case if the protocol has generic requirements. If the protocol has, for example, associated types that each underlying type provides on its own, Swift will strictly forbid you from referring to this protocol if you’re also not providing appropriate generic constraints:

protocol Sequence {
    associatedtype Element
}
var sequences = [Sequence]() // Can't do!
// Error: Protocol 'Sequence' can only be used as a generic constraint
// because it has Self or associated type requirements

The reason you can’t do this is that Swift is designed to be a type-safe language. Even though the compiler has no problem determining the underlying type of a Sequence type in the previous snippet, Swift has these features in order to allow you to ship safer products (runtime-wise). Think of access control properties like public and private; they are good examples of concepts that have absolutely no impact on the final binary. In the end, everything is accessible from everywhere, but inside your IDE, the compiler forces access control conventions to be followed so at the very least you are able to write code that is used the way you intended it to be used.

The issue here with generic constraints is similar. The compiler does know what the underlying type of a particular generic protocol is in runtime, but because Swift is designed to be type-safe, for safety reasons, if it can’t be determined in compile time, it will not let you do it. This is the complete opposite of Objective-C where you could easily do whatever you wanted for the (very big) cost of runtime safety.

Before Swift 5.7, the solution to this problem was to box these objects into unconstrained and generally unsafe “Any” variations of their protocols, like this:

class AnySequence {
    let value: Any

    init<T: Sequence>(_ sequence: T) {
        self.value = shape
    }
}

This process is called type erasure, and the Swift standard library itself contains many such objects. There are many situations in Swift where you’d need to do this, and one of them was covered in our 2020 article on how to use type erasure to build a dependency injection system. In general, you’ll find yourself needing to type erase a protocol with generic requirements whenever you’re in a situation where knowing the actual constraints of said protocols are irrelevant.

After Swift 5.7: Enter “any”

As far as I’m aware, this process was widely disliked by the Swift community. Not only it was a symptom of a design mistake in the language, but the process itself was also quite unsafe as it often involved the extensive usage of force-unwrapping (due to the need of referring to everything as Any).

This has changed in Swift 5.7. With the addition of the new any existential type keyword, type erasure is now a feature of the Swift compiler. You do not need to create your own “AnyMyType” abstraction anymore; by adding any before a type, the Swift compiler will now automatically abstract it for you. While I like to call these “type-erased values” for simplicity, you should know that the proper terminology for referring to a value as an abstract representation is existential type.

let erased: any Sequence = MyConcreteSequenceType()

This also means that the old “protocol can only be used as a generic constraint” error has changed. In Swift 5.7, trying to use such protocols without their constraints will now make the compiler prompt you to refer to it as an existential.

let erased: Sequence = MyConcreteSequenceType()
// Error: Use of protocol 'Sequence' as a type must be written 'any Sequence'

Type-safety of “any”

One amazing benefit of this new keyword is that unlike manual type-erasure, using the new any keyword is type-safe. While before Swift 5.7 you generally had to force-unwrap your type-erased values in order to “unbox” them and access their constraints (and hope you didn’t get anything wrong), the Swift 5.7 compiler will watch your back and prevent you from making mistakes. The way in which you achieve this is a bit tricky though, so I’ve modified an example from WWDC 2022’s session about the feature to clarify it.

Let’s assume that we have a Animal protocol that defines a generic food type and a method to feed said food. We also have a FoodProvider protocol that is able to provide said food.

protocol Animal {
    associatedtype Food
    func eat(_ food: Food)
}

protocol FoodProvider {
    func getFood<T: Animal>(for animal: T) -> T.Food
}

Let’s now pretend that we have a helper method that receives a group of animals and tries to feed all of them. Because the particular type of animal doesn’t matter to us in this scenario, we want to do so without declaring any generic constraints.

func feedAll(_ animals: [Animal], provider: FoodProvider) {
    animals.forEach {
        let food = provider.getFood(for: $0)
        $0.eat(food)
    }
}

As you might expect after reading this article, the existence of an associated type will make Swift prevent us from doing that. Before Swift 5.7, the solution to this was generally to create a AnyAnimal type and abstract these actions under unsafe closures powered by force-unwrapping:

final class AnyAnimal {
    var getFood: (FoodProvider) -> Any
    var eatFood: (Any) -> Void

    init<T: Animal>(_ animal: T) {
        self.getFood = { provider in
            provider.getFood(for: animal)
        }
        self.eatFood = { food in
            animal.eat(food as! T.Food)
        }
    }
}

func feedAll(_ animals: [AnyAnimal], provider: FoodProvider) {
    animals.forEach {
        let food = $0.getFood(provider)
        $0.eatFood(food)
    }
}

While this solution will work for this case, it’s not safe as you could quite easily pass the wrong food type to the erased value and cause a crash. Swift 5.7’s any keyword solves this problem by enforcing complete type-safety on any existential value, but there’s a small catch (Note: This refers to a limitation of Swift 5.7. If you’re reading this in the future, this might not be the case anymore.). While Swift will freely allow you to call any unconstrained methods of the existential type, attempting to use any constrained methods can prove to be a bit challenging:

func feedAll(_ animals: [any Animal], provider: FoodProvider) {
    animals.forEach {
        let food = provider.getFood(for: $0)
        $0.eat(food) // Member 'eat' cannot be used on value of type 'any Animal'
    }
}

Even though the naked eye might tell you that the output of getFood(_:) definitely matches the input of getFood(_:), Swift will not allow you to call any methods involving generic parameters unless you first explicitly “unbox” the existential by passing it to a generic method that receives a non-erased value (with no constraints):

func feedAll(_ animals: [any Animal], provider: FoodProvider) {
    animals.forEach {
        feed($0, provider: provider) // Now works!
    }
}

func feed<T: Animal>(_ animal: T, provider: FoodProvider) {
    let food = provider.getFood(for: animal)
    animal.eat(food)
}

I thought this seemed like a pointless step since they’re both doing the same thing, so I asked about it on the official Swift Forums and it turns out that this is indeed a limitation of Swift 5.7. To be more specific, the proposal that introduced the ability to “unbox” existential types in a type-safe manner has a negative side-effect that makes the compiler “lose” information about a particular existential outside of said “unboxing” contexts. You can read more about it in the proposal.

It’s important to keep in mind though that the any keyword doesn’t replace all cases in which you’d need to erase generic types. While it takes care of the basic case of hiding a type’s constraints, more special cases such as transforming constraints (like in our tutorial about it) still require you to go with the manual approach for the time being.

Supporting Features

Despite not being directly related to the concept of type erasure, Swift 5.7 comes with some additional features that greatly empower it. Let’s check them out.

Opaque Parameters

One thing we mentioned is the necessity of “unboxing” existentials by writing empty generic clauses. These empty clauses are very common even outside type erasure, and were improved in Swift 5.7 by the addition of the new Opaque Parameters feature. Now, instead of writing this:

func feed<T: Animal>(_ animal: T)

You can write it like this:

func feed(_ animal: some Animal)

Deep down, these are exactly the same thing. Declaring a parameter as some Type is simply a syntax sugar for declaring an empty generic constraint clause. Despite not being a ground-breaking feature, this is a very welcome change as generic methods with several parameters tended to become borderline unreadable.

Note: This is not to be confused with Swift 5.1’s Opaque Return Types feature. Despite using the same keyword and behaving relatively similarly, they are different features that work in very different ways.

Primary Associated Types

Starting on Swift 5.7, you can now declare one or more of your protocol’s associated types as its primary associated types by adding them between angle brackets in the protocol’s declaration:

protocol Sequence<Element> { // Element is now a primary associated type
    associatedtype Element
    associatedtype Iterator
}

This has two purposes. The first one is that it gives you a very welcome syntax sugar when declaring generic constraints just like the one we saw in the Opaque Parameters section; instead of declaring ugly constraint clauses for that particular type like this:

extension Sequence where Element == Int {
    func grabSomeNumbers() { ... }
}

// or

func grabSomeNumbers<T: Sequence>(_ s: T) where T.Element == Int { ... }

…you are now able to inform this directly in the type!

extension Sequence<Int> {
    func grabSomeNumbers() { }
}

// or

func grabSomeNumbers(_ s: Sequence<Int>) { ... }

In addition to doing this to your own types, you may find it interesting that many protocols of Swift’s Standard Library have already been updated to declare primary associated types.

The second and most ground-breaking purpose is that this is not restricted to extensions and methods; you can combine with the any keyword to declare type-erased, partially constrained stored properties:

let intSequences: [any Sequence<Int>]

This was previously unheard of in Swift as we were never allowed to define generic constraints in properties. I’m partially disappointed though that they didn’t go to the full way to allow us to declare any type of constraint on the fly (like Sequence<where Element == String, Iterator = MyIterator>), but the proposal indicates that the existence of primary associated types does not prevent this from being potentially implemented in the future.



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *