《C++ primer plus》第15章:友元、异常和其他(5)

exception 类

C++ 异常的主要目的是设计容错程序提供语言级支持,即异常使得在程序设计中包含错误处理功能更容易,以免事后采取一些严格的错误处理方式·。异常的灵活性和相对方便性激励着程序员在条件允许的情况下在程序设计中加入错误功能。总之,异常是这样一种特性:类似于类,可以改变您的编程方式。

较新的 C++ 编译器将异常合并到语言中。例如,为支持该语言,exception 头文件(以前为 exception.h 或 except.h)定义了 exception 类,C++ 可以把它用作其他异常类的基类。代码可以引发 exception 异常,也可以将 exception 类用作基类。有一个名为 what() 的虚拟成员函数,它返回一个字符串,该字符串的特征随实现而异。然而,由于这是一个虚方法,因此可以在从 exception 派生而来的类中重新定义它:

#include<exception>
class bad_hmean : public std::exception {
public:
	const char * what() { return "bad arguments to hmean()"; }
	...
};
class bad_gmean : public std::exception {
public:
	const char * what() { return "bad arguments to gmean()"; }
	...
};

如果不想以不同的方式处理这些派生而来的异常,可以在同一个基类处理程序中捕获它们:

try {
	...
}
catch(std::exception & e){
	cout << e.what() << endl;
	...
}

否则,可以分别捕获它们。
C++ 库定义了很多基于 exception 的异常类型。

  1. stdexcept 异常类
    头文件 stdexcept 定义了其他几个异常类。首先,该文件定义了logic_error 和 runtime_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 描述了典型的逻辑错误。总体而言,通过合理的编程可以避免这种错误,但实际上这些错误还是可能发生的。每个类的名称指出了它用于报告的错误类型:

    • demain_error;
    • invalid_argument;
    • length_error;
    • out_of_bounds;

    每个类独有一个类似于 logic_error 的构造函数,让您能够提供一个供方法 what() 返回的字符串。数学函数有定义域 (domain)和值域(range)。定义域由参数的可能取值组成,值域由函数可能的返回值组成。例如,正弦函数的定义域为负无穷大到正无穷大,因为任何实数都有正弦值;但正弦函数的值域为 -1 到 +1,因为它们分别是最大和最小的正弦值。另一方面,反正弦函数的定义域为 -1 到 +1,值域为 -Π到 + Π。如果您编写一个函数,该函数将一个参数传递给函数 std::sin(),则可以让该函数在参数不在定义域 -1 到 +1 之间时引发 domain_error 异常。

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

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

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

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

    • range_error;
    • overflow_error;
    • underflow_error。

    每个类独有一个类似于 runtime_error 的构造函数,让您能够提供一个供方法 what() 返回的字符串。

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

    一般而言,logic_error 系列异常表明存在可以通过编程修复的问题,而 runtime_error 系列异常表明存在无法避免的问题。所有这些错误类有相同的常规特征,它们之间的主要区别在于:不同的类名让您能够分别处理每种异常。另一方面,继承关系让您能够一起处理它们(如果您愿意的话)。例如,下面的代码首先单独捕获 out_of_bounds 异常,然后统一捕获其他 logic_error 系列异常,最后统一捕获 exception 异常、runtime_error 系列异常以及其他从 exception 派生而来的异常:

    try {
    	...
    }
    catch(out_of_bounds & oe) {// catch out_of_bounds error
    	...
    }
    catch(logic_error & oe) {	// catch remaining logic_error family
    	... 
    }
    catch(exception & ee) {	// catch runtime_error, exception objects
    	...
    }
    

    如果上述库类不能满足您的需求,应该从 logic_error 或 runtime_error 派生出一个异常类,以确保您异常类可归入同一个继承层次结构中。

  2. bad_alloc 异常和 new
    对于使用 new 导致的内存分配问题,C++ 的最新处理方式是让 new 引发 bad_alloc 异常。头文件 new 包含 bad_alloc 类的声明,它是从 exception 类公有派生而来的。但在以前,当无法分配请求的内存量时,new 返回一个空指针。

    下面的程序演示了最新的方法。捕获到异常后,程序将显示继承的 what() 方法返回的消息(该消息随实现而异),然后终止。

    // newexcp.cpp -- the bad_alloc exception
    #include<iostream>
    #include<new>
    #include<cstdlib>   // for exit(), EXIT_FAILURE
    
    using namespace std;
    
    struct Big{
        double stuff[200000];
    };
    
    int main(){
        Big * pb;
        try {
            cout << "Trying to get a big block of memory:\n";
            pb = new Big[10000];    // 1600000000 bytes
            cout << "Got past the new request:\n";
        }
        catch (bad_alloc & ba){
            cout << "Caught the exception!\n";
            cout << ba.what() << endl;
            exit(EXIT_FAILURE);
        }
        cout << "Memory successfully allocated\n";
        pb[0].stuff[0] = 4;
        cout << pb[0].stuff[0] << endl;
        delete[] pb;
        return 0;
    }
    

    下面该程序在某个系统中的输出:

    Trying to get a big block of memory:
    Caught the exception!
    std::bad_alloc
    

    在这里,方法 what() 返回字符串 “std::bad_alloc"。
    如果程序在您的系统上运行时没有出现内存分配问题,可尝试提高请求分配的内存量。

  3. 空指针和new
    很多代码都是在 new 在失败时返回空指针时编写的。为处理 new 的变化,有些编译器提供了一个标记(开关),让用户选择所需的行为。当前,C++ 标准提供了一种在失败时返回空指针的 new 其用法如下:

    int * pi = new (std::nothrow) int;
    int * pa = new (std::nothrow) int[500];
    

    使用这种 new,可将上面的程序的核心代码改为如下所示:

    Big * pb;
    pb = new (std::nothrow) Big[10000];	// 1600000000 bytes
    if (pb==0){
    	cout << "Could not allocate memory. Bye.\n";
    	exit(EXIT_FAILURE);
    }
    

