前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【笔记】《C++Primer》—— 第18章:用于大型程序的工具

【笔记】《C++Primer》—— 第18章:用于大型程序的工具

作者头像
ZifengHuang
发布2020-07-29 16:31:29
9220
发布2020-07-29 16:31:29
举报
文章被收录于专栏:未竟东方白未竟东方白

这一章介绍了写大型程序中可能用到的工具,读起来也不困难,内容也不算多。其中18.2的命名空间的介绍解释了一开始书中建议我们不要随便using namespace std;的原因,18.3的多重继承让我们的继承体系更加灵活,都可以有效提高我们的开发效率。

18.1 异常处理

  • 在之前5.6的时候简单提到过异常处理,当时只大概介绍了如何使用C++的异常处理部分,这一节更深入地介绍了异常处理时的细节
  • 异常处理的流程是:在C++中我们throw了一个表达式后会rised一个异常,然后调用链中与类型匹配的最近的handler会处理这个异常,被抛出的异常中携带的信息会协助处理部分进行处理。
  • 要注意每次在try框内throw的时候,throw后面的剩余语句将不会再执行,程序的控制权会转移到成功catch的模块内,这个catch可能在同个函数中也可能是在外层调用链的嵌套中
  • 因此要注意出现异常的时候函数可能会提早退出,而且一旦开始异常处理,这段调用链中创建的局部对象会被销毁,因此throw有点类似于return, 因此我们最好将其放在某部分的最后一条语句中
  • 和return相同,我们也不该抛出指向局部对象的指针
  • 这里程序按照函数调用链逐个朝外寻找匹配的catch的过程称为栈展开,当查找到主函数还没有停止时会调用terminate终止程序,而如果找到了则在catch处理完异常后从这个最后的catch之后的地方继续程序的运行
  • 由于栈展开可能会提前退出一些块,此时编译器将负责保证其中的对象的销毁,此时这些对象的析构函数会自动调用
  • 但是异常可能在任何地方出现,即使在析构函数中也是一样,为了保证析构的正常我们需要自己保证析构时不应该抛出自己无法处理的异常到外层,而应该保证自己能完整析构所有成员,否则很容易就会进入terminate。标准库类型都保证自己的析构不会抛出异常
  • 异常自然也可能在构造函数出现,如果我们在构造函数体中初始化成员自然可以用try-catch处理,但是初始值列表在返回之外,为了处理初始值列表的异常我们需要用函数try语句块形式,也就是在构造函数的初始值列表冒号后面用try-catch将整个列表和函数体包住,这样就可以处理构造函数开始执行后发生的所有异常了。要注意是构造函数开始执行后的异常,如果是参数初始化过程中发生的异常则需要调用者自己在上下文中处理
  • 异常发生时抛出的异常对象是一种特殊的对象,可以是类对象也可以是函数或数组指针。编译器使用异常抛出表达式来对类异常对象进行拷贝初始化,因此异常对象必须是完全类型的,而且必须拥有相应的构造函数,函数和数组则必须可以转换为指针
  • 异常对象位于编译器管理的空间中,这保证了不管是链上的哪个catch都能正常处理异常
  • 异常对象的类型是由表达式的静态类型决定的,也就是我们抛出指向派生类的基类指针时,该派生类将被切去一部分
  • catch语句的括号内容是异常声明,类似函数的形参列表,用起来也很相近,和之前一样如果我们想要catch接受的异常与某个继承体系有关,最好将该catch的参数定义为引用
  • catch的匹配顺序是从上往下的,因此我们应该像逻辑表达式中的短路计算一样,将匹配范围最小的,也就是最特殊的匹配放在最上面,以免被范围更大的catch捕获异常忽略掉
  • catch只允许最基础的转换,包括常量改变,派生向基类,数组转指针,函数转指针四种,其他的类型转换都不支持
  • 有时候我们发现单个catch无法完全处理好异常时,我们用一个空的throw将异常重新抛出,这个throw只能出现在catch或catch调用的函数内,否则会terminate。重新将异常抛出要注意是将异常对象原样抛出,也就是如果我们没有用引用修改异常对象的话,我们在异常处理里对异常对象的修改就没法保留
  • 类似swicth的default语句,我们用catch(…)可以捕获所有类型的异常,但是此时由于没有异常对象的名字所以我们一般进行一些对现状的处理操作就重新抛出
  • 如果我们清楚某个函数不会产生异常或者不应该产生任何异常就应该将在函数后面指定noexcept即不会抛出异常,这样可以让编译器进行一些特殊的优化操作
  • 但是noexcept只是一个承诺,我们仍然可以在函数中抛出异常不会在编译时报错,但是一旦真的抛出异常会调用terminate终止程序
  • noexcept说明符有一个bool类型的实参,true则不会抛出异常,false则可能抛出异常,这个标记是和同名的运算符noexcept(e)混合使用而设计的,这个运算符类似sizeof可以返回给定的表达式是否会抛出异常,只有当检测对象e调用的所有对象都noexcept=true且不包含任何throw语句时才会返回true
  • noexcept说明符所关联的函数指针都必须有一样的说明,如果一个虚函数承诺了它不跑出异常,则后续派生的所有对象也不能抛,反之如果虚函数可以抛,派生倒是可以承诺不抛出
  • 标准库准备了一系列标准exception类,包含了基础的操作函数和what虚成员,what可以返回const char*说明异常信息,这个信息在对应exception的构造函数中输入。我们一般应用时是通过继承标准exception来构造自己的异常库进行各种处理的

