基于 Effective C++ Third Edition: 55 Specific Ways to Improve Your Programs and designs 编写

让你自己习惯于 C++

将 C++ 视为语言的联邦

C++ 不是一门单独的语言,其具有各种不同风格的语言特性,以至于可能出现两个同样使用 C++ 的人会看不懂彼此的代码。一般分为四大组成部分:

  • C
  • 面向对象的 C++
  • 模板 C++
  • STL

因此基于使用的 C++ 的部分不用,Effective C++ 编程的规则也可能不同

倾向于 constenuminline 而不是 #define

对于变量而言,constenum 限制了作用域

对于函数而言,inline 函数能够避免一些未知错误

尽可能使用 const

声明某种东西 const 可以帮助编译器检测一些使用错误,同时 const 可以适用于任何作用域中的对象,包括函数参数,返回值,甚至整个成员函数

编译器强制 bitwise const,但应该使用逻辑 const 编程

当 const 和非 const 成员函数有相同的实现时,可以让非 const 版调用 const 版本

确保对象在被使用之前初始化

手动初始化内置类型对象

在构造函数中,倾向于使用成员初始化列表

通过将非局部静态对象替换为局部静态对象来避免跨转移单元的初始化顺序问题

构造函数、析构函数和赋值运算符

了解 C++ 默默地写和调用的是什么函数

编译器可能会隐式生成类的默认构造函数,复制构造函数,复制赋值运算符和析构函数

显式禁止编译器生成的不想要的函数的使用

通过声明这些函数为 private 并不给出实现

在多态基类中声明析构函数为 virtual

多态基类应该声明 virtual 析构函数,即如果一个类有虚函数,则应该有虚析构函数

相反,不被设计为基类或被多态使用的类不应该声明虚析构函数

避免异常离开析构函数

析构函数不应该发送异常,故析构函数应该捕捉任何其调用的函数可能抛出的异常,然后吞下这些异常或终止程序

如果类的客户需要对执行操作中抛出的异常做出反应,则类应该提供一个非析构函数来执行这个操作,如:

class DBConn {
public:
void close() {
db.close();
closed = true;
}
~DBConn() {
if (!closed) {
try {
db.close();
} catch (...) {

}
}
}
private:
DBConnection db;
bool closed;
}

用户需要手动调用 close() 并对其可能抛出的异常做出反应

永远不要在构造或析构中调用虚函数

因为在构造或析构中派生类不存在,只存在基类,故调用的是基类的虚函数,这会引起误解

让赋值运算符返回对 *this 的引用

主要是为了支持链式赋值:x = y = z = 1;

operator= 中处理对自己赋值的情况

方法有

  • 比较源和目标的地址
  • 仔细检查语句的顺序
  • 复制并交换

同理,要确保任何对多个对象操作的函数在有相同对象的情况下表现也正确

复制一个对象的所有部分

复制函数应该确保复制了一个对象的所有数据成员和所有基类的部分

不要试着用一个复制函数实现另一个,而是应该将共同的功能放到一个第三者函数中

资源管理

使用对象来管理资源

为了防止资源泄漏,使用 RAII 对象在其构造函数中获取资源并在其析构函数中释放资源

两个常用的 RAII 类为 unique_ptrshared_ptr

在资源管理类中仔细考虑复制行为

复制 RAII 对象需要复制其管理的资源,所以资源的复制行为决定了 RAII 对象的复制行为

一般的 RAII 类复制行为是禁止复制和执行引用计数,但其他的行为是可能的

在资源管理类中提供对原始资源的访问

API 通常需要访问原始资源,所以每个 RAII 类应该提供方法来获取其管理的资源

访问可能通过显式或隐式转换,一般来说,显式转换更安全,而隐式转换对客户来说更方便

在一对 newdelete 中使用相同的形式

如果在 new 中使用了 [],那么在相对于的 delete 中也要使用 []

在单独的语句中在智能指针中存储 new 的对象

如果和其他语句放在一起,则在异常抛出时,资源可能还没交给智能指针管理,造成内存泄漏

设计和声明

让接口更容易正确地使用,更难错误地使用

促进正确使用的方法包括

  • 接口的一致性
  • 对内置类型的表现兼容

防止错误的方法包括

  • 创建新的类型
  • 限制对类型的操作
  • 限制类型值
  • 减少客户端资源管理的责任

shared_ptr 支持自定义删除器,可以防止跨 DLL 问题,可以用于自动解锁 mutex

将类设计视为类型设计

类的本质就是类型,在设计类时会和内置类型的设计者考虑相同的问题,包括:

  • 新类型的对象应该怎样被创建和销毁?
  • 对象初始化而后对象赋值有什么不同?
  • 按值传递对新类型的对象意味着什么?
  • 新类型的合法值有花生米限制?
  • 新类型的适用于继承图吗?
  • 新类型允许哪些转换?
  • 对新类型来说,什么运算和函数合理?
  • 那些标准函数应该被禁止?
  • 谁应该能够访问新类型的成员?
  • 新类型未声明的接口是什么?关乎实现,如效率、异常安全、资源管理等的保证
  • 新类型有多通用?
  • 新类型真的需要吗?

