Here's a quick question to test your Rust knowledge: which of these lines of code do the same thing?

drop(a);
let _ = b;
c;

I found the answer a bit surprising.

drop(a) is the least mysterious— a will be destructed immediately (if it implements the Drop trait, then its drop() will be called before any memory is freed.)

The other two may not behave as you'd expect: c; also immediately drops, while let _ = b; does nothing. But why?

The answer has to do with Rust's move semantics as well as the definition of expressions and statements.

An expression is something that resolves to a value, for example 4 or Box::new(4).

A statement does not resolve to a value, for example let x = 4; or struct X {}. If we have an expression and we don't want to save its value, we can always tack a semicolon on the end, creating a statement. So Box::new(4); will create a Box and then immediately drop its result. This is also known as an expression statement.

Let's add another few examples:

Box::new(d);
(e,);
{f;}
{g};

Each of these are also expression statements. An expression is formed (moving the variable contents), and the result is discarded. It may not be obvious that e, f, and g are moved the same way as d is, but in Rust every time a variable name is used as an expression, the value is moved (unless you create a reference with & or &mut.) Only types that implement the Copy trait may be passed by value. Everything else gets moved.

In Rust, x; is also an expression statement. It moves the value out of x and then discards the result. So x;, (x,);, {x}; are all synonyms for drop(x).

Why is let _ = x; different? The answer is that the language designers chose to make it this way. let _ = x; is not an expression statement; it's a let statement. The right side of a let statement is an expression, so you might think that naming x would trigger the same move semantics as everything else. This statement is a special case, however: if the left side is the wildcard pattern _, and right side is a naked variable name, the "don't bind" behavior means that the statement has no effect. The wildcard pattern _ means "don't bind the result to a name", not "throw away the result". If the right side is a variable name, the "don't bind" behavior overrides the right side being evaluated as an expression (which would trigger a move).

We can trigger expression evaluation if we want, though. Each of these will result in a move (dropping the contents of the variable):

let _ = (h,);
let _ = {i};

If you want another way to remember that let _ = x; is special, think of it as a pattern-matching expression like if let or match. These also honor the "don't bind" behavior and don't move the contents (meaning the variable isn't dropped):

match j {
    _ => {}
}
if let _ = k {}

In fact, the description of match expressions is the only place in the language reference that I've found an obvious explanation of the special treatment that place expressions get (which includes local variables):

A match behaves differently depending on whether or not the scrutinee expression is a place expression or value expression. If the scrutinee expression is a value expression, it is first evaluated into a temporary location, and the resulting value is sequentially compared to the patterns in the arms until a match is found. The first arm with a matching pattern is chosen as the branch target of the match, any variables bound by the pattern are assigned to local variables in the arm's block, and control enters the block.

When the scrutinee expression is a place expression, the match does not allocate a temporary location; however, a by-value binding may copy or move from the memory location. When possible, it is preferable to match on place expressions, as the lifetime of these matches inherits the lifetime of the place expression rather than being restricted to the inside of the match.

This behavior is what allows us to do useful things like:

let x = ("hello".to_string(), "world".to_string());
let (ref y, _) = x;  // value is not moved ouf of x
println!("y is {:?}", y);
println!("x is {:?}", x);

The Rust Programming Language book shows another way that the non-binding behavior of _ is useful:

let s = Some(String::from("Hello!"));

if let Some(_) = s {
    println!("found a string");
}

println!("{:?}", s);