`borrow` and `take` parameter ownership modifiers

i was an early adopter of _move(_:) because (like many others i’m sure) i remember the swift 4.x days when the compiler was not so good at optimizing copies, so when _move(_:) was announced i was thrilled and if you are anything like me i started spraying it everywhere.

in hindsight this was not so smart because with _move(_:) and no take/borrow, it is really easy to fool yourself into thinking you prevented a copy. because as @michelf said, if you _move(_:)/_move into a pass guaranteed function parameter, it just copies into a temporary, and most functions are pass guaranteed, so i really wish when i did this the compiler would emit a warning:

import Bar 
func foo(buffer:__owned [Int]) 
{
    var buffer:[Int] = _move buffer 
        buffer.append(0)
    Bar.bar(_move buffer)
    //            ^~~~~~
    // _move into __shared copies into a temporary; 
    // add parentheses to surpress this warning
}

and for a long time i didn’t know this was a thing because it’s not always obvious why the example before is dumb but the example below is smart usage of _move:

import Bar 
func foo(buffer:__owned [Int]) 
{
    var buffer:[Int] = _move buffer 
        buffer.append(0)
    var view:Bar = .init(_move buffer)
}

and i don’t really notice this because i use @inlinable a lot too much and more often than not the footgun gets jammed because Bar.bar(_:) is not from another module it is from the same module and the compiler can make it __owned, and also the 5.6 compiler is a lot smarter than the 4.x compiler was.

6 Likes

It seems reasonable for borrow to be able to fulfil both take and inout, since there’s a trivial meaning to it that doesn’t abandon any information, and Swift introduces implicit thunks between basically-compatible-but-technically-different function types all over the place.

inout shouldn’t be able to fulfil take or borrow because the output value would be silently discarded, as if it was @discardableResult.

take could trivially fulfil borrow or inout today (with a thunk adding an implicit copy), but that will break with move-only types.

3 Likes

I think take x should produce a temporary move-only value.

For instance, a take x expression could produce a @moveonly X, where @moveonly is a type modifier preventing X–which is copiable–from being implicitly copied. The @moveonly modifier disappears when assigned to a variable, and it also disappears once passed to another function. It's simply a flag attached to the return value that would prevent the compiler from making a copy before the value is passed somewhere else, as if the returned X was, for a moment, a move-only type.

This way the expectation that take x will prevent copying is fulfilled.

(A precedent would be X! which is in reality type X? with a special behavior attached to it, but where the behavior isn't otherwise propagated with the type.)

1 Like

I don't think we want to have too many different modifiers here, so it'd be good to pick one name and one semantics. I can see how it's appealing to require take x in an expression to match a take T parameter, though I am concerned about the added source breakage hazard this creates for source-only packages that want to adjust their API conventions. Possibly one compromise we could make is to have it so that using take x as an argument to a function in the same module that isn't explicitly take becomes a warning, since that's more likely to be in the developer's control to change.

Note also that passing take x as an argument that happens to be borrowed should still always avoid copying, since if x is a valid operand to take, we can end its lifetime in the caller immediately after the call regardless. The only inefficiency in this case is that the destruction of the argument has to happen in the caller after the call. (Conversely, passing an intended borrow to a take parameter would necessitate a copy, since the callee wants to consume the parameter but the caller wants to keep the value alive for its own continued use.)

a lot of times the callee wants to mutate the argument like

func renderCitiesList(_ cities:__shared [City.ID: City]) -> HTML 
{
    var cities:[City.ID: City] = _move cities
        cities.removeValue(forKey: self.hometown)
    return HTML.render(cities: cities)
}

so calling renderCitiesList(_:) makes a copy even if you remember to _move

self.html = context.renderCitiesList(_move cities)

of course this is not _move’s fault it is renderCitiesList(_:)’s fault because it is __shared when it should be __owned but __shared is the default, and a lot of times i write _move and i think i solved the problem when in fact i did not which is why i feel like it is a footgun.


UPDATE: this example is now forbidden by the compiler, so it is no longer an issue. it might still happen if you do not use _move and rely on the compiler to optimize the local variables, which it will not. but the _move will generate an error and tell you that the __shared is wrong, which helps.

3 Likes

This seems like a reasonable situation to warn/error about too, since in this case the source of the mismatch is even more localized than the case where you're trying to call some other function with an uncooperative convention.

5 Likes

I wish it was possible writing that efficiently without "move", but with some sort of "lazy", or similar to "drop", so there is no copy done at any point. Pseudocode of simplified version ahead:

func render(cities: TBD) {
    cities.forEach {
        ... /* doesn't contain hometown */
    }
}

func renderCitiesList(cities: [ID: City]) {
    render(cities.lazy.removingValue(forKey: hometown))
    // alternatively:
    render(cities.droppingValue(forKey: hometown))
}

let cities: [ID: City] = ...
renderCitiesList(cities)

this will be slower if City.ID.==(_:_:) is expensive because instead of hashing and comparing expected O(1)’nce you are comparing cities.count times. but you are right if City.ID is Int or something this would be preferable and have less mutation.

1 Like

Presumably "render(cities: TBD)" is O(N) anyway? (it wasn't shown so I've guesstimated). So the resulting algorithm O-big complexity is unchanged. You are correct that with slow hash/EQ it's inferior (still O(n) just with an extra big factor for N).

I type this as a relative novice with Swift, though earlier in my career I may have been considered somewhat expert in 'C'.
To begin, I cannot disagree objectively with anything written above, though much of it goes further into the workings of the compiler than I typically care to venture.

One thing strikes me though, and that is that this proposal adds a fine degree of control to how code will be run by the compiler, at the expense of adding low level complexity to the language.
Coming from C, one thing I have appreciated about Swift is that it mostly made sense - and looking at someone else's code, it was generally possible to get a good understanding of what it was doing. C can be justly criticised for making it far too easy to write wonderfully obfuscated code that would take an awful lot of deciphering. I fear, it is now becoming all too easy in Swift also.

As time has moved on, Swift has adopted many language features from .. other languages, because developers used them in those other languages, and want those features in Swift. That's not unreasonable, but .. and this is a big but.. is there a body in Swift.org that is looking at how to keep Swift "simple" or "compact", and in fact looking at ways to reduce the language by improving the compiler decisions? [ Aside: Who is Swift's intended audience? Do we want to cover all the bases for everyone from high level designers to assembly language programmers ?]

