移动赋值运算符的问题

6

想象一下管理资源的类(我的问题只与移动赋值运算符有关):

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;
     } 
};

问题:

上面的两个移动赋值运算符#1和#2各有什么优缺点?我相信唯一的区别在于std::swap保留了lhs的存储,然而,我不知道这有什么用处,因为rvalue会被销毁。也许唯一的时间是像a1 = std::move(a2);这样的情况,但是即使在这种情况下,我也看不到使用#1的任何理由。


我不太明白您的问题。为什么不直接使用 std::unique_ptr<int> 成员(而不是 int*),并让相关运算符自动生成或 =default - Walter
@Walter:这个问题是一个学习实验,而不是我在生产中使用的东西。我会选择std::vector。此外,在编写时,MSVC还没有实现“default”。 - Jesse Good
好的,但是MSVC不在标签中。 - Walter
3个回答

9
使用 #1 比使用 #2 更加可靠,因为如果您使用 #2,则会违反 DRY 原则并复制析构逻辑。其次,请考虑以下赋值运算符:
A& operator=(A other) {
    swap(*this, other);
    return *this;
}

这是既包含复制又包含移动赋值运算符,没有重复代码- 是一种极好的形式。


为了提问者的方便,DRY是什么? - Nicol Bolas
谢谢,这是我没有考虑到的一个角度。 - Jesse Good
1
DRY = "不要重复自己" - Mr Fooz
@DeadMG - 为什么两者都有复制和移动?move() 不需要传递右值引用吗?如果不需要,为什么? - Steve Lorimer
2
@loriпәље› дёғotherеЏ‚ж•°еЏҮд»Өз”±д»»дҢ•йљђеәЏжһ„йЂ е‡Ңж•°жһ„йЂ -包括移动和е¤Қ制。 - Puppy

9

这是一个需要进行实际测量的情况。

我正在查看原帖中的复制赋值运算符,并发现存在低效率问题:

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

如果*thisother具有相同的s,会发生什么?
在我看来,如果s == other.s,更智能的复制赋值可以避免访问堆。它只需要进行复制即可:
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;
}

如果您只需要在复制赋值(例如std :: string,std :: vector等)上实现基本的异常安全性而不是强异常安全性,则以上内容可能会带来潜在的性能提升。具体提升多少?请进行测试。
我以三种方式编写了这个类:
设计1:使用上述复制赋值运算符和OP的移动赋值运算符#1。
设计2:使用上述复制赋值运算符和OP的移动赋值运算符#2。
设计3:DeadMG的复制赋值运算符用于复制和移动赋值。
以下是我用于测试的代码:
#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";
}

以下是我得到的输出:

$ 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对我来说看起来不错。

注意:如果类有需要快速释放的资源,如互斥锁所有权或文件打开状态的所有权,则从正确性的角度来看,设计2的移动赋值运算符可能更好。但是,当资源只是内存时,尽可能延迟释放它通常是有优势的(如OP的用例所示)。

注意2:如果您有其他重要的用例,请测量它们。您可能会得出与我在这里的结论不同的结论。

注意:我的价值观是性能高于“DRY”。所有这里的代码都将被封装在一个类(struct A)中。让struct A变得尽可能好。如果您做得足够高质量,那么struct A的客户端(可能是您自己)就不会被诱惑“RIA”(重新发明)。我更喜欢在一个类中重复一些代码,而不是一遍又一遍地重复整个类的实现。


谢谢,这很有启发性。我也对设计#2的结果感到惊讶。 - Jesse Good
2
看起来设计1相对于设计2的性能优势是由于测试工具没有计时析构函数调用 - 如果您在计时工具中包含它,我预计性能差异将消失。我也有点惊讶LLVM没有完全优化掉a1和a2。 - Richard Smith
我强烈支持Richard Smith的评论。这个时机是不公平的,而且并没有真正比较相同的事情。时钟区域应该包括从中移动的对象的销毁,因为在实践中,这几乎总是会发生移动。我很惊讶你们居然没有做到这一点。 - Walter
@Walter:不要感到惊讶。考虑一下在前面插入一个足够容量的vector<T>::insert。它将执行1次移动构造和N-1次移动赋值的T,以创建插入新元素的空间。它不会销毁任何已经移动分配的N-1个T。如果你想找出大多数移动赋值发生的位置(以及源是否很快被销毁),看看你的std-lib底层,或者与有经验的人交谈。 - Howard Hinnant

3
由DeadMG发布的赋值运算符在涉及交换对象时做了所有正确的事情,如果交换不能抛出异常。不幸的是,这并不能始终保证!特别是,如果您有具有状态的分配器,则无法正常工作。如果分配器可以不同,则似乎需要单独的复制和移动赋值:复制构造函数将无条件创建一个传递分配器的副本:
T& T::operator=(T const& other) {
    T(other, this->get_allocator()).swap(*this);
    return * this;
}

移动赋值将测试分配器是否相同,如果相同,只需使用 swap()交换两个对象,否则只需调用复制赋值:
T& operator= (T&& other) {
    if (this->get_allocator() == other.get_allocator()) {
        this->swap(other);
    }
    else {
        *this = other;
    }
    return *this;
}

如果noexcept(v.swap(*this))true,则取一个值的版本是一种更简单的选择,应该优先考虑。

这也隐含回答了最初的问题:在抛出swap()和移动赋值的情况下,两种实现都不符合基本异常安全性。假设swap()中唯一的异常来源是不匹配的分配器,则上述实现具有强大的异常安全性。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接