IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> C++知识库 -> 《深度探索C++对象模型》学习笔记 — 执行期语义学(Runtime Semantics) -> 正文阅读

[C++知识库]《深度探索C++对象模型》学习笔记 — 执行期语义学(Runtime Semantics)

一、对象的构造和析构

1、编译器插入的构造析构代码

对于最简单的函数,编译器需要在对象声明之后插入构造函数的调用;在函数返回之前插入析构函数的调用。实际上,对于任何一个函数中的对象,编译器需要在每一个离开点之前插入析构函数的调用。例如:

class CLS_Test {};

int main()
{
	int i = 0;
	CLS_Test test;
	...
	switch (i)
	{
	case 0:
	{
		...
		return;
	}
	case 1:
	{
		...
		return;
	}
	case 2:
	{
		...
		return;
	}
	case 3:
	{
		...
		return;
	}
	}
}

在每个return之前,都需要插入析构的代码。因此,创建对象的时机并非越早越好,而是越接近使用的时机越好。这样可以减少产生的冗余代码。

2、全局对象

在C语言中,全局对象是保存在数据段中的,并且它们的初始值也是保存在可执行文件中的。然而,对于C++中的全局object,我们虽然可以将它们同样放置在代码段中并且置为0,但其构造函数要保证在用户的main函数之前调用。这是怎样实现的呢?

作者提到在cfront编译器中,使用了munch策略,即:
(1)为每个需要静态初始化的文件生成一个 _sti 函数,内含必要的构造函数调用及inline扩展;
(2)类似地,提供一个 _std 函数,内含必要的析构操作;
(3)提供一组动态库munch函数:一个 _main 函数,用于在调用用户代码前后,分别执行上述生成的两个函数。
为了支持跨平台性,作者提到使用了两次CC命令以将第一次找到的两类函数添加到用户命令前后。

静态全局对象同样存在一些缺点:
(1)使用静态全局对象初始化时,我们无法进行try-catch处理。也就是说,在此过程中发生的异常将必然导致程序退出。
(2)不同文件中静态对象的依存关系和编译顺序会带来很高的复杂度。

3、局部静态对象

局部静态对象有着与全局对象相同的生命周期,但其作用域仅限于声明该变量的代码块。在现在的C++标准中,要求局部静态对象的初始化发生在其作用域函数第一次被调用时。这样可以在函数未被调用时的内存开销。那么这是怎样实现的呢?作者在书中提到了cfront编译器的处理:

void test()
{
	static CLS_Test test;
	...
}

变为

static CLS_Test* __0__F3 = nullptr;
void test()
{
	__0__F3 ? 0 : __0__F3 = CLS_Test::CLS_Test(&test);
	...
}

析构时:

char __std__stat()
{
	__0__F3 ? CLS_Test::~CLS_Test(&test) : 0;
}

查看msvc反汇编源码我们也可以推测下其实现方式:

void test()
{
	static CLS_Test test1;
	static CLS_Test test2;
}
	static CLS_Test test1;
006810D2  mov         eax,dword ptr fs:[0000002Ch]  
006810D8  mov         ecx,dword ptr [eax]  
006810DA  mov         edx,dword ptr [$TSS0 (06853C8h)]  
006810E0  cmp         edx,dword ptr [ecx+4]  
006810E6  jle         test+80h (0681130h)  
006810E8  push        offset $TSS0 (06853C8h)  
006810ED  call        _Init_thread_header (06818F7h)  
006810F2  add         esp,4  
006810F5  cmp         dword ptr [$TSS0 (06853C8h)],0FFFFFFFFh  
006810FC  jne         test+80h (0681130h)  
006810FE  mov         dword ptr [ebp-4],0  
00681105  mov         ecx,offset test1 (06853CCh)  
0068110A  call        CLS_Test::CLS_Test (0681000h)  
0068110F  push        offset `test'::`2'::`dynamic atexit destructor for 'test1'' (0682780h)  
00681114  call        atexit (0681C09h)  
00681119  add         esp,4  
0068111C  mov         dword ptr [ebp-4],0FFFFFFFFh  
00681123  push        offset $TSS0 (06853C8h)  
00681128  call        _Init_thread_footer (06818ADh)  
0068112D  add         esp,4  
	static CLS_Test test2;
