神奇的 C++ 传值——从期末 C++ 考试题说起

(这篇文章主要涉及以下概念:指针、引用、内存地址、栈、堆、汇编、反汇编、寄存器、堆栈空间、赋值构造函数。)

不得不说我们学院的刚哥的 C++ 考试题就是不一样…考完试之后,有一道题被很多同学议论颇多,这就是写运行结果的第一题。这道题的题目大约如下。

using namespace std;

class A{
    int i;
public:
    A(int x=0){
        i=x;
        cout << "In Constructor" << endl;
    }
    A(const A& a){
        i=a.i;
        cout << "In Copy Constructor" << endl;
    }
    ~A(){
        cout << "In Destructor" << endl;
    }
};

A func(A a){
    return a;
}

int main(){
    A a;
    A b = 1;
    A c = func(a);
    return 0;
}

这道题目的正确答案是,两个 In Constructor,两个 In Copy Constructor,四个 In Destructor。这本身并没有什么疑问。

但是,如果我把这个程序给稍微给改一下,就会产生疑惑。

将 main 函数的内容改为如下:

int main(){
    A a;
    A b;
    b = func(a);
    return 0;
}

并且在 return 0; 设置断点,运行,我们会发现输出结果如下。

In Constructor

In Constructor

In Copy Constructor

In Copy Constructor

In Destructor

In Destructor

我们分析一下:

  1. A a; 时产生了一次构造函数调用,A b; 时产生第二次。
  2. func(a) 复制实参时,调用复制构造函数,在func函数返回值的时候,由于返回的是类的对象本身,产生了一次复制构造函数调用。
  3. b 随后被赋值为 func(a) 的返回值,所以实参析构,析构函数被调用。

问题是,后面的另一个析构函数调用是如何产生的?

经过分析我们可以发现,func(a) 产生了一个临时的对象 tmp,随后 tmp 被赋值给 b,随后 tmp 被析构。

随后我们把 main 函数改为如下:

int main(){
    A a;
    A b = func(a);
    return 0;
}

这段代码和上面代码的区别是,b 从先定义后赋值改成了直接定义和初始化。

这时候我们会发现,tmp 的析构不存在了,func 返回时产生的 a 的副本是直接产生在 b 的地址上的。也就是说,不存在临时的对象。

这时我们会很好奇。func 的返回值究竟是如何传递的?A b = func(a); 这样的语句是如何能够让 func(a) 的返回值直接写入到了 b 的地址空间呢?毕竟,返回值所创建的那个对象副本是在 func 作用域和栈空间里的,而 b 是 main 函数的局部变量,保存在 main 的堆栈空间。

于是我猜测,对 func 的调用压入了一个隐含参数,这个参数的内容是指向返回值的地址的,func 的返回值就保存在给定的地址中。这样,在函数内部就可以访问到这个空间并保存函数的返回值。在 b = func(a); 语句中,b 已经初始化,这时要使用赋值构造函数重新构造 b,所以需要给赋值构造函数传递一个参数,就必须存在一个临时变量 tmp 的地址,于是隐含参数所包含的地址就是编译器创建的临时对象的地址。

从此,可以分析出,func 的返回值是保存在一块新的内存区域的;如果用户将这个返回值直接用作初始化,那么这块区域就是被初始化函数的内存区域;如果不是,那么编译器会在调用方所在函数的栈空间里创建一个临时的内存区域,用于保存返回值,由于临时对象的唯一作用是作为赋值构造函数的参数传递给operator=函数从而将 b 重写为 a 的副本,所以这必将导致一个结果,就是当 b 被赋值完毕之后,这个无名的临时对象就无用了。

那么,编译器又如何处理了这个无名的临时对象呢?正常人的想法是,这个变量应该和其他的同属于这个栈的变量们一起,在函数作用域结束之前被析构。然而上述的运行结果告诉我们,这个临时变量 tmp 是在之前就已经析构了的。从以上内容可以得到的结论是,编译器对于函数调用,应该是在调用之前分配返回值的内存空间,并在调用后将这个空间立即处理掉(直接分配到局部变量的地址上的情况除外)。

事实真的如此吗?我写了下面这样一个程序进行验证。

using namespace std;

int time = 0;

class TestClass
{
public:
	TestClass()
	{
		thistime = rand() % 1000;
		ortime = -1;
		cout << "[" << thistime << "] : created " << endl;
	}
	TestClass(TestClass &a)
	{
		thistime = rand() % 1000;
		ortime = a.thistime;
		cout << "[" << thistime << "] : created (from " << ortime << ")" << endl;

	}
	TestClass& operator= (const TestClass& a)
	{
		this->thistime = rand() % 1000;
		this->ortime = a.thistime;
		cout << "[" << thistime << "] : assigned (from " << a.thistime << ")" << endl;
		return \*this;
	}
	~TestClass()
	{
		cout << "[" << thistime << "] : destroyed " << endl;
	}
	int thistime;
	int ortime;
};

TestClass func(TestClass test)
{
	cout << "[" << test.thistime << "] : func called " << endl;
	return test;
}

int main()
{
	TestClass a;
	TestClass b;
	b = func(a);
	cout << b.thistime << endl;
	TestClass c = func(b);
	cin.get();
	return 0;
}

这个程序的运行结果如下:

[41] : created

[467] : created

[334] : created (from 41)

[334] : func called

[500] : created (from 334)

[334] : destroyed

[169] : assigned (from 500)

[500] : destroyed

169 [724] : created (from 169)

[724] : func called

[478] : created (from 724)

[724] : destroyed

运行结果分析如下:

  1. 在本程序刚启动的时候,创建了一个 41 号的类(这个是变量 a)。
  2. 又创建了一个 467 号类(变量 b)。
  3. 到了 b = func(a),首先 a 被复制构造函数创建了一个副本到 func 的实参里,这个是 from 41 的 334 号类产生的原因。这时,程序为 func 的返回值也分配好了一个临时的内存空间,位于 main 函数的堆栈空间(frame)里,并通过隐式参数传递到 func。
  4. 然后开始执行 func 函数体。输出「[334] : func called」,这个函数产生了一个返回值 a。由于是类的对象,所以创建了一个副本,也就是调用复制构造函数,创建了 500 号类(from 334),并保存到通过隐式参数传递过来的临时的内存空间地址中。func 调用结束,334 这个实参没用了,所以 destroyed 了。
  5. 临时空间中的500 号类被传递到 operator = (赋值构造函数)中,并用其重新构造了 b,此时 原来的 467 号类 b 变成了 169 号类。但其实还是原来的那个变量,地址不变,只是内容变了。
  6. 临时空间用完了,随即被编译器生成的代码释放,此时调用了 500 号的析构函数(也解释了最开始的问题)。
  7. 继续执行 func(b) ,同理,b (169号)先被复制成 func 的实参 724号类,然后 724 复制了一个副本,产生返回值,这是 478 号。由于变量 c 是直接初始化而不是赋值的, 所以区别就来了,c的地址直接被传递给了 func,所以 func 的返回值直接存放到了 c 中,不存在任何复制构造或赋值构造。
  8. 724 号类随着 func 的运行结束被销毁,析构函数被调用。
  9. 控制权交给 cin.get(); 语句。

为了进一步验证我们的猜想是否正确,我们现在对这个程序进行反汇编。核心的汇编代码列举如下。先来看看赋值构造函数情况下。(其中,ebp 是栈的基地址,eax 是 C/C++ 语言中存放返回值或返回值地址的寄存器的默认约定。)

// 语句 b = func(a);
010C1808  sub         esp,8  
010C180B  mov         ecx,esp  
010C180D  mov         dword ptr [ebp-12Ch],esp  
010C1813  lea         eax,[ebp-18h]  
010C1816  push        eax  // 以上代码分配返回值的内存空间,位于 main 函数领空的堆栈空间,并压入栈内作为隐性参数
010C1817  call        TestClass::TestClass (10C120Dh)  // 调用复制构造函数获得实参
010C181C  mov         dword ptr [ebp-134h],eax  
010C1822  lea         ecx,[ebp-120h]  
010C1828  push        ecx  // 实参压栈
010C1829  call        func (10C104Bh)  // 执行 func 函数
010C182E  add         esp,0Ch  
010C1831  mov         dword ptr [ebp-138h],eax  
010C1837  mov         edx,dword ptr [ebp-138h]  
010C183D  mov         dword ptr [ebp-13Ch],edx  
010C1843  mov         byte ptr [ebp-4],2  
010C1847  mov         eax,dword ptr [ebp-13Ch]  
010C184D  push        eax  // 将临时内存空间内的类对象的地址传给 operator= 构造变量 b 
010C184E  lea         ecx,[ebp-28h]  
010C1851  call        TestClass::operator= (10C11C7h)  
010C1856  mov         byte ptr [ebp-4],1  
010C185A  lea         ecx,[ebp-120h]  
010C1860  call        TestClass::~TestClass (10C112Ch)   // 调用析构函数,析构临时内存空间内的类对象

下面是直接初始化的情况。

// 语句 TestClass c = func(b);
010C1895  sub         esp,8  
010C1898  mov         ecx,esp  
010C189A  mov         dword ptr [ebp-110h],esp  
010C18A0  lea         eax,[ebp-28h]  
010C18A3  push        eax // 变量 c 的地址
010C18A4  call        TestClass::TestClass (10C120Dh)  // 复制实参
010C18A9  mov         dword ptr [ebp-134h],eax  
010C18AF  lea         ecx,[ebp-38h]  
010C18B2  push        ecx  // 实参入栈
010C18B3  call        func (10C104Bh)  
010C18B8  add         esp,0Ch  
010C18BB  mov         dword ptr [ebp-138h],eax  
010C18C1  mov         byte ptr [ebp-4],3  

明显比前面的代码少,返回值被直接写入了调用方指定的栈中,也就是证明了我们之前的想法。

总结如下:调用函数时,不管参数类型如何,调用者(caller)负责从右至左将参数依次压栈,最后压入返回地址并跳转到被调函数入口处执行。被传递的参数和返回地址都是位于调用者的stack frame(堆栈空间)中的。

如果函数的返回值类型是整型(包括char,short,int,long及它们的无符号型)或指针类型的话(它们的长度小于等于 EAX 寄存器的长度(32位机上是 4 个字节)),那么就利用EAX寄存器来传回返回值。否则,利用 EAX 寄存器存放返回值的地址。传入的返回地址可能是一个临时的地址,这个地址将会在其他变量赋值后被立即回收,也可能是一个局部变量的地址,将在作用域超过时正常回收。

题外话:我帮你整理了包括 AI 写作、绘画、视频(自媒体制作)零门槛 AI 课程 + 国内可直接顺畅使用的软件。想让自己快速用上 AI 工具来降本增效,辅助工作和生活?限时报名

当前页阅读量为: