多态
多态是面向对象程序设计语言中数据抽象和继承之外的第三个基本特征。
多态性(polymorphism)提供接口与具体实现之间的另一层隔离,从而将”what”和”how”分离开来。多态性改善了代码的可读性和组织性,同时也使创建的程序具有可扩展性,项目不仅在最初创建时期可以扩展,而且当项目在需要有新的功能时也能扩展。
C++支持编译时多态(静态多态)和运行时多态(动态多态),运算符重载和函数重载就是编译时多态,而派生类和虚函数实现运行时多态。
就是函数地址是早绑定(静态联编)还是晚绑定(动态联编)。如果函数的调用在编译阶段就可以确定函数的调用地址、并产生代码,就是静态多态(编译时多态),就是说 地址是早绑定的。而如果函数的调用地址不能编译不能在编译期间确定,而需要在运行时才能决定,这这就属于晚绑定(动态多态——运行时多态)。示例代码
#include <iostream>
using namespace std;
//计算器
class Caculator {
public:
void setA(int a) {
this->mA = a;
}
void setB(int b) {
this->mB = b;
}
void setOperator(string oper) {
this->mOperator = oper;
}
int getResult() {
if (this->mOperator == "+") {
return mA + mB;
}
else if (this->mOperator == "-") {
return mA - mB;
}
else if (this->mOperator == "*") {
return mA * mB;
}
else if (this->mOperator == "/") {
return mA / mB;
}
}
private:
int mA;
int mB;
string mOperator;
};
//这种程序不利于扩展,维护困难,如果修改功能或者扩展功能需要在源代码基础上修改
//面向对象程序设计一个基本原则:开闭原则(对修改关闭,对扩展开放)
//抽象基类
class AbstractCaculator {
public:
void setA(int a) {
this->mA = a;
}
virtual void setB(int b) {
this->mB = b;
}
virtual int getResult() = 0;
protected:
int mA;
int mB;
string mOperator;
};
//加法计算器
class PlusCaculator : public AbstractCaculator {
public:
virtual int getResult() {
return mA + mB;
}
};
//减法计算器
class MinusCaculator : public AbstractCaculator {
public:
virtual int getResult() {
return mA - mB;
}
};
//乘法计算器
class MultipliesCaculator : public AbstractCaculator {
public:
virtual int getResult() {
return mA * mB;
}
};
void DoBussiness(AbstractCaculator* caculator) {
int a = 10;
int b = 20;
caculator->setA(a);
caculator->setB(b);
cout << "计算结果:" << caculator->getResult() << endl;
}
int main(int argc, char *argv[]) {
AbstractCaculator* ac1 = new PlusCaculator;
DoBussiness(ac1);
delete ac1;
AbstractCaculator* ac2 = new MultipliesCaculator;
DoBussiness(ac2);
delete ac2;
return 0;
}
程序输出:
计算结果:30
计算结果:200
动态多态产生条件
先有继承关系
父类中有虚函数,子类重写父类中的虚函数
父类中的指针或引用指向子类的对象
向上类型转换
问题抛出
对象可以作为自己的类或者作为它的基类的对象来使用。还能通过基类的地址来操作它。取一个对象的地址(指针或引用),并将其作为基类的地址来处理,这种称为向上类型转换。
也就是说:父类引用或指针可以指向子类对象,通过父类指针或引用来操作子类对象。
示例代码
#include <iostream>
using namespace std;
class Animal {
public:
void speak() {
cout << "动物在唱歌..." << endl;
}
};
class Dog : public Animal {
public:
void speak() {
cout << "小狗在唱歌..." << endl;
}
};
void DoBussiness(Animal& animal) {
animal.speak();
}
void test() {
Dog dog;
DoBussiness(dog);
}
int main(int argc, char *argv[]) {
test();
return 0;
}
程序输出:
动物在唱歌...
问题抛出 : 我们给DoBussiness
传入的对象是dog
,而不是animal
对象,输出的结果应该是Dog::speak
。
解决思路
解决这个问题,我们需要了解下绑定(捆绑,binding)概念。把函数体与函数调用相联系称为绑定(捆绑,binding)。
当绑定在程序运行之前(由编译器和连接器)完成时,称为早绑定(early binding)。C语言中只有一种函数调用方式,就是早绑定。
上面的问题就是由于早绑定引起的,因为编译器在只有Animal
地址时并不知道要调用的正确函数。编译是根据指向对象的指针或引用的类型来选择函数调用。这个时候由于DoBussiness
的参数类型是Animal&
,编译器确定了应该调用的speak
是Animal::speak
的,而不是真正传入的对象Dog::speak
。
解决方法就是迟绑定(迟捆绑,动态绑定,运行时绑定,late binding),意味着绑定要根据对象的实际类型,发生在运行。
C++语言要实现这种动态绑定,必须有某种机制来确定运行时对象的类型并调用合适的成员函数。对于一种编译语言,编译器并不知道实际的对象类型(编译器并不知道Animal
类型的指针或引用指向的实际的对象类型)。
解决方案-虚函数
C++动态多态性是通过虚函数来实现的,虚函数允许子类(派生类)重新定义父类(基类)成员函数,而子类(派生类)重新定义父类(基类)虚函数的做法称为覆盖(override),或者称为重写。
对于特定的函数进行动态绑定,C++要求在基类中声明这个函数的时候使用virtual关键字,动态绑定也就对使用了virtual
关键字的函数起作用。
为创建一个需要动态绑定的虚成员函数,可以简单在这个函数声明前面加上
virtual
关键字,定义时候不需要如果一个函数在基类中被声明为
virtual
,那么在所有派生类中它都是virtual
的在派生类中
virtual
函数的重定义称为重写(override)virtual
关键字只能修饰成员函数构造函数不能为虚函数
注意: 仅需要在基类中声明一个函数为virtual
。调用所有匹配基类声明行为的派生类函数都将使用虚机制。虽然可以在派生类声明前使用关键字virtual
(这也是无害的),但这个样会使得程序代码显得有些冗余。(建议写上,这样看到派生类中该函数的时间会更清晰的知道这是虚函数)
示例代码
#include <iostream>
using namespace std;
class Animal {
public:
virtual void speak() {
cout << "动物在唱歌..." << endl;
}
};
class Dog : public Animal {
public:
virtual void speak() {
cout << "小狗在唱歌..." << endl;
}
};
// 对于有父子关系的两个类, 指针或引用 是可以直接转换的
void DoBussiness(Animal& animal) {
// 如果地址早就绑定好了 地址早绑定 属于静态联编
// 如果想调用小狗说话,这个时候函数的地址就不能早绑定,而是在运行的阶段再去绑定函数地址 地址晚绑定 属于动态联编 动态联编需要借助 virtual 来实现
animal.speak();
}
void test() {
Dog dog;
DoBussiness(dog);
}
int main(int argc, char *argv[]) {
test();
return 0;
}
程序输出:
小狗在唱歌...
C++如何实现动态绑定
动态绑定什么时候发生?所有的工作都是由编译器在幕后完成。当我们告诉通过创建一个virtual函数来告诉编译器要进行动态绑定,那么编译器就会根据动态绑定机制来实现我们的要求, 不会再执行早绑定。
问题:C++的动态捆绑机制是怎么样的?
首先,我们看看编译器如何处理虚函数。当编译器发现我们的类中有虚函数的时候,编译器会创建一张虚函数表,把虚函数的函数入口地址放到虚函数表中,并且在类中秘密增加一个指针,这个指针就是vpointer(缩写vptr),这个指针是指向对象的虚函数表。在多态调用的时候,根据vptr指针,找到虚函数表来实现动态绑定。
验证对象中的虚指针
#include <iostream>
using namespace std;
class A {
public:
virtual void func1() {}
virtual void func2() {}
};
//B类为空,那么大小应该是1字节,实际情况是这样吗?
class B : public A {};
void test() {
cout << "A size:" << sizeof(A) << endl;
cout << "B size:" << sizeof(B) << endl;
}
int main(int argc, char *argv[]) {
test();
return 0;
}
程序输出:
A size:4
B size:4
在编译阶段,编译器秘密增加了一个vptr指针,但是此时vptr指针并没有初始化指向虚函数表(vtable)。
什么时候vptr才会指向虚函数表?在对象构建的时候,也就是在对象初始化调用构造函数的时候。编译器首先默认会在我们所编写的每一个构造函数中,增加一些vptr指针初始化的代码。如果没有提供构造函数,编译器会提供默认的构造函数,那么就会在默认构造函数里做此项工作,初始化vptr指针,使之指向本对象的虚函数表。
起初,子类继承基类,子类继承了基类的vptr指针,这个vptr指针是指向基类虚函数表,当子类调用构造函数,使得子类的vptr指针指向了子类的虚函数表。
当子类无重写基类虚函数时:
过程分析:
Animal* animal = new Dog;
animal->fun1();
当程序执行到这里,会去animal指向的空间中寻找vptr指针,通过vptr指针找到func1函数,此时由于子类并没有重写也就是覆盖基类的func1函数,所以调用func1时,仍然调用的是基类的func1
执行结果: 我是基类的func1
测试结论: 无重写基类的虚函数,无意义
当子类重写基类虚函数时:
过程分析:
Animal* animal = new Dog;
animal->fun1();
当程序执行到这里,会去animal指向的空间中寻找vptr指针,通过vptr指针找到func1函数,由于子类重写基类的func1函数,所以调用func1时,调用的是子类的func1
执行结果: 我是子类的func1
测试结论: 无重写基类的虚函数,无意义
多态的成立条件
有继承
子类重写父类虚函数
返回值、函数名字、函数参数必须和父类完全一致(析构函数除外)
子类中virtual关键字可写可不写,建议写
类型兼容,父类指针、父类引用 指向 子类对象
抽象基类和纯虚函数
在设计时,常常希望基类仅仅作为其派生类的一个接口。这就是说,仅想对基类进行向上类型转换使用它的接口,而不希望用户实际的创建一个基类的对象。同时创建一个纯虚函数允许接口中放置成员原函数,而不一定要提供一段可能对这个函数毫无意义的代码。
做到这点,可以在基类中加入至少一个纯虚函数(pure virtual function),使得基类称为抽象类(abstract class)。
纯虚函数使用关键字virtual,并在其后面加上
=0
。如果试图去实例化一个抽象类,编译器则会阻止这种操作。当继承一个抽象类的时候,必须实现所有的纯虚函数,否则由抽象类派生的类也是一个抽象类。
Virtual void fun() = 0;
告诉编译器在vtable(虚函数表)中为函数保留一个位置,但在这个特定位置不放地址。
建立公共接口目的是为了将子类公共的操作抽象出来,可以通过一个公共接口来操纵一组类,且这个公共接口不需要实现(或者不需要完全实现)。可以创建一个公共类。
在纯虚函数学习中必须记住以下两点
使用纯虚函数的类叫做抽象类,不能用该类来实例化对象,主要用作基类来生成子类用的;不能实例化对象,但是可以定义指针和引用变量,因为要以此实现多态。
子类中必须实现该基类中定义的纯虚函数。
案例: 模板方法模式
#include <iostream>
using namespace std;
//抽象制作饮品
class AbstractDrinking {
public:
//烧水
virtual void Boil() = 0;
//冲泡
virtual void Brew() = 0;
//倒入杯中
virtual void PourInCup() = 0;
//加入辅料
virtual void PutSomething() = 0;
//规定流程
void MakeDrink() {
Boil();
Brew();
PourInCup();
PutSomething();
}
};
//制作咖啡
class Coffee : public AbstractDrinking {
public:
//烧水
virtual void Boil() {
cout << "煮农夫山泉!" << endl;
}
//冲泡
virtual void Brew() {
cout << "冲泡咖啡!" << endl;
}
//倒入杯中
virtual void PourInCup() {
cout << "将咖啡倒入杯中!" << endl;
}
//加入辅料
virtual void PutSomething() {
cout << "加入牛奶!" << endl;
}
};
//制作茶水
class Tea : public AbstractDrinking {
public:
//烧水
virtual void Boil() {
cout << "煮自来水!" << endl;
}
//冲泡
virtual void Brew() {
cout << "冲泡茶叶!" << endl;
}
//倒入杯中
virtual void PourInCup() {
cout << "将茶水倒入杯中!" << endl;
}
//加入辅料
virtual void PutSomething() {
cout << "加入食盐!" << endl;
}
};
//业务函数
void DoBussiness(AbstractDrinking* drink) {
drink->MakeDrink();
delete drink;
}
void test() {
DoBussiness(new Coffee);
cout << "--------------" << endl;
DoBussiness(new Tea);
}
int main(int argc, char *argv[]) {
test();
return 0;
}
程序输出:
煮农夫山泉!
冲泡咖啡!
将咖啡倒入杯中!
加入牛奶!
--------------
煮自来水!
冲泡茶叶!
将茶水倒入杯中!
加入食盐!
纯虚函数和多继承
多继承带来了一些争议,但是接口继承可以说是一种毫无争议的运用了。
绝大数面向对象语言都不支持多继承,但是绝大数面向对象对象语言都支持接口的概念,C++中没有接口的概念,但是可以通过纯虚函数实现接口。
多重继承接口不会带来二义性和复杂性问题。接口类只是一个功能声明,并不是功能实现,子类需要根据功能说明定义功能实现。
注意:除了析构函数外,其他声明都是纯虚函数。
虚析构函数
虚析构函数作用
虚析构函数是为了解决基类的指针指向派生类对象,并且派生类对象中有成员属性存放在堆区的情况下,不实现基类的虚析构函数无法调用派生类析构函数的问题。
当派生类对象的成员属性使用了堆区内存的情况下,必须把基类的析构函数实现成虚函数。
示例代码
#include <iostream>
using namespace std;
class People {
public:
People() {
cout << "构造函数 People!" << endl;
}
virtual void showName() = 0;
virtual ~People() {
cout << "析构函数 People!" << endl;
}
};
class Worker : public People {
public:
Worker() {
cout << "构造函数 Worker!" << endl;
pName = new char[10];
}
virtual void showName() {
cout << "打印子类的名字!" << endl;
}
~Worker() {
cout << "析构函数 Worker!" << endl;
if (pName != NULL) {
delete pName;
}
}
private:
char* pName;
};
void test() {
People* people = new Worker;
people->~People();
}
int main(int argc, char *argv[]) {
test();
return 0;
}
程序输出:
构造函数 People!
构造函数 Worker!
析构函数 Worker!
析构函数 People!
纯虚析构函数
纯虚析构函数在C++中是合法的,但是在使用的时候有一个额外的限制:必须为纯虚析构函数提供一个函数体。
那么问题是:如果给虚析构函数提供函数体了,那怎么还能称作纯虚析构函数呢?
纯虚析构函数和非纯析构函数之间唯一的不同之处在于纯虚析构函数使得基类是抽象类,不能创建基类的对象。
示例代码
#include <iostream>
using namespace std;
//非纯虚析构函数
class A {
public:
virtual ~A();
};
A::~A() {}
//纯析构函数
class B {
public:
virtual ~B() = 0;
};
B::~B() {}
void test() {
A a;//A类不是抽象类,可以实例化对象
B b;//B类是抽象类,不可以实例化对象
}
int main(int argc, char *argv[]) {
test();
return 0;
}
如果类的目的不是为了实现多态,作为基类来使用,就不要声明虚析构函数;反之,如果类的目的是为了实现多态,则应该为类声明虚析构函数。
重写、重载、重定义
,同一作用域的同名函数同一个作用域
参数个数、参数顺序、参数类型不同
和函数返回值,没有关系
const也可以作为重载条件
do(const Teacher& t){}
do(Teacher& t)
有继承
子类(派生类)重新定义父类(基类)的同名成员(非virtual函数)
有继承
子类(派生类)重写父类(基类)的virtual函数
函数返回值、函数名字、函数参数,必须和基类中的虚函数一致
示例代码
#include <iostream>
using namespace std;
class A {
public:
//同一作用域下,func1函数重载
void func1() {}
void func1(int a) {}
void func1(int a, int b) {}
void func2() {}
virtual void func3() {}
};
class B : public A {
public:
//重定义基类的func2,隐藏了基类的func2方法
void func2() {}
//重写基类的func3函数,也可以覆盖基类func3
virtual void func3() {}
};
int main(int argc, char *argv[]) {
return 0;
}
RTTI
RTTI(Run Time Type Identification)运行时类型识别,通过运行时类型识别程序能够使用基类的指针或者引用来检查这些指针或引用所指对象的实际派生类型。
我们可以把RTTI看成是一种系统提供给我们的能力或者功能,这种能力或功能是通过两个运算符来体现的:
dynamic_cast
:能够将基类指针或引用安全的转换为派生类指针或引用;typeid
:返回指针或引用所指对象的实际类型。
dynamic_cast<目标类型>(被转换的变量名)
对于指针类型成功返回指针,失败返回空指针nullptr
;对于引用类型成功返回一个引用类型,失败抛出std::bad_cast
异常。
利用汇编分析虚函数和静动态绑定
环境构建,我们先编写一段简单的带有继承关系的代码
#include <iostream>
using namespace std;
class Base {
public:
void show() { cout << "Base::show()" << endl; }
void show(int) { cout << "Base::show(int)" << endl; }
protected:
int _a;
};
class Derive : public Base {
public:
void show() { cout << "Derive::show()" << endl; }
protected:
int _b;
};
int main(int argc, char *argv[]) {
// 此时派生类中的show重定义了基类中的show方法,导致基类中所有的同名方法都被隐藏了
Derive d;
d.show(); // 因为发生了隐藏,所以使用派生类调用的就是自己的show
//d.show(10); // 因此这句是无法调用的
Base *pb = &d; // 当使用基类指针指向子类对象时,因为是基类指针,所以调用的都是基类的方法
pb->show(); // 调用的基类中的show
pb->show(10); // 调用的基类中的show(int)
return 0;
}
int main(int argc, char *argv[]) {
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 83 ec 30 sub $0x30,%rsp
c: 89 7d dc mov %edi,-0x24(%rbp)
f: 48 89 75 d0 mov %rsi,-0x30(%rbp)
13: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
1a: 00 00
1c: 48 89 45 f8 mov %rax,-0x8(%rbp)
20: 31 c0 xor %eax,%eax
// 此时派生类中的show重定义了基类中的show方法,导致基类中所有的同名方法都被隐藏了
Derive d;
d.show(); // 因为发生了覆盖,所以使用派生类调用的就是自己的show
22: 48 8d 45 f0 lea -0x10(%rbp),%rax
26: 48 89 c7 mov %rax,%rdi
29: e8 00 00 00 00 call 2e <main+0x2e>
//d.show(10); // 因此这句是无法调用的
Base *pb = &d; // 当使用基类指针指向子类对象时,因为是基类指针,所以调用的都是基类的方法
2e: 48 8d 45 f0 lea -0x10(%rbp),%rax
32: 48 89 45 e8 mov %rax,-0x18(%rbp)
pb->show(); // 调用的基类中的show
36: 48 8b 45 e8 mov -0x18(%rbp),%rax
3a: 48 89 c7 mov %rax,%rdi
3d: e8 00 00 00 00 call 42 <main+0x42>
pb->show(10); // 调用的基类中的show(int)
42: 48 8b 45 e8 mov -0x18(%rbp),%rax
46: be 0a 00 00 00 mov $0xa,%esi
4b: 48 89 c7 mov %rax,%rdi
4e: e8 00 00 00 00 call 53 <main+0x53>
return 0;
$ g++ noVirtual.cpp
$ ./a.out
Derive::show()
Base::show()
Base::show(int)
通过汇编代码,我们可以看到,三次调用show都是call的一个地址,程序编译后函数调用的地址就已经确定了。这就是静态绑定。
上面这段代码主要诠释了隐藏的特性,。接下来我们来看覆盖的情况
#include <iostream>
using namespace std;
class Base {
public:
virtual void show() { cout << "Base::show()" << endl; }
virtual void show(int) { cout << "Base::show(int)" << endl; }
protected:
int _a;
};
class Derive : public Base {
public:
void show() { cout << "Derive::show()" << endl; }
protected:
int _b;
};
int main(int argc, char *argv[]) {
// 此时派生类中的show重写了基类中的show方法,导致基类中参数列表相同、返回值相同的同名方法都被覆盖了
Derive d;
d.show(); // 因为发生了覆盖,所以使用派生类调用的就是自己的show
//d.show(10); // 这句依然是无法调用的,需要使用::添加基类作用域才能调用
Base *pb = &d; // 当使用基类指针指向子类对象时
pb->show(); // 调用的派生类中的show,因为派生类重写了基类中的show
pb->show(10); // 调用的基类中的show(int)
return 0;
}
int main(int argc, char *argv[]) {
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 83 ec 40 sub $0x40,%rsp
c: 89 7d cc mov %edi,-0x34(%rbp)
f: 48 89 75 c0 mov %rsi,-0x40(%rbp)
13: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
1a: 00 00
1c: 48 89 45 f8 mov %rax,-0x8(%rbp)
20: 31 c0 xor %eax,%eax
// 此时派生类中的show重写了基类中的show方法,导致基类中参数列表相同、返回值相同的同名方法都被覆盖了
Derive d;
22: 48 8d 45 e0 lea -0x20(%rbp),%rax
26: 48 89 c7 mov %rax,%rdi
29: e8 00 00 00 00 call 2e <main+0x2e>
d.show(); // 因为发生了覆盖,所以使用派生类调用的就是自己的show
2e: 48 8d 45 e0 lea -0x20(%rbp),%rax
32: 48 89 c7 mov %rax,%rdi
35: e8 00 00 00 00 call 3a <main+0x3a>
//d.show(10); // 这句依然是无法调用的,需要使用::添加基类作用域才能调用
Base *pb = &d; // 当使用基类指针指向子类对象时
3a: 48 8d 45 e0 lea -0x20(%rbp),%rax
3e: 48 89 45 d8 mov %rax,-0x28(%rbp)
pb->show(); // 调用的派生类中的show,因为派生类重写了基类中的show
42: 48 8b 45 d8 mov -0x28(%rbp),%rax
46: 48 8b 00 mov (%rax),%rax
49: 48 8b 10 mov (%rax),%rdx
4c: 48 8b 45 d8 mov -0x28(%rbp),%rax
50: 48 89 c7 mov %rax,%rdi
53: ff d2 call *%rdx
pb->show(10); // 调用的基类中的show(int)
55: 48 8b 45 d8 mov -0x28(%rbp),%rax
59: 48 8b 00 mov (%rax),%rax
5c: 48 83 c0 08 add $0x8,%rax
60: 48 8b 10 mov (%rax),%rdx
63: 48 8b 45 d8 mov -0x28(%rbp),%rax
67: be 0a 00 00 00 mov $0xa,%esi
6c: 48 89 c7 mov %rax,%rdi
6f: ff d2 call *%rdx
return 0;
通过汇编代码可以看到,当使用派生类对象调用show的时间也是call的一个地址,说明在编译阶段函数调用的地址就已经确定了。但是当使用基类指针指向派生类对象的时间,两次调用show的时候call的都是寄存器,说明在编译阶段并不能确定函数调用的地址,当程序运行时才能知道rdx寄存器中存放的地址具体是多少。
通过代码的直接对比发现只是给基类中的两个show方法添加了virtual关键字,在使用基类指针指向子类对象的时间,函数的调用地址就从静态绑定(编译时期的绑定)变成了动态绑定(运行时期的绑定)。
linux中查看class布局
网上大多数文章使用的是g++ -fdump-class-hierarchy vptr.cpp
生成输出文件,通过文件查看内存布局。但是优于g++的版本问题,在8之后该选项已经失效,改用g++ -fdump-lang-class vptr.cpp
才能正确生成输出文件。
输出文件是一个包含几多信息的文件,可以在文件中通过类名定位到要查看的类。
$ g++ -fdump-lang-class noVirtual.cpp
$ g++ -fdump-lang-class virtual.cpp
通过派生类的class布局可以看到,在基类中含有虚函数的情况下派生类也产生了虚函数表,并且在虚函数表中存放有自己重写的show函数的地址及基类中show(int)函数的地址。
面试题
一个类添加了虚函数对这个类有什么影响?
- 一个类添加了虚函数,那么编译器会在编译阶段为这个类生成一张虚函数表vftable,一个类只有一张vftable。vftable中主要存储的是RTTI(运行时类型识别)指针和虚函数的地址。当程序运行时,每一张虚函数表都会加载到
.rodata
区。 - 一个类里面定义了虚函数,那么这个类定义的对象运行时,内存中开始部分也会多存储一个虚函数指针vfptr,指向该类的虚函数表vftable。一个类定义的n个对象,它们的vfptr指向的都是同一张vftable,也就是一个类只生成的唯一的一张vftable。
- 一个类里面虚函数的个数不会影响该类对象内存的大小,影响的是该类类型虚函数表(vftable)的大小。
- 如果派生类中的方法和基类的某个方法的返回值、函数名、参数列表都相同,而且基类的该方法是virtual虚函数,那么这个派生类的方法自动处理成虚函数。此时发生的是覆盖,覆盖的是派生类vftable中该方法函数调用的地址。(覆盖就是重写)
实例分析
空口无凭,我们来使用代码演示一下。创建一个基类Base,其中有两个虚函数show,一个普通成员函数show;派生类Derive,其中重写了基类中的无参虚函数show。
class Base {
virtual void show() {}
virtual void show(int a) {}
void show(double d) {}
};
class Derive : public Base {
void show() {}
};
int main() { return 0; }
$ g++ -fdump-lang-class vftable.cc
$ ls
a.out a-vftable.cc.001l.class vftable.cc
$ cat a-vftable.cc.001l.class
Vtable for Base
Base::_ZTV4Base: 4 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI4Base)
16 (int (*)(...))Base::show
24 (int (*)(...))Base::show
Class Base
size=8 align=8
base size=8 base align=8
Base (0x0x778941db3420) 0 nearly-empty
vptr=((& Base::_ZTV4Base) + 16)
Vtable for Derive
Derive::_ZTV6Derive: 4 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI6Derive)
16 (int (*)(...))Derive::show
24 (int (*)(...))Base::show
Class Derive
size=8 align=8
base size=8 base align=8
Derive (0x0x778941c0e340) 0 nearly-empty
vptr=((& Derive::_ZTV6Derive) + 16)
Base (0x0x778941db3660) 0 nearly-empty
primary-for Derive (0x0x778941c0e340)
通过输出文件可以看到,基类Base和派生类Derive都生成了一张虚函数表vftable,并且基类Base的vftable中有三个函数地址,分别是:
- 0:虚函数表的起始地址
- 8:RTTI指针
- 16:虚函数show()的地址
- 24:虚函数show(int a)的地址
派生类Derive的vftable中也有三个函数地址,分别是:
- 0:虚函数表的起始地址
- 8:RTTI指针
- 16:虚函数show()的地址
- 24:虚函数show(int a)的地址
Tips
- 基类中的
show(double d)
不是虚函数,所以基类的vftable中没有show(double d)
函数的地址。 - 派生类重写了基类中的
show()
函数,所以派生类中有一个指针是指向自身的show()
的;派生类没有重写基类中的show(int a)
函数,所以派生类的vftable中show(int a)
函数的地址还是基类指向基类中的show(int a)
。
可以看到,基类和派生类的虚函数表中函数地址都是一样的,说明基类和派生类的虚函数表是共享的。
是不是虚函数的调用一定就是动态绑定的?
答案肯定是否定的。
- 如果是使用对象 调用虚函数发生的就是静态绑定
- 如果是使用指针变量或引用变量 调用虚函数发生的就是动态绑定
代码分析
#include <iostream>
using std::cout;
using std::endl;
class Base {
public:
virtual void show() { cout << "call Base::show()" << endl; }
};
class Derive : public Base {
public:
void show() { cout << "call Derive::show()" << endl; }
};
int main() {
Base b;
Derive d;
// 使用对象本身调用 -> 静态绑定
b.show();
d.show();
cout << "-----" << endl;
// 使用指针调用 -> 动态绑定
Base *pb1 = &b;
pb1->show();
Base *pb2 = &d;
pb2->show();
cout << "-----" << endl;
// 使用引用调用 -> 动态绑定
Base &rb1 = b;
rb1.show();
Base &rb2 = d;
rb2.show();
return 0;
}
编译并运行
$ g++ dynamic_bind.cc
$ ./a.out
call Base::show()
call Derive::show()
-----
call Base::show()
call Derive::show()
-----
call Base::show()
call Derive::show()
pb2->show();
和rb2.show();
都是使用基类指针或引用指向派生类对象,调用的却是派生类中的show函数,所以说是动态绑定的。
在类的构造函数中调用虚函数也是静态绑定的。(在构造函数中调用其他函数,不会发生动态绑定)
模板属不属于多态,能不能归于静态多态
在C++中,模板不属于传统意义上的多态(无论是静态多态还是动态多态),尽管其编译时行为可能与静态多态有表面相似性。以下是关键区分点:
1. 模板的核心目的
模板(包括函数模板和类模板)的核心是泛型编程,即通过参数化类型实现代码复用。例如:
template <typename T>
T add(T a, T b) { return a + b; }
该模板允许对任意支持+
操作符的类型进行操作,但其本质是代码生成机制(编译时为具体类型生成特化版本),而非多态的"同一接口不同行为"设计。
2. 静态多态的实质
静态多态(如函数重载、运算符重载)的本质是编译时接口绑定,例如:
void print(int x) { /*...*/ } // 重载1
void print(double x) { /*...*/ } // 重载2
调用print(3.14)
时,编译器直接选择print(double)
,没有运行时动态性。这与模板的泛型实例化机制在编译期决策上有相似性,但设计目标不同。
3. 关键区别
特性 | 模板 | 静态多态 |
---|---|---|
核心目标 | 类型泛化 | 接口复用 |
决策时机 | 编译时生成代码 | 编译时绑定接口 |
行为变化 | 通过类型特化改变行为 | 通过不同函数实现改变行为 |
扩展性 | 通过模板参数扩展类型 | 通过添加重载函数扩展接口 |
4. 模板与多态的交集:特化与偏特化
模板允许通过 特化(Specialization) 为特定类型提供定制实现:
template <>
std::string add(std::string a, std::string b) {
return a + " + " + b;
}
这种特化看似类似静态多态的"不同行为",但它是编译时类型分发的结果,而非面向对象的接口设计。模板特化的本质是代码生成,而非多态的"同一接口不同实现"。
结论
模板应被视为泛型编程工具,而非多态的分支。虽然模板的编译时行为与静态多态在决策时机上有相似性,但其设计哲学和用途(类型泛化 vs 接口复用)存在本质区别。因此,模板不属于多态,无论是静态还是动态。
如何解释多态?
C++中的多态分为静态多态和动态多态。
静态多态就是编译时期的多态,在编译时期就能确定类型,或者在编译时期就能够确定函数的调用地址;
动态多态是在继承结构中,基类指针或引用指向派生类对象,通过该指针或引用调用同名覆盖方法,基类指针或引用指向哪个派生类对象,就调用哪个派生类对象的同名覆盖方法。
多态底层是通过动态绑定来实现的。基类指针或引用指向了哪个派生类对象,就访问哪个派生类对象的虚函数指针,通过虚函数指针进而访问该派生类的虚函数表,通过虚函数表中存放的虚函数的调用地址来进行函数调用,以此来实现动态多态。
代码分析
有以下程序代码,请给出程序运行结果并分析程序运行情况
#include <iostream>
#include <string>
using namespace std;
class Animal {
public:
Animal(string name) : _name(name) {}
virtual void bark() = 0;
protected:
string _name;
};
class Cat : public Animal {
public:
Cat(string name) : Animal(name) {}
void bark() { cout << _name << " miao miao~" << endl; }
};
class Dog : public Animal {
public:
Dog(string name) : Animal(name) {}
void bark() { cout << _name << " wang wang!" << endl; }
};
int main(int argc, char *argv[]) {
Animal *p1 = new Cat("橘猫");
Animal *p2 = new Dog("柯基");
int *p3 = (int *) p1;
int *p4 = (int *) p2;
int temp = p3[0];
p3[0] = p4[0];
p4[0] = temp;
p1->bark();
p2->bark();
delete p1;
delete p2;
return 0;
}
程序运行结果:
橘猫 wang wang!
柯基 miao miao~
当程序运行至28行,p1指向的对象内存空间中vfptr存放的是Cat类的vftable,p2指向的对象内存空间中vfptr存放的是Dog类的vftable。程序继续执行至35行,p1指向的对象内存空间中vfptr指向的地址和p2的互换了,导致程序运行36行时调用的是Dog类的bark,但是只有vfptr中存放的地址呼唤了,其他数据没有呼唤,因此p1指向的对象的_name还是橘猫,最终就导致橘猫汪汪叫,程序运行37行p2调用bark时与p1相似,导致的结果就是柯基喵喵叫。之后delete p1 p2后程序正常运行结束。
有以下程序代码,请给出程序运行结果并分析程序运行情况
#include <iostream>
using namespace std;
class Base {
public:
virtual void show(int i = 10) { cout << "Base::show() i=" << i << endl; }
};
class derive : public Base {
public:
void show(int i = 20) { cout << "derive::show() i=" << i << endl; }
};
int main(int argc, char *argv[]) {
Base *pb = new derive();
pb->show();
delete pb;
return 0;
}
程序运行结果:
derive::show() i=10
会出现这种运行结果是因为函数参数压栈的指令是在程序编译时期就确定了的,当代码进行编译时,编译器看到是Base类型的指针调用show函数,就去Base类中找到show函数,看到它的默认参数就把默认参数进行压栈了。
因为是指针调用虚函数,所以第16行会发生多态,程序运行时才能确定调用的是基类的show还是派生类的show,当程序发现指针指向的是派生类对象,并且派生类重写了虚函数,因此就会调用派生类中的show方法。最终导致的结果解释i的值是Base类中show的默认参数10,但是打印的是派生类中的show。
有以下程序代码,请给出程序运行结果并分析程序运行情况
#include <iostream>
using namespace std;
class Base {
public:
virtual void show() { cout << "Base::show()" << endl; }
};
class derive : public Base {
private:
void show() { cout << "derive::show()" << endl; }
};
int main(int argc, char *argv[]) {
Base *pb = new derive();
pb->show();
delete pb;
return 0;
}
程序运行结果:
derive::show()
程序编译之后可以正常打印出derive::show()
,成员方法能不能访问是在编译阶段就确定了的,编译时期会检查语法错误,如果调用私有成员方法编译会报错,不能通过编译。
本程序中,编译器看到是使用基类指针调用show方法,并且show在基类中是公有成员方法,因此编译时期不会报错,又因为是指针调用虚函数,因此会发生多态,程序运行时会直接从vfptr指向的vftable中找到show的地址并调用,在这个过程中没有权限检查机制存在,在调用show时编译生成的汇编代码中根本没有权限检查的相关代码。
分析下面这段代码运行后的情况,程序能否正常运行结束?为什么?
#include <cstring>
#include <iostream>
using namespace std;
class Base {
public:
Base() {
cout << "Base::Base()" << endl;
clean();
}
void clean() {
memset(this, 0, sizeof(*this));
}
virtual void show() {
cout << "Base::show()" << endl;
}
~Base() {
cout << "Base::~Base()" << endl;
}
};
class Derive : public Base {
public:
Derive() {
cout << "Derive::Derive()" << endl;
}
void show() {
cout << "Derive::show()" << endl;
}
~Derive() {
cout << "Derive::~Derive()" << endl;
}
};
void test1() {
Base *pb = new Derive;
pb->show();
delete pb;
}
void test2() {
Base *pb = new Base();
pb->show();
delete pb;
}
int main(int argc, char *argv[]) {
test1();
test2();
return 0;
}
程序在执行test1函数的时间正常运行,但是运行test2函数的时间会发生错误,只能打印基类的构造函数,打印以后在调用show方法的时间发生错误。
分析程序运行过程,在test1函数中,使用基类指针指向派生类对象,会先调用基类的构造函数,然后再调用派生类的构造函数,析构的时间顺序与构造相反。在调用构造函数的时间会先把vftable的地址写到vfptr指针中,然后基类调用了clean方法把对象中所有内存清零了,此时会把vfptr中存放的地址清零;但是基类构造函数调用完成之后会调用派生类的构造函数,此时会把派生类的vftable的地址重新写入到vfptr中,派生类也重写了基类的show方法,因此最终调用的时间调用的是派生类的show,因此test1函数中不会出现问题。
当程序运行test2函数时,基类指针指向的是基类的对象,因为使用指针调用虚函数会发生过动态绑定,因此成员函数show的地址需要程序运行时从vftable中去取。但是在构造函数中,vfptr指针中存放的地址被清零了,程序运行时无法找到vftable的地址,又因为0地址不可读不可写,因此调用show的时间程序会出错。