5 类

5.1 引言

类、基本操作、模板、概念和泛型编程,这四部分内容集中展示了C++是如何在不涉及过多细节的前提下,支持抽象和资源管理的:

以上特性,共同构成C++语言支持面向对象编程和泛型编程的语法基础。C++语言的其它内容更多集中在标准库设施及其使用方法上。

5.1.1 类的概述

C++最核心的语言特性就是类。类(class)是一种由用户自定义的数据类型,用于在程序代码中表示某种实体。无论何时,只要程序设计者想为程序添加一个有用的想法、实体或数据集合,都应该设法把它表示为程序中的一个类。程序设计者的思想即体现在该类之中,而非仅仅存在于他的头脑、设计文档或代码注释中。对于一个程序而言,无论是从它的可读性,还是从它的正确性来衡量,构建于一组精挑细选的类之上的程序,比直接搭建在内置类型上的程序,其优势要显著得多。而且,往往库所提供的产品就是类——类库。

程序/类库
程序员
定义
定义
定义
描述想法的类
描述实体的类
描述数据集合的类
想法
实体
数据集合

在C++语言中,除基本类型、操作符和流程控制语句之外的所有特性,其目标只有一个,就是帮助程序设计者定义出更好的类,并更加方便地使用它们。所谓“更好”,即更正确、更高效、更优雅、更易用、更易读、更易推断和更易维护。大多数编程技术都是依赖于某些特定类的设计与实现。程序员的需求和偏好千差万别,因此对类的支持也应该是宽泛和丰富的。以下是其中最重要的三种对类的基本支持:

具体类
抽象类
类层次结构

很多有用的类都可以被归属到这三个类别中。其它类则可被视作这三个类别的简单变形或组合。

5.2 具体类

具体类的基本思想,就是让它们在行为和使用上,肖似甚或高于内置类型。例如,复数类型和无穷精度整数类型,与内置的int类型非常象,当然它们有自己的语义和操作集。类似的,vector和string,与内置的数组类型也非常象,但在可操作性上更胜一筹。

具体类的典型特征,就是它的成员变量是其定义的一部分。如vector,它的成员变量只不过是一个或几个指向某块用于保存数据元素的内存的指针,但这种成员变量存在于每个vector对象中。这样的对象:

具体类的成员变量可被限定为私有,就象vector那样。这意味着这部分内容确实存在,但不能在类的外部被直接访问,只能通过公有的成员函数被间接地访问。因此,一旦成员变量发生了任何明显的改动,使用者就必须重新编译整个程序。这也是让具体类尽可能接近内置类型所必须付出的代价。对于那些不经常改动的类型,以及那些为局部变量提供了足够的清晰性和效率的类型而言,这个代价是可以接受的,而且通常也是非常理想的。为了提高灵活性,可将其实际内容保存在自由存储(动态内存、堆)中,同时通过存储在对象内部的成员变量,比如指针或引用,访问它们。vector和string就是这样设计的。它们可被视为带有精致接口的资源管理器。

自由存储
具体类
外部
调用
操作
指向/引用
内容
接口
成员变量
用户

5.2.1 一种算术类型

一个经典的“用户自定义算术类型”是Complex:

这是对标准库complex类的一个简化的模拟版本。类定义本身只包含访问其成员变量的操作。它的成员变量非常简单,源于约定俗成的数学常识。出于编程实践的考虑,它必须兼容60年前Fortran语言提供的版本,同时还要支持一些常规的操作符。除了满足逻辑上的要求,Complex类必须足够高效,否则没有任何实用价值。这意味着,有必要将一些简单操作实现为内联函数,在最终生成的机器代码中,象构造函数、获取和设置成员变量的函数、大量使用的各种操作符函数等,都不再以函数调用的形式出现。直接在类内部定义的成员函数,默认为内联函数,也可以在函数声明的前面加上inline关键字,将其显式指定为内联。此外,标准库的complex类还将一部分成员函数声明为constexpr,以使某些算术运算可以在编译阶段完成。

默认情况下,拷贝构造函数和拷贝赋值操作符函数,会被隐式生成。这里给出的定义并非十分必要。

