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

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

One thing that I think is important to understand here is that when we mentioned move/c++ in the proposal, we were only using it to refer to the inspiration for the name, not the implementation. For instance:

  1. This doesn't disable NRVO.
  2. It doesn't leave behind a potential zombie value with underspecified semantics.

The way I would think about move is it is like Rust's drop except that it also works for copyable types and lets you get back out the moved value.

1 Like

One thing that I would like to make clear to the reviewers is that the move function is not intended to be the end all be all of tools in this space. The idea behind this system swift effort is to provide system programmers with a new toolbox of compiler driven power tools to get their job done. The move function is just intended to be a wrench in that toolbox and its existence should not be seen to imply that the language will not eventually provide a jack hammer (full on move only types) or a buzz saw (no implicit copy) in the fullness of time. NOTE: That fullness isn't too far in the future since we already have a no implicit copy implementation on main and I am currently prototyping on main full on move only types.

13 Likes

Yeah, I just wanted to emphasise that "copy" for references would be somewhat confusing ("copy a reference" vs "copy a referenced object") as historically in Obj-C we did have both "retain" and "copy" doing different things.

On naming. Like Array's removeFirst is used to remove first element and either use it to ignore it, we can use the name "drop" for both operations:

foo(drop(z))
let y = drop(x)
drop(y)

If to use an operator to make it feel more built-in:

foo(^z)
let y = ^x
^x

or

foo(z^)
let y = x^
x^
1 Like

Expanding on that thought, this is the alternative for pitch examples using nesting instead of "move".

The nesting alternative

Example from the pitch:

How we can do it instead:

func test() {
	var x: [Int] = getArray()

	// x is appended to. After this point, we know that x is unique. We want to
	// preserve that property.
	x.append(5)

	// We create a new variable y so we can write an algorithm where we may
	// change the value of y (causing a COW copy of the buffer shared with x).
	
	do {
		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 will be unique again here.
	x.append(7)
}

Another example from the pitch:

How we can do it instead (pseudocode):

func useX(_ x: SomeClassType) -> () {}
func consumeX(_ x: __owned SomeClassType) -> () {}

func f() {
	let other = do {
		let x = ...
		useX(x)
		return x
	} // x's lifetime ends
	// other is a new binding used to extend the lifetime of x
	useX(other)     // other is used here... no problem.
	consumeX(other) // other is used here... no problem.
}

The second example is more interesting as it asks for the value of "do {...}" block, something which is not currently possible (*):

let other = do {
	let x = ...
	useX(x)
	return x
} // `x` lifetime ended
// `x`  is moved to `other`

We don't have this today (*), but hopefully we will have it at some point (along with other statements like "if" and "switch" used as expressions).

Am I missing something obvious on why move is significantly better than nesting? It feels a bit like "goto" compared to more structured alternatives.

(*) Edit: in fact it is possible today, just using a closure:

let other = { () -> ... in
	let x = ...
	useX(x)
	return x
}() // `x` lifetime ended
// `x`  is moved to `other`

but it would be nice if "do" operator supported it as well (or better! without extra brackets and type annotations).

1 Like

True, but it's not a good idea to introduce cryptic syntax for a seldom used, niche feature. This isn't something programmers need to or should learn about when they start using Swift. They'll only be confused when they stumble across something that looks like stray characters.

2 Likes