Partial struct initialization with private fields

Currently, there is no way to partially initialize a struct that may have fields added to it later on without a breaking change.

For example, you have struct Foo:

pub struct Foo {
    a: usize,
    b: usize,
}

and you want users to be able to initialize like this:

let foo = Foo {
     a: 0,
     b: 1,
};

However, you also want to be able to add new fields to Foo later on like, for example, in wgpu-rs, a Rust implementation of the WebGPU standard.

We want to be able to have users do things like this:

device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
     push_constant_ranges: &[wgpu::PushConstantRange {
          stages: wgpu::ShaderStage::FRAGMENT,
               range: 0..4,
     }],
     ..wgpu::PipelineLayoutDescriptor::new(&[&bind_group_layout])
})

but, if we add another field to wgpu::PipelineLayoutDescriptor, it's a breaking change to the api no matter what we do because if we want to support this partial initialization syntax, all the fields of the struct must be public, so the user can just initialize it like a normal structure.

What we wish we could do

Ideally, we could either use the #[non_exhaustive] attribute, which seems like it should work in this case, but it turns out that no initialization, partial or full, is allowed for a struct with that attribute.

We could also try having private fields so the user is forced to use partial init syntax, but it turns out that the desugaring of partial init syntax actually doesn't allow that.

Solution

There have been a few attempts to change this behavior, but so far, the attempts have created breaking changes. We believe there's a way to implement this without breaking changes by following the following rules: (courtesy of @kvark)

  1. is it a private field? if not, proceed as usual

  2. is the source container borrowed (as opposed to moved)? if not, move the value in

  3. is the source container copyable? if yes, copy the value in, otherwise:

  4. issue a compile error, specifying that there is a field Xxx in a borrowed struct that can't be initialized because the struct is not Copy .

This seems like a pretty small change to me, so I don't think it'd need an RFC, but I can write one if that's the right way forward. I'm also happy to implement this.

For some urgency, we'd like to get the ability to do partial init syntax while still allowing for the addition of more struct fields without needing to do a breaking change before the WebGPU MVP is standardized. So, as soon as possible.

Alternatives

The alternatives to partial init are

  1. Builders
  2. Mutation

Builders are probably better than mutation for this case, but they also add a lot of unnecessary line noise and also diverge significantly from the javascript syntax that the WebGPU standard uses.

3 Likes

The standard, understood way to handle this is #[non_exhaustive(pub)] (which unfortunately has not been implemented, though iirc there's general consensus that the functionality is what we want, if not the syntax).

Specifically,

#[non_exhaustive(pub)]
pub struct Foo {
    pub a: usize,
    pub b: usize,
}

// or a proposed non-attribute syntax
pub struct Foo {
    pub a: usize,
    pub b: usize,
    pub ..
}

would allow FRU syntax (Foo { a: 0, ..foo }) because the pub would promise any new fields to also be public (and thus accessible for FRU).


Allowing FRU for stucts with private members is really scary, because there can be arbitrary safety requirements for those fields. The only FRU for structs with some private fields I'd feel confident saying is sound is one that uses field assignment to the existing object (or is provably isomorphic to doing so) rather than the current FRU which is field assignment to the new object.

Existing FRU is a simple desugar:

let foo2 = Foo { a: 0, ..foo1 };
// desugars
let foo2 = Foo {
    a: 0,
    b: foo1.b,
    other_field: foo1.other_field,
    // and so on
};

and all rules for its use follow simply from said desugar, including that it's not usable with #[non_exhaustive] types (as downstream cannot name all of the fields).

2 Likes

In the case that there are private fields (from the perspective of the desugar), could it just switch to a mutation based desugar? Like this:

#[derive(Default)]
pub struct Foo {
    pub a: usize,
    a: usize,
}

/// From another crate:
let foo =  Foo {  a: 0, ..Default::default() };
// desugars
let foo = {
    let foo: Foo = Default::default();
    foo.a = 0;
    foo
};

I don't get why we have to treat this as scary. Can't we limit the semantics down to the old good moves and copies, like outlined in the 4-point rule in the original post?

#[non_exhaustive(pub)] is not only a new (unimplemented) syntax, it also happens to be more limited.

Either way, we need something to be available in the 6-month time frame.

It's the move/copy/use of the private fields that's scary. If it's specified only in terms of public fields (thus having an actual surface-level desugar) then it's fine. The steps in the OP involve breaking privacy controls. Even if it's in a very controlled manner, it's still scary to do so (even if only because one necessary hole in privacy as implemented by the compiler makes another accidental hole easier).

IIUC, there is general consensus that we want something along these lines. If someone were to implement a switch to a mutation-based FRU desugar only when a private field is present and the functionality could be properly feature gated, I don't think it'd be unwanted, but I obviously can't predict the lang team's opinions. And I strongly doubt it would see stabilization within a 3 month timeframe (to allow the 12 week train ride to stable within the 6 month timeframe) just because the feature (obviously) isn't that high priority to get a lang team member to champion it through the process that quickly.

2 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.