【笔记】C++底层原理笔记

目录

一、关于对象

二、深入了解数据成员

三、深入了解函数成员

关于对象

1.类继承时加上virtual关键字就变成了虚拟继承

  • 虚拟继承的作用是保证无论同一父类被该子类重复n次直接或间接继承,该子类的对象中始终只有一个此父类的实例
  • 例如b1虚拟继承a、b2也虚拟继承a,然后c既继承b1又继承b2,此时c类对象也只有一个a类实例

2.C++程序设计模型的三种范式,即每个程序要保证用同一范式思想

  • 程序模型:使用C的语法,用字符数组和str*函数族群
  • 抽象数据类型模型(基于对象OB):也用string等对象,强调数据封装,但不支持多态
  • 面向对象模型(面向对象OO):强调基类、虚函数和动态绑定等概念,支持多态

3.指针的本质是完全相同的,不管它指向的是哪种类型对象

  • 它的内存存的都是一个机器地址,也就是说指针类型会教导编译器如何解释某个特定地址中的内存内容及其大小
  • 例如一个指向地址1000的int指针,在32位机器上将涵盖地址空间1000~1003
  • 例如一个指向地址1000的string指针(包括一个4字节字符指针和一个用来表示字符串长度的整数4字节),那么它将将涵盖地址空间1000~1008,注意string实现方式有很多种,不过一般都不少于8字节
  • 而void*指针只标识了地址并没有说明有多大,因此不能直接通过它操作它指向的内容
  • 因此指针的转换cast其实是一种编译器指令,大部分情况它不改变一个指针所含的真正地址,它只影响“被指出的内存的大小和内容”的解释方式

4.引用也额外需要4字节空间(存在堆上)

5.枚举类型enum默认每个成员占4字节,除非有大于4字节的值那么就以这个大值为准

6.子类对象的占用字节数等于子类的字节+父类的字节之和

  • 因为规定对象的大小必须大于0,所以一个空类的对象占用1字节(编译器会给它安排一个char),但如果是继承空类的子类对象的话那么占用字节就应该等于子类的字节+0(有些老的编译器也还是会+1)

7.只有用new定义的对象才在堆里面,否则定义类对象和定义变量一样都存在栈里面

8.对象、变量放栈和堆的选择问题

  • 堆空间大,适合放数组以及类对象(系统自带或自定义)
  • 堆里面的数据不会自动清空,这样就可以利用指针来访问,(自动清空数据恰好是栈的优点)。所有在使用的时候要辩证来用,如果是用指针来访问,就用堆,千万不能用栈
  • 效率低,因为堆空间大,所以相对来说效率会低一点
  • 使用堆长期运行,有可能会导致内存碎片问题(请求一个43字节的内存块时,因为没有适合大小的内存,所以它可能会获得44字节,因此由所需大小四舍五入而产生的多余空间就叫内部碎片,外部碎片是频繁分配和回收物理页面导致小的页面块夹在已分配的页面中间而用不上)

9.虚继承也会有虚指针占用空间

1
2
3
4
5
6
7
8
9
10
class X { }; //X占1字节,编译器给它加的char相当于一个标识
class Y : public virtual X { }; //Y占4字节,4字节来自虚函数指针
class Z : public virtual X { }; //Z占4字节,4字节来自虚函数指针
class A : public Y, public Z{ }; //A占8字节,8字节来自Z和Y分别含有的虚函数指针

//如果是旧的没有处理虚基类的编译器
class X { }; //X占1字节,编译器给它加的char相当于一个标识
class Y : public virtual X { }; //Y占8字节,4字节来自虚函数指针+1字节X+3字节补齐
class Z : public virtual X { }; //Z占8字节,4字节来自虚函数指针+1字节X+3字节补齐
class A : public Y, public Z{ }; //A占12字节,1字节X+Y自己本身的4字节+Z自己本身的4字节+3字节补齐

8.计算类的占用字节数也会补齐字节

  • 已知A类有一个char成员占1字节,B类继承自A类并自己有一个int成员
  • 则B类对象占用8字节,来源于char1+int4=5,然后要为4的倍数则补上3字节,最后等于1+4+3=8

9.windows64位可以看成32位的标准,因为

  • windows64位一般使用LLP64模型
  • 64位Unix,Linux使用的是LP64模型

10.判断一个类的占用字节,一定要考虑以下两点

  • 因虚函数和虚继承产生的虚表指针占的字节
  • 因字节补齐多出来的字节

11.虚指针放在类末尾可以兼容C的结构体,而放在类开头则可以提高含有抽象基类、虚拟继承结构的效率

二、深入了解数据成员

