This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
<audio id="audio" title="01 | 学习编程,我到底该选择哪门语言?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/95/fc/95e5cb2f5d269772fb71cbb78c7fdefc.mp3"></audio>
你好我是胡光。欢迎来到我的极客时间专栏。在接下来的两个多月里我将陪伴在你的每一天的清晨或是夜晚在人潮拥挤的上班地铁上在你家里的书桌前再或者是在你公司楼下的咖啡厅里每天10分钟让好学的你有所收获就是我的任务。
## 那些年,我学过的编程语言
面对编程这个话题或许你已是一位编程老手对编程熟悉无比现在是想查缺补漏亦或许你是一个纯新手对编程一无所知学习完全是从0开始。不管哪种情况在我们讨论编程学习的时候怎么都绕不开一个话题那就是语言选择。
鉴于以往的工作经历,我了解或者熟悉的编程语言有十几种之多,包括:
- 最能反映系统本质的 C 语言
- 叫人难以捉摸的 C++
- 天生就格式优美的 Python
- 上古级的 Pascal
- 神奇的函数式编程语言 JavaScript
- 微软系的王牌语言 C#
- 被誉为世界上最好语言的 PHP
- 使用人数最多的 Java
- 能够方便操作系统的 Shell 脚本语言
- 还有我自己开发的一门娱乐级编程语言 Hython
此外,还有一些仅仅是使用过,能看懂的语言,就不列出来了。
你可能会有疑问了,为什么我会这么多编程语言呢?原因很简单,工作中是一个边学习边工作的过程,不同编程语言擅长做的事情不一样,让专业的语言干专业的事情,这是一个程序开发人员最基本的认知,所以我能学会多种编程语言也是情理之中。
你可能又会问了,学习了这么多编程语言,难道不会造成混淆么?其实,编程语言设计者,更多的还是为了让人们使用自己的编程语言进行开发,所以语言设计本身都会有前辈语言的主流特征,这也就是为什么,只要你学习了一门主流语言后,会大大降低你学习第二门语言的成本。可第一门语言的选择,是门技术活,这也是今天我们要讲的主题。
最后你是否好奇:“**我为什么还要自己开发一门娱乐级编程语言呢?**”简单点儿回答就是:自娱自乐。正式点儿的回答就是:经历了开发编程语言这个过程,会对很多语言的特性理解得更深刻,知其然,知其所以然。所以当你自己能开发出一门编程语言的时候,站在开发者的角度再去学习其他编程语言,简直就属于“降维打击”般的学习。
## Pascal、C、Python哪个是你的首选
刚才列举了十多种编程语言,接下来我会以我的亲身经历,来说说学习不同语言都有什么样的体验,以及我在学习这些语言的过程中,遇到的惊喜和踩过的坑。
我从2006年开始接触编程那年我们微机老师向我们推介一门除数学、物理、生物和化学以外的第五大学科竞赛“信息学竞赛”。我当时对计算机的印象还停留在《热血传奇》《半条命》和《红色警戒》那个阶段没错我对计算机的认识都是和游戏相关。当时老师在台上介绍了一大堆东西现在我已经忘得一干二净了。
但有件事至今我还记得:课间的时候,我问老师:老师,编程学好了,能做游戏么?老师说了一声:恩!就是那种不置可否的“恩”,可以自行脑部相声演员岳云鹏的那个"恩"。对于幼小的我来说,这就是肯定句,从此我就踏上了学习编程的道路。
我所接触的第一门编程语言是 Pascal这是一门上古级的编程语言语法风格类似现在的 Python 和 C 的混搭风。现在没有多少人使用的原因我猜是因为其特立独行的语法规则。Pascal 程序需要你在最开始把所有需要的变量都定义一个遍,然后再描述程序的过程逻辑。所以当时我们打趣说,作为 Pascal 的忠实用户,我们比那些使用 C 语言的要思虑周全。
下面放一段 Pascal 的“判断素数”的程序你自己感受一下这门上古级语言那遮挡不住的力量。这段代码你看不懂没关系只是让你对Pascal 有个直观的感受。
```
program JudgePrime; // 程序名称
var x, i : integer; f : boolean; // 变量仅能在此定义
begin
readln(x);
f := true;
if x &lt; 2 then
begin
write('F');
exit;
end;
for i:=2 to trunc(sqrt(x)) do
if x mod i=0 then f:=false;
if f then write('T') else write('F');
end.
```
在这段用 Pascal 写的判断素数的程序中,第一行是程序的名字,第二行就是定义变量部分,并且变量只能在这里定义。从 begin 到 end. 中间就是我们所谓的程序逻辑部分了,是不是有种看 Python 代码的感觉?
你能想象么那时稚嫩的我在定义变量名这件事儿上把26个英文字母都用遍了以后最后不得不使用类似 “aa”“bb”“ccd” 这种变量名,往事不堪回首啊。
过了两年,为了参加 ACM 竞赛,不得不学习 C++ 了,准确地说,是学习 C 语言风格的 C++,就像印度人说英语,怎么说怎么一股咖喱味儿。因此,在转 C 语言之前,我还有点儿担心这个过程会比较坎坷,谁知道,就用了几天的时间,就搞定了我编程时候需要的几乎全部语法。你要知道,我学习 Pascal 的时候,可是花了四个月啊!
从这以后,我才意识到那句话的真正含义,那句话是这样说的:“语言从来不是编程的障碍,思维才是”。所谓“思维”大多数的时候,反映出来的是“编程技巧”,更形式化一点,我们叫它 “编程范式” 和 “算法数据结构”,这部分的东西,我后面还会着重讲解,并且会教你一些提升编程技巧方法。
当时的 C 语言,真是一上来就让我欲罢不能。下面我给你来一段判断素数的 C 语言程序,你来感受一下,就像感受一个刚从牢笼中挣脱出来的鸟,正如汪峰有首歌所唱的:这是自由的感觉!
```
#include &lt;stdio.h&gt;
int main() {
int x; // 定义变量x
scanf(&quot;%d&quot;, &amp;x);
int f = 0; // 定义变量 f
for (int i = 2; i * i &lt;= x; i++) { // 定义循环变量i
if (x % i) continue;
f = 1;
break;
}
if (f) printf(&quot;F\n&quot;);
else printf(&quot;T&quot;);
return 0;
}
```
和上面的那段 Pascal 程序对比你发现差别了么就是变量定义这里C 语言中我想在哪里定义,就在哪里定义!从此我跟 C 语言进入了蜜月期。
C 语言除了可以随处定义变量这个特性它与Pascal 语言还有什么不同的特性呢这里就不得不提到我曾经做数独程序的经历了。有一次我在做一个数独的题目就是每行、每列和每个3*3的宫格内部不重复地填上19这9个数字。
这个问题我曾经用 Pascal 语言做过,解题思路是:每次向函数中传入一个代表数独的数组作为参数,然后不断尝试修改这个数组中相应位置的值,如果尝试进行不下去了,就回到上一个状态,重新尝试。
我就原封不动地将 Pascal 语言的解题思路搬到了 C 语言中但怎么调试都是错的我自己反反复复检查逻辑可就是查不出错误。在挣扎了一下午以后我终于忍不住求助了学长这才发现有一个关键的语言特性C 语言和 Pascal 完全不一样。下面我就说说这个事儿,你现在听不懂没关系,希望你记住这个事情,等到我们一起学习了一段时间以后,你再回头来细看这段。
普通变量向函数中传值就是将原变量中的值拷贝给函数参数的过程这个过程我们称作“实参给形参赋值”。原变量就是实参函数参数就是形参。在这个过程中本质上还是两个变量两片独立的存储位置也就意味着我们对形参的改动不会影响实参。这一点上C 语言和 Pascal 是完全一致的,下面就要说到不太一样的地方了。
<img src="https://static001.geekbang.org/resource/image/33/65/33a9733f80d76da885c16b1bcb09e165.jpg" alt="">
请观察图1在 Pascal 中,如果你将一个数组作为参数传递给一个函数,函数内部还是会复制一份这个数组,也就是说,在 Pascal 中数组的传递过程和普通变量的传递过程没有任何区别。
<img src="https://static001.geekbang.org/resource/image/e9/56/e9a796386b27d4b3eb76b65194b37356.jpg" alt="">
你再观察图2图2展示的是 C 语言中数组作为参数传递的方式,你在图中会看到一个 “0x1234” 的信息,我们称之为地址,就类似于你家的门牌号。当我们传递一个数组时,其实在传递的是数组的首地址,也就是说,无论实参还是形参,实际上指向同一片存储区!
总结来说对于数组Pascal 函数内外是两个互不相同互不影响的存储区。C 语言则是函数内外是同一片存储区,任何一个修改,都意味着外部的数组也被修改了。就是这点差异,导致我用一下午也没找到错误的原因!
看了上面这段,不知道你可不可以理解我当时的困扰。我当时用 Pascal 的语言特性去检查 C 语言的程序,从逻辑上来讲,当然是发现不了任何 Bug 了。当时我还以为这个语言特性,是 C 的特立独行,后来才发现,特立独行的是 Pascal。
从我的这段经历你可以发现,**初学编程选择什么语言作为自己的第一门语言是多么重要**。如果你选择一个比较“偏”的语言形成了它独特的语言特性可能会对你学习其他语言造成不小的困扰。而C 语言,由于它的共通性,很少会出现这样的问题。
最后给你介绍的一门语言就是 Python上面我们欣赏过了从 Pascal 过渡到 C 语言的神清气爽,那你知道如果你学完 C再学 Python是什么感觉么简直就跟吃了一大口芥末一样提神下面来看看 Python 的判断素数的一个程序。
```
#!/usr/bin/env python
# coding=utf-8
x = int(input())
i = 2
while i * i &lt;= x:
if x % i == 0:
print(&quot;F&quot;)
break
i = i + 1
else:
print(&quot;T&quot;)
```
对比上面的两段代码, Python 的这份代码,是不是看着就简洁、清爽?而 Python 为什么被评价为天生就格式优美呢?那是因为,在 Python 中,如果你不按照缩进组织程序的层次关系,你的程序根本没有办法正常运行。
不同的人编写代码可能有不同的风格就像C 语言10个人可能就有10种风格但Python的代码风格就像人的指纹它是天生的不管多少人用Python编写代码可能也只有一种风格。所以无论你是否写过程序在写 Python 的时候,都将写得很漂亮,很舒服。
最适合学习编程的操作系统是 LinuxLinux 中有一个核心设计思想,叫做“一切皆文件”,理解了文件,就理解了整个 Linux 操作系统,这里说的文件,可不是你所认为的常规的 windows文件。Linux 世界中的文件,就像是我们这个世界中的原子一样,是一种本质。
而 Python 中也有一个类似的核心设计思想,就是“一切皆对象”,理解了什么是对象,你就理解了 Python。而这么抽象的概念我不认为我现在可以用两三句话就给你讲明白不过还是那个道理“语言从来不是编程的障碍”关于对象这个概念在你日后对编程的知识逐渐丰富起来以后自然就会明白了。
## 编程入门舍我其谁C 语言
听完了以上三种语言的介绍,你可能已经打定主意准备把界面精练的 Python 作为自己学习编程的入门语言。但是如果看完下面我给你的这张图,你可能需要再考虑考虑。
<img src="https://static001.geekbang.org/resource/image/27/ad/27b55e38da2f15736e4f226a692395ad.jpg" alt="">
上图中的时间,只是一个参考,可能你比较有天赋,会比图中所标记时间用时短,可绝大多数的人,只会多于图上的时间,不会更少的。你会看到图上有两条学习路径,绿色的学习路径用时两个月多一点,红色的学习路径用时四个月。其实这张图,我就是想跟你说明,在我们学习过程中的一个重要的概念:学习路径。一个合理的学习路径,可以帮助你大大缩短整体的学习时长,毕竟你的时间才是最大的成本。
其实正如你所看到的,你用相同的时间掌握了 C 语言后,会对你学习其他语言有很大的帮助。反观,如果你一上来掌握的就是拥有很多奇怪语法特性的语言,不仅要花很长时间学,在日后的学习中,你会发现这些语法特性在其他语言甚至都找不到。基于这类知识锻炼出来的编程思维,是不具备延展性的。所以,在选择第一门语言的时候,一定要选择简洁、高效、不拘泥于语法特性的语言。就像学习武功一样,摒弃掉花拳绣腿,去稳扎稳打的进行练习,才是快速成长的诀窍。
这里,请记住:学习编程不等于学习语言,前者包含后者,也就是说想学好编程,不仅是学好语言,还有很多比语言更难的东西等着你呢。
既然要给你讲编程,我决定选择一门既可以带你潜入底层系统一窥究竟,又可以顺畅简洁表达逻辑,还没有特别多奇奇怪怪的语法特性的语言。我希望借助这门语言,让你在学习编程的过程中,能够专注于编程思维训练本身,帮助你一步一个脚印地学习编程,培养编程思维。这门语言,就是我们前文说到的 C 语言。
我有朋友是这样形容 C 语言的,我觉得很贴切,拿过来用一下,他说:学编程就像是学乾坤大挪移,而 C 就是语言中的九阳神功。
## 推荐书籍
专栏里面呢,由于篇幅有限,没有办法穷尽 C 语言的所有知识。不过,我讲的会是一些容易被你忽视的,容易被你误解的,以及你自学不容易学会的知识点。而关于 C 语言更多的知识,我专门买了市面上最畅销的 15 本C 语言的书籍,经过一番筛选之后,我选出来了以下三本小册子,推荐给你。之所以说是小册子,那是因为他们每一本较其他 C 语言的相关书籍都很薄,内容也很详实准确,并且在内容上,三本有着递进的关系。
<li>
<p>**第一本:《啊哈 C 语言》**<br>
这本由电子工业出版社出版的《啊哈 C 语言!》被叫做“厕所 C 语言教科书”。这不是在说这本书很差,恰恰相反,这是一本浅显易懂的 C 语言入门书籍,即使是利用蹲马桶的时间看上一会儿,你也是看得懂的。并且和书籍配套的还有《啊哈 C 语言!》特别版编译器,会使得你在学习 C 语言基础知识的时候,更加轻松,自在。</p>
</li>
<li>
<p>**第二本《C 专家编程》**<br>
这本由人民邮电出版社出版的《C 专家编程》,会是你入门 C 语言以后的第二本必备书籍。这本书,会从 C 语言的发展历史讲解 C 语言中一些语法特性,以及相关语言特性当初被设计的目的,以及现有的缺陷,会给你一个更深层次的解释。并且,作者给你展现的,不仅仅是教你 C 语言语法,更多的是在给你讲 C 语言是怎么被设计出来的。这会使得你对于你今后所写的每一行代码,都会理解得比旁人深刻。</p>
</li>
<li>
<p>**第三本《C 缺陷与陷阱》**<br>
这本也是由人民邮电出版社出版可以说是《C 专家编程》的延续,针对性会更强,直接指出 C 语言中各种各样的问题,并且加以分析。正所谓人无完人,那么由人所设计出来的语言,当然也没有完美的。你作为外行的时候, C 语言的美足够好好体会和欣赏,可想成为内行,你就必须要知道你所使用的工具,有什么缺点和短板,这样才能真正的在日后应用中,游刃有余。</p>
</li>
## 课程小结
说了这么多今天我只是想让你记住一件事情不同的学习路径会有不同的时间成本。C 语言只是我们入门编程的一个载体,也是最有效、最深刻的一个载体。从 C 语言入手,会使得你的总时间成本最低。永远记住,扎扎实实,稳扎稳打,才是真正的捷径。
最后呢,请你想一想,在你的学习过程中,有没有哪块知识,是你身边的行业前辈们告诉你很重要的,你一开始忽视了,然后过了好久,才发现,前辈说的对的,欢迎留言给我。
我是胡光,今天我们就先聊到这儿,下期内容,我们不见不散。
>
我录制了一个关于编程环境说明的视频,如果有对编程一点也不熟悉的朋友,可以看看这个视频,了解一下编程环境。
<video poster="https://static001.geekbang.org/resource/image/6e/97/6ea2225ab478dd8cf93a6579edda7497.jpg" preload="none" controls=""><source src="https://media001.geekbang.org/customerTrans/8ea7d4d8f077bad948e0480848916f24/5c4ac391-16f6578f338-0000-0000-01d-dbacd.mp4" type="video/mp4"><source src="https://media001.geekbang.org/cdf2e7a71d7b4dbabae8a63438ea1abc/2726b8360ed745218f57c9cbee678e86-624b0e8d2150fbfc3a7c975d0a024ed4-sd.m3u8" type="application/x-mpegURL"><source src="https://media001.geekbang.org/cdf2e7a71d7b4dbabae8a63438ea1abc/2726b8360ed745218f57c9cbee678e86-ade943b9d78465c2ce45459c2e8101d6-hd.m3u8" type="application/x-mpegURL"></video>

View File

