移动赋值与标准复制交换不兼容。

34

测试新的移动语义。

我刚刚问了一个有关移动构造函数的问题。但是在评论中,事实证明问题实际上是当您使用标准的“复制和交换”惯用语时,“移动赋值”运算符和“标准赋值”运算符会发生冲突。

这是我正在使用的类:

#include <string.h>
#include <utility>

class String
{
    int         len;
    char*       data;

    public:
        // Default constructor
        // In Terms of C-String constructor
        String()
            : String("")
        {}

        // Normal constructor that takes a C-String
        String(char const* cString)
            : len(strlen(cString))
            , data(new char[len+1]()) // Allocate and zero memory
        {
            memcpy(data, cString, len);
        }

        // Standard Rule of three
        String(String const& cpy)
            : len(cpy.len)
            , data(new char[len+1]())
        {
            memcpy(data, cpy.data, len);
        }
        String& operator=(String rhs)
        {
            rhs.swap(*this);
            return *this;
        }
        ~String()
        {
            delete [] data;
        }
        // Standard Swap to facilitate rule of three
        void swap(String& other) throw ()
        {
            std::swap(len,  other.len);
            std::swap(data, other.data);
        }

        // New Stuff
        // Move Operators
        String(String&& rhs) throw()
            : len(0)
            , data(null)
        {
            rhs.swap(*this);
        }
        String& operator=(String&& rhs) throw()
        {
            rhs.swap(*this);
            return *this;
        }
};

我认为这很标准化。

然后我像这样测试了我的代码:

int main()
{
    String  a("Hi");
    a   = String("Test Move Assignment");
}

这里分配给a的操作应该使用“移动赋值”运算符。但是与“标准赋值”运算符(写为标准复制和交换)发生了冲突。

> g++ --version
Configured with: --prefix=/Applications/Xcode.app/Contents/Developer/usr --with-gxx-include-dir=/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.9.sdk/usr/include/c++/4.2.1
Apple LLVM version 5.0 (clang-500.2.79) (based on LLVM 3.3svn)
Target: x86_64-apple-darwin13.0.0
Thread model: posix

> g++ -std=c++11 String.cpp
String.cpp:64:9: error: use of overloaded operator '=' is ambiguous (with operand types 'String' and 'String')
    a   = String("Test Move Assignment");
    ~   ^ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
String.cpp:32:17: note: candidate function
        String& operator=(String rhs)
                ^
String.cpp:54:17: note: candidate function
        String& operator=(String&& rhs)
                ^

现在我可以通过修改“标准赋值”运算符来修复这个问题:

    String& operator=(String const& rhs)
    {
        String copy(rhs);
        copy.swap(*this);
        return *this;
    }

但这并不好,因为它会影响编译器优化复制和交换操作。请参见此处此处有关复制和交换惯用法的解释。

我是否忽略了一些不太明显的东西?


