《C++ Primer第五版》第十八章学习笔记

18.1 异常处理

1.1 抛出异常

  1. C++通过抛出一个表达式来引发一个异常,throw之后的代码将不会被执行,程序控制权将从throw转移到与之匹配的catch模块(即捕获)(若 try 语句块还有嵌套,将继续在外层中寻找,称为栈展开),执行完这个catch语句之后,将转移到 try 块关联的最后一个catch子句之后的点,并从这里继续执行,若找不到与之匹配的catch语句,程序将调用标准库的 terminate 函数终止程序。

  2. 栈展开过程中,对象被自动销毁。即,如果某个局部对象的类型是类类型,则该对象的析构函数被自动调用。

    析构函数总是会被执行的,但是函数中负责释放资源的代码却可能被跳过,这一特点对于我们如何组织程序结构有重要影响。如我们在12.1.4节(第415页)介绍过的。如果一个块分配了资源,并且在负责释放这些资源的代码前面发生了异常,则释放资源的代码将不会被执行。另一方面,类对象分配的资源将由类的析构函数负责释放。因此,如果我们使用类来控制资源的分配,就能确保无论函数正常结束还是遭遇异常,资源都能被正确地释放。析构函数在栈展开的过程中执行,这一事实影响着我们编写析构函数的方式。在栈展开的过程中,已经引发了异常但是我们还没有处理它。如果异常抛出后没有被正确捕获,则系统将调用 terminate 函数。因此,出于栈展开可能使用析构函数的考虑,析构函数不应该抛出不能被它自身处理的异常。换句话说,如果析构函数需要执行某个可能抛出异常的操作,则该操作应该被放置在一个try语句块当中,并且在析构函数内部得到处理。在实际的编程过程中,因为析构函数仅仅是释放资源,所以它不太可能抛出异常。所有标准库类型都能确保它们的析构函数不会引发异常。

    在栈展开的过程中,运行类类型的局部对象的析构函数。因为这些析构函数是自动执行的,所以它们不应该抛出异常。一旦在栈展开的过程中析构函数抛出了异常,并且析构函数自身没能捕获到该异常,则程序将被终止。

  3. 异常对象

    异常对象是一种特殊的对象,编译器使用异常抛出表达式来对异常对象进行拷贝初始化。因此,throw语句中的表达式必须拥有完全类型(参见7.3.3节,第250页)。而且如果该表达式是类类型的话,则相应的类必须含有一个可访问的析构函数和一个可访问的拷贝或移动构造函数。如果该表达式是数组类型或函数类型,则表达式将被转换成与之对应的指针类型。异常对象位于由编译器管理的空间中,编译器确保无论最终调用的是哪个catch子句都能访问该空间。当异常处理完毕后,异常对象被销毁。当我们抛出一个表达式时,该表达式的静态编译时类型决定了异常对象的类型。比如,如果一个throw表达式解引用一个基类指针,而该指针实际指向的是派生类对象,则抛出的对象将被切掉一部分,只有基类的部分被成功抛出。

1.2 捕获异常

  1. 声明的类型决定了处理代码所能捕获的异常类型。这个类型必须是完全类型,它可以是左值引用,但不能是右值引用。通常情况下,如果catch接受的异常与某个继承体系有关,则最好将该catch的参数定义成引用类型(可以用派生类给基类传参,只不过最后用的只能是基类就是了)。

  2. 越是专门的 catch 越应该至于整个 catch 列表的前端(比如把继承链最底端的类放在最前面)。因为 catch 语句是按照其出现的顺序逐一进行匹配的。异常和 catch 声明的匹配规则受到更多限制,绝大多数的类型转换都不被允许。

    • 允许从非常量向常量的类型转换,也就是说,一条非常量对象的 throw 语句可以匹配一个接受常量引用的 catch 语句。
    • 允许从派生类向基类的类型转换。
    • 数组被转换成指向数组(元素)类型的指针,函数被转换成指向该函数类型的指针。

    除此之外,包括标准算数类型转换和类类型转换在内,其他所有转换规则都不能使用。

  3. 重新抛出:

    有时,一个单独的catch 语句不能完整地处理某个异常。在执行了某些校正操作之后,当前的 catch 可能会决定由调用链更上一层的函数接着处理异常。一条 catch 语句通过重新抛出的操作将异常传递给另外一个catch语句。这里的重新抛出仍然是一条throw语句,只不过不包含任何表达式:

    throw;

    • 空的throw语句只能出现在catch 语句或catch语句直接或间接调用的函数之内。如果在处理代码之外的区域遇到了空throw语句,编译器将调用 terminate。
    • 一个重新抛出语句并不指定新的表达式,而是将当前的异常对象沿着调用链向上传递。很多时候,catch 语句会改变其参数的内容。如果在改变了参数的内容后catch语句重新抛出异常,则只有当catch 异常声明是引用类型时我们对参数所做的改变才会被保留并继续传播。

    捕获所有异常的语句: catch (…) ,它可以捕获所有的异常,因此它应该是放在最后的 catch。出现在捕获所有异常语句后面的 catch 语句将永远不会被匹配。

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

