前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【笔记】《C++Primer》—— 第19章:特殊工具与技术

【笔记】《C++Primer》—— 第19章:特殊工具与技术

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

这一章介绍了平时可能不太会用到的C++特性,内容比较杂。其中有类似枚举,联合,局部类这样之前就用过的特性,也有类成员指针,局部类这样新了解的特性。其中个人觉得19.1对new和delete的讨论很重要,19.2的RTTI介绍也扩展了我们编码的自由度,最后19.8的位域让我们可以更方便地进行位运算。

终于到了最后一章,七百余页的漫长阅读和几万字的笔记都即将迎来结束,回顾一下真挺感慨的,我应该早些看这本书才对,真的教了我很多很多。下一篇是第四部分的总结,标准库部分太杂了就暂时不总结了,然后可能还有篇全书"小结"部分的总结,就到头了。

19.1 控制内存分配

  • 我平时有时候会说“重载new和delete”,但实际上重载这两个运算符和重载其他的运算符大不相同,准确来说实际上我们并不能重载new和delete,这需要了解new和delete的原理
  • 当我们调用new时,实际上我们先调用了一个称为operator new的标准库函数分配了一块足够大的未命名的内存,然后将目标元素构造在这块内存中,完成后返回这块内存的头指针
  • delete也是类似的过程,不过和new相反,实际上会先调用析构函数将指针区域内的对象析构,然后调用一个称为operator delete的标准库函数释放内存空间
  • 以上我们就可以理解了,new和delete实际上是一系列写好的操作,我们真正能重载的是构造函数析构函数和两个operator函数,通常我们说的重载new和delete就是指重载两个operator函数
  • 在运行的时候,编译器会查找可调用的operator,和通常的查找一样,但是如果到最后都没有找到自定义的版本的话就会使用标准库的版本。同样的我们可以使用全局访问符来忽略掉我们自己定义在类内的函数
  • 和我们习惯的一样直接使用new和delete的时候是不需要头文件的,但是如果我们需要重载operator new/delete的话我们需要包含头文件new,包含后我们可以重载下面八个函数,其中nothrow_t是new文件中的一个const struct对象,只是一个标记
  • 当我们重载这些函数时,我们需要定义在全局作用域或类作用域中,当我们将这些函数定义为类成员时,它们将是隐式静态的,我们无需声明static。因为它们被运用在构造前和析构后,所以不该也不能操作任何类的成员
  • 当我们调用new的时候,size_t参数是要分配的对象的字节数,当我们调用new[]时,参数则是数组所有元素的字节和
  • 注意new和delete,new[]和delete[]不要混用,否则容易造成段错误,因为这两个操作符的应用过程有些不同,new[]会将元素数量存到内存区域的头四个字节中,delete[]会读取那四个字节才来进行正确的析构
  • 当我们要自定义新的new函数时,可以定义新的参数但调用时必须使用new的定位形式。所谓定位new即在调用new时用括号在类型和new之间加入一个参数,这是传递给new的额外参数
  • 要注意自定义new时,void *operator new(size_t, void*);函数不能被重新定义,这是标准库专用的
  • 通常来说我们自定义这两个operator时都会用到cstdlib中的C函数malloc和free,malloc接受字节数参数返回分配完的头指针,free将指针所指的内存返回
  • 当没有使用定位new时,默认会调用void *operator new(size_t, void*);,如果我们使用定位new,默认情况下根据参数的不同能调用下面的四个函数,用起来将会和allocator的construct类似,可以在指定的内存地址分配想要的对象
  • 定位new和allocator的construct最大区别是定位new可以接受任意的指针内存,甚至不需要是动态内存
  • 这两个operator一样可以被当作普通函数调用

