C++ Primer 学习笔记 第十八章 用于大型程序的工具

异常处理允许程序中独立开发的部分能够在运行时就出现的问题进行通信并做出相应处理。它能将问题的检测和解决过程分离开来,程序一部分负责检测问题的出现,解决该问题的任务传递给程序的另一部分。

通过抛出一条表达式来引发一个异常,抛出的表达式类型和当前的调用链共同决定了哪段处理代码处理该异常。

执行throw后,程序的控制权从throw交给与之匹配的catch模块。这可能引起调用链的函数提前退出,和沿着调用链创建的对象将被销毁。

栈展开:在抛出一个异常后,如在一个try块中抛出,则在这个try块的catch子句中找匹配的异常,如未找到,并且该try语句嵌套在其他try块中,则在外层try块的catch子句中匹配,如还是没有匹配成功,则退出当前函数,在调用当前函数的函数中继续寻找。

栈展开沿着嵌套函数的调用链不断查找,直到找到了与异常匹配的catch子句,或退出主函数后终止。

如找到了匹配的catch子句,则程序运行该catch子句中的代码,执行完后,从该catch子句之后开始运行。

如没找到匹配的catch子句,程序调用标准库函数terminate终止程序的执行。

栈展开过程中会退出某些块,这个块中的对象会被销毁。如异常发生在构造函数中,有些成员已被初始化,我们要确保已构造的成员被正确地销毁。如异常发生在数组或标准库容器元素初始化过程中,应确保已经构造的元素被正确销毁。

如在块中分配了资源,但在释放资源前发生了异常,则资源不会被释放。但类的析构函数总是会执行,即使是栈展开时也需要析构函数销毁对象,因此我们应使用类来控制资源的分配(或使用智能指针)。

栈展开过程中,会使用类类型的析构函数销毁对象,因此析构函数中的异常应在析构函数内部得到处理,否则程序将被终止,而不是析构函数中不能抛出异常:

class c {
public:
    ~c() {
        try {
            throw exception("aaa");
        } catch (exception e) {
            cout << "caught exception: " << e.what() << endl;
        };
    }
};

void func() {
    c cObj;
    throw exception("bbb");
}

int main() {
    try {
        func();
    } catch (exception e) {
        cout << "caught exception: " << e.what() << endl;
    }
}

运行以上程序:
在这里插入图片描述

编译器使用异常抛出表达式对异常对象进行拷贝初始化。因此,throw语句中的表达式必须是完全类型(不能只是声明,也要有定义的类),如该表达式(throw后边的为表达式)是类类型,则相应的类必须含有可访问的析构函数和可访问的移动或拷贝构造函数。如该表达式是数组或函数类型,则表达式被转换为与之对应的指针类型。抛出string表达式:

    try {
        throw new string("sss");
    } catch (string *s) {
        cout << "caught exception: " << s->c_str() << endl;
    }

运行它:
在这里插入图片描述

异常对象位于编译器管理的空间中,编译器保证无论最终调用的是哪个catch子句都能访问该空间,异常处理完毕后,异常对象被销毁。

抛出一个指向局部对象的指针几乎总是错误的,因为栈展开时会销毁局部对象,如指针所指对象位于某个块中,而该块在catch语句之前就退出了,则意味着执行catch语句前该局部对象就被销毁了。以下是正确的:

    string s = "sss";

    try {
        throw &s;
    } catch (string *s) {    // 在catch到此异常时,没有退出当前函数
        cout << "caught exception: " << s->c_str() << endl;
    }

运行它:
在这里插入图片描述

抛出的表达式的静态类型决定了异常对象的类型。如抛出的表达式类型来自一个继承体系,在throw后解引用一个基类指针,而该指针实际指向派生类对象,则派生类部分会被切掉。

catch子句的异常声明看起来像是只包含一个形参的函数形参列表,如catch无需访问抛出的表达式,则我们可以忽略捕获形参的名字。

异常声明的类型决定了处理代码所能捕获的异常类型,这个类型必须是完全类型,且可以是左值引用,但不能是右值引用。

进入catch语句后,异常对象用来初始化异常声明中的参数。如catch中异常声明的参数类型是非引用类型,则该参数是一个异常对象的副本,而如果参数类型是引用类型,则该参数是异常对象的一个别名,改变参数也就相当于改变异常对象。

异常声明的静态类型决定catch语句所能做的操作,如catch的参数是基类类型,则它不能使用派生类特有的任何成员,但可以使用函数重写:

class a {
public:
    virtual string func() {
        return "in a";
    }
};

class b : public a {
public:
    virtual string func() override {
        return "in b";
    }
};

int main() {
    try {
        throw b();
    } catch (a &aObj) {
        cout << aObj.func();
    }
}

运行它:
在这里插入图片描述

通常,如果catch接受的异常与某个继承体系有关,最好将catch的参数定义为引用类型。(书中的推荐的最佳实践,作用是动态绑定虚函数成员,使用指针也可以)

搜寻catch语句过程中,最终找到的catch未必是异常的最佳匹配,应是第一个与异常匹配的catch语句,因此,越是专门的catch越应该位于整个catch列表的前端,最派生的类放在前面,最基类的类放在后面。

除了以下三点,要求异常的类型和catch声明的类型是精确匹配的:
1.非常量的异常对象可以匹配接受常量引用的catch语句。
2.允许派生类向基类的类型转换。(不用引用或指针):

class B { };

class D : public B { };

int main() {
    D d;
    try {
        throw d;
    } catch (B b) {
        cout << "派生类向基类转换" << endl;    // 被输出
    }
}

3.数组和函数被转换为对应的指向其的指针。

其他的整型提升、算术类型转换和类类型转换都不能用在catch匹配过程。

一条catch语句可以通过重新抛出操作将异常传递给另外一个catch语句:

throw;    // 此时不含任何表达式

空的thorw只能出现在catch子句中,如出现在其他地方,编译器将调用terminate。

catch语句常改变参数内容,此时再重新抛出异常时,只有catch异常声明是引用类型时,我们所做的改变才会继续保留传播:

catch (my_error &eObj) {
    eObj.status = errCodes::severeErr;    // 修改异常对象
    throw;    // 抛出改变之后的异常对象
} catch (other_error eObj) {
    eObj.status = errCodes::badErr;    // 修改的是异常对象的副本
    throw;    // 抛出没有被改变的副本
}

捕获所有异常:

try {
    // 引发一个异常
} catch (...) {    // 捕获任何异常
    // 处理异常
    throw;
}

如除了catch(…)外,还有其他catch语句,必须将catch(…)放在最后,否则其后边的catch语句永远不会捕获到异常。

构造函数体执行之前会执行构造函数初始值列表,如此时发生异常,构造函数体内的try语句块还未生效,导致构造函数体内的catch语句无法处理构造函数初始值列表中抛出的异常。要想处理它,要把构造函数写成函数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);
}

上例try关联的catch可以处理构造函数初始值列表和构造函数体中的异常。但在初始化构造函数参数时也可能发生异常,这种异常需要在调用表达式的上下文中处理。

C++ 11中可以通过提供noexcept说明指定某个函数不会抛出异常:

void recoup(int) noexcept;    // 该函数不会抛出异常

以上函数做了不抛出声明。noexcept需要出现在该函数的所有定义和声明的地方。该声明应出现在函数的尾置返回类型之前。也可以在函数指针的声明和定义中指定noexcept,但在typedef或类型别名中不能出现noexcept。成员函数中的noexcept需要出现在const及引用限定符后,且在final、override或虚函数的=0之前。

编译器并不会在编译时检查noexcept声明,因此可能会出现尽管函数声明了不会抛出异常,但实际上还是抛出了,一旦这种函数抛出了异常,程序会调用terminate,但这一过程对是否进行栈展开未作约定。因此noexcept用于:我们确认函数不会抛出异常或我们不知道该如何处理异常。

C++ 11前的版本中,我们可以通过throw说明符后的异常类型列表指定函数可能抛出的异常,但在C++ 11中已经取消,但向后兼容还能用:

void recoup(int) noexcept;
void recoup(int) throw();    // 不指定可能抛出的异常,两者等价

noexcept说明符接受可选的实参,该实参需能转化成bool类型:

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

C++ 11引入了noexcept运算符,常与noexcept说明符的实参混合使用:

noexcept(e);    // noexcept运算符,e是可调用对象,如果e调用的所有函数都做了不抛出说明且e也不含throw语句时,返回true
void f() noexcept(noexcept(g()));    // f和g的异常说明一致,可调用对象要加形参列表

