移动语义

本文是对《最好的C++教程》的整理,主要是移动语义部分,包含视频85p左值和右值89p移动语义90p stdmove和移动赋值操作符

移动语义是C++11的新feature,可能许多人学习的时候尚未使用到C++11的特性,但是现在C++11已经过去了10年了,早已成为广泛使用的基础特性。所以绝对值得一学。在我的上一篇博客自己动手写Vector中就用到了相关的内容对Vector的性能做了一定的提升,学习完本文后可以到其中看看实际中的使用。

文章主题内容来自Cherno的视频教程,但是同时也加入了一些个人的理解和思考在其中,并未一一指出,如有错误或疑惑之处,欢迎留言批评指正。

本文包含的知识点:左值和右值、移动语义、stdmove、移动赋值操作符

原作者Cherno视频链接:Cherno C++视频教程

文中代码GitHub链接:https://github.com/zhangyi1357/Little-stuff/tree/main/Move-Semantics

左值与右值

相信你已经在很多地方听过左值右值了,比如编译器的报错等处。想要理解移动语义,左值和右值是绕不过去的概念。

如果你对左值和右值已经十分熟悉了,可以直接跳过此章节,直接阅读移动语义部分。

如果你去看左值右值的定义或者到CSDN上去找什么是左值右值,你可能会看得晕头转向。不过我们不需要对背诵左值和右值的定义,只需要用一个基本的原则指导我们去应用左值和右值就可以了。毕竟我们只是需要学习其用法而不是做语言律师。

这个基本原则就是:

  • 左值对应于一个实实在在的内存位置,右值只是临时的对象,它的内存对程序来说只能读不能写。

以上原则或许不能精确描述左值和右值的定义,但是足够我们理解左值和右值的应用。

我们结合一些具体的例子来应用上面的原则。

基本概念

int i = 10;
i = 5;
int a = i;
a = i;

这里a, i就是左值,10, 5为右值,我们可以用右值来初始化左值或赋值,也可以用左值来初始化左值或赋值给左值。

10 = i; // error

而左值显然不能赋给右值。

应用基本原则上述都是很自然的事情,右值没有存储其的位置,自然不能给它赋值,左值就当成一个变量,想怎么赋值就怎么赋值。

引用

// int& b = 5;  // can't reference rvalue
int& c = i; // allowed

可以对一个有地址的变量创建引用(引用本质上就是指针的语法糖),右值没有地址自然不能引用。

函数返回值

关于函数返回值和参数完全可以把传参和返回过程看成是赋值来理解。

int GetValue() {
return 5;
}
i = GetValue();
GetValue() = i; // error

这里GetValue函数的返回值为右值,可以当成和前面一样的情况。

int& GetLValue() {
static int value = 10;
return value;
}
i = GetLValue(); // true
GetLValue() = i; // true

函数的返回值一样可以是左值,不过要注意的是函数不能返回其临时变量,因为临时变量虽然有其内存位置,但是函数调用结束后栈帧就销毁了,临时变量一并销毁了,所以就不能作为左值了。

函数参数

void SetValue(int value) {}

void SetLValue(int& value) {}

SetValue(i);
SetValue(5); SetLValue(i);
SetLValue(5); // error

这几个可以用作练习。

Const

上面的函数参数问题似乎有些让人恼火,因为有时候你确实就是想传入一个值而不是创建一个变量再传入,实际上C++为此提供了解决方案。

const int & d = 5;

void SetConstValue(const int& value) {}
SetConstValue(i);
SetConstValue(5);

你可能会想说,这样就没法在函数里改变value的值了。但是如果你需要改变value的值,你就不能传入一个右值。二者不可兼得。

右值引用

现在我们介绍一个对于移动语义实现的关键。

前面我们说到int&只接受左值,const int&左右值都接受,那么有没有一种方式只接受右值呢?

void PrintName(const std::string& name) {
std::cout << "[lvalue] " << name << std::endl;
}
void PrintName(const std::string&& name) {
std::cout << "[rvalue] " << name << std::endl;
} std::string firstName = "Yan";
std::string lastName = "Chernikov";
std::string fullName = firstName + lastName; PrintName(fullName);
PrintName(firstName + lastName);

注意第二个函数的参数类型,相较于前一个多了一个&符号,代表其仅接受右值引用。

以上程序的输出为:

[lvalue] YanChernikov
[rvalue] YanChernikov

移动语义

为什么需要移动语义?

首先来讲讲我们为什么需要移动语义,很多时候我们只是单纯创建一些右值,然后赋给某个对象用作构造函数。

这时候会出现的情况是,我们首先需要在main函数里创建这个右值对象,然后复制给这个对象相应的成员变量。

如果我们可以直接把这个右值变量移动到这个成员变量而不需要做一个额外的复制行为,程序性能就这样提高了。

例子

让我们看下面这样一个例子

