C++ Primer 总结索引 | 第十八章:用于大型程序的工具

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

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

对应 异常处理、命名空间和多重继承

1、异常处理

1、异常处理机制 允许程序中 独立开发的部分 能够与运行时就出现的问题 进行通信 并做出相应的处理。异常使得 能够将问题的检测 与 解决过程分开来。程序的一部分 负责检测问题的出现, 然后将解决该问题的任务 传递给程序的另一部分。检测环节 无须知道问题处理模块的所有细节 , 反之亦然

2、要想有效地 使用异常处理,必须首先 了解当抛出异常时 发生了什么, 捕获异常时 发生了什么, 以及用来传达错误的对象的意义

1.1 抛出异常

1、通过抛出一条表达式 来引发一个异常。被抛出的表达式的类型 以及当前的调用链 共同决定了 哪段处理代码将被用来处理该异常。被选中的处理代码 是在调用链中 与抛出对象类型匹配的 最近的处理代码。其中, 根据抛出对象的类型 和 内容, 程序的异常抛出部分 将会告知异常处理部分到底发生了什么错误

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

  • 沿着调用链的函数 可能会提早退出
  • 一旦程序 开始执行异常处理代码,则沿着 调用链创建的对象将被销毁

因为 跟在 throw 后面的语句将不再被执行,所以 throw 语句的用法 有点类似于 return 语句:它通常作为条件语句的一部分 或者作为某个函数的最后(或者唯一)一条语句

2、栈展开
当抛出一个异常后,程序暂停当前函数的执行过程 并立即开始寻找与异常匹配的 catch 子句。当 throw 出现在一个try语句块内时,检查 与该 try 块关联的 catch 子句。如果找到了匹配的 catch,就使用该 catch 处理异常。如果没有找到匹配的 catch 且该 try 语句嵌套在其他 try 块中,则继续检查与外层 try 匹配的 catch 子句。如果还是找不到匹配的 catch,则退出当前的函数,在调用当前函数的外层函数中 继续寻找

上述过程 被称为 栈展开过程。栈展开过程 沿着嵌套函数的调用链 不断查找,直到找到了 与异常匹配的 catch 子句为止;或者 也可能一直没找到匹配的 catch ,则退出主函数后 查找过程终止

假设找到了一个匹配的 catch 子句,则程序进入该子句 并执行其中的代码。当执行完这个 catch 子句后,找到与 try 块关联的最后一个 catch 子句之后的点,并从这里继续执行
如果没有找到匹配的 catch 子句,程序将退出。当找不到匹配的 catch 时,程序将调用标准库函数 terminate(责终止程序的执行过程)

3、栈展开过程中 对象被自动销毁
在栈展开过程中,位于调用链上的语句块 可能会提早退出。通常情况下,程序在这些块中 创建了一些局部对象。块退出后它的局部对象也将随之销毁,这条规则 对于栈展开过程同样适用。如果在栈展开过程中 退出了某个块,编译器 将负责确保在 这个块中创建的对象 能被正确地销毁。如果某个局部对象的类型是 类类型,则该对象的析构函数 将被自动调用。与往常一样,编译器在销毁内置类型的对象时 不需要做任何事情

指针的释放:如果这个指针是通过动态分配内存(例如 new 或 malloc)得到的,那么在栈展开退出块之前,需要确保正确释放该指针所指向的内存

int* ptr = new int(10);
// 在块退出前需要 delete ptr;
delete ptr;

指针的作用域:如果指针指向的是局部变量,那么在退出块后,该指针将变得无效,因为指向的内存已经被释放

int x = 10;
int* ptr = &x;
// 块退出后,ptr 将变为悬挂指针(dangling pointer),不应再使用

智能指针的使用:为了更好地管理指针生命周期,建议使用智能指针,这样在块退出时会自动释放指针所指向的内存

如果异常 发生在构造函数中,也可能发生在 数组或标准库容器的元素初始化过程中,则当前的对象可能只构造了一部分,也应该确保已构造的成员能被正确地销毁

4、析构函数与异常
析构函数 总是会被执行的,但是函数中 负责释放资源的代码 却可能被跳过,这一特点 对于如何组织程序结构有重要影响。如果一个块分配了资源,并且在负责释放这些资源的代码前面 发生了异常,则释放资源的代码 将不会被执行。另一方面,类对象分配的资源 将由类的析构函数负责释放。因此,如果 使用类来控制资源的分配,就能确保无论函数正常结束还是遭遇异常,资源都能被正确地释放

#include <iostream>
#include <stdexcept>

class Resource {
public:
    // 构造函数分配资源
    Resource() {
        ptr = new int(10); // 动态分配资源
        std::cout << "Resource acquired\n";
    }

    // 析构函数释放资源
    ~Resource() {
        delete ptr; // 释放资源
        std::cout << "Resource released\n";
    }

    // 用于访问资源
    int getValue() const {
        return *ptr;
    }

private:
    int* ptr; // 内部资源
};

void functionThatMightThrow() {
    Resource res; // 分配资源

    // 假设某种条件下发生异常
    throw std::runtime_error("Something went wrong!");

    // 由于异常抛出,下面的代码不会执行
    std::cout << "Value: " << res.getValue() << std::endl;
}

int main() {
    try {
        functionThatMightThrow();
    } catch (const std::exception& e) {
        std::cout << "Caught exception: " << e.what() << std::endl;
    }

    // 程序继续执行,资源已经被正确释放
    return 0;
}

析构函数 在栈展开的过程中 执行,这一事实 影响着 编写析构函数的方式。在栈展开的过程中,已经引发了异常 但是 还没有处理它。如果异常抛出后 没有被正确捕获,则系统将调用 terminate 函数。因此,出于栈展开可能使用析构函数的考虑,析构函数 不应该抛出不能被它自身处理的异常。换句话说,如果析构函数 需要执行某个可能抛出异常的操作,则该操作应该被放置在一个 try 语句块当中,并且在析构函数内部得到处理

因为析构函数 仅仅是释放资源,所以 不太可能抛出异常。所有标准库类型都能确保 它们的析构函数不会引发异常

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

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

range_error r("error");
	throw r; // r为range_error
exception *p = &r;
	throw *p; // p是exception
// 写成 throw p; 此时 抛出的为 指向局部对象的指针,几乎可以肯定这是一种错误行为

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

当一个异常被抛出时,沿着调用链的块 将依次退出 直至找到与异常匹配的处理代码。如果退出了某个块,则同时 释放块中局部对象使用的内存。因此,抛出一个指向局部对象的指针 几乎肯定是一种错误的行为。出于同样的原因,从函数中 返回指向局部对象的指针也是错误的。如果指针所指的对象 位于某个块中,而该块在 catch 语句之前就已经退出了,则意味着在执行 catch 语句之前局部对象已经被销毁了

当 抛出一条表达式时,该表达式的静态编译时类型 决定了异常对象的类型。因为很多情况下 程序抛出的表达式类型 来自于某个继承体系。如果一条 throw 表达式解引用一个基类指针,而该指针实际上指向的是 派生类对象,则抛出的对象 将被切掉一部分,只有基类部分被抛出

抛出指针 要求在 任何对应的处理代码存在的地方,指针所指的对象都必须存在

18.2 当在指定的位置发生了异常时将出现什么情况

void exercise(int *b, int *e)
{
	vector<int> v(b, e);
	int *p = new int[v.size()];
	ifstream in("ints");
	//此处发生异常
}

在这里插入图片描述

p的内存将不会被释放,会出现内存泄漏

解决方案

  1. 使用智能指针:
    使用智能指针(如std::unique_ptr)来管理动态分配的内存。智能指针会在其超出作用域时自动释放所管理的资源
#include <memory> // 需要包含头文件
void exercise(int *b, int *e)
{
    std::vector<int> v(b, e);
    std::unique_ptr<int[]> p(new int[v.size()]);
    std::ifstream in("ints");
    //此处发生异常
}

  1. 手动释放资源:
    如果 坚持使用原始指针,需要在catch块中手动释放资源,但这通常不推荐,因为容易出错和复杂化代码
void exercise(int *b, int *e)
{
    int* p = nullptr;
    try {
        std::vector<int> v(b, e);
        p = new int[v.size()];
        std::ifstream in("ints");
        //此处发生异常
    } catch (...) {
        delete[] p;
        throw; // 重新抛出异常
    }
}

或者自己写个类 代替 int*

struct P {
	int *p = nullptr;
	P(std::size_t n):p(new int[n]){}
	~P()
	{
		delete []p; 
// delete p 仅适用于单个int指针,而不是一个数组指针(int[]),应该使用delete[] p来释放由new[]分配的数组
	}
};

1.2 捕获异常

1、catch子句中的异常声明 看起来像是 只包含一个形参的函数形参列表。像在 形参列表中一样,如果 catch 无须访问 抛出的表达式的话,则 可以忽略获取形参的名字

声明的类型 决定了 处理代码所能捕获的异常类型。这个类型必须是完全类型,它可以是左值引用(&),但不能是右值引用(&&)
在C++中,“完全类型” 是一个在编译时已知其大小和内存布局的类型。与之相对的是“非完全类型”,即在某个时刻编译器还无法确定其大小或内存布局的类型

完全类型的特点
大小已知:编译器可以确定该类型的大小
内存布局已知:编译器可以知道该类型的具体结构和如何分配内存
可以创建对象:因为大小和布局已知,所以可以定义该类型的对象或变量

非完全类型的特点
大小未知:编译器无法确定该类型的大小
内存布局未知:编译器不知道该类型的具体结构
无法创建对象:由于大小和布局未知,因此不能定义该类型的对象

  1. 类的前向声明:
class MyClass;  // 前向声明,MyClass 是非完全类型

此时,MyClass 是一个非完全类型。你可以声明 指向它的指针或引用,但 不能创建它的对象 或 访问其成员

MyClass* p;  // 可以声明指针
MyClass obj; // 错误:无法创建对象
  1. 类定义后的情况:
class MyClass {
    int data;
};  // MyClass 现在是完全类型

当类被完整定义后,它变成了 一个完全类型,可以定义 它的对象、访问成员等

  1. 数组类型:
int arr[];  // 非完全类型,因为大小未知

此时,arr 是非完全类型,因为 没有指定数组的大小,编译器无法确定 其大小和内存布局

int arr[10];  // 完全类型,因为大小已知

当数组的大小已知时,arr成为完全类型

2、当进入一个 catch 语句后,通过异常对象初始化 异常声明中的参数。和函数的参数类似,如果 catch 的参数类型是 非引用类型,则该参数是 异常对象的一个副本,在 catch 语句内 改变该参数 实际上改变的是 局部副本而非异常对象本身;相反,如果参数是引用类型,则和其他引用参数一样,该参数是异常对象的一个别名,此时改变参数也就是改变异常对象

catch 的参数 还有一个特性也与函数的参数非常相似:如果 catch 的参数是 基类类型,则 可以使用其派生类的异常对象 对其进行初始化。此时,如果 catch 的参数是 非引用类型,则异常对象 将被切掉一部分,这与将派生类对象 以值传递的方式 传给一个普通函数差不多

另一方面,如果 catch 的参数是 基类的引用,则该参数 将以常规方式绑定到异常对象上
这意味着即使异常对象的实际类型是基类的派生类,该引用也能捕获该异常
1)基类引用绑定:
如果 catch 块的参数是一个基类的引用(如 catch (Base &e)),那么即使抛出的异常对象是该基类的派生类(如 Derived),该引用也会绑定到这个派生类的对象上
这使得 可以通过基类引用来捕获并处理多个不同派生类的异常

2)多态行为:
当引用绑定到派生类对象时,如果基类中定义了虚函数,并且派生类覆盖了这些虚函数,那么即使通过基类的引用调用这些虚函数,仍然会调用派生类的实现。这就是多态性在异常处理中的体现

#include <iostream>
class Base {
public:
    virtual void what() const { std::cout << "Base exception" << std::endl; }
};

class Derived : public Base {
public:
    void what() const override { std::cout << "Derived exception" << std::endl; }
};

int main() {
    try {
        throw Derived(); // 抛出派生类异常
    }
    catch (Base &e) {  // 用基类引用捕获异常
        e.what();  // 调用的是派生类的 what() 方法
    }
    return 0;
}

输出将是:
Derived exception

  • 使用基类引用来捕获异常时,要确保基类有一个虚析构函数,否则可能导致资源泄漏
    虚函数的主要作用是 支持运行时多态性,即通过基类指针 或 引用来调用派生类的重写函数,而不是基类中的版本
#include <iostream>

class Base {
public:
    virtual void show() {  // 定义虚函数
        std::cout << "Base class show function" << std::endl;
    }
};

class Derived : public Base {
public:
    void show() override {  // 重写虚函数
        std::cout << "Derived class show function" << std::endl;
    }
};

int main() {
    Base* ptr;
    Derived d;
    ptr = &d;

    ptr->show();  // 调用的是 Derived 类的 show(),而不是 Base 类的 show()
    
    return 0;
}

