Checking for traits instead of types

In classic OOP, you often create superclasses and subclasses to group together objects with similar capabilities. If you thoroughly modeled a part of felines in the animal kingdom with classes, you'd end up with a diagram that looks like this:

Checking for traits instead of types

If you've ever tried to model more animals, you would know that it's a really complex task because some animals share a whole bunch of traits although they are actually quite far apart from each other in the class diagram.

One example would be that both cats and dogs are typically kept as pets. This means that they should optionally have an owner and maybe a home. But cats and dogs aren't the only animals kept as pets because fish, guinea pigs, rabbits, and even snakes are kept as pets. However, it would be really hard to figure out a sensible way to restructure your class hierarchy in such a way that you don't have to redundantly add owners and homes to every pet in the hierarchy because otherwise it would be impossible to selectively add these properties to the right classes.

This problem gets even worse when you write a function or method that prints a pet's home. You would either have to make that function accept any kind of animal or write a separate implementation of the same function for each type that has the properties you're looking for. Both don't really make sense because you don't want to write the same function over and over again with just a different class for the parameter, and passing an instance of GreatWhiteShark to a function called printHomeAddress doesn't really make a lot of sense either. Of course, the solution to this problem is to use protocols.

In the situation described in the previous section, we're referring to objects just by what they are, not by what they do. We care about the fact that a certain animal is part of a certain family or type, not about whether it lives on land or not. We can't really differentiate between animals that can fly and animals that can't because not all birds can fly. Inheritance simply isn't compatible with this way of thinking. Imagine a definition for a Pigeon struct that looks like this:

struct Pigeon: Bird, FlyingType, OmnivoreType, Domesticatable 

As Pigeon is a struct, we know that Bird isn't a struct or class; it's a protocol that defines a couple of requirements about what it means to be a bird. The Pigeon struct also conforms to the FlyingType, OmnivoreType, and Domesticatable protocols. Each of these protocols tells us something about Pigeon in terms of its capabilities or traits. The definition actually shows us what a pigeon is and does instead of simply communicating that it inherits from a certain type of bird. Setting the Pigeon struct up like this is really powerful; we can refactor the printHomeAddress function now and set it up so it accepts any object that conforms to Domesticatable:

protocol Domesticatable { 
    var homeAddress: String? { get } 
} 
 
func printHomeAddress(animal: Domesticatable) { 
    if let address = animal.homeAddress { 
        print(address) 
    } 
} 

The Domesticatable protocol has a homeAddress property that's optional. Not every animal that can be domesticated actually is. For example, think about the pigeon; some pigeons are kept as pets but most aren't. This also applies to cats and dogs, not every cat or dog has a home.

This approach is very powerful, but shifting your mind from an object-oriented mindset where you think in an inheritance hierarchy to a protocol-oriented mindset where you focus on traits instead of inheritance isn't easy.

Let's expand our example code a bit more by defining OmnivoreType, HerbivoreType, and CarnivoreType. These types will represent the three main types of eaters in the animal kingdom. We'll actually make use of inheritance inside of our protocols because the OmnivoreType is both a HerbivoreType and a CarnivoreType, so we can make the OmnivoreType inherit from both of these protocols:

protocol HerbivoreType { 
    var favoritePlant: String { get } 
} 
 
protocol CarnivoreType { 
    var favoriteMeat: String { get } 
} 
 
protocol OmnivoreType: HerbivoreType, CarnivoreType {} 

Composing two protocols into one like we did in the previous section is really powerful. Imagine if we wrote two new functions, one to print a carnivore's favorite meat and one to print an herbivore's favorite plant. Those functions would look like this:

func printFavoriteMeat(forAnimal animal: CarnivoreType) { 
    print(animal.favoriteMeat) 
} 
 
func printFavoritePlant(forAnimal animal: HerbivoreType) { 
    print(animal.favoritePlant) 
} 

This probably isn't very surprising to you. However, neither of these methods accept an OmnivoreType. This is perfectly fine because the OmnivoreType inherits from the HerbivoreType and CarnivoreType. This actually works just like you're used to in classical object-oriented programming with the main exception being that our OmnivoreType inherits from multiple protocols instead of just one. This means that the printFavoritePlant function accepts a Pigeon instance as its argument because Pigeon confirms to the OmnivoreType, which inherits from the HerbivoreType.

Using protocols to compose your objects like this is really powerful, and it can greatly simplify your code. Instead of thinking about complex inheritance structures, you can compose your objects with protocols that define certain traits. The beauty in this is that it makes defining new objects relatively easy. Imagine that a new type of animal is discovered. One that can fly, swim, and live on the land. This weird new species would be really hard to add to an inheritance-based architecture. When you're using protocols, you could simply add conformance to FlyingType, LandType, and SwimmingType protocols and you're all set. Any methods or functions that require an animal to live on land will happily accept your new animal since it conforms to the LandType protocol.

