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.