C++重温笔记(三): C++的引用竟然也有点深不可测

1. 写在前面

c++在线编译工具,可快速进行实验: https://www.dooccn.com/cpp/

这段时间打算重新把c++捡起来, 实习给我的一个体会就是算法工程师是去解决实际问题的,所以呢,不能被算法或者工程局限住,应时刻提高解决问题的能力,在这个过程中,我发现cpp很重要, 正好这段时间也在接触些c++开发相关的任务,所有想借这个机会把c++重新学习一遍。 在推荐领域, 目前我接触到的算法模型方面主要是基于Python, 而线上的服务全是c++(算法侧, 业务那边基本上用go),我们所谓的模型,也一般是训练好部署上线然后提供接口而已。所以现在也终于知道,为啥只单纯熟悉Python不太行了, cpp,才是yyds。

和python一样, 这个系列是重温,依然不会整理太基础性的东西,更像是查缺补漏, 不过,c++对我来说, 已经5年没有用过了, 这个缺很大, 也差不多相当重学了, 所以接下来的时间, 重温一遍啦 😉

资料参考主要是C语言中文网光城哥写的C++教程,然后再加自己的理解和编程实验作为辅助,加深印象。

这篇文章主要是c++的引用,这个C语言里面也是没有的, 正好是刚回到学校, 作为缓冲,就少整理点, 赶的刚刚好, 哈哈。

主要内容:

  • C++引用初识
  • C++引用的本质(和指针有什么区别?)
  • C++引用没法绑定到没法寻址数据(基本类型的临时数据,常量表达式等)
  • 编译器会为const引用创建临时变量(下面这俩操作背后原理差不多,但却是之前不知道的,神奇)
  • C++const引用与转换类型的奇妙之处

Ok, let’s go!

2. C++引用初识

函数调用时,对于参数的传递本质上是一次赋值的过程, 所谓赋值,就是对内存进行拷贝,而拷贝,就是将一块内存上的数据复制到另一块内存上。

对于基本类型(语言本身支持,like char, int, float)的数据,它们占用的内存往往只有几个字节,上面这个过程会很快, 而对于复杂类型(数组,结构体,类等由基本类型组合而成),对象是一系列数据的集合,数据数量无限制,这时候,对它们频繁拷贝可能消耗很多时间,效率变低。

所以C/C++禁止在函数调用的时候,直接传递数组的内容,而是强制传递数组指针, 但是呢? 对于结构体和对象并没有这种限制,可以是内容,也可以是指针,为了提高效率,在C语言里面,建议使用结构体指针(struct struct_name * var_name),而C++里面,可以用一种比指针更加便捷传递聚合类型数据的方式, 那就是引用。

引用是C++对C语言的一大扩充, 引用可以看做是数据的一个别名, 这个类似Windows的快捷方式,或者人的绰号。语法如下:

type &name = data;

type是被引用的数据的类型,name是引用的名称,data是被引用的数据。 引用必须在定义的时候同时初始化,并且以后也要从一而终,不能再引用其他数据,有点类似于常量

另外还要注意, 引用在定义的时候需要加&, 使用的时候不能加&, 使用时添加&表示取地址

看个例子:

#include <iostream>
using namespace std;
int main() {
    int a = 99;
    int &r = a;
    cout << a << ", " << r << endl;  // 99 99
    cout << &a << ", " << &r << endl;   // 0x7fff04542d94, 0x7fff04542d94

	r = 40;
	cout << a << "," << r << endl;   // 40 40
    return 0;
}

如果不希望通过引用修改原始数据, 可以在定义时加const限制

const type &name = value;

2.1 C++引用作为函数参数

定义或声明函数时,可以将函数的形参指定为引用的形式,这样在调用函数时就会将实参和形参绑定在一起,让它们指代同一份数据。 如果在函数体修改了形参的数据, 那么实参的数据也会被修改,达到"函数内部影响函数外部"的效果。

比如,在学习C++时候常用的那个经典案例:

#include <iostream>
using namespace std;
void swap1(int a, int b);
void swap2(int *p1, int *p2);
void swap3(int &r1, int &r2);
int main() {
    int num1 = 1, num2 = 2;
    swap1(num1, num2);
    cout << num1 << " " << num2 << endl;  // 1 2
    num1 = 1;
    num2 = 2;
    swap2(&num1, &num2);
    cout << num1 << " " << num2 << endl;  // 2 1
    num1 = 1;
    num2 = 2;
    swap3(num1, num2);
    cout << num1 << " " << num2 << endl;  // 2 1
    return 0;
}
//直接传递参数内容
void swap1(int a, int b) {
    int temp = a;
    a = b;
    b = temp;
}
//传递指针
void swap2(int *p1, int *p2) {
    int temp = *p1;
    *p1 = *p2;
    *p2 = temp;
}
//按引用传参
void swap3(int &r1, int &r2) {
    int temp = r1;
    r1 = r2;
    r2 = temp;
}

这三种交换的方式,后面两种可以真正达到交换的目的。

  • swap1()直接传递参数的内容,a,b是形参,它们有自己独立内存,作用范围仅限于函数内部变量
  • swap2()传递的是指针,调用的时候,是num1num2的地址传递给p1p2, 这样p1p2指向的就是a,b代表的数据,通过指针间接修改了a,b的值。
  • swap3()是引用传递,调用的时候,将r1和r2绑定到num1num2所指代的数据,这时候,r1num1r2num2就代表同一份数据了。

相比来看,第三种方式会更加直观。

2.2 C++引用作为函数返回值

引用除了可以作为函数形参,还可以作为函数返回值。

int &plus(int &r){
	r += 10;
	return r;
}

int main(){
	int num1 = 10;
	int num2 = plus(num1);
	
	cout << num1 << " " << num2 << endl;   // 20 20
}

上面要注意一个问题,就是返回的时候,不能返回局部数据(变量,对象,数组等),因为当函数调用完后局部数据会被销毁,有可能下次使用时数据就不存在。比如:

int &plus10(int &r) {
    int m = r + 10;
    return m;  //返回局部数据的引用
}

// 这个有的编译器编译不过去,有的即使编译过去了,如果用两次的话,第二次也会出问题。不要这样玩。

3. C++引用的本质

引用是对指针的简单封装,底层依然是通过指针实现,引用占用的内存和指针占用的内存长度一样,在32位环境下是4个字节,64位环境下8个字节,但由于编译器内部的转换缘故,没法获取引用本身的地址,但引用是会占用内存的。 下面的栗子:

int a = 99;
int &r = a;
r = 18;
cout<<&r<<endl

编译的时候,会被转换成下面的样子:

int a = 99;
int *r = &a;
*r = 18;
cout << r << endl;

使用&r取地址时,编译器会对代码进行隐式的转换,使得代码输出的是 r 的内容(a 的地址),而不是 r 的地址,这就是为什么获取不到引用变量的地址的原因。也就是说,不是变量 r 不占用内存,而是编译器不让获取它的地址。

引用的好处是让代码写的更加简洁, 比指针更加易用,但其背后还是指针。但这俩哥们之间有什么区别呢?

  1. 引用必须在定义的时候初始化,并且以后也要从一而终,不能指向其他数据; 而指针没有这个限制,指针定义时不必赋值, 以后也能改变指向。 做了个小实验:

    #include <iostream>
    using namespace std;
    
    int main() {
        int num1 = 10, num2 = 15;
        
        //int &a;
        //a = num1;   // error  'a' declared as reference but not initialized
        
        int &a = num1;
        cout << a << endl;
        
        // &a = num2;   //  lvalue required as left operand of assignment
        a = num2;
        cout << a << " " << num1 << endl;    // 15 15
        
        a = a - 10;
        cout << a << " " << num2 << " " << num1 << endl; // 5  15  5
        
        return 0;
    }
    
    // 可以发现, 一旦引用a指向了num1之后,就认定num1了。
    
  2. 可以用const指针,但是没有const引用, 不能这么玩

    int a = 20;
    int & const r = a;  //  'const' qualifiers cannot be applied to 'int&'
    

    因为r本来就不能改变指向,何必再加const?

  3. 指针可以有多级,但是引用只能有一级,比如int **p是合法的,但int &&r是不合法的。如果希望定义一个引用变量来指代另外一个引用变量,也只需要加一个&

    int a = 10;
    int &r = a;
    int &rr = r;
    
  4. 指针和引用的自增,自减运算意义不一样。对指针使用++表示指向下一份数据,而对引用使用++表示它所指代数据本身加1.

    int a = 10;
    int &r = a;
    r++;
    cout<<r<<endl;   // 11
       
    int arr[2] = { 27, 84 };
    int *p = arr;
    p++;
    cout<<*p<<endl;  // 84
    

4. C++引用不能绑定到没法寻址数据

4.1 临时数据

这个概念得先从指针的角度去看, 指针就是数据或代码在内存中的地址, 指针变量指向的就是内存中的数据或者代码。 注意这里的内存, 指针只能指向内存,不能指向寄存器或者硬盘。因为寄存器和硬盘没有办法寻址。

  • C++中大部分内容是放在内存中,比如定义的变量,定义的对象,字符串常量,函数形参,函数体本身,new或者malloc分配的内存等,所以这些内容都能&来获取地址。
  • But, 有一些数据,比如表达式的结果,函数的返回值等,它们可能在内存,可能在寄存器,一旦放入了寄存器,就没法用&获取地址,当然也就没法用指针去指向。

比如:

int n = 100, m = 200;
int *p1 = &(m + n);    //m + n 的结果为 300
int *p2 = &(n + 100);  //n + 100 的结果为 200
bool *p4 = &(m < n);   //m < n 的结果为 false

int func(){
    int n = 100;
    return n;
}
int *p = &(func());

上面这些表达式的结果以及函数返回值的结果都是放到寄存器中,所以尝试用&获取地址是错误的。

那么什么样的临时数据会放到寄存器里面呢? 盘点下:

寄存器离CPU近,速度比内存快,临时数据放到寄存器是为了加快程序运行,但寄存器不能放太大的数据,放一些小数据。 比如int, double, bool, char等基本类型的临时数据。 而对象,结构体变量自定义类型的数据, 大小不可预测,所以这两种类型的临时数据放到内存中。

看下面这个代码:

typedef struct{
	int a;
	int b;
} S;

// 搞一个运算符重载, + 实现结构体之间的加法
S operator + (const S &A, const S &B){
	S C;
	C.a = A.a + B.a;
	C.b = A.b + B.b;
	return C
}
// 定义一个函数
S func(){
	S a;
	a.a = 100;
	a.b = 200;
	return a;
}

// 主函数
S s1 = {23, 45};
S s2 = {90, 75};
S *p1 = &(s1 + s2);
S *p2 = &(func());
cout << p1 << " " << p2 << endl;

上面这个代码是没有问题的,也证明了结构体类型的临时数据被放到了内存里面。

4.2 常量表达式

不包含变量的表达式称为常量表达式,比如100, 200+34, 1*2等。

常量表达式由于不包含变量,无不稳定因素,所以在编译阶段就能求值。 编译器不会单独分配内存来存储常量表达式的值,而是将常量表达式和代码合并到一起,放到虚拟空间代码区。

  • 从汇编的角度,常量表达式的值是一个立即数,被"硬编码"到指令中,不能寻址。

所以,常量表达式的值虽然在内存,但没有办法寻址,也不能用&来获取地址,不能用指针指向*

// error   error: lvalue required as unary '&' operand
int *p1 = &(100);    
int *p2 = &(23 + 45 * 2);

4.3 引用也不能指代临时数据

引用的本质就是指针,所以无法寻址的临时数据, 引用同样也不能绑定,并且C++对引用要求更严格,在某些编译器下,连放在内存中的临时遍历数据都不能指代。

//下面的代码在GCC和Visual C++下都是错误的
int m = 100, n = 36;
int &r1 = m + n;
int &r2 = m + 28;
int &r3 = 12 * 3;
int &r4 = 50;
   
//下面的代码在GCC下是错误的,在Visual C++下是正确的
S s1 = {23, 45};
S s2 = {90, 75};
S &r6 = func_s();
S &r7 = s1 + s2;

当引用作为函数参数的时候,很容易给它传递临时数据, 下面的使用一定要注意:

bool isOdd(int &n){
    if(n%2 == 0){
        return false;
    }else{
        return true;
    }
}
int main(){
    int a = 100;
    isOdd(a);  //正确
    isOdd(a + 9);  //错误
    isOdd(27);  //错误
    isOdd(23 + 55);  //错误
    return 0;
}

判断是否是奇数的函数参数是引用类型,只能传递变量,不能是常量或者表达式。 其实更加标准的写法是值传递,而不用引用传递。

bool isOdd(int n){  //改为值传递
    if(n%2 == 0){
        return false;
    }else{
        return true;
    }
}

这样就没有问题了。

5. const引用创建临时变量

引用不能绑定到临时数据, 但是有一种情况是例外的,就是使用const关键字对引用加以限定,引用就可以绑定到临时数据了

#include <iostream>
using namespace std;

typedef struct{
    int a;
    int b;
} S;
int func_int(){
    int n = 100;
    return n;
}
S func_s(){
    S a;
    a.a = 100;
    a.b = 200;
    return a;
}
S operator+(const S &A, const S &B){
    S C;
    C.a = A.a + B.a;
    C.b = A.b + B.b;
    return C;
}
int main(){
    int m = 100, n = 36;
    const int &r1 = m + n;
    const int &r2 = m + 28;
    const int &r3 = 12 * 3;
    const int &r4 = 50;
    const int &r5 = func_int();
    S s1 = {23, 45};
    S s2 = {90, 75};
    const S &r6 = func_s();
    const S &r7 = s1 + s2;
    return 0;
}

还是这段代码,加了const之后, 编译就没有问题了。这是因为将常引用绑定到临时数据,编译器采取了一种妥协机制: 编译器会为临时数据创建一个新的,无名的临时变量,并将临时数据放入该临时变量中,然后再将引用绑定到该临时变量, 此时,其实也是引用绑定到了临时变量里面,临时变量是会被分配内存的。

那么,这时候,可能就会有一个疑问了,为啥,编译器对于常引用,就创建个临时变量,然后绑定, 而对于普通引用,就不创建临时变量呢?

  1. 首先, 引用绑定到一份数据上之后,是可以通过引用真实操作数据的,包括读取和写入,而写入,会改变数据的值。 临时数据往往无法寻址,是没法写入的,即使为临时数据创建临时变量,那么修改的时候,也是改的这个临时变量里面的值,影响不到原来的数据。 这时候,出现一种情况就是通过引用改变的还是临时变量的值,改变不了原来数据, 那么引用也就失去了意义。 所以为普通引用创建临时变量没有意义,编译器不会这么做。
  2. const引用和普通引用不一样,我们只能通过const引用读取数据的值,而不能修改它的值,不用考虑同步更新的问题,也不会产生不同数据,为const引用创建临时变量反而使得引用更加灵活通用。

简单的说,常引用不能修改数据,只能读,此时不会产生数据不一致性,而普通引用,如果建立了临时变量,那么通过引用只会修改到临时变量的值,而不会改到原值,此时会产生数据不一致性, 所以编译器考虑的很周到, 只有在必要时才会创建临时变量

bool isOdd(const int &n){  //改为常引用
    if(n/2 == 0){
        return false;
    }else{
        return true;
    }
}

int a = 100;
isOdd(a);  //正确
isOdd(a + 9);  //正确
isOdd(27);  //正确
isOdd(23 + 55);  //正确

这个代码就是正确的了。 所以对于之前的代码,两种改法:值传递和const 引用。

6. C++ const引用与转换类型

6.1 类型转换依然先从指针说起

不同类型数据占用内存数量不一样,处理方式不一样,指针的类型要与它指向的数据类型严格对应。 下面这些是错误的:

int n = 100;
int *p1 = &n;  //正确
float *p2 = &n;  //错误

char c = '@';
char *p3 = &c;  //正确
int *p4 = &c;  //错误

尽管int可以自动转换成floatchar可以自动转成int,但float *类型指针就是不能指向int, int * 类型的指针也不能指向char, 这个是合理的。因为浮点数和整数在虽然都是占4个字节的内存,但程序处理方式是不一样的。

  • 对于int, 程序把最高1位作为符号位,剩下的31位作为数值位
  • 对于float,程序把最高1位作为符号位, 最低23位作为尾数为,中间8位作为指数位

