十四、C++中如何处理多返回值
本部分也是碎碎念,因为这些点都是很小的点,构不成一篇文章,所以本篇就是想到哪个点就写哪个点。
1、C++中如何处理多个返回值
写过python的同学都知道,当你写一个函数的返回时,那是你想返回谁就能返回谁,想返回几个就可以返回几个,几乎是非常的随心所欲了,因为python背后是做了很多很多事情才让你如此肆意的。C++就非常不行了,因为C++本身就很底层,没有更底层的东西去为它做这些事情了,所以所有都得C++自己花式变出来。
我们都知道,C++默认情况下,一个函数是不能返回两个对象的,就是只能返回一个特定的对象,所以更别提返回多个类型的对象了。
那你的意思是python都能完成的事情,C++却完成不了?非也,这两者根本都没有可对比性。C++是底层,就是所有顶层的东西都是底层一生二、二生三、三生万物而呈现出来的百花齐放。底层不存何来万物。所以C++当然也可以实现返回多个对象,只是这个实现过程需要你用你的智慧去实现。
所以在C++中,你要使一个函数返回多个对象或者多种类型的对象,其实是有很多不同的方法可以实现的。下面我就尽量多罗列几种方法吧。
(1)利用结构体实现
我个人也比较推荐这种方法,比较清晰也好理解。
这里想强调的是,只要涉及到多返回值,或多或少都要涉及到复制,只要涉及到复制,性能就是一个绕不开的话题。上述例子中,两个字符串是程序进入main函数后,先开始运行func函数,而运行func函数就先组织参数,组织参数就是在常量区先写入"lyy""liyuanyuan"这两个字符串,然后才开始执行func,func又是在栈内存创建的,而执行func我们是通过引用传参的,这样就少了一次复制。
func函数执行完毕,就是赋值给e。此时就得先找到连续的空间给e,然后把e的3个对象的值初始化成func的返回值。此时必定的复制呀。所以这里想说的是不管你使用哪种方法,你脑子里一定要不断确认是否有性能问题。
(2)通过参数实现
这种方法不需要使用C++提供的特定类(方法),但是稍微有点难懂
通过参数实现一个函数多返回值的方法,稍稍难理解,不过如果你对传值、传址、指针、引用这些基础概念非常清晰的话,其实也不难。
所以这里要强调的是,如果你是通过参数实现的,那你千万可不能传值,传值将不会得到你想要的结果的。因为func函数的返回值变量,我们是先定义到main函数里,然后再把变量的引用(上左图)或者变量的地址(上右图)传给func,然后在func函数体内初始化或者修改或者赋值这些变量的。
如果你传给func是变量的值,那func会在自己体内重新复制一份数据,然后再计算,结果就和main函数中定义的变量完全无关了,所以你就得不到你想要的结果了。
(3)通过数组实现返回相同类型的多个对象
通过数组实现是最难懂的,是考察一个人对C++基本数据类型的理解程度和灵活使用程度。因为C++还兼容了底层的C,所以数组本身就非常复杂,有最基本的C风格数组、array数组、动态数组vector、还有字符串也有自己单独的数组string[],更有很多变异的交叉组合的东西,比如指针数组、数组指针、高维数组、指向数组首元素的指针、指向数组的指针、指向“指向数组的指针”的指针、函数指针、指针函数、函数返回一个指针、指针指向一个函数、用指针当参数传参、还可以和结构体、类等结合,等等,非常晦涩,非常考验你的底层基础理解得有多深入。
针对本小知识点,你要清楚下面三方面:
首先你要明确:C++中的函数是不能返回普通数组的!会报编译错误!那我们又想让函数返回一个普通数组怎么办?我们可以通过返回一个指针来曲线救国,就是要通过直接返回一个指针的方式,来间接返回一个数组。我们知道一个数组的数组名就是这个数组的第一个元素的指针,&数组名就是一个指向数组的指针。所以一是你可以返回这个数组的&数组名(下面的A案例)。二是你可以返回一个指向指针数组名的的指针,就是返回一个指向指针的指针(下面的B案例)。
其次,前面知识点牢固的同学都知道,在C++中,数组可以用C风格的、也可以用array数组。所以你用哪种类型的数组,难度是不一样的。C风格的数组几乎就是内置类型了,非常底层,所以非常难,但效率高。array数组是标准库的东西,已经包装得非常人性化了,虽然简单好用但肯定是要多一点点开销的。我们先展示如何用最原始的C风格的数组(A案例、B案例、D案例),再展示array数组(C案例)。
最后,不管我们是用C风格数组还是array数组,也不管我们是要返回指针数组的数组名还是返回一个指向指针的指针,背后都还是数组,那既然是数组,数组是要求所有元素的数据类型是相同的。即使你的数组是指针数组,也是要求所有元素(指针)指向的对象都是相同类型的。所以下面案例中就不能比如创建两个字符串数组,再创建也给整型,然后再创建一个指针数组,把三者的地址分别放进去!这样是没法赋值指针数组中的元素的!虽然每个元素都是地址,但是也是没法赋值的。所以下面的A、B、C案例我们都用的是字符串类型。就是只能返回某一个类型的多个对象,不能返回多个类型的对象。不像上面的结构体方法、参数方法是可以返回多个类型的多个对象的。
A案例:返回指向指针数组的指针
上面展示的案例中,func函数返回的是一个指向“指针数组”的指针。就是func返回了一个指针,这个指针指向的是一个数组,这个数组中的元素又都是指针,这些元素又指向堆内存中的字符串数组。但是上述案例main函数中调用func函数的做法有些不恰当。因为这种调用方式根本没有任何用处,因为一点func函数调用完毕,“指针数组”就随着func函数的结束而湮灭了,那要一个指向"指针数组"的指针干嘛用啊,一个指向不存在的指针没有任何用处的。所以main函数应该如下写法:

B案例:返回指向指针的指针
这次我在堆上创建指针数组,这样就不会出现调用func完毕后,指针数组消失的情况了(上图方法1)。或者我还可以先在main函数创建一个指针数组,然后当参数传给func,让func给我初始化一下再返回(上图方法2)。当然也可以不用返回(就是下面图D案例)。
C案例+ D案例:
C案例中,虽然s1、s2、array都是在func函数内部创建的,main函数调用了func后,这些对象都还在,那是因为main函数又自己复制了一份数据。由于array库给我们包装了很多东西,所以这里的代码看起来就非常容易理解。
D案例也是非常巧妙的用法,和前面的小标(2)的思路是一样的。
E案例:返回vector数组
C案例中的array是在栈上创建的,vector会把它的底层存储存储在堆上,所以从技术上讲array会更快。
上面给大家展示这么多小案例,也只是一部分,聪明的你应该能想出更多的办法。这里仅仅是为了展示各种用法。用法和用法是没有优劣之分的,每种用法都有其特定的应用场景。只有非常了解每种用法才能灵活应用。此处也是深刻的领略了一下C++的超级灵活性,真是像水的品行一样,可以各种变形、各种绕开、各种组合,利万物而不争、静水深流、强大而温润。如果你对指针、数组、指针数组、数组指针等这些基础概念不清晰的同学,你就会非常懵,花式报错,建议仔细看这篇博文:【C++】深度理解C++数据类型:常量、变量、数组、字符串、指针、函数_c++ 字符串常量-CSDN博客 ,这篇博文是基础中的基础,是C++内置类型的详细说明,你把这些吃透了,这些案例你也就懂了。
(4)通过元组tuple实现多个返回对象
(5)通过Pair实现多个返回对象
(6)C++的结构化绑定
结构化绑定structured bindings是C++17中引入的一项特性,它允许开发者方便地从元组、结构体或数组中解包数据到单独的变量。是在元组(tuple)和对组(pairs)的基础上,新扩展的一种处理多返回值的新方法。
下面例子是我用一个函数返回一个tuple元组,我是如何取出元组中的各个元素的,也就是如何解包的:
这里的前两种方式不限于C++标准的版本,但是第三种结构化绑定只适用于C++17标准。所以要使第三种解包方式顺利通过编译,就得在visual studio的property页面里面设置为C++17标准,如上右图所示。
十五、C++中的模板templates
本部分讨论C++中的模板templates
在别的语言中,比如java、c、c#等托管语言中,模板类似泛型的概念,但模板比泛型要强大得多。模板有点像宏,而泛型却非常受限于类型系统以及其他很多因素。同时模板也是一个巨大的、复杂的话题,本部分仅仅是浅浅的入门。
1、什么是模板?
模板就是基于你给定的一套规则让编译器为你写代码。或者通俗的说就是,你写个模板,里面抠出一些空,这些空填上不同的东西,就是一个可用的对象。或者我举个例子,比如开发票,其实发票的格式都是一样的,只有抬头、金额、数量等几个要素不一样。你把空发票就可以看出模板,里面的抬头、金额、数量等几个地方是空的,你只要根据不同的客户填上不同的信息,每个客户的发票就开好了。通俗的理解,模板就是那个空发票。你把要填的信息填到对应的空里面,就你生成一张特定客户的发票了。
所以模板就是把代码的某些部分挖掉,然后传给编译器挖掉部分要填的内容,编译器就帮你完成这段代码了。所以说模板就是你给编译器一个套路,然后再给要填的空的答案,编译器就自动帮你完成了。
2、为什么要用模板?
如上左图所示,我只是想写一个func函数,但是我得要允许func函数可以接受各种类型的参数,比如整型、字符串、浮点型等等,那此时我就得写好几个函数重载,如上图A处。而且这些重载函数除了参数类型不一样外,其他地方都一模一样。手动重复写A处这么多差别不大的函数太费劲。如果我写一个模板,其他都写全,就把参数类型空出来,然后我给编译器传入那个空的答案,编译器像填空一样帮我填上就行了。这样我就省力很多了。这就是template诞生的初衷。
说明:cout是可以接受任何基本类型的或者说C++内置类型,就是我们现在正在使用的这些类型。
3、template的语法
上右图B处是template的语法:template单词就表示你定义了一个模板;尖括号里面的typename是模板的参数;T是名字,你可以随便取。
T用在C处。也就是T可以在整个模板代码中使用,来代替任何出现类型的地方。比如如果代码中出现int value,我们就可以写成T value;再比如如果出现string value,我们也可以写成T value。
当你定义了一个模板后(B处),编译器就会在编译期评估这个模板。所以上右图B、C处的代码不是真正的代码,func函数也不是一个真正的函数,它只是模板的一部分。只有当我们实际调用它的时候(D处),func函数才被真正的创建,创建时也是根据我们传入的参数类型,T才被替换,func才被创建出来,并作为源代码的一部分进行编译的。所以,比如MSVC编译器就不会对你不使用的模板错误进行报错,但是比如Clang等一些编译器会报错。
4、模板的工作原理
当我们调用func("hello")时,模板的另一个版本的函数就会被编译器创造(把尖括号中的类型替换T),并复制到模板下面,然后才编译。
所以模板的工作原理就是,当你调用模板中的函数时,编译器就根据你给它的信息,把该填的空都对应填上,并将生成的代码复制到模板后面即可。
模板不仅可以让编译器帮我们写函数,还可以写类。事实上,大量的C++标准模板库同样完全使用了模板,下面我们再展示如何写类的模板:
上面右图我们创造数组的方式,和C++标准模板库创造array数组的方式一样。记不记得array数组也是让我们在尖括号里面提供两个参数,一个是数组的类型,一个是数组的长度。所以模板有点像C++的meta programming(元编程),就是编译器在编译时实际还进行了编程。
了解模板模板的工作原理后,我们自然可以判断,不是什么情况都适合使用模板的,有的个性化非常强的工作,你就还是别使用模板了。如果是一些重复性很强的工作,比如日志系统,比如要写很多类型的重载函数,这样的工作使用模板就是非常合适的。
十六、C++的宏(Macro)
C++的宏(Macro)是一种让你写代码时更方便、更简洁、更灵活的工具。
1、宏是什么?
当我们编译C++代码时,第一步就是预处理器会过一遍我们所能看到的源代码中的所有语句。预处理的工作原理基本就是不停的查找-替换、查找-替换...这样过一遍我们的源代码。当预处理器看到#时,它会把#后面的内容都替换成对应的代码。所以宏也是这个时候、这样被替换的。
当预处理器把全部源代码处理一遍后,才是编译器开始编译实际上要编译的代码,编译器把预处理器替换完毕的文本代码编译成二进制指令。所以预处理阶段基本上就是一个文本编辑阶段。也所以当我们了解了与编译器的工作原理后,我们是知道什么代码被喂入了编译器的。
所以,在C++中我们可以使用预处理器来"宏"化某些操作,这样我们就不用一遍又一遍的手动输入代码了,我们就可以利用预处理器,在某种程度上帮助我们实现自动化。预处理器就是一个替换的功能,所以重复的东西我们可以让预处理器帮我们在预编译阶段就替换一下。
2、宏定义的定义及类型
C++中的宏是一种预处理指令,用于在预编译时将代码中的标识符替换为指定的文本。
宏是用#define指令来定义的,用#undef指令来取消定义。
宏名是标识符,宏值可以是任何合法的C++表达式。在编译时,预处理器会将代码中所有出现的宏名替换为宏值。所以从表面上看,宏就是一种代码片段,可以被宏的值所替换。
宏有两种类型:类对象宏和类函数宏。
类对象宏就类似一个常量,是不需要分配内存空间的。因为在预编译阶段,它就被作为二进制可执行指令的一部分,随同指令一起被存放到代码区,在程序运行过程中是不可改变、不可寻址的。
类函数宏就像一个函数,只不过不需要调用和返回,因为是预处理器的操作,而预处理是在源代码预处理阶段上场的,所以压根就涉及不到函数的调用和返回这些事情,仅仅就是替换一段代码片段的操作而已。而函数调用和返回是编译器在编译阶段发生的。
因为是一段代码片段嘛,所以类函数宏在定义时是可以带参数,称为带参数的宏定义。语法格式为:#define 宏名(参数列表) 宏值。
下面给大家展示3个宏示例:
第1个宏,在预编译时,预处理器会将代码中的所有PI替换为3.1415926
第2个宏,在预编译时,预处理器会将代码中的MAX(3, 5)替换为宏值,替换后的代码就是:int max_num = ((3) > (5) ? (3) : (5));
第3个宏,在预编译时,预处理器把符号WAIT替换cin.get()字符串。
可见,宏只是预编译器的替换功能。预编译器是先把源代码从头到尾扫描一遍,把里面该替换的源码全部替换了,此时源代码还是字符形式的代码。预编译器替换完毕后,代码才进入编译阶段,此时是编译器上场对预编译完毕后的字符源码进行编译,编译完毕源代码已经变成二进制机器码了。然后再是链接器上场,把所有该链接的都链接上,才生成.exe可执行文件。所以本部分一定要结合【C++】编译原理_c++编译原理-CSDN博客 编译原理这部分一起理解,这篇博文里面有预处理完毕后的生成文件展示。
从上面3个示例中我们也可以得出结论:不要随便使用宏,比如上例3,别人看到你的代码中有一个WAIT,就很懵,你这是啥标识符???别人还得去查找,才知道WAIT是cin.get()。尤其是这个WAIT标识符还是一个全局变量,就是在别的文件中也是可以直接用WAIT的。如果此时读你代码的人在别的文件中看到WAIT这个标识符,他得查好几个文件才能找到你这个WAIT的意思,此时读你代码的人就可能要崩溃了。所以不要随便使用宏,你的代码不仅你自己读,也得要别人也易读。写代码不是炫技,是准确传递信息,那你就得按照规则传递,对方才不会误解。
3、宏的使用场景
当你需要在编译时替换一些常量或表达式,而不是在运行时计算它们。
当你需要定义一些简单的操作,而不想为它们创建函数或模板。
当你需要根据不同的编译选项生成不同的代码,比如调试信息或条件编译。
当你需要使用一些特殊的语法或符号,比如变长参数或字符串化。
这里展示一个非常有用的使用场景:就是我们在开发代码的时候,我们可能想用一些东西,但是当代码开发完毕,发行时,有些东西又不想让它运行,比如日志,客户是不关心的,所以发行版是不需要运行日志的,此时我们可以设置一些宏,使在开发模式下编译是有这些代码的,但发行模式下编译就没有了这些代码,也让发行版有更好的性能了:
此外,宏对于跟踪或者调试也是非常重要。在debug构建中,我们可能会想知道,比如main.cpp的第15行,多少个字节被分配了?这些被分配的内存是来自从哪个文件、哪行代码?宏就可以帮我们做这些事情。比如你把new关键字替换成一个自定义的单词,宏就会自动的跟踪是从哪个文件的哪一行进行分配的、分配了多少字节等等。 下面再写一个例子,来展示如何用宏来测试main函数。就是创建一个包含所有的main函数的宏。
宏要求只能写一行,那我们可以使用反斜杠来写多行的宏。反斜杠是换行字符的转义,就是Enter键的转义。下面例子的宏也是可以正常运行的:
4、使用宏的注意事项
但是宏也有很多局限和风险,所以应该谨慎使用,并尽量用C++提供的其他特性来代替它们
(1)C++提供了哪些特性可以代替宏呢?
C++提供了一些特性可以代替宏,比如:
用inline函数或模板来代替函数式宏,可以保证类型安全和编译优化。
用constexpr或const来代替常量宏,可以避免重复定义和命名冲突。
用using或typedef来代替类型别名宏,可以增加可读性和灵活性。
用enum class或namespace来代替枚举宏,可以提供作用域和强类型检查。
用if constexpr或模板特化来代替条件编译宏,可以实现编译时分支选择。
用字符串字面量或运算符重载来代替字符串化或连接宏,可以实现更自然的语法。
(2)哪些情况是必须使用宏的呢?
宏在C++中的必要性已经大大降低了,但是还有一些情况是无法用其他特性替代的,比如:
当你需要定义一些编译器或平台相关的常量或标志,比如__FILE__或_WIN32。
当你需要使用一些预处理指令,比如#include或#pragma。
当你需要实现一些复杂的元编程技巧,比如BOOST_PP库。
(3)宏和内联函数有以下几个区别:
宏是由预处理器展开的,而内联函数是由编译器复制到调用处的。
宏必须在程序开始处定义,而内联函数可以在程序任何地方声明。
宏不能访问类的数据成员,而内联函数可以。
宏没有类型检查和返回类型,而内联函数有。
宏可能会导致副作用和错误,而内联函数不会。
(4)宏和模板的区别:
宏和模板还是不一样的。宏发生在预编译阶段,模板则是发生在编译阶段。宏是在预编译阶段被预处理器替换的。模板则是在编译阶段,编译器发现有模板函数被调用了,才生成函数代码并把生成的代码复制到模板后面,此时的函数都已经是二进制的形式了,所以编译器复制的也是二进制。