在派生类中重写基类的虚函数时,建议使用 override 关键字 来显式表明这是一次重写操作。这有助于编译器检查函数签名 是否与基类中的虚函数匹配,避免因签名不匹配 而导致的隐藏错误

  • 绑定到基类引用的对象仍然只能访问基类中的成员,如果需要访问派生类特有的成员,需要通过类型转换(dynamic_cast)来转换引用类型
#include <iostream>
#include <typeinfo>

class Base {
public:
    virtual ~Base() {}  // 基类需要有一个虚析构函数以支持动态类型识别
};

class Derived : public Base {
public:
    void derivedFunction() {
        std::cout << "Derived function called" << std::endl;
    }
};

void handleException(Base& e) {
    try {
        // 尝试将 Base& 转换为 Derived&
        Derived& derivedRef = dynamic_cast<Derived&>(e);
        // 如果转换成功,可以调用 Derived 类的特有成员
        derivedRef.derivedFunction();
    } catch (const std::bad_cast& bc) {
        // 如果转换失败,捕获 std::bad_cast 异常
        std::cerr << "Bad cast: " << bc.what() << std::endl;
    }
}

int main() {
    try {
        throw Derived();  // 抛出 Derived 类对象
    } catch (Base& e) {  // 用 Base& 捕获异常
        handleException(e);  // 处理异常,尝试转换为 Derived&
    }

    try {
        throw Base();  // 抛出 Base 类对象
    } catch (Base& e) {  // 用 Base& 捕获异常
        handleException(e);  // 处理异常,尝试转换为 Derived&
    }

    return 0;
}

输出结果
Derived function called
Bad cast: std::bad_cast

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

常情况下,如果 catch 接受的异常 与 某个继承体系有关,则最好将该 catch 的参数定义成引用类型(避免对象切割,支持多态行为,避免不必要的拷贝)

3、查找匹配的处理代码
在搜寻 catch 语句的过程中,最终找到的 catch 未必是 异常的最佳匹配。相反,挑选出来的应该是 第一个与异常匹配的 catch 语句。因此,越是专门的 catch 越应该置于整个 catch 列表的前端
因为 catch 语句 是按照其出现的顺序 逐个进行匹配的,所以 当程序使用具有继承关系的多个异常时 必须对 catch 语句的顺序进行组织和管理,使得派生类异常的处理代码 出现在 基类异常的处理代码之前

与实参 和 形参的匹配规则相比,异常和 catch 异常声明的匹配规则 受到更多限制。此时,绝大多数类型转换都不被允许,除了以下几点之外,要求异常的类型 和 catch 声明的类型是精确匹配的:

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

除此之外,包括 标准算术类型转换 和 类类型转换在内,其他所有转换规则都不能在匹配 catch 的过程中使用

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

class MyClass {
public:
    operator int() const {
        return 42;
    }
};

MyClass obj;
int value = obj;  // MyClass 对象被隐式转换为 int

在这里插入图片描述

4、重新抛出
在执行了 某些校正操作之后,当前的 catch 可能会决定 由调用链更上一层的函数 接着处理异常。一条 catch 语句通过重新抛出的操作 将异常传送给 另外一个 catch 语句。这里的重新抛出 仍然是一条 throw 语句,只不过不包含任何表达式:throw;
空的 throw 语句 只能出现在 catch 语句 或 catch 语句直接或间接调用的函数之内。如果在处理代码之外的区域 遇到了空 throw 语句,编译器将调用 terminate
一个重新抛出语句 并不指定新的表达式,而是 将当前的异常对象沿着调用链向上传送

catch 语句 会改变其参数的内容。如果在改变了参数的内容后 catch 语句重新抛出异常,则只有当 catch 异常声明是引用类型时 对参数所做的改变 才会被保留并继续传播:

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

5、捕捉所有异常的处理代码
希望 不论抛出的异常是什么类型,程序 都能统一捕获它们。要想捕捉所有可能的异常 是比较有难度的,毕竟有些情况下 也不知道异常的类型到底是什么。即使 知道所有的异常类型,也很难为所有类型 提供唯一一个 catch 语句。为了一次性捕获所有异常,使用省略号 作为异常声明,这样的处理代码 称为捕获所有异常 的处理代码,catch(...)。一条捕获所有异常的语句 可以与任意类型的异常匹配

catch(…) 通常与重新抛出语句一起使用,其中 catch 执行当前局部能完成的工作,随后重新抛出异常:

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

重新抛出异常
catch(...) 处理完成后,通常会使用 throw; 重新抛出异常。重新抛出异常的原因包括:

1)保持异常传播:在执行完局部处理之后,可能需要将异常 继续传播给调用栈上的上层函数,让上层函数来决定如何进一步处理这个异常。重新抛出 可以让程序在局部处理完成后依然能够保留异常的原始信息和上下文

2)不吞掉异常:如果 catch(...) 捕获异常后不重新抛出,那么异常将被"吞掉",程序执行将继续,不会再有其他地方接收到这个异常。这通常不是理想的,因为上层代码可能需要知道异常发生以便采取合适的行动

3)确保正确终止:有些异常的处理逻辑 要求程序必须终止,而不是 简单地捕获异常继续执行。通过重新抛出异常,可以确保 异常沿着调用链向上传递,直到找到合适的地方进行处理(如顶层捕获后终止程序)

6、catch(...) 既能单独出现,也能与其他几个 catch 语句一起出现
如果 catch(...) 与其他几个 catch 语句一起出现,则 catch(...) 必须在最后的位置。出现在 捕获所有异常语句后面的 catch 语句将永远不会被匹配

7、abort() 是一个用于终止程序执行的函数。调用 abort() 函数时,会立即终止程序,并且不会调用任何对象的析构函数 或 执行其他清理工作

18.6 已知下面的异常类型 和 catch 语句,书写一个 throw 表达式 使其创建的异常对象能被这些 catch 语句捕获

(a) class exceptionType { };
	catch(exceptionType *pet) { }
(b) catch(...) { }
(c) typedef int EXCPTYPE;
	catch(EXCPTYPE) { }

(a) exceptionType *pet;
	//...
	throw pet;
(b) 可以捕获所有的异常
(c) int a;
	//...
	throw a;

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

1、异常可能发生在 处理构造函数初值的过程中。构造函数在进入其函数体之前 首先执行初始值列表。因为在其初始值列表抛出异常时 构造函数体内的 try 语句块还未生效,所以构造函数体内的 catch 语句 无法处理构造函数初始值列表抛出的异常

2、要想处理 构造函数初始值抛出的异常,必须将构造函数写成函数 try 语句块(也称为函数测试块)的形式。函数 try 语句块使得一组 catch 语句既能处理构造函数体(或析构函数体),也能处理构造函数的初始化过程(或析构函数的析构过程)。举个例子,可以把 Blob 的构造函数置于一个函数 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 出现在 表示构造函数初始值列表的冒号 以及 表示构造函数体(此例为空)的花括号之前。与这个 try 关联的 catch 既能处理 构造函数体抛出的异常,也能处理 成员初始化列表抛出的异常

处理构造函数初始值异常的唯一方法是 将构造函数写成函数 try 语句块

1.4 noexcept 异常说明

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

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

void recoup(int) noexcept; // 不会抛出异常,recoup 做了不抛出说明
void alloc(int); // 可能抛出异常

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

引用限定符 用于成员函数的一部分,用于限定成员函数的调用对象 是左值还是右值。它们是C++11引入的新特性,主要有两种:
& 引用限定符:表示成员函数只能被左值对象调用
&& 引用限定符:表示成员函数只能被右值对象调用

class MyClass {
public:
    void foo() & {
        std::cout << "Called on an lvalue object." << std::endl;
    }
    
    void foo() && {
        std::cout << "Called on an rvalue object." << std::endl;
    }
};

int main() {
    MyClass obj;
    obj.foo();         // 输出: Called on an lvalue object.
    MyClass().foo();   // 输出: Called on an rvalue object.
    return 0;
}

2、违反异常说明
编译器 并不会在编译时检查 noexcept 说明。实际上,如果一个函数在说明了 noexceot 的同时 又含有 throw 语句 或者 调用了可能抛出异常的其他函数,编译器将顺利编译通过

// 尽管该函数明显违反了异常说明,但它仍然可以顺利编译通过
void f() noexcept // 承诺不会抛出异常
{
	throw exception(); // 违反了异常说明
}

一旦一个 noexcept 函数抛出了异常,程序就会调用 terminate 以确保遵守不在运行时抛出异常的承诺
上述过程 对是否执行栈展开 未作约定,因此 noexcept 可以用在两种情况下:一是我们确认函数不会抛出异常,二是我们根本不知道该如何处理异常

3、向后兼容:异常说明
早期的 C++ 版本 可以指定某个函数可能抛出的异常类型。函数可以指定一个关键字 throw,在后面 加上括号起来的异常类型列表。throw 说明符所在的位置 与新版本 C++ 中 noexcept 所在的位置相同

在 C++11 新版本中已经被取消了。然而尽管如此,它还有一个重要的用处。如果函数被设计为是 throw(),则意味着该函数将不会抛出异常(可能抛出的异常类型为空):

// 两条声明语句是等价的,都承诺 recoup 不会抛出异常
void recoup(int) noexcept; // recoup 不会抛出异常
void recoup(int) throw(); // 等价的声明

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

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

5、noexcept 运算符
noexcept 说明符的实参 常常与 noexcept 运算符混合使用。noexcept 运算符是 一个一元运算符,它的返回值是 一个 bool 类型的右值常量表达式,用于表示给定的表达式 或函数 是否会抛出异常。和 sizeof 类似,noexcept 也不会求其运算对象的值

1)用于表达式(运算符)
例如,因为 声明 recoup 时使用了 noexcept 说明符,所以下面的表达式的返回值为 true:

noexcept(recoup(i)) // 如果 recoup 不抛出异常则结果为 true;否则结果为 false

更普通的形式是:

noexcept(e)

当 e 调用的所有函数都做了不抛出说明 且 e 本身不含 throw 语句时,上述表达式为 true;否则 noexcept(e) 返回 false
即使表达式 e 中写了 throw 语句,但没有实际抛出异常,noexcept(e) 依然会返回 false。这是因为 noexcept 运算符是在编译时检查表达式是否有可能抛出异常,而不考虑运行时的具体行为

#include <iostream>

void func1() noexcept {
    // 这个函数不会抛出异常
}

void func2() {
    // 这个函数可能抛出异常
    throw std::runtime_error("Exception in func2");
}

int main() {
    std::cout << std::boolalpha;
    std::cout << "noexcept(func1()): " << noexcept(func1()) << std::endl; // true
    std::cout << "noexcept(func2()): " << noexcept(func2()) << std::endl; // false

    return 0;
}

2)用于函数声明(异常说明符)
在函数声明中使用 noexcept(true) 或简写为 noexcept,表示该函数不会抛出异常。如果 noexcept(false),表示函数可能抛出异常

void func1() noexcept {
    // 这个函数保证不抛出异常
    // 如果函数在声明为 noexcept 的情况下抛出了异常,程序会调用 std::terminate() 结束运行
}

void func2() noexcept(false) {
    // 这个函数可能抛出异常
}

3)条件 noexcept(运算符 + 异常说明符)
可以使用条件 noexcept 运算符来根据编译期条件决定函数是否为 noexcept:

template<typename T>
void myFunc(T&& x) noexcept(noexcept(T(std::forward<T>(x)))) {
    T obj(std::forward<T>(x));
}

myFunc 的 noexcept 状态取决于 T 类型的构造函数 是否是 noexcept 的。如果 T 的构造函数是 noexcept 的,那么 myFunc 也是 noexcept 的

可以使用 noexcept 运算符 得到如下异常说明:

void f() noexcept(noexcept(g())); // f 和 g 的异常说明一致

如果函数 g 承诺了不会抛出异常,则 f 也不会抛出异常;如果 g 没有异常说明符,或者 g 虽然有异常说明符 但是允许抛出异常,则 f 也可能抛出异常

noexcept 有两层含义:当跟在函数参数列表后面时 它是异常说明符;而当作为 noexcept 异常说明的 bool 实参出现时,它是一个运算符

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

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

// recoup 和 pf1 都承诺不会抛出异常
void (*pf1)(int) noexcept = recoup;
// 正确:recoup 不会抛出异常,pf2 可能抛出异常,二者之间互不干扰
void (*pf2)(int) = recoup;

pf1 = alloc; // 错误:alloc 可能抛出异常,但是 pf1 已经说明了它不会抛出异常
pf2 = alloc; // 正确:pf2 和 alloc 都可能抛出异常(注意,尽管 recoup 不会抛出异常)

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

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 做了更严格的限定,这是允许的
};

当编译器合成拷贝控制成员时,同时 也生成一个异常说明。如果 对所有成员和基类的所有操作 都承诺了不会抛出异常,则 合成的成员是 noexcept 的。如果 合成成员调用的任意一个函数可能抛出异常,则合成的成员是 noexcept(false)。而且,如果 定义了一个析构函数 但是没有为它提供异常说明,则编译器将合成一个。合成的异常说明 将与假设由编译器为类合成构造函数时所得的异常说明一致

1.5 异常层次

1、
在这里插入图片描述
std::exception
基类:所有标准库异常类的基类
成员函数:what(),返回指向 C 风格字符串的指针,描述异常的原因

std::logic_error
描述:由程序的逻辑错误引发的异常。一般是可以通过修改代码避免的
派生类:
std::invalid_argument:提供的参数无效
std::domain_error:数学函数输入的参数导致的错误
std::length_error:试图创建超出最大长度的对象时抛出
std::out_of_range:访问超出范围的元素时抛出(例如,访问 std::vector 中不存在的元素)

std::runtime_error
描述:表示程序在运行时出现的问题。这些异常 通常是无法通过修改代码预防的
派生类:
std::range_error:表示计算结果超出表示范围
std::overflow_error:表示算术运算导致的上溢出
std::underflow_error:表示算术运算导致的下溢出

std::bad_alloc
描述:内存分配失败时抛出
用法:通常由 new 操作符在内存分配失败时抛出

#include <iostream>
#include <new> // 包含 std::bad_alloc

int main() {
    try {
        int* ptr = new int[1000000000000000]; // 大量分配内存
    } catch (const std::bad_alloc& e) {
        std::cout << "Caught: " << e.what() << std::endl;
    }
    return 0;
}

std::bad_cast
描述:在使用 dynamic_cast 进行类型转换时,如果类型转换失败 且类型转换是引用类型,则抛出该异常

#include <iostream>
#include <typeinfo> // 包含 std::bad_cast

class Base {
    virtual void func() {}
};

class Derived : public Base {};

int main() {
    try {
        Base b;
        Derived& d = dynamic_cast<Derived&>(b); // 错误的类型转换
    } catch (const std::bad_cast& e) {
        std::cout << "Caught: " << e.what() << std::endl;
    }
    return 0;
}

std::bad_typeid
描述:使用 typeid 操作符获取多态对象的类型信息时,如果对象的类型是 nullptr,则抛出该异常

#include <iostream>
#include <typeinfo> // 包含 std::bad_typeid

class Base {
    virtual void func() {}
};

int main() {
    try {
        Base* b = nullptr;
        std::cout << typeid(*b).name() << std::endl; // 对空指针调用 typeid
    } catch (const std::bad_typeid& e) {
        std::cout << "Caught: " << e.what() << std::endl;
    }
    return 0;
}

类型 exception 仅仅定义了拷贝构造函数、拷贝赋值运算符、一个虚析构函数和一个名为 what 的虚成员。其中 what 函数返回一个 const char*,该指针指向一个以 null 结尾的字符数组,并且确保不会抛出任何异常(what 中如果抛出异常,需要 try catch 捕获,再调用 what,一直循环,直达内存耗尽)

类 exception、bad_cast 和 bad_alloc 定义了默认构造函数。类 runtime_error 和 logic_error 没有默认构造函数,但是有一个可以接受 C 风格字符串 或者 标准库 string 类型实参的构造函数,这些实参 负责提供关于错误的更多信息。在这些类中,what 负责返回用于初始化异常对象的信息。因为 what 是虚函数,所以当 捕获基类的引用时,对 what 函数的调用 将执行与异常对象动态类型对应的版本

2、书店应用程序的异常类
实际的应用程序 通常会自定义 exception(或者 exception 的标准库派生类)的派生类以扩展其继承体系
需要建立一个自己的异常类体系,用它来表示 与应用相关的各种问题

// 为某个书店应用程序设置的异常类
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) { } // left / right 表示两个不等的isbn
	const std::string left, right;
};

面向应用的异常类 继承自标准异常类。和其他继承体系一样,异常类也可以看作 按照层次关系组织的。层次越低,表示的异常情况就越特殊。例如,在异常类继承体系统中 位于最顶层的通常是 exception,exception 表示的含义是某处出错了,至于错误的细节则未作描述

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

书店应用程序 进一步细分上述异常类别。名为 out_of_stock 的类表示 在运行时可能发生的错误,比如某些顺序无法满足:名为 isbn_mismatch 的类表示 logic_error 的一个特例,程序可以通过比较对象的 isbn() 结果来阻止或处理这一错误

3、使用我们自己的异常类型
使用自定义异常类的方式 与 使用标准异常类的方式完全一样。程序在某处抛出异常类型的对象,在另外的地方捕获并处理这些问题
可以为 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; // 计算它们的和
	// 此处使用 sum
} catch (const isbn_mismatch &e) {
	cerr << e.what() << ": left isbn(" << e.left
		 << ") right isbn(" << e.right << ")" << endl;
	}
}

2、命名空间

1、大型程序 往往会使用多个独立开发的库,这些库 又会定义大量的全局名字,如类、函数和模板等。当应用程序 用到多个供应商提供的库时,不可避免地会发生 某些名字相互冲突的情况。多个库 将名字放置在全局命名空间中 将引发命名空间污染

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

2.1 命名空间定义

1、一个命名空间的定义 包含两部分:首先是 关键字 namespace,随后是 命名空间的名字。在命名空间名字后面是 一系列由花括号括起来的声明和定义。只要 能出现在全局作用域中的声明 就能置于命名空间内,主要包括:类、变量(及其初始化操作)、函数(及其定义)、模板和其他命名空间:

cplusplus_primer::Query q = cplusplus_primer::Query("hello");

如果其他命名空间(比如说 AddisonWesley)也提供了一个名为 Query 的类,并且 希望使用这个类替代 cplusplus_primer 中定义的同名类,则可以按照如下方式修改代码:

AddisonWesley::Query q = AddisonWesley::Query("hello");

2、命名空间可以是不连续的
命名空间 可以定义在几个不同的部分,这一点 与其他作用域不太一样

namespace nsp {
// 相关声明
}

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

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

  • 命名空间的一部分成员的作用是 定义类,以及 声明作为类接口的函数及对象,则这些成员应该置于头文件中,这些头文件 将被包含在使用了这些成员的文件中
  • 命名空间成员的定义部分 则置于另外的源文件中

假设 在开发一个简单的数学库,这个库 包含基本的几何和代数运算。希望将这些功能 都放在一个命名空间 MathLib 中

1)头文件 (MathLib.h):
这个文件中 声明了 MathLib 命名空间的一些类和函数

// MathLib.h
#ifndef MATHLIB_H
#define MATHLIB_H

namespace MathLib {
    class Algebra {
    public:
        static int Add(int a, int b);
        static int Multiply(int a, int b);
    };

    class Geometry {
    public:
        static double CircleArea(double radius);
        static double RectangleArea(double width, double height);
    };
}

#endif // MATHLIB_H

2)代数运算的实现文件 (Algebra.cpp):
在这个文件中,实现了 MathLib::Algebra 类的函数

// Algebra.cpp
#include "MathLib.h"

namespace MathLib {
    int Algebra::Add(int a, int b) {
        return a + b;
    }

    int Algebra::Multiply(int a, int b) {
        return a * b;
    }
}

3)几何运算的实现文件 (Geometry.cpp):
在这个文件中,实现了 MathLib::Geometry 类的函数

// Geometry.cpp
#include "MathLib.h"

namespace MathLib {
    double Geometry::CircleArea(double radius) {
        return 3.14159 * radius * radius;
    }

    double Geometry::RectangleArea(double width, double height) {
        return width * height;
    }
}

将 MathLib 命名空间中的不同功能模块(代数和几何运算)分别放在不同的源文件中,但它们都属于同一个命名空间 MathLib。在实际使用时,可以通过包含头文件 MathLib.h 来访问这些功能

// main.cpp
#include <iostream>
#include "MathLib.h"

int main() {
    // 使用MathLib命名空间中的代数功能
    int sum = MathLib::Algebra::Add(5, 3);
    int product = MathLib::Algebra::Multiply(5, 3);

    // 使用MathLib命名空间中的几何功能
    double circleArea = MathLib::Geometry::CircleArea(10);
    double rectangleArea = MathLib::Geometry::RectangleArea(5, 8);

    // 输出结果
    std::cout << "Sum: " << sum << std::endl;
    std::cout << "Product: " << product << std::endl;
    std::cout << "Circle Area: " << circleArea << std::endl;
    std::cout << "Rectangle Area: " << rectangleArea << std::endl;

    return 0;
}

在程序中 某些实体只能定义一次:如非内联函数、静态数据成员、变量等,命名空间中定义的名字 也需要满足这一要求,可以通过上面的方式来 组织命名空间并达到目的。这种接口和实现分离的机制确保 所需的函数和其他名字只定义一次,而只要是要用到这些实体的地方 都能看到对于实体名字的声明

内联函数 是一种提示编译器将函数的调用 直接替换为函数体的代码,以减少函数调用的开销。它通常用于那些很小且频繁调用的函数。内联函数通过 inline 关键字来声明,例如:

inline int add(int a, int b) {
    return a + b;
}

根据 C++ 标准的规定,内联函数可以在多个源文件中定义多次,但必须满足以下条件:
1)inline 关键字:函数必须用 inline 关键字修饰。这告诉编译器 该函数可能在多个翻译单元中定义,并且编译器应该处理这种情况

2)相同的定义:在多个源文件中定义的内联函数 必须具有完全相同的函数体。如果在不同文件中定义的内联函数的内容不同,则会导致未定义行为

3)ODR(One Definition Rule)例外:通常在 C++ 中,函数只能在一个翻译单元中定义一次(ODR规则),但内联函数是这个规则的一个例外。多个翻译单元中定义的内联函数 在最终链接时会合并为一个定义

// Header file: MathLib.h
inline int add(int a, int b) {
    return a + b;
}

// Source file 1: main.cpp
#include "MathLib.h"
#include <iostream>

int main() {
    std::cout << add(3, 4) << std::endl;
    return 0;
}

// Source file 2: helper.cpp
#include "MathLib.h"

int multiplyByTwo(int x) {
    return add(x, x);  // Uses the inline function add
}

内联函数 add 被定义在头文件 MathLib.h 中,并被 main.cpp 和 helper.cpp 两个源文件包含和使用。这是合法的,因为 add 是一个内联函数,它的定义在多个翻译单元中是相同的,并且使用了 inline 关键字

定义 多个类型不相关的命名空间 应该使用单独的文件分别表示每个类型(或关联类型构成的集合)

3、定义本书的命名空间
通过 使用上述接口 与 实现分离的机制,可以将 cplusplus_primer 库 定义在几个不同的文件中。Sales_data 类的声明 及其函数将置于 Sales_data.h 头文件中,第 15 章介绍的 Query 类将置于 Query.h 头文件中,以此类推。对应的实现文件将是 Sales_data.cc 和 Query.cc

// Sales_data.h
// #include 应该出现在打开命名空间的操作之前
#include <string>
namespace cplusplus_primer {
	class Sales_data {/.../};
	Sales_data operator+(const Sales_data&, const Sales_data&);
	// Sales_data 的其他接口函数的声明
}

// Sales_data.cc
// 确保#include 出现在打开命名空间的操作之前
#include "Sales_data.h"
namespace cplusplus_primer {
// Sales_data 成员及重载运算符的定义
}

程序 如果想使用我们定义的库,必须包含必要的头文件,这些头文件中的名字 定义在命名空间 cplusplus_primer 内:

// --- user.cc ---
// Sales_data.h 头文件的名字位于命名空间 cplusplus_primer 中
#include "Sales_data.h"

int main()
{
	using cplusplus_primer::Sales_data;
	Sales_data trans1, trans2;
	// ...
	return 0;
}

这种程序的组织方式 提供了开发者和库用户所需的模块性。每个类 仍组织在自己的接口和实现文件中,一个类的用户 不必编译与其他类相关的名字
对用户隐藏了实现细节,同时允许文件 Sales_data.cc 和 user.cc 被编译并链接成一个程序 而不会产生任何编译时错误或链接时错误。库的开发者可以分别实现每一个类,相互之间没有干扰

在通常情况下,不把 #include 放在命名空间内部。如果这么做了,隐含的意思是 把头文件中的所有名字定义成该命名空间的成员。例如,如果 Sales_data.h 在包含 string 头文件(会打开命名空间 std)前 就已经打开了命名空间 cplusplus_primer ,则程序将出错,因为这么做意味着 试图将命名空间 std 嵌套在命名空间 cplusplus_primer 中

4、定义命名空间成员
假定作用域中 存在合适的声明语句,则命名空间中的代码 可以直接使用同一命名空间定义的名字的简写形式:

#include "Sales_data.h" 
namespace cplusplus_primer {    //重新打开命名空间cplusplus_primer  
	// 命名空间中定义的成员可以直接使用名字,此时无须前缀
	std::istream& operator>>(std::istream &in, Sales_data &s) {/*...*/}
}

也可以 在命名空间定义的外部 定义该命名空间的成员。命名空间对于名字的声明 必须在作用域内,同时该名字的定义 需要明确指出其所属的命名空间:

//命名空间之外定义的成员必须使用含有前缀的名字
cplusplus_primer::Sales_data
cplusplus_primer::operator+(const Sales_data &lhs,
                        const Sales_data &rhs) 
