条款 28:避免返回 handles 指向对象的内部成分
考虑以下Rectangle
类:
struct RectData {Point ulhc;Point lrhc;
};class Rectangle {
public:Point& UpperLeft() const { return pData->ulhc; }Point& LowerRight() const { return pData->lrhc; }private:std::shared_ptr<RectData> pData;
};
这段代码看起来没有任何问题,但其实是在做自我矛盾的事情:我们通过const成员函数返回了一个指向成员变量的引用,这使得成员变量可以在外部被修改,而这是违反 logical constness 的原则的。如果它们返回的是指针或迭代器,相同的情况还是会发生,原因也相同。换句话说,你绝对不应该令成员函数返回一个指针指向“访问级别较低”的成员函数(降低了对象的封装性)。
改成返回常引用可以避免对成员变量的修改:
const Point& UpperLeft() const { return pData->ulhc; }
const Point& LowerRight() const { return pData->lrhc; }
但是这样依然会带来一个称作 dangling handles(空悬句柄) 的问题,当对象不复存在时,你将无法通过引用获取到返回的数据。
采用最保守的做法,返回一个成员变量的副本:
Point UpperLeft() const { return pData->ulhc; }
Point LowerRight() const { return pData->lrhc; }
避免返回 handles(包括引用、指针、迭代器)指向对象内部。遵循这个条款可增加封装性,使得const成员函数的行为符合常量性,并将发生 “空悬句柄” 的可能性降到最低。
条款 29:为“异常安全”而努力是值得的
当异常被抛出时,带有异常安全性的函数会:
- 不泄漏任何资源。
- 不允许数据败坏。
异常安全函数提供以下三个保证之一:
基本承诺: 如果异常被抛出,程序内的任何事物仍然保持在有效状态下,没有任何对象或数据结构会因此败坏,所有对象都处于一种内部前后一致的状态,然而程序的真实状态是不可知的,也就是说客户需要额外检查程序处于哪种状态并作出对应的处理。
强烈保证: 如果异常被抛出,程序状态完全不改变,换句话说,如果函数成功,就是完全成功,如果函数失败,程序会回复到“调用函数之前”的状态。
不抛掷(nothrow)保证: 承诺绝不抛出异常,因为程序总是能完成原先承诺的功能。作用于内置类型(int,指针等等)身上的所有操作都提供 nothrow 保证。
原书中实现 nothrow 的方法是throw()
,不过这套异常规范在 C++11 中已经被弃用,取而代之的是noexcept
关键字:
int DoSomething() noexcept;
注意,使用noexcept
并不代表函数绝对不会抛出异常,而是在抛出异常时,将代表出现严重错误,会有意想不到的函数被调用(可以通过set_unexpected
设置),接着程序会直接崩溃。
考虑以下PrettyMenu
的ChangeBackground
函数:
class PrettyMenu {
public:...void ChangeBackground(std::vector<uint8_t>& imgSrc);...
private:Mutex mutex; // 互斥锁Image* bgImage; // 目前的背景图像int imageChanges; // 背景图像被改变的次数
};void PrettyMenu::ChangeBackground(std::vector<uint8_t>& imgSrc) {lock(&mutex);delete bgImage;++imageChanges;bgImage = new Image(imgSrc);unlock(&mutex);
}
很明显这个函数不满足我们所说的具有异常安全性的任何一个条件,若在函数中抛出异常,mutex
会发生资源泄漏,bgImage
和imageChanges
也会发生数据败坏。
通过以对象管理资源,使用智能指针和调换代码顺序,我们能将其变成一个具有强烈保证的异常安全函数:
void PrettyMenu::ChangeBackground(std::vector<uint8_t>& imgSrc) {Lock m1(&mutex);bgImage.reset(std::make_shared<Image>(imgSrc));++imageChanges;
}
另一个常用于提供强烈保证的方法是我们所提到过的 copy and swap,为你打算修改的对象做出一份副本,对副本执行修改,并在所有修改都成功执行后,用一个不会抛出异常的swap方法将原件和副本交换:
struct PMImpl {std::shared_ptr<Image> bgImage;int imageChanges;
};class PrettyMenu {...
private:Mutex mutex;std::shared_ptr<PMImpl> pImpl;
};void PrettyMenu::ChangeBackground(std::vector<uint8_t>& imgSrc) {Lock m1(&mutex);auto pNew = std::make_shared<PMImpl>(*pImpl); // 获取副本pNew->bgImage.reset(std::make_shared<Image>(imgSrc));++pNew->imageChanges;std::swap(pImpl, pNew);
}
当一个函数调用其它函数时,函数提供的“异常安全保证”通常最高只等于其所调用的各个函数的“异常安全保证”中的最弱者。
强烈保证并非永远都是可实现的,特别是当函数在操控非局部对象时,这时就只能退而求其次选择不那么美好的基本承诺,并将该决定写入文档,让其他人维护时不至于毫无心理准备。
条款 30:透彻了解 inlining 的里里外外
将函数声明为内联一共有两种方法,一种是为其显式指定inline
关键字,另一种是直接将成员函数的定义式写在类中,如下所示:
class Person {
public:...int Age() const { return theAge; } // 隐式声明为 inline...
private:int theAge;
};
在inline
诞生之初,它被当作是一种对编译器的优化建议,即将“对此函数的每一个调用”都以函数本体替换之。但在编译器的具体实现中,该行为完全被优化等级所控制,与函数是否内联无关。
在现在的 C++ 标准中,inline
作为优化建议的含义已经被完全抛弃,取而代之的是“允许函数在不同编译单元中多重定义”(inlining在大多数c++程序中是编译期行为),使得可以在头文件中直接给出函数的实现。
inline函数无法随着程序库的升级而升级,如果func函数是程序库的一个inline函数,客户将func函数编进其程序中,一旦程序设计者改变func函数,所有的func的客户端程序必须重新编译。而如果func函数是一个non-inline函数。一旦它有修改,客户端只需重新连接就好,如果程序库采用动态连接,func函数的改动可以不知不觉地被程序吸纳。
在调试版本的程序中禁止发生inlining。
在 C++17 中,引入了一个新的inline
用法,使静态成员变量可以在类中直接定义:
class Person {
public:...
private:static inline int theAge = 0; // since C++17
};
条款 31:将文件间的编译依存关系降至最低
C++ 坚持将类的实现细节放置于类的定义式中,这就意味着,即使你只改变类的实现而不改变类的接口,在构建程序时依然需要重新编译。这个问题的根源出在编译器必须在编译期间知道对象的大小,如果看不到类的定义式,就没有办法为对象分配内存。也就是说,C++ 并没有把“将接口从实现中分离”这件事做得很好。
用“声明的依存性”替换“定义的依存性”:
我们可以玩一个“将对象实现细目隐藏于一个指针背后”的游戏,称作 pimpl idiom(pimpl 是 pointer to implemention 的缩写):将原来的一个类分割为两个类,一个只提供接口,另一个负责实现该接口,称作句柄类(handle class):
// person.hpp 负责声明类class PersonImpl;class Person {
public:Person();void Print();...
private:std::shared_ptr<PersonImpl> pImpl;
};// person.cpp 负责实现类class PersonImpl {
public:int data{ 0 };
};Person::Person() {pImpl = std::make_shared<PersonImpl>();
}void Person::Print() {std::cout << pImpl->data;
}
这样,假如我们要修改Person
的private成员,就只需要修改PersonImpl
中的内容,而PersonImpl
的具体实现是被隐藏起来的,对它的任何修改都不会使得Person
客户端重新编译,真正实现了“类的接口和实现分离”。
如果使用对象引用或对象指针可以完成任务,就不要使用对象本身:
你可以只靠一个类型声明式就定义出指向该类型的引用和指针;但如果定义某类型的对象,就需要用到该类型的定义式。
如果能够,尽量以类声明式替换类定义式:
当你在声明一个函数而它用到某个类时,你不需要该类的定义;但当你触及到该函数的定义式后,就必须也知道类的定义:
class Date; // 类的声明式
Date Today();
void ClearAppointments(Date d); // 此处并不需要得知类的定义
为声明式和定义式提供不同的头文件:
为了避免频繁地添加声明,我们应该为所有要用的类声明提供一个头文件,这种做法对 template 也适用:
#include "datefwd.h" // 这个头文件内声明 class Date
Date Today();
void ClearAppointments(Date d);
此处的头文件命名方式"datefwd.h"
取自标准库中的<iosfwd>
。
上面我们讲述了接口与实现分离的其中一个方法——提供句柄类,另一个方法就是将句柄类定义为抽象基类,称作接口类(interface class)(它通常不带成员变量,也没有构造函数,只有一个virtual析构函数和一组pure virtual函数,用来描述整个接口):
class Person {
public:virtual ~Person() {}virtual void Print() = 0;...
};
为了将Person
对象实际创建出来,我们一般采用工厂模式。可以尝试在类中塞入一个静态成员函数Create
用于创建对象:
class Person {
public:...static std::shared_ptr<Person> Create();...
};
但此时Create
函数还无法使用,需要在派生类中给出Person
类中的函数的具体实现:
class RealPerson : public Person {
public:RealPerson(...) { ... }virtual ~RealPerson() {}void Print() override { ... }private:int data{ 0 };
};
完成Create
函数的定义:
static std::shared_ptr<Person> Person::Create() {return std::make_shared<RealPerson>();
}
毫无疑问的是,句柄类和接口类都需要额外的开销:句柄类需要通过 pimpl 取得对象数据,增加一层间接访问、指针大小和动态分配内存带来的开销;而接口类会增加存储虚表指针和实现虚函数跳转带来的开销。
而当这些开销过于重大以至于类之间的耦合度在相比之下不成为关键时,就以具象类(concrete class)替换句柄类和接口类。