@@ -0,0 +1,243 @@
<audio id="audio" title="02 | 第一个程序:教你输出彩色的文字" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/08/d1/08934d18eb03abfea8dd7f7c153d30d1.mp3"></audio>
你好,我是胡光。从今天开始,我将带你正式进入 C 语言的编程世界。我们总会听到这样一种说法:兴趣是最好的老师。引起你对编程的兴趣,就是今天这讲的目的。如果你之前对于 C 语言的认知还停留在黑白纯色阶段的话,今天就让我们一起来进入一个彩色的 C 语言世界,来,跟我一起给它点儿颜色!
## 今日任务
先来看看今天这10分钟我们要完成的任务。今天你将会学到如何设计一个读入字符串并且按照字符串信息中规定的颜色输出相应颜色文本内容的方法程序。
例如当我们:
- 输入red:color content 输出红色的color content
- 输入blue:color content输出蓝色的color content
- 输入yellow:color content输出黄色的color content
下面这张图呢,就是当你完成这个任务时,你程序应该有的一个输出效果:<br>
<img src="https://static001.geekbang.org/resource/image/7e/17/7e32da2b4dcf06f100f6c9c2cf65c617.jpg" alt="">
那么想完成这个任务,我们都需要做哪些准备呢?又有哪些概念需要我们理解和掌握的?请你多点耐心,听我一步步讲。
## 必知必会,查缺补漏
#### 1.输出函数:让计算机“说出话”
我还记得我当年学习C语言的时候最兴奋的地方就是计算机可以根据我的“指令”打印出一句我想说的话来。这个功能虽然简单但它也预示着我们可以初步“控制”计算机了。下面我附了一段代码代码后面有相应的中文说明你可以先看看。
```
#include &lt;stdio.h&gt; // 文件包含,之所以能使用 printf跟这行代码有关
int main() { // 主函数部分,整个程序的入口位置
printf(&quot;hello my friend!&quot;); // 打印函数
return 0; // 暂不介绍,不是重点
}
```
不知道你有没有理解这段代码,我多啰嗦两句。上面代码中,// 后面的内容属于注释,它是用来说明代码功能的,不属于程序部分,而且就算写在程序里面,也不会影响程序逻辑的正确性。
如果你在编译器中运行上面这段程序,就会看到如下一段输出:
```
hello my friend
```
恭喜你,现在你已经成功与我们的计算机朋友打招呼了,这是一个好的开始,不是么?
printf 函数就是所谓的“输出函数”,现在你可以尝试在 printf 函数的双引号中间换一些其他内容来试试效果了。但要注意printf 后面的括号和双引号(且是英文输入法环境下)是必须要有的,其中双引号里面的内容才是最后程序输出的内容。至于为什么是这样,你也不用想,一开始,先死记住就可以了,或者换句话说,这就是规则。有些规则,本来就没有为什么。
我学习计算机的时候我的老师就让我把上面的代码敲了N遍最后都成了肌肉记忆。现在想想也是这么回事。
上面那段代码,如果你玩够了的话,可以将 printf 函数换成下面的内容,看看输出结果:
```
printf(&quot;Hi, my friend:\n\tthis is the first day I know you.&quot;);
```
你所看到的输出内容,应该与下面这段内容相似:
```
Hi, my friend:
this is the first day I know you.
```
我们看到有了换行效果,且第二行开头有了缩进。
你要是使用过Word的话应该知道 Tab 键吧,对,\t 的效果就相当于在相应位置敲击了 Tab 键, \n 的效果就相当于敲击了换行。在C语言中\t、\n都属于转义字符还是和上面一样它是C语言定义的规则你也先不用问为什么记住它然后多用几次就可以了。下面这个表里我还给出了一些更多的转义字符你可以拿来玩一玩。<br>
<img src="https://static001.geekbang.org/resource/image/84/b3/8400db71ab5a307ea0a5c4b14f9b8db3.jpg" alt="">
#### 2.类型与变量:组织语言让计算机理解你的意思
现在我们来假设一个场景,在一片硝烟弥漫的战场上,你身处其中,需要将战况传回指挥部,以便指挥官做出下一步的战斗指示,你可能会将如下信息回传:
>
<p>报告指挥官,敌军兵力大致如下:<br>
重步兵100人<br>
坦克4辆<br>
火箭炮6门<br>
报告完毕,请总部做出下一步战斗指示!</p>
这个场景中100、4、6 都是对抽象的战场环境的客观且具体的数字描述。
现实世界就像这个战场一样,是抽象的,而计算机的世界是具体的、可计算的。从现在开始,你应该注意学习如何将现实世界的“**信息**”,映射到计算机世界中“**数据**”的技巧。
下面就来看现实生活中几种常见的信息,以及相应信息在计算机中的表示:<br>
<img src="https://static001.geekbang.org/resource/image/8b/7f/8b14b84efaa6ef6a1bfb9ea6c33ef57f.jpg" alt=""><br>
在上表中金拱门有多少家是一个整数因为不可能出现0.5这样的半家所以在计算机中表示为int的整数类型巨人的身高则有零有整所以在计算机中表示为float或double的浮点数类型而一个人的名字就不能用数字类型来表示了而是采用字符串类型来存储。
可以看到,我们说到的这几种基础数据类型,用来代表不同种类的信息。
在现实生活中,你可能会把各种信息记录在纸条上,或者本子的某个地方。在程序中,我们把这些信息,记录在一些叫做“变量”的东西里面。注意,类型和变量是两个完全不同的概念。
下面我们来看一个简单的变量示例:
```
#include &lt;stdio.h&gt;
int main() {
int a = 167, b = 543; //定义变量a,b
printf(&quot;%d + %d = %d\n&quot;, a, b, a + b);
return 0;
}
```
在上面的程序中a、b就是变量167、543就是数据。那么167、543这样的数据是什么类型呢我们看到它们是整数所以用的是int 。可以看到我们定义了两个整数型变量a、b并把数据167、543分别放到了变量a和变量b里进而实现了程序目的。
所以,这里我划个重点,**变量是用来存储数据的**。你理解了吧?
上面例子中的 printf 函数虽然复杂一点儿,但其实简单来说就只有如下两部分:
1. “%d + %d = %d\n”叫做**格式控制字符串**,其中 %d 是整型的“格式占位符”。
1. a, b, a + b叫做**参数列表**,每一项依次对应一个“格式占位符”要输出的内容。
“格式占位符”与参数列表中的项一一对应,按照顺序,第一个%d与a对应第二个%d与b对应第三个%d与a+b对应。在输出内容时会被替换成为对应项的内容。例如上述程序的输出结果如下图所示
<img src="https://static001.geekbang.org/resource/image/70/ce/703986ddac7707f86bf0c0aee552a2ce.jpg" alt="">
你会看到,相应的 %d 被按顺序替换成了变量 a、变量 b以及表达式 a + b 的内容。
我们利用 printf 函数输出了二者加法表达式的值。至此,这个程序之于我们而言,已经具备了一个简单计算器的基本功能了。
下表是一些常用的“类型”与其“格式占位符”之间的对应关系,同样,还是不用问为什么,先试着去用,把它当做规则记住就可以了。<br>
<img src="https://static001.geekbang.org/resource/image/50/c5/500329dcf91c14904bd318db91e18ec5.jpg" alt="">
#### 3.输入函数:让计算机“捡起”你的话
前面我们已经看过了printf 函数的输出功能,它可以把程序中数据信息输出到屏幕上,这个屏幕,就是现在我们与程序交互的最基本的场所,以后你还会接触其他交互形式,但不急,慢慢来。
你可以把这个屏幕想象成一个桌子,你和计算机坐在桌子的两侧,当程序执行到 printf 的时候,计算机会把数据放到这个桌子上。那么这个时候,如果你往桌子上放了一些数据,计算机如何把这些数据信息“捡起来”呢?
看到这里,请在你的编译器中,输入如下程序:
```
#include &lt;stdio.h&gt;
int main() {
int a, b; //定义变量a,b
scanf(&quot;%d%d&quot;, &amp;a, &amp;b); // 输入函数
printf(&quot;%d + %d = %d&quot;, a, b, a + b); //输出函数
return 0;
}
```
代码的第5行中有一个 scanf 函数,它会帮计算机把你放在屏幕上的数据“捡起来”,就像上文中说的“变量是用来存放数据的”,计算机会把捡起来的数据存储在 a、b 两个变量中。
注意a、b 两个变量前面有一个特殊的符号&amp;(取地址符),在这里暂不做过多解释。
当你运行上面这段程序后,可能会得到如下效果:
```
192 567
192 + 567 = 759
```
第一行是你放到屏幕上的信息,第二行是计算机放到屏幕上的信息。
有了输入函数以后,面对每次不同的计算需求,就不需要修改程序代码了,我们可以直接在屏幕上输入两个需要计算的数据,计算机就会给我们一个满意的答案。
最后我们来看看输入输出函数的返回值:
- printf 函数的返回值,代表向屏幕上打印了多少个字符。
- scanf 函数的返回值,代表成功给多少个变量赋了值(后续再展开讲)。
## 一起动手,搞事情
下面我给出两道思考题,希望你能尽量自己动手查资料解决。
以后,基本每堂课我都会留一些你抬抬脚就能解决的问题,不太简单,可绝不会难上天。我尽量控制,也欢迎你在专栏下面留下意见和建议,更欢迎你将思考过程留在专栏下面。
#### 思考题1位数输出
>
<p>计算一个输入整数的十进制表示的位数?<br>
条件1允许有多余输出的情况下怎么实现<br>
条件2只允许输出数字位数的时候又该怎么实现<br>
请单纯考虑使用 printf 一族函数方法实现。</p>
#### 思考题2读入一行字符串
>
<p>请大家自行实现一个读入一行字符串,并且输出相关内容的程序,思考如下:<br>
条件1如果字符串中没有空格怎么实现<br>
条件2如果字符串中有空格又该怎么实现<br>
请单纯考虑用 scanf 一族函数方法实现。</p>
## “hello world”显示什么颜色你做主
前面我们讲了如何使用输出函数让计算机显示什么,又讲了如何利用类型与变量等组织一句计算机可以理解的话语,以及如何让计算机接收到你想传达信息的渠道。接下来,就回到我们今天的任务:按照字符串信息中规定的颜色,输出相应颜色文本内容的方法程序。
在我讲输出函数的时候,提到转义字符,其中有一个转义字符就是用来操作颜色的,它就是:\033。下面就让我们具体看一下它是如何工作的。
设置颜色,以 \033 开始,也以 \033 结束,这种首尾呼应的结构对记忆比较友好。具体格式如下:
```
格式:\033[属性代码{;属性代码...}m输出内容\033[0m
```
我们来介绍几个属性代码,并加以使用:
- 0 代表关闭所有属性
- 1 代表高亮/粗体
- 4 代表下划线
- 33 代表黄色
如果你在你的 Linux 环境中输入如下代码:
```
#include &lt;stdio.h&gt;
int main() {
printf(&quot;\033[1;4;33mhello color\033[0m&quot;);
return 0;
}
```
运行以后你就会在终端看到一行高亮且带有下划线的“hello color”字符串。如下图所示
<img src="https://static001.geekbang.org/resource/image/d7/26/d7116bf318af80c5d1900e07b89bf726.jpg" alt="">
至此,我们就准备好了完成课程任务的所有基本元素了,下面,就请你自行尝试一下本任务吧,即使做不出来,也不用担心,我会上传参考代码。
参考代码中,会涉及一些我们后续才会学到的编码技巧,你暂时看不懂也没关系,只需要欣赏它就好了。毕竟,想要进入一个行业的前提,是要懂得这个行业的审美标准。
## 课程小结
今天是我们第一次真正接触C语言所涉及的专业词汇可能有点多你可能看完后对一些概念也是分不太清楚但不要担心当你接触的多了这些术语渐渐都会清晰明白。下面呢我来给你总结以下今天的重点内容
1. printf 函数是用来输出内容的方法,包含了格式控制字符串和参数列表两部分。
1. 类型和变量是两个完全不同的概念,变量是用来存储数据的。
1. 使用格式占位符的时候,需要对应到相关类型,整型对应到 %d字符型对应到 %c浮点型对应到 %f双精度浮点型对应到 %lf。
总之,今天这堂课你已经知道如何和计算机打招呼,以及如何让计算机“听”你说的话了。
我是胡光这是我带你第一次接触C语言你还有什么疑惑或其他想知道的我们留言区见。

View File

@@ -0,0 +1,291 @@
<audio id="audio" title="03 | 判断与循环:给你的程序加上处理逻辑" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/34/10/34a12443e73834cce3a40a5d87ea4510.mp3"></audio>
你好,我是胡光,咱们又见面了。不知道上一讲的内容,你自己做练习了么?你是否还觉得 C 语言枯燥无味呢?不管你有没有练习,我都还要啰嗦下,学习编程,就像是学骑自行车,你只看别人怎么骑,你只看自行车的原理,那永远也不可能学会骑自行车,对于你来说,唯一的捷径就是多练习,多思考。在上一讲小试牛刀之后,今天我将带你领略一下算法和逻辑层面的小惊喜。
## 今日任务
先来看看今天这 10 分钟我们要完成的任务。日期这个概念你肯定不陌生生日对你我来说都很重要如果你身边有2月29号过生日的小伙伴恐怕最少4年才能为他/她办一次生日宴。今天我们的这个任务,就和日期有关系。如果我给你一个由年月日组成的日期,再给你一个数字 X你能否准确地让程序输出 X 天后的日期呢?
例如下面这个数据:
```
1989 11 20
20
1989 12 10
```
数据中给出了1989年11月20日这个日期然后问你20天后的日期是多少你的程序应该输出1989年12月10日。特别需要注意的是在这个任务中你需要考虑到闰年中2月份的特殊性闰年的2月有29天。今天我们就学习如何用计算机解决这类任务吧。
## 必知必会,查缺补漏
根据对任务的理解,我们可以分成两步来思考这个问题:
- 第一步我们来思考如何求解1天后的日期在求解1天后日期的过程中我们涉及到的核心问题就是判断日子是否跨月月份是否跨年即判断
- 第二步:是要让计算机重复 X 次1天后日期的计算过程即重复循环做这件事。
要解决这两个难题,我们需要讲讲 C 语言中的一些基础知识,其中包括了程序中用于**逻辑分支判断的分支结构**,以及可以**重复做大量事情的循环结构**。听着这些专业词汇,你可能有点懵,别怕,等我下面讲了它们是什么意思,你就会感觉这些其实很简单。
#### 1.给代码添加判断能力“if…else”语法结构
我们一起来读下这句话:如果隔壁商店有酱油,就买酱油,否则就买点儿醋回来。可以看到,这句话用了“如果……就”的假设关系关联词,“如果”后面接的是假设条件,“就”后面接的是条件成立后的结果,“否则”接的是条件不成立后的结果。
现在我们想把计算机变成我们的小帮手,就必须要有一种语法,能够表达 “如果……就……否则……”的逻辑,这种语法,就是接下来我要介绍给你的 “if…else”语法结构。
在这里我将简单介绍“if…else”语法结构主要目的是让你看懂今天我们这个任务的代码后续在课程逐步展开的过程中我还会逐步的引入这个语法结构的一些其他知识点。
我们先来看 “if…else”最基本的语法结构
```
if (条件表达式) 一条语句1;
else 一条语句2;
```
简单来说if 和 else 都是关键字,代表分支逻辑中的 “如果”和 “否则”。if 后面跟着的括号里面,需要放一个条件表达式,条件表达式如果成立,程序会执行 “语句1”否则就会执行 “语句2”。下面我来举个例子你就明白了
```
#include &lt;stdio.h&gt;
int main() {
int a, b;
scanf(&quot;%d%d&quot;, &amp;a, &amp;b);
if (a == b) printf(&quot;a is equal to b!\n&quot;);
else printf(&quot;a is not equal to b!\n&quot;);
return 0;
}
```
这段程序中,首先定义了两个变量 a 和 b然后通过输入函数scanf给变量 a、b 赋值。之后就是重点部分了,根据我们上面所说的,如果 if 后面的条件表达式成立,那么就会输出 “a is equal to b!\n” 否则就会输出 “a is not equal to b!\n”。
最后,我就再带你理解两个概念,一是条件表达式是什么,二是怎样理解 if 后面跟一条语句,所谓一条语句的概念范围是什么。
回到上面的程序中,你会看到程序中的 if 后面跟着一个括号,括号里面放着一个表达式,这个就是我们所谓的条件表达式,而这个括号,是必不可少的。我们发现,这个条件表达式用**两个等号**连接 a 和 b作用是判断 a 和 b 里面存储的值是否相等。可千万别跟赋值表达式的**一个等号**弄混了。
说到这里我要告诉你一个重要的事实变量有变量对应的值表达式也有表达式对应的值。那么例如上面代码中的条件表达式“a == b”所对应的值是什么呢其实就是数字 1 或者 0分别表示“条件成立”a与b的值相等和“不成立”a与b的值不相等
>
<p>**延伸内容:**<br>
那么除了条件判等以外还有哪些条件运算符呢有判断不等于的“a != b”大于的 “a &gt; b”小于的 “a &lt; b”大于等于的 “a &gt;= b”小于等于的 “a &lt;= b”逻辑非 “!(a &gt; b)”,等价于 “a &lt;= b”。同时多个条件表达式还可以用逻辑 &amp;&amp; 和 || 进行连接,这个后面我再跟你细说。</p>
事实上if 的括号里面,不仅可以放条件表达式,类似于 “a - b”这种的表达式也是可以当做 if 的条件的。
当一般表达式作为条件的时候if 是怎么执行的呢?很简单,记住:**表达式的值,非 0 即为真**。例如,下面两行代码,效果等价:
```
if (a != b) printf(&quot;a is not equal to b!\n&quot;);
if (a - b) printf(&quot;a is not equal to b!\n&quot;);
```
你会看到,第二行代码中,用 “a - b”代替 “a != b”取得了同样的程序运行效果。因此你只需要重点思考表达式 “a - b” 什么时候结果非 0 即可,是不是当且仅当 “a != b”时“a - b”的结果非 0根据之前所说的非 0 即为真,那么 if 条件也就算是成立了。
最后,我们来讲一下怎么理解“**if 后面跟一条语句**”这个概念,其实指的是 if后面的条件成立时所执行的代码。这里我们的重点是要理解一条语句都包含什么形式大致可以分为如下几类。
**第一种,空语句**,就是什么都没有,单纯以一个分号结尾,例如下面这行代码,即使条件成立,也不会有任何实质上的操作。
```
if (a == 3) ;
```
**第二种,单一语句**,比空语句多了语句内容,以分号结尾,例如下面这行代码,当条件成立的时候,会输出 “hello geek!”。
```
if (a == 3) printf(&quot;hello geek!\n&quot;);
```
**第三种,复合语句**,被大括号包裹,中间是若干条语句,例如下面这段代码:
```
if (a == 3) {
printf(&quot;hello geek1!\n&quot;);
printf(&quot;hello geek2!\n&quot;);
printf(&quot;hello geek3!\n&quot;);
}
```
当条件成立以后,程序会依次执行大括号里面的三条语句:
```
hello geek1!
hello geek2!
hello geek3!
```
**第四种,结构语句**,以 ifforwhile 等开头的分支语句或循环语句,例如下面这段代码,首先会先判断 a==3如果条件成立才会执行下面第二条 if 分支语句,当第二条 if 分支语句的条件也成立的时候,才会输出 “hello geek!”。
```
if (a == 3)
if (b == 4) {
printf(&quot;hello geek!\n&quot;);
}
```
由此可以看到if 后面所谓跟着的一条语句,还真是丰富多彩,你可以在后面跟上像上面代码中所写的 printf 函数调用的单一语句,也可以用一个大括号,里面放上若干条语句,亦或是 if 后面跟着另一个 if 也是可以的!你看这种组合能力,有没有点儿像乐高玩具?
至此,你就已经掌握了基础的将 “如果……就……否则……”这种逻辑结构转换成代码的能力了。你的计算机,终于有了“判断力”。
#### 2.给程序添加重复执行功能for和while语句
想想小的时候你最讨厌什么事情我最讨厌的就是被老师罚写汉字错一个字罚写100遍那种的在我看来真的是在浪费时间。可当我学了程序以后我发现程序真的是特别擅长做这种重复的工作而实现这种功能的语法结构就是 for 语句和 while 语句。
我们先来看语法结构较简单的 while 语句:
```
while (循环条件) 一条语句;
```
以 while 关键字开头后面跟着循环条件也就是一个条件表达式然后是一条语句。while 循环,顾名思义,当循环条件成立时,就会执行一次后面的语句,之后就是再判断循环语句是否成立,如果成立就再执行,一直到循环条件不成立为止。
下面呢,我们就用最简单的形式,利用 while 循环输出前100个正整数
```
int i = 0;
while (i++ &lt; 100) printf(&quot;%d\n&quot;, i);
```
这段代码里面,出现了一个你之前没有见过的语法,就是 i++,这也是表达式,这个表达式的值等于 i 之前的值当这条表达式执行完以后i 会变成 i + 1 的值。例如起初i = 2i++ 表达式的值就等于 2可表达式执行以后你要是输出 i 的值,这时 i 实际等于 3。
上面代码中,我们是用 i++ 表达式的值和 100 进行比较,表达式的值会遍历 0 到 99所有的值由于 printf 在 i++之后输出 i 的值,所以实际上每次输出的都是 i + 1之后的值也就是说 printf 会输出 1100 所有值。具体的你可以参考下面的这个程序流程图。
<img src="https://static001.geekbang.org/resource/image/a3/f4/a37e05821040b86c2226ac60b95dacf4.jpg" alt="" title="while 循环流程图">
另外,顺便再问你个问题,你还记得上一节课里,我们学到的\n和%d分别代表什么意思嘛如果不记得记得回去再复习下。
有了 while 循环语句的加持之后,是不是重复做某件事,变得很方便了呢?不急,下面我要给你介绍的是功能更为强大的 for 语句。还是先来看一下 for 语句的结构吧:
```
for (初始化①;循环条件②;循环后操作③) 一条语句④;
```
正如你看到的,我把 for 语句的四部分已经给你标出来了for 语句会按照 ①②④③②④③…循环,直到某一次循环条件②不成立了为止。
你会发现,①这一部分只在循环开始时执行了一次,真正所谓的循环,是以循环条件②,一条语句④以及循环后操作③组成的。
如果要是用 for 循环输出 1100 所有值,会显得代码更清晰一些:
```
for (int i = 1; i &lt;= 100; i++) printf(&quot;%d\n&quot;, i);
```
上面这段代码,就是用 for 循环实现了和之前 while 循环相同的功能。
看了 for 循环和 while 循环以后你可能会问实际中哪种循环用的比较多我个人经验来说for 循环用的比较多,因为 for 循环每一部分都非常明确对于比较复杂的循环控制过程for 循环写出来以后,一般都会比 while 循环可读性强。
为了让你感受到 for 循环真正的威力,写一段代码,让你感受一下:
```
for (int i = 1, k = 0; i &lt;= 48; i++, k += 2) printf(&quot;%d\n&quot;, k);
```
上面这段程序中我们用到了两个同步信息变量i 和 ki 从 1 到 48保证循环了48次代码中“k+=2”表示k每次增加 2 也就是说在这个过程中i 遍历了 1 到 48 这 48 个整型值,而 k 同步地遍历了从 0 开始的前 48 个偶数。这段代码的意思其实就是打印出从0开始后的共48个偶数即0、2、4……92、94。
如果用while来实现这个目的知道怎么写吗你可以自己在计算机上试一下。
## 一起动手,搞事情
#### 思考题:打印乘法表
>
<p>使用循环和条件判断打印一个格式优美的66乘法表<br>
要求1输出内容及样式参照下面给出的样例<br>
要求2每两列之间用 \t 字符进行分隔,行尾无多余 \t 字符</p>
```
1*1=1
1*2=2 2*2=4
1*3=3 2*3=6 3*3=9
1*4=4 2*4=8 3*4=12 4*4=16
1*5=5 2*5=10 3*5=15 4*5=20 5*5=25
1*6=6 2*6=12 3*6=18 4*6=24 5*6=30 6*6=36
```
## “日期计算器”程序完成
准备完了所有的基础技能后就让我们来完成开始说的那个任务吧我们来思考一下哈首先我们需要有一个循环循环每一次让计算机帮我们计算一次下一天的日期。每次在计算下一天日期的过程中先让日子加1判断是否跨月如果跨过了一个月份就让日子从1开始让月份加1再判断是否跨年如果跨年了就让月份从1开始年份加1。
如上的过程中有一个关键问题需要你注意就是2月份的月份天数的计算方法咱们来简单回顾一下闰年的规则年份满足以下其中一条即为闰年
- 能被4整除但不能被 100 整除;
- 能被 400 整除。
如果把闰年的规则翻译成逻辑判断,应该是下面这个样子:
```
if ((year % 4 == 0 &amp;&amp; year % 100 != 0) || year % 400 == 0) ...
```
下面就让我们把思路过程转换成程序过程:
```
#include &lt;stdio.h&gt;
int main() {
int y, m, d, X; // 定义存储 年月日 和 X 的变量
scanf(&quot;%d%d%d&quot;, &amp;y, &amp;m, &amp;d); // 读入年月日
scanf(&quot;%d&quot;, &amp;X); // 读入 X 值
for (int i = 0; i &lt; X; i++) { // 循环 X 次,每次向后推一天
d += 1;
switch (m) {
case 1:
case 3:
case 5: { // 第一部分逻辑
if (d &gt; 31) d = 1, m += 1;
if (m == 13) m = 1, y += 1;
}; break;
case 4:
case 6: { // 第二部分逻辑
if (d &gt; 30) d = 1, m += 1;
} break;
case 2: { // 第三部分逻辑
if ((y % 4 == 0 &amp;&amp; y % 100 != 0) || y % 400 == 0) {
if (d &gt; 29) d = 1, m += 1;
} else if (d &gt; 28) {
d = 1, m += 1;
}
} break;
}
}
printf(&quot;%d %d %d\n&quot;, y, m, d);
return 0;
}
```
上面这段程序是个半成品只处理了前6个月的情况并且用到了**switch…case 的分支结构**,与 if 结构类似,都是用于做逻辑分支判断的。关于这部分的内容,给你留个小作业,自学一下 switch…case 分支结构然后按照自己的理解补全上述程序使得上述程序可以处理一年中12个月的全部情况。
虽然这个程序中有一部分内容需要你进行自学,可你也不要担心,我还是会跟你详细解释上述程序设计的思路。读入部分的代码,相信你现在已经可以很好的掌握了,这一部分就不展开解释了。程序整体设计中,是用 for 循环包裹了 switch…case 结构for 循环负责循环 X 次,每次在循环内部,都将对日子变量 d 进行加 1 操作,而在 switch…case 结构内部,主要是处理跨月和跨年的问题。
你会看到 switch…case 结构中主要分成三部分逻辑第一部分逻辑主要处理天数为31天的月份由于12月也是31天所以当本月是12月并且发生了跨月变成了13月说明是到了下一年的 1 月,需要将年份 +1月份置为 1 月。第二部分逻辑主要处理天数为30天的月份。第三部分逻辑主要处理 2 月份的情况在这里程序中分成两种情况来讨论闰年和非闰年闰年的时候判断日子是否超过29天非闰年判断日子是否超过28天。
我保证,在你尝试补全上述程序的过程中,你会发现,上述程序易于修改和补全,你要是能试着将上述程序修改成 if 分支结构,那就更好了。这样你将对上述程序结构的美,会感受的更深刻。
## 课程小结
最后呢,来总结一下今天所学的重点。今天呢,我们主要学习了两种程序流程控制结构,一种分支结构,主要以 if 语句为代表,另一种循环结构,以 for 循环和 while 循环为代表。如果说你只想记住几点的话,那么应该是以下几点:
1. 熟练掌握分支和循环结构的执行顺序,这一点很重要。
1. if 语句,首先判断条件表达式的真假,如果为真,则执行 if 里面的语句。
1. for 循环,分成四部分,其中②④③部分,构成了一个循环,第①部分是用做初始化的。
1. 所谓一条语句的概念,包括了空语句,单一语句,复合语句和结构语句。
以上这 4 点要牢记哦,尤其是其中的分支和循环结构的执行顺序,因为掌握和理解了程序的执行顺序,才是分析程序,理解程序的第一步。
好了,今天就到这里了,下期我将带你来做一个小总结,我将带你学习一个有趣的圆周率的计算方法,我是胡光,我们下期见。

