Because spending a week diving down compile-time rabbit holes is easier than shipping a dependency with your library.

Preamble

Firstly, I will assume that you already have your own compile-time string class. I’m using C++20 and as far as I’m aware std::string isn’t compile-time. For this, you can just use a simple wrapper class around std::array<char, N>.

Secondly, I want to emphasise that this will by no means be a feature-rich implementation, you can use fmt for that. All I care about is that it works and is somewhat readable and easy to understand. I’m just a student with limited time and don’t want to spend it all reinventing the wheel. This will become more evident later as a few concepts were copied from fmt.

Why

While you could (and probably should) use fmt for this, I find that learning CMake is significantly more boring than learning how fmt works under the hood. Besides, for my use case of having simple string formatting (using only numbers and other strings as arguments), I feel like using fmt and shipping that with my library is a bit overkill.

How

First, let’s break down how a formatting function works. Take this for example:

std::format("Hello {}!", "World")

  • Loop through the format string, count how many times {} shows up
  • Count how many arguments we have
  • Check if the amount of format specifiers matches the amount of arguments, if false, display an error message
  • Cast our arguments to a string representation and store them in an array
  • Loop through the format string again, replacing any occurrence of {} with the respective argument

While this seems trivial for a runtime implementation, handling all of this at compile-time becomes an interesting challenge.

Implementation

First, we have to count the amount of format specifiers. While you would think that this is trivial, here we run into the first and perhaps most annoying issue of our challenge:

template <typename... T>
constexpr auto format(const std::string& fmt, const T&... args) {
  size_t fmt_count = 0;
  for (size_t i = 0; i < fmt.length(); i++) {
    if (fmt[i] == '{' && fmt[i+1]=='}')
      fmt_count++;
  }
}

Trying to call the format function will result in the following error: Function parameter 'fmt' with unknown value cannot be used in a constant expression.

Function parameter values can not be used in constant expressions as they are unknown at compile-time. This seems backwards as if we provided let’s say, a string as an argument as per our example above, the string would be stored inside the binary. This would make you think that in fact that string is a constant expression but C++ seems to disagree.

Looking at fmt, they use a compiled_format struct which already stores the information we want upon instantiation. This is achieved through the (mis)use of templates, specifically, non-type template parameters. These allow us to use actual values instead of types as a template parameter. While function arguments cannot be used at compile-time, template arguments can.

However, this doesn’t mean we can just wrap a value inside of a struct and access it as we’ll run into the same error as before. Instead, we need to make that information available upon type instantiation. To achieve this, we can do the following.

template <size_t N, size_t Count>
struct compiled_format {
  std::array<char, N> buf;
  static constexpr size_t count = Count;
  static constexpr size_t size = N;
  ...
}

For someone who is newer to C++ the static might confuse you. However, all you need to know is that compiled_format<1,2> != compiled_format<1,1> as they are separate types. Therefore, using static just exposes the variable. Now all that’s left to do is to count the amount of format specifiers. For this, we need to use the function shown at the beginning of the post with some slight modifications:

template <size_t N>
constexpr size_t count_fmt(const char (&format)[N]) {
  size_t c = 0;
  for (size_t i = 0; i < N - 1; i++) {
    if (format[i] == '{' && format[i + 1] == '}') {
      c++;
      i++;
    }
  }
  return c;
}

The only thing that’s changed is that now instead of passing a std::string, we use string literals which allows us to get their length at compile-time1.

Now we can construct an object of compiled_format type like so:

constexpr auto x = compiled_format<sizeof("Hello {}"), count_fmt("Hello {}")>{"Hello {}"};

However, syntax isn’t ideal, let alone when we want to pass this to our formatting function. To solve this, I will once again be stealing a trick from fmt. While a lot of people will tell you to avoid using macros for this sort of stuff, I think it’s a perfectly valid use of it as this doesn’t do much outside of just hiding the ugly syntax. All this does is create a constexpr lambda which instantly returns our type.