00681130  mov         eax,dword ptr fs:[0000002Ch]  
00681136  mov         ecx,dword ptr [eax]  
00681138  mov         edx,dword ptr [$TSS1 (06853C0h)]  
0068113E  cmp         edx,dword ptr [ecx+4]  
00681144  jle         test+0DEh (068118Eh)  
00681146  push        offset $TSS1 (06853C0h)  
0068114B  call        _Init_thread_header (06818F7h)  
00681150  add         esp,4  
00681153  cmp         dword ptr [$TSS1 (06853C0h)],0FFFFFFFFh  
0068115A  jne         test+0DEh (068118Eh)  
0068115C  mov         dword ptr [ebp-4],1  
00681163  mov         ecx,offset test2 (06853C4h)  
00681168  call        CLS_Test::CLS_Test (0681000h)  
0068116D  push        offset `test'::`2'::`dynamic atexit destructor for 'test2'' (0682770h)  
00681172  call        atexit (0681C09h)  
00681177  add         esp,4  
0068117A  mov         dword ptr [ebp-4],0FFFFFFFFh  
00681181  push        offset $TSS1 (06853C0h)  
00681186  call        _Init_thread_footer (06818ADh)  
0068118B  add         esp,4

TSS应该是任务状态段。test1test2对象在该状态段中分别有一个状态保存它们的初始化信息,06853C8h06853C0h。当这个状态值与经过fs寄存器寻址得到的值一致时,说明没有初始化过,需要调用构造函数。类似地,再往下看,有一处dynamic atexit destructor用于放置程序退出时需要析构的对象及其析构函数地址。这里test1test2的析构函数地址分别放置在0682780h0682770h。按照栈的执行顺序,这保证了后构造的静态对象会先被释放。

3、对象数组

以下我并没有找到msvc对应的描述,仅是转述作者的话。‘
对于一个对象数组,会使用vec_new处理不含虚基类的对象,使用vec_vnew处理含有虚基类的对象。vec_new的函数原型如下:

void*
vec_new(
	void *array,                    // 数组起始地址
	size_t elem_size,               // 数组元素大小
	int elem_count,                 // 数组元素个数
	void (*constructor)(void*),
	void (*destructor)(void*, char)
)

array为0时,说明需要从堆中分配内存。constructordestructor分别为默认构造函数和析构函数的函数指针。这里提供destructor的目的是用于异常处理,以在构造函数抛出异常时析构对象。
相应地,当该数组声明周期结束时,有一个vec_delete函数用于释放这部分空间。其原型为:

void*
vec_new(
	void *array,                    // 数组起始地址
	size_t elem_size,               // 数组元素大小
	int elem_count,                 // 数组元素个数
	void (*destructor)(void*, char)
)

二、new 和 delete 运算符

1、new 和 delete 的步骤

对一个对象使用new操作符其实可以分为两步进行(与allocator类的使用类似):第一步是空间分配,第二步是构造函数调用。类似地,对一个指针进行delete时,首先会调用析构函数,然后才是空间的释放。

2、数组 new 和 delete

数组的newdelete必须成对出现。但有趣的是,在delete数组指针的时候,我们并不需要传入该数组的大小。那编译器是怎么知道要清空多大的内存呢??
事实上,在最初版本的编译器中,数组的大小是需要程序员传递给编译器的。在后来的升级过程中,这项要求被去掉。一个明显的方法是在数组空间中额外多申请一个word的大小,用于保存数组的长度。这种策略被称为cookie。然而,在这个策略中,对于野指针的释放将会导致一块非预期的内存被释放,进而导致程序异常。除此之外,还有一个策略叫联合数组,放置指针、大小即析构函数地址。在这个策略下,如果我们对野指针进行释放,只是会取出错误的元素个数;而析构函数地址并不存在,因此不会导致错误的空间释放。

3、数组与多态

