ENH: Add `exp10` and `expf(base: x)` f64/f32 methods to stdlib, to symmetrize API

This is a proposal to add the inverses of log(self, base: f64) -> f64 and log10(self) -> f64 to the standard lib, namely:

  • expf(self, base: f64) -> f64
  • exp10(self) -> f64

I was struck to find that there was no exp10 function in the stdlib. After all, the pairs exp/ln and exp2 log2 exists, and so does log10 - so by symmetry so should exp10. Is there an explanation for this API asymmetry? I couldn't find any.

See comparison of currently required syntax (unless I'm missing something) and the possible syntax under this proposal

let x = 3.0_f64;

// All the following expressions should be ~3.0
// Because we are chaining an operation and its inverse
let y = x.log10().exp10(); // proposal
let z = 10_f64.powf(x.log10()); // current syntax required

let a = x.log(2.3).expf(2.3); // proposal
let b = 2.3_f64.powf(x.log(2.3)); // current syntax required

let c = x.log2().exp2(); // this is already supported
let d = x.ln().exp(); // also already supported
}

I feel that from symmetry this proposal is a no-brainer. It's more concise, obvious, clean to read, gets rid of the _f64 explicit type annotation that's unnecessary in symmetric mode.

For every float method there should be the inverse available, using predictable syntax. Right now that requirement is not satisfied as far as I'm aware.

Note: When I first wrote the code, I didn't include the _f64 in 10_f64.powf(x.log10()); causing compiler error. This proposal would eliminate this type of bug. One more reason.

Unfortunately, because exp() is already defined and Rust doesn't support overloading, the inverse of log(base: f64) can't be exp(base: f64), hence my idea to name it expf - but other names would be just as good. Maybe exp_base or something would be ok as well.

The lack of exp10 seems like an oversight. That would be nice to have.

I am not so convinced by a variable-base exponential function. While I agree that needing the _f64 in 2.3_f64.powf(x) is a pain (and just 2.3.powf(x) looks a bit weird too with the two periods), adding a new function that is just the same with argument order swapped feels like it could just add more confusion. Consider that you can also write f64::powf(2.3, x).

For comparison, the exponential functions included on the list of recommended operations in the IEEE754-2008 (or 2019) standards are B^x and B^x - 1 for each B in [e, 2, 10]: exp, expm1, exp2, exp2m1, exp10, exp10m1, and the listed logarithms are their inverses: log, logp1, log2, log2p1, log10, log10p1. Out of those, Rust is missing exp10 and the +/-1 variants for bases 2 and 10.

Notably there is no variable-base logarithm there at all, and the natural logarithm is just called "log" (in Rust it is f64::ln, and f64::log takes any base).

3 Likes

Thanks, that's insightful @quaternic

I think one advantage of expf you miss is that you can't chain methods. Writing f64::powf(2.3, x) doesn't solve that issue.

This is an actual code example I had to write:

10.0_f64.powf(node.node_attrs.placement_prior.clone().map_or(-10.0, |attr| attr.value))
10.0_f64.powf(
  node
    .node_attrs
    .placement_prior
    .clone()
    .map_or(-10.0, |attr| attr.value)
) // multi-line

This would be more logical as:

node.node_attrs.placement_prior.clone().map_or(-10.0, |attr| attr.value).expf(10.0)
node
  .node_attrs
  .placement_prior
  .clone()
  .map_or(-10.0, |attr| attr.value)
  .expf(10.0) //multi-line

This would get even worse if one wanted to do things after the doing the exponential. Then a neat .chain would arbitrarily, for no reasons other than .expf not being available end up as two chains

One case for not adding inverse of arbitrary exp is that it's probably much more rarely used than the bases 2, e, 10.

As an aside, the reason for the ±1 variants (which Rust should add) is the loss of precision inherent in IEEE 754 when you do (B^x) - 1 if x is close to 0. For example, if x equals 1.0e-40_f64, then to the limits of f64, exp(x) - 1.0_f64 is 0.0, while expm1(x) is 1e-40. Similar applies in the other direction - log1p(1.0e-40_f64) is 1e-40, while log(1.0_f64 + 1.0e-40_f64) is 0.0.

Most of the time, this doesn't matter - but when it does matter, it really matters.

2 Likes

Note that C99 also has log10 but not exp10: Common mathematical functions - cppreference.com

And .Net has Math.Log10(Double) Method (System) | Microsoft Learn, but neither Exp2 nor Exp10.

So this is at least not a Rust-specific quirk.

One possible reason is that C99 doesn't have an arbitrary-base logarithm function, and I would bet log(x)/log(2) is going to round incorrectly in common cases. Whereas pow exists, so pow(2, x) and pow(10, x) work fine (as 2 and 10 are exact), so the only necessary special case is exp(x) since pow(e, x) is inaccurate due to e not being representable. (And exp2 is just because the floating-point numbers use power-of-two scale, so it can be done more efficiently than general pow.)

3 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.