15.
Advanced Classes
Written by Eli Ganim
Chapter 14, “Classes”, introduced you to the basics of defining and using classes in Swift. Classes are reference types and can be used to support traditional object-oriented programming.
Classes introduce inheritance, overriding, and polymorphism, making them suited for this purpose. These extra features require special consideration for initialization, class hierarchies, and understanding the class lifecycle in memory.
This chapter will introduce you to the finer points of classes in Swift and help you understand how you can create full-featured classes and class hierarchies.
Introducing Inheritance
In Chapter 14, “Classes”, you saw a Grade struct and a pair of class examples: Person and Student.
struct Grade {
var letter: Character
var points: Double
var credits: Double
}
class Person {
var firstName: String
var lastName: String
init(firstName: String, lastName: String) {
self.firstName = firstName
self.lastName = lastName
}
}
class Student {
var firstName: String
var lastName: String
var grades: [Grade] = []
init(firstName: String, lastName: String) {
self.firstName = firstName
self.lastName = lastName
}
func recordGrade(_ grade: Grade) {
grades.append(grade)
}
}
It’s not difficult to see redundancy between Person and Student. Maybe you’ve also noticed that a Student is a Person! This simple case demonstrates the idea behind class inheritance. Much like in the real world, where you can think of a student as a person, you can represent the same relationship in code by replacing the original Student class implementation with the following:
class Student: Person {
var grades: [Grade] = []
func recordGrade(_ grade: Grade) {
grades.append(grade)
}
}
In this modified example, the Student class now inherits from Person, indicated by a colon after the declaration of Student, followed by the class from which Student inherits, which in this case is Person. Through inheritance, Student automatically gets the properties and methods declared in the Person class. In code, it would be accurate to say that a Student is-a Person.
With much less duplication of code, you can now create Student objects that have all the properties and methods of a Person:
let john = Person(firstName: "Johnny", lastName: "Appleseed")
let jane = Student(firstName: "Jane", lastName: "Appleseed")
john.firstName // "John"
jane.firstName // "Jane"
Additionally, only the Student object will have all of the properties and methods defined in Student:
A class inheriting from another class is known as a subclass or a derived class. The class it inherits is known as a superclass or a base class.
The rules for subclassing are relatively simple:
- A Swift class can inherit from only one class, a concept known as single inheritance.
- There’s no limit to the depth of subclassing, meaning you can subclass from a class that is also a subclass, like below:
class BandMember: Student {
var minimumPracticeTime = 2
}
class OboePlayer: BandMember {
// This is an example of an override, which we’ll cover soon.
override var minimumPracticeTime: Int {
get {
super.minimumPracticeTime * 2
}
set {
super.minimumPracticeTime = newValue / 2
}
}
}
A chain of subclasses is called a class hierarchy. In this example, the hierarchy would be OboePlayer -> BandMember -> Student -> Person. A class hierarchy is analogous to a family tree. Because of this analogy, a superclass is also called the parent class of its child class.
Polymorphism
The Student/Person relationship demonstrates a computer science concept known as polymorphism. In brief, polymorphism is a programming language’s ability to treat an object differently based on context.
An OboePlayer is also a Person. Because it derives from Person, you could use an OboePlayer object anywhere you’d use a Person object.
This example demonstrates how you can treat an OboePlayer as a Person:
func phonebookName(_ person: Person) -> String {
"\(person.lastName), \(person.firstName)"
}
let person = Person(firstName: "Johnny", lastName: "Appleseed")
let oboePlayer = OboePlayer(firstName: "Jane",
lastName: "Appleseed")
phonebookName(person) // Appleseed, Johnny
phonebookName(oboePlayer) // Appleseed, Jane
Because OboePlayer derives from Person, it’s a valid input into the function phonebookName(_:). More importantly, the function has no idea that the object passed in is anything other than a regular Person. It can only observe the elements of OboePlayer that are defined in the Person base class.
With the polymorphism characteristics provided by class inheritance, Swift treats the object referred to by oboePlayer differently based on the context. This distilled behavior can be advantageous when you have many specialized derived types but want to code that operates on a common base class.
Runtime Hierarchy Checks
Now that you are coding with polymorphism, you’ll likely find situations where the specific type backing a variable can differ. For instance, you could define a variable hallMonitor as a Student:
var hallMonitor = Student(firstName: "Jill",
lastName: "Bananapeel")
But what if hallMonitor were a more derived type, such as an OboePlayer?
hallMonitor = oboePlayer
This assignment works because hallMonitor is a Student. However, the compiler won’t allow you to use properties or methods for the more derived type OboePlayer with the hallMonitor instance.
Fortunately, Swift provides the as casting-operator to treat a property or a variable as another type:
-
as: Cast to a type known at compile-time to succeed, such as casting to a supertype. It is guaranteed to succeed. -
as?: An optional downcast (to a subtype). If the downcast fails, the result of the expression will benil. -
as!: A forced downcast. If the downcast fails, the program will halt execution. Use this rarely and only when you are sure the cast will always succeed.
Casts can be used in various contexts to treat the hallMonitor as a BandMember or the oboePlayer as a less-derived Student.
oboePlayer as Student
(oboePlayer as Student).minimumPracticeTime
hallMonitor as? BandMember
(hallMonitor as? BandMember)?.minimumPracticeTime // 4 (optional)
hallMonitor as! BandMember // Careful! Failure would lead to a runtime crash.
(hallMonitor as! BandMember).minimumPracticeTime // 4 (force unwrapped)
The optional downcast as? is particularly useful in if let or guard statements:
if let hallMonitor = hallMonitor as? BandMember {
print("This hall monitor is a band member and practices
at least \(hallMonitor.minimumPracticeTime)
hours per week.")
}
You may wonder under what contexts you would use the as operator by itself. Any object contains all the properties and methods of its parent class, so what use is casting it to something it already is?
Swift has a strong type system, and the interpretation of a specific type can affect static dispatch, aka the process of deciding which operation to use at compile-time.
Sound confusing? Let’s see an example.
Assume you have two functions with identical names and parameter names for two different parameter types:
func afterClassActivity(for student: Student) -> String {
"Goes home!"
}
func afterClassActivity(for student: BandMember) -> String {
"Goes to practice!"
}
If you were to pass oboePlayer into afterClassActivity(for:), which one of these implementations would get called? The answer lies in Swift’s dispatch rules, which will select the more specific version that takes in an OboePlayer.
If, instead, you were to cast oboePlayer to a Student, the Student version would be called:
afterClassActivity(for: oboePlayer) // Goes to practice!
afterClassActivity(for: oboePlayer as Student) // Goes home!
Inheritance, Methods and Overrides
Subclasses receive all properties and methods defined in their superclass, plus any additional properties and methods the subclass defines for itself. In that sense, subclasses are additive.
For example, you saw that the Student class can add additional properties and methods to handle a student’s grades. These properties and methods are available to any Person class instances but fully available to Student subclasses.
Besides creating their own methods, subclasses can override methods defined in their superclass. For another example, assume that student-athletes become ineligible for the athletics program if they fail three or more classes. That means you need to keep track of failing grades somehow, like so:
class StudentAthlete: Student {
var failedClasses: [Grade] = []
override func recordGrade(_ grade: Grade) {
super.recordGrade(grade)
if grade.letter == "F" {
failedClasses.append(grade)
}
}
var isEligible: Bool {
failedClasses.count < 3
}
}
In this example, the StudentAthlete class overrides recordGrade(_:) to keep track of any courses the student has failed. StudentAthlete has isEligible, its own computed property that uses this information to determine the athlete’s eligibility.
When overriding a method, use the override keyword before the method declaration.
If your subclass were to have an identical method declaration as its superclass, but you omitted the override keyword, Swift would emit a compiler error:
This requirement makes it very clear whether a method is an override of an existing one or not.
Introducing Super
You may have also noticed the line super.recordGrade(grade) in the overridden method. The super keyword is similar to self, except it will invoke the method in the nearest implementing superclass. In the example of recordGrade(_:) in StudentAthlete, calling super.recordGrade(grade) will execute the method defined in the Student class.
Remember how inheritance lets you define Person with first name and last name properties and avoid repeating those properties in subclasses? Similarly, calling the superclass methods means you can write the code to record the grade once in Student and then call “up” to it as needed in subclasses.
Although it isn’t always required, it’s often important to call super when overriding a method in Swift. The super call will record the grade in the grades array because that behavior isn’t duplicated in StudentAthlete. Calling super is also a way of avoiding the need for duplicate code in StudentAthlete and Student.
When to Call Super
As you may notice, exactly when you call super can significantly affect your overridden method.
Suppose you replace the overridden recordGrade(_:) method in the StudentAthlete class with the following version that recalculates the failedClasses each time a grade is recorded:
override func recordGrade(_ grade: Grade) {
var newFailedClasses: [Grade] = []
for grade in grades {
if grade.letter == "F" {
newFailedClasses.append(grade)
}
}
failedClasses = newFailedClasses
super.recordGrade(grade)
}
This version of recordGrade(_:) uses the grades array to find the current list of failed classes. If you’ve spotted a bug in the code above, good job! Since you call super last, if the new grade.letter is an F, the code won’t update failedClasses properly.
It’s best practice to call the super version of a method first when overriding. That way, the superclass won’t experience any side effects introduced by its subclass, and the subclass won’t need to know the superclass’s implementation details.
Preventing Inheritance
Sometimes you’ll want to disallow subclasses of a particular class. Swift provides the final keyword for you to guarantee a class will never get a subclass:
By marking the FinalStudent class final, you tell the compiler to prevent any classes from inheriting from FinalStudent. This requirement can remind you — or others on your team! — that a class wasn’t designed to have subclasses.
Additionally, you can mark individual methods as final if you want to allow a class to have subclasses but protect individual methods from being overridden:
class AnotherStudent: Person {
final func recordGrade(_ grade: Grade) {}
}
There are benefits to initially marking any new class you write as final. This keyword tells the compiler it doesn’t need to look for any more subclasses, which can shorten compile time, and it also requires you to be very explicit when deciding to subclass a class previously marked final.
Inheritance and Class Initialization
Chapter 14, “Classes”, briefly introduced you to class initializers, which are similar to their struct counterparts. With subclasses, there are a few more considerations about setting up instances.
Note: In the chapter’s playground, I have renamed
StudentandStudentAthletetoNewStudentandNewStudentAthleteto keep both versions working side-by-side.
Modify the StudentAthlete class to add a list of sports an athlete plays:
class StudentAthlete: Student {
var sports: [String]
// original code
}
Because sports doesn’t have an initial value, StudentAthlete must provide one in its initializer:
class StudentAthlete: Student {
var sports: [String]
init(sports: [String]) {
self.sports = sports
// Build error - super.init isn’t called before
// returning from initializer
}
// original code
}
Uh-oh! The compiler complains that you didn’t call super.init by the end of the initializer:
Initializers in subclasses are required to call super.init because, without it, the superclass won’t be able to provide initial states for all its stored properties — in this case, firstName and lastName.
Let’s make the compiler happy:
class StudentAthlete: Student {
var sports: [String]
init(firstName: String, lastName: String, sports: [String]) {
self.sports = sports
super.init(firstName: firstName, lastName: lastName)
}
// original code
}
The initializer now calls the initializer of its superclass, and the build error is gone.
Notice that the initializer now takes in a firstName and a lastName to call the Person initializer.
You also call super.init after you initialize the sports property, an enforced rule.
Two-Phase Initialization
Because Swift requires that all stored properties have initial values, initializers in subclasses must adhere to Swift’s convention of two-phase initialization.
-
Phase one: Initialize all of the stored properties in the class instance, from the bottom to the top of the class hierarchy. You can’t use properties and methods until phase one is complete.
-
Phase two: You can now use properties, methods and initializations that require the use of
self.
Without two-phase initialization, methods and operations on the class might interact with properties before they’ve been initialized.
The transition from phase one to phase two happens after you’ve initialized all stored properties in the base class of a class hierarchy.
In the scope of a subclass initializer, you can think of this as coming after the call to super.init.
Here’s the StudentAthlete class again, with athletes automatically getting a starter grade:
class StudentAthlete: Student {
var sports: [String]
init(firstName: String, lastName: String, sports: [String]) {
// 1
self.sports = sports
// 2
let passGrade = Grade(letter: "P", points: 0.0,
credits: 0.0)
// 3
super.init(firstName: firstName, lastName: lastName)
// 4
recordGrade(passGrade)
}
// original code
}
The above initializer shows two-phase initialization in action.
-
First, you initialize the
sportsproperty ofStudentAthlete. This is part of the first initialization phase and must be done before you call the superclass initializer. -
Although you can create local variables for things like grades, you can’t call
recordGrade(_:)yet because the object is still in the first phase. -
Call
super.init. When this returns, you know that you’ve also initialized every class in the hierarchy because the same rules apply at every level. -
After
super.initreturns, the initializer is in phase 2, so you callrecordGrade(_:).
Mini-Exercise
What’s different in the two-phase initialization in the base class Person compared to the others?
Required and Convenience Initializers
You already know it’s possible to have multiple initializers in a class, which means you could potentially call any of those initializers from a subclass.
Often, you’ll find that your classes have various initializers that simply provide a “convenient” way to initialize an object:
class Student {
let firstName: String
let lastName: String
var grades: [Grade] = []
init(firstName: String, lastName: String) {
self.firstName = firstName
self.lastName = lastName
}
init(transfer: Student) {
self.firstName = transfer.firstName
self.lastName = transfer.lastName
}
func recordGrade(_ grade: Grade) {
grades.append(grade)
}
}
In this example, the Student class can be built with another Student object. The student may come from another school. Both initializers fully set the first and last names.
Subclasses of Student could potentially rely on the Student-based initializer when they call super.init. Subclasses might not even provide a method to initialize with first and last names.
You might decide the first and last name-based initializer is important enough that you want it to be available to all subclasses.
Swift supports this through the language feature known as required initializers.
class Student {
let firstName: String
let lastName: String
var grades: [Grade] = []
required init(firstName: String, lastName: String) {
self.firstName = firstName
self.lastName = lastName
}
// original code
}
In the modified version of Student above, the first and last name-based initializer has been marked with the keyword required. This keyword will force all subclasses of Student to implement this initializer.
Now that there’s a required initializer on Student, StudentAthlete must override and implement it.
class StudentAthlete: Student {
// Now required by the compiler!
required init(firstName: String, lastName: String) {
self.sports = []
super.init(firstName: firstName, lastName: lastName)
}
// original code
}
Notice how the override keyword isn’t needed with required initializers. In its place, the required keyword must be used to ensure that any subclass of StudentAthlete implements this required initializer.
You can also mark an initializer as a convenience initializer:
class Student {
convenience init(transfer: Student) {
self.init(firstName: transfer.firstName,
lastName: transfer.lastName)
}
// original code
}
The compiler forces a convenience initializer to call a non-convenience initializer (directly or indirectly) instead of handling the initialization of stored properties itself. A non-convenience initializer is called a designated initializer and is subject to the rules of two-phase initialization. All initializers you’ve written in previous examples were, in fact, designated initializers.
You might want to mark an initializer as convenience if you only use it as an easy way to initialize an object. However, you still want it to leverage one of your designated initializers.
Here’s a summary of the compiler rules for using designated and convenience initializers:
- A designated initializer must call a designated initializer from its immediate superclass.
- A convenience initializer must call another initializer from the same class.
- A convenience initializer must ultimately call a designated initializer.
Mini-Exercise
Create two more convenience initializers on Student. Which other initializers are you able to call?
When and Why to Subclass
This chapter has introduced you to class inheritance and the numerous programming techniques that subclassing enables.
But you might be asking, “When should I subclass?”
Rarely is there a right or wrong answer, so you need an understanding of the trade-offs to make an informed decision for a particular case.
Using the Student and StudentAthlete classes as an example, you might decide you can simply put all of the characteristics of StudentAthlete into Student:
class Student: Person {
var grades: [Grade]
var sports: [Sport]
// original code
}
In reality, this could solve all of the use cases for your needs. A Student that doesn’t play sports would simply have an empty sports array, and you would avoid some of the added complexities of subclassing.
Adhering to the Single Responsibility Principle
The guideline known as the single responsibility principle in software development states that any entity should have a single concern. Having more components with a single responsibility makes mixing and matching (composing) your components easier to build up functionality. When it comes time to change and add features, it is easier to augment your system when everything has a single, well-understood job.
This principle is true for object-oriented design. For example, in Student/StudentAthlete, you might argue that it shouldn’t be the Student class’s job to encapsulate responsibilities that only make sense to student-athletes. That way, if you later need to support students in student government, you can do so without worrying about their athletic standing.
Leveraging Strong Types
Subclassing creates an additional type. With Swift’s type system, you can declare properties or behavior based on objects that are student-athletes, not regular students:
class Team {
var players: [StudentAthlete] = []
var isEligible: Bool {
for player in players {
if !player.isEligible {
return false
}
}
return true
}
}
A team has players who are student-athletes. If you tried to add a regular Student object to the array of players, the type system wouldn’t allow it. This new type is helpful as the compiler can help you enforce the logic and requirement of your system.
Note: This is also where single-inheritance classes in Swift fall a bit short. This design might not work if you later added a
StudentPresidenttype, but the student president was on the track team for one year. To overcome this limitation, Swift also comes with protocol inheritance which effectively allows multiple inheritances. You will learn about this in Chapter 17 - “Protocols”.
Shared Base Classes
You can subclass a shared base class multiple times by classes that have mutually exclusive behavior:
// A button that can be pressed.
class Button {
func press() {}
}
// An image that can be rendered on a button
class Image {}
// A button that is composed entirely of an image.
class ImageButton: Button {
var image: Image
init(image: Image) {
self.image = image
}
}
// A button that renders as text.
class TextButton: Button {
var text: String
init(text: String) {
self.text = text
}
}
In this example, you can imagine numerous Button subclasses sharing only that they can be pressed. The ImageButton and TextButton classes likely use different mechanisms to render a given button, so they might have to implement their own behavior to handle presses. You can see here how storing image and text in the Button class — not to mention any other kind of button there might be — would quickly become impractical. It makes sense for Button to be concerned with the press behavior and the subclasses to handle the actual look and feel of the button.
Extensibility
Sometimes you need to extend the behavior of code you don’t own. In the example above, it’s possible Button is part of an external framework you’re using, so there’s no way you can modify the source code to fit your specific case.
But you can subclass Button and add your custom subclass to use with code that’s expecting an object of type Button.
Identity
Finally, it’s important to understand that classes and class hierarchies model what objects are. If your goal is to share behavior (what objects can do) between types, more often than not, you should prefer protocols over subclassing. Again, you’ll learn about protocols in Chapter 17, “Protocols”.
Understanding the Class Lifecycle
In Chapter 14, “Classes”, you learned that objects are created in memory and stored on the heap. Objects on the heap are not automatically destroyed because the heap is simply a giant pool of memory. Without the utility of the call stack, there’s no automatic way for a process to know that a piece of memory will no longer be in use.
In Swift, the mechanism for deciding when to clean up unused objects on the heap is known as reference counting. Each object has a reference count incremented for each constant or variable with a reference to that object and decremented each time a reference is removed.
Note: You might see the reference count called a “retain count” in other books and online resources. They refer to the same thing!
The object is abandoned when a reference count reaches zero since nothing in the system holds a reference to it. When that happens, Swift will clean up the object.
Here’s a demonstration of how the reference count changes for an object. Note that only one actual object is created in this example; the one object has many references to it.
var someone = Person(firstName: "Johnny", lastName: "Appleseed")
// Person object has a reference count of 1 (someone variable)
var anotherSomeone: Person? = someone
// Reference count 2 (someone, anotherSomeone)
var lotsOfPeople = [someone, someone, anotherSomeone, someone]
// Reference count 6 (someone, anotherSomeone, 4 references in lotsOfPeople)
anotherSomeone = nil
// Reference count 5 (someone, 4 references in lotsOfPeople)
lotsOfPeople = []
// Reference count 1 (someone)
Now we create another object and replace someone with that reference.
someone = Person(firstName: "Johnny", lastName: "Appleseed")
// Reference count 0 for the original Person object!
// Variable someone now references a new object
In this example, you don’t have to do any work yourself to increase or decrease the object’s reference count. That’s because Swift has a feature known as automatic reference counting or ARC. While some older languages require you to increment and decrement reference counts in your code, the Swift compiler adds these calls automatically at compile-time.
Note: If you use a low-level language like C, you’re required to manually free memory you’re no longer using yourself. Higher-level languages like Java and C# use something called garbage collection. In that case, the language’s runtime will search your process for references to objects before cleaning up those no longer in use. While more automatic and behind the scenes than ARC, Garbage collection comes with a memory utilization and performance cost that Apple decided wasn’t acceptable for mobile devices or a general systems language.
Deinitialization
Swift removes the object from memory and marks that memory as free when an object’s reference count reaches zero.
A deinitializer is a special method on classes that runs when an object’s reference count reaches zero but before Swift removes the object from memory.
Modify Person as follows:
class Person {
// original code
deinit {
print("\(firstName) \(lastName) is being removed
from memory!")
}
}
Much like init is a special method in class initialization, deinit is a special method that handles deinitialization. Unlike init, deinit isn’t required and is automatically invoked by Swift. You also aren’t required to override it or call super within it. Swift will make sure to call each class deinitializer.
If you add this deinitializer, you’ll see the message Johnny Appleseed is being removed from memory! in the debug area after running the previous example.
What you do in a deinitializer is up to you. Often you’ll use it to clean up other resources, save state to a disk or execute any other logic you might want when an object goes out of scope.
Mini-Exercises
Modify the Student class to have the ability to record the student’s name to a list of graduates. Add the student’s name to the list when the object is deallocated.
Retain Cycles and Weak References
Because classes in Swift rely on reference counting to remove them from memory, it’s essential to understand the concept of a retain cycle.
Add a field representing a classmate — for example, a lab partner — and a deinitializer to class Student like this:
class Student: Person {
var partner: Student?
// original code
deinit {
print("\(firstName) is being deallocated!")
}
}
var alice: Student? = Student(firstName: "Alice",
lastName: "Appleseed")
var bob: Student? = Student(firstName: "Bob",
lastName: "Appleseed")
alice?.partner = bob
bob?.partner = alice
Now suppose both alice and bob drop out of school:
alice = nil
bob = nil
If you run this in your playground, you’ll notice that you don’t see the message Alice/Bob is being deallocated!, and Swift doesn’t call deinit. Why is that?
Alice and Bob each have a reference to each other, so the reference count never reaches zero! To make things worse, by assigning nil to alice and bob, there are no more references to the initial objects. This situation is a classic case of a retain cycle, which leads to a software bug known as a memory leak.
With a memory leak, memory isn’t freed up even though its practical lifecycle has ended. Retain cycles are the most common cause of memory leaks. Fortunately, there’s a way that the Student object can reference another Student without being prone to retain cycles, and that’s by making the reference weak:
class Student: Person {
weak var partner: Student?
// original code
}
This simple modification marks the partner variable as weak, which means the reference in this variable will not take part in reference counting. When a reference isn’t weak, it’s called a strong reference, which is the default in Swift. Weak references must be declared as optional types so that when the object they are referencing is released, it automatically becomes nil.
Challenges
Before moving on, here are some challenges to test your advanced class knowledge. It’s best to try and solve them yourself, but solutions are available if you get stuck. These came with the download or are available at the printed book’s source code link listed in the introduction.
Challenge 1: Initialization Order
Create three simple classes called A, B, and C where C inherits from B and B inherits from A. In each class initializer, call print("I’m <X>!") both before and after super.init(). Create an instance of C called c. What order do you see each print() called in?
Challenge 2: Deinitialization Order
Implement deinit for each class. Create your instance c inside a do { } scope, causing the reference count to go to zero when it exits the scope. Which order do the classes deinitialize?
Challenge 3: Type Casting
Cast the instance of type C to an instance of type A. Which casting operation do you use and why?
Challenge 4: To Subclass or Not
Create a subclass of StudentAthlete called StudentBaseballPlayer and include properties for position, number, and battingAverage. What are the benefits and drawbacks of subclassing StudentAthlete in this scenario?
Key Points
- Class inheritance is a feature of classes that enables polymorphism.
- Subclassing is a powerful tool, but it’s good to know when to subclass. Subclass when you want to extend an object and could benefit from an “is-a” relationship between subclass and superclass, but be mindful of the inherited state and deep class hierarchies.
- The keyword
overridemakes it clear when you are overriding a method in a subclass. - The keyword
finalprevents a class from being subclassed. - Swift classes use two-phase initialization as a safety measure to ensure all stored properties are initialized before they are used.
- Class instances have lifecycles which their reference counts control.
- Automatic reference counting, or ARC, handles reference counting for you automatically, but it’s essential to watch out for retain cycles.