你好,C++(37)上车的人请买票!6.3.3 用虚函数实现多态

时间:2023-03-10 04:43:12
你好,C++(37)上车的人请买票!6.3.3  用虚函数实现多态

6.3.3  用虚函数实现多态

在理解了面向对象的继承机制之后,我们知道了在大多数情况下派生类是基类的“一种”,就像“学生”是“人”类中的一种一样。既然“学生”是“人”的一种,那么在使用“人”这个概念的时候,这个“人”可以指的是“学生”,而“学生”也可以应用在“人”的场合。比如可以问“教室里有多少人”,实际上问的是“教室里有多少学生”。这种用基类指代派生类的关系反映到C++中,就是基类指针可以指向派生类的对象,而派生类的对象也可以当成基类对象使用。这样的解释对大家来说是不是很抽象呢?没关系,可以回想生活中经常遇到的一个场景:“上车的人请买票”。在这句话中,涉及一个类——人,以及它的一个动作——买票。但上车的人可能是老师、学生,也可能是工人、农民或者某个程序员,他们买票的方式也各不相同,有的投币,有的刷卡,可为什么售票员不说“上车的老师请刷卡买票”或者说“上车的工人请投币买票”,而仅仅说“上车的人请买票”就足够了呢?这是因为虽然上车的人可能是老师、学生、公司职员等,但他们都是“人”这个基类的派生类,所以这里就可以用基类“人”来指代所有派生类对象,通过基类的接口“买票”来调用派生类的对这个接口的具体实现来完成买票的具体动作。如图6-12所示。

你好,C++(37)上车的人请买票!6.3.3  用虚函数实现多态

图6-12  “上车的人请买票”

学习了前面的封装和继承,我们可以用C++把这个场景描述如下:

// “上车买票”演示程序

// 定义Human类,这个类有一个接口函数BuyTicket()表示买票的动作
class Human
{
// Human类的行为
public:
// 买票接口函数
void BuyTicket()
{
cout<<"人买票。"<<endl;
}
}; // 从“人”派生两个类,分别表示老师和学生
class Teacher : public Human
{
public:
// 对基类提供的接口函数重新定义,适应派生类的具体情况
void BuyTicket()
{
cout<<"老师投币买票。"<<endl;
}
}; class Student : public Human
{
public:
void BuyTicket()
{
cout<<"学生刷卡买票。"<<endl;
}
}; // 在主函数中模拟上车买票的场景
int main()
{
// 车上上来两个人,一个是老师,另一个是学生
// 基类指针指向派生类对象
Human* p1 = new Teacher();
Human* p2 = new Student();
// 上车的人请买票
p1->BuyTicket(); // 第一个人是老师,投币买票
p1->BuyTicket(); // 第二个人是学生,刷卡买票
// 销毁对象
delete p1;
delete p2;
p1 = p2 = nullptr; return ;
}

在这段代码中,我们先定义了一个基类Human,它有一个接口函数BuyTicket()表示“人”买票的动作。然后定义了它的两个派生类Teacher和Student,通过继承,这两个派生类本来已经直接拥有了BuyTicket()函数表示买票的行为,但是,“老师”和“学生”买票的行为是比较特殊的,所以我们又各自在派生类中对BuyTicket()函数作了重新定义以表达他们特殊的买票动作。在主函数中,我们模拟了“上车买票”这一场景:首先分别创建了Teacher和Student对象,并用基类Human的两个指针分别来指代这两个对象,然后通过Human类型的指针调用接口函数BuyTicket()函数来表达“上车的人请买票”的意思,完成Teacher和Student对象的买票动作。最后,程序的输出结果是:

人买票。

人买票。

细心的你一定已经注意到一件奇怪的问题:虽然Teacher和Student都各自重新定义了表示买票动作的BuyTicket()函数,虽然基类的指针指向的实际是派生类的对象,可是在用基类的指针调用这个函数时,得到的动作却是相同的,都是来自基类的动作。这显然是不合适的。虽然都是“人买票”,但是不同的人应该有不同的买票方式,如果这个人是老师就投币买票,如果是学生就该刷卡买票。根据“人”所指代的具体对象不同动作也应该有所不同。为了解决这个问题,C++提供了虚函数(virtual function)的机制。在基类的函数声明前加上virtual关键字,这个函数就成为了虚函数,而派生类中对这个虚函数的重新定义,无论是否显式地添加了virtual关键字,也仍然是虚函数。在类中拥有虚函数的情况下,如果通过基类指针调用类中的虚函数,那将调用这个指针实际所指向的具体对象(可能是基类对象,也可能是派生类对象,根据运行时情况而定)的虚函数,而不再是像上面的例子那样,基类指针指向的是派生类的对象,调用的却是基类的函数,也就完美地解决了上面的问。像这种在派生类中利用虚函数对基类的成员函数进行重新定义,并在运行时刻根据实际的对象来决定调用哪一个函数的机制,被称为函数重写(override) 。