18.2 命名空间

  • 命名空间要解决的问题是大型程序中名字相互冲突的问题,通过让不同程序的名称放在不同的命名空间中,然后通过命名空间来特指所需要的名称来减少名称冲突
  • 每个命名空间都是一个作用域,一个命名空间由关键字namespace和命名空间的名字开始,然后用一个花括号括住需要需要放置的名字,和类不同命名空间的花括号外不需要分号结尾
  • 所有能出现在全局作用域的声明都能出现在命名空间中,联想标准库命名空间std即可
  • 命名空间的特点是命名空间可以嵌套定义,使用方法和嵌套类差不多,但是注意命名空间不能放在函数或类的内部
  • 命名空间可以分布式定义,也就是可以被定义在不同的文件中,但是此时要注意命名空间有声明顺序的问题,只能使用已经被编译器获得到的名称
  • 学习标准库,类型不同的类放在各自的文件中,只要都放在同个命名空间中就好
  • 要注意通常情况下我们不把#include放在命名空间内部,因为这代表我们要把头文件的所有名称都放入这个命名空间
  • 当我们在命名空间中使用成员时可以直接使用名字,不用特指,和模板内部调用成员类似
  • 我们也可以在命名空间的外部定义命名空间内的成员,但是注意只能是在外层,不能在不相干的同级作用域中定义
  • 全局作用域实际上是一个无名命名空间,我们用::XXX来特指
  • C11引入了内联命名空间,特点是内联空间的名字可以被外侧直接使用无需特指,方法是在定义命名空间前面加上inline类似内联成员的定义法,inline必须出现在命名空间第一次定义的地方,后续则可写可不写
  • 内联命名空间一般用在例如双版本代码共存的时候,将旧版本的代码放在命名空间中,新版本则内联,这样容易切换所需的版本
  • 如果namespace后面不加名字直接定义命名空间的话,此时称为未命名命名空间,在这里面定义的变量有静态的生命周期,在第一次使用时创建,然后直到程序结束才销毁
  • 未命名的命名空间可以在某个文件内不连续,但是不能横跨多个文件,这其实是取代当时C语言中声明static全局实体的替代,为了定义一些只在当前文件中生效的全局变量。未命名的命名空间中的名字作用域其所在的空间相同,如果定义在全局区域则相当于全局作用域,定义在别的空间中则相当于其他的命名空间
  • 除了直接特指命名空间中的名称来进行调用外,我们也可以用using XXX= <namespace>来声明一个命名空间或者空间里的一个成员的别名
  • using声明的特点是一次只引入一个成员,生命周期从声明开始到声明所处的作用域结束为止
  • 然后有更强大我们也很常用的using指示,直接using一个命名空间,如我们平时using std一样,效果是将这个命名空间里的所有名字都提到using语句所在的层级中,如果有些不能存在于局部作用域中的名称还会继续往外层升级,这样我们就可以直接访问它们。我们平时的写法就是把整个std的名称都引入了全局作用域中
  • 因此我们不应该滥用using指示,这很容易导致我们一开始想要避免的名称冲突问题重新出现
  • 如果我们在头文件的顶层作用域中使用了using指示或using声明,那么会将这个名称注入到所有包含了这个头文件的文件中,这也会有很大的风险,所以我们最好只在头文件的局部作用域中用using
  • 命名空间中的名称查找有和之前的名称查找相似的特性,只向上查找声明了的名字
  • 当我们用限定符特指名称时,要注意限定名是从大到小写的
  • 在函数查找名称时,有一个重要的例外就是函数除了进行常规查找,还会查找其实参所在的命名空间的内容,因此我们可以直接使用某些类的重载运算符来处理那个类而不用给运算符加限定
  • move 和 forward 函数需要额外注意,最好特指着使用它们因为它们可以匹配任何形参加上名字特殊容易覆盖我们自己的目标函数
  • 命名空间的实参查找例外对友元函数编写很重要
  • 要记得using声明的是一个名称,是不能有参数的,所以using会给函数重载带来很多麻烦。一个using声明引入的函数将重载所属作用域中的其他同名函数,如果这个函数恰好遇到了形参相同的同名函数则会容易出现二义性,需要特指来避免,形参不相同的也会进入重载列表中
  • 如果存在多个using指示,那么多个命名空间的名字都会进入候选函数集,这都可能引起混乱