不提供实参就能被调用的构造函数称为默认构造函数,它决定了对象的默认状态,即未提供任何初始化信息的缺省状态。为一个类提供默认构造函数,可有效防止因成员变量未被初始化而导致的不确定性。

在用于获取实部和虚部的成员函数中,const修饰符表示它们不会修改调用对象的内容。const成员函数既可以被const对象、指针或引用调用,也可以被非const对象、指针或引用调用,而非const成员函数只能被非const对象、指针或引用调用。例如:

有些成员函数并不需要访问调用对象的成员变量,在类的外部,以全局函数的形式定义它们更为合适。例如:

很多函数的参数都使用了const引用,这样既可以避免参数传递过程中对象复制的开销,又可以防止在函数中对实参对象的意外修改。

C++编译器有能力将由Complex对象和操作符组成的表达式,转换为针对操作符函数的调用,如将“c1 + c4”转换为“c1.operator+(c4)”,或将“c1 != c4”转换为“operator!=(c1, c4)”。

在使用用户自定义的操作符,即操作符重载时,应该小心谨慎,并尊重其常规的使用习惯。比如无法将斜杠(/)定义为一元操作符,也无法将加号(+)定义为对两个int型操作数做减法。编译器的既定规则,不会因操作符重载而改变。

5.2.2 容器

容器是一个包含若干元素的对象。Vector类的对象就是一个容器,因此称Vector为一个容器类型。Vector作为一个容器具有许多优点:易于理解、内置了必要的约束条件、提供了带边界检查的下标访问、通过size成员函数方便用户遍历其中的元素,等等。然而,它还有一个致命的缺陷——在构造函数中通过new操作符分配的内存,没有得到释放。这显然是一个糟糕的设计,因为C++并未提供任何垃圾收集机制,任何未被手动释放的内存,都不可能再被分配给新的对象,除非终止程序或重启系统。C++不提供垃圾回收器,一方面是因为它并不总是可用,另一方面是因为精确控制资源的分配与释放,能更好地满足逻辑和性能的要求。这里迫切需要一种机制,当对象已不可用时,及时释放其所持有的动态资源,这种机制就是析构函数。例如:

析构函数的命名规则是在一个波浪线(~)后面紧跟类名。从语义上讲,它是构造函数的对偶。

Vector类的构造函数,借助new操作符,从自由存储(亦称堆或动态内存)中分配了一些内存空间。析构函数则通过“delete[]”操作符释放了该内存空间,以达到清理资源的目的。单独的一个“delete”只能释放一个对象,而“delete[]”则可以释放一个数组中的所有对象。

有关内存资源的分配与释放,并不需要Vector类的使用者插手,他们只需象使用内置类型的变量一样,使用Vector对象即可。例如:

Vector类的对象,与int或double等内置类型的变量一样,遵循相同的命名规则、作用域、生命周期,及内存空间的分配与释放策略。构造——析构函数机制,是很多优雅技术的基础,尤其是大多数C++通用资源管理技术的基础。Vector对象的内存模型如下图所示:

自由存储
Vector对象
0 | 0 | 0 | 0 | 0
m_elem
m_size

Vector类的构造函数负责分配内存并初始化元素,同时为该类的成员变量设定初值,析构函数则负责释放内存。这是一个典型的数据句柄模型,常用来管理在对象的生命周期内,大小会发生变化的数据。在构造函数中获取资源,然后在析构函数中释放这些资源,这种技术称为资源获取即初始化,即RAII。对象生,资源在,对象亡,资源无。借助RAII技术,可以避免所谓的“裸new操作”和“裸delete操作”。在对象以外的代码中,几乎看不到任何new和delete操作,它们完全隐藏在良好抽象的实现内部。裸new和裸delete的消失,可以使程序代码远离各种潜在的风险,尤其是资源泄漏的风险。

5.2.3 容器的初始化

容器的作用是保存元素,因此需要找到一种便捷的方式将元素存入容器。目前的做法是先创建Vector容器,其中的元素都被初始化为0,然后再通过下标操作符依次为这些元素赋值。例如:

这种做法显然不够优雅。更好的做法是:

