简介

  • C++类是创建新类型的工具,创建出的新类型可以像内置类型一样方便地使用。而且派生类和模板允许程序员表达类之间的(层次和参数化)关系并且利用这种关系。
  • 一个类型就是一个概念(一个思想,一个观念等)的具体表示。
  • 类是用户自定义类型。如果一个概念没有与之直接对应的内置类型,我们就定义一个新类型来表示它。

  • 定义新类型的基本思想是将实现的细节(例如,某种类型对象的数据存储布局)与正确使用它的必要属性(例如,可访问数据的函数的完整列表)分离。这种分离的最佳表达方式是:通过一个专用接口引导数据结构及其内部辅助例程的使用

类基础

  • 类的简要概括:
    • 一个类就是一个用户自定义类型
    • 一个类由一组成员构成。最常见的成员类别是数据成员和成员函数
    • 成员函数可定义初始化(创建),拷贝,移动和清理(析构)等语义
    • 可以为类定义运算符,如+, !和[]
    • 一个类就是一个包含其成员的名字空间
    • 对对象使用.(点)访问成员,对指针使用->(箭头)访问成员
    • public成员提供类的接口,private成员提供实现细节。
    • struct是成员默认为public的class
  • 声明于类定义内的函数称为成员函数(member function),对恰当类型的特定变量使用结构成员访问语法才能调用这种函数。
  • 由于不同结构可能有同名成员函数,在定义成员函数时必须指定结构名。

类内函数定义

  • 如果一个函数不仅在类中声明,还在类中定义,那么它就被当作内联函数处理,即很少修改且频繁使用的小函数适合类内定义。

自引用

  • 在非static成员函数中,关键字this是指向调用它的对象的指针

成员和基类初始化

  • 类自身的构造函数在其函数体执行之前会先调用成员的构造函数
  • 成员的构造函数按成员在类中声明的顺序调用,而不是按成员在初始化器列表中出现的顺序。

不变式

  • 一个类通常都会有一个不变式。如果是这样,我们希望拷贝和移动操作能够保持此不变式,而析构函数能释放任何用到的资源。
  • 不幸的是,编译器不可能在任何情况下都能了解程序员所考虑的不变式是什么。只要可能,我们就应该:
    • 在构造函数中建立不变式(包括可能的资源获取)
    • 在拷贝和移动操作中保持不变式(利用常用名字和类型)
    • 在析构函数中做任何需要的清理工作(包括可能的资源释放)
  • 不变式很多最关键,最明显的应用都与资源管理相关。
  • 简单的句柄Handle,其思想是,给定一个用new分配的对象的指针,创建一个Handle。这个Handle提供对象访问功能,并负责最终delete对象

建议

  • 应该将构造函数,赋值操作以及析构函数设计为一组匹配的操作
  • 使用构造函数为类建立不变式
  • 如果一个构造函数获取了资源,那么这个类就需要一个析构函数释放该资源
  • 如果一个类有虚函数,它就需要一个虚析构函数。
  • 如果一个类没有构造函数,它可以进行逐成员初始化
  • 优先选择使用{}初始化而不是=()初始化
  • 如果一个类是一个容器,为它定义一个初始化器列表构造函数
  • 按声明顺序初始化成员和基类
  • 在构造函数中优先选择成员初始化而不是赋值操作
  • 使用类内初始化器来提供默认值
  • 如果一个类是一个资源句柄,它可能需要拷贝和移动操作
  • 一个拷贝操作应该提供等价性和独立性
  • 如果一个类被用作基类,防止切片现象
  • 如果一个类需要一个拷贝操作或者一个析构函数,它可能需要一个构造函数,一个析构函数,一个拷贝赋值操作以及一个拷贝构造函数。
  • 如果一个类是一个资源句柄,它需要一个构造函数,一个析构函数和非默认拷贝操作
  • 显式说明你的不变式;用构造函数建立不变式,用赋值操作保持不变式