View File

@@ -0,0 +1,154 @@
<audio id="audio" title="04 | 随机函数:随机实验真的可以算 π 值嘛?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6a/db/6a8f564abd3519c251927b057461eadb.mp3"></audio>
你好,我是胡光。上次课里关于判断和循环的内容你做练习了么?其实这两部分内容都不复杂,你想,判断就是“如果…就…”,而循环就是重复做一件事情。程序里,只是我们换了一种方式来描述和抽象这两个场景。
## 今日任务
今天的任务其实也是和上次讲的内容有很大关系。如果你对上次讲的内容不理解,我建议你先再好好回顾下上次讲的知识,然后开始今天的任务。
先来看看今天这 10 分钟我们要完成的任务。圆周率 π 对你来说肯定不是一个陌生的概念,它指的是圆的周长与直径的比值。在古代,数学家们为了计算 π 的精确值想尽方法,可能穷尽一生也不过精确到小数点后几位而已。但到了现在,你可能不相信,只要你知道 π 的定义,就可以利用编程轻易计算出 π 的值。那究竟怎么做到呢?
我们先来看一个用蒙特卡罗法计算 π 的示意图:<br>
<img src="https://static001.geekbang.org/resource/image/bb/3f/bb18f5516dedc5c1d5ae2aa610ce523f.jpg" alt="" title="图1蒙特卡罗法示意图">
通过观察图1请你思考一个问题如果你随机地在正方形区域中选择一个点那么这个被选择的点也恰巧落在圆形红色区域的概率是多大这个问题很简单就是圆面积和正方形面积的比值简单计算就可以得到这个概率值应该是 π/4。
也就是说,如果我们做大量的随机实验,最终落在圆内部的次数除以总次数再乘以 4 得到的值,应该接近圆周率 π。随机次数越多,所得到的数值越接近 π。你肯定不喜欢做这种重复的“重体力”劳动,但如果你写好编程,让它帮你做这件事,那就简单容易快捷多了。计算机可是一个不怕辛苦、没有怨言的好帮手,今天就让它来帮助我们完成这个任务吧。
## 必知必会,查缺补漏
思考一下其实要完成上面这个任务我们已经具备了一些基础知识比如说分支结构if…else可以帮助你判断某个点是否在圆内部循环结构for/while可以帮助你完成大量的重复实验。
说到这里,你会发现,面对今天的这个任务,我们还需要做到随机选点,那么这个随机操作,在计算机中应该如何来完成呢?今天我将告诉你的就是程序语言中的随机函数,准备好了么?让我们开始吧。
#### 1.真随机与伪随机
说到随机,就需要说一下真随机与伪随机的概念了。
所谓**真随机**其实并不难理解,我们以掷骰子为例,掷出 16 点的概率均为 1/6如果我问你上一次掷出的点数是4那么下一次掷出 6 点的概率是多大?你会发现,依然是 1/6我们称这两次掷骰子的事件是相互独立的上一次的结果和下一次之间没有必然联系。<br>
<img src="https://static001.geekbang.org/resource/image/a8/9f/a8e3c9f39a4cd913891d10f35f6f369f.jpg" alt="" title="图2真随机示意图"><br>
通过上面这个示意图,你就很容易看出,所谓真随机,就是我们通常意义下的随机。那么什么又是伪随机呢?从名字上面来看,伪随机,带个伪字,说明本质上不是随机,可看起来是随机。
下面请你注意观察下图的两个数字循环序列:<br>
<img src="https://static001.geekbang.org/resource/image/f4/7a/f44e2aafc5cfd8e4b0f8d50434d5b17a.jpg" alt="" title="图3显然规则与非显然规则">
你观察上面这两个数字序列会发现第一个序列是123456这是一个有明显规律的序列你一定不会觉得这个序列是随机生成的。另一个序列是421635好像没有什么明显的规律相比于第一个序列你是不是更偏向于相信第二个序列是随机生成的序列呢
第二个序列就是我刚刚所说的伪随机看起来像是随机序列可实际上4后面一定会出现22后面一定是11后面一定是6也就是说前一个数字决定了后一个数字。
计算机中究竟如何制造出来这样一个伪随机序列呢,这个问题留到后面的 “动手搞事情” 中我会使用一行简单的数学公式制造一个包含100个数字的伪随机数字序列类似于上图中第二个序列的加大版。
最后你会发现,**所谓计算机中的伪随机数序列****就是类似第二个序列那样的,没有什么明显规律的一个规模更大的循环序列。**
现在你知道为什么叫做伪随机了吧,那是因为,一旦要是上一个随机函数的值确定了,下一个数字也就确定了,而纯正意义上的真随机,应该是前后两次出现的数字是两次独立事件,没有任何关系。
#### 2.程序中的随机函数
现在我们所接触到的语言中,没有真随机,全是伪随机。也就是说,语言中给我们准备好了一个随机函数,这个随机函数会根据上一个**随机值**和一个**固定的计算规则**,得到下一个**随机值**。
而你在其他资料中可能会看到**随机种子**这个概念设置随机种子就是在设置随机函数中记录的上一个随机值。例如上面我们自己做出来的6个长度的伪随机序列如果随机种子设置为值1我们得到的值依次是 635421如果设置为值 3那么我们将依次得到 542163。
下面就看看 C 语言中的随机函数的用法吧:
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;time.h&gt;
int main() {
printf(&quot;%d\n&quot;, rand() % 1000); // 永远输出固定值
srand(time(0));
printf(&quot;%d\n&quot;, rand() % 1000); // 每次运行都不同
return 0;
}
```
上面代码中,我们用 rand() 函数,获得一个随机值,这个就是我们前面讲的随机函数,它将依次的返回随机序列中的每一个值。
而 srand() 函数就是设置随机种子的函数也就是设置随机函数上一次的状态值。time(0) 将返回一个时间戳,你就可以把他当成和当前时间相关的一个整型数字。
你会发现,上面这段程序中,在第 6 行代码里,我们虽然使用了 rand() 函数,可每次运行都将输出同样的值,这是因为我们没有设置随机种子,每次运行时 rand() 函数所记录的起始值都相同,所以每次运行输出的随机值也都相同。
而第 8 行代码中,由于我们根据程序运行时的当前时间设置了随机种子,每次运行程序,第 8 行都将输出不同的值。事实上,如果你在 srand() 函数里面设置一个固定值,每次运行程序,结果也都将是一样的,这个你可以自行尝试。
至此,我们就准备好了今天任务的全部基础知识了,接下来做道练习题,锻炼一下吧。
## 一起动手,搞事情
#### 思考题:设计迷你随机函数
>
<p>设计一个循环过程循环100次以不太明显的规律输出 1100 中的每个数字。<br>
要求1规律尽量不明显。<br>
要求2只能使用循环和最基本的运算不允许超前使用数组。</p>
下表是我的程序输出的序列,以供你做参考:
```
5 15 45 34 1 3 9 27 81 41
22 66 97 89 65 94 80 38 13 39
16 48 43 28 84 50 49 46 37 10
30 90 68 2 6 18 54 61 82 44
31 93 77 29 87 59 76 26 78 32
96 86 56 67100 98 92 74 20 60
79 35 4 12 36 7 21 63 88 62
85 53 58 73 17 51 52 55 64 91
71 11 33 99 95 83 47 40 19 57
70 8 24 72 14 42 25 75 23 69
```
## 用有趣的方法计算 π 值
准备完了所有的基础技能后,就让我们来完成开始说的那个任务吧。
我们来思考一下哈首先我们需要有一个循环循环每一次让计算机帮我们做一次实验。每次实验呢让计算机模拟随机选择点的这个过程然后我们需要判断一下随机选择的点是否在圆内部如果在我们就记录一次。最后用落在圆里的次数比上总实验次数再乘以4就得到了 π 的近似值。
这个过程中,你到现在还比较懵的,可能就是随机选点的过程了。那就跟我来看下面代码吧:
```
double x = 1.0 * rand() / RAND_MAX;
```
上述代码中的 rand() 随机函数,返回值的范围是[0, RAND_MAX],通过上述表达式计算,我们就得到了一个[0.0, 1.0] 之间的随机值了。
下面就让我们完善程序:
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;time.h&gt;
int main() {
int n = 0, m = 0;
for (int i = 0; i &lt; 1000000; i++) {
double x = 1.0 * rand() / RAND_MAX;
double y = 1.0 * rand() / RAND_MAX;
if (x * x + y * y &lt;= 1.0) m += 1;
n += 1;
}
printf(&quot;%lf\n&quot;, 4.0 * m / n);
return 0;
}
```
上述代码中我让计算机重复10万次实验每次在坐标轴的第一象限中的 1 * 1 的区域中随机选择一个点,变量 m 记录的是落在圆内部的次数,变量 n 记录的是总实验次数。运行这个程序,在我的环境中,输出的是 3.142096,你可以试一下在你的环境中的运行结果,以及加大实验次数以后,对结果的影响。
是不是很难想象如果没有计算机我们自己将如何来完成这10万次实验呢想想都是很痛苦的过程
## 课程小结
今天这节课你了解了C 语言中的随机函数,以及计算机中随机函数的基本原理。最后呢,总结一下今天的重点,就两点:
1. 计算机中都是伪随机函数,也就是说,下一次的随机值,跟本次的随机值是相关的。
1. 使用 srand 函数设置随机种子,也就是设置伪随机过程中的第一个起始点的位置。
理解了上面这两点,也就算是真正理解了计算机中的随机函数的概念了。
从今天开始,记住,计算机就是你的小帮手了,以后的日子里,请动用你的智力,使用它的体力!随着你的思维逻辑越来越严谨,你会爱上这个帮手的,即使它日后可能会因为一些不知名的小 Bug 惹你不开心,相信我,都是暂时的。
好了,今天就到这里了,我是胡光,我们下期见。

View File

@@ -0,0 +1,252 @@
<audio id="audio" title="05 | 数组:一秒钟,定义 1000 个变量" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e1/c0/e12cf1d352822c4003573932308c80c0.mp3"></audio>
你好,我是胡光,咱们又见面了。通过前几节的学习,你已经了解了基本的程序结构。我们来简单总结一下,其中第一种结构就是顺序结构,它指的是我们所写的按照顺序执行的代码,执行完上一行,再执行下一行这种的。第二种就是分支结构,主要是用 if 条件分支语句来实现,主要特征是根据表达式的真假,选择性地执行后续代码。最后一种就是循环结构,用来重复执行某段代码的结构。
如果把程序比喻成工厂的话,现在你的工厂中已经有了各种各样的流水线,但这个工厂只是能生产产品还不行,还需要有存储的空间。今天,我们来学习的就是如何创建和使用工厂中的库房,本节之后,你的程序工厂就可以开工了!
## 今日任务
先来看看今天这10分钟的小任务吧。今天的任务是这样的程序中读入一个整数 n假设 n 不会大于 1000请输出 1 到 n 的每一个数字二进制表示中的 1 的个数。
我举个例子哈,当 n 等于 7 的时候,我们把 1 到 7 的每个数字的二进制表示罗列出来,会得到下表所示内容:
<img src="https://static001.geekbang.org/resource/image/da/4a/da9aa66b4391bcf6078e6d521d2a134a.jpg" alt="" title="表11到7的二进制表示">
根据表1中的内容如果你的程序编写成功的话程序应该分别输出1、1、2、1、2、2、3这些输出内容分别代表每个数字二进制表示中 1 的数量。
对于这个任务,你想写出来一个可行的程序不难,例如:我们可以循环 n 次,每次计算一个数字二进制中 1 的数量。怎么计算一个数字二进制中 1 的数量呢?这个问题,你可能想采用如下程序来进行实现:
```
int cnt = 0;
while (n != 0) {
if (n % 2 == 1) cnt += 1;
n /= 2;
}
```
我解释下上面这段程序,它每次都会判断 n 的二进制末尾是不是 1如果是 1计数量 cnt 就加 1+=表达式,我这里就不解释了,如果你不理解,可以自己查下),然后将 n 除以2相当于去掉 n 的二进制表示的最后一位,这样就可以用 O(logn) 的时间复杂度(关于这个知识点,你也可以自行查阅相关资料,其实很简单)计算一个数字 n 二进制中 1 的数量。
以二进制数字110为例末尾是0计数量 cnt 不进入计算然后使用二进制除法让110除以2即去掉最后一位的0变成了11此时末尾是1计数量cnt 就加111再除以2变成了1此时末尾是1计数量cnt 再次加1 。最后的n等于1再除以2n变成了0循环结束。
可以看到当我们输入数字6二进制的表示是110时整个程序中计数量cnt 共计算了2次所以最后的输出结果是2 。
关于时间复杂度这个概念,后续我们还会进一步介绍,现在你可以简单地理解成为是程序运行的次数,例如 n=8 的时候,上面循环执行 3 次,也就是 log 以 2 为底 8 的对数的值。
如果你的方法像上面这么做的话,确实是一种可行的方法,可是效率不是很高。今天这个任务的要求是,对每一个数字,请用 O(1) 的时间复杂度计算得到其二进制表示中 1 的个数。O(1) 也就是 1 次,或者是与问题规模 n 无关的有限次例如2次、3次均可。下面就让我们来看看如何完成这个任务吧。
## 必知必会,查缺补漏
#### 1.数组:规模化存储工具
我要给你介绍的第一个帮助我们完成今天任务的工具是:数组。所谓数组,你可以把这两个字对调过来理解,即组数,一组数据。
以往我们定义的变量,都是单一变量,例如:一个整型变量,一个浮点型变量,等等。可当我们要同时记录 n 个整型数据的时候,通过以往的知识,你能实现这个需求么?注意,这个里面的 n 是通过读入的一个变量通常情况会有一个最大范围例如n 不会超过1000。你总不能定义1000个整型变量吧
面对上面这种需求,数组就派上了用场,利用数组,我们可以定义存放一组数据的存储区,用法如下代码所示:
```
int arr[1000];
```
通过上述代码,我们很轻松的就定义了存储 1000 个整型变量的存储区 arr。这里相当于向计算机申请了可以存储1000个整型变量的存储空间。第一个存储整型数据的内存空间也就是第一个整型变量就是 arr[0],第二个整型变量是 arr[1]以此类推。arr 后面方括号里面的东西,我们称之为“数组下标”,数组下标从 0 开始,也就是说,代表 1000 个整型变量的数组,下标范围应该是 0 到 999具体可以参考图1。<br>
<img src="https://static001.geekbang.org/resource/image/dd/a3/dd48cf83f2e2a3510c50a72b9368bca3.jpg" alt="" title="图1数组示意图">
有了数组以后,你就可以轻松的完成读入 n 个整型数据的任务了,参考代码如下:
```
int n, arr[1000];
scanf(&quot;%d&quot;, &amp;n);
for (int i = 0; i &lt; n; i++) scanf(&quot;%d&quot;, &amp;arr[i]);
```
代码中,第一行定义了一个整型变量 n 和一个最多存储 1000 个整型元素的数组空间。第二行接下来读入 n 的值,第三行利用循环结构循环 n 次,循环变量 i 取值从 0 到 n-1循环每次读入一个整型数据存放在 arr[i] 里面。
这样一段程序执行完后n 个整型数据就被依次的存放在了 arr[0] 到 arr[n-1]中。当你想在程序中使用第三个整型数据的时候,只需要访问 arr[2] 即可。当然,上述循环变量的取值范围也可以调整到 1 到 n这样做的话相当于我们将 n 个整型数据存放在了 arr[1]到 arr[n] 处。
#### 2.字节与地址:数据的住所和门牌号
在之前第2篇的学习中不知道你还记不记得一个叫做 char 的数据类型我们称其为字符型。当时在学习的时候我们说字符型数据形如“a”“b”“c”“+”“-” 等被引号包裹着的内容。这次我将带你从 char 类型开始,深入理解两个概念:字节与地址。
什么是字节呢它是计算机中最基本的存储单位就像一个一个不可分割的小格子一样存在于我们计算机的内存中。例如我们通常所说的一个32位整型元素占用4个字节那就意味着这个元素需要占用4个小格子不会存在某个元素占用 0.5 个小格子的情况。这就是所谓的不可分割。<br>
<img src="https://static001.geekbang.org/resource/image/d7/9a/d7a4f1a553a70e730749f4af790e559a.jpg" alt="" title="图2字节示意图">
任何类型的元素整型也好浮点型也罢只要是想存储在计算机中就一定要放在这些小格子里面唯一的区别就是每一种类型的元素占用的格子数量不一样。例如32位整型占4个格子double 双精度浮点型占 8 个格子。在这里,需要注意的是,每一种基础类型,在内存中存储时,一定是占用若干个连续的存储单元。
那么如何查看某个类型的元素究竟占用多大的存储空间呢?可以使用 sizeof 这个运算符,如下:
```
int a;
sizeof(a); // 计算 a 变量占用字节数量
sizeof(int); // 计算一个整型元素占用字节数量
```
正如你所看到的sizeof 的使用,就像函数方法一样,我们想要查看什么元素或者类型所占用字节数量,就把什么传入 sizeof 即可,你可以使用 printf 语句输出 sizeof 表达式的值以查看结果。
了解了什么是字节以后,下面我们就要说一个更小的单位了,叫做比特,英文是 bit。这个是计算机中表示数据的最小单位。对比**字节是存储数据的最基本单位,比特是表示信息的最基本单位。**
那什么又是比特呢?在其他参考资料上你可能知道,计算机里面的所有数据,均是用二进制来表示以及存储的,这里需要注意,是所有的。那么一个比特,就是一个二进制位,要么是 0要么是 1。8比特位是 1 个字节那么我们之前所说的32位整型也就是占32个比特位的整数类型换算一下正好是占 4 个字节。
说完了字节的概念后,我们再来说说地址。
现在我们的一些小区里面都有一个集中式的邮箱,邮递员来投递信件的时候,只需要把信件放到相应的邮箱里面即可。而作为住户,会有一把能打开自己家邮箱的钥匙,找到自己的邮箱,取出信件即可。
如果把这个场景放在计算机中,住户其实就是 CPU而邮箱就是内存。你会发现住户之所以可以准确找到自己的邮箱是因为每个邮箱上面有一个独立编号。那么 CPU 能够准确找到程序所需要数据的本质原因,也是因为每一个字节都有一个独立的编号,我们管这个编号,叫做:内存地址!下面我给你放了一张示意图:
<img src="https://static001.geekbang.org/resource/image/ef/64/ef6ef4330f86c45d5c18202d06edf364.jpg" alt="" title="图3内存地址示意图">
上图中,下面空白的格子就是我们所谓的字节,具体的数据信息,就是存储在这些格子里面的,格子上面的是十六进制数字,就是我们所谓的地址,你会看到,在内存中,字节的地址是连续的。
最后我们来总结一下,比特是数据表示的最小单位,就是我们通常所说的一个二进制位。字节是数据存储的最基本单位,存储在计算机中的数据,一定是占用若干个字节的存储空间。最后就是内存地址,是每一个字节的唯一标记。
#### 3.直观感受:内存地址
你可能会觉得内存地址是一个很抽象的概念,不具体。其实我们可以像输出整型值一样,把内存地址也输出出来。
你还记得格式占位符的作用吧?不同数据类型,用不同的格式占位符输出,例如:%d 对应了十进制整型的输出。内存地址则采用 %p 这个格式占位符进行输出,下面给你一个演示程序,你可以在你的环境中运行一下:
```
#include &lt;stdio.h&gt;
int main() {
int a;
printf(&quot;%p\n&quot;, &amp;a); // 输出 a 变量的地址
return 0;
}
```
代码中,首先定义一个了整型变量 a然后使用 %p 占位符输出 a 变量的地址。单一的 &amp; 运算符放到变量前面,取到的就是这个变量的**首地址**。
为什么说是首地址呢上一部分说了一个32位整型变量会占用4个字节的存储空间每一个字节都会有一个地址那么你会发现上面程序中的 a 变量实际上有 4 个地址,这 4 个地址究竟哪一个作为 a 变量的地址呢?答案是最靠前的那个地址,作为 a 变量的地址,也就是这个变量的首地址。<br>
<img src="https://static001.geekbang.org/resource/image/83/10/8367f7c6d3405494a19b5c4289e4f710.jpg" alt="" title="图4变量的首地址">
看到了变量的地址信息以后,下面就让我们来看一看与数组相关的地址信息,看下面这段程序:
```
#include &lt;stdio.h&gt;
int main() {
int arr[100];
printf(&quot;&amp;arr[0] = %p\n&quot;, &amp;arr[0]); // 输出 arr[0] 的地址
printf(&quot;&amp;arr[1] = %p\n&quot;, &amp;arr[1]); // 输出 arr[1] 的地址
printf(&quot; arr = %p\n&quot;, arr); // 输出 arr 的信息
return 0;
}
```
上述代码会输出三行信息针对这三行信息每个人的程序运行出来的结果很可能是不一样的这一点没关系可你一定会发现如下规律第一个地址与第二个地址之间差4字节而输出的第三个地址与第一个地址完全相同。
下面我就来解释一下这两个现象。
<li>
第一数组的每个元素之间在内存中是连续存储的也就是对上面程序中的数组而言第一个元素占头4个字节第二个元素紧接着占接下来的4个字节的存储空间。再结合上面说到的变量首地址的概念你就很容易理解为什么头两个地址之间差4了。
</li>
<li>
第二在程序中当我们单独使用数组名字的时候实际上就代表了整个数组的首地址整个数组arr[100])的首地址就是数组中第一个元素的首地址,也就是 arr[0] 的地址。
</li>
在这里我们来进一步看一下这个等价关系arr 等价于 &amp;arr[0](取地址 arr[0]),实际上我们的地址也是支持+/-法的,也就是 arr + 0 等价于 arr[0] 的地址,那么 arr[1] 的地址等于 arr 加几呢?
你可能会认为是加 4这种直觉还是值得鼓励的可结果不正确这个和地址的类型有关系后面讲到指针的时候我再详细的讲给你听。不过事实上arr + 1 就等价于 arr[1] 的地址,更一般的 arr + i 就等价于 arr[i] 的地址。关于地址上的+/-运算的规则,我在后续的文章中会详细进行讲解。
#### 4.再看 scanf 函数:其实我是一个“邮递员”
有了上面对于地址的基本认识以后,我们再来回顾一下 scanf 函数的用法,你可能会有新的收获,看如下读入程序:
```
#include &lt;stdio.h&gt;
int main() {
int a;
scanf(&quot;%d&quot;, &amp;a);
return 0;
}
```
上面这个程序,就是一个最简单的读入程序,首先定义一个整型变量 a然后读入一个整数存储到 a 中。
学习完了地址以后,你就会意识到,我们传给 scanf 函数的,不是 a 变量,准确来说,而是 a 变量的地址。
为什么要把 a 变量的地址传递给 scanf 函数呢?这个很好理解,你就把 scanf 函数当成邮递员,邮递员得到了信件以后,需要知道这个数据放到哪个邮箱里面啊,而你需要做的就是把邮箱地址告诉这个邮递员即可,就是变量 a 的地址,这样 scanf 函数就能把获得的数据,准确的放到 a 变量所对应的内存单元中了。
## 一起动手,搞事情
#### 思考题:去掉倍数
>
<p>设计一个去掉倍数的程序,要求如下:<br>
首先读入两个数字 n 和 mn 的大小不会超过10m 的大小都不会超过 10000<br>
接下来读入 n 个各不相同的正整数,输出 1 到 m 中,有哪些数字无法被这 n 个正整数中任意的一个整除。</p>
下面给出一组输入和输出的样例,以供你来参考。
输入如下:
```
3 12
4 5 6
```
输出如下:
```
1 2 3 7 9 11
```
## 用数组,做递推
有了对数组的基本认识之后,就让我们来看一下今天的任务应该如何求解。请你观察下面的位运算性质:
```
y = x &amp; (x - 1)
```
我们看到,我们将 x 与 x - 1 这两个数字做**按位与**(这个名词的含义很简单,你随便查查资料就知道了),按位与以后的结果再赋值给 y 变量,下面我们着重来讨论 y 变量与 x 变量之间的关系。
既然是位运算,我们就需要从二进制的角度来思考这个问题。首先思考 x - 1 的二进制表示与 x 二进制表示之间的关系,当 x 二进制表示的最后一位是 1 的时候x - 1 就相当于将 x 最后的一位 1 变成了0如果 x 二进制表示最后一位是 0 呢,计算 x - 1 的时候就会试图向前借位应该是找到最近的一位不为0的位置将这一位变成 0原先后面的 0 都变成 1如下图所示<br>
<img src="https://static001.geekbang.org/resource/image/f2/96/f253de915071b2dcf400b5c2bb87d096.jpg" alt="" title="图5x 与 x-1 的二进制表示">
图中打 * 的部分,代表了 x 与 x - 1 二进制表示中完全相同的部分。根据按位与操作的规则相应位置都为1结果位就为 1那么 x 与上 x - 1 实际效果等效于去掉 x 二进制表示中的最后一位1从而我们发现原来 y 变量与 x 变量在二进制表示中,只差一个 1。
回到原任务,如果我们用一个数组 f 记录相应数字二进制表示中 1 的数量,那么 f[i] 就代表 i 这个数字二进制表示中 1 的数量,从而我们可以推导得到 f[i] = f[i &amp; (i - 1)] + 1也就是说 i 比 i &amp; (i - 1) 这个数字的二进制表示中的 1 的数量要多一个,这样我们通过一步计算就得到 f[i] 的结果。
下面给你准备了一份参考程序:
```
#include &lt;stdio.h&gt;
int f[1001];
int main() {
int n;
scanf(&quot;%d&quot;, &amp;n);
f[0] = 0;
for (int i = 1; i &lt;= n; i++) {
f[i] = f[i &amp; (i - 1)] + 1;
}
for (int i = 1; i &lt;= n; i++) {
if (i != 1) printf(&quot; &quot;);
printf(&quot;%d&quot;, f[i]);
}
printf(&quot;\n&quot;);
return 0;
}
```
这个程序中,首先先读入一个整数 n代表要求解的范围然后循环 n 次,每一次通过递推公式 f[i] = f[i &amp; (i - 1)] + 1 计算得到 f[i] 的值,最后输出 1 到 n 中每个数字二进制表示中 1 的个数。
## 课程小结
通过今天这个任务,你会发现,有了数组以后,我们可以记录一些计算结果,这些计算结果可能对后续的计算有帮助,从而提高程序的执行效率。关于数组的使用,会成为你日后学习中的一个重点,今天就当先热个身吧。下面呢,我来总结一下今天课程中需要你记住的重点:
1. 使用数组,可以很方便的定义出一组变量存储空间,数组下标从 0 开始。
1. 数据的最基本存储单位是字节,每一个字节都有一个独一无二的地址。
1. 一个变量占用若干个字节,第一个字节的地址,是这个变量的首地址,称为:变量地址。
记住今天这些,对于日后学习指针相关知识,会有很大的帮助。好了,今天就到这里了,我是胡光,我们下期见。

View File

@@ -0,0 +1,203 @@
<audio id="audio" title="06 | 字符串:彻底被你忽略的 printf 的高级用法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/97/b6/9773caa42d3da29c4e3cdd8ca3bf44b6.mp3"></audio>
你好,我是胡光,咱们又见面了,之前我们学习了基础数据类型,还有 scanf 函数和 printf 函数,这些知识都是单独的出现在我们之前的文章中的。今天呢,我将把它们组合在一起,给你展现一片新天地,你会掌握一种数据处理技巧,本质上是在上述几种数据类型之间做转换,从而使你对 scanf 函数和 printf 函数的理解更加深刻。
今天呢,我将给你介绍一种特殊的数组:字符数组,一种用来存储字符串信息的数组。来,跟我一起看看到底是怎么回事吧!
## 今日任务
首先,先让我们来看一下今天的任务。你还记得 printf 函数如何输出浮点型吧那下面请你回忆一下printf 函数在输出浮点型数据的时候,如何保留位数呢?下面的代码,演示了如何保留小数点后两位:
```
printf(&quot;%.2lf&quot;, 3.1415926);
```
%lf 是 double 双精度浮点型输出的格式占位符,当我们想要保留小数点后两位的时候,只需要在 % 和 lf 中间加上 .2 即可2 写在 .(点) 的后面代表小数点后 2 位。
有了上面这个基础,现在我让你保留小数点后 2 位、3 位、4 位。对你来说肯定都不是什么难题了。先不要高兴太早,今天我们的任务是,实现一个能够保留小数点后 n 位的程序,这里的 n 是我们程序读入的一个变量。
例如,面对如下输入:
```
3.1415926 2
3.14
```
第1行有两个数字 3.1415926 和 2第一个浮点数代表要进行保留位数输出的浮点值第二个整型代表具体要保留 2 位小数,你的程序应该能够正确的输出 3.14。那么类似的,当程序输入 3.1415926 和 3 的时候,你的程序应该输出 3.142。
面对这样一个任务,你想怎样进行实现呢?下面就给你一个设计不太优美的程序示例:
```
#include &lt;stdio.h&gt;
int main() {
double num;
int n;
scanf(&quot;%lf%d&quot;, &amp;num, &amp;n);
switch (n) {
case 1: printf(&quot;%.1lf\n&quot;, num); break;
case 2: printf(&quot;%.2lf\n&quot;, num); break;
case 3: printf(&quot;%.3lf\n&quot;, num); break;
case 4: printf(&quot;%.4lf\n&quot;, num); break;
case 5: printf(&quot;%.5lf\n&quot;, num); break;
}
return 0;
}
```
你会看到,在这段程序中,使用 switch…case 结构将 n 等于 1、2、3、4、5 时候所对应的程序输出行为都罗列了出来,看似好像解决了问题,可实际不然。当 n 等于 6、7、8、9、10 怎么办呢?就让我们通过今天的学习,来尝试解决这个问题吧。
## 必知必会,查缺补漏
你先来好好看看上面给出的那个设计的很丑的示例代码case结构中的 5 行代码,除了 printf 中的格式控制字符串不同以外,其余代码均相同,是不是稍稍感觉这里有些可操作的空间呢?
#### 1.值和变量
在继续往下讲之前,我们先来重新认识一下两个概念,就是“变量”和“值”的概念。“变量”你肯定不陌生,之前的课程中我们一直在说,例如:整型变量,浮点型变量。
所谓整型变量,就是存储整型的变量。这么解释呢,好像又啥都没说,可这句话本来就带有不说自明的效果。根据名字理解,就是可以变化的量,就像我在代码中定义了一个整型变量 a然后通过读入给 a 赋值我问你a 等于多少你只能回答不知道因为这个a 变量的值是可以变化的。
看完了变量这个概念以后,那什么又是“值”呢,也很简单,就是存储在变量中的内容,就是值。整型变量存储整型值,浮点型变量存储浮点型值。比如说,数字 3 就是整型值,如果我们为了后续计算想存储这个整型值,就需要放到一个整型变量中。
所以总得来说,具体结果就是值,明确知道等于多少的就是值。存储这些值的地方,就是变量,就是在程序中用来指代某片存储空间的名称,值就是存储空间里面的具体内容。如下图所示:<br>
<img src="https://static001.geekbang.org/resource/image/b3/28/b3b9f7c50459f54386c0ec08bc1e7428.jpg" alt="" title="图1:变量和值">
#### 2. 字符串和字符数组
为什么要明确什么是“值”什么是“变量”呢,因为我想让这两个概念在你的脑子中产生具象化的效果,一说到“值”,你就知道,是存储在“变量”中的内容,某一种类型的值,存储在某一种类型的变量中。
下面将要讲解的这两个概念,就是“值”和“变量”概念的衍生品,它们是“字符串”与“字符数组”。
“字符串”就是“值”而“字符数组”就是存储字符串数据的空间类比于“变量”的概念。如果说1、2、3、4、5这种整型“值”你可能很容易理解因为在你之前的学习中就接触过。那么什么是字符串值呢?先看一下字符串值的形式:<br>
<img src="https://static001.geekbang.org/resource/image/16/ca/160d3bd153ce0022754f14a04393aaca.jpg" alt="" title="图2字符串数据示意图">
如上图所示,是一个 “hello world” 内容的字符串数据,字符串数据被一对双引号包裹,中间是字符串内容。像我们看到的上面字符串内容中的 h 啊e 啊l 啊,这些就是所谓的“字符”,那么多个字符写成一串,故名“字符串”。为了加以区分,字符内容是用单引号,字符串内容是用双引号。
之前我们学过,一个字符占一个字节的空间,那么这个 “hello world” 字符串内容占多少个存储空间呢hello 有五个字符world 有五个字符,是 10 个字符么?不对。别忘了中间还有一个空格字符呢,虽然不可见,可你看得出来它对内容的影响。
那么加在一起一共是 11 个字符么?也不对,这个字符串数据中,还有一个彻底看不见影响,可对于底层来说很重要的字符,我们管它称作“杠零字符”,写作 \0。每次读这个都让我想起健身房里面的杠铃。
那么这段字符串实际在内存中存储的结果,应该如图中所示:<br>
<img src="https://static001.geekbang.org/resource/image/2a/df/2a0f2862b86dd81eb65fe7893157f5df.jpg" alt="" title="图3字符串存储示意图">
你可以看到“hello world” 字符串中的每一个字符会占用一个字节存储单元,结尾还有一个 \0 字符,这个 \0 字符是标记字符串结束的。也就是,我们的程序在看到这个字符串的时候,会从第一个字符开始,直到碰到 \0 字符为止,这中间的内容就是字符串的内容。
这里我们要强调两个概念“字符串长度”和“字符串占用空间”这是两个相似但不同的概念。字符串长度就是字符串内容所占字节数量例如示意图中“hello world”这个字符串内容长度是 11。
而如果要说这个字符串所占用的空间大小,那就别忘了,还有一个 \0 字符需要额外占用 1 位呢。所以如果说到“字符串占用空间”是多少的话应该是12它要比“字符串长度”多 1多出来的这个1就是用来存放 \0 字符的。
看完了字符串的基本概念以后,字符数组的概念就容易理解得多了,就是用来存放字符串的变量空间。从名字上面看的话,字符数组本质上是个数组,但这个数组的每一个元素类型不再是我们之前说到的整数类型,而是变成了字符型,也就是之前所提到的 char 类型。
那么如果想要存储示意图中的字符串,我们需要定义一个多大的数组呢?你稍加思索,就应该知道,最少要定义一个长度为 12 的数组。
```
char str[12] = &quot;hello world&quot;;
printf(&quot;%s&quot;, str);
```
就如上面这段代码所示,定义了一个长度为 12 的字符数组,然后将字符串 “hello world” 存储到这个空间中。注意这种给字符数组赋值的方法只能出现在字符数组定义时这样使用非定义语句中不能这样给字符数组赋值会得到一个编译器给的错误提示。然后在第2行代码中我们通过 printf 函数,输出字符数组中的信息,其中 %s 是字符串数据的格式占位符。最终你会在你的电脑上得到一行 hello world 的输出。
#### 3. 字符串操作的利器
明确了什么是“字符串”和“字符数组”以后,下面来让我们看看如何操作字符串信息。说到操作,你来回忆一下整型数据支持的操作都有什么?能想到的最简单的是不是就是:加、减、乘、除和取余运算。
所谓操作,更准确地说是“运算”,就是使用现有值,产生新值的一个过程。上面我们明确了字符串数据就是一种值,那么这种值又支持哪些操作呢?
很多资料上面会讲解一些函数方法,例如:求字符串长度的 strlen 函数,拷贝字符串的 strcpy 函数,比较字符串的 strcmp 函数,连接两个字符串的 strcat 函数。因为这些函数方法较为简单,我打算把这些函数方法的学习作为你的自学作业,请你自学 string.h 头文件中的相关函数使用。
除此之外,今天我打算给你介绍两个更加灵活且强大的字符串操作函数:**sscanf函数**和 **sprintf 函数**
从名字上面来看,除了名称前面加了一个 s 以外,剩下的部分,就是 scanf 和 printf前面这个 s 其实指代的是 string也就是字符串。正常来说与 scanf 和 printf 进行信息交互的场所就是你运行 C 语言程序时候所看到的那个黑色框框,而与 sscanf 和 sprintf 进行信息交互的场所则是字符数组。你读这句话可能有点懵,听我继续往下讲。
下面让我们看一个 sscanf 的简单例子:
```
char str[20] = &quot;123.45&quot;;
double num;
sscanf(str, &quot;%lf&quot;, &amp;num);
```
在这个例子中第1行是一个字符数组 str其中的字符串信息是 “123.45”第2行定义了一个浮点型的变量第3行代码是重点它使用 sscanf 从 str 所存储的字符串中读入一个浮点型数据,然后赋值给 num 变量,这里比传统的 scanf 函数多了一个参数,这个参数代表读取数据的字符串信息。
上面例子中展示了**如何将一个字符串转换成一个浮点型数据**即把原始字符串的“123.45”转换成了浮点型数据“123.45”。那么你也可以自行设想将字符串转换成整型数据等等。其实 sscanf 就是提供了一种将字符串信息转换成其他信息的方式。
看完 sscanf 以后,下面看 sprintf 就简单多了sprintf 也是比传统的 printf 函数多了一个参数,请看下面的这个使用 sprintf 输出 “hello world” 字符串的例子:
```
char str[100];
sprintf(str, &quot;hello world&quot;);
printf(&quot;%s&quot;, str);
```
例子代码中分成3行第1行定义了一个字符数组 str第2行调用 sprintf 函数,相比于 printf 函数,多了第一项的参数,代表将原本输出到标准输出流中的内容,输出到 str 数组中。所谓标准输出流是一种专业的叫法,现在你可以简单的认为,就是你所认识的那个黑框框。
在这个例子中,也就是将 “hello world” 字符串,输出到了 str 字符数组中,也就是完成了一个字符数组赋值的过程。最后一行,使用 printf 函数,打印 str 数组的值,你会在屏幕上看到 “hello world” 字符串。
通过上面这个例子你可以清楚的看到sprintf 函数就是将原本 printf 函数输出的内容,输出到一个字符数组中存储起来,以方便在程序中的后续操作和使用。
sscanf 函数与sprintf 函数对比着看的话,如果说 sscanf 是将字符串转换成整型、浮点型等其他类型的方法,那么 sprintf 就是将其他类型转换成字符串类型的方法。
## 一起动手,搞事情
今天的动手实践环节呢,我给你准备了两道特别有意思的题目,一起来看看吧。
#### 思考题1体验利器
>
刚刚在上面,介绍了 sprintf 和 sscanf 两个字符串处理的利器工具,那么就请使用 sprintf 函数实现 strlen、strcpy、strcat 函数的功能,注意哦,只允许使用 sprintf 函数,模仿如上三个函数的功能。
#### 思考题2优美的遍历技巧
>
<p>介绍完了字符串的相关知识以后,请思考如下问题,如何在不计算字符串长度的情况下,遍历字符串的每一位呢?<br>
程序设计要求是,读入一个字符串,然后在不计算字符串长度的情况下,输出字符串中的每一个字符,每个字符占一行。其中所谓计算字符串长度的方法,包括但不限于 strlensprintf先行 for 循环遍历统计等。</p>
## 实现保留任意小数
最后让我们回到今天的任务实现保留任意位小数输出的功能。请你注意观察printf 函数的第一个参数究竟是个什么你会恍然大悟printf 函数的第一个参数不就是个字符串,既然是字符串信息,那我只需要在 printf 语言前,拼接合适的字符串信息传给 printf 函数即可。
例如,要求保留 1 位小数的时候,我就拼接出来 “%.1lf” 字符串信息,要求保留 2 位小数的时候,就拼接处理出 “%.2lf”字符串信息即可。
下面是我给出的示例程序,你可以作为参考:
```
#include &lt;stdio.h&gt;
int main() {
double num;
int n;
char str[100];
scanf(&quot;%lf%d&quot;, &amp;num, &amp;n);
sprintf(str, &quot;%%.%dlf\n&quot;, n); // %% 代表输出一个 %
printf(str, num);
return 0;
}
```
程序主函数的第35行分别定义了一个浮点型变量 num一个整型变量 n 和 一个字符数组 str。接下来我们就要依据 n 的信息,利用 sprintf 函数把构造出来的格式控制字符串信息输出到 str 字符数组中。
这里需要注意的是第7行的两个百分号 %%),它代表输出的时候,输出一个百分号,这是为了与格式占位符的 % 相兼容。试想一下,如果不这样规定,当你格式控制字符串是 “%d” 的时候,就会出现歧义,一方面可以解释成为是输出一个 % 和一个 d 字符,另一方面可以代表十进制整型的格式占位符。
以防出现这种歧义,当我们想输出一个百分号的时候,需要在格式控制字符串里面,打上两个百分号,以示区分。
最后我们把字符数组 str 当成原来的格式控制字符串传给 printf 函数,这样,就将文章开始时,代码中的 printf 函数的第一个参数,从定值字符串信息,替换成了变量字符数组 str 中所存储的信息。通过今天这个任务,你应该可以看到,拥有变量的程序,会使得我们的程序更具一般性和通用性。
其实变量代表了问题中可以被抽象化出来的量,就像以前,我们刻板的认为 printf 的第一个参数只能是一个显示的字符串信息通过今天的学习我们才彻底明白printf 第一个参数,无非就是需要一个字符串的值,所以我们不仅仅可以传递给 printf 函数一个字符串的值,更可以传递给它一个字符数组,这样会使得整个程序功能更加灵活。
## 课程小结
通过今天对于字符串内容的学习,我们更加明确了“值”和“变量”的概念,这个概念,在后面学习指针相关知识的时候是非常重要的,所以你可千万不要忽视了今天我们花大量篇幅解释的这两个看似显然的概念。记住“值”和“变量”是两个概念,“变量”是存储“值”的地方。
最后,我希望你通过今天的学习,能够记住如下两点:
1. 字符串信息可以存储在字符数组中,字符数组就是“变量”的概念,字符串就是“值”的概念
1. sscanf 和 sprintf 函数,本质上在做的是以字符串为中间值,做各种数据类型之间的转换。
好了,踏实地消化吸收今天的内容吧,我是胡光,我们下节课,见指针。

View File

