Formatted Diagnostics with C++20

C++26 adds formatted diagnostics with static_assert, something like this:

static_assert(
  sizeof(int) == 4,
  std::format("Expected 4, got {}", 
              sizeof(int)));

The benefit of course is that when such an assertion fails, the compiler outputs a diagnostic that we control. But can this be done in C++20? Well, this is C++! So the answer is a qualified yes – we can get the compiler to output our formatted text.

We’re going to use a combination of techniques and serendipities to get there. And yes, macros are involved. Such is life. Here we go.

First: of course we can do string formatting in a constexpr context. I’m using fmtlib for this.

Second: we’ll use a C++20 structural-string type for a template argument. Most implementations are pretty much the same.

template <std::size_t N>
struct cx_string {
  // some constructors etc...

  std::array<char, N> value{};
};

template <cx_string S>
auto f() {
  // S is a compile-time string, yay
}

Third: in order to preserve the constexpr nature of our format function arguments (which are naturally known at compile time), we can use the wrap-in-a-lambda trick.

auto f(auto l) {
  // f is not necessarily marked constexpr
  // l cannot be marked constexpr
  // but we can get a constexpr value out
  constexpr auto value = l();
}

// lambda's call operator is constexpr
// and 42 is a compile-time value
f([] { return 42; });

Fourth: it is often convenient to print type and enumeration names at compile time, so we can use the well-known __PRETTY_FUNCTION__ trick to turn them into string_views at compile time.

Fifth: it’s going to be convenient to treat types and values the same, so we’ll use the previously-known trick for that, and we’ll combine it with the constexpr-preserving wrap-in-a-lambda trick.

// treat all of these the same, for formatting
CX_VALUE(42);
CX_VALUE(int);
CX_VALUE("Hello world"sv);

Sixth: we’ll use the happy fact that compiler diagnostics print compile-time strings out for us.

template <cx_string>
struct undef;

undef<"Hello, world!"> q{};
error: implicit instantiation of undefined template 'undef<{{"Hello, world!"}}>'

This works since clang 15 and GCC 13.2 AFAIK, but this is where we leave MSVC, which is still printing ASCII codes, behind. Also, clang has another problem:

undef<"Hello, world! this is a string that is longer than the compiler likes to print"> q{};
error: implicit instantiation of undefined template 'diag<cx_string<79>{{"hello, world! this is a string tha[...]"}}>'

Clang elides the string in the diagnostic after a certain size. It even does this when we pass -fno-elide-type (which is arguably a bug). So we’ll have to find another trick.

Seventh: somewhere in recent history, concepts were billed as an improvement to diagnostics. Which means compilers like to output concept check failures verbosely.

template <cx_string S>
concept check = false;

static_assert(check<"Hello, world! this is a string that is long">);
error: static assertion failed
...
note: because cx_string<44>{{"Hello, world! this is a string that is long"}} does not satisfy 'check'

So when we use a concept check failure, we get the whole message. (Thanks to Patrick Roberts for making me aware of this last piece of the puzzle.)

We’re done. All that remains is to put all these things together with about as reasonable an interface as we can manage, in C++20.

template <typename T> constexpr auto f() {
    STATIC_ASSERT(
      std::is_unsigned_v<T>,
      "hello {} {} {}", 
      CX_VALUE("world"), CX_VALUE(T), 123);
}

auto main() -> int { f<int>(); }
...
note: because 'check<cx_string<20>{{"hello world int 123"}}>' evaluated to false

There you have it: with a couple of the major compilers at least, we have user-formatted text in compiler diagnostics with C++20. This is C++. Putting a bunch of tricks under the hood in order to have a nice interface experience is what we do.

Published
Categorized as C++