Dynx and sealed traits
As described here, every dyn-safe trait Trait
gets an "accompanying" dynx Trait
struct and an impl Trait for dynx Trait
impl for that struct. This can have some surprising interactions with unsafe code -- if you have a trait that can only be safely implemented by types that meet certain criteria, the impl for a dynx
type may not meet those criteria. This can lead to undefined behavior. The question then is: whose fault is that? In other words, is it the language's fault, for adding impls you didn't expect, or the code author's fault, for not realizing those impls would be there (or perhaps for not declaring that their trait had additional safety requirements, e.g. by making the trait unsafe).
A dynx Trait
type is basically an implicit struct that implements Trait
. For some traits, notably "sealed" traits, this could potentially be surprising.
Consider a trait PodTrait
that is meant to be implemented only for "plain old data" types, like u8
and u32
. By leveraging the "sealed trait" pattern, one can make it so that users cannot provide any impls of this trait:
#![allow(unused)] fn main() { pub mod public_api { mod sealed { pub trait Supertrait { } } /// **Guarantee.** Every type `T` that implements `PodTrait` meets /// this condition: /// /// * Given a `&T`, there are `byte_len` bytes of data behind the /// pointer that safely be zeroed pub trait PodTrait: sealed::Supertrait { fn byte_len(&self) -> usize; } impl PodTrait for u8 { fn byte_len(&self) -> usize { 1 } } impl PodTrait for u32 { fn byte_len(&self) -> usize { 4 } } ... } }
This trait could then be used to build safe abstractions that leverage unsafe reasoning:
#![allow(unused)] fn main() { pub mod public_api { ... pub fn zero(pod: &mut impl PodTrait) { let n = pod.byte_len(); // OK: We have inspected every impl of `PodTrait` // and we know that `byte_len` accurately // describes the length of a pointer. unsafe { <*mut _>::write_bytes(pod, 0, n) } } ... } }
Unfortunately, this reasoning is not sound if combined with a dynx
type:
#![allow(unused)] fn main() { pub mod public_api { ... trait GetPodTrait { fn get(&self) -> impl PodTrait; } } fn method(x: &dyn GetPodTrait) { // `y` will be a `dynx GetPodTrait`, which will be // a `Box<u8>` or `Box<u32>` let mut y = x.get(); // Calling `zero` is trying to zero the memory *referenced* by // the box, but it will actually zero the box pointer itself. // Bad! public_api::zero(&mut y); } }
What went wrong here? The problem is implementing PodTrait
carries an additional proof obligation beyond those that the Rust type checker is aware of. As the comment says, one must guarantee that byte_len
, invoked on an &impl PodTrait
, correctly describes the number of bytes in that memory (and that it can safely be zeroed). This invariant was manually verified for the u8
and u32
impls, but it does not hold for the impl PodTrait for dynx PodTrait
generated by the compiler.
There are a few ways to resolve this conundrum:
- Declare that
PodTrait
should have been declared asunsafe
, and that-> impl Foo
is not dyn safe ifFoo
is an unsafe trait.- One could argue that the original code was wrong to declare
PodTrait
as safe; if it were declared asunsafe
, and we used that to suppress thedynx
impl (after all, we can't know whether it satisfies the extra conditions), the above code would no longer compile. - However, many Rust devs consider
unafe
to be more of a tool for the user -- if you have private functions and types, you are supposed to be able to reason fully about what they do without needing internalunsafe
annotations (there hasn't been a formal decision about whether this is a valid principle, but it has many adherents, and it's possible we should make it one). - Furthermore, this means that unsafe traits can't be used with
dynx
, which could in some cases be useful.- We could have an opt-in for this case.
- One could argue that the original code was wrong to declare
- Some form of opt-in for dyn compatibility (aka, dyn safety).
- Ignoring backwards compatiblility, we could require traits to "opt-in" to being dyn-compatible (e.g., one might write
dyn trait Foo
instead of just having atrait Foo
that doesn't make use of various prohibited features). In that case, the user is "opting in" to the generation of thedynx
impl; e.g., adyn trait PodTrait
declaration would be simply broken, because thatdyn trait
declaration implies the generation of animpl PodTrait for dynx PodTrait
, and the safety criteria are not met (this is subtle, but then the code itself is subtle).- We could leverage editions in various ways to resolve the backwards compatibility issues.
- Arguments in favor of opt-in:
- The fact that the compiler implicitly decides whether your trait is dyn compatible or not is a common source of user confusion -- it's easy to not realize you are promising people dyn compatibility, and also easy to not realize that you've lost it via some change to the crate.
- Furthermore, there are some soundness holes (e.g., #57893) that are much harder to fix because there is no declaration of dyn compatibility. In those cases, if we knew the trait was meant to be dyn compatible, we could easily enforce stricter rules that would prevent unsoundness. But detecting whether the trait would meet those stricter rules is difficult, and so it is hard to determine automatically whether the trait should be dyn compatible or not.
- Finally, some form of explicit "opt-in" to dyn compatibility might allow us to close various ergonomic holes, such as automatically providing an impl of
Trait
forBox<dyn Trait>
,&dyn Trait
, and other pointer types (perhaps all pointer types).
- Arguments against:
- Backwards compatibility. We currently determine automatically if traits are "dyn compatible" or not. That code has to keep working.
- Arguably, traits should be dyn compatible by default, and you should "opt out" to make use of the extended set of features that purely static traits provide (we could, of course, do that over an edition).
- Ignoring backwards compatiblility, we could require traits to "opt-in" to being dyn-compatible (e.g., one might write
- If a trait
SomeTrait
includes an-> impl Foo
method, make the trait dyn safe only ifSomeTrait
could itself have declared a struct that implementsFoo
- This is a rather wacky rule, but the idea is that if something is "sealed", then either (a) the trait
SomeTrait
is outside the sealed abstraction, and so won't be able to implement it; or (b) it's your own fault, because it's inside the sealed abstraction. - In this case, the offending code is inside the sealed abstraction, so we'd still have the bug.
- This is a rather wacky rule, but the idea is that if something is "sealed", then either (a) the trait
- Introduce a "sealed" keyword and declare that code is wrong.
- We would rather not entangle these designs; also, like the "unsafe"-based solution, combining sealed + dyn compatible would likely make sense.