Doug's Compiler Corner

Originally posted on 2025-01-07 06:00:00 +0000

Last updated on 2025-01-08 14:06:45 +0000

Swift for C++ Practitioners, Part 12: Move Semantics

Since their introduction in C++11, move semantics have been an integral part of programming in C++. Move semantics are implemented in C++ with rvalue references, which express the idea that the entity they refer to is temporary in nature (an rvalue in programming language speak) and effectively won't be used again later. There are a number of uses of rvalue references in C++, but I like to think of them as enabling three separate but complementary features:

  • Move optimizations: when one needs to copy a value, but the source of that copy is not going to be used afterward, one can optimize the copy by transferring ownership of the resources from the source to the destination instead of copying them. For container types like std::vector or std::string, this means replacing a linear-time copy with a constant-time move, making it a valuable optimization. One can trigger this optimization explicitly using std::move.
  • Move-only types: as their name implies, move-only types can be moved, but they can never be copied. They are primarily used for providing unique ownership of resources that either can't be copied or for which the overhead of copying is so great that the programmer wants to make every copy explicit. std::unique_ptr is the canonical example of a move-only type in C++.
  • Perfect forwarding: in C++ templates, one often needs to take some argument values and forward them to another template. In doing so, we want to retain as many of the characteristics of the original argument as possible: its type, but also whether it is const and whether it refers to an lvalue (named thing) or rvalue (temporary thing), so that (for example) the argument can be moved-from in the template being forwarded to if the argument originally started its live as a non-const rvalue. One can use std::forward along with rvalue-reference arguments to forward.

More or less, the same reasons that motivated the introduction of move semantics into C++ are applicable to Swift. We'll dive in a moment, but at a very high level:

  • Move optimizations are automatic in Swift, which is good for performance but can hold some surprises for C++ programmers, especially around the RAII idiom.
  • Move-only types are available in Swift under the name "non-copyable types", and I'll spend the bulk of this post talking about them.
  • Perfect forwarding isn't possible in Swift, because we haven't found a solution that works well with Swift's separate type checking model.

Automatic move optimizations

I defined a move optimization as a place where something that would normally be a copy is replaced by a "move", that steals the resources from the source rather than making a copy of them. So long as the source value isn't used again after it is moved-from (or is assigned to again), and this is a safe optimization. Let's start with a C++ shared_ptr example and assess the copies that occur:

class Counter {
  int value;
  
public:
  Counter() : value(0) { }
};

std::shared_ptr<Counter> createCounter() {
  auto counter = std::make_shared<Counter>(); // (A)
  auto counter2 = counter;                    // (B)
  return counter2;                            // (C)
}

All three of the marked lines here are interesting for different reasons. The line marked (A) creates a new std::shared_ptr<Counter> instance and initializes counter with it. This looks like a copy, but because the initializer on the right-hand side an rvalue (temporary), this ends up calling shared_ptr's move constructor, so C++ is getting an implicit move optimization.

The line marked (B) initializes another std::shared_ptr<Counter> instance, counter2, from the first counter. This looks a little like the first line, but because counter is an lvalue (a thing with a name), C++ will not turn it into a move: this still calls shared_ptr's' copy constructor.

The line marked (C) is returning counter2, which is conceptually copying the shared_ptr instance out as the return value. However, C++ does another, conceptually related optimization here---the Named Return Value Optimization, or NRVO---to not call any constructor at all. It puts the local variable counter2 in the place where the return value should go, so it doesn't have to copy or move to it later.

So in C++, we get a move, a copy, and an NRVO optimization in this example. All of this is required by the language, and you can observe it if you were to inspect the compiler's output or annotate shared_ptr's various constructors with logging to see what happens.

In Swift, things are a bit different. Let's translate that example:

class Counter {
  var value: Int = 0
}

func createCounter() -> Counter {
  let counter = Counter()          // (A)
  let counter2 = counter           // (B)
  return counter2                  // (C)
}

Semantically, all of (A), (B), and (C) are copies in Swift. However, Swift's optimizer will turn all of them into moves. For the line marked (A), this is trivial for the same reasons as in C++: the right-hand side of the initialization is a temporary, so we can move from it.

The line marked (B) requires more analysis. The right-hand side is counter, which is not used after the initialization of counter2, so it's safe to end its lifetime early by moving from it rather than copying. If you were to add a print(counter) or similar use after (B), then (B) would remain a copy. The line marked (C) uses a similar analysis to (B): after the return, counter2 isn't used, so it can safely be moved-from, ending its lifetime.

These optimizations are possible in Swift because code cannot observe copies or moves: there's no equivalent to a user-written copy or move constructor, so there is no place that one could provide behavior that differs between the two. Yes, you can inspect the compiler's output to see what happens (the compiler flag -emit-sil is good for doing this), and perhaps copies will show up in a profile if they are happening too often, but that's it.

Explicitly consuming (moving from) a value

Is that unnecessary copy at the line marked (B) in the C++ code still bothering you? I thought so, and this is why std::move exists in C++. std::move effectively turns an lvalue into an rvalue, allowing move optimizations to occur. This has no copies in it:

std::shared_ptr<Counter> createCounter() {
  auto counter = std::make_shared<Counter>(); // (A)
  auto counter2 = std::move(counter);         // (B)
  return counter2;                            // (C)
}

From the C++ perspective, the lifetime of counter hasn't changed even after it has been moved-from at the line marked (B). However, the move constructor has possibly stolen its resources and left it in some assignable-and-destructible state. If you really wanted to add a line after (B) this does something like this:

std::cout << counter->value << "\n";

the C++ language won't stop you. The C++ standard will tell you it was a bad idea, because you shouldn't be using a moved-from value unless you are destroying it or assigning to it. If you run this code, it'll probably SEGFAULT, because the move constructor for shared_ptr makes the moved-from pointer NULL, stealing the pointer to avoid increasing the reference count. We tend to say that C++ has "non-destructive" moves, because moving from a value still leaves the value accessible, and it's up to the programmer to do so correctly.

Swift has an equivalent to std::move called consume. It is used to force Swift to perform a move (rather than a copy) either to be more explicit about documenting the end of a value's lifetime or to ensure that a desired optimization happens. Let's do that to the Swift version of createCounter:

func createCounter() -> Counter {
  let counter = Counter()          // (A)
  let counter2 = consume counter   // (B)
  return counter2                  // (C)
}

The consume ensures that a move will occur, whether or not the optimizer would have done so on its own. Consumes in Swift are destructive, meaning that this consume operation ends the lifetime of counter entirely: the variable can no longer be read from. If I try to print its value, I'll get a compile-time error like this:

