github linkedin stack-exchange mail youtube discord steam

Aren't Traits and Enums the Same?

This article is about the Rust programming language, traits, and enums.

Consider the following code:

mod trait_polymorphism {
    pub trait Abstract {
        fn get_num(&self) -> i32;
    }
    
    pub struct ConcreteA;
    
    impl Abstract for ConcreteA {
        fn get_num(&self) -> i32 {
            0
        }
    }
    
    pub struct ConcreteB(pub i32);
    
    impl Abstract for ConcreteB {
        fn get_num(&self) -> i32 {
            self.0
        }
    }
    
    pub struct ConcreteC {
        pub items: Vec<i32>
    }
    
    impl Abstract for ConcreteC {
        fn get_num(&self) -> i32 {
            self.items.iter().sum()
        }
    }

    pub fn generic_fn<T>(x: T) -> i32 
    where 
        T: Abstract 
    {
        let n = x.get_num();
        (n + 5) * 13
    }
}

mod enum_polymorphism {
    pub use self::Abstract::*;

    pub enum Abstract {
        ConcreteA,
        ConcreteB(i32),
        ConcreteC {
            items: Vec<i32>
        }
    }
    
    impl Abstract {
        pub fn get_num(&self) -> i32 {
            match self {
                ConcreteA => 0,
                ConcreteB(x) => *x,
                ConcreteC { items } => items.iter().sum()
            }
        }
    }
    
    pub fn generic_fn(x: Abstract) -> i32 {
        let n = x.get_num();
        (n + 5) * 13
    }
}

use trait_polymorphism as tp;
use enum_polymorphism as ep;

fn main() {
    assert_eq!(tp::generic_fn(tp::ConcreteA), 65);
    assert_eq!(tp::generic_fn(tp::ConcreteB(30)), 455);
    assert_eq!(tp::generic_fn(tp::ConcreteC { items: vec![9, 2, 15, 36] }), 871);
    
    assert_eq!(ep::generic_fn(ep::ConcreteA), 65);
    assert_eq!(ep::generic_fn(ep::ConcreteB(30)), 455);
    assert_eq!(ep::generic_fn(ep::ConcreteC { items: vec![9, 2, 15, 36] }), 871);
}

This code demonstrates the practical similarity between enums and traits.

Despite being very syntactically different, both traits and enums allow the implementation of the same underlying abstraction: polymorphism.

As such, their feature set largely overlaps, and they end up being differentiated by only a few things:

traits:

  • Are implementable by external users, and are thus extendable

  • Do not have a known compile-time size, and are not always object-safe

  • Provide no information about their underlying concrete type, and thus cannot be pattern matched on, such as with a `match` block

enums:

  • Are not extendable, and have a fixed set of variants

  • Always have a known size at compile-time

  • Have a definitively known underlying concrete type, and can be pattern matched on

Otherwise, however, they're actually functionally pretty similar.

Are traits and enums really all that different?


Lets do a little thought experiment: what if we were to try and consolidate the features of both structures into a single one? How could we do so?

We could try to invent a new language structure to replace both of them, but I think it makes more sense to start with one, and try to incorporate the features from the other.

We could take two approaches:

  • Attempt to incorporate the features of traits into enums, which would consist of:

    • Allowing enums to be extended

  • Attempt to incorporate the features of enums into traits, which would consist of:

    • Allowing trait objects to always have a known size, and always be object-safe

    • Allowing trait implementors to be pattern matched on

Because doing the first is, in my arbitrary opinion, more complicated, I'm going to explore the latter: attempting to incorporate the features of enums into traits.


The two feature sets of enums and traits that we want to combine into a single structure are mutually exclusive to one another, so we will want to introduce a keyword that allows us to select between "enum-like" behavior and "trait-like" behavior in our constructed abstraction.

For this, I propose the keyword `sealed`.

A sealed trait is defined like this:

sealed trait Abstract {
  fn generic_fn(&self) -> i32;
}

struct ConcreteA;

impl Abstract for ConcreteA {
  fn generic_fn(&self) -> i32 {
    0
  }
}

struct ConcreteB(i32);

impl Abstract for ConcreteB {
  fn generic_fn(&self) -> i32 {
    self.0
  }
}

Simple enough, right? Other than the keyword, you define it the same way as you would any other trait.

Sealed traits, however, would have one additional rule to follow: they can only be implemented by types defined in the current crate.

In exchange for this restriction, we can make additional assumptions about sealed traits:

  • They are always object safe (because we know what all their implementers are)

  • They can be pattern matched on, since we have access to a definitive, exhaustive list of every concrete type that implements them

All of a sudden, our sealed trait seems to have all the features of an enum that we'd want!

In fact, if we're allowed to pattern-match on sealed trait values, we could rewrite the code up above like so:

sealed trait Abstract;

struct ConcreteA;
impl Abstract for ConcreteA {}

struct ConcreteB;
impl Abstract for ConcreteB {}

