Effective C++ note

条款01:视C++为一个语言联邦

  • 语言联邦
    • C
    • Object-Oriented C++
    • Template C++
    • STL
  • 内置类型而言,pass-by-value比pass-by-reference高效。
  • 类类型而言,pass-by-reference-to-const更好。
  • STL的迭代器和函数对象都是在C指针之上塑造出来的,故pass-by-value依然有效。

条款02:尽量以const、enum、inline替代#define

定义常量

  • 宏在编译器开始处理代码之前就被移走了,实际使用的名称可能并未进入记号表
  • 使用常量替换宏
    • 对于浮点常量而言,使用常量可能比宏导致更小的码量,因为预处理器盲目的替换宏为浮点数,导致目标代码出现多份浮点数。
    • 定义常量指针时,由于常量定义式通常放在头文件中,因此有必要将指针声明为const
    • 定义class专属常量时,必须让它成为一个static成员
  • 使用enum替换宏
    • enum的行为比较像#define而不像const,如const变量可以取地址,但是不能取enum和#define的地址(不会导致非必要内存的分配)。
    • enum在很多代码中使用了,实质上是模板元编程

定义函数

  • 使用inline替换宏函数
    • inline可以获得宏的效率函数类型安全性
    • 由于不知道传入内敛模板的类型,故使用pass-by-reference-to-const。
1
2
3
4
5
template<typename T>
inline void callWithMax(const T& a, const T& b)
{
...
}

条款03:尽可能使用const

  • const指定一个语义约束,由编译器强制实施这个约束。
  • const出现在*左边,表示指针指向的对象是常量,出现在*右边,表示指针本身是常量。

STL的迭代器是以指针为根据封装出来的,所以迭代器的作用像个T*指针

声明迭代器为const(const_iterator)就像声明指针为const一样,表示迭代器不可指向不同的东西。

  • 令函数返回常量值,往往可以降低因客户错误而造成的意外。

const成员函数

  • const成员函数重要的原因
    • 使得class接口比较容易被理解。
    • 使得操作const对象成为可能。
  • 两个成员函数如果只是常量性不同,可以被重载

bitwise constness

  • 成员函数只有在不更改对象的任何成员变量时才可以说是const。
  • 优点:编译器只需寻找成员变量的赋值动作就可以找到违反const的点。
  • 缺点:一些成员函数不具备const的性质,仍能通过bitwise测试。
    • 如一个更改了指针所指对象的成员函数不能算是const,但如果只有指针隶属于对象,就能通过bitwise测试。

logical constness

  • 一个const成员函数可以修改它所处理的对象内的某些bits。
  • 使用mutable释放掉non-static成员的bitwise constness约束。

const和non-const重复避免

  • 为减少代码重复,可以令重载的函数一个调用另外一个。
  • 令non-const调用const是安全的做法,过程中需要转型。
    • 第一个转型动作:为调用const版本的成员函数,必须使用const对象/指针/引用来调用,故需要将*this从&转换为const &,这个转型是安全的,可以使用static_cast。
    • 第二个转型动作:将调用结果转换为非const类型,可以使用const_cast。

条款04:确定对象被使用前已先被初始化

  • 读取未初始化的值会导致未定义行为。
  • 内置类型来说,它们是类C的,必须手工完成初始化。
  • 类类型来说,尽量使用成员初始化列表完成初始化,而不是赋值。
    • 总是在初始列中列出所有成员变量,以免遗漏。
    • 成员变量是const或者reference则必须初始化而不能赋值。
    • 多份成员初始化列表时,可以将那些初始化和赋值一样高效的变量归类为统一的赋值工作(一般是private)。
    • 成员初始化顺序只和在类中定义的顺序相同

函数内的static对象被称为local static对象,其他static对象被称为non-local static对象。

编译单元是指产出单一目标文件的源码(cpp文件加上其包含的头文件内容)。

  • 如果某编译单元内的non-static对象初始化时使用了另一个编译单元的non-local static对象,它所用到的这个对象可能尚未初始化。
  • 解决方案
    • 将每个non-local static对象搬到自己的专属函数内,对象在函数内被声明为static(变为local),返回它的reference,其他用户调用这些函数而不是对象。
    • 这是Singleton模式的常见手法,且由于函数内容简单、可能被频繁调用,可以将其设置为inline。

static对象是非线程安全的。

条款05:了解C++默认生成和调用的函数

  • 如果程序员没有声明,编译器会自动为它声明:默认构造函数(没有任何构造函数时)、拷贝构造函数、拷贝赋值运算符=、析构函数。且都是public和inline。
  • 编译器生成的默认构造函数和析构函数中,包含了父类和non-static成员的构造函数和析构函数。

编译器生成的析构函数是non-virtual的,除非其基类有virtual的析构函数。

  • 编译器生成的拷贝构造函数和拷贝赋值运算符=只是浅拷贝。
  • 拷贝赋值运算符=是否生成
    • 取决于成员是否包括reference和const成员。
    • 基类的拷贝赋值运算符=为private时,也拒绝生成。

条款06:若不想使用编译器自动生成的函数,就该明确拒绝

  • 拷贝构造函数和拷贝赋值运算符会自动生成,导致不能直接通过不声明的方式拒绝其生产。
    • 方法1:可以声明一个private成员函数,阻止用户调用。
    • 方法2:方法1对于友元和其他成员函数无效,可以在其基础上不定义函数内容,如果有人使用了该函数,则会获得一个连接错误(连接期)。
    • 方法3:为了将连接期的错误提前到编译期,可以将方法2移植到其基类(专门用于阻止自动生成)中。
      • 编译器生成这些函数时,会尝试调用父类的对应函数,调用会被拒绝(父类中是private)。
      • 使用这项技术可能导致多重继承的问题。
  • 现代方法:加上=delete。

条款07:为多态基类声明virtual析构函数

  • 情景:只想使用基类的一个功能,可以使用工厂函数返回指向派生类对象的基类指针,然后通过基类指针调用功能。而此时基类的析构函数是non-virtual的,此时析构只会执行父类的析构函数,而子类额外的部分无法被销毁。
  • 解决方案:给基类一个virtual析构函数。

virtual函数的目的是允许派生类的实现得以客制化。

  • 带virtual函数的类几乎都应该有一个virtual析构函数。
  • 若类不想作为基类,不应该将其析构函数设置为virtual。
    • 因为虚函数都放在虚函数表中,用一个vptr指针就是指向虚函数表的指针,相当于多了内存开销。

继承类的时候,需要注意基类的析构函数是否是virtual的。

  • 构造抽象类时,不管成员函数有无pure virtual,析构函数可以声明为pure virtual的。
    • 必须为这个析构函数提供定义,否则基类调用时会连接失败。

条款08:别让异常逃离析构函数

  • C++不禁止析构函数抛出异常,但是不建议。

两个同时作用的异常会导致程序结束或者不明确行为。

  • 如果程序已经遭遇析构期间的错误,强迫程序终止可避免异常从析构函数抛出。
  • 如果某个操作可能导致异常抛出,必须让它在析构函数以外的函数抛出(相当于转移责任)。

条款09:绝不在构造和析构过程中调用virtual函数

  • 由于基类的构造函数先于派生类的构造函数执行,当执行基类构造函数时,派生类的成员变量尚未初始化
    • 基类构造函数中调用的虚函数使用派生类版本,虚函数又不免用到派生类成员变量,此时成员变量未初始化
  • 在派生类对象调用基类构造函数期间,对象的类型是基类而不是派生类。
    • 使用RTTI,也会把对象视作基类。
    • 因为对象在派生类构造函数开始执行之前不会成为一个派生类对象。
  • 析构函数同理
    • 派生类析构函数开始后,派生类成员变量呈现未定义值,所以C++视它们不存在。
    • 进入基类析构函数时,对象才成为一个基类对象。