19.2 运行时类型识别

  • 运行时类型识别(RTTI)是一种比较危险的操作,在我们想使用基类对象的指针或引用来执行某个派生类的非虚函数时使用,相关的运算符是typeid和dynamic_cast,如果可以的话最好还是应该用虚函数而不是直接改变类型
  • typeid可以返回表达式的类型,dynamic_cast将基类的指针或引用强制转为派生类的指针或引用
  • dynamic_cast有模板参数,是目标要转换的类型,通常情况下应该有虚函数,是指针,左值引用或右值引用,运算符参数是需要被转换的目标。当要转到指针时,目标必须是指针;要转到左值引用则必须是左值,要转到右值引用则必须不是左值
  • 对目标是指针的类型转换失败是会返回0,当目标是引用失败时抛出bad_cast异常,这个异常在typeinfo头文件里
  • typeid(e)会返回一个常量对象type_info的引用,这个type_info在typeinfo头文件中,我们可以在这个对象中读取到目标e的类型。typeid不会自动进行指针的标准类型转换,也就是当e是数组时返回的是数组类型而不是指针类型
  • 当目标e没有虚函数时,typeid返回的是目标的静态类型,当e是定义了至少一个虚函数的类的左值时,结果会到运行时才求得。由于这一点我们想要得到类的类型时,记得将指针转为对象,否则返回结果会是指针的静态类型
  • typeid也会决定表达式是否会被求值,只有类型含有虚函数时才会对表达式进行求值
  • 如果e是一个空指针,那么typeid会抛出一个bad_typeid异常
  • typeid返回的type_info类是与编译器相关的,我们可以调用其成员函数name(),返回一个C风格的字符串表示类型的名字,但是这个名字很可能与我们编写的名字会不同,编译器只能保证不同类型的名字是不同的
  • RTTI的关键用处在于当我们想为具有继承关系的类实现相等运算符时,如果我们想到用虚函数让派生类和基类返回比较的结果,但是我们的equal为了保证接受不同的类型必须接受基类引用,此时我们又不能使用派生类的独有成员了,也就无法进行比较了。
  • 解决继承关系类的相等运算符问题,可以看到关键是我们不能确定两个对象的类型从而无法对比它们的成员。我们应该使用RTTI,首先用typeid去对比两个对象的类型,类型不同便必定不相等,可以返回;如果相同,我们仍然使用虚函数来比较成员,但是这次用基类引用读取对象后,由于我们相当于已经知道对象的真正类型了,所以可以使用dynamic_cast将不同的类型强制转换为相同的类型而不用担心出错,转换为相同的类型后剩下的部分就都是正常的equal比较了。以下是RTTI简单的运用代码:
代码语言:javascript
复制
class Base {
  virtual bool Base::equal(const Base& r) const {
    // 比较基类成员
  }

  friend bool operator==(const Base& l, const Base& r) {
    // 先判断是否是同类型,同类型下再进行比较
    return (typeid(l) == typeid(r)) && l.equal(r);
  }
};

class Derived :public Base {
  bool Derived::equal(const Base& r) const {
    // 由于已经判断为同类型,所以可以大胆进行类型转换
    auto rr = dynamic_cast<const Derived&>(r);
    // 转换后就可以比较派生类成员了
  }
};

19.3 枚举类型

  • 枚举类型很常见了,国内的C++或者C教材一般在比较前面就会讲到这个东西,这本书将其安排到了后面。这个类型主要是让我们可以将一组整型常量组织在一起,像一个简单的文件夹一样
  • C原本只有一种枚举类型:不限定作用域的枚举。C11加入了限定作用域的枚举。
  • 不限定作用域的枚举和我们知道的一样,enum TypeName{mem1, mem2, mem3}; 这里的obj必须是整型,可以在花括号中直接用整型初始化成员。不限定作用域的成员由于作用域与类型名相同,所以可以被直接用名字访问无需指定,但是这样也引来了重复定义名称的问题
  • 不限定作用域的枚举类的名称是可选,如果这个enum未命名,则必须在定义该enum时就定义它们的成员
  • 限定作用域的枚举类在定义时需要加class/struct,即如enum class TypeName{mem1, mem2, mem3}; 限定作用域的枚举类的成员由于作用域在枚举类的内部,所以必须通过访问符才能得到,避免了名称冲突的问题
  • 默认情况下枚举值从0开始,逐个加1,值不一定唯一
  • 枚举成员是const的,因此我们初始化时必须用const,使用的时候也可以当作const使用
  • 枚举类的成员初始化后就必须使用同枚举类的成员才能赋值了,但是使用枚举类成员赋值给其他元素时,成员会自动转换为整型
  • 限定作用域的枚举成员默认类型是int,不限定作用域的枚举成员则没有默认类型,我们只能知道其类型足够容纳其初始值。C11让我们可以给枚举类型附加类型声明 enum TypeName: memberType {mem1, mem2, mem3}; 指定枚举类型的类型
  • 一旦某个枚举成员的值大于其类型,则会引发错误
  • C11中我们可以先声明一个枚举类,然后后面再定义它,但是类似数组的声明,我们需要保证声明时整个枚举类的空间是可确定的,也就是我们必须指定限定作用域的枚举类的成员类型