noexcept说明符不属于函数类型的一部分。

函数指针及该指针所指的函数必须具有一致的异常说明。即做了不抛出声明的函数指针只能指向不抛出异常的函数;但没做不抛出声明的函数指针表示其指向的函数可能抛出异常,它就能指向任意函数:

void (*pf1)(int) noexcept = recoup;    // 正确,指针和函数都承诺不抛出异常
void (*pf2)(int) = recoup;    // 正确,pf2可能抛出异常

pf1 = alloc;    // 错误,alloc可能抛出异常
pf2 = alloc;    // 正确

如基类中一个虚函数承诺了它不会抛出异常,则派生出来的虚函数也要做不抛出声明;但如果基类中的虚函数没做不抛出声明,则派生类可以抛出异常,也可以不允许抛出异常。

编译器合成拷贝控制成员时,如果对所有成员和基类的所有操作都承诺了不会抛出异常,则合成的成员是noexcept的。而且,如果我们自己定义了一个析构函数,但没有为它提供异常说明,则编译器会合成一个异常说明,这个合成的异常说明与假设编译器为类合成析构函数时所得的异常说明一致。

异常类层次:
在这里插入图片描述
exception类中定义了拷贝构造函数、拷贝赋值运算符、虚析构函数、名为what的虚成员,该成员返回const char *指针,指向一个以空字符结尾的字符数组,且不会抛出任何异常。

exception类、bad_cast类、bad_alloc类定义了默认构造函数和接受C风格字符串或string类型的构造函数,而类runtime_err和logic_err只有接受C风格字符串或string类型的构造函数,接受C风格字符串或string类型的构造函数负责提供关于错误的更多信息,而what负责返回这些信息,又因为what是虚函数,因此,我们捕获基异常类的引用时,对what的调用为动态绑定,将执行与异常对象动态类型对应的版本。

自定义异常类,用于书店程序:

class out_of_stock : public std::runtime_err {
public:
    explicit out_of_stock(const std::string &s) : std::runtime_error(s) { }
};

class isbn_mismatch : public std::logic_err {
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;
    }
}

what函数不应抛出异常。

大型程序开发时会用到很多库,如果将所有库的名字放置在全局命名空间将引发命名空间污染。传统上,程序员通过将其定义的全局实体名字写得很长来避免命名空间污染,一般会在全局实体前加库名,但这样不利于书写和阅读。

命名空间分隔了全局命名空间,每个命名空间都是一个作用域,从而避免了使用多个库带来的命名空间污染问题。

定义命名空间:

namespace cpp_primer {
    // 能包含全局作用域中的声明和定义
    // 主要包括类、变量(及其初始化操作)、函数(及其定义)、模板、其他命名空间
}    // 结尾无需分号

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

命名空间可以不连续的特性使得我们可以将头文件和源文件分开,即将命名空间中类的定义、类接口函数及对象的声明放在头文件中,将命名空间中成员的定义部分放到源文件中。

class A;

void func(A a) { }    // 错误,不完全类型不能用于函数定义
void func1(A);    // 正确,用于函数声明

命名空间的名字在它所在的作用域中保持唯一。命名空间能定义在:全局作用域内、其他命名空间中,不能定义在函数或类的内部。

定义在某个命名空间内的名字可被该命名空间和内嵌作用域中的任何单位访问。该命名空间之外的代码必须使用作用域运算符::指出名字所属的命名空间。

程序中的非内联函数、静态数据成员、变量等,只能定义一次,在命名空间中定义的名字也要满足这一要求。

如一个命名空间中定义了多个类型不相关的类型,应使用不同文件表示每个类型。我们可以将cpp_primer库定义在不同文件中,Sales_data类的声明应放在Sales_data.h头文件,Query类声明放入Query.h头文件中:

// ------Sales_data.h-----
#include <string>    // 保证出现在打开命名空间操作前
namespace cpp_primer {
    class Sales_data { /*.....*/ };
    Sales_data operator+(const Sales_data &, const Sales_data &);
    // Sales_data的其他接口函数的声明
}
// ------Sales_data.cc-----
#include "Sales_data.h"    // 保证出现在打开命名空间操作前
namespace cpp_primer {
    // Sales_data成员及重载运算符的定义
}

用户使用:

// ------user.cc-----
#include "Sales_data.h"
int main() {
    using cpp_primer::Sales_data;
    Sales_data trans;
    // ...
    return 0;
}

#include放在命名空间内部的含义是把它引入的头文件中的所有名字定义成该命名空间的成员。如在打开了cpp_primer命名空间之后我们再包含头文件string,表示将命名空间std嵌套在命名空间cpp_primer中,程序会报错。

定义命名空间中成员的两种方法,法一:

#include "Sales_data.h"
namespace cpp_primer {    // 先打开命名空间
    std::istream &operator>>(std::istream &in, Sales_data &s) { /*.....*/ }    // 此时命名空间中的名字可以直接访问
}

法二:

#include "Sales_data.h"
cpp_primer::Sales_data cpp_primer::operator+(const Sales_data &lhs, const Sales_data &rhs) {
    Sales_data ret(lhs);    // 一旦有了完整前缀名,就可以确定Sales_data所在的命名空间了,此处和形参的Sales_data都不用加命名空间
    // ...
}

法二的定义方式只能出现在全局作用域中或cpp_primer作用域中,而不能出现在不相关的作用域内。

模板特例化必须声明在原始模板所属的命名空间中:

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.book) ^ hash<unsigned>()(s.units_sold) ^ hash<double>()(s.revenue);
    }
    // 其他成员
}

全局命名空间定义所有类、函数、命名空间之外定义的名字。它以隐式方式声明,并存在于所有程序中。全局作用域中的名字隐式地添加到全局命名空间中。

::member_name;    // 表示全局命名空间中一个成员

嵌套的命名空间:定义在其他命名空间中的命名空间。

namespace cpp_primer {
    namespace QueryLib {
    }
    namespace Bookstore {
    }
}

嵌套的命名空间也是嵌套的作用域,内层命名空间声明的名字隐藏外层命名空间声明的名字。

访问内层命名空间名字:

cpp_primer::QueryLib::Query;

C++ 11引入了内联命名空间,内联命名空间中的名字可被外层命名空间直接使用:

inline namespace FifthEd { }    // 定义时要加inline

namespace FifthEd { }    // 再次打开时可加inline,也可不加,如不加,为隐式inline

内联命名空间的一种适用场景:

namespace cpp_primer {
    #include "FifthEd.h"    // 当前版本代码,其中定义了内联的第五版代码的命名空间
    #include "FourthEd.h"    // 早期版本的代码,头文件中定义了第四版代码的命名空间
}

如上,我们就能直接用cpp_primer::获取当前版本代码的命名空间中成员,而使用早期版本代码时必须把早期版本的命名空间也注明。#include语句后不用分号。

未命名的命名空间:namespace后直接跟花括号。其中定义的变量拥有静态生命周期(它们在第一次使用前创建,直到程序结束才销毁)。

未命名的命名空间可以在某个文件内不连续,但不能跨越多个文件。如两个文件中各有一个未命名的命名空间,则这两个空间无关。

如一个头文件中定义了未命名的命名空间,则在该命名空间中定义的名字将在每个包含了该头文件的文件中对应不同实体。

定义在未命名的命名空间中的名字可以直接使用,我们不能对未命名的命名空间中的成员使用作用域运算符。

未命名的命名空间中的成员的作用域与该未命名命名空间所在的作用域相同:

int i;    // 全局声明
namespace {
    int i;
}

i = 10;    // 二义性错误
namespace local {
    namespace {
        int i;
    }
}

local::i = 42;    // 正确

C++引入命名空间前,程序需将全局名字声明为static的使其对于整个文件有效,而其他文件访问不到,即使使用extern声明也无法访问到另一文件中的static全局实体,即static全局实体的作用域是文件。这是由C继承而来的,这一做法已被未命名的命名空间代替,前一用法已被C++标准取消。

命名空间别名:

namespace primer = cpp_primer;
namespace Qlib = cpp_primer::QueryLib;

命名空间定义后才能声明别名。一个命名空间可以有多个别名。

using声明一次引入命名空间的一个成员,它使得我们可以清楚地知道程序中用的是哪个名字。using声明的有效范围从using声明的地方开始,到using声明所在的作用域结束为止。此过程中,外层作用域的同名实体被隐藏,有效作用域结束后,我们就必须使用加上命名空间限定的名字了。

