C++_Primer18.tools_for_large_programs

用于大型程序的工具

大规模应用程序的特殊要求包括:

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

异常处理、命名空间和多重继承的技术能够满足以上要求。

异常处理

异常使得我们能够将问题的检测和解决过程分离开来。
异常的捕获过程叫做栈展开过程,一层一层地匹配catch中的异常,如果直到最外成都匹配不到异常,则它不会被捕获。
如果一个异常没有被捕获,则它将调用标准库函数 terminate 终止当前的程序。

块中的代码发生异常,则后续代码不会被执行,如果后续有释放资源的代码,则不会正常释放。而如果类中的代码发生异常,则析构函数总是会被执行。
实际编程中,析构函数仅仅是释放资源,不太可能抛出异常。标准库类型都能确保他们的析构函数不会引发异常。

异常对象

异常对象是一种特殊对象,编译器使用异常抛出表达式来对异常对象进行拷贝初始化。因此,throw语句中的表达式必须拥有完全类型。
如果该表达式是类类型的话,则相应的类必须含有一个可访问的析构函数和一个可访问的拷贝或移动构造函数。
如果该表达式是数组类型或函数类型,则表达式将被转换成与之对应的指针类型。

当抛出一条表达式时,该表达式的静态编译时类型决定了异常对象的类型。
如果throw表达式解引用一个基类指针,而该指针实际指向的是派生类对象,则抛出的对象将被切掉一部分,只有基类部分被抛出。

捕获异常

catch 子句中的异常声明的类型决定了所能捕获的异常类型,这个类型必须是完全类型,它可以是左值引用,但不能是右值引用。
catch捕获的类型可以是引用类型,如果不是引用类型,则形参是实参的一个拷贝,修改其成员不会改变实参。
如果 catch 的参数是非引用类型,则异常对象将被切掉一部分,这与派生类对象段递给一个普通函数差不多。
如果 catch 的参数是基类的引用,则该参数将以常规方式绑定到异常对象上。

通常,如果 catch 接受的异常与某个继承体系有关,则最好将它定义成引用类型,并且将继承链最低端的类放在前面,继承链最顶段的类放在后面。

重新抛出

有时一个单独的 catch 语句不能完整地处理某个异常,执行了一些操作后抛出给调用链的更上一层,重新抛出的仍然是一条 throw 语句,而且不包含任何表达式:

throw;

如果在处理代码之外遇到了空 throw 语句,编译器将调用 terminate.

捕获所有异常

使用 catch(...) 捕获所有异常:

void main() {
    try {
        // ...
    } catch (...) {
        // ...
        throw;
    }
}

如果 catch(...) 与其他几个 catch 语句一起出现,则 catch(...) 必须放在最后位置,出现在其后的所有异常语句永远不会被匹配。

函数 try 语句块与构造函数

在创建对象时需要调用构造函数,而构造函数初始化成员时可能会产生异常,此时还未进入函数体,无法在函数体内捕捉异常。
此时有两种异常:成员初始化的异常和形参初始化的异常。

想要捕捉构造函数对成员初始化时抛出的异常,可以将构造函数写成函数 try 语句块:

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);
}

而形参初始化的异常属于调用表达式,应在调用者所在的上下文中处理。

noexcept 异常说明

如果预先知道某个函数不会抛出异常,可以通过 noexcept 告诉编译器,这样它就能进行某些特殊的优化操作:

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

对于一个函数来说,noexcept 要么出现在所有声明语句和定义语句中,要么一次也不出现。该说明符应该在函数的尾置返回类型之前。
noexcept 可以出现在函数指针的声明和定义中。不能出现在 typedef 或类型别名中。
成员函数中,它要出现在 const 和引用限定符之后,在 final, override 或虚函数的 =0 之前。

编译器不会在编译时检查 noexcept,所以既有 noexcept 又有 throw 语句或可能抛出异常的函数,编译器不会报错。
如果声明了 noexcept 的函数抛出了异常,程序就会调用 terminate.

一般 noexcept 用在两种情况下:确认函数不会抛出异常;我们根本不知道该如何处理异常。

noexcept 说明符接受一个布尔类型的可选实参,true 表示不会抛出异常,false 表示可能抛出异常:

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

noexcept 运算符

noexcept(e)

如果e调用的所有函数都作了不抛出说明,且e本身不含有 throw 语句时,上述表达式为 true;否则为 false。

noexcept(recoup(i)) // 如果先前声明 recoup 为不抛出异常,则结果为 true,否则为 false
// 使 f 函数的异常说明与 g 一致
void f() noexcept(noexcept(g()));

异常说明与指针、虚函数和拷贝控制

