Enum vs. Generic with Trait pros/cons

I've got a few different elements I'm trying to put into a struct, each of which has a few options for their implementation, but all their implementations should function similarly. This has lead me to two paths to consider... First, the obvious:

pub enum Coefficient<T: Float> {
    fixed(T),
    interpolated(Spline<T, T>),
    conditional(HashMap<&'static char, T>),
}

Something like that using a single get value function. There's obviously a bit more involved as the get_value(&self) implementation doesn't provide the argument for interpolating or selection from the HashMap. But I can put a reference in the interpolated and conditional coefficients back to where it can obtain the needed info at will.

But since it then has a single access point with a fixed signature, I could do something like this instead and implement it for each struct and avoid the enum.

trait CoeffTrait<T: Float> {
    fn get_value(&self) -> T;
}
impl<T: Float> CoeffTrait<T> for FixedCoefficient<T> {} 
// and the others...

That then gives the two options for where these are stored:

pub struct OptionA<T: Float> {
   coefficients: Vec<Coefficient<T>>,
}

pub struct OptionB<T: Float, C: CoeffTrait<T>> {
   coefficients: Vec<C>,
}

However, if I go the implementation method to avoid enums for too many things, I end up at a top level struct with tons generics (on top of a couple lifetimes):

pub struct Env<'env, 'run, T: Float, C: CoeffTrait, Solver: SolveMethods, Run: Runtime> {
    objects: Vec<Object<'env, Solver>>,
}

I was initially wary of an enum with most commonly a fixed f64 value sharing other variants that were not sized. But also I really only want to have to specify T when allocating an Env. Also, I'm starting to think that the cascade of the traits back to the top-level means that I'll need to lock in a coefficient type at environment creation, and also that different objects couldn't have different solve methods or coefficient types (unless I'm missing something). I may have just set myself back to enums in this process. But, I'd certainly appreciate any thoughts on the various tradeoffs of how to accomplish something like this.

1 Like

I suppose an alternate option avoiding both at the struct level might be:

pub stuct OptionC<T: Float> {
    fixed_coefficients: Vec<FixedCoefficient<T>>,
    interpolated_coefficients: Vec<InterpolatedCoefficient<T>>,
    conditional_coefficients: Vec<ConditionalCoefficient<T>>,
}

Then would something like this be viable?

impl<T: Float, C: CoeffTrait<T>, I: Iterator> IntoIterator for OptionC<T> {
    Type = <I::Item as C>;
    IntoIter = I;
    fn into_iter(&self) -> I {
        self.coefficients.into_iter()
            .chain(self.interpolated_coefficients.into_iter())
            .chain(self.conditional_coefficients.into_iter())
    }
}

I think the main benefit of using a trait is that you can write useful code without knowing in advance all the types that will be plugged into it. In particular, this is a nice mechanism for reusable libraries to let users take advantage of their work in novel ways without a lot of marshalling back and forth.

On the other hand, if you want to abstract over a fixed set of types, all of which are known to you, I'd guess that an enum will be easier to work with (as well as expressing your intention more faithfully).

If you have a compelling reason to use a trait, but don't want to have to fix the "flavor" of coefficient at the type level and propagate that generic parameter everywhere, you could always use dyn CoeffTrait. This comes at some cost in performance, which may or may not be significant in your case.

I don't quite understand what you're describing here -- does it have something to do with keying the HashMap on &'static char instead of plain char?

2 Likes

I'd say my concern with the enum option is the memory and performance implications. An 'enum' is as large as its largest variant (plus discriminator). If my most common use case is a vector of f64 values, what hit am I taking by having them wrapped in an enum shared with less regularly used vectors and hashmaps? My OptionC saves this by relegating those to what could remain as empty vectors, though I need to do a lot more learning on iterators to see if that's viable. I've also considered forms of the interpolated and conditional coefficients based on fixed size arrays [f64; 8] or [f64; 4] as a means of bounding their memory use.

The other one I'm looking at is an enum for timestep formulations for solving, which could be first, second, or fourth order. It seems silly to build around an enum for solve/timestep data that's 4x larger than the common first-order method. Everything's memory footprint would look like a fourth-order solver, even if computationally it was only performing one solve per step. For that I do think that a Trait might be the more elegant solution, if I can figure out how to make objects each have their own solver that the environment remains independent of. Perhaps I just need to code up a bit more and see where it gets me.

And sorry for any confusion on the explanation. &static char was just my way of enforcing that only a few characters were options for the hashmap, and would be known at compile. I was trying to say that the variants presented couldn't truly share a common access method. As written for my example it would have to be set up like:

impl<T: Float> FixedCoefficient<T> {
    pub fn get_value(&self) -> T {
        *self.value
    }
}
impl<T: Float> InterpolatedCoefficient<T> {
    pub fn get_value(&self, x: T) -> T {
          self.spline.get_clamped(x)
    }
}
impl<T: Float> ConditionalCoefficient<T> {
    pub fn get_value(&self, cond: char) -> T {
        self.condition_values.get(cond)
    }
}

But I could put a reference and method in each that could be used to get the x or cond for finding the value; thus allowing all access methods to share a common get_value(&self) form. Which was what led me to the resulting question of if a Trait could simplify and reduce memory footprint.

It seems to me the reason you're having trouble making a call is because you haven't figured out your requirements.

trait CoeffTrait<T: Float> {
    fn get_value(&self) -> T;
}
pub struct OptionB<T: Float, C: CoeffTrait<T>> {
    coefficients: Vec<C>,
    _marker: std::marker::PhantomData<fn() -> T>,
}

In the above version all the coefficients are always the same type, and that type is known at compile time.

