I mentioned at the beginning of the chapter that protocol-oriented programming is about so much more than just the protocol and that it is a new way of not only writing applications but also thinking about programming. In this section, we will examine the difference between our two designs to see what that statement actually means.
As a developer, our primary goal is always to develop an application that works properly, but we should be focused on writing clean and safe code as well. In this section, we will be talking about clean and safe code a lot, so let's look at what we mean by these terms.
Clean code is code that is very easy to read and understand. It is important to write clean code because any code that we write will need to be maintained by someone and that someone is usually the person who wrote it. There is nothing worse than looking back at code you wrote and not being able to understand what it does. It is also a lot easier to find errors in the code that is clean and easy to understand.
By safe code we mean code that is hard to break. There is nothing more frustrating as a developer than to make a small change in our code and have errors pop up throughout the code base or to have numerous bugs pop up within our application. By writing clean code, our code will be inherently safer because other developers will be able to look at the code and understand exactly what it does.
Now, let's briefly look at the difference between protocols / protocol extensions and superclasses. We will be covering this a lot more in Chapter 4, All about the Protocol, and Chapter 5, Let's Extend Some Types.
In the object-oriented programming example, we created a Drink
superclass from which all of the drink classes were derived. In the protocol-oriented programming example, we used a combination of a protocol and a protocol extension to achieve the same results; however, there are several advantages of using protocols.
To refresh our memory of the two solutions, let's look at the code for both the Drink
superclass and the Drink
protocol and protocol extension. The following code shows the Drink
superclass:
class Drink { var volume: Double var caffeine: Double var temperature: Double var drinkSize: DrinkSize var description: String init(volume: Double, caffeine: Double, temperature: Double, drinkSize: DrinkSize) { self.volume = volume self.caffeine = caffeine self.temperature = temperature self.description = "Drink base class" self.drinkSize = drinkSize } func drinking(amount: Double) { volume -= amount } func temperatureChange(change: Double) { temperature += change } }
The Drink
superclass is a complete type that we can create instances of. This can be a good or a bad thing. There are times, like in this example, when we should not be creating instances of the superclass; we should only be creating instances of the subclasses. For this, we can still use protocols with object-oriented programming; however, we will need to use protocol extensions to add the common functionality that will then lead us down the protocol-oriented programming path.
Now, let's look at how we would use protocol-oriented programming with the Drink
protocol and the Drink
protocol extension:
protocol Drink { var volume: Double {get set} var caffeine: Double {get set} var temperature: Double {get set} var drinkSize: DrinkSize {get set} var description: String {get set} } extension Drink { mutating func drinking(amount: Double) { volume -= amount } mutating func temperatureChange(change: Double) { temperature += change } }
The code in both the solutions is pretty safe and easy to understand. As a personal preference, I like separating the implementation from the definition. Therefore, to me, the protocol / protocol extension code is better, but this really is a matter of preference. However, we will see in the next few pages that the protocol / protocol extension solution as a whole is cleaner and easier to understand.
There are three other advantages that protocols / protocol extensions have over superclasses. The first advantage is that types can conform to multiple protocols; however, they can only have one superclass. What this means is that we can create numerous protocols that add very specific functionality rather than creating a single monolithic superclass. For example, with our Drinks
protocol, we could also create the DietDrink
, SodaDrink
, and EnergyDrink
protocols that contain specific requirements and functionality for these types of drinks. Then, the DietCoke
and CaffeineFreeDietCoke
types would conform to the Drink
, DietDrink
, and SodaDrink
protocols, while the Jolt
structure would conform to the Drink
and EnergyDrink
protocols. With a superclass, we would need to combine the functionality defined in the DietDrink
, SodaDrink
, and EnergyDrink
protocols into the single monolithic superclass.
The second advantage that protocol / protocol extensions have is that we can use protocol extensions to add functionality without needing the original code. What this means is that we can extend any protocol, even the protocols that are a part of the Swift language itself. To add functionality to our superclass, we need to have the original code. We could use extensions to add functionality to a superclass, which means that all the subclass will also inherit that functionality. However, generally we use extensions to add functionality to a specific class rather than adding functionality to a class hierarchy.
The third advantage that protocols / protocol extensions have is that protocols can be adopted by classes, structures, and enumerations, while class hierarchies are restricted to class types. Protocols / protocol extensions give us the option to use value types where appropriate.
The implementation of drink types (the Jolt
and CaffeineFreeDietCoke
types) was significantly different between the object-oriented example and the protocol-oriented example. We will look at the differences between these two examples, but first let's take a look at the code again to remind us about how we implemented the drink types. We will look at how we implemented the drink types in the object-oriented example first:
class Jolt: Drink { init(temperature: Double) { super.init(volume: 23.5, caffeine: 280, temperature: temperature, drinkSize: DrinkSize.Can24) self.description = "Jolt energy drink" } } class CaffeineFreeDietCoke: Drink { init(volume: Double, temperature: Double, drinkSize: DrinkSize) { super.init(volume: volume, caffeine: 0, temperature: temperature, drinkSize: drinkSize) self.description = "Caffeine Free Diet Coke" } }
Both of these classes are subclasses of the Drink
superclass and both implement a single initializer. While these are pretty simple and straightforward implementations, we really need to fully understand what the superclass expects to implement them properly. For example, if we do not fully understand the Drink
superclass, we may forget to set the description properly. In our example, forgetting to set the description may not be that big of an issue, but in more complex types, forgetting to set a property may cause very unexpected behavior. We could prevent these mistakes by setting all the properties in the superclass's initializer; however, this may not be possible in some situations.
Now, let's look at how we implemented the drink types in the protocol-oriented programming example:
struct Jolt: Drink { var volume: Double var caffeine: Double var temperature: Double var drinkSize: DrinkSize var description: String init(temperature: Double) { self.volume = 23.5 self.caffeine = 280 self.temperature = temperature self.description = "Jolt Energy Drink" self.drinkSize = DrinkSize.Can24 } } struct CaffeineFreeDietCoke: Drink { var volume: Double var caffeine: Double var temperature: Double var drinkSize: DrinkSize var description: String init(volume: Double, temperature: Double, drinkSize: DrinkSize) { self.volume = volume self.caffeine = 0 self.temperature = temperature self.description = "Caffiene Free Diet Coke" self.drinkSize = drinkSize } }
Implementing the types in the protocol-oriented programming example takes significantly more code than the object-oriented programming example; however, the code in the protocol-oriented example is a lot safer and easier to understand. The reason we say the protocol-oriented example is safer and easier to understand is how the properties and initializer are implemented in both the examples.
In the object-oriented programming example, all of the properties are defined in the superclass. We will need to look at the code or the documentation for the superclass to see what properties are defined and how they are defined. With protocols, we also need to look at the protocol itself or the documentation for the protocol to see what properties to implement, but the implementation is done in the type itself. This allows us to see how everything is implemented for the type without having to look back at the code for the superclass or having to dig through a complete class hierarchy to see how things are implemented and initialized.
The initializer in the subclass must also call the initializer in the superclass to ensure that all the properties of the superclass are set properly. While this does ensure that we have a consistent initialization between the subclasses, it also hides how the class is initialized. With the protocol example, all the initialization is done within the type itself. Therefore, we do not have to dig through a class hierarchy to see how everything is initialized.
Superclasses in Swift provide an implementation of our requirements. Protocols in Swift are simply a contract that says any type that conforms to a given protocol must fulfill the requirements specified by the protocol. Therefore, with protocols, all of the properties, methods, and initializers are defined in the conforming types themselves. This allows us to very easily see how everything is defined and initialized.
There are several fundamental differences between reference and value types, and we will discuss these in greater detail in Chapter 2, Our Type Choices. Right now, we will focus on one of the main differences between the two types and that is how the types are passed. When we pass an instance of a reference type (class), we are passing a reference to the original instance. This means that any changes made are reflected back to the original instance. When we pass an instance of a value type, we are passing a new copy of the original instance. This means any changes made are not reflected back to the original instance.
As we mentioned earlier, in our example, an instance of the drink types should only have one owner at a time. There should never be a need for multiple parts of our code to interact with a single instance of a drink type. As an example, when we create an instance of a drink type, we will put it in an instance of the cooler type. Then, if a person comes along and removes that instance from the cooler, the person will own that drink instance. If one person gives the drink to another person, then that second person will own the drink.
Using value types ensures that we always get a unique instance since we pass a copy of the original instances rather than a reference to the original instance. Therefore, we can trust that no other part of our code is going to unexpectedly change that instance on us. This is especially helpful with multithreaded environments, where a different thread can alter the data and create unexpected behavior.
We need to make sure that we use value types and reference types appropriately. In this example, the drink types illustrated when value types should be preferred and the Cooler type illustrated when a reference type should be preferred.
In most object-oriented languages, we do not have the option of implementing our custom types as value types. In Swift, classes and structures are much closer in functionality than other languages and we have the option of creating custom types as value types. We just need to make sure we use the appropriate type when we create our custom types. We will discuss these options in greater detail in Chapter 2, Our Type Choices.