前言

本文是阅读《Effective C++ 改善程序与设计的55个具体做法(第三版)》的心得笔记第四部分,文章也会按照原书的顺序依次记录各个条款。

第一部分的阅读笔记参见effective C++ 读书笔记01

第二部分的阅读笔记参见effective C++ 读书笔记02

第三部分的阅读笔记参见effective C++ 读书笔记03

实现

条款26:尽可能延后变量定义式的出现时间

尽可能延后变量定义式的出现时间,理由如下:

  1. 可以避免不必要的构造和析构成本:比如在使用某对象前,因某些原因函数返回或抛出异常,若已定义了该对象,便仍需要承担构造和析构成本,即便你并未真正的使用它;

  2. 延后变量定义式的真正意义并不只是延后变量定义式的位置,甚至应当延后定义直到你能为其提供初值实参为止。这样可以避免无意义的default构造成本,而且用具有明显意义的初值来初始化变量,还可以附带说明变量的目的;

std::string encryptPassword(const std::string& password){
if(password.length() < 8){
throw std::logic_error("Password is too short");
}// 考虑1:在异常之后定义变量
std::string encrypted(password);//考虑2:定义延后至变量能赋初值的时机
encrypt(encrypted);
return encrypted;
}
  1. 对于循环而言,变量定义在循环内还是循环外?
// 定义于循环外 成本:1次构造 + 1次析构 + n次赋值
Widget w;
for(int i = 0 ; i< n; ++i){
    w = foo(i);
   // other...
}

// 定于于循环内 成本:n次构造 + n次析构
for(int i = 0 ; i< n; ++i){
    Widget w(foo(i));
   // other...
}

如何选择取决于赋值成本构造+析构成本的大小关系,且定义于循环外会导致变量的作用域更大,会一定程度破坏程序的可理解性和易维护性。所以除非你明确地知道赋值成本要低于构造+析构成本,且你对这段循环代码的效率非常在意,那么更应该选择的是定义于循环内。

条款27:尽量少做转型动作

转型(casts)会破坏C++的类型系统(type system)。

C风格的转型动作(旧式转型):

(new_type)expression
new_type(expression)

C++风格的转型动作(新式转型):

const_cast<new_type> (expression)
dynamic_cast<new_type> (expression)
reinterpret_cast<new_type> (expression)
static_cast<new_type> (expression)

// C++11推出针对智能指针的转型
const_pointer_cast<new_type> (expression)
dynamic_pointer_cast<new_type> (expression)
reinterpret_pointer_cast<new_type> (expression)
static_pointer_cast<new_type> (expression)
  • const_cast:通常用来将对象的常量性转除,除了const修饰符外,new_typeexpression的类型一致。它是唯一有此功能的C++风格的转型操作符;
  • dynamic_cast:主要用来执行安全向下转型(safe downcasting),即将基类的指针或引用安全地转换成派生类的指针或引用,从而能够用派生类的指针或引用调用非虚函数。这是唯一无法用旧式语法执行的动作,也是唯一可能耗费巨大执行成本的转型动作。当指针是智能指针时候,向下转型需要使用dynamic_pointer_cast
  • reinterpret_cast:用来处理无关类型之间的低级转型,即新类型的值与原始参数expression有完全相同的比特位,只是被强行解释为新类型。错误的使用reinterpret_cast很容易造成程序的不安全,除非你明确的知道你在做什么;
  • static_cast:用来强制隐形转换(implicit conversions),即显示转型。编译时检查安全性,但没有运行时类型检查来保证转换的安全性。主要有如下几种用法:
    • 用于类层次结构中基类和派生类之间指针或引用的转型:
      • 进行向上转型(把派生类指针或引用转换成基类表示)是安全的;
      • 进行向下转型(把基类指针或引用转换成派生类表示)时,由于没有动态类型检查,所以是不安全的。
    • 用于基本数据类型之间的转换,如把int转换成char,把int转换成enum等。这种转换的安全性也要开发人员来保证;
    • 把void指针转换成目标类型的指针;
    • 把任何类型的表达式转换成void类型;
    • 可以将non-const对象转换成const对象,反之则不行(只能通过const_cast);

旧式转型依旧合法,但新式转型更受欢迎。理由如下:首先,新式转型更容易辨识(无论是人工还是通过工具检索),从而更容易在DEBUG时找出类型系统被破坏的位置;其次,转型动作的分工更加细化,一方面是提醒开发人员是否真的选对了转型动作,另一方面也让编译器更容易发现错误。