听起来像是那些苹果公司“假装”的C++11库之一,它不提供此功能。 这在过去给我带来了很多痛苦,我无法回忆起所有的问题。 请使用[__has_feature(cxx_implicit_moves)](http://clang.llvm.org/docs/LanguageExtensions.html)保护代码路径。 另一个问题是Android上的STLport。 - jww
3个回答

28

如果你定义赋值运算符为取值方式,那么你不需要(也不能)定义以右值引用方式接收的赋值运算符。因为这没有任何意义。

通常情况下,只有当你需要区分左值和右值时才需要提供以右值引用方式重载,但在这种情况下,你的实现选择意味着你不需要进行区分。无论你有一个左值还是右值,你都会创建参数并交换内容。

String f();
String a;
a = f();   // with String& operator=(String)
在这种情况下,编译器将解析调用为 a.operator=(f()); 它将意识到返回值的唯一原因是成为operator=的参数,因此将省略任何拷贝 --这就是首先让函数采用值的原因所在!

如果你只有 operator =(String),在从一个左值分配时,其长度小于目标长度时,你会失去一个性能优化。请查看我的帖子。 - Cassio Neri
@CassioNeri:不错的优化。但我认为这是针对特定类型问题的(选这个作为例子是我的错)。我试图研究一般情况。 - Martin York
1
@CassioNeri: 除此以外,你是正确的,如果你想要针对左值和右值有不同的行为,则需要进行重载,但你需要针对左值引用和右值引用进行重载,而不是针对值与任何引用类型进行重载。 - David Rodríguez - dribeas
简短地说:您的意思是,如果您有一个使用复制和交换完成的“标准赋值”(Standard Assignment),编译器优化(RVO)已经足够好,不需要“移动赋值”。 - Martin York
1
@Troy:性能分析程序和数据可用吗?我想看一下。 - David Rodríguez - dribeas
显示剩余6条评论

12

其他答案建议只有一个重载 operator =(String rhs),采用按值传递参数的方式,但这不是最高效的实现方法。

确实,在这个由David Rodríguez - dribeas提供的示例中,这是正确的。

String f();
String a;
a = f();   // with String& operator=(String)

没有复制操作。 然而,假设只提供了 operator =(String rhs) 并考虑以下示例:

String a("Hello"), b("World");
a = b;

发生的情况是:

  1. b 复制到 rhs(包括内存分配和 memcpy 操作);
  2. 交换 arhs
  3. 销毁 rhs

如果我们实现了 operator =(const String& rhs)operator =(String&& rhs),那么当目标字符串长度大于源字符串长度时,可以避免步骤 1 中的内存分配。例如,这是一个简单的实现(不完美:如果 String 有一个 capacity 成员会更好):

String& operator=(const String& rhs) {
    if (len < rhs.len) {
        String tmp(rhs);
        swap(tmp);
    else {
        len = rhs.len;
        memcpy(data, rhs.data, len);
        data[len] = 0;
    }
    return *this;
}

String& operator =(String&& rhs) {
    swap(rhs);
}

除了当swapnoexcept时,如果内存分配是"潜在的",那么operator=(String&&)就不能是noexcept,否则可以是noexcept

请参考Howard Hinnant的这篇精彩的解释获取更多细节。


那么,可以这样说,如果您不希望编写单独的移动赋值运算符,那么复制赋值运算符应该按值接受其参数吗? - M.M
@MattMcNabb 在我看来,这是一个非常合理的准则,但有时候会有一些特殊情况,不适用于此(我没有任何例子可以提供)。 - Cassio Neri
在一般情况下,你需要小心处理 "operator=(Whatever &&rhs)"。当函数退出时,“rhs”仍然存在(它的析构函数还没有运行),因此目标对象中以前存储的资源尚未释放(惊喜!)。如果有人通过 "obj1 = std::move(obj2)" 调用它,并且 "Whatever" 持有某种影响外部世界的资源(独占句柄、锁、大块内存等),那么你应该在退出函数之前显式地释放该资源。C++ 充满了陷阱,即使我在 C 和 C++ 中已经超过 30 年了,我仍然感到困难。 - Larry
@Larry,这没有任何意义。如果锁是独占的,并且在函数结束时两个对象都持有它,那么你正在做一些疯狂的事情。如果rvalue仍然拥有大量内存,为什么需要释放它?它的析构函数会自动完成这个任务。听起来你正在把移动操作符写成复制操作符。 - kfsone
@kfsone:两个对象都没有保留它,因为它们只是交换了资源。不过这已经无关紧要了。问题在于,当赋值结束时(即尚未销毁时),目标对象以前持有的资源仍然存在于“rhs”中。所以用 Peter Beckley 的话来说,你已经“漂移到了非确定性销毁的虚无世界”。请参阅他在此处的文章http://thbecker.net/articles/rvalue_references/section_01.html(该情况在文章中被埋藏在某个地方,但这是一个广为人知的问题)。 - Larry

3
你只需要这些内容即可完成复制和赋值操作:
    // As before
    String(const String& rhs);

    String(String&& rhs)
    :   len(0), data(0)
    {
        rhs.swap(*this);
    }

    String& operator = (String rhs)
    {
        rhs.swap(*this);
        return *this;
    }

   void swap(String& other) noexcept {
       // As before
   }

@Raxvan 这个类的实现很有趣,但根据 Howard Hinnant 的这篇优秀的解释,并不是最佳实现。 - Cassio Neri
@CassioNeri 如果字符串有容量的概念,那么你的观点就差不多正确了。 - user2249683
@DieterLücking 确实,如果 String 有一个容量成员,那么最好的效果就可以实现。然而,即使没有它(只使用 len),当源是一个 lvalue 且其 len 小于目标时,我们也可以获得更好的性能。请参阅我的帖子。 - Cassio Neri
@CassioNeri 因此留下一个设计决策问题:是更好地复制内容并保持(可能)未使用的额外容量,还是分配内容和容量(也可能是额外未使用的容量),或者分配迭代器范围assign(first last))? - user2249683
为什么要使用'rhs.swap(*this)'而不是'swap(rhs)'?https://godbolt.org/g/v3M5xy - kfsone

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