Why does dyn UnsafeTrait auto-implement UnsafeTrait?

The compiler cannot guarantee the safety contract of UnsafeTrait when implementing it for dyn UnsafeTrait type (because it doesn't see it at the very least - it's in the comment), especially if it's something like:

/// Safety: TypeId::of::<Self>() must be different from
/// TypeId::of::<dyn UnsafeTrait>() for any implementor
unsafe trait UnsafeTrait {}

As a sidenote, dyn Sized: !Sized neatly falls out from this, because Sized is - at its core - very similar to an unsafe trait.

If a dyn of a trait doesn't implement that trait, what reason does it have to exist? The only reason you can call methods on a dyn Trait in the first place is because the dyn Trait type implements the trait.

I think this is more a footgun than a "regular" soundness problem; when defining an unsafe trait UnsafeTrait, the person designing it can in theory be aware that dyn UnsafeTrait will exist and implement the trait if the trait happens to be dyn-compatible, and thus if dyn UnsafeTrait wouldn't obey the safety requirement, the definition of the trait needs to exclude it by doing something to cause the trait to not be dyn-compatible. (As such, this is a rule of the language that you just need to follow when defining an unsafe trait, but it's somewhat unintuitive and thus there is a substantial chance that it isn't followed in practice, which is what makes it a footgun.)

This situation came up in practice for me: the current unstable (but soon-to-be-stabilised) Allocator trait isn't particularly useful for type-level traits, and I realised that it would be much more useful in practice if it had a soundness condition including "For every type T: Allocator, any allocation produced by a given allocator of type T (or that coerces to T) can be deallocated by any allocator of the same type T (or that coerces to T), even if it's a different object". (This rule is obeyed by almost all practical allocators – even local bump allocators, because their deallocation routine is a no-op – and would be a simple resolution to the huge problems that BTreeMap has trying to ensure that the leaves of the tree use the same allocator as the root.) Unfortunately, dyn Allocator (obviously) doesn't obey this restriction, and thus adding it would mean that you would have to remove dyn-compatibility of Allocator.

I know that many people want dyn Allocator which is why I haven't proposed adding the restriction, but if we could have a non-dyn-compatible Allocator I would want to add it.

1 Like

I disagree with this. This is false for any allocator that owns its own memory chunks, and can reuse deallocated memory.

I am going to challenge the "any" here. It's false for allocators that:

  1. own their own memory chunks, and
  2. can reuse deallocated memory, and
  3. require the deallocated memory to be within their own memory chunks (as opposed to just placing the deallocated memory on a free-list and returning it later), and
  4. have the same type regardless of which memory chunks are being used.

3 is in general not true for slab allocators that use a free-list (which is a very common way to write an allocator that owns its own chunks), because they can add allocations obtained from other allocators into the free-list without causing any problems.

Avoiding 4 is not a natural thing to do, but doing so forms a universal way to get the desired property for allocators (by using generativity or one of the generalizations of it to give allocators that allocate from different memory chunks different types). It's also the only way to get reasonable performance (because the allocator is stored in every Box, it needs to be a zero-sized type to avoid wasting memory, which implies a different type for each chunk of memory so that the allocator can find the appropriate chunk of memory to allocate from despite being zero-sized).

In general I would consider an allocator that needs to store a reference to the memory it's allocating from to be impractical, due to the overhead of needing to store an additional entirely redundant pointer in every allocation.

If that's the case, what you need is a dyn-incompatible subtrait of Allocator that imposes the requirement that every instance of the allocator of the same type is equivalent. I'd recommend writing up an ACP: Feature lifecycle - Standard library developers Guide

By the way, the fact that dyn traits automatically implement the trait, even when the trait is unsafe, interacts poorly with subtraits. See:

Nobody has figured out a good solution for this yet.

2 Likes

are you sure?

let (a, b) = (SlabAllocator::new(), SlabAllocator::new());
let ptr = a.alloc(foo);
b.dealloc(ptr);
drop(a);
let kaboom = b.alloc(foo); // a pointer to freed memory from buffer of `a`

That example uses raw pointers, which due to a historical design mistake in Rust don't have lifetimes.

As long as the lifetime-of-allocations of the allocator that originally produced the allocation is at least as long as the allocator that deallocates it, there isn't a problem (and raw pointers should be tracking their lifetimes to catch numerous mistakes, including this one). After all, using a raw pointer outside the lifetime of the reference from which it was obtained, or the allocator from which it was obtained, is always undefined behaviour and could be reasonably caught by the borrow checker, so really the borrow checker should be checking it. (The only real downside would be mild confusion because the lifetime of a raw pointer would have a different meaning from the lifetime of a reference, but it borrow-checks the same way.)

