C++ 多态

这并不是常规的虚函数介绍,如果没有了解C++继承,还请移步C++继承,否则下面地内容可能会有些困难


虚函数

接下来,看看虚函数是什么样的吧

虚函数一旦被声明,则必须有定义,否则链接时会报错,除非不实例化这个类。一般来说,如果我们没有使用到一个函数,则无需定义它。但虚函数最终会以一个函数指针的方式存在,仅在调用时将其取出。因为函数指针是一个变量,所以编译器也不清楚是否会使用到某个虚函数(编译器难以预测变量的值,也就是函数指针的值),所以我们必须为每一个虚函数提供定义。

虚函数的声明:

virtual type func();

虚函数会被类存放入虚函数表,并将虚函数表放置在类的开头的位置。

下面我们来验证虚函数表的存在

typedef void func(void); //要与虚函数的函数类型对应
typedef int DWORD; //习惯把要看作十六进制的int 写作 DWORD

class Animal {
public:
    virtual void move() { cout << "动物在动" << endl; }
    virtual void breath() { cout << "动物在呼吸" << endl; }
};

void main() {
    Animal a;
    DWORD* aThis = (DWORD*)&a; //这里写 int* 是为了取值转成地址, Amimal*解引用是不正确的
    func** pVirtualFuncTable = (func**)(*aThis);//等会还要运算所以先写成int
    
    //func* pVirtualFuncTable[] = a.(*this);//这相当于这样的声明
    //func** pVirtualFuncTable = *(func***)&a; //上面两句的替代写法,能理解一种就行

    //第一种调用方式,常规写法
    a.move();
    a.breath();
    //第二种调用方式
    
    (*pVirtualFuncTable)();//(func*)pVirtualFuncTable[0]; 
    (*(pVirtualFuncTable + 1))();//(func*)pVirtualFuncTable[1]
}
//打印结果:
//动物在动
//动物在呼吸
//动物在动
//动物在呼吸

两种方法都能正确调用虚函数

我们可以聚焦到第二种调用方法上,我们采用DWORD*也就是int*来读取a.this。接着我们对aThis进行了解引用,并将解引用的值当做一个指针转存起来,起名pVirtualFuncTable(虚函数表)

其实可以想象这是一个结构体,结构体的第一个元素存放的是一个数组,而我们获取的是这个数组的首地址,所以pVirtualFuncTable所指向的就是虚函数数组的首地址,这个数组里存放的都是函数的地址。

struct cls{
	int addrs[n];
	...
}

虚函数表里的内容是函数指针,也就是说*pVirtualFuncTable是一个函数指针,可以简单理解成func* pVirtualFuncTable[]*pVirtualFuncTable相当于pVirtualFuncTable[0],能取出一个函数指针func*,但编译器也不会让我们写arr[] = &x这样的代码,所以只能写成上面的那种形式。


那么有的同学就要问了,如果我实例化多个类的时候,虚函数表会是同一个吗?

废话不多说,直接写代码好吧

void main(){
	Animal a1, a2;
	func* pVirtualFuncTable1 = *(func**)&a1;
    func* pVirtualFuncTable2 = *(func**)&a2;
    cout << (pVirtualFuncTable1 == pVirtualFuncTable2) << endl;
}//打印结果: true

确实,他们会指向同一个虚函数表


上面那个小实验的补充内容:(可选则跳过这段内容)

有的人可能会好奇到打印一下函数的第一条指令是不是一样的,但是很遗憾,是不一样的。如果对这个问题不感兴趣,那么就跳过吧。

int main(){
	...
	
	//尝试打印这些值,但是很遗憾,每个函数指针打印出来的值并不能很好地对应
	//需要把 跳转表 关掉才行,捣鼓了半天,也没搞懂怎么关
	printf("%p\t", &Animal::move); 
	printf("%p\n", &Animal::breath);
	printf("%p\t", *pVirtualFuncTable);
	printf("%p\n", *(pVirtualFuncTable + 1));
}

这一段打印代码不能打印出一样的地址原因如下:

这里涉及到跳转表,函数调用时首先会跳掉一个全是jmp指令的地方,然后才跳到函数开始的地方。然而每次调用的时候,都是不同的地址,然而都会跳到同一个地方去执行,这意味着两个跳转表都能跳到正确的位置执行,有兴趣的同学可以自己想办法关掉跳转表,再打印


多态

首先回顾一下,之前所学的内容

之前说过,如果出现同名函数,那么重名的函数会被重写,但是并不会将其删除,而是被隐藏起来了。所以我们会优先调用当前类型的同名函数,People* p = new People,那么,当前类型就是People

如何实现多态?

当我们对一个虚函数进行重写的时候,就能实现多态

class Animal {
public:
    virtual void move() { cout << "动物在动" << endl; }
    virtual void breath() { cout << "动物在呼吸" << endl; }
};

class Bird : public Animal {
public:
    void move() { cout << "鸟儿在飞" << endl; }
    void breath() { cout << "鸟儿在空中呼吸" << endl; }
};

void main() {
    Animal* a  = new Animal;
    Animal* a2 = new Bird;
    a->move();
    a->breath();
    a2->move();
    a2->breath();
    delete a;
    delete a2;
}
//打印结果:
//动物在动
//动物在呼吸
//鸟儿在飞
//鸟儿在空中呼吸

如果没有写virtual关键字的话,声明是什么类的变量,就会调用哪个类的函数。Animal a不管后面写的是什么,如果函数不是virtual的,那么就会调用Animal::func()。如果声明了virtual的,那么就会按照定义时(new)的类型进行调用。


到底是什么使得virtual,能调用new的类的函数呢?

之前我们说过了,virtual的函数会装入一个虚函数表里。那么有没有可能是在new的时候,就直接把自己的虚函数写入了这个表呢?

下面我们来试着验证一下:

稍微修改忆点点main函数

void main() {
    Animal* a  = new Animal;
    Animal* a2 = new Bird;
    Bird*   b = new Bird;
    func** a_virTable = *(func***)a;
    func** a2_virTable = *(func***)a2;
    func** b_virTable = *(func***)b;
    //func* virTable[] = *(func***)a; //这样的写法是错误的,只是便于理解
    printf("a  point to move:\t %p\n", *a_virTable); //打印函数指针
    printf("a  point to breath:\t %p\n", *(a_virTable + 1));
    printf("a2 point to move:\t %p\n", *a2_virTable);
    printf("a2 point to breath:\t %p\n", *(a2_virTable + 1));
    printf("b  point to move:\t %p\n", *b_virTable);
    printf("b  point to breath:\t %p\n", *(b_virTable + 1));
    ((func*)*a_virTable)();         //a->move();
    ((func*)*(a_virTable + 1))();   //a->breath();
    ((func*)*a2_virTable)();        //a2->move();
    ((func*)*(a2_virTable + 1))();  //a2->breath();
    ((func*)*b_virTable)();         //b->move();
    ((func*)*(b_virTable + 1))();   //b->breath();
    delete a;
    delete a2;
    delete b;
}
//打印结果:
//a  point to move:        010E1537
//a  point to breath:      010E1762
//a2 point to move:        010E1609
//a2 point to breath:      010E1992
//b  point to move:        010E1609
//b  point to breath:      010E1992

我们发现,正如猜想的那样,a2的虚函数表的内容和b的一模一样。

如果虚函数没有被重写,那么会装载父类的虚函数,这和继承是一样的。可以自己验证一下。

书上把填充虚函数表这个行为称之为绑定,但我们现在只看结果,不用去在意过程是怎么样的,不用关注具体到在什么时候进行的绑定,除非真的到了要了解这个的时候。

我们只需要大概了解到虚函数在new的时候会将虚函数填充完整, 如果遇到了没有重载的虚函数,那么就会将父类的虚函数填入表中,如果父类也没有向上进行寻找,直到没有父类为之。


不要在构造函数和析构函数里调用虚函数

因为这时候和上面所说的完全不符

class People {
public:
    virtual void func() { cout << "people func" << endl; }
    People() { 
        cout << "People 构造函数:";
        func();
    }
    virtual ~People() { 
        cout << "People 析构函数:";
        func();
    }
};