重载还是重写,这是一个问题!

在前面的5.3小节中,我们学习过函数的重载,而在这里,我们又学习了函数的重写。那么,这对都姓“重”的孪生兄弟有什么区别呢?如何辨认区分它们呢?

实际上,它们都是C++中对函数行为进行重新定义的一种方式,同时,它们重新定义的函数名都跟原来的相同,所以它们才都姓“重”,只是因为它们发生的时间和位置不同,这才产生了“重载”和“重写”的区别。

重载(overload)是一个编译时概念,它发生在代码的同一层级。它表示在代码的同一层级(同一名字空间或者同一个类)中,一个函数因参数类型与个数不同可以有多个不同的实现。在编译时刻,编译器会根据函数调用的实际参数类型和个数来决定调用哪一个重载函数版本。

重写(override)是一个运行时概念,它发生在代码的不同层级(基类和派生类之间)。它表示在派生类中对基类中的虚函数进行重新定义,两者的函数名、参数类型和个数都完全相同,只是具体实现不同。而在运行时刻,如果是通过基类指针调用虚函数,它会根据这个指针实际指向的具体对象类型来选择调用基类或是派生类的重写函数。例如:

// 同一层级的两个同名函数因参数不同而形成重载
class Human
{
public:
virtual void Talk()
{
cout<<"Ahaa"<<endl;
} virtual void Talk(string msg)
{
cout<<msg<<endl;
}
}; // 不同层级的两个同名且参数相同的函数形成重写
class Baby : public Human
{
public:
virtual void Talk()
{
cout<<"Ma-Ma"<<endl;
}
}; int main()
{
Human MrChen;
// 根据参数的不同来决定具体调用的重载函数,在编译时刻决定
MrChen.Talk(); // 调用无参数的Talk()
MrChen.Talk("Balala"); // 调用以string为参数的Talk(string) Human* pBaby = new Baby();
// 根据指针指向的实际对象的不同来决定具体调用的重写函数,在运行时刻决定
pBaby->Talk(); // 调用Baby类的Talk()函数 delete pBaby;
pBaby = nullptr; return ;
}

在这个例子中,Human类当中的两个Talk()函数是重载函数,因为它们位于同一层级,拥有相同的函数名但是参数不同。而Baby类的Talk()函数则是对Human类的Talk()函数的重写了,因为它们位于不同层级(一个在基类,一个在派生类),但是函数名和参数都相同。可以记住这样一个简单的规则:相同层级不同参数是重载,不同层级相同参数是重写。

另外还需要注意的一点是,重载和重写的结合,会引起函数的隐藏(hide)。还是上面的例子:

Baby cici;
cici.Talk("Ba-Ba"); // 错误:Baby类中的Talk(string)函数被隐藏,无法调用

这样的结果是不是让人有点意外?本来,按照类的继承规则,Baby类也应该继承Human类的Talk(string)函数。然而,这里Baby类对Talk()函数的重写隐藏了从Human类继承的Talk(string)函数,所以才无法使用Baby类的对象直接调用基类的Talk(string)函数。一个曲线救国的方法是,可以通过基类的指针或类型转换,间接地实现对被隐藏函数的调用:

((Human)cici).Talk("Ba-Ba"); // 通过类型转换实现对被隐藏函数的调用

但是,值得告诫的是,不到万不得已,不要这样做。

我们在这里对重载和重写进行比较,其意义并不在于让我们去做一个名词辨析的考试题(虽然这种题目在考试或者面试中也非常常见),而在于让我们理解C++中有这样两种对函数进行重新定义的方式,从而可以让我们在合适的地方使用合适的方式,充分发挥用函数解决问题的灵活性。

