多态
本章思维导图
注:本章思维导图对应的.xmind
和.png
文件都已同步导入至资源
写在前面:
本篇所有的测试代码都是在VS2019的x86环境下运行的,所涉及的指针都是4字节指针。不同环境可能会有所不同。
文章目录
1. 多态的概念
多态,即多种状态,简单点说就是不同的对象执行相同的动作时会得到不同的结果
例如,实际生活中买高铁票时,成人这类对象买票时,就需要全价买票;学生这类对象买票时就只需要买半价票。即两类不同的对象执行同一的买票操作得到的结果不同。
我么可以先看一段实现代码,之后再做具体分析:
class Person
{
public:
virtual void buy_ticket()
{
cout << "adult -> full price" << endl;
}
};
class Student : public Person
{
public:
virtual void buy_ticket()
{
cout << "student -> half price" << endl;
}
};
void func(Person* p)
{
p->buy_ticket();
}
int main()
{
Person per;
Student st;
func(&per);
func(&st);
return 0;
}
output:
adult -> full price
student -> half price
//可以看到,上面的代码做到了“传父类指针就调用父类的函数,传子类的指针就调用子类的函数”
//即实现了多态
2. 多态的实现
要实现多态,就需要达成两个条件:一个为基类函数和派生类函数要构成虚函数的重写,一个为必须是多态调用
2.1 虚函数的重写
2.1.1 虚函数
被关键字
virtual
修斯的成员函数就是虚函数
关于虚函数我们需要注意几点:
- 虚函数只能是类的成员函数
- 静态成员函数没有this指针,不是成员函数,因此不能成为虚函数
- 成员函数可以同时被
inline
和virtual
修饰,但是其并不会成为一个内联函数(没有内敛属性)
inline
只是一个建议- 虚函数的地址会存储到虚表中,但是编译时会对内联函数进行展开,内联函数没有地址
- 构造函数不能成为虚函数
2.1.2 何为重写
当基类的某个虚函数和派生类的某个虚函数的返回值、函数名、参数都相同时,就说这两个虚函数构成重写关系
注:基类的虚函数和派生类的虚函数发生协变时,返回值可以不同
例如:
class Person
{
public:
virtual void buy_ticket()
{
cout << "adult -> full price" << endl;
}
};
class Student : public Person
{
public:
virtual void buy_ticket()
{
cout << "student -> half price" << endl;
}
};
基类Person
的虚函数buy_ticket()
和子类Student
的虚函数buy_ticket()
返回值、函数名、参数都想同,因此构成重写关系
同时我们发现,所谓的重写,实际上重写的就是函数的实现,因此多态的重写实际上也叫做实现重写
2.2 多态调用
要实现多态调用,那就只能是基类的指针或引用调用虚函数,不满足上述条件的都是普通调用
例如:
class Person
{
public:
virtual void buy_ticket()
{
cout << "adult -> full price" << endl;
}
};
class Student : public Person
{
public:
virtual void buy_ticket()
{
cout << "student -> half price" << endl;
}
};
int main()
{
Person per;
Student st;
//多态调用
cout << "多态调用" << endl;
Person* ptr = nullptr;
ptr = &per;
ptr->buy_ticket();
ptr = &st;
ptr->buy_ticket();
//普通调用
cout << "普通调用" << endl;
per.buy_ticket();
per = st;
per.buy_ticket();
return 0;
}
output:
多态调用
adult -> full price
student -> half price
普通调用
adult -> full price
adult -> full price
从上面代码的结果,我么可以总结出普通调用和多态调用的不同:
- 多态调用只能是基类的指针或引用调用虚函数,多态调用的结果依据的是引用或指针指向对象的类型(指向父类调用父类虚函数,指向子类调用子类虚函数)
- 不满足多态调用就是普通调用,普通调用根据的是调用对象的类型,如果是父类的对象/指针/引用调用,那得到的就是父类的切片,调用的就是父类的函数,子类同理
2.3 多态的特殊情况
2.3.1 协变
当基类的某个虚函数和子类的虚函数构成重写关系时,其返回值可以不同。但是基类必须返回基类的指针(引用)同时派生类要返回派生类的指针(引用),此时成为协变
例如下面两种写法就是协变:
//基类返回基类的指针,派生类返回派生类的指针
class Person
{
public:
virtual Person* buy_ticket()
{
cout << "adult -> full price" << endl;
return this;
}
};
class Student : public Person
{
public:
virtual Student* buy_ticket()
{
cout << "student -> half price" << endl;
return this;
}
};
//或者基类返回基类的引用,派生类返回派生类的引用
class Person
{
public:
virtual Person& buy_ticket()
{
cout << "adult -> full price" << endl;
return *this;
}
};
class Student : public Person
{
public:
virtual Student& buy_ticket()
{
cout << "student -> half price" << endl;
return *this;
}
};
下面的几种写法就不是协变,是错误写法,编译报错
//错误示例一:基类返回基类对象,派生类返回派生类对象
class Person
{
public:
virtual Person buy_ticket()
{
cout << "adult -> full price" << endl;
return *this;
}
};
class Student : public Person
{
public:
virtual Student buy_ticket()
{
cout << "student -> half price" << endl;
return *this;
}
};
//错误示例二:基类返回基类对象引用,派生类返回派生类对象
class Person
{
public:
virtual Person& buy_ticket()
{
cout << "adult -> full price" << endl;
return *this;
}
};
class Student : public Person
{
public:
virtual Student buy_ticket()
{
cout << "student -> half price" << endl;
return *this;
}
};
2.3.2 关键字virtual的修饰
如果基类的某个虚函数和子类的某个虚函数构成重写关系,子类虚函数的virtual
可以隐藏不写
例如:
class Person
{
public:
virtual void buy_ticket()
{
cout << "adult -> full price" << endl;;
}
};
class Student : public Person
{
public:
void buy_ticket()
{
cout << "student -> half price" << endl;
}
};
//基类的buy_ticket()和子类的buy_ticket()仍构成重载
2.3.3 析构函数的重写
我们来看下面的代码:
class Person
{
public:
~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
~Student()
{
cout << "~Student()" << endl;
delete _per;
}
protected:
Person* _per;
};
int main()
{
Person* per = new Student;
delete per;
return 0;
}
output:
~Person()
我们这里用父类的指针指向了一个子类的对象,之后再delete
这个指针。可以发现,系统只调用了父类的析构函数。
- 这是可以理解的,因为
per
指向的实际上是子类中父类的切片,而这是一个普通调用,调用的自然就是父类的析构了 - 但是这就导致了一个问题——子类的析构没有调到,因此无法清理子类的资源,从而导致了内存泄漏
- 为了解决这个问题,我们应该让这一调用成为多态调用,即让父类和基类的析构函数构成重写关系,即让
virtual
修饰析构函数,让析构函数成为虚函数
class Person
{
public:
virtual ~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
~Student()
{
cout << "~Student()" << endl;
delete _per;
}
protected:
Person* _per;
};
int main()
{
Person* per = new Student;
delete per;
return 0;
}
output:
~Student()
~Person()
有小伙伴会问:父类和子类的析构函数的名字都不同,怎么构成重写关系?
其实不然,在编译阶段,编译器会将子类和父类的析构函数替换为同名函数destructor()
,这样加上关键字virtual
后就可以顺利地构成重写关系了
2.4 关键字final
一个问题:怎么让一个类不能被继承?
一种方法——将这个类的构造函数私有化
派生类的构造会调用基类的构造,如果基类的构造被私有化,那么基类的构造就在派生类中不可见,也就不能对基类的构造进行调用
class A { protected: int _a; private: A() { _a = 1; } };
另一种方法——用关键字final
修饰这个类
被关键字
final
修饰的类被称为最终类,最终类不能被继承class A final { protected: int _a = 1; };
关键字final
同时也能修饰虚函数,用来表示该虚函数不能被重写
class A
{
public:
virtual void func() final {}
protected:
int _a = 1;
};
class B : public A
{
public:
virtual void func()
{
cout << endl;
//编译报错:“A::func”: 声明为“final”的函数无法被“B::func”重写
}
};
2.5 关键字override
关键字override
用于加在派生类虚函数后,用于检查该虚函数是否完成了重写
class A
{
public:
virtual void func() {}
protected:
int _a = 1;
};
class B : public A
{
public:
virtual void func() override
{
cout << endl;
}
virtual void func1() override
{
//编译报错:“B::func1”: 包含重写说明符“override”的方法没有重写任何基类方法
}
};
3. 实现继承与接口继承
3.2 实现继承
普通函数的继承就是实现继承,实现继承继承的就是函数的实现,派生类继承基类函数后就可以直接进行使用
class A
{
public:
void func()
{
cout << "hello world" << endl;
}
};
class B : public A
{
};
int main()
{
B bb;
bb.func();
return 0;
}
3.2 接口继承
- 虚函数的继承就是接口继承,继承的是基类函数的接口而非实现
- 接口继承的目的是为了重写(重写函数的实现),从而实现多态
- 因此,如果不实现多态,就不要将成员函数设置为虚函数
class A
{
public:
virtual void func()
{
cout << "hello world" << endl;
}
};
class B : public A
{
public:
virtual void func()
{
cout << "nice to meet you" << endl;
}
};
接下来我们利用一道例题来对多态的接口继承和实现重写进行更加深刻的理解:
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; } //输出什么?
我们来进行分析:
- 子类B的指针p指向了一个子类对象,并通过指针p调用了函数
test()
- 函数
test()
为基类A的函数,并嵌套了函数func()
- 由于函数
func()
作用域在类A,因此实际上是这样调用的:this->func()
,this指针就是类A的this指针- 由于函数
func()
符合虚函数重写规则,且为基类的指针进行调用,因此这是一个多态调用- 需要注意,这一切都发生在派生类B中,因此父类指针this指向的实际上是子类B,即函数
func()
实际上是子类的func()
- 又由于虚函数的继承为接口继承,因此函数
func()
使用的接口为void func(int val = 1)
- 重写是实现重写,即重写为:
std::cout << "B->" << val << std::endl;
- 所以最终输出
B->1
4. 抽象类
4.1 纯虚函数
如果在虚函数的后面加上=0
,那么该虚函数就会变为纯虚函数:
class Car
{
public:
virtual void name() = 0
{
cout << "car" << endl;
}
};
4.2 抽象类
拥有纯虚函数的类就是抽象类,例如上面的类Car
就是一个抽象类。
- 抽象类不能实例化对象
- 抽象类强制了继承它的类要重写纯虚函数,否则这个派生类仍含有纯虚函数,仍旧是抽象类,不能实例化对象
class Car
{
public:
virtual void name() = 0
{
cout << "car" << endl;
}
};
class Benz : public Car
{
public:
virtual void name()
{
cout << "Benz" << endl;
}
};
5. 多态的原理
5.1 虚函数表指针和虚函数表
大家先来思考,下面这个类的大小会是多少:
class A
{
public:
virtual void func1() {}
virtual void func2() {}
protected:
int _a = 1;
};
int main()
{
cout << sizeof(A) << endl;
return 0;
}
output:
8
这和一开始我们认为的答案4
并不吻合。
其原因在于,如果一个类拥有虚函数,那么这个类就会多一个指针_vfptr
,称其为虚函数表指针(virtual function table pointer)
我么可以调试进行查看:
可以发现,虚函数表指针_vfptr
指向的这块区域存储着两个虚函数func1()
和func2()
的地址,我们称**_vfptr
指向的这块区域为虚函数表**,也简称为虚表,同时也可以发现,虚表实际上就是一个函数指针数组,存放着虚函数的地址,在VS2019中,虚函数表以NULL
结尾
那么虚表存放在哪呢?是栈区,还是堆区,还是常量区?我么可以利用比较法进行推导:
class A
{
public:
virtual void func1() {}
virtual void func2() {}
protected:
int _a;
};
int main()
{
A a;
int num = 1;
int* ptr = new int[3];
const char* str = "xxxxx";
static char ch = 'a';
printf("栈区: %p\n", &num);
printf("堆区: %p\n", ptr);
printf("静态区: %p\n", &ch);
printf("常量区: %p\n", str);
printf("虚表: %p\n", *(int*)&a);
return 0;
}
output:
栈区: 00AFFC9C
堆区: 00E6B7F0
静态区: 00D2A000
常量区: 00D27B40
虚表: 00D27B34
通过对比我们可以发现,在VS2019中,虚表应该存储在常量区
注:有的小伙伴可能对代码 *(int*)&a
不是很理解,下面来进行分析:
- 想要知到虚表存储在哪块区域,就需要知到虚表的地址
- 在VS2019中,虚表指针存放在类的最开始处,因此我们只需要对对象取地址,将其转换成4字节大小的指针
int*
,这样就得到了虚表指针的地址- 最后再对虚表指针的地址解引用,就得到了虚表指针,也就是虚表存放的地址
同时我们需要知到,虚表是在编译时就创建好了的,而虚函数表指针是在构造函数初始化的
同一个类实例化出的不同对象共用一个虚表:
class A
{
public:
virtual void func1() {}
protected:
int _a;
};
int main()
{
A a1, a2, a3;
printf("%p\n", *(int*)&a1);
printf("%p\n", *(int*)&a2);
printf("%p\n", *(int*)&a3);
return 0;
}
output:
00307B34
00307B34
00307B34
5.1 单继承与多继承的虚表
基类的虚表我们在上面已经分析得差不多了,接下来我们分析单继承有多继承中派生类的虚表
5.1.1 单继承
我们来看下面的代码:
class A
{
public:
virtual void func1() {}
virtual void func2() {}
protected:
int _a = 1;
};
class B : public A
{
public:
protected:
int _b = 2;
};
int main()
{
B b;
return 0;
}
可以看到,派生类B继承了基类A的虚表,而没有单独创建一个虚表
再来看下面的代码:
/*
派生类B重写了基类A的虚函数func1()
同时新增了一个虚函数func3()
之后定义了一个基类A对象,一个派生类B对象
*/
class A
{
public:
virtual void func1() {}
virtual void func2() {}
protected:
int _a = 1;
};
class B : public A
{
public:
virtual void func1() {}
virtual void func3() {}
protected:
int _b = 2;
};
int main()
{
A a;
B b;
return 0;
}
我么可以发现几个现象:
- 基类对象的虚表和派生类对象的虚表是不同的
- 派生类B没有重写基类A的虚函数
func2()
,因此在两个类的虚表中,函数func2()
的地址是相同的,也就是说基类和派生类使用的是相同的func2()
- 派生类B重写了基类A的虚函数
func1()
,因此派生类B新的func1()
的地址覆盖了原来的基类func1()
的地址,因此我们也称重写关系为覆盖关系。重写体现在实现,而覆盖体现在底层原理
但是有一点很奇怪:派生类B明明新增了一个虚函数func3()
,为什么在监视窗口中他没有出现在b的虚表中呢?
实际上,虚函数func3()
确实会被加入到b的虚表,但是由于某些特殊的原因,VS的监视窗口并没有显示。可以调用内存窗口进行查看:
可以进行总结:
- 子类会继承父类的虚表
- 不同类的虚表会不同
- 如果子类的虚函数对父类的虚函数进行了重写,那么子类虚函数的地址就会对虚表原有父类虚函数的地址进行覆盖。因此重写也称为覆盖
- 如果子类新增了虚函数,那么该虚函数的地址也会被添加到虚表的后面
5.1.2 多继承
我们来看下面这个派生类的大小:
class A
{
public:
virtual void func1() {}
protected:
int _a = 1;
};
class B
{
public:
virtual void func2() {}
protected:
int _b = 2;
};
class C : public A, public B
{
public:
virtual void func3() {}
protected:
int _c = 3;
};
int main()
{
cout << sizeof(C) << endl;
return 0;
}
output:
20
我们可以进行推断:派生类C继承了两个基类A和B,其包含了3个整形数据,即12字节,那么剩下的8个字节应该是类A和类B的虚表指针
即,在多继承中,派生类会继承基类的虚表
在多继承中不得不面临这样一个问题:例如派生类C新增了一个虚函数fuc3(),那么这个虚函数是存放在A类的虚表,还是存放在B类的虚表,还是两个虚表都存呢?
我们可以调用内存窗口进行查看:
可以看到,func3()
被放在了最先被继承的类A的虚表中
可以进行总结:
- 如果派生类继承了多个基类,那这个派生类也会继承这些基类的虚表
- 如果派生类新增了虚函数,那这个虚函数的地址会被放在最先被继承的虚表中
5.2 实现多态的原理
我们以一个简单的多态调用为例来说明多态的实现原理:
class A
{
public:
virtual void func1()
{
cout << "hello\n";
}
protected:
int _a = 1;
};
class B : public A
{
public:
virtual void func1()
{
cout << "world\n";
}
protected:
int _b = 2;
};
int main()
{
A* ptr = new B;
ptr->func1();
return 0;
}
- 我们用基类的指针
ptr
指向了一个派生类对象,并调用重写好的虚函数func1()
,从而构成了多态调用 - 由继承的知识我们知道,此时
ptr
指向的就是派生类B中父类的切片,从而可以通过func1()
中的this指针来找到虚表 - 最后再通过虚表找到虚函数
func1()
的地址就可以实现多态调用了
从这里我们也就知道为什么静态成员函数为什么不能称为虚函数,实现多态调用了。
- 这是因为静态成员函数没有this指针,也就无法通过this指针找到虚表,也就不能找到对应虚函数的地址实现调用了。
同时也能更好地解释为什么构造函数不能成为虚函数。
- 我们前面说过虚表指针是在构造的时候初始化,而虚函数的调用需要用到虚表指针
- 那么如果构造函数是虚函数,那么在调用构造函数的时候,虚表指针都没有初始化,谈何虚函数的调用?
5.3 静态多态与动态多态
5.3.1 静态多态
静态多态又称为静态绑定,即在编译期间就决定了程序的行为,是编译时的
函数重载就是典型的静态多态,其通过函数名修饰规则来实现相同名字不同函数的调用。因为函数重载规定函数的参数必须不同,因此在编译时就可以通过函数参数来确定调用哪个函数
int add(int a, int b)
{
return a + b;
}
double add(double a, double b)
{
return a + b;
}
此外,模板也是一种静态多态,编译时通过传入的类型来实例化出具体的类或函数
template<class T>
T add(T a, T b)
{
return a + b;
}
5.3.2 动态多态
动态多态又称为动态绑定,即在运行时确定程序的行为,时运行时的
多态就是动态多态,因为虚函数的重写要求函数的返回值、参数、名字都相同,在编译时无法判断到底调用哪个函数,只能在运行时通过虚表来查看虚函数的地址进行调用
本篇完
如果错误,欢迎斧正