Getting the hang of this way of thinking isn't simple, it will require quite some practice. But whenever you're getting ready to create a superclass or subclass, ask yourself why. If you're actually trying to encapsulate a certain trait in that class, try using a protocol. This will train you to think differently about your objects, and before you know it, your code is cleaner, more readable, and more flexible using protocols and checking for traits instead of taking action based on what an object is.

As you've seen, a protocol doesn't need to have a lot of requirements, sometimes one or two are enough to convey the right thing. Don't hesitate to create protocols with just a single property or method.

Extending your protocols with default behavior

The previous examples have mainly used variables as the requirements for protocols. One slight downside of protocols is that they can result in a bit of code duplication. For example, any object that is a HerbivoreType will have a favoriteMeat variable. This means that you have to duplicate this variable in every object that conforms to the HerbivoreType. Usually, you would want as little code repetition as possible, and duplicating a variable like this might seem like a step backward.

Even though it's nice if you don't have to declare the same property over and over again, there's a certain danger in not doing this. If your app grows to a large size, you won't remember each and every class, subclass, and superclass all of the time. This means that changing or removing a certain property can have undesired side effects in other classes.

Declaring the same properties on every object that conforms to a certain protocol isn't that big a deal, it usually takes just a few lines of code to do this. However, protocols can also require certain methods to be present on objects that conform to them. Declaring those over and over can be cumbersome, especially if the implementation is generally always the same. Luckily, you can make use of Protocol Extensions to implement a certain degree of default functionality.

To explore protocol extensions, let's move the printHomeAddress function into the Domesticatable protocol so all Domesticatable objects can print their own home address. The first approach we can take is to immediately define the method on a protocol extension:

extension Domesticatable { 
    func printHomeAddress() { 
        if let address = homeAddress { 
            print(address) 
        } 
    } 
} 

By defining the printHomeAddress method in this protocol extension, every object that conforms to Domesticatable has the following method available:

let myPidgeon = Pigeon(favoriteMeat: "Insects", 
                       favoritePlant: "Seeds", 
                       homeAddress: "Leidse plein 12, Amsterdam") 
 
myPidgeon.printHomeAddress() // "Leidse plein 12, Amsterdam" 

This technique is very convenient if you want to implement some default behavior that's associated with a protocol. You didn't even have to add the printHomeAddress method as a requirement to the protocol. However, this approach can give you some strange results if you're not careful. The following snippet illustrates the problem by adding a custom implementation of printHomeAddress to the Pigeon struct:

struct Pigeon: Bird, FlyingType, OmnivoreType, Domesticatable { 
    let favoriteMeat: String 
    let favoritePlant: String 
    let homeAddress: String? 
     
    func printHomeAddress() { 
        if let address = homeAddress { 
            print("address: (address.uppercased())") 
        } 
    } 
} 
 
let myPigeon = Pigeon(favoriteMeat: "Insects", 
                      favoritePlant: "Seeds", 
                      homeAddress: "Leidse plein 12, Amsterdam") 
 
myPigeon.printHomeAddress() // address: Leidse plein 12, Amsterdam 
 
func printAddress(animal: Domesticatable) { 
    animal.printHomeAddress() 
} 
printAddress(animal: myPigeon) // Leidse plein 12, Amsterdam 

When we call myPigeon.printHomeAddress, the custom implementation is executed. However, if we define a function, printAddress, that takes a Domesticatable object as its parameter, the default implementation is used.

This happens because printHomeAddress isn't a requirement of the protocol. Therefore, if you call printHomeAddress on a Domesticatable object, the implementation from the Protocol Extension is used. If you use the exact same snippet as in the preceding section but adapt the protocol as shown in the following code, both calls to printHomeAddress print the same thing, that is, the custom implementation in the Pigeon struct:

protocol Domesticatable { 
    var homeAddress: String? { get } 
     
    func printHomeAddress() 
} 

This behavior is likely to be unexpected in most cases, so it's usually a good idea to define all methods you use in the protocol requirements unless you're absolutely sure you want the behavior we've just explored.

Even though extensions can't hold stored properties, there are situations where you can still add a computed property to a protocol extension in order to avoid duplicating the same variable in multiple places. Let's take a look at an example; the updated code is highlighted:

protocol Domesticatable { 
    var homeAddress: String? { get } 
    var hasHomeAddress: Bool { get } 
     
    func printHomeAddress() 
} 
 
extension Domesticatable { 
    var hasHomeAddress: Bool { 
        return homeAddress != nil 
    } 
     
    func printHomeAddress() { 
        if let address = homeAddress { 
            print(address) 
        } 
    } 
} 

If we want to be able to check whether a Domesticatable has a home address, we can add a requirement for a Bool value, hasHomeAddress. If the homeAddress property is set, hasHomeAddress should be true. Otherwise, it should be false. This property is computed in the protocol extension, so we don't have to add this property to all of our Domesticatable objects. In this case, it makes a lot of sense to use a computed property because the way its value is computed should most likely be the same across all Domesticatable objects.