异常、类和继承

异常、类和继承以三种方式相互关联。首先,可以像标准 C++ 库所做的那样,从一个异常类派生出另一个;其次,可以在类定义中嵌套异常类声明来组合异常;第三,这种嵌套声明本身可被继承,还可用作基类。

下面的程序带领我们开始了上述一些可能性的探索之旅。这个头文件声明了一个 Sales 类,它用于存储一个年份以及一个包含 12 个月的销售数据的数组。LabeledSales 类是从 Sales 派生而来的,新增了一个用于存储数据标签的成员。

// sales.h -- exceptions and inheritance

#include<stdexcept>
#include<string>

class Sales{
protected:
    enum {MONTHS = 12}; // could be a static const
public:
    class bad_index : public std::logic_error{
    private:
        int bi;     // bad index value
    public:
        explicit bad_index(int ix, 
        const std::string & s = "Index error in Sales object\n");
        int bi_val() const { return bi; }
        virtual ~bad_index() throw() {}
    };
    explicit Sales(int yy = 0);
    Sales(int yy, const double * gr, int n);
    virtual ~Sales() {}
    int Year() const { return year; }
    virtual double operator[](int i) const;
    virtual double & operator[](int i);
private:
    double gross[MONTHS];
    int year;
};


class LabeledSales : public Sales{
public:
    class nbad_index : public Sales::bad_index{
    private:
        std::string lbl;
    public:
        nbad_index(const std::string & lb, int ix,
            const std::string & s = "Index error in LabeledSales object\n");
        const std::string & label_val() const { return lbl;}
        virtual ~nbad_index() throw() {}
    };
    explicit LabeledSales(const std::string & lb = "none", int yy = 0);
    LabeledSales(const std::string & lb, int yy, const double * gr, int n);
    virtual ~LabeledSales() {}
    const std::string & Label() const { return label; }
    virtual double operator[](int i) const;
    virtual double & operator[](int i);
private:
    std::string label;
};

来看一下程序的几个细节。首先,符号常量 MONTHS 位于 Sales 类的保护部分,这使得派生类(如 LabeledSales)能够使用这个值。

