Lifetime erasure type

tl;dr: I want a type EraseLife<T> such that for<T> EraseLife<T>: 'static (i.e.: EraseLife is always 'static even if T = &'a Ty)

My use case is to use TypeId to specialize for known types to remove some safety checks. E.g.

if !trusted::<T>() {
    // do some safety check
}

fn trusted<T>() -> bool {
    // w/o ErasedLife this wouldn't work because T is not 'static
    TypeId::of::<ErasedLife<T>>() == TypeId::of::<ErasedLife<String>>()
        // because of ErasedLife this would return true for str with any lifetime
        || TypeId::of::<ErasedLife<T>>() == TypeId::of::<ErasedLife<&'static str>>() 
        || TypeId::of::<ErasedLife<T>>() == TypeId::of::<ErasedLife<Cow<'static, str>>>() 
}

This does not need to transmute types and as such should be perfectly safe, right?

I couldn't find a way to create such a type without compiler support. Is it even possible to implement something like that in the compiler? Does it break something that I've missed?

I don't think that this would ever be implemented, but want to dream for a bit :smile:

The absence of an explicit transmute() call is not an argument that this is sound. There's plenty of other ways to cause UB with unsafe code.

AFAIK what you're describing is just fundamentally unsound. Box<T> can already do some things you might call "lifetime erasure" that are clearly sound, but boxing a dangling reference obviously won't make it any less dangling.

4 Likes

Why? Can you show me how could I cause UB with ErasedLife in safe code?

I agree, if it would allow accessing and/or setting values of type T then it would be unsound. But it's just an analog to PhamtomData what could go wrong?

I don't think you've suggested an actual API for this, so I doubt we can give any answer more precise than what I already said. For example, I could obviously show you how using transmute() to completely bypass the borrow checker can trivially cause UB, but I doubt that's what you're asking for (and the Rustonomicon already covers that). And if this actually is equivalent to PhantomData, then it's even more unclear what you think you're proposing; why not actually use PhantomData?

The onus is on the change proposer to actually propose a concrete, specific change, especially if you want concrete, specific feedback.

1 Like

I think it's pretty evident from the OP: a type constructor EraseLife such that EraseLife<T> has lifetime 'static but is otherwise equivalent to () (or even !), for the purpose of type-level operations. (Apparently PhantomData lacks the former property.)

I'm not necessarily in favour of this, but it's hardly unclear what is being proposed.

1 Like

I thought that what I'm proposing is actually clear... I'll try to explain better. I propose API (if you may call it so) like this:

// mod core::marker

/// A marker empty type that is `'static` even if `T` is not.
/// 
/// ## Examples
/// 
/// ```
/// fn assert_static<T: 'static>() {}
/// 
/// fn lifetimed<'a>(_: &'a ()) {
///     assert_static::<EraseLife<&'a ()>>();
/// }
/// ```
/// 
/// Integration with [`TypeId`](core::any::TypeId):
/// ```
/// use core::any::type_id;
/// 
/// fn test<'a, 'b>(_a: &'a str, _b: &'b str) {
///     // `ErasedLife` ignores lifetimes so this won't fail even if 'a != 'b
///     // This DOES NOT mean that `_a` and `_b` has the same type
///     // it only means that they have the same types _if you'll ignore lifetimes_ 
///     assert_eq!(type_id::<ErasedLife<&'a str>>(), type_id::<ErasedLife<&'b str>>())
/// }
/// 
/// test(String::new().as_str(), "");
/// ```
#[lang_item = "erase_lifetimes"]
pub struct EraseLife<T>;

Instead of proposing a new lang item that lets you use TypeId as a workaround for specialisation, why not just use specialisation directly?

#![feature(specialization)]

use std::borrow::Cow;

pub fn trusted<T>() -> bool {
    <T as MaybeTrusted>::IS_TRUSTED
}

trait MaybeTrusted {
    const IS_TRUSTED: bool;
}

impl<T> MaybeTrusted for T {
    default const IS_TRUSTED: bool = false;
}

impl<'a> MaybeTrusted for &'a str {
    const IS_TRUSTED: bool = true;
}

impl MaybeTrusted for String {
    const IS_TRUSTED: bool = true;
}

impl<'a> MaybeTrusted for Cow<'a, str> {
    const IS_TRUSTED: bool = true;
}

There's probably a better way though to achieve what you want without unstable features though... If some type is being used in a generic context then you already need to add trait bounds in order for it to be useful (unless you're doing unsafe things with pointers and raw memory, in which case "lifetime erasure" will almost certainly lead to a bad time).

And if that's the case, why not just roll your "trusted-ness" check into the function's trait bounds?

If you want to make sure downstream users can't override this check then you can use the "sealed trait" pattern (playground).


As an aside, this reminds me a lot of std::remove_cv from C++. Lifetimes and const/volatile have a lot of similar properties and there are valid reasons to want to work with something regardless of cv-qualifiers in C++, so maybe it's worth providing people with similar tools in Rust?

1 Like

