美文网首页
C++对象模型探索

C++对象模型探索

作者: ZWRMFW | 来源:发表于2021-07-19 11:22 被阅读0次

    2021.7.19

    2.1 类对象所占用的空间

    成员函数不占用类对象的内存空间,每个类只诞生 一个(跟着类走)
    一个类对象至少占用1个字节的内存空间
    成员变量是占用对象的内存空间

    2.2 对象结构的发展和演化

    (1)非静态的成员变量(普通成员变量)跟着类对象走(存在对象内部),也就是每个类对象都有自己的成员变量
    (2)静态成员变量跟对象没有什么关系,所以肯定不会保存在对象内部,是保存在对象外面(表示所占用的内存空间和类对象无关)的。
    (3)成员函数:不管静态的还是非静态,全部都保存在类对象之外。所以不管几个成员函数,不管是否是静态的成员函数,对象的sizeof的大小都是不增加的;
    (4)虚函数:不管几个虚函数,sizeof()都是多了4个字节。
    (4.1)类里只要有一个虚函数(或者说至少有一个虚函数),这个类 会产生一个 指向 虚函数 的指针。有两个虚函数,那么这个类 就会产生两个指向虚函数的指针。类本身 指向虚函数的 指针(一个或者一堆)要有地方存放,存放在一个表格里,这个表格我们就称为“虚函数表(virtual table【vtbl】)”;这个虚函数表一般是保存在可执行文件中的,在程序执行的时候载入到内存中来。虚函数表是基于类的,跟着类走的;
    (4.2)说说类对象,这四个字节的增加,其实是因为虚函数的存在;因为有了虚函数的存在,导致系统往类对象中添加了一个指针,
    这个指针正好指向这个虚函数表,很多资料上把这个指针叫vptr;这个vptr的值由系统在适当的时机(比如构造函数中通过增加额外的代码来给值);
    ---------------总结:对于类中
    (1)静态数据成员不计算在类对象sizeof()内;
    (2)普通成员函数和静态成员函数不计算在类对象的sizeof()内
    (3)虚函数不计算在类对象的sizeof()内,但是虚函数会让类对象的sizeof()增加4个字节以容纳虚函数表指针。
    (4)虚函数表[vtbl]是基于类的(跟着类走的,跟对象没关系,不是基于对象的);
    (5)如果有多个数据成员,那么为了提高访问速度,某些编译器可能会将数据成员之间的内存占用比例进行调整。(内存字节对齐)
    (6)不管什么类型指针char *p,int *q;,该指针占用的内存大小是固定的
    总结类对象大小的组成:
    (1)非静态成员变量所占的内存总量以及这些成员变量之间内才能字节对齐所额外占用的内存;
    (2)若有虚函数,则会产生虚函数表指针(vptr)。
    当然,如果类之间是多重继承关系。并且每个父类都有虚函数,情况不同,后续探讨。

    2.3 this指针调整

    派生类对象 它是包含 基类子对象的。
    如果派生类只从一个基类继承的话,那么这个派生类对象的地址和基类子对象的地址相同。
    但如果派生类对象同时继承多个基类,那么大家就要注意:
    第一个基类子对象的开始地址和派生类对象的开始地址相同。
    后续这些基类子对象的开始地址 和派生类对象的开始地址相差多少呢?那就得吧前边那些基类子对象所占用的内存空间干掉。
    总结:你调用哪个子类的成员函数,这个this指针就会被编译器自动调整到对象内存布局中 对应该子类对象的起始地址那去;
    如果派生类的函数覆盖了积累的函数,基类的函数该怎么调用:pai.ji::tongminghanshu()

    2.4 分析obj目标文件,构造函数语义

    默认构造函数(缺省构造函数):没有参数的构造函数;
    传统认识认为:如果我们自己没定义任何构造函数,那么编译器就会为我们隐式自动定义 一个默认的构造函数,我们称这种构造函数为:“合成的默认构造函数”
    结论:“合成的默认构造函数”,只有在 必要的时候,编译器才会为我们合成出来,而不是必然或者必须为我们合成出来。
    必要的时候 是什么时候?
    编译器会在哪些必要的时候帮助我们把默认的构造函数合成出来呢?
    (1)该类MBTX没有任何构造函数,但包含一个类类型的成员ma,而该对象ma所属于的类MATX 有一个缺省的构造函数。
    这个时候,编译器就会为该类MBTX生成一个 “合成默认的构造函数”,合成的目的是为了调用MATX里的默认构造函数。
    换句话说:编译器合成了默认的MBTX构造函数,并且在其中 安插代码,调用MATX的缺省构造函数;

    2.5 构造函数语义续

    (2)父类带缺省构造函数,子类没有任何构造函数,那因为父类这个缺省的构造函数要被调用,所以编译器会为这个子类合成出一个默认构造函数。
    合成的目的是为了调用这个父类的构造函数。换句话说,编译器合成了默认的构造函数,并在其中安插代码,调用其父类的缺省构造函数。
    (3)如果一个类含有虚函数,但没有任何构造函数时
    因为虚函数的存在,
    a)编译器会给我们生成一个基于该类的虚函数表vftable。
    b)编译给我们合成了一个构造函数,并且在其中安插代码: 把类的虚函数表地址赋给类对象的虚函数表指针 (赋值语句/代码);
    我们可以把 虚函数表指针 看成是我们表面上看不见的一个类的成员函数,
    为什么这么麻烦,因为虚函数的调用存在一个多态问题,所以需要用到虚函数表指针。

    编译器给我们往MBTX缺省构造函数中增加了代码:
    (a)生成了类MBTX的虚函数表
    (b)调用了父类的构造函数
    (c)因为虚函数的存在,把类的虚函数表地址赋给对象的虚函数表指针。
    当我们有自己的默认构造函数时,编译器会根据需要扩充我们自己写的构造函数代码,比如调用父类构造函数,给对象的虚函数表指针赋值。
    编译器干了很多事,没默认构造函数时必要情况下帮助我们合成默认构造函数,如果我们有默认构造函数,编译器会根据需要扩充默认构造函数里边的代码。
    (4)如果一个类带有虚基类,编译器也会为它合成一个默认构造函数。
    虚基类:通过两个直接基类继承同一个简介基类。所以一般是三层 ,有爷爷Grand,有两个爹A,A2,有孙子C
    vbtable虚基类表。 vftalble(虚函数表);
    虚基类结构,编译器为子类和父类都产生了“合成的默认构造函数”
    需要注意: 为了保证虚基类在派生类中只继承一次,应当在该基类的所有直接派生类中声明为虚基类。否则仍然会出现对基类的多次继承。

    2.6 拷贝构造函数语义

    传统上,大家认为:如果我们没有定义一个自己的拷贝构造函数,编译器会帮助我们合成 一个拷贝构造函数。这个合成的拷贝构造函数,也是在必要的时候才会被编译器合成出来。
    成员变量初始化手法,比如int这种简单类型,直接就按值就拷贝过去,编译器不需要合成拷贝构造函数的情况下就帮助我们把这个事情办了;
    比如类A中有类类型ASon成员变量asubobj,也会递归是的去拷贝类ASon的每个成员变量。

    那编译器在什么情况下会帮助我们合成出拷贝构造函数来呢?那这个编译器合成出来的拷贝构造函数又要干什么事情呢?
    (1)如果一个类A没有拷贝构造函数,但是含有一个类类型CTB的成员变量m_ctb。该类型CTB含有拷贝构造函数,那么当代码中有涉及到类A的拷贝构造时,编译器就会为类A合成一个拷贝构造函数。
    编译器合成的拷贝构造函数往往都是干一些特殊的事情。如果只是一些类成员变量值的拷贝这些事,编译器是不用专门合成出拷贝构造函数来干的,编译器内部就干了;
    (2)如果一个类CTBSon没有拷贝构造函数,但是它有一个父类CTB,父类有拷贝构造函数,
    当代码中有涉及到类CTBSon的拷贝构造时,编译器会为CTBSon合成一个拷贝构造函数 ,调用父类的拷(3)如果一个类CTBSon没有拷贝构造函数,但是该类声明了或者继承了虚函数。当代码中有涉及到类CTBSon的拷贝构造时,编译器会为CTBSon合成一个拷贝构造函数 ,往这个拷贝构造函数里插入语句:
    这个语句的含义 是设定类对象myctbson2的虚函数表指针值。虚函数表指针,虚函数表等概念,详细可以学习第三章。
    (4)如果 一个类没有拷贝构造函数, 但是该类含有虚基类当代码中有涉及到类的拷贝构造时,编译器会为该类合成一个拷贝构造函数;
    综上所述:涉及由拷贝构造去构造一个函数时,如果有虚函数,类成员,虚基类,父类拷贝构造函数等特殊事情需要处理时,就会生成默认的拷贝构造函数。

    2.7程序转化语义

    我们写的代码,编译器会对代码进行拆分,拆分成编译器更容易理解和实现的代码。
    程序员看代码视角 和 编译器看代码视角之间不断切换。
    //(1)定义时初始化对象(程序员视角)
    X x0;
    x0.m_i = 15;
    X x1 = x0; //定义的时候初始化
    X x2(x0);
    X x3 = (x0);
    //切换到编译器角度,编译器会拆分成两个步骤(编译器视角)
    cout << "---------------" << endl;
    X x100; //步骤一:定义一个对象,为对象分配内存。从编译器视角来看,这句是不调用X类的构造函数。
    x100.X::X(x0); //步骤二:直接调用对象的拷贝构造函数去了;
    (2)参数的初始化(程序员视角/现代编译器)
    X x0;
    //func(x0);
    //老编译器视角
    X tmpobj; //编译器产生一个临时对象
    tmpobj.X::X(x0); //调用拷贝构造函数
    func(tmpobj); //用临时对象调用func
    tmpobj.X::~X(); //func()被调用完成后,本析构被调用。
    (3)返回值初始化(程序员角度)
    X my = func();
    编译器对上述代码的理解(编译器角度)
    X my; //不会调用X的构造函数
    func(my);
    人类视角
    func().functest();
    切换到编译器视角
    X my; //不会调用X的构造函数
    (func(my), my).functest(); //逗号表达式:先计算表达式1,再计算表达式2,整个逗号表达式的结果是表达式2的值;
    程序员视角
    X(*pf)(); //定义个函数指针
    pf = func;
    pf().functest();
    编译器视角

    2.8程序的优化

    2.9程序优化续、拷贝构造续,深浅拷贝

    因为我们增加了自己的拷贝构造函数,导致编译器本身的bitwise拷贝能力失效,所以结论:
    如果你增加了自己的拷贝构造函数后,就要对各个成员变量的值的初始化负责了;
    深浅拷贝问题;

    2.10 成员初始化列表说

    1.何时必须用成员初始化列表
    a)如果这个成员是个引用
    b)如果是个const类型成员
    c)如果你这个类是继承一个基类,并且基类中有构造函数,这个构造函数里边还有参数。
    d)如果你的成员变量类型是某个类类型,而这个类的构造函数带参数时;
    2.使用初始化列表的优势(提高效率)
    除了必须用初始化列表的场合,我们用初始化列表还有什么其他目的? 有,就是提高程序运行效率。
    对于类类型成员变量xobj放到初始化列表中能够比较明显的看到效率的提升,但是如果是个简单类型的成员变量 比如 int m_test,其实放在初始化列表或者放在函数体里效率差别不大;
    对于用户定义类型(即类类型和结构体类型):
    1)如果使用类初始化列表,直接调用对应的构造函数即完成初始化
    2)如果在构造函数中初始化,那么首先调用默认的构造函数,然后调用指定的构造函数。

    所以对于用户定义类型,使用列表初始化可以减少一次默认构造函数调用过程
    提醒:成员变量初始化尽量放在初始化列表里,显得 高端,大气上档次,考官对这个感兴趣。
    (3)初始化列表细节探究
    说明:
    (3.1)初始化列表中的代码可以看作是被编译器安插到构造函数体中的,只是这些代码有些特殊;
    (3.2)这些代码 是在任何用户自己的构造函数体代码之前被执行的。所以大家要区分开构造函数中的用户代码和编译器插入的初始化所属的代码。
    (3.3)这些列表中变量的初始化顺序是定义顺序,而不是在初始化列表中的顺序。

    3.1虚函数表指针位置分析

    虚函数表指针在什么地方?对象模型的开头还是结尾?
    可以用下面的方法检验一下

        char *p1 = reinterpret_cast<char *>(&aobj); //类型转换,硬转 &aobj这是对象aobj的首地址。
        char *p2 = reinterpret_cast<char *>(&(aobj.i));
        if (p1 == p2) //说明aobj.i和aobj的位置相同,说明i在对象aobj内存布局的上边。虚函数表指针vptr在下边
        {
            cout << "虚函数表指针位于对象内存的末尾" << endl;
        }
        else
        {
            cout << "虚函数表指针位于对象内存的开头" << endl;
        }
    

    3.2 继承关系作用下虚函数的手工调用

    取出对象内部的虚函数表指针,找打虚函数表,找到函数指针,调用函数:

        Derive *d = new Derive(); //派生类指针。
        long *pvptr = (long *)d;  //指向对象的指针d转成了long *类型。
        long *vptr = (long *)(*pvptr); //(*pvptr) 表示pvptr指向的对象,也就是Derive本身。Derive对象是4字节的,代表的是虚函数表指针地址。
        for (int i = 0; i <= 4; i++) //循环5次;
        {
            printf("vptr[%d] = 0x:%p\n", i, vptr[i]);
        }
        typedef void(*Func)(void); //定义一个函数指针类型
        Func f = (Func)vptr[0]; //f就是函数指针变量。 vptr[0]是指向第一个虚函数的。
        Func g = (Func)vptr[1];
        Func h = (Func)vptr[2];
        /*Func i = (Func)vptr[3];
        Func j = (Func)vptr[4];*/
        f();
        g();
        h();
    

    3.3 虚函数表分析

    如果子类中完全没有新的虚函数,则我们可以认为子类的虚函数表和父类的虚函数表内容相同。但仅仅是内容相同,这两个虚函数表在内存中处于不同位置,换句话来说,这是内容相同的两张表。虚函数表中每一项,保存着一个虚函数的首地址,但如果子类的虚函数表某项和父类的虚函数表某项代表同一个函数(这表示子类没有覆盖父类的虚函数),则该表项所执行的该函数的地址应该相同,超出虚函数表部分内容不可知;
    直接用子类对象给父类对象值,子类中的属于父类那部分内容会被编译器自动区分(切割)出来并拷贝给了父类对象。
    所以Base base = derive;实际干了两个事情:
    第一个事情:生成一个base对象
    第二个事情:用derive来初始化base对象的值。
    这里编译器给咱们做了一个选择,显然derive初始化base对象的时候,derive的虚函数表指针值并没有覆盖base对象的虚函数表指针值,编译器帮我们做到了这点(初始化方面编译器做了很大额外的工作)
    OO(面向对象) 和OB(基于对象)概念:
    c++通过类的指针和引用来支持多态,这是一种程序设计风格,这就是我们常说的面向对象。object-oriented model;
    OB(object-based),也叫ADT抽象数据模(abstract datatype model),不支持多态,执行速度更快,因为
    函数调用的解析不需要运行时决定(没有多态),而是在编译期间就解析完成,内存空间紧凑程度上更紧凑,因为没有虚函数指针和虚函数表这些概念了;
    但显然,OB的设计灵活性就差;c++既支持面向对象程序设计(继承,多态)(OO),也支持基于对象(OB)程序设计。

    3.4 多重继承虚函数表分析

    (1)一个对象,如果它的类有多个基类则有多个虚函数表指针(注意是两个虚函数表指针,而不是两个虚函数表);在多继承中,对应各个基类的vptr按继承顺序依次放置在类的内存空间中,且子类与第一个基类共用一个vptr(第二个基类有自己的vptr)。

    3.5 vptr、vtbl创建时机

    vptr(虚函数表指针)什么时候创建出来的?
    vptr跟着对象走,所以对象什么时候创建出来,vptr就什么时候创建出来。运行的时候;
    实际上,对于这种有虚函数的类,在编译的时候,编译器会往相关的构造函数中增加 为vptr赋值的代码,这是在编译期间编译器为构造函数增加的。
    这属于编译器默默为我们做的事,我们并不清楚。
    当程序运行的时候,遇到创建对象的代码,执行对象的构造函数,那么这个构造函数里有 给对象的vptr(成员变量)赋值的语句,自然这个对象的vptr就被赋值了;
    虚函数表是什么时候创建的?
    //实际上,虚函数表是编译器在编译期间(不是运行期间)就为每个类确定好了对应的虚函数表vtbl的内容。
    //然后也是在编译器期间在相应的类构造函数中添加给vptr赋值的代码,这样程序运行的时候,当运行到成成类对象的代码时,会调用类的构造函数,执行到类的构造
    //函数中的 给vptr赋值的代码,这样这个类对象的vptr(虚函数表指针)就有值了;

    3.6 单纯的类不纯时引发的虚函数调用问题

    如果类并不单纯,那么在构造函数中使用如上所示的memset或者拷贝构造函数中使用如上所示的memcpy方法,那么就会出现程序崩溃的情形;
    某些情况下,编译器会往类内部增加一些我们看不见 但真实存在的成员变量(隐藏成员变量),有了这种变量的类,就不单纯了;这种隐藏的成员变量的 增加(使用) 或者赋值的时机,往往都是在 执行构造函数或者拷贝构造函数的函数体之前进行。如果使用memset,memcpy,很可能把编译器给隐藏变量的值你就给清空了,要么覆盖了;
    对多台,虚函数,父类子类。虚函数,主要解决的问题父类指针指向子类对象这种情况。
    只有虚函数,没有继承,那么虚函数和普通函数就没啥区别。
    静态联编和动态联编。
    静态联编: 我们编译的时候就能确定调用哪个函数。把调用语句和倍调用函数绑定到一起;
    动态联编:是在程序运行时,根据时机情况,动态的把调用语句和被调用函数绑定到一起,动态联编一般旨有在多态和虚函数情况下才存在。
    虚函数,多态,这种概念专门给指针或者引用用的。

    4.1 数据成员绑定时机

    对于成员函数中出现的成员变量,需要整个类定义完毕后进行确定;而对于成员变量的类型,是在定义该变量往前推最近的类型定义进行确定。
    讲的是类中的变量声明最好放在最前面。

    4.2 进程内存空间布局

    不同的数据在内存中会有不同的保存时机,保存位置。当运行一个可执行文件时,操作系统就会把这个可执行文件加载到内存;此时进程有一个虚拟的地址空间(内存空间)。linux有个nm命令:能够列出可执行文件中的全局变量存放的地址;

    4.3 数据成员布局

    一:观察成员变量地址规律
    普通成员变量的存储顺序是按照在类中的定义顺序从上到下来的;比较晚出现的成员变量在内存中有更高的地址;类定义中pubic,private,protected的数量,不影响类对象的sizeof;
    二:边界调整,字节对齐
    某些因素会导致成员变量之间排列不连续,就是边界调整(字节对齐),调整的目的是提高效率,编译器自动调整;调整:往成员之间填补一些字节,使用类对象的sizoef字节数凑成 一个4的整数倍,8的整数倍;为了统一字节对齐问题,引入一个概念叫字节对齐(不对齐);有虚函数时,编译器往类定义中增加vptr虚函数表指针:内部的数据成员。
    三:成员变量偏移值的打印
    成员变量偏移值,就是这个成员变量的地址,离对象首地址偏移多少;
    怎么打印偏移:&类名::变量名
    静态成员变量的类内偏移在最偏远,因定义不在类内

    4.4 数据成员存取

    一:静态成员变量的存取
    静态成员变量,可以当做一个全局量,但是他只在类的空间内可见;引用时用 类名::静态成员变量名。静态成员变量只有一个实体,保存在可执行文件的数据段的;
    二:非静态成员变量的存取(普通的成员变量),存放在类的对象中。存取通过类对象(类对象指针)。对于普通成员的访问,编译器是把类对象的首地址加上成员变量的偏移值;

    4.5 单一继承下的数据成员布局

    (1)一个子类对象,所包含的内容,是他自己的成员,加上他父类的成员的总和;
    (2)从偏移值看,父类成员先出现,然后才是孩子类成员。
    子类对象中实际上是包含着父类子对象的

    4.6 单类单继承虚函数下的数据成员布局

    一:单个类带虚函数的数据成员布局
    类中引入虚函数时,会有额外的成本付出
    (1)编译的时候,编译器会产生虚函数表,参考三章五节
    (2)对象中会产生 虚函数表指针vptr,用以指向虚函数表的
    (3)增加或者扩展构造函数,增加给虚函数表指针vptr赋值的代码,让vptr指向虚函数表;
    (4)如果多重继承,比如你继承了2个父类,每个父类都有虚函数的话,每个父类都会有vptr,那继承时,子类就会把这两根vptr都继承过来,如果子类还有自己额外的虚函数的话,子类与第一个基类共用一个vptr(三章四节);
    (5)析构函数中也被扩展增加了虚函数表指针vptr相关的赋值代码,感觉这个赋值代码似乎和构造函数中代码相同;

    4.7 多重继承数据布局与this调整深谈

    一:单一继承数据成员布局this指针偏移知识补充
    二章三节 :this指针调整
    二:多重继承且父类都带虚函数的数据成员布局
    (1)通过this指针打印,我们看到访问Base1成员不用跳 ,访问Base2成员要this指针要偏移(跳过)8字节;
    (2)我们看到偏移值,m_bi和m_b2i偏移都是4;
    (3)this指针,加上偏移值 就的能够访问对应的成员变量,比如m_b2i = this指针+偏移值
    我们学习得到一个结论:
    我们要访问一个类对象中的成员,成员的定位是通过:this指针(编译器会自动调整)以及该成员的偏移值,这两个因素来定义;
    这种this指针偏移的调整 都需要编译器介入来处理完成;
    将子类对象赋给父类指针,对象内存会减去一部分,神奇的是,当它再一次被赋值给子类指针时,减去的一部分又回来了,这是编译器做的工作。

    4.8 虚基类问题的提出和初探

    一:虚基类(虚继承/虚派生)问题的提出传统多重继承造成的 :空间问题,效率问题,二义性问题;虚基类,让Grand类只被继承一次;
    二:虚基类初探
    两个概念:(1)虚基类表 vbtable(virtual base table).(2)虚基类表指针 vbptr(virtual base table pointer)
    空类sizeof(Grand) ==1好理解;virtual虚继承之后,A1,A2里就会被编译器插入一个虚基类表指针,这个指针,有点成员变量的感觉;A1,A2里因为有了虚基类表指针,因此占用了4个字节。虚基类表指针,用来指向虚基类表(后续谈)。虚基类表,多个父类每人一个,孙类有没有就不知道了。

    4.9 两层结构时虚基类表内容分析

    一:虚基类表内容之5-8字节内容分析
    虚基类表 一般是8字节,四个字节为一个单位。每多一个虚基类,虚基类表会多加4个字节。编译器因为有虚基类,会给A1,A2类增加默认的构造函数,并且这个默认构造函数里,会被编译器增加进去代码,给vbptr虚基类表指针赋值。“虚基类表指针”成员变量的首地址 + 这个偏移量 就等于 虚基类对象首地址。跳过这个偏移值,我们就能够访问到虚基类对象;
    二:继续观察各种形色的继承
    a)虚基类表 现在是3项, +4,+8,都是通过取得虚基类表中的偏移值来赋值的
    b)虚基类表中的偏移量是按照继承顺序来存放的;
    c)虚基类子对象一直放在最下边;
    三:虚基类表内容之1-4字节内容分析
    虚基类表指针成员变量的首地址 ,和本对象A1首地址之间的偏移量 也就是:虚基类表指针 的首地址 - A1对象的首地址
    结论:只有对虚基类成员进行处理比如赋值的时候,才会用到虚基类表,取其中的偏移,参与地址的计算;虚基类怎么起作用的还不熟悉。
    4.11 数据成员指针
    二:成员变量的偏移值及其指针(和具体对象是没有关系的)
    用成员变量指针来打印偏移值也可以,看写法
    大家要知道,成员变量指针里边保存的 实际上是个偏移值(不是个实际内存地址)。
    类名后加冒号 指明这是取对象内偏移。

    5.1 普通成员函数调用方式

    c++语言设计的时候有一个要求:要求对这种普通成员函数的调用不应该比全局函数效率差;
    基于这种设计要求,编译器内部实际上是将对成员函数myfunc()的调用转换成了对全局函数的调用;
    成员函数有独立的内存地址,是跟着类走的,并且成员函数的地址 是在编译的时候就确定好的;
    a)编译器额外增加了一个叫this的形参,是个指针,指向的其实就是生成的对象;
    b)常规成员变量的存取,都通过this形参来进行,比如上述this->m_i + abc;

    5.2 虚成员函数、静态成员函数调用方式

    6.1 继承体系下的对象构造步骤

    一:对象的构造顺序
    二:虚函数的继续观察
    大家千万不要在构造函数中你自己的代码中使用诸如memcpy或者直接操作等手段,来修改虚函数表指针的值,否则,调用虚函数时就可能造成系统崩溃;
    三:构造函数中对虚函数的调用
    某个类的构造函数 中 调用一个虚函数,那么走的不是虚函数表,而是直接调用。(原因是什么)
    另:在C++中,不能声明虚构造函数,多态是不同的对象对同一消息有不同的行为特性,虚函数作为运行过程中多态的基础,主要是针对对象的,而构造函数是在对象产生之前运行的,因此虚构造函数是没有意义的;可以声明虚析构函数,析构函数的功能是在该类对象消亡之前进行一些必要的清理工作,如果一个类的析构函数是虚函数,那么,由它派生而来的所有子类的析构函数也是虚函数。析构函数设置为虚函数之后,在使用指针引用时可以动态联编,实现运行时的多态,保证使用基类的指针就能够调用适当的析构函数针对不同的对象进行清理工作。

    6.2 对象复制语义学、析构函数语义学

    一:对象的默认复制行为
    如果我们不写自己的拷贝构造函数和拷贝赋值运算符,编译器也会有默认的对象拷贝和对象赋值行为;
    二:拷贝赋值运算符,拷贝构造函数
    当我们提供自己的拷贝赋值运算符和拷贝构造函数时,我们就接管了系统默认的拷贝行为,此时,我们有责任在拷贝赋值运算符和拷贝构造函数中写适当的代码,来完成对象的拷贝或者赋值的任务;
    三:如何禁止对象的拷贝构造和赋值
    把拷贝构造函数和拷贝赋值运算符私有起来,只声明,不需要些函数体;
    四:析构函数语义
    (4.1)析构函数被合成
    什么情况下编译器会给我们生成一个析构函数?
    a)如果继承一个基类,基类中带析构函数,那么编译器就会给咱们A合成出一个析构函数来调用基类JI中的析构函数
    b)如果类成员是一个类类型成员,并且这个成员带析构函数,编译器也会合成出一个析构函数,这个析构函数存在的意义是要调用m_j这个类类型成员所在类的析构函数;
    (4.2)析构函数被扩展
    如果我们有自己的析构函数,那么编译器就会在适当的情况下扩展我们的析构函数代码;
    a)如果类成员m_j是一个类类型JI成员,并且这个成员m_j带析构函数JI(),编译器扩展类A的析构函数A()代码
    先执行了类A的析构函数代码,再执行JI的析构函数代码
    b)如果继承一个基类,基类中带析构函数,那么编译器就会扩展咱们类A的析构函数来调用基类JI中的析构函数
    虚基类:留给大家探索;虚基类会带来更多的复杂性,也会程序执行效率有一定的影响;

    6.3 局部对象、全局对象的构造和析构

    一:局部对象的构造和析构
    主要出了对象的作用域,编译器总会在适当的地方插入调用对象析构函数的代码;
    二:全局对象的构造和析构
    全局变量是放在数据段里的
    全局对象,在不给初值的情况下,编译器默认会 把全局对象所在内存全部清0;
    全局变量在编译阶段就会把空间分配出来(全局变量的地址在编译期间就确定好的)。
    全局对象构造和析构的步骤:
    a)全局对象g_aobj获得地址(编译时确定好的,内存也是编译时分配好的,内存时运行期间一直存在)
    b)把全局对象g_aobj的内存内容清0的能力(也叫静态初始化)
    c)调用全局对象g_aobj所对应的类A的构造函数
    e)调用全局对象g_aobj所对应类A的析构函数
    在main函数开始之前和结束之后都要干很多事

    6.4 局部静态对象、对象数组构造析构和内存分配

    没什么值得深入的

    一:局部静态对象的构造和析构
    a)如果我们不调用myfunc()函数,那么根本不会触发A的构造函数;
    b)局部静态对象,内存地址是在编译期间就确定好的;
    c)静态局部量刚开始也被初始化为0;
    d)局部静态对象的析构,是在main函数执行结束后才被调用的。(前提是这个静态局部对象被构造过)
    局部静态对象只会被构造一次,在调用的时候构造;在main函数执行完毕后析构

    6.5 new、delete运算符,内存高级话题

    二:malloc来分配0个字节
    即便malloc(0)返回的是一个有效的内存地址,你也不要去动这个内存,不要修改内容,也不要去读;new调用的也是malloc

    6.6 new、delete的进一步认识

    二:从new说起
    (2.1)new类对象时加不加括号的差别
    (2.1.1)如果是个空类,那么如下两种写法没有区别,现实中,你不可能光写一个空类
    (2.1.2)类A中有成员变量则:带括号的初始化会把一些和成员变量有关的内存清0,但不是整个对象的内存全部清0;
    (2.1.3)当类A有构造函数 ,下面两种写法得到的结果一样了;
    (2.2)new干了啥
    new 可以叫 关键字/操作符
    new 干了两个事:一个是调用operator new(malloc),一个是调用了类A的构造函数
    delete干了两个事:一个是调用了类A的析构函数,一个是调用operator delete(free)

    6.7 new细节探秘,重载类内operator new、delete

    一:new内存分配细节探秘
    我们注意到,一块内存的回收,影响范围很广,远远不是10个字节,而是一大片。分配内存这个事,绝不是简单的分配出去4个字节,而是在这4个字节周围,编译器做了很多处理,比如记录分配出去的字节数等等;分配内存时,为了记录和管理分配出去的内存,额外多分配了不少内存,造成了浪费;尤其是你频繁的申请小块内存时,造成的浪费更明显,更严重。

    6.8 内存池概念、代码实现和详细分析

    一:内存池的概念和实现原理概述
    malloc:内存浪费,频繁分配小块内存,则浪费更加显得明显
    “内存池”,要解决什么问题?
    (1)减少malloc的次数,减少malloc()调用次数就意味着减少对内存的浪费
    (2)减少malloc的调用次数,是否能够提高程序运行效率? 会有一些速度和效率上的提升,但是提升不明显;
    “内存池的实现原理”:
    (1)用malloc申请一大块内存,当你要分配的时候,
    我从这一大块内存中一点一点的分配给你,当一大块内存分配的差不多的时候,我再用malloc再申请一大块内存,然后再一点一点的分配给你;

    6.9 嵌入式指针概念及范例、内存池改进版

    一:嵌入式指针(embedded pointer)
    (1.1)嵌入式指针概念
    一般应用在内存池相关的代码中;成功使用嵌入式指针有个前提条件:(类A对象的sizeof必须不小于4字节)。嵌入式指针工作原理:借用A对象所占用的内存空间中的前4个字节,这4个字节用来 链住这些空闲的内存块;但是,一旦某一块被分配出去,那么这个块的 前4个字节 就不再需要,此时这4个字节可以被正常使用;
    (1.2)嵌入式指针演示代码
    sizeof()超过4字节的类就可以安全的使用嵌入式指针,因为,在当前的vs2017环境下,指针的sizeof值是4。嵌入式指针能和内存池联用,这里不深究。

    6.10 重载全局new、delete,定位new及重载等

    二:定位new(placement new)
    有placement new,但是没有对应的placement delete
    功能:在已经分配的原始内存中初始化一个对象;
    a)已经分配,定位new并不分配内存,你需要提前将这个定位new要使用的内存分配出来
    b)初始化一个对象(初始化一个对象的内存),我们就理解成调用这个对象的构造函数;
    说白了,定位new就是能够在一个预先分配好的内存地址中构造一个对象;
    格式:new (地址) 类类型()

    6.11 临时性对象的详细探讨-01

    一:拷贝构造函数相关的临时性对象
    编译器产生临时对象,把tmpobj对象的内容通过调用拷贝构造函数 把tmpobj的内容拷贝构造给这个临时对象;然后返回的是这个临时对象;
    二:拷贝赋值运算符相关的临时性对象
    //编译器产生临时对象,把tmpobj对象的内容通过调用拷贝构造函数 把tmpobj的内容拷贝构造给这个临时对象;然后返回的是这个临时对象;
    三:直接运算产生的临时性对象
    (3.1)临时对象被摧毁
    (3.2)临时对象因绑定到引用而被保留
    编译器产生临时对象,把tmpobj对象的内容通过调用拷贝构造函数 把tmpobj的内容拷贝构造给这个临时对象;
    然后返回的是这个临时对象;

    7.1 模板及其实例化详细分析

    显式实例化语法,这种语法会把模板类的所有内容都实例化出来;
    显式实例化单独的成员函数,并没有实例化出类ATPL<int>本身;
    一:函数模板
    编译器编译时,根据针对funcadd()的调用来确定T的类型。
    如果我们并没有针对funcadd()的调用代码,那么编译器不会为我们产生任何和funcadd有关的代码,就好像函数模板funcadd()从来没存在过一样;
    针对T的类型推断,是编译器在编译的时候,根据针对funcadd()的调用来确定的
    二:类模板的实例化分析
    如果程序代码中没有用到ATPL,那么编译器对ATPL类模板视而不见,就好像从来没存在过一样;
    (2.1)模板中的枚举类型,和类模板本身关系不大
    (2.2)类模板中的静态成员变量
    (2.3)类模板的实例化
    (2.4)成员函数的实例化
    三:多个源文件中使用类模板
    在多个obj文件中可能产生多个重复的类模板对应的具体的实例化类,但链接的时候只会保留一个ATPL<int>类的实体,其余的会被忽略掉;
    (3.1)虚函数的实例化
    虚函数即使没有被调用,但也会被实例化出来,为什么?因为有虚函数,编译器就会产生虚函数表。虚函数表里是各个虚函数的地址,既然需要各个虚函数的地址,那么必须要实例化每个虚函数出来。
    (3.2)显式实例化

    7.2 邪门歪道、推荐书籍、结束语

    一:不能被继承的类B
    c++11 final关键字;
    友元函数+虚继承 = 不能被继承的类
    副作用:友元,破坏封装性;虚继承,消耗比较大;

    相关文章

      网友评论

          本文标题:C++对象模型探索

          本文链接:https://www.haomeiwen.com/subject/ncucmltx.html