1.区别pointers和references
- 引用总是指向最初获得的对象(必须被初始化)
- 当需要指向某个东西,且绝不会改变其指向 或者 实现一个操作符而其语法需求无法由
pointers
完成时选择references
,其他时候,选择pointers
.- 如
vector
的operator[]
返回references
, 使用时可以v[0] = 1;
, 若返回指针,则需要比较奇怪的形式:*v[0] = 1
.
- 如
2.新式转型
static_cast
: 实现旧式转型并受到相同限制。
dynamic_cast
:执行继承体系中安全的向下转型或跨系转型。无法用于缺乏虚函数的类型。
const_cast
:去除常量性。
reinterpret_cast
: 不具有移植性, 最常用用途是 转换“函数指针”类型。
新式转型 严谨且易于辨识,更容易被解析,编译器也得以诊断转型错误。(新式转型失败会以一个null或exception表现出来)。
3.不要以多态方式处理数组
书中举例是将子类数组传入以父类数组为参数的函数中,且函数体内涉及了指针算术(array[i] -> *(array + i)
), 而参数是父类数组,每个元素的大小则会被认为是父类对象的大小,造成未定义结果。
总结就是,多态和指针算术不能混用, 尽量不要让一个具体类继承自另一个具体类。
4.非必要不提供默认构造函数
凡可以合理地从无到有生成对象的类,应该内含默认构造函数;
必须有某些外来信息才能生成对象的类,则不必拥有。
添加无意义的默认构造函数,也会影响class的效率(如验证该值是否有意义等)。
但若没有默认构造函数时,会造成诸多麻烦:
- 产生数组时,一般而言没有办法为数组中的对象指定构造函数自变量
- 可以使用对象数组的方法,即为数组中每个元素调用构造函数传入自变量
- 使用指针数组的方法,定义一个数组存放指向每个对象的指针
- 可能会造成资源泄露(必须记得将此数组指向的所有对象删除)和内存浪费(多了指针的空间,但可以通过分配
raw memory
,之后使用placement new
在这块内存构造对象解决)
- 可能会造成资源泄露(必须记得将此数组指向的所有对象删除)和内存浪费(多了指针的空间,但可以通过分配
- 将不适用于许多
template-based container classes
(对templates,被实例化的目标类型必须得有默认构造函数,因为这些模板中总会产生以template
类型参数作为类型的数组- 严谨的模板则不要求,如
vector template
就不要求其类型参数拥有默认构造函数
- 严谨的模板则不要求,如
5.最好不要提供任何类型转换函数
单自变量constructors
(只有一个参数 或 剩下参数都有默认值)和 隐式类型转换操作符
允许编译器进行隐式类型转换之用。(即可以成为类型转换函数)
不要提供类型转换函数的根本问题是:此类函数经常在无意愿调用的情况下被调用。
一般通过下列方法解决这两种转换函数:
- 隐式类型转换操作符
因此一般以另一个功能对等的函数代替类型转换操作符。class Rational { ... public: operator double() const; // 将Rational对象转为double` }; Rational r(1,2); cout << r ; // 未定义operator<< , 但r会被隐式类型转换操作符转换为double,输出double类型数据而非分数
class Rational { ... public: double asDouble() const; }; Rational r(1,2); cout << r; // error,没有定义operator<< , 也不会隐式转换 cout << r.asDouble() ; // 显式调用函数进行转换
- 单自变量
constructors
- 1.通过
explicit
关键字解决,拒绝编译器因隐式类型转换调用之,仍允许显式类型转换; - 2.若不支持上述关键字,可以通过
proxy classes
解决。方法就是 在类内额外加入一个新的class,这个类相当于单自变量构造函数参数的替身。- 利用任何一个转换程序都不能含有一个以上的“用户定制转换行为”。(即不能连续隐式转换1次以上)这个规则,达到拒绝隐式转换的目的。
- 1.通过
6.区别increment/decrement操作符的前置和后置形式
class A {
public:
A& operator++(); // prefix ++
const A operator++(int); // postfix ++
A& operator--(); // prefix --
const A operator--(int); // postfix --
A& operator+=(int);
...
}
前置、后置的区分是通过一个int
自变量实现的,编译器为其默默指定一个0
(i++; - > i.operator++(0);
前置返回reference
, 后置返回const
对象:
- 其原因是前置是先累加再取出,得到最新值;后置是先取出,在累加,得到旧值。因此后置只能返回一个对象而非引用。
- 后置不仅返回一个对象,而且是
const
对象,其原因是杜绝连续的两次累加:i++++; // error, 返回的是const对象
(由于返回的对象,第二次累加是在返回的临时对象身上累加,行为非所预期,因此使用返回const
对象的方法防止此类错误)
7.不要重载&&
,||
和 ,
操作符
- 重载
&& ||
操作符后,函数调用语义会替代短路式语义,所有参数都会被评估; - C++并未定义函数调用动作中参数评估顺序,而短路式的语义总是自左向右评估。
- 逗号操作符也类似,无法保证从左向右的函数自变量的评估顺序。
8.了解不同意义的new和delete
new operator
和 operator new
:
string *ps = new string("hello_world");
-
上述语句中的
new
是new operator
, 是语言内建的操作符。其操作每次都同,分为两步:- 1.分配足够的内存(通过
operator new
实现)- 唯一能够改变的就是这一步,通过重载函数
void * operator new(size_t size);
, 改变内存的分配行为。和malloc
一样,operator new
唯一任务就是分配内存。
- 唯一能够改变的就是这一步,通过重载函数
- 2.调用
constructor
,为分配的内存中的对象设定初值。
- 1.分配足够的内存(通过
-
编译器会将上述语句转换为类似下列代码的行为:
void *memory = operator new(sizeof(string)); call string::string("hello_world"); on *memory; string *ps = static_cast<string*>(memory);
- 由于我们无法直接调用构造函数,因此要构建一个
heap-based object
,一定要使用new operator
.
- 由于我们无法直接调用构造函数,因此要构建一个
-
总结就是:
使用new operator
使得对象产生于heap
, 其分配内存并为该对象调用constructor
使用operator new
只分配内存;
若打算自己决定内存分配方式,则重载operator new
若打算在已分配(并拥有指针)的内存中构造对象,使用placement new
.
new (loc) Widget(Widget_size);
// new operator的用法之一,loc在其隐式调用operator new时使用
// 所谓placement new类似于下面的operator new形式,size_t并未使用,因为placement new是一种特殊的operator new,而其主要任务就是找到一块内存,而内存已经有了,可以直接返回
void* operator new(size_t, void* loc) {
return loc;
}
同样,delete operator
完成在对应内存析构,并释放空间(operator delete
完成)。且operator delete
也可以被重载。
这两者相当于malloc
和free
。
void *buffer = operator new(50*sizeof(char)); // 50个char的空间,不调用任何ctors
...
operator delete(); // 释放内存,没有调用dtors
如果使用placement new
在某块内存产生对象,则应避免使用delete operator
,因为该内存并非由operator new
分配得来。可以直接调用该对象的dtors
.
另外,上述的都是针对单个对象,对于数组版的处理要用operator new[]
和operator delete[]
.(分配空间,同样可以被重载。
string *ps = new string[10];
// 调用operator[] 分配10个string对象的内存,对每个元素调用string default ctor
delete [] ps;
// 为数组中每个元素调用string dtor,调用operator delete[] 释放内存
new operator
delete operator
都是内建操作符,无法被控制。我们只能够更改他们所调用的内存分配/释放函数。
9. 利用destructor避免泄露资源
异常无法被忽略,若程序仅仅以返回错误码的方式发出异常信号,无法保证此函数的调用者会检查那个变量或检验那个错误码。
如果函数以抛出exception
的方式发出异常信号,而该exception
未被捕捉,程序执行便会立刻中止。
举了指针及其他资源使用过程发生异常,导致未能够释放资源,造成泄露资源的例子。
本讲就是鼓励将使用的资源放在对象内,使得即使异常发生,离开该函数时也能够释放资源,避免泄露资源。
10.在constructors中阻止资源泄露
考虑拥有两个其他成员对象A、B的类C。在constructor
中初始化完A后,初始化B时发生异常,则C对象未构造完成,C++不会析构未初始化完全的对象,因此资源A发生泄露。
- 解决方案是在
consructor
内捕捉全部exception
,执行某种清理工作(如删除对应指针资源),重新抛出exception
(throw),使其继续传播出去即可。
但对于常量指针成员,只能在成员初值列表内加以初始化。由于无法将try和catch放入成员初值列内,故可以将其放入工具函数内,虽然能够解决,但写起来繁琐。
因此,还是采用第九讲的方法,将对象视作资源,交给局部对象管理(如智能指针管理原始指针对象),如此便可免除构造函数内exception
出现时发生的上述资源泄露,且无需在destructors
内亲自手动释放资源。
11.禁止exception流出destructor之外
析构函数在两种情况下会被调用:
- 1.对象正常情况下被销毁;
- 2.
exception
传播过程中的栈展开机制——销毁。- 栈展开(stack unwinding):如果在一个函数内部抛出异常,而此异常并未在该函数内部被捕捉,就将导致该函数的运行在抛出异常处结束,所有已经分配在栈上的局部变量都要被释放。
如果因为栈展开机制调用destructor
,又因为exception
的因素离开destructor
。此时正有另外一个exception
处于作用状态,c++则会调用terminate
结束程序。
为了防止程序强制结束,可以在析构函数内部捕捉所有异常,并使用空的catch(...){}
防止异常流出。
这样也能够让析构函数剩余的部分得以执行。
12.※抛出一个exception 与 传递一个参数 和 调用一个虚函数 之间的差异
主要讨论了传递对象到函数 或以对象调用虚函数 和 将对象抛出成为一个exception
之间的3个主要差异:
exception
对象总会被复制,以by value
方式捕捉,甚至会被复制两次(临时对象、参数)。以指针方式抛出是唯一在搬移异常相关信息时不需要复制对象(复制指针)的做法,但要注意不要抛出局部对象的指针。- 被抛出成为
exception
的对象,被允许的类型转换动作少(仅允许继承架构中的类转换、有型指针转化为无型指针) catch
子句以其出现于源代码的顺序被编译器检验比对(最先吻合策略)。
13.以by reference方式捕捉exceptions
开头举例以对象指针的方式捕捉exception
,但很多coder往往忘记抛出全局或者静态对象的指针(或构建heap中的新对象),返回了局部对象的指针,造成错误。并且4个标准的exception
(bad_alloc
,bad_cast
,bad_typeid
,bad_exception
)都是对象,只能by value
或by reference
方式捕捉。
另外,也可以在heap
中构建一个新对象,抛出指向它的指针。这时捕捉到的人就必须删除之,否则会造成资源泄露。
但捕捉者又不清楚返回的是在heap
中构建的新对象 还是 全局或静态对象的指针,因此就不知道是否要删除。
为了解决该困境,并与标准exception
兼容,最好采用by value
和by reference
的方式捕捉exception
。
而by value
方式可能造成 对象切割(捕捉处是基类,抛出子类对象),并且复制2次;
故最好采用by reference
捕捉异常对象。既可以避免对象切割,也可以捕捉标准异常,亦约束了exception
对象复制的次数。
14、15
主要讨论的就是谨慎使用exception specifiction
, 因为与说明不一致会造成程序terminate
且要考虑exception
抛出造成的成本问题,必要时才使用try
.
16.80-20法则
17.缓式评估
- 本节首先举了字符串共享计数的方法,在需要赋值动作时,先不要真的拷贝赋值,而是记录这个字符串由哪些串共享,读操作不影响,写操作时才创建其副本进行更改。如果没有写操作,就可以避免非必要的对象复制。
- 对于共享计数的字符串,读操作的代价远远低于写(需要创建副本),通过以
proxy class
方法以及缓式评估的思想,在operator[]
内区别读写操作。 - 在取用大型对象时,可能只用其中某个成员,可以先构造空对象,内部置位null,用到时如果是null,再从数据库取出对应成员, 避免非必要的数据库读取动作
- 最后举了矩阵计算的例子,如矩阵相乘的结果,可以记录乘法动作及参与矩阵,若只是取用某一行,则用的时候只计算那一行,避免非必要的数值计算动作。
但是缓式评估在某些计算是必要的情况下,反而会因为额外的记录信息浪费更大内存,且无提升。使用要依照场景而定。
最后比较有用的做法是,可以用急式评估的方法实现,撰写报告时将缓式评估的方法作为优化点写入报告。
19 分期摊还预期的计算成本
本讲提出了over-eager evaluation
(如caching
, prefetching
等)分期摊还预期预算成本。这种做法与上讲的lazy evaluation
使用场景不同。在必须支持某些运算而其结果几乎总是被需要,或常常被多次需要时适用。
举了提前计算平均值、最大值、最小值等例子,增量计算;
或者利用相对廉价的内存数据结构查找动作(如map存储查找)代替相对昂贵的数据库查询动作;
根据局部性原理多取数据的思想;
以及类似于vector
源码中采用的动态数组分配2倍空间,减少系统调用分配空间,节省时间。
虽然本讲提倡的是 空间换时间。但事实并不总是这样,比如较大的对象不易塞入虚内存分页或缓存分页中。若换页活动增加,或缓存击中率降低,结果就适得其反。
19.了解临时对象的来源
C++所谓的临时对象是不可见的。通常发生于2中情况:
- 隐式类型转换时
- 如将一个
char*
类型的字符串传入string
类型的函数参数时,会产生一个string
临时对象(以char*
为自变量,调用string ctor
)。- 上述情况只有在
by value
或者reference-to-const
参数传递时才会产生临时对象。(因为若传递给非const的reference,产生了修改操作,修改的是临时对象,不符合修改意图)。
- 上述情况只有在
- 如将一个
- 函数返回对象时
- 可以进行RVO优化优化临时对象的产生
20. Return Value Optimization
主要是讲RVO
优化掉临时对象的产生。
考虑一个分数类:
class Rational {
public:
Rational(int numerator = 0, int denominator = 1);
...
int numerator() const;
int denominator() const;
};
const Rational operator*(const Rational& lhs, const Rational& rhs);
其operator*
必须返回一个对象,若返回指针可能会造成资源泄露问题,返回引用又不正确(局部对象引用问题)。
必须以返回临时对象的方法才有效,这样却会造成多次构造析构(临时对象,函数返回对象)。
可以采用constructor arguments
取代局部对象,当做返回值:
inline const Rational operator*(const Rational& lhs, const Rational& rhs) {
return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
}
Rational a = 10;
Rational b(1, 2);
Rational c = a * b;
这样C++允许编译器将临时对象优化,使其不复存在(得以消除operator*内的临时对象+返回的临时对象,即将return表达式定义的对象构造于c的内存内)
另外:像下面这样先构造命名对象res,再返回该对象,编译器也会进行优化,叫做NRVO
( named return value optimization
- 具名返回值优化)。
inline const Rational operator*(const Rational& lhs, const Rational& rhs) {
Rational res(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
return res;
}
Rational a = 10;
Rational b(1, 2);
Rational c = a * b;
RVO大部分编译器都支持。
NRVO要VC++8.0或以上才支持。
21.利用函数重载避免隐式转换
举的 自定义数据类型 和 int类型(内置类型) 重载operator+
的例子中。int会被隐式转换为自定义数据类型,之后进行加法运算。
为了避免该隐式转换带来的开销,同时完成不同类型数据的加法任务。可以多次重载operator+
运算符,支持不同了类型数值的运算。
- 但要注意C++规定,每个重载操作符必须获得至少一个用户定制类型的自变量。(本例中不能重载出一个两个参数都是int的
operator+
,因为int是内置类型)。
22.考虑以操作符复合形式(op=)取代其独身形式(op)
在自定义类型时,往往以operator+=
实现operator+
template<class T>
const T operator+(const T& lhs, const T& rhs) {
return T(lhs) += rhs;
// T()以lhs构造一个临时对象,该临时对象用来调用operator+=,并以rhs为自变量。
}
operator+=
(复合形式,返回引用)直接将结果写入左端自变量,不需要产生临时对象放置返回值。
23.考虑使用其他程序库
operator<<
类型安全且可扩充,printf
两者皆否。但iostream
的速度慢,可执行文件会变大。
因此当发现程序中I/O是瓶颈,则可以考虑将以stdio
取代iostream
,当发现程序在动态分配内存方面花销很大,则可以考虑是否有其他提供了operator new
operator delete
的程序库。
24.了解虚函数、多重继承、虚基类、runtime type identification(RTTI)的成本
- 凡是声明或继承虚函数的每个类,都拥有一个
vtbl
(virtual table),其中的条目是该类各个虚函数实现体的指针。(包括基类的虚函数)。- 1.拥有虚函数的类付出了额外
vtbl
空间的代价
- 1.拥有虚函数的类付出了额外
virtual table pointer (vptr)
则存在于每个声明或继承了虚函数的class对象内,负责指示出每个对象对应于哪一个vtbl
.- 2.拥有虚函数的对象付出了额外指针的代价
如pC1
调用虚函数f1()
,且该函数位于vtbl的i
索引:(*pC1->vptr[i])(pC1); // pC1被传给函数作为this指针
- 3.另外,由于虚函数不能被声明为
inline
(inlining是编译期行为,虚函数是运行期),也相应的少了减少成本的机会。
- 2.拥有虚函数的对象付出了额外指针的代价
多重继承中,每个子类对象中会包含多个vptr
(每个基类对应一个)。
并且为了消除多重继承中的“钻石继承”问题,会采用虚继承的方式。此时,子类对象又会多出一个(或多个)指针用来指向虚基类成分,以此消除基类的复制行为。
RTTI
让我们在运行期获得对象和类的相关信息,这些额外的信息被存放在类型为type_info
的对象内。可以使用typeid
操作符取得某个类对应的type_info
对象。
一个类只需要拥有一份RTTI信息,其type_info
对象经常在vtbl
的索引0
的条目内被指针指向。
- 因此,其成本就是vtbl内的一个指针,和每个class所需的一份
type_info
对象空间。
25.将constructor
和non-member function
虚化
所谓虚构造函数,是某种函数,视其获得的输入,可产生不同类型的对象。
作者举了如下例子:
一个类D拥有一个list成员,list中元素是基类A的指针。
现在需要从磁盘中读取对象并将其存入list。将读取对象的功能集中于一个函数内,由于其产生一个个崭新对象,行为类似构造函数,并且根据读入数据产生不同类型的对象(可能是A,也可能是子类B、C等),称为虚构造函数。
也介绍了更为特殊的virtual copy constructor
, 其返回一个指针,指向其调用者(某对象)的一个新副本。
class A {
public:
virtual A * clone() const = 0;
...
};
class B : public A {
public:
// virtual copy constructor
virtual B * clone() const {
return new B(*this);
}
...
};
class D {
...
private:
list<A*> components;
}
类D中list含有基类指针,指向不同类的对象。通过调用每个指针的clone()
虚函数,达到拷贝构造不同对象的目的。
最后作者介绍了“虚化” 非成员函数 的做法:写一个虚函数做实际工作,再写一个非虚函数调用虚函数。如operator<<
(可以inline降低成本)作为非成员函数,拥有参数ostream &s
, 在其内调用虚函数print(s)
, 达到输出list内的不同对象,并且符合传统语法的目的。
26.限制某个class所能产生的对象数量
阻止某个类产出对象的最简单方法是将其constructor
声明为private
若希望只能存在一个对象,可以将该对象设为static
并放置于函数内,而非类内:
class Printer {
public:
...
friend Printer& thePrinter();
private:
Printer();
Printer(const Printer& rhs);
...
};
Printer& thePrinter() {
static Printer p; // 唯一的打印机对象
return p;
}
- 若放置于类内,成为
static member function
,调用需加类名,使用不方便; - 并且函数中的
staitc
对象在第一次调用时产生(代价是每次要检查是否需要产生),而class内static对象不论是否用到,都会被构造和析构。 - 另外,函数内的
static
对象初始化时机是确定的(第一次调用函数且到达其定义处),class中的不一定什么时候初始化。
此外,作者举了另一个限制只能存在一个对象的错误例子,但很有代表性:
不将构造函数设为private
,使用一个static
对象计数当前产生了几个对象,若大于规定的对象数(设为1)就抛出异常,每次对象析构时计数减一。
- 表面上看设计简洁,且可扩充。但是这种设计只是限制了对象产生的个数,而忽视了该对象的存在形态可以有多种(对象本身、存在于子类对象中、内嵌于其他对象)。若有子类继承于该类,产生多个子类对象时也会被抛出异常。(而将构造函数设为
private
的class是不能被继承的,也不能被嵌入其他对象内,避免了此问题)。
上述的函数内static
对象的例子虽然保证了唯一的Printer对象,但不能够使用不同的对象(如程序不同位置先后使用不同的Printer对象,这样也是不允许的)
解决方案是采用伪构造函数 + 对象计数
将构造函数设为private
禁用,而采用显示调用伪构造函数的方法调用构造函数,当构造对象大于限定数值则抛出异常或者返回null。
这样不但实现了限定个数的对象,更加灵活,也可以允许对象构造和析构。
最后,作者又实现了一个基类计数器,所有需要的class都可以继承它做计数以减少代码重复。
27.要求(或禁止)对象产生于heap之中
可以通过限制ctor
和dtor
的使用(private
),阻止非堆对象产生。但会影响继承和内含。
- 可以将其设为
protected
解决继承问题,将内含该对象改为内含指向该对象指针解决内含问题。
另外,目前没有一个具有移植性的方法判断对象是否位于heap内。
- 那种以程序的地址空间以线性序列组织而成,
stack
从高地址向低地址成长,heap
从低地址向高地址成长为依据,判断是否位于堆内的方法是不完善的。因为除此之外还可能有static
对象(声明为static的对象、全局范围、命名空间范围的对象),其位置是根据系统不同而不同的。
禁止对象产生于堆中的方法可以是将operator new(和delete)
重载并置于private
,但同样也会受到继承和内含关系不是意向行为的困扰。
28.智能指针
其主要内容类似:
简单实现智能指针
主要讨论了智能指针设计中所要经受的 nullness
的测试、原生指针转换、以继承为本的转换、对pointers-to-consts
的支持等挑战。
在继承相关的类型转换问题中提出:
智能指针不能够在继承相关的类型转换上做到与原生指针一样好,所能做的最好情况是使用
member templates
产生转换函数,然后在出现模棱两可时使用转型动作(如C++的理念是,对任何转换函数的调用动作,都一样好。传递给基类 或者 基类之基类的智能指针对象会被视为模棱两可)
另外,在支持pointers-to-consts
时说道:
若编译器支持member template
,可以以此解决;
若不支持,可以类比子类public继承基类(对子类可以做更多事情,对non-const对象同样),设计2个智能指针类,用于const
对象,另一个用于non-const
对象。
并在基类中使用union
减少子类smartPtr对象内含的原生指针数目。(但要自行约束各类成员函数使用恰当的指针)
template<class T>
class SmartPtrToConst {
...
functions
protected:
union {
const T* constPointee;
T* pointee;
};
};
template<class T>
class SmartPtr : public SmartPtrToConst<T> { // 没有数据成员,与基类共享union内的指针
...
};
29. 引用计数
作者逐层递进讲解了如何实现一个简单的string类,并使其具备引用计数功能。
其整体结构如下:
string
类内包含RCPtr
对象,相当于自动管理引用计数的智能指针,处理引用计数的细节;
该智能指针管理stringValue
对象(含有实值,继承自引用计数对象RCObject
(含有可共享标志、共享计数)).并且限制对象只在堆中产生(要用delete删除计数为0的对象)。
另外,作者还给出了一般类对象想要使用共享计数,但又不能继承RCObject classs的类的解决方案(如库内的无法更改),可以加一层间接性实现:
最后,讨论了共享计数的使用场景,毕竟实现较为复杂,并且有额外空间的代价。因此,对象数量/实值数量 比值越高,共享计数的收益越大。
注:上述讨论未解决共享计数在应用于某些数据结构时引发的 自我参考(或循环相依)结构。
-
循环引用
class TestA{ public TestB b; } class TestB{ public TestA a; } public class Main{ public static void main(String[] args) { A a = new A();//对象A的引用计数为1 B b = new B(); //对象B的引用计数为1 a.b=b; //对象B的引用计数为2 b.a=a; //对象A的引用计数为2 a = null; //对象A的引用计数为1 b = null; //对象B的引用计数为1 } }
上述是循环引用的一个例子:将a=null后,对象A的引用计数为1,b=null后B的引用次数是1,不会释放对象B,A的引用计数就不会减为0,B也同样。这样就造成了误用共享计数,造成资源泄露无法释放的问题。
30 proxy classes
讲了使用proxy class
解决 区分读写(左右值区分)operator[] 、多维数组的做法:
- 多维数组中通过内嵌返回一维数组的对象的
proxy class
实现;(为了使用a[dim1][dim2]
的形式) - 区分左右值时,调用
operator[]
后返回的是proxy object
,后续根据读写的不同场景,调用不同的隐式类型转换函数完成区分读写场景。(如作者举例的cout << s[1]
; 此处由于proxy object
未重载operator<<
, 故调用proxy class
内的隐式转换char()
, 而s[1] = 'a'
; 则调用proxy class
内的operator=
)
但使用proxy class
扮演函数返回值的角色,这种proxy object
是一种临时对象,也会有构造析构代价,且编码复杂度增高。
另外,proxy object
展现的行为也会和真正对象有些许差异:
- 如需要重载适用于真实对象的每一个函数(否则不能使用对应成员函数;
- 传递给
reference to non-const object
时,无法将该代理对象转换为T&
(即使能转,该proxy object
为临时对象,也不允许绑定到non-const reference
) - 还有就是不允许用户定制的一次以上的隐式转换,故使用代理类可能隐式转换也会有区别。
31.让函数根据 一个以上的对象类型 决定如何虚化
虚函数可以根据调用对象的动态类型决定调用哪个函数。当需要确定两个(或多个)对象的动态类型才能得到最终调用的函数时,单纯的虚函数就满足不了需求。
本讲讨论的就是让函数根据 一个以上的对象类型 决定如何虚化。(以两个为例)
首先给出了虚函数 + RTTI的方法。
- 先根据虚函数机制确定第一个对象的真实类型,然后利用
typeid()
在运行期确定另一个对象的真实类型。但这种方法毫无封装性,虚函数要知道每个兄弟类。若添加一个新对象,就必须修改每个可能遇到新对象的类。
接着介绍了只用虚函数的方法:
- 重载每个类型所需的虚函数,利用虚函数机确定一个对象的真实类型,然后在函数处理体内再次调用,利用虚函数确定第二个对象真实类型(将第一个对象真实类型作为参数传入,参数根据静态类型确定重载函数)。
- 这种方法需要对头文件有修改权限,因为每增加一个新类型,就要对每个类增加一个新的重载函数(参数为新类型)。
通过对上述2种方法的讨论,我们应该尽可能避免这种double-dispatching
的状况,如若不能,虚函数法则必RTTI更安全一些,但需要更大修改权限。RTTI不需要重新编译,但维护困难。
最后,介绍了自行仿真虚函数表格的方法。
这里要注意的是不能重载,需要定义多个不同的函数。因为要求每个函数的参数都要相同,才能够利用智能指针管理返回的指针(避免返回对象的成本)。
利用map映射函数名到函数指针(模仿虚表),并且最后还添加了自行往map添加和删除映射关系的函数,灵活性更大。
32. 在未来时态下编写程序
本讲列出了一些常见错误,如用户会抛出exception
,会对对象自我赋值、会未获得初值就使用对象、会给初值而从来不使用、会给过大值、过小值、null值。这些不能仅仅依靠文档说明规范,而应该利用C++本身来表现各自规范。
另外,尽量设计代码保证局部化:如尽量使用匿名namespaces、文件内的static对象和static函数;
尽量避免设计出虚基类(因为这种类必须被每一个子类初始化,即使是间接的);
避免RTTI作为设计基础导致一层层的if-then-else,因为一旦继承体系有变动,每一组这样的语句都要更新。
总结:本讲核心思想就是设计class时,要以未来时态完成,对class定位是如何的就如何设计,而不是根据现在情况(如定位为基类,即便现在没有子类,也设计出虚函数)。
- 提供完整的类,即使有的部分暂时用不到;
- 设计接口,让class轻易地被正确运用,难以被错误运用(如禁止拷贝和赋值操作);
- 尽量泛化代码,除非有巨大的不良后果。