条款10:令operator=返回一个reference to *this

  • 连锁赋值
1
x = y = z = 15;
  • 为了实现“连锁赋值”,赋值操作符必须返回一个reference指向操作符的左侧实参
1
2
3
4
Base& operator=(const Base& rhs) {
...
return *this;
}

这只是个建议,并无强制性

条款11:在operator=中处理自我赋值

  • 潜在自我赋值
1
2
a[i] = a[j];
*px = *py;
  • 如果尝试自行管理资源,可能造成对释放资源的使用
1
2
3
4
5
Base& operator=(const Base& rhs) {
delete p_bitmap;
p_bitmap = new Bitmap(*rhs.p_bitmap);
return *this;
}
  • 上述代码如果左侧和右侧为同一个对象,则会发生错误:释放指针后使用该指针
    • 阻止这种错误的传统做法是在operator=的函数体中加一个认同测试,以检验是否是自我赋值。
1
2
3
4
5
6
Base& operator=(const Base& rhs) {
if(this == &rhs) return *this;
delete p_bitmap;
p_bitmap = new Bitmap(*rhs.p_bitmap);
return *this;
}
  • 上述版本仍不具备异常安全性:new Bitmap导致异常时,p_bitmap指向被删除的对象。
    • 且由于自我赋值较少,导致认同测试实际进入分支次数少,但每次都需要判断,降低了执行速度
    • 替代方案是使用copy and swap技术
1
2
3
4
5
6
7
8
9
10
11
//代码逻辑清晰版本
Base& operator=(const Base& rhs) {
Base tmp(rhs);
swap(tmp);
return *this;
}
//代码简洁版本
Base& operator=(Base rhs) { //值传递
swap(rhs);
return *this;
}

条款12:复制对象时勿忘每一个成分

  • 设计良好的面向对象系统会将对象内部封装起来,只留下拷贝构造函数拷贝赋值操作符(统称为copying函数)用于对象拷贝。
  • 如果声明自己的copying函数,即使你的代码不完全,编译器也不会报错。
    • 当你添加一个成员变量时,往往需要修改所有的构造函数和operator=(不仅仅是copying函数),如果没有修改,编译器也不会提醒。
  • 编写自己的copying函数需要确保:
    • 复制所有local成员变量
    • 调用基类的copying函数
1
2
3
4
5
6
7
8
9
10
11
12
13
class Derived : Base {
public:
...
Derived(const Derived& rhs)
: Base(rhs), local_variable(rhs.local_variable){ }
Derived& operator=(const Derived& rhs) {
Base::operator=(rhs);
local_variable=rhs.local_variable;
return *this;
}
private:
int local_variable;
}
  • copying函数有大致相同的实现过程,可以通过调用其他函数来避免代码重复。
    • 但是:不应该令拷贝构造函数调用拷贝赋值操作符,相反同理
    • 正确的做法是:建立一个新的成员函数给copying函数调用。这个函数往往是private,且常被命名为init

条款13:以对象管理资源

  • 背景:有时使用工厂函数创建资源返回指针,需要考虑资源的释放
    • 由于一些特殊情况(异常、分支、遗忘),会导致资源无法被释放,导致内存泄漏。
  • 为确保资源总是能被释放,我们需要将资源放进对象内,当离开作用域时,该对象可以自动释放资源(利用对象的析构函数)
    • 使用智能指针
  • “以对象管理资源”的关键想法
    • 获得资源后立即放进管理对象。我们应该在获得资源后在同一语句初始化其管理对象,这被称为RAII(资源取得实际便是初始化时机)
    • 管理对象运用析构函数确保资源被释放

智能指针在析构函数里面做的是delete而不是delete[],故不能管理动态数组资源

  • 工厂函数返回未加工指针很容易造成资源泄漏

条款14:在资源管理类中小心copying行为

  • 智能指针表现在堆创建的资源上,其余资源的管理可能需要自己创建资源管理类。
  • 自己创建资源管理类面临复制,有以下4种可能:
    • 禁止复制:将copying声明为private;
    • 对底层资源使用引用计数
    • 复制底部资源:进行深拷贝。
    • 转移底部资源的拥有权:希望同时只有一个RAII指向一个未加工资源,在被复制时,转移拥有权

shared_ptr支持指定删除器

条款15:在资源管理类中提供对原始资源的访问

  • 使用资源管理类来管理原始资源,而不是直接处理。
  • 有的函数需要提供原始资源的指针,但此时是用资源管理类封装的资源,有两个方法可以完成。
    • 显示转换:提供一个get成员函数,返回原始指针
    • 隐式转换:将类型隐式转换为原始指针类型
1
2
3
4
5
6
7
8
9
10
11
class ManageResouce {
private:
Resource* p_res;
public:
...
//显式转换
Resource* get() const { return p_res; }
//隐式转换
operator ManageResouce() const { return p_res; }
}

  • 关于选择显式转换还是隐式转换,主要取决于RAII类被设计的特定工作。

条款16:成对使用new和delete时要采取相同形式

  • new对象时,有两件事发生
    • 内存被分配出来;
    • 针对此内存调用构造函数。
  • delete对象时,也有两件事发生
    • 针对此内存调用析构函数;
    • 内存被释放。
  • delete需要知道被删除的指针是单一对象还是对象数组
    • 使用delete加上[]表示为对象数组:delete[]会读取若干内存并将其解释为数组大小,开始多次调用析构函数。
  • 由于new和delete的形式必须匹配,所以不建议使用typedef定义数组,因为不知道使用delete还是delete[]。

条款17:以独立语句将newed对象置入智能指针

  • 考虑情景
1
2
3
4
int priority();
void func(shared_ptr<Resource> pr, int priority);
...
func(shared_ptr<Resource>(new Resource), priority()); //调用func
  • 对于上述情景,func执行前会发生:
    • priority的调用;
    • new Resource;
    • 构造shared_ptr。
  • 但是上述事件的执行顺序是不一定的(new Resource一定在构造shared_ptr之前),编译器会进行优化。
  • 当priority在其他两者之间执行时,若priority抛出异常,会导致new Resource之后并没有传入shared_ptr,这块内存会遗失
    • 是因为在资源被创建资源被转换为资源管理对象之间可能发生异常。
    • 解决方法就是将资源被创建资源被转换为资源管理对象单独出来执行。
1
2
shared_ptr<Resource>sr(new Resource);
func(sr, priority());

条款18:让接口容易被正确使用,不易被误用

必须考虑客户可能犯什么错误

1
2
//客户可能传输错误的参数范围或者顺序
void Date(int month, int day, int year);
  • 上述问题可以通过导入新类型来预防。
    • 导入覆盖类型来区分month、day、year。
1
2
3
4
5
struct Month {	//Day、Year类似
explicit Day(int d) : val(d){}
int val;
};
void Date(const Month& m, const Day& d, const Year& y);

将类型设置为成熟的class比struct更好

  • 类型正确后,可以通过逻辑限制其范围
    • 可以通过enum枚举可用值,但enum不具备类型安全性
    • 有效安全的解法是预先定义所有有效的值。
1
2
3
4
5
6
7
8
class Month {
public:
static Month Jan() { return Month(1); }
private:
explicit Month(int m);
};
...
Date(Month::Dec(), Day(30), Year(2000));
  • 限制客户使用的对象功能

    • 通常是加上const(参考条款3)。
  • 除非有特殊原因,应该令自定义类型行为和其内置类型行为一致。

    • 如上述例子中,应该让Month的行为类似于int,但由于乘法不合法,应该避免乘法。
    • 方法是提供行为一致的接口
  • 参考条款13的客户错误:没有删除指针、删除同一个指针两次。

    • 解决方案是让客户使用智能指针接收原始指针
  • 万一客户忘记使用智能指针接收。
    • 解决方案是工厂函数将指针通过智能指针的形式返回。
