移动构造函数与移动赋值运算符

在 课程智能指针与移动语义简介中,我们回顾了 std::auto_ptr,讨论了移动语义的需求,并分析了当拷贝语义(拷贝构造函数与拷贝赋值运算符)被改写成移动语义时所引发的一系列问题。

本节将深入探讨 C++11 如何利用移动构造函数(move constructor)与移动赋值运算符(move assignment operator)解决上述难题。


回顾拷贝构造函数与拷贝赋值

首先,简要回顾拷贝语义

  • 拷贝构造函数用于以同类对象的副本初始化新对象。
  • 拷贝赋值运算符用于把一个已存在的对象复制给另一个已存在的对象。
  • 若程序员未显式提供,C++ 会默认生成上述函数,并执行浅拷贝;对于管理动态内存的类而言,这通常会导致问题。因此,此类类应重写这些函数,以执行深拷贝

回到本章首个示例中的 Auto_ptr 智能指针,我们给出一个实现了深拷贝的拷贝构造函数和拷贝赋值运算符的版本,并附示例程序:

#include <iostream>

template<typename T>
class Auto_ptr3
{
    T* m_ptr {};
public:
    Auto_ptr3(T* ptr = nullptr) : m_ptr{ ptr } {}

    ~Auto_ptr3() { delete m_ptr; }

    // 拷贝构造函数:深拷贝
    Auto_ptr3(const Auto_ptr3& a)
    {
        m_ptr = new T;
        *m_ptr = *a.m_ptr;
    }

    // 拷贝赋值运算符:深拷贝
    Auto_ptr3& operator=(const Auto_ptr3& a)
    {
        if (&a == this) return *this;

        delete m_ptr;

        m_ptr = new T;
        *m_ptr = *a.m_ptr;

        return *this;
    }

    T& operator*() const { return *m_ptr; }
    T* operator->() const { return m_ptr; }
    bool isNull() const { return m_ptr == nullptr; }
};

class Resource
{
public:
    Resource()  { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource destroyed\n"; }
};

Auto_ptr3<Resource> generateResource()
{
    Auto_ptr3<Resource> res{ new Resource };
    return res; // 按值返回,将调用拷贝构造函数
}

int main()
{
    Auto_ptr3<Resource> mainres;
    mainres = generateResource(); // 赋值将调用拷贝赋值运算符
    return 0;
}

运行程序,输出:

Resource acquired
Resource acquired
Resource destroyed
Resource acquired
Resource destroyed
Resource destroyed

(若编译器实施了返回值优化,可能仅出现 4 行输出。)

为何会如此频繁地创建与销毁?让我们逐步分析:

  1. generateResource() 中,局部变量 res 被创建并动态分配 Resource,出现第 1 条 “Resource acquired”。
  2. res 按值返回,触发拷贝构造函数,深拷贝产生新的 Resource,出现第 2 条 “Resource acquired”。
  3. res 离开作用域,销毁原 Resource,出现第 1 条 “Resource destroyed”。
  4. 临时对象通过拷贝赋值运算符赋给 mainres,再次深拷贝,出现第 3 条 “Resource acquired”。
  5. 临时对象销毁,出现第 2 条 “Resource destroyed”。
  6. mainres 离开作用域,最终 Resource 被销毁,出现第 3 条 “Resource destroyed”。

深拷贝两次,共创建并销毁 3 个独立对象,效率低下,但至少不会崩溃。

借助移动语义,我们可以做得更好。


移动构造函数与移动赋值运算符

C++11 引入了两类专为移动语义服务的特殊成员函数:

  • 移动构造函数:以右值实参初始化新对象时调用。
  • 移动赋值运算符:以右值实参赋值给已存在对象时调用。

与拷贝版本复制资源不同,移动版本转移资源所有权,通常成本极低。

它们的形参为非 const 右值引用T&&),仅绑定到右值。

以下示例在 Auto_ptr4 中同时保留深拷贝版本,以便对比:

#include <iostream>

template<typename T>
class Auto_ptr4
{
    T* m_ptr {};
public:
    Auto_ptr4(T* ptr = nullptr) : m_ptr{ ptr } {}
    ~Auto_ptr4() { delete m_ptr; }

    // 拷贝构造:深拷贝
    Auto_ptr4(const Auto_ptr4& a)
    {
        m_ptr = new T;
        *m_ptr = *a.m_ptr;
    }

    // 移动构造:转移所有权
    Auto_ptr4(Auto_ptr4&& a) noexcept
        : m_ptr{ a.m_ptr }
    {
        a.m_ptr = nullptr; // 使源对象为空
    }

    // 拷贝赋值:深拷贝
    Auto_ptr4& operator=(const Auto_ptr4& a)
    {
        if (this == &a) return *this;
        delete m_ptr;
        m_ptr = new T;
        *m_ptr = *a.m_ptr;
        return *this;
    }

