You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

13 KiB

做好闭环(二):函数是压缩的数组,数组是展开的函数

你好,我是胡光。

不知不觉,我们已经学完了语言基础篇的全部内容。其实还有很多东西想给你讲,可限于篇幅,所以咱们整个语言基础篇中的内容,都是那些,我认为你自学容易忽视的,容易学错的知识点。有道是,授之以鱼,不如授之以渔,我也相信只要你跟着课程学习,一定会感觉到自己收获到了“渔具”。如果能引发你的主动思考,进而触类旁通,举一反三,那这场学习过程就更加有意义啦。

我也非常高兴,看到很多同学都在紧跟着专栏更新节奏,坚持学习。每每在专栏上线的第一时间,这些同学就给我留言,提出自己的疑惑。大部分留言,我都在相对应的文章中回复过了,而对于文章中的思考题呢,由于要给你充足的思考时间,所以我选择在今天这样一篇文章中,给你进行一一的解答。

看一看我的参考答案,和你的思考结果之间,有什么不同吧。也欢迎你在留言区中,给出一些你感兴趣的题目的思考结果,我希望我们能在这个过程中,碰撞出更多智慧的火花。

函数:自己动手实现低配版 scanf 函数

在这一节里面呢《函数:自己动手实现低配版 scanf 函数》,我们讲了函数的基本概念,明确了“实参”和“形参”两个概念,并且知道了函数传参的过程,就是“实参”给“形参”赋值的过程。

还有,我们介绍了“传入参数”和“传出参数”两个概念,弄懂这两个概念,对于设计一个函数来说,还是非常重要的。“传入参数”是从外部,传入到函数内部,影响函数内部执行逻辑的参数,“传出参数”呢,就是由函数内部,传出到函数外部的参数,一般是以传送地址为主要形式。

最后呢,我留了两个开放性的思考题,我选择其中一个你可能会不知所措的题目,来讲解一下如何完成这个题目。下面就看看我的答案吧。

思考题1数组和函数的思考

关于这个问题呢我们首先来具象化一下我们设想一种具体的问题情况比如说arr 数组里面arr[i] 存储的是 2 * i 的值,你可以认为是第 i 个偶数的值func 函数的功能呢func(x) = 2*x就是计算得到第 x 个偶数的值。如下述示意代码所示:

int arr[100] = {0, 2, 4, 6, 8, 10, ...}; 
int func(int x) {
    return 2 * x;
}

解析这个示例代码,我们先从数组 arr 和函数 func 的不同处开始说起。

很明显两者的本质不一样arr 是数组,对于代码 arr[36] 相当于访问数组下标 36 的位置中存储的值;而 func 是函数,对于代码 func(36) 来说,也会得到一个整型值,但是这个整型值,却是由 func 的函数逻辑代码计算得到的。简单来说,就是对于 arr 中的值进行访问,是一个静态的过程,而对于 func 函数得到返回值的过程来说,是一个动态计算的过程。

我们再来看看两者的时间和空间效率,也就是代码运行速度以及所需要的存储空间之间的比较。

  • 关于时间效率方面,通常来说是数组访问速度要比函数计算速度快很多。
  • 而空间效率的话,函数通常要比数组节省很多存储空间,就像 func 函数的值,是动态计算得到的,通常情况下,不管我们代码中执行 func(100) 还是 func(10000),我们不需要修改函数的代码。但对于 arr 数组来说,当我们需要访问 arr[100] 的时候,数组最起码要有 101 个元素空间,而当我们想要访问 arr[10000] 的时候,数组最起码要有 10001 个元素空间。总的来说,就是函数比数组更加节省空间,数组比函数呢,得到结果的速度更快。

说完二者的不同以后,我们再来看看二者的相同之处。

站在使用者的角度来看,当你盯着 arr[100] 和 func(100) 这两段代码看的时候你没觉得这两个代码的异常的相似func 和 arr 就是名字不一样,如果这个时候我将 func 后面的小括号换成中括号,你是不是就会觉得 func 是一个数组?

你可能发现了在使用者看来func(100) 和 arr[100] 的作用是完全一样的,区别可能只是中括号和小括号的区别。你不觉得站在使用者的角度,考虑这个问题很有趣么?本质区别很大的两个东西,一个函数,一个数组,突然发现它俩的区别根本没有那么大。

