首页 深度探索 C++ 对象模型 笔记
文章
取消

深度探索 C++ 对象模型 笔记

深度探索 C++ 对象模型 笔记

1.1

  • 类的封装不会给C++带来额外成本。
  • 额外成本(时间,空间)来自于虚机制
  • 类内只包含非静态成员变量的大小 和 虚机制导致的虚指针(如果有的话)。静态成员变量,所有成员函数都不算作类的大小。此处不再赘述
  • 关于虚函数和虚指针,RTTI在单独的笔记有。

1.2

  • C++优先判断一个语句为声明:当语言无法区分一个语句是声明还是表达式时,就需用用一个 超越语言范围的规则 —— C++优先判断为声明。

小例子就是在声明对象的时候,使用无参构造函数不能加括号

1
2
test test1(); 	//这是声明了一个返回test类型的无参函数
test test1; 	//这才是是声明了一个test对象并使用了无参构造
  • C++只保证处于同一个 access section 的数据,一定会以声明的次序出现在内存布局当中。 C++标准只提供了这一点点的保证

所以说变长数组(柔性数组,动态数组)这种东西一般只出现在单纯的class/struct里面。

2.1构造函数语义学

注意,默认构造函数的意思是,我们没写,编译器认为需要,编译器生成的。这个叫默认构造。 但是我们写了一个构造,无论是有参还是无参,假如我们现在只有一个有参构造,则我们需要无参初始化的时候就要再写一个无参构造。因为只要我们写了任意一个构造,编译器就不会为我们合成默认构造。

  • 所以我们只要写了构造函数,就要写写一个默认无参构造函数。

    • 如果构造函数参数有默认值,这个构造函数就成了默认构造函数
    • 默认构造函数就是要么没有参数,要么所有参数都有默认值。
  • 只有编译器认为需要默认构造的时候才会被合成出来。任何被程序需要的东西,编译器都不负责合成,这是程序员的责任
  • 只有全局变量和静态变量会保证被初始化,因为他们都是保存在BSS段(如果未初始化或者是初始化为0值)。

  • 如果 class A 内含一个或以上的 member objects,那么 A 的 constructor 必须调用每一个 member class 的(默认或对应的)构造函数。没有显式调用,编译器则会扩张构造函数,帮我们调用。

这句话看起来很抽象,什么意思呢?举个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class test{
    public:
    test(){ cout << "test const" << endl;}
    int val;
};
class test1{
    public:
    int t1val; 		
    test tt;			//test1 内涵test 而不是继承
    test1(){			//显式定义无参构造函数,但是没有显式初始化我们的test对象。
        t1val = 10;
        cout <<"t1const " << endl;
    }
};
int main()
{
    test1 t1;			//声明test1
	return 0;
}

我们这里test1内涵了一个test对象(如果 class A 内含一个或以上的 member objects),我们test1有自己显式的无参构造函数。但是我们的构造函数没有初始化test对象,这怎么办?编译器会帮我们。在我们test1的构造函数中会被插入形如tt.test::test()这样的一条代码来调用对应的构造函数来初始化test对象。(那么 A 的 constructor 必须调用每一个 member class 的(默认或对应的)构造函数。

如果有多个class member objects 都要求constructor初始化操作,将如何? C++语言要求以“member objects 在 class中的声明顺序”来调用各个constructors。这就是声明 的次序决定了初始化次序(构造函数初始化列表一直要求以声明顺序来初始化)的根本原因!(当然你实际写的时候逆序也可以,但是编译器依旧会按照声明的顺序去初始化,和你写的顺序没关系,但是可能你这么写会造成一些错误)

整体来说,就是编译器会扩张 constructors ,在其中安插代码使得在 user code 被调用之前先调用 member objects 的默认构造函数(当然如果需要调用基类的默认构造函数,则放在基类的默认构造函数调用之后:基类构造函数 -> 成员构造函数 ->user code)。

带有有 虚函数 的类的默认构造函数毫无疑问是 notrivial 的,需要编译器安插额外的 成员 vptr 并在构造函数中正确的设置好 vptr,这是编译器的重要职责之一。 带有 虚基类 的类的默认构造函数同样也毫无疑问的 notrivial,编译器需要正确设 置相关的信息以使得这些虚基类的信息能够在执行时准备妥当,这些设置取决于编译实现虚基类 的手法。(一句话:虚基表和虚函数表的存在导致含有虚机制的类在没有构造函数的情况下必须被合成一个默认构造函数

总结:

编译器有 4 种情况会使得编译器真正的为 class 生成 nontrivial 的默认构造函数,这个 nontrivial 的默认构造函数只满足编译器的需要。

  1. 调用 member objects 的默认构造函数
  2. 调用 base class 的默认构造函数、
  3. 初始化 virtual function (虚机制)
  4. 初始化 virutal base class (虚机制)
  5. 其它情况时,类在概念上拥有默认构造函数,但是实际上根本不会被产生出来(前面的区分 trivial 和 notrivial)。

C++新手常见的 2 个误区:

  1. 如果 class 没有定义 default constructor 就会被合成一个; 首先定义了其它的 constructor 就不会合成默认构造函数,再次即使没有定义任何构造函数 也不一定会合成 default constructor,可能仅仅是概念上有,但实际上不合成出来。
  2. 编译器合成出来的默认构造函数会明确设定每一个 data member 的默认值; 明显不会,区分了 Global objects, Stack objects, Heap objects 就非常明白了只有在 Global 上的 objects (全局或静态变量)会被清 0,其它的情况都不会保证被清 0。

2.2 拷贝构造函数语义学

  • bitwise copy = 浅拷贝
  • memberwise copy = 深拷贝

拷贝构造函数 和默认构造函数一样,只有在必须的时候才会被产生出来,对于大部分的 class 来说,拷贝构造函数仅仅需要按位拷贝就可以。 满足 bitwise copy semantics 的拷贝构 造函数是 trivial 的,就不会真正被合成出来(与默认构造函数一样,只有 nontrivial 的拷贝构 造函数才会被真正合成出来)。注意,深拷贝和浅拷贝,也就是拷贝构造函数的正确与否在这里我们是不关心的。我们只关心生成与否。

什么时候不满足浅拷贝语义呢?也就是什么时候拷贝构造会被真正的合成出来呢?四种情况

  1. 当class内含一个member object而后者的class声明有一个copy constructor时(不论是被显式地声明,或是被编译器合成)。一句话:当类内含有另一个类成员,而另一个类成员含有了拷贝构造,无论是声明出来的或者是被合成的。因为这个类的拷贝构造必须要调用包含的类成员的拷贝构造,执行这个过程则必须生成拷贝构造,然后隐式插入其中。
  2. 当class继承自一个base class而后者存在一个copy constructor时(再次强调,不论是被显式声明或是被合成而得)。一句话:当子类继承父类,父类含有拷贝构造的时候,无论是声明出来的或者是被合成的。
  3. 当class声明了一个或多个virtual functions 时。(有虚函数时)
    • 这个就是我们说的,当我们用子类对象给父类对象赋值的时候,会产生切割(slice)。而且在赋值的时候编译器会禁止赋值任何和虚机制相关的东西

QQ截图20220823200622

也就是说,合成出来的 父类拷贝构造会显式设定对象的虚函数表指针指向父类自己的虚函数表,而不是直接从右手边的对象 中将其虚函数表指针现值拷贝过来。

  1. 当class派生自一个继承串链,其中有一个或多个virtual base classes时。(有虚继承时)
    • 编译器会合成一个拷贝构造函数,安插一些代码用来设定虚基类指针和偏移的初值以及执行其他的内存相关工作

2.3 程序转化语义学

NRV优化 杂记2提到了。不赘述

然后就是,在不需要深拷贝语义的情况下不要瞎鸡巴写拷贝构造。效率低没意义。

2.4 构造函数初始化列

  • 构造函数初始化列在初始化的时候就将值做直接初始化。 而不是先默认构造后再赋值 assignment。
  • 如果不用初始化列表,则会先initialize初始化再做赋值assignment(拷贝)动作。 先调用默认无参构造,再调用拷贝赋值。

  • 比如如果构造函数中存在string,我们在普通情况下会调用默认构造,再调用拷贝赋值。 如果使用初始化列表,则会调用参数最匹配的构造函数进行直接初始化。也就是直接调用拷贝构造
1
2
3
4
test(const string& a){ //先调用默认构造函数构造val, 然后使用拷贝赋值把a赋值给val
  val = a;
}
test(const string& a):val(a){} //直接调用string类变量val的拷贝构造,把a复制进去。因为val是个string
  • 类的构造函数后面跟冒号:找到最匹配的构造函数直接构建对象。也就是系统创建类成员变量的同时初始化(本例中直接调用拷贝构造函数)。
  • 类的构造函数里面等号赋值的方式:使用默认构造构建对象后再进行拷贝赋值。也就是系统创建成员变量后(调用默认构造函数后),再进行拷贝赋值。

四种情况必须使用初始化列进行成员的初始化:

  1. 常量成员 const
    • 因为如果不用初始化列,会先调用默认构造函数生成const对象,再去赋值。但是const对象不允许更改值。(const对象不允许定义和初始化分离。)
  2. 引用成员 reference
    • 原因同上。引用不允许定义和初始化分离。
  3. 调用父类的有参构造的时候
  4. 调用类内其他类成员的有参构造的时候
  5. 当某一个成员没有默认构造的时候
    1. 由于初始化列是直接调用对应构造,所以没有默认构造但是能找到其他的构造也可以。
    2. 但是写在函数体内就是先默认构造再拷贝赋值。这样就必须要求对应成员拥有默认构造。

构造函数初始化列的初始化顺序和初始化列的顺序无关,只和类内变量声明的顺序有关。但是如果顺序错乱,虽然编译层面不会报错,但会导致不可预知的错误

1
2
3
4
5
class test{
    int i;
    int j;
    test(int val):j(val), i(j){}
}

上面的代码看起来像是要把j设初值为val,再把i设初值为j。问题在于,由于声明顺序的缘故,初始化列中的i(j)其实比j(val)更早执行。但因为j一开始未有初值,所以i (j)的执行结果导致i无法预知其值。

除非改成这样:

1
2
3
4
5
6
7
class test{
    int i;
    int j;
    test(int val):j(val){
        i = j;
    }
}

因为初始化列会被放在用户代码之前执行,也就是这里,我们由于初始化列只初始化j,没有i, 所以他跳过了i先初始化了j,然后进入到用户代码块去给i初始化。

在初始化列中使用成员函数是合法的,因为和这个对象相关的this指针已经被构建。但是要考虑依赖性来避免发生错误。

比如:

1
2
3
4
int i;
int value;
int j;
A():i(99),j(100),value(foo());

这会不会产生错误取决于成员函数foo()是依赖于i还是j: 如果foo依赖于i,由于i声明在value之前,所以不会产生错误﹔ 如果foo依赖于j,由于j声明在value之后,就产生了使用未初始化成员的错误。

一句话总结:编译器会一一操作初始化列表,把其中的初始化操作以 member 声明的次序在 constructor 内安插初始化操作,并且在任何 explicit user code 之前。 以 member 声明的次序来决定初始化次序 和 初始化列表中的排列次序 之间的外观错乱,可 能会导致一些不明显的 Bug。

3.3 类成员变量的存取速度

类静态对象无论是通过对象调用还是通过指针调用,没有差距。因为静态对象不属于类。

普通的类对象通过指针调用还是对象调用也没有区别。因为变量地址可以在编译器确定。

当对象地址无法在编译期被确定,比如在有虚机制发生的时候,这时候通过指针调用和对象调用就会有速度差异。这时候对象调用会更快。因为对象调用不会触发多态,而指针调用会触发多态。编译的时候无法确定到底是哪一种类型。

3.4 C++ Standard 保证:“出现在派生类中的 base class subobject 有其完整原样性!”

子类会被放在父类的对齐空白字节之后,因为父类的完整性必须得以保证,父类的对齐空白字节也是父类的一部分,也是不可分割的。

举个例子

QQ截图20220826025225

我们这里可以用如下语句来进行深拷贝(如果定义了深拷贝的拷贝构造/拷贝赋值)

1
*pc1_2 = *pc1_1

前提是他俩实际上都指向Concrete1。如果pc1_1实际指向了Concrete2或者Concrete3,则上述操作应该吧2和3里面的父类部分(1自己的部分)复制给1。

如果没有padding,那么父类对象这里现在是空白,而子类对象现在这里有值。那么一旦发生子类对象给父类对象赋值的话,父类对象本来应该空的位置会被子类的数据填充,这样就乱套了。所以先补齐是为了保证每一个类的完整性。

所以是先补齐,再继承。

关于多态的虚函数表指针应该放在父类的屁股还是放在开头的小讨论

我们讨论过。父类指针指向子类对象,父类指针可以解析的范围仅是子类的父类范围。因为父类指针仅有权限读取并正确解释那么大的区块。而且我们讨论过,虚函数表指针是可以理解成继承(复制)自父类的。属于子类的父类部分。所以在有虚函数表指针的时候,如果放在基类的屁股,则无论是在屁股后增添子类数据成员还是赋值回去造成的slice都比较自然。如果放在开头,则没有多态和有多态的时候,类成员的起始地址会有差异。

关于类的成员对象指针可能会在实际偏移量上+1 也就是取类成员地址的偏移量。

QQ截图20220828073243

我们如果想拿到一个类的成员的地址可以如下面操作:

1
2
3
4
5
6
7
8
9
10
11
12
class A {
public:
    int a;
};


int main() {
    A obj;
    cout << &A::a << endl; //输出1
    cout << &obj.a << endl;//输出0x61fe1c
    return 0;
}

这里第一种叫做取一个类的非静态成员的地址。这里会输出相对地址。因为这是废话。真实地址要依靠对象来实现。所以这里会输出1。为啥是1不是0?

第二种才是叫做取一个类的对象身上的类的成员地址。这里才会输出真实地址。因为是绑定在对象身上的。

这里的主要意思我想就是,一个空指针会指向0。但是类的成员指针其实是按照对象起始地址的偏移量来计算的(实际储存的是相对于对象起始地址的偏移)。假如刚好偏移量为0,为了区分是空指针还是指向了成员的头部,可能编译器会在指向成员头部指针的具体offset上面+1.

4.1 各种成员函数的调用方式

注意 类静态成员函数不可以被声明为const。也就是类成员函数不可同时用const和static修饰。

因为const必须是成员函数(需要this指针)。然而static修饰的成员不属于类,(没有this指针)。

非静态成员函数和普通函数在调用层面没有性能差别

因为编译器会把每所有的非虚函数(此处不确定是否正确。有的笔记里写的是普通函数,普通成员函数和静态成员函数。书里此处没有提及静态成员函数。)进行处理:

  1. 首先是把对象调用的方式改为值调用。也就是在形参的第一个位置放一个this指针(指向对象的指针)做为入参

  2. 其次是把函数内对于非静态数据成员的直接操作变成用this指针的操作。

  3. 然后是名称重新编译成独特名称(这一步的目的是实现重载。注意,C++重载是依靠名称和参数,C只有名称)
  4. 最后是实施NRV优化(如果有)

类成员函数在处理后可以理解为变成了带作用域的全局函数。虚函数也只不过是多了查表的步骤。

虚函数在调用层面的优化

  • 只有触发多态的时候(引用或指针调用虚函数)才会被编译器处理成进行查表(我们提到过虚函数调用其实就是查虚函数表)。如果没有触发多态(对象调用)则会被编译器按照正常成员函数处理。因为没有触发多态,不查表。

  • 在虚函数内调用虚函数的时候,可能会有一个非常大的优化空间。也就是因为第一层虚函数已经被查表到了,所以自己内部的调用虚函数的操作可能会被直接处理为通过作用域运算符调用对应类的函数。(这里不太确定)

使用类作用域访问运算符显式调用虚函数会压制虚函数的调用方法(不会查表),调用法会被处理成调用非静态成员函数。

QQ截图20220828220848

静态成员函数的调用法

在进行静态成员函数的调用时,编译器依旧会把它变成一般的非成员函数进行调用。和类的非静态成员函数唯一的区别就是,静态成员函数没有this指针,不需要添加这个形参。

4.2 虚成员函数(就是虚函数)

单继承部分

第一部分讲的是虚函数表。已经在别的地方说过了此处不赘述。

需要补充的部分:虚函数表是编译期确定的原因是,我们已经按照顺序对所有的虚函数进行了排列。也就是知道了每一个虚函数表的下标对应的函数是什么。但是唯一一个需要运行期确定的是,我要找哪个表?

QQ截图20220829005232

多继承部分

在虚函数笔记有写。

不要在一个 virtual base class 中声明 nonstatic data members。如果一定要这么做,那么你会距离复杂的深渊愈来愈近,终不可拔。

4.3 函数的效能

函数性能测试表明, inline 函数的性能如此之高,比其它类型的函数高的不是一个等级。因为 inline 函数不只能够节省一般函数调用所带来的额外负担,也给编译器提供了程序优化的额外机会

内联函数就是把调用函数的部分换成内联函数的函数体。会造成代码膨胀。而且inline关键词仅仅是建议,编译器不一定会执行。

4.4 指向成员函数的指针

非虚函数

  • 我们提到过,直接提取非静态成员数据的地址只能拿到类内的偏移量。只有通过对象调用才可访问到真实地址。
  • 成员函数虽然直接提取可以提取到真正的地址,但是此时无法调用,必须绑定到一个对象上之后通过对象调用,因为会被编译器转化成一个带有this指针的函数。需要传入this指针,所以需要对象地址。

QQ截图20220831044250

具体在杂记2有介绍。

虚函数

对一个 虚函数取其地址,所能获得的只是一个 虚函数表中的索引值。

多重继承或虚继承

过于复杂不说了。

4.5 inline

inline 函数扩展时的实际参数取代形式参数的过程,会聪明地引入临时变量来避免重复求值。

假如有如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
inline int minval(int i, int j){
    return  i < j ? i : j;
}

inline int bar(){
    int minval;
    int val1 = 1024;
    int val2 = 2048;
    
    minval = min(val1, val2);		//1
    minval = min(1024, 2048);		//2
    minval = min(foo(), bar()+1);	//3
    return minval;
}

对于第一种,会发生参数替换:

1
minval = val1 < val2 : val1, val2;

对于第二种,替换后直接常量计算。

1
minval = 1024;

对于第三种,会引发副作用。所以会引入临时变量避免了对这个函数的多次调用。因此,知道编译器会自动的做这些优化,就没有必须自己去画蛇添足的手动引入临时变量了。

1
2
3
int temp1;
int temp2;
minval = (t1 = foo), (t2 = bar()+1), t1 < t2 ? t1 : t2;

inline 中再调用 inline 函数,可能使得表面上一个看起来很平凡的 inline 却因连锁的复杂性而没有办法扩展开来。在inline函数中的局部变量和有副作用的参数也会导致大量临时对象的产生。编译器不一定可以把他们移除。

5.1 无继承情况下的对象构造

纯虚函数

纯虚函数介绍在vptr。这里仅做补充。

  • 不要把所有函数都声明为虚函数,然后依赖于编译器优化,这样不好。如果这个函数不需要多态调用,则不要把他声明为虚函数。这样会提升效率。
  • 一个虚函数该不该被定义为 const 呢?一个虚函数在基类中不需要修改 data member 并不意味着派生类改写它时一定不会修改 data member

对象生存周期

QQ截图20220831081947

一个对象的生命,是该对象的一个执行期属性。局部对象 的生命从L5的定义开始,到L10为止。全局对象 的生命和整个程序的生命相同。堆对象的生命从它被new运算符配置出来开始,到它被delete运算符摧毁为止。

记住:理论上,编译器会为每一个类产生四个(六个)默认函数:构造,析构,拷贝构造,拷贝赋值,(移动构造,移动赋值)

但是,并不一定真正产生。因为有的时候他们是trivial的。此处前面提到过,不赘述。2.1提到了何时生成non-trivial的构造函数。

  • POD类型可以使用列表初始化(聚合初始化),C++11后限制放宽。参见聚合初始化笔记。

  • POD类型是聚合类型的子集。

  • 针对可以使用列表初始化的类型,使用列表初始化会比一个一个赋值要快一些。

  • 针对移动构造和移动赋值,有更严格的要求:
    • 编译器只会针对满足如下情况的类生成移动构造和移动赋值
      • 没有用户定义的移动构造/移动赋值
      • 没有用户声明的拷贝构造/拷贝赋值
      • 没有用户声明的析构函数
  • 所以会有0/3/5法则。

引入虚函数会给对象的构造,拷贝,析构带来负担。

我们说过,在多态调用中,我们构建子类对象的时候会伴随着虚函数表指针的切换。父类部分构造期间指向父类,子类部分构造期间指向子类。

(严格来说虚函数表指针的切换应该是在构造函数后,用户代码前)。

而且也提到过为何对象调用无法触发多态,因为虚函数表指针不会被复制。编译器优化后就是不查表。(本笔记关键词搜索:对象调用)

这一切都需要编译器隐式的在我们的构造函数中添加代码。尤其是对象调用的时候,为了防止错误的拷贝虚函数表指针,还需要额外合成拷贝构造和拷贝赋值。

5.2 继承情况下的对象构造

  • C++ Standard 要求尽量延迟 nontrival members 的实际合成操作,直到真正遇到其使用场合为止。

构造函数可能会被安插大量代码:

  1. 记录在成员初始化列表中的数据成员初始化操作会被放进构造函数的函数本体,并以数据成员的声明顺序为顺序。(初始化列表的部分被声明)
  2. 如果有一个成员并没有出现在成员初始化列之中,但它有一个默认构造函数,那么该d默认构造函数必须被调用。(前面提到过,编译器会帮助我们调用对应的构造函数来构造出在类内但是没有在初始化列中的数据。看2.1)。
  3. 在那之前,如果这个类有虚函数指针,则需要被设定至指向正确的虚函数表(设置虚函数表指针)
  4. 在那之前,所有的父类构造函数必须被调用。以父类的声明顺序为顺序(调用父类构造)
    1. 如果父类被放在了子类的初始化列表中,则所有的显式指定的参数都要被传递(这块看一下频道的子类构造中调用父类构造)
    2. 如果父类没有被放在子类的初始化列表中,但是有默认构造,则调用默认构造。
    3. 如果这个父类是多重继承下的非第一基类,则this指针需要调整。(这部分看vptr笔记的多继承thunk技术)
  5. 在那之前,所有的虚基类构造函数必须被调用,从左到右,从深到浅。同时设置好所有的虚基类所需要的各种机制。(虚继承部分)

总结:顺序就是

  • 虚继承部分
  • 继承部分
  • 虚函数表指针
  • 数据成员。

QQ截图20220831091426

QQ截图20220831091431

记得在拷贝赋值中要进行自我赋值检测。

虚继承中,共享基类(虚基类)必须由最底层的类负责初始化操作。

QQ截图20220901000848

这里,point是point3d和vertex虚继承的父类(共享基类)。然后只有vertec3D才会负责初始化虚基类的内容。这也是为什么虚基类的元素总会在屁股。

通常,中间类调用这个共享基类初始化的操作会被编译器压制住。具体做法是增加一个most_derived参数判断是否是最底层的类。

  • 我们已经说过了,但还要再重复一次。构造函数中的代码是不具有多态性的。编译器在构造函数中安插代码时会保证: 先调用所有基类的构造函数,再设置 vptr,然后再调用 member initialization 操作。这是构造函数中没有多态性的根本原因!

5.3 对象复制语义学

拷贝赋值函数在如下四种情况不会表现出浅拷贝语义(bitwise copy)。此处和拷贝构造一样。看2.2

  • 类内包含一个有拷贝赋值的对象

  • 类继承自一个有拷贝赋值的类

  • 虚函数。

  • 虚继承。

C++ 标准没有规定在虚继承时 拷贝赋值 中是否会多次调用共享基类的 拷贝赋值。这样就有可能造成共享基类被赋值多次,造成一些错误,所以程序员应该在使用了 虚基类 时小心检验 拷贝赋值 里的代码,以确保这样的多次赋值没有问题或者查看编译器是否已经提供了解决方案。因此,尽可能不要允许一个 虚基类 的拷贝操作,甚至根本不在要任何 虚基类 中声明数据。

一般而言,我个人觉得在继承中,最好显式调用基类的拷贝构造。使用this->base::operator=()的方式调用。(个人理解,关于调用参考笔记拷贝构造)

5.5 析构语义学

析构函数如果没有被定义,则只有两种情况下才会被编译器真正合成出来。

  • 在类内含有一个其他拥有析构函数的类的对象的情况下。
  • 类拥有析构函数的情况下。

其他情况下,析构函数被视为trivial所以不会合成也不会被调用。

有虚函数不代表需要析构函数

要思考析构函数的作用。析构函数的作用旨在帮助你执行你认为当对象被销毁后应该执行的操作。

  • 对象销毁会调用析构函数,所以调用时机是:

    • 栈对象离开其作用域

    • 堆对象被手动 delete

析构函数执行顺序

  • 首先开始执行析构函数本体。谨记虚函数指针会在用户代码执行前被重新设置。
  • 如果类中拥有其他类的对象,并且对应类拥有析构函数。则他们会以其声明顺序的相反顺序被调用。
  • 重新设置虚函数指针,让它指向当前这个构造函数所处的类。也就是子类析构期间,析构函数中的虚函数一定执行的是子类的。父类析构期间,因为子类已经被析构,则更不可能调用到子类的虚函数。所以必须在做其他事情之前先重新正确设置虚函数指针。
  • 如果有自己的非虚拟继承的父类,则会以其声明顺序的相反顺序被调用。也就是析构顺序是先子类析构再父类析构。和构造顺序相反。
  • 如果有任何的虚拟继承的父类,而且当前类是最底层的。则他们会按照原来的构造顺序相反的顺序进行调用。

由于析构函数中的重设 vptr 会在任何代码之前被执行,这样就保证了在析构函数中也不具有多态性,从而不会调用非本类的函数。因为此时对象已经不完整了,有些成员已经不存在了,而函数有可能需要使用这些成员。

构造函数和析构函数中(的函数调用)都不具有多态性(也就是调用对应类型的虚函数一定是本类的):这并不是语言的弱点,而是正确的语意所要求的(因为那个时候的对象不完整)

6.1 执行期语义学 - 对象的构造和析构

  • 因为c++有很多的隐式类型转换,所以不太容易从程序代码中看出表达式的复杂度(时间和空间)。因为涉及到隐式类型转换的时候大概率会需要临时对象。所以其实一个简单的表达式可能会被编译器扩展为具有多次函数调用和多个临时对象的代码段。

  • 因为局部变量的生存周期是作用域(代码区块(花括号))。所以在这个区块内,可能会有隐式的多个地方可能调用这个对象的析构函数。换句话讲,如果一个区块内有多个退出点,则每一个退出点之前都要有这个区块内所有局部对象的析构函数。因为已经到了对象的生存期。所以尽量在需要对象的时候才声明对象,不要在一开始就声明所有的对象。这样可以在一些情况下减少非必要的对象的创建和销毁成本。
  • C++保证全局对象会被在第一次使用之前构建完毕,在main结束之前析构掉。
  • 建议不要使用需要使用静态初始化的全局对象。(不理解)
  • 现在的 C++ Standard 已经强制要求局部静态对象在第一次被使用时才被构造出来。这也是mayer’s 单例模式所用到的特性。

6.1 new 和 delete 运算符 搭配memory1/2笔记一起看

我一再强调。new和delete是运算符。malloc和free是C库函数。具体的区别看memory1笔记。

那就再提一遍。new是先分配内存在调用构造。delete是先析构再释放内存。

  • 每次调用new都需要传回一个独一无二的指针。为了解决这个问题,哪怕new的空间是0,也要为其分配1个字节然后传回这1字节内存地址的指针。
  • new中是有一个new_handler的,用来给内存不足的时候进行一次补救的机会。看memory6。
  • 一般来说new和free底层都用的是C库的malloc和free,尽管没有强制规定。

针对array new 而言:

  • 一般来说,如果new一个数组,数组里面储存的对象没有定义默认析构或者默认构造函数,则不需要调用对象的构造或析构函数。
  • 我们说过 array new 和 array delete必须成对出现。否则数组中储存的元素的析构函数只会被调用一次。这是为什么?
    • 因为获取数组的大小是有额外开销的。所以只有在[]出现的时候才会去寻找这个维度。否则他就假设只有一个对象是需要被删除的,也就是只调用一次元素的析构函数。
    • 如果使用array delete去释放单个对象,会进入死循环。
  • 原始数组和多态天生不兼容。当你对一个指向派生类的基类指针进行 delete [] pbase; 操作时,它是不会有正确的语意的。
    • 这是由于 delete []实际上会使用 vec_delete()类似的函数调用代替,而在 vec_delete()的参数中已经传递了元素的大小,在 vec_delete 中的迭代删除时,会在删除一个指针之后将指针向后移动 item_size 个位置,如果 DerivedClass 的 size 比 BaseClass 要大的话(通常都是如此),指针就已经指向了一个未知的区域了(如果 Derived 与 Base 大小相同,那碰巧不会发生错误,delete []可以正确的执行)。

针对placement new的语义。

我们在memory的笔记中已经了解了。placement operator new是一种可以在已分配内存中放置对象的操作。也就是它本身并不申请内存,只进行对象构造。

比如 我们他的实现可能长这个样子:

1
2
3
void* operator new(size_t, void* p){
  return p
}

可能这个看起来没有逼用,因为他的另一半我们写不出来。

另一半的可能实现可能长这样:

1
2
3
4
test* ptr = (test*) obj;
if(ptr != nullptr){
  ptr->test::test();
}

但是,如果此时这段内存上本身有对象,也就是在原来存在的对象上构造新的对象,此时这个原有的对象不会被销毁掉。我们的第一反应可能是这样直接调用delete:

1
2
3
delete ptr;
ptr = new(obj) test;
//obj是已分配的内存的指针。test是构建的对象

但是这个时候有问题:delete是不仅进行析构,而且释放内存。所以这时候其实已经新分配内存了。所以是错误的。

理论上来讲,应该需要这么写:

1
2
ptr->~test(); //显式调用析构函数。
ptr = new(obj) test;

理论上可以使用placement delete

所以placement operator new 应该和placement operator delete 搭配使用。也可以在 placement operator new 出来的对象上显式的调用它的析构函数使得原来的内存又可以被再次使用

一般而言,placement operator new 并不支持多态。因为子类一般比父类要大。所以已经存在的父类的内存不一定可以装得下子类对象。

6.3 临时对象

我们已经多次说过临时对象的三种产生时机:

  • 以值形式传递函数参数
  • 隐式类型转换
  • 函数返回一个对象(值返回)

临时对象的摧毁时机:

  • 临时对象应该在产生它的完整的表达式的最后一个步骤后被摧毁。切记是完整的表达式,比如一连串的逗号或一堆的括号,只有在完整的表达式最后才能保证这个临时对象在后面不会再被引用到。
  • 如果一个临时性对象被绑定于一个引用,则对象将残留,直到被这个引用的生命结束,或者直到临时对象的生命范围结束。视哪一种情况先到达而定。

总结:临时性对象的确在一些场合、一定程度上影响了C++的效率。但是这些影响完全可以通过良好的编码和编译器的积极优化而解决掉临时性对象带来的问题(至少在很大的程度上),所以对临时性对象的影响不能大意但也不必太放在心上。

7.1 模板

模板的二段式查找和实例化行为

首先我们来看一段正常的,没有用模板的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void c(){
    cout<<"global c()"<<endl;
}
class C{
    public: 
        void c() { 
            cout << "C::c()" << endl; 
        }
};

class D:public C
{
  public: 
    void g(){
        ::c();//gloabl c()
        c(); 	//C::c()
        this->c(); //C::c()
      	C::c();	//C::c()
   }
};

int main(){
    D d;
    d.g();
}

这段代码我们可以清楚地看到。子类可以调用父类函数,也就是他找得到。如果想调用全局函数需要加::作用域解析运算符强制使用。

那么我们再看看有模板类的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void f(){
    cout<<"global f()"<<endl;
}
template<typename T>
class A{
    public: 
        void f() { 
            cout << "A::f()" << endl; 
        }
};

template<typename T>
class B:public A<T>
{
  public: 
    void g(){
        f();//gloabl f() 如果global没有会报错。
        this->f();//A::f() 查找被延迟了
        A<T>::f();//A::f() 使用限定名来引入依赖性。
        B::f(); //  B::f() 当上面这种方法模板参数很长的时候可以使用这个方法引入依赖性
        // 或者直接引入名称空间
        using space = typename B::A;
        space::f();
   }
};

int main(){
    B<int> b;
    b.g();
}

我们发现了几个奇怪现象:

  • 如果全局函数没有f(),则编译期间会报错。
  • 如果有了全局函数f(),默认直接调用会使用全局函数而不是父类函数。
  • 只有使用了this指针或者是限定作用域才能调用父类的f()函数s

为什么?

编译器在看到一个模板的声明时会做出什么反映呢?实际上编译器没有任何反映!(这部分不同编译器不同。有的会报错有的不会。因为有的没有语法检查。)编译器的反映只有在真正具现化(实例化/使用)时才会发生。明白了这个,就明白了为什么在模板内部有明显的语法错误,编译器也不会报错,除非你要具现化出这个模板的一具实体时编译器才会发出抱怨。但是,凡是和模板参数有关的部分,都必须延迟到真正的实例化发生时才可以。比如由于我们不知道T类型是什么,T obj = 1024;这句可能是对的,也可能是错的。所以必须等到我们确定了T是什么的时候我们才会知道是对的还是错的。

也就是模板实例化的宗旨是延迟定义。也就是在模板类中,只有某个成员函数真正被使用的时候才会被实例化。这样做有至少两个好处:

  • 空间和时间上的效率。假设我们有一百个函数,我们使用了两种类型,其中一种类型使用了其中三个,另一种使用了其中五个。如果实例化全部的200个(100+100不同类型是不同的实例) 则会消耗很多资源。
  • 如果使用的类型并不完全支持所有的函数,但是只需要不去用那些不支持函数,这样的部分 具现化就能得以通过编译。

额外注意:声明一个模板类型的指针是不会引起模板的具现化操作的,因为仅仅声明指针不需要知道class的实际内存布局

一般来说类的名称查找有如下规则

1.对于两个非模板继承是可直接继承. 2.对于模板类继承非模板类时,在模板声明进行解析的时候就会进行查找 3.对于继承父类是模板类的会进行名称二段式查找。

二段式查找有如下两个阶段:

  • 模板定义阶段(scope of the template definition):刚被定义时,只有模板中独立的名字(可以理解为和模板参数无关的名字,非依赖名)参加查找
  • 模板实例化阶段(scope of the template instantiation):实例化模板代码时,非独立的名字(和模板参数有关的名字,依赖名)才参加查找。

依赖名和非依赖名在萃取笔记有。

模板相关的东西非常复杂。可以参考这篇文章:https://www.cnblogs.com/yyxt/p/5150449.html

所以我们回顾一下为什么在模板类中会出现如此特殊的现象。

首先进入 B 的模板定义阶段,此时 B 的基类 A<T> 依赖于模板参数 T,所以是一个「非独立」的名字。所以在这个阶段,对于 B 来说 A<T> 这个名字是不存在的,因为无法确定其父类类型,于是 A<T>::f() 也不存在。因为不会去父类查找。但此时这段代码仍旧是合法的,因为此时编译器可以认为 f() 是一个非成员函数(我们有全局函数f()的定义)。当稍晚些时候进入 B 的模板实例化阶段时,编译器已经坚持认为f() 是非成员函数,纵使此时已经可以查到 A<T>::f(),编译器也不会去这么做。因为他不会去基类查找。可以理解为在模板定义阶段,对于非依赖名称,链接是静态的,也就是现场决议。没有被实例化出来的父类是不存在的。一个模板子类其实是不能在实例化之前就知道他的模板父类到底是谁。

我们如何显示让一个名字从非依赖名变成依赖名?

  • this
  • 使用作用域运算符(引入限定名)

以上两种方式可以把对应的查找延迟到第二阶段,也就是把名称替换为依赖名。说白了就是显式告诉编译器,东西在父类里。不要在第一阶段查找。

针对这个问题简而言之:

  • 对于数据成员和成员函数,显式地为它们添加前缀,this->使它们依赖于模板类的完整定义。
  • 对于静态成员和成员类型,请使用模板类的名称显式对它们进行范围限定,如果要查找成员类型,请使用typename前缀 。

(如果您想访问静态数据成员或静态成员函数,这两个选项都可用,因为您可以使用this来访问静态数据成员或静态成员函数。)

来自:关于模板依赖性的解决办法

7.2 异常,有时间再看。

7.3 RTTI 在虚函数表说过了

多态类就是有虚函数的类(直接声明或继承)

特别注意:

C++隐式生成的 4 大成员函数,在不是真正需要的情况下都不要自己去声明。

因为如果是 trivial 的,这些函数不会被真正的合成出来(只存在于概念上),当然也就没有调用的成本了,去提供一个 trivial 的成员反而是不符合效率的。

不要觉得有构造就一定要有析构。要看实际是否需要。

构造函数和析构函数中都不具有多态性:这并不是语言的弱点,而是正确的语意所要求的(因为那个时候的对象不完整)

本文由作者按照 CC BY 4.0 进行授权