【c++ Prime 学习笔记】第18章 用于大型程序的工具

18.2 命名空间

  • 大型程序使用多个独立开发的库,它们定义的名字可能冲突。多个库将名字都放在全局命名空间中将引发命名空间污染
  • 传统上解决命名空间污染的方法之一是将全局名字设得很长,这些名字通常包含库的前缀,但这样不好
  • 命名空间为防止名字冲突提供了更加可控的机制。命名空间分割了全局命名空间,每个命名空间是一个作用域,通过在某个命名空间中定义库的名字,库的作者和用户可避免全局名字固有的限制

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 name {}可能是创建新命名空间,也可能是打开已存在的命名空间向其中添加成员
  • 命名空间的不连续性,允许将几个独立的接口和实现文件组成一个命名空间,此时命名空间的组织方式类似管理自定义类/函数的方式
    • 命名空间某些成员的作用是定义类、声明作为类接口的函数、对象,这些成员应置于头文件
    • 命名空间成员的定义部分应置于源文件
  • 某些实体只能定义一次(如非内联函数、静态数据成员、静态变量),将命名空间分离为头文件部分和源文件部分可实现这些要求
  • 定义含多个不相关类型的命名空间时应使用单独的头文件和源文件分别表示每个类型

定义本书的命名空间

  • 通常不把#include放在命名空间内,因为这么做的含义是将该头文件中的所有名字定义为该命名空间的成员
/* 文件名:Sales_data.h */
#include<string>                        //#include头文件不可放入命名空间
namespace cplusplus_primer{             //头文件中打开命名空间,放入类的定义和接口函数的声明
    class Sales_data{/* 类的定义 */};
    Sales_data operator+(const Sales_data &,const Sales_data &);
    /* 其他接口的声明 */
}
/* 文件名:Sales_data.cc */
#include"Sales_data.h"                  //#include头文件不可放入命名空间
namespace cpluspluc_primer{             //源文件中打开命名空间,实现类的成员函数和接口函数
    /* 类成员函数和接口函数的实现 */ 
}
/* 文件名:user.cc */
#include"Sales_data.h"
int main(){
    using cplusplus_primer::Sales_data; //从命名空间中引入名字,该名字在头文件中声明,源文件中实现
    Sales_data trans1,trans2;
    return 0;
}

定义命名空间成员

  • 命名空间中的名字必须声明在命名空间内,定义可在空间中也可在空间外
  • 命名空间中的名字定义在空间外时类似类外定义成员,需用作用域算符::指明命名空间。一旦看到含有完整前缀的名字,就可确定该名字位于该命名空间,后面的形参列表和函数体不再需要指定该命名空间
  • 尽管命名空间的名字可定义在空间外,但必须定义在所属空间的外层空间中。即,可在该空间内或全局作用域中定义,但不能在不相关的作用域内定义
//返回类型和函数名需指定命名空间,函数名之后的部分不需要
cpluspluc_primer::Sales_data
cpluspluc_primer::operator+(const Sales_data &lhs,const Sales_data &rhs){
    /* 函数的实现 */
}

模板特例化

  • 模板特例化必须声明在原始模板所在的命名空间中,只要声明在命名空间中,就可在空间外定义它
//在std命名空间中声明特化的hash
namespace std{
    template <> struct hash<Sales_data>;
}
//在命名空间外实现特化的hash
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);
    }
};

全局命名空间

  • 全局作用域中定义的名字是定义在全局命名空间中。全局命名空间以隐式的方式声明,且在所有程序中都存在。
  • 使用作用域算符::可手动指定全局命名空间。由于全局作用域是隐式的,没有名字,故其中成员应写为::member

嵌套的命名空间

  • 嵌套命名空间是定义在其他命名空间中的命名空间。
  • 嵌套的命名空间同时是嵌套的作用域,它嵌套在外层命名空间的作用域中。
  • 内层命名空间中声明的名字将隐藏外层命名空间的同名成员,嵌套命名空间中定义的名字只在内层空间有效,外层需用::才可访问
//两个命名空间嵌套在一个外层空间中
namespace cplusplus_primer{                             //外层命名空间
    namespace QueryLib{                                 //嵌套命名空间
        class Query{/* 类的定义 */};
        Query operator&(const Query &,const Query &);
    }
    namespace Bookstore{                                //嵌套命名空间
        class Quote{/* 类的定义 */};
        class Disc_quote: public Quote{/* 类的定义 */};
    }
}

cplusplus_primer::QueryLib::Query

内联命名空间

  • C++11引入内联命名空间,它是一种特殊的嵌套命名空间,内敛空间中的名字可被外层空间直接使用而不需要::
    • 定义内联命名空间的方法是在关键字namespace前添加inline
    • inline必须出现在命名空间第一次定义的地方,后续打开空间时可写可不写
    • 程序代码更新版本时经常用内联空间:当前版本放在内联空间中,历史版本放在非内联空间中,并将这些空间一起内嵌到同一个外层空间中。效果是:用外层空间直接访问名字时访问的是当前版本,用外层空间和内层空间访问名字时可指定访问历史版本
/* 文件名:FifthEd.h */
inline namespace FifthEd{                       //定义第五版命名空间,是内联,使用时不需指定该空间的名字
    class Query_base{/* 类的定义 */};
}
/* 文件名:FourthEd.h */
namespace FourthEd{                             //定义第四版命名空间
    class Query_base{/* 类的定义 */};
}
/* 文件名:cplusplus_primer.h */
namespace cplusplus_primer{                     //将上面两个命名空间嵌套进外层空间
    #include"FifthEd.h"                         //将#include放在命名空间定义中,即引入头文件中的所有名字
    #include"FourthEd.h"
}
/* 文件名:main.cc */
#include"cplusplus_primer.h"
using cplusplus_primer::Query_base;             //默认使用第五版中的成员
using cplusplus_primer::FourthEd::Query_base;   //手动指定第四版中的成员

未命名的命名空间

  • 未命名的命名空间是指关键字namespace后紧跟花括号,而不指定命名空间的名字
  • 未命名的命名空间中定义的变量都有静态生命周期,即第一次使用前创建,程序结束时销毁
  • 未命名的命名空间可以在一个文件内不连续,但不可跨越文件。若两个文件中都有未命名的命名空间,则这两个空间互相无关,在这两个空间内可定义相同的名字且不冲突。
  • 若一个头文件定义了未命名的命名空间,则该命名空间中定义的名字将在每个包含了该头文件的文件中对应不同实体
  • 定义在未命名的命名空间中的名字可以直接使用,不能对未命名的命名空间的成员使用作用域算符
  • 未命名的命名空间中定义的名字的作用域与该命名空间所在的作用域相同,即可跨越到上一层作用域。
    • 若未命名的命名空间定义在文件的最外层作用域,则该命名空间中的名字一定要和全局作用域的名字有所区别
    • 未命名的命名空间可嵌套在其他命名空间中,此时未命名的命名空间中的成员可通过外层命名空间的名字来访问
//未命名的命名空间定义于最外层作用域时,其中名字可能与全局命名空间的名字冲突
int i;
namespace{
    int i;
}
i=10;           //错,未命名的命名空间中的名字和全局命名空间的名字冲突
//嵌套在内部的未命名的命名空间中的名字可通过外层命名空间直接访问
namespace local{
    namespace{
        int j;
    }
}
local::j=42;    //对,可通过未命名的命名空间的上一层空间直接访问其成员

18.2.2 使用命名空间成员

命名空间的别名

  • 可用命名空间别名来为命名空间设定一个更短的名字,形如namespace new_name=old_name;
  • 也可为嵌套的空间定义别名,形如namespace new_name=old_name1::old_name2;
  • 一个命名空间可以有多个别名,但不能在未定义命名空间之前就声明别名

using 声明:扼要概述

  • using声明一次只引入命名空间的一个成员,形如using namespace_name::member_name;
    • using声明引入的名字的有效范围从using声明处开始,直到using声明所在的作用域结束
    • using声明会隐藏外层作用域的同名名字
    • using声明可出现在全局作用域、局部作用域、命名空间作用域、类作用域,在类作用域中using声明只能指向基类成员

using 指示

  • using指示引入命名空间中的所有名字,形如using namespace namespace_name;
    • using指示引入的名字的有效范围从using指示处开始,直到using指示所在的作用域结束
    • using指示可出现在全局作用域、局部作用域、命名空间作用域,但不可出现在类作用域
  • 若提供了多个命名空间的using指示而未做特殊控制,则可能产生命名空间污染的问题