class Student : public People {
public:
    virtual void func() { cout << "student func" << endl; }
    Student() { 
        cout << "Student 构造函数:";
        func(); 
    }
    ~Student() { 
        cout << "Student 析构函数:"; 
        func(); 
    }
};

    
void test33() {
    Student s;
}
//打印结果:
//People 构造函数:people func
//Student 构造函数:student func
//Student 析构函数:student func
//People 析构函数:people func

构造函数:当People构造函数调用func的时候,Student还没有完成构造,所以Student的参数尚未初始化,如果调用了Student::func()那么将产生未定义行为

析构函数:当People析构函数调用func的时候,Student已经被释放,所以Student的参数已经没有意义了,如果调用了Student::func()那么将产生未定义行为

因此编译器就出了这么一个折中的办法,请务必注意


虚函数的析构函数

上次谈到使用多态形式时,delete 实例只会调用当前类型的析构函数,因为子类的空间并不归父类管

那么想要调用子类的析构函数,就要另辟蹊径了。

上面我们讲到,如果函数被声明为虚函数,那么实例化(new)的类型的对应函数的函数指针就会被装载到虚函数表中。不管当前类是什么,调用的都是这个虚函数表中的虚函数的指针。

那么如果我们把析构函数声明成虚函数,new的时候析构函数就会装载到虚函数表里,我们再调用这个类的析构函数的时候,就会查虚函数表,刚好能找到创建时放入的函数。而子类的析构函数会调用父类的析构函数。这一波简直完美。

class Animal {
public:
    virtual ~Animal() { cout << "Animal 析构函数" << endl; }
};
class Bird : public Animal {
public:
    ~Bird() { cout << "Bird析构函数" << endl; }
};
int main(){
	Animal* b = new Bird;
	delete b;
}
//打印结果:
//Bird析构函数
//Animal 析构函数

纯虚函数

有纯虚函数的类被当做是抽象类。
这个类不能被实例化,因为纯虚函数并没有定义(实现),而之前提到虚函数必须要有定义。

纯虚函数的声明:

virtual void func() = 0;

没错,就是显示地写出 = 0,表示我不回去实例化它,如果要在声明纯虚函数的作用域定义它,会被编译器拒绝。这样就能防止不小心定义了这个纯虚函数。

继承的父类中有纯虚函数,但不实例化,那么当前类也被认为是个抽象类。

override 和 final

override关键字的主要作用是防止我们重载写错的

试想一下,我在父类中写了一个函数virtual void func(w_char c),然后我的子类继承了父类,我要重写这个函数。但是一不小心我写错了,写成(virtual) void func(char c)。但编译器并不会报错,因为这是个全新的函数,我们为子类声明一个新的函数,或是一个新的虚函数,这并没有什么不妥,但这个结果却不是我们所想要的。所以为了防止这种错误的发生,C++11起,可以使用override关键字,表示这个函数是被重写的。当我们写void func(char c) override的时候,编译器会查找父类,父类的父类…如果没有找到,那么他就会报错,因为这个函数匹配不到可重写的函数,这是个新的函数,不符合override的语义

final则更为简单,字面上的意思是最后的

所以如果这个关键字写在函数后面,则表示我不希望子类再对这个函数进行重写了,这是最后的版本。如果写在类的后面,则表示这个类是最终版本,不希望再被继承。

总结

  • 虚函数表会被放置在类空间的最上方的位置
  • 当new的时候,函数会将虚函数表填充完整
  • 当子类虚函数没有被重载的时候,那么会想父类进行查找填充
  • 如果父类,父类的父类…查到尽头都没有查到虚函数的定义,那么这个函数不能被实例化
  • 抽象类不能被实例化,如果子类继承了抽象类,但没有重写纯虚函数,那么子类也是抽象类,也不能被实例化
  • 善用override关键字,也许会在关键时刻省去许多麻烦,final表示这个最后一个版本
  • 对了,还有跳转表是个有意思的东西,函数不会直接call到函数本体里去,而是通过一个跳转表重新定位函数所在的地方
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值