十七、函数指针、lambda表达式、名称空间
1、函数指针
这里我们讲的是原始风格的函数指针(raw function pointer),也就是来自C语言的函数指针,但是这里将展示用C++的方式来使用函数指针。
函数指针在【C++】深度理解C++数据类型:常量、变量、数组、字符串、指针、函数_c++ 字符串常量-CSDN博客 中的函数部分是有涉及到的,感兴趣的同学可以找找。
函数指针是将一个函数赋值给一个变量的方法。所以函数指针的本质是一个指针,这个指针指向的是一个函数。
使用函数指针,我们可以轻松的把函数赋值给变量,也可以把一个函数作为参数传递给其他函数等实现一些更加复杂的逻辑。
可见调用函数不仅可以使用传统的调用方式(上图2),还可以使用函数指针来调用函数(上图3、4、5)。
上图B处比A处多了一个取址符号。所以B处的取址符号是可以省略,因为这里有隐式转换。
取址符号其实就是得到这个函数的内存地址。当我们编译源代码时,我们源代码中写的函数就被编译成二进制指令,放在二进制文件(也就是可执行文件)中的某个地方。所以函数的本质其实就是cpu指令。所以这个取址符号就表示:在可执行文件中,找到func1这个函数。也就是找到func1函数对应的指令的内存地址,跳到这个地址去执行里面的指令。也所以当一个函数被调用时,其实就是去检索要执行的指令的位置。
上图就是函数指针的工作原理,下面再展示2个复杂一点的例子,这部分内容就算讲清楚了:
2、lambda表达式
lambda表达式是零零碎碎写在了很多地方,【C++】C++中的关键字:const、mutable、auto、typedef、using、new、delete、explicit、this、多文件放代码-CSDN博客 中的mutable关键字中写过了一点关于lambda的使用方法,感兴趣的同学可以先去查看。lambda的难点在于我们为什么要使用lambda表达式?什么场景使用?用在什么地方?
(1)简单认识lambda的工作原理
这里再接着写lambda表达式也是因为它和前面的函数指针、函数都有密切的联系,所以为了联系理解,就把lambda表达式放这里再详细讲解一下。所以这里首先把上面的例子改成lambda表达式的样子,我们先对比一下:
上右图是使用lambda表达式。
- 什么情况下可以使用lambda表达式?有函数指针的地方(比如E2处),我们就可以用lambda表达式(E2对应的地方F处)。就是只要你有一个函数指针,你都可以在C++中使用lambda。
- 为什么要用lambda?最浅显的意图是:因为C处的func1函数太简单了,我们用lambda就可以不用写func1了,就是右图的F处,就替代func1了,这样代码更简洁。
- lambda表达式的用法:
方括号[]叫捕获方式,也就是如何传入传出参数,就是传参的方式。
匿名函数也是函数,是函数就可能会有参数,也可以没有,所以小括号()就是参数。
花括号{}就是和正常函数一样,是函数体。
所以F处的代码怎么写,要根据E2处的函数指针的要求来写。E2指针要求函数的返回是void,那F就不能有返回值;E2要求函数的参数是一个int类型的,那F处的小括号()内就只能写int a.
上左图代码的执行逻辑是:从main函数入口开始执行代码,执行到A处,跳到B处调用fun2函数,func2执行到B1处时需要参数func1,再跳到C处执行func1的代码。所以上左图我们的开发过程是:先写好func1函数,再写func2函数,最后在main函数中调用func2。
而上右图代码的执行逻辑是:从main函数入口开始执行代码,执行到D处,跳到E处调用fun2函数,func2执行到E1处时需要指针f指向的函数,再跳回到F处执行lambda函数。
所以,使用lambda仅仅是为了代码更简洁吗?当然不是。假如func1非常复杂,func1还没有开发完毕,或者func1需要的数据(vector中的元素),这些数据func1还没有得到,那我们就不能开发func2了嘛?当然不是,我们可以使用函数指针+lambda表达式,来先指定这个函数(func1),就是先指定我们未来我们想要运行的代码。而不是苦苦等等func1。
lambda本质上就是一个普通函数,只是它不像普通函数那样做声明,它是在我们的代码在执行过程中生成的,用完即弃的函数,所以也算不上是个真正的函数,所以叫匿名函数。我们不去实际上真正的去写一个函数(比如上例中的func1),而是使用这种方式(lambda表达式)快速创建一个一次性函数。这种就类似一个变量,在实际编译的代码中就是一个符号存在。所以lambda的意义就是,先指定我未来想要运行的代码。
(2)lambda语法
基于lambda肩负的这个责任,我们有必要详细看一下lambda的语法,这样我们才能灵活应对各种情况:
我们可以参考 www.cppreference.com 这个网址里面的关于lambda的说明,下图就是我到这个参考网址上的截图:
上图是lambda表达式的语法说明:
第一个是方括号[capture],就是捕获过来的变量的逗号分隔序列。那如何获得捕获的变量呢?[=]就是拷贝捕获;[&]表示通过引用获取;[this]表示通过指针获得。
第二个是小括号(params)参数。是函数就可能有参数,也可以没有参数。如果有参数,小括号就是获取的参数列表。
当我们拷贝获取参数列表后,如果lambda内部要修改参数,就得加上numtable修饰符。mutable就表示可以修改copy过来的函数参数。
(3)lambda的使用方式
3、名称空间
名称空间,也叫命名空间。这部分内容其实你只要弄懂:为什么需要命名空间?为什么存在命名空间这个概念?using namaspace究竟能做什么?什么场景下使用?什么场景下最好别用?那这部分内容你就算是过关了。所以本部分我都用简单的例子来说明。
(1)为什么需要命名空间?为什么存在命名空间这个概念?
我们从下面这个例子说起:
我们在代码开发过程中不可能闭门造车、从零做起,因为这会重复造轮子、极其地低效。这就好比我们要做一顿饭,你没必要先钻木取火、再自己造铁锅、烧陶瓷做碗等等一些列的工作,否则都饿死一百次了饭也吃不到嘴里。所以我们都是必须要站在前人的肩上继续前行。代码开发也一样,我们都是要尽量使用前人已经为我们包装好的东西,再继续开发出逻辑更加复杂、更加贴近我们需求的功能。
具体到使用C++语言进行开发时,C++的内置类型、关键字、标识符等都是前人开发完毕的、为我们打包好的、我们可以直接使用的。所以在上图源代码中,我一开始就得#include包含iostream头文件和string头文件(上图A处),否则我BCD处的代码中的一些符号就不能使用。比如:
第一行代码:#include <iostream>
我include的iostream头文件,就是C++标准库中的、用于实现输入输出的库。也就是文件iostream中都是前人帮我们写好的一些用于进行输入输出操作的类和函数。
看过我写的编译原理的同学都知道,当我上面的Main.cpp文件开始编译时,第一步就是预处理器处理#include <iostream>这行代码。它是怎么处理的呢?就是把iostream中的代码原本不动的替换了我现在写的#include <iostream>这行代码。意思就是iostream中的函数和类我就不用重复写了,编译器直接帮我复制到我们现在写的文件中了。那也就相当于我已经开发了iostream里面的所有代码了,所以我下面的BCD处的代码就可以使用iostream里面的函数和类了。也所以我下面的BCD处可以使用cin和cout了。
第二行代码:#include <string> 就同理了
string文件是C++标准库中和处理字符串相关的库文件,这个文件中的函数和类提供了字符串的操作和处理功能。所以我们include文件string后,编译器也是会把string文件中的所有内容替换到我的第二行代码位置。所以我们下面C处的代码中,我就可以创建temp对象了、我还可以使用reverse函数了。
话说回来就是,我开发Main.cpp文件时,我的文件中要用到cin输入、cout输出、创建字符串、让字符串倒排序的reverse函数,所以我必须在文件的最开始的地方写#include <iostream> 和 #include <string>,因为这些符号的代码是放在iostream文件和string文件中的。也所以我上面的Main.cpp文件才20行代码,但预编译完毕后就是:
都快7万行代码了!这些代码都是前人帮我们造好的轮子,里面有我这20行代码中需要的东西。我要执行我这20行代码指令,我只要拿来前人的轮子使用即可。
这里再补充一个知识点:到这里肯定会有人问:你就写这20行代码就得用前面快7万行代码,那如果你开发个几万行代码,岂不是要使用前人几亿行代码了?!这样太天量了,内存够吗?硬件能撑住吗?所以这就是为什么我们在开发过程中非常重视优化、效率等这些概念。我这个例子中其实我只是需要前人的“cin输入、cout输出、创建字符串、让字符串倒排序的reverse函数”这4个功能,但是预编译器给我放入了iostream文件和string文件中的所有内容!不过没关系,预处理完毕后,代码就进入编译阶段,编译器会把没用的东西都忽视的。因为编译器的工作原理是,把你写的这20行代码转化成AST语法树,AST中涉及到的东西就是你开发的这20行代码中的所有token,以及与这些token相关的iostream中和string中的token。所以天量的iostream中和string中的没必要的东西自然就无视了。除此之外,编译器还会有一些逻辑上的优化,让代码更加轻量。也所以在编译阶段我们有debug模式和release模式之说,所以:
虽然Main.i文件很大,但是Main.obj就只有不到200K,而且还是在debug模式下,如果在release模式下最终声明的可执行文件还会更小:
这些内容不是非常了解的同学可以参考我的编译原理博文:【C++】编译原理_c++编译原理-CSDN博客 融汇贯通理解。
上面洋洋洒洒说了这么多,简单总结就是:我们为什么要A处的代码,以及A处代码的工作原理。或者说:正是因为有了A,我们才能在BCD处使用cout、cin、string temp、reverse这几个符号。
这似乎和命名空间越来越远了,现在我们继续回到命名空间这个话题。
前面说的意思是你在真正开发一个非常复杂的系统时,你会include很多库文件,还可能会有你自己的自建库、以及第三方外部库等,此时你就很难保证这些库文件中的函数签名(函数名、参数列表、返回类型)是没有重复的。一旦出现重复,你的代码就会报编译错误,就是没法编译了。这种情况还算是好的,你可以再根据报错去查原因。但是还有一种情况是不会报错,它内部给隐式转换了,代码都是可以顺利编译并执行的,但就是执行结果不是你预期的,此时你才是欲哭无泪,非常难debug,可能debug费的劲儿都比开发大了(下图展示的就是这种情况)。所以此时我们就非常需要命名空间这个东西。
上图D就是鼠标悬停在字符串“Hello World!”上面,显示这个字符串是const char []类型的,也就是这个字符串本身就是一个指针,所以当代码中出现两个同名函数时,编译器就自然是调用B了,因为B的参数就是指针啊,不用隐式转换就能调用了,调用A还需要隐式转换一次。const char数组是可以转换成string的。所以此时,你绞尽脑汁也理解不了为啥程序运行结果不是自己预想的。此时你再debug你会非常惨烈。下面我再展示一下什么是隐式转换:
这样是不是就是我们预期的结果啊。不是特别懂字符串为什么会隐式转换的同学可以参考 【C++】深度理解C++数据类型:常量、变量、数组、字符串、指针、函数_c++ 字符串常量-CSDN博客 中的字符串字面量,以及 【C++】C++中的关键字:const、mutable、auto、typedef、using、new、delete、explicit、this、多文件放代码-CSDN博客 中的explicit关键字。
所以,我们需要命名空间。
(2)什么是命名空间?
下面继续用这段代码说明这个问题:
细心的同学会发现,上面代码中的cout、cin、string temp、reverse这些符号前面还加std::,这是什么东西?这就是名称空间。
我们知道C++是兼容C的,或者说C++是在C上发展而来的。C是没有命名空间这个概念的,所以当我们使用C库时,C库的函数都是上上图的写法。所以当我们include包含了C中的库时,我们使用其中的函数或者类,我们直接调用即可,不存在命名空间一说。当然这就也存在弊端,弊端就是上面讲得,一旦你的代码非常巨大,你很容易碰到同名而出现编译错误,或者很难debug的隐式转换,你都不知道你到底include了什么,中间到底转换了什么,哪里转换了。非常头疼。
C++为了避免名字定义冲突,引入了“名字空间的定义”,即namespace。如上图中的B、C处的写法。也就是C++标准库中的函数都是用namespace std{}给包住的。虽然iostream和string都是C++标准库中的库文件,虽然我们也include包含这个两个文件了(上图A处),我们想在BCD处使用cout、cin、endl、string temp、reverse这些符号,我们是不能直接用的,我们得前面加上std::才能使用(如上图中的代码)。
同理,上图中我们自己开发的函数func_a,我们也是用namespace xxx把它包住(上图的B处和C处),这样我们在调用(上图D处)时,我们就得在func_a前面加apple::,或者添加orange::来标明func_a到底是来自哪个命名空间中函数。
此外,还要说明的是,命名空间是可以嵌套的:
(3)如何使用命名空间?
很多同学到这里就烦了,每次都要写std::好心烦啊!那我们在开发过程中加上using namespace std语句,就可以省略std::了
说明:using namespace是可以声明在任何作用域内,所以当然也可以声明在函数内,上右例就是声明在main函数中了。
所以上左图,我们把using namespace std写在文档的最开始,那后面遇到这些符号时我们都可以不用加std::了。
所以上右图,我们把using namespace std写在了main函数里面,也就是它的作用域仅仅限于main函数的作用域,所以只有main函数中的符号不用加std::,其他地方的还得加std::的。
同理,我们照葫芦画瓢。显然上图中的func_a函数我们也是写在了名称空间中,参数是string&的func_a是在apple名称空间中,参数是const char*的func_a是在叫orange的名称空间中。所以我们在main函数中调用这两个函数时,我们得在前面加上apple::或者orange::,才能调用。
照葫芦画瓢,我们使用using namespace,改改上面的代码:
总之,不管我们怎样写,一个原则就是,尽量使用在一个尽量小的作用域中,比如只在某个函数作用域中使用,或者只在比如if语句的作用域中使用。最好别总是在顶层定义using namespace。下面开始详细讨论为什么要限制它的作用域。
(4)什么场景下使用using namespace?什么场景下最好别用using namespace?
上面的例子,如果我们这样写呢:
上左图:直接报编译错误。
上右图:虽然可以正常运行,但是A处是重载了!重载可不是我们想要的!我们本意不是让重载的!我们是要非常确定知道自己到底调用的哪个函数的!
可见如果我们到处写using namespace,也就相当于没有namespace了。namespace存在的意义就弱化了,甚至没有了。
所以,很多资深的C++开发的人员能不写using namespace std就尽量不写的,而是不厌其烦地写std::。因为这样代码的可读性会更好一些。同时最重要的是debug时更容易查找。
也所以,当我们开发自己的库文件时,为了避免函数签名冲突,我们也要用namespace xxx{}包裹住我们的代码,这样可以有效避免和其他文件发生名称冲突,也不会出现隐式转换、重载等情况。尤其是一些非常通用的函数,比如init函数,大家都取名是init,那调用的时候怎么区分呢,你不可能去修改源码,此时使用名称空间就可以避免名称冲突,可以自由开发了,不需要考虑命名是否有冲突的风险了。就像C++标准库中的所有东西都位于std名称空间后面,这样就不会出现命名冲突了。如果你使用C语言代码,你就得时刻注意是否有命名冲突,如果你声明的函数名和C代码库中的函数名一样,就立即产生冲突了。
但是在库文件开发中我们一定一定要、百分比的要避免使用using namespace xxx。因为你开发的库文件是别人的头文件。当别人include包含你开发的库文件时,别人的文件中就把你的库文件中的所有代码复制到人家的文件中的,所以连带using namespace xxx也复制进去了。这样这些人家压根都不知道的命名空间用在了、人家压根也不知道的地方!但是编译器知道啊。
此时出现符号冲突,报编译错误,就是上左图的情况,人家真是欲哭无泪,委屈至极,人家啥都没干,就编译错误了,而且也不知道问题出在哪儿,因为include的文件不是人家写的嘛,怎么能各个细节都很清楚啊?!搞得人家一头雾水,再去debug你写的库文件,查看到底是和哪个文件中的符号冲突了,为什么会冲突(就因为你的using namespace xxx),人家会debug到崩溃的。
如果没报编译错误,0 warning, 0 error,但是执行结果不是预期中的,就如上右图A处,这是一种无声的、百思不得其解的错误:为啥我就是打印一个字符串,执行结果是倒着给我打印出来的!噩梦般的一头雾水中,人家得打断点一点点看到底是怎么跳转的,人家会拍死你的。
为了避免上面的错误,一定要谨慎使用using namespace,外部库一定最好写上名称空间,比如std::。这样就非常明了这个符号来自哪里,避免未来可能出现的错误。如果非得想写using namespace,最好只限于使用在自己写的库上,而且最好局限于一个足够小的作用域中。同时一定一定不要在库文件中使用using namespace.
任何大型项目出现上面的问题,追踪起来都会非常非常费时费力。所以大家都要有全局观,而不是只图自己方便。