@@ -0,0 +1,180 @@
<audio id="audio" title="07 | 指针系列(一):记住,指针变量也是变量" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/70/9f/70a156ff4af3f7418b5ff24bab9be59f.mp3"></audio>
你好,我是胡光,上节课中,我们对两个概念做了区分,就是“值”和“变量”。你也看到了,当我们将 printf 函数中的第一个参数,抽象成变量以后,整个程序的功能会变得异常的灵活。
今天我们将要学习的 “指针”呢,也是一种变量,这是一种存储地址的变量。这种变量,可谓是所有变量的终极形态,掌握了指针,也就掌握了程序设计中“变量”的全部知识。今天,我们只会围绕着一句话进行学习,一定要记住,那就是 “指针变量也是变量”。
## 任务介绍
这次的任务,是需要我们结合两次学习(本节内容和下一节内容)才能完成,到底是什么呢?你不要有畏惧心理,其实这个任务很简单,假设有如下结构体数组,请看如下代码:
```
struct Data {
int x, y;
} a[2];
```
请用尽可能多的形式,替换下面代码中 &amp;a[1].x 的部分,使得代码效果不变:
```
struct Data *p = a;
printf(&quot;%p&quot;, &amp;a[1].x);
```
你会看到,如上代码中,其实就是输出 a[1].x 的地址值。
到了这里,你可能对结构体还不熟悉,并且,你可能对于这个任务应该如何完成还是一头雾水,没关系,暂时忘了这个任务,我们先来讲讲可以解决任务的一些基础知识,再回来看这个任务。
进行下面的学习之前,我还是要强调一下那句话,这句话是我们这两次学习的重点,也是帮助你学习指针的利器,叫做“**指针变量也是变量**”。
## 必知必会,查缺补漏
#### 1. 初识:结构体
为了完成今天的任务,你先要学习一些关于结构体的知识。先来想一个这样的问题:想要在程序中输入 n 个整数的话,我们知道可以用整型数组来进行存储,可是如果想要是输入 n 个点的坐标信息呢?用什么类型的数组来存储呢?是使用坐标类型的数组来存储么?没错!
你可能会疑问了,坐标类型怎么表示呢?其实这个坐标类型,可不像整型一样,整型是程序语言中给我准备好的现成的类型,而这个所谓的坐标类型,虽然程序语言中没有,但我们**可以通过C语言里面的工具来描述这种类型的特点这个可以用来描述和定义新类型的工具就叫做结构体**。
下面我们看看如何用结构体定义一个新的数据类型,名字就叫做 point 类型吧:
```
struct point {
// 描述这个类型的组成部分
};
```
上面在这行代码中,我们定义了一个新类型,是 struct point也就是结构体点类。我这里强调一下这个新类型不是 point在 C 语言中,这个新类型是 struct point。struct 是关键字代表结构体point 是为了与其它结构体定义的类型相区分,后面的大括号内部是用来描述这个新类型的组成部分的。
有了这个类型以后,你就可以写如下的代码,来定义点类型的变量了:
```
struct point p1, p2;
```
正如你看到的我们定义了两个点类型的变量p1 和 p2可由于上面我们没有具体描述点类型的组成部分所以这个 p1 和 p2 变量只是名义上的点类型变量,却没有什么实质性的作用。
什么叫做“具体描述点类型的组成部分”呢?来让我们想想,我们如何表示一个坐标点,在数学中,一般情况是用一个二元组 (x, y) 表示一个点坐标。假设,在我们的问题场景中,点坐标都是整型的话,那么程序中的点类,就应该是由一对基础的整型变量组成的,具体写成代码如下所示:
```
struct point {
int x, y;
};
```
正如你所看到的,我们在原本的结构体点类的大括号中,加入了两个整型字段,具体的语义含义是,一个点类型数据其实可以具体的表示成为两个整型数据。
在这个过程中,有没有一种盖房子的感觉?先有地基,再盖一楼,然后是二楼。也就是在程序中,先有基础数据类型,然后是基于这些基础数据类型,定义出新的数据类型。
你也可以想象,我们其实可以用我们定义出来的新类型,去定义另一个更新的类型。而所谓 C 语言中的基础数据类型,就是程序语言给我们准备好了的地基,而所谓程序的功能模块,就是别人盖好的房子,我们直接拿过来使用。就像之前我们了解的 printf 函数和 scanf 函数一样都是C 语言给我们准备好了的基础功能模块。
有了基础功能,我们可以开发更高级的功能,有了基础类型呢,我们也可以开发更复杂的类型。这个过程,将来你可以自己逐渐的加深体会,在这里,我就不过多的展开来说了。
描述了结构体点类型的具体组成部分以后,之前的 p1 和 p2 变量就具备了实际的功能了,下面,我们让 p1 代表点(2, 3),让 p2 代表点 (7, 9),代码如下:
```
p1.x = 2;
p1.y = 3;
p2.x = 7;
p2.y = 9;
```
可以看到,我们可以给 p1 和 p2 变量中的 xy 字段分别赋值。这里出现了一个新的运算符,就是点“.”运算符这个也叫做“直接引用”运算符p1.x意思是 p1 变量里面的 x 字段。后面讲解完指针内容以后,我们还会介绍间接引用运算符“-&gt;”,由一个减号和一个大于号组成,这个我们后面再说。
#### 2. 结构体变量的大小
就像我们之前所说的,变量是存储值的地方,只要是变量,就一定占用若干存储单元,也就是占用若干字节的空间。结构体变量既然也是变量的话,那么一个结构体变量又占用多少个字节呢?
以我们刚才设置的结构体变量为例这个包含两个整型字段的结构体类型变量占多少个字节的存储空间呢你可能会想那还不简单最起码要拥有足够放下两个32位整型数据的存储空间吧因为其中包括了两个整型字段所以一个 struct point 类型变量最起码应该占 8 个字节。如何验证你的想法呢?还记得之前讲过的 sizeof 方法吧?
```
struct point p;
sizeof(p);
sizeof(struct point);
```
这两种使用 sizeof 方法的代码均能正确的告诉你一个 struct point 类型的变量占用的存储空间大小。至此,你可能感觉自己已经掌握了计算结构体变量大小的诀窍。
先不要高兴太早,看下面这两个结构体的情况:<br>
<img src="https://static001.geekbang.org/resource/image/86/21/86eb572fca7bff9391303e90ddd1fa21.jpg" alt="" title="图1结构体占用空间对比">
可以看到, Data1 和 Data2 两个结构体,都是由两个字符型字段和一个整型字段组成的。但这个对比中,存在两个你无法忽视的问题:
- Data1 结构体,只包含一个整型和两个字符型字段,所占用的空间大小应该是 4+1+1=6 个字节啊,怎么变成了 8 个字节?
- Data2 结构体,和 Data1 结构体包含字段种类都是一样的,那既然 Data1 是 8 个字节,为什么 Data2 是 12 个字节呢?
下面我们就来对这两个问题,一一作答,学会了这两个问题,你才是真正抓住了计算结构体变量大小的诀窍。
先来看第一个问题,为什么 Data1 类型的变量占用的是 8 个字节,而不是 6 个字节呢?这里就要说到结构体变量申请存储空间的规则了。正如你知道的,像整型这种 C 语言原有的内建类型,都是占用若干个字节,整型变量的存储,就是以字节为单位的。而今天我们学到的结构体变量,需要占用若干个存储单元,结构体变量的存储,就是以存储单元为单位的,那么一个存储单元占用多少个字节呢?
记住,下面这个就是重点了:**对于某个结构体类型而言,其存储单元大小,等于它当中占用空间最大的基础类型所占用的字节数量。**
说白了,对于 Data1 结构体类型来说,整型是其当中占用空间最大的基础类型,它的一个存储单元的大小,就是 4 个字节等于它当中整型字段所占用的字节数量。也就是说Data1 这个结构体类型,要不就占用 1 个存储单元,即 4 个字节的空间,要不然就占用 2 个存储单元即8个字节的存储空间不会出现 6 个字节的情况。
那么究竟占多少呢按照最小存得下原则Data1 最少应该占用 2 个存储单元,才能放下一个整型和两个字符型,这就是为什么 Data1 类型占用 8 个字节的原因。
你会问了,按照这个解释,那 Data2 为什么占用 12 个字节呢Data2 中不也是一个整型和两个字符型么?先别着急,这就进入我要讲的第二个重点了:**结构体的字段在内存中存储的顺序,是按照结构体定义时的顺序排布的,而且当本存储单元不够安放的时候,就从下个存储单元的头部开始安放。**
这是什么意思呢?下面是我给你准备的一张 Data1 和 Data2 两个结构体类型的内存占用情况图:<br>
<img src="https://static001.geekbang.org/resource/image/30/b9/306b94ab84e3c096f90716d7153430b9.jpg" alt="" title="图2结构体内存结构示意图">
你可以看到,在 Data1 中,首先是 int 类型的 a 变量,占用了第一个存储单元,然后 b 和 c 占用了第二个存储单元的前两个字节。
再看 Data2由于 Data2 不同于 Data1 的字段顺序b 占用了第一个存储单元的第一个字节,剩余的 3 个字节不够存放一个 int 类型变量的,所以按照上面我们讲的规则“当本存储单元不够安放的时候,就从下个存储单元的头部开始安放”, a 变量就单独占用了第二个存储单元c 自己占用第三个存储单元的第一个字节。
所以虽然在数据表示上Data1 和 Data2 是等价的,可 Data2 却占用了更多的存储空间,相比于 Data1 造成了 50% 的空间浪费。由此可见,**在设计结构体的时候,不仅要设计新的结构体类型中所包含的数据字段,还需要关注各个字段之间的顺序排布**。
#### 3.指针变量也是变量
看完了结构体相关的知识以后,下面来让我们进入一个被很多初学者称为 C 语言中最难理解的的部分,指针相关知识的学习。面对这部分内容,我只希望你记住一句话:指针变量也是变量。
想想之前我们学习的“变量”和“值”的概念,我们说,什么类型的值,就用什么类型的变量进行存储,整型变量,是存储整型值的东西,浮点型变量是存储浮点型的东西。
当你听到“指针变量也是变量”这句话的时候,我希望你能提出如下问题:既然指针变量也是变量,那指针变量是存储什么类型的值的呢?还记得我们之前讲的地址的概念吧,你会发现,所谓变量的地址,就像整数和字符串一样,其实是一个明确的值啊。
那对于地址,我们使用什么变量来进行存储呢?没错,**指针是变量,指针是一种用来存储地址的变量!**在这里我再强调一遍“指针变量也是变量”,这意味着,你之前对于“变量”这个概念的认识,都可以放到指针变量的理解上。
让我们先来看一下如何定义一个指针变量:
```
int a = 123, *p = &amp;a;
printf(&quot;%d %p %d\n&quot;, a, p, *p);
```
在上面这段代码中a 是一个整型变量p 变量前面多了一个*,这个*就是用来说明 p 是一个指针变量,是一个存储整型变量地址的指针变量,在代码中,你也可以看到,我们将 a 的地址赋值给了 p 变量。
代码的第2行共输出三项信息第一项输出 a 中存储的整型值(第一个%d对应的是a),第二项是输出 p 中存储的地址值(%p对应的是p),第三项输出的是 *p 的值(第二个%d对应的是 *p)p 里面存储的是地址,*p 代表了 p 所指向的存储区内部的值。
为了更清楚的解释 *p给你准备了下面的图以便你理解 a 和 p 的关系:<br>
<img src="https://static001.geekbang.org/resource/image/ac/ff/ac2972988abc713c2db1960062f3a4ff.jpg" alt="" title="图3a 变量与 p 变量">
从图中你可以看到p 变量中存储的就是 a 变量的首地址,也就是说,我们可以通过 p 变量中所存储的信息,按图索骥,就能找到 a 变量所代表的存储区,进而操作那片存储区中的内容。 p 变量对于 a 变量的作用,是不是很像一个指路牌呢?指针的名称,也就由此而来。
我们再来看,如果 p 本身代表了 a 变量的地址,那么如何取到这个地址所对应的存储空间中的内容呢?这个就是 * 运算符,放到变量名前面,我们叫做“取值”运算符,对于 *p 的理解就是取值 p 所指向存储区的内容,也就是原有 a 变量中所存储的值。
一种更简单的理解方法是,在写程序的时候 *p 就是等价于 a也就是说当你写如下代码的时候
```
*p = 45;
```
实际上等价于写了一行代码 a = 45。也就是说实际上是把 a 变量中存储的值给改变了。
## 课程小结
在最后的这个例子中呢聪明的你有没有注意到这样一个问题a 变量实际上有 4 个地址p 中存储的只不过是 a 变量的首地址也就是说p 中所存储的地址,只指向了一个字节的存储空间,那为什么当我们使用 *p 的时候,程序可以正确的对应到 4 个字节中的数据内容呢?
上面这个问题,就要涉及到指针的类型的作用了,下一篇文章我们再详细聊一下这个事情。今天要说有什么重点需要你记住的,那就是希望你记住如下两点:
1. 结构体是用来创造新类型的利器,而结构体类型所占存储空间大小,与其内部字段的组成和各个字段的顺序排布均有关。
1. 指针变量也是变量,这是一种存储地址信息的变量。
好了,我是胡光,我们下次见。

View File

@@ -0,0 +1,186 @@
<audio id="audio" title="08 | 指针系列(二):记住,指针变量也是变量" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/88/58/88f93ed94e321681d5b08bc93a396358.mp3"></audio>
你好,我是胡光,咱们又见面了,上节课中,我们介绍了结构体相关的基础知识,也介绍了指针变量,并且教给你了最重要的一句话“指针变量也是变量”。这句话的意思在于告诉你,所有你对变量的理解,都可以放到指针变量上,例如:变量有类型,变量有大小,变量里面的值支持某些操作等等。今天呢,我们就来详细地聊一下指针变量。
## 任务回顾
在正式开始之前,我们先来回顾一下上节课的任务内容:
上节课我们说,如果给我们如下 Data 结构体类型,这个类型中有两个整型数据字段 xy
```
struct Data {
int x, y;
} a[2];
```
那么请用尽可能多得形式,替换下面代码中 &amp;a[1].x 的部分,使得代码效果不变:
```
struct Data *p = a;
printf(&quot;%p&quot;, &amp;a[1].x);
```
你会看到,如上代码中,就是输出 a[1].x 的地址值。
通过上节的学习,你现在已经掌握了关于结构体的相关知识,也初步地接触了“指针变量也是变量”的这个概念,今天就让我们再深入了解指针变量吧。
## 必知必会,查缺补漏
#### 1. 深入理解:指针变量的类型
还记得我们是如何定义 p 变量的么?代码语句是:
```
int *p
```
之前我们介绍了,语句中的 * 代表 p 变量是一个指针变量,而 int 的作用是什么呢?只是用来说明 p 是一个指向整型存储区的指针变量么?其实 int 更大的作用,就是用来解决我们上面提到的那个问题,根据 p 变量中的内容,我们可以找到一个存储区的首地址,然后再根据 p 的类型,就可以确定要取几个字节中的内容了。
下面给你举个例子:
```
int a = 0x61626364;
int *p = &amp;a;
char *q = (char *)&amp;a;
printf(&quot;%x %c\n&quot;, *p, *q);
```
这段上面代码中p 和 q 同时指向了 a 变量的存储区。而取值 p 和取值 q 的结果,却截然不同。这是因为,取值 p 时,程序会从 p 所指向的首地址开始,取 4 个字节的内容作为数据内容进行解析,而取值 q 的时候,则是取 1 个字节的内容,作为数据内容进行解析。
你如果运行上述代码,大概率你会看到输出内容是:
```
61626364 d
```
小概率会看到输出内容是:
```
61626364 a
```
这个原因和“大端机”“小端机”有关,关于这个问题,你要是有兴趣的话,可以自行查阅相关资料。下面的图中呢,就是以“小端机”为例,说明的 p 和 q 取值的问题:<br>
<img src="https://static001.geekbang.org/resource/image/2f/53/2f8c77a569286f3bc3fb8adbf0dc3553.jpg" alt="" title="图1指针变量取值示意图">
如图所示p 变量对应了 a 变量整个存储区中的内容,所以输出取值 p 和 a 原本存储内容相同。而 q 变量由于是字符型指针变量,只能从首地址取到 1 个字节的内容取到的就是64这里的 64 注意可是 16 进制的数字,对应到 10 进制数字就是 100而 %c 是输出一个字符,数字 100 对应的字符就是英文小写字母 d
实际上,我们看到的任何字符,在底层都对应了一个具体的数字。常用的有字符 a对应的是 97字符 b对应的是 98以此类推还有数字 0 是 48数字 1 是 49后面的对应规律类似我们管这个对应规则叫做 ASCII 编码。
指针变量的类型,除了用来确定取值时,确定覆盖存储区的大小以外,还有其他作用。想一想,整型支持加减乘除操作,而我们所谓的地址类型的值,也可以在其上面做加减的操作,你可以试着运行下面的代码:
```
int a, *p = &amp;a;
char *q = &amp;a;
printf(&quot;%p %p&quot;, p, q);
printf(&quot;%p %p&quot;, p + 1, q + 1);
```
代码中,定义了三个变量,其中一个整型变量 a两个指针变量 p 和 q其中 p 是整型指针变量q 是字符型指针变量。然后分别输出 p 和 q以及 p + 1 和 q + 1 的值以作对比。
如果你运行上面的程序你会看到p 和 q 的值是相同的,都是 a 变量的首地址,但是 p + 1 和 q + 1 的值却不同。如果你仔细观察会发现p + 1 的地址值与 a 的地址之间差了 4 个字节,而 q + 1 的地址值与 a 的地址之间只差了 1 个字节。<br>
<img src="https://static001.geekbang.org/resource/image/19/f1/199260e49de2ab7bd33cf2610b4a33f1.jpg" alt="" title="图2地址加法操作结果">
通过上图,你就可以更清晰的看到,由于 p 是整型指针,所以 p + 1 的计算结果,是向后跳了一个整型,相当于从第一个整型的首地址,跳到第二个整型的首地址;而由于 q 是字符型指针,所以 q + 1 的计算结果,就是向后跳了一个字符型。
这样,你就可以明白了吧?如果一个浮点型的指针变量加 1就会向后跳一个浮点型。这就是**指针变量类型的第二个作用:在加法或者减法时,确定增加或者减少的地址长度**。
#### 2. 指针变量与数组
理解了指针类型的作用以后,我们再回到“指针变量也是变量”这句话上,指针变量所存储的值,就是地址。在之前的学习中,还有什么是与地址相关的概念呢?你一定会想起数组这个概念。对,数组名代表了数组中第一个元素的首地址,也是整个数组的首地址,既然是地址,那就可以用指针变量来存储。
下面,我就跟你说几个之前没有告诉你,但却很有趣的事情。
假设有一个整型数组arr如何表示第二个元素的地址呢是不是 &amp;arr[1] ?如果 arr 也代表了整个数组的首地址,同时把这个首地址存储在一个整型指针变量 p 中,那么用这个指针变量如何表示第二个元素的地址呢?
根据上面的学习,应该是 p + 1。那如何表示 arr[n] 元素的地址呢?稍加思索,你就应该知道就是 p + n。所以我们现在知道了在程序中&amp;arr[n] 等价于 p + n当然也等价于 arr + n聪明的你别犯糊涂一定要注意参与运算的是值不是变量名
既然 p 中存储了一个地址,可以参与加法运算,那么 arr 实际上也代表了一个地址,也可以参与加法运算。地址才是参与运算的值,指针只是存储地址值的变量,只是一个容器。所以,不是指针支持加减法操作,而是地址这种类型的值,支持加减法操作。
在这里,我们回头看数组名称后面的那一对方括号,如果我告诉你这也是一个运算符,你会想到什么?请注意认真看下面这一段合理化的猜想推理:
如果那一对方括号代表了运算符,而运算符本质上是作用在值上面,也就是说,当我们写 arr[1] 的时候,方括号运算符前面看似放着一个数组名,实际上放了一个地址,放了一个数组的首地址,因为 arr 就是数组的首地址,还是那句话:地址才是参与运算的值。也就是说,当我们把数组的首地址,存储在一个指针变量中以后,这个指针变量配合上方括号运算符,也可以达到相同的效果!
为了让你更清楚的理解,准备了如下演示代码:
```
int arr[100] = {1, 2, 3, 4};
int *p = arr;
printf(&quot;%d %d\n&quot;, arr[1], p[1]);
```
代码中,我们定义了一个整型数组 arr然后将数组的首地址赋值给了一个整型指针变量 p最后分别输出 arr[1] 和 p[1] 的值,你将看到输出的是同一个值,都是数组中第二个元素的值。
最后,我用一张图给你展示了指针与数组的几个程序代码层面的等价关系,在实际编程过程中,重点是需要分析,相关的指针操作后,对应的到底是哪个元素,对应的是这个元素的首地址,还是这个元素的值。<br>
<img src="https://static001.geekbang.org/resource/image/08/af/08de66172ebcf2f13cc0ff2b8deba8af.jpg" alt="" title="图3指针与数组的等价表示">
从上图的等价表示中,你可能会自己推导出另外一种等价表示*(p + 5) 等于 arr[5]。我希望你重视等价表示的学习,因为所谓等价表示,就是在写程序的时候,多种等价表示,写哪一种都一样。这就造成了,不同的编码习惯,会用不同的符号来完成程序,如果你不理解这些等价的表示方法,很有可能在看别人程序的过程中,就会出现看不懂的现象。
#### 3.指针变量的大小
最后,我们再回到“指针变量也是变量”这句话上。只要是变量,就占据一定的存储空间,那一个指针变量占多少个字节的存储空间呢?
在回答这个问题之前,我先问你另一个问题,请你思考一下:是整型指针变量占用的存储空间大,还是字符型指针变量占用的存储空间大?我们想想啊,一种数据类型占用多少存储空间跟什么有关系?和存储的值有关系啊。当你想存储一个 32 位整数的时候,就必须要用 4 个字节,不能用 2 个字节,也不能用 3 个字节,这都是不够的。
究竟是哪一种类型的指针占的存储空间大呢?答案是:一样大。为什么呢?就是因为,无论是什么类型的指针,存储的值都是某个字节的地址,而在一个系统中,无论是哪个字节的地址,二进制数据长度都是一样的。所以,无论什么类型的指针,所需要存储的值的底层表示长度是一样的,那么所占用的存储空间也当然是一样的了!
有句话描述的非常形象“类型就是指针变量的职业”。什么意思呢?我们知道现实生活中,有些人做保安,有些人做工程师,还有些人当艺术家,可不管你做什么,你无法改变的是你作为人的生理结构。所以放到指针变量的概念里,那就是不管什么类型的指针,指针所改变不了的是其占用空间的存储大小,因为不管是什么类型的指针,存储的都是无差别的地址信息。
## 任务参考答案
至此,我们终于准备完了所有的基础知识,下面就让我们回到最开始的那个任务吧。对于这个任务,如果我们要是想写的话,至少能写出 20 种以上的答案。这里,我会选出两种比较有代表性的、比较有趣的做法分享给你。
#### 1. 间接引用
首先来看第一种:
```
struct Data *p = a;
printf(&quot;%p&quot;, &amp;((a + 1)-&gt;x));
```
这里用到了一个之前提到过,可是没有讲到的运算符,减号大于号(-&gt;),组合起来,我们叫做“间接引用”运算符,作用可以和“直接引用”运算符对比。
例如a 是一个结构体变量a 中有一个字段叫做 x由 a 去找到 x这个过程比较直接我们就用 a.x 来表示。可如果 p 是一个指针,指向 a 变量,如果要是由 p 去找到 x这个过程就是个间接的过程所以我们就使用 p-&gt;x。简单来说就是是结构体变量引用字段就直接引用如果是指针想引用字段就是间接引用。
在这个第一种做法中,直接用 a + 1 定位到第二个结构体元素的首地址,然后间接引用 x 字段,最后再对 x 字段取地址,那么得到的和原任务中所输出的地址是一样的。
#### 2. 巧妙使用指针类型
再来看一下第二种:
```
struct Data *p = a;
printf(&quot;%p&quot;, &amp;(a[0].y) + 1);
```
这个第二种做法就有点儿意思了。首先,它先定位到 a[0] 元素中 y 字段的首地址,然后对 y 字段取地址,这个时候,由于 y 字段是整型,所以取到的地址类型就是整型地址,之后再对这个整型地址执行 +1 操作,得到的也是 a[1].x 的首地址。
按照之前所学,画出内存中的存储示意图,你就会得到下面这张图的具体情况:<br>
<img src="https://static001.geekbang.org/resource/image/40/bd/409bd833baaab2a1ac3b89c27688cfbd.jpg" alt="" title="图4a数组内存结构示意图">
第二种方法巧妙的利用了地址类型这个知识点,通过整型地址加法操作结合对于内存存储结构的知识,综合运用以上两个知识点,最终定位 a[1].x 变量的地址。如果你可以独立想出这个方案,那我真的是要给你点赞的!
上面的方案中,都在用原数组 a 去定位 a[1].x 变量的地址,你可以使用 p 指针,完成相同的操作么?欢迎把你的答案写在留言区,让我也欣赏一下你的思维方式。记住,这个问题,至少能写出来 20 种以上的等价表示形式。
## 课程小结
今天我们终于讲完了指针部分,这一部分的知识,再回过头来看,虽然各种各样的知识点,可我想让你记住的还是那一句话:“指针变量也是变量”。
而在今天的学习中,我希望你记住的重点,有以下三点:
1. 指针的类型,决定了指针取值时所取的字节数量。
1. 指针的类型,决定了指针加减法过程中,所跨越的字节数量。
1. 无论是什么类型的指针,大小都相等,因为地址信息是统一规格的。
好了,今天就到了这里了,我是胡光,我们下次见!

