Selective control of implicit copying behavior: `take`, `borrow`, and `copy` operators, `@noImplicitCopy`

It is occasionally important for performance reasons to end the lifetime of a take parameter before the end of the function. (I agree that this is worth mentioning explicitly, and that it's harder to justify without having explicit take parameters in the language yet.)

1 Like

There are a few reasons not to rely only on lexical scope to end value lifetimes:

  • We don't want to impose "pyramid of doom" situations when you have more than one value whose lifetime you need to cut off.
  • Value lifetimes don't necessarily need to nest. var x = foo(); var y = bar(); consume(take x); consume(take y) should be valid.
  • It is useful to be able to take the current value out of a mutable variable and reinitialize it later.

Rust 1.0 originally only had strictly scoped value lifetimes, and this was a significant ergonomic constraint until "non-lexical lifetimes" were added later, which allowed for value lifetimes to shrinkwrap to their actual duration of use. (We had originally tried that same shrinkwrap-to-last-use rule in Swift, too, actually, but found that it broke idiomatic Cocoa code that makes heavy use of weak references too aggressively.)

7 Likes

Joe, these are great answers, thank you, please include some wording to that effect into the actual pitch once it is in the form of actual pitch.

I can see these two arguments:

  • we can sort of do this today without nesting:
func test() {
    var x: [Int] = getArray()
    x.append(5)
    var y = x
    longAlgorithmUsing(&y)
    consumeFinalY(y)
    guard let y = drop() else { fatalError() }
    x.append(7)
}

func drop() -> Void? { () }

although I admit that this is cheating / abusing and not as ergonomic as your explicit "take x" or "drop x".

  • The second one is harder to swallow – and I ask for an apology for repeating this argument from the previous discussions (just I never saw it answered) – the fears of "pyramid of doom" are understandable, just I can see how a similar line of reasoning can justify the introduction of "goto" statement ("makes language simpler"; "instead of all those "while's" / "for's", braces and scopes / "pyramids of doom" just have "if" and "goto""; "allows for an ultimate flexibility impossible to express via "structured" alternatives"; "language X has it and it works great in there".)

I'd like to see some strong motivated example that makes the superiority of explicit "take" / "drop" approach obvious. Hopefully people here who are familiar with Rust or other language with a similar feature could come up with some killer example.

1 Like

Just a short note here but I think this will be really valuable for us working on swift-nio. The number of circumstances where we really have to wrestle with ownership carefully is low, but there are a few places where we'd really benefit from giving the opportunity to our users to make their lifetimes clear, as well as in our own implementations. I think this proposal is clear and pragmatic, so I'm a strong +1.

4 Likes

Looks like you are in a good position to provide that killer example..

Have a look at this one:

func test() {
    var x = ValueType(1)
    print("x.append(5)  -->")
    x.append(5)
    var y = x
    longAlgorithmUsing(&y)
    consumeFinalY(y)
    print("x.append(7)  --> ")
    x.append(7)
}

It is the same as in the pitch above, just making use of some explicit types to see the internal nuts and bolts:

The value / ref types used.
class RefType {
    var value: Int
    
    init(_ value: Int) {
        self.value = value
    }
}

struct ValueType {
    private var ref: RefType!
    
    init(_ value: Int) {
        ref = RefType(value)
    }
    
    mutating func append(_ value: Int) {
        if isKnownUniquelyReferenced(&ref) {
            print("  quick assignment")
            ref.value = value
        } else {
            print("  COW assignment")
            ref = RefType(value)
        }
    }
    
    mutating func drop() {
        ref = nil
    }
}

func longAlgorithmUsing(_ v: inout ValueType) {}
func consumeFinalY(_ v: ValueType) {}

As written the above example would produce the expected (and unwanted) sequence:

x.append(5)  -->
  quick assignment
x.append(7)  --> 
  COW assignment

Then we introduce this extra line:

func test() {
    var x = ValueType(1)
    print("x.append(5)  -->")
    x.append(5)
    var y = x
    longAlgorithmUsing(&y)
    consumeFinalY(y)
    y.drop()  // **************
    print("x.append(7)  --> ")
    x.append(7)
}

and the log changes to the desired:

x.append(5)  -->
  quick assignment
x.append(7)  --> 
  quick assignment

Just note that in this case the "drop" is merely a function of "ValueType":

struct ValueType {
...
    mutating func drop() {
        ref = nil
    }
}

and no language change was needed to achieve the desired "non COW" behaviour. What am I missing? Is it not just a matter of agreement for value types to provide a function called "drop" and changing std types to adopt that convention?

As before, I would rather we found a different name for "copy"; it's a highly overloaded term, even in Swift. For example, "copy on write" refers to a different kind of copying, as do established Cocoa APIs such as NSCopying and NSMutableCopying.

I'm also very much -1 on the name @noImplicit{Anything} - Since it is phrased as an opt-out, it gives me the impression that the compiler typically does something undesirable, at least in some situations; and that I can't trust the language as it works by default. It feels like @noNannying or @noHandholding. It immediately makes you think "why would somebody want to disable this?".

I think @explicit{Copy/Lifetime/Ownership} is preferable; I don't feel it has such a connotation that the default behaviour is incorrect or undesirable. It's very... well, explicit about what it is trying to achieve - that you want to make a value's lifetime events visible in code, even if the default behaviour happens to also be correct.

At least, that's how I read it. Perhaps not everybody reads it like that.

4 Likes

Counterpoint: I think it's plausible to read @explicitCopy as a marker of an explicit copy rather than a marker that requires explicit copying. IOW, in isolation, @explicitCopy seems like it could be another proposed spelling for the copy operation.

In that sense I think @noImplicitCopy is better at describing what it's actually doing (disabling the default implicit copy behavior).

That doesn't mean that the polarity of @explicitCopy is incorrect, but I think something like @requireExplicitCopy would more accurately describe what's going on here, even if it is a bit more verbose.

6 Likes

The reason I said @explicit{Copy/Lifetime/Ownership} is because I'm not a fan of the word "copy" being used to describe this operation at all. Either of the latter two - @explicitLifetime or @explicitOwnership, are what I would prefer.

Whether or not they are also confusable with the operation itself depends on what the operation ends up being called.

2 Likes

That’s how @explictCopy sounds to me too. How about @explictCopying to mark explicit copying?

1 Like

I agree with that sentiment.

Would it make sense if we called this operation "duplicate" instead of "copy"? The operator could be called dup for short:

  • dup: contextual operator to create a duplicate
  • @overtDup: dup must be explicit
  • @noDup: duplications are forbidden (move-only), for a future proposal
func doStuff() {
  @overtDup var x = "hello"
  x += " world"
  read(dup x, andModify: &x)
}

Also, I think dup x should be a warning when the variable or type is not @overtDup (possibly with a fix-it to add @overtDup to the variable). I don't think there is a point in using dup, other than making unnecessary duplicates, when the compiler is managing it for you.

Was that hard to read? ... same thing reworded with "copy":

Also, I think copy x should be a warning when the variable or type is not @noImplicitCopy (possibly with a fix-it to add @noImplicitCopy to the variable). I don't think there is a point in using copy, other than making unnecessary copies, when the compiler is managing it for you?

The thing about the word copy is that we're actually talking about something even shallower than a "shallow copy". let y = copy x does not actually construct a new object; it constructs a new reference. Basically, it's a retain, and @noImplicitCopy is really "no implicit retains" -- it makes certain ARC operations, which are otherwise invisible, visible.

To underscore that point, consider that because NSCopying exists, we will have objects where:

someFunction(x.copy())
someFunction(copy x)

Have entirely different semantics. NSCopying declares that it produces an "independent object", and as such we can expect it to stop the sharing of mutable state:

a copy must be a functionally independent object with values identical to the original at the time the copy was made

NSCopying documentation

copy x (the operator being discussed in this proposal) does not produce an independent object, and that is not its goal - it exists to extend the lifetime of an existing object. The result of copy x may still share mutable state with x.

We also use the word copy in Obj-C properties in a way that aligns with NSCopying:

copy

Specifies that a copy of the object should be used for assignment.
The previous value is sent a release message.
The copy is made by invoking the copy method. This attribute is valid only for object types, which must implement the NSCopying protocol.

assign

Specifies that the setter uses simple assignment. This attribute is the default.
You use this attribute for scalar types such as NSInteger and CGRect.

retain

Specifies that retain should be invoked on the object upon assignment.
The previous value is sent a release message.

The Objective-C Programming Language

This is just the confusion we can expect within the Cocoa/Obj-C ecosystem, and among developers used to that terminology and way of thinking ("copy produces an independent object"). We should also consider what C++ developers will expect when writing copy x on an imported class (if they are managed by ARC in some way).

This is only true for reference types. Copying a value certainly produces an independent value.

I don't think there's any escape hatch there. A value may contain a reference, in which case @Karl's point is still valid.

It seems to me that this isn't just about retaining objects. In principle, @noImplicitCopy refers to any aspect of the value that the compiler might regard as duplicative in normal use, and which might be worth expending effort to avoid.

For example, it might be that the stack-based memory for a value might be regarded as a precious resource, so that it might be undesirable make additional stack copies of some very large struct.

It's been a truism of Swift that values of value types (unless modified) and references to objects of reference types exist in multiple interchangeable copies. This proposal introduces the idea of non-interchangeable copies, a distinction that exists regardless of the value/reference divide.

This looks great to me overall! Very nice to have a more complete picture of the ownership story related to take.

This as the headline motivating example of take remains unsatisfying to me. The text says take is introduced to solve the problems that 1) there is no "strong guarantee" that the compiler will optimize the lifetime of y to end before the final use of x and 2) future modifications of the code may introduce uses of y that inadvertently extend its lifetime. But as far as (1) goes, it seems even with take we have no such "strong guarantee":