其中,push_back用于在序列末尾添加一个新元素。例如:

第一个for循环负责执行输入操作。循环的终止条件是到达文件末尾,或遇到格式错误,如输入一个无法被解释为浮点数的字符串。在此之前,每读入一个浮点数,即添加到vec的末尾。最后,vec中的元素就是用户输入的数据。这里使用for循环而非while循环的好处是,可以将d的作用域限制在循环内部。

这里的push_back函数显然不够优化,标准库vector的push_back函数,可以更高效地扩展保存元素序列的内存空间。

接受初始值列表的构造函数,以标准库类型initializer_list作为其唯一的参数。编译器能够识别它,当看到被花括号括起的初始值列表时,会自动创建一个initializer_list类型的对象,传递给该构造函数。例如:

在接受初始值列表的构造函数中,为了将初始值列表的大小转换成int类型,使用了static_cast。这种写法比较呆板,因为一个手写的初始值列表,其中的元素个数怎么也不可能超过一个int类型值所能表示的范围(一个32位int型整数的最大值是2'147'483'647)。但要记住,编译器的类型系统是没有判断能力的,它只知道一个变量的可能取值,而非实际取值,因此它常常会无中生有地报告一些错误或警告,然而对于程序员来说,这些警示信息迟早会发挥作用,它能防止程序陷入特别严重的错误。

static_cast本身并不负责检查所要转换的值,它假设程序员完全清楚自己的所作所为。遗憾的是,这个假设并不总能成立,因此程序员如果不确定一个值能否被正确地转换为所要求的类型,最好先确认这一点,指望编译器完成这种检查是不现实的。显式类型转换,亦称强制类型转换,本来就是为了解决某种可能存在的问题而设计的,能不用尽量不用。为了降低错误发生的概率,程序员应当尽量将未经检查的类型转换,限制在软件系统的底层。

其它显式类型转换还包括:

善用类型系统和设计良好的标准库,在软件系统的上层,消除未经检查的类型转换。

C++11:通过花括号列表,执行统一且通用的初始化

C++11:从花括号列表到std::initializer_list的语言映射

C++11:容器的initializer_list构造函数

5.3 抽象类

Complex类和Vector类之所以被称为具体类,是因为它们的实现属于定义的一部分。在这一点上,它们与内置类型非常相似。相反,抽象类把类的使用者和类的实现完全隔离开来,以接口的形式解除二者间的耦合。抽象类本身没有对应的实现,也无法被实例化为对象,只能以指针或者引用的形式出现在代码中。

自由存储
实现类
抽象类
外部
调用
覆盖
操作
指向/引用
内容
实现
成员变量
接口
用户

Container类就是一个抽象类,其中包含一系列针对容器的操作接口。例如:

对包括Vector在内的每一种具体容器而言,Container类纯粹是一个接口。关键字virtual的意思是“可能在该类的派生类中被重新定义”。这种被关键字virtual声明的成员函数称为虚函数。Container类的派生类负责为其中的接口(虚函数)提供具体实现(重新定义)。“= 0”语法意在说明该虚函数是一个纯虚函数,即派生类必须给出关于它的重新定义。因此,无法单纯定义一个Container类的对象。例如:

Container类相当于一系列接口的集合,它的派生类负责其中接口的具体实现。至少包含一个纯虚函数的类称为抽象类。Container类的正确用法类似下面这个样子:

请注意,readAndSum函数是如何在完全不知道实现细节的前提下使用Container接口的。它使用了push_back、size和下标操作符,却根本不知道调用的是哪个类的成员函数。一个可以为其它类提供接口的类,比如Container类,被称为多态类型。

作为一个抽象类,Container类中没有构造函数,毕竟它不需要初始化数据,但它却拥有一个析构函数,该函数是一个空函数,而且也被声明为virtual。这是为了保证当通过一个Container类型的指针销毁动态创建的派生类对象时,派生类的析构函数能够被执行,以正确释放其所持有的资源。

Vector类是Container类的一个实现类,其中包含对接口的具体实现。例如:

这里的“: public”读作“公有派生自”或“是······的公有子类型”。称Vector类Container类的派生类,Container类是Vector类的基类,或称Vector类是Container类的子类,Container类是Vector类的超类。派生类继承其基类的成员,因此称派生类继承自基类,派生类和基类之间的关联关系,称为继承。

继承
«interface»
Container
+~Container()
+operator[](int) : double&
+operator[](int) : const double&, const
+size() : int, const
+push_back(double) : void
Vector
-double* m_elem
-int m_size
+Vector()
+Vector(int)
+Vector(const initializer_list&)
+~Vector()
+operator[](int) : double&
+operator[](int) : const double&, const
+size() : int, const
+push_back(double) : void

派生类Vector中的析构函数和三个成员函数——operator[]、size和push_back,覆盖了其基类Container中的对应成员。此处显式声明为override,以明确表达程序员的意图。这里的override关键字不是必须的,但显式注明有助于编译器捕捉不易察觉的错误,比如虚函数的派生类版本与基类版本原型不一致,以至无法形成有效覆盖。override关键字在复杂类层次结构中特别有用,否则很难一眼看出到底是谁覆盖了谁。

派生类Vector的析构函数~Vector,覆盖了基类Container的析构函数~Container。注意,基类Container的析构函数~Container会被派生类Vector的析构函数~Vector隐式调用。

象“readAndSum(... Container& con ...)”这样的函数,可以在完全不了解任何Container类的派生类的情况下,仅凭Container类中的接口(纯虚函数)访问一个(抽象化的)容器所提供的各种功能,如通过下标访问特定元素(operator[])、获取元素数量(size)、在序列末尾添加新元素(push_back),等等。当然实际传递给该函数的参数,必须是实现(覆盖)了Container类接口(纯虚函数)的(具体化的)容器对象,比如Vector:

readAndSum函数只知道Container类而不知道Vector类,因此对于Container接口(抽象基类)的其它实现(具体派生类),该函数同样适用。例如:

这里的关键在于,readAndSum函数并不清楚它的Container&类型的参数con,究竟引用的是一个Vector对象还是一个List对象。这已经不重要了,它只需知道该参数引用的是一个Container类型的容器,Container类的接口它都可以访问就足够了。因此,无论是修改了Vector或List类的实现,还是增加了新的表示具体容器的派生类,比如Deque,readAndSum函数都无需重新编译。

readAndSum函数
调用
传参
传参
传参
Container& con
operator[]、size、push_back
Vector对象
List对象
其它容器对象

这种灵活性唯一的不足,就是只能通过指针或者引用,操作多态类型的对象。

C++11:通过override关键字显式指明覆盖

5.4 虚函数

现在的问题是,readAndSum函数的参数con,只是一个Container类的引用,它凭什么知道,是应该调用Vector类的,还是调用List类的成员函数,operator[]、size或者push_back呢?为了做到这一点,该引用的目标对象中必须包含一些有助于它在运行时选择正确函数的信息。常见的做法是,编译器会将虚函数的名字转换为函数指针数组中的索引值,这个函数指针数组就是所谓的虚函数表(vtbl)。每个包含虚函数的类都有自己的虚函数表,该类的每个对象中都有一个指向虚函数表的指针。调用虚函数时,会根据索引在相应的虚函数表中查找所调虚函数的入口地址,完成函数调用。如下图所示:

Vector对象
Vector类的虚函数表
(vtbl)
List对象
List类的虚函数表
(vtbl)
虚函数表指针
非静态成员变量
0 | 虚函数指针
1 | 虚函数指针
2 | 虚函数指针
3 | 虚函数指针
4 | 虚函数指针
虚函数表指针
非静态成员变量
0 | 虚函数指针
1 | 虚函数指针
2 | 虚函数指针
3 | 虚函数指针
4 | 虚函数指针
List::~List()
double& List::operator[](int)
const double& List::operator[](int) const
int List::size() const
void List::push_back(double)
Vector::~Vector()
double& Vector::operator[](int)
const double& Vector::operator[](int) const
int Vector::size() const
void Vector::push_back(double)

编译器在编译readAndSum函数时,并不知道其“Container&”型参数con,究竟会在运行时引用什么对象,但它会生成一系列指令,插入到虚函数被调用的位置。该指令在运行时执行,依次完成如下动作:

由此可见,编译器在编译readAndSum函数时,即使对Vector类或List类一无所知,也能生成正确的虚函数调用代码。它只需要知道Container类中每个虚函数的索引值,以及每个对象中虚函数表指针的存放位置(通常在对象的起始位置)即可。

虚函数调用的时空效率与普通函数调用非常接近:

5.5 类层次结构

所谓类层次结构,是指通过继承产生的一组在框架中有序排列的类。借助类层次结构,可以很方便地表达,现实世界中那些明显带有层次关系的概念。例如:

汽车
卡车
消防车

又如:

«interface»
Shape
+~Shape()
+center() : Point, const
+move(Point) : void
+draw() : void, const
Circle
-m_center
-m_radius
+Circle(Point, int)
+center() : Point, const
+move(Point) : void
+draw() : void, const
Rectangle
-m_lt
-m_rb
+Rectangle(Point lt, Point rb)
+center() : Point, const
+move(Point) : void
+draw() : void, const
Smiley
-m_eyes
-m_mouth
+Smiley(Point, int)
+~Smiley()
+move(Point) : void
+draw() : void, const
+addEye(Shape*) : void
+setMouth(Shape*) : void
+wink(int) : void, const

实际应用中,动辄包含上百个类的复杂层次结构,并不鲜见。上图中Shape类的代码如下:

这个接口显然是一个抽象类,对于每种形状而言,它们的具体实现各不相同,但都要提供诸如获取中心、移动、绘制等功能,因此都是Shape类的直接或间接子类。基于上述定义,能操作一组形状的通用函数可定义如下:

要定义一种具体形状,首先必须指明它是一个Shape,即继承自Shape类,然后再定义其特有的属性和行为,包括给出各个纯虚函数的具体实现。例如:

甚至可以继续派生出更多的类。例如:

std::vector类模板的push_back成员函数负责将其参数(的副本)添加到容器(m_eyes)中。通过Smiley类的基类,即Circle类的draw成员函数,和Smiley类成员变量的draw成员函数,实现Smiley类的draw成员函数:

请注意,Shape类的析构函数是一个虚函数,Smiley类的析构函数覆盖了它。对于抽象类而言,因其派生类的对象通常都是通过基类的接口操作的,所以基类中必须有一个虚析构函数。当借助一个基类指针销毁其指向的派生类对象时,虚函数调用机制能够确保实际被调用的是派生类的析构函数。该析构函数在完成对派生类对象所持有的动态资源的释放后,会隐式调用其基类和成员类型的析构函数,保证基类子对象和成员子对象中的动态资源也能够被释放干净。

派生类对象
基类子对象
成员子对象
释放
释放
释放
调用
调用
调用
派生类的析构函数(虚)
动态资源
成员类型的析构函数
动态资源
基类的析构函数(虚)
动态资源
delete
指向派生类对
象的基类指针

在这个简单的例子中,程序员负责在表示笑脸的对象中恰当地放置眼睛和嘴:

并在笑脸对象被销毁时,一并销毁它的眼睛和嘴:

以派生的方式定义新类,可为其添加成员变量和成员函数。这种机制固然为类的设计提供了巨大的灵活性,但同时也可能带来混淆,从而导致糟糕的设计。

5.5.1 类层次结构的益处

类层次结构的益处主要体现在以下两个方面:

具体类,尤其是表现形式不复杂的类,具有非常类似于内置类型的行为。通常将其定义为局部变量,可通过变量名直接访问或随意拷贝。然而,处于层次结构中的类却与此有所不同,更倾向于通过new操作符,在自由存储中为其创建对象,而后通过基类类型的指针或引用访问这些对象。例如下面的函数用于从标准输入读取一个Shape对象:

使用这些类和函数的代码如下所示:

这段代码从始至终都不知道它所操作的究竟是什么类型的对象。对Shape类的派生类的任何修改,或者增加更多的派生类,比如Triangle等,都不会对这段代码构成任何影响。在这段代码的最后,通过delete操作符,销毁了形状容器中的所有对象。因为Shape类的析构函数是一个虚函数,因此delete会根据shape指针的目标对象,调用相应派生类的析构函数。这一点非常关键,因为派生类可能有很多种需要释放的动态资源,比如内存、文件句柄、锁、I/O流等。派生类的析构函数在完成对这些动态资源的释放后,会隐式调用其基类的析构函数,继续释放基类子对象中的动态资源。在类层次结构中,构造的顺序是自上而下,即先执行基类构造函数中的代码,再执行派生类构造函数中的代码,而析构的顺序是自下而上,即先执行派生类析构函数中的代码,再执行基类析构函数中的代码。

基类
派生类
自上而下
自下而上
构造函数
析构函数
构造函数
析构函数

5.5.2 类层次结构导航

如果需要将一个指向派生类对象的基类指针或引用,还原为派生类类型的指针或引用,以便访问派生类的特有成员,可以使用dynamic_cast操作符。该操作符会检查基类指针或引用的目标对象,是否确为需要转换的目标类型或其派生类。如果是,则转换成功,不是,则返回空指针(转指针)或抛出bad_cast异常(转引用)。

dynamic_cast
成功
dynamic_cast
失败
dynamic_cast
失败
Circle对象
Shape*
Shape&
Circle*
Circle&
Rectangle*
Rectangle&
Smiley*
Smiley&
dynamic_cast
失败
dynamic_cast
成功
dynamic_cast
失败
Rectangle对象
Shape*
Shape&
Circle*
Circle&
Rectangle*
Rectangle&
Smiley*
Smiley&
dynamic_cast
成功
dynamic_cast
失败
dynamic_cast
成功
Smiley对象
Shape*
Shape&
Circle*
Circle&
Rectangle*
Rectangle&
Smiley*
Smiley&

例如:

适度使用dynamic_cast能让代码显得更简洁。如果能避免使用类型信息,就能写出更加简洁有效的代码。不过在某些情况下,缺失的类型信息必须被恢复出来,尤其是当对象被传递给某些系统,而这些系统只接受基类定义的接口时。当该系统稍后传回对象以供使用时,可能不得不恢复它们的原始类型。dynamic_cast操作符的语义更象是在说“是······的一种”或者“是······的一个实例”。

5.5.3 避免资源泄漏

分配了资源却没有释放,谓之泄漏。泄漏将导致系统无法访问相关资源,因此必须尽量避免。否则,泄漏最终将耗尽系统资源,导致系统卡顿甚至崩溃。

上面的程序中存在三处安全隐患:

从某种意义上讲,从函数中返回指向自由存储中的对象的指针是非常危险的。任何通过旧式的裸指针表达所有权的做法都应当尽量避免。例如:

如果圆形无法完整呈现在屏幕的有效区域内,则或抛出异常,或返回失败。这时“delete shape”语句将不会被执行,从而引发内存泄漏。将new的返回值交给一个裸指针,在任何时候都是自找麻烦。

上述问题的简单解决方案,是使用标准库的unique_ptr替代裸指针。例如:

这个改变额外带来了一个令人愉快的副作用——不再需要为Smiley类提供析构函数了。编译器会隐式地生成并执行,在vector中的unique_ptr所需要的析构操作。使用unique_ptr的代码,与正确使用原始指针的代码同样高效。

考虑Shape对象的用户代码:

readShape函数返回的,是一个持有Shape对象地址的unique_ptr对象,而不再是Shape对象的地址本身,即裸指针,只要unique_ptr离开了它的作用域,其析构函数将负责销毁其所持有的Shape对象。注意,对unique_ptr做拷贝构造和拷贝赋值是被禁止的,但允许对它做转移构造和转移赋值。

unique_ptr< Shape >
new
delete
构造函数
析构函数
Shape*
Smiley类
Smiley对象
裸指针

当然,这里还需要能够接受vector<unique_ptr<Shape>>类型参数的moveShapes和drawShapes函数:

如果有很多函数都需要接受vector<unique_ptr<Shape>>类型的参数,使用函数对象或可使代码变得更加简洁。

5.6 建议