DEV Community

Sandor Dargo
Sandor Dargo

Posted on

My first work experience with C++20

I joined a new team recently. We have our own internal microservices as well as libraries. While for microservices we support one main branch, for libraries we do have to support at least three, in reality about five versions.

The different releases are using different toolchains supporting different versions of C++. Starting from C++11 we have all the versions up to C++20. While I had already been studying C++20 on my own, I didn't have to chance to use it in a real-world corporate context. In fact, not even C++17 - though it doesn't offer so many novelties.

In this small post, I'd like to reflect on our so called innovation week that I could spend on modernizing some our codebases.

Not even C++11

Using a new version is not just l'art pour l'art. Using a new standard can and should simplify quite a bit your code, it should make the life of maintainers easier. Long years after introducing C++11 to our codebases, I barely found the use of range-based for loops. Okay, okay, range-based for loops do have an important bug, but I clearly doubt that it's the reason behind not having these readable loops.

Instead, I found many long constructs of iterators, or even the good old for loops with the use of an incremented index along with the subscription operator ([]).

And then I haven't even mentioned the lack of using smart pointers, default member initalization, etc.

Maps and sets now have contains

If you have to work with std::map or std::set or their unordered versions, you most probably know how cumbersome is to find out whether they have a certain item (as a key) or not. Using a find() and then comparing its result with the end() iterator is verbose, not very readable and not elegant.

With C++20 we can replace all that with contains!

std::map<std::string, int> myMap;
// ...

//before C++20
if (myMap.find(aKey) != myMap.end()) {
    // the map contains a key
    // ...
}

// with C++20
if (myMap.contains(aKey)) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Of course, if you need an iterator to that item, you'll still need to use find, but contains will simplify your code in lots of cases.

Iterate over maps with structured bindings

I often saw that people created an iterator outside the loop because the type is very long, then in the first lines of the loop body they took references to the key and value of the given map element.

std::map<std::string, SomeLengthClassName>::const_iterator aIt;

for (aIt = myMap.begin(); aIt != myMap.end(); ++aIt)
{
    const std::string& aKey = aIt->first;
    const SomeLengthClassName& aValue = aIt->second;
    // ...
}
Enter fullscreen mode Exit fullscreen mode

With C++17, we can use structured bindings and we can get rid of these complex loops including the manual creation of those references.

for (const auto& [aPhase, aValue]: myMap)
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

That's shorter and way more readable.

But what you should you do when you only need the key or the value?

Ranges and what is missing

But there is more than that we can do with C++20 when we don't use the keys or the values!

Continuing the idea of structured bindings, when you don't need one of the key-value pair, with C++17 you used to simple name the not needed one as an _. With C++20 ranges there are these possibilities instead!

std::map<std::string, int> myMap {{"one", 1}, {"two", 2}, {"three", 3}};
for (auto aIt = myMap.begin(); aIt != myMap.end(); ++aIt)
{
    std::cout << aIt->second << '\n';
}


for (auto const& aValue: std::views::values(myMap))    
// or...
for (auto const& aKey: std::views::keys(myMap))

Enter fullscreen mode Exit fullscreen mode

That's already more readable and we haven't even tried to use the "pipe-syntax" that must be a kind of a satisfaction for programmers working on Linux.

for (auto const& aValue: myMap | std::views::keys) {
       std::cout << aValue << '\n';
}
Enter fullscreen mode Exit fullscreen mode

This pipe-syntax shows best it's potential when we chain multiple algorithms, views, etc. together and instead of building layers around the initial range we can simply read from the left to the right and quickly understand what goes on. This is all possible as functions in the ranges and views namespace do not take a pair of iterators but the containers directly. More on that in another article.

Is there a difference in performance between the good old way, the loop with structured bindings and with ranges/views?

I did some analyzes on quick bench and I found no difference between the C++17 and C++20 way of iterating over keys or values, but they are both a bit faster than dealing manually with the iterators.

Not surprisingly, I didn't find many usages of standard algorithms. But when I did I could almost always replace them with the range version, meaning that I don't have to pass the begin and end iterators anymore just the container - which is treated as a whole as a range.

I already showed how ranges could help me simplify loops to iterate over the keys of map or how I could replace simple standard algorithms with standard range-based algorithms.

std::copy(myVec.begin(), myVec.end(), std::back_inserter(results));

// The above example would become
std::copy(myVec, std::back_inserter(results));
Enter fullscreen mode Exit fullscreen mode

At a first glance, it seems that there is a small performance penalty on the ranges version. Something I have to analyze further. It's definitely not significant in applications were most time is lost in database and network class, but maybe it's too much in other cases.

