C++ Primer 0x0F 学习笔记

Posted 鱼竿钓鱼干

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++ Primer 0x0F 学习笔记相关的知识,希望对你有一定的参考价值。

📔 C++ Primer 0x0F 学习笔记

更好的阅读体验(实时更新与修正)

推荐阅读 《C++ Primer 5th》知识点总结&练习题解

15.1 OOP:概述

  • 面向对象程序设计的核心思想是数据抽象、继承和动态绑定
    • 使用数据抽象,我们可以将类的接口与实现分离
    • 使用继承,可以定义相似的类型并对其相似关系建模
    • 使用动态绑定,可以一定程度上忽略相似类型的区别,统一的方式使用他们的对象

继承

  • 通过继承联系在一起的类构成一种层次关系,根部的为基类,其他类别直接或间接继承基类称为派生类

  • 基类负责负责定义层次关系中所有类共同拥有的成员,而每个派生类定义各自特有的成员

  • 对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明为虚函数

动态绑定

  • 通过使用动态绑定,我们能用同一段代码分别处理派生类和基类
  • 当我们使用基类的引用(或指针)调用一个虚函数时,将发生动态绑定(也叫运行时绑定)

15.2 定义基类和派生类

15.2.1 定义基类

  • 基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此

成员函数与继承

  • 派生类可以继承基类的成员,但是也需要对一些操作提供自己的新定义以覆盖(override)基类继承而来的定义
  • 虚函数用来区分基类里希望派生类进行覆盖的函数和不希望派生类覆盖的函数
  • 当我们用指针或引用调用虚函数时,该调用被动态绑定。根据所绑定对象类型不同,可能执行基类的版本,也可能执行某个派生类的版本
  • 任何构造函数之外的非静态函数都可以是虚函数
  • 关键字virtual只能出现在类内部的声明语句之前而不能用于类外部函数的定义
  • 如果基类把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数
  • 成员函数如果没被声明为虚函数,则其解析过程发生在编译时而非运行时

访问控制与继承

  • 派生类可以继承定义在基类的成员,但派生类的成员函数不一定有权访问从基类继承而来的成员。
  • 派生类可以访问publicprotected,不能访问private
  • protected允许派生类、基类、友元访问,禁止其他用户访问
  • private只允许基类本身和友元访问

15.2.2 定义派生类

  • 派生类必须通过使用类派生列表明确指出它从哪个(哪些)基类继承而来(: 访问说明符 基类)
  • 派生类必须将继承来的成员函数中需要覆盖的那些重新声明
  • 派生列表里访问说明符的作用是控制派生类从基类继承而来的成员是否对派生类的用户可见
  • 如果一个派生是公有的,则基类的公有成员也是派生类接口的组成部分
  • 大多数类都只继承自一个类(单继承)

派生类中的虚函数

  • 派生类经常(但不总是)覆盖它继承的虚函数,如果没覆盖那就直接继承在其基类中的版本
  • 派生类必须在其内部对所有重新定义的虚函数进行声明,可以在这样的函数之前加上virtual关键字(但不非得这么做)
  • C++11新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,具体措施是在该函数的形参列表后增加一个ovrride关键字

派生类对象及派生类向基类的类型转换

  • 一个派生类对象包含多个组成部分:派生类自己定义的非静态成员的子对象,基类对应的子对象
  • 编译器会隐式地执行派生类到基类的转换,这意味着我们可以把派生类对象或者派生类对象的引用(指针) 用在需要基类引用(指针)的地方

派生类构造函数

  • 派生类并不能直接初始化基类继承来的成员,必须使用基类的构造函数来初始话它的基类部分
  • 每个类控制自己的尘缘初始化过程
  • 如果没有特别指出,派生类对象的基类部分会像数据成员一样默认执行初始化
  • 首先初始化基类部分,然后按照声明顺序一次初始化派生类成员

派生类使用基类成员

  • 派生类可以访问基类的公有成员和受保护成员
  • 派生类的作用于嵌套在基类的作用域之内,因此对于一个派生类成员来说,使用派生类的成员和使用基类成员的方式没什么不同
  • 必须明确一点:每个类负责定义各自的接口。要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也一样

继承于静态成员

  • 如果基类定义了一个静态成员,那么整个继承体系只存在该成员的唯一定义
  • 静态成员遵循通用的访问控制

派生类的声明

  • 派生类的声明和其他类别差别不大,不包含它的派生列表