View File

@@ -0,0 +1,262 @@
<audio id="audio" title="09 | 函数:自己动手实现低配版 scanf 函数" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8e/44/8e516f98bcef5c51ccf344ddbc271b44.mp3"></audio>
你好,我是胡光,今天是大年初一,是咱们中国传统的重要节日,春节!能在这样的节日氛围里,还能坚持过来学习的,我必须要说一声“鼠”你最优秀!在这里我也祝福热爱学习的你,在新的一年,身体健康,阖家欢乐!
今天呢,我们的学习课程也将迎来里程碑式的一课。所谓里程碑,是因为在这一节之前,你写的程序,只是片段,只是思想的随意表达,而通过了本节的学习,你的程序结构将发生翻天覆地的变化,会变得规格严整,变得可以复用,变得易于找错。
前面的课程,我们主要就是在一些基本的程序结构中做学习,包括顺序结构,分支结构以及循环结构。今天这一节中,我们将要认识的函数,可以将功能封装成可以复用的模块,就像创造乐高积木一样,废话不多说,开始今天的学习吧。
## 今日任务
对程序的输入输出函数,你应该已经很熟悉了。今天我们仿照 scanf 函数,实现一个低配版的 my_scanf 函数。这个函数的功能,简单来说就是将一个字符串信息转换成整型数字,能够完成这个任务,你会更深刻的理解 scanf 函数,更深刻的理解参数设计。下面给你几个例子。
首先先来看第一个基础功能:
```
int n = 98;
my_scanf(&quot;12345&quot;, &amp;n);
printf(&quot;%d&quot;, n); // 输出 12345而不是 98
```
上面这段代码中,我们利用 my_scanf 函数,将字符串信息转换成了整型数据,并且将结果存储到了 n 变量的内存空间中,调用 printf 函数打印 n 变量值的时候,输出的信息不是 n 变量原有的初值 98而是 12345。对于这个基础的转换功能要考虑兼容负数的情况。
只有这一个基础功能肯定是远远不够的,下面就让我们看另外一种情况:
```
int n = 98, m = 0;
my_scanf(&quot;123 45&quot;, &amp;n, &amp;m);
printf(&quot;n = %d m = %d&quot;, n, m); // 输出 n = 123 m = 45
```
上面这段代码中,首先我们定义了两个整型变量 n 和 m然后给 n 初始化为 98m 初始化为 0。之后给 my_scanf 函数传入的字符串信息中有一个空格,那么 my_scanf 函数会以空格作为分隔符,将第一个转换出来的数字 123 赋值给 n第二个转换出来的数字 45 赋值给 m。
上面举例了 my_scanf 函数转换 1 个整型参数和 2 个整型参数情况,这些都是在函数的基本知识范围内的内容。经常有初学者学完函数相关的基本知识以后,就认为自己掌握了函数的全部知识,但事实绝非如此,而之所以初学者有这种“假想”,是因为他不知道如何找到和判定自己的知识盲区。
所以今天我们要讲的内容就是破除“假想”。这个任务就是要设计一个能够转换任意个整型参数的 my_scanf 函数,注意,这里的重点难点,可是在任意个参数上面。清楚了任务以后,下面就让我们进入今天的查缺补漏环节吧。
## 必知必会,查缺补漏
要完成今天的这个任务,首先你需要知道如何实现一个基本的函数,由于要支持转换任意多个整型参数,所以你还需要知道变参函数相关的知识。下面我们就逐项的来进行学习吧。
#### 1. 函数的基础知识
数学中的函数,大家都不陌生,一般的形式是 f(x) = yx 是自变量y 是函数值。程序中的函数,和数学中的函数基本一致,有自变量,我们称作“传入参数”,还有函数值,我们叫做返回值。
先让我们来看一下程序中的函数的基本组成部分:<br>
<img src="https://static001.geekbang.org/resource/image/31/76/312f867dfccc3ed8422a5612a11d2e76.jpg" alt="" title="图1函数的基本组成部分">
如图1所示一个程序函数从左到右从上到下大体可以分成四个部分
- 第一个部分,是函数返回值的类型。
- 第二个部分,是函数名字,调用函数的时候,需要给出这个函数名,所以在设计函数名的时候,要尽量设计一个与函数功能有关的名字,例如上图中的函数,通过名字我们可知,这就是一个求平方的函数。
- 第三部分,是传入参数,就是数学函数中的自变量。
- 第四部分就是函数体,也就是要完成函数功能的逻辑代码,结果值是通过 return 语句进行返回的,而整个函数体的逻辑要包裹在一对大括号内部。
下面我们就来看一下在程序中如何使用函数功能:
```
#include &lt;stdio.h&gt;
int square(int x) { // 定义函数 square
return x * x;
}
int main() {
int n;
scanf(&quot;%d&quot;, &amp;n);
printf(&quot;%d\n&quot;, square(n));
return 0;
}
```
上述代码中,在主函数中,我们读入一个整型数字 n然后输出 n 的平方值。这里在计算 n 的平方值的时候,程序中调用了上面定义的 square 函数,那么 printf 函数相当于输出的是 square 函数的返回值,根据 square 函数的实现,如果传入的值是 x那么返回值就是 x * x ,即 x 的平方值。
这里需要你注意两个概念,我们将 n 传递给函数 square 的过程中,会涉及到 n 给 square 函数参数 x 赋值的过程。也就是说,主函数中的 n 变量和 square 函数参数 x 变量是两个相互独立的变量,其中 n 叫做“实参”实际的参数x 叫做“形参”,形式上的参数。
关于这个例子,我还要多说一句,还记得程序中的顺序结构吧,这是程序最基本的执行结构,也就是从左到右,从上到下的执行程序中的每一条语句。其实,函数和函数之间的关系,也可以理解为这种顺序执行的关系。
在这个例子中,我们在主函数中调用了 square 函数,也就意味着在这句话之前,程序中必须知道 square 函数的存在,因此 square 函数实现在了主函数之前。后面的文章中,你将会学到,其实 square 函数不用实现在主函数之前也可以,这就要涉及到“声明”与“定义”的区别了,这个我后面再和你详细解释。
#### 2. 普通变量的函数传递参数
了解了函数的基本知识以后,接下来让我们重点学习一下函数的参数传递过程,也就是上文中提到的“形参”和“实参”之间关系的问题。接下来的学习,我们都是围绕着一句话展开的,你先记住:**函数的参数传递过程,就是“实参”给“形参”赋值的过程,“实参”与“形参”之间互相独立,互不影响。**
下面先来看一下普通变量的传递过程,请看下面这段程序:
```
#include &lt;stdio.h&gt;
void add(int n, int m) {
n += m;
return ;
}
int main() {
int n, m;
scanf(&quot;%d%d&quot;, &amp;n, &amp;m);
add(n, m);
printf(&quot;%d\n&quot;, n);
return 0;
}
```
这段程序中,首先读入两个变量 n 和 m 的值,然后将 n 和 m 传递给一个名叫 add 的函数add 函数的相关参数也叫 n 和 m然后在 add 函数内部,将 m 累加到了 n 上面之后函数返回结束没有返回值。add 函数执行完后,回到主函数中,输出 n 的值。我的问题是此时n 的值有没有变化?
如果你实际运行这个程序你会发现n 的值不会改变,这就是我想让你记住的那句话,函数的参数传递过程,就是“实参”给“形参”赋值的过程。
这个程序中,主函数中的变量 n 就是“实参”add 函数中的参数 n 就是“形参”,虽然两者名字一样,可完全是两个互相独立的变量。
两者有各自的存储空间,“实参”就是把自己存储空间中的值,复制一份给了“形参”,所以,在函数内部,我们实际修改的是“形参”中所存储的值,对主函数中的变量 n 毫无影响。整个过程如下图所示:<br>
<img src="https://static001.geekbang.org/resource/image/2b/ac/2be15931cddfb830bd07012c8d7aefac.jpg" alt="" title="图2实参、形参赋值示意图">
如图所示add 函数内部做的所有操作,都是在黄色的变量存储区内做的,对主函数中的变量存储区毫无影响。
那么如果我们想要改变n最后输出的值你知道这个程序怎么改动呢这里你需要注意往下学习什么是传入参数和传出参数。
#### 3. 数组的函数传参
看了普通变量的传参以后,下面来看一下数组作为参数时候的传参方式和特性,请看下面这段代码:
```
#include &lt;stdio.h&gt;
void add(int *p, int n) {
for (int i = 1; i &lt; n; i++) {
p[0] += p[i];
}
return ;
}
int main() {
int arr[10] = {1, 2, 3};
add(arr, 3);
printf(&quot;%d&quot;, arr[0]);
return 0;
}
```
这段程序中主函数定义了一个拥有10个整型元素的数组然后数组的前三位分别初始化为1、2、3之后将数组作为 add 函数的第一个参数,第二个参数是一个数字 3add 函数的功能是将传入的数组中的前 n 个位置的值,累加到数组的第一个元素上。在 add 函数执行完后,在主函数中输出数组第一个元素的值。
对于这份代码的输出,你有什么预测么?在你做出预测之前,我提醒你注意一个地方,就是 add 函数中负责接收数组参数的第一个参数的类型,是一个指针类型,这里结合之前的知识就能理解了。数组名一般情况下代表了数组的首地址,将一个地址作为值传入函数,当然要用指针变量来进行接收了。
最后,你运行这段程序,会发现输出的结果是 6意味着数组中的第一个元素的值发生了变化。再想想今天我们要记住的那句话**函数的参数传递过程,就是“实参”给“形参”赋值的过程,“实参”与“形参”之间互相独立,互不影响。**
不是说互相独立么,怎么数组的第一个元素的值却改变了呢。没错,数组的第一个元素的值确实在函数内部被改变了,可这跟“实参”和“形参”的关系完全没有冲突。
请你注意,这里面我们的“实参”,实际上是数组的首地址,形参是存储这个首地址的函数参数中的那个指针变量。也就是说,在 add 函数内部,操作的地址空间,和主函数中的那个数组的空间是一个空间,这就是为什么传递数组时,相关元素的值在函数内部可以被改掉的一个原因,因为传递的是地址!<br>
<img src="https://static001.geekbang.org/resource/image/01/cb/018c3fdadbe20ef95fa19997650bbecb.jpg" alt="" title="图3数组传参过程">
就如图3中所示主函数中的数组 arr 将自己的首地址赋值给了指针变量 p两者虽然互相独立可只要不改变指针变量 p 中存储的地址p[0] 和 arr[0] 实际上对应的就是同一片存储空间,所以修改 p[0] 的值,也相当于修改了 arr[0] 的值。
#### 4. 传入与传出参数
学习了函数的基本知识以后,最后让我们来看两个逻辑上的概念“传入参数”与“传出参数”。
请看下面这段程序:
```
#include &lt;stdio.h&gt;
void calc(int x, int *p) {
*p = x * x;
return ;
}
int main() {
int n, m;
scanf(&quot;%d&quot;, &amp;n);
calc(n, &amp;m);
printf(&quot;%d\n&quot;, m);
return 0;
}
```
上面这段程序中,开始先定义了一个 calc 函数calc 函数有两个参数,第一个是一个整型参数,第二个是一个整型地址,函数内部,将 x 的平方值存储到了 p 所指向的存储空间中。在主函数中调用了 calc 函数,分别传入 n 的值和 m 的地址,然后输出 m 的值,最后你会发现输出的 m 值,就是 n 的平方值。
在这里我们重点来讨论一下函数 calc 两个参数的本质作用。首先第一个参数 x是外部传入的一个值这个值在函数内部要参与重要的运算过程也就是说这个值的感觉更像是从外部传入到内部然后在函数内部发挥作用这种类型的参数我们就叫“传入参数”。
而再看 calc 函数的第二个参数,传入的是一个地址。在函数内部的作用,只是将计算得到的一些结果存储在这个地址所指向的空间中,而记录的这些结果,在函数内部是没有用的,是要等到函数执行完后,回到调用者之后,例如上面的主函数内部,才有用。这一个参数的作用,更像是把值从 calc 内部带出到主函数内部而设计的,这类参数,我们叫做“传出参数”。
就像上面代码中看到的,“传入参数”一般就是把值传进去就行,而“传出参数”由于要把值从函数中带出来,一般要传变量地址进去,这样,函数内部才能准确的把结果写入到相关地址所对应的内存中。
## 一起动手,搞事情
我们又到了每天的“一起动手,搞事情”的环节,今天呢,将给你留两个思考题。
#### 思考题1数组和函数的思考
请思考如下两个概念的异同:
<li>
一个整型数组元素例如arr[100]
</li>
<li>
一个传入整型并且返回整型的函数例如func(100)
</li>
这是一个开放思考题,写出你的理解及思考过程即可。
#### 思考题2如何确定存在知识的盲区
什么叫“存在知识的盲区”呢?就是当你面对一片黑暗的时候,你可以确定这里一定有知识,而不仅仅只是一片黑暗。就像今天我们学习了函数的相关知识,自然的,就会反问自己一句,这些就是函数知识的全部了么?我们如何来确定这个问题的答案呢?很简单,根据已知推未知。
我们假设现在学习的内容,已经是函数知识的全部了,也就是说,只要是函数,我们就能用我们现有知识对其加以解释。
那么,在之前,我们已知的函数中,有两个很基础,也很重要的函数,一个是 scanf 函数,一个是 printf 函数。
随便来看一个,例如来看 scanf 函数当我问你scanf 函数,传入几个参数的时候,你会发现是若干个。第一个参数是一个字符串,往后的参数,是根据字符串中格式占位符的数量而定的。在不要求你实现 scanf 函数功能的情况下,你能将 scanf 函数包含参数定义的形式写出来么?直到这里,我们就发现了一个存在知识的盲区。
所以,没有知识的盲区,只是盲区,发现有价值盲区的能力,也是我们要锻炼的重要能力。既然发现了这个知识盲区,给你留个小作业,自学“可变参函数”相关的知识吧。
## 实现 my_scanf 函数
准备完了对于函数的基础知识以后,再回到今天一开始提到的任务。首先来分析一下只转换一个整型参数的 my_scanf 函数应该如何进行实现。
第一步,我们先来看参数设计,第一个参数,应该是一个字符串类型的“传入参数”,代表要转换成整型信息的字符串信息。第二个参数,应该是一个指针类型的“传出参数”,指向存储转换结果的内存区域。
具体功能实现,请看下面这段代码:
```
#include &lt;stdio.h&gt;
void my_scanf(char *str, int *ret) {
int num = 0, flag = 0;
if (str[0] == '-') str += 1, flag = 1;
for (int i = 0; str[i]; i++) {
num = num * 10 + (str[i] - '0');
}
if (flag == 1) num = -num;
*ret = num;
return ;
}
int main() {
char str[1000];
int n = 65;
scanf(&quot;%s&quot;, str);
my_scanf(str, &amp;n);
printf(&quot;n = %d\n&quot;, n);
return 0;
}
```
这段代码中,实现了 my_scanf 函数。在看 my_scanf 函数具体逻辑之前,先来看一下主函数里面都写了些什么。
主函数的头两行定义了两个变量,一个是字符数组 str另外是一个整型变量 n然后读入一个字符串将其保存在字符数组中。再之后使用 my_scanf 函数将字符数组中的字符串信息,转换成为整型信息存储在 n 中,最后,使用 printf 函数输出 n 的值,加以确认。
看完了主函数以后,再来看一下 my_scanf 函数的具体实现。my_scanf 函数第一行定义了两个变量,一个用于存放转换结果的 num 变量,另一个 flag 变量用来标记正负数的0代表正数1代表负数。
第2行判断字符串中的第一位是不是字符 -’,如果是字符 -’,就将 flag 标记为1并且把 str 字符指针所指的位置,向后跳动一位,因为 - 后面就是要转换的第一个数字字符了。之后遍历字符串剩余的每一位,每次将当前字符所代表的数字,放到 num 数字的末尾。
其中 str[i] - 0就是将相关的数字字符转换成对应的数字。之前我们说了任何一个信息在底层存储的时候都是二进制信息表示也就是说都可以转换为一个十进制数字字符信息也不例外。其中字符 0 所对应的底层数字是48字符 1 是 49字符 2 是 50依次类推。所以当我们用 2 - 0 的时候相当于50 - 48得到的结果就是数字 2。
最后把 num 中的值拷贝到 ret 所指向的存储区中,也就是主函数中的 n 变量的内存区中。至此我们就完成了一个整型参数的 my_scanf 函数的实现。接下来,运用“可变参函数”的相关知识,改写这个程序,去独立完成最终形态的程序吧。
## 课程小结
今天讲的内容呢,是里程碑式的一课,到目前为止,你已经学会了将程序模块化的最基本技术:函数。也是从这一课开始,后面我将越来越多的起到引导你的作用,逐渐帮你撤掉学习中对我的依赖,如果后续学习中遇到什么问题,咱们随时在留言区中讨论。
最后呢,我来给你总结一下今天课程的重点,只希望你记住三点:
1. 函数的作用,是做功能封装,以便在程序其他地方复用相关功能。
1. C 语言中的函数的传参过程,是实参给形参赋值的过程,改变形参的值,不会影响实参。
1. 在函数参数设计中,一定要分清楚,传入参数和传出参数在功能上的差别。
好了,今天就到这里了,我是胡光,我们下次见。

View File

