前言
C++入门专栏是为了补充C的不足,并为后面学习类和对象打基础。在前面我们已经讲解了命名空间、输入输出、缺省参数、重载函数等,今天我们将完结C++的入门。
下面开始我们的学习吧!
一、引用
1、引用是什么呢?为什么C++添加了引用?
(1)引用的概念: 引用是给已存在变量取了一个别名,不是重新定义一个新变量。编译器不会为引用变量开辟内存空间,它和它引用的变量一起用同一块内存空间。(就如西游记中孙悟空,也叫齐天大圣,你不管叫他那个名字,都是代表一个人。)
(2) 在C语言中,我们要改变一个变量的值可以取一个变量的地址通过解引用来改变,但是这是一种间接的玩法。总结:简单的说就是指针是间接的,而引用相当于它的别名还是它自己,更直接,更方便了。 所以以后我们大多都是使用引用了。下面让我们详细了解引用的魅力!
2、引用的语法
引用的语法:
类型& 引用变量名(对象名) = 引用实体。
tip:
(1)引用的理解:
①C++觉得添加太多新符号不太好,所以就会在某些地方共用一些符号。例如这里的‘&’。
②‘&’这个符号用法的区分:‘&’在变量名之前,代表取地址;‘&’在类型之后代表引用(取别名)。
③引用在语法逻辑上不会开辟新空间,它和它引用的实体共用一块内存空间。
④引用就是取别名,不管怎样还是共用一块内存空间。
⑤代码示例:
//引用的理解:
#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++的革命是不彻底的,引用并没有将指针完全替代)。
④代码示例:
//引用的特性:
#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类型不会改变,所以需要生成一个中间变量来进行类型转化。
⑤当引用的实体是一个常变量的时候,我们就要使用常引用,因为权限不可以放大。
⑥常引用的应用:常引用做参数——如果函数中只是使用参数,不改变参数的值,建议使用常引用。
⑦代码示例:
//常引用——引用的权限
#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)引用做参数
引用做参数的意义:
①做输出型参数:形参的改变要影响实参。
代码示例:交换两个整数
#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++里面我们可以使用引用实现。
②引用做参数,减少拷贝提高了效率。(特别是对于大对象和深拷贝类对象)
代码示例:
//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)引用做返回值
①引用做返回值的第一个意义:减少拷贝提高效率。
代码示例:
//①减少拷贝提高效率
#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:局部变量传引用返回
//局部变量传引用返回
#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:静态变量传引用返回
//静态变量传引用返回
#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位置值
//代码示例:静态顺序表获取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)语法层面: 引用不开空间,是对实体取别名;指针开空间,是存储实体地址。
代码示例:
//引用与指针的区别
#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:
#define Add(x,y) x+y
解读:Add(10,20) * 20,宏预编译进行替换为10 + 20 * 20,我们发现因为操作符优先级的问题,不是先加后乘。
错误形式2:
#define Add(x,y) (x+y)
解读:Add(1 | 2 , 1 & 2),宏替换后为:(1 | 2 + 1 & 2),位操作符的优先级低于算术操作符,所以错误。
正确形式:
#define Add(x,y) ((x) + (y))
tip:宏参数的求值是在所有周围表达式的上下环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多写括号。(可以替换看一看。)
(2)宏的优缺点:
优点: 不需要建立栈帧,提高效率。
缺点: 因为宏在预编译阶段进行了替换,所以①不方便调试;②代码可读性差,可维护性差,容易出错;③没有类型的检查等等。
(3)宏函数有这么多缺点,我们C++祖师爷就看不下去了,所以就有了inline内联函数。
2、内联函数
(1)概念: 以inline修饰的函数叫做内联函数,编译时C++编译器会在调用函数的地方展开,没有函数调用建立栈帧的开销,内联函数提升程序运行的效率。
代码示例:
//内联函数
#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被展开,就没有函数地址了,链接就会找不到。
代码示例:
//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修饰的。如下:
int a = 10;//自动存储类型
auto int b = 10;//自动存储类型
这样的话,auto没有用了。
C++11中: 标准委员会赋予了auto全新的含义即:auto不再是一个存储类型指示符,而是作为一个新的类型指示符来指示编译器,auto声明的变量必须由编译器在编译时期推导而得。
简单来说,在C++11中auto可以根据右边的表达式自动推导变量的类型
代码示例:
#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来帮我们自动推导变量的类型,就非常方便了。
代码示例:
#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与指针和引用结合使用
代码示例:
#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在同一行定义多个变量
当在同一行声明多个变量时,这些变量必须是相同类型,否则编译器将报错,因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量。
代码示例:
int main()
{
auto a = 1, b = 2;
auto c = 3, d = 13.14;//编译失败,因为c和d的初始化表达式类型不同
return 0;
}
4、auto不能推导的场景
(1)auto不能作为函数的参数
void TestAuto(auto a){}
此处代码编译失败,auto不能作为形参类型,因为编译器无法对a的实际类型进行推导。
(2)auto不能直接用来声明数组
void TestAuto()
{
auto a[] = {1, 2};
}
编译失败,auto不能声明数组,因为auto类型不能出现在顶级数组类型中。
四、基于范围的for循环(C++11)
1、范围for的语法
在以前(C++98),如果要遍历一个数组,我们是按照下面的方式实现:
#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的语法格式如下:
for(auto e : array)
for循环后的括号由冒号“:”分为两部分:第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。
现在我们使用范围for来遍历数组,修改数组:
#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的范围是不确定的
void TestFor(int arr[])
{
for (auto e : arr)
{
cout << e << endl;
}
}
解读:数组传参,实际只能接收数组的首元素地址,这里我们并不知道数组的范围,所以报错。
(2)迭代的对象要实现++和==的操作。(后期讲解,大家先知道即可)
五、指针空值nullptr(C++11)
1、C++98中的指针空值
在良好的C/C++编程习惯中,声明一个变量时最好给变量一个合适的初始值,否则可能会出现不可预料的错误,比如未初始化的指针。
如果一个指针没有合法的指向,我们就需要将其置为空。
代码演示:
int main()
{
int* p1 = NULL;
int* p2 = 0;
return 0;
}
为什么0也可以将指针置为空呢?
答案是:NULL实际是一个宏,在传统的C头文件(stddef.h)中,可以看到如下代码:
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
可以看到,NULL可能别被定义为字面常量0,或者被定义为无类型指针(void)的常量。
不论采用哪种定义,在使用空值的指针时,都不可避免的遇到一些麻烦,如下:
#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
先看一段代码示例:
#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)所占字节数相同。