一些错误的转型尝试

尝试1

单一对象可能拥有一个以上的地址,即指向它的基类指针和派生类指针可能并不相同,这会随着编译器的不同而不同。所以一些尝试对对象地址进行转型的骚操作,几乎总是会导致未定义行为

尝试2

转型操作会返回一个原参数的新类型的副本,所以如下操作会出问题:

class Window {
public:
virtual void onResize() { ... }
...
};

class SpecialWindow : public Window {
public:
virtual void onResize() {
static_cast<Window>(*this).onResize(); // 通用操作
... // 专属操作
}
...
};

上述代码的本意是希望派生类中的虚函数先调用基类的虚函数实现(通用操作),然后再执行一些派生类专属的操作。但是转型动作构建了一个*this对象的基类类型副本,并由这个副本调用onResize函数,那么通用操作所修改的属性是这个副本的,而不是*this对象的。正确的写法如下:

class SpecialWindow : public Window {
public:
virtual void onResize() {
Window::onResize(); // 通用操作
... // 专属操作
}
...
};

尝试3

过度的使用dynamic_cast会导致程序效率低下。如前所述,一般我们使用dynamic_cast的目的是为了将基类的指针或引用安全地转换成派生类的指针或引用,从而能够用派生类的指针或引用调用非虚函数。那么想要规避使用dynamic_cast无非有下面两个思路:

  • 修改代码,窄化类型,直接持有派生类的指针即可;
  • 将想要调用的非虚函数实现为虚函数,基类中缺省实现(条款34有进一步讨论),派生类中有具体实现,用多态的方式去解决;

总结

  1. 尽量避免使用转型,尤其是在注重效率的代码中避免使用dynamic_cast
  2. 当转型不可避免时,可以将其封装在函数中供用户使用;
  3. 优先使用新式转型,而不是旧式转型。

条款28:避免返回 handles 指向对象内部成分

所谓的handles(号码牌)包括指针、引用和迭代器。

应当避免返回指向对象内部成分的handles,原因有2:

    1. 这样做会破坏封装性
// 
class Point {
public:
Point(int x, int y) : x_(x), y_(y) {
}
void setX(int x) {
x_ = x;
}
void setY(int y) {
y_ = y;
}

private:
int x_;
int y_;
};

struct RectData {
Point ulhc_;
Point lrhc_;

RectData(const Point& p1, const Point& p2) : ulhc_(p1), lrhc_(p2) {
}
};

class Rectangle {
public:
Rectangle(const Point& p1, const Point& p2) {
pData = std::make_shared<RectData>(p1, p2);
}

Point& getUpperLeft() const {
return pData->ulhc_;
}
Point& getLowerRight() const {
return pData->lrhc_;
}

private:
std::shared_ptr<RectData> pData = nullptr;
};

int main() {
Point p1(0, 0);
Point p2(100, 100);
const Rectangle rect(p1, p2);

rect.getUpperLeft().setX(50);

return 0;
}

上述例子虽然能通过编译,但实际上是与设计初衷相违背的。getUpperLeftgetLowerRight被声明为const成员函数,即表明了它们会给用户返回相关坐标点,但并不允许用户修改它。但是这里返回的是内部private变量的引用,这一方面相当于把priavte变量的封装性降低到public,更糟糕的是用户就可以通过返回的引用间接的更改rect对象的内部数据,即便rect对象被声明为const。

解决方法也很简单,只要对给返回类型加上const即可。虽然仍然降低了封装性,但是是有限度的降低,即只赋予了用户读权限,而没有放开写权限。

class Rectangle {
public:
...
const Point& getUpperLeft() const {
return pData->ulhc_;
}
const Point& getLowerRight() const {
return pData->lrhc_;
}
...
};
    1. 这样做可能会导致空悬的handles

虽然使用const能解决封装性的问题,但无法解决handles可能比其所指的内部对象更长寿的问题。即可能存在内部对象已析构,但handles还留存的问题。尤其是临时变量的析构,不太容易察觉。考虑如下情况:

class GUIObject { ... };
const Rectangle boundingBox(const GUIObject& obj); // 以by-value方式返回一个矩形

// 用户调用
GUIObject* pgo; //
...
const Point* pUL = &(boundingBox(*pgo).getUpperLeft());

