现代 c++ 三:右值引用与移动语义
c++11 为了提高效率,引入了右值引用及移动语义,这个概念不太好理解,需要仔细研究一下,下文会一并讲讲左值、右值、左值引用、右值引用、const 引用、移动构造、移动赋值运行符 … 这些概念。
左值和右值
左值和右值是表达式的属性。从 c 语言开始就有左值、右值这两个名词,当时的用途也很简单,就是帮助记忆:左值可以位于赋值语句的左侧,而右值不能。
c++ 的表达式也只有左值和右值,大体也是相似的意思。简单的理解,有地址(内存位置)的对象就是一个左值,比如 int i = 3
,i 是一个左值,它是有地址的,而 3 是右值,它是个字面值,没有地址。有时候左值可以作为右值,这时候用的是它的值,比如这样:int i = 3; int j = i;
,当用 i 去初始化 j 的时候,它是作为右值出现的,这时候用的是 i 的值,而不是 i 的地址(内存位置)。
所以,可以简单的归纳一下:当一个对象被用作右值时,用的是对象的值(内容);当对象被用作左值时,用的是对象的地址(内存位置)。[1]
运算符的运算对象和运算结果
-
赋值运算符:运算对象是左值,运算结果也是左值。
-
取地址符:运算对象是左值,运算结果是右值。
-
内置解引用运算符、下标运算符、迭代器解引用运算符、string&vector的下标运算符:运算对象是左值,运算结果也是左值。
-
内置类型和迭代器的递增递减运算符:运算对象是左值,运算结果,前置版本是左值,后置版本是右值。
解引用运行符就是 * 操作符,用于获得指针所指的对象,比如:
int v = 100;
int* p = &v;
*p = 200;
p 是一个指向了对象的指针,则 *p 就是获得指针 p 所指的对象,比如 *p = 100;
递增递减的前置和后置版本的具体区别:
- 前置版本,比如: ++i 返回的是左值,过程是直接把 i 加 1,然后返回 i。
- 后置版本,比如: i++ 返回的是右值,过程是先用一个临时变量保存 i 的值,然后把 i 值加1,然后返回临时变量。
所以,建议是:除非必须,不要使用递增递减的后置版本,它们生成了临时变量,是一种浪费。
左值引用
左值引用就是绑定到左值上的引用,用 &
表示。c++11 之前,引用都是左值引用。左值引用就相当于给一个左值(对象)取一个别名。
它与指针是有显著区别的,指针可以指向 NULL 对象,指针可以只声明不初始化,但左值引用都不行。左值引用必须引用一个已经存在的对象,必须定义时初始化,像这样:
int i = 100;
int& refi = i; // 合法
int& refi2; // 不合法
另外,左值引用不能绑定到临时对象上:
int& i = 100; // 不合法
string& s {"hello"}; // 不合法
const 引用
const 引用是一种特殊的左值引用,与常规左值引用的区别在于,它可以绑定到临时对象:
const int& i1 = 100; // 合法,相当于:int temp = 100; const int& i1 = temp;
const string& s1 {"hello"}; // 合法,相当于:string temp {"hello"}; const string& s1 {temp};
c++ 只会为 const 引用产生临时对象,不会对非 const 引用产生临时对象,这一特性导致了一些容易让人困惑的现象:
void f1(const string& s) {cout << s << endl;
}void f2(string& s) {cout << s << endl;
}f1("hello"); // 正常,"hello" 转换成 string 类型的临时对象,临时对象可以被 const 引用 引用
f2("hello"); // 编译报错,"hello" 转换成 string 类型的临时对象,临时对象不可以被 左值引用 引用// 会报类似这样的编译错误:no known conversion from 'const char[6]' to 'string &'string s = "hello";
f1(s); // 正常,一个左值可以被 左值引用 所引用
f2(s); // 正常一个左值可以被 const引用 所引用
右值引用
右值引用是 c++11 引入的新概念,就是绑定到右值上的引用,用 &&
表示,按流行的说法,右值引用是绑定到“一些即将销毁的对象上”。
右值引用只能绑定到右值上,不能绑定到左值上,举些例子:
int r = 100;
int&& r1 = r; // 不合法,r 是一个左值
int&& r2 = 100; // 合法,100 是一个右值
string&& s1 {"hello"}; // 合法,"hello" 是一个右值
string&& s2 {s1}; // 不合法,s1 是一个左值,"string 右值引用" 只是它的类型,它本质是上一个左值,它是有地址(内存位置)的,这点很容易犯错int x = 100;
int&& x1 = ++x; // 不合法,++x 返回的是左值
int&& x2 = x++; // 合法,x++ 返回的是右值,虽然可以,但项目中不要这么写,容易被别人打:)
上面例子中,要特别注意的情况是:string&& s1 {"hello"};
,在这里,s1 是一个类型为 “string 右值引用” 的左值,当我们把 右值引用 当成一种类型之后,就比较好理解 s1 是一个左值的事实了,它是地址(内存位置)的变量。再举一个例子:void f(int&& p1);
,在这个函数声明中,p1 是一个类型为 int 右值引用
的左值。可归纳如下:
1、变量都是左值。
2、函数的形参都是左值。
3、临时对象都是右值。
下面是最重要的问题,为什么会需要右值引用?
简单的说,右值引用的目的在于提高运行效率,把对象复制变成对象移动。
这个过程是怎么发生的呢?下文接着讲。
移动语义,移动构造函数、移动赋值运算符函数
对象的移动是如何发生的?在 c++11 中,是通过移动构造函数和移动赋值运算符来实现的,这两个函数与拷贝构造函数和拷贝赋值运算符是相对的。前者的参数是右值引用,而后者的参数是左值引用。
如果没有定义移动函数或者源对象不是右值,用一个对象给另一个对象初始化或赋值,调用的都是拷贝函数。
如果定义了移动函数并且源对象是右值,用一个对象给另一个对象初始化或赋值,调用的都是移动函数。
复制对象的基本模式是,目标对象往往需要 new 一块内存出来,然后从源对象那里复制内存数据。
移动对象的基本模式是,直接挪用内存,不 new 内存也不拷贝数据,直接把源对象的内存数据拿来用,其代价往往只是一些指针变量的赋值。
显而易见,如果有比较多的内存需要拷贝,移动对象的效率是更高很多的。
下面举个例子证明以上的说法:
源码可在此找到:https://github.com/antsmallant/antsmallant_blog_demo/tree/main/blog_demo/modern-cpp 。
// move_constructor_demo.cpp
// 编译&执行:g++ -std=c++14 move_constructor_demo.cpp && ./a.out
// 屏蔽rvo的编译&执行:g++ -std=c++14 -fno-elide-constructors move_constructor_demo.cpp && ./a.out#include <iostream>
#include <vector>
using namespace std;class A {
private:vector<int>* p;
public:A() {cout << "A 构造函数,无参数" << endl;p = new vector<int>();}// 构造函数A(int cnt, int val) {cout << "A 构造函数,带参数" << endl;p = new vector<int>(cnt, val);}// 析构函数~A() {if (p != nullptr) {delete p;p = nullptr;cout << "A 析构函数,释放 p" << endl;} else {cout << "A 析构函数,不需要释放 p" << endl;}}// 拷贝构造函数A(const A& other) {cout << "A 拷贝构造函数" << endl;p = new vector<int>(other.p->begin(), other.p->end());}// 拷贝赋值运算符A& operator = (const A& other) {cout << "A 拷贝赋值运算符" << endl;if (p != nullptr) {cout << "A 拷贝赋值前释放旧内存" << endl;delete p;p = nullptr;}p = new vector<int>(other.p->begin(), other.p->end()); return *this;}// 移动构造函数A(A&& other) noexcept {cout << "A 移动构造函数" << endl;this->p = other.p; // 挪用别人的other.p = nullptr; // 置空别人的}// 移动赋值运算符A& operator = (A&& other) noexcept {cout << "A 移动赋值运算符" << endl;this->p = other.p; other.p = nullptr; return *this;}
};void test_copy_constructor() {A a(10, 100);A b(a);
}void test_copy_assign_operator() {A a(10, 100);A b;b = a;
}A getA(int cnt, int val) {return A(cnt, val);
}void test_move_constructor() {A a(getA(10, 200));
}void test_move_assign_operator() {A a;a = getA(10, 200);
}int main() {cout << "测试拷贝构造: " << endl;test_copy_constructor();cout << endl << "测试拷贝赋值运算符: " << endl;test_copy_assign_operator();cout << endl << "测试移动构造: " << endl;test_move_constructor();cout << endl << "测试移动赋值运算符: " << endl;test_move_assign_operator();return 0;
}
输出是:
测试拷贝构造:
A 构造函数,带参数
A 拷贝构造函数
A 析构函数,释放 p
A 析构函数,释放 p测试拷贝赋值运算符:
A 构造函数,带参数
A 构造函数,无参数
A 拷贝赋值运算符
A 拷贝赋值前释放旧内存
A 析构函数,释放 p
A 析构函数,释放 p测试移动构造:
A 构造函数,带参数
A 析构函数,释放 p测试移动赋值运算符:
A 构造函数,无参数
A 构造函数,带参数
A 移动赋值运算符
A 析构函数,不需要释放 p
A 析构函数,释放 p
oops,上面的测试可以说有 75% 成功了,关于移动构造的测试失败了,它压根没调用移动构造函数。怎么回事?
这实际上是一种编译器优化,叫 RVO(Return Value Optimization),返回值优化,这个我们下文再具体讲讲,为了避免这种优化对于我们测试的影响,我们可以给编译器传递一个选项,暂时禁用这种优化,修改一下编译命令: g++ -std=c++14 -fno-elide-constructors move_constructor_demo.cpp && ./a.out
,重新编译运行,移动构造的测试输出变成:
测试移动构造:
A 构造函数,带参数
A 移动构造函数
A 析构函数,不需要释放 p
A 移动构造函数
A 析构函数,不需要释放 p
A 析构函数,释放 p
虽然如愿输出了 “A 移动构造函数”,但输出有点多。把代码列出来,简单分析一下:
A getA(int cnt, int val) {// 1、用带参数的构造函数 A(10, 200) 生成一个局部对象 x// 2、return 的时候,用移动构造函数 A(x) 生成一个临时对象 treturn A(cnt, val);
}void test_move_constructor() {// 3、用移动构造函数 A(t) 生成局部对象 aA a(getA(10, 200));
}
最精准的分析需要看汇编代码,但汇编有点复杂,这里先不看,等下篇文章讲 RVO 的时候再一并用汇编分析。
编译器默认生成的移动(构造/赋值运算符)函数
如果我们没有自己写拷贝构造函数或拷贝赋值运算符,那么编译器会帮我们生成默认的。
编译器在特定条件下,也会帮我们生成默认的移动函数[2]:
- 一个类没有定义任何版本的拷贝构造函数、拷贝赋值运算符、析构函数;
- 类的每个非静态成员都可以移动
- 内置类型(如整型、浮点型)
- 定义了移动操作的类类型
第1点,应该是确保系统可以生成符合程序员需要的移动函数,如果代码中定义了那三种函数,说明程序员有自己控制复制或释放的倾向,这时候编译器就不默认生成了。
第2点,只有确保成员都可移动,才能生成正确的移动函数。
std::move
上面讲移动构造和移动赋值运算符的时候,发现由于编译器的优化:RVO,导致即使我们构造了合适的场景,也没能验证移动构造的使用。
接下来介绍的 std::move,即使不屏蔽 RVO,也可以验证移动构造的使用,只需要这样修改
// g++ -std=c++14 move_constructor_demo.cpp && ./a.outvoid test_move_constructor_use_stdmove() {A x(10, 200);A a(std::move(x));
}int main() {cout << endl << "使用 std::move 测试移动构造:" << endl;test_move_constructor_use_stdmove();
}
输出:
A 构造函数,带参数
A 移动构造函数
A 析构函数,释放 p
A 析构函数,不需要释放 p
std::move 把 x 转换成了一个右值类型的变量,所以编译器使用移动构造函数来生成变量 a。
特别注意,std::move 并不完成对象的移动,它的作用只是把传递进去的实参转换成一个右值,可以理解它是某种 cast 封装,一种可能的实现如下[3]:
template<typename T>
typename remove_reference<T>::type&&
move(T&& param)
{using ReturnType = typename remove_reference<T>::type&&;return static_cast<ReturnType>(param);
}
实参可以是左值,也可以是右值:
int a = 100;
int&& r1 = std::move(a); // 合法
int&& r2 = std::move(200); // 合法
std::move 只是完成类型转换,真正起作用的是移动构造函数或移动赋值运算符函数,在这两个函数中写移动逻辑。
综上,std::move 是一种危险的操作,调用时必须确认源对象没有其他用户了,否则容易发生一些意外的难以理解的状况。
参考
[1] [美] Stanley B. Lippman, Josée Lajoie, Barbara E. Moo. C++ Primer 中文版(第 5 版). 王刚, 杨巨峰. 北京: 电子工业出版社, 2013-9: 120, 154, 182.
[2] 王健伟. C++新经典. 北京: 清华大学出版社, 2020-08-01.
[3] [美]Scott Meyers. Effective Modern C++(中文版). 高博. 北京: 中国电力出版社, 2018-4: 149, 151.
相关文章:
现代 c++ 三:右值引用与移动语义
c11 为了提高效率,引入了右值引用及移动语义,这个概念不太好理解,需要仔细研究一下,下文会一并讲讲左值、右值、左值引用、右值引用、const 引用、移动构造、移动赋值运行符 … 这些概念。 左值和右值 左值和右值是表达式的属性。…...

Java学习【类与对象—封装】
Java学习【类与对象—封装】 封装的概念封装的实现包的概念import 导包导包中*的介绍import static 导入包中的静态方法和字段 static关键字的使用static 修饰成员变量static修饰方法静态成员变量的初始化 代码块静态代码块非静态代码块/实例化代码块/构造代码块加载顺序 封装的…...

Co-Driver:基于 VLM 的自动驾驶助手,具有类人行为并能理解复杂的道路场景
24年5月来自俄罗斯莫斯科研究机构的论文“Co-driver: VLM-based Autonomous Driving Assistant with Human-like Behavior and Understanding for Complex Road Scenes”。 关于基于大语言模型的自动驾驶解决方案的最新研究,显示了规划和控制领域的前景。 然而&…...

硅胶可以镭射吗?
在科技发展的今天,我们经常会遇到各种各样的材料,其中就有一种叫做硅胶的材料。那么,硅胶可以镭射吗?答案是肯定的,硅胶不仅可以镭射,而且在某些应用中,它的镭射特性还非常突出。 首先ÿ…...

财务风险管理:背后真相及应对策略
市场经济蓬勃发展,机遇与风险并存也是市场经济的一项重要特征。而财务状况的好坏影响着一个企业的发展前景,作为市场经济的必然产物,财务风险贯穿于企业的一切生产经营活动中,无法预知也不以人的意志为转移。 一、企业财务风险的特…...
MySQL深入理解事务(详解)
事务概述 事务是数据库区别于文件系统的重要特性之一,当我们有了事务就会让数据库始终保持一致性,同时我们还能通过事务机制恢复到某个时间点,这样可以保证已提交到数据库的修改不会因为系统崩溃而丢失。 1、基本概念 事务:一组…...

