I'm doing some exercises to learn the mindset required to code in rust, And I had a few problems understanding the limits up to which we can push generics, and where to prefer trait objects or enums instead.
As @Phlopsi said here, there are of course trade-offs; I don't care much about binary size and privacy; so performance and flexibility are what we need to optimize here, but we're gonna have to pay for it with increased complexity.
I've been working on a small symbolic math library as an exercise to learn rust better, and I feel like I've reached some dead ends with generics.
I'm pretty sure there's something wrong with my mindset here, cuz it feels like I'm still bound to the Object Oriented mindset and handling abstract interfaces like I do in C#; but the problem is that generics seems to spread like a virus in my codebase.
Consider the following :
pub trait VectorSpaceElement: Sized + PartialEq + Copy + Neg<Output = Self>
+ Add<Self, Output = Self> + Sub<Self, Output = Self>
+ Mul<Self, Output = Self> + Div<Self, Output = Self>
+ AddAssign<Self> + SubAssign<Self> + MulAssign<Self>
+ DivAssign<Self> { }
impl VectorSpaceElement for i8 {}
impl VectorSpaceElement for i16 {}
impl VectorSpaceElement for i32 {}
impl VectorSpaceElement for i64 {}
impl VectorSpaceElement for f32 {}
impl VectorSpaceElement for f64 {}
impl VectorSpaceElement for i128 {}
impl VectorSpaceElement for isize {}
#[derive(Clone, PartialEq, Debug)]
pub struct Symbol {
name: String,
}
impl Symbol {
pub fn new(name: &str) -> Self {
return Self {
name: name.to_string(),
};
}
}
impl Display for Symbol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return f.write_str(&self.name);
}
}
pub type SymbolMap<TResult> = Box<dyn Fn(&Symbol) -> Option<TResult>>;
pub trait Expression {
type SimplifyOutput: Expression;
type EvaluateOutput: Expression;
fn simplify(&self) -> Self::SimplifyOutput;
fn evaluate<TResult: VectorSpaceElement>(
&self,
map: SymbolMap<TResult>,
) -> Self::EvaluateOutput;
}
impl<Element: VectorSpaceElement> Expression for Element {
type SimplifyOutput = Element;
type EvaluateOutput = Element;
fn simplify(&self) -> Self::SimplifyOutput {
self.clone()
}
fn evaluate<TResult: VectorSpaceElement>(&self, _: SymbolMap<TResult>) -> Self::EvaluateOutput {
self.clone()
}
}
#[derive(Clone, PartialEq, Debug)]
pub enum SymbolOrValue<Value: VectorSpaceElement> {
Symbol(Symbol),
Value(Value),
}
impl<Value: VectorSpaceElement> Expression for SymbolOrValue<Value> {
type SimplifyOutput = Self;
type EvaluateOutput = Self;
fn simplify(&self) -> Self::SimplifyOutput {
return self.clone();
}
fn evaluate<TResult: VectorSpaceElement>(
&self,
map: SymbolMap<TResult>,
) -> Self::EvaluateOutput {
return self.clone();
}
}
impl Expression for Symbol {
type SimplifyOutput = Self;
// This is where I'm beginning to get into trouble
type EvaluateOutput = SymbolOrValue<VectorSpaceElement>;
fn simplify(&self) -> Self::SimplifyOutput {
return self.clone();
}
fn evaluate<TResult: VectorSpaceElement>(
&self,
map: SymbolMap<TResult>,
) -> Self::EvaluateOutput {
if let Some(value) = map(self) {
return SymbolOrValue::Value(value);
}
return SymbolOrValue::Symbol(self.clone());
}
}
The line below my comment is a compile-time error, because VectorSpaceElement is a trait and its size is not known during compile-time.
There are multiple workarounds using trait objects (I can of course put it in a Box), but if we want to stay on the generic path, then we're gonna have to make Symbol (and all its implementations, and everywhere it is used) generic too. Something like this :
pub struct Symbol<Value: VectorSpaceElement> {
name: String,
_phantom: PhantomData<Value>,
}
pub enum SymbolOrValue<Value: VectorSpaceElement> {
Symbol(Symbol<Value>),
Value(Value),
}
impl<Value: VectorSpaceElement> Expression for Symbol<Value> {
type SimplifyOutput = Self;
type EvaluateOutput = SymbolOrValue<Value>;
fn simplify(&self) -> Self::SimplifyOutput {
return self.clone();
}
fn evaluate<TResult: VectorSpaceElement>(
&self,
map: SymbolMap<TResult>,
) -> Self::EvaluateOutput {
// And of course now we're gonna getting errors here
// so we have to change the signature of 'evaluate' to use another associated type
if let Some(value) = map(self) {
return SymbolOrValue::Value(value);
}
return SymbolOrValue::Symbol(self.clone());
}
}
Next stop, some operation traits (like BinaryOperation) and structs (like BinaryAddition) will be added, And they need to be implemented for each of the 4 combinations of Symbol and VectorSpaceElement, They sure can easily be implemented as generics too, but the fact that Symbol itself is generic is just going to add a whole lot of other generic parameters to those operations; and the story continues.
So my problem is, I don't exactly know where to draw the line ?
Where should I just say 'That's enough generics for now' and use a Box ?