pub enum Coefficient<T: Float> {
    Fixed(T),
    Interpolated(Spline<T, T>),
    Conditional(HashMap<&'static char, T>),
}
pub struct OptionA<T: Float> {
   coefficients: Vec<Coefficient<T>>,
}

In this version each coefficient may be of a different kind, so you can mix fixed, interpolated and conditional coefficients freely at runtime.

pub struct OptionC<T: Float> {
    fixed_coefficients: Vec<FixedCoefficient<T>>,
    interpolated_coefficients: Vec<InterpolatedCoefficient<T>>,
    conditional_coefficients: Vec<ConditionalCoefficient<T>>,
}

Okay, in this version you can combine coefficient types at runtime, but only in a fixed order: all the fixed coefficients, then all the interpolated ones, then all the conditional ones.

So... what is it? All of these options look plausible to me, but they solve different problems, so which one you choose will depend on your requirements. (There are also other possibilities we could come up with if none of them are exactly right.)

2 Likes

You can check the size of your struct or enum with std::mem::size_of, e.g.

pub enum Coeff {
    Fixed(f64),
    Interpolated(Vec<f64>),
    Conditional(HashMap<&'static char, f64>),
}

fn main() {
    println!("{}", std::mem::size_of::<Coeff>());
    // prints '56'
}

(playground)

An important thing to realize here is that the data in a Vec or HashMap are not kept on the stack, so you don't pay for that storage whenever you use an enum with one of those as a variant. Vec<T> only keeps 3 usize (start, length, capacity) on the stack, for example. You can experiment with commenting out and adjusting some of the variants and seeing how that affects the memory footprint.

Coefficient order doesn't matter, so that's easy. And I'd like to be able to mix in all three variants, at will, without having to change allocation of the holder of the coefficients. But, at absolute worst I'd accept being able to make an environment that holds objects, and each object fixed to one coefficient type, but different objects able to have a different coefficient types within the same environment (so, like Option B, I suppose? But with the coefficient type decoupled from the environment).

Also, the original enum option perfectly met my requirements, but I just wondered if there was a smarter way.

Your use of &'static char is clever! It sounds like you might be able to achieve the same goal by defining (what else?) an enum:

enum AllowedChar {
    A,
    B,
    C,
    ...
}

You'd probably want a method on AllowedChar to translate back into char, and if you have a lot of allowed characters you might have to hack up a macro to generate the definitions. But if the current strategy is working for you then I wouldn't worry too much about changing it.

1 Like

That's the trick I was going to pull to make the ConditionalCoefficient based on an [f64; 4]. I'd actually be happy to have objects in the environment be able to give that enum at will, so could avoid characters entirely.

My issue on the interpolated ones is that I worry about limiting the length of the points of the interpolation curve, plus it requires x and y values. But maybe I could make a fixed x value array to cut that down to [f64; 4] and avoid duplicates.

But still that leaves me with objects 4x larger than my base case. But perhaps the stack/heap split is less of a problem than I was anticipating, since they'll be in a Vec anyway.

I read back through this example from the Rust book:

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

But the functioning of that seems dependent on the Box. Which makes me think that would be part of making my very uninvestigated OptionC work, so that might end up being more of a performance hit than the "problem" I'm trying to solve.

Type parameter defaults can help here:

pub trait CoeffTrait<T: Float> {
    fn get_value(&self) -> T;
}

// By default, store an enum.  Anything else needs to be explicitly named.
pub struct OptionB2<Output: Float, C: CoeffTrait<Output> = Coefficient<Output>> {
   coefficients: Vec<C>,
   phantom: std::marker::PhantomData<fn()->Output>
}

impl<T:Float> CoeffTrait<T> for Coefficient<T> {
    fn get_value(&self)->T { unimplemented!() }
}

impl<T:Float> CoeffTrait<T> for Spline<T,T>{
    fn get_value(&self)->T { unimplemented!() }
}

impl<T:Float> CoeffTrait<T> for Box<dyn CoeffTrait<T>> {
    fn get_value(&self)->T { (*self).get_value() }
}

Sounds like Generics with a Trait is a very inelegant "solution" for something that's just a small piece built around obtaining a single f64 value. Not actually managing to simplify one thing and making everything else around it more complicated in the process.

But, on the solver side, I'd be pretty happy to use Box<dyn Solver> to decouple solver selection from the types of the struct. Then I presume I could just unbox the solver for use at runtime, and it'd be the run/solve function that would know the struct it's getting that implements Solver?

The most important question is, are you working on a lib or bin project? If it's latter, just use whatever gets you to your goal the fastest. You can always rewrite/optimize later. If it's a lib project, it gets more complicated.

1 Like

I'm going for a lib. A stretch goal will be to drop only the absolute essential values into a runtime specific struct as a fixed array of these coefficients with a size based on typenum, or using arrayvec, or something along those lines. Which is kind of why I'm probing options for minimization. But also I'm trying to avoid premature optimization, which is definitely slowing down my progress.

On the OptionC side, I've realized I only need to request a single iterable of f64 values, so the IntoIter implementation doesn't technically need specify the trait of its various sources as long as OptionC knows how to make a single Vec<f64> output from each that it could chain.

This is a cluster of chains and maps, but...

impl<T: Float> IntoIter for Coefficients<T> 
{
    type Item = T; // f64 or f32
    type IntoIter = std::vec::IntoIter<Self::Item>;
    
    fn into_iter(self) -> Self::IntoIter {
        self.fixed_coefficients.into_iter()
                .map(|coeff| -> T { coeff.get_value() })
            .chain(self.interpolated_coefficients.into_iter()
                .map(|coeff| -> T { coeff.get_value() }))
            .chain(self.conditional_coefficients.into_iter()
                .map(|coeff| -> T { coeff.get_value() }))
    }
}

Would that work correctly to avoid/hide the trait and only expose the chain of float outputs?

"unbox" isn't the word I'd use -- dyn Solver always needs to be behind a reference of some kind -- but the Deref and DerefMut traits let you call methods of Solver on something of type Box<dyn Solver>, so you can usually pretend the Box isn't there.

Anyway, yeah, this sounds like a good use-case for trait objects. Just make sure Solver is object-safe.