On a site like medium, I read a post recently from a C# developer who was despairing of the language evolution, where more and more complexity was being added, with the result that there were now so many corners of the language that debugging and supporting someone else's code could be nightmarish. I wouldn't want that to happen to Swift, which has started out life with a wonderful elegance, whilst still being performant.

So in summary, I understand the desire for this feature, however I wanted to raise the burden of language complexity that we might be layering on.
I also wanted to make a plea for the group to keep, if not simplicity (incredibly hard to define) but perhaps 'compactness' as a design goal, so that there aren't too many dark corners of the language that barely anyone knows are there, let alone understands.

11 Likes

Right, this should just be illegal because you can't move from a non-owning binding.

3 Likes

FYI, You do get an error here today with the shared argument:

test.swift:13:34: error: _move applied to value that the compiler does not support checking
    var cities:[City.ID: City] = _move cities
                                 ^

which is due to the shared. Now the error msg could be improved, but still it isn't accepted today.

2 Likes

+1. I like the idea of owned / borrowed words for parameter modifiers. Own is more understandable because:

  • own word is rarely used, while take is a common name – take(prefix:), take(while:), take(until:), take(after:)...
  • own more precisely describe what it does

In my opinion this also solves great what @beccadax posted above. At the call site we write own / borrow.

func foo(_ val: own T)
foo(owned x)

I agree. I was not suggesting having both move (general expression) and take (call-site modifier only), only pointing out the broken expectations of either approaches. I think I managed to find a solution a bit later: an expression producing a temporary move-only value.

Question. Can you edit this post above to say that this is actually not an issue since move doesn't work on shared? Otherwise I am worried that my response (that it can't happen) will get lost in the thread and create confusion.

1 Like

There's no mention on how would we change setters with the modifiers. Are setters must always adhere to default take behaviour?

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:

  1. borrow parameters just let you read a value;
  2. inout parameters let you modify it; and
  3. 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.

9 Likes

Thanks for your insights @Alvae!

set seems like it's isomorphic to a return value. In Swift, self in an initializer is effectively a named return slot, and indirect returns are implemented at the ABI level by destination-passing, so that a caller can pass the intended destination for a return value directly. Are there reasons to have syntactically out parameters that aren't returns?

Nothing here prevents us from making the optimizer better too, and nothing obligates you to use the operators if you trust the compiler to do the right thing. Swift does have to deal with the reality of heavily object-oriented APIs, so we have some incidental complexity that prevents us from making absolute no-exceptions guarantees we'd like to, but I think we can still do pretty well in the end. We are also working out the model that allows for the most implicit optimization for value-oriented code without breaking people's ability to work naturally with objects.

Note that, even if you do have hard optimization guarantees, it is still often useful to make guarantees that you're relying on explicit. Most functional languages guarantee tail call optimization when a call is in tail position, but developers still make the mistake of assuming a call is in tail position when it isn't and breaking the intended complexity of the algorithm they wrote. So many of those languages also have an explicit tail annotation (or their users pine hopelessly for it in the face of their language designers' purity). Explicit moves are basically the data analog to tail call optimization for code.

8 Likes

But must the sentinel be built into the language? There are possible designs in which the sentinel is delivered as an API—whether a standard library function or otherwise. That’s why I kept harping on move(_:)-as-function in the previous review thread.

The overall direction I see in this pitch and SE-0366 seems to reflect an effort to give the beginning programmer an illusion that the difference between borrow and take is not fundamental (despite the very real need for @escaping). IMO that will result in a more complex language overall, reflected in part by the spurious distinctions take vs. @escaping and borrow vs. ordinary pass-by-value, and in the use of call-site annotations in “expert” code.

Question for Swift designers: is it useful for us to discuss Val's approach in these forums? I think we're onto something that could benefit Swift greatly, but we don't want to just be a distraction.

2 Likes