【Linux系统】进程控制
本篇博客整理了进程控制有关的创建、退出、等待、替换操作方面的知识,最终附有模拟实现命令行解释器shell来综合运用进程控制的知识,旨在帮助读者更好地理解进程与进程之间的交互,以及对开发有一个初步了解。 目录 一、进程创建 1.创建子进…...
Go语言数值类型教程
Go语言提供了丰富的数值类型,包括整数类型、浮点类型和复数类型。每种类型都有其特定的用途和存储范围。下面将详细介绍这些类型,并附带示例代码。 原文链接: Go语言数值类型教程 - 红客网-网络安全与渗透技术 1. 整数类型 原文链接…...

Linux进程控制——Linux进程等待
前言:接着前面进程终止,话不多说我们进入Linux进程等待的学习,如果你还不了解进程终止建议先了解: Linux进程终止 本篇主要内容: 什么是进程等待 为什么要进行进程等待 如何进程等待 进程等待 1. 进程等待的概念2. 进…...

GPT-4o:融合文本、音频和图像的全方位人机交互体验
引言: GPT-4o(“o”代表“omni”)的问世标志着人机交互领域的一次重要突破。它不仅接受文本、音频和图像的任意组合作为输入,还能生成文本、音频和图像输出的任意组合。这一全新的模型不仅在响应速度上达到了惊人的水平,在文本、音频和图像理解方面也表现出色,给人带来了…...