In any case, the increase in readability might justify a bit of loss in CPU time. It depends on your situation.

I found ranges the best when I wanted to replace full for loops. Let me share an example with you.

bool Configuration::warnOnMissingData(const Date& iCheckDate)
{
    bool aWasAWarningIssued(false);

    Date aLastValidDate;
    std::vector<ImportantData>::const_iterator aDataIterator;
    for (aDataIterator = _data.begin(); aDataIterator != _data.end(); ++aDataIterator)
    {
        aLastValidDate = aDataIterator->second->getLastDate();
        if (aLastValidDate < iCheckDate)
        {
            LOG_ERROR(aDataIterator->second);
            aWasAWarningIssued = true;
        }
    }

    return aWasAWarningIssued;
}
Enter fullscreen mode Exit fullscreen mode

That loop was never great. Like why do we keep looping after the first matching condition? Because of logging maybe? It's not a great explanation. Even C++11 had great options for simplifying the above loop. But it's hard to find time to change working code. But when you do, don't be shy. Make sure that the code is tested and refactor it according to your best knowledge.

bool Configuration::warnOnMissingDataKeys(const Date& iCheckDate)
{
    auto isDataLastDateOlderThan = [&iCheckDate](const auto& aData) {
            if (aData == nullptr) {
                    return false;
            }
            return aData->getLastDate() < iCheckDate;
        };
    const auto& aPotentialMatch = std::ranges::find_if(
            _data,
            isDataLastDateOlderThan,
            &std::vector<ImportantData>::value_type::second
    );
    if (aPotentialMatch == _data.end()) { return false; }
    LOG_ERROR(aPotentialMatch->first);
    return true;
}
Enter fullscreen mode Exit fullscreen mode

With this refactoring, we could introduce an algorithm instead of a raw loop and we could give a name even to the condition. We only lost some logging which was probably not even meant.

Concepts for templates

Last but not least, I followed the T.10 core guideline's recommendation of not having bare template parameters. Each of them is constrained by some concepts now. Sometimes I only used a standard concept, but often I had to create the our own concepts first.

How did I come of with these new concepts?

I had a deep look into the templates to see how they use their template parameters. With that, I understood what API we have to require from any type. Then I also had a look into each instantiation to see if I can find a pattern. Often I realized that the API I require is the API defined by an abstract base class which each template argument type used as a base.

Nowing this fact let you decide whether I wanted to describe once more the interface or just require that the incoming parameters are implementing that base class, that interface. Ultimately I might even think about removing the base class if it is just for an interface, turn it into a concept and make sure that the used-to-be child class satisfies that base constraint. With that I'd basically introduce duck-typing, but I'd remove some virtual tables and pointers and runtime interface in general.

But let's come back to the creation of concepts. Only when I had a couple of rounds of this investigation I could focus on coming up with a good name for the concept. I found this part the most difficult one. Should I use a noun or an adjective? I'm not all set on that question. So far I used nouns that seemed to read slightly better. What do you think?

Conclusion

In this article I shared my first experience with C++20 and production code. I didn't only introduce C++20 features,
in some cases, C++17 suffice - bear in mind structures mindings. C++20 introduced some great library features like contains for maps and sets, but also new the ranges library and concepts. All this require some learning, but they can greatly simplify your code.

Connect deeper

If you liked this article, please

Top comments (2)

Collapse
 
610yesnolovely profile image
Harvey Thompson

Great article covering some of the useful things in C++20.

I saw you didn't cover C++ 20 modules. Probably wise, while seemingly a great idea, still has a lot of issues. If anyone ever wants to try them out, prototype a small project. While the idea and standard for C++20 modules seems inviting, I found it to be incredible annoying/frustrating and requires you to rewrite and re-architecture so much, with broken tools/build/compilers, with little advantage to alternatives. I've yet to find an actual project that uses them.

For naming concepts I've been using adjectives either with "-able", typically for describing verb-like objects that can do something (probably have one requirement), and "-like", typically for describing noun-like objects that can be something (probably have multiple requirements). It's whatever sounds right and describes it best, since they appear before a template parameter name, eg:

Linkable T, Closable F
ListLike T, FileLike F

Collapse
 
sandordargo profile image
Sandor Dargo

Thanks for your comment, Harvey!

Indeed, modules seem nice, but when I look for "easy" improvement possibilities to existing codebases modules are not among my first ideas.

I like your advice about concept names ending with "-like" or "-able". Just bear in mind that if you want to follow what the standard and the core guidelines went with, you should use names like linkable, closable, list_like, file_like, etc. It's not like the Concepts TS was.