上述代码中,boundingBox函数会返回一个临时的Rectangle对象,然后这个临时对象调用getUpperLeft返回一个引用(handle)指向对象内部成分,更具体的说,是表征矩形右上角点坐标的Point成员对象的引用。然后用一个指针指向了该引用。但是随着该语句的结束,临时的Rectangle对象将会析构,相应的,其中的Point成员对象也会被析构,最终导致pUL指针指向一个不复存在的对象,形成空悬/虚吊(dangling)!

所以,应当尽量避免返回 handles 指向对象内部成分,但并不意味着绝对禁止,有时候你不得不这样做。比如std::stringstd::vectoroperator[]操作符就允许返回容器内元素的引用。但不可避免的,这些元素也会随容器的销毁而销毁,所以也要谨慎。

条款29:为“异常安全”而努力是值得的

两个条件

异常安全(Exception Safety)是指,在异常被抛出时,满足以下两个条件:

  • 不泄露任何资源。这里资源包括动态内存、互斥锁等;

  • 不允许数据败坏。不会因为异常抛出而导致出现空悬指针等未定义行为。

如下示例则不满足上述两个条件:

class PrettyMenu {
public:
...
void changeBackground(std::istream& imgSrc); // 改变背景图像
...
private:
Mutex mutex; // 互斥器,用于多线程环境下的并发控制
Image* bgImage; // 目前的北京图像
int imageChanges; // 背景图像被改变的次数
};

void PrettyMenu::changeBackground(std::istream& imgSrc) {
lock(&mutex); // 加锁
delete bgImage; // 销毁旧的背景图像
++imageChanges; // 图像变更次数+1
bgImage = new Image(imgSrc); // 创建新的背景图像
unlock(&mutex); // 解锁
}

在上述例子中,若new Image时抛出异常,则unlock(&mutex)无法被调用到,互斥锁资源泄漏;bgImage已经被销毁却没有指向新的背景图像,出现了空悬指针。

三个等级

异常安全函数会提供如下三种等级的保证之一:

  • 基本承诺:如果异常被抛出,程序内的任何事务仍然保持在有效状态下,没有资源泄漏,也没有数据败坏。在上例中,可以修改changeBackground函数,使PrettyMenu在异常被抛出时,继续拥有原背景图像,或者拥有一个缺省背景图像,让程序能够继续有效运行(只要合法就行,不能强求如何实现,此所谓基本);

  • 强烈保证:如果异常被抛出,程序状态不改变。即在此异常安全等级保证下的函数要么完全成功执行,要么回退到函数执行之前的状态;

  • 不抛掷保证:函数承诺绝不抛出异常,总是能够完成原来承诺的功能。如所有针对内置类型的操作都能够提供nothrow保证。

如果可能的话,我们应当尽可能的提供最强等级的nothrow保证。但很多时候这很难实现,对于大多数函数而言,往往需要在基本承诺和强烈保证之间进行选择。之前的例子提供强烈保证的实现版本如下:

class PrettyMenu {
public:
...
void changeBackground(std::istream& imgSrc); // 改变背景图像
...
private:
Mutex mutex; // 互斥器,用于多线程环境下的并发控制
std::shared_ptr<Image> bgImage; // RAII 防止内存泄漏,参见条款13
int imageChanges; // 背景图像被改变的次数
};

void PrettyMenu::changeBackground(std::istream& imgSrc) {
Lock ml(&mutex); // RAII 资源管理类,参见条款13,14
bgImage.reset(new Image(imgSrc)); // 若new失败,则不会reset
++imageChanges; // 事情真的完成了再++cnt
}

copy and swap 策略

copy and swap 策略的原则是:为你打算修改的对象(原件)拷贝一份副本,然后在副本上做一切必要的修改。若有任何修改动作抛出异常,原对象仍保持未改变状态。待所有修改操作都成功后,再将修改过的那个副本和原对象在一个不抛出异常的swap操作中进行置换。

基于上述示例的改写如下:

struct PMImpl {		// Pretty Menu Impl
std::shared_ptr<Image> bgImage;
int imageChanges;
};

class PrettyMenu {
public:
...
void changeBackground(std::istream& imgSrc); // 改变背景图像
...
private:
Mutex mutex; // 互斥器,用于多线程环境下的并发控制
std::shared_ptr<PMImpl> pImpl;
};

void PrettyMenu::changeBackground(std::istream& imgSrc) {
using std::swap; // 参见条款25
Lock ml(&mutex); // RAII 资源管理类
std::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl)); // 拷贝副本
pNew->bgImage.reset(new Image(imgSrc)); // 修改副本
++pNew->imageChanges;
swap(pImpl, pNew); // 置换
}

