如果你对外语感兴趣,那肯定听过“idiom”这个词。牛津词典对于它的解释叫惯用语,再精简一些可以叫“成语”。想要掌握一门语言,其中的“成语”是不能不学的,而希望成为地道的语言使用者,“idiom”则是必不可少的。程序语言其实和外语也很类似,两者都有自己的语法,一个个函数也就像一个个词汇,大部分的外语都是自然语言,有着深厚的历史文化底蕴,因此有不少idiom,而编程语言虽然只有短短数十岁,idiom却不比前者少。不过对于程序设计语言的idiom来说比起文化历史的积累倒更像是工程经验指导下的最佳实践。

话说回来,我并不是在推荐你像学外语一样学c++,然而想要做个一个地道的c++程序员,常见的idiom是不可不知的。今天我们就来看看copy and swap idiom是怎么一回事。

本文索引

设计一个二维数组

前排提示:不要模仿这个例子,有类似的需求应该寻找第三方库或者使用容器/智能指针来实现类似的功能。

现在我们来设计一个二维数组,这个二维数组可以存任意的数据,所以我们需要泛型;我还想要能在初始化时指定数组的长度,所以我们需要一个构造函数来分配动态数组,于是我们的代码第一版是这样的:

template <typename T>
class Matrix {
public:
Matrix(unsigned int _x, unsigned int _y)
: x{_x}, y{_y}
{
data = new T*[y];
for (auto i = 0; i < y; ++i) {
data[i] = new T[x]{};
}
} ~Matrix() noexcept
{
for (auto i = 0; i < y; ++i) {
delete [] data[i];
}
delete [] data;
} private:
unsigned int x = 0;
unsigned int y = 0;
T **data = nullptr;
};

x是横向长高度,y是纵向长度,而在c++里想要表示这样的结构正好得把x和y对调,这样一个x=4, y=3的matrix看上去是下面的效果:

显而易见,我们的二维数组其实是多个单独分配的一维数组组合而成的,这也意味着他们之间的内存可能不是连续的,这也是我不推荐模仿这种实现的原因之一。

在构造函数中我们分配了内存,并且对数组使用了方括号初始化器,所以数组内如果是类类型数据则会默认初始化,如果是标量类型(int, long等)则会进行零初始化,因此不用担心我们的数组里会出现未初始化的垃圾值。

接着我们还定义了析构函数用于释放资源。

看起来一个简易的二维数组类Matrix定义好了。

还缺些什么

对,直觉可能告诉你是不是还有什么遗漏。

直觉通常是不可靠的,然而这次它却十分准,而且我们遗漏的东西不止一个!

不过在查漏补缺之前请允许我对两个早就人尽皆知的c++原则炒个冷饭。

rule of zero

c++的类类型里有几种特殊成员函数:默认构造函数、复制构造函数、移动构造函数、析构函数、复制赋值运算符和移动赋值运算符。

如果用户没有定义(哪怕是空函数体,除非是=default)这些特殊成员函数,且没有其他语法定义的冲突(比如定义了任何构造函数都会导致默认构造函数不进行自动合成),那么编译器会自动合成这些特殊成员函数并用在需要它们的地方。

其中复制构造/赋值、移动构造/赋值是针对每一项类的非静态数据成员进行复制/移动。析构函数则自动调用每一项类的非静态数据成员的析构函数(如果有的话)。

看起来是很便利的功能吧,假如我的类有10个成员变量,那编译器自动合成这些函数可以省去不少烦恼了。

这就是rule of zero:如果你的类没有自己定义任何一个除了默认构造函数外的特殊成员函数,那么就不应该定义任何一个复制/移动构造函数、复制/移动赋值运算符、析构函数

标准库的容器都定义了自己的资源管理手段,如果我们的类只使用这些标准库里的内容,或者没有自己申请分配资源(文件句柄,内存)等,则应该遵守“rule of zero”,编译器会自动为我们合成合适的函数。

默认只进行浅复制

如果我要在类里分配点资源呢?比如某些系统的文件句柄,共享内存什么的。

那就要当心了,比如对于我们的Matrix,编译器合成的复制赋值运算符是类似这样的:

template <typename T>
class Matrix {
public:
/* ... */
// 合成的复制赋值运算符类似下面这样
Matrix& operator=(const Matrix& rhs)
{
x = rhs.x;
y = rhs.y;
data = rhs.data;
} private:
unsigned int x = 0;
unsigned int y = 0;
T **data = nullptr;
};

问题很明显,data被浅复制了。对于指针的复制操作,默认只会复制指针本身,而不会复制指针所指向的内存。

然而即使能复制指针指向的内存,在我们这个Matrix里还是有问题的,因为data指向的内存里存的几个也是指针,它们分别指向别的内存区域!

这样会有什么危害呢?

两个指针指向同一个区域,而且两个指针最后都会被析构函数delete,当delete第二个指针的时候就会导致双重释放的bug;如果只删除其中一个指针,两个指针指向的内存会失效,对另一个指针指向的失效内存进行访问将会导致更著名的“释放后重用”漏洞。

这两类缺陷犹如c++er永远无法苏醒的梦魇。这也是我不推荐你模仿这个例子的又一个原因。

rule of five

如果“rule of zero”不适用,那么就要遵循“rule of five”的建议了:如果复制类特殊成员函数、移动类特殊成员函数、析构函数这5个函数中定义了任意一个(显式定义,不包括编译器合成和=default,那么其他的函数用户也应该显式定义

有了自定义析构函数所以需要其他特殊成员函数很好理解,因为自定义析构函数通常意味着释放了一些类自己申请到的资源,因此我们需要其他函数来管理类实例被复制/移动时的行为。

而通常移动类特殊成员函数和复制类的是相互排斥的。

移动意味着所有权的转移,复制意味着所有权共享或是从当前类复制出一个一样的但是完全独立的新实例,这些对于所有权移动模型来说都是禁止的行为,因此一些类只能移动不能复制,比如mutexunique_ptr

而一些东西是支持复制的,但移动的意义不大,比如数组或者一块被申请的内存。

最后一种则同时支持移动和复制,通常复制产生副本是有意义的,而移动则在某些情况下帮助从临时对象那里提高性能。比如vector

我们的Matrix恰好属于后者,移动可以提高性能,而复制出副本可以让同一个二维数组被多种算法处理。

Matrix本身定义了析构函数,因此根据“rule of five”应该至少实现移动类或复制类特殊成员函数中的一种,而我们的类要同时支持两种语义,自然是一个也不能落下。

copy and swap惯用法

说了这么多也该进入正题了,篇幅有限,所以我们重点看复制类函数的实现。

实现自定义复制

因为浅拷贝的一系列问题,我们重新实现了正确的复制构造函数和复制赋值运算符:

// 普通构造函数
Matrix<T>::Matrix(unsigned int _x, unsigned int _y)
: x{_x}, y{_y}
{
data = new T*[y];
for (auto i = 0; i < y; ++i) {
data[i] = new T[x]{};
}
} Matrix<T>::Matrix(const Matrix &obj)
: x{obj.x}, y{obj.y}
{
data = new T*[y];
for (auto i = 0; i < y; ++i) {
data[i] = new T[x];
for (auto j = 0; j < x; ++j) {
data[i][j] = obj.data[i][j];
}
}
} Matrix<T>& Matrix<T>::operator=(const Matrix &rhs)
{
// 检测自赋值
if (&rhs == this) {
return *this;
} // 清理旧资源,重新分配后复制新数据
for (auto i = 0; i < y; ++i) {
delete [] data[i];
}
delete [] data;
x = rhs.x;
y = rhs.y;
data = new T*[y];
for (auto i = 0; i < y; ++i) {
data[i] = new T[x];
for (auto j = 0; j < x; ++j) {
data[i][j] = rhs.data[i][j];
}
}
return *this;
}

这样做正确,但非常啰嗦。比如复制构造函数里初始化xy和分配内存的工作实际上和构造函数中的没有区别,一句老话叫“Don't repeat yourself”,所以我们可以借助c++11的新语法构造函数转发把这部分工作委托给构造函数,我们的复制构造函数只进行数组元素的复制:

Matrix<T>::Matrix(const Matrix &obj)
: Matrix(obj.x, obj.y)
{
for (auto i = 0; i < y; ++i) {
for (auto j = 0; j < x; ++j) {
data[i][j] = obj.data[i][j];
}
}
}

复制赋值运算符里也有和构造函数+析构函数重复的部分,我们能简化吗?遗憾的是我们不能在赋值运算符里转发操作给构造函数,而delete this后再使用构造函数也是未定义行为,因为this代指的类实例如果不是new分配的则不合法,如果是new分配的也会因为delete后对应内存空间已失效再次进行访问是“释放后重用”。那我们先调用析构函数再在同一个内存空间上构造Matrix呢?对于能平凡析构的类型来说,这是完全合法的,可惜的是自定义析构函数会让类无法“平凡析构”,所以我们也不能这么做。

虽说不能简化代码,但我们的类不是也能正确工作了吗,先上线再说吧。

如果发生了异常

看起来Matrix可以正常运行了,然而上线几天后程序崩溃了,因为复制赋值运算符的new语句或是某次数组元素拷贝抛出了一个异常。

你想这样什么大不了的,我早就未雨绸缪了:

try {
Matrix<T> a{10, 10};
Matrix<T> b{20, 20};
// 一些操作
a = b;
} catch (exception &err) {
// 打些log,然后对a和b做些善后
}

这段代码天衣无缝的外表下却暗藏杀机:a在复制失败后原始数据已经删除,而新数据也可能只初始化了一半,这是访问a的数据会导致多种未定义行为,其中一部分会让系统崩溃。

关键在于如何让异常发生的时候a和b都能保持有效状态,现在我们可以保证b有效,需要做到的是如何保证a能回到初始化状态或者更好的办法——让a保持赋值前的状态不变。

至于为什么不让赋值运算不抛异常,因为我们控制不了用户存入的T类型的实例会不会抛异常,所以不能进行控制。

copy and swap

现在我们不仅没解决重复代码的问题,我们的赋值运算符几乎把析构函数和复制构造函数抄了一遍;还引入了新的问题赋值运算的异常安全性——要么赋值成功,要么别对运算的操作数产生任何影响。

该轮到“copy and swap惯用法”闪亮登场了,它可以帮我们一次解决这两个问题。

我们来看看它有什么妙招:

  1. 首先我们用复制构造函数从rhs复制出一个tmp,这一步复用了复制构造函数;
  2. 接着用一个保证不会发生错误的swap函数交换tmp和this的成员变量;
  3. 函数返回,交换后的tmp销毁,等于复用了析构函数,旧资源也得到了正确清理。

如果复制发生错误,那么前面例子里的a不会被改变;如果tmp析构发生错误,当然这是不可能的,因为我们已经把析构函数声明成noexcept了,还要抛异常只能说明程序遇到了非常严重的错误会被系统立即中止运行。

显然,重点是swap函数,我们看看是怎么实现的:

template <typename T>
class Matrix {
friend void swap(Matrix &a, Matrix &b) noexcept
{
using std::swap; // 这一步允许编译器基于ADL寻找合适的swap函数
swap(a.x, b.x);
swap(a.y, b.y);
swap(a.data, b.data);
}
};

通过ADL,我们可以利用std::swap或是某些类型针对swap实现的优化版本,而noexcept则保证了我们的swap不会抛出异常(简单的交换通常都基于移动语义实现,一般保证不会产生异常)。本质上swap的逻辑是很简洁明了的。

有了swap帮忙,现在我们的赋值运算符可以这么写了:

Matrix<T>& Matrix<T>::operator=(const Matrix &rhs)
{
// 检测自赋值
if (&rhs == this) {
return *this;
} Matrix tmp = rhs; // copy
swap(tmp, *this); // swap
return *this;
}

你甚至还可以省去自赋值检测,因为现在使用了copy and swap后自赋值除了浪费了点性能外已经无害了

使用“copy and swap惯用法”不仅解决了代码复用,还保证了赋值操作的安全性,真正的一箭双雕。

对于移动赋值

移动赋值运算本身只是释放左操作数的数据,再移动一些已经获得的资源然后把rhs重置会安全的初始化状态,这些通常都不会产生异常,代码也很简单没有太多重复,只不过释放数据和把数据从rhs移动到lhs,这两个操作是不是有点眼熟?

对,swap写出来就是为了干这种杂活的,所以我们还能实现move and swap

Matrix<T>& Matrix<T>::operator=(Matrix2 &&rhs) noexcept
{
Matrix2 tmp{std::forward<Matrix2>(rhs)};
swap(*this, tmp);
return *this;
}

当然,正如我说的,通常没必要这么写。

性能对比

现在我们的Matrix已经可以健壮地管理自己申请的内存资源了。

然而还有最后一点疑问:我们知道copy and swap会多创建一个临时对象并多出一次交换操作,这对性能会带来多大的影响呢?

我只能说会有一点影响,但这个“一点”到底是多少不跑测试我也口说无凭。所以我基于google benchmark写了个简单测试,如果还不了解benchmark怎么用,可以看看我写的教程

全部的测试代码有200行,实在是太长了,所以我把它贴在了gist上,你可以在这里查看。

下面是在我的机器上的测试结果:

可以看到性能差异几乎可以忽略不计,因为Matrix只有三个简单的成员变量,自然也不会有太大的开销。

所以我的建议是:能上copy and swap的地方尽量上,除非你测试显示copy and swap带来了严重的性能瓶颈。

参考

https://stackoverflow.com/questions/3279543/what-is-the-copy-and-swap-idiom

https://stackoverflow.com/questions/6687388/why-do-some-people-use-swap-for-move-assignments

https://stackoverflow.com/questions/32234623/using-swap-to-implement-move-assignment

http://www.vollmann.ch/en/blog/implementing-move-assignment-variations-in-c++.html

https://cpppatterns.com/patterns/copy-and-swap.html

做个地道的c++程序猿:copy and swap惯用法的更多相关文章

  1. 做一枚精致的程序猿,Fighting!

    这几天我和我们的团队正在做一个公司管理系统的项目,团队分工根据成员的水平高低来分工,这样看似公平,但其实不公平,如此这样一来,那些水平稍不如别人的成员就没有发展的机会?那么问题来了,对于水平稍逊色的程 ...

  2. Java全栈程序员之01:做个Linux下的程序猿

    Windows10正在成为史上口碑最差的Windows系统 (图侵删) 我曾经花了数次1小时去寻找解决方案去关闭自动更新,包括停掉服务.修改注册表等等.但是都没有成功. 微软自身是知道这个问题的,但就 ...

  3. 关于App程序猿泡沫

    前言 做开发快七年了,对于程序猿,外行人总有着数不完的讽刺和误解,可是我都懒得去解释.代码搬运工人也好,民工也罢,随他们去说吧.可是网上近期流传的程序猿泡沫,尤其是APP程序猿泡沫的文章导致非常多我们 ...

  4. 程序猿修仙之路--数据结构之你是否真的懂数组? c#socket TCP同步网络通信 用lambda表达式树替代反射 ASP.NET MVC如何做一个简单的非法登录拦截

    程序猿修仙之路--数据结构之你是否真的懂数组?   数据结构 但凡IT江湖侠士,算法与数据结构为必修之课.早有前辈已经明确指出:程序=算法+数据结构  .要想在之后的江湖历练中通关,数据结构必不可少. ...

  5. [MarsZ]程序猿谈大学之为什么不推荐就业时做程序猿

    这篇文章适合一切有志做一个程序猿的人,而不仅仅只是即将进入就业市场的大学生. “又到了毕业找工作的时候了,好多朋友打电话向我咨询要不要让孩子做程序员.作为一个业内资深人士,我觉得这不能一概而论!要辩证 ...

  6. 如何做程序猿SOHO它定购家庭赚外快?

    做为一名程序猿.我想大多数人除了平时削尖了脑袋研究各种各样的技术之外. ArticleId=28404183" width="1" height="1" ...

  7. 做一个懒COCOS2D-X程序猿(一)停止手打所有cpp文件到android.mk

    前言:”懒”在这里当然不是贬义词,而是追求高效,拒绝重复劳动的代名词!做一个懒COCOS2D-X程序猿的系列文章将教会大家在工作中如何偷懒,文章篇幅大多较短,有的甚至只是几行代码,争取把懒发挥到极致! ...

  8. IT程序猿们,我该做什么选择呢

    这个时刻,我想我遇到人生小拐点了,程序猿到了30岁,到达了一个分界线了,现在的我该何去何从呢? 先谈下简单的情况吧: 来这个公司2年了,之前因为身体的原因,不想那么累,于是选择了一份维护的工作,就来了 ...

  9. 如何向非技术人(程序猿)解释SQL注入?

    前两天看博客园新闻,有一篇文章名为<我该如何向非技术人解释SQL注入?>(http://kb.cnblogs.com/page/515151/).是一个外国人写的,伯乐在线翻译的.我当时看 ...

随机推荐

  1. k8s 证书更新操作

    kubernetes证书更新 版本:1.14.2,以下操作在3台master节点上操作 1.各个证书过期时间 /etc/kubernetes/pki/apiserver.crt #1年有效期 /etc ...

  2. 1022 Digital Library

    A Digital Library contains millions of books, stored according to their titles, authors, key words o ...

  3. CSS快速入门基础篇,让你快速上手(附带代码案例)

    1.什么是CSS 学习思路 CSS是什么 怎么去用CSS(快速上手) CSS选择器(难点也是重点) 网页美化(文字,阴影,超链接,列表,渐变等) 盒子模型 浮动 定位 网页动画(特效效果) 项目格式: ...

  4. linux下export命令添加删除环境变量

    Linux export命令参数   功能说明:设置或显示环境变量. 语 法:export [-fnp][变量名称]=[变量设置值] 补充说明:在shell中执行程序时,shell会提供一组环境变量. ...

  5. hdu1043 经典的八数码问题 逆向bfs打表 + 逆序数

    题意: 题意就是八数码,给了一个3 * 3 的矩阵,上面有八个数字,有一个位置是空的,每次空的位置可以和他相邻的数字换位置,给你一些起始状态 ,给了一个最终状态,让你输出怎么变换才能达到目的. 思路: ...

  6. PhpMyWind储存型XSS漏洞练习(CVE-2017-12984)

    0x01 介绍 又是一款开源CMS内容管理系统PhpMyWind,在小于等于5.4版本中存在储存型XSS漏洞.如下图所示,这个就是发生储存型XSS漏洞的代码 0x02 演示 1.第一张图是客户留言时, ...

  7. Windows PE资源表编程(枚举资源树)

    资源枚举 写一个例子,枚举一个PE文件的资源表.首先说下资源相关的作为铺垫. 1.资源类型也是PE可选头中数据目录的一种.位于第三个类型. 2.资源目录分为三层.第四层是描述文件相关的.这些结构是按照 ...

  8. PowerShell-3.多线程

    $start = Get-Date $task1 = { $vUrl = 'http://img.mottoin.com/wp-content/uploads/2016/09/5-25.png' $v ...

  9. 【目录】Java项目开发中的知识记录

    此篇文章为学习Java的目录,<a href="#"></>这种的是还没有写的文章.已经加a标签的是已经写完的.没写的文章急切需要的话可以直接留言,不是特别 ...

  10. Java前后端分离的认识

    1.原由 在网上查了关于前后端分离的资料,有所粗浅认识.记录下来,方便以后使用.以下均是个人看法,仅做参考.如有错误请指教,共同进步. 2.为什么前后端分离? ①.一个后台,可以让多种前台系统使用.后 ...