Search code examples
c++c++20constexprc++23consteval

How can a sprintf-like function in C++20/23 verify that the number of format specifiers matches the number of provided arguments at compile time?


I’m trying to learn C++23 and thought a good exercise would be writing a sprintf()-like function that —- provided the given format string is a string literal —- does a compile-time check to ensure the number of arguments match the number of placeholders (here just ‘%’ for simplicity) in the format string, then goes on to create and return the formatted string. Ignoring any string formatting for now, I can’t get the compile-time check on the number of arguments vs. placeholders to work.

For example the code below doesn’t end up calling count_percents() on the string literal at compile time, I think because my use of std::to_string() in Sprintf() is not a constant expression (using GCC trunk —-std=c++2b). If I remove the call to std::to_string(), I can mark both functions consteval and get the desired compile-time check, but then by ability to do much else in Sprintf() is severely constrained (for example I can’t use std::to_string() or create/manipulate then return a std::string).

I realize I could get the desired result by making Sprintf into a variadic macro that first checks the number of arguments with a direct call to a consteval count_percents() before then calling a (non-consteval) helper function to do the actual string formatting (as is done here in C++17), but it feels like there must be a better, macro-free way in C++23.

#include <iostream>

template <int N>
constexpr size_t count_percents(const char (&s)[N]) {
    size_t count = 0;
    for (size_t i = 0; i < N; i++) 
        if (s[i] == '%') ++count;
    return count;
}

template <int N, typename... Args>
constexpr std::string Sprintf(const char (&format)[N], Args&&... args) {
    if (count_percents(format) != sizeof...(args)) 
        throw std::invalid_argument("wrong number of placeholders in format string");

    return (std::to_string(args) + ...);
}

int main() {
    std::cout << Sprintf("%%", 1, 2);  // outputs "12", ok

    // ideally wouldn't compile (but does - then throws runtime exception)
    std::cout << Sprintf("%", 1, 2); 
}

I found this answer helpful, but it doesn’t address my use case where I want a compile-time error based on the number of arguments passed to the non-consteval function. (It would work if all compile-time checks I wanted to do depended only on the contents of the format string itself.)

EDIT

Here’s my own answer to my precise question, which allows checking the argument count at compile time, but the chosen answer below is better because it allows inspecting the actual argument types as well.

https://godbolt.org/z/na5769EaP

#include <cstddef>
#include <iostream>

consteval size_t count_percents(const char* s) {
    size_t i = 0;
    while (*s) if (*s++ == '%') ++i;
    return i;
}

template <size_t N>
struct format_string {
  const char* str;

  consteval format_string(const char* s) : str(s) {
    if (N != count_percents(str))
        // if this code path is instantiated, 
        // this 'throw' is a compile-time error 
        // in this consteval function
        throw std::invalid_argument("wrong number of placeholders");
  }
};

template <typename... T>
void my_print(format_string<sizeof...(T)> format, T&&... args) {
    // if we're here, placeholder count worked
}

int main() {
    my_print("foo%%%", 1, 2, 3);   // ok
    // my_print("foo%", 1, 2, 3);  // compile-time error!
}

Solution

  • Here is modified version of your example that should work: (https://godbolt.org/z/q5T8zr71q)

    #include <iostream>
    #include <type_traits>
    
    constexpr size_t count_percents(const char* s) {
        size_t count = 0;
        while (*s != '\0') {
            if (*(s++) == '%') {
                count++;
            }
        }
        return count;
    }
    
    template <typename... Args>
    struct format_args {
    
        const char* format;
    
        consteval format_args(const char* format) : format(format) {
            if (count_percents(format) != sizeof...(Args)) {
                throw std::invalid_argument("wrong number of placeholders in format string");
            }
        }
    };
    
    
    template <typename... Args>
    constexpr std::string Sprintf(format_args<std::type_identity_t<Args>...> format, Args&&... args) {
    
        return (std::to_string(args) + ...);
    
    }
    
    int main() {
    
        std::cout << Sprintf("%%", 1, 2);  // outputs "12", ok
    
        // now throws correctly at compile time
        std::cout << Sprintf("%", 1, 2); 
    }
    

    The answer you've referenced is a good starting point. However, it does not address passing the pack Args to format_args's constructor.

    I guess the secret sauce here is the std::type_identity_t. If you'd declare it naively like:

    constexpr std::string Sprintf(format_args<Args...> format, Args&&... args) { 
      // impl 
    }
    

    ... it won't work, because the when the compiler does template resolution, it checks parameters from left to right and try to match them. It will first try to deduce Args... from the type of the first parameter you passed in. Well, it can't because it's a const char*. So it screams out an error!

    Here is where type_identity_t comes in handy: it makes the Args pack inside the std::type_identity_t<Args>... live in a non-deduced context, so the compiler won't bother deducing it and will substitute it later if it deduce what Args should be later (which it can from the type of args...).