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

Why does Zero::zero not take self? #272

Open
EdmundsEcho opened this issue May 24, 2023 · 11 comments
Open

Why does Zero::zero not take self? #272

EdmundsEcho opened this issue May 24, 2023 · 11 comments

Comments

@EdmundsEcho
Copy link

I have an enum type that wraps various Number like types. I was hoping the return value of Zero::zero would depend on the variant. Because the function does not take self, there is no way to perform a match... This "dead-end" if you will is having me both post this question, and suspect I should be approaching my task differently.

impl<N: Number> Zero for AnyNumber<N> {
    fn is_zero(&self) -> bool {

        match self {
            AnyNumber::UInt8(v) => v.is_zero(),
            AnyNumber::UInt16(v) => v.is_zero(),
            AnyNumber::UInt32(v) => v.is_zero(),
            ...
            AnyNumber::Float32(v) => v.is_zero(),
            AnyNumber::Float64(v) => v.is_zero(),
        }
    }
    fn zero() -> Self {
        Zero::zero()  // <<<< recursive/fails
    }
}

Any comments/suggestions would be appreciated.

@EdmundsEcho
Copy link
Author

Given the maturity and popularity of this crate, I'm looking forward to learning how to get out of my current train of thought. That said, I'm stuck on suspecting the design of the trait made an assumption that could be flawed.

I get how zero is a way to produce a "neutral" value for the addition operation (the "identity element" in a monoid specification). The current design of Zero presumes there is only one identity element per type. However, it misses how a person might use an enum type to host multiple types, each with their own identity value.

Enums and traits are ways to unify behavior across types. Has the design over-looked how an enum might need an identity element for each variant?

@tarcieri
Copy link
Contributor

tarcieri commented May 25, 2023

For any other use case but yours, it seems weird to have to have a value of your numeric type in order to call zero() on it

@cuviper
Copy link
Member

cuviper commented May 25, 2023

It's meant to be able to manifest a zero out of thin air -- e.g., iter.fold(T::zero(), |acc, x| ...). For your enum, I suppose you'll have to pick a "default" variant. You can also override set_zero(&mut self) to maintain the same variant though, especially since you have is_zero(&self) working for any variant.

Does your AnyNumber allow operations between different variants? If so, then any zero variant should be fine; if not, then I think it probably would not make sense to implement num traits at all.

@EdmundsEcho
Copy link
Author

EdmundsEcho commented May 26, 2023

Thank you for thinking through this with me.

Does your AnyNumber allow operations between different variants?

Great question: How might an operation between variants enable the implementation? I can imagine starting with a chosen variant then cast?

I don't have an answer but in my recent experience with using num_traits, the use case is compelling. Granted, the popularity of the use case depends on how often one might use an enum to unify a group of types.

Notwithstanding, I say compelling because the Zero trait was the only trait I could not implement for the AnyNumber enum I was using (I could implement Add, Ord etc...).

I was able to find a way to implement self.get_zero() by way of a "witness". The witness is a value that can communicate the enum variant to the caller (the value does not matter). Another way to look at it is it reads any value of the type to learn how to instantiate zero. Of course, I was not able to use it where other crates called Zero as you have with fold.

So far, my codebase without using the enum is much larger.

The design constraint stems from the fact that we don't have access to the context. Maybe it makes sense to include the identity value with the traits that specify a numeric operation? Finally, this is more of an "And", an extra function with a default implementation that uses N::zero()?

@cuviper
Copy link
Member

cuviper commented May 26, 2023

(I could implement Add, Ord etc...)

If your Add runs with different operands, say UInt8(1) + Float64(2.0), does it work? If you can mix variants, then it doesn't really matter which kind of zero you pick for the additive identity.

@cuviper
Copy link
Member

cuviper commented May 26, 2023

FWIW, here's a real use of getting zero without any "reference" value:
https://docs.rs/ndarray/latest/ndarray/struct.ArrayBase.html#method.zeros

@EdmundsEcho
Copy link
Author

If your Add runs with different operands, say UInt8(1) + Float64(2.0), does it work?

