文章目录
5. 实现
条款26:尽可能延后变量定义式的出现时间
当变量定义出现时,程序需要承受其构造成本;当变量离开其作用域时,程序需要承受其析构成本。因此,避免不必要的变量定义,以及延后变量定义式直到你确实需要它。
延后变量定义式还有一个意义,即“默认构造+赋值”效率低于“直接构造”:
// 效率低
std::string encrypted;
encrypted = password;
// 效率高
std::string encrypted(password);
对于循环中变量的定义,我们一般有两种做法:
这种做法产生的开销:1 个构造函数 + 1 个析构函数 + n 个赋值操作
Widget w;
for (int i = 0; i < n; ++i) {
w = 取决于 i 的某个值;
...
}
这种做法产生的开销:n 个构造函数 + n 个析构函数
for (int i = 0; i < n; ++i) {
Widget w(取决于 i 的某个值);
...
}
由于做法A会将变量的作用域扩大,因此除非知道该变量的赋值成本比“构造+析构”成本低,或者对这段程序的效率要求非常高,否则建议使用做法B。
- 尽可能延后变量定义式的出现。这样做可增加程序的清晰度并改善程序效率。
条款27:尽量少做转型操作
C 式转型:
(T)expression
T(expression)
C++ 式转型:
const_cast<T>(expression)
dynamic_cast<T>(expression)
reinterpret_cast<T>(expression)
static_cast<T>(expression)
- const_cast 通用常来将对象的常量性移除。
- dynamic_cast 主要用来执行“安全向下转型”,也就是用来决定某对象是否归属继承体系中的某个类型。这也是唯一 一个 C 式转型无法代替的转型操作,它会执行对继承体系的检查,因此会带来额外的开销。只有拥有虚函数的基类指针能进行。
- reinterpret_cast 用于在任意两个类型间进行低级转型,执行该转型可能会带来风险,也可能不具备移植性。
- static_cast 用于进行强制隐式转换,也是最常用的转型操作,可以将内置数据类型互相转换,也可以将void*和typed指针,基类指针和派生类指针互相转换。
避免对*this进行转型,参考以下例子:
class Window {
public:
virtual void OnResize() { ... }
...
};
class SpecialWindow : public Window {
public:
virtual void OnResize() {
static_cast<Window>(*this).OnResize();
...
}
...
};
这段代码试图通过转型*this来调用基类的虚函数,然而这是严重错误的,这样做会得到一个新的Window副本并在该副本上调用函数,而非在原本的对象上调用函数。
正确写法
class SpecialWindow : public Window {
public:
virtual void OnResize() {
Window::OnResize();
...
}
...
};
- 如果可以,尽量避免转型,特别是在注重效率的代码中避免dynamic_casts。如果有个设计需要转型动作,试着发展无需转型的替代动作
- 宁可使用C++ style转型,不要使用旧式转型。前者很容易辨识出来。
条款28:避免返回handles指向对象内部成分
考虑以下Rectangle类:
class Point{
public:
Point(int x,int y);
void setX(int newVal);
void setY(int newVal);
...
};
struct RectDat{
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(包括reference、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,包括const成员函数的行为像个const,并将发生“虚吊号码牌”的可能性降到最低。
条款29:为“异常安全”而努力是值得的
异常安全函数提供以下三个保证之一:
基本承诺: 如果异常被抛出,程序内的任何事物仍然保持在有效状态下,没有任何对象或数据结构会因此败坏,所有对象都处于一种内部前后一致的状态,然而程序的真实状态是不可知的,也就是说客户需要额外检查程序处于哪种状态并作出对应的处理。
强烈保证: 如果异常被抛出,程序状态完全不改变,换句话说,程序会回复到“调用函数之前”的状态。
不抛掷(nothrow)保证: 承诺绝不抛出异常,因为程序总是能完成原先承诺的功能。作用于内置类型身上的所有操作都提供 nothrow 保证。
当异常被抛出时,带有异常安全性的函数会:
不泄漏任何资源。
不允许数据败坏。
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方法将原件和副本交换:
truct 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作为优化建议的含义已经被完全抛弃,取而代之的是“允许函数在不同编译单元中多重定义”,使得可以在头文件中直接给出函数的实现。
在 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); // 此处并不需要得知类的定义
为声明式和定义式提供不同的头文件:
#include "datefwd.h" // 这个头文件内声明 class Date
Date Today();
void ClearAppointments(Date d);
上面我们讲述了接口与实现分离的其中一个方法——提供句柄类,另一个方法就是将句柄类定义为抽象基类,称作接口类(interface class):
class Person {
public:
virtual ~Person() {}
virtual void Print();
...
};
为了将Person对象实际创建出来,我们一般采用工厂模式。可以尝试在类中塞入一个静态成员函数Create用于创建对象:
class Person {
public:
...
static std::shared_ptr<Person> Create();
...
};