    // 移动赋值:转移所有权
    Auto_ptr4& operator=(Auto_ptr4&& a) noexcept
    {
        if (this == &a) return *this;
        delete m_ptr;
        m_ptr = a.m_ptr;
        a.m_ptr = nullptr;
        return *this;
    }

    T& operator*() const { return *m_ptr; }
    T* operator->() const { return m_ptr; }
    bool isNull() const { return m_ptr == nullptr; }
};

class Resource
{
public:
    Resource()  { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource destroyed\n"; }
};

Auto_ptr4<Resource> generateResource()
{
    Auto_ptr4<Resource> res{ new Resource };
    return res; // 按值返回,将调用移动构造函数
}

int main()
{
    Auto_ptr4<Resource> mainres;
    mainres = generateResource(); // 赋值将调用移动赋值运算符
    return 0;
}

输出:

Resource acquired
Resource destroyed

流程与之前相同,但使用了移动版本:

  1. res 创建并分配 Resource,出现第 1 条 “Resource acquired”。
  2. 返回时调用移动构造函数res 把指针所有权交给临时对象。
  3. res 离开作用域,但已为空,无动作。
  4. 临时对象通过移动赋值运算符把所有权交给 mainres
  5. 临时对象销毁,但已为空,无动作。
  6. mainres 离开作用域,最终销毁 Resource

资源仅被构造与销毁一次,显著提高效率。


相关注意

  • 移动构造与移动赋值应标记为 noexcept,向编译器承诺不会抛异常。
    (课程 移动构造函数与移动赋值运算符.介绍 noexcept,课程 移动构造函数与移动赋值运算符 解释为何如此。)

何时调用移动构造/移动赋值?

  • 若已定义移动构造/移动赋值,且构造/赋值的实参为右值(通常是字面量或临时对象),则调用移动版本;
  • 否则(实参为左值,或虽为右值但未定义移动版本),调用拷贝版本。

隐式移动构造与移动赋值

若以下全部条件成立,编译器会隐式生成移动构造与移动赋值:

  • 无用户声明的拷贝构造、拷贝赋值、移动构造、移动赋值;
  • 无用户声明的析构函数。

它们执行逐成员移动

  • 若成员支持移动构造/移动赋值,则调用之;
  • 否则执行成员拷贝。

⚠️ 警告
隐式版本对指针成员只做浅拷贝,不会“移动”指针;如需真正移动指针,请自行定义。


移动语义的核心洞见

你已具备理解移动语义本质所需的全部背景:

  • 当构造或赋值实参为左值,我们只能安全地拷贝,因为后续程序可能继续使用该左值。
  • 当实参为右值(临时对象),我们已知它即将被销毁,无需再拷贝,直接转移资源即可,既安全又高效。

借助右值引用,C++11 允许根据实参值类别(左值/右值)提供不同行为,从而做出更明智、更高效的设计。

核心洞见
移动语义是一种优化机会


移动后务必使源对象处于有效状态

在上述示例中,移动函数都把 a.m_ptr 设为 nullptr。看似多余:临时对象反正会被销毁,何必清理?

原因简单:当 a 离开作用域,其析构函数会 delete a.m_ptr。若此时 a.m_ptrm_ptr 仍指向同一资源,则 m_ptr 成为悬空指针,后续使用或销毁将触发未定义行为。

因此,实现移动语义时,必须确保被移对象处于有效状态,能安全析构。


返回值优化与自动左值的移动

Auto_ptr4generateResource() 中,局部变量 res 按值返回,尽管它是左值,但 C++ 规范特别规定:
自动对象的按值返回可触发移动,而非拷贝,因为该对象即将销毁,直接“窃取”资源更合理。

编译器有时还能完全省略拷贝/移动(强制拷贝消除),此时既不调用拷贝构造,也不调用移动构造。


禁用拷贝

Auto_ptr4 中,我们保留拷贝版本以作对比。但在纯移动类中,通常应删除拷贝构造与拷贝赋值,禁止拷贝:

示例 Auto_ptr5(仅支持移动):

template<typename T>
class Auto_ptr5
{
    T* m_ptr{};
public:
    Auto_ptr5(T* ptr = nullptr) : m_ptr{ ptr } {}
    ~Auto_ptr5() { delete m_ptr; }

    Auto_ptr5(const Auto_ptr5& a) = delete;
    Auto_ptr5& operator=(const Auto_ptr5& a) = delete;

    Auto_ptr5(Auto_ptr5&& a) noexcept
        : m_ptr{ a.m_ptr } { a.m_ptr = nullptr; }