19.4 类成员指针

  • 成员指针给了我们一种指向类的非静态成员的方法,因为一般来说我们指针只能指向某个对象的成员而非一个抽象上的类的成员,静态成员除外因为静态成员不属于任何一个对象
  • 成员指针让我们可以指向非静态成员,但是当我们需要使用成员指针时我们还是需要指定它真正所属的对象
  • 成员指针的特点是我们声明的时候需要给目标加上访问符::,例如 string Screen::*p = &Screen::data; 这句话我们定义了一个类型是string的Screen类成员指针,指针指向Screen类的data成员。整个写法可能有些繁琐,C11支持用auto或decltype直接解决 auto p = &Screen::data;
  • 成员指针的好处是我们可以将类的成员作为参数或返回值了,但是当我们要访问成员指针时,需要用.*或->*来在具体对象上访问成员。直观的理解就是当我们用解引用符*对成员指针解引用时,我们得到的是对应类的成员类型,我们还需要对应某个具体的对象用点或箭头获取对象中的这个成员类型的真正的值
  • 成员指针可以指向数据成员,自然也可以指向成员函数,最简单的方法依然是用auto,如果要显式声明的话,例如 string (Screen::*fun)(int arg) = &Screen::testFunction; 这里需要注意前面函数名处的括号不可少,否则编译器会认为此函数声明无效,后面的取地址符也不可少,这是因为成员函数到函数指针间没有自动转换规则
  • 我们调用成员函数的方法和使用成员类差不多,区别是标志着函数名的括号仍然不可少,这是因为调用运算符的优先级太高了,会和指向成员运算符相混。string result = (screenObj.*fun)(10);
  • 我们常用类型别名来简化成员指针的运用
  • 成员指针的一大用处是存放为函数表,因为此时我们可以批量调用函数了,一般是将多个函数存放在成员指针数组中
  • 当我们想要将成员函数作为可调用对象从而可以传递给其他的函数时,由于成员指针不是可调用对象,因此最常用的方法仍然是使用标准库模板function。例如 funtion<string int> fun= &Screen::testFunction; 此处string是返回类型,int是参数类型,得到的fun就是可调用对象了
  • function实际上做的事情类似于在函数调用此指针时自动进行了形式转换,将调用运算符转为指针调用
  • C11多了一种简单一些的方法是使用标准库功能mem_fn来让编译器自己去推断成员的类型,也就是在需要传入可调用对象的地方改写为mem_fn(&Screen::testFunction)即可
  • 还有一种方法就是老方法bind函数,即bind(&Screen::testFunction, _1)

19.5 嵌套类

  • 一个类可以定义在另一个类的内部,这点我们平时可能偶尔已经自己试出来了,也很好猜到其意义就是进一步的封装,其概念都比较朴素
  • 嵌套类的特点是其名字在外层类之外就不可见了,需要用作用域符来访问
  • 嵌套类和外层类之间没有权限特权,完全可以当作一个独立的类使用
  • 嵌套类必须在类内声明,但是依然可以在类外定义,就是需要更详细的名称指定而已
  • 嵌套类的名字查找和普通的查找一致
  • 在嵌套类的外层类完成真正的定义之前,嵌套类都不算是一个完全类型
