Pre-RFC: Add `Forget` auto trait (again)

Hi dear Rustaceans, I'm new to this forum and this is the first time I participate in a discussion about language desin, so please forgive me if I made obvious mistakes.

Background

The Forget trait idea has been proposed by several people independently, including Fixing the `Drop` trait bound, Pre-Pre-RFC: Forget trait, in a reddit thread, in a review comment.

It was part of a bigger idea in most cases, and when it was independently proposed in Pre-Pre-RFC: Forget trait, comments seem to imply lacking motivation and the discusstion drifted to linear types which faded like other threads.

I recently hit a use case where Forget is valuable and I'd like to share my case with you.

Design

Forget would be an unsafe auto trait:

pub unsafe auto trait Forget { }

T is Forget when std::mem::forget is a no-op.

This means:

  • Primitive types (i32, fn, pointer, shared reference, unit...) are Forget.
  • A type is Forget if all of its components are Forget and it is not Drop.

It must not be implemented manually (not sure about this) and is a super trait of Copy.

Copy is modified to:

pub trait Copy: Clone + Forget { }

Is it just Copy?

No. Copy is opt-in. Users can choose to not implement Copy when a type is actually Forget.

Without the Forget trait, we can't tell the difference between can't implement Copy and choose not to implement Copy.

Is it just !Drop?

No. !Drop is a "super trait" of Forget. When a type has drop glue but is not Drop, it's not Forget.

Is it backwards compatible?

Looks like so to me. Copy can't be implemented on non-Forget types now.

Motivation

I was writing a 3D renderer based on wgpu. wgpu, like any other graphics libraries, exposes interfaces accepting raw &[u8] buffers and a type descriptor (for example IndexFormat). My abstraction for this is type_erased_vec - Rust.

A type T can be put into TypeErasedVec when it's Forget, because the Drop implementation of TypeErasedVec lacks type information and doesn't run T's destructor.

Now I used the bound T: Copy, which is overly restrictive as expalined before. I can't see a workaround without Forget.

I expect this situation happening often when people manage memory manually and need to know if some memory can be simply deallocated without running user code.

That's it! Any feedback will be appreciated!

8 Likes

Is it just !Drop?
No. !Drop is a "super trait" of Forget. When a type has drop glue but is not Drop, it's not Forget.

If we "fix" T: Drop bounds to mean "has drop glue," add !Trait bounds for "explicitly promises never to impl Trait, and make Copy: !Drop in the type system (all plausibly discussed previously), how does Forget differ from !Drop?

I think just that Forget is an auto-trait. I don't know if jt being an auto-trait is desirable; adding a Drop impl can already be breaking (can change when borrows end, interactions with the borrowck eye patch (#[may_dangle])), and Forget would turn it into a much more direct breakage.

I just glanced at it, but providing &(ptr, len, cap) as &Vec is (library) UB, and always will be. Additionally, providing &Vec<T, A> as &Vec<T, Global> is also (library) UB, and always will be. #[repr(Rust)] layout is not defined, and you must not assume any details about how such types are laid out.

Also, if your types have padding in them, &[T] as &[u8] is also UB, because padding bytes are uninitialized.

You probably want bytemuck::Pod.

It's always safe to mem::forget something, so it's never unsound to transmute a Pod type into [u8; size_of::<T>()]. It could potentially skip a destructor (Pod does not forbid drop glue), but it'd be a very surprising implementation of Pod (alongside other misimplementation of safe traits: unintended behavior, but not unsound behavior).

Along those same lines, Forget should be a safe trait, as implementing it will never lead to unsound behavior, just skipping drop glue, which is never unsound behavior.

