4

Imagine the following class that manages a resource (my question is only about the move assignment operator):

struct A
{
    std::size_t s;
    int* p;
    A(std::size_t s) : s(s), p(new int[s]){}
    ~A(){delete [] p;}
    A(A const& other) : s(other.s), p(new int[other.s])
    {std::copy(other.p, other.p + s, this->p);}
    A(A&& other) : s(other.s), p(other.p)
    {other.s = 0; other.p = nullptr;}
    A& operator=(A const& other)
    {A temp = other; std::swap(*this, temp); return *this;}
    // Move assignment operator #1
    A& operator=(A&& other)
    {
        std::swap(this->s, other.s);
        std::swap(this->p, other.p);
        return *this;
    }
    // Move assignment operator #2
    A& operator=(A&& other)
    {
        delete [] p;
        s = other.s;
        p = other.p;
        other.s = 0;
        other.p = nullptr;
        return *this;
     } 
};

Question:

What are the advantages and disadvantages of the two move assignment operators #1 and #2 above? I believe the only difference I can see is that std::swap preserves the storage of the lhs, however, I don't see how that would be useful as rvalues would be destroyed anyways. Maybe the only time would be with something like a1 = std::move(a2);, but even in this case I don't see any reason to use #1.

Jesse Good
  • 46,179
  • 14
  • 109
  • 158
  • I don't quite get your question. Why don't you simply use a `std::unique_ptr` member (instead of `int*`) and have the operators in question either auto-generated or `=default`? – Walter Nov 19 '15 at 09:12
  • @Walter: The question was a learning experiment and not something I would use in production. I would of chosen `std::vector` instead. Also, at the time of writing, `default` was not implemented by MSVC. – Jesse Good Nov 19 '15 at 10:22
  • Fair enough, but `MSVC` was not among the tags. – Walter Nov 19 '15 at 13:40

3 Answers3

9

This is a case where you should really measure.

And I'm looking at the OP's copy assignment operator and seeing inefficiency:

A& operator=(A const& other)
    {A temp = other; std::swap(*this, temp); return *this;}

What if *this and other have the same s?

It seems to me that a smarter copy assignment could avoid making a trip to the heap if s == other.s. All it would have to do is the copy:

A& operator=(A const& other)
{
    if (this != &other)
    {
        if (s != other.s)
        {
            delete [] p;
            p = nullptr;
            s = 0;
            p = new int[other.s];
            s = other.s;
        }
        std::copy(other.p, other.p + s, this->p);
    }
    return *this;
}

If you don't need strong exception safety, only basic exception safety on copy assignment (like std::string, std::vector, etc.), then there is a potential performance improvement with the above. How much? Measure.

I've coded this class three ways:

Design 1:

Use the above copy assignment operator and the OP's move assignment operator #1.

Design 2:

Use the above copy assignment operator and the OP's move assignment operator #2.

Design 3:

DeadMG's copy assignment operator for both copy and move assignment.

Here is the code I used to test:

#include <cstddef>
#include <algorithm>
#include <chrono>
#include <iostream>

struct A
{
    std::size_t s;
    int* p;
    A(std::size_t s) : s(s), p(new int[s]){}
    ~A(){delete [] p;}
    A(A const& other) : s(other.s), p(new int[other.s])
    {std::copy(other.p, other.p + s, this->p);}
    A(A&& other) : s(other.s), p(other.p)
    {other.s = 0; other.p = nullptr;}
    void swap(A& other)
    {std::swap(s, other.s); std::swap(p, other.p);}
#if DESIGN != 3
    A& operator=(A const& other)
    {
        if (this != &other)
        {
            if (s != other.s)
            {
                delete [] p;
                p = nullptr;
                s = 0;
                p = new int[other.s];
                s = other.s;
            }
            std::copy(other.p, other.p + s, this->p);
        }
        return *this;
    }
#endif
#if DESIGN == 1
    // Move assignment operator #1
    A& operator=(A&& other)
    {
        swap(other);
        return *this;
    }
#elif DESIGN == 2
    // Move assignment operator #2
    A& operator=(A&& other)
    {
        delete [] p;
        s = other.s;
        p = other.p;
        other.s = 0;
        other.p = nullptr;
        return *this;
     } 
#elif DESIGN == 3
    A& operator=(A other)
    {
        swap(other);
        return *this;
    }
#endif
};

int main()
{
    typedef std::chrono::high_resolution_clock Clock;
    typedef std::chrono::duration<float, std::nano> NS;
    A a1(10);
    A a2(10);
    auto t0 = Clock::now();
    a2 = a1;
    auto t1 = Clock::now();
    std::cout << "copy takes " << NS(t1-t0).count() << "ns\n";
    t0 = Clock::now();
    a2 = std::move(a1);
    t1 = Clock::now();
    std::cout << "move takes " << NS(t1-t0).count() << "ns\n";
}

Here is the output I got:

$ clang++ -std=c++11 -stdlib=libc++ -O3 -DDESIGN=1  test.cpp 
$ a.out
copy takes 55ns
move takes 44ns
$ a.out
copy takes 56ns
move takes 24ns
$ a.out
copy takes 53ns
move takes 25ns
$ clang++ -std=c++11 -stdlib=libc++ -O3 -DDESIGN=2  test.cpp 
$ a.out
copy takes 74ns
move takes 538ns
$ a.out
copy takes 59ns
move takes 491ns
$ a.out
copy takes 61ns
move takes 510ns
$ clang++ -std=c++11 -stdlib=libc++ -O3 -DDESIGN=3  test.cpp 
$ a.out
copy takes 666ns
move takes 304ns
$ a.out
copy takes 603ns
move takes 446ns
$ a.out
copy takes 619ns
move takes 317ns

DESIGN 1 looks pretty good to me.

Caveat: If the class has resources that need to be deallocated "quickly", such as mutex lock ownership or file open-state ownership, the design-2 move assignment operator could be better from a correctness point of view. But when the resource is simply memory, it is often advantageous to delay deallocating it as long as possible (as in the OP's use case).

Caveat 2: If you have other use cases you know to be important, measure them. You might come to different conclusions than I have here.

Note: I value performance over "DRY". All of the code here is going to be encapsulated within one class (struct A). Make struct A as good as you can. And if you do a sufficiently high quality job, then your clients of struct A (which may be yourself) won't be tempted to "RIA" (Reinvent It Again). I much prefer to repeat a little code within one class, rather than repeat the implementation of entire classes over and over again.

Howard Hinnant
  • 179,402
  • 46
  • 391
  • 527
  • Thanks, this was very informative. I was also surprised with the outcome of move in Design #2. – Jesse Good Mar 24 '12 at 11:19
  • 2
    It looks like the performance advantage of design 1 over design 2 is caused by the test harness not timing the destructor calls -- if you include that in your timing harness, I would expect the performance difference to disappear. I'm also a little surprised that LLVM doesn't optimize out a1 and a2 entirely. – Richard Smith May 28 '12 at 23:42
  • I strongly support Richard Smith's comment. This timing is **unfair** and does not really compare the same things. The clocked region should include the destruction of the object moved from, for in practice, this will almost always follow the move. I'm astonished that you of all didn't do this. – Walter Nov 19 '15 at 09:18
  • @Walter: Save your astonishment. Consider a sufficient-capacity `vector::insert` at the front. It is going to 1 move construction and N-1 move assignments of `T` to create space to insert the new element. It will destruct none of the N-1 `T` that it move assigned. If you want to find out where most move assignments are happening (and whether or not the source is soon destructed afterwards), look under the hood of your std-lib, or talk to someone who has. – Howard Hinnant Nov 19 '15 at 15:26
8

It's more valid to use #1 than #2 because if you use #2, you're violating DRY and duplicating your destructor logic. Secondly, consider the following assignment operator:

A& operator=(A other) {
    swap(*this, other);
    return *this;
}

This is both copy and move assignment operators for no duplicated code- an excellent form.

Puppy
  • 138,897
  • 33
  • 232
  • 446
3

The assignment operator posted by DeadMG is doing all the right thing if swap()ing involved objects can't throw. Unfortunately, this can't be always guaranteed! In particular, if you have stateful allocators and this won't work. If the allocators can differ it seems you want separate copy and move assignment: the copy constructor would unconditionally create a copy passing in the allocator:

T& T::operator=(T const& other) {
    T(other, this->get_allocator()).swap(*this);
    return * this;
}

The move assignment would test if the allocators are identical and, if so, just swap() the two objects and otherwise just call the copy assignment:

T& operator= (T&& other) {
    if (this->get_allocator() == other.get_allocator()) {
        this->swap(other);
    }
    else {
        *this = other;
    }
    return *this;
}

The version taking a value is a much simpler alternative which should be preferred if the noexcept(v.swap(*this)) is true.

This implicitly also answers the original qurstion: in the presence of throwing swap() and move assignment both implementations are wrong as they aren't basic exception safe. Assuming the only source of exceptions in swap() are mismatched allocators the implementation above is strong exception safe.

Dietmar Kühl
  • 141,209
  • 12
  • 196
  • 356