SE-0366: Move Function + "Use After Move" Diagnostic

There's a discussion about this topic over here: Move-only types and Any

3 Likes

I’m not sure that move(_:) as a function provides such a tool. For example, the semantics of inout are defined in terms of a temporary copy and write-back. There have been requests (from myself and others) for a way to statically guarantee in-place mutation. Does move(_:) as a function achieve that?

Meanwhile, encoding move-only-ness in the type makes it easier to extend the definition of inout to guarantee in-place modification of move-only values while retaining the current combination of formal semantics and best-effort optimization that applies to copyable types.

But in a world with Copyable, either everyone would have to write : Copyable to get the current Swift behavior, or there would need to be a new syntax for negated type constraints (e.g. : !Copyable).

This all goes toward the question of whether move(_:) feels “Swifty”. Swift definitely has a history of leaving things unwritten, especially behaviors that are guaranteed by the language. The behavior of : MoveOnly can have guaranteed effects at the use site without requiring explicit spelling unless a programmer wants to opt in to a using move-only envelope.

I went back to double-check the design, and I do think there’s a hole:

let variable1 = 100
let closure = { print(variable1) }
if Bool.random() {
  let variable2: Int = move(variable1)
  closure()
} else {
  closure()
  let variable2: Int = move(variable1)
}

variable1 meets the definition of “movable binding” according to the proposal, but clearly one of these codepaths is ill-formed. Unlike the examples in the proposal, variable1 is captured, not referenced in an argument.

Does the compiler “tag” the value assigned to closure with the fact that it captures variable1, and reject the true branch of the if? Or does it permit the capture and blow up at runtime in the case where move(_:) is invoked before closure()? (I believe this is undefined behavior in C++, but in Swift it would probably require runtime bookkeeping.)

If the move-only-ness of variable1 were encoded in its type, the compiler could statically reject the capture of variable1.

1 Like

Wouldn’t closure here be implicitly @escaping (even though it doesn’t actually ‘escape’) thereby making variable1 non-movable?

I’m not seeing why it would be “implicitly @escaping.” It doesn’t escape its enclosing scope.

I suppose @escaping could be the “tag” I mentioned, but the compiler would then need to retroactively add that “tag” once it encountered a move of a captured variable.

As it stands, the move intrinsic does not carry any special side effects. I don't think we want to give it special side effects either. That's contrary to the intent of "just express what we intended the optimizer to do without otherwise inhibiting optimization". So I can't think of a language rule that prevents the transformation above. But I agree that it's harmful optimization behavior and should hopefully never happen in practice. The compiler does have a conservative understanding of where uniqueness checks may be performed. It would be pretty bad to hoist a uniqueness check above deinitialization of a potentially aliasing reference.

I'm seeing two purposes for move:

  1. We want to avoid making copies.
  2. We want to ensure lifetime of a variable is terminated at a certain point.

I think move does a poor job for #1. Copies are implicit, so writing move at one place does not ensure no copy will be made unless you have a very accurate model of the language in your head. I think it'd be better to reverse the burden of proof by annotating variables in a way to force copies of it to be explicit. Perhaps like this:

@noimplicitcopy let x = 1
let y = x       // error, copy must be explicit: use copy(x)
let z = copy(x) // ok

And move is not a very good name for #2. If you want to end the lifetime of a variable at a certain point, it should have a more adequate name:

let x = 1
let y = endLifetime(x)
endLifetime(y) // @discardableResult
8 Likes

And ditto for retain ?

I thought that closure expressions defaulted to @escaping absent other type info—the following compiles without error:

class C {
    var h: () -> Void = {}

    func f() {
        let h = {}
        self.h = h
        g(h)
    }

    func g(_: @escaping () -> Void) {
        print("escaped")
    }
}

That would prevent the following very simple example from compiling:

let a = 100
let b = { a + 1 }()
let c = move(a)
1 Like

This definitely fits with my experience with move semantics in C++. I've gotten very good at identifying at a glance where implicit copies will happen and after years of working with it I now find it pretty easy to avoid undesired copies, but that did take literally years. Based on my experience reviewing PRs, many developers who are otherwise very strong at C++ don't always have this intuition developed.

