100 Days of SwiftUI Checkpoint 7

100 Days of SwiftUI Checkpoint 7

Course Credit: 

This blog post's content is based on the fantastic learning materials from Paul Hudson's 100 Days of SwiftUI course, available for free at Hacking with Swift.


Classes in Swift

Classes are a fundamental building block in Swift, offering a powerful way to organize your code and model real-world objects. While they share some similarities with structs, classes have unique features like inheritance and deinitializers that make them perfect for certain tasks.

1. Creating a Class

A class is a blueprint for creating objects. You can give it properties to store data and methods to perform actions. In this example, we create a Game class with a single property, score. We also use a didSet property observer to run code whenever the score changes.

class Game {
    var score = 0 {
        didSet {
            // This 'didSet' property observer is called automatically whenever the 'score' property changes.
            print("Score is now \(score)")
        }
    }
}

var newGame = Game()
newGame.score += 10

2. Class Inheritance

One of the most powerful features of classes is inheritance, which allows a class to inherit properties and methods from a parent class. This helps you build a clean, hierarchical structure for your code.

In this example, Developer and Manager are subclasses of Employee. They automatically get the hours property and the printSummary() method from their parent class. The Developer class also shows how to override a method to provide a custom implementation.

class Employee {
    let hours: Int

    init(hours: Int) {
        self.hours = hours
    }
    
    func printSummary() {
        print("I work \(hours) hours a day.")
    }
}

class Developer: Employee {
    func work() {
        print("I'm writing code for \(hours) hours.")
    }
    
    override func printSummary() {
        print("I'm a developer who will sometimes work \(hours) hours a day, but other times spend hours arguing about whether code should be indented using tabs or spaces.")
    }
}

class Manager: Employee {
    func work() {
        print("I'm going to meetings for \(hours) hours.")
    }
    // This class does not override 'printSummary()', so it will use the default implementation from the 'Employee' class.
}

let robert = Developer(hours: 8)
let joseph = Manager(hours: 10)
robert.work()
joseph.work()

let novall = Developer(hours: 8)
novall.printSummary()

3. Class Initializers

Initializers are special methods that prepare a new instance of a class for use. When you create a subclass with its own properties, you must first call the parent class's initializer to set up all the inherited properties. This is done using the super.init() call.

class Vehicle {
    let isElectric: Bool

    init(isElectric: Bool) {
        self.isElectric = isElectric
    }
}

class Car: Vehicle {
    let isConvertible: Bool

    init(isElectric: Bool, isConvertible: Bool) {
        self.isConvertible = isConvertible
        // The 'super.init()' call is required to initialize the properties inherited from the parent class (Vehicle).
        super.init(isElectric: isElectric)
    }
}

let teslaX = Car(isElectric: true, isConvertible: false)
print(teslaX.isElectric)
print(teslaX.isConvertible)

4. Copying Classes

Unlike structs, which are copied by value, classes are copied by reference. This means when you copy a class instance, you're just creating another pointer to the same underlying data. Changing the data through one reference will change it for all of them.

class User {
    var username = "Anonymous"
}

var user1 = User()
var user2 = user1 // 'user2' and 'user1' are now two references to the *same* instance.
user2.username = "Taylor"

print(user1.username) // This will print "Taylor" because the change in 'user2' affected the shared instance.
print(user2.username)

// You can create a manual copy method to bypass reference semantics
class NewUser {
    var username = "Anonymous"

    func copy() -> NewUser {
        // This method creates a brand new, independent copy of the NewUser instance.
        let NewUser = NewUser()
        NewUser.username = username
        return NewUser
    }
}

var NewUser1 = NewUser()
print(NewUser1.username)

5. Deinitializers

deinitializer is a special method called deinit that is executed just before a class instance is destroyed and deallocated from memory. It's often used to clean up resources, though in Swift's modern memory management, it's not needed as often as in other languages.

class UserDeinit {
    let id: Int

    init(id: Int) {
        self.id = id
        print("User \(id): I'm alive!")
    }

    deinit {
        // This 'deinit' method is called automatically when an instance of the class is deallocated from memory.
        print("User \(id): I'm dead!")
    }
}

var users = [UserDeinit]()

