Raising compile-time errors in order to constraint possible permutations of a struct in Rust

95 Views Asked by At

Imagine a struct...

struct Pair {
    letter: Letter,
    number: Number,
}

enum Letter {A, B, C}
enum Number {One, Two, Three}

The total number of combinations in this struct would be 3 x 3 = 9, however, I want to make only some of them possible. Say, I allow all combinations to exist, but {C, Three} cannot exist, for whatever reason.

How can the type system in Rust help me achieve this such that if I have a constructor for Pair...

impl Pair {
    fn new(letter : Letter, number : Number) -> Pair {
        ...
    }
}

and then later in my main function I do... Pair::new(Letter::C, Number::Three); the compiler raises a compile-time error instead of panicking during runtime?

2

There are 2 best solutions below

0
Chayim Friedman On BEST ANSWER

One possibility is to use const eval to make sure invalid combinations don't compile:

impl Pair {
    const fn new(letter: Letter, number: Number) -> Pair {
        assert!(!matches!((letter, number), (Letter::C, Number::Three)));

        ...
    }
}

But marking the function as const does not guarantee it will be evaluated at compile time, only makes that possible. To guarantee that, you can use a macro:

macro_rules! new_Pair {
    ($letter:expr, $number:expr $(,)?) => {{
        const V: Pair = Pair::new($letter, $number);
        V
    }};
}
0
Peter Hall On

The type system deals with types, not values[1]. In order to add a restriction, you need to build it into the types themselves.

For example, this setup encodes the restriction that you mention in your question:

enum Pair {
   A(Number),
   B(Number),
   C(NumberForC),
}
enum Number {
    One,
    Two,
    Three,
}
enum NumberForC {
    One,
    Two,
}

Each variant of Pair is a constructor, but you can't implement a new function with the signature you describe.

Another formulation would be to add types for each data variant and use traits to encode the constraints. You can use these as arguments for your Pair constructor:

// Using Pair, Letter and Number as provided in the question
impl<N, L> Pair<N, L>
where
    L: Assoc<N> + Into<Letter>,
    N: Into<Number>,
{
    fn new(letter: L, number: N) -> Self {
        Pair {
            letter: letter.into(),
            number: number.into(),
        }
    }
}

// Use macros to generate the remainder:

struct A;
struct B;
struct C;

struct One;
struct Two;
struct Three;

trait Assoc<N> {}
impl Assoc<One> for A {}
impl Assoc<Two> for A {}
impl Assoc<Three> for A {}
impl Assoc<One> for B {}
impl Assoc<Two> for B {}
impl Assoc<Three> for B {}
impl Assoc<One> for C {}
impl Assoc<Two> for C {}

impl From<One> for Number {
    fn from(_: One) -> Self {
        Self::One
    }
}
impl From<Two> for Number {
    fn from(_: Two) -> Self {
        Self::Two
    }
}
impl From<Three> for Number {
    fn from(_: Three) -> Self {
        Self::Three
    }
}
impl From<A> for Letter {
    fn from(_: A) -> Self {
        Self::A
    }
}
impl From<B> for Letter {
    fn from(_: B) -> Self {
        Self::B
    }
}
impl From<C> for Letter {
    fn from(_: C) -> Self {
        Self::C
    }
}

This explodes fast in the number of types and trait implementations you'll need to write, but you could potentially use macros to keep it under control.


[1] Actually it can use some integer types as a constraint, using const generics, but that is a bit limited and won't help you here.