    Auto_ptr5& operator=(Auto_ptr5&& a) noexcept
    {
        if (this == &a) return *this;
        delete m_ptr;
        m_ptr = a.m_ptr;
        a.m_ptr = nullptr;
        return *this;
    }

    /* ... 其他接口 ... */
};

至此,Auto_ptr5 已是一个合格的智能指针。标准库中的 std::unique_ptr 设计与它极为相似。


另一个示例:动态数组

下面给出一个管理动态内存的简易模板数组类 DynamicArray,先展示深拷贝版本:

(代码略,见原文)

使用 Timer测试 100 万整数拷贝:

  • 深拷贝版本耗时约 0.00826 秒。
  • 替换为移动构造/移动赋值并禁用拷贝后,耗时约 0.0056 秒,快 32.1%

删除移动构造/移动赋值

与删除拷贝函数一样,可用 = delete 禁用移动版本:

Name(Name&& name) = delete;
Name& operator=(Name&& name) = delete;

⚠️ 注意
若仅删除移动构造/移动赋值,而保留默认拷贝,当强制拷贝消除不适用时,函数返回对象会优先选择已删除的移动构造,导致编译错误。因此,“五之法则” 建议:
若定义/删除拷贝构造、拷贝赋值、移动构造、移动赋值或析构函数中任意一个,其余也应显式定义或删除。


std::swap 的互操作(高级)

在 课程移动构造函数与移动赋值运算符 我们介绍过拷贝并交换惯用法。该惯用法同样可用于移动语义:通过交换资源,简化实现。

但若在移动构造/移动赋值中直接使用 std::swap,会引发无限递归,因为 std::swap 内部又会调用移动构造/移动赋值。

正确做法是提供自定义无递归交换

示例见原文,最终安全实现无栈溢出。


小结

  • 移动构造/移动赋值以右值引用为参数,转移而非复制资源。
  • 移动函数应置 noexcept,并确保源对象处于可析构的合法状态。
  • 标准库中 std::unique_ptr 与此处 Auto_ptr5 设计一致,应优先使用。
  • 合理运用移动语义,可显著减少不必要的深拷贝,提高性能。 当谈到“交换”时,首先浮现在脑海的通常是 std::swap()。然而,若直接利用 std::swap() 来实现移动构造函数和移动赋值运算符,会带来严重问题:
    std::swap() 内部会同时调用移动构造函数与移动赋值运算符,从而导致无限递归,最终耗尽栈空间。

以下示例演示了这一问题:

#include <iostream>
#include <string>
#include <string_view>

class Name
{
private:
    std::string m_name {}; // std::string 支持移动

public:
    Name(std::string_view name) : m_name{ name } {}

    Name(const Name& name) = delete;
    Name& operator=(const Name& name) = delete;

    Name(Name&& name) noexcept
    {
        std::cout << "Move ctor\n";
        std::swap(*this, name); // 危险!
    }

    Name& operator=(Name&& name) noexcept
    {
        std::cout << "Move assign\n";
        std::swap(*this, name); // 危险!
        return *this;
    }

    const std::string& get() const { return m_name; }
};

int main()
{
    Name n1{ "Alex" };
    n1 = Name{ "Joe" }; // 触发移动赋值
    std::cout << n1.get() << '\n';
    return 0;
}

运行结果:

Move assign
Move ctor
Move ctor
Move ctor
Move ctor
...

递归不断,直至栈溢出。

解决方案:自定义 swap

只要提供一个不调用移动构造/移动赋值的 swap 函数,即可安全实现。做法如下:

  • 在类内声明友元 swap,仅交换内部数据成员(而非整个对象)。
  • 在移动构造/移动赋值中调用该友元 swap,而非 std::swap

修正后的示例:

#include <iostream>
#include <string>
#include <string_view>

class Name
{
private:
    std::string m_name {};

public:
    Name(std::string_view name) : m_name{ name } {}

    Name(const Name& name) = delete;
    Name& operator=(const Name& name) = delete;

    // 自定义 swap 友元函数:仅交换数据成员
    friend void swap(Name& a, Name& b) noexcept
    {
        std::swap(a.m_name, b.m_name); // 只交换底层 std::string
    }

    Name(Name&& name) noexcept
    {
        std::cout << "Move ctor\n";
        swap(*this, name); // 调用自定义 swap,无递归
    }

    Name& operator=(Name&& name) noexcept
    {
        std::cout << "Move assign\n";
        swap(*this, name); // 调用自定义 swap,无递归
        return *this;
    }

    const std::string& get() const { return m_name; }
};

int main()
{
    Name n1{ "Alex" };
    n1 = Name{ "Joe" }; // 触发移动赋值
    std::cout << n1.get() << '\n';
    return 0;
}

输出:

Move assign
Joe

程序运行正常,不再出现无限递归。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"