Understanding if-let

I've been trying to figure out just how the if-let statement works and really haven't gotten anywhere. I've read some threads here on this forum as well as the section out of The Book and still come away confused. So, I'm hoping someone here will be able to lay it out in a way that makes sense. (Please keep in mind that, for all practical purposes, I am approaching this from a Rust-as-a-first-language standpoint, so references to other computer languages don't mean much.) I'll try to provide some directed questions to help this process along.

Question #1: One thing I gathered from the threads I've looked at is that when one is using a standard let statement, the left side is actually a pattern. I get stuck right there, since if I write the statement

let clem = 4;

then, as I understand it, clem becomes a pointer to a location on the stack where the value 4 is stored. That might not be the perfect way to define it, but I fail to see how clem could be considered to be a pattern.

Anyone want to take a stab at that one?

I don't know of a pattern (for use with let) that matches an integer in any useful way. Let's take a struct instead (not compiled):

struct S { x: usize, y: usize }

fn calc(s: S) -> usize {
  let S{ x, y } = s;
  x + y
}

This can be further simplified. Parameter names can also be patterns, because passing a parameter is just like assigning to the parameter name using let:

fn calc(S { x, y }: S) -> usize {
    x + y
}
1 Like

anything that you can assign to is a pattern

2 Likes

The idea "is a pattern" is about the language syntax, not about what's in memory. Consider this, to understand what “is a pattern” means: In most cases, you can replace let with match.

let clem = 4;
println!("{clem}");

is the same program as

match 4 {
    clem => { println!("{clem}"); }
}

And in the opposite direction, you can take any valid match that has only one arm and replace it with a let. Then, what if let does is give another syntax for a match with two arms, the second of which is _ => {...}. (One could reasonably hold the opinion that if let has no reason to exist other than removing one level of block indentation.)

13 Likes

It's... a name of some location. It's not a pointer. More formally, clem is a binding and the location is a place.

You can have patterns that create bindings, where the entire pattern is not a binding. That's what @jumpnbrownweasel's examples show. Here's a good article on patterns. It's not comprehensive (e.g. it was written before binding modes existed[1]), but I feel it gives a good introduction to the concept.

When used with let, the pattern has to be irrefutable -- always able to match -- because there's no mechanism to not continue if the pattern doesn't match. When used with if let, the pattern can be refutable -- it can have the possibility of not matching. If it matches, execution flows into the if let block and the bindings are available.

if let Ok(value) = some_result {
    println!("{value}");
}

If you're comfortable with matches that have wildcard arms, that functions like this does:

match some_result {
    Ok(value) => { println!("{value}"); }
    _ => { /* if you had an else block, it would go here */ }
}

  1. which I won't go into detail over either ↩︎

11 Likes

This is also a good reading material on the subject: Why Did Rust Pick the 'If Let' Syntax? - Joe Clay

4 Likes

Well, maybe still not useful, but I guess it can be fun to explore patterns that can work for i32.

One very common pattern of course is _

let _ = 4;

[1]

one can also combine a pattern with @

let x @ _ = 4;

This includes variable patterns

let x @ y = 4;
assert_eq!(x, y);

or other @-patterns

let x @ y @ z = 4;
assert_eq!(x, y);
assert_eq!(y, z);

Then, infallible patterns can be made from full ranges...

let i32::MIN..=i32::MAX = 4;

yeah.. this is useless, I know! But it's legal!

Half-ranges work, too

let i32::MIN.. = 4;
let ..=i32::MAX = 4;

These could make for a cursed way of annotating the type (for not-i32) I suppose...

let x @ i8::MIN.. = 4;
let y @ 0_u8.. = 4;

  1. by the way, if you hate idiomatic Rust, and want to save a few keystrokes, write this as _ = 4 without the let ↩︎

5 Likes

On the flip side, here's a useful irrefutable pattern that's not a simple binding.

fn example<T: std::fmt::Display>(result: Result<T, T>) {
    let (Ok(t) | Err(t)) = result;
    println!("{t}");
}
4 Likes

How does that pattern work? :open_mouth: It looks like destructuring the union of Ok and Err into a tuple, to put it into words.

Edit: nvm, I get it now :smile: . It blew my mind at first sight, indeed.

1 Like

I'm sorry if this is going slightly off-topic, but now (inspired by this) I found the following (cursed) code idea quite "fun" :sweat_smile::

struct Say(&'static str);
impl Drop for Say {
    fn drop(&mut self) {
        println!("{}", self.0);
    }
}

fn greeting(flipped: bool) {
    let ((false, _, __) | (true, __, _)) = (flipped, Say("Hello"), Say("World"));
}

fn main() {
    greeting(false);
    println!("-----");
    greeting(true);
}

Rust Playground

Hello
World
-----
World
Hello
5 Likes

As explained above,

let PATTERN = EXPR;
STATEMENTS...

is roughly equivalent to

match EXPR {
    PATTERN => {
        STATEMENTS
    }
}

If transformed like this, then

fn example<T: std::fmt::Display>(result: Result<T, T>) {
    let (Ok(t) | Err(t)) = result;
    println!("{t}");
}

becomes

fn example<T: std::fmt::Display>(result: Result<T, T>) {
    match result {
        (Ok(t) | Err(t)) => {
            println!("{t}");
        }
    }
}

and with some unnecessary bracketing removed, we have

fn example<T: std::fmt::Display>(result: Result<T, T>) {
    match result {
        Ok(t) | Err(t) => println!("{t}"),
    }
}

Now onto the |, which is an “or-pattern”. If this is at the top level of a match arm, if you want to, you can “desugar” this like

PAT1 | PAT2 => RHS,

becoming two arms

PAT1 => RHS,
PAT2 => RHS,

with the right-hand-side duplicated. This transforms the above further into

fn example<T: std::fmt::Display>(result: Result<T, T>) {
    match result {
        Ok(t) => println!("{t}"),
        Err(t) => println!("{t}"),
    }
}

Hopefully, at this point it’s clear how the code works :wink:


Neither of these “desugaring” steps is perfect. A match drops temporary variables at different times than a let does; and an or-pattern doesn’t actually duplicate code, so further restrictions apply: e.g. the variables t would still have to have the same type, whereas my “desugared” version no longer needs this to be the case.

1 Like

Yeah, it took my brain a few seconds to catch up :sweat_smile:. It's just that seeing all those syntax features combined to make an irrefutable pattern in a position where I wasn't expecting to see it confused me.

It's not a pointer. It is a local binding, which is just a way to define some memory location to the compiler. Here "memory" should be understood in the wide sense of "anything which can store data", so that it includes not just RAM, but also processor registers & cache, files on disk, or whatever other data storage you can think of. Not all of those may be used in practice (e.g. you can't control the processor's cache), but that's purely an implementation detail of the compiler and the target platform.

In practice, for simple values the binding can be entirely optimized out. Often it will refer to some values which are stored in processor's registers (note that more complex types, like structs and arrays, typically won't fit in a register, and thus will be split over several registers and main memory). More complex values may be spilled on the stack, but that's not a given. Read-only values may be stored in read-only memory, probably in the executable file itself. Technically a local binding may even be put on the heap. This is unlikely to happen with ordinary native compilation, due to performance and predictability reasons, but it's possible. I wouldn't be surprised if it happens when compiling to WASM, and it will certainly be the case for any compiler backend which would target JVM (i.e. compiling Rust code into JVM bytecode).

Overall, the exact place in memory where a local binding lives is entirely up to the implementation. But as a rough approximation, yes, you can assume that it's a variable living on the stack.

Any local binding is a pattern, by definition. It is a pattern which matches anything, and binds it (immutably) to the given local variable name in the current scope.

Another commonly used pattern is mut <ident>, like this:

let mut foo = bar;

mut foo is also a pattern, which also matches anything, and binds it to a mutable place with the given name.

2 Likes

If it helps to clarify, this:

let <PATTERN GOES HERE> = x;
<CODE GOES HERE>

Should generally work the same as this:

match x {
    <PATTERN GOES HERE> => {
        <CODE GOES HERE>
    },
}

Of course, the latter block relies on that one pattern being exhaustive of all cases, or it wouldn't compile--and so the same goes for the first. So for example, you can do things like:

// variable name is a very basic pattern
let a = 1;
println!("{}", a);

// that's equivalent to this
match 1 {
    a => {
        println!("{}", a);
    },
}
// underscore is a different but still very simple pattern
let _ = 1;
// that's like doing this:
match 1 {
    _ => {},
}
struct Foo { x: i32 }

let a = Foo { x: 7 };
// slightly less trivial:
let Foo { x } = a;
assert_eq!(x, 7);

// that's equivalent to this:
match a {
    Foo { x } => assert_eq!(x, 7),
}

Then, to make the jump to if-let, this:

if let <PATTERN GOES HERE> = x {
    <IF CASE CODE>
} else {
    <ELSE CASE CODE>
}

Should generally work the same as this:

match x {
    <PATTERN CODES HERE> => {
        <IF CASE CODE>
    },
    _ => {
        <ELSE CASE CODE>
    },
}
3 Likes

while all very interesting and in-depth comments, i wonder whether crazy (in a good sense) examples like some of above do not cause less hard-core Rustlings to roll eyes and shy away from the beauty that is some Rust features... :smiley:

2 Likes

Yes, I found the rabbit trail above rather daunting, and wonder whether the if-let shorthand is worth it. You experienced people seem to think so and that means I need to give it some time and give it a serious try. Still, despite the rabbit trails, I think I am getting a better handle on just what if-let does.

1 Like

If-let isn't harder than match, though. It's the pattern concept which takes some time to get used to, but you'll have to understand patterns anyway. You can't use Rust without them.

If-let, specifically, is just a different way to write match expressions.

// This
if let PAT = EXPR {
    /* do stuff */
} else {
    /* other stuff */
}

// is the same as this
match EXPR {
    PAT => { /* do stuff */ }
    _ => { /* other stuff */ }
}
3 Likes

Honestly, ignore the rabbit trails for the sake of your OP (so everything after the first 5 comments or so).

5 Likes

@gretchenfrage & @afetisov Your posts really, really helped. Thank you. I just copied it into my "rusty_notes.txt" file so I can easily refer back to it. Some of the other replies helped a lot, too. So, let's check my understanding:

-- Am I correct in saying that the if-let syntax is just shorthand for a two-arm match in which the second arm serves as the _ => catch-anything-else-that-hasn't-already-been-covered arm that we all put at the end of our match statements?

-- Here is the offending code that motivated me to start this topic:

    if let Ok(entries) = fs::read_dir(dir_path) {
             // Do stuff
    }

If I'm understanding what you are saying correctly, then this would translate into the following:

match fs::read_dir(dir_path) {
    Ok(entries) => { // Do stuff },
    _ => { // Whatever else you need to do },

Part of the advantage of the if-let syntax is that it doesn't require that second arm if you don't want it. Do I have this put together right?

3 Likes

That is correct.

match allows providing any number of patterns to match.
if let concisely checks a single pattern, with an optional else.

1 Like