​ 要想处理构造函数与初始值抛出的异常,我们必须将构造函数写成函数 try 语句块的形式。函数 try 语句块使得一组 catch 语句既能处理构造函数体(或析构函数体),也能处理构造函数的初始化过程(或析构函数的析构过程)。

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

这是处理构造函数初始值异常的唯一方法。

1.4 noexcept 异常说明

  1. 对于用户及编译器来说,预先知道某个函数不会抛出异常显然大有裨益。首先,知道函数不会抛出异常有助于简化调用该函数的代码;其次,如果编译器确认函数不会抛出异常,它就能执行某些特殊的优化操作,而这些优化操作并不适用于可能出错的代码。

    对于一个函数来说,noexcept 说明要么出现在该函数的所有声明语句和定义语句中,要么一次也不出现。该说明应该在函数的尾置返回类型(参见6.3.3节,第206页) 之前。我们也可以在函数指针的声明和定义中指定 noexcept。在 typedef 或类型别名中则不能出现 noexcept。在成员函数中,noexcept说明符需要跟在const 及引用限定符之后,而在final、override或虚函数的 = 0之前。

    noexcept 可以用在两种情况下:一是我们确认函数不会抛出异常,二是我们根本不知道该如何处理该异常。(一旦一个 noexcept函数抛出了异常,程序就会调用 terminate 以确保遵守不在运行时抛出异常的承诺。)指明某个函数不会抛出异常可以令该函数的调用者不必再考虑如何处理异常。无论是函数确实不抛出异常,还是程序被终止,调用者都无须为此负责。

  2. 异常说明的实参
    noexcept 说明符接受一个可选的实参,该实参必须能转换为bool类型: 如果实参是true,则函数不会抛出异常;如果实参是false,则函数可能抛出异常:

    void recoup(int) noexcept (true);	//recoup不会抛出异常
    void alloc(int) noexcept (false);	//alloc可能抛出异常
    
  3. noexcept 运算符
    noexcept 说明符的实参常常与 noexcept 运算符(noexcept operator)混合使用。noexcept运算符是一个一元运算符,它的返回值是一个 bool 类型的右值常量表达式,用于表示给定的表达式是否会抛出异常。和 sizeof(参见4.9节,第139页)类似,noexcept也不会求其运算对象的值。

    noexcept(e);
    当 e 调用的所有函数都做了不抛出说明且e本身不含有throw语句时,上述表达式为true;否则noexcept(e) 返回false。

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

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

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

18.2 命名空间

2.1 命名空间定义

  1. 命名空间为防止名字冲突提供了更加可控的机制。

    和其他名字一样,命名空间的名字也必须在定义它的作用域内保持唯一。命名空间既可以定义在全局作用域内,也可以定义在其他命名空间中,但是不能定义在函数或类的内部。

    • 每个命名空间都是一个作用域。
    • 命名空间可以是不连续的。
  2. 命名空间的一部分成员的作用是定义类,以及声明作为类接口的函数及对象,则这些成员应该置于头文件中,这些头文件将被包含在使用了这些成员的文件中。命名空间成员的定义部分则置于另外的源文件中。尽管命名空间的成员可以定义在命名空间外部,但是这样的定义必须出现在所属命名空间的外层空间中。换句话说,我们可以在 cplusplus primer 或全局作用域中定义Sales_data operator+,但是不能在一个不相关的作用域中定义这个运算符。比如A包含B,那么B中的东西可以定义在B中,A中和全局作用域中。但若是有一个和B没有什么包含关系的C,那么就不可以在C中定义B中的东西。

  3. 模板特例化必须定义在原始模板所属的命名空间中。和其他命名空间名字类似,只要我们在命名空间中声明了特例化,就能在命名空间外部定义它了。

    //我们必须将模板特例化声明成std的成员
    namespace std {
    	template <> struct hash<Sales_data>;
    }
    //在std中添加了模板特例化的声明后,就可以在命名空间std的外部定义它了
    template <> struct std::hash<Sales _data>
    size_t operator()(const sales_data& s) const
    { 
        return hash<string>() (s.bookNo) ^ 
        hash<unsigned>()(s.units_sold) ^
        hash<double>() (s.revenue); }
    	//其他成员与之前的版本一致
    };
    
  4. 作用域运算符同样可以用于全局作用域的成员,因为全局作用域是隐式的,所以它并没有名字。下面的形式
    : member_name
    表示全局命名空间中的一个成员。
  5. 内联命名空间,和普通的嵌套命名空间不同,内联命名空间中的名字可以被外层命名空间直接使用。即,不需要使用该命名空间的前缀,通过外层命名空间的名字就可以直接访问它。

    inline namespace FifthEd{
        //该命名空间表示本书第五版的代码
    }
    namespace FifthEd{
        //相关的声明
    }
    

    **关键字 inline 必须出现在命名空间第一次定义的地方,后续再打开命名空间的时候可以写 inline,也可以不写。**当应用程序在一次发布和另一次发布之间发生了改变时,常常会用到内联命名空间。

    例如第五版的代码可以如此使用(cplusplus中嵌套了 FifthEd 和 FourthEd 这两个命名空间)

    cplusplus::Query_base

    而第四版的代码就必须

    cplusplus::FourthEd::Query_base

  6. 未命名的命名空间是指关键字 namespace 后紧跟花括号括起来的一系列声明语句。未命名的命名空间中定义的变量具有静态生命周期:它们在第一次使用前创建,并且直到程序结束才销毁。

    • 一个未命名的命名空间可以在某个给定的文件内不连续,但是不能跨越多个文件。
    • 每个文件定义自己的未命名的命名空间,如果两个文件都含有未命名的命名空间,则这两个空间互相无关。在这两个未命名的命名空间中可以定义相同的名字,并且这些定义表示的是不同实体。
    • 如果一个头文件定义了未命名的命名空间,则该命名空间中定义的名字将在每个包含了该头文件的文件中对应不同实体。

    和其他命名空间不同,未命名的命名空间仅在特定的文件内部有效,其作用范围不会横跨多个不同的文件。定义在未命名的命名空间中的名字可以直接使用,毕竟我们找不到什么命名空间的名字来限定它们;同样的,我们也不能对未命名的命名空间的成员使用作用域运算符。所以一定要注意它与全局命名空间中名字的区别,否则会产生二义性!(表示全局命名空间中的名字参见第五条)

    一个未命名的命名空间也能嵌套在其他命名空间当中。此时,未命名的命名空间中的成员可以通过外层命名空间中的名字来访问。

    namespace A
    {
        namespace
        {
            int i;
        }
    }
    A::i = 10;
    

    未命名的命名空间的作用之一就是取代文件中的静态声明

    在标准C++引入命名空间的概念之前,程序需要将名字声明成 static的以使得其对于整个文件有效。在文件中进行静态声明的做法是从C语言继承而来的。在C语言中,声明为static的全局实体在其所在的文件外不可见。在文件中进行静态声明的做法已经被C++标准取消了,现在的做法是使用未命名的命名空间。

    //test1.h
    static char g[7] = "123456";	//static声明和定义同时给出
    void fun1();
    
    //test1.cpp
    #include "test1.h"
    #include <iostream>
    void fun1()
    {   
    	//g[0] = '9';
    	std::cout << g << std::endl;
    }
    
    //test2.h
    void fun2();
    
    //test2.cpp
    #include "test2.h"
    #include "test1.h"
    #include <iostream>
    void fun2()
    {
    	std::cout << g << std::endl;
    }
    
    main.cpp
    #include"test1.h"
    #include"test2.h"
    int main()
    {
    	fun1();
    	fun2();
    	return 0;
    }
    

    img

    如果修改掉test1.cpp中的注释,则运行结果为:

    img

    本部分代码参考来源链接

    所谓的不可见是指不同的文件中有不同的副本,修改其中一个不影响其他文件的(如果使用了 extern 则直接就在编译时error)。所以目前在C++中,用无命名空间代替 static 就可以了。