如果为某个指针做了不抛出异常的声明,则该指针只能指向不抛出异常的函数。
如果指针显式或隐式地说明了可能抛出异常,则它可以指向任何函数。

void (*pf1)(int) noexcept = recoup; // recoup 和 pf1 都不会抛出异常
void (*pf2)(int) = recoup;          // 正确
pf1 = 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);          // 错误
    int f2() noexcept(false);   // 正确
    void f3() noexcept;         // 正确,Derived 的 f3 做了更严格的限定
};

对于合成拷贝控制的成员,如果对所有成员和基类的所有操作都承诺了不会抛出异常,则合成的成员是 noexcept 的。如果合成成员调用的任意一个函数可能抛出异常,则合成的成员是 noexcept(false) 的。
如果没有为析构函数提供异常说明,则编译器将合成一个。

异常类层次

exception:

  • bad_cast
  • runtime_error
    • overflow_error
    • underflow_error
    • range_error
  • logic_error
    • domain_error
    • invalid_argument
    • out_of_range
    • length_error
  • bad_alloc

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

类 exception, bad_cast 和 bad_alloc 定义了默认构造函数,类 runtime_error 和 logic_error 没有默认构造函数,但有一个可以接受C风格字符串或标准库 string 类型实参的构造函数。

what 函数返回用于初始化异常对象的信息。what 是虚函数,对它的调用将执行异常对象动态类型对应的版本。

自定义异常类例

class out_of_stock: public std::runtime_error {
public:
    explicit out_of_stock(const std::string& s): std::runtime_error(s) {}
};
class isbn_mismatch: public std::logic_error {
public:
    explicit isbn_mismatch(const std::string& s): std::logic_error(s) {}
    isbn_mismatch(const std::string& s, const std::string& lhs,
            const std::string& rhs):
        std::logic_error(s), left(lhs), right(rhs) {}
    const std::string left, right;
};

// 当两个 Sales_data 对象相加,但 isbn 不同时抛出 isbn_mismatch 异常
Sales_data& Sales_data::operator+=(const Sales_data& rhs) {
    if (isbn() != rhs.isbn()) {
        throw isbn_mismatch("wrong isbns", isbn(), rhs.isbn());
    }
    units_sold += rhs.units_sold;
    revenue += rhs.revenue;
    return *this;
}

// 使用异常
Sales_data item1, item2, sum;
while (cin >> item1 >> item2) {
    try {
        sum = item1 + item2;
        //...
    } catch (const isbn_mismatch& e) {
        cerr << e.what() << ": left isbn()" << e.left << ") right isbn ("
            << e.right << ")" << endl;
    }
}

命名空间

命名空间定义

多个库名字放置在全局命名空间中时,可能发成命名空间污染。
命名空间为防止名字冲突提供了更加可控的机制,命名空间分割了全局命名空间,其中每个命名空间是一个作用域。

只要能出现在全局作用域中的声明就能放在命名空间内,主要包括:类、变量(及其初始化操作)、函数(及定义)、模板和其他命名空间。
命名空间可以定义在全局作用域内,也可以定义在其他命名空间中,但不能定义在函数或类内部。
命名空间作用域后无须分号。

命名空间内的名字可以被该命名空间内的其他成员直接访问,也可以被这些成员内嵌作用域中的任何单位访问。位于命名空间之外的代码则必须明确指出所用的名字属于哪个命名空间:

std::vector<int> v = std::vector<int>{1,2,3};

命名空间可以是不连续的。这个特性可以将几个独立的接口和实现组成一个命名空间。此时命名空间的的组织管理方式类似自定义类和函数:

  • 定义类、声明作为类接口的函数及对象放在头文件中
  • 命名空间成员的定义部分放在另外的源文件中

一般不把 #include 放在命名空间内部。如果放了,意思是把头文件中所有名字定义成该命名空间的成员。

全局命名空间

::member_name

嵌套命名空间

namespace a{
    namespace b{
        class Query { ... };
        Query operator&(const Query&, const Query&);
    }
    namespace c{
        class Quote { ... };
        class Disc_quote : public Quote { ... };
    }
}

// 使用:
a::b::Query

内联命名空间

内联命名空间中的名字可以被外层命名空间直接使用。即无需在内联命名空间的名字前添加表示该命名空间的前缀,通过外层命名空间的名字就可以直接访问它。
在 namespace 前添加关键字 inline 即可。inline 必须出现在命名空间第一次定义的地方,后续再打开命名空间时可以不写 inline。

inline namespace d {
    ...
}
namespace d {
    class Query_base { ... };
}

可以通过内联命名空间实现版本切换。比如当前版本是第4版,要切换到第5版。第四版的代码使用正常命名空间,第5版的代码写在内联空间 FifthEd 中:

// FifthEd.h
namespace FifthEd {
    class Item_base { ... };
    class Query_base { ... };
}

// 引入两个版本的代码
namespace a {
#include "FifthEd.h"
#include "FourthEd.h"
}

当要使用第5版代码时,直接使用类似 a::Item_base 的代码访问,而要使用第四版的代码则改成类似于 a::FourthEd::Item_base 即可。

未命名的命名空间

未命名的命名空间是指关键字 namespace 后紧跟花括号扩起来的一系列声明语句。其中定义的变量拥有静态生命周期:第一次使用之前创建,直到程序结束才销毁。
可以在同一个文件内不连续,但不能跨越多个文件。每个文件可以定义自己的未命名的命名空间,不同文件的未命名的命名空间相互无关。所以不同文件的未命名的命名空间中可以定义同名的名字。
定义在未命名的命名空间中的名字可以直接使用。
未命名的命名空间中定义的名字的作用域与该命名空间所在的作用域相同。所以,重名的名字会产生二义性。

C语言中一般使用 static 进行静态声明,但这种声明在所在的文件外是不可见的。
C++标准取消了这种做法,使用未命名的命名空间代替。

使用命名空间成员

命名空间别名

namespace cplusplus_primer { ... };
// 使用别名
namespace primer = cplusplus_primer;
// 嵌套的命名空间
namespace qlib = cplusplus_primer::QueryLib;
qlib::Query q;

using 声明和指示

using 声明每次只引入命名空间的一个成员。可以出现在全局作用域、局部作用域、命名空间作用域以及类的作用域中。
using 指示引入整个命名空间。可以出现在全局作用域、局部作用域、命名空间作用域,但不能出现在类的作用域中。

using 指示比声明复杂地多。它使得整个命名空间的所有内容都变得有效,它将命名空间成员提升到包含命名空间本身和 using 指示的最近作用域的能力。

当命名空间中的名字被提升到外层作用域后,可能会产生名字冲突的情况,这种情况是允许的。区别他们的方式是使用作用域运算符明确指出所需的版本,而未加限定的变量都会产生二义性:

// blib 命名空间和当前作用域都有名为 j 的成员
j           // 直接调用 j 会产生二义性
::j         // 全局作用域的成员 j
blib::j     // blib 命名空间中的成员 j

通常,头文件只负责定义接口部分的名字,而不定义实现,所以头文件最多只能在它的函数或命名空间内使用 using 指示或 using 声明。

应当避免使用 using 指示,尽量使用 using 声明,这样可以减少注入到命名空间中的名字数量,减少二义性的出现。
using 指示并非一无是处,比如可以在命名空间本身的实现文件中就可以使用 using 指示。

实参相关的查找和类类型形参

对于命名空间中名字的隐藏规则来说有个重要的例外,它使得我们可以直接访问输出运算符。
这个例外是,当我们传递给函数一个类类型的对象时,除了在常规作用域查找外,还会查找实参类所属的命名空间。
这个例外对于传递类的引用或指针的调用同样有效。

std::string s;
std::cin >> s;  // 编译器会从 cin 和 s 所属的命名空间查找 operator>>() 函数

对于友元:

// f 和 f2 是定义在A中的成员
namespace A {
    class C{
        friend void f2();
        friend void f(const C&);
    };
}

int main() {
    A::C cobj;
    f(cobj);        // 正确,通过A::C中的友元声明找到 A::f
    f2();           // 错误,f2没有形参,无法通过形参找到 f2

    return 0;
}

重载与命名空间

using 声明引入的函数将重载该声明语句所属作用域中已有的其他同名函数。
如果 using 声明出现在局部作用域中,则引入的名字将隐藏外层作用域的相关声明;
如果 using 声明所在的作用域中已有一个函数与新引入的函数同名且形参列表相同,则该 using 声明将引发错误;
除此之外,using 声明将为引入的名字添加额外的重载实例,并最终扩充候选函数集的规模。

与之相反,using 指示引入的与已有函数形参列表完全相同的函数并不会产生错误,此时,只需要我们指明调用的是命名空间中的函数版本还是当前作用域的版本即可。

多重继承与虚继承

多重继承

C++11中,允许派生类从它的一个或多个基类中继承构造函数,但如果从多个基类中继承了相同的构造函数,则产生错误:

struct Base1 {
    Base1() = default;
    Base1(const std::string&);
    Base1(std::shared_ptr<int>);
};
struct Base2 {
    Base2() = default;
    Base2(const std::string&);
    Base2(int);
};
// 错误:D1 试图从两个基类中都继承 D1::D1(const string&)
struct D1: public Base1, public Base2 {
    using Base1::Base1;
    using Base2::Base2;
};
// 如果一个类从它的多个基类中继承了相同的构造函数,则它必须定义自己的版本
struct D2: public Base1, public Base2 {
    using Base1::Base1;
    using Base2::Base2;
    D2(const string& s): Base1(s), Base2(s) {}
    D2() = default;     // 一旦 D2 定义了自己的构造函数,默认构造函数必须出现
};

