Pre-RFC: #[derive] support for arithmetic traits (Add, Sub, Mul, Div) on structs


Summary

Add support for deriving Add, Sub, Mul and Div on structs whose fields implement them.

EDIT: Here we are talking about Rhs=Self implementation.

Rationale

In Rust, implementing arithmetic traits like Add, Sub, Mul, or Div requires either manually writing implementation or dependency on third-party crates like derive_more even for trivial cases where the semantics are unambiguous (e.g. field-wise operations on numeric structs).

#[derive(Add)]
struct Point(u32, u32); // a + b == Point(a.0 + b.0, a.1 + b.1)

This is pointless busywork when the only sensible behavior is to perform the operation field by field. It would work the same way Clone, Debug and Copy is resolved.

Generics?

For generic structs like:

#[derive(Add)]
struct Point<T> {
    x: T,
    y: T,
}

the derived implementation automatically requires T: Add (exactly like #[derive(Clone)] requires T: Clone ).

What about enums?

I noticed that enums rarely need arithmetic and when they do, manual impls are clearer so this proposal intentionally excludes support for deriving arithmetic traits on enums. While third-party crates like derive_more offer a solution for enums - wrap the output in Result<Self, Error>.

impl Add for FooBar {  
    type Output = Result<Self, Error>; 
    fn add(self, rhs: Self) -> Self::Output {  
        match (self, rhs) {  
            (Foo(a), Foo(b)) => Ok(Foo(a + b)),  
            (Bar(a), Bar(b)) => Ok(Bar(a + b)),  
            _ => Err(Error::Mismatch),
        }  
    }  
}  

Drawbacks

Adding a solution to the standard library that is necessarily more limited will create a small split in the ecosystem: "Should I use the standard derive or the more powerful derive_more ?" Nevertheless, I decided to propose it, especially since the #[derive(From)] RFC has recently reached implementation so ¯\_(ツ)_/¯.


Unresolved Questions

Overflow behavior

Should derived implementations panic on overflow? The std's arithmetic traits provide no overflow control

My main concern is whether unconstrained field-wise projection of these operators is really all that common. Most times that I have a wrapper struct that I want to implement arithmetic operators for, it's because the struct represents some kind of mathematical object that has its own rules for those operators, e.g. complex numbers, quaternions, matrices, or homogeneous vectors.

Why would you ever want to add two TcpPorts to each other? Adding an integer makes sense if you want to scan for an open port to use, or even a range iterator, but I just don't see the value in this particular example.

8 Likes

Not that you’d ever expect derive to do this, but my point is that “addition” can mean a bunch of things that are not “add the components”, even in the realm of math.

I’m much more positive about wrappers for single values, but even there we run into trouble. A Duration plus a Duration is another Duration, but the operation you want for Instant isn’t Instant + Instant = Instant; it’s Instant + Duration = Instant. Your TcpPort example and 2e71828’s response are probably part of this category too.

Still, a derive doesn’t have to handle every solution to be useful, since you can always fall back to a manual implementation. So… :person_shrugging:

2 Likes

Most times that I have a wrapper struct that I want to implement arithmetic operators for, it's because the struct represents some kind of mathematical object that has its own rules for those operators, e.g. complex numbers, quaternions, matrices, or homogeneous vectors.

Fair point - for cases like yours this proposal is largely irrelevant. I often work with domain-specific quantities representing something where field-wise arithmetic is the "natural" behavior and that made me wonder why the std doesn't provide derive for arithmetic traits.

Why would you ever want to add two TcpPorts to each other?

Was playing around in rust playground and didn't feel like using Foo/Bar/Baz for it's name. I'll change it to not raise confusion

If you have newtype wrapper structs for units (kg, m, etc) I can see this being useful. But for any type with more than one member there are a lot of diffrent ways to implement these operations.

  • Is it a 4 element vector, or a 2x2 matrix?
  • is it a 2D vector? Cool, polar or cartesian coordinates? Are they in the same coordinate system even?
  • Or a complex number (on what form of coordinates again)? Or something entirely non-geometric?
2 Likes

Other commenters have hinted at the existence of something called affine geometry and affine spaces. Where vector spaces are made of vectors (duh), affine spaces are made of points and translation vectors, or just translations, between points.

Addition and scalar multiplication make sense for vectors, but for points they are meaningless in general; instead the two primitive operations are point–translation addition, yielding a point, and point–point subtraction, yielding a translation.

Surprisingly many real-world spaces are in fact affine, not vector spaces – including the physical Euclidean space itself! Instants and durations are a textbook example, as are the Celsius and Fahrenheit temperature scales. Another example familiar to programmers is pointers and ptrdiff_t aka isize. Geographical coordinates are yet another (dim>1) example; it makes no sense to add together the coordinates of two cities, but the difference of two coordinates is entirely meaningful.

(I’m finishing a MSc thesis about this and some related topics :smiley: As it happens, it also involves Rust and its type system.)

4 Likes

For a while now I've been pondering encoding this in compile-time-homogeneous coordinates.

If you have something like

type Point<T> = Homogeneous<T, 1>;
type Vector<T> = Homogeneous<T, 0>;

Then you could write things like

impl<T, C> Neg for Homogeneous<T, C> { 
    type Output = Homogeneous<T, -C>;
    ....
}
impl<T, C1, C2> Add<Homogeneous<T, C2>> for Homogeneous<T, C1> {
    type Output = Homogeneous<T, {C1 + C2}>;
}
impl<T, C1, C2> Sub<Homogeneous<T, C2>> for Homogeneous<T, C1> {
    type Output = Homogeneous<T, {C1 - C2}>;
}

and all of a sudden not only does p1 - p2 work, but -p2 + p1 does too!

Add in some div-by-constant things and you can even make let p = (p1 + p2 + p3).div_const<3>() work.

4 Likes

This may be better solved by delegation syntax or generalised macros for constructing derives.

The problem with such basic arithmetic derive is that it has to assume a certain relationship between the fields and operations. It means it can only generate that one type (like a point or a vector), so you're not really gaining ability to derive arithmetic easily, you only gain ability to make struct Point with different names.

How do you derive for matrices? Quaternions? Complex numbers? Angle + magnitude vectors? Points on non-Euclidean surfaces? Temperature in units other than Kelvin? Gamma-compressed colors? Types that contain a mix of these?

If you really need points in type-safe flavors, maybe it would be better to have delegation that would allow you to extend/wrap an existing UVec2 instead.

3 Likes

Even in this example, Point, field-wise addition makes little sense as others have mentioned.

For Vector field-wise addition would make sense, but multiplication wouldn't normally be field-wise. The dot product would make more sense as the multiplication operator for Vector.

2 Likes

I see it this way: Per-field operations don't always make sense, similar to adding Eq or Ord doesn't always make sense, that's part of why they are not there by default. Deriving Debug doesn't always produce useful results (e.g. on a struct containing a large Vec<u8>). But they are all useful in most cases and their limitations are documented.

If addition does not make sense in your context you don't have to derive Add. If the fieldwise implementation doesn't make sense for your Vector you don't derive Mul. You derive those for which the default implementation makes sense, otherwise you implement it manually.

There certainly is the argument that #[derive(Mul)] struct Vector(u32, u32) can be misleading, but so would a wrongly implemented manual implementation. The main difference is the amount of (boilerplate) code you have to write.

I'm therefore not sure if it should be done for multi-field structs or how useful it would actually be for them. But I think the existence of them in derive_more shows that they are useful and the most sane default implementation is fieldwise operation.

How used is that particular part of derive_more though? Maybe that is not widely used and other derives from that crate is what most dependencies use.

A survey of that from dependants on crates.io would be useful input to this discussion. I.e, for the dependants: which derives do they actually make use of.

4 Likes

I noticed that this part of derive_more isnt used all that often (it even might be there simply for the sake of completeness), though I actually run into it pretty frequently when working with newtypes or complex objects in gamedev - like for colors or physics forces. One pattern I noticed in my code is that I usually derive arithmetic traits when I dont need any validation and also most of the time, the fields already come with their own Add implementation, which makes sense - for example, with Radians and Degrees, or clamped number types.

In the end I think that if this gets added to std, it won't see much use outside of this pretty specific niche.

Wouldn't you want the arithmetic operators to perform a modulo 2pi for radians and modulo 360 for degrees?

That depends a lot on what this is being used for— You might not want a commanded 360 degree turn to be automatically canceled out to nothing, for example.

Fair enough, but I think this again shows that even for seemingly simple newtypes it is not obvious what the derives for arithmetic operators should do. There isn't a single right answer, and derives in Rust are hard to make flexible in a way that they are useful to a large group of people in such a situation.

Contrast that with deriving Clone or Debug: very rarely do you actually need anything but the standard derive. Same goes for PartialEq, PartialOrd etc: most structs don't need to customise here (but it is slightly more common to do so than for Clone or Debug).

2 Likes

If you think about it, this applies to any kind of coordinates, because the purpose of coordinates is to designate a place in a given space, and adding two places is not semantically meaningful; adding two offsets or a place and an offset, however, is, so adding two Point's is also meaningless, but OP uses exactly that as an example :wink:

P.S. I would welcome a newtype derive in the std library, as I tend to reimplement this stuff in the rare cases that need it, instead of using the newtype crate.

1 Like

You can add two points…on an elliptic curve. But that needs a specific curve as context and, even with some "standard" curve, feels like way too much to hide behind a simple + symbol.

In one of my (unpublished) crates I have a macro that hides a lot of the boilerplate of the various traits that error objects need to implement:

/// Represents failure to interpret a string as a Checksum value.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ParseChecksumError;

error_impl! {
    ParseChecksumError:
    Display (self, f) {
        f.write_str("invalid checksum value")
    };
    Error () {};
    From (_discard: blake3::HexError) { Self };
    From (_discard: std::array::TryFromSliceError) { Self };
}

I feel like this is a better approach to making it less tedious to impl traits that need some programmer-supplied code, than trying to shoehorn 'em into #[derive] somehow. I wonder if we could come up with a generalization of this macro that would be suitable as official syntactic sugar.

Hypothetically, for any trait whose impl blocks must define exactly one method, we could allow something like

impl Display (self, f) for ParseChecksumError {
    f.write_str("invalid checksum value")
}

instead of

impl Display for ParseChecksumError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        f.write_str("invalid checksum value")
    }
}

Now you may be thinking "meh, that's not that much boilerplate removal," but consider: it removes a level of indentation, it frees you from having to remember the name and type signature of the required method (I always have to look that up), and if you have N impls in a row (e.g. of From<X>), it's substantially less repetitive and therefore also less visually cluttered.

Unresolved problems include:

  • Can we generalize this to cover "one required method and one required type" trait impls? That would make it applicable to a bunch more stdlib traits that people often need to impl, such as FromStr and Iterator.
  • How much about the type signature of the one required method should be elided? I went for an extreme above, but I could be persuaded we shouldn't leave quite that much out.

This isn't really a problem, rust analyser has code actions to create either all or only the required funtions for any trait.

1 Like

[All-purpose reminder that not everyone uses rust-analyzer, nor necessarily can use rust-analyzer.]

[The last time I tried to use it it cut my laptop's battery runtime in half.]