文章目录
前言
一、实现C++版本的hello world
二、命名空间
1、namespace 的价值
2、namespace 的定义
(1.域会影响一个编译器编译语法时的查找规则
(2、域会影响生命周期
(3、命名空间域只能定义在全局
(4、编译器会自动合并相同命名空间中的内容
(5、C++标准库放在命名空间域std
3、命名空间的使用
(1、指定命名空间访问
(2、利用using 将命名空间中的某个成员展开(部分展开)
(3、利用using 将命名空间中的某个成员展开(部分展开)
三、C++ 的输入、输出
四、缺省参数
五、函数重载
六、引用
1、引用的概念和定义
2、引用的特征
3、引用的使用
4、const 引用
5、指针与引用的关系(面试重点)
七、nullptr
八、inline
总结
前言
路漫漫其修远兮,吾将上下而求索;
一、实现C++版本的hello world
C++兼容C语言绝大多数的语法,所以C语言实现的hello world 依旧可以运行,不过在C++之中需要将文件的后缀名改为 .cpp。需要注意的是,vs编译器看到.cpp 就会调用C++编译器,Linux 便要用g++编译器,不再是gcc;
代码如下:
当然,C++有着自己的一套输入输出,严格来说,C++版本的hello world 版本代码如下:
也正是因为C语言的输入输出存在巨大的缺陷(占位符的存在,只能针对特定类型的数据),所以C++重新整了一套输入、输出;
二、命名空间
1、namespace 的价值
在C/C++ 中,变量、函数以及后面会学习的类都是大量存在的,这些变量和类的名称将都存在于全局作用域中,可能会导致许多冲突。使用命名空间的目的就是对标识符的名称进行本地化,以避免命名冲突或者名字污染,namespace 关键字的出现就是针对这种问题;
- eg. 我们知道rand 是一个库函数,使用函数rand 未包含头文件 <stdlib.h> 便会报错,倘若包含了头文件<stdlib.h> 再使用rand 作为一个变量名这也是会报错的(学C语言的变量命名时的阶段便说过,关键字、函数名、类型不能作为变量名,其根本原因就是因为命名冲突);但是在C++ 中有了命名空间,就可以在命名空间中以关键字、函数名作为变量名;
命名空间最大的优势在于实际的协作中,每个程序员写代码命名风格可能会大差不差,此时如果有了命名空间,每个程序员写的代码封装在一个命名空间中,那么此时就不怕程序员之间命名相同而导致命名冲突的问题;
使用命名空间:
将变量rand 放在命名空间中,即使包含了头文件 <stdlib.h> 也不会报错;
2、namespace 的定义
- 定义命名空间,需要使用到namespace关键字,后面跟命名空间的名字,然后接一对{} 即可;{} 中即为命名空间中的成员。命名空间中可以定义变量/函数/类型等;
- namespace 本质是定义出一个域,这个域(命名空间域)跟全局域各自独立,不同的域之中可以定义同名的变量;(上面的例子便是这个原理)
- C++ 中域有局部域、全局域、命名空间域、类域,有了域的隔离,就可以很好地解决命名冲突的问题;域影响的是对编译时语法查找一个变量/函数/类型出处(声明或定义)的逻辑,局部域和全局域除了会影响编译查找逻辑,还会影响变量的生命周期,而命名空间和类域不会影响变量的生命周期;
- namespace 只能定义在全局,当然还可以嵌套定义;
- 项目工程中多文件中定义同名的namespace ,编译器会认为是同一个namespace ,会自动合并其中的内容,也不会冲突;
- C++标准库都放在一个叫std (standard) 的命名空间中;
注:之前在C语言时学习了局部变量与全局变量,其对应的就是局部域(更小的局部域:函数中的循环体)和全局域(函数外面的空间均叫做全局域);
(1.域会影响一个编译器编译语法时的查找规则
Q1.域究竟会影响什么?
- 域会影响一个编译器编译语法时的查找规则;
Q2.编译器的特点?
- 在用一个变量、函数、类型的时候,编译器的语法规定:必须知道该变量、函数、类型的声明或者定义;那么编译才能符合语法规则,才可编译通过;(在使用一个东西的时候,需要知道这个东西的出处、是在哪里声明的、哪里定义的);
Q3.编译器在查找平时的变量(or 函数 or 类型)的时候,在“域”这一概念中是如何查找的呢?
- 编译的默认查找规则:在没有指定”域“的情况下,编译器首先会在局部域进行查找,当在局部域没有找到的时候才会到全局域进行查找;(这也是同一变量名命名在同一域中报错的原因)
注:上面的例子中,利用函数名rand 当作变量名(变量rand 是全局变量),并且包含头文件<stdlib.h> 编译器便会报错;这是由于编译器在编译语法的时候,首先会在局部域中查找,没有找到便会去全局域中进行查找,然后编译器就发现在全局之中有两个”rand“ ,这两个”rand“ 一个是变量,一个是函数,编译器不知道究竟使用哪一个,故而报了”重定义“的错误;当将变量rand 放在namespace 的命名空间里的时候,编译器查找rand 的规则依然不变(编译器先是会去局部域中查找,当在局部中找不到的时候便会去全局中查找),但是倘若用域作用限定符指定了变量的域,那么就会改变编译器的查找规则,eg. ,编译器便会直接去命名空间域 zjx 中查找;
、
如上图所示( x64 环境下),第一个printf("%p\n" ,rand); 中的rand ,编译器首先会去局部域(此处体现为当前的main 函数)中查找,在局部域中没找到就回去全局域中查找,全局域中的rand 是一个函数,函数名为函数的地址,故而用%p 打印出来一串地址(此处举例用的是 x64 );
注:编译链接之中的阶段:预处理、编译、汇编、链接;在预处理阶段头文件 <stdlib.h> 会在所包含的文件中展开,故而变量rand 是在全局域中找到的;
综上,namespace 命名空间域也就是意味着默认情况下编译器不会到该域中查找数据,但是倘若为该变量(用域作用限定符)指定了域,那么就会改变编译器的查找规则;
(2、域会影响生命周期
域除了会影响编译器的查找规则,还会影响生命周期;
命名空间域和类域不会影响变量、函数、类型的生命周期;即在命名空间域、类域中的变量、函数、类型均为全局变量(存在于整个程序的运行期间);但是局部域和全局域会影响变量、函数、类型的生命周期;
注:生命周期:一个变量创建出来之后多久销毁;局部域中的变量在该局部域结束的时候销毁;全局域中的变量存在于整个程序的运行期间,当该程序结束的时候便会销毁;
总之,namespace 命名空间的存在,解决了C语言中”命名冲突“的问题;命名空间可以做到很好的命名隔离,让我们写的代码与库中的进行隔离,也可以与团队中的程序员的代码进行隔离;域的存在影响了编译时,编译器的查找规则(默认情况下,编译器有着自己的一套查找规则,当你指定了所要查找的域以后,编译器首先便会去你指定的域中进行查找);
(3、命名空间域只能定义在全局
需要注意的是,命名空间域只能定义在全局,不可以在局部域中定义命名空间域;命名空间域是可以嵌套定义的,如下图:
使用命名空间的结构体时,zjx::hello:: 并不是指定在struct 之前,而是指定在结构体的便签名之前,如下图:
(4、编译器会自动合并相同命名空间中的内容
在项目之中会用到许多文件,一个程序员可只是用一个命名空间就可以与团队中的其他程序员的代码进行隔离,而一个程序员会写多个文件,而在多个文件中是可以只使用一个命名空间(只要保证程序员之间的代码相互隔离便可);
(5、C++标准库放在命名空间域std
而至于C++标准库放在命名空间域std 之中而也是为了避免别人的代码与自己的代码相撞,如下图:
3、命名空间的使用
在命名空间中的变量不可以直接使用,如下图:
使用命名空间中定义的变量、函数有三种方式:
(1、指定命名空间访问
在项目中最推荐使用这种方式,如下图:
但是有时候也会存在问题;
例如:当我们写一个程序、算法的时候,要使用域中的成员时(尤其时标准库std 中的),每次使用均要指定命名空间访问,这样做写起来非常麻烦,如下图:
(2、利用using 将命名空间中的某个成员展开(部分展开)
在项目中经常访问的成员推荐使用这种方式;
注:此处所述的展开并非头文件的展开(头文件的展开:编译时将头文件中的内容拷贝过来,放在此处)而命名空间的展开是指影响编译器的查找规则(编译器的默认查找规则:先去局部域中进行查找,当在局部域中找不到的时候便会去全局域中进行查找,当在全局域中也找不到的时候便会报错)使用了using 之后,编译器可以到被展开的命名空间、命名空间成员中进行查找
(3、利用using 将命名空间中的某个成员展开(部分展开)
在项目之中不推荐这么做,冲突的风险很大,因为命名空间的展开就意味着命名空间没有名字隔离了,如果团队中的程序员将自己的命名空间展开,有可能就会发生命名冲突;另外一般在日常的练习之中可以这么做;
注:在leetcode 上做题,无需指定命名空间,是因为该命名空间中的全部成员均被展开了;
三、C++ 的输入、输出
注:IO流涉及了很多类和对象、运算符重载、继承等很多面向对象的知识,本篇文章就着其简单用法来讲解,更详细地阐述可以见后面的文章;
相关知识点:
- <iostream> 是 Input Output Stream 的缩写,是标准的输入、输出流库,定义了标准的输入、输出对象;
- std::cin 时istream 类对象(可以简单将“类”想象为“结构体”以利于理解),它主要面向窄字符(narrow characters (of type char),窄字符--> 与编码相关) 的标准输入流;
- std::cout 是ostream类的对象,它主要面向窄字符的标准输出流;
- std::endl 是一个函数,流插入输出的时,相当于插入一个换行字符+刷新缓冲区
- << 是流插入运算符, >> 是流提取运算符。(注:在C语言中这两个运算符还作为左移、右移);
- 使用C++ 输入、输出更加方便,不需要像printf/scanf 输入输出那样,需要手动定义格式,C++的输入输出可以自动识别变量类型,其自动识别的本质是通过函数重载(“函数重载”这个概念后面的文章会提及),其实最重要的是C++ 流能更好地支持自定义类型对象的输入输出;
- cout/cin/endl 等都属于C++标准库,C++标准库都放在一个叫std (standard) 的命名空间中,所以要通过命名空间的使用方式去用他们;在日常练习中,我们可以直接用 using namespace std 来直接展开标准库(在实际项目开发中不建议直接去展开标准库!)
- 此处我们并没有包含<stdio.h> , 也可以使用printf 和 scanf ,这是因为<stdio.h> 在 <iostream> 中间接包含了;在VS系列的编译器是这样的,但是由于编译器的实现存在差异,在其他编译器这样做就可能会报错;
C语言有自己的一套输入、输出(printf 、scanf),C++也有自己的一套输入、输出,故而我们在使用c++ 时需要包含一个头文件 <iostream> ;
注:iostream: input output stream
Q1: C++ 为什么要搞属于自己的输入输出呢?
- 与类和对象有关;主要是因为C语言中的printf 与 scanf 有缺陷:只能输入、输出内置的基本类型的数据(输入输出时要用上对应类型的占位符导致了其局限性);在C++ 之中确实也可以使用printf 与 scanf ,但是C++ 之中还有自定义类型的概念(自定义类:类、联合体定义的其他的更加复杂的类型),故而C++ 弄了一个IO流(即iostream),当你重载了运算符,便可以输入输出任何类型的对象(变量);综上,C++ 之所以另外搞了一套自己的输入输出是源于C语言存在的局限性;
Q2:C++ 的头文件为什么没有带 .h ?
- 早期是带 .h 的,但是那个时候并没有命名空间,故而带.h ;(C++ 的头文件带 .h ,可以在很古早的软件上使用,eg. vc 6.0 --> <iostream.h>) ;但是当C++ 有了命名空间的概念之后,将C++的标准库包含到了命名空间中,为了做好过度和兼容,于是在新版本中就干脆用命名空间包含过的,于是便将头文件中的 .h 给去掉了;
cout 可以打印任何类型数据的本质:任何类型的数据可以将其转换成char 的字符串类型,然后就会输入到cout 中(一般输入到控制台中,在Windows 中称为控制台,在Linux 中称为终端),例子如下:
注:此处也还可以继续使用printf 与 scanf 是因为在VS编译器中间接将其包含了;但是在不同的编译器中,情况不一样,例如在Linux 中printf 与 scanf并没有被间接包含;
Q3.倘若想换行,又该如何操作?
- 利用换行符('\n') 就可以了,也可以使用函数endl(等价于 '\n' + 刷新缓冲区)
使用如下:
也正是因为使用的时候像“流水”一样,故而称<< 为流插入,cout 为ostream 对象,例子如下:
Q4.倘若想控制浮点数后两位的输出又该如何做呢?
在C语言中用的是 .2f -->
- 由于C++兼容C语言,实际上也可以照用C语言的方法(怎么方便怎么来);C++也有自己的方式,利用width
使用如下:
同理,对于cin + >>(流提取), 将从键盘上读取的数据“字符串”转换成对象对应的类型放入对象之中,例子如下:
sum:任意类型的数据在输出的时候均可将其转换成字符串然后再输出到对应的流之中。
注:在打竞赛中如果使用cin 与cout 可以增加以下三行代码以避免极端情况
//在io 需求比较高的情况下,添加以下三行代码可以提升C++ IO 效率
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
如果该测试用例和输出的结果都比较多,那么cout 与cin 的效率就没有printf 与scanf 的效率那么高(在后面的学习中便会知道,cout 与 cin 其实就是一些函数调用,因为要走流插入、流提取),所以如果要用C++的输入、输出写算法代码过不了的时候,可以尝试添加以上的三行代码;上述的三行代码可以取消C++ IO流之间的一些绑定与同步;
四、缺省参数
- 缺省参数是声明或者定义函数时为函数的参数指定一个缺省值。在调用该函数的时候,如果没有指定实参则采用该形参的缺省值,否则使用指定的实参,缺省参数分为全缺省和半缺省参数。(注:有些地方把缺省参数也叫默认参数)
- 全缺省就是全部形参给缺省值,半缺省就是部分形参给缺省值。C++规定半缺省参数必须从右往左依次连续缺省,不能间隔跳跃给缺省值;
- 带缺省参数的函数调用,C++规定必须从左往右依次给实参,不能跳跃给实参;
- 函数声明和定义分离的时候,缺省参数不能再函数声明和定义中同时出现,规定必须函数声明给缺省值;
从上面的例子中,我们大概就清楚:倘若一个函数含有缺省参数,当我们没有传参的时候,形参就会使用其缺省值;传了参,形参便为实参(分传值、传址);
Q1:那么缺省参数(默认参数)在此处有什么好处呢?
- 在前面我们学习用数据结构设计栈的时候,如果我们并不知道要插入多少个值得时候,那么在我们不断插入得过程中便会不断地扩容,而扩容是有消耗的(扩容的消耗:尤其是异地扩容,会开辟一段新的空间然后再将旧空间中的数据拷贝放入新空间中,最后再释放旧空间)。但是如果我们明确地知道要插入多少个数据,便可以直接扩容,以减少消耗;
如何实现想传参就传参不想传参就是用默认值呢?使用缺省参数;
其中需要注意的是:当使用缺省参数的函数的声明与定义分离的时候,缺省参数不能在函数的声明与定义中同时出现,规定必须在函数声明的时候给缺省值;因为如果在声明或者定义同时出现缺省值,编译器会报错,因为在两个地方给缺省值可能会给的不一样,为了杜绝这种情况的出现,编译器就直接把给两个缺省值的情况扼杀的摇篮之中。例子如下:
缺省还分为全缺省与半缺省
顾名思义,全缺省就是函数的参数全为缺省参数,半缺省就是函数的参数部分为缺省参数;
对于半缺省,C++有规定:必须从右往左依次给连续的缺省参数,不能间隔跳跃给缺省参数;
调用带缺省值的函数的时候,C++规定:必须从左往右依次给实参,不能跳跃给实参;
此处需要注意的是,不要与C语言中的“压参数”的概念搞混,此处的缺省参数仅仅只是语法层的体现;而“压参数”:函数调用、(栈帧中)实参将值传给形参会有压参(“汇编层”)这个概念,从左往右压参还是从右往左压参均可以,取决于平台;
例子如下:
虽然我们此处给的是缺省参数,但是在实际中编译器识别该函数是这样识别的:(编译器处理之后)形参需要多少实参便会传多少个参数(只是该参数是用你实际给的实参还是缺省值作为形参罢了);
五、函数重载
C++支持在同一作用域中出现同名函数,但是要求这些同名函数的形参不同,可以是参数的个数不同或者类型不同。这样C++调用就表现出了多态行为,使用更灵活。C语言是不支持同一作用域中出现同名函数的;
在学习C语言部分时,由于不能支持同名函数的存在,在某些方面就很不方便;eg: 想写一个变量交换等类似的函数,用C语言写的话,我们就要针对不同类型的变量写出不同类型的函数;这是非常麻烦的重复性工作!并且函数名不同,对应不同类型的数据去调用对应的函数也十分麻烦,如下图所示:
C++中函数重载如下:
从上例可知,当同名函数的函数参数类型不同、参数个数不同、参数的顺序不同,就可以构成函数重载;函数名相同看似调用了同一个函数,实则不然,编译器会通过其参数在编译的时候“智能”地进行匹配,而去调用对应的函数;
可能你还会想,函数的返回类型不同是否可以作为函数重载的条件,这是不可以的,如下图:
即使函数有返回值,在函数调用的时候也可以不用变量来接收其返回值(返回值并不是必须的),故而函数名相同但是仅仅只有函数返回值不同的函数不会构成函数重载,编译器无法区分这两个函数就会构成重定义问题;
注:严格来说,函数重载对函数的返回值是没有要求的,构成函数重载的函数其返回值可以相同也可以不相同,关键看的是参数类型、个数与顺序;
其中还需要注意的一个点:缺省参数的应用;
当不传参的时候,上例中的两个函数的参数个数相同在使用的时候就会发生歧义,编译器不知道究竟要调用哪一个函数;函数重载时参数若为缺省参数的时候,需要注意这一点!
在上文讲解输出 cout 时:使用cout 无需指定数据的类型,因为cout 可以自动识别类型,其自动识别的本质其实就是函数重载的实现;
也就是说上例中的 cout << 10; cout << "111111"; 会去分别调用不同的函数;(在后面的“类和对象”部分会详细讲解“运算符重载”,运算符重载会转换成调用对应的函数,其自动识别类型的本质就是因为构成了函数重载)那么这个“函数”究竟是什么呢,见下图:
sum: 函数重载就是,编译时通过参数类型、参数个数(参数的顺序实际上也可以归为参数的个数不同)去识别、匹配而自动调用对应的类型的函数;其中“自动调用对应类型的函数”的行为可以看作是多态行为;需要注意的是,函数的返回值不同 (函数名、参数类型、参数个数均相同,仅仅只是函数的返回值类型不同) 不可以作为函数重载的条件;
六、引用
1、引用的概念和定义
引用不是新定义一个变量,而是给已经存在变量取了一个别名,编译器不会为引用变量开辟内存空间。它和它引用的变量共用一块内存空间;
类型& 引用别名 = 引用对象
注:
- C++中为了避免引入太多运算符,回复用C语言的一些符号,比如前面学习的 >> 以及 << , 此处的引用也和C语言取地址使用了同一个符号:&;
- 引用时各个面向对象语言均有的,在Java 、Python 之中也有对应类似的“引用”,或多或少抄了C++的“作业”,C++ 的引用与其他语言的引用有所不同;
使用例子如下:
在上例中,引用取别名的特点:在语法层编译器并没有为引用变量创建一个新的空间
2、引用的特征
- 引用在定义时必须初始化
- 一个变量可以有多个引用
- 引用一旦引用一个实体,再不能引用其他实体(此点与Java 不同,在Java 之中可以改变引用的指向,但是C++ 不可以)
3、引用的使用
- 引用在实践中主要是于引用传参和引用做返回值中减少拷贝提高效率和改变引用对象时同时改变被引用对象(相较于传址调用,改变形参也可以改变实参);
- 引用传参跟指针传参功能时类似的,引用传参相对更加方便一些;
- 引用返回值的场景相对比较复杂,本篇只做简单的介绍,更加深入的探索会在后文的“类和对象”之中;
- 引用和指针在实践当中相辅相成,功能有重叠性,但是各有特点,互相不可替代;C++的引用跟其他语言的引用(如Java)是有很大的区别的,除了用法,最大的特点是:C++引用定义后不可以更改指向,但是Java 的引用可以更改指向;
- 需要注意的是,在一些主要用C代码实现版本数据结构的教材中,常使用C++中的引用替代指针传参,其目的就是简化程序,避开较为复杂的指针;
例如:在以前写交换变量的swap 函数时,需要传址传参,而在C++ 中可以用引用来替代传址传参的用法;引用的写法相较于传址传参会简洁一些,如下图:
从上图中可以显然地看到,利用C语言中指针的写法,在写法上面是有点麻烦的,并且很容易失误(取地址传参、对指针进行解引用操作等);倘若利用c++的引用以及函数重载就可实现“自动匹配”不同类型的数据,如下例:
我么可以简单将引用的作用理解为指针,其二者的区别在于传参的时候是否需要取地址;其中需要注意的是,指针变量也是变量,所以也可以使用引用给指针起别名,如下例:
类型& 引用别名 = 引用对象;
传值返回返回的并不是这个对象,C/C++规定,如果传值返回的对象很小的话,通常会将值拷贝给给一个临时对象/寄存器:(以栈返回栈顶的数据为例:)
Q1:什么是临时对象?
- 临时对象是编译器在栈帧等上临时开辟的空间的对象(变量), 临时对象具有常性,不可修改;严格来说,因为临时对象、寄存器均属于右值(右值即不可更改的值),所以不可以修改;
小结:
- 传值调用:实参传递给形参,形参是实参的临时拷贝
- 传值返回:传值返回返回的并不是这个对象本身。返回的是这个对象的临时对象的拷贝;
Q2:为什么传值返回返回的并不是这个对象呢?为什么要这样设计?
- 这样设计是出于安全考虑;假设返回一个局部变量本身,显然是不合理的;当局部变量出了作用域便会销毁,此时倘若还去访问被销毁的空间,就是越界野指针的问题;
如果想要改变函数返回值,就不要使用传值返回而是使用传址返回或者是引用返回;
引用返回的特点:不会再生成一个临时对象,而是生成了一个别名进行返回:(以栈返回栈顶的数据为例,栈的空间是在堆上开辟的,故而返回栈顶数据的空间就是返回堆上的空间)
引用返回和传址返回的效果一样,只不过写法不同;
在使用引用的时候还需注意引用对象的生命周期,就像指针一样,倘若在函数内部返回一个局部变量的指针,那么出了其作用域,该指针就会变成野指针,不可以访问这个指针指向的内容;同理,对于引用来说也是如此,使用引用返回的时候,需要注意该空间的生命周期,即关注这个函数结束之后该空间是否还存在;例如下图,就是错误的使用:
Q3:为什么上面的这个例子编译器不报错,而只是警告?为什么编译器不做严格的检查?
- 因为编译器是不同人来实现的,难免就会出现有些地方实现地比较严格,而有些地方实现得就没有那么严格;并且越界,编译器是不一定会报错的;因为编译器对于越界的检查属于一种抽查,只是在数组的位置设计了一些标志位去检查,由于不同编译器的实现方式不同,一般情况下,数组的越界是这样检查的:会在数组结束的位置设置标志位,而编译器的不同,其设置的标志位的数量可能不同;
当程序结束的时候(main 函数结束),编译器便会看一下设置的这些标志位中的数据是否被修改,修改了就说明发生了越界;没修改就说明没有越界。故而当一个数组越界了,但是越界的空间并不是设置的标志位的时候,此时编辑器就无法检查不出越界的错误,像是一种”抽查“行为;就像查酒驾一样,酒驾很危险,但是交警也没有办法把所有人都盯着,也不好都不管,那么就会选择一种抽查的方式:晚上xx点在xx地点进行抽查酒驾,如果酒驾的人从这条路上开过,就会被查到,如果没走这条路,那么就不会被查到;
(这一点知识可以仅作了解)引用做返回值在C++之中有大用处,就拿后面会学习的运算符重载为例子,eg. vector 重载的 operator[ ] :
Q4:C++ 之中的引用能否替代指针?能否有了引用之后便不再使用指针了呢?
- 不能。因为本贾尼博士在设计引用的时候就没有想过要让引用去替代指针,而是想让引用和指针处于一个平衡的共存关系,在使用指针较为复杂的场景中让引用去替代。但是它们二者各自是相辅相成的;引用不可以替代指针比较重要的原因:引用不可以更改指向(在C++中引用一旦指向一个实体,就不能改变其指向);
在C++中其参数、返回值等均使用了引用不再用指针(不是因为指针不能用了,而是因为引用比指针更好用)但是有一个场景之下必须使用指针而不能使用引用,定义数据结构时只能用指针,例如定义链表:
由于 引用不可以改变指向,倘若结点中存放的是引用,那么就不能完成删除、插入等会改变结点链接的操作;
注:由于历史发展的原因,C++ 有指针的同时也有引用;而Java语言出现得比较晚,并且在Java 之中并没有指针这一概念,所以Java的引用 与C++ 的引用有所不同,Java的引用可以改变指向;
其次,动态内存管理也是需要使用指针的;
4、const 引用
- 可以引用一个const 对象,必须用const 引用。const 引用也可以引用普通对象,因为对象的访问权限在引用过程中可以缩小,但是不可以放大;
- 不需要注意的是类似:(如下例:)
- a*3 是一个表达式,其结果存放在一个临时对象之中,而临时对象具有常性,而对于 int& rd = d; 来说,会进行隐式类型转换,而隐式类型转换会产生临时变量,所以说上例中的 rb 与 rd 都是对临时对象的引用,而临时对象具有常性,倘若直接用int& 便会将权限放大,故而此处应该使用const 引用,修改如下图:
注:权限的缩小与放大只涉及指针和引用,普通对象不涉及;
- 所谓临时对象就是编译器需要一个空间暂存表达式的求值结果时创建的一个未命名的对象,C++把这个为命名的对象叫做临时对象;
同理,对const 对象取别名,由于const 对象本身就是不可以修改的,那么其别名就不能放大它的权限,即需要加上const 修饰const 对象的别名;
注:权限不可以放大,但是可以平移;
同样的,权限可以缩小,如下例:
此时你可能会有疑问了,那么 const int a = 10; 那么 int rra = a ; 又是什么呢?
- 这并涉及权限的放大,这是将对象a 中的数据10 拷贝放入对象rra 的空间当中,即此处是赋值拷贝;普通对象并不涉及权限的放大与缩小;引用本身就没有创建额外的空间,是为被引用对象取了一个别名,由于 ra 是对象a 的别名, 故而ra 要与 a 的权限保持一致(ra 也可以缩小权限);而普通对象 (eg. rra ) 是创建新的空间来存放对象a 中的数据,即赋值;
注:对于引用来说涉及权限的放大、缩小与平移,那么对于指针来说也是同理;
指针和引用都是在维护一块空间,所以就得对指针或者引用有所要求;
- 在这块空间上不可做的,指针、引用一定不可以做(权限不可以放大);
- 在这块空间上能做的,指针、引用可做可不做(权限可以平移、缩小);
也可以使用const 修饰的引用(or 指针)去引用(指向)常量、表达式,这相当于是权限的平移(因为常量、表达式不可被修改,常量放于只读的常量区,而表达式需要一个临时对象/寄存器来存其结果,而临时对象(寄存器)具有常性);
同样的,隐式类型转换的数据因借助于临时对象也具有常性,例子如下:
隐式类型转换原理:
注:现已知,类型转换、传值返回、运算表达式均会产生临时对象;
Q:const 修饰的引用有什么用?
- 由上文可知,使用引用、指针时,切记权限不可以放大,但是可以缩小、平移;那么应用于函数传参,形参倘若没有加const ,那么实参就不可以传具有常性的参数(例如,常量、表达式),但是倘若形参加上了const 那么实参传参就没那么受限制,这就是为什么在很多的函数中不修改实参,但是喜欢使用引用、指针喜欢加上const 的原因,如下图stack 中的 push:
前文也提及过,使用引用传参可以减少拷贝提高效率(以后学习了模板之后,就知道引用有多香了),const 引用传参(前提:并不想改变实参)的好处:可传普通变量、常量、表达式、类型转换产生的临时对象;
5、指针与引用的关系(面试重点)
- C++ 中指针和引用就像两个性格迥异的亲兄弟,指针是哥哥,引用是弟弟,在实践当中他们相辅相成,功能有重叠性,但是各自有各自的特点,互相不可替代。
- 语法概念上引用是一个变量的别名,无需开辟重新开辟空间;指针存储的是一个变量的地址,要开空间;
- 引用在定义时必须初始化;指针建议初始化,语法上并不是必须的;
- 引用在初始化时引用一个对象后,就不能再引用其他对象了(引用不可以改变指向);但是指针可以不断地改变指向对象。
- 引用可以直接访问指向地对象,而指针需要解引用才可以访问指向地对象;
- sizeof 中含义不同,引用结果为引用类型的大小;但是指针始终时地址空间所占字节个数( 32 位平台下,占4 byte ,64 位平台下,占 8byte)
- 指针很容易出现空指针或者野指针的问题,但是引用很少出现(也存在野引用,只不过相较于指针来说,引用的问题更少出现),引用使用起来相对更安全一些;
注1:除了数据结构中会用指针(需要改变指向),其他地方如果用指针与引用都可以解决的话,推荐是用引用;
注2:引用也不是绝对安全,引用相当于指针较为安全因为引用必须初始化,不能改变指向;
Q1:如何理解在语法层面上,引用并未开辟空间,指针会开辟额外的空间?
对于汇编分析如下:
注:lea : load effictive address 加载有效地址
在汇编层并没有引用的概念,上层引用的语法在汇编层用指针实现;需要注意的是,我们说在语法层上引用并未开辟空间是利于理解,实际上引用的底层还是会开辟空间的(引用的本质是指针);
七、nullptr
NULL实际上是一个宏,在传统的C头文件 (stddef.h),我们可以看到如下代码:
注:在C++中会默认添加 __cpluscplus 这个标识符
从上图的条件编译中可以得知,在C语言中,NULL为 ((void*)0) 即空指针,而在C++ 中NULL为0;
- C++中的NULL可能会被定义为字面常量0 , 或者C 中被定义为无类型 (void*) 的常量。不论采用何种定义,在使用控制的指针时,都不可避免的会遇到一些麻烦。倘若本来想通过f(NULL) 调用指针版本的 f(int*) 函数,但是由于NULL被定义为了0,调用f(int x) ,因此与程序的初衷相悖。f((void*)NULL) 的调用会出错;
- C++11 中引入nullptr, nullptr 是一个特殊的关键字,nullptr 是一种特殊类型的字面常量,它可以转换成任意其他类型的指针类型;使用nullptr 定义空指针可以避免类型转换问题,因为nullptr 只能被隐式地转换为指针类型,而不能被转换成整数类型;
在语法层面上,nullptr 可以自动识别类型转换成任意类型的指针;总之,nullptr 的存在可以避免类型转换的问题,也可以避免匹配整形的问题;在C++ 之中表示空指针使用nullptr ,而不再使用NULL;
下例当中,传NULL作为参数,当同时出现形参为整形,以及指针时,匹配就会出错:
在C语言中,NULL 也有坑,在C语言中写NULL 会被宏替换成 ((void*)0) 如下:
八、inline
inline 是实现内联函数的一个关键字;
- 用inline 修饰的函数叫做内联函数,编译时C++ 编译器会在调用的地方展开内联函数,这样调用内联函数就不需要创建栈帧,可以提高效率
- inline 对于编译器而言只是一个建议,也就是说,即使加了inline 编译器也可以选择在调用的地方不展开,不同编译器关于inline 什么情况展开各不相同,因为C++ 标准并没哟规定这个。inline 适用于频繁调用的短小的函数,对于递归函数,代码相对多一些的函数,加上inline 也会被编译器忽略;
- C语言实现宏函数就会在预处理时展开,但是宏函数实现很复杂的功能就很容易出错,并且由于宏函数的特性不利于调试,C++中设计了inline 的目的就是替代C语言的宏函数;
- VS编译器debug 版本下面默认是不展开inline 的,因为这样方便调试,debug 版本想要展开inline 函数需要设置以下两个地方(下文中会提及)
- inline 不建议声明和定义分离到两个文件中,分离会导致链接错误;因为inline 被展开,就没有函数的地址,链接的时候会出现报错;
注:C语言中的宏本质上是一种替换,可以定义常量也可以定义宏函数;用宏定义函数的例子如下:这是正确的宏函数的实现,但是一不小心就会写错,如下:
Q1: 为什么宏函数不能加分号、实现部分需要外面、里面都加上括号?
- 不能加上分号是因为,宏的本质是替换,如果写做:#define ADD(a , b) a+b; 就会在出现ADD(a,b) 的地方替换成 a+b; 多了一个分号,要么会在原语句上形成空语句,要么就会报错;而在外面加上括号是为了保证运算的优先级,里面加上括号也是缘于要保证运算优先级,因为a 、b 有可能是一个表达式;
正是因为宏函数存在这么多的细节问题,并且预处理阶段就完成了宏替换就不可以进行调试;而直接创建普通函数,再调用函数的时候:传参、函数栈帧的开辟,然后执行相关计算、返回值带回(如果这个函数有返回值的话) ,相较于宏函数来说,普通函数的调用在时间、空间上有了多的消耗;并且普通函数必须声明为特定的类型,而宏的参数与类型无关,这也就决定了函数只能在类型合适的表达式上使用,而宏适用于可用于计算该式的所有类型;
能否将宏函数与普通函数中和一下呢?于是乎在C++ 中创建了一个新的关键字 —— inline (内联函数),可以将inline 内联函数当作宏函数的优化版本;
inline 内联函数完美地解决了宏函数的缺点:
- 1、是否加分号问题
- 2、操作符优先级问题
- 3、宏写起来容易出错
而内联函数不易出错的原因:inline内联函数就是在普通函数的返回值类型前加一个inline 关键字,如下:
其次需要注意的是,inline 对于编译器来说,只是一个建议;
Q2:如何体会inline 只是一个建议呢?
- 代码被编译了之后会形成汇编指令,那么函数被编译结束之后也会形成汇编指令;倘若想调用函数,便会有一条call 指令(实际上还有jump 指令),也就是说如果inline 内联函数被展开就没有call 指令了,因为完成了替代;
注: call 指令本身是一个跳转指令
例子如下:
函数的本质:函数名为函数的地址;此处加上了inline 但是仍然没有其作用,还是以普通函数调用的方式完成相关功能?
首先,我们要确保我们的配置是正确;vs 编译器debug 版本下面默认是不展开inline的(debug 版本本来就是用来调试的,如果将linline 展开,那么),因为这样方便调试,倘若想在debug 版本下展开,需要如下图的操作:
视图-> 属性页 -> 常规 -> 调试信息格式 -> 程序数据库(/Zi)
-> 优化 -> 内联函数扩展 -> 只适用于 _inline(/Ob1)
此时,我们再调试,可以发现:
需要注意的是,如果inline 内联函数的行数比较多(大概10行以上,标准并未定义,不同的编译器是不同的)或者为递归,编译器就会忽略inline ;
Q3:为什么inline 内联函数中的代码太多了就不会展开?
- 多次展开会增加代码的行数。假设inline 内联函数如果展开有100条左右的指令(汇编指令),如果该函数被调用了一万次左右,那么此时展开的话就会有10000*100 条指令,但是以函数调用的方式,则会有10000+100 条指令;而这些编译的指令最终会变成可执行程序;显然,当函数的代码行数比较多的时候,相较于展开,直接调用会更好,就可以避免代码膨胀(让可执行程序的体积变大);而代码行数比较少的函数,倘若多次调用似乎也会导致代码膨胀,但是行数比较少相较于代码行数多的来说,较为可控;
Q4:可执行程序变大了又会有怎么样的影响?
- 我们所下载的App,安装包等下载的都是可执行程序;安装包的本质:将这程序的各种编译好的东西进行进一步的压缩;所以当我们下载了安装包之后,主要进行解压,当然,还会对这些软件进行配置等。倘若让安装包变得很大,会影响用户体验;
例如:王者荣耀进行更新,如果往常得更新都是IGB左右,而现在更新突然就需要10GB,是不是非常影响使用得心情和体验感
总之,inline 设计地非常合理,将最后地决策交给了编译器,以防止程序员乱用;
至于inline 的声明和定义不能分布到两个文件中去,否则就会发生链接错误;因为inline 被展开是没有函数地址的(准确来说,inline 内联函数也是有地址的,本质上没有放进符号表中,所以就无法在多个文件中进行链接;注:这点解释可以参考博主之前写的编译、链接的博文),使用例子如下:
Q5:为什么inline 内联函数的声明和定义分离到两个文件中会报错?
- 在预处理的时候,会将头文件拷贝放到 .cpp 中包含了这个头文件的地方完成替换;函数F没有地址(无定义就没有函数的地址),因为在Test.cpp 中只包含了F.h ,而在F.h 之中只有函数F的声明而没其定义;而F(10); 是需要一条 call 指令跳转到F函数的地址处,去执行F函数的代码,而这个函数没有地址,在执行的时候编译器不会报inline 声明与定义分离的错误(编译器只是检查了该函数的语法使用是否正确、传参是否正确,检查有没有该函数的声明,判断该函数是否存在)而是报找不到该函数的错误;
链接会将不同的目标文件(.obj / .o 文件)合并在一起生成可执行程序;
在编译期间会将符号进行汇总,而在汇编期间形成符号表,在链接阶段会合并符号表并进行重定位;这些操作就是为了在链接期间能够跨文件找到函数 --> 在符号表中去查找(符号表的存在得以实现跨文件使用)
Q6:为什么inline 内联函数不会被汇总到符号表中?
- inline 内联函数会被定义为无需链接,因为内联函数均是在调用的地方完成替换,即无需调用函数(知道函数的地址)也可以使用该函数,故而也就没有必要放到符号表中;
将声明与定义放在同一文件中,如下例:
声明函数F 为内联属性,那么编译器在看待F的定义的时候就会把F函数当作内联函数,知道内联函数会在其调用的地方展开,于是就没有将这个函数的地址汇总放到符号表中;
其次是,函数的定义就是特殊的函数声明,inline 内联函数还可以这么写:
内联函数建议放在 .h 文件中;需要注意的是,普通函数不可以这么做,否则就是函数重定义了,或者普通函数放在 .h 文件中但是加了static 修饰, static 修饰一个函数可以改变这个函数的外部链接属性,让这个函数只在当前文件可见,本质上就是让这个函数不进入符号表;
注:.h 文件会在 .cpp 文件中展开,倘若在多个 .cpp 文件中包含了普通函数的定义的 .h 的话,此时编译器就会报重定义的错误;这是因为在链接期间会将多个 .obj/.o 文件中的符号表全部汇总,如果出现两个相同的函数,此时就会报重定义的错误;
小复习:
sum: 调用普通函数(反复执行的函数,只有一个地址)时需要生成一条call 指令,建立一块栈帧;而对于内联函数(直接在原地展开,无需开辟栈帧,直接计算)来说,当它被展开之后所在的地址与其定义的内联函数的地址是不同的;
总结
1、使用命名空间的目的就是对标识符的名称进行本地化,以避免命名冲突或者名字污染
2、C++ 中域有局部域、全局域、命名空间域、类域,有了域的隔离,就可以很好地解决命名冲突的问题;
3、域影响的是对编译时语法查找一个变量/函数/类型出处(声明或定义)的逻辑
4、编译的默认查找规则:在没有指定”域“的情况下,编译器首先会在局部域进行查找,当在局部域没有找到的时候才会到全局域进行查找;
5、局部域和全局域除了会影响编译查找逻辑,还会影响变量的生命周期,而命名空间和类域不会影响变量的生命周期;
6、命名空间的使用:指定命名空间访问、利用using 将命名空间中的某个成员展开(部分展开)、利用using 将命名空间中的某个成员展开(部分展开)
7、函数endl(等价于 '\n' + 刷新缓冲区)
8、缺省参数是声明或者定义函数时为函数的参数指定一个缺省值。在调用该函数的时候,如果没有指定实参则采用该形参的缺省值,否则使用指定的实参,缺省参数分为全缺省和半缺省参数。
9、函数声明和定义分离的时候,缺省参数不能再函数声明和定义中同时出现,规定必须函数声明给缺省值;
10、当同名函数的函数参数类型不同、参数个数不同、参数的顺序不同,就可以构成函数重载;
11、不可以将函数的返回类型作为函数重载的条件
12、引用不是新定义一个变量,而是给已经存在变量取了一个别名;类型& 引用别名 = 引用对象
13、引用取别名的特点:在语法层编译器并没有为引用变量创建一个新的空间
14、引用在定义时必须初始化、一个变量可以有多个引用、引用一旦引用一个实体,再不能引用其他实体
15、sizeof 中含义不同,引用结果为引用类型的大小;但是指针始终时地址空间所占字节个数( 32 位平台下,占4 byte ,64 位平台下,占 8byte)
16、nullptr 只能被隐式地转换为指针类型,而不能被转换成整数类型;
17、用inline 修饰的函数叫做内联函数,编译时C++ 编译器会在调用的地方展开内联函数,这样调用内联函数就不需要创建栈帧,可以提高效率
18、inline 对于编译器而言只是一个建议,对于递归函数,代码相对多一些的函数,加上inline 也会被编译器忽略;