With the introduction of Swift 5.9 came new ownership features mostly reserved for advanced use cases. We break down the complexity so even beginner developers can learn how to use them.
When the Swift Ownership Manifesto was initially published in 2017, it laid out a vision for making Swift a successful low-level programming language. By 2023 and the release of Swift 5.9, the work outlined in the manifesto was, for the good part, done.
Since the ownership features released in Swift 5.9 are focused on more advanced use cases, such as systems programming and performance tuning, we wanted to break them down to help developers get familiar with them.
Understanding ownership
“Ownership is the responsibility of some piece of code to eventually cause a value to be destroyed. An ownership system is a set of rules or conventions for managing and transferring ownership.”
The concept of ownership in programming is not new. Every programming language incorporates some form of memory management. It can be manual, as in C, or automated, as it is in Swift. In manual memory management, allocated memory needs to be explicitly freed.
However, it is not always obvious who is responsible for releasing the allocated memory. For example, let’s imagine the following C function:
void process(int *array, int count) {
for (int i = 0; i < count; i++) {
// do something
}
}
This function receives a pointer to an array of integers and processes each of them. But in this case, it is not clear if the function should free the memory passed to it. The language lacks expressiveness to indicate this, so we need to rely on convention and documentation.
Instead, if the function frees the memory, we could name it in such a way to indicate that:
void process_and_free(int *array, int count) {
for (int i = 0; i < count; i++) {
// do something
}
free(array);
}
The idea of ownership is not inherent to memory management; it applies to resources as well as frameworks. For example, the Memory Management Programming Guide for Core Foundation lays out the ownership rules for the CoreFoundation framework in detail.
Before Swift 5.9, the ownership system was largely not exposed to the end user. Swift had a default set of ownership rules that worked well for the majority of use cases, freeing programmers from explicitly thinking about ownership.
Unfortunately, the only way to opt out of default ownership rules was to manage ownership manually, using a set of Unsafe
APIs that require manual allocation and deallocation of memory. This is generally too low-level and error-prone.
The new Swift 5.9 features were released with the goal of giving programmers the tools to control ownership without sacrificing safety and ergonomics.
Law of exclusivity
Enforcement of exclusive access to memory, more commonly referred to as the “Law of exclusivity,” comprises a set of rules that the compiler and runtime enforce to maintain memory safety. It is the foundational feature for more advanced functionalities outlined in the Swift Ownership manifesto.
This feature was initially introduced with Swift 4, enforcing exclusivity only in Debug mode. Later, Swift 5 began to enforce exclusivity in Release mode as well.
The impact on typical programs is minimal, as the conditions under which exclusivity violations occur are quite specific. Still, they can easily become relevant as program complexity grows.
To achieve memory safety, Swift requires exclusive access to a variable in order to modify that variable.
Let’s look at an example:
var x = 1 // global variable
func addGlobalX(y: inout Int) {
y += x
}
addGlobalX(y: &x)
In this example, the addGlobalX
function is called with the inout
argument x
, requiring exclusive access to variable x
via parameter y
for the duration of addGlobalX
. However, since function addGlobalX
accesses x
directly, the exclusivity of the inout
parameter y
is violated.
If this seems contrived, it probably is. As we mentioned, the conditions under which exclusivity violation happens are quite specific.
Probably the most common example of code that was previously allowed but now violates exclusivity is the following:
var array = [1, 2, 3]
swap(&array[0], &array[1])
As the compiler suggests, a simple solution to this problem is:
var array = [1, 2, 3]
array.swapAt(0, 1)
This “workaround” was implemented as part of SE-0173 Add MutableCollection.swapAt. Apart from being more readable, it is required for legal reasons.
Why exclusivity enforcement is important
You might be asking yourself, why does all of this matter in the first place?
As outlined in the Swift blog (Motivation chapter), there are various aspects to consider, but for this article, we will talk about two of them.
1
Enforcement legalizes performance optimization while protecting memory safety
Consider the following example of a contrived Swift function:
func addAndReset(x: inout Int, y: inout Int, count: Int) {
var i = 0
while i < count {
x += y
i += 1
}
y = 0
}
To delve into this code further, we will use Compiler Explorer. Compiler Explorer is a powerful tool for understanding low-level code as it provides access to generated assembly.
Note: Don’t be afraid of the assembly. In this article, we will annotate and shorten assembly snippets to make them concise and understandable.
If we select the Swift 3 compiler, the generated assembly is:
addAndReset(x: inout Int, y: inout Int, count: Int) -> ():
mov rax, qword ptr [rdi] // Move x into register rax
.LBB1_2: // Loop
add rax, qword ptr [rsi] // Read memory pointed to by y and add to rax register
On the other hand, the Swift 5.9 compiler emits the following assembly:
adddAndReset(x: inout Int, y: inout Int, count: Int) -> ():
mov rax, qword ptr [rdi] // Move x into register rax
mov rcx, qword ptr [rsi] // Move y into register rcx
.LBB1_2: // Loop
add rax, rcx // Add contents of register rcx to rax
...
Here, we can observe a clear difference:
Without the assumption of exclusivity, every loop iteration needs to read the value from the memory location instead of using a register.
The reason for this is that the compiler is not allowed to assume that x and y are not the same variable, as is the case if you call this function in the following way:
var x = 1
addAndReset(x: &x, y: &x)
This requires the compiler to be conservative and limits optimization opportunities. In Swift 5.9, this code is not allowed and is rejected by the compiler.
This issue is not specific to Swift. Consider an equivalent C code snippet:
void add_and_reset(int *x, int *y, int count) {
int i = count;
while (i > 0) {
*x = *x + *y;
i++;
}
y = 0;
}
This code sample suffers from the same issue – every loop iteration reads the memory location of y
.
The solution to this problem in C is to use the restrict keyword:
void add_and_reset(int *restrict x, int *restrict y, int count) { }
However, in C, nothing is preventing us from calling this function with the same pointer. Swift is much more powerful in this regard.
2
Exclusivity rules are needed to give the programmer control of ownership and move-only type
In a very simplistic model, calling a function in Swift with an argument of a value type will copy the contents of that value type onto the stack.
When that argument is not modified, it could be possible to avoid copying and providing direct access to the original storage location. If Swift aims to support such a construct of “shared” value, that value must remain valid during the entire function call. Allowing other code to interact with the same storage location simultaneously would require the compiler to copy the value to avoid undefined behavior. This defeats the purpose of the idea of “shared”.
Dynamic enforcement performance considerations
When the compiler can check exclusiveness statically, it will do so. However, in some situations, this is not practical. Class properties use dynamic enforcement, meaning the program needs to track active access to a property dynamically.
Let’s examine this simple Swift class:
class X {
var y: Int = 0
}
If we inspect the generated assembly code, we can see the generated setter and getter for y
:
output.X.y.getter : Swift.Int:
call swift_beginAccess@PLT
...
call swift_endAccess@PLT
output.X.y.setter : Swift.Int:
call swift_beginAccess@PLT
...
call swift_endAccess@PLT
And we can see that they are wrapped in swift_beginAccess
and swift_endAccess
. This would certainly have some performance impact on accessing the variable and can be disabled by:
class X {
@exclusivity(unchecked) var y: Int = 0
}
To understand the impact of this, we will use Package Benchmark. Package Benchmark is an excellent Swift package that allows us to output many statistics in a user-friendly way. You can run benchmarks from the associated sample code by executing the following command:
swift package benchmark --target ExclusivityCheck --scale
The following chart shows the overhead of exclusivity checking when accessing class property:
Swift’s default ownership system
Before diving into the details of Swift’s default ownership system, let’s clarify what ownership means for value types compared to reference types:
- To take ownership of a value type, we need to copy it
- To take ownership of a reference type, we need to increase its reference count
Before Swift 5.9, there was no way to control ownership of parameters passed to functions.
Instead, Swift’s default argument passing semantics were as follows:
- Initializers take ownership of parameters passed to them
- Functions do not take ownership of parameters passed to them
But what does this mean in practice? Let’s explore a small example:
final class B {}
final class A {
init(x: B) {}
}
let b = B()
let a = A(x: b)
Looking at the assembly code generated by the compiler in Godbolt, we observe the following:
main:
...
call swift_retain@PLT
call (output.A.__allocating_init(x: output.B) -> output.A)
We can see that before calling A.init
, the compiler inserts a retain. The reason is that, by default, initializers are taking ownership of their arguments, and to do so, they need to be passed with a +1 retain count.
On the other hand, this short code snippet:
final class B {}
final class A {
func f(x: B) {}
}
let b = B()
let a = A()
a.f(x: b)
compiles to:
main:
...
call (output.A.f(x: output.B) -> ())
We can see that there is no retain before calling A.f
. That is because, by default, functions do not take ownership of their arguments.
But what if a function needs ownership of an argument? For example, in this case:
final class B {}
final class A {
private var b: B!
func f(x: B) {
self.b = x
}
}
let b = B()
let a = A()
a.f(x: b)
In this case, the function will retain the argument:
output.A.f(x: output.B) -> ():
...
call swift_retain@PLT
// store into self
You might wonder why initializers are not borrowing as well. The reasoning would be – if an initializer doesn’t need ownership of an argument, we avoid the extra retain. If it needs it, it can retain it in the same way as functions.
To understand this, let’s look at this example:
final class B {}
final class A {
private var b: B
init(x: B) {
self.b = x
}
}
let b = B()
let a = A(x: b)
In this example, when we initialize b
, it is automatically retained. However, pay attention that b
is not used anymore. So we create b
just to pass it to A.init
. In this case, b
can pass ownership to A.init
, and no extra retain needs to be emitted.
Swift has made some choices for its default ownership system that, in practice, offer a good trade-off for most common use cases.
Performance impact of reference counting
To get a better understanding of why these choices are important, we will examine a performance benchmark that measures the impact of the retain operation.
Retain release
You can run this performance benchmark by executing:
swift package benchmark --target Retain --scale
Keep in mind that this only demonstrates the performance impact of retain. When we retain an object, we usually need to release it, making the impact even more significant.
Borrowing and consuming parameter modifiers
SE-0377 introduced new function parameter modifiers: borrowing
and consuming
. Syntactically, they are used in the same position as inout
and are mutually exclusive with it.
Semantically, the low-level meaning of these operations can be summarized in the following table:
Modifier | Caller | Callee |
borrowing | Remains owner of original value. No need to copy or retain value. Responsible for keeping it alive for the duration of the callee. | No need for a release. No implicit copying allowed. Explicit copying is required with a copy operator. |
consuming | It can either give ownership of the original value to the callee OR retain or copy the value if it requires ownership of its own value. | Required to release the value. |
To understand the performance impact of these annotations, we will actively work against Swift’s default ownership system. This means:
- Using the
init
parameter in a borrowing way - Using the function parameter in a consuming way
Borrowing init
Let’s consider the following example:
public class BorrowingInit {
private let first: Int
public init(test: Test) {
first = test.x
}
public init(borrowingTest: borrowing Test) {
first = borrowingTest.x
}
}
What is the performance difference between calling init(test:)
and init(borrowingTest:)
?
You can run the performance benchmark for this example by executing:
swift package benchmark --target BorrowingInit --scale
Consuming function
Let’s consider the following example:
public class ConsumingFunc {
private var test: Test!
public init() {}
public func assignTest(consumingTest: consuming Test) {
self.test = consumingTest
}
public func assignTest(test: Test) {
self.test = test
}
}
What is the performance difference of calling assignTest(test:)
vs assignTest(consumingTest:)
?
You can run this benchmark by executing:
swift package benchmark --target ConsumingFunction --scale
Compiler optimizations
Even without annotating parameters such as borrowing, the compiler can sometimes have visibility that the initializer borrows the argument and can optimize away unnecessary ARC traffic. For compiler optimization tips and tricks, check out this document.
However, compiler optimizations depend on the optimization mode, code structure, and other factors that are not always in our control.
For performance-critical code, we want guaranteed predictable behavior, which the new parameter modifiers provide.
Consume operator
Introduced in SE-0366, the consume operator allows us to end the lifetime of a variable.
For example:
class X { }
let x = X()
_ = consume x
After we consume x
, the attempt to use it will throw a compiler error “x used after consume.”
This is great for passing ownership of local variables explicitly to the initializer and works well with its consuming
counterpart.
For example:
class X { }
class Y {
init(x: consuming X) { }
}
let x = X()
let y = Y(x: consume x)
The performance implications of this are shown in the chart below, but you can run this benchmark by executing:
swift package benchmark --target ConsumeOperator --scale
Powerful and still beginner-friendly
While the new borrowing and consuming modifiers can be considered “advanced” features, understanding them at a basic level provides a better insight into how the Swift compiler and optimizer work. It is also critical to understand them for writing low-level code without sacrificing the type safety provided by Swift.
However, even with these new “advanced” features, Swift remains a beginner-friendly language. As nicely summarized in A roadmap for improving Swift performance predictability, “we want these features to fit within the “progressive disclosure” ethos of Swift.”
The Ownership Manifesto confirms a similar approach:
“It is the core team’s expectation that ownership can be delivered as an opt-in enhancement to Swift. Programmers should be able to largely ignore ownership and not suffer for it. If this expectation proves to not be satisfiable, we will reject ownership rather than imposing substantial burdens on regular programs.”
You can find the associated source code here.