读书笔记系列2:《More Effective C++》

《More Effective C++》读书笔记

Item1 指针和引用的区别

首先,没有空引用的概念,引用必须指向一个对象(object),因此C++要求引用必须被初始化(指针则无此限制)。这一个特性,使得引用会比指针更高效,因为使用引用之前无需测试其合法性,然而指针需要:

void printDouble(const double& rd)
{
	cout << rd; // no need to test rd; it must refer to a double
}

void printDouble(const double *pd)
{
    if (pd) { // check for null pointer
    	cout << *pd;
    }
}

指针与引用的另一个重要的不同是指针可以被重新赋值以指向另一个不同的对象。但是引用则总是指向在初始化时被指定的对象,以后不能改变。

总的来说,在以下情况下你应该使用指针,一是你考虑到存在不指向任何对象的可能(在这种情况下,你能够设置指针为空),二是你需要能够在不同的时刻指向不同的对象(在这种情况下,你能改变指针的指向)。如果总是指向一个对象并且一旦指向一个对象后就不会改变指向,那么你应该使用引用。

Item2 使用C++类型的风格转换

  • static_cast 用于普通转换(无继承关系),但是功能受限,如不能将struct转换为int
  • const_cast 用于类型转换掉表达式的const或volatileness属性
  • dynamic_cast 用于安全地沿着类的继承关系向下进行类型转换(将基类指针或引用转换为派生类)。且能够判断转型是否成功:失败返回空指针或抛出异常(转换引用)。它不能被用于缺乏虚函数的类型上,或没有继承关系的类型之间(如int到double)
  • reinterpret_cast implementation-defined,移植性差。如对函数指针转换,

Item3 不要对数组使用多态

class BST { ... };
class BalancedBST: public BST { ... };

//Consider a function to print out the contents of each BST in an array of BSTs
void printBSTArray(ostream& s, const BST array[], int numElements)
{
    for (int i = 0; i < numElements; ++i) {
    	s << array[i]; // this assumes an operator<< is defined
    }
}

//This will work fine when you pass it an array of BST objects:
BST BSTArray[10];
printBSTArray(cout, BSTArray, 10); // works fine

//however, what happens when you pass printBSTArray an array of BalancedBST objects:
BalancedBST bBSTArray[10];
printBSTArray(cout, bBSTArray, 10); // works fine?

array[i]是指针算术的简写:它表示*(array+i)。编译器必须知道array中对象的实际大小,来执行指针算术。在printBSTArray函数声明中,其参数arrayBST类型,所以每个数组元素的大小即sizeof(BST)

所以当传递BalancedBST数组给该函数时,由于子类通常比基类大小更大,编译器很可能会出错,其行为未定义。

同样,当通过基类指针删除一个含有派生类对象的数组时,也可能出现各种问题:

//删除一个数组, 但是首先记录一个删除信息
void deleteArray(ostream& logStream, BST array[])
{
	logStream << "Deleting array at address " << static_cast<void*>(array) << '\n';
	delete [] array;
}

//这里隐藏着看不到的指针算法。当一个数组被删除时,每一个元素的析构函数会被调用,编译器很可能这样生成代码:
// 以与构造顺序相反的顺序来析构array数组里的对象
for ( int i = 数组元素的个数; i >= 0;--i)
{
	array[i].BST::~BST(); // 调用 array[i]的析构函数
}

The language specification says the result of deleting an array of derived class objects through a base class
pointer is undefined.

Polymorphism and pointer arithmetic simply don’t mix. Array operations almost always involve pointer
arithmetic, so arrays and polymorphism don’t mix.

注1:如果不从一个具体类派生,可能不会出现以上错误。详见Item33.

注2:在VC++中测试通过,它没有数组new/delete函数?见代码:D:\Workspace\CPP\projects\LearnCpp\more_effective_cpp\item3

示例代码

#include <iostream>
using namespace std;

#define VIRTUAL_BASE_DTOR

class BST {
private:
	int foo;
public:
#ifdef VIRTUAL_BASE_DTOR
	/*
	在VS2017测试:当定义为virtual时,deleteArray正常工作:子类析构函数正常调用
	在NDK-r11c Android7测试失败,删除第2个元素时,发生段错误,即多态下指针算术出现问题:
	Deleting array at address 0xebd13008
	~BalancedBST()
	virtual ~BST()
	Segmentation fault
	*/
	virtual ~BST() { std::cout << "virtual ~BST()\n"; }
#else
	~BST() { std::cout << "~BST()\n"; }
#endif
};

class BalancedBST : public BST { 
private:
	int bar;
public:
	~BalancedBST() { std::cout << "~BalancedBST()\n"; }
};

void deleteArray(ostream& logStream, BST array[])
{
	logStream << "Deleting array at address " << static_cast<void*>(array) << '\n';
	delete[] array;
}

void deleteArray2(ostream& logStream, BalancedBST array[])
{
	logStream << "Deleting array at address " << static_cast<void*>(array) << '\n';
	delete[] array;
}

int main() {
	BST* arr = new BalancedBST[10];
	deleteArray(std::cout, arr);
	std::cout << "using deleteArray2\n";
	BalancedBST* arr2 = new BalancedBST[10];
	deleteArray2(std::cout, arr2);
	return 0;
}

Item4 避免默认的构造函数

默认构造函数(即没有参数的构造函数)是无中生有的C++表述。构造函数初始化对象,而默认构造函数不使用任何参数来初始化对象。有时候这一点很有意义,像数值一类的对象,被初始化为0或者未定义的值是合理的;指针一类的对象,被初始化为null或者未定义值也是合理的;像链表、哈希表、map被初始化为空的容器同样是合理的。

但并不是所有对象都是这一类,有些对象缺少外部信息时没有理由完成初始化。如果一个类没有默认构造函数,那么在3中场景下的使用会有问题。

考虑如下类,没有默认构造函数,其构造函数有一个强制的id参数:

class EquipmentPiece {
public:
	EquipmentPiece(int IDNumber);
	//..
};

问题1 创建数组

第一个问题,创建数组。一般来说,没有办法为数组对象的构造函数提供参数。

//error C2512: “EquipmentPiece”: 没有合适的默认构造函数可用
EquipmentPiece arr[10];
//同上
EquipmentPiece *ep = new EquipmentPiece[10];

有3种方式,可以解决这个限制问题。

方式1

对于non-heap数组,是在定义数组处,为构造函数提供参数:

EquipmentPiece arr[3] = {
		EquipmentPiece(1),
		EquipmentPiece(2),
		EquipmentPiece(3)
	};

不过,这种方式不适用于堆数组?

EquipmentPiece *ep[] = {
		new EquipmentPiece(1),
		new EquipmentPiece(2),
		new EquipmentPiece(3)
	};
方式2

另一种更一般的方式是,指针数组替代对象数组:

typedef EquipmentPiece* PEP; // a PEP is a pointer to an EquipmentPiece

void test() {
	PEP bestPieces2[10]; // fine, no ctors called
	PEP *bestPieces = new PEP[10]; // also fine
	for (int i = 0; i < 10; ++i)
		bestPieces[i] = new EquipmentPiece(i);
}

这种方式有2个弊端:

  • 第一,需要记得删除这些对象,否则发生内存泄露
  • 第二,需要更多的内存空间,因为除了对象外,指针同样需要占据空间。
方式3

如果在堆上为数组对象申请内存,然后利用placement new在内存中构造数组对象,可以解决方式2中的第2个弊端:

void test() {
	const int ARR_SZ = 5;
	//直接调用array new。传统new[]是隐式调用array new,注意差别
	void * rawMem = operator new[](sizeof(EquipmentPiece) * ARR_SZ);
	// make bestPieces point to it so it can be treated as an EquipmentPiece array
	EquipmentPiece * bestPieces = static_cast<EquipmentPiece*>(rawMem);
	for (int i = 0; i < ARR_SZ; ++i)
	{
		//placement new. mem[i] is the i-th object, &mem[i] take its address
		new (&bestPieces[i])EquipmentPiece(i);
	}
#if 1	
	// destruct the objects in bestPieces in the inverse order in which they were constructed
	for (int i = ARR_SZ-1; i >= 0 ; --i)
	{
		//must call its destructor
		bestPieces[i].~EquipmentPiece();
	}
	//then deallocate the rawMem。直接调用array delete
	operator delete[] (rawMem);
#else
	//隐式调用 array delete。delete []无法获知bestPieces元素个数、大小等信息
	delete[] bestPieces;// undefined! bestPieces didn't come from the new operator
#endif
}

这种方式同样有弊端,当不再需要这些对象时,首先需要手动调用其析构函数,然后通过operator delete[]手动释放申请的原始内存。

If you forget this requirement and use the normal array-deletion syntax, your program will behave unpredictably. That’s because the result of deleting a pointer that didn’t come from the new operator is undefined.

这部分更多内容,参见本书item8。

也参见《C++必知必会》Item37 数组分配

问题2 使用模板

多数情况下,精心设计的模板可以消除对默认构造函数的需要。例如STL中的vector模板即如此。

不幸地是,很多模板并未精心设计,

template <typename T>
class Array {
public:
	Array(int size) {
		data = new T[size];//为每个数组元素调用T::T()
	}

private:
	T * data;
};

int main() {
	//当T为EquipmentPiece时,error C2512: “EquipmentPiece”: 没有合适的默认构造函数可用
	Array<EquipmentPiece> arr(3);
	return 0;
}

问题3 虚基类

不提供缺省构造函数的虚基类,很难与其进行合作。因为几乎所有的派生类在实例化时都必须给虚基类构造函数提供参数。这就要求所有由没有缺省构造函数的虚基类继承下来的派生类(无论有多远)都必须知道,并理解提供给虚基类构造函数的参数的含义。派生类的作者是不会企盼和喜欢这种规定的。

总结

因为这些强加于没有缺省构造函数的类上的种种限制,一些人认为所有的类都应该有缺省构造函数,即使缺省构造函数没有足够的数据来完整初始化一个对象。比如这个原则的拥护者会这样修改EquipmentPiece类:

class EquipmentPiece {
public:
	EquipmentPiece(int IDNumber = UNSPECIFIED);
private:
	static const int UNSPECIFIED = -1; // 其值代表 ID 值不确定。
};
EquipmentPiece e; //这样合法

这样的修改使得其他成员函数变得复杂,因为不能确保对象进行了有意义的初始化。如此一来,大多数成员函数需要检测ID的合法性。也会影响类的工作效率,付出更多代码测试成分是不是被正确初始化,也付出更多时间。

而如果一个类的构造函数能够确保所有的部分被正确初始化,所有这些弊病都能够避免。缺省构造函数一般不会提供这种保证,所以在它们可能使类变得没有意义时,尽量去避免使用它们。使用这种(没有缺省构造函数的)类的确有一些限制,但是当你使用它时,它也给你提供了一种保证:你能相信这个类被正确地建立和高效地实现。

Item5 谨慎定义类型转换函数

C++编译器能够在两种数据类型之间进行隐式转换(implicit conversions),它继承了 C 语言的转换方法,例如允许把 char 隐式转换为 int 和从 short 隐式转换为 double。

你对这些类型转换是无能为力的,因为它们是语言本身的特性。不过当你增加自己的类型时,你就可以有更多的控制力,因为你能选择是否提供函数让编译器进行隐式类型转换。

有两种函数允许编译器进行这些的转换:单参数构造函数(single-argument constructors)和隐式类型转换运算符。

隐式类型转换函数示例:

//隐式类型转换函数
class Rational { // 有理数类
public:
	//转换 int 到有理数类
	Rational(int numerator = 0, int denominator = 1):_numerator(numerator),_denominator(denominator){}
	//转换Rational类成double类型
	operator double() const { return static_cast<double>(_numerator) / _denominator; }
private:
	int _numerator;
	int _denominator;
};

int main() {
	Rational r(1, 2); // r 的值是 1/2
	double d = 0.5 * r; // 转换 r 到 double, 然后做乘法
	//double d = 0.5 * r.operator double(); // 与上方等价
	return 0;
}

避免类型转换

这里把 ArraySize 嵌套入 Array 中,为了强调它总是与 Array 一起使用。你也必须声明ArraySize 为公有,为了让任何人都能使用它。
想一下,当通过单参数构造函数定义 Array 对象,会发生什么样的事情:
Array<int> a(10);
你的编译器要求用 int 参数调用 Array<int> 里的构造函数,但是没有这样的构造函数。编译器意识到它能从 int 参数转换成一个临时 ArraySize 对象,ArraySize 对象只是Array<int>构造函数所需要的,这样编译器进行了转换。函数调用(及其后的对象建立)也就成功了。
事实上你仍旧能够安心地构造 Array 对象,不过这样做能够使你避免类型转换。

示例:

//#define PROXY_CLASS
#define EXPLICIT_CTOR

template<class T>
class Array {
public:
#ifdef PROXY_CLASS
	//proxy class
	class ArraySize { // 这个类是新的
	public:
		ArraySize(int numElements) : theSize(numElements) {}
		int size() const { return theSize; }
	private:
		int theSize;
	};
	Array(ArraySize size) {} // 注意新的声明
#elif defined(EXPLICIT_CTOR)
	explicit Array(int size) {} // explicit声明
#else
	Array(int size) {} // 旧的声明
#endif
	Array(int lowBound, int highBound) {}
	T& operator[](int index) { return data[index]; }
private:
	T * data;
};

bool operator==(const Array<int>& lhs, const Array<int>& rhs) { return true; }

int main() {
	Array<int> a(10);
	Array<int> b(10);
	for (int i = 0; i < 10; ++i)
	{
		//注意可能的书写错误导致逻辑不通,对比三种情形下编译的合法性
        //do something for when a[i] and b[i] are equal;
		if (a == b[i]) {}
	}
}

proxy class,详见Item30.

Item6 自增自减运算符

不论是 increment 或 decrement 的前缀还是后缀都只有一个参数。为了解决这个语言问题,C++规定后缀形式有一个 int 类型参数,当函数被调用时,编译器传递一个 0 做为 int 参数的值给该函数:

From your days as a C programmer, you may recall that the prefix form of the increment operator is sometimes called “increment and fetch,” while the postfix form is often known as “fetch and increment.” These two phrases are important to remember, because they all but act as formal specifications for how prefix and postfix increment should be implemented.

class UPInt { // "unlimited precision int"
public:
	UPInt(int data = 0) : _data(data) {}

	// += 操作符,UPInts 与 ints 相运算
	UPInt& operator+=(int val) {
		_data += val;
		return *this;
	}

	// ++ 前缀
	UPInt& operator++() { 
		*this += 1; 
		return *this;
	};

	// ++ 后缀
    //后缀操作符函数没有使用它的参数。它的参数只是用来区分前缀与后缀函数调用。
	const UPInt operator++(int) {
		UPInt old = *this;
		++ *this;
		return old;
	};
	
	UPInt& operator--(); // -- 前缀
	const UPInt operator--(int); // -- 后缀
private:
	int _data;
};

