Posts When Is An Antipattern Not An Antipattern?
Post
Cancel

When Is An Antipattern Not An Antipattern?

We have all been told that applying design patterns in our codebase is something we want to strive for. But I believe there are fewer of us who have been told what antipatterns are and how dangerous it is for them to end up in our code. Moreover, what about those situations when you think a pattern is an antipattern but it’s not eventually. Have you ever faced that? Today’s post will show you the paradox I have faced a few months back.

Before getting to the main point, let’s start with the explanation of a well known C++ antipattern.

Returning by std::move

Returning by std::move is a commonly known C++ antipattern, one that most of us have heard of. Why is it so notorious? Here are the two things you need to know:

  • it disables (N)RVO ((Named-)Return-Value-Optimization) meaning that copy elision is not happening
  • it makes our code redundant as move will happen even without explicitly calling std::move

Let’s make the above more clear by going through the examples.

Imagine there is a struct Foo specified like:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Foo
{
    Foo()               { std::puts( "Foo()"              ); }
    Foo( Foo const  & ) { std::puts( "Foo( Foo const & )" ); }
    Foo( Foo       && ) { std::puts( "Foo( Foo && )"      ); }

    Foo & operator=( Foo const  & ) { std::puts( "operator=( Foo const & )" ); return *this; }
    Foo & operator=( Foo       && ) { std::puts( "operator=( Foo && )"      ); return *this; }

    ~Foo() { std::puts( "~Foo()" ); }

    int value{ 0 };
};

If we assume that Foo object is rather complicated to construct, we would write a helper function like:

1
2
3
4
5
6
7
8
Foo create()
{
    Foo f;

    // complicated initialization

    return std::move( f );
}

Everything OK? Nah… I wouldn’t say so.

std::move in the return statement indeed forces move constructor to be called but, at the same time, it disables NRVO / copy elision from happening.

How to fix this? Simply don’t call std::move!

Here is the program’s output in both cases:

with std::movewithout std::move
Foo()
Foo( Foo && )
~Foo()
Foo()

You can play with the demo here.

For the sake of completeness, we need to discuss when returning by std::move is actually a preferred thing to do. Imagine our Foo structure is expensive to copy and update function takes rvalue reference, modifies it and returns an updated Foo object. In this, rather contrived example, you need to use std::move since the parameter is rvalue reference. Otherwise, the object would have been copied, not moved.

1
2
3
4
5
6
7
Foo update( Foo && f, int const value )
{
    f.value = value;
    return std::move( f );
    // move to prevent copy ctor to be called
    // however, in latest compiler versions this is not needed - check out P1825R0
}

* P1825R0

However, above example can be rewritten in a much better way*:

1
2
3
4
5
Foo update( Foo f, int const value )
{
    f.value = value;
    return f;
}

Here is the full demo with the program’s output.

* The best approach, however, would be to have separate overloads if needed. Check out this blog post for more context.

Antipattern’s antipattern

Finally, we came to the main point of this blog post. Remember our struct Foo? Let’s inherit from it for some non-specific reason:

1
2
struct MyBrandNewFoo : Foo
{};

Now, let’s make pretty much the same create function, the right version of it - without std::move and without adding the antipattern to our code (even though returning derived object as base in the following snippet might seem as the antipattern too, this is made on purpose and object slicing is not actually happening), as we did in previous section:

1
2
3
4
5
6
7
8
Foo create()
{
    MyBrandNewFoo newFoo;

    // interesting stuff

    return newFoo;
}

What do you expect to see as the program’s output? I leave you to think for a few moments…

Few months ago, when I was few months younger and few months more inexperienced than now, I thought the program’s output would simply be:

1
2
3
Foo()
Foo( Foo && )
~Foo()

* Note: Copy elision can’t happen here for sure because Foo and MyBrandNewFoo are not of the same types.

But it turned out I was wrong… The copy was made. Check it out here.

Do you agree this behaviour is somehow unexpected (at least to me) and easy to miss in the code? If the copy of my MyBrandNewFoo was expensive, the cost would be huge. Fortunately, the fix for this is easy. Simply put std::move in the return statement to force move constructor to be called.

The interesting thing is that this behaviour is fixed in Clang trunk and latest GCC version. However, Clang does the implicit move for both C++17 and C++20 while GCC does it only for C++20.

Check it out here.

Arthur O’Dwyer described this issue far better than I did in this post, so I recommend listening to his talk at CppNow 2021. Also, if you are interested to go into more details, check out his proposal about simpler implicit moves.

Conclusion

Even though the behaviour explained in this post is fixed in the latest compiler versions, many of us are still using the older ones so be careful about these kinds of situations. They might introduce unnecessary copies to your source code and, thus, decrease its performance.

If you want to read more interesting C++ content, check out the list of C++ programming blogs released by Feedspot.

Trending Tags