1.编译器对类里面的函数成员进行分析其实是要在识别类的};之后才进行,注意参数列表不是这样,参数列表一声明就分析,即有可能会用到全局声明的类型

  • 目的是为了避免有一个全局变量和内部数据成员重名,而导致出现在定义数据成员语句前函数成员会去调用类外的全局变量
  • 现在很多将内联函数声明写在类内,定义写在类外的习惯在一定程度也是受老版本C++会先分析函数而出现以上问题的影响

2.目前大部分编译器对数据成员的布局(实际上C++标准允许任意顺序存放)

  • 按声明顺序连续存放
  • static忽略,因为它放在静态存储区
  • vptr放在所有显示声明的数据成员末尾,也有一些放在对象开头
  • 操作过程都是把一个以上的access sections连锁在一起,依照声明顺序成为一个连续区块。注意在1个section中声明8个变量和在8个section中总共声明8个变量对象大小是一样的

3.一段用于判断两个数据成员的先后地址关系代码,用到class member指针,值得深究

1
2
3
4
5
6
7
8
template<class class_type, class data_type1, class data_type2>
char* box(data_type1 class_type::*mem1, data_type1 class_type::*mem1){
return mem1 < mem2 ? "mem1 occure first" : "mem2 occure first";
}

box(&Point3d::z, &Point3d::y); //z和y是类Point3d中的float成员

//class_type会被绑定为Point3d,而data_type1和data_type2会被绑定为float

4.对象的直接调用成员和对象指针调用成员差异

1
2
3
4
Point3d origin, *pt = &origin;

origin.x = 0.0;
pt->x = 0.0;
  • 使用origin.x在编译期可以明确知道origin是Point3d类型,也知道具体地址
  • 而pt->x编译器会不明确pt是具体指向哪个class type,也许是虚基类呢,所以要等到执行期时经由一个间接导引才能够解决

5.无论继承还是被继承,static成员还是只有一个实例,它被提出到类外视作global,但注意它的只在类的生命周期内存活

  • 两个类都声明了一个同名的static成员,那么编译器暗中会对每一个static成员编码,每种编译器的暗中编码方法不同
  • static成员重名的话,会优先调用自己的static成员
  • 注意一点,指针是什么类型的它就会去调用什么类的静态成员,即父类指针指向子类对象,如果static成员同名也会使用父类指向的static,
  • 还有,如果多重继承的两个父类有重名static时,用子类对象、指针、作用域去调用该成员,就会报错ambiguous(有歧义)
  • static函数同上

6.对一个对象的成员用地址直接操作,由此可知一个足够勤劳的编译器在编译期就已经可以解决对y的存取了

1
2
3
4
5
6
7
Point3d origin;

//&origin.y = &origin + (&Point3d::y-1)
//即origin的y成员的地址等于origin对象地址+y在Point3d中的偏移量-1
//注意这个-1是编译系统为区分指向数据成员的指针是否有真实指向一个成员的所做的标识
//即Point3d::y的偏移值实际上是4,但因为C++有如上规定,所以&Point3d::y传回来的就是5,因此要-1
//注意某些编译器已经自动处理了这个,所以某些环境不用-1

7.类分层时要注意这样的占用空间

  • 不分层时
    1
    2
    3
    4
    5
    6
    7
    class A{
    int val;
    char bit1;
    char bit2;
    char bit3;
    };
    //总共占4+1+1+1+1(padding)=8字节
  • 分成3层后
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class A{
    int val;
    char bit1;
    };
    class B:public A{
    char bit2;
    };
    class C:public B{
    char bit3;
    };

    //一个C类对象占(4+1+3(padding))+(1+3(padding))+(1+3(padding))=16字节
  • C++这样做的原理是为保证使用父类指针指向不同子类时,每次父类指针的相互复制只改变父类部分,而不改变子类独有的成员
    1
    2
    3
    4
    5
    child c1;
    child c2;
    father *p1 = &c1;
    father *p2 = &c2;
    *p2 = *p1; //只改变p2的父类数据成员,对p2的子类独有成员无影响
  • 而如果不这样在继承体系中保持每个类的独立性,反而能把B类、C类的char成员插入到A类原本被padding占的空间中,在使用指针这样地址覆盖的形式*p2 = *p1,会导致*p2的子类独有成员也被篡改

8.3d类在2d类+=方法上的扩展模板

1
2
3
4
5
//此前2d类的+=要声明为虚函数
void operator+=(const Point2d& rhs){
Point2d::operator+={rhs}; //隐含this的Point2d部分 += rhs
z += rhs.z(); //隐含this->z += rhs.z()
}

9.多重继承的地址顺序

  • 先对继承的父类成员从左到右初始化
  • 再初始化自己的成员
  • 例子
    • 结构
    • 实例化C后地址

