Try   HackMD

let-else desugaring ideas

Recommendation

  • We did not find a way to express the desired lifetimes with the existing HIR nodes.
  • We recommend extending the HIR node for Let with an Option representing the else and desugaring at a later stage.
    • We explored alternative HIR extensions but they seemed too complex and sketchy enough to hold their weight.

Schemes we could do today

We did not find a desugaring scheme that works today. Here are the options we considered.

Scheme 0: If-let (this is what we use today)

let pat = expr1 else { expr2 }; // 
expr3...;

if let pat = expr1 {
    expr3...
} else {
    expr2
}

This doesn't work because temporaries from expr1 are in scope over all of expr3.

Scheme 1: Match-in-place

let pat = expr1 else { expr2 }; // 
expr3...;

let (bindings...) = match expr1 { pat => (bindings...), _ => expr2 };
expr3...

This doesn't work because let pat = expr1 evaluates expr1 with (potentially) extended temporary lifetimes, but match expr1 does not, and so this changes the behavior of an example like

let temp = &Droppy::default() else { return; }

so that Droppy drops too soon (its lifetime is no longer extended).

Scheme 2: Bind temporaries to lets

You could imagine doing a "deep" desugaring so that, somehow

let temp = &Droppy::default() else { return; }

becomes

let temp0 = Droppy::default();
let temp = &temp0 else { return; }

But to do this correctly requires (a) a knowledge of which temporaries are extended lifetime, which we don't want to have available at HIR construction time (it is available today, but shouldn't be for RFC 66), and (b) it is wicked complex and not truly a "desugaring", more like a "compiling".

Scheme 3: use DropTemps

https://github.com/rust-lang/rust/pull/94012 proposed using DropTemps, which causes the lifetime to be made shorter, but this does not work for the cases where you DO want an extended lifetime.

Solutions, if we are willing to change HIR

A. Extend HIR with let-else as a native construct, desugar at THIR

  • HIR contains let-else as a native construct
  • The temporary lifetime code can easily manage that in the same way as a let is managed
  • Desugar at THIR point to a match
  • This may simplify error messages for "else clause of let...else does not diverge"

Niko's opinion is that we desugar too much at HIR construction time and should do less.

B. Extend HIR with "extended-temporary" as a node

  • HIR gets a new ExtendedTemporary node
    • it is meant to be used when we know that values matched against this expression will be stored into let bindings at the block scope
  • The temporary lifetime code uses that as a "root" point, along with the right-hand-side of a let
  • so we desugar to
let pat = expr1 else { expr2 }; // 
expr3...;

let (bindings...) = match extended-temporary(expr1) { pat => (bindings...), _ => expr2 };
expr3...

and things work.

Side note

We are unfortunately inconsistent between if and if-let

https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=765846eddafa1e5dc049c336754980b4

this seems like a wart, but you can sort of rationalize it as "the data is potentially being stored into bindings now and so it lives till the end of the if-let (versus an if, which always drops temporary before entering the body)". Note that the if-let/match behavior is a common footgun though and we should lint.

Appendix: Examples

Here is some Rust code where we were playing around with different desugarings to see if they were equivalent.

#![feature(let_else)]

#[derive(Default)]
struct Droppy(u32);

impl Drop for Droppy {
    fn drop(&mut self) {
        println!("I dropped");
    }
}

fn main() {
    // Scheme 1
    {
        let _ = Droppy::default().0 else { return };
        println!("Drop should have occurred");
    }
    {
        let () = match Droppy::default().0 { _ => (), _ => return };
        println!("Drop should have occurred");
    }
    
    // Original
    {
        let temp = &Droppy::default() else { return };
        println!("Drop should not have occurred");
    }
    
    // Scheme 1 (doesn't work)
    {
        let temp = match &Droppy::default() { x => x, _ => return };
        println!("Drop should not have occurred");
    }

    // Scheme 2 (works, but too complex)
    {
        let foo = &Droppy::default();
        let temp = match foo { x => x, _ => return };
        println!("Drop should not have occurred");
    }

}