3

This question (code below) std::async and object copy got me thinking about copy/move heavy library implementations. This ones makes 5 copies for example. Countermeasures can be tricky, like in the case of async where passing by reference as opposed to value can cause concurrency problems. Move constructors are also not always cheap.

My question is could countermeasures like finding a way to pass by reference really be solving a problem that isn't there? I assume that most if not all of the library functions will end up being inlined, they are often only one or two lines of code. I of course still get five "delete obj" in output because the compiler has to follow the as if rule. If I were to take away the side effect of the destructor, by not printing, would the as if rule go further and end up with only one copy/move in release builds?

Are there things I should watch out for when writing move constructors as to not confuse the compiler or unintentionally introduce side effects and stop it from optimization?

class obj {
public:
    int val;
    obj(int a) : val(a) {
        cout << "new obj" << endl;
    }
    ~obj() {
        cout << "delete obj" << endl;
    }
};

void foo(obj a) {
    this_thread::sleep_for(chrono::milliseconds(500));
    cout << a.val << endl;
}

int main(int argc, int **args) {
    obj a(5);
    auto future = async(foo, a);
    future.wait();
    return 0;
}
Community
  • 1
  • 1
odinthenerd
  • 5,422
  • 1
  • 32
  • 61

2 Answers2

5

Does counting moves cause a Heisenberg effect?

No, not really. If you mean that observation of an event changes the event itself, I believe that's a wrong metaphor in this case.

The risk is rather that your observation will miss the event, as if you had never written those side-effects. Although, in general, side-effects shall be taken into consideration by compilers under the "as if" rule (1.9/1), there are a few important situations where the "as if" rule does not apply: that case is when copy/move elision of a temporary object can be performed by the compiler.

The situations when this is allowed are precisely described in Paragraph 12.8/31 of the C++11 Standard:

When certain criteria are met, an implementation is allowed to omit the copy/move construction of a class object, even if the copy/move constructor and/or destructor for the object have side effects. In such cases, the implementation treats the source and target of the omitted copy/move operation as simply two different ways of referring to the same object, and the destruction of that object occurs at the later of the times when the two objects would have been destroyed without the optimization. This elision of copy/move operations, called copy elision, is permitted in the following circumstances (which may be combined to eliminate multiple copies): [...]

The paragraph then goes on listing what the concrete circumstances are. This is not relevant to answer the question, so I omitted them. You can go look them up.

The important bit is that your side effects may be skipped. If the implementation of std::async() performs some copies or moves, depending on how this is done, the compiler might elide them even if you have some printouts in there, or more generally, even if you have some side-effects.

Thus, you should not rely on side-effects in your copy constructors, move constructors, or destructors as a portable way of counting how many copies or moves are performed within a function: different compilers, different optimization levels and, of course, different implementations of that function might yield different results.

Andy Prowl
  • 124,023
  • 23
  • 387
  • 451
3

There only needs to be one copy, which is because you pass an lvalue to std::async, all the others could be moves if your type was movable, which it isn't because your user-defined destructor suppresses the implicitly-defined move constructor. You could turn that one copy into a move by doing async(foo, std::move(a)) to pass an rvalue.

Remember that if you see five destructors one of them is a itself and one is the function parameter of foo, so that leaves three more done internally by std::async. With GCC's std::async there are only two more, I don't think it can be done with fewer, but it shouldn't really need more either. If the type is movable all the internal "copies" should be moves (unless your implementation's async is really badly written)

In any case, if you make it cheap to move your type then it doesn't really matter much if you have three moves or ten.

As explained in answers to your other question, the side effects in your destructor do not prevent copy elision, that is allowed to happen even if it removes side effects, so the answer to:

would the as if rule go further and end up with only one copy/move in release builds?

is No, and the answer to:

Are there things I should watch out for when writing move constructors as to not confuse the compiler or unintentionally introduce side effects and stop it from optimization?

is also No. Side effects do not prevent moving. Moving is not done by the optimizer, it's done when your code constructs (or assigns) an object from an rvalue. This is not optimization, it's governed by the rules and semantics of the C++ language.

In this code:

A a;
A b = a;

the compiler cannot use a move constructor for b, no matter how clever its optimizer is. It must use the copy constructor, that's what the rules of C++ say. In this code:

A a;
A b = std::move(a);

if A has a move constructor the compiler cannot use the copy constructor for b, it must use the move constructor, even without optimization turned on, that's what the rules of C++ say.

Jonathan Wakely
  • 166,810
  • 27
  • 341
  • 521