被用作基类的类

  • 如果我们想将某个类用作基类,则该类必须已经定义而非仅仅声明
  • 直接基类出现在派生列表中,间接基类通过直接基类继承过来
  • 最终派生类将包含它的直接基类的子对象以及每个间接基类的子对象

防止继承的发生

  • final关键字可以防止继承的发生

15.2.3 类型转换与继承

  • 可将基类的指针或引用绑定到派生对象上有一层极为重要的含义:当使用基类的引用(或指针)时,实际上我们也不清楚该引用(或指针)所绑定对象的真实类型,可能是基类的对象,也可能是派生类的对象
  • 和内置指针一样,只能指针类也支持派生类向基类的类型转换,这意味着我们可以将一个派生类的指针存储在一个基类的智能指针内

静态类型与动态类型

  • 当我们使用存在继承关系的类型时,必须区分一个变量或其他表达式的静态类型和该表达式表达对象的动态类型
  • 表达式的静态类型是在编译时已知的,它是变量声明时的类型或表达式生成的类型
  • 动态类型是变量或表达式表示的内存中的对象的类型,直到运行时才可知
  • 基类的指针或引用的静态类型与动态类型可能不一致

不存在从基类向派生类的隐式转换

  • 编译器无法在编译时确定某个特点的转换在运行时是否安全

  • 如果基类含有一个或多个虚函数,可以用dynamic_cast请求类型转换,该转换的安全检查在运行执行

  • 如果我们已知基类向派生类的转换是安全的,可以使用static_cast来强制覆盖编译器的检查工作

派生类和基类在对象之间不存在类型转换

  • 派生类向基类的自动类型转换只对指针或引用类型有效,在派生类类型和基类类型之间不存在这样的转换
  • 当我们使用派生类对象为一个基类对象初始化或赋值时,实际上是调用了构造或赋值函数(参数为const 版本引用)。只有派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分将会被忽略掉

存在继承关系的类型之间的转换规则

  • 从派生类向基类的转换只对指针或引用类型有效
  • 基类向派生类不存在隐式类型转换
  • 和其他成员一样,派生类向基类的转换可能由于访问受限变得不可行
  • 我们通常能够将一个派生类对象拷贝、移动、赋值给一个基类对象。不过需要注意的是,这种操作之处理派生类对象的基类部分

15.3 虚函数

  • 因为知道运行时才知道到底调用了哪个版本的虚函数,所以所有虚函数都必须有定义

对虚函数的调用可能在运行时才被解析

  • 当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型才会和静态类型不同
  • OPP的核心思想是多态性,引用或指针的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本所在

派生类中的虚函数

  • 当我们在派生类中覆盖了某个虚函数时,可以再次使用virtual关键字指出该函数的性质,不过不是必须。因为一旦某个函数被声明成虚函数,则在所有派生类中它都是虚函数
  • 一个派生类如果覆盖了某个继承来的虚函数,它的形参类型必须要与被覆盖的基类函数完全一致
  • 派生类中虚函数返回类型也要与基类函数匹配。当类的虚函数返回类型是类本身的指针或引用时,这一规则无效,当然这要求类型转换可访问(看书上解释)

final 和 override 说明符

  • 派生类如果定义了一个函数与基类中虚函数名字相同但形参列表不同,仍然合法。这时,派生类的函数并没有覆盖掉基类中的版本,与基类中的原始函数相互独立。一般不这么做,如果出问题调试起来困难
  • 使用override可以让意图更加清晰,如果使用override标记了某个函数,但该函数没有覆盖已存在的虚函数,编译器会报错
  • final关键字用来防止继承,禁止了派生类覆盖该函数

虚函数与默认实参

  • 虚函数也可以有默认实参,如果某次函数调用使用了默认实参,则该实参的值由本次调用的静态类型决定
  • 如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致

回避虚函数机制

  • 在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行某个特定版本,你们可以使用作用域运算符实现这一目的(此时该调用将在编译时完成解析)
  • 通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数机制
  • 什么时候需要回避虚函数的默认机制?通常当一个派生类的虚函数调用它覆盖的基类的虚函数版本时。在此情况下,基类的版本通常完成继承层次中所有类型都要做的共同任务,而派生类中定义的版本需要执行一些与派生类本身密切相关的操作。
  • 如果一个派生类虚函数需要调用它的基类版本,但没有使用作用于运算符,则运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归

15.4 抽象基类

纯虚函数

  • 在声明语句的分号之前书写=0,可以定义为纯虚函数。其中=0只能出现在类内部的虚函数声明语句处
  • 一个纯虚函数无需定义,如果要定义就必须定义在类的外部也就是说,我们不能在类的内部为一个=0的函数提供函数体

含有纯虚函数的类是抽象基类

  • 含有(或未经覆盖直接继承)纯虚函数的类是抽象基类
  • 抽象爱那个基类负责定义接口,而后续其他类可以覆盖该接口
  • 我们不能直接创建一个抽象基类的对象

派生类构造函数只初始化它的直接基类

  • 每个类各自控制其对象的初始化过程,派生类构造函数只初始化它的直接基类

15.5 访问控制与继承

受保护的成员

  • 受保护的成员对于类的用户来说是不可访问的
  • 受保护的成员对于派生类成员和友元来说是可以访问的
  • 派生类的成员或友元只能通过派生类对象来访问基类中的受保护成员。派生类对于一个基类对象中受保护成员没有任何访问特权
  • 派生类的成员和友元只能访问派生类对象中的基类部分的受保护成员,对于普通的基类对象中的成员不具有特殊的访问权限

公有、私有和受保护继承

  • 某个类对其继承而来的成员的访问权限受两个因素影响 (两个取最小)
    • 基类中该成员的访问说明符
    • 派生类的派生列表中的访问说明符
  • 派生访问说明符对于派生类的成员(及友元)能否访问直接基类的成员没什么关系。对基类成员的访问权限只与基类中的访问说明符有关
  • 派生访问说明符的目的是控制派生类用户(包括派生类的派生类)对于基类成员的访问权限
  • 派生访问说明符还可以控制继承子派生类的新类的访问权限

对于没有继承关系的类有两种不同的用户:

  • 普通用户:编写的代码使用类的对象,这部分代码只能访问类的公有成员
  • 实现者:负责编写类的成员和友元代码,成员和友元技能访问公有部分也能访问私有部分

考虑继承的话会出现第三种用户:派生类

  • 基类把希望派生类使用的部分声明为受保护的,普通用户不能访问受保护的成员,派生类及其友元仍旧不能访问私有成员

派生类向基类转换的可访问性

  • 派生类向基类的转换是否可访问由使用该转换的代码决定,同时派生类的派生访问符也会有影响
  • 只有当D公有继承B时,用户代码才能使用派生类向基类的转换
  • 不论D以什么方式继承B,D的成员函数和友元都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和友元永远都是可访问的
  • 如果D继承B的方式是公有的或受保护的,则D的派生类的成员和友元可以使用D向B的类型转换,反之不行

友元与继承

  • 友元关系不能继承,基类的友元在访问派生类时不具有特殊性,派生类的成员也不能随意访问基类的成员

  • 对基类的访问权是由基类本身控制的,即使对于派生类的基类部分也是如此

改变个别成员的可访问性

  • 有时我们需要改变派生类继承的某个名字的访问级别,通过using声明可以达到这一目的
  • 如果using声明语句出现在类的private部分,则该名字只能被类的成员和友元访问
  • 如果using声明语句出现在类的public部分,则类的所有用户都能使用它
  • 如果using声明语句出现在类的protected部分,则该名字对于成员、友元和派生类是可访问的
  • 派生类只能为那些它可访问的名字提供using声明

默认的继承保护级别

  • 默认情况下,class关键字定义的派生类是私有继承,struct关键字定义的派生类是公有继承
  • 一个私有派生的类最好显示地将private声明出来,而不要仅仅依赖于默认的设置

15.6 继承中的类作用域

  • 当存在继承关系时,派生类的作用于嵌套在基类的作用域之内。
  • 如果一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找改名字的定义

在编译时进行名字查找

  • 一个对象、引用或指针静态类型决定了该对象的哪些成员是可见的。即使静态类型于动态类型可能不一致,但我们能使用哪些成员仍然是由静态类型决定的

名字冲突与继承

  • 派生类的成员将隐藏同名的基类成员

通过作用域运算符来使用隐藏的成员

  • 我们可以使用作用域运算符来使用一个被隐藏的基类成员
  • 除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字

名字查找与继承

理解函数的解析过程对于理解C++的继承至关重要,假定调用p->mem()

  • 首先确定p的静态类型,因为调用的是个成员,所以该类型必然是类类型
  • p的静态类型对于的类中查找mem。如果找不到一次在直接基类中不断查找,直到达到继承链顶端。如果还找不到就报错
  • 一旦找到mem就进行常规类型检查,以确定本次调用是否合法
  • 假设调用合法,则编译根据调用的是否是虚函数而产生的不同代码
    • 如果mem是虚函数且我们通过引用或指针进行的调用,则编译器产生的代码将在运行时确定运行虚函数的哪个版本,依据对象的动态类型
    • 如果mem不是虚函数或者我们通过对象而非指针或引用进行的调用,编译器将产生一个常规的函数调用

