Swift Noncopyable Type and Variable Ownerships
In Swift 5.9, the Swift team introduced multiple amazing features, such as Macros
, variable consuming
and Actor Custom Executor
.
The Macros
is a marvelous enhancement to Swift and for all Swift developers. I will post another article for exploring what the macro is and how to build a macro of our own.
In this post, we will learn a new type Noncopyable
and some attributes collaborating with it.
Noncopyable
The value types like struct
and enum
are implicitly copy-on-write types in Swift. If we assign a exited struct instance to another variable and change the instance’s value, the struct value will duplicate itself to two instances with distinct memory addresses.
Namely the struct
and enum
values are implicitly copyable and don’t share their inner data in different places.
The Noncopyable
type is not a real Swift protocol that we can use it as type declarations. We can only use it decorate struct or enum declarations with a specific attribute ~Copyable
.
1 | struct SecretNumber: ~Copyable { |
As you can see, a noncopyable struct can declare the deinit
method that can only be used in classes usually.
Before diving into, I list some limitations for using noncopyable types:
- Only structs and enums can be noncopyable.
- A struct or enum having noncopyable properties must also be noncopyable.
- Noncopyable types cannot be used in generics situations at this time.
Here is an example for illustrating how the noncopyable type SecretNumber behaves:
1 | var number = SecretNumber(666) |
In above code, the expression let copyNumber = number
will assign the number’s data to the new variable copyNumber and the old variable number
will be consumed that means the variable number
is invalid or uninitialized after assigning.
As a result, when we invoke the expression print(number.value)
to access the variable number will trigger a compilation error: 'message' used after consume
. We cannot reuse the variable number
after it was consumed.
If you watch the output in the console carefully, you will notice that the deinit log was printed before the value printing of the number. This is a slight distinction between noncopyable types and classes.
1 | class ClassNumber { |
consuming & borrowing
Besides using noncopyable types as local variables, we need passing them into method as parameters. When we create a normal method that take a parameter of the SecretNumber
type, we will get a compilation error: Noncopyable parameter must specify its ownership
.
Xcode provides three options for us to resolve this problem:
- Add
borrowing
for an immutable reference. - Add
inout
for a mutable reference. - Add
consuming
to take the value from the caller.
Theinout
attribute is familiar for us. That means the caller will pass a reference pointer into the inout parameter and any modification to the inout parameter will influence the outer variable.
The borrowing
and consuming
are two new attributes specially offered to noncopyable types.
1 | func consume(_ number: consuming SecretNumber) {} |
Literally, the consume method will consume the parameter like assigning the value to another variable. Actually, it is just that.
1 | let number = SecretNumber(100) |
Inside the method consume(:)
, the consuming parameter number still is a normal noncopyable value and we can use it is like using a normal local noncopyable variable.
1 | func consume(_ number: consuming SecretNumber) { |
The other attribute borrowing
means that the parameter is just borrowing the outer value with readonly mode. You can think it of a const SecretNumber * const number
reference pointer.
1 | func borrow(_ number: borrowing SecretNumber) { |
Otherwise, a noncopyable variable “borrowed” by other methods can still be used in subsequent code.
1 | let number = SecretNumber(999) |
We can also use the consuming
or borrowing
attribute to decorate functions in noncopyable types.
1 | struct SecretNumber: ~Copyable { |
A noncopyable instance invoke a consuming
instance method will consume itself and then the instance variable cannot be accessed in subsequent code.
1 | let number = SecretNumber(666) |
Inside the consuming function, we can access or consume self
.
1 | consuming func consume() { |
Inside the borrowing function, we can only read properties but cannot assign self to another variable.
1 | borrowing func borrow() { |
Invoking borrowing instance functions of a noncopyable variable doesn’t affect the variable at all.
1 | let number = SecretNumber(200) |
Notes that normal functions without any attributes behave identically as borrowing functions.
We all know functions declared with the attribute mutating
can change properties or assign a new value to the self
. Can we consume self in a mutating function? The answer is YES. Extra, we need reassign a new value to self after consuming self, because a mutating
method is still a normal function that doesn’t consume the variable to outer.
1 | mutating func mutate() { |
Reminder to reinitialize self in mutating functions of noncopyable types after resuming self.
1 | mutating func mutate() { |
In some special situations, we may do some cleanup work or releasing resources in the deinit
function. If we have a consuming function that will do the same operations above, the resources will be released twice separately in the consuming function and the deinit function.
Let’s say we have a FileDescriptor
type like below.
1 | struct FileDescriptor: ~Copyable { |
Swift has provided a new operator discard
that can be used in consuming methods to suppress the invoking of the deinit method.
1 | struct FileDescriptor: ~Copyable { |
We can only use the discard
operator in consuming methods with the format discard self
and all stored properties of the noncopyable type must be of trivially-destroyed
types. If we redefine the number property of our SecretNumber
struct to be of String type and discard self in the consume method, we will receive a compiler error.
1 | struct SecretNumber: ~Copyable { |
There are many behavioral distinctions between trivial or non-trivial types when we use them in the Swift variable ownership system. We will discuss the trivial types and influences produced by them in detail at the end of this post.
consume
The consuming
and borrowing
attributes are only available with noncopyable types. If we want to consume a normal variable of any type, we can use the new operator consume
. A local variable following a consume
operator when be assigned to another variable or passed to functions will forward the variable itself, without copying.
1 | let message = "secret content" |
If the local variable is mutable, we can reuse the variable message by reassigning a new value to it after consuming it.
1 | var message = "secret content" |
Empty Struct cannot be consumed
If you define a struct with empty properties, using consume
operator will cause a compiler error.
1
2
3struct Pet {}
var tom = Pet()
var other = consume tom // 'consume' applied to value that the compiler does not support. This is a compiler bug. Please file a bug with a small example of the bug
trivial types & trivially-destroyed types
In the discussion about the discard
operator above, we have said the necessary condition is all stored properties of the noncopyable type must be of trivially-destroyed
types. This is due to noncopyable types have deinit methods used to safely release resources, but the discard self
syntax will suppress the invocation of the deinit method. That means resources in the object may not have been released correctly and produced unexpected behaviors and memory leaks.
The main feature of trivially-destroyed
types is that they can be safely destroyed without any additional cleanup operations. This ensure the discard self
operation is safe.
In Swift official documents, there is a brief description to trivial types:
A trivial type can be copied bit for bit with no indirection or reference-counting operations. Generally, native Swift types that do not contain strong or weak references or other forms of indirection are trivial, as are imported C structs and enums. Copying memory that contains values of nontrivial types can only be done safely with a typed pointer. Copying bytes directly from nontrivial, in-memory values does not produce valid copies and can only be done by calling a C API, such as memmove().
I have tested many types and listed some below considered trivial types in Swift. It’s worth nothing that whether a type is considered trivial
or not depends on variety of factors, including the size and complexity of the type. For example, even types that contain reference types (such as classes) or other non-trivial elements may be considered trivial if they meet certain criteria. Therefore, while the below list covers many of the most common cases, it is not exhaustive.
- Builtin integer types (Int, UInt, Int8, UInt8, Int16, UInt16, Int32, UInt32, Int64, UInt64)
- Builtin floating-point types (Float, Double, Float80)
- Builtin Boolean type (Bool)
- Raw pointer types (UnsafePointer, UnsafeMutablePointer, UnsafeRawPointer, UnsafeMutableRawPointer)
- AnyOptional and its derivatives (such as Optional
) - OptionSet types
- Function types (() -> Void, (Int) -> String)
- Tuples containing trivial types only
- Enums that have no associated values or only have associated values of trivial types.
- Structs containing stored properties of trivial types only
All trivially-destroyed types are also trivial types, the converse is not necessarily true. Conceptually, a trivial type is a type that has a simple structure and can be safely copied by simply copying its underlying memory and a trivial-destroyed type means whose instances can be destroyed without requiring any additional cleanup operations.
Now, we declare two general struct types for testing the consume
operator:
1 | struct TrivialStruct { |
When we use the consume
to handle a nontrivial value, we will get expected behaviors as we comprehended in above learning.
1 | var value = NontrivialStruct() |
But if we replace the NontrivialStruct
to the TrivialStruct
, we will receive a compilation error: 'consume' applied to value that the compiler does not support checking
. And if we also change the var
to the let
, the compilation error disappeared, but confusingly the value can still be used after being consumed.
1 | var value = TrivialStruct() |
1 | let value = TrivialStruct() |
For understanding what underlying operations the compiler did, we can use the swiftc to generate canonical SIL code for our example code with the command swiftc main.swift -emit-silgen
.
1 | // var value = NontrivialStruct() |
1 | // var value = TrivialStruct() |
As you can see above, a non-trivial value will do some operations about ownership and a trivial value is treaded as normal variables without any additional operations. So we can continually use the value even though it has already been consumed.
Then we redefine our NontrivialStruct
and TrivialStruct
to be noncopyable types and add empty consuming methods for them.
1 | struct TrivialStruct: ~Copyable { |
Whether we use let
or var
and NontrivialStruct
or TrivialStruct
, they all behaved expectedly. Then I found another question that we can still modify properties of the noncopyable variable having been consumed.
1 | var value = TrivialStruct() |
Out of Date
Swift team has fixed this problem. Now we reassign values to a consumed instance's properties will trigger a compiler error: cannot partially reinitialize 'tom' after it has been consumed; only full reinitialization is allowed
.
We must reinitialize the instance integrally.
Execute the above code, the console wil print deinit messages twice. This is because modifying a struct will trigger the copy-on-write to duplicate a new instance. But if the TrivialStruct is of normal copyable types, the compiler doesn’t allow us to modify properties of the variable being consumed by the syntax consume value
.
Even if we don’t invoke the method value.consume()
, we can also see twice deinit messages. The funny thing is that if we declare the struct with more than one properties, we will see some strange behaviors.
1 | struct SingleStruct: ~Copyable { |
1 | func main() { |
Just create instances and then their deinit methods will be invoked once the calling stack ended.
1 | func main() { |
The variable multiple
having two properties didn’t invoke its deinit method.
1 | func main() { |
The deinit method invoked when invoking the consume method. The variable multiple didn’t invoke the deinit method again after modifying its property age.
1 | func main() { |
The compiler threw an error when we tried to invoke the consume method after reassigning the property age. If we reassign all properties of the consumed variable and compile again, we will get expected output.
1 | func main() { |
Finally, I proposed a assumption that a noncopyable variable with multiple stored properties must reassign all its properties for completing initialization after being consumed. A consumed variable with multiple properties will not invoke its deinit method after properties are changed unless invoking the consume method again.