前言

C语言的指针让我们有了直接操控内存的强大能力,同时指针也是使用C语言时最容易出问题的地方。C++在继承了C语言的指针的同时又给我们提供了另外一个武器:引用。今天我们来探讨一下指针与引用的异同以及两者之间的性能差异。

指针与引用的区别

引用与指针的区别也算是C++中老生常谈的话题了,无论是在期末考试的试卷上还是找工作时的笔试面试上,这个问题都是“常客”。对于这个问题更加详细的解答请参考《More Effective C++》的条款1,本文主要提一下以下三个区别:

  1. 引用必须初始化,而指针可以不初始化。

    我们在定义一个引用的时候必须为其指定一个初始值,但是指针却不需要。

    1
    2
    int &r;    //不合法,没有初始化引用
    int *p; //合法,但p为野指针,使用需要小心
  1. 引用不能为空,而指针可以为空。

    由于引用不能为空,所以我们在使用引用的时候不需要测试其合法性,而在使用指针的时候需要首先判断指针是否为空指针,否则可能会引起程序崩溃。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    void test_p(int* p)
    {
    if(p != null_ptr) //对p所指对象赋值时需先判断p是否为空指针
    *p = 3;
    return;
    }
    void test_r(int& r)
    {
    r = 3; //由于引用不能为空,所以此处无需判断r的有效性就可以对r直接赋值
    return;
    }
  1. 引用不能更换目标

    指针可以随时改变指向,但是引用只能指向初始化时指向的对象,无法改变。

    1
    2
    3
    4
    5
    6
    7
    8
    int a = 1;
    int b = 2;

    int &r = a; //初始化引用r指向变量a
    int *p = &a; //初始化指针p指向变量a

    p = &b; //指针p指向了变量b
    r = b; //引用r依然指向a,但a的值变成了b

引用的使用场景

只看两者区别的话,我们发现引用可以完成的任务都可以使用指针完成,并且在使用引用时限制条件更多,那么C++为什么要引入“引用”呢?

限制条件多不一定是缺点,C++的引用在减少了程序员自由度的同时提升了内存操作的安全性和语义的优美性。比如引用强制要求必须初始化,可以让我们在使用引用的时候不用再去判断引用是否为空,让代码更加简洁优美,避免了指针满天飞的情形。除了这种场景之外引用还用于如下两个场景:

  1. 引用型参数

    一般我们使用const reference参数作为只读形参,这种情况下既可以避免参数拷贝还可以获得与传值参数一样的调用方式。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    void test(const vector<int> &data)
    {
    //...
    }
    int main()
    {
    vector<int> data{1,2,3,4,5,6,7,8};
    test(data);
    }
  2. 引用型返回值

    C++提供了重载运算符的功能,我们在重载某些操作符的时候,使用引用型返回值可以获得跟该操作符原来语法相同的调用方式,保持了操作符语义的一致性。一个例子就是operator []操作符,这个操作符一般需要返回一个引用对象,才能正确的被修改。

    1
    2
    3
    4
    vector<int> v(10);
    v[5] = 10; //[]操作符返回引用,然后vector对应元素才能被修改
    //如果[]操作符不返回引用而是指针的话,赋值语句则需要这样写
    *v[5] = 10; //这种书写方式,完全不符合我们对[]调用的认知,容易产生误解

指针与引用的性能差距

指针与引用之间有没有性能差距呢?这种问题就需要进入汇编层面去看一下。我们先写一个test1函数,参数传递使用指针:

1
2
3
4
5
void test1(int* p)
{
*p = 3; //此处应该首先判断p是否为空,为了测试的需要,此处我们没加。
return;
}

该代码段对应的汇编代码如下:

1
2
3
4
5
6
7
8
pushq	%rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp)
movq -8(%rbp), %rax
movl $3, (%rax)
nop
popq %rbp
ret

上述代码1、2行是参数调用保存现场操作;第3行是参数传递,函数调用第一个参数一般放在rdi寄存器,此行代码把rdi寄存器值(指针p的值)写入栈中;第4行是把栈中p的值写入rax寄存器;第5行是把立即数3写入到rax寄存器值所指向的内存中,此处要注意(%rax)两边的括号,这个括号并并不是可有可无的,(%rax)和%rax完全是两种意义,(%rax)代表rax寄存器中值所代表地址部分的内存,即相当于C++代码中的*p,而%rax代表rax寄存器,相当于C++代码中的p值,所以汇编这里使用了(%rax)而不是%rax。

我们再写出参数传递使用引用的C++代码段test2:

1
2
3
4
5
void test2(int& r)
{
r = 3; //赋值前无需判断reference是否为空
return;
}

这段代码对应的汇编代码如下:

1
2
3
4
5
6
7
8
pushq	%rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp)
movq -8(%rbp), %rax
movl $3, (%rax)
nop
popq %rbp
ret

我们发现test2对应的汇编代码和test1对应的汇编代码完全相同,这说明C++编译器在编译程序的时候将指针和引用编译成了完全一样的机器码。所以C++中的引用只是C++对指针操作的一个“语法糖”,在底层实现时C++编译器实现这两种操作的方法完全相同。

总结

C++中引入了引用操作,在对引用的使用加了更多限制条件的情况下,保证了引用使用的安全性和便捷性,还可以保持代码的优雅性。在适合的情况使用适合的操作,引用的使用可以一定程度避免“指针满天飞”的情况,对于提升程序鲁棒性也有一定的积极意义。最后,指针与引用底层实现都是一样的,不用担心两者的性能差距。