I think the Swift rules for implicit copies are more complicated than those of C++ (unless it's just comparatively less experience with avoiding copies in Swift on my end). I can see why move() does something useful in the examples given, but they aren't places where I would have immediately identified just from reading the code.

I think the current proposal is not something which the average developer will be able to use to successfully avoid implicit copies outside of cases where they're specifically profiling and benchmarking a specific function. OTOH, I'm not sure that's really a problem? In my experience overhead from implicit copies that aren't actually needed isn't a common problem in Swift to begin with. If move() is solving a niche problem that most developers don't need to solve, then does it really need a design that works without specifically profiling the function being optimized?

3 Likes

I want to refine this opinion a bit. “Viral” seems like the wrong word, given that const is truly viral.

std::move() is more like a magic spell. The instant I see std::move(), I immediately start to worry about how load-bearing it is. Removing it might or might not affect performance, depending on the optimizer. Or it may affect correctness, if the move constructor enforces some invariant. Or it may even be incorrect, as std::move() in return position often is.

I don’t want to be struck with the same fear by encountering a move(_:) expression in Swift code.

2 Likes

I want to throw out perhaps a middle ground here: What about a standard library operator?

I'm not referring to making move an operator spelled as-is, I mean some counterpart to f(&x) for inout which could be declared as a regular standard library operator but, by virtue of eschewing the standard function calling parens at the call site, avoids reading as "just any old function."

For example, suppose we spelled the operation as prefix <-:

// Proposed syntax:
f(move(x))
// New bikeshed syntax:
f(<-x)

// Proposed syntax:
let y = move(x)
// New bikeshed syntax:
let y = <-x
4 Likes

Swift's "hidden" retain/release (ARC) is great for general code, but it does have limits. In particular, over the last two years, we've worked to develop a set of ARC optimizations that tries to balance the need for predictable behavior vs. the desire for optimal performance. While I personally am quite pleased with the result overall, I also realize that it is a compromise: code with particular performance constraints will need to vary from the default ARC behavior.

The move operation is one tool for that purpose: As described in the proposal, it can be used to selectively control lifetimes of particular objects, clearly documenting the programmer's intention to avoid certain retain/release or copy-on-write operations.

As such, this move operation should never be "required" in any situation to ensure correct behavior. (This is significantly different from C++ std::move, which is sometimes required in order to avoid memory leaks.)

Tim

7 Likes

Move-only types do not eliminate the utility of a move operation.

Most types in Swift will always be copyable, and so there will always be a need for developers to exercise occasional control over the copying of such types.

I think a comparison with C++ std::move is rather misleading. C++ std::move exists specifically to control copy behaviors in a language that does not automate memory management.

Swift is still automatically managing all memory, even in the presence of move operations. The move operation is only a way for the developer to improve the compiled result by more clearly documenting their intent. By explicitly denoting the end of a variable lifetime, the developer can help the compiler make more appropriate choices about memory management for specific values.

This is not fundamentally different from @noescape annotations: You would still get correct behavior if every closure argument were conservatively treated as escaping, but the ability to annotate some closures as escaping and some as non-escaping allows you to specify cases where the compiler should favor performance (non-escaping) over utility (escaping).

Most developers may indeed never need to use a move operation in Swift. I believe that Swift's design should always prioritize providing good behavior for the majority of developers without requiring specialized tools like move.

But many developers will rely on high-performance libraries that do indeed need to fine-tune their performance. The underlying ideas here were inspired by discussions with people building some very high-performance Swift libraries: Those developers are indeed doing significant profiling and identifying specific points where the compiler's defaults are not always ideal.

Tim

11 Likes

Immediately-applied closure literals are nonescaping.

7 Likes

I think the proposal is nice, and particularly appreciate the attention to detail with defer and repopulating an inout after moving from it.

I agree with the general concerns above. move isn't a function and breaks composition in weird ways, it is built-in.

I think that Xiaodi's suggestion is good, given that operators feel more "built in".

I'd throw out <* or <= or << or <@ as other examples for consideration, e.g.:

  var y = x
  longAlgorithmUsing(&y)
  // We no longer use y after this point, so move it when we pass it off to
  // the last use.
  consumeFinalY(<*y)
...

  _ = <*x

Both connote "pulling the value out of the RHS".

-Chris

11 Likes

For a class/actor reference, copying the reference is what ends up calling retain. I could rewrite my example with this spelling and it'd be exactly the same thing:

@noimplicitretain let x = MyObject()
let y = x         // error, retain must be explicit: use retain(x)
let z = retain(x) // ok
1 Like

I think it's a fair observation that a move annotation and and no-copying constraints are complementary approaches to a similar problem of wanting to increase control over the lifetime and copying of values. However, I think even when we have move-only types, no-implicit-copy annotations on values or scopes, and other such tools, an explicit move operation still has a place, as additional emphasis that the move-only value is expected to end its lifetime at a specific location. Although consuming a move-only value will provide a static constraint that the value can't be used again, that might not be evident to readers or future maintainers of the code, who might otherwise refactor the code to cause the value to be consumed later, or dropped without being consumed. I could still see the ability to explicitly mark the end of a value's lifetime being useful even with move-only types, so I think the proposed feature is still complementary to those future language features.

7 Likes

This is the epitome of a Swifty approach.

The vast majority of Swift programmers do not need to control lifetimes. The language lets them go about their business without being burdened by lifetime annotations. In those rare situations where the design depends specific lifetime behavior, programmers can express their intentions. That's important not only to verify assumptions but to communicate to other programmers.

The move intrinsic is complementary with a near-future @noImplicitCopy annotation along with other lifetime controls. They're being introduced in the order that it makes sense to develop them and get programmer feedback. Naturally, that tends to be in order if increasing importance. If you find yourself saying "I won't use this control, but would like the next one", then that's reasonable and expected.

We're starting with niche tools now, but they are an important steps toward introducing move-only types, which will be a much more visible language feature. Move-only types build directly on top of these controls and will inherit their semantics. Being able to experiment those semantics earlier means that the design of the larger feature will be better when it is introduced.

6 Likes