And (2) still seems problematic since it does nothing for introducing new uses of x that are interleaved with the 'critical section' where y is alive, e.g.:

func test() {
  var x: [Int] = getArray()
  x.append(5)
  
  var y = x
  longAlgorithmUsing(&y)

  x.append(6) // non-unique!

  consumeFinalY(take y)

  x.append(7)
}

When I raised this previously it was pointed out that the solution I was reaching for seemed similar to the borrow variables discussed in the updated ownership roadmap, but that still leaves me with questions in regards to take. Is some form of borrow variables the intended solution here? If so, why is this example being used to motivate take?

Ought we have some sort of marker in these cases at the point where the non-transitivity of @noImplicitCopy causes us to lose its guarantees? It seems a bit silly to provide the functionality of @noImplicitCopy and also allow it to end up getting silently discarded.

And somewhat relatedly, is copy intended to guarantee that the compiler really will emit a copy operation, or does it just serve to allow the compiler to emit a copy when necessary? If the latter, would it make sense to just use copy as that marker to indicate that you may end up copying the value by passing it somewhere that loses its @noImplicitCopy-ness?

The example does not directly defend either of these claims...

Language rules give us strong guarantees about the static lifetimes of variables. Language rules don't give us strong guarantees about when copies that alias become unique again, because that information doesn't exist statically. Once you have separately owned aliasing references, you can't prevent someone else from introducing code that mutates one and copies the elements.

I do think the example makes it easy to understand how take can avoid copying CoW elements. And there's nothing wrong with relying on reasonable optimizer behavior. Of course, you could insert paranoid optimizer controls if you felt inclined:

  consumeFinalY(take y)
  withExtendedLifetime(x) {}
  x.append(7)

or

  consumeFinalY(take y)
  lifetimeBarrier() // DIY barrier
  x.append(7)

It would be interesting to talk about adding "lifetimeBarrier" to the standard library. That's entirely tangential though. Again, I don't recommend actually writing this code!

It's easy to defend the two main claims if we just focus on eliminating copies. Real-word cases frequently involve forwarding. Here, take is needed to avoid a reference copy (grep for __owned or __consuming in the stdlib code to see many places that need take):

  public func isSuperset<S: Sequence>(of possibleSubset: take S) -> Bool
   where S.Element == Element {
     if let s = possibleSubset as? Set<Element> {
       return isSuperset(of: take s)
     }
     //...

Avoiding useless copies is important enough on its own to justify the proposal. But it's more compelling if we bring it back to CoW performance. Avoiding copies in general can indirectly avoid copying all the elements in a CoW container. Here's a really terrible example off the top of my head:

func doNotCopyArray<T>(membership: inout Set<T>, array: inout [T], value: T?) {
  let subset = value != nil ? array : []
  //...
  if (!membership.isSuperset(of: take subset)) {
    array.append(value!)
  }
  //...
}

I'm sure someone can think of something less contrived.

1 Like

Sorry, I've been a bit unclear. I think we're saying very similar things. My point is more of a rhetorical one: I think using this CoW example as the primary motivating example for take is confusing precisely because it does not directly defend either of the claims that are used to introduce it. This CoW example was the only example raised in the Motivation section of original take pitch, and I think treating it as the main motivation for take muddies the waters.

That take also provides a way to avoid CoW triggering in the face of potentially-aliasing variables is a nice additional benefit, sure, but IMO it's better if the primary motivation for take are the situations where take is needed to avoid pessimistic copies.

1 Like

Sorry, it wasn't clear that I was agreeing with your observation. The example needs to be replaced--probably by a few simpler ones.

One point I should make on "optimizer guarantee" argument though...

There is a critical difference between the two kinds of optimizer guarantees we're talking about. This proposal claims to make strong guarantees that compiler can find the optimal points where a variable's static lifetime ends. The programmer expects the optimizer to be "smart enough to figure it out" despite arbitrary code that may be increasingly complex as the code evolves. That's best done explicitly.

Your counter example talks about whether the optimizer will introduce some pessimization in case the programmer has already created unavoidable copies. "Don't do something crazy dumb" is not something we promote using explicit source controls for. In fact, as the code evolves and becomes more complex, it would be harder for the optimizer to introduce the pessimization.

1 Like

In another topic, the question of whether to adopt __shared led to the conclusion that sometimes the function author just doesn't know whether it will be beneficial. For example, general functions like map(_:) can’t know whether any particular argument benefits from being __shared.

This design proposes take and borrow keywords adorning parameters at the call site, but these keywords only serve as static assertions that the same annotation exists on the corresponding parameter declaration. Can we possibly address the first use case by enhancing the function call ABI to reflect the caller’s ownership preference? For example, a function declared func foo(param: unowned Int) could be called as foo(shared globalInt) or foo(take localInt) (*); at the ABI level, foo would take a hidden parameter that describes whether param was passed shared or take. The downside is increased code size and branchiness within foo, but this could be well worth it for significantly large value types with many members.

(*) Footnote: I still would like to advocate for keywords only at the declaration site, and replacing the call-site keywords with standard library functions move(_:), borrow(_:), etc.

I can think of cases where you might want to have a function that polymorphically ties the convention of a few parameters together (for instance, it'd be cool if map could borrow when the mapped function borrows, takes when the mapped function takes, inouts when the mapped function inouts), but it seems to me like it'd be hard for the callee implementation to take concrete advantage of the benefits of any convention at all if it's entirely up to the caller—every attempt to consume it would need to have a conditional copy in case it was borrowed, and we would need to conditionally destroy it on every exit in case it was taken.

5 Likes

Thanks for the reminder. Based on the discussion between you and Andy, I reworked the motivating example for take to something that wouldn't depend on lifetime barriers to sequence the lifetime of two copies of a variable. I've also incorporated the feedback from the SE-0366 review and the discussion in this thread to revise the proposal:

Here is the new motivating example from the revised proposal:

7 Likes