C++入门——引用|内联函数|auto关键字|基于范围的for循环|指针空值

前言

C++入门专栏是为了补充C的不足,并为后面学习类和对象打基础。在前面我们已经讲解了命名空间、输入输出、缺省参数、重载函数等,今天我们将完结C++的入门。

下面开始我们的学习吧!

一、引用

1、引用是什么呢?为什么C++添加了引用?

(1)引用的概念: 引用是给已存在变量取了一个别名,不是重新定义一个新变量。编译器不会为引用变量开辟内存空间,它和它引用的变量一起用同一块内存空间。(就如西游记中孙悟空,也叫齐天大圣,你不管叫他那个名字,都是代表一个人。)

(2) 在C语言中,我们要改变一个变量的值可以取一个变量的地址通过解引用来改变,但是这是一种间接的玩法。总结:简单的说就是指针是间接的,而引用相当于它的别名还是它自己,更直接,更方便了。 所以以后我们大多都是使用引用了。下面让我们详细了解引用的魅力!

2、引用的语法

引用的语法:

类型& 引用变量名(对象名) = 引用实体。

tip:

(1)引用的理解:

①C++觉得添加太多新符号不太好,所以就会在某些地方共用一些符号。例如这里的'&'。

②'&'这个符号用法的区分:'&'在变量名之前,代表取地址;'&'在类型之后代表引用(取别名)。

③引用在语法逻辑上不会开辟新空间,它和它引用的实体共用一块内存空间。

④引用就是取别名,不管怎样还是共用一块内存空间。

⑤代码示例:

cpp 复制代码
//引用的理解:
#include<iostream>

using namespace std;

int main()
{
	int a = 10;
	//b是a的引用(别名)
	int& b = a;

	//打印a与b的地址
	cout << &a << endl;
	cout << &b << endl;
	return 0;
}

运行结果:

(2)引用的特性:

①引用在定义的时候必须初始化(就如你给谁取别名,肯定是有一个明确的指向,是给谁取的)。

②一个变量可以有多个别名(就像孙悟空就有多个别名)。

③引用一旦引用了一个实体,就不能再引用其他实体了(从这里就能看出C++的革命是不彻底的,引用并没有将指针完全替代)。

④代码示例:

cpp 复制代码
//引用的特性:
#include<iostream>

using namespace std;

int main()
{
	int a = 10;
	//①必须初始化;
	//int& b;//编译报错,引用必须初始化

	//②一个变量可以有多个引用
	int& b = a;//b是a的引用(别名)
	int& c = b;//c是b的引用(别名的别名也是可以的)

	//③引用一旦引用了一个实体,就不能在引用其他实体了
	int x = 9;
	//int& b = x;//编译报错,"b"重定义,多次初始化
	b = x;//注意这里b不是x的引用,而是x赋值给b,b仍是a的别名。
	return 0;
}

(3)常引用------引用的权限

①引用过程中,权限可以平移或者缩小,但是不可以放大。

②算术转化:如果操作符的操作数类型不一致,会发生类型转化,只有类型一致,才能进行运算。

③类型转化:生成一个临时变量,临时变量具有常性,即临时变量不可改变,是常变量。图示:

④为什么类型转化会生成临时变量------因为变量a类型不会改变,所以需要生成一个中间变量来进行类型转化。

⑤当引用的实体是一个常变量的时候,我们就要使用常引用,因为权限不可以放大。

⑥常引用的应用:常引用做参数------如果函数中只是使用参数,不改变参数的值,建议使用常引用。

⑦代码示例:

cpp 复制代码
//常引用------引用的权限
#include<iostream>

using namespace std;

int main()
{
	//1、引用过程中,权限可以平移或者缩小
	int a = 10;
	int& b = a;//权限平移,a/b可读可写
	//如a能++,b也可以
	b++;
	a++;
	const int& c = a;//权限的缩小,a可读可写c可读不可写
	//a能++,c不可以
	a++;
	//c++;//编译报错,c不能改变

	//2、引用过程中,权限不可以放大
	double x = 9;
	//int& y = x;//因为类型不一致,x生成const int的临时变量可读不可写,而y可读可写,引用权限不可以放大,所以报错。
	const int& y = x;//权限平移

	return 0;
}

