Skip to content

← Smart Pointers and Interior Mutability step 13 of 26

Medium Primitives

`let _ =` versus `let _x =`: the guard that dies instantly

Four blocks, four ways of binding the same guard. Produce the exact log.

pub fn compare_bindings() -> Vec<String>
pub fn one_form(which: usize) -> Vec<String>

A Span logs enter <name> when built and exit <name> when dropped — the guard from the previous item, already written for you. mark(log, text) appends a plain line.

The four blocks, exactly:

fn block_a(log: &Log) {            fn block_b(log: &Log) {
    mark(log, "A: before");            mark(log, "B: before");
    let _ = Span::new("a", log);       let _s = Span::new("b", log);
    mark(log, "A: after");             mark(log, "B: after");
}                                  }

fn block_c(log: &Log) {            fn block_d(log: &Log) {
    mark(log, "C: before");            mark(log, "D: before");
    Span::new("c", log);               let _s2 = Span::new("d", log);
    mark(log, "C: after");             mark(log, "D: middle");
}                                      drop(_s2);
                                       mark(log, "D: after");
                                   }

compare_bindings() runs all four against one log and returns it. one_form(which) runs just block 0/1/2/3 against a fresh log — anything else returns an empty log.

Predict the answer before you run it. That is the exercise.

The whole item in one sentence

_ is a pattern, not an identifier.

let _ = expr; does not create a binding. It matches the value against a wildcard, which binds nothing, so the value is a temporary and a temporary is dropped at the end of its statement. The guard’s destructor has already run by the time the next line executes.

let _s = expr; does create a binding. The leading underscore is nothing but a convention that silences the unused_variables warning — _s is an ordinary variable name, the value is owned by it, and it is dropped at the end of the enclosing block like any other local.

Those two lines are three characters apart and produce two completely different programs.

The consequence, in production

let _ = mutex.lock();
// "critical section"
shared.do_something_dangerous();

The lock is acquired and released on the first line. Everything after it runs unprotected, and it will pass every test you write on an unloaded machine, because nothing is contending. It fails under load, in production, rarely, and the diff that introduced it looks completely innocent.

Clippy takes this seriously enough to make it one of the very few deny-by-default lints: let_underscore_lock is in the correctness group, which means “this is a bug”, not “this is unidiomatic”.

Be equally clear about what is not caught. rustc’s let_underscore_drop fires on let _ = <a type with a destructor> in general — and it is allow-by-default, so the gate will not catch it. If your guard is a transaction, a span, a tracing scope, a temporary file, or anything else that is not literally a lock, let _ = guard; compiles clean and silently does nothing. Knowing which of your footguns the tooling actually covers is part of the job.

let _ versus _x versus nothing at all

form binds? dropped
let _ = f(); no end of statement
f(); no end of statement
let _x = f(); yes end of block
let x = f(); yes end of block (plus an unused warning if never read)
let _x = f(); drop(_x); yes at the drop call

Rows one and two are the same program. Row three is the one you almost always want when you write a guard. Row five is how you end a critical section early without introducing a block — and it works precisely because _x is a name, which is exactly what _ is not.

The starter proves that last point by failing to compile:

error: in expressions, `_` can only be used on the left-hand side of an assignment
  |     drop(_);
  |          ^ `_` not allowed here

You cannot pass _ to drop, because there is nothing there to pass. It was never a variable.

Preferring a block

When a critical section has a natural extent, an explicit block says so better than a drop call does:

{
    let _guard = lock();
    // exactly this much is protected
}

The scope is the documentation, it survives refactoring, and it still does the right thing if the body returns early or panics.

Loading visualization…