using 指示与作用域

  • using指示引入的名字作用域远比using声明引入的名字作用域更复杂:
    • using声明的名字作用域与using声明语句本身的作用域一致,就像在当前作用域内创建了命名空间该成员的别名
    • using指示可将命名空间成员提升到包含命名空间本身和using指示的最近作用域
  • 通常命名空间会含有一些不能出现在局部作用域的定义,故using指示引入名字时不是引入到当前作用域,而是引入到最近的外层作用域
namespace A{
    int i,j;
}
void f(){
    using namespace A;  //函数内使用using指示,将A中名字引入最近的外层作用域,即全局作用域
    cout<<i*j<<endl;    //使用命名空间中的名字,在f看来,A中的名字好像是出现在全局作用域中f之前的位置
}

using 指示示例

  • 当命名空间被using指示注入到外层作用域时,很可能与外层作用域的名字冲突。这种冲突允许存在,但要想使用冲突的名字则必须用::明确指定版本
namespace blip{
    int i=16,j=15,k=23;     //命名空间中定义名字i、j、k
}
int j=0;                    //全局作用域中定义名字j
void manip(){
    using namespace blip;   //using指示将blip中名字注入最近外层作用域,即注入全局作用域
    ++i;                    //对,设置的是blip::i
    ++j;                    //错,全局作用域本来有::j,又注入了blip::j,二义性
    ++::j;                  //对,手动指定::j
    ++blip::j;              //对,手动指定blip::j
    int k=97;               //定义局部的k隐藏外层作用域的blip::k
    ++k;                    //设置的是上一行定义的局部k
}

头文件与 using 声明或指示

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

提示避免 using 指示

  • using指示一次性注入命名空间的所有名字,风险:
    • 若同时对多个库使用using指示,很可能造成命名空间污染
    • using指示引发的二义性错误只有在使用冲突名字的地方才会被发现,难以定位bug
  • 相比于using指示,在程序中对用到的命名空间成员分别使用using声明更安全:
    • using声明引入的名字可控,不容易造成命名空间污染
    • using声明引起的二义性问题在声明处就会被发现

18.2.3 类、命名空间与作用域

  • 在命名空间内部对名字的查找遵循常规查找规则:由内向外查找每个外层作用域。只有位于开放的块中且在使用点之前声明的名字才被考虑
  • 在命名空间内部的类中,查找规则仍适用:成员函数适用某个名字时,先在成员中查找,再在类中找(包含基类),再在类的外层作用域中查找

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

  • 命名空间中名字的隐藏规则,在调用函数时有一个重要例外
    • 给函数传递类类型对象/引用/指针时,先在常规的作用域中查找函数名,随后还会在实参类所属的命名空间中查找函数名。
    • 这是std::cout<<"hello"这种代码能跑起来的基础。
    • 这个例外使得概念上作为接口一部分的非成员函数不需单独using声明就可被程序使用
std::string s;
std::cin>>s;                    //该例外使得该代码可运行,查找operator>>时会进入string所属命名空间中查找
using std::operator>>;          //若无该例外,需对该函数单独进行using声明
std::operator>>(std::cin,s);    //若无该例外,需显式写出该函数的命名空间

查找与 std::move 和 std::forward

  • 若在程序中定义了一个标准库中已存在的名字,则:
    • 或者根据一般的重载规则确定某次调用的版本
    • 或者直接不会执行标准库版本
  • std::move/std::forward中,参数是万能引用,可匹配任意类型。若用户程序中也定义了同名函数,则必定引起冲突。因此move/forward的冲突比其他名字的冲突频繁得多。因此建议每次使用时都显式写出命名空间std::

友元声明与实参相关的查找

  • 当类声明了友元时,友元声明并未使友元本身可见,还需要在类外给出该友元的正式声明
  • 一个未声明的类/函数若第一次出现在友元声明中,则认为它是最近的外层命名空间的成员。该规则与实参相关的函数查找规则结合可能产生意想不到的效果
namespace A{
    class C{
        //这2个友元声明时还没有正式声明,认为它是最近的外层空间的成员,即隐式声明为空间A的成员
        friend void f2();           //没有形参,除非另有声明,否则不会被找到
        friend void f(const C &);   //根据实参相关的查找规则可以被找到
    };
}
int main(){
    A::C cobj;
    f(cobj);                        //对,f被隐式声明为A的成员,且实参决定会在A中查找函数f
    f2();                           //错,虽然f2被隐式声明为A的成员,但未显式指明
}