using声明可以出现在全局作用域、局部作用域、命名空间作用域、类作用域中。出现在类作用域中时,它只能指向基类中的非private名字,用以改变基类中成员的访问规则。

using指示使得命名空间中所有名字都是可见的:

using namespace std;    // using指示

using指示所用的名字只能是一个定义好的命名空间名字,否则程序会报错。

using指示可以出现在全局作用域、局部作用域、命名空间作用域中。

using指示有效范围从using指示开始,到using指示所在作用域结束。

using指示令命名空间中所有内容变得有效,通常,命名空间中会含有一些不能出现在局部作用域中的定义,因此,using指示一般被看作是出现在最近的外层作用域中:

namespace A {
    int i, j;
}

void f() {
    using namespace A;    // 在此处使用using指示,在f看来,A中的名字仿佛是出现在全局作用域中f之前的位置一样
    cout << i * j << endl;
}    // 到此处using指示失效
namespace blip {
    int i = 16, j = 15, k = 23;
}

int j = 0;

void manip() {
    using namespace blip;    // 在manip中看来,blip中的成员被添加到了全局作用域中
    ++i;    // 执行后blip::i为17
    ++j;    // 二义性错误
    ++::j;    // 全局变量j为1
    ++blip::j;    // blip中值为16
    int k = 97;    // 隐藏了blip中的k
    ++k;    // 局部变量k值为98
}

头文件中如果在其顶层作用域中含有using指示或using声明,则会将名字注入到所有包含了该头文件的文件中。头文件应只负责定义接口部分的名字,而不定义实现部分的名字,因此,头文件最多在它的函数中或命名空间内使用using。

using指示如注入了过多库的名字,则全局命名空间污染的问题又会重现。风险在于,引入了库新版本后,如果新版本中有一个与当前使用的名字冲突的名字,就会编译失败,并且只有当使用了冲突名字的时候才会检测到错误,这就延后了冲突爆发时间。而using声明引起的二义性问题在声明处就能发现。

namespace blip {
    int i = 16, j = 15, k = 23;
}

int i = 4;

int main() {
    using blip::i;
    cout << i << endl;    // 输出16,隐藏了全局变量i
}

命名空间内部的名字查找是由内向外依次查找每个外层作用域:

namespace A {
    int i;
    
    namespace B {
        int i;    // 隐藏A::i
        int j;
        int func() {
            int j;    // 隐藏A::B::j
            return i;    // 返回值为A::B::i
        }
    }    // B中名字到此之后不可见
    
    int func2() {
        return j;    // 错误
    }
}

命名空间中的类的成员函数使用某个名字时,首先在该成员函数中查找,之后在类(包括基类)中查找,接着在外层作用域中查找:

namespace A {
    int i;
    int k;
    
    class C1 {
    public:
        C1() : i(0), j(0) { }    // 初始化C1::i和C1::j
        int f1() {
            return k;    // 返回A::k
        }
        int f2() {
            return h;    // 错误,h还未定义
        }
        int f3();
    private:
        int i;    // 隐藏了A::i
        int j;
    }
    
    int h = i;    // A::i初始化A::h
}
// f3成员定义在命名空间A外部
int A::C1::f3() {
    return h;    // 正确,返回A::h,因为f3定义在h定义之后,因此正确
}
std::string s;
std::cin >> s;
operator>>(std::cin, s);    // 与上句代码等价

以上代码中,operator>>定义在标准库string中,string又定义在命名空间std中,但我们不用std::限定符或using声明就能调用operator>>运算符,这是因为当我们给函数传递一个类类型对象(或类类型指针、引用)时,除了在常规作用域中查找函数外,还会在实参类所属的命名空间中查找,此例中,编译器还会查找cin和s所属的命名空间std,在std中,编译器找到了string的输出运算符。

以上这个查找规则允许概念上作为类接口一部分的非成员函数无须单独的using声明或作用域运算符限定就可以使用,否则,我们要这样调用string的输入运算符:

using std::operator>>;
operator>>(std::cin, s);
// ------------或以下方式------------
std::operator>>(std::cin, s);

