《C++ Primer Plus》读书笔记 第15章 友元、异常和其他

第15章 友元、异常和其他

1.友元类

可以将一个类作为另一个类的友元。友元类的所有方法都可以访问原始类的私有成员和保护成员

通过在类中使用下面的语句来声明友元类:

friend class class_name;

友元声明可以位于公有、私有或保护部分,其所在的位置无关紧要。

假设现在要定义一个Tv类和Remote类,前者表示电视机,后者表示遥控器。两者显然不符合is-a关系,电视机不是一个遥控器,反之亦然;也不符合has-a关系,电视机不是遥控器的一部分,反之亦然。但是需要用遥控器来改变电视机的状态,这表明应该将Remote类作为Tv类的友元:

class Tv
{
public:
	friend class Remote;
	enum {Off, On};
	enum {MinVal, MaxVal = 20};
	enum {Antenna, Cable};
	enum {TV,DVD};
	Tv(int s = Off, int mc = 125): state(s), volume(5),
	maxchannel(mc), channel(2), mode(Cable), input(TV) {}
	void onoff() {state = (state == On) ? Off : On;}
	bool ison() const {return state == On;}
	bool volup();
	bool voldown();
	void chanup();
	void chandown();
	void set_mode() {mode =(mode == Antenna) ? Cable : Antenna;}
	void set_input() {input = (input == TV) ? DVD : TV;}
	void settings() const;
private:
	int state;
	int volume;
	int maxchannel;
	int channel;
	int mode;
	int input;
};

class Remote
{
private:
	int mode;
public:
	Remote(int m = Tv::Tv) : mode(m) {}
	bool volup(Tv & t) { return t.volup(); }
	bool voldown(Tv & t) { return t.voldown(); }
	void onoff(Tv & t) { t.onoff(); }
	void chanup(Tv & t) { t.chanup(); }
	void chandown(Tv & t) { t.chandown(); }
	void set_chan(Tv & t, int c) { t.channel = c; }
	void set_mode(Tv & t) { t.set_mode(); }
	void set_input(Tv & t) { t.set_input(); }
};

由于Remote类提到了Tv类,所以编译器必须了解Tv类后,才能处理Remote类,为此,最简单的方法是首先定义Tv类。

从上述代码可以看出,大多数Remote方法都是用Tv类的公有接口实现的。这意味着这些方法不是真正需要作为友元。而唯一直接访问Tv成员的Remote方法是Remote::set_chan(),因此它是唯一需要作为友元的方法。可以选择仅让特定的类成员成为另一个类的友元,而不必让整个类成为友元,即在Tv类中将其声明为友元:

class Tv
{
	friend void Remote::set_chan(Tv & t, int c);
	//...
};

然而,要使编译器能够处理这条语句,它必须知道Remote的定义。否则,它无法知道Remote是一个类,而set_chan是这个类的方法。这意味着应将Remote的定义放到Tv的定义前面。但是Remote的方法提到了Tv对象,又意味着Tv定义应当位于Remote定义之前。避开这种循环以来的方法是,使用前向声明。在Remote定义的前面插入下面的语句:

class Tv;

所以,排列次序应该如下:

class Tv;
class Remote { ... };
class Tv { ... };

而不能像下面这样排列:

class Remote;
class Tv { ... };
class Remote { ... };

因为在编译器在Tv类的声明中看到Remote的一个方法被声明为Tv类的友元之前,应该先看到Remote来的声明和set_chan方法的声明。

还有一个问题,在Remote声明中包含了如下代码:

void onoff(Tv & t) { t.onoff(); }

由于这将调用Tv的一个方法,所以编译器此时必须已经看到了Tv类的声明,这样才能知道Tv有哪些方法。这种问题的解决方式是,使Remote声明中只包含方法声明,并将实际的定义放在Tv类之后。这样,排列顺序将如下:

class Tv;
class Remote { ... };
class Tv;
//Remote方法的定义

