Four Ways to Drop: How rustc Decides Whether a Value Needs a Drop Flag
rustc's drop elaboration classifies every drop site as dead, static, conditional or open - and only the conditional case allocates a runtime flag.
TL;DR: Every
Dropterminator in MIR becomes a guarded drop. rustc classifies each one into four cases — Dead (never drop), Static (always drop), Conditional (depends on a runtime flag) and Open (drop the fields separately) — using two dataflow analyses. Only the Conditional case allocates a flag, so a local that is definitely moved out generates no drop code at all.
Rust’s drop semantics are subtle. A local may be moved out before the end of its scope, in which case it must not be dropped. It may be moved out on some paths but not others, in which case a runtime flag is needed. Or it may be partially moved, in which case only the remaining fields should be dropped.
rustc’s ElaborateDrops pass turns all of this into MIR.
The four-way classification
// compiler/rustc_mir_transform/src/elaborate_drop.rs:19-38
/// Describes how/if a value should be dropped.
#[derive(Debug)]
pub(crate) enum DropStyle {
/// The value is already dead at the drop location, no drop will be executed.
Dead,
/// The value is known to always be initialized at the drop location, drop will always be
/// executed.
Static,
/// Whether the value needs to be dropped depends on its drop flag.
Conditional,
/// An "open" drop is one where only the fields of a value are dropped.
///
/// For example, this happens when moving out of a struct field: The rest of the struct will be
/// dropped in such an "open" drop. It is also used to generate drop glue for the individual
/// components of a value, for example for dropping array elements.
Open,
}And the function that decides:
// compiler/rustc_mir_transform/src/elaborate_drops.rs:166-190
#[instrument(level = "debug", skip(self), ret)]
fn drop_style(&self, path: Self::Path, mode: DropFlagMode) -> DropStyle {
let ((maybe_init, maybe_uninit), multipart) = match mode {
DropFlagMode::Shallow => (self.init_data.maybe_init_uninit(path), false),
DropFlagMode::Deep => {
let mut some_maybe_init = false;
let mut some_maybe_uninit = false;
let mut children_count = 0;
on_all_children_bits(self.move_data(), path, |child| {
let (maybe_init, maybe_uninit) = self.init_data.maybe_init_uninit(child);
debug!("elaborate_drop: state({:?}) = {:?}", child, (maybe_init, maybe_uninit));
some_maybe_init |= maybe_init;
some_maybe_uninit |= maybe_uninit;
children_count += 1;
});
((some_maybe_init, some_maybe_uninit), children_count != 1)
}
};
match (maybe_init, maybe_uninit, multipart) {
(false, _, _) => DropStyle::Dead,
(true, false, _) => DropStyle::Static,
(true, true, false) => DropStyle::Conditional,
(true, true, true) => DropStyle::Open,
}
}Read the inputs: maybe_init and maybe_uninit come from two dataflow
analyses (MaybeInitializedPlaces and MaybeUninitializedPlaces) that
already exist for the borrow checker. multipart says “this value has more
than one child move-path”.
The four outcomes:
maybe_init == false→ the value is definitely uninitialized at the drop point → Dead. The drop terminator is replaced with a plaingoto. No code at all.maybe_init && !maybe_uninit→ definitely initialized → Static. Drop unconditionally, no flag.maybe_init && maybe_uninit && !multipart→ Conditional. Needs a runtimeboolflag.multipart→ Open. The drop is decomposed into per-field drops (a “drop ladder”), because a single flag cannot represent “some fields initialized, some not”.
The shallow/deep split
// compiler/rustc_mir_transform/src/elaborate_drop.rs:40-47
DropFlagMode::Shallow looks only at the top-level path — “is this whole
value initialized?”. DropFlagMode::Deep aggregates over all children and
additionally records whether the path is multipart.
The children_count != 1 test is subtle: it distinguishes “a value with one
child that is itself multipart” from “a genuinely multi-field value”. The
first case can still use a single flag (the child’s own flag suffices); the
second cannot.
Flags are created lazily, and only where needed
// compiler/rustc_mir_transform/src/elaborate_drops.rs:292-336
fn collect_drop_flags(&mut self) {
for (bb, data) in self.body.basic_blocks.iter_enumerated() {
let terminator = data.terminator();
let TerminatorKind::Drop { ref place, .. } = terminator.kind else { continue };
let path = self.move_data().rev_lookup.find(place.as_ref());
debug!("collect_drop_flags: {:?}, place {:?} ({:?})", bb, place, path);
match path {
LookupResult::Exact(path) => {
self.init_data.seek_before(self.body.terminator_loc(bb));
on_all_children_bits(self.move_data(), path, |child| {
let (maybe_init, maybe_uninit) = self.init_data.maybe_init_uninit(child);
...
if maybe_init && maybe_uninit {
self.create_drop_flag(child, terminator.source_info.span)
}
});The condition for creating a flag is maybe_init && maybe_uninit — that
is, exactly the Conditional case. A local that is definitely moved out gets
no flag, no alloca, no store, no load, no branch.
What the naive design costs
The traditional approach — which rustc itself used to have — is: every
droppable local gets a bool flag, initialised to true, cleared when the
value is moved. That costs, per droppable local:
- one stack slot,
- one store at initialisation,
- one store at each move,
- one load and one branch at the drop site.
Even for a local whose drop is statically known to be dead, even for a local that is never moved at all.
The 4-way classification reduces the common cases to zero extra
instructions. The cost is the two dataflow analyses and the move-path
machinery — but those are shared with the borrow checker, so the marginal
cost of the whole ElaborateDrops pass is much lower than it looks.
The composite analysis
One more piece of design worth stealing: borrow checking is not one analysis with a three-part domain. It is three analyses composed:
// compiler/rustc_borrowck/src/dataflow.rs:15-23
// This analysis is different to most others. Its results aren't computed with
// `iterate_to_fixpoint`, but are instead composed from the results of three sub-analyses that are
// computed individually with `iterate_to_fixpoint`. Because it's faster that way than having a
// single analysis where the domain has three components.
pub(crate) struct Borrowck<'a, 'tcx> {
pub(crate) borrows: Borrows<'a, 'tcx>,
pub(crate) uninits: MaybeUninitializedPlaces<'a, 'tcx>,
pub(crate) ever_inits: EverInitializedPlaces<'a, 'tcx>,
}Three independent fixpoints beat one fixpoint over a tuple domain, because the tuple’s join would have to merge all three components on every propagation even when only one of them changed.
Where it lives
compiler/rustc_mir_transform/src/elaborate_drop.rs—DropStyle.compiler/rustc_mir_transform/src/elaborate_drops.rs—drop_style,collect_drop_flags.compiler/rustc_mir_transform/src/impls/initialized.rs— theMaybeInitializedPlacesanalysis.compiler/rustc_borrowck/src/dataflow.rs— the composed borrowck result.
Takeaway
A boolean flag is a great abstraction and a terrible default. The interesting engineering is in deciding when you need one. rustc answers that question with existing dataflow results rather than with a fresh analysis, and classifies the answer into four cases so that the two statically-decidable ones cost nothing at all.