int main() {
	UPInt i;
	++i; // 调用 i.operator++();
	i++; // 调用 i.operator++(0);

	//i++++;//编译无法通过
	++ ++i;//"increment and fetch" makes sense
}

尤其要注意的是:这些操作符前缀与后缀形式返回值类型是不同的。前缀形式返回一个引用,后缀形式返回一个 const 类型。

假设不是 const 对象,下面的代码就是正确的:

UPInt i;
i++++; // 两次 increment 后缀
//这组代码与下面的代码相同:
i.operator++(0).operator++(0);

很明显,第一个调用的 operator++函数返回的对象调用了第二个 operator++函数。
有两个理由导致我们应该厌恶上述这种做法,第一是与内置类型行为不一致。当设计一个类遇到问题时,一个好的准则是使该类的行为与 int 类型一致。而 int 类型不允许连续进行两次后缀 increment:

int i;
i++++; // 错误!

第二个原因是使用两次后缀 increment 所产生的结果与调用者期望的不一致。如上所示,第二次调用operator++改变的值是第一次调用返回对象的值,而不是原始对象的值。

因此如果i++++;是合法的,i 将仅仅增加了一次。这与人的直觉相违背,使人迷惑(对于 int 类型和 UPInt都是一样,所以最好禁止这么做。

C++禁止 int 类型这么做,同时你也必须禁止你自己写的类有这样的行为。最容易的方法是让后缀 increment 返回 const 对象。当编译器遇到这样的代码:

i++++; // same as
i.operator++(0).operator++(0);

它发现从第一个 operator++函数返回的 const 对象又调用 operator++函数,然而这个函数是一个non-const 成员函数,所以 const 对象不能调用这个函数。

Item7 不要重载“&&”,“||”,“,”

与 C 一样,C++使用布尔表达式短路求值法(short-circuit evaluation)。这表示一旦确定了布尔表达式的真假值,即使还有部分表达式没有被测试,布尔表达式也停止运算。

C++允许根据用户定义的类型,来定制&&和||操作符。方法是重载函数 operator&&operator||,你能在全局重载或每个类里重载。

你以函数调用法替代了短路求值法。也就是说如果你重载了操作符&&,对于你来说代码是这样的:

if (expression1 && expression2) ...
// 对于编译器来说,等同于下面代码之一:
if (expression1.operator&&(expression2)) ...
// when operator&& is a member function
if (operator&&(expression1, expression2)) ...
// when operator&& is a global function

这好像没有什么不同,但是函数调用法与短路求值法是绝对不同的。首先当函数被调用时,需要运算其所有参数,所以调用函数 operator&&operator||时,两个参数都需要计算,换言之,没有采用短路计算法。第二是 C++语言规范没有定义函数参数的计算顺序,所以没有办法知道表达式 1 与表达式 2 哪一个先计算。完全可能与具有从左参数到右参数计算顺序的短路计算法相反。
因此如果你重载&&或||,就没有办法提供给程序员他们所期望和使用的行为特性,所以不要重载&&和||。

同样的理由也适用于逗号操作符,逗号操作符用于组成表达式,经常在 for 循环的更新部分(update part)里遇见它。参考摘自K&R《The C Programming Language》P55的字符串反转函数:

#include <string.h>
 /* reverse: reverse string s in place */
 void reverse(char s[])
 {
 	int c, i, j;
     for (i = 0, j = strlen(s)-1; i < j; i++, j--) {
         c = s[i];
         s[i] = s[j];
         s[j] = c;
     }
 }

在 for 循环的最后一个部分里,i 被增加同时 j 被减少。在这里使用逗号很方便,因为在最后一个部分里只能使用一个表达式,分开表达式来改变 ij的值是不合法的。

与&&和||相似,也有规则来定义逗号操作符的计算方法。一个包含逗号的表达式首先计算逗号左边的表达式,然后计算逗号右边的表达式;整个表达式的结果是逗号右边表达式的值。所以在上述循环的最后部分里,编译器首先计算++i,然后是--j,逗号表达式的结果是--j

如果你写一个非成员函数 operator,你不能保证左边的表达式先于右边的表达式计算,因为函数(operator)调用时两个表达式做为参数被传递出去。但是你不能控制函数参数的计算顺序。所以非成员函数的方法绝对不行。
剩下的只有写成员函数 operator 的可能性了。即使这里你也不能依靠于逗号左边表达式先被计算的行为特性,因为编译器不一定必须按此方法去计算。因此你不能重载逗号操作符,保证它的行为特性与其被料想的一样。重载它是完全轻率的行为。

当然能重载这些操作符不是去重载的理由。操作符重载的目的是使程序更容易阅读,书写和理解,而不是用你的知识去迷惑其他人。如果你没有一个好理由重载操作符,就不要重载

Item8 理解new/delete的不同含义

参考:

new operator and operator new, also delete, array new, array delete

multiple forms of operator new/delete, array new/array delete

the placement new function and a placement new function

Item10 在构造函数中防止资源泄露

#include <string>
#include <list>
using std::string;
using std::list;

class Image { // 用于图像数据
public:
	Image(const string& imageDataFileName) {}
};
class AudioClip { // 用于声音数据
public:
	AudioClip(const string& audioDataFileName) {}
};
class PhoneNumber { }; // 用于存储电话号码

class BookEntry { // 通讯录中的条目
public:
	BookEntry(const string& name,
		const string& address = "",
		const string& imageFileName = "",
		const string& audioClipFileName = "");
	~BookEntry();
	// 通过这个函数加入电话号码
	void addPhoneNumber(const PhoneNumber& number) {}
private:
	string theName; // 人的姓名
	string theAddress; // 他们的地址
	list<PhoneNumber> thePhones; // 他的电话号码
	Image *theImage; // 他们的图像
	AudioClip *theAudioClip; // 他们的一段声音片段
};

BookEntry::BookEntry(const string& name,
	const string& address,
	const string& imageFileName,
	const string& audioClipFileName)
	: theName(name), theAddress(address),
	theImage(0), theAudioClip(0)//both are initialized to null, C++ guarantees it's safe to delete null pointers
{
	if (imageFileName != "") {
		theImage = new Image(imageFileName);
	}
	if (audioClipFileName != "") {
		theAudioClip = new AudioClip(audioClipFileName);
	}
}
BookEntry::~BookEntry()
{
	delete theImage;
	delete theAudioClip;
}

考虑如果BookEntry的构造函数执行过程中抛出异常(operator new无法分配内存,或某个构造函数本身抛出异常),会发生什么?

C++ destroys only fully constructed objects, and an object isn’t fully constructed until its constructor has run to completion.

So if a BookEntry object b is created as a local object,

void testBookEntryClass()
{
	BookEntry b("Addison-Wesley Publishing Company", "One Jacob Way, Reading, MA 01867");
}

and an exception is thrown during construction of b , b’s destructor will not be called. Furthermore, if you try to take matters into your own hands by allocating b on the heap and then calling delete if an exception is thrown,

void testBookEntryClass()
{
    BookEntry *pb = 0;
    try {
        pb = new BookEntry("Addison-Wesley Publishing Company",
        "One Jacob Way, Reading, MA 01867");
    }
    catch (...) { // catch all exceptions
    	delete pb; // delete pb when an exception is thrown
    	throw; // propagate exception to caller
    } 
    delete pb; // delete pb normally
}

you’ll find that the Image object allocated inside BookEntry 's constructor is still lost, because no assignment is made to pb unless the new operation succeeds. If BookEntry 's constructor throws an exception, pb will be the nullpointer, so deleting it in the catch block does nothing except make you feel better about yourself.

总之,BookEntry的析构函数不会被调用。

Item11 禁止异常信息传递到析构函数外

在有两种情况下会调用析构函数。第一种是在正常情况下删除一个对象,例如对象超出了作用域或被显式地 delete。第二种是异常传递的堆栈辗转开解(stack-unwinding)过程中,由异常处理系统删除一个对象。

在上述两种情况下,调用析构函数时异常可能处于激活状态也可能没有处于激活状态。遗憾的是没有办法在析构函数内部区分出这两种情况。因此在写析构函数时你必须保守地假设有异常被激活。因为如果在一个异常被激活的同时,析构函数也抛出异常,并导致程序控制权转移到析构函数外,C++将调用 terminate 函数。这个函数的作用正如其名字所表示的:它终止你程序的运行,而且是立即终止,甚至连局部对象都没有被释放。

禁止异常传递到析构函数外有两个原因,第一能够在异常转递的堆栈辗转开解(stack-unwinding)的过程中,防止 terminate 被调用。第二它能帮助确保析构函数总能完成我们希望它做的所有事情。

实测

异常发生在析构函数内,会调用abort()终止程序;构造函数中则提示“有未经处理的异常”

//Item11 禁止异常信息传递到析构函数外
#include <iostream>
using namespace std;

//#define DTOR_NO_THROW
//#define DTOR_THROW_CATCH
//#define CTOR_THROW

class Trace {
public:
	Trace(std::string func) :_func(func) {
#ifdef CTOR_THROW
		//0x00007FFC4F1A4FD9 处(位于 cpp_senior.exe 中)有未经处理的异常: 
		//Microsoft C++ 异常: int,位于内存位置 0x000000B16A7CFC70 处。
		throw 1;
#endif
		cout << "Trace() " << _func.c_str() << endl;
	}

#ifdef DTOR_NO_THROW
	~Trace() {
		cout << "~Trace() " << _func.c_str() << endl;
	}
#elif defined(DTOR_THROW_CATCH)
	~Trace() {
		try
		{
			throw 1;
		}
		catch (...){}
		cout << "~Trace() " << _func.c_str() << endl;
	}
#else
	//warning C4297: “Trace::~Trace”: 假定函数不引发异常,但确实发生了
	//析构函数或释放器具有一个(可能是隐含的)非引发异常规范
	//异常发生在析构函数内,会调用abort()
	~Trace() {
		throw 1;
		cout << "~Trace() " << _func.c_str() << endl;
	}
#endif
private:
	std::string _func;
};

#define TRACE_FUNC Trace t(__FUNCTION__);

int main()
{
	{
		TRACE_FUNC
	}
	getchar();
}

附:stack unwinding

Stack Unwinding is the process of removing function entries from function call stack at run time. The local objects are destroyed in reverse order in which they were constructed.

Stack Unwinding is generally related to Exception Handling. In C++, when an exception occurs, the function call stack is linearly searched for the exception handler, and all the entries before the function with exception handler are removed from the function call stack. So, exception handling involves Stack Unwinding if an exception is not handled in the same function (where it is thrown). Basically, Stack unwinding is a process of calling the destructors (whenever an exception is thrown) for all the automatic objects constructed at run time.

Explanation:

  • When f1() throws exception, its entry is removed from the function call stack, because f1() doesn’t contain exception handler for the thrown exception, then next entry in call stack is looked for exception handler.
  • The next entry is f2(). Since f2() also doesn’t have a handler, its entry is also removed from the function call stack.
  • The next entry in the function call stack is f3(). Since f3() contains an exception handler, the catch block inside f3() is executed, and finally, the code after the catch block is executed.

Note that the following lines inside f1() and f2() are not executed at all.

 cout<<"f1() End \n";  // inside f1()

 cout<<"f2() End \n";  // inside f2()

If there were some local class objects inside f1() and f2(), destructors for those local objects would have been called in the Stack Unwinding process.

代码
//参考链接
//https://www.geeksforgeeks.org/stack-unwinding-in-c/
// CPP Program to demonstrate Stack Unwinding
#include <iostream>
using namespace std;
class Trace {
public:
	Trace(std::string func):_func(func){
		cout << "Trace() " << _func.c_str() << endl;
	}

	~Trace() {
		cout << "~Trace() " << _func.c_str() << endl;
	}
private:
	std::string _func;
};

#define TRACE_FUNC Trace t(__FUNCTION__);


// A sample function f1() that throws an int exception
void f1() throw(int)
{
	TRACE_FUNC
	cout << "f1() Start \n";
	throw 100;
	cout << "f1() End \n";
}

// Another sample function f2() that calls f1()
void f2() throw(int)
{
	TRACE_FUNC
	cout << "f2() Start \n";
	f1();
	cout << "f2() End \n";
}

// Another sample function f3() that calls f2() and handles exception thrown by f1()
void f3()
{
	TRACE_FUNC
	cout << "f3() Start \n";
	try {
		f2();
	}
	catch (int i) {
		cout << "Caught Exception: " << i << endl;;
	}
	cout << "f3() End\n";
}

// Driver Code
int main()
{
	TRACE_FUNC
	f3();
	getchar();
	return 0;
}

输出:

Trace() main
Trace() f3
f3() Start
Trace() f2
f2() Start
Trace() f1
f1() Start
~Trace() f1
~Trace() f2
Caught Exception: 100
f3() End
~Trace() f3

Item12 抛异常、传参、调用虚函数之异同

传递函数参数与异常的方式可以是传值、传递引用或传递指针,这是相同的。但是当传递参数和异常时,系统所要完成的操作过程则是完全不同的。产生这个差异的原因是:调用函数时,程序的控制权最终还会返回到函数的调用处,但是当抛出一个异常时,控制权永远不会回到抛出异常的地方。

考虑如下函数,参数类型是 Widget,并抛出一个 Widget 类型的异常:

class Widget {};
class SpecialWidget : public Widget {};

istream operator>>(istream& s, Widget& w);
void passAndThrowWidget()
{
	Widget localWidget;
 	cin >> localWidget; //传递 localWidget 到 operator>>
	throw localWidget; // 抛出 localWidget 异常
}

当传递 localWidget 到函数 operator>>里,不用进行拷贝操作,而是把 operator>>内的引用类型变量 w 指向 localWidget,任何对 w 的操作实际上都施加到 localWidget 上。这与抛出 localWidget 异常有很大不同。不论通过传值捕获异常还是通过引用捕获(不能通过指针捕获这个异常,因为类型不匹配)都将进行localWidget的拷贝操作,也就说传递到catch子句中的是 localWidget 的拷贝。必须这么做,因为当 localWidget 离开了生存空间后,其析构函数将被调用。如果把 localWidget 本身(而不是它的拷贝)传递给 catch 子句,这个子句接收到的只是一个被析构了的 Widget,一个 Widget 的“尸体”。这是无法使用的。因
C++规范要求被做为异常抛出的对象必须被复制。即使被抛出的对象不会被释放,也会进行拷贝操作,如localWidgetstatic变量。对异常对象进行强制复制拷贝,这个限制有助于我们理解参数传递与抛出异常的第二个差异:抛出异常运行速度比参数传递要慢

当异常对象被拷贝时,拷贝操作是由对象的拷贝构造函数完成的。该拷贝构造函数是对象的静态类型(static type)所对应类的拷贝构造函数,而不是对象的动态类型(dynamic type)对应类的拷贝构造函数:

void passAndThrowWidget() {
	SpecialWidget localSpecialWidget;
	Widget& rw = localSpecialWidget; // rw 引用 SpecialWidget
	throw rw; //它抛出一个类型为 Widget的异常。通过静态类型Widget进行拷贝
}

这里抛出的异常对象是 Widget,即使 rw 引用的是一个 SpecialWidget。因为 rw 的静态类型(static
type)是 Widget,而不是 SpecialWidget。你的编译器根本没有注意到 rw 引用的是一个SpecialWidget。编译器所注意的是 rw 的静态类型(static type)。这种行为可能与你所期待的不一样,但是这与在其他情况下 C++中拷贝构造函数的行为是一致的。(也可以让你根据对象的dynamic type进行拷贝,参见条款 M25)。

异常是其它对象的拷贝,这个事实影响到你如何在 catch 块中再抛出一个异常。注意这两个 catch 块差异:

catch (Widget& w) // 捕获 Widget 异常
{
	// 处理异常
	throw; // 重新抛出异常,让它继续传递,不进行拷贝
}
catch (Widget& w) // 捕获 Widget 异常
{
	// 处理异常
	throw w; //每次throw obj,都会传递被捕获异常的拷贝。但是通过静态类型Widget进行拷贝
}

传递参数与传递异常的另一个差异。一个被异常抛出的对象(刚才解释过,总是一个临时对象)可以通过普通的引用捕获。它不需要通过指向 const 对象的引用(reference-to-const)捕获。在函数调用中不允许转递一个临时对象到一个非 const 引用类型的参数里(参见条款 M19),但是在异常中却被允许。

综上所述,把一个对象传递给函数或一个对象调用虚拟函数与把一个对象做为异常抛出,这之间有三个主要区别。

第一、异常对象在传递时(throw obj)总是被进行拷贝;当通过传值方式捕获时,异常对象被拷贝了两次。对象做为参数传递给函数时不一定需要被拷贝(使用引用)。注意:通过指针抛出异常时,同参数传递一样,会对指针进行拷贝。在设计时,应避免抛出一个指向局部对象的指针(离开作用域时会析构),可以是全局的或堆中的。

第二、对象做为异常被抛出与做为参数传递给函数相比,前者类型转换比后者要少,前者只有两种转换形式:第一种是派生类可转换为基类(数值、引用和指针都适用);第二种是允许从一个类型化指针转换为无类型指针void*,即带有const void*类型的catch子句可以捕获任意类型的指针类型异常。与此相比,函数传参可以进行隐式类型转换,如intdouble

第三,catch 子句进行异常类型匹配的顺序是它们在源代码中出现的顺序,第一个类型匹配成功的 catch 将被用来执行。当一个对象调用一个虚拟函数时,被选择的函数位于与对象类型匹配最佳的类里,即使该类不是在源代码的最前头。虚函数采用最优适合法,而异常处理采用的是最先适合法

完整示例

#include <iostream>

class Widget {
public:
	Widget() { std::cout << "Widget()\n"; }
	Widget(const Widget&) { std::cout << "Widget(const Widget&)\n"; }
	~Widget() { std::cout << "~Widget()\n"; }
};
class SpecialWidget : public Widget {
public:
	SpecialWidget() { std::cout << "SpecialWidget()\n"; }
	SpecialWidget(const SpecialWidget&) { std::cout << "SpecialWidget(const SpecialWidget&)\n"; }
	~SpecialWidget() { std::cout << "~SpecialWidget()\n"; }
};

void passAndThrowWidget() {
	SpecialWidget localSpecialWidget;
	const Widget& rw = localSpecialWidget; // rw 引用 SpecialWidget
	throw rw; //它抛出一个类型为 Widget的异常。通过静态类型Widget进行拷贝
}

//三种方式均能捕获异常,其中const Widget&可以捕获所有类型 
#define CATCH_BY_VALUE
#define CATCH_BY_REF

int test() {
	try
	{
		passAndThrowWidget();
	}
#ifdef CATCH_BY_VALUE
	//通过值捕获,会执行两次拷贝,一次为异常抛出机制建立的临时对象,另一个是把临时对象拷贝进w
	catch (Widget w)
	{
		std::cout << "Widget w\n";
	}
#elif defined(CATCH_BY_REF)
	//通过引用捕获,而无需通过reference-to-const。这在函数参数传递中是不允许的。
	catch (Widget& w)
	{
		std::cout << "Widget& w\n";
#if 0
		throw;// 重新抛出异常,让它继续传递,不进行拷贝
#else
		throw w;//每次throw obj,都会传递被捕获异常的拷贝。但是通过静态类型Widget进行拷贝
#endif
	}
#else
	catch (const Widget& w)
	{
		std::cout << "const Widget& w\n";
	}
#endif
	return 0;
}

int main() {
	try
	{
		test();
	}
	catch (const Widget& w)
	{
		std::cout << "_______________const Widget& w\n";
	}
	getchar();
}

Item13 通过引用捕获异常

当你写一个 catch 子句时,必须确定让异常通过何种方式传递到 catch 子句里。你可以有三个选择:与你给函数传递参数一样,通过指针(by pointer),通过传值(by value)或通过引用(by reference)。

throw 处传递一个异常到 catch 子句是一个缓慢的过程,使用指针理论上效率最高,且只有这种方式能够避免对象的拷贝。但是指针的方式面临一个问题:当程序控制权离开抛出指针的函数后,对象是否继续生存?全局对象或static对象可以,但是程序员可能会忘记此约束;而在堆上创建对象,catch的用户又面临是否delete的问题。并且,通过指针捕获异常也不符合C++语言本身的规范。

四个标准的异常:

bad_alloc(当 operator new(参见条款 M8)不能分配足够的内存时,被抛出),

bad_cast(当dynamic_cast 针对一个引用(reference)操作失败时,被抛出),

bad_typeid(当dynamic_cast 对空指针进行操作时,被抛出),

bad_exception(用于 unexpected 异常;参见条款 M14),

都不是指向对象的指针,所以你必须通过值或引用来捕获它们。

通过捕获异常(catch-by-value)可以解决上述的问题,例如异常对象删除的问题和使用标准异常类型的问题。但是当它们被抛出时系统将对异常对象拷贝两次(参见条款M12)。而且它会产生 slicing problem,即派生类的异常对象被做为基类异常对象捕获时,那它的派生类行为就被切掉了(sliced off)。这样的 sliced 对象实际上是一个基类对象:它们没有派生类的数据成员,而且当本准备调用它们的虚拟函数时,系统解析后调用的却是基类对象的函数。

slicing行为:

class Validation_error: public runtime_error {// 客户自己加入个类
public:
    // 重新定义在异常类中虚拟函数
	virtual const char * what() throw();
};

void someFunction() // 抛出一个 validation异常
{
    if (a validation 测试失败) {
        throw Validation_error();
    }
}

void doSomething()
{
    try {
        someFunction(); // 抛出 validation异常
    }
    catch (exception ex) { //捕获所有标准异常类或它的派生类
        cerr << ex.what(); // 调用 exception::what(),而不是 Validation_error::what()
    }
}

最后剩下方法就是通过引用捕获异常(catch-by-reference)。通过引用捕获异常能使你避开上述所有问题。不像通过指针捕获异常,这种方法不会有对象删除的问题而且也能捕获标准异常类型。也不象通过值捕获异常,这种方法没有 slicing problem,而且异常对象只被拷贝一次。

Item14 审慎使用异常明细

如果一个函数抛出一个不在异常规格范围内的异常,系统在运行时能够检测出这个错误,然后unexpected函数将被自动调用。异常规格既可以做为一个指导性文档同时也是异常使用的强制约束机制。

std::unexpected is called by the C++ runtime when a dynamic exception specification is violated: an exception is thrown from a function whose exception specification forbids exceptions of this type.

语法:

throw(comma-separated type-id-list(optional))	//deprecated in C++11, removed in C++17

标明异常规范函数的异常集:

Each function f, pointer to function pf, and pointer to member function pmf has a set of potential exceptions, which consists of types that might be thrown. Set of all types indicates that any exception may be thrown. This set is defined as follows:

  1. If the declaration of f, pf, or pmf uses a dynamic exception specification that does not allow all exceptions (until C++11), the set consists of the types listed in that specification.

  2. Otherwise, if the declaration of f, pf, or pmf uses noexcept(true), the set is empty.

(since C++11)

  1. Otherwise, the set is the set of all types.

以上详见cppreference

函数unexpected缺省的行为是调用函数 terminate,而 terminate 缺省的行为是调用函数 abort,所以一个违反异常规格的程序其缺省的行为就是 halt(停止运行)。在激活的栈中的局部变量没有被释放,因为 abort 在关闭程序时不进行这样的清除操作。对异常规格的触犯变成了一场并不应该发生的灾难。

编译器仅仅部分地检测异常的使用是否与异常规格保持一致。一个函数调用了另一个函数,并且后者可能抛出一个
违反前者异常规格的异常(A 函数调用 B 函数,但因为 B 函数可能抛出一个不在 A 函数异常规格之内的异常,所以这个函数调用就违反了 A 函数的异常规格 译者注)编译器不对此种情况进行检测,并且语言标准也禁止编译器拒绝这种调用方式(尽管可以显示警告信息)。

规避

因为你的编译器允许你调用一个函数,其抛出的异常与发出调用的函数的异常规格不一致,并且这样的调用可能导致你的程序执行被终止,所以在编写软件时应该采取措施把这种不一致减小到最少。

第一个方法是避免在带有类型参数的模板内使用异常规格。更一般的情形,就是没有办法知道某种模板类型参数抛出什么样的异常。我们几乎不可能为一个模板提供一个有意义的异常规格。因为模板总是采用不同的方法使用类型参数。解决方法只能是模板和异常规格不要混合使用。

第二个方法是如果在一个函数内调用其它没有异常规格的函数,那么应该去除这个函数的异常规格

传递函数指针时进行这种异常规格的检查,是语言的较新的特性,所以有可能你的编译器不支持这个特性。

第三个方法是处理系统本身抛出的异常。这些异常中最常见的是 bad_alloc,当内存分配失败时它被 operator newoperator new[]抛出(参见条款 M8)。如果你在函数里使用 new 操作符,你必须为函数可能遇到 bad_alloc 异常作好准备。

处理

有时直接处理 unexpected 异常比防止它们被抛出要简单。

处理1 自定义转换

NDK-r11c验证通过。VS2017无论f0还是fterminate,平台差异?

#include <iostream>
#include <exception>

class UnexpectedException {}; // 所有的 unexpected 异常对象被替换为这种类型对象
void convertUnexpected() // 如果一个 unexpected 异常被抛出,这个函数被调用
{
	std::cout << "convertUnexpected()\n";
	throw UnexpectedException();
}

//异常规格没有UnexpectedException,故仍然调用terminate
//VS2017: 有未经处理的异常: Microsoft C++ 异常: int
//NDK-r11c: terminate called after throwing an instance of 'int'
//Aborted
void f(){
	throw 1;
}

//Provided the exception
//specification that was violated includes UnexpectedException, exception propagation will then continue as if
//the exception specification had always been satisfied.
void f0() throw(UnexpectedException){
	throw 1;
}

int main(){
	std::set_unexpected(convertUnexpected);
	try
	{
		f();
	}
	catch (UnexpectedException&e)
	{
		std::cout << "UnexpectedException\n";
	}
	getchar();
}
处理2:转换std::bad_exception

std::bad_exception

If a dynamic exception specification is violated and std::unexpected throws or re-throws an exception that still violates the exception specification, but the exception specification allows std::bad_exception, std::bad_exception is thrown.

#include <iostream>
#include <exception>

//重新抛出当前异常,这样异常将被替换为 bad_exception
//如果这么做,你应该在所有的异常规格里包含 bad_exception(或它的基类,标准类
//exception)。你将不必再担心如果遇到 unexpected 异常会导致程序运行终止。任何不听话
//的异常都将被替换为 bad_exception,这个异常代替原来的异常继续传递。
void convertUnexpected()
{
	std::cout << "convertUnexpected()\n";
	throw;
}

//异常规格需包含std::bad_exception
void f() throw(std::bad_exception) {
	throw 1;
}

int main() {
	std::set_unexpected(convertUnexpected);
	try
	{
		f();
	}
	catch (const std::bad_exception&)
	{
		std::cout << "bad_exception\n";
	}
	getchar();
}

结论

异常规格是一个应被审慎使用的特性。在把它们加入到你的函数之前,应考虑它们所带来的行为是否就是你所希望的行为。

Item15 了解异常处理的系统开销

NDK编译时,Application.mk中:

APP_CPPFLAGS += -fexceptions -frtti -std=c++11

为了在运行时处理异常,程序要记录大量的信息。无论执行到什么地方,程序都必须能够识别出如果在此处抛出异常的话,将要被释放哪一个对象;程序必须知道每一个入口点,以便从 try 块中退出;对于每一个 try 块,他们都必须跟踪与其相关的 catch 子句以及这些catch子句能够捕获的异常类型。这种信息的记录不是没有代价的。虽然确保程序满足异常规格不需要运行时的比较(runtime comparisons),而且当异常被抛出时也不用额外的开销来释放相关的对象和匹配正确的 catch 字句。但是异常处理确是有代价的,即使你没有使用trythrowcatch 关键字,你同样得付出一些代价。

空间开销和时间开销,对本条款所叙述的开销有了解,但是不深究具体的数量。例如:

  • 需要空间建立数据结构来跟踪对象是否被完全构造、需要 CPU 时间保持这些数据结构不断更新
  • try 块带来代码尺寸增长
  • 编译器为异常规格生成的代码
  • 抛出异常的开销(实际发生)。与一个正常的函数返回相比,通过抛出异常从函数里返回可能会慢三个数量级

Item19 理解临时对象的来源

在C++中,真正的临时对象是不可见的,即它们不出现在代码中。任何时候一个非堆的无名对象被创建,就产生临时对象。这种无名对象通常在以下两种情形下产生:

  • 为了函数调用而发生的隐式类型转换
  • 当函数返回对象时

理解这些临时对象如何跟为何产生和销毁很重要,因为它们的产生跟销毁的开销对于程序的性能来说有着不可忽视的影响。

函数调用

首先考虑为使函数成功调用而建立临时对象这种情况。当传送给函数的对象类型与参数类型不匹配时会产生这种情况。仅当通过传值(by value)方式传递对象或传递常量引用(reference-to-const)参数时,才会发生这些类型转换。当传递一个非常量引用(reference-to-non-const)参数对象,就不会发生。

//VS2017: error C2664: “void toUpperCase(std::string &)”: 无法将参数 1 从“char [6]”转换为“std::string &”
//NDK-r11c: error: invalid initialization of non-const reference of type 'std::string& {aka std::basic_string<char>&}' 
//from an rvalue of type 'char*'
void toUpperCase(std::string& s) {
	//...
}

int main() {
	char str[] = "Hello";
	toUpperCase(str);
	return 0;
}

函数返回对象

建立临时对象的第二种环境是函数返回对象时。

例如:

class Number{};
const Number operator+(const Number& lhs, const Number& rhs);

这个函数的返回值是临时的,因为它没有被命名;它只是函数的返回值。你必须为每次调用operator+构造和释放这个对象而付出代价。(参见《Effective C++》E3,为何为const,E21为何返回对象)

通常我们不想引入这个开销,对于operator+这个特定函数,可以切换到operator+=来解决(参考M22解决这类转换),但是对于多数返回对象的函数,切换到一个不同的函数不可行,也没有办法避免返回对象的构造和析构。至少从概念上无法避免。然而在概念和现实之间,有一个模糊地带叫做优化。有时你能以某种方法编写返回对象的函数,以允许你的编译器优化临时对象。这些优化中,最常见和最有效的是返回值优化(参考M20)。

Item20 返回值优化

以某种方法返回对象,能让编译器消除临时对象的开销,这样编写函数通常是很普遍的。这种技巧是返回constructor argument而不是直接返回对象。例如:

// an efficient and correct way to implement a function that returns an object
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
	return Rational(
        lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator()
    );
}

因此如果你在如下的环境里调用operator*

Rational a = 10;
Rational b(1, 2);
Rational c = a * b; // 在这里调用 operator*

编译器就会被允许消除在operator*内的临时变量和 operator*返回的临时变量。它们能在为目标 c 分配的内存里构造 return 表达式定义的对象。如果你的编译器这样去做,调用 operator*的临时对象的开销就是零:没有建立临时对象。你的代价就是调用一个构造函数――建立 c 时调用的构造函数。而且你不能比这做得更好了,因为 c 是命名对象,命名对象不能被消除(参见条款 M22[注7])。不过你还可以通过把函数声明为 inline 来消除 operator*的调用开销(不过首先参见 Effective C++ 条款 33)。

注7:In July 1996, the ISO/ANSI standardization committee declared that both named and unnamed objects may be optimized away via the return value optimization, so both versions of operator* above may now yield the same (optimized) object code.

Item21 通过重载避免隐式类型转换

考虑如下类:

class UPInt { // class for unlimited precision integers
public:
	UPInt();
	UPInt(int value);
};

// add UPInt and UPInt
const UPInt operator+(const UPInt& lhs, const UPInt& rhs);

下面这些语句:

UPInt upi1, upi2;
UPInt upi3 = upi1 + upi2;
// 以下也能运行成功
upi3 = upi1 + 10;
upi3 = 10 + upi2;

