Last Update:
Parsing Numbers At Compile Time with C++17, C++23, and C++26
Table of Contents
Thanks to the powerful constexpr
keyword and many enhancements in recent C++ standards, we can now perform a lot of computations at compile time. In this text, we’ll explore several techniques for parsing integers, including the “naive” approach, C++23, from_chars
, std::optional
, std::expected
, and even some upcoming features in C++26.
But first… why would this even be needed?
Why at compile time?
While it may sound like a theoretical experiment, since C++11 we can shift more and more computations to compile-time. Here are some key areas and examples where constexpr
can be beneficial:
- Building lookup tables
- Working with embedded resources (like fonts or bitmaps)
- Parsing configuration strings
- Regular Expressions at compile time
- Working with bit flags and bits
- UB program verification (
constexpr
context guarantees no UB in your code) - and many more… see Your New Mental Model of constexpr - Jason Turner - CppCon 2021 - YouTube or Lightning Talk: Memoizing Constexpr Programs - Chris Philip - CppCon 2021 - YouTube or Anything can be a Constexpr if you try hard enough - Zoe Carver - CppCon 2019 - YouTube
Starting easy from C++17
Starting with C++17, we are now capable of writing complex constexpr
functions. However, our ability to do so is still limited by the range of algorithms available in that context. Luckily, with the introduction of string_view
in that version of C++, there is no longer any need to work with “raw” const char*
buffers.
Here’s a manual version:
constexpr int parseInt(std::string_view str) {
const auto start = str.find_first_not_of(WhiteSpaceChars);
if (start == std::string_view::npos)
return 0;
int sign = 1;
size_t index = start;
if (str[start] == '-' || str[start] == '+') {
sign = (str[start] == '-') ? -1 : 1;
++index;
}
int val = 0;
for (; index < str.size(); ++index) {
char ch = str[index];
if (ch < '0' || ch > '9') {
break;
}
val = val * 10 + (ch - '0');
}
return sign * val;
}
And the test code:
int main() {
static_assert(parseInt("123") == 123);
static_assert(parseInt("-123") == -123);
static_assert(parseInt(" 456 ") == 456);
static_assert(parseInt("abc123def") == 0);
static_assert(parseInt("") == 0);
static_assert(parseInt("abcdef") == 0);
std::cout << parseInt("9999999999");
}
Okay, the code tests most cases, but one interesting case is left to run at runtime (see std::cout ...
). The output is:
1410065407
Although it looks funny, does it actually work correctly?
One of the significant advantages of constexpr
functions is that they can run in both “modes,” and this allows for more aggressive testing:
Add this line:
static_assert(parseInt("9999999999") == 1410065407);
And you’ll immediately get the compiler error:
error: non-constant condition for static assertion
42 | static_assert(parseInt("9999999999") == 1410065407);
<source>:42:27: in 'constexpr' expansion ...
<source>:42:42: error: overflow in constant expression [-fpermissive]
From C++ reference - on constant expressions:
A core constant expression is any expression whose evaluation would not evaluate any one of the following:
… 8) an expression whose evaluation leads to any form of core language undefined behavior (including signed integer overflow, division by zero, pointer arithmetic outside array bounds, etc).
To put it simply, when we apply our parseInt
function to the argument of "9999999999"
, it results in an integer overflow. Consequently, the code cannot be executed. Unfortunately, this issue may go unnoticed during runtime, leading to unexpected errors, which can be quite problematic.
To fix the issues, we can use long int
to store our intermediate result:
long int val = 0;
for (; index < str.size(); ++index) {
char ch = str[index];
if (ch < '0' || ch > '9')
break;
val = val * 10 + (ch - '0');
if (val > numeric_limits<int>::max() || val < numeric_limits<int>::min())
return 0;
}
C++23 - std::from_chars
Writing some manual parsing code might be fun… and suitable for some experimental code, but it’s best to use a more reliable solution. In C++17, we have from_chars
char conversion routines that are super fast and easy to use.
The great thing is that as of C++23, integral overloads for this function, can be used at compile time!
We can use it and swap without limited manual code:
#include <string_view>
#include <charconv> // <<
using namespace std::literals;
constexpr auto WhiteSpaceChars = " \t\n\r\f\v"sv;
constexpr auto NumericCharsMinus = "-1234567890"sv;
constexpr int parseIntCpp23(std::string_view str) {
const auto start = str.find_first_of(NumericCharsMinus);
int result{};
if (start != std::string_view::npos)
{
for (size_t i = 0; i < start; ++i)
if (!WhiteSpaceChars.contains(str[i]))
return 0;
std::from_chars(str.data() + start, str.data() + str.size(), result);
}
return result;
}
int main() {
static_assert(parseIntCpp23("123") == 123);
static_assert(parseIntCpp23("-123") == -123);
static_assert(parseIntCpp23(" 456") == 456);
static_assert(parseIntCpp23("abc123") == 0);
static_assert(parseIntCpp23("") == 0);
static_assert(parseIntCpp23("abcdef") == 0);
static_assert(parseIntCpp23("99999999999999") == 0);
}
C++23 - std::optional
Our initial C++23 version returns 0 in case of an error, which is not very informative. Let’s try to improve it.
Fortunately C++23 brings us constexpr
support for std::optional
(and also std::variant
). This feature can improve our parse.
Have a look:
#include <string_view>
#include <charconv> // <<
#include <optional>
using namespace std::literals;
constexpr auto WhiteSpaceChars = " \t\n\r\f\v"sv;
constexpr auto NumericCharsMinus = "-1234567890"sv;
constexpr std::optional<int> tryParseIntCpp23(std::string_view str) {
const auto start = str.find_first_of(NumericCharsMinus);
if (start != std::string_view::npos) {
for (size_t i = 0; i < start; ++i)
if (!WhiteSpaceChars.contains(str[i]))
return std::nullopt;
int result{};
auto [ptr, ec] = std::from_chars(str.data() + start,
str.data() + str.size(), result);
if (ec == std::errc())
return result;
}
return std::nullopt;
}
int main() {
static_assert(tryParseIntCpp23("345") == 345);
static_assert(tryParseIntCpp23("-345") == -345);
static_assert(tryParseIntCpp23("00") == 0); // <<
static_assert(tryParseIntCpp23(" 6789").value() == 6789);
static_assert(!tryParseIntCpp23("abc345def").has_value());
static_assert(!tryParseIntCpp23("").has_value());
static_assert(!tryParseIntCpp23("abcdef").has_value());
}
C++23 - std::expected
And there’s even more!
The version with std::optional
conveys more information about the correct conversion, but we can use another handy type to output more data.
std::expected
from C++23 is a mix between std::optional
and std::variant
. It stores the expected value but also has a way to pass error type.
Have a look:
#include <string_view>
#include <charconv> // <<
#include <expected>
using namespace std::literals;
constexpr auto WhiteSpaceChars = " \t\n\r\f\v"sv;
constexpr auto NumericCharsMinus = "-1234567890"sv;
constexpr std::expected<int, std::errc> expectParseIntCpp23(std::string_view str) {
const auto start = str.find_first_of(NumericCharsMinus);
if (start != std::string_view::npos) {
for (size_t i = 0; i < start; ++i)
if (!WhiteSpaceChars.contains(str[i]))
return std::unexpected{std::errc::invalid_argument};
int result{};
auto [ptr, ec] = std::from_chars(str.data() + start,
str.data() + str.size(), result);
if (ec == std::errc())
return result;
return std::unexpected { ec };
}
return std::unexpected{std::errc::invalid_argument};
}
int main() {
static_assert(expectParseIntCpp23("567") == 567);
static_assert(expectParseIntCpp23("-567") == -567);
static_assert(expectParseIntCpp23(" 8910").value() == 8910);
static_assert(expectParseIntCpp23("9999999999").error()
== std::errc::result_out_of_range);
static_assert(expectParseIntCpp23("").error()
== std::errc::invalid_argument);
static_assert(expectParseIntCpp23("mnopqr").error()
== std::errc::invalid_argument);
}
As you can see, this time, we not only have a way to pass the result but also the complete error enum type.
C++26
C++26 is still not feature-ready, but we can get a glimpse of the features and play with them under GCC 14.
There’s at least one feature that can improve our code: Testing for success or failure of <charconv>
functions - P2497.
Rather than:
auto res = std::from_chars(str.data() + start, str.data() + str.size(), result);
if (res.ec == std::errc{}) {...}
We can write:
auto res = std::from_chars(str.data() + start, str.data() + str.size(), result);
if (res) { ... }
Small, but handy!
Run @Compiler Explorer - uses GCC trunk, GCC 14 with this feature already available in the Standard Library.
Summary
In this text, I showed you an example of parsing strings into numbers at compile time. We started from some “manual” parsers in C++17 and then extended it though C++23 and even C++26 features.
As you can notice, C++23 brings many constexpr improvements and allows us to use vocabulary types.
I’m curious about more C++26 features, as we might get “#embed,” which enables working with standalone files at compile time! Parsing configurations, jsons, or other data will be even nicer!
Please note that the parsing code in this article is still not production ready, and there are some cases which I skipped.
Back to you
- Do you parse strings at compile time?
- When do you use
constexpr
functions?
Share your comments below.
I've prepared a valuable bonus for you!
Learn all major features of recent C++ Standards on my Reference Cards!
Check it out here: