在理解了面向对象的继承机制以后,咱们知道了在大多数状况下派生类是基类的“一种”,就像“学生”是“人”类中的一种同样。既然“学生”是“人”的一种,那么在使用“人”这个概念的时候,这个“人”能够指的是“学生”,而“学生”也能够应用在“人”的场合。好比能够问“教室里有多少人”,实际上问的是“教室里有多少学生”。这种用基类指代派生类的关系反映到C++中,就是基类指针能够指向派生类的对象,而派生类的对象也能够当成基类对象使用。这样的解释对你们来讲是否是很抽象呢?不要紧,能够回想生活中常常遇到的一个场景:“上车的人请买票”。在这句话中,涉及一个类——人,以及它的一个动做——买票。但上车的人多是老师、学生,也多是工人、农民或者某个程序员,他们买票的方式也各不相同,有的投币,有的刷卡,可为何售票员不说“上车的老师请刷卡买票”或者说“上车的工人请投币买票”,而仅仅说“上车的人请买票”就足够了呢?这是由于虽然上车的人多是老师、学生、公司职员等,但他们都是“人”这个基类的派生类,因此这里就能够用基类“人”来指代全部派生类对象,经过基类的接口“买票”来调用派生类的对这个接口的具体实现来完成买票的具体动做。如图6-12所示。程序员
图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 0; }
在这段代码中,咱们先定义了一个基类Human,它有一个接口函数BuyTicket()表示“人”买票的动做。而后定义了它的两个派生类Teacher和Student,经过继承,这两个派生类原本已经直接拥有了BuyTicket()函数表示买票的行为,可是,“老师”和“学生”买票的行为是比较特殊的,因此咱们又各自在派生类中对BuyTicket()函数做了从新定义以表达他们特殊的买票动做。在主函数中,咱们模拟了“上车买票”这一场景:首先分别建立了Teacher和Student对象,并用基类Human的两个指针分别来指代这两个对象,而后经过Human类型的指针调用接口函数BuyTicket()函数来表达“上车的人请买票”的意思,完成Teacher和Student对象的买票动做。最后,程序的输出结果是:ide
人买票。函数
人买票。性能
细心的你必定已经注意到一件奇怪的问题:虽然Teacher和Student都各自从新定义了表示买票动做的BuyTicket()函数,虽然基类的指针指向的实际是派生类的对象,但是在用基类的指针调用这个函数时,获得的动做倒是相同的,都是来自基类的动做。这显然是不合适的。虽然都是“人买票”,可是不一样的人应该有不一样的买票方式,若是这我的是老师就投币买票,若是是学生就该刷卡买票。根据“人”所指代的具体对象不一样动做也应该有所不一样。为了解决这个问题,C++提供了虚函数(virtual function)的机制。在基类的函数声明前加上virtual关键字,这个函数就成为了虚函数,而派生类中对这个虚函数的从新定义,不管是否显式地添加了virtual关键字,也仍然是虚函数。在类中拥有虚函数的状况下,若是经过基类指针调用类中的虚函数,那将调用这个指针实际所指向的具体对象(多是基类对象,也多是派生类对象,根据运行时状况而定)的虚函数,而再也不是像上面的例子那样,基类指针指向的是派生类的对象,调用的倒是基类的函数,也就完美地解决了上面的问。像这种在派生类中利用虚函数对基类的成员函数进行从新定义,并在运行时刻根据实际的对象来决定调用哪个函数的机制,被称为函数重写(override) 。学习
重载仍是重写,这是一个问题!spa
在前面的5.3小节中,咱们学习过函数的重载,而在这里,咱们又学习了函数的重写。那么,这对都姓“重”的孪生兄弟有什么区别呢?如何辨认区分它们呢?指针
实际上,它们都是C++中对函数行为进行从新定义的一种方式,同时,它们从新定义的函数名都跟原来的相同,因此它们才都姓“重”,只是由于它们发生的时间和位置不一样,这才产生了“重载”和“重写”的区别。code
重载(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 0; }
在这个例子中,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() = 0; };
当类中有纯虚函数时,这个类就成为了一个抽象类(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 关键字来阻止虚函数重写的发生。若是出于性能的要求,或者是咱们只是简单地不但愿虚函数被重写,一般,最好的作法就是在一开始的地方就不要让这个函数成为虚函数。
面向对象的多态机制为派生类修改基类的行为,并以一致的调用形式知足不一样的需求提供了一种可能。合理利用多态机制,能够为程序开发带来更大的灵活性。
应用程序没必要为每一个派生类编写具体的函数调用,只须要在基类中定义好接口,而后针对接口编写函数调用,而具体实现再留给派生类本身去处理。这样就能够“以不变应万变”,能够应对需求的不断变化(需求发生了变化,只须要修改派生类的具体实现,而对函数的调用不须要改变),从而大大提升程序的可复用性(针对接口的复用)。
派生类的行为能够经过基类的指针访问,能够很大程度上提升程序的可扩展性,由于一个基类的派生类能够不少,而且能够不断扩充。好比在上面的例子中,若是想要增长一种乘客类型,只须要添加一个Human的派生类,实现本身的BuyTicket()函数就能够了。在使用这个新建立的类的时候,无须修改程序代码中的调用形式。