Chapters

Hide chapters

Swift Apprentice: Fundamentals

First Edition · iOS 16 · Swift 5.7 · Xcode 14.2

Section III: Building Your Own Types

Section 3: 9 chapters
Show chapters Hide chapters

15. Advanced Classes
Written by Eli Ganim

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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.

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

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")
hallMonitor = oboePlayer
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)
if let hallMonitor = hallMonitor as? BandMember {
  print("This hall monitor is a band member and practices
         at least \(hallMonitor.minimumPracticeTime)
         hours per week.")
}
func afterClassActivity(for student: Student) -> String {
  "Goes home!"
}

func afterClassActivity(for student: BandMember) -> String {
  "Goes to practice!"
}
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.

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
  }
}

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.

When to Call Super

As you may notice, exactly when you call super can significantly affect your overridden method.

override func recordGrade(_ grade: Grade) {
  var newFailedClasses: [Grade] = []
  for grade in grades {
    if grade.letter == "F" {
      newFailedClasses.append(grade)
    }
  }
  failedClasses = newFailedClasses

  super.recordGrade(grade)
}

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:

class AnotherStudent: Person {
  final func recordGrade(_ grade: Grade) {}
}

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.

class StudentAthlete: Student {
  var sports: [String]
  // original code
}
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
}

class StudentAthlete: Student {
  var sports: [String]

  init(firstName: String, lastName: String, sports: [String]) {
    self.sports = sports
    super.init(firstName: firstName, lastName: lastName)
  }
  // original code
}

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.

Nutyay Dgadads NzatawzUkdriyi Holhuht bzomb ij yeomarnwy iy ezizaotebez tuzubrCnega(wakrVqowi) liyb.rnoyfd = gguwys nigaf.irar(...) yezag.akot(...) kepb.jumnrTeke = kascrPupe Kbebo 7 Zgiyi 4 (urrovf ob ixibainudak) (zawx bay fi izin) tetd.tonwSoti = zigdPaji vaqq.gkonux = [] Cbuli 6 (geyj qaq be ivak) Ssuno 2 (ebpesr op exebeohenin)

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
}

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.

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)
  }
}
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
}
class StudentAthlete: Student {
  // Now required by the compiler!
  required init(firstName: String, lastName: String) {
    self.sports = []
    super.init(firstName: firstName, lastName: lastName)
  }
  // original code
}
class Student {
  convenience init(transfer: Student) {
    self.init(firstName: transfer.firstName,
              lastName: transfer.lastName)
  }
  // original code
}

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.

class Student: Person {
  var grades: [Grade]
  var sports: [Sport]
  // original code
}

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.

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
  }
}

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
  }
}

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.

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.

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)
someone = Person(firstName: "Johnny", lastName: "Appleseed")
// Reference count 0 for the original Person object!
// Variable someone now references a new object

Deinitialization

Swift removes the object from memory and marks that memory as free when an object’s reference count reaches zero.

class Person {
  // original code
  deinit {
    print("\(firstName) \(lastName) is being removed
          from memory!")
  }
}

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.

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
alice = nil
bob = nil
class Student: Person {
  weak var partner: Student?
  // original code
}

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 override makes it clear when you are overriding a method in a subclass.
  • The keyword final prevents 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.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now