C++设计模式由浅入深(一)—— 继承和多态

一、继承和多态

C++首先是一个面向对象的语言,对象是C++程序的基石。通过类的继承和派生,软件工程师可以自由表达对软件系统中各个部分之间的关系与交互逻辑,定义各个组件之间的接口和实现,有秩序地组织起数据结构和代码。本书的目的不是为了教授C++语言,本章的要义是为了帮助读者理解C++语言特性中与类和继承相关的知识,有助于后续章节的展开。因此,我不会事无巨细地描述C++中类及其相关工具的具体用法,而是会介绍贯彻本书中的基础概念和语言结构。

本章我们将要讨论:

  • C++中的类是什么?它在C++语言中扮演什么样的角色?
  • 类的继承是什么?C++是如何利用继承的?
  • 什么是运行时多态?以及如何在C++中使用它。

1 类和对象

面向对象程序设计是一种把数据结构和算法有机结合起来的程序构建方式,其中算法运行于被称为“对象”的单一实体之上。大多数面向对象程序语言,包括C++,都是基于类的。类是用来定义对象的,它描述了对象的算法、数据结构、表现形式以及与其他类之间的关系。一个“对象”是类的具体实例,换言之,是一个变量。对象拥有地址,即其在内存中占有一席之地。类是一种用户定义的类型。总的来说,任意数量的对象可以通过类所提供的定义实例化(也有例外,某些类可能会限制对象的数量,但这不是常态)。

在C++中,类中包含的数据被组织成为类的数据成员,由不同类型的变量组成。算法,则由类的方法,也就是各种成员函数来实现。尽管从语言上来讲,没有要求类的数据成员必须与其提供的算法相关,但是作为一个良好的设计习惯,数据应当被妥善地封装在类的内部,同时类的方法提供给外部对于类内数据的有限访问。

封装的概念是C++中类的核心,语言标准允许我们通过将成员设置为public使其对外暴露,或者设置为private将它们隐藏。一个优秀的设计通常有且仅有private数据成员,仅通过public方法对外提供数据访问的接口。这些public接口就像是一个合约,类的设计者允诺这个类将提供某些操作和功能。类的private成员及方法则作为实现的一部分,并且只要public接口有效,可以任意修改。举个例子,下面的类表达了一个有理数,并且支持增量操作,其暴露出的public接口如:

class Rational {
public:
    Rational& operator+=(const Rational& rhs);
};

一个良好设计的类不会通过public接口暴露非必要的实现细节,因为实现的细节不是“合约”的一部分,不过接口的文档有时倒是会提醒你接口会有哪些限制罢了。比方说,如果我们承诺所有的有理数中,分子和分母不存在公倍数,那么当两个有理数相加时,需要一个约分的步骤。这个步骤最好用private方法来实现它,因为他可能还会调用其他的细节操作。因此类的客户不需要去执行这些复杂的操作,因为在这个方法的结果返回给调用者时,private方法已经完成了约分:

class Rational {
public:
	Rational& operator+=(const Rational& rhs);
private:
	long n_; // 分子
	long d_; // 分母
	void reduce();
};
Rational& Rational::operator+=(const Rational& rhs) {
	n_ = n_*rhs.d_ + rhs.n_*d_;
	d_ = d_*rhs.d_;
	reduce();
    return *this;
}
Rational a, b;
a += b;

类方法拥有数据成员的特殊访问权限,因此可以直接访问private数据成员。注意这里的operator+=是类的成员函数,它是由a对象调用并作用于自身的。如果一个成员函数通过“直呼其名”引用了一个数据成员,那么它访问的就是调用这个函数的对象中的那个数据成员,可以想象在这里编译器自动给我们显式地添加了this->n_this->d_。访问另一个对象的的成员则需要通过指向另一个对象的指针或者引用来进行,不过对于非成员函数而言,这样的访问可能会收到访问权限控制的限制。