At runtime (or const time!), mem::needs_drop (though tbf it's only a hint and is allowed to spuriously say true).

2 Likes

No difference at all. Has no drop and has no drop glue is the whole purpose of Forget. But changing the meaning of Drop would be a breaking change? So Forget is my last resort.

Thanks! I was not aware of this. Is it UB even if (ptr, len, cap) was returned from Vec::into_raw_parts? This claim seems to beat the purpose of Vec::from_raw_parts?

Is it so when I can't deallocate/reallocate the memory? I mean, Given a &Vec<T, Global>, all I can do is to get its len/cap or clone it.

EDIT: I realized that this breaks the invariant that ptr is allocated with A. I should probably remove this interface.

Yes, in the 3D renderer I use Pod. But in TypeErasedVec, Pod is even more restrictive than Copy.

Good point! Agreed!

Note that this already exists in nightly, and is being expanded with negative impls integrated into coherence · Issue #96 · rust-lang/lang-team · GitHub

We can do impl !Trait on nightly but can't do where T: !Trait. And negative bound can't help tell the difference unless meaning of Drop is modified, right?

The purpose of into_raw_parts is that you can use the raw parts individually, and perhaps later give them to Box::from_raw (as a slice of the full capacity) or Vec::from_raw_parts.

You must not ever transmute/cast from &T to &U when either type is #[repr(Rust)] (with very specialized exceptions[1]), because #[repr(Rust)] is unspecified. This means you do not know the field order, and it could change for any reason (there's even a flag to randomize field order). This even applies to e.g. Box<T, A> and Box<U, A>! It is never valid to treat the bytes of one #[repr(Rust)] type as valid for another type (except for the aforeündermentioned exceptions) because of this.

My own erased thin pointer abstraction provided a closure callback based API for this reason. It's not the most ergonomic, but it is sound.

Given that a T: Drop bound warns, has warned for a long time, and is functionality useless, my understanding was that last time it was discussed, it was considered fine to expand the set of types which fulfill that bound to be more correct. All types fulfilling the bound would be correct (as all types can be dropped), but I do think types with drop glue is a more actually useful meaning.

Either way, what would actually be useful would be !Drop anyway, which would cover Copy types and any other type which had explicitly impl !Drop for T to promise never to have drop glue. (Which also makes adding drop glue more immediately breaking, I suppose. I do think it's already effectively breaking, iirc, just less obviously so.)


If you want a better "doesn't have drop glue," though, you can cobble it together fairly easily now, even with min const generics, before boolean logic is more tightly integrated into where clauses:

// roughly
trait True {}
trait False {}
struct Bool<const B: bool>;
impl True for Bool<true> {}
impl False for Bool<false> {}

trait Forget {}
impl<T> Forget for T
where Bool<{ mem::needs_drop::<T>() }>: False
{}

..... this means needs_drop isn't just a hint at const time and needs to be implemented properly to detect the presence of drop glue. I don't know if this was discussed when it was made stably const; either way, the docs probably should be updated to reflect this.

Scratch that bit, type parameters can't be used in const parameters yet. (The fact that a const fn needs_drop needs to be accurate stands, though; you can have it in the type system for concrete T.)


[1] the exceptions are built-in types, which still technically are #[repr(Rust)] but have a defined layout — iNN, uNN, bool, [T; N], [T], *const T, *mut T, &T, and &mut T — library types with a guaranteed layout — ptr::NonNull<_> and Box<_, Global> — and "Option-like enums" (ones with one data variant and one unit variant), which are guaranteed to use the null value for the unit variant if (and only guaranteed if) the data variant is a non null pointer type.

Thanks for the detailed explanation!

I've modified TypeErasedVec to use only Vec::from_raw_parts_in to produce a Vec<T, A>. Hope it's sound after all.

If people tend to go with modifying the meaning of Drop, I guess I'll live with Copy now and wait for a new RFC once people agree on how negative trait bound should work.

If you're referring to Pod from bytemuck, it does. Pod: Copy + 'static

Forget seems like NoDropGlue, so something along the lines of:

unsafe auto trait NoDropGlue {}
impl<T : Drop> !NoDropGlue for T {}

, assuming the case of "neither T : Trait nor T : !Trait hold" works correctly: "given T : ?Drop, we should have T : ?NoDropGlue" etc.

In that case, indeed, we could add NoDropGlue as a super-trait of Copy, since the compiler already enforces this.


@chubei would such an auto-trait definition suit your use case? Also, FWIW, you could take the approach of ::uninit, and offer a "potentially leaking" API for non-Copy types (an example):

2 Likes

Wouldn't that break pin-project?

3 Likes

If it has public fields it's very breaking, as adding Drop prevents moving out fields, even with ownership: https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=4b39a12043150726ed51877dd0487fbe

2 Likes

It avoids the law of excluded middle, and brings us closer to the world in which trait bounds are always positive assumptions or capabilities: a trait bound not being met means merely means you cannot assume certain properties or access certain operations, even if they may be available to someone else, or in the future. This simplifies reasoning about code and makes adding a trait implementation always a backwards-compatible change.

Plus, defining T: Drop to mean ‘automatic disposal is possible’ instead of ‘automatic disposal is non-trivial’ opens the door to adding true linear types, if we ever decide to do so.

3 Likes

To be clear: adding any drop glue is already a breaking change. (I've now been convinced this is always true.) Also, T: !Trait is a) planned, at least for coherence purposes, even if not directly in bounds; and b) only true if the trait is explicitly promised to not be implemented, not just that it happens to not be implemented. (And I believe the expectation will be to only impl the negative trait when it would be wrong to implement the trait. The big one that currently exists is !DerefMut for &T.)

This means that the middle isn't excluded; it's a deliberately considered design goal that the default position is safe to evolve and implement more traits.


Back on the OP though... the Forget trait doesn't add any new functionality. You can still mem::forget any type; Forget is just a marker trait that forgetting the type is "non problematic."

I think that because of this it doesn't really hold enough value to merit being in the standard library. Your type tag can either hold a function pointer to drop_in_place::<T> to implement dropping, or if it's going to be ManualDroppy, you can make an easy constructor for types known to have no drop glue (Copy) and an annoying one for the rest that require wrapping in ManualDrop to make the drop suppression clear (or just make it in the name of the constructor).

1 Like

Yes! Exactly what I need.

Great idea! I'll think about what a "leaking API" looks like.

This is off-topic but interesting... I don't think this is possible. Type of drop_in_place::<T> is unsafe fn(*mut T), which is not type-erased and can not be held in TypeErasedVec.

Can't you wrap it? Something like this:

struct Erased; // XXX: Can this be a ZST?
let drop_erased = |ptr: *mut Erased| {
    // SAFETY: Will only ever pass in `self.erased.as_ptr()`
    drop_in_place::<T>(unsafe { ptr as *mut T })
};
// Put `drop_erased` into the vec
3 Likes

Can't put drop_erased into the Vec because it's a closure and can't have its type named.

You can coerce that closure into a function pointer unsafe fn(*mut Erased) since it's stateless (or just define it as a fn in the first place).

3 Likes

Yes! Brilliant solution. I'll try it!

In general, a type that implements a trait is strictly more useful than a type that doesn't implement that trait. Drop is the ugly exception to this rule. The proposal to "fix" !Drop would make it a double-negative (a type is !Drop if it's not not able to be used in situations where only drop-glue free types can be used), which is arguably confusing.

What if we instead used Forget for this purpose, and replaced Drop with !Forget? !Forget would become a "special" negative impl that has a method (though user code can't call that method directly):

// In `core`

#[lang = ...]
pub auto trait Forget {}

// `Forget` is special so it can do this
pub trait !Forget {
    /// User code can't call this directly.
    /// Note the empty default implementation:
    /// Types which are `!Forget` only because they contain a `!Forget` type
    /// use this empty impl
    fn drop(&mut self) {}
}

pub type Drop = !Forget; // For backward compatibility
// User code

pub struct Foo {}

impl !Forget for Foo {
    fn drop(&mut self) {
        // Drop glue
    }
}

This alternative syntax has the advantage of ensuring that "implementing a trait makes your type strictly more useful" always holds, avoiding the !Drop "double-negative".


Note: currently !Trait syntax implies a semver commitment to never implement a trait for a type, so under this syntax there is technically no way to express "this type currently has drop glue, but I might get rid of it later". This isn't a huge issue though given that current Rust can't express this either, and in any case removing drop glue isn't actually breaking in practice (if it was, the motivation for this new syntax would be invalid).

However, if a no-semver-commitment negative impl syntax is desired, maybe impl ?Forget or !impl Forget would work? This would be useful for other auto-traits as well.

1 Like