简单来说,就是在数学里,函数做的事情就是“映射”,传入一个值,传出一个值。在程序中也不例外,函数做的事情,就是从传入值到传出值的映射。而数组做的事情呢,其实是从下标到存储值的映射。你会发现,数组和函数做的事情,本质上都是映射!

最后,我来总结一下,这个总结讲对你日后的程序设计思维有巨大的帮助,这句话就是“函数是压缩的数组,数组是展开的函数”,也就是说当你可以用数组进行程序的时候,你也可以使用某个能够完成相同映射功能的函数来进行替代。

二者在程序设计方面的差别,就在于时间和空间的使用效率,数组在时间效率方面占优势,函数在空间效率方面占优势。当你理解了这些事情以后,你就可以更好的理解某些资料里面经常讲的“时间换空间”或者“空间换时间”的概念了。你现在可以简单的理解成为是数组思维和函数思维之间的互相转换。

预处理命令:必须掌握的“黑魔法”,让编译器帮你写代码

关于预处理命令这个知识点,我们用了两节课的篇幅来讲解,《预处理命令(上):必须掌握的“黑魔法”,让编译器帮你写代码》《预处理命令(下):必须掌握的“黑魔法”,让编译器帮你写代码》。其中讲了两种使用比较多的预处理命令,宏定义和条件编译。并且强调了,宏定义就是做简单替换,条件编译做的事情,就是代码剪裁,根据条件是否成立,决定哪段代码最终留在“待编译源码”中。

其中,用户 @一步 问到:有没有什么办法可以看到预处理阶段后的待编译源代码的内容?这个应该是很多小伙伴的共同问题吧,在这里我就来讲一下。

在 Linux/Mac 的编程环境下呢,操作比较简单,原本的程序编译命令是 gcc 加源文件名,如果你想看到待编译源码的内容,你只需要在中间加一个 -E 编译选项即可例如gcc -E test.c。如果你用的是集成开发环境那你就需要自己搜索解决办法了你可以搜索关键词如XXX 下如何查看宏展开内容。XXX 就代表了你的集成开发环境。

对于课后的思考题,这里必须为用户@Geek_Andy_Lee00 和 用户 @Aaren Shan 的回答点赞。答案虽然不是很完美,可我想说,答案不重要,重要的是思考过程。 下面就来看看我给出的参考答案吧。

思考题:没有 Bug 的 MAX 宏

就像之前所说的,对于这个问题呢,能否满分通过,是不重要的,重要的是你在解决这个问题过程中遇到的一个又一个 Bug以及你对于这些 Bug 的思考过程。下面我就将带你一步一步地解决,这个问题中,你可能遇到的几个典型的 Bug以及解决办法。

首先,让我们先对样例输出的每一行编上序号,如下所示:

MAX(2, 3) = 3
②5 + MAX(2, 3) = 8
③MAX(2, MAX(3, 4)) = 4
④MAX(2, 3 > 4 ? 3 : 4) = 4
⑤MAX(a++, 5) = 6
⑥a = 7

我们先来实现一个最简单的 MAX 宏,如下所示:

#define MAX(a, b) a > b ? a : b

如上所示MAX 宏的实现利用了三目运算符问号冒号表达式a > b 条件如果成立,表达式的值等于 a 的值,否则等于 b 的值。看似没问题,但如果你要是运行代码,你会发现,程序的输出可能会如下所示:

MAX(2, 3) = 3
❌5 + MAX(2, 3) = 2
❌MAX(2, MAX(3, 4)) = 2
❌MAX(2, 3 > 4 ? 3 : 4) = 2
❌MAX(a++, 5) = 7
❌a = 8

你会发现,这种实现,只有第一行是对的,其余几行都是错的。我们就来首先分析一下第 3 行到底是什么错误。按照宏展开的替换原则,最外层的 MAX 宏会被替换成2 > MAX(3, 4) ? 2 : MAX(3, 4)。然后我们再将里面的 MAX(3, 4) 宏展开,就变成了:

2 > 3 > 4 ? 3 : 4 ? 2 : 3 > 4 ? 3 : 4

这段表达式代码,看着有点儿乱,别急,我来帮你分析。首先我们从左向右看,先分离出来第一个问号冒号表达式的结构:

(2 > 3 > 4) ? (3) : (4 ? 2 : 3 > 4 ? 3 : 4)

