1.引用的基本概念与用法
引用是一个重要的概念,它提供了一种方式,通过它可以让两个不同的标识符(变量名、参数名等)引用同一个数据对象
在本质上,引用就像是数据对象的一个别名。使用引用时,对引用的任何操作都会直接反映到被引用的对象上。它允许程序员在不使用指针的情况下通过不同的名称访问同一数据块
void TestRef() { int a = 10; int& b = a; cout << &a << endl; cout << &b << endl; return 0; }
在变量类型前使用&来声明一个引用类型
在这个示例中,b是a的引用int& b = a;,我们对b修改,a的值也会改变,当打印a和b的地址时,会看到它们的地址是相同的
b就是a的别名
1.1引用特性
引用必须被初始化
在C++中,声明引用时必须同时进行初始化。这表明,引用一旦被创建,就必须立即指向一个已存在的变量。你不能像指针那样先声明一个引用,然后再让它指向一个变量
int x = 5;
int &b = x; // 正确,b被初始化为x的引用
int &c; // 错误,引用必须在声明时被初始化
引用本质上是所引用变量的别名
一旦引用被初始化为某个变量的引用,它就永远引用那个变量,不会像指针那样可以改变所指向的变量。这意味着通过引用对数据的任何操作都是直接作用于它所引用的那个变量上
int a =0; int &b=a; int c=2; b=c;
b引用了a,则不会再改变,这里b=c则就是把c的值赋值给b
我们可以看到,a和b的地址是相同的
一个变量可以有多个引用
1. int a = 0; 2. int& b = a; 3. int& c = a; 4. int& d = a;
1.2使用场景
做参数
如果我们想用一个函数来实现两个数的交换,用我们学过的知识,这里我们使用指针:
void Swap(int* a, int* b) { int tmp = *a; *a = *b; *b = tmp; } int main() { int x = 10; int y = 20; Swap(&x, &y); return 0; }
如果我们不传地址,那么a就是x的临时拷贝,a的改变不会影响x的值
下面是引用的做法
void Swap(int& a, int& b) { int tmp = a; a = b; b = tmp; } int main() { int x = 10; int y = 20; Swap(x, y); return 0; }
里a就是x的别名,b就是y的别名,对ab进行修改同时就对xy进行修改
在后面我们会讲到这个部分的底层逻辑
这个版本的 Swap 函数展示了C++引用的强大用处和简洁语法。通过引用参数,可以直接修改传入的变量,而无需担心指针解引用和地址操作,这使得代码更加安全、清晰
但是由于引用定义后不能改变指向,引用不能替代指针
当然,这里swap函数取名字也可以取x,y,因为他们在不同作用域,对结果没有什么影响
void Swap(int& x, int& y) { int tmp = x; x = y; y = tmp; }
1.3传值、传引用效率比较
以值作为参数或者返回值类型,在传参和返回期间,函数不会直接传递实参或者将变量本身直接返回,而是传递实参或者返回变量的一份临时的拷贝,因此用值作为参数或者返回值类型,效率是非常低下的,尤其是当参数或者返回值类型非常大时,效率就更低
1. #include 2. struct A { int a[10000]; }; 3. void TestFunc1(A a) {} 4. void TestFunc2(A& a) {} 5. void main() 6. { 7. A a; 8. // 以值作为函数参数 9. size_t begin1 = clock(); 10. for (size_t i = 0; i < 10000; ++i) 11. TestFunc1(a); 12. size_t end1 = clock(); 13. // 以引用作为函数参数 14. size_t begin2 = clock(); 15. for (size_t i = 0; i < 10000; ++i) 16. TestFunc2(a); 17. size_t end2 = clock(); 18. // 分别计算两个函数运行结束后的时间 19. cout << "TestFunc1(A)-time:" << end1 - begin1 << endl; 20. cout << "TestFunc2(A&)-time:" << end2 - begin2 << endl; 21. }
按值传递 (TestFunc1(A a))
当你以值传递一个对象给函数时,该函数获得的是原对象的一个完全独立的拷贝。这意味着函数中对该参数的任何修改都不会反映到原对象上。在这个具体的例子中,当TestFunc1被调用,结构体A中包含的数组将会被整个复制给函数内的一个新的局部变量a
对于大的结构体(如本例中定义的struct A { int a[10000]; };),这个拷贝操作将会非常昂贵,从时间和处理器资源的角度考虑都是如此。每次函数调用都会触发一个大数组的拷贝过程,这可能导致显著的性能下降
按引用传递 (TestFunc2(A& a))
与按值传递不同,按引用传递对象意味着函数接收的是原对象的一个引用(或者说是原对象的一个别名)。这意味着函数中对参数的任何修改都将反映到传入的原始对象上。关键点在于没有产生任何拷贝,函数直接在原对象上工作
在本例中,当TestFunc2被调用,并且以A&(结构体A的引用)作为参数时,它实际上是直接操作原有的对象a,而不是创建一个新的拷贝。这样就避免了昂贵的拷贝操作,极大地提高了效率
在上述代码示例中,TestFunc1(按值传递)会因为每次调用时都需要复制一个大数组而显得非常慢,而TestFunc2(按引用传递)则会因为避免了这种拷贝,运行时间将大大减少
1.4引用做返回值
首先我们来看这串代码
int func() { int a = 0; return a; } int main() { int ret = func(); return 0; }
在函数 func 中定义的变量 a 是一个局部变量。它的生命周期仅限于函数 func 的执行期间。一旦 func 执行完毕,a所占用的内存就会被释放掉,该内存区域可以被其他函数或变量复用。这意味着,在函数 func 外部,我们无法安全地访问变量 a
当函数被调用时,一个栈帧(stack frame)就会被分配给这个调用。栈帧是存储函数局部变量、参数和其他信息(如返回地址)的内存块。对于 func 函数,它的栈帧将包含局部变量 a 的存储空间
值返回的基本原理
当 func 函数通过 return a; 返回 a 的值时,实际上返回的是 a 值的一个副本,而不是 a 自身。这个返回值副本通常是通过寄存器传递给函数的调用者,在 main 函数中, int ret = func(); 一句捕获了 func 返回的 a 的副本,并将其存储在 main 的局部变量 ret 中。值得注意的是,此时的 ret 和 func 中的 a 互不干扰,ret 拥有 a 的一个独立副本
再看这串代码:
int& func() { int a = 0; return a; } int main() { int ret = func(); return 0; }
这里func返回的是a的别名,但这里有很大问题
问题解释
在 func 函数中,a 是个局部变量,它的生命周期仅限于函数 func 的执行期间。当 func 函数执行完毕后,局部变量 a 的存储空间将被释放,此时返回给调用者的引用将指向一个已经被销毁的对象。尽管 main 函数中用 int ret = func(); 接收的是引用的返回值的拷贝,从而避免直接持有悬空引用,但 func 函数的设计本身是有问题的,因为它返回了对局部变量的引用
返回局部变量的引用导致了未定义行为,因为一旦 func 函数返回,a 的生命周期结束,其所占用的内存可能会被其他数据覆盖,或者其所在的栈帧空间可能被后续的函数调用复用。在这种情况下,通过悬空引用访问这块内存是非法的,这可能导致程序崩溃
替代方案
返回静态局部变量的引用:静态局部变量的生命周期持续到程序结束,因此返回其引用是安全的
int& func() { static int a = 0; return a; }
使用动态内存分配:在一些必须返回复杂数据结构而又不希望拷贝它们的情况下,可以动态分配内存(例如,使用new),然后返回指向它的指针
总结:若返回变量出了函数作用域生命周期结束,不能用引用返回
1.5引用和指针的对比
语法层面:
引用是别名,不开空间;指针是地址,需要开空间存地址
在底层实现上实际是有空间的,因为引用是按照指针方式来实现的
int main() { int a = 10; int& ra = a; ra = 20; int* pa = &a; *pa = 20; return 0; }
引用底层是用指针实现的
引用必须初始化,指针可以不初始化
引用不能改变指向,指针可以
引用相对更安全,没有空引用,但是有空指针
在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32位平台下占4个字节)
引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小
有多级指针,但是没有多级引用
底层层面:
在汇编层面,没有引用,都是指针,引用编译后也转换成指针了
1.6常引用
权限缩小:
int a = 10; const int &c =a;
下面这种会报错:
const int x =10; int & y=x; 1 2
x是只读的,y是可读可写的,这里就把权限放大了
我们需要在y前面也加入const修饰
const int x =10; const int & y=x;
2.内联函数
内联函数旨在减少函数调用的开销,通过在每个调用点将函数体展开来达到这一目的。这种方法适用于那些函数体较小、调用频繁的函数
比如,我要调用一万次Add函数:
1. int Add(int x,int y) 2. { 3. return x+y; 4. }
如果我要调用一万次,意味着要建立一万个栈帧,消耗比较大
在c语言中,我们可以用宏来解决
#define Add(x,y) ((x)+(y))
在c++中,可以通过在函数声明前添加关键字inline来指示编译器将一个函数视为内联函数
1. inline int Add(int x,int y) 2. { 3. return x+y; 4. }
当编译器处理到函数调用时,如果该函数被声明为内联,则编译器会尝试将该函数调用替换为函数体本身的代码。这样,当程序运行到那一点时,不再需要跳转到函数然后返回,而是直接执行了函数的代码。这样做的好处是减少了函数调用的消耗
1. #include 2. using namespace std; 3. inline int Add(int x, int y) { 4. return x + y; 5. } 6. 7. int main() { 8. int result = Add(5, 3); 9. cout << result << endl; 10. return 0; 11. }
在这个简单的例子中,由于Add函数被声明为内联,编译器可能会将main函数中的Add(5, 3)调用直接替换为5 + 3,从而避免了函数调用的开销
inline是一种以空间换时间的做法,如果编译器将函数当成内联函数处理,在编译阶段,会用函数体替换函数调用,缺陷:可能会使目标文件变大,优势:少了调用开销,提高程序运行效率
inline对于编译器而言只是一个建议,不同编译器关于inline实现机制可能不同,一般建议:将函数规模较小(即函数不是很长,具体没有准确的说法,取决于编译器内部实现)、不是递归、且频繁调用的函数采用inline修饰,否则编译器会忽略inline特性
inline不建议声明和定义分离,分离会导致链接错误。因为inline被展开,就没有函数地址了,链接就会找不到
3.auto关键字
auto 关键字是 C++11 中引入的一个特性,它让编译器能够自动推导变量的类型。使用 auto 可以使代码更加简洁易读,特别是当处理复杂的类型
int a = 0; int b = a; auto c = a; auto p = &a; auto *p = &a; auto& r = a;
它的推导是十分灵活的
用auto声明指针类型时,用auto和auto*没有任何区别,但用auto声明引用类型时则必须加&
比如我们想创建一个函数指针:
void func(int a, int b) { } int main() { void(*pf1)(int,int)=func; auto pf2=func; return 0; }
其类型是相同的
使用auto定义变量时必须对其进行初始化,在编译阶段编译器需要根据初始化表达式来推导auto的实际类型。因此auto并非是一种“类型”的声明,而是一个类型声明时的“占位符”,编译器在编译期会将auto替换为变量实际的类型
当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量
void TestAuto() { auto a = 1, b = 2; auto c = 3, d = 4.0; // 该行代码会编译失败,因为c和d的初始化表达式类型不同 }
auto不能推导的场景:
auto不能作为函数的参数 void TestAuto(auto a) {}
auto不能直接用来声明数组
4. 基于范围的for循环(C++11)
在C++98中如果要遍历一个数组,可以按照以下方式进行:
void TestFor() { 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; }
C++11 引入了范围 for 循环(也称为基于范围的 for 循环),它使得遍历容器(例如数组、向量等)变得更加简单和直观。这个特性大大简化了对容器元素的访问和迭代操作
范围 for 循环的语法如下:
for (declaration : range) { // 循环体 }
declaration:声明一个变量,这个变量的类型应该与 range 中的元素类型相兼容。这个变量将在每次循环迭代时被初始化为序列中的当前元素。这里可以使用 auto 使编译器自动推断元素类型。
range:是您要遍历的序列或容器,可以是数组、向量、列表等。
示例
遍历数组:
1. int arr[] = {1, 2, 3, 4, 5}; 2. for (int elem : arr) { 3. std::cout << elem << ' '; 4. }
输出:
1 2 3 4 5
使用 auto 关键字:
int arr[] = { 1,2,3,4,5 }; for (auto e : arr) { std::cout << e << " "; }
输出:
1 2 3 4 5
按引用遍历以修改元素
1. int array[] = { 1, 2, 3, 4, 5 }; 2. for (auto& e : array) 3. e *= 2; 4. for (auto e : array) 5. cout << e << " ";
输出
2 4 6 8 10
范围for的使用条件:
for循环迭代的范围必须是确定的
对于数组而言,就是数组中第一个元素和最后一个元素的范围
以下代码就有问题,因为for的范围不确定:
void TestFor(int array[]) { for(auto& e : array) cout<< e < }
5.指针空值nullptr(C++11)
在 C++ 中,nullptr 是一个字面量,用于表示空指针。它在 C++11 标准中引入,用以替代 C 语言时代的 NULL 宏和 C++ 中的 0(零),以明确表示空指针的意图。这样的引入解决了旧有方法的一些类型安全性问题和模糊性,增强了代码的可读性和可维护性
如果一个指针没有合法的指向,我们基本都是按照如下方式对其进行初始化
int* p1 = NULL; int* p2 = 0;
NULL实际是一个宏,在传统的C头文件(stddef.h)中,可以看到如下代码
#ifndef NULL #ifdef __cplusplus #define NULL 0 #else #define NULL ((void *)0) #endif #endif
可以看到,NULL可能被定义为字面常量0,或者被定义为无类型指针(void*)的常量,我们可能遇到下面的麻烦:
void f(int) { cout<<"f(int)"< } void f(int*) { cout<<"f(int*)"< } int main() { f(0); f(NULL); f((int*)NULL); return 0; }
程序本意是想通过f(NULL)调用指针版本的f(int*)函数,但是由于NULL被定义成0,与预期违背
nullptr 的类型是 nullptr_t,可以自动转换到任何其他指针类型,但不可以不经转换直接用于整数类型,这解决了原来使用 NULL 或 0 可能引起的一些类型混淆或过载解析问题,nullptr 可用于任何需要空指针的地方,与所有指针类型兼容,包括 C++ 基本类型指针、对象指针、函数指针以及成员函数指针
由于 nullptr 有自己的类型 nullptr_t,所以它可以被用于函数重载的场景,这在使用 NULL(通常被定义为 0 或 ((void)0))时无法实现*
为了提高代码的健壮性,在后续表示指针空值时建议最好使用nullptr