Drop elaboration
- Dynamic drops
- Drop obligations
- Drop elaboration
- Drop elaboration in
rustc
- Aside: drop elaboration and const-eval
Dynamic drops
According to the reference:
When an initialized variable or temporary goes out of scope, its destructor is run, or it is dropped. Assignment also runs the destructor of its left-hand operand, if it's initialized. If a variable has been partially initialized, only its initialized fields are dropped.
When building the MIR, the Drop
and DropAndReplace
terminators represent
places where drops may occur. However, in this phase, the presence of these
terminators does not guarantee that a destructor will run. That's because the
target of a drop may be uninitialized (usually because it has been moved from)
before the terminator is reached. In general, we cannot know at compile-time whether a
variable is initialized.
#![allow(unused)] fn main() { let mut y = vec![]; { let x = vec![1, 2, 3]; if std::process::id() % 2 == 0 { y = x; // conditionally move `x` into `y` } } // `x` goes out of scope here. Should it be dropped? }
In these cases, we need to keep track of whether a variable is initialized dynamically. The rules are laid out in detail in RFC 320: Non-zeroing dynamic drops.
Drop obligations
From the RFC:
When a local variable becomes initialized, it establishes a set of "drop obligations": a set of structural paths (e.g. a local
a
, or a path to a fieldb.f.y
) that need to be dropped.The drop obligations for a local variable x of struct-type
T
are computed from analyzing the structure ofT
. IfT
itself implementsDrop
, thenx
is a drop obligation. IfT
does not implementDrop
, then the set of drop obligations is the union of the drop obligations of the fields ofT
.
When a structural path is moved from (and thus becomes uninitialized), any drop
obligations for that path or its descendants (path.f
, path.f.g.h
, etc.) are
released. Types with Drop
implementations do not permit moves from individual
fields, so there is no need to track initializedness through them.
When a local variable goes out of scope (Drop
), or when a structural path is
overwritten via assignment (DropAndReplace
), we check for any drop
obligations for that variable or path. Unless that obligation has been
released by this point, its associated Drop
implementation will be called.
For enum
types, only fields corresponding to the "active" variant need to be
dropped. When processing drop obligations for such types, we first check the
discriminant to determine the active variant. All drop obligations for variants
besides the active one are ignored.
Here are a few interesting types to help illustrate these rules:
#![allow(unused)] fn main() { struct NoDrop(u8); // No `Drop` impl. No fields with `Drop` impls. struct NeedsDrop(Vec<u8>); // No `Drop` impl but has fields with `Drop` impls. struct ThinVec(*const u8); // Custom `Drop` impl. Individual fields cannot be moved from. impl Drop for ThinVec { fn drop(&mut self) { /* ... */ } } enum MaybeDrop { Yes(NeedsDrop), No(NoDrop), } }
Drop elaboration
One valid model for these rules is to keep a boolean flag (a "drop flag") for
every structural path that is used at any point in the function. This flag is
set when its path is initialized and is cleared when the path is moved from.
When a Drop
occurs, we check the flags for every obligation associated with
the target of the Drop
and call the associated Drop
impl for those that are
still applicable.
This process—transforming the newly built MIR with its imprecise Drop
and
DropAndReplace
terminators into one with drop flags—is known as drop
elaboration. When a MIR statement causes a variable to become initialized (or
uninitialized), drop elaboration inserts code that sets (or clears) the drop
flag for that variable. It wraps Drop
terminators in conditionals that check
the newly inserted drop flags.
Drop elaboration also splits DropAndReplace
terminators into a Drop
of the
target and a write of the newly dropped place. This is somewhat unrelated to what
we've discussed above.
Once this is complete, Drop
terminators in the MIR correspond to a call to
the "drop glue" or "drop shim" for the type of the dropped place. The drop
glue for a type calls the Drop
impl for that type (if one exists), and then
recursively calls the drop glue for all fields of that type.
Drop elaboration in rustc
The approach described above is more expensive than necessary. One can imagine a few optimizations:
- Only paths that are the target of a
Drop
(or have the target as a prefix) need drop flags. - Some variables are known to be initialized (or uninitialized) when they are dropped. These do not need drop flags.
- If a set of paths are only dropped or moved from via a shared prefix, those paths can share a single drop flag.
A subset of these are implemented in rustc
.
In the compiler, drop elaboration is split across several modules. The pass itself is defined here, but the main logic is defined elsewhere since it is also used to build drop shims.
Drop elaboration designates each Drop
in the newly built MIR as one of four
kinds:
Static
, the target is always initialized.Dead
, the target is always uninitialized.Conditional
, the target is either wholly initialized or wholly uninitialized. It is not partly initialized.Open
, the target may be partly initialized.
For this, it uses a pair of dataflow analyses, MaybeInitializedPlaces
and
MaybeUninitializedPlaces
. If a place is in one but not the other, then the
initializedness of the target is known at compile-time (Dead
or Static
).
In this case, drop elaboration does not add a flag for the target. It simply
removes (Dead
) or preserves (Static
) the Drop
terminator.
For Conditional
drops, we know that the initializedness of the variable as a
whole is the same as the initializedness of its fields. Therefore, once we
generate a drop flag for the target of that drop, it's safe to call the drop
glue for that target.
Open
drops
Open
drops are the most complex, since we need to break down a single Drop
terminator into several different ones, one for each field of the target whose
type has drop glue (Ty::needs_drop
). We cannot call the drop glue for the
target itself because that requires all fields of the target to be initialized.
Remember, variables whose type has a custom Drop
impl do not allow Open
drops because their fields cannot be moved from.
This is accomplished by recursively categorizing each field as Dead
,
Static
, Conditional
or Open
. Fields whose type does not have drop glue
are automatically Dead
and need not be considered during the recursion. When
we reach a field whose kind is not Open
, we handle it as we did above. If the
field is also Open
, the recursion continues.
It's worth noting how we handle Open
drops of enums. Inside drop elaboration,
each variant of the enum is treated like a field, with the invariant that only
one of those "variant fields" can be initialized at any given time. In the
general case, we do not know which variant is the active one, so we will have
to call the drop glue for the enum (which checks the discriminant) or check the
discriminant ourselves as part of an elaborated Open
drop. However, in
certain cases (within a match
arm, for example) we do know which variant of
an enum is active. This information is encoded in the MaybeInitializedPlaces
and MaybeUninitializedPlaces
dataflow analyses by marking all places
corresponding to inactive variants as uninitialized.
Cleanup paths
TODO: Discuss drop elaboration and unwinding.
Aside: drop elaboration and const-eval
In Rust, functions that are eligible for evaluation at compile-time must be
marked explicitly using the const
keyword. This includes implementations of
the Drop
trait, which may or may not be const
. Code that is eligible for
compile-time evaluation may only call const
functions, so any calls to
non-const Drop
implementations in such code must be forbidden.
A call to a Drop
impl is encoded as a Drop
terminator in the MIR. However,
as we discussed above, a Drop
terminator in newly built MIR does not
necessarily result in a call to Drop::drop
. The drop target may be
uninitialized at that point. This means that checking for non-const Drop
s on
the newly built MIR can result in spurious errors. Instead, we wait until after
drop elaboration runs, which eliminates Dead
drops (ones where the target is
known to be uninitialized) to run these checks.