6. 引用(引用就是取别名)
6.1 引用的概念和定义
引用不是新定义一个变量,而是给已存在变量取了⼀个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。比如:水浒传中李逵,宋江叫"铁牛",江湖上人称"黑旋风";林冲,外号豹子头;
类型& 引用别名 = 引用对象;
C++中为了避免引入太多的运算符,会复用C语言的⼀些符号,比如前面的<< 和 >>,这里引用也和取地址使用了同⼀个符号&,大家注意使用方法角度区分就可以。
int a = 0;
// 引⽤: b 和 c 是 a 的别名
int& b = a;
int& c = a;
// 也可以给别名 b 取别名, d 相当于还是 a 的别名
int& d = b;
这串代码在底层的角度是这样的
对指针变量取别名:
int* p1 = &a;
int*& p2 = p1;
指针变量的使用:
typedef struct ListNode
{
int val;
struct ListNode* next;
}LTNode, *PNode;
int main()
{
PNode plist = NULL;
ListPushBack(plist, 1);
return 0;
}
这里的*PNode是指把typedef struct ListNode* 定义为PNode。
6.2 引用的特性
• 引用在定义时必须初始化
• ⼀个变量可以有多个引用
• 引用一旦引用⼀个实体,就不能引用其他实体
这里的d的地址没变,说明d还是a的别名,d没有指向e,只是把e的值赋给了d。
当我们实现链表的时候,我们要删除一个结点,但这时的地址之间是相互关联的,因为引用不能改变指向,所以就不可能完成。所以C++的指针引用不能完全替代指针。
6.3 引用的使用
• 引用在实践中主要是用于**引用传参和引用做返回值中减少拷贝提高效率和改变引用对象时同时改变被引用对象。**引用用作别名,没有额外开辟空间,可减少拷贝效率。
减少拷贝的案例:
• 引用传参跟指针传参功能是类似的,引用传参相对更方便一些。
• 引用返回值的场景相对比较复杂,我们在这里简单讲了一下场景,还有一些内容后续类和对象章节中会继续深入讲解。
• 引用和指针在实践中相辅相成,功能有重叠性,但是各有特点,互相不可替代。C++的引用跟其他语言的引用(如Java)是有很大的区别的,除了用法,最大的点,C++引用定义后不能改变指向,
Java的引用可以改变指向。
• ⼀些主要用C代码实现版本数据结构教材中,使用C++引用替代指针传参,目的是简化程序,避开复杂的指针,但是很多同学没学过引用,导致一头雾水。
传值传参会生成一个拷贝,传值返回也会生成一个拷贝。C++中是这样规定的,在红线这里进行一个传值返回,返回这个对象的时候, 他不会引用这个对象做函数调用的返回值,不会返回要返回的东西,它会生成一个临时对象,把这的值给临时变量,再用临时变量做这个整个表达式的返回值。STTop(st1)+=1;这是的加1就是加到临时对象上面。临时对象具有常性。
那临时对象是什么呢?
临时变量通常是指编译器在栈里面临时开一块空间存储中间值的这种,也有可能是用寄存器去存。
这里如何使用STTop(st1)+=1呢?只需要采用引用返回就可以了。传引用返回就是返回他的别名,也就是这里的2。也就a数组指向的top-1这里的对象。这时候就把引用对象给改变了。
typedef int STDataType;
typedef struct Stack
{
STDataType* a;
int top;
int capacity;
}ST;
void STInit(ST& rs, int n = 4)
{
rs.a = (STDataType*)malloc(n * sizeof(STDataType));
rs.top = 0;
rs.capacity = n;
}
// 栈顶
void STPush(ST& rs, STDataType x)
{
// 满了, 扩容
if (rs.top == rs.capacity)
{
printf("扩容\n");
int newcapacity = rs.capacity == 0 ? 4 : rs.capacity * 2;
STDataType* tmp = (STDataType*)realloc(rs.a, newcapacity *
sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
rs.a = tmp;
rs.capacity = newcapacity;
}
rs.a[rs.top] = x;
rs.top++;
}
int& STTop(ST& rs)
{
assert(rs.top > 0);
return rs.a[rs.top - 1];
}
int main()
{
ST st1;
STInit(st1);
STPush(st1, 1);
STPush(st1, 2);
cout << STTop(st1) << endl;
//修改栈顶的数据呢
(STTop(st1)) += 1;
cout << STTop(st1) << endl;
return 0;
}
其实指针也可以做到:
并不是任何场景都能用引用返回(后面结合类和对象讲),比如说:
ret是局部变量,这里类似于野引用。
int* fun( )
{
int ret = 10;
return *ret;
}
这里的指针就越界了,指针越界不一定报错。
在vs中设置了抽查位置,这两个位置不分配给别人,给两个固定的值,在程序运行结束时看这两个位置的值有没有发生改变,没有被修改就说明没有越界。
6.4 const引用
• 可以引用一个 const 对象,但是必须用 const 引用。const 引用也可以引用普通对象,因为对象的访问权限在引用过程中可以缩小,但是不能放大。(const对象只能用const引用,普通对象可以用const引用,也可以普通引用。引用、指针存在权限的放大和缩小。)
这里是把x的这块空间拷贝给给y,x不能修改这块空间。
下面这个也是经典的权限放大:
下图这个是权限的缩小:
p1对于a的权限是只读不能写的,p1拷贝给给p2,p2又变成可读可写的了,p2的权限被放大,p5指向b的权限是可读可写的,p6指向p5也是可读可写的(这里不存在权限放大,因为const修饰的是p5本身不是指向的内容)。
• 需要注意的是类似 int& rb = a*3; double d = 12.34; int& rd = d; 这样⼀些场景下 a*3的和结果保存在一个临时对象中, int& rd = d 也是类似,在类型转换中会产生临时对象存储中间值,也就是时,rb和rd引用的都是临时对象,而C++规定临时对象具有常性(所谓常性就像是被const修饰了一样),所以这里就触发了权限放大,必须要用常引用才可以。
• 所谓临时对象就是编译器需要一个空间暂存表达式的求值结果时临时创建的⼀个未命名的对象,
C++中把这个未命名对象叫做临时对象。
a * 3这里存在一个临时变量,临时变量具有常性,这里加个const就行了。
这里的d给给rd其实也不是直接给过去的,中间也会产生一个临时变量来存储中间的结果。d给给中间的临时对象,这个临时对象是int类型,临时对象再给给rd。
类型转换会产生临时对象,d给了临时对象,这里的临时对象给给rd。也就是说rd引用了临时对象。
那上面这些到底有什么用呢?
这里的传值传参就不说了,对于引用传参
void fun(int& rx)如果变量 rx 不改变形参,建议前面加 const,void fun(const int& rx),加const的好处是什么呢?传参就非常宽泛。
const引用的价值是什么?
1.可以引用const对象
2.可以引用普通对象
3.可以引用临时对象
6.5 指针和引用的关系
C++中指针和引用就像两个性格迥异的亲兄弟,指针是哥哥,引用是弟弟,在实践中他们相辅相成,功能有重叠性,但是各有自己的特点,互相不可替代。(下面的结论面试官可能会问)
• 语法概念上引用是⼀个变量的取别名不开空间,指针是存储⼀个变量地址,要开空间。
• 引用在定义时必须初始化,指针建议初始化,但是语法上不是必须的。
• 引用在初始化时引用一个对象后,就不能再引用其他对象;而指针可以在不断地改变指向对象。
• 引用可以直接访问指向对象,指针需要解引用才是访问指向对象。
• sizeof中含义不同,引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32位平台下占4个字节,64位下是8byte)
• 指针很容易出现空指针和野指针的问题,引用很少出现,引用使用起来相对更安全一些。
野引用的案例:
从底层汇编的角度看,引用也是用指针实现的。
7. inline
• 用 inline修饰的函数叫做内联函数(通常放到返回值的前面),编译时C++编译器会在调用的地方展开内联函数,这样调用内联函数就不需要建立栈帧了,就可以提高效率。
inline在这里的作用就是没有宏函数的坑,也不用建立栈帧,提效。
• inline对于编译器而言只是⼀个建议,也就是说,加了inline编译器也可以选择在调用的地方不展
开,不同编译器关于inline什么情况展开各不相同,因为C++标准没有规定这个。inline适用于频繁
调用的短小函数,对于递归函数,代码相对多⼀些的函数,加上inline也会被编译器忽略。
• C语言实现宏函数也会在预处理时替换展开,但是宏函数实现很复杂很容易出错的,且不方便调
试,C++设计了inline目的就是替代C的宏函数。
• vs编译器 debug版本下面默认是不展开inline的(release版本下默认是展开inline的),这样方便调试,debug版本想展开需要设置一下以下两个地方。
可执行程序是一个文件,它会以一个进程的角度来进行运行(可执行程序会生成一个进程),进程才会给它分配内存,进程会把可执行程序的那个指令加载到内存的里面。
10000*100是指10000行指令*100行Add的指令总和的指令,10000+100是指10000行指令+100行Add的指令总和的指令。内联展开会导致一个问题:代码膨胀,代码膨胀会导致可执行程序变大,可执行程序变大,加载到进程也会变大,加载到内存导致内存变大。(可执行程序就是安装包)
• inline不建议声明和定义分离到两个文件,分离(分离会找这个链接的地址)会导致链接错误。因为inline被展开,就没有函数地址,链接时会出现报错。链接就是声明的函数要找它的地址,去其它文件找它的地址。
// F.h
include <iostream>
using namespace std;
inline void f ( int i);
// F.cppinclude "F.h"
void f ( int i)
{
cout << i << endl;
}
// text.cppinclude "F.h"
int main ()
{
// 链接错误:⽆法解析的外部符号 "void __cdecl f(int)" (?f@@YAXH@Z)
f ( 10 );
return 0 ;
}
//那要怎么做呢?
把// F.cpp这个文件中的f(int i)给注释掉include "F.h"
//void f ( int i)
//{
//cout << i << endl;
//}
在// F.h中定义
inline void f ( int i)
{
cout << i << endl;
}
//实现⼀个Add**宏函数的常见问题
//#define Add(int a, int b) return a + b;
//#define Add(a, b) a + b;
//#define Add(a, b) (a + b)
// 正确的宏实现(宏本质是一种替换)
//宏函数坑很多,但是由于替换机制,调用函数时不用建立函数栈帧,能做到提效的作用。define Add(a, b) ((a) + (b))
// 为什么不能加分号 ?
int main()
{
int ret = Add(1,2);//这里是将a替换成1,b替换成2;//int ret = Add(1,2);;
//在这种场景下不会有问题
cout << Add(1,2) << endl;
//如果加分号在这种情况下就会报错,还有下面这种
if(Add(1,2))
{
//...
}
cout << ret << endl;
}
// 为什么要加外⾯的括号 ?
//有下面这种情况
int main()
{
int ret = Add(1,2);//这里是将a替换成1,b替换成2;//int ret = Add(1,2);;
cout << Add(1,2)*3 << endl;
//cout << (1) + (2) * 3 << endl;
}
// 为什么要加里面的括号 ?
int main()
{
int x =1,y = 2;
Add(x & y ,x | y); // -> (x & y + x | y)
}
8. nullptr
(C++里面空指针都用NULL)NULL实际是⼀个宏,在传统的C头文件(stddef.h)中,可以看到如下代码:
#ifndef NULL
#ifdef __cplusplus
#define NULL 0//C++
#else
#define NULL ((void *)0)//C语言
#endif
#endif
• C++中NULL可能被定义为字面常量0,或者C中被定义为无类型指针(void*)的常量。不论采取何种定义,在使用空值的指针时,都不可避免的会遇到⼀些麻烦,本想通过f(NULL)调用指针版本的
f(int*)函数,但是由于NULL被定义成0,调用了f(int x),因此与程序的初衷相悖。f((void*)NULL);
调用会报错。
下面的代码我们普遍认为f(0)调用f(int x),f(NULL)调用f(int *ptr),但实际调用的都是f(int x)。
• C++11中引入nullptr,nullptr是⼀个特殊的关键字,nullptr是⼀种特殊类型的字⾯量,它可以转换 成任意其他类型的指针类型。使用nullptr定义空指针可以避免类型转换的问题,因为nullptr只能被 隐式地转换为指针类型,而不能被转换为整数类型。