Pre-RFC: Relaxed #[non_exhaustive] structs

A recent blog post reminded me of a slight change to the #[non_exhaustive] attribute I'd like to see, one that provides a nice alternative to the builder pattern.

The idea is to provide a relaxed version of #[non_exhaustive] for structs that would allow other crates to continue constructing the struct, but only with functional update syntax.

Example

#[non_exhaustive(pub)]
pub struct Options {
    pub a: bool,
    pub b: bool,
}

impl Default for Options {
    fn default() -> Options {
        // omitted
    }
}

// Crates other than the declaring crate may use functional update struct expressions
let options = Options { a: false, b: true, ..Default::default() }; // Allowed
// However, struct expression without functional update are still disallowed outside declaring crate
// let options = Options { a: false, b: true }; // Not allowed!

The idea would be that the #[non_exhaustive(pub)] attribute would only apply to pub fields and not to more private fields. Adding new public fields to the struct would be a non-breaking change. Private fields would still be considered exhaustive, and additional private fields would be a breaking change.

4 Likes

Why would new private fields be a breaking change? Breaking changes are changes that keep existing code from compiling. How would new private fields keep existing code from compiling? I think you are believing any ABI change is a breaking change. I don't think that is the case (someone correct me if I'm wrong here).

1 Like

The goal is to keep S { a, b, ..Default::default() } working. As of current, the FRU syntax only works for structures with entirely public fields. So adding a private field breaks FRU.

There's two ways to address this: #[non_exhaustive(pub)] (this), which promises all new functions to be pub (thus FRU compatible); or allowing FRU to work with non-pub fields.

The non_exhaustive variant is theoretically simple to implement. The FRU changes are a lot more involved and potentially problematic.

(In fact, the "dedicated syntax non_exhaustive" supports #[non_exhaustive(pub)], and doing so was a primary reason for its suggestion. struct S { pub a: A, pub b: B, pub .. })

2 Likes

Making functional record update (FRU, ..other_struct_value) syntax work for structures with private fields would have some wide-ranging implications that we may not want. That would break assumptions of existing code which wants to prevent creating or copying such structures without using constructors, for instance. At the very least we'd need a separate opt-in for that, rather than making it implicit as part of some other feature; even then I'm not sure we'd want to go that route.

4 Likes

FRU on a non-copy value moves that value, right?

1 Like

I haven’t checked this, but judging from the examples in the RFC applying privacy to FRU each individual field is copied/moved from rather than the value as a whole. So if all private fields were Copy it was possible to copy out from an existing borrowed value by giving new values for any !Copy field.

I think we could just extend FRU so that, for structs with non-accessible fields, instead of generating a compiler error, it is instead desugared to code that assigns the provided default value and then assigns the specified fields.

This would not break any assumption since it would only be syntax sugar, and solves the issue without needing to introduce any new syntax.

For instance, if Options has non-accessible fields, then

let options = Options { a: false, b: true, ..Default::default() };

would be desugared to

let options = {
  let mut x: Options = Default::default();
  x.a = false;
  x.b = true;
  x
};
5 Likes

What even is the current FRU desugar? Based on RFC#736, it looks like the desugar would be

struct Options {
    a: bool,
    b: bool,
    c: bool,
}
Options { a: true, ..source }
//
Options { a: true, b: source.b, c: source.c }

And this playground seems to confirm that.

1 Like

I think that would be much better than the current situation. Is there any example where this change could break code?

We can start experimenting these semantics with macro:

macro_rules! FRU {(
    $Struct:path {
        $(
            $field_name:ident : $value:expr,
        )*
        .. $initial:expr
    }
) => ({
    let mut it = $initial;
    let $Struct { $($field_name: _,)* .. } = it; // ensure any DerefMut shenanigans are shadowed
    $(
        it.$field_name = $value;
    )*
    it
})}
1 Like

Yes. That is, if it applies to all FRU and not just those where there exist a private field (thus FRU doesn't currently work).

#[derive(Default, Debug, Copy, Clone)]
struct Bool(bool);

#[derive(Default, Debug)]
struct Opts {
    a: Bool,
    b: Bool,
    c: Bool,
}

fn sugar() {
    let source: Opts = Default::default();
    let _ = Opts {
        a: Bool(true),
        ..source
    };
    dbg!(source);
}

fn stable() {
    let source: Opts = Default::default();
    let _ = Opts {
        a: Bool(true),
        b: source.b,
        c: source.c,
    };
    dbg!(source);
}


fn proposed() {
    let source: Opts = Default::default();
    let _ = {
        let mut out = source;
        out.a = Bool(true);
    };
    dbg!(source); // error
}
2 Likes

The fact that it breaks code means that such change would require an edition boundary and a way / syntax to express the current semantics if the syntax were to be overriden with the semantics showcased by the macro.

Another option is to use a slightly different syntax for this new suggested behavior (which, imho, is more logical than that of copying each field when possible when a struct is not Copy itself). What about:

source = Options { a: Bool(true) .. move source };

Combined with .. } becoming sugar for .. move Default::default() }, it would enable what the OP wants, while letting existing code just be.

3 Likes

It doesn't break anything as long as the current behavior is retained for types for which the compiler accepts FRU (those with all accessible fields), and the new behavior is used for all other types that currently cause a compiler error.

There are other ways to implement the new behavior other than the one I proposed that are closer to the current behavior: for instance for types that don't explicitly implement Drop using the current desugaring, but always moving non-accessible fields even if they are Copy, and only moving the whole structure if it explicitly implements Drop and thus it cannot be moved out of.

Having an explicit "move" keyword is an option, but I think we could not have, with the same argument that led to deciding to not require it for common moves and accepting the ambiguity with copying.

I agree with @dhm that a new syntax would be better, so it's obvious what's happening: ..source copies/moves the individual fields, ..move source copies/moves the whole struct.

Unfortunately yes, even though this has been coming up for 4 years:

The original proposal in this thread, #[non_exhaustive(pub)], seems to spell out a very common case: "I may add more fields to this struct, but I promise all of them will be public." It also seems like a more minimal change than adding ..move syntax, and doesn't require more typing anywhere you would want to use FRU with one of these structs.

It is a bit cryptic (would someone seeing #[non_exhaustive(pub)] guess what the pub means?) But it fits a common use case so well that I think it would still be worthwhile.

One way to address the readability issue is to also support writing an explicit "wildcard" field, as proposed in the original Extensible enums RFC, and allow a visibility modifier on it:

struct Opts {
  pub a: bool,
  pub b: bool,
  pub ..,
}
3 Likes