22-3 移动构造函数与移动赋值

第22.1课——智能指针与移动语义介绍中,我们探讨了std::auto_ptr的用法,讨论了移动语义的需求,并分析了将为复制语义设计的函数(复制构造函数和复制赋值运算符)重定义为实现移动语义时可能出现的弊端。

本节课我们将深入探讨C++11如何通过移动构造函数和移动赋值运算符来解决这些问题。


回顾复制构造函数与复制赋值

首先,让我们花点时间回顾复制语义。

复制构造函数用于通过复制同类对象来初始化类。复制赋值则用于将一个类对象复制到另一个已存在的类对象中。默认情况下,若未显式提供,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;
	}

	// Copy constructor
	// Do deep copy of a.m_ptr to m_ptr
	Auto_ptr3(const Auto_ptr3& a)
	{
		m_ptr = new T;
		*m_ptr = *a.m_ptr;
	}

	// Copy assignment
	// Do deep copy of a.m_ptr to m_ptr
	Auto_ptr3& operator=(const Auto_ptr3& a)
	{
		// Self-assignment detection
		if (&a == this)
			return *this;

		// Release any resource we're holding
		delete m_ptr;

		// Copy the resource
		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; // this return value will invoke the copy constructor
}

int main()
{
	Auto_ptr3<Resource> mainres;
	mainres = generateResource(); // this assignment will invoke the copy assignment

	return 0;
}

在此程序中,我们使用名为 generateResource() 的函数创建一个智能指针封装的资源,该资源随后被传递回 main() 函数。main() 函数将该资源赋值给一个现有的 Auto_ptr3 对象。

运行此程序时,它会输出:

image

没有优化的(此为原文版本)如下:
image

(注:若编译器省略了函数 generateResource() 的返回值,您可能仅获得 4 条输出)

如此简单的程序竟涉及大量资源创建与销毁操作!究竟发生了什么?

让我们深入分析。程序中发生了6个关键步骤(对应每条打印信息):

  1. 在generateResource()内部,创建局部变量res并用动态分配的Resource初始化,触发首次“Resource acquired”提示。
  2. res通过值传递返回至main()。此处采用值返回,因为 res 是局部变量——无法通过地址或引用返回,否则 generateResource() 结束时 res 将被销毁。因此 res 被复制构造为临时对象。由于我们的复制构造函数执行深拷贝,此处分配了新的 Resource,触发第二次“Resource acquired”。
  3. res 超出作用域导致原始创建的 Resource 被销毁,触发第一次“Resource destroyed”。
  4. 临时对象通过复制赋值被赋给mainres。由于复制赋值同样执行深拷贝,故分配了新的Resource对象,触发第三次“Resource acquired”提示。
  5. 赋值表达式执行完毕,临时对象脱离表达式作用域被销毁,触发“Resource destroyed”提示。
  6. main()结束时,mainres脱离作用域,最终显示“Resource destroyed”提示。

简而言之,由于我们调用复制构造函数将res复制到临时对象,又通过复制赋值将临时对象复制到mainres,最终共分配并销毁了3个独立对象。

效率低下,但至少不会崩溃!

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


移动构造函数与移动赋值

C++11 为移动语义定义了两个新函数:移动构造函数和移动赋值运算符。复制构造函数与复制赋值的目标是将一个对象复制到另一个对象,而移动构造函数与移动赋值的目标则是将资源的所有权从一个对象转移到另一个对象(通常比复制操作消耗更少资源)。

定义移动构造函数和移动赋值操作符与复制构造函数及复制赋值操作符类似。但复制版本接受常量左值引用参数(可绑定至任意类型),而移动版本则使用非常量右值引用参数(仅能绑定右值)。