I was not intending on it. But, great idea. I guess I could implement the binary operation traits such that they allow a mix of variants in a way that allows me to use the variant that hosts the return value of Zero::zero().

@cuviper
Copy link
Member

cuviper commented May 26, 2023

You could also have a special AnyNumber::Zero variant, if you don't want to mix variants otherwise -- depending on how much you're willing to deal with Zero everywhere. (And maybe similar for One?)

@EdmundsEcho
Copy link
Author

EdmundsEcho commented Jun 14, 2023

@cuviper Thank you for identifying a work-around. This experience motivated me augment my intuition for generic code in Rust (and in part by contrasting it with my experience in Haskell). In Haskell there was a bit of a debate of when it made sense to use type classes instead of "regular" types. A respected point of view suggested the over-use of type classes. In Rust, traits take on a whole other level of importance and have a lot more "natural" utility.

I'm providing this preamble to contextualize the following which might be a bit clumsy in how I articulate the point, but here goes. Just to level-set, generic code is not inherently better. In other words, it's not a given that it makes a codebase more extensible (it often can, but not always). A bias or reasonable starting point => use concrete types if it does the job. Error messages and many other benefits exist with concrete types - if it does the job.

Unifying types

If the need is to provide a means to unify a collection of types we have enums. The reason not to use an enum is if we can't know all of the possible/potentially useful types a user might want to include. Furthermore, extending an enum requires an update to the code base; something that can be avoided using traits.

Enum is a natural choice working with numbers - we know the types ahead of time (primitives don't change much)

The punchline: when working with numbers, we know ahead of time what we need to support: u8, u16, floats etc.. For me, that means I think I made the right design choice to use an enum for an app that works with numbers and matrices (matrices can be build using the enumerated primitives).

Keeping this issue open to allow for future consideration

I'm sold on the benefit of the current design; T::zero() - nice. However, it misses what I consider a fundamental property of numbers (identity elements are associated with binary operations). It's for this reason I appreciate that no one chose to close this issue once we identified a potential work-around. Having mulled over my experience of somewhat being "forced" to use traits to solve my unification challenge, I think it remains useful to consider how num-traits might, over time figure out a way to access T::zero and T::one (the complete set of useful primitive identity elements for all operations between numbers).

Fodder

Getting around the need to access a context with Self is not obvious to me. Here are some high-level ideas, each use the parameter passed to the function to enable the desired functionality.

  • specify and access the identity element with the binary operation (i.e., numbers as monoids)
  • use a "constant as function" (associated function)
    • zero :: a -> (() -> a) or
    • zero :: a -> a
  • specify an iterator that uses the first Item to access the required type information; something that can be composed with folds and the like (a "functor" capability that provides access to underlying data).

Alternatively, formalize the work-around you proposed using a "dummy type" (e.g., ZERO) where I then have the infrastructure required to specify match... (dummy, u32(v)) => v, (u32(v1), u32(v2)) = v.add(v) etc...

@tarcieri
Copy link
Contributor

@EdmundsEcho expressing what you want would be natural with variant types, since you could impl Zero::zero for each variant type which would return the appropriate enum variant.

Unfortunately, they are only a hypothetical unimplemented feature.

@EdmundsEcho
Copy link
Author

EdmundsEcho commented Jun 16, 2023

I agree. This whole thread would be moot if variants were an option.

I suspect that this feature won't be coming anytime soon. As long as the implementation requires treating variants as separate types it gets messy mostly b/c it defeats the purpose (type unification).

Users of num-traits always have access to Self, at some point - eg fold

I wonder if providing an iterator.with_identity() is better than might first appear? It would be like enumerate in that it augments Item. It addresses demand for fold, a universal constructor (anything can be build from fold) and thus likely utilized a lot.

If not doing some sort of generic construction, the user will have access to self. In my case I was instantiating nalgebra matrices where the bottom half needed to be zeros. I was building it in a context where I had access to Self. Here I would use self.xero_const() that I implemented for MyNum type.

... this option does not obviate the need for the Iterator. nalgebra would prefer using fold composed with the identity Iterator. Generally, never use One::one() when the identity Iterator is an option.

What other use cases exist?

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

3 participants