c++学习-虚函数学习

来源:互联网 发布:Linux mv移动文件 编辑:程序博客网 时间:2024/05/29 04:46

本文记录我在看《深入探索c++对象模型》时虚函数的学习心得,当然也参考了互联网上的一些资料。先列出参考。
[虚函数浅析]
[关于虚函数的那些事]

基本知识

1 . 虚函数的作用说白了就是:当调用一个虚函数时,被执行的代码必须和调用函数的对象的动态类型相一致。
2 . 编译器需要做的就是如何高效的实现提供这种特性。不同编译器实现细节也不相同。大多数编译器通过 vtbl(virtual table)和 vptr(virtual table pointer)来实现的.
3 . 那么问题来了,通过vptr和vtable到底是怎么实现的?

如果类定义了虚函数,该类及其派生类就要生成一张虚拟函数表,即vtable。而在类的对象地址空间中存储一个该虚表的入口(vptr),占4个字节,这个入口地址是在构造对象时由编译器写入的。所以,由于对象的内存空间包含了虚表入口,编译器能够由这个入口找到恰当的虚函数,这个函数的地址不再由数据类型决定了。(所以,虚函数的地址和对象的内存布局其实是有关系的)。故对于一个父类的对象指针,调用虚拟函数,如果给他赋父类对象的指针,那么他就调用父类中的函数,如果给他赋子类对象的指针,他就调用子类中的函数(取决于对象的内存地址)。

从上面这段话,我们知道。为什么可以多态,因为有了vptr之后,vptr在构造对象时由编译器写入,从而每个对象通过vptr都会关联到逻辑上属于自己的vtable. 从而调用相应的虚函数代码。

4 . 虚函数难道没有缺点?

虚函数运行时所需的代价主要是虚函数不能是内联函。这也是非常好理解的,是因为内联函数是指在编译期间用被调用的函数体本身来代替函数调用的指令,但是虚函数的“虚”是指“直到运行时才能知道要调用的是哪一个函数。”但虚函数的运行时多态特性就是要在运行时才知道具体调用哪个虚函数,所以没法在编译时进行内联函数展开。当然如果通过对象直接调用虚函数它是可以被内联,但是大多数虚函数是通过对象的指针或引用被调用的,这种调用不能被内联。 因为这种调用是标准的调用方式,所以虚函数实际上不能被内联。

  1. 关于包含虚函数的类以及继承该类的内存布局。我主要关心的问题是,假设A类包含虚函数,此时B类单继承了A类,那么在B的内存布局当中,是怎样一种情况。我主要关心的是,B类由于存了A类的副本,会不会保存A::vptr,然后自己生成B::vptr?

我直接给出结论:对于单继承,派生类只会有一个vptr,一个vtable. vptr指向B类的vtable,在B的vtable当中,是对A::vtable进行的扩展。所以,只会有一个vptr,以及一个vtable,B::vtable是对A::vable进行扩展后生成的。不要受保存副本的印象,直接看派生类的内存布局即可。

例子

单继承(无虚函数重写)

class Base{public:    virtual void f();    virtual void g();    virtual void h();};class Derive :public Base{public:    virtual void f1();    virtual void g1();    virtual void h1();};

这里写图片描述
我们可以看到下面几点:
1)派生类的vtable在父类的vtable上进行扩展,但是整个派生类当中只存在一个vtable.
2)虚函数按照其声明顺序放于表中。
3)父类的虚函数在子类的虚函数前面。

单继承(有虚函数重写)

class Base{public:    virtual void f();    virtual void g();    virtual void h();};class Derive :public Base{public:    virtual void f();    virtual void g1();    virtual void h1();};

这里写图片描述
我们从表中可以看到下面几点,
1)覆盖的f()函数被放到了虚表中原来父类虚函数的位置。
2)没有被覆盖的函数依旧。

多继承(无虚函数重写)

这里写图片描述
这里写图片描述
我们可以看到:
1) 派生类扩展了每个基类的vtable,从而有多少个含有基类的虚函数,派生类当中就有多少个vtable
2) 子类的虚函数被放到了第一个父类的vtable中。(所谓的第一个父类是按照声明顺序来判断的)

