Hello everyone.
I am practicing implementing operator overloading.
As naive exercise I have implented Add for 2 Vectors so that line: let v3 = v1 + v2;
would add each corresponding element of vectors.
I used Newtype pattern to overload Add for Vector type.
However, I need to check for both vectors to have identical length before I add them.
This is not possible to check via Generic bounds, at compile time, due to vector dynamic size run-time nature (am I wrong? can vector length/size be checked another way?).
So I made Add to return a Result<> where Err is returned if vector lengths are not identical.
For all other things I used Rust Generic bounds to handle.
Is this approach of returing Result<> from overloaded operator considered idiomatic Rust?
Is there a better, more effective Rust approach?
thank you.
Sampe code below:
#[derive(Debug)]
struct MyVec<T: Add<Output = T> + Copy>(Vec<T>); //Newtype pattern
use std::fmt; // for Error type
impl<T: Add<Output = T> + Copy + Default> Add<MyVec<T>> for MyVec<T>
where
T: Add<Output = T>,
{
type Output = Result<MyVec<T>, MyErr>; // Error if vector lengths are un-equal
fn add(self, rhs: Self) -> Self::Output {
let mut v = Vec::<T>::with_capacity(self.0.len());
if self.0.len() != rhs.0.len() {
return Err(MyErr {
msg: "two vectors to be added must be of Equal size".to_string(),
});
}
for i in 0..self.0.len() {
v.push(self.0[i] + rhs.0[i]);
}
Ok(MyVec(v))
}
}
#[derive(Debug)]
struct MyErr {
msg: String,
}
impl fmt::Display for MyErr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "({})", self.msg)
}
}
impl std::error::Error for MyErr {}
let v = MyVec(vec1) + MyVec(vec2);
println!("{:?}", v.unwrap().0);
// let us Add mv1 and mv2 to produce mv vector.
let mv1 = vec![1i32; 100_000_000];
let mv2 = vec![1i32; 100_000_000];
let mv = MyVec(mv1) + MyVec(mv2);
All Rust code criticism is most welcome, I am learning the Rust craft.
I do realise there may well be an existing crate which does vector addition much better (please let me know if it exists so I can read the code and learn).
If Rust’s standard library implementations for number types (which panic, or sometimes even silently wrap (in release mode) on things like overflow, division by zero, etc…), or libraries like ndarray are an indicator of idiomatic Rust, then it seems more common to have such operators panic with mismatching sizes. I suppose the reasoning is that
it’s easy to avoid panics by adding a check for same-size before the operation
it’s fairly common that you already know that arrays are of the same size, so with Result, use-cases would commonly become verbose with a lot of .unwrap()s
if you want to also offer Result-returning versions, the operators can be panicking, allowing the less verbose case to look even nicer, whilst the Result-returning (or perhaps Option-returning) version of the operations can still be offered via a method; e.g. compare to methods like i32::checked_add
For example in a foo + bar + baz situation, handling Result would either look like ((foo + bar)? + baz)? or ((foo + bar).unwrap() + baz).unwrap(). Arguably, a method might be even nicer here, requiring fewer parentheses, as in foo.try_add(bar)?.try_add(baz)? or foo.try_add(bar).unwrap().try_add(baz).unwrap(); though the latter could then, if a panicking operator is provided, too, written as foo + bar + baz. (Precise name to be determined, I don’t know whether “try_add” is the best name and I’m using it here just as an example.)
There is also an argument to be had about the ownership structure of the arguments here. Arguably, requiring owned arguments for both self and rhs: Self requires more ownership than necessary. Perhaps the user wanted to re-use the arguments afterwards. &self and rhs: &Self can be annoying, too, as chaining that looks like &(&foo + &bar) + &baz. The approach of self is owned and rhs is borrowed can be nice, and also allows for memory to be re-used (if you change the implementation accordingly), and chianing is nicer as e.g. foo + &bar + &baz or foo.clone() + &bar + &baz if foo shall be available afterwards. The String type for example offers an Add operation only of this type (with &str taking the role of &String), but on the other hand, e.g. num::bigint::BigInt takes the approach of simply offering all 4 versions of Self + Self, &Self + Self, Self + &Self and &Self + &Self.
By the way, in any case, together with an Add implementation if can often also be reasonable to also offer an AddAssign implementation. Looking at BigInt again that could be two implementations, one AssAssign<Self> and one AddAssign<&Self> one, the latter more faithfully mirroring the truly required ownership situation.
It would probably make sense to first check the lenghts and then create the vector. The reason beeing that Vec::with_capacity() already allocates memory which is unnecessary in the case where you return an error.