方法是通过建立临时对象把整形数 10 转换为 UPInt

让编译器完成这种类型转换是确实是很方便,但是建立临时对象进行类型转换工作是有开销的,而我们不想承担这种开销。

让我们回退一步,认识到我们的目的不是真的要进行类型转换,而是用 UPintint做为参数调用operator+。隐式类型转换只是用来达到目的的手段,但是我们不要混淆手段与目的。还有一种方法可以成功进行operator+的混合类型调用,即重载:

// add UPInt and int
const UPInt operator+(const UPInt& lhs, int rhs);

// add int and UPInt
const UPInt operator+(int lhs, const UPInt& rhs);

一旦你开始用函数重载来消除类型转换,你就有可能这样声明函数,把自己陷入危险之中:

const UPInt operator+(int lhs, int rhs); // 错误!

利用重载避免临时对象的方法不只是用在 operator 函数上。比如在大多数程序中,你想允许在所有能使用 string 对象的地方,也一样可以使用 char*,反之亦然。

不过,必须谨记 80-20 规则(参见条款 M16)。没有必要实现大量的重载函数,除非你有理由确信程序使用重载函数以后其整体效率会有显著的提高。

operator重载的限制

在 C++中有一条规则是每一个重载的 operator 必须带有一个用户定义类型(user-defined type)的参数。

//VS2017: error C2803: “operator +”必须至少有一个类类型的形参
//NDK-r11c: error: 'int operator+(int, int)' must have an argument of class or enumerated type
int operator+(int lhs, int rhs) {}

Item22 使用op=替代单独op

operator的赋值形式(如operator+=)比单独形式(如operator+)效率更高。

使用模板,只要为operator赋值形式定义某种类型,一旦需要,其对应的operator单独形式就会被自动生成。

示例:

//Item22 使用op=代替op

#include <iostream>

class Rational {
public:
	// ctor is deliberately not explicit, allows implicit int-to-Rational conversions
	Rational(int numerator = 0, int denominator = 1) :_numerator(numerator), _denominator(denominator) {
		std::cout << "Rational(int, int)\n";
	}
	int numerator() const { return _numerator; } // accessors for numerator and
	int denominator() const { return _denominator; } // denominator — see Item 22

	Rational(const Rational& other) {
		_numerator = other.numerator();
		_denominator = other.denominator();
		std::cout << "Rational(const Rational&) "<< other.numerator() <<", " << other.denominator() <<"\n";
	}
	Rational& operator+=(const Rational& rhs) {
		_numerator = this->numerator() + rhs.numerator();
		_denominator = this->denominator() + rhs.denominator();
		return *this;
	}
	Rational& operator-=(const Rational& rhs) {
		_numerator = this->numerator() - rhs.numerator();
		_denominator = this->denominator() - rhs.denominator();
		return *this;
	}

private:
	int _numerator;
	int _denominator;
};

//注意这里:T(lhs)不同编译器可能会有不同表现:1. 调用T的拷贝构造函数 2. 执行类型转换去除lhs的constness
//前者的条件下,该函数调用会有2次拷贝构造函数调用,第二次为通过相加结果拷贝构造返回对象
template <typename T>
const T operator+(const T& lhs, const T& rhs) {
	//以下两种方式差别:前者总是可以实现返回值优化,后者可能在老编译器上不受支持
#if 1
	return T(lhs) += rhs;
#else
	//named object return value optimization
	//经测试VS2017与NDK-r11c均支持
	T result(lhs);
	return result += rhs;
#endif
}

template <typename T>
const T operator-(const T& lhs, const T& rhs) {
	return T(lhs) -= rhs;
}

int main() {
	Rational l(1,4), r(2,3);
	//l += r;
	Rational t = l + r;
	return 0;
}

Item24 理解虚函数、多继承、虚基类、RTTI的开销

虚函数

When a virtual function is called, the code executed must correspond to the dynamic type of the object on which the function is invoked; the type of the pointer or reference to the object is immaterial. How can compilers provide this behavior efficiently? Most implementations use virtual tables and virtual table pointers. Virtual tables and virtual table pointers are commonly referred to as vtbls and vptrs, respectively.

注:前者关联到类,后者关联到对象。

A vtbl is usually an array of pointers to functions. (Some compilers use a form of linked list instead of an array, but the fundamental strategy is the same.) Each class in a program that declares or inherits virtual functions has its own vtbl, and the entries in a class’s vtbl are pointers to the implementations of the virtual functions for that class.

考虑如下两个类:

class C1 {
public:
    C1();
    virtual ~C1();
    virtual void f1();
    virtual int f2(char c) const;
    virtual void f3(const string& s);
    void f4() const;
};

// C2 类继承自 C1,重新定义了它继承的一些虚函数,并加入了它自己的一些虚函数
class C2: public C1 {
public:
    C2(); // 非虚函数
    virtual ~C2(); // 重定义函数
    virtual void f1(); // 重定义函数
    virtual void f5(char *str); // 新的虚函数
};

类C1 vtbl的可能实现:

在VS2017中,实现为void ** __vfptr,每个函数指针为void *

索引函数名
vbtl[0]C1::~C1
vbtl[1]C1::f1
vbtl[2]C1::f2
vbtl[3]C1::f3

Non-virtual functions — including constructors, which are by definition non-virtual — are implemented just like ordinary C functions, so there are no special performance considerations surrounding their use.

类C2 vtbl的可能实现:

索引函数名
vbtl[0]C2::~C2
vbtl[1]C2::f1
vbtl[2]C1::f2
vbtl[3]C1::f3
vbtl[4]C2::f5

这个论述引出了虚函数的第一个代价:你必须为每个包含虚函数的类的virtual talbe留出空间。类的vtbl的大小与类中声明的虚函数的数量成正比(包括从基类继承的虚函数)。每个类应该只有一个virtual table,所以virtual table所需的空间不会太大,但是如果你有大量的类或者在每个类中有大量的虚函数,你会发现vtbl会占用大量的地址空间。

每个类只需要一个 vtbl 拷贝,所以编译器肯定会遇到一个棘手的问题:把它放在哪里。编译器厂商为此分成两个阵营:

  • 提供集成开发环境(包含编译程序和连接程序)的厂商,一种干脆的方法是为每一个可能需要 vtbl 的 object 文件生成一个 vtbl 拷贝。连接程序然后去除重复的拷贝,在最后的可执行文件或程序库里就为每个 vtbl 保留一个实例。

  • 更普通的设计方法是采用启发式算法来决定哪一个object文件应该包含类的 vtbl。

    要在一个 object 文件中生成一个类的 vtbl,要求该 object 文件包含该类的第一个非内联、非纯虚拟函数(non-inline non-pure virual function)定义(也就是类的实现体)。因此上述 C1 类的 vtbl 将被放置到包含 C1::~C1 定义的 object 文件里(不是内联的函数),C2 类的 vtbl 被放置到包含 C1::~C2 定义的 object 文件里(不是内联函数)。

    实际当中,这种启发式算法效果很好。如果在类中的所有虚函数都内声明为内联函数,启发式算法就
    会失败,大多数基于启发式算法的编译器会在每个使用它的 object 文件中生成一个类的vtbl。

    解决此问题的方法是避免把虚函数声明为内联函数。下面我们将看到,有一些原因导致现在的编译器
    一般总是忽略虚函数的的 inline 指令。

Virtual table 只实现了虚拟函数的一半机制,如果只有这些是没有用的。只有用某种方法指出每个对象对应的 vtbl 时,它们才能使用。这是 virtual table pointer 的工作,它来建立这种联系。

每个声明了虚函数的对象都带有它,它是一个看不见的数据成员,指向对应类的vtbl。这个看不见的数据成员也称为 vptr,被编译器加在对象里,位置只有才编译器知道。位置通常不确定(编译器相关,细节看书),大小通常为CPU位宽。

虚函数的第二个代价是:在每个包含虚函数的类的对象里,你必须为额外的指针付出代价。

考虑这段这段代码:

void makeACall(C1 *pC1)
{
	pC1->f1();
}

编译器需要保证根据pC1所指向对象的动态类型进行正确的函数调用,生成的代码会做如下事情:

  1. 通过对象的 vptr 找到类的 vtbl。开销是偏移获取vptr和间接寻址获取vtbl。
  2. 找到所调用函数在vtbl中的指针。开销是偏移获取函数指针。
  3. 调用第2步中的函数。

假设每个对象有个隐藏成员vptr,f1在vtbl中的索引是i,那么语句pC1->f1()生成的代码是:

// call the function pointed to by the i-th entry in the vtbl pointed to by pC1->vptr; pC1 is passed to the function as the "this" pointer
(*pC1->vptr[i])(pC1);

这几乎与调用非虚函数效率一样。在大多数计算机上它多执行了很少的一些指令。调用虚函数所需的代价基本上与通过函数指针调用函数一样。虚函数本身通常不是性能的瓶颈。

在实际运行中,虚函数所需的代价与内联函数有关。实际上,虚函数不能是内联的。

这是因为“内联”是指“在编译期间用被调用的函数体本身来代替函数调用的指令”,但是虚函数的“虚”是指“直到运行时才能知道要调用的是哪一个函数”。如果编译器不知道在某个位置调用哪个函数,也就能理解为什么不能内联这个函数调用。

这是虚函数所需的第三个代价:你实际上放弃了使用内联函数。(当通过对象调用虚函数时,它可以被内联,但是大多数虚函数是通过对象的指针或引用被调用的,这种调用不能被内联。因为这种调用是标准的调用方式,所以虚函数实际上不能被内联。)

多继承、虚基类

也参见《Effective C++》 Item40 effective_cpp\Item40\main.cpp

目前为止的讨论对单继承和多继承都适用,但是多继承时,事情变得更复杂。

多继承中,在对象内获取vptr的偏移变得更复杂。一个对象内有多个vptr(每个基类一个);并且除了我们上面讨论的vtbl外,还需要为基类生成特殊的vtbl。因此,类和对象中虚函数的空间开销增加,运行时的调用开销也增加。

多继承通常导致对虚基类的需要。

多继承指的是有一个以上的基类,通常这些基类还有更高级别的(共同基类),这通常导致“菱形继承”。

虚基类指的是在多继承中,直接继承自虚基类的类,通过virtual public继承,避免在多条路径中对基类成员进行复制。虚基类可以有数据成员,但是尽量避免之。

我们现在已经看到虚函数如何导致对象变得更大,并导致不能内联,我们也已经审查过多继承和虚基类如何增加对象的大小。现在让我们进入最后的话题,运行时类型检查(RTTI)的开销。

RTTI

Run Time Type Identification(RTTI)允许我们在运行时发现对象和类的信息,因此必须有一个位置来存储这些信息让我们查询。这些信息存储在一个type_info的对象中,可以通过typeid操作符来访问一个类的type_info对象。

type_info

typeid operator 及 c++ value category

在每个类中仅仅需要一个 RTTI 的拷贝,但是必须有办法得到任何对象的类型信息。实际上这不是很准确。语言规范上这样描述:我们保证可以获得一个对象动态类型信息,只要该类型有至少一个虚函数。这使得 RTTI 数据似乎有些像virtual function table(虚函数表)。每个类我们只需要信息的一个拷贝,我们需要一种方法从任何包含虚函数的对象里获得合适的信息。这种 RTTI 和 virtual function table 之间的相似点并不是巧合:RTTI被设计为在类的 vtbl 基础上实现。

例如,vtbl数组的索引0处可能包含一个指向type_info对象的指针,该对象属于该vtbl所属的类。

C1的vtbl可能会是这样:

索引函数名
vtbl[0]C1’s type_info object
vtbl[1]C1::~C1
vtbl[2]C1::f1
vtbl[3]C1::f2
vtbl[4]C1::f3

通过以上的实现,RTTI的空间开销即为类的vtbl数组中的一个元素加上该类中type_info对象的空间。就像多数应用中,虚函数表的空间不太可能会被注意到一样,你也不太可能因为type_info对象大小而遭遇问题。

总结

下表总结了这几个概念的主要开销:

FeatureIncreases Size of ObjectsIncreases Per-Class DataReduces Inlining
Virtual FunctionsYesYesYes
Multiple InheritanceYesYesNo
Virtual Base ClassesOftenSometimesNo
RTTINoYesNo

理解虚函数、多继承、虚基类、RTTI 所需的代价是重要的,但是如果你需要这些功能,不管采取什么样的方法你都得为此付出代价,理解这点也同样重要。有时你确实有一些合理的原因要绕过编译器生成的服务。例如隐藏的 vptr 和指向虚基类的指针会使得在数据库中存储 C++对象或跨进程移动它们变得困难,所以你可能希望用某种方法模拟这些特性,能更加容易地完成这些任务。不过从效率的观点来看,你自己编写代码不可能做得比编译器生成
的代码更好。

Item25 将构造函数和非成员函数虚拟化

参考《c++必知必会》 Item29

  • 不存在真正的虚构造函数,但是生成对象的copy通常涉及到通过一个虚函数对其类的构造函数的间接调用,效果上的虚构造函数。
  • 被派生类重定义的虚拟函数不用必须与基类的虚拟函数具有一样的返回类型。如果函数的返回类型是一个指向基类的指针(或一个引用),那么派生类的函数可以返回一个指向该基类的派生类的指针(或引用)。

Because it creates new objects, it acts much like a constructor, but because it can create different types of objects, we call it a virtual constructor. A virtual constructor is a function that creates different types of objects depending on the input it is given.

就象构造函数不能真的成为虚拟函数一样,非成员函数也不能成为真正的虚拟函数(参见Effective C++条款19)。然而,既然一个函数能够构造出不同类型的新对象是可以理解的,那么同样也存在这样的非成员函数,可以根据参数的不同动态类型而其行为特性也不同。

如何重载operator <<,实现多态?

class NLComponent {
public:
	virtual ostream& print(ostream& s) const = 0;
};

class TextBlock: public NLComponent {
public:
	virtual ostream& print(ostream& s) const;
};

class Graphic: public NLComponent {
public:
	virtual ostream& print(ostream& s) const;
};

inline ostream& operator<<(ostream& s, const NLComponent& c)
{
	return c.print(s);
}

Item26 限制某个类所产生的对象数量

禁止将含有局部静态变量的函数声明为inline

内联意味着使用函数体替换函数调用,对于非成员函数,还意味着内部链接(internal linkage)。

程序中内部链接的函数会被复制,例如程序的object code可能包含内部链接的函数的多个拷贝。

除了internal linkage ,也可用anonymous namespaces实现类似效果,在其他翻译单元内不可访问

// In namespace scope or global scope.
int i; // extern by default
const int ci; // static by default
extern const int eci; // explicitly extern
static int si; // explicitly static

// The same goes for functions (but there are no const functions).
int f(); // extern by default
static int sf(); // explicitly static 

// anonymous namespaces
namespace {
  int i; // extern by default but unreachable from other translation units
  class C; // extern by default but unreachable from other translation units
}

该部分关键词:

  • singleton的两种写法:经典静态成员函数,友元函数(非静态)

  • Objects Counting实现

  • translation unit之static对象初始化顺序,参考《Effective C++》Item4 P30

Item27 要求或禁止在堆中产生对象

要求在堆上创建对象

非堆对象在定义它的地方被自动构造,在生存时间结束时自动被释放,所以只要禁止使用隐式的构造函数和析构函数,就可以实现这种限制。

把这些调用变得不合法的一种最直接的方法是把构造函数和析构函数声明为 private。这样做副作用太大。没有理由让这两个函数都是 private。最好让析构函数成为 private,让构造函数成为 public。处理过程与条款 26 相似,你可以引进一个专用的伪析构函数,用来访问真正的析构函数。客户端调用伪析构函数释放他们建立的对象。(WQ 加注:注意,异常处理体系要求所有在栈中的对象的析构函数必须声明为公有!)

class UPNumber {
public:
	UPNumber();
	UPNumber(int initValue);
	UPNumber(double initValue);
	UPNumber(const UPNumber& rhs);
	// 伪析构函数 (一个 const 成员函数, 因为即使是 const 对象也能被释放。)
	void destroy() const { delete this; }
private:
	~UPNumber();
};

//UPNumber n; // 错误!
int main() {
	//UPNumber n; // 错误! (在这里合法, 但是当它的析构函数被隐式地调用时,就不合法了)
	UPNumber *p = new UPNumber; //正确
	//delete p; // 错误! 试图调用private 析构函数
	p->destroy();
}

通过限制访问一个类的析构函数或它的构造函数来阻止建立非堆对象,但是在Item26已经说过,这种方法也禁止了继承和包含(containment)。这些困难不是不能克服的。

通过把UPNumber的析构函数声明为protected(造函数还保持public)就可以解决继承的问题,需要包含UPNumber对象的类可以修改为包含指向UPNumber的指针。

判断一个对象是否在堆中

两种行不通的方式
  • operator new/new[]修改标记,构造函数中判断标记

限制1:new UPNumber [N]形式,一次new[],多次构造函数调用

限制2:UPNumber *pn = new UPNumber(*new UPNumber) 无法保证newctor的顺序

  • 基于原理:程序的地址空间被做为线性地址管理,程序的栈从地址空间的顶部向下扩展,堆则从底部向上扩展
bool onHeap(const void *address)
{
	char onTheStack; // 局部栈变量
	return address < &onTheStack;
}

限制:未考虑static对象(explicit static/global scope/namespace scope),它们不在栈上也不在堆上。

它们的位置是依据系统而定的,但是在很多栈和堆相向扩展的系统里,它们位于堆的底端。故不能辨别堆对象与静态对象的区别。

参见书中的两幅图。

VS2017实测,均不能判断,详见代码:

void main()
{
	char *pc = new char; // 堆对象: onHeap(pc)将返回 true
	char c; // 栈对象: onHeap(&c)将返回 false
	static char sc; // 静态对象: onHeap(&sc)将返回 true

	bool b1 = onHeap(pc);
	bool b2 = onHeap(&c);
	bool b3 = onHeap(&sc);
}
进一步思考

如果你发现自己实在为对象是否在堆中这个问题所困扰,一个可能的原因是你想知道对象是否能在其上安全调用 delete。这种删除经常采用“delete this”这种声明狼籍的形式。不过知道“是否能安全删除一个指针”与“只简单地知道一个指针是否指向堆中的事物”不一样,因为不是所有在堆中的事物都能被安全地 delete。再考虑包含 UPNumber 对象的 Asset对象:

class Asset {
private:
	UPNumber value;
};
Asset *pa = new Asset;

很明显*pa(包括它的成员value)在堆上。同样很明显在指向pa->value上调用delete是不安全的,因为该指针不是被new返回的。

幸运的是“判断是否能够删除一个指针”比“判断一个指针指向的事物是否在堆上”要容易。

我们希望这些函数提供这些功能时能够不污染全局命名空间,没有额外的开销,没有正确性问题。幸运的是 C++使用一种抽象 mixin 基类满足了我们的需要。

使用抽象基类的实现

抽象基类是不能被实例化的基类,也就是至少具有一个纯虚函数的基类。mixin( “mix in”)类提供某一特定的功能,并可以与其继承类提供的其它功能相兼容(参见 Effective C++条款 7)。这种类几乎都是抽象类。因此我们能够使用抽象混合(mixin)基类给派生类提供判断指针指向的内存是否由operator new分配的能力。该类如下:


#include <iostream>
#include <list>
using std::list;

class HeapTracked {
public:
	class MissingAddress{};
	//定义纯虚析构函数,成为抽象类,virtual使之成为多态类型,查看isOnHeap()中dynamic_cast
	virtual ~HeapTracked() = 0;
	//~HeapTracked();
	static void * operator new(size_t size);
	static void operator delete(void * mem);
	bool isOnHeap() const;

private:
	typedef const void * RawAddress;
	static list<RawAddress> addresses;
};

//非const静态变量提供定义,注意类型嵌套
list<HeapTracked::RawAddress> HeapTracked::addresses;

//析构函数必须被定义,提供空定义
HeapTracked::~HeapTracked(){}

void * HeapTracked::operator new(size_t size) {
	void * addr = ::operator new(size);
	//addresses.push_back(addr);
	addresses.push_front(addr);
	return addr;
}

void HeapTracked::operator delete(void * mem) {
	auto it = std::find(addresses.begin(), addresses.end(),mem);
	if (it != addresses.end()) {
		::operator delete(mem);
		addresses.erase(it);
	}
	else
	{
		throw MissingAddress();
	}
}

bool HeapTracked::isOnHeap() const {
	//运行时dynamic_cast必须为多态类型,
	RawAddress addr = dynamic_cast<RawAddress>(this);
	auto it = std::find(addresses.begin(), addresses.end(), addr);
	return it != addresses.end();
}

class Asset : public HeapTracked {

};


static Asset gs_asset;

int main() {
	//HeapTracked hc;//抽象类不允许创建对象

	static Asset s_asset;
	Asset asset;
	HeapTracked *p_asset = new Asset;

	std::cout << "gs_asset.isOnHeap() " << gs_asset.isOnHeap() <<"\n";
	std::cout << "s_asset.isOnHeap() " << s_asset.isOnHeap() <<"\n";
	std::cout << "asset.isOnHeap() " << asset.isOnHeap() <<"\n";
	std::cout << "p_asset->isOnHeap() " << p_asset->isOnHeap() <<"\n";

	delete p_asset;
	//delete &asset;//unexpected exception
	return 0;
}

禁止堆对象

判断对象是否在堆中的测试到现在就结束了。与此相反的领域是“禁止在堆中建立对象”。通常对象的建立这样三种情况:

  • 对象被直接实例化;
  • 对象做为派生类的基类被实例化;
  • 对象被嵌入到其它对象内。

operator new 声明为 private 就足够了,但是把 operator new声明为 private,而把 operator delete 声明为 public,这样做有些怪异,所以除非有绝对需要的原因,否则不要把它们分开声明,最好在类的一个部分里声明它们。如果你也想禁止 UPNumber 堆对象数组,可以把 operator new[]operator delete[](参见条款 M8)也声明为 private。

有趣的是,把 operator new 声明为 private 经常会阻碍 UPNumber 对象做为一个位于堆中的派生类对象的基类被实例化。因为 operator newoperator delete 是自动继承的,如果 operator newoperator delete 没有在派生类中被声明为 public(进行重写,override),它们就会继承基类中 private 的版本。

UPNumber operator new是 private 这一点,不会对包含 UPNumber 成员对象的对象的分配产生任何影响。

Item28 智能指针

手册

另参见《c++必知必会》 Item42 智能指针

dynamic memory management on cppreference.com

智能指针的构造、赋值和析构

以STL的auto_ptr为例:

  • 为何不允许常规的复制拷贝和复制赋值?造成多个auto_ptr指向同一个对象,多次delete导致undefined
  • 复制拷贝和复制赋值为成员模板,且实现所有权转移。参数类型非const,会修改源,删除源中的对象。
  • 函数参数通过auto_ptr<T>值传递的结果。函数执行后,源被删除。几乎不存在这种需求,故使用const auto_ptr<T> &

实现解引用操作符

auto_ptr

  • T& auto_ptr<T> operator*() const返回值为引用类型,保证高效性和正确性。如果返回object,在指向T的派生类时,会返回错误对象,导致slicing problem,即不支持virtual多态行为。

  • T * auto_ptr<T>operator->() const 返回值为dumb pointer

Testing Smart Pointers for Nullness

背景,像使用原始指针那样对智能指针进行判断,如:

auto_ptr<TreeNode> ptn;
if (ptn == 0) ... // error!
if (ptn) ... // error!
if (!ptn) ... // error!

有两种实现方式,注意优劣比较。

iostream library implementations provide an operator! in addition to the implicit conversion to void* , but these two functions typically test for slightly different stream states.

重载隐式类型转换操作符void *

auto_ptr<T> operator void*()可以解决上述需求,但是带来了一个问题:就是不同类型的智能指针之间的判断,如:

auto_ptr<Apple> pa;
auto_ptr<Orange> po;
if (pa == po) ...

因为内建指针可以进行比较,这种行为使重载隐式类型转换操作符变得很危险。

甚至变种类型,但是均不能解决不同类型间比较的问题:

auto_ptr<T> operator const void*();
auto_ptr<T> operator bool();
重载隐式类型转换操作符!

auto_ptr<T> operator !()可以得到如下效果:

`auto_ptr<TreeNode> ptn;
...
if (!ptn) { // fine
... // ptn is null
}
else {
... // ptn is not null
}

但是不能这样:

if (ptn == 0) ... // still an error
if (ptn) ... // also an error

不同类型比较的风险:

auto_ptr<Apple> pa;
auto_ptr<Orange> po;
if (!pa == !po) ...

当然,通常不会写出这样的代码。

Converting Smart Pointers to Dumb Pointers

The bottom line is simple: don’t provide implicit conversion operators to dumb pointers unless there is a compelling reason to do so.

背景,在一个使用dumb pointer的程序中,加入智能指针,达到混用的效果,如:

class Tuple { ... }; // as before
void normalize(Tuple *pt); // put *pt into canonical form; note use of dumb pointer

无法做到像使用dumb pointer那样使用智能指针:

auto_ptr<Tuple> pt;
normalize(&*pt); // gross, but legal

定义向T *的隐式类型转换操作符:

auto_ptr<T>:: operator T*(){
    return pointee;
}

现在可以:

auto_ptr<Tuple> pt;
normalize(pt); // this now works

也能解决了非空判断问题:

if (pt == 0) ... // fine, converts pt to a Tuple*
if (pt) ... // ditto
if (!pt) ... // ditto (reprise)

但是也带来一个问题,就是让使用者轻易地使用dumb pointer,从而忽视了智能指针带来的特性,通常智能指针的行为是程序设计的重要组件,这将带来灾难。尤其是像Item29中那样,假如智能指针实现了引用计数的策略,将破坏引用计数的数据结构:

void processTuple(auto_ptr<Tuple>& pt)
{
	Tuple *rawTuplePtr = pt; // converts DBPtr<Tuple> to Tuple*
	//use rawTuplePtr to modify the tuple;
}

更有甚,即使定义了隐式类型转换操作符,智能指针也不会真正地跟dumb pointer可交换。因为从smart pointer到dumb pointer的转换是用户自定义转换,编译器一次只允许执行一次转换,例如:

class TupleAccessors {
public:
	TupleAccessors(const Tuple *pt); // pt identifies the tuple whose accessors
}; 

TupleAccessors的单参数构造函数也扮演了从Tuple*TupleAccessors*的类型转换操作符。考虑下面函数:

TupleAccessors merge(const TupleAccessors& ta1, const TupleAccessors& ta2);

由于Tuple*可以隐式转换为TupleAccessors*,使用dumb pointer调用merge函数是OK的:

Tuple *pt1, *pt2;
merge(pt1, pt2); // fine, both pointers are converted to TupleAccessors objects

使用智能指针,就无法编译:

auto_ptr<Tuple> pt1, pt2;
merge(pt1, pt2); // error! No way to convert pt1 and pt2 to TupleAccessors objects

因为从auto_ptr<Tuple>TupleAccessors的转换,需要两次用户自定义转换:一次auto_ptr<Tuple>Tuple*,一次Tuple*TupleAccessors,语言本身不允许这样的转换。

此外,还会带来另外的特殊bug:

auto_ptr<Tuple> pt;// = new Tuple;//这一行无法编译
delete pt;

回忆Item5所说,编译器会调用隐式类型转换操作符来尽可能使得函数调用成功。Item8中讲到,使用delete操作符会导致对析构函数和operator delete的调用,这二者都是函数。

编译器想要这些函数能够成功调用,所以在以上的delete语句中,编译器隐式地将pt转换为Tuple*,然后删除它。这将破坏你的程序(本来是你写错的代码,却编译过了):

  • case1. pt拥有对象的所有权,导致删除2次:一次delete语句,一次pt析构;
  • case2. pt没有对象的所有权,而是某个人拥有,那个人同时负责删除pt,这种情况下还好;
  • case3. pt指向对象的拥有者不是删除pt的人,可以预料到拥有者后面还会再次删除那个对象。

第1和3中,都会导致对象被删除2次,导致undefined行为。

这个bug极为有害,隐藏在智能指针后的全部思想是让它们看起来跟dumb pointer尽可能地相似。你越接近这种思想,你的客户就越容易忘记他们正在使用智能指针。如果他们这样做了,谁又能责备他们为了防止资源泄露,他们必须在调用new之后调用delete呢?

智能指针和基于继承的类型转换

另参见《Effective C++》Item45 成员模板函数(泛化的拷贝构造和拷贝赋值)

本节先以人为特化到基类为例进行转换,带来缺点:1. 必须人为特化auto_ptr类,破坏模板通用性 2. 可能必须添加很多类型转换符,因为你指向的对象可以位于继承层次中很深的位置,你必须为直接或间接继承的每一个基类
提供一个类型转换符。例如:

class auto_ptr<Cassette> {
public:
    operator auto_ptr<MusicProduct>(){ 
        return SmartPtr<MusicProduct>(pointee);
    }
private:
	Cassette * pointee;
};

后采用隐式类型转换操作符实现:

template <typename T>
class auto_ptr {
public:

	auto_ptr(T * p = 0) : pointee(p) {}

	template <typename U>
	operator auto_ptr<U>();
#if 0 //类内定义
	template <typename U>
	operator auto_ptr<U>() {
		return auto_ptr<U>(pointee);
	}
#endif
private:
	T * pointee;
};

//类外定义
template <typename T>
template <typename U>
auto_ptr<T>:: operator auto_ptr<U>() {
	return auto_ptr<U>(pointee);
}

与使用泛化构造拷贝函数形式相同,该隐式类型转换操作符可以在任意“可执行隐式类型转换”的指针类型之间进行转换。当且仅当T1*可以隐式转换为T2*时,smart pointer-to-T1 可以转换为 smart pointer-to-T2。二者均可在发生函数调用时,促进编译器完成隐式类型转换。

当有以下的继承关系时,可以进行如下的函数调用:

特别注意,类型转换发生的条件,限定const reference或传值,参见理解Item19临时对象的来源–函数调用

class MusicProduct {
public:
	MusicProduct(const string& title) {}
	virtual void play() const = 0;
	virtual void displayTitle() const = 0;
};

class Cassette : public MusicProduct {
public:
	Cassette(const string& title) : MusicProduct(title) {};
	virtual void play() const {};
	virtual void displayTitle() const {};
};

class CD : public MusicProduct {
public:
	CD(const string& title) : MusicProduct(title) {};
	virtual void play() const {};
	virtual void displayTitle() const {};
};

//注意const,无const时无法完成转换
void displayAndPlay(const auto_ptr<MusicProduct>& pmp, int numTimes);

//测试程序
int main(){
    auto_ptr<Cassette> funMusic(new Cassette("Alapalooza"));
    auto_ptr<CD> nightmareMusic(new CD("Disco Hits of the 70s"));
    displayAndPlay(funMusic, 1);//OK to compile
    displayAndPlay(csMusic, 1); //OK to compile
    return 0;
}
重载限制

但是当情形变得复杂,看如下代码:

//其他保持不变
...

//增加一个新的类
class CasSingle : public Cassette {
public:
	CasSingle(const string& title) : Cassette(title) {};
	virtual void play() const {};
	virtual void displayTitle() const {};
};

void displayAndPlay(const auto_ptr<MusicProduct>& pmp, int numTimes) {}
//定义重载函数以欲达到dumb pointer的效果:精确匹配
void displayAndPlay(const , int howMany) {}

int main() {
	auto_ptr<Cassette> funMusic(new Cassette("Alapalooza"));
	auto_ptr<CD> nightmareMusic(new CD("Disco Hits of the 70s"));
	auto_ptr<CasSingle> csMusic(new CasSingle("Disco Hits of the 70s"));
    
	displayAndPlay(funMusic, 1);
	displayAndPlay(csMusic, 1);//error C2668: “displayAndPlay”: 对重载函数的调用不明确
	//displayAndPlay(nightmareMusic, 1);//ditto
	return 0;
}

因为CasSingle直接继承自Cassette,我们期望下方重载的版本被调用,就像dumb pointer表现的那样。但是智能指针并不是如此,这里导致二义性,编译失败!

因为从auto_ptr<CasSingle>auto_ptr<Cassette>的转换并不比向auto_ptr<MusicProduct>的转换更合适,两个转换函数同样合适。

小结

通过成员模板完成智能指针转换有2个缺陷:

  • 支持成员模板的编译器少,导致移植性查;
  • 种方法的工作原理不明了,要理解它需要对以下知识有清晰的理解:
    • 函数调用的参数匹配规格
    • 隐式类型转换函数
    • 模板函数的隐式特化
    • 成员模板函数的依赖

我们想知道,到底如何才能让智能指针类表现的像普通指针那样用于基于继承关系的类型转换,答案是没有。智能指针很智能,但它们不是指针。我们能做到最好就是用成员模板去生成转换函数,然后在产生二义性的地方使用类型转换(Item2)。

这不是一个完美的方法,不过已经很不错了,在一些情况下需去除二义性,所付出的代价与智能指针提供复杂的功能相比还是值得的。

智能指针和 const

就像原始指针有4种组合一样,对应的智能指针也有4种组合:

SmartPtr<CD> p; // non-const object, non-const pointer
SmartPtr<const CD> p; // const object, non-const pointer
const SmartPtr<CD> p = &goodCD; // non-const object, const pointer
const SmartPtr<const CD> p = &goodCD; // const object, const pointer

使用原始指针,可以用non-const指针初始化const指针,也可以用指向non-const对象的指针初始化指向const对象的指针;赋值的规则也是一样。

但是对于智能指针auto_ptr<CD>auto_ptr<const CD>,编译器认为它们是完全不同的类,毫无关系。

不过如上节所述,利用成员模板支持:只要对应的原始指针可以执行隐式类型转换,就可以完成:

int main() {
	auto_ptr<CD> pCD = new CD("jay");
	auto_ptr<const CD> pConstCD = pCD;//OK
}

涉及到const的转换都是单向的:从non-const转换到const是安全的,但是反过来不行。深入一些,任何可以对const指针做的操作都适用于non-const,但是对于non-const指针可以做其他操作(例如:赋值)。

相似的,任何可以对pointer-to-const的操作也都适用于pointer-to-non-const,但是后者可以做前者不能做的操作(例如:赋值)。

这些规则看起来与public继承的规则相类似(Effective C++ 条款 35)。你能够把派生类对象转换成基类对象,但是反之则不行,你对基类所做的任何事情对派生类也能做,但是还能对派生类做另外一些事情。我们能够利用这一点来实现智能指针,就是说可以让每个指向T的智能指针类public派生自一个对应的指向 const-T 的智能指针类:

template<class T> // smart pointers to const objects
class SmartPtrToConst { // the usual smart pointer member functions
protected:
	union {
		const T* constPointee;  // for SmartPtrToConst access
		T* pointee;				// for SmartPtr access
	};
public:
	SmartPtrToConst(const T * ct = 0) :constPointee(ct) {}
};

template<class T> // smart pointers to non-const objects
class SmartPtr : public SmartPtrToConst<T> {
	// no data members
public:
	//NDK-r11c: error: class 'SmartPtr<T>' does not have any field named 'pointee'
	//VS2017: error C2614: “SmartPtr<CD>”: 非法的成员初始化:“pointee”不是基或成员
	//SmartPtr(T * t = 0) : pointee(t) {}
	SmartPtr(T * t = 0) : SmartPtrToConst(t) {}
};

struct CD{};

int main() {
	SmartPtr<CD> pCD = new CD;
	SmartPtrToConst<CD> pConstCD = pCD; // fine

	size_t sz1 = sizeof(pCD);//8 on win64
	size_t sz2 = sizeof(pConstCD);//8 on win64
	return 0;
}

使用这种设计方法,指向non-const-T对象的智能指针包含一个指向const-T的dumb指针,指向const-T的智能指针需要包含一个指向cosnt-T的dumb指针。最方便的方法是把指向const-T的dumb指针放在基类里,把指向non-const-T的dumb指针放在派生类里,然而这样做有些浪费,因为SmartPtr对象包含两个dumb指针:一个是从SmartPtrToConst继承来的,一个是SmartPtr自己的。

适用union可以解决这个问题,在基类中设置为protected,所以两个类都可以访问它,它包含两个必须的dumb指针类型,SmartPtrToConst<T>对象使用constPointee指针,SmartPtr<T>对象使用pointee指针。因此我们可以在不分配额外空间的情况下,使用两个不同的指针(参见Effective C++条款 10中另外一个例子)这就是union美丽的地方。当然两个类的成员函数必须约束它们自己仅仅使用适合的指针。这是使用union所冒的风险。

利用这种新设计,不使用成员模板方式,我们也能够获得所要的行为特性。

附:一个联合用法

总结

智能指针值得如此麻烦吗?尤其是你的编译器不支持成员模板时候。

通常是值得的。举个例子,使用智能指针能够极大简化Item29中引用计数的代码。并且,如例子所说,一些智能指针的使用场景非常受限,如:

  • 非空测试
  • 转换为原始指针
  • 基于继承的类型转换
  • pointers-to-const的支持

同时智能指针的实现、理解和维护需要大量的技巧。调试使用智能指针的代码也比调试使用 dumb 指针的代码困难。无论如何你也不可能设计出一种通用目的的智能指针,能够替代 dumb 指针。

使用智能指针能够让一些使用其他方式很难实现的效果变得可能,但是智能指针应该谨慎使用。

Item29 引用计数

主要概念:

  • 实现引用计数:需要一个地方来存储这个计数值

    这个地方不能在String对象内部,因为需要的是每个String值一个引用计数值,而不是每个String对象一个引用计数。这意味着String值和引用计数间是一一对应的关系,所以我们将创建一个类来保存引用计数及其跟踪的值。我们叫这个类StringValue,又因为它唯一的用处就是帮助我们实现String类,所以我们将它嵌套在String类的私有区内。

  • copy-on-write(写时拷贝):与其它对象共享一个值直到写操作时才拥有自己的拷贝。这事实现高性能的通用方式的一个具体示例——懒惰计算。

  • 解决非const版本char & String:: operator[](int index)可能修改(或通过指针指向此函数返回值来修改)导致不能被共享:唯一修改shareable的位置——此函数

    class String {
    private:
        struct StringValue {
            int refCount;
            bool shareable; // add this
            char *data;
            StringValue(const char *initValue);
            ~StringValue();
        };
    };
    
  • 确保带引用计数的基类其对象只能创建在堆上?确保只有一些类能够创建其对象

  • 使用引用计数可能存在环状引用问题,导致计数无法清零

  • 基类构造函数调用规则:只有无参构造函数才会被默认调用,其他任意形式需要在初始化列表中显式调用。并非子类拷贝构造即调用基类拷贝构造。

Item30 代理类

实现多维数组

在c++中的二维数组,不可以使用变量作为维度的大小。因此,可以定义一个类模板来实现二维数组:

template <typename T>
class Array2D {
public:
    class Array1D {
    public:
        T& operator[](int index);
        const T& operator[](int index) const;
    };
    Array1D operator[](int index);
    const Array1D operator[](int index) const;
    Array2D(int dim1, int dim2);
};

以上使用代理(surrogate)类Array1D,来达到类似数组索引的目的:cout << arr[3][2]。注意:c++中没有operator[][]这样的操作符重载,降级方案重载operator()(int,int),但是使用起来不够直觉:cout << a(2,3),像是函数调用。

区分operator[]是读操作还是写操作

Item29中通过是否包含const进行重载,但是实际不能解决问题,因为编译器根据调用成员函数的对象的const属性来选择此成员函数的const和非const版本:

class String {
public:
    const char& operator[](int index) const; // for reads
    char& operator[](int index); // for writes
};

int main(){
    String s1, s2;
    cout << s1[5]; // calls non-const operator[], because s1 isn't const
    s2[5] = 'x'; // also calls non-const operator[]: s2 isn't const
    s1[3] = s2[8]; // both calls are to non-const operator[], because both s1
}

也许不可能在 operator[]内部区分左值还是右值操作,但我们仍然能区别对待读操作和写操作,如果我们将判断读还是写的行为推迟到我们知道operator[]的结果被怎么使用之后的话。我们所需要的是有一个方法将读或写的判断推迟到operator[]返回之后。

代码实现参见LearnCpp\more_effective_cpp\item30\main.cpp

局限性

  • To make proxies behave like the objects they stand for, you must overload each function applicable to the real objects so it applies to proxies, too.——需要为代理类与增加实际类的相同(功能)成员函数

    class Rational {
    public:
        Rational(int numerator = 0, int denominator = 1);
        int numerator() const;
        int denominator() const;
    };
    Array<Rational> array;
    //这是我们所期望的使用方式,但我们很失望:
    cout << array[4].numerator(); // error!
    int denom = array[22].denominator(); // error!
    

    Rational的代理类没有numerator()denominator()函数

  • Yet another situation in which proxies fail to replace real objects is when being passed to functions that take references to non-const objects.——不能替代作为函数参数的非const引用类型实际对象

    void swap(char& a, char& b); // swaps the value of a and b
    String s = "+C+"; // oops, should be "C++"
    swap(s[0], s[1]); // this should fix the problem, but it won't compile
    

    String::operator[]返回一个 CharProxy 对象,但swap()函数要求它所参数是char &类型。一个 CharProxy对象可以隐式地转换为一个char,但没有转换为char &的转换函数。而它可能转换成的 char 并不能成为swapchar &参数,因为这个char是一个临时对象(它是operator char()的返回值),根据 Item M19 的解释,拒绝将临时对象绑定为非const的引用的形参是有道理的。

  • A final way in which proxies fail to seamlessly replace real objects has to do with implicit type conversions.——限制隐式类型转换

    class TVStation {
    public:
    	TVStation(int channel);
    };
    void watchTV(const TVStation& station, float hoursToWatch);
    
    //OK. implicit type conversion int to TVStation
    watchTV(10, 2.5); // watch channel 10 for 2.5 hours
    
    Array<int> intArray;
    intArray[4] = 10;
    watchTV(intArray[4], 2.5); // error! no conversion from Proxy<int> to TVStation
    

    实际上好的做法是,将TVStation(int channel)声明为explicit以禁止所有转换。

  • 作为函数返回值,Proxy对象是临时对象(见 Item 19),它们必须被构造和析构。Proxy对象的存在增加了软件的复杂度,因为额外增加的类使得事情更难设计、实现、理解和维护。

  • 最后,从一个处理实际对象的类改换到处理Proxy对象的类经常改变了类的语义,因为Proxy对象通常表现出的行为与实际对象有些微妙的区别。

Item31 让函数根据一个以上的对象来决定如何虚拟

A call that’s virtual on two parameters is implemented through a double-dispatching. The generalization of this — a function acting virtual on several parameters — is called multiple dispatch.

virtual + RTTI

引入虚函数的主要原因:将产生和维护类型相关的函数调用的担子由程序员转给编译器。当我们用RTTI实现二重调度时,我们正退回到过去的苦日子中。

// if we collide with an object of unknown type, we throw an exception of this type:
class CollisionWithUnknownObject {
public:
    CollisionWithUnknownObject(GameObject& whatWeHit);
};
void SpaceShip::collide(GameObject& otherObject)
{
    const type_info& objectType = typeid(otherObject);
    if (objectType == typeid(SpaceShip)) {
        SpaceShip& ss = static_cast<SpaceShip&>(otherObject);
        //process a SpaceShip-SpaceShip collision;
    }
    else if (objectType == typeid(SpaceStation)) {
        SpaceStation& ss = static_cast<SpaceStation&>(otherObject);
        //process a SpaceShip-SpaceStation collision;
    }
    else if (objectType == typeid(Asteroid)) {
        Asteroid& a = static_cast<Asteroid&>(otherObject);
        //process a SpaceShip-Asteroid collision;
    }
    else {
    	throw CollisionWithUnknownObject(otherObject);
    }
}

只使用虚函数

class SpaceShip; // forward declarations
class SpaceStation;
class Asteroid;
class GameObject {
public:
    virtual void collide(GameObject& otherObject) = 0;
    virtual void collide(SpaceShip& otherObject) = 0;
    virtual void collide(SpaceStation& otherObject) = 0;
    virtual void collide(Asteroid& otherobject) = 0;
};

class SpaceShip: public GameObject {
public:
    virtual void collide(GameObject& otherObject);
    virtual void collide(SpaceShip& otherObject);
    virtual void collide(SpaceStation& otherObject);
    virtual void collide(Asteroid& otherobject);
};
//其他类实现同上

其基本原理就是用两个单一调度实现二重调度,也就是说有两个单独的虚函数调用:第一次决定第一个对象的动态类型,第二次决定第二个对象动态类型。和前面一样,第一次虚函数调用带的是GameObject类型的参数。以SpaceShip中的实现为例:

//第一次
void SpaceShip::collide(GameObject& otherObject)
{
    //第二次?
	otherObject.collide(*this);
}

一眼看上去,这似乎仅仅是对collide的递归调用,只是参数的顺序颠倒了,即otherObject变成了成员函数collide的调用者,*this变成了collide函数的参数。

仔细看,这不是递归调用。我们知道,编译器根据传递给函数参数的静态类型来从一组函数中选择调用哪一个函数。在本例中,有四个不同的collide函数可能被调用,但是根据*this的静态来决定调用哪一个。它的静态类型是什么?作为SpaceShip类中的成员函数,*this类型一定是SpaceShip。因此对collide的调用,会选择SpaceShip&参数的那个,而不是GameObject&

所有的collide函数都是虚函数,所以SpaceShip::collide中的collide调用会选择otherObject的真实类型(动态类型)中实现的collide版本。在那个collide实现中,两个对象的真实类型都已确定,因为左边(otherObject)对象是*this,右边(实参)对象真实类型是SpaceShip,跟声明的形参类型相同。

你看到了,一点都不混乱,也不麻烦,没有RTTI,也不需要为意料之外的对象类型抛异常。不会有意料之外的类型的,这就是使用虚函数的好处。实际上,没有下面的致命缺陷的话,它就是实现二重调度问题的完美解决方案。

这个缺陷是,和前面看到的RTTI方法一样:每个类都必须知道它的同胞类。当增加新类时,所有的代码都必须更新。不过,更新方法和前面不一样。确实,没有 if…then…else需要修改,但通常是更差:每个类都需要增加一个新的虚函数。

修改现存类经常是你做不到的。比如,你不是在写整个游戏,只是在完成程序框架下的一个支撑库,你可能无权修改GameObject类或从其经常的框架类。此时,增加一个新的成员函数(虚的或不虚的),都是不可能的。也就说,你理论上有操作需要被修改的类的权限,但实际上没有。打个比方,你受雇于 Nitendo,使用一个包含GameObject和其它需要的类的运行库进行编程。当然不是只有你一个人在使用这个库,全公司都将震动于每次你决定在你的代码中增加一个新类型时,所有的程序都需要重新编译。实际中,广被使用的库极少被修改,因为重新编译所有用了这个库的程序的代价太大了。(参见 Item M34,以了解怎么设计将编译依赖度降到最低的运行库。)

总结一下就是:如果你需要实现二重调度,最好的办法是修改设计以取消这个需要。如果做不到的话,虚函数的方法比 RTTI 的方法安全,但它限制了你的程序的可控制性(取决于你是否有权修改头文件)。另一方面,RTTI的方法不需要重编译,但通常会导致代码无法维护。

模拟虚函数表

GameObjcet继承体系中的函数作一些修改:

class GameObject {
public:
	virtual void collide(GameObject& otherObject) = 0;
};

class SpaceShip: public GameObject {
public:
    virtual void collide(GameObject& otherObject);
    // 参考下一小节,these functions will be made to take a GameObject& parameter
    virtual void hitSpaceShip(SpaceShip& otherObject);
    virtual void hitSpaceStation(SpaceStation& otherObject);
    virtual void hitAsteroid(Asteroid& otherobject);
};

和开始时使用的基于RTTI的方法相似,GameObjcet类只有一个处理碰撞的函数,它实现必须的二重调度的第一重。和后来的基于虚函数的方法相似,每种碰撞都由一个独立的函数处理,不过不同的是,这次,这些函数有着不同的名字,而不是都叫collide。放弃重载是有原因的,你很快就要见到的。注意,上面的设计中,有了所有其它需要的东西,除了没有实现Spaceship::collide(这是不同的碰撞函数被调用的地方)。和以前一样,实现了SpaceShip类,SpaceStation类和Asteroid类也就出来了。

SpaceShip::collide中,我们需要一个方法来映射参数otherObject的动态类型到一个成员函数指针(指向一个适当的碰撞处理函数)。一个简单的方法是创建一个映射表,给定的类名对应恰当的成员函数指针。直接使用一个这样的映射表来实现collide是可行的,但如果增加一个中间函数lookup时,将更好理解。lookup函数接受一个GameObject参数,返回相应的成员函数指针:

class SpaceShip: public GameObject {
private:
    //成员函数指针
    typedef void (SpaceShip::*HitFunctionPtr)(GameObject&);
    static HitFunctionPtr lookup(const GameObject& whatWeHit);
    typedef std::map<string, HitFunctionPtr> HitMap;
    static HitMap * initializeCollisionMap();
};

void SpaceShip::collide(GameObject& otherObject)
{
    HitFunctionPtr hfp = lookup(otherObject); 
 	// find the function to call if a function was found
    if (hfp) {
    	(this->*hfp)(otherObject); // call it
    }
    else {
        //如果我们能保持映射表和GameObject的继承层次的同步,lookup就总能找到传入对象
		//对应的有效函数指针。为避免犯错,仍然检查lookup的返回值并在其失败时抛异常
   		throw CollisionWithUnknownObject(otherObject);
}

实现lookup。提供了一个对象类型到成员函数指针的映射表后,lookup自己很容易实现,但创建、初始化和析构这个映射表是个有意思的问题。

这样的数组应该在它被使用前构造和初始化,并在不再被需要时析构。让编译器自动完成,在lookup中把这个数组声明为静态:

SpaceShip::HitFunctionPtr SpaceShip::lookup(const GameObject& whatWeHit)
{
    //initializeCollisionMap通过返回指针避免值拷贝,并用智能指针自动delete
    static auto_ptr<HitMap> collisionMap(initializeCollisionMap());
    // look up the collision-processing function for the type
    // of whatWeHit. The value returned is a pointer-like
    // object called an "iterator" (see Item 35).
	HitMap::iterator mapEntry = collisionMap.find(typeid(whatWeHit).name());
    
	// mapEntry == collisionMap.end() if the lookup failed;
	// this is standard map behavior. Again, see Item 35.
	if (mapEntry == collisionMap.end()) return 0;
    
    // If we get here, the search succeeded. mapEntry
    // points to a complete map entry, which is a
    // (string, HitFunctionPtr) pair. We want only the
    // second part of the pair, so that's what we return.
    //最后一句是 return (*mapEntry).second 而不是习惯上的mapEntry->second以满足
	//STL 的奇怪行为。具体原因见 Item M18?
	return (*mapEntry).second;
}

初始化模拟虚函数表(成员函数指针的地址问题)

初始化map数组时,这不能编译通过。因为HitMap被声明为包容一堆指向成员函数的指针,它们全带同样的参数类型,也就是GameObject

SpaceShip::HitMap * SpaceShip::initializeCollisionMap()
{
    HitMap *phm = new HitMap;
    (*phm)["SpaceShip"] = &hitSpaceShip;
    (*phm)["SpaceStation"] = &hitSpaceStation;
    (*phm)["Asteroid"] = &hitAsteroid;
    return phm;
}

派生类到基类可以转换,但对带这些参数类型的函数指针可没有这样的转换关系。

尝试reinterpret_cast,而它在函数指针的类型转换中通常是被舍弃的,可以编译通过,但是不可取

SpaceShip::HitMap * SpaceShip::initializeCollisionMap()
{
	HitMap *phm = new HitMap;
    (*phm)["SpaceShip"] = reinterpret_cast<HitFunctionPtr>(&hitSpaceShip);
    (*phm)["SpaceStation"] = reinterpret_cast<HitFunctionPtr>(&hitSpaceStation);
    (*phm)["Asteroid"] = reinterpret_cast<HitFunctionPtr>(&hitAsteroid);
    return phm;
}

编译器可能通过产生错误的代码来报复你,当通过*phm调用函数,而相应的GameObject的派生类是多重继承的或有虚基类时。如果SpaceStationSpaceShipAsteroid除了GameObject外还有其它基类,当调用函数时,其行为非常的粗暴。

A-B-C-D继承体系中,D中的四个类的部分,其地址都不同。这很重要,因为虽然指针和引用的行为并不相同
(见 Item M1),编译器产生的代码中通常是通过指针来实现引用的。于是,传引用通常是通过传指针来实现的。当一个有多个基类的对象(如D的对象)传引用时,最重要的就是编译器要传递正确的地址——匹配于被调函数声明的形参类型的那个。

但如果你对你的编译器撒谎说你的函数期望一个GameObject而实际上要的是一个SpaceShip或一个SpaceStation时,发生什么?编译器将传给你错误的地址,导致运行期错误。而且将非常难以定位错误的原因。

不使用类型转换。但函数指针类型不匹配的还没解决只有一个办法:将所有的函数都改为接受GameObject类型

class SpaceShip: public GameObject {
public:
    virtual void collide(GameObject& otherObject);
    // these functions now all take a GameObject parameter
    virtual void hitSpaceShip(GameObject& spaceShip);
    virtual void hitSpaceStation(GameObject& spaceStation);
    virtual void hitAsteroid(GameObject& asteroid);
};

这里没有照抄基于虚函数解决二重调度问题的方法中重载collide的函数,而使用了一组成员函数指针。所有的碰撞处理函数都有着相同的参数类型,所以必须给它们以不同的名字。

现在上述我们一直期望的方式来写initializeCollisionMap函数是合法的了

很遗憾,我们的碰撞函数现在得到的是一个更基本的CameObject参数而不是期望中的派生类类型。要想得到我们所期望的东西,必须在每个碰撞函数开始处采用dynamic_cast

void SpaceShip::hitSpaceShip(GameObject& spaceShip)
{
    SpaceShip& otherShip = dynamic_cast<SpaceShip&>(spaceShip);
    //process a SpaceShip-SpaceShip collision;
}
//其他形式同理

使用非成员的碰撞处理函数

因为这张表包含的是指向成员函数的指针,所以在增加新的GameObject类型时仍然需要修改类的定义,这还是意味着所有人都必须重新编译,即使他们根本不关心这个新的类型。

解决方法是只需做小小的修改:如果映射表中包含的指针指向非成员函数,那么就没有重编译问题了

如果将碰撞处理函数从类里移出来,我们在给用户提供类定义的头文件时,不用带上任何碰撞处理函数。我们可以将实现碰撞处理函数的文件组织成这样:

#include "SpaceShip.h"
#include "SpaceStation.h"
#include "Asteroid.h"

// unnamed namespace — see below
namespace {
    //排列组合A(3,2) = 6
    // primary collision-processing functions
    void shipAsteroid(GameObject& spaceShip, GameObject& asteroid);
    void shipStation(GameObject& spaceShip, GameObject& spaceStation);
    void asteroidStation(GameObject& asteroid, GameObject& spaceStation);

    // secondary collision-processing functions that just implement symmetry: 
    //swap the parameters and call a primary function
    void asteroidShip(GameObject& asteroid, GameObject& spaceShip)
    { 
        shipAsteroid(spaceShip, asteroid); 
    }
    void stationShip(GameObject& spaceStation, GameObject& spaceShip)
    { 
        shipStation(spaceShip, spaceStation); 
    }
    void stationAsteroid(GameObject& spaceStation, GameObject& asteroid)
    { 
        asteroidStation(asteroid, spaceStation); 
    }

    // see below for a description of these types/functions
    typedef void (*HitFunctionPtr)(GameObject&, GameObject&);
    typedef map< pair<string,string>, HitFunctionPtr > HitMap;
    std::pair<string,string> makeStringPair(const char *s1, const char *s2);

    HitMap * initializeCollisionMap();
    HitFunctionPtr lookup(const string& class1, const string& class2);
} // end namespace

void processCollision(GameObject& object1, GameObject& object2)
{
    HitFunctionPtr phf = lookup(typeid(object1).name(), typeid(object2).name());
    if (phf) 
        phf(object1, object2);
    else 
        throw UnknownCollision(object1, object2);
}

这里使用匿名命名空间来包含实现碰撞处理函数所需要的函数。匿名命名空间中的东西是当前编译单元(内部链接——其实就是当前文件)私有的——很像被声明为文件范围内static的函数一样。有了匿名命名空间后,文件范围内的static已经不赞成使用了,你应该尽快让自己习惯使用无名的命名空间(只要编译器支持)。

理论上,这个实现和使用成员函数的版本是相同的,只有几个轻微区别:

  • 第一,HitFunctionPtr现在是一个指向非成员函数的指针类型的重定义。
  • 第二,意料之外的类CollisionWithUnknownObject被改叫UnknownCollision
  • 第三,其构造函数需要两个对象作参数而不再是一个了。这也意味着我们的映射需要三个信息:两个类型名,一个HitFunctionPtr

标准的map类被定义为只处理两个信息。我们可以通过使用标准的pair模板来解决这个问题,pair可以让我们将两个类型名捆绑为一个对象。借助makeStringPair的帮助,initializeCollisionMap的实现如下:

// we use this function to create pair<string,string>
// objects from two char* literals. It's used in
// initializeCollisionMap below. Note how this function
// enables the return value optimization (see Item 20).
namespace { // unnamed namespace again — see below
    std::pair<string,string> makeStringPair(const char *s1, const char *s2)
    { 
        return pair<string,string>(s1, s2); 
    }
} // end namespace

namespace { // still the unnamed namespace — see below
    HitMap * initializeCollisionMap()
    {
        HitMap *phm = new HitMap;
        (*phm)[makeStringPair("SpaceShip","Asteroid")] = &shipAsteroid;
        (*phm)[makeStringPair("SpaceShip", "SpaceStation")] = &shipStation;
        //...其他类似
        return phm;
    }
} // end namespace

//lookup 函数也必须被修改以处理 pair<string,string>对象,并将它作为映射表的第一部分:
namespace { // I explain this below — trust me
    HitFunctionPtr lookup(const string& class1, const string& class2)
    {
        static auto_ptr<HitMap> collisionMap(initializeCollisionMap());
        // see below for a description of make_pair
        HitMap::iterator mapEntry = collisionMap->find(make_pair(class1, class2));
        if (mapEntry == collisionMap->end()) 
            return 0;
        return (*mapEntry).second;
    }
} // end namespace

因为makeStringPairinitializeCollisionMaplookup都是声明在无名的命名空间中的,它们的实现也必须在同一命名空间中。这就是为什么这些函数的实现在上面被写在了一个匿名命名空间中的原因(必须和它们的声明在同一编译单元中):这样链接器才能正确地将它们的定义(或说实现)与它们的之前的声明关联起来。

如果增加了新的GaemObject的子类,现存类不需要重新编译(除非它们用到了新类)。没有了RTTI的混乱和 if…then…else 的不可维护。增加一个新类只需要做明确定义了的局部修改:在initializeCollisionMap中增加一个或多个新的映射关系,在processCollision所在的无名的命名空间中申明一个新的碰撞处理函数。

继承和模拟虚函数表

对继承体系作如下修改:

class SpaceShip{};
class CommercialShip : public SpaceShip{};
class MilitaryShip : public SpaceShip{};

假设CommercialShipMilitaryShip在碰撞过程中的行为是一致的。于是,我们期望可以使用相同的碰撞处理函数(在增加这两类以前就有的那个)。尤其是,在一个MilitaryShip对象和一个Asteroid对象碰撞时,我们期望调用:

void shipAsteroid(GameObject& spaceShip, GameObject& asteroid);

它不会被调用的。实际上,抛了一个UnknownCollision的异常。因为lookup在根据类型名MilitaryShipAsteroidcollisionMap中查找函数时没有找到。虽然MilitaryShip可以被转换为一个SpaceShip,但 lookup却不知道这一点。

初始化模拟虚函数表(再次讨论)

我们可以将映射表放入一个类,并由它提供动态修改映射关系的成员函数。例如:

class CollisionMap {
public:
    typedef void (*HitFunctionPtr)(GameObject&, GameObject&);
    void addEntry(const string& type1, const string& type2, HitFunctionPtr collisionFunction, bool symmetric = true); // see below
    void removeEntry(const string& type1, const string& type2);
    HitFunctionPtr lookup(const string& type1, const string& type2);
    
    // this function returns a reference to the one and only map — see Item 26
    static CollisionMap& theCollisionMap();
private:
    // these functions are private to prevent the creation of multiple maps — see Item 26
    CollisionMap();
    CollisionMap(const CollisionMap&);
};

借助于单例CollisionMap类,每个想增加映射关系的用户可以直接这么做:

void shipAsteroid(GameObject& spaceShip, GameObject& asteroid);
CollisionMap::theCollisionMap().addEntry("SpaceShip", "Asteroid", &shipAsteroid);
void shipStation(GameObject& spaceShip, GameObject& spaceStation);
CollisionMap::theCollisionMap().addEntry("SpaceShip", "SpaceStation", &shipStation);
void asteroidStation(GameObject& asteroid, GameObject& spaceStation);
CollisionMap::theCollisionMap().addEntry("Asteroid", "SpaceStation", &asteroidStation);

必须确保在发生碰撞前就将映射关系加入了映射表。一个方法是让GameObject的子类在构造函数中进行确认。这将导致在运行期的一个小小的性能开销。另外一个方法是创建一个RegisterCollisionFunction类:

class RegisterCollisionFunction {
public:
    //在辅助类的构造函数中注册
    RegisterCollisionFunction(const string& type1, const string& type2, CollisionMap::HitFunctionPtr collisionFunction, bool symmetric = true)
    {
    	CollisionMap::theCollisionMap().addEntry(type1, type2,  collisionFunction,symmetric);
    }
};

用户于是可以使用此类型的全局对象来自动地注册他们所需要的函数,因为这些全局对象在main被调用前就构造了,它们在构造函数中注册的函数也在main被调用前就加入映射表了:

RegisterCollisionFunction cf1("SpaceShip", "Asteroid", &shipAsteroid);
RegisterCollisionFunction cf2("SpaceShip", "SpaceStation", &shipStation);
RegisterCollisionFunction cf3("Asteroid", "SpaceStation", &asteroidStation);

int main(){
    //...
}

如果以后增加了一个派生类,以及一个或多个碰撞处理函数,这些新函数可以用同样方法加入映射表而不需要修改现存代码:

class Satellite: public GameObject { ... };

void satelliteShip(GameObject& satellite, GameObject& spaceShip);
void satelliteAsteroid(GameObject& satellite, GameObject& asteroid);

RegisterCollisionFunction cf4("Satellite", "SpaceShip", &satelliteShip);
RegisterCollisionFunction cf5("Satellite", "Asteroid", &satelliteAsteroid);

Item34 在同一个程序中混用C和C++

在保证编译器可以产生C/C++兼容的目标文件前提下,还需考虑如下4方面:

  • 名变换(Name Mangling)
  • 静态初始化(Initialization of Statics)
  • 动态分配内存(Dynamic Memory Allocation)
  • 数据结构兼容(Data Structure Compatibility)

名变换

名变换(Name Mangling),即C++编译器给程序中每一个函数一个独一无二的名称的过程。C语言中,没有函数重载,因此没有名变换过程。

重载不兼容于绝大部分链接程序,因为链接程序通常无法分辨同名的函数。名变换是对链接程序的妥协;链接程序通常坚持函数名必须独一无二。

在纯C++中,不用担心名变换。假设有一个drawLine函数,编译器将其变换为xyzzy,你总是使用drawLine而不会注意到背后的obj文件引用的是xyzzy

//header decleration
void drawLine(int x1, int y1, int x2, int y2);
//call in source code
drawLine(a, b, c, d); // call to unmangled function name
//obj文件中调用形式
xyzzy(a, b, c, d); // call to mangled function mame

如果drawLine是一个C函数,obj文件(或动态链接库之类的文件)中包含的编译之后的函数名仍然叫drawLine,没有名变换动作。 当你试图将obj文件链接为程序时,将得到一个错误,因为链接程序在寻找一个xyzzy的函数,而它不存在。

解决方案

要解决这个问题,你需要一种方法来告诉 C++编译器不要在这个函数上进行名变换。你不期望对用其它语言写的函数进行名变换,如 C、汇编。总之,如果你调用一个名字为drawLine的 C函数,它实际上就叫drawLine,你的 obj 文件应该包含这样的一个引用,而不是引用进行了名变换的版本。

通过使用extern "C"指示:

// declare a function called drawLine; don't mangle its name
extern "C" void drawLine(int x1, int y1, int x2, int y2);

不要将extern "C"看作是声明这个函数是用 C语言写的,应该看作是声明这个函数应该被当作像C语言写的一样而进行调用。

Technically, extern “C” means the function has C linkage, which supressed name mangling.

在汇编中,同样适用:

// this function is in assembler — don't mangle its name
extern "C" void twiddleBits(unsigned char bits);

也可以将C++函数声明为extern "C"。这一点在使用C++写一个提供给其他语言调用的库时很有用,如JNI开发。

可以使用花括号将一组C++函数声明为exterc "C"extern "C"的使用可以简化对同时为C和C++所使用的头文件的维护:

#ifdef __cplusplus
// disable name mangling for all the following functions in curly braces
extern "C" {
#endif
    void drawLine(int x1, int y1, int x2, int y2);
    void twiddleBits(unsigned char bits);
    void simulate(int iterations);
#ifdef __cplusplus
}
#endif

顺便提一下,没有标准的名变换规则。不同的编译器可以随意使用不同的变换方式,而事实上不同的编译器也是这么做的。如果混合链接来自于不同编译器的 obj文件,极可能得到一个链接错误。

静态初始化

在C++中大量代码在main函数执行之前和之后执行,特别是静态类对象和定义在全局、名称空间和文件体中对象的构造函数通常在main函数体执行之前调用,这个过程叫做静态初始化(static initialization)。相似的,在**静态析构(static destruction)**过程中,通过静态初始化创建的对象的析构函数也必须被调用,这一过程发生在main函数执行结束之后。

为了解决main()应该首先被调用,而对象又需要在main()执行前被构造的两难问题,许多编译器在main()的最开始处插入了一个特别的函数,由它来负责静态初始化。同样地,编译器在main()结束处插入了一个函数来析构静态对象。产生的代码通常看起来象这样:

int main(int argc, char *argv[])
{
	performStaticInitialization(); // generated by the implementation
    //the statements you put in main go here;
	performStaticDestruction(); // generated by the implementation
}

不要注重函数名字,它们通常更含糊,甚至是inline的,如此一来在obj中将找不到这些函数。

重要之处在于:如果c++编译器采用以上方式初始化和静态对象,那么除非main()函数是用c++写的,否则这些对象将不会被初始化,也不会被销毁。

因为这种初始化和析构静态对象的方法是如此通用,只要程序的任意部分是 C++写的,你就应该用 C++写main()函数。

有时看起来用 C 写main()更有意义:比如程序的大部分是 C 的,C++部分只是一个支持库。然而,这个 C++库很可能含有静态对象(即使现在没有,以后可能会有——参见 Item32),所以用 C++写main()仍然是个好主意。这并不意味着你需要重写你的 C 代码。只要将C 写的main()改名为realMain(),然后用 C++版本的main()调用 realMain():

// implement this function in C
extern "C" int realMain(int argc, char *argv[]);

// write this in C++
int main(int argc, char *argv[]) 
{
	return realMain(argc, argv);
}

这么做时,最好加上注释来解释原因。

动态内存分配

通行规则很简单:C++部分使用 new 和 delete(参见 Item8),C 部分使用 malloc(或其变形)和 free。只要 new 分配的内存使用 delete 释放,malloc分配的内存用 free 释放,那么就没问题。用 free 释放 new 分配的内存或用 delete 释放malloc 分配的内存,其行为没有定义。那么,唯一要记住的就是:将你的 new 和 delete 与
malloc 和 free 进行严格的隔离。

数据结构的兼容性

在 C++和 C 之间传递数据,不可能让 C 的函数了解 C++的特性,所以它们之间的交互必须限定在 C 可表示的概念上。

但是,C 了解普通指针,所以只要你的 C++和 C 编译器产生可兼容的输出,两种语言间的函数就可以安全地交换指向对象的指针和指向非成员函数或静态成员函数的指针。自然地,结构和内建类型(如 int、char 等)的变量也可自由交换。

struct的角度来说,只要struct的定义可以同时在C和C++中编译,那么就可以在C和C++中来回传递。为struct添加非virtual函数不影响兼容性;添加virtual函数会改变struct的内存模型,继承自其他structclass同样影响,也就影响了兼容性。

Because the rules governing the layout of a struct in C++ are consistent with those of C, it is safe to assume that a structure definition that compiles in both languages is laid out the same way by both compilers. Such structs can be safely passed back and forth between C++ and C. If you add nonvirtual functions to the C++ version of the struct, its memory layout should not change, so objects of a struct (or class) containing only non-virtual functions should be compatible with their C brethren whose structure definition lacks only the member function declarations. Adding virtual functions ends the game, because the addition of virtual functions to a class causes objects of that type to use a different memory layout (see Item 24). Having a struct inherit from another struct (or class) usually changes its layout, too, so structs with base structs (or classes) are also poor candidates for exchange with C functions.

From a data structure perspective, it boils down to this: it is safe to pass data structures from C++ to C and from C to C++ provided the definition of those structures compiles in both C++ and C. Adding nonvirtual member functions to the C++ version of a struct that’s otherwise compatible with C will probably not affect its compatibility, but almost any other change to the struct will.

总结

如果想在同一程序下混合 C++与 C 编程,记住下面的原则:

  • 确保 C++和 C 编译器产生兼容的 obj 文件。
  • 将在两种语言下都使用的函数声明为extern "C"
  • 只要可能,用 C++写main()
  • 总用 delete 释放 new 分配的内存;总用 free 释放 malloc 分配的内存。
  • 将在两种语言间传递的东西限制在用 C 编译的数据结构的范围内;这些结构的 C++版本可以包含非虚成员函数。
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
作者 : Scott Meyers 译序、导读 : 侯捷 译序(侯捷) C++ 是一个难学易用的语言! C++ 的难学,不仅在其广博的语法,以及语法背後的语意,以及语意背後的深层思维,以及深层思维背後的物件模型;C++ 的难学,还在於它提供了四种不同(但相辅相成)的程式设计思维模式:procedural-based,object-based,object-oriented,generic paradigm。 世上没有白吃的午餐。又要有效率,又要有弹性,又要前瞻望远,又要回溯相容,又要能治大国,又要能烹小鲜,学习起来当然就不可能太简单。 在如此庞大复杂的机制下,万千使用者前仆後续的动力是:一旦学成,妙用无穷。C++ 相关书籍之多,车载斗量;如天上繁星,如过江之鲫。广博如四库全书者有之(The C++ Programming Language、C++ Primer),深奥如重山复水者有之(The Annotated C++ Reference Manual, Inside the C++ Object Model),细说历史者有之(The Design and Evolution of C++, Ruminations on C++),独沽一味者有之(Polymorphism in C++, Genericity in C++),独树一帜者有之(Design Patterns,Large Scale C++ Software Design, C++ FAQs),程式库大全有之(The C++ Standard Library),另辟蹊径者有之(Generic Programming and the STL),工程经验之累积亦有之(Effective C++, More Effective C++, Exceptional C++)。 这其中,「工程经验之累积」对已具C++ 相当基础的程式员而言,有著致命的吸引力与立竿见影的帮助。Scott Meyers 的Effective C++ 和More Effective C++ 是此类佼佼,Herb Sutter 的Exceptional C++ 则是後起之秀。 这类书籍的一个共通特色是轻薄短小,并且高密度地纳入作者浸淫於C++/OOP 领域多年而广泛的经验。它们不但开展读者的视野,也为读者提供各种C++/OOP 常见问题或易犯错误的解决模型。某些小范围主题诸如「在base classes 中使用virtual destructor」、「令operator= 传回*this 的reference」,可能在百科型C++ 语言书籍中亦曾概略提过,但此类书籍以深度探索的方式,让我们了解问题背後的成因、最佳的解法、以及其他可能的牵扯。至於大范围主题,例如smart pointers, reference counting, proxy classes,double dispatching, 基本上已属design patterns 的层级! 这些都是经验的累积和心血的结晶。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值