C++ Primer学习笔记33_面向对象编程(4)--虚函数与多态(一):多态、派生类重定义、虚函数的访问、 . 和->的区别、虚析构函数、object slicing与虚函数
一、多态
多态可以简单地概括为“一个接口,多种方法”,前面讲过的重载就是一种简单的多态,一个函数名(调用接口)对应着几个不同的函数原型(方法)。
更通俗的说,多态行是指同一个操作作用于不同的对象就会产生不同的响应。或者说,多态性是指发出同样的消息被不同类型的对象接收时有可能导致完全不同的行为。
多态行分为静态多态性和动态多态性,其中函数重载和运算符重载属于静态多态性,虚函数属于动态多态性。
C++是依靠虚函数来实现动态多态性的。
多态的实现:
函数重载
运算符重载
模板
虚函数
1、静态联编(绑定)与动态联编(绑定)
程序调用函数时,具体应使用哪个代码块是由编译器决定的。
(1)静态联编(绑定)
绑定过程出现在编译阶段,在编译期就已确定要调用的函数。
(2)动态联编(绑定)
绑定过程工作在程序运行时执行,在程序运行时才确定将要调用的函数。
C++通过虚函数来实现动态联编(绑定)。
二、虚函数
虚函数的概念:在基类中的成员函数前加上一个关键字 virtual
如果一个函数在基类中被声明为虚函数,则他在所有派生类中都是虚函数。即使省略了virtual关键字,也仍然是虚函数。
虚函数的定义:
virtual 函数类型 函数名称(参数列表);
只有通过基类指针或引用调用虚函数才能引发动态绑定,包括通过基类指针的反引用调用虚函数,因为反引用一个指针将返回所指对象的引用。
虚函数不能声明为静态
常见的不能声明为虚函数的有:普通函数(非成员函数)、静态成员函数、构造函数、友元函数,而内联成员函数、赋值操作符重载函数即使声明为虚函数也无意义。
1、派生类重定义
派生类中可根据需要对虚函数进行重定义,重定义的格式有一定的要求:
(1)与基类的虚函数有相同的参数个数;
(2)与基类的虚函数有相同的参数类型;
(3)与基类的虚函数有相同的返回类型:或者与基类虚函数相同,或者都返回指针(或引用),并且派生类虚函数所返回的指针(或引用)类型是基类中被替换的虚函数所返回的指针(或引用)类型的子类型(派生类型)。
2、虚函数的访问
(1)通过对象访问:和普通函数一样,虚函数可以通过对象名来调用,此时编译器采用的时静态联编。通过对象名访问虚函数时,调用哪个类的函数取决于定义对象的类型。对象类型是基类时,就调用基类的函数;对象类型是子类时,就调用子类的函数。
(2)通过指针访问非虚函数:使用指针访问非虚函数时,编译器根据指针本身的类型决定要调用哪个函数,而不是根据指针指向的对象类型。
(3)通过指针访问虚函数:使用指针访问虚函数时,编译器根据指针所指向对象的类型决定要调用哪个函数(动态联编),而与指针本身的类型无关。
(4)通过引用访问虚函数:与使用指针访问虚函数类似,但代码的安全性高(详细看书),可以将引用理解成一种“受限制的指针”。
总结:C++中的函数调用默认不是用动态绑定。要触动动态绑定,需满足两个条件:
(1)第一,只有指定为虚函数的成员函数才能进行动态绑定。
(2)第二,必须通过基类类型的引用或指针进行函数调用。
【例1】调用一成员函数时,使用动态联编的情况是()
A、通过对象调用一虚函数
B、通过指针或引用调用一虚函数
C、通过对象调用静态函数
D、通过指针或引用调用一静态函数
解答:B
【例2】下述代码的输出结果是什么?
#include <iostream> using namespace std; class base { public: virtual void disp() {cout << "hello, base1" << endl;} void disp2() {cout << "hello, base2" << endl;} }; class child1: public base { public: void disp() {cout << "hello, child1" << endl;} void disp2() {cout << "hello, child2" << endl;} }; int main() { base* base = NULL; child1 obj_child1; base = &obj_child1; base->disp(); base->disp2(); return 0; }
解答:输出:
hello, child1
hello, child2
通过指针访问非虚函数:使用指针访问非虚函数时,编译器根据指针本身的类型决定要调用哪个函数,而不是根据指针指向的对象类型。
通过指针访问虚函数:使用指针访问虚函数时,编译器根据指针所指向对象的类型决定要调用哪个函数(动态联编),而与指针本身的类型无关。
【C++中类的方法的调用 . 和->的区别】
->是用在指针类型的类实例的,所以对于指针类型的类实例使用->是没问题的。对象则用.操作符。
【例3】构造函数为什么不能是虚函数?
解答:假设有如下代码:
#include <iostream> using namespace std; class A { public: A() {cout << "A()" <<endl;} }; class B: public A { public: B(): A() {cout << "B()" <<endl;} }; int main() { B b; B* pb = &b; }
则构造B类对象时:
(1)根据继承的性质,构造函数执行顺序是:
A()B()
(2)根据虚函数的性质,如果A的构造函数为虚函数,且B类也给出了构造函数,则应该只执行B类的构造函数,不再执行A类的构造函数。这样A就不构造了。
(3)这样(1)和(2)就发生了矛盾。
另外,virtual函数是在不同类型的对象产生不同的动作,现在对象还没有产生,如何使用virtual函数来实现你想完成的动作。
【例4】那些函数不能为虚函数?
解答:常见的不能声明为虚函数的有:普通函数(非成员函数)、静态成员函数、构造函数、友元函数,而内联成员函数、赋值操作符重载函数即使声明为虚函数也无意义。
(1)为什么C++不支持普通函数为虚函数?
普通函数(非成员函数)只能被overload(重载),不能被override(覆盖),声明为虚函数也没有什么意义,因此编译器会在编译时绑定函数。
(2)为什么C++不支持构造函数为虚函数?
上例已经给出答案。
(3)为什么C++不支持静态成员函数为虚函数?
静态成员函数对于每个类来说只有一份代码,所有的对象都共享这一份代码,它不归某个具体对象所有,所以它没有要动态绑定的必要性。
(4)为什么C++不支持友元函数为虚函数?
因为C++不支持友元函数的继承,没有实现为虚函数的必要。
内联函数:内联函数是为了在代码中直接展开,减少函数调用花费的代价,虚函数是为了在继承后,对象能够准确地执行自己的动作,这是不可能统一的。即使虚函数被声明为内联函数,编译器遇到这种情况根本不会把这样的函数内联展开,而是当做普通函数来处理。
赋值运算符:虽然可以在基类中将成员函数operator= 定义为虚函数,但这样做没有意义。赋值操作符重载函数要求形参与类本身类型相同,故基类中的赋值操作符形参类型为基类类型,即使声明为虚函数,也不能作为子类的赋值操作符。
【例5】以下描述正确的是()
A、虚函数是可以内联的,可以减少函数调用的开销提高效率
B、类里面可以同时存在函数名和参数都一样的虚函数和静态函数
C、父类的析构函数是非虚的,但是子类的析构函数是虚的,delete子类对象指针会调用父类的析构函数
D、以上都不对
解答:C。C中即使子类的析构函数不是虚的,对子类对象指针调用析构函数,也会调用父类的析构函数。但若delete父类对象指针却不会调用子类的析构函数,因为父类的析构函数不是虚函数,不执行动态绑定。
【例6】以下代码的输出结果是()。
#include <iostream> using namespace std; class B { public: B() { cout << "B constructor, " << endl; s = "B"; } void f() {cout << s << endl;} private: string s; }; class D: public B { public: D(): B() { cout << "D constructor, " << endl; s = "D"; } void f() {cout << s << endl;} private: string s; }; int main() { B* b = new D(); b->f(); ((D*)b)->f(); delete b; return 0; }
解答:输出结果是:
B constructor,
D constructor,
B
D
若在类B中的函数f前加上virtual关键字,则输出结果为
B constructor,
D constructor,
D
D
可见若函数不是虚函数,则不是动态绑定。
【例7】下列代码的输出结果是什么?
#include <iostream> using namespace std; class A { public: virtual void Fun(int number = 10) { cout << "A::Fun with number " << number << endl; } }; class B: public A { public: virtual void Fun(int number = 20) { cout << "B::Fun with number " << number << endl; } }; int main() { B b; A &a = b; a.Fun(); }
运行结果:
B::Fun with number 10
解释:B::Fun with number 10。虚函数动态绑定到B,但缺省实参是编译时候确定的10,而非20.
三、几道c++面试题
1. 来看一道出错的题:
#include <iostream> using namespace std; class A { public: virtual void test() { cout<<"A::test()"<<endl; } private: int i; }; class B: public A { public: void test() { cout<<"B::test()"<<endl; } private: int i; }; void f(A* p, int len) { for (int i = 0; i < len; i++) { p[i].test(); } } int main(void) { cout << sizeof(A) << endl; cout << sizeof(B) << endl; B b[3]; f(b, 3); return 0; }
运行结果:
8
12
B::test()
段错误 (核心已转储)
只会输出一次B::test() 然后就崩溃了,因为第二次按A的大小去跨越,找到的不是第二个B的首地址。
如果将B中的int i;一句注释掉,使得A和B大小都是8时,即程序可以正常运行。
而输出的是B::test() 是因为p[i] 可以看做指针的反引用,返回的是A对象的引用,故调用的是虚函数。
2. 再来看一道有些迷惑的题:
#include <iostream> using namespace std; class A { public: A() { Print(); } virtual void Print() { cout << "A is constructed." << endl; } }; class B: public A { public: B() { Print(); } virtual void Print() { cout << "B is constructed." << endl; } }; int main(void) { A *pA = new B(); delete pA; return 0; }
运行结果:
A is constructed.
B is constructed.
解释:先后打印出两行:A is constructed.
B is constructed. 调用B的构造函数时,先会调用B的基类A的构造函数。然后在A的构造函数里调用Print。由于此时实例的类型B的部分还没有构造好,本质上它只是A的一个实例,它的虚函数表指针指向的是类型A的虚函数表。因此此时调用的Print是A::Print,而不是B::Print。接着调用类型B的构造函数,并调用Print。此时已经开始构造B,因此此时调用的Print是B::Print。
同样是调用虚拟函数Print,我们发现在类型A的构造函数中,调用的是A::Print,在B的构造函数中,调用的是B::Print。因此虚函数在构造函数中,已经失去了虚函数的动态绑定特性。
3. 在普通成员函数里调用虚函数?
#include <iostream> using namespace std; class Base { public: void print() { doPrint(); } private: virtual void doPrint() { cout << "Base::doPrint" << endl; } }; class Derived : public Base { private: virtual void doPrint() { cout << "Derived::doPrint" << endl; } }; int main() { Base b; b.print(); Derived d; d.print(); return 0; }
运行结果:
Base::doPrint
Derived::doPrint
解释:在print中调用doPrint时,doPrint()的写法和this->doPrint()是等价的,因此将根据实际的类型调用对应的doPrint。所以结果是分别调用的是Base::doPrint和Derived::doPrint2。
四、构造函数和析构函数中的虚函数
1、概念
构造派生类对象时,首先运行基类构造函数初始化对象的基类部分。在执行基类构造函数时,对象的派生类部分是未初始化的。实际上,此时对象还不是一个派生类对象。
撤销派生类对象时,首先撤销它的派生类部分,然后按照与构造顺序的逆序撤销它的基类部分。
在这两种情况下,运行构造函数或析构函数时,对象都是不完整的。为了适应这种不完整,编译器将对象的类型视为在构造或析构期间发生了变化。在基类构造或析构函数中,将派生类对象当做基类类型对象对待。
【例子】以下哪些做法是不正确或是应该极力避免的()(多选)
A、构造函数声明为虚函数
B、派生关系中的基类析构函数声明为虚函数
C、构造函数调用虚函数
D、析构函数调用虚函数
解答:ACD。构造函数和析构函数是特殊的成员函数,在其中访问虚函数时,C++采用静态联编,即在构造函数或析构函数内,即使是使用“this->虚函数名”的形式来调用,编译器仍将其解释为静态联编的“本类名::虚函数名”,因而这样会与使用者的意图不符,应该尽量避免。
2、虚析构函数
何时需要虚析构函数?
当你可能通过基类指针删除派生类对象时
如果你打算允许其他人通过基类指针调用对象的析构函数(通过delete这样做是正常的),并且被析构的派生类对象是有重要的析构函数需要执行,就需要让基类的析构函数作为虚函数。
【例子】
#include <iostream> using namespace std; class Base { public: virtual void Fun1() { cout << "Base::Fun1 ..." << endl; } virtual void Fun2() { cout << "Base::Fun2 ..." << endl; } void Fun3() { cout << "Base::Fun3 ..." << endl; } Base() { cout << "Base ..." << endl; } // 如果一个类要做为多态基类,要将析构函数定义成虚函数 virtual ~Base() { cout << "~Base ..." << endl; } }; class Derived : public Base { public: /*virtual */ void Fun1() { cout << "Derived::Fun1 ..." << endl; } /*virtual */ void Fun2() { cout << "Derived::Fun2 ..." << endl; } void Fun3() { cout << "Derived::Fun3 ..." << endl; } Derived() { cout << "Derived ..." << endl; } /* virtual*/ ~Derived() //即使没有virtual修饰,也是虚函数 { cout << "~Derived ..." << endl; } }; int main(void) { Base *p; p = new Derived; p->Fun1(); delete p; //通过基类指针删除派生类对象 return 0; }
运行结果:
Base ...
Derived ...
Derived::Fun1 ...
~Derived ...
~Base ...
解释:即通过delete 基类指针删除了派生类对象(执行派生类析构函数),此时就好像delete 派生类指针 效果一样。如果基类析构函数没有声明为virtual,此时只会输出~Base。
五、object slicing与虚函数
首先看下图的继承体系:
#include <iostream> using namespace std; class CObject { public: virtual void Serialize() { cout << "CObject::Serialize ..." << endl; } }; class CDocument : public CObject { public: int data1_; void func() { cout << "CDocument::func ..." << endl; Serialize(); } virtual void Serialize() { cout << "CDocument::Serialize ..." << endl; } CDocument() { cout << "CDocument()" << endl; } ~CDocument() { cout << "~CDocument()" << endl; } CDocument(const CDocument &other) { data1_ = other.data1_; cout << "CDocument(const CDocument& other)" << endl; } }; class CMyDoc : public CDocument { public: int data2_; virtual void Serialize() { cout << "CMyDoc::Serialize ..." << endl; } }; int main(void) { CMyDoc mydoc; CMyDoc *pmydoc = new CMyDoc; cout << "#1 testing" << endl; mydoc.func(); cout << "#2 testing" << endl; ((CDocument *)(&mydoc))->func(); cout << "#3 testing" << endl; pmydoc->func(); cout << "#4 testing" << endl; ((CDocument)mydoc).func(); //mydoc对象强制转换为CDocument对象,向上转型 // 将派生类对象转化为了基类对象 //vptr 指向基类的虚函数表 delete pmydoc; return 0; }
运行结果:
CDocument()
CDocument()
#1 testing
CDocument::func ...
CMyDoc::Serialize ...
#2 testing
CDocument::func ...
CMyDoc::Serialize ...
#3 testing
CDocument::func ...
CMyDoc::Serialize ...
#4 testing
CDocument(const CDocument& other)
CDocument::func ...
CDocument::Serialize ...
~CDocument()
~CDocument()
~CDocument()
解释:由于Serialize是虚函数,根据this指针指向的真实对象,故前3个testing输出都是CMyDoc::Serialize ...但第4个testing中发生了Object Slicing,即对象切割,将CMyDoc对象转换成基类CDocument对象时,调用了CDocument类的拷贝构造函数,CMyDoc类的额外成员如data2_消失,成为完全一个CDocument对象,包括vptr
也指向基类的虚函数表,故输出的是CDocument::Serialize ...
此外还可以看到,调用了两次CDocument构造函数和一次CDocument 拷贝构造函数,CDocument析构函数被调用3次。
参考:
C++ primer 第四版
C++ primer 第五版
版权声明:本文为博主原创文章,未经博主允许不得转载。