// 不需要前缀 cplusplus_primer 的原因是在实现函数 cplusplus_primer::operator+ 时已经位于 cplusplus_primer 命名空间的内部
{
    Sales_data ret(lhs);
    //...
}

不需要前缀 cplusplus_primer 的原因是在实现函数 cplusplus_primer::operator+ 时已经位于 cplusplus_primer 命名空间的内部,举个例子:

namespace std {
    void func();
}

#include <iostream>
// 在命名空间外定义函数体
void std::func() {
    cout << "Hello, World!" << endl; // 不需要加 std:: 前缀
}

int main() {
    std::func();
    return 0;
}

在这里插入图片描述
和 定义在类外部的类成员一样,一旦看到 含有完整前缀的名字,就确定 该名字位于命名空间的作用域内。在命名空间 cplusplus_primer 内部,可以直接使用 该命名空间的其他成员,比如在上面的代码中,可以直接使用 Sales_data 定义函数的形参

尽管 命名空间的成员可以定义在命名空间外部,但是这样的定义 必须出现在 所属命名空间的外层空间中。换句话说,可以在 cplusplus_primer 或全局作用域中 定义 Sales_data operator+,但是 不能在一个不相关的作用域中 定义这个运算符

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

// 必须将模板特例化声明成 std 的成员
namespace std {
	template <> struct hash<Sales_data>;	
	// 除了为模板类提供一个通用版本外,也为某些类型(如 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); }
    // 其他成员与之前的版本一致
}

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

作用域运算符 同样可以 用于全局作用域的成员,因为全局作用域是隐式的,所以它并没有名字。下面的形式

::member_name

表示全局命名空间中的一个成员

#include <iostream>

// 全局命名空间中的变量
int globalVar = 42;

// ::globalVar = 2;错的,只能在别的命名空间里面用

namespace MyNamespace {
    int globalVar = 100; // 局部命名空间中的变量

    void printVars() {
        std::cout << "MyNamespace::globalVar: " << globalVar << std::endl;
        // ::member_name 访问全局命名空间中的globalVar
        std::cout << "::globalVar: " << ::globalVar << std::endl; 
    }
}

int main() {
    MyNamespace::printVars();

    return 0;
}

在这里插入图片描述

全局作用域 在C++中不能直接跨文件(自然 全局作用域中的对象 也没办法跨文件),但可以通过一些机制实现类似的效果
1)使用 extern 关键字
extern 关键字用于 声明一个全局变量或函数在另一个文件中定义。这种方法 可以让全局变量或函数 在多个文件之间共享

示例:
假设 有两个文件 file1.cpp 和 file2.cpp,编译时,将两个文件一起编译
file1.cpp:

#include <iostream>

int globalVar = 10; // 定义全局变量

void printGlobalVar() {
    std::cout << "globalVar: " << globalVar << std::endl;
}

file2.cpp:

#include <iostream>

// 声明全局变量
extern int globalVar;

void printGlobalVar();

int main() {
    printGlobalVar(); // 使用 file1.cpp 中定义的函数
    globalVar = 20;   // 修改全局变量
    printGlobalVar(); // 变20了
    return 0;
}

2)头文件包含
也可以将全局变量的声明放在头文件中,然后在 每个需要使用这个全局变量的源文件中 包含这个头文件
global.h:

extern int globalVar; // 通过 extern 声明全局变量

file1.cpp:

#include "global.h"

int globalVar = 10; // 定义全局变量

file2.cpp:

#include "global.h"
#include <iostream>

int main() {
    std::cout << "globalVar: " << globalVar << std::endl;
    return 0;
}

这种方式 也需要将所有相关的源文件一起编译

3)静态变量
如果 希望一个全局变量只能在一个文件中访问,可以使用 static 关键字,这样变量的作用域将被限制在定义它的文件内

file1.cpp:

static int staticVar = 10; // 静态全局变量,仅在 file1.cpp 中可见

file2.cpp:

extern int staticVar; // 错误:file2.cpp 无法访问 staticVar

extern 关键字:用于在多个文件中共享全局变量或函数
头文件包含:将声明放在头文件中,通过 #include 实现跨文件共享
static 关键字:将变量的作用域限制在文件内,防止跨文件访问

在全局命名空间中 定义的变量、函数或类,可以在 同一编译单元(文件)内的任何地方访问,也可以通过 在其他文件中使用 extern 声明进行访问
global_namespace_example.cpp

#include <iostream>

// 在全局命名空间中定义的变量、函数或类。可以在 global_namespace_example.cpp 文件中的任何地方直接访问

// 在全局命名空间中定义一个全局变量
int globalVar = 42;

// 在全局命名空间中定义一个全局函数
void printGlobalVar() {
    std::cout << "Global variable value: " << globalVar << std::endl;
}

// 在全局命名空间中定义一个类
class GlobalClass {
public:
    void display() {
        std::cout << "This is a method in GlobalClass" << std::endl;
    }
};

int main() {
    printGlobalVar();  // 调用全局函数

    GlobalClass obj;   // 创建全局类的对象
    obj.display();     // 调用全局类的方法

    return 0;
}

在多个文件中使用全局命名空间
文件1:global_vars.h

// 声明全局变量和函数
extern int globalVar;

void printGlobalVar();

文件2:global_vars.cpp

#include <iostream>
#include "global_vars.h"

// 定义全局变量
int globalVar = 42;

// 定义全局函数
void printGlobalVar() {
    std::cout << "Global variable value: " << globalVar << std::endl;
}

文件3:main.cpp

#include "global_vars.h"

int main() {
    printGlobalVar();  // 使用全局变量和全局函数

    globalVar = 100;   // 修改全局变量
    printGlobalVar();  // 再次使用全局变量

    return 0;
}

编译

g++ global_vars.cpp main.cpp -o global_example

7、嵌套的命名空间
嵌套的命名空间是指 定义在其他命名空间中的命名空间:

namespace cplusplus_primer {    
	// 第一个嵌套的命名空间:定义了库的Query部分
	namespace QueryLib {
		class Query { /*...*/ };
		Query operator+(const Query&, const Query&);
		// ...
	}

	// 第二个嵌套的命名空间:定义了库的Sales_data部分
	namespace Bookstore {
		class Quote { /*...*/ };
		class Disc_quote : public Quote { /*...*/ };
		// ...
	}
}

将命名空间 cplusplus_primer 分割为 两个嵌套的命名空间,分别是 QueryLib 和 Bookstore

嵌套的命名空间 同时是一个嵌套的作用域,它嵌套在 外层命名空间的作用域中。嵌套的命名空间中的名字 遵循的规则与往常类似:内层命名空间声明的名字 将隐藏外层命名空间声明的同名成员。在嵌套的命名空间中 定义的名字 只在内层命名空间中有效,外层命名空间中的代码 要想访问它 必须在名字前添加限定符。例如,在嵌套的命名空间 QueryLib 中声明的类名是 cplusplus_primer::QueryLib::Query

8、内联命名空间
C++11 新标准 引入了 一种新的嵌套命名空间,称为内联命名空间。和普通的嵌套命名空间不同,内联命名空间中的名字 可以被外层命名空间直接使用

定义内联命名空间的方式是 在关键字 namespace 前添加关键字 inline:

inline namespace FifthEd {
    // 该命名空间表示本书第5版的代码
}
namespace FifthEd { // 隐式内联
	class Query_base {/*...*/};
    // 其他与 Query 有关的声明
}

关键字 inline 必须出现在 命名空间第一次定义的地方,后续再打开命名空间的时候 可以写 inline,也可以不写

当应用程序的代码 在一次发布和另一次发布之间 发生了改变时,常常会用到 内联命名空间。例如,可以把本书当前版本的所有代码 都放在一个内联命名空间中,而之前版本的代码 都放在一个非内联命名空间中:

namespace FourthEd {
    class Item_base {/*...*/};
    class Query_base {/*...*/};   
    // 本书第四版用到的其他代码
}

命名空间 cplusplus_primer 将同时使用这两个命名空间。例如,假定每个命名空间 都定义在同名的头文件中,则可以把命名空间 cplusplus_primer 定义成如下形式:

namespace cplusplus_primer {
#include "FifthEd.h"
#include "FourthEd.h"
}

因为 FifthEd 是内联的,所以形如 cplusplus_primer:: 的代码可以直接获得 FifthEd 的成员。如果我们想使用早期版本的代码,则必须像其他嵌套的命名空间一样 加上完整的外层命名空间名字,比如 cplusplus_primer::FourthEd::Query_base

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

一个未命名的命名空间 可以在某个给定的文件内不连续,但是 不能跨越多个文件。每个文件定义自己的未命名的命名空间,如果两个文件都含有未命名的命名空间,则这两个空间互相无关(和其他命名空间不同,未命名的命名空间 仅在特定的文件内部有效,其作用范围 不会横跨多个不同的文件)。在这两个未命名的命名空间中 可以定义相同的名字,并且 这些定义表示的是不同实体。如果一个头文件 定义了未命名的命名空间,则该命名空间中定义的名字 将在每个包含了该头文件的文件中对应不同实体

定义在 未命名的命名空间中的名字 可以直接使用;同样的,也不能对未命名的命名空间的成员 使用作用域运算符(全局命名空间可用 ::member_name,当然也可以直接用)

未命名的命名空间中 定义的名字的作用域 与该命名空间所在的作用域相同。如果未命名的命名空间 定义在文件的最外层作用域中,则该命名空间中的名字 一定要与全局作用域中的名字有所区别:

int i = 0; // i的全局说明
namespace {
	int i;
}
// 二义性:i 的定义既出现在全局作用域中,又出现在未嵌套的未命名的命名空间中
i = 10;

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

namespace local {
	namespace {
    	int i;
    }
}

// 正确:定义在嵌套的未命名的命名空间中的 i 与全局作用域中的 i 不同
local::i = 42;

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

在文件中进行静态声明的做法已经被 C++ 标准取消了,现在的做法是使用未命名的命名空间

18.13 什么时候应该使用未命名的命名空间
在需要 在其所在的文件中可见,在其所在的文件外 不可见时;
static 只能用于变量与函数,不可用于 用户自定义的类型

18.14 假设下面的 operator* 声明的是 嵌套的命名空间 mathLib::MatrixLib 的一个成员:

namespace mathLib {
	namespace MatrixLib {
		class matrix { /* ... */ };
		matrix operator* (const matrix &, const matrix &);
		// ...
	}
}

在全局作用域中声明该运算符

mathLib::MatrixLib::matrix mathLib::MatrixLib::operator*(const matrix&, const matrix&);

2.2 使用命名空间成员

1、之前的程序已经使用过其中一种方法,即 using 声明,还将介绍 另外几种方法,如命名空间的别名 以及 using 指示等

2、命名空间的别名
使得 可以为命名空间的名字 设定一个短得多的同义词

namespace cplusplus_primer { /*...*/ }

可以为其设定一个短得多的同义词:

namespace primer = cplusplus_primer;

不能在命名空间还没有定义前 就声明别名
命名空间的别名 也可以指向一个嵌套的命名空间:

namespace QLib = cplusplus_primer::QueryLib;
QLib::Query q;

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

2、using 声明:扼要概述
一条 using 声明语句 一次只引入命名空间的一个成员

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