18.2.4 重载与命名空间

与实参相关的查找与重载

  • 对于接受类类型实参的函数,其名字查找将进入实参类所在命名空间。这条规则也会影响函数重载
  • 函数重载时将会在每个实参类及其基类所在的命名空间中搜寻候选函数,即使其中某些函数在调用语句处不可见
//在命名空间中定义基类和接口函数
namespace NS{
    class Quote{/* 实现 */};                //定义基类
    void display(const Quote &){/* 实现 */} //定义接口函数
}
//以命名空间中的类为基类,派生出类
class Bulk_item: public NS::Quote {/* 实现 */};
//使用外部派生的类
int main(){
    Bulk_item book1;    
    display(book1);         //对,会在实参类及其基类所属的命名空间中查找函数
    return 0;
}

重载与 using 声明

  • using声明语句声明的是一个名字,而非特定函数。该函数的所有版本都被引入当前作用域
  • using声明引入的函数将重载该声明语句所属作用域中已有的同名函数。
    • 若using声明在局部作用域中,则引入的名字将隐藏外层作用域的相同名字(名字查找先于类型检查)
    • 若using声明所在的作用域已有一个函数与using引入的函数同名且形参列表相同,则报错
    • 除此之外,using声明将为引入的名字添加额外的重载实例,扩充候选函数集
using NS::print(int);  //错误,不能指定形参
using NS::print;  //正确,指示声明一个名字

重载与 using 指示

  • using指示将命名空间的成员提升到外层作用域,若命名空间中的某函数与该命名空间所属作用域的函数同名,则命名空间的函数被添加到重载集合
  • 对于using指示,引入一个与已有函数形参列表相同的函数不会报错(与using声明不同),只需要在使用时指明版本
namespace libs_R_us{
    extern void print(int);
    extern void print(double);
}
void print(const string &);
using namespace libs_R_us;
/* 此时的候选函数有3个:
 * libs_R_us::print(int)
 * libs_R_us::print(double)
 * ::print(const string &)
 */
void fooBar(int ival){
    print("Value: ");   //调用::print(const string &)
    print(ival);        //调用libs_R_us::print(int)
}

跨越多个 using 指示的重载

  • 若存在多个using指示,则来自每个命名空间的名字都会成为候选函数集的一部分
namespace AW{
    int print(int);
}
namespace Primer{
    double print(double);
}
using namespace AW;
using namespace Primer;
long double print(long double);
/* 此时的候选函数有3个:
 * AW::print(int)
 * Primer::print(double)
 * ::print(long double)
 */
int main(){
    print(1);       //调用AW::print(int)
    print(3.1);     //调用Primer::print(double)
    return 0;
}

18.3 多重继承与虚继承

  • 多重继承是指从多个直接基类中产生派生类,多重继承的派生类继承了所有父类的属性
  • 多重继承概念简单,但多个基类产生的细节可能带来错综复杂的设计和实现问题

18.3.1 多重继承

  • 在派生类的派生列表中可包含多个基类,每个基类前有可选的访问说明符:
    • 若派生类关键字是class则默认的派生访问说明符是private
    • 若派生类关键字是struct则默认的派生访问说明符是public
  • 多重继承的派生列表也只能包含已被定义的类,且它们不能是final
  • C++对派生类能继承的基类个数未进行特殊规定,但一个基类在同一派生列表中只能出现一次
class Bear: public ZooAnimal{/* 定义*/};                //Bear继承自ZooAnimal
class Panda: public Bear,public Endangered{/* 定义*/};  //Panda继承自Bear和Endangered

多重继承的派生类从每个基类中继承状态

  • 在多重继承中,派生类的对象包含每个基类的子对象。如上例,Panda从Bear和Endangered中派生而来,Bear又由ZooAnimal派生而来

image

派生类构造函数初始化所有基类

  • 构造一个派生类的对象将同时构造并初始化它的所有基类子对象,多重继承的派生类的构造函数初始列表只能初始它的直接基类
  • 构造派生类时,基类的构造顺序与派生列表的顺序保持一致,而与派生类构造函数初值列表的顺序无关
  • 构造派生类时若未在构造函数初值列表中初始化某个基类,则这个基类部分被默认初始化