18.3 多重继承与虚继承

  • C++支持多重继承(很多其他语言不支持这一特性),方法就是继承列表多些几个基类,因此我们可以从多个直接基类产生派生类,这个派生类会继承所有父类的属性
  • 多重继承通常概念上就是某个类有多个平级或者难以定级的属性,例如熊猫继承自动物园类,熊类,濒危类
  • 多重继承也只能继承已经定义过的类,不能是final的,而且一个基类在列表中只能出现一次
  • 构造多重继承的对象和构造单继承的对象类似,自己决定好参数要传递到哪里。要注意的是基类的构造顺序是与派生列表中基类的出现顺序一致,与派生类参数顺序无关
  • C11中允许派生类从多个基类中继承构造函数,但是如果多个构造函数都相同的话将产生错误,此时派生类应该自己定义一个构造函数来覆盖它们
  • 析构函数的调用顺序与构造顺序相反的特性仍在
  • 合成拷贝移动等操作的规则也与之前一致
  • 我们可以用基类指针指向派生类对象,但是调用对应函数的时候编译器不会觉得不同方向的转换有好坏之分,因此当有多个接受不同基类参数但名字相同的函数时,如果我们直接调用函数传递派生类对象进去,会产生二义性错误,需要用特质来解决
  • 和单继承时一样,静态类型决定了我们能调用那些成员
  • 在多继承的时候,名称查找会在所有直接基类中同时进行,单个继承链上才有顺序,此时如果名字在多个基类中被同时找到,则名字会有二义性,因此最好我们调用基类函数时也要特指
  • 和之前一样,先找名字再类型检查,因此名字相同就已经会发生二义性错误了
  • 尽管派生类中直接基类只能出现一次,但显然我们可以间接继承多次相同的基类(例如动物园类和熊类可以都继承自动物类),此时派生类相当于出现了多个重复的成员,很多时候这会对我们的操作产生影响。C++通过虚继承来解决这个问题,我们可以将某个类在继承的时候声明为虚基类,方法是在继承的派生列表中对应项前面加上virtual,这样处理后无论这个目标类被间接继承多少次,这个基类成员都只会出现一次,此时的派生称为虚派生
  • 这种操作显然产生了一个矛盾:类的继承常常是后期决定的,而如果不是在间接继承的时候就声明virtual,如果漏声明了哪一次,则仍然可能产生重复的基类成员,这给后期扩充类增加了麻烦。这个矛盾难以解决,只能通过调整任意安排让一个人负责一个类的编写并安排好类的继承层次来避免
  • 在每个共享的虚基类中只会有一个共享的子对象,所以我们可以直接访问这个对象不会产生二义性。但是如果两个直接基类都继承了虚基类的同一个对象,任何派生类再继承这两个基类,那么调用这个对象时会产生歧义因为这个对象被两个类重新继承了,最好的解决方法仍然是在派生类中再继承一次对象指明好对象的来源
  • 在虚派生中,虚基类是从最底层的派生类开始初始化的,也就是由最先一个虚派生汇合处的类开始初始化,此时即使这个类不是虚基类的直接派生也可以初始化虚基类的实例
  • 而且编译器是先按顺序初始化所有的虚基类,然后再按顺序初始化非虚基类,初始化的时候按照从底往上,同级的时候从列表左往右的顺序初始化,可以用下面例子加深理解
本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-06-06,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 未竟东方白 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档