文章目录
1、引用
1、概念
上一篇比较久远的C++笔记末尾提到了引用,这里重新写一下。C语言代码编写中,指针是个很重要的概念,同样也有点复杂。指针有一,二,三级等指针,在初阶数据结构中,比如链表,最一开始为了改变链表结构,我们需要用二级指针来接收。像这种问题,我们很容易搞错。但指针对于代码的编写很有帮助,许多功能也要靠它来完成。所以C++为了解决指针的问题,出现了引用的概念。
引用如果简单来说是一个起别名的作用,不过究其本质,它还是用指针来实现的。创建一个变量a后,引用另一个变量名b,仍然指向a,如果改变了b的值,a的值也改变了,相当于一个标签。
#include <iostream>
using namespace std;
int main()
{
int a = 1;
int& b = a;
int& c = a;
cout << &a << endl;
cout << &b << endl;
cout << &c << endl;
}
地址完全一样,说明指向的都是同一个地址。使用场景上,除了像这样
也有在数据结构上,代替指针的。
现在我们继续看其他的使用场景来理解引用。
2、引用特性和使用场景
1、返回值
先看一段代码
int Count()
{
int n = 0;
n++;
return n;
}
int main()
{
int ret = Count();
return 0;
}
以及
int Count()
{
static int n = 0;
n++;
return n;
}
n是如何返回的?main函数有main的栈帧,在执行Count函数前,main栈帧里会创建一个ret变量。执行Count函数后,系统也开辟了Count栈帧,里面有一个n变量。n++后,n为1,那么此时n就可以返回了?当n++后,Count函数已经调用完成了,函数栈帧销毁,此时n在之前Count栈帧这块空间里,但是这时候已经销毁了,栈帧销毁也就意味着这块空间已经还给了系统,交给系统自由支配,那么这时候n就无法保证还是之前的值了。系统为了能够得到n的值,会把n的值给一个临时变量,ret去找这个临时变量要值即可。这个临时变量一般在寄存器中运行,在调用Count函数之前,就在main的栈帧里创建这个临时变量。
加上static后,n变量是在静态区的,这个和Count栈帧没关系,所以不会存在上述问题,但是计算机懒得判断,虽然是静态区,但还是先给一个临时变量,再由临时变量把值返回过去。
所以这样是不是太傻瓜了,有点多此一举。明明在静态区,干嘛还要再创建一个变量?在之前函数传参的时候也可以用引用,用引用是否可以解决这个问题?
int& Count()
{
static int n = 0;
n++;
return n;
}
int main()
{
int ret = Count();
cout << ret << endl;
return 0;
}
当用引用来返回值的时候,程序返回的就是一个引用类型的变量,这个引用是n的引用。所以当函数结束的时候,系统就会通过传过来的n的引用来得到n的值,而不需要创建临时变量,这样的函数传返回值是传引用调用,这里也体现引用的一个特性
减少拷贝
看下一段代码
#define N 10
typedef struct Array
{
int a[N];
int size;
}AY;
int& At(AY& ay, int i)
{
assert(i < N);
return ay.a[i];
}
int main()
{
AY ay;
for (int i = 0; i < N; i++)
{
At(ay, i) = i * 4;
}
for (int i = 0; i < N; i++)
{
cout << At(ay, i) << " ";
}
cout << endl;
return 0;
}
返回的是ay.a[i]的引用,我们可以直接对它进行赋值。这也就是引用的第二个特性
可修改返回对象
在前面写的函数结束,销毁栈帧中可以知道n此时已经归系统管理了,我们无法控制这个n,而后面两个引用的例子中,静态区是一个单独的区域,不受影响,main里的局部变量则更不受影响,只要我们不删除代码。所以引用的条件就是
引用的对象必须是操作者可以控制的对象
如果不是,那么此时它的值可就不确定了。不确定是一个问题,但主要的错误就是我们没有权限访问这个变量。栈帧销毁,空间已经还给系统,变量确实还在那个空间里,我们貌似也确实可以给它赋值,操作它,但这就形成了非法访问。非法访问编译器不一定会报错,但其不确定性很影响代码的实现。所以如果用到引用,就要先观察变量的走向。
关于之前不能使用引用的例子,我们再看一段错误代码
int& Add(int a, int b)
{
int c = a + b;
return c;
}
int main()
{
int& ret = Add(1, 2);
Add(3, 4);
cout << "Add(1, 2) is :" << ret << endl;
cout << "Add(1, 2) is :" << ret << endl;
return 0;
}
int& ret = Add(1, 2);
这行代码,程序调用了Add函数,Add栈帧空间里有个c变量,数值为3,当函数结束时,空间还给系统,c也是,这时候c完全由系统管理,我们也不清楚系统做了什么。函数结束的时候给我们传回了c的引用,然后用一个引用类型的ret变量来接收,也就是说返回了c的别名,ret又是c的别名,所以c是多少,ret就是多少。再次调用Add,c应当为7,然后还是一样,交由系统全权管理,我们还是不知道系统会干什么,所以下面两个输出不同人的电脑会呈现不同的结果,这取决于系统,比如我的就是随机值
当出了函数的这个作用域,对于main函数内部来说,这就是另一个空间的变量,相当于我们创建了两个函数,都有各自的变量,只不过这里是引用,我们可以访问到它,但是结果是未定义的,编译器会报错未定义。这就是错误使用引用的例子。
2、传值、传引用效率对比
这个其实没什么可写的,结果就是引用更快,不过也就是相差几毫秒,可以用下面两段代码进行测试。
#include <time.h>
struct A { int a[10000]; };
void TestFunc1(A a) {}
void TestFunc2(A& a) {}
void TestRefAndValue()
{
A a;
// 以值作为函数参数
size_t begin1 = clock();
for (size_t i = 0; i < 10000; ++i)
TestFunc1(a);
size_t end1 = clock();
// 以引用作为函数参数
size_t begin2 = clock();
for (size_t i = 0; i < 10000; ++i)
TestFunc2(a);
size_t end2 = clock();
// 分别计算两个函数运行结束后的时间
cout << "TestFunc1(A)-time:" << end1 - begin1 << endl;
cout << "TestFunc2(A&)-time:" << end2 - begin2 << endl;
}
#include <time.h>
struct A{ int a[10000]; };
A a;
// 值返回
A TestFunc1() { return a;}
// 引用返回
A& TestFunc2(){ return a;}
void TestReturnByRefOrValue()
{
// 以值作为函数的返回值类型
size_t begin1 = clock();
for (size_t i = 0; i < 100000; ++i)
TestFunc1();
size_t end1 = clock();
// 以引用作为函数的返回值类型
size_t begin2 = clock();
for (size_t i = 0; i < 100000; ++i)
TestFunc2();
size_t end2 = clock();
// 计算两个函数运算完成之后的时间
cout << "TestFunc1 time:" << end1 - begin1 << endl;
cout << "TestFunc2 time:" << end2 - begin2 << endl;
}
3、引用和指针的区别
1、权限可小不可大
int main()
{
int a = 1;
int& b = a;
const int c = 2;
int& d = c;
}
这段代码中,c那里会出错。
引用和指针都有一个特性,赋值或者初始化的时候,权限可以缩小,但不可以放大。
c已经被限制,不能更改,但是d却没有限制,这就是权限放大。指针也一样
const int* a = NULL;
int* b = a;
也不能给到b。想解决这个问题那么d和b前面就要加上const,这叫做权限保持。如果c是正常,d是受const限制,那么没有问题,因为这是权限缩小。
int Count()
{
int n = 0;
n++;
return n;
}
还是这个Count函数,main函数里这样写
int& ret = Count();
无法编译通过。返回的不是n,是临时变量,这个变量其实是有常属性的,不能被改变,所以int&之前加一个const就行。另外的解决办法就是让Count函数变成int&类型。
下一个代码
int i = 0;
double& j = i;
编不过,但是前面加上一个const就好了。这是因为类型转换的时候会产生临时变量。强制类型转换也是如此,强制转换的变量还是那个类型,但是打印出来的只是临时变量。不管是显式还是隐式,转换时产生的都是临时变量,原变量没有变。
2、关于引用空间的简单理解
引用在语法上来讲,是不开空间的,和其引用实体共用一块空间,而指针则是开空间的。
如果以底层实现来讲,引用是按照指针形式实现的。
int main()
{
int a = 10;
int& b = a;
b = 20;
int* pa = &a;
*pa = 20;
return 0;
}
同样操作的指针和引用,我们查看一下反汇编。
仔细看,引用和指针在底层实现的代码是一样的。int& b = a那里,a的值给了rax,下一行取出b的地址,把rax的值放进去;b = 20,编译器会把b的地址给到rax,下一行解引用rax,把20这个值的16进制形式给rax。下面也一样,所以这样简单地查看后会发现,引用也是按照指针形式做的,只是语法上我们可以看作是不开空间,起别名。
3、其他区别
引用在定义时必须初始化,指针无要求
引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体
没有NULL引用,但有NULL指针
在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32 4, 64 8)
引用自加,即引用的实体加1,指针自加即指针向后偏移一个类型的大小
有多级指针,但没有多级引用
访问实体方式不同,指针需要显式解引用,引用编译器自己处理
引用比指针使用起来更安全
2、内联
1、内联与宏的对比
C语言中,有宏这个概念。
在C++中,一般用const和enum替代宏常量,inline去替代宏函数。
我们先看宏,宏有一些缺点,不能调试,没有类型安全的检查,有些场景下实现起来也非常复杂。所以C++要解决这个问题。
宏函数是用宏写函数,C++用inline来替代它。宏函数是不开栈帧的,它是直接替换,所以也不能调试。C++则这样写
inline int Add(int x, int y)
{
int z = x + y;
return z;
}
int main()
{
int ret = Add(1, 2);
cout << ret << endl;
return 0;
}
这个就是内联。内联是在使用函数的时候编译器自己优化一下代码,然后在使用处展开,但不会有栈帧,可以调试。这就避开了宏的缺点。在release模式下可查看内联的反汇编。
但是显而易见,内联有缺点。内联是一种空间换时间的做法,在某处展开代码,如果有多处使用函数的地方,那么代码量就会增加很多,编译出来的可执行程序就会变大。
这里借用《C++ primer》的一段话
内联说明只是向编译器发出的一个请求,编译器可以选择忽略这个请求
一般来说,内联机制用于优化规模较小、流程直接、频繁调用的函数。很多编译器都不支持内联递归函数,而且一个很多行的函数也不大可能在调用点内联地展开
2、声明和定义分离
内联不建议声明和定义分离,会导致链接错误。
func.h
#include <iostream>
using namespace std;
inline void f(int i);
func.cpp
#include "func.h"
void f(int i)
{
cout << i << endl;
}
test.cpp
#include "func.h"
int main()
{
f(1);
return 0;
}
结果就会这样报错
如果不加inline,是一个很正常的程序。程序运行的时候,main函数里调用了f这个函数,在反汇编中就是一个call指令,后面跟一个地址,通过这个地址可以跳到一个jmp指令,而函数的地址也就是jmp指令的地址,jmp前面括号里的地址,找到这个地址,也就找到了函数。
但是内联函数不需要调用地址等等,内联只是要直接展开,不开辟栈帧,所以在上面程序中,我们调用的时候就只会展开一个声明,找不到定义,而且在程序汇编完后进入链接阶段后,内联函数不产生地址,所以链接无法通过。
下一篇继续写C++入门的知识。
结束。