• 【原创】精通C++系列:再论虚函数 终结


    题记:
    有一些问题,每过一段时间就有新的理解...

    聚合对象结构体和类

    聚合对象,就是一系列的基本数据类型的组装,在内存中像线型一样紧密摆放,比如结构体就是典型的代表:

    typedef struct tagPerson{
        int age;
        char address[128];
    }PERSON;

    如果我们对C这套东西很了解,能知道在这个语言中,基本上的数据类型就是1,2,4,8,或者它们之间的组合。
    比如,char 1字节,int 4字节,指针(任何数据类型的)占4字节,double 8字节。在这些类型中,特别注意指针是4字节

    所以聚合对象,就是基本对象的组合,如果我们基本对象熟练,那么聚合对象自然就熟练。

    类和结构体从内存结构来说几乎一样,只相当于把名字struct换成class。因此,类也可认为是聚合对象。只不过,语言设计者又给它添加了构造析构等方法。
    因此,把等价类比于结构体是认识提升的第一步。

    方法初探

    写完类的基础,再看类的方法。一个类的方法是在编译期或者说运行之后就已经固定了,我们认为它就是固定的,比如:

    class Person{
        public:
            void show(){ printf("hello\n");}
    };

    实例一个对象,并调用:
    Person p;
    p.show();

    对象p调用方法,这个show()方法确定无疑在内存中处在一个固定的位置,我们常常说,方法是通用的,图示:

    对象有若干个,但是方法只有一个。只要进入时限定对象,就可以通用,比如对象a进入,就将a的首地址传进去,对象b进入就将b的首地址传入,这样就可以区别开。

    调用父类的方法

    我们可以认为所有的一切都是事先安排好的,这其中,都是编译器在背后做的工作。举例:

    class Person{
    public:
        void GetAge(){}
    };
    class Employee:public Person{
    public:
        void GetInfo(){ //调用GetAge()方法
            GetAge();
        }
        void GetAge(){}
    };
    
    Employee e;
    e.GetInfo();

    类Employee的GetInfo方法调用GetAge(),由于不存在虚函数,而且层次又这么清楚,所以每个类方法没有任何理由是动态的,也就是所有的方法都是固定位置确定无疑的。编译器编译三个方法:
    Person::GetAge()
    Employee::GetInfo()
    Employee::GetAge()

    因此,GetInfo()里面的方法,在编译期就能确定调用的是Employee::GetAge(),这就是上面所说的,一切都是事先安排好的。有的人说,我会强转大法,如下:

    Employee per;
    Person* p=(Person*)&per;
    p->GetAge();

    将雇员地址强转为Person*,编译器一眼就看到这是确定的,这是调用Person::GetAge()方法,所以我们的结论成立

    结论:所有的方法都是事先安排好的

    类派生的内存结构

    普通的方法理解之后,我们再看下类的内存结构,它非常类似于套娃,如图所示:

    class A{
    public:
        int m_a;
    };
    class B:public A{
    public:
        int m_b;
    };
    class C:public B{
    public:
        int m_c;
    };
    class D:public C{
    public:
        int m_d;
    };

    对于派生终端的子类D来说,它将会继承上面的所有类成员,并且最高层的类成员排在内存靠前位置,如下:

    虚表指针

    如果没有虚方法,那么内存布局就像上图所示,非常明了。一旦有虚方法之后,对象的起始4字节就被虚表指针占用了(前面我们假设指针占用4字节),就变成如下样式:

    就好比虚表指针在说:都让开,前面4字节必须由我来占用。怎么验证呢?其实也比较简单,我就把这个对象的首地址前4字节强转出来,看看长的什么样:

    class AA{
    public:
        virtual void Show(){}
    };
    class V:public AA{
    public:
        V(){this->a=10;}
    private:
        int a;
    };
    
    int main(int argc,char* argv[]){
        V v;
        int* p=(int*)&v;
        printf("%p\n",*p); //注意这是取的是对象首地址四字节存储的值;如果没有虚方法,打印结果应该是10;
        printf("%d\n",*(p+1)); //将指针向前推四个字节,打印出10;
        return 0;
    }

    按照内存结构,开始应该打印出10,结果是0x00402020(可以看出是一个地址值),然后将指针向前推了4字节,才得到10。说明前面四字节确实是虚表地址
    (这个例子也说明,类的成员访问控制仅仅是编译层面进行,实际简单绕一下就过去了)

    虚表结构

    从上面的实证中,我们知道对象前四个字节确实是虚表地址,有时候,学习就是这样,必须确定无疑拿出一个结果出来,我们真正用眼睛看到了才能真正理解。
    下面我们接着说虚表结构,其实是一个指针数组,而这些指针就是函数地址。如下图所示:

    假如我们打开编辑器进行debug,定位到0x00402020,就会看到类似下面这样的地址:

    10 22 40 00 1A 22 40 00,(每个机器可能都不一样,这里仅做示例)

    可以看出,这是地址,而虚表就是数组,由于指针是四字节,所以每四个字节为一组向前填充,这一步验证了虚表的概念

    虚函数指针的填充

    有的人会问,我没有在任何地方做,或者看到虚表指针被处理,那么它在哪个地方做了处理呢?答案是,编译器在构造方法中进行的。
    因此会有结论:

    1. 如果派生中存在虚函数定义,那么一定会有一个构造方法。
    2. 如果用户没写,那么编译器会默认定义一个
    3. 如果用户写了构造函数,即使什么都没做,编译器也在这个构造方法中悄悄的将虚表指针正确部署起来

    虚函数的部署

    为了理解这个问题,我们做个简单的例子,假如有两个类A和B,类A有一个虚方法,如下所示:

    class A{
    public:
        virtual void Show(){
            printf("父类A");
        }
    };
    class B:public A{
    public:
    
    };

    由于子类B没有重写,因此原样将A继承过来了,虚表示意图如下:

    在前面说明类方法时说过,每个类的方法都是固定的,因为它没有动态的理由,因此编译器首先在内存中部署了一个函数A::Show(),它和一般的方法并没有什么不同,只是因为
    它是虚方法。假定它的入口地址是0x1000,如上图所示,就将该地址登记在虚表中。(注意,这部分都在说类B

    虚方法被重写如何呢?

    假如B重写了类A的虚方法呢?对不起,A::Show()退下去,如图所示:

    可以看到,A::Show()被排挤出去了,只留下B::Show(),因为只要写了,编译器足够聪明的知道你重写了。想一想,如果这一步不能确定,它怎么能正确的安装虚表呢?
    这一步关键在于,要知道A::Show()并不是被继承过来了,而是被排挤出去了,现在只有B::Show()在虚表中。

    好了,再次验证一句话:原来一切都是安排好的

    多态

    当编译器安排好之后,如何实现多态呢?我们再举一例说明:

    class Base{
    public:
        virtual void Show(){
            printf("父类Base\n");
        }
    };
    class A:public Base{
    public:
        virtual void Show(){
            printf("A类Show\n");
        }
    };
    class B:public Base{
    public:
    };
    
    void test(Base* base){
        base->Show();
    }
    int main(int argc,char* argv[]){
        A a;
        B b;
        test(&a);
        test(&b);
        return 0;
    }

    这个例子比较简单,A重写父类,B没有重写,根据前面的说明,我们知道A的虚表也被重写,B的虚表相当于继承过来,这一步是编译器干的。
    因为编译器明确知道一切信息,所以虚表才能正确部署。

    当调用test方法时,虽然形式上用的是基类的指针,但是我们注意到,这里实质传进去的是派生类对象的指针。

    那么编译器会关心这个指针是谁吗?不,它一点都不关心,因为虚表已经确定了。

    由于虚表指针是在对象首地址的前4字节,所以编译器不管你传什么指针进来,它第一步就去取虚表地址

    void test(Base* base){
        base->Show();
    }

    为什么呢?因为代码是在调用虚方法,编译结果即如此,所以在这一步中,base实参无论是A指针或B指针或Base指针都无所谓,
    因为能调用虚方法的对象,前4字节必定是虚表地址。

    当取到虚表地址之后,由于虚表已经固定,所以Show()方法的地址是可以精确计算出来的。
    在我们这个例子中,虚方法Show确定是在第一行中,无论对于A重写来说,对于B继承来说,或原类Base,第一个虚方法地址必定是Show,所以这一步能取到Show方法地址。

    最后一步,我们把对象地址push入栈,首地址确定即对象确定。

    所有操作,都对传进来的指针是谁没有一点限定!

    原来,一切早已安排好

    - - 完结撒花 2022-4-8号 夜 - -

  • 相关阅读:
    greta一些简单实用的字符串匹配
    内存管理
    粒子系统
    资源的后台加载
    GRETA正则表达式模板类库
    便利的开发工具log4cpp快速使用指南
    vc/mfc/vs2005下正则表达式源代码编程/微软greta Regular Expressions
    GRETA库在VS 2005环境下的编译经验
    揭开正则表达式的神秘面纱
    greta简单使用
  • 原文地址:https://www.cnblogs.com/tinaluo/p/16115137.html
Copyright © 2020-2023  润新知