15 | 
16 | func createCounter() -> Counter {
17 |   let counter = Counter()
   |                 `- error: 'counter' used after consume
18 |   let counter2 = consume counter
   |                  `- note: consumed here
19 |   print(counter.value)
   |                 `- note: used here
20 |   return counter2
21 | }

It's not that common to use consume in Swift, because the move optimizations are often good enough. But it's a tool for making those moves more explicit, and unlike std::move, the Swift compiler will ensure that you don't use the value after it was moved-from.

Variable lifetimes can be shortened in Swift

Earlier, I noted that you cannot observe copies or moves (other than an explicit consume). However, you can observe when a move optimization ends the lifetime of a value early, because classes can have a deinit that (like a C++ destructor) runs when the instance is destroyed. Here's an example in Swift where I've triggered the "early" destruction of a class instance via a move optimization:

class Counter {
  var value = 0

  deinit {
    print("Destroying Counter(\(value))")
  }
}

class TakeCounter {
  init(_: Counter) { }

  deinit {
    print("Destroying TakeCounter")
  }
}

func eatCounter() {
  let counter = Counter()
  let takeCounter = TakeCounter(counter)
}

eatCounter()

If you run this without optimizations, you'll get the following output:

Destroying TakeCounter
Destroying Counter(0)

This is what you would expect from C++: takeCounter is initialized second, so it is destroyed first, as a rule. When Swift's optimizer is enabled (e.g., by providing -O on the command line), passing counter into the TakeCounter initializer is optimized into a move (rather than a copy), because counter isn't used later in the function. The TakeCounter initializer cleans up the counter argument it is given, ending the lifetime early, so the output you will get is:

Destroying Counter(0)
Destroying TakeCounter

This optimization is important for the performance of automatic reference counting and for working with large value types like arrays and dictionaries (which use automatic reference counting under-the-hood). However, it's likely to surprise seasoned C++ developers who have internalized C++'s strict rules for when objects are destroyed.

RAII is not the same

The shortening of variable lifetimes also means that one of the core idioms of C++, Resource Acquisition Is Initialization (RAII) behaves very differently in Swift. You can use safely classes to hold on to resources in Swift, where the initializer allocates the resource and the deinitializer deallocates it. However, you can't depend on strict stack-based deallocation. For example, this class encapsulates a file descriptor:

class FileHandle {
  var fd: CInt
  
  init(filename: String) {
    fd = open(filename, O_RDONLY)
  }
  
  deinit {
    close(fd)
  }
}

However, it is not safe to use the value of fd from outside of the class because the class instance might get destroyed early:

func maybeBadFunc() {
  let file = FileHandle(filename: "myfile.txt")
  let fd = file.fd
  // do stuff 'file' and 'fd'
  // do stuff with 'fd', but don't touch 'file'
}

The break in encapsulation that allows the value of fd to be used after the file is no longer used can mean that the file gets closed before we're actually done with it. In Swift, there is a standard idiom for correctly handling this: the FileHandle class will make that state private and provide a withXYZ method that gives the value to a closure. Clients then use that value within the closure, and the file handle is kept alive. Here's what that could look like:

class FileHandle {
  private var fd: CInt
  
  // init, deinit stay the same
 
  func withFileDescriptor<R, E>(body: (CInt) throws(E) -> R) throws(E) -> R {
    return try body(fd)
  }
}

The withFileDescriptor method takes a closure and calls it, returning its result. If that closure throws some error type E, then that's what withFileDescriptor throws. A client can use withFileDescriptor to perform an operation on the underlying file descriptor like this:

let file = FileHandle(filename: "myfile.txt")
let data = file.withFileDescriptor { fd in 
  // read some bytes from fd
  // decode those bytes into some data
  return data
}

This explicit form of "with" block is common in Swift: it has the benefit of making the scope of the access to the internal state (here, the file descriptor) clearly called out in the source code, for human reader and Swift optimizer alike, and encourages programmers to keep these blocks small and focused.

Non-copyable ("move-only") types

In both C++ and Swift, most types are copyable by default. In C++, one can define a move-only type by explicitly deleting the copy constructor and copy-assignment operator. For example, a simple move-only file handle might look like this:

class FileHandle {
  int fd;
  
public:
  FileHandle(const FileHandle &) = delete;
  FileHandle &operator=(const FileHandle &) = delete;
};

In Swift, you can't delete those because you can't write them in the first place. Instead, you make the type non-copyable (Swift's term for "move-only") by suppressing its copyability in the type definition:

struct FileHandle: ~Copyable {
  private let fd: CInt
  
  init(filename: String) {
    fd = open(filename, O_RDONLY)
  }
  
  deinit {
    close(fd)
  }
}

Here, Copyable is a protocol that all copyable types in Swift implicitly conform to. The tilde (~) suppresses that implicit conformance, so our definition of FileHandle here is suppressing the implicit conformance to Copyable. Therefore, it is a non-Copyable (or "move-only") type.

Non-copyable types look and act like other types in Swift, except that they are never copied. If you do something that would require a copy, the compiler will produce an error. Here's an example taking a file handle and trying to use it afterward:

func useSomething() {
  var fileHandle = FileHandle(filename: "myfile.txt")
  let otherHandle = fileHandle
  _ = fileHandle
}

The Swift compiler will reject this with the following error:

14 | 
15 | func useSomething() {
16 |   var fileHandle = FileHandle(filename: "myfile.txt")
   |       `- error: 'fileHandle' consumed more than once
17 |   let otherHandle = fileHandle
   |                     `- note: consumed here
18 |   _ = fileHandle
   |     `- note: consumed again here
19 | }

I like to think of this as being a spectrum for move optimizations: copies of copyable value types will be optimized to moves when safe, and you can force the optimization in particular instances with consume. Non-copyable types force the move optimization everywhere, so every copy is removed (or else the program fails to compile). If we tried the above with C++, we'd have to write a std::move(fileHandle) in the initialization of otherHandle, but it would still allow us to access fileHandle (incorrectly) later in the function body.

Deinitializers in structs!?

Our first FileHandle in Swift was a class, with a deinitializer that closes the file. You may have been tempted to write a deinitializer in a struct or an enum, only to be rebuked by the compiler with an error like this:

error: deinitializer cannot be declared in struct 'FileHandle' that conforms to 'Copyable'

Because (copyable) structs and enums can be freely copied, there's no single point of ownership that can be identified, so there's no way to know when the last copy is destroyed. They can't meaningfully have deinits. But non-copyable structs and enums do have a single point of ownership: it's the live variable storing the value. In our example above, when we initialized fileHandle, that variable owned the value. When otherHandle was initialized from fileHandle, ownership was transferred from the now-dead fileHandle over to otherHandle. This ownership is understood by the compiler, so the deinit will be invoked exactly once, when the live value goes out of scope.

C++ move-only types also generally have this notion of the "live value", but it's not understood by the compiler. Rather, it's built into the types themselves. For example, std::unique_ptr will store a NULL pointer to indicate that it doesn't have the live value, and moving from a std::unique_ptr always makes the moved-from value have a NULL pointer. Effectively, every move-only type in C++ has to have a "dead" state where the destructor does nothing, because the C++ compiler will run the destructor for both the live value and the dead values. Noncopyable Swift structs and enums don't need to have a "dead" state, because the deinitializer won't get called on dead values. This is one of the other benefits of destructive moves: they simplify non-copyable/move-only types by eliminating the dead state, and eliminate a branch in the destructor/deinitializer to test for that dead state.

Noncopyable structs (and enums) can have a deinit to free resources, and they provide strict ownership without any runtime overhead. For automatically managing a resource in Swift, use either a class (when the resource is shared) or a noncopyable struct or enumerator (when the resource must be uniquely owned). Allocate the resource in the initializer, deallocate it in the deinitializer.

Parameter passing

When passing around values to functions, Swift will make copies as needed to provide arguments to functions. With non-copyable types, we can no longer make a copy when passing an argument to a function, so we need to know more about what the function will do with the argument. Is it going to mutate it? Store it in some data structure somewhere else? This is part of the interface to the function, so it expressed as part of the parameter:

  • consuming parameters will consume their argument, ending its lifetime. Think of this as an rvalue reference parameter, FileHandle&&, where you are expected to take the value in C++ (and it's enforced in Swift) and the caller can no longer use it.
  • borrowing parameters will operate on their argument for a time without modifying it or ending its lifetime. Think of this as a constant lvalue reference parameter, const FileHandle &, where you can access but not mutate the value you're given.
  • inout parameters can mutate their argument but will not end its lifetime. Think of this as a non-constant lvalue reference parameter, FileHandle &, where you can access and mutate the value. We discussed these before in the post on value types, and the behavior is the same for copyable and non-copyable value types.

These modifiers are placed on the function parameter to say how the function will work with that parameter. Of these, the most interesting is consuming, because it does something not available with normal copyable value types: it ends the lifetime of its argument so it cannot be used again. For example, one could make a function that explicitly consumes a file handle so that it will be closed before the function returns:

func closeMe(_ fileHandle: consuming FileHandle) { }

func testClose() {
  let fileHandle = FileHandle(filename: "myfile.txt")
  closeMe(fileHandle)
  _ = fileHandle // error: fileHandle was consumed by the call to closeMe
}

The call to closeMe consumes fileHandle, and closeMe itself will call the deinitializer for its argument. Therefore, any attempt to use fileHandle after this call is an error. In C++ terms, closeMe would take a FileHandle&& that it steals from, and the call to closeMe would implicity move its argument with std::move(fileHandle).

This particular function is probably better off as a method on FileHandle. One can make methods consuming to indicate that they consume self (just like making them mutating indicates that self is inout):

extension FileHandle {
  consuming func close() {
    // nothing to do, because the deinitializer will be called implicitly on self
  }
}

A consuming method ends the lifetime of self, so again the body is empty because the deinit is called implicitly. However, we should probably introduce some error checking here to make sure that the file closed properly, and (say) throw an error if it didn't---something we cannot do from the deinitializer. To make this work, however, we have to tell Swift not to call the deinitializer, because we don't want to end up introducing a "dead" state like we have to in C++. We can do so with the statement discard self, like this:

extension FileHandle {
  consuming func closeMe() {
    let result = close(fd)
    discard self
    if result != 0 {
      throw FileError.closeFailed
    }
  }
}

Here, we close the file, tell Swift not to destroy self for us, and then throw an error if the close failed. From the caller perspective, the FileHandle has been consumed either way, and they'll be able to react to an error using Swift's error-handling mechanisms.

borrowing and inout are more straightforward, because they work similarly to parameters that have copyable value types: neither consumes the value, so the main difference is that borrowing parameters can only look at the argument (not change it), whereas inout parameters are allowed to change the argument. As noted above, this is like the difference between const FileHandle& and FileHandle& in C++. However, Swift has more safeguards here, because it's going to ensure that a mutable reference to a value is unique. To explain, let's return to our old friend from the value types post, the Law of Exclusivity.

The Law of Exclusivity, again

Swift's Law of Exclusivity says that a mutable reference to a value can only exist if it is the only place referencing the value. If you have an inout parameter, Swift will ensure that nobody else can access (read or write) the argument for the duration of the call. An example I used before is a mutating swap method on value types, shown here with the "conflicting access" error when code tries to violate this law:

26var p1 = LabeledPoint(x: 0, y: 0, label: "Origin")
27var p2 = LabeledPoint(x: 1, y: 1, label: "Upper right unit")
28 │   p1.swapX(&p1)
   │   │        ╰─ note: conflicting access is here
   │   ╰─ error: overlapping accesses to 'p1', but modification requires exclusive access; consider copying
 to a local variable
29

inout behaves the same way for copyable types and non-copyable types: you need to have exclusive access to the argument to pass it as inout. consuming parameters (of non-copyable type) are the same way: you must have exclusive ownership to be able to transfer that ownership to the parameter. borrowing parameters, on the other hand, don't need exclusive access: you can have many borrows (readers) of the same value at the same time, so long as there are no inouts or consumes of that value (since those can mutate the value).

As an example, let's imagine a merge function to merge the contents of two files into a third file, with this signature:

func merge(
  _ first: borrowing FileHandle,
  and second: borrowing FileHandle,
  into result: inout FileHandle
) {
  // ...
}

Let's say we have three file handles, named f1, f2, and f3:

let f1 = FileHandle(filename: "1.txt")
let f2 = FileHandle(filename: "2.txt")
var f3 = FileHandle(filename: "3.txt")

It's fine to merge f1 and f2 into f3, e.g.,

merge(f1, and: f2, into: &f3)

and, because the first two parameters are borrowing, to merge a file into itself to produce a third output:

merge(f1, and: f1, into: &f3) // okay

However, trying to alias the third parameter with either of the first two parameters will produce an error:

48 |   merge(f1, and: f3, into: &f3)
   |                  |         `- error: overlapping accesses to 'f3', but modification requires exclusive access
   |                  `- note: conflicting access is here

The same applies with consuming operations. For example, perhaps we have a merge-and-close function with this signature:

func mergeAndClose(
  _ first: borrowing FileHandle,
  and second: borrowing FileHandle,
  into result: consuming FileHandle
) {
  // ...
}

Again, it's fine to merge-and-close f1 and f2 into f3, or merge-and-close f1 and itself into f3, because such a call borrows the first two arguments and consumes the third. However, one cannot alias the first or second arguments with the third, consumed argument:

56 |   mergeAndClose(f1, and: f3, into: f3)
   |                          |         `- error: overlapping accesses to 'f3', but deinitialization requires exclusive access
   |                          `- note: conflicting access is here
57 | }

As the error message notes, this call is ending the lifetime of f3. One cannot access it's value after the call, but because it is a var, it can still be reassigned to a new value:

f3 = FileHandle(filename: "otherfile.txt")

The Law of Exclusivity, which is crucial to Swift's memory-safety story, encompasses noncopyable types and well as copyable types. In the earlier discussion of the Law of Exclusivity, I'd said that it's mostly invisible to Swift programmers, because the Swift language model introduces copies in many places to avoid exclusivity violations. With noncopyable types, we can't copy our way out of potential exclusivity violations, so you're likely to encounter more compiler errors intended to guide you to verifiably correct, unique ownership of resources.

RAII with non-copyable types

Earlier, I warned against using Swift classes for the Resource Acquisition Is Initialization (RAII) idiom that we're accustomed to in C++, because the easy of copying and the Swift compiler's reference-counting optimizations can make lifetimes less predictable than in C++, at least for those who have internalized C++'s lifetime rules.

However, Swift's noncopyable types are much more suitable for RAII, because the lifetimes of noncopyable values are predictable: there are no extraneous copies, and the lifetime ends either when the value is consumed or goes out of scope. One cannot accidently use a value after its lifetime has ended (because the compiler checks for that), and code can document its intention to end the lifetime of a value earlier with a consume expression.

Non-copyable types and generics

Most types in Swift are copyable by default, and one can opt out of copyability with the ~Copyable syntax. The same holds true for generic parameters: they are considered to be Copyable unless one explicitly opts out. Let's revisit our first generic function, the identity function, with this in mind. The following function

func identity<T>(_ value: T) -> T {
  return value
}

is treated as if one had written a Copyable constraint on T, like this:

func identity<T: Copyable>(_ value: T) -> T {
  return value
}

Attempting to call this function with an instance of FileHandle will, therefore, produce an error like this:

67 |   let f = FileHandle(filename: "1.txt")
68 |   identity(f)
   |   `- error: global function 'identity' requires that 'FileHandle' conform to 'Copyable'

We can generalize identity to make it work with non-copyable types by suppressing the implicit requirement that T conform to Copyable with the same ~Copyable syntax, like this:

func identity<T: ~Copyable>(_ value: T) -> T {
  return value
}

Written this way, the compiler will produce an error because it doesn't know what parameter passing convention to apply to the value parameter:

62 | func identity<T: ~Copyable>(_ value: T) -> T {
   |                                      |- error: parameter of noncopyable type 'T' must specify ownership
   |                                      |- note: add 'borrowing' for an immutable reference
   |                                      |- note: add 'inout' for a mutable reference
   |                                      `- note: add 'consuming' to take the value from the caller
63 |   return value
64 | }

For this function, consuming is the only option that makes sense, and adding that annotation allows us to pass a FileHandle instance into the identity function.

Non-copyable is NOt Necessarily Copyable

The generalization of the identity function does not require T to be Copyable, so it must ensure that the value is uniquely owned. That means attempts to copy the same value twice will produce a compiler error, just like in non-generic code:

71 | func badIdentity<T: ~Copyable>(_ value: consuming T) -> T {
   |                                  `- error: 'value' consumed more than once
72 |   let firstCopy = value
   |                   `- note: consumed here
73 |   let secondCopy = value
   |                    `- note: consumed again here
74 |   return firstCopy
75 | }

The fact that the type argument for T is not required to be Copyable doesn't mean that it can't be Copyable, though: the generalized identity really is more general, because it allows both copyable and non-copyable types. When used with a Copyable type like String, the copy operations will simply never be used within the generic function. This is why Swift uses the tilde (~) rather than something that implies the actual opposite, like ! or -. Even so, the phrase "non-copyable type" sounds more restrictive than it is: when dealing with generics, it's really that they are NOt Necessarily Copyable (but it's okay if they are).

One side benefit of generalizing a generic function to support non-copyable types is that it guarantees that all copying operations (which can involve reference counting) are completely eliminated. That can provide a stronger performance guarantee than one can get when relying on compiler optimizations to remove reference-counting overhead. It's not likely to matter for small generic functions, but for more complicated algorithms it can force one to write them in a way that eliminates all extraneous copies.

Conditionally-copyable generic types

Generic types can opt out of the implicit Copyable conformance in the same way as non-generic types can. For example, we can create a Pair type that is non-copyable:

struct NCPair<T, U>: ~Copyable {
  var first: T
  var second: U
}

Here, we have made every instance of NCPair non-copyable. That is fine, but the type still requires its generic arguments to be Copyable (due to the implicit requirement) making NCPair itself unusable with non-copyable types like FileHandle. It would be far better to suppress the Copyable requirement on its argument types, T and U, to produce a pair type that can aggregate non-copyable types:

struct Pair<T: ~Copyable, U: ~Copyable>: ~Copyable {
  var first: T
  var second: U
}

This Pair type can be specialized for both copyable and non-copyable types, e.g., Pair<Int, FileHandle>, but is always non-copyable. That is still too strict: there is no reason why a Pair of two copyable types (say, Pair<Int, String>) should be restricted to be non-copyable. Indeed, we can express the actual semantics here with a conditional conformance that makes Pair Copyable when both of its argument types are non-copyable, like this:

extension Pair: Copyable where T: Copyable, U: Copyable { }

Such a type is called a conditionally-copyable type, because it is sometimes copyable and sometimes not. Conditionally-copyable types are great for low-level generic libraries where some clients might opt to use them with non-copyable types (for stronger ownership and performance guarantees) while also allowing clients to use copyable types (that are generally simpler to learn and program with). The Swift standard library provides conditionally-copyable types for core types like Optional.

Extending generic types with noncopyable generic parameters

There's one thing to be aware of when extending generic types with non-copyable generic parameters: the implicit copyability requirement applies to the generic parameters of the extended generic type unless it is actively suppressed. So if we extend the generic Pair above like this:

extension Pair {
  // ...
}

then it is equivalent to having written a constrained extension like this:

extension Pair where T: Copyable, U: Copyable {
  // ...
}

If our extension had added a conformance to a protocol, the conformance would be conditional on the generic arguments to T and U being Copyable. This behavior is consistent with the rule stated previously, that generic parameters require Copyable arguments unless specified otherwise.

To extend every instance of Pair, one can suppress the Copyable requirements the same way one does for a generic function:

extension Pair where T: ~Copyable, U: ~Copyable {
  // ...
}

Any members within such an extension will need to work with T, U, and the type Pair<T, U> as non-copyable types, but will of course still be usable if T,, U, or both happen to be Copyable.

Noncopyable protocols

Finally, protocols can also suppress copyability with ~Copyable, which will allow non-copyable types to conform to them:

protocol DebugPrint: ~Copyable {
  func debugPrint()
}

extension Pair: DebugPrint where T: DebugPrint & ~Copyable, U: DebugPrint & ~Copyable {
  func debugPrint() {
    first.debugPrint()
    second.debugPrint()
  }
}

The best way to think of a non-copyable protocol is to recall that every protocol has an implicit type parameter named Self that conforms to that protocol. For a non-copyable protocol, the implicit Self parameter is not necessarily copyable, i.e., the protocol has the requirement Self: ~Copyable. All of the other behaviors with respect to non-copyable protocols fall out from that definition. For example, an extension of DebugPrint will have the implicit requirement Self: Copyable unless explicitly suppressed with Self: ~Copyable, as will any protocol that inherits from DebugPrint.

Whither perfect forwarding?

I noted in the beginning that Swift does not have an equivalent to C++'s "perfect forwarding"' construct with rvalue references. The rest of this section will describe "why", for folks interested in the language design aspects of it. If you're not interested, feel free to skip ahead to the conclusion :)

Still here? Okay. Perfect forwarding in a C++ function template passes on the type, const-ness, and lvalue-or-rvalue-ness of an argument along when making another function call, such as in this apply template:

template<typename F, typename ...T>
void apply(F &&f, T &&...t) {
  std::forward<F>(f)(std::forward<T>(t)...);
}

In Swift, we can express the notion of taking a function with an arbitrary number of parameters that we then forward, like this:

func apply<each T>(_ f: (repeat each T) -> Void, _ t: repeat each T) {
  f(repeat each t)
}

However, this only works for copyable types passed by-value: attempting to pass in a function for f that uses inout, or noncopyable types (which must use borrowing or consuming for their convention) will match the function type (repeat each T) -> Void because the parameter passing convention isn't part of the type T: it's a separate aspect of each parameter.

There is a similar limitation in C++ that can illustrate this idea of a missing abstraction mechanism. Imagine that you want to implement the C++ standard library's is_member_function_pointer by partial specialization to recognize all member function pointer types. You might start like this:

template<typename T>
struct is_member_function_pointer : bool_constant<false> { };

template<typename R, typename C, typename ...Args>
struct is_member_function_pointer<R (C::*)(Args...)> : bool_constant<true> { };

This almost works, accounting for any class type, any return type, and any number of parameters including their types and (because const, volatile, and references are part of the type system) parameter-passing conventions. But this doesn't handle const or volatile member functions, or ones where *this is an rvalue reference, because there's no way to abstract over the *this parameter in C++. You have to provide partial specializations for all combinations:

template<typename R, typename C, typename ...Args>
struct is_member_function_pointer<R (C::*)(Args...) const> : bool_constant<true> { };

template<typename R, typename C, typename ...Args>
struct is_member_function_pointer<R (C::*)(Args...) volatile> : bool_constant<true> { };

template<typename R, typename C, typename ...Args>
struct is_member_function_pointer<R (C::*)(Args...) const volatile> : bool_constant<true> { };

template<typename R, typename C, typename ...Args>
struct is_member_function_pointer<R (C::*)(Args...) const &> : bool_constant<true> { };

template<typename R, typename C, typename ...Args>
struct is_member_function_pointer<R (C::*)(Args...) volatile &> : bool_constant<true> { };

template<typename R, typename C, typename ...Args>
struct is_member_function_pointer<R (C::*)(Args...) const volatile &> : bool_constant<true> { };

template<typename R, typename C, typename ...Args>
struct is_member_function_pointer<R (C::*)(Args...) const &&> : bool_constant<true> { };

template<typename R, typename C, typename ...Args>
struct is_member_function_pointer<R (C::*)(Args...) volatile &&> : bool_constant<true> { };

template<typename R, typename C, typename ...Args>
struct is_member_function_pointer<R (C::*)(Args...) const volatile &&> : bool_constant<true> { };

Yuck. This is the same issue Swift has with parameter-passing conventions, just applied to *this in C++.

However, there's another complication: the parameter passing convention is part of a parameter, not part of its type, because there is no way to abstract over parameter passing in a manner that allows separate type checking: a borrowing parameter works very differently from a consuming or inout one, so there's very little one could do without knowing the convention. We actually see this in C++ as well: references and const are part of the type system, but it's actually quite hard to implement a template that works properly with reference types, because they act so differently once instantiated). C++'s std::optional template is an instructive example: it does not allow optional references because the semantics were hard to pin down (in 2013), an issue that the committee is still trying to fix a decade later. JeanHeyd Meneide provides a detailed breakdown of all of the design considerations for how this one class template could behave with reference types, and it's quite daunting. There may yet be an answer for Swift here that provides perfect forwarding with separate type checking at a reasonable level of complexity... but we don't have it yet.

When to use non-copyable types

Non-copyable types offer guaranteed unique ownership over a resource with zero runtime overhead, providing more safety and better usability than move-only types in C++. There are three situations in which non-copyable types are most appropriate:

  • Unique ownership of a resource: if you need a resource to be uniquely owned and never shared.
  • RAII: if you need to scope the ownership of a resource to a lexical block and reason specifically about where it will be freed.
  • Zero overhead: if you need to ensure that a value is never copied unnecessarily, for example to guarantee that a section of code has no extraneous reference counting.

Non-copyable types should be considered an advanced feature in Swift, and should be used only when the benefits outweigh the additional complexity that non-copyability introduces. Most Swift code should continue using copyable types, and Swift's defaults (including for conditionally-copyable types) intentionally skew toward using copying to maintain local reasoning and a simpler programmer model for most users.

Tagged with: