TypeId::try_of

We only support downcasts to 'static types but we could support non-'static types to satisfy downcasting traits if we add an underlying TypeId which simply fails on non-'static types.

/// We do not implement comparison traits because non-`'static` `TypeId`s are
/// incomparable, so even comparing `TypeId::try_of` errors creates a footgun.
pub struct NonStaticTypeIdError; 

impl TypeId {
    /// Returns the `TypeId` of the type `T` if `T: 'static`.  Returns an error otherwise.
    pub fn try_of<T: ?Sized>() -> Result<TypeId,NonStaticTypeIdError> { .. }
}

At present, we could've a trait method like fn type_id(&self) -> TypeId where Self: 'static + ?Sized and then provide downcast methods in impl dyn Trait + 'static { } blocks. Yet, this fails when we want to pass the type through some intermediate interface without the 'static bound.

So this fails at runtime rather than the status quo at compile time? What's the advantage of that?

Doesn't this have the same problem as specialization? When the compiler reaches monomorphization lifetimes are erased so it can't know whether the T is actually 'static or not.

10 Likes

Yes, you'd always have actually 'static types which failed this, but those types also fail T: 'static. The point is not to enlarge the definition of T: 'static, but to be able to write code that downcasts only when allowed to do so.

It's kinda niche I guess, so maybe easier to simply invoke the underlying intrinsic and do your downcast manually, but simply not provide any non-'static downcast targets.

I don't think so. Take &'static str for example. It satisfies &'static str: 'static, but after lifetime erasure it becomew &'a str, which is not known to be 'static. Thus try_id's monomorphization would have to pick the non-'static path, returning Err, even though TypeId::of compiles.

5 Likes

The use of 'static as a bound is not uncontroversial and somewhat contrived in the first place. It is used as a sort of proof that no other valid instantiations of the type family containing T exists, that merely differs in lifetimes. This is dubious. For references and all types covariant in lifetimes it's obviously true, the only instance fulfilling the bound is the one with all parameters substituted with 'static which can be at most a single element.

  • For instance, &'static u8 as the only one of the &'a u8 family.
  • Or &'static Location<'static> for &'a Location<'b>.
  • But what about fn(&'a u8), which is contravariant? There's the super type for<'a> fn(&'a u8), which is clearly considered 'static.

Here reasoning becomes murky. We have fn(&'static u8): 'static. But should the variance not also imply that also fn(&'a u8): 'static for any other lifetime? But then the current TypeId would allow two distinct types of the same lifetime-family, which would get assigned the same ID and allow unsound casts. The only reason this doesn't explode is that the variance-based implication is not the case.

Code as proof
fn is_static<T: 'static>(_: T) { }

fn test(_: &u8) {}

const _: () = {
    is_static::<u8>;
    is_static::<for <'a> fn(&'a u8)>;
    is_static::<fn(&'static u8)>;
};

fn family_is_static() {
    let v: for<'a> fn(&'a u8) = test;
    is_static(v);
}

fn fail<'a>() {
    let v: fn(&'a u8) = test;
    // is_static::<fn(&'a u8)>(v);
}

Clearly Rust does not consider fn(&'a u8): 'static. And if it did, a property which the usual subtyping relationship requires, then Any is unsound. This is spicy. Clearly even a small oversight in the implementation (trait objects, and higher kinds could also introduce contravariance) can escalate a bit.

3 Likes

I would consider the "static" version of fn(&'a u8) to be fn(&'static u8).

Also, be careful with HRTBs since with them the same type can have multiple representations, with multiple TypeIds. For example for<'a> fn(&'a u8, &'a u8) and for<'a, 'b>(&'a u8, &'b u8) are effectively the same type, but they have different TypeIds. See also https://github.com/rust-lang/rust/issues/97156

2 Likes

What would be theoretically possible is to have a fallible way to get a TypeId which succeeds if the type captures no lifetimes and is thus guaranteed to always be 'static. This is useful, but it would be surprising that by necessity you'd have TypeId::try_of::<&'static str>() == None despite TypeId::of::<&'static str>() compiling.

A function couldn't, but a builtin could theoretically produce Some(TypeId::of::<T>()) if that would compile and None otherwise, but that's of extremely niche use at best; basically only in macro-expanded code, since otherwise you can just use TypeId::of directly, and even then I can't think of an application.

What might be actually useful is some sort of covariance system like is used to implement the Provider interface. What that uses is a way to get TypeId for non-'static values via "type tagging"; T gets type_id(Own(T)), &T type_id(Ref(T)), and &mut T type_id(Mut(T)). We can then compare for type equality of these tags and exploit the covariance to restrict the actually downstream provided result to a safe lifetime.

A fn TypeId::of_static<T: ?Sized>() -> TypeId which gives the type with all lifetimes set to 'static would be useful for doing such schemes more easily, but is very difficult to hold correctly. It's essentially impossible to soundly use generically without some sort of trait for covariance as well, kinda like how yoke does it.

It could perhaps look something like [playground]

// mod std::any;

impl TypeId {
    pub fn of_static<T>() -> TypeId;
}

pub trait CoAny {
    fn static_type_id(&self) -> TypeId;
}

impl<T> CoAny for T {}

// SAFETY: 🤷
pub unsafe trait Covariant {
    type Output<'a>: ?Sized + 'a
    where Self: 'a;
}

unsafe impl<T: ?Sized + 'static> Covariant for T {
    type Output<'a> = T;
}

unsafe impl<T: ?Sized + Covariant> for &'_ T {
    type Output<'a> = &'a T::Output<'a>;
}