It slipped my mind to not mention this caveat because I've for ages been thinking about / programming in a model where allocations have type &'a own MaybeUninit<T> rather than *mut T, so I forgot that the allocations don't have lifetimes in the current Rust type system (and am generally following the principle of "design new language features based on how Rust will be in the future, not how Rust will be now"). In general, I'm not a fan of raw pointers existing in the language at all: almost all their behaviour can be replicated with a sufficiently large pile of wrappers (along the lines of MaybeDangling<Option<&'a UnsafeCell<MaybeUninit<T>>>>; this isn't exactly right because raw pointers can be misaligned, but it's close) and that includes the lifetime, so I occasionally forget that the version of raw pointers in the standard library omits it.

1 Like

I disagree, raw pointers don't have lifetimes intentionally because the compiler is necessarily limited in how it can analyze lifetimes, so when it can't prove that some correct code is correct, you can use raw pointers to work around that. a good example of where you can't just use lifetimes: the underlying thread local in scoped_tls - Rust which lives for longer than any of the references that are temporarily stored in it, so it must use raw pointers to avoid lifetime errors

4 Likes

I don't think the scoped_tls example disproves raw pointer lifetimes being part of the type, because its restriction to work only on references is somewhat artificial. In essence, scoped_tls allows values of multiple different types to be stored in a single memory location: in the case of scoped_tls specifically they are references with different lifetimes, but the same technique would work for any set of types that all had the same size.

At least for sized T, a crate like that could for example be implemented via storing a MaybeUninit<usize> in the TLS slot (plus, to avoid problems with niches, a boolean to check whether it's initialized), and transmuting the references into it and back out of it. The actual implementation seen there uses a raw pointer rather than a MaybeUninit<usize> to hold the references, but it is in fact just transmuting back and forth, and thus it doesn't really matter for that crate whether the lifetime is part of a raw pointer or not (because you never do anything with the raw pointer for which its type matters). In versions of this sort of code that aren't tied to a single thread, you actually can't write it using a raw pointer (because the slot has to be Sync but raw pointers aren't), so using a raw pointer is in a way a little buggy; scoped_tls gets away with it because it is naturally tied to a single thread and so the incorrect !Sync turns out not to matter.

I have written similar code myself; the primary difference is that in my version, instead of tracking at runtime whether or not the slot was initialized (potentially causing panics if there are mistakes in the downstream code), I used a marker type to track it at compile time (each slot had its own marker which would only exist while it was initialized). In my version, the marker type acted like an &'a T with the correct lifetime 'a, and (other than size and alignment) the type that the TLS slot had while there wasn't a value in it didn't matter at all (it simply served as the backing allocation for the reference, and was a MaybeUninit that produced a value using MaybeUninit::write). I used this to support multiple different lifetimes 'a, but it could just as well have been used to support multiple different types T (as long as there were runtime checks to ensure that you didn't try to recursively write a value of one type into the TLS slot while it was already holding a value of a different type).

The main interesting difference with scoped_tls is that the type stored by inside set blocks (a short-lived reference) is always a subtype of the type stored by outside set blocks (a long-lived reference) and is only accessible as an even narrower subtype (a reference scoped to just a single with call). In other words, it lets you store values of multiple different types into the slot (&'a T with different 'as), but only allows you to access them as their common subtype (i.e. by providing a function of type impl for<'a> Fn(&'a T)). This doesn't seem to have anything to do with raw pointer lifetimes, though (e.g. the same principle would work for allowing you to store a T and a U and retrieving it as a union with fields of type T and U). In particular, it doesn't matter for this whether *'a T and *'b T are or aren't the same type in just the same way that it doesn't matter that &'a T and &'b T aren't the same type.

An unsafe opt-in attribute for unsafe traits?

unsafe trait UnsafeTrait {}
trait SubTrait : UnsafeTrait {}

UnsafeTrait to not be implemented for dyn UnsafeTrait or dyn SubTrait by default; the first to require opt-in attribute #[unsafe(dyn_impl(this)] and second #[unsafe(dyn_impl(all_subtraits))].

SubTrait implementation for dyn SubTrait to be conditional on dyn SubTrait: UnsafeTrait specified somewhere explicitly.


Only the dyn_impl(this) form of attribute is truly required, as it tells the compiler to do dynamic dispatch, while the second could be written by user: upcast whichever wrapper of dyn SubTrait they have to dyn UnsafeTrait and go from there.

So, if someone wishes dyn_impl(all_subtraits) in their code, they will have a place to write down its justification.

A drastic option, but one I think could potentially be justified: in editionNext, unsafe traits are no longer by-default dyn-compatible. Instead, if you want dyn for an unsafe trait, you need to opt in by writing unsafe impl UnsafeTrait for dyn UnsafeTrait {}. All items which are dyn-compatible today have a default value in the impl block.

I've wanted "explicit dyn impls" before for other reasons too, such as the ability to define a dyn-compatible trait with a dyn-incompatible associated item where an implementation can be provided on dyn Trait based on the dyn-compatible parts.

5 Likes

It doesn't cover the #[dyn_impl(all_subtraits)] from my proposal though, because it isn't possible to write

unsafe impl<trait SubTrait> UnsafeTrait for dyn SubTrait
    where SubTrait: UnsafeTrait {}
1 Like

How do you impl that for unsafe traits with methods? Do you call the methods as if they were inherent? (AFAIK currently dyn trait methods are not considered inherent).

2 Likes