派生类

  • C++从Simula借鉴了类和类层次的思想。而且,C++还借鉴了一个重要的设计思想:类应该用来建模程序员和应用程序世界中的思想
  • 任何一个概念都不是孤立存在的,都有与之共存的相关概念,而且其强大能力中的大部分都源于与其它概念的关联。

  • C++提供了派生类的概念及相关的语言机制来表达层次关系,即,表达类之间的共性
  • C++语言特性支持从已有类构建新的类:
    • 实现继承(implementation inheritance):通过共享基类所提供的特性来减少实现工作量
    • 接口继承(interface inheritance):通过一个公共基类提供的接口允许不同派生类互换使用。
  • 接口继承常被称为运行时多态(run-time polymorphism, 或动态多态, dynamic polymorphism)
  • 相反,模板所提供的类的通用性与继承无关,常被称为编译时多态(compile-time polymorphism, 或静态多态, static polymorphism)

  • 我们常常称一个派生类继承了来自基类的属性,因此这种关系也称为继承(inheritance)
  • 派生类的成员可以使用基类的公有和保护成员,就好像它们声明在派生类中一样,但是派生类不能访问基类的私有成员

类层次

  • 一个派生类自身也可以作为其他类的基类,我们习惯称这样的一组相关的类为类层次(class hierarchy)。
  • 这种层次结构大多数情况下是一棵树,但也可能是更一般的图结构

类型域

  • 为了使派生类不至于成为仅仅是一种方便的声明简写方式,我们必须解决一个问题:给定一个Base* 类型的指针,它指向的对象的真正派生类型是什么?
  • C++提供了四种基本解决方法:
    1. 保证指针只能指向单一类型的对象
    2. 在基类中放置一个类型域,供函数查看
    3. 使用dynamic_cast
    4. 使用虚函数
  • 除非使用final,否则方法1依赖于所使用类型的很多值是,比编译器所能掌握的更多。一般而言,不要试图比类型系统更聪明。但是方法1可用来(特别是与模板组合使用)实现同构容器(如标准库vector和map),以获得非常好的性能
  • 方法2,3和4可用来实现异构列表,即,多种不同类型对象(指针)的列表。
  • 方法3是方法2的一种语言支持的变体,
  • 方法4是方法2的一种特殊的类型安全的变体

虚函数

  • 虚函数机制允许程序员在基类中声明函数,然后在每个派生类中重新的定义这些函数,从而解决了类型域方法的固有问题。编译器和链接器会保证对象和施用于对象之上的函数之间的正确关联。
  • 为了允许一个虚函数声明能作为派生类中定义的函数的接口,派生类中函数的参数类型必须与基类中声明的参数类型完全一致,返回类型也只允许细微改变。虚成员函数有时也称为方法(method)

  • 如果派生类中的一个函数的名字参数类型与基类中的一个虚函数完全相同,则称它覆盖(override)了虚函数的基类版本。此外,我们也可以用一个派生层次更深的返回类型覆盖基类中的虚函数
  • 除了我们显式说明调用虚函数的哪个版本之外,覆盖版本会作为最恰当的选择应用于调用它的对象。无论用哪个基类(接口)访问对象,虚函数调用机制都会保证我们总是得到相同的函数

  • 无论真正使用的确切Employee类型是什么,都能令Employee的函数表现出“正确的”行为,这称为多态性(polymorphism)。具有虚函数的类型称为多态类型(polymorphic type)或(更精确的)运行时多态类型(run-time polymorphic type)
  • 在C++中为了获得运行时多态行为,必须调用virtual成员函数,对象必须通过指针或引用进行访问。当直接操作一个对象时(而不是通过指针或引用)编译器了解其确切类型,从而就不需要运行时多肽了。
  • 默认情况下,覆盖虚函数的函数自身也变为virtual的。我们在派生类宗可以重复关键字virtual,但是这不是必需的。建议不重复virtual。如果希望明确标记覆盖版本,可以使用override

  • 显然,为了实现多态性,编译器必须在每个Employee类的对象中保存某种类型信息,并利用它选择虚函数的正确版本。在一个典型的C++实现中,这只会占用一个指针大小的空间:常用的编译器实现技术是将虚函数名转换为函数指针表中的一个索引。这个表通常称为虚函数表(the virtual function table)或者简称为vbtl。每个具有虚函数的类都有自己的vbtl,用来标识它的虚函数。

  • 显式限定,使用作用域解析运算符::调用函数(Manager::print())能够保证不是用virtual机制
  • 如果一个虚函数也是一个inline,对于使用::限定的调用就可以进行内联替换。这给程序员提供了一种方法高效处理某些重要的特殊情形:一个虚函数对相同对象调用另一个虚函数。

override

  • override不是一个关键字,它是所谓的上下文关键字(contextual keyword)。即,override在某些上下文中有特殊含义,但是在其他地方可用作标识符

