传值、传指针和传引用

我们先从最简单的内置类型说起,结合汇编代码说说三者之间的联系和区别;然后我们再讲用户自定义类类型的三种传参方式的优劣。

内置类型

内置类型的传值是最简单的,也是其他传参形式的基础。此处的“其它传参形式”指所有传参方式,包括内置类型的传指针、传引用,也包括自定义类类型的传值、传指针和传引用。

传指针也是传值

内存地址(指针)也是一个数值,所以从汇编代码的层次看,传值和传指针其实是一样的(《Head First C》中也提到 C 语言所有的传参都是传值)。传值之后的操作更大程度上依赖于业务:

  1. 传值(不包括传指针的情形),被调用函数声明为 func(const int i)func(int i) 的形式,但后者在其多数的使用场景中其实并不规范,往往应该用 const 修饰。在 func() 的定义中一般不会对 i 进行取地址操作。这是最基础的使用情形。

  2. 传值(特指传指针),被调用函数多声明为 func(int *pi) 的形式,也有变种 func(int * const pi)func(const int *pi)。传指针的应用场景又可以分为两种:

    • 需要解引用。一般使用场景为修改指针指向的值的内容,此时不可以声明为 func(const int *pi)
    • 不需要解引用。只是单纯的对指针做一些操作,比如重新指向,或者加减指针操作,此时不可以声明为 func(int * const pi)

引用的实现往往基于指针

广义的传值是包括传指针的。传引用,个人认为是对传指针的一种包装,更易于使用,更少的出错可能性。实际上引用的实现往往基于指针,我们可以粗略的认为“传引用 = 传指针 + 解引用”。传引用也可以分为两种使用场景:

  1. 不会对引用进行取地址操作。这是最常用的情形。
  2. 会对引用进行取地址操作。这其实是存在性能浪费的,为什么不直接传指针呢?如果说你是回避指针,减少出错的可能性,但既然还是要取地址,那不如从一开始就面对;另一种可能性是函数定义中多数操作是非指针操作,取地址只是个小比例。

代码怎么写更好

上述列举了 5 种代码场景,我们通过业务场景再进行一次分类:

不改实参

我们不需要在被调用函数中修改实参

1
2
3
func(const int i);
func(const int* pi);
func(const int& ref);

虽然上述 3 种都可以实现目的,但哪一种更好呢?先说结论,在《Effective C++》里提到

对内置(C-like)类型在函数传参时pass by value比pass by reference更高效,当用OO的c++自定义类型(存在构造/析构等)pass by reference to const 更好,STL里的迭代器和函数对象是用C指针实现的,因此pass by value更好。

我们先不讨论自定义类类型。如果有特殊的业务要求,比如要根据指针获得下一个内存块,那么使用指针更合适,但最普通的使用场景下,传指针(包括传引用)比传值多一个解引用步骤(传指针需要显示解引用,传引用是隐式解引用),除此之外两者的开销其实是一样的,就像前面提到的,在汇编代码层次,传递指针和传递数值指令是一样的。

要改实参

我们有修改实参的需求

1
2
func(int * const pi);
func(int& ref);

哪一种更好呢?如果没有必须使用指针的理由,为了减少出现错误的可能性,个人认为还是选择引用更好一些。貌似 Google 的编码规范中统一接口形式,要求使用指针。

其他

  1. 对于内置类型,传值比传引用更高效。虽然我无法确定内置类型没有构造函数(我个人倾向于没有构造的观点),参考 Do built-in types have default constructors?
  2. 内置类型的初始化和赋值成本一样(参考《Effective C++》 第28页)
  3. 来看 《Effective Modern C++》第 50 页的一句话,重点看我加粗的这一句。可以看出大师对“内置类型初始化和赋值区别”的态度——对于使用者来说,两者的区别不值一提。由此推断,对使用者来说可以认为两者的成本是一样的。同大师在上一版本的作品中给出的结论一致。

The “confusing mess” lobby points out that the use of an equals sign for initialization(使用 = 的初始化) often misleads C++ newbies into thinking that an assignment is taking place(误认为是赋值操作), even though it’s not. For built-in types like int, the difference is academic(学术的,理论的), but for userdefined types, it’s important to distinguish initialization from assignment

类类型

我们之前讨论内置类型时,提到一点“内置类型的传值和传引用(传指针),在撇开解引用步骤之外开销是一样的”。因为内置类型传值是传递一个数值,传指针也是传递一个数值。

我们都知道类类型是对数据的封装——对内置类型或类类型的封装,继承体系中派生类对基类的封装。只关注首尾,类其实是对很多个内置类型的数据成员的封装。从自然语义上说,类类型的传值得包括其所有数据成员的传值,如此就意味着得传递很多个(内置类型)数值了。从严格的语法定义上,类类型传值需要付出类对象初始化(拷贝构造)和销毁(析构)成本,而传引用(指针)可以通过“传地址 + 解引用”来避免这些成本,这基本上是稳赚的——类封装起来的内置类型成员越多,代价越大;而传地址只是传递一个数值,解引用只是一步寻址操作,成本是固定的。只封装一个内置类型成员的类多见于测试代码,实际应用中基本不会出现。

用户自定义的类类型,函数调用时传常量引用比传值更高效,因为前者不会有类对象初始化(拷贝构造)和销毁(析构)成本。

附录

汇编代码

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 所有的汇编指令都是对内存块的操作,其参数都是内存标记(寄存器或内存地址)
#include <iostream>
#include <string>

using std::cout;
using std::endl;
void Print(const int i)
{
int j = i;
//008A181E mov eax,dword ptr [i]
//008A1821 mov dword ptr [j],eax
cout << j << endl;
}

void Print2(const int& i)
{
int j = i;
// 引用多用指针实现
//00DB179E mov eax,dword ptr [i] // 跟传指针一样。直接将 [] 中的 i 臆想为 pi
//00DB17A1 mov ecx,dword ptr [eax] //
//00DB17A3 mov dword ptr [j],ecx
cout << j << endl;
}

void Print3(const int *pi)
{
int j = *pi;
//009C181E mov eax,dword ptr [pi] // 将内存地址 pi 中的数据(pi 的值,即 i 的地址)赋给eax
//009C1821 mov ecx,dword ptr [eax] // 将内存地址 eax 中的数据赋给 ecx,ecx 得到 i 的值
//009C1823 mov dword ptr [j],ecx
cout << j << endl;
}

int main()
{
int value = 10;
//008A1908 mov dword ptr [value],0Ah // 将 0Ah 赋给内存地址 value 中的数据
Print(value);
//008A190F mov eax,dword ptr [value] // 将内存地址 value 中的数据赋给 eax
//008A1912 push eax
//008A1913 call Print (08A111Dh)
//008A1918 add esp,4
Print2(value);
//00DB191B lea eax,[value] // 将 value 存储单元的有效地址(偏移地址)传送到 eax。
//00DB191E push eax
//00DB191F call Print2 (0DB1082h)
//00DB1924 add esp,4
Print3(&value);
//009C4317 lea eax,[value]
//009C431A push eax
//009C431B call Print3 (09C1370h)
//009C4320 add esp,4
return 0;
}