C++ | 引用变量

目录

导语:为什么要有引用变量

拿一个简单的例子来说,要交换两个变量的值,如何交换?注意这里我们说的是,交换的功能我们会封装成一个函数。
在 C 中我们都是这么调用的:

void Swap(int* a, int* b) {
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

int main() {
    int a = 5;
    int b = 3;
    Swap(&a, &b);
    return 0;
}

我们采用的是一种传址的方式,而不是传值。原因在于,调用的 Swap 中的 a 、 b 是形参,而不是实参。那有没有更简便的方法?毕竟,我们这里举的只是一个简单的例子,当涉及到的东西更多时(比如之前学的单链表不带头结点删除..传二级指针简直太复杂),在 C++ 中有一个新的复合类型——引用变量。

引用的概念

我们说的引用变量,实际上是给另一个变量取一个别名。例如,我们已经有了一个叫做 a ,将一个新的变量 b ,作为变量 a 的引用,相当于给 a 取了一个别名叫做 b 。如下图:
引用图解ab
一开始我们给这块空间取了一个名字叫做 a ,后来又给他取了个小名叫做 b,相当于之后,我们对 b 进行任何操作,也会对 a 进行修改。

用法示例

正如上面所举的例子, b 是 a 的引用变量,这就需要在创建 b 的时候,就告诉编译器这个信息,用 & 来声明引用变量:

int a;
int& b = a;

在这里 & 不是取地址操作符,而是类型标识符的一部分。正如 char* , * 也是类型标识符的一部分,表示一个指向 char 类型的指针变量,而我们的 int& 表示一个指向 int 类型的引用变量。
这里的 ab 指向相同的值和内存单元(我的是64位平台)。可以测试一下:

int main() {
    int a = 5;
    int& b = a;
    cout << &a << endl; // 取a的地址
    cout << &b << endl; // 取b的地址
    return 0;
}

输出:

0x7fff5fbff80c
0x7fff5fbff80c

可以看到,两个变量的地址是相同的。

在这里, a 变量的地址是在他定义之时随机分配的,但 b 不是,他是根据自己即将要引用的变量的地址来分配。

既然如此,在引用变量 b 定义之时,必须初始化,而不能先声明,再赋值。也就是必须定义时就指向另一个已经定义了的变量,否则,引用变量将无法得知自己的地址,也就是说 "int& b;" 这样的写法是错误的。

注意:
当引用变量在创建时进行初始化,一旦与某个变量关联起来,就会一起效忠于他。
即引用变量只能作为一个变量的引用,正如上面的 b ,当他成为 a 的引用之后,在他的生命周期里,就不能成为其他变量的引用

例如,当 b 成为 a 的引用之后,如果我们试图让他成为c 的引用,并不会成功,只会实现赋值的效果(连 a 的值也相对的改变):

int main(int argc, const char * argv[]) {
    int a = 5;
    int& b = a;

    int c = 20;
    b = c;
    cout << a << endl; // 输出a修改后的值
    cout << b << endl; // 输出b修改后的值
    return 0;
}

输出结果:

20
20

总结一下上面所说的:
引用变量必须在创建的时候初始化,一旦与某个变量关联起来,就只能是这个变量的引用。


高能预警,以下有些复杂,涉及到 临时变量、引用参数和 const ,看了好几遍 primer 上的讲解才看懂。

引用的属性与特别之处

我们不能将一个变量作为右值的引用。如下:

void test(int& ra) {
    // ...
}

int main(int argc, const char * argv[]) {
    int a = 5;
    test(a + 3);
    return 0;
}

这样编译器将会报错。因为 a + 3 并不是变量。但是早期的编译器会比较温和的发出警告,而不是直接报错,而现在的编译器,只有参数类型为 const引用 才能编译通过。

这是为什么呢,接下来我们将详细剖析其中的原理。

以前的编译器之所以允许将表达式传给引用变量是因为,由于 a + 3 并不是 int 类型的变量,于是程序将创建一个临时的无名变量,初始化为 a + 3 的值,然后将 ra 成为该无名变量的引用。

那什么时候才会生成临时变量呢?有以下两种情况。

1.实参类型正确,但不是左值
2.实参类型不正确,但可以转换为正确的类型

第一种情况:非左值会产生临时变量,哪些是非左值呢?也就是常量或包含多项的表达式。
而左值就是我们可以赋值的对象。但是,在引入 const 关键字之后,常规变量const 变量 都可视为左值,因为可通过地址访问他们。

常规变量视为可修改的左值,const变量视为不可修改的左值。

第二种情况则是类型不匹配。

回到之前的一个例子:

void test(int& ra) {
    ++ra;
}

int main(int argc, const char * argv[]) {
    int a = 5;
    double b = 4;
    
    test(a + 3);
    test(b);
    return 0;
}

上面这个例子,a + 3 作为参数属于第一种情况(不是左值), b 属于第二种情况(类型不正确),这在以前的编译器,可以通过,是因为这时编译器产生了临时变量,分别让临时变量等于 a + 3 和 b 的值,而 ra 成为他们的引用,这对于原本的 a + 3 和 b 不并不会产生影响,所以他们的值不变。
换言之,从前的编译器采取了一种机制——创建临时变量,使得原本的变量的值不会改变。因此,如果我们现在采取一种类似的机制,也能使得编译通过:使用 const 。

我们将代码修改为:

void test(const int& ra);

这样也会使得编译通过。在这样的函数中,我们的目的只是使用传递的值,而不是修改他们。后面还会具体阐述这样使用 const 与引用搭配的好处。

这就是为什么以前的编译器采用创建临时变量,现在采用加 const 修饰都可以编译通过的原因。

主要作用

1.引用作参数

引用变量的主要作用体现在函数传参。以往我们已经有了两种传参方式:按值传参、按址传参(指针)。现在有了第三种方法:按引用传参。

用法

还是之前交换a、b的例子,现在有了一种新的写法:

void Swap(int& x, int& y) {
    int tmp = x;
    x = y;
    y = tmp;
}

int main(int argc, const char * argv[]) {
    int a = 5;
    int b = 3;
    Swap(a, b);
    return 0;
}

但是有的时候,我们又不希望传入的值会改变,例如以下例子:

int cube(int& ra) {
    ra *= ra * ra;
    return ra;
}

int main() {
    int a = 3;
    int ret = cube(a);
    cout << "cube of " << a << " = "<< ret << endl;
    return 0;
}

结果:cube of 27 = 27

我们想要求 a 的立方,并且之后还要用到 a ,这时候并不希望改变 a ,而这时候 a 却被改变了。为了解决这个问题,当我们希望不改变原值时,尽量采用 const 来修饰:

int cube(const int& ra);

这时候我们若不小心改变了 a 的值,编译器便会报错。

但是我相信您肯定要问了,传参时采用引用变量作为参数的原因就是,这样可以很方便的修改原值,这时候又说不改变原值,这不是自相矛盾么?

引用传参的另一个好处

这其实是因为,引用变量作为参数还有另外一个好处,便是省时间、省空间。大家都知道,我们平时传参时,都需要将原来的变量拷贝一份至一个临时变量,再将这个临时变量作为形参传入函数,但现在不需要了,因为从始至终,都是原来的那个变量。
别看一个小小的原生类型占不了多少空间,复制一份也用不了多少时间,但是当我们传的是一个自定义变量,一个十分巨大的结构体或类的时候呢?,我们不需要去占用空间拷贝,这省了很多时间与空间。
但同时也要注意,如果这时候您不希望改变原值,记得加上 const 来修饰。

小结

  1. 在您打算修改原变量的值时,尽量使用引用作参数,省时间省空间,更重要的是比采取指针传参的方式简单很多;
  2. 在您并不打算改变变量的值,但是写了一个函数,要使用到您变量的值的时候,可以采用 const + 引用 的方式,表面上看和从前两者都不用的时候效果是一样的,但实际上省了很多的时间与空间(在传参较大时)。

2.引用作返回值

为何要返回引用

来看一个例子:

double m = sqrt(16.0);

在这个例子中, sqrt 函数的返回值为 4.0 ,但若是让函数中的临时变量的值直接赋给 m 是不行的,因为 sqrt 函数结束,里面变量的生存周期已结束,再将其值赋给 m 可能会出错。总之,4.0 先被复制到了一个临时位置(实际上在寄存器,出了函数作用于也存在),**然后再复制给了 m **,和之前传参时情形类似。

不懂的可以再看下面:
临时变量
例如上面这个程序里,c 是一个临时变量,而 Add 函数的返回值并不是 c ,因为出了这个函数, c 就相当于不在了,所以他会先将 c 的值复制给另一个临时变量(如果较小是寄存器,较大则是提前开辟好的一块空间),这个临时变量的生存周期比较长,能将其值复制给 ret 变量。

这样就存在一个效率问题,也就是多复制了一步。别看这里只是32位平台上八个字节的 double 变量而已,但如果是一个极大的结构体,就会浪费很多时间和空间。但是返回引用变量能很好地解决这个问题。

例如以下例子
引用
a 自增以后再返回其值,利用引用,这时候并不需要考虑生命周期的问题,因为来来回回都是在堆那一块空间进行修改,一直在 a / ra 的作用域内,省去了再复制一步的时间和空间。

返回引用时要注意的问题

引用变量作返回值并不是任何时候都那么好用的:
下面是一个错误的例子:

int& Add(int a, int b) {
    int c = a + b;
    return c;
}

int main() {
    int& ret = Add(1, 2);
    Add(10, 20);
    cout << ret << endl;
    return 0;
}

输出结果:30

我们之前提到了,当函数返回一个临时变量 c ,这时候会在创建一个一般在寄存器的临时变量,我们给这个变量取名为 k ,由于返回的类型是引用,这时候 k 成为了 c 的引用,而接受的类型也是引用,即 ret 是 k 的引用。当我们再调用 Add 时,k 内存放的是新的 c 的值,也就是 30,因此 ret 也跟着变为了 30 。

也就是说,引用作返回值,也是有前提的:引用的变量需要出了这个函数作用域还在,否则可能会出错。而前面 my_func 的例子可以用的原因是:先传入了一个引用类型的参数,再返回这个变量。从始至终,实参、形参、与返回值,都是同一个变量,这个变量一直都在。

同时这个时候,我们返回的是一个作为参数传递给函数的引用,正是因为如此,在这过程中这个参数可能会改变。当没有这样的参数传入的时候,我们可以开辟一块存储空间(c++ 用 new, 记得搭配 delete),在堆上就不会被自动分配与销毁了,或者返回一个静态/全局变量。

小结

  1. 引用作返回值,不能返回一个临时变量的引用,需要变量在这个函数结束后还在,例如静态或全局变量
  2. 如果可以的话,我们尽量用引用作返回值,因为更省时间和空间

何时使用引用参数

当然,以下只是建议,而非必须如此。

1. 传递值而不修改值(尽量 const 修饰)

 1、内置数据类型:由于较小,可直接按值传递;
 2、数组:采用 const 修饰的指针;
 3、较大的结构:使用 const 指针或 const 引用,可提高效率、节省时间空间;
 4、类对象:const 引用。

2. 需要修改原数据

 1、内置数据类型:可使用指针;
 2、数组:只能使用指针;
 3、较大的结构:使用指针或引用;
 4、类对象:const 引用。

实现方法(汇编层来看)

从汇编层来看,引用的最终实现方法也是借用了指针(先取了地址,再解引用)。这和我们的指针的实现机制是一样的。但从语法层面来讲,引用比指针更省空间。

引用与指针的区别:

  1. 引用必须初始化,并且从一而终,但指针可以修改指向;
  2. 引用必须指向有效变量,指针可以指向空;
  3. 指针代表着地址,而引用代表着变量。sizeof 指针,是指针的大小,sizeof 变量,是变量的大小;
  4. 引用与指针自增自减的意义不同。
  5. 引用比指针更安全,没有野指针的危险,但同时指针也比引用灵活。

当然这肯定没把引用写完啦,但是目前就涉及了这么多,看完后面的内容再来补充啦。

哈哈哈哈哈哈