@@ -0,0 +1,154 @@
<audio id="audio" title="10 | 预处理命令(上):必须掌握的“黑魔法”,让编译器帮你写代码" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/75/a3/750a0e76e68ed35cc60d0e33b6093ea3.mp3"></audio>
你好,我是胡光,欢迎回来。今天是大年初四,春节的气氛依然很浓厚,春节玩得开心吗?但也别忘了咱们的继续学习哦。今天还在看专栏,依旧没有忘记学习的你,我必须赞叹一声:学会编程,非你莫“鼠”!
之前我们学习的编程知识,都是作用在程序运行阶段,也就是说,当我们写完了一段代码以后,只有编译成可执行程序,我们才能在这个可执行程序运行后,看到当初我们所写代码的运行效果。而你有没有想过,存在一些编程技巧,是作用在非运行阶段的呢?这就是我们今天要学习的内容。
今天呢,我们将来讲解整个语言基础篇的最后一部分:预处理命令。那么什么是预处理命令呢?它又为什么被称为程序设计中的“黑魔法”呢?让我们开始今天的学习吧。
## 任务介绍
这次这个任务呢,我们将分成两节来讲解,这是因为,想要掌握程序设计中的这门“黑魔法”,真的急不来,咱得慢慢来。
本次这个任务呢,和输出有关系:请你实现一个打印“漂亮日志格式”的方法。你可能想用 printf 直接打印,别着急,听我详细说完这个打印日志的功能介绍以后,你可能就知道什么叫做“魔法般的方法”了。
首先我们先说“日志”的作用,程序中的“日志”,通常是指在程序运行过程中,输出的一些与程序当前状态或者数据相关的一些信息。这些信息,可以帮助程序开发人员做调试,帮助运营人员做数据分析,帮助管理人员分析日活等等。总而言之,一份合理的日志信息,是非常有价值的数据。而我们今天呢,接触一种最简单的日志形式,就是程序运行过程中的调试信息。
请你实现一个参数形式和 printf 函数一样的 log 方法,用法如代码所示:
```
#include &lt;stdio.h&gt;
void func(int a) {
log(&quot;a = %d\n&quot;, a);
}
int main() {
int a = 123;
printf(&quot;a = %d\n&quot;, a);
log(&quot;a = %d\n&quot;, a);
func(a);
return 0;
}
```
你会看到上述代码中,有一个和 printf 名字不一样可用法完全一样的方法叫做 log而这个 log 的输出结果,和 printf 可不一样。
具体如下:
```
a = 123
[main, 10] a = 123
[func, 4] a = 123
```
你会看到 log 的方法,虽然和 printf 函数的用法一致可在输出内容中log 方法的输出明显比 printf 函数的输出要多了一些信息。
首先第1行是 printf 函数的输出这个就不用我多说了想必你已经很熟悉了。第2行和第3行都是 log 方法的输出,一个是主函数中的 log 方法,另外一个是在 func 函数中执行的 log 方法。
你会看到log 方法的输出中,会输出额外的两个信息:一个是所在的函数名称信息,在主函数中的 log 方法就会输出 main 主函数的名称,在 func 函数中的 log 方法,就会输出 func 函数的名称;除了函数名称信息以外,另一个就是多了一个 log 函数所在代码第几行的信息,第一个执行的 log 在代码的第 10 行,就输出了个 10第二个 log 执行的时候,在代码的第 4 行,就输出了个 4。
正是因为 log 方法比 printf 函数多了这些信息,才使我们更清晰地知道相关调试信息在源代码逻辑中所在的位置,能够帮助我们更好地去理解, 以及分析程序运行过程中的问题。哦,对了,这里再加一个小需求,就是设计完 log 方法以后,请再给这个 log 方法提供一个小开关,开关的作用是能够很方便的打开或者关闭程序中所有 log 的输出信息。
现在你应该清楚了本次的这个任务吧,那么如何完成这样的一个任务呢?跟我来一起开始预处理命令相关的学习吧。
## 必知必会,查缺补漏
#### 1. 认识预处理命令家族
先来让我们认识一下今天课程的主角:预处理命令家族。在真实世界里面,有很多家族,每个家族都有自己的姓氏,例如:数学圈里面的伯努利家族,伯努利就是这个家族的统一的符号。而预处理命令家族,也有自己的特殊符号,那就是以 # 作为开头的代码。
说到这个特征,你能想到什么?你之前其实就见过这个家族的成员,只不过那个时候,我们没有特殊的提出来过。敏锐的你,可能想到了,#include 不就是以 # 作为开头的代码么?
没错,#include 就是预处理命令家族中的一员,对于它的认知,你可能觉得,是用来做功能添加的,当我们写了#include &lt;stdio.h&gt; 以后,程序中就有了 scanf 函数或者 printf 等函数的功能了,这种认识没有错,不过还是不够精准。
为了更精准地认识预处理命令的作用,我们得先来说一下 C 语言程序从源代码到可执行程序的过程。并且为了让你能够更聚焦地进行学习,我挑了三个重要的环节来展示给你,理解了这三个环节,也就能够理解 C 语言在编译过程中所报出来的 90% 的错误原因。
这三个环节就是:**预处理阶段****编译阶段****链接阶段**。三个阶段从前到后依次执行,完成整个 C 语言程序的编译过程,上一个阶段的输出就是下一个阶段的输入。说到这里,你可能发现了,原来我们之前所说的编译程序,是这个复杂过程的简称。
为了让你更清楚地了解三个阶段的关系,我给你准备了下面的一张图,帮助你理解:<br>
<img src="https://static001.geekbang.org/resource/image/e8/d0/e8526b70fe1405759d5633f047e60ed0.jpg" alt="" title="程序编译流程图">
在上图中,有两个概念,你是熟悉的,一个是**源代码**,就是你所编写的代码,另外一个是**可执行程序**就是你的编译器最终产生的可以在你的环境中运行的那个程序。windows 下面,就是产生的那个后缀名为 .exe 的文件。
剩余两个概念,你可能比较陌生,一个是**待编译源码**,另外一个是**对象文件**。关于这两个概念,今天我将重点给你介绍的就是**待编译源码**,也就是预处理阶段输出的内容,同时也是编译阶段的输入内容。
而关于**对象文件**的相关知识,我会在后面给你留个小作业,不用担心,现阶段,你即使不理解**对象文件**是什么东西,也不会影响你之后的学习。如果你想搞懂什么是**对象文件** 那我建议你,先搞懂“声明”和“定义”的区别,这种学习路线,会更加有效一些。
#### 2. 预处理阶段
下面呢,我们就来说说预处理阶段。首先来看预处理阶段的输入和输出内容,输入内容是“源代码”就是你写的程序,输出内容是“待编译源码”。之所以叫做“待编译源码”,那是因为这份代码,才是我们交给编译器完成后续编译过程的真正的代码。它是由预处理器处理完“源代码”中的所有预处理命令后,所产生的代码,这份代码的内容跟“源代码”相比,已经算是面目全非了。
咱们下面就拿一个最简单的例子,来说明这一点。刚刚我们说过了,#include 是我们所谓的预处理命令家族中的一员,它真正的作用,是在预处理阶段的时候,把其后所指定文件中的内容粘贴到相应的代码处。
例如:#include &lt;stdio.h&gt;这句代码,在预处理阶段,预处理器就会找到 stdio.h 这个文件,然后把这个文件中的内容原封不动的粘贴到 #include &lt;stdio.h&gt; 代码所在的位置。至于 stdio.h 这个文件在哪里,编译器是怎么找到它的,这个问题不是我们今天所讨论的重点,所以你可以先忽略它。这样呢,我们对于预处理命令 include 就有了更清晰的认识了。
下面呢,我们就围绕着 include 预处理命令设计一个小实验,来说明“源代码”和“待编译源码”的区别。
首先呢,我们准备两个文件,两个文件一定要在同一个目录下,一个文件的名字叫做 my_header.h另外一个叫做 test_include.c两个文件中的内容呢如下所示
```
//my_header.h 文件内容
int a = 123, b = 456;
```
```
//test_include.c 文件内容
#include &lt;stdio.h&gt;
#include &quot;my_header.h&quot;
int main() {
printf(&quot;%d + %d = %d\n&quot;, a, b, a + b);
return 0;
}
```
如果你编译运行 test_include.c 这个程序的话,你会发现,程序可以正常通过编译,并且会在屏幕上正确输出一行信息:
```
123 + 456 = 579
```
这个过程中,我们就重点来思考一个问题,为什么在 test_include 源文件中没有定义 a、b 变量,而我们在主函数中却可以访问到 a、 b 变量,并且 a、b 变量所对应的值和我们在 my_header 头文件中对 a、 b 变量初始化的值一样?
要解答上面这个问题,就要理解刚刚所说的 include 预处理命令的作用。回想一下,刚刚我们介绍 include 预处理命令的作用就是在预处理阶段把后面指定文件的内容原封不动的粘贴到对应的位置。也就是说test_include 源代码文件经过了预处理阶段以后,所产生的待编译源码已经变成了如下样子,如下所示,其中我删掉了 stdio.h 展开以后的内容:
```
// 假装这里有 stdio.h 展开以后的内容
int a = 123, b = 456; // my_header.h 展开以后的内容
int main() {
printf(&quot;%d + %d = %d\n&quot;, a, b, a + b);
return 0;
}
```
在这份待编译源码中,你可以看到是存在变量 a 和 b 的相关定义和赋值初始化的。因为待编译源码是一份合法的代码,所以才能通过编译阶段,最终生成具有相应功能的可执行文件。
看完了这个过程以后,我希望你注意到一点,如果要分析最终程序的功能,不是分析“源代码”,而是要分析“待编译源码”,也就是说,是“待编译源码”决定了程序最终功能。
要想搞清楚待编译源码,就必须要理解预处理阶段做的事情,也就是各种预处理命令的作用。这些预处理命令,会在编译过程中,帮你改变你的代码,更形象化一点儿,就是仿佛是编译器在帮你修改代码一样。
那么程序最终的功能呢,就是由这份编译器修改过后的代码所决定的,编译器就是预处理命令这个“黑魔法”背后,那股神秘而强大的力量。
## 思考题
今天呢,没有以往具体的要求,让你写出一个实现什么功能的程序。而是留了一个对你要求更高,更加考验你思考总结能力的问题。
这个课后自学作业,就是请你通过自己查阅资料,搞清楚对象文件的作用,并且用尽可能简短的话语在留言区阐述你的理解。记住:由简到繁,是能力,由繁到简,是境界。
## 课程小结
最后,我来给你做一下这次的课程总结。今天,只希望你理解以下三点即可:
1. C 语言的程序编译是一套过程,中间你必须搞懂的有:预处理阶段,编译阶段和链接阶段。
1. 程序最终的功能,是由“待编译源码”决定的,而“待编译源码”是由各种各样的预处理命令决定的。
1. 预处理命令之所以被称为“黑魔法”,是因为编译器会根据预处理命令改变你的源代码,这个过程,神秘而具有力量,功能强大。
下篇文章中呢,我将带你具体的认识几个预处理命令家族中的成员,带你真正的体会一下这个“黑魔法”的力量,并且我们会在下一篇文章中,解决掉今天提到的“打印漂亮日志”的任务。
好了,今天就到这里了,我是胡光,我们下期见。

View File

@@ -0,0 +1,283 @@
<audio id="audio" title="11 | 预处理命令(下):必须掌握的“黑魔法”,让编译器帮你写代码" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ca/ac/caf64fcff1332808e90e667c208000ac.mp3"></audio>
你好,我是胡光,欢迎回来。最近为了防范疫情,很多人应该都窝在家里吧?春节假期除了娱乐放松,也不要忘记学习提高呀!
上次呢,我们知道了,原来程序的编译,是一个复杂的过程,其中重要的是三个阶段:**预处理阶段****编译阶段**和**链接阶段** 。
同时,我们也搞清楚了“源代码”和“待编译源码”两个概念的区别,其中“待编译源码”是由“源代码”经过预处理阶段所产生的代码,并且“待编译源码”才是决定程序最终功能的终版代码。
今天呢,我们继续上节课的知识,来具体学习几个重要的,能够影响“待编译源码”内容的预处理命令吧。
## 本次任务
在正式开始今天课程之前,我们先来回顾一下任务内容:实现一个使用方法和 printf 函数一样的,但是输出信息却比 printf 更加人性化的,更加具体的 log 方法。
具体代码及事例,参考如下:
```
#include &lt;stdio.h&gt;
void func(int a) {
log(&quot;a = %d\n&quot;, a);
}
int main() {
int a = 123;
printf(&quot;a = %d\n&quot;, a);
log(&quot;a = %d\n&quot;, a);
func(a);
return 0;
}
```
```
a = 123
[main, 10] a = 123
[func, 4] a = 123
```
通过文稿代码可以看到经过log方法后我们获得了更多程序信息。但我们的任务是设计完 log 方法以后,请再给这个 log 方法提供一个小开关,使其很方便的打开或者关闭程序中所有 log 的输出信息。
回顾完了任务以后,就让我们一起来进行具体的预处理命令的学习吧。
## 必知必会,查缺补漏
在上一节,我们明确了 include 文件包含预处理命令的作用。今天,我们将来着重讲解两种预处理命令**宏定义**与**条件编译**。它们是什么意思呢?不要着急,听我一个个给你解释。
#### 1. 初识宏定义
宏定义在预处理阶段的作用,就是做简单的替换,将 A 内容,替换成 B 内容,这里需要你特别注意的是,一个宏定义只能占一行代码,这可不是你所认为的一行代码,而是编译器所认为的一行代码,这里在后面,我们会详细来介绍一下。
这里先给你准备了一张示意图,用来说明宏定义的基本用法:<br>
<img src="https://static001.geekbang.org/resource/image/93/c8/939a45667cbb4daca048c2e052b371c8.jpg" alt="" title="图1:宏定义语法结构示意图">
正如上图所示,宏定义以 #define 作为语句的开头,之后两部分,用空格分隔,在预处理阶段期间,会把代码中的 A 内容替换成 B 内容,以此来最终生成“待编译源码”。
下面我们就使用宏来实现一个读入圆的半径,输出圆面积的程序:
```
#include &lt;stdio.h&gt;
#define PI 3.1415926
int main() {
double r;
scanf(&quot;%lf&quot;, &amp;r);
printf(&quot;%lf\n&quot;, PI * r * r);
return 0;
}
```
在上面程序中,我们定义了一个名字为 PI 的宏其替换内容为3.1415926,也就是圆周率π的相似值。在主函数中,我们读入一个圆的半径值,存储在 r 变量中,然后输出圆的面积,在计算圆面积公式的时候,我们没有使用圆周率本来值来进行程序书写,而是使用刚刚上面定义的宏 PI 代替了圆周率的作用。
面对这份源代码,在预处理阶段的时候,编译器会把代码中所有使用 PI 的地方都替换成3.1415926,也就是说,上述代码中的输出函数中,原本的 PI * r * r 的代码内容,会被编译器改写成为 3.1415926 * r * r 作为“待编译源码”。
通过这个例子,我想你就能差不多明白了,什么叫做“宏定义在预处理阶段做的就是简单的替换”以及“宏定义在代码中,只能占一行”,简单来说,就是**宏定义关键字**、**原内容和替换内容** **三者必须写到一行**
#### 2.宏定义之傻瓜表达式
前面呢,我们说的是宏定义的最基本用法。其实,宏定义中的“原内容”的形式,不仅仅有刚才的类似于 PI 这种简单符号,还有一种更加灵活实用的带参数的形式,如图所示:<br>
<img src="https://static001.geekbang.org/resource/image/63/70/632274dbc1188cf9dddf607647871270.jpg" alt="" title="图2:傻瓜表达式结构示意图">
可以看到,我们定义了一个支持两个参数的宏,名字为 mul替换的内容为 a * b。注意替换内容中的 a 是宏参数中的 ab 也是宏参数中的 b。这里我再强调一下理解宏的工作过程始终离不开那句话**宏做的就是简单替换**。
下面给你举个例子:
```
#include &lt;stdio.h&gt;
#define mul(a, b) a * b
int main() {
printf(&quot;mul(3, 5) = %d\n&quot;, mul(3, 5));
printf(&quot;mul(3 + 4, 5) = %d\n&quot;, mul(3 + 4, 5));
return 0;
}
```
上面代码中,使用了 mul 宏,分别输出了 mul(3, 5) 的值,和 mul(3 + 4, 5) 的值。如果你把 mul 当成函数看待的话,你应该会觉得,第一行输出的值应该是 15即 3 * 5 结果;第二行应该是 35计算的应该是 7 * 5 的结果。
可如果你在你的环境中运行这个代码,你会看到第一行输出的结果确实是 15和我们的预期一样可第二行输出的却是 23这个离我们预想的可就有点儿不一样了。
想要理解为什么输出的是 23而不是 35 的话,我们需要综合以下两点来进行思考:
- “待编译源码”决定了最终程序的功能。
- 宏做的就是简单的替换。
宏在预处理阶段将被展开变成“待编译源码”中的内容并且做的仅仅是简单的替换。也就是说mul(a, b) 这个宏,替换的形式是 a * b而 mul(3 + 4, 5) 中 3 + 4 是参数 a 的内容5 是 b 的内容,依次替换为 a*b 式中的 ab 的话,最终得到的替换内容应该是 “3 + 4 * 5”这个才是“待编译源码”中真正的内容。面对这个替换以后的表达式你就知道为什么输出的结果是 23而不是 35 了吧。
所以,正如你所看到的,**mul 的使用形式虽然和函数类似,可实际运行原理和函数完全不一样**,甚至显得有些机械化。因为 mul 是宏,而宏做的就是简单的替换操作,变成最终的“待编译源码”中的内容。这个过程机械且简单,所以,我们有时也称其为**傻瓜表达式**。
再回来看上面的 mul 宏,使用形式像函数,但函数可以在代码中写成多行的一段代码。可宏呢,只能写成一行,就会使得当我们面对稍微复杂一点的替换内容,宏代码的可读性就会变得特别差。
还好C 语言给我们提供了一种在行尾加 \(反斜杠)的语法,以此来告诉编译器,本行和下一行其实是同一行内容。这样就做到了:人在阅读代码的时候,看到的是两行代码,而编译器在解析的时候,会认为是一行代码,也就解决了复杂的宏定义的可读性的问题。
具体事例,看如下代码:
```
#include &lt;stdio.h&gt;
#define swap(a, b) { \
__typeof(a) __temp = a; \
a = b, b = __temp; \
}
int main() {
int num_a = 123, num_b = 456;
swap(num_a, num_b);
printf(&quot;num_a = %d\n&quot;, num_a);
printf(&quot;num_b = %d\n&quot;, num_b);
return 0;
}
```
如上代码中,我们定义了一个用于交换两个变量值的宏 swap代码的第 2、3、4 行的末尾都有一个反斜杠,编译器就会认为把程序的这几行内容当成一行内容来对待。这样,既保证了宏定义的只占用一行的语法要求,又兼顾了代码可读性。
需要特别注意的是,**代码中反斜杠的后面,不能出现任何其他内容**。作为新手的话,这里是最容易出错的,很多人会在反斜杠后面多打一个空格,会导致反斜杠失去原本的作用,代码查错的时候,也不容易被发现,这里一定要十分小心。
此外,你看到上述代码中,多了一个`__typeof`方法,关于这个方法的作用呢,给你留个小的作业题,请你自行查阅相关资料,并用一句话描述 `__typeof` 的作用。欢迎在专栏的留言区里面写下你认为足够简洁的 `__typeof`的功能描述。
#### 3. 初识条件编译
看完了宏定义之后,下面来让我们看看另一个使用的比较频繁的预处理命令:条件编译。说到条件编译,光看名字,你也许会联想到 if 条件分支语句。对,条件编译,就是预处理阶段的条件分支语句,其主要作用是根据条件,决定“源代码”中的哪些代码,接下来会被预处理继续进行处理。
我们先来从最容易理解的条件编译开始看起,来了解一下条件编译的语法格式:<br>
<img src="https://static001.geekbang.org/resource/image/9b/4a/9bac2450536bc8dfaa3569a8986de94a.jpg" alt="" title="图3:条件编译的语法结构示意图">
如图所示,这个条件编译以指令 #ifdef 作为开头,后面接了一个 Debug。意思是如果定义了Debug 这个宏就让预处理器继续处理“代码内容1”否则就处理“代码内容2”。记住条件编译可以没有 #else 部分,可最后一定要以 #endif 作为结束。
下面给你举个简单的例子:
```
#include &lt;stdio.h&gt;
#define Debug
#ifdef Debug
#define MAX_N 1000
#else
#define MAX_N 5000
#endif
int main() {
printf(&quot;MAX_N = %d\n&quot;, MAX_N);
return 0;
}
```
如果你运行上面这段代码,你的程序一定会输出 MAX_N = 1000那是因为当代码运行到条件编译的时候由于之前定义了 Debug 宏,条件编译的条件成立,保留的是第 4 行代码内容,所以主函数中的 MAX_N 宏最终就会被替换成为 1000。
如果你将第2行代码去掉的话那么条件编译的条件就不成立了最终被保留下来的是第 6 行代码,程序就会输出 MAX_N = 5000关于这点你可以自行尝试一下。
其实在条件编译中,除了我们刚才讲到的三个指令:#ifdef#else#endif 之外,还有 #if#ifndef 以及 #elif 等指令。关于剩下的三个指令的含义和作用,有了这个基础之后,你就可以很轻松的学会了,我就不再赘述了。
我在这里给你准备了一张对照表,以说明这 6 个指令各自的作用:<br>
<img src="https://static001.geekbang.org/resource/image/64/0b/64ca97df0c65c079a62919dc362b0f0b.jpg" alt="">
## 一起动手,搞事情
#### 思考题:没有 Bug 的 MAX 宏
>
请你完善下面代码中的 MAX 宏MAX 宏的作用,就是接受两个元素,选择出两个元素中的最大值。完善以后的 MAX 宏,输出需要与如下给出的输出样例一致,注意,只能修改 MAX 宏的定义内容,不可以修改主函数中的内容。
```
#include &lt;stdio.h&gt;
#define P(item) printf(&quot;%s = %d\n&quot;, #item, item);
#define MAX(a, b) // TODO
int main() {
int a = 6;
P(MAX(2, 3));
P(5 + MAX(2, 3));
P(MAX(2, MAX(3, 4)));
P(MAX(2, 3 &gt; 4 ? 3 : 4));
P(MAX(a++, 5));
P(a);
return 0;
}
```
输出结果参考:
```
MAX(2, 3) = 3
5 + MAX(2, 3) = 8
MAX(2, MAX(3, 4)) = 4
MAX(2, 3 &gt; 4 ? 3 : 4) = 4
MAX(a++, 5) = 6
a = 7
```
## 输出漂亮的日志格式
准备完了上面的这些基础知识以后,下面来让我们回到最开始的那个任务。
首先我们来思考,要实现一个和 printf 使用方式一样的 log 方法, printf 函数是一个变参函数,那么 log 也需要支持变参,而 log 方法又比 printf 输出的更人性化一些,其中包括了可以输出所在的函数信息,以及所在的代码位置信息。这里,我们选择使用宏定义来实现所谓的 log 方法。
下面,就给你再补充一个小知识点,就是如何定义一个支持可变参数的 log 宏,看如下代码:
```
#define log(frm, args...) // 假装这里有内容,后续展开讲解
```
如上代码所示,在最后一个参数后面,加上三个点,就代表,这个宏除了第一个 frm 参数以外,后面接收的参数个数是可变的,那么后面的参数内容,统一存放在参数 args 中。
这样,我们就可以设计如下代码,使得 log 方法的使用方式与 printf 类似了:
```
#define log(frm, args...) printf(frm, args)
```
此时log 方法的输出内容,只是和 printf 方法的输出内容是一致的,还无法输出所在函数以及所在代码位置的相关信息。
下面,我们来补充最后一个知识点,就是编译器会预设一些宏,这些宏会为我们提供很多与代码相关的有用信息,具体如下表所示:<br>
<img src="https://static001.geekbang.org/resource/image/b4/04/b4c736f3cbb0638d3d9171944841d904.jpg" alt="">
我们看到表中有两个宏,是我们这个任务所需要的,一个是 `__func__`代表了当前所在的函数名,另一个是`__LINE__`代表了当前行号。
其中宏`__func__`后面的说明中,注明了是“非标准”,什么叫做非标准呢,也就是说,在不同的编译器中,这个宏的名称可能是不同的,甚至某些编译器不提供这个宏,也是有可能的。例如在 VC 6.0 的环境中就没有`__func__`宏,因为这个宏不是 C 语言标准里面的东西。
通过这个`__func__`宏,我想让你初步认识到什么是代码的 **“可移植性”**,也就是说,你写了一份代码,当你的运行环境发生改变时,你的代码到底要不要做修改?如果要做修改,到底要做多少修改?这是代码的可移植性所讨论的问题。
放到今天这个例子中,就是说,如果你在你的代码中,不做任何处理的,直接使用`__func__`宏,那么就会影响你代码的可移植性。如果还不清楚什么是代码的可移植性,你就回想一下,当初我们输出彩色文字的那个代码,是不是在有些人的环境中,无法输出彩色文字?
最后,有了这些基础知识以后,就不难完成这个任务了,下面是我给出的 log 宏的参考代码:
```
#define log(frm, args...) {
printf(&quot;[%s : %d] &quot;,__func__,__LINE__); \
printf(frm, args); \
}
```
正如你看到的log 宏的定义中,使用了编写多行宏的技巧,就是在行尾添加反斜杠,以达到增强代码可读性的目的。然后 log 宏中,包含两个 printf 输出语句,第一个 printf 语句,输出函数以及代码位置信息;第二个 printf语句输出 log 宏所接收的内容。
至此,我们看似完成了最初的任务,可不要高兴太早,所有与宏相关的东西,都没那么简单。上面的这个实现,其实是有 Bug不信的话你就在你的环境中尝试像如下代码一样调用 log 宏:
```
log(&quot;hello world\n&quot;);
```
这个就是今天给你留的最后一个需要自己独立解决的小 Bug记住勤用及善用搜索引擎会大大提升你的学习效率和效果。至于如何方便的开关日志输出参考今天的条件编译思考一下我相信这个难不倒你。
## 课程小结
通过这个任务呢,我们大体的认识了预处理命令家族,算是全方位地了解了宏及条件编译相关的内容。下面呢,我来给你做一下今天这节课的课程小结:
1. 宏定义只占用一行代码,为了增强宏定义的代码可读性,我们可以采用在行尾加反斜杠的技巧,来使得上下两行代码,变成编译器眼中的一行代码。
1. 宏的作用,就是替换,要想理解最终的代码行为,必须从宏替换以后的代码入手分析。
1. 条件编译相当于一种预处理阶段的代码剪裁技巧。
1. 编译器预设的宏,有标准的,也有非标准的,非标准的代码会影响其可移植性。
至此,我们就完成了“语言基础篇”的全部内容,从下一节开始呢,我们将进入注重培养编程思维“编码能力训练篇”的学习。届时,我们的学习更偏重于思维方式的训练和讲解,不会像语言基础篇一样,有这么多零零碎碎的知识点。我也相信,只要你勤于思考,就一定跟得上学习节奏。
好了,今天就到这里了,我是胡光,我们“编码能力训练篇”,不见不散。

View File