impl dyn CoAny + '_ {
    pub fn is<T>(&self) -> bool;

    pub fn downcast<'a, T>(self: Box<Self>) -> Result<Box<T::Output<'a>>, Box<Self>>
    where
        Self: 'a,
        T: Covariant,
        T::Output<'a>: Sized;

    pub fn downcast_ref<'a, T>(&'a self) -> Option<&'a T::Output<'a>>
    where
        Self: 'a,
        T: Covariant,
        T::Output<'a>: Sized;

    // downcast_mut would be unsound! as &mut T isn't covariant over T
}

but that would still need a somewhat new compiler magic[1] for Covariant to be useful, or a large pile of manual impls for whatever's considered useful like yoke is doing.

Plus, Covaraint is probably not quite the right name; the purpose is to require that all captured lifetimes are covariant and limit them to at most 'a so you can't transmute any lifetimes improperly via "downcast".


  1. Ideally, Covariant would be automatically implemented where possible, so CoAny just works for as many types as possible, like Any works for all T: 'static. But even if not, it still wants magic; the overlap is not quite just specialization, since we want to know T: Covariant<Output = T> when T is "invariantly 'static". ↩ī¸Ž

Such a TypeId::try_of would make broption unsound.

Another concern with such an approach would be the one I pointed out here.

1 Like

I'd envisioned TypeId::try_of::<&'static str>() being an error here, but yeah right that's some other property than 'static. It's anyways possible to implement this in practice.

/// We do not implement comparison traits because non-`'static` `TypeId`s are
/// incomparable, so even comparing `TypeId::try_of` errors creates a footgun.
pub struct TypeIdError; 

/// If you want to downcast to some type `T` then you can instantiate this trait like
/// ```
/// unsafe impl MaybeAny for T {
///     fn try_type_id(&self) -> Result<TypeId,TypeIdError> {
///         Ok(TypeId::of::<Self>())
///     }
/// }
/// ```
/// It's unsound to return `TypeId` for some type other than `Self`, but always sound
/// to return `Err(TypeIdError)`.
///
/// We suggest `try_type_id` return `TypeIdError` for all types that capture any lifetimes.
/// In principle, users could choose to only `impl MaybeAny` for `'static` flavors of their
/// types, but then `core::any::Any` maybe fits their use cases better.
pub unsafe trait MaybeAny {
    /// This could return `TypeId::of::<Self>()` provided `Self` cannot capture
    /// non-`'static` lifetimes.  It could safely return an error too, the default.
    fn try_type_id(&self) -> Result<TypeId,TypeIdError> {
        Err(TypeIdError)
    }
}

impl dyn MaybeAny { ... }

We do not have some AlwaysStatic bound for specialization, but if one wants this then one could build out this trait for whatever types you encounter.

Huh, digging into that, we're surprised our own "non-'static Any with binded lifetimes" (as opposed to hidden lifetimes; we think we called it AnyA<'a> at the time) didn't come up.


Non-'static Any is completely sound, if you allow it to carry the correct lifetime information. What is "the correct lifetime information"? Consider:

type Foo<'a, 'b> = &'a &'b str;

Here we have two lifetimes: 'a and 'b. But we also have binding sites for them: &'a &'b str. But we could also write:

type Foo<'a, 'b> = &'b &'a str;

This changes the binding sites. Fortunately we can represent binding sites in TypeId! Consider:

    dbg!(std::any::TypeId::of::<for<'a, 'b> fn(&'a (), &'b (), &'a &'b str)>());
    dbg!(std::any::TypeId::of::<for<'a, 'b> fn(&'a (), &'b (), &'b &'a str)>());

(the &'a () etc are needed because for<'a, 'b> doesn't by itself specify the binding order.)

Now what if we had a type, say, dyn AnyAB<'a, 'b>, which provided checked lifetimes for 'a and 'b? We've already shown how to distinguish between &'a &'b str and &'b &'a str, so at least that part is sound, which is all that is required for soundly downcasting AnyAB. Ofc, constructing an AnyAB is still a challenge, and wouldn't really be possible without the compiler's help. But maybe we'll have that too some day.

1 Like

