Contraints on const fns

Hi,

I'd like to have a LOG2PI constant. Here's my attempt, with the compiler error:

error[E0015]: cannot call non-const fn `std::f64::<impl f64>::ln` in constants
 --> src/main.rs:8:32
  |
8 | const LOG2PI: f64 = (2.0 * PI).ln();
  |                                ^^^^
  |
  = note: calls in constants are limited to constant functions, tuple structs and tuple variants

Having a look at the implementation of ln, it looks like the issue might be that it's not declared as a const fn:

pub fn ln(self) -> f64 {
    crate::sys::log_wrapper(self, |n| unsafe { intrinsics::logf64(n) })
}

This leads me to a few questions:

  1. What's the best way to work around this? I've also tried making this a let or static, neither of which work. I guess I could do fn log2pi() -> f64 {(2.0 * PI).ln()}, but that seems silly.
  2. Is this something that needs to be fixed about ln, or is there a good reason for it not to be a const fn?
  3. What's the right way to think about const fns, anyway? Given Rust's safety guarantees, it seems like the only way for a function to be "non-const" would be to take a mutable reference, either as an argument or as a closure. I'd think Rust's borrow checker would make it easy for the compiler to reason about these, no there'd be no need for a programmer to think about const fn at all. What am I missing?

Thanks!

What's the best way to work around this?

Declare a constant with the decimal value, and write a test for it:

use std::f64::consts::PI;

pub const LOG2PI: f64 = 1.8378770664093453;
 
#[test]
fn log2pi() {
    assert_eq!(LOG2PI, (2.0f64 * PI).ln());
}

Is this something that needs to be fixed about ln, or is there a good reason for it not to be a const fn?

Compile-time floating point operations are tricky to define in a fashion that is sound and consistent with run-time evaluation, so the compiler doesn't support them yet.

What am I missing?

The set of stuff that can go in a const fn is not "only pure functions"; it's "only code that we are confident the compiler's compile-time interpreter can reliably execute deterministically". So, the constraint is not based on principle but on implementation capability and caution.

If you watch Rust release notes you'll see plenty of “we made this function const” over time.

6 Likes

If I were in your position, I'd either try to alter the ln() to be const and submit a PR, or, if that wasn't possible, manually implement ln() as a const fn in my own crate. Assuming it's implemented as some sort of table, this should be doable with only language features that support const.

I can't speak to the reason that the fn isn't const ATM; it could be some technical detail in tgr current impl that prevents the fn from being const, or it might be that the author doesn't wish to expose const-ness in the API, or it might simply be that the code was written when some features being used in the impl were stable, but not usable in a const context yet. Impossible to say without both reading the code and talking to the author.

Const fns are defined as functions that can be run at compile time, specifically in a const context.
Note that constness is orthogonal to purity i.e. in principle const fns can perform e.g. I/O.

That is orthogonal to constness. In fact I'm not entirely sure that mutable borrows are available in const code, and if they are then likely only &'static mut T, and not &'a mut T for some lifetime 'a.
If you want a fn to not be const, simply don't declare it as const fn.

1 Like

Note that, as stated already, many fp ops are inconsistent across implementations. This test may fail on certain platforms but may not on others. Logarithms and trigonometries are not part of the IEEE-754 standard and it's not that rare that 2 different implementations produce values different an epsilon for exactly same operands.

2 Likes

Ok thanks, that's good for a temporary work-around, though it would be nice to be able to avoid hand-calculations in general.

Do you know what's tricky about it? It's all deterministic, and e.g. const TWOPI: f64 = 2.0 * PI; works fine.

How are these different? Is it a limitation of the reliability of the interpreter?

It's not too big a deal, I just posted because it's very simple in principle but I couldn't get it to work, so I figured I must be missing something obvious.

Oh, that's interesting. Are there also pure functions that should not be const?

Good point. So is the idea of const fn that it should be platform-independent? How does this relate to @jjpe 's point that a const fn could perform IO?

Note that you probably want that to be of actual π, not of PI. It's possible that PI.ln() would produce a different float than the actual closest possible float.

So I'd just do

const LOG2PI = 2.6514961294723187980432792951080073350184769267630415294067885154;

with clearly way more digits than are meaningful, rather than trying to calculate it from the PI constant that has a relative error already of about -4×10⁻¹⁷.

EDIT: Oops, tczajka points out below that my brain got stuck on LOG2PI and I looked up log₂(π), rather than logₑ(2π) :person_facepalming: Make that 1.8378770664093454835606594728112352797227949472755668256343030809.

2 Likes

In case hardcoding the closest float value is not feasible you can consider using the const_soft_float crate

2 Likes

Well purity as a concept doesn't really exist in the Rust language, so it's kind of hard to argue anything should be pure, other than for the usual reasons of clarity and perhaps referential transparency.

