C++入门(二)

引用

引用概念

引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。

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

在这里插入图片描述
注意:引用类型必须与引用实体是同种类型的

引用特性

  1. 引用在定义时必须初始化(int& a; 这种定义就是错误的)
  2. 一个变量可以有多个引用(int& b=a; int& c=a; a变量可以有多个引用)
  3. 引用一旦引用一个实体,再不能引用其他实体(就比如int& b=a;b=c; 这就是错的)
    在这里插入图片描述

如果更换引用,那实际上就变成了赋值,而非引用
C++的引用,可以在指针使用比较复杂的场景进行替换,让代码更简单,但是其定义之后不能改变指向,这一点是一个问题

这里举一个例子:链表
当我们想要增加或删除链表的节点时,我们就需要改变指针指向的内容,如果我们用引用实现链表,那么我们就无法改变next指向的内容,也就无从做到删除节点,实现链表的这一功能了

这里说一个小小的题外话,那么java和python没有指针,它们如何去创建链表的功能呢?答案是它们的引用是可以改变指向的

常引用

我们看下面一系列代码:

 	const int a = 10;
    //int& ra = a;   // 该语句编译时会出错,a为常量
    const int& ra = a;
    // int& b = 10; // 该语句编译时会出错,b为常量
    const int& b = 10;
    double d = 12.34;
    //int& rd = d; // 该语句编译时会出错,类型不同
    const int& rd = d;

常量是无法进行引用的,以及,引用时的变量类型也需要注意

再给大家看一个之前我们可能会犯的错误:

int sum() {
	int a = 10;
	return a;
}

int main()
{
	int a = sum();
	cout << a << endl;
	return 0;
}

我们在sum函数中创建了一个临时变量a并给它赋值,然后返回a的值给寄存器,再拷贝给main函数中的a;但是我们知道,在调用函数时会创建函数栈帧,而结束之后栈帧则被销毁,我们无法再找到a的具体位置;就像我们旅游住酒店,我们退了房之后,谁知道之后房间里的是什么呢?原本的那块空间里面有可能还存放着原本的东西,也可能是随机数,总之这是一个危险的行为。
那么接下来我们看下一个:

int& func() {
	int a = 0;
	return a;
}

int main()
{
	int a = sum();
	cout << a << endl;
	a = func();
	cout << a << endl;
	return 0;
}

之前的a起码还是拥有自己的独立空间,只是接受了a的值,但现在,我们返回a的引用,我们知道, 引用就是占据同一片空间,而func函数中返回a的引用,也就是让main函数中的a改变了地址,到了原本应该销毁掉的func函数中的a的地址,而这很明显是一个“野引用”,而这比刚才的赋值更危险

由此,我们知道,变量出了函数作用域,生命周期到了就要销毁(局部变量),不能用引用返回
而可以用引用返回的,是全局变量/静态变量/堆上变量

使用场景

1.做参数

void swap(int& a, int& b) {
	int temp = a;
	a = b;
	b = temp;
}

int main()
{
	int a = 2, b = 5;
	cout << a << b << endl;
	swap(a, b);
	cout << a << b << endl;
	return 0;
}

以前我们这里就是使用指针,把a和b的地址传过去,但是现在我们的参数是a和b的引用,与a和b拥有同一个地址,所以本质上是一样的

2.做返回值

int& count() {
	static int count = 0;
	count++;
	return count;
}

int main()
{
	int cnt = count();
	cout << cnt << endl;
	return 0;
}

由于静态变量在程序结束以后才会销毁,所以我们可以用引用传回静态变量的值
我们再看一个错误示范:

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

最后ret的值输出的是一个随机数,这就跟我们上面说过的情况一样,我们返回的一个被销毁的值,那么我们再在那个位置找到的就不再是原本的那个值了

总结