Specialization doesn't seem to be stabilized anytime soon, it's the only problem (with nightly feature I'm already using specialization...)

This will lead to code duplication and user API pollution. E.g.:

impl MyType {
    fn new_trusted<S: Borrow<Str> + TrustedBorrow>(s: S) -> Self { ... }
    fn new_untrusted<S: Borrow<Str>>(s: S) -> Self { ... }
}

Another API that may look less dangerous / error-prone and still enable your initial use-case would be to have:

fn maybe_type_id<T> () -> Option<TypeId>
{
    builtin!(if T : 'static { Some(TypeId::of::<T>()) } else { None })
}

It would make the idea that "I don't care about TypeId precise comparison in the non-'static case" clearer.


In practice, however, your use case is actually more of a specialization one.

  • Granted, that one isn't stable yet (but that isn't an issue on Internals / when trying to ask for a new feature, since that new feature wouldn't be stable either), and for this case, specialization does carry many subtleties that your suggested simplification (especially further simplified to mine) does not. But I don't think that adding "intermediary features to save time" is an argument that holds in practice, since the main goal is to improve Rust in the long run.

Now, regarding the XY problem and the attempt to circumvent specialization,

This is something very common in the Rust ecosystem, so it should not be seen as bad, imho

  • See, for instance, Pin::new() vs. Pin::new_unchecked().

What you can do, if you want downstream users to be able to unsafe-ly provide their own trustable types, is try to change the API a bit, by making the function names express whether they perform a check or not, as is customary to do, and then provide an escape-hatch to opt-out of unsafe for the specific strings that can be trusted:

// Expected usage:
MyType::new(s, ...); // performs checks no matter what.

MyType::new_unchecked(s.into(), ...); // no need for `unsafe`
unsafe {
    MyType::new_unchecked(Trusted::trust(s), ...); // Trusted::trust needs `unsafe`
}
Implementation

Have the _unchecked() variant take a Trusted<S> where S : Borrow<str>:

pub
struct Trusted<T> /* = */ (
    pub(in crate) T,
);

impl<T> Trusted<T> {
    /// # Safety
    /// ...
    pub
    unsafe
    fn trust (it: T) -> Self
    {
        Self(it)
    }
}

/// # Safety
///   - it must be safe to call `Trustable::trust` on any instance
///     of type `Self`.
pub
unsafe
trait Trustable {}

impl<T : Trustable> From<T> for Trusted<T> {
    fn from (it: T) -> Self
    {
        unsafe {
            // Safety: `T : Trustable`
            Trusted::trust(it)
        }
    }
}
1 Like

Seems better to provide a variant of TypeId::of without the 'static bound.

1 Like

While I have found myself wanting to erase lifetimes, that's only because I wanted to shove something into PhantomData without having to deal with the fact that the type might have lifetimes in it. The problem is that in Rust, the lifetime 'a simply cannot be named outside of the corresponding region (which makes sense, since as they do not exist at runtime).

What I think would be much more useful is something like MakeStatic<T>, such that if T has lifetimes in it, then MakeStatic<T>::Output is T with all those lifetimes set to 'static. Off the top of my head, I have no reason to believe there exists a type constructor of kind K: lifetime -> type such that K<'static> is an ill-formed type, but perhaps there's a contrived example involving contravariance.

Unfortunately, I don't believe such a thing could be implemented without compiler support.

Sure, but the fact is, this cannot be done yet. Type-checking and "lifetime checking" (borrowck) are different things, meaning that the type information of Thing<'a> is the same as that of Thing<'b>, compiler-wise1, so they would have the same TypeId. Thus:

  • if Any lost its 'static bound, that would be unsound;

  • if it didn't, some people would circumvent that by reimplementing a new Any using that non-'static-ally-bound TypeId::of, and that would be unsound.

Hence my suggestion for a TypeId::of alternative, that would not require 'static, but which would return Option<TypeId>, assuming, of course, a minimal capacity of the compiler type representation to be able to distinguish the 'static case from anything else.


1 To expand on this: IIUC, the compiler never sees an actual lifetime instance other than 'static: once lifetime parameters are involved, borrowck handles that by creating constraints on "regions" and trying to see just if solutions to the constrained problem could exist. But instances of an actual solution are never witnessed.

3 Likes

I mean, a variant of TypeId::of without a 'static bound that returns the same TypeId as if all lifetimes were replaced with 'static.

Obviously you can't use that to implement a safe downcasting API or Any variant, and if someone does their crate is broken.

The actual problem is the same reason we don't want to allow specialization on lifetimes: it allows program behavior to change based on inferred lifetimes.

The specific problem with Any is that:

  • You cannot know exact lifetimes at runtime, only "this lifetime was at least as long as this other one" (a compile-time fact, really).
  • You can't move the problem to compile time: lifetime variance makes something like Any<'a>: 'a (read: "any type that lives for as long as 'a") not work. Let's elaborate on that.

Suppose we parametrize Any and TypeId by a lifetime, such that Any<'a> now represents "any type that outlives 'a" (the current meaning is recovered by setting 'a = 'static), so T: 'a implies T: Any<'a>. As before, all of Any<'a>'s impls are for dyn Any<'a> + 'a.

TypeId::<'a>::of:<T>() requires that T: 'a, and returns some value that may or may not incorporate 'a into it (probably not, given the current implementation).

Here's where a problem might arise:

let x: &i32;
let any: &dyn Any<'a> + 'a = &x;
let any2: = &dyn Any<'static> + 'static = any;
let y = any2.downcast_ref::<&'static i32>();

This program is clearly incorrect, since we've changed an invariant lifetime. However, it seems that the conversion of Any<'a> into Any<'b> is not allowed: trait object lifetime parameters seem to always be invariant: https://godbolt.org/z/3GEs7q

Unfortunately, this isn't the whole picture. While TypeId<'a> and dyn Any<'a> can be invariant, the impl of Any<'a> cannot. That is, if we wrote

pub fn is<T: Any<'a>>(&self) -> bool {
  TypeId::<'a>::of::<T>() == self.type_id()
}

we have no way of ensuring that T does not have lifetimes in it that are longer than those of Self. In general, I don't believe the lifetime formalism can express such a constraint.

See: https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=3abb9a4e777673f0344c6163b3dd1312

2 Likes

That's a clever idea, thanks! Probably I'll use it.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.