一条 using 声明语句 可以出现在全局作用域、局部作用域、命名空间作用域 以及类的作用域中。在类的作用域中,这样的声明语句只能指向基类成员(用于引入基类中的名称,使其在派生类中可见 using Base::show; // 引入基类的所有show()函数

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

using 指示以关键字 using 开始,后面是关键字 namespace 以及 定义好的命名空间的名字。using 指示可以出现在 全局作用域、局部作用域 和 命名空间作用域中,但是 不能出现在类的作用域中

class MyClass {
    // 错误:不能在类的作用域中使用using指示
    using namespace MyNamespace; 
};

using 声明是 可用出现在类的作用域中

class Derived : public Base {
public:
    using Base::foo;  // 合法:引入基类的成员
};

using 指示 使得某个特定的命名空间中 所有的名字都可见,这样 就无须再为它们添加任何前缀限定符。简写的名字从 using 指示开始,一直到最后 using 指示所在的作用域结束 都能使用

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

4、using 指示与作用域
using 指示引入的名字的作用域 远比 using 声明引入的名字的作用域复杂。using 声明的名字的作用域与 using 声明语句本身的作用域一致;using 指示 具有将命名空间成员 提升到 包含命名空间本身和 using 指示的最近作用域的能力

对于 using 声明来说,只是简单地 令名字在局部作用域内有效。相反,using 指示 是令整个命名空间的所有内容变得有效。通常情况下,命名空间中会含有 一些不能出现在局部作用域中的定义,因此,using 指示一般被视为是 出现在最近的外层作用域中

假定 有一个命名空间 A 和一个函数 f,都定义在全局作用域中。如果 f 含有一个对 A 的 using 指示,则在 f 看来,A 中的名字仿佛是出现在全局作用域中 f 之前的位置一样

// 命名空间 A 和函数 f 定义在全局作用域中
namespace A {
	int i, j;
}
void f() 
{
	using namespace A; // 把 A 中的名字注入到全局作用域中
	cout << i * j << endl; // 使用命名空间 A 中的 i 和 j
	// ...
}

5、using 指示示例

namespace blip {
	int i = 16, j = 15, k = 23;
	// 其他声明
}

int j = 0; // 正确:blip 的 j 隐藏在命名空间中

void manip()
{
	// using 指示,blip 中的名字被“添加”到全局作用域中
	using namespace blip; 	// 如果使用了 j,则将在::j 和 blip::j 之间产生冲突
	++i; 					// 将 blip::i 设置为 17
	++j; 					// 二义性错误:是全局的 j 还是 blip::j
	++::j; 					// 正确:将全局的 j 设置为 1
	++blip::j; 				// 正确:将 blip::j 设置为 16
	int k = 97; 			// 当前局部的 k 隐藏了 blip::k
	++k; 					// 将当前局部的 k 设置为 98
}

当命名空间 被注入到它的外层作用域之后,很可能 该命名空间中定义的名字 会与其外层作用域中的成员冲突。例如在 manip 中,blip 的成员 j 就与全局作用域中的 j 产生了冲突。这种冲突是允许存在的,但是要想 使用冲突的名字,就必须明确指出 名字的版本。manip 中所有未加限制的 j 都会产生二义性错误

因为 manip 的作用域 和 命名空间的作用域不同,所以 manip 内部的声明 可以隐藏命名空间中的某些成员名字。例如,局部变量 k 隐藏了命名空间的成员 blip::k。在 manip 内使用 k 不存在二义性,它指的就是局部变量 k

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

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

当引入库的新版本后,正在工作的程序 很可能会编译失败。如果新版本引入了一个 与应用程序正在使用的名字冲突的名字,就会出现这个问题
另一个风险是 由 using 指示引发的二义性错误 只有在使用了冲突名字的地方才能 被发现。这种延后的检测意味着 可能在特定库引入很久之后才爆发冲突。直到程序开始使用该库的新部分后,之前一直未被检测到的错误才会出现。

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

using指示 也并非一无是处,例如 在命名空间本身的实现文件中 就可以使用 using 指示(使得在随后的代码中可以直接使用这些名称,而不需要命名空间前缀,指示 仅在实现文件中有效,因此不会影响 其他文件的命名空间解析)
有一个名为 MyNamespace 的命名空间,并且其中包含一些函数和类的声明

// MyNamespace.h
namespace MyNamespace {
    void myFunction();
    class MyClass {
    public:
        void doSomething();
    };
}

在实现文件中,可以使用 using namespace 指示来简化代码:

// MyNamespace.cpp
#include "MyNamespace.h"
using namespace MyNamespace; // 在实现文件中使用 using 指示

void myFunction() {
    // 直接使用函数名,而不需要前缀 MyNamespace::
}

void MyClass::doSomething() {
    // 直接使用类名,而不需要前缀 MyNamespace::
}

18.16

# include <iostream>

namespace Exercise {
	int ivar = 0;
	double dvar = 0;
	const int limit = 1000;
}

int ivar = 1; // 2 3 4 没事,1 的时候加入就多次声明了

//using Exercise::ivar;	//1
//using Exercise::dvar;
//using Exercise::limit;

// using namespace Exercise;	//3

void mainp() {
	 //using Exercise::ivar;	//2
	 //using Exercise::dvar;
	 //using Exercise::limit;

	using namespace Exercise;	//4

	// double dvar = 3.1416; 1 可用 2 会跟using Exercise::dvar冲突
	int iobj = limit + 1;
	// std::cout << ivar << std::endl;// 1 2
	std::cout << Exercise::ivar << std::endl;// 3 4
	// 1 在全局命名空间 使用 using 声明 覆盖了全局的ivar,不管是ivar 还是 ::ivar,都是用的Exercise::ivar 
	// 2 ivar 用的 Exercise::ivar,::ivar 是全局作用域的那个,using 指示只是在作用域内有效(函数内)
	// 3 4 需要指明是Exercise::ivar
	std::cout << ::ivar << std::endl;
}

int main()
{
	mainp();
	return 0;
}

2.3 类、命名空间与作用域

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

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

对于位于 命名空间中的类 来说,常规的查找规则 仍然适用:当成员函数使用某个名字时,首先 在该成员中进行查找,然后 在类中查找(包括基类),接着 在外层作用域中查找,这时 一个或几个外层作用域就是命名空间:

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; // 在C1中隐藏了A::i
		int j;
	};
	int h = i; // 用A::i进行初始化
}
// 成员f3定义在C1和命名空间A的外部
int A::C1::f3() { return h; } // 正确:返回A::h

除了类内部出现的 成员函数定义之外,总是 向上查找作用域。f2() 返回的 h 中,如果 h 在 A 中定义的位置位于 C1 的定义之前,则上述语句将合法。类似的,因为 f3 的定义位于 A::h 之后,所以 f3 对于 h 的使用是合法的

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

std::string s;
operator>>(std::cin, s); 

operator>> 函数 定义在标准库 string 中,string 又定义在命名空间 std 中。但是我们不用 std:: 限定符 和 using 声明就可以调用 operator>>

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

当编译器发现对 operator>> 的调用时,首先在当前作用域中 寻找合适的函数,接着 查找输出语句的外层作用域。随后,因为>>表达式的形参是类类型的,所以编译器会查找 cin 和 s 的类所属的命名空间。也就是说,对于这个调用来说,编译器 会查找定义了 istream 和 string 的命名空间 std。当在 std 中查找时,编译器找到了 string 的输出运算符函数

查找规则的这个例外 允许概念上 作为类接口一部分的非成员函数 无需单独的 using 声明就能被程序使用。假如该例外不存在,则 将不得不为 输出运算符专门提供一个using声明:

using std::operator>>; 	// 要想使用cin >> s就必须有该 using 声明

或者 使用函数调用的形式 以把命名空间的信息包含进来:

std::operator>>(std::cin, s);  // 正确:显式地使用std::

3、查找 与 std::move 和 std::forward
如果 在应用程序中 定义了一个标准库中的名字,则会出现 以下两种情况之一:要么根据一般的重载规则 确定某次调用 应该执行函数的哪个版本;要么应用程序 根本就 不会执行函数的标准库版本

考虑标准库 move 和 forward 函数。这两个 都是模板函数,在标准库的定义中 都接受 一个右值引用的函数形参。在函数模板中,右值引用形参 可以匹配任何类型(右值引用形参 (T&&) 可以匹配右值(例如临时对象或表达式的结果),但不能直接匹配左值(变量等有持久存储地址的对象)。但是,有一种特殊的右值引用,称为万能引用,其语法形式也是 T&&。当一个 T&& 在函数模板的参数推导中使用时,如果 T 是一个模板参数,这个 T&& 可以匹配左值、右值两种类型,这种情况下 T&& 就成了万能引用)
如果 应用程序也定义了一个接受单一形参的 move 函数,则不管 该形参是什么类型,应用程序的 move 函数都将与标准库的版本冲突

move(以及forward)的名字冲突要比其他标准库函数的冲突频繁得多
建议 最好使用它们的带限定词的完整版本。通过书写 std::move 而不是 move,就能明确地知道 想要使用的是函数的标准库版本

4、友元声明 与 实参相关的查找
当类 声明了一个友元时,该友元声明 并没有使得友元本身可见(友元声明只是授予指定类或函数访问私有和保护成员的权限,但不会使这些友元在当前作用域中可见。为了使用这些友元,仍然需要在它们各自的作用域中显式声明或定义它们)。然而,一个另外的未声明的类或函数 如果第一次出现在友元声明中,则 认为它是最近的外层命名空间的成员

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没有被声明
}

因为 f 接受一个类类型的实参,而且 f 在 C 所属的命名空间 进行了 隐式的声明,所以 f 能被找到。相反,因为 f2 没有形参,所以它无法被找到(区别是 可以没有显式声明)

当调用一个函数,并且该函数的参数类型 与某个类类型相关联时,编译器会在该类的命名空间中 查找该函数。这是因为 友元函数可以被认为是在类的命名空间中声明的
因此,当在 main 函数中调用 f(cobj) 时,编译器会根据实参 cobj 的类型 (类型为 A::C) 查找相应的函数。由于 f 在 A::C 中被声明为友元,编译器能够找到 A::f 并成功调用它

当尝试调用 f2() 时,没有传递任何参数,因此编译器 不知道去哪里查找 f2。即使 f2 被声明为 C 的友元,由于没有参数,编译器无法根据实参相关查找规则来定位 f2
因为 f2 在 main 函数所在的命名空间中没有声明(using A::f2),编译器无法找到它,从而导致编译错误

#include <iostream>

namespace A {
    class C {
        friend void f(); // 友元函数的声明
        friend void f2(const C&); // 带实参的友元函数的声明
    private:
        int value = 42;
    };

    void f() { // 在命名空间 A 中定义友元函数
        std::cout << "f is called" << std::endl;
    }

    void f2(const C& obj) { // 在命名空间 A 中定义友元函数
        std::cout << "f2 is called, value = " << obj.value << std::endl;
    }
}

int main() {
    using A::f; // 显式声明
    f(); // 需要加A::友元函数(A::f(),或者显式声明)

	A::C cobj;
    f2(cobj); // 能自己在A::中找
    
    return 0;
}

在这里插入图片描述
18.18 已知有下面的 swap 的典型定义,当 mem1 是一个 string 时程序使用 swap 的哪个版本?如果 mem1 是 int 呢?说明在这两种情况下名字查找的过程

void swap(T v1, T v2)
{
	using std::swap;
	swap(v1.mem1, v2.mem1);
	//交换类型的其他成员
}

swap(v1.mem1, v2.mem1); 代码中,编译器会进行如下的名称查找过程:
局部作用域查找: 首先查找 swap 在局部作用域中的定义。在这段代码中,using std::swap; 引入了 std::swap 进入当前局部作用域
名称隐藏: 在当前作用域中,std::swap 被引入,如果 std::swap 是一个合适的匹配(根据 v1.mem1 和 v2.mem1 的类型),那么它将会被使用,而不是当前定义的 void swap(T v1, T v2) 函数

当 mem1 是一个 string 时,使用 std::swap(std::string&, std::string&),当 mem1 是一个 int 时,使用 template <class T> void swap(T& a, T& b) noexcept(noexcept(swapdecl));

如果对 swap 的调用形如 std::swap(v1.mem1, v2.mem1) 将会发生什么情况?
将只使用标准库的 swap,如果 v1.mem1 和 v2.mem1 为用户自定义类型,将无法使用用户定义的针对该类型的 swap

std::swap 的几种形式:
1)通用版本:

template <class T>
void swap(T& a, T& b) noexcept(noexcept(swapdecl));

这个版本是最常用的,它可以接受任何类型的对象 a 和 b,只要这些类型支持移动操作。可以交换 数组、指针、基本类型(如 int、float、double、char 等)

2)特定类型版本:
对于某些特定类型,如 std::string 或 std::vector(交换标准库容器(如 std::vector、std::string、std::list、std::map 等)),std::swap 可能会有更高效的实现,通常直接作为类型的方法提供。例如:

void swap(string& s1, string& s2) noexcept;

专门用于 std::string 类型的对象

许多标准容器和类型都提供了成员版本的 swap 方法,这通常是内部最高效的方式:

class vector {
    // ...
    void swap(vector& other) noexcept;
    // ...
};

3)用户自定义版本
如果没有提供自定义的 swap 函数,std::swap 将使用拷贝构造函数和赋值运算符进行交换

class MyClass {
public:
    int x;
    MyClass(int val) : x(val) {}
};

MyClass obj1(1), obj2(2);
std::swap(obj1, obj2);

2.4 重载与命名空间

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

2、与实参相关的查找与重载
对于 接受类类型实参的函数来说,其名字查找 将在实参类所属的命名空间中 进行。将要在每个实参类(以及实参类的基类)所属的命名空间中 寻找候选函数。在这些命名空间中 所有与被调用函数同名的函数 都将被添加到候选集中,即使其中有些函数在调用语句处不可见 也是如此:

namespace NS { //...
    class Quote { /*...*/ };

    void display(const Quote&);
    //Bulk_item的基类声明在命名空间NS中
    class Bulk_item : public NS::Quote { /*...*/ };

    int main()
    {
        Bulk_item book1;
        display(book1);
        return 0;
    }
}

传递给 display 的实参 属于类类型 Bulk_item,因此 该调用语句的候选函数 不仅应该在调用语句所在的作用域中 查找,而且还应该在 Bulk_item 及 其基类 Quote 所属的命名空间中查找。命名空间 NS 中声明的函数 display(const Quote&) 也将被添加到候选函数集当中

3、重载与 using 声明
using 声明语句声明的是一个名字,而非一个具体的函数

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

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

一个 using 声明囊括了 重载函数的所有版本 以确保不违反命名空间的接口。库的作者 为某项任务提供了好几个不同的函数,允许用户 选择性地忽略重载函数中的一部分 但不是全部 有可能导致意想不到的程序行为

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

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

