Rust: Tracking issue for RFC 2342, "Allow `if` and `match` in constants"

Created on 18 Mar 2018  ·  83Comments  ·  Source: rust-lang/rust

This is a tracking issue for the RFC "Allow if and match in constants" (rust-lang/rfcs#2342).

Please redirect constification of specific functions or issues you want to report to fresh issues and label them appropriately with F-const_if_match so that this issues doesn't get flooded with ephemeral comments obscuring important developments.

Steps:

  • [x] Implement the RFC
  • [ ] Adjust documentation (see instructions on forge)
  • [x] Stabilization PR (see instructions on forge)
  • [x] let bindings in constants that use && and || short circuiting operations. These are treated as & and | inside const and static items right now.

Unresolved questions:

None

A-const-eval A-const-fn B-RFC-approved C-tracking-issue F-const_if_match T-lang disposition-merge finished-final-comment-period

Most helpful comment

Now that #64470 and #63812 have been merged, all the tools required for this exist in the compiler. I still need to make some changes to the query system around const qualification to make sure that it isn't needlessly inefficient with this feature enabled. We are making progress here, and I believe an experimental implementation of this will be available on nightly in weeks, not months (famous last words :smile:).

All 83 comments

  1. add a feature gate for it
  2. switch and switchInt terminators in https://github.com/rust-lang/rust/blob/master/src/librustc_mir/transform/qualify_consts.rs#L347 need to have custom code in case the feature gate is active
  3. instead of having a single current basic block (https://github.com/rust-lang/rust/blob/master/src/librustc_mir/transform/qualify_consts.rs#L328) this needs to be some container that has a list of basic blocks it still has to process.

@oli-obk It's a bit trickier because the complex control-flow means dataflow analysis needs to be employed. I need to get back to @alexreg and figure out how to integrate their changes.

@eddyb A good starting point would probably be to take my const-qualif branch (minus the top commit), rebase it over master (not going to be fun), and then add data annotation stuff, right?

Any news on this?

@mark-i-m Alas no. I think @eddyb has been very busy indeed, because I've not even been able to ping him on IRC for the last few weeks hah. Sadly my const-qualif branch doesn't even compile since I last rebased it over master. (I don't believe I've pushed yet though.)

thread 'main' panicked at 'assertion failed: position <= slice.len()', libserialize/leb128.rs:97:1
note: Run with `RUST_BACKTRACE=1` for a backtrace.
error: Could not compile `rustc_llvm`.

Caused by:
  process didn't exit successfully: `/Users/alex/Software/rust/build/bootstrap/debug/rustc --crate-name build_script_build librustc_llvm/build.rs --error-format json --crate-type bin --emit=dep-info,link -C opt-level=2 -C metadata=74f2a810ad96be1d -C extra-filename=-74f2a810ad96be1d --out-dir /Users/alex/Software/rust/build/x86_64-apple-darwin/stage1-rustc/release/build/rustc_llvm-74f2a810ad96be1d -L dependency=/Users/alex/Software/rust/build/x86_64-apple-darwin/stage1-rustc/release/deps --extern build_helper=/Users/alex/Software/rust/build/x86_64-apple-darwin/stage1-rustc/release/deps/libbuild_helper-89aaac40d3077cd7.rlib --extern cc=/Users/alex/Software/rust/build/x86_64-apple-darwin/stage1-rustc/release/deps/libcc-ead7d4af4a69e776.rlib` (exit code: 101)
warning: build failed, waiting for other jobs to finish...
error: build failed
command did not execute successfully: "/Users/alex/Software/rust/build/x86_64-apple-darwin/stage0/bin/cargo" "build" "--target" "x86_64-apple-darwin" "-j" "8" "--release" "--manifest-path" "/Users/alex/Software/rust/src/librustc_trans/Cargo.toml" "--features" " jemalloc" "--message-format" "json"
expected success, got: exit code: 101
thread 'main' panicked at 'cargo must succeed', bootstrap/compile.rs:1085:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.
failed to run: /Users/alex/Software/rust/build/bootstrap/debug/bootstrap -i build

Okay, funnily enough, I rebased again just today and it seems to be building all fine now! Looks like there was a regression, and it just got fixed. All over to @eddyb now.

@alexreg Sorry, I've switched to a local sleep schedule and I see you've pinged me when I wake up but then you're offline all day when I'm awake (ugh timezones).
Should I just make a PR out of your branch? I forgot what we were supposed to do with it?

@eddyb That's alright heh. You must be off to bed early, since I'm usually on from 8:00PM GMT, but it's all good! :-)

I'm really sorry, took me a while to realize that the series of patches in question requires removing Qualif::STATIC{,_REF}, i.e. the errors about accessing statics at compile-time. OTOH, this is already broken in terms of const fns and access to statics:

#![feature(const_fn)]
const fn read<T: Copy>(x: &T) -> T { *x }
static FOO: u32 = read(&BAR);
static BAR: u32 = 5;
fn main() {
    println!("{}", FOO);
}

This is not detected statically, instead miri complains that "dangling pointer was dereferenced" (which should really say something about statics instead of "dangling pointer").

So I think reading statics at compile-time should be fine, but some people want const fn to be "pure" (i.e. "referentially transparent" or thereabouts) at runtime, which would mean that a const fn reading from behind a reference it got as an argument is fine, but a const fn should never be able to obtain a reference to a static out of thin air (including from consts).

I think then we can keep statically denying mentioning statics (even if only to take their reference) in consts, const fn, and other constant contexts (including promoteds).
But we still have to remove the STATIC_REF hack which allows statics to take the reference of other statics but (poorly tries and fails to) deny reading from behind those references.

Do we need an RFC for this?

Sounds fair w.r.t. reading from statics. Doubt it needs an RFC, maybe just a crater run, but then I’m probably not the best one to say.

Note that we wouldn't be restricting anything, we'd be relaxing a restriction that's already broken.

Oh, I misread. So const evaluation would still be sound, just not referentially transparent?

The last paragraph describes a referentially transparent approach (but we lose that property if we start allowing mentioning statics in consts and const fns). I don't think soundness was really under discussion.

Well, "dangling pointer" sure sounds like a soundness issue, but I'll trust you on this!

"dangling pointer" is a bad error message, that's just miri forbidding reading from statics. The only constant contexts that can even refer to statics are other statics, so we could "just" allow those reads, since all of that code always runs once, at compile-time.

(from IRC) To summarize, referentially transparent const fn could only ever reach frozen allocations, without going through arguments, which means const needs the same restriction, and non-frozen allocations can only come from statics.

I do like preserving referential transparency so @eddyb's idea sounds fantastic!

Yeah I’m pro making const fns pure as well.

Please note that certain seemingly harmless plans could ruin referential transparency, e.g.:

let x = 0;
let non_deterministic = &x as *const _ as usize;
if non_deterministic.count_ones() % 2 == 0 {
    // do one thing
} else {
    // do a completely different thing
}

This would fail with a miri error at compile-time, but would be non-deterministic at runtime (because we can't mark that memory address as "abstract" like miri can).

EDIT: @Centril had the idea of making certain raw pointer operations (such as comparisons and casts to integers) unsafe within const fn (which we can do up until we stabilize const fn), and state that they can only be used in ways that miri would allow at compile-time.
For example, subtracting two pointers into the same local should be fine (you get a relative distance that only depends on the type layout, array indices, etc.), but formatting the address of a reference (via {:p}) is an incorrect use and therefore fmt::Pointer::fmt can't be marked const fn.
Also none of the Ord / Eq trait impls for raw pointers can be marked as const (whenever we get the ability to annotate them as such), because they're safe but the operation is unsafe in const fn.

Depends what you mean by "harmless"... I can certainly see reason we'd want to ban such non-deterministic behaviour.

It would be fantastic if work were continued on this.

@lachlansneff It's moving... not as quickly as we'd like, but work is being done. At the moment we're waiting on https://github.com/rust-lang/rust/pull/51110 as a blocker.

@alexreg Ah, thank you. It would be very useful to be able to mark a match or if as const even when not in a const fn.

any status updates now that #51110 is merged?

@programmerjake I'm waiting for some feedback from @eddyb on https://github.com/rust-lang/rust/pull/52518 before it can get merged (hopefully very soon). He's been very busy lately (always in high demand), but he's gotten back to reviews and whatnot in the past few days, so I'm hopeful. After that, it will need some work by him personally, I suspect, since adding proper dataflow analysis is a complicated affair. We'll see though.

Somewhere to the TODO lists in the first post(s), it should be added to remove the current horrible hack that translates && and || to & and | inside constants.

@RalfJung Wasn't that part of the old const eval, that's complete gone now that MIRI CTFE is in place?

AFAIK we do that translation somewhere in HIR lowering, because we have code in const_qualify that rejects SwitchInt terminators which would otherwise be generated by ||/&&.

Also,another point: @oli-obk said somewhere (but I cannot find where) that conditionals are somehow more complicated than one would naively think... was that "just" about the analysis for drop/interior mutability?

was that "just" about the analysis for drop/interior mutability?

I'm currently trying to clear that up. Will get back to you when I have all the information

What's the status of this? Is this in need of manpower or is it blocked on solving some problem?

@mark-i-m It's blocked on implementing proper dataflow analysis for const qualification. @eddyb is the most knowledgeable in this area, and he had previously done some work on this. (So had I, but that sort of stagnated...) If @eddyb still doesn't have time, perhaps @oli-obk or @RalfJung could tackle this at some point soon. :-)

58403 is a small step towards dataflow-based qualification.

@eddyb you mentioned preserving referential transparency in const fn, which I think is a good idea. What if you prevented using pointers in const fn? So your previous code sample would no longer compile:

let x = 0;
// compile time error: cannot cast reference to pointer in `const fun`
let non_deterministic = &x as *const _ as usize;
if non_deterministic.count_ones() % 2 == 0 {
    // do one thing
} else {
    // do a completely different thing
}

References would still be allowed but you wouldn't be allowed to introspect them:

let x = 0;
let p = &x;
if *p != 0 {  // this is fine
    // do one thing
} else {
    // do a completely different thing
}

Let me know if I'm completely off base, I just thought this would be a good way to make this deterministic.

@jyn514 that is already covered by making as usize casts unstable (https://github.com/rust-lang/rust/issues/51910), but users can also compare raw pointers (https://github.com/rust-lang/rust/issues/53020) which is just as bad and thus also unstable. We can handle these independently of control flow.

Any new on this?

There is some discussion on https://rust-lang.zulipchat.com/#narrow/stream/146212-t-compiler.2Fconst-eval/topic/dataflow-based.20const.20qualification.20MVP

@oli-obk your link doesn't work. What does it say?

It works for me... you've got to sign into Zulip though.

@alexreg hmm yeah I presume it was about the dataflow based const qualification work. @alexreg do you know why it's needed for if and match in constants?

if we don't have a dataflow based version we either accidentally allow &Cell<T> inside constants or accidentally forbid None::<&Cell<T>> (which works on stable. It's essentially impossible to implement properly without dataflow (or any implementation will be a bad broken ad-hoc version of dataflow)

@est31 Well, @oli-obk understands this much better than me, but from a high-level basically anything involving branching is going to predicate dataflow analysis unless you want a bunch of edge cases. Anyway, it seems like this person on Zulip is trying to work on it, and if not I know oli-obk and eddyb have intentions to, maybe this month or next (from when I last spoke to them about it), though I can't/won't make promises on their behalf.

@alexreg @mark-i-m @est31 @oli-obk I should be able to publish my WIP implementation of dataflow-based const qualification sometime this week. There are a lot of compatibility hazards here, so it may take a while to actually merge it.

Super; look forward to it.

(copying from #57563 per request)

Would it be possible to special-case bool && bool, bool || bool, etc.? They can currently be performed in a const fn, but doing so requires bitwise operators, which is sometimes unwanted.

They are already special-cased in const and static items -- by translating them to bitwise operations. But that special-casing is a huge hack and it is very hard to make sure that this is actually correct. As you said, it is also sometimes unwanted. So we'd rather not do this more often.

Doing things right will take a bit, but it'll happen. If we pile on too many hacks in the mean time, we might pain ourselves into a corner that we cannot get out of (if some of those hacks end up interacting in wrong ways, thus accidentally stabilizing behavior we do not want).

Now that #64470 and #63812 have been merged, all the tools required for this exist in the compiler. I still need to make some changes to the query system around const qualification to make sure that it isn't needlessly inefficient with this feature enabled. We are making progress here, and I believe an experimental implementation of this will be available on nightly in weeks, not months (famous last words :smile:).

@ecstatic-morse Great to hear! Thanks for your concerted efforts to get this done; I personally have been keen on this feature for a while now.

Would love to see heap allocation support for CTFE after this is done. I don't know if you or anyone else is interested in working on this, but if not perhaps I could help.

@alexreg Thanks!

Discussion about heap allocation at compile time is over at rust-rfcs/const-eval#20. AFAIK, the most recent developments were around a ConstSafe/ConstRefSafe paradigm for determining what can be observed directly/behind a reference in the final value of a const. I think there's more design work needed though.

For those following along, #65949 (which itself depends on a few smaller PRs) is the next blocker for this. While it may seem only tangentially related, the fact that const-checking/qualification was so tightly coupled with promotion was part of the reason this feature was blocked for so long. I plan on opening a subsequent PR that will remove the old const-checker entirely (currently we run both checkers in parallel). This will avoid the inefficiencies I mentioned earlier.

After both of the aforementioned PRs are merged, if and match in constants will be a few diagnostics improvements and a feature flag away! Oh, and also tests, so many tests...

If you need tests, I'm not sure how to get started but I am more than willing to contribute! Just let me know where the tests should go/what they should look like/what branch I should base code off of :)

Next PR to watch is #66385. This removes the old const qualification logic (which could not handle branching) completely in favor of the new dataflow-based version.

@jyn514 That would be great! I'll ping you when I start drafting the implementation. It would also be very helpful for people to try to violate const safety (especially the HasMutInterior part) once if and match are available on nightly.

66507 contains an initial implementation of RFC 2342.

I expect it will take a while to remove the rough edges, especially with respect to diagnostics, and test coverage is pretty sparse (@jyn514 we should coordinate over on that issue). Nevertheless, I'm hopeful that we can release this behind a feature flag in the next few weeks.

This was implemented in #66507 and can now be used in the latest nightly. There's also an Inside Rust blog post that details the newly available operations as well as some issues you might encounter with the existing implementation around types with interior mutability or a custom Drop impl.

Go forth and constify!

It seems that equality is not const? Or am I mistaken:

error[E0019]: constant function contains unimplemented expression type
  --> src/liballoc/raw_vec.rs:55:22
   |
55 |         let cap = if mem::size_of::<T>() == 0 { !0 } else { 0 };
   |                      ^^^^^^^^^^^^^^^^^^^^^^^^

error[E0019]: constant function contains unimplemented expression type
  --> src/liballoc/raw_vec.rs:55:19
   |
55 |         let cap = if mem::size_of::<T>() == 0 { !0 } else { 0 };
   |                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

error: aborting due to 2 previous errors

@mark-i-m That should indeed work. Maybe a bootstrapping issue? Let's discuss on Zulip.

Not sure if this is intentional, but trying to match on an enum gives the error

const fn with unreachable code is not stable

despite the fact that the enum is exhaustive and defined in the same crate.

@jhpratt can you post the code? I can match on simple enums without a problem: https://play.rust-lang.org/?version=nightly&mode=debug&edition=2018&gist=585e9c2823afcb49c6682f69569c97ea

@jhpratt can you post the code? I can match on simple enums without a problem:

here:
https://play.rust-lang.org/?version=nightly&mode=debug&edition=2018&gist=13a9fbc4251d7db80f5d63b1dc35a98b

Beat me by a couple seconds. That's a minimal example demonstrating my exact case.

@jhpratt Definitely not intentional. Could you open an issue?

Please redirect constification of specific functions or issues you want to report to fresh issues and label them appropriately with F-const_if_match so that this issues doesn't get flooded with ephemeral comments obscuring important developments.

@Centril Not a bad thing to put into the top comment so yours doesn't get buried.

Status update:

This is ready for stabilization from an implementation perspective, but there is the question of whether we want to keep the value based dataflow we have right now instead of a type based (but less powerful) one. The value based dataflow is a bit more expensive (more on that further down), and we need it for functions like

const fn foo<T>() {
    let x = Option::<T>::None;
    {x};
}

which a type based analysis would reject, because an Option<T> may have destructors that would now try to run and thus may execute non-const code.

We can fall back to a type based analysis the moment there are branches, but that would mean that we'd reject

const fn foo<T>(b: bool) {
    let x = Option::<T>::None;
    assert!(b);
    {x};
}

which would probably be very surprising to users.

@ecstatic-morse ran the analyis on all functions, not just const fn and saw slowdowns up to 5% (https://perf.rust-lang.org/compare.html?start=93dc97a85381cc52eb872d27e50e4d518926a27c&end=51cf313c7946365d5be38113950703c6aea9f2f3). Note that this is a pessimistic version, since it means this is also run on functions that won't and often can't ever become const fn.

This means that if we make loads of functions const fn, we may see some compilation slowdowns due to this value based analysis.

A middle ground could be to only run the value based analysis if the type based analysis fails. This means that if there are no destructors, we don't need to run the value based analysis to figure out if the non-existant destructors won't be run (yes I know, loads of negations here). To phrase it differently: we only run the value based analysis if there are destructors present.

I'm nominating this for @rust-lang/lang discussion so we can figure out whether we want to go with

  • the type based option in the presence of loops or branches (giving odd behaviour to users)
  • full value based analysis (more expensive, but full expressiveness for users)
  • mixed scheme, still full expressiveness for users, some extra impl complexity, but should reduce the compile-time problems to the cases that need it.

@oli-obk

the type based option in the presence of loops or branches (giving odd behaviour to users)

Just to check in on this: is it not an option to have a type-based analysis even in straightline code? I imagine that's kind of backwards incompatible, given that we already accept the following (playground):

struct Foo { }

impl Drop for Foo {
    fn drop(&mut self) { }
}

const T: Option<Foo> = None;

fn main() { }

Personally, I tend to think we should push for the more consistent, better experience for users. It seems like we can optimize as needed, and in any case the cost is not too bad. But I'd like to understand just a bit better exactly what's happening in this more expensive analysis: is the idea that we are basically doing "constant propagation", such that whenever something is dropped, we analyze the exact value being dropped to determine whether it may contain a value that would need to run a destructor? (i.e., if it is None, to use the common example of Option<T>)

Just to check in on this: is it not an option to have a type-based analysis even in straightline code? I imagine that's kind of backwards incompatible, given that we already accept the following (playground):

Yes, that's the reason we can't just move entirely to the type based analysis.

is the idea that we are basically doing "constant propagation", such that whenever something is dropped, we analyze the exact value being dropped to determine whether it may contain a value that would need to run a destructor? (i.e., if it is None, to use the common example of Option)

We're only propagating a list of flags (Drop and Freeze, I just showed Drop here because it's easier to explain). When we reach a Drop terminator without having set the Drop flag, we ignore the Drop terminator. This allows code like the following:

{
    let mut x = None;
    // Drop flag for x: false
    let y = Some(Foo);
    // Drop flag for y: true
    x = y; // Dropping x is fine, because Drop flag for x is false
    // Drop flag for y: false, Drop flag for x: true
    x
    // Dropping y is fine, because Drop flag for y is false
}

This does not happen at evaluation time, so the following is not fine:

{
    let mut x = Some(Foo);
    if false {
        x = None;
    }
    x
}

We check that all possible execution paths don't cause a Drop.

Constant propagation is a good analogy though. It's another dataflow problem whose transfer function can't be expressed with gen/kill sets, which don't handle copying state between variables. However, constant propagation needs to store the actual value of each variable, but const-checking only needs to store a single bit indicating whether that variable has a custom Drop impl or is not Freeze making it a bit less expensive than constant propagation would be.

To be clear, @oli-obk's first example compiles on stable today, and has since 1.38.0, which did not include #64470.

Furthermore, const X: Option<Foo> = None; compiles since 1.0, everything else is just a natural extension from that with the new features that const eval has gained.

OK, I believe it makes sense to adopt the purely value-based option then.

I guess we can cover it in the meeting and report back =)

Summary

I propose that we stabilize #![feature(const_if_match)] with the current semantics.

Specifically, if and match expressions as well as the short-circuiting logic operators && and || will become legal in all const contexts. A const context is any of the following:

  • The initializer of a const, static, static mut or enum discriminant.
  • The body of a const fn.
  • The value of a const generic (nightly only).
  • The length of an array type ([u8; 3]) or an array repeat expression ([0u8; 3]).

Furthermore, the short-circuiting logic operators will no longer be lowered to their bitwise equivalents (& and | respectively) in const and static initializers (see #57175). As a result, let bindings can be used alongside short-circuiting logic in those initializers.

Tracking issue: #49146
Version target: 1.45 (2020-06-16)

Implementation History

64470 implemented a value-based static analysis that supported conditional control-flow and was based on dataflow. This, along with #63812, allowed us to replace the old const-checking code with one that worked on complex control-flow graphs. The old const-checker was run in parallel with the dataflow-based one for a time to make sure that they agreed on programs with simple control flow. #66385 removed the old const-checker in favor of the dataflow-based one.

66507 implemented the #![feature(const_if_match)] feature gate with the semantics that are now being proposed for stabilization.

Const Qualification

Background

[Miri] has powered compile-time function evaluation (CTFE) in rustc for several years now, and has been able to evaluate conditional statements for at least that long. During CTFE, we must avoid certain operations, such as calling custom Drop impls or taking a reference to a value with interior mutability. Collectively, these disqualifying properties are known as "qualifications", and the process of determining whether a value has a qualification at a specific point in the program is known as "const qualification".

Miri is perfectly capable of emitting an error when it encounters an illegal operation on a qualified value, and it can do so with no false positives. However, CTFE occurs post-monomorphization, meaning it cannot know if constants defined in a generic context are valid until they are instantiated, which could happen in another crate. To get pre-monomorphization errors, we must implement a static analysis that does const qualification. In the general case, const qualification is undecidable (see Rice's Theorem), so any static analysis can only approximate the checks that Miri performs during CTFE.

Our static analysis must forbid a reference to a type with interior mutability (e.g. &Cell<i32>) from appearing in the final value of a const. If this were allowed, a const could be modified at run-time.

const X: &std::cell::Cell<i32> = std::cell::Cell::new(0);

fn main() {
  X.get(); // 0
  X.set(42);
  X.get(); // 42
}

However, we allow the user to define a const whose type has interior mutability (!Freeze) as long as we can prove that the final value of that const does not. For example, the following has compiled since the first edition of stable rust:

const _X: Option<&'static std::cell::Cell<i32>> = None;

This approach to static analysis, which I will call value-based as opposed to type-based, is also used to check for code that may result in a custom Drop impl being called. Calling Drop impls is problematic because they are not const-checked and thus can contain code that would not be allowed in a const context. Value-based reasoning was extended to support let statements, meaning the following compiles on rust 1.42.0 stable.

const _: Option<Vec<i32>> = {
  let x = None;
  let mut y = x;
  y = Some(Vec::new()); // Causes the old value in `y` to be dropped.
  y
};

Current Nightly Semantics

The current behavior of #![feature(const_if_match)] extends the value-based semantics to work on complex control-flow graphs by using dataflow. In other words, we try to prove that a variable does not have the qualification in question along all possible paths through the program.

enum Int {
    Zero,
    One,
    Many(String), // Dropping this variant is not allowed in a `const fn`...
}

// ...but the following code is legal under this proposal...
const fn good(x: i32) {
    let i = match x {
        0 => Int::Zero,
        1 => Int::One,
        _ => return,
    };

    // ...because `i` is never `Int::Many` on any possible path through the program.
    std::mem::drop(i);
}

All possible paths through the program include ones that may never be reached in practice. An example, using the same Int enum as above:

const fn bad(b: bool) {
    let i = if b == true {
        Int::One
    } else if b == false {
        Int::Zero
    } else {
        // This branch is dead code. It can never be reached in practice.
        // However, const qualification treats it as a possible path because it
        // exists in the source.
        Int::Many(String::new())
    };

    // ILLEGAL: `i` was assigned the `Int::Many` variant on at least one code path.
    std::mem::drop(i);
}

This analysis treats function calls as opaque, assuming that their return value may contain any value of its type. We also fall back to a type-based analysis for a variable as soon as a mutable reference to it is created. Note that creating a mutable reference in a const context is currently forbidden on stable rust.

#![feature(const_mut_refs)]

const fn none() -> Option<Cell<i32>> {
    None
}

// ILLEGAL: We must assume that `none` may return any value of type `Option<Cell<i32>>`.
const BAD: &Option<Cell<i32>> = none();

const fn also_bad() {
    let x = Option::<Box<i32>>::None;

    let _ = &mut x;

    // ILLEGAL: because a mutable reference to `x` was created, we can no
    // longer assume anything about its value.
    std::mem::drop(x)
}

You can see more examples of how a value-based analysis is conservative around interior mutability and custom drop impls, as well as some cases where a conservative analysis is able to prove that nothing illegal can happen in the test suite.

Alternatives

I've found it difficult to come up with practical, backwards compatible alternatives to the existing approach. We could fall back to type-based analysis for all variables as soon as conditionals are used in a const context. However, that would also be difficult to explain to users, since seemingly unrelated additions would cause code to no longer compile, such as the assert in the following example from @oli-obk.

const fn foo<T>(b: bool) {
    let x = Option::<T>::None;
    assert!(b);
    {x};
}

The increased expressiveness of the value-based analysis is not free. A perf run that did const qualification on all item bodies, not just const ones, showed up to a 5% regression on check builds. This is a worst-case scenario, as it assumes that all items will be made const at some point in the future. Possible optimizations, such as the one in #71330, have been discussed earlier in the thread.

Future work

At the moment, const-checking is run before drop elaboration, meaning some drop terminators remain in the MIR that are unreachable in practice. This is preventing Option::unwrap from becoming const fn (see #66753). This is not too hard to resolve, but it will require splitting the const-checking pass into two phases (pre- and post-drop elaboration).

Once #![feature(const_if_match)] is stabilized, a great deal of library functions can be made const fn. This includes many methods on primitive integer types, which have been enumerated in #53718.

Loops in a const context are blocked on the same const qualification question as conditionals. The current dataflow-based approach also works for cyclic CFGs with no modifications, so if #![feature(const_if_match)] is stabilized, the main blocker for #52000 will be gone.

Acknowledgements

Special thanks are due to @oli-obk and @eddyb, who were the primary reviewers for most of the implementation work, as well as the rest of @rust-lang/wg-const-eval for helping me understand the relevant issues around const qualification. None of this would be possible without Miri, which was created by @solson and now maintained by @RalfJung and @oli-obk.

This is intended to be the stabilization report preceding the FCP. I can't open FCP, however.

@ecstatic-morse Thank you so much for all your hard work on this issue!

Great report!

One thing that I think I would like to see, @ecstatic-morse, is

  • links to some representative tests in the repo, so we can observe the behavior
  • whether there are implications around semver or anything else -- I think the answer is largely no, right? In other words, we're deciding on the analysis used to determine whether the body of a const fn is legal, but given a const fn, our choices here don't determine things like "what the caller of the const fn can do with the result", right? I'm trying to figure out what an example might be of what I'm talking about -- I suppose it would be that the caller doesn't get to know precisely which variants of an enum were used, only that -- whatever value was returned -- it didn't have interior mutability (which they presumably can't rely upon when matching, either, since).

In other words, we're deciding on the analysis used to determine whether the body of a const fn is legal, but given a const fn, our choices here don't determine things like "what the caller of the const fn can do with the result", right? I'm trying to figure out what an example might be of what I'm talking about -- I suppose it would be that the caller doesn't get to know precisely which variants of an enum were used, only that -- whatever value was returned -- it didn't have interior mutability (which they presumably can't rely upon when matching, either, since).

Yes, a const fn's body is opaque. This is in contrast to a const item's intializer expression. You can observe this by the fact that

const FOO: Option<Cell<i32>> = None;

can be used to create a &'static Option<Cell<i32>>

const BAR: &'static Option<Cell<i32>> = &FOO;

while a const fn with the same body cannot:

const fn foo() -> Option<Cell<i32>> { None }
const BAR: &'static Option<Cell<i32>> = &foo();

playground demo

When we introduce control flow to constants, this means that

const FOO: Option<Cell<i32>> = if MEH { None } else { None };

will also work, irrelevant of the value of MEH and

const FOO: Option<Cell<i32>> = if MEH { Some(Cell::new(42)) } else { None };

will not work, again, irrelevant of the value of MEH.

Control flow does not change anything about the call sites of const fn, just about what code is allowed inside that const fn.

links to some representative tests in the repo, so we can observe the behavior.

I added a paragraph at the end of the "Current Nightly Semantics" section that links to some interesting test cases. I feel like we need more tests (a statement that is true regardless of the circumstances) before this is stabilized, but that can be addressed once we decide whether the current semantics are desirable.

whether there are implications around semver or anything else.

In addition to what @oli-obk said above, I want point out that changing the final value of a const is technically a semver-breaking change already:

// Upstream crate
const IDX: usize = 1; // Changing this to `3` will break downstream code!

// Downstream crate

extern crate upstream;

const X: i32 = [0, 1, 2][upstream::IDX]; // Only compiles if `upstream::IDX <= 2`

However, because we can't do const qualification with perfect precision, changing a constant to use if or match could break downstream code, even if the final value doesn't change. For example:

// Changing from `cfg` attributes...

#[cfg(not(FALSE))]
const X: Option<Vec<i32>> = None;
#[cfg(FALSE)]
const X: Option<Vec<i32>> = Some(Vec::new());

// ...to the `cfg` macro...

const X: Option<Vec<i32>> = if !cfg!(FALSE) { None } else { Some(Vec::new() };

// ...could break downstream crates, even though `X` is still `None`!

// Downstream

 // Only legal if static analysis can prove the qualifications in `X`
const _: () =  std::mem::drop(upstream::X); 

This doesn't apply to changes inside the body of a const fn, because we always use type-based qualification for the return value, even within the same crate.

In my view, the "original sin" here was not falling back to type-based qualification for const and statics defined in external crates. However, I believe that this has been the case since 1.0, and I suspect that quite a lot of code depends on it. As soon as you allow const initializers for which static analysis cannot be perfectly precise, it becomes possible to modify those initializers in such a way that they will produce the same value without static analysis being able to prove it.

edit:

There's nothing unique about if and match in this regard. For example, it's currently a breaking change to refactor a const initializer into a const fn if the downstream crate was relying on value-based qualification.

// Upstream
const fn none<T>() -> Option<T> { None }

const VALUE_BASED: Option<Vec<i32>> = None;
const TYPE_BASED: Option<Vec<i32>> = none();

// Downstream

const OK: () = { std::mem::drop(upstream::VALUE_BASED); };
const ERROR: () = { std::mem::drop(upstream::TYPE_BASED); };

@ecstatic-morse Thanks for writing up the stabilization report! Let's gauge consensus asynchronously:

@rfcbot merge

If anyone wants to discuss this synchronously in a meeting, please re-nominate.

Team member @joshtriplett has proposed to merge this. The next step is review by the rest of the tagged team members:

  • [x] @cramertj
  • [x] @joshtriplett
  • [x] @nikomatsakis
  • [x] @pnkfelix
  • [ ] @scottmcm
  • [ ] @withoutboats

No concerns currently listed.

Once a majority of reviewers approve (and at most 2 approvals are outstanding), this will enter its final comment period. If you spot a major issue that hasn't been raised at any point in this process, please speak up!

See this document for info about what commands tagged team members can give me.

:bell: This is now entering its final comment period, as per the review above. :bell:

Does this also allow using ? in const fn?

Using ? means using the Try trait. Using traits in const fn is unstable, see https://github.com/rust-lang/rust/issues/67794.

@TimDiekmann for the time being, you'll have to write proc macros that lower the ? manually. Same goes for loop and for, at least up to a certain limit (primitive recursion style) but const eval has such limits anyways. This feature is so awesome, it enables a LOT of things that weren't possible previously. You can even build a tiny wasm vm in const fn if you want.

The final comment period, with a disposition to merge, as per the review above, is now complete.

As the automated representative of the governance process, I would like to thank the author for their work and everyone else who contributed.

The RFC will be merged soon.

Was this page helpful?
0 / 5 - 0 ratings