接下来,bad_index 被嵌套在 Sales 类的公有部分中,这使得客户类的 catch 块可以使用这个类作为类型。注意,在外部使用这个类型时,需要使用 Sales::bad_index 来标识。这个类是从 logic_error 类派生而来的,能够存储和报告数组索引的超界值(out-of-bounds value)。

nbad_index 类被嵌套到 LabeledSales 的公有部分,这使得客户类可以通过 LabeledSales::nbad_index 来使用它。它是从 bad_index 类派生而来的,新增了存储和报告 LabeledSales 对象的标签的功能。由于 bad_index 是从 logic_error 派生而来的,因此 nbad_index 归根结底也是从 logic_error 派生而来的。

这两个类都有重载的 operator 方法,这些方法设计用于访问存储在对象中的数组元素,并在索引超界时引发异常。

bad_index 和 nbad_index 类都使用了异常规范 throw(),这是因为它们都归根结底是从基类 exception 派生而来的,而 exception 的虚析构函数使用了异常规范 throw()。这是 C++98 的一项功能,在 C++11 中,exception 的构造函数没有使用异常规范。

下面的程序是程序清单中没有声明为内联的方法的实现。注意,对于被嵌套类的方法,需要使用多个作用域解析运算符。另外,如果数组索引超界,函数 operator 将引发异常。

// sales.cpp -- Sales implementation

#include"15.14_sales.h"
#include <stdexcept>
using std::string;

Sales::bad_index::bad_index(int ix, const string & s)
    : std::logic_error(s), bi(ix){

}

Sales::Sales(int yy){
    year = yy;
    for (int i = 0; i < MONTHS; ++i){
        gross[i] = 0;
    }
}

Sales::Sales(int yy, const double * gr, int n){
    year = yy;
    int lim = (n < MONTHS)? n : MONTHS;
    int i;
    for (i = 0; i < lim; ++i){
        gross[i] = gr[i];
    }
    for (; i < MONTHS; ++i){
        gross[i] = 0;
    }
}

double Sales::operator[](int i) const{
    if (i < 0 || i >= MONTHS)
        throw bad_index(i);
    return gross[i];
}

double & Sales::operator[](int i){
    if(i<0||i>=MONTHS){
        throw bad_index(i);
    }
    return gross[i];
}

LabeledSales::nbad_index::nbad_index(const string & lb, int ix,
        const string & s) : Sales::bad_index(ix, s){
            lbl = lb;
        }

LabeledSales::LabeledSales(const string & lb, int yy) : Sales(yy){
    label = lb;
}

LabeledSales::LabeledSales(const string & lb, int yy,
                           const double * gr, int n) : Sales(yy, gr, n){
                               label = lb;
                           }

double LabeledSales::operator[](int i) const{
    if (i < 0 || i >= MONTHS){
        throw nbad_index(Label(), i);
    }
    return Sales::operator[](i);
}

double & LabeledSales::operator[](int i){
    if (i < 0 || i >= MONTHS){
        throw nbad_index(Label(), i);
    }
    return Sales::operator[](i);
}

下面的程序使用了这些类:首先试图超越 LabeledSales 对象 sales2 中数组的末尾,然后试图超越 Sales 对象 sales1 中数组的末尾。这些尝试是在两个 try 块中进行的,让您能够检测每种异常。

// use_sales.cpp -- nested exceptions

#include<iostream>
#include"15.14_sales.h"

