摘要:多态的概念,多态的条件,虚函数的重写,抽象类,多态的原理,虚函数与虚函数表,与多态有关的问答题
1. Concept
多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会 产生出不同的状态。
举例:买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人 买票时是优先买票。(该例子的示例代码如下)
#include<iostream>
class ticket
{
public:
virtual void buy()
{
std::cout << "ticket" << std::endl;
}
};
class Student_ticket :public ticket
{
public:
virtual void buy()
{
std::cout << "Student:半价" << std::endl;
}
};
void test1()
{
ticket t;
Student_ticket st;
ticket* pt = &st;
pt->buy();
pt = &t;
pt->buy();
}
int main()
{
test1();
return 0;
}
👆上述代码的运行结果:
Student:半价
ticket
上述代码说明:当基类的指针 pt 指向的对象是 Student Type 时,pt->buy() 会去调用 Student 的 buy() 函数,当 pt 指向的对象时 ticket Type 时,pt->buy() 回去调用 ticket 的 buy() 函数。最后代码的运行结果就如同我们所解释的这样。
2. Condition
多态的条件:
①完成虚函数的重写
②基类的指针或引用去调用虚函数
1)虚函数的重写
虚函数:即被virtual修饰的类成员函数称为虚函数。
- virtual 该关键词只能修饰成员函数,只能用在成员函数的声明上!
- 虚函数的重写要满足三同:函数名、参数列表(仅指参数个数、参数数据类型及顺序)、返回值
(ps.派生类可以不加 virtual,只要满足三同,基类和子类之间也能实现虚函数的重写,因为“虚函数的重写”的“重写”是对基类某个成员函数的重写,可以认为是基类的虚函数的 virtual 被派生类继承下来了。然而不建议这样做,最好基类和子类的虚函数都加 virtual)
虚函数重写的两个例外:
- 协变(基类与派生类虚函数返回值类型不同) :
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。(了解)(示例如下)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{};
class Student :public Person{};
void test2()
{
Person* p = new Person;
delete p;//p->destructor()+operator detele(p)
Person* p2 = new Student;
delete p2;//p2->destructor()+operator detele(p2)
}
如上代码,按编译器的处理,基类 Person 和派生类 Student 的 destructor 构成隐藏关系,然而这里我们期待构成多态,p2 指针指向的是一个 Student 类型,则 delete p2 应该调用 Student 的析构。因此这里必须对基类和派生类的析构函数实现虚函数的重写。如下。
class Person
{
public:
virtual ~Person()
{
//……
}
};
class Student :public Person
{
public:
virtual ~Student()//此处的virtual是可以省略的,但是不建议省略
{
//……
}
};
基于此,建议在继承关系中,析构函数都实现成虚函数,以防止析构出现问题。
2)多态的调用
- 对于自定义类型调用成员函数
普通调用:调用这个函数的 Type 是什么,就去调用这个 Type 的函数。例如 class Person 类型的指针调用函数就会去调用 Person 的成员函数,class Student 类型的指针去调用函数就会去调用 Student 的函数。
多态调用(凡是不满足多态调用的两个条件的都是普通调用):(继承关系是多态调用的前提)调用函数的 指针 或 引用 指向的对象是什么,就去调用这个 对象(object) 的函数。例如 Person* 的指针如果指向的是一个 Person 类型的对象,就去调用 Person 的函数;Person* 的指针如果指向的是一个 Student 类型的对象,就去调用 Student 的函数。(指针类型和指针指向的数据的类型是两码事,指针类型的不同决定了对指针本身进行操作的结果的不同,对此感到理解困难的可以去温习C语言关于指针的解释)
关于虚函数的重写。重写其实可以理解为继承了函数的接口(或者说函数的声明),重写了函数的定义。(重写定义不是重定义,重定义是隐藏,这里只是为了形象地解释重写的过程)
下面来看一道题来加深对多态调用的理解:
以下程序输出结果是什么()
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: 以上都不正确
分析及解答:
如上图所说,从 p->test()普通调用 到 A* this ->func() 构成了多态调用(func完成了虚函数重写,只是参数的缺省值给的不同不影响完成重写),所以这里会去调用 B 的 func 函数,又因为我们前面说到过,重写继承了基类的函数接口,因此这里 val 的缺省值为 1 。(从这里也印证了为什么参数的缺省值给的不同不影响完成虚函数的重写,因为不管派生类给什么缺省值,多态调用的时候都只会用基类的接口给)最后我们得到本题的答案:选B选项。
3)函数重载、重写(覆盖)、重定义(隐藏)的比较
- 函数重载:①位于同一作用域;②函数名相同,参数列表不同(仅返回值不同不能构成函数重载)
- 函数重写:①分别位于基类和派生类的作用域;②三同(函数名同,参数列表同,返回值同);③virtual 修饰
- 函数重定义:①分别位于基类和派生类的作用域;②函数名同
(从上面不难看出,重写的要求比重定义的言责,所以我们可以把重写看成是一种特殊的重定义)
4)关键词
final:对于 类 可以使得该类不能被继承;对于 虚函数 可以使得不能被重写(使用示例如下)
class ticket final
{
public:
virtual void buy() final
{
std::cout << "ticket" << std::endl;
}
};
override:修饰派生类的虚函数,用于检查是否完成重写,只能完成检查。如果没有重写编译报错。(使用示例如下)
class ticket //final
{
public:
virtual void buy() //final
{
std::cout << "ticket" << std::endl;
}
};
class Student_ticket :public ticket
{
public:
virtual void buy()override
{
std::cout << "Student:半价" << std::endl;
}
};
3. 抽象类
概念:在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类)。抽象类不能实例化出对象(但是可以定义这个类class的类型Type的指针→多态调用)。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
意义:①抽象类某种程度上强制派生类实现虚函数的重写;②提供了“抽象”的概念。(抽象是从众多的事物中抽取出共同的、本质性的特征)例如,class Animal,现实中没有个具体的对象,而可以其派生类 class Cat、Gog……具体的物种现实中有具体的对应(即概念上等同于可以实例化对象)因此一般抽象类有多个派生类。所以通俗来讲就是抽象类的存在告诉我们这个类所指的概念在现实中无具体的实体。
class Animal
{
public:
virtual void Print() = 0;
};
class Cat :public Animal
{
public:
void Print()
{
std::cout << "Cat" << std::endl;
}
};
void func()
{
Animal* pa = new Cat;
pa->Print();
}
接口继承与实现继承:虚函数继承就是一种接口继承;普通函数继承就是一种实现继承。
4. 多态的原理
vft——virtual function table 虚函数表;vftptr——virtual function table pointer 虚函数标指针.
(虚函数表指针命名为 __vfptr 是 vs 平台下编译器的个人行为)
如上,含虚函数的类中都有一个“vft(虚函数表,简称虚表)”
多态调用的原理:
class Base
{
public:
virtual void func1()
{
std::cout << "Base:func1()" << std::endl;
}
private:
int _a = 0;
};
class Derive :public Base
{
public:
virtual void func1()
{
std::cout << "Derive:func1()" << std::endl;
}
private:
int _b = 1;
};
从上图我们看到,多态调用其实就是去虚函数表里取函数的地址直接调用,所以如果指针指向的是派生类对象,那么取到的 vftptr 就是派生类对象的虚表指针,再由此找到虚表里存储的函数地址;如果指向的是基类对象就是取到不一样的虚表。这样就完成了多态调用。
虚函数的“重写”又可以称为“覆盖”,可以 形象地理解 为派生类先把基类的虚表拷贝一份过来,在虚表中对于实现了虚函数重写的函数的地址将被覆盖成一个新地址。(只是这样理解而不是说编译器会真的按所说的做)
注意:对于虚表,一个类只有一个虚表,不同类不共用虚表,这个类所有实例化出来的对象共享一份虚表。成员函数对于一个类来说是“公共区域”,存储函数地址的虚表同样也是。
了解完多态的原理之后我们再来看普通调用与多态调用的区别:
多态调用是运行时,去虚表里面找到函数的地址,确定地址后,调用这个地址;普通调用时编译或链接时,确定地址。
- 为什么多态调用一定要基类的指针或引用?
①派生类可以赋值给基类的指针/引用/对象,基类对象不可以赋值给派生类。
②基类的指针/引用直接指向/指代派生类对象(切片)
③派生类对象 赋值给 基类对象,不会拷贝虚函数表指针!如果脸虚表都拷贝,那么多态调用就失效了,我们无法分清这个虚表里存储的是哪个类的成员函数地址,更严重的,会导致析构函数调用错误。
5. 虚函数与虚函数表
- 虚函数存在哪?虚函数表存在哪?
答:代码段
虚函数本质上就是函数,很容易想到和普通函数一样存在代码段;
对于虚函数表:
①应该不在栈上。如果在栈上出作用域就会被销毁,而一个类共用一个虚表;
②应该不在堆上。堆上的空间是需要动态申请的,如果是在堆上,谁去申请空间,谁又去清理空间呢?分别发生在什么时候呢?
下面对上述猜想进行验证:
class Base{//……};//具体内容省略
void test4()
{
static int a = 0;
printf("静态区:%p\n", &a);
int b = 0;
printf("栈:%p\n", &b);
int* p = new int;
printf("堆:%p\n", p);
const char* pc = "hello";
printf("代码段:%p\n", pc);
Base bs;
printf("虚表指针:%p\n", *((int*)&bs));
printf("虚表:%p\n", &Base::func1);
}
上述代码-说明:ps. x86(32位)平台下指针的大小为 4 byte,与 int 的大小相同。
①打印虚表指针的地址时,对 bs 取地址之后强制转换为 int* 的指针再解引用,是因为虚表指针位于 Base 对象的开头位置,而不同的指针类型之间的转化是自然的,我们很容易取到 bs 开头的虚表指针之后将其转化后再解引用;
②函数名即为函数地址,但这里要打印这个地址的之后仍需要加 “&”,这是一个特殊的语法规定;
③func1 是 class Base 中的成员函数,要突破类域才能访问。
执行上述代码可知,虚表的地址和代码段的地址很相近,由此我们可以粗略得出——虚表位于代码段(更靠近常量区)。
1)单继承的情况
- 虚函数都存在虚函数表里吗?
- 答:是的
为了验证上面的说法,我们需要打印虚函数表(有时候编译时的监视窗口并不能完整的显示虚表),代码如下:
(运行时遇到的问题:关于vs平台下对虚函数表的结尾的处理(下面代码的注释中有写)存疑,在虚函数实现的不同的情况下好像有所不同。如果该错误由代码本身导致,欢迎指正。)
(代码注释提供了一些关于个别语句的解释,如有不懂,注意参看注释)
实现打印虚表的思路很简单:虚函数表就是一个指针数组,我们只需要像打印普通数组那样打印虚表即可。代码涉及到函数指针,对该部分感到难以理解的请去温习C语言的函数指针。
class Base
{
public:
virtual void func1() { std::cout << "Base:func1()" << std::endl; }
virtual void func2() { std::cout << "Base:func2()" << std::endl; }
virtual void func3() { std::cout << "Base:func3()" << std::endl; }
};
class Derive :public Base
{
public:
virtual void func2() { std::cout << "Derive:func2()" << std::endl; }
virtual void func4() { std::cout << "Derive:func4()" << std::endl; }
};
class D_Derve :public Derive
{
public:
virtual void func3() { std::cout << "D_Derive:func3()" << std::endl; }
};
typedef void(*VFUNC)();//定义一个函数指针类型 void(*)() 返回值为void; 参数列表为(); 的函数指针为 VFUNC
void printVFT(VFUNC a[])//本质上就是 VFUNC* a,a是一个函数指针数组,数组名
{
for (size_t i = 0; a[i] != 0; ++i)//因为vs平台对vft会以‘0’结尾,所以这里以不等于0为循环继续的条件
{
printf("[%d] -> %p", i, a[i]);
std::cout << " ";
VFUNC fp = a[i];
fp();//通过函数指针调用函数
}
}
void test5()
{
Base b;
printVFT((VFUNC*)(*(int*)&b));
//先对b取地址, 再将其强转为int*(取到vftptr), 再对其解引用(拿到vft的地址), 再将这个地址强转为VFUNC*
std::cout << "----------------------------------------------Base" << std::endl;
std::cout << std::endl;
Derive d;
printVFT((VFUNC*)(*(int*)&d));//????
std::cout << "----------------------------------------------Derive" << std::endl;
std::cout << std::endl;
//Base* pb = &d;
D_Derve d_d;
printVFT((VFUNC*)(*(int*)&d_d));
std::cout << "----------------------------------------------D_Derive" << std::endl;
//Derive* ptest = &d_d;
}
关于指针类型强转的说明:
x64(64位):指针大小为 8 byte. 👉 这个时候不能再强转成 int* 而应为 long long* ,而 long long* 同样适用于32位的环境,因为编译器会对地址进行截断,但这样写对32位的环境来说并不安全。可以使用 条件编译 的方式来使得代码有更好的跨平台性。
x86(32位):指针大小为 4 byte.
2)多继承的情况
如下代码为多继承的情况:(下面代码使用了单继承中实现了的函数)
class Base1
{
public:
virtual void func1() { std::cout << "Base1:func1()" << std::endl; };
virtual void func2() { std::cout << "Base1:func2()" << std::endl; };
private:
int _b1;
};
class Base2
{
public:
virtual void func1() { std::cout << "Base2:func1()" << std::endl; };
virtual void func2() { std::cout << "Base2:func2()" << std::endl; };
private:
int _b2;
};
class Derive_B1B2 :public Base1, public Base2
{
public:
virtual void func1() { std::cout << "Derive_B1B2:func1()" << std::endl; };
virtual void func3() { std::cout << "Derive_B1B2:func3()" << std::endl; };
private:
int _d;
};
void test6()
{
Derive_B1B2 d;
printVFT((VFUNC*)*(int*)&d);//打印 Derive_B1B2 中的 Base1 的vft
std::cout << "---------------------------------------------------------------------" << std::endl;
//printVFT((VFUNC*)*((char*)&d + sizeof(Base1)));//打印 Derive_B1B2 中的 Base2 的vft
//强转成 char* 是因为char类型占1byte,而sizeof的单位为byte, 这样做可以使得指针往后挪动到我们想要的地方去
//更优的写法
Base2* pb2 = &d;
printVFT((VFUNC*)*(int*)pb2);
}
执行结果:(具体地址每次执行结果会不同)
[0] -> 007411DB Derive_B1B2:func1()
[1] -> 00741398 Base1:func2()
[2] -> 0074108C Derive_B1B2:func3()
---------------------------------------------------------------------
[0] -> 0074100F Derive_B1B2:func1()
[1] -> 007410EB Base2:func2()
上述代码逻辑图解:(ps.多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中。如下func3函数。
*拓展了解:从上面多继承的代码的执行结果我们可以发现都是 Derive_B1B2 的 func1 函数,但是打印出来的地址却不同。e.g. [0] -> 007411DB Derive_B1B2:func1() 与 [0] -> 0074100F Derive_B1B2:func1()。我们需要进一步通过反汇编来了解其中发生了什么。
从上图我们可以看出,最终调用函数的地址还是一样的。形象地理解为:最后func1的地址是目的地,监视窗口看见不同的地址是因为调用func1的时候走了两个不同的“路线”。另外,从上图可以发现,p2调用函数过程中有一个“ sub ecx,8 ”的操作。我们可以由此推测p2调用函数过程中这么多中间过程是为了修正 this 指针。因为此处 p1 与 p2 分别是 Base1* 和 Base2* 类型,而 func1 函数作为 Derive_B1B2 的成员函数,具有隐藏的参数 Derive_B1B2* this,p1恰巧与&d是相同的指针内容,只是指针类型不同,调用的时候不需要修正this指针,只需要修改对该指针的类型识别即可;p2则不同,这个指针与&d的内容不同,所以需要在中间修正this指针。
菱形继承的情况(了解)
一般的菱形继承就跟多继承的情况是一样的。
菱形虚拟继承的情况:(仅举例展示)
6. 总结-问答题⭐
(节选部分问题)
1. ( )是面向对象程序设计语言中的一种机制。这种机制实现了方法的定义与具体的对象无关,而对方法的调用则可以关联于具体的对象。
A: 继承 B: 模板 C: 对象的自身引用 D: 动态绑定
答:静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态, 比如:函数重载 2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体 行为,调用具体的函数,也称为动态多态。
2. 内联函数可以是虚函数吗?
答:普通调用时,inline起作用;多态调用时,inline不起作用。
3. 静态成员函数可以是虚函数吗?
答:不能。静态成员函数强行实现成虚函数会引发编译错误。
分析:静态成员函数可看作成受类域限制的全局函数,没有隐藏的形参 this指针,无法进行多态调用。
4. 构造函数可以是虚函数吗?
答:不能。会引发编译错误。
分析:类实例化出一个对象的时候,这个对象的虚函数表指针是通过构造函数阶段才被初始化的,而多态调用,要去虚函数表中去找函数的地址,而在执行构造函数之前,虚函数表指针还未被初始化。
5. 对象调用普通函数快还是调用虚函数快?
答:看是普通调用还是多态调用,多态调用慢一点。注意:虚函数不一定是被多态调用,也可以是被普通调用,注意多态调用的两个条件。
6. 虚函数表什么时候生成,存储在哪?
答:编译时生成,但执行构造函数的时候虚函数表指针才被初始化;存储在代码段。
END