Hi everyone,
I've been working on a new move-checker for the Swift compiler, with the goal of closing usability gaps with noncopyable types. One gap is that you can't consume a noncopyable stored property of a class, or a global variable, and then put a new value back later in the same function. I'd like to start a discussion on a solution that lifts the restriction.
Problem
Today, the compiler rejects this:
struct NC: ~Copyable { /* ... */ }
func transform(_ value: consuming NC) -> NC { /* ... */ }
final class C {
var x = NC()
var y = NC()
func switcheroo() {
let a = consume self.x
// ^ error: missing reinitialization of inout parameter 'self.x'
// after consume while accessing memory
self.x = transform(a)
}
}
This looks harmless. I'm reinitializing self.x after the consume and there doesn't appear to be an inout parameter here. Yet if I rewrite this to use a nested function that actually does have an inout parameter, it's suddenly accepted:
extension C {
func switcherooWorkaround() {
{ (x: inout NC) in
let a = consume x
x = transform(a)
}(&self.x)
}
}
Being forced to structure code in this way is awful for readability and confusing when you encounter it. It also can lead you to keep access scopes open for longer than may be needed, because it's onerous to refactor code into multiple nested functions:
struct Node: ~Copyable {}
class Tree {
var right: Node?
var left: Node?
func something() {
{ (right: inout NC, left: inout NC) in
// Access to both left and right are open at the same time, becoming
// a runtime trap.
self.right = self.walkLeftNode(consume self.right)
self.left = self.walkRightNode(consume self.left)
}(&self.right, &self.left)
}
}
This same sort of workaround is needed to consume a var captured by an escaping closure.
Background
Global variables, class stored properties, and vars captured by escaping closures all have their exclusivity enforced dynamically. Every mutation runs inside an exclusive access scope. An ordinary assignment current = new consumes the old value and stores the new one inside a single access:
// `current = new` becomes:
begin_access current
old = consume current
current = new
destroy old
end_access
A mutating access must start and end with the storage initialized. A consume is effectively the first half of an assignment, and Swift ends its access right after the value is moved out. So switcheroo actually looks like this:
// `let a = consume self.x`
begin_access self.x
a = consume self.x
end_access // <- self.x is left uninitialized!
new = call transform(a)
// `self.x = new`
begin_access self.x
self.x = new
end_access
The first access ends with self.x uninitialized, so the compiler has to reject this. Otherwise transform (or anything else it calls) could reach this object through some other reference and read self.x after it has been consumed. Class references can be stored almost anywhere, there's no way for the compiler to prove it won't happen.
The closure workaround is accepted because a function call holds exclusive access to each inout argument for the whole call:
// `{ ... }(&self.x)` becomes:
begin_access self.x
call closure(self.x) // consumes and reinitializes inside
end_access
Proposed solution
When a function consumes a global, a class stored property, or a var captured by an escaping closure, that begins an implicit inout access beginning at the consume that stays open until the storage is reinitialized. If there are several reinitializations, it ends at the first one reached. If there exists a control-flow path reaching the end of the lexical scope of the variable or a function exit that is missing a reinitialization, it is diagnosed as an error. For example, removing any of the reinitializations below would be diagnosed as an error:
extension C {
func refresh(force: Bool) throws {
let old = consume self.x // access to self.x begins
guard force || old.isStale else {
self.x = old // ...ends here,
return
}
do {
self.x = try rebuild(old) // ...or here,
} catch {
self.x = NC() // ...or here.
throw error
}
}
}
An advantage of scope extension, over the introduction of a function with inout parameters, is that each property gets its own access that is only kept open until the property is re-initialized:
extension C {
func rotate() {
let a = consume self.x // begin access to self.x
let b = consume self.y // begin access to self.y
self.x = b // end access to self.x
self.doSomething() // <- method can access self.x; not self.y
self.y = a // end access to self.y
}
}
If reinitialization occurs within a loop, the access would end after the loop:
extension FileHolder {
func drain() {
something()
// -- access to self.file starts here --
self.file.close() // <- consumption
for path in self.paths {
self.file = File()
self.readFile() // <- may runtime trap, despite being initialized
}
// -- access to self.file ends here --
return
}
}
If consumption occurs conditionally, but reinitialization happens unconditionally,
the access starts earlier, at a common ancestor point of all places where it's reinitialized.
var global: File = File()
func maybeClose() {
something()
// -- access to `global` starts here --
if .random() {
global.close()
}
global = File()
// -- access to `global` ends here --
another()
return
}
Exclusivity
Holding an access open across arbitrary code means a conflicting access can trap at runtime:
extension C {
func peek() -> Int { x.value }
func oops() {
let a = consume self.x // exclusive access to `self.x` begins
_ = peek() // runtime trap: overlapping access to `self.x`
self.x = a // exclusive access ends
}
}
The inout closure workaround has exactly the same overhead and exposure for runtime traps, but its access always lasts for the entire closure. Under this proposal, the access is never longer than the workaround's. It's usually shorter.
Thus, the major difference is that it can be harder to see where an access begins and ends. Swift already works this way for ~Escapable types, though. For example, a MutableSpan taken from a global or class stored property silently holds a mutating access for as long as the span is used:
class ArrayHolder {
var data: [Int] = [1, 2, 3]
}
func peek(_ ah: ArrayHolder) { print(ah.data.count) }
func example(_ ah: ArrayHolder) {
var span = ah.data.mutableSpan // mutating access to `ah.data` begins
span[0] = 10
peek(ah) // runtime trap: overlapping access to `ah.data`
span[1] = 20 // access ends after the last use of `span`
}
Thus, having a consumed but not-yet-reinitialized property hold the access open would follow the same precedent.