如果你对外语感兴趣,那肯定听过“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. 算法图解...pdf

    电子书资源:算法图解 书籍简介   本书示例丰富,图文并茂,以让人容易理解的方式阐释了算法,旨在帮助程序员在日常项目中更好地发挥算法的能量.书中的前三章将帮助你打下基础,带你学习二分查找.大O表示法. ...

  2. 使用Font Awesome替换EasyUI的图标

    用过EasyUI的朋友都知道,大部分组件都有一个iconCls属性,用于显示一个图标.但是EasyUI自带图标数量少.不美观,于是想到了使用Font Awesome来更换和拓展这些图标. 先看看Eas ...

  3. AI数学基础之:P、NP、NPC问题

    目录 简介 P问题 NP问题 NP问题的例子 有些NP问题很难解决 NPC问题 NP-hard P和NP问题 简介 我们在做组合优化的时候需要去解决各种问题,根据问题的复杂度不同可以分为P.NP.NP ...

  4. 1443. Minimum Time to Collect All Apples in a Tree

    Given an undirected tree consisting of n vertices numbered from 0 to n-1, which has some apples in t ...

  5. hdu1960 最小路径覆盖

    题意:       给你明天的出租车订单,订单中包含每个人的起点和终点坐标,还有时间,如果一辆出租车想接一个乘客必须在每个订单前1分钟到达,也就是小于等于time-1,问你完成所有订单要最少多少量出租 ...

  6. POJ1679判断最小生成树的唯一性

    题意:      判断最小树是否唯一. 思路:      我用了两种方法,主要就是好久没敲了,找个水题练练手,第一种就是先一遍最小生成树,然后枚举最小生成树上的每一条边,然后取消这条边,在跑一遍最小生 ...

  7. c# p/invoke 无法加载指定的dll 找不到指定的模块 解决方法

    写的程序本来开始好好的,不知道怎么突然就出现了以上这个问题,纠结了好久,网上找了各种方法,比如什么嵌入dll,在system32下面放入dll等等,均宣告失败 下面把我的解决方法写出来,以后只要是这个 ...

  8. c/c++ 中访问2维数组的方式

    指针是c的灵魂,这个真是不容置疑,太灵活了,太随意了, 当然,如果理解了,用得好,比弱类型语言的var 用的还舒服, 用的不好,那就是程序的灾难,哈哈,不多说了,访问二维或多维数组有如下几种方式,下面 ...

  9. 【CSS】CSS3从入门到深入(复习查漏向

    CSS3从入门到深入(复习查漏向 pre_section CSS:层叠样式表,决定网页表现 网页为多层结构,CSS为每一层设置样式,最后显示最上一层 CSS语句 = 选择器 + 声明块 形式 内联样式 ...

  10. layui处理表单/按钮进行多次提交

    在一个项目中,我们最频繁的操作是CRUD,所以一定有涉及到按钮的操作.比如:确认保存,确认编辑,确认删除等等.所以,为了避免表单进行多次提交就显得特别地重要. 代码实现 知识点 $(':button' ...