下文展示了与前文相同的Auto_ptr3类,新增了移动构造函数和移动赋值运算符。为便于对比,我们保留了深拷贝的复制构造函数和复制赋值运算符。

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

	// Copy constructor
	// Do deep copy of a.m_ptr to m_ptr
	Auto_ptr4(const Auto_ptr4& a)
	{
		m_ptr = new T;
		*m_ptr = *a.m_ptr;
	}

	// Move constructor
	// Transfer ownership of a.m_ptr to m_ptr
	Auto_ptr4(Auto_ptr4&& a) noexcept
		: m_ptr { a.m_ptr }
	{
		a.m_ptr = nullptr; // we'll talk more about this line below
	}

	// Copy assignment
	// Do deep copy of a.m_ptr to m_ptr
	Auto_ptr4& operator=(const Auto_ptr4& a)
	{
		// Self-assignment detection
		if (&a == this)
			return *this;

		// Release any resource we're holding
		delete m_ptr;

		// Copy the resource
		m_ptr = new T;
		*m_ptr = *a.m_ptr;

		return *this;
	}

	// Move assignment
	// Transfer ownership of a.m_ptr to m_ptr
	Auto_ptr4& operator=(Auto_ptr4&& a) noexcept
	{
		// Self-assignment detection
		if (&a == this)
			return *this;

		// Release any resource we're holding
		delete m_ptr;

		// Transfer ownership of a.m_ptr to m_ptr
		m_ptr = a.m_ptr;
		a.m_ptr = nullptr; // we'll talk more about this line below

		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; // this return value will invoke the move constructor
}

int main()
{
	Auto_ptr4<Resource> mainres;
	mainres = generateResource(); // this assignment will invoke the move assignment

	return 0;
}

移动构造函数和移动赋值运算符的实现很简单。我们不将源对象(a)深度复制到目标对象(隐式对象),而是直接移动(窃取)源对象的资源。具体操作是将源指针浅拷贝到隐式对象,然后将源指针置为空。

运行时,该程序输出:

image

这样就好多了!

程序流程与之前完全相同。但此处调用的是移动构造函数和移动赋值运算符,而非复制构造函数和复制赋值运算符。深入分析如下:

  1. 在 generateResource() 内部,创建局部变量 res 并用动态分配的 Resource 初始化,由此产生首次“Resource acquired”提示。
  2. res以值传递方式返回至main()。res通过移动构造被转换为临时对象,将res存储的动态创建对象转移至临时对象。具体原因将在下文说明。
  3. res超出作用域。由于res不再管理指针(已移至临时对象),此处无特殊情况发生。
  4. 临时对象通过移动赋值赋给mainres。这将临时对象中存储的动态创建对象转移至mainres。
  5. 赋值表达式结束,临时对象脱离表达式作用域并被销毁。但由于临时对象不再管理指针(已移交mainres),此处同样无特殊情况发生。
  6. 在 main() 结束时,mainres 超出作用域,最终显示“Resource destroyed”。

因此我们并非复制 Resource 两次(一次用于复制构造函数,一次用于复制赋值),而是进行两次转移。这种方式更高效,因为 Resource 仅被构造和销毁一次,而非三次。

相关内容
移动构造函数和移动赋值操作应标记为noexcept。这告知编译器这些函数不会抛出异常。

我们在第27.9节——异常规范与noexcept中介绍了noexcept,并在第27.10节——std::move_if_noexcept中讨论了为何移动构造函数和移动赋值操作会被标记为noexcept。


移动构造函数和移动赋值运算符在何时被调用?

当移动构造函数和移动赋值运算符已被定义,且构造或赋值的参数为右值时,它们会被调用。最常见的情况是该右值为字面量或临时值。

在其他情况下(当参数为左值,或参数为右值但未定义移动构造函数或移动赋值运算符时),则使用复制构造函数和复制赋值运算符。


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

当以下所有条件均成立时,编译器将生成隐式移动构造函数与移动赋值运算符:

  • 不存在用户声明的复制构造函数或复制赋值运算符
  • 不存在用户声明的移动构造函数或移动赋值运算符
  • 不存在用户声明的析构函数

这些函数执行成员级移动操作,其行为如下:

  • 若成员拥有移动构造函数或移动赋值运算符(视情况而定),则会调用该构造函数或运算符。
  • 否则,该成员将被复制。

需特别注意:这意味着指针将被复制而非移动!

警告:
隐式移动构造函数和移动赋值运算符会复制指针而非移动指针。若需移动指针成员,必须自行定义移动构造函数和移动赋值运算符。


移动语义背后的关键洞见

现在你已具备足够的背景知识来理解移动语义背后的关键洞见。

当我们构造对象或进行赋值操作时,若参数是左值,唯一合理的方式就是复制该左值。我们不能假设修改左值是安全的,因为它可能在程序后续被再次使用。例如表达式“a = b”(其中b是左值)中,我们合理地预期b不会被以任何方式改变。

然而,当构造对象或执行赋值时,若参数为右值,则该右值本质上是某种临时对象。此时无需进行耗时的复制操作,只需将资源(成本低廉)转移至目标对象即可。这种操作是安全的,因为临时对象在表达式结束时必然被销毁,确保其资源绝不会被重复使用!

C++11通过右值引用机制,使我们能够根据参数是右值还是左值提供不同行为,从而更智能高效地决定对象的行为方式。

核心洞见:
移动语义是优化机会。


移动函数应始终使两个对象保持有效状态

在上例中,移动构造函数和移动赋值函数均将 a.m_ptr 设为 nullptr。这看似多余——毕竟若 a 是临时右值,参数 a 终将被销毁,何必进行“清理”?

答案很简单:当 a 脱离作用域时,其析构函数会被调用,a.m_ptr 将被删除。若此时 a.m_ptr 仍指向与 m_ptr 相同的对象,则 m_ptr 将成为悬空指针。当包含 m_ptr 的对象最终被使用(或销毁)时,将引发未定义行为。

实现移动语义时,必须确保被移动对象保持有效状态,从而保证其能正确销毁(避免产生未定义行为)。


自动生成的左值通过值返回时可能被移动而非复制

在上文Auto_ptr4示例的generateResource()函数中,当变量res通过值返回时,即使res是左值,它也会被移动而非复制。C++规范中有项特殊规则:函数通过值返回的自动对象即使是左值,也可能被移动。这完全合理,因为res在函数结束时本就会被销毁!与其进行昂贵且多余的复制,不如直接窃取其资源。

虽然编译器可以移动左值返回值,但在某些情况下,它甚至能通过完全省略复制操作(从而完全避免复制或移动)实现更优处理。此时既不会调用复制构造函数,也不会调用移动构造函数。


禁用复制

在上面的Auto_ptr4类中,我们保留了复制构造函数和赋值运算符以供比较之用。但在启用移动语义的类中,有时需要删除复制构造函数和复制赋值函数以确保不会创建副本。对于我们的Auto_ptr类而言,我们不希望复制模板对象T——既因为操作成本高昂,也因为类T本身可能根本不支持复制!

以下是支持移动语义但禁用复制语义的Auto_ptr实现版本:

#include <iostream>

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

	~Auto_ptr5()
	{
		delete m_ptr;
	}

	// Copy constructor -- no copying allowed!
	Auto_ptr5(const Auto_ptr5& a) = delete;

	// Move constructor
	// Transfer ownership of a.m_ptr to m_ptr
	Auto_ptr5(Auto_ptr5&& a) noexcept
		: m_ptr { a.m_ptr }
	{
		a.m_ptr = nullptr;
	}

	// Copy assignment -- no copying allowed!
	Auto_ptr5& operator=(const Auto_ptr5& a) = delete;

	// Move assignment
	// Transfer ownership of a.m_ptr to m_ptr
	Auto_ptr5& operator=(Auto_ptr5&& a) noexcept
	{
		// Self-assignment detection
		if (&a == this)
			return *this;

		// Release any resource we're holding
		delete m_ptr;

		// Transfer ownership of a.m_ptr to 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; }
};

image

若试图将 Auto_ptr5 左值按值传递给函数,编译器会报错指出:初始化函数参数所需的复制构造函数已被删除。这恰恰是正确的行为,因为我们本就应该通过 const 左值引用传递 Auto_ptr5!

Auto_ptr5(终于)成为了一个优秀的智能指针类。事实上,标准库中已包含一个与此高度相似的类(你应当优先使用它),名为std::unique_ptr。本章后续内容将深入探讨std::unique_ptr。


另一个示例

让我们看看另一个使用动态内存的类:一个简单的动态模板数组。该类包含一个深度复制的构造函数和复制赋值运算符。

#include <cstddef> // for std::size_t

template <typename T>
class DynamicArray
{
private:
	T* m_array {};
	int m_length {};

	void alloc(int length)
	{
		m_array = new T[static_cast<std::size_t>(length)];
        	m_length = length;
	}
public:
	DynamicArray(int length)
	{
		alloc(length);
	}

	~DynamicArray()
	{
		delete[] m_array;
	}

	// Copy constructor
	DynamicArray(const DynamicArray &arr)
	{
		alloc(arr.m_length);
		std::copy_n(arr.m_array, m_length, m_array); // copy m_length elements from arr to m_array
	}

	// Copy assignment
	DynamicArray& operator=(const DynamicArray &arr)
	{
		if (&arr == this)
			return *this;

		delete[] m_array;

		alloc(arr.m_length);

		std::copy_n(arr.m_array, m_length, m_array); // copy m_length elements from arr to m_array

		return *this;
	}

	int getLength() const { return m_length; }
	T& operator[](int index) { return m_array[index]; }
	const T& operator[](int index) const { return m_array[index]; }
};

现在让我们在程序中使用这个类。为了展示当我们在堆上分配一百万个整数时该类的表现,我们将借助第18.4课——代码计时 中开发的Timer类。我们将使用Timer类来计时代码的运行速度,并向您展示复制与移动操作之间的性能差异。

#include <algorithm> // for std::copy_n
#include <chrono> // for std::chrono functions
#include <iostream>

// Uses the above DynamicArray class

class Timer
{
private:
	// Type aliases to make accessing nested type easier
	using Clock = std::chrono::high_resolution_clock;
	using Second = std::chrono::duration<double, std::ratio<1> >;

	std::chrono::time_point<Clock> m_beg { Clock::now() };

public:
	void reset()
	{
		m_beg = Clock::now();
	}

	double elapsed() const
	{
		return std::chrono::duration_cast<Second>(Clock::now() - m_beg).count();
	}
};

// Return a copy of arr with all of the values doubled
DynamicArray<int> cloneArrayAndDouble(const DynamicArray<int> &arr)
{
	DynamicArray<int> dbl(arr.getLength());
	for (int i = 0; i < arr.getLength(); ++i)
		dbl[i] = arr[i] * 2;

	return dbl;
}

int main()
{
	Timer t;

	DynamicArray<int> arr(1000000);

	for (int i = 0; i < arr.getLength(); i++)
		arr[i] = i;

	arr = cloneArrayAndDouble(arr);

	std::cout << t.elapsed();
}

image

在作者的一台机器上,该程序在发布模式下以0.00825559秒的速度执行完毕。

现在让我们通过用移动构造函数和移动赋值替换复制构造函数和复制赋值来更新DynamicArray,然后再次运行程序:

#include <cstddef> // for std::size_t

template <typename T>
class DynamicArray
{
private:
	T* m_array {};
	int m_length {};

	void alloc(int length)
	{
		m_array = new T[static_cast<std::size_t>(length)];
		m_length = length;
	}
public:
	DynamicArray(int length)
	{
		alloc(length);
	}

	~DynamicArray()
	{
		delete[] m_array;
	}

	// Copy constructor
	DynamicArray(const DynamicArray &arr) = delete;

	// Copy assignment
	DynamicArray& operator=(const DynamicArray &arr) = delete;

	// Move constructor
	DynamicArray(DynamicArray &&arr) noexcept
		:  m_array { arr.m_array }, m_length { arr.m_length }
	{
		arr.m_length = 0;
		arr.m_array = nullptr;
	}

	// Move assignment
	DynamicArray& operator=(DynamicArray &&arr) noexcept
	{
		if (&arr == this)
			return *this;

		delete[] m_array;

		m_length = arr.m_length;
		m_array = arr.m_array;
		arr.m_length = 0;
		arr.m_array = nullptr;

		return *this;
	}

	int getLength() const { return m_length; }
	T& operator[](int index) { return m_array[index]; }
	const T& operator[](int index) const { return m_array[index]; }
};

#include <iostream>
#include <chrono> // for std::chrono functions

class Timer
{
private:
	// Type aliases to make accessing nested type easier
	using Clock = std::chrono::high_resolution_clock;
	using Second = std::chrono::duration<double, std::ratio<1> >;

	std::chrono::time_point<Clock> m_beg { Clock::now() };

public:
	void reset()
	{
		m_beg = Clock::now();
	}

	double elapsed() const
	{
		return std::chrono::duration_cast<Second>(Clock::now() - m_beg).count();
	}
};

// Return a copy of arr with all of the values doubled
DynamicArray<int> cloneArrayAndDouble(const DynamicArray<int> &arr)
{
	DynamicArray<int> dbl(arr.getLength());
	for (int i = 0; i < arr.getLength(); ++i)
		dbl[i] = arr[i] * 2;

	return dbl;
}

int main()
{
	Timer t;

	DynamicArray<int> arr(1000000);

	for (int i = 0; i < arr.getLength(); i++)
		arr[i] = i;

	arr = cloneArrayAndDouble(arr);

	std::cout << t.elapsed();
}

image

在同一台机器上,该程序的执行耗时为0.0056秒。

比较两个程序的运行时间,(0.00825559 - 0.0056) / 0.00825559 * 100 = 32.1%!


删除移动构造函数和移动赋值

您可以使用 = delete 语法删除移动构造函数和移动赋值,其方式与删除复制构造函数和复制赋值完全相同。

#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;
    Name(Name&& name) = delete;
    Name& operator=(Name&& name) = delete;

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

int main()
{
    Name n1{ "Alex" };
    n1 = Name{ "Joe" }; // error: move assignment deleted

    std::cout << n1.get() << '\n';

    return 0;
}

image

若删除复制构造函数,编译器将不会生成隐式移动构造函数(导致对象既不可复制也不可移动)。因此在删除复制构造函数时,需明确说明对移动构造函数的行为期望:要么显式删除它们(明确表示这是预期行为),要么将其设为默认构造函数(使类成为仅支持移动构造的类型)。

关键要点
五条规则rule of five指出:若复制构造函数、复制赋值、移动构造函数、移动赋值或析构函数被定义或删除,则这些函数都应统一处理。

虽然仅删除移动构造函数和移动赋值看似能实现可复制但不可移动的对象,但这会导致在强制复制省略不适用的场景下,该类无法通过值返回。这是因为被删除的移动构造函数仍属于已声明函数,因此仍参与重载解析。而按值返回时,系统会优先选择被删除的移动构造函数而非未删除的复制构造函数。下面的程序演示了这一现象:

#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) = default;
    Name& operator=(const Name& name) = default;

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

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

Name getJoe()
{
    Name joe{ "Joe" };
    return joe; // error: Move constructor was deleted
}

int main()
{
    Name n{ getJoe() };

    std::cout << n.get() << '\n';

    return 0;
}

image

移动语义与std::swap的高级问题

在第21.12节——赋值运算符重载中,我们提到了复制交换模式。复制交换同样适用于移动语义,这意味着我们可以通过与即将被销毁的对象交换资源来实现移动构造函数和移动赋值。

此方法具有双重优势:

持久对象现可控制原先由即将销毁对象持有的资源(这正是我们的首要目标)。

即将销毁对象现可控制原先由持久对象持有的资源。当该对象实际销毁时,可对这些资源执行任何必要的清理操作。

提及交换操作时,人们通常首先想到std::swap()。但使用该函数实现移动构造函数和移动赋值会引发问题:对于支持移动的对象,std::swap()会同时调用移动构造函数和移动赋值操作,导致无限递归。

以下示例可直观展示该问题:

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

class Name
{
private:
    std::string m_name {}; // std::string is move capable

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); // bad!
    }

    Name& operator=(Name&& name) noexcept
    {
        std::cout << "Move assign\n";

        std::swap(*this, name); // bad!

        return *this;
    }

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

int main()
{
    Name n1{ "Alex" };
    n1 = Name{"Joe"}; // invokes move assignment

    std::cout << n1.get() << '\n';

    return 0;
}

这将输出:

image

如此这般……直到栈溢出。

你可以使用自定义的交换函数来实现移动构造函数和移动赋值操作,前提是你的交换成员函数不调用移动构造函数或移动赋值操作。以下是一个实现示例:

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

    // Create our own swap friend function to swap the members of Name
    friend void swap(Name& a, Name& b) noexcept
    {
        // We avoid recursive calls by invoking std::swap on the std::string member,
        // not on Name
        std::swap(a.m_name, b.m_name);
    }

    Name(Name&& name) noexcept
    {
        std::cout << "Move ctor\n";

        swap(*this, name); // Now calling our swap, not std::swap
    }

    Name& operator=(Name&& name) noexcept
    {
        std::cout << "Move assign\n";

        swap(*this, name); // Now calling our swap, not std::swap

        return *this;
    }

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

int main()
{
    Name n1{ "Alex" };
    n1 = Name{"Joe"}; // invokes move assignment

    std::cout << n1.get() << '\n';

    return 0;
}

这按预期运行,并输出:

image

posted @ 2026-01-27 23:10  游翔  阅读(0)  评论(0)    收藏  举报