现在出现了一种交互式遥控器,能让用户回答电视节目中的问题,如果回答错误,电视将在遥控器上产生嗡嗡声。也就是说,Tv类和Remote类可以相互改变对方的成员。这可以通过让类彼此成为对方的友元来实现

class Tv
{
	friend class Remote;
	//...
};

class Remote
{
	friend class Tv;
	//...
};

对于使用Remote对象的Tv方法,其原型可在Remote类声明之前声明,但必须在Remote类声明之后定义。

需要使用友元的另一种情况是,函数需要访问两个类的私有数据。这可以通过将函数同时作为两个类的友元来解决。

2.嵌套类

在C++中,可以将类声明放在另一个类中。在另一个类中声明的类被称为嵌套类,它通过提供新的类型类作用域来避免名称混乱。包含类的成员函数可以创建和使用被嵌套类的对象。

如果嵌套类是在另一个类的私有部分声明的,则只有后者知道它。对于从后者派生而来的类,嵌套类也是不可见的,因为派生类不能直接访问基类的私有部分。

如果嵌套类是在另一个类的保护部分声明的,则它对于后者来说是可见的,但是对于外部世界则是不可见的。在这种情况中,派生类将知道嵌套类,并可以直接创建这种类型的对象。

如果嵌套类是在另一个类的公有部分声明的,则允许后者、后者的派生类以及外部世界使用它,因为它是公有的。然而,由于嵌套类的作用域为包含它的类,因此在外部世界使用它时,必须使用类限定符。表15.1总结了嵌套类、结构和枚举的作用域特征。

表15.1 嵌套类、结构、枚举的作用域特征
声明位置 包含它的类是否能使用它 从包含它的类派生出来的类是否可以使用它 在外部是否可以使用它
私有部分
保护部分
公有部分 是,通过作用域限定符来使用

对嵌套类访问权的控制规则与对常规类相同。

3.异常

程序有时会遇到运行阶段错误,导致程序无法正常地运行下去。例如在计算两个数x、y的调和平均数的函数中,如果y是x的负值,则会导致0是除数。

对于这种问题,处理方式之一是,如果其中一个参数是另一个参数的负值,则调用abort()函数。abort()函数的原型位于头文件cstdlib中,其典型实现是向标准错误流(即cerr使用的错误流)发送消息abnormal program termination(程序异常终止),然后终止程序。它还返回一个随实现而异的值,告诉操作系统(如果程序是由另一个程序调用的,则告诉父进程),处理失败。下面是使用abort()函数的例子:

double hmean(double a, double b)
{
	if (a == -b)
	{
		std::cout << “untenable arguments to hmean()\n”;
		std::abort();
	}
	return 2.0 * a * b / (a + b);
}

在hmean()中调用abort()函数将直接终止程序,而不是先返回到main()。

一种比直接终止更灵活的方法是,使用函数的返回值来指出问题。

bool hmean(double a, double b, double * ans)
{
	if (a == -b)
	{
		*ans = DBL_MAX;
	return false;
	}
	else
	{
		*ans = 2.0 * a * b / (a + b);
		return true;
	}
}

下面介绍使用异常机制来处理错误。C++异常是对程序运行过程中发生的异常情况的一种响应。异常提供了将控制权从程序的一个部分传递到另一部分的途径。对异常的处理有3个组成部分:

  • 引发异常

程序在出现问题时将引发异常。throw语句用于跳转,即命令程序条到另一条语句。throw关键字表示引发异常,紧随其后的值(例如字符串或对象)指出了异常的特征。

  • 使用处理程序捕获异常

程序使用异常处理程序来捕获异常,异常处理程序位于要处理问题的程序中。catch关键字表示捕获异常。处理程序以关键字catch开头,随后是位于括号中的类型声明,它指出了异常程序要响应的异常类型;然后是一个用花括号括起的代码块,指出要采取的措施。catch关键字和异常类型用作标签,指出当异常被引发时,程序应跳到这个位置执行。异常处理程序也被称为catch块

  • 使用try块。

