moroz.dev

<< Back to index

Learning iOS and Swift. Day 6: Structs and classes

Abstract

Briefly discussing structs and classes, with key differences, property and method declaration, and property observers.

There are three types of “algebraic” data types in Swift: enumerations, structs, and classes. That’s one more than Rust, which does not have classes.

Classes vs. Structs

In Swift, structs and classes are very similar in functionality and behavior. The key differences I have noticed by now is that classes can be subclassed (i. e. they support inheritance), and the way they are passed around in memory: structs are passed by value (like Copy values in Rust), while class instances are passed by reference (so, passing pointers around).

This can be easily illustrated by implementing a simple counter. With a struct, it looks like this:

struct StructCounter {
    private(set) var value: Int = 0

    mutating func inc() {
        self.value += 1
    }

    mutating func dec() {
        self.value -= 1
    }
}

Notice that within a struct, methods that modify the value of the struct’s properties must be annotated with mutating.

var s1 = StructCounter()
s1.inc() // s1.value is now 1

var s2 = s1 // s1 gets copied
s2.inc()

print(s1.value) // 1
print(s2.value) // 2

By annotating the value property with private(set), we prevent the struct from being modified from the outside of the struct. If we tried to reassign the value nevertheless, we would get a compiler error:

myCounter.value = 100
struct_with_methods.swift:21:11: error: cannot assign to property: 'value' setter is inaccessible
myCounter.value = 100
~~~~~~~~~~^~~~~

The same counter functionality, but written as a class:

class ClassCounter {
    private(set) var value: Int = 0

    func inc() {
        self.value += 1
    }

    func dec() {
        self.value -= 1
    }
}

In class definitions, mutating methods must not be annotated with mutating.

var c1 = ClassCounter()
c1.inc() // c1.value is now 1

var c2 = c1 // c2 and c1 now point to the same instance
c2.inc() // so when you call a method on c2, c1 gets mutated as well

print(c1.value) // 2
print(c2.value) // 2

Property observers

It is possible to execute some code after (or before) a property has been set on a struct or class instance. This is done using a feature called “property observers,” which are a nice alternative to setter methods. An example use case of property observers is in an Active Record-like data structure, representing a record in the database. Before validating and writing string input, we need to trim all surrounding whitespace from a string. This section is inspired by an example from the book Swift in depth by Tjeerd in ’t Veen.

import Foundation

struct User {
    let id: String
    var name: String {
        didSet {
            self.name = self.name.trimmingCharacters(in: .whitespaces)
        }
    }

    init(name: String) {
        defer { self.name = name }
        self.id = UUID.init().uuidString.lowercased()
        self.name = ""
    }
}

This example defines a struct called User with two properties: an immutable id set in the initializer, and a mutable name. I also defined a didSet closure that trims the name after it has been set:

var name: String {
    didSet {
        self.name = self.name.trimmingCharacters(in: .whitespaces)
    }
}

The way to trim whitespaces from a string in Swift is using:

string.trimmingCharacters(in: .whitespaces)

Note that this method is provided by Foundation. Another feature from the Foundation framework is the UUID struct, providing methods to generate and manipulate UUIDs.

In the initializer, I generate a random UUID to use as the record’s unique identifier. In this example, I treat the UUID as a string, because the actual data representation of a UUID is not relevant to this example. I initialize name as an empty string to let the compiler know that the value will always be initialized in the initializer. The actual initial value for name is set in a defer closure, which will be fired after the rest of the initialization code. This is because the didSet closure does not fire in the initializer.

init(name: String) {
    defer { self.name = name }
    self.id = UUID.init().uuidString.lowercased()
    self.name = ""
}

Let’s try this out:

var newUser = User(name: "   Test   ")
print(newUser)
// User(id: "ba3d5e23-431c-404c-a371-8ed306f07900", name: "Test")

As expected, upon initialization, all surrounding whitespace in the name is trimmed. What happens when we set a new value?

newUser.name = "   A changed value "
print(newUser)
// User(id: "ba3d5e23-431c-404c-a371-8ed306f07900", name: "A changed value")

Here, too, the value has been correctly trimmed.

Besides didSet, you can also add a willSet observer, but it seems like it cannot be used to manipulate the property value:

var name: String {
    didSet {
        self.name = self.name.trimmingCharacters(in: .whitespaces)
    }
    willSet {
        print("About to change the value to \(newValue)")
    }
}