admin 管理员组

文章数量: 887006

C++ Primer系列 第18章 用于大型程序的工具

C++ Primer系列 第18章 用于大型程序的工具

  • 18.1 异常处理
    • 18.1.1 抛出异常
    • 18.1.2 捕获异常
    • 18.1.3 函数try语句块与构造函数
    • 18.1.4 noexcept异常说明
    • 18.1.5 异常类层次
  • 18.2 命名空间
    • 18.2.1 命名空间定义
    • 18.2.2 使用命名空间成员
    • 18.2.3 类,命名空间与作用域
    • 18.2.4 重载与命名空间
  • 18.3 多重继承与虚继承
    • 18.3.1 多重继承
    • 18.3.2 类型转换与多个基类
    • 18.3.3 多重继承下的类作用域
    • 18.3.4 虚继承
    • 18.3.5 构造函数与虚继承
  • 小结

与仅虚几个程序员就能开发完成的系统相比,大规模编程对程序设计语言的要求更高。大规模应用程序的特殊要求包括:

  • 在独立开发的子系统之间协同处理错误的能力。
  • 使用各种库(可能包含独立开发的库)进行协同开发的能力。
  • 对比较复杂的应用概念建模的能力。

本章介绍的三种C++语言特征正好能满足上述要求,它们是:异常处理,命名空间和多重继承。

18.1 异常处理

异常处理(exception handling)机制允许程序中独立开发的部分能够在运行时就出现的问题进行通信并作出相应的处理。

18.1.1 抛出异常

当执行一个throw时,跟在throw后面的语句将不再被执行。相反,程序的控制权从throw转移到与之匹配的catch模块。该catch可能是同一函数中的局部catch,也可能是位于直接或间接调用了发生异常的函数的另一个函数中。控制权从一处转移到另一处,这有两个重要的含义:

  • 沿着调用链的函数可能会提早退出。
  • 一旦程序开始执行异常处理代码,则沿着调用链创建的对象将会被销毁。
  • 一个异常如果没有被捕获,则它将终止当前的程序。
  • 在栈展开的过程中,运行类类型的局部对象的析构函数。因为这些析构函数是自动执行的,所以它们不应该抛出异常。一旦在栈展开的过程中析构函数抛出了异常,并且析构函数自身没能捕获到该异常,则程序将被终止。
  • 抛出指针要求在任何对应的处理代码存在的地方,指针所指的对象都必须存在。

18.1.2 捕获异常

异常声明的静态类型将决定catch语句所能执行的操作。如果catch的参数是基类类型,则catch无法使用派生类特有的任何成员。

  • 通常情况下,如果catch接受的异常与某个继承体系有关,则最好将该catch的参数定义成引用类型。

在搜寻catch语句的过程中,我们最终找到的catch未必是异常的最佳匹配。相反,挑选出来的应该是第一个与异常匹配的catch语句。因此,越是专门的catch越应该置于整个catch列表的前端。

  • 如果在多个catch语句的类型之间存在着继承关系,则我们应该把继承链最底端的类(most derived type)放在前面,而将继承链最顶端的类(least derived type)放在后面。

重新抛出

catch (my_error& eObj) { // 引用类型eObj.status = errCodes::serverErr; // 修改了异常对象throw; // 异常对象的status成员是serverErr
} catch (other_error eObj) { // 非引用类型eObj.status = errCodes::serverErr; // 只修改了异常对象的局部副本throw; // 异常对象的status成员没有改变
}

捕获所有异常的处理代码