这样做就是为了解决不同的父类类型的指针指向同一个子类实例,而能够调用到实际的函数。

多继承(有虚函数重写)

这里写图片描述
这里写图片描述

虚继承

这一块稍微说下,有点麻烦。主要是布局的事。
参考了如下两篇链接:
[图说c++对象模型]
[请大神看我对虚函数表和虚基类表的理解对不对?]

虚继承需要区分简单虚继承以及菱形虚继承的情形?对于内存布局的区分要明白,主要是虚继承增加了虚基类表指针,对于这个指针的用途要明白。

虚继承和普通继承的区别主要体现在:
1. 虚继承的子类也单独保留了父类的vprt与虚函数表,主要是单独保留。派生类如果有虚函数会在基类的虚函数表上进行扩展,但是也会单独保存父类的。
2. 虚继承的子类,如果本身定义了新的虚函数,则编译器为其生成一个虚函数指针(vptr)以及一张虚函数表。该vptr位于对象内存最前面。
3. 虚继承子类当中增加了虚基类表和虚基类表指针。

强调:虚基类最大的用途还是在菱形继承的时候只保留一份祖先的数据副本,节省对象的尺寸。

虚基类表是干什么用的?他里面存什么东西?

虚基类表的第二、第三…个条目依次为该类的最左虚继承父类、次左虚继承父类…的内存地址相对于虚基类表指针的偏移值。第一个条目存放虚基类表指针(vbptr)所在地址到该类内存首地址的偏移值。

至于虚基类表指针的原因是什么?我多少有一点明白了,这个你要对比不用虚继承的时候菱形继承的情形。此时,A在B,C中的相对位置是固定的。因为派生类B,C都有A的子对象在里面。注意:我此时是在整个菱形继承的体系里面去说的。但是,如果采用虚继承之后,最后A只保留一份副本,之前BC的布局是要破坏的。所以要记录B、C的位置。


———————–-----------------------—————————
———————–------------华丽分割线------—————————
———————–-----------------------—————————

这一块最近做了点题,有了更深入的理解!
首先还是给出一个参考链接:[c++对象模型-吴秦]

多态到底是怎么支持的

我们都知道多态是通过vptr和vtable进行支持的,那么它到底是怎么支持的。
先看一段代码:

#include <iostream>class A {public:    virtual void f(){ std::cout << "A::f() called." << std::endl; }};class B : public A {public:    virtual void g(){ std::cout << "B::g() called." << std::endl; }};int main( void ){    A* pa = new B();    pa->g();    delete pa;    return 0;}

这段代码的运行结果是什么?结果是编译通不过去。说下误区。

误区

先说下我对vptr和vtable对多态支持的理解。首先,上面也说到了,对于继承的情形,派生类一般是扩展基类的vtable.在这个vtable当中,基类的虚函数排在前面,派生类的虚函数排在后面。当然,如果派生类重写了基类的虚函数,那么排在前面的这个虚函数会替换成派生类当中的。对于派生类而言,它只有一张vtable,因为从外界来看。派生类是单独的一个逻辑单位,虽然它内涵了基类。但是从外界来看,人们看见派生类是不会想到基类的。就好比你看到一个人,难道你会看到他里面的哺乳动物吗?我这段想说的是,整个派生类当中只有一张vtable,它内涵的基类是没有的。

所以,对于上面的代码。pa->g(),pa存储的是派生类对象的地址。所以,我认为,pa->g()一定是从派生类的vtable当中寻找g()。而不是去所谓的基类vtable中去寻找,因为就没有基类的vtable.
但是,问题是。派生类的vtable当中的格式可能如下:

A::f() B::g()

由于此时指针的类型是A::,所以,我认为它只会寻找由A::限定函数。所以,此时找不到g()。编译无法通过。看下面代码:

#include <iostream>class A {public:    virtual void f() { std::cout << "A::f() called." << std::endl; }};class B : public A {public:    virtual void g() { std::cout << "B::g() called." << std::endl; }    virtual void f() { std::cout << "B::f() called." << std::endl; }};int main( void ){    A* pa = new B();    pa->f();    delete pa;    return 0;}

此时,派生类的vtable当中的格式可能如下:

B::f() B::g()

虽然,还是基类指针。但是,此时由于基类的f()函数被重写,从而发生了替换。只能找到B::f()。其实就是刚才A::f()的位置。也就是说,多态如果要发生,有虚函数在语言层面上的支持还是不够的。必须用户完成对于虚函数的重写才可!

例子1

再看下面的代码:

#include <iostream>class A {public:    virtual void f(char a) { std::cout << "A::f() called." << std::endl; }};class B : public A {public:    virtual void g() { std::cout << "B::g() called." << std::endl; }    virtual void f(int a) { std::cout << "B::f() called." << std::endl; }};int main( void ){    A* pa = new B();    pa->f(65);    delete pa;    return 0;}/*A::f() called.*/

此时,派生类的vtable当中的格式可能如下:
| A::f(char)| B::g() |B::f(int)
| ————- |:————-:|
由于虚函数没有发生重写,所以基类的指针在vtable当中查找的时候,只查找A::作用域内的虚函数。所以,调用f(char)。没有调用B::f()。因为,没有发生重写!本质上没有触发多态!因为重写之后,派生类的函数才会替换基类的虚函数。从而基类指针在vtable当中查找的时候,虽然找的是基类作用域的虚函数,但是此时派生类的虚函数会被找出来。

例子2

在看下面的代码:

#include <iostream>class A {public:    virtual void f() { std::cout << "A::f() called." << std::endl; }    void do_f(){ f(); };};class B : public A {public:    virtual void f() { std::cout << "B::f() called." << std::endl; }};int main( void ){    A* pa = new B();    pa->do_f();    delete pa;    return 0;}/*B::f() called.*/

分析上面的代码,do_f()调用不是虚函数调用,紧接着调用f()。但是,f()函数是虚函数,所以只能去vtable当中寻找。我再次强调,当前是派生类对象!!!vtable当中只有一项,就是被重写之后B::f()。所以,调用的就是派生类的f()函数。


———————–-----------------------—————————
———————–------------华丽分割线------—————————
———————–-----------------------—————————

今天再次考虑了这个问题,在上面的基础上有了更进一步的认识。对多态有了更清楚的认识。

#include <iostream>class A {public:    virtual void f(){ std::cout << "A::f() called." << std::endl; }};class B : public A {public:    virtual void f(){ std::cout << "B::f() called." << std::endl; }    virtual void g(){ std::cout << "B::g() called." << std::endl; }};int main( void ){    A* pa = new B();    pa->f();    //pa->g(); compile error: class A has no member named g    return 0;}

看上面这一段代码,几个注意点。

1 . 派生类只有一个vtable,这个vtable是扩展基类的vtable得到的。那么,我们看一下他们的布局。

对于基类而言:
class A vtable:

f (本质是A::f())

对于派生类而言
class B vtable

f(B::f()) g

2 . A* pa = new B(); 这句话 本质是 A* pa = (A*)( new B() ) ;
这句话里面自带了默认的隐式转换,因为向上转换是允许的。所以,此时pa指针指向的是派生类当中的基类部分。也就是说,虽然pa实际指向的确实是B的对象,但是它也只能访问B当中基类的部分。否则,类型转换毫无意义!
并且,通过代码调用,发现pa->g();非法,说明就是这样。
但是,为什么可以多态,那是因为,在pa是可以访问,派生类当中的基类部分,所以,对于虚函数的访问,我们要去vtable,但是也只能是访问vtable当中基类的部分,也就是f函数。但是,多态的实现是派生类中重写了基类的方法会替代基类当中的方法,所以,虽然看起来访问的还是基类当中的f函数,但是实际内容已经变成了派生类当中的f函数。

总结:我想强调的是,基类的指针即使指向派生类对象,也只是能访问派生类当中基类的部分!这点毫无置疑。只是,多态的实现是可以替换基类当中的重写函数,所以,访问的还是基类当中的f函数,但是本质上访问的是被重写的f函数。这一切都依赖于vtable的支持,因为只有虚函数才是通过vtable进行访问的。

原创粉丝点击