#define COMPILE_FORMAT(str)                                   \
  ([]() constexpr {                                           \
    return compiled_format<sizeof(str), count_fmt(str)>{str}; \
  }())

Now we can write like so:

constexpr auto x = COMPILE_FORMAT("Hello {}");

Which will result in an instantiation of compiled_format<8, 1>

Now that we have all the information about our format string available at compile-time, the hardest part of this challenge is behind us. All that’s left is implementing the formatting function. But first, we will use C++ concepts to make sure that only compiled_format types can be passed to our function.

template <typename T>
struct is_compiled_format : std::false_type {};

template <size_t N, size_t Count>
struct is_compiled_format<compiled_format<N, Count>> : std::true_type {};

template <typename T>
concept CompiledFormat = is_compiled_format<T>::value;

template <CompiledFormat Format, Formattable... Args>
constexpr auto format(const Format& format, const Args&... args) {}

As for the formatting function, firstly, we will check if the amount of format specifiers matches the amount of arguments provided and convert our arguments to string representations. In my case, this looks like so, where Yarn is my string class:

template <CompiledFormat Format, Formattable... Args>
constexpr auto format(const Format& format, const Args&... args) {
  static_assert(sizeof...(Args) == Format::count,
    "Mismatch between number of arguments and format specifiers");

  const std::array<Yarn<>, sizeof...(Args)> arg_list{to_yarn(args)...};
}

Checking format specifiers is achieved with a static_assert, however, argument conversion requires a bit of attention. In my case, I only care about a few types being formattable so this approach works more than fine. If you wish to support more types or add the ability for users to define a formatter for their own types, you might want to look into how std::formatter works. One important detail to note is that the order of the if statements was carefully chosen as bool can decay to int. Additionally, both float and int satisfy std::is_arithmetic which requires us to specifically check if an argument is a floating point number or not2.

template <Formattable T>
constexpr Yarn<> to_yarn(const T& value) {
  if constexpr (std::same_as<std::decay_t<T>, char>) {
    return Yarn<>(value);
  } else if constexpr (std::same_as<std::remove_cvref_t<T>, bool>) {
    return bool_to_yarn(value);
  } else if constexpr (std::is_integral_v<T>) {
    return int_to_yarn(value);
  } else if constexpr (std::is_arithmetic_v<T>) {
    return float_to_yarn(value);
  }
  return Yarn<>(value);
}

The rest of the format function is pretty trivial. We have to check every occurrence of {} and replace it with the corresponding argument in arg_list.

template <CompiledFormat Format, Formattable... Args>
constexpr Yarn<> format(const Format& format, const Args&... args) {
  static_assert(sizeof...(Args) == Format::count,
                "Mismatch between number of arguments and format specifiers");

  const std::array<Yarn<>, sizeof...(Args)> arg_list{to_yarn(args)...};
  auto res = Yarn<>();
  size_t arg_idx = 0;
  size_t res_idx = 0;

  for (int i = 0; i < Format::size; i++) {
    if (format[i] == '{' && format[i + 1] == '}') {
      for (int j = 0; j < arg_list[arg_idx].size(); j++) {
        res.append(arg_list[arg_idx][j]);
        res_idx++;
      }
      arg_idx++;
      i++;
    } else {
      res.append(format[i]);
      res_idx++;
    }
  }

  return res;
}

Now, we can finally call our function like so

constexpr auto str = format(COMPILE_FORMAT("Hello {}"), "world")
std::cout << str << std::endl;
// prints "Hello world"

Conclusion

Hopefully you learned something from reading this, whether that’s about how fmt works or just more about C++. In addition to that, thank you for reading my first blog post ever, if you wish to leave me any feedback or just get in touch with me, you can reach me at

Footnotes

  1. This is not to say you cannot get the length of a std::string at compile time, see this.

  2. In my case, float_to_yarn accepts any floating point type and automatically processes them based on precision. See this