Pitch: Don't copy `self` when calling non-mutating methods

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.

28 Likes

Does this change also apply when calling a subscript getter for a value? I think right now there's a copy made there that's oftentimes optimized out, but not always.

Yes. Getters, including subscript getters are non-mutating methods.

3 Likes

Existing, working code failing at runtime without a compile-time diagnostic seems alarming even if gated on a major version change.

11 Likes

One of the goals of SE-0377 is to allow for API authors to apply borrowing/consuming conventions as optimizations without affecting developers who are using only copyable values and aren't asking to interact with move semantics. What should we do with the self of a consuming method? If we make the borrowing apparent for self of normal methods, that exposes a behavior difference to those don't-care developers, unless we artifically borrow the original self while passing a copy along.

1 Like

On the face of it, this seems to me like a deal-killer.

Joe, can you give an example of what that behavior difference would look like in practice? I don’t understand SE-0377 well enough to imagine how it plays out.

If we pass the receiver by-borrow without copying, that means that nobody else can try to mutate it for the duration of the borrow, because of the rule of exclusivity. So something like:

struct Foo {
  func hold(andModify x: inout Foo) {}
}

do {
  var x = Foo()
  x.hold(andModify: &x)
}

would become a static error. If x is a global variable or class property, then it becomes a dynamic error in the general case, since you can have attempted mutations at a distance from potentially anywhere else in the code. However, if we still copy in order to preserve a value after a consuming method call, then you'd go back to the behavior you see today, where the current value of x is copied to pass by value as the receiver to hold, and then x is accessed exclusively for the andModify: argument.

2 Likes

Calling a consuming method should copy 'self'. This is consistent with the general pass-by-copy rule that says the caller and callee ownership must match to prevent a copy. There's no default change in behavior here. The optimizer will still try to eliminate the copy. The programmer can force a "move" rather than a copy by writing (take self).foo() (where the spelling of "take" is TBD).

The original goal makes sense for pass-by-copy arguments. This pitch already introduces a different convention for passing 'self'. It's reasonable to introduce exclusivity requirements in conjunction with the new convention, which only applies to self. An API author can introduce 'consuming' on a method without breaking source. If the API author removes 'consuming' in a later release, they could break client source. This is an increasingly hypothetical case, and my sense is that any code that violates exclusivity here was likely a programming error. Nonetheless, it's only reasonable to do this under a language mode.

The alternative to this pitch is to have developers start writing
(borrow self).foo() as standard practice to guard against unexpected copies.

1 Like

Assuming, of course, that self can be copied.

Right, but by doing so, it would longer be an agnostic change to alter a method from consuming to borrowing, since that could introduce new static and dynamic errors in code that (possibly inadvertently) expects the copying behavior.

Right, calling a consuming method on a non-copyable type is already an implicit move. In general, non-copyable types already break the goal of SE-0377 (to allow for API authors to apply borrowing/consuming conventions as an optimization). This pitch just broadens the new behavior that we'll see with non-copyable types to every non-consuming method, presumably under a language mode.

1 Like