作者提到,使用基类数组指针保存派生类数组对象后,其释放只能调用基类析构函数。我在msvc编译器进行验证:

#include <iostream>
using namespace std;

class CLS_Base
{
public:
	virtual ~CLS_Base()
	{
		cout << "~CLS_Base()" << endl;
	}
};
class CLS_Derived : public CLS_Base 
{
public:
	virtual ~CLS_Derived()
	{
		cout << "~CLS_Derived()" << endl;
	}
};

int main()
{
	CLS_Base* pBaseArr = new CLS_Derived[10];
	delete[] pBaseArr;
}

在这里插入图片描述
假设现在还是使用的vec_delete函数。那么msvc中的数组析构策略应该是通过vptr指向的虚析构函数地址,而非单纯的把析构函数地址传递给vec_delete

4、定位 new 运算符

关于定位new运算符,有一个很有趣的问题:

#include <iostream>
using namespace std;

class CLS_Base
{
public:
	int m_iMem;
	virtual void test()
	{
		cout << "test in Base" << endl;
	}
};
class CLS_Derived : public CLS_Base 
{
public:
	virtual void test()
	{
		cout << "test in Derive" << endl;
	}
};

int main()
{
	CLS_Base baseObj;
	baseObj.test();
	baseObj.~CLS_Base();
	new (&baseObj) CLS_Derived;
	baseObj.test();
}

这个问题的关键在于基类对象与派生类对象大小相同,因此可以在一个基类对象的空间上分配派生类对象的空间。在msvc编译器中,其输出结果为:
在这里插入图片描述
其实这可以理解,我们是通过对象调用的该函数,因此不存在通过vptr寻找实际函数的过程。如果我们改为用指针调用:

(&baseObj)->test();

则可以得到派生类中的输出。

三、临时性对象

1、临时对象的产生

关于临时性对象,作者提到了几点:
(1)当在定义对象时通过函数进行初始化,一般不会产生临时对象;
(2)当定义之后,在通过函数进行赋值,一般会产生临时性对象;并且,在赋值之前需要先调用析构函数;
(3)当调用函数而不将函数返回值置给某个变量时会产生临时对象。

#include <iostream>
using namespace std;

class CLS_Test
{
public:
	int a = 0;
	CLS_Test()
	{
		cout << "CLS_Test() this = " << this << endl;
	}

	CLS_Test(const CLS_Test& other)
	{
		cout << "CLS_Test(const CLS_Test&) this = " << this << " other = " << &other << endl;
	}

	~CLS_Test()
	{
		cout << "~CLS_Test() this = " << this << endl;
	}

	CLS_Test operator+(const CLS_Test& other)
	{
		CLS_Test test;
		return test;
	}

	CLS_Test& operator=(const CLS_Test& other)
	{
		cout << "operator= this = " << this << " other = " << &other << endl;
		return *this;
	}
};

int main()
{
	CLS_Test obj1, obj2;
	cout << endl << "initialization:" << endl;
	CLS_Test obj3 = obj1 + obj2;
	cout << endl << "assignment:" << endl;
	obj3 = obj1 + obj2;
	cout << endl << "don't save object:" << endl;
	obj1 + obj2;
	cout << endl << "release:" << endl;
}

在这里插入图片描述
上述结果需要在开启优化的情况下出现。其中,第三种情况产生的意义在于,在函数执行过程中,可能做操作数或有操作数的状态会发生变化。该变化需要编译器保证发生。

2、临时对象的生命周期

如果我们尝试执行:

printf("%s", obj1 + obj2);

我们能否打印出临时对象中的数据呢?
按照C++标准:

Temporary objects are destroyed as the last step in evaluating the full-expression that (lexically) contains the point where they were created

临时性对象的摧毁应该是对完整表达式求值过程中的最后一个步骤。因此,我们应该在printf执行完再调用临时对象的析构函数。

那么什么是完整表达式呢?C++标准中这样解释:

A full-expression is an expression that is not a subexpression of another expression.

也就是不隶属于其他任何表达式的表达式。

那么当对象的产生是运行时确定的,其生命规则就显得有些复杂。例如

if (obj1 + obj2 || obj3 + obj4)
{
	...
}

这两个表达式所产生的临时对象该怎样处理呢?按照作者在书中所说,该表达式可能被转换为:

if (operator bool(obj5 = obj1.operator(obj2)), obj5.destructor() 
	|| operator bool(obj6 = obj3.operator(obj4)), obj6.destructor())

这样就与C++标准相冲突了,obj5析构时表达式还没有被完整求值。也就是说,我们必须在执行完整的if语句后,插入条件判断,确定两个对象是否需要销毁。

3、生命规则例外

C++标准中提到生命规则有三个例外。作者在书中也提到两个例外,然而它们并不全相同。

(1)数组初始化(两种例外)

关于数组初始化,C++标准提到有两种例外情况:

The first context is when a default constructor is called to initialize an element of an array with no corresponding initializer. The second context is when a copy constructor is called to copy an element of an array while the entire array is copied. In either case, if the constructor has one or more default arguments, the destruction of every temporary created in a default argument is sequenced before the construction of the next array element, if any.

大致意思是对于一个没有初始化列表形式的构造函数却含有一个以上默认参数的类,如果通过对临时对象的构造(例外1)或者拷贝构造(例外2)使用默认构造函数执行对数组的初始化,每个临时对象的析构将发生在下一个数组元素构造之前:

#include <iostream>
using namespace std;

class CLS_Test
{
public:
	CLS_Test(int b = 10)
	{
		cout << "CLS_Test()" << endl;
	}

	~CLS_Test()
	{
		cout << "~CLS_Test() " << endl;
	}

	CLS_Test(const CLS_Test&)
	{
		cout << "CLS_Test(const CLS_Test&)" << endl;
	}

	int getElement()
	{
		return 5;
	}
};

int main()
{
	CLS_Test test;

	cout << endl << "normal case:" << endl;
	CLS_Test().getElement(), CLS_Test().getElement(), CLS_Test().getElement();

	cout << endl << "first context:" << endl;
	int arr[3] = { CLS_Test().getElement(), CLS_Test().getElement(), CLS_Test().getElement() };

	cout << endl << "second context:" << endl;
	int arr2[3] = { CLS_Test(test).getElement(), CLS_Test(test).getElement(), CLS_Test(test).getElement() };
	
	cout << endl;
}

在这里插入图片描述
对比正常情况和下面两种情况可以看出构造函数和析构调用顺序确实不一样。然而,如果我把默认构造函数去掉,并没有任何区别。不知道是不是编译器实现的问题。如果有朋友知道,请帮忙指出。

(2)引用绑定(标准中的例外3)

C++标准中提到的第三种例外是:

The third context is when a reference is bound to a temporary object.

也就是将临时对象绑定到引用上。这也比较好理解,如果我们在临时对象绑定之后将其释放,这个引用就成为空悬引用(dangling reference)。在这种情况下临时对象的释放有几种情况,感兴趣的话可以参见C++20标准6.7.7。

(3)拷贝赋值(书中提到)

如果我们将一个表达式产生的临时对象赋值给另一个变量:

CLS_Test obj3 = obj1 + obj2;

如果我们在右侧表达式求值后直接释放了临时对象,这时拷贝函数就无法执行了。因此,C++标准中规定,凡持有表达式执行结果的临时性对象,应该保留到object的初始化操作完成为止。(这点我没有在C++20的草案中看到)。

  C++知识库 最新文章
【C++】友元、嵌套类、异常、RTTI、类型转换
通讯录的思路与实现(C语言)
C++PrimerPlus 第七章 函数-C++的编程模块(
Problem C: 算法9-9~9-12:平衡二叉树的基本
MSVC C++ UTF-8编程
C++进阶 多态原理
简单string类c++实现
我的年度总结
【C语言】以深厚地基筑伟岸高楼-基础篇(六
c语言常见错误合集
上一篇文章      下一篇文章      查看所有文章
加:2021-07-17 11:43:20  更:2021-07-17 11:45:00 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年5日历 -2024/5/6 17:27:58-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码