ZST semver footgun

I recently got quite stuck on a bug in my project which boils down to this example:

use std::any::Any;

#[derive(Debug)]
pub struct Foo(pub u32);

fn boxit<T: 'static>(x: T) -> Box<dyn Any> {
    Box::new(x)
}

fn unboxit<T: 'static>(b: Box<dyn Any>) -> T {
    *b.downcast::<T>().unwrap()
}

fn main() {
    let b = boxit(Foo);
    let x: Foo = unboxit(b);
    println!("{x:?}");
}

The footgun here is that once Foo was a ZST type and the program worked, but then someone added a field to it, and while the code kept compiling the semantics changed. Suddenly Foo in boxit(Foo) was a function fn() -> Foo and main panicked.

What is the right strategy to avoid this problem?

2 Likes

I don't think the lint exists yet, but GitHub - obi1kenobi/cargo-semver-checks: Scan your Rust crate for semver violations. would happily want to have it as a lint.

If it's a recurring issue, you could declare your unit structs as struct Foo(). Also, I'm not sure this qualifies as a semver issue, since adding fields to a public struct like that is already a breaking change.

Cross-crate the solution is

#[non_exhaustive]
struct Foo;
// + Default or w/e for construction

Unfortunately there is (to date) no way to tune non_exhaustive to be on any privacy boundary (such as a module), it's always pub(crate)-esque.

For the example I used pub u32 to avoid a warning about an unused variable, but the same issue arises if the field is private.

the same issue arises if the field is private.

No, if the field is private, then you can't use the Foo constructor function from outside the module, and so boxit(Foo) won't compile.

1 Like

It's still a semver breakage to go from a publically-constructable unit struct to a non-constructable non-unit struct, since old code attempting to create a unit struct is now broken. It's just that in this particular case, it's a runtime instead of comptime error.

if you want to guarantee a compile-time error instead of silently changing semantics when Foo's definition changes to add fields, you can write boxit(Foo {}) instead, which will error when anyone adds fields, but will keep working whenever Foo is a struct without fields (whether it's declared struct Foo;, struct Foo();, or struct Foo {}) as long as Foo is not #[non_exhaustive].

2 Likes