Table of Contents

std::format added in C++20 is a powerful helper for various text formatting tasks. In this blog post, we’ll have fun and print some tables with it. You’ll see the “old” C++17 version and compare it against the C++20 style.

std::format excercise  

As an exercise to learn about std::format, we can try printing some more advanced structures than just “Hello World”.

For example, if we have a map of data:

constexpr size_t Rows = 5;
const std::map<std::string, std::array<double, Rows>> productToOrders{
        { "apples", {100, 200, 50.5, 30, 10}},
        { "bananas", {80, 10, 100, 120, 70}},
        { "carrots", {130, 75, 25, 64.5, 128}},
        { "tomatoes", {70, 100, 170, 80, 90}}
};

We want to print them in the following table:

    apples   bananas   carrots  tomatoes
    100.00     80.00    130.00     70.00
    200.00     10.00     75.00    100.00
     50.50    100.00     25.00    170.00
     30.00    120.00     64.50     80.00
     10.00     70.00    128.00     90.00

C++17 version  

Let’s try the following code from C++17:

// print headers:
for (const auto& [key, val] : productsToOrders)
    std::cout << std::setw(10) << key;
std::cout << '\n';

// print values:
for (size_t i = 0; i < NumRows; ++i) {
    for (const auto& [key, val] : productsToOrders) {
        std::cout << std::setw(10) << std::fixed 
                  << std::setprecision(2) << val[i];
    }
    std::cout << '\n';
}

Structured bindings from C++17 help here, but as you can see, sometimes we use only keys and sometimes only values.

Improvements into C++20  

Okay, we have basic code, but let’s try improving it.

Max column width  

The first small thing is that we use a fixed length of fields, so if some text in a column is larger than ten characters, we’ll have some overflow.

We can write the following helper function:

template <typename T>
size_t MaxKeyLength(const std::map<std::string, T>& m) {
    size_t maxLen = 0;
    for (const auto& [key, val] : m)
        if (key.length() > maxLen)
            maxLen = key.length();
    return maxLen;
}

And use it:

const auto ColLength = MaxKeyLength(productsToOrders) + 2;

Later we can pass it to all std::setw() functions.

The code is fine, as it handles all maps that have std::string as the key, and values might be of a different type. Still, it’s better to rely on the more generic standard algorithm.

The first approach might be the following:

template <typename T>
size_t MaxKeyLength(const std::map<std::string, T>& m) {
    auto res = std::ranges::max_element(m, 
    [](const auto& a, const auto& b) {
        return a.first.length() < b.first.length();
    });
    return res->first.length();
}

But we can also use a handy view that returns only keys:

template <typename T>
size_t MaxKeyLength(const std::map<std::string, T>& m) {
    auto res = std::ranges::max_element(std::views::keys(m), 
        [](const auto& a, const auto& b) {
            return a.length() < b.length();
        });
    return (*res).length();
}

Adding std::format  

Let’s now replace output with std::cout into std::format calls:

// headers:
for (const auto& name : std::views::keys(productsToOrders))
    std::cout << std::format("{:*>{}}", name, ColLength);
std::cout << '\n';

The code uses this special format specifier: "{:>{}}":

{:fill-and-align sign width precision type}         

The code uses * as the placeholder character and > as an alignment (we can also use < for left or ^ for center). And then, for the width, we use {}, which points to a length that comes just after name.

Similarly, for values, we can implement the following loop:

// print values:
for (size_t i = 0; i < NumRows; ++i) {
    for (const auto& values : std::views::values(productsToOrders)) {
        std::cout << std::format("{:>{}.2f}", values[i], ColLength);
    }
    std::cout << '\n';
}

When we run this code, we should see the following:

****apples***bananas***carrots**tomatoes
    100.00     80.00    130.00     70.00
    200.00     10.00     75.00    100.00
     50.50    100.00     25.00    170.00
     30.00    120.00     64.50     80.00
     10.00     70.00    128.00     90.00

You can play with this version @Compiler Explorer

Side note: as of Feb 2023, it looks like all major compilers (MSVC, Clang 17, GCC 13) support std::format and even std::chrono calendar!

Adding dates from std::chrono  

While our table looks fine, it lacks some details. What do those numbers in rows mean?

We can add the following:

std::chrono::year_month_day startDate{2023y, month{February}, 20d};

And then add one column:

std::cout << std::format("{:>{}}", "date", ColLength);

And then, print dates before printing the value set:

const auto nextDay = sys_days{ startDate } + days{ i };
std::cout << std::format("{:>{}}", nextDay, ColLength);

Now we have the following table, which is probably easier to read:

Orders:
      date    apples   bananas   carrots  tomatoes
2023-02-20    100.00     80.00    130.00     70.00
2023-02-21    200.00     10.00     75.00    100.00
2023-02-22     50.50    100.00     25.00    170.00
2023-02-23     30.00    120.00     64.50     80.00
2023-02-24     10.00     70.00    128.00     90.00

As you can see, the code uses a starting date, then adds one day and prints it. Surprisingly we have to convert the date into sys_days before adding a new day. The initial date format year_month_date doesn’t have days precision.

Play with code @Compiler Explorer

This article started as a preview for Patrons, sometimes even months before the publication. If you want to get extra content, previews, free ebooks and access to our Discord server, join the C++ Stories Premium membership or see more information.

Summary  

Today’s experiment went from a simple table printing code using C++17 into a fancier version from C++20. The improved code was “nicer” and easier to write and maintain. Additionally, we got working date-time handling capabilities in just a couple of lines of code! Which wasn’t possible until C++20.

Back to you

Have you tried std::format or maybe {fmt}?

Share your comments below.