rust-lang / libs-team

The home of the library team
Apache License 2.0
132 stars 19 forks source link

Add associated consts to f32, f64 for mathematical constants #210

Open pommicket opened 1 year ago

pommicket commented 1 year ago

Proposal

Problem statement

Currently, to use mathematical constants such as π, you need to use std::f32::consts::PI. This proposal is for adding associated constants to the f32 and f64 types so that the constants could be accessed with just f32::PI for example.

Motivation, use-cases

  1. std::f32::consts::PI is quite long. It takes a while to type out, and makes code difficult to read if written out in full everywhere.
    Consider
    assert_eq!(std::f32::consts::FRAC_PI_4.sin(), std::f32::consts::FRAC_1_SQRT_2);

    vs

    assert_eq!(f32::FRAC_PI_4.sin(), f32::FRAC_1_SQRT_2);

    You can always do something like use std::f32::consts as f32c; but it would be nice for a shorter path to be included in the standard library.

  2. I think this is where first-time users of Rust would expect mathematical constants to be. I know that when I first wanted to use π in a rust program, I wrote out f32::PI and to my disappointment it didn't work and I had to do an internet search to find out the correct path.
  3. NAN, INFINITY, etc. are associated constants, and it feels inconsistent to me for f32::NAN to exist, but not f32::PI.

Solution sketches

I have implemented this on my fork: https://github.com/rust-lang/rust/compare/master...pommicket:rust:shorter-paths-for-float-consts

Links and related work

What happens now?

This issue is part of the libs-api team API change proposal process. Once this issue is filed the libs-api team will review open proposals in its weekly meeting. You should receive feedback within a week or two.

scottmcm commented 1 year ago

https://github.com/rust-lang/rfcs/pull/2700 chose not to do this for the mathematical constants.

So I think this ACP should address what's changed since then that means it was a bad idea then but a good idea now. And if moving MIN like that was an RFC, maybe this should be too.

One thing that comes to mind is that use std::f32::consts::*; seems more plausible than use f32::*;, should that latter formulation ever be allowed.


I agree with @joshtriplett that it would be nice to be able to use associated constants. Maybe a lang change to enable that should happen before this?

pitaj commented 1 year ago

Relevant tracking issue: https://github.com/rust-lang/rust/issues/68490

I also suggested this there, but I think it's relevant to put here too.

You can kind of emulate a module in this case by using inherent_associated_types: https://play.rust-lang.org/?version=nightly&mode=debug&edition=2021&gist=1498d15e059ff14dd69ae46c5a10ee94

Also possible with just an inherent constant, but doesn't result in purely path syntax: https://play.rust-lang.org/?version=nightly&mode=debug&edition=2021&gist=5b9d620c72fa0a10c66cb750a2515dc5

pommicket commented 1 year ago

https://github.com/rust-lang/rfcs/pull/2700 chose not to do this for the mathematical constants.

Thanks I didn't know that.

Seems like it was decided that it could be left to a later RFC. I'd be happy to create it, if people want (although maybe that should be left to someone who has more experience creating RFCs), or wait until (if ever) it's possible to use f32::PI;.

Personally, I would prefer to have the constants available even without the ability to use associated constants. I still think that the advantage of being able to type out f32::PI rather than std::f32::consts::PI is worth the extra difficulty of having to do const PI: f32 = f32::PI; instead of use std::f32::consts::PI; in the rare cases where you really need a short name for pi — but we wouldn't necessarily have to deprecate std::f32::consts, at least until associated types can be used.

As for use std::f32::consts::*; I'm not sure if that's ever a good idea, but okay that's a reasonable objection.

quaternic commented 1 year ago

First off, I do agree withs the points that having to write std::f32::consts::PI is a pain, and f32::PI just working would be a very reasonable expectation.

However, the mathematical constants are fundamentally different from the current associated constants, which are all either special values of that type like NAN or INFINITY, or otherwise describe properties of the type like MANTISSA_DIGITS or MAX_EXP.

The constants in std::{f32,f64}::consts are all approximations of the mathematical expressions they represent. Best approximations for their types, but rounded values nonetheless. For example, the value of std::f32::consts::PI is in no way special in the f32 type: std::f32::consts::PI.sin() does not return 0, because f32::sin() is periodic in , not in 2.0*std::f32::consts::PI. Similarly, std::f32::consts::E.ln() returns 0.99999994_f32, which is the correctly rounded result: Round(ln(Round[e, 2^-22]),2^-24).

IMO, ideally we could do something like this:

let pi = std::math::PI as f32;

That is, the named mathematical constants would have one definition each, independently of the numeric types, with operations to obtain the actual primitive values in whichever type you want. This would naturally allow getting upper and lower bounds for those approximations as well:

use std::math::PI;
let pi_lb = PI.lower_bound().into();
for x in data {
    // |x| < π
    if x.abs() <= pi_lb {
        ...
    }
}

The above would be equally correct for both f32 and f64, which is difficult to do with just the current definitions.

A couple of other things to consider:

pommicket commented 1 year ago

IMO, ideally we could do something like this:

What is PI here exactly? A generic constant (this exists in C++, but not rust yet...)? A constant of some special new type FloatingConstant which impls Into<f32>,Into<f64> (i hope it would also impl Add<f32>, etc)?

Anyways this definitely seems interesting but I'm not sure if it would happen in the forseeable future. And for now what might one day happen to PI is not really relevant to whether std::f32::consts::PI or f32::PI is better (except that having to switch paths twice is tedious)

The ability to get the lower and upper bound seems cool, but I don't know of any language that has that in its standard library... perhaps it is best left for a crate.

The above would be equally correct for both f32 and f64, which is difficult to do with just the current definitions.

Yeah, that's neat. Although it's worth mentioning the same could be said of NAN, INFINITY (although you can always do 1.0 / 0.0 or 0.0 / 0.0 which is more difficult to do (in a readable and definitely-precise way) for some mathematical constants).

At least with this proposal it would be possible to do:

type MySpecialType = f32; // might change to f64!
// ...
let pi = MySpecialType::PI;

which is not currently possible (admittedly you can do std::f64::consts::PI as MySpecialType but that's less readable...)

And if typeof is ever added it will be possible to do

for x in data {
    if x.abs() <= (typeof x)::PI {
        // ...
    }
}

:)

This would add many more items under f32 which is already crowded.

Yeah, that's a good point. I will add it to the "Drawbacks" section of the RFC.

Many of these constants are redundant

Well, it is true that FRAC_PI_3 != PI / 3.0 for f64, and maybe it might seem weird to have FRAC_PI_3 but not FRAC_PI_2 even though the latter could be perfectly accurately written as PI / 2.0 (tbh I mostly agree with you here but these constants already exist oh well)

pitaj commented 1 year ago

I've got another option for you. Put these constants on a trait:

pub trait MathConsts {
    const PI: Self;
    // etc
}

Only when the trait is in scope, you can access all the constants directly on f32 or f64:

use std::math::MathConsts;
dbg!(f64::PI);

This approach doesn't clutter editor suggestions or the docs page.

And you can even use type inference when you need to:

for x in data {
    if x.abs() <= MathConsts::PI {
        // ...
    }
}

https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=81cfb8e84724a88bf56b561516db9e11

pommicket commented 1 year ago

This would be especially nice since then you would be able to use PI in a generic context.

Unfortunately it would mean that adding a new constant would be a breaking change for any types which impl MathConsts

For what it's worth this trait already exists but with fns instead of consts in the num crate: https://docs.rs/num/0.4.0/num/traits/trait.FloatConst.html :) — and for now maybe that's better given how limited const evaluation currently is....

I will add this to the "alternatives" section of the RFC though!

pitaj commented 1 year ago

Unfortunately it would mean that adding a new constant would be a breaking change for any types which impl MathConsts

I think it would be best to seal this trait so it's only implementable by std types anyways.

Another option is to just create a supertrait for each addition and change the name resolution on an edition boundary.

scottmcm commented 1 year ago

IMO, ideally we could do something like this:

let pi = std::math::PI as f32;

One way to do that would be to add

// in std::num::consts
pub struct Pi;

And add impl From<Pi> for f32 and impl From<Pi> for f64, which would also let others implement things like impl From<Pi> for MyFancyBigFloat.

That way you'd have f32::from(Pi), or with Mul overrides one could also have x * Pi.

I played around with doing this for Zero and One in https://lib.rs/crates/zero-one, as a potential alternative to https://docs.rs/num-traits/latest/num_traits/identities/trait.Zero.html-style approaches. But it could absolutely work for things like π too.

(Dunno if it's something people would want, but I think it does at least mean a simpler approach than adding new traits for every constant of interest.)

hkBst commented 1 year ago

One way to do that would be to add

// in std::num::consts
pub struct Pi;

And add impl From<Pi> for f32 and impl From<Pi> for f64, which would also let others implement things like impl From<Pi> for MyFancyBigFloat.

This does seem to match mathematical reality most closely in that we cannot express real numbers exactly, so the idea of converting with From has considerable appeal.

If we have a symbolic struct Pi, then we could also implement for it functions with nice outcomes, like sin and cos. Which makes me wonder how we can get sin(2*pi) etc to work as well.

pitaj commented 1 year ago

Which makes me wonder how we can get sin(2*pi) etc to work as well.

I think it would be best to just leave this up to a Rational type.

let n = Rational::new(2, 1) * Rational::from(Pi);
dbg!(n.sin()); // 0/1
quaternic commented 1 year ago

And if typeof is ever added it will be possible to do

for x in data {
    if x.abs() <= (typeof x)::PI {
        // ...
    }
}

And you can even use type inference when you need to:

for x in data {
    if x.abs() <= MathConsts::PI {
        // ...
    }
}

These are actually a good example of why I mentioned lower bounds; your examples would not be strictly correct for both f32 and f64. I really did mean that it's difficult with just the current definitions: With the rounded values of the constants, it just so happens that f64::PI < π < f32::PI (but you couldn't know this just from reading the documentation), so if the code is intended to separate out precisely the values greater than π, it needs to be either x < f32::PI or x <= f64::PI. Related issue: https://github.com/rust-lang/rust/issues/108769#issuecomment-1465069896

If we have a symbolic struct Pi, then we could also implement for it functions with nice outcomes, like sin and cos. Which makes me wonder how we can get sin(2*pi) etc to work as well.

The IEEE-754 standard recommends the functions sinPi, cosPi, tanPi, asinPi, acosPi, atanPi, and atan2Pi, which all measure angles in multiples of π. E.g. sinPi(x) = sin(π*x), asinPi(x) = asin(x)/π. Implementing those for f32 and f64 might be a reasonable thing to do.

A more ambitious (experiment-in-a-crate-first) direction might be something like a wrapper type for multiples of π:

```rust // PiMul(x) represents the value π * x struct PiMul(T); // just for the examples below type M = PiMul; ``` With signatures for the arithmetic operators: ``` // no-op wrap/unwrap: Pi * f32 -> M f32 * Pi -> M M / Pi -> f32 // the rest operate on the contained f32s M * f32 -> M f32 * M -> M M / f32 -> M M / M -> f32 M + M -> M M - M -> M ``` And as the main course, trigonometry functions that unwrap/wrap accordingly: ``` // method calling sinPi on the contained f32 M::sin(M) -> f32 // associated function calling atanPi to return the angle as a multiple of π radians M::atan(f32) -> M ``` These would then compile to just calls to sinPi and atanPi with no value of π involved: ```rust (x * Pi).sin() PiMul::atan(x) / Pi ``` Admittedly the latter doesn't look quite right.

Anyway, @scottmcm's suggestion of having a ZST-type Pi: Into<f32> + Into<f64> is pretty much what I was tending towards, although I would be wary of defining the arithmetic operators by casting to the other operand's type since Rust doesn't generally do implicit conversions.

pommicket commented 1 year ago

This is neat, but all general-purpose programming languages that I know of just have floating-point constants for π, etc., and they seem to get along fine without all this "machinery". In other languages people use math.pi/Math.PI/whatever all the time — just having PI constants for f32 and f64 is good enough for 95% of cases (the remaining 5% can imo be left to crates...)

Using floating-point numbers in general will invariably lead to some amount of mathematical incorrectness unless you're extremely careful. No, that example code isn't correct, but it's correct down to 1 ULP which is good enough for almost everyone (if those values came from a previous calculation with floats, they will be (potentially) off by at least 1 ULP anyways).

quaternic commented 1 year ago

This is neat, but all general-purpose programming languages that I know of just have floating-point constants for π, etc., and they seem to get along fine without all this "machinery". In other languages people use math.pi/Math.PI/whatever all the time — just having PI constants for f32 and f64 is good enough for 95% of cases (the remaining 5% can imo be left to crates...)

Well, I wasn't sure either, but I tried with Julia and found out the standard library can do this:

julia> π
π = 3.1415926535897...

julia> π == pi
true

julia> typeof(π)
Irrational{:π}

julia> sin(π)
1.2246467991473532e-16

julia> sinpi(1)
0.0

julia> sin(BigFloat(π))
1.096917440979352076742130626395698021050758236508687951179005716992142688513354e-77

julia> sin(BigFloat(π, precision=20000))
4.502850813633212220480382554741730704191676738588386332317097735049223941670455e-6021

julia> lb = Float32(π, RoundDown)
3.1415925f0

julia> ub = Float32(π, RoundUp)
3.1415927f0

julia> lb < π < ub
true

julia> nextfloat(lb) == ub
true

Now, dynamic typing does afford a lot of internal complexity without getting in the way of ergonomics, and Rust would have a hard time reaching the same.

Using floating-point numbers in general will invariably lead to some amount of mathematical incorrectness unless you're extremely careful. No, that example code isn't correct, but it's correct down to 1 ULP which is good enough for almost everyone (if those values came from a previous calculation with floats, they will be (potentially) off by at least 1 ULP anyways).

Sure, some amount of error is to be expected in many cases, but regardless, the value you have is the value you're using, and you expect the range check to check that value. As in the issue I linked, if the reason for testing the magnitude (supposing the threshold was actually π/2) was that you next pass the angle to tan, that 1 ULP will become the difference between something very positive and something very negative.