Lifetime dependencies and conditionals

Hello! I have three mostly-unrelated questions that all pertain to the interactions between lifetime dependencies / conditionals / reassignment / inout argument dependence. I thought that I would group them here, rather than make separate posts. Any help is appreciated—feel no pressure to respond to all three questions simultaneously.

(1) inout argument dependence behaving like conditional reassignment

In the WIP Swift Evolution proposal for lifetimes, it is stated that

inout argument dependence behaves like a conditional reassignment

I am curious to know why inout argument dependence always behaves like conditional reassignment. Specifically, there are some functions where the reassignment always happens, meaning the conjoined dependence is an unnecessary overapproximation. As an example,

@_lifetime(x: copy y)
func reassign(_ x: inout Span<Int>, _ y: Span<Int>) {
  x = y;
}

The inout argument dependence here could act like unconditional reassignment, instead of implicitly acting more like @_lifetime(x: copy x, copy y).

I am also not quite sure if this is changing—when trying things out with a nightly build of the compiler, this behavior was not the case (i.e. this in fact behaved like @_lifetime(x: copy y) without the implicit copy x). But this behavior also appeared to be buggy, so I am not sure what the intended design here is.

Is this a language design question, or is the documented behavior necessary? I would imagine that the non-overapproximating alternative would be technically sound, possible to implement, just maybe not particularly useful. But my perspective here is not the most informed.

(2) Lifetime dependent variables escaping their scope

When reassigning lifetime-dependent variables in branches of a conditional, errors about escaping are raised inconsistently. I am curious about what the sound, intended behavior is.

func test() {
  var arr = [1, 2, 3];
  var span: Span<Int>! = nil;

  // this will always work, regardless of whether `arr` is `let` or `var`
  do {
    span = arr.span
  }

  // this will give an error when arr is `var` but NOT when it is `let`
  // error: lifetime-dependent variable `span` escapes its scope.
  if (Bool.random()) {
    span = arr.span;
  } else {
    span = arr.span;
  }

  // ensure that span is not implicitly dropped
  print(span[0]);
}

I would expect the example above to be accepted, regardless of whether arr is let or var. If this is the case, I am more than happy to file an issue on GitHub with this behavior.

More generally, I would expect that lifetimes to be adjusted by taking the least upper bound of the lifetime information in the two branches. For instance, I would imagine that in the following example, usage of span after the conditional would require read access to both arr1 and arr2.

  if (Bool.random()) {
    span = arr1.span;
  } else {
    span = arr2.span;
  }

Does this seem reasonable? If so, does the implementation align with this? I tried testing it out, but I couldn’t find a way to test this behavior without running into the var behavior noted above.

(3) Ternary expressions and borrowing

It appears that the “branches” of a ternary expression will consume those expressions. As an example,

func bor(it: borrowing MutableSpan<Int>) { }

func test(s1: borrowing MutableSpan<Int>,
          s2: borrowing MutableSpan<Int>,
          b: Bool) {
  // works
  if (b) {
    bor(it: s1)
  } else {
    bor(it: s2)
  }

  // error: s1 is borrowed and cannot be consumed
  bor(it: b ? s1 : s2)
}

My (evidently faulty) mental model for this is that the bor function requests its argument to be able to be borrowed, and that either branch of the ternary can be borrowed, so this should be okay. But, I’d imagine that the reason that this does not work is that the ternary operator itself consumes both of the expressions in its branches, making this get rejected.

Can expressions just not be borrowed in this way? I believe that once the Borrow type + accessors land, this should be able to work as follows (using syntax I’m mostly making up):

