Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
chirsz-ever opened this issue Jan 6, 2024 · 17 comments
Open
Labels
api-change-proposal A proposal to add or alter unstable APIs in the standard libraries T-libs-api

Comments

@chirsz-ever
Copy link

chirsz-ever commented Jan 6, 2024

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:

  • Adding macros to prelude is a break change. But the todo, dbg and matches were added to prelude, maybe this is not a big problem?
  • Maybe we could use another name, such as const_assert. I think static_assert is better, becaus C, C++ and rustc developers just used it.

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):

  • We think this problem seems worth solving, and the standard library might be the right place to solve it.
  • We think that this probably doesn't belong in the standard library.

Second, if there's a concrete solution:

  • We think this specific solution looks roughly right, approved, you or someone else should implement this. (Further review will still happen on the subsequent implementation PR.)
  • We're not sure this is the right solution, and the alternatives or other materials don't give us enough information to be sure about that. Here are some questions we have that aren't answered, or rough ideas about alternatives we'd want to see discussed.
@chirsz-ever chirsz-ever added api-change-proposal A proposal to add or alter unstable APIs in the standard libraries T-libs-api labels Jan 6, 2024
@pitaj
Copy link

pitaj commented Jan 8, 2024

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
Copy link
Member

ChrisDenton commented Jan 9, 2024

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
Copy link
Member

joshtriplett commented Jan 9, 2024

@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
Copy link
Member

cuviper commented Jan 9, 2024

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

@joshtriplett
Copy link
Member

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

@tgross35
Copy link

tgross35 commented Jan 9, 2024

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
Copy link
Author

chirsz-ever commented Jan 9, 2024

@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
Copy link
Member

@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
Copy link

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
Copy link

CAD97 commented Feb 9, 2024

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
Copy link
Member

programmerjake commented Feb 9, 2024

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
Copy link
Member

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
Copy link

CAD97 commented Feb 10, 2024

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

Small clarification: while C++ (dependent1) 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 result2, even if said evaluation could panic, causing compilation to fail as a side effect.

Example

Rust, passes:

trait T {
    fn f() {}
    const STATIC_ASSERT: () = assert!(false);
}
impl<U> T for U {}

fn main() {
    <i32 as T>::f();
}

C++, fails:

template<typename Self>
struct T
{
    static void f() {}
    static_assert(sizeof(Self) < 0);
    // workaround MSVC missing P2593
};

int main()
{
    T<int>::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 are4 led to large (double-digit percentage, IIRC) regressions in check times5, 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.

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.

Footnotes

  1. Prior to P2593/CWG2518 (C++11 defect report)3, 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. 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.

  3. 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.

  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.

@the8472
Copy link
Member

the8472 commented Feb 10, 2024

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
Copy link

CAD97 commented Feb 10, 2024

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

@chirsz-ever
Copy link
Author

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
Copy link

CAD97 commented Jun 16, 2024

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-mono1 form (const _: () = { … }) and the post-mono2 form (const { … }). But I also agree that the simple form being available uniformly is valuable.

Footnotes

  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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api-change-proposal A proposal to add or alter unstable APIs in the standard libraries T-libs-api
Projects
None yet
Development

No branches or pull requests

10 participants