Checking for traits instead of types

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

If you try to model more animals, you will find that it's a complex task because some animals share a whole bunch of traits, although they are 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. It would be tough 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 it would be impossible to add these properties to the right classes selectively.

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 animal or write a separate implementation of the same function for each type that has the properties you're looking for. Both don't make sense because you don't want to write the same function over and over again with just a different class for the parameter. Even if you choose to do this and you end up with a method that prints an animals home address that accepts a Fish instance, passing an instance of GreatWhiteShark to a function called printHomeAddress() doesn't make a lot of sense either, because sharks typically don't have home addresses. Of course, the solution to this problem is to use protocols.

In the situation described in the previous section, objects were mostly defined by what they are, not by what they do. We care about the fact that an animal is part of a particular family or type, not about whether it lives on land. You can't differentiate between animals that can fly and animals that can't because not all birds can fly. Inheritance 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 

Since Pigeon is a struct, you 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 you something about Pigeon regarding its capabilities or traits. The definition explains what a pigeon is and does instead of merely communicating that it inherits from a certain type of bird. For instance, almost all birds can fly, but there are some exceptions to the rule. You could model this with classes, but this approach is tedious and might be inflexible, depending on your needs and how your code evolves over time. Setting the Pigeon struct up with protocols is powerful; you can now write a printHomeAddress() function and set it up so that 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 requires an optional homeAddress property. 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, because not every cat or dog has a home.

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

Let's expand the 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. You can make use of inheritance inside of these protocols because OmnivoreType is both HerbivoreType and CarnivoreType, so you can make 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 you did in the preceding example is really powerful, but be careful when you do this. You don't want to create a crazy inheritance graph like you would when you do OOP; you just learned that inheritance could be wildly complex and inflexible. Imagine writing two new functions, one to print a carnivore's favorite meat and one to print a 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)
} 

The preceding code might be exactly what you would write yourself. However, neither of these methods accepts OmnivoreType. This is perfectly fine because OmnivoreType inherits from HerbivoreType and CarnivoreType. This works in the same way that you're used to in classical object-oriented programming, with the main exception being that 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 OmnivoreType, which inherits from HerbivoreType.

Using protocols to compose your objects like this can drastically simplify your code. Instead of thinking about complex inheritance structures, you can compose your objects with protocols that define certain traits. The beauty of 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 lives on land. This weird new species would be really hard to add to an inheritance-based architecture since it doesn't fit in with other animals. When using protocols, you could add conformance to the FlyingType, LandType, and SwimmingType protocols and you'd be all set. Any methods or functions that take a LandType animal as an argument will happily accept your new animal since it conforms to the LandType protocol.

Getting the hang of this way of thinking isn't simple, and it will require some practice. But any time you're getting ready to create a superclass or subclass, ask yourself why. If you're 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 actions 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 meaning. Don't hesitate to create protocols with just a single property or method; as your projects grow over time and your requirements change, you will thank yourself for doing so.

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

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