for i in 1...3 {
    let user = UserDeinit(id: i)
    print("User \(user.id): I'm in control!")
    users.append(user)
}

print("Loop is finished!")
users.removeAll() // This line deallocates the instances, which triggers their 'deinit' methods.
print("Array is clear!")

6. Working with Variables

When you have a class instance created with var, you can change its properties, as well as reassign the variable to a completely new instance.

class UserVar {
    var name = "Hey Paul"
}

var user = UserVar() // 'user' now holds a reference to the first instance of UserVar.
user.name = "Yo Joe"
user = UserVar() // The 'user' variable is reassigned to a *new* instance of UserVar.
print(user.name) // This will print the default "Hey Paul" from the new instance.

7. Checkpoint 7: Putting It All Together

Finally, here's a complete class hierarchy example that combines everything you've learned. It demonstrates inheritance with the Animal class and its subclasses, as well as method overriding in the speak() methods of the different animal types.

class Animal {
    var legs: Int

    init(legs: Int) {
        self.legs = legs
    }
}

class Dog: Animal {
    init() {
        super.init(legs: 4)
    }
    
    func speak() {
        print("Woof!")
    }
}

class Corgi: Dog {
    override func speak() {
        print("The Corgi barks excitedly!")
    }
}

class Poodle: Dog {
    override func speak() {
        print("The Poodle lets out a high-pitched yelp!")
    }
}

class Cat: Animal {
    var isTame: Bool

    init(isTame: Bool) {
        self.isTame = isTame
        super.init(legs: 4) // All cats have 4 legs.
    }

    func speak() {
        print("Meow!")
    }
}

class Persian: Cat {
    init() {
        super.init(isTame: true)
    }

    override func speak() {
        print("The Persian purrs softly.")
    }
}

class Lion: Cat {
    init() {
        super.init(isTame: false)
    }

    override func speak() {
        print("The Lion roars mightily!")
    }
}

let myCorgi = Corgi()
myCorgi.speak()

let myPersian = Persian()
myPersian.speak()
print("Is the Persian tame? \(myPersian.isTame)")

let myLion = Lion()
myLion.speak()
print("Does the Lion have \(myLion.legs) legs? Yes!")

Why Use Classes?

Classes are especially useful when you need to model objects that share data or are part of a hierarchy. They are the ideal choice for frameworks like UIKit because they allow for code reuse and for objects to share a single source of truth.

You should use a class when:

  • You need to use inheritance to create a logical hierarchy of objects.
  • The data you are modeling needs to be shared among multiple parts of your code.
  • You need to use a deinitializer to perform cleanup when an object is destroyed.

For all other cases, you should use a struct. Structs are generally simpler, faster, and safer to work with because they are copied by value, preventing unexpected side effects.

import UIKit

//------ How to create your own classes
class Game {
    var score = 0 {
        didSet {
            // This 'didSet' property observer is called automatically whenever the 'score' property changes.
            print("Score is now \(score)")
        }
    }
}

var newGame = Game()
newGame.score += 10

//------ How to make one class inherit from another
class Employee { //here is an Employee class with one property and an initializer:
    let hours: Int

    init(hours: Int) {
        self.hours = hours
    }
    
    func printSummary() {
        print("I work \(hours) hours a day.")
    }
}

class Developer: Employee { //subclasses of Employee, each of which will gain the hours property and initializer:
    func work() {
        print("I'm writing code for \(hours) hours.")
    }
    
    override func printSummary() {
        print("I'm a developer who will sometimes work \(hours) hours a day, but other times spend hours arguing about whether code should be indented using tabs or spaces.")
    }
}

class Manager: Employee { //subclasses of Employee, each of which will gain the hours property and initializer:
    func work() {
        print("I'm going to meetings for \(hours) hours.")
    }
    // This class does not override 'printSummary()', so it will use the default implementation from the 'Employee' class.
}
let robert = Developer(hours: 8)
let joseph = Manager(hours: 10)
robert.work()
joseph.work()

let novall = Developer(hours: 8)
novall.printSummary() // calls the printSummary() from the class Employee but was overrideed in the class Developer

//------ How to add initializers for classes
class Vehicle {
    let isElectric: Bool

    init(isElectric: Bool) {
        self.isElectric = isElectric
    }
}

class Car: Vehicle {
    let isConvertible: Bool

    init(isElectric: Bool, isConvertible: Bool) {
        self.isConvertible = isConvertible
        // The 'super.init()' call is required to initialize the properties inherited from the parent class (Vehicle).
        super.init(isElectric: isElectric)
    }
}

let teslaX = Car(isElectric: true, isConvertible: false)
print(teslaX.isElectric)
print(teslaX.isConvertible)

//------ How to copy classes
class User {
    var username = "Anonymous"
}

var user1 = User()
var user2 = user1 // 'user2' and 'user1' are now two references to the *same* instance.
user2.username = "Taylor"

print(user1.username) // This will print "Taylor" because the change in 'user2' affected the shared instance.
print(user2.username)

class NewUser {
    var username = "Anonymous"

    func copy() -> NewUser {
        // This method creates a brand new, independent copy of the NewUser instance.
        let NewUser = NewUser()
        NewUser.username = username
        return NewUser
    }
}

var NewUser1 = NewUser()
print(NewUser1.username)


//------ How to create a deinitializer for a class
class UserDeinit {
    let id: Int

    init(id: Int) {
        self.id = id
        print("User \(id): I'm alive!")
    }

    deinit {
        // This 'deinit' method is called automatically when an instance of the class is deallocated from memory.
        print("User \(id): I'm dead!")
    }
}

var users = [UserDeinit]()

for i in 1...3 {
    let user = UserDeinit(id: i)
    print("User \(user.id): I'm in control!")
    users.append(user)
}

print("Loop is finished!")
users.removeAll() // This line deallocates the instances, which triggers their 'deinit' methods.
print("Array is clear!")

//------ How to work with variables inside classes
class UserVar {
    var name = "Hey Paul"
}

var user = UserVar() // 'user' now holds a reference to the first instance of UserVar.
user.name = "Yo Joe"
user = UserVar() // The 'user' variable is reassigned to a *new* instance of UserVar.
print(user.name) // This will print the default "Hey Paul" from the new instance.

/*
 Recap of Classes:
 Here are the key differences between classes and structs:

 * **Inheritance:** Classes can inherit from other classes, gaining their properties and methods. You can also override methods in subclasses or mark a class as `final` to prevent others from subclassing it.
 * **Initializers:** Swift doesn't automatically create a memberwise initializer for classes, so you must write your own. Subclasses are required to call their parent class's initializer.
 * **Reference Semantics:** Copies of a class instance all point to the same data. Changing data in one copy will change it in all of them.
 * **Deinitializers:** Classes can have a deinitializer (`deinit`) that runs when the last reference to an instance is destroyed.
 * **Mutability:** Variable properties within a class instance can be changed even if the instance itself was created with `let` (as a constant).
 */

//------ Checkpoint 7
class Animal {
    var legs: Int

    init(legs: Int) {
        self.legs = legs
    }
}
//------ A subclass of Animal.
class Dog: Animal {
    init() {
        // The Dog's initializer must call the parent's initializer to set the 'legs' property.
        super.init(legs: 4)
    }
    
    func speak() {
        print("Woof!")
    }
}

class Corgi: Dog {
    override func speak() {
        print("The Corgi barks excitedly!")
    }
}

class Poodle: Dog {
    override func speak() {
        print("The Poodle lets out a high-pitched yelp!")
    }
}

//------ A subclass of Animal.
class Cat: Animal {
    var isTame: Bool

    init(isTame: Bool) {
        self.isTame = isTame
        super.init(legs: 4) // All cats have 4 legs.
    }

    func speak() {
        print("Meow!")
    }
}

class Persian: Cat {
    init() {
        super.init(isTame: true)
    }

    override func speak() {
        print("The Persian purrs softly.")
    }
}

class Lion: Cat {
    init() {
        super.init(isTame: false)
    }

    override func speak() {
        print("The Lion roars mightily!")
    }
}

//------ Example usage:
let myCorgi = Corgi()
myCorgi.speak()

let myPersian = Persian()
myPersian.speak()
print("Is the Persian tame? \(myPersian.isTame)")

let myLion = Lion()
myLion.speak()
print("Does the Lion have \(myLion.legs) legs? Yes!")