[Idea] Refining const generic types

Thanks to the great work of contributors, the implementation of const generics in Rust is getting further and further along.

As such, I myself have been experimenting on nightly, and considering some future extensions. One of the things I believe might be a future UX improvement with const generics, is a way to safely "refine" a generic const generic type to a specific type, as can be done today using an unsafe transmute:

#![feature(const_generics)]

struct S<const B: bool>;

impl S<true> {
  fn print_true(&self) { println!("true"); }
}

impl S<false> {
  fn print_false(&self) { println!("false"); }
}

impl<const B: bool> S<{B}> {
  fn print(&self) {
    match B {
      true => unsafe { core::mem::transmute::<&S<{B}>, &S<{true}>>(self).print_true() },
      false => unsafe { core::mem::transmute::<&S<{B}>, &S<{false}>>(self).print_false() }
    }
  }
}

playground

I believe this to be sound (is it?).

Note that in this trivial example the implementation of print could be reduced to just

match B {
  true => println!("true"),
  false => println!("false")
}

but as a more complicated example, consider some trait implemented for specific type variant(s) but not the generic type:

trait T { fn print(&self); }
impl T for S<{true}> { ... }

impl<const B: bool> S<{B}> {
  fn print(&self) {
    match B {
      true => <S<{true}> as T>::print(self), // error: mismatched types; expected `&S<{true}>`, found `&S<{B}>`
      // or
      true => <S<{B}> as T>::print(self), // error: T is not implemented for S<{B}>, found implementation for S<{true}>

      false => ...
    }  
  }
}

I believe this conditionally "refining" a generic type S<{B}> to a specific variant S<{true}> is

  • sound, when B == true
  • potentially safe, I believe the compiler could have enough information to eliminate the need for unsafe transmutes
  • useful, it allows you to use the traits, types and methods associated with a specific variant type when using the generic type, as shown in the example

Thoughts?

(I could not find if something like this has been discussed before, although the idea of bridging the gap between generic types and specific types seems similar to an earlier discussion, Exhaustiveness checks for const generic impls: if a trait is implemented for S<{true}> and S<{false}>, should it automatically also be implemented for S<{B}>?)

This is basically downcasting but it doesn't (?) involve runtime type information. As such, I would consider it a big red flag. It might be useful as a last resort in some cases, but matching on the type and changing behavior based on that type is usually regarded as bad practice, as it is circumventing an abstraction boundary. As such, I strongly disagree that this should be added as a language feature.

If you depend on the const value parameter in a function, you should instead make the function itself generic, and match on the value in that generic function like so:

impl<const B: bool> S<{B}> {
    fn print_bool(&self) {
        println!("{}", B);
        // or, using the more explicit:
        match B {
            true => println!("true"),
            false => println!("false"),
        }
    }

    fn print(&self) {
        self.print_bool()
    }
}

I agree that this should be added.

It is a standard feature in dependently typed languages, which wouldn't be usable without this.

It has nothing to do with "matching on types" directly, it's matching on values and refining a dependent type inside the match arm.

1 Like

Shouldn't this be expected to work?

trait T { fn print(&self); }
impl T for S<{true}> { ... }

impl<const B: bool> S<{B}> {
  fn print(&self) {
    match self {
      b @ S<{true}> => b.print(); // or more explicitely `<b as T>::print(b)`
      false => ...
    }  
  }
}

I would agree that the syntax isn't great since you can't use self as a binding on the left side of @, but for anything but self I think it would be totally fine.

My take on this would be, this works today:

trait Print {
    fn print(&self);
}
impl<const B: bool> Print for S<B> {
    default fn print(&self) {
        unreachable!()
    }
}
impl Print for S<true> {
    fn print(&self) {
        self.print_true()
    }
}
impl Print for S<false> {
    fn print(&self) {
        self.print_false()
    }
}

fn call_print<const B: bool>(x: S<B>) {
    x.print()
}

fn main() {
    call_print(S::<true>);
    call_print(S::<false>);
}

(with specialization)

and this works today:

impl S<true> {
    fn print(&self) {
        self.print_true()
    }
}
impl S<false> {
    fn print(&self) {
        self.print_false()
    }
}

fn main() {
    S::<true>.print();
    S::<false>.print();
}

so then this should work IMO:

impl S<true> {
    fn print(&self) {
        self.print_true()
    }
}
impl S<false> {
    fn print(&self) {
        self.print_false()
    }
}

fn call_print<const B: bool>(x: S<B>) {
    x.print()
}

fn main() {
    call_print(S::<true>);
    call_print(S::<false>);
}

i.e. the compiler should realize that there is an exhaustive set of impl blocks for boolean arguments, so calling print with a generic bool should be allowed.

As mentioned this is just a subset of (compile-time) downcasting (a.k.a. specialization), and can be safely implemented that way today:

#![feature(const_generics)]

use core::any::Any;

struct S<const B: bool>;

impl S<true> {
    fn print_true(&self) {
        println!("true");
    }
}

impl S<false> {
    fn print_false(&self) {
        println!("false");
    }
}

impl<const B: bool> S<{ B }> {
    fn print(&self) {
        if let Some(true_self) = (self as &dyn Any).downcast_ref::<S<true>>() {
            true_self.print_true();
        } else if let Some(false_self) = (self as &dyn Any).downcast_ref::<S<false>>() {
            false_self.print_false();
        } else {
            unreachable!();
        }
    }
}

fn main() {
    S::<true>.print();
}

and this gets successfully optimized into directly calling the correct implementation.

2 Likes

This is effectively specialization, and should be treated as delicately as true specialization. However, in favor of "const specialization/refinement":

  • referential transparency is already broken by any::type_name. This isn't carte blanche to freely break it everywhere else -- it's still a useful property and any::type_name's purpose is to break it for debugging purposes -- but it is an argument of keeping it just to keep the property.
  • extending that same argument, (minimal) specialization is coming, eventually. In some limited fashion, trait impls will be allowed to refine behavior for specific types within a blanket impl.
  • and unlike type information, which is black-boxed behind a (mostly) opaque type variable (or dyn indirection) and only available at runtime (modulo monomorphizations, which are really an implementation detail), the point of const generics is to specialize behavior based on constant compile-time arguments.

It's that third argument that pushes me over the edge. (The first two are basically just weakening arguments against, the third is distinctly for.) const generics' purpose is to be available at compile-time to make these kinds of dispatch decisions.

So especially because it's already possible to optimize down to zero-cost with Any, I'd actually be behind some limited form of refinement typing should be possible, though I'd probably think matching on the (type of) the refined value rather than the const generic itself makes more sense within Rust's design (with explicit types that don't change to refine, because different types can have different representations), as sketched by @robinm