/* 上下文:上例中的Bear和Panda定义 */
//初始化顺序:ZooAnimal -> Bear -> Endangered -> Panda
Panda::Panda(string name,bool onExhibit):
            Bear(name,onExhibit,"panda"),       //初始化基类部分Bear
            Endangered(Endangered::critical)    //初始化基类部分Endangered
            {}
Panda::Panda():
            Endangered(Endangered::critical)    //初始化基类部分Endangered,而基类部分Bear默认初始化
            {}

继承的构造函数与多重继承

  • C++11允许派生类从它的一个或几个基类中继承构造函数,但若从多个基类中继承了形参列表相同的构造函数则产生错误
  • 若一个派生类从多个基类中继承了形参列表相同的构造函数,则派生类必须为这种形参列表的构造函数定义自己的版本
struct Base1{
    Base1()=default;
    Base1(const string &);
    Base1(shared_ptr<int>);
};
struct Base2{
    Base2()=default;
    Base2(const string &);
    Base2(int);
};
struct D1: public Base1,public Base2{
    using Base1::Base1;     //引入Base1的构造函数并继承
    using Base2::Base2;     //引入Base2的构造函数并继承
    /* 会引发冲突:
     * 两个基类都定义了默认构造函数和接受string常量引用的构造函数,
     * 从两个基类中继承构造函数时,这两种构造函数不知道从哪个基类中继承。
     * 解决方法:派生类自己再定义一个
     */
};
struct D2: public Base1,public Base2{
    using Base1::Base1;
    using Base2::Base2;
    D2()=default;           //两基类的默认构造函数形参冲突,派生类必须自定义
    D2(const string &s):    //两基类的接受string常量引用的构造函数形参冲突,派生类必须自定义
      Base1(s),Base2(s)
      {}
};

析构函数与多重继承

  • 派生类的析构函数只负责清除派生类本身分配的资源,派生类的成员和基类部分都是自动销毁的
  • 合成的析构函数体为空
  • 析构函数的调用顺序和构造函数相反:先析构派生类后析构基类,多继承遵循类派生列表的逆序

多重继承的派生类的拷贝与移动操作

  • 多继承的派生类若定义了自己的拷贝/移动/赋值操作,则必须在完整的对象上执行这些操作,即派生类的对应成员需手动调用直接基类的对应成员。
  • 只有当派生类使用合成版本的拷贝/移动/赋值成员时,才会自动对基类部分执行这些操作。
  • 在合成的拷贝控制成员中,每个基类部分分别调用自己的对应成员隐式完成构造/赋值/销毁操作

18.3.2 类型转换与多个基类

  • 无论是一个基类还是多个基类,派生类的指针/引用都可自动转换为可访问的基类的指针/引用,即可令可访问的基类指针/引用指向派生类对象
  • 对于多个基类情形,编译器不会在派生类向多个基类的转换中进行比较,转换到任何基类都一样好
/* 上下文:Panda由Bear和Endangered两个基类派生而来 */
//派生类可转换为任何一个基类引用/指针
void print(const Bear &);
void highlight(const Endangered &);
ostream &operator<<(ostream &,const ZooAnimal &);
Panda ying_yang("yingyang");
print(ying_yang);               //这3个函数可调用的原因是派生类Panda可转换为基类Bear/Endangered的引用/指针
highlight(ying_yang);
cout<<ying_yang<<endl;
//派生类向多个基类引用/指针的转换一样好
void print(const Endangered &);
print(ying_yang);               //基类Bear和Endangered都定义了print函数,都可调用,二义性

基于指针类型或引用类型的查找

  • 对象/指针/引用的静态类型决定可使用哪些成员

image

/* 继承体系是:Panda由Bear和Endangered派生而来,Bear由ZooAnimal派生而来
 * 继承体系中定义的虚函数如表18.1
 */
//用基类Bear的指针访问派生类Panda
Bear *pb=new Panda("ying_yang");
pb->print();        //对,Bear定义了print,调用的是Panda::print()
pb->cuddle();       //错,Bear未定义cuddle
pb->highlight();    //错,Bear未定义highlight
delete pb;          //对,Bear从ZooAnimal继承了虚析构函数,调用的是Panda::~Panda()
//用基类Endangered的指针访问派生类Panda
Endangered *pe=new Panda("ying_yang");
pe->print();        //对,Endangered定义了print,调用的是Panda::print()
pe->toes();         //错,Endangered未定义toes
pe->cuddle();       //错,Endangered未定义cuddle
pe->highlight();    //对,Endangered定义了highlight,调用的是Panda::highlight()
delete pe;          //对,Endangered定义了虚析构函数,调用的是Panda::~Panda()

