Day 12 - Swift Classes and Inheritance
Table of Contents
Classes are quite similar to Struct at first glance. With Classes we can create new data types with their own properties and methods. But with one difference, Classes also offer us inheritance. With inheritance, we can build one class on the foundations of another class.
Structs are used extensively in SwiftUI when designing user interfaces. Classes are also used extensively for data.
How to Create a Class? #
We have already seen how to create our own custom data type with Struct. Another way to create our own custom data type is with Classes. They have a lot in common with Struct, but they also have some very important differences.
What Class and Struct have in Common #
- We can create and name both ourselves.
- We can add properties and methods to both, including property observer and access control.
- In both we can create a custom initializer.
Differences between Class and Struct #
- We can build a class on the properties of another class and take all its properties and methods as a starting point. We can also selectively override some methods.
- Because of the first point, Classes do not automatically create a memberwise initializer. For this reason, we need to create a custom initializer for classes or assign default values for all properties.
- The reason why you cannot create a memberwise initializer is inheritance. Suppose we create a class through inheritance, then when I modify some properties in my parent class, the initializer of the inherited class could be broken.
- When we copy an instance of a class, both copies share the same data, so if we change one copy, the other copy will also change.
- When the last instance of a Class instance is destroyed, Swift can optionally call a special function called deinitializer.
- Even if we make a class a constant, we can change its properties as long as they are variables (
var
).
Class Definition #
class Game {
var score = 0 {
didSet {
print("Score is now \(score)")
}
}
}
var newGame = Game()
newGame.score += 10
The class
definition above is quite similar to the struct
definition. But the five differences in the background are really important.
Inheritance #
Swift allows us to create a new class based on an existing class through inheritance. When a class inherits from another class (parent class or super class), Swift gives the new class (child class or subclass) access to the properties and methods of the parent class, allowing us to add to and modify the properties of the child class.
To enable one class to inherit from another, the name of the parent class is written with :
in the child class. Let’s examine our example;
//PARENT CLASS
class Employee {
let hours: Int
init(hours: Int) {
self.hours = hours
}
}
We can create two subclasses of the Employee
class. Each of the two subclasses will have the hours
property and initializer.
//CHILD CLASSES
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.")
}
}
Note that these two child classes can directly use the hours
property.
These child classes inherit from Employee
, then each child adds its own customizations. So if we create an instance of each of them and call the work()
method, we get a different result.
let robert = Developer(hours: 8)
let joseph = Manager(hours: 10)
robert.work()
joseph.work()
//OUTPUT:
//----------------------------------------
//I'm writing code for 8 hours.
//I'm going to meetings for 10 hours.
As properties can be inherited by the child class, methods can also be inherited. For example, let’s add the following method to the Employee
class.
func printSummary() {
print("I work \(hours) hours a day.")
}
Since Developer
inherits from Employee
, we can start calling the printSummary()
method on all Developer
instances.
let novall = Developer(hours: 8)
novall.printSummary()
//OUTPUT:
//----------------------------------------
//I work 8 hours a day.
Things can get a bit complicated when we want to change an inherited method. For example, we put the printSummary()
method in the Employee
class, but maybe one of the child classes wants it to act differently.
At this point Swift introduces a simple rule: If a child class wants to replace a method in the parent class, the child class must use override
. This does two things;
- If we try to modify a method without using
override
, Swift will refuse to generate the code. This way we don’t accidentally override a method. - If we use
override
, but our method doesn’t actually override anything in the superclass, Swift will still not generate our code. Because we probably made a mistake.
So if we want the Developer
class to have a unique printSummary()
method, we add the following code 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.")
}
There is also a distinction when doing override
. If our parent class has a work()
method that takes no parameters, but the child class has a work()
method that accepts String
, then we don’t need to use override
because we are not modifying the parent method.
If we are sure that our class should not support inheritance, we can mark it as
final
. This means that the class itself can inherit from other classes, but cannot be used for inheritance. No child class can take afinal
class as a parent.
Adding Initializer for Classes #
Class initializers are a bit more complicated than struct initializers. If a child class has a custom initializer, it must call the initializer of the parent class after setting its own properties.
Whether a class inherits or not, the automatically generated memberwise initializer cannot be used in Classes. So either we need to write our own custom initializer or we need to assign default values to all properties of the class.
Let’s start by defining a class;
class Vehicle {
let isElectric: Bool
init(isElectric: Bool) {
self.isElectric = isElectric
}
}
This class has a single Boolean property and an initializer to set this property.
Now we want to make a Car
class inheriting from Vehicle
class;
//CAUTION INVALID CODE.
class Car: Vehicle {
let isConvertible: Bool
init(isConvertible: Bool) {
self.isConvertible = isConvertible
}
}
But Swift will reject the above code. The Vehicle
class has a property called isElectric
but we have not provided a value for it.
Swift wants us to provide an initializer to the Car
class containing the isElectric
and isConvertible
properties. But instead of storing isElectric
ourselves, we need to pass it. So we need to ask the parent class (Vehicle
) to run its own initializer.
class Car: Vehicle {
let isConvertible: Bool
init(isElectric: Bool, isConvertible: Bool) {
self.isConvertible = isConvertible
super.init(isElectric: isElectric)
}
}
super
is one of the values that Swift automatically provides for us, similar to self
. It allows us to call methods of our parent class like initializer. We can use super
for other methods if we want, it is not limited to init.
Now that we have a valid initalizer in both our classes, we can create an instance of Car
as follows.
let teslaX = Car(isElectric: true, isConvertible: false)
If a child class does not have its own initializer, it automatically inherits the initializer of the parent class.
How to Copy Classes? #
In Swift, all copies of an instance of a class share the same data, so any change we make in one copy automatically changes the other copies. This is because Classes are reference type in Swift.
Let’s see this in practice;
class User {
var username = "Anonymous"
}
The User
class has only one property. But since it is inside a Class, it will be shared across all copies.
Let’s create an instance of the User
class;
var user1 = User()
Then make a copy of user1
and change the value of username
on the copy;
var user2 = user1
user2.username = "Taylor"
Now let’s print the username of both instances;
print(user1.username)
print(user2.username)
//OUTPUT:
//----------------------------------------
//Taylor
//Taylor
Even though we changed only one of the instances, the other also changed.
This may seem like a bug, but it’s actually an important feature. This way we can easily share common data across our application.
Unlike classes, Structs do not share data in instance copies. So if we change User
class to User
Struct in our code, we get a different result. In this case, first Anonymous and then Taylor will appear on the screen.
Deep Copy #
The process of creating a unique copy of a class instance is called deep copy. When deep copy is done, a new instance is created and copying can be done in this way.
class User {
var username = "Anonymous"
func copy() -> User {
let user = User()
user.username = username
return user
}
}
With the copy()
method above, we can create an instance with the same initial value. This way any future changes will not affect the original.
Referance Type and Value Type #
Structs are a value type. That is, they hold the data themselves, no matter how many properties or methods they have, they are still accepted as a constant value. Classes, on the other hand, are reference type. That is, they refer to data that exists elsewhere.
var message = "Welcome"
var greeting = message
greeting = "Hello"
Obviously value type is intuitively quite easy to understand. When the code above runs, message
will still be “Welcome”, only greeting
will be “Hello”. Struct’s values reside entirely inside their variables and are not shared with other values. This means that all their data is stored directly, so when they are copied we get a deep copy of all the data.
In contrast, we can think of reference type as a signpost. When we create an instance of a class, the variable that stores the instance actually points to the memory where the object exists, not the object itself. If we make a copy of the object, we get a new signpost, but it still points to the memory where the original object exists. This is why changing one instance of a class changes all copies. All copies of the object are just signs pointing to the same piece of memory.
Class Deinitializer #
Swift Classes can optionally create a deinitializer. The deinitializer is called when the object is destroyed, not when it is created.
Deinitializer features;
- Just like initializers, deinitializers do not use
func
. - Deinitializers take no parameters or return no values, not even
()
parentheses. - The deinitializer is automatically called when the last copy of an instance of a class is destroyed.
- We can never call deinitializers directly; they are called automatically by the system.
- Since struts cannot be copied, they do not have deinitializers.
Exactly when deinitializers are called depends on what we are doing and its scope (scope). Scope (scope) means the context in which the information is available. We can exemplify the scope (scope) as follows;
- If we create a variable inside a function, we cannot access it from outside the function.
- If we create a variable inside an
if
condition, this variable cannot be used outside the condition. - If we create a variable inside a
for
loop, including a loop variable (loop variable), we cannot use it outside the loop.
When a value goes out of scope (scope), it means that the context in which it was created is destroyed. In the case of a Struct this means that the data is destroyed, but in the case of classes it means that only one copy of the data is destroyed, so there may still be other copies elsewhere. However, when the last copy is destroyed, the underlying data is also destroyed and the memory it used is given back to the system.
Let’s make an example to demonstrate this;
class User {
let id: Int
init(id: Int) {
self.id = id
print("User \(id): I'm alive!")
}
deinit {
print("User \(id): I'm dead!")
}
}
Using looping, we can quickly create and destroy instances of this Class. If we create a User
instance inside the loop, the underlying data will be destroyed when the loop iteration ends.
for i in 1...3 {
let user = User(id: i)
print("User \(user.id): I'm in control!")
}
print("Loop is over!")
//OUTPUT:
//----------------------------------------
//User 1: I'm alive!
//User 1: I'm in control!
//User 1: I'm dead!
//User 2: I'm alive!
//User 2: I'm in control!
//User 2: I'm dead!
//User 3: I'm alive!
//User 3: I'm in control!
//User 3: I'm dead!
//Loop is over!
When this code is executed, we will see that each user
is created and destroyed separately. The other one will be completely destroyed before the new one is created.
Deinitializer is only called when the last remaining reference to an instance of a class is destroyed. This can be a stored variable, a constant or an Array.
For example, if we were adding User
instances as we create them, 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!")
//OUTPUT:
//----------------------------------------
//User 1: I'm alive!
//User 1: I'm in control!
//User 2: I'm alive!
//User 2: I'm in control!
//User 3: I'm alive!
//User 3: I'm in control!
//Loop is finished!
//User 1: I'm dead!
//User 2: I'm dead!
//User 3: I'm dead!
//Array is clear!
Working with Variables Inside Classes (Mutability) #
Swift’s classes act like signposts: each copy of a class instance we have is actually a signpost pointing to the underlying piece of data.
class User {
var name = "Paul"
}
let user = User()
user.name = "Taylor"
print(user.name)
//OUTPUT:
//----------------------------------------
//Taylor
The code above creates a constant User
instance, but then modifies it. Does it look a bit strange?
Yes, the data inside the class has changed, but the class instance itself (the object we created) has not changed and in fact cannot be changed because we made it a constant.
Let’s imagine that we created a constant sign pointing to a user
, but we deleted the name tag of that user
and wrote a different name. That user
hasn’t changed - it still exists - but some of its internal data has changed.
If we had made the name
property constant, then it could not be changed.
However, what if we make both the user
instance and the name
property variable (var)? We can change the property, and we can also switch to a completely new User
instance if we want. Continuing with the signboard analogy, it would be like turning the signboard to show a completely different person.
class User {
var name = "Paul"
}
var user = User()
user.name = "Taylor"
user = User()
print(user.name)
//OUTPUT:
//----------------------------------------
//Paul
The code above will print “Paul” as a result, because even though we changed the name
to “Taylor”, we reset it to “Paul” by overwriting the entire user
object with a new one.
Our last variation is to have a variable instance and a constant property. This means that we can create a new User
if we want, but we can only assign its property once.
Let’s consider all four possibilities;
- Constant (
let
) instance, constant (let
) property : a sign that always points to the sameuser
, always has the samename
. - Constant (
let
) instance, variable (var
) property: a sign that always points to the sameuser
, butname
can change. - Variable (
var
) instance, constant (let
) property : a sign that can point to differentusers
butname
never changes. - Variable (
var
) instance, variable (var
) property : a sign that can point to differentusers
and thename
of theseusers
can also change.
Let’s say we are given an instance of User
. The instance is a constant (let
) but the property in it is declared as a variable (var
). This tells us not only that we can change this property at any time, but also that there is a possibility that this property could be changed elsewhere: the class we have could be copied from somewhere else, and since the property is a variable (var
), another part of the code could change it in a surprising way.
When we see fixed (let
) properties, we can be sure that neither our existing code nor any other part of the program can change this property. However, when we deal with variable (var
) properties, the possibility arises that the data can change, regardless of whether the instance is a constant (let
) or not.
This is different from a struct because the properties of a struct cannot be changed even if a variable is made. This is because structs have no signposts and hold their data directly. This means that when we try to change a value in a struct, we indirectly change the struct itself, which is not possible because it is constant.
For these reasons there is no need to use the mutating
keyword in classes. But in structs mutating
is very important. We have already seen that we can change the property of a struct with methods marked as mutating
, but if the instance of the struct is defined as a constant (let
), we still cannot change the property.
//THE FOLLOWING CODE WILL NOT EXECUTE
struct Test {
var name = "Anon"
mutating func change(name2:String) {
self.name = name2
}
}
//CANNOT CHANGE HERE AS LET IS DEFINED.
let instance = Test()
instance.change(name2:"görkem")
print(instance.name)
In class, it doesn’t matter how the instance is created (constant or variable). The only thing that determines whether a property can be changed is whether the property itself is created as a constant. Swift can tell whether a property can be changed by looking at how it was created, there is no need to specifically mark it.
100 Days of SwiftUI Checkpoint - 7 #
You can also read this article in Turkish.
Bu yazıyı Türkçe olarak da okuyabilirsiniz.