Generics are a fundamental tool for writing abstract code in Swift. Learn how you can identify opportunities for abstraction as your code evolves, evaluate strategies for writing one piece of code with many behaviors, and discover language features in Swift 5.7 that can help you make generic code easier to write and understand.
♪ Mellow instrumental hip-hop music ♪ ♪ Hi everyone, I'm Holly from the Swift Compiler team.
Welcome to "Embrace Swift generics." Generics are a fundamental tool for writing abstract code in Swift, which is crucial for managing complexity as your code evolves.
Abstraction separates ideas from specific details.
In code, there are a lot of different ways abstraction is useful.
One form of abstraction that you likely use all the time is factoring code out into a function or a local variable.
This can be really useful if you need to use the same functionality or value multiple times.
When you extract the functionality into a function, the details are abstracted away, and the code that uses the abstraction can express the idea of what's happening without repeating the details.
In Swift, you can also abstract away concrete types.
If you have a set of types that are all the same idea with different details, you can write abstract code to work with all of those concrete types.
Today, we'll walk through the workflow of modeling code with concrete types, identifying common capabilities of a set of concrete types, building an interface to represent those capabilities, and finally, we'll dive into writing generic code using that interface.
We'll dig into Swift's abstraction tools while building up some code to simulate a farm.
So, let's start by writing some concrete types.
We'll start with one struct called "Cow." Cow has a method called "eat," which accepts a parameter of type Hay.
Hay is another struct.
It has a static method called "grow" to grow the crop that produces Hay, which is Alfalfa.
The Alfalfa struct has a method to harvest Hay from an instance of Alfalfa.
Finally, we'll add a struct called "Farm" that has a method for feeding a cow.
The feed method can be implemented by first growing some alfalfa to produce hay, then harvesting the hay, and finally, feeding the hay to the cow.
And now, I can feed cows on my farm.
But I want to add more kinds of animals.
I can add more structs to represent other animals, like Horse and Chicken.
And I want to be able to feed cows, horses, and chickens on the farm.
I could overload the feed method to accept each type of parameter separately, but each overload will have a really similar implementation.
This will become extra boilerplate as I add more types of animals, and it's mostly repeated code anyway.
If you find yourself writing overloads with repetitive implementations, it might be a sign to generalize.
Fundamentally, these implementations are so similar because different types of animals are similar in functionality.
The next step is to identify the common capabilities between the animal types.
We've built a set of animal types that all have the ability to eat some type of food.
Each type of animal will have a different way of eating, so each implementation of the eat method will have differences in behavior.
What we want to do is allow abstract code to call the eat method and have that abstract code behave differently depending on the concrete type it's operating on.
The ability of abstract code to behave differently for different concrete types is called "polymorphism." Polymorphism allows one piece of code to have many behaviors depending on how the code is used.
Appropriately, polymorphism itself comes in different forms.
The first is function overloading, where the same function call can mean different things depending on the argument type.
Overloading is called "ad-hoc polymorphism" because it isn't really a general solution.
We just saw how overloading can lead to repetitive code.
Next is subtype polymorphism, where code operating on a supertype can have different behavior based on the specific subtype the code is using at runtime.
Finally, we have parametric polymorphism, which is achieved using generics.
Generic code uses type parameters to allow writing one piece of code that works with different types, and concrete types themselves are used as arguments.
We've already ruled out overloading, so let's try to use subtype polymorphism.
One way to represent subtype relationships is with a class hierarchy.
We could introduce a class called "Animal." Next, we'd change each animal type from a struct to a class.
Each specific animal class will inherit from the Animal superclass, and override the eat method.
Now, we have an abstract base-class Animal that can represent all of our specific animal types.
Code that calls eat on the Animal class will use subtype polymorphism to call the subclass implementation.
But we're not done.
We still haven't filled in a parameter type for the eat method on Animal, and there are a few other red flags in this code.
First, using classes forced us into reference semantics, even though we don't need or want any state to be shared between different animal instances.
This strategy also requires subclasses to override methods in the base class, but forgetting to do this wouldn't be caught until runtime.
But the bigger problem with this model of abstraction is that each animal subtype eats a different type of food, and this dependency is really difficult to express with a class hierarchy.
One approach we could take is to have the method accept a less specific type, such as Any.
But this strategy relies on subclass implementations to make sure the correct type was passed at runtime.
So, we've imposed extra boilerplate in each overridden method, but more importantly, it allows you to accidentally pass the wrong type of food, leaving you with another bug that could only be caught at runtime.
So, let's try something else.
We could instead express the animal's feed type in a type-safe way by introducing a type parameter on the Animal superclass.
This type parameter serves as a placeholder for the specific feed type for each subclass.
With this approach, the Food type parameter must be elevated to the declaration of the Animal class.
This seems a little unnatural because though animals need food to operate, eating food isn't the core purpose of an animal, and a lot of code that works with animals probably won't care about food at all.
Despite that, all references to the Animal class need to specify the food type.
For example, each Animal subclass needs to explicitly specify its food type in angle brackets in the inheritance clause.
This boilerplate at each use site of the Animal class could become onerous if we added more types that are specific to each animal.
So, none of our approaches here have good ergonomics or the right semantics.
The fundamental problem is that a class is a data type, and we're trying to convolute a superclass to make it represent abstract ideas about concrete types.
Instead, we want a language construct that was designed to represent capabilities of types without the details of how the capability works.
Animals have two common capabilities.
Each animal has a specific type of food, along with an operation for consuming some of that food.
We can build an interface that represents those capabilities.
In Swift, this is done using a protocol.
A protocol is an abstraction tool that describes the functionality of conforming types.
Using a protocol, you can separate the ideas about what a type does from the implementation details.
The ideas about what a type does are expressed through an interface.
Let's translate the capabilities of an animal to a protocol interface.
The name of the protocol represents the category of types we're describing, so I called this protocol "Animal." Each capability will map to a protocol requirement.
The specific type of food will map to an associated type of the Animal protocol.
Just like a type parameter, an associated type serves as a placeholder for a concrete type.
What makes associated types special is that they depend on the specific type that conforms to the protocol.
This relationship is guaranteed, so each instance of a specific type of animal always has the same type of food.
Next, the operation to consume food will map to a method.
This method is called "eat," and it accepts a parameter of the animal's feed type.
The protocol does not have an implementation of this method, and concrete animal types are required to implement it.
Now that we have the Animal protocol, we can make each concrete animal type conform to it.
You can annotate a concrete type with a protocol conformance at the declaration or in an extension.
Protocols are not limited to classes, so we can use protocols with structs, enums, and actors, too.
Once you write this conformance annotation, the compiler will check that the concrete type implements each of the protocol requirements.
Each animal type must implement the eat method, and the compiler can infer what the feed type is, because it's used in the parameter list.
The feed type can also be written explicitly using a type alias.
We've successfully identified the common capabilities of an animal and expressed those capabilities, using a protocol interface.
Now, we can start to write generic code.
We can use the Animal protocol to implement the feed method on Farm.
We want to write one implementation that works for all concrete animal types.
We'll use parametric polymorphism and introduce a type parameter that will be replaced with a concrete type when the method is called.
A type parameter is written after the function name in angle brackets.
Just like regular variables and function parameters, you can name a type parameter whatever you like.
And just like any other type, you can reference the type parameter throughout the function signature, using its name.
Here, I declared a type parameter called “A”, and I used A as the type of the animal function parameter.
We always want the concrete animal type to conform to the Animal protocol, so we annotate the type parameter with a protocol conformance.
Protocol conformances can be written in angle brackets, or they can be written in a trailing "where" clause, where you can also specify relationships between different type parameters.
Named type parameters and trailing "where" clauses are really powerful, because they allow you to write sophisticated requirements and type relationships.
But most generic functions don't need this generality.
Let's focus on the feed method.
The type parameter A appears once in the parameter list, and the "where" clause lists a conformance requirement on the type parameter.
In this case, naming the type parameter and using the "where" clause make the method look more complicated than it really is.
This generic pattern is really common, so there's a simpler way to express it.
Instead of writing a type parameter explicitly, we can express this abstract type in terms of the protocol conformance by writing "some Animal”.
This declaration is identical to the previous one, but the unnecessary type parameter list and "where" clause are gone, because we didn't need the expressiveness they provide.
Writing "some Animal" is more straightforward, because it reduces syntactic noise, and it includes the semantic information about the animal parameter right in the parameter declaration.
Let's break down the some Animal syntax.
The "some" in "some Animal" indicates that there is a specific type that you're working with.
The "some" keyword is always followed by a conformance requirement.
In this case, the specific type must conform to the Animal protocol, which will allow us to use requirements from the Animal protocol on the parameter value.
The "some" keyword can be used in parameter and result types.
If you've written SwiftUI code before, you've already used "some" in result position using "some View." A result type of "some View" is exactly the same concept.
In a SwiftUI view, the body property returns some specific type of view, but code that uses the body property doesn't need to know what the specific type is.
Let's take a step back to better understand the concept of a specific abstract type.
An abstract type that represents a placeholder for a specific concrete type is called an opaque type.
The specific concrete type that is substituted in is called the underlying type.
For values with opaque type, the underlying type is fixed for the scope of the value.
This way, generic code using the value is guaranteed to get the same underlying type each time the value is accessed.
A type using the "some" keyword and a named type parameter in angle brackets both declare an opaque type.
Opaque types can be used for both inputs and outputs, so they can be declared in parameter position or in result position.
The function arrow is the dividing line between these positions.
The position of an opaque type determines which part of the program sees the abstract type and which part of the program determines the concrete type.
Named type parameters are always declared on the input side, so the caller decides the underlying type, and the implementation uses the abstract type.
In general, the part of the program supplying the value for an opaque parameter or result type decides the underlying type, and the part of the program using the value sees the abstract type.
Let's dig into how this works, following our intuition about parameter and result values.
Because the underlying type is inferred from a value, the underlying type always comes from the same place as the value.
For a local variable, the underlying type is inferred from the value on the right-hand side of assignment.
This means local variables with opaque type must always have an initial value; and if you don't provide one, the compiler will report an error.
The underlying type must be fixed for the scope of the variable, so attempting to change the underlying type will also result in an error.
For parameters with opaque type, the underlying type is inferred from the argument value at the call site.
Using "some" in parameter position is new in Swift 5.7.
The underlying type only needs to be fixed for the scope of the parameter, so each call can provide a different argument type.
For an opaque result type, the underlying type is inferred from the return value in the implementation.
A method or computed property with an opaque result type can be called from anywhere in the program, so the scope of this named value is global.
This means the underlying return type has to be the same across all return statements; and if it isn't, the compiler will report an error that the underlying return values have mismatched types.
For an opaque SwiftUI view, the ViewBuilder DSL can transform control-flow statements to have the same underlying return type for each branch.
So in this case, we can fix the issue by using the ViewBuilder DSL.
Writing an @ViewBuilder annotation on the method and removing return statements will enable the result to be built for us by the ViewBuilder type.
Let's go back to the feedAnimal method.
I can use "some" in the parameter list because I don't need to reference the opaque type anywhere else.
When you need to refer to the opaque type multiple times in the function signature, that's when a name type parameter comes in handy.
For example, if we add another associated type to the animal protocol called "Habitat," we might want to be able to build a habitat on the farm for a given animal.
In this case, the result type depends on the specific animal type, so we need to use the type parameter A in the parameter type and the return type.
Another common place where you need to refer to an opaque type multiple times is in generic types.
Code often declares a type parameter on a generic type, uses the type parameter for a stored property, and again in a memberwise initializer.
Referencing a generic type in a different context also requires you to explicitly specify the type parameter in angle brackets.
The angle brackets at the declaration can help clarify how to use a generic type, so opaque types must always be named for generic types.
Now, let's build out the implementation of the feed method.
We can use the type of the animal parameter to access the crop type to grow through the Feed-associated type.
We'll call Feed.grow() to get an instance of the crop that produces this type of feed.
Next, we need to harvest the produce from the crop, which we can do by calling a method provided by the crop type called "harvest." And finally, we can feed this produce to the animal.
Because the underlying animal type is fixed, the compiler knows the relationship between the plant type, the produce type, and the animal type across the various method calls.
These static relationships prevent us from making the mistake of feeding the animal the wrong type of food.
If we attempt to use a type that is not guaranteed to be the correct food type for this animal, the compiler will tell us.
To learn how the other farm protocols were crafted to express the relationship between the animal-feed type and its plant, check out "Design protocol interfaces in Swift." Lastly, let's add a method for feeding all the animals.
I'll add a method called feedAll that accepts an array.
I know the element type needs to conform to the Animal protocol, but I want the array to be able to store different types of animals.
Let's see if some Animal can help us here.
With "some" there is a specific underlying type that cannot vary.
Because the underlying type is fixed, all of the elements in the array need to have the same type.
So, an array of some Animal doesn't express the right thing, because I want an array that can hold different animal types.
Here, we really need a supertype that can represent any type of animal.
We can express an arbitrary type of animal by writing "any Animal." The "any" keyword indicates that this type can store any arbitrary type of animal, and the underlying type of animal can vary at runtime.
Just like with the "some" keyword, the "any" keyword is always followed by a conformance requirement.
any Animal is a single static type that has the capability to store any concrete animal type dynamically, which allows us to use subtype polymorphism with value types.
To allow for this flexible storage, the any Animal type has a special representation in memory.
You can think of this representation like a box.
Sometimes, a value is small enough to fit inside the box directly.
And other values are too large for the box, so the value has to be allocated elsewhere, and the box stores a pointer to that value.
The static type any Animal that can dynamically store any concrete animal type is formally called an existential type.
And the strategy of using the same representation for different concrete types is called "type erasure." The concrete type is said to be erased at compile time, and the concrete type is only known at runtime.
These two instances of the existential type any Animal have the same static type, but different dynamic types.
Type erasure eliminates the type-level distinction between different animal values, which allows us to use values with different dynamic types interchangeably as the same static type.
We can use type erasure to write a heterogeneous array of value types, which is exactly what we want for the feedAll method.
So we'll use an array of any Animal as the parameter type.
Using the "any" keyword for protocols with associated types is new in Swift 5.7.
To implement the feedAll method, we'll first iterate over the animal's array.
For each animal, we want to call the eat method from the Animal protocol.
To call this method, we need to get the specific feed type for the underlying animal at this iteration.
But as soon as we try to call eat on any Animal, we'll get a compiler error.
Because we've eliminated the type-level distinction between specific animal types, we've also eliminated all type relationships that depend on the specific animal type, including associated types.
So, we can't know what type of feed this animal expects.
To rely on type relationships, we need to get back into a context where the specific type of animal is fixed.
Instead of calling eat directly on any Animal, we need to call the feed method that accepts some Animal.
Now, any Animal is a different type from some Animal, but the compiler can convert an instance of any Animal to some Animal by unboxing the underlying value and passing it directly to the some Animal parameter.
This capability of unboxing arguments is new in Swift 5.7.
You can think of unboxing as the compiler opening the box and taking out the value stored inside.
For the scope of the some Animal parameter, the value has a fixed underlying type, so we have access to all of the operations on the underlying type, including access to associated types.
This is really cool because it allows us to opt for flexible storage when we need it, while still allowing us to get back to a context where we have the full expressivity of the static type system by fixing the underlying type for the scope of a function.
And most of the time, you don't have to think about the unboxing because it just works in the way you'd expect, similar to how calling a protocol method on any Animal really calls the method on the underlying type.
So, we can pass each animal to the feed method, where we can grow and harvest the appropriate crop to feed to the specific animal at each iteration.
Throughout this process, we've seen that "some" and "any" have different capabilities.
With "some," the underlying type is fixed.
This allows you to rely on type relationships to the underlying type in your generic code, so you'll have full access to the API and associated types on the protocol you're working with.
Use "any" when you need to store arbitrary concrete types.
"any" provides type erasure, which allows you represent heterogeneous collections, represent the absence of an underlying type, using optionals, and make the abstraction an implementation detail.
In general, write "some" by default, and change "some" to "any" when you know you need to store arbitrary values.
With this approach, you'll only pay the cost of type erasure and its semantic limitations when you need the storage flexibility it provides.
This workflow is similar to writing let-constants by default, until you know you need mutation.
In this session, we walked through the workflow of generalizing code as it evolves and gains more functionality.
We started by writing concrete types.
As the code gained more functionality, we noticed repetition between different concrete types.
From there, we identified common capabilities and generalized them using a protocol.
Finally, we wrote abstract code using "some" and "any”, and we discussed preferring "some" for more expressive code.
To dig deeper into crafting protocols and understanding type erasure, check out "Design protocol interfaces in Swift." Thank you joining me and have a great WWDC.
Looking for something specific? Enter a topic above and jump straight to the good stuff.
An error occurred when submitting your query. Please check your Internet connection and try again.