倾向于按 const 引用传递而不是按值传递

一般来说更有效率,同时也避免了派生类传递给基类时多余的部分被切掉的问题

内置类型、STL 迭代器和函数对象类型一般来说按值传递

当必须返回一个对象的时候,不要返回引用

不要返回对局部栈空间的对象的指针或引用

在新标准中,编译器已经做了这种事

声明数据成员为 private

因为这样给予客户一致的对数据的访问,获取细粒度的访问控制,允许强制不变量,并灵活地提供类作者实现

protected 并没有比 public 封装程度高,因为所有的派生类仍然能够访问这些数据

倾向于非成员非友元函数而不是成员函数

这种做法提高了

  • 封装:少了一个能够访问所有私有成员的函数
  • 打包灵活性:连通其他同类型的有用的函数打包到一个命名空间中
  • 功能扩展:与客户端的扩展而不是类的定义更接近

当类型转换应该适用于所有的参数时,声明非成员函数

例如要重载乘法运算符,最好声明为成员函数,因为其左右两边的参数都可以发生隐式类型转换

考虑支持不抛出异常的 swap

std::swap 可能对你的类型不高效时,提供一个 swap 函数,确保其不会抛出异常

如果提供了一个成员 swap,也应该提供一个调用了该成员的非成员函数,对于类(不是类模板),同时特例化 std::swap

但调用 swap 时,使用 using std::swap,然后直接调用 swap()

对于用户定义的类型可以特例化 std 模板,但不要尝试添加全新的东西到 std 中

实现

尽可能延迟变量定义

这种做法增加了程序的清晰度并提高了程序效率

最小化 cast

尽可能避免 cast,尤其是效率敏感的代码中的 dynamic_cast

当 cast 必须时,将其藏在函数中

选择 C++ 风格的 cast

避免返回对对象内部的“处理”

“处理”即引用,指针或迭代器。这种做法提高了封装,有助于 const 成员函数表现 const,并最小化悬垂指针的创建

力求无异常的代码

无异常的函数即使当发生异常时,无资源泄漏且无数据结构被破坏

  • 基本保证:程序中的所有东西都在一个有效的状态
  • 强保证:在程序无改变的状态
  • 无异常保证:从不抛出异常

强保证可以通过复制并交换来实现,但是并不是对所有函数都实用

函数可以提供不强于其调用的最弱保证的函数的保证

理解 inline 的里外

inline 应该是小的、频繁调用的函数。这促进调试和二进制更新能力,最小化潜在的代码膨胀,最大化更快程序的可能性

不要因为其出现在头文件中,就声明函数模板为 inline

最小化文件之间的编译依赖

核心思想是依赖于声明而不是定义,有两种方法:

把手类 handle class

额外提供一个声明的头文件 ...fwd.h,客户端引用这个头文件而不是定义

只有在定义中才需要引入相关的定义的头文件

同时,因为对象中的数据大小是不确定的,所以可以通过将所有数据打包为一个指向 ...Impl 的智能指针,这样类的大小就是固定的了。这个 ...Impl 和原始的类应该有相同的接口

另一种方法是接口类 interface class

方法是定义一个接口类(无构造函数,虚析构函数和纯虚函数),并提供一个静态的工厂函数以创建其实现类的对象的智能指针

当然,这两种方法都是有代价的。其牺牲了一定的性能和大小。

库头文件应该存在完全和只声明的形式,无论是否有模板都适用。

继承与面向对象设计

确保 public 继承模型 is-a

任何适用于基类的东西必须都适用于派生类,因为每个派生类都一个基类对象

注意这是一个子集的关系,而不是松散意义上的“是”。这意味着不光是基类对象的所有描述性定义,还是其衍生出来的所有性质,子类都必须拥有

避免隐藏继承名字

派生类中的名称隐藏了基类中的名称,在 public 继承下,这是不想要的。

为了让隐藏的名称重新可见,可以使用 using 声明或者转发函数

区分接口继承和实现继承

在 public 继承下,派生类总是继承基类的接口

  • 纯虚函数只指明了继承的接口
  • 简单的虚函数指明了继承的接口及默认实现
  • 非虚函数指明了继承的接口和强制性的视线

考虑虚函数的替代品

包括了非虚函数接口习语 non-virtual interface idiom(NVI) 和可变形式的策略设计模式。NVI 习语实际上就是模板方法设计模式的一个例子

将功能从成员函数中移到类外部的函数的缺点是非成员函数缺少对类中的非公共成员的访问

function 对象类似于泛化的函数指针。这种对象支持所有有相同目标签名的可调用的实体

