【C++】《Effective C++》第五章
第五章 实现
条款26:尽可能延后变量定义式的出现时间
只要定义了一个变量而其类型带有一个构造函数或析构函数,那么
- 当程序的控制流到达这个变量定义式时,你得承受这个构造成本。
- 当这个变量离开这个作用域时,你得承受这个析构成本。
即使这个变量最终并未被使用,仍然需要耗费这些成本,所以应该尽可能避免这种情形,即延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。
如果变量只在循环内使用,那么把它定义于循环外并在每次循环迭代是赋值给它比较好,还是该把它定义在循环内?
// 方法A:定义于循环外
Widget w;
for(inti i = 0; i < n; ++i) {
w = 取决于i的某个值;
// ...
}
// 方法B:定义于循环内
for(int i = 0; i < n; ++i) {
Widget w(取决于i的某个值);
// ...
}
以上的两种做法的成本如下:
- 做法A:
1
个构造函数 +1
个析构函数 +n
个赋值操作 - 做法B:
n
个构造函数 +n
个析构函数
从效率上看,如果类的一个赋值成本低于一组构造和析构成本,做法A
大体而言比较高效,尤其是当n
的数值很大时。否则做法B
更好。
从可理解性和维护性上看,做法A
造成w
的作用域比做法B更大,可维护性和可理解性相对较差。
请记住
- 尽可能延后变量定义式的出现,这样做可增加程序的清晰度并该改善程序效率。
条款27:尽量少做转型动作
显然,转型(casts
)会破坏类型系统(type system
),但是有时也不得不这样做。
转型分类包括:
- 旧式转型(
old-style casts
):- C风格:
(T) expression
- 函数风格:
T(expression)
- C风格:
- 新式转型(
C++-style casts
):const_cast<T>(expression);
- 通常被用来将对象的常量性转除(
cast away the constness
)。
- 通常被用来将对象的常量性转除(
dynamic_cast<T>(expression);
- 主要用来执行"安全向下转型",也就是用来决定某对象是否归属继承体系中的某个类型、即主要用于将基类指针转换成派生类指针(或引用),通常需要知道转换源和转换目标的类型。可能会耗费重大的运行成本。
reinterpret_cast<T>(expression);
- 通常为算术对象的位模式提供较低层次上的重新解释。例如将
int*
转换为char*
,比较危险的转换!
- 通常为算术对象的位模式提供较低层次上的重新解释。例如将
static_cast<T>(expression);
- 用来强制隐式转换,只要不包含底层const,都可以使用。适合将较大算术类型转换为较小算术类型。例如将
non-const对象
转换为const对象
,int
转为double
等等。唯一例外是无法将const
转为non-const
,这个只要const_cast
才能做到。
- 用来强制隐式转换,只要不包含底层const,都可以使用。适合将较大算术类型转换为较小算术类型。例如将
应该尽可能使用新式转型,主要有两点原因:
- 它们很容易在代码中被辨别出来(无论是人工还是使用工具如
grep
等),因而得以简化"找出类型系统在哪个地方呗破坏"的过程。 - 各转型动作的目标越窄化,编译器越可能诊断出错误的运用。
尽管如此,还是应该尽量少做转型,原因如下:
-
转型不只是告诉编译器把某种类型视为另一种类型这么简单,任何一个转型动作往往令编译器编译出运行期间执行的代码。
int
转型为double
几乎肯定会产生一些代码,因为在大部分体系结构中,int
的底层表述不同于double
的底层表述。
int x, y; // ... double d = static_cast<double>(x)/y;
- 会有个偏移量在运行期被实施于
Derived*
指针上,用以取得正确的Base*
地址。
class Base {}; class Derived : public Base {}; Derived d; Base* pb = &d;
-
很容易写出似是而非的代码。
class Window {
public:
virtual void onResize();
// ...
};
// 错误的用法,因为转型并非在当前对象身上调用Window::onResize(),而是当前对象的base class成分的副本上调用Window::onResize()。
class SpecialWindow: public Window {
public:
virtual void onResize() {
static_cast<Window*>(this)->onResize();
// 进行SpecialWindow的专属行为
}
};
// 正确的用法
class SpecialWindow: public Window {
public:
virtual void onResize() {
Window::onResize();
// 进行SpecialWindow的专属行为
}
};
- 继承中的类型转换效率低:
C++
通过dynamic_cast
实现继承中的类型转换,通常是因为想在一个认定为derived class
对象身上执行derived class
操作没,但是拥有的是一个指向base的指针或引用
。这样做其效率都是相当低的!这种情况下有两种办法可以避免转型:- 使用容器并在其中存储直接指向
derived class
对象的指针 - 将
derived class
中的操作上升到base class
内,成为virtual
函数,base class
提供一份缺省实现
- 使用容器并在其中存储直接指向
请记住
- 如果可以,尽量避免转型,特别是在注重效率的代码中避免
dynamic_casts
。如果有个设计需要转型动作,试着发展无需转型的替代设计。 - 如果转型是必要的,试着将它隐藏于某个函数背后。客户随后可以调用该函数,而不需将转型放进它们自己的代码内。
- 宁可使用
C++-style
(新式)转型,不要使用旧式转型。前者很容易辨识出来,而且也比较有着分门别类的职责。
条款28:避免返回handles指向对象内部成分
references
、指针和迭代器统统都是所谓的handles
,即号码牌,用来取得某个对象。
- 增加封装性:如果成员函数返回
handles
,那么相当于成员变量的封装性从private
上升到public
,这显然与条款22:将成员变量声明为private
冲突。 - 使得"通过const修改对象的数据"成为可能:对象只包含指针成员,实际数据通过这个指针指向,
const成员函数
返回一个这个指针所指对象的引用,并不会造成指针被修改,也就符合bitwise constness
,但是通过这个引用却可以改变对象实际的数据。 - 防止"虚吊"发生:若返回的
handles
指向一个临时对象,那么返回后临时对象被销毁,handles
就会成为"虚吊的",只要handles
被传出去,就会面临"handles
比其所指对象更长寿"的风险。
请记住
- 避免返回
handles
(包括references
、指针和迭代器)指向对象内部。遵守这个条款可增加封装性,帮助const
成员函数的行为像个const
,并将发生"虚吊号码牌"(dangling handles
)的可能性降至最低。
条款29:为”异常安全“而努力是值得的
考虑一个例子,有一个菜单类,changeBg函数可以改变它的背景,切换背景计数,同时提供线程安全。
class Menu {
public:
// ...
void changeBg(std::istream& src);
private:
Mutex mutex; // 互斥器,提供多种线程互斥访问
Image* bg; // 背景图片
int changeCount; // 切换背景次数
};
void Menu::changeBg(std::istream& src) {
lock(&mutex);
delete bg;
++changeCount;
bg = new Image(src);
unlock(&mutex);
}
- 异常安全的两个条件:
- 不泄漏任何资源:即发生异常时,异常发生之前获得的资源都应该释放,不会因为异常而泄漏。在上面的例子中,如果
new Image(src)
发生异常,那么unlock
就永远不会被调用,因此锁资源会泄漏。 - 不允许数据破坏:如果
new Image(src)
发生异常,背景图片会被删除,计数也会改变,但是新背景并未设置成功。
- 不泄漏任何资源:即发生异常时,异常发生之前获得的资源都应该释放,不会因为异常而泄漏。在上面的例子中,如果
对于资源泄漏,条款13:以对象管理资源可以解决;使用智能指针指定删除器也可以解决;
void Menu::changeBg(std::istream& src) {
Lock m1(&mutex);
delete bg;
++changeCount;
bg = new Image(src);
}
对于数据破坏,需要注意异常安全函数的三个保证:
- 基本承诺:抛出异常后,对象仍然处于合法(valid)的状态,但是不确定处于哪个状态。
- 强烈保证:如果抛出了异常,状态并不会发生任何改变。就像没调用这个函数一样。
- 不抛掷保证:这是最强的保证,承诺绝不抛出异常,函数总是能完成它所承诺的事情。
对于前面的例子,可以使用智能指针,以及重排changeBg的语句顺序来满足"强烈保证"。
class Menu {
public:
// ...
void changeBg(std::istream& src);
private:
Mutex mutex; // 互斥器,提供多种线程互斥访问
std::shared_ptr<Image> bg; // 背景图片
int changeCount; // 切换背景次数
};
void Menu::changeBg(std::istream& src) {
Lock m1(&mutex);
bd.reset(new Image(src)); // 以new的执行结果设定bg内部指针
++changeCount;
}
可以使用 copy-and-swap
策略,它通常能够为对象提供异常安全的"强烈保证"。当我们要改变一个对象时,先把它复制一份,然后去修改它的副本,改好了再与原对象交换。
struct MentImpl {
std::shared_ptr<Image> bg; // 背景图片
int changeCount; // 切换背景次数
};
class Menu {
public:
// ...
void changeBg(std::istream& src);
private:
Mutex mutex; // 互斥器,提供多种线程互斥访问
std::shared_ptr<MentImpl> pImpl;
};
void Menu::changeBg(std::istream& src) {
using std::swap;
Lock m1(&mutex); // 获得mutex的副本数据
std::shared_ptr<MenuImpl> pNew(new MenuImpl(*pImpl));
pNew->bg.reset(new Image(src)); // 修改副本
++pNew->changeCount;
swap(pImpl, pNew); // 置换数据,释放mutex
}
请记住
- 异常安全函数(
Exception-safe functions
)即使发生异常也不会泄漏资源或允许任何数据结构败坏。这样的函数区分为三种可能的保证:基本型、强烈型、不抛出异常型。 - "强烈保证"往往能够以
copy-and_swap
实现出来,但"强烈保证"并非对所有函数都可实现或具备现实意义。 - 函数提供的"异常安全保证"通常最高只等于所调用之各个函数的"异常安全保证"中的最弱者。
条款30:透彻了解inlining的里里外外
使用inline
的优劣:
- 优势:较少函数调用的开销;编译器对
inline
的优化; - 劣势:目标代码的增加,程序体积增大,导致额外的换页行为,降低指令高速缓存装置的命中率。
提出inline
的两种方式:
- 显式提出
- 隐式提出(类内实现成员函数)
大部分编译器拒绝将太过复杂的(例如带有循环或递归)函数inlining
,而所有对virtual
函数的调用也会拒绝inlining
,因为virtual
意味着"等待,直到运行期才能确定调用哪个函数",而inline
意味准备"执行前,先将调用动作替换为被调用函数的本体"。
总的来说,一个表面上看似inline
的函数是否真是inline
,取决于你的环境,主要取决于编译器。幸运的是大多数编译器提供了一个诊断级别:如果它们无法将你要求的函数inline
化,会给你一个警告信息。inline
函数无法随着程序库的升级而升级。
构造函数和析构函数往往是inlining
的糟糕候选人。对于以下代码:
class Base {
public:
// ...
private:
std::string bm1, bm2;
};
class Derived: public Base {
public:
Derived() {}
// ...
private:
std::string bm1, bm2, bm3;
};
虽然看上去Derived构造函数
为空,符合一个函数成为inline的特性。但是为了确保C++
对于"对象被创建和被销毁时发生什么事"做出的各式各样的保证,编译器会在其中安插代码。因此实际的Derived
构造函数可能是这样的:
Derived::Derived() {
Base::Base();
try {dm1.std::string::string();}
catch() {
Base::~Base();
throw;
}
try {dm2.std::string::string();}
catch() {
dm1.std::string::~string();
Base::~Base();
throw;
}
try {dm3.std::string::string();}
catch() {
dm2.std::string::~string();
dm1.std::string::~string();
Base::~Base();
throw;
}
}
对于上述代码。显而易见,真正的编译器会以更精细复杂的做法来处理异常。相同理由也使用与析构函数。
80-20经验法则:平均而言一个程序往往将80%的执行时间花费在20%的代码上。这个法则提醒我们,作为一个软件开发者,你的目标是找出这可以有效增进程序整体效率的20%代码,然后将它inline
或竭尽所能地将它瘦身。但除非你选对目标,否则一切都是徒劳。
请记住
- 将大多数
inlining
限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,是程序的速度提升机会最大化。 - 不要只因为
function templates
出现在头文件,就将它们声明为inline
。
条款31:将文件间的编译关系降至最低
C++并没有把"将接口从实现中分离"这件事做的很好,例如:
#include <string>
#include "date.h"
#include "address.h"
class Person {
public:
// ...
private:
std::string theName;
Date theBirthDate;
Address theAddress;
};
如果没有引入头文件,那么编译将无法通过。但是这样会在Person
定义文件和其含入文件之间形成了一种编译依赖关系。如果这些头文件中任何一个被改变,或这些文件多依赖的其他头文件有任何改变,那么每个含有Person class
的文件都得重新编译,任何使用Person class
的文件也必须重新编译,这样的连串编译依存关系会对许多项目造成难以形容的灾难。
可能会想着将实现细目分开:
namespace std {
class string; // 前置声明,但不正确
}
class Data; // 前置声明
class Address; // 前置声明
class Person {
public:
// ...
};
如果这样做,Person
的客户就只需要在Person
接口被修改过时才重新编译,但是这种想法存在两个问题:
string
并不是class
,它是个typedef
,上述前置声明不正确,正确的前置声明较为复杂。- 最重要的是,编译器必须在编译期间知道对象的大小:
解决这个问题的方法主要有两种:
- 第一种方法:
handle classes
,把Person
分割成两个类:一个只提供接口(Person
),一个负责实现接口(PersonImpl
)。即使用条款25:接口class中只包含一个负责实现接口的class的指针,因此任何改变都只是在负责实现接口的class中进行。这正是接口和实现分离,这种情况下,想Person
这样使用pImpl
的class
往往被称为handle classes
。
#include <string>
#include <memory>
class PersonImpl;
class Person {
public:
Person(string& name);
string name() const;
private:
std::shared_ptr<PersonImpl> pImpl; // 指针,指向实现物
};
Person::Person(string& name): pImpl(new PersonImpl(name)){}
string Person::name() {
return pImpl->name();
}
- 第二种方法:
Interface classes
,令Person
成为一种特殊的abstract class
:
class Person {
public:
virtual ~Person();
virtual std::string name() const = 0;
static std::shared_ptr<Person> create(string& name); // 由于客户不能实例化这个类,只能使用它的引用和指针,所以需要提供一种方法来获得一个实例
};
std::shared_ptr<Person> Person::create(string& name) {
return std::shared_ptr<Person>(new RealPerson(name));
}
class RealPerson: public Person {
public:
RealPerson(const std::string& name): theName(name) {}
virtual ~RealPerson(){}
std::string name() const;
private:
std::string theName;
};
// 使用
std::shared_ptr<Person> p(Person::create("parzulpan"));
std::cout << p->name() << std::endl;
请记住
- 支持"编译依存性最小化"的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是
Handle classes
和Interface classes
。 - 程序库头文件应该以"完全且仅有声明式"(
full and declaration-only forms
)的形式存在。这种做法不论是否涉及templates
都适用。