灵活的静态存储控制器 (FSMC)的介绍(STM32F4)
目录 概述 1 认识FSMC 1.1 应用介绍 1.2 FSMC的主要功能 1.2.1 FSMC用途 1.2.2 FSMC的功能 2 FSMC的框架结构 2.1 AHB 接口 2.1.1 AHB 接口的Fault 2.1.2 支持的存储器和事务 2.2 外部器件地址映射 3 地址映射 3.1 NOR/PSRAM地址映射 3.2 NAND/PC卡地址映射 概述…...
nginx-rtmp
1.已经安装nginx;configure配置模块;make编译无需安装;把objs/nginx复制到已安装的宁目录下 ./configure --prefix/usr/local/nginx --add-module/usr/local/src/fastdfs-nginx-module/src --add-module/usr/local/src/nginx-rtmp-module-mas…...
nginx 代理java 请求报502
情况:nginx代理java 请求 后端返回正常,但是经过nginx 时报502 经过多次对比其他接口发现可能是返回的请求头过大,导致nginx 报错:如下 2024/05/13 02:57:12 [error] 88#88: *3755 upstream sent too big header while reading r…...

面试集中营—Redis面试题
一、Redis的线程模型 Redis是基于非阻塞的IO复用模型,内部使用文件事件处理器(file event handler),这个文件事件处理器是单线程的,所以Redis才叫做单线程的模型,它采用IO多路复用机制同时监听多个socket&a…...