namespace libs_R_us {
    extern void print(int);
    extern void print(double);
} 
// 普通的声明
void print(const std::string &);
// 这个using指示把名字添加到print调用的候选函数集
using namespace libs_R_us; 
// print调用此时的时候候选函数包括:
// libs_R_us的print(int)
// libs_R_us的print(double)
// 显式声明的print(const std::string &)
void fooBar(int ival) 
{
    print("Value:");   // 调用全局函数print(const string &)
    print(ival);       // 调用libs_R_us::print(int)
}

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

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

namespace AW { 
	int print(int); 
}
namespace Primer { 
	double print(double); 
}
// using指示从不同的命名空间中 创建了一个重载函数集合
using namespace AW;
using namespace Primer;
long double print(long double);
int main() {
	print(1);         // 调用 AW::print(int)
	print(3.1);       // 调用 Primer::print(double)
	return 0;
}

函数 print 的重载集合包括 print(int)、print(double) 和 print(long double),尽管它们的声明位于不同作用域中,但它们都属于 main 函数中 print 调用的候选函数集

18.20

namespace primerLib {
	void compute();
	void compute(const void *);
}
using primerLib::compute;
void compute(int);
void compute(double, double = 3.4);
void compute(char*, char* = 0);
void f()
{
	compute(0);
}

候选函数:全部 compute 函数
在这里插入图片描述

可行函数:
void compute(int)(最佳匹配)
void compute(double, double = 3.4)(int->double,整型提升)
void compute(char*, char* = 0)(0->nullptr)
void compute(const void *)(0->nullptr)(任何对象都可以转换为 const void *

将 using 声明置于 main 函数中 compute 的调用点之前会发生什么情况
void compute(const void *)为最佳匹配
using primerLib::compute; 的引入影响了候选函数的选择顺序:
在 C++ 的重载解析中,如果 using 声明引入了一个函数,并且存在一个符合条件的非模板函数(如 primerLib::compute(const void *)),那么这个函数将优先于 当前作用域中的其他重载函数

3、多重继承与虚继承

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

以动物园中动物的层次关系 为例。动物园中的动物 存在于 不同的抽象级别上。有个体的动物,如 Ling-Ling、Mowgli 和 Balou 等,以名字进行区分;每个动物 属于一个物种,例如 Ling-Ling 是一只大熊猫;物种又是科的成员,大熊猫是熊科的成员;每个科是动物界的成员,在这个例子中 动物界是指一个动物园中所有动物的总和

将定义一个抽象类 ZooAnimal,用它来保存动物园中 动物共有的信息 并提供公共接口。类 Bear 将存放 Bear 科特有的信息,以此类推
还包含 其他一些辅助类,这些类 负责封装不同的抽象,如 濒临灭绝的动物(Endangered 类)。以类 Panda 的实现为例,Panda 是由 Bear 和 Endangered 共同派生而来的

3.1 多重继承

1、在派生类的派生列表中 可以包含多个基类:

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

每个基类 包含一个可选的访问说明符。一如此往常,如果 访问说明符被忽略掉了,则关键字 class 对应的默认访问说明符是 private,关键字 struct 对应的是 public

和只有一个基类的继承一样,多重继承的派生列表 也只能包含已经被定义过的类,而且这些类不能是 final 的。对于派生类能够继承的基类个数,C++ 没有进行特殊规定;但是 在某个给定的派生列表中,同一个基类 只能出现一次

2、多重继承的派生类从每个基类中继承状态
在这里插入图片描述
3、派生类构造函数 初始化所有基类
构造一个派生类的对象 将同时构造并初始化它的所有基类子对象。与从一个基类进行的派生一样,多重继承的派生类的构造函数初始值 也只能初始化它的直接基类:

// 显式地初始化所有基类
Panda::Panda(std::string name, bool onExhibit)
	  : Bear(name, onExhibit, "panda"),
		Endangered(Endangered::critical) { }

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

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

一个 Panda 对象按照如下次序进行初始化:

  • ZooAnimal 是整个继承体系的最终基类,Bear 是 Panda 的直接基类,ZooAnimal 是 Bear 的基类,所以首先初始化 ZooAnimal
  • 接下来初始化 Panda 的第一个直接基类 Bear
  • 然后初始化 Panda 的第二个直接基类 Endangered
  • 最后初始化 Panda

4、继承的构造函数 与 多重继承
在 C++11 新标准中,允许派生类 从它的的一个或几个基类中继承构造函数。但是,如果从多个基类中 继承了相同的构造函数(即形参列表完全相同),则程序将产生错误:

struct Basel {
    Basel() = default;
    Basel(const std::string&);
    Basel(std::shared_ptr<int>);
};

struct Base2 {
    Base2() = default;
    Base2(const std::string&);
    Base2(int);
};
// 错误:D1试图从两个基类中都继承D1::D1(const string&)
struct D1 : public Basel, public Base2 {
    using Basel::Basel;      // 从Base1继承构造函数数
    using Base2::Base2;      // 从Base2继承构造函数数
};

如果一个类 从它的多个基类中 继承了相同的构造函数,则这个类 必须为该构造函数定义它自己的版本:

struct D2 : public Basel, public Base2 {
    using Basel::Basel;       // 从Base1 继承构造函数
    using Base2::Base2;       // 从Base2 继承构造函数
    // D2 必须自定义一个接受 string 的构造函数
    D2(const string &s) : Basel(s), Base2(s) {}
    D2() = default;          // 一旦D2 定义了自己的构造函数,则必须出现
};

和往常一样,派生类的析构函数 只负责清除派生类本身分配的资源,派生类的成员及基类 都是自动销毁的。合成的析构函数体为空

在派生类的析构函数 执行完成之后,C++ 会自动调用基类的析构函数。这意味着 基类部分的清理工作 由基类的析构函数来处理,而无需 在派生类的析构函数中 手动调用基类的析构函数,由于这些清理工作是 自动完成的,因此如果 派生类没有额外的资源需要清理,派生类的析构函数体 可以是空的
如果 没有为派生类 显式定义析构函数,C++ 编译器会为你 生成一个默认的析构函数,即“合成的析构函数”。这个合成的析构函数 什么也不做,它只是 调用基类的析构函数 以及派生类所有成员的析构函数

将基类的析构函数 声明为虚函数(virtual),确保了 在通过基类指针删除派生类对象时,会调用派生类的析构函数
虚析构函数的工作机制是,C++通过虚函数表 来跟踪对象的动态类型,因此在删除对象时,会按照正确的顺序 调用析构函数:首先调用 派生类的析构函数,然后是 基类的析构函数

如果类 将来可能会被继承,并且可能通过 基类指针或引用来 操作对象,应该始终 将基类的析构函数 声明为虚函数

class Base {
public:
    virtual ~Base() {
        // 基类的清理工作
    }
};

class Derived : public Base {
public:
    ~Derived() override {  // 派生类的清理工作
        // 释放派生类的资源
    }
};

无论是 通过基类指针 还是派生类指针 删除对象,析构函数的调用顺序 都将是正确的

析构函数的调用顺序 正好与构造函数相反,在例子中,析构函数的调用顺序是 ~Panda~Endangered~Bear~ZooAnimal

5、多重继承的派生类的拷贝 与 移动操作
重继承的派生类 如果定义了 自己的拷贝 / 赋值构造函数 和 赋值运算符,则必须 在完整对象上执行拷贝、移动 或 赋值操作,而不仅仅是 派生类自身的部分

  • 拷贝构造函数:如果你在派生类中定义了拷贝构造函数,你需要确保在这个构造函数中,调用所有基类的拷贝构造函数,从而保证整个对象的拷贝是完整的
  • 赋值构造函数:类似地,如果定义了赋值构造函数,也需要确保在构造过程中正确调用基类的赋值构造函数
  • 赋值运算符:如果你重载了赋值运算符(operator=),你需要在该运算符的实现中,先调用基类的赋值运算符,然后再处理派生类自身的数据成员。这样可以确保完整对象的赋值操作是正确的

定义 Base1 和 Base2 类,它们都有自己的拷贝构造函数和赋值运算符:

#include <iostream>
#include <string>

class Base1 {
public:
    std::string data1;

    Base1(const std::string& data) : data1(data) {}

    // 拷贝构造函数
    Base1(const Base1& other) : data1(other.data1) {
        std::cout << "Base1 拷贝构造函数" << std::endl;
    }

    // 赋值运算符
    Base1& operator=(const Base1& other) {
        if (this != &other) {
            data1 = other.data1;
            std::cout << "Base1 赋值运算符" << std::endl;
        }
        return *this;
    }
};

class Base2 {
public:
    std::string data2;

    Base2(const std::string& data) : data2(data) {}

    // 拷贝构造函数
    Base2(const Base2& other) : data2(other.data2) {
        std::cout << "Base2 拷贝构造函数" << std::endl;
    }

    // 赋值运算符
    Base2& operator=(const Base2& other) {
        if (this != &other) {
            data2 = other.data2;
            std::cout << "Base2 赋值运算符" << std::endl;
        }
        return *this;
    }
};

定义派生类 Derived,它从 Base1 和 Base2 继承:

class Derived : public Base1, public Base2 {
public:
    std::string data3;

    Derived(const std::string& data1, const std::string& data2, const std::string& data3)
        : Base1(data1), Base2(data2), data3(data3) {}

    // 拷贝构造函数
    Derived(const Derived& other) : Base1(other), Base2(other), data3(other.data3) {
        std::cout << "Derived 拷贝构造函数" << std::endl;
    }

    // 赋值运算符
    Derived& operator=(const Derived& other) {
        if (this != &other) {
            Base1::operator=(other);
            Base2::operator=(other);
            data3 = other.data3;
            std::cout << "Derived 赋值运算符" << std::endl;
        }
        return *this;
    }
};

拷贝构造函数:在 Derived 的拷贝构造函数中,我们调用了 Base1 和 Base2 的拷贝构造函数来确保基类部分的数据也被正确拷贝。这样可以保证整个对象(包括基类部分)的拷贝是完整的
赋值运算符:在 Derived 的赋值运算符中,我们首先调用 Base1 和 Base2 的赋值运算符来处理基类的赋值,然后再处理派生类自身的数据成员 data3 的赋值。这确保了整个对象的赋值操作是完整的

int main() {
    Derived obj1("Data1", "Data2", "Data3");
    Derived obj2 = obj1;  // 触发拷贝构造函数

    Derived obj3("Data4", "Data5", "Data6");
    obj3 = obj1;  // 触发赋值运算符

    return 0;
}

输出

Base1 拷贝构造函数
Base2 拷贝构造函数
Derived 拷贝构造函数
Base1 赋值运算符
Base2 赋值运算符
Derived 赋值运算符

Base1 和 Base2 的操作都被正确调用,从而确保了整个 Derived 对象的拷贝和赋值是完整的

只有当派生类使用的是 合成版本的拷贝、移动或赋值成员时,才会自动 对其基类部分执行这些操作。在合成的拷贝控制成员中,每个基类 分别使用自己的对应成员 隐式地完成构造、赋值或销毁等工作

6、多重继承的派生类的拷贝 与 移动操作
多重继承的派生类 如果定义了 自己的 拷贝/赋值构造函数 和 赋值运算符,则必须在完整对象上 执行拷贝、移动或赋值操作
只有当派生类使用的是 合成版本的拷贝、移动或赋值成员时,才会自动对其基类部分 执行这些操作。在合成的拷贝控制成员中,每个基类 分别使用自己的 对应成员 隐式地完成构造、赋值或销毁等工作

假设 Panda 使用了合成版本的成员 ling_ling 初始化过程

Panda ying_yang("ying_yang");
Panda ling_ling = ying_yang; // 使用拷贝构造函数

将调用 Bear 的拷贝构造函数,后者 又在执行自己的拷贝任务之前 先调用 ZooAnimal 的拷贝构造函数。一旦 ling_ling 的 Bear 部分构造完成,接着就会调用 Endangered 的拷贝构造函数 来创建对象相应的部分。最后,执行 Panda 的拷贝构造函数。合成的移动构造函数的工作机理与之类似

合成的拷贝赋值运算符的行为与拷贝构造函数很相似。它首先赋值 Bear 部分(并且通过 Bear 赋值 ZooAnimal 部分),然后赋值 Endangered 部分,最后是 Panda 部分。移动赋值运算符的工作机理与之类似

18.22 已知存在如下所示的类的继承体系,其中每个类都定义了一个默认构造函数:

class A { ... };
class B : public A { ... };
class C : public B { ... };
class X { ... };
class Y { ... };
class Z : public X, public Y { ... };
class MI : public C, public Z { ... };

对于 MI mi; 来说,构造函数的执行顺序是:A -> B -> C -> X -> Y -> Z -> MI

3.2 类型转换与多个基类

在这里插入图片描述

1、在只有一个基类的情况下,派生类的指针或引用 能自动转换成 一个可访问基类的指针或引用。多个基类的情况与之类似。可以 令某个可访问基类的指针或引用 直接指向一个派生类对象。例如,一个 ZooAnimal、Bear 或 Endangered 类型的指针或引用 可以绑定到 Panda 对象上:

// 接受 Panda 的基类引用的一系列操作
void print(const Bear&);
void highlight(const Endangered&);
ostream& operator<<(ostream&, const ZooAnimal&);

Panda ying_yang("ying_yang");  
print(ying_yang);            // 把一个 Panda 对象传送给一个 Bear 的引用
highlight(ying_yang);        // 把一个 Panda 对象传送给一个 Endangered 的引用
cout << ying_yang << endl;   // 把一个 Panda 对象传送给一个 ZooAnimal 的引用

编译器不会在派生类向基类的几种转换中 进行比较和选择,因为在它看来 转换到任意一种基类都一样好。例如,如果 存在如下所示的 print 重载形式:

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

通过 Panda 对象 对不带前缀限定符的 print 函数 进行调用将产生编译错误:

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

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

Bear *pb = new Panda("ying_yang");
pb->print();               // 正确:Panda::print()
pb->cuddle();              // 错误:不属于Bear的接口
pb->highlight();           // 错误:不属于Bear的接口
delete pb;                 // 正确:Panda::~Panda()

通过 Endangered 的指针或引用 访问一个 Panda 对象时,Panda 接口中 Panda 特有的部分 以及属于 Bear 的部分都是不可见的:

Endangered *pe = new Panda("ying_yang");
pe->print();               // 正确:Panda::print()
pe->toes();               // 错误:不属于 Endangered 的接口
pe->cuddle();             // 错误:不属于 Endangered 的接口
pe->highlight();          // 正确:Panda::highlight()
delete pe;               // 正确:Panda::~Panda()

18.25 假设有两个基类 Base1 和 Base2,它们各自定义了一个名为 print 的虚成员 和 一个虚析构函数。从这两个基类中文名 派生出下面的类,它们都重新定义了 print 函数:

class D1 : public Base1 { /* ... */};
class D2 : public Base2 { /* ... */};
class MI : public D1, public D2 { /* ... */};

通过下面的指针,指出在每个调用中分别使用了哪个函数:

Base1 *pb1 = new MI;
Base2 *pb2 = new MI;
D1 *pd1 = new MI;
D2 *pd2 = new MI;
(a) pb1->print();
(b) pd1->print();
(c) pd2->print();
(d) delete pb2;
(e) delete pd1;
(f) delete pd2;

(a) pb1->print();

如果不是 虚函数,这个调用会使用 MI 对象中的 D1 的 print 函数,因为 pb1 是 Base1 类型的指针,而 MI 通过 D1 继承了 Base1。最终调用的是 D1 中的 print 函数,又因为 是虚函数,所以 会调用 MI::print();

(b) pd1->print();

如果不是 虚函数,pd1 是一个指向 MI 对象的 D1 类型指针,因此调用 print 时会使用 D1 中的 print 函数,又因为 是虚函数,所以 会调用 MI::print();

(c) pd2->print();

如果不是 虚函数,pd2 是一个指向 MI 对象的 D2 类型指针,因此调用 print 时会使用 D2 中的 print 函数,又因为 是虚函数,所以 会调用 MI::print();

(d) delete pb2;

pb2 是一个指向 Base2 的指针,但它实际上指向的是一个 MI 类型的对象的 D2 部分
当执行 delete pb2; 时,由于 Base2 的析构函数是虚函数,这个操作将首先调用 MI 类中的析构函数
在 MI 类的析构函数中,首先会调用 D2 类的析构函数,然后调用 D1 类的析构函数,最后会调用 Base1 和 Base2 的析构函数

(e) delete pd1;

pd1 指向 MI 对象中的 D1 部分。与 (d) 相同,析构函数是虚的,delete pd1; 会触发 MI 的析构函数,进而依次调用 D2、D1 和 MI 中的析构函数

(f) delete pd2;

pd2 指向 MI 对象中的 D2 部分。与 (d) 和 (e) 相同,析构函数是虚的,delete pd2; 会触发 MI 的析构函数,进而依次调用 D2、D1 和 MI 中的析构函数

3.3 多重继承下的类作用域

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

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

在例子中,如果 通过 Panda 的对象、指针或引用使用了一个名字,则程序会并行地在 Endangered 和 Bear/ZooAnimal 这两棵子树中查找该名字。如果这个名字 在超过一棵树中被找到,则该名字的使用具有二义性。对于一个派生类来说,从它的几个基类中 分别继承名字相同的成员是完全合法的,只不过在使用这个名字时 必须明确指出它的版本

例如,如果 ZooAnimal 和 Endangered 都定义了名为 max_weight 的成员,并且 Panda 没有定义该成员,则下面的调用是错误的

double d = ying_yang.max_weight();

Panda 在派生的过程中 拥有了两个名为 max_weight 的成员,这是完全合法的。派生 仅是产生了潜在的二义性,只要 Panda 对象 不调用 max_weight 函数就能避免二义性错误。另外,如果每次调用 max_weight 时 都指出所调用的版本(ZooAnimal::max_weight 或者 Endangered::max_weight),也不会发生二义性。只有当要调用哪个函数 含糊不清时程序才会出错

一种更复杂的情况是,有时 即使派生类继承的两个函数形参数不同 也可能发生错误。此外,即使 max_weight 在一个类中是私有的,而在另一个类中是公有的或受保护的 同样可能发生错误。最后一种情况,假如 max_weight 定义在 Bear 中而非 ZooAnimal 中,上面的程序仍然是错误的

1)派生类继承的两个函数形参数不同可能导致错误
当一个派生类从两个基类继承了同名的函数(即使形参不同),编译器可能会无法确定应该调用哪一个函数。这种情况称为“二义性问题”

