Why the Struct Update Syntax is confusing

So i was learning Rust as usual with the Reference book and then i got this amazing Struct pre initialization Syntax

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    // --snip--

    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        email: String::from("another@example.com"),
        ..user1
    };
}

why does the base Struct comes after update field here?

email: String::from("another@example.com"),
        ..user1

This seems very confusing after coming from py, js background where we first initialise the mapping then updates the fields

JavaScript:

const obj = {1: 2, 2: 3};
console.log({...obj, 2: 4});

Python:

m = {1: 2, 2: 3}
print({**m, 2: 4})

I am interested in a rationale behind this confusion, Do anybody know about it?

1 Like

At a guess,[1] for parsing reasons; the thing after .. can be any expression.

However, also note the SUS[2] does not act like this:

// let other = Thing { foo, ..original };
let other = {
    let mut tmp = original;
    tmp.foo = foo;
    tmp
};

In instead acts like this:

let other = Thing { foo, bar: original.bar, baz: original.baz };

where individual fields are moved (or not moved).

Which is why you can append a use of user1.email to your example (even though it is not Copy).

    let user2 = User {
        email: String::from("another@example.com"),
        ..user1
    };
    
    let email = user1.email;

  1. as far as I know the syntax -- which used to be called Functional Record Update, or FRU -- has existed before Rust RFCs, so it may take some archaeology to find out for sure ↩︎

  2. heh ↩︎

4 Likes

I thought of something else. This is legal:

impl User {
    fn silly(&mut self) -> bool {
        true
    }
}

// ...

    let user2 = User {
        email: String::from("another@example.com"),
        active: user1.silly(),
        ..user1
    };

The call to silly has to come before the partial deconstruction of user1.

If you could write

    let user2 = User {
        ..user1,
        email: String::from("another@example.com"),
        active: user1.silly(),
    };

Either execution order would have to be different or you would have to get a compilation error.

8 Likes

I mostly agree with all quinedot said. But I do think it's weird that you're not allowed to put it anywhere.

Like this cursed hypothetical:

let user2 = User {
    email: String::from("another@example.com"),
    active: user1.silly(),
    ..user1,
    username: user1.email,
};
1 Like

It helps if you read the .. as a literal ellipsis: [1]

let user2 = User {
    email: "foo@bar.com",
    username: "foo",
    ..user1
};

should be read as "user2 is a User with email foo@bar.com and username foo, and the rest of the fields from user1", which also corresponds to how it's desugared.


  1. (indeed I wish it were ... instead, as that wouldn't have parsing ambiguities with ranges either) ↩︎

9 Likes

:pick: :cowboy_hat_face:

Here's the original design... discussion. :sweat_smile: It was originally (also?) for what I guess are ad-hoc product types / structs, aka records (dropped sometime before 1.0).

let x = { f: 1, g: 1 with y};

At a guess it's inspired by OCaml (albeit in the opposite order for some reason).

let register_heartbeat t hb =
  { t with last_heartbeat_time   = hb.Heartbeat.time;
           last_heartbeat_status = hb.Heartbeat.status_message;
  };;

Eventually, with was replaced by ... Probably in analogy to a spread operator, even though that's not what it is (but I didn't find the reasoning, just the commit).

9 Likes

Or maybe just a push to reduce keywords :person_shrugging:.

3 Likes

Note that ability to do that relies on the ability to easily copy strings. And that ability relies on the GC. And also on bazillion other hidden magical tricks.

Rust doesn't believe in magic in runtime. If you haven't used user1.username then it's left available to be used for user3 like this:

pub fn main() {
    // --snip--

    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        email: String::from("another@example.com"),
        ..user1
    };

    let user3 = User {
        username: String::from("anotherusername123"),
        ..user1
    };
}

But if your user3 would try to use user1.email ? That's not allowed:

pub fn main() {
    // --snip--

    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        email: String::from("another@example.com"),
        ..user1
    };

    let user3 = User {
        email: String::from("yetanother@example.com"),
        ..user1
    };
}

The confusion mostly comes from different things looking similarly. Rust doesn't make copies of objects behind your back, it doesn't magically tracks object lifetimes (it asks you to do that instead) and so on.

While updates look superficially similar they are not the same. E.g. in JavaScript you would do updates that way, too:

const obj = {1: 2, 2: 3};
console.log({3:7, ...obj});

This works because, as you have said, JavaScript doesn't do what Rust is doing (and it doesn't need to do what Rust is doing because it may rely on magic).

Rust's approach of treating variables more like physical objects is both blessing and a curse here: it makes things more robust and efficient (magic that other languages like to use leaks, remember?) but on the flip side it means that many design patterns that look superficially similar to what other languages are doing are not, really, possible in Rust.

2 Likes

Hey @quinedot @drewtato @jdahlstrom @khimru,
Thanks for joining me in this discussion.

My apologies on responding lately..

@quinedot yeh, but this seems very trivial reason

I just confirmed that they were moved, so it makes sense.

After whatever you presented about Struct nature in this context, it makes sense that it should be the last one to be messed before all update syntax gets resolved with/without base Struct's associated funcs/attrs.

But in my pov, An avg programmer like me would see the execution order as:

struct User {                                                                                                                                
active: bool,                                                                                                                                
    username: String,                                                                                                                        
    email: String,                                                                                                                           
    sign_in_count: u64,                                                                                                                      
}                                                                                                                                           
                                                                                                                                            
fn main() {                                                                                                                                  
    // --snip--                                         //                                                 |                                 
                                                        //                                                 |                                 
    let user1 = User {                                  //                                                 |                                 
        email: String::from("someone@example.com"),     //                                                 |                                 
        username: String::from("someusername123"),      //                                                 |                                 
        active: true,                                   //                                                 |                                 
        sign_in_count: 1,                               //                                                 |                                 
    };                                                  //                                                 |  (Main Program Execution Order) 
                                                        //                                                 |                                 
    let user2 = User {                                  //     A                                           |                                 
        email: String::from("another@example.com"),     //     |                                           |                                 
        ..user1                                         //     | (Struct Declaration Execution Order)      |                                 
    };                                                  //     |                                           |                                 
}                                                       //                                                 v                                 

Which seems very ambigious and the cause of this thread.

@drewtato That one somewhat makes sense too

Even in one of usecase, Suppose you maintain a default valued instance of same Struct type and one of your func receives user's instance too,

let default = Config { ... }   // some pre defined values;
let user_config = Config { ... }  // constructed by user

// Now you want to normalise these into single instance
let main_config = Config {
     ..default,
     seed: random(),  // some value generated while merging them, can be used in further operations like this seed could be used for random id generation
     ..user_config
}

This isn't valid code anyhow, but the expectations are guessed by execution order ( Top - Down ) that first it would initialise with default instance, then update fields would mutate/create fields and finally user config is applied to it.

@jdahlstrom I appreciate the explanation.

Even that one makes sense as per the POV i mentioned above.

Sounds like to deal with this as a small price to get rid of GC overhead by other langs :face_holding_back_tears:

@khimru
It seems the syntax got me as Struct behave like HashMap ( object in js, dictionary in py ) which the above observations contradicts now
I was never against with Rust's philosophy for explicitness too, which is why i liked the default move behaviour instead of copying everything.

Now i need to handle them like inherited attributes from base struct which seem more logical

This one doesn't make sense because Rust doesn't have partially initialized structs. After ..default, all the fields are set, and it makes sense for seed to be overwritten, but you couldn't logically apply anything from ..user_config since everything is filled out already.

1 Like

That's only part of the full price, lol. The story of GC is that while it doesn't provide 100% reliable solution for any issue, it provides half-decent solution for many of different issues.

That's why people tried so hard to make GC work. For years.

1 Like

I think they could be overridden just like how seed worked hypothetically, but anyways , this now seem more like syntactic sugar to traditional assignments with moves.

Indeed, This is why i am shifting more to langs like Cpp and Rust
Surprisingly i found both to be contrary to each other by their design

What is "they" though? user_config has every field. Either you overwrite everything or you overwrite nothing.

1 Like

It's the new instance of Config.

Oh welp , that actually makes sense now for partial initialisation of Structs which isn't possible

As i told previously,

I don't know how helpful it is to you, but I think of ..user1 as "now, take every field not yet specified from user1". Maybe that helps.

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.