【问题标题】:A canonical way to implement move semantics in a class在类中实现移动语义的规范方法
【发布时间】:2016-01-16 20:45:44
【问题描述】:

我正在寻找一个很好的例子,说明如何使用移动语义实现派生类和基类。我想得越多,默认移动构造函数和赋值移动运算符似乎就越能正常工作,因为大多数标准(STL)类型和智能指针都是默认可移动的。

无论如何,如果我们有一个需要显式移动实现的类层次结构,我应该怎么做 - 至少作为第一次切割?

在这个例子中,我使用了一个原始指针,我通常会将它包装在 std::unique_ptr 中,但我需要一个移动的示例,它不是默认可移动的。

任何帮助将不胜感激。 :)

目前,我做了以下尝试:

struct BlobA
{
    char data[0xaa];
};

struct BlobB
{
    char data[0xbb];
};

//----------------------------------------

class Base
{
public:

    //Default construct the Base class
    //C++11 allows the data members to initialised, where declared. Otherwise you would do it here.
    Base()
    {

    }

    //Define the destructor as virtual to ensure that the derived destructor gets called. (In case it is necessary. It's not in this example but still good practice.)
    virtual ~Base()
    {
        delete m_moveableDataInBase; //this is a contrived example to show non-default moveable data, in practice RAII should be used rather than deletes like this
    }

    //Copy constructor, needs to accept a const ref to another Base class with which it copies the data members
    Base(const Base& other) :
    m_moveableDataInBase(new BlobA(*other.m_moveableDataInBase)), // copy the other's moveable data
    m_pNonMoveableDataInBase(other.m_pNonMoveableDataInBase)        // copy the other's non-moveable data
    {

    }

    //Assignment operator uses the canonical copy then swap idiom. It returns a reference to allow chaining: a = b = c
    //This is thus implemented in terms of the copy constructor.
    Base& operator=(const Base& rhs)
    {
        Base temp(rhs);
        Swap(temp);
        return *this;
    }

    //The move construtor is declared as throwing no exceptions so that it will be called by STL algorithms
    //It accepts an rvalue and moves the Base part.
    Base(Base&& other) noexcept :
    m_moveableDataInBase(nullptr) // don't bother allocating our own resources to moveable data because we are about to move (steal) the other's resource
    {
        Swap(other);
    }

    //The move assignment operator is declared as throwing no exceptions so that it will be called by STL algorithms
    //It accepts an rvalue and moves (steals) the data resources from the rhs using swap and copies the non moveable data from the rhs
    Base& operator=(Base&& rhs) noexcept
    {
        //move (steal) the moveable contents from rhs
        std::swap(m_moveableDataInBase, rhs.m_moveableDataInBase);

        //copy the non-moveable contents from rhs
        m_pNonMoveableDataInBase = rhs.m_pNonMoveableDataInBase;

        return *this;
    }

private:
    //this private member swaps the data members' contents.
    //It is private because it isn't virtual and only swaps the base contents and is thus not safe as a public interface
    void Swap(Base& other)
    {
        std::swap(m_moveableDataInBase, other.m_moveableDataInBase);
        std::swap(m_pNonMoveableDataInBase, other.m_pNonMoveableDataInBase);
    }

    //an example of some large blob of data which we would like to move instead of copy for performance reasons.
    //normally, I would have used a unique_ptr but this is default moveable and I need an example of something that isn't
    BlobA* m_moveableDataInBase{ new BlobA };

    //an example of some data that we can't or don't want to move
    int m_pNonMoveableDataInBase = 123;
};

//----------------------------------------

class Derived : public Base
{
public:

    //Default construct the Derived class, this is called after the base class constructor
    //C++11 allows the data members to initialised, where declared. Otherwise you would do it here.
    Derived()
    {

    }

    //Default virtual destructor, to clean up stuff that can't be done automatically through RAII
    virtual ~Derived()
    {
        delete m_pMoveableDataInDerived; //this is a contrived example to show non-default moveable data, in practice RAII should be used rather than deletes like this
    }

