A move semantics centric view of drops (Addressing problems of RFC PR 210, Take 3)


#1

Old title: A lifetime-management centric view of drops. I think the new one fits the contents better.


When I was preparing the RFC (which never sees the light of the day) for the ~ early drop point marker, I gave some thought into what implicit drops really mean, and realized:

Early drops are measures, not problems.

They are measures for balancing object moves in different branches of execution.

This is a very important matter of perspective.

A matter of perspective

C++'s move semantics is bolted on, just like many other things there. In particular, move semantics there is constrained by its implicit destructor invoking semantics. And what we call drop flags, is originally a way to walk around C++'s limitation. It’s just that in C++, drop flags is a convention which must be implemented by the programmer manually, while in Rust, it is automatically handled by the compiler.

But Rust doesn’t have to inherit this limitation. Rust is a clean slate centred around ownership and lifetime management, so it is natural to think about drops, or anything for that matter, in this context.

It’s all about lifetime management

What is ownership?

  • The ability to control an object’s lifetime.

What is a move?

  • An action to give up/transfer ownership.
  • The target is said to consume the object.

What is a drop?

  • A special move - into oblivion.

What can the owner of an object do?

  1. maintain ownership - do nothing, or pass the object by reference;
  2. explicitly transfer ownership - call consuming functions, including drop, or do move by assignment;
  3. implicitly give up ownership - the compiler inserts implicit drop calls.

The move guarantees and implicit drops

What Rust currently provides, the so called dynamic drop semantics, actually satisfies the following guarantee:

Emptied Scope Guarantee.

  • At the end of a scope, all movable objects owned by the scope are guaranteed to have been moved out of the scope.

And, the implicit drops at the end of scopes, or implicit scoping drops as I now call them, are drops we are all familiar with. They are measures the compiler takes to satisfy this guarantee.

The Rust compiler’s reasoning, from this point of view, is as follows:

  1. All owned objects must be moved out.
  2. But the programmer doesn’t explicitly move some of them.
  3. Implicit moves needed.
  4. But where to?
  5. The only destination that I can think of without the programmer’s explicit instruction, is oblivion.
  6. So, I should insert implicit drops.

Well we can see, implicit drops already exist in Rust, and they are measures to satisfy a guarantee, not disasters waiting to happen.

The above also applies to the so-called static drop semantics and early drops.

“Wait! But implicit scoping drops don’t cause problems!”

Eh, no. Consider:

fn fancy() {
    let foo = ...
    ...
    consume(foo);  // important, must call!
    ...
}

What happens when you forget the consume call? You lose foo to oblivion. If instead you are required to explicitly call drop(foo) at the end of the scope, maybe it’ll be easier for you to remember to call consume?

So, just like sometimes you do not want early drops, sometimes you do not want the “not-early” drops too. They are not as different as you may think.

“OK, then what?”

So, what exactly is static drop semantics? What exactly are early drops?

Or, as I’d like to say, the balanced move semantics and implicit balancing drops.

Balanced move semantics also satisfies the Emptied Scope Guarantee. Additionally, with the help of implicit balancing drops, it satisfies:

Balanced Move Guarantee

  • All branches of the same branching operation are guaranteed to move the same set of objects.

Yeah, simple like that.

The “problems”

“Well, from this new perspective, the new semantics is indeed simple, but it is still different to how C++ works so …”

Balanced moves makes code reasoning harder.

Nope, I’d argue that it actually makes code reasoning easier, if you know the additional guarantee.

With dynamic drops, you cannot just look at an object somewhere in the code and statically determine whether it is alive or not, because its liveness is decided by the code path that get executed at runtime. But with balanced move semantics, you can do this.

“But if I look at a single code path, it may drop something completely unrelated.”

Question, does this matter?

No? Then why bother?

Yes? Then you are worried that this code path may be dropping something you care about.

The only reason this path may be implicitly dropping it, is that it get moved in another path.

Do you move it in another path?

Of course you know, because you care, right?

Anyway, balanced moves effectively “forces” the programmer to get the big picture. I think this is a good thing in the long run.

Balanced moves make it harder to ensure that an object has scoped lifetime.

Eh, no again. The only way to ensure that an object has scoped lifetime is ensuring that it never moves out of the scope before the end of the scope. If you want scoped lifetime, then surly you will not explicitly moving it.

  • If you are not explicitly moving it in branches, the balanced move guarantee would guarantee that it would not get moved/dropped in any branches.
  • If you are explicitly moving it in one branch but not others, then you already give up control in that one branch, and anything can threaten the object’s life. You are already doing it wrong today, balanced move semantics or not, irrelevant.

So this leads us to the big question …

What new problems does NoisyDrop/QuietDrop solve

I don’t know.

As I said above, balanced moves doesn’t introduce new problems, if anything, they may highlight existing ones.

What do we actually need

  1. Change the perspective we think about drops. Don’t get limited by the C++ mentality.
  2. A way to directly tell the compiler that we want guaranteed scoped lifetime on some objects.

As I said, the only way to ensure that an object has scoped lifetime is ensuring that it never moves out of the scope before the end of the scope.

So what we need is simply an attribute #[lifetime(scope)] on objects. Once an object get tagged, it can never leave the scope.

And we can have use for this attribute today!