一如往常,名字查找先于类型检查

  • 声明在内层作用于的函数不会重载外层作用域的函数,因此派生类的函数也不会重载基类的成员
  • 如果派生类的成员和基类成员同名,则派生类在其作用域内隐藏基类成员,即使形参列表不一致也会被隐藏

虚函数与作用域

  • 基类与派生类中的虚函数必须有相同的形参列表。因为,假如基类和派生类接受的实参不同,我们就无法通过基类的引用或指针调用派生类的虚函数了
  • 形参列表不同会隐藏但不是覆盖

通过基类调用隐藏的虚函数

  • 调用非虚函数不会发生动态绑定,实际调用的函数版本由指针的静态类型决定

覆盖重载的函数

  • 派生类可以覆盖重载函数的0个或多个实例
  • 如果派生类希望所有的重载版本对于它来说都是可见的,那它就需要覆盖所有版本,或者一个也不覆盖
  • 如果一个类仅需覆盖重载集合中部分函数,我们可以为重载的成员提供一条using声明,把该函数的所有重载实例添加的派生类作用域当中,这样派生类只要定义其特有的函数就可以了
  • 类内using声明的规则也适用于重载函数的名字,基类函数的每个实例在派生类中都必须可访问。对派生类没有重新定义的重载版本的访问实际上是对using声明点的访问

15.7 构造函数与拷贝控制

15.7.1 虚析构函数

  • 继承关系对基类拷贝控制最直接的影响是基类通常定义一个虚析构函数,这样我们就能动态分配继承体系中的对象了
  • 析构函数的虚属性也会被继承
  • 只要基类的析构函数是虚函数,就能确保我们delete基类指针时将运行正确的析构函数版本
  • 如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为
  • 基类的虚析构函数,并不像普通类的一样,我们不能因为析构函数推断出该类是否还需要赋值运算符和拷贝构造函数。因为为了成为虚函数,它的内容是空的

虚析构函数阻止合成移动操作

  • 基类需要一个虚析构函数这一事实还会对基类和派生类的地故意产生另外一个间接影响:如果一个类定义了析构函数,即使它通过=default的形式使用了合成版本,编译器也不会为这个类合成移动操作

15.7.2 合成拷贝控制与继承

  • 基类或派生类的合成拷贝控制成员除了对类本身对象一次初始化、赋值或销毁,还负责使用直接基类中对应的操作对一个对象的直接基类部分继续初始化、赋值或销毁
  • Quote继承体系中,所有类都使用合成的析构函数。其中派生类隐式地使用而基类通过将其虚析构函数定义成=default而显示地使用。
  • 合成的析构函数体是空的,其隐式的析构部分负责销毁类的成员。对于派生类的析构函数来说,它除了销毁派生类自己的成员外,还负责销毁派生类的直接基类,直到继承链顶端
  • 定义了虚析构函数阻止了合成的移动操作,所以如果基类没有移动操作,那么派生类也没有。如果要使用的合成的版本就要显式地用defaut定义出来。一旦定义了自己的移动操作,那么也就必须同时显式地定义拷贝操作

派生类中删除的拷贝控制与基类的关系

  • 如果基类的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或不可访问的,那派生类里也一样
  • 如果基类有不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类对象的基类部分
  • 编译器不会合成一个删除掉的移动操作,如果基类里析构函数是删除的或不可访问的,派生类的移动构造函数也是删除的

移动操作与继承

  • 因为虚析构函数,所以默认情况下基类不会包含合成的移动操作,派生类也不会包含。如果我们确实需要执行移动操作,首先在基类中定义。如果定义了自己的移动操作,就必须同时显式地定义拷贝操作

15.7.3 派生类的拷贝控制成员

  • 派生类构造函数在初始化阶段不但要初始化派生类自己的成员,还要初始化派生类对象的基类部分
  • 派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类部分成员
  • 派生类赋值运算符也必须为其基类部分成员赋值
  • 析构函数只负责销毁派生类自己分配的资源,对象的成员是被隐式销毁的,派生类对象的基类对象也是自动销毁的

定义派生类的拷贝或移动构造函数

  • 当为派生类定义拷贝或移动构造函数时,通常使用对应的基类构造函数初始化对应的基类部分
  • 在默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始列表值中显式地使用基类的拷贝(或移动)构造函数