int main(){
    using std::cout;
    using std::cin;
    using std::endl;
    double vals1[12] = {
        1220, 1100, 1122, 2212, 1232, 2334,
        2884, 2393, 3302, 2922, 3002, 3544
    };
    double vals2[12] = {
        12, 11, 22, 21, 32, 34,
        28, 29, 33, 29, 32, 35
    };

    Sales sales1(2011, vals1, 12);
    LabeledSales sales2("Blogstar", 2012, vals2, 12);

    cout << "First try block:\n";
    try{
        int i;
        cout << "Year = " << sales1.Year() << endl;
        for (i=0; i<12; i++){
            cout << sales1[i] << ' ';
            if ( i % 6 == 5)
                cout << endl;
        }
        cout << "Year = " << sales2.Year() << endl;
        cout << "Label = " << sales2.Label() << endl;
        for (i=0; i<= 12; i++){
            cout << sales2[i] << ' ';
            if (i % 6 == 5)
                cout << endl;
        }
        cout << "End of try block 1.\n";
    }
    catch(LabeledSales::nbad_index & bad){
        cout << bad.what();
        cout << "Company: " << bad.label_val() << endl;
        cout << "bad index: " << bad.bi_val() << endl;
    }
    catch(Sales::bad_index & bad){
        cout << bad.what();
        cout << "bad index: " << bad.bi_val() << endl;
    }


    cout << "\nNext try block:\n";
    try{
        sales2[2] = 37.5;
        sales1[20] = 23345;
        cout << "End of try block 2.\n";
    }
    catch(LabeledSales::nbad_index & bad){
        cout << bad.what();
        cout << "Company: " << bad.label_val() << endl;
        cout << "bad index: " << bad.bi_val() << endl;
    }
    catch(Sales::bad_index & bad){
        cout << bad.what();
        cout << "bad index: " << bad.bi_val() << endl;
    }

    cout << "done\n";

    return 0;
}

异常何时会迷失方向

异常被引发后,在两种情况下,会导致问题。首先,如果它是在带异常规范的函数中引发的,则必须与规范列表中的某种异常匹配(在继承层次结构中,类类型与这个类及其派生类的对象匹配),否则称为意外异常(unexpected exception)。在默认情况下,这将导致程序异常终止(虽然 C++11 摒弃了异常规范,但仍支持它,且有些现有的代码使用了它)。如果异常不是在函数中引发的(或者函数没有异常规范),则必须捕获它。如果没被捕获(在没有try块或没有匹配的 catch 块时,将出现这种情况),则异常被称为未捕获异常(uncaught exception)。在默认情况下,这将导致程序异常终止。然而,可以修改程序对意外异常和未捕获异常的反应。下面来看如何修改,先从未捕获异常开始。

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

typedef void (*terminate_handler) ();
terminate_handler set_terminate(terminate_handler f) throw();	// C++98
terminate_handler set_terminate(terminate_handler f) noexcept;	// C++11
void terminate();		// C++98
void terminate() noexcept;	// C++11

其中 typedef 使 terminate_handler 成为这样一种类型的名称:指向没有参数和返回值的函数的指针。set_terminate() 函数将不带任何参数且返回类型为 void 的函数的名称(地址)作为参数,并返回该函数的地址。如果调用了 set_terminate() 函数多次,则 terminate() 将调用最后一次 set_terminate() 调用设置的函数。

来看一个例子。假设希望未捕获的异常程序打印一条消息,然后调用 exit() 函数,将退出状态值设置为 5。首先,请包含头文件 exception。可以使用 using 编译指令、适当的 using 声明或 std::限定符,来使其声明可用。

#include<exception>
using namespace std;

然后,设计一个完成上述两种操作所需的函数,其原型如下:

void myQuit(){
	cout << "Terminating due to uncaught exception\n";
	exit(5);
}

最后,在程序的开头,将终止操作指定为调用该函数。

set_terminate(myQuit);

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

接下来看意外异常。
通过给函数指定异常规范,可以让函数的用户知道要捕获哪些异常。假设函数的原型如下:

double Argh(double, double) throw(out_of_bounds);

则可以这样使用该函数:

try {
	x = Argh(a, b);
}
catch(out_of_bounds & ex){
...
}

知道应捕获哪些异常很有帮助,因为默认情况下,未捕获的异常将导致程序异常终止。

原则上,异常规范应包含函数调用的其他函数引发的异常。例如,如果 Argh() 调用了 Duh() 函数,而后者可能引发 retort 对象异常,则 Argh() 和 Duh() 的异常规范中都应包含 retort。除非自己编写所有的函数,并且特别仔细,否则无法保证上述工作都已正确完成。例如,可能使用的是老式商业库,而其中的函数没有异常规范。这表明应进一步探讨这样一点,即如果函数引发了其异常规范中没有的异常,情况将如何?这也表明异常规范机制处理起来比较麻烦,这也是 C++ 将其摒弃的原因之一。