如果程序中定义了一个标准库中已有的名字,要么会重载,要么会只执行用户定义的版本。对于move和forward,最好加上std::限定,因为它们都是函数模板,且形参是模板类型的右值引用,因此它们可以匹配任何类型的变量,而此时如果我们自定义了一个move函数,就会发生冲突,如我们定义的也是模板函数,接受的形参也是模板参数类型右值引用,那么就会永远二义性调用;如我们定义的是接受一个具体类型如int的函数,那么使用int实参时,永远都会调用自定义的版本。

类声明一个友元时,并没有使得友元本身可见,如果想让类的用户使用友元,必须在类外再声明。一个未声明过的类或函数如第一次出现在友元声明中,则我们认为它是最近的外层命名空间中的成员:

namespace A {
    class C {
        friend void f2();
        friend void f(const C &);
    };    // 此时f和f2都是命名空间A中的成员
}

int main() {
    A::C cObj;
    f(cObj);    // 正确,因为编译器会在实参类型所在的命名空间中查找函数,而f函数隐式地成为A命名空间的成员
    f2();    // 错误,它没有实参,不会在命名空间A中搜索此函数,只能用using或作用域运算符限定
}

对于接受类类型实参、类类型指针或引用的函数来说,将在每个实参类以及实参类的基类所属的命名空间中搜寻候选函数:

namesapce NS {
    class Quote { /*...*/ }
    void display(const Quote &) { /*...*/ }
}

class Bulk_item : public NS:Quote { /*...*/ }

int main() {
    Bulk_item book1;
    display(book1);    // 基类命名空间NS中的display也被加入候选集中
    return 0;
}

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

using NS::print(int);    // 错误,不能指定形参列表
using NS::print;    // 正确,将NS中所有print版本都引入当前作用域

如using声明出现在局部作用域中,则引入的名字将隐藏外层作用域中的名字,同时重载当前局部作用域中的同名名字。如using声明所在作用域已有一个与新引入的函数同名且形参列表相同,则using声明将出错。

而using指示将命名空间的成员提升到外层作用域中,因此using指示不会覆盖外层作用域的成员,而是成为外层成员的重载,且using指示允许与一个已有函数的函数名和形参列表完全相同的函数,只要调用时使用作用域限定即可。

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

多重继承指从多个直接基类中产生派生类的能力,此时派生类继承了所有父类的属性。

例子:定义一个抽象基类ZooAnimal,保存动物园中所有动物共有信息。类Bear存放Bear科特有的信息,以此类推。

辅助类应负责封装不同的抽象,如濒临灭绝的动物,以Panda类为例,Panda是由Bear和Endangered共同派生而来:

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

如上,每个基类包含可选的访问说明符。与只有一个基类继承时一样,多重继承派生列表的每个基类都要被定义过,而不能只是声明过。派生类能继承的基类个数C++没有规定,但在某个给定派生列表中,同一个基类只能出现一次。

Panda对象概念结构:
在这里插入图片描述
Panda类的构造函数:

Panda::Panda(std::string name, bool onExhibit) : Bear(name, onExhibit, "Panda") ,Endangered(Endangered::critical) { }

Panda::Panda() : Endangered(Endangered::critical) { }    // 隐式使用Bear的默认构造函数初始化Bear子对象

基类的构造顺序与派生列表中基类出现的顺序保持一致。Panda对象初始化顺序:
1.Bear是Panda的派生列表中第一个基类,又由于ZooAnimal是Bear的基类,因此先初始化ZooAnimal。
2.初始化Bear的非基类的Bear部分。
3.初始化Endangered。
4.初始化Panda的非基类Panda部分。

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

struct D1 : public Base1, public Base2 {
    using Base1::Base1;    // 从Base1继承构造函数
    using Base2::Base2;    // 从Base2继承构造函数出错
};

改正:

struct D2 : public Base1, public Base2 {
    using Base1::Base1;    // 从Base1继承构造函数
    using Base2::Base2;    // 从Base2继承构造函数
    D2(const string &s) : Base1(s), Base2(s) { }    // 必须重新定义接受相同形参的构造函数
};

与往常一样,析构函数负责释放派生类本身分配的资源,其调用顺序与构造函数相反,Panda类析构时调用析构函数的顺序:Panda、Endangered、Bear、ZooAnimal。

合成的拷贝赋值运算符首先赋值Bear部分(通过Bear赋值ZooAnimal部分),然后赋值Endangered部分,最后是Panda部分,与对象的构造顺序一致。