顺便一提,C++也支持C风格的结构体struct。但是在C++中,struct并不仅仅局限为一个数据的聚合体,它可以有成员方法,private和public访问权限控制,以及任何作为类可以有的属性。从语言的层面来看,类和对象的区别仅仅在于默认访问权限。在类(class)中,所有的成员默认是private访问权限,而struct是public访问权限。除此之外,对于struct或者class的选择更多的是一种习惯或者约定俗成,通常我们用struct来表示C风格的结构体,它的表现能够和C的结构体一样好。例如,仅有构造函数的struct。当然,这样的界定并不准确,也取决于不同的项目或者团队的编码风格。

除了我们上面看到的数据成员和成员方法以外,C++的类也支持static数据成员和static成员方法。static成员方法非常接近与非成员函数,它不会由类的任何对象调用,仅能通过它的参数访问类的对象。然而,与非成员函数不同的是,类的static成员方法保留了对类的private成员的访问特权。

2 继承和派生

在C++中,类的派生有两个主要的目的。一方面,它允许我们表达对象间的关系;另一方面,它允许我们通过许多更简单的类型来构建复杂的类型。这两种目的都是通过类的继承达成的。

继承是C++中使用类和对象的核心概念。继承允许我们扩展已有类来定义新的类。当一个类派生自另一个类的时候,某些情况下,可以包含父类的所有数据成员和算法,并且添加它自己的算法和数据成员。在C++中,区分private继承和public继承非常重要。

Public继承方式继承了类的公有接口,并且继承了所有实现——基类的数据成员也会成为派生类的一部分。接口的继承是区分public继承和private继承的标志,因为公有继承会将基类的共有接口作为派生类共有接口的一部分对外暴露,就像上面讲过的合约一样。

通过public继承自基类,并且遵循基类接口的限制条件,派生类就可以将其绑定在与基类相同的合约上,同时可以对合约进行适当的扩展和增补。由于public继承的派生类遵循了合约的规范,因此所有能用到基类的地方都可以用派生类来代替,但是这样就无法用到派生类对于基类的扩展特性了。

这个特点通常被称为“is-a”法则,一个派生类的实例也是一个基类的实例。但是我们在C++中对于“is-a”关系的阐释不能全凭直觉。例如,正方形是一种矩形嘛?如果它是,那么我们就可以尝试从Rectangle类中派生出Square类:

class Rectangle {
public:
	double Length() const { return length_; }
	double Width() const { return width_; }
	//...
private:
	double l_;
	double w_;
};
class Square : public Rectangle {
	//...
};

但是我们马上就能发现有点不对头,派生类的正方形拥有两个数据成员,而现实中的正方形并不需要,因为只需要知道一条边长即可确定一个正方形。这个看起来并不是很糟糕,但是考虑一下如果我们在矩形类的“合约中”提供了任意缩放的功能:

class Rectangle {
public:
	void Scale(double sl, double sw) { // 对长和宽按不同比例缩放
		length_ *= sl;
		width_ *= sw;
	}
//...
};

现在回头看,通过public继承而来的正方形也具有了这样的任意缩放性质。事实上,通过public继承,我们相当于承诺在能用矩形的地方都可以用正方形代替。很显然,这样的承诺条件并不能够被满足。当我们派生类的客户准备调整一个正方形的比例时,这种操作显然不合法。也许我们可以忽略这个调用,或者抛出运行时异常。但不论如何,我们都违背了“合约”的精神。对于C++来说,这种情况只有一种解决方案,那就是:正方形不是矩形。反之亦然,我们无法通过正方形能够提供的接口来派生出合法的矩形。

类似地,在C++中,如果鸟类具有飞行的接口,那么企鹅也不能算作鸟类。正确的设计应该是设计一个更抽象的Bird类,它不会保证提供飞或者不飞的接口。然后我们再创建中间的基类,如FlyingBirdFlightLessBird,这样我们就能正常地为Eagle或者Penguin这样的类实现派生。最重要的是,在C++中对于企鹅算不算鸟类,取决于我们如何定义鸟,或者说,从C++语言的角度上讲,取决于鸟类的公有接口是如何。

由于public继承隐含了“is-a”的关系,语言层面就允许我们对引用和指针进行宽泛地转化。首先,派生类的指针是可以隐式转换为基类指针的,对于引用也是如此:

class Base { ... };
class Derived : public Base { ... };
Derived* d = new Derived;
Base* b = d; // 隐式转换

这样的转换总是合法的,因为派生类的实例(public继承)一定是基类的实例。反之则不一定,如果要转换必须显式转换:

Base* b = new Derived; // *b 是指向派生类的
Derived* d = b; // 无法通过编译
Derived* d = static_cast<Derived*>(b); // 显式强制转换

这样的转换无法通过隐式方式进行的原因是,如果要让这样的转换行为合法,那么基类的指针必须真实地指向一个派生类对象,否则这将会是未定义行为。作为程序员,必须显式地断言,使用static_cast,从逻辑上表示出程序员对于当前进行的转换是有意而为之并且确认合法的。如果不确定这样的转换是否合法,我们也有其他的途径可以尝试,下一小节将会介绍这样的转换(dynamic_cast)。

C++的另一种类型的继承则是private继承。当我们声明一个private继承时,派生类并不会对基类的公有接口进行扩展,相反,所有基类的方法都变成派生类的私有方法了。派生类的所有public接口,都要从零开始编写。这样一来,派生类的对象就不能顺理成章替代基类原有的位置了。派生类所能得到的则是基类的所有内部实现,即算法和数据成员。这种继承方式通常来说也被成为“has-a”模式,也就是派生类中“拥有”一个基类的实例。

对于私有继承的派生类而言,基类之于派生类的关系就如同数据成员之于类的关系。这种实现技巧被称为组合方式,一个对象由多个其他的对象组成,这些其他对象作为它的数据成员而存在。通常如果没有特别的其他原因,类的组合是比私有继承更最合适的方式。那么,还有什么其他的理由会用到私有继承呢?还是有一些可能性的。首先,在派生类中,可以通过using声明将一些基类的public成员方法重新暴露出去:

class Container : private std::vector<int> {
public:
	using std::vector<int>::size;
	//...
};

在某些罕见场景下这种用法可能会有奇效,但这也等价于类组合中使用inline的转发函数:

class Container {
private:
	std::vector<int> v_;
public:
	size_t size() const { return v_.size(); }
//...
};

其次,指向派生类的指针或引用可以被转换为基类指针或引用,但仅在派生类的成员函数中可用。到目前为止,我们还没看到使用私有继承的充足理由。当然,通常来说我们建议使用组合的方式设计类。但是接下来的两个理由却非常充分,任意一个都可以被作为使用私有继承的动机。

其中之一就是当我们对于对象大小比较敏感的时候,我们可以通过私有继承的方式缩小对象的尺寸。仅拥有方法而没有数据成员的基类并不罕见,这样的类没有数据成员,因此他也不应该占据空间。但是在C++中,这种类必须拥有非0的大小。因为语言要求任意两个不同的对象必须由不同且唯一的地址。一个典型的例子就是,当我们先后依次声明两个变量,那么第二个变量的地址就是第一个变量的地址加上第一个变量的大小:

int x; // 地址为 0xffff0000, sizeof x 为 4
int y; // 地址为 0xffff0004

为了避免处理0大小对象的难题,C++中一个空的对象至少需要占据1个字节的大小。如果一个类中拥有这样的空对象,他也会占据至少1个字节的空间(由于对第二个数据成员的布局字节填充可能会导致这个值变大)。这种情况是对内存的浪费,因为这个空间完全没有被任何东西使用。一方面,如果一个空类被当作基类,对象中的基类成分不要求为非0值。对于派生类而言,整个对象的大小必须非0。这种情况下,派生类对象的地址,派生类中基类对象的地址以及派生类首个数据成员的地址三者可以为同一地址。因此,在C++中不给空基类分配内存其实是合法的,尽管sizeof这个空基类的大小是1字节。因此,大多数现代编译器都会做如下的优化:

class Empty {
public:
	void useful_function();
};
class Derived : private Empty {
    int i;
}; // sizeof(Derived) == 4 私有继承仅占据4字节
class Composed {
	int i;
	Empty e;
}; // sizeof(Composed) == 8 类组合占据8字节

如果应用场景中需要创造大量这样的派生类对象,那么通过私有继承方式可以节省下大量的内存空间。另一个使用私有继承的原因可能和虚函数有关,我们将在下一节中介绍。

3 多态与虚函数

我们讨论public继承时提到了,派生类可以在任何期望可以用到基类的地方做替换。即使存在这个要求,知晓实际对象的真实类型也是很有用的:

Derived d;
Base& b = d;
//...
b.some_method(); // b其实是派生类

some_method()是基类提供的public接口的一部分,因此在派生类中也必须有效。但是在基类接口提供的合约的灵活性规范内,我们可以对它进行适当的修改。例如,之前我们做过对飞行类鸟类的类型派生设计。假设FlyingBird类提供了fly()接口,每一种派生自该类的鸟类都同样具有这样的接口。但是,老鹰和秃鹫飞行的方式略有不同,也就是说,老鹰和秃鹫对fly()的实现可以有差异。任何对于任意FlyingBird类调用fly()接口的代码可能会得到不同的效果:

class FlyingBird : public Bird {
public:
	virtual void fly(double speed, double direction) {
		//... 某个飞行速度,高度等 ...
	}
    //...
};

派生类继承了这个成员函数的声明和实现,如果这个实现满足了派生类的要求,那么就无需改动。但如果派生类需要修改实现,它可以通过覆写基类的实现来达成:

class Vulture : public FlyingBird {
public:
	virtual void fly(double speed, double direction) {
    //... move the bird but accumulate exhaustion if too fast ...
	}
};

当一个虚函数被调用的时候,C++的运行时系统就会判定对象的真实类型。通常,这类信息在编译时不可知,而必须在运行时决定:

void hunt(FlyingBird& b) {
b.fly(...); // Could be Vulture or Eagle
	...
};
Eagle e;
hunt(e); // hunt参数b的真实类型是Eagle, FlyingBird::fly() 被调用
Vulture v;
hunt(v); // hunt参数b的真实类型是Vulture, Vulture::fly() 被调用

这种在多个基类对象上调用同样方法但其运行结果不同的编程技巧,称为“运行时多态”。在C++中,对象要具有多态性质必须至少拥有一个虚函数,并且只有提供了虚函数的接口才能实现多态的特性。

显然,对于虚函数接口的声明和其派生类对其进行的覆写必须具有同样的形式,程序员调用了基类中的方法,但是实际运行了派生类中的版本。这种情况当且仅当基类和派生类对同一个虚函数拥有完全一致的名称,返回值和参数列表(有一个例外,当虚函数返回基类的指针或者引用时,派生类覆写虚函数的时候可以返回派生类的指针或引用)。

关于多态派生的一个非常常见的场景就是基类没有对虚函数的默认实现。例如,所有的鸟类都可以飞,但是他们有不同的速度,因此对于鸟类的基类来说,没有必要为他们设置一个默认的速度。在C++中,我们可以在基类中拒绝为虚函数提供实现。这样的函数我们称他为纯虚函数,任何包含纯虚函数的类都被称为抽象类:

class FlyingBirt {
public:
	virtual void fly(...) = 0; // 纯虚函数
};

抽象类仅仅定义了接口,实现接口的任务就落在了派生类的身上。如果一个派生类的父类包含了纯虚函数,那么任何在此派生链上的类都必须对纯虚函数进行实现。换言之,拥有纯虚函数的类无法被实例化。然而,我们可以拥有指向真实派生类对象的基类指针或引用。

C++语法中有一些小技巧,当我们覆写虚函数的时候,不要求明确写出virtual关键字。如果基类声明了一个虚函数,那么子类的同名、同参数的函数就自动具备了虚函数的属性,并且会覆盖基类的实现。注意,如果派生类中的同名函数参数列表与基类同名虚函数不同,那么这个函数不会覆盖任何东西,但是会遮蔽(shadow)基类中的同名函数。当程序员意图覆盖基类虚函数但没有抄对声明的时候,可能会导致很难发现的bug:

class Eagle : public FlyingBird {
public:
	virtual void fly(int speed, double direction);
};

这里的参数类型与基类中不相同。尽管Eagle中的fly也是虚函数,但是它并没有覆写基类的虚函数。而由于基类中的fly()是纯虚函数,这个bug可以被发现,因为纯虚函数没有被实现。然而,如果基类提供了fly()的实现,那么编译器就无法发现这个隐秘的bug。好在C++11中提供了override关键字,它可以极大地方便我们甄别出这类隐秘的bug,任何意图覆写基类的函数中都可以在声名时加入override关键词:

class Eagle : public FlyingBird {
public:
	void fly(int speed, double direction) override;
};

这里的virtual关键字是可选的,但是如果基类中不存在名为fly的虚函数,那么这样的代码也会触发编译错误。

虚函数的最常见用法就是通过公有继承进行派生。由于所有派生类对象都是基类对象(is-a关系),一个程序可以同时操作一堆派生自统一基类的不同派生类的集合,就好像他们是同一个类型一样。虚函数的覆写保证了对每个对象的处理都能按照正确的方式进行:

void MakeLoudBoom(std::vector<FlyingBird*> birds) {
	for (auto bird : birds) {
		bird->fly(...); // Same action, different results
	}
}

不过,即便是私有继承也可以利用到虚函数的特性。只不过它的用法比较晦涩,甚至比较罕见。毕竟,通过私有继承的派生类不能通过其基类指针对其访问(被指涉的私有基类是不可访问基类,此时将派生类指针转换为基类的操作会失败)。然而, 在一种场景下这种转换是允许的,就是在派生类的成员函数中。这就是通过私有继承基类的方法调用派生类方法的方式(感觉很别扭):

class Base {
public:
	virtual void f() { std::cout << "Base::f()" << std::endl; }
	void g() { f(); }
};
class Derived : private Base {
	virtual void f() { std::cout << "Derived::f()" << std::endl; }
	void h() { g(); }
};
Derived d;
d.h(); // 打印 Derived::f()

由于基类的public接口在派生类中是private属性,我们不能直接调用它。但我们可以通过派生类的另一个方法,如h()去调用基类中的接口。如果我们直接在h()中调用f()那么毫无疑问它调用的肯定是派生类的f()。相反,我们通过h()调用了基类中的g(),并让g()去调用基类中的f()。此时,我们可以看到,派生类中的虚函数f()正确地覆写了基类中的虚函数f(),在这个上下文中,我们通过h()调用到的基类f()会正确地调用派生类覆写后的版本。

前面的小节我们讨论过,类的组合通常比私有继承更好用,这里就是第二个使用私有继承的原因。当我们确实需要用到虚函数的多态性质,但又不想暴露public接口,那么只能使用私有继承来实现。

当我们不确定基类指针指涉对象的真实类型时,使用static_cast是危险的。但是我们可以利用dynamic_cast来帮助判断。当我们用尝试转换一个基类指针到派生类指针时,如果转换的类型正确,那么我们就可以得到一个正确的派生类指针;否则,dynamic_cast就会返回一个空指针:

class Base { ... };
class Derived : public Base { ... };
Base* b1 = new Derived; // 真的派生类
Base* b2 = new Base; // 不是派生类
Derived* d1 = dynamic_cast<Derived*>(b1); // 成功转换
Derived* d2 = dynamic_cast<Derived*>(b2); // 转换失败 d2 == nullptr