1
shared_ptr<Resource> createResource();
  • 若工厂函数仍返回原始指针,且希望使用指定删除器释放指针,同时客户错误的使用了delete(或其他方式)释放了资源。
    • 解决方案是工厂函数返回一个绑定了删除器的智能指针
1
2
3
4
5
shared_ptr<Resource> createResource() {
shared_ptr<Resource> retSP(nullptr, removeResource);
retSP = ...;
return retSP;
}

当然,能够将原始指针直接传给智能指针的构造函数是更好的。

  • 在一个DLL被new创建,却在另一个DLL内被delete销毁,会导致cross-DLL问题。
    • 解决方案是使用智能指针,因为它的默认删除器是创建时DLL的删除器

智能指针比原始指针大且慢,而且使用辅助动态内存

但实际中成本不显著,而且能大幅减少客户错误

条款19:设计class犹如设计type

设计高效的类,需要考虑的问题如下。

  • 对象如何被创建和销毁
    • 影响到构造函数、析构函数、内存分配和释放函数的设计。
  • 对象的初始化和赋值应该有什么区别?
    • 影响到构造函数和赋值操作符的设计。
  • 对象被pass by value,意味着什么?
    • 矛盾:拷贝构造函数不能pass by value
  • 什么是对象的合法值
    • 约束条件决定了成员函数必须进行的错误检查工作。
  • 类需要继承吗?
    • 作为派生类,就会受到基类的约束,特别是成员函数是否为virtual的影响;
    • 作为基类,需要注意成员函数(尤其是析构函数)是否为virtual
  • 类需要什么转换
    • A隐式转换到B:在A中写一个类型转换函数或在B中写一个non-explicit-one-argument的构造函数;
    • 只允许explicit构造函数:就得写出专门负责转换的函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
class A {
private:
int val;
public:
A(int _val) : val(_val) {}
operator B() { return B(val); }
};
class B {
private:
int val;
public:
B(int _val) : val(_val) {}
};
  • 类需要哪些函数和操作符
  • 类应该拒绝哪些函数
    • 决定了必须声明为private的函数。
  • 谁能使用类的成员
    • 决定了public、protected和private
  • 什么是类的未声明接口
    • 待办
  • 类有多么一般化
    • 有时候不仅仅是定义一个类,而是定义一整个类家族,这种情况可以选择定义一个类模板
  • 真的需要一个新类吗?
    • 有时候继承再添加功能是更好的选择。

条款20:宁以pass-by-reference-to-const替换pass-by-value

  • 缺省情况下C++以by value的方式传递对象。
    • 副本由对象的拷贝构造函数产出;
    • 传递开销多了:1次对象的拷贝构造函数的调用+1次对象的析构函数调用
  • pass-by-reference-to-const常用于替换pass-by-value
    • 效率高:没有对象被创建;
    • 合理性:pass-by-value的参数一般不会被修改,使用const合理且安全;
    • 避免对象切割:派生类对象以by-value的形式传给基类参数时,只会保留基类的部分
  • C++编译器底层实现reference的方式是指针
  • 某些编译器对内置类型用户自定义类型态度不同。
    • 例如:(某些编译器)拒绝把只由单一内置类型组成的对象放入缓存器,但单一内存类型数组可以。
    • 此时应该使用by reference的方式传递此对象。

条款21:必须返回对象时,别妄想返回其reference

  • 如果想要返回reference,就会有个致命错误:传递reference指向并不存在的对象。
    • 返回reference的初衷是避免调用构造函数
    • 函数创建对象有2个方式:stack空间和heap空间创建。
  • 若在stack创建对象,相当于定义一个local对象

    • 本身可以拒绝,因为违背了使用reference的初衷
    • 任何函数返回reference指向local对象,返回后local对象已经销毁了
  • 若在heap创建对象,同时通过reference返回

    • 无法delete在函数内部new的对象。