我们看到在这个里面,第一部分 2 > 3 > 4 是条件;第二部分 3 是在表达式为真时候的返回值;第三部分,是包含两个问号冒号表达式结构的式子。我们继续对第三部分进行拆解:

(4) ? (2) : (3 > 4 ? 3 : 4)

继续拆解后,我们发现,第一部分 4 是条件;第二部分的 2 是表达式为真时的返回值;第三部分,就是一个单独的问号冒号表达式。拆解到现在为止,已经不需要再继续拆解了。

要想理解原表达式,我们需要先了解 2 > 3 > 4 这个“惨无人道”的表达式的值。这个表达式,从左向右执行,首先执行 2 > 3 这个条件表达式的判断。之前我们讲过,条件表达式的值,只有 1 和 0那么 2 > 3 这个表达式的值,显然是 0之后其实是在做 0 > 4 的判断,结果也是 0。

所以 2 > 3 > 4 这个表达式的值,就是 0也就是假值代表条件不成立之后的事情就是转到了两个问号冒号表达式的部分剩下的事情你自己就可以理解最终原表达式的值为什么是 2 了。

理解了原表达式值计算的原理以后,下面让我们来分析一下,为什么会出现这种问题。本质原因,就在于我们实现的宏中,参数 ab 原本都是独立的表达式部分,而我们却简单的把它们放到问号冒号表达式中,导致展开以后的内容前后连接到一起后,改变了原本我们想要的计算顺序。

所以针对这种情况,我们在实现宏的时候,可以给每个参数部分,都加上一个括号,就变成了如下所示的实现方式:

#define MAX(a, b) (a) > (b) ? (a) : (b)

至此,你就会得到如下的输出:

MAX(2, 3) = 3
❌5 + MAX(2, 3) = 2
MAX(2, MAX(3, 4)) = 4
MAX(2, 3 > 4 ? 3 : 4) = 4
❌MAX(a++, 5) = 7
❌a = 8

在这份输出中,第 1 行、第 3 行、第 4 行均已正确。如果你自己仿照我上面说的方式对第二行内容的输出加以分析你一定可以知道如何修正第2行的结果错误。如果你努力以后还是想不到的话可以参考用户 @Aaren Shan 的留言。这样做以后呢,你程序的输出,就会变成如下输出:

MAX(2, 3) = 3
5 + MAX(2, 3) = 8
MAX(2, MAX(3, 4)) = 4
MAX(2, 3 > 4 ? 3 : 4) = 4
❌MAX(a++, 5) = 7
❌a = 8

其中还是有两行是错误的,你如果试着展开第 5 行的宏,你会得到如下的代码:

a++ > 5 ? a++ : 5

在这行代码中,如果 a++表达式的值真的大于 5 的话,那么 a++ 就会被执行两次。而原本使用者的意图,是执行一次 a++,如何让 a++ 只执行一次呢这需要用到之前我跟你提过的__typeof 相关的技巧了,下面是我给你准备的参考代码:

#define MAX(a, b) ({ \
    __typeof(a) __a = (a), __b = (b); \
    __a > __b ? __a : __b; \
})

在这段代码中我们定义了两个中间变量__a 和 __b 用来存储宏参数中 a 和 b 部分的原本的值,之后判断大小的部分呢,我们使用新的变量 __a 和 __b 即可。

这段代码中,我们看到了,无论是 a 部分,还是 b 部分的表达式,只被使用了一次,也就保证了只被计算了一次。而这个里面,用小括号括起来了大括号,实际作用是把一个代码段,变成一个表达式。根据任何表达式都有值的特性,这个代码段表达式的值,等于其中最后一行代码表达式的值,也就是等于最后那行问号冒号表达式的值。

第6行的错误其实和第5行的一样解决了第5行的错误这一行的Bug也就解了。

至此,我们就几乎完美地解决了 MAX 宏的问题了。通过这个问题,你会看到,预处理命令虽然强大,可你需要拥有掌握这种强大的力量。这份力量,包括了你的基础知识储量,还包括了你严谨的思维逻辑。

想要掌握强大,必先变得强大,记住,一步一个脚印,才是最快、最靠谱的成长路线,学习过程中,没有捷径。

好了今天的思考题答疑就结束了,如果你还有什么不清楚的,或者有更好的想法的,欢迎告诉我,我们留言区见!