在上述示例中,用到了pimpl手法,即将隶属于对象的数据从原对象放进另一个对象中,然后赋予原对象一个指针,指向那个所谓的实现对象(implementation object)。这里让PMImpl成为一个struct而不是一个class,是因为在其封装性因pImpl是private成员而得到保证的前提下,用struct实现会更方便。

异常安全的连带影响(side effects)

函数提供的“异常安全保证等级”只取决于其调用的各个子函数的“最不安全者”

考虑如下使用了 copy and swap 策略的示例:

void someFunc {
... // 对local状态做一份副本
f1();
f2();
... // 将修改后的状态置换过来
}

虽然 copy and swap 策略在尽力强烈保证异常安全,但如果f1f2函数的异常安全等级比强烈保证低,则someFunc就很难成为强烈异常安全。即便f1f2都是强烈异常安全的,但f1成功做了修改,f2随后抛出异常而回退状态(f1成功执行后的状态),却也无法恢复someFunc函数被调用前的状态,故也称不上强烈异常安全

除此之外,copy and swap 策略会为每一个即将被改动的对象创建副本,这可能会对内存和效率产生不小的挑战。

综上所述,当你在撰写代码时,应当思考如何让代码具有异常安全性:首先,以对象管理资源,可以阻止内存泄漏;其次,在三个异常安全等级中选择一个合适的实施于你写的每一个函数上,且应当选择实际可实施的最强烈等级。

但总是想要提供强烈保证的异常安全并非易事,如果你能证明实现强烈保证不切实际时,可以退而求其次,选择基本保证。若你调用了不具备异常安全性的旧代码,你才别无选择地不提供任何异常安全性的保证。

条款30:透彻了解 inlining 的里里外外

inline函数,即内联函数会在函数调用处展开代码,直接将函数体插入函数调用处,从而省去了函数调用的开销。但相应地,代价就是会导致函数无法被复用,从而导致代码膨胀,(过度的inlining)进而可能会导致额外的换页行为(paging),降低指令cache的命中率,反而降低了代码效率。

关于inline,还有如下几点需要注意:

inline只是对编译器的申请

inline只是对编译器的申请或建议,并不是强制命令,编译器有权利对其认为不适合inline的函数拒绝inline。此外,原书还提到如果编译器拒绝,通常它会给出warning信息,但在VS2019中实测并未看到。

inline的申请方式有两种:

  • 显示inline:在函数定义时使用inline关键字;
  • 隐式inline:在(头文件中)类内实现的成员函数或者friend函数;

inline和template并无必然联系

inline函数和template函数通常都被定义于头文件中(这是因为inline的函数体替换操作和template的具现化操作通常都是在编译期执行的),而且不少简短的template函数都是带有inline关键字(如下例),但这并不代表两者有着必然的因果关系。

template<typename T>
inline const T& std::max(const T& a, const T& b){ //可以申请inline,但不是必须申请
return a < b ? b : a;
}

不适合(编译器拒绝)进行inline的函数类型

  • 复杂函数:如函数体内有循环或者递归;
  • 虚函数:inline是编译器决定的,而虚函数需要等到运行期才能确定;
  • 构造/析构函数:即便构造函数体为空,但编译器在编译时会生成调用基类或者成员对象的构造函数的代码,同时为其生成精致复杂的异常处理代码;若基类或者派生类成员对象的构造/析构函数为inline,其在派生类中的代码展开也会导致不合理的代码膨胀;
  • 通过函数指针调用的函数:函数指针不可能指向一个并不存在的函数,因此通过函数指针调用的函数仍会生成一个outlined函数本体,示例如下:
inline void f() {...} 		// 假设编译器不拒绝 inline “对f的调用”
void (* pf)() = f; // pf 指向 f
...
f(); // 这个调用将被inlined,因为是个正常调用
pf(); // 这个很可能不被inlined,因为是通过函数指针调用的

过度inline会为调试和发布带来困难

inline是代码嵌入与展开,而非函数调用,所以某些编译器不支持inline的单步Debug(就像宏展开一样不支持调试),某些支持inline函数调试的编译环境也只是在Debug中禁止发生inlining来实现的。

此外,若inline函数发生修改,则(客户端)调用它的代码全都要重新编译,而non-inline函数则只需要重新链接即可。如果采用动态库的方式进行发布,non-inline函数的升级甚至都可以不被察觉。

条款31:将文件间的编译依存关系降到最低

C++并没有把将接口从实现中分离这件事做的很好。即(头文件中)类定义式中不仅包含了接口,而且可以有实现。如下示例:

#include <string>
#include "date.h" // 定义 Date 类
#include "address.h" // 定义 Address 类

class Person {
public:
// 接口
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
...
private:
// 实现
std::string theName;
Date theBirthDate;
Address theAddress;
};

在上例中,Person类的定义文件(大概率也是头文件)通过#includedate.haddress.h形成了编译依存关系(compilation dependency),如果这些头文件中有任何变化,或者这些头文件所依赖(include)的其他头文件有任何变化,则任何使用了Person类的文件都得重新编译。

当然,该定义文件与标准库<string>也存在编译依存关系,但标准头文件一般不会改动,且由于预编译头的加持,其编译不会成为瓶颈,故这里我们仅讨论自己实现定义的头文件。

Handle Class(pimpl 手法)

使用pimpl(pointer to implementation)手法可以实现接口和实现的分离:

// person.h
#include <string> // std::string 定义
#include <memory> // std::shared_ptr 定义

// 用 前置声明 替代 #include
class PersonImpl; // Person实现类的前置声明
class Date;
class Address;

class Person {
public:
// 接口 只声明,实现部分放到cpp中
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
...
private:
std::shared_ptr<PersonImpl> pImpl; // 指针指向Person实现类
};

// person.cpp
#include "person.h"
#include "person_impl.h"
#include "date.h"
#include "address.h"

Person::Person(const std::string& name, const Date& birthday, const Address& addr)
: pImpl(new PersonImpl(name, birthday, addr)) {}

std::string Person::name() const {
return pImpl->name();
}

...

在上例中,Person类的使用者(#include "person.h")完全不用关心Person类接口及其实现类PersonImpl的实现细节(PersonImplPerson有着完全相同的成员函数,两者接口完全相同),也不需要关心其他关联类DateAddress的实现细节。这些类的实现的任何修改也不需要使用者代码的重新编译,真正实现了接口与实现分离!像Person这样使用pimpl手法的类通常称为Handle class

这里做到分离的关键点在于用声明的依存性替代定义的依存性。这正是编译依存性最小化本质:让头文件尽可能自我满足,如果做不到,则也要依赖于其他文件的声明式而非定义式。

具体到设计策略,有如下几点:

  • 如果使用 object references 或 object pointers 可以完成任务,就不要使用objects。如果定义某类型的objects,就需要使用该类型的定义式;而定义该类型的指针或引用,则只需要类型声明式。
  • 尽量以 class 声明式替换 class 定义式。函数声明中的 class 类型可以只用声明式,而并不需要类的定义,即使函数是以by-value的形式传递参数(虽然这样不好,参见条款20),亦是如此。
  • 为声明式和定义式提供不同的头文件。比如上面提到的Date类,可以分为提供声明式的datefwd.h和提供定义式的date.h,那么就可以用声明式头文件代替前置声明,在真正调用函数的源文件中include定义式头文件。这种方式在STL库中使用较多,具体可参考<iosfwd>sstreamfstreamiostream等。

Interface class

另一个实现Handle class的方法就是构建一个abstract base class(抽象基类),称为Interface class

这种类的目的是一一描述派生类的接口,因此它通常不会包含成员变量,也没有构造函数, 只有一个virtual析构函数以及一组pure virtual函数,用来描述整个接口。Person类的例子如下:

// person.h
class Person {
public:
virtual ~Person();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
static std::shared_ptr<Person> create(const std::string& name,
const Date& birthday,
const Address& addr);
};

// person.cpp
std::shared_ptr<Person> Person::create(const std::string& name,
const Date& birthday,
const Address& addr) {
return std::shared_ptr<Person>(new RealPerson(name, birthday, addr));
}

Person::~Person() {}

// real_person.h
class RealPerson: public Person {
public:
RealPerson(const std::string& name, const Date& birthday,
const Address& addr)
: theName(name), theBirthDate(birthday), theAddress(addr) {}

virtual ~RealPerson() {}
std::string name() const;
std::string birthDate() const;
std::string address() const;

private:
std::string theName;
Date theBirthDate;
Address theAddress;
};

// 用户代码
std::string name;
Date dateOfBirth;
Address address;
...
std::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
...
std::cout << pp->name() << " was born on " << pp->birthDate()
<< " and now lives at " << pp->address();

在上述例子中,create函数是一个工厂函数,用于返回基类指针,指向动态分配的派生类对象。该工厂函数可以基于额外的参数值等条件,创建不同派生类的对象。