bor(it: borrow (b ? Borrow(s1) : Borrow(s2))

Does this seem plausible? Will there be language-level support (rather than library level through a Borrow type) for borrowed expressions like this? Am I just totally misunderstanding this situation?

Again, any and all thoughts on one or many of these questions would be appreciated.

5 Likes

(1) inout argument dependence behaving like conditional reassignment

Given:

func reassign(_ x: inout Span<Int>, _ y: Span<Int>)

@_lifetime(x: copy x) means that x can be mutated in place but cannot be reassigned to anything other than itself (unless the reassigned value inherits the lifetime of incoming x).

@_lifetime(x: copy y) means that x must be reassigned to y (or the equivalent from a lifetime dependence perspective)

@_lifetime(x: copy x, copy y) means that x may either be mutated in-place or (conditionally) reasigned to y.

The current proposal draft seems to be referring to the default dependency without annotation. This is an interesting question that I want feedback on...

According to the current in-tree documentation:
Same-type default lifetime (unimplemented)

Without annotation, the default dependency would be:

@_lifetime(x: copy x, x: copy y)

That default assumes possible reassignment to any other same-type parameter.

In my current (unmerged) implementation, however, I chose to revise the same-type rule to:

Where R: ~Escapable, A == R, and ais not aninoutparameter, default to@_lifetime(copy a).

Meaning that the default dependency in this case would follow the normal inout rule:

@_lifetime(x: copy x)

This creates a clear distinction between the same-type rule vs. the inout rule without any confusing overlap. Furthermore, it biases the default dependency of the inout result to its incoming value. If the API may reassign the inout argument to a different argument, then that needs to be explicit.

The counter-argument to my change is that the author of a public API can easily forget to write that annotation if the initial implementation does not reassign. If they decide to reassign the inout in some future implementation, they've broken source. But that's generally true of any API that produces a non-Escapable value. I don't think it warrants extra consideration here.

I'm about to land the same-type default rule with this change, so it's a good time to air any opinions on this choice of defaults.

[EDIT] To be clear, I’m asking developers to think about APIs in which the same non-Escapable type occurs in multiple parameter positions, one of which is mutating:

func reassign(_ x: inout Span<Int>, _ y: Span<Int>)

or

extension MutableSpan {
  mutating func foo(_ y: MutableSpan<Element>)
}

Does this indicate likely reassignment, or does it indicate normal mutation of the mutable parameter while simply accessing the contents of the other parameter. I suspect the later.

1 Like
  var span: Span<Int>! = nil;
  // this will give an error when arr is `var` but NOT when it is `let`
  // error: lifetime-dependent variable `span` escapes its scope.
  if (z) {
    span = arr.span;
  }

It is intentional that dependence on a var is different than dependence on a let. Depending on a mutable variable means depending on the access to that variable. Since the access is conditional, it's expected that the dependency is conditional.

This will, however, drive everyone crazy. It is possible for the compiler to hoist the access to var in most simple cases. This is a well-known feature request that simply needs to be prioritized.

1 Like
  // error: s1 is borrowed and cannot be consumed
  bor(it: b ? s1 : s2)

The move checker doesn't know how to borrow over a conditional. It's an implementation deficiency. I suspect we have a bug related to this, but it wouldn't hurt to raise a github issue.

2 Likes

Thanks for the detailed responses, Andy!

I see. I think that the part of the lifetime documentation that confused me was this:

inout argument dependence behaves like a conditional reassignment. After the call, the variable passed to the inout argument has both its original dependence along with a new dependence on the argument that is the source of the argument dependence.

If having the lifetime dependency @_lifetime(x: copy y) means that the function must reassign x to y, is it true that after a call to that function, x will have its original dependence as well? It would surprise me if that was the case, but it seems to be what the quoted (potentially outdated) documentation implies (if I understand correctly).

Thanks for sharing this documentation! This will definitely be useful to me as I continue playing with these lifetime features. I don’t write enough real Swift code to have a very valuable opinion on the same-type defaulting behavior; I think that your proposal sounds very reasonable.

One quick confusion I have from this description: is this procedure to determine default lifetime dependencies applied to inout arguments in addition to the function’s return? I’d imagine so given the context.

Sorry, I’m confused by this. I was expecting dependence on both a var and a let to depend on access to that variable. Why isn’t this the case for let? But then again, with that mental model, I still expected the code above to work (in both cases, not in neither case). Which indicates that I’m still missing something here.

I think that I was expecting for uses of span to depend on the access to arr, but that the access to arr would be extended until after the final usage of span. Is it that such extensions to accesses be made across a scope? Meaning if the access started in the outer scope, the extension would be possible? Or maybe my understanding here is entirely off.

Above, I assume that the hoisting you mention is taking place at the SIL level, and not something that can really be influenced at the source code level. Is this accurate?

Ack. I have opened a GitHub issue just in case, and linked it to this thread.

Are there any language constructs that the move checker does know how to borrow over?

Your intuition is correct. The draft proposal is wrong. It might have reflected an implementation of default lifetimes before it was fixed. When you specify @_lifetime(target: …) that needs to suppress any usual lifetime defaults for target. Otherwise you just wouldn’t be able to override the defaults.

I propose that an argument only participates in either the inout default rule or the same-type default rule but never both.

inout rule

Default to @_lifetime(a: copy a) for all inout parameters where a is ~Escapable.

same-type rule

func foo(..., a: A, ...) -> R { ... }

Where R: ~Escapable, A == R, and a is not an inout parameter, default to @_lifetime(copy a).

// inout default: @_lifetime(a: copy a)
// same-type default: @_lifetime(copy b)
func foo(a: inout T, b: T) -> T

So, by default, the result does not depend on mutable parameter a, and the mutated value of a does not depend on b.


The alternative is that we have no inout rule at all, and inout dependencies are just a variant of the same-type rule. While a single rule may seem simpler, it leads to confusing and typically wrong defaults:

// same-type default: @_lifetime(a: copy a, copy b)
// same-type default: @_lifetime(copy a, copy b)
func foo(a: inout T, b: T) -> T

I think it will be more natural for programmers to think about inout dependencies separately from result dependencies.

This is about Span’s exclusivity requirement. Depending on a variable depends on an exclusive access scope. For let, you can think of the entire variable scope as a single access. For var each reference to the variable is a separate access. Yes, that access will be extended to the last dependent use (that’s a SIL transformation). If, however, the access is inside a conditional statement, extension isn’t sufficient. Accesses are scoped, so you can’t “extend” into an outer scope. We also need to hoist an access (into the outer scope), and sometimes combine multiple accesses into a single access. That’s reasonably straightforward to do at the SIL level but unimplemented.

Meaning if the access started in the outer scope, the extension would be possible?

Correct

Yep, I’m talking about a SIL transformation. You might be able to hoist the access at the source level, create two spans, and conditional reassign one, but the compiler should do this for you.

1 Like

Awesome, these responses were just what I was looking for! Especially the bits on extending accesses across scopes. Thank you for your time. I have just two last comments (now that I am revisiting this much-appreciated response):

This was somewhat surprising to me. In my current model, accessing let and var (and function parameters too) are handled more similarly to each other than this suggests. I don’t expect you to have a concrete answer here, but does my approach sound suspicious? Is the behavior described in the quote above more of an implementation detail, or something more fundamental about the programming model? Even just a rough vibe here could be useful, to orient my future considerations (especially since I lack some intuition here).

I understand what it is that you are saying, and this helps me understand the behavior above. Perhaps I should know better, but why is this the case? If the newly started accesses don’t depend on things which go out of scope, couldn’t this be relaxed? (Or, maybe this criterion for “relaxing” is really just the criterion for being able to do the hoisting you discuss at length).

And now I’m a little confused as to why the do version of this example works. Is the compiler able to hoist the access in that case?