19.6 union:一种节省空间的类
  • union(联合)也是国内C语言教材比较早就会提高的内容,可能是和C语言那对内存极度关心的特性有关。
  • union和struct可对照着看,union的特性是任意时刻只允许其中一个成员有值,然后同样可以和struct一样有丰富的成员,当作一个简单的类来使用,默认的访问控制符是public
  • union不能继承其他类,自然也就不能有虚函数,但是其他的包括构造和析构函数都可以有
  • union只允许一个成员有值的特性让其存储空间仅仅是可以容纳其最大成员的大小
  • union常常用来表示一组互斥值,我们可以用花括号内的一个值来初始化一个union,默认情况下这个值会被用来初始化union的第一个值
  • 对union的一个值进行赋值会让其他的成员变为未定义的状态,因此我们需要时刻记得union此时有效的值是什么,一般来说我们通过在外层再包装一个类,类中用一个enum标记此时的类型来控制,这个标记被称为判别式
  • union也可以匿名定义,此时在其所在的作用域中union的成员都可以被直接访问,匿名union不可以有保护和私有成员,也不可以有函数
  • union的匿名定义一般就是用在包装类中的
  • C11允许了union有含有构造和拷贝函数的类成员,但是当我们对这样的成员进行更改时就需要自己允许其构造或析构函数了,这种状态控制一般也是通过外面包装的类的接口函数和判别式来控制的,让外部访问不同成员时按照情况析构union目前的成员,构造新的成员

19.7 局部类

  • 类似于嵌套类,局部类是定义在函数内部的类,局部类的定义只在定义它的作用域中可见
  • 局部类的特点是其所有成员都必须在类内完成定义,因此我们一般不会定义很大型的局部类
  • 局部类不能使用其所在的函数中的局部变量,只能访问这个函数外层的类型名,静态变量,枚举成员
  • 同样局部类和函数之间没有权限特权,但是局部类一般被声明为public的,因为在这么小的作用域中封装只会显得碍手碍脚
  • 局部类内还可以嵌套类,但是嵌套类可以定义在局部类外部,但是必须定义在与局部类相同的作用域内
  • 局部类内的嵌套类本质也属于一个局部类,所以嵌套类自身的成员必须定义在嵌套类内部

19.8 固有的不可移植的特性

  • 不可移植的特性是指那些机器相关的特性,当我们把含有这种特性的程序转移到另一台机器上时,一般需要修改程序来适配。典型的不可移植特性是2.1中说到的算术类型在不同机器上的差异
  • 类可以将其数据成员定义为位域(bit-field),一个位域中含有一定数量的二进制位数据,定义方式是Bit name: bitCount; bitCount就是其包含的二进制位数
  • 连续定义的位域会被编译器压缩在一个整数的相邻位中,但是如何压缩是机器相关的
  • 位域通常是使用位运算符来操作的
  • 程序中可能有一些变量并不由程序自身控制,例如与时钟相关的变量,此时我们希望编译器不要随便对这样的变量进行优化,我们可以通过volatile限定符来声明这个变量是不需要进行优化的
  • volatile的用法和const很相似,只起到修饰作用,与const并不冲突
  • 只有volatile的成员函数才可以被volatile对象调用,指针,变量等也是,需要互相对应
  • volatile的一大特点是我们不能用合成的拷贝/移动构造函数和赋值运算符来初始化volatile对象或从volatile对象赋值,我们必须自定义这些操作
  • 使用volatile前要问自己使用这个特性是不是真的有意义,是不是真的需要
  • C++有时候需要调用其他语言编写的函数,对于这样的函数编译器尽管检查调用的方法和C++函数一样,但是生成的代码有所差别,C++使用链接指示来指出那些非C++的函数
  • 使用其他语言函数前要确保有权使用其他语言的编译器且与当前的C++编译器是互相兼容的
  • 链接指示也就是在函数的声明前写 extern "Lang",其中Lang是目标语言的代号,例如C语言是C,当需要指示多个函数时可以用大括号把函数都括在一起,这称为多重声明
  • 多重声明可以包括头文件,而且链接指示可以嵌套,也就是此时头文件中的所有函数都会被当作另一种语言写的,而且头文件中的链接指示不受影响
  • 如果声明了有链接指示的函数指针的话,它所指向的函数都需要有相同的链接指示
  • 链接指示对整个声明都有效,也就是链接指示函数中的参数如果是函数指针的话也需要是有链接指示的函数指针
  • 链接指示的函数可能不支持一些特性,例如C函数无法重载和传递对象
  • 我们也可以对一个有C++定义的函数标记链接指示,这样会使得这个函数可以被目标语言调用
本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-06-10,如有侵权请联系 cloudcommunity@tencent.com 删除

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

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

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

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