Exclusivity and Mutation Tracking in Swift: Value vs Reference types

In this article, we’re going to see inout in action, work with value types, see how to use mutation tracking to our advantage, and explore the law of exclusivity.

INOUT in action

Many of you probably already know that value types with functions that modify themselves need to be marked with mutating. In the following example, we’ll work with the Counter struct that keeps a read and write count on the _value that it holds. We have a mutating function that’s marked with a verb— reset, as suggested by swift API’s design guidelines.

Let’s investigate how inout works in greater detail.

struct Counter<V> : CustomDebugStringConvertible {
    private var _value: V
    private(set) var valueReadCount = 0
    private(set) var valueWriteCount = 0

    init(value: V) {
        self._value = value
    }

    mutating func reset() {
        valueReadCount = 0
        valueWriteCount = 0
    }

    var debugDescription: String {
        return ("(_value) is read (valueReadCount) times and written (valueWriteCount) times")
    }

    var val : V {
        mutating get {
            valueReadCount += 1
            return _value
        }
        set {
            valueWriteCount += 1
            _value = newValue
        }
    }
}

We have a Counter struct that keeps the count of the number of read and writes that happen on the value (value can be any type). The value of valueReadCount and valueWriteCount gets bumped when the value is accessed, and that’s done by making use of the computed property val.

We can also modify the value of a value type argument by passing it with inout. At the call site, we show this by adding an (ampersand) &.

Next, let’s create some functions to see what happens when we use inout to pass values inside any function. We have created the following two functions:


func doNothing(with value: inout Int) {
}

func add100Ints(to value: inout Int) {
    for _ in 0..<20 {
        value += 1
    }
}

var counter = Counter(value: 1)
print(counter)

doNothing(with: &counter.val)
print(counter)

counter.reset()
add100Ints(to: &counter.val)
print(counter)

The first function takes inout as a parameter and does nothing with it, whereas the second function takes an inout parameter and adds an integer to it 20 times.

Next, we have initialized a counter with an integer value of 1.

Let’s look at the result of valueReadCounter and valueWriteCounter for counter on initialization, on executing the first function, and then the second function:

We can see that on initialization, the value of valueReadCounter and valueWriteCounter are both 0, and when these values are passed in the first function using inout, the value is copied in once, used in the function, and then copied back even when the function does nothing. Similarly, when the second function is executed, the value is copied in only once, used in the function, and then copied back, resulting in the count to only bump up once.

Mutation tracking

In this section, we’re going to explore mutation and see what happens when we mix reference types and value types together. Let’s work with the following example:

struct TemperatureValue: CustomStringConvertible {
  var celcius, fahrenheit: Int
  
  var description: String {
    return "((celcius)℃ - (fahrenheit)℉)"
  }
  
  mutating func transposeCelciusToFahrenheit() {
    (fahrenheit, celcius) = (celcius, fahrenheit)
  }
}

final class TemperatureReference: CustomStringConvertible {
  var celcius, fahrenheit: Int
  
  init(celcius: Int, fahrenheit: Int) {
    self.celcius = celcius
    self.fahrenheit = fahrenheit
  }
  
  var description: String {
    return "((celcius)℃ (fahrenheit)℉)"
  }
  
  func transposeCelciusToFahrenheit() {
    (fahrenheit, celcius) = (celcius, fahrenheit)
  }
}

var temperatureValues : [TemperatureValue] = [] {
    didSet {
        print("Temperature values =", temperatureValues)
    }
}

We have two different versions of temperature. TemperatureValue is a value type and TemperatureReference is a reference type. Both the types have celcius and fahrenheit properties and a transpose method with one exception, i.e. the absence of a mutating keyword with transposeCelciusToFahrenheit() in reference type.

Let’s now create an array of temperature values and observe it with a didSet statement:

var temperatureValues : [TemperatureValue] = [] {
    didSet {
        print("Temperature values =", temperatureValues)
    }
}

var tempVal1 = TemperatureValue(celcius: 12, fahrenheit: 35)
temperatureValues.append(tempVal1) // Calls didSet
let tempVal2 = TemperatureValue(celcius: 11, fahrenheit: 33)
let tempVal3 = TemperatureValue(celcius: 10, fahrenheit: 32)
temperatureValues.append(tempVal2) // Calls didSet
temperatureValues.append(tempVal3) // Calls didSet
temperatureValues[0].transposeCelciusToFahrenheit() // Calls didSet

