Effective C++条款25、26、27
条款二十五: 考虑写出一个不抛出异常的swap函数
我也不知道为什么作者给这个条款起这样的名字,因为这样看上去重点是在“不抛出异常”,但事实上作者只是在全文最后一段说了一下不抛异常的原因,大部分段落是在介绍怎样写一个节省资源的swap函数。
你可以试一下,只要包含了头文件iostream,就可以使用swap函数,比如:
#include <iostream>
int main()
{
int a = 3;
int b = 4;
std::swap(a, b);
}
结果就是a为4,b为3了,也就是说,在std的命名空间内,已经有现成的swap的函数了,这个swap函数很简单,它看起来像这样:
template<class T>
void swap(T& a, T& b)
{
T c = a;
a = b;
b = c;
}
这是最常见形式的两数交换了(特别地,当T是整数的时候,还可以使用异或的操作,这不是本条款讨论的重点,所以略过了,但面试题里面很喜欢问这个)。
假设存在一个类Element,类中的元素比较占空间
class Element
{
private:
int a;
int b;
vector<double> c;
};
Sample类中的私有成员是Element的指针,有原生指针,大多数情况下都需要自定义析构函数、拷贝构造函数和赋值运算符,像下面一样。
class Sample
{
private:
Element* p;
public:
~Sample();
Sample(const Sample&);
Sample& operator= (const Sample&);
};
在实现operator=的时候,有一个很好的实现方法,参见条款十一。大概像这样:
Sample& operator= (const Sample& s)
{
if(this != &s)
{
Sample temp(s);
swap(*this, temp);
}
return *this;
}
当判断不是自我赋值后,是通过调用拷贝构造函数来创建一个临时的对象(这里可能会有异常,比如不能分配空间等等),如果这个对象因异常没有创建成功,那么下面的swap就不执行,这样不会破坏this的原始值,如果这个对象创建成功了,那么swap一下之后,把临时对象的值换成*this的值,达到了赋值的效果。
上面的解释是条款九的内容,如果不记得了,可以回头翻翻看,本条款的重点在这个swap函数上。这里调用的是默认的std里面的swap函数,它会创建一个临时的Sample对象(拷贝构造函数),然后调用两次赋值运算,这就会调回来了,即在swap函数里面调用operator=,而之前又是在operator=中调用swap函数,这可不行,会造成无穷递归,堆栈会溢出。
因此,我们要写一份自己的swap函数,这个函数是将Sample里面的成员进行交换。
问题又来了,Sample里面存放的是指向Element的指针,那是交换指针好呢,还是逐一交换指针所指向的对象里面的内容好呢?Element里面的东西挺多的,所以显然还是直接交换指针比较好(本质是交换了Element对象存放的地址)。
因此,可以定义一个swap的成员函数。像这样:
void swap(Sample& s)
{
std::swap(p, s.p);
}
Sample& operator= (const Sample& s)
{
if(this != &s)
{
Sample temp(s);
this->swap(s);
}
return *this;
}
但这样看上去有点别扭,我们习惯的是像swap(a, b)这种形式的swap,如果交给其他程序员使用,他们也希望在类外能够像swap(SampleObj1, SampleObj2)那样使用,而不是SampleObj1.swap(SampleObj2)。为此我们可以在std空间里面定义一个全特化的版本(std namespace是不能随便添加东西的,只允许添加类似于swap这样的全特化版本),像这样:
namespace std
{
template<>
void swap<Sample>(Sample &s1, Sample &s2)
{
s1.swap(s2); // 在这里调用类的成员函数
}
}
Sample& operator= (const Sample& s)
{
if(this != &s)
{
Sample temp(s);
swap(*this, s); // 顺眼多了,会先去调用特化版本的swap
}
return *this;
}
这样,就可以在使用namespace std的地方用swap()函数交换两个Sample对象了。
下面书上的内容就变难了,因为假设Sample现在是一个模板类,Element也是模板类,即:
template <class T>
class Element
{…};
template <class T>
class Sample
{…};
那应该怎么做呢?
在模板下特化std的swap是不合法的(这叫做偏特化,编译器不允许在std里面偏特化),只能将之定义在自定义的空间中,比如:
namespace mysample
{
template <class T>
class Element
{…};
template <class T>
class Sample
{…};
template <class T>
void swap(Sample<T> &s1, Sample<T> &s2)
{
s1.swap(s2);
}
}
总结一下,当是普通类时,可以将swap的特化版本放在std的namespace中,swap指定函数时会优先调用这个特化版本;当是模板类时,只能将swap的偏特化版本放在自定义的namespace中。好了,问题来了,这时候用swap(SampleObj1, SampleObj2)时,调用的是std版本的swap,还是自定义namespace的swap?
事实上,编译器还是会优先考虑用户定义的特化版本,只有当这个版本不符合调用类型时,才会去调用std的swap。但注意此时:
Sample& operator= (const Sample& s)
{
if(this != &s)
{
Sample temp(s);
swap(*this, s); // 前面的swap不要加std::
}
return *this;
}
里面的swap不要用std::swap,因为这样做,编译器就会认为你故意不去调用位于samplespace里面的偏特化版本了,而去强制调用std命名空间里的。
为了防止出这个错,书上还是建议当Sample是普通类时,在std命名空间里定义一个全特化版本。
这个条款有些难度,我们总结一下:
- 在类中提供了一个public swap成员函数,这个函数直接交换指针本身(因为指针本身是int类型的,所以会调用std的普通swap函数),像下面这样:
void Sample::swap(Sample &s)
{
swap(p, s.p); // 也可以写成std::swap(this->p, s.p);
}
- 在与Sample在同一个namespace的空间里提供一个non-member swap,并令他调用成员函数里的swap,像下面这样:
template <>
void swap<Sample>(Sample& s1, Sample& s2){s1.swap(s2);} // 如果Sample是普通类,则定义swap位于mysample空间中,同时多定义一个位于std空间中(这个多定义不是必须的,只是防御式编程)
//或者
template <class T>
void swap(Sample<T>& s1, Sample<T>& s2){s1.swap(s2);} // 如果Sample是模板类时,只能定义在mysample空间中
好了,最后一段终于说到了不抛异常的问题,书上提到的是不要在成员函数的那个swap里抛出异常,因为成员函数的swap往往都是简单私有成员(包括指针)的置换,比如交换两个int值之类,都是交换基本类型的,不需要抛出异常,把抛出异常的任务交给non-member的swap吧。
最后总结一下:
当std::swap对你的类型效率不高时,提供一个swap成员函数,这个成员函数不抛出异常,只对内置类型进行操作
如果提供一个member swap,也该提供一个non-member swap来调用前者,对于普通类,也请特化std::swap
调用swap时,区分是调用自身命名空间的swap还是std的swap,不能乱加std::符号
为“用户自定义类型”进行std template全特化是好的,但千万不要尝试在std内加入某些对std而言全新的东西。
条款二十六:尽可能延后变量定义式的出现时间
这个条款从字面意思还是很好理解的,就是在使用这个变量前才去定义,而不是很早就定义了它,而在很后面的时候才去使用。这个条款只适用于对变量声明位置没有要求的语言,比如C++。对于像C或者一些脚本语言,语法要求变量声明放在函数开始处,这个条款就不能使用了。
但其实从使用的角度而言,如果不是语法的硬性要求,还是在变量使用前再去定义变量的做法比较好。这有几点原因,最直观的就是可读性比较好,程序员在阅读代码时,看到一个陌生的变量名,不用向上翻好几页才看到它的定义类型,而且对于开发者而言,也不会出现前面定义了一个变量,之后又忘记使用它的情况。另一个原因就是可以节省资源,考虑以下的代码:
void example(const A& parm)
{
A a;
fun(); // 这个fun()函数不会使用a
a = parm;
…
}
假设存在一个类A,如果在程序的开始处就定义它的对象a(调用了构造函数),中间夹了一个函数fun(),这个函数可能抛出异常,导致下面的程序不去执行,这样对象a的构造函数就白调用了,既浪费了空间,也浪费了时间。
所以改进的做法就是:
void example(const A& parm)
{
fun();
A a;
a = parm;
…
}
这下如果fun()抛出异常,那么也不会浪费资源了,但这里还是有一个比较“废”的地方,就是调用了构造函数,也调用了赋值运算,但这两个操作的目的都是给a一个值,所以可以将这两个操作进行精减,直接调用A的拷贝构造函数,像这样:
void example(const A& parm)
{
fun();
A a(parm);
…
}
赋值运算就不用调用了。
书上还讲到了一个写代码常见的问题,就是循环中的变量定义,是放在循环内比较好呢,还是放在循环外比较好呢?看下面的例子:
// 放在循环外
A a;
for(int i = 0; i < N; ++i)
{
a = a * b[i];
…
}
//后面不再使用a
// 放在循环内
for(int i = 0; i < N; ++i)
{
A a(b[i]);
…
}
//后面不再使用a
分析一下,如果采用放在循环外的方法,代价是1次构造+1次析构+N次赋值;如果采用放在循环内的方法,代价是N次构造+N次析构。所以究竟哪个更好,还是要看构造函数、析构函数VS赋值操作,哪一个比较废。
但如果两方的代价差不多,还是推荐放在循环内的做法,因为这种做法使得a的作用域局限于for循环内,符合作用域能小就小的编程原则。
最后总结一下:
尽可能延后变量定义式的出现,这样做可增加程序的清晰度并改善程序效率。
条款二十七:尽量少做转型动作
有关转型的几种做法,已经在早些的博客中写过了。这里先简单回顾一下,再讲一讲effective中对之更深入的阐述。
转型可以按风格可以分成C风格转型和C++风格转型两大类,C风格转型很容易看到,因为我们会经常使用,像
(T) expression
以及:
T (expression)
最经典的例子就是处理整数除法,在C/C++程序中,整数除法的结果还是整数,有时会得不到我们想到的结果,比如3/5,结果是0,而不是0.6,但如果这样double(3) / 5,结果就会是0.6了,因为转型操作double(3),将整数转成了浮点数,这样就是小数除法了,可以得到带小数点的结果。
C++风格的转型操作分成四类:
const_cast<T>(expression)
static_cast<T>(expression)
dynamic_cast<T>(expression)
reinterpret_cast<T>(expression)
effective推荐使用的是前三种,最后一种是把一个指针转成整数或者把整数看成指针,对平台依赖性很强,不建议使用。
const_cast很简单,就是去掉常量属性,比如:
int main()
{
int a= 3;
const int* ca = &a;
int *pb = const_cast<int*> (ca);
*pb = 5;
cout << a << endl; // a的输出结果是5
}
但这种常量性的转换只是针对于指针或引用(包括this指针),是不能针对于普通变量的,比如下面的做法就是错误的:
int main()
{
const int a = 3;
int b = const_cast<int>(a); //编译出错,只能使用static_cast或者C风格转换
}
对于const_cast,既可以将const->非const,也可以将非const->const,只要记住两点,其一是这个操作只对指针或引用有效;其二,这个操作并不改变被转换对象的常属性,对于:
int a= 3;
const int* ca = &a;
int *pb = const_cast<int*> (ca);
//ca仍是指向常量的指针,对它的操作,比如*ca = 5,是会报编译错的。
static_cast是最为常用C++转型了,我们常见的int->double亦或是float->int等等,都是用static_cast来进行转换的(包括const int -> int,以及int -> const int,只要不是常指针/引用->non常指针/引用)。
dynamic_cast是当想把基类指针转成派生类指针时用,这种转换可以保证安全性,当把两个不相干的类之间用dynamic_cast时,转换的结果将是空指针,同时若基类指针指向的对象只是基类本身时,对基类指针进行dynamic_cast向下转型到派生类,会得到空指针,防止进一步的操作。
介绍到这里,需要我们回答一个问题,既然旧式转型已经够用了(毕竟C语言就是这样用的,没有人抱怨转型的功能不够丰富),那为什么C++还要再定义功能重复的转型呢?
原因有两个:第一,细化转型使它们很容易在代码中被辨识出来,可以不去看上下文就知道这样的转型是什么类型的(常量转型还是向下转型);第二,各转型动作的目标愈窄化,编译器愈可能诊断出错误的应用。
下面来讲一下,为什么effective中要我们尽量少做转型动作。
不清楚原理的转型会带来严重的bug,比如书上提到的,假设有一个基类Window,它有一个派生类SpecialWindow,它们都有一个OnResize()的成员函数,在派生类的OnResize()中想要先调用基类的OnResize(),于是有程序员便这样做了:
void SpecialWindow::OnResize()
{
static_cast(*this).OnResize();
…
}
这样真的没有问题吗?将派生类对象转成Window对象,然后对其调用OnResize(),这确确实实调用的是基类的OnResize(),但OnReszie()对成员变量操作的结果是你想要的吗?
答案是否定的,因为static_cast生成的是一个临时的基类的对象,这个对象并不是真正组成派生类的那个基类,而是它的一个副本(这个副本的成员变量值与派生类对象中基类的成分是一样的,但地址不同),调用OnResize()变更的结果是这个副本的成员变量变了,但派生类中包含的基类的成员变量却是没有任何变化的。好了,这就是bug了,在之后的测试中这个问题会让程序员纠结好一阵子。
一句话,转型生成的是一个copy,一份副本,有的时候这并不是你想要的,修正方法其实很简单,就是:
void SpecialWindow::OnResize()
{
Window::OnResize(); // OK了,就这么简单
…
}
有的转型操作也是比较废的,比如dynamic_cast,这个转型会对类名称进行strcmp,以判断向下转型是否合理,如果继承深度比较大,那么每一次的dynamic_cast将会进行多次strcmp,这将严重影响程序的执行效率。解决方法就是如果可以话,直接使用指向子类的指针,真的想用父类的指针(比如工厂设计模式等),那就考虑多态吧,在父类相应的函数前面加virtual,然后子类进行覆盖即可。
最后总结一下,有的程序员认为,转型其实什么也没有做,只是告诉编译器把某种类型视为另一种类型而已。如果说的是指针,这样理解是正确的,但如果说的是全部的话(包括变量),恐怕就不妥了,比如static_cast(a) / b,这一类经典的整数除法->小数除法转换,转换前后在底层产生的代码是绝对不同的。
如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_casts。
如果转型是必要的,试着将它隐藏于某个函数背后,客户可以随后调用这个函数,而不需要将转型放在他们自己的代码里。
宁可使用C++风格的新式转型,少用C风格转型,因为前者很容易辨识出来,而且也比较有着分门别类的职掌。
推荐阅读
-
Effective C++ 笔记:条款 31 将编译关系降至最低
-
Effective Modern C++ 条款32 对于lambda,使用初始化捕获来把对象移动到闭包
-
Effective Modern C++ 条款23 理解std::move和std::forward
-
Effective Modern C++ 条款37 在所有路径上,让std::thread对象变得不可连接(unjoinable)
-
Effective Modern C++ 条款38 意识到线程句柄的析构函数的不同行为
-
Effective Modern C++ 条款22 当使用Pimpl Idiom时,在实现文件中定义特殊成员函数
-
Effective C++ 笔记:条款 33 避免继承导致的名称遮掩
-
Effective C++:条款26:尽可能延后变量定义式的出现时间
-
Effective C++ Term 26 实现一个不抛出异常的 swap
-
Effective C++ 条款26 尽可能延后变量定义式的出现时间