rust-lang / libs-team

The home of the library team
Apache License 2.0
110 stars 18 forks source link

Add macro `static_assert` to perform compile-time assertion checking #325

Open chirsz-ever opened 6 months ago

chirsz-ever commented 6 months ago

Proposal

Problem statement

People often want to check some conditions at compile-time, but currently Rust does not have an intuitionistic way to do it.

Motivating examples or use cases

Here are use cases in rust-lang/rust and Rust-for-Linux:

https://github.com/rust-lang/rust/blob/432fffa8afb8fcfe658e6548e5e8f10ad2001329/library/std/src/io/error/repr_bitpacked.rs#L352

https://github.com/rust-lang/rust/blob/432fffa8afb8fcfe658e6548e5e8f10ad2001329/compiler/rustc_index/src/lib.rs#L43

https://github.com/rust-lang/rust/blob/432fffa8afb8fcfe658e6548e5e8f10ad2001329/compiler/rustc_middle/src/ty/consts/kind.rs#L75

https://github.com/Rust-for-Linux/linux/blob/cae4454fe293141be428436e5278261494cef02a/rust/kernel/static_assert.rs

People define their own versions of static_assert macros to meet the requirements.

Solution sketch

Add a new macro static_assert which check it's argument in compile-time.

static_assert is allowed to used at module's top-level, not like assert which can only used in a function or initialization context:

// allowed
static_assert!(0 == 0);

// not allowed: error: non-item macro in item position
assert!(0 == 0);

Like assert and debug_assert, static_assert_eq and static_assert_ne could also be added to the standard library.

The error message for failed assertion would be like:

error[E0796]: static assertion failed
 --> src/lib.rs:7:1
  |
7 | static_assert!(0 == 1);
  | ^^^^^^^^^^^^^^^^^^^^^^^ static assertion failed: 0 == 1

The macro can take a custom error message like assert, but currently we cannot call non-const formatting macro in constants. My opinion is just keeping the behavior same as assert, in the future when formating is allowd in const context, we would automaticly get the feature.

The macro should be able to be used in generic context to assert about generic parameters:

fn foo<const N: usize>() {
    static_assert!(N < 2);
}

Maybe some drawbacks:

Alternatives

There are several alternatives, the precondition is that const_panic is stablized since Rust 1.57, so we could perform assertion at const context, which would cause a compile-time error.

We can write const _: () = assert!(condition). This is stable, and works for most cases. The drawback is that it is not intuitionistic with grammar noise. People need to lean them from somewhere, such as TRPL or Rust Cookbook.

Another drawback of const _: () = assert!(condition) is that, due to E0401, you cannot assert about generic parameters:

fn foo<const N: usize>() {
    const _: () = assert!(N < 1);
}

playground

This code cannot be compiled currently, so Rust-For-Liunx has to use a build_assert macro.

With const block stable since 1.79, we could write const { assert!(condition) } to perform static assertion. It has no problem with generic parameters:

#![feature(inline_const)]

pub fn foo<const N: usize>() {
    const { assert!(N < 1); };
}

pub fn bar() {
    foo::<0>();
    // foo::<1>();
}

playground

But the const block has another issue: it is an expression so it is not able to be written at top level. I think it is easy to define the top-level const block:

const {
    do_sth();
}

is identical to

const _: () = {
    do_sth();
};

Links and related work

What happens now?

This issue contains an API change proposal (or ACP) and is part of the libs-api team feature lifecycle. Once this issue is filed, the libs-api team will review open proposals as capability becomes available. Current response times do not have a clear estimate, but may be up to several months.

Possible responses