2.2 使用命名空间成员

  1. 命名空间的别名

​ namespace Qlib = cplusplus::QueryLib;

​ Qlib::Query q;

​ 一个命名空间可以有好多个同义词或别名,所有别名都与命名空间原来的名字等价。

  • using 声明

    • 一条 using 声明语句一次只引入命名空间中的一个成员。

    • 一条using声明语句可以出现在全局作用域、局部作用域、命名空间作用域以及类的作用域中。在类的作用域中,这样的声明语句只能指向基类成员(参见15.5节,第546页)。

      using 声明引入的名字遵守与过去一样的作用域规则:它的有效范围从using声明的地方开始,一直到using声明所在的作用域结束为止。在此过程中,外层作用域的同名实体将被隐藏。未加限定的名字只能在using声明所在的作用域以及其内层作用域中使用。在有效作用域结束后,我们就必须使用完整的经过限定的名字了。

  • using 指示

    • 和 using声明不同的地方是,我们无法控制哪些名字是可见的,因为所有名字都是可见的。
    • using 指示可以出现在全局作用域、局部作用域和命名空间作用域中,但是不能出现在类的作用域中。

    简写的名字从 using 指示开始,一直到 using 指示所在的作用域结束都能使用。

    头文件如果在其顶层作用域中含有 using 指示或 using 声明,则会将名字注入到所有包含了该头文件的文件中。

    建议:避免 using 指示

    using 指示一次性注入某个命名空间的所有名字,这种用法看似简单实则充满了风险:只使用一条语句就突然将命名空间中所有成员的名字变得可见了。如果应用程序使用了多个不同的库,而这些库中的名字通过using指示变得可见,则全局命名空间污染的问题将重新出现。而且,当引入库的新版本后,正在工作的程序很可能会编译失败。如果新版本引入了一个与应用程序正在使用的名字冲突的名字,就会出现这个问题。**另一个风险是由 using 指示引发的二义性错误只有在使用了冲突名字的地方才能被发现。**这种延后的检测意味着可能在特定库引入很久之后才爆发冲突。直到程序开始使用该库的新部分后,之前一直未被检测到的错误才会出现。相比于使用using指示,在程序中对命名空间的每个成员分别使用using声明效果更好,这么做可以减少注入到命名空间中的名字数量。using 声明引起的二义性问题在声明处就能发现,无须等到使用名字的地方,这显然对检测并修改错误大有益处。using指示也并非一无是处,例如在命名空间本身的实现文件中就可以使用using指示。