class Base1 {
public:
    virtual void print(int x) { /* ... */ }
};

class Base2 {
public:
    virtual void print(double y) { /* ... */ }
};

class Derived : public Base1, public Base2 {
public:
    // Derived doesn't override print()
};

在 Derived 类的对象上调用 print(),编译器将不知道应该调用哪一个版本的 print,因为没有足够的信息来确定这种调用
解决方法:派生类可以明确地覆盖或重载该函数,或者在调用时明确指定基类

derivedObj.Base1::print(10); // 调用 Base1::print(int)
derivedObj.Base2::print(10.0); // 调用 Base2::print(double)

2)max_weight 在不同基类中有不同的访问级别
当派生类 从两个基类继承同名的成员变量,并且 这些变量在不同基类中 具有不同的访问权限时,也会导致问题。访问权限的不同 会使得这些成员变量的访问方式 不一致,可能导致 访问冲突或逻辑错误

class Base1 {
private:
    int max_weight;
};

class Base2 {
protected:
    int max_weight;
};

class Derived : public Base1, public Base2 {
public:
    void setWeight(int w) {
        max_weight = w; // 为什么会导致错误?
    }
};

当编译器在 Derived 类中解析 max_weight 时,它必须考虑到 所有可能的继承链。尽管 Base1 中的 max_weight 是私有的 且不可访问,但编译器仍然知道它的存在。这就造成了编译器的困惑:当在 Derived 中直接使用 max_weight 时,编译器无法唯一确定 是要访问 Base1 中的私有成员(尽管这是不允许的)还是 Base2 中的受保护成员

3)max_weight 定义在 Bear 中而非 ZooAnimal
如果其他代码 假定所有 ZooAnimal 都有 max_weight,这种变更可能导致不一致性和逻辑错误

要想避免潜在的二义性,最好的办法是 在派生类中为该函数定义一个新版本。例如,可以为 Panda 定义一个 max_weight 函数 从而解决二义性问题:

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

18.26 下面对print的调用为什么是错误的?适当修改MI,令其对print的调用可以编译通过并正确执行

struct Base1 {
	void print(int) const; // 默认情况下是公有的
protected:
	int ival;
	double dval;
	char cval;
private:
	int *id;
};

struct Base2 {
	void print(double) const; // 默认情况下是公有的
protected:
	double fval;
private:
	double dval;
};
struct Derived : public Base1 { 
	void print(std::string) const; // 默认情况下是公有的
protected:
	std::string sval;
	double dval;
};

struct MI : public Derived, public Base2 {
	void print(std::vector<double>) ; // 默认情况下是公有的
protected:
	int *ival;
	std::vector<double> dvec;
}
MI mi;
mi.print(42);

没有匹配的print调用(只要派生类有相同名字的(参数可以不同),就只找派生类,不在基类中寻找了)
当注释 void print(std::vector) 时又会出现二义性

可以通过在 MI 中明确指定调用 Base1::print(int),以消除任何可能的歧义

#include <iostream>
#include <vector>

struct Base1 {
    void print(int) const {
        std::cout << "Base1 Print Used" << std::endl;
    }
protected:
    int ival;
    double dval;
    char cval;
private:
    int* id;
};

struct Base2 {
    void print(double) const;
protected:
    double fval;
private:
    double dval;
};

struct Derived : public Base1 {
    void print(std::string) const;
protected:
    std::string sval;
    double dval;
};

struct MI : public Derived, public Base2 {
    void print(std::vector<double>) {}
    void print(int x) {
        Base1::print(x);  // 调用 Base1 的 print(int)
    }
protected:
    int* ival;
    std::vector<double> dvec;
};

int main() {
    MI mi;
    mi.print(42);  // 现在可以正常工作
    return 0;
}

在多重继承中,如果多个派生类从同一个基类继承,那么可用使用 虚继承 解决
通过虚继承,派生类不直接继承基类的副本,而是创建一个共享的虚基类,这样最终派生类只包含一个基类实例

#include <iostream>

struct Base {
    int value;
    Base() : value(42) {}
};

struct Derived1 : public Base {};

struct Derived2 : public Base {};

struct MI : public Derived1, public Derived2 {};

int main() {
    MI mi;
    // 访问 Base::value 会导致二义性,因为 MI 有两个 Base 子对象
    std::cout << mi.Derived1::value << std::endl;  // 访问 Derived1 继承的 Base
    std::cout << mi.Derived2::value << std::endl;  // 访问 Derived2 继承的 Base
    return 0;
}

为了避免这种问题,可以在 Derived1 和 Derived2 中使用虚继承,这样 Base 的子对象在 MI 中只有一个副本,而不是两份

#include <iostream>

struct Base {
    int value;
    Base() : value(42) {}
};

struct Derived1 : virtual public Base {};

struct Derived2 : virtual public Base {};

struct MI : public Derived1, public Derived2 {};

int main() {
    MI mi;
    // 现在可以直接访问 Base::value,因为只有一个 Base 子对象
    std::cout << mi.value << std::endl;  // 输出 42
    return 0;
}

虚函数的匹配
在多重继承中,如果基类中 有同名的虚函数,派生类 可以覆盖其中之一或全部。当通过派生类对象、基类指针或引用 调用虚函数时,会根据 指针或引用的实际类型 进行动态绑定(即调用实际对象所属类的覆盖版本)

struct Base1 {
    virtual void foo() { std::cout << "Base1::foo" << std::endl; }
};

struct Base2 {
    virtual void foo() { std::cout << "Base2::foo" << std::endl; }
};

struct Derived : public Base1, public Base2 {
    void foo() override { std::cout << "Derived::foo" << std::endl; }
};

int main() {
    Derived d;
    Base1* b1 = &d;
    Base2* b2 = &d;

	d.foo();  // 调用 Derived::foo()
    b1->foo();  // 调用 Derived::foo()
    b2->foo();  // 调用 Derived::foo()
}

Derived 覆盖这两个基类的虚函数,因此通过 d、b1 和 b2 调用时都将调用 Derived::foo()

覆盖与隐藏
在多重继承中,派生类 可以覆盖基类的虚函数,但不能 覆盖非虚函数。如果派生类 声明了 与基类函数同名但不同参数的函数,基类的同名函数会被隐藏,而不是被覆盖。这种隐藏 只在编译时起作用,并且基类的函数仍然可以 通过作用域解析运算符访问

#include <iostream>
#include <vector>

struct Base1 {
    void foo(int) { std::cout << "Base1::foo(int)" << std::endl; } // 非虚函数
};

struct Base2 {
    void foo(int) { std::cout << "Base2::foo(int)" << std::endl; } // 非虚函数
};

struct Derived : public Base1, public Base2 {
    void foo(int) { std::cout << "Derived::foo(int)" << std::endl; }
};

int main() {
    Derived d;
    d.foo(42);    // 调用 Derived::foo(int)
    d.Base1::foo(1);  // 调用 Base1::foo(),非虚函数
    d.Base2::foo(1);  // 调用 Base2::foo(),非虚函数
}

3.4 虚继承

1、尽管 在派生列表中 同一个基类只能出现一次,但事实上 派生类可以多次继承同一个类。派生类 可以通过它的两个直接基类 分别继承同一个间接基类,也可以 直接继承某个基类,然后通过 另一个基类 再次间接继承该类
(在多重继承中,如果同一个基类 被多次间接继承,可能会导致 数据成员和函数的多份拷贝,这就是所谓的“菱形继承”问题。为了解决这个问题,可以使用 虚继承)