上述两个方法都因为对返回结果调用构造函数而失败。

  • 在stack使用static对象,可以成功返回引用并且在全局变量区
    • 首先是非多线程安全的;
    • 每次调用该函数返回的是同一个static变量,若两次函数同时比较返回结果,那么一定是相同的(都是后一次调用结果)。
  • 正确写法:返回一个新对象,而不是引用
    • 编译器会自动优化。

条款22:将成员变量声明为private

  • 若成员变量不是public,客户只能通过成员函数访问对象。
    • public接口全都是函数,客户就不需要考虑是成员还是变量(是否带()
    • 使用函数可以保证对成员变量的处理更精确(定义具体使用方式)
    • 每个成员变量都需要一个getter和setter函数
    • 封装之后,可以在类内部修改成员变量逻辑,而使用该类的用户并不需要做出任何变化;
      • 可根据不同需求修改底层逻辑,使得程序设计更有弹性
  • public成员和protected成员变量类似,从一致性访问控制来说都不合适(和private相反)。
    • 成员变量的封装性“成员变量内容改变时所破坏的代码数量”反比
    • 若一个public成员变量被取消,所有使用它的客户代码都会被破坏;
    • 若一个protected成员变量被取消,所有使用它的派生类都会被破坏。
  • 封装的角度来看,只有两种访问权限:
    • private:提供封装;
    • 其他:不提供封装。

条款23:宁以non-member、non-friend替换member函数

  • 如果用户想将一系列成员函数封装到一个函数中统一运行时,有两个选择:
    • member函数
    • non-member函数

面向对象守则要求数据尽可能被封装

  • member函数的封装性比non-member函数
    • non-member函数对类有较大的包裹弹性,导致编译依赖度低,增加了类的可延伸性
    • 越少代码可以看到数据,越多的数据可被封装=>越多函数可访问,数据的封装性越低
    • member函数可以访问类的private部分以及enum、typedef等,而non-member且non-friend函数不可。

注意:

  1. 上述只适用于non-member且non-friend函数:friend函数等同于member函数。
  2. 此类的non-member函数,可以是其他类的member。
  • C++的做法是使用non-member函数并将其放到namespace中。
    • namespace可以跨越多个源码文件,而class不行;
    • 若发生编译依赖问题,可以将不同的non-member功能函数放到不同的头文件中

C++标准库就是这么做的,将封装函数放到多个头文件中,但隶属于同一个命名空间

条款24:若所有参数皆需类型转换,请为此采用non-member函数

  • 有时候需要做混合运算时,若使用member函数会出现以下情况:
1
2
result = rational * 2;	//在non-explicit构造函数下没问题
result = 2 * rational; //甚至在non-explicit构造函数下也报错
  • 因为只有当参数位于参数列表中,该参数才允许被隐式转换。

    • 调用函数的this对应的对象不允许隐式转换。
  • 让混合运算的函数成为non-member函数,即可允许编译器给每个实参进行隐式转换。

    • 该函数不应该是friend函数,因为member函数的对立面应该是non-member且non-friend函数(见条款23)。

条款25:考虑写出一个不抛异常的swap函数

std::swap是提供异常安全的。

  • 若swap的缺省实现可以满足class或class template交换的效率,则使用缺省版本。
  • 若swap效率不足(往往是class或class template使用了pimpl手法),做法如下:
    • 提供一个public swap函数,内部对内置类型进行高效交换
    • 在class或class template所在命名空间提供一个non-member swap函数,令其调用public swap成员函数
    • 如果编写的是class而非class template,最好为class特化std::swap,并令它调用public swap成员函数

调用swap时,在函数中包含using声明式,从而不加namespace修饰直接调用。

  • 成员版swap绝不可抛出异常
    • swap的应用可以帮助class/class template提供强异常安全性
    • 只约束成员版,因为swap缺省版本以拷贝构造函数和拷贝赋值操作符为基础,而这两者都允许抛出异常。