派生类的析构函数只负责清楚派生类本身分配的资源,派生类的成员和基类都是自动销毁的,合成的析构函数为空。
析构函数的调用顺序与析构函数相反,先派生类,后基类。

与单继承一样,如果多重继承的派生类定义了自己的拷贝、移动和赋值操作,则必须在完整的对象上执行拷贝、移动和赋值操作。
只有当派生类使用合成版本的拷贝、移动和赋值成员时,才会自动对其基类部分执行这些操作。

类型转换与多个基类

与单继承一样,派生类可以自动转换成基类:

// Panda 继承 Bear, ENdangered; Bear 继承 ZooAnimal
void print(const Bear&);
void highlight(const Endangered&);
ostream& operator<<(ostream&, const ZooAnimal&);

Panda pd("pd");
print(pd);
highlight(pd);
cout << pd << endl;

编译器不会在派生类向基类的几个转换中进行比较,在他看来每个转换都一样好:

void print(const Bear&);
void print(const Endagered&);

Panda pd("pd");
print(pd);      // 二义性错误

对象、指针和引用的静态类型决定了我们能使用哪些成员。基类对象(对象、引用或指针)只能使用基类和基类的基类的成员,不能使用派生类的成员。

当调用一个成员时,编译器会在多个基类中同时查找,如果有超过1个基类包含该成员,则该名字的使用具有二义性,需要指定它的版本。可以利用作用域运算符指定版本:

double d = ZooAnimal::max_weight();
double d = Endandered::max_weight();

避免二义性的最好办法是在派生类中为该函数定一个新版本:

double Panda::max_weight() const {
    return std::max(ZooAnimal::max_weight(), Endangered::max_weight());
}

虚继承

如果一个衍生类继承了多个基类,而这些基类又继承了同一个基类,也就是说这个衍生类间接地多次继承了同一个基类。这样派生类中会包含多个同一个基类的子对象。
比如 iostream 类继承了 istream 和 ostream,而他们又都继承了 base_ios 类,所以 iostream 继承了 base_ios 两次。而一个 iostream 对象肯定希望在同一个缓冲区中进行读写,如果 iostream 对象中真的包含 base_ios 的两份拷贝,则共享行为就无法实现。

通过虚继承机制可以解决继承多次的情况。虚继承的目的是令某个类作出声明,承诺愿意共享它的基类。共享的基类子对象称为虚基类。这样对一个基类不管间接继承了多少次,派生类的对象都只包含唯一一个共享的虚基类子对象。

实际编程中,位于中间层次的基类将其继承声明为虚继承一般不会带来什么问题。

class Raccoon : public virtual ZooAnimal { ... };
class Bear : virtual public ZooAnimal { ... };
class Panda : public Bear, public Reccoon, public Endangered { ... };

构造函数与虚继承

含有虚基类的对象的构造顺序:首先构造虚基类子部分,然后按照直接基类在派生列表中出现的顺序进行构造。
如果有多个虚基类,则这些虚的子对象按照他们在派生列表中出现的顺序构造。

class Character { ... };
class BookCharacter: public Character { ... };
class ToyAnimal { ... };
class TeddyBear : public BookCharacter, public Bear,
        public virtual ToyAnimal { ... };

// 想要创建一个 TeddyBear 的对象,按照如下次序调用这些构造函数:
ZooAnimal();
ToyAnimal();
Character();
BookCharacter();
Bear();
TeddyBear();

小结

异常处理、命名空间和多重继承技术常用于解决超大规模问题。

异常处理可以使错误的检测部分和处理部分分割开来。
抛出异常时,当前函数暂时中止,开始查找最近邻和最匹配的 catch 语句。如果查找过程中退出了某些函数,则函数中定义的局部变量也随之销毁。

命名空间是一种管理大规模应用程序的机制。
一个命名空间是一个作用域,可以在其中定义对象、类型、函数、模板及其他命名空间。
标准库定义在 std 的命名空间中。

多重继承:一个派生类可以从多个基类继承而来,构造过程比普通类稍微复杂,并可能产生名字冲突带来的二义性问题。
如果一个类继承了多个基类,那么这些基类可能共享了另一个基类。此时,中间类可以选择使用虚继承。这样后代派生类中将有一个共享虚基类的副本。

posted @ 2023-07-02 11:56  keep-minding  阅读(15)  评论(0)    收藏  举报