关于使用git拉取gitlab仓库的步骤(解决公钥问题和pytho版本和repo版本不对应的问题)
先获取权限,提交ssh-key 虚拟机连接 GitLab并提交代码_gitlab提交mr-CSDN博客 配置完成上诉步骤之后,执行下列指令进行拉去仓库的内容 sudo apt install repo export PATHpwd/.repo/repo:$PATH python3 "实际路径"/repo init -u ssh://gitxx…...

Django图书馆综合项目-学习(2)
接下来我们来实现一下图书管理系统的一些相关功能 1.在书籍的book_index.html中有一个"查看所有书毂"的超链接按钮,点击进入书籍列表book_list.html页面. 这边我们使用之前创建的命名空间去创建超连接 这里的book 是在根路由创建的namespacelist是在bo…...
vue3+ts 获取input 输入框中的值
从前端input 输入框获取值,通过封装axios 将值传给后端服务 数据格式为json html <el-form> <el-form-item label"域名"><el-input v-model"short_url" style"width: 240px"type"text"placeholder&quo…...
Gin框架返回Protobuf类型:提升性能的利器
在构建高效、高性能的微服务架构时,数据序列化和反序列化的性能至关重要。Protocol Buffers(简称Protobuf)作为一种轻量级且高效的结构化数据存储格式,已经在众多领域得到广泛应用。Gin框架作为Go语言中流行的Web框架,…...

