While browsing Microsoft’s public STL implementation, I noticed to my surprise that the assert macro (<cassert>) can be evaluated within a constant-evaluated context (e.g., compile time evaluation). Due to the relaxing constraints on constexpr functions, it becomes possible to use assert in constexpr functions in C++14 (some custom assert workarounds are possible in C++11).
// assert
#include <cassert>
constexpr auto f(int i)
noexcept -> int
{
assert(0 <= i); // Ensure i is non-negative integer
return i;
}
auto main()
-> int
{
return f(-1); // Pass negative integer
}
output.s: ./example.cpp:6: int f(int): Assertion `0 <= i' failed.
This is great! On the one hand, more and more functions can be made constexpr as newer C++ standards tend to further relax the constraints and tend to make the already existing functionality of the standard library more constexpr as well (e.g., std::string, std::vector). On the other hand, my mostly used defensive strategy to validate program correctness depends on asserts checking invariants, pre- and postconditions (as opposed to total and defensive programming).
Unfortunately, MAGE already uses custom asserts (MAGE_ASSERT) with logging support through spdlog, which could not be used in constexpr functions. Replacing these with assert in constexpr functions only is not a viable solution:
- having different assert macros depending on the context is confusing;
- when the given expression to
assertis not evaluated at compile time in aconstexprfunction, sinceconstexprfunctions are not guaranteed to be evaluated at compile time, our custom logger is not used; assertwill not be evaluated if theNDEBUGsymbol is defined, as is the case forMAGE_ASSERT. But the latter is actually implemented usingMAGE_ENSURE, which guarantees to evaluate the given expression independent of the configuration. No equivalent macro for this exists in the C++ standard library.
//-----------------------------------------------------------------------------
// Engine Defines: Ensure
//-----------------------------------------------------------------------------
// The expression is guaranteed to be evaluated in all configurations.
#define MAGE_ENSURE(expression, ...) \
do \
{ \
if (not (expression)) \
{ \
::mage::details::LogAssert(#expression, \
MAGE_SOURCE_LOCATION, \
__VA_ARGS__); \
MAGE_DEBUG_BREAK; \
} \
} \
while(false)
//-----------------------------------------------------------------------------
// Engine Defines: Assert
//-----------------------------------------------------------------------------
// The expression is not guaranteed to be evaluated in all configurations.
#ifdef MAGE_DEBUG
#define MAGE_ASSERT MAGE_ENSURE
#else // MAGE_DEBUG
#define MAGE_ASSERT MAGE_UNEVALUATED_EXPRESSION
#endif // MAGE_DEBUG
Fortunately, C++20 added std::is_constant_evaluated for the purpose of querying whether a function call occurs within a constant-evaluated context, allowing us to refine the previous macro definitions by explicitly distinguishing between both cases:
//-----------------------------------------------------------------------------
// Engine Defines: Ensure
//-----------------------------------------------------------------------------
// The expression is guaranteed to be evaluated in all configurations.
#define MAGE_ENSURE(expression, ...) \
do \
{ \
if (not (expression)) [[unlikely]] \
{ \
if (std::is_constant_evaluated()) \
{ \
???; \
} \
else \
{ \
::mage::details::LogAssert(#expression, \
MAGE_SOURCE_LOCATION, \
__VA_ARGS__); \
MAGE_DEBUG_BREAK; \
} \
} \
} \
while(false)
So what can MAGE_ENSURE do inside the constant-evaluated context? We cannot use assert, as Microsoft’s STL for example defines it as:
#ifdef NDEBUG
#define assert(expression) ((void)0)
#else
_ACRTIMP void __cdecl _wassert(
_In_z_ wchar_t const* _Message,
_In_z_ wchar_t const* _File,
_In_ unsigned _Line
);
#define assert(expression) (void)( \
(!!(expression)) || \
(_wassert(_CRT_WIDE(#expression), _CRT_WIDE(__FILE__), (unsigned)(__LINE__)), 0) \
)
#endif
It is not possible to define the NDEBUG symbol, include <cassert>, define MAGE_ENSURE using assert and undefine the NDEBUG symbol, as MAGE_ENSURE itself is a macro definition that will require the NDEBUG symbol to be defined upon expansion. Furthermore, assert does not support std::string_view, but requires null-terminated strings.
It is not possible to use static_assert, because that cannot evaluate expressions based on function parameters.
The trick consists of ignoring any logging. For expressions that are not evaluated at compile time, but at runtime instead, we want to redirect the assert messages to our logger. At compile time itself, we can obviously not use that same logger. But should we use a logger or output at all? As long as we guarantee the compilation to fail, the compiler will provide us some information about the cause without us having to customize this. The question then becomes, how can we let the compilation fail within a constant-evaluated context? Well, we can try to evaluate something that could never be evaluated within such a context (e.g., non-constexpr function). There are many possibilities, but my personal favourite is std::abort.
//-----------------------------------------------------------------------------
// Engine Defines: Ensure
//-----------------------------------------------------------------------------
// The expression is guaranteed to be evaluated in all configurations.
#define MAGE_ENSURE(expression, ...) \
do \
{ \
if (not (expression)) [[unlikely]] \
{ \
if (std::is_constant_evaluated()) \
{ \
std::abort(); \
} \
else \
{ \
::mage::details::LogAssert(#expression, \
MAGE_SOURCE_LOCATION, \
__VA_ARGS__); \
MAGE_DEBUG_BREAK; \
} \
} \
} \
while(false)
//-----------------------------------------------------------------------------
// Engine Defines: Assert
//-----------------------------------------------------------------------------
// The expression is not guaranteed to be evaluated in all configurations.
#ifdef MAGE_DEBUG
#define MAGE_ASSERT MAGE_ENSURE
#else // MAGE_DEBUG
#define MAGE_ASSERT MAGE_UNEVALUATED_EXPRESSION
#endif // MAGE_DEBUG
In addition, we can define a non-conditional fail macro:
//-----------------------------------------------------------------------------
// Engine Defines: Fail
//-----------------------------------------------------------------------------
#define MAGE_FAIL(...) \
do \
{ \
if (std::is_constant_evaluated()) \
{ \
std::abort(); \
} \
else \
{ \
::mage::details::LogFail(MAGE_SOURCE_LOCATION, __VA_ARGS__); \
MAGE_DEBUG_BREAK; \
} \
} \
while(false)
Which can be used in constexpr enum-to-enum conversion functions:
[[nodiscard]]
constexpr auto Convert(RasterizerState::FillMode input)
noexcept -> D3D12_FILL_MODE
{
switch (input)
{
using enum RasterizerState::FillMode;
case Solid:
return D3D12_FILL_MODE_SOLID;
case Wireframe:
return D3D12_FILL_MODE_WIREFRAME;
[[unlikely]] default:
MAGE_FAIL("Invalid rasterization fill mode: {}",
underlying_cast(input));
return {};
}
}
🧙