That's a very interesting pitch!
I'd like to throw here that I'm working with @dabrahams and a handful of other people on a Swift-inspired language that adopted this approach at its core: Val.
Val has four passing conventions: inout, which works almost exactly like Swift, let and sink, which correspond to this pitch's borrow and take, respectively, and set. We found that the latter completes the calculus when we consider accessors in subscripts, or the convention of self in an initializer. A set parameter is considered uninitialized at the beginning of the function/subscript and must be guaranteed initialized before the function/subscript returns.
There are a couple of observations we made with Val that I think are relevant to the current discussion.
It could alternatively be argued that explicitly stating the convention for a value argument indicates that the developer is interested in guaranteeing that the optimization occurs [...] We believe that it is better to keep the behavior of the call in expressions independent of the declaration [...], and that explicit operators on the call site can be used in the important, but relatively rare, cases where the default optimizer behavior is insufficient to get optimal code.
I am not thrilled at the idea to use operators at the call site to just help the optimizer do something I believe it can do on its own if the language gives reasonable guarantees.
Val has a very transparent cost model that actually frees the user from constantly worrying about what the optimizer will be able to do. That transparency is based on the fact that an API documents a clear intent. I argue that it's likely that API consumers will want to leverage that intent rather than guessing how to best guide the optimizer at the call site (see the example at the end).
Val predictably choses how to pass arguments depending on the way they are used in the caller. Perhaps that philosophy clashes with the goals of SE-0366, but I worry that multiplying operators will unjustifiably make writing efficient Swift more complex.
Alternatively, we could explore schemes to allow the self parameter to be declared explicitly, which would allow for the take and borrow modifiers as proposed for other parameters to also be applied to an explicit self parameter declaration.
We've done that with Val. It turns out take methods are very interesting in the context of value semantics, as a way to "destructure" non-copyable types.
[...] it may be a reasonable default to have it so that take parameters are internally mutable.
We've had that discussion during the development of Val. As it turns out, making take parameters mutable in the callee makes a lot of sense in terms of the calculus. The parameter passing conventions offer different, escalating levels of privileges:
borrow parameters just let you read a value;
inout parameters let you modify it; and
take parameters let you do whatever you want with it, including having them escape.
[A protocol] requirement may still be satisfied by an implementation that uses different conventions for parameters of copyable types
Function values can also be implicitly converted to function types that change the convention of parameters of copyable types among unspecified, borrow, or take
Parameter conventions relate to the correspondence between functional updates (e.g., x = f(x)) and in-place updates (e.g., f'(&x)). Because of that relationship, it is easy to synthesize an in-place operation from a functional update, and vice-versa. So I believe function values could also convert a take convention to an inout one, and vice-versa, even for non-copyable types. That's what Val does in method bundles.
Finally, the pitch points out that, by carefully choosing the parameter convention that we use, we can also avoid unnecessary allocations without relying on optimizer heroics. It follows that it might be interesting to implement the same operation with different conventions and let the user (but ideally the compiler) choose the most appropriate version depending on the context in which the operation is used.
To illustrate, consider this program:
typealias Vec2 = (x: Double, y: Double)
struct Polygon {
var vertices: [Vec2]
}
func offset(_ shape: borrow Polygon, by delta: Vec2) -> Polygon { ... }
func offset(_ shape: take Polygon, by delta: Vec2) -> Polygon { ... }
let ngon = Polygon(vertices: ...)
print(offset(take ngon, by: (x: 10, y: 10)))
Here, we're better off using the first offset function to avoid leaving to the optimizer the task to eliminate the unnecessary allocation that would be caused by the first one.
It would be nice if we didn't have to add a take operator at the call site though. The compiler could select the right implementation on its own by realizing that ngon is no longer used after the call.