void mainip() {try {// 这里的操作将引发并抛出一个异常} catch (...) {// 处理异常的某些特殊操作throw;}
}

18.1.3 函数try语句块与构造函数

要想处理构造函数初始值抛出的异常,我们必须将构造函数写成函数try语句块(也成为函数测试块,function try block)的形式。

template <typename T>
Blob<T>::Blob(std::initializer_list<T> il) try :data(std::make_shared<std::vector<T>>(il)) {/*空函数体*/
} catch (const std::bad_alloc& e) { handle_out_of_memory(e); }

注意:关键字try出现在表示构造函数初始值列表的冒号以及表示构造函数体(此例为空)的花括号之前。

18.1.4 noexcept异常说明

在C++11新标准中,我们可以通过提供noexcept说明(noexcept specification)指定某个函数不会抛出异常。其形式是关键字noexcept紧跟在函数的参数列表后面,用以标识该函数不会抛出异常:

void recoup(int) noexcept; // 不会抛出异常
void alloc(int); // 可能抛出异常

异常说明与指针,虚函数和拷贝控制
尽管noexcept说明符不属于函数类型的一部分,但是函数的异常说明仍然会影响函数的使用。

函数指针及该指针所指的函数必须具有一致的异常说明。也就是说,如果我们为某个指针做了不抛出异常的声明,则该指针将只能指向不抛出异常的函数。相反,如果我们显示或隐式地说明了指针可能抛出异常,则该指针可以指向任何函数,即使是承诺了不抛出异常的函数也可以:

// 正确:recoup不会抛出异常,pf2可能抛出异常,二者之间互不干扰
void (*pf2)(int) = recoup;pf1 = alloc; // 错误:alloc可能抛出异常,但是pf1已经说明了它不会抛出异常
pf2 = alloc; // 正确:pf2和alloc都可能抛出异常

如果一个虚函数承诺了它不会抛出异常,则后续派生出来的虚函数也必须做出同样的承诺;与之相反,如果基类的虚函数允许抛出异常,则派生类的对应函数既可以抛出异常,也可以不允许抛出异常:

class Base {
public:virtual double f1(double) noexcept; // 不会抛出异常virtual int f2() noexcept(false); // 可能抛出异常virtual void f3(); // 可能抛出异常
};
class Derived :public Base {
public:double f1(double); // 错误:Base::f1承诺不会抛出异常int f2() noexcept(false); // 正确:与Base::f2的异常说明一致void f3() noexcept; //正确:Derived的f3做了更严格的限定,这是允许的
};

18.1.5 异常类层次

标准库异常构成了下图所示的继承体系:

类型exception仅仅定义了拷贝构造函数,拷贝赋值运算符,一个虚析构函数和一个名为what的虚成员。其中what函数返回一个const char*,该指针指向一个以null结尾的字符数组,并且确保不会抛出任何异常。

继承体系的第二层将exception划分为两个大的类别:运行时错误和逻辑错误。运行时错误表示的是只有在程序运行时才能检测到的错误;而逻辑错误一般指的是我们可以在程序代码中发现的错误。

18.2 命名空间

命名空间(namespace)为防止名字冲突提供了更加可控的机制。命名空间分割了全局命名空间,其中每个命名空间是一个作用域。通过在某个命名空间中定义库的名字,库的作者(以及用户)可以避免全局名字固有的限制。

18.2.1 命名空间定义

一个命名空间的定义包含两部分:首先是关键字namespace,随后是命名空间的名字。在命名空间名字后面是一系列由花括号括起来的声明和定义。

namespace cplusplus_primer {class Sales_data { /*...*/ };Sales_data operator+(const Sales_data&, const Sales_data&);class Query { /*...*/ };class Query_base { /*...*/ };
} // 命名空间结束后无需分号,这一点与块类似

命名空间可以是不连续的
命名空间可以定义在几个不同的部分,这一点与其他作用域不太一样。编写如下的命名空间:

namespace nsp {// 相关声明
}

可能是定义了一个名为nsp的新命名空间,也可能是为已经存在的命名空间添加一些新成员。如果之前没有名为nsp的命名空间定义,则上述代码创建一个新的命名空间;否则,上述代码打开已经存在的命名空间定义,并为其添加一些新成员的声明。

命名空间的定义可以不连续的特性,使得我们可以将几个独立的接口和实现文件组成一个命名空间。此时,命名空间的组织方式类似于我们管理自定义类及函数的方式:

  • 命名空间的一部份成员的作用是定义类,以及声明作为类接口的函数及对象,则这些成员应该置于头文件中,这些头文件将被包含在使用了这些成员的文件中。
  • 命名空间成员的定义部分则置于另外的源文件中。
  • 定义多个类型不相关的命名空间应该使用单独的文件分别表示每个类型(或关联类型构成的集合)。

全局命名空间
全局作用域中定义的名字(即在所有类,函数及命名空间之外定义的名字)也就是定义在全局命名空间(global namespace)中。全局命名空间以隐式的方式声明,并且在所有程序中都存在。全局作用域中定义的名字被隐式地添加到全局命名空间中。

作用域运算符同样可以用于全局作用域地成员,因为全局作用域是隐式的,所以它并没有名字。下面的形式:
::member_name
表示全局命名空间中的一个成员。

18.2.2 使用命名空间成员

using声明:扼要概述
一条using声明(using decleration)语句一次只引入命名空间的一个成员。它使得我们可以清楚地知道程序中所用的到底是哪些名字。

using声明引入的名字遵守与过去一样的作用域规则:它的有效范围从using声明的地方开始,一直到using声明所在的二作用域结束为止。

using指示
using指示(using directive)和using声明类似的地方是,我们可以使用命名空间名字的简写形式;和using声明不同的地方是,我们无法控制哪些名字是可见的,因为所有名字都是可见的。

using指示以关键字using开始,后面是关键字namespace以及命名空间的名字。

  • 如果我们提供了一个对std等命名空间的using指示而未做任何特殊控制的话,将重新引入由于使用了多个库而造成的名字冲突问题。

提示:避免using指示
using指示一次性注入某个命名空间的所有名字,这种用法看似简单实则充满了风险:只使用一条语句就突然将命名空间中所有成员的名字变得可见了。如果应用程序使用了多个不同的库,而这些库中的名字通过using指示变得可见,则全局命名空间污染的问题将重新出现。

相比于使用using指示,在程序中命名空间的每个成员分别使用using声明效果更好,这么做可以减少注入到命名空间中的名字数量。using声明引起的二义性问题在声明处就能发现,无需等到使用名字的地方,这显然对检测并修改错误大有益处。

using指示也并非一无是处,例如在命名空间本身的实现文件中就可以使用using指示。

18.2.3 类,命名空间与作用域

对命名空间内部名字的查找遵循常规的查找规则:即由内向外依次查找每个外层作用域。外层作用域也有可能是一个或多个嵌套的命名空间,直到外层的全局命名空间查找过程终止。只有位于开放的块中且在使用点之前的名字才被考虑:

namespace A {int i;namespace B {int i; // 在B中隐藏了A::iint j;int f1() {int j; // j是f1的局部变量,隐藏了A::B::jreturn i; // 返回B::i}} // 命名空间B结束,此后B中定义的名字不再可见int f2() {return j; // 错误:j没有被定义}int j = i; // 用A::i进行初始化
}

对于命名空间中的类来说,常规的查找规则仍然适用:

namespace A {int i;int j;class C1 {public:C1() :i(0), j(0) { } // 正确:初始化C1::i和C1::jint f1() { return k; } // 返回A::kint f2() { return h; } // 错误:h未定义int f3();private:int i; // 在C1中隐藏了A::iint j;};int h = i; //用A::i进行初始化
}
// 成员f3定义在C1和命名空间A的外部
int A::C1::f3() { return h; } // 正确:返回A::h

18.2.4 重载与命名空间

命名空间对函数匹配过程有两方面的影响。其中一个影响非续明显:using声明或using指示能将某些函数添加到候选函数集。

重载与using声明
要想理解using声明与重载之间的交互关系,必须明确一条:using声明语句声明的是一个名字,而非一个特定函数:

using NS::print(int); // 错误:不能指定形参列表
using NS::print; // 正确:using声明只声明一个名字

当我们为函数书写using声明时,该函数的所有版本都被引入到当前作用域中。

18.3 多重继承与虚继承

多重继承(multiple inheritance)是指从多个直接基类中产生派生类的能力。多重继承的派生类继承了所有父类的属性。尽管概念上非常简单,但是多个基类相互交织产生的细节可能会带来错综复杂的设计问题与实现问题。

18.3.1 多重继承

在派生类的派生列表中可以包含多个基类,每个基类包含一个可选的访问说明符:

class Bear : public ZooAnimal {
class Panda : public Bear, public Endangered { /* ... */ };

多重继承的派生类从每个基类中继承状态
在多重继承关系中,派生类的对象包含有每个基类的子对象。如下图所示,在Panda对象中含有一个Bear部分,一个Endangered部分以及Panda中声明的非静态数据成员。

派生类构造函数初始化所有基类
构造一个派生类的对象同时构造并初始化它的所有基类子对象。与从一个基类进行的派生类一样,多重继承的派生类的构造函数初始值也只能初始化它的直接基类:

// 显示地初始化所有基类
Panda::Panda(std::string name, bool onExhibit): Bear(name, onExhibit, "Panda"),Endangered(Endangered::critical) { }// 隐式地使用Bear的默认构造函数初始化Bear子对象
Panda::Panda(std::string name, bool onExhibit): Endangered(Endangered::critical) { }

派生类的构造函数初值列表将实参分别传递给每个直接基类。其中基类的构造顺序与派生列表中基类的出现顺序保持一致,而与派生类构造函数初值列表中基类的顺序无关。

18.3.2 类型转换与多个基类

在只有一个基类的情况下,派生类的指针或引用能自动转换为一个可访问基类的指针或引用。多个基类的情况与之类似。我们可以令某个可访问基类的指针或引用直接指向一个派生类对象。

与只有一个基类的继承一样,对象,指针和引用的静态类型决定了我们能够使用哪些成员。如果我们使用了一个ZooAnimal指针,则只有定义在ZooAnimal中的操作是可以使用的,Panda接口中的Bear,Panda和Endangered特有的部分都不可见。类似的,一个Bear指针或引用只能访问Bear及ZooAnimal的成员,一个Endangered的指针或引用只能访问Endangered的成员。

18.3.3 多重继承下的类作用域

在只有一个基类的情况下,派生类的作用域嵌套在直接基类和间接基类的作用域中。查找过程沿着继承体系自底向上进行,直到找到所需的名字。派生类的名字将隐藏基类的同名成员。

在多重继承的情况下,相同的查找过程在所有基类中同时进行。如果名字在多个基类中都被找到,则对改名字的使用将具有二义性。

18.3.4 虚继承

尽管在派生类列表中同一基类只能出现一次,但实际上派生类可以多次继承同一个类。派生类可以通过它的两个直接基类分别继承同一个间接基类,也可以直接继承某个基类,然后通过另一个基类再一次间接继承该类。

在C++语言中,我们通过虚继承(virtual inheritance)的机制解决上述问题。虚继承的目的是令某个类做出声明,承诺愿意共享它的基类。其中,共享的基类子对象称为虚基类(virtual base class)。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含唯一一个共享的虚基类子对象。

  • 虚派生只影响从指定了虚基类的派生类中进一步派生出的类,它不会影响派生类本身。

使用虚基类
我们指定虚基类的方式是在派生列表中添加关键字virtual:

// 关键字public和virtual的顺序随意
class Raccoon : public virtual ZooAnimal { /* ... */ };
class Bear : virtual public ZooAnimal { /* ... */ };

通过上面的代码我们将ZooAnimal定义为Raccoon和Bear的虚基类。

virtual说明符表明了一种愿望,即在后续的派生类当中共享虚基类的同一份实例。至于什么样的类能够作为虚基类并没有特殊规定。
如果某个类指定了虚基类,则该类的派生仍按常规方式进行:

class Panda : public Bear,public Raccoon, public Endangered {};

18.3.5 构造函数与虚继承

含有虚基类的对象的构造方式
含有虚基类的对象的构造顺序与一般的顺序稍有区别:首先使用提供给最低层派生类构造函数的初始值初始化该对象的虚基类子部分,接下来按照直接基类在派生列表中出现的次数以此对其进行初始化。

小结

C++语言可以用于解决各种类型的问题,既有几个小时就可以解决的小问题,也有一个大团队工作数年才能解决的超大规模问题。C++的某些特性特别适合于处理超大规模问题,这些特性包括:异常处理,命名空间以及多重继承或虚继承。

本文标签: C Primer系列 第18章 用于大型程序的工具