@@ -0,0 +1,200 @@
<audio id="audio" title="做好闭环(一):不看答案可能就白学了" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9b/be/9b6dc65919721c7186faa786404a18be.mp3"></audio>
你好,我是胡光。
不知不觉,语言基础篇已学习过半,我非常高兴,看到很多同学都在坚持学习。并且,还有一些同学,每每都能在专栏上线的第一时间里,给我留言,提出疑惑。当面对一些知识点的时候,如果在我的观念中它是不说自明,而对于新手的你来说,可能十分难理解的时候,我也很希望你能指出来,我会在留言区中给你解答的。因为,我知道这种讨论,肯定能够帮助到更多的人。
大部分留言,我都在相对应的文章中回复过了,而对于文章中的思考题呢,由于要给你留足思考时间,所以我选择,一起留在今天这样一篇文章中,给你进行一一的解答。
看一看我的参考答案,和你的思考结果之间,有什么不同吧。也欢迎你在留言区中,给出一些你感兴趣的题目的思考结果,我希望我们能在这个过程中,碰撞出更多智慧的火花。在这里呢,@rocedu 用户在第一篇留言区中给大家推荐的《程序设计实践》一书,也是非常优秀的书籍。有兴趣的小伙伴,也可以去到他提到的豆瓣读书主页中去游览一番。
## 第一个程序:教你输出彩色的文字
在这一篇里面呢,我们接触了如何在 Linux 环境下输出彩色文字的编程知识。初步学习了 scanf 和 printf 函数的基础用法,两者一个负责读入,一个负责输出。如果你对这篇文章的内容有点陌生,可以再回去看看[《第一个程序:教你输出彩色的文字》](https://time.geekbang.org/column/article/186076)。最后围绕着这两个函数,给你出了两个思考题。这两个思考题做的怎么样?下面来看看我的参考答案吧。
#### 思考题1位数输出
```
#include &lt;stdio.h&gt;
int main() {
int n;
scanf(&quot;%d&quot;, &amp;n);
printf(&quot; has %d digits\n&quot;, printf(&quot;%d&quot;, n)); // 有多余输出
char output[50];
int ret = sprintf(output, &quot;%d&quot;, n);
printf(&quot;%d\n&quot;, ret); // 无多余输出
return 0;
}
```
运行如上程序,如果输入 123程序会输出如下两行内容
```
123 has 3 digits
3
```
你会看到第1行除了数字的位数信息以外还有多余的输出第2行则是没有多余的输出。而两个信息都是单纯利用 printf 一族函数完成的。这个问题的解题关键是,理解 printf 函数是有返回值的,而其返回的含义是打印了多少个字符。
那么,当我们使用 printf 打印数字 n 的时候printf 函数的返回值,就是代表了 n 的位数。类似的sprintf 也是 printf 一族函数中的一员,它的返回值与 printf 含义相同。
#### 思考题2读入一行字符串
```
#include &lt;stdio.h&gt;
char str[100];
int main() {
scanf(&quot;%[^\n]s&quot;, str);
printf(&quot;%s\n&quot;, str);
return 0;
}
```
这段代码展现了如何使用 scanf 读入一行包含空格的字符串信息。其中,要读入字符串,就需要使用 %s 格式占位符。可是这道题目中,在 % 和 s 中间有一对中括号[],这个[] 代表了一个集合,用来控制%s 在读入过程中可以读入的字符集合的,例如:%[a-z]s是可以输入小写字母 a 到 z那么一旦遇到了非小写字母就会停止。
而上述代码中的 ^ 上尖号,读作非,“^\n” 就是非换行符,也就是说,只要不是换行符,就可以继续读入。这也就达到了我们想要用 scanf 读入一行的功能要求。你可以自己试一下换成 %[a-z]s然后输入 “abcd12efeee”看看程序的输出你就能明白了。
## 判断与循环:给你的程序加上处理逻辑
在这篇文章[《判断与循环:给你的程序加上处理逻辑》](http://time.geekbang.org/column/article/185667)中呢,我们学习了除了顺序结构以外的两种程序执行结构:分支结构和循环结构。知识点的话,主要涉及:**条件表达式、if 语句、for 语句等知识内容**。我们说到任何表达式都有返回值条件表达式的值就是1或者0代表“真”或者“假”“成立”或者“不成立”。并且介绍了条件判断的时候实际上遵循的原则是“非零即为真”。最后呢给你留了一个和循环相关的思考题“打印乘法表”下面就看看我的参考答案吧。
#### 思考题:打印乘法表
```
#include &lt;stdio.h&gt;
int main() {
for (int i = 1; i &lt;= 6; i++) {
for (int j = 1; j &lt;= i; j++) {
j == 1 || printf(&quot;\t&quot;);
printf(&quot;%d * %d = %d&quot;, j, i, i * j);
}
printf(&quot;\n&quot;);
}
return 0;
}
```
这段代码中,采用两层循环,外层循环控制行数,内层循环控制每一行的列数,第 i 行应该有 i 列,所以内层循环是从 1 循环到 i 为止。其中最值得琢磨的是“j == 1 || printf("\t");”这句代码,其实这句代码就是用来实现行尾无多余 \t 字符这个要求的。代码中采用了在每一列的前面输出一个 \t 字符,可是在第一列的前面不输出 \t 字符,这样就保证了行尾无 \t 字符。
那么“j == 1 || printf("\t");”这句代码是如何工作的呢?首先看 || 条件或运算符。|| 运算符的工作逻辑是,左右两侧只要有一个条件成立,那么最终结果就是成立的。这个工作逻辑,还值得细细思考,|| 运算符,从左到右依次判断两个条件是否成立,那么如果第一个左边的条件就成立了呢?作为一个聪明人,还需要判断第二个右边的条件么?你会发现,根本不需要再判断右边的条件了,也就是说不需要执行右边的代码了。
看完了条件“或”的这个特性之后我们再看看“j == 1 || printf("\t");”这句代码,也就是说,当 j==1 成立时,也就是第一列的时候,右边的 printf("\t") 代码就根本不会执行。这也就意味着,第一列前面不会多输出一个 \t 字符。而其他的情况呢,均会执行 printf("\t") 代码,这也就实现了题目中的要求。
## 随机函数:随机实验真的可以算 π 值嘛?
这一篇文章里面[《随机函数:随机实验真的可以算 π 值嘛?》](https://time.geekbang.org/column/article/187287),我们介绍了程序里面随机函数的基本原理,说明了“真随机”和“伪随机”的本质区别。看了一些留言以后,我来给你总结一下,所谓“真随机”与“假随机”,只要你不太清楚下一个产生的值是什么,那么对于你来说,就是随机的,而“真”或者“假”,讨论的是随机方法的本质。如果随机过程可以保证,下一次产生的每个值都有一定的概率,那么这个就是“真随机”,如果不能保证,那就是“伪随机”。
理解程序中的“伪随机”,你需要在你的脑袋中,构建一个由值组成的环形序列图,设置随机种子,就是选择图中的某个点作为起始点,在我们一次次地获得随机值的过程中,其实程序就是依次地输出了这个环形序列中的每个状态的值。
最后呢,给你留了一个设计随机函数过程的思考题,关于这个思考题,我要提前先跟你道歉,因为这个思考题,并不是想让你做出来的。下面来看看我的参考答案吧。
#### 思考题:设计迷你随机函数
```
#include &lt;stdio.h&gt;
int main() {
int n = 5;
for (int i = 1; i &lt;= 100; i++) {
printf(&quot;%2d &quot;, n);
if (i % 10 == 0) printf(&quot;\n&quot;);
n = (n * 3) % 101;
}
return 0;
}
```
当你运行这个程序的时候,就会看到程序的输出,正如原文中我给你的样例输出一样。要是想理解这段程序,你需要一些数论方面的基础知识,其中包括:欧拉函数,欧拉定理、费马小定理、取余循环节等知识。
在这里,我要再次因为设置这个你可能做不出来这个题,而向你道歉。不过,当你看到上面的那些知识以后,你会发现,这是一道初学者很大概率不可能完成的题目,尽管代码很简单,可背后的原理却看似不简单。其实,我就是想跟你说明,程序的灵魂在算法,算法的灵魂在数学。
## 数组:一秒钟,定义 1000 个变量
这一篇中,我们学习了数组的基本用法,学会了定义一组数据存储区的方法。并且,围绕着数组知识,完成了“计算数字二进制表示中 1 的个数”的递推程序的设计与实现。
相关的课后思考题呢,也是希望你使用数组来完成相关任务,我看到用户 @奔跑的八戒,完成的就很好,他的思路描述与参考答案一致。也非常感谢 @梅利奥猪猪毛丽莎肉酱(根据这位用户的名称,我猜可能是漫画《七大罪》的爱好者)和@Geek_And_Lee00 给出的修改建议以及指正出文章中的笔误,再次感谢二位。如果有好奇的朋友,可以到原文章及留言区看看[《数组:一秒钟,定义 1000 个变量》](https://time.geekbang.org/column/article/188612)。
最后让我们来看看这篇文章的参考答案吧。
#### 思考题:去掉倍数
```
#include &lt;stdio.h&gt;
int check[1005] = {0};
int main() {
int n, m, num;
scanf(&quot;%d%d&quot;, &amp;n, &amp;m);
for (int i = 0; i &lt; n; i++) {
scanf(&quot;%d&quot;, &amp;num);
for (int j = num; j &lt;= m; j += num) {
check[j] = 1;
}
}
for (int i = 1; i &lt;= m; i++) {
if (check[i] == 1) continue;
printf(&quot;%d &quot;, i);
}
return 0;
}
```
这段代码中,使用一个 check 数组作为标记check[i] 等于 0代表 i 这个数字不是 n 个数字中的任何一个数字的倍数。check[i] 等于 1代表 i 这个数字能够被 n 个数字中的某个数字整除。其中第 7 行到第 10 行代码,是需要特别关注的。这段代码中,首先读入 n 个数字中的某一个,存储在 num 变量中,之后循环 m 以内所有 num 的倍数,把每个数字的 check 值标记为 1。最后我们循环把 1 到 m 中没有被标记的数字输出,就是符合题目要求的所有数字。
## 字符串:彻底被你忽略的 printf 的高级用法
这篇[《字符串:彻底被你忽略的 printf 的高级用法》](https://time.geekbang.org/column/article/189458)的文章中,我们认识了 scanf 和 printf 家族中的两员猛将sscanf 函数和 sprintf函数。这两者操作的是字符串可以理解其本质就是以字符串为中介做数据类型之间的转换。并且我们还介绍了字符串的相关知识字符串的相关知识中比较重要的就是那个 \0 字符,这是一个标记字符串结束的字符,虽然看不到,可作用非常重要,并且这个 \0 字符,也是需要占用存储空间的。
这篇文章中的两个思考题,都是帮助你打开脑洞的,主要就是想告诉你,知识点是死的,而理解知识点和应用知识点是活的,也就是我们常说的活学活用。下面就来看看这篇文章中的两个思考题的参考答案吧。
#### 思考题1体验利器
```
#include &lt;stdio.h&gt;
char str1[1000], str2[1000];
int main() {
scanf(&quot;%s%s&quot;, str1, str2);
printf(&quot;str1 = %s\tstr2 = %s\n&quot;, str1, str2);
sprintf(str1, &quot;%s&quot;, str1); // strlen(str1)
sprintf(str1, &quot;%s&quot;, str2); // strcpy(str1, str2)
printf(&quot;str1 = %s\tstr2 = %s\n&quot;, str1, str2);
sprintf(str1, &quot;%s%s&quot;, str1, str2); // strcat(str1, str2)
printf(&quot;str1 = %s\tstr2 = %s\n&quot;, str1, str2);
return 0;
}
```
在这段代码中首先读入两个字符串str1 和 str2。然后使用 sprintf 分别替代 strlen、strcpy 以及 strcat 三个函数的功能。具体如下:
首先,使用 sprintf(str1, “%s”, str1); 代替 strlen(str1) 的功能正如你所知道的sprintf 返回值代表输出了多少个字符,这行代码中也就是 str1 字符串中的字符数量。
其次,使用 sprintf(str1, “%s”, str2); 代替 strcpy(str1, str2) 的功能。使用 sprintf 函数,将 str2中的内容输出到 str1 的存储空间中,其实就相当于把 str2 的内容复制到了 str1 中。
最后使用sprintf(str1, “%s%s”, str1, str2); 代替 strcat(str1, str2) 的功能。这里,我们将 str1和 str2 的值,依次性的输出到 str1 中以后str1 的内容,就是原 str1和 str2 内容连接以后的总内容了。
#### 思考题2优美的遍历技巧
```
#include &lt;stdio.h&gt;
int main() {
char str[1000];
scanf(&quot;%s&quot;, str);
for (int i = 0; str[i]; i++) {
printf(&quot;%c\n&quot;, str[i]);
}
return 0;
}
```
这段代码中,最值得思考的是循环的终止条件。当循环条件成立的时候,循环会一直执行,不成立的时候,循环就会终止。那么 str[i] 你可以看成是字符,也可以看成是一个整型值,因为任何信息在底层都是二进制存储的,那么其余字符均为非零值,也就是代表条件成立。
只有一个字符的值是零值,就是我们之前所说的字符串中的最后一个特殊的,看不见的字符,\0 字符,这个字符所对应的整型值就是 0也就是我们所谓的假值。那么这个循环就会一直循环到字符串的最后一位才会停止。
好了,今天的思考题答疑就结束了,如果你还有什么不清楚的,或者有更好的想法的,欢迎告诉我,我们留言区见!

View File

@@ -0,0 +1,184 @@
<audio id="audio" title="做好闭环(二):函数是压缩的数组,数组是展开的函数" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/09/ef/092082774d421733dd71d3c0c5d927ef.mp3"></audio>
你好,我是胡光。
不知不觉,我们已经学完了语言基础篇的全部内容。其实还有很多东西想给你讲,可限于篇幅,所以咱们整个语言基础篇中的内容,都是那些,我认为你自学容易忽视的,容易学错的知识点。有道是,授之以鱼,不如授之以渔,我也相信只要你跟着课程学习,一定会感觉到自己收获到了“渔具”。如果能引发你的主动思考,进而触类旁通,举一反三,那这场学习过程就更加有意义啦。
我也非常高兴,看到很多同学都在紧跟着专栏更新节奏,坚持学习。每每在专栏上线的第一时间,这些同学就给我留言,提出自己的疑惑。大部分留言,我都在相对应的文章中回复过了,而对于文章中的思考题呢,由于要给你充足的思考时间,所以我选择在今天这样一篇文章中,给你进行一一的解答。
看一看我的参考答案,和你的思考结果之间,有什么不同吧。也欢迎你在留言区中,给出一些你感兴趣的题目的思考结果,我希望我们能在这个过程中,碰撞出更多智慧的火花。
## 函数:自己动手实现低配版 scanf 函数
在这一节里面呢[《函数:自己动手实现低配版 scanf 函数》](https://time.geekbang.org/column/article/192053),我们讲了函数的基本概念,明确了“实参”和“形参”两个概念,并且知道了函数传参的过程,就是“实参”给“形参”赋值的过程。
还有,我们介绍了“传入参数”和“传出参数”两个概念,弄懂这两个概念,对于设计一个函数来说,还是非常重要的。“传入参数”是从外部,传入到函数内部,影响函数内部执行逻辑的参数,“传出参数”呢,就是由函数内部,传出到函数外部的参数,一般是以传送地址为主要形式。
最后呢,我留了两个开放性的思考题,我选择其中一个你可能会不知所措的题目,来讲解一下如何完成这个题目。下面就看看我的答案吧。
#### 思考题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] 的作用是完全一样的,区别可能只是中括号和小括号的区别。你不觉得站在使用者的角度,考虑这个问题很有趣么?本质区别很大的两个东西,一个函数,一个数组,突然发现它俩的区别根本没有那么大。
简单来说,就是在数学里,函数做的事情就是“映射”,传入一个值,传出一个值。在程序中也不例外,函数做的事情,就是从传入值到传出值的映射。而数组做的事情呢,其实是从下标到存储值的映射。你会发现,数组和函数做的事情,本质上都是映射!
最后,我来总结一下,这个总结讲对你日后的程序设计思维有巨大的帮助,这句话就是“**函数是压缩的数组,数组是展开的函数**”,也就是说当你可以用数组进行程序的时候,你也可以使用某个能够完成相同映射功能的函数来进行替代。
二者在程序设计方面的差别,就在于时间和空间的使用效率,数组在时间效率方面占优势,函数在空间效率方面占优势。当你理解了这些事情以后,你就可以更好的理解某些资料里面经常讲的“**时间换空间**”或者“**空间换时间**”的概念了。你现在可以简单的理解成为是数组思维和函数思维之间的互相转换。
## 预处理命令:必须掌握的“黑魔法”,让编译器帮你写代码
关于预处理命令这个知识点,我们用了两节课的篇幅来讲解,[《预处理命令(上):必须掌握的“黑魔法”,让编译器帮你写代码》](https://time.geekbang.org/column/article/192060) 和[《预处理命令(下):必须掌握的“黑魔法”,让编译器帮你写代码》](https://time.geekbang.org/column/article/193126)。其中讲了两种使用比较多的预处理命令,宏定义和条件编译。并且强调了,宏定义就是做简单替换,条件编译做的事情,就是代码剪裁,根据条件是否成立,决定哪段代码最终留在“待编译源码”中。
其中,用户 @一步 问到:有没有什么办法可以看到预处理阶段后的待编译源代码的内容?这个应该是很多小伙伴的共同问题吧,在这里我就来讲一下。
在 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 &gt; 4 ? 3 : 4) = 4
MAX(a++, 5) = 6
a = 7
```
我们先来实现一个最简单的 MAX 宏,如下所示:
```
#define MAX(a, b) a &gt; b ? a : b
```
如上所示MAX 宏的实现利用了三目运算符问号冒号表达式a &gt; b 条件如果成立,表达式的值等于 a 的值,否则等于 b 的值。看似没问题,但如果你要是运行代码,你会发现,程序的输出可能会如下所示:
```
MAX(2, 3) = 3
❌5 + MAX(2, 3) = 2
❌MAX(2, MAX(3, 4)) = 2
❌MAX(2, 3 &gt; 4 ? 3 : 4) = 2
❌MAX(a++, 5) = 7
❌a = 8
```
你会发现,这种实现,只有第一行是对的,其余几行都是错的。我们就来首先分析一下第 3 行到底是什么错误。按照宏展开的替换原则,最外层的 MAX 宏会被替换成2 &gt; MAX(3, 4) ? 2 : MAX(3, 4)。然后我们再将里面的 MAX(3, 4) 宏展开,就变成了:
```
2 &gt; 3 &gt; 4 ? 3 : 4 ? 2 : 3 &gt; 4 ? 3 : 4
```
这段表达式代码,看着有点儿乱,别急,我来帮你分析。首先我们从左向右看,先分离出来第一个问号冒号表达式的结构:
```
(2 &gt; 3 &gt; 4) ? (3) : (4 ? 2 : 3 &gt; 4 ? 3 : 4)
```
我们看到在这个里面,第一部分 2 &gt; 3 &gt; 4 是条件;第二部分 3 是在表达式为真时候的返回值;第三部分,是包含两个问号冒号表达式结构的式子。我们继续对第三部分进行拆解:
```
(4) ? (2) : (3 &gt; 4 ? 3 : 4)
```
继续拆解后,我们发现,第一部分 4 是条件;第二部分的 2 是表达式为真时的返回值;第三部分,就是一个单独的问号冒号表达式。拆解到现在为止,已经不需要再继续拆解了。
要想理解原表达式,我们需要先了解 2 &gt; 3 &gt; 4 这个“惨无人道”的表达式的值。这个表达式,从左向右执行,首先执行 2 &gt; 3 这个条件表达式的判断。之前我们讲过,条件表达式的值,只有 1 和 0那么 2 &gt; 3 这个表达式的值,显然是 0之后其实是在做 0 &gt; 4 的判断,结果也是 0。
所以 2 &gt; 3 &gt; 4 这个表达式的值,就是 0也就是假值代表条件不成立之后的事情就是转到了两个问号冒号表达式的部分剩下的事情你自己就可以理解最终原表达式的值为什么是 2 了。
理解了原表达式值计算的原理以后,下面让我们来分析一下,为什么会出现这种问题。本质原因,就在于我们实现的宏中,参数 ab 原本都是独立的表达式部分,而我们却简单的把它们放到问号冒号表达式中,导致展开以后的内容前后连接到一起后,改变了原本我们想要的计算顺序。
所以针对这种情况,我们在实现宏的时候,可以给每个参数部分,都加上一个括号,就变成了如下所示的实现方式:
```
#define MAX(a, b) (a) &gt; (b) ? (a) : (b)
```
至此,你就会得到如下的输出:
```
MAX(2, 3) = 3
❌5 + MAX(2, 3) = 2
MAX(2, MAX(3, 4)) = 4
MAX(2, 3 &gt; 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 &gt; 4 ? 3 : 4) = 4
❌MAX(a++, 5) = 7
❌a = 8
```
其中还是有两行是错误的,你如果试着展开第 5 行的宏,你会得到如下的代码:
```
a++ &gt; 5 ? a++ : 5
```
在这行代码中,如果 a++表达式的值真的大于 5 的话,那么 a++ 就会被执行两次。而原本使用者的意图,是执行一次 a++,如何让 a++ 只执行一次呢这需要用到之前我跟你提过的__typeof 相关的技巧了,下面是我给你准备的参考代码:
```
#define MAX(a, b) ({ \
__typeof(a) __a = (a), __b = (b); \
__a &gt; __b ? __a : __b; \
})
```
在这段代码中我们定义了两个中间变量__a 和 __b 用来存储宏参数中 a 和 b 部分的原本的值,之后判断大小的部分呢,我们使用新的变量 __a 和 __b 即可。
这段代码中,我们看到了,无论是 a 部分,还是 b 部分的表达式,只被使用了一次,也就保证了只被计算了一次。而这个里面,用小括号括起来了大括号,实际作用是把一个代码段,变成一个表达式。根据任何表达式都有值的特性,这个代码段表达式的值,等于其中最后一行代码表达式的值,也就是等于最后那行问号冒号表达式的值。
第6行的错误其实和第5行的一样解决了第5行的错误这一行的Bug也就解了。
至此,我们就几乎完美地解决了 MAX 宏的问题了。通过这个问题,你会看到,预处理命令虽然强大,可你需要拥有掌握这种强大的力量。这份力量,包括了你的基础知识储量,还包括了你严谨的思维逻辑。
想要掌握强大,必先变得强大,记住,一步一个脚印,才是最快、最靠谱的成长路线,学习过程中,没有捷径。
好了今天的思考题答疑就结束了,如果你还有什么不清楚的,或者有更好的想法的,欢迎告诉我,我们留言区见!