Day 12
Day 12 êŽë š
Classes
At first, classes seem very similar to structs because we use them to create new data types with properties and methods. However, they introduce a new, important, and complex feature called inheritance â the ability to make one class build on the foundations of another.
This is a powerful feature, thereâs no doubt about it, and there is no way to avoid using classes when you start building real iOS apps. But please remember to keep your code simple: just because a feature exists, it doesnât mean you need to use it. As Martin Fowler wrote, âany fool can write code that a computer can understand, but good programmers write code that humans can understand.â
Iâve already said that SwiftUI uses structs extensively for its UI design. Well, it uses classes extensively for its data: when you show data from some object on the screen, or when you pass data between your layouts, youâll usually be using classes.
I should add: if youâve ever worked with UIKit before, this will be a remarkable turnaround for you â in UIKit we normally use classes for UI design and structs for data. So, if you thought perhaps you could skip a day here and there Iâm sorry to say that you canât: this is all required.
Today you have six tutorials to work through, and youâll meet classes, inheritance, deinitializers, and more. Once youâve watched each video and completed any optional extra reading you wanted, there are short tests to help make sure youâve understood what was taught.
1. How to create your own classes
1. How to create your own classes
Swift uses structs for storing most of its data types, including String
, Int
, Double
, and Array
, but there is another way to create custom data types called classes. These have many things in common with structs, but are different in key places.
First, the things that classes and structs have in common include:
- You get to create and name them.
- You can add properties and methods, including property observers and access control.
- You can create custom initializers to configure new instances however you want.
However, classes differ from structs in five key places:
- You can make one class build upon functionality in another class, gaining all its properties and methods as a starting point. If you want to selectively override some methods, you can do that too.
- Because of that first point, Swift wonât automatically generate a memberwise initializer for classes. This means you either need to write your own initializer, or assign default values to all your properties.
- When you copy an instance of a class, both copies share the same data â if you change one copy, the other one also changes.
- When the final copy of a class instance is destroyed, Swift can optionally run a special function called a deinitializer.
- Even if you make a class constant, you can still change its properties as long as they are variables.
On the surface those probably seem fairly random, and thereâs a good chance youâre probably wondering why classes are even needed when we already have structs.
However, SwiftUI uses classes extensively, mainly for point 3: all copies of a class share the same data. This means many parts of your app can share the same information, so that if the user changed their name in one screen all the other screens would automatically update to reflect that change.
The other points matter, but are of varying use:
- Being able to build one class based on another is really important in Appleâs older UI framework, UIKit, but is much less common in SwiftUI apps. In UIKit it was common to have long class hierarchies, where class A was built on class B, which was built on class C, which was built on class D, etc.
- Lacking a memberwise initializer is annoying, but hopefully you can see why it would be tricky to implement given that one class can be based upon another â if class C added an extra property it would break all the initializers for C, B, and A.
- Being able to change a constant classâs variables links in to the multiple copy behavior of classes: a constant class means we canât change what pot our copy points to, but if the properties are variable we can still change the data inside the pot. This is different from structs, where each copy of a struct is unique and holds its own data.
- Because one instance of a class can be referenced in several places, it becomes important to know when the final copy has been destroyed. Thatâs where the deinitializer comes in: it allows us to clean up any special resources we allocated when that last copy goes away.
Before weâre done, letâs look at just a tiny slice of code that creates and uses a class:
class Game {
var score = 0 {
didSet {
print("Score is now \(score)")
}
}
}
var newGame = Game()
newGame.score += 10
Yes, the only difference between that and a struct is that it was created using class
rather than struct
â everything else is identical. That might make classes seem redundant, but trust me: all five of their differences are important.
Iâll be going into more detail on the five differences between classes and structs in the following chapters, but right now the most important thing to know is this: structs are important, and so are classes â you will need both when using SwiftUI.
1. How to create your own classes - Additional
2. How to make one class inherit from another
2. How to make one class inherit from another
Swift lets us create classes by basing them on existing classes, which is a process known as inheritance. When one class inherits functionality from another class (its âparentâ or âsuperâ class), Swift will give the new class access (the âchild classâ or âsubclassâ) to the properties and methods from that parent class, allowing us to make small additions or changes to customize the way the new class behaves.
To make one class inherit from another, write a colon after the child classâs name, then add the parent classâs name. For example, here is an Employee
class with one property and an initializer:
class Employee {
let hours: Int
init(hours: Int) {
self.hours = hours
}
}
We could make two subclasses of Employee
, each of which will gain the hours
property and initializer:
class Developer: Employee {
func work() {
print("I'm writing code for \(hours) hours.")
}
}
class Manager: Employee {
func work() {
print("I'm going to meetings for \(hours) hours.")
}
}
Notice how those two child classes can refer directly to hours
â itâs as if they added that property themselves, except we donât have to keep repeating ourselves.
Each of those classes inherit from Employee
, but each then adds their own customization. So, if we create an instance of each and call work()
, weâll get a different result:
let robert = Developer(hours: 8)
let joseph = Manager(hours: 10)
robert.work()
joseph.work()
As well as sharing properties, you can also share methods, which can then be called from the child classes. As an example, try adding this to the Employee
class:
func printSummary() {
print("I work \(hours) hours a day.")
}
Because Developer
inherits from Employee
, we can immediately start calling printSummary()
on instances of Developer
, like this:
let novall = Developer(hours: 8)
novall.printSummary()
Things get a little more complicated when you want to change a method you inherited. For example, we just put printSummary()
into Employee
, but maybe one of those child classes wants slightly different behavior.
This is where Swift enforces a simple rule: if a child class wants to change a method from a parent class, you must use override
in the child classâs version. This does two things:
- If you attempt to change a method without using
override
, Swift will refuse to build your code. This stops you accidentally overriding a method. - If you use
override
but your method doesnât actually override something from the parent class, Swift will refuse to build your code because you probably made a mistake.
So, if we wanted developers to have a unique printSummary()
method, weâd add this to the Developer
class:
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.")
}
Swift is smart about how method overrides work: if your parent class has a work()
method that returns nothing, but the child class has a work()
method that accepts a string to designate where the work is being done, that does not require override
because you arenât replacing the parent method.
Tip: If you know for sure that your class should not support inheritance, you can mark it as final
. This means the class itself can inherit from other things, but canât be used to inherit from â no child class can use a final class as its parent.
2. How to make one class inherit from another - Additional
3. How to add initializers for classes
3. How to add initializers for classes
Class initializers in Swift are more complicated than struct initializers, but with a little cherrypicking we can focus on the part that really matters: if a child class has any custom initializers, it must always call the parentâs initializer after it has finished setting up its own properties, if it has any.
Like I said previously, Swift wonât automatically generate a memberwise initializer for classes. This applies with or without inheritance happening â it will never generate a memberwise initializer for you. So, you either need to write your own initializer, or provide default values for all the properties of the class.
Letâs start by defining a new class:
class Vehicle {
let isElectric: Bool
init(isElectric: Bool) {
self.isElectric = isElectric
}
}
That has a single Boolean property, plus an initializer to set the value for that property. Remember, using self
here makes it clear weâre assigning the isElectric
parameter to the property of the same name.
Now, letâs say we wanted to make a Car
class inheriting from Vehicle
â you might start out writing something like this:
class Car: Vehicle {
let isConvertible: Bool
init(isConvertible: Bool) {
self.isConvertible = isConvertible
}
}
But Swift will refuse to build that code: weâve said that the Vehicle
class needs to know whether itâs electric or not, but we havenât provided a value for that.
What Swift wants us to do is provide Car
with an initializer that includes both isElectric
and isConvertible
, but rather than trying to store isElectric
ourselves we instead need to pass it on â we need to ask the super class to run its own initializer.
Hereâs how that looks:
class Car: Vehicle {
let isConvertible: Bool
init(isElectric: Bool, isConvertible: Bool) {
self.isConvertible = isConvertible
super.init(isElectric: isElectric)
}
}
super
is another one of those values that Swift automatically provides for us, similar to self
: it allows us to call up to methods that belong to our parent class, such as its initializer. You can use it with other methods if you want; itâs not limited to initializers.
Now that we have a valid initializer in both our classes, we can make an instance of Car
like so:
let teslaX = Car(isElectric: true, isConvertible: false)
Tip: If a subclass does not have any of its own initializers, it automatically inherits the initializers of its parent class.
3. How to add initializers for classes - Additional
- Test: Class inheritance
4. How to copy classes
4. How to copy classes
In Swift, all copies of a class instance share the same data, meaning that any changes you make to one copy will automatically change the other copies. This happens because classes are reference types in Swift, which means all copies of a class all refer back to the same underlying pot of data.
To see this in action, try this simple class:
class User {
var username = "Anonymous"
}
That has just one property, but because itâs stored inside a class it will get shared across all copies of the class.
So, we could create an instance of that class:
var user1 = User()
We could then take a copy of user1
and change the username
value:
var user2 = user1
user2.username = "Taylor"
I hope you see where this is going! Now weâve changed the copyâs username
property we can then print out the same properties from each different copy:
print(user1.username)
print(user2.username)
âŠand thatâs going to print âTaylorâ for both â even though we only changed one of the instances, the other also changed.
This might seem like a bug, but itâs actually a feature â and a really important feature too, because itâs what allows us to share common data across all parts of our app. As youâll see, SwiftUI relies very heavily on classes for its data, specifically because they can be shared so easily.
In comparison, structs do not share their data amongst copies, meaning that if we change class User
to struct User
in our code we get a different result: it will print âAnonymousâ then âTaylorâ, because changing the copy didnât also adjust the original.
If you want to create a unique copy of a class instance â sometimes called a deep copy â you need to handle creating a new instance and copy across all your data safely.
In our case thatâs straightforward:
class User {
var username = "Anonymous"
func copy() -> User {
let user = User()
user.username = username
return user
}
}
Now we can safely call copy()
to get an object with the same starting data, but any future changes wonât impact the original.
- Optional: Why do copies of a class share their data?
- Test: Copying objects
:::
5. How to create a deinitializer for a class
5. How to create a deinitializer for a class
Swiftâs classes can optionally be given a deinitializer, which is a bit like the opposite of an initializer in that it gets called when the object is destroyed rather than when itâs created.
This comes with a few small provisos:
- Just like initializers, you donât use
func
with deinitializers â they are special. - Deinitializers can never take parameters or return data, and as a result arenât even written with parentheses.
- Your deinitializer will automatically be called when the final copy of a class instance is destroyed. That might mean it was created inside a function that is now finishing, for example.
- We never call deinitializers directly; they are handled automatically by the system.
- Structs donât have deinitializers, because you canât copy them.
Exactly when your deinitializers are called depends on what youâre doing, but really it comes down to a concept called scope. Scope more or less means âthe context where information is availableâ, and youâve seen lots of examples already:
- If you create a variable inside a function, you canât access it from outside the function.
- If you create a variable inside an
if
condition, that variable is not available outside the condition. - If you create a variable inside a
for
loop, including the loop variable itself, you canât use it outside the loop.
If you look at the big picture, youâll see each of those use braces to create their scope: conditions, loops, and functions all create local scopes.
When a value exits scope we mean the context it was created in is going away. In the case of structs that means the data is being destroyed, but in the case of classes it means only one copy of the underlying data is going away â there might still be other copies elsewhere. But when the final copy goes away â when the last constant or variable pointing at a class instance is destroyed â then the underlying data is also destroyed, and the memory it was using is returned back to the system.
To demonstrate this, we could create a class that prints a message when itâs created and destroyed, using an initializer and deinitializer:
class User {
let id: Int
init(id: Int) {
self.id = id
print("User \(id): I'm alive!")
}
deinit {
print("User \(id): I'm dead!")
}
}
Now we can create and destroy instances of that quickly using a loop â if we create a User
instance inside the loop, it will be destroyed when the loop iteration finishes:
for i in 1...3 {
let user = User(id: i)
print("User \(user.id): I'm in control!")
}
When that code runs youâll see it creates and destroys each user individually, with one being destroyed fully before another is even created.
Remember, the deinitializer is only called when the last remaining reference to a class instance is destroyed. This might be a variable or constant you have stashed away, or perhaps you stored something in an array.
For example, if we were adding our User
instances as they were created, they would only be destroyed when the array is cleared:
var users = [User]()
for i in 1...3 {
let user = User(id: i)
print("User \(user.id): I'm in control!")
users.append(user)
}
print("Loop is finished!")
users.removeAll()
print("Array is clear!")
5. How to create a deinitializer for a class - Additional
6. How to work with variables inside classes
6. How to work with variables inside classes
Swiftâs classes work a bit like signposts: every copy of a class instance we have is actually a signpost pointing to the same underlying piece of data. Mostly this matters because of the way changing one copy changes all the others, but it also matters because of how classes treat variable properties.
This one small code sample demonstrates how things work:
class User {
var name = "Paul"
}
let user = User()
user.name = "Taylor"
print(user.name)
That creates a constant User
instance, but then changes it â it changes the constant value. Thatâs bad, right?
Except it doesnât change the constant value at all. Yes, the data inside the class has changed, but the class instance itself â the object we created â has not changed, and in fact canât be changed because we made it constant.
Think of it like this: we created a constant signpoint pointing towards a user, but we erased that userâs name tag and wrote in a different name. The user in question hasnât changed â the person still exists â but a part of their internal data has changed.
Now, if we had made the name
property a constant using let
, then it could not be changed â we have a constant signpost pointing to a user, but weâve written their name in permanent ink so that it canât be erased.
In contrast, what happens if we made both the user
instance and the name
property variables? Now weâd be able to change the property, but weâd also be able to change to a wholly new User
instance if we wanted. To continue the signpost analogy, it would be like turning the signpost to point at wholly different person.
Try it with this code:
class User {
var name = "Paul"
}
var user = User()
user.name = "Taylor"
user = User()
print(user.name)
That would end up printing âPaulâ, because even though we changed name
to âTaylorâ we then overwrote the whole user
object with a new one, resetting it back to âPaulâ.
The final variation is having a variable instance and constant properties, which would mean we can create a new User
if we want, but once itâs done we canât change its properties.
So, we end up with four options:
- Constant instance, constant property â a signpost that always points to the same user, who always has the same name.
- Constant instance, variable property â a signpost that always points to the same user, but their name can change.
- Variable instance, constant property â a signpost that can point to different users, but their names never change.
- Variable instance, variable property â a signpost that can point to different users, and those users can also change their names.
This might seem awfully confusing, and perhaps even pedantic. However, it serves an important purpose because of the way class instances get shared.
Letâs say youâve been given a User
instance. Your instance is constant, but the property inside was declared as a variable. This tells you not only that you can change that property if you want to, but more importantly tells you thereâs the possibility of the property being changed elsewhere â that class you have could be a copy from somewhere else, and because the property is variable it means some other part of code could change it by surprise.
When you see constant properties it means you can be sure neither your current code nor any other part of your program can change it, but as soon as youâre dealing with variable properties â regardless of whether the class instance itself is constant or not â it opens up the possibility that the data could change under your feet.
This is different from structs, because constant structs cannot have their properties changed even if the properties were made variable. Hopefully you can now see why this happens: structs donât have the whole signpost thing going on, they hold their data directly. This means if you try to change a value inside the struct youâre also implicitly changing the struct itself, which isnât possible because itâs constant.
One upside to all this is that classes donât need to use the mutating
keyword with methods that change their data. This keyword is really important for structs because constant structs cannot have their properties changed no matter how they were created, so when Swift sees us calling a mutating
method on a constant struct instance it knows that shouldnât be allowed.
With classes, how the instance itself was created no longer matters â the only thing that determines whether a property can be modified or not is whether the property itself was created as a constant. Swift can see that for itself just by looking at how you made the property, so thereâs no more need to mark the method specially.
6. How to work with variables inside classes - Additional
7. Summary: Classes
7. Summary: Classes
Classes arenât quite as commonly used as structs, but they serve an invaluable purpose for sharing data, and if you ever choose to learn Appleâs older UIKit framework youâll find yourself using them extensively.
Letâs recap what we learned:
- Classes have lots of things in common with structs, including the ability to have properties and methods, but there are five key differences between classes and structs.
- First, classes can inherit from other classes, which means they get access to the properties and methods of their parent class. You can optionally override methods in child classes if you want, or mark a class as being
final
to stop others subclassing it. - Second, Swift doesnât generate a memberwise initializer for classes, so you need to do it yourself. If a subclass has its own initializer, it must always call the parent classâs initializer at some point.
- Third, if you create a class instance then take copies of it, all those copies point back to the same instance. This means changing some data in one of the copies changes them all.
- Fourth, classes can have deinitializers that run when the last copy of one instance is destroyed.
- Finally, variable properties inside class instances can be changed regardless of whether the instance itself was created as variable.
8. Checkpoint 7
8. Checkpoint 7
Now that you understand how classes work, and, just as importantly, how they are different from structs, itâs time to tackle a small challenge to check your progress.
Your challenge is this: make a class hierarchy for animals, starting with Animal
at the top, then Dog
and Cat
as subclasses, then Corgi
and Poodle
as subclasses of Dog
, and Persian
and Lion
as subclasses of Cat
.
But thereâs more:
- The
Animal
class should have alegs
integer property that tracks how many legs the animal has. - The
Dog
class should have aspeak()
method that prints a generic dog barking string, but each of the subclasses should print something slightly different. - The
Cat
class should have a matchingspeak()
method, again with each subclass printing something different. - The
Cat
class should have anisTame
Boolean property, provided using an initializer.
Iâll provide some hints in a moment, but first I recommend you go ahead and try it yourself.
Still here? Okay, here are some hints:
- Youâll need seven independent classes here, of which only one has no parent class.
- To make one class inherit from another, write it like this:
class SomeClass: OtherClass
. - You can make subclasses have a different
speak()
method to their parent by using theoverride
keyword. - All our subclasses have four legs, but you still need to make sure you pass that data up to the
Animal
class inside theCat
initializer.