11.
Advanced Protocols & Generics
Written by Ehab Amer
This chapter covers more advanced uses of protocols and generics. Expanding on what you’ve learned in Swift Apprentice: Fundamentals and previous chapters, you’ll make generic protocols with constraints. You’ll also see how to hide unimportant implementation details using type erasure and opaque types while emphasizing the important ones with primary associated types.
Existential Protocols
This chapter will introduce some new terminology that may be confusing initially, but they’re important concepts for you to understand. Existential type is one such term. It’s a name for something you already know and have used — it’s merely a concrete type accessed through a protocol.
Put this into a playground:
protocol Pet {
var name: String { get }
}
struct Cat: Pet {
var name: String
}
In this code, the Pet protocol says that pets must have a name. Then, you defined a concrete type Cat, which conforms to Pet. Now, create a Cat like so:
var pet: any Pet = Cat(name: "Kitty")
Here, you defined the variable pet with a type of any Pet instead of the concrete type Cat. Here any Pet is an existential type or boxed type— it’s an abstract concept, a protocol, that refers to a concrete type, such as a struct, that exists. The compiler automatically creates a boxed type and wires up the concrete type inside of it.
These boxed types look like abstract base classes in object-oriented programming, but you can also apply them to enums and structs.
Note: Strictly speaking, for simple protocols with no associated types, you do not need to use the
anykeyword before the protocol. However, the need to writeanymay change in future versions of Swift and become required. Theanymakes clear you are paying a small but non-zero cost of accessing the concrete type through the compiler-generated box type.
Protocols with Associated Types
As you saw in Chapter 17 of Swift Apprentice, some protocols are naturally associated with other types. You specify these with the associatedtype keyword. If a protocol has any associated types, you must use the any keyword when you refer to a protocol as a type. For example, change Pet like so:
protocol Pet {
associatedtype Food
var name: String { get }
}
This version adds the associated type Food that the protocol needs to operate. To create an instance of pet, you would do this:
struct DogFood { }
struct Dog: Pet {
typealias Food = DogFood
var name: String
}
var pet: any Pet = Dog(name: "Mattie")
typealias is used here to define the associated type explicitly.
To see why associated types are so useful, consider this example:
protocol WeightCalculatable {
associatedtype WeightType
var weight: WeightType { get }
}
This protocol defines weight without fixing weight to one specific type. You can create a class (or a struct) that sets the WeightType as an Int or a Double or anything you want. For example:
class Truck: WeightCalculatable {
// This heavy thing only needs integer accuracy
var weight: Int {
100
}
}
class Flower: WeightCalculatable {
// This light thing needs decimal places
var weight: Double {
0.0025
}
}
Even though you didn’t use typealias to specify the associated WeightType, the clever compiler can look at the type of the property weight and infer it.
You can also be explicit and set it with a typealias as before:
class Flower: WeightCalculatable {
typealias WeightType = Double
var weight: Double {
0.0025
}
}
The associated type is completely unconstrained in that it can be anything you want. Nothing stops you from defining WeightType as a string or something else entirely.
class StringWeightThing: WeightCalculatable {
typealias WeightType = String
var weight: String {
"Superheavy" // Difficult to compute with!
}
}
class DogWeightThing: WeightCalculatable {
typealias WeightType = Dog
var weight: Dog {
Dog(name: "Rufus") // What is a dog doing here?
}
}
These types compile, but they are not very useful.
Constraining the Protocol to a Specific Type
When you first thought about creating this protocol, you wanted it to define a weight through a number, and it worked perfectly when used that way. It simply made sense!
If you wanted to write generic code around it and the generic system knows nothing about WeightType capabilities, you can’t do anything with it.
To remedy this, you want to add a constraint that requires WeightCalculatable to be Numeric:
protocol WeightCalculatable {
associatedtype WeightType: Numeric
var weight: WeightType { get }
}
This change will make strings and dogs invalid weight types:
You can now write generic short-hand functions that use WeightCalculatable in computations instead of accessing its underlying weight property. Why not start making good use of that? Write this:
extension WeightCalculatable {
static func + (left: Self, right: Self) -> WeightType {
left.weight + right.weight
}
}
var heavyTruck1 = Truck()
var heavyTruck2 = Truck()
heavyTruck1 + heavyTruck2 // 200
Anything that conforms to WeightCalculatable must have a WeightType representing a number. You can add the numeric capabilities directly into the protocol.
var lightFlower1 = Flower()
heavyTruck1 + lightFlower1
Also, notice that when you tried to add two different weight types, it didn’t work. That’s because the + operator requires both the left and right-hand sides to be the same type: Self. The protocol ensures that only the same conforming types add to produce a WeightType result.
Expressing Relationships Between Types
Next, look at how to use type constraints to express a relationship between types.
Suppose you want to model a factory that makes products. Enter this code to get started:
protocol Product {}
protocol ProductionLine {
func produce() -> any Product
}
protocol Factory {
var productionLines: [any ProductionLine] { get }
}
extension Factory {
func produce() -> [any Product] {
var items: [any Product] = []
productionLines.forEach { items.append($0.produce()) }
print("Finished Production")
print("-------------------")
return items
}
}
Here, you define protocols for Product, the ProductionLine that produces products, and Factory, which has production lines. You also extend Factory with produce(), which makes one product for every factory production line.
Next, define some concrete types:
struct Car: Product {
init() {
print("Car 🚘")
}
}
struct CarProductionLine: ProductionLine {
func produce() -> any Product {
Car()
}
}
struct CarFactory: Factory {
var productionLines: [any ProductionLine] = []
}
You now have concrete types for the Product, ProductionLine, and Factory. You’re ready to start the manufacturing process:
var carFactory = CarFactory()
carFactory.productionLines = [CarProductionLine(), CarProductionLine()]
carFactory.produce()
With this code, you created a factory, gave it two production lines and told it to start production. So far, so good! Now try this:
struct Chocolate: Product {
init() {
print("Chocolate bar 🍫")
}
}
struct ChocolateProductionLine: ProductionLine {
func produce() -> any Product {
Chocolate()
}
}
var oddCarFactory = CarFactory()
oddCarFactory.productionLines = [CarProductionLine(), ChocolateProductionLine()]
oddCarFactory.produce()
What’s chocolate doing in the car factory? How does this make sense?
The CarFactory type has no problem with a mix of car and chocolate production lines since they both conform to ProductionLine and can be put in the any ProductionLine box.
But the government health inspectors would never approve of chocolate produced in the same factory that makes cars. How can you specify that each factory should only produce one type of product?
First, start fresh with a new set of protocols — this time using associated types:
protocol Product {
init()
}
protocol ProductionLine {
associatedtype ProductType: Product
func produce() -> ProductType
}
protocol Factory {
associatedtype ProductType: Product
associatedtype LineType: ProductionLine
var productionLines: [LineType] { get }
func produce() -> [ProductType]
}
extension Factory where ProductType == LineType.ProductType {
func produce() -> [ProductType] {
var newItems: [ProductType] = []
productionLines.forEach { newItems.append($0.produce()) }
print("Finished Production")
print("-------------------")
return newItems
}
}
Notice with this set of protocols that the any Product and any ProductionLine go away and instead are replaced with concrete associated types constrained by a protocol. The where clause conditionally creates the produce() method for factories when the product produced by the factory is the same as the product that comes off its production line.
Product now includes init(), so the production line can create new products without knowing the product’s concrete type. Your Car and Chocolate types remain the same:
struct Car: Product {
init() {
print("Car 🚘")
}
}
struct Chocolate: Product{
init() {
print("Chocolate bar 🍫")
}
}
Instead of creating specific production lines and factories for cars and chocolates, you can create a single, generic production line and factory:
struct GenericProductionLine<P: Product>: ProductionLine {
func produce() -> P {
P()
}
}
struct GenericFactory<P: Product>: Factory {
typealias ProductType = P
var productionLines: [GenericProductionLine<P>] = []
}
Note how you use the generic type P to ensure the production line produces the same ProductType as the factory. You also constrain P to Product so that it must have a default initializer. You can now create a car factory as follows:
var carFactory = GenericFactory<Car>()
carFactory.productionLines = [GenericProductionLine<Car>(),
GenericProductionLine<Car>()]
carFactory.produce()
To create a chocolate factory, change <Car> to <Chocolate>.
Mini-Exercise
Here’s a little challenge for you. Try to do these two things:
- Instead of supplying the factory with production lines through the property
productionLines, the factory can increase its production lines. - Instead of the factory creating the products and doing nothing with them, the factory should store the items in a warehouse instead.
More Constraints Using a where Clause
You can set up useful and powerful constraints using a where clause. For example, suppose you want to write a generic function that sums the values of a collection and returns that sum as the same type as the element of the input collection. You could write this:
func sum<C: Collection>(_ input: C) -> C.Element where C.Element: Numeric {
input.reduce(0, +)
}
sum([1, 2, 3]) // Returns Int (6)
sum([1.25, 2.25, 3.25]) // Returns Double (6.75)
The function uses the associated type Element of Swift’s Collection protocol to define its return type. It uses the where clause to ensure that the Element type is something Numeric so you can call + on it.
Primary Associated Types
Think of associated types for protocols as generic parameters without angle brackets. Because of this, they are hidden as an implementation detail of a conforming type.
In some cases, though, the associated type is essential to the protocol, and you want to expose it as more than just an implementation detail.
Go back to the definition of ProductionLine and add a primary associated type: <ProductType>:
protocol ProductionLine<ProductType> {
associatedtype ProductType: Product
func produce() -> ProductType
}
The associated type appears in angle brackets. You can make a function that produces Car instances.
func produceCars(line: any ProductionLine<Car>, count: Int) -> [Car] {
(1...count).map { _ in line.produce() }
}
The function takes any production line as long as it produces Car types.
You can call the function like so:
produceCars(line: GenericProductionLine<Car>(), count: 5)
This code produces five Car instances from the generic producer of cars. The compiler won’t let you accidentally pass in a Chocolate producing production line.
Note: Primary Associated Types were introduced in Swift 5.7, and many standard library types now take advantage of this feature. For example, instead of only specifying a
Collectionprotocol, you can constrain the specific element type such as:any Collection<Double>.
Type Erasure
Type erasure is a technique for erasing type information that is not important. The type Any is the ultimate type erasure. It expunges all type information. As a consequence, it is lengthy and error-prone to use. As an example, consider the following collection types:
let array = Array(1...10)
let set = Set(1...10)
let reversedArray = array.reversed()
Each of these has a particular type. For example, reversedArray is of type ReversedArray<Array<Int>>. You can loop over it as you would normally because it conforms to the Sequence protocol. It’s this Sequence protocol that matters. Write:
for e in reversedArray {
print(e)
}
But what happens if you need to spell out the types explicitly? For example, you usually specify the exact type when you return a type or pass it as a parameter. Suppose you wanted to make a collection like this:
These three variables are collections of different types, and you can’t group them up together in a homogeneous element array.
You could get around this and not use the Any type like so:
let arrayCollections = [array, Array(set), Array(reversedArray)]
Here, arrayCollections is of type [[Int]]. This approach is often a good solution, thanks to the ability of Array to initialize from a sequence of elements and infer the element type automatically. However, it’s O(N) in time and space because it makes a copy of all the elements.
This easy solution might not be tenable if the collections are gigantic. Fortunately, Swift provides a type-erased type for collections called AnyCollection, and it throws away type-specific information while keeping all the collection goodness. Create it with this:
let collections = [AnyCollection(array),
AnyCollection(set),
AnyCollection(array.reversed())]
Creating an AnyCollection from a collection is a constant-time, O(1) operation because it wraps the original type without copying every element. The type is generic with elements of type Int, and the compiler can infer it in the above example. That lets you do computations, such as summing up the elements like this:
let total = collections.reduce(0) { $0 + $1.reduce(0, +) } // 165
This code reduces the elements of AnyCollection<Int> types and adds the subtotals from each collection in the array.
There are several type-erased types in not only the Swift standard libraries but other libraries as well. For example, AnyIterator, AnySequence, AnyCollection, AnyHashable are part of the Swift standard library. AnyPublisher is part of the Combine framework, and AnyView is part of SwiftUI.
The downside with these Any types is that it requires creating a whole new type that wraps the original. The process is straightforward but requires a lot of boilerplate code to achieve.
The any keyword also performs type erasure. With the example above, you can define the array using the new keyword like this:
let collections: [any Collection] = [array, set, reversedArray]
The any keyword created an array of type-erased Collection objects just as the AnyCollection type did but without any additional code or new types to create.
But here, too much information got erased. You know it is an any Collection but an any Collection of what? Fortunately, you can use the primary associated type of Collection to fill in the information. Replace the collection with this:
let collections: [any Collection<Int>] = [array, set, reversedArray]
The primary associated type of Int lets the compiler know what to expect for elements.
This additional information allows you to iterate over them and sum them up just as with AnyCollection<Int>:
let total = collections.reduce(0) { $0 + $1.reduce(0, +) } // 165
any Collection versus AnyCollection
There’s a fundamental difference between type erasure types like AnyCollection and existential types like any Collection protocol conformance.
AnyCollection conforms to the Collection and Sequence protocols, while any Collection does not. You will often not notice this limitation because the compiler opens the existential (opens the box) and turns it into a concrete type if you pass it as a parameter.
Sometimes you might notice this limitation. For example, it is impossible to call flatMap on [any Collection] because the elements, any Collection, do not conform to Sequence. flatMap() works perfectly fine for AnyCollection, which does conform to Sequence. You can do this:
let collections = [AnyCollection(array),
AnyCollection(set),
AnyCollection(array.reversed())]
let total = collections.flatMap { $0 }.reduce(0, +) // 165
Calling flatMap() isn’t possible using [any Collection<Int>].
Opaque Types
Using any SomeProtocol erases type information by putting the original type in a box. The box can hold any type that conforms to SomeProtocol and even change during runtime. That dynamicity comes with a cost both in complexity and runtime.
Swift provides a related language feature called opaque types. Opaque return types work by making the compiler keep track of the concrete return type. However, the compiler only lets the function caller use a protocol interface the type supports. Instead of the keyword any, opaque types use some.
Here’s a trivial example:
func makeValue() -> some FixedWidthInteger {
42
}
The magic here is some FixedWidthInteger. (All of the different integer types in Swift adopt the FixedWidthInteger protocol.) With this return type, you only know that it’s a kind of integer.
Using the protocol, though, you can do useful things such as addition:
print("Two makeValues summed", makeValue() + makeValue())
+ works because it is defined for FixedWidthInteger types that are the same type on the left and right-hand side.
But importantly, the makeValue function returns a distinct, compiler-known type with a known size (in this case, an Int) that won’t change from call to call.
The compiler will enforce that the function always returns a distinct type from all code paths:
To fix the compile error, change the type to the same:
func makeValueRandomly() -> some FixedWidthInteger {
if Bool.random() {
return Int(42)
}
else {
return Int(24)
}
}
You can also return a value as an object that implements a composition of protocols.
Use a more primitive numeric protocol, Numeric, that works for both integer and floating-point numbers:
func makeEquatableNumericInt() -> some Numeric & Equatable { 1 }
func makeEquatableNumericDouble() -> some Numeric & Equatable { 1.0 }
let value1 = makeEquatableNumericInt()
let value2 = makeEquatableNumericInt()
print(value1 == value2) // prints true
print(value1 + value2) // prints 2
print(value1 > value2) // error
The first two print statements compile and run as expected, thanks to the protocol conformances. But the third print needs conformance to Comparable. Although the actual type is a Comparable integer, this information is not exposed.
Also, even though it would seem from the outside that the types are the same, some Numeric & Equatable, the compiler knows the concrete types Int and Double are not:
// Compiler error, types don't match up
makeEquatableNumericInt() == makeEquatableNumericDouble()
Note: Opaque return types constitute a major feature of SwiftUI, whose
Viewprotocol returns abodyofsome View. It isn’t essential to know the exact type of the returned view and maintain that every time a button moves. This maintenance would be highly error-prone. The concrete type under the hood means that SwiftUI can find differences between views lightning-fast, translating to excellent user experiences and a simple programming model.
You can also use some with a property and not just as a return type:
var someCollection: some Collection = [1, 2, 3]
And printing the type of someCollection will give you the actual type of the property.
print(type(of: someCollection)) // Array<Int>
But just like with return types, that type isn’t exposed:
someCollection.append(4) // Compiler error
append(:) isn’t part of Collection, so you can’t call it.
Note: If you want
someCollection.append(4)to compile, you would need to declare it assome RangeReplaceableCollection<Int>, which does include the methodappend(: Int).
The main difference between any and some is that any supports dynamic, heterogeneous types and some is for static, homogeneous types. Put this in the playground:
var intArray = [1, 2, 3]
var intSet = Set([1, 2, 3])
Here, you’re creating two properties: An array and a set of integers. Next, create two arrays of Collection — one with any and one with some:
var arrayOfSome: [some Collection] = [intArray, intSet] // Compiler error
var arrayOfAny: [any Collection] = [intArray, intSet]
some doesn’t allow mixing objects of different types, but any didn’t complain. Additionally, try this:
var someArray: some Collection = intArray
var someSet: some Collection = intSet
someArray = someSet // Compiler error
someSet = someArray // Compiler error
var anyElement: any Collection = intArray
anyElement = intSet
Although someArray and someSet are both of type some Collection, the compiler didn’t allow you to pass the value of one to the other. But when doing the same with anyElement, the any keyword didn’t restrict changing the value to a different type.
some created an opaque version of a concrete type. Its actual type is still there, but the compiler doesn’t expose that information anymore:
var intArray2 = [1, 2, 3]
var someArray2: some Collection = intArray2
someArray = someArray2 // Compiler Error
The underlying type of someArray and someArray2 is [Int], but the compiler isn’t allowing the new assignment to happen because this information isn’t present anymore.
However, any doesn’t cause those issues. It allows you to change the value, even if it’s a different type.
Using Opaque Types Instead of Angle Brackets
You can use opaque types for generic programming. Consider the following:
func product<C: Collection>(_ input: C) -> Double where C.Element == Double {
input.reduce(1, *)
}
product([1,2,3,4]) // 24
This function takes a collection input constrained to have Double elements and returns their product.
You can replace it with this:
func product(_ input: some Collection<Double>) -> Double {
input.reduce(1, *)
}
This version is equivalent to the previous one but more readable. It’s all thanks to the power of opaque types and primary associated types. Although you may need to reach for traditional generic angle brackets and a where clause to make certain types of constraints, you should prefer this easier-to-read style when possible.
Challenges
Congratulations on making it this far! But before you come to the end of this chapter, here are some challenges to test your knowledge of advanced protocols and generics. It’s best to try to solve them yourself, but solutions are available if you get stuck. You can find the solutions with the download or the printed book’s source code link listed in the introduction.
Challenge 1: Robot vehicle builder
Using protocols, define a robot that makes vehicle toys:
-
Each robot can assemble a different number of pieces per minute. For example, Robot-A can assemble 10 pieces per minute, while Robot-B can assemble five.
-
Each robot type can only build a single type of toy.
-
Each toy type has a price value.
-
Each toy type has a different number of pieces. You tell the robot how long it should operate, and it will provide the finished toys.
-
Add a method to tell the robot how many toys to build. It will build them and say how much time is needed.
Challenge 2: Toy Train Builder
Declare a function that constructs robots that make toy trains:
- A train has 75 pieces.
- A train robot can assemble 500 pieces per minute.
- Use an opaque return type to hide the type of robot you return.
Challenge 3: Monster Truck Toy
Create a monster truck toy with 120 pieces and a robot to make this toy. The robot is less sophisticated and can only assemble 200 pieces per minute. Next, change the makeToyBuilder() function to return this new robot.
Challenge 4: Shop Robot
Define a shop that uses a robot to make the toy that this shop will sell:
- This shop should have two inventories: a display and a warehouse.
- There’s a limit to the number of items on display, but there’s no limit on the warehouse’s size.
- In the morning of each day, the warehouse fills its display.
- Each customer buys an average of 1.5 toys.
- If the shop needs the robot, rent the robot and operate it for the duration required.
- To reduce the shop’s running costs, the robot only works when the warehouse contents are less than the display’s size. The robot should produce enough toys so that the inventory is twice the size of the display.
- The shop has a
startDay(numberOfVisitors: Int)method. This method will first fill the display from the inventory, then sell items from the display based on the number of customers and finally produce new toys, if needed.
Key Points
- You can use protocols as existential types, opaque types and generic constraints.
- Existentials use the keyword
anyand are boxed types that can be used polymorphically, like a base class. - Generic constraints express the capabilities required by a type.
- Associated types make protocols generic. They provide greater generality and can be type-checked.
- Type erasure is a way to hide concrete details while preserving important type information.
- You can mark associated types as primary associated types, which lets you specify them explicitly as constraints in angle brackets.
-
somekeyword creates an opaque type that lets you access only protocol information from a concrete type. - The more generic you write your code, the more places you can reuse it.
And that’s a wrap! Generics will help you make your code less coupled and dependent on specific types. Protocols, extensions and associated types will allow you to write composable and reusable types that can be used in various contexts to solve a broader range of problems.