18.3.3 多重继承下的类作用域

  • 只有一个基类时,派生类的作用域嵌套在直接基类和间接基类的作用域中。查找名字的过程由派生类作用域向基类作用域逐级查找,派生类的名字将隐藏基类的同名成员
  • 若在派生类对象/指针/引用中用了某个名字,则程序并行地在其多个基类中查找该名字。若该名字在多个基类中被找到,则有二义性(派生只是引入潜在的二义性,真正使用该名字时才会报错)
  • 对于派生类,从多个基类中继承同名成员完全合法,只是使用该名字时必须明确指出版本,即使用::
  • 若派生类的名字在多个基类中被找到,则使用该名字时报错:
    • 若该名字直接定义在多个基类中,是二义性
    • 若该名字在多个基类中是形参列表不同的函数,也是二义性
    • 若该名字在一个基类中是private而在另一个基类中是public/protected,也是二义性
    • 若该名字在一个基类中直接找到而在另一个基类的间接基类中找到,也是二义性
  • 名字查找先于类型检查,当编译器在两个作用域中同时发现一个名字,直接报错二义性
  • 避免这种二义性的方法是在派生类中再定义一次这个名字,覆盖基类名字,避免在基类中查找
struct ZooAnimal{
    double max_weight() const {/* 实现 */}      //在基类中定义名字max_weight
};
struct Bear: public ZooAnimal{/* 定义 */};
struct Endangered{
    double max_weight() const {/* 实现 */}      //在基类中定义名字max_weight
};
struct Panda: public Bear,public Endangered{
    //在派生类中重新定义名字max_weight,屏蔽基类名字,避免二义性
    double max_weight() const {
        //派生类中通过指定作用域来定向访问基类名字,由于制定了作用域故不存在二义性
        return std::max(ZooAnimal::max_weight(),Endangered::max_weight());
    }
};

18.3.4 虚继承

  • 尽管派生列表中同一基类只能出现一次,但实际上派生类可多次继承同一个基类:
    • 可以将一个类继承为两个直接基类的间接基类(菱形继承,如图18.3)
    • 可以将一个类继承为直接基类,同时继承为另一个直接基类的间接基类
  • 菱形继承例子:
    • IO库的istream和ostream都继承了抽象基类base_ios,它用于保存流的缓冲内容并管理流的条件状态
    • iostream同时继承了istream和ostream,故它两次继承base_ios
  • 默认派生类中含有继承链上每个基类的子部分,若某类在派生中出现多次,则派生类中将包含该类的多个子对象
  • 有时候不希望派生类中包含同一基类的多个子对象,例如iostream中若同时含有两个base_ios则无法使输入输出操作共享缓冲区和条件状态
  • 通过虚继承,可令某个类做出声明,承诺共享它的基类,被共享的基类子对象称为虚基类
  • 通过虚继承机制,无论虚基类在继承体系中出现了多少次,任意一个派生类中都只含有它的一个子对象
  • 使用虚继承机制解决多次继承的问题

image

  • 虚继承的一个不太直观的特性:必须在虚派生的需求出现之前就已完成虚派生的操作。即,经常并不知道一个类是否会被继承多次,因此不知道由它而来的派生是否应该是虚派生
  • 实际编程中,位于中间层次的类将其继承基类的方式声明为虚继承并不会出问题。虚派生只影响从虚基类的派生类中进一步派生出的类,它不影响虚基类的派生类。

使用虚继承

  • 指定虚基类的方式是在派生列表中添加关键字virtual,派生列表中public和virtual的顺序随意
  • virtual说明符表明,在后续的派生类中应共享虚基类的同一份实例
  • 对于什么样的类可以是虚基类,没有特殊规定