Implementing a default behavior in protocol extensions makes the protocol-oriented approach we've seen before even more powerful; we can essentially mimic multiple inheritance this way without all the downsides of subclassing. Simply adding conformance to a protocol can add all kinds of functionality to your objects, and if the extensions allow it, you won't need to add anything else. Let's see how we can make the most out of our protocols and extensions by making them generic with associated types.

Improving your protocols with associated types

One more awesome aspect of a protocol-oriented programming is the use of associated types. An associated type is a generic, non-existing type that can be used in your protocol as if it were a type that exists. The real type of this generic is determined by the compiler based on the rest of the context. This description is abstract, and you might not immediately understand why or how an associated type can benefit your protocols. After all, aren't protocols themselves a very flexible way to make several unrelated objects fit a criteria based on the protocols they conform to?

To illustrate and discover the use of associated types, we will expand our animal kingdom a bit. What we'll do is give our herbivores an eat method and an array to keep track of the plants they've eaten, as follows:

protocol HerbivoreType { 
    var plantsEaten: [PlantType] { get set } 
     
    mutating func eat(plant: PlantType) 
} 
 
extension HerbivoreType { 
    mutating func eat(plant: PlantType) { 
        plantsEaten.append(plant) 
    } 
} 

This code looks fine at first sight; an herbivore eats plants, that's established by this protocol. PlantType is a protocol defined as follows:

protocol PlantType { 
    var latinName: String { get } 
} 

Let's define two different plant types and an animal that we'll use to illustrate the problem with the preceding problem:

struct Grass: PlantType{ 
    var latinName = "Poaceae" 
} 
 
struct Pine: PlantType{ 
    var latinName = "Pinus" 
} 
 
struct Cow: HerbivoreType { 
    var plantsEaten = [PlantType]() 
} 

There shouldn't be a big surprise here; let's continue with creating a Cow instance and let's feed it a Pine:

var cow = Cow() 
let pine = Pine() 
cow.eat(plant: pine) 

This doesn't really make sense. Cows don't eat pines, they eat grass! We need some way to limit this cow's food intake because this approach isn't going to work. People can feed our HerbivoreType animals anything that's considered a plant. We need some way to limit the types of food our cows are given, to grass.

The problem we're facing now is that all HerbivoreType animals mainly eat one plant type, and not all plant types are a good fit for all herbivores. This is where associated types are a great solution. An associated type for the HerbivoreType protocol can constrain the PlantType to a single type that is defined by the confirming object. Let's see what this looks like; first, we'll redefine the protocol:

protocol HerbivoreType { 
    associatedtype Plant: PlantType 
     
    var plantsEaten: [Plant] { get set } 
     
    mutating func eat(plant: Plant) 
} 
 
extension HerbivoreType { 
    mutating func eat(plant: Plant) { 
        print("eating a (plant.latinName)") 
        plantsEaten.append(plant) 
    } 
} 

The first highlighted line associates the generic Plant type that doesn't exist to our protocol. We've constrained the Plant, to ensure that it's a PlantType so that only a PlantType is allowed to be used here.

The second highlighted line demonstrates how the Plant associated type is actually is used as a PlantType. The plant type itself is merely an alias for any type that conforms to PlantType and is used as the type of object we use for plantsEaten and the eat methods. Let's redefine our Cow struct to see this associated type in action:

struct Cow: HerbivoreType { 
    var plantsEaten = [Grass]() 
} 

The changed code is highlighted. Instead of making plantsEaten a PlantType array, we've now made it a Grass array. In the protocol and the definition, the type of plant is now Grass. The compiler understands this because we've defined the plantsEaten array as an array of Grass. Let's define a second HerbivoreType that eats a different type of PlantType:

struct Carrot: PlantType { 
    let latinName = "Daucus carota" 
} 
 
struct Rabbit: HerbivoreType { 
    var plantsEaten = [Carrot]() 
} 

If we try to feed our Cow carrots or if we try to feed the Rabbit a Pine, the compiler will throw errors. The reason for this is that the associated type constraint allows us to define the type of Plant in each struct separately.

One side note about associated types is that it's not always possible for the compiler to properly infer the real type for an associated type. In our current example, this would happen if we didn't have the plantsEaten array in the protocol. The solution would be to define a typealias on objects that conform to HerbivoreType so that the compiler understands which type Plant represents:

protocol HerbivoreType { 
    associatedtype Plant: PlantType 
     
    mutating func eat(plant: Plant) 
} 
 
struct Cow: HerbivoreType { 
    typealias Plant = Grass 
} 

Associated types can be really powerful when used correctly, but sometimes using them can also cause you a lot of headaches due to the amount of inferring the compiler has to do. If you forget a few tiny steps, the compiler can easily lose track of what you're trying to do, and the error messages aren't always the clearest messages. Keep this in mind when you're using associated types, and try to make sure that you're as explicit as possible about the type you're looking to be associated. Sometimes adding a typealias to give the compiler a helping hand is better than trying to get the compiler to correctly infer everything on its own.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.145.202.27