IO 标准库的 istream 和 ostream 分别继承了 一个共同的名为 base_ios 的抽象基类。该抽象基类负责保存流的缓冲内容 并管理流的状态。iostream 是另外一个类,它从 istream 和 ostream 直接继承而来,可以同时读写流的内容。因为 istream 和 ostream 都继承自 base_ios,所以 iostream 继承了 base_ios 两次,一次是通过 istream,另一次是通过 ostream

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

这种默认的情况 对某些如 iostream 的类 显然是行不通的。一个 iostream 对象 肯定希望 在一个缓冲区中进行读写操作,也会要求 条件状态能同时反映 输入和输出的情况。假设在 iostream 对象中真的包含了 base_ios 的两份拷贝,则上述的共享行为就无法实现了

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

2、另一个Panda类
可以对 Panda 类进行修改,令其同时继承 Bear 和 Racoon。此时,为了避免赋予 Panda 两份 ZooAnimal 的子对象,将 Bear 和 Racoon 继承 ZooAnimal 的方式定义为 虚继承
在这里插入图片描述
必须在虚派生的真实需求出现前 就已经完成虚派生的操作。在实际的编程过程中,位于中间层次的基类 将其继承声明为虚继承 一般不会带来什么问题。通常情况下,使用虚继承的类层次是由一个人或一个项目组一次性设计完成的

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

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

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

virtual说明符表明 在后续的派生类中 共享虚基类的同一份实例,至于什么样的类 能够作为虚基类并没有特殊规定

如果某个类 指定了虚基类,则该类的派生仍按常规方式进行:

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

4、支持向基类的常规类型转换
不论基类 是不是虚基类,派生类对象 都能被 可访问基类的指针或引用操作

void dance(const Bear&);
void rummage(const Raccoon&);
ostream& operator<<(ostream&, const ZooAnimal&);

Panda ying_yang;
dance(ying_yang); // 正确:把一个Panda对象当成Bear传递
rummage(ying_yang); // 正确:把一个Panda对象当成Raccoon传递
cout << ying_yang; // 正确:把一个Panda对象当成ZooAnimal传递

5、虚基类成员的可见性
因为每个共享的虚基类中 只有一个共享的子对象,所以 该基类的成员可以直接访问,并且 不会产生二义性。此外,如果虚基类的成员 只被一条派生路径覆盖,则 仍然可以访问 这个被覆盖的成员。但是,如果成员被多于一个基类覆盖,则一般情况下 派生类必须为该成员自定义一个新的版本

情况1:成员只被一条派生路径覆盖
当虚基类的成员 只被一条派生路径覆盖时,派生类 仍然可以通过这条路径访问该成员

#include <iostream>

struct Base {
    int value;
    void show() const { std::cout << "Base::value = " << value << std::endl; }
};

struct Derived1 : virtual public Base {
    void setValue(int v) { value = v; }
};

struct Derived2 : virtual public Base {};

struct MI : public Derived1, public Derived2 {};

int main() {
    MI mi;
    mi.setValue(42); // 通过 Derived1 覆盖 Base::value
    mi.show();       // 通过 Base::show() 访问 Base::value
    return 0;
}

Base 中的 value 只通过 Derived1 覆盖。MI 类可以直接访问 Derived1 中的 setValue 函数,设置 Base 的 value,并通过 Base::show() 显示该值

情况2:成员被多条派生路径覆盖
如果虚基类的成员 通过多个派生路径被覆盖,访问该成员 就会出现二义性。这时,编译器无法确定 到底应该调用哪一个覆盖版本。因此,派生类 必须为 该成员自定义一个新的版本,以明确解决这个问题

#include <iostream>

struct Base {
    int value;
    virtual void show() const { std::cout << "Base::value = " << value << std::endl; }
};

struct Derived1 : virtual public Base {
    void show() const override { std::cout << "Derived1::value = " << value << std::endl; }
};

struct Derived2 : virtual public Base {
    void show() const override { std::cout << "Derived2::value = " << value << std::endl; }
};

struct MI : public Derived1, public Derived2 {
    void show() const override {  // 必须自定义新的 show() 以解决二义性
        std::cout << "MI::value = " << value << std::endl;
    }
};

int main() {
    MI mi;
    mi.value = 42;
    mi.show(); // 调用 MI 自定义的 show() 函数
    return 0;
}

Base 中的 show() 函数被 Derived1 和 Derived2 覆盖。如果 MI 没有自定义一个新的 show() 函数,那么编译器将无法确定调用哪个 show(),从而导致编译错误

例如,假定类 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 将产生二义性问题(成员被多于一个基类覆盖)

与非虚的多重继承体系 一样,解决这种二义性问题最好的方法是 在派生类中为成员自定义新的实例

18.28 已知存在如下的继承体系,在 VMI 类的内部哪些继承而来的成员 无须前缀限定符就能直接访问?哪些必须有限定符才能访问?

struct Base {
	void bar(int);
protected:
	int ival;
};
struct Derived1 : virtual public Base {
	void bar(char);
	void foo(char);
protected:
	char cval;
};
struct Derived2 : virtual public Base {
	void foo(int);
protected:
	int ival;
	char cval;
};
class VMI : public Derived1, public Derived2 { };

无需限定符的成员:
Derived1::bar(bar 不仅是 Base 的成员,也是 Derived1 的成员,派生类的 bar 把共享虚基类的 bar 隐藏了,用作用域符号还是可以访问 Base::bar,并不是覆盖);
Derived2::ival(派生类 Derived2 的 ival 把共享虚基类的 ival 隐藏了);

需要限定符的成员:
foo(Derived1和Derived2都存在该成员);
cval(Derived1和Derived2都存在该成员);
其他需要限定符的原因为会被覆盖

#include <iostream>

struct Base {
	void bar(int) {
		std::cout << "Base::bar" << std::endl;
	};
protected:
	int ival;
};
struct Derived1 : virtual public Base {
	void bar(char) {
		std::cout << "Derived1::bar" << std::endl;
	};
	void foo(char) {
		std::cout << "Derived1::foo" << std::endl;
	};
protected:
	char cval;
};
struct Derived2 : virtual public Base {
	void foo(int) {
		std::cout << "Derived2::foo" << std::endl;
	};
protected:
	int ival = 2;
	char cval;
};
class VMI : public Derived1, public Derived2 {
public:
	void accessIval() {
		Base::ival = 42;  // 访问 Base::ival
		std::cout << "Derived2::ival = " << ival << std::endl; //  访问 Derived2::ival
	}
};

int main()
{
	VMI v;
	v.bar('5');
	v.accessIval();
	// v.foo(); 报错不明确
	// v.cval; 报错不明确
	v.Derived1::foo('5');
	return 0;
}

在这里插入图片描述

3.5 构造函数与虚继承

1、在虚派生中,虚基类是 由最低层的派生类初始化的。当创建 Panda 对象时,由 Panda 的构造函数独自控制 ZooAnimal 的初始化过程

虚基类将会 在多条继承路径上被重复初始化。以 ZooAnimal 为例,如果应用普通规则,则 Raccoon 和 Bear 都会试图初始化 Panda 对象的 ZooAnimal 部分

只要 能创建虚基类的派生类对象,该派生类的构造函数 就必须初始化它的虚基类。例如,在继承体系中,当创建一个 Bear(或 Raccoon)的对象时,它已经位于派生的最低层,因此 Bear(或 Raccoon)的构造函数将直接初始化其 ZooAnimal 基类部分:

Bear::Bear(std::string name, bool onExhibit) :
    ZooAnimal(name, onExhibit, "Bear") {}
Raccoon::Raccoon(std::string name, bool onExhibit) :
    ZooAnimal(name, onExhibit, "Raccoon") {}

即使 ZooAnimal 不是 Panda 的直接基类,Panda 的构造函数也可以初始化 ZooAnimal:

Panda::Panda(string name, bool onExhibit)
	  : ZooAnimal(name, onExhibit, "Panda"), 
	    Bear(name, onExhibit), 
	    Raccoon(name, onExhibit), 
	    Endangered(Endangered::critical), 
	    sleeping_flag(false)  { }

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

当创建一个 Panda 对象时:

  • 首先使用 Panda 的构造函数初始值列表中 提供的初始值 构造虚基类 ZooAnimal 部分
  • 接下来构造 Bear 部分
  • 然后构造 Raccoon 部分
  • 然后构造第三个直接基类 Endangered
  • 最后构造 Panda 部分

如果 Panda 没有显式地初始化 ZooAnimal 基类,则 ZooAnimal 的默认构造函数将被调用。如果 ZooAnimal 没有默认构造函数,则代码将发生错误

#include <iostream>

class ZooAnimal {
public:
    ZooAnimal() {
        std::cout << "ZooAnimal constructed" << std::endl;
    }
    ZooAnimal(int age) {
        std::cout << "ZooAnimal constructed with age: " << age << std::endl;
    }
};

class Bear : virtual public ZooAnimal {
public:
    Bear() {
        std::cout << "Bear constructed" << std::endl;
    }
    Bear(int age) : ZooAnimal(age) {
        std::cout << "Bear constructed with age: " << age << std::endl;
    }
};

class Raccoon : virtual public ZooAnimal {
public:
    Raccoon() {
        std::cout << "Raccoon constructed" << std::endl;
    }
};

class Endangered {
public:
    Endangered() {
        std::cout << "Endangered constructed" << std::endl;
    }
};

class Panda : public Bear, public Raccoon, public Endangered {
public:
    Panda() : Bear(7), ZooAnimal(5){
        std::cout << "Panda constructed" << std::endl;
    }
};

int main() {
    Panda panda;
    return 0;
}

虚基类 ZooAnimal 的构造:
虚基类 ZooAnimal 是首先被构造的(跟子构造函数的顺序没关系)。因为 Bear 和 Raccoon 都虚继承自 ZooAnimal,所以 ZooAnimal 的构造函数只会被调用一次,并且使用 Panda 的构造函数提供的初始化列表中的值
在 Panda 的构造函数初始化列表中,ZooAnimal(5) 会先执行,构造 ZooAnimal,输出 ZooAnimal constructed with age: 5

按照派生列表顺序构造直接基类:
Bear 基类会被首先构造。因为 Bear 在 Panda 的初始化列表中指定了 Bear(7),因此 Bear 的构造函数输出 Bear constructed with age: 7
接下来构造 Raccoon,因为它是虚继承自 ZooAnimal,它不再构造 ZooAnimal,而是使用已经初始化的那个虚基类子对象。输出 Raccoon constructed
然后构造 Endangered,输出 Endangered constructed
最终构造 Panda 自身

最后,Panda 类本身的构造函数被调用,输出 Panda constructed

运行结果
在这里插入图片描述
3、构造函数 与 析构函数的次序
一个类 可以有多个虚基类。此时,这些虚的子对象(仅仅是虚子对象) 按照它们在派生列表中 出现的顺序 从左向右依次构造。例如,在下面这个稍显杂乱的 TeddyBear 派生关系中有两个虚基类:ToyAnimal 是直接虚基类,ZooAnimal 是 Bear 的虚基类:

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

编译器 按照直接基类的声明顺序 对其依次进行检查,以确定 其中是否含有虚基类。如果有,则先构造虚基类,然后 按照声明的顺序 逐个构造其他非虚基类。因此,要想创建一个 TeddyBear 对象,需要按照如下次序调用这些构造函数:

ZooAnimal(); // Bear 的虚基类(bear在前面)
ToyAnimal(); // 直接虚基类

Character(); // 第一个非虚基类的间接基类
BookCharacter(); // 第一个直接非虚基类

Bear(); // 第二个直接非虚基类

TeddyBear(); // 最底层的派生类

合成的拷贝和移动构造函数 按照完全相同的顺序执行,合成的赋值运算符中的成员 也按照该顺序赋值。和往常一样,对象的销毁顺序 与构造顺序正好相反,首先销毁 TeddyBear 部分,最后销毁 ZooAnimal 部分

18.29

class Class { ... };
class Base : public Class { ... };
class D1 : virtual public Base { ... };
class D2 : virtual public Base { ... };
class MI : public D1, public D2 { ... };
class Final : public MI, public Class { ... };

(a) 当作用于一个Final对象时,构造函数和析构函数的执行次序分别是什么?

(b) 在一个Final对象中有几个Base部分?几个Class部分?

(c) 下面的哪些赋值运算符将造成编译错误?
Base *pb; Class *pc; MI *pmi; D2 *pd2;
(a) pb = new Class;
(b) pc = new Final;
(c) pmi = pb;
(d) pd2 = pmi;

(a)构造函数执行次序Class、Base、D1、D2、MI、Class、Final,析构函数执行次数与上述相反;(先虚子对象,从左到右,从基到派生)

(b)一个Base两个Class;(虚继承和非虚继承区别)

(c)
在这里插入图片描述

术语表

构造函数顺序:在非虚继承中,基类的构造顺序 与其在派生列表中出现的顺序一致。在虚继承中,首先构造虚基类。虚基类的构造顺序 与其在派生类的派生列表中出现的顺序一致。具有最底层的派生类 才能初始化虚基类。虚基类的初始值 如果出现在中间基类中,则这些初始值将被忽略

  • 6
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值