xml
hello,这里是AuroraWanderll。
兴趣方向:C++,算法,Linux系统,游戏客户端开发
欢迎关注,我将更新更多相关内容!
这是系列的第二篇文章,上篇指引:C++11:初始化列表,STL新增,范围for,新增关键字auto...
C++11核心突破:右值引用与移动语义(上)
本篇博客涵盖的要点:
- 左值引用和右值引用的概念
- 左值与右值的区别
- 左值引用与右值引用的比较
- 右值引用使用场景和意义
- 移动构造函数和移动赋值运算符
- 移动语义的性能优势
一、左值引用与右值引用的概念
在C++11之前,C++只有引用的概念(现在我们称之为左值引用 )。C++11引入了右值引用,这是C++语言发展中的一个重要里程碑,它彻底改变了我们对资源管理和性能优化的理解。
不过我们可以先认为,无论左值引用还是右值引用,都是给对象取别名
1.1 什么是左值?什么是左值引用?
左值 是一个表示数据的表达式,我们可以获取它的地址,并且可以对它赋值。左值可以出现在赋值符号的左边,也可以出现在右边。但右值不能出现在赋值符号左边。
需注意:定义时const修饰后的左值,我们虽然不能给他赋值,但是依然可以取地址。
左值引用就是给我们的左值取别名,给左值的引用。
#include <iostream>
int main() {
// 以下的p、b、c、*p都是左值
int* p = new int(0);
int b = 1;
const int c = 2;
// 以下几个是对上面左值的左值引用
int*& rp = p; // rp是p的引用(指针的引用)
int& rb = b; // rb是b的引用
const int& rc = c; // rc是c的引用
int& pvalue = *p; // pvalue是*p的引用
// 通过引用修改值
rb = 100;
std::cout << "b的新值: " << b << std::endl; // 输出100
return 0;
}
左值引用的关键特性:
- 相当于是给左值起别名
- 通过引用可以修改原值(除非是const引用)
- 引用在初始化后不能再绑定到其他对象
1.2 什么是右值?什么是右值引用?
右值 也是一个表示数据的表达式,但它不能出现在赋值符号的左边,也不能取地址 。常见的右值包括字面常量、表达式返回值、函数返回值(非引用返回)等。
右值引用就是给右值起别名
#include <iostream>
#include <cmath>
// 返回右值的函数
int createInt() {
return 42;
}
double createDouble() {
return 3.14;
}
int main() {
double x = 1.1, y = 2.2;
// 以下几个都是常见的右值
10; // 字面常量
x + y; // 表达式返回值
std::fmin(x, y); // 函数返回值
createInt(); // 函数返回值
// 验证右值特性
// &10; // 错误:不能取字面常量的地址
// &(x + y); // 错误:不能取表达式返回值的地址
// &createInt(); // 错误:不能取函数返回值的地址
// 右值引用:给右值起别名
int&& rr1 = 10; // 字面常量
double&& rr2 = x + y; // 表达式返回值
double&& rr3 = std::fmin(x, y); // 函数返回值
int&& rr4 = createInt(); // 函数返回值
std::cout << "rr1 = " << rr1 << std::endl;
std::cout << "rr2 = " << rr2 << std::endl;
std::cout << "rr3 = " << rr3 << std::endl;
std::cout << "rr4 = " << rr4 << std::endl;
// 右值引用可以修改右值(注意:这是C++11的特性)
rr1 = 20;
rr2 = 5.5;
std::cout << "修改后 rr1 = " << rr1 << std::endl;
std::cout << "修改后 rr2 = " << rr2 << std::endl;
// 以下操作都是非法的,证明这些是右值
// 10 = 1; // 错误:右值不能在赋值左边
// x + y = 1; // 错误:右值不能在赋值左边
// createInt() = 1; // 错误:右值不能在赋值左边
// 可能是比较难以理解的一点,后面细说:右值引用本身是左值
std::cout << "rr1的地址: " << &rr1 << std::endl; // 可以取地址
int&& rr5 = std::move(rr1); // 需要std::move,因为rr1是左值
return 0;
}
需要注意的是右值是不能取地址的
但是给右值取别名后,会导致右值被存储到特定位置,且可以取到该位置的地址,也就是说例如:不能取字面量10的地址
但是rr1引用后,可以对rr1取地址,也可以修改rr1-----(这进一步揭示了右值的引用rr1其实是一个左值)。
如果不想rr1被修改,可以用const int&& rr1 去引用
但右值本身的引用场景不在这里
右值引用的关键特性:
- 相当于给右值起别名
- 延长右值的生命周期(原本右值在表达式结束后就销毁)
- 可以修改右值(除非是const右值引用)
- 右值引用变量本身是左值
1.3 深入理解右值的生命周期
#include <iostream>
#include <string>
class Resource {
public:
Resource() { std::cout << "Resource constructed" << std::endl; }
~Resource() { std::cout << "Resource destroyed" << std::endl; }
};
Resource createResource() {
return Resource(); // 返回临时对象(右值)
}
int main() {
std::cout << "情况1:右值未被引用" << std::endl;
{
createResource(); // 临时对象立即销毁
std::cout << "临时对象已销毁" << std::endl;
}
std::cout << "\n情况2:右值被const左值引用绑定" << std::endl;
{
const Resource& r1 = createResource(); // 延长生命周期到r1的作用域
std::cout << "r1仍然有效" << std::endl;
} // r1销毁,临时对象也随之销毁
std::cout << "\n情况3:右值被右值引用绑定" << std::endl;
{
Resource&& r2 = createResource(); // 延长生命周期到r2的作用域
std::cout << "r2仍然有效" << std::endl;
} // r2销毁,临时对象也随之销毁
std::cout << "\n情况4:右值被右值引用绑定后移动" << std::endl;
{
Resource&& r3 = createResource();
Resource r4 = std::move(r3); // 移动构造
std::cout << "r3的资源已被移动到r4" << std::endl;
}
return 0;
}
二、左值引用与右值引用比较
2.1 左值引用的限制
左值引用有一些重要的限制,理解这些限制有助于我们理解为什么需要右值引用。
#include <iostream>
void processValue(int& val) {
std::cout << "Processing lvalue: " << val << std::endl;
}
void processValue(const int& val) {
std::cout << "Processing const lvalue or rvalue: " << val << std::endl;
}
int main() {
int a = 10;
const int b = 20;
// 左值引用只能绑定到左值
int& ra1 = a; // 正确:a是左值
// int& ra2 = 10; // 错误:10是右值,不能绑定到非const左值引用
// const左值引用可以绑定到左值和右值
const int& ra3 = 10; // 正确:const引用可以绑定到右值
const int& ra4 = a; // 正确:const引用可以绑定到左值
const int& ra5 = b; // 正确:const引用可以绑定到const左值
// 函数调用示例
processValue(a); // 调用第一个版本,a是左值
processValue(10); // 调用第二个版本,10是右值
processValue(b); // 调用第二个版本,b是const左值
// 左值引用的重要限制:不能延长临时对象的生命周期(除非是const引用)
// int& temp_ref = a + b; // 错误:a+b是右值
const int& const_temp_ref = a + b; // 正确:const引用可以
return 0;
}
总结:
左值引用只能引用左值,不能用来引用右值
2.但是const左值引用既可以引用左值也可以引用右值
2.2 右值引用的限制
右值引用也有自己的规则和限制:
#include <iostream>
#include <utility> // std::move
int main() {
int a = 10;
// 右值引用只能绑定到右值
int&& r1 = 10; // 正确:10是右值
// int&& r2 = a; // 错误:a是左值,不能直接绑定到右值引用
// 但是可以通过std::move将左值转换为右值引用
int&& r3 = std::move(a); // 正确:std::move(a)返回右值引用
std::cout << "a = " << a << std::endl; // a的值可能被改变!
std::cout << "r3 = " << r3 << std::endl; // r3现在是a的别名
// 注意:使用std::move后,a处于"被移动"状态
// 技术上a仍然有效,但它的值是不确定的
// 实际编程中,我们应该假设a不再可用,除非重新赋值
// const右值引用
const int&& r4 = 20; // 正确:const右值引用
// r4 = 30; // 错误:const引用不能修改
// 右值引用可以绑定到哪些表达式?
int x = 5, y = 3;
// int&& r5 = x + y; // 正确:表达式x+y是右值
// int&& r6 = x++; // 正确:后置++返回右值
// int&& r7 = ++x; // 错误:前置++返回左值
return 0;
}
总结:
1.右值引用只能用来引用右值,不能引用左值
2.但是右值引用可以引用move以后的左值
需要特别注意:前置的++返回的是左值,后置的++返回的是右值
2.3 综合比较表
| 特性 | 左值引用 | const左值引用 | 右值引用 | const右值引用 |
|---|---|---|---|---|
| 可绑定的值 | 左值 | 左值、右值 | 右值 | 右值 |
| 是否可修改 | 是 | 否 | 是 | 否 |
| 主要用途 | 避免拷贝,修改原值 | 避免拷贝,只读访问 | 移动语义,完美转发 | 很少使用 |
| 生命周期延长 | 否 | 是 | 是 | 是 |
| 与std::move | 不能直接绑定 | 可以直接绑定 | 可以直接绑定 | 可以直接绑定 |
三、右值引用使用场景和意义
3.1 左值引用的短板
前面我们提到左值引用虽然只能引用左值,但是通过const左值引用,我们也可以引用右值了。那么为什么我们C++11还需要加一个右值引用呢?
在理解右值引用之前,我们先看看左值引用的短板:
#include <iostream>
#include <cstring>
namespace my {
class string {
private:
char* _str;
size_t _size;
size_t _capacity;
public:
// 构造函数
string(const char* str = "")
: _size(strlen(str)), _capacity(_size) {
std::cout << "构造函数: " << str << std::endl;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
// 拷贝构造函数(深拷贝)
string(const string& s)
: _size(s._size), _capacity(s._capacity) {
std::cout << "拷贝构造函数(深拷贝): " << s._str << std::endl;
_str = new char[_capacity + 1];
strcpy(_str, s._str);
}
// 析构函数
~string() {
delete[] _str;
}
// 获取C风格字符串
const char* c_str() const {
return _str;
}
// 获取大小
size_t size() const {
return _size;
}
};
// 一个返回string的函数
string createString(int value) {
my::string str;
// 假设这里进行了一些复杂的字符串构建
char buffer[20];
sprintf(buffer, "%d", value);
str = my::string(buffer); // 这里会发生拷贝构造
return str; // 这里也会发生拷贝构造(不考虑编译器优化的情况下)
}
}
int main() {
std::cout << "测试左值引用的短板:" << std::endl;
// 情况1:函数参数传递
my::string s1("Hello");
// 调用函数时,如果参数是传值,会发生拷贝
// void func(my::string s) {} // 调用func(s1)会发生深拷贝
// 如果使用左值引用,可以避免拷贝
// void func(const my::string& s) {} // 不会发生拷贝
std::cout << "\n情况2:函数返回值" << std::endl;
my::string s2 = my::createString(123); // 这里可能发生多次拷贝,而createString(123)返回的是右值
// 问题:createString返回的是一个临时对象(右值)
// 如果这个临时对象用于构造s2,我们其实希望"窃取"它的资源
// 而不是进行深拷贝,因为临时对象很快就会被销毁
std::cout << "s2 = " << s2.c_str() << std::endl;
return 0;
}
左值引用缺陷:如上面代码所示,我们可以在很多场景下使用左值引用来减少参数传递之间出现的多次拷贝,从而大大提高效率。
可是,当函数返回值对象是一个局部对象,出了作用域就不再存在,那么它就返回的是右值。我们不可以使用左值引用返回,就非得用传值返回
这样就至少会出现一次拷贝构造。

3.2 移动语义的引入
移动语义通过"窃取"临时对象的资源来避免不必要的深拷贝。这是通过移动构造函数 和移动赋值运算符实现的。
如果我们没有实现移动构造,那么,因为const &是可以引用右值的,那么他编译器就会自动调用拷贝构造等深拷贝,造成性能消耗。
所以我们自主实现移动构造,来实现资源的窃取(替换掉原先不必要的拷贝)
下面注意对比我们普通拷贝和移动版的区别
#include <iostream>
#include <cstring>
#include <utility>
namespace my {
class string {
private:
char* _str;
size_t _size;
size_t _capacity;
public:
// 默认构造函数
string(const char* str = "")
: _str(nullptr), _size(0), _capacity(0) {
if (str && str[0] != '\0') {
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
}
// 拷贝构造函数(深拷贝)
string(const string& s)
: _str(nullptr), _size(0), _capacity(0) {
std::cout << "拷贝构造函数(深拷贝)" << std::endl;
if (s._str) {
_size = s._size;
_capacity = s._capacity;
_str = new char[_capacity + 1];
strcpy(_str, s._str);
}
}
// 移动构造函数,参数使用右值引用,这样当传参为右值就会优先调用移动版
string(string&& s) noexcept
: _str(s._str), _size(s._size), _capacity(s._capacity) {
std::cout << "移动构造函数" << std::endl;
// "窃取"s的资源
s._str = nullptr; // 重要:将源对象置空,防止双重释放
s._size = 0;
s._capacity = 0;
}
// 拷贝赋值运算符
string& operator=(const string& s) {
std::cout << "拷贝赋值运算符(深拷贝)" << std::endl;
if (this != &s) {
delete[] _str;
_size = s._size;
_capacity = s._capacity;
_str = new char[_capacity + 1];
strcpy(_str, s._str);
}
return *this;
}
// 移动赋值运算符,参数使用右值引用,这样当传参为右值就会优先调用移动版
string& operator=(string&& s) noexcept {
std::cout << "移动赋值运算符" << std::endl;
if (this != &s) {
delete[] _str; // 释放现有资源
// "窃取"s的资源
_str = s._str;
_size = s._size;
_capacity = s._capacity;
// 将源对象置空
s._str = nullptr;
s._size = 0;
s._capacity = 0;
}
return *this;
}
// 交换函数
void swap(string& s) {
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
// 更高效的移动构造函数实现(使用swap)
// string(string&& s) noexcept : _str(nullptr), _size(0), _capacity(0) {
// std::cout << "移动构造函数(使用swap)" << std::endl;
// swap(s);
// }
// 析构函数
~string() {
delete[] _str;
}
// 获取C风格字符串
const char* c_str() const {
return _str ? _str : "";
}
// 获取大小
size_t size() const {
return _size;
}
};
// 一个返回string的函数
string createString(int value) {
char buffer[20];
sprintf(buffer, "Number: %d", value);
return string(buffer); // 这里会调用移动构造函数
}
}
//////////////////////////场景演示////////////////////////////////
void demonstrateMoveSemantics() {
std::cout << "=== 移动语义演示 ===" << std::endl;
// 情况1:从函数返回值构造对象
std::cout << "\n1. 从函数返回值构造对象:" << std::endl;
my::string s1 = my::createString(123); // 调用移动构造函数
// 情况2:显式移动
std::cout << "\n2. 显式移动:" << std::endl;
my::string s2("Hello");
my::string s3 = std::move(s2); // 调用移动构造函数
std::cout << "s2 after move: \"" << s2.c_str() << "\"" << std::endl; // 空字符串
std::cout << "s3: \"" << s3.c_str() << "\"" << std::endl;
//这里我们打印s2,会发现s2为空字符串,而s3是Hello,进一步验证了我们的移动构造是窃取资源的形式
//使用move,提醒读代码的人,我们调用了移动构造!
// 情况3:移动赋值
std::cout << "\n3. 移动赋值:" << std::endl;
my::string s4;
s4 = my::createString(456); // 调用移动赋值运算符
// 情况4:标准容器中的移动
std::cout << "\n4. 在vector中使用移动语义:" << std::endl;
std::vector<my::string> vec;
vec.reserve(10); // 预分配空间,避免重新分配时的拷贝
// 插入右值会调用移动构造函数
vec.push_back(my::string("First"));
vec.push_back(my::string("Second"));
// 插入左值会调用拷贝构造函数
my::string s5("Third");
vec.push_back(s5); // 拷贝
// 但我们可以使用std::move将左值转换为右值
my::string s6("Fourth");
vec.push_back(std::move(s6)); // 移动
std::cout << "s6 after move: \"" << s6.c_str() << "\"" << std::endl; // 空
}
int main() {
demonstrateMoveSemantics();
return 0;
}
我们可以注意到,移动构造通常相比于深拷贝和拷贝构造,他没有新开空间:
而是更多的去采取->释放自己的资源->获取对方的资源->在释放对方的空间这样的情况,减少了拷贝次数与空间开辟(也可以理解为swap的过程)
从而提高的我们函数调用返回值的效率。
还有关于情况4中reserve()与移动语义有什么关系可能并不明朗,这边我在进一步说明:
vec.reserve(10) 与移动语义没有直接关系 ,但它是性能优化的重要前提:
-
reserve()的作用:预先分配vector的内存,避免后续插入时多次重新分配(reallocation) -
与移动语义的间接关系 :如果vector需要重新分配,它会将旧元素移动或拷贝到新内存
- 没有
reserve()→ 可能多次重新分配 → 多次移动/拷贝 - 使用
reserve()→ 避免重新分配 → 避免不必要的移动/拷贝
- 没有
-
移动语义在vector中的两个应用场景:
-
场景1:插入元素时(
push_back/emplace_back) -
场景2:vector扩容重新分配时(用reserve来优化,所以我们重点讨论场景一)
如此一来,我们可以更加清晰的去讨论移动语义在插入方面的重大影响,这是更重要的。
-
3.3 移动语义的性能优势
让我们通过一个具体的性能测试(该性能测试由AI生成)来展示移动语义的优势:
#include <iostream>
#include <vector>
#include <chrono>
#include <cstring>
class Buffer {
private:
char* _data;
size_t _size;
public:
// 构造函数
Buffer(size_t size) : _size(size) {
_data = new char[_size];
std::memset(_data, 'A', _size);
}
// 拷贝构造函数
Buffer(const Buffer& other) : _size(other._size) {
std::cout << "拷贝构造函数" << std::endl;
_data = new char[_size];
std::memcpy(_data, other._data, _size);
}
// 移动构造函数
Buffer(Buffer&& other) noexcept : _data(other._data), _size(other._size) {
std::cout << "移动构造函数" << std::endl;
other._data = nullptr;
other._size = 0;
}
// 析构函数
~Buffer() {
delete[] _data;
}
size_t size() const { return _size; }
};
void performanceTest() {
const int NUM_BUFFERS = 10000;
const size_t BUFFER_SIZE = 1024 * 1024; // 1MB
std::cout << "=== 性能测试:拷贝 vs 移动 ===" << std::endl;
std::cout << "每个缓冲区大小: " << BUFFER_SIZE / 1024 << "KB" << std::endl;
std::cout << "缓冲区数量: " << NUM_BUFFERS << std::endl;
// 测试1:使用拷贝
{
std::cout << "\n测试1:使用拷贝语义" << std::endl;
auto start = std::chrono::high_resolution_clock::now();
std::vector<Buffer> buffers;
buffers.reserve(NUM_BUFFERS);
for (int i = 0; i < NUM_BUFFERS; ++i) {
Buffer buf(BUFFER_SIZE);
buffers.push_back(buf); // 这里会发生拷贝
}
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << "时间: " << duration.count() << "ms" << std::endl;
//不必关注,只需知道这里计算了消耗时间
}
// 测试2:使用移动
{
std::cout << "\n测试2:使用移动语义" << std::endl;
auto start = std::chrono::high_resolution_clock::now();
std::vector<Buffer> buffers;
buffers.reserve(NUM_BUFFERS);
for (int i = 0; i < NUM_BUFFERS; ++i) {
Buffer buf(BUFFER_SIZE);
buffers.push_back(std::move(buf)); // 这里会发生移动
}
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << "时间: " << duration.count() << "ms" << std::endl;
}
// 测试3:直接构造在vector中,在代码下面有详细解释,如果看不太懂可以先翻下去
{
std::cout << "\n测试3:直接构造在vector中(最佳)" << std::endl;
auto start = std::chrono::high_resolution_clock::now();
std::vector<Buffer> buffers;
buffers.reserve(NUM_BUFFERS);
for (int i = 0; i < NUM_BUFFERS; ++i) {
// 使用emplace_back直接在vector中构造对象
buffers.emplace_back(BUFFER_SIZE);
}
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << "时间: " << duration.count() << "ms" << std::endl;
}
}
int main() {
performanceTest();
return 0;
}
这里写上测试三的很重要的原因------它展示了C++11中比移动语义更进一步的优化技术(emplace_back)。
也是emplace_back在某些方面优于push_back的地方,他能够做出比单纯移动构造替换掉深构造深拷贝的更进一步的优化。
emplace_back相对于push_back,他能够直接跳过临时对象的构造,直接在vector中构造出对象
而push_back更倾向于,我已有一个对象,我要把他放入vector中,这过程会有临时对象的产生。
意味着,当一个对象很大,emplace_back会有比push_back好的多的优化效果。
不过也有缺陷就是说,emplace_back并不在所有情况的优于push_back,且他的代码可读性相对较差
总结
右值引用和移动语义是C++11最重要的改进之一,它们彻底改变了C++程序的资源管理和性能优化方式:
- 右值引用允许我们区分左值和右值,为移动语义和完美转发奠定了基础。
- 移动语义通过"窃取"临时对象的资源,避免了不必要的深拷贝,显著提高了程序性能。
这些特性不仅在标准库中得到广泛应用,也为开发者编写高性能的C++代码提供了强大的工具。理解并正确使用这些特性,是成为现代C++开发者的关键一步。
下一篇中,我们将探讨进一步探讨C++11:std::move与完美转发,这些特性让C++的函数式编程能力得到了极大的增强。。
xml
感谢你能够阅读到这里,如果本篇文章对你有帮助,欢迎点赞收藏支持,关注我,
我将更新更多有关C++,Linux系统·网络部分的知识。