1 . 多态的概念
1.1 概念
多态的概念:通俗来说,就是同一种行为的多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。
举个生活中的例子:比如你追你喜欢的女孩,怎么追也追不到,她对你爱答不理;但是另一个人追你喜欢的女孩,她却很热情,并且轻而易举得到了… 呜呜~~泪目了。不提了,学习!!!
2. 多态的定义及实现
2.1 多态讲解的前期知识铺垫
2.1.1 虚函数
虚函数:即被virtual修饰的类成员函数称为虚函数。虚函数会被存储在虚函数表中(后面会讲解)。
class A
{
public:
virtual void Print() //这就是一个虚函数
{
cout << " 我是A " << endl;
}
};
2.1.2 虚函数的重写
虚函数的重写(覆盖) 必须要有的条件:
(1) 派生类中这个虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同(函数体中的内容可以不同),简称三同。
(2) 向上转型的父类的指针或者引用调用这个虚函数,如果不是父类的指针或者引用调用,即使符合三同的条件也不会发生虚函数重写
总结:其实一句话就是只有构成多态时,才会发生虚函数的重写
代码示例:
//下面这段代码dog重写了animal的虚函数
class animal
{
public:
virtual void bark()
{
cout << "吼叫" << endl;
}
};
class dog : public animal
{
public:
virtual void bark()
{
cout << "汪汪~" << endl;
}
};
int main()
{
dog d;
animal* ani = &d;
ani->bark();
return 0;
}
2.1.3 虚函数重写的三个例外
- 协变(基类与派生类虚函数返回值类型不同)
重写时基类和派生类函数的返回值类型可以不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。(了解)
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;}
};
- 析构函数的重写(基类与派生类析构函数的名字不同)
当基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。
原因:在前边类和对象时已经讲过类的析构函数编译后析构函数的名称统一处理成destructor,所以实际上它们的名字仍是相同的,符合重写的条件。
思考:那么为什么要完成析构函数的重写的?
因为在某些情况下(经常发生在动态开辟类对象的空间的情况下),重写析构函数会防止资源泄露
class Person {
public:
virtual ~Person() {cout << "~Person()" << endl;}
};
class Student : public Person {
public:
virtual ~Student() { cout << "~Student()" << endl; }
};
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函
//数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;
delete p2;
return 0;
}
- 子类的虚函数可以不加virtual
在父类的虚函数加上virtual的情况下,子类的虚函数不加上virtual,并且符合重写的其他条件,照样可以实现重写。但是建议在实现重写时,子列和父类的虚函数都要加上virtual。
class animal
{
public:
virtual void bark()
{
cout << "吼叫" << endl;
}
};
class dog : public animal
{
public:
void bark() //照样可以实现多态,不信自己尝试一下
{
cout << "汪汪~" << endl;
}
};
2.2 多态的构成条件
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价。
那么在继承中要构成多态有两个必不可少的条件:
- 虚函数的重写——三同(函数名, 参数,返回值)
其中有三个例外会在后边讲解:
(1)协变 (2)子类的虚函数可以不加virtual (3)析构函数的重写 - 向上转型的父类指针或者引用去调用这个虚函数
示例代码:
#include<iostream>
using namespace std;
class Animal
{
public:
virtual void bark()
{
cout << "吼叫" << endl;
}
};
class Dog :public Animal
{
public:
virtual void bark()
{
cout << "汪汪~" << endl;
}
};
void test(Animal* animal)
{
animal->bark();
}
int main()
{
Dog dog;
test(&dog);
return 0;
}
运行结果:
2.3 再次理解继承及区分继承和重写、接口继承和实现继承
(1)首先大家先明白什么是实现继承和接口继承
(2)继承是一种实现继承,是把基类的成员原封不动的全部继承下来成为自己的东西,只有不过有自己的作用域,这个作用域的意义就是保证可以完全继承下来基类的成员,哪怕和子类的成员重名也不怕,因为可以构成隐藏(重定义)。对于成员变量的继承,想必大家都没什么疑惑,很简单,继承下来成为自己的成员就好。但是对于成员函数的继承却有一个细节值得大家关注,下面给大家讲解一下:
(3)虚函数的重写是一种接口继承
2.4 C++构造函数被禁止使用多态
C++的构造函数被禁止使用多态。假设我们有这么一段代码。其中,Foo是虚函数。
class Base
{
public:
Base()
{
Foo();
}
virtual void Foo() { std::cout << "Base::Foo"; }
};
class Derived : public Base
{
public:
Derived() : Base() {}
virtual void Foo() { std::cout << "Derived::Foo"; }
};
int main()
{
Derived* d = new Derived();
return 0;
}
你会发现打印出来的是Base::Foo而不是Derived::Foo,虚函数Foo并没有正确地执行运行时多态。
为什么C++要这么规定呢?
先下结论,这是为了避免调用虚函数的派生类版本的时候,使用了一些未被初始化的字段,从而引发崩溃。
假设Derived类是这么定义的:
class Derived : public Base
{
Derived() : Base() {}
virtual void Foo { std::cout << "Derived::Foo" << HelloStr; }
std::string HelloStr;
}
如果你对C++类构造函数的执行顺序有所了解,应该会知道,基类的构造函数是先于派生类构造函数执行的。也就是说假设构造函数中多态生效了,那么执行顺序是:
Base::Base()
Derived::Foo()
Derived::Derived()
执行Derived::Foo()的时候,其实派生类中的字段还未被初始化,这个时候去访问它,就可能会发生crash。
为此,C++直接取消了构造函数中的多态。
2.5 override和final
- final:修饰基类的虚函数,表示该虚函数不能再被重写。若修饰的是派生类的虚函数则不起任何作用,因为此时虚函数已经重写完毕。
class Animal
{
public: //注意final修饰的是基类的虚函数
virtual void bark() final //表明该虚函数不能被重写
{
cout << "吼叫" << endl;
}
};
class Dog :public Animal
{
public:
virtual void bark()
{
cout << "汪汪~" << endl;
}
};
- override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
class Animal
{
public:
virtual void bark()
{
cout << "吼叫" << endl;
}
};
class Dog :public Animal
{
public: //注意override检查的是派生类的虚函数是否重写
virtual void bark() override //检查该函数是否重写,如果没重写则报错
{
cout << "汪汪~" << endl;
}
2.6 重载、覆盖(重写)、隐藏(重定义)的总结区分
3. 抽象类
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象 ,派生类继承后也不能实例化出对象。
解决方案:只有继承抽象类的派生类重写纯虚函数,派生类才能实例化出对象,而且允许把重写了继承的抽象类的纯虚函数的类的指针或引用赋值给基类的指针或引用(因为抽象类无法实例化出对象,所以这里就不能赋值给基类的对象),从而实现多态。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
4. 多态的原理
4.1 虚函数表(Virtual Function Table)
首先大家计算一下这个类的大小是多大?
class Test
{
public:
virtual void func1()
{
cout << "func1" << endl;
}
};
int main()
{
Test t;
cout << sizeof(t);
return 0;
}
运行结果:
监视窗口:
在我们的认知范围内。这个类的大小应该是0。但是实际却是4,是为什么呢?通过调试观察。我们可以发现这个类内多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(Virtual Function Table Pointer)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表,。那么派生类中这个表放了些什么呢?我们接着往下分析:
总结:
- 总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
- 类的成员函数是虚函数的话,就会把虚函数的地址存储到类内虚表指针指向的虚表中,类对象调用虚函数均是向自己的虚表指针指向的虚表中去找到虚函数地址然后调用。
- 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr,VS系列编译器才有nullptr(有时会有bug不会出现,清理一下解决方案再运行就可以了),g++就没有。
- 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
- 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。
- 虚函数存在哪的?虚表存在哪的?
答:虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?实际我们去验证一下会发现vs下是存在代码段的,Linux g++下大家自己去验证? - 虚函数会存入虚表中,如何确定哪个虚函数在表内的哪个位置?
一般是根据声明顺序,先声明在前,后声明在后。如果有重写,就覆盖(新地址覆盖旧地址)。 - 子类对象赋值给父类对象发生切片,但不会对子类的虚表进行拷贝,赋值后父类对象中仍是父类的虚表,否则以后父类中虚表中是子是父就分不清了 。
4.2 多态的原理
那么多态的原理到底是什么呢?虚表的作用到底是怎么体现的呢?
4.3 动态绑定与静态绑定
(1) 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态。比如:函数重载,因为编译时,每个重载函数的地址根据函数名修饰规则,就已经确定,可以直接编译时确定函数的地址,所以是静态绑定。
(2) 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
5. 单继承和多继承中的虚函数表
5.1 单继承中的虚函数表
5.1.1 打印单继承中的虚函数表
从这里可以看出监视窗口是有一定的偏差的,所以有时监视窗口也是不可信的。所以我们要打印出虚表来一探究竟,那么该如何打印虚表呢?对于上面例子中的对象b和对象d,该如何打印出它们的虚表呢?
讲解板书:
5.1.2 单继承中派生类的虚表总结
根据一些测试现象可以得出单继承中派生类的虚函数表的生成情况如下:
(1)基类有虚表,子类没重写也没自己的虚函数的情况:将基类中的虚表内容拷贝一份到派生类虚表中,基类和子类共用一张完全相同的虚表
(2)基类有虚表,子类有重写或有自己的虚函数:先将基类中的虚表内容拷贝一份到派生类虚表中,若派生类重写了基类的虚函数或者有自己的虚函数,则会重写继承的表中的对应虚函数或者按声明顺序添加自己的虚函数到继承的虚表中,因为此时的虚表已经与原来继承的父类的虚表不同,所以此时指向的虚表其实属于自己的新的虚表(新的虚表就是对原虚表进行了覆盖或者添加虚函数)。
(3)基类没有虚表,子类有虚函数:此时子类会先继承父类的成员,然后在继承的所有父类成员后面,在自己原本就有的成员的那一部分 的最前面形成自己的虚函数指针,指向自己的虚表。
5.2 多继承中的虚函数表
5.2.1 多继承的虚表打印
看下面这个例子,d对象有两个虚表,那么func3到底是在哪个虚表当中呢?是不是多继承也是要打印出虚表才能正确观察多继承的情况。
那么该如何打印出d对象的虚表呢?
5.2.2 多继承的虚表总结
(1)基类有虚表,子类没重写也没自己的虚函数的情况:分别继承每一个基类的虚表,继承的每个虚表分别在对应继承基类部分的最前方(VS), 子类和基类共用几个完全相同的虚表
(2)基类有虚表,子类有重写:分别继承每一个基类的虚表,继承的每个虚表分别在对应继承基类部分的最前方(VS),若派生类重写了基类的虚函数,则会覆盖对应继承基类部分的虚表中的对应虚函数(若两个或多个基类都有func1函数,而派生类重写了func1函数,则在每个继承基类部分的虚表中覆盖func1),因为此时的虚表已经与原来继承的父类的虚表不同,所以此时指向的虚表其实属于自己的新的虚表(新的虚表就是对原虚表进行了覆盖)。
(3)基类有虚表,子类有自己的虚函数(没重写):分别继承每一个基类的虚表,继承的每个虚表分别在对应继承基类部分的最前方(VS),子类若有自己的虚函数,则会添加到第一个继承基类部分的虚函数表中,因为此时的虚表已经与原来继承的父类的虚表不同,所以此时指向的虚表其实属于自己的新的虚表(新的虚表就是对原虚表进行了添加虚函数)。
(4)派生类既有重写,又有自己的虚函数则是(2)(3)的叠加。
(5)基类没有虚表,子类有虚函数:此时子类会先继承父类的成员,然后在继承的所有父类成员后面,在自己原本就有的成员的那一部分 的最前面形成自己的虚函数指针,指向自己的虚表。
5.2.3 多继承的疑问解决
不知道大家发现一个问题没有:
原因:是为了要修正this指针的位置
5.3. 菱形继承、菱形虚拟继承
实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。一般我们也不需要研究清楚,因为实际中很少用。想研究的可以看这两篇文章。
6 . 典型的问题
- 什么是多态?答:参考本节课件内容
- 什么是重载、重写(覆盖)、重定义(隐藏)?
- 多态的实现原理?
- inline函数可以是虚函数吗?答:可以,不过编译器就忽略inline属性,这个函数就不再是inline,因为虚函数要放到虚表中去。
- 静态成员可以是虚函数吗?答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
- 构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
- 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?答:可以,并且最好把基类的析构函数定义成虚函数。
- 对象访问普通函数快还是虚函数更快?答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
- 虚函数表是在什么阶段生成的,存在哪的?答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。
- C++菱形继承的问题?虚继承的原理?答:注意这里不要把虚函数表和虚基表搞混了。
- 什么是抽象类?抽象类的作用?答:抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。
7.两道练习题
1.以下程序输出结果是什么()
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: 以上都不正确
正确答案:B
2.
正确答案:C