More Effective Cpp
一个有思想的人不会表达就相当于没有思想~
Basics 基础议题
条款 1:仔细区别pointers和references
-
指针和引用都是间接使用其他对象。
-
没有null reference,引用必须有初值,而指针没有此限制。因此引用使用时不用判断其有效性,而指针通常需要。
ex1:任何情况下都不能使用指向空值的引用。
1
2char* pc = 0;// 设置指针为空值
char& rc = *pc;// 让引用指向空值好处:不存在指向空值的引用这个事实意味着使用引用的代码效率比使用指针要高,因为在使用引用之前不需要检查它的合法性。
ex2:引用必须初始化
1
2
3
4string& rs; // 错误,引用必须被初始化。
string s("XYZ");
string& rs = s; // rs 指向 s。
string *ps; // 未初始化的指针,有效,但风险高。 -
reference总是指向(代表)初始化的对象,而指针可以被重新赋值。
1
2
3
4
5
6
7string s1("Nancey");
string s2("Clancy");
string& rs = s1; // rs 指向 s1。
string *ps = &s2; // ps 指向 s1。
rs = s2; // rs 仍然代表 s1, 但是现在其值被赋为 s2 的值。
ps = &s2; // ps 现在指向 s2,s1 没有变化。 -
结论:当你知道你需要指向某个东西,而且绝不会改变指向其他东西,或是当你实现一个操作符而其语法需求无法由pointers 达成,你就应该选择 references。任何其他时候,请采用pointers。
条款 2:最好使用C++转型操作符
-
C++中的4个转型操作符:
static_cast
,const_cast
,dynamic_cast
和reinterpret_cast
。1
2
3
4
5
6(type) expression; // 旧的转型
int firstNumber, secondNumber;
...
double result = ((double)firstNumber) / secondNumber;
static_cast<type>(expression); //新的转型
double result = static_cast<double>(firstNumber) / secondNumber; -
const_cast
用来改变表达式中的常量性(constness)或变易性(volatileness)。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class Widget { ... };
class SpecialWidget: public Widget { ... };
void update( SpecialWidget *psw );
SpecialWidget sw; // sw 是一个 non-const 对象
const SpecialWidget& csw = sw; // csw 是一个代表 sw 的 const 引用
update( &csw ); //错误!不能将 const SpecialWidget*传递给一个需要 SpecialWidget* 的函数。
update( const_cast<SpecialWidget*>(&csw) ); // 可行,&csw 的常量下性被去除了, 因此csw(亦即sw)在此函数中可被更改。
update( ( SpecialWidget* )&csw ); // 和第9行情况相同,但使用的是较难识别的 C 旧式转型语法。
Widget *pw = new SpecialWidget;
update(pw); // 错误!pw类型是Widget*,但update()需要的是SpecialWidget*。
update( const_cast<SpecialWidget*>(pw) ); // 错误!const 只能影响常量性或易变性。 -
dynamic_cast
,用来执行继承体系中“安全的向下转型或跨系转型动作”。可以利用dynamic_cast
将指向基类对象的指针或引用转型为指向其派生类或兄弟类的指针或引用。成功返回1,失败会议一个null
指针或者异常(当转型对象是引用)表现出来。1
2
3
4
5Widget *pw;
...
update( dynamic_cast<SpecialWidget*>(pw) ); // 如果转型成功,将传给 updata()一个指针,指向 pw 所指的 SpecialWidget, 否则传给updata()一个 null 指针。
void updateViaRef( SpecialWidget& rsw);
updateViaRef( dynamic_cast<SpecialWidget&>(*pw) ); // 如果转型成功,将传给 updateViaRef() pw 所指的 SpecialWidget, 否则抛出一个异常。
总结:
- static_cast:在功能上和C风格类型转换一样强大,含义也一样,可以被用于强制隐形转换(例如,non-const对象转换为const对象,int转型为double,等等)。
- const_cast: 去掉const或volatileness属性的操作。
- dynamic_cast:用于安全的沿着类的继承关系向下进行类型转换,失败的转换将返回空指针(针对指针进行类型转换)或者抛出异常(针对引用进行类型转换)。
- reinterpret_cast:主要是将数据从一种类型的转换为另一种类型,是特意用于底层的强制转型,导致实现依赖(就是说,不可移植)的结果,例如,将一个指针转型为一个整数。
条款 3:绝对不要以多态方式处理数组
-
继承的重要性质之一就是,可以通过指向基类对象的指针或引用,来操作派生类。
-
当将派生类类型的数组传递给需要基类类型的数组时,其会安基类类型的大小来操作数组指针。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18class BST { ... };
class BalancedBST: public BST { ... };
void printBSTArray(ostream& s, const BST array[], int numElements)
{
for ( int i = 0; i < numElements; ++i )
{
s << array[i];
}
}
BST BSTArray[10];
...
printBSTArray(cout, BSTArray, 10); // 没问题
BalancedBST bBSTArray[10];
...
printBSTArray( cout, bBSTArray, 10); // 将以 BST 类型处理 bBSTAraay 函数指针,即指针操作的步长为 sizeof(BST)
条款 4:非必要不提供default constructor
**(1)**default constructor:在没有任何外来信息的情况下将对象初始化.
**(2)**但是有些对象如果没有外来信息,就没有办法完成初始化动作,那么这些对象,就没有必要提供default constructor.
**(3)**如果一个类缺乏default constructor,那么使用这个类的时候会存在一定的限制.
限制1
限制1:产生数组的时候,没有任何一个方法可以为数组中的对象指定constructor自变量。
1 | EquipmentPiece bestPieces[10];//错误! |
限制2
限制2:不适用于某些模板类
对那些模板而言,被实例化的目标类型必须得有一个default constructor,这是一个普遍的共同需求,因为那些模板内几乎总是会产生一个以template作为类型而构架起来的数组,当然,如果谨慎设计模板,可以避免这个问题,但是很多模板的设计者独缺谨慎,这样会导致缺乏default constructor的类不兼容于许多模板。
例子:
1 |
|
解释:在这个示例中,NoDefaultConstructor
是一个没有默认构造函数的类。然后,我们尝试将它作为模板参数传递给模板类 Container
。由于 Container
在默认构造函数中尝试使用默认构造函数来初始化成员变量 obj
,但 NoDefaultConstructor
没有默认构造函数,因此编译器会报错。
**(4)**避免限制1的三种方法
方法1:在堆栈中创建数组,并且使用显示初始化列表
1 | A a[5]={A(1),A(2),A(3),A(4),A(5)}; |
缺点:比较麻烦,如果数组元素有1W个,那么要写1w个构造函数,显然不可能
并且只适用于堆栈数组,堆中没有此语法(C++中堆栈指的是栈)
方法2:使用指针数组,而非对象数组
使用指针数组(指针不受构造函数的束缚)。如 A* ptr_A[10];
1 | typedef EquipmentPiece* PEP; |
理由:在大多数平台上,指针的大小通常是相同的。无论是指向对象的指针、指向函数的指针还是指向任何其他类型的指针,它们的大小通常是相同的。这是因为指针在内存中存储的是地址,而地址的大小在大多数现代计算机体系结构中都是相同的。
例子:
现在我们想要创建一个指针数组,其中存储的是指向 Shape
类对象的指针,但我们不想受到构造函数的束缚,也就是说,我们希望能够动态地创建 Circle
和 Square
对象,并将它们存储到指针数组中。
1 |
|
数组中的各个指针可以用来指向一个个不同的对象
缺点:
1.不再需要这些对象时必须将此数组所指向的对象删除,避免内存泄漏
2.存放指针数组,需要额外的空间
方法3:使用内存池,使用operator new[]函数预先申请一块内存,要使用的时候再使用placement new(定位new)依次构造
1 |
|
优点:可以在堆中创建数组,并且不需要占用额外空间
缺点:
1)在数组内对象的生命周期结束时需要手动调用其析构函数,然后调用operator delete[]释放这块内存
2)如果采用一般的数组删除语法,程序行为将不可预期,因为删除一个不可以new operator获得的指针,其结果没有定义
**(5)**总结
添加没有意义的default constructor,也会影响classes的效率,如果member function 必须测试字段是否真被初始化了,其调用者必须为测试行为付出时间代价,并且为测试代码付出空间代价,因为可执行文件和程序库都变大了,万一测试结果为否定,对应的测试程序又要付出一些空间代价,如果class constructors可以确保对象的所有字段都会被正确初始化,上述所有成本都可以免除,如果default constructor无法提供这种保证,那么最好避免让default constructor出现,虽然这可能会对classses的使用方式带来某种限制,但同时也带来一种保证,当你真的使用了这样的classes,你可以预期他们所产生的对象会被完全的初始化,实现上也富有效率
Operators 操作符
条款 5:对定制的“类型转换函数”保持警觉
总结
- 单变量(或其他变量有默认参数)构造函数ctor:可能触发不必要的隐式转换,所以尽量用explict修饰
- 隐式类型转换符最好只有bool类型,其他类型的用别名函数代替(asDouble)
1 | struct Rational{ |
-
编译器允许使用单自变量构造函数(单一自变量成功调用的)和隐式类型转换操作符(关键词operator 之后加上一个类型名称)进行类型转换。
-
类型转换很可能在你未预期的情况下进行。
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
26class Name
{
public:
Name(const string& s); // 将 string 转换为 Name
...
};
class Rational
{
public:
Rational(int numerator = 0, int denominator = 1); // 将 int 转换为 Rational
...
};
class Rational
{
public:
...
operator double() const; // 将 Rational 转换为 double
};
// 以下情况自动调用隐式类型转换操作符函数
Rational r(1, 2);
double d = 0.5 * r; // 将 r 转换为 double, 然后执行乘法运算
cout << r; // 若你并未为 Rational 定义 << 操作符其也能通过编译并运行,因为 r 会隐式的转换为能和 << 匹配的类型( 此处为double )类型输出 -
解决第二点的方法是以功能对等的另一个函数取代类型转换操作符。
1
2
3
4
5
6
7
8
9
10class Rational
{
public:
...
double asDouble() const;
};
Rational r(1, 2);
cout << r; // 错误! Rationals 没有 operator<<
cout << r.asDouble(); // 以 double 的形式输出 r -
将构造函数声明为
explicit
,编译器便不能因隐式类型转换的需要而调用它们。 -
允许编译器执行隐式类型转换,害处将多过好处。所以不要提供转换函数,除非你确定你需要它们。
条款 6:区别 increment / decrement 操作符的前置和后置形式
-
重载函数是以其参数类型来区分彼此的,然而不论 increment 或 decrement 操作符的前置式或后置式,都没有参数。为了填平这个语言学上的漏洞,只好让后置式有一个 int 自变量,并且在它被调用时,编译器默默地为该 int 指定一个 0值
1
2
3
4
5
6
7
8
9
10
11
12class UPInt
{
public:
UPInt& operator++(); // prefix ++
const UPInt operator++(int); // postfix ++
UPInt& operator--(); // prefix --
const UPInt operator--(int); // postfix --
UPInt& operator+=(int);
...
}; -
increment 操作符的前置式意义“increment and fetch”(累加然后取出),后置式意义“fetch and increment”(取出然后累加)。
1
2
3
4
5
6
7
8
9
10
11
12UPInt& UPInt::operator++()
{
*this += 1; // increment
return *this; // fetch
}
const UPInt UPInt::operator++(int)
{
UPInt oldValue = *this; // fetch
++(*this); // increment
return oldValue;
} -
后置式 increment 和 decrement 操作符的实现应以其前置式兄弟为基础。如此一来你就只需维护前置式版本,因为后置式版本会自动调整为一致的行为。
-
后置累加或减操作返回
const
对象是防止i++++
行为。
条款 7:千万不要重载 &&
,||
和 ,
操作符
-
和 C 一样,C++对于“真假值表达式”采用所谓的“骤死式”评估方式。意思是一旦该表达式的真假值确定,即使表达式中还有部分尚未检验,整个评估工作仍告结束。
1
2
3
4
5
6char *p;
...
if ((p != 0) && (strlen(p) > 10)) // 若 p 为 NULL 表达式直接为假, 不会调用后续 strlen()
{
...
} -
但重载的
&&
和||
做不到“骤死式”。在下列示例中问题在于:1,函数调用被执行时所有的参数都已评估完成,所以&&
两边的表达式都将运行;2,C++中没有规范函数调用中的参数的评估顺序,也就是我们不知道&&
两边的表达式那个先运行。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15if (expression1 && expression2)
{
...
}
// 等价于
if (expression1.operator&&(expression2)) // 假设 operator&& 是个成员函数
{
...
}
if (operator&&(expression1, expression2)) // 假设 operator&& 是个全局函数
{
...
} -
操作符重载的目的是要让程序更容易被阅读、被撰写、被理解,不是为了向别人夸耀你知道“逗号其实是个操作符”。如果你没有什么好理由将某个操作符重载,就不要去做。
条款 8:了解各种不同意义的 new
和 delete
-
new operator,第一,它分配足够的内存,用来放置某类型的对象。以上例而言,它分配足够放置一个string 对象的内存。第二,它调用一个 constructor,为刚才分配的内存中的那个对象设定初值。new operator 总是做这两件事,无论如何你不能够改变其行为。
1
string *ps = new string("Memory Management");
-
new operator调用某个函数,执行必要的内存分配动作,你可以重写或重载那个函数,改变其行为。这个函数的名称叫做 operator new。
1
2
3void *operator new(size_t size);
void *rawMemory = operator new(sizeof(string)); // 调用 operator new -
使用
new []
时要匹配使用delete []
。【这个在effectivate C++中有详细说明】
Exceptions 异常处理
条款 9:利用析构函数避免资源泄漏
- 以一个对象存放“必须自动释放的资源”,并依赖该对象的 destructor 释放——亦可对“以指针为本”以外的资源施行。
- 把资源封装在对象内,通常便可以在 exceptions出现时避免泄漏资源。
例子:
1 | void processAdoption(istream& dataSource) |
上述代码如果pa->processAdoption()跑出异常,那么delete pa不会执行,导致内存泄露。
解决方法之一,利用try-catch捕捉,不好之处就是程序流程被打乱:
1 | void processAdoption(istream& dataSource){ |
解决方法之二,就是智能指针(auto_ptr是C++98的,现在提倡使用unique_ptr):
1 | void processAdoption(istream& dataSource) |
解决方法之三,封装资源,令其constructor和destructor分别获取资源和释放资源(模仿智能指针)。
1 | //此函数可能抛出异常之后发生资源泄露问题。 |
上述看起来就像auto_ptr一样。
有了这个封装类,我们重写displayInfo函数:
1 | void displayInfo(const Information& info) |
现在即使displayInfo跑出异常,createWindow产生的窗口还是会被销毁。
条款 10:在构造函数内阻止资源泄漏
用函数try语句块来确保构造函数不抛出异常、用智能指针来管理类中的指针资源避免手动释放。
TITLE:在constructors内阻止资源泄露(resource leak)
-
注意没有构造完成的对象,是无法被析构的(就是不会调用析构函数)
1
2
3
4
5template<typename T>
Blob<T>::Blob(initializer_list<T> il) try: data(make_shared<vector<T>>(il) {
// 函数体
} catch(const std::bad_alloc &e) { handle_out_of_memory(e); }
总结:如果你用对用的auto_ptr对象【智能指针】代替指针成员变量,就可以防止构造函数存在异常时候导致资源泄露,你也不用手动析构函数中释放对象,并且你还能像以前使用非const指针一样使用const指针给其赋值。
条款 11:禁止异常流出析构函数之外
- 两种情况下 destructor 会被调用。
- 第一种情况是当对象在正常状态下被销毁,也就是当它离开了它的生存空间(scope)或是被明确地删除。
- 第二种情况是当对象被 exception 处理机制——也就是exception 传播过程中的 stack-unwinding(栈展开)机制—销毁。
- 有两个好理由支持我们“全力阻止 exceptions传出 destructors 之外。
- 第一,它可以避免 terminate函数【https://blog.csdn.net/wangyin159/article/details/46584257?ref=myread】在 exception传播过程的栈展开(stack-unwinding)机制中被调用。
- 第二,它可以协助确保 destructors 完成其应该完成的所有事情。
条款 12:了解“抛出一个 exception”与“传递一个参数”或“调用一个虚函数”之间的差异
- 原因是当你调用一个函数,控制权最终会回到调用端(除非函数失败以至于无法返回),但是当你抛出一个 exception,控制权不会再回到抛出端。
- 一个对象被抛出作为 exception时,总是会发生复制(copy)。
- 异常只关心静态类型。
- exceptions 与
catch
子句相匹配”的过程中,仅有两种转换可以发生。第一种是“继承架构中的类转换(inheritance-based conversions)”。第二个允许发生的转换是从一个“有型指针”转为“无型指针”,所以一个针对const void*
指针而设计的 catch子句,可捕捉任何指针类型的 exception。 catch
子句总是依出现顺序做匹配尝试。- 虚函数遵循最佳吻合策略(best fit),异常处理机制遵循最先吻合策略(first fit)。 因此绝不要将“针对base class 而设计的 catch子句”放在“针对 derived class 而设计的catch 子句”之前。
条款 13:以 by reference 的方式捕捉异常
-
不要以指针的方式抛出异常,会有何时释放资源的难题,也无法捕捉标准异常。
-
使用传值的方式抛出异常时,每次有异常抛出,将发生复制两次。当派生类对象被接收基类异常的
catch
捕捉时,将发生对象切割,同时,当其虚函数在异常中调用时,将被解析为基类的虚函数。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
35
36
37
38class exception // 标准的 exception class
{
public:
virtual const char* what() throw();
...
};
class runtime_error: public exception { ... };
class Validation_error: public runtime_error // 新增的 class
{
public:
virtual const char* what() throw();
...
};
void someFunction()
{
...
if (a validation test fails)
{
throw Validation_error();
}
...
}
void doSomething()
{
try
{
someFunction(); // 可能会抛出一个有效的 exception
}
catch (exception ex) // 捕捉标准继承体系内的所以 exceptions (或其派生类)
{
cerr << ex.what(); // 调用的是 exception::what()
... // 而不是 Validation_error::what()
}
}
总结:
- by reference捕捉异常的好处有3:①throw对象不会被切割、②标准库里exception 通常都throw对象(我们我们用by pointer类型不匹配),而refernce可以绑定对象、③约束了exception obj复制的次数(就throw时复制一次)
- by pointer缺点:不符合上述②,标准库里都throw对象
- by value缺点:不符合上述①,throw出的对象会被切割,无法调用虚函数。
条款 14:明智运用 exception specifications
条款 15:了解异常处理
Efficiency 效率
条款 16:谨记 80-20 法制
- 软件中的80%的时间浪费在了20%的代码上。
条款 17:考虑使用 lazy evaluation
-
从效率的观点来看,最好的运算是从未被执行的运算。
-
Reference Counting(引用计数)- 避免非必要的对象复制:在你真正需要之前,不必着急为某物做一个副本。取而代之的是,以拖延战术应付之——只要能够,就使用其他副本。在某些应用领域,你常有可能永远不需要提供那样一个副本。
- 比如string s2 = s1,但是后续的操作op1_s2, op2_s2,等等都是不改变s2的,所以只要让s2和s1共享即可,当真正要改变s2时才拷贝s1。
-
区分读和写:读取操作往往代价低廉,写操作可能效率低,开销大。如果能延缓决定“究竟是读还是写,直到能确定为止”将提高效率。
- 对于一个refer但是此处法无法判定是否能区分,如果能够在operator[]里面区分读和写,就可以判定需不需要做额外的操作。s[3]作为左边的值是写,s[3]作为右边的值是读。
-
Lazy Fetching(缓式取出):生产一个对象时,只产生该对象的“外壳”,不从磁盘读取任何字段数据。当对象内的某个字段被需要了,程序才取回对应的数据。
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
29class LargeObject
{
public:
LargeObject(ObjectID id);
const string& field1() const;
int field2() const;
double field3() const;
const string& field4() const;
...
private:
ObjectID oid;
mutable string *field1Value;
mutable int *field2Value;
mutable double *field3Value;
mutable string *field4Value;
...
};
LargeObject::LargeObject(ObjectID id)
: oid(id), field1Value(0), field2Value(0), field3Value(0), ...{}
const string& LargeObject::field1() const
{
if (field1Value == 0)
{
//read the data for field 1 from the database and
//meke field1Value point to it;
}
return *field1Value;
} -
Lazy Expression Evaluation(表达式缓评估):将表达式的值暂存起来,需要时再计算它(往往需要只一部分,则只计算一部分)。
1
2
3
4
5
6
7template<class T>
class Matrix{...}; //for homogeneous matrices
Matrix<int> m1(1000,1000); //一个1000*1000的矩阵
Matrix<int> m2(1000,1000);
...
Matrix<int> m3 = m1 + m2;过operator 的实现是eagar evaluation:在这种情况下,它会计算和返回m1 与 m2的和。这个计算量非常大(100000次加法运算),当然系统会分配内存来存储这个值。
lazy evaluation方法就是建立一个数据结构来表示m3的值是m1和m2的和,再用一个enum来表示它们之间的加法,这样数据结构要比m1和m2相加快许多,也能节约内存。
条款 18:分期摊还预期的计算成本
-
Over-eager evaluation 背后的观念是,如果你预期程序常常会用到某个计算,你可以降低每次计算的平均成本,办法就是设计一份数据结构以便能够极有效率地处理需求。
-
将“已经计算好而有可能再被需要”的数值保留下来(所谓 caching)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18int findCubicleNumber(const string& employeeName)
{
typedef map<string, int> CubicleMap;
static CubicleMap cubes; // 局部缓存
CubicleMap::iterator it = cubes.find(employeeName);
if (it == cubes.end)
{
int cubicle = /* the result of looking up employeeName's cubicle number
in the database */
cubes[employeeName] = cubicle;
return cubicle;
}
else
{
return (*it).second;
}
} -
使用 Prefetching(预先取出)
-
当你必须支持某些运算而其结果几乎总是被需要,或其结果常常被多次需要的时候,over-eager evaluation 可以改善程序效率。
条款 19:了解临时对象的来源
只要你产生一个no-heap-object而没有为它命名,便产生了一个临时对象。
临时对象的产生途径:
-
当隐式型别转换被施行起来以求函数能够调用成功。
-
当函数返回对象的时候。
1.当隐式型别转换被施行起来以求函数能够调用成功。
1 | size_t countChar(const string& str,char ch){ |
要点
解释:利用char buffer[20]调用countChar(string& str,char ch)需要做哪些事情呢?
重点:先转化,构造一个string类型的临时对象,以buffer为自变量,调用string constructor,str就会绑定在临时对象上面【在调用函数的时候存在调用栈上】。
【重点中的重点】请注意,这边str是const string &类型,也就是不允许改变str的值,即绑定在该临时对象上面,读取临时对象的值,而没有改变临时对象的值,改变临时对象的值是没有意义的,临时对象会被销毁。
只有当对象以 by value(传值)方式传递,或是当对象被传递给一个reference-to-const(常引用)参数时,转换才会发生。当对象传递给一个reference-to-non-const参数,并不会发生此种转换。
例子:
1 | size_t countChar( string& str,char ch)</strong>{ |
将上述的size_t countChar(const string& str,char ch)
修改为size_t countChar( string& str,char ch)
,编译立刻出错
1 | error C2664: “countChar”: 不能将参数 1 从“char [20]”转换为“std::string &” |
即无法进行类型转换。
理由:string& str 是 references-to-non-const ,如果编译器针对 references-to-non-const 对象进行隐式型别转换,会允许临时对象被改变。改变临时对象是没有意义的。references-to-const参数则不需要承担这一问题,因为此参数为const,无法改变其内容。
2.当函数返回对象的时候。
1 | const Number operator+(const Number& lhs, const Number& rhs) { |
- 返回可以用类似复合语句(+=, -=,…)来优化返回时临时对象的个数。
- 注意函数返回引用:是不产生临时对象的。
条款 20:协助完成”返回值优化(return value optimization)“
-
尽量写出编译器可帮你消除临时对象+返回值对象的拷贝的代码
-
如果还可以将函数inline,编译器还消除了函数调用的成本
1
2
3
4
5
6
7
8
9inline const Rational operator*(const Rational& lhs, const Rational& rhs) {
return Rational(lhs.numberator() * rhs.number(), ...);
// 这就是ctor arguments,直接一个return 没有临时对象的成本,就能够被编译器优化!
// 总共两个成本:
// 1. 临时对象的成本:Rational(lhs.numberator() * rhs.number(), ...);
// 2. ByValue返回值的成本:因为你const Rational& 在这里无法办到
}
Rational c = a * b; // 调用这个时能够被忽略!!
条款 21:利用重载技术避免隐式转换
- 每个“重载操作符”必须获得至少一个“用户定制类型”的自变量。
例子:
1 | class UPInt |
代码:
1 | upi3 = upi1 + 10; |
这里的10会建立临时对象把整型10转换成UPInt,经历转换会有开销。
为了避免开销,如果我们想把UPInt和int对象相加,通过声明函数来达到目的。
1 | const UPInt operator+(const UPInt* lhs,const UPInt* rhs);//UPInt相加 |
请注意:
C++规定:每一个重载的operator必须带有一个用户自定义类型。也在本条款的最前面给出。
错误代码
1 | const UPInt operator+(int lhs,int rhs); |
条款 22:考虑以操作符复合形式取代其独身形式
对于下面的代码
1 | x = x + y ; x = x - y; |
也可以这样写:
1 | x += y; x -= y; |
正如这个条款的题目所言:
以操作符复合形式取代其独身形式。operator的复合形式与一个单独形式之间存在正常的关系
1 | //operator+根据operator+=来实现 |
好处:
operator复合形式比单独形式效率高,因为单独形式要返回一个新的对象【因为要创建个新对象作为最终的值】,从而在临时对象的构造和释放有一些开销,operator复合形式把结果写到左边的参数里,因此不需要临时对象来容纳operator的返回值。
条款 23:考虑使用其他程序库
iostream和stdio之间性能的对比。
我们需要有这样的意识,具有相同功能的不同的程序库在性能上采取不同的权衡措施,所以一旦找到程序的瓶颈,你应该知道是否可以通过替换程序库来消除瓶颈。比如如果你的程序的有I/O瓶颈,你考虑用stdio替换iostream,如果程序的在动态分配和释放上存在使用大量的时间,你可以想想是否有其他的operator new和operator delete的实现可用。因为不同的程序库在效率、可扩展性、移植性、类型安全和其他一些重要领域上蕴含着不用的设计理念,通过变更使用给予性能的更多考虑的程序库,你有时可以大幅度提高软件的效率。
条款 24:了解 virtual functions、multiple inheritance、virtual base classes、runtime type identification的成本
虚函数有4个成本需要注意:
- 每个拥有虚函数的类,需要一个vtbl(其大小由虚函数个数决定),用于保存其可调用虚函数的指针。
- 每个拥有虚函数的对象,需要一个vptr,用于指向vtbl
- 虚函数应当不写inline关键字。避免这样写,有些编译器能帮忙优化,有些编译器不会。
- vtbl放在那里呢?一般而言:
class's vtbl
通常放在其第一个non-inline, non-pure
虚函数定义的目标文件中,比如class C1
的vtbl可能放在定义C1::~C1
的目标文件中。或者编译器会让你选择,vtbl放在那里。【在大多数情况下,虚函数表(vtable)是与拥有虚函数的类放在一起的,但并不总是这样。具体取决于编译器和平台的实现。(gpt的答案)】 - 因为虚函数如果可以inline(相当于define在header中),会导致
vtbl
需要拷贝到每一个使用了此class's vtbl
的目标文件中。- 如果你在类的声明中将虚函数定义为内联函数(inline),编译器会尝试在每个使用了这些虚函数的地方直接插入函数的代码,而不是通过调用虚函数表来调用函数。这样做会导致虚函数表的内容被复制到每个使用了这些虚函数的目标文件中,而不是集中放在一个地方。
- 如果将虚函数定义为内联函数,并且虚函数表的内容被复制到每个使用了这些虚函数的目标文件中,那么在调用这些虚函数时就不会发生多态。
- vtbl放在那里呢?一般而言:
- 每个class对应的vtbl,需要指向一个type_info对象,type_info对象用于保存其类型信息。.
注意:
-
class的vtble常见的位置,是在第一个【非inline,非纯虚函数】(non-inline, non-pure) 的定义文件中。
-
多重继承导致需要虚基类,而虚基类会让隐藏指针vptr变多,占用空间。
-
A是BC的虚基类,D继承BC,所以对D来说,它的内存结果如下右图。BC要指向共同的A的pointer to virtual base部分,BCAD都有自己的vptr
-
Techniques 技术
条款 25:将构造函数和非成员函数虚化
- 所谓 virtual constructor 是某种函数,视其获得的输入,可产生不同类型的对象。
- 如果函数的返回类型是个指针(或reference),指向一个base class,那么 derived class 的函数可以返回一个指针(或reference),指向该 base class 的一个 derived class。
- 写一个虚函数做实际工作,再写一个什么都不做的非虚函数,只负责调用虚函数。
1 | class Expr_node |
条款 26:限制某个 class
所能产生的对象数量
问题1:如何允许建立零个对象?
最容易的方法就是把该类的构造函数声明在类的private域。代码示例如下:
1 | class CantBeInstantiated |
问题2:如何允许建立一个对象?
将类的构造函数声明为private后,每个用户都没有权力建立对象,但实际应用的需求是我们需要建立一个对象,因此我们需要选择性地放松这个限制。
对于声明为private的构造函数,我们可以引入友元函数或成员函数来进行访问,并利用静态成员变量来保证对象的唯一,具体实现代码如下:
1 | // 使用友元函数来访问私有构造函数 |
1 | //使用静态成员函数来访问私有构造函数 |
**1)唯一的Printer对象是位于函数里的静态成员而不是在类中的静态成员。**在类中的静态对象有两个缺点,一个总是被构造(和释放),即使不使用该对象;另一个缺点是它的初始化时间不确定。
2)thePrinter()函数没有声明为内联函数,因为内联意味编译器用函数体代替对函数的每一个调用,这样会导致函数内的静态对象在程序内被复制,可能会使程序的静态对象的拷贝超过一个。
问题3:如何限制允许建立多个对象?
我们可以换一种思路,不利用友元函数或成员函数来作为建立对象的中介,而引入计数器简单地计算对象的数目,一旦需要太多的对象,就抛出异常。具体代码实现如下:
1 | class Printer |
此法的核心思想就是使用numObjects跟踪Printer对象存在的数量。当构造对象时,它的值就增加,释放对象时,它的值就减少。如果试图构造过多的对象,就会抛出一个TooManyObjects类型的异常。通过maxObjects设置可允许建立对象的最大数目。
以上两种方法基本上可以满足,限制对象数目的要求。但是具体应用中,仍会出现问题,上述两种方法无法解决。如下面代码所示:
还是存在问题:
1 | class ColorPrinter:public Printer{ |
1 | class CPFMachine |
利用计数器的方法,上述代码中,都将会产生多个Printer对象,由Printer被作为基类或者被包含于其他类中,导致其他对象的构造时,隐藏构造Printer对象。这主要是计数器方法无法区分对象存在的三种不同环境:只有它们本身;作为其他派生类的基类;被嵌入在更大的对象中。
利用thePrinter()函数的方法,把Printer对象的数量限制为一个,这样做的同时也会让我们每一次运行程序时只能使用一个Printer对象。导致我们不能在程序的不同部分使用不同的Printer对象。如下面伪代码所示:
1 | 建立Printer对象p1; |
问题4:解决问题3的问题
**思路:将两种方法结合,将构造函数声明为private,限制其被继承和被包含于其他的类,解决计数器的问题,并提供伪构造函数作为访问对象的接口,并统计对象的个数,来解决限制构造多个对象和程序不同部分使用不同对象的问题。**具体代码实现如下:
1 | class Printer{ |
到目前为止,对于限制对象数目的问题,应该是大功告成。但是仍不能满足工程性上的应用,例如我们有大量像Printer需要限制实例数量的类,就必须一遍又一遍地编写一样的代码,每个类编写一次。作为程序员,应该避免这种重复性的工作。
一个具有对象计数功能的基类
**针对重复性工作的问题,解决思路是:构造一个具有实例计数功能的基类,让像Printer这样的类从该基类继承,利用派生类对象的构造,需要先构造基类对象的特点,通过隐藏的基类实现计数功能。**具体实现如下:
1 | template<class BeingCounted> |
条款 27:要求(或禁止)对象产生于 heap 之中
要求对象产生于heap中,意思是需要阻止clients不得使用new以外的方法产生对象。比较好的方法就是将destructor定义为private,因为constructor的类型太多,所以仍然将constructor定义为public。然后定义一个pseudo destructor来调用真正的destructor。示例如下:
1 | class HeapBasedObject { |
禁止对象产生于heap中,则是要让clients不能使用new方法来产生对象。方法就是将operator new和operator delete定义为private。示例如下:
1 |
|
下例是实现的一个判断某个对象是否位于heap内的基类HeapTracked。
1 |
|
条款 28:Smart Pointers(智能指针)
所谓 smart pointers 是那些看起来、用起来、感觉起来都像内建指针,但提供更多机能的一种对象。smart pointers 由 templates 产生出来,可以利用 templates 参数表明其所指对象的类型。
Smart Pointers 的构造、赋值、析构
Smart pointer 的构造通常非常简单:确定一个目标物(通常是利用 smart pointer 的 constructor 自变量),然后让 smart pointer 内部的 dumb pointer 指向它。如果尚未决定目标物,就将内部指针设为 0,或是发出一个错误信息。此外,如果一个 smart pointer 拥有它所指的对象,它就有责任在本身即将被销毁时删除该对象,前提是这个 smart pointer 所指对象系动态分配而得。
以 by value 方式传递 auto_ptrs 往往是个非常糟的做法,只有当你确定要讲对象拥有权移转给函数的某个参数时,才应该以 by value 方式传递 auto_ptrs.若一定要以 auto_ptrs 作为参数,应采用 pass-by-reference-to-const 方式。当 smart pointer 被复制,或是身为赋值动作的来源端时,它会被改变。
smart pointer 的 destructor 通常看起来如下:
1 | template<class T> |
实现解析操作符
operator* 返回所指对象,reference;operator-> 返回 dumb pointer(指向某对象),或者一个 smart pointer。
1 | template<class T> |
测试 Smart Pointers 是否为 Null
smart pointer 不能直接进行测试是否为 Null,需要提供一个隐式类型转换符:
1 | SmartPtr<TreeNode> ptn; |
Smart Pointers 与继承有关的转换
也需要添加一个隐式类型转换符:
1 | class MusicProduct{....}; |
Smart Pointer 和 const
1 | SmartPtr<CD> p; //non-const 对象 non-const 指针 |
条款 29:Reference counting(引用计数)
引用计数有两个好处:
- 简化 heap objects 周边的记录工作:当对象使用了引用计数,它就拥有了自己,一旦不再有任何人使用它,便自动销毁自己。
- 允许多个等值对象共享同一实值,避免多次存储。
引用计数的实现:
产生一个 class 不仅存储引用计数,也存储它们追踪的对象值。
技巧: 将一个 struct 嵌套放进一个 class 的 private 段落内,可以很方便地让该 class 的所有 members 有权处理这个 struct,而又能禁止任何其他人访问这个 struct。
1 |
|
条款 30:Proxy classes(替身类、代理类)
多维数组
1 | //定义一个类模板如下: |
- 将
operator[]
重载,令它返回一个Array1D对象; - 对Array1D重载
operator[]
,令它返回原来二维数组中的一个元素:
1 | template<class T> |
data[3]获得一个Array1D对象,对该对象再施行operator[],获得原二维数组中(3, 6)位置的浮点数;Array2D类的用户不需要知道Array1D类的存在。
凡“用来代表(象征)其他对象”的对象,常被称为proxy objects(替身对象);
用以表现proxy objects者,我们称为proxy classes。
区分operator[]
的读写动作
对于一个proxy,你只有3件事情可做:
产生它,本例也就是指定它代表哪一个字符串中的哪一个字符;
以它作为赋值动作的目标(接受端),这种情况下你是对它所代表的字符串内的字符做赋值动作。如果这么使用,proxy代表的将是“调用operator[]函数”的那个字符串的左值运用;
以其他方式使用之。如果这么使用,proxy表现的是“调用operatorp[]函数”的那个字符串的右值运用。
1 | //一个reference-counted String class, |
每个函数都只是产生并返回“被请求之字符”的一个替代品。没有任何动作施加于此字符身上:我们延缓此等行为,直到知道该行为是“读取”或者“写”。
operator[]
返回的每一个proxy都会记住它所附属的字符串,以及它所在的索引位置:
1 | String::CharProxy::CharProxy(String& str,int index) |
例子:
1 | //CharProxy的赋值操作符: |
限制隐式类型转换
难点:“对proxy取址”所获取的指针类型和“对真实对象取址”获取的指针类型不同。
解决:需要在CharProxy类内将取址操作符加以重载:
1 | class String |
1 | const char* String::CharProxy::operator() const |
条款 31:让函数根据一个以上的对象类型来决定如何虚化
里指出了一个情况,例如我们有三种物体,且都继承GameObject
SpaceShip
飞船SpaceStation
空间站Asteroid
陨石
不同的物体会相撞,且会产生不同的结果。例如飞船和空间站相撞,飞船能进入到空间站内;飞船和陨石相撞,两者都会摧毁。
这个时候,我们需要一个方法,传入任意俩个GameObject
都可以处理。
1 | void processCollision(GameObject& object1, GameObject& object2) |
书中讨论了一套方法,是一个不错的方法,但是感觉还不是很完美。目前就整理一下代码,记录下来。
1 |
|
Miscellany 杂项讨论
条款 32:在未来时态下发展程序
未来式思维只不过是加上一些额外的考虑:
-
提供完整的classes——即使某些部分目前用不到。当新的需求进来,你不太需要去回头修改那些classes;
比如:【C++ Efficiency】over-eager evaluation的两种做法:caching和prefetching -
设计你的接口,使有利共同的操作行为,阻止共同的错误。让这些classes轻易地被正确运用,难以被错误运用;
-
尽量使你的代码一般化(泛化),除非有不良的巨大后果。
条款 33:将非尾端类(non~leaf classes)设计为抽象类(abstract classes)
假设我们有一种这样的继承结构
1 |
|
考虑到继承关系,Lizard
类型的对象可以被视为 Animal
类型的对象,因为 Lizard
是 Animal
的派生类。因此,*pAnimal1 = *pAnimal2;
这种情况下是合法的,因为它将一个 Lizard
类型的对象赋值给了另一个 Lizard
类型的对象。
*pAnimal1 = *pAnimal3;
这种情况下是不合法的。尽管 pAnimal1
和 pAnimal3
都是 Animal
类型的指针,但是它们指向的对象类型不同,一个是 Lizard
,一个是 Chicken
。在赋值操作中,编译器会尝试调用适当的赋值运算符重载函数,但是 Animal
类型的赋值运算符函数只能保证处理 Animal
类型的对象,而不能处理 Lizard
或 Chicken
类型的对象。因此,尝试将 Chicken
类型的对象赋值给 Lizard
类型的对象是不合法的,这是由于类型不匹配造成的。
上边代码存在的两个问题:
- 部分赋值:最后一句调用 Animal class 的赋值操作符只会修改 liz 的 Animal 成分
- class 容易被误用,如果将
Animal::operator=
则会让上边的代码成为合法代码,这会带来异型赋值问题
通过设计抽象类,可以实现通过指针进行的同型赋值而又可以禁止通过同样那些指针而进行的异型赋值。
1 | class AbstractAnimal{//抽象类--必须内含至少一个纯虚函数 |
如果没有任何 member functions 可以很自然地被你声明为纯虚函数,传统做法是让 destructor 成为纯虚函数。
纯虚析构函数必须被实现出来,因为只要有一个 derived class destructor 被调用,它们便会被调用。此外,它们通常执行一些有用的工作,如释放资源或记录运转消息等等。
条款 34:如何在同一个程序中结合 C++和 C
- name mangling(名称重整)、statics(静态对象)初始化、动态内存分配、数据结构的兼容性。
- 如果你打算在同一个程序中混用 C++和 C,请记住以下几个简单守则:
- 确定你的 C++和 C 编译器产出兼容的目标文件(object files)。
- 将双方都使用的函数声明为
extern "C"
。 - 如果可能,尽量在 C++中撰写
main
。 - 总是以 delete删除 new返回的内存;总是以
free
释放malloc
返回的内存。 - 将两个语言间的“数据结构传递”限制于 C 所能了解的形式;C++ structs 如果内含非虚函数,倒是不受此限。
条款 35:让自己习惯于标准 C++语言
主要是了解不断发展的 C++ 标准的特性:RTTI、Templates、异常处理、STL等。