    //Copy constructor, needs to accept a const ref to another derived class with which it
    //first copy constructs the base and then copies the derived data members
    Derived(const Derived& other) :
    Base(other),  // forward to the base copy constructor
    m_pMoveableDataInDerived(new BlobB(*other.m_pMoveableDataInDerived)),  // copy the other's moveable data
    m_pNonMoveableDataInDerived(other.m_pNonMoveableDataInDerived)         // copy the other's non-moveable data
    {

    }

    //Assignment operator uses the canonical copy then swap idiom. It returns a reference to allow chaining: a = b = c
    //Because it uses the derived copy constructor, which in turn copy constructs the base, we don't forward to the base assignment operator.
    Derived& operator=(const Derived& rhs)
    {
        Derived temp(rhs);
        Swap(temp);
        return *this;
    }

    //The move construtor is declared as throwing no eceptions so that it will be called by STL algorithms
    //It accepts an rvalue and first moves the Base part and then the Derived part.
    //There is no point in allocating any resource before moving so in this example, m_pBlobB is set to nullptr
    Derived(Derived&& other) noexcept
    : Base(std::move(other)), // forward to base move constructor
    m_pMoveableDataInDerived(nullptr) // don't bother allocating our own resources to moveable data because we are about to move (steal) the other's resource
    {
        Swap(other);
    }

    //The move assignment operator is declared as throwing no exceptions so that it will be called by STL algorithms
    //It accepts an rvalue and first calls the base assignment operator and then moves the data resources from the rhs using swap
    Derived& operator=(Derived&& rhs) noexcept
    {
        //forward to the base move operator=
        Base::operator=(std::move(rhs));

        //move (steal) the moveable contents from rhs
        std::swap(m_pMoveableDataInDerived, rhs.m_pMoveableDataInDerived);

        //copy the non-moveable contents from rhs
        m_pNonMoveableDataInDerived = rhs.m_pNonMoveableDataInDerived;
    }

private:
    //this member swaps the Derived data members contents.
    //It is private because it doesn't swap the base contents and is thus not safe as a public interface
    void Swap(Derived& other) noexcept
    {
        std::swap(m_pMoveableDataInDerived, other.m_pMoveableDataInDerived);
        std::swap(m_pNonMoveableDataInDerived, other.m_pNonMoveableDataInDerived);
    }

    //an example of some large blob of data which we would like to move instead of copy for performance reasons.
    //normally, I would have used a unique_ptr but this is default moveable and I need an example of something that isn't
    BlobB* m_pMoveableDataInDerived{ new BlobB };

    //an example of some data that we can't or don't want to move
    int m_pNonMoveableDataInDerived = 456;
};

【问题讨论】:

  • 您是否考虑过使用unique_ptr<Bla> 作为可移动数据指针?然后你会“免费”获得很多语义
  • @MM,是的,我在我的问题中指出了这一点,如果您可以花时间阅读它。谢谢。
  • 我不明白您在包含“unique_ptr”一词的句子中要说什么。你是说你选择不使用 unique_ptr 即使你知道它会解决你的问题?

标签: c++ c++11 c++14


【解决方案1】:

您必须首先了解您的类不变量是什么。类不变量是您的数据成员之间始终为真的某种东西或某种关系。然后您要确保您的特殊成员可以使用满足您的类不变量的任何值进行操作。特殊成员不应该有前置条件(除了所有类不变量都必须为真)。

我们以你的例子为例进行讨论。首先让我们专注于Base。我喜欢把我的私人数据成员放在前面,以便他们靠近特殊成员。这样我可以更容易地看到默认或隐式声明的特殊成员实际上做了什么。

基础

class Base
{
    //an example of some large blob of data which we would like to move 
    //  instead of copy for performance reasons.
    //normally, I would have used a unique_ptr but this is default moveable
    //   and I need an example of something that isn't
    BlobA* m_moveableDataInBase{ new BlobA };