在这种情况下,行为与未捕获的异常极其类似。如果发生意外异常,程序将调用 unexpected() 函数(您没有想到是 unexpected() 函数吧?谁也想不到!)。这个函数将调用 terminate(),后者在默认情况下,后者在默认情况下将调用 abort()。正如有一个可用于修改 terminate() 的行为的 set_terminate() 函数一样,也有一个可用于修改 unexpected() 的行为的 set_unexpected() 函数。这些新函数也是在头文件 exception 中声明的:

typedef void (*unexpected_handler) ();
unexpected_handler set_unexpected(unexpected_handler f) throw();		// C++98
unexpected_handler set_unexpeced(unexpected_handler f) noexcept;	// C++11
void unexpected();		// C++98
void unexpected() noexcept;	// C++11

然而,与提供给 set_teriminate() 的函数的行为相比,提供给 set_unexpected() 的函数的行为受到更严格的限制。具体地说,unexpected_handler 函数可以:

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

引发异常(第二种选择)的结果取决于 unexpected_handler 函数所引发的异常以及引发意外异常的函数规范:

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

总之,如果要捕获所有的异常(不管是预期的异常还是意外异常),则可以这样做:
首先确保异常头文件的声明可用:

#include<exception>
using namespace std;

然后,设计一个替代函数,将意外异常转换为 bad_exception 异常,该函数的原型如下:

void myUnexpected() {
	throw std::bad_exception();		// or just throw;
}

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

接下来在程序的开始位置,将意外异常操作指定为调用该函数:

set_unexpected(myUnexpected);

最后,将 bad_exception 类型包括在异常规范中,并添加如下 catch 块序列:

double Argh(double, double) throw(out_of_bounds, bad_exception);
...
try {
	x = Argh(a, b);
}
catch (out_of_bounds & ex) {
	...
}
catch (bad_exception & ex) {
	...
}

有关异常的注意事项

从前面关于如何使用异常的讨论可知,应在设计程序时就加入异常处理功能,而不是以后再添加。这样做有些缺点。例如,使用异常会增加程序代码,降低程序的运行速度。异常规范不适用于模板,因为模板函数引发的异常可能随特定的具体化而异。异常和动态内存分配并非总能协同工作。

下面进一步探讨动态内存分配和异常。首先,请看下面的函数:

void test1(int n) {
	string mesg("I'm trapped in an endless loop");
	...
	if( oh_no)
		throw exception();
	...
	return;
}

string 类采用动态内存分配。通常,当函数结束时,将为 mesg调用 string 的析构函数。虽然 throw 语句过早地终止了函数,但它仍然使得析构函数被调用,这要归功于栈解退。因此在这里,内存被正确地管理。

接下来看下面这个函数:

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

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

这种泄漏是可以避免的。例如,可以在引发异常的函数中捕获该异常,在 catch 块中包含一些清理代码,然后重新引发异常:

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

然而,这将增加疏忽和产生其他错误的机会。另一种解决办法是使用第16章讨论的智能指针模板之一。

总之,虽然异常处理对于某些项目极为重要,但它也会增加编程的工作量、增大程序、降低程序的速度。另一方面,不进行错误检查的代价可能非常高。

异常处理
在现代库中,异常处理的复杂程度可能再创新高——主要原因在于文档没有对异常处理例程进行解释或解释得很蹩脚。任何熟练使用现代操作系统的人都遇到过未处理的异常导致的错误和问题。这些错误背后的程序员通常面临异常艰难的战役,需要不断了解库的复杂性:什么异常将被引发,它们发生的原因和时间,如何处理它们,等等。

程序员新手很快就发现,理解库中异常处理像学习语言本身一样困难,现代库中包含的例程和模式可能像 C++ 语法细节一样陌生而困难。要开发出优秀的软件,必须花时间了解库和类中的复杂内容,就像必须花时间学习 C++ 本身一样。通过库文档和源代码了解到的异常和错误处理细节将使程序员和他的软件受益。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值