从不重定义一个继承的非虚函数

因为可能会导致非预期的错误

从不重定义一个函数的继承的默认参数值

因为默认参数值是静态绑定的,然而虚函数——唯一应该重定义的函数——是动态绑定的

通过组合模拟“有一个”或“以…实现”

组合与公共继承有完全不同的意思

  • 在应用领域,组合意味着 has-a
  • 在实现领域,它意味着 is-implemented-in-terms-of

谨慎使用 private 继承

private 继承同样意味着 is-implemented-in-terms-of,通常比组合低级,只有在需要访问基类中 private 成员或需要重定义虚函数时才会使用

不像组合,private 继承能够空基优化,对于库开发者来说,可以最小化对象大小

谨慎使用多继承

多继承比单继承更复杂,可能导致模棱两可的问题,需要虚继承

虚继承在大小、速度、初始化和赋值的复杂性上都有代价,只有在虚类没有数据时最实际

多继承的用处在于将从接口类的公共继承和用于实现的类的私有继承组合起来

模板和泛型编程

了解隐式接口和编译时多态

类和模板都支持接口和多态

  • 对于类来说,接口是显式的,基于函数签名。多态通过虚函数在运行时出现
  • 对于模板参数来说,接口是隐式的,基于有效的表达式。多态通过模板实例化和函数重载解析,在编译时出现

理解 typename 的两种意思

在声明模板参数时,classtypename 是可互换的

使用 typename 来识别嵌套依赖的类型名,除了在基类列表或在成员初始化列表中的基类标识符

例如:

template<typename T>
class Derived: public Base<T>::Nested {
public:
explicit Derived(int x)
: Base<T>::Nested(x) {
typename Base<T>::Nested temp;
}
}

知道如何在模板化的基类中访问名字

在派生类模板中,使用 this->using 声明或显式基类限定,引用基类模板中的名字

主要是因为在派生类实例化的时候,基类模板没有实例化

将参数无关的代码移除出模板

模板生成多个类和多个函数,故任何不依赖于模板参数的模板代码都会导致膨胀

由于非类型模板参数导致的膨胀通常可以通过将模板参数替换为函数参数或类数据成员来减少

由于类型参数导致的膨胀可以通过共享相同二进制表示的实例化类型实现来减少,如替换为指针或引用等

使用成员函数模板来接受“任何兼容类型”

使用成员函数模板来生成接受所有兼容类型的函数

如果声明了对泛化的复制构造函数或泛化的赋值的成员模板,则仍然需要声明正常的复制构造函数或复制赋值操作

当需要类型转换的时候,在模板内部定义非成员函数

当编写需要对所有参数支持隐式类型转换的类模板时,在类内部定义函数为友元

例如:

template<typename T>
const Rational<T> doMultiply(const Rational<T>&lhs, const Rational<T>&rhs);

template<typename T>
class Rational {
public:
friend const Rational<T> operator*(const Rational<T>&lhs, const Rational<T>&rhs) {
return doMultiply(lhs, rhs);
}
}

template<typename T>
const Rational<T> doMultiply(const Rational<T>&lhs, const Rational<T>&rhs) {
...
}

对于关于类型的信息,使用 trait 类

trait 类在编译时使关于类型的信息可用,其通过模板和模板特例化实现

和重载结合,trait 类使得执行编译时对 if-else 检测可用

了解模板元编程

模板元编程可以将运行时的工作移动到编译时,使得更早检测到错误并提高运行效率

TMP 可以用于生成基于策略选择的自定义代码,也可以用于避免生成对特定类型不合适的代码

自定义 newdelete

理解 new-handler 的表现

set_new_handler 运行指明当内存分配请求无法满足时调用的函数

不抛出异常的 new 用处不大,因为其只适用于内存分配,但在相关的构造函数被调用时仍然有可能抛出异常

理解替代 newdelete 的情况

  • 提高效率
  • 调试堆使用错误
  • 收集堆使用信息

在写 newdelete 时坚持惯例

  • new 应该
    • 在无限循环中尝试分配内存
    • 如果无法满足一个内存请求,则调用 new-handler
    • 处理 0 字节的请求
    • 指明类的版本应该处理比期望更大的块的请求,如 if (size != sizeof(Base)) return ::operator new(size);
  • delete 应该
    • 如果传递的指针是 null,则什么都不做
    • 指明类的版本应该处理比期望的块更大,如 if (size != sizeof(Base)) { ::operator delete(rawMemory); return; }

如果写 new 的替代,也要编写 delete 的替代

否则可能会发生内存泄漏

注意不要隐藏了这些函数的正常版本

杂项

关注编译器警告

力求在最大警告级别时仍然没有警告

但不要依赖于编译器警告,因为不同编译器警告不同的东西

熟悉标准库,包括 TR1

TR1 是一个规范,需要实现,如 Boost

熟悉 Boost

Boost C++ Libraries