Pre-RFC: make downcast user-friendly

Trait-object upcast was recently merged. Unfortunately downcasting is still not very user-friendly.

Motivation

Given

use std::{any::Any, ops::Deref};

trait A: Any {}
trait B: A {}

impl A for () {}
impl B for () {}

we can now easily upcast trait objects:

let b = Box::new(()) as Box<dyn B>;
let a = b as Box<dyn A>; // upcast

Type-checking and downcast is supported, but usage is less clear:

assert!((a.deref() as &dyn Any).is::<()>());
let c = (a as Box<dyn Any>).downcast::<()>();

We should be able to improve this to something like the following:

assert!(a.inner_type_is::<()>()); // method defined on Box
assert!(a.deref().type_is::<()>()); // alternative
// Note that we type-check the contents, not the Box!

let c = a.downcast::<()>();

Implementating details

Type-checking

std::any::Any plus TypeId provide all we need, but the following is not user-friendly:

assert_eq!(a.deref().type_id(), TypeId::of::<()>());

Currently, fn is is defined on dyn Any, dyn Any + Send and dyn Any + Send + Sync. This implies that (1) it is not directly available on dyn A, dyn B etc. and (2) it is not available on generics like T: Any.

Aside: is is too short/generic a name for a widely available method, hence I use the longer name type_is below.

Options for making type_is more accessible:

  1. As an additional method on Any: fn type_is<U: Any>(&self) -> bool;

    Usage on a Box requires an explicit dereference to test the inner type, not the type of the Box: a.deref().type_is::<()>() or (*a).type_is::<()>().

    Issue: this is technically a breaking change since anywhere that Any is in scope, all deriving trait objects will gain a new method. This could in theory conflict with user-defined methods on derived traits. Likely impact: low.

  2. The same method, but on an extension trait AnyExt: Any.

    This would be provided with impl<T: Any + ?Sized> AnyExt for T {..}. Users must explicitly import std::any::AnyExt, thus avoiding unexpected conflicts.

  3. A similar method placed directly on Box (and similar container types) which emphasizes that it tests the inner type: fn inner_type_is<U: Any>(&self) -> bool;

    This approach is more user-friendly than (1) and (2) since there is no need to deref() (nor the footgun of accidentally forgetting), while still covering likely use-cases.

    Like (1) there is potential breakage, but with much lower expected impact (more restricted availability and more complex name), thus this should be a non-issue.

Downcast

Downcast is necessarily implemented on containing types like Box. Today, Box provides impls for three cases:

  • Box<dyn Any>
  • Box<dyn Any + Send>
  • Box<dyn Any + Send + Sync

This does not cover user-defined traits like Box<dyn B>. What we really want is to define downcast on Box<T> where T: Any + ?Sized, but for obscure reasons some types like str and [i32] technically implement Any (unsafely due to length being omitted from the TypeId) while not supporting the ability to type-cast to dyn Any.

What we need to make the generics work is provided by Unsize:

pub trait BoxExt: Sized {
    /// Optional method: type-check inner value
    fn inner_type_is<U: Any>(&self) -> bool;

    /// Attempt to downcast the inner value
    fn downcast<U: Any>(self) -> Result<Box<U>, Self>;
}

impl<T: Any + Unsize<dyn Any> + ?Sized> BoxExt for Box<T> {
    fn inner_type_is<U: Any>(&self) -> bool {
        self.deref().type_is::<U>()
    }

    fn downcast<U: Any>(self) -> Result<Box<U>, Self> {
        if self.inner_type_is::<U>() {
            unsafe {
                // This cast requires T: Unsize<dyn Any> :
                let any: Box<dyn Any> = self as Box<dyn Any>;
                let raw: *mut dyn Any = Box::into_raw(any);
                Ok(Box::from_raw(raw as *mut U))
            }
        } else {
            Err(self)
        }
    }
}

Full example on the playground

Usage on non-dyn types

Since the above are generic, they work on non-dyn types:

    let b = Box::new(());
    assert!(b.inner_type_is::<()>());
    let b: Box<()> = b.downcast().unwrap();

This is fine, and has some utility in generic code.

Introducing the above

The first potential blocker is that Unsize has not yet been stabilized. It is however already widely used within the std library so this may not be an issue.

The second issue potential issue is breaking changes. It is possible to implement user-defined methods on Box via extension traits as demonstrated above but not inherent methods. While such conflicts cannot be ruled out, they are unlikely.

Proposal

Implement the following on Box, replacing the current impls of downcast:

impl<T: Any + Unsize<dyn Any> + ?Sized, A: Allocator> Box<T, A> {
    /// Type-check inner value
    pub fn inner_type_is<U: Any>(&self) -> bool {
        self.deref().type_id() == TypeId::of::<U>()
    }

    /// Attempt to downcast the inner value
    pub fn downcast<U: Any>(self) -> Result<Box<U, A>, Self> {
        if self.inner_type_is::<U>() {
            unsafe {
                let raw: *mut dyn Any = Box::into_raw(self as Box<dyn Any, A>);
                Ok(Box::from_raw(raw as *mut U))
            }
        } else {
            Err(self)
        }
    }
}

Do the same for other container types currently providing fn downcast (Error, Rc, Arc).

The method fn downcast is strict superset of the existing downcast methods on Box. There is some potential for collision through user-defined extension trait on Box (certainly worth a crater run).

The method fn inner_type_is is an optional extra with some applications such as passing messages via a variadic queue/stack (where only messages of known type are removed).

Alternative: implement via extension traits

Instead of proposal 1, introduce the same methods via an extension trait BoxExt (respectively ErrorExt, RcExt, ArcExt) which requires explicit import. Keep the existing downcast methods (not a conflict since inherent methods are preferred over trait-provided methods).

This avoids the (low) potential for breakage through name conflicts but has worse usability: discovery, API complexity and need-to-import.

2 Likes

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