try块标识其中特定的异常可能被激活的代码块,它后面跟一个或多个catch块。try块是由关键字try指示的,关键字try的后面是一个由花括号括其的代码块,表明需要注意这些代码引发的异常。

下面是使用异常机制的例子:

double hmean(double, double);

int main()
{
	//...
	try
	{
		z = hmean(x, y);
	}
	catch (const char * s)
	{
		std::cout << s << std::endl;
		std::cout << “Enter a new pair of numbers:;
		continue;
	}
	//...
}

double hmean(double a, double b)
{
	if (a == -b)
		throw “bad hmean() arguments: a = -b not allowed”;
	return  2.0 * a * b / (a + b);
}

执行throw语句类似与执行返回语句,因为它也将终止函数的执行;但throw不是将控制权返回给调用程序,而是导致程序沿函数调用序列后退,知道找到包含try块的函数。在上面的例子中,throw将程序控制权返回给main()。程序将在main()中寻找与引发的异常类型匹配的异常处理程序

通常,引发异常的函数将传递一个对象。这样做的优点之一,可以使用不同的异常类型来区分不同的函数在不同情况下引发的异常。另外,对象可以携带信息,可以根据这些信息来确定引发异常的原因。同时,catch块可以根据这些信息来决定采取什么样的措施。例如,下面是针对函数hmean()引发的异常而提供的一种设计:

class bad_hmean
{
private:
	double v1;
	double v2;
public:
	bad_hmean(int a = 0, int b = 0) : v1(a), v2(b) {}
	void mesg();
};

inline void bad_hmean::mesg()
{
	std::cout <<hmean(<< v1 <<,<< v2 <<):<< “invalid arguments: a = -b\n”;
}

double hmean(double a, double b)
{
	if (a == -b)
		throw bad_hmean(a, b);
	return  2.0 * a * b / (a + b);
}

int main()
{
	...
	try
	{
		z = hmean(x, y);
	}
	catch (bad_hmean & bg)
	{
		bh.mesg();
		cout << “Try again.\n”;
		//...
	}
	//...
}

在C++98中新增了一项功能:异常规范

double harm(double a) thorw(bad_thing);		//可能会抛出bad_thing异常
double warm(double) throw();				//不会抛出异常

异常规范的作用之一是,告诉用户可能需要使用try块。另一个作用是,让编译器添加执行运行阶段检查的代码,检查是否违反了异常规范。

C++11已将这种异常规范摒弃了,不建议使用。然而,C++11支持一种特殊的异常规范:可以使用新增的关键字noexcept指出函数不会引发异常:

double marm() noexcept;

还有运算符noexcept(),它判断其操作数是否会引发异常

如果函数由于出现异常(而不是由于返回)而终止,则程序也将释放栈中的内存,但不会在释放栈的第一个返回地址后停止,而是继续释放栈,直到找到一个位于try块中的返回地址。随后,控制权将转到块为的异常处理程序,而不是函数调用后面的第一条语句。这个过程被称为栈解退

引发机制的一个非常重要的特性是,和函数返回一样,对于栈中的自动类对象,类的析构函数将被调用。然而,函数返回仅仅处理该函数放在栈中的对象,而throw语句则处理try块和throw之间整个函数调用序列放在栈中的对象。如果没有栈解退这种特性,则引发异常后,对于中间函数调用放在栈中的自动类对象,其析构函数将不会被调用。

throw-catch机制和函数参数和函数返回机制另一个不同之处是,引发异常时编译器总是创建一个临时拷贝,即使异常规范和catch块中指定的是引用

class problem {...};
//...
void super() throw (problem)
{
	//...
	if (oh_no)
	{
		problem oops;
		throw oops;
		//...
	}
	try
	{
		super();
	}
	catch(problem & p)
	{
		...
	}
}