这时候如果类型转换出现截断的时候,往往就会出现问题,而这种问题,往往我们是不太容易发现。 比如下面这个:

int main(){
    int n = 100;
    float *p = (float*)&n;
    *p = 19.625;
    printf("%d\n", n);   // 1100808192
    return 0;
}

强行让float * 指向int型数据,此时会发现结果是上面一个奇怪的数,不应该是19吗? 就是因为上面的原因,才会导致这样的结果,所以编译器禁止这种指向。 我们也要遵循这样的规则,不要强转。

类比到引用,这个是一样的道理:

int n = 100;
int &r1 = n;  //正确
float &r2 = n;  //错误
char c = '@';
char &r3 = c;  //正确
int &r4 = c;  //错误 

6.2 加上const呢?

类型严格一致,对于普通引用,那必须遵守,但是加上const限定后,情况又发生变化, 编译器允许const引用绑定到类型不一致的数据

int n = 100;
int &r1 = n;  //正确
const float &r2 = n;  //正确

char c = '@';
char &r3 = c;  //正确
const int &r4 = c;  //正确 

why? 这背后依然是临时变量在发挥着作用。

当引用类型和数据类型不一致的时候,如果类型相近,且遵守类型的自动转换,此时编译器会创建一个临时变量,并将数据赋值给临时变量(这时候自动发生类型转换), 然后将const 引用绑定到临时变量。 这个和const引用绑定到临时数据采用方案是一样的。

注意,临时变量的类型和引用的类型是一样的,在将数据赋值给临时变量时会发生自动类型转换.

float f = 12.45;
const int &r = f;
printf("%d", r);   // 12

这个和我们想的就一样了, 这个其实,先为f创建一个int型的临时变量,这时候就是12, 然后再把int型的引用变量r给到int型的临时变量。但是当引用的类型和数据类型不遵守数据类型自动转换,编译器会报错。

char *str = "http://c.biancheng.net";
const int &r = str;   // error

小总:,给引用添加 const 限定后,不但可以将引用绑定到临时数据,还可以将引用绑定到类型相近的数据,这使得引用更加灵活和通用,它们背后的机制都是临时变量。

所以,我发现很多比较规范的代码都有这样的一个习惯: 引用类型的函数形参都一般加const修饰, 现在也差不多知道了原因。

当引用作为函数参数时,如果在函数体内部不会修改引用所绑定的数据,那么请尽量为该引用添加 const 限制。

下面的例子,演示了const引用的灵活性:

// volume() 函数用来求一个长方体的体积,它可以接收不同类型的实参,也可以接收常量或者表达式。
double volume(const double &len, const double &width, const double &hei){
    return len*width*2 + len*hei*2 + width*hei*2;
}
int main(){
    int a = 12, b = 3, c = 20;
    double v1 = volume(a, b, c);
    double v2 = volume(10, 20, 30);
    double v3 = volume(89.4, 32.7, 19);
    double v4 = volume(a+12.5, b+23.4, 16.78);
    double v5 = volume(a+b, a+c, b+c);
    printf("%lf, %lf, %lf, %lf, %lf\n", v1, v2, v3, v4, v5);
    return 0;
}

概括起来, 引用类型的形参加const限制的理由有3个:

  1. 使用const可以避免无意中修改数据的编程错误
  2. 使用const能让函数接收const和非const类型的实参,否则只能接收非const的实参
  3. 使用const引用能让函数正确生成并使用临时变量

在大的开发项目中,我也发现他们写函数形参的时候,也往往喜欢加const修饰,原来背后的原因是这啊。 学习到了哈哈。

这篇文章就整理到这里了,由于刚回到学校, c++这块先简单整理点作为缓冲,大部分内容是来自上面链接的第一篇文章,只摘取了我不知道的一些知识, 如果想系统学习,可以去上面链接里面去看啦。

接下来,利用10月份的这段时间,打算把c++的基础知识突击完毕啦,这个系列恢复更新, 其他系列,像推荐模型,西瓜书重温等,也同样恢复更新,不过,那俩由于是知识串联和白话解读,可能会慢很多,但也会坚持更新的频率,回学校就要开始好好学习,天天向上了 😉

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值