Generalizing over various interoperable inputs to math operations

I'm working with a combination of newtypes and generics to abstract over a couple different but interoperable numeric types, either owned or referenced, all of which implement common math ops. Apparently this is recommended practice for APIs (future proofing), though I'm using it more for strict type assurances while remaining flexible over a sealed set of properly implemented inner types, roughly:

use core::ops::Add;

struct ArgA<A: Real>(pub A);
struct ArgB<B: Real>(pub B);
struct Gives<C: Real>(pub C);

fn do_math<A: Real, B: Real>(a: ArgA<A>, b: ArgB<B>) -> Gives<<A as Add<B>>::Output>
where
    A: Add<B>,
{
    Gives(a.0 + b.0)
}

This is quite nice for math ops that have concrete Output types, and it's easy enough to extend other ones to specify their Output:

trait PowI {
    type Output;
    fn pow_i(&self, f: i32) -> Self::Output;
}

impl PowI for &'_ f64 {
    type Output = f64;
    fn pow_i(&self, f: i32) -> Self::Output { self.powi(f) }
}

But, using this pattern with larger equations becomes quite cumbersome. While the following works (and nicely allows abstracting over floats / nalgebra / ndarray / whatnot), it is quite definitely a fustercluck of trait bounds:

fn drag_force<Rho, U: PowI, CD, A>(
    rho: Density<Rho>,
    u: Velocity<U>, 
    c_d: Coefficient<CD>, 
    a: Area<A>) -> Force<<<<<f64 as Mul<Rho>>::Output as Mul<<U as PowI>::Output>>::Output as Mul<CD>>::Output as Mul<A>>::Output>
where
    f64: Mul<Rho>,
    <f64 as Mul<Rho>>::Output: Mul<<U as PowI>::Output>,
    <<f64 as Mul<Rho>>::Output as Mul<<U as PowI>::Output>>::Output: Mul<CD>,
    <<<f64 as Mul<Rho>>::Output as Mul<<U as PowI>::Output>>::Output as Mul<CD>>::Output: Mul<A>,
{
    Force(0.5 * rho.0 * u.0.pow_i(2) * c_d.0 * a.0)
}

[playground]

Frankly, I'm almost willing to use this as-is. It's either easy enough for small equations or the compiler gives the fully qualified types and trait requirements when things start getting too ridiculous. But, I feel like there should be a better way.

Math operations are commutative but the compiler desugars the function calls in the order given. So, while a * b * c should be free to work as either a * (b * c) or (a * b) * c with both giving (approximately) the same output, there is no freedom to compose arguments in arbitrary order. It also doesn't allow the compiler to optimize their composition, but I'm pretty certain that wouldn't matter much.

But that consideration brings me to a second form; using a typed builder pattern so that every operation can be fully separated to only require the generic bounds pertinent to a single op. While it's a tiny bit more readable, it is even more ridiculous to type everything out: [playground]

And whenever things become cumbersome and repetitive I think macros, which brings me to a third alternative:

macro_rules! drag_force {
    ($rho:ident, $u:ident, $c_d:ident, $a:ident) => {{
        let (Density(rho), Velocity(u), Coefficient(c_d), Area(a)) = ($rho, $u, $c_d, $a);
        Force(0.5 * rho * u.pow_i(2) * c_d * a)
    }}
}

It ends up relatively clean but loses some of the benefits of function typing, both for selectable floating point constants as well as having a named output type.

Are there any other options? I think where this all comes to is if it would be possible to make a closed set of types (which are all interoperable with consistent output types) implement a common trait Real, such that you could minimize the bounds:

fn some_math<A: Real, B: Real>(a: ArgA<A>, b: ArgB<B>) -> Gives<impl Real> {
    Gives(0.5 * (a.0 + b.0))
}

Any thoughts would be appreciated. I'm sure there's something I'm missing; Rust usually surprises me with it's elegance when finding the "right" way to do something.

The problem is that expressing units as types does not (ergonomically) work (in a safe way), because you'd need to write your own type checker (unification) for these units.

A (unfinished) example I've found here: GitHub - Yoric/yaiouom: Prototype extension of the Rust type system towards checking units-of-measure. He does the checking in an external linter instead of adding that to the compiler:
https://github.com/Yoric/yaiouom/blob/master/crates/driver/src/main.rs
https://github.com/Yoric/yaiouom/blob/master/crates/driver/src/dimanalysis.rs

Also check out https://crates.io/crates/uom

Interesting. The Op<A, B> type in yaiouom as a means to simplify composing outputs was another one of my considerations, though I never got it fully worked out to blanket impl far enough to simplify signatures to my liking.

Good idea on the UOM crates. I should have realized they'd have the same challenges. Though I'm preferring handling units in my own interface, so I can enforce that a distance squared is just that unless explicitly changed to call it an area.

So I'm looking at the (maybe slightly simpler) challenge of just getting free interoperability of unknown arguments of floats and/or ndarray::ArrayView1 values. Basically, if arguments A, B, C, and D are all floats (or references to), it will return float. If any one of them are an ArrayView1 (or Array1) it will return a new owned Array1.

I suppose I could find a way to manually implement across a list of all viable type combinations by naming it as a trait with a specified output...

trait Real: PowI + CommonRealOps {}

trait DragFunction<Rho: Real, U: Real, CD: Real, A: Real> {
    type Output: Real;
    
    fn drag_function(rho: Density<Rho>, u: Speed<U>, c_d: Coefficient<CD>, a: Area<A>) -> Force<Self::Output>;
}

I realized I've been conflating a few separate requirements, and that left me lacking for a central type which implemented closed ops. I think I just need to name an output type for which there is a single central intermediate type, which the various input types can reach via deref/as_ref/into or similar, and which also implement both closed math ops and ops with and as a target of the intermediate type as well. Then just wrap the math expression into a final into to catch the rare all float access, and hope it will all "just work".

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.