HTML满屏漂浮爱心
目录 写在前面 满屏爱心 代码分析 系列推荐 写在最后 写在前面 小编给大家准备了满屏漂浮爱心代码,一起来看看吧~ 满屏爱心 文件heart.svg <svg xmlns"http://www.w3.org/2000/svg" width"473.8px" height"408.6px" view…...
爬虫应该选择住宅ip代理还是数据中心代理?
住宅代理 住宅代理是互联网服务提供商 (ISP) 提供的 IP 地址,它们是附加到实际物理位置的真实IP地址。住宅代理允许用户通过目标区域内的真实IP地址连接到互联网。 数据中心代理 数据中心代理是指是使用数据中心拥有并管理IP的代理,IP地址来源于数据中…...
Vue记事本应用实现教程
文章目录 1. 项目介绍2. 开发环境准备3. 设计应用界面4. 创建Vue实例和数据模型5. 实现记事本功能5.1 添加新记事项5.2 删除记事项5.3 清空所有记事 6. 添加样式7. 功能扩展:显示创建时间8. 功能扩展:记事项搜索9. 完整代码10. Vue知识点解析10.1 数据绑…...

iOS 26 携众系统重磅更新,但“苹果智能”仍与国行无缘
美国西海岸的夏天,再次被苹果点燃。一年一度的全球开发者大会 WWDC25 如期而至,这不仅是开发者的盛宴,更是全球数亿苹果用户翘首以盼的科技春晚。今年,苹果依旧为我们带来了全家桶式的系统更新,包括 iOS 26、iPadOS 26…...
Admin.Net中的消息通信SignalR解释
定义集线器接口 IOnlineUserHub public interface IOnlineUserHub {/// 在线用户列表Task OnlineUserList(OnlineUserList context);/// 强制下线Task ForceOffline(object context);/// 发布站内消息Task PublicNotice(SysNotice context);/// 接收消息Task ReceiveMessage(…...

大数据零基础学习day1之环境准备和大数据初步理解
学习大数据会使用到多台Linux服务器。 一、环境准备 1、VMware 基于VMware构建Linux虚拟机 是大数据从业者或者IT从业者的必备技能之一也是成本低廉的方案 所以VMware虚拟机方案是必须要学习的。 (1)设置网关 打开VMware虚拟机,点击编辑…...

CocosCreator 之 JavaScript/TypeScript和Java的相互交互
引擎版本: 3.8.1 语言: JavaScript/TypeScript、C、Java 环境:Window 参考:Java原生反射机制 您好,我是鹤九日! 回顾 在上篇文章中:CocosCreator Android项目接入UnityAds 广告SDK。 我们简单讲…...

从零开始打造 OpenSTLinux 6.6 Yocto 系统(基于STM32CubeMX)(九)
设备树移植 和uboot设备树修改的内容同步到kernel将设备树stm32mp157d-stm32mp157daa1-mx.dts复制到内核源码目录下 源码修改及编译 修改arch/arm/boot/dts/st/Makefile,新增设备树编译 stm32mp157f-ev1-m4-examples.dtb \stm32mp157d-stm32mp157daa1-mx.dtb修改…...
Caliper 配置文件解析:config.yaml
Caliper 是一个区块链性能基准测试工具,用于评估不同区块链平台的性能。下面我将详细解释你提供的 fisco-bcos.json 文件结构,并说明它与 config.yaml 文件的关系。 fisco-bcos.json 文件解析 这个文件是针对 FISCO-BCOS 区块链网络的 Caliper 配置文件,主要包含以下几个部…...
什么?连接服务器也能可视化显示界面?:基于X11 Forwarding + CentOS + MobaXterm实战指南
文章目录 什么是X11?环境准备实战步骤1️⃣ 服务器端配置(CentOS)2️⃣ 客户端配置(MobaXterm)3️⃣ 验证X11 Forwarding4️⃣ 运行自定义GUI程序(Python示例)5️⃣ 成功效果启动的时候只要运行下方代码即可: # Also add the following code, # so that every time the environment (kernel) starts, # just run the following code: import sys sys.path.append(/home/aistudio/external-libraries)机…...
HTML前端开发:JavaScript 获取元素方法详解
作为前端开发者,高效获取 DOM 元素是必备技能。以下是 JS 中核心的获取元素方法,分为两大系列: 一、getElementBy... 系列 传统方法,直接通过 DOM 接口访问,返回动态集合(元素变化会实时更新)。…...