1.引用
1.1 引用的概念
引用并不是定义一个新的变量,而是给已经存在的变量起的一个别名。从语言的层面上,编译器并不会为了引用而去开辟新的内存空间。引用和被它引用的变量是共用一块内存空间的。举个生活中引用的例子,西游记中,孙悟空,他的别名有很多。像齐天大圣、弼马温、美猴王等。下面通过一个简单的代码看看什么是引用。
#include<iostream>
using namespace std;
int main()
{
int a = 10;
int& ra = a;
cout << &a << endl;
cout << &ra << endl;
return 0;
}
运行上一段代码可以看见,引用的变量和被引用的变量是共享同一块空间的。那么对ra++是否影响a呢?答案是会影响的。
#include<iostream>
using namespace std;
int main()
{
int a = 10;
int& ra = a;
ra++;
cout << a << endl;
return 0;
}
需要注意的是引用的类型和被引用的类型需要保持一致,这里简单举一个样例。
int main()
{
double d = 1.23;
int& ri = d;//这样引用是错误的
return 0;
}
1.2 引用的特性
1、引用在定义时,必须初始化。
2、一个变量可以有多个引用。
3、一个引用一旦引用了一个实体就不能再继续引用其他实体。
引用在定义时,必须初始化。否则编译器也不知道你具体是谁的别名。
int main()
{
double a = 10;
int& ri;//这样引用是错误的
return 0;
}
int main()
{
int a = 5;
int& b = a;
int& c = b;
int& d = a;
cout << a << endl;
cout << b << endl;
cout << c << endl;
cout << d << endl;
return 0;
}
这里的c虽然是b的别名,但它的本质还是a的别名。
int main()
{
int a = 5;
int& b = a;
int& c = b;
int& d = a;
int x = 10;
d = x;//赋值操作,而非引用操作
return 0;
}
上面的代码中,在前面的基础上多定义了一个变量x,d = x这句代码的意思是将x的值赋给引用变量d,这里不会改变d的指向,d依旧是变量a的别名。所以,这句话后a,b,c,d变量的值都变成10。这也说明了C++中一个引用变量只能引用一个实体。
1.3 引用的使用场景
1.3.1 引用做参数
在之前的C语言学习中,当需要写一个交换两个局部变量值的函数时,通常需要一个两个变量类型的指针变量来做参数。当学习引用之后,便可以感受引用做参数带来的好处。
//指针做参数
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//引用做参数
void Swap(int& x, int& y)
{
int tmp = x;
x = y;
y = tmp;v
}
int main()
{
int a = 10,b = 20;
int x = 3, y = 5;
Swap(&a,&b);
Swap(x,y);
std::cout << a << " " << b << std::endl;
std::cout << x << " " << y << std::endl;
return 0;
}
通过两种方式实现Swap函数的比较可以发现,使用引用做函数的参数和使用指针做函数参数相比,使用引用做函数参数在函数调用和函数的实现都比较方便。因为,在调用时不必传变量的地址,在实现时不再需要解引用操作。所以,引用通常可以用做输出型的参数。引用做参数还可以减少传参时的拷贝从而提升效率。下面我通过举例和原理来进行说明。
#include<iostream>
#include <time.h>
using namespace std;
struct A { int a[100000]; };
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;
}
int main()
{
TestRefAndValue();
return 0;
}
从上述样例中可以看到,当传参为大对象(即sizeof值较大的对象)时,在函数栈帧开辟时,传结构体拷贝实参的性能损耗较大。而传引用,传的是结构体的别名,拷贝的损耗几乎可以忽略。所以,引用做参数传参的效率是更高的。
1.3.2 引用做返回值
在讲引用做返回值之前,我先简单普及一个概念。就是函数的返回值是怎么带回的。
引用做函数返回值就相当于上图中的返回值n是调用处ret的别名。
int& Add(int x, int y)
{
int n = x + y;
return n;
}
int main()
{
int& ret = Add(1, 2);
cout << ret << endl;
return 0;
}
但是,这段代码是错误的,因为局部变量n随时函数栈帧的销毁,所属空间使用权归还给操作系统。这里获取到的数是3是恰好的,因为Add函数的栈帧没有被破坏。如果加上一次函数调用,那么原属于Add函数的栈帧会用于调用其它函数,从而导致引用返回的变量被覆盖。
总结
1、任何场景下都可以使用引用做参数。
2、慎用引用做返回值,如果变量出了局部作用域就销毁,使用引用做返回值就有可能会产生不可预知的错误。尽量使用存储在静态区、全局空间、堆区等等出了局部作用域不销毁的变量上做引用返回。
1.4 常引用
常引用就是对引用的变量前加上const修饰,使得引用具有常数性。
1.4.1 常引用的概念
int main()
{
int a = 10;
const int& ra = a;
//ra++;//常属性变量不能被修改
a++;
//由于ra是a的别名,a的改变会影响ra
cout << ra << endl;
return 0;
}
1.4.2 引用的权限放大问题
样例
int Func()
{
int n = 10;
return n;
}
int main()
{
int& ret = Func();
return 0;
}
前面我们说到,函数调用结束后,返回值会存在一个临时空间里,然后带回给调用处。这里由于临时变量是具有常属性的。所以不能直接赋给ret,因为涉及引用权限放大问题。此时const修饰一下引用,使引用也具有常属性就可以。
int Func()
{
int n = 10;
return n;
}
int main()
{
const int& ret = Func();
return 0;
}
1.4.3 引用的权限平移和缩小
int main()
{
//权限的平移
const int a = 10;
const int&ra = a;
int b = 20;
int& rb = b;
//权限的缩小
int* c = NULL;
const int*& rc = c;
return 0;
}
1.5 引用和指针的区别
1、定义引用是不需要额外的开辟空间,而定义指针变量是需要额外开辟空间来存储的。
2、引用必须初始化,指针可以不初始化。
3、引用在初始化指向一个实体后,便不能在更改指向。而指针变量指向一个实体后,可以继续更改自己的指向。
4、没有空引用,但是有空指针。
5、 在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32位平台下占4个字节,64位平台下占8个字节)。
6、引用自加1,表示引用的实体对象的值加1。指针变量自加1,表示指针变量向后偏移一个自身类型大小的距离。
7、没有多级引用的概念,但是指针是有分多级,如一级指针变量的地址就得用二级指针变量存储。
8、访问实体的方式不同,引用由编译器来处理,指针需要解引用操作。
9、引用和指针比起来,使用起来更加安全。
1.5.1 引用的汇编指令实现
通过调试可以看到,VS2019编译器下,引用和指针在汇编指令实现的方式是类似的,都是将实体的值通过保存在寄存器上,再拷贝给编译器开辟的特定空间存储。所以,引用在汇编指令的层面上的实现其实是会开辟内存的。但是,在学习引用这个概念的时候还是要从C++的语法层面看待这一现象,即引用是不开辟空间的,这样有助于学习和理解。、
2.auto关键字
auto关键字由c++11标准引入。是一个根据右表达式的类型来推导左表达式类型的关键字。auto在实际中最常见的优势用法就是跟C++11提供的新式for循环,还有lambda表达式等进行配合使用。
2.1 auto关键字的概念
int main()
{
int a = 10;
int b = a;
auto c = a;
auto d = 1 + 1.11;
cout << typeid(c).name() << endl;
cout << typeid(d).name() << endl;
return 0;
}
需要注意的是使用auto关键字必须初始化。如果只是声明,编译器不能推导出他具体的类型。所以auto并不是一种类型的声明,而是类型的‘占位符’。
2.2 auto 与指针和引用
auto自动推导指针类型时,auto后是否带(*)指针标识符都是可以的。但是,auto自动推导为引用时,auto后必须带(&)引用标识符。
int main()
{
int a = 10;
auto* b = &a;//ok
auto& c = a;//ok
auto* d = 10;//error
auto e = &a;//ok
return 0;
}
2.3 auto不能推导的场景
2.3.1 auto做函数形式参数
因为编译器无法对auto作为形式参数的类型进行推导。
void test(auto a)
{
\\...
}
2.3.2 auto不能定义数组
void TestAuto()
{
int a[] = {1,2,3};
auto b[] = {4,5,6};
}
3.基于范围的for循环
在C++11之前,遍历一个数组可以用以下方式
int main()
{
int array[] = { 1, 2, 3, 4, 5 };
for (int i = 0; i < sizeof(array) / sizeof(array[0]); ++i)
{
array[i] *= 2;
}
for (int* p = array; p < array + sizeof(array) / sizeof(array[0]); ++p)
{
cout << *p << endl;
}
return 0;
}
也许是觉得这个语法太麻烦了,c++11给出了一个甜点语法即(范围for)。
int main()
{
int array[] = { 1, 2, 3, 4, 5 };
for (auto& e : array)
{
e++;
}
for (auto e : array)
{
cout<<e<<" ";
}
cout << endl;
return 0;
}
当你使用了这种for循环遍历数组,你还会想用C语言的方式来写吗?当然是不会的。不过这种用法下,范围for的迭代条件必须是明确的。对于数组而言,范围for的遍历范围就是从第一个元素到最后一个元素。对于类而言,应该提供begin和end的
方法,begin和end就是for循环迭代的范围。
//错误样例
void test(int arr[])
{
for(auto& e : arr)
{
cout<< e<<endl;
}
}
范围for的范围不确定所以会报错。最后就是迭代的对象要实现++和==的操作。这个由于我现在还没有学习这一块的知识,只能等以后学了才知道。
4.nullptr关键字(C++11)
在c/c++编程的学习过程中,我们不免得要和指针打交道。我们需要养成良好指针使用习惯。那就是初始化指针变量。初始化指针变量的好处可以让指针变量的指向更安全。
int main()
{
int *pa =NULL;
int* pb = 0;
return 0;
}
而在传统的C语言头文件(stddef.h)中对于NULL这个宏是这样定义的
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
可以看到当以cpp格式编译时,NULL就会被展开成0值,这其实是有些许缺陷的。当然,从语言发展的角度来看,这么做也许是祖师爷的迫于无奈。在C++11标准定义中,nullptr关键字引入,这样也就方便了我们去初始化指针。当然sizeof(nullptr)的大小和sizeof((void*)0)的大小是一样的。为了提高程序的健壮性,推荐使用nullptr关键字。