Associated types and Self requirements are important features of Swift and are used extensively in the standard library. But they can be tricky to use and continue to frustrate many Swift programmers. This article shows how, with a protocol-oriented approach, it is possible not only to avoid having to use associated types in many situations but also to use associated types and Self requirements, where appropriate, to leverage their benefits while avoiding the pitfalls.
Contents
How not to use protocols with associated types
A protocol-oriented way to use associated types
A protocol-oriented alternative to associated types
Use of associated types in the standard library
What is a Self requirement in a protocol
A protocol-oriented approach to using Self requirements
Use of Self requirements in the standard library
Other advantages of protocol-oriented design
1. Dealing with exceptional conditions
2. Extending the domain
Conclusion
What is an associated type
An associated type is a placeholder in a protocol for a type that is used as part of the protocol. Any type that conforms to the protocol must specify an actual type that will replace the placeholder. A protocol can have one or more associated types and these associated types provide flexibility for conforming types to decide which type to use in place of each associated type placeholder. Associated types are specified with the associatedtype
keyword. Conforming types can use the typealias
keyword to specify the type that will replace each associated type placeholder. In many cases, however, Swift is able to infer that type from the context.
A protocol with an associated type is an incomplete protocol since one or more of the types used as part of the protocol do not get resolved until the protocol is adopted. It is important to note that, although the presence of an associated type makes a protocol generic in the sense that conforming types can substitute any type for the placeholder, it does not mean that every type conforming to such a protocol will be generic. That decision is left to the conforming types.
How not to use protocols with associated types
Protocols with associated types have got something of a bad name in Swift circles. This is due to the very nature of protocols with associated types. As already noted, a protocol with an associated type is an incomplete protocol. It uses a type but that type remains unspecified until a concrete type conforms to the protocol. Therefore, Swift does not consider protocols with associated types as first-class types, i.e., they cannot be used as types of variables, constants, elements of collections, or parameters and return values of functions.
The frustration Swift developers sometimes experience with associated types is because of using protocols with associated types as abstractions for domain entities. Such abstractions then prove unusable as the abstract type cannot be used as the type of a variable, the type of elements of an array, or as a parameter or return type of a function.
To see how an associated type can be problematic when used inappropriately, let’s say we want to automate marking of certain types of exam questions. For a question to be automatically marked, we should be able to compare a given answer with the correct answer for value equality. Since we need to accommodate different types of questions that can be automatically marked, e.g., multiple-choice questions, true-false questions, etc., we need to use a protocol and the protocol should allow us to associate each type of question with a specific type of answer. Sounds like a problem that calls for a protocol with an associated type. Following classic object-oriented thinking, we would use a protocol to model an abstract question, as shown below.
protocol AutoMarkableQuestion {
associatedtype Answer: Equatable
var statement: String { get set }
var correctAnswer: Answer { get set }
func isCorrect(answer: Answer) -> Bool
}
The protocol AutoMarkableQuestion
defines an associated type Answer
, which is a placeholder for the type of answer that will be associated with each concrete question type. As we see in this example, Swift allows us to apply constraints to associated types. Here, we constrain the associated type Answer
such that any type which replaces this placeholder must conform to Equatable
so instances of concrete answer types can be compared for value equality using the ==
operator. The protocol also defines a variable to store the question statement, a variable to store the correct answer, and a method to check whether a given answer is correct.
Since the isCorrect(answer:)
method will just test the given answer for value equality with the stored correct answer, we can implement this method in a protocol extension.
extension AutoMarkableQuestion {
func isCorrect(answer: Answer) -> Bool {
answer == correctAnswer
}
}
Now, we can define concrete question types that conform to the protocol. Let’s start with a true-false question. Note that we don’t need to use the typealias
keyword in this case as Swift is able to infer the type we are using in place of the Answer
placeholder.
enum TrueFalseAnswer {
case t, f
}
struct TrueFalseQuestion: AutoMarkableQuestion {
var statement: String
var correctAnswer: TrueFalseAnswer
}
Following the same pattern, we can define a type for multiple-choice questions where the correct answer is one of four choices.
enum MultipleChoiceAnswer {
case a, b, c, d
}
struct MultipleChoiceQuestion: AutoMarkableQuestion {
var statement: String
var correctAnswer: MultipleChoiceAnswer
}
Let’s test these concrete types by defining one instance of each question type.
let trueFalseQuestion = TrueFalseQuestion(
statement: "The sun revolves around the earth.",
correctAnswer: .f)
let multipleChoiceQuestion = MultipleChoiceQuestion(
statement: "Which city is the capital of France? a) Rome; b) Paris; c) London; d) Accra",
correctAnswer: .b)
We can check the output of the isCorrect(answer:)
method by providing a correct and incorrect answer for each question.
print(trueFalseQuestion.isCorrect(answer: .t))
// false
print(trueFalseQuestion.isCorrect(answer: .f))
// true
print(multipleChoiceQuestion.isCorrect(answer: .a))
// false
print(multipleChoiceQuestion.isCorrect(answer: .b))
// true
Associated types get the Swift type system working for us and we are greeted with a compile-time error if the answer we provide is an instance of any type other than the answer type associated with the question being answered. We can see this type-safety in action if we try to provide a multiple-choice answer to a true-false question.
print(trueFalseQuestion.isCorrect(answer: MultipleChoiceAnswer.a))
// Error: Cannot convert value of type 'MultipleChoiceAnswer' to expected argument type 'TrueFalseAnswer'
The compiler complains that the type of the value MultipleChoiceAnswer.a
is not the expected answer type for a true-false question. We get a different error is we omit the MultipleChoiceAnswer
prefix.
print(trueFalseQuestion.isCorrect(answer: .a))
// Error: Type 'TrueFalseAnswer' has no member 'a'
This is all well and good. But the point of using a protocol is to be able to treat questions at an abstract level so we can use questions without having to know the actual type of each concrete question instance. Let's try and do that with one of the questions.
let question: AutoMarkableQuestion = trueFalseQuestion
// Error: Protocol 'AutoMarkableQuestion' can only be used as a generic constraint because it has Self or Associated type requirements
We get the error that has baffled many Swift programmers. The problem is that, since the AutoMarkableQuestion
protocol has an associated type, the protocol is not a first-class type and it can be used only as a generic constraint. Therefore, we are getting an error when assigning an instance of TrueFalseQuestion
to a variable of type AutoMarkableQuestion
even though TrueFalseQuestion
conforms to AutoMarkableQuestion
. The same thing happens if we try to create an array to store instances of different concrete question types.
let questions: [AutoMarkableQuestion] = [trueFalseQuestion, multipleChoiceQuestion]
// Error: Protocol 'AutoMarkableQuestion' can only be used as a generic constraint because it has Self or Associated type requirements
The fact that we cannot create a variable of type AutoMarkableQuestion
or use AutoMarkableQuestion
as the type of the elements of an array is not a limitation of Swift. It simply reflects the fact that the AutoMarkableQuestion
protocol is incomplete and the protocol type does not provide sufficient information about instances of conforming types. For example, if we could create a variable of type AutoMarkableQuestion
, what answer type would we expect a concrete instance stored in that variable to receive. The reality is that we cannot know since the protocol does not provide that information. We would have the same issue if we could create an array with elements of type AutoMarkableQuestion
or a function with a parameter or return value of type AutoMarkableQuestion
. This is why Swift renders a protocol with an associated type unusable as a first-class type. This makes the AutoMarkableQuestion
protocol unusable as an abstraction, defeating the purpose of creating the protocol.
A protocol-oriented way to use associated types
In the previous section, we saw how using an associated type in a protocol renders it unusable as an abstraction of a domain entity since the protocol cannot be used a first-class type. This can sometimes deter developers from using protocols with associated types. But protocols with associated types can enable us to solve otherwise thorny problems in simple and elegant ways. The solution to this seeming conundrum is to look at the domain through a protocol-oriented lens, using protocols to model traits of domain entities rather than the domain entities themselves. This can enable us to leverage the strengths of associated types while avoiding the issues we saw in the previous section.
Let’s revisit the problem of automating marking of certain types of exam questions. This time, rather than using a protocol with an associated type to model a question, which is a domain entity, we use a protocol with an associated type to model the characteristic that we want our questions to have, i.e., they should support automatic marking.
protocol AutoMarkable {
associatedtype Answer: Equatable
var correctAnswer: Answer { get set }
func isCorrect(answer: Answer) -> Bool
}
The AutoMarkable
protocol is similar to the AutoMarkableQuestion
protocol we had defined in the previous section. It defines an associated type Answer
, which is a placeholder for the type of answer that will be associated with each conforming type. As we had done before, we constrain the associated type Answer
such that any type which replaces this placeholder must conform to Equatable
so instances of answer types as can be compared for value equality using the ==
operator. We also define a variable correctAnswer
to store the correct answer and a method to check whether a given answer is correct. Unlike the AutoMarkableQuestion
protocol, we don’t have a variable to store the question statement, since this protocol does not represent a question but rather a specific attribute of questions.
As we had done with the AutoMarkableQuestion
protocol, we use a protocol extension to implement the isCorrect(answer:)
method to test the given answer for value equality with the stored correct answer.
extension AutoMarkable {
func isCorrect(answer: Answer) -> Bool {
return answer == correctAnswer
}
}
Now, we can define concrete question types to conform to the AutoMarkable
protocol. We start with a true-false question, using the TrueFalseAnswer
type we had defined in the previous section.
struct TrueFalseQuestion: AutoMarkable {
var statement: String
var correctAnswer: TrueFalseAnswer
}
Following the same pattern, we can define a type to model a multiple-choice question, using the MultipleChoiceAnswer
type defined in the previous section.
struct MultipleChoiceQuestion: AutoMarkable {
var statement: String
var correctAnswer: MultipleChoiceAnswer
}
To test these types, we write a function to check whether an answer given for a question is correct.
func printResultOf<Question: AutoMarkable>(question: Question, forAnswer answerGiven: Question.Answer) {
print(question.isCorrect(answer: answerGiven) ? "Correct, one point!" : "Sorry, try again!")
}
Remember the error we got in the previous section when we tried to use the AutoMarkableQuestion
protocol as a first-class type and we were told that AutoMarkableQuestion
can only be used as a generic constraint, which made the abstraction unusable. Now that we have used the protocol AutoMarkable
to abstract a trait of the domain entity rather than a domain entity itself, we can do exactly what the error was telling us to do, i.e., use the AutoMarkable
protocol as a generic constraint. In the generic function printResultOf(question:forAnswer:)
, we have used AutoMarkable
as a constraint on the type parameter Question
, which requires that any type that replaces the Question
placeholder must conform to AutoMarkable
.
We create concrete question instances in exactly the same way as we had done in the previous section.
let trueFalseQuestion = TrueFalseQuestion(
statement: "The sun revolves around the earth.",
correctAnswer: .f)
let multipleChoiceQuestion = MultipleChoiceQuestion(
statement: "Which city is the capital of France? a) Rome; b) Paris; c) London; d) Accra",
correctAnswer: .b)
Now we can use the printResultOf(question:forAnswer:)
function to test answers to questions, as shown below.
printResultOf(question: trueFalseQuestion, forAnswer: .t)
// Sorry, try again!
printResultOf(question: trueFalseQuestion, forAnswer: .f)
// Correct, one point!
printResultOf(question: multipleChoiceQuestion, forAnswer: .a)
// Sorry, try again!
printResultOf(question: multipleChoiceQuestion, forAnswer: .b)
// Correct, one point!
Note that the forAnswer
parameter of printResultOf(question:forAnswer:)
has the type Question.Answer
, which is the answer type associated with the concrete type that replaces the Question
placeholder. This gets the Swift type system working for us to guard against using the incorrect answer type for any question. We see below the compile-time error we get if we try to use a true-false answer for a multiple-choice question.
printResultOf(question: multipleChoiceQuestion, forAnswer: TrueFalseAnswer.t)
// Error: Cannot convert value of type 'TrueFalseAnswer' to expected argument type 'MultipleChoiceAnswer'
This error tells us that the type of the argument we have provided for the forAnswer
parameter does not match the Answer
type associated with the type we have used to replace the Question
placeholder. The compiler catches us even if we omit the TrueFalseAnswer
prefix, reminding us that the MultipleChoiceAnswer
type has no member t
.
printResultOf(question: multipleChoiceQuestion, forAnswer: .t)
// Error: Type 'MultipleChoiceAnswer' has no member 't'
A protocol-oriented alternative to associated types
As we have seen in previous sections, using a protocol with an associated type is not the right way to create an abstraction for a domain entity since the protocol type will no longer be a first-class type, making it useless as an abstraction. We also saw how using a protocol-oriented approach can enable us to use protocols with associated types as they were intended to be used, i.e., as generic constraints rather than as first-class types. In this section, we will explore how using a protocol-oriented approach can often allow us to avoid using protocols with associated types altogether.
The key to protocol-oriented thinking is to start not by looking for abstractions for domain entities but by understanding what the entities will do and what algorithms we need. Since Swift protocols enable retroactive modeling, we can start by writing some concrete code to better understand what our code is supposed to do, use that knowledge to create abstractions based on the attributes and algorithms we need, and retroactively model our concrete types to work with these abstractions.
Let’s consider a case where we want to model different kinds of animals and the food they eat. Thinking from an object-oriented perspective, where we would normally start by looking for abstractions for domain entities, we may use an Animal
protocol to model an animal, as shown below, where the associated type Food
creates a placeholder that conforming types will use to specify the type of food that a particular type of animal eats.
protocol Animal {
associatedtype Food
func eat(_: Food)
}
Having seen the limitations of protocols with associated types, however, we know that this approach will land us in trouble when we try to use Animal
as a first-class type, which would be expected of a protocol used to model an abstraction of a domain entity.
var animal: Animal
// Error: Protocol 'Animal' can only be used as a generic constraint because it has Self or Associated type requirements
var animals: [Animal]
// Error: Protocol 'Animal' can only be used as a generic constraint because it has Self or Associated type requirements
As we had noted in a previous section, the fact that we cannot create a variable of type Animal
, or use Animal
as the type of the elements of an array, is not a limitation of Swift. It simply reflects the fact that the Animal
protocol is incomplete and the protocol type does not provide sufficient information about instances of conforming types. For example, if we could create a variable of type Animal
, we would not know what food we can expect a concrete animal instance stored in that variable to eat.
With a protocol-oriented approach, however, we don’t need to think about abstractions at the outset. We can write some concrete code and let it guide us to the abstractions we may need. To demonstrate this, we start by creating two types of food that animals commonly eat.
struct Grass {}
struct Meat {}
Now we can create concrete animal types that eat these types of food.
struct Goat {
func eat(_: Grass) {
print("Eating Grass")
}
}
struct Dog {
func eat(_: Meat) {
print("Eating Meat")
}
}
From the above code, we can see that the characteristic we want animals to have is that they eat, and different types of animals only eat specific types of food. The two cases we have are animals that eat grass and those that eat meat. Here is how we can create abstractions to model these eating traits.
protocol GrassEating {
func eat(_: Grass)
}
protocol MeatEating {
func eat(_: Meat)
}
We can use retroactive modeling to make our concrete animal types conform to the relevant protocols.
extension Goat: GrassEating {}
extension Dog: MeatEating {}
Since the implementations of the eat(_:)
methods follow a standard pattern in the concrete Goat
and Dog
types, we can use protocol extensions to implement these methods for animals that eat grass and meat respectively.
extension GrassEating {
func eat(_: Grass) {
print("Eating Grass")
}
}
extension MeatEating {
func eat(_: Meat) {
print("Eating Meat")
}
}
With these extensions in place, we don’t need to implement the eat(_:)
method in the concrete types and we can rewrite the Goat
and Dog
types as below.
struct Goat: GrassEating {}
struct Dog: MeatEating {}
We can also define other grass-eating and meat-eating animals in the same way.
struct Sheep: GrassEating {}
struct Wolf: MeatEating {}
This gives the concrete animal types the ability to eat their specific types of food and the type system will stop us if we try to make them eat something that they should not.
let goat = Goat()
let sheep = Sheep()
let wolf = Wolf()
let grass = Grass()
let meat = Meat()
goat.eat(grass)
// Eating Grass
sheep.eat(grass)
// Eating Grass
wolf.eat(meat)
// Eating Meat
goat.eat(meat)
// Error: Cannot convert value of type 'Meat' to expected argument type 'Grass'
What is different between the Animal
protocol we had shown earlier and the GrassEating
and MeatEating
protocols is that the protocols we are using now are complete protocols and the protocols types are first-class types which can be used as types of variables and as types of elements of arrays.
let grazer: GrassEating = goat
grazer.eat(grass)
// Eating Grass
let grazers: [GrassEating] = [goat, sheep]
grazers.forEach({ $0.eat(grass) })
// Eating Grass
// Eating Grass
Use of associated types in the standard library
The Swift standard library makes extensive use of associated types in the protocols used to model sequences and collections. These associated types give significant flexibility to conforming types to customize certain aspects of their implementations while remaining within the overall framework defined by the protocol.
The Collection
protocol, for instance, has five associated types:
Element
: Represents the collection’s elementsIndex
: Represents a position in the collectionIndices
: Represents the indices valid for subscripting the collectionIterator
: Provides the collection’s iteration interfaceSubSequence
: Represents a contiguous subrange of the collection’s elements
These associated types give concrete types that adopt the Collection
protocol the flexibility to replace any of the associated type placeholders with appropriate types as required. Array
, Set
and Dictionary
are all collection types in the standard library. Set
and Dictionary
conform to Collection
while Array
conforms to RandomAccessCollection
and MutableCollection
, both of which inherit from Collection
. All three are generic collections but there is a difference in the way they use the Element
associated type defined by the Collection
protocol. Array
uses Element
as its type parameter, meaning the type that replaces the Element
placeholder is decided at the time of instance creation, enabling Array
to be used to create an array of any valid type in Swift.
struct Array<Element>
Set
also uses Element
as the placeholder for the type of elements of each set but, since it needs to test efficiently for membership and ensure that each element appears only once in a set, there is the additional condition that the type of elements should conform to the Hashable
protocol.
struct Set<Element> where Element: Hashable
Dictionary
is a collection of key-value pairs so it redefines the Element
placeholder as a tuple containing a key-value pair, with the condition that only the keys need to conform to the Hashable
protocol.
struct Dictionary<Key, Value> where Key: Hashable
String
is also a collection as it conforms to BidirectionalCollection
and RangeReplaceableCollection
, both of which inherit from Collection
. There is no need for String
to be generic, however, since all strings are collections of characters. Accordingly, the String
type replaces the Element
placeholder with the Character
type and has no type parameter.
If we look further into the Array
and String
types, we see that Array
replaces the SubSequence
placeholder with the ArraySlice
type, which presents a view onto the storage of a larger array, while String
replaces SubSequence
with the Substring
type, which is a slice of a string that shares its storage with the original string. Since arrays are indexed using integers, Array
replaces the Index
placeholder with the Int
type. String
, on the other hand, replaces the Index
placeholder with a nested type String.Index
, which represents a position of a character or code unit in a string. A string in Swift is actually a collection of extended grapheme clusters, represented by the Swift Character
type. Many individual characters can be made up of multiple Unicode scalar values. This is why, unlike Array
, which allows random access to its elements using integer indices and whose count
property has O(1) complexity, Swift has to actually ‘walk’ a string to be able to get to a certain index in the string or to report the length of the string, so the count
property of String
has O(n) complexity.
What is a Self requirement in a protocol
A Self
requirement is a placeholder in a protocol for a type, used as part of the protocol, which is replaced by the concrete type that conforms to the protocol. A Self
requirement can thus be thought of as a special case of an associated type. Like a protocol with an associated type, a protocol with a Self
requirement is also an incomplete protocol since the type to be used in place of the Self
placeholder gets resolved only when the protocol is adopted. The difference is that, while a conforming type can use any type it wants to replace an associated type, any Self
placeholder must be replaced by the confirming type itself.
A protocol-oriented approach to using Self requirements
Thinking back to the animal example we saw in an earlier section, where we modeled the eating characteristic of animals, let’s say we now want to model a different characteristic of animals, i.e., they can mate, but only with other animals of the same type. If we think with an object-oriented mindset, we could use an Animal
protocol to model an abstract animal, as shown below, to reflect the mating characteristic of animals.
protocol Animal {
func mate(with: Self)
}
Note the Self
placeholder for the type of the parameter of the mate(with:)
method. In concrete types that adopt this protocol, this placeholder must be replaced by the concrete type itself, ensuring that each type of animal can mate only with other animals of the same type. The Animal
protocol is thus said to have a Self
requirement.
To demonstrate this, we define a Cow
type to conform to the Animal
protocol, replacing the Self
placeholder with Cow
.
struct Cow: Animal {
func mate(with: Cow) {
print("Mating with another Cow")
}
}
Since mate(with:)
will be implemented similarly in all conforming types, we can use a protocol extension to implement this method.
extension Animal {
func mate(with other: Self) {
print("Mating with another \(type(of: other))")
}
}
Now we can define concrete types conforming to the protocol without having to implement the mate(with:)
method.
struct Cow: Animal {}
struct Deer: Animal {}
As with an associated type, use of a Self
requirement gets the Swift type system working for us, allowing each animal to mate with another animal of the same type, but not with an animal of a different type.
let cow = Cow()
let deer = Deer()
let anotherCow = Cow()
let anotherDeer = Deer()
cow.mate(with: anotherCow)
// Mating with another Cow
deer.mate(with: anotherDeer)
// Mating with another Deer
cow.mate(with: deer)
// Error: Cannot convert value of type 'Deer' to expected argument type 'Cow'
But using a Self
requirement in the Animal
protocol would land us in exactly the same problem that we faced when we used the Food
associated type in an earlier section. Just like an associated type, a Self
requirement in a protocol also renders the protocol unusable as a first-class type.
let animal: Animal = cow
// Error: Protocol 'Animal' can only be used as a generic constraint because it has Self or Associated type requirements
let animals: [Animal] = [cow, deer]
// Error: Protocol 'Animal' can only be used as a generic constraint because it has Self or Associated type requirements
This is because a protocol with a Self
requirement is also an incomplete protocol. Even if we could define a variable of the above protocol type Animal
, how would we use this variable since we cannot know from the protocol with which concrete type of animal this animal can mate.
The solution is to again think in a protocol-oriented way and, rather than using a protocol to create an abstraction for the animals themselves, use a protocol to create an abstraction for what animals can do in this context, i.e., they can mate with other animals of the same type. Using this approach, we use a protocol to create an appropriate trait and a protocol extension to implement the trait.
protocol Mating {
func mate(with: Self)
}
extension Mating {
func mate(with other: Self) {
print("Mating with another \(type(of: other))")
}
}
It is simple to make concrete animal types conform to this protocol. If we don’t have these concrete types, we can declare the conformance when we define the concrete types; if we already have them, we can declare conformance via extensions to the concrete types. Here, we show the former approach, with conformance declared at the time of creating concrete animal types.
struct Cow: Mating {}
struct Deer: Mating {}
This affords us the same benefit of the Swift type system that we had when we gave the Animal
protocol a Self
requirement, i.e., animals are allowed to mate with their own type but not with an animal of another type.
cow.mate(with: anotherCow)
// Mating with another Cow
deer.mate(with: anotherDeer)
// Mating with another Deer
cow.mate(with: deer)
// Error: Cannot convert value of type 'Deer' to expected argument type 'Cow'
Note that the Mating
protocol has a Self
requirement and cannot be used as a first-class type. Unlike the Animal
protocol, however, where not being able to use the protocol type as a first-class type posed a problem because animal is a domain entity, this limitation actually makes sense when we think about the use cases of Mating
. When and why would we want to create a variable of type Mating
or an array with elements of type Mating
. Even if we could do so, how would we use such a variable or such an array since we will not know the concrete type of animals that can mate with an animal instance stored in such a variable or in an element of such an array.
Use of Self requirements in the standard library
Self
requirements are quite common in protocols found in the standard library. Take the Equatable
protocol as an example, which enables two instances of a type to be compared for value equality using the ==
operator and value inequality using !=
operator. The following function is required to conform to the Equatable
protocol.
static func ==(Self, Self) -> Bool
Use of Self
placeholders for the types of both parameters ensures that, in implementations provided by conforming types, both the parameters must be of the conforming type. Consider the case where we have the following Course
type.
struct Course {
var code: Int
var name: String
}
We can make Course
conform to the Equatable
protocol through the following extension, where the ==
function we implement replaces both the Self
placeholders with the Course
type.
extension Course: Equatable {
static func ==(lhs: Course, rhs: Course) -> Bool {
lhs.code == rhs.code && lhs.name == rhs.name
}
}
The above implementation of ==
is shown for illustrative purposes only. In practice, Swift is able to automatically synthesize Equatable
conformance for most structs and enums, provided Equatable
conformance is declared in the type’s original declaration. So we could simply have defined our Course
type as follows to make it conform to Equatable
.
struct Course: Equatable {
var code: Int
var name: String
}
Two other commonly used standard library protocols Comparable
and Hashable
, both of which inherit from Equatable
, also have Self
requirements. Another protocol with Self
requirements that is used often is Numeric
, which provides a basis for arithmetic on scalar values, such as integers and floating-point numbers. The Numeric
protocol can be used to write generic methods that can operate on any numeric type in the standard library. It can also be used as a constraint on type parameters of generic types to ensure that certain type parameters can only be used with numeric types, without having to specify the specific numeric type until the moment of instance creation.
Other advantages of protocol-oriented design
So far in this article, we have seen how protocols can help avoid having to use associated types in many situations while also enabling associated types and Self
requirements to be used effectively where appropriate. The protocol-oriented approach that we have used offers other advantages as well. To see these advantages in action, let’s quickly recap the two design approaches we have used to deal with a domain dealing with animals. In the first approach, we used a protocol to create an abstract Animal
type, with an associated type Food
as a placeholder for the types of food different animals eat. Subsequently, we showed the same Animal
protocol with a Self
requirement to model the characteristic that animals can mate, but only with animals of the same type. If we put these requirements together, the Animal
protocol would look like this.
protocol Animal {
associatedtype Food
func eat(_: Food)
func mate(with: Self)
}
As we saw, this approach creates issues as it renders the Animal
type unusable as a first-class type, making the abstraction quite useless. We then presented a protocol-oriented approach, using protocols as abstractions for what animals can do rather than for the animals themselves. We created the following protocols to model the ability of animals to eat different types of food.
protocol GrassEating {
func eat(_: Grass)
}
protocol MeatEating {
func eat(_: Meat)
}
With this approach, we could use the protocols as first-class types, using the abstractions to represent individual animals and groups of animals according to the food they eat. We then used the same approach to represent the mating trait, with the following protocol.
protocol Mating {
func mate(with: Self)
}
These traits, as we have seen, can be easily added not only to new animal types by declaring conformance to the protocol at the time of defining the concrete types but also to existing animal types through retroactive modeling by declaring conformance to the protocol in extensions to the concrete types.
Now let’s see what other advantages, besides the ones we have already seen, we get with a protocol-oriented design where we use protocols as abstractions for traits rather than as abstractions for domain entities. We will briefly cover two additional benefits of this protocol-oriented approach in the remainder of this article.
1. Dealing with exceptional conditions
If we use a protocol to model a domain entity, as we did with the Animal
protocol, the implicit assumption is that all animals have the same characteristics, i.e., they eat a particular type of food and they mate with their own kind. But what if there are animals that do one but not the other. One example of such an animal is a mule, which is produced by breeding a jack (male donkey) with a mare (female horse). Ignoring for a moment that in our model, where an animal can mate only with its own kind, a mule cannot be born, how would we use a model where every animal must have a mate(with:)
method, to deal with a case where an animal needs to eat but cannot mate.
Such exceptions do occur in real-life domains and using protocols to create abstractions for domain entities can sometimes back us into corners from which there may not be a graceful exit. What would we do if we need to model an animal that needs to eat but cannot mate? Make the mate(with:)
method throw an error to deal with the exceptional case? That would make the API awkward to use at client sites, in particular when the overwhelming majority of concrete animal types will actually not throw an error but client sites will have to incorporate error handling to be able to cater to the few exceptions. Moreover, if the exception is not known during initial design, introducing it later may risk breaking existing client code.
With a protocol-oriented approach, however, we can easily handle such exceptions because we use abstractions to represent the individual traits rather than the animals themselves. If we need to model an animal that does not need one or more of the traits, we simply don’t make that concrete type conform to those traits. We show this by creating two new kinds of animals, both of which eat grass but one of them does not mate.
struct Horse: GrassEating, Mating {}
struct Mule: GrassEating {}
let horse = Horse()
let mule = Mule()
let anotherHorse = Horse()
let anotherMule = Mule()
horse.eat(grass)
// Eating Grass
horse.mate(with: anotherHorse)
// Mating with another Horse
mule.eat(grass)
// Eating Grass
mule.mate(with: anotherMule)
// Error: Value of type 'Mule' has no member 'mate'
This approach obviates the need to anticipate or design for all possible exceptions at the outset. With individual traits represented by separate protocols and concrete types composed using these traits, it is quite easy to deal with exceptions regardless of whether they are known during initial design or present themselves at a later stage.
2. Extending the domain
Another advantage of protocol-oriented design is that it allows us to easily extend the domain. This is because traits modeled using protocols represent certain characteristics or abilities that apply to domain entities. The traits don’t say anything about what the domain entities are and can be used for any domain entity that has the same characteristics or abilities. Let’s consider the eating and mating traits as an example, which we have thus far applied to animals because that was the requirement of the domain at the time. We are free, however, to apply the same traits to any domain entity that needs to have the same characteristic or ability. In this case, for instance, we could easily extend the domain to include humans in the same context, since humans also eat specific types of food and mate only with other humans. As humans have a more diversified palate than animals, we just need to create some new types of food.
struct Vegetable {}
struct Fruit {}
let vegetable = Vegetable()
let fruit = Fruit()
Next, we create traits for eating these new types of food, using protocols and protocol extensions, as we did for other eating traits.
protocol VegetableEating {
func eat(_: Vegetable)
}
extension VegetableEating {
func eat(_: Vegetable) {
print("Eating a Vegetable")
}
}
protocol FruitEating {
func eat(_: Fruit)
}
extension FruitEating {
func eat(_: Fruit) {
print("Eating a Fruit")
}
}
Now we can create a concrete type to model humans.
struct Human: MeatEating, VegetableEating, FruitEating, Mating {}
Note that we have used the newly defined traits VegetableEating
and FruitEating
seamlessly with the traits MeatEating
and Mating
that we had defined for animals.
An instance of the Human
type defined above will automatically get methods to eat the three types of food that we have specified and a method to mate with other humans.
let human = Human()
human.eat(meat)
// Eating Meat
human.eat(vegetable)
// Eating a Vegetable
human.eat(fruit)
// Eating a Fruit
let anotherHuman = Human()
human.mate(with: anotherHuman)
// Mating with another Human
Conclusion
Associated types and Self
requirements are important features of Swift and are used extensively in the standard library. It is crucial to bear in mind, however, that a protocol with an associated type or a Self
requirement is an incomplete protocol and cannot be used as a first-class type. This is why it is not advisable to use such protocols to represent abstractions for domain entities since such abstractions will be unusable. With a protocol-oriented approach, however, where protocols are used to represent things domain entities can do and roles domain entities can play rather than the domain entities themselves, it is possible in many cases to avoid having to use associated types in protocols. This approach also makes it possible to use associated types and Self
requirements in protocols, where appropriate, to leverage the advantages that associated types and Self
requirements offer while avoiding the pitfalls.
Thank you for reading! I always appreciate constructive comments and feedback. Please feel free to leave a comment in the comments section of this post or start a discussion on Twitter.
Maheen says
Brilliant! Always look forward to these posts, hope to see more soon.
Khawer Khaliq says
Thanks. Great to see that you enjoyed the article.
Ivan says
Thanks! Very enlightening article.
Khawer Khaliq says
Thanks Ivan. Happy to see that you found the article useful.
Paul says
This is a brilliantly thorough and insightful article. As the sole iOS developer for a startup, I’ve written 30k lines across 350 swift files (and 90 protocols) this past year. Your article has made it easy for me to understand why some of the protocols in my project have been awkward to work with. Thanks Khawer for writing this and providing me with the thought-process on how to address the issues!
Khawer Khaliq says
Thanks Paul, for your kind words. Really glad that you found the article helpful.
Eric says
Really nice article, which explains the core idea of protocol oriented approach in a simple but effective way. Thanks.
Khawer Khaliq says
Thanks Eric. Really happy that you liked the article.
Ramon says
Good article with solid examples.
I have one question though: In your questions example, how would you then go about creating an array of questions (e.g., to create a quiz of varying question types)? Because when using
[AutoMarkable]
as the array’s type obviously yields the same problem as before. Introducing another protocol or parent struct for this would work, but then you won’t be able to directly call theAutoMarkable
parameters anymore. Introducing an enum with aQuestionType
as a protocol could be a solution, but then you would need to downcast the type before being able to use the concrete questions. Do you have any ideas about this and how to neatly address this issue?Khawer Khaliq says
Hi Ramon,
Thanks for your comment. So glad that you liked the article.
All the approaches that you have indicated will work but, as you have noted, they have their own drawbacks and which approach should be adopted will depend on the context. Unfortunately, to my knowledge, there is no ‘neat’ way to address this issue using a protocol with an associated type.
Thet Tun says
Love it! Been looking for an article like this for quite some time. Not only did I learn more about the limits of protocols in swift language, but also how to properly use the protocol-oriented approach to solve its limitations. Big thanks for the great explanation.
Khawer Khaliq says
Many thanks for your kind remarks. Really glad that you found the article useful.
Olya says
Brilliant. There were ‘a-ha’ moments that I really needed.
Saikumar Kankipati says
Came across this article so late, but nevertheless its a worthy read, as you have emphasized using protocols for designing traits of domain objects but not as abstractions for domain objects clearly with examples, do you have any other article that talks about how design abstractions for domain objects in swift ? a deep dive in that would complete the great points you have discussed here
Khawer Khaliq says
Hi,
Glad to see that you liked the article. Having come from an object-oriented background, I always had trouble with protocols because I was using them to model domain objects, as with classes in object-oriented programming. Using protocols to model traits is a subtle shift in the thinking process, which makes protocols feel more natural and intuitive. Regarding your question on other resources, there is a series of articles by Rob Napier on the same subject. I found these articles very insightful. Hope you do too. Here is a link to the first article: https://robnapier.net/start-with-a-protocol
I have also written an article on retroactive modeling using protocols, which demonstrates some of the same concepts. The essential point is that, unlike object-oriented programming where the conventional wisdom is to start with abstractions, with protocol-oriented programming you can start concrete and add protocols to model domain abstractions as and when required. This has two advantages. First, you don’t use unnecessary abstractions which can make code more complex and often inefficient due to indirection. Second, you avoid getting stuck with abstractions that don’t scale well with your design. With retroactive modeling, you can add and remove abstractions as required, making designs simpler and more flexible and scalable. Here is a link to the article: https://khawerkhaliq.com/blog/swift-protocols-retroactive-modeling/
Manish Nahar says
An excellent explanation for the Associated type. The Associated type is a powerful feature in Swift.
The opaque type, introduced in Swift 5.1, serves as an enhancement to the existing Associated type mechanism. Please extend your article to explain how opaque type helps in solving the problems associated with Associated types