3、引用的应用

(1)引用做参数

引用做参数的意义:

①做输出型参数:形参的改变要影响实参。

代码示例:交换两个整数

cpp 复制代码
#include<iostream>

using namespace std;
//使用引用做输出参数交换两个整数
void Swap(int& x, int& y)
{
	int temp = x;
	x = y;
	y = temp;
}

int main()
{
	int a = 3;
	int b = 5;
	//调用函数交换a b
	Swap(a, b);
	cout << "交换后:a=" << a << " b=" << b << endl;
	return 0;
}

tip:在C语言的时候,我们交换两个整数,要使形参的改变影响实参,我们用的指针来实现,现在C++里面我们可以使用引用实现。

②引用做参数,减少拷贝提高了效率。(特别是对于大对象和深拷贝类对象)

代码示例:

cpp 复制代码
//2.调高效率,建议大对象/深拷贝对象使用引用做参数

#include<iostream>
#include<time.h>

using namespace std;

//定义一个大结构体
struct A
{
	int a[10000];
};

//定义函数:以值作为函数参数
void Func1(A a){}
//定义函数:以引用作为函数参数
void Func2(A& a){}

//定义函数:分别计算两个函数运行结束后的时间
void TestRefAndValue()
{
	//struct A a;//C中定义结构体类型变量的方式,Cpp也可以这样(兼容C)
	A a;//Cpp中定义类对象的方式,因为在Cpp中struct升级为类了

	//以值作为函数参数
	size_t begin1 = clock();//开始时间
	for (size_t i = 0; i < 10000; ++i)
	{
		Func1(a);
	}//调用10000次Func1函数
	size_t end1 = clock();//结束时间

	//以引用作为函数参数
	size_t begin2 = clock();
	for (size_t i = 0; i < 10000; ++i)
	{
		Func2(a);
	}
	size_t end2 = clock();

	//分别输出两个函数运行结束后的时间
	cout << "Func1(A)_time:" << end1 - begin1 << endl;
	cout << "Func1(A&)_time:" << end2 - begin2 << endl;
}

int main()
{
	//调用函数TestRefAndValue分别计算以值为参数和以引用为参数的运行时间
	TestRefAndValue();
	return 0;
}

运行结果:

(2)引用做返回值

①引用做返回值的第一个意义:减少拷贝提高效率。

代码示例:

cpp 复制代码
//①减少拷贝提高效率
#include<iostream>
#include<time.h>

using namespace std;

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;
}

int main()
{
	TestReturnByRefOrValue();
	return 0;
}

运行结果:

②为什么会减少拷贝提高效率呢?

答案是:函数的返回类型是传值返回,就会将返回值拷贝到一个临时变量中,最后再拷贝回主调函数。当函数的返回类型是引用返回时,就不会生成临时变量通过拷贝返回,而是直接返回返回值的别名。图示:

③局部变量与静态变量传引用返回。

代码示例1:局部变量传引用返回

cpp 复制代码
//局部变量传引用返回
#include<iostream>

using namespace std;

int& Count()
{
	int n = 0;//局部变量
	n++;
	//......
	return n;
}

int main()
{
	//ret也是n的别名
	int& ret = Count();
	//调用完Count直接输出ret
	cout << ret << endl;
	printf("sss\n");
	//调用完printf,再次输出ret
	cout << ret << endl;
	return 0;
}

运行结果:

为什么ret的值不一样呢------因为局部变量随栈帧销毁而销毁。如果Count函数结束,栈帧不清理,那么ret的结果侥幸正确;如果Count函数结束,栈帧清理,那么ret的结果是随机值。图示:

代码示例2:静态变量传引用返回

cpp 复制代码
//静态变量传引用返回
#include<iostream>

using namespace std;

int& Count()
{
	static int n = 0;//静态变量
	n++;
	//......
	return n;
}

int main()
{
	//ret也是n的别名
	int& ret = Count();
	//调用完Count直接输出ret
	cout << ret << endl;
	printf("sss\n");
	//调用完printf,再次输出ret
	cout << ret << endl;
	return 0;
}

运行结果:

为什么这里ret的值一样呢------静态变量存储在静态区,不随栈帧的销毁而销毁。

tip:谨慎用引用做返回值,出了函数作用域,对象不在了,就不能用引用返回,还在就可以用引用返回。

④引用做返回值的第二个意义:获取返回值与修改返回值。

代码示例:静态顺序表获取pos位置值与修改pos位置值

cpp 复制代码
//代码示例:静态顺序表获取pos位置值与修改pos位置值
#include<iostream>
#include<assert.h>

using namespace std;

//定义静态顺序表类型
struct SeqList
{
	int a[100];//顺序表大小
	int size;//有效数据个数
};

//C实现------获取pos位置值
int SLGet(SeqList* ps, int pos)
{
	//断言pos位置是否合理
	assert(pos >= 0 && pos < 100);
	//返回pos位置值
	return ps->a[pos];
}

//C实现------修改pos位置值
void SLModify(SeqList* ps, int pos, int x)
{
	//断言pos位置是否合理
	assert(pos >= 0 && pos < 100);
	//修改pos位置值
	ps->a[pos] = x;
}

//C++使用引用做返回值实现------修改&获取pos位置值
int& SLAt(SeqList& ps, int pos)
{
	//断言pos位置是否合理
	assert(pos >= 0 && pos < 100);
	//返回pos位置的别名
	return ps.a[pos];
}

int main()
{
	SeqList s;
	//调用C的实现,来获取与修改pos位置
	SLModify(&s, 1, 2);
	cout << SLGet(&s, 1) << endl;
	//调用C++的实现,来获取与修改pos位置
	SLAt(s, 0) = 1;//修改
	cout << SLAt(s, 0) << endl;//获取
	return 0;
}

总结

1、引用做参数:①做输出型参数;②减少拷贝提高效率;③基本任何场景都可以用引用做参数。

2、引用做返回值:①减少拷贝提高效率;②可以读写返回值;③谨慎用引用做返回值,出了函数作用域,对象不在了,就不能用引用返回,还在就可以用引用返回。

4、引用与指针的区别

(1)语法层面: 引用不开空间,是对实体取别名;指针开空间,是存储实体地址。

代码示例:

cpp 复制代码
//引用与指针的区别
#include<iostream>

using namespace std;

int main()
{
	int a = 11;

	//引用在语法层面:不开空间,是对a的别名
	int& ra = a;
	ra = 13;

	//指针在语法层面:开空间,存储a的地址
	int* pa = &a;
	*pa = 14;
	return 0;
}

F10调试观察:

(2)底层层面: 从底层汇编指令实现的角度看,引用是类似指针的方式实现的。即在底层实现上引用实际是开空间的。

汇编代码图示:

(3)引用与指针不同点总结:

①在语法层面:引用不开空间是一个实体的别名,指针开空间,存储实体的地址。

②引用在定义时必须初始化,指针没有要求。

③引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体。(即引用不可以修改指向,指针可以修改指向。)

④没有NULL引用,但有NULL指针。

⑤在sizeof中含义不同:引用结果为引用类型的大小,但是指针始终是地址空间所占字节个数。

⑥引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小。

⑦有多级指针,但没有多级引用。

⑧访问实体方式不同,指针需要显示解引用,引用编译器自己处理。

⑨引用比指针使用起来相对更安全。(如上面第四点。)

二、内联函数

1、回顾宏函数

我们都知道调用函数需要建立栈帧是有消耗的,所以对于一些代码少且频繁调用的函数,在C语言我们使用了宏函数优化。

(1)代码示例:两数相加的宏

错误形式1:

cpp 复制代码
#define Add(x,y) x+y

解读:Add(10,20) * 20,宏预编译进行替换为10 + 20 * 20,我们发现因为操作符优先级的问题,不是先加后乘。

错误形式2:

cpp 复制代码
#define Add(x,y) (x+y)

解读:Add(1 | 2 , 1 & 2),宏替换后为:(1 | 2 + 1 & 2),位操作符的优先级低于算术操作符,所以错误。

正确形式:

cpp 复制代码
#define Add(x,y) ((x) + (y))

tip:宏参数的求值是在所有周围表达式的上下环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多写括号。(可以替换看一看。)

(2)宏的优缺点:

优点: 不需要建立栈帧,提高效率。

缺点: 因为宏在预编译阶段进行了替换,所以①不方便调试;②代码可读性差,可维护性差,容易出错;③没有类型的检查等等。

(3)宏函数有这么多缺点,我们C++祖师爷就看不下去了,所以就有了inline内联函数。

2、内联函数

(1)概念: 以inline修饰的函数叫做内联函数,编译时C++编译器会在调用函数的地方展开,没有函数调用建立栈帧的开销,内联函数提升程序运行的效率。

代码示例:

cpp 复制代码
//内联函数
#include<iostream>
using namespace std;

//inline修饰的函数
inline int Add(int x, int y)
{
	return x + y;
}

int main()
{
	int ret = 0;
	ret = Add(1, 2);
}

tip: ①内联函数弥补了宏的缺点,继承了宏的优点。内联函数可读性高,可调试,不复杂等等。②在默认的debug模式下,inline不会起作用,否则不方便调试。

(2)内联这么好能不能都写成内联呢?

答案是: 不可以,因为inline的特性不支持所有函数都写成内联。

(3)内联函数的特性:

①inline是一种以空间换时间 的做法,如果编译器将函数当成内联函数处理,在编译阶段,会用函数体替换函数调用,缺点:可能会使目标文件变大,优势:少了调用开销,提高程序运行效率。例如Func编译后是50行指令,如果Func不是inline,调用10000次Func合计指令为:10000+50(调用即call Func(地址),跳转到Func。);如果Func是inline,调用10000次Func合计指令为:10000*50(假设inline只是单纯展开,实际不是)。指令越多,目标文件越大。

inline对于编译器而言只是一个建议,最终是否成为inline,由编译器决定 。一般来说,内联机制用于优化规模较小 (即函数不是很长,具体没有准确的说法,取决于编译器内部实现)、流程直接 (不递归)、频繁调用 的函数。例如递归函数加了inline也会被编译器否决。

inline不建议声明和定义分离 ,分离会导致链接错误。因为inline被展开,就没有函数地址了,链接就会找不到。

代码示例:

cpp 复制代码
//F.h
#include<iostream>
using namespace std;

inline void f(int i);
//F.cpp
#include"F.h"

void f(int i)
{
	cout << i << endl;
}
//Test.cpp
#include"F.h"

int main()
{
	f(10);
	return 0;
}

//链接错误:Test.obj:LNK2019:无法解析的外部符号 "void __cdecl f(int)" (? f@@YAXH@Z),函数 _main 中引用了该符号	

tip:不建议声明与定义分开,所以inline直接在.h文件中定义实现。

三、auto关键字(C++11)

1、auto简介

C++11之前: 在早期C/C++中auto的含义是:使用auto修饰的变量,是具有自动存储器的局部变量(限定变量的作用域及生命周期),但是没有人去使用,因为局部变量默认是auto修饰的。如下:

cpp 复制代码
int a = 10;//自动存储类型
auto int b = 10;//自动存储类型

这样的话,auto没有用了。

C++11中: 标准委员会赋予了auto全新的含义即:auto不再是一个存储类型指示符,而是作为一个新的类型指示符来指示编译器,auto声明的变量必须由编译器在编译时期推导而得。

简单来说,在C++11中auto可以根据右边的表达式自动推导变量的类型

代码示例:

cpp 复制代码
#include<iostream>
using namespace std;

int main()
{
	auto a = 13;
	auto b = 0.14;

	//打印类型
	cout << typeid(a).name() << endl;
	cout << typeid(b).name() << endl;
	return 0;
}

运行结果:

注意:使用auto定义变量时必须对其进行初始化,在编译阶段需要根据初始化表达式来推导auto的实际类型。因此auto并非是一种"类型"的声明,而是一个类型声明时的"占位符",编译器在编译期会将auto替换为变量实际的类型。

为了避免与C++98中的auto发生混淆,C++11只保留了auto作为类型指示符的用法。

2、auto的应用场景

随着程序越来越复杂,程序中用到的类型也越来越复杂。类型复杂 ,我们不仅容易写错还难于拼写。所以这个时候使用auto来帮我们自动推导变量的类型,就非常方便了。

代码示例:

cpp 复制代码
#include<iostream>
#include<map>

int main()
{
	std::map<std::string, std::string> dict;

	//std::map<std::string, std::string>::iterator it = dict.begin();
	//等价于
	auto it = dict.begin();
	return 0;
}

tip:auto在实际中最常见的优势用法就是跟C++11提供的新式for循环,还有lambda表达式等进行配合使用。

3、auto的使用细则

(1)auto与指针和引用结合使用

代码示例:

cpp 复制代码
#include<iostream>
using namespace std;

int main()
{
	int x = 10;
	auto a = &x;
	auto* b = &x;//auto*指定必须是指针类型
	auto& c = x;

	cout << typeid(a).name() << endl;
	cout << typeid(b).name() << endl;
	cout << typeid(c).name() << endl;

	return 0;
}

运行结果:

tip:使用auto声明指针时,用auto和auto没有任何区别(auto指定必须是指针),但用auto声明引用类型时必须加&。

(2)auto在同一行定义多个变量

当在同一行声明多个变量时,这些变量必须是相同类型,否则编译器将报错,因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量。

代码示例:

cpp 复制代码
int main()
{
	auto a = 1, b = 2;
	auto c = 3, d = 13.14;//编译失败,因为c和d的初始化表达式类型不同
	return 0;
}

4、auto不能推导的场景

(1)auto不能作为函数的参数

cpp 复制代码
void TestAuto(auto a){}

此处代码编译失败,auto不能作为形参类型,因为编译器无法对a的实际类型进行推导。

(2)auto不能直接用来声明数组

cpp 复制代码
void TestAuto()
{
	auto a[] = {1, 2};
}

编译失败,auto不能声明数组,因为auto类型不能出现在顶级数组类型中。

四、基于范围的for循环(C++11)

1、范围for的语法

在以前(C++98),如果要遍历一个数组,我们是按照下面的方式实现:

cpp 复制代码
#include<iostream>
using namespace std;

int main()
{
	int arr[] = { 1, 2, 3, 4, 5 };
	//通过下标访问
	for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++)
	{
		arr[i] *= 2;
	}
	//通过指针访问
	for (int* p = arr; p < arr + sizeof(arr) / sizeof(arr[0]); p++)
	{
		cout << *p << endl;
	}
	return 0;
}

对于一个有范围的集合 而言,由程序员来说明循环的范围是多余的,有时候还容易犯错。因此C++11中引入了基于范围的for循环

范围for的语法格式如下:

cpp 复制代码
for(auto e : array)

for循环后的括号由冒号":"分为两部分:第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。

现在我们使用范围for来遍历数组,修改数组:

cpp 复制代码
#include<iostream>
using namespace std;

int main()
{
	int arr[] = { 1, 2, 3, 4, 5 };
	//范围for与引用结合:修改数组
	for (auto& e : arr)
	{
		e *= 2;
	}
	//范围for:打印数组
	for (auto e : arr)
	{
		cout << e << endl;
	}
	return 0;
}

总结:

①使用范围for遍历数组与以前相比,用起来非常方便,不易出错。所以范围for是一个语法糖。

②适用于数组,依次取数组中的数据赋值给变量e,自动迭代,自动判断结束。

③与普通循环类似,可以用continue来结束本次循环,也可以用break来跳出整个循环。

2、范围for的使用条件

(1)for循环迭代的范围必须是确定的

对于数组而言,就是数组中第一个元素和最后一个元素的范围;对于类而言,应该提供begin和end的方法,begin和end就是for循环迭代的范围。

错误演示:以下代码中for的范围是不确定的

cpp 复制代码
void TestFor(int arr[])
{
	for (auto e : arr)
	{
		cout << e << endl;
	}
}

解读:数组传参,实际只能接收数组的首元素地址,这里我们并不知道数组的范围,所以报错。

(2)迭代的对象要实现++和==的操作。(后期讲解,大家先知道即可)

五、指针空值nullptr(C++11)

1、C++98中的指针空值

在良好的C/C++编程习惯中,声明一个变量时最好给变量一个合适的初始值,否则可能会出现不可预料的错误,比如未初始化的指针。

如果一个指针没有合法的指向,我们就需要将其置为空。

代码演示:

cpp 复制代码
int main()
{
	int* p1 = NULL;
	int* p2 = 0;
	return 0;
}

为什么0也可以将指针置为空呢?

答案是:NULL实际是一个宏,在传统的C头文件(stddef.h)中,可以看到如下代码:

cpp 复制代码
#ifndef NULL
    #ifdef __cplusplus
        #define NULL 0
    #else
        #define NULL ((void *)0)
    #endif
#endif

可以看到,NULL可能别被定义为字面常量0,或者被定义为无类型指针(void)的常量。

不论采用哪种定义,在使用空值的指针时,都不可避免的遇到一些麻烦,如下:

cpp 复制代码
#include<iostream>
using namespace std;

//参数类型是整形
void f(int)
{
	cout << "f(int)" << endl;
}
//参数类型是整形指针
void f(int*)
{
	cout << "f(int*)" << endl;
}

int main()
{
	//调用f函数,观察
	f(0);
	f(NULL);
	f((int*)NULL);
	return 0;
}

运行结果:

解读:

①程序的本意是想通过f(NULL)调用指针版本的f(int*)函数,但是由于NULL被定义成0,因此与程序初衷相孛。

②在C++98中字面常量0即可以是一个整形数字,也可以是无类型的指针(void*)常量,但是编译器默认情况下将其看成是一个整形常量,如果要将其按照指针方式来使用,必须对齐进行强转(void*)0。

③形参因为我们只是观察参数匹配规则,所以可以只写形参类型。

NULL这样太尴尬,所以C++11引入了nullptr

2、nullptr

先看一段代码示例:

cpp 复制代码
#include<iostream>
using namespace std;

//参数类型是整形
void f(int)
{
	cout << "f(int)" << endl;
}
//参数类型是整形指针
void f(int*)
{
	cout << "f(int*)" << endl;
}

int main()
{
	//调用f函数,观察
	f(NULL);
	f(nullptr);
	return 0;
}

运行结果:

总结:

①为了提高代码的健壮性,在后续表示指针空值时建议最好使用nullptr。

②在使用nullptr表示空值时,不需要包含头文件,因为nullptr是C++11作为关键字引入的。

③在C++11中,sizeof(nullptr)与sizeof((void*)0)所占字节数相同。

相关推荐
Charles Ray8 分钟前
C++学习笔记 —— 内存分配 new
c++·笔记·学习
重生之我在20年代敲代码8 分钟前
strncpy函数的使用和模拟实现
c语言·开发语言·c++·经验分享·笔记
2401_858286113 小时前
52.【C语言】 字符函数和字符串函数(strcat函数)
c语言·开发语言
jiao000015 小时前
数据结构——队列
c语言·数据结构·算法
铁匠匠匠5 小时前
从零开始学数据结构系列之第六章《排序简介》
c语言·数据结构·经验分享·笔记·学习·开源·课程设计
C-SDN花园GGbond5 小时前
【探索数据结构与算法】插入排序:原理、实现与分析(图文详解)
c语言·开发语言·数据结构·排序算法
迷迭所归处6 小时前
C++ —— 关于vector
开发语言·c++·算法
CV工程师小林6 小时前
【算法】BFS 系列之边权为 1 的最短路问题
数据结构·c++·算法·leetcode·宽度优先
Navigator_Z7 小时前
数据结构C //线性表(链表)ADT结构及相关函数
c语言·数据结构·算法·链表
white__ice7 小时前
2024.9.19
c++