多个基类中的任意可访问的基类的指针或引用都能指向派生类,基类的指针或引用类型决定了我们能使用哪些成员。派生类向每一种基类的转换都是一样好的,因此在函数调用时,当有两个函数分别接受不同基类的引用或指针时,会出现二义性错误。

派生类中的名字会隐藏基类中的同名成员:

class B {
public:
    void func() {
        cout << "B" << endl;
    }
};

class D : public B {
public:
    void func(int) {
        cout << "D" << endl;
    }
};

int main() {
    D d;
    d.func();    // 错误,func已被隐藏
    d.B::func();    // 正确
}

多重继承时,如名字在多个基类都能找到,则对该名字的使用具有二义性:

class B1 {
public:
    void func() {
        cout << "B1" << endl;
    }
};

class B2 {
public:
    void func(int) {
        cout << "B2" << endl;
    }
};

class D : public B1, public B2 { };

int main() {
    D d;
    d.func();    // 错误,func二义性
    d.B1::func();    // 正确
}

即使来自于两个基类的同名成员的形参列表不同、访问权限不同,调用时也具有二义性,因为此时编译器会先查找名字后类型检查,编译器在两个作用域中都发现了此名字,将直接报告一个二义性错误。为避免这种二义性,可以在派生类中定义一个同名成员覆盖来自基类的同名成员。

派生类可以多次继承同一个类,方法是直接继承某个基类,再通过另一个基类间接继承该类。该方法没有违反派生列表中各基类名只能出现一次的规则。IO库的istream和ostream都继承了基类base_ios,而iostream类由继承了istream和ostream,此时base_ios被继承了两个。

默认,派生类中含有继承链上每个类对应的子部分,因此,如某个类派生过程中出现了多次,该派生类将包含该类多个子对象。但这对iostream对象行不通,因为希望它只在一个缓冲区读写,也要求条件状态同时反映输入输出状态,但istream中含两个base_ios,就不能实现以上要求了。

我们可以通过虚继承解决,它令某个类作出声明,承诺愿意共享它的基类,共享基类的子对象称为虚基类,此时,无论虚基类在继承体系中出现几次,派生类中都会含有唯一一个共享的虚基类子对象。

虚基类例子:大熊猫属于Raccoon科还是Bear科有争论,我们可以通过令Panda类同时继承Raccoon和Bear来反映这一点:
在这里插入图片描述
我们必须在虚派生的需求(即Panda类)出现前就完成虚派生操作,如定义Panda时还没有虚派生操作,就要改变类的继承结构。

虚派生时,不会影响虚派生出来的类(Bear和Raccoon),只会影响从派生类派生出来的类(Panda)。

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

不论基类是不是虚基类,派生类对象都能被可访问基类的指针或引用操作。

共享的虚基类中只有一份子对象,因此可直接访问不会产生二义性。

如虚基类的成员被一条派生路径上的某派生类覆盖,我们访问时访问的是虚基类的派生类中新定义的成员,这个新定义的成员会覆盖虚基类的成员,由于虚基类的成员只有一份,因此相当于任何路径上都覆盖了,而不会产生二义性。但如果成员被多于一个派生类所覆盖,则调用会产生二义性。

虚派生中,虚基类是由最后的派生类初始化的,如Panda初始化ZooAnimal,总的构造顺序:
1.Panda使用函数初始值列表中初始值构造虚基类ZooAnimal。(虚基类总是先于非虚基类构造,需要在Panda类的构造函数中调用虚基类ZooAnimal的构造函数)
2.构造Bear部分。
3.构造Raccoon部分。
4.构造Endangered部分。
5.构造Panda部分。

如Panda没有显式初始化ZooAnimal基类,则调用ZooAnimal的默认构造函数。如ZooAnimal没有默认构造函数,则会报错。

虚继承体系中每个类都可能成为最后的派生类,因此每个虚基类的派生类都要在构造函数中调用虚基类的构造函数初始化虚基类。

有多个虚基类时,按照派生列表中虚基类出现的顺序创建虚基类。如一个类的派生类路径中有虚基类,构造排序时也算虚基类。

合成的拷贝、移动构造函数,合成的赋值运算符都按以上顺序执行。对象销毁的顺序正好与构造顺序相反。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值