But if a pure function were to perform heap allocation in its body, it couldn't be const since AFAIK heap allocation in a const context doesn't exist in stable as of now.

Reminds me of the Action vs Calculation dichotomy, I think we can have that. Maybe not const-ly

/// Actions interact with externals, might have errors. Actions are not pure.
pub trait Action<I, O> {
/// an error type for this action
type Error: std::error::Error;
/// interact with an external
fn run(&mut self, source: &I, output: &mut O) -> Result<(), Self::Error>;
}

/// Pure calculations do not interact with externals, only have option output
pub trait Pure<X, Y> {
/// calculate a value from an input
fn calculate(input: X) -> Option;
}

I'm not sure exactly what you mean, but traits or generics probably aren't the answer. Whenever generics are involved, there's no way to exclude something with interior mutability.

1 Like

You can implement a proc macro that gives you const ln() - probably not with it, but is an option in a case like this...

1 Like

Wouldn’t interior mutability require a mut keyword? Seems like we ought to be able to specify this. Admittedly maybe Fn(&X) -> Option<Y> is a better approach for the functional aspect

2.0 * PI already is a constant: TAU. I would call that constant LN_TAU (for consistency with LN_2).

Actually they are part of the IEEE-754 standard -- they are on the list of "recommended operations". Moreover, the standard specifies that if they are provided, they should be correctly rounded! Basically nobody implements it that way. You can argue that it's not a violation because it's just "recommended operations", so the "incorrectly rounded log" is just a different operation, and the "correctly rounded log" is not provided.

The value is wrong since that's the base-2 log rather than the natural log.

This is an example where hexadecimal literals would be useful, since you could specify this precisely in hex:

// Unfortunately doesn't work.
const LN_TAU: f64 = 0x1.d67f1c864beb5;

You can do this using the hexf crate:

const LN_TAU: f64 = hexf64!("0x1.d67f1c864beb5p0");
2 Likes

No. mut is about exclusiveness, and interior mutability enables mutation without exclusiveness. It's also known as shared mutability.

Rust opted to focus on aliasing/ownership instead of mutability.

Put another way, it’s become clear to me over time that the problems with data races and memory safety arise when you have both aliasing and mutability. The functional approach to solving this problem is to remove mutability. Rust’s approach would be to remove aliasing. This gives us a story to tell and helps to set us apart.

That is some twisted rule lawyering. Love it.

3 Likes

No. They are simply different things. const means "evaluate me at compile time". This has nothing to do with mutability. It's possible to evaluate mutability at compile time.

2 Likes

Thanks all for the replies. This is still kind of confusing to me. There are some references to "const contexts", but I haven't found documentation of this. Maybe it means "after a const keyword", but then the logic seems circular, so I guess that must be wrong.

I think what I'm still missing is

  • What guarantees does const aim to satisfy?
  • What constraints does the compiler impose to do this?

I can imagine a few possibilities on the first point:

  1. const values should be guaranteed not to change during this run, or
  2. const values should be fixed for all runs on a given machine, for a given version of the compiler, or
  3. const values should depend only on hardware, not the compiler, or
  4. const values must be fixed across hardware and compiler versions, for all time.

I first assumed Rust would be aiming for (1), which should be simple. But then I don't see a reason to disallow logarithms. So maybe it's one of the others?

A lot of this is also a question of ergonomics and self-documenting code. I'd much rather write a mathematical expression and ask the compiler to make it const than manually compute a long decimal and copy/paste it in. That takes much more work, is easier to get wrong, and the result is much more cluttered. Here it's just one number, but for, say, Taylor series expansions for special functions, it could be hundreds or even thousands.

I'm also very interested in staged compilation. I'm coming from Julia, where I've found this to work really well, and I'm hoping there might be opportunities to do similar things in Rust. Obviously it can't be identical, given the static/dynamic language differences, but using e-graphs to do compile-time optimizations seems like an obvious benefit.

Anyway, you can think of compile-time constants as a very simplified version of this, and I'm a little concerned with how even this simple thing is so non-trivial. So I still think I'm missing something.

https://doc.rust-lang.org/reference/const_eval.html#const-context

1 Like

Thanks. This is helpful for the intent, but still doesn't explain why something as simple as f64::ln would be disallowed. Given the power of rustc, I'd think const could involve just two rules

  1. No I/O
  2. Any mutable references must not outlive the const context

This seems easy for the compiler to check. Is that wrong? Or does this not capture what's meant by const-ness?

It seems like much more work to hand-label each function that's allowed, and there will always be some (like f64::ln, currently) that are missed. So it's hard to make sense of the current approach.

It's just not implemented yet. See this issue.

I think the main potential problem with this is that the build platform and the target platform may be different, and it would be at least undesirable for the value of the same expression to depend on whether it's computed at compile time or not.

2 Likes