Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extension of num::Zero ? #440

Open
jonaspleyer opened this issue Nov 9, 2024 · 8 comments
Open

Extension of num::Zero ? #440

jonaspleyer opened this issue Nov 9, 2024 · 8 comments

Comments

@jonaspleyer
Copy link

Setting

I am designing a crate which performs numerical calculations but abstracts over the underlying types in question such that it can be used with static, dynamic, Vectors, Matrices, etc. A typical function in this context may look like this:

fn calculate<X>(increments: impl IntoIterator<Item=X>) -> X
where
    X: num::Zero + core::ops::AddAssign<X>,
{
    increments
        .into_iter()
        .fold(X::zero(), |acc, x| acc+x)
}

It is obvious for me to test very frequently with types from the popular nalgebra crate.However the situation becomes problematic when dealing with dynamically sized objects which do no implement the num::Zero trait. These objects do implement the general From trait but this trait carries no information about whether the object is zero or not. They also implement Default but this initializes a 0x0-dimensional matrix with no entries.

Question

So my question is: Is it reasonable and possible to generalize the existing num::Zero trait to also include constructor methods based on external input?
Nalgebra already provides the ::zeros(nrows, ncols) methods to construct empty matrices but they do not fall under any trait. In the future, we will probably see many more crates for abstractions of vectors, tensors (such as ndarray) etc. (possibly on the GPU as well).

Example

An initial attempt from me looked like this:

trait ZeroFrom<S> {
    fn zero_from(s: &S) -> Self;
}

impl ZeroFrom<[usize; 2]> for nalgebra::DMatrix<f64> {
    fn zero_from(shape: &[usize; 2]) -> Self {
        nalgebra::DMatrix::zeros(shape[0], shape[1])
    }
}

See this playground example.

@cuviper
Copy link
Member

cuviper commented Nov 16, 2024

I think it's reasonable, but it should be clear that this can't help any code that only uses T: Zero right now. I do worry that the S will make it difficult to actually write generic code for this though, because that becomes another parameter that you'll have to plumb through -- both the type parameter and the argument to fill it, unless you only handle a particular type.

Outside vector-like things, another example I thought of was a dynamic modular-arithmetic type, where the modulus is another field that should be provided on construction. Although depending on how that works with mixed moduli, such a type might be happy enough with 0 mod 1 to implement the normal Zero.

@jonaspleyer
Copy link
Author

I think it's reasonable, but it should be clear that this can't help any code that only uses T: Zero right now.

Yes that is the case the ZeroFrom<S> trait is technically more general than the num::Zero trait. One could even go as for as to say that num::Zero is just ZeroFrom<()>

impl<T> num::Zero for T
where
    T: ZeroFrom<()>,
{
    fn zero() -> Self {
        <Self as ZeroFrom<()>>::zero_from(&())
    }
}

I do worry that the S will make it difficult to actually write generic code for this though, because that becomes another parameter that you'll have to plumb through -- both the type parameter and the argument to fill it, unless you only handle a particular type.

But for libraries such as nalgebra it should be no problem. It already uses these types anyways. And other libraries will face the same problem. Users outside of these libraries are not required to use these generics at all. In this sense, this addition would be fully backwards-compatible since it only adds a new feature.

@cuviper
Copy link
Member

cuviper commented Nov 17, 2024

For the sake of compatibility, I would write that blanket impl the other way:

impl<T: Zero> ZeroFrom<()> for T {
    fn zero_from(_: &()) -> Self {
        T::zero()
    }
}

I think it would make sense for crates like nalgebra to define their own local trait for this first, and if the design proves useful and there's a desire for interoperability, then we can think about "promoting" it to num-traits.

@jonaspleyer
Copy link
Author

jonaspleyer commented Nov 17, 2024

For the sake of compatibility, I would write that blanket impl the other way:

When we actually implement it, we might do it like this. I just wanted to emphasize that in principle Zero is a special case of ZeroFrom. But this might make a difference for the future (see below).

I think it would make sense for crates like nalgebra to define their own local trait for this first, and if the design proves useful and there's a desire for interoperability, then we can think about "promoting" it to num-traits.

I did this for my own crate and implemented the new ZeroFrom trait for other external types. When implementing ZeroFrom<()> for types which also implement num::Zero we assume that the 0 value is generated by the specific type (). But we could also take the stance that the input type does not matter at all so every type could be inserted.

impl<T, S> ZeroFrom<S> for T
where
    T: num::Zero
{
    fn zero_from(_: &S) -> Self {
        Self::zero()
    }
}

Now we can not manually implement ZeroFrom<usize> for Vec<f32> for example. See this playground.

But it could be useful to want this type of blanket implementation instead of ZeroFrom<()>. Think about a simple function:

fn some_fun<F, X, Y, S>(a: Option<F>, x: X, y: Y, s: &S) -> X
where
    X: core::ops::Mul<F, Output=X>,
    X: ZeroFrom<S>,
    X: core::ops::Add<Y, Output=X>,
    Y: ZeroFrom<S>,
{
    if let Some(a) = a {
        x * a + y
    } else {
        X::zero_from(s) + y
    }
}

Now let's say that the type X is statically allocated and thus implements num::Zero and Y is dynamically allocated and thus implements only ZeroFrom<S> for some specific S!=(). Now we will not be able to use those types together even if they fulfill every other trait bound. But if we had implemented ZeroFrom<S> for T we could actually use them together. But as I explained before this is only possible if the ZeroFrom<S> trait is part of num. Otherwise we run into the mentioned error.

@cuviper
Copy link
Member

cuviper commented Nov 17, 2024

Now let's say that the type X is statically allocated and thus implements num::Zero and Y is dynamically allocated and thus implements only ZeroFrom<S> for some specific S!=().

I think it would make more sense for X to also implement ZeroFrom<S> for a particular S as if it were dynamic, and probably even assert that the runtime lengths in that argument do correspond to its static lengths.

@jonaspleyer
Copy link
Author

jonaspleyer commented Nov 17, 2024

Now let's say that the type X is statically allocated and thus implements num::Zero and Y is dynamically allocated and thus implements only ZeroFrom<S> for some specific S!=().

I think it would make more sense for X to also implement ZeroFrom<S> for a particular S as if it were dynamic, and probably even assert that the runtime lengths in that argument do correspond to its static lengths.

So if nalgebra::DMatrix implements ZeroFrom<[usize; 2]> you want nalgebra::Matrix2x2 to also implement ZeroFrom<[usize; 2]> and then check inside the zero_from(s: &[usize; 2]) function that the array is actually assert_eq!(s, &[2, 2]) ?
This seems too convoluted to me and I do not see the benefit of this approach. This would also require for nalgebra::Matrix2x2 to possibly implement ZeroFrom<_> for multiple types such as [usize; 2], (usize, usize), nalgebra::Vector2<usize> and this burden would be up to the library author.

Maybe my concern is also unfounded regarding the blanket impl and it could be enough to do impl<T> ZeroFrom<()> for T where T: num::Zero ...

@cuviper
Copy link
Member

cuviper commented Nov 17, 2024

If you're going to add extra S implementations at all, I think checking the length is more idiomatic of Rust, whereas accepting anything for S would be out of character.

@jonaspleyer
Copy link
Author

If you're going to add extra S implementations at all, I think checking the length is more idiomatic of Rust, whereas accepting anything for S would be out of character.

I actually think that the most idiomatic way to do it would be

impl<T: num::Zero> ZeroFrom<()> for T {
    fn zero_from(_: &()) -> Self { Self::zero() }
}

and thus treat Zero as a special case of ZeroFrom.
This will not require any runtime overhead and it is clear that the resulting instance of T is created "from nothing".
But the question about compatibility is another one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants