一、多态
多态是指接口的多种不同实现方式,使得同一接口有多种不同行为。多态分动态和静态:
动态多态是类继承+虚函数机制实现的,运行时通过虚函数接口,不同类型的对象调用其对应的虚函数实现形式来产生不同行为。
静态多态是函数重载或模板的泛型编程:
(1)函数重载在编译时根据参数类型和数量来确定要调用的相应函数;
(2)模板允许将不同行为与单个泛化符号进行关联,编译阶段通过确定泛化符号进而确定行为。
一般说的多态指的是动态多态,以下也直接称其为多态了。
1、多态实现的必要条件
(1)子类必须继承父类
(2)父类必须有虚函数接口
(3)子类必须重写父类的虚函数接口
(4)用父类引用/指针指向子类对象
2、多态的好处
降低代码间的耦合度(关联程度),提高可扩展性和可维护性
3、多态中的类型转换:分向上和向下
向上转型(子类转父类):这种转型是隐式的,不需要显式的类型转换运算符。向上转型是安全的,因为基类指针或引用可以指向派生类对象,但不能访问派生类的非虚成员,只能访问派生类重写的虚函数和基类的成员。
Derived obj;
Base * p=static_cast<Base*>(&obj);
向下转型(父类转子类):向下转型需要显示使用类型转换符,最好用 dynamic_cast,因为它会在运行时执行类型检查,如果转型不合法,会返回一个空指针(如果转换的是指针)或抛出异常(如果转换的是引用)。而 static_cast 不会进行运行时检查,因此更不安全。不正确的向下转型 或 转型后访问派生类特有成员,可能触发未定义行为或内存访问错误。
注:由于易出错,应尽量避免使用向下转型。
Base* basePtr = new Derived;
Derived* derivedPtr = dynamic_cast<Derived*>(basePtr);
if (derivedPtr) {
// 转换成功,可以安全地使用 derivedPtr
} else {
// 转换失败,basePtr 不是 Derived 类型的对象
}
二、虚函数
1、虚函数在多态的应用
多态的表现形式为,如果使用对基类的指针或引用来处理派生类,则调用重写后虚函数将会调用派生类中定义的行为。 这样的函数调用被称为虚函数调用或虚拟调用。
如果使用限定名称查找选择了函数(即,如果函数的名称出现在作用域解析运算符::的右侧),则虚函数调用将被抑制。
struct A{
virtual void f(){cout<<"A\n";}
};
struct B: public A{
void f() override {cout<<"B\n";}
};
int main(){
A * pa=new B;
A & ra=new B;
pa.f(); //调用子类的f()
ra.f(); //调用子类的f()
pa.A::f(); //调用父类的f()
}
运行结果
B
B
A
struct A{
virtual void f(){cout<<"A\n";}
void run(){ f(); }
};
struct B: public A{
void f() override {cout<<"B\n";}
};
int main(){
A * pa=new B;
pa.run(); //B
}
2、虚函数原理(重点!!!)
对于一个类,如有虚函数,则在其内存分布中有一个虚函数表vtable,虚函数表类似于一个数组,虚函数表里存放的都是该类的虚函数的地址,包括类中重写的虚函数地址、继承自父类的没有重写的虚函数地址。子类继承父类时,先将父类内容拷贝一份,然后替换重写了的虚函数,添加上扩展的成员。因为继承函数时继承的是函数的调用权(地址)而非函数那块内存,所以也不必讨论继承的函数多大。
对于类的每一个对象,都有一个指针this指向对象自己,也就是保存对象内存区的首地址,如对应的类有虚函数,则对象第一个成员为类的虚函数表指针vptr,占用4 Bytes,保存虚函数表的地址。
对于函数调用,编译器会将函数与其地址绑定,分为静态绑定和动态绑定。采用静态绑定时,函数与地址在编译时就绑定好了,运行时直接对函数地址进行访问调用。而虚函数的调用是动态绑定的过程,函数地址不固定,便需要运行时才能确认,通过访问虚函数表再根据函数名或索引找到对应函数地址的方式来进行,过程为:this->vptr->vtable->具体函数。
注:有虚函数的类才有虚函数表,否则没有。
动态绑定和静态绑定代码举例:
struct A {
virtual void f() {};
};
struct B :public A{
void f() {};
};
int main() {
A a;
a.f(); //静态绑定
B b;
b.A::f(); //静态绑定
b.f(); //静态绑定
A* pa = new B;
pa->f(); //动态绑定
A* pa1 = &a;
pa1->f(); //动态绑定
}
从汇编代码可以看出,静态绑定直接调用的是固定的地址,编译时直接就给出了,通过对象直接调用的函数和明确指定类名的A::f()形式,编译器都是明确知道属于哪个类的,所以和普通函数void f(){...}一样都是静态绑定的。
而通过基类指针间接调用虚函数时,是需要在运行时根据对象动态绑定具体函数地址的,需与指针pa和其他寄存器交互获取函数地址,并存放到eax寄存器中进行调用。
虚函数原理代码举例:
#include <iostream>
using namespace std;
struct Base1 {
int a;
virtual void f() {
cout << "Base1.f()" << endl;
}
};
struct Base2 {
int b;
void f1() {
cout << "Base2.f1()" << endl;
}
virtual void f2() {
cout << "Base2.f2()" << endl;
}
};
struct Derived :Base2, Base1
{
int c, d;
void f() override {
cout << "Derived.f()" << endl;
}
void f1() {
cout << "Derived.f1()" << endl;
}
void f2() {
cout << "Derived.f2()" << endl;
}
};
int main() {
Base1 base1;
Base2 base2;
Derived derived;
}
运行后vs的自动窗口显示:
简述要点:
(1)当出现Base1 *p1=new Derived;这句时,p1指针放在栈区,所指对象动态分配在堆区,对象实际大小为16字节。
(2)创建派生类对象时,实际内存最先存放虚函数表指针vptr,然后是按照基类的继承顺序,先放继承自Base2的变量b,再放继承自Base1的变量a,最后按照派生类自己特有的变量的声明顺序,依次添加变量c和d。
vptr指向的虚函数表vtable,按照基类的继承顺序和每个类内的函数声明顺序,先放Base2的虚函数f2,再放Base1的虚函数f,同时分别进行覆盖重写。
(3)当实现运行时多态,便根据访问的派生类对象的this指针找到该对象的虚函数表指针vptr,如此就能找到真正要调用的函数。
而对于非虚函数的覆盖,例如派生类对Base2的f1函数实现的是直接覆盖,是逻辑而非真正物理内存上的覆盖,因为非虚函数是直接调用的,不用再动态分析。
3、虚函数的其他应用
除了多态,虚函数的另一个应用是模板方法(template method,设计模式之一),典型例子如MFC。含义为:编写代码时,有时有些逻辑在目前尚不明确,便可在类中为未来预留一个接口(虚函数),待以后需添加或修改内容时重写这个接口即可。
4、常见的可执行程序的分段
(静态,程序编译和连接时确定的,包含了程序静态的数据和代码,定义了程序在加载到内存时的布局,例如:堆段和栈段保存的是一些空间指导信息,表示运行时需要多大的堆和栈,以便运行时动态分配堆和栈)
1、代码段(.text):只读,运行时不可修改,存放程序的可执行代码(机器指令)
2、只读数据段(.rodata):只读,运行时不可修改,存放程序中的常量数据,例如字符串文字、全局常量、常量数组等
3、数据段(.data):可读可写,存放初始化的全局变量和静态变量,程序启动时自动初始化,并在程序的整个生命周期内存在。
4、BSS 段(.bss):可读可写,存放未初始化的全局变量和静态变量,程序启动时会自动初始化为0或空值
5、堆(Heap):用于动态分配内存的区域,通常由运行时库(如C库或C++标准库)管理
6、栈(Stack):后进先出(LIFO)的数据结构,用于跟踪函数调用的执行顺序
7、动态链接库(Dynamic Linking):放动态库的(.dll或.so)
8、重定位表(Relocation Table):包含了需要在加载时修正的地址或信息,确保可执行文件中的代码和数据放置在内存中的正确位置
9、其他自定义段:用户可自定义段来扩展可执行程序的功能
5、c++程序的内存区
(动态,程序运行时操作系统分配的)
常量区:存储常量数据,如字符串文字和全局常量等,为只读数据,存储在可执行文件的
.rodata
段。代码区:存储在可执行文件的
.text
段
全局/静态存储区:分全局存储区和静态存储区,由数据的初始化方式决定存储在可执行文件的BSS段还是数据段堆(Heap):动态分配的内存,可在程序运行时动态分配和释放。
栈(Stack):存储函数调用信息和局部变量,调用函数时自动分配,调用完成自动释放。
注意:库文件本身(静态库或动态库)是在程序加载和执行时才被操作系统载入内存的,而不是在编译时就放入内存的。库文件中的代码和数据在加载到内存时将占用可执行程序的相应内存区域,所以库文件可能依据需求占用任何分区
6、虚函数表和虚表指针的存放位置(重点!!!)
虚函数表的存放位置:每个虚函数表全局仅一份,只读,且非逻辑代码,所以存放在c++内存区的常量区,即可执行文件的只读数据段(.rodata)
而虚函数表指针,由于是对象内存的首个成员,对象放哪它就放哪,对象如果是动态分配的就在堆,如果是全局或静态就在数据段,如果是是局部变量就在栈。
代码举例:
#include <iostream>
using namespace std;
struct Base {
int a;
virtual void f() {
cout << "Base.f()" << endl;
}
};
struct Derived :Base
{
int b;
void f() {
cout << "Derived.f()" << endl;
}
virtual ~Derived() {};
};
int main() {
Base* p = new Derived;
}
对应的内存布局如下:
参考:
virtual function specifier - cppreference.com
深度分析:理解Java中的多态机制,一篇直接帮你掌握! - 架构人生 - SegmentFault 思否