How do I create an enum that subsumes others

I want to create a rust UXF library. (UXF is a plain text format a bit reminiscent of JSON.)

The format has 11 types that I'd like to represent as a single Value enum. These types are in 3 categories: "scalar" (single-valued values that can't be map keys); "key" (single-valued values that can be map keys); "collection" (multi-valued values that can contain values of any Value type including nested collections).

Here's my initial (non-compiling; non-working) code:

use std::collections::HashMap;
use chrono::prelude::*;

pub enum Value<I, F> {
    // ???
}

pub enum Scalar<F> {
    Null,
    Bool(bool),
    DateTime(NaiveDateTime),
    Real(F),
}

pub enum Key<I> {
    Bytes(Vec<u8>),
    Date(NaiveDate),
    Int(I),
    Str(String),
}

pub enum Collection<I, F> {
    List(List<I, F>),
    Map(Map<I, F>),
    Table(Table<I, F>),
}

pub struct List<I, F> {
    vtype: String,
    data: Vec<Value<I, F>>,
}

pub struct Map<I, F> {
    ktype: String,
    vtype: String,
    data: HashMap<Key<I>, Value<I, F>>,
}

pub struct Table<I, F> {
    ttype: String,
    data: Vec<Value<I, F>>,
}
  1. I want to restrict the generic I to be i32 or i64 only; and the generic F to be f32 and f64 only.
  2. Ideally I'd like to set defaults for I (i32) and F (f64) so that users don't have to specify them if they don't want to.
  3. The Key enum is needed for the Map type, and the Value for all the collection types. (I will add methods later once I get something working.)
  4. How do I implement the Value enum to subsume Scalar, Key, and Collection so that in-effect you could treat Value as if it were defined as:
pub enum Value<I, F> {
    Null,
    Bool(bool),
    DateTime(NaiveDateTime),
    Real(F),
    Bytes(Vec<u8>),
    Date(NaiveDate),
    Int(I),
    Str(String),
    List(List<I, F>),
    Map(Map<I, F>),
    Table(Table<I, F>),
}

Do you really need to have *32 types here? Are there any cases where this additional complexity is profitable over just using i64 and f64?

1 Like

For restricting, you need to make a marker trait: trait Int. Then do an impl Int for i32 {} and impl Int for i64 {}. Finally, you can use this trait bound to restrict. Similar for f32 and f64.

Thanks for your replies. I decided to hard-code the int and float sizes, so here's my revised code (which now compiles):

pub enum Value {
    /*
    Null,
    Bool(bool),
    DateTime(NaiveDateTime),
    Real(f64),
    Bytes(Vec<u8>),
    Date(NaiveDate),
    Int(i64),
    Str(String),
    List(List),
    Map(Map),
    Table(Table),
    */
}

pub enum Scalar {
    Null,
    Bool(bool),
    DateTime(NaiveDateTime),
    Real(f64),
}

pub enum Key {
    Bytes(Vec<u8>),
    Date(NaiveDate),
    Int(i64),
    Str(String),
}

pub enum Collection {
    List(List),
    Map(Map),
    Table(Table),
}

pub struct List {
    vtype: String,
    data: Vec<Value>,
}

pub struct Map {
    ktype: String,
    vtype: String,
    data: HashMap<Key, Value>,
}

pub struct Table {
    ttype: String,
    data: Vec<Value>,
}

This question now remains:
Given that the Key enum is needed for the Map type, and the Value for all the collection types, how do I implement the Value enum to subsume Scalar, Key, and Collection so that in-effect you could treat Value as if it were defined as if uncommented above.

I would use sealed traits for this, where the I trait is only implemented for i32 and i64, and the F trait is only implemented for f32 and f64.

You can use Default Type Parameters for this. It would look something like enum Key<I=i32> { ... }.

What's stopping you from just defining it as such? You can always write an impl<I, F> From<Key<I>> for Value<I, F> to make the conversion from Key to Value trivial.

--

As an aside, do you really need to have so many generics here?

It feels like you would lose a lot in terms of ergonomics (now every single function operating on a Value needs to be generic over I and F) and it could easily lead to awkward situations where one function returns a Value<i64, f32> and another expects a Value<f64, i32>. As always, simpler is better than clever, especially for something as fundamental to your users as a Value type.

Unless you've got a good reason otherwise, I would keep the types as dumb as possible. If that means using an i64 when you technically only need i32 that's fine, because you don't actually save space by storing the smaller integer size[1].


  1. One of Value's other variants is a Vec, so it's automatically 3 pointers = 24 bytes wide, probably 32 bytes when you take the enum descriminator and padding into account. ↩ī¸Ž

2 Likes

What does the term, "subsume", mean to you in this context? If you want to be able to trivially convert between them, you can just write a From impl and use key.into().

1 Like

I took your advice and dropped all the generics.

I'm trying to understand your bit that I've quoted. I hope this is what you mean:

pub enum Value {
    Null,
    Bool(bool),
    DateTime(NaiveDateTime),
    Real(f64),
    Bytes(Vec<u8>),
    Date(NaiveDate),
    Int(i64),
    Str(String),
    List(List),
    Map(Map),
    Table(Table),
}

impl From<Scalar> for Value {
    fn from(scalar: Scalar) -> Self {
        match scalar {
            Scalar::Null => Value::Null,
            Scalar::Bool(b) => Value::Bool(b),
            Scalar::DateTime(dt) => Value::DateTime(dt),
            Scalar::Real(r) => Value::Real(r),
        }
    }
}

Naturally, I'll do the same for Key and Collection.

Thank you:-)

Yep, that's what I had in mind.

You can also define some sort of fallible coversion in the opposite direction.

impl Value {
  fn as_scalar(self) -> Option<Scalar> {
    match self {
      Value::Null => Some(Scalar::Null),
      Value::Bool(b) => Some(Scalar::Bool(b)),
      Value::DateTime(d) => Some(Scalar::DateTime(d)),
      Value::Real(r) => Some(Scalar::Real(r)),
      _ => None,
    }
  }
}

(this could also be implemented as an impl TryFrom<Value> for Scalar which returns the original Value when the conversion fails)

The derive_more crate also defines a bunch of macros which you can use for shorthands. For example, adding a #[derive(derive_more::From)] attribute to the top of your Value enum will define conversions from bools, floats, tables, and so on into a Value. The #[derive(derive_more::TryInto)] attribute will define a fallible conversion similar to the one I wrote above, except for converting to the type of a variant (e.g. so you can do let s: String = Value::String(..).try_into()?).

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.