p将指向oops的副本而不是oops本身。由于super()执行完毕后,oops将不复存在,这样做能避免引用指向一个不存在的对象。

那么既然throw语句将生成副本,为什么还要使用引用呢?由于基类引用可以执行派生类对象。假设有一组通过继承关联起来的异常类型,则在异常规范中值需列出一个基类引用,它将与任何派生类对象匹配

假设有一个异常类层次结构,并要分别处理不同异常类型,则使用基类引用能够捕获任何异常对象;而使用派生类对象只能捕获它所属类及从这个类派生而来的类的对象。引发的异常对象将被第一个与之匹配的catch块捕获。这意味着catch块的排列顺序应该与派生顺序相反

class bad_1 {...};
class bad_2 : public bad_1 {...};
class bad_3 : public bad_2 {...};
//...
void duper()
{
	//...
	if (oh_on)
		throw bad_1();
	if (rats)
		throw bad_2();
	if (drat)
		throw bad_3();
}
//...
try
{
	duper();
}
catch(bad_3 & be)
{...}
catch(bad_2 & be)
{...}
catch(bad_3 & be)
{...}

如果将bad_1 &处理程序放在最前面,它将捕获异常bad_1、bad_2和bad_3;通过按相反的顺序排列,bad_3异常将被bad_3 &处理程序所捕获

如果并不知道会发生哪些异常,可以使用省略号来表示异常类型,从而捕获任何异常:

catch (...) { // statements }

如果知道一些可能会引发的异常,可以将上述捕获所有异常的catch块放在最后面:

try
{
	duper();
}
catch(bad_3 & be)
{ // statements }
catch(bad_2 & be)
{ // statements }
catch(bad_1 & be)
{ // statements }
catch(...)
{ // statements }

exception头文件定义了exception类,C++可以把它用作其他异常类的基类。代码可以引发exception异常,也可以将exception类用作基类。有一个名为what()的虚成员函数,它返回一个字符串,该字符串的特征随实现而异。可以在从exception派生而来的类中重新定义它:

#include 
class bad_hmean : public std::exception
{
public:
	const char * what() { return “bad arguments to hmean(); }
	//...
}

头文件stdexcept定义了其他几个异常类。首先,该文件定义了loic_errorruntime_error类,它们都是以公有方式从exception派生而来的:

class logic_error : public exception
{
public:
	explicit logic_error(const string & what_arg);
	//...
};

class domain_error : public logic_error
{
public:
	explicit domain_error(const string & what_arg);
	//...
};

这些类的构造函数接受一个string对象作为参数,该参数提供了方法what()以C-风格字符串方式返回的字符数据。

异常类系列logic_error描述了典型的逻辑错误。总体而言,通过合理的变成可以避免这种错误,但实际上这些错误还是可能发生的。每个类的名称指出了它用于报告的错误类型:

  • doain_error;

异常doain_error指出函数的定义域错误。如果编写一个函数,该函数将一个参数传递给函数std::sin(),则可以让该函数在参数不在定义域-1到+1之间时引发doain_error异常。

  • invalid_argument;

异常invalid_argument指出给函数传递了一个意料外的值。例如,如果函数希望接受一个这样的字符串:其中每个字符要么是‘0’要么是‘1’,则当传递的字符串中包含其他字符时,该函数将引发invalid_argument异常。

  • length_error;

异常length_error用于指出没有足够的空间来执行所需的操作。例如,string类的append()方法在合并得到的字符串长度超过最大允许长度时,将引发length_error异常。

  • out_of_bounds;

异常out_of_bounds通常用于指示索引错误。例如,可以定义一个类似于数组的类,其operator()[]在使用的索引无效时引发out_of_bounds异常。

接下来,runtime_error异常系列描述了可能在运行期间发生但难以预计和防范的错误。每个类的名称指出了它用于报告的错误类型:

  • range_error;
  • overflow_error;
  • underflow_error;

下溢错误在浮点数计算中,一般而言,存在浮点数类型可以表示的最小非零值,计算结果比这个值还小时将导致下溢错误。整形和浮点都可能发生上溢错误,当计算结果超过了某种类型能够表示的最大数量级时,将发生上溢错误。计算结果可能不在函数允许的范围之内,但没有发生上溢或下溢错误,这种情况下,可以使用range_error异常。

对于使用new导致的内存分配问题,C++最新的处理方式是让new引发bad_allc异常。头文件new包含bad_allc类的声明。它是从exception类公有派生而来的。下面是使用bad_allc异常的示例:

stcuct Big
{
	double stuff[20000];
};

int main()
{
	Big * pb;
	try
	{
		pb = new Big[1000];	
	}
	catch(bad_allc & ba)
	{
		cout << ba.what() << endl;
		exit(EXIT_FAILURE);
	}
	//...
}

C++还提供了一种在new失败时返回空指针的方法:

int * pi = new (std::nothrow) int;

异常被引发后,在两种情况下,会导致问题。首先,如果它是在带异常规范的函数中引起的,则必须与规范列表中的某种异常匹配(在继承层次结构中,类类型与这个类及其派生类的对象匹配),否则称为意外异常。在默认情况下,这将导致程序异常终止。如果异常不是在函数中引发的,则必须捕获它。如果没被捕获,则异常被称为未捕获异常。在默认情况下,这将导致程序异常终止。然而,可以修改程序对意外异常和未捕获异常的反应。

未捕获异常不会导致程序立刻异常终止。相反,程序将首先调用函数terminate()。在默认情况下,terminate()调用abort()函数。可以指定terminate()应调用的函数来修改terminate()的这种行为。为此,可调用set_terminate()函数。set_terminate()和terminate()函数都是在头文件exception中声明的。应先设计一个完成所希望的操作的函数:

void MyQuit()
{
	//...
}

然后在程序的开头,调用set_terminate()函数将终止操作指定为调用该函数:

set_terminate(MyQuit);

现在,如果引发了一个异常且没有被捕获,程序将调用terminate(),而terminate()将调用MyQuit()。

意外异常与未捕获的异常类似。如果发生意外异常,程序将调用unexception()函数。这个函数将调用terminate(),terminate()在默认情况下将调用abort()。也有一个可用于修改unexcepted()的行为的set_unexcepted()函数。这些新函数也是在头文件exception中声明的。

然而,提供给set_unexcepted()的函数的行为受到更样的限制。具体地说,unexcepted_handler函数可以:

  • 通过调用terminate()、abort()或exit()来终止程序;
  • 引发异常。

引发异常的结果取决于unexcepted_handler函数所引发的异常以及引发意外异常的函数的异常规范:

  • 如果新引发的异常与原来的异常规范匹配,则程序将从那里开始进行正常处理,即寻找与新引发的异常匹配的catch块。基本上,这种方法将用与其的异常取代意外异常;
  • 如果新引发的异常与原来的异常规范不匹配,且异常规范中没有包括std::bad_exception类型,则程序将调用terminate()。bad_exception是从exception派生而来的,其声明位于头文件exception中;
  • 如果新引发的异常与原来的异常规范不匹配,且原来的异常规范中包含了std::bad_exception类型,则不匹配的异常将被std::bad_exception异常所取代。

总之,如果要捕获所有的异常,则可以这样做:

首先设计一个替代函数,将意外异常转换为bad_exception异常:

void MyUnexpected()
{
	throw std::bad_exception();	//或throw
}

仅使用throw,而不指定异常将导致重新引发原来的异常。然而,如果异常规范中包含了这种类型,则该异常将被bad_exception所取代。

然后将意外异常操作指定为调用该函数:

set_unexpected(MyUnexpected);

最后,将bad_exception类型包括在异常规范中,并添加个相应catch块序列。

下面进一步讨论动态内存分配和异常。下面有一个函数:

void test1(int n)
{
	double * ar = new double [n];
	//...
	if (oh_no)
		throw exception();
	delete [] ar;
	return;
}

这里有个问题,栈解退时,将删除栈中的变量ar。但函数过早的终止意味着函数末尾的delete []语句被忽略。指针消失了,但它指向的内存块未被释放,并且不可访问。总之,这些内存被泄露了。

这种泄露是可以避免的:

void test2(int n)
{
	double * ar = new double [n];
	//...
	try
	{
		if (oh_no)
		throw exception();
	}
	catch(exception & ex)
	{
		delete [] ar;
		throw;
	}
	//...
	delete [] ar;
	return;
}

4.运行阶段类型识别(RTTI)

RTTI(Runtime Type Identification)是新添加到C++中的特性之一。C++有3个支持RTTI的元素:

  • 如果可能的话,dynamic_cast运算符将使用一个指向基类的指针来生成一个指向派生类的指针;否则,将返回0——空指针。
  • typeid运算符返回一个指出对象的类型的值。
  • type_info结构存储了有关特定类型的信息。

只能将RTTI用于包含虚函数的类层次结构,原因在于只有对于这种类层次结构,才应该将派生对象的地址赋给基类指针。

dynamic_cast运算符能够回答“是否可以安全地将对象的地址赋给特定类型的指针”这样的问题。假设有下面的类层次结构:

class Grand { // 有虚函数 };
class Superb : public Grand { ... };
class Magnificent : public Superb { ... };

假设有下面的指针:

Grand * pg = new Grand;

dynamic_cast的用法如下:

Superb * pm = dynamic_cast<Superb *>(pg);

如果指针pg的类型可以被安全地转换为Superb *,运算符将返回对象的地址,否则返回一个空指针。

也可以将dynamic_cast用于引用。但是没有与空指针对应的引用值,因此无法使用特殊的引用值来指示失败。当请求不正确时,dynamic_cast将引发类型为bad_cast的异常,这种异常是从exception类派生而来的,它是在头文件typeinfo中定义的。因此,可以这样使用该运算符,其中rg是对Grand对象的引用:

#include 
//...
try
{
	Superb & rs = dynamic_cast<Superb &>(rg);
	//...
}
catch(bad_cast &)
{
	//...
}

typeid运算符能够确定两个对象是否为同种类型。可接受两种参数:

  • 类名;
  • 结果为对象的表达式。

typeid运算符返回一个对type_info对象的引用,其中,type_info是在头文件typeinfo中定义的一个类。type_info类重载了==和!=运算符,以便可以使用这些运算符来对类型进行比较:

typeid(Magnificent) == typeid(*pg);

如果pg是一个空指针,程序将引发bad_typdid异常。该异常类型是从exception类派生而来的,是在头文件typeinfo中声明的。

5.类型转换运算符

C++提供了4种类型转换运算符:

  • ynamic_cast
  • const_cast

该运算符用于执行只有一种用途的类型转换,即只能在const和非const之间相互转换:

const_cast < type-name > (expression)
  • static_cast

该运算符的语法与上述类型的转换符相同:

static_cast < *type-name* > (*expression*)

仅当type-name可被隐式转化为experssion所属类型或expression可被隐式转换为type-name所属的类型时,上述转换才是合法的,否则将出错。

static_cast运算符也可以将double转换为int、将float转换为long以及其他各种数值转换。

  • reinterpret_cast

该运算符的语法与上述类型的转换符相同:

reinterpret_cast < *type-name* > (*expression*)

reinterpret_cast运算符用于天生危险的类型转换。它不允许删除const,但会执行其他令人生厌的操作。且不能将函数指针转换为数据指针,反之亦然。

你可能感兴趣的:(读书笔记)