The libs team may respond in various different ways. First, the team will consider the problem (this doesn't require any concrete solution or alternatives to have been proposed):

Second, if there's a concrete solution:

pitaj commented 6 months ago

I'll just note that we could add macros to the prelude on an edition boundary.

Also, are there any places where macro invocations are not currently allowed, but const _: () = assert!(...); is?

Finally, is static_assert the best name? Would const_assert be better?

ChrisDenton commented 6 months ago

Assuming inline_const gets stabilized, this could also be spelled:

const { assert!(0 == 1) }

playground link

This has the advantage that you don't need a new macro. Though the advantage of the macro is that it'll work as a top level item.

joshtriplett commented 6 months ago

@ChrisDenton We could, theoretically, make const { assert!(0 == 1); } work at top level. That'd require a lang proposal, but it seems doable if someone defines the semantics.

I'd be happy to review such a proposal if someone wanted to pitch it. It'd likely be a fairly simple lang RFC. "Allow const { ... } blocks at top level, with the semantic of evaluating everything in the block."

cuviper commented 6 months ago

Wouldn't inline-const have the same diagnostic issue as OP's const _: () = assert!(condition);?

joshtriplett commented 6 months ago

@cuviper I think we could easily make const blocks have better error messages than const _. We could have it just say assertion failed.

tgross35 commented 6 months ago

One of the issues with the const _: () = assert!(...) pattern is just that it isn't immediately obvious that all const evaluations get called all the time, and maybe we don't wan't that to be the case forever (maybe unused evaluations could be validated but pruned if unused). Having a dedicated macro or non-assignment block makes the behavior more intuitive.

chirsz-ever commented 6 months ago

@joshtriplett Top-level const block, just like comptime block in Zig and static block in Nim? This deserves a new RFC.

I think the semantics could be easily defined:

const {
    do_sth();
}

is identical to

const _: () = {
    do_sth();
};
joshtriplett commented 6 months ago

@chirsz-ever In a context that has const generics (e.g. a function with const generic arguments), it'd also be possible for const { ... } to look at const generics, which const _: () = { ... }; can't.

afetisov commented 6 months ago

maybe we don't wan't that to be the case forever (maybe unused evaluations could be validated but pruned if unused).

I doubt that's possible. Plenty of crates already rely on the current behaviour.

As one of the alternatives to this proposal, one should consider the static-assertions crate. It also provides many examples of more complex assertions (type equality/inequality, presence or absence of trait implementations, object safety).

Personally I feel that simple equality & predicate assertions aren't worth adding a separate feature. They may have an issue with discoverability, but a separate static assertion feature would also require reading a specific manual, and we could just add a "static assertions" chapter to the reference/whatever other manual, with the const item trick documented. More complex assertions are a more compelling case for a separate feature. They require more complex tricks than a simple anonymous const item, and they are hard to provide good error messages for without special compiler support.

CAD97 commented 5 months ago

This could "just" be a change in constant evaluation (miri) to produce a nicer error message, e.g.

error[E0080]: constant assertion failed
 --> src/main.rs:1:15
  |
1 | const _: () = assert!(1 == 2, "panic message");
  |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the evaluated program panicked
  = note: panic message
  = note: this error originates in the macro `$crate::panic::panic_2021` which comes from the expansion of the macro `assert` (in Nightly builds, run with -Z macro-backtrace for more info)

instead of the current

error[E0080]: evaluation of constant value failed
 --> src/main.rs:1:15
  |
1 | const _: () = assert!(1 == 2, "panic message");
  |               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the evaluated program panicked at 'panic message', src/main.rs:1:15
  |
  = note: this error originates in the macro `$crate::panic::panic_2021` which comes from the expansion of the macro `assert` (in Nightly builds, run with -Z macro-backtrace for more info)

especially since assert! is already a compiler built-in.

The macro should be able to be used in generic context to assert about generic parameters

To note, this defers the assertion from pre-monomorphization to post-monomorphization. I personally think it's important that the difference is made clear as we make post-monomorphization const panics more accessible. IMO const_assert!(...) is somewhat ambiguous between being const _: () = assert!(...); (pre-monomorphization, cannot capture generics) or const { assert!(...) }; (post-monomorphization, can capture generics), as both are still constant evaluated asserts, but static_assert! should unambiguously mean a pre-monomorphization assert.

The difference between const _: () = assert!(...); and const { assert!(...) }; isn't obvious if you don't already know that it exists, but at least the different syntactic forms provide some amount of affordance to the different semantics, as does the fact that only the latter form can use captured generics. For that reason I think it's important that if std provides a post-mono-capable const_assert! that a) std also provide a pre-mono static_assert!, and b) const_assert! should always be post-mono (never eagerly evaluate the assertion pre-mono).

The fact that const { assert!(...) }; is easier to "reinvent" than const _: () = assert!(...); does lead me to thinking std should provide a pre-mono static_assert!, if only to have a nice visible location to call out that static_assert! is not const { assert!(...) };. The fact that post-mono errors should be avoided does suggest std shouldn't provide a post-mono const_assert!.

programmerjake commented 5 months ago

I will point out that static_assert in C++ is post-monomorphization, so I think it's a bad idea to use the same name in Rust for a pre-monomorphization version because people won't expect it to behave differently.

ChrisDenton commented 5 months ago

This just makes me think new macros would be more confusing then helpful. Most people will end up using const { assert!(...) } and that's fine.

If people do need the pre-monomorphization version and don't know about it then is that something that diagnostics could call attention to?

CAD97 commented 5 months ago

I will point out that static_assert in C++ is post-monomorphization

Small clarification: while C++ (dependent[^1]) static_assert is done at template instantiation time, and this is more eager than post-monomorphization constant evaluation in Rust. Instantiated but unused static_assert will fail C++ compilation, but rustc is (at least currently; see bottom of post) surprisingly eager to skip evaluation of constants which don't directly impact the compilation result[^3], even if said evaluation could panic, causing compilation to fail as a side effect.

[^1]: Prior to P2593/CWG2518 (C++11 defect report)[^2], a static_assert in a template definition which cannot be instantiated to pass made the program ill-formed, no diagnostic required. MSVC currently still issues a pre-instantiation error for non-dependent failing static_assert.

[^2]: That C++ removed pre-instantiation static_assert failures is a decent indicator that we should probably avoid having const blocks conditionally pre- or post-mono based on if they capture generics. I wouldn't want us to end up needing dependent_false style tricks like MSVC C++ needs.

The most equivalent behavior for Rust to C++ `static_assert` (post CWG2518, ignoring the differences between instantiation-time and monomorphization-time failure) would be to permit `const` blocks at item and `impl` scope and for `const` blocks to always capture (be dependent on) all generics in scope.

[^3]: In this way, while we do have stronger promises around const evaluation (and panics) happening at compile-time nowadays, its roots as "just an optimization" over copy/paste doing the evaluation at each runtime usage show through here. You shouldn't be trusting constant assertions without somehow "observing" "evaluation" of the assertion because of this.

Example Rust, passes: ```rust trait T { fn f() {} const STATIC_ASSERT: () = assert!(false); } impl T for U {} fn main() { ::f(); } ``` C++, fails: ```cpp template struct T { static void f() {} static_assert(sizeof(Self) < 0); // workaround MSVC missing P2593 }; int main() { T::f(); return 0; } ``` I'm not certain on the specifics w.r.t. instantiation of non-template members of a templated class (and I think behavior might differ) -----------

C++ compilers don't really have an equivalent to cargo check, but the fact check doesn't diagnose post-mono errors (only cargo build does any monomorphization work) certainly exacerbates the downsides of post-mono assertions.

I would personally love for constant evaluation (and thus constant evaluation errors) to happen at instantiation time instead of monomorphization time. But last I heard, the experiment with tracking required constant instantiations the way type instantiations are[^4] led to large (double-digit percentage, IIRC) regressions in check times[^5], so is unlikey to happen for check. That optimizations (dead code elimination) can impact the set of constants evaluated by build is considered a bug, at least; see the various issues reachable from the inline-const tracking issue.

[^4]: That type instantiations are always checked while const instantiations aren't is one reason to still use the array type equality version of const_assert! even though the const-panic version has significantly better diagnostics.

[^5]: Doing more work takes time, who would've thought. And most significant compilation time improvements have been around culling processing of unneeded monomorphization, so "fixing" this functionally reverts an unfortunate quantity of compiler perf work.

In short: it's complicated. Adding a macro is a value add if (and likely only if) it can help (document and) tame the complexity somewhat.

the8472 commented 5 months ago

Having const asserts not happen on dead call trees (where the instantiation isn't "real" in the sense that it could be known at compile time that it'll never be called) can be desirable if the call tree itself has been eliminated by a if const {}.

Making const eval happen more eagerly makes asserts run for code that's intended never to be called and the resulting errors may logically be false positives.

So I think pushes for more eager const eval are going to hurt some uses if no alternative to opt out is provided.

CAD97 commented 5 months ago

The solution for that is to actually have "const if". Semantics should never rely on optimization. (User linker shenanigans notwistanding.)

chirsz-ever commented 1 month ago

const block is stabilized in 1.79. Now we have complete mechanisms for triggering compile-time errors.

// the most case
const { assert!(condition) };

// out of functions
const _:() = assert!(condition);

Do you know how to define a static_assert macro expanded to the second form when placed out of functions?

CAD97 commented 1 month ago

There is no functionality available to macros to expand to different tokens based on whether it is in item position (outside function) or expression/statement position (inside function). IIRC there was some tentative support for allowing top-level const blocks, which would allow using just const { assert!( … ) } in both positions.

Personally, I still think there is some benefit in distinguishing between the pre-mono[^1] form (const _: () = { … }) and the post-mono[^2] form (const { … }). But I also agree that the simple form being available uniformly is valuable.

[^1]: The guarantee that if a constant value is "evaluated" at runtime then it was evaluated at compile time is FCP accepted, as is a similar guarantee for top-level const items. While extending that same guarantee to items in function scope makes some sense, not doing so also makes some sense

[^2]: The conditionality of exactly when post-mono checks are asserted are why I don't particularly like them.