目录
1.什么是多态?
2.多态的定义与实现
①多态的构成条件
②虚函数
③虚函数的重写
1.协变
2.析构函数的重写
④final和override
1.final
2.override
⑤重载、重写(覆盖)与重定义(隐藏)的区别
3.什么是抽象类
4.多态的底层原理
①虚函数表
②多态的原理与底层
③动态绑定与静态绑定
5.继承关系中的虚表
①单继承中的虚表
②多继承中的虚表
6.一些继承与多态中的问题
①概念题
参考答案与解析:
1. A
2. D
3. C
4. A
5. B
6. D
7. D
8. A
9. C
10. B
②问答题
参考答案与解析:
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
1.什么是多态?
多态是面向对象编程中的一个概念,它允许不同的对象对同一个消息作出不同的响应。简单来说,多态是指同一种操作或方法可以在不同的对象上产生不同的行为。这种灵活性使得代码更加可扩展和可维护。在多态中,对象的类型可以是其父类或接口的类型,而实际执行的方法则是对象自身的方法。这样,通过多态性,我们可以在不改变代码的情况下,通过替换对象的类型来改变程序的行为。
举个例子,在买票时,同样是买票,不同的人买票有不同的结果,比如普通人去买得到的结果就是全价,而学生去买得到的结果就是半价,退伍军人去买则是免费。
2.多态的定义与实现
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了
Person,Person对象买票全价,Student对象买票半价。代码实现如下
class Person
{
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }
};class Student : public Person
{
public:virtual void BuyTicket() { cout << "买票-半价" << endl; }
};void Func1(Person& p)
{p.BuyTicket();
}void Func2(Person* p)
{p->BuyTicket();
}int main()
{Person ps;Student st;Func1(ps);Func1(st);Func2(&ps);Func2(&st);return 0;
}
①多态的构成条件
在继承中要构成多态需要两个条件:
1. 必须通过基类的指针或者引用调用虚函数
2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
②虚函数
虚函数:
即被virtual修饰的类成员函数称为虚函数。
如
class Person
{
public:virtual void BuyTicket() { cout << "买票-全价" << endl;}
};
中的BuyTicket函数
③虚函数的重写
虚函数的重写(覆盖):
派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
(注:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用)即在派生类中可以如此书写
void BuyTicket()
{ cout << "买票-半价" << endl;
}
但是在重写的时候有几个例外:
1.协变
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指
针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。举个例子
class A
{};class B : public A
{};class Person
{
public:virtual A* f() { return new A; }
};class Student : public Person
{
public:virtual B* f() { return new B;}
};
2.析构函数的重写
为什么需要对析构函数进行重写呢?我们先来看下面这个例子
class Person
{
public:virtual ~Person() { cout << "~Person()" << endl; }
};class Student : public Person
{
public:virtual ~Student() { cout << "~Student()" << endl; }
};int main()
{Person* p1 = new Person;Person* p2 = new Student;delete p1;delete p2;return 0;
}
在这里由于切片,p1和p2指针指向的都是基类这个部分,他指向的有可能是派生类的一部分,也有可能就是基类,那么在释放空间的时候就无法保证做到先析构派生类再析构基类。因此,只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。而为了构成多态,编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。此外,如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写。
④final和override
不难发现,C++对于重写的要求是比较严格的,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写:
1.final
修饰虚函数,表示该虚函数不能再被重写,意思也很明显,就是“最后”的意思,即这之后不能再对该函数进行重写
以下面的代码为例
class Car
{
public:virtual void Drive() final {}
};class Benz :public Car
{
public:virtual void Drive() { cout << "Benz-舒适" << endl; }
};
观察有
2.override
检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译则报错
以下面的代码为例
class Car
{
public:void Drive() {}
};class Benz :public Car
{
public:virtual void Drive() override { cout << "Benz-舒适" << endl; }
};
观察有
⑤重载、重写(覆盖)与重定义(隐藏)的区别
3.什么是抽象类
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。
以下面的代码为例
class Car
{
public:virtual void Drive() = 0;
};class Benz :public Car
{
public:virtual void Drive(){cout << "Benz-舒适" << endl;}
};class BMW :public Car
{
public:virtual void Drive(){cout << "BMW-操控" << endl;}
};void Test()
{Car* pBenz = new Benz;pBenz->Drive();Car* pBMW = new BMW;pBMW->Drive();
}int main()
{Test();return 0;
}
抽象类的设计强制重写虚函数,更加体现了接口封装这一特性(即只使用接口而不需要知道内部的操作)。
4.多态的底层原理
①虚函数表
在多态这一部分常常会考下面这类题
//问:sizeof(Base)?
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;
};
在32位平台下我们运行发现 那么这是为什么呢?我们经过调试可以发现
在b1这个对象中,除了_b这个成员外,还有一个_vfptr指针,这个指针我们称之为虚函数表指针(vf表示virtual function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表(注:不要与虚基表混淆)。我们再往后分析,既然基类有虚基表指针,那么它的派生类又如何呢?虚表与虚表指针有何作用?当函数变多时虚表中该如何存放?针对这些问题我们对上述代码作出一些改进
class Base
{
public:virtual void Func1(){cout << "Base::Func1()" << endl;}virtual void Func2(){cout << "Base::Func2()" << endl;}void Func3(){cout << "Base::Func3()" << endl;}
private:int _b = 1;
};class Derive : public Base
{
public:virtual void Func1(){cout << "Derive::Func1()" << endl;}
private:int _d = 2;
};int main()
{Base b;Derive d;return 0;
}
首先我们观察d,
可以发现
1. d中也有虚表指针,但是这个指针指向的虚表与父类不同,且可以看到在各自的虚表中
2. d对象的Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,因此虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法
3. 而Func2继承下来后是虚函数,因此放进了虚表,Func3也继承下来了,但不是虚函数,所以不会放进虚表,而放进代码段
4. 还可以看到_vfptr本质上是一个函数指针数组,一般而言会在最后一个位置放上nullptr
5. 在这里我们便可以对派生类生成的虚表做一个总结:
a.先将基类中的虚表内容拷贝一份到派生类虚表中
b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
在这里我们还要探究一个具体的问题,就是虚函数与虚表存放的位置是哪里?首先我们要明白,虚函数与一般的成员函数类似都是存放在代码段的,只是这些虚函数的指针被取出存放在了虚表中,那我们具体要如何验证呢?以以下代码为例
int main()
{Base b;Derive d;// 栈区int a = 0;printf("栈区:%p\n", &a);// 堆区int* pc = new int[10];printf("堆区:%p\n", pc);// 静态区static int c = 1;printf("静态区:%p\n", &c);// 常量区const char* ch = "hello world";printf("常量区:%p\n", ch);// 虚表// &d:取得Derive类对象d的地址//(int*)&d:将Derive类对象的地址强制转换为int* 类型的指针//*((int*)&d):对强制转换后的指针进行解引用操作,获取指针所指向的整数值,也就是对象中虚函数表的起始地址printf("d虚表地址:%p\n", *((int*)&d));printf("b虚表地址:%p\n", *((int*)&b));return 0;
}
在vs中运行可以发现
在g++运行有
那么我们基本可以确定虚表和虚函数类似,就存放在常量区(即代码段位置)
②多态的原理与底层
那我们分析了这么多,那多态到底是怎么做到传Student调用Student的函数,传Person调用Person的函数呢?以下列代码为例
class Person
{
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }
};class Student : public Person
{
public:virtual void BuyTicket() { cout << "买票-半价" << endl; }
};void Func(Person& p)
{p.BuyTicket();
}int main()
{Person Mike;Func(Mike);Student Johnson;Func(Johnson);return 0;
}
1. 观察上图的红色箭头我们看到,p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚函数是Person::BuyTicket。
2. 观察上图的蓝色箭头我们看到,p是指向johnson对象时,p->BuyTicket在johson的虚表中
找到虚函数是Student::BuyTicket。
3. 这样就实现出了不同对象去完成同一行为时,展现出不同的形态。
至此,我们就可以明确形成多态的条件:
1. 父类的指针或引用(注:不能是父类对象,因为如果使用切片拷贝,不会拷贝虚表,因为如果拷贝虚表的话,在其他地方使用父类时会产生bug)
2. 虚函数的重写
那么我们再来看看多态的底层,观察它到底是如何运行的,以以下代码为例
class Base
{
public:virtual void func1() { cout << "Base::func1" << endl; }
private:int b1;
};class Derive :public Base
{
public:virtual void func1() { cout << "Derive::func1" << endl; }
private:int d1;
};int main()
{Base b;Derive d;Base* ptr1 = &b;ptr1->func1();Base* ptr2 = &d;ptr2->func1();return 0;
}
可以看到
满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的。
③动态绑定与静态绑定
1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,如:函数重载
2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。如:虚函数的调用
5.继承关系中的虚表
①单继承中的虚表
我们以下列代码为例
class Base
{
public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }
private:int a;
};class Derive :public Base
{
public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; }virtual void func4() { cout << "Derive::func4" << endl; }
private:int b;
};int main()
{Base b;Derive d;return 0;
}
在调试模式中我们可以看到
可以看到,这里的d对象中只有func1与func2函数,可以认为这里出了一点小bug,我们用以下代码来验证
typedef void(*vfptr)();void PrintVTable(vfptr* vTable)
{// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数cout << " 虚表地址: " << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);vfptr f = vTable[i];f();}cout << endl;
}int main()
{Base b;Derive d;// 思路:取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质// 是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr// 1.先取d的地址,强转成一个int*的指针// 2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针// 3.再强转成vfptr*,因为虚表就是一个存vfptr类型(虚函数指针类型)的数组。// 4.虚表指针传递给PrintVTable进行打印虚表// 注:需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处// 理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需// 要点目录栏的 - 生成 - 清理解决方案,再编译就好了。vfptr* pd = (vfptr*)(*(int*)&d);vfptr* pb = (vfptr*)(*(int*)&b);PrintVTable(pb);PrintVTable(pd);return 0;
}
运行如下
可以发现,编译器确实是自动隐藏了Derive后面的几个虚函数
②多继承中的虚表
那么在知道了单继承的虚表后,多继承的虚表又是如何体现的呢?我们以以下代码为例
class Base1
{
public:virtual void func1() { cout << "Base1::func1" << endl; }virtual void func2() { cout << "Base1::func2" << endl; }
private:int b1;
};class Base2
{
public:virtual void func1() { cout << "Base2::func1" << endl; }virtual void func2() { cout << "Base2::func2" << endl; }
private:int b2;
};class Derive : public Base1, public Base2
{
public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; }
private:int d1;
};typedef void(*vfptr) ();
void PrintVTable(vfptr* vTable)
{cout << " 虚表地址:" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);vfptr f = vTable[i];f();}cout << endl;
}int main()
{Derive d;// 找到d中的Base1,打印它的虚表vfptr* vTableb1 = (vfptr*)(*(int*)&d);PrintVTable(vTableb1);// 找到d中的Base2,打印它的虚表vfptr* vTableb2 = (vfptr*)(*(int*)((char*)&d + sizeof(Base1)));PrintVTable(vTableb2);return 0;
}
运行结果如下
可以看出,多继承派生类的未重写的虚函数放在第一个继承基类部分的虚表中,而非放在两个虚表中
6.一些继承与多态中的问题
①概念题
1. 下面哪种面向对象的方法可以让你变得富有( )
A: 继承 B: 封装 C: 多态 D: 抽象
2. ( )是面向对象程序设计语言中的一种机制。这种机制实现了方法的定义与具体的对象无关,
而对方法的调用则可以关联于具体的对象。
A: 继承 B: 模板 C: 对象的自身引用 D: 动态绑定
3. 面向对象设计中的继承和组合,下面说法错误的是?()
A:继承允许我们覆盖重写父类的实现细节,父类的实现对于子类是可见的,是一种静态复
用,也称为白盒复用
B:组合的对象不需要关心各自的实现细节,之间的关系是在运行时候才确定的,是一种动
态复用,也称为黑盒复用
C:优先使用继承,而不是组合,是面向对象设计的第二原则
D:继承可以使子类能自动继承父类的接口,但在设计模式中认为这是一种破坏了父类的封
装性的表现
4. 以下关于纯虚函数的说法,正确的是( )
A:声明纯虚函数的类不能实例化对象 B:声明纯虚函数的类是虚基类
C:子类必须实现基类的纯虚函数 D:纯虚函数必须是空函数
5. 关于虚函数的描述正确的是( )
A:派生类的虚函数与基类的虚函数具有不同的参数个数和类型 B:内联函数不能是虚函数
C:派生类必须重新定义基类的虚函数 D:虚函数可以是一个static型的函数
6. 关于虚表说法正确的是( )
A:一个类只能有一张虚表
B:基类中有虚函数,如果子类中没有重写基类的虚函数,此时子类与基类共用同一张虚表
C:虚表是在运行期间动态生成的
D:一个类的不同对象共享该类的虚表
7. 假设A类中有虚函数,B继承自A,B重写A中的虚函数,也没有定义任何虚函数,则( )
A:A类对象的前4个字节存储虚表地址,B类对象前4个字节不是虚表地址
B:A类对象和B类对象前4个字节存储的都是虚基表的地址
C:A类对象和B类对象前4个字节存储的虚表地址相同
D:A类和B类虚表中虚函数个数相同,但A类和B类使用的不是同一张虚表
8. 下面程序输出结果是什么? ()
#include<iostream>using namespace std;class A
{
public:A(const char* s) { cout << s << endl; }~A() {}
};class B :virtual public A
{
public:B(const char* s1, const char* s2) :A(s1) { cout << s2 << endl; }
};class C :virtual public A
{
public:C(const char* s1, const char* s2) :A(s1) { cout << s2 << endl; }
};class D :public B, public C
{
public:D(const char* s1, const char* s2, const char* s3, const char* s4):B(s1, s2), C(s1, s3), A(s1){cout << s4 << endl;}
};int main()
{D* p = new D("class A", "class B", "class C", "class D");delete p;return 0;
}
A:class A class B class C class D B:class D class B class C class A
C:class D class C class B class A D:class A class C class B class D
9. 下面说法正确的是( )
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main()
{Derive d;Base1* p1 = &d;Base2* p2 = &d;Derive* p3 = &d;return 0;
}
A:p1 == p2 == p3 B:p1 < p2 < p3
C:p1 == p3 != p2 D:p1 != p2 != p3
10. 以下程序输出结果是什么()
class A
{
public:virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }virtual void test() { func(); }
};class B : public A
{
public:void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};int main(int argc, char* argv[])
{B* p = new B;p->test();return 0;
}
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确
参考答案与解析:
1. A
继承是在已有的基类上做出改进,进而产生派生类,因此可以变得富有
2. D
根据描述我们可以知道这里描述的就是多态,根据答案最贴切的就是D选项
3. C
优先使用组合,而不是继承,是面向对象设计的第二原则
4. A
B:声明纯虚函数的类是抽象类
C:子类可以不实现基类的纯虚函数
D:纯虚函数可以有具体的实现,也可以为空函数,具体实现取决于需求和设计。
5. B
B:内联函数在编译前就已经展开,无法再将此函数的地址存放在虚表中
A:派生类的虚函数与基类的虚函数不一定具有不同的参数个数和类型
C:派生类可以不重新定义基类的虚函数
D:虚函数有this指针,而static函数没有this指针,因此虚函数不能是static型的函数
6. D
A:一个类可能有多张虚表,如一个多继承类
B:就算不重写也会生成不同虚表,如下图所示
C:虚表是在编译期间动态生成的
// 代码示例:
class Base
{
public:virtual void func1() { cout << "Base::func1" << endl; };
private:int b;
};class Derive : public Base
{
public:virtual void func2() { cout << "Derive::func2" << endl; };
private:int d;
};int main()
{Base b;Derive d;return 0;
}
观察有
7. D
A:A类对象的前4个字节存储虚表地址,B类对象前4个字节也是是虚表地址
B:A类对象和B类对象前4个字节存储的都是虚表的地址
C:A类对象和B类对象前4个字节存储的虚表地址不同,因为B类对象重写了虚表
8. A
运行结果如下
理解:
在构造D类对象时,先构造B类与C类对象,而在构造B类与C类对象时,又会先构造A类对象,因此最后的结果如图所示
9. C
理解如图所示
首先由于切片,p1指向了d中的Base1的地址,p2指向了d中的Base2的地址,p3则直接指向d的地址,而由于继承关系,Base1的地址与d的首地址重合,因此最终可以得到p1 == p2 != p3
10. B
经过运行可以得到如下结果
理解:
这里是B类对A中的func函数进行了重写,但是重写仅仅只是重写了实现,即调用的是A类的func函数,因此val为1,实现是B类的func函数,因此最后显示B->
②问答题
1. 什么是多态?
2. 什么是重载、重写(覆盖)、重定义(隐藏)?
3. 多态的实现原理?
4. inline函数可以是虚函数吗?
5. 静态成员可以是虚函数吗?
6. 构造函数可以是虚函数吗?
7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
8. 对象访问普通函数快还是虚函数更快?
9. 虚函数表是在什么阶段生成的,存在哪的?
10. C++菱形继承的问题?虚继承的原理?
11. 什么是抽象类?抽象类的作用?
参考答案与解析:
1.
答:多态分为静态多态与动态多态。静态多态指的是函数重载,而动态多态指的是继承中派生类对虚函数的重写和父类指针(引用)的调用。
2.
答:重载是在同一作用域中,函数名相同,而参数或返回值不同;重写是在继承中,基类中某一函数为虚函数,而派生类中也有一个同名虚函数,且函数参数,返回值都与基类中的函数相同;重定义是在继承中,基类与派生类为同名函数,那么派生类就重定义了基类的函数。
3.
答:静态多态是根据C++进行编译时的函数名修饰规则形成不同的符号表来实现的,而动态多态是根据虚函数表来实现的。
4.
答:可以是虚函数,但是这个函数会默认地忽略掉inline的属性,因为如果要是inline函数会在编译时展开,这样就无法将虚函数存放到虚表中了。
5.
答:不能,因为静态函数没有this指针,在使用类::函数时,无法调用虚表,因此无法是虚函数。
6.
答:不能,因为虚表指针是在构造函数的初始化列表阶段产生的。
7.
答:可以,而且最好在基类中就将析构函数设置为虚函数,在如下场景Per为基类,Stu为子类,
Per* p = Stu s; delete p; 这里如果没有将析构函数设置为虚函数,那么最后会导致p不知道自己该调用哪个析构函数。
8.
答:如果是普通对象的调用,那么调用速度没有什么变化;如果是父类的指针(或引用)的调用,那么普通函数的调用速度更快,因为调用虚函数时会在虚函数表中去寻找。
9.
答:在编译阶段,正常情况存储在代码段(常量区)。
10.
答:菱形继承时会造成数据冗余和二义性;虚继承通过形成虚基表来存储偏移量,借此解决数据冗余的问题。
11.
答:抽象类是包含了纯虚函数在内的类;抽象类要求了在派生类中重写纯虚函数,体现出了接口继承关系。