Way to make a configuration struct stable across versions?

Here's a usage snippet for a rendering library I wrote for my own use:

	let triangle_pipeline = device.create_raster_pipeline(RasterPipelineOptions {
		vertex_shader: Some(triangle_shader_module.get("vertex")),
		fragment_shader: Some(triangle_shader_module.get("fragment")),
		..Default::default()
	})?;

You can see the intent: I want to pass some options to this function by name and fill the rest with defaults, and on the library side, be able to add options in the future without needing to break semver. For example, I could add more customization for blending or multisampling that I hadn't thought of in my first release. Ideally, the user would be able to create the struct, but be required to use the spread expression to account for any gaps.

I thought surely I could just slap a #[non_exhaustive] onto the options struct, and that'd do the trick. But it turns out that attribute actually blocks other crates from creating it! The reason appears to be that you could add private fields... even though that would not make any sense here. The obvious fix would be if there were a #[non_exhaustive_pub] or #[non_exhaustive(pub)] variant that lifts that restriction, but there isn't currently.

Also, obviously, an alternative way to implement this would be to use a builder. The problem is that that's way too much work. A struct would be much more ergonomic, and there should be no need to create a function for each field.

So, has a #[non_exhaustive(pub)] (or another form of this functionality) ever been considered (like an RFC that I didn't find)? Or is there a better way to do this, or closely emulate it? (Of course, if I'm wrong that it is currently not possible to do this, do tell.)

3 Likes

It's called functional update syntax. Note that it takes an arbitrary expression and isn't limited to Default::default(). It's not allowed when there are private fields or when non_exhaustive is in effect, as there may be invariants that need upheld which could be violated by just copying or moving the private fields.

If non_exhaustive is not in effect and there are no private fields, you don't have to use function update syntax (if you supply all the fields), which defeats your "add options in the future" goal. So while I too would like to see non_exhaustive be applicable with privacy scopes other than just crate, it wouldn't actually solve your use case on its own.

This would be some new "allow struct expression but require functional update syntax" feature in concert with non_exhaustive, or perhaps "require functional update syntax" in combination with all public and defaulted field values, etc. There's some related discussion in the future possibilities of the default field value RFC.

5 Likes

It seems like it goes by several names. Those docs call it functional updates, RFC 2008 calls it functional record updates, and I've seen "spread" a lot. I'm going with FRU I guess.

So, first, not sure if there was a misunderstanding, but yep, that is the point: non_exhaustive does not work for this, so I imagined an alternative that would. The hypothetical #[non_exhaustive(pub)] (or whatever other syntax would be better) would mean "you must assume this struct can have more fields, but all of those fields will be public", and so external code would be able to instantiate the type, but only using FRU. That would be sort of like a "new feature in concert with non_exhaustive", but also not entirely new. I think it would fit into this option mentioned in that RFC:

  • Extend #[non_exhaustive] with arguments in order to specify the desired behavior

Second, I gave it more thought, and I actually found a workaround. As far as I can tell, it's surprisingly perfect, but it's a bit hacky and generates a warning.

#[derive(Default)]
pub(crate) struct NonExhaustiveMarker;

#[derive(Default)]
pub struct Config {
	pub message: String,
	// ...
	#[allow(private_interfaces)]
	pub non_exhaustive_marker: NonExhaustiveMarker,
}

Due to the private type, you are not allowed to set non_exhaustive_marker from outside the crate, and you cannot mention it or interact with it at all, not even indirectly. However, since Config is public and all of its fields are public, you are allowed to construct it as long as you use FRU. Unless there is a flaw I missed, this is the exact desired behavior (although implementing it is still quite awkward compared to a simple attribute).

3 Likes

You could manually construct this with

Config {
  message: String::new(),
  non_exhaustive_marker: <_>::default(),
}

EDIT: but to avoid this, instead of deriving default on the marker you can manually implement default on Config within a scope that has access. There’s still ways to get an instance to manually call the constructor, but they get even more convoluted. If you mark the field as deprecated too you can be pretty confident that anyone bypassing it knows they’re unsupported.

1 Like

Nope, that doesn't compile! I was surprised too. Rust Playground

Not even non_exhaustive_marker: panic!() compiles. It's like the field is forbidden from being acknowledged at all.

Ok, that is extremely weird that you cannot have an expression with that type, but FRU still works with it, so it's no longer the equivalent of just desugaring to all the field assignments.

EDIT: skimming https://rust-lang.github.io/rfcs/2145-type-privacy.html and the rfc/tracking issues I don't actually see any mention of how it relates to FRU.

2 Likes

I think a builder here is more ergonomic for the user. A builder could easily look like

let triangle_pipeline = RasterPipelineOptions::default()
    .vertex_shader(triangle_shader_module.get("vertex"))
    .fragment_shader(triangle_shader_module.get("fragment"))
    .create_with(device)?;

which has less nesting, avoids writing "raster pipeline" twice, and avoids the Some(...)'s which are just expressing the fact that you have chosen to include this option rather than omit it (there is almost no circumstance where you would write RasterPipelineOptions { vertex_shader: None, ..Default::default() }; the Some is not communicating valuable information, it is just visual clutter required by the direct field assignment).

It's true that making the option struct function nicely as a builder would take more work, but it is also something that could be accomplished by a derive macro (at least if you're okay with .vertex_shader(Some(...)) instead of .vertex_shader(...) or using attributes to specify when optional values should be implicit) or even a simple declarative macro. If you're adding an attribute to support this anyway, it doesn't seem like there's that much gained over just adding a macro for making builder structs.

I'll admit one disadvantage of the macro-generated builder approach is that it plays somewhat less nicely with LSPs, but I think it's probably better overall in most cases.