抽象类

  • 具有一个或者多个纯虚函数的类称为抽象类(abstract class),我们无法创建抽象类的对象
  • 抽象类就是要作为通用指针引用访问的对象的接口(为了保持多态行为)
  • 因此,对于一个抽象类来说,定义一个虚析构函数通常很重要。由于抽象类提供的接口不能用来创建对象,因此抽象类通常没有构造函数。

  • 如果纯虚函数在派生类中未被定义,那么它仍保持是纯虚函数,因此派生类也是一个抽象类。这令我们可以阶段性地构建具体实现

  • 抽象类提供接口,但是不暴露实现细节。
  • 抽象类所支持的设计风格称为接口继承(interface inheritance),它与实现继承(implementation inheritance)相对,后者是由带状态或定义了成员函数的基类所支撑的。两种风格组合使用是有可能的。即,我们可以定义并使用即带状态又有纯虚函数的基类。但是,这种混合风格会令人迷惑,也需要特别小心。

基类和派生类成员

  • 一个派生类至少包含从基类那里继承来的成员,通常还包含其他成员。这意味着我们可以安全地将一个基类成员指针赋予一个派生类成员指针,但是反方向赋值则不行,这一特性常被称为逆变性(contravariance)
  • 这一逆变规则看起来与另一规则是相反的:我们可以将一个派生类指针赋予其基类的指针。
  • 实际上,两个规则都是为了提供基本保障:一个指针永远不应该指向这样的对象–不能提供指针所承诺的最基本的属性。

类层次

  • 一个系统应该用抽象类层次表示,而用传统的层次体系实现。换句话说:
    • 用抽象类支持接口继承
    • 用带有虚函数实现的基类支持实现继承

多重继承(multiple inheritance)

  • 直接从多个类中派生称为多重继承
  • 用一个基类表示实现细节,用另一个基类表示接口(抽象类)的做法对于所有支持继承和编译时接口检查的编程语言来说都是非常常见的。

  • “我个人习惯于使用一个实现层次体系,再(在必要时)辅以几个提供接口的抽象类。这种方式比较灵活,也易于系统的演化。但是我们未必总能如愿,尤其是当需要使用现有的类,有不想对它作出任何修改时更时如此(比如,这些类属于别人的库)”

二义性

  • 两个基类的成员函数可能具有相同的名字,产生歧义。具体做法是为成员名字加一个类限定符。
  • 然而,显式消除二义性比较繁琐,解决此类问题的最佳方式是在派生类中定义一个新函数。在派生类中声明的函数会覆盖基类中所有同名及同类型的函数。通常情况下,这种效果就是我们需要的,因为在同一个类中的同一个名字不宜有多重含义。virtual的目标是对于一个调用来说,不管我们是通过哪个接口找到函数的,它的执行效果都应该保持一致。

虚基类

  • 如果每个类只有一个直接基类,则类层次表现为一棵树,并且每个类在树中只能出现一次。如果每个类可以有多个基类,则在层次体系中每个类可能出现多次。
  • 重复基类的虚函数可以在派生类中被一个(单独的)函数覆盖。通常情况下,这个覆盖的函数先调用其基类的版本,然后执行派生类自己的操作。
  • 每个被指定为virtual的基类只用该类的一个单独的对象表示。另一个方面,非virtual基类由其子对象表示。

  • 派生类可以覆盖其直接或者间接虚基类的虚函数。尤其是,两个不同的类可能会覆盖虚基类的不同的虚函数。通过这种方式,几个派生类就能共同为一个虚基类表示的接口提供实现了。

建议

  • 不要在作为接口的基类中放置数据成员
  • 用抽象类表示接口
  • 为抽象基类定义一个虚析构函数确保其正确地清理资源
  • 用抽象类支持接口继承
  • 用含有数据成员的基类支持实现继承
  • 用普通的多重继承表示特征的组合
  • 用多重继承把实现的接口分离开来
  • 用虚基类表示层次中一部分(而非全部)类公有的内容

运行时类型信息

  • 一般来说,类是从基类的框架中构造出来的。这种类框架(class lattice)通常被称为类层次(class hierarchy)
  • 在运行时使用类型信息通常被称为“运行时类型信息”,简写为RTTI(Run-Time Type Information)
  • 从基类到派生类的转换通常称为向下转换(downcast),从派生类到基类的转换称为向上转换(upcast),从基类到兄弟类的转换,称为交叉转换(crosscast)