Using "generic generics" or HKTs or whatever you wanna name it, it is indeed possible to split a 'lt-infected type into 'lt, and the type without the lifetime:

&'lt str ~ ('lt, HKT!(<'r> = &'r str))

with that second element being 'static and thus having a queryable TypeId.

  • (We could even further generalize this to multiple lifetimes).

The problem then, becomes a lack of injectivity w.r.t. this split for, e.g., certain 'static types:

&'static str ~ ('static, HKT!(<'r> = &'r str))
// but also
&'static str ~ ('static, HKT!(<'r> = &'static str))
             // ^ or 'whatever

Which definitely illustrates that the one trying to query the TypeId ought to be providing some choice of that "lifetime-deprived" TypeId witness (that is, if your starting point was some non-: 'static <T> type in scope; you'd have to require that <'lt, G> be given (whereG : HKT, G::_<'lt> = T). That is:

trait Example { // no `Self : 'static` bound!
    fn hkt_type_id<'lt, G : HKT>()
      -> TypeId
    where
        G : 'static, // to get the TypeId
        // `Self ~ ('lt, G)` i.e., `G<'lt> = Self`.
        G::__<'lt> : Is<EqTo = Self>,
    {
        TypeId::of::<G>()
    }
}

Getting lifetime-infected Any from this idea is then easy:

  • unsafe
    trait Any1<'lt> : 'lt {
        fn type_id(&self) -> TypeId;
    }
    
    impl dyn Any1<'lt> {
        fn coerce<T : 'static + HKT>(value: Box<T::__<'lt>>)
          -> Box<Self>
        {
            #[repr(transparent)]
            struct Helper<'lt, T : HKT>(
                T::__<'lt>,
            );
    
            impl<'lt, T : 'static + HKT> Any1<'lt> for Helper<'lt, T> {
                fn type_id(&self) -> TypeId {
                    TypeId::of::<T>()
                }
            }
    
            let value: Box<Helper<'lt, T>> = unsafe {
                // Safety: `repr(transparent)` layout.
                ::core::mem::transmute(value)
            };
            value
        }
    
        fn is<T : 'static + HKT>(&self)
          -> bool
        {
            self.type_id() == TypeId::of::<T>()
        }
    
        fn downcast<T : 'static + HKT>(self: Box<Self>)
          -> Result<
                Box<T::__<'lt>>,
                Box<Self>,
            >
        {
            if self.is::<T>() {
               let ptr: *mut () = Box::into_raw(self) as _;
               // let ptr: *mut Helper<'lt, T> = ptr.cast(); // the real type
               let ptr: *mut T::__<'lt> = ptr.cast(); // thanks to repr(transparent)
               Ok(unsafe { Box::from_raw(ptr) })
            } else {
                Err(self)
            }
        }
    }
    

    Playground

    /// `&str`
    type StrRef = HKT!(&'__ str);
    let local = String::from("...");
    let b: Box<&str> = Box::new(&local);
    let b: Box<dyn Any1<'_>> = <dyn Any1>::coerce::<StrRef>(b);
    let b: Box<&str> = b.downcast::<StrRef>().unwrap_or_else(|_| unreachable!());
    dbg!(b);
    

And to avoid needing to turbofish/clarify the T : HKT parameter each time, I guess we could try to provide some arbitrarily/canonical injectivity in certain cases:

  • trait Is { type EqTo : ?Sized; }
    impl<T : ?Sized> Is for T { type EqTo = Self; }
    
    fn into<T>(this: T) -> <T as Is>::EqTo { this }
    fn from<T>(it: <T as Is>::EqTo) -> T { it }
    
    trait RemoveLifetime<'lt>
    where
     // Self      =     Apply<'lt, Self::HKT>
        Self: Is<EqTo = Apply<'lt, Self::HKT>>,
    {
        type HKT : 'static + HKT;
    }
    
    impl<'lt, T : ?Sized + 'static> RemoveLifetime<'lt> for &'lt T {
        type HKT = HKT!(&'__ T);
    }
    
    fn boxed_any<'lt, T : RemoveLifetime<'lt>>(value: T)
      -> Box<dyn Any1<'lt>>
    {
        <dyn Any1<'_>>::coerce::<T::HKT>(Box::new(into(value)))
    }
    
    fn downcast<'lt, T : RemoveLifetime<'lt>>(
        value: Box<dyn Any1<'lt>>,
    ) -> Option<T>
    {
        value.downcast::<T::HKT>().ok().map(|b| from(*b))
    }
    

    Playground

    // Look ma, no turbofish!
    let local = String::from("...");
    let b: Box<dyn Any1<'_>> = boxed_any(&*local);
    let b: &str = downcast(b).unwrap();
    dbg!(b);
    

All this is not necessarily a direct answer to the OP, but I think it touches quite a few tangential things, with actual Rust code, so that questions we may have around the semantics of such may hopefully be answered or clarified through these snippets :slight_smile:

2 Likes

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