tempVal1.transposeCelciusToFahrenheit() // DOES NOT call didSet
temperatureValues[0].celcius = 42 // Calls didSet

The code creates TemperatureValue instances, appends those values to a temperatureValues array, and transposes those values within and outside the array.

Let’s see how this observer acts when we append to the array and try to execute transposeCelciusToFahrenheit() on elements inside or outside the array. Executing the code will result in the following:

This means that every time a new value is added to the array or a value within the array is changed, didSet for the array is called, as mentioned in the code comments.

Now, let’s perform the same experiment with the TemperatureReference type. We have similar looking code:

var temperatureReference : [TemperatureReference] = [] {
    didSet {
        print("Temperature reference values =", temperatureReference)
    }
}

var tempRef1 = TemperatureReference(celcius: 12, fahrenheit: 35)
temperatureReference.append(tempRef1) // Calls didSet
let tempRef2 = TemperatureReference(celcius: 11, fahrenheit: 33)
let tempRef3 = TemperatureReference(celcius: 10, fahrenheit: 32)
temperatureReference.append(tempRef2) // Calls didSet
temperatureReference.append(tempRef3) // Calls didSet
temperatureReference[0].transposeCelciusToFahrenheit() // DOES NOT call didSet
tempRef2.transposeCelciusToFahrenheit() // DOES NOT call didSet
temperatureReference[2].celcius = 52 // DOES NOT call didSet

Similar to the value type code, this code creates TemperatureReference instances, appends those values to a temperatureReference array, and transposes those values within and outside the array.

The code works similar until we keep appending elements to the array, i.e. didSet fires and we see the following output on the console:

The overall output to the console is different than the one we saw while working with value types, and that difference specifically springs up while trying to modify an instance within the array.

So what’s going on here? What happens when we mutate existing elements inside an array that has reference types?

We notice that even when elements of the array are mutated, didSet isn’t getting called.

This is what triggered willSet and didSet for our example of the Value type.

Law of Exclusivity

We can look at a specific block of code and data that the code uses within a scope and accurately determine the behavior of that code, in other words predict the state of the program deterministically. This happens because we know that there’s no underlying reference that can cause a side affect by modifying the state of this section from outside the scope. This process is also termed as local reasoning.

Prior to Swift 4 and 5, there was a loophole with how mutation was handled. Even value types couldn’t provide deterministic behavior and degrade local reasoning in certain scenarios. This also caused poor memory management and degraded efficiency since it forced the compiler to create unnecessary copies of objects in order to guarantee safety. Fortunately, this problem was solved with the law of exclusivity.

Let’s work with an example to see when such a scenario can happen:

func addNumbers(_ number1: inout Int, _ number2: inout Int) {
    number2 = number1 + number2
}

var num1 = 1
var num2 = 2
addNumbers(&num1, &num2)
print("Num1 = (num1) and Num2 is = (num2)")

Here we have a method to add two numbers and assign the sum to the second argument. Both arguments are marked with inout. Then we create two variables and call the function using them.

As expected, the result of executing this code prints the following to the console:

Both variables are accessed over the lifetime of the function because they’re passed in with an inout and num2 is now incremented to contain 3. This worked fine, and it caused no issues in the code, but what if we try to pass the same variable to both the parameters inside the function? Let’s see what happens in that case:

var num1 = 1
var num2 = 2
addNumbers(&num1, &num1)
print("Num1 = (num1) and Num2 is = (num2)")

Instead of num2 in the second argument, we try to pass num1 in the second argument. This triggers an exclusivity error, and the compiler starts complaining, resulting in the following error:

This happens because when we were trying to modify number1, something was trying to modify number2. And that might lead to unexpected behavior—Swift doesn’t allow this anymore.

For other updates you can follow me on Twitter on my twitter handle @NavRudraSambyal

Thanks for reading, please share it if you found it useful 🙂

Avatar photo

Fritz

Our team has been at the forefront of Artificial Intelligence and Machine Learning research for more than 15 years and we're using our collective intelligence to help others learn, understand and grow using these new technologies in ethical and sustainable ways.

Comments 0 Responses

Leave a Reply

Your email address will not be published. Required fields are marked *