c++metaprogrammingcompile-timeconsteval

Need help to understand how std::format can check its format string at compile-time


Out of curiosity, and with hope of learning useful compile-time techniques, I'm trying to understand how std::format performs compile-time checking of its arguments.

I started from the implementation and cross-referenced it with this StackOverflow answer.

I'm thus trying a simplified use-case where I want to check if the number of {} placeholders in a format string, provided at compile-time, is less than or equal to the number of provided arguments. Here is my attempt:

#include <string_view>

// compile-time count of "{}" inside "str"
consteval int count_occurrences(std::string_view str) {
    int count = 0;
    size_t pos = 0;
    std::string_view pattern("{}");

    while ((pos = str.find(pattern, pos)) != std::string_view::npos) {
        ++count;
        pos += pattern.length();
    }

    return count;
}

template <typename... Args>
struct FormatStringImpl {
    template <typename Str>
    consteval FormatStringImpl(Str const& str) : frmt(str) {
        // frmt is not considered as a constant expression so the next line
        // obviously doesn't compile
        auto constexpr n = count_occurrences(frmt);
        static_assert(sizeof...(Args) >= n, "wrong number of arguments");
    }
    std::string_view frmt;
};

// don't try to deduce Args from FormatString<Args...> in foo call
template <typename... Args>
using FormatString = FormatStringImpl<std::type_identity_t<Args>...>;

template <typename... Args>
consteval void foo([[maybe_unused]] FormatString<Args...> const frmt,
                   [[maybe_unused]] Args&&... args) {};

DEMO

The general idea is to perform the check during compile-time construction of the argument frmt (and not inside foo() where no input argument could be used inside a constant evaluated expression).

Another trick is to make the format string type dependent on the types of the other arguments of foo() (and not their values).

Now I am stuck, because I don't see how to proceed with the check inside the constructor. My attempt obviously fails, because I am trying to initialize a compile-time expression from a formally non-constant expression. Looking at a Standard Library implementation quickly loses me in the details that prevent me from getting the main ideas (which I could reuse with more elaborated checks).

I found a possible solution from this other StackOverflow answer:

    consteval FormatStringImpl(Str const& str) : frmt(str) {
        // frmt is not considered as a constant expression so I'm not using a constexpr variable anymore
        auto n = count_occurrences(frmt);
        if (sizeof...(Args) < static_cast<std::size_t>(n))
        {
            // is it legal?!
            throw "wrong number of arguments";
        };

DEMO

Here, I am removing the need for a constant expression, but I thought that using a throw inside a constexpr/consteval function was ill-formed, and I am unsure that it is required for the compiler to emit an error if it encounters a throw during a compile-time evaluation (AFAIK, it is IFNDR: https://timsong-cpp.github.io/cppwp/n4861/dcl.constexpr#6).

What is the right technique to move on with the check? Is there a way to make use of a constant expression (and possibly static_assert)? Can the throw solution be used (update AFAIK, MSVC's implementation uses throw).


Solution

  • I thought that using a throw inside a constexpr/consteval function was ill-formed and I am unsure that it is require for compiler to emit an error if it encounters a throw during a compile-time evaluation (AFAIU, it is IFNDR: https://timsong-cpp.github.io/cppwp/n4861/dcl.constexpr#6 ).

    That paragraph, emphasis mine

    if no argument values exist such that an invocation of the function or constructor could be an evaluated subexpression of a core constant expression, or, for a constructor, an evaluated subexpression of the initialization full-expression of some constant-initialized object ([basic.start.static]), the program is ill-formed, no diagnostic required.

    is to specify that the function could be called correctly by at least one parameters set.

    BTW, I think this paragraph has been removed to ease code to be valid with multiple different standard (as constexpr is added more and more, and allow more and more stuff)

    Can the throw solution be used?

    Yes, and it is used by several libraries in that context.