现代C++学习指南 继承和多态

2022/10/01

在前一章,我们学习了怎样将数据封装在一个类里,这一章我们将继续向前,学习数据和行为分散在不同类里的处理方式,领略面向对象的魅力。

概论

在很早我初学编程之时,很多老师讲面向对象时都是说它是将数据封装在内部,然后用成员函数访问数据。当时我也把这些记住了,也严格按照这样做了。但是在使用的过程中,我发现这和面向过程没什么区别,数据在里面和外面,对于一个类来说,影响只能说是微乎其微。直到后面对继承和多态有了更深入的理解和应用,我回过头来才发现面向对象的实质不是这些封装,而是利用这些封装实现继承和多态。如果说继承只是从手拉车到马车的转变的话,多态就是从马车到内燃车的跨越。

在实现数据的封装后,就会发现有很多同类型的数据,他们可能只有很少的差异,但是却把他们分开封装在不同的类里,这不合理,很容易想到的解决方案就是:那我再加一层抽象呢?在封装数据之前,先找出这类数据的共性,先提供第一轮的共性封装,再在第一轮封装的基础上完成特性封装,这种做法就称为继承。继承的主要目的就是实现代码复用。因为这些数据都有共性,行为都可以直接写在父类中,特性行为和数据再写在子类里,子类就可以用尽量少的代码量实现较大的功能。当然,这种抽象维度也只是一个小的跃升,体现出了面向对象中对代码复用的潜力。如果数据足够复杂,可以通过增加足够多的中间层来解决这种复杂度,但是随之而来的就是难于阅读的代码和无法想象的维护性,所以继承不是灵丹妙药,只能在一定程度上缓解问题的复杂度,过度滥用可能还会增加复杂度。

所以数据抽象这条路在继承上是走到头了,我们需要从另一个维度来考虑抽象的问题。在Python中,有一个广为流传的语录,一个对象只要能嘎嘎叫,那么就可以把它当作是鸭子类型。它的核心要义就是不看对象实际包含什么数据,只要能完成相应的动作,就可以把他们看成是一类数据。同样的道理,很多场合,我们需要控制的不是数据本身,而是对数据流转进行控制。因为成员函数才能控制数据,所以控制数据流转相当于是控制成员函数,也就是控制行为。这种以行为为抽象点的做法就是多态。多态摆脱了固有的数据分类方式,将类的适用范围扩展到了整个宇宙。所以多态的出现算是正式确定了近二十多年来面向对象的统治地位。当然它也不是毫无缺点,多态对数据的控制是松散的,假如没有严格的控制手段,可能让系统陷入瘫痪。

继承

上一节,我说过继承就是找出共性。那么什么是共性呢?共性就是无论什么场合,都适用的属性。人就是最简单的例子。一个人的名字在任何场景都是他的一种标识,那么名字就可以作为人这个类的一个共性,放在父类里。

class Person {
private:
	std::string name;

public:
	Person(const std::string name) :name{ name } {
		std::cout << "Create person = " << name << std::endl;
	}

	void introduction() {
		std::cout << "My name is " << name << std::endl;
	}

};

int main() {
	Person p{ "张三" };
	p.introduction();
}
// 输出
// Create person = 张三
// My name is 张三

这是很简单的类,运用的就是上一篇文章中所说的封装技术。现在我们的张三要去朋友家做客了,朋友家来了很多人,有熟悉的人,也有不熟悉的人。熟悉的人热情地叫他狗蛋,不熟悉的人还是叫他张三。然后问题出现了,狗蛋和张三都是指张三那个人,但是环境变了,对他的关注点也就变了,熟悉的环境关注点在他的绰号上,陌生的环境关注点在他的名字上。那么我们该再创建一个类来完成绰号这个环境下的封装吗?答案显然不是,这两个特性都是指的同一个人,他们应该统一在一个对象上。那么将这个特性放在Person类里吗?也不对!要始终记住,共性才能发在父类里,绰号显然称不上共性,它只在熟人圈里使用。既然两个单独的类也不行,放一起也不行,那应该怎么做呢?答案是继承。熟人圈是一种特定场景,所以也应该有个特性的类来描述这个场景下的人的性质。

// Person类不变
class Friends :public Person {
	std::string nickname;
public:
	Friends(const std::string name, const std::string nickname) :Person(name), nickname{ nickname }{
		std::cout << "Create person = " << name << "with nickname " << nickname << std::endl;
	}

	void hi() {
		std::cout << "My nickname is " << nickname << std::endl;
	}
};

int main() {
	Friends f{ "张三","狗蛋"};
	//陌生人
	f.introduction();
	//熟人
	f.hi();
}
// 输出
// Create person = 张三
// Create person = 张三with nickname 狗蛋
// My name is 张三
// My nickname is 狗蛋

从输出我们可以知道,几乎和Person类一样的结构,却完成了两个类的功能,这就是继承的威力。代码中有两个关键点

  1. 第1行Friends后面多出了:public Person这是一种继承语法,作用就是告诉编译器,FriendsPerson是有共性的。它的出现使得我们不再需要定义自己的introduction成员函数。
  2. 第5行,成员初始化列表中,我用类名加实参的形式初始化了Person中的name成员变量,为的是继承来的introduction成员函数能按预期运行。这种形式称之为委托构造

继承可以有效解决代码复用的问题,使得每个类都只需要关注自身特性的管理上,实现较好的单一原则。但是也不难发现他的弱点,从共性到特性的过渡并不总是那么完美,同时随着继承链的增长,类之间的依赖更加复杂和不可控,这使得修改父类的代价越来越大,甚至会对子类造成破坏性的更改,引发不可控的BUG。

虚函数

有了继承之后,很自然的一个需求就是改变父类的某个行为,这个需求和构造父类时改变父类成员变量一样自然。而改变父类行为的方式就是虚函数。虚函数也是成员变量,但是它有个特殊的功能,允许子类定义一个和父类成员函数名称,参数列表,返回值完全一样的函数。当子类的某个对象调用这个方法后,实际执行的就会是子类定义的成员函数,从而改变达到改变父类行为的目的。

要实现修改父类行为的目的,有两个步骤:父类定义,子类修改。

父类定义的关键点就是为父类的成员函数打上virtual的标签。

class Person {
protected:
	std::string name;

public:
	Person(const std::string name) :name{ name } {
		std::cout << "Create person = " << name << std::endl;
	}

	virtual void introduction() {
		std::cout << "My name is " << name << std::endl;
	}

};
  1. 第2行我们将private换成了protected,代表从这一行开始到下一个权限限定符为止,里面定义的成员子类都可以访问。也就是说name可以在子类中直接使用。
  2. 第10行,我们在成员函数的函数头里加入了virtual关键字,为的就是标识introduction这个成员函数是虚函数,其函数体定义的行为可以被子类修改。

我们再来看,子类修改:子类修改的关键就是定义一个和父类要修改的那个成员函数完全一样的函数,然后写自己的逻辑。此例中就是在子类中定义一个和introduction一模一样的成员函数。

class Friends :public Person {
	std::string nickname;
public:
	Friends(const std::string name, const std::string nickname) :Person(name), nickname{ nickname }{
		std::cout << "Create person = " << name << "with nickname " << nickname << std::endl;
	}

	void hi() {
		std::cout << "My nickname is " << nickname << std::endl;
	}

    void introduction() override{
        std::cout << "From Friends -> my name is " << name << std::endl;
    }
};

这个定义和前面例子相比,就多了一个introduction的定义,并且在函数声明的最后还有个override的标识。此标识就是告诉编译器,当前定义的成员函数来自于父类,并非想重新定义一个新的,需要编译器保证父类存在一个一模一样(或者更宽泛)的成员函数。

来看看使用Friends对象会发生什么情况

int main() {
	Friends f{ "张三","狗蛋"};
	//陌生人
	f.introduction();
	//熟人
	f.hi();
    
    return 0;
}

// 输出
// Create person = 张三
// Create person = 张三with nickname 狗蛋
// From Friends -> my name is 张三
// My nickname is 狗蛋

从输出不难看出,通过Friends对象调用introduction函数时,函数的行为已经被修改了,这种子类修改父类行为的方式,我们称之为重写。

重写(override)和重载(overload)的区别:

  • 重载和重写都要求函数名相同,但是除了函数名外,确定函数的还有参数列表和返回值。重载要求三者完全一样,重载要求参数列表不一样,对返回值没有要求。
  • 重写只能存在于有继承关系的子类中,用于改变父类的行为。
  • 重载只要是在同一个作用域中,函数名相同,参数列表不同就都属于。

多态

多态的基础也是继承,但是多态的继承关注点集中在了** **行为继承上。首先对于父类来说,它的主要功能就是规定可以执行什么动作,而不提供完成这项功能的数据。如拿几何图形来说,它们都能计算周长和面积。但是长方形和圆的周长面积计算方式却不一样,所以不能用父类来存储计算需要用到的基本字段,而是需要子类来自己定义。但是父类却能规定子类需要可以计算周长和面积才能继承自它。在使用这些类的时候,通常只使用类的动作来完成任务,而不再依赖它保存的数据。

// 几何图形,规定继承自它的子类需要完成的动作
class Shape {
public:
    // 计算面积的动作
	virtual float area() { return 0; }

    // 计算周长的动作
	virtual float perimeter() { return 0; }
};

class Rect :public Shape {
	float x, y;
public:

	Rect(float x, float y) :x{ x }, y{ y } {
	}

	float area() override {
		return x * y;
	}

	float perimeter() override {
		return 2 * (x + y);
	}

};

void calc(Shape& shape) {
	cout << "Area of this shape is " << shape.area() << endl;
	cout << "Perimeter of this shape is " << shape.perimeter() << endl;
}

int main() {
	Rect r{ 2,3 };
	calc(r);
  
	return 0;
}

// 输出
// Area of this shape is 6
// Perimeter of this shape is 10

上例中,calc是定义的一种计算过程,但是我们没有使用具体的类,只是用了父类Shape。多态的存在可以保证在运行这个算法时运行到具体(Rect)的虚函数(area,perimeter)。这就是多态的含义,继承自同一个父类的多个子类,在以指针或者引用对象调用虚函数的时候,调用的是子类的函数体,不是父类的函数体,也就是算法过程是一样,但是算法结果不一样。利用多态这种特性,可以在不修改核心算法的情况下很方便地增强软件的功能。

抽象类

其实上例中,Shape的主要目标是定义接口,并且保证子类都必须实现那些接口,同时还要阻止用户直接使用**Shape**创建对象,显然上面的例子没有达到这个目标,子类没有重写接口,程序也能正常工作,也可以直接创建Shape对象。所以我们需要一种新的特性来帮助我们实现这个目标,这个特性就是抽象类。

**抽象类的主要功能就是规定接口,并且阻止用户直接创建该类的对象。**其实现手段还是利用virtual关键字,只不过这次不光有virtual,还有= 0;。我们用这个思想来实现上一节的例子

// 几何图形,规定继承自它的子类需要完成的动作
class Shape {
public:
    // 计算面积的动作
	virtual float area() = 0;

    // 计算周长的动作
	virtual float perimeter() = 0;
};

// Rect,main函数,输出都不变

在这次改良版的代码中,我们的主要改变就是移除了虚函数的函数体,并用= 0;替代了。= 0;是一种固定写法,告诉编译器,这个虚函数是**纯虚函数,没有函数体,并且只要是继承自这个类的子类,必须实现这个虚函数。**同时存在纯虚函数的类也自动升级了,变成了抽象类,抽象类就可以保证用户不能直接创建该类对象,使用它的唯一方式就是继承它。

One more thing——虚析构函数

之前探讨过,析构函数是做数据清理的。但是在有虚函数的类中情况稍有不同。在使用虚函数时,我们通常使用抽象类来实现多态行为,但抽象类不能创建对象,那么这个实际对象肯定是来自某个子类。当我们使用完这个对象后,可能需要将它占用的空间通过delete操作符清理掉。那么问题就出现了:对象的调用类型是父类,实际类型是子类,所以调用delete清理的时候,调用形式就是清理父类,而实际我们的目的是清理子类。所以我们需要一种方案,从父类指针调用到子类函数,这恰恰就是虚函数的目标呀,所以解决方案也就呼之欲出,将父类的析构函数也定义为虚函数,也就是虚析构函数,它和普通的析构函数一样,能够保证delete清理对象时能清理到正确的目标对象。

什么时候使用虚函数?

在前一篇中,我们主要聊的是具体类,它能较方便地完成数据封装和有些功能扩展,但是不适合用来扩展。这一篇主要聊的是扩展能力。所以使用它们需要区分业务。

假如我们只需要一个类来提供一些能力,则可以直接使用具体类的思路封装,简单快捷,可靠稳定。而假如需要对数据或者行为进行抽象,则就需要使用到虚函数,使用虚函数虽然方便,但是并非没有代价。

其一,虚函数的功力需要通过指针或者引用才能发挥出来,而用指针和引用调用成员函数时,有时间和空间的代价。

  1. 指针和引用需要首先解引用才能找到真正的对象
  2. 找到真正对象后,虚函数还需要通过虚指针查找到真正的虚函数调用地址
  3. 根据查找函数地址调用函数。

这里相比普通的成员函数调用多了前面的两个步骤,同时还多了虚函数表和虚指针的空间开销。所以虚函数是比普通函数慢一点的。

其二,虚函数容易出错。虚函数的某些行为是子类确定的,这些子类就像黑盒一样,假如这些行为没有经过良好的测试,就会引发不可预知的BUG,且不太容易定位到问题。

总结

继承和多态是很好的能力,但是相比于具体类,其影响的范围更宽更广了。在需要扩展能力的业务中,它们能发挥很好的作用,减少代码量,降低理解难度,但是使用它们也同样有空间和时间的代价,所以使用它需要视业务而定。同时不要忘了定义虚析构函数,保证对象能被正确清理。

继承和多态都是抽象的语言解决方案,它并不是灵丹妙药,而是依赖开发者对问题的拆解,和对问题的抽象化转化,语言只能提供良好抽象的正确性保证。所以当代码出现问题时,我们可以先从实际问题入手,从抽象入手,也许问题就出现在抽象的过程中。