2.3 类、命名空间与作用域

  1. 实参相关的查找与类类型形参:对于命名空间中名字的隐藏规则来说有一个重要的例外,这个例外是,当我们给函数传递一个类类型的对象时,除了在常规的作用域查找外还会查找实参类所属的命名空间。这一例外对于传递类的引用或指针的调用同样有效。

  2. 在一个类中声明了一个友元时,只是通知一下这个类,它有这个友元。它只是指定了访问的权限,而非一个通常意义上的函数声明。如果我们需要调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明。(虽然有些编译器并未强制必须这么做)

    class fr;
    void print(fr &f)
    class fr
    {
        friend void print(fr &f);
    }
    

    当类声明了一个友元时,该友元声明并没有使得友元本身可见。然而,一个另外的未声明的类或函数如果第一次出现在友元声明中,则我们认为它是最近的外层命名空间的成员。这条规则与实参相关的查找规则结合在一起将产生意想不到的效果。

    namespace A {
    class C {
    //两个友元,在友元声明之外没有其他的声明
    //这些函数隐式地成为命名空间A的成员
    friend void f2 ();		 //除非另有声明,否则不会被找到
    friend void f(const C&); //根据实参相关的查找规则可以被找到
    };
    //此时,f和f2都是命名空间A的成员。即使f不存在其他声明,我们也能通过实参相关的查找规则调用f:
    int main ()
    {
        A::C cobj;
        f(cobj);	//正确:通过在A::C中的友元声明找到A::f
        f2();		//错误:A::f2没有被声明
    }
    

2.4 重载与命名空间

using 声明或 using 指示能将某些函数添加到候选函数集。

​ 某函数的实参属于类类型,则我们除了会在调用语句所在的作用域中查找,也会在该类型及其基类所属的命名空间中查找。

  • 重载与 using 声明

    using 声明语句声明的是一个名字,而非一个特定的函数。

using NS::print(int);	//error:不能指定形参列表
using NS::print;		//正确

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

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

  • 重载与 using 指示

    using 指示将命名空间的成员提升到外层作用域中,如果命名空间的某个函数与该命名空间所属作用域的函数同名,则命名空间的函数将被添加到重载集合中。

    与 using 声明不同的是,对于 using 指示来说,引入一个与已有函数形参列表完全相同的函数并不会产生错误(如果是 using 声明的话,那该语句会报错)。此时,只要我们指明调用的是命名空间中的函数版本还是当前作用域的版本即可。

跨越多个 using 指示的重载:如果存在多个 using 指示,则来自每个命名空间中的名字都会成为候选函数集的一部分。

18.3 多重继承与虚继承

3.1 多重继承

  1. 和只有一个基类的继承一样,多重继承的派生列表也只能包含 已经被定义过的类
  2. 基类的构造顺序与派生列表中基类的出现顺序保持一致,而与派生类构造函数初始值列表中基类的顺序无关。
  3. 如果一个类从它的多个基类中继承了相同的构造函数(形参列表完全相同),则这个类必须为该构造函数定义它自己的版本。(从基类中继承构造函数,即 using 声明的那个)

3.2 类型转换与多个基类

​ 虚析构函数的虚属性也会被继承。

3.3 多重继承下的类作用域

当一个类拥有多个基类时,有可能出现派生类从两个或更多基类中继承了同名成员的情况。此时,不加前缀限定符直接使用该名字将引发二义性

3.4 虚继承

  1. 在默认情况下,派生类中含有继承链上每个类对应的子部分。如果某个类在派生过程中出现了多次,则派生类中将包含该类的多个子对象。

  2. 虚继承:令某个类作出声明,承诺愿意共享它的基类。

    虚基类:共享的基类子对象称为虚基类。

    在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含唯一一个共享的虚基类子对象。

    //关键字public和virtual的顺序随意
    class Raccoon : public virtual ZooAnimal { /*...*/ };
    class Bear : virtual public ZooAnimal { /* ...*/ };
    class Panda : public Bear, public Raccoon, public Endangered{
    };
    
  3. 虚基类成员的可见性:

    因为在每个共享的虚基类中只有唯一一个共享的子对象,所以该基类的成员可以被直接访问,并且不会产生二义性。此外,如果虚基类的成员只被一条派生路径覆盖,则我们仍然可以直接访问这个被覆盖的成员。但是如果成员被多余一个基类覆盖,则一般情况下派生类必须为该成员自定义一个新的版本。例如,假定类B定义了一个名为x的成员,D1 和 D2都是从B虚继承得到的。D继承了 D1和 D2,则在D的作用域中,x 通过 D 的两个基类都是可见的。如果我们通过 D 的对象使用 x,有三种可能性:

    • 如果在D1和 D2中都没有x的定义,则x将被解析为B的成员,此时不存在二义性,一个D的对象只含有x的一个实例。
    • 如果 x 是 B 的成员,同时是D1和 D2中某一个的成员,则同样没有二义性,派生类的 x 比共享虚基类B的 x 优先级更高。
    • 如果在 D1 和 D2 中都有 x 的定义,则直接访问 x 将产生二义性问题。与非虚的多重继承体系一样,解决这种二义性问题最好的方法是在派生类中为成员自定义新的实例。

3.5 构造函数与虚继承

  1. 在虚派生中,虚基类是由最低层的派生类初始化的。也就是说,即使虚基类不是某个类的直接基类,该类的构造函数也可以初始化虚基类。(对于非虚继承而言, 构造函数只能初始化它的直接基类

    Panda::Panda (std::string name, bool onExhibit)
    :ZooAnimal (name,onExhibit, "Panda" ),
    Bear (name,onExhibit),
    Raccoon (name, onExhibit),
    Endangered (Endangered::critical), sleeping_flag (false) {}
    
    1. 虚继承的对象的构造方式

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

    1. 构造函数与析构函数的次序

    一个类可以有多个虚基类。此时,这些虚的子对象按照它们在派生列表中出现的顺序从左向右依次构造。在全部的虚基类都构造完成后,然后再按照声明的顺序逐一构造其他非虚基类。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值