#include <iostream>
#include <cstring> class String {
public:
String() = default;
String(const char* string) {
printf("Created!\n");
m_Size = strlen(string);
m_Data = new char[m_Size];
memcpy(m_Data, string, m_Size);
} String(const String& other) {
printf("Copied!\n");
m_Size = other.m_Size;
m_Data = new char[m_Size];
memcpy(m_Data, other.m_Data, m_Size);
} ~String() {
delete[] m_Data;
} void Print() {
for (uint32_t i = 0; i < m_Size; ++i)
printf("%c", m_Data[i]); printf("\n");
}
private:
char* m_Data;
uint32_t m_Size;
}; class Entity {
public:
Entity(const String& name)
: m_Name(name) {}
void PrintName() {
m_Name.Print();
}
private:
String m_Name;
}; int main(int argc, const char* argv[]) {
Entity entity(String("Cherno"));
entity.PrintName(); return 0;
}

程序的输出结果是

Created!
Copied!
Cherno

可以看到中间发生了一次copy,实际上这次copy发生在Entity的初始化列表里。

从String的复制构造函数可以看到,复制过程中还申请了新的内存空间!这会带来很大的消耗。

移动构造函数

现在让我们为String写一个移动构造函数并为Entity重载一个接受右值引用参数的构造函数,另外我们还将原来的构造函数注释掉了。

    String(String&& other) {
printf("Moved!\n");
m_Size = other.m_Size;
m_Data = other.m_Data;
other.m_Data = nullptr;
other.m_Size = 0;
} ~String() {
printf("Destroyed!\n");
delete[] m_Data;
} Entity(String&& name)
: m_Name(name) {} // Entity(const String& name)
// : m_Name(name) {}

输出为

Created!
Copied!
Destroyed!
Cherno
Destroyed!

幸运的是可以看到没有报错,确实调用了新写的Entity的构造函数并输出了结果。

但是不幸的是还是调用了String的赋值构造函数,问题出在哪呢?

实际上接受右值的函数在参数传进来后其右值属性就退化了,所以给m_Name的参数仍然是左值,还是会调用复制构造函数。

解决的办法是将name转型,

Entity(String&& name)
:m_Name((String&&)name) {}

但是这样的作法并不优雅,C++为了提供了更为优雅的做法

Entity(String&& name)
:m_Name(std::move(name)) {}

修改之后的输出结果为

Created!
Moved!
Destroyed!
Cherno
Destroyed

完美!

移动赋值运算符

上面的例子讲了关于移动构造函数的例子,然而有时候我们想要将一个已经存在的对象移动给另一个已经存在的对象,就像下面这样。

int main(int argc, const char* argv[]) {
String apple = "apple";
String orange = "orange"; printf("apple: ");
apple.Print();
printf("orange: ");
orange.Print(); orange = std::move(apple); printf("apple: ");
apple.Print();
printf("orange: ");
orange.Print();
return 0;
}

我们需要的是一个移动赋值运算符重载

    String& operator=(String&& other) {
printf("Moved\n");
if (this != &other) {
delete[] m_Data; m_Size = other.m_Size;
m_Data = other.m_Data; other.m_Data = nullptr;
other.m_Size = 0;
}
return *this;
}

注意这里的实现还是有点讲究的,因为移动赋值相当于把别的对象的资源都偷走,那如果移动到自己头上了就没必要自己偷自己 。

更重要的是原来自己的资源一定要释放掉,否则指向自己原来内容内存的指针就没了,这一片内存就泄露了!

上述输出结果是

Created!
Created!
apple: apple
orange: orange
Moved
apple: orange
orange:
Destroyed!
Destroyed!

很漂亮,orange的内容被apple偷走了。

C++ 三/五法则

浏览知乎时看到了如下的回答

其实这说的就是如果有必要实现析构函数,那么就有必要一并正确实现复制构造函数和赋值运算符,这被称为三法则。

如果加上这一节所讲的移动构造函数和移动赋值运算符,则被称为五法则。

上述法则可以用来识别C++项目的代码质量,既然在用C++写代码,希望就能写出符合规范的优雅的代码,做一个更优秀的C++er。

更多详细资料可以参考C++ 三/五法则 - 阿玛尼迪迪 - 博客园 (cnblogs.com)

参考资料

陈硕大佬关于识别C++项目代码质量的回答

Cherno C++视频教程

C++ 三/五法则 - 阿玛尼迪迪 - 博客园 (cnblogs.com)