impl<T> T where T: Abstract {
  fn generic_fn(&self) -> i32 {
    match self {
      ConcreteA => 0,
      ConcreteB(x) => *x,
    }
  }
}

This is looking more like an enum already!

Note: `impl<T> for T`, although disallowed normally, is fine here, because `T` is constrained by a sealed trait. This means that all values of `T` can only be concrete types defined in this crate, so all types this impl covers are locally defined. Thus, it doesn't break rust's rules regarding implicit impl blocks for foreign types.


Lets take it a step further.

Maybe not always, but sometimes or perhaps often, we'll define all implementers of our sealed trait in one place. If so, it's probably cumbersome to have to write individual `impl` blocks for every single new struct we want to include in our trait definition. This is especially true if the trait itself is empty, and we're just defining implementations to denote a relationship alone, without creating any concrete functionality, like in the code example above.

Here, i'd like to propose some new syntax:

sealed trait Abstract for {
  ConcreteA,
  ConcreteB(i32),
}

This would desugar to the same code from above:

sealed trait Abstract {}
struct ConcreteA;
impl Abstract for ConcreteA {}
struct ConcreteB(i32);
impl Abstract for ConcreteB {}

But without all the extra boilerplate.

Putting it all together, we end up with this:

sealed trait Abstract for {
  ConcreteA,
  ConcreteB(i32),
}

impl<T> T where T: Abstract {
  fn generic_fn(&self) -> i32 {
    match self {
      ConcreteA => 0,
      ConcreteB(x) => *x,
    }
  }
}

This is nearly identical to the equivalent enum-based code you would write in standard rust:

enum Abstract {
  ConcreteA,
  ConcreteB(i32),
}

impl Abstract {
  fn generic_fn(&self) -> i32 {
    match self {
      ConcreteA => 0,
      ConcreteB(x) => *x,
    }
  }
}

So it seems we've officially come full-circle. I would consider this a moderate success.


But, we're not done yet!

What if you wanted to define trait functions for the sealed trait as well like up here, in addition to pattern-matching function implementations?

Well, we can extend the syntax to support that too. Have a look at this:

sealed trait Abstract {
  fn other_fn(&self) -> i32;
} for {
  ConcreteA with {
    fn other_fn(&self) -> i32 {
      -5
    }
  },
  ConcreteB(i32) with {
    fn other_fn(&self) -> i32 {
      self.0 - 5
    }
  },
}

impl<T> T where T: Abstract {
  fn generic_fn(&self) -> i32 {
    match self {
      ConcreteA => 0,
      ConcreteB(x) => *x,
    }
  }
}

This code desugars how you would expect:

sealed trait Abstract {
  fn other_fn(&self) -> i32;
}

struct ConcreteA;
impl Abstract for ConcreteA {
  fn other_fn(&self) -> i32 {
    -5
  }
}

struct ConcreteB;
impl Abstract for ConcreteB {
  fn other_fn(&self) -> i32 {
    self.0 - 5
  }
}

impl<T> T where T: Abstract {
  fn generic_fn(&self) -> i32 {
    match self {
      ConcreteA => 0,
      ConcreteB(x) => *x,
    }
  }
}

This gives us the freedom to use either enum definition syntax, trait definition syntax, or a mix of the two in order to define our implementations.


Here's another interesting potential use. Observe the following code:

sealed trait AbstractA;
sealed trait AbstractB;

struct ConcreteA;
struct ConcreteB;
struct ConcreteC;
struct ConcreteD;

impl AbstractA for ConcreteA {}
impl AbstractA for ConcreteB {}
impl AbstractA for ConcreteC {}
impl AbstractB for ConcreteB {}
impl AbstractB for ConcreteC {}
impl AbstractB for ConcreteD {}

impl<T> T where T: AbstractA + AbstractB {
  fn generic_fn(&self) -> i32 {
    match self {
      ConcreteB => 5,
      ConcreteC => 10,
      // match is exhaustive because only ConcreteB and 
      // ConcreteC both impl AbstractA and AbstractB
    }
  }
}

Because we again know every concrete type that both traits can implement, when binding to multiple traits at once, we can simply take the union of their valid concrete types as the list of possible match arms.

Under even more specific circumstances, we can take this one step further:

sealed trait AbstractA;
sealed trait AbstractB;

struct ConcreteA;
struct ConcreteB(i32);
struct ConcreteC;

impl AbstractA for ConcreteA {}
impl AbstractA for ConcreteB {}
impl AbstractB for ConcreteB {}
impl AbstractB for ConcreteC {}

impl<T> T where T: AbstractA + AbstractB {
  fn generic_fn(&self) -> i32 {
    // allowed because ConcreteB is the only possible concrete 
    // implementation that satisfies the given trait bounds
    let ConcreteB(x) = self; 
    *x
  }
}

In this instance, we're restricting the trait bounds so far that the union of the two only contains one concrete type. As a result, we can match on that type in a `let` statement without an accompanying `else` block.


In theory, one could imagine replacing enums entirely with sealed traits in this way. I'm not sure if that's actually a good idea, or even practical, but it's a fun thought experiment.