资讯详情

读书笔记系列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测试失败,删除第二个元素时,段错误,即多态指针算术存在问题:  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的不同含义

参考:

  • 本书附录9.3

  • Counting Objects in C++ by Scott Meyers

  • Scott Meyers Publications

  • 《Effective C++》Chapter8 Item49~Item52 effective_cpp\item52\main.cpp

  • 《C++必知必会》Item35

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

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.

  • 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 的“尸体”。这是无法使用的。因 此 。即使被抛出的对象不会被释放,也会进行拷贝操作,如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)

标签: 规格重载连接器m18圆柱型激光对射型传感器utsuki液位传感器upim12t连接器m8直头专用连接器用于m12连接器

锐单商城拥有海量元器件数据手册IC替代型号,打造 电子元器件IC百科大全!

锐单商城 - 一站式电子元器件采购平台