C++移动语义 详细讲解【Cherno C++教程】的更多相关文章

  1. node+vue进阶【课程学习系统项目实战详细讲解】打通前后端全栈开发(1):创建项目,完成登录功能

    第一章 建议学习时间8小时·分两次学习      总项目预计10章 学习方式:详细阅读,并手动实现相关代码(如果没有node和vue基础,请学习前面的vue和node基础博客[共10章]) 视频教程地 ...

  2. Step by Step 真正从零开始,TensorFlow详细安装入门图文教程!帮你完成那个最难的从0到1

    摘要: Step by Step 真正从零开始,TensorFlow详细安装入门图文教程!帮你完成那个最难的从0到1 安装遇到问题请文末留言. 悦动智能公众号:aibbtcom AI这个概念好像突然就 ...

  3. auth权限认证详细讲解

    auth权限认证详细讲解 一.总结 一句话总结:四表两组关系,一个多对多(权限和用户组之间)(多对多需要3个表),一个一对多(用户和用户组之间) 1.实际上使用Auth是需要4张表的(1.会员表 2. ...

  4. python format函数/print 函数详细讲解(4)

    在python开发过程中,print函数和format函数使用场景特别多,下面分别详细讲解两个函数的用法. 一.print函数 print翻译为中文指打印,在python中能直接输出到控制台,我们可以 ...

  5. vue-cli 目录结构详细讲解

    https://juejin.im/post/5c3599386fb9a049db7351a8 vue-cli 目录结构详细讲解 目录 结构预览 ├─build // 保存一些webpack的初始化配 ...

  6. head标签详细讲解

    head标签详细讲解 head位于html网页的头部,后前的标签,并以开始以结束的一html标签. Head标签位置如图: head标签示意图 head包含标签 meta,title,link,bas ...

  7. 详细讲解nodejs中使用socket的私聊的方式

    详细讲解nodejs中使用socket的私聊的方式 在上一次我使用nodejs+express+socketio+mysql搭建聊天室,这基本上就是从socket.io的官网上的一份教程式复制学习,然 ...

  8. iOS KVC详细讲解

    iOS KVC详细讲解 什么是KVC? KVC即NSKeyValueCoding,就是键-值编码的意思.一个非正式的 Protocol,是一种间接访问对象的属性使用字符串来标识属性,而不是通过调用存取 ...

  9. Android webservice的用法详细讲解

    Android webservice的用法详细讲解 看到有很多朋友对WebService还不是很了解,在此就详细的讲讲WebService,争取说得明白吧.此文章采用的项目是我毕业设计的webserv ...

随机推荐

  1. get方式和post方式的区别

     1.请求的URL地址不同:             post:"http://192.168.13.83:8080/itheima74/servlet/LoginServlet" ...

  2. webpack热更新 同时导出文件到本地

    webpack 配置热更新后,文件配置导出到本地 安装 npm i webpack-dev-server-output --save-dev 引入 const WebpackDevServerOutp ...

  3. Serializable接口中serialVersionUID字段的作用

    序列化运行时使用一个称为 serialVersionUID 的版本号与每个可序列化类相关联,该序列号在反序列化过程中用于验证序列化对象的发送者和接收者是否为该对象加载了与序列化兼容的类. 如果接收者加 ...

  4. socket在php作用

    PHP 使用Berkley的socket库来创建它的连接.你可以知道socket只不过是一个数据结构.你使用这个socket数据结构去开始一个客户端和服务器之间的会话.这个服务器是一直在监听准备产生一 ...

  5. 线性结构和非线性结构、稀疏数组、队列、链表(LinkedList)

    一.线性结构和非线性结构 线性结构: 1)线性绪构作为最常用的数据结构,其特点是数据元素之间存在一对一的线性关系 2)线性结构有两种不同的存储结构,即顺序存储结构和链式存储结构.顺序存储的线性表称为顺 ...

  6. Windows安装MySQL5.7解压版

    1. 解压后根目录添加配置文件my.ini [client] default-character-set=utf8mb4 [mysql] default-character-set=utf8mb4 [ ...

  7. Linux忘记Root密码怎么找回

    进入1级别,单用户模式 ,修改root密码即可(运行级别不懂看这里) 具体操作如下: 1.开机时按enter键 2.进入GRUB界面 3.输入 e,在引导系统前编辑命令 4.选择第二行 kernel ...

  8. elk监听Java日志发送微信报警

    一年前写过logstash根据日志关键词报警 ,今年重温一下.并且记录一下遇到的问题解决办法. Java错误日志一般出现一大坨,如下图: 所以我们的filebeat日志收集器就要改成多行匹配模式,以日 ...

  9. Java高性能本地缓存框架Caffeine

    一.序言 Caffeine是一个进程内部缓存框架,使用了Java 8最新的[StampedLock]乐观锁技术,极大提高缓存并发吞吐量,一个高性能的 Java 缓存库,被称为最快缓存. 二.缓存简介 ...

  10. 如何使用 Rancher Desktop 访问 Traefik Proxy 仪表板

    Adrian Goins 最近举办了关于如何使用 K3s 和 Traefik 保护和控制边缘的 Kubernetes 大师班,演示了如何访问 K3s 的 Traefik Proxy 仪表板,可以通过以 ...