How is __shared keyword supposed to work in Swift 5.0?

Thanks @Joe_Groff. That's right, there's no debate about how we want the code to behave. The debate is about how people will expect the code to behave based on the name __shared and how it was described earlier in this thread and in the current documentation. I'll try restating my position again in case it wasn't clear (yes, I think it's pretty important).

I want to unambiguously communicate the argument passing semantics for a trivial function application. I don't expect users to pick apart separate complex caller/callee semantics and try to infer the simple argument passing semantics from that.

Tweaking the original example:

func f(sh: __shared Int, f: () -> ()) {
  f()
  print(sh)
}

var x = 5
let f = { x += 1 }
f(sh: x, f: f)

By messaging that __shared is a "shared value" convention, we encourage people to interpret it as Rust's 'immutable borrow'. The only other possible interpretation, given that we've messaged it as a change to argument passing semantics would be C++ 'const &'. Both are wrong.

The counter argument is that users should know that it's impossible to pass a mutable variable of copyable type as __shared, and therefore they should expect the compiler to create a new invisible variable that can then be "shared" with the callee. Even if I could accept this logic at face value, it is totally counter intuitive. Swift could easily have made it legal to pass a mutable variable of copyable type as __shared with exclusivity enforced. The fact that it doesn't work this way then just looks like an implementation quirk that no one would expect.

In my mind, when the user writes f(sh: x, f: f), at the highest level of semantics it should mean that x is the "variable" that will be bound to the sh argument. It's unnecessarily confusing to think about this as the caller implicitly passing a new invisible variable to sh. In fact, I would say that, by creating that temporary phantom "variable", the compiler contradicts any reasonable interpretation of __shared.

Instead, I would progressively explain Swift's conventions this way:

Level 1: Argument Passing

Value types have value semantics, reference types have reference semantics. Swift's argument passing semantics are always pass-by-value for value types where inout means copy-in, copy-out. End of story. Whether a callee declares ownership of the argument does not affect these semantics.

Level 2: Lifetime

For lifetime-managed values (references or move-only types) the user can override whether the caller or callee takes ownership of the argument for the purpose of ending its lifetime. This can reduce the copies required at the implementation level. There are no semantics here. The compiler is free to destroy things out of order.

Level 3: Move-only semantics (straw man)

Now the declaration of ownership can affect semantics simply because variables of move-only types are semantically different depending on whether the variable has ownership. So it's really the implicit ownership conversions that have semantics, not the argument passing itself. For arguments of move-only type:

  • owned to owned: implicit move

  • owned to unowned: implicit borrow (exclusivity enforced)

  • unowned to owned: illegal

For mutable variables with copyable types, all of the above implicitly copy, as pass-by-value semantics dictate. The only one left is:

  • unowned to unowned (any type): no implicit operations

Passing an unowned variable to an unowned argument is the only situation that could reasonably be called "shared".

In the owned to unowned case, even though copyable types will be semantically copied, as an optimization and ABI convention, the implementation can share references by guaranteeing lifetimes. This ABI is required to implement move-only semantics, but does not by itself change program behavior!

Level 4: Explicit borrow and move (straw man)

I think it's important for the programmer to be able to achieve the same argument passing semantics for copyable types that move-only types will implicitly adopt. So, to get the behavior of Rust's immutable borrow we could invent syntax like f(sh: borrow(x), f: f). This would give the user a way to declare an expectation of zero abstraction cost, free the implementation to remove physical copies, and, in the running example above, produce a diagnostic that the code is illegal because f will modify x.

8 Likes