As part of work towards move-only types, I think it's time to fix this inconsistency in Swift 5.
Pitch: Don't copy self
when calling non-mutating methods
Introduction
In Swift 5, calling any non-mutating method on a value type implicitly copies the object before making the call. In order to provide more predictable performance with a model that will support future non-copyable types, we propose changing this so that the self argument is borrowed by default instead of copied.
Motivation
Consider the following:
var array = [[1,2], [3,4,5]]
array[0].forEach { array[1].append($0) }
In the current implementation, the code above actually copies array[0]
before invoking forEach()
. This avoids an exclusivity violation in this case, but has a number of drawbacks:
-
Inserting a copy by default incurs performance overhead unless the optimizer is able to remove the copy. Although this currently does occur in most cases, the compiler cannot guarantee that copies will be removed in any particular case. This means that real-world code will see occasional performance regressions when optimizer changes affect which copies actually get removed.
-
It is inconsistent with mutating methods. Invoking a mutating method such as
obj.foo()
cannot operate on a copy, as that would lose the intended mutation. -
There are simple mechanisms for copying if that's desired in a particular case. In practice, we believe that there are very few cases where copying is actually the preferred behavior; it makes more sense to have the default match the most common expectation.
In addition to the above concerns, we've recently begun work to provide support for upcoming non-copyable types. For those, we would obviously need to consistently avoid copying and it seems inconsistent to have such a basic behavioral difference between copyable and non-copyable types.
Proposed solution
We propose that when compiling in Swift 6 mode, the compiler will borrow the self argument by default before invoking a non-mutating method, including getters. This behavior will also be available in Swift 5 mode via a compiler flag.
In practice, this will have no functional effect on code such as the following. It may improve performance by removing an unnecessary copy in the few cases where the optimizer was unable to remove it:
struct SomeType {
func foo() -> String { "Hello" }
var val: Int
}
let obj = SomeType()
print(obj.foo())
let x = obj.val
Code such as the following that used to succeed will now fail with an exclusivity violation either at compile-time or at runtime, depending on whether the compiler diagnostics are able to successfully see into the closure. (See SE-0176 for more details about exclusivity failures.)
var array = [[1,2], [3,4,5]]
array[0].forEach { array[1].append($0) }
Developers who wish to recover the previous behavior can easily do so by on the caller side by inserting an explicit copy:
var array = [[1,2], [3,4,5]]
let source = array[0]
source.forEach { array[1].append($0) }
None of the above is intended to prevent the compiler from inserting copies when necessary. For example, a method that is explicitly declared as consuming
or taking
may require the compiler to insert a copy in situations like the following:
struct SomeType {
consuming func close() { ... }
}
var s : SomeType
s.close() // Consumes `s`
// In order for the following to be valid, the
// compiler must copy `s` before the call above
s.otherMethod()
(The keyword here should be aligned with SE-0377 once that is complete.)
Source and ABI compatibility
This is a behavioral change that could break some code. As mentioned above, code that previously relied on the copy behavior may fail to compile or assert at runtime because of exclusivity violations.
It does not change the ABI for the called methods; libraries and their clients will continue to be able to call each other even if one is compiled in Swift 5 mode with the older behavior and the other in Swift 6 mode with the new behavior.