C++学习笔记--移动语义和右值引用:现代C++基础

文章目录

  • 前言:
  • 2.1、值类别
    • 2.1.1、左值和纯右值的对比
    • 2.1.2、亡值
    • 2.1.3、可以“移动”的对象
    • 2.1.4、何时使用std::move转换
    • 2.1.5、值类型总结
  • 2.2、右值引用
  • 2.3、充分利用移动语义
  • 2.4、this指针类型

前言:

接上文
接下来将介绍在C++11中的移动语义、值类别和右值引用。

2.1、值类别

  • 广义左值(glvalues):具有标识的表达式;也就是说,可以确定两个表达式是否引用相同的基础实体。
  • 右值(rvalues):可以被“移动”的表达式。
    这两个类别结合起来:
  • 左值(lvalues):具有标识且不能被移动。
  • 亡值(xvalues):具有标识且可以被移动。
  • 纯右值(prvalues):没有标识且可以被移动。
  • 未使用的表达式类别:没有标识且不能被“移动”的表达式。

2.1.1、左值和纯右值的对比

为了进行演示,我们将对比左值纯右值

int x = 30, y = 30;
assert(&x != &y);

变量名x和y是左值。值得注意的是,任何变量、函数、模板参数对象或数据成员的名称都是左值
变量 x 和 y 是不同的实体,我们可以使用断言(第2行)来验证这一事实。
整数常量是纯右值,这两个常量具有相同的含义,但讨论它们是否是一个实体或多个实体是没有意义的。

int x = 30;
assert(&[](int&v) -> int& { return v; }(x) == &x);

重要的是,表达式的复杂程度不重要。只要保持标识,该表达式就是左值。因此,在这里,内联 lambda 调用是一个左值表达式。我们还可以通过断言验证调用结果和名称 x 引用了相同的实体。然而,lambda 本身没有标识;因此,它是一个纯右值。
左值和纯右值的另一个典型示例是函数调用和操作表达式,它们会产生引用(对于左值)或非引用(对于纯右值)。

2.1.2、亡值

讨论最后一个类别是亡值(xvalues),它们是被标记为即将过期的广义左值(亡值=即将过期的值)。

void consume_name(std::string name);
int main() {
	std::string name = "Tom";
	consume_name(std::move(name));
	name = "Jerry";
	std::cout << name << "\n";
}

在这里,我们通过使用 std::move 转换(第3行)手动将名称标记为即将过期。其结果是(通常情况下)唯一有效的操作是覆盖其状态(第5行)。
由于具体的行为可以通过重载移动构造函数和移动赋值运算符来控制,一些类提供了额外的保证:

void consume_element(std::unique_ptr<int> el);

int main() {
	std::unique_ptr<int> el = std::make_unique<int>(30);
	consume_element(std::move(el));
	assert(el == nullptr);
}

已过期的 unique_ptr 一定是 nullptr。

2.1.3、可以“移动”的对象

更详细地谈谈右值和“移动”的含义。
在 C++11 之前,当我们想要从现有值中创建一个新值时,唯一的选项是复制构造。然而,考虑到当我们有一个亡值时,源值的内容注定会过期。
在许多情况下,进行复制会效率低下,因为我们可以利用源值的内容。
同样,纯右值也可以被利用,因为它们在当前表达式之后就不再存在。

void manual_swap(std::string& left, std::string& right) {
	std::string tmp(std::move(left));
	left = std::move(right);
	right = std::move(tmp);
}

std::string person = "I'm a person";
std::string animal = "I'm an animal";
manual_swap(person, animal);
// person == "I'm an animal", animal == "I'm a person"

在这个示例中,我们利用了移动构造(第2行)和移动赋值(第3、4行),快速交换了两个字符串(通常只涉及重新分配三个 64 位值,而不涉及内存分配)。

2.1.4、何时使用std::move转换

最后,讨论一下在代码中何时应该使用 std::move 转换。幸运的是,这很简单:
在将左值传递给函数调用(或操作表达式)时,且底层实体的状态不再需要时,使用 std::move 转换。

void some_function(std::string name);

std::string my_func() {
	std::string name = "John Doe";
	some_function(std::move(name));
	// OK, we no longer need the state

	std::string label = "100% Orange Juice";
	name = std::move(label);
	// OK, syntax sugar for name.operator=(std::move(label));

	label = std::move(std::string("Hello World!"));
	// BAD, std::string("Hello World!") is a prvalue

	return name; // OK, no cast here
}

如果在与旧版(或设计不良)接口交互时,过于刻意地遵循这个规则可能会降低性能。然而,这是一个不错的基准。在其他情况下使用 std::move 转换,特别是在纯右值或返回表达式中使用,将阻止编译器的优化,应该避免这样做。

2.1.5、值类型总结

需要记住的主要要点:

  • 左值表达式具有标识

    值得注意的是,名称表达式是左值,以及任何导致引用命名实体的复合表达式也是左值

  • 纯右值没有标识

    所有字面值都是纯右值(字符串字面值除外,它们是左值),以及表示临时值的表达式也是纯右值

  • 亡值是将左值表达式标记为即将过期的结果

    当不再需要底层变量的内容时,在左值表达式上使用移动转换

2.2、右值引用

为了编写能够充分利用移动语义的代码,我们需要讨论另一方面的内容:右值引用
首先,让我们看一看在使用 C++11 之前的引用和常量引用重载方式时,调用是如何解析的:

void accepts_int(int& v) {
	std::cout << "Calling by reference: " << v << "\n";
}

void accepts_int(const int& v) {
	std::cout << "Calling by const-reference: " << v << "\n";
}

accepts_int(10); // Call by const-reference

int x = 15;
accepts_int(x); // Call by reference
accepts_int(std::move(x)); // Call by const-reference

const int y = 5;
accepts_int(y); // Call by const-reference

正如您所看到的,纯右值会绑定到常量引用(第9行),可修改的左值绑定到引用(第12行),亡值绑定到常量引用(第13行),不可修改的左值也绑定到常量引用(第16行)。
如果我们添加一个接受右值引用的第三个重载,情况将会发生改变:

void accepts_int(int& v) {
	std::cout << "Calling by reference: " << v << "\n";
}

void accepts_int(const int& v) {
	std::cout << "Calling by const-reference: " << v << "\n";
}

void accepts_int(int&& v) {
	std::cout << "Calling by rvalue reference: " << v << "\n";
}

accepts_int(10); // Call by rvalue reference

int x = 15;
accepts_int(x); // Call by reference
accepts_int(std::move(x)); // Call by rvalue reference

const int y = 5;
accepts_int(y); // Call by const-reference

规则如下:

  • 右值(纯右值和亡值)会绑定到常量引用或右值引用,但更倾向于右值引用
  • 可修改的左值会绑定到常量引用或引用,但更倾向于引用
  • 不可修改的左值只会绑定到常量引用

以下示例可能会让您感到困惑:

void accepts_int(int& v) {
	std::cout << "Calling by reference: " << v << "\n";
}

void accepts_int(int&& v) {
	std::cout << "Calling by rvalue reference: " << v << "\n";
}

accepts_int(10); // Calling by rvalue reference

int&& x = 10; // OK, prvalues bind to rvalue references
accepts_int(x); // Calling by reference

当我们使用 x 调用 accepts_int 时,它会解析为通过引用的调用,尽管 x 的类型是 int 的右值引用。要理解为什么会这样,我们需要回到本文的第一部分。请记住,任何具有标识的表达式和任何名称表达式都是左值。因此,在这里,x 是一个左值,它将绑定到一个引用。
当我们编写int&& x = 10 时,我们将一个纯右值(常量 10)赋予了一个名称和寿命。因此,对于函数来说,int x = 10;和 int&& x = 10; 之间没有区别。值得注意的是,它们都是寿命超过函数调用的可变整数变量。

2.3、充分利用移动语义

到目前为止,我们一直在处理合成的示例,但现在是时候涵盖移动语义的典型用例,即为您的类实现移动语义。
假设您的类正在实现自定义资源管理。在这种情况下,您可以通过在典型的复制构造函数、复制赋值运算符和析构函数之上实现移动构造函数和移动赋值运算符来充分利用移动语义。
以下是一个具有移动语义的简单堆栈实现示例:

class Stack {
	public:
		Stack() : data_(nullptr), size_(0), capacity_(0) {}
		Stack(const Stack& other) : data_(new int[other.capacity_]),
		size_(other.size_), capacity_(other.capacity_) {
		std::copy(other.data_, other.data_ + other.size_, data_);
	}

	~Stack() { delete[] data_; }

	Stack& operator=(const Stack& other) {
		if (this == &other)
		return *this;

		int* buff = data_;
		data_ = new int[other.capacity_];
		std::copy(other.data_, other.data_ + other.size_, data_);
		size_ = other.size_;
		capacity_ = other.capacity_;
		delete[] buff;
		return *this;
	}

	Stack(Stack&& other) : data_(std::exchange(other.data_, nullptr)),
	size_(std::exchange(other.size_, 0)),
	capacity_(std::exchange(other.capacity_, 0)) {}

	Stack& operator=(Stack&& other) {
		if (this == &other)
		return *this;

		delete[] data_;
		data_ = std::exchange(other.data_, nullptr);
		size_ = std::exchange(other.size_, 0);
		capacity_ = std::exchange(other.capacity_, 0);
		return *this;
	}

	void push(int value) {
		if (size_ == capacity_) {
			size_t new_cap = std::max(capacity_*2, UINTMAX_C(64));
			int* buff = new int[new_cap];
			std::copy(data_, data_ + size_, buff);
			delete[] data_;
			data_ = buff;
			capacity_ = new_cap;
		}

		data_[size_] = value;
		++size_;
	}

	int pop() {
		if (empty())
		throw std::runtime_error("Can't pop empty stack.");
		--size_;
		return data_[size_];
	}

	int peek() {
		if (empty())
		throw std::runtime_error("Can't peek into empty stack.");
		return data_[size_-1];
	}

	bool empty() {
		return size_ == 0;
	}

private:
	int *data_;
	size_t size_;
	size_t capacity_;
};

我们正在利用C++14的std::exchange,它将x = other.x; other.x = value; 这两个步骤的过程缩短为一条语句。当您对比复制构造函数(第5行)与移动构造函数(第25行),以及复制赋值运算符(第12行)与移动赋值运算符(第29行)时,您可以看到制作副本和利用其他实例的内容之间的区别。

如果您的类没有实现自定义资源管理,那么您可能可以坚守“零规则”:

struct MyStruct {
	std::string label;
	std::vector<int> data;
};

class MyClass {
	public:
		MyClass() : label_("default"), data_{1,2,3,4,5} {}
	private:
		std::string label_;
		std::vector<int> data_;
};

MyStruct x{"default", {1, 2, 3, 4, 5}};
MyClass y;

只要您不声明任何自定义的复制构造函数、移动构造函数、复制赋值运算符或析构函数,所有这些都将由编译器提供。不过需要注意的是,默认实现将进行简单的逐段复制/移动,这仅适用于不实现手动资源管理的类型。

移动语义还解锁了实现仅可移动类型(move-only types)的潜力。仅可移动类型对于唯一资源句柄非常有用,例如 unique_ptr。

struct MoveOnly {
	MoveOnly() = default;
	MoveOnly(MoveOnly&&) = default;
	MoveOnly& operator=(MoveOnly&&) = default;
};

MoveOnly a;
MoveOnly b;
// b = a; Would not compile
b = std::move(a); // OK, xvalue
a = MoveOnly{}; // OK, prvalue
// MoveOnly c(b); Would not compile
MoveOnly c(std::move(b)); // OK, xvalue

声明移动构造函数或移动赋值运算符(即使将其声明为默认)会禁用默认的复制构造函数和复制赋值运算符。声明移动构造函数还会移除默认的默认构造函数(因此,我们在第2行重新设置为默认)。

2.4、this指针类型

最后,在C++11之前,我们可以根据实例是常量还是可变的来重载方法。在C++11中,我们还可以根据实例是右值还是左值进一步重载:

struct Demo {
	void whoami() & { std::cout << "I'm a modifiable lvalue.\n"; }
	void whoami() const& { std::cout << "I'm a non-modifiable lvalue.\n"; }
	void whoami() && { std::cout << "I'm an r-value.\n"; }
};

Demo i;
i.whoami(); // modifiable

const Demo j;
j.whoami(); // non-modifiable

Demo{}.whoami(); // r-value

待续

你可能感兴趣的:(C++,个人笔记,学习笔记,c++,学习,笔记)