Effective C++ Notes (2)
Implementations
Item 26: Postpone variable definitions as long as possible
为了尽可能减少变量构造和析构成本,我们应该直到变量被使用的前一刻再定义它。
1 | std::string encryptPassword(const std::string& password) |
在这个例子里,如果我们在第4行处抛出异常,已经被构造出的excrypted
需要被析构,尽管它从来没有被使用过。如果我们把encrypted
的定义往后挪,就能避免这种情况。
1 | std::string encryptPassword(const std::string& password) |
这可能不符合(ANSI)C语言中把变量声明全部放在函数体头部的习惯,但这是C++的best practice。
对于循环变量:
1 | // A |
A的开销是1次构造+1次析构+n次赋值,B的开销是N次构造+n次析构,因此使用哪种需要根据具体情况下相关函数的开销而定。
Item 27: Minimize casting
类型转换不仅仅是告诉编译器将一种类型视作另一种类型,还可能产生额外代码,因此是有代价的。例如整型数转浮点数:
1 | int i = 42; |
1 | pxor xmm0, xmm0 |
不要试图通过将派生类对象转成基类来调用基类函数(谁会这么写?)
以下代码的输出是0 1
,因为Derive::foo()
在一个临时的Base
对象上调用了Base::foo()
。直接用Base::foo()
调用就行。
1 | class Base { |
dynamic_cast
性能开销很大,如果有一个元素为基类指针Base*
的容器,不要试图做这些事:
遍历每个元素,用
dynamic_cast
判断它指向的是否为派生类Derived
,是的话调用Derived::foo()
。这样会做n次
dynamic_cast
,不如把foo()
变成虚函数,给Base::foo()
一个空定义,然后直接对每个元素调用foo()
。用一堆
if else
逐一判断每个元素指向对象的类型。性能极差,而且每次继承体系被修改也得跟着改。应该改成基于虚函数的实现。
Item 28: Avoid returning "handles" to object internals
如果我们有一个Point
类和一个Rectangle
类,Rectangle
提供upperleft()
成员函数,返回左上角Point
的引用。这是一个const
成员函数,因为该函数不修改矩形的private
变量。
1 | class Point { |
以上代码无法通过编译,因为upperleft()
的返回类型没有用const
修饰,提醒我们要加上这个const
。
但是,如果我们的Rectangle
是基于pimpl实现的:
1 | class Rectangle { |
尽管我们忘记给upperleft()
的返回类型加const
,这段代码仍然能通过编译,因为该函数确实没有修改pImpl
,编译器不管嵌套类RectangleImpl
里面的事情(对编译器来说,这是一个与Rectangle
无关的类)。因此,可以通过如下代码修改一个const
对象。
1 | const Rectangle rec(Point(0, 0), Point(10, 10)); |
因此,返回一个类内部成分的handle(包括指针,引用,迭代器)是危险的,这里的内部成分是逻辑上的,不一定是对象的成员,语法上可以是对象以外的一个变量(如本例)。
即使我们这里给Rectangle::upperleft()
加了const
,虽然不能改对象了,还是有悬垂handle的问题:
1 | Rectangle foo() { |
这里的upperleft
是对foo()
返回的临时对象内部成分的引用,因此它在第6行结束后成为一个悬垂引用,对其的访问是非法的。
当然,不是任何情况都不能返回指向类内部成分的handle,operator[]
就是一个反例,它不得不这么做。
Item 29: Strive for exception-safe code
异常安全(exception-safe)指的是函数抛出异常后,应该仍满足:
不发生资源泄漏,例如一个拿住的锁没有释放。
所操作的对象仍处于一个一致(合法)的状态。
“一致”不局限于函数完全执行,或者完全没有执行两种状态,只要是对象的一个合法状态就可以。
一个不满足2的例子:
1
2
3
4
5
6void PrettyMenu::changeBg(std::istream& imgSrc)
{
delete bgImage_;
++imageChangeCnt;
bgImage_ = new Image(imgSrc);
}如果创建
Image
对象时抛出异常,bgImage_
会成为一个空悬指针。
对于1,通过RAII资源管理类可以保证资源不泄漏(如lock_guard
之于mutex
)。对于2,函数的一致性保证又可以分为不同程度:
- 基本保证:异常抛出后,对象仍处于一致状态,但不知道具体是哪一种。
- 强保证:异常抛出后,对象回到函数调用之前的状态。
- 承诺不抛出异常。
大部分函数很难做到3,例如一个需要分配内存的函数必须在内存不足时抛出bad_alloc
。
使用copy-and-swap和pimpl
idiom是一种提供强保证的方式,即把数据成员放到impl类里,操作在impl的副本上做,最后通过不抛异常的swap
交换新旧impl类。这样做(需要拷贝)代价很大,需要权衡。
Item 30: Understand the ins and outs of inlining
inline
向编译器发出内联申请。内联消除了函数调用开销,但也导致代码体积变大。
如果成员函数在类的内部定义,它被视为隐式添加了inline
。
虚函数也可以被声明为inline
,但只有当不需要动态绑定(即编译期能确定调用的是哪个版本)时才可能真正被内联。
派生类的构造/析构函数哪怕是空函数,也一般不会被内联。因为编译器会生成一堆代码调用父类的构造/析构函数。
函数模板不一定要是内联的。只有确信任何实例化出来的函数都应该被内联,才应该为函数模板添加inline
修饰。
(以上为原书内容)
在Modern Cpp中,inline
的意义已经超出了它的字面意思。cppreference指出:
An inline function or inline variable(since C++17) has the following properties:
- The definition of an inline function or variable(since C++17) must be reachable in the translation unit where it is accessed (not necessarily before the point of access).
- An inline function or variable(since C++17) with external linkage (e.g. not declared static) has the following additional properties:
- There may be more than one definition of an inline function or variable(since C++17) in the program as long as each definition appears in a different translation unit and (for non-static inline functions and variables(since C++17)) all definitions are identical. For example, an inline function or an inline variable(since C++17) may be defined in a header file that is included in multiple source files.
- It must be declared inline in every translation unit.
- It has the same address in every translation unit.
被声明为inline
的函数(C++17后也可以是变量)被允许在每个使用到的translation
unit中都有一份定义,非inline
的函数和变量在整个程序中只能定义一次(one-definiton
rule)。工程上,inline
允许我们在头文件中定义函数和变量,并被多个源代码文件包含,同时不违反one-definition
rule。这也解释了为什么类的成员函数会被隐式inline
,因为这允许我们在头文件中定义类的成员函数。
1 | // header.h |
以上代码会导致链接时的多重定义,只有给foo()
加上inline
才能正确链接。
Item 31: Minimize compilation dependencies between files
编写C++程序时,如果做到将接口与类的实现分离,就可以在修改实现时避免重新编译仅依赖接口的源文件。
一个concrete class的例子,它没有很好地做到这一点:
1 |
|
Person
的接口部分和实现部分在一起,同时必须#include
与私有成员相关的头文件,引入了依赖关系。一旦这些头文件有修改,依赖了这个Person
类的任何文件都需要一起修改。
为了解决这个问题,大概有两种思路:
基于pimpl idiom,此时
Person
只提供接口,称为handle class。实现部分放到一个PersonImpl
中去:1
2
3
4
5
6
7
8
9
10
11
12
13class PersonImpl;
class Date;
class 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::shared_ptr<PersonImpl> pImpl;
};PersonImpl
的接口与Person
完全一样,Person
的成员函数调用PersonImpl
的同名对应函数。Person
的实现(PersonImpl
,Address
,Data
)的修改不会导致依赖Person
的代码重新编译。这里我们只需要声明
Date
和Address
,而不需要定义它们。在更复杂一点的场景中,这个Date
的声明可能不是class Date
,而是一个更复杂的东西(比如是一个typedef BasicDate<int> Date
之类的),我们不知道要怎么声明Date
。这种情况下,Date
的作者应该提供一个类似datefwd.h
的头文件,我们只要include进来就行。一个这样的例子是标准库里的<iosfwd>
。基于handle class将接口与实现分离会带来一定性能损失,因为多了一个指针带来了间接访问。
另一个思路是将
Person
实现为interface。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33class Person {
public:
virtual ~Person();
virtual string name() const = 0;
virtual string birth_date() const = 0;
virtual string address() const = 0;
static shared_ptr<Person>
create(const string& name, const string& birth_date, const string& address) {
return shared_ptr<Person>(new RealPerson(name, birth_date, address));
}
};
class RealPerson : public Person {
public:
RealPerson(const string& name, const string& birth_date, const string& address)
: theName(name), theBirthDate(birth_date), theAddress(address) {}
virtual string name() const { return theName; }
virtual string birth_date() const { return theBirthDate; }
virtual string address() const { return theAddress; }
private:
string theName;
string theBirthDate;
string theAddress;
};
int main() {
shared_ptr<Person> p = Person::create("John Doe", "1970-01-01", "1234 56th Street");
cout << p->name() << endl;
cout << p->birth_date() << endl;
cout << p->address() << endl;
}Person
作为抽象基类定义了接口,RealPerson
继承接口提供了实现。基于interface将接口与实现分离会带来一定性能损失,因为所有成员函数都是虚函数,带来了查虚函数表的开销。
Inheritance and Object-oriented Design
Item 32: Make sure public inheritance models "is-a"
public
继承代表"is-a"的关系。如果Derived
以public
形式继承Base
,说明Derived
对象同时也是一个Base
对象。
"is-a"意味着任何一个能对Base
对象进行的操作也能对Derived
对象进行,任何一个需要Base
对象的地方也可以接受一个Derived
对象,而不能简单依靠常识判断,例如:
- 如果
Bird
类支持fly()
函数,那企鹅就不是鸟,因为企鹅不会飞。 - 如果
Rectangle
支持修改宽度,并且让宽度不等于长度,那Square
就不是Rectangle
,因为对正方形不能这么做。
Item 33: Avoid hiding inherited names
不要让派生类中的名称遮挡基类中的名称:
1 | class Base { |
Base
中的mf1()
和mf3()
两个名字被遮挡了,相当于派生类没有继承带参数版本的mf1(int)
和mf3(double)
。对于public
继承而言这是不允许的。
Item 34: Differentiate between inheritance of interface and inheritance of implementation
当派生类继承基类时,它一定继承了基类各成员函数的接口。但是否继承成员函数的实现,取决于不同成员函数的声明方式:
纯虚函数:只继承接口,不继承实现。因为派生类必须给出纯虚函数的实现。
语法上,纯虚函数是可以有实现的。
普通函数(非虚函数):继承接口和实现,并且不允许实现被修改。
虚函数:继承接口和默认实现。继承虚函数存在一种潜在的危险:
1
2
3
4
5
6
7
8
9class AirPlane {
public:
virtual void fly() {
cout << "AirPlane::fly()" << endl;
};
};
class ModelA : public AirPlane {};
class ModelB : public AirPlane {};ModelA
和ModelB
都可以使用通用的fly()
,它们直接继承了基类实现并且未作修改。但假如有一种新式飞机
ModelC
,它并不能使用AirPlane::fly()
。如果类的编写者忘记重载fly()
,就会导致错误。我们希望发生这种情况时能报出一个编译错误。一种做法是将接口继承和实现继承分离开来,把
AirPlane
的虚函数fly()
拆成一个纯虚函数fly()
和一个非虚函数defaultFly()
。派生类必须重载fly()
并显式调用defaultFly()
来表明它们不仅希望继承接口,还希望继承实现。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17class AirPlane {
public:
virtual void fly() = 0;
protected:
void defaultFly() {
cout << "AirPlane::fly()" << endl;
}
};
class ModelA : public AirPlane {
public:
void fly() override { defaultFly(); }
};
class ModelB : public AirPlane {
public:
void fly() override { defaultFly(); }
};这样做的坏处是多引入了一个函数,不利于代码可读性。另一种方案是依赖纯虚函数的定义:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17class AirPlane {
public:
virtual void fly() = 0;
};
void AirPlane::fly() {
cout << "AirPlane::fly()" << endl;
}
class ModelA : public AirPlane {
public:
void fly() override { AirPlane::fly(); }
};
class ModelB : public AirPlane {
public:
void fly() override { AirPlane::fly(); }
};效果和之前一样,派生类必须重载
fly()
。
Item 35: Consider alternatives to virtual function
以下定义一个GameCharacter
类,并定义了一个healthValue()
虚函数,因为不同游戏人物可能有不同的计算血量方式。
1 | class GameCharacter { |
本章给出了实现这个需求的几种其他方式。
Non-Virtual Interface(NVI)
该方式实现了Template
Method设计模式,healthValue()
是一个模板方法,它不允许被派生类重载。派生类只可以重载其中的一个步骤,即doHealthValue()
。这样一来,healthValue()
中的一些通用步骤(日志,拿锁,验证)就可以在整个继承体系中保留。
1 | class GameCharacter { |
基于函数指针
该方式实现了Strategy设计模式。healthValue()
的实现不再是GameCharacter
的一部分,而是拆分出去,通过一个函数指针被GameCharacter
调用。
1 | class GameCharacter; |
这样做的好处是:
- 同一类型的不同对象可以拥有不同的函数指针,也就是不同的血量计算方式。
- 血量计算方式在运行时可以变更。
但是,如果healthFunc
函数需要通过GameCharacter
的私有成员计算血量,这个做法就行不通了。
基于std::function
和上例一样,只是把第5行定义的函数指针改为std::function
:
1 | typedef std::function<int (const GameCharacter&)> HealthCalcFunc; |
这允许GameCharacter
使用任何callable计算血量,例如函数对象,甚至其他类的成员函数(通过std::bind
)。
两个继承体系
HealthCalcFunc
形成一个自己的继承体系,GameCharacter
包含一个指向基类HealthCalcFunc
的指针。这是实现Strategy设计模式的经典方式。
1 | class GameCharacter; |
Item 36: Never redefine an inherited non-virtual function
不要重新定义继承来的非虚函数,因为非虚函数不会触发动态绑定。
Item 37: Never redefine a function's inherited default parameter value
虚函数默认参数的值是静态绑定的,在编译时确定。
以下例子会打印出x = 10
。
1 | class Base { |
因此不要改变继承来的虚函数的默认参数的值。事实上,虚函数带有默认参数不是一个好的设计,最好通过Non-Virtual Interface的方式写成这样:
1 | class Base { |
Item 38: Model "has-a" or "is-implemented-in-terms-of" through composition
复合(composition)指一个类包含了另一个类,它可以表示一种"has-a"的关系,如Person
之于Address
和PhoneNumber
:
1 | class Address {}; |
复合还可以表示一种"is-implemented-in-terms-of"的关系,指A是利用B来实现的。这纯粹是一种实现手段,和类之间的逻辑关系无关。如下面的例子,我们基于std::list
实现了集合MySet
:
1 | template<class T> |
Item 39: Use private inheritance judiciously
我们知道,public
继承代表一种"is-a"的关系,而private
继承其实代表了一种"is-implemented-in-terms-of"的关系。如果Derived
私有继承了Base
,代表Derive
只要Base
的实现,不要Base
的接口。这纯粹是实现细节,和类之间的逻辑关系设计无关。一个例子:
1 | class Timer { |
已有的Timer
类每隔tick
就会触发一次onTick()
。我们的Widget
也希望每隔一定秒数触发一次动作,因此我们private
继承了Timer
,并重新定义了虚函数onTick()
。逻辑上,Widget
类和Timer
类没有任何关系,Widget
并不是一个Timer
。
事实上,私有继承并不是必须的,这个例子也可以通过上一节提到的复合改写成这样:
1 | class Widget { |
这样写甚至比私有继承更好,因为可以把WidgetTimer
的定义放在其他地方,这样消除了Widget
和Timer
类之间的编译依赖。
作者给出了一个私有继承比复合更好的场景:私有继承一个没有non-static成员变量的empty class没有空间开销,但是把这个empty class复合进来至少需要占1个字节:
1 | class Empty {}; |
Widget1
的大小应该等于int
的大小,Widget2
则会比int
更大。
Item 40: Use multiple inheritance judiciously
使用多重继承会带来一些问题,例如:
从多个父类继承到相同的名字会导致歧义,必须显式通过类名指定在使用哪个名字。
出现菱形继承时,共同父类的成员会被继承两遍。此时两个中层的派生类应该虚继承它们的共同基类。然而:
虚继承本身有性能开销。
虚继承出现时,虚基类的初始化由most-derived class负责,如果most-derived class派生了新的类,这个职责也需要转移,增加了认知成本和错误率。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35class Base {
public:
Base(int value) : value_(value) {
std::cout << "Base constructor called with value " << value_ << std::endl;
}
private:
int value_;
};
class Derived1 : virtual public Base {
public:
Derived1(int value) : Base(value) {
std::cout << "Derived1 constructor called" << std::endl;
}
};
class Derived2 : virtual public Base {
public:
Derived2(int value) : Base(value) {
std::cout << "Derived2 constructor called" << std::endl;
}
};
class MostDerived : public Derived1, public Derived2 {
public:
MostDerived(int value) : Base(value), Derived1(value), Derived2(value) {
std::cout << "MostDerived constructor called" << std::endl;
}
};
int main() {
MostDerived obj(10);
return 0;
}这段代码的输出是:
1
2
3
4Base constructor called with value 10
Derived1 constructor called
Derived2 constructor called
MostDerived constructor called即
Derived1
和Derived2
对Base
构造函数的调用都被忽略,构造函数的实际调用顺序由MostDerived
给出。
作者建议少使用虚继承,用的时候最好虚继承一个没有non-static成员的基类,避免初始化带来的心智负担。
作者指出,一种合理的使用多重继承的场景是:一个类C
需要public
继承一个接口类I
,以体现"has-a"的关系;同时,实现C
时又需要基于一个CInfo
类,因此C
需要(当然不是必须,见Item
39)private
继承CInfo
,体现"is-implemented-in-terms-of"的关系。
Templates and Generic Programming
Item 41: Understand implicit interfaces and compile-time polymorphism
类型和函数模板都是在定义程序的接口,也都提供了多态支持。
- 类型提供了显式接口,体现在成员函数的签名上;函数模板提供了隐式接口,体现在对泛型对象执行的操作中。例如对泛型对象
T& w
执行了操作w.size()
,就意味着模板实例化时的类型T
必须支持size()
这一接口。 - 类型提供了运行期多态,即对虚函数的调用是在运行时动态绑定的;函数模板提供了编译期多态,通过模板实例化确定调用的函数是哪一个版本。函数重载也属于编译期多态。
Item 42: Understand the two meanings of typename
声明模板时可以使用template <typename T>
,或者template <class T>
,这两种写法完全等价。
typename
还有另一个用途:当我们在模板使用一个nested
dependent
name时,编译器在parse阶段无法确定这个名称究竟是一个类型名还是变量名,它默认认为这是一个变量名。我们通过在名称前加上typename
提升编译器这其实是一个类型名。
dependent name: 模板中一个依赖模板参数的名称,如
B<T>
,T::A
。nested name:一个位于某作用域内部(即
::
后面)的名称,如std::cout
。
1 | int p = 1; |
这个例子里,编译器在模板实例化之前无法确定const_iterator
是一个类型名(声明一个指向迭代器的指针p
),还是一个变量名(将该变量与变量p
相乘),而默认的行为是后者。如果我们希望的行为是前者,就必须加上typename
。
一个反例是当基类的名称是dependent name,且出现在base-class
list或者构造函数成员初始化列的时候不能加typename
,此时名称默认被当作类型名。但该名称出现在其他地方时,仍然需要加typename
。
1 | template<typename T> |
除了typename
以外,template
也被用作一个去除歧义的disambiguator。这是cppreference中的例子:
1 | template<typename T> |
由于bar
是一个模板函数,bar
中定义的变量s
的类型S<T>
是一个dependent
name,因此编译器(parser)无法判断foo
这个名称的类型。它可能是一个模板,也可能是一个s
的成员,正在与T
进行小于比较(虽然在这个例子里不合法)。此时必须用template
修饰foo
消除歧义。
cppreference中介绍了dependent name和disambiguator的更多细节。
Item 43: Know how to access names in templatized base classes
在模板类的派生类中,编译器不会自动在(模板)基类的作用域中查找继承来的名称,如下例:
1 | template<typename T> |
13行对foo()
的调用会失败,编译器不会在Base<T>
中寻找foo
函数。这么做的逻辑是模板实例化之前,不能确保Base<T>
中一定还存在着foo()
这个名称,如果有这么一个全特化模板,它没有定义foo()
,那么Derived<int>
就继承不到foo()
这个函数:
1 | template<> |
因此,需要显式告知编译器我们需要在模板基类中寻找函数名称(这一告知也同时保证该名称存在,否则会出现编译错误)。一种做法是通过this->
调用foo()
:
1 | template<typename T> |
或者通过using
告知编译器foo()
确实存在于Base<T>
中:
1 | template<typename T> |
Item 44: Factor parameter-independent code out of templates
使用模板函数可能会导致代码膨胀。在下面的例子中,由于我们为SquareMatrix
提供了两种实例化,invert()
函数也会有两个实例,尽管它们唯一的区别只是维度n
的区别:
1 | template<typename T, std::size_t n> |
如果是非模板的场景,我们很可能只实现一次invert()
,并给它一个参数n
。对于模板场景,我们也希望这么做。我们可以令SquareMatrix
继承一个尺寸无关的基类:
1 | template<typename T> |
这里:
- 由于矩阵数据保存在
SquareMatrix
,SquareMatrixBase
需要维护一个指针指向矩阵数据,供矩阵操作函数(如invert()
,这里没有体现)使用。 SquareMatrix
私有继承了SquareMatrixBase
,因为这里体现的是"is-implemented-in-terms-of"的关系。SquareMatrix
需要显式using
基类中的invert()
函数(见Item 43)。
Item 45: Use member function templates to accept "all compatible types"
如果我们在实现一个智能指针类:
1 | template<typename T> |
我们希望像裸指针一样,将指向派生类Derived
的指针转换为指向基类Base
的指针。但是mySmartPtr<Base>
和mySmartPtr<Derived>
是完全无关的两个类,不存在类型转换,因此第20行的赋值无法编译。
我们不可能穷尽智能指针所可能指向的所有类型,来写出各种构造函数,用于类型转换。因此,我们必须定义一个成员函数模板:
1 | template<typename T> |
这里,我们通过ptr_(other.get())
将底层的裸指针互相转换,它能保证不合法的指针转换(如Base*
到Derived*
)无法编译,使mySmartPtr
也表现出相同的效果。
值得注意的是,尽管这里定义的拷贝构造函数模板看似包括了mySmartPtr
的默认拷贝构造函数(当T
和U
是同种类型时),但它并不是默认拷贝构造函数,编译器仍然会生成默认拷贝构造函数,因此:
1 | mySmartPtr<Base> spb1; |
这里的构造将会匹配到默认拷贝构造函数(因为这是一个更特化的匹配),而不是这里的拷贝构造函数模板,因此不会打印出输出。
Item 46: Define non-member functions inside templates when type conversions are desired
在Item
24中,我们通过定义一个non-member的operator *
,允许Rational
和int
变量相乘。现在,我们给出一个模板化的版本:
1 | template<typename T> |
然而,这段代码(19行)无法编译,因为编译器无法找到一个可供调用的operator *
。具体来说,编译器在推导模板实参时不会考虑通过构造函数发生的隐式类型转换,因此无法将int
转换为Rational<int>
,从而推导出T
为Rational
。
我们可以在Rational
内声明operator*
为一个友元函数。
1 | template<typename T> |
现在,代码可以通过编译了。编译器完成operator*
调用的逻辑是:
- 定义
r1
时,Rational<int>
这个类就已经被实例化了,其中T
为int
。 Rational<int>
中声明了一个含有两个Rational<int>
形参的函数operator*
,这个函数没有用template
修饰,不是模板函数。- 调用非模板函数时,编译器会考虑隐式类型转换,并且
int
确实可以隐式转换成Rational<int>
,调用成功。
然而,这段代码现在无法链接,因为operator*
只有声明没有定义。
Q:11-14行不是定义了
operator*
吗?A:那只是一个从来没有被实例化过的函数模板罢了,我们现在需要的
operator*
是一个非模板函数,它们根本就不是同一个函数。
我们可以通过以下方式修正这个程序:
定义一个
Rational<int>
版本的operator*
,这显然不是一个通用的方案,丧失了模板的优势。这里只是用来证明为operator*
提供一个(当前实例化下的)定义确实可以成功链接。1
2
3Rational<int> operator*(const Rational<int>& lhs, const Rational<int>& rhs) {
return Rational<int>(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
}直接在声明友元函数时给出定义。
1
2
3
4
5
6
7template<typename T>
class Rational {
...
friend Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs) {
return Rational<T>(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
}
};这显然是一个更好的方案。把友元函数在类的内部定义产生的副作用是它会被隐式
inline
,如果我们担心operator*
的函数体过长而不适合inline
,可以把实现部分定义在类的外面,友元函数只是调用实现函数:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20template<typename T>
class Rational {
public:
Rational(const T& n = 0, const T& d = 1) : n_(n), d_(d) {}
T numerator() const { return n_; }
T denominator() const { return d_; }
friend Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs) {
return do_multiply(lhs, rhs);
}
private:
T n_, d_;
};
template<typename T>
Rational<T> do_multiply(const Rational<T>& lhs, const Rational<T>& rhs) {
Rational<T> ret;
// balabala
return ret;
}
Item 47: Use traits classes for information about types
C++
中的trait类是一种用于提取类型信息或执行编译时判定的机制。一个例子是iterator_traits
类,它使用户可以在运行时确定迭代器的类型、value_type
等信息。
以迭代器类型(input
output
forward
bidirectional
random_access
)为例,iterator_traits
的用法如下:
1 | template<typename IterT, typename DistT> |
myAdvance
需要移动迭代器,但是不同类型迭代器支持的操作不同,myAdvance
的实现也应该不同。于是,myAdvance
通过iterator_traits
在编译期获得迭代器的类型,并根据该类型,通过函数重载(编译期确定)调用对应版本的doAdvance
函数。
iterator_traits
的实现类似于:
1 | template<typename IterT> |
迭代器的实现者需要在类型定义时typedef
出iterator_category
这个类型名,iterator_traits
会直接使用这个类型。由于指针也是一种迭代器,而基本类型里没有办法typedef
,iterator_traits
还有一个偏特化的版本用于处理指针,并将指针标为随机访问迭代器。
另一个通过偏特化实现的traits例子是is_same
:
1 | template <typename T, typename U> |
Item 48: Be aware of template metaprogramming
本节介绍了模板元编程的基本概念,给出了一个计算阶乘的Hello-world示例:
1 | template<unsigned n> |
Customizing new and delete
Item 49: Understand the behavior of the new-handler
operator new
有一个nothrow
版本,签名如下:
1 | void* operator new ( std::size_t count, const std::nothrow_t& tag ); |
用户通过以下方式调用nothrow
版本的new
,它不会抛出bad_alloc
异常,而是在分配失败时返回空指针:
1 | int *p = new (std::nothrow) int; |
但是,对于非基本类型,new
和构造函数往往是一起被调用的,就算分配失败时new
不抛异常,构造函数也很可能抛异常。所以不要因为用了nothrow
版本的new
就忽略异常处理。
当operator new
无法满足内存分配请求时,它会调用一个new_handler
函数,然后再次尝试分配,重复此过程直到分配成功或抛出异常为止。
<new>
头文件中定义了new_handler
相关的基础设施:
1 | /** If you write your own error handler to be called by @c new, it must |
set_new_handler
设置一个自定义的new_handler
并返回旧的那个,get_new_handler
返回当前的new_handler
。
如果我们希望在分配不同类型的对象时,使用不同的new_handler
处理失败,可以采取以下的方式:
1 | class NewHandlerHolder { |
这里NewHandlerHolder
是一个RAII类,它管理的资源是global版本的new_handler
。
用户通过Widget::set_handler
指定专供new Widget
使用的new_handler
,这个局部new_handler
会在Widget::new
每次被调用时被设置,并在调用结束后恢复成全局new_handler
:
1 | void outOfMem() {} |
我们注意到,这种为不同类型提供自定义new_handler
的方式是通用的,对Widget
是这样,对其他类也是这样。因此,我们可以通过模板复用这个方式:
1 | class NewHandlerHolder { |
在NewHandlerHolder
的基础上,我们定义了一个模板类NewHandlerSupport
,它定义了自己的set_new_handler
和new
,从而使我们不需要像之前那样侵入式地修改Widget
类。现在,Widget
只需要public
继承NewHandlerSupport<Widget>
获得这两个接口即可。
注意这里NewHandlerSupport
虽然是模板类,但内部实现完全与模板参数T
无关。这里T
的唯一作用就是让NewHandlerSupport
的不同派生类拥有各自的基类,即NewHandlerSupport<T>
。这也是为什么Widget
要继承NewHandlerSupport<Widget>
,把自己的类名当作模板参数T
。这种继承方式被称为CRTP(Curiously
Recurring Template Pattern)。
Item 50: Understand when it makes sense to replace new and delete
本节泛泛而谈了一些需要自定义new
和delete
的理由:
- 针对应用的内存分配pattern和使用场景,实现特化的分配器以提升分配性能,或者降低内存分配器的内存占用。
- 收集内存使用的统计信息。
- 检测内存分配错误,例如
new
的时候写入canary值,delete
时检查。 - 实现一些非传统行为,例如用
new
和delete
封装C语言共享内存API。 - 实现自定义的内存对齐需求。C++17已经提供了接受alignment版本的
new
和delete
。
Item 51: Adhere to convention when writing new and delete
实现自定义版本的new
和delete
时,应该让它们表现出与默认版本类似的行为,例如:
- 每当分配失败时,调用
new_handler
(如果存在)并重试,直到分配成功为止。 - 将申请分配0字节的请求视为分配1字节,因为C++标准通常规定分配器应该返回一个指向足够大的内存块的指针,使得在这个内存块上进行存储是安全的。
一个典型的operator new
的逻辑类似于:
1 | void *operator new(size_t size) { |
有时,我们提供给基类的new
就是为基类设计的,不想给派生类使用,此时可以:
1 | class Base { |
通过将size
和sizeof(Base)
比较,得知分配的是否为基类对象。
对于delete
,我们应该保证delete
一个空指针是安全的:
1 | void operator delete(void *p) noexcept { |
如果像上面的例子一样,我们为基类和派生类调用了不同版本的new
函数,delete
也要相应处理这种情况:
1 | class Base { |
Item 52: Write placement delete if you write placement new
如下所示,我们定义了一个Widget
类并提供了自定义的new()
,它除了size_t
外还接受一个string
作为参数。如果我们不想让局部定义的名称new
遮挡住标准库里的::new
的话,就应该同时定义new(size_t size)
这个重载,它调用:: operator new
。delete()
同理。
事实上,标准库里的
operator new
有多个重载版本,这里只考虑了new(size_t size)
这一个版本。我们可以考虑声明一个StandardNewDeleteForms
类,把标准库里所有的new
和delete
都包装一层,然后让Widget
public
继承StandardNewDeleteForms
并using
operator new
和operator delete
这两个名称,这样就不会有遮挡全局名称的问题了。
现在的问题是:Widget *pw1 = new Widget
意味着调用了两个函数:new()
和Widget
的构造函数。如果new()
成功了但是构造函数抛出异常,编译器生成的代码需要保证delete()
被调用。
1 | class Widget { |
但是,编译器怎么知道需要被“撤销”的那个new()
对应的delete()
函数是哪个呢?答案是,它会寻找额外参数个数和类型都与new()
完全相同的那个delete()
,并使用与调用new()
时相同的额外参数调用它。如果找不到,它就不会调用delete()
造成,内存泄漏。
额外参数指的是除了size_t
(对于new()
)或者void *
(对于delete()
)以外的其他参数,拥有额外参数的new()
被称为placement
new,拥有额外参数的delete()
被称为placement delete。
当额外参数是一个
void *
时,这个new()
是一个狭义上的placement new,用于在一个指定的内存位置上创建对象。
因此,当我们自定义了一个placement new时,不能忘记同时定义对应的placement delete,否则就有内存泄漏的风险。
需要注意的是,29行的delete pw1
如果被执行,调用的仍然是non-placement版本的delete
(因为我们没有提供string
参数)。上述的placement
new/delete对应规则仅在构造函数抛出异常时触发。