    //an example of some data that we can't or don't want to move
    int m_pNonMoveableDataInBase = 123;

到目前为止一切顺利,但这里有一个轻微歧义:m_moveableDataInBase == nullptr 可以吗?没有一个正确或错误的答案。这是Base的作者必须回答的问题,然后写代码强制执行。

另外,概述您的成员函数。即使您决定要内联它们,也要在声明之外这样做。否则你的类声明变得难以阅读:

class Base
{
    BlobA* m_moveableDataInBase{ new BlobA };
    int m_pNonMoveableDataInBase = 123;

public:
    virtual ~Base();
    Base() = default;
    Base(const Base& other);
    Base& operator=(const Base& rhs);
    Base(Base&& other) noexcept;
    Base& operator=(Base&& rhs) noexcept;
};

析构函数是最能说明问题的特殊成员。我喜欢先声明/定义它:

Base::~Base()
{
    delete m_moveableDataInBase;
}

这看起来不错。但这还没有回答m_moveableDataInBase 是否可以是nullptr 的问题。接下来,如果存在,则默认构造函数。实用时首选= default 定义。

现在是复制构造函数:

Base::Base(const Base& other)
    : m_moveableDataInBase(new BlobA(*other.m_moveableDataInBase))
    , m_pNonMoveableDataInBase(other.m_pNonMoveableDataInBase)
{
}

好的,这说明了一些重要的事情:

other.m_moveableDataInBase != nullptr  // ever

我向前看了一眼,查看了您的移动构造函数,然后将移动的值保留为m_moveableDataInBase == nullptr。所以我们有一个问题:

  1. 您的复制构造函数中存在错误,您应该检查other.m_moveableDataInBase == nullptr 的情况,或者

  2. 您的移动构造函数中有一个错误,它不应该使用m_moveableDataInBase == nullptr 离开移动状态。

这两种解决方案都不是正确的。 Base 作者必须做出这个设计决定。如果他选择 2,那么确实没有合理的方法来实现移动构造函数,使其比复制构造函数更快。在这种情况下,要做的不是编写移动构造函数,而是让复制构造函数完成这项工作。所以我会选择 1 这样还有一个移动构造函数可以讨论。更正了复制构造函数:

Base::Base(const Base& other)
    : m_moveableDataInBase(other.m_moveableDataInBase ?
                           new BlobA(*other.m_moveableDataInBase) :
                           nullptr)
    , m_pNonMoveableDataInBase(other.m_pNonMoveableDataInBase)
{
}

另外,由于我们选择了这个不变量,重新访问默认构造函数可能不是一个坏主意,而是说:

    BlobA* m_moveableDataInBase = nullptr;

现在我们有一个noexcept 默认构造函数。

接下来是复制赋值运算符。不要陷入默认选择复制/交换习语的陷阱。有时这个成语很好。但它往往表现不佳。 And performance is more important than code reuse。考虑这种复制/交换的替代方法:

Base&
Base::operator=(const Base& rhs)
{
    if (this != &rhs)
    {
        if (m_moveableDataInBase == nullptr)
        {
            if (rhs.m_moveableDataInBase != nullptr)
                m_moveableDataInBase = new BlobA(*rhs.m_moveableDataInBase);
        }
        else  // m_moveableDataInBase != nullptr
        {
            if (rhs.m_moveableDataInBase != nullptr)
                *m_moveableDataInBase = *rhs.m_moveableDataInBase;
            else
            {
                delete m_moveableDataInBase;
                m_moveableDataInBase = nullptr;
            }
        }
        m_pNonMoveableDataInBase = rhs.m_pNonMoveableDataInBase;
    }
    return *this;
}

如果Base 的值通常具有m_moveableDataInBase != nullptr,那么这种重写比复制/交换快得多。在这种常见情况下,复制/交换始终执行 1 次新建和 1 次删除。这个版本做 0 条新闻和 0 条删除。它只复制 170 个字节。

如果我们选择了m_moveableDataInBase != nullptr 是不变量的设计,那么复制分配会变得更加简单:

Base&
Base::operator=(const Base& rhs)
{
    *m_moveableDataInBase = *rhs.m_moveableDataInBase;
    m_pNonMoveableDataInBase = rhs.m_pNonMoveableDataInBase;
    return *this;
}

最小化堆调用不是过早的优化。它是工程学。这就是移动语义的组成部分。这正是为什么std::vectorstd::string 复制分配不使用复制/交换习语。太慢了。

移动构造函数:我会这样编码:

Base::Base(Base&& other) noexcept
    : m_moveableDataInBase(std::move(other.m_moveableDataInBase))
    , m_pNonMoveableDataInBase(std::move(other.m_pNonMoveableDataInBase))
{
    other.m_moveableDataInBase = nullptr;
}

这节省了一些负载和存储。我没有费心检查生成的程序集。我敦促您在选择实施之前这样做。在 noexcept 移动构造函数中,计算加载和存储。

作为风格指南,我喜欢 move 成员,即使我知道他们是标量并且移动没有影响。这使阅读代码的人不必确保所有未移动的成员都是标量。

我觉得你的移动任务很好:

Base&
Base::operator=(Base&& rhs) noexcept
{
    //move (steal) the moveable contents from rhs
    std::swap(m_moveableDataInBase, rhs.m_moveableDataInBase);
    //copy the non-moveable contents from rhs
    m_pNonMoveableDataInBase = rhs.m_pNonMoveableDataInBase;
    return *this;
}

您不想这样做的一次是当您在 lhs 上有需要立即销毁的非内存资源,而不是交换到 rhs 时。但是您的示例只是交换内存。

派生

对于Derived,我将完全按照我为Base 显示的那样编写它,除了首先完全按照您在代码中显示的那样复制/移动Base。例如这里是移动构造函数:

Derived::Derived(Derived&& other) noexcept
    : Base(std::move(other))
    , m_pMoveableDataInDerived(std::move(other.m_pMoveableDataInDerived))
    , m_pNonMoveableDataInDerived(std::move(other.m_pNonMoveableDataInDerived))
{
    other.m_pMoveableDataInDerived = nullptr;
}

同时使用override 标记~Dervied() 而不是virtual。您希望编译器告诉您是否不小心以某种方式未将 ~Base() 覆盖为 ~Derived()

class Derived : public Base
{
    BlobB* m_pMoveableDataInDerived = nullptr;
    int m_pNonMoveableDataInDerived = 456;

public:
    ~Derived() override;
    Derived() = default;
    Derived(const Derived& other);
    Derived& operator=(const Derived& rhs);
    Derived(Derived&& other) noexcept;
    Derived& operator=(Derived&& rhs) noexcept;
};

测试

同时使用 static_assert 和 type-traits 测试所有六个特殊成员(无论是否有):

static_assert(std::is_nothrow_destructible<Base>{}, "");
static_assert(std::is_nothrow_default_constructible<Base>{}, "");
static_assert(std::is_copy_constructible<Base>{}, "");
static_assert(std::is_copy_assignable<Base>{}, "");
static_assert(std::is_nothrow_move_constructible<Base>{}, "");
static_assert(std::is_nothrow_move_assignable<Base>{}, "");

static_assert(std::is_nothrow_destructible<Derived>{}, "");
static_assert(std::is_nothrow_default_constructible<Derived>{}, "");
static_assert(std::is_copy_constructible<Derived>{}, "");
static_assert(std::is_copy_assignable<Derived>{}, "");
static_assert(std::is_nothrow_move_constructible<Derived>{}, "");
static_assert(std::is_nothrow_move_assignable<Derived>{}, "");

您甚至可以为您的 Blob 类型测试这些:

static_assert(std::is_trivially_destructible<BlobA>{}, "");
static_assert(std::is_trivially_default_constructible<BlobA>{}, "");
static_assert(std::is_trivially_copy_constructible<BlobA>{}, "");
static_assert(std::is_trivially_copy_assignable<BlobA>{}, "");
static_assert(std::is_trivially_move_constructible<BlobA>{}, "");
static_assert(std::is_trivially_move_assignable<BlobA>{}, "");

static_assert(std::is_trivially_destructible<BlobB>{}, "");
static_assert(std::is_trivially_default_constructible<BlobB>{}, "");
static_assert(std::is_trivially_copy_constructible<BlobB>{}, "");
static_assert(std::is_trivially_copy_assignable<BlobB>{}, "");
static_assert(std::is_trivially_move_constructible<BlobB>{}, "");
static_assert(std::is_trivially_move_assignable<BlobB>{}, "");

总结

总而言之,给六位特殊成员每一个他们应得的关爱,即使结果是禁止他们、隐含地声明他们、或明确地默认或删除他们。编译器生成的移动成员将移动每个基,然后移动每个非静态数据成员。更喜欢那个配方,尽可能默认它,并在必要时简单地增加它。

通过将成员函数定义移出类声明来突出显示您的类 API。

测试。至少测试一下你是否拥有全部 6 个特殊成员,如果你拥有它们,它们是 noexcept 还是微不足道的(或不是)。

谨慎使用复制/交换。它可能会成为性能杀手。

【讨论】:

    【解决方案2】:

    无论如何,如果我们有一个类层次结构,这需要一个 明确的移动实现,我应该怎么做 - 至少作为第一个 切?

    不要。

    你不移动基类。您将指针移至基类。对于派生类,您可以移动它,但您知道派生类是什么,因此您可以相应地编写移动构造函数/赋值运算符。

    此外,原始指针是完全可移动的。你觉得unique_ptr是怎么实现的?

    【讨论】:

    • 感谢您的回复。如果我移动派生类,那么我需要移动派生类和基类中的资源。我没有指向基类的指针,它共享派生类的内存。例如导出d1;自动 d2(std::move(d1));默认的移动构造函数不会移动指针指向的内存,它只会复制指针。移动时 std::unique_ptr 将指向对方的内存并将对方的指针设置为 nullptr。
    • 这就是自定义移动构造函数的用途。您可以在移动构造函数中轻松地将另一个指针设置为 null。
    • 这就是我上面的尝试正在做的事情,也是我正在寻找的建议,尤其是在派生类和基类的上下文中。
    • 那我看不出是什么问题。您已经找到并实施了解决方案。在这种情况下,派生类和基类不会改变或真正重要。
    【解决方案3】:

    我会放弃对可读性没有帮助的“与其他交换”方法,而是使用简单的分配。

    class A{
       int * dataA;
       public:
       A() { dataA = new int(); }
       virtual ~A() {delete dataA; }
       A(A&& rhs) noexcept { dataA = rhs.dataA; rhs.dataA = nullptr; } 
       A& operator = (A&& rhs) noexcept {
          if (this != &rhs){
           if (dataA) delete  dataA; 
           dataA = rhs.dataA;
           rhs.dataA = nullptr;
          }
          return *this;
       } 
    }
    
    class B: public A{
       int* dataB;
       public:    
           B(){ dataB = new int(); }
           virtual ~B() {delete dataB; }
           B(B&& rhs) noexcept : A(std::move(rhs)) { dataB = rhs.dataB; rhs.dataB = nullptr; }  
    
       B& operator = (B&& rhs) noexcept {
          A::operator == (std::move(rhs));
          if (this != &rhs){           
          if (dataB) delete  dataB; 
          dataB = rhs.dataB;
          rhs.dataB = nullptr;
          }
          return *this;
       }
    }
    

    调用父移动构造函数来移动对象的父部分。在移动构造函数中完成其余的工作。

    赋值运算符也是如此。

    【讨论】:

    • 感谢大卫的反馈。我使用交换习语的原因是因为这是实现 operator= 的规范方式,并且现在有共享代码。它也适用于转向自我。例如,您的代码会中断: B d; d = std::move(d);您的实施中还有其他一些问题,但我认为它们只是拼写错误。
    • 无论如何,没有“Cannocal 方式”这样的东西。 “规范的方式”是浅拷贝 rhs 的内容并以 rhs 析构函数可以安全地破坏它的方式使 rhs 无效。谁告诉你“交换(其他)”是一种“规范”的方式?
    • 感谢您指出错字,尽管您也拼错了! ;-) 。在复制构造函数方面,交换是实现 operator= 的规范。我也冒昧地使用它来实现移动语义,但我不能 100% 确定这是最好的方法,这基本上是我寻求帮助的方法。
    猜你喜欢
    • 1970-01-01
    • 2014-06-09
    • 1970-01-01
    • 1970-01-01
    • 2011-11-25
    • 2019-06-04
    • 1970-01-01
    • 2017-02-27
    • 1970-01-01
    相关资源
    最近更新 更多