class ZooAnimal{/* 定义 */};                          //ZooAnimal作为虚基类,将被多次继承
class Bear: virtual public ZooAnimal {/* 定义 */};    //虚继承,保证由Bear引入的ZooAnimal子对象只有一个
class Raccoon: public virtual ZooAnimal {/* 定义 */}; //虚继承,保证由Raccoon引入的ZooAnimal子对象只有一个
class Endangered{/* 定义 */};
//继承Bear和Raccoon时不需要特殊处理,它们的虚派生已经说明ZooAnimal不会被多次引入
class Panda: public Bear,public Raccoon,public Endangered {/* 定义 */};

支持向基类的常规类型转换

  • 无论基类是否是虚基类,派生类对象都能被可访问的基类的指针/引用访问

虚基类成员的可见性

  • 每个共享的虚基类中在派生类中只有唯一一个共享的子对象,故该基类的成员可被直接访问,没有二义性
  • 派生路径中覆盖虚基类的成员(即在虚基类的派生类中再次定义该成员):
    • 若虚基类的成员只被一条派生路径覆盖,则派生类仍可直接访问被覆盖的成员
    • 若虚基类的成员被多条派生路径覆盖,则派生类不可直接访问被覆盖的成员,需要自定义该成员
  • 假设菱形继承:类B定义了成员xD1D2B虚继承得到,D继承自D1D2,则在D的作用域中,x通过两个基类都可见。若通过D的对象使用x,有几种可能:
    • D1D2中都未定义x,则x被解析为B的成员,不存在二义性。因为只在虚基类中有定义
    • D1D2其中之一定义了x,则x被解析为D1D2的成员,不存在二义性。因为D1D2是派生类,位于内层作用域,优先级更高
    • D1D2中都定义了x,则直接访问x时是二义性。因为D1D2的优先级相同
  • 解决上述多条路径覆盖导致的二义性,应该在派生类中自己定义该成员,其中可使用作用域来指定基类中的该成员

18.3.5 构造函数与虚继承

  • 在虚派生中,虚基类由最终的派生类在其构造函数初值列表中初始化(越过了继承链),而非由其直接派生类初始化
  • 若虚基类由其直接派生类初始化,则会在多次继承时被重复初始化多次
  • 若最终的派生类未初始化虚基类,则虚基类默认初始化
  • 继承体系中的任何类都可能在某个时刻成为“最终的派生类”,只要创建了虚基类的派生类对象,该派生类的构造函数就会越过继承链初始化虚基类
//创建Bear对象时,Bear是最终的派生类,由Bear初始化虚基类
Bear::Bear(string name,bool onExhibit):
          ZooAnimal(name,onExhibit,"Bear")
          {}
//创建Raccoon对象时,Raccoon是最终的派生类,由Raccoon初始化虚基类
Raccoon::Raccoon(string name,bool onExhibit):
                ZooAnimal(name,onExhibit,"Raccoon")
                {}
//创建Panda对象时,Panda是最终的派生类,由Panda初始化虚基类
Panda::Panda(string name,bool onExhibit):
            ZooAnimal(name,onExhibit,"Raccoon"),    //越过继承链,手动初始化虚基类
            Bear(name,onExhibit),
            Raccoon(name,onExhibit),
            Endangered(Endangered::critical),
            sleeping_flag(false)
            {}

虚继承的对象的构造方式

  • 含有虚基类的对象构造时最先初始化虚基类
    • 首先使用提供给最终派生类构造函数的初值来初始化虚基类(若最终的派生类未显式初始化虚基类,则虚基类默认初始化)
    • 一个类可有多个虚基类,这些虚基类的初始化顺序是它们在派生列表中的顺序
    • 然后按照直接基类在派生列表中的顺序初始化非虚基类
  • 构造派生类时,编译器按照直接基类的声明顺序对其依次检查,若基类中含有虚基类,则先构造虚基类,然后按照声明顺序逐一构造其他非虚基类
  • 合成的拷贝/移动/赋值对各基类部分的执行顺序和构造函数一致,而析构函数与其相反
class Character{/* 定义 */};
class BookCharacter: public Character {/* 定义 */};
class ToyAnimal{/* 定义 */};
class TeddyBear: public BookCharacter,public Bear,public virtual ToyAnimal {/* 定义 */};
TeddyBear teddy;
/* 创建TeddyBear对象时,调用构造函数顺序:
 * ZooAnimal(虚基类)
 * ToyAnimal(虚基类)
 * Character
 * BookCharacter
 * Bear
 * TeddyBear
 */
posted @ 2021-04-22 17:06  砥才人  阅读(368)  评论(0编辑  收藏  举报