Destructuring with haskell-style "as pattern"

Haskell has a nice feature where you can destructure a value and keep a reference to the whole value. So if f() returns a tuple (a,b,c) then q@(x,y,z) = f() will store a in x etc and also q will contain (a,b,c). This is sometimes a useful feature. Now, I don't think Rust has this feature, and I think I understand why, but I want to double check that I am getting this right.

So, Rust does not have this "as pattern" feature because destructuring moves the value, so you can't also keep a reference to the whole thing around, because it's been moved. Am I understanding that correctly?

rust does have @ patterns, you just need to make the ownership and references check.

let ref q@(ref x, ref y, ref z) = (1, 2, 3);
println!("{x}, {y}, {z}, {q:?}");

or with match ergonomics:

let q@(x, y, z) = &(1, 2, 3);
println!("{x}, {y}, {z}, {q:?}");

reference:

6 Likes

rust patterns have binding modes:

1 Like

Copy types also allow some nested by-value patterns.

2 Likes

That's the kind of trouble it's all too easy to run into when trying to mentally map a set of concepts you might have got used to from one environment/PL into another one. Haskell ships with a built-in GC. Rust does not. Destructuring a "value" in Haskell (in all likelihood) simply creates another "reference" to the (heap) pointer of the original "value". Rust does not.

"Destructuring" isn't the same, either.

In your particular example, Haskell f() isn't going to return a mere "tuple" (a,b,c) but a full-flegded BoxGC((BoxGC(a), BoxGC(b), BoxGC(c)). When you "destructure" it by q@(x,y,z) = f():

  • q points to the outermost BoxGC
  • x points to the BoxGC of a
  • y to the BoxGC of b
  • z to that of c

with all of the above being BoxGC-ed themselves, in turn.

Contrast that to Rust, where an f() returning a tuple t (which you can destructure into a (a,b,c) or (x,y,z) or (f0, f1, _) or whatever else) means that the whole tuple will be placed back on the stack (not on the heap inside any Box), and you'll figure out the rest on your own.

Big takeaway: to talk about a being "stored" in an x without any consideration for the underlying memory management model of the PL you're using is, all things considered, a fool's errand.

Even if the all fields inside of your tuple are Copy (as per @quinedot), if you "keep a reference to the whole" while you "destructure" the rest of it you'll no longer deal with the same value:

Summary
fn main() {
    // `q` and `x/y/z` won't reference the same value (`i32` is `Copy`)
    let ref mut q @ (x,y,z) = f_i32();
    println!("before: {q:?} and ({x}, {y}, {z})");
    // changing the former won't affect the latter
    *q = (10, 100, 1000);
    println!("after: {q:?} and ({x}, {y}, {z})");
    
    // you can reference all the fields in a "read-only" mode
    let ref q @ (ref x, ref y, ref z) = f_string();
    println!("strings: {q:?} and ({x}, {y}, {z})");
    
    // but attempting the `&` the "whole" value
    // while destructuring the rest will fail 
    let ref mut q @ (x,y,z) = f_string();
}
4 Likes

This is great thanks. To add to this, here are some details about the types of the variables in these examples. If x,y,z have type T, then:

let ref q@(ref x, ref y, ref z) = (1, 2, 3);

in this case, q has type (T,T,T) and x has type &T.

let q@(x, y, z) = &(1, 2, 3);

in this case, q has type &(T,T,T) and x has type &T.

Here's a Rust Playground link where I used compiler errors to tell me what the types were.

1 Like

This one confuses me. q is &T3<String> and x is &String, correct? But there's no variable owning the tuple, so it's referring to what has been returned from the function, which likely sits on the stack (pointing to the string content on the heap). Isn't it strange that the compiler doesn't complain about the value being dropped before it can be used?

Binding a shared reference to a temporary returned from a function triggers “temporary lifetime extension” which is pretty much exactly what it sounds like. For a lot more detail, check out Niko’s post.

1 Like

I'm surprised; after hitting the "temporary value is freed at the end of this statement", I thought lifetime extension was limited to a very few cases. Good to know!

Yup.

Yup, still. Unless it's optimized away at compile-time, it's not just "likely" but "guaranteed".

Nope, because it's not dropped at all. You can think of it as:

let ref q @ (ref x, ref y, ref z) = f_string();
// is (kinda sorta) the same as:
let <anon_bind> = f_string();
let ref q = <anon_bind>; // one `ref` binding `&`s the whole
let (ref x, ref y, ref z) = <anon_bind>; // the rest `&` the inner fields 1-by-1
1 Like

I just meant that some types could also be in a register, but it's only an implementation / optimization detail; it can still be considered as stack material.

1 Like