需要注意的是,当dynamic_cast作用在引用身上时,由于不存在所谓的“空引用”,如果转换失败,dynamic_cast就会抛出异常,通常是std::bad_cast

到目前为止,我们把自己局限在了一个基类的情况下。这样其实也有助于我们思考类的继承和派生关系,就好比一棵树的许多枝叶很根系。下面我们将要讨论多重继承的情况。

4 多重继承

在C++中,一个类可以从多个基类中派生而来。回到我们上面提到的鸟类,可以观察到一个问题,尽管可飞行的鸟类之间有许多相似之处,但是它们也与其他的可飞行动物由相似之处,即“可以飞行”的属性。由于飞行的属性并不局限于鸟类,我们也许希望把处理飞行相关的数据结构和算法移动到单独的基类之中。毫无疑问的是,老鹰是属于鸟类的,因此为了表达这种关系,我们可以使用两个基类来构造老鹰类:

class Eagle : public Bird, public FlyingAnimal {...};

在这个例子中,从两个基类的派生方式都是public继承,这就意味着派生类继承的接口必须满足两组“合约”。想象一下,如果两个接口都定义了同名的方法会怎样?如果这个同名方法不是虚函数,那么派生类对于该方法的调用是有歧义的,并且程序无法通过编译。如果该方法是虚函数,并且派生类对其进行了覆写,那么这样就不会产生歧义。此外,Eagle类也同时是Bird和FlyingAnimal了:

Eagle* e = new Eagle;
Bird* b = e;
FlyingAnimal* f = e;

这两种从派生类向基类进行的指针转换是允许的。反之,则必须显式调用dynamic_cast或者static_cast来进行。此外也有一个有趣的转换,如果一个指向FlyingAnimal的类同时也是Bird类,这个指针可以在两者之间互相转换吗?答案是肯定的,只是需要用dynamic_cast

Bird* b = new Eagle; // Eagle同时也是FlyingAnimal
FlyingAnimal* f = dynamic_cast<FlyingAnimal*>(b);

在这个上下文中,dynamic_cast有时也被称为交叉转换(cross-cast),即我们不在继承架构中向上或者向下转换(即在基类和派生类间),而是在同一个继承层次上互相转换。

多重继承是C++最被人所诟病的和讨厌的特性。网上看到的建议多数是过时的,因为当时的编译器对多重继承的优化和效率都非常低下。现如今,在现代编译器的加持之下,这已经不是个问题了。又有很多人说,多重继承让类的继承体系更难理解和分析。也许更准确的说法应该是,设计一个良好的多重继承体系并且精确地反映对象之间的不同属性之关系是很难的,因此一个设计糟糕的多重继承体系确实难以理解和分析。

这些问题主要发生在多重继承使用public继承的情况下。但是我们要知道,多重继承也可以通过私有继承的方式来实现的。也许多重继承中使用私有继承的理由相较于单继承场景下更加少,然而,对于多个空类的场景下,多重私有继承产生的开销优化效果就足够充分了:

class Empty1 {};
class Empty2 {};
class Derived : private Empty1, private Empty2 {
	int i;
}; // sizeof(Derived) == 4
class Composed {
	int i;
	Empty1 e1;
	Empty2 e2;
}; // sizeof(Composed) == 8

当一个派生类代表了一个由许多相互之间无关联且不重叠的属性组成的系统时,多重继承就尤为高效。在本书探索由C++表示的诸多设计模式时,我们会遇到上述例子类似的情况。

总结

尽管本章无意作为对类和对象使用的权威指南或者参考,我们仍然向读者介绍了在后续章节中会需要理解的例子和解释。由于我们的目标是探索C++的设计模式,本章的重点就聚焦在了如何正确地使用类和继承。我们对C++中不同的关系是如何表达的这一特点进行了深入探讨。借由这些特性,我们得以在设计模式中对不同组件的关系和交互进行良好的表达。

下一章我们会用同样的基调去理解C++中对于设计模式所需的模板的特性,这些特性有助于我们更好地理解后续的章节。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值