10.多重继承里加入虚拟继承后的地址

  • 分不变区域和共享区域
  • 不变区域中的数据成员不管后续如何衍化,都是拥有固定的offset偏移值(从实例化的对象开头算起)
  • 共享区域就是放虚拟继承的类,其位置会因为每次派生操作而有所变化,所以不能直接确定地址,只能间接存取,不同编译器有不同策略。
    • 现在一般还是每个对象对每一个虚拟父类背负一个额外的指针
      • 微软提出的解决办法是引入虚基类表,只有一个对象有至少一个虚拟父类就会给它安排一个指向虚基类表的指针(虚基类表中存指向各个虚拟父类的指针)
      • 另一个解决办法是在虚函数表中放虚基类的偏移值

11.因此一般而言,虚基类不应该含任何数据成员,否则会增加复杂性和开销

  • 即使这样,虚拟继承的引入也会增大开销,双层虚拟继承开销大于单层虚拟继承,以此类推

12.用偏移量直接取数据成员的办法,有些编译器会产生很大时间开销尽量不用,了解原理即可

1
2
3
father f1;
int father::*p1 = &father::a;
int temple = f1.*p1; //此时temple值其实就等于f1.a

三、深入了解函数成员

1.C++的设计准则之一就是成员函数至少要有和非成员函数一样的效率,即以下实现的两种方法效率在编译器中是一样的(实际上也是一样的,因为编译器在内部会把成员函数转换成非成员函数形式)

  • 非成员函数,通过传入类对象指针作为参数进行计算
  • 成员函数,直接调取类数据成员计算(但编译器内部也会把它换成用this指针的形式)

2.不管是类的数据成员还是函数成员,编译器在内部都会通过name mangling将其转换成独一无二的命名,例如Bar类的ival就有可能变成ival_3Bar。经过改进现在这个mangled名字一般不会出现在报错里但内部其实还是这种形式

3.对一个虚函数的调用

  • 如果normalize()是一个虚函数
  • 那么ptr->normalize()就会被内部转换成(*ptr->vptr[1])(ptr)的形式
    • vptr是由编译器产生的指针,它指向virtual table,它存在于包含虚函数成员的类对象中
    • [1]是virtual table slot的索引值,用来关联normalize()函数
    • (ptr)的ptr表示this指针
  • 由此可知如果显式地调用(类名::虚函数名),可以压制虚拟机制产生的重复操作,所以明确的时候(已经在某个类函数成员里再对某个虚函数进行调用,那么这个虚函数一定是此时这个类的函数)应尽量用显式调用
    • 隐式调用就是直接写函数名,不加类名::,这样会多一步对vptr的导向

4.一个返回int的静态成员函数的地址在编译器内部其实是unsigned int (*)()类型

  • 而不是unsigned int (类名*)()
  • 即把它当做nonmember函数指针

5.每一个类有一个虚函数表,每一个对象有一个虚表指针vptr指向虚函数表

  • 举一个直观的例子:
  • 一个父类指针p1指向一个子类对象,然后去调用父类和子类都有的box()
  • 按照指针类型此时应该只在父类里面去找box()的实现
  • 但是如果box()在父类里面被声明是虚函数的话,p1->box()会因为动态绑定的类型是子类而被vptr接手重新导向虚函数表,然后由虚函数表含有的虚函数地址指向子类的box()方法

6.具有多态性质的类对象(有虚函数),在执行期需要额外信息,编译器实现如下

  • 每个类有一个虚函数表,每个虚函数有一个表格索引值
  • 每个对象多一个字符串或数字成员,表示class类型
  • 每个对象多一个指针,指向某表格,表格中持有程序的虚函数执行期地址(这些地址在编译时已经确定好的,执行期不需要操作)

7.C++ primer的作者Stan在深度探索C++对象模型中明确指出,虚基类不要声明非静态数据成员,因为这除了增加编译底层的复杂性,并没有带来实际好处,反而会陷入地址offsets的迷宫

8.指向成员函数的指针

  • 定义
    1
    2
    double (Point::*coord)() = &Point::x;
    //表示定义一个名为coord的函数指针,它指向的是返回double类型的Point类里的x方法
  • 用指针形式使用该成员函数
    1
    2
    (对象名.*coord)(); //编译器内部会翻译成(coord)(&对象名)
    (对象指针->*coord)(); //编译器内部会翻译成(coord)(ptr)
  • 实际指向的地址是该成员函数在内存中的真正地址,但是这个值是不完全的,它需要被绑定在某个已经实例化的对象地址上才能通过这个地址来调用该函数
  • 所有的非虚函数成员函数都需要对象的地址(以参数this隐含指出),来存取实际的对象数据成员

9.虚函数表放在可执行文件的只读数据段