现在,就可以用虚函数来解决上面例子中的奇怪问题,让通过Human基类指针调用的BuyTicket()函数,可以根据指针所指向的真实对象来选择不同的买票动作:

// 经过虚函数机制改写后的“上车买票”演示程序
// 定义Human类,提供公有接口
class Human
{
// Human类的行为
public:
// 在函数前添加virtual关键字,将BuyTicket()函数声明为虚函数,
// 表示其派生类可能对这个虚函数进行重新定义以满足其特殊需要
virtual void BuyTicket()
{
cout<<"人买票。"<<endl;
}
}; // 在派生类中对虚函数进行重新定义
class Teacher : public Human
{
public:
// 根据实际情况重新定义基类的虚函数以满足自己的特殊需要
// 不同的买票方式
virtual void BuyTicket()
{
cout<<"老师投币买票。"<<endl;
}
}; class Student : public Human
{
public:
// 不同的买票方式
virtual void BuyTicket()
{
cout<<"学生刷卡买票。"<<endl;
}
}; // …

虚函数机制的改写,只是在基类的BuyTicket()函数前加上了virtual关键字(派生类中的virtual关键字是可以省略的),使其成为了一个虚函数,其他代码没做任何修改,但是代码所执行的动作却发生了变化。Human基类的指针p1和p2对BuyTicket()函数的调用,不再执行基类的这个函数,而是根据这些指针在运行时刻所指向的真实对象类型来动态选择,指针指向哪个类型的对象就执行哪个类的BuyTicket()函数。例如,在执行“p1->BuyTicket()”语句的时候,p1指向的是一个Teacher类对象,那么这里执行的就是Teacher类的BuyTicket()函数,输出“老师投币买票”的内容。经过虚函数的改写,这个程序最后才输出符合实际的结果:

老师投币买票。

学生刷卡买票。

这里我们注意到,Human基类的BuyTicket()虚函数虽然定义了但从未被调用过。而这也恰好体现了虚函数“虚”的特征:虚函数是虚(virtual)的,不实在的,它只是提供一个公共的对外接口供派生类对其重写以提供更具体的服务,而一个基类的虚函数本身却很少被调用。更进一步地,我们还可以在虚函数声明后加上“= 0”的标记而不定义这个函数,从而把这个虚函数声明为纯虚函数。纯虚函数意味着基类不会实现这个虚函数,它的所有实现都留给其派生类去完成。在这里,Human基类中的BuyTicket()虚函数就从未被调用过,所以我们也可以把它声明为一个纯虚函数,也就相当于只是提供了一个“买票”动作的接口,而具体的买票方式则留给它的派生类去实现。例如:

// 使用纯虚函数BuyTicket()作为接口的Human类
class Human
{
// Human类的行为
public:
// 声明BuyTicket()函数为纯虚函数
// 在代码中,我们在函数声明后加上“= 0”来表示它是一个纯虚函数
virtual void BuyTicket() = ;
};

当类中有纯虚函数时,这个类就成为了一个抽象类(abstract class),它仅用作被继承的基类,向外界提供一致的公有接口。同普通类相比,抽象类的使用有一些特殊之处。首先,因为抽象类中包含有尚未完工的纯虚函数,所以不能创建抽象类的具体对象。如果试图创建一个抽象类的对象,将产生一个编译错误。例如:

// 编译错误,不能创建抽象类的对象
Human aHuman;

其次,如果某个类从抽象类派生,那么它必须实现其中的纯虚函数才能成为一个实体类,否则它将继续保持抽象类的特征,无法创建实体对象。例如:

class Student : public Human
{
public:
// 实现基类中的纯虚函数,让Student类成为一个实体类
virtual void BuyTicket()
{
cout<<"学生刷卡买票。"<<endl;
}
};

使用virtual关键字将普通函数修饰成虚函数以形成多态的很重要的一个应用是,我们通常用它修饰基类的析构函数而使其成为一个虚函数,以确保在利用基类指针释放派生类对象时,派生类的析构函数能够得到正确执行。例如:

class Human
{
public:
// 用virtual修饰的析构函数
virtual ~Human()
{
cout<<"销毁Human对象"<<endl;
}
}; class Student : public Human
{
public:
// 重写析构函数,完成特殊的销毁工作
virtual ~Student()
{
cout<<"销毁Student对象"<<endl;
}
}; // 将一个Human类型的指针,指向一个Student类型的对象
Human* pHuman = new Student(); // …
// 利用Human类型的指针,释放它指向的Student类型的对象
// 因为析构函数是虚函数,所以这个指针所指向的Student对象的析构函数会被调用,
// 否则,会错误地调用Human类的析构函数
delete pHuman;
pHuman = nullptr;

最佳实践:不要在构造函数或析构函数中调用虚函数

我们知道,在基类的普通函数中,我们可以调用虚函数,而在执行的时候,它会根据具体的调用这个函数的对象而动态决定调用执行具体的某个派生类重写后的虚函数。这是C++多态机制的基本规则。然而,这个规则并不是放之四海皆准的。如果这个虚函数出现在基类的构造函数或者析构函数中,在创建或者销毁派生类对象时,它并不会如我们所愿地执行派生类重写后的虚函数,取而代之的是,它会直接执行这个基类自身的虚函数。换句话说,在基类构造或析构期间,虚函数是被禁止的。

为什么会有这么奇怪的行为?这是因为,在创建一个派生类的对象时,基类的构造函数是先于派生类的构造函数被执行的,如果我们在基类的构造函数中调用派生类重写的虚函数,而此时派生类对象尚未创建完成,其数据成员尚未被初始化,派生类的虚函数执行或多或少会涉及到它的数据成员,而对未初始化的数据成员进行访问,无疑是一场恶梦的开始。

在基类的析构函数中调用派生类的虚函数也存在相似的问题。基类的析构函数后于派生类的析构函数被执行,如果我们在基类的析构函数中调用派生类的虚函数,而此时派生类的数据成员已经被释放,如果虚函数中涉及对派生类已经释放的数据成员的访问,就成了未定义行为,后果自负。

为了阻止这些行为可能带来的危害,C++禁止了虚函数在构造函数和析构函数中的向下匹配。为了避免这种不一致的匹配规则所带来的歧义(你以为它会像普通函数中的虚函数一样,调用派生类的虚函数,而实际上它调用的却是基类自身的虚函数),最好的方法就是,不要在基类的构造函数和析构函数中调用虚函数。永绝后患!

当我们在派生类中重写基类的某个虚函数对其行为进行重新定义时,并不需要显式地使用virtual关键字来说明这是一个虚函数重写,只需要派生类和基类的两个函数的声明相同即可。例如上面例子中的Teacher类重写了Human类的BuyTicket()虚函数,其函数声明中的virtual关键字就是可选的。无须添加virtual关键字的虚函数重写虽然简便,但是却很容易让人晕头转向。因为如果派生类的重写虚函数之前没有virtual关键字,会让人对代码的真实意图产生疑问:这到底是一个普通的成员函数还是虚函数重写?这个函数是从基类继承而来的还是派生类新添加的?这些疑问在一定程度上影响了代码的可读性以及可维护性。所以,虽然在语法上不是必要的,但为了代码的可读性和可维护性,我们最好还是在派生类的虚函数前加上virtual关键字。

为了让代码的意义更加明晰,在 C++中,我们可以使用 override关键字来修饰一个重写的虚函数,从而让程序员可以在代码中更加清晰地表达自己对虚函数重写的实现意图,增加代码的可读性。例如:

class Student : public Human
{
public:
// 虽然没有virtual关键字,
// 但是override关键字一目了然地表明,这就是一个重写的虚函数
void BuyTicket() override
{
cout<<"学生刷卡买票。"<<endl;
}
// 错误:基类中没有DoHomework()这个虚函数,不能形成虚函数重写
void DoHomework() override
{
cout<<"完成家庭作业。"<<endl;
}
};

从这里可以看到,override关键字仅能对派生类重写的虚函数进行修饰,表达程序员的实现意图,而不能对普通成员函数进行修饰以形成重写。上面例子中的 DoHomework() 函数并没有基类的同名虚函数可供重写,所以添加在其后的 override关键字会引起一个编译错误。如果希望某个函数是虚函数重写,就在其函数声明后加上override关键字,这样可以很大程度地提高代码的可读性,同时也可以让代码严格符合程序员的意图。例如,程序员希望派生类的某个函数是虚函数重写而为其加上override修饰,编译器就会帮助检查是否能够真正形成虚函数重写,如果基类没有同名虚函数或者虚函数的函数形式不同无法形成重写,编译器会给出相应的错误提示信息,程序员可以根据这些信息作进一步的处理。

与override相对的,有的时候,我们还希望虚函数不被默认继承,阻止某个虚函数被派生类重写。在这种情况下,我们可以为虚函数加上 final 关键字来达到这个目的。例如:

// 学生类
class Student : public Human
{
public:
// final关键字表示这就是这个虚函数的最终(final)实现,
// 不能够被派生类重写进行重新定义
virtual void BuyTicket() final
{
cout<<"学生刷卡买票。"<<endl;
}
// 新增加的一个虚函数
// 没有final关键字修饰的虚函数,派生类可以对其进行重写重新定义
virtual void DoHomework() override
{
cout<<"完成家庭作业。"<<endl;
}
}; // 小学生类
class Pupil : public Student
{
public:
// 错误:不能对基类中使用final修饰的虚函数进行重写
// 这里表达的意义是,无论是Student还是派生的Pupil,买票的方式都是一样的,
// 无需也不能通过虚函数重写对其行为进行重新定义
virtual void BuyTicket()
{
cout<<"学生刷卡买票。"<<endl;
} // 派生类对基类中没有final关键字修饰的虚函数进行重写
virtual void DoHomework() override
{
cout<<"小学生完成家庭作业。"<<endl;
}
};

既然虚函数的意义就是用来被重写以实现面向对象的多态机制,那么为什么我们还要使用final关键字来阻止虚函数重写的发生呢?任何事物都有其两面性,C++的虚函数重写也不例外。实际上,我们有很多正当的理由来阻止一个虚函数被它的派生类重写,其中最重要的一个理由就是这样做可以提高程序的性能。因为虚函数的调用需要查找类的虚函数表,如果程序中大量地使用了虚函数,那么将在虚函数的调用上浪费很多不必要的时间,从而影响程序性能。阻止不必要的虚函数重写,也就是减小了虚函数表的大小,自然也就减少了虚函数调用时的查表时间提高了程序性能。而这样做的另外一个理由是出于代码安全性的考虑,某些函数库出于扩展的需要,提供了一些虚函数作为接口供专业的程序员对其进行重写,从而对函数库的功能进行扩展。但是对于函数库的普通使用者而言,重写这些函数是非常危险的,因为知识或经验的不足很容易出错。所以有必要使用final关键字阻止这类重写的发生。

虚函数重写可以实现面向对象的多态机制,但过多的虚函数重写又会影响程序的性能,同时使得程序比较混乱。这时,我们就需要使用final关键字来阻止某些虚函数被无意义地重写,从而取得某种灵活性与性能之间的平衡。那么,什么时候该使用final而什么时候又不该使用呢?这里有一个简单的原则:如果某人重新定义了一个派生类并重写了基类的某个虚函数,那么会产生语义上的错误吗?如果会,则需要使用final关键字来阻止虚函数被重写。例如,上面例子中的Student有一个来自它的基类Human的虚函数 BuyTicker(),而当定义Student的派生类Pupil时,就不应该再重写这个虚函数了,因为无论是Student还是 Pupil,其BuyTicket()函数的行为应该是一样的,不需要重新定义。在这种情况下,就可以使用 final 关键字来阻止虚函数重写的发生。如果出于性能的要求,或者是我们只是简单地不希望虚函数被重写,通常,最好的做法就是在一开始的地方就不要让这个函数成为虚函数。

面向对象的多态机制为派生类修改基类的行为,并以一致的调用形式满足不同的需求提供了一种可能。合理利用多态机制,可以为程序开发带来更大的灵活性。

1. 接口统一,高度复用

应用程序不必为每个派生类编写具体的函数调用,只需要在基类中定义好接口,然后针对接口编写函数调用,而具体实现再留给派生类自己去处理。这样就可以“以不变应万变”,可以应对需求的不断变化(需求发生了变化,只需要修改派生类的具体实现,而对函数的调用不需要改变),从而大大提高程序的可复用性(针对接口的复用)。

2. 向后兼容,灵活扩展

派生类的行为可以通过基类的指针访问,可以很大程度上提高程序的可扩展性,因为一个基类的派生类可以很多,并且可以不断扩充。比如在上面的例子中,如果想要增加一种乘客类型,只需要添加一个Human的派生类,实现自己的BuyTicket()函数就可以了。在使用这个新创建的类的时候,无须修改程序代码中的调用形式。