派生类赋值运算符

  • 与拷贝和移动构造函数一样,派生类的赋值运算符也必须显式地为其基类部分赋值

  • 首先要显式地调用基类赋值运算符为基类部分赋值,然后给派生类成员赋值,酌情处理自赋值和资源释放

派生类析构函数

  • 析构函数体执行完成后,对象的成员会被隐式销毁,对象的基类部分也是隐式销毁的
  • 和构造函数及赋值运算符不同的是,派生类析构函数只负责销毁由派生类自己分配的资源
  • 对象销毁的顺序与创建顺序相反:派生类析构函数首先执行,然后是基类的析构函数,以此类推,沿着继承体系的反方向直至最后

在构造函数和析构函数中调用虚函数

  • 派生类对象的基类部分首先被创建。当执行基类的构造函数时,派生类部分是未被初始化状态,该对象处于未完成状态。为了处理这种情况,编译器认为对象的类型在构造或析构过程中仿佛改变了一样
  • 如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型相对应的虚函数版本

15.7.4 继承的构造函数

  • 一个类只"继承"其直接基类的构造函数(非常常规方法,姑且算是继承),类不能继承默认、拷贝和移动构造函数(基本就是自己写的那些传参数的普通构造函数),如果类没有直接定义这些构造函数,则编译器将为派生类合成它们
  • 派生类继承基类构造函数的方式是提供一条注明了直接基类名的using声明语句(注意是继承构造函数,赋值运算符不算)
    • 通常情况下using声明语句只是令某个名字在当前作用域内可见
    • using作用于构造函数时,会让编译器生产代码,对于基类的每个构造函数,都生成一个对应的派生类构造函数
    • 如果派生类有数据成员,那会被默认初始化

继承的构造函数的特点

  • 和普通成员的using声明不一样,一个构造函数的using声明不会改变该构造函数的访问级别
  • 一个using声明语句不能指定explicitconstexpr。如果基类的构造函数是explicit或者constexpr,则继承的构造函数也拥有相同的属性
  • 当一个基类构造函数含有默认实参,这些实参不会被继承。相反派生类将获得多个继承的构造函数,其中每个构造函数分别省略掉一个含有默认实参的形参
  • 默认、拷贝和移动构造函数不会被继承。这些构造函数按照正常规则被合成。继承的构造函数不会被作为用户定义的构造函数来使用,因此,如果一个类只含有继承的构造函数,则它也将拥有一个合成的默认构造函数

15.8 容器与继承

  • 当我们使用容器存放继承体系中的对象时,通常必须采取间接存储的方式。因为不允许在容器中保存不同类型的元素,所以我们不能把具有继承关系的多种类型对象直接存放到容器中
  • 当派生类对象被赋值给基类对象时,其中的派生类部分会被切掉,因此容器和存在继承关系的类型无法兼容

15.8.1 编写 Basket 类

  • 尽量隐藏指针
  • 尽量不要让用户来处理动态内存
  • 如果编写函数传了一个基类的引用,然后这个函数要进行动态内存分配的任务。要考虑到new Quote(sale)这种语句将分配一个Quote类型的对象并拷贝saleQuote部分,但sale实际上指向的可能是Bulk_quote对象,此时对象就会被迫切掉一部分。为了解决这个问题我们需要模拟虚拷贝

15.9 文本查询程序再探

15.9.1 面向对象的解决方案

继承与组合

  • 当我们令一个类公有地继承另一个类时,派生类应当反映与基类的"是一种(Is A)"关系。在良好的类体系当中,公有派生类的对象应该可以用在任何需要基类对象的地方
  • 类直接另一种常见关系是"有一个(Has A)"关系,具有这种关系的类暗号成员的意思

抽象基类

  • 如果一些概念上互为兄弟的类共享同一个接口,那么我们可以定义一个抽象基类来表示该接口

将层次关系隐藏于接口类当中

  • 隐含的意思是用户层代码将不会直接使用继承的类,相反我们将定义一个接口类,有它来负责隐藏整个继承体系

以上是关于C++ Primer 0x0F 学习笔记的主要内容,如果未能解决你的问题,请参考以下文章

C++ primer puls 学习笔记

C++ Primer 5th笔记(chap 18 大型程序工具)noexcept

C++ Primer学习笔记

C++ Primer 0x06 学习笔记

C++ Primer 0x05 学习笔记

C++ Primer 0x10 学习笔记