我们总结一下:
引用:1、做参数(a、输出型参数 b、对象比较大,减少拷贝,提高效率(指针也可以做到这些,但是引用更方便)
减少拷贝的原因在于,引用和原本的变量用的是同一空间,不会开辟新的空间,所以节省了内存的消耗
2、做返回值(a、修改返回对象 b、减少拷贝提高效率)

引用和指针的区别

语法:
1、引用是别名,不开空间;指针是地址,需要开空间存地址
2、引用必须初始化,指针可以也可以不初始化
3、引用不能改变指向,指针可以
4、引用相对更安全,没有空引用;有空指针和野指针,但是很少出现野引用
5、sizeof,++、解引用等使用也有区别

在这里插入图片描述
就比如这里,指针b和引用c,一个输出的是指针本身的大小,另一个则是输出自己引用类型的大小
而++也是,指针++是往后偏移一个类型的大小,而引用则是让自己引用的对象+1
6、有多级指针,但是没有多级引用

底层:
汇编层面上没有引用,都是指针,引用编译后也转换为指针了(这个很重要,要记得)
在这里插入图片描述

传值、传引用效率比较

以值作为参数或者返回值类型,在传参和返回期间,函数不会直接传递实参或者将变量本身直接返回,而是传递实参或者返回变量的一份临时的拷贝,因此用值作为参数或者返回值类型,效率是非常低下的,尤其是当参数或者返回值类型非常大时,效率就更低。
在这里插入图片描述

值和引用作为返回值作为返回值类型的性能比较

在这里插入图片描述
通过上述代码的比较,发现传值和指针在作为传参以及返回值类型上效率相差很大。

内联函数

概念

inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数调用建立栈帧的开销,内联函数提升程序运行的效率
在这里插入图片描述
一般我们的函数都会在调用的时候call函数的地址

而加上inline之后:
在这里插入图片描述
很明显,在汇编中已经看不到call了

宏的优缺点

我们知道,inline内联函数的需求是为了在需要大量调用函数的时候,从而使用的用于减少指令消耗,增加效率的,于是我们想到另一个东西:宏
之前在C语言中我们就学过宏,但是我们知道宏会延伸出很多问题,举个例子:

#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))

我们可以发现,这有很多种宏的写法,但正确的只有一个
首先第一个和第二个的共同错误,后面加入了分号;再是第一个加入了变量类型,并且写成了跟函数一样的return
第三个则是因为会产生歧义,假设a=2&1,b=3,这个时候由于加法的优先级更高,所以算式就会变成2&(1+3),而跟我们想要的结果背道而驰了,所以在第四个,我们给a和b也加上了括号,防止出现符号优先级导致的问题

由此来看,宏的缺点很多:
1、语法复杂,坑很多,不容易控制
2、不能调试(编译预处理阶段就已经转换成文本了)
3、没有类型安全的检查

但宏也有优点:
1.增强代码的复用性。
2.提高性能。

C++有哪些技术替代宏?
1. 常量定义 换用const enum
2. 短小函数定义 换用内联函数

inline特性

1. inline是一种以空间换时间的做法,如果编译器将函数当成内联函数处理,在编译阶段,会用函数体替换函数调用,缺陷:可能会使目标文件变大,优势:少了调用开销,提高程序运行效率。
2. inline对于编译器而言只是一个建议,不同编译器关于inline实现机制可能不同,一般建
议:将函数规模较小(即函数不是很长,具体没有准确的说法,取决于编译器内部实现)、不
是递归、且频繁调用的函数采用inline修饰,否则编译器会忽略inline特性。下图为
《C++prime》第五版关于inline的建议:

在这里插入图片描述
3. inline不建议声明和定义分离,分离会导致链接错误。因为inline被展开,就没有函数地址了,链接就会找不到

在这里插入图片描述
给大家讲一个题目,大家理解一下:
1、我们创建一个ADD函数,声明定义在.h文件中,然后同时包含在两个cpp文件中,这个时候它会报错,错误名是重定义

每个.cpp文件(编译单元)在编译过程中会生成自己的符号表,这个符号表包含了该编译单元中定义和引用的所有函数、变量等符号的信息。当链接器合并这些编译单元时,它会检查符号表以解析符号引用并处理可能的符号冲突。

对于非内联函数,符号表中会包含该函数的符号。如果有两个编译单元定义了同名的非内联函数,链接器会发现符号冲突(并且这两个函数并不构成函数重载),根据ODR(One Definition Rule,一个定义规则),非内联函数在程序中只能有一个定义。这会导致链接错误,因为链接器不知道应该使用哪个定义。

有些同学可能会觉得是#pragma once的问题,但是其是用来解决同一个文件中头文件重复包含的问题,下面我们为大家介绍三个解决方法:

static:假设我们就想在.h文件中直接声明+定义一个函数,static 关键字可以用来修饰函数,这表示该函数具有内部链接(internal linkage),也就是说这个函数只在定义它的源文件中可见,对其他的源文件是不可见的。因此,如果你在两个不同的源文件中定义了两个具有相同签名的 static 函数,这并不会导致链接错误,因为链接器不会尝试将这两个函数合并或解析为同一个符号。
声明定义分离:这是最常用的方法,声明在.h中,定义在cpp里,然后在正式使用的时候,.h包含在里面,通过声明call函数地址,在cpp里面找到再链接到一起。这样就只有一个目标文件中有函数的定义

inline:对于inline函数,情况有所不同。inline函数的定义通常放在头文件中(跟静态原理类似),并被多个编译单元包含。虽然每个包含该头文件的编译单元都会看到并可能使用inline函数的定义来生成内联代码,但inline函数的符号通常不会进入常规符号表(我们将常规函数的地址加入符号表就是为了方便调用,而内联函数不需要call)。这是因为链接器知道这些函数是内联的,并且期望它们在编译时已经被内联到调用点中,而不是作为独立的函数实体存在。(这里也是为什么inline函数声明定义不能分离的原因)

这里我们讲一下空间换时间,虽然我们少了调用开销,但是其他地方会有消耗。比如我们有一个100行的func函数,需要被调用1W次;如果有inline,100行在1W个地方展开,1W个位置就会变成100行;如果没有inline,100行,我有1W个调用的地方(指令call),call跳转到函数的第一行开始执行,执行完了就走回去,然后再接着执行;100是函数的指令,但是没有展开

所以这里的空间换时间,指的是编译好的可执行文件的大小,不管代码量多小都会有膨胀,只是没有代码量大的那么严重,到底展不展开要看编译器的限制

100行在1W个地方展开,1W个位置就会变成100行;100行,我有1W个调用的地方(指令call),call跳转到函数的第一行开始执行,执行完了就走回去,然后再接着执行;100是函数的指令,但是没有展开

需要注意的是,虽然电脑会自动判断这个函数是否可以通过inline展开提高效率,但是我们要自己判断,不能每个函数都写一个inline让它自己去判断

auto关键字

类型别名思考

1、类型难于拼写
2、含义不明确导致容易出错

我们举一个之后我们会学到的类型:std::map<std::string, std::string>::iterator,这类型太长,很容易写错
在以前,我们会使用typedef给类型取别名,但这虽然可以简化代码,却会有另外一个问题(typedef的局限性):

typedef char* pstring;
int main()
{
 const pstring p1;    // 编译成功还是失败?
 const pstring* p2;   // 编译成功还是失败?
 return 0;
}

我们通过编译器编译,发现p2的编译并没有错误,但p1报错,其需要初始值设定项;因为const修饰的其实是pstring,也就是char * ,所以实际上转化后的结果应该是这样的:

	const pstring p1;//char* const p1(这是typedef解析之后的),因为const修饰的是pstring,也就是char*,修饰的是指针本身
	//并不是我们理解的const char* p1
	const pstring* p2;
	return 0;

在这里插入图片描述

所以实际上,p1是一个指向字符的常量指针,而不是一个指向常量字符的指针,所以p1必须在声明时初始化,以确保它在整个生命周期中都有一个明确的、固定的地址值,并且一旦声明之后就不可以再修改
而p2,char* const* 是一个指向常量指针的指针,具体来说,它指向一个char*类型的常量指针。这里的const修饰的是指针所指向的对象,而不是指针本身。

const char* ptr; —— 指向常量字符的指针,指向的值不可变。
char* const ptr; —— 指向字符的常量指针,指针本身不可变。
const char* const ptr; —— 指向常量字符的常量指针,两者都不可变。

auto简介

在早期C/C++中auto的含义是:使用auto修饰的变量,是具有自动存储器的局部变量,但遗憾的是一直没有人去使用它,大家可思考下为什么?

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

在这里插入图片描述
typeid 是 C++ 标准库中的一个操作符,它用于获取其操作数的类型信息。
我们可以发现,auto可以根据变量的值自动推导类型,适用于那些变量类型复杂的情况,可以简短代码

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

auto使用细则

  1. auto与指针和引用结合起来使用
    用auto声明指针类型时,用auto和auto*没有任何区别,但用auto声明引用类型时则必须
    加&
int main()
{
    int x = 10;
    auto a = &x;
    auto* b = &x;
    auto& c = x;
    cout << typeid(a).name() << endl;
    cout << typeid(b).name() << endl;
    cout << typeid(c).name() << endl;
    *a = 20;
    *b = 30;
     c = 40;
    return 0;
}
  1. 在同一行定义多个变量
    当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量。
void TestAuto()
{
    auto a = 1, b = 2; 
    auto c = 3, d = 4.0;  // 该行代码会编译失败,因为c和d的初始化表达式类型不同
}

在这里插入图片描述

auto不能推导的场所

1、auto不能作为函数的参数

// 此处代码编译失败,auto不能作为形参类型,因为编译器无法对a的实际类型进行推导
void test(auto i){
	
}

2、auto不能直接拿来声明数组

void testauto(){
	int a[]={1,2,3};
	auto b[]={2,3,4};
}

基于范围的for循环

在C++98中如果要遍历一个数组,会这样做:

int main()
{
	int a[] = { 1,2,3 };
	for (int i = 0; i < sizeof(a) / sizeof(a[0]); i++) {
		cout << a[i] << endl;
	}
	return 0;
}

对于一个有范围的集合而言,由程序员来说明循环的范围是多余的,有时候还会容易犯错误。因此C++11中引入了基于范围的for循环。for循环后的括号由冒号“ :”分为两部分:第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围

int main()
{
	int array[] = { 1,2,3 };
	for (auto e : array) {
		cout << e << endl;
	}
	return 0;
}

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

范围for使用条件

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

对于数组而言,就是数组中第一个元素和最后一个元素的范围;对于类而言,应该提供
begin和end的方法,begin和end就是for循环迭代的范围。
注意:以下代码就有问题,因为for的范围不确定

void TestFor(int array[])
{
    for(auto& e : array)
        cout<< e <<endl;//只知道开始的地址,但是没有结尾
}
  1. 迭代的对象要实现++和==的操作。(关于迭代器这个问题,以后会讲,现在提一下,没办法讲清楚,现在大家了解一下就可以了)

如果想修改数组里面的值,那么要把auto改成auto&,这样才不是拷贝值,才可以修改实参

int main()
{
	int array[] = { 1,2,3 };
	for (auto& e : array) {
		e = e * 2;
		cout << e << endl;
	}
	return 0;
}

指针空值nullptr

C++98里的空指针

良好的C/C++编程习惯中,声明一个变量时最好给该变量一个合适的初始值,否则可能会出现不可预料的错误,比如未初始化的指针。如果一个指针没有合法的指向,我们基本都是按照如下方式对其进行初始化:

void TestPtr()
{
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)"<<endl;
}
void f(int*)
{
 cout<<"f(int*)"<<endl;
}
int main()
{
 f(0);
 f(NULL);
 f((int*)NULL);
 return 0;
}

程序本意是想通过f(NULL)调用指针版本的f(int*)函数,但是由于NULL被定义成0,因此与程序的初衷相悖。
在C++98中,字面常量0既可以是一个整形数字,也可以是无类型的指针(void*)常量,但是编译器默认情况下将其看成是一个整形常量,如果要将其按照指针方式来使用,必须对其进行强转(void *)0。

注意:
1. 在使用nullptr表示指针空值时,不需要包含头文件,因为nullptr是C++11作为新关键字引入的。
2. 在C++11中,sizeof(nullptr) 与 sizeof((void * )0)所占的字节数相同。
3. 为了提高代码的健壮性,在后续表示指针空值时建议最好使用nullptr。

这之后,我们就要开始学习C++的类和对象,我们一起加油把!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值