Linux C编程一站式学习

宋劲杉 北京亚嵌教育研究中心 songjinshan AT akaedu DOT org

版权 © 2008, 2009 宋劲杉, 北京亚嵌教育研究中心

Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with the Invariant Sections being 前言, with no Front-Cover Texts, and no Back-Cover Texts. A copy of the license is included in 附录 B, GNU Free Documentation License Version 1.3, 3 November 2008.

2009.6.23

修订历史
修订 0.6 2009.2.27

添加了GFDL许可证,正式网络发布。第三部分还很粗糙,错误也有不少,有待改进。

第一部分和第二部分已经比较成熟,第二部分还差三章没写。

修订 0.7 2009.4.24
全书的章节基本完成,但有些章节还很不完善。

历史

本书改编和包含了以下两本书的部分章节,这两本书均以 GNU Free Documentation License 发布。

How To Think Like A Computer Scientist: Learning with C++

作者Allen B. Downey。原书由Green Tea Press发行,可以从 http://www.greenteapress.com/ 下载到。

Programming from the Ground Up: An Introduction to Programming using Linux Assembly Language

作者Jonathan Bartlett。原书由Bartlett Publishing发行,可以从 http://savannah.nongnu.org/projects/pgubook/ 下载到。

前言

这本书有什么特点?面向什么样的读者?

这本书最初是为北京亚嵌教育研究中心的嵌入式Linux系统工程师就业班课程量身定做的教材之一。该课程是为期四个月的全日制职业培训,要求学员毕业时具备非常Solid的C编程能力,能熟练地使用Linux系统,同时对计算机体系结构与指令集、操作系统原理和设备驱动程序都有较深入的了解。然而学员入学时的水平是非常初级而且参差不齐的:学历有专科、本科也有研究生,专业有和计算机相关的也有很不相关的(例如会计专业),以前从事的职业有和技术相关的也有完全不相关的(例如HR),年龄从二十出头到三十五六岁的都有。这么多背景完全不同、基础完全不同、思维习惯和理解能力完全不同的人来听同一堂课,大家都迫切希望学会嵌入式开发技术,投身IT行业,这就是职业教育的特点,也是我编这本书时需要考虑的主要问题。

学习编程绝不是一件简单的事,尤其是对于零基础的初学者来说。大学的计算机专业有四年时间从零基础开始培养一个人,微积分、线代、随机、离散、组合、自动机、编译原理、操作系统、计算机组成原理等等一堆基础课,再加上C/C++、Java、数据库、网络、软件工程、计算机图形学等等一堆专业课,最后培养出一个能找到工作的学生。很遗憾这最后一条很多学校没有做好,来亚嵌培训的很多学生就是四年这么学过来的,但据我们考查他们的基础几乎为零,我不知道为什么。与之形成鲜明对比的是,只给我们四个月的时间,同样要求从零基础开始,最后培养出一个能找到工作的学生,而且还要保证他找到工作,这就是职业教育的特点。

为什么我说“只给我们四个月的时间”?我们倒是想教四年呢,但学时的长短我们做不了主,是由市场规律决定的。四年的任务要求四个月做好,要怎么完成这样一个几乎不可能的任务?有些职业教育给出的答案是“实用主义”,打出了“有用就学,没有用就不学”的口号,大肆贬低说大学里教的基础课都是过时的、无用的,只有他们教的技术才是实用的,这种炒作很不好,我认为大学里教的每一门课都是非常有用的,基础知识在任何时候都不会过时,倒是那些时髦的“ 实用技术”有可能很快就过时了。

四年的任务怎么才能用四个月做好?我们给出的答案是“优化”。现在大学里安排的课程体系最大的缺点就是根本不考虑优化。每个过来人都会有这样的感觉:大一大二学了好多数学课,却不知道都是干什么用的,为什么要学。连它有什么用都不知道怎么能有兴趣学好呢?然后到大三大四学专业课时,用到以前的知识了,才发现以前学的数学是多么有用,然而早就忘得一干二净了,考完试都还给老师了,回头重新学吧,这时候才发现很多东西以前根本没学明白,现在才真的学明白了,那么前两年的时间岂不是都浪费了?大学里的课程体系还有一个缺点就是不灵活,每门课必须占一个学期,必须由一个老师教,不同课程的老师之间没有任何沟通和衔接,其实这些课程之间是相互依赖的,把它们强行拆开是不符合人的认知规律的。比如我刚上大学的时候,大一上半学期就被逼着学C语言,其实C语言是一门很难的编程语言,不懂编译原理、操作系统和计算机体系结构根本不可能学明白,那半个学期自然就浪费掉了。当时几乎所有学校的计算机相关专业都是这样,大一上来就学C语言,有的学校更疯狂,上来就学C++,导致大多数学生都以为自己会C语言,但其实都是半吊子水平,到真正写代码的时候经常为一个Bug搞得焦头烂额,却没有机会再系统地学一遍C语言,因为在学校看来,C语言课早在大一就给你“ 上完了”,就像一顿饭已经吃完了,不管你吃饱没吃饱,不会再让你重吃一遍了。显而易见,如果要认真地对这些课程做优化,的确是有很多水份可以挤的。

本书有以下特点:

  • 不是孤立地讲C语言,而是和编译原理、操作系统、计算机体系结构结合起来讲。或者说,本书的内容只是以C语言为载体,真正讲的是计算机的原理和程序的原理。
  • 强调基本概念和基本原理,在编排顺序上非常重视概念之间的依赖关系,每次引入一个新的概念,只依赖于前面章节已经讲过的概念,而绝不会依赖后面章节要讲的概念。有些地方为了叙述得完整,也会引用后面要讲的内容,比如说“有关XX我们到XX章再仔细讲解”,凡是这种引用都不是必要的依赖,可以当它不存在,只管继续往下看就行了。
  • 尽量做到每个知识点直到要用的时候才引入。过早引入一个知识点,讲完了又不用它,读者很快就会遗忘,这是不符合认知规律的。

这是一本从零基础开始学习编程的书,不要求读者有任何编程经验,但读者至少需要具备以下素质:

  • 熟悉Linux系统的基本操作。如果不具备这一点,请先参考其它教材学习Linux系统的基本操作,熟练之后再学习本书,《鸟哥的Linux私房菜》据说是Linux系统管理和应用方面比较好的一本书。但学习本书并不需要会很多系统管理技术,只要会用基本命令,会自己安装系统和软件包就足够了。
  • 具有高中毕业的数学水平。本书会用到高中的数学知识,事实上,如果不具有高中毕业的数学水平,也不必考虑做程序员了。但并不是说只要具有高中毕业的数学水平就足够做程序员了,只能说看这本书应该没有问题,数学是程序员最重要的修养,计算机科学其实就是数学的一个分支,如果你的数学功底很差,日后还需恶补一下。
  • 具有高中毕业的英文水平。理由同上。
  • 对计算机的原理和本质深感兴趣,不是为就业而学习,不是为拿高薪而学习,而是真的感兴趣,想把一切来龙去脉搞得清清楚楚而学习。
  • 勤于思考。本书尽最大努力理清概念之间的依赖关系,力求一站式学习,读者不需要为了找一个概念的定义去翻其它书,也不需要为了搞清楚一个概念在本书中前后一通乱翻,只需从前到后按顺序学习即可。但一站式学习并不等于傻瓜式学习,有些章节有一定的难度,需要积极思考才能领会。本书可以替你节省时间,但不能替你思考,不要指望像看小说一样走马观花看一遍就能学会。

又是一本C语言书。好吧,为什么我要学这本书而不是谭浩强或者K&R?

谭浩强的书我就不说什么了。居然教学生include一个.c文件。

K&R是公认的世界上最经典的C语言教程,这点毫无疑问。在C标准出台之前,K&R第一版就是事实上的C标准。C89标准出台之后,K&R跟着标准推出了第二版,可惜此后就没有更新过了,所以不能反映C89之后C语言的发展以及最新的C99标准,本书在这方面做了很多补充。上面我说过了,这本书与其说是讲C语言,不如说是以C语言为载体讲计算机和操作系统的原理,而K&R就是为了讲C语言而讲C语言,侧重点不同,内容编排也很不相同。K&R写得非常好,代码和语言都非常简洁,但很可惜,只有会C语言的人才懂得欣赏它,K&R是非常不适合入门学习的,尤其不适合零基础的学生入门学习。 这本书“是什么”和“不是什么”

本书包括三大部分:

  • C语言入门。介绍基本的C语法,帮助没有任何编程经验的读者理解什么是程序,怎么写程序,培养程序员的思维习惯,找到编程的感觉。前半部分改编自 [ThinkCpp]
  • C语言本质。结合计算机和操作系统的原理讲解C程序是怎么编译、链接、运行的,同时全面介绍C的语法。位运算的章节改编自亚嵌教育林小竹老师的讲义,链表和二叉树的章节改编自亚嵌教育朱老师的讲义。汇编语言的章节改编自 [GroudUp],在该书的最后一章提到,学习编程有两种Approach,一种是Bottom Up,一种是Top Down,各有优缺点,需要两者结合起来。所以我编这本书的思路是,第一部分Top Down,第二部分Bottom Up,第三部分可以算填了中间的空隙,三部分全都围绕C语言展开。
  • Linux系统编程。介绍各种Linux系统函数和内核的工作原理。Socket编程的章节改编自亚嵌教育卫剑钒老师的讲义。

这本书定位在入门级,虽然内容很多,但不是一本百科全书,除了C语言基本要讲透之外其它内容都不深入,书中列出了很多参考资料,是读者进一步学习的起点。K&R的第一章是一个Whirlwind Tour,把全书的内容简单过了一遍,然后再逐个深入进去讲解。本书也可以看作是计算机专业课程体系的一个Whirlwind Tour,学习完本书之后有了一个全局观,再去学习那些参考资料就应该很容易上手了。

为什么要在Linux平台上学C语言?用Windows学C语言不好吗?

用Windows还真的是学不好C语言。C语言是一种面向底层的编程语言,要写好C程序,必须对操作系统的工作原理非常清楚,因为操作系统也是用C写的,我们用C写应用程序直接使用操作系统提供的接口。既然你选择了看这本书,你一定了解:Linux是一种开源的操作系统,你有任何疑问都可以从源代码和文档中找到答案,即使你看不懂源代码,也找不到文档,也很容易找个高手教你,各种邮件列表、新闻组和论坛上从来都不缺乐于助人的高手;而Windows是一种封闭的操作系统,除了微软的员工别人都看不到它的源代码,只能通过文档去猜测它的工作原理,更糟糕的是,微软向来喜欢藏着揶着,好用的功能留着自己用,而不会写到文档里公开。本书的第一部分在Linux或Windows平台上学习都可以,但第二部分和第三部分介绍了很多Linux操作系统的原理以帮助读者更深入地理解C语言,只能在Linux平台上学习。

Windows平台上的开发工具往往和各种集成开发环境(IDE,Integrated Development Environment) 绑在一起,例如Visual Studio、Eclipse等。使用IDE确实很便捷,但IDE对于初学者绝对不是好东西。微软喜欢宣扬傻瓜式编程的理念,告诉你用鼠标拖几个控件,然后点一个按钮就可以编译出程序来,但是真正有用的程序有哪个是这么拖出来的?很多从Windows平台入门学编程的人,编了好几年程序,还是只知道编完程序点一个按钮就可以跑了,把几个源文件拖到一个项目里就可以编译到一起了,如果有更复杂的需求他们就傻眼了,因为他们脑子里只有按钮、菜单的概念,根本没有编译器、链接器、Makefile的概念,甚至连命令行都没用过,然而这些都是初学编程就应该建立起来的基本概念。另一方面,编译器、链接器和C语言的语法有密切的关系,不了解编译器、链接器的工作原理,也不可能真正掌握C的语法。所以,IDE并没有帮助你学习,而是阻碍了你学习,本来要学好C编程只要把语法和编译命令学会就行了,现在有了IDE,除了学会语法和编译命令,你还得弄清楚编译命令和IDE是怎么集成的,这才算学明白了,本来就很复杂的学习任务被IDE搞得更加复杂了。Linux用户的使用习惯从来都是以敲命令为主,以鼠标操作为辅,从学编程的第一天起就要敲命令编译程序,等到你把这些基本概念都搞清楚了,你觉得哪个IDE好用你再去用,不过到那时候你可能会更喜欢 vi或emacs而不是IDE了。

致谢

本书的写作得到北京亚嵌教育研究中心的全力支持,尤其感谢李明老师和何家胜老师,没有公司的支持,我不可能有时间有条件写这本书,也不可能有机会将这本书公开在网上。

然后要感谢亚嵌教育的历届学员和各位老师,在教学和讨论过程中我经常会得到有益的启发,这些都促使这本书更加完善。在本书的写作过程中,很多读者为本书提出很有价值的建议,很多建议是热心网友通过在线评论提的,有些网友我只知道id或email。都列在下面,排名不分先后。

感谢北京亚嵌教育研究中心的老师们:李明,何家胜,邸海霞,郎铁山,朱仲涛,廖文江,韩超,吴岳,邢文鹏,何晓龙,林小竹,卫剑钒。

感谢热心网友:

ddd ddd@clf.net
wuyulei wuyulei0210@163.com
comma commapopo@hotmail.com
田伟 sioungiep@zzxy.org
田雨 tianyu_1123@hotmail.com
daidai daidai0628@sina.com
邓楠 monnand@gmail.com
杜朴风 cplusplus@zzxy.org
Zoom.Quiet zoom.quiet@gmail.com
陈老师 cljcore@gmail.com
杨景 yangbajing@gmail.com
章钰 buptzhangyu@163.com
chen cry2133@gmail.com
Jiawei Zhang rhythm.zhang@gmail.com
waterloo waterloo2005@gmail.com
张现超 zxqianrong@gmail.com
曾宇 uyucn@163.com
董俊波 dongjunbo@gmail.com
RobinXiang dancelinux@gmail.com
刘艳明 lonny_liu@hotmail.com
been2100@163.com
cleverd crossie@qq.com
orange juicerococo@hotmail.com
徐斌 simlink_xub@163.com
cyy cyy198767@hotmail.com
Linux_Xfce coodycody23@gmail.com
冯海云 906702745@qq.com
侯延祥 houyx2008@163.com
churchmice firefoxelectric@gmail.com
linux——00xx00xxooxx codycody23@gmail.com
syfeagle syfeagle@hotmail.com
王公仆 wanggongpu@gmail.com
刘敏 liuminchinese@163.com
Laciq dd@qq.com

在写作过程中我遇到过很多困难,工作繁忙,对未来迷茫,生活压力大,缺乏安全感,个人琐事等等。然而有这么多热心的同学、老师、朋友、网友在等着看我的书更新,给我提建议希望我把书改得更完善,这是我坚持写下去的最大的动力。谢谢你们! 最后几句话

和大多数作者一样,最后我要说的是我水平十分有限,没写过C编译器和C标准库,我不能保证书中的内容全部正确,如有错误欢迎批评指正。遗憾的是很多作者把这句话当成了挡箭牌,当成了自己不必竭尽全力保证内容正确性的借口。写书是一件严肃的事,书中的错误所有人都看得见,白纸黑字赖不掉的。我教过的很多学生都在大学里学过C语言,甚至考过二级,但程序写得一塌糊涂,连最基本的概念都搞错了,以前学过的C语言教材中的错误在他们脑子里根深蒂固,即使我纠正多次,他们仍然只记得以前学过的错误概念,这种有基础的学生还不如没有任何基础的学生教起来容易。我非常害怕我教给别人的仍然是错的,所以我仔细研究了C99之后才敢动笔写书。这本书涵盖的话题比较广泛,我竭尽全力也不足以保证书中的内容全部正确,还要依靠社区的力量一起来完善这本书,这样才能真正对读者负责,所以我选择将这本书开源。

希望本书成为你求学道路上的第一个伙伴。

宋劲杉 北京 2008年11月27日

C语言入门

程序的基本概念

程序和编程语言

程序(Program) 告诉计算机应如何完成一个计算任务,这里的计算可以是数学运算,比如解方程,也可以是符号运算,比如查找和替换文档中的某个单词。从根本上说,计算机是由数字电路组成的运算机器,只能对数字做运算,程序之所以能做符号运算,是因为符号在计算机内部也是用数字表示的。此外,程序还可以处理声音和图像,声音和图像在计算机内部必然也是用数字表示的,这些数字经过专门的硬件设备转换成人可以听到、看到的声音和图像。

程序由一系列指令(Instruction) 组成,指令是指示计算机做某种运算的命令,通常包括以下几类:

输入(Input)
从键盘、文件或者其它设备获取数据。
输出(Output)
把数据显示到屏幕,或者存入一个文件,或者发送到其它设备。
基本运算
执行最基本的数学运算(加减乘除)和数据存取。
测试和分支
测试某个条件,然后根据不同的测试结果执行不同的后续指令。
循环
重复执行一系列操作。

对于程序来说,有上面这几类指令就足够了。你曾用过的任何一个程序,不管它有多么复杂,都是由这几类指令组成的。程序是那么的复杂,而编写程序可以用的指令却只有这么简单的几种,这中间巨大的落差就要由程序员去填了,所以编写程序理应是一件相当复杂的工作。编写程序可以说就是这样一个过程:把复杂的任务分解成子任务,把子任务再分解成更简单的任务,层层分解,直到最后简单得可以用以上指令来完成。

编程语言(Programming Language) 分为低级语言(Low-level Language) 和高级语言(High-level Language) 。机器语言(Machine Language) 和汇编语言(Assembly Language) 属于低级语言,直接用计算机指令编写程序。而C、C++、Java、Python等属于高级语言,用语句(Statement) 编写程序,语句是计算机指令的抽象表示。举个例子,同样一个语句用C语言、汇编语言和机器语言分别表示如下:

一个语句的三种表示方式
编程语言 表示形式
C语言
a = b + 1;
汇编语言
mov 0x804a01c, %eax
add $0x1, %eax
mov %eax, 0x804a018
机器语言
a1 1c a0 04 08
83 c0 01
a3 18 a0 04 08

计算机只能对数字做运算,符号、声音、图像在计算机内部都要用数字表示,指令也不例外,上表中的机器语言完全由十六进制数字组成。最早的程序员都是直接用机器语言编程,但是很麻烦,需要查大量的表格来确定每个数字表示什么意思,编写出来的程序很不直观,而且容易出错,于是有了汇编语言,把机器语言中一组一组的数字用助记符(Mnemonic) 表示,直接用这些助记符写出汇编程序,然后让汇编器(Assembler) 去查表把助记符替换成数字,也就把汇编语言翻译成了机器语言。从上面的例子可以看出,汇编语言和机器语言的指令是一一对应的,汇编语言有三条指令,机器语言也有三条指令,汇编器就是做一个简单的替换工作,例如在第一条指令中,把 movl ?,%eax 这种格式的指令替换成机器码 a1 ?? 表示一个地址,在汇编指令中是 0x804a01c ,转换成机器码之后是 1c a0 04 08 (这是指令中的十六进制数的小端表示,小端表示将在 目标文件 介绍)。

从上面的例子还可以看出,C语言的语句和低级语言的指令之间不是简单的一一对应关系,一条a=b+1;语句要翻译成三条汇编或机器指令,这个过程称为编译(Compile) ,由编译器(Compiler) 来完成,显然编译器的功能比汇编器要复杂得多。用C语言编写的程序必须经过编译转成机器指令才能被计算机执行,编译需要花一些时间,这是用高级语言编程的一个缺点,然而更多的是优点。首先,用C语言编程更容易,写出来的代码更紧凑,可读性更强,出了错也更容易改正。其次,C语言是可移植的(Portable) 或者称为平台无关的(Platform Independent) 。

平台这个词有很多种解释,可以指计算机体系结构(Architecture) ,也可以指操作系统(Operating System) ,也可以指开发平台(编译器、链接器等)。不同的计算机体系结构有不同的指令集(Instruction Set) ,可以识别的机器指令格式是不同的,直接用某种体系结构的汇编或机器指令写出来的程序只能在这种体系结构的计算机上运行,然而各种体系结构的计算机都有各自的C编译器,可以把C程序编译成各种不同体系结构的机器指令,这意味着用C语言写的程序只需稍加修改甚至不用修改就可以在各种不同的计算机上编译运行。各种高级语言都具有C语言的这些优点,所以绝大部分程序是用高级语言编写的,只有和硬件关系密切的少数程序(例如驱动程序)才会用到低级语言。还要注意一点,即使在相同的体系结构和操作系统下,用不同的C编译器(或者同一个C编译器的不同版本)编译同一个程序得到的结果也有可能不同,C语言有些语法特性在C标准中并没有明确规定,各编译器有不同的实现,编译出来的指令的行为特性也会不同,应该尽量避免使用不可移植的语法特性。

总结一下编译执行的过程,首先你用文本编辑器写一个C程序,然后保存成一个文件,例如program.c(通常C程序的文件名后缀是.c),这称为源代码(Source Code) 或源文件,然后运行编译器对它进行编译,编译的过程并不执行程序,而是把源代码全部翻译成机器指令,再加上一些描述信息,生成一个新的文件,例如a.out,这称为可执行文件,可执行文件可以被操作系统加载运行,计算机执行该文件中由编译器生成的指令,如下图所示:

编译执行的过程

有些高级语言以解释(Interpret) 的方式执行,解释执行过程和C语言的编译执行过程很不一样。例如编写一个Shell脚本script.sh,内容如下:

#! /bin/sh
VAR=1
VAR=$(($VAR+1))
echo $VAR

定义Shell变量 VAR 的初始值是 1,然后自增 1,然后打印 VAR 的值。用Shell程序/bin/sh 解释执行这个脚本,结果如下:

$ /bin/sh script.sh
2

这里的 /bin/sh 称为解释器(Interpreter) ,它把脚本中的每一行当作一条命令解释执行,而不需要先生成包含机器指令的可执行文件再执行。如果把脚本中的这三行当作三条命令直接敲到Shell提示符下,也能得到同样的结果:

$ VAR=1
$ VAR=$(($VAR+1))
$ echo $VAR
2
解释执行的过程

编程语言仍在发展演化。以上介绍的机器语言称为第一代语言(1GL,1st Generation Programming Language) ,汇编语言称为第二代语言(2GL,2nd Generation Programming Language) ,C、C++、Java、Python等可以称为第三代语言(3GL,3rd Generation Programming Language) 。目前已经有了4GL(4th Generation Programming Language) 和5GL(5th Generation Programming Language) 的概念。3GL的编程语言虽然是用语句编程而不直接用指令编程,但语句也分为输入、输出、基本运算、测试分支和循环等几种,和指令有直接的对应关系。而4GL以后的编程语言更多是描述要做什么(Declarative) 而不描述具体一步一步怎么做(Imperative) ,具体一步一步怎么做完全由编译器或解释器决定,例如SQL语言(SQL,Structured Query Language,结构化查询语言) 就是这样的例子。

习题

1、解释执行的语言相比编译执行的语言有什么优缺点?

注解

Zombie110year

解释执行的语言大多可以直接从源码运行, 只要安装了相应的解释器就行. 例如 JavaScript, Python 等, 都是流行的解释型语言. 解释语言常用源码分发, 它们开发门槛低, 开放性高, 因此能建立起良好的社区.

解释型语言和编译型语言相比, 编译策略不同: 解释型语言其实也需要编译为机器码, 它运行一行, 编译一行, 在循环结构中的一行语句, 可能会重复编译成千上万次, 每一次循环都需要编译一次, 因此效率较低. 但是现在有名为 JIT(Just in Time) 的算法, 用于优化解释型语言的编译, 当某语句被重复执行的数量超过一定阈值后, 解释器就将编译得到的机器码保留下来, 下次执行就不用再编译了. JIT 技术最先是为 Java 开发的, 后来 JavaScript 也采用了这项机制, Python 可以通过 numba 来实现 JIT.

另外, 解释型语言要求使用者安装有解释器, 这就为小白用户带来了不便. 规避此问题的方法是, 将解释器(或虚拟机)和代码一起打包成二进制可执行文件.

这是我们的第一个思考题。本书的思考题通常要求读者系统地总结当前小节的知识,结合以前的知识,并经过一定的推理,然后作答。本书强调的是基本概念,读者应该抓住概念的定义和概念之间的关系来总结,比如本节介绍了很多概念:程序由语句或指令组成,计算机只能执行 低级语言 中的指令(汇编语言的指令要先转成机器码才能执行),高级语言要执行就必须先翻译成低级语言,翻译的方法有两种--编译和解释,虽然有这样的不便,但高级语言有一个好处是 平台无关性 。什么是平台?一种平台,就是一种体系结构,就是一种指令集,就是一种机器语言,这些都可看作是一一对应的,上文并没有用“一一对应”这个词,但读者应该能推理出这个结论,而高级语言和它们不是一一对应的,因此高级语言是 平台无关 的,概念之间像这样的数量对应关系尤其重要。那么编译和解释的过程有哪些不同?主要的不同在于什么时候翻译和什么时候执行。

现在回答这个思考题,根据编译和解释的不同原理,你能否在执行效率和平台无关性等方面做一下比较?

希望读者掌握以概念为中心的阅读思考习惯,每读一节就总结一套概念之间的关系图画在书上空白处。如果读到后面某一节看到一个讲过的概念,但是记不清在哪一节讲过了,没关系,书后的索引可以帮你找到它是在哪一节定义的。

自然语言和形式语言

自然语言(Natural Language) 就是人类讲的语言,比如汉语、英语和法语。这类语言不是人为设计(虽然有人试图强加一些规则)而是自然进化的。形式语言(Formal Language) 是为了特定应用而人为设计的语言。例如数学家用的数字和运算符号、化学家用的分子式等。编程语言也是一种形式语言,是专门设计用来表达计算过程的形式语言。

形式语言有严格的语法(Syntax) 规则,例如,3+3=6 是一个语法正确的数学等式,而 3=+6$ 则不是,\(H_2 O\) 是一个正确的分子式,而 \(2Zz\) 则不是。语法规则是由符号(Token) 和结构(Structure) 的规则所组成的。Token的概念相当于自然语言中的单词和标点、数学式中的数和运算符、化学分子式中的元素名和数字,例如 3=+6$ 的问题之一在于 $ 不是一个合法的数也不是一个事先定义好的运算符,而 \(2Zz\) 的问题之一在于没有一种元素的缩写是 Zz。结构是指Token的排列方式,3=+6$ 还有一个结构上的错误,虽然加号和等号都是合法的运算符,但是不能在等号之后紧跟加号,而 \(2Zz\) 的另一个问题在于分子式中必须把下标写在化学元素名称之后而不是前面。关于Token的规则称为词法(Lexical) 规则,而关于结构的规则称为语法(Grammar) 规则 [1]

当阅读一个自然语言的句子或者一种形式语言的语句时,你不仅要搞清楚每个词(Token)是什么意思,而且必须搞清楚整个句子的结构是什么样的(在自然语言中你只是没有意识到,但确实这样做了,尤其是在读外语时你肯定也意识到了)。这个分析句子结构的过程称为解析(Parse) 。例如,当你听到“The other shoe fell.”这个句子时,你理解the other shoe是主语而fell是谓语动词,一旦解析完成,你就搞懂了句子的意思,如果知道shoe是什么东西,fall意味着什么,这句话是在什么上下文(Context) 中说的,你还能理解这个句子主要暗示的内容,这些都属于语义(Semantic) 的范畴。

虽然形式语言和自然语言有很多共同之处,包括Token、结构和语义,但是也有很多不一样的地方。

歧义性(Ambiguity)
自然语言充满歧义,人们通过上下文的线索和自己的常识来解决这个问题。形式语言的设计要求是清晰的、毫无歧义的,这意味着每个语句都必须有确切的含义而不管上下文如何。
冗余性(Redundancy)
为了消除歧义减少误解,自然语言引入了相当多的冗余。结果是自然语言经常说得啰里啰嗦,而形式语言则更加紧凑,极少有冗余。
与字面意思的一致性
自然语言充斥着成语和隐喻(Metaphor) ,我在某种场合下说“The other shoe fell”,可能并不是说谁的鞋掉了。而形式语言中字面(Literal) 意思基本上就是真实意思,也会有一些例外,例如下一章要讲的C语言转义序列,但即使有例外也会明确规定哪些字面意思不是真实意思,它们所表示的真实意思又是什么。

说自然语言长大的人(实际上没有人例外),往往有一个适应形式语言的困难过程。某种意义上,形式语言和自然语言之间的不同正像诗歌和说明文的区别,当然,前者之间的区别比后者更明显:

诗歌
词语的发音和意思一样重要,全诗作为一个整体创造出一种效果或者表达一种感情。歧义和非字面意思不仅是常见的而且是刻意使用的。
说明文
词语的字面意思显得更重要,并且结构能传达更多的信息。诗歌只能看一个整体,而说明文更适合逐字句分析,但仍然充满歧义。
程序
计算机程序是毫无歧义的,字面和本意高度一致,能够完全通过对Token和结构的分析加以理解。

现在给出一些关于阅读程序(包括其它形式语言)的建议。首先请记住形式语言远比自然语言紧凑,所以要多花点时间来读。其次,结构很重要,从上到下从左到右读往往不是一个好办法,而应该学会在大脑里解析:识别Token,分解结构。最后,请记住细节的影响,诸如拼写错误和标点错误这些在自然语言中可以忽略的小毛病会把形式语言搞得面目全非。

[1]很不幸,Syntax和Grammar通常都翻译成“语法”,这让初学者非常混乱,Syntax的含义其实包含了Lexical和Grammar的规则,还包含一部分语义的规则,例如在C程序中变量应先声明后使用。即使在英文的文献中Syntax和Grammar也常混用,在有些文献中Syntax的含义不包括Lexical规则,只要注意上下文就不会误解。另外,本书在翻译容易引起混淆的时候通常直接用英文名称,例如Token没有十分好的翻译,直接用英文名称。

程序的调试

编程是一件复杂的工作,因为是人做的事情,所以难免经常出错。据说有这样一个典故:早期的计算机体积都很大,有一次一台计算机不能正常工作,工程师们找了半天原因最后发现是一只臭虫钻进计算机中造成的。从此以后,程序中的错误被叫做臭虫(Bug) ,而找到这些Bug并加以纠正的过程就叫做调试(Debug) 。有时候调试是一件非常复杂的工作,要求程序员概念明确、逻辑清晰、性格沉稳,还需要一点运气。调试的技能我们在后续的学习中慢慢培养,但首先我们要区分清楚程序中的Bug分为哪几类。

编译时错误
编译器只能翻译语法正确的程序,否则将导致编译失败,无法生成可执行文件。对于自然语言来说,一点语法错误不是很严重的问题,因为我们仍然可以读懂句子。而编译器就没那么宽容了,只要有哪怕一个很小的语法错误,编译器就会输出一条错误提示信息然后罢工,你就得不到你想要的结果。虽然大部分情况下编译器给出的错误提示信息就是你出错的代码行,但也有个别时候编译器给出的错误提示信息帮助不大,甚至会误导你。在开始学习编程的前几个星期,你可能会花大量的时间来纠正语法错误。等到有了一些经验之后,还是会犯这样的错误,不过会少得多,而且你能更快地发现错误原因。等到经验更丰富之后你就会觉得,语法错误是最简单最低级的错误,编译器的错误提示也就那么几种,即使错误提示是有误导的也能够立刻找出真正的错误原因是什么。相比下面两种错误,语法错误解决起来要容易得多。
运行时错误
编译器检查不出这类错误,仍然可以生成可执行文件,但在运行时会出错而导致程序崩溃。对于我们接下来的几章将编写的简单程序来说,运行时错误很少见,到了后面的章节你会遇到越来越多的运行时错误。读者在以后的学习中要时刻注意区分编译时和运行时(Run-time)这两个概念,不仅在调试时需要区分这两个概念,在学习C语言的很多语法时都需要区分这两个概念,有些事情在编译时做,有些事情则在运行时做。
逻辑错误和语义错误
第三类错误是逻辑错误和语义错误。如果程序里有逻辑错误,编译和运行都会很顺利,看上去也不产生任何错误信息,但是程序没有干它该干的事情,而是干了别的事情。当然不管怎么样,计算机只会按你写的程序去做,问题在于你写的程序不是你真正想要的,这意味着程序的意思(即语义)是错的。找到逻辑错误在哪需要十分清醒的头脑,要通过观察程序的输出回过头来判断它到底在做什么。

注解

Zombie110year

感觉编译时错误表示孩子夭折了, 运行时错误表示小孩不会做事, 而逻辑错误和语义错误则是做错了事.

通过本书你将掌握的最重要的技巧之一就是调试。调试的过程可能会让你感到一些沮丧,但调试也是编程中最需要动脑的、最有挑战和乐趣的部分。从某种角度看调试就像侦探工作,根据掌握的线索来推断是什么原因和过程导致了你所看到的结果。调试也像是一门实验科学,每次想到哪里可能有错,就修改程序然后再试一次。如果假设是对的,就能得到预期的正确结果,就可以接着调试下一个Bug,一步一步逼近正确的程序;如果假设错误,只好另外再找思路再做假设。“当你把不可能的全部剔除,剩下的——即使看起来再怎么不可能——就一定是事实。”(即使你没看过福尔摩斯也该看过柯南吧)。

也有一种观点认为,编程和调试是一回事,编程的过程就是逐步调试直到获得期望的结果为止。你应该总是从一个能正确运行的小规模程序开始,每做一步小的改动就立刻进行调试,这样的好处是总有一个正确的程序做参考:如果正确就继续编程,如果不正确,那么一定是刚才的小改动出了问题。例如,Linux操作系统包含了成千上万行代码,但它也不是一开始就规划好了内存管理、设备管理、文件系统、网络等等大的模块,一开始它仅仅是Linus Torvalds用来琢磨Intel 80386芯片而写的小程序。据Larry Greenfield 说,“Linus的早期工程之一是编写一个交替打印AAAA和BBBB的程序,这玩意儿后来进化成了Linux。”(引自The Linux User’s Guide Beta1版)在后面的章节中会给出更多关于调试和编程实践的建议。

注解

Zombie110year

现在的持续集成就是使用的这种思想: 每一个改动都必须不破坏以往的功能.

第一个程序

通常一本教编程的书中第一个例子都是打印“Hello, World.”,这个传统源自 [K&R],用C语言写这个程序可以这样写:

Hello World
#include <stdio.h>

/* main: generate some simple output */

int main(void)
{
    printf("Hello, world.\n");
    return 0;
}

将这个程序保存成main.c,然后编译执行:

$ gcc main.c
$ ./a.out
Hello, world.

gcc是Linux平台的C编译器,编译后在当前目录下生成可执行文件a.out,直接在命令行输入这个可执行文件的路径就可以执行它。如果不想把文件名叫a.out,可以用gcc的-o参数自己指定文件名:

$ gcc main.c -o main $ ./main Hello, world.

虽然这只是一个很小的程序,但我们目前暂时还不具备相关的知识来完全理解这个程序,比如程序的第一行,还有程序主体的 int main(void){...return 0;} 结构,这些部分我们暂时不详细解释,读者现在只需要把它们看成是每个程序按惯例必须要写的部分(Boilerplate) 。但要注意 main 是一个特殊的名字,C程序总是从 main 里面的第一条语句开始执行的,在这个程序中是指 printf 这条语句。

注解

Zombie110year:

main 函数就是 C 程序的 “程序入口”, 或称 “入口函数”.

第3行的 /* ... */ 结构是一个注释(Comment) ,其中可以写一些描述性的话,解释这段程序在做什么。注释只是写给程序员看的,编译器会忽略从 /**/ 的所有字符,所以写注释没有语法规则,爱怎么写就怎么写,并且不管写多少都不会被编译进可执行文件中。

printf 语句的作用是把消息打印到屏幕。注意语句的末尾以 ; 分号(Semicolon) 结束,下一条语句 return 0; 也是如此。

C语言用 {} 花括号(Brace或Curly Brace) 把语法结构分成组,在上面的程序中``printf`` 和 return 语句套在 main{} 括号中,表示它们属于 main 的定义之中。我们看到这两句相比 main 那一行都缩进(Indent) 了一些,在代码中可以用若干个空格(Blank) 和Tab字符来缩进,缩进不是必须的,但这样使我们更容易看出这两行是属于 main 的定义之中的,要写出漂亮的程序必须有整齐的缩进,第 1 节 “缩进和空白”将介绍推荐的缩进写法。

正如前面所说,编译器对于语法错误是毫不留情的,如果你的程序有一点拼写错误,例如第一行写成了 stdoi.h ,在编译时会得到错误提示:

$ gcc .\hello.c
.\hello.c:1:10: fatal error: stdoi.h: No such file or directory
#include <stdoi.h>
        ^~~~~~~~~
compilation terminated.

这个错误提示非常紧凑,初学者往往不容易看明白出了什么错误,即使知道这个错误提示说的是第1行有错误,很多初学者对照着书看好几遍也看不出自己这一行哪里有错误,因为他们对符号和拼写不敏感(尤其是英文较差的初学者),他们还不知道这些符号是什么意思又如何能记住正确的拼写?对于初学者来说,最想看到的错误提示其实是这样的:“在 main.c 程序第1行的第19列,您试图包含一个叫做 stdoi.h 的文件,可惜我没有找到这个文件,但我却找到了一个叫做 stdio.h 的文件,我猜这个才是您想要的,对吗?”可惜没有任何编译器会友善到这个程度,大多数时候你所得到的错误提示并不能直接指出谁是犯人,而只是一个线索,你需要根据这个线索做一些侦探和推理。

有些时候编译器的提示信息不是 error 而是 warning ,例如把上例中的 printf("Hello, world.\n"); 改成 printf(1); 然后编译运行:

$ gcc main.c
main.c: In function ‘main’:
main.c:7: warning: passing argument 1 of ‘printf’ makes pointer from integer without a cast
$ ./a.out
Segmentation fault

这个警告信息是说类型不匹配,但勉强还能配得上。警告信息不是致命错误,编译仍然可以继续,如果整个编译过程只有警告信息而没有错误信息,仍然可以生成可执行文件。但是,警告信息也是不容忽视的。出警告信息说明你的程序写得不够规范,可能有Bug,虽然能编译生成可执行文件,但程序的运行结果往往是不正确的,例如上面的程序运行时出了一个段错误,这属于运行时错误。各种警告信息的严重程度不同,像上面这种警告几乎一定表明程序中有Bug,而另外一些警告只表明程序写得不够规范,一般还是能正确运行的,有些不重要的警告信息 gcc 默认是不提示的,但这些警告信息也有可能表明程序中有Bug。一个好的习惯是打开 gcc 的 -Wall 选项,也就是让 gcc 提示所有的警告信息,不管是严重的还是不严重的,然后把这些问题从代码中全部消灭。比如把上例中的 printf("Hello, world.\n"); 改成 printf(0); 然后编译运行:

$ gcc main.c
$ ./a.out

编译既不报错也不报警告,一切正常,但是运行程序什么也不打印。如果打开 -Wall 选项编译就会报警告了:

$ gcc -Wall main.c
main.c: In function ‘main’:
main.c:7: warning: null argument where non-null required (argument 1)

如果 printf 中的 0 是你不小心写上去的(例如错误地使用了编辑器的查找替换功能),这个警告就能帮助你发现错误。虽然本书的命令行为了突出重点通常省略 -Wall 选项,但是强烈建议你写每一个编译命令时都加上 -Wall 选项。

习题

1、尽管编译器的错误提示不够友好,但仍然是学习过程中一个很有用的工具。你可以像上面那样,从一个正确的程序开始每次改动一小点,然后编译看是什么结果,如果出错了,就尽量记住编译器给出的错误提示并把改动还原。因为错误是你改出来的,你已经知道错误原因是什么了,所以能很容易地把错误原因和错误提示信息对应起来记住,这样下次你在毫无防备的情况下撞到这个错误提示时就会很容易想到错误原因是什么了。这样反复练习,有了一定的经验积累之后面对编译器的错误提示就会从容得多了。

注解

Zombie110year

编译器输出的重定向:

gcc *.c 1> stdout.txt   // 一般输出
gcc *.c 2> stderr.txt   // 错误输出

0, 1, 2 分别代表了 stdin, stdout, stderr 三个文件流. 如果不用 1,2 特殊表示的话, stdout, stderr 是合并到一起输出的.

常量、变量和表达式

继续Hello World

第一个程序 中,读者应该已经尝试对 Hello world 程序做各种改动看编译运行结果,其中有些改动会导致编译出错,有些改动会影响程序的输出,有些改动则没有任何影响,下面我们总结一下。首先,注释可以跨行,也可以穿插在程序之中,看下面的例子。

带更多注释的 Hello World
#include <stdio.h>

/*
* comment1
* main: generate some simple output
*/

int main(void)
{
    printf(/* comment2 */"Hello, world.\n"); /* comment3 */
    return 0;
}

第一个注释跨了四行,头尾两行是注释的界定符(Delimiter) /**/,中间两行开头的 * 号(Asterisk) 并没有特殊含义,只是为了看起来整齐,这不是语法规则而是大家都遵守的C代码风格(Coding Style) 之一,代码风格将在 编码风格 详细介绍。

使用注释需要注意两点:

注释不能嵌套(Nest) 使用,就是说一个注释的文字中不能再出现 /**/ 了,例如 /* text1 /* text2 */ text3 */ 是错误的,编译器只把 /* text1 /* text2 */ 看成注释,后面的 text3 */ 无法解析,因而会报错。

有的C代码中有类似 // comment 的注释,两个 / 斜线(Slash) 表示从这里直到该行末尾的所有字符都属于注释,这种注释不能跨行,也不能穿插在一行代码中间。这是从C++借鉴的语法,在C99中被标准化。

重要

C语言标准

C语言的发展历史大致上分为三个阶段:Old Style C 、C89 和C99 。Ken Thompson和Dennis Ritchie最初发明C语言时有很多语法和现在最常用的写法并不一样,但为了向后兼容性(Backward Compatibility) ,这些语法仍然在C89和C99中保留下来了,本书不详细讲Old Style C,但在必要的地方会加以说明。C89是最早的C语言规范,于1989年提出,1990年首先由ANSI(美国国家标准委员会,American National Standards Institute) 推出,后来被接纳为ISO国际标准(ISO/IEC 9899:1990) ,因而有时也称为C90 ,最经典的C语言教材 [K&R] 就是基于这个版本的,C89是目前最广泛采用的C语言标准,大多数编译器都完全支持C89。C99标准(ISO/IEC 9899:1999) 是在1999年推出的,加入了许多新特性,但目前仍没有得到广泛支持,在C99推出之后相当长的一段时间里,连gcc也没有完全实现C99的所有特性。C99标准详见 [C99]。本书讲C的语法以C99为准,但示例代码通常只使用C89语法,很少使用C99的新特性。

C标准的目的是为了精确定义C语言,而不是为了教别人怎么编程,C标准在表达上追求准确和无歧义,却十分不容易看懂,[Standard C][Standard C Library] 是对C89及其修订版本的阐释(可惜作者没有随C99更新这两本书),比C标准更容易看懂,另外,参考 [C99 Rationale] 也有助于加深对C标准的理解。

注解

Zombie110year

现在广泛应用的 C 语言的最新标准为 C11.

"Hello, world.\n" 这种由双引号(Double Quote) 引起来的一串字符称为 字符串字面值 (String Literal) ,或者简称字符串。注意,程序的运行结果并没有双引号,printf 打印出来的只是里面的一串字符 Hello, world.,因此双引号是字符串字面值的界定符,夹在双引号中间的一串字符才是它的内容。注意,打印出来的结果也没有 \n 这两个字符,这是为什么呢?在 自然语言和形式语言 中提到过,C语言规定了一些转义序列(Escape Sequence) ,这里的 \n 并不表示它的字面意思,也就是说并不表示 \n 这两个字符本身,而是合起来表示一个换行符(Line Feed) 。例如我们写三条打印语句:

printf("Hello, world.\n");
printf("Goodbye, ");
printf("cruel world!\n");

运行的结果是第一条语句单独打到第一行,后两条语句都打到第二行。为了节省篇幅突出重点,以后的例子通常省略 #includeint main(void) { ... } 这些Boilerplate,但读者在练习时需要加上这些构成一个完整的程序才能编译通过。C标准规定的转义字符有以下几种:

C标准规定的转义字符
符号 含义  
\' 单引号’(Single Quote或Apostrophe)
\" 双引号”
\? 问号?(Question Mark)
\\ 反斜线(Backslash)
\a 响铃(Alert或Bell)
\b 退格(Backspace)
\f 分页符(Form Feed)
\n 换行(Line Feed)
\r 回车(Carriage Return)
\t 水平制表符(Horizontal Tab)
\v 垂直制表符(Vertical Tab)

如果在字符串字面值中要表示单引号和问号,既可以使用转义序列 \'\? ,也可以直接用字符 '? ,而要表示 \" 则必须使用转义序列,因为 \ 字符表示转义而不表示它的字面含义," 表示字符串的界定符而不表示它的字面含义。可见转义序列有两个作用:一是把普通字符转义成特殊字符,例如把字母 n 转义成换行符;二是把特殊字符转义成普通字符,例如 \" 是特殊字符,转义后取它的字面值。

C语言规定了几个控制字符,不能用键盘直接输入,因此采用 \ 加字母的转义序列表示。 \a 是响铃字符,在字符终端下显示这个字符的效果是PC喇叭发出嘀的一声,在图形界面终端下的效果取决于终端的实现。在终端下显示 \b 和按下退格键的效果相同。 \f 是分页符,主要用于控制打印机在打印源代码时提前分页,这样可以避免一个函数跨两页打印。 \n\r 分别表示 Line Feed 和 Carriage Return,这两个词来自老式的英文打字机,Line Feed是跳到下一行(进纸,喂纸,有个喂的动作所以是feed),Carriage Return是回到本行开头(Carriage是卷着纸的轴,随着打字慢慢左移,打完一行就一下子移回最右边),如果你看过欧美的老电影应该能想起来这是什么。用老式打字机打完一行之后需要这么两个动作, \r\n ,所以现在Windows上的文本文件用 \r\n 做行分隔符,许多应用层网络协议(如HTTP)也用 \r\n 做行分隔符,而Linux和各种UNIX上的文本文件只用 \n 做行分隔符。在终端下显示 \t 和按下Tab键的效果相同,用于在终端下定位表格的下一列, \v 用于在终端下定位表格的下一行。 \v 比较少用,\t 比较常用,以后将“水平制表符”简称为“制表符”或Tab。请读者用 printf 语句试试这几个控制字符的作用。

注意 "Goodbye, " 末尾的空格,字符串字面值中的空格也算一个字符,也会出现在输出结果中,而程序中别处的空格和Tab多一个少一个往往是无关紧要的,不会对编译的结果产生任何影响,例如不缩进不会影响程序的结果,main后面多几个空格也没影响,但是int和main之间至少要有一个空格分隔开:

int main (void) { printf("Hello, world.\n"); return 0; }

不仅空格和Tab是无关紧要的,换行也是如此,我甚至可以把整个程序写成一行,但是 include 必须单独占一行:

#include<stdio.h>
int main(void){printf("Hello, world.\n");return 0;}

注解

Zombie110year

include 是编译器指令, 所以和 C 语言的语法有差异.

这样也行,但肯定不是好的代码风格,去掉缩进已经很影响可读性了,写成现在这个样子可读性更差。如果编译器说第2行有错误,也很难判断是哪个语句有错误。所以,好的代码风格要求缩进整齐,每个语句一行,适当留空行。

常量

常量(Constant) 是程序中最基本的元素,有字符(Character) 常量、整数(Integer) 常量、浮点数(Floating Point) 常量和枚举常量。枚举常量将在 数据类型标志 介绍。下面看一个例子:

printf("character: %c\ninteger: %d\nfloating point: %f\n", '}', 34, 3.14);

字符常量要用单引号括起来,例如上面的 ‘}’,注意单引号只能括一个字符而不能像双引号那样括一串字符,字符常量也可以是一个转义序列,例如 ‘\n’,这时虽然单引号括了两个字符,但实际上只表示一个字符。和字符串字面值中使用转义序列有一点区别,如果在字符常量中要表示双引号 " 和问号 ? ,既可以使用转义序列 \"\? ,也可以直接用字符 "? ,而要表示 '\ 则必须使用转义序列。[1]

计算机中整数和小数的内部表示方式不同(将在 计算机中数的表示 详细介绍),因而在C语言中是两种不同的类型(Type) ,例如上例的 343.14,小数在计算机术语中称为浮点数。这个语句的输出结果和 Hello World 不太一样,字符串 "character: %c\ninteger: %d\nfloating point: %f\n" 并不是按原样打印输出的,而是输出成这样:

character: }
integer: 34
floating point: 3.14

printf 中的第一个字符串称为格式化字符串(Format String) ,它规定了后面几个常量以何种格式插入到这个字符串中,在格式化字符串中 % 号(Percent Sign) 后面加上字母c、d、f分别表示字符型、整型和浮点型的转换说明(Conversion Specification) ,转换说明只在格式化字符串中占个位置,并不出现在最终的打印结果中,这种用法通常叫做占位符(Placeholder) 。这也是一种字面意思与真实意思不同的情况,但是转换说明和转义序列又有区别:转义序列是编译时处理的,而转换说明是在运行时调用 printf 函数处理的。源文件中的字符串字面值是 "character: %c\ninteger: %d\nfloating point: %f\n"\n 占两个字符,而编译之后保存在可执行文件中的字符串是 character: %c换行integer: %d换行floating point: %f换行\n 已经被替换成一个换行符,而 %c 不变,然后在运行时这个字符串被传给 printfprintf 再把其中的 %c%d%f 解释成转换说明。

有时候不同类型的数据很容易弄混,例如 "5"'5'5,如果你注意了它们的界定符就会很清楚,第一个是字符串字面值,第二个是字符,第三个是整数,看了本章后面几节你就知道为什么一定要严格区分它们之间的差别了。

注解

Zombie110year

"字符串"'c', 字符串和字符的区别是, 字符是一个整数, 范围在 0~255 之间, 按照 ASCII 表映射为字符. 而字符串, 则是一个字符数组, 并且在末尾会自动添加一个 \0 转义序列用于标识字符串的结束. 现在 C11 标准中添加了 Unicode 支持, 字符串默认以 UTF-8 编码, 将字符解析为字节储存在 char 类型的数组中.

习题

1、总结前面介绍的转义序列的规律,想想在printf的格式化字符串中怎么表示一个%字符?写个小程序试验一下。

注解

Zombie110year

%%

[1]读者可能会奇怪,为什么需要规定一个转义序列?呢?因为C语言规定了一些三连符(Trigraph) ,在某些特殊的终端上缺少某些字符,需要用Trigraph输入,例如用??=表示#字符。Trigraph极不常用,介绍这个只是为了让读者理解C语言规定转义序列的作用,即特殊字符转普通字符,普通字符转特殊字符,?也是一种特殊字符。极不常用的C语法在本书中通常不会介绍。

变量

变量(Variable) 是编程语言最重要的概念之一,变量是计算机存储器中的一块命名的空间,可以在里面存储一个值(Value) ,存储的值是可以随时变的,比如这次存个字符 'a' 下次存个字符 'b',正因为变量的值可以随时变所以才叫变量。

常量有不同的类型,因此变量也有不同的类型,变量的类型也决定了它所占的存储空间的大小。例如以下四个语句定义了四个变量 fredbobjimmytom,它们的类型分别是字符型、整型、浮点型:

char fred;
int bob;
float jimmy;
double tom;

重要

声明和定义

C语言中的声明(Declaration) 有变量声明、函数声明和类型声明三种。如果一个变量或函数的声明要求编译器为它分配存储空间,那么也可以称为定义(Definition) ,因此定义是声明的一种。在接下来几章的示例代码中变量声明都是要分配存储空间的,因而都是定义,等学到 定义与声明 我们会看到哪些变量声明不分配存储空间因而不是定义。在下一章我们会看到函数的定义和声明也是这样区分的,分配存储空间的函数声明可以称为函数定义。从 结构体 开始我们会看到类型声明,声明一个类型是不分配存储空间的,但似乎叫 “类型定义” 听起来也不错,所以在本书中“类型定义”和“类型声明”表示相同的含义。声明和语句类似,也是以 ; 号结尾的,但是在语法上声明和语句是有区别的,语句只能出现在 {} 括号中,而声明既可以出现在 {} 中也可以出现在所有 {} 之外。也就是说,可以有局部的声明,也可以有全局的声明。

浮点型有三种,float 是单精度浮点型,double 是双精度浮点型,long double 是精度更高的浮点型。它们之间的区别和转换规则将在 数据类型详解 详细介绍,在随后的几章中我们只使用 double 类型,上一节介绍的常量 3.14 应该看作 double 类型的常量, printf%f 也应该看作 double 型的转换说明。给变量起名不能太随意,上面四个变量的名字就不够好,我们猜不出这些变量是用来存什么的。而像下面这样起名就很好:

char firstletter;
char lastletter;
int hour, minute;

我们可以猜得到这些变量是用来存什么的,前两个变量的取值范围应该是 'A' ~ 'Z''a' ~ 'z',变量 hour 的取值范围应该是 0 ~ 23,变量 minute 的取值范围应该是 0 ~ 59 ,所以应该给变量起有意义的名字。从这个例子中我们也看到两个相同类型的变量( hourminute )可以一起声明。

给变量起名有一定的限制,C语言规定必须以字母或下划线 _``(Underscore) 开头,后面可以跟若干个字母、数字、下划线,但不能有其它字符。例如这些是合法的变量名:``Abc__abc___123。但这些是不合法的变量名:3abcab$。其实这个规则不仅适用于变量名,也适用于所有可以由程序员起名的语法元素,例如以后要讲的函数名、宏定义、结构体成员名等,在C语言中这些统称为 标识符 (Identifier) 。

另外要注意,表示类型的 charintfloatdouble 等虽然符合上述规则,但也不能用作标识符。在C语言中有些单词有特殊意义,不允许用作标识符,这些单词称为关键字(Keyword) 或保留字(Reserved Word) 。通常用于编程的文本编辑器都会高亮显示(Highlight) 这些关键字,所以只要小心一点通常不会误用作标识符。C99规定的关键字有:

auto  break  case  char  const  continue  default  do  double  else  enum
extern  float  for  goto  if  inline  int  long  register  restrict  return
short  signed  sizeof  static  struct  switch  typedef  union  unsigned
void  volatile  while  _Bool  _Complex  _Imaginary

还有一点要注意,一般来说应避免使用以下划线开头的标识符,以下划线开头的标识符只要不和C语言关键字冲突的都是合法的,但是往往被编译器用作一些功能扩展,C标准库也定义了很多以下划线开头的标识符,所以除非你对编译器和C标准库特别清楚,一般应避免使用这种标识符,以免造成命名冲突。

请记住:理解一个概念不是把定义背下来就行了,一定要理解它的外延和内涵,也就是什么情况属于这个概念,什么情况不属于这个概念,什么情况虽然属于这个概念但一般推荐的做法(Best Practice)是要尽量避免这种情况,这才算是真正理解了。

赋值

定义了变量之后,我们要把值存到它们所表示的存储空间里,可以用赋值(Assignment) 语句实现:

char firstletter;
int hour, minute;
firstletter = 'a';   /* give firstletter the value 'a' */
hour = 11;           /* assign the value 11 to hour */
minute = 59;         /* set minute to 59 */

注意变量一定要先声明后使用,编译器必须先看到变量声明,才知道 firstletterhourminute 是变量名,各自代表一块存储空间。另外,变量声明中的类型表明这个变量代表多大的一块存储空间,这样编译器才知道如何读写这块存储空间。还要注意,这里的等号不表示数学里的相等关系,和 1+1=2 的等号是不同的,这里的等号表示赋值。在数学上不会有 i=i+1 这种等式成立,而在C语言中表示把变量 i 的存储空间中的值取出来,再加上 1,得到的结果再存回 i 的存储空间中。再比如,在数学上 a=77=a 是一样的,而在C语言中后者是不合法的。总结一下:定义一个变量,就是分配一块存储空间并给它命名;给一个变量赋值,就是把一个值保存到这块存储空间中。变量的定义和赋值也可以一步完成,这称为变量的初始化(Initialization) ,例如要达到上面代码的效果也可以这样写:

char firstletter = 'a';
int hour = 11, minute = 59;

在初始化语句中,等号右边的值叫做Initializer ,例如上面的 'a'1159。注意,初始化是一种特殊的声明,而不是一种赋值语句。就目前来看,先定义一个变量再给它赋值和定义这个变量的同时给它初始化所达到的效果是一样的,C语言的很多语法规则既适用于赋值也适用于初始化,但在以后的学习中你也会了解到它们之间的不同,请在学习过程中注意总结赋值和初始化的相同和不同之处。

如果在纸上“跑”一个程序(每个初学编程的人都要练这项基本功),可以用一个框表示变量的存储空间,在框的外边标上变量名,在框里记上它的值,如下图所示。

在纸上表示变量

你可以用不同形状的框表示不同类型的变量,这样可以提醒你给变量赋的值必须符合它的类型。如果所赋的值和变量的类型不符会导致编译器报警告或报错(这是一种语义错误),例如:

int hour, minute;
hour = "Hello.";       /* WRONG ! */
minute = "59";         /* WRONG !! */

注意第3个语句,把 "59" 赋给minute看起来像是对的,但是类型不对,字符串不能赋给整型变量。

既然可以为变量的存储空间赋值,就应该可以把值取出来用,现在我们取出这些变量的值用 printf 打印:

printf("Current time is %d:%d", hour, minute);

变量名用在等号左边表示赋值,而用在 printf 中表示把它的存储空间中的值取出来替换在那里。不同类型的变量所占的存储空间大小是不同的,数据表示方式也不同,变量的最小存储单位是字节(Byte) ,在C语言中 char 型变量占一个字节,其它类型的变量占多少字节在不同平台上有不同的规定,将在 数据类型详解 详细讨论。

表达式

常量和变量都可以参与加减乘除运算,例如 1+1hour-1hour * 60 + minuteminute/60 等。这里的 + - * / 称为运算符(Operator) ,而参与运算的常量和变量称为操作数(Operand) ,上面四个由运算符和操作数所组成的算式称为表达式(Expression) 。

和数学上规定的一样, hour * 60 + minute 这个表达式应该先算乘再算加,也就是说运算符是有优先级(Precedence) 的,*/ 是同一优先级,+- 是同一优先级,*/ 的优先级高于 +- 。对于同一优先级的运算从左到右计算,如果不希望按默认的优先级计算则要加 () 括号(Parenthesis) 。例如 (3+4)*5/6 应先算 3+4 ,再算 *5 ,再算 /6

前面讲过打印语句和赋值语句,现在我们定义:在任意表达式后面加个 ; 号也是一种语句,称为表达式语句。例如:

hour * 60 + minute;

这是个合法的语句,但这个语句在程序中起不到任何作用,把 hour 的值和 minute 的值取出来加乘,得到的计算结果却没有保存,白算了一通。再比如:

int total_minute;
total_minute = hour * 60 + minute;

这个语句就很有意义,把计算结果保存在另一个变量 total_minute 里。事实上等号也是一种运算符,称为赋值运算符,赋值语句就是一种表达式语句,等号的优先级比 +* 都低,所以先算出等号右边的结果然后才做赋值操作,整个表达式 total_minute = hour * 60 + minute 加个 ; 号构成一个语句。

任何表达式都有值和类型两个基本属性。hour * 60 + minute 的值是由三个 int 型的操作数计算出来的,所以这个表达式的类型也是 int 型。同理,表达式 total_minute = hour * 60 + minute 的类型也是 int ,它的值是多少呢? C语言规定 等号运算符的计算结果就是等号左边被赋予的那个值,所以这个表达式的值和 hour * 60 + minute 的值相同,也和 total_minute 的值相同。

等号运算符还有一个和 + - * / 不同的特性,如果一个表达式中出现多个等号,不是从左到右计算而是从右到左计算,例如:

int total_minute, total;
total = total_minute = hour * 60 + minute;

计算顺序是先算 hour * 60 + minute 得到一个结果,然后算右边的等号,就是把 hour * 60 + minute 的结果赋给变量 total_minute ,这个结果同时也是整个表达式 total_minute = hour * 60 + minute 的值,再算左边的等号,即把这个值再赋给变量 total 。同样优先级的运算符是从左到右计算还是从右到左计算称为运算符的结合性(Associativity) 。 + - * / 是左结合的,等号是右结合的。

现在我们总结一下到目前为止学过的语法规则:

表达式 → 标识符 表达式 → 常量 表达式 → 字符串字面值 表达式 → (表达式) 表达式 → 表达式 + 表达式 表达式 → 表达式 - 表达式 表达式 → 表达式 * 表达式 表达式 → 表达式 / 表达式 表达式 → 表达式 = 表达式 语句 → 表达式; 语句 → printf(表达式, 表达式, 表达式, …); 变量声明 → 类型 标识符 = Initializer, 标识符 = Initializer, …; (= Initializer的部分可以不写)

注意,本书所列的语法规则都是简化过的,是不准确的,目的是为了便于初学者理解,比如上面所列的语法规则并没有描述运算符的优先级和结合性。完整的C语法规则请参考 [C99] 的 Annex A。

表达式可以是单个的常量或变量,也可以是根据以上规则组合而成的更复杂的表达式。以前我们用printf打印常量或变量的值,现在可以用 printf 打印更复杂的表达式的值,例如:

printf("%d:%d is %d minutes after 00:00\n", hour, minute, hour * 60 + minute);

编译器在翻译这条语句时,首先根据上述语法规则把这个语句解析成下图所示的语法树,然后再根据语法树生成相应的指令。语法树的末端的是一个个Token,每一步展开利用一条语法规则。

语法树

根据这些语法规则进一步组合可以写出更复杂的语句,比如在一条语句中完成计算、赋值和打印功能:

printf("%d:%d is %d minutes after 00:00\n", hour, minute, total_minute = hour * 60 + minute);

理解组合(Composition) 规则是理解语法规则的关键所在,正因为可以根据语法规则任意组合,我们才可以用简单的常量、变量、表达式、语句搭建出任意复杂的程序,以后我们学习新的语法规则时会进一步体会到这一点。从上面的例子可以看出,表达式不宜过度组合,否则会给阅读和调试带来困难。

根据语法规则组合出来的表达式在语义上并不总是正确的,例如:

minute + 1 = hour;

等号左边的表达式要求表示一个存储位置而不是一个值,这是等号运算符和 + - * / 运算符的又一个显著不同。有的表达式既可以表示一个存储位置也可以表示一个值,而有的表达式只能表示值,不能表示存储位置,例如 minute + 1 这个表达式就不能表示存储位置,放在等号左边是语义错误。表达式所表示的存储位置称为左值(lvalue) (允许放在等号左边),而以前我们所说的表达式的值也称为右值(rvalue) (只能放在等号右边)。上面的话换一种说法就是:有的表达式既可以做左值也可以做右值,而有的表达式只能做右值。目前我们学过的表达式中只有变量可以做左值,可以做左值的表达式还有几种,以后会讲到。

我们看一个有意思的例子,如果定义三个变量 int a, b, c; ,表达式 a = b = c 是合法的,先求 b = c 的值,再把这个值赋给 a,而表达式 (a = b) = c 是不合法的,先求 (a = b) 的值没问题,但 (a = b) 这个表达式不能再做左值了,因此放在 = c 的等号左边是错的。

关于整数除法运算有一点特殊之处:

hour = 11;
minute = 59;
printf("%d and %d hours\n", hour, minute / 60);

执行结果是 11 and 0 hours,也就是说 59/600 ,这是因为两个 int 型操作数相除的表达式仍为 int 型,只能保存计算结果的整数部分,即使小数部分是 0.98 也要舍去。

向下取整的运算称为 Floor ,用数学符号 ⌊⌋ 表示;向上取整的运算称为Ceiling ,用数学符号 ⌈⌉ 表示。例如:

⌊59/60⌋=0
⌈59/60⌉=1
⌊-59/60⌋=-1
⌈-59/60⌉=0

在C语言中整数除法取的既不是Floor也不是Ceiling,无论操作数是正是负总是把小数部分截掉,在数轴上向零的方向取整(Truncate toward Zero) ,或者说当操作数为正的时候相当于Floor,当操作符为负的时候相当于Ceiling。回到先前的例子,要得到更精确的结果可以这样:

printf("%d hours and %d percent of an hour\n", hour, minute * 100 / 60);
printf("%d and %f hours\n", hour, minute / 60.0);

在第二个 printf 中,表达式是 minute / 60.060.0double 型的,/ 运算符要求左右两边的操作数类型一致,而现在并不一致。C语言规定了一套隐式类型转换规则,在这里编译器自动把左边的 minute 也转成 double 型来计算,整个表达式的值也是 double 型的,在格式化字符串中应该用 %f 转换说明与之对应。本来编程语言作为一种形式语言要求有简单而严格的规则,自动类型转换规则不仅很复杂,而且使C语言的形式看起来也不那么严格了,C语言这么设计是为了书写程序简便而做的折衷,有些事情编译器可以自动做好,程序员就不必每次都写一堆繁琐的转换代码。然而C语言的类型转换规则非常难掌握,本书的前几章会尽量避免类型转换,到 类型转换 再集中解决这个问题。

习题

1、假设变量 xn 是两个正整数,我们知道 x/n 这个表达式的结果要取Floor,例如 x 是 17,n 是 4,则结果是 4。如果希望结果取 Ceiling 应该怎么写表达式呢?例如 x 是 17, n 是 4,则结果是 5; x``是 16,``n 是 4 ,则结果是 4 。

注解

Zombie110year

int resualt_up   = (int) ( x + 0.5 ) / n // 向上取整
int resualt_down = (int) ( x - 0.5 ) / n // 向下取整

字符类型与字符编码

字符常量或字符型变量也可以当作整数参与运算,例如:

printf("%c\n", 'a'+1);

执行结果是 b

我们知道,符号在计算机内部也用数字表示,每个字符在计算机内部用一个整数表示,称为字符编码(Character Encoding) ,目前最常用的是 ASCII 码(American Standard Code for Information Interchange,美国信息交换标准码) ,详见 图 ASCII 码表。表中每一栏的最后一列是字符,前三列分别是用十进制(Dec)、十六进制(Hx)和八进制(Oct)表示的字符编码,各种进制之间的换算将在 不同进制之间的换算 介绍。从十进制那一列可以看出ASCII码的取值范围是 0 ~ 127。表中的很多字符是不可见字符(Non-printable Character) 或空白字符(Whitespace) [2],不能像字母 a 这样把字符本身填在表中,而是用一个名字来描述该字符,例如 CR(carriage return)、LF(NL line feed,newline)、DEL等等。作为练习,请读者查一查 表 C标准规定的转义字符 中的字符在 ASCII 码表中的什么位置。

回到刚才的例子,在 ASCII 码中字符 a 是 97,字符 b 是 98。计算 'a'+1 这个表达式,应该按 ASCII 码把 'a' 当作整数值 97,然后加 1,得到 98,然后 printf98 这个整数值当作 ASCII 码来解释,打印出相应的字符 b

之前我们说“整型”是指 int 型,而现在我们知道 char 型本质上就是整数,只不过取值范围比 int 型小,所以以后我们把 char 型和 int 型统称为整数类型(Integer Type)或简称整型,以后我们还要学习几种类型也属于整型,将在 整型 详细介绍。

字符’a’~’z’、’A’~’Z’、‘0’~‘9’的 ASCII 码都是连续的,因此表达式 'a'+25'z' 的值相等, '0'+9'9' 的值也相等。注意 ‘0’~‘9’ 的 ASCII 码是十六进制的 30~39 ,和整数值0~9是不相等的。

字符也可以用 ASCII 码转义序列表示,这种转义序列由 \ 加上 1~3 个八进制数字组成,或者由 \x 或大写 \X 加上 1~2 个十六进制数字组成,可以用在字符常量或字符串字面值中。例如 '\0' 表示 NUL 字符(Null Character) ,'\11''\x9' 表示Tab字符, "\11""\x9" 表示由Tab字符组成的字符串。注意 '0' 的ASCII码是48,而 '\0' 的ASCII码是0,两者是不同的。

[2]空白字符在不同的上下文中有不同的含义,在C语言中空白字符定义为空格、水平Tab、垂直Tab、换行和分页符,本书在使用“空白字符”这个词时会明确说明在当前上下文中空白字符指的是哪些字符。

简单函数

数学函数

在数学中我们用过 \(\operatorname{sin}\)\(\operatorname{ln}\) 这样的函数,例如 \(\operatorname{sin} ( \pi / 2 ) = 1\)\(\operatorname{ln} 1 = 0\) 等等,在C语言中也可以使用这些函数( \(\operatorname{ln}\) 函数在C标准库中叫做 log ):

在 C 语言中使用数学函数
#include <math.h>
#include <stdio.h>

int main(void)
{
    double pi = 3.1416;
    printf("sin(pi/2)=%f\nln1=%f\n", sin(pi/2), log(1.0));
    return 0;
}

编译运行这个程序,结果如下:

$ gcc main.c -lm
$ ./a.out
sin(pi/2)=1.000000
ln1=0.000000

在数学中写一个函数有时候可以省略括号,而C语言要求一定要加上括号,例如 log(1.0)。在C语言的术语中,1.0 是参数(Argument),log 是函数(Function) ,log(1.0) 是函数调用(Function Call) 。sin(pi/2)log(1.0) 这两个函数调用在我们的 printf 语句中处于什么位置呢?在上一章讲过,这应该是写表达式的位置。因此函数调用也是一种表达式,这个表达式由函数调用运算符( () 括号)和两个操作数组成,操作数 log 是一个函数名(Function Designator) ,它的类型是一种函数类型(Function Type) ,操作数 1.0double 型的。log(1.0) 这个表达式的值就是对数运算的结果,也是 double 型的,在C语言中函数调用表达式的值称为函数的返回值(Return Value) 。总结一下我们新学的语法规则:

表达式 → 函数名

表达式 → 表达式(参数列表)

参数列表 → 表达式, 表达式, …

现在我们可以完全理解 printf 语句了:原来 printf 也是一个函数,上例中的 printf("sin(pi/2)=%f\nln1=%f\n", sin(pi/2), log(1.0)) 是带三个参数的函数调用,而函数调用也是一种表达式,因此 printf 语句也是表达式语句的一种。但是 printf 感觉不像一个数学函数,为什么呢?因为像 log 这种函数,我们传进去一个参数会得到一个返回值,我们调用 log 函数就是为了得到它的返回值,至于 printf,我们并不关心它的返回值(事实上它也有返回值,表示实际打印的字符数),我们调用 printf 不是为了得到它的返回值,而是为了利用它所产生的副作用(Side Effect) --打印。C语言的函数可以有Side Effect,这一点是它和数学函数在概念上的根本区别。

Side Effect这个概念也适用于运算符组成的表达式。比如 a + b 这个表达式也可以看成一个函数调用,把运算符 + 看作函数,它的两个参数是 ab,返回值是两个参数的和,传入两个参数,得到一个返回值,并没有产生任何Side Effect。而赋值运算符是有Side Effect的,如果把 a = b 这个表达式看成函数调用,返回值就是所赋的值,既是 b 的值也是 a 的值,但除此之外还产生了Side Effect,就是变量a被改变了,改变计算机存储单元里的数据或者做输入输出操作都算Side Effect。

回想一下我们的学习过程,一开始我们说赋值是一种语句,后来学了表达式,我们说赋值语句是表达式语句的一种;一开始我们说 printf 是一种语句,现在学了函数,我们又说 printf 也是表达式语句的一种。随着我们一步步的学习,把原来看似不同类型的语句统一成一种语句了。学习的过程总是这样,初学者一开始接触的很多概念从严格意义上说是错的,但是很容易理解,随着一步步学习,在理解原有概念的基础上不断纠正,不断泛化(Generalize) 。比如一年级老师说小数不能减大数,其实这个概念是错的,后来引入了负数就可以减了,后来引入了分数,原来的正数和负数的概念就泛化为整数,上初中学了无理数,原来的整数和分数的概念就泛化为有理数,再上高中学了复数,有理数和无理数的概念就泛化为实数。坦白说,到目前为止本书的很多说法都是不完全正确的,但这是学习理解的必经阶段,到后面的章节都会逐步纠正的。

程序第一行的 # 号(Pound Sign,Number Sign或Hash Sign) 和 include 表示包含一个头文件(Header File) ,后面尖括号(Angel Bracket) 中就是文件名(这些头文件通常位于 /usr/include 目录下)。头文件中声明了我们程序中使用的库函数,根据先声明后使用的原则,要使用 printf 函数必须包含 stdio.h,要使用数学函数必须包含 math.h,如果什么库函数都不使用就不必包含任何头文件,例如写一个程序 int main(void){int a;a=2;return 0;},不需要包含头文件就可以编译通过,当然这个程序什么也做不了。

使用 math.h 中声明的库函数还有一点特殊之处,gcc命令行必须加 -lm 选项,因为数学函数位于 libm.so 库文件中(这些库文件通常位于 /lib 目录下),-lm 选项告诉编译器,我们程序中用到的数学函数要到这个库文件里找。本书用到的大部分库函数(例如 printf)位于 libc.so 库文件中,使用 libc.so 中的库函数在编译时不需要加 -lc 选项,当然加了也不算错,因为这个选项是gcc的 默认选项。关于头文件和库函数目前理解这么多就可以了,到 链接详解 再详细解释。

重要

C标准库和glibc

C标准主要由两部分组成,一部分描述C的语法,另一部分描述C标准库。C标准库定义了一组标准头文件,每个头文件中包含一些相关的函数、变量、类型声明和宏定义。要在一个平台上支持C语言,不仅要实现C编译器,还要实现C标准库,这样的实现才算符合C标准。不符合C标准的实现也是存在的,例如很多单片机的C语言开发工具中只有C编译器而没有完整的C标准库。

在Linux平台上最广泛使用的C函数库是 glibc,其中包括C标准库的实现,也包括本书第三部分介绍的所有系统函数。几乎所有C程序都要调用glibc的库函数,所以glibc是Linux平台C程序运行的基础。glibc提供一组头文件和一组库文件,最基本、最常用的C标准库函数和系统函数在 libc.so 库文件中,几乎所有C程序的运行都依赖于 libc.so,有些做数学计算的C程序依赖于 libm.so,以后我们还会看到多线程的C程序依赖于 libpthread.so。以后我说libc时专指 libc.so 这个库文件,而说 glibc 时指的是 glibc 提供的所有库文件。

glibc并不是Linux平台唯一的基础C函数库,也有人在开发别的C函数库,比如适用于嵌入式系统的uClibc。

自定义函数

我们不仅可以调用C标准库提供的函数,也可以定义自己的函数,事实上我们已经这么做了:我们定义了 main 函数。例如:

int main(void)
{
    int hour = 11;
    int minute = 59;
    printf("%d and %d hours\n", hour, minute / 60);
    return 0;
}

main 函数的特殊之处在于执行程序时它自动被操作系统调用,操作系统就认准了 main 这个名字,除了名字特殊之外,main 函数和别的函数没有区别。我们对照着 main 函数的定义来看语法规则:

函数定义 → 返回值类型 函数名(参数列表) 函数体

函数体 → { 语句列表 }

语句列表 → 语句列表项 语句列表项 …

语句列表项 → 语句

语句列表项 → 变量声明、类型声明或非定义的函数声明

非定义的函数声明 → 返回值类型 函数名(参数列表);

我们稍后再详细解释“函数定义”和“非定义的函数声明”的区别。从 结构体 开始我们才会看到类型声明,所以现在暂不讨论。

给函数命名也要遵循上一章讲过的标识符命名规则。由于我们定义的 main 函数不带任何参数,参数列表应写成 void 。函数体可以由若干条语句和声明组成,C89要求所有声明写在所有语句之前(本书的示例代码都遵循这一规定),而C99的新特性允许语句和声明按任意顺序排列,只要每个标识符都遵循先声明后使用的原则就行。 main 函数的返回值是 int 型的,return 0; 这个语句表示返回值是 0main 函数的返回值是返回给操作系统看的,因为 main 函数是被操作系统调用的,通常程序执行成功就返回 0,在执行过程中出错就返回一个非零值。比如我们将 main 函数中的 return 语句改为 return 4; 再执行它,执行结束后可以在Shell中看到它的退出状态(Exit Status) :

$ ./a.out
11 and 0 hours
$ echo $?
4

$? 是Shell中的一个特殊变量,表示上一条命令的退出状态。关于 main 函数需要注意两点:

  1. [K&R] 书上的 main 函数定义写成 main(){...} 的形式,不写返回值类型也不写参数列表,这是Old Style C的风格。Old Style C规定不写返回值类型就表示返回 int 型,不写参数列表就表示参数类型和个数没有明确指出。这种宽松的规定使编译器无法检查程序中可能存在的Bug,增加了调试难度,不幸的是现在的C标准为了兼容旧的代码仍然保留了这种语法,但读者绝不应该继续使用这种语法。
  2. 其实操作系统在调用 main 函数时是传参数的,main 函数最标准的形式应该是 int main(int argc, char *argv[]),在 指向指针的指针与指针数组 详细介绍。C标准也允许 int main(void) 这种写法,如果不使用系统传进来的两个参数也可以写成这种形式。但除了这两种形式之外,定义 main 函数的其它写法都是错误的或不可移植的。

关于返回值和 return 语句我们将在 return语句 详细讨论,我们先从不带参数也没有返回值的函数开始学习定义和使用函数:

最简单的自定义函数
#include <stdio.h>

void newline(void)
{
    printf("\n");
}

int main(void)
{
    printf("First Line.\n");
    newline();
    printf("Second Line.\n");
    return 0;
}

执行结果:

First Line.

Second Line.

我们定义了一个 newline 函数给 main 函数调用,它的作用是打印一个换行,所以执行结果中间多了一个空行。 newline 函数不仅不带参数,也没有返回值,返回值类型为 void 表示没有返回值 [1],这说明我们调用这个函数完全是为了利用它的Side Effect。如果我们想要多次插入空行就可以多次调用 newline 函数:

int main(void)
{
    printf("First Line.\n");
    newline();
    newline();
    newline();
    printf("Second Line.\n");
    return 0;
}

如果我们总需要三个三个地插入空行,我们可以再定义一个 threeline 函数每次插入三个空行:

较简单的自定义函数
#include <stdio.h>

void newline(void)
{
    printf("\n");
}

void threeline(void)
{
    newline();
    newline();
    newline();
}

int main(void)
{
    printf("Three lines:\n");
    threeline();
    printf("Another three lines.\n");
    threeline();
    return 0;
}

通过这个简单的例子可以体会到:

  1. 同一个函数可以被多次调用。
  2. 可以用一个函数调用另一个函数,后者再去调第三个函数。
  3. 通过自定义函数可以给一组复杂的操作起一个简单的名字,例如 threeline。对于``main`` 函数来说,只需要通过 threeline 这个简单的名字来调用就行了,不必知道打印三个空行具体怎么做,所有的复杂操作都被隐藏在 threeline 这个名字后面。
  4. 使用自定义函数可以使代码更简洁,main 函数在任何地方想打印三个空行只需调用一个简单的 threeline(),而不必每次都写三个 printf("\n")

读代码和读文章不一样,按从上到下从左到右的顺序读代码未必是最好的。比如上面的例子,按源文件的顺序应该是先看 newline 再看 threeline 再看 main 。如果你换一个角度,按 代码的执行顺序 来读也许会更好:首先执行的是 main 函数中的语句,在一条 printf 之后调用了 threeline,这时再去看 threeline 的定义,其中又调用了 newline ,这时再去看 newline 的定义,newline 里面有一条 printf,执行完成后返回 threeline,这里还剩下两次 newline 调用,效果也都一样,执行完之后返回 main,接下来又是一条 printf 和一条 threeline。如下图所示:

函数调用的执行顺序

读代码的过程就是模仿计算机执行程序的过程,我们不仅要记住当前读到了哪一行代码,还要记住现在读的代码是被哪个函数调用的,这段代码返回后应该从上一个函数的什么地方接着往下读。

现在澄清一下函数声明、函数定义、函数原型(Prototype) 这几个概念。比如 void threeline(void) 这一行,声明了一个函数的名字、参数类型和个数、返回值类型,这称为函数原型。在代码中可以单独写一个函数原型,后面加 ; 号结束,而不写函数体,例如:

void threeline(void);

这种写法只能叫函数声明而不能叫函数定义,只有带函数体的声明才叫定义。上一章讲过,只有分配存储空间的变量声明才叫变量定义,其实函数也是一样,编译器只有见到函数定义才会生成指令,而指令在程序运行时当然也要占存储空间。那么没有函数体的函数声明有什么用呢?它为编译器提供了有用的信息,编译器在翻译代码的过程中,只有见到函数原型(不管带不带函数体)之后才知道这个函数的名字、参数类型和返回值,这样碰到函数调用时才知道怎么生成相应的指令,所以函数原型必须出现在函数调用之前,这也是遵循“先声明后使用”的原则。

注解

Zombie110year

在进行多文件编译时, 为了让一个文件编译得到的代码能够使用另一个文件中的变量、函数,需要使用 “声明” 而不是 “定义”。

在上面的例子中,main 调用 threelinethreeline 再调用 newline ,要保证每个函数的原型出现在调用之前,就只能按先 newlinethreelinemain 的顺序定义了。如果使用不带函数体的声明,则可以改变函数的定义顺序:

#include <stdio.h>

void newline(void);
void threeline(void);

int main(void)
{
    ...
}

void newline(void)
{
    ...
}

void threeline(void)
{
    ...
}

这样仍然遵循了先声明后使用的原则。

由于有Old Style C语法的存在,并非所有函数声明都包含完整的函数原型,例如 void threeline(); 这个声明并没有明确指出参数类型和个数,所以不算函数原型,这个声明提供给编译器的信息只有函数名和返回值类型。如果在这样的声明之后调用函数,编译器不知道参数的类型和个数,就不会做语法检查,所以很容易引入Bug。读者需要了解这个知识点以便维护别人用Old Style C风格写的代码,但绝不应该按这种风格写新的代码。

如果在调用函数之前没有声明会怎么样呢?有的读者也许碰到过这种情况,我可以解释一下,但绝不推荐这种写法。比如按上面的顺序定义这三个函数,但是把开头的两行声明去掉:

#include <stdio.h>

int main(void)
{
    printf("Three lines:\n");
    threeline();
    printf("Another three lines.\n");
    threeline();
    return 0;
}

void newline(void)
{
    printf("\n");
}

void threeline(void)
{
    newline();
    newline();
    newline();
}

编译时会报警告:

$ gcc main.c
main.c:17: warning: conflicting types for ‘threeline’
main.c:6: warning: previous implicit declaration of ‘threeline’ was here

但仍然能编译通过,运行结果也对。这里涉及到的规则称为函数的隐式声明(Implicit Declaration) ,在 main 函数中调用 threeline 时并没有声明它,编译器认为此处隐式声明了 int threeline(void);,隐式声明的函数返回值类型都是 int,由于我们调用这个函数时没有传任何参数,所以编译器认为这个隐式声明的参数类型是 void,这样函数的参数和返回值类型都确定下来了,编译器根据这些信息为函数调用生成相应的指令。然后编译器接着往下看,看到 threeline 函数的原型是 void threeline(void),和先前的隐式声明的返回值类型不符,所以报警告。好在我们也没用到这个函数的返回值,所以执行结果仍然正确。

[1]敏锐的读者可能会发现一个矛盾:如果函数 newline 没有返回值,那么表达式 newline() 不就没有值了吗?然而上一章讲过任何表达式都有值和类型两个基本属性。其实这正是设计 void 这么一个关键字的原因:首先从语法上规定没有返回值的函数调用表达式有一个 void 类型的值,这样任何表达式都有值,不必考虑特殊情况,编译器的语法解析比较容易实现;然后从语义上规定 void 类型的表达式不能参与运算,因此 newline() + 1 这样的表达式不能通过语义检查,从而兼顾了语法上的一致和语义上的不矛盾。

形参和实参

下面我们定义一个带参数的函数,我们需要在函数定义中指明参数的个数和每个参数的类型,定义参数就像定义变量一样,需要为每个参数指明类型,参数的命名也要遵循标识符命名规则。例如:

带参数的自定义函数
#include <stdio.h>

void print_time(int hour, int minute)
{
    printf("%d:%d\n", hour, minute);
}

int main(void)
{
    print_time(23, 59);
    return 0;
}

需要注意的是,定义变量时可以把相同类型的变量列在一起,而定义参数却不可以,例如下面这样的定义是错的:

void print_time(int hour, minute)
{
    printf("%d:%d\n", hour, minute);
}

学习C语言的人肯定都乐意看到这句话:“变量是这样定义的,参数也是这样定义的,一模一样”,这意味着不用专门去记住参数应该怎么定义了。谁也不愿意看到这句话:“定义变量可以这样写,而定义参数却不可以”。C语言的设计者也不希望自己设计的语法规则里到处都是例外,一个容易被用户接受的设计应该遵循最少例外原则(Rule of Least Surprise) 。其实关于参数的这条规定也不算十分例外,也是可以理解的,请读者想想为什么要这么规定。学习编程语言不应该死记各种语法规定,如果能够想清楚设计者这么规定的原因(Rationale) ,不仅有助于记忆,而且会有更多收获。本书在必要的地方会解释一些Rationale,或者启发读者自己去思考,例如上一节在脚注中解释了 void 关键字的Rationale。 [C99 Rationale] 是随C99标准一起发布的,值得参考。

总的来说,C语言的设计是非常优美的,只要理解了少数基本概念和基本原则就可以根据组合规则写出任意复杂的程序,很少有例外的规定说这样组合是不允许的,或者那样类推是错误的。相反,C++的设计就非常复杂,充满了例外,全世界没几个人能把C++的所有规则都牢记于心,因而C++的设计一直饱受争议,这个观点在 [UNIX编程艺术] 中有详细阐述。

在本书中,凡是提醒读者注意的地方都是多少有些Surprise的地方,初学者如果按常理来想很可能要想错,所以需要特别提醒一下。而初学者容易犯的另外一些错误,完全是因为没有掌握好基本概念和基本原理,或者根本无视组合规则而全凭自己主观臆断所致,对这一类问题本书不会做特别的提醒,例如有的初学者看完 常量、变量和表达式 之后会这样打印 \(\pi\) 的值:

double pi=3.1416;
printf("pi\n");

之所以会犯这种错误,一是不理解Literal的含义,二是自己想当然地把变量名组合到字符串里去,而事实上根本没有这条语法规则。如果连这样的错误都需要在书上专门提醒,就好比提醒小孩吃饭一定要吃到嘴里,不要吃到鼻子里,更不要吃到耳朵里一样。

回到正题。我们调用 print_time(23, 59) 时,函数中的参数 hour 就代表 23,参数 minute 就代表59。确切地说,当我们讨论函数中的 hour 这个参数时,我们所说的“ 参数”是指形参(Parameter) ,当我们讨论传一个参数 23 给函数时,我们所说的“参数”是指实参(Argument) ,但我习惯都叫参数而不习惯总把形参、实参这两个文绉绉的词挂在嘴边(事实上大多数人都不习惯),读者可以根据上下文判断我说的到底是形参还是实参。记住这条基本原理:形参相当于函数中定义的变量,调用函数传递参数的过程相当于定义形参变量并且用实参的值来初始化。例如这样调用:

void print_time(int hour, int minute)
{
    printf("%d:%d\n", hour, minute);
}

int main(void)
{
    int h = 23, m = 59;
    print_time(h, m);
    return 0;
}

相当于在函数print_time中执行了这样一些语句:

int hour = h;
int minute = m;
printf("%d:%d\n", hour, minute);

main 函数的变量 hprint_time 函数的参数 hour 是两个不同的变量,只不过它们的存储空间中都保存了相同的值 23,因为变量 h 的值赋给了参数 hour。同理,变量 m 的值赋给了参数 minute。C语言的这种传递参数的方式称为Call by Value 。在调用函数时,每个参数都需要得到一个值,函数定义中有几个形参,在调用时就要传几个实参,不能多也不能少,每个参数的类型也必须对应上。

肯定有读者注意到了,为什么我们每次调用 printf 传的实参个数都不一样呢?因为C语言规定了一种特殊的参数列表格式,用命令 man 3 printf 可以查看到 printf 函数的原型:

int printf(const char *format, ...);

第一个参数是 const char * 类型的,后面的 ... 可以代表 0 个或任意多个参数,这些参数的类型也是不确定的,这称为可变参数(Variable Argument) , 可变参数 将会详细讨论这种格式。总之,每个函数的原型都明确规定了返回值类型以及参数的类型和个数,即使像 printf 这样规定为“不确定”也是一种明确的规定,调用函数时要严格遵守这些规定,有时候我们把函数叫做接口(Interface) ,调用函数就是使用这个接口,使用接口的前提是必须和接口保持一致。

重要

Man Page

Man Page是Linux开发最常用的参考手册,由很多页面组成,每个页面描述一个主题,这些页面被组织成若干个Section。FHS(Filesystem Hierarchy Standard) 标准规定了Man Page各Section的含义如下:

Man Page的Section
Section 描述
1 用户命令,例如 ls(1)
2 系统调用,例如 _exit(2)
3 库函数,例如 printf(3)
4 特殊文件,例如 null(4) 描述了设备文件 /dev/null/dev/zero 的作用
5 系统配置文件的格式,例如 passwd(5) 描述了 系统配置文件 /etc/passwd 的格式
6 游戏
7 其它杂项,例如 bash-builtins(7) 描述了 bash 的各种内建命令
8 系统管理命令,例如 ifconfig(8)

注意区分用户命令和系统管理命令,用户命令通常位于 /bin/usr/bin 目录,系统管理命令通常位于 /sbin/usr/sbin 目录,一般用户可以执行用户命令,而执行系统管理命令经常需要 root 权限。系统调用和库函数的区别将在 main函数和启动例程 说明。

Man Page中有些页面有重名,比如敲 man printf 命令看到的并不是C函数 printf,而是位于第1个Section的系统命令 printf ,要查看位于第3个Section的 printf 函数应该敲 man 3 printf,也可以敲 man -k printf 命令搜索哪些页面的主题包含 printf 关键字。本书会经常出现类似 printf(3) 这样的写法,括号中的 3 表示 Man Page 的第 3 个 Section,或者表示 “我这里想说的是 printf 库函数而不是 printf 命令”。

习题

1、定义一个函数 increment,它的作用是把传进来的参数加 1。例如:

void increment(int x)
{
    x = x + 1;
}

int main(void)
{
    int i = 1, j = 2;
    increment(i); /* i now becomes 2 */
    increment(j); /* j now becomes 3 */
    return 0;
}

我们在 main 函数中调用 increment 增加变量 i 和 j 的值,这样能奏效吗?为什么?

注解

Zombie110year

并不能. 因为在调用 increment 时, 传入的值会在 increment 的作用域中创建一个新的内存区域. 实际上, 在 increment 中的 i 和在 main 中的 i 并不是同一块内存. 由于 increment 没有返回值, 那么在调用结束后, 内部的变量就被直接销毁了.

2、如果在一个程序中调用了 printf 函数却不包含头文件,例如 int main(void) { printf("\n"); },编译时会报警告: warning: incompatible implicit declaration of built-in function ‘printf’。请分析错误原因。

注解

Zombie110year

由于没有引用 stdio.h, 因此, 编译器找不到 printf 标识符的定义, 就算默认链接了 libc.so, 也不知道该执行哪一段机器码. 编译器在编译过程中, 会对其进行检查, 因此报错.

全局变量、局部变量和作用域

我们把函数中定义的变量称为局部变量(Local Variable) ,由于形参相当于函数中定义的变量,所以形参也是一种局部变量。在这里“局部”有两层含义:

1、一个函数中定义的变量不能被另一个函数使用。例如 print_time 中的 hourminutemain 函数中没有定义,不能使用,同样 main 函数中的局部变量也不能被 print_time 函数使用。如果这样定义:

void print_time(int hour, int minute)
{
    printf("%d:%d\n", hour, minute);
}
int main(void)
{
    int hour = 23, minute = 59;
    print_time(hour, minute);
    return 0;
}

main 函数中定义了局部变量 hourprint_time 函数中也有参数 hour ,虽然它们名称相同,但仍然是两个不同的变量,代表不同的存储单元。 main 函数的局部变量 minuteprint_time 函数的参数 minute 也是如此。

2、每次调用函数时局部变量都表示不同的存储空间。局部变量在每次函数调用时分配存储空间,在每次函数返回时释放存储空间,例如调用 print_time(23, 59) 时分配 hourminute 两个变量的存储空间,在里面分别存上 2359 ,函数返回时释放它们的存储空间,下次再调用 print_time(12, 20) 时又分配 hourminute 的存储空间,在里面分别存上 1220

与局部变量的概念相对的是全局变量(Global Variable) ,全局变量定义在所有的函数体之外,它们在程序开始运行时分配存储空间,在程序结束时释放存储空间,在任何函数中都可以访问全局变量,例如:

全局变量
#include <stdio.h>

int hour = 23, minute = 59;

void print_time(void)
{
    printf("%d:%d in print_time\n", hour, minute);
}

int main(void)
{
    print_time();
    printf("%d:%d in main\n", hour, minute);
    return 0;
}

正因为全局变量在任何函数中都可以访问,所以在程序运行过程中全局变量被读写的顺序从源代码中是看不出来的,源代码的书写顺序并不能反映函数的调用顺序。程序出现了Bug往往就是因为在某个不起眼的地方对全局变量的读写顺序不正确,如果代码规模很大,这种错误是很难找到的。而对局部变量的访问不仅局限在一个函数内部,而且局限在一次函数调用之中,从函数的源代码很容易看出访问的先后顺序是怎样的,所以比较容易找到Bug。因此,虽然全局变量用起来很方便,但一定要慎用,能用函数传参代替的就不要用全局变量。

如果全局变量和局部变量重名了会怎么样呢?如果上面的例子改为:

作用域

则第一次调用 print_time 打印的是全局变量的值,第二次直接调用 printf 打印的则是 main 函数局部变量的值。在C语言中每个标识符都有特定的作用域,全局变量是定义在所有函数体之外的标识符,它的作用域从定义的位置开始直到源文件结束,而 main 函数局部变量的作用域仅限于 main 函数之中。如上图所示,设想整个源文件是一张大纸,也就是全局变量的作用域,而 main 函数是盖在这张大纸上的一张小纸,也就是 main 函数局部变量的作用域。在小纸上用到标识符 hourminute 时应该参考小纸上的定义,因为大纸(全局变量的作用域)被盖住了,如果在小纸上用到某个标识符却没有找到它的定义,那么再去翻看下面的大纸上有没有定义,例如上图中的变量 x

作用域-1

到目前为止我们在初始化一个变量时都是用常量做Initializer,其实也可以用表达式做Initializer,但要注意一点:局部变量可以用类型相符的任意表达式来初始化,而全局变量只能用常量表达式(Constant Expression)初始化。例如,全局变量 pi 这样初始化是合法的:

double pi = 3.14 + 0.0016;

但这样初始化是不合法的:

double pi = acos(-1.0);

然而局部变量这样初始化却是可以的。程序开始运行时要用适当的值来初始化全局变量,所以初始值必须保存在编译生成的可执行文件中,因此 初始值在编译时就要计算出来 ,然而上面第二种Initializer的值必须在程序运行时调用 acos 函数才能得到,所以不能用来初始化全局变量。请注意区分编译时和运行时这两个概念。为了简化编译器的实现,C语言从语法上规定全局变量只能用常量表达式来初始化,因此下面这种全局变量初始化是不合法的:

int minute = 360; int hour = minute / 60;

虽然在编译时计算出 hour 的初始值是可能的,但是 minute / 60 不是常量表达式,不符合语法规定,所以编译器不必想办法去算这个初始值。

注解

Zombie110year

如果要用一个标识符表示一个常量(可以在编译时得到的量), 使用 const 关键字.

const int minute = 360;
int hour = minute / 60;

就能通过编译了.

如果全局变量在定义时不初始化则初始值是0,如果局部变量在定义时不初始化则初始值是不确定的,为内存中的垃圾值,也就是之前程序运行的残留。所以,局部变量在使用之前一定要先赋值,如果基于一个不确定的值做后续计算肯定会引入Bug。

如何证明“局部变量的存储空间在每次函数调用时分配,在函数返回时释放”?当我们想要确认某些语法规则时,可以查教材,也可以查C99,但最快捷的办法就是编个小程序验证一下:

验证局部变量存储空间的分配和释放
#include <stdio.h>

void foo(void)
{
    int i;
    printf("%d\n", i);
    i = 777;
}

int main(void)
{
    foo();
    foo();
    return 0;
}

第一次调用 foo 函数,分配变量 i 的存储空间,然后打印 i 的值,由于 i 未初始化,打印的应该是一个不确定的值,然后把 i 赋值为 777 ,函数返回,释放 i 的存储空间。第二次调用 foo 函数,分配变量 i 的存储空间,然后打印 i 的值,由于 i 未初始化,如果打印的又是一个不确定的值,就证明了“局部变量的存储空间在每次函数调用时分配,在函数返回时释放”。分析完了,我们运行程序看看是不是像我们分析的这样:

134518128 777

结果出乎意料,第二次调用打印的 i 值正是第一次调用末尾赋给 i 的值 777。有一种初学者是这样,原本就没有把这条语法规则记牢,或者对自己的记忆力没信心,看到这个结果就会想:哦那肯定是我记错了,改过来记吧,应该是“函数中的局部变量具有一直存在的固定的存储空间,每次函数调用时使用它,返回时也不释放,再次调用函数时它应该还能保持上次的值”。还有一种初学者是怀疑论者或不可知论者,看到这个结果就会想:教材上明明说“ 局部变量的存储空间在每次函数调用时分配,在函数返回时释放”,那一定是教材写错了,教材也是人写的,是人写的就难免出错,哦,连C99也这么写的啊,C99也是人写的,也难免出错,或者C99也许没错,但是反正运行结果就是错了,计算机这东西真靠不住,太容易受电磁干扰和宇宙射线影响了,我的程序写得再正确也有可能被干扰得不能正确运行。

这是初学者最常见的两种心态。不从客观事实和逻辑推理出发分析问题的真正原因,而仅凭主观臆断胡乱给问题定性,“说你有罪你就有罪”。先不要胡乱怀疑,我们再做一次实验,在两次 foo 函数调用之间插一个别的函数调用,结果就大不相同了:

int main(void)
{
    foo();
    printf("hello\n");
    foo();
    return 0;
}

结果是:

134518200
hello
0

这一回,第二次调用 foo 打印的 i 值又不是 777 了而是 0,“局部变量的存储空间在每次函数调用时分配,在函数返回时释放”这个结论似乎对了,但另一个结论又不对了:全局变量不初始化才是 0 啊,不是说“局部变量不初始化则初值不确定”吗?

关键的一点是,我说“初值不确定”,有没有说这个不确定值不能是0?有没有说这个不确定值不能是上次调用赋的值?在这里“不确定”的准确含义是:每次调用这个函数时局部变量的初值可能不一样,运行环境不同,函数的调用次序不同,都会影响到局部变量的初值。在运用逻辑推理时一定要注意,不要把必要条件(Necessary Condition)当充分条件(Sufficient Condition),这一点在Debug时尤其重要,看到错误现象不要轻易断定原因是什么,一定要考虑再三,找出它的真正原因。例如,不要看到第二次调用打印 777 就下结论 “函数中的局部变量具有一直存在的固定的存储空间,每次函数调用时使用它,返回时也不释放,再次调用函数时它应该还能保持上次的值”,这个结论倒是能推出 777 这个结果,但反过来由 777 这个结果却不能推出这样的结论。所以说 777 这个结果是该结论的必要条件,但不是充分条件。也不要看到第二次调用打印 0 就断定“局部变量未初始化则初值为 0 ”, 0 这个结果是该结论的必要条件,但也不是充分条件。至于为什么会有这些现象,为什么这个不确定的值刚好是 777 ,或者刚好是 0 ,等学到 研究函数的调用过程 就能解释这些现象了。

自定义函数 介绍的语法规则可以看出,非定义的函数声明也可以写在局部作用域中,例如:

int main(void)
{
    void print_time(int, int);
    print_time(23, 59);
    return 0;
}

这样声明的标识符 print_time 具有局部作域,只在 main 函数中是有效的函数名,出了 main 函数就不存在 print_time 这个标识符了。

写非定义的函数声明时参数可以只写类型而不起名,例如上面代码中的 void print_time(int, int); ,只要告诉编译器参数类型是什么,编译器就能为 print_time(23, 59) 函数调用生成正确的指令。另外注意,虽然在一个函数体中可以声明另一个函数,但不能定义另一个函数,C语言不允许嵌套定义函数 [2]

[2]但gcc的扩展特性允许嵌套定义函数,但这并不是 C 标准。本书不做详细讨论。在以下网站可以找到一些资料:: http://gcc.gnu.org/onlinedocs/gccint/Trampolines.html http://blog.bitfoc.us/p/82

分支语句

if 语句

目前我们写的简单函数中可以有多条语句,但这些语句总是从前到后顺序执行的。除了顺序执行之外,有时候我们需要检查一个条件,然后根据检查的结果执行不同的后续代码,在C语言中可以用分支语句(Selection Statement) 实现,比如:

if (x != 0) {
    printf("x is nonzero.\n");
}

其中 x != 0 表示 “x不等于0” 的条件,这个表达式称为控制表达式(Controlling Expression) 如果条件成立,则 {} 中的语句被执行,否则 {} 中的语句不执行,直接跳到 } 后面。 if 和控制表达式改变了程序的控制流程(Control Flow) ,不再是从前到后顺序执行,而是根据不同的条件执行不同的语句,这种控制流程称为分支(Branch) 。上例中的 != 号表示“不等于”,像这样的运算符有:

逻辑运算符
运算符 含义
== 等于
!= 不等于
> 大于
< 小于
>= 大于或等于
<= 小于或等于

注意以下几点:

  1. 这里的 == 表示数学中的相等关系,相当于数学中的 \(=\) 号,初学者常犯的错误是在控制表达式中把 == 写成 = ,在C语言中 = 号是赋值运算符,两者的含义完全不同。
  2. 如果表达式所表示的比较关系成立则值为真(True) ,否则为假(False) ,在C语言中分别用 int 型的 10 表示。如果变量 x 的值是 -1,那么 x>0 这个表达式的值为 0 , x>-2 这个表达式的值为1。
  3. 在数学中 \(a<b<c\) 表示 b 既大于 a 又小于 c,但作为C语言表达式却不是这样。以上几种运算符都是左结合的,请读者想一下这个表达式应如何求值。
  4. 这些运算符的两个操作数应该是相同类型的,两边都是整型或者都是浮点型可以做比较,但两个字符串不能做比较,在 比较字符串 我们会介绍比较字符串的方法。
  5. ==!= 称为相等性运算符(Equality Operator) ,其余四个称为关系运算符(Relational Operator) ,相等性运算符的优先级低于关系运算符。

总结一下, if (x != 0) { ... } 这个语句的计算顺序是:首先求 x != 0 这个表达式的值,如果值为 0 ,就跳过 {} 中的语句直接执行后面的语句,如果值为 1 ,就先执行 {} 中的语句,然后再执行后面的语句。事实上控制表达式取任何非 0 值都表示真值,例如 if (x) { ... }if (x != 0) { ... } 是等价的,如果 x 的值是 2 ,则 x != 0 的值是 1 ,但对于 if 来说不管是 2 还是 1 都表示真值。

和if语句相关的语法规则如下:

语句 → if (控制表达式) 语句

语句 → { 语句列表 }

语句 → ;

在C语言中,任何允许出现语句的地方既可以是由 ; 号结尾的一条语句,也可以是由 {} 括起来的若干条语句或声明组成的语句块(Statement Block) ,语句块和上一章介绍的函数体的语法相同。注意语句块的 } 后面不需要加 ; 号。如果 } 后面加了 ; 号,则这个 ; 号本身又是一条新的语句了,在C语言中一个单独的 ; 号表示一条空语句(Null Statement) 。上例的语句块中只有一条语句,其实没必要写成语句块,可以简单地写成:

if (x != 0)
    printf("x is nonzero.\n");

语句块中也可以定义局部变量,例如:

void foo(void)
{
    int i = 0;
    {
        int i = 1;
        int j = 2;
        printf("i=%d, j=%d\n", i, j);
    }
    printf("i=%d\n", i); /* cannot access j here */
}

和函数的局部变量同样道理,每次进入语句块时为变量 j 分配存储空间,每次退出语句块时释放变量 j 的存储空间。语句块也构成一个作用域,和 作用域 的分析类似,如果整个源文件是一张大纸,foo 函数是盖在上面的一张小纸,则函数中的语句块是盖在小纸上面的一张更小的纸。语句块中的变量 i 和函数的局部变量 i 是两个不同的变量,因此两次打印的 i 值是不同的;语句块中的变量 j 在退出语句块之后就没有了,因此最后一行的 printf 不能打印变量 j ,否则编译器会报错。语句块可以用在任何允许出现语句的地方,不一定非得用在 if 语句中,单独使用语句块通常是为了定义一些比函数的局部变量更“局部”的变量。

习题

1、以下程序段编译能通过,执行也不出错,但是执行结果不正确(根据 程序的调试 的定义,这是一个语义错误),请分析一下哪里错了。还有,既然错了为什么编译能通过呢?

int x = -1;
if (x > 0);
    printf("x is positive.\n");

注解

Zombie110year

注意到 if (x > 0); 的后面有一个分号了吗? 该程序在语法 (Syntax) 上没有错误, 因此能通过编译器的检查, 但是, 实际运行起来, 则是

int x = -1;
if (x > 0)
    ;
printf("x is positive.\n");

也就是说, 这个 if 结构仅仅只控制了一个空语句的执行, 毫无意义.

良好的开发习惯是, 在每一个分支下, 都使用 {} 花括号, 并且, 每一个语句都独占一行. 这样的规则, 一般都会写进代码风格要求之中, 违反这些规则可是不能提交改动的哦.

int x = -1;
if (x > 0) {
    ; // 删掉这一行
    printf("x is positive.\n");
}

if/else语句

if 语句还可以带一个 else 子句(Clause) ,例如:

if (x % 2 == 0)
    printf("x is even.\n");
else
    printf("x is odd.\n");

这里的 % 是取模(Modulo) 运算符, x%2 表示 x 除以 2 所得的余数(Remainder) ,C语言规定 % 运算符的两个操作数必须是整型的。两个正数相除取余数很好理解,如果操作数中有负数,结果应该是正是负呢?C99规定,如果 ab 是整型, b 不等于 0,则表达式 (a/b)*b+a%b 的值总是等于a,再结合 表达式 讲过的整数除法运算要 向零取整 (Truncate Toward Zero),可以得到一个结论: % 运算符的结果总是与被除数同号(想一想为什么)。其它编程语言对取模运算的规定各不相同,也有规定结果和除数同号的,也有不做明确规定的。

取模运算在程序中是非常有用的,例如上面的例子判断 x 的奇偶性(Parity) ,看 x 除以 2 的余数是不是 0 ,如果是 0 则打印 x is even.,如果不是 0 则打印 x is odd.,读者应该能看出 else 在这里的作用了,如果在上面的例子中去掉 else ,则不管 x 是奇是偶, printf("x is odd.\n"); 总是执行。为了让这条语句更有用,可以把它封装(Encapsulate) 成一个函数:

void print_parity(int x)
{
    if (x % 2 == 0)
        printf("x is even.\n");
    else
        printf("x is odd.\n");
}

把语句封装成函数的基本步骤是:把语句放到函数体中,把变量改成函数的参数。这样,以后要检查一个数的奇偶性只需调用这个函数而不必重复写这条语句了,例如:

print_parity(17);
print_parity(18);

if/else 语句的语法规则如下:

语句 → if (控制表达式) 语句 else 语句

右边的“语句”既可以是一条语句,也可以是由 {} 括起来的语句块。一条 if 语句中包含一条子语句,一条 if/else 语句中包含两条子语句,子语句可以是任何语句或语句块,当然也可以是另外一条 if 或 if/else 语句。根据组合规则, if 或 if/else 可以嵌套使用。例如可以这样:

if (x > 0)
    printf("x is positive.\n");
else if (x < 0)
    printf("x is negative.\n");
else
    printf("x is zero.\n");

也可以这样:

if (x > 0) {
    printf("x is positive.\n");
} else {
    if (x < 0)
        printf("x is negative.\n");
    else
        printf("x is zero.\n");
}

现在有一个问题,类似 if (A) if (B) C; else D; 形式的语句怎么理解呢?可以理解成

if (A)
    if (B)
        C;
else
    D;

也可以理解成

if (A)
    if (B)
        C;
    else
        D;

继续Hello World 讲过,C代码的缩进只是为了程序员看起来方便,实际上对编译器不起任何作用,你的代码不管写成上面哪一种缩进格式,在编译器看起来都是一样的。那么编译器到底按哪种方式理解呢?也就是说, else 到底是和 if (A) 配对还是和 if (B) 配对?很多编程语言的语法都有这个问题,称为 Dangling-else 问题。C语言规定,else 总是和它上面最近的一个 if 配对,因此应该理解成 elseif (B) 配对,也就是按第二种方式理解。如果你写成上面第一种缩进的格式就很危险了:你看到的是这样,而编译器理解的却是那样。如果你希望编译器按第一种方式理解,应该明确加上 {}

if (A) { if (B) C; } else D;

顺便提一下,浮点型的精度有限,不适合用 == 运算符做精确比较。以下代码可以说明问题:

double i = 20.0;
double j = i / 7.0;
if (j * 7.0 == i)
    printf("Equal.\n");
else
    printf("Unequal.\n");

不同平台的浮点数实现有很多不同之处,在我的平台上运行这段程序结果为 Unequal,即使在你的平台上运行结果为 Equal,你再把i改成其它值试试,总有些值会使得结果为Unequal。等学习了 浮点数 你就知道为什么浮点型不能做精确比较了。

习题

1、写两个表达式,分别取整型变量x的个位和十位。

2、写一个函数,参数是整型变量x,功能是打印x的个位和十位。

布尔代数

if 语句 讲过, a<b<c 不表示 b 既大于 a 又小于 c ,那么如果想表示这个含义该怎么写呢?可以这样:

if (a < b) {
    if (b < c) {
        printf("b is between a and c.\n");
    }
}

我们也可以用逻辑与(Logical AND) 运算符表示这两个条件同时成立。逻辑与运算符在C语言中写成两个 &``号(Ampersand) ``&& ,上面的语句可以改写为:

if (a < b && b < c) {
    printf("b is between a and c.\n");
}

对于 a < b && b < c 这个控制表达式,要求 “a < b的值非0” 和 “b < c的值非0” 这两个条件同时成立整个表达式的值才为 1,否则整个表达式的值为 0。也就是只有两个条件都为真,它们做逻辑与运算的结果才为真,有一个条件为假,则逻辑与运算的结果为假,如下表所示:

AND的真值表
A B A AND B
0 0 0
0 1 0
1 0 0
1 1 1

这种表称为真值表(Truth Table) 。注意逻辑与运算的操作数以非 0 表示真以 0 表示假,而运算结果以 1 表示真以 0 表示假(类型是int),我们忽略这些细微的差别,在表中全部以 1 表示真以 0 表示假。C语言还提供了逻辑或(Logical OR) 运算符,写成两个 | 线(Pipe Sign) || ,逻辑非(Logical NOT) 运算符,写成一个 ! 号(Exclamation Mark) ,它们的真值表如下:

OR的真值表
A B A OR B
0 0 0
0 1 1
1 0 1
1 1 1
NOT的真值表
A NOT A
0 1
1 0

逻辑或表示两个条件只要有一个为真,它们做逻辑或运算的结果就为真,只有两个条件都为假,逻辑或运算的结果才为假。逻辑非的作用是对原来的逻辑值取反,原来是真的就是假,原来是假的就是真。逻辑非运算符只有一个操作数,称为单目运算符(Unary Operator) ,以前讲过的加减乘除、赋值、相等性、关系、逻辑与、逻辑或运算符都有两个操作数,称为双目运算符(Binary Operator) 。

关于逻辑运算的数学体系称为布尔代数(Boolean Algebra) ,以它的创始人布尔命名。在编程语言中表示真和假的数据类型叫做布尔类型,在C语言中通常用 int 型来表示,非0表示真,0表示假 [1] 。布尔逻辑是写程序的基本功之一,程序中的很多错误都可以归因于逻辑错误。以下是一些布尔代数的基本定理,为了简洁易读,真和假用1和0表示,AND用 * 号表示,OR用 + 号表示(从真值表可以看出AND和OR运算确实有点像乘法和加法运算),NOT用 ¬ 表示,变量 x、y、z的值可能是0也可能是1。

¬¬x = x

x * 0 = 0
x + 1 = 1

x * 1 = x
x + 0 = x

x * x = x
x + x = x

x * ¬x = 0
x + ¬x = 1

x * y = y * x
x + y = y + x

x * (y * z) = (x * y) * z
x + (y + z) = (x + y) + z

x * (y + z) = x * y + x * z
x + y*z = (x+y) * (x+z)

x + x*y = x
x * (x+y) = x

x * y + x * ¬y = x
(x + y) * (x + ¬y) = x

¬(x * y) = ¬x + ¬y
¬(x + y) = ¬x * ¬y

x + ¬x * y = x + y
x * (¬x + y) = x * y

x * y + ¬x * z + y * z = x * y + ¬x * z
(x + y) * (¬x + z) * (y + z)=(x + y) * (¬x + z)

除了第1行之外,这些公式都是每两行一组的,每组的两个公式就像对联一样:把其中一个公式中的 * 换成 ++ 换成 * 、 0 换成 1 、 1 换成 0 ,就变成了与它对称的另一个公式。这些定理都可以通过真值表证明,更多细节可参考有关数字逻辑的教材,例如 [数字逻辑基础]。我们将在本节的练习题中强化训练对这些定理的理解。

注解

Zombie110year

布尔代数是数学中 集合论 的内容.

目前为止介绍的这些运算符的优先级顺序是:

!, * / %, + -, > < <= >=, == !=, &&, ||, =

写一个控制表达式很可能同时用到这些运算符中的多个,如果记不清楚运算符的优先级一定要 多套括号 。我们将在 运算符总结 总结C语言所有运算符的优先级和结合性。

习题

1、把代码段

if (x > 0 && x < 10)
    ;
else
    printf("x is out of range.\n");

改写成下面这种形式:

if (____ || ____)
    printf("x is out of range.\n");

____ 应该怎么填?

2、把代码段:

if (x > 0)
    printf("Test OK!\n");
else if (x <= 0 && y > 0)
    printf("Test OK!\n");
else
    printf("Test failed!\n");

改写成下面这种形式:

if (____ && ____)
    printf("Test failed!\n");
else
    printf("Test OK!\n");

____ 应该怎么填?

注解

Zombie110year

if (x > 0)
    printf("Test OK!\n");
else { // 进入这里, x <= 0 是肯定的
    if (x <= 0 && y > 0)
        printf("Test OK!\n");
    else
        printf("Test failed!\n"); // y <= 0 && x <= 0
}

3、有这样一段代码:

if (x > 1 && y != 1) {
    ...
} else if (x < 1 && y != 1) {
    ...
} else {
    ...
}

要进入最后一个 else, xy 需要满足条件 ____ || ____ 。这里应该怎么填?

注解

Zombie110year

前两个条件都没有满足.

  1. 进入第一个 else: ¬(A * B) = ¬A + ¬B, 全集缩小为 \(\Omega = x \le 1 \cup y = 1\).
  2. 进入第二个 else: ¬(A * B) = ¬A + ¬B, 不满足的条件是, x >= 1 || y = 1, 从全集取并集, 应该有 \(x = 1 \cup y = 1\)

答案是 x == 1 || y == 1

4、以下哪一个 if 判断条件是多余的可以去掉?这里所谓的“多余”是指,某种情况下如果本来应该打印 Test OK!,去掉这个多余条件后仍然打印 Test OK! ,如果本来应该打印 Test failed! ,去掉这个多余条件后仍然打印 Test failed!

if (x < 3 && y > 3)
    printf("Test OK!\n");
else if (x >= 3 && y >= 3) // x >= 3 || y <= 3
    printf("Test OK!\n");
else if (z > 3 && x >= 3) // 任意 x 都不满足, y < 3
    printf("Test OK!\n");
else if (z <= 3 && y >= 3) // 任意 x,y 都不满足, z <= 3
    printf("Test OK!\n");
else // 永远不会输出
    printf("Test failed!\n");
[1]C99也定义了专门的布尔类型_Bool,但目前没有被广泛使用。

switch语句

switch 语句可以产生具有多个分支的控制流程。它的格式是:

switch (控制表达式) {
case 常量表达式: 语句列表
case 常量表达式: 语句列表
...
default: 语句列表
}

例如以下程序根据传入的参数 1~7 分别打印 Monday~Sunday:

switch 语句

如果传入的参数是 2,则从 case 2 分支开始执行,先是打印相应的信息,然后遇到 break; 语句,它的作用是跳出整个 switch 语句块。C语言规定各 case 分支的常量表达式必须互不相同,如果控制表达式不等于任何一个常量表达式,则从 default 分支开始执行,通常把 default 分支写在最后,但不是必须的。使用 switch 语句要注意几点:

  1. case 后面跟表达式的必须是常量表达式,这个值和全局变量的初始值一样必须在编译时计算出来。
  2. if/else语句 讲过浮点型不适合做精确比较,所以C语言规定 case 后面跟的必须是整型常量表达式。
  3. 进入 case 后如果没有遇到 break 语句就会一直往下执行,后面其它 case 或 default 分支的语句也会被执行到,直到遇到 break,或者执行到整个 switch 语句块的末尾。通常每个 case 后面都要加上 break 语句,但有时会故意不加 break 来利用这个特性,例如:
缺break的switch语句

switch 语句不是必不可缺的,显然可以用一组 if ... else if ... else if ... else ... 代替,但是一方面用 switch 语句会使代码更清晰,另一方面,有时候编译器会对 switch 语句进行整体优化,使它比等价的 if/else 语句所生成的指令效率更高。

注解

Zombie110year

一般来讲, 超过三个以上的 if-else 分支就可以考虑重构成 switch, 如果分支更多, 那么可以考虑做成一个 条件映射表 (Map) TODO:

深入理解函数

return语句

之前我们一直在 main 函数中使用 return 语句,现在是时候全面深入地学习一下了。在有返回值的函数中, return 语句的作用是提供整个函数的返回值,并结束当前函数返回到调用它的地方。在没有返回值的函数中也可以使用 return 语句,例如当检查到一个错误时提前结束当前函数的执行并返回:

#include <math.h>
void print_logarithm(double x)
{
    if (x <= 0.0) {
        printf("Positive numbers only, please.\n");
        return;
    }
    printf("The log of x is %f", log(x));
}

这个函数首先检查参数 x 是否大于 0,如果 x 不大于 0 就打印错误提示,然后提前结束函数的执行返回到调用者,只有当 x 大于 0 时才能求对数,在打印了对数结果之后到达函数体的末尾,自然地结束执行并返回。注意,使用数学函数 log 需要包含头文件 math.h ,由于 x 是浮点数,应该与同类型的数做比较,所以写成 0.0

if/else语句 中我们定义了一个检查奇偶性的函数,如果是奇数就打印 x is odd. ,如果是偶数就打印 x is even. 。事实上这个函数并不十分好用,我们定义一个检查奇偶性的函数往往不是为了打印两个字符串就完了,而是为了根据奇偶性的不同分别执行不同的后续动作。我们可以把它改成一个返回布尔值的函数:

int is_even(int x)
{
    if (x % 2 == 0)
        return 1;
    else
        return 0;
}

有些人喜欢写成 return(1); 这种形式也可以,表达式外面套括号表示改变运算符优先级,在这里不起任何作用。我们可以这样调用这个函数:

int i = 19;
if (is_even(i)) {
    /* do something */
} else {
    /* do some other thing */
}

返回布尔值的函数是一类非常有用的函数,在程序中通常充当控制表达式,函数名通常带有 isif 等表示判断的词,这类函数也叫做谓词(Predicate) 。 is_even 这个函数写得有点啰嗦, x % 2 这个表达式本来就有 0 值或非 0 值,直接把这个值当作布尔值返回就可以了:

int is_even(int x)
{
    return !(x % 2);
}

函数的返回值应该这样理解:函数返回一个值相当于定义一个和返回值类型相同的临时变量并用 return 后面的表达式来初始化。例如上面的函数调用相当于这样的过程:

int tmp = !(x % 2);
// 函数退出,局部变量x的存储空间释放;
if (tmp) { /* 临时变量用完就释放 */
    /* do something */
} else {
    /* do some other thing */
}

当 if 语句对函数的返回值做判断时,函数已经退出,局部变量 x 已经释放,所以不可能在这时候才计算表达式 !(x % 2) 的值,表达式的值必然是事先计算好了存在一个临时变量里的,然后函数退出,局部变量释放,if 语句对这个临时变量的值做判断。注意,虽然函数的返回值可以看作是一个临时变量,但我们只是读一下它的值,读完值就释放它,而不能往它里面存新的值,换句话说, 函数的返回值不是左值,或者说函数调用表达式不能做左值 ,因此下面的赋值语句是非法的:

is_even(20) = 1;

形参和实参 中讲过,C语言的传参规则是 Call by Value,按值传递,现在我们知道返回值也是按值传递的,即便返回语句写成 return x; ,返回的也是变量 x 的值,而非变量 x 本身,因为变量 x 马上就要被释放了。

在写带有 return 语句的函数时要小心检查所有的代码路径(Code Path) 。有些代码路径在任何条件下都执行不到,这称为Dead Code ,例如把 &&|| 运算符记混了(据我了解初学者犯这个低级错误的不在少数),写出如下代码:

void foo(int x, int y)
{
    if (x >= 0 || y >= 0) {
        printf("both x and y are positive.\n");
        return;
    } else if (x < 0 || y < 0) {
        printf("both x and y are negetive.\n");
        return;
    }
    printf("x has a different sign from y.\n");
}

最后一行 printf 永远都没机会被执行到,是一行Dead Code。有Dead Code就一定有Bug,你写的每一行代码都是想让程序在某种情况下去执行的,你不可能故意写出一行永远不会被执行的代码,如果程序在任何情况下都不会去执行它,说明跟你预想的不一样,要么是你对所有可能的情况分析得不正确,也就是逻辑错误,要么就是像上例这样的笔误,语义错误。还有一些时候,对程序中所有可能的情况分析得不够全面将导致漏掉一些代码路径,例如:

int absolute_value(int x)
{
    if (x < 0) {
        return -x;
    } else if (x > 0) {
        return x;
    }
}

这个函数被定义为返回 int ,就应该在任何情况下都返回 int ,但是上面这个程序在 x==0 时安静地退出函数,什么也不返回,C语言对于这种情况会返回什么结果是未定义的,通常返回不确定的值,等学到 函数调用 你就知道为什么了。另外注意这个例子中把 - 号当负号用而不是当减号用,事实上 + 号也可以这么用。正负号是单目运算符,而加减号是双目运算符,正负号的优先级和逻辑非运算符相同,比加减的优先级要高。

以上两段代码都不会产生编译错误,编译器只做语法检查和最简单的语义检查,而不检查程序的逻辑 [1]。虽然到现在为止你见到了各种各样的编译器错误提示,也许你已经十分讨厌编译器报错了,但很快你就会认识到,如果程序中有错误编译器还不报错,那一定比报错更糟糕。比如上面的绝对值函数,在你测试的时候运行得很好,也许是你没有测到 x==0 的情况,也许刚好在你的环境中 x==0 时返回的不确定值就是 0,然后你放心地把它集成到一个数万行的程序之中。然后你把这个程序交给用户,起初的几天里相安无事,之后每过几个星期就有用户报告说程序出错,但每次出错的现象都不一样,而且这个错误很难复现,你想让它出现时它就不出现,在你毫无防备时它又突然冒出来了。然后你花了大量的时间在数万行的程序中排查哪里错了,几天之后终于幸运地找到了这个函数的Bug,这时候你就会想,如果当初编译器能报个错多好啊!所以,如果编译器报错了,不要责怪编译器太过于挑剔,它帮你节省了大量的调试时间。另外,在 math.h 中有一个 fabs 函数就是求绝对值的,我们通常不必自己写绝对值函数。

习题

1、编写一个布尔函数 int is_leap_year(int year) ,判断参数 year 是不是闰年。如果某年份能被4整除,但不能被100整除,那么这一年就是闰年,此外,能被400整除的年份也是闰年。

2、编写一个函数 double myround(double x) ,输入一个小数,将它四舍五入。例如 myround(-3.51) 的值是 -4.0myround(4.49) 的值是 4.0。可以调用math.h中的库函数 ceilfloor 实现这个函数。

[1]有的代码路径没有返回值的问题编译器是可以检查出来的,如果编译时加-Wall选项会报警告。

增量式开发

目前为止你看到了很多示例代码,也在它们的基础上做了很多改动并在这个过程中巩固所学的知识。但是如果从头开始编写一个程序解决某个问题,应该按什么步骤来写呢?本节提出一种增量式(Incremental) 开发的思路,很适合初学者。

现在问题来了:我们要编一个程序求圆的面积,圆的半径以两个端点的座标 \((x_1, y_1)\)\((x_2, y_2)\) 给出。首先分析和分解问题,把大问题分解成小问题,再对小问题分别求解。这个问题可分为两步:

  1. 由两个端点座标求半径的长度,我们知道平面上两点间距离的公式是:

    \[r = \sqrt{(x_2 - x_1)^2 + (y_2-y_1)^2}\]

    括号里的部分都可以用我们学过的C语言表达式来表示,求平方根可以用 math.h 中的 sqrt 函数,因此这个小问题全部都可以用我们学过的知识解决。这个公式可以实现成一个函数,参数是两点的座标,返回值是 distance

  2. 上一步算出的距离是圆的半径,已知圆的半径之后求面积的公式是:

    \[A = \pi r^2\]

    也可以用我们学过的C语言表达式来解决,这个公式也可以实现成一个函数,参数是 radius ,返回值是 area

首先编写 distance 这个函数,我们已经明确了它的参数是两点的座标,返回值是两点间距离,可以先写一个简单的函数定义:

double distance(double x1, double y1, double x2, double y2)
{
    return 0.0;
}

初学者写到这里就已经不太自信了:这个函数定义写得对吗?虽然我是按我理解的语法规则写的,但书上没有和这个一模一样的例子,万一不小心遗漏了什么呢?既然不自信就不要再往下写了,没有一个平稳的心态来写程序很可能会引入Bug。所以在函数定义中插一个 return 0.0 立刻结束掉它,然后立刻测试这个函数定义得有没有错:

int main(void)
{
    printf("distance is %f\n", distance(1.0, 2.0, 4.0, 6.0));
    return 0;
}

编译,运行,一切正常。这时你就会建立起信心了:既然没问题,就不用管它了,继续往下写。在测试时给这个函数的参数是 (1.0, 2.0)(4.0, 6.0) ,两点的 x 坐标距离是 3.0y 坐标距离是 4.0,因此两点间距离应该是 5.0 ,你必须事先知道正确答案是 5.0 ,这样你才能测试程序计算的结果对不对。当然,现在函数还没实现,计算结果肯定是不对的。现在我们再往函数里添一点代码:

double distance(double x1, double y1, double x2, double y2)
{
    double dx = x2 - x1;
    double dy = y2 - y1;
    printf("dx is %f\ndy is %f\n", dx, dy);
    return 0.0;
}

如果你不确定 dxdy 这样初始化行不行,那么就此打住,在函数里插一条打印语句把 dxdy 的值打出来看看。把它和上面的 main 函数一起编译运行,由于我们事先知道结果应该是 3.04.0 ,因此能够验证程序算得对不对。一旦验证无误,函数里的这句打印就可以撤掉了,像这种打印语句,以及我们用来测试的 main 函数,都起到了类似脚手架(Scaffold) 的作用:在盖房子时很有用,但它不是房子的一部分,房子盖好之后就可以拆掉了。房子盖好之后可能还需要维修、加盖、翻新,又要再加上脚手架,这很麻烦,要是当初不用拆就好了,可是不拆不行,不拆多难看啊。写代码却可以有一个更高明的解决办法:把 Scaffolding 的代码注释掉。

double distance(double x1, double y1, double x2, double y2)
{
    double dx = x2 - x1;
    double dy = y2 - y1;
    /* printf("dx is %f\ndy is %f\n", dx, dy); */
    return 0.0;
}

这样如果以后出了新的Bug又需要跟踪调试时,还可以把这句重新加进代码中使用。两点 x 坐标距离和 y 坐标距离都没问题了,下面求它们的平方和:

double distance(double x1, double y1, double x2, double y2)
{
    double dx = x2 - x1;
    double dy = y2 - y1;
    double dsquared = dx * dx + dy * dy;
    /* printf("dsquared is %f\n", dsquared); */
    return 0.0;
}

然后再编译、运行,看看是不是得 25.0 。这样的增量式开发非常适合初学者,每写一行代码都编译运行,确保没问题了再写一下行,一方面在写代码时更有信心,另一方面也方便了调试:总是有一个先前的正确版本做参照,改动之后如果出了问题,几乎可以肯定就是刚才改的那行代码出的问题,这样就避免了必须从很多行代码中查找分析到底是哪一行出的问题。在这个过程中 printf 功不可没,你怀疑哪一行代码有问题,就插一个 printf 进去看看中间的计算结果,任何错误都可以通过这个办法找出来。以后我们会介绍程序调试工具 gdb ,它提供了更强大的调试功能帮你分析更隐蔽的错误。但即使有了gdb, printf 这个最原始的办法仍然是最直接、最有效的。最后一步,我们完成这个函数:

distance函数
#include <math.h>
#include <stdio.h>

double distance(double x1, double y1, double x2, double y2)
{
    double dx = x2 - x1;
    double dy = y2 - y1;
    double dsquared = dx * dx + dy * dy;
    double result = sqrt(dsquared);

    return result;
}

int main(void)
{
    printf("distance is %f\n", distance(1.0, 2.0, 4.0, 6.0));
    return 0;
}

然后编译运行,看看是不是得 5.0 。随着编程经验越来越丰富,你可能每次写若干行代码再一起测试,而不是像现在这样每写一行就测试一次,但不管怎么样,增量式开发的思路是很有用的,它可以帮你节省大量的调试时间,不管你有多强,都不应该一口气写完整个程序再编译运行,那几乎是一定会有Bug的,到那时候再找Bug就难了。

这个程序中引入了很多临时变量: dxdydsquaredresult ,如果你有信心把整个表达式一次性写好,也可以不用临时变量:

double distance(double x1, double y1, double x2, double y2)
{
    return sqrt((x2-x1) * (x2-x1) + (y2-y1) * (y2-y1));
}

这样写简洁得多了。但如果写错了呢?只知道是这一长串表达式有错,根本不知道错在哪,而且整个函数就一个语句,插 printf 都没地方插。所以用临时变量有它的好处,使程序更清晰,调试更方便,而且有时候可以避免不必要的计算,例如上面这一行表达式要把 (x2-x1) 计算两遍,如果算完 (x2-x1) 把结果存在一个临时变量 dx 里,就不需要再算第二遍了。

接下来编写 area 这个函数:

double area(double radius)
{
    return 3.1416 * radius * radius;
}

给出两点的座标求距离,给出半径求圆的面积,这两个子问题都解决了,如何把它们组合起来解决整个问题呢?给出半径的两端点座标 (1.0, 2.0)(4.0, 6.0) 求圆的面积,先用 distance 函数求出半径的长度,再把这个长度传给 area 函数:

double radius = distance(1.0, 2.0, 4.0, 6.0);
double result = area(radius);

也可以这样:

double result = area(distance(1.0, 2.0, 4.0, 6.0));

我们一直把“给出半径的两端点座标求圆的面积”这个问题当作整个问题来看,如果它也是一个更大的程序当中的子问题呢?我们可以把先前的两个函数组合起来做成一个新的函数以便日后使用:

double area_point(double x1, double y1, double x2, double y2)
{
    return area(distance(x1, y1, x2, y2));
}

还有另一种组合的思路,不是把 distancearea 两个函数调用组合起来,而是把那两个函数中的语句组合到一起:

double area_point(double x1, double y1, double x2, double y2)
{
    double dx = x2 - x1;
    double dy = y2 - y1;
    double radius = sqrt(dx * dx + dy * dy);

    return 3.1416 * radius * radius;
}

这样组合是不理想的。这样组合了之后,原来写的 distancearea 两个函数还要不要了呢?如果不要了删掉,那么如果有些情况只需要求两点间的距离,或者只需要给定半径长度求圆的面积呢? area_point 把所有语句都写在一起,太不灵活了,满足不了这样的需要。如果保留 distancearea 同时也保留这个 area_point 怎么样呢? area_pointdistance 有相同的代码,一旦在 distance 函数中发现了Bug,或者要升级 distance 这个函数采用更高的计算精度,那么不仅要修改 distance ,还要记着修改 area_point ,同理,要修改 area 也要记着修改 area_point ,维护重复的代码是非常容易出错的,在任何时候都要尽量避免。因此,尽可能复用(Reuse)以前写的代码,避免写重复的代码。封装就是为了复用,把解决各种小问题的代码封装成函数,在解决第一个大问题时可以用这些函数,在解决第二个大问题时可以复用这些函数。

解决问题的过程是把大的问题分成小的问题,小的问题再分成更小的问题,这个过程在代码中的体现就是函数的分层设计(Stratify) 。 distancearea 是两个底层函数,解决一些很小的问题,而 area_point 是一个上层函数,上层函数通过调用底层函数来解决更大的问题,底层和上层函数都可以被更上一层的函数调用,最终所有的函数都直接或间接地被 main 函数调用。如下图所示:

函数的分层设计

参见

软件工程中, 是怎么进行测试的呢? TODO:

递归

如果定义一个概念需要用到这个概念本身,我们称它的定义是递归的(Recursive) 。例如:

frabjuous
an adjective used to describe something that is frabjuous.

这只是一个玩笑,如果你在字典上看到这么一个词条肯定要怒了。然而数学上确实有很多概念是用它自己来定义的,比如 n 的阶乘(Factorial) 是这样定义的: \(n! = n \times (n-1)!\) 。如果这样就算定义完了,恐怕跟上面那个词条有异曲同工之妙了: n-1 的阶乘是什么?是 n-1 乘以 n-2 的阶乘。那 n-2 的阶乘又是什么?这样下去永远也没完。因此需要定义一个最关键的基础条件(Base Case) :0的阶乘等于1。

\[\begin{split}\begin{cases} 0! = 1 \\ n! = n \times (n-1)! \\ \end{cases}\end{split}\]

因此, 3! = 3 * 2!2! = 2 * 1!1! = 1 * 0! = 1 * 1 = 1 ,正因为有了Base Case,才不会永远没完地数下去,知道了 1!=1 我们再反过来算回去, 2! = 2 * 1! = 2 * 1 = 23! = 3 * 2! = 3 * 2 = 6 。下面用程序来完成这一计算过程,我们要写一个计算阶乘的函数 factorial ,先把Base Case这种最简单的情况写进去:

int factorial(int n)
{
    if (n == 0)
        return 1;
}

如果参数 n 不是 0 应该 return 什么呢?根据定义,应该 return n*factorial(n-1); ,为了下面的分析方便,我们引入几个临时变量把这个语句拆分一下:

int factorial(int n)
{
    if (n == 0)
        return 1;
    else {
        int recurse = factorial(n-1);
        int result = n * recurse;
        return result;
    }
}

factorial 这个函数居然可以 自己调用自己 ?是的。自己直接或间接调用自己的函数称为 递归函数 。这里的 factorial 是直接调用自己,有些时候函数 A 调用函数 B ,函数 B 又调用函数 A ,也就是函数 A 间接调用自己,这也是递归函数。如果你觉得迷惑,可以把 factorial(n-1) 这一步看成是在调用另一个函数--另一个有着相同函数名和相同代码的函数,调用它就是跳到它的代码里执行,然后再返回 factorial(n-1) 这个调用的下一步继续执行。我们以 factorial(3) 为例分析整个调用过程,如下图所示:

factorial(3)的调用过程

图中用实线箭头表示调用,用虚线箭头表示返回,右侧的框表示在调用和返回过程中各层函数调用的存储空间变化情况。

  1. main() 有一个局部变量 result ,用一个框表示。
  2. 调用 factorial(3) 时要分配参数和局部变量的存储空间,于是在 main() 的下面又多了一个框表示 factorial(3) 的参数和局部变量,其中 n 已初始化为 3
  3. factorial(3) 又调用 factorial(2) ,又要分配 factorial(2) 的参数和局部变量,于是在 main()factorial(3) 下面又多了一个框。 全局变量、局部变量和作用域 讲过,每次调用函数时分配参数和局部变量的存储空间,退出函数时释放它们的存储空间。 factorial(3)factorial(2) 是两次不同的调用, factorial(3) 的参数 nfactorial(2) 的参数 n 各有各的存储单元,虽然我们写代码时只写了一次参数 n ,但运行时却是两个不同的参数 n 。并且由于调用 factorial(2)factorial(3) 还没退出,所以两个函数调用的参数 n 同时存在,所以在原来的基础上多画一个框。
  4. 依此类推,请读者对照着图自己分析整个调用过程。读者会发现这个过程和前面我们用数学公式计算 \(3!\) 的过程是一样的,都是先一步步展开然后再一步步收回去。

我们看上图右侧存储空间的变化过程,随着函数调用的层层深入,存储空间的一端逐渐增长,然后随着函数调用的层层返回,存储空间的这一端又逐渐缩短,并且每次访问参数和局部变量时只能访问这一端的存储单元,而不能访问内部的存储单元,比如当 factorial(2) 的存储空间位于末端时,只能访问它的参数和局部变量,而不能访问 factorial(3)main() 的参数和局部变量。具有这种性质的数据结构称为 堆栈 (Stack) ,随着函数调用和返回而不断变化的这一端称为栈顶,每个函数调用的参数和局部变量的存储空间(上图的每个小方框)称为一个栈帧(Stack Frame) 。操作系统为程序的运行预留了一块栈空间,函数调用时就在这个栈空间里分配栈帧,函数返回时就释放栈帧。

在写一个递归函数时,你如何证明它是正确的?像上面那样跟踪函数的调用和返回过程算是一种办法,但只是 factorial(3) 就已经这么麻烦了,如果是 factorial(100) 呢?虽然我们已经证明了 factorial(3) 是正确的,因为它跟我们用数学公式计算的过程一样,结果也一样,但这不能代替 factorial(100) 的证明,你怎么办?别的函数你可以跟踪它的调用过程去证明它的正确性,因为每个函数只调用一次就返回了,但是对于递归函数,这么跟下去只会跟得你头都大了。事实上并不是每个函数调用都需要钻进去看的。我们在调用 printf 时没有钻进去看它是怎么打印的,我们只是相信它能打印,能正确完成它的工作,然后就继续写下面的代码了。在上一节中,我们写了 distancearea 函数,然后立刻测试证明了这两个函数是正确的,然后我们写 area_point 时调用了这两个函数:

return area(distance(x1, y1, x2, y2));

在写这一句的时候,我们需要钻进 distancearea 函数中去走一趟才知道我们调用得是否正确吗?不需要,因为我们已经相信这两个函数能正确工作了,也就是相信把座标传给 distance 它就能返回正确的距离,把半径传给 area 它就能返回正确的面积,因此调用它们去完成另外一件工作也应该是正确的。这种“相信”称为Leap of Faith ,首先相信一些结论,然后再用它们去证明另外一些结论。

在写 factorial(n) 的代码时写到这个地方:

...
int recurse = factorial(n-1);
int result = n * recurse;
...

这时,如果我们相信 factorial(n-1) 是正确的,也就是相信传给它 n-1 它就能返回 (n-1)! ,那么 recurse 就是 (n-1)! ,那么 result 就是 n*(n-1)! ,也就是 n! ,这正是我们要返回的 factorial(n) 的结果。当然这有点奇怪:我们还没写完 factorial 这个函数,凭什么要相信 factorial(n-1) 是正确的?可Leap of Faith本身就是Leap(跳跃)的,不是吗?如果你相信你正在写的递归函数是正确的,并调用它,然后在此基础上写完这个递归函数,那么它就会是正确的,从而值得你相信它正确。

这么说好像有点儿玄,我们从数学上严格证明一下 factorial 函数的正确性。刚才说了, factorial(n) 的正确性依赖于 factorial(n-1) 的正确性,只要后者正确,在后者的结果上乘个n返回这一步显然也没有疑问,那么我们的函数实现就是正确的。因此要证明 factorial(n) 的正确性就是要证明 factorial(n-1) 的正确性,同理,要证明 factorial(n-1) 的正确性就是要证明 factorial(n-2) 的正确性,依此类推下去,最后是:要证明 factorial(1) 的正确性就是要证明 factorial(0) 的正确性。而 factorial(0) 的正确性不依赖于别的函数调用,它就是程序中的一个小的分支 return 1; ,这个 1 是我们根据阶乘的定义写的,肯定是正确的,因此 factorial(1) 的实现是正确的,因此 factorial(2) 也正确,依此类推,最后 factorial(n) 也是正确的。其实这就是在中学时学的 数学归纳法 (Mathematical Induction) ,用数学归纳法来证明只需要证明两点:Base Case正确,递推关系正确。写递归函数时一定要记得写Base Case,否则即使递推关系正确,整个函数也不正确。如果 factorial 函数漏掉了Base Case:

int factorial(int n)
{
    int recurse = factorial(n-1);
    int result = n * recurse;
    return result;
}

那么这个函数就会永远调用下去,直到操作系统为程序预留的栈空间耗尽程序崩溃(段错误)为止,这称为无穷递归(Infinite recursion) 。

到目前为止我们只学习了全部C语法的一个小的子集,但是现在应该告诉你:这个子集是完备的,它本身就可以作为一门编程语言了,以后还要学习很多C语言特性,但全部都可以用已经学过的这些特性来代替。也就是说,以后要学的C语言特性会使代码写起来更加方便,但不是必不可少的,现在学的这些已经完全覆盖了 程序和编程语言 讲的五种基本指令了。有的读者会说循环还没讲到呢,是的,循环在下一章才讲,但有一个重要的结论就是 递归和循环是等价的 ,用循环能做的事用递归都能做,反之亦然 ,事实上有的编程语言(比如某些LISP实现)只有递归而没有循环。计算机指令能做的所有事情就是数据存取、运算、测试和分支、循环(或递归),在计算机上运行高级语言写的程序最终也要翻译成指令,指令做不到的事情高级语言写的程序肯定也做不到,虽然高级语言有丰富的语法特性,但也只是比指令写起来更方便而已,能做的事情是一样多的。那么,为什么计算机要设计成这样?在设计时怎么想到计算机应该具备这几样功能,而不是更多或更少的功能?这些要归功于早期的计算机科学家,例如Alan Turing,他们在计算机还没有诞生的年代就从数学理论上为计算机的设计指明了方向。有兴趣的读者可以参考有关计算理论的教材,例如 [IATLC]

递归绝不只是为解决一些奇技淫巧的数学题 [2] 而想出来的招,它是计算机的精髓所在,也是编程语言的精髓所在。我们学习在C的语法时已经看到很多递归定义了,例如在 数学函数 讲过的语法规则中,“表达式”就是递归定义的:

表达式 → 表达式(参数列表)

参数列表 → 表达式, 表达式, …

再比如在 if 语句 讲过的语法规则中,“语句”也是递归定义的:

语句 → if (控制表达式) 语句

可见编译器在解析我们写的程序时一定也用了大量的递归,有关编译器的实现原理可参考 [Dragon Book]

习题

1、编写递归函数求两个正整数a和b的最大公约数(GCD,Greatest Common Divisor) ,使用Euclid算法:

如果a除以b能整除,则最大公约数是b。

否则,最大公约数等于b和a%b的最大公约数。

Euclid算法是很容易证明的,请读者自己证明一下为什么这么算就能算出最大公约数。最后,修改你的程序使之适用于所有整数,而不仅仅是正整数。

注解

Zombie110year

gcd.recursive.c
int gcd(int a, int b)
{
    int res = a % b;
    if (res) { // 当 res == 0 时, 表示整除, 但是作为条件表达式, 却表示 False
        return gcd(b, res); // res != 0 时执行
    } else {
        return b;
    }
}

2、编写递归函数求Fibonacci数列的第n项,这个数列是这样定义的:

\[\begin{split}\begin{cases} fib(0)=1 \\ fib(1)=1 \\ fib(n)=fib(n-1)+fib(n-2) \end{cases}\end{split}\]

注解

Zombie110year

fibonacci.recursive.c
int fibonacci(int n)
{
    if (n < 0) {
        return -1;
    } else if (n < 2) {
        return 1; // fib(0), fib(1)
    } else {
        return fibonacci(n - 1) + fibonacci(n - 2);
    }
}

上面两个看似毫不相干的问题之间却有一个有意思的联系:

Lamé定理
如果Euclid算法需要k步来计算两个数的GCD,那么这两个数之中较小的一个必然大于等于Fibonacci数列的第k项。

感兴趣的读者可以参考 [SICP] 第1.2节的简略证明。

[2]例如很多编程书都会举例的汉诺塔问题,本书不打算再重复这个题目了。

循环语句

while语句

递归 中,我们介绍了用递归求 n! 的方法,其实每次递归调用都在重复做同样一件事,就是把 n 乘到 (n-1)! 上然后把结果返回。虽说是重复,但每次做都稍微有一点区别( n 的值不一样),这种每次都有一点区别的重复工作称为迭代(Iteration) 。我们使用计算机的主要目的之一就是让它做重复迭代的工作,因为把一件工作重复做成千上万次而不出错正是计算机最擅长的,也是人类最不擅长的。虽然迭代用递归来做就够了,但C语言提供了循环语句使迭代程序写起来更方便。例如 factorial 用 while 语句可以写成:

int factorial(int n)
{
    int result = 1;
    while (n > 0) {
        result = result * n;
        n = n - 1;
    }
    return result;
}

和 if 语句类似, while 语句由一个控制表达式和一个子语句组成,子语句可以是由若干条语句组成的语句块。

语句 → while (控制表达式) 语句

如果控制表达式的值为真,子语句就被执行,然后再次测试控制表达式的值,如果还是真,就把子语句再执行一遍,再测试控制表达式的值……这种控制流程称为循环(Loop) ,子语句称为循环体。如果某次测试控制表达式的值为假,就跳出循环执行后面的 return 语句,如果第一次测试控制表达式的值就是假,那么直接跳到 return 语句,循环体一次都不执行。

变量 result 在这个循环中的作用是累加器(Accumulator) ,把每次循环的中间结果累积起来,循环结束后得到的累积值就是最终结果,由于这个例子是用 乘法 来累积的,所以 result 的初值是 1 ,如果用 加法 累积则 result 的初值应该是 0 。变量 n 是循环变量(Loop Variable) ,每次循环要改变它的值,在控制表达式中要测试它的值,这两点合起来起到控制循环次数的作用,在这个例子中 n 的值是递减的,也有些循环采用递增的循环变量。这个例子具有一定的典型性,累加器和循环变量这两种模式在循环中都很常见。

可见,递归能解决的问题用循环也能解决,但解决问题的思路不一样。用递归解决这个问题靠的是递推关系 \(n! = n \times (n-1)!\) ,用循环解决这个问题则更像是把这个公式展开了: \(n! = n \times (n-1) \times \cdots \times 2 \times 1\) 。把公式展开了理解会更直观一些,所以有些时候循环程序比递归程序更容易理解。但也有一些公式要展开是非常复杂的甚至是不可能的,反倒是递推关系更直观一些,这种情况下递归程序比循环程序更容易理解。此外还有一点不同:看 factorial(3)的调用过程 ,在整个递归调用过程中,虽然分配和释放了很多变量,但所有变量都只在初始化时赋值,没有任何变量的值发生过改变,而上面的循环程序则通过对 nresult 这两个变量多次赋值来达到同样的目的。前一种思路称为 函数式编程 (Functional Programming) ,而后一种思路称为 命令式编程 (Imperative Programming) ,这个区别类似于 程序和编程语言 讲的 Declarative 和 Imperative 的区别。函数式编程的“函数”类似于数学函数的概念,回顾一下 数学函数 所讲的,数学函数是没有Side Effect的,而C语言的函数可以有Side Effect,比如在一个函数中修改某个全局变量的值就是一种Side Effect。 全局变量、局部变量和作用域 指出,全局变量被多次赋值会给调试带来麻烦,如果一个函数体很长,控制流程很复杂,那么局部变量被多次赋值也会有同样的问题。因此,不要以为“变量可以多次赋值”是天经地义的,有很多编程语言可以完全采用函数式编程的模式,避免Side Effect,例如LISP、Haskell、Erlang等。用C语言编程主要还是采用Imperative的模式,但要记住,给变量多次赋值时要格外小心,在代码中多次读写同一变量应该以一种一致的方式进行。所谓“一致的方式”是说应该有一套统一的规则,规定在一段代码中哪里会对某个变量赋值、哪里会读取它的值,比如在 errno与perror函数 会讲到访问errno的规则。

递归函数如果写得不小心就会变成无穷递归,同样道理,循环如果写得不小心就会变成无限循环(Infinite Loop) 或者叫死循环。如果 while 语句的控制表达式永远为真就成了一个死循环,例如 while (1) {...} 。在写循环时要小心检查你写的控制表达式有没有可能取值为假,除非你故意写死循环(有的时候这是必要的)。在上面的例子中,不管 n 一开始是几,每次循环都会把 n 减掉 1n 越来越小最后必然等于 0 ,所以控制表达式最后必然取值为假,但如果把 n = n - 1; 这句漏掉就成了死循环。有的时候是不是死循环并不是那么一目了然:

while (n != 1) {
    if (n % 2 == 0) {
        n = n / 2;
    } else {
        n = n * 3 + 1;
    }
}

如果 n 为正整数,这个循环能跳出来吗?循环体所做的事情是:如果 n 是偶数,就把 n 除以 2 ,如果 n 是奇数,就把 n 乘 3 加 1 。一般来说循环变量要么递增要么递减,可是这个例子中的 n 一会儿变大一会儿变小,最终会不会变成 1 呢?可以找个数试试,例如一开始 n 等于 7 ,每次循环后 n 的值依次是: 7、22、11、34、17、52、26、13、40、20、10、5、16、8、4、2、1。最后 n 确实等于1了。读者可以再试几个数都是如此,但无论试多少个数也不能代替证明,这个循环有没有可能对某些正整数 n 是死循环呢?其实这个例子只是给读者提提兴趣,同时提醒读者写循环时要有意识地检查控制表达式。至于这个循环有没有可能是死循环,这是著名的 \(3x+1\) 问题,目前世界上还无人能证明。许多世界难题都是这样的:描述无比简单,连小学生都能看懂,但证明却无比困难。

习题

1、用循环解决 递归 的所有习题,体会递归和循环这两种不同的思路。

注解

Zombie110year

gcd.loop.c
int gcd(int a, int b)
{
    int res, tmp;
    while (res = a % b) { // res = a % b 表达式的值为 最后运算得到的 res 的值.
        a = b;
        b = res;
    } // 当 res == 0 时, 循环中断
    return b;
}
fibonacci.loop.c
int fibonacci(int n)
{
    int j = 0; // 从 0 开始, 假设为 fib(-1)
    int i = 1; // fib(0) == 1
    int tmp; // 用于交换 i, j 变化前值的临时变量
    /* fib(j) fib(i) fib(n) */
    while (n > 0) {
        tmp = j;
        j = i;
        i += tmp;
        n--;
    }
    /**
     * 最后应当输出 i 还是 j?
     *
     * 当 n 为 0, 则不会循环, 对应的值为 1, 正是 i 的初始值. 由数学归纳法,
     * 应输出 i.
     **/
    return i;
}

2、编写程序数一下 1 到 100 的所有整数中出现多少次数字 9 。在写程序之前先把这些问题考虑清楚:

  1. 这个问题中的循环变量是什么?
  2. 这个问题中的累加器是什么?用加法还是用乘法累积?
  3. if/else语句 的习题 1 写过取一个整数的个位和十位的表达式,这两个表达式怎样用到程序中?

注解

Zombie110year

const int min = 1;
const int max = 100;
const int target = 9;
const int base = 10;
// 到时候把参数一换, 不就能计算任意情况了?
int count9(void)
{
    int count = 0;
    int whole, last; // 整个数, 最后一位, 作为临时变量
    for (int i = min; i <= max; i++) {
        whole = i;
        while (whole) { // 依次核对每一位数
            last = whole % base; // 取个位
            if (last == target) {
                count++;
            }
            whole /= base; // 裁剪最后一位
        }
    }
    return count;
}

do/while语句

do/while语句的语法是:

语句 → do 语句 while (控制表达式);

while语句先测试控制表达式的值再执行循环体,而 do/while 语句先执行循环体再测试控制表达式的值。如果控制表达式的值一开始就是假,while 语句的循环体一次都不执行,而 do/while 语句的循环体仍然要执行一次再跳出循环。其实只要有 while 循环就足够了, do/while 循环和后面要讲的 for 循环都可以改写成 while 循环,只不过有些情况下用 do/while 或 for 循环写起来更简便,代码更易读。上面的 factorial 也可以改用 do/while 循环来写:

int factorial(int n)
{
    int result = 1;
    int i = 1;
    do {
        result = result * i;
        i = i + 1;
    } while (i <= n);

    return result;
}

写循环一定要注意循环即将结束时控制表达式的临界条件是否准确,上面的循环结束条件如果写成 i < n 就错了,当 i == n 时跳出循环,最后的结果中就少乘了一个 n 。虽然变量名应该尽可能起得有意义一些,不过用 ijk 给循环变量起名是很常见的。

注解

Zombie110year

大多数循环控制变量都是 “用完就丢” 的. 如果遇到一个循环变量在多个地方使用, 那么就得注意它的命名了.

for语句

前两节我们在 while 和 do/while 循环中使用循环变量,其实使用循环变量最见的是 for 循环这种形式。 for 语句的语法是:

for (控制表达式1; 控制表达式2; 控制表达式3) 语句

如果不考虑循环体中包含 continue; 语句的情况(稍后介绍 continue 语句),这个 for 循环等价于下面的 while 循环:

{
    控制表达式1;
    while (控制表达式2) {
        语句
        控制表达式3;
    }
}

从这种等价形式来看,控制表达式 1 和 3 都可以为空,但控制表达式 2 是必不可少的,例如 for (;1;) {...} 等价于 while (1) {...} 死循环。C语言规定,如果控制表达式 2 为空,则认为控制表达式 2 的值为真,因此死循环也可以写成 for (;;) {...}

上一节 do/while 循环的例子可以改写成 for 循环:

int factorial(int n)
{
    int result = 1;
    int i;
    for(i = 1; i <= n; ++i)
        result = result * i;
    return result;
}

其中 ++i 这个表达式相当于 i = i + 1 [1]++ 称为前缀自增运算符(Prefix Increment Operator) ,类似地, -- 称为前缀自减运算符(Prefix Decrement Operator) [2]--i 相当于 i = i - 1 。如果把 ++i 这个表达式看作一个函数调用,除了传入一个参数返回一个值(等于参数值加1)之外,还产生一个Side Effect,就是把变量 i 的值增加了1。

++-- 运算符也可以用在变量后面,例如 i++i-- ,为了和前缀运算符区别,这两个运算符称为后缀自增运算符(Postfix Increment Operator) 和后缀自减运算符(Postfix Decrement Operator) 。如果把 i++ 这个表达式看作一个函数调用,传入一个参数返回一个值,返回值就等于参数值(而不是参数值加1),此外也产生一个Side Effect,就是把变量 i 的值增加了 1 ,它和 ++i 的区别就在于返回值不同。同理, --i 返回减 1 之后的值,而 i-- 返回减 1 之前的值,但这两个表达式都产生同样的Side Effect,就是把变量 i 的值减了1。

使用 ++-- 运算符会使程序更加简洁,但也会影响程序的可读性, [K&R] 中的示例代码大量运用 ++-- 和其它表达式的组合使得代码非常简洁。为了让初学者循序渐进,在接下来的几章中 ++-- 运算符总是单独组成一个表达式而不跟其它表达式组合,从 排序与查找 开始将采用 [K&R] 的简洁风格。

我们看一个有意思的问题: a+++++b 这个表达式如何理解?应该理解成 ((a++)++) + b 还是 (a++) + (++b) ,还是 a + (++(++b)) 呢?应该按第一种方式理解。编译的过程分为 词法解析 和 语法解析 两个阶段,在 词法解析 阶段,编译器总是 从前到后找最长的合法Token 。把这个表达式从前到后解析,变量名 a 是一个Token,a后面有两个以上的 + 号,在C语言中一个 + 号是合法的Token(可以是加法运算符或正号),两个 + 号也是合法的Token(可以是自增运算符),根据最长匹配原则,编译器绝不会止步于一个 + 号,而一定会把两个 + 号当作一个Token。再往后解析仍然有两个以上的 + 号,所以又是一个 ++ 运算符。再往后解析只剩一个 + 号了,是加法运算符。再往后解析是变量名 b 。词法解析之后进入下一阶段语法解析, a 是一个表达式,表达式 ++ 还是表达式,表达式再 ++ 还是表达式,表达式再 +b 还是表达式,语法上没有问题。最后编译器会做一些基本的语义分析,这时就有问题了, ++ 运算符要求操作数能做左值, a 能做左值所以 a++ 没问题,但表达式 a++ 的值只能做右值,不能再 ++ 了,所以最终编译器会报错。

C99规定了一种新的 for 循环语法,在控制表达式 1 的位置可以有变量定义。例如上例的循环变量 i 可以只在 for 循环中定义:

int factorial(int n)
{
    int result = 1;
    for(int i = 1; i <= n; i++)
        result = result * i;
    return result;
}

如果这样定义,那么变量 i 只是 for 循环中的局部变量而不是整个函数的局部变量,相当于 if 语句 讲过的语句块中的局部变量,在循环结束后就不能再使用 i 这个变量了。这个程序用 gcc 编译要加上选项 -std=c99 。这种语法也是从 C++ 借鉴的,考虑到兼容性不建议使用这种写法。

注解

Zombie110year

现在, 这种写法已经得到了广泛采用.

[1]这两种写法在语义上稍有区别,详见 复合赋值运算符
[2]increment 和 decrement 这两个词很有意思,大多数字典都说它们是名词,但经常被当成动词用,在计算机术语中,它们当动词用应该理解为 increase by one和decrease by one 。现代英语中很多原本是名词的都被当成动词用,字典都跟不上时代了,再比如 transition 也是如此。

break和continue语句

switch语句 中我们见到了 break 语句的一种用法,用来跳出 switch 语句块,这个语句也可以用来跳出循环体。 continue 语句也会终止当前循环,和 break 语句不同的是, continue 语句终止当前循环后又回到循环体的开头准备执行下一次循环。对于 while 循环和 do/while 循环,执行 continue 语句之后测试控制表达式,如果值为真则继续执行下一次循环;对于 for 循环,执行 continue 语句之后首先计算控制表达式 3 ,然后测试控制表达式 2 ,如果值为真则继续执行下一次循环。例如下面的代码打印 1 到 100 之间的素数:

求1-100的素数
#include <stdio.h>

int is_prime(int n)
{
    int i;
    for (i = 2; i < n; i++)
        if (n % i == 0)
            break;
    if (i == n)
        return 1;
    else
        return 0;
}

int main(void)
{
    int i;
    for (i = 1; i <= 100; i++) {
        if (!is_prime(i))
            continue;
        printf("%d\n", i);
    }
    return 0;
}

is_prime 函数从 2n-1 依次检查有没有能被 n 整除的数,如果有就说明 n 不是素数,立刻跳出循环而不执行 i++ 。因此,如果 n 不是素数,则循环结束后 i 一定小于 n ,如果 n 是素数,则循环结束后 i 一定等于 n 。注意检查临界条件: 2 应该是素数,如果 n 是2,则循环体一次也不执行,但 i 的初值就是 2 ,也等于 n ,在程序中也判定为素数。其实没有必要从 2 一直检查到 n-1 ,只要从 \(2\) 检查到 \(\sqrt{n}\),如果全都不能整除就足以证明 n 是素数了,请读者想一想为什么。

在主程序中,从 1 到 100 依次检查每个数是不是素数,如果不是素数,并不直接跳出循环,而是 i++ 后继续执行下一次循环,因此用 continue 语句。注意主程序的局部变量 iis_prime 中的局部变量 i 是不同的两个变量,其实在调用 is_prime 函数时主程序的局部变量 i 和参数 n 的值相等。

习题

1、求素数这个程序只是为了说明 breakcontinue 的用法才这么写的,其实完全可以不用 breakcontinue ,请读者修改一下控制流程,去掉 breakcontinue 而保持功能不变。

2、上一节讲过怎样把 for 循环改写成等价的 while 循环,但也提到如果循环体中有 continue 语句这两种形式就不等价了,想一想为什么不等价了?

嵌套循环

上一节求素数的例子在循环中调用一个函数,而那个函数里面又有一个循环,这其实是一种嵌套循环。如果把那个函数的代码拿出来写就更清楚了:

用嵌套循环求1-100的素数
#include <stdio.h>
int main(void)
{
    int i, j;
    for (i = 1; i <= 100; i++) {
        for (j = 2; j < i; j++)
            if (i % j == 0)
                break;
        if (j == i)
            printf("%d\n", i);
    }
    return 0;
}

现在内循环的循环变量就不能再用 i 了,而是改用 j ,原来程序中 is_prime 函数的参数 n 现在直接用 i 代替。在有多层循环或 switch 嵌套的情况下, break 只能跳出最内层的循环或 switch , continue 也只能终止最内层循环并回到该循环的开头。

用循环也可以打印表格式的数据,比如打印小九九乘法表:

打印小九九
#include <stdio.h>

int main(void)
{
    int i, j;
    for (i=1; i<=9; i++) {
        for (j=1; j<=9; j++)
            printf("%d  ", i*j);
        printf("\n");
    }
    return 0;
}

内循环每次打印一个数,数与数之间用两个空格隔开,外循环每次打印一行。结果如下:

1  2  3  4  5  6  7  8  9
2  4  6  8  10  12  14  16  18
3  6  9  12  15  18  21  24  27
4  8  12  16  20  24  28  32  36
5  10  15  20  25  30  35  40  45
6  12  18  24  30  36  42  48  54
7  14  21  28  35  42  49  56  63
8  16  24  32  40  48  56  64  72
9  18  27  36  45  54  63  72  81

结果有一位数的有两位数的,这个表格很不整齐,如果把打印语句改为 printf("%d\t", i*j); 就整齐了,所以Tab字符称为制表符。

习题

1、上面打印的小九九有一半数据是重复的,因为 8*99*8 的结果一样。请修改程序打印这样的小九九:

1
2   4
3   6       9
4   8       12      16
5   10      15      20      25
6   12      18      24      30      36
7   14      21      28      35      42      49
8   16      24      32      40      48      56      64
9   18      27      36      45      54      63      72      81

2、编写函数 diamond 打印一个菱形。如果调用 diamond(3, '*') 则打印:

    *
*   *       *
    *

如果调用 diamond(5, '+') 则打印:

        +
    +       +       +
+   +       +       +       +
    +       +       +
        +

如果用偶数做参数则打印错误提示。

goto语句和标号

分支、循环都讲完了,现在只剩下最后一种影响控制流程的语句了,就是 goto 语句,实现 无条件跳转 。我们知道 break 只能跳出最内层的循环,如果在一个嵌套循环中遇到某个错误条件需要立即跳出最外层循环做出错处理,就可以用 goto 语句,例如:

for (...)
    for (...) {
        ...
        if (出现错误条件)
            goto error;
    }
error:
    出错处理;

这里的 error: 叫做标号(Label) ,任何语句前面都可以加若干个标号,每个标号的命名也要遵循标识符的命名规则。

goto 语句过于强大了,从程序中的任何地方都可以无条件跳转到任何其它地方,只要在那个地方定义一个标号就行,唯一的限制是 goto 只能跳转到同一个函数中的某个标号处,而不能跳到别的函数中 [3]滥用 goto 语句会使程序的控制流程非常复杂,可读性很差 。著名的计算机科学家 Edsger W. Dijkstra 最早指出编程语言中 goto 语句的危害,提倡取消 goto 语句。 goto 语句不是必须存在的,显然可以用别的办法替代,比如上面的代码段可以改写为:

int cond = 0; /* bool variable indicating error condition */
for (...) {
    for (...) {
        ...
        if (出现错误条件) {
            cond = 1;
            break;
        }
    }
    if (cond)
        break;
}
if (cond)
    出错处理;

通常 goto 语句只用于这种场合,一个函数中任何地方出现了错误条件都可以立即跳转到函数末尾做出错处理(例如释放先前分配的资源、恢复先前改动过的全局变量等),处理完之后函数返回。比较用 goto 和不用 goto 的两种写法,用 goto 语句还是方便很多。但是除此之外,在任何其它场合都不要轻易考虑使用 goto 语句。有些编程语言(如C++)中有异常(Exception) 处理的语法,可以代替 goto 和 setjmp/longjmp 的这种用法。

回想一下,我们在 switch语句 学过 casedefault 后面也要跟冒号( : 号,Colon) ,事实上它们是两种特殊的标号。和标号有关的语法规则如下:

语句 → 标识符: 语句

语句 → case 常量表达式: 语句

语句 → default: 语句

反复应用这些语法规则进行组合可以在一条语句前面添加多个标号,例如在 缺break的switch语句 的代码中,有些语句前面有多个 case 标号。现在我们再看 switch 语句的格式:

switch (控制表达式) {
case 常量表达式: 语句列表
case 常量表达式: 语句列表
...
default: 语句列表
}

{} 里面是一组语句列表,其中每个分支的第一条语句带有 casedefault 标号,从语法上来说, switch 的语句块和其它分支、循环结构的语句块没有本质区别:

语句 → switch (控制表达式) 语句

语句 → { 语句列表 }

有兴趣的读者可以在网上查找有关 Duff’s Device 的资料,Duff’s Device 是一段很有意思的代码,正是利用 switch 的语句块和循环结构的语句块没有本质区别 这一点实现了一个巧妙的代码优化。

[3]C标准库函数 setjmp 和 longjmp 配合起来可以实现函数间的跳转,但只能从被调用的函数跳回到它的直接或间接调用者(同时从栈空间弹出一个或多个栈帧),而不能从一个函数跳转到另一个和它毫不相干的函数中。 setjmp/longjmp 函数主要也是用于出错处理,比如函数 A 调用函数 B,函数 B 调用函数 C,如果在 C 中出现某个错误条件,使得函数 B 和 C 继续执行下去都没有意义了,可以利用 setjmp/longjmp 机制快速返回到函数 A 做出错处理,本书不详细介绍这种机制,有兴趣的读者可参考 [APUE2e]

结构体

复合类型与结构体

在编程语言中,最基本的、不可再分的数据类型称为基本类型(Primitive Type) ,例如整型、浮点型;根据语法规则由基本类型组合而成的类型称为复合类型(Compound Type) ,例如字符串是由很多字符组成的。有些场合下要把复合类型当作一个整体来用,而另外一些场合下需要分解组成这个复合类型的各种基本类型,复合类型的这种两面性为数据抽象(Data Abstraction) 奠定了基础。 [SICP] 指出,在学习一门编程语言时要特别注意以下三个方面:

  1. 这门语言提供了哪些Primitive,比如基本类型,比如基本运算符、表达式和语句。
  2. 这门语言提供了哪些组合规则,比如基本类型如何组成复合类型,比如简单的表达式和语句如何组成复杂的表达式和语句。
  3. 这门语言提供了哪些抽象机制,包括数据抽象和过程抽象(Procedure Abstraction) 。

本章以结构体为例讲解数据类型的组合和数据抽象。至于过程抽象,我们在 if/else语句 已经见过最简单的形式,就是把一组语句用一个函数名封装起来,当作一个整体使用,本章将介绍更复杂的过程抽象。

现在我们用C语言表示一个复数。从直角座标系来看,复数由实部和虚部组成,从极座标系来看,复数由模和辐角组成,两种座标系可以相互转换,如下图所示:

复数

如果用实部和虚部表示一个复数,我们可以写成由两个 double 型组成的结构体:

struct complex_struct {
    double x, y;
};

这一句定义了标识符 complex_struct (同样遵循标识符的命名规则),这种标识符在C语言中称为 Tag , struct complex_struct { double x, y; } 整个可以看作一个类型名 [1] ,就像 intdouble 一样,只不过它是一个复合类型,如果用这个类型名来定义变量,可以这样写:

struct complex_struct { double x, y; } z1, z2;

这样 z1z2 就是两个变量名,变量定义后面带个 ; 号是我们早就习惯的。但即使像先前的例子那样只定义了 complex_struct 这个Tag而不定义变量,}后面的 ; 号也不能少。这点一定要注意,类型定义也是一种声明,声明都要以 ; 号结尾,结构体类型定义的 } 后面少 ; 号是初学者常犯的错误。不管是用上面两种形式的哪一种定义了 complex_struct 这个Tag,以后都可以直接用 struct complex_struct 来代替类型名了。例如可以这样定义另外两个复数变量:

struct complex_struct z3, z4;

如果在定义结构体类型的同时定义了变量,也可以不必写Tag,例如:

struct {
    double x, y;
} z1, z2;

但这样就没办法再次引用这个结构体类型了,因为它没有名字。每个复数变量都有两个成员(Member) xy ,可以用 . 运算符( . 号,Period) 来访问,这两个成员的存储空间是相邻的 [2] ,合在一起组成复数变量的存储空间。看下面的例子:

定义和访问结构体
#include <stdio.h>

int main(void)
{
    struct complex_struct { double x, y; } z;
    double x = 3.0;
    z.x = x;
    z.y = 4.0;
    if (z.y < 0)
        printf("z=%f%fi\n", z.x, z.y);
    else
        printf("z=%f+%fi\n", z.x, z.y);

    return 0;
}

注意上例中变量 x 和变量 z 的成员 x 的名字并不冲突,因为变量 z 的成员 x 只能通过表达式 z.x 来访问,编译器可以从语法上区分哪个 x 是变量 x ,哪个 x 是变量 z 的成员 x变量的存储布局 会讲到这两个标识符 x 属于不同的命名空间。结构体 Tag 也可以定义在全局作用域中,这样定义的 Tag 在其定义之后的各函数中都可以使用。例如:

struct complex_struct {
    double x, y;
};
int main(void)
{
    struct complex_struct z;
    ...
}

结构体变量也可以在定义时初始化,例如:

struct complex_struct z = { 3.0, 4.0 };

Initializer 中的数据依次赋给结构体的各成员。如果 Initializer 中的数据比结构体的成员多,编译器会报错,但如果只是末尾多个逗号则不算错。如果 Initializer 中的数据比结构体的成员少,未指定的成员将用 0 来初始化,就像未初始化的全局变量一样。例如以下几种形式的初始化都是合法的:

double x = 3.0;
struct complex_struct z1 = { x, 4.0, }; /* z1.x=3.0, z1.y=4.0 */
struct complex_struct z2 = { 3.0, }; /* z2.x=3.0, z2.y=0.0 */
struct complex_struct z3 = { 0 }; /* z3.x=0.0, z3.y=0.0 */

注意, z1 必须是局部变量才能用另一个变量 x 的值来初始化它的成员,如果是全局变量就只能用常量表达式来初始化。这也是 C99 的新特性, C89 只允许在 {} 中使用常量表达式来初始化,无论是初始化全局变量还是局部变量。

{} 这种语法不能用于结构体的赋值,例如这样是错误的:

struct complex_struct z1;
z1 = { 3.0, 4.0 };

以前我们初始化基本类型的变量所使用的 Initializer 都是表达式,表达式当然也可以用来赋值,但现在这种由 {} 括起来的Initializer并不是表达式,所以不能用来赋值 [3] 。 Initializer 的语法总结如下:

Initializer → 表达式

Initializer → { 初始化列表 }

初始化列表 → Designated-Initializer, Designated-Initializer, …

(最后一个Designated-Initializer末尾可以有一个多余的,号)

Designated-Initializer → Initializer

Designated-Initializer → .标识符 = Initializer

Designated-Initializer → [常量表达式] = Initializer

Designated Initializer 是 C99 引入的新特性,用于初始化稀疏(Sparse) 结构体和稀疏数组很方便。有些时候结构体或数组中只有某一个或某几个成员需要初始化,其它成员都用 0 初始化即可,用Designated Initializer语法可以针对每个成员做初始化(Memberwise Initialization) ,很方便。例如:

struct complex_struct z1 = { .y = 4.0 };
/* z1.x=0.0, z1.y=4.0 */

数组的 Memberwise Initialization 语法将在下一章介绍。

结构体类型用在表达式中有很多限制,不像基本类型那么自由,比如 + - * / 等算术运算符和 && || ! 等逻辑运算符都不能作用于结构体类型, if 语句、 while 语句中的控制表达式的值也不能是结构体类型。严格来说,可以做算术运算的类型称为算术类型(Arithmetic Type) ,算术类型包括整型和浮点型。可以表示零和非零,可以参与逻辑与、或、非运算或者做控制表达式的类型称为标量类型(Scalar Type) ,标量类型包括算术类型和以后要讲的指针类型,详见 C语言类型总结

结构体变量之间使用赋值运算符是允许的,用一个结构体变量初始化另一个结构体变量也是允许的,例如:

struct complex_struct z1 = { 3.0, 4.0 };
struct complex_struct z2 = z1;
z1 = z2;

同样地, z2 必须是局部变量才能用变量 z1 的值来初始化。既然结构体变量之间可以相互赋值和初始化,也就可以当作函数的参数和返回值来传递:

struct complex_struct
add_complex(struct complex_struct z1, struct complex_struct z2)
{
    z1.x = z1.x + z2.x;
    z1.y = z1.y + z2.y;
    return z1;
}

这个函数实现了两个复数相加,如果在 main 函数中这样调用:

struct complex_struct z = { 3.0, 4.0 };
z = add_complex(z, z);

那么调用传参的过程如下图所示:

结构体传参

变量 zmain 函数的栈帧上,参数 z1z2add_complex 函数的栈帧上, z 的值分别赋给 z1z2 。在这个函数里, z2 的实部和虚部被累加到 z1 中,然后 return z1; 可以看成是:

z1 初始化一个临时变量。

函数返回并释放栈帧。

把临时变量的值赋给变量 z ,释放临时变量。

. 运算符组成的表达式能不能做左值取决于 . 运算符左边的表达式能不能做左值。在上面的例子中, z 是一个变量,可以做左值,因此表达式 z.x 也可以做左值,但表达式 add_complex(z, z).x 只能做右值而不能做左值,因为表达式 add_complex(z, z) 不能做左值。

[1]其实C99已经定义了复数类型 _Complex 。如果包含C标准库的头文件 complex.h ,也可以用 complex 做类型名。当然,只要不包含头文件 complex.h 就可以自己定义标识符complex,但为了尽量减少混淆,本章的示例代码都用 complex_struct 做标识符而不用 complex
[2]我们在 结构体和联合体 会看到,结构体成员之间也可能有若干个填充字节。
[3]C99 引入一种新的表达式语法 Compound Literal 可以用来赋值,例如 z1 = (struct complex_struct){ 3.0, 4.0 }; ,本书不使用这种新语法。

数据抽象

现在我们来实现一个完整的复数运算程序。在上一节我们已经定义了复数的结构体类型,现在需要围绕它定义一些函数。复数可以用直角座标或极座标表示,直角座标做加减法比较方便,极座标做乘除法比较方便。如果我们定义的复数结构体是直角座标的,那么应该提供极座标的转换函数,以便在需要的时候可以方便地取它的模和辐角:

#include <math.h>
struct complex_struct {
    double x, y;
};
double real_part(struct complex_struct z)
{
    return z.x;
}
double img_part(struct complex_struct z)
{
    return z.y;
}
double magnitude(struct complex_struct z)
{
    return sqrt(z.x * z.x + z.y * z.y);
}
double angle(struct complex_struct z)
{
    return atan2(z.y, z.x);
}

此外,我们还提供两个函数用来构造复数变量,既可以提供直角座标也可以提供极座标,在函数中自动做相应的转换然后返回构造的复数变量:

struct complex_struct make_from_real_img(double x, double y)
{
    struct complex_struct z;
    z.x = x;
    z.y = y;
    return z;
}

struct complex_struct make_from_mag_ang(double r, double A)
{
    struct complex_struct z;
    z.x = r * cos(A);
    z.y = r * sin(A);
    return z;
}

在此基础上就可以实现复数的加减乘除运算了:

struct complex_struct add_complex(struct complex_struct z1, struct complex_struct z2)
{
    return make_from_real_img(real_part(z1) + real_part(z2), img_part(z1) + img_part(z2));
}

struct complex_struct sub_complex(struct complex_struct z1, struct complex_struct z2)
{
    return make_from_real_img(real_part(z1) - real_part(z2), img_part(z1) - img_part(z2));
}

struct complex_struct mul_complex(struct complex_struct z1, struct complex_struct z2)
{
    return make_from_mag_ang(magnitude(z1) * magnitude(z2), angle(z1) + angle(z2));
}

struct complex_struct div_complex(struct complex_struct z1, struct complex_struct z2)
{
    return make_from_mag_ang(magnitude(z1) / magnitude(z2), angle(z1) - angle(z2));
}

可以看出,复数加减乘除运算的实现并没有直接访问结构体 complex_struct 的成员 xy ,而是把它看成一个整体,通过调用相关函数来取它的直角座标和极座标。这样就可以非常方便地替换掉结构体 complex_struct 的存储表示,例如改为用极座标来存储:

#include <math.h>

struct complex_struct {
    double r, A;
};
double real_part(struct complex_struct z)
{
    return z.r * cos(z.A);
}
double img_part(struct complex_struct z)
{
    return z.r * sin(z.A);
}
double magnitude(struct complex_struct z)
{
    return z.r;
}
double angle(struct complex_struct z)
{
    return z.A;
}
struct complex_struct make_from_real_img(double x, double y)
{
    struct complex_struct z;
    z.A = atan2(y, x);
    z.r = sqrt(x * x + y * y);
}
struct complex_struct make_from_mag_ang(double r, double A)
{
    struct complex_struct z;
    z.r = r;
    z.A = A;
    return z;
}

虽然结构体 complex_struct 的存储表示做了这样的改动, add_complexsub_complexmul_complexdiv_complex 这几个复数运算的函数却不需要做任何改动,仍然可以用,原因在于这几个函数只把结构体 complex_struct 当作一个整体来使用,而没有直接访问它的成员,因此也不依赖于它有哪些成员。我们结合下图具体分析一下。

数据抽象

这里是一种抽象的思想。其实“抽象”这个概念并没有那么抽象,简单地说就是“提取公因式”: \(ab+ac=a(b+c)\) 。如果 \(a\) 变了, \(ab\)\(ac\) 这两项都需要改,但如果写成 \(a(b+c)\) 的形式就只需要改其中一个因子。

在我们的复数运算程序中,复数有可能用直角座标或极座标来表示,我们把这个有可能变动的因素提取出来组成复数存储表示层: real_partimg_partmagnitudeanglemake_from_real_imgmake_from_mag_ang 。这一层看到的数据是结构体的两个成员 xy ,或者 rA ,如果改变了结构体的实现就要改变这一层函数的实现,但函数接口不改变,因此调用这一层函数接口的复数运算层也不需要改变。复数运算层看到的数据只是一个抽象的“ 复数”的概念,知道它有直角座标和极座标,可以调用复数存储表示层的函数得到这些座标。再往上看,其它使用复数运算的程序看到的数据是一个更为抽象的“复数”的概念,只知道它是一个数,像整数、小数一样可以加减乘除,甚至连它有直角座标和极座标也不需要知道。

这里的复数存储表示层和复数运算层称为抽象层(Abstraction Layer) ,从底层往上层来看,复数越来越抽象了,把所有这些层组合在一起就是一个完整的系统。组合使得系统可以任意复杂,而抽象使得系统的复杂性是可以控制的,任何改动都只局限在某一层,而不会波及整个系统。著名的计算机科学家 Butler Lampson 说过:“All problems in computer science can be solved by another level of indirection.” 这里的 indirection 其实就是 abstraction 的意思。

习题

1、在本节的基础上实现一个打印复数的函数,打印的格式是 x+yi ,如果实部或虚部为 0 则省略,例如: 1.0-2.0i-1.0+2.0i1.0-2.0i 。最后编写一个 main 函数测试本节的所有代码。想一想这个打印函数应该属于上图中的哪一层?

2、实现一个用分子分母的格式来表示有理数的结构体 rational 以及相关的函数, rational 结构体之间可以做加减乘除运算,运算的结果仍然是 rational 。测试代码如下:

int main(void)
{
    struct rational a = make_rational(1, 8); /* a=1/8 */
    struct rational b = make_rational(-1, 8); /* b=-1/8 */
    print_rational(add_rational(a, b));
    print_rational(sub_rational(a, b));
    print_rational(mul_rational(a, b));
    print_rational(div_rational(a, b));

    return 0;
}

注意要约分为最简分数,例如 1/8-1/8 相减的打印结果应该是 1/4 而不是 2/8 ,可以利用 递归 练习题中的 Euclid 算法来约分。在动手编程之前先思考一下这个问题实现了什么样的数据抽象,抽象层应该由哪些函数组成。

数据类型标志

在上一节中,我们通过一个复数存储表示抽象层把 complex_struct 结构体的存储格式和上层的复数运算函数隔开, complex_struct 结构体既可以采用直角座标也可以采用极座标存储。但有时候需要同时支持两种存储格式,比如先前已经采集了一些数据存在计算机中,有些数据是以极座标存储的,有些数据是以直角座标存储的,如果要把这些数据都存到 complex_struct 结构体中怎么办?一种办法是规定 complex_struct 结构体采用直角座标格式,直角座标的数据可以直接存入 complex_struct 结构体,而极座标的数据先转成直角座标再存,但由于浮点数的精度有限,转换总是会损失精度的。这里介绍另一种办法, complex_struct 结构体由一个数据类型标志和两个浮点数组成,如果数据类型标志为 0 ,那么两个浮点数就表示直角座标,如果数据类型标志为 1 ,那么两个浮点数就表示极座标。这样,直角座标和极座标的数据都可以适配(Adapt) 到 complex_struct 结构体中,无需转换和损失精度:

enum coordinate_type { RECTANGULAR, POLAR };
struct complex_struct {
    enum coordinate_type t;
    double a, b;
};

enum 关键字的作用和 struct 关键字类似,把 coordinate_type 这个标识符定义为一个 Tag , struct complex_struct 表示一个结构体类型,而 enum coordinate_type 表示一个枚举(Enumeration) 类型。枚举类型的成员是常量,它们的值由编译器自动分配,例如定义了上面的枚举类型之后, RECTANGULAR 就表示常量 0POLAR 表示常量 1 。如果不希望从 0 开始分配,可以这样定义:

enum coordinate_type { RECTANGULAR = 1, POLAR };

这样, RECTANGULAR 就表示常量 1 ,而 POLAR 表示常量 2 。枚举常量也是一种整型,其值在编译时确定,因此也可以出现在常量表达式中,可以用于初始化全局变量或者作为 case 分支的判断条件。

有一点需要注意,虽然结构体的成员名和变量名不在同一命名空间中,但枚举的成员名却和变量名在同一命名空间中,所以会出现命名冲突。例如这样是不合法的:

int main(void)
{
    enum coordinate_type { RECTANGULAR = 1, POLAR };
    int RECTANGULAR;
    printf("%d %d\n", RECTANGULAR, POLAR);
    return 0;
}

complex_struct 结构体的格式变了,就需要修改复数存储表示层的函数,但只要保持函数接口不变就不会影响到上层函数。例如:

struct complex_struct make_from_real_img(double x, double y)
{
    struct complex_struct z;
    z.t = RECTANGULAR;
    z.a = x;
    z.b = y;
    return z;
}

struct complex_struct make_from_mag_ang(double r, double A)
{
    struct complex_struct z;
    z.t = POLAR;
    z.a = r;
    z.b = A;
    return z;
}
习题

1、本节只给出了 make_from_real_imgmake_from_mag_ang 函数的实现,请读者自己实现 real_partimg_partmagnitudeangle 这些函数。

2、编译运行下面这段程序:

#include <stdio.h>

enum coordinate_type { RECTANGULAR = 1, POLAR };

int main(void)
{
    int RECTANGULAR;
    printf("%d %d\n", RECTANGULAR, POLAR);
    return 0;
}

结果是什么?并解释一下为什么是这样的结果。

嵌套结构体

结构体也是一种递归定义:结构体的成员具有某种数据类型,而结构体本身也是一种数据类型。换句话说,结构体的成员可以是另一个结构体,即结构体可以嵌套定义。例如我们在复数的基础上定义复平面上的线段:

struct segment {
    struct complex_struct start;
    struct complex_struct end;
};

复合类型与结构体 讲的 Initializer 的语法可以看出, Initializer 也可以嵌套,因此嵌套结构体可以嵌套地初始化,例如:

struct segment s = {{ 1.0, 2.0 }, { 4.0, 6.0 }};

也可以平坦(Flat) 地初始化。例如:

struct segment s = { 1.0, 2.0, 4.0, 6.0 };

甚至可以把两种方式混合使用(这样可读性很差,应该避免):

struct segment s = {{ 1.0, 2.0 }, 4.0, 6.0 };

利用C99的新特性也可以做 Memberwise Initialization,例如 [4]

struct segment s = { .start.x = 1.0, .end.x = 2.0 };

访问嵌套结构体的成员要用到多个 . 运算符,例如:

s.start.t = RECTANGULAR;
s.start.a = 1.0;
s.start.b = 2.0;
[4]为了便于理解, 复合类型与结构体 讲的Initializer语法并没有描述这种复杂的用法。

数组

数组的基本概念

数组(Array) 也是一种复合数据类型,它由一系列相同类型的元素(Element) 组成。例如定义一个由 4 个 int 型元素组成的数组 count

int count[4];

和结构体成员类似,数组 count 的 4 个元素的存储空间也是相邻的。结构体成员可以是基本数据类型,也可以是复合数据类型,数组中的元素也是如此。根据组合规则,我们可以定义一个由 4 个结构体元素组成的数组:

struct complex_struct {
    double x, y;
} a[4];

也可以定义一个包含数组成员的结构体:

struct {
    double x, y;
    int count[4];
} s;

数组类型的长度应该用一个整数常量表达式来指定 [1] 。数组中的元素通过下标(或者叫索引,Index) 来访问。例如前面定义的由 4 个 int 型元素组成的数组 count 图示如下:

数组count

整个数组占了 4 个 int 型的存储单元,存储单元用小方框表示,里面的数字是存储在这个单元中的数据(假设都是0),而框外面的数字是下标,这四个单元分别用 count[0]count[1]count[2]count[3] 来访问。注意,在定义数组 int count[4]; 时,方括号(Bracket) 中的数字 4 表示数组的长度,而在访问数组时,方括号中的数字表示访问数组的第几个元素。和我们平常数数不同,数组元素是从“第0个”开始数的,大多数编程语言都是这么规定的,所以计算机术语中有 Zeroth 这个词。这样规定使得访问数组元素非常方便,比如 count 数组中的每个元素占 4 个字节,则 count[i] 表示从数组开头跳过 4*i 个字节之后的那个存储单元。这种数组下标的表达式不仅可以表示存储单元中的值,也可以表示存储单元本身,也就是说可以做左值,因此以下语句都是正确的:

count[0] = 7;
count[1] = count[0] * 2;
++count[2];

到目前为止我们学习了五种后缀运算符:后缀 ++ 、后缀 -- 、结构体取成员 . 、数组取下标 [] 、函数调用 () 。还学习了五种单目运算符(或者叫前缀运算符):前缀 ++ 、前缀 -- 、正号 + 、负号 - 、逻辑非 ! 。在C语言中后缀运算符的优先级最高,单目运算符的优先级仅次于后缀运算符,比其它运算符的优先级都高,所以上面举例的 ++count[2] 应该看作对 count[2] 做前缀 ++ 运算。

数组下标也可以是表达式,但表达式的值必须是整型的。例如:

int i = 10;
count[i] = count[i+1];

使用数组下标不能超出数组的长度范围,这一点在使用变量做数组下标时尤其要注意。C编译器并不检查 count[-1] 或是 count[100] 这样的访问越界错误,编译时能顺利通过,所以属于运行时错误 [2] 。但有时候这种错误很隐蔽,发生访问越界时程序可能并不会立即崩溃,而执行到后面某个正确的语句时却有可能突然崩溃(在 段错误 我们会看到这样的例子)。所以从一开始写代码时就要小心避免出问题,事后依靠调试来解决问题的成本是很高的。

数组也可以像结构体一样初始化,未赋初值的元素也是用0来初始化,例如:

int count[4] = { 3, 2, };

count[0] 等于 3 , count[1] 等于 2 ,后面两个元素等于 0 。如果定义数组的同时初始化它,也可以不指定数组的长度,例如:

int count[] = { 3, 2, 1, };

编译器会根据 Initializer 有三个元素确定数组的长度为 3 。利用 C99 的新特性也可以做 Memberwise Initialization:

int count[4] = { [2] = 3 };

下面举一个完整的例子:

定义和访问数组
#include <stdio.h>

int main(void)
{
    int count[4] = { 3, 2, }, i;

    for (i = 0; i < 4; i++)
        printf("count[%d]=%d\n", i, count[i]);
    return 0;
}

这个例子通过循环把数组中的每个元素依次访问一遍,在计算机术语中称为遍历(Traversal) 。注意控制表达式 i < 4 ,如果写成 i <= 4 就错了,因为 count[4] 是访问越界。

数组和结构体虽然有很多相似之处,但也有一个显著的不同:数组不能相互赋值或初始化。例如这样是错的:

int a[5] = { 4, 3, 2, 1 };
int b[5] = a;

相互赋值也是错的:

a = b;

既然不能相互赋值,也就不能用数组类型作为函数的参数或返回值。如果写出这样的函数定义:

void foo(int a[5])
{
    ...
}

然后这样调用:

int array[5] = {0};
foo(array);

编译器也不会报错,但这样写并不是传一个数组类型参数的意思。对于数组类型有一条特殊规则:数组名就是指向该数组第一个元素的指针 。所以上面的函数调用其实是传一个指针类型的参数,而不是数组类型的参数。接下来的几章里有的函数需要访问数组,我们就把数组定义为全局变量给函数访问,等以后讲了指针再使用传参的办法。这也解释了为什么数组类型不能相互赋值或初始化,例如上面提到的 a = b 这个表达式, ab 都是数组类型的变量,但是 b 做右值使用,自动转换成指针类型,而左边仍然是数组类型,所以编译器报的错是 error: incompatible types in assignment

动态数组

前面我们介绍了定义一个固定大小的数组的例子:

element_t array[length];

这样定义出来的数组 array 拥有固定的 length 长度,无法进行修改。

我们也提到了数组名是指向数组第一个元素的指针,实际上一个数组的下标用法就是对指针加上偏移量的解引用:

array[2] // 等价于 *(array + 2)

这也是类似于 2[array] 这样的语法能够通过的原因,编译器在翻译此表达式时,生成的代码和 *(2 + array) 是一样的效果。

动态数组就是利用了指针的特性:

  1. 声明一个指针
  2. 在内存中分配一片区域
  3. 让指针指向此区域的首地址

这样就构建了一个动态数组。与普通数组不同的是,动态数组的数据会存储在 中,不像普通数组那样存在 中。

动态数组需要手动释放内存, 而普通数组当作用域结束时就会随着栈一起被释放。

动态数组的构建与释放需要用到 stdlib 中的 calloc() (或者 malloc() 等内存分配函数) 以分配一片内存,在使用完毕后, 通过 free() 进行释放:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

// 可以使用 int array[] 来声明形参是一个 int 数组
// 但是只能声明一维数组, 高维数组只能用多级指针
void displayArray(int array[], int length) {
  for (int i = 0; i < length - 1; ++i) {
    printf("%d ", array[i]);
  }
  printf("%d\n", array[length - 1]);
}

// 也可以使用 int *array, 来声明形参是一个指针
// 效果都一样, 都可以用下标访问元素
void displayArrayPtr(int *array, int length) {
  for (int i = 0; i < length - 1; ++i) {
    printf("%d ", array[i]);
  }
  printf("%d\n", array[length - 1]);
}

int main(int argc, char const *argv[]) {
  srand(time(NULL)); // 初始化随机数生成器

  // 声明一个 int 型的指针, 并分配 10 个 int 数据的空间
  int *array = calloc(10, sizeof(int));

  for (int i = 0; i < 10; ++i) {
    // 等价于 *(array + i) = rand() % 100;
    array[i] = rand() % 100; // 可以使用下标用法访问动态数组的元素
  }

  displayArray(array, 10);
  displayArrayPtr(array, 10);

  // 用完后释放
  free(array);
  return 0;
}
习题

1、编写一个程序,定义两个类型和长度都相同的数组,将其中一个数组的所有元素拷贝给另一个。既然数组不能直接赋值,想想应该怎么实现。

[1]C99的新特性允许在数组长度表达式中使用变量,称为变长数组(VLA,Variable Length Array) ,VLA只能定义为局部变量而不能是全局变量,与VLA有关的语法规则比较复杂,而且很多编译器不支持这种新特性,不建议使用。
[2]你可能会想为什么编译器对这么明显的错误都视而不见?理由一,这种错误并不总是显而易见的,在 指针的基本概念 会讲到通过指针而不是数组名来访问数组的情况,指针指向数组中的什么位置只有运行时才知道,编译时无法检查是否越界,而运行时每次访问数组元素都检查越界会严重影响性能,所以干脆不检查了;理由二, [C99 Rationale] 指出C语言的设计精神是:相信每个C程序员都是高手,不要阻止程序员去干他们需要干的事,高手们使用 count[-1] 这种技巧其实并不少见,不应该当作错误。

注解

Zombie110year

在某些情况下, 变长数组有利于解决问题. TODO:

数组应用实例:统计随机数

本节通过一个实例介绍使用数组的一些基本模式。问题是这样的:首先生成一列 0 ~ 9 的随机数保存在数组中,然后统计其中每个数字出现的次数并打印,检查这些数字的随机性如何。随机数在某些场合(例如游戏程序)是非常有用的,但是用计算机生成完全随机的数却不是那么容易。计算机执行每一条指令的结果都是确定的,没有一条指令产生的是随机数,调用C标准库得到的随机数其实是伪随机(Pseudorandom) 数,是用数学公式算出来的确定的数,只不过这些数看起来很随机,并且从统计意义上也很接近均匀分布(Uniform Distribution) 的随机数。

C标准库中生成伪随机数的是 rand 函数,使用这个函数需要包含头文件 stdlib.h ,它没有参数,返回值是一个介于 0RAND_MAX 之间的接近均匀分布的整数。 RAND_MAX 是该头文件中定义的一个常量,在不同的平台上有不同的取值,但可以肯定它是一个非常大的整数。通常我们用到的随机数是限定在某个范围之中的,例如 0~9,而不是 0 ~ RAND_MAX ,我们可以用 % 运算符将 rand 函数的返回值处理一下:

int x = rand() % 10;

完整的程序如下:

生成并打印随机数
#include <stdio.h>
#include <stdlib.h>
#define N 20

int a[N];

void gen_random(int upper_bound)
{
    int i;
    for (i = 0; i < N; i++)
        a[i] = rand() % upper_bound;
}

void print_random()
{
    int i;
    for (i = 0; i < N; i++)
        printf("%d ", a[i]);
    printf("\n");
}

int main(void)
{
    gen_random(10);
    print_random();
    return 0;
}

这里介绍一种新的语法:用 #define 定义一个常量。实际上编译器的工作分为两个阶段,先是预处理(Preprocess) 阶段,然后才是编译阶段,用 gcc 的 -E 选项可以看到预处理之后、编译之前的程序,例如:

$ gcc -E main.c

// ...(这里省略了很多行stdio.h和stdlib.h的代码)
int a[20];

void gen_random(int upper_bound)
{
    int i;
    for (i = 0; i < 20; i++)
        a[i] = rand() % upper_bound;
}

void print_random()
{
    int i;
    for (i = 0; i < 20; i++)
        printf("%d ", a[i]);
    printf("\n");
}

int main(void)
{
    gen_random(10);
    print_random();
    return 0;
}

可见在这里预处理器做了两件事情,一是把头文件 stdio.hstdlib.h 在代码中展开,二是把 #define 定义的标识符 N 替换成它的定义 20 (在代码中做了三处替换,分别位于数组的定义中和两个函数中)。像 #include#define 这种以 # 号开头的行称为预处理指示(Preprocessing Directive) ,我们将在 预处理 学习其它预处理指示。此外,用 cpp main.c 命令也可以达到同样的效果,只做预处理而不编译, cpp 表示 C preprocessor。

那么用 #define 定义的常量和 数据类型标志 讲的枚举常量有什么区别呢?首先,#define 不仅用于定义常量,也可以定义更复杂的语法结构,称为宏(Macro) 定义。其次, #define 定义是在预处理阶段处理的,而枚举是在编译阶段处理的。试试看把 数据类型标志 习题2的程序改成下面这样是什么结果。

#include <stdio.h>
#define RECTANGULAR 1
#define POLAR 2

int main(void)
{
    int RECTANGULAR;
    printf("%d %d\n", RECTANGULAR, POLAR);
    return 0;
}

注意,虽然 includedefine 在预处理指示中有特殊含义,但它们并不是C语言的关键字,换句话说,它们也可以用作标识符,例如声明 int include; 或者 void define(int); 。在预处理阶段,如果一行以 # 号开头,后面跟 includedefine ,预处理器就认为这是一条预处理指示,除此之外出现在其它地方的 includedefine 预处理器并不关心,只是当成普通标识符交给编译阶段去处理。

回到随机数这个程序继续讨论,一开始为了便于分析和调试,我们取小一点的数组长度,只生成20个随机数,这个程序的运行结果为:

3 6 7 5 3 5 6 2 9 1 2 7 0 9 3 6 0 6 2 6

看起来很随机了。但随机性如何呢?分布得均匀吗?所谓均匀分布,应该每个数出现的概率是一样的。在上面的 20 个结果中, 6 出现了 5 次,而 4 和 8 一次也没出现过。但这说明不了什么问题,毕竟我们的样本太少了,才 20 个数,如果样本足够多,比如说 100000 个数,统计一下其中每个数字出现的次数也许能说明问题。但总不能把 100000 个数都打印出来然后挨个去数吧?我们需要写一个函数统计每个数字出现的次数。完整的程序如下:

统计随机数的分布
#include <stdio.h>
#include <stdlib.h>
#define N 100000

int a[N];

void gen_random(int upper_bound)
{
    int i;
    for (i = 0; i < N; i++)
        a[i] = rand() % upper_bound;
}

int howmany(int value)
{
    int count = 0, i;
    for (i = 0; i < N; i++)
        if (a[i] == value)
            ++count;
    return count;
}

int main(void)
{
    int i;

    gen_random(10);
    printf("value\thow many\n");
    for (i = 0; i < 10; i++)
        printf("%d\t%d\n", i, howmany(i));

    return 0;
}

我们只要把 #define N 的值改为 100000 ,就相当于把整个程序中所有用到 N 的地方都改为 100000 了。如果我们不这么写,而是在定义数组时直接写成 int a[20]; ,在每个循环中也直接使用 20 这个值,这称为 硬编码 (Hard coding) 。如果原来的代码是硬编码的,那么一旦需要把 20 改成 100000 就非常麻烦,你需要找遍整个代码,判断哪些 20 表示这个数组的长度就改为 100000 ,哪些 20 表示别的数量则不做改动,如果代码很长,这是很容易出错的。所以,写代码时应尽可能避免硬编码,这其实也是一个“提取公因式”的过程,和 数据抽象 讲的抽象具有相同的作用,就是避免一个地方的改动波及到大的范围。这个程序的运行结果如下:

$ ./a.out
value how many
0 10130 1 10072 2 9990 3 9842 4 10174 5 9930 6 10059 7 9954 8 9891 9 9958

各数字出现的次数都在 10000 次左右,可见是比较均匀的。

注解

Zombie110year

#define 预处理更多的是进行 条件编译, 它进行宏替换时, 本质上是对源代码进行处理, 将涉及的代码文字替换成目标文字, 没有作用域, 数据类型等规则.

自从 C 语言引入了 const 关键字后, 应当使用

const unsigned int N = 10000;

来定义上述常量.

习题

1、用rand函数生成 \([10, 20]\) 之间的随机整数,表达式应该怎么写?

数组应用实例:直方图

继续上面的例子。我们统计一列0~9的随机数,打印每个数字出现的次数,像这样的统计结果称为直方图(Histogram) 。有时候我们并不只是想打印,更想把统计结果保存下来以便做后续处理。我们可以把程序改成这样:

int main(void)
{
    int howmanyones = howmany(1);
    int howmanytwos = howmany(2);
    ...
}

这显然太繁琐了。要是这样的随机数有100个呢?显然这里用数组最合适不过了:

int main(void)
{
    int i, histogram[10];

    gen_random(10);
    for (i = 0; i < 10; i++)
        histogram[i] = howmany(i);
    ...
}

有意思的是,这里的循环变量 i 有两个作用,一是作为参数传给 howmany 函数,统计数字 i 出现的次数,二是做 histogram 的下标,也就是 “把数字 i 出现的次数保存在数组 histogram 的第 i 个位置”。

尽管上面的方法可以准确地得到统计结果,但是效率很低,这 100000 个随机数需要从头到尾检查十遍,每一遍检查只统计一种数字的出现次数。其实可以把 histogram 中的元素当作累加器来用,这些随机数只需要从头到尾检查一遍(Single Pass) 就可以得出结果:

int main(void)
{
    int i, histogram[10] = {0};

    gen_random(10);
    for (i = 0; i < N; i++)
        histogram[a[i]]++;
    ...
}

首先把 histogram 的所有元素初始化为 0 ,注意使用局部变量的值之前一定要初始化,否则值是不确定的。接下来的代码很有意思,在每次循环中, a[i] 就是出现的随机数,而这个随机数同时也是 histogram 的下标,这个随机数每出现一次就把 histogram 中相应的元素加 1。

把上面的程序运行几遍,你就会发现每次产生的随机数都是一样的,不仅如此,在别的计算机上运行该程序产生的随机数很可能也是这样的。这正说明了这些数是伪随机数,是用一套确定的公式基于某个初值算出来的,只要初值相同,随后的整个数列就都相同。实际应用中不可能使用每次都一样的随机数,例如开发一个麻将游戏,每次运行这个游戏摸到的牌不应该是一样的。因此,C标准库允许我们自己指定一个初值,然后在此基础上生成伪随机数,这个初值称为 Seed ,可以用 srand 函数指定 Seed 。通常我们通过别的途径得到一个不确定的数作为 Seed ,例如调用 time 函数得到当前系统时间距 1970年1月1日00:00:00 [3] 的秒数,然后传给 srand

srand(time(NULL));

然后再调用 rand ,得到的随机数就和刚才完全不同了。调用 time 函数需要包含头文件 time.h ,这里的 NULL 表示空指针,到 指针的基本概念 再详细解释。

习题

1、补完本节直方图程序的 main 函数,以可视化的形式打印直方图。例如上一节统计 20 个随机数的结果是:

0  1  2  3  4  5  6  7  8  9

*  *  *  *     *  *  *     *
*     *  *     *  *  *     *
      *  *     *
               *
               *

2、定义一个数组,编程打印它的全排列。比如定义:

#define N 3
int a[N] = { 1, 2, 3 };

则运行结果是:

$ ./a.out
1 2 3
1 3 2
2 1 3
2 3 1
3 2 1
3 1 2
1 2 3

程序的主要思路是:

  1. 把第 1 个数换到最前面来(本来就在最前面),准备打印 1xx ,再对后两个数 2 和 3 做全排列。
  2. 把第 2 个数换到最前面来,准备打印 2xx ,再对后两个数 1 和 3 做全排列。
  3. 把第 3 个数换到最前面来,准备打印 3xx ,再对后两个数 1 和 2 做全排列。

可见这是一个递归的过程,把对整个序列做全排列的问题归结为对它的子序列做全排列的问题,注意我没有描述 Base Case 怎么处理,你需要自己想。你的程序要具有通用性,如果改变了 N 和数组 a 的定义(比如改成 4 个数的数组),其它代码不需要修改就可以做 4 个数的全排列(共24种排列)。

完成了上述要求之后再考虑第二个问题:如果再定义一个常量 M 表示从 N 个数中取几个数做排列( N == M 时表示全排列),原来的程序应该怎么改?

最后再考虑第三个问题:如果要求从 N 个数中取 M 个数做组合而不是做排列,就不能用原来的递归过程了,想想组合的递归过程应该怎么描述,编程实现它。

[3]各种派生自UNIX的系统都把这个时刻称为Epoch ,因为UNIX系统最早发明于1969年。

字符串

之前我一直对字符串避而不谈,不做详细解释,现在已经具备了必要的基础知识,可以深入讨论一下字符串了。字符串可以看作一个数组,它的每个元素是字符型的,例如字符串 "Hello, world.\n" 图示如下:

字符串
H e l l o ,   w o r l d . \n \0

注意每个字符末尾都有一个字符 '\0' 做结束符,这里的 \0 是 ASCII 码的八进制表示,也就是 ASCII 码为 0 的 Null 字符,在C语言中这种字符串也称为以零结尾的字符串(Null-terminated String) 。数组元素可以通过数组名加下标的方式访问,而字符串字面值也可以像数组名一样使用,可以加下标访问其中的字符:

char c = "Hello, world.\n"[0];

但是通过下标修改其中的字符却是不允许的:

"Hello, world.\n"[0] = 'A';

这行代码会产生编译错误,说字符串字面值是只读的,不允许修改。字符串字面值还有一点和数组名类似,做右值使用时自动转换成指向首元素的指针,在 形参和实参 我们看到 printf 原型的第一个参数是指针类型,而 printf("hello world") 其实就是传一个指针参数给 printf

前面讲过数组可以像结构体一样初始化,如果是字符数组,也可以用一个字符串字面值来初始化:

char str[10] = "Hello";

相当于:

char str[10] = { 'H', 'e', 'l', 'l', 'o', '\0' };

str 的后四个元素没有指定,自动初始化为 0 ,即 Null 字符。注意,虽然字符串字面值 "Hello" 是只读的,但用它初始化的数组 str 却是可读可写的。数组 str 中保存了一串字符,以 '\0' 结尾,也可以叫字符串。在本书中只要是以 Null 字符结尾的一串字符都叫字符串,不管是像 str 这样的数组,还是像 "Hello" 这样的字符串字面值。

如果用于初始化的字符串字面值比数组还长,比如:

char str[10] = "Hello, world.\n";

则数组 str 只包含字符串的前 10 个字符,不包含 Null 字符,这种情况编译器会给出警告。如果要用一个字符串字面值准确地初始化一个字符数组,最好的办法是不指定数组的长度,让编译器自己计算:

char str[] = "Hello, world.\n";

字符串字面值的长度包括 Null 字符在内一共 15 个字符,编译器会确定数组 str 的长度为15。

有一种情况需要特别注意,如果用于初始化的字符串字面值比数组刚好长出一个 Null 字符的长度,比如:

char str[14] = "Hello, world.\n";

则数组 str 不包含Null字符,并且编译器不会给出警告, [C99 Rationale] 说这样规定是为程序员方便,以前的很多编译器都是这样实现的,不管它有理没理,C标准既然这么规定了我们也没办法,只能自己小心了。

补充一点, printf 函数的格式化字符串中可以用 %s 表示字符串的占位符。在学字符数组以前,我们用 %s 没什么意义,因为

printf("string: %s\n", "Hello");

还不如写成

printf("string: Hello\n");

但现在字符串可以保存在一个数组里面,用 %s 来打印就很有必要了:

printf("string: %s\n", str);

printf 会从数组 str 的开头一直打印到 Null 字符为止, Null 字符本身是 Non-printable 字符,不打印。这其实是一个危险的信号:如果数组 str 中没有 Null 字符,那么 printf 函数就会访问数组越界,后果可能会很诡异:有时候打印出乱码,有时候看起来没错误,有时候引起程序崩溃。

错误

Zombie110year

让编译器自动计算字符串长度的话, 右侧必须是一个字符串常量(字面值).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

char c[] = "jfdklasfjkl";

char* getString(void)
{
    return "fjladsjfkjasldkf";
}

int main(int argc, char const* argv[])
{
    char a[] = "jfkdalsjfkljsadlkfjsa";
    char b[] = getString();
    printf("%s", a);
    printf("%s", b);
    printf("%s", c);
    return 0;
}

编译器报错为:

❯ gcc -g -Wall test.c
test.c: In function 'main':
test.c:15:16: error: invalid initializer
    char b[] = getString();
                ^~~~~~~~~

多维数组

就像结构体可以嵌套一样,数组也可以嵌套,一个数组的元素可以是另外一个数组,这样就构成了多维数组(Multi-dimensional Array) 。例如定义并初始化一个二维数组:

int a[3][2] = { 1, 2, 3, 4, 5 };

数组 a 有 3 个元素, a[0]a[1]a[2] 。每个元素也是一个数组,例如 a[0] 是一个数组,它有两个元素 a[0][0]a[0][1] ,这两个元素的类型是 int ,值分别是 12 ,同理,数组 a[1] 的两个元素是 34 ,数组 a[2] 的两个元素是 50 。如下图所示:

多维数组

从概念模型上看,这个二维数组是三行两列的表格,元素的两个下标分别是行号和列号。从物理模型上看, 这六个元素在存储器中仍然是连续存储的 ,就像一维数组一样,相当于把概念模型的表格一行一行接起来拼成一串,C语言的这种存储方式称为 Row-major 方式,而有些编程语言(例如FORTRAN)是把概念模型的表格一列一列接起来拼成一串存储的,称为 Column-major 方式。

多维数组也可以像嵌套结构体一样用嵌套 Initializer 初始化,例如上面的二维数组也可以这样初始化:

int a[][2] = {
    { 1, 2 },
    { 3, 4 },
    { 5, }
};

注意,除了第一维的长度可以由编译器自动计算而不需要指定,其余各维都必须明确指定长度。利用 C99 的新特性也可以做 Memberwise Initialization,例如:

int a[3][2] = { [0][1] = 9, [2][1] = 8 };

结构体和数组嵌套的情况也可以做 Memberwise Initialization,例如:

struct complex_struct {
    double x, y;
} a[4] = { [0].x = 8.0 };

struct {
    double x, y;
    int count[4];
} s = { .count[2] = 9 };

如果是多维字符数组,也可以嵌套使用字符串字面值做 Initializer,例如:

多维字符数组
#include <stdio.h>

void print_day(int day)
{
    char days[8][10] = {
        "", "Monday", "Tuesday",
        "Wednesday", "Thursday", "Friday",
        "Saturday", "Sunday"
    };

    if (day < 1 || day > 7)
        printf("Illegal day number!\n");
    printf("%s\n", days[day]);
}

int main(void)
{
    print_day(2);
    return 0;
}
多维字符数组

这个程序中定义了一个多维字符数组 char days[8][10] ;,为了使 1~7 刚好映射到 days[1] ~ days[7] ,我们把 days[0] 空出来不用,所以第一维的长度是 8 ,为了使最长的字符串”Wednesday”能够保存到一行,末尾还能多出一个Null字符的位置,所以第二维的长度是10。

这个程序和 switch语句 的功能其实是一样的,但是代码简洁多了。简洁的代码不仅可读性强,而且维护成本也低,像 switch语句 那样一堆 caseprintfbreak ,如果漏写一个 break 就要出Bug。这个程序之所以简洁,是因为用数据代替了代码。具体来说,通过下标访问字符串组成的数组可以代替一堆 case 分支判断,这样就可以把每个 case 里重复的代码( printf 调用)提取出来,从而又一次达到了“ 提取公因式”的效果。这种方法称为 数据驱动的编程 (Data-driven Programming) ,写代码最重要的是选择正确的数据结构来组织信息,设计控制流程和算法尚在其次,只要数据结构选择得正确,其它代码自然而然就变得容易理解和维护了,就像这里的 printf 自然而然就被提取出来了。 [人月神话] 中说过:“ Show me your flowcharts and conceal your tables, and I shall continue to be mystified. Show me your tables, and I won’t usually need your flowcharts; they’ll be obvious.”

最后,综合本章的知识,我们来写一个最简单的小游戏--剪刀石头布:

剪刀石头布
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main(void)
{
    char gesture[3][10] = { "scissor", "stone", "cloth" };
    int man, computer, result, ret;

    srand(time(NULL));
    while (1) {
        computer = rand() % 3;
        printf("\nInput your gesture (0-scissor 1-stone 2-cloth):\n");
        ret = scanf("%d", &man);
        if (ret != 1 || man < 0 || man > 2) {
            printf("Invalid input! Please input 0, 1 or 2.\n");
            continue;
        }
        printf("Your gesture: %s\tComputer's gesture: %s\n",
            gesture[man], gesture[computer]);

        result = (man - computer + 4) % 3 - 1;
        if (result > 0)
            printf("You win!\n");
        else if (result == 0)
            printf("Draw!\n");
        else
            printf("You lose!\n");
    }
    return 0;
}

0、1、2三个整数分别是剪刀石头布在程序中的内部表示,用户也要求输入0、1或2,然后和计算机随机生成的0、1或2比胜负。这个程序的主体是一个死循环,需要按 Ctrl-C 退出程序。以往我们写的程序都只有打印输出,在这个程序中我们第一次碰到处理用户输入的情况。我们简单介绍一下 scanf 函数的用法,到 格式化I/O函数 再详细解释。 scanf("%d", &man) 这个调用的功能是等待用户输入一个整数并回车,这个整数会被 scanf 函数保存在 man 这个整型变量里。如果用户输入合法(输入的确实是数字而不是别的字符),则 scanf 函数返回 1 ,表示成功读入一个数据。但即使用户输入的是整数,我们还需要进一步检查是不是在 0 ~ 2 的范围内,写程序时对用户输入要格外小心,用户有可能输入任何数据,他才不管游戏规则是什么。

printf 类似, scanf 也可以用 %c%f%s 等转换说明。如果在传给 scanf 的第一个参数中用 %d%f%c 表示读入一个整数、浮点数或字符,则第二个参数的形式应该是 & 运算符加相应类型的变量名,表示读进来的数保存到这个变量中, & 运算符的作用是得到一个指针类型,到 指针的基本概念 再详细解释;如果在第一个参数中用 %s 读入一个字符串,则第二个参数应该是数组名,数组名前面不加 & ,因为数组类型做右值时自动转换成指针类型,在 断点 有scanf读入字符串的例子。

留给读者思考的问题是: (man - computer + 4) % 3 - 1 这个神奇的表达式是如何比较出0、1、2这三个数字在“剪刀石头布”意义上的大小的?

编码风格

代码风格好不好就像字写得好不好看一样,如果一个公司招聘秘书,肯定不要字写得难看的,同理,代码风格糟糕的程序员肯定也是不称职的。虽然编译器不会挑剔难看的代码,照样能编译通过,但是和你一个Team的其他程序员肯定受不了,你自己也受不了,写完代码几天之后再来看,自己都不知道自己写的是什么。 [SICP] 里有句话说得好:“ Thus, programs must be written for people to read, and only incidentally for machines to execute.”代码主要是为了写给人看的,而不是写给机器看的,只是顺便也能用机器执行而已,如果是为了写给机器看那直接写机器指令就好了,没必要用高级语言了。代码和语言文字一样是为了表达思想、记载信息,所以一定要写得清楚整洁才能有效地表达。正因为如此,在一个软件项目中,代码风格一般都用文档规定死了,所有参与项目的人不管他自己原来是什么风格,都要遵守统一的风格,例如Linux内核的 [CodingStyle] 就是这样一个文档。本章我们以内核的代码风格为基础来讲解好的编码风格都有哪些规定,这些规定的Rationale是什么。我只是以Linux内核为例来讲解编码风格的概念,并没有说内核编码风格就一定是最好的编码风格,但Linux内核项目如此成功,就足以说明它的编码风格是最好的C语言编码风格之一了。

缩进和空白

我们知道C语言的语法对缩进和空白没有要求,空格、Tab、换行都可以随意写,实现同样功能的代码可以写得很好看,也可以写得很难看。例如上一章 剪刀石头布 的代码如果写成这样就很难看了:

缺少缩进和空白的代码
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main(void)
{
char gesture[3][10]={"scissor","stone","cloth"};
int man,computer,result, ret;
srand(time(NULL));
while(1){
computer=rand()%3;
printf("\nInput your gesture (0-scissor 1-stone 2-cloth):\n");
ret=scanf("%d",&man);
if(ret!=1||man<0||man>2){
printf("Invalid input! Please input 0, 1 or 2.\n");
continue;
}
printf("Your gesture: %s\tComputer's gesture: %s\n",gesture[man],gesture[computer]);
result=(man-computer+4)%3-1;
if(result>0)printf("You win!\n");
else if(result==0)printf("Draw!\n");
else printf("You lose!\n");
}
return 0;
}

一是缺少空白字符,代码密度太大,看着很费劲。二是没有缩进,看不出来哪个 { 和哪个 } 配对,像这么短的代码还能凑合着看,如果代码超过一屏就完全没法看了。 [CodingStyle] 中关于空白字符并没有特别规定,因为基本上所有的C代码风格对于空白字符的规定都差不多,主要有以下几条。

1、关键字 if 、 while 、 for 与其后的控制表达式的 ( 括号之间插入一个空格分隔,但括号内的表达式应紧贴括号。例如:

while␣(1);

2、双目运算符的两侧各插入一个空格分隔,单目运算符和操作数之间不加空格,例如 i␣=␣i␣+␣1、++i、!(i␣<␣1)、-x、&a[1] 等。

3、后缀运算符和操作数之间也不加空格,例如取结构体成员 s.a 、函数调用 foo(arg1) 、取数组成员 a[i]

4、 , 号和 ; 号之后要加空格,这是英文的书写习惯,例如 for␣(i␣=␣1;␣i␣<␣10;␣i++)foo(arg1,␣arg2)

5、以上关于双目运算符和后缀运算符的规则并没有严格要求,有时候为了突出优先级也可以写得更紧凑一些,例如 for␣(i=1;␣i<10;␣i++)distance␣=␣sqrt(x*x␣+␣y*y) 等。但是省略的空格一定不要误导了读代码的人,例如 a||b␣&&␣c 很容易让人理解成错误的优先级。

6、由于UNIX系统标准的字符终端是 24 行 80 列的,接近或大于 80 个字符的较长语句要折行写,折行后用空格和上面的表达式或参数对齐,例如:

if␣(sqrt(x*x␣+␣y*y)␣>␣5.0
    &&␣x␣<␣0.0
    &&␣y␣>␣0.0)

再比如:

foo(sqrt(x*x␣+␣y*y),
    a[i-1]␣+␣b[i-1]␣+␣c[i-1])

7、较长的字符串可以断成多个字符串然后分行书写,例如:

printf("This is such a long sentence that "
    "it cannot be held within a line\n");

C编译器会自动把相邻的多个字符串接在一起,以上两个字符串相当于一个字符串 "This is such a long sentence that it cannot be held within a line\n"

8、有的人喜欢在变量定义语句中用Tab字符,使变量名对齐,这样看起来很美观。

→int    →a, b;
→double →c;

内核代码风格关于缩进的规则有以下几条。

1、要用缩进体现出语句块的层次关系,使用Tab字符缩进,不能用空格代替Tab。在标准的字符终端上一个Tab看起来是8个空格的宽度,如果你的文本编辑器可以设置Tab的显示宽度是几个空格,建议也设成8,这样大的缩进使代码看起来非常清晰。如果有的行用空格做缩进,有的行用Tab做缩进,甚至空格和Tab混用,那么一旦改变了文本编辑器的Tab显示宽度就会看起来非常混乱,所以内核代码风格规定只能用Tab做缩进,不能用空格代替Tab。

2、if/else、while、do/while、for、switch这些可以带语句块的语句,语句块的 {} 应该和关键字写在同一行,用空格隔开,而不是单独占一行。例如应该这样写:

if␣(...)␣{ →语句列表 }␣else␣if␣(...)␣{ →语句列表 }

但很多人习惯这样写:

if␣(...)
{
    →语句列表
}
else␣if␣(...)
{
    →语句列表
}

内核的写法和 [K&R] 一致,好处是不必占太多行,使得一屏能显示更多代码。这两种写法用得都很广泛,只要在同一个项目中能保持统一就可以了。

3、函数定义的 {} 单独占一行,这一点和语句块的规定不同,例如:

int␣foo(int␣a,␣int␣b)
{
    →语句列表
}

4、switch和语句块里的 casedefault 对齐写,也就是说语句块里的 casedefault 标号相对于 switch 不往里缩进,但标号下的语句要往里缩进。例如:

→switch␣(c)␣{ →case 'A': → →语句列表 →case 'B': → →语句列表 →default: → →语句列表 →}

用于 goto 语句的自定义标号应该顶头写不缩进,而不管标号下的语句缩进到第几层。

5、代码中每个逻辑段落之间应该用一个空行分隔开。例如每个函数定义之间应该插入一个空行,头文件、全局变量定义和函数定义之间也应该插入空行,例如:

#include <stdio.h>
#include <stdlib.h>

int g;
double h;

int foo(void)
{
    →语句列表
}

int bar(int a)
{
    →语句列表
}

int main(void)
{
    →语句列表
}

6、一个函数的语句列表如果很长,也可以根据相关性分成若干组,用空行分隔。这条规定不是严格要求,通常把变量定义组成一组,后面加空行, return 语句之前加空行,例如:

int main(void)
{
    →int    →a, b;
    →double →c;

    →语句组1

    →语句组2

    →return 0;
}

注释

单行注释应采用 /*␣comment␣*/ 的形式,用空格把界定符和文字分开。多行注释最常见的是这种形式:

/*
␣*␣Multi-line
␣*␣comment
␣*/

也有更花哨的形式:

/*************\
* Multi-line  *
* comment     *
\*************/

使用注释的场合主要有以下几种。

1、整个源文件的顶部注释。说明此模块的相关信息,例如文件名、作者和版本历史等,顶头写不缩进。例如内核源代码目录下的 kernel/sched.c 文件的开头:

/*
*  kernel/sched.c
*
*  Kernel scheduler and related syscalls
*
*  Copyright (C) 1991-2002  Linus Torvalds
*
*  1996-12-23  Modified by Dave Grothe to fix bugs in semaphores and
*              make semaphores SMP safe
*  1998-11-19  Implemented schedule_timeout() and related stuff
*              by Andrea Arcangeli
*  2002-01-04  New ultra-scalable O(1) scheduler by Ingo Molnar:
*              hybrid priority-list and round-robin design with
*              an array-switch method of distributing timeslices
*              and per-CPU runqueues.  Cleanups and useful suggestions
*              by Davide Libenzi, preemptible kernel bits by Robert Love.
*  2003-09-03  Interactivity tuning by Con Kolivas.
*  2004-04-02  Scheduler domains code by Nick Piggin
*/

2、函数注释。说明此函数的功能、参数、返回值、错误码等,写在函数定义上侧,和此函数定义之间不留空行,顶头写不缩进。

3、相对独立的语句组注释。对这一组语句做特别说明,写在语句组上侧,和此语句组之间不留空行,与当前语句组的缩进一致。

4、代码行右侧的简短注释。对当前代码行做特别说明,一般为单行注释,和代码之间至少用一个空格隔开,一个源文件中所有的右侧注释最好能上下对齐。尽管 带更多注释的 Hello World 讲过注释可以穿插在一行代码中间,但不建议这么写。内核源代码目录下的 lib/radix-tree.c 文件中的一个函数包含了上述三种注释:

/**
* radix_tree_insert - insert into a radix tree
* @root: radix tree root * @index: index key
* @item: item to insert
*
* Insert an item into the radix tree at position @index.
*/

int radix_tree_insert(struct radix_tree_root* root, unsigned long index, void* item)
{
    struct radix_tree_node *node = NULL, *slot;
    unsigned int height, shift;
    int offset;
    int error; /* Make sure the tree is high enough. */
    if ((!index && !root->rnode) || index > radix_tree_maxindex(root->height)) {
        error = radix_tree_extend(root, index);
        if (error)
            return error;
    }
    slot = root->rnode;
    height = root->height;
    shift = (height - 1) * RADIX_TREE_MAP_SHIFT;
    offset = 0; /* uninitialised var warning */
    do {
        if (slot == NULL) { /* Have to
                add a child node. */
            if (!(slot = radix_tree_node_alloc(root)))
                return -ENOMEM;
            if (node) {
                node->slots[offset] = slot;
                node->count++;
            } else
                root->rnode = slot;
        } /* Go a level down */
        offset = (index >> shift) & RADIX_TREE_MAP_MASK;
        node = slot;
        slot = node->slots[offset];
        shift -= RADIX_TREE_MAP_SHIFT;
        height--;
    } while (height > 0);
    if (slot != NULL)
        return -EEXIST;
    BUG_ON(!node);
    node->count++;
    node->slots[offset] = item;
    BUG_ON(tag_get(node, 0, offset));
    BUG_ON(tag_get(node, 1, offset));
    return 0;
}

[CodingStyle] 中特别指出,函数内的注释要尽可能少用。写注释主要是为了说明你的代码“能做什么”(比如函数接口定义),而不是为了说明“怎样做”,只要代码写得足够清晰,“ 怎样做”是一目了然的,如果你需要用注释才能解释清楚,那就表示你的代码可读性很差,除非是特别需要提醒注意的地方才使用函数内注释。

5、复杂的结构体定义比函数更需要注释。例如内核源代码目录下的 kernel/sched.c 文件中定义了这样一个结构体:

/*
* This is the main, per-CPU runqueue data structure.
*
* Locking rule: those places that want to lock multiple runqueues
* (such as the load balancing or the thread migration code), lock
* acquire operations must be ordered by ascending &runqueue.
*/
struct runqueue {
        spinlock_t lock;

        /*
        * nr_running and cpu_load should be in the same cacheline because
        * remote CPUs use both these fields when doing load calculation.
        */
        unsigned long nr_running;
#ifdef CONFIG_SMP
        unsigned long cpu_load[3];
#endif
        unsigned long long nr_switches;

        /*
        * This is part of a global counter where only the total sum
        * over all CPUs matters. A task can increase this counter on
        * one CPU and if it got migrated afterwards it may decrease
        * it on another CPU. Always updated under the runqueue lock:
        */
        unsigned long nr_uninterruptible;

        unsigned long expired_timestamp;
        unsigned long long timestamp_last_tick;
        task_t *curr, *idle;
        struct mm_struct *prev_mm;
        prio_array_t *active, *expired, arrays[2];
        int best_expired_prio;
        atomic_t nr_iowait;

#ifdef CONFIG_SMP
        struct sched_domain *sd;

        /* For active balancing */
        int active_balance;
        int push_cpu;

        task_t *migration_thread;
        struct list_head migration_queue;
        int cpu;
#endif

#ifdef CONFIG_SCHEDSTATS
        /* latency stats */
        struct sched_info rq_sched_info;

        /* sys_sched_yield() stats */
        unsigned long yld_exp_empty;
        unsigned long yld_act_empty;
        unsigned long yld_both_empty;
        unsigned long yld_cnt;

        /* schedule() stats */
        unsigned long sched_switch;
        unsigned long sched_cnt;
        unsigned long sched_goidle;

        /* try_to_wake_up() stats */
        unsigned long ttwu_cnt;
        unsigned long ttwu_local;
#endif
};

6、复杂的宏定义和变量声明也需要注释。例如内核源代码目录下的 include/linux/jiffies.h 文件中的定义:

/* TICK_USEC_TO_NSEC is the time between ticks in nsec assuming real ACTHZ and  */
/* a value TUSEC for TICK_USEC (can be set bij adjtimex)                */
#define TICK_USEC_TO_NSEC(TUSEC) (SH_DIV (TUSEC * USER_HZ * 1000, ACTHZ, 8))

/* some arch's have a small-data section that can be accessed register-relative
* but that can only take up to, say, 4-byte variables. jiffies being part of
* an 8-byte variable may not be correctly accessed unless we force the issue
*/
#define __jiffy_data  __attribute__((section(".data")))

/*
* The 64-bit value is not volatile - you MUST NOT read it
* without sampling the sequence number in xtime_lock.
* get_jiffies_64() will do this for you as appropriate.
*/
extern u64 __jiffy_data jiffies_64;
extern unsigned long volatile __jiffy_data jiffies;

标识符命名

标识符命名应遵循以下原则:

  1. 标识符命名要清晰明了,可以使用完整的单词和易于理解的缩写。短的单词可以通过去元音形成缩写,较长的单词可以取单词的头几个字母形成缩写。看别人的代码看多了就可以总结出一些缩写惯例,例如 count 写成 cntblock 写成 blklength 写成 lenwindow 写成 winmessage 写成 msgnumber 写成 nrtemporary 可以写成 temp ,也可以进一步写成 tmp ,最有意思的是 internationalization 写成 i18n ,词根 trans 经常缩写成 x ,例如 transmit 写成 xmt 。我就不多举例了,请读者在看代码时自己注意总结和积累。
  2. 内核编码风格规定变量、函数和类型采用全小写加下划线的方式命名,常量(比如宏定义和枚举常量)采用全大写加下划线的方式命名,比如上一节举例的函数名 radix_tree_insert 、类型名 struct radix_tree_root 、常量名 RADIX_TREE_MAP_SHIFT 等。
  3. 微软发明了一种变量命名法叫匈牙利命名法(Hungarian notation) ,在变量名中用前缀表示类型,例如 iCnt (i表示int)、 pMsg (p表示pointer)、 lpszText (lpsz表示long pointer to a zero-ended string)等。Linus在 [CodingStyle] 中毫不客气地讽刺了这种写法:“Encoding the type of a function into the name (so-called Hungarian notation) is brain damaged - the compiler knows the types anyway and can check those, and it only confuses the programmer. No wonder MicroSoft makes buggy programs.”代码风格本来就是一个很有争议的问题,如果你接受本章介绍的内核编码风格(也是本书所有范例代码的风格),就不要使用大小写混合的变量命名方式 [1] ,更不要使用匈牙利命名法。
  4. 全局变量和全局函数的命名一定要详细,不惜多用几个单词多写几个下划线,例如函数名 radix_tree_insert ,因为它们在整个项目的许多源文件中都会用到,必须让使用者明确这个变量或函数是干什么用的。局部变量和只在一个源文件中调用的内部函数的命名可以简略一些,但不能太短。尽量不要使用单个字母做变量名,只有一个例外:用 ijk 做循环变量是可以的。
  5. 针对中国程序员的一条特别规定:禁止用汉语拼音做标识符,可读性极差。

错误

yao zhi dao, zhong wen li mian you hen duo duo yin zi, tong yin zi, suo yi ru guo you da duan da duan de pin yin, ni hen nan gao qing chu wo zai shuo shen me.

[1]

大小写混合的命名方式是 Modern C++ 风格所提倡的,在 C++ 代码中很普遍,称为 CamelCase ),大概是因为有高有低像驼峰一样。

例如

struct DateString getDate(time_t RawTime);

函数

每个函数都应该设计得尽可能简单,简单的函数才容易维护。应遵循以下原则:

  1. 实现一个函数只是为了做好一件事情,不要把函数设计成用途广泛、面面俱到的,这样的函数肯定会超长,而且往往不可重用,维护困难。
  2. 函数内部的缩进层次不宜过多,一般以少于4层为宜。如果缩进层次太多就说明设计得太复杂了,应考虑分割成更小的函数(Helper Function) 来调用。
  3. 函数不要写得太长,建议在24行的标准终端上不超过两屏,太长会造成阅读困难,如果一个函数超过两屏就应该考虑分割函数了。 [CodingStyle] 中特别说明,如果一个函数在概念上是简单的,只是长度很长,这倒没关系。例如函数由一个大的 switch 组成,其中有非常多的 case ,这是可以的,因为各 case 分支互不影响,整个函数的复杂度只等于其中一个 case 的复杂度,这种情况很常见,例如 TCP协议的状态机实现。
  4. 执行函数就是执行一个动作,函数名通常应包含动词,例如 get_currentradix_tree_insert
  5. 比较重要的函数定义上侧必须加注释,说明此函数的功能、参数、返回值、错误码等。
  6. 另一种度量函数复杂度的办法是看有多少个局部变量,5到10个局部变量已经很多了,再多就很难维护了,应该考虑分割成多个函数。

indent工具

indent工具可以把代码格式化成某种风格,例如把 缺少缩进和空白的代码 格式化成内核编码风格:

// $ indent -kr -i8 main.c
// $ cat main.c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main(void)
{
    char gesture[3][10] = { "scissor", "stone", "cloth" };
    int man, computer, result, ret;
    srand(time(NULL));
    while (1) {
        computer = rand() % 3;
        printf
            ("\nInput your gesture (0-scissor 1-stone 2-cloth):\n");
        ret = scanf("%d", &man);
        if (ret != 1 || man < 0 || man > 2) {
            printf("Invalid input! Please input 0, 1 or 2.\n");
            continue;
        }
        printf("Your gesture: %s\tComputer's gesture: %s\n",
            gesture[man], gesture[computer]);
        result = (man - computer + 4) % 3 - 1;
        if (result > 0)
            printf("You win!\n");
        else if (result == 0)
            printf("Draw!\n");
        else
            printf("You lose!\n");
    }
    return 0;
}

-kr 选项表示 K&R 风格, -i8 表示缩进8个空格的长度。如果没有指定 -nut 选项,则每8个缩进空格会自动用一个 Tab 代替。注意 indent 命令会直接修改原文件,而不是打印到屏幕上或者输出到另一个文件,这一点和很多 UNIX 命令不同。可以看出, -kr -i8 两个选项格式化出来的代码已经很符合本章介绍的代码风格了,添加了必要的缩进和空白,较长的代码行也会自动折行。美中不足的是没有添加适当的空行,因为 indent 工具也不知道哪几行代码在逻辑上是一组的,空行还是要自己动手添,当然原有的空行肯定不会被 indent 删去的。

如果你采纳本章介绍的内核编码风格,基本上 -kr -i8 这两个参数就够用了。 indent 工具也有支持其它编码风格的选项,具体请参考 Man Page。有时候 indent 工具的确非常有用,比如某个项目中途决定改变编码风格(这很少见),或者往某个项目中添加的几个代码文件来自另一个编码风格不同的项目,但绝不能因为有了 indent 工具就肆无忌惮,一开始把代码写得乱七八糟,最后再依靠 indent 去清理。

gdb

程序中除了一目了然的Bug之外都需要一定的调试手段来分析到底错在哪。到目前为止我们的调试手段只有一种:根据程序执行时的出错现象假设错误原因,然后在代码中适当的位置插入 printf ,执行程序并分析打印结果,如果结果和预期的一样,就基本上证明了自己假设的错误原因,就可以动手修正Bug了,如果结果和预期的不一样,就根据结果做进一步的假设和分析。本章我们介绍一种很强大的调试工具 gdb ,可以完全操控程序的运行,使得程序就像你手里的玩具一样,叫它走就走,叫它停就停,并且随时可以查看程序中所有的内部状态,比如各变量的值、传给函数的参数、当前执行的代码行等。掌握了 gdb 的用法之后,调试手段就更加丰富了。但要注意,即使调试手段丰富了,调试的基本思想仍然是 “分析现象->假设错误原因->产生新的现象去验证假设” 这样一个循环,根据现象如何假设错误原因,以及如何设计新的现象去验证假设,这都需要非常严密的分析和思考,如果因为手里有了强大的工具就滥用而忽略了分析过程,往往会治标不治本地修正Bug,导致一个错误现象消失了但Bug仍然存在,甚至是把程序越改越错。本章通过初学者易犯的几个错误实例来讲解如何使用 gdb 调试程序,在每个实例后面总结一部分常用的 gdb 命令。

注解

Zombie110year

现在有许多图形调试工具可以使用, 但是, 如果你有在控制台字符界面上 debug 的需求, 最好还是学一下 gdb 的用法.

单步执行和跟踪函数调用

看下面的程序:

函数调试实例
#include <stdio.h>

int add_range(int low, int high)
{
    int i, sum;
    for (i = low; i <= high; i++)
        sum = sum + i;
    return sum;
}

int main(void)
{
    int result[100];
    result[0] = add_range(1, 10);
    result[1] = add_range(1, 100);
    printf("result[0]=%d\nresult[1]=%d\n", result[0], result[1]);
    return 0;
}

add_range 函数从 low 加到 high ,在 main 函数中首先从 1 加到 10 ,把结果保存下来,然后从 1 加到 100 ,再把结果保存下来,最后打印的两个结果是:

result[0]=55
result[1]=5105

第一个结果正确 [1] ,第二个结果显然不正确,在小学我们就听说过高斯小时候的故事,从 1 加到 100 应该是 5050 。一段代码,第一次运行结果是对的,第二次运行却不对,这是很常见的一类错误现象,这种情况不应该怀疑代码而应该怀疑数据,因为第一次和第二次运行的都是同一段代码,如果代码是错的,那为什么第一次的结果能对呢?然而第一次和第二次运行时相关的数据却有可能不同,错误的数据会导致错误的结果。在动手调试之前,读者先试试只看代码能不能看出错误原因,只要前面几章学得扎实就应该能看出来。

在编译时要加上 -g 选项,生成的可执行文件才能用gdb进行源码级调试:

$ gcc -g main.c -o main
$ gdb main
GNU gdb 6.8-debian
Copyright (C) 2008 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "i486-linux-gnu"...
(gdb)

-g 选项的作用是在可执行文件中加入源代码的信息,比如可执行文件中第几条机器指令对应源代码的第几行,但并不是把整个源文件嵌入到可执行文件中,所以在调试时必须保证 gdb 能找到源文件。 gdb 提供一个类似 Shell 的命令行环境,上面的 (gdb) 就是提示符,在这个提示符下输入 help可以查看命令的类别:

(gdb) help
List of classes of commands:

aliases -- Aliases of other commands
breakpoints -- Making program stop at certain points
data -- Examining data
files -- Specifying and examining files
internals -- Maintenance commands
obscure -- Obscure features
running -- Running the program
stack -- Examining the stack
status -- Status inquiries
support -- Support facilities
tracepoints -- Tracing of program execution without stopping the program
user-defined -- User-defined commands

Type "help" followed by a class name for a list of commands in that class.
Type "help all" for the list of all commands.
Type "help" followed by command name for full documentation.
Type "apropos word" to search for commands related to "word".
Command name abbreviations are allowed if unambiguous.

也可以进一步查看某一类别中有哪些命令,例如查看 files 类别下有哪些命令可用:

(gdb) help files
Specifying and examining files.

List of commands:

add-shared-symbol-files -- Load the symbols from shared objects in the dynamic linker's link map
add-symbol-file -- Load symbols from FILE
add-symbol-file-from-memory -- Load the symbols out of memory from a dynamically loaded object file
cd -- Set working directory to DIR for debugger and program being debugged
core-file -- Use FILE as core dump for examining memory and registers
directory -- Add directory DIR to beginning of search path for source files
edit -- Edit specified file or function
exec-file -- Use FILE as program for getting contents of pure memory
file -- Use FILE as program to be debugged
forward-search -- Search for regular expression (see regex(3)) from last line listed
generate-core-file -- Save a core file with the current state of the debugged process
list -- List specified function or line
...

现在试试用 list 命令从第一行开始列出源代码:

(gdb) list 1
1   #include <stdio.h>
2
3   int add_range(int low, int high)
4   {
5           int i, sum;
6           for (i = low; i <= high; i++)
7                   sum = sum + i;
8           return sum;
9   }
10

一次只列10行,如果要从第11行开始继续列源代码可以输入

(gdb) list

也可以什么都不输直接敲回车,gdb 提供了一个很方便的功能,在提示符下直接敲回车表示重复上一条命令。

(gdb) (直接回车)
11  int main(void)
12  {
13          int result[100];
14          result[0] = add_range(1, 10);
15          result[1] = add_range(1, 100);
16          printf("result[0]=%d\nresult[1]=%d\n", result[0], result[1]);
17          return 0;
18

gdb 的很多常用命令有简写形式,例如 list 命令可以写成 l,要列一个函数的源代码也可以用函数名做参数:

(gdb) l add_range
1   #include <stdio.h>
2
3   int add_range(int low, int high)
4   {
5           int i, sum;
6           for (i = low; i <= high; i++)
7                   sum = sum + i;
8           return sum;
9   }
10

现在退出gdb的环境:

(gdb) quit

我们做一个实验,把源代码改名或移到别处再用 gdb 调试,这样就列不出源代码了:

$ mv main.c mian.c
$ gdb main
...
(gdb) l
5   main.c: No such file or directory.
    in main.c

可见 gcc 的 -g 选项并不是把源代码嵌入到可执行文件中的,在调试时也需要源文件。现在把源代码恢复原样,我们继续调试。首先用 start 命令开始执行程序:

$ gdb main
...
(gdb) start
Breakpoint 1 at 0x80483ad: file main.c, line 14.
Starting program: /home/akaedu/main
main () at main.c:14
14          result[0] = add_range(1, 10);
(gdb)

gdb停在main函数中变量定义之后的第一条语句处等待我们发命令,gdb列出的这条语句是即将执行的下一条语句。我们可以用 next 命令(简写为n)控制这些语句一条一条地执行:

(gdb) n
15          result[1] = add_range(1, 100);
(gdb) (直接回车)
16          printf("result[0]=%d\nresult[1]=%d\n", result[0], result[1]);
(gdb) (直接回车)
result[0]=55
result[1]=5105
17          return 0;

n 命令依次执行两行赋值语句和一行打印语句,在执行打印语句时结果立刻打出来了,然后停在 return 语句之前等待我们发命令。虽然我们完全控制了程序的执行,但仍然看不出哪里错了,因为错误不在 main 函数中而在 add_range 函数中,现在用 start 命令重新来过,这次用 step 命令(简写为 s )钻进 add_range 函数中去跟踪执行:

(gdb) start
The program being debugged has been started already.
Start it from the beginning? (y or n) y

Breakpoint 2 at 0x80483ad: file main.c, line 14.
Starting program: /home/akaedu/main
main () at main.c:14
14          result[0] = add_range(1, 10);
(gdb) s
add_range (low=1, high=10) at main.c:6
6           for (i = low; i <= high; i++)

这次停在了 add_range 函数中变量定义之后的第一条语句处。在函数中有几种查看状态的办法, backtrace 命令(简写为 bt )可以查看函数调用的栈帧:

(gdb) bt
#0  add_range (low=1, high=10) at main.c:6
#1  0x080483c1 in main () at main.c:14

可见当前的 add_range 函数是被 main 函数调用的, main 传进来的参数是 low=1 , high=10main 函数的栈帧编号为 1, add_range 的栈帧编号为 0。现在可以用 info 命令(简写为 i )查看 add_range 函数局部变量的值:

(gdb) i locals
i = 0
sum = 0

如果想查看 main 函数当前局部变量的值也可以做到,先用 frame 命令(简写为f)选择1号栈帧然后再查看局部变量:

(gdb) f 1
#1  0x080483c1 in main () at main.c:14
14          result[0] = add_range(1, 10);
(gdb) i locals
result = {0, 0, 0, 0, 0, 0, 134513196, 225011984, -1208685768, -1081160480,
...
-1208623680}

注意到 result 数组中有很多元素具有杂乱无章的值,我们知道未经初始化的局部变量具有不确定的值。到目前为止一切正常。用 sn 往下走几步,然后用 print 命令(简写为 p )打印出变量 sum 的值:

(gdb) s
7 sum = sum + i;
(gdb) (直接回车)
6 for (i = low; i <= high; i++)
(gdb) (直接回车)
7 sum = sum + i;
(gdb) (直接回车)
6 for (i = low; i <= high; i++)
(gdb) p sum
$1 = 3

第一次循环 i1 ,第二次循环 i 是 2,加起来是 3 ,没错。这里的 $1 表示gdb保存着这些中间结果, $ 后面的编号会自动增长,在命令中可以用 $1$2$3 等编号代替相应的值。由于我们本来就知道第一次调用的结果是正确的,再往下跟也没意义了,可以用 finish 命令让程序一直运行到从当前函数返回为止:

(gdb) finish
Run till exit from #0  add_range (low=1, high=10) at main.c:6
0x080483c1 in main () at main.c:14
14          result[0] = add_range(1, 10);
Value returned is $2 = 55

返回值是 55 ,当前正准备执行赋值操作,用 s 命令赋值,然后查看 result 数组:

(gdb) s
15          result[1] = add_range(1, 100);
(gdb) p result
$3 = {55, 0, 0, 0, 0, 0, 134513196, 225011984, -1208685768, -1081160480,
...
-1208623680}

第一个值 55 确实赋给了 result 数组的第 0 个元素。下面用 s 命令进入第二次 add_range 调用,进入之后首先查看参数和局部变量:

(gdb) s
add_range (low=1, high=100) at main.c:6
6           for (i = low; i <= high; i++)
(gdb) bt
#0  add_range (low=1, high=100) at main.c:6
#1  0x080483db in main () at main.c:15
(gdb) i locals
i = 11
sum = 55

由于局部变量 isum 没初始化,所以具有不确定的值,又由于两次调用是挨着的, isum 正好取了上次调用时的值,原来这跟 验证局部变量存储空间的分配和释放 是一样的道理,只不过我这次举的例子设法让局部变量 sum 在第一次调用时初值为 0 了。 i 的初值不是 0 倒没关系,在 for 循环中会赋值为 0 的,但 sum 如果初值不是 0 ,累加得到的结果就错了。好了,我们已经找到错误原因,可以退出 gdb 修改源代码了。如果我们不想浪费这次调试机会,可以在 gdb 中马上把 sum 的初值改为 0 继续运行,看看这一处改了之后还有没有别的Bug:

(gdb) set var sum=0
(gdb) finish
Run till exit from #0  add_range (low=1, high=100) at main.c:6
0x080483db in main () at main.c:15
15          result[1] = add_range(1, 100);
Value returned is $4 = 5050
(gdb) n
16          printf("result[0]=%d\nresult[1]=%d\n", result[0], result[1]);
(gdb) (直接回车)
result[0]=55
result[1]=5050
17          return 0;

这样结果就对了。修改变量的值除了用 set 命令之外也可以用 print 命令,因为 print 命令后面跟的是表达式,而我们知道赋值和函数调用也都是表达式,所以也可以用 print 命令修改变量的值或者调用函数:

(gdb) p result[2]=33
$5 = 33
(gdb) p printf("result[2]=%d\n", result[2])
result[2]=33
$6 = 13

我们讲过, printf 的返回值表示实际打印的字符数,所以 $6 的结果是 13。总结一下本节用到的 gdb 命令:

gdb基本命令1
命令 描述
backtrace(或bt) 查看各级函数调用及参数
finish 连续运行到当前函数返回为止,然后停下来等待命令
frame(或f) 帧编号 选择栈帧
info(或i) locals 查看当前栈帧局部变量的值
list(或l) 列出源代码,接着上次的位置往下列,每次列10行
list 行号 列出从第几行开始的源代码
list 函数名 列出某个函数的源代码
next(或n) 执行下一行语句
print(或p) 打印表达式的值,通过表达式可以修改变量的值或者调用函数
quit(或q) 退出gdb调试环境
set var 修改变量的值
start 开始执行程序,停在main函数第一行语句前面等待命令
step(或s) 执行下一行语句,如果有函数调用则进入到函数中
习题

1、用 gdb 一步一步跟踪 递归 讲的 factorial 函数,对照着 factorial(3)的调用过程 查看各层栈帧的变化情况,练习本节所学的各种 gdb 命令。

[1]这么说不够准确,在有些平台和操作系统上第一个结果也未必正确,如果在你机器上运行第一个结果也不正确,首先检查一下程序有没有抄错,如果没抄错那就没关系了,顺着我的讲解往下看就好了,结果是多少都无关紧要。

断点

看以下程序:

#include <stdio.h>

int main(void)
{
    int sum = 0, i = 0;
    char input[5];

    while (1) {
        scanf("%s", input);
        for (i = 0; input[i] != '\0'; i++)
            sum = sum*10 + input[i] - '0';
        printf("input=%d\n", sum);
    }
    return 0;
}

这个程序的作用是:首先从键盘读入一串数字存到字符数组 input 中,然后转换成整型存到 sum 中,然后打印出来,一直这样循环下去。 scanf("%s", input); 这个调用的功能是等待用户输入一个字符串并回车, scanf 把其中第一段非空白(非空格、Tab、换行)的字符串保存到 input 数组中,并自动在末尾添加 '\0' 。接下来的循环从左到右扫描字符串并把每个数字累加到结果中,例如输入是 "2345" ,则循环累加的过程是 (((0*10+2)*10+3)*10+4)*10+5=2345 。注意字符型的 '2' 要减去 '0' 的 ASCII 码才能转换成整数值 2 。下面编译运行程序看看有什么问题

$ gcc main.c -g -o main
$ ./main
123
input=123
234
input=123234
(Ctrl-C退出程序)
$

又是这种现象,第一次是对的,第二次就不对。可是这个程序我们并没有忘了赋初值,不仅 sum 赋了初值,连不必赋初值的 i 都赋了初值。读者先试试只看代码能不能看出错误原因。下面来调试:

$ gdb main
...
(gdb) start
Breakpoint 1 at 0x80483b5: file main.c, line 5.
Starting program: /home/akaedu/main
main () at main.c:5
5           int sum = 0, i = 0;

有了上一次的经验, sum 被列为重点怀疑对象,我们可以用 display 命令使得每次停下来的时候都显示当前 sum 的值,然后继续往下走:

(gdb) display sum
1: sum = -1208103488
(gdb) n
9                   scanf("%s", input);
1: sum = 0
(gdb)
123
10                  for (i = 0; input[i] != '\0'; i++)
1: sum = 0

undisplay 命令可以取消跟踪显示,变量 sum 的编号是 1 ,可以用 undisplay 1 命令取消它的跟踪显示。这个循环应该没有问题,因为上面第一次输入时打印的结果是正确的。如果不想一步一步走这个循环,可以用 break 命令(简写为 b )在第 9 行设一个断点( Breakpoint ) :

(gdb) l
5           int sum = 0, i;
6           char input[5];
7
8           while (1) {
9                   scanf("%s", input);
10                  for (i = 0; input[i] != '\0'; i++)
11                          sum = sum*10 + input[i] - '0';
12                  printf("input=%d\n", sum);
13          }
14          return 0;
(gdb) b 9
Breakpoint 2 at 0x80483bc: file main.c, line 9.

break 命令的参数也可以是函数名,表示在某个函数开头设断点。现在用 continue 命令(简写为 c )连续运行而非单步运行,程序到达断点会自动停下来,这样就可以停在下一次循环的开头:

(gdb) c
Continuing.
input=123

Breakpoint 2, main () at main.c:9
9                   scanf("%s", input);
1: sum = 123

然后输入新的字符串准备转换:

(gdb) n
234
10                  for (i = 0; input[i] != '\0'; i++)
1: sum = 123

问题暴露出来了,新的转换应该再次从 0 开始累加,而 sum 现在已经是 123 了,原因在于新的循环没有把 sum 归零。可见断点有助于快速跳过没有问题的代码,然后在有问题的代码上慢慢走慢慢分析,“断点加单步”是使用调试器的基本方法。至于应该在哪里设置断点,怎么知道哪些代码可以跳过而哪些代码要慢慢走,也要通过对错误现象的分析和假设来确定,以前我们用 printf 打印中间结果时也要分析应该在哪里插入 printf ,打印哪些中间结果,调试的基本思路是一样的。一次调试可以设置多个断点,用 info 命令可以查看已经设置的断点:

(gdb) b 12
Breakpoint 3 at 0x8048411: file main.c, line 12.
(gdb) i breakpoints
Num     Type           Disp Enb Address    What
2       breakpoint     keep y   0x080483c3 in main at main.c:9
    breakpoint already hit 1 time
3       breakpoint     keep y   0x08048411 in main at main.c:12

每个断点都有一个编号,可以用编号指定删除某个断点:

(gdb) delete breakpoints 2
(gdb) i breakpoints
Num     Type           Disp Enb Address    What
3       breakpoint     keep y   0x08048411 in main at main.c:12

有时候一个断点暂时不用可以禁用掉而不必删除,这样以后想用的时候可以直接启用,而不必重新从代码里找应该在哪一行设断点:

(gdb) disable breakpoints 3
(gdb) i breakpoints
Num     Type           Disp Enb Address    What
3       breakpoint     keep n   0x08048411 in main at main.c:12
(gdb) enable 3
(gdb) i breakpoints
Num     Type           Disp Enb Address    What
3       breakpoint     keep y   0x08048411 in main at main.c:12
(gdb) delete breakpoints
Delete all breakpoints? (y or n) y
(gdb) i breakpoints
No breakpoints or watchpoints.

gdb 的断点功能非常灵活,还可以设置断点在满足某个条件时才激活,例如我们仍然在循环开头设置断点,但是仅当 sum 不等于 0 时才中断,然后用 run 命令(简写为 r )重新从程序开头连续运行:

(gdb) break 9 if sum != 0
Breakpoint 5 at 0x80483c3: file main.c, line 9.
(gdb) i breakpoints
Num     Type           Disp Enb Address    What
5       breakpoint     keep y   0x080483c3 in main at main.c:9
    stop only if sum != 0
(gdb) r
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /home/akaedu/main
123
input=123

Breakpoint 5, main () at main.c:9
9                   scanf("%s", input);
1: sum = 123

结果是第一次执行 scanf 之前没有中断,第二次却中断了。总结一下本节用到的 gdb 命令:

gdb基本命令2
命令 描述
break(或b) 行号 在某一行设置断点
break 函数名 在某个函数开头设置断点
break … if … 设置条件断点
continue(或c) 从当前位置开始连续运行程序
delete breakpoints 断点号 删除断点
display 变量名 跟踪查看某个变量,每次停下来都显示它的值
disable breakpoints 断点号 禁用断点
enable 断点号 启用断点
info(或i) breakpoints 查看当前设置了哪些断点
run(或r) 从头开始连续运行程序
undisplay 跟踪显示号 取消跟踪显示
习题

1、看下面的程序:

#include <stdio.h>

int main(void)
{
    int i;
    char str[6] = "hello";
    char reverse_str[6] = "";

    printf("%s\n", str);
    for (i = 0; i < 5; i++)
        reverse_str[5-i] = str[i];
    printf("%s\n", reverse_str);
    return 0;
}

首先用字符串 "hello" 初始化一个字符数组 str (算上 '\0' 共 6 个字符)。然后用空字符串 "" 初始化一个同样长的字符数组 reverse_str ,相当于所有元素用 '\0' 初始化。然后打印 str ,把 str 倒序存入 reverse_str ,再打印 reverse_str 。然而结果并不正确:

$ ./main
hello

我们本来希望 reverse_str 打印出来是 olleh ,结果什么都没有。重点怀疑对象肯定是循环,那么简单验算一下, i=0 时, reverse_str[5]=str[0] ,也就是 'h'i=1 时, reverse_str[4]=str[1] ,也就是 'e' ,依此类推, i=0,1,2,3,4 ,共 5 次循环,正好把 h, e , l , l , o 五个字母给倒过来了,哪里不对了?用 gdb 跟踪循环,找出错误原因并改正。

观察点

接着上一节的步骤,经过调试我们知道,虽然 sum 已经赋了初值 0 ,但仍需要在 while (1) 循环的开头加上 sum = 0;

#include <stdio.h>

int main(void)
{
    int sum = 0, i = 0;
    char input[5];

    while (1) {
        sum = 0;
        scanf("%s", input);
        for (i = 0; input[i] != '\0'; i++)
            sum = sum*10 + input[i] - '0';
        printf("input=%d\n", sum);
    }
    return 0;
}

使用 scanf 函数是非常凶险的,即使修正了这个 Bug 也还存在很多问题。如果输入的字符串超长了会怎么样?我们知道数组访问越界是不会检查的,所以 scanf 会写出界。现象是这样的:

$ ./main
123
input=123
67
input=67
12345
input=123407

下面用调试器看看最后这个诡异的结果是怎么出来的 [2]

$ gdb main
...
(gdb) start
Breakpoint 1 at 0x80483b5: file main.c, line 5.
Starting program: /home/akaedu/main
main () at main.c:5
5           int sum = 0, i = 0;
(gdb) n
9                   sum = 0;
(gdb) (直接回车)
10                  scanf("%s", input);
(gdb) (直接回车)
12345
11                  for (i = 0; input[i] != '\0'; i++)
(gdb) p input
$1 = "12345"

input 数组只有 5 个元素,写出界的是 scanf 自动添的 '\0' ,用 x 命令看会更清楚一些:

(gdb) x/7b input
0xbfb8f0a7: 0x31    0x32    0x33    0x34    0x35    0x00    0x00

x 命令打印指定存储单元的内容。 7b 是打印格式, b 表示每个字节一组, 7 表示打印 7 组 [3] ,从 input 数组的第一个字节开始连续打印 7 个字节。前 5 个字节是 input 数组的存储单元,打印的正是十六进制 ASCII 码的 '1''5' ,第 6 个字节是写出界的 '\0' 。根据运行结果,前 4 个字符转成数字都没错,第 5 个错了,也就是 i 从 0 到 3 的循环都没错,我们设一个条件断点从 i 等于 4 开始单步调试:

(gdb) l
6           char input[5];
7
8           while (1) {
9                   sum = 0;
10                  scanf("%s", input);
11                  for (i = 0; input[i] != '\0'; i++)
12                          sum = sum*10 + input[i] - '0';
13                  printf("input=%d\n", sum);
14          }
15          return 0;
(gdb) b 12 if i == 4
Breakpoint 2 at 0x80483e6: file main.c, line 12.
(gdb) c
Continuing.

Breakpoint 2, main () at main.c:12
12                          sum = sum*10 + input[i] - '0';
(gdb) p sum
$2 = 1234

现在 sum 是 1234 没错,根据运行结果是 123407 我们知道即将进行的这步计算肯定要出错,算出来应该是 12340 ,那就是说 input[4] 肯定不是‘5’了,事实证明这个推理是不严谨的:

(gdb) x/7b input
0xbfb8f0a7: 0x31    0x32    0x33    0x34    0x35    0x04    0x00

input[4] 的确是 0x35 ,产生 123407 还有另外一种可能,就是在下一次循环中 123450 不是加上而是减去一个数得到 123407 。可现在不是到字符串末尾了吗?怎么会有下一次循环呢?注意到循环控制条件是 input[i] != '\0' ,而本来应该是 0x00 的位置现在莫名其妙地变成了 0x04 ,因此循环不会结束。继续单步:

(gdb) n
11                  for (i = 0; input[i] != '\0'; i++)
(gdb) p sum
$3 = 12345
(gdb) n
12                          sum = sum*10 + input[i] - '0';
(gdb) x/7b input
0xbfb8f0a7: 0x31    0x32    0x33    0x34    0x35    0x05    0x00

进入下一次循环,原来的 0x04 又莫名其妙地变成了 0x05 ,这是怎么回事?这个暂时解释不了,但 123407 这个结果可以解释了,是 12345*10 + 0x05 - 0x30 得到的,虽然多循环了一次,但下次一定会退出循环了,因为 0x05 的后面是 '\0'

input[4] 后面那个字节到底是什么时候变的?可以用观察点 (Watchpoint) 来跟踪。我们知道断点是当程序执行到某一代码行时中断,而观察点是当程序访问某个存储单元时中断,如果我们不知道某个存储单元是在哪里被改动的,这时候观察点尤其有用。下面删除原来设的断点,从头执行程序,重复上次的输入,用 watch 命令设置观察点,跟踪 input[4] 后面那个字节(可以用 input[5] 表示,虽然这是访问越界):

(gdb) delete breakpoints Delete all breakpoints? (y or n) y
(gdb) start Breakpoint 1 at 0x80483b5: file main.c, line 5.
Starting program: /home/akaedu/main main () at main.c:5
5 int sum = 0, i = 0;
(gdb) n 9 sum = 0;
(gdb) (直接回车)
10 scanf("%s", input);
(gdb) (直接回车) 12345
11 for (i = 0; input[i] != '\0'; i++)
(gdb) watch input[5]
Hardware watchpoint 2: input[5]
(gdb) i watchpoints
Num Type Disp Enb Address What 2 hw watchpoint keep y input[5]
(gdb) c
Continuing. Hardware watchpoint 2: input[5]
Old value = 0 '\0'
New value = 1 '\001'
0x0804840c in main () at main.c:11
11 for (i = 0; input[i] != '\0'; i++)
(gdb) c Continuing. Hardware watchpoint 2: input[5]
Old value = 1 '\001'
New value = 2 '\002' 0x0804840c in main () at main.c:11
11 for (i = 0; input[i] != '\0'; i++)
(gdb) c Continuing. Hardware watchpoint 2: input[5]
Old value = 2 '\002'
New value = 3 '\003' 0x0804840c in main () at main.c:11
11 for (i = 0; input[i] != '\0'; i++)

已经很明显了,每次都是回到 for 循环开头的时候改变了 input[5] 的值,而且是每次加 1 ,而循环变量 i 正是在每次回到循环开头之前加 1 ,原来 input[5] 就是变量 i 的存储单元,换句话说, i 的存储单元是紧跟在 input 数组后面的。

修正这个 Bug 对初学者来说有一定难度。如果你发现了这个 Bug 却没想到数组访问越界这一点,也许一时想不出原因,就会先去处理另外一个更容易修正的 Bug :如果输入的不是数字而是字母或别的符号也能算出结果来,这显然是不对的,可以在循环中加上判断条件检查非法字符:

while (1) {
    sum = 0;
    scanf("%s", input);
    for (i = 0; input[i] != '\0'; i++) {
        if (input[i] < '0' || input[i] > '9') {
            printf("Invalid input!\n");
            sum = -1;
            break;
        }
        sum = sum*10 + input[i] - '0';
    }
    printf("input=%d\n", sum);
}

然后你会惊喜地发现,不仅输入字母会报错,输入超长也会报错:

$ ./main
123a
Invalid input!
input=-1
dead
Invalid input!
input=-1
1234578
Invalid input!
input=-1
1234567890abcdef
Invalid input!
input=-1
23
input=23

似乎是两个 Bug 一起解决掉了,但这是治标不治本的解决方法。看起来输入超长的错误是不出现了,但只要没有找到根本原因就不可能真的解决掉,等到条件一变,它可能又冒出来了,在下一节你会看到它又以一种新的形式冒出来了。现在请思考一下为什么加上检查非法字符的代码之后输入超长也会报错。最后总结一下本节用到的 gdb 命令:

gdb基本命令3
命令 描述
watch 设置观察点
info(或i) watchpoints 查看当前设置了哪些观察点
x 从某个位置开始打印存储单元的内容,全部当成字节来 看,而不区分哪个字节属于哪个变量
[2]不得不承认,在有些平台和操作系统上也未必得到这个结果,产生Bug的往往都是一些平台相关的问题,举这样的例子才比较像是真实软件开发中遇到的Bug,如果您的程序跑不出我这样的结果,那这一节您就凑合着看吧。
[3]打印结果最左边的一长串数字是内存地址,在 内存与地址 详细解释,目前可以无视。

段错误

如果程序运行时出现段错误,用 gdb 可以很容易定位到究竟是哪一行引发的段错误,例如这个小程序:

#include <stdio.h>

int main(void)
{
    int man = 0;
    scanf("%d", man);
    return 0;
}

调试过程如下:

$ gdb main
...
(gdb) r
Starting program: /home/akaedu/main
123

Program received signal SIGSEGV, Segmentation fault.
0xb7e1404b in _IO_vfscanf () from /lib/tls/i686/cmov/libc.so.6
(gdb) bt
#0  0xb7e1404b in _IO_vfscanf () from /lib/tls/i686/cmov/libc.so.6
#1  0xb7e1dd2b in scanf () from /lib/tls/i686/cmov/libc.so.6
#2  0x0804839f in main () at main.c:6

在 gdb 中运行,遇到段错误会自动停下来,这时可以用命令查看当前执行到哪一行代码了。 gdb 显示段错误出现在 _IO_vfscanf 函数中,用 bt 命令可以看到这个函数是被我们的 scanf 函数调用的,所以是 scanf 这一行代码引发的段错误。仔细观察程序发现是 man 前面少了个 &

继续调试上一节的程序,上一节最后提出修正 Bug 的方法是在循环中加上判断条件,如果不是数字就报错退出,不仅输入字母可以报错退出,输入超长的字符串也会报错退出。表面上看这个程序无论怎么运行都不出错了,但假如我们把 while (1) 循环去掉,每次执行程序只转换一个数:

#include <stdio.h>

int main(void)
{
    int sum = 0, i = 0;
    char input[5];

    scanf("%s", input);
    for (i = 0; input[i] != '\0'; i++) {
        if (input[i] < '0' || input[i] > '9') {
            printf("Invalid input!\n");
            sum = -1;
            break;
        }
        sum = sum*10 + input[i] - '0';
    }
    printf("input=%d\n", sum);
    return 0;
}

然后输入一个超长的字符串,看看会发生什么:

$ ./main
1234567890
Invalid input!
input=-1

看起来正常。再来一次,这次输个更长的:

$ ./main
1234567890abcdef
Invalid input!
input=-1
Segmentation fault

又出段错误了。我们按同样的方法用 gdb 调试看看:

$ gdb main
...
(gdb) r
Starting program: /home/akaedu/main
1234567890abcdef
Invalid input!
input=-1

Program received signal SIGSEGV, Segmentation fault.
0x0804848e in main () at main.c:19
19  }
(gdb) l
14                  }
15                  sum = sum*10 + input[i] - '0';
16          }
17          printf("input=%d\n", sum);
18          return 0;
19  }

gdb 指出,段错误发生在第 19 行。可是这一行什么都没有啊,只有表示 main 函数结束的 } 括号。这可以算是一条规律, 如果某个函数的局部变量发生访问越界,有可能并不立即产生段错误,而是在函数返回时产生段错误

想要写出 Bug-free 的程序是非常不容易的,即使 scanf 读入字符串这么一个简单的函数调用都会隐藏着各种各样的错误,有些错误现象是我们暂时没法解释的:为什么变量 i 的存储单元紧跟在 input 数组后面?为什么同样是访问越界,有时出段错误有时不出段错误?为什么访问越界的段错误在函数返回时才出现?还有最基本的问题,为什么 scanf 输入整型变量就必须要加 & ,否则就出段错误,而输入字符串就不要加 & ?这些问题在后续章节中都会解释清楚。其实现在讲 scanf 这个函数为时过早,读者还不具备充足的基础知识。但还是有必要讲的,学完这一阶段之后读者应该能写出有用的程序了,然而一个只有输出而没有输入的程序算不上是有用的程序,另一方面也让读者认识到, 学C语言不可能不去了解底层计算机体系结构和操作系统的原理 ,不了解底层原理连一个 scanf 函数都没办法用好,更没有办法保证写出正确的程序。

排序与查找

算法的概念

算法( Algorithm) 是将一组输入转化成一组输出的一系列计算步骤, 其中每个步骤必须能在有限时间内完成。 比如 递归 习题 1 中的 Euclid 算法,输入是两个正整数, 输出是它们的最大公约数, 计算步骤是取模、 比较等操作, 这个算法一定能在有限的步骤和时间内完成( 想一想为什么?)。 再比如将一组数从小到大排序, 输入是一组原始数据, 输出是排序之后的数据, 计算步骤包括比较、 移动数据等操作。

算法是用来解决一类计算问题的,注意是一类问题,而不是一个特定的问题。例如,一个排序算法应该能对任意一组数据进行排序,而不是仅对 int a[] = { 1, 3, 4, 2, 6, 5 }; 这样一组数据排序,如果只需要对这一组数据排序可以写这样一个函数来做:

void sort(void)
{
    a[0] = 1;
    a[1] = 2;
    a[2] = 3;
    a[3] = 4;
    a[4] = 5;
    a[5] = 6;
}

这显然不叫算法,因为不具有通用性。由于算法是用来解决一类问题的,它必须能够正确地解决这一类问题中的任何一个实例,这个算法才是正确的。对于排序算法,任意输入一组数据,它必须都能输出正确的排序结果,这个排序算法才是正确的。不正确的算法有两种可能,一是对于该问题的某些输入,该算法会无限计算下去,不会终止,二是对于该问题的某些输入,该算法终止时输出的是错误的结果。有时候不正确的算法也是有用的,如果对于某个问题寻求正确的算法很困难,而某个不正确的算法可以在有限时间内终止,并且能把误差控制在一定范围内,那么这样的算法也是有实际意义的。例如有时候寻找最优解的开销很大,往往会选择能给出次优解的算法。

本章介绍几种典型的排序和查找算法,并围绕这几种算法做时间复杂度分析。学完本章之后如果想进一步学习,可以参考一些全面系统地介绍算法的书,例如 [TAOCP][算法导论]

插入排序

插入排序算法类似于玩扑克时抓牌的过程,玩家每拿到一张牌都要插入到手中已有的牌里,使之从小到大排好序。例如(该图出自 [算法导论] ):

扑克牌的插入排序

也许你没有意识到, 但其实你的思考过程是这样的: 现在抓到一张 7, 把它和手里的牌从右到左依次比较, 7 比 10 小, 应该再往左插, 7 比 5 大, 好, 就插这里。 为什么比较了 10 和 5 就可以确定 7 的位置? 为什么不用再比较左边的 4 和 2 呢? 因为这里有一个重要的前提: 手里的牌已经是排好序的。 现在我插了 7 之后, 手里的牌仍然是排好序的, 下次再抓到的牌还可以用这个方法插入。

编程对一个数组进行插入排序也是同样道理,但和插入扑克牌有一点不同,不可能在两个相邻的存储单元之间再插入一个单元,因此要将插入点之后的数据依次往后移动一个单元。排序算法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/**
 * 插入排序
 */
#include "readarray.h" // 引入 readarray 函数, 用于从 10int.array 中获取数据
#include <stdio.h>

// typedef struct {
//     int length;
//     int *body;
// } array_t;

/**
 *  使用插入法将数组排序为从小到大的序列
 *  将序列划分为 "已排序", "未排序" 两个部分
 *
 *  1. 从未排序部分选中一个基准位值
 *  2. 在已排序部分从后向前依次选择, 将元素向后挪, 当找到一个比此位小的值,
 *     则 将 key 插入到此值之后
 *  3. 将已排序部分向后伸展一位
 *
 *  :param array: 要处理的数组, 此结构体指针在整个程序中不会指向其他位置
 *  :param counter: 计数器, 一次调用增加一
 */
void insertion_sort(const array_t *array, int *counter) {
  int key;          // 选择基准位值
  int sorted_i = 0; // 已排序部分的索引
  int j;            // 当前操作位置

  while (sorted_i < array->length && *counter < 1000) { // 防止失败而陷入死循环
    printarray(array, *counter);
    (*(counter))++;
    // 选出未排序部分中的一个元素
    key = array->body[sorted_i];
    // 在已排序部分中进行比较
    for (j = sorted_i; j > 0; --j) {
      if (array->body[j] < key) {
        array->body[j + 1] = key;
        ++sorted_i; // 成功排序一位
        break;
      } else {
        array->body[j] = array->body[j - 1];
      }
    }
    // 如果排到了头部, 则说明 key 在已排序部分最小, 将他插到第一位
    if (j == 0) {
      array->body[j] = key;
      ++sorted_i; // 成功排序一位
    }
  }
}

int main(void) {
  array_t array;
  int counter = 0;
  readarray("10int.array", &array);
  insertion_sort(&array, &counter);
  printarray(&array, -1);
  return 0;
}

为了更清楚地观察排序过程,我们在每次循环开头插了打印语句,在排序结束后也插了打印语句。程序运行结果是:

 0:  96,  93,   0,   0,   2,  73,  21,  73,  30,  46,
 1:  96,  93,   0,   0,   2,  73,  21,  73,  30,  46,
 2:  93,  96,   0,   0,   2,  73,  21,  73,  30,  46,
 3:   0,  93,  96,   0,   2,  73,  21,  73,  30,  46,
 4:   0,   0,  93,  96,   2,  73,  21,  73,  30,  46,
 5:   0,   0,   2,  93,  96,  73,  21,  73,  30,  46,
 6:   0,   0,   2,  73,  93,  96,  21,  73,  30,  46,
 7:   0,   0,   2,  21,  73,  93,  96,  73,  30,  46,
 8:   0,   0,   2,  21,  73,  73,  93,  96,  30,  46,
 9:   0,   0,   2,  21,  30,  73,  73,  93,  96,  46,
-1:   0,   0,   2,  21,  30,  46,  73,  73,  93,  96,

这个来自 Wikipedia 的 GIF 图也许能给你带来直观的印象:

https://upload.wikimedia.org/wikipedia/commons/0/0f/Insertion-sort-example-300px.gif

如何严格证明这个算法是正确的? 换句话说, 只要反复执行该算法的外层 for 循环体, 执行 length-1 次, 就一定能把数组 array 排好序, 而不管数组 array 的原始数据是什么, 如何证明这一点呢? 我们可以借助 循环不变性 的概念和数学归纳法来理解循环结构的算法, 假如某个判断条件满足以下三条准则, 它就称为 循环不变性

  1. 第一次执行循环体之前该判断条件为真。
  2. 如果 “第 N-1 次循环之后( 或者说第 N 次循环之前) 该判断条件为真” 这个前提可以成立, 那么就有办法证明第 N 次循环之后该判断条件仍为真。
  3. 如果在所有循环结束后该判断条件为真,那么就有办法证明该算法正确地解决了问题。

只要我们找到这个 循环不变性 ,就可以证明一个循环结构的算法是正确的。上述插入排序算法的 循环不变性 是这样的判断条件:第 j 次循环之前,子序列 array[0 .. j-1] 是排好序的。在上面的打印结果中, 我在最前面标注了子序列是前多少位 。下面我们验证一下 循环不变性 的三条准则:

  1. 第一次执行循环之前, j=1 ,子序列 a[0..j-1] 只有一个元素 a[0] ,只有一个元素的序列显然是排好序的。
  2. j 次循环之前,如果 “子序列 a[0..j-1] 是排好序的” 这个前提成立,现在要把 key=a[j] 插进去,按照该算法的步骤,把 a[j-1]a[j-2]a[j-3] 等等比 key大的元素都依次往后移一个,直到找到合适的位置给 key 插入,就能证明循环结束时子序列 a[0..j] 是排好序的。就像插扑克牌一样,“手中已有的牌是排好序的”这个前提很重要,如果没有这个前提,就不能证明再插一张牌之后也是排好序的。
  3. 当循环结束时, j=length ,如果“子序列 a[0..j-1] 是排好序的”这个前提成立,那就是说 a[0..length-1] 是排好序的,也就是说整个数组 arraylength 个元素都排好序了。

可见,有了这三条,就可以用数学归纳法证明这个循环是正确的。这和 递归 证明递归程序正确性的思想是一致的,这里的第一条就相当于递归的 Base Case,第二条就相当于递归的递推关系。这再次说明了递归和循环是等价的。

算法的时间复杂度分析

解决同一个问题可以有很多种算法,比较评价算法的好坏,一个重要的标准就是算法的时间复杂度。现在研究一下插入排序算法的执行时间,按照习惯,输入长度 length 以下用 n 表示。设循环中各条语句的执行时间分别是 \(c_1, c_2, c_3\) 这样三个常数 [1]

void insertion_sort(int* array, size_t length)   // 执行时间
{
    int key;
    int i;

    for (int j = 1; j < length; j++) {
        key = array[j];                     // c_1
        for (i = j - 1; i >= 0; i--) {
            if (array[i] > key) {           // c_2, 表示整个 if 结构
                array[i + 1] = array[i];
            } else {
                break;
            }
        }
        array[i + 1] = key;                 // c_3
    }

显然外层 for 循环的执行次数是 \(n-1\) 次,假设内层的 for 循环执行 \(m\) 次,则总的执行时间粗略估计是 \((n-1) (c_1 + c_3 + m c_2)\) 。当然, for 循环 后面括号中的赋值和条件判断的执行也需要时间,而我没有设一个常数来表示,这不影响我们的粗略估计。

这里有一个问题, \(m\) 不是个常数,也不取决于输入长度 \(n\) ,而是取决于具体的输入数据。在最好情况下,数组 a 的原始数据已经排好序了,内层 for 循环一次也不执行,总的执行时间是 \((n-1)*(c_1 + c_3)\) ,可以表示成 \(an+b\) 的形式,是 \(n\) 的线性函数(Linear Function) 。那么在最坏情况(Worst Case) 下又如何呢?所谓最坏情况是指数组 a 的原始数据正好是从大到小排好序的,这意味着需要从头到尾重新排序,把上式中的 \(m\) 替换掉算一下执行时间就是:

\[m = \sum_{i=1}^{n-1} i = \frac{n}{2}\]

则总时间为

\[T = (n-1)(c_1 + c_3 + \frac{c_2 n}{2}) = \frac{c_2}{2} n^2 + \frac{2c_1 + 2c_3 - c_2}{2} - (c_1 + c_3)\]

数组a的原始数据属于最好和最坏情况的都比较少见,如果原始数据是随机的,可称为平均情况(Average Case) 。如果原始数据是随机的,那么每次循环将已排序的子序列 a[1..j-1] 与新插入的元素 key 相比较,子序列中都存在一些元素比 key 大而另一些比 key 小, 导致 \(m\) 能有所积累。最后的结论应该是:在最坏情况和平均情况下,总的执行时间都可以表示成 \(a n^2 + b n + c\) 的形式,是 \(n\) 的二次函数(Quadratic Function) 。

在分析算法的时间复杂度时,我们更关心最坏情况而不是最好情况,理由如下:

  1. 最坏情况给出了算法执行时间的上界,我们可以确信,无论给什么输入,算法的执行时间都不会超过这个上界,这样为比较和分析提供了便利。
  2. 对于某些算法,最坏情况是最常发生的情况,例如在数据库中查找某个信息的算法,最坏情况就是数据库中根本不存在该信息,都找遍了也没有,而某些应用场合经常要查找一个信息在数据库中存在不存在。
  3. 虽然最坏情况是一种悲观估计,但是对于很多问题,平均情况和最坏情况的时间复杂度差不多,比如插入排序这个例子,平均情况和最坏情况的时间复杂度都是输入长度 \(n\) 的二次函数。

比较两个多项式 : \(a_1 n+ b_1\)\(a_2 n_2+b_2 n+c_2\) 的值( \(n\) 取正整数)可以得出结论: \(n\) 的最高次指数是最主要的决定因素,常数项、低次幂项和系数都是次要的。比如 \(100n+1\)\(n2+1\) ,虽然后者的系数小,当 \(n\) 较小时前者的值较大,但是当 \(n>100\) 时,后者的值就远远大于前者了。如果同一个问题可以用两种算法解决,其中一种算法的时间复杂度为线性函数,另一种算法的时间复杂度为二次函数,当问题的输入长度 \(n\) 足够大时,前者明显优于后者。因此我们可以用一种更粗略的方式表示算法的时间复杂度,把系数和低次幂项都省去,线性函数记作 \(\Theta(n)\) ,二次函数记作 \(\Theta(n^2)\)

\(\Theta(g(n))\) 表示和 \(g(n)\) 同一量级的一类函数,例如所有的二次函数 \(f(n)\) 都和 \(g(n)=n^2\) 属于同一量级,都可以用 \(\Theta(n^2)\) 来表示,甚至有些不是二次函数的也和 \(n^2\) 属于同一量级,例如 \(2 n^2+ 3 \operatorname{lg} n\) 。“同一量级”这个概念可以用下图来说明(该图出自 [算法导论] ):

Theta-notation

如果可以找到两个正的常数 \(c_1\)\(c_2\) ,使得 \(n\) 足够大的时候(也就是 \(n \ge n_0\) 的时候) \(f(n)\) 总是夹在 \(c_1 g(n)\)\(c_2g(n)\) 之间,就说 \(f(n)\) 是同一量级的, \(f(n)\) 就可以用 \(\Theta(g(n))\) 来表示。

以二次函数为例,比如 \(\frac{1}{2} n^2-3n\) ,要证明它是属于 \(\Theta(n^2)\) 这个集合的,我们必须确定 \(c_1, c_2, n_0\) ,这些常数不随 \(n\) 改变,并且当 \(n \ge n_0\) 以后, \(c_1 n^2 \le \frac{1}{2} n^2 - 3n \le c_2 n^2\) 总是成立的。为此我们从不等式的每一边都除以n2,得到 \(c_1 \le \frac{1}{2} - \frac{3}{n} \le c_2\) 。见下图:

1/2-3/n

这样就很容易看出来,无论 \(n\) 取多少,该函数一定小于 \(\frac{1}{2}\) ,因此 \(c_2 = \frac{1}{2}\) ,当 \(n=6\) 时函数值为 0 , \(n>6\) 时该函数都大于 0 ,可以取 \(n_0=7\)\(c_1=\frac{1}{14}\) ,这样当 \(n \ge n_0\) 时都有 \(\frac{1}{2} - \frac{3}{n} \ge c_1\) 。通过这个证明过程可以得出结论,当 \(n\) 都夹在 \(c_1 n^2\)\(c_2 n^2\) 之间,相对于 \(n^2\) 项来说 \(bn+c\) 的影响可以忽略, \(a\) 可以通过选取合适的 \(c_1, c_2\) 来补偿。

几种常见的时间复杂度函数按数量级从小到大的顺序依次是 \(\Theta(\lg n), \Theta(\sqrt{n}), \Theta(n), \Theta(n \lg n), \Theta(n^2), \Theta(n^3), \Theta(2^n), \Theta(n!)\) 。其中, \(\lg n\) 通常表示以 10 为底 \(n\) 的对数,但是对于 \(\Theta\) -notation 来说, \(\Theta(\lg n)\)\(\Theta(\log_2 n)\) 并无区别(想一想这是为什么),在算法分析中 \(\lg n\) 通常表示以 2 为底 \(n\) 的对数。可是什么算法的时间复杂度里会出现 \(\lg n\)lg` 呢?下一节 归并排序 的时间复杂度里面就有 \(\lg\) ,请读者留心 \(\lg\) 运算是从哪出来的。

除了 \(\Theta\) -notation 之外,表示算法的时间复杂度常用的还有一种 Big-O notation 。我们知道插入排序在最坏情况和平均情况下时间复杂度是 \(\Theta(n^2)\) ,在最好情况下是 \(\Theta(n)\) ,数量级比 \(\Theta(n^2)\) 要小,那么总结起来在各种情况下插入排序的时间复杂度是 \(O(n^2)\)\(\Theta\) 的含义和“等于”类似,而 \(O\) 的含义和“小于等于”类似。

[1]受内存管理机制的影响,指令的执行时间不一定是常数,但执行时间的上界(Upper Bound) 肯定是常数,我们这里假设语句的执行时间是常数只是一个粗略估计。

归并排序

插入排序算法采取增量式(Incremental) 的策略解决问题,每次添一个元素到已排序的子序列中,逐渐将整个数组排序完毕,它的时间复杂度是 \(O(n^2)\) 。下面介绍另一种典型的排序算法--归并排序,它采取分而治之(Divide-and-Conquer) 的策略,时间复杂度是 \(\Theta(n \lg n)\) 。归并排序的步骤如下:

  1. Divide: 把长度为 \(n\) 的输入序列分成两个长度为 \(\frac{n}{2}\) 的子序列。
  2. Conquer: 对这两个子序列分别采用归并排序。
  3. Combine: 将两个排序好的子序列合并成一个最终的排序序列。

在描述归并排序的步骤时又调用了归并排序本身,可见这是一个递归的过程。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
// TODO:
const int gLEN = 8;
int gCOUNT = 0;

void mergeSort(int* array, int length);
extern int* array;
/**
 * 打印目标数组的情况
 */
void printArray()
{
    printf("%2d: ", gCOUNT); // %I64d 是 size_t 类型的格式符
    for (size_t i = 0; i < gLEN; i++) {
        printf("%3d, ", *(array + i));
    }
    printf("\n");
}

int main(void)
{
    srand(time(NULL));
    static int array[gLEN];
    for (int i = 0; i < gLEN; i++) {
        array[i] = rand() % 100;
    }
    mergeSort(array, gLEN);
    return 0;
}

/**
 * 归并排序
 */
void mergeSort(int* array, int length)
{
    gCOUNT++;
    printArray();

    int mid = length >> 1; // 将 length 除以二
    int *left, *right;
    left = array;
    right = array + mid;
    // 拆分数组
    if (mid <= 1) {
        mergeSort(left, mid);
        mergeSort(right, length - mid);
    } else {
        for (int i = 0; i < mid; i++) {
            if (left[i] <= right[i]) {
                array[2 * i] = left[i];
                array[2 * i + 1] = right[i];
            } else {
                array[2 * i] = right[i];
                array[2 * i + 1] = left[i];
            }
        }
    }
}
https://upload.wikimedia.org/wikipedia/commons/c/cc/Merge-sort-example-300px.gif

栈与队列

数据结构的概念

数据结构(Data Structure) 是数据的组织方式。程序中用到的数据都不是孤立的,而是有相互联系的,根据访问数据的需求不同,同样的数据可以有多种不同的组织方式。以前学过的复合类型也可以看作数据的组织方式,把同一类型的数据组织成数组,或者把描述同一对象的各成员组织成结构体。数据的组织方式包含了存储方式和访问方式这两层意思,二者是紧密联系的。例如,数组的各元素是一个挨一个存储的,并且每个元素的大小相同,因此数组可以提供按下标访问的方式,结构体的各成员也是一个挨一个存储的,但是每个成员的大小不同,所以只能用.运算符加成员名来访问,而不能按下标访问。

本章主要介绍栈和队列这两种数据结构以及它们的应用。从本章的应用实例可以看出,一个问题中数据的存储方式和访问方式就决定了解决问题可以采用什么样的算法,要设计一个算法就要同时设计相应的数据结构来支持这种算法。所以Pascal语言的设计者Niklaus Wirth提出:算法+数据结构=程序(详见 [算法+数据结构=程序] )。

堆栈

是计算机程序中相当重要的两个概念。

虽然很多情况下,堆栈 这两个字常组合在一起使用,表示的却仅仅是 的意思。虽然栈在很多使用场景上都是被分配在堆上的,但 还是需要区分开的。它们本来就不是同一个意思, 表示一类内存空间, 却是一种数据结构。

栈是一系列数据的组合,类似于数组。但和数组不同的是,数组可以按索引进行访问,而栈只允许 push, pop 两种行为:

  • push 向栈中压入一个元素
  • pop 从栈中弹出一个元素

栈具有 先进后出 的性质,类似于一口井 – 你可以向井里一层一层地扔东西,但是得把上层的东西掏出来才能拿到下层的东西:

栈

在设计栈这类数据结构时,只需要满足三个要素就可以了:

  1. 存储数据的空间
  2. 指示当前层数的索引 (称作 栈顶指针 )
  3. push 与 pop 两个行为

其他的部分并不仅仅是栈的特征。我们用数组来存储 char 类型的数据,并设计一个栈:

struct {
    int top;    // 栈的当前层数
    char *body; // 存储实际数据
}

为了追求良好的编码风格,对于复杂的结构,定义其构造与析构函数: new, delete 来初始化结构或释放内存。

另外,为了实际上应用的考虑,再添加一个 length 成员以限制栈的大小。

用栈实现逆序打印
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <stdio.h>
#include <stdlib.h>

typedef struct {
  int top;      // 栈的当前层数
  int length;   // 栈的最大深度
  char *body;   // 存储实际数据
} char_stack_t;

// 栈的实例化
int new (char_stack_t *);
// 栈的析构
int delete (char_stack_t *);

/* 栈的标志行为 -- push, pop */
int push(char_stack_t *, char);
/* 用指针接收值, 返回值用于传递错误码 */
int pop(char_stack_t *, char *);

int main(int argc, char const *argv[]) {
  char_stack_t stack;
  new (&stack);

  char *string = "abcdefghij";
  for (int i = 0; i < 10; ++i) {
    push(&stack, string[i]);
  }
  for (char i; pop(&stack, &i) == 0;) {
    putchar(i);
  }
  putchar('\n');

  delete (&stack);
  return 0;
}

int push(char_stack_t *stack, char item) {
  if (stack->top < stack->length - 1) {
      // 入栈前, 先向后运动一位
    ++stack->top;
    stack->body[stack->top] = item;
    return 0;
  } else {
    return -1; // 栈已满
  }
}

int pop(char_stack_t *stack, char *result) {
  if (stack->top >= 0) {
    *result = stack->body[stack->top];
    // 出栈后, 再向前运动一位
    --stack->top;
    return 0;
  } else {
    return -1; // 栈已空
  }
}

int new (char_stack_t *stack) {
  stack->body = calloc(100, sizeof(char));
  if (stack->body != NULL) {
    stack->top = -1; // -1 表示空
    stack->length = 100;
    return 0;
  } else {
    return -1; // 内存分配失败
  }
}

int delete (char_stack_t *stack) {
  free(stack->body);
  stack->top = -1;
  stack->length = 0;
  return 0;
}

char_stack_t 的成员 body 是一个字符数组,栈的存储空间;而成员 top 则是 栈顶指针,在栈的任何一次操作中,它总是指向栈顶的当前元素,这被称作 类不变性。在前面的 排序与查找 中介绍了插入排序的 循环不变性,这里的 类不变性 与之类似:

  1. 在 push 之前, top 指向栈顶元素
  2. 在 pop 之前, top 指向栈顶元素
  3. 在 push, pop 之后, top 指向新的栈顶元素

警告

popposh 操作中,总是要先检查 top 是否越界,否则会有发生 段错误 的危险。

除了利用上述的数组构成的栈之外,还可以利用递归函数来实现 “先进后出” 的逆序打印需求:

利用递归实现逆序打印
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#define LEN 3

char buf[LEN]={'a', 'b', 'c'};

void print_backward(int pos)
{
    if(pos == LEN)
    return;
    print_backward(pos+1);
    putchar(buf[pos]);
}

int main(void)
{
    print_backward(0);
    putchar('\n');

    return 0;
}

这是利用了函数的 帧栈 特性。所谓的 就是指函数调用时所产生的作用域。在帧上,函数存储了本地变量的数据。在整个程序中,帧又是以栈的形式存储的。因此在递归调用中,也有 “先调用后结束” 的特性。

深度优先搜索

现在我们用堆栈解决一个有意思的问题,定义一个二维数组:

迷宫设定
OXOOO
OXOXO
OOOXO
OXXXO
OOOXO

它表示一个迷宫,其中的 X 表示墙壁, O 表示可以走的路,只能横着走或竖着走,不能斜着走,要求编程序找出从左上角到右下角的路线。程序如下:

这下,在栈 stack_t 中存储的元素是 point_t 类型的,存储了迷宫中一个位置的坐标。

让我们着重看看 walk 函数,了解迷宫中的每一步,程序是如何寻路的。

首先,我们走到了起点 (0, 0),并将这一步压入路径栈。 然后就开始循环,进行了一系列判定:

  1. 从路径栈中弹出一步(上一步)
  2. 根据上一步的位置,首先判断是否已经到达终点,然后依次判定右,下,左,上是否可以移动。如果可以移动过去,则移动到目标位置。并将这一步存入栈中。
  3. 当走到岔路时,将按照右、下、左、上的顺序随便走进一条路。
  4. 当走到死路时,

C语言本质

Linux系统编程

C 标准库

对于 Linux 系统, 标准库头文件处于目录 /usr/include 之下. 对应的链接库则位于 /lib/x86_64-linux-gnu/libc.so.6.

实际上这是个符号链接, 指向了 /lib/x86_64-linux-gnu/libc-2.27.so. 估计和操作系统的版本有关系.

libc 一般都可执行以显示版本信息:

$ /lib/x86_64-linux-gnu/libc.so.6

GNU C Library (Debian GLIBC 2.27-3) stable release version 2.27.
Copyright (C) 2018 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 7.3.0.
libc ABIs: UNIQUE IFUNC
For bug reporting instructions, please see:
<http://www.debian.org/Bugs/>.

从路径上可以看出不同的 CPU 架构会影响系统存放库文件的路径.

标准库根据用途不同分成了 15 个不同的头文件. 但他们都会链接到同一个库文件.

assert.h
提供一个宏, 验证一个表达式的布尔值, 当值为假时输出诊断信息.
ctype.h
提供了检查变量数值类型的函数.
errno.h
提供整数变量 errno, 这个变量可由系统自动更改, 或者手动修改. 它的值对应了不同的错误类型, 可用 man errno 查看.
float.h
提供了当前平台关于浮点数标准的常量. 只提供信息, 不提供功能.
limits.h
提供了当前平台关于各类型数值极限的常量. 只提供信息, 不提供功能.
locale.h
与本地化相关的数据结构与函数. 可用于设定日期格式, 货币符号等等.
math.h
一系列数学计算函数. 参数与返回值都是 double 类型.
setjmp.h
强大的跳转功能. 比 goto 语句更强大也更难用. 不推荐使用.
signal.h
处理系统信号.
stdarg.h
提供了一个变长参数列表类型用于函数定义.
stddef.h
定义的标准扩展类型与宏. 其他头文件大多包含了此头文件.
stdio.h
与输入输出相关的类型, 函数与宏.
stdlib.h
通用工具. 与随机数相关的函数与宏也在此头文件中.
string.h
处理字符串(字符数组).
time.h
操作日期与时间.

assert.h

assert.h 头文件中只提供了一个宏函数 assert() 使用. 这个函数将判断传入的表达式的布尔值, 如果判断为假, 则中断程序并输出调试信息.

assert(expr)
#  define assert(expr)                                                  \
((void) sizeof ((expr) ? 1 : 0), __extension__ ({                       \
    if (expr)                                                           \
        ; /* empty */                                                   \
    else                                                                \
        __assert_fail (#expr, __FILE__, __LINE__, __ASSERT_FUNCTION);   \
    }))

math.h

概览

math.h 提供了一系列数学计算相关的函数, 在某些版本的 GCC 中, 需要在链接时指定 libm 才能使用:

gcc test_math.c -lm

math.h 中的函数传入的参数和返回值都是 double 类型的.

HUGE_VAL

用来表示一个无法用浮点数表示的数, 可以认为代表 “无穷大”

当数值超出浮点数范围时, errno 被设为 ERANGE, 并返回 HUGE_VAL- HUGE_VAL.

函数

三角函数
double acos(double)
返回:反余弦 \(\arccos\), 弧度制
double asin(double)
返回:反正弦 \(\arcsin\), 弧度制
double atan(double)
返回:反正切 \(\arctan\), 弧度制
double atan2(double y, double x)
返回:点 (x, y) 对应的角度, 弧度制
double sin(double)
返回:正弦 \(\sin\), 弧度制
double cos(double)
返回:余弦 \(\cos\), 弧度制
double tan(double)
返回:正切 \(\tan\), 弧度制
双曲函数
double cosh(double)
返回:双曲余弦 \(\cosh\)
double sinh(double)
返回:双曲正弦 \(\sinh\)
double tanh(double)
返回:双曲正切 \(\tanh\)
指数对数
double exp(double x)
返回:指数 \(e^x\)
double frexp(double x, int *n)

分解浮点数 \(x = a \times 2^n\)

返回值为 \(a\), 将指数存入整数指针 n 所指的内存.

double ldexp(double x, int n)
返回:\(x \times 2^n\)
double log(double x)
返回:自然对数 \(\ln x\) (基数为 \(e\))
double log10(double x)
返回:常用对数 \(\log x\), (基数为 10)
double pow(double x, double y)
返回:\(x^y\)
其他
double sqrt(double x)

平方根.

返回:\(\sqrt{x}\)
double fmod(double x, double *a)

分解浮点数为整数与小数部分 \(x = a + b\)

  • a 为整数部分
  • b 为小数部分

小数部分被返回, 整数部分通过指针赋值.

返回:b
double modf(double x, double y)

返回 \(x / y\) 的余数.

double ceil(double x)

向上取整

ceil(123.9) == 124.0;
ceil(123.1) == 124.0;
ceil(122.9) == 123.0;
double fabs(double x)

绝对值

返回:\(|x|\)

signal.h

处理 *nix 系统的进程信号.

stdarg.h

stdarg 提供了可变长参数功能.

类型

va_list

一个可变长的参数列表.

va_start(ap, param)

初始化 va_list 参数列表. 可变长参数必须初始化后使用.

参数:
  • ap – 需要初始化的 va_list 变量.
  • param – 最后一个固定参数.
va_arg(ap, type)

从可变长参数列表 ap 中获取下一个 type 类型的参数.

如果 ap 中有不同类型的参数, 且 type 未改变, 则对应位置的参数会被跳过, 并且可能访问越界.

参数:
  • ap – 可变长参数列表.
  • type – 下一个获取的参数值的类型.
va_end(ap)

释放参数列表 ap 占用的内存.

va_copy(dest, src)

将 src 复制到 dest 当中. src 的读取状态也会被复制. dest 将无法获取已经被 src 提取过的参数.

参数:

示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include <stdarg.h>
#include <stdio.h>

/**
 * 虽然第一个参数 count 并不要求为可变参数的数目,
 * 但是读取可变参数的过程中需要这一个参数用于循环.
 *
 * 可以使用多个固定参数, 用省略号 ``...`` 表示接下来的参数数目不定.
 */
int sum(const int count, ...)
{
    va_list args;
    int sum = 0;
    int tmp;
    // 初始化参数列表, 传入第二个参数表示
    // 这个函数的最后一个固定参数.
    va_start(args, count);
    for (int i = 0; i < count; i++){
        // 从参数列表中获取下一个 int 类型的值
        tmp = va_arg(args, int);
        printf("%d, ", tmp);
        sum += tmp;
    }
    va_end(args);
    return sum;
}

double avg(int count, ...)
{
    va_list args, copied;
    double result = 0;
    double result_bak = 0;
    double tmp;
    va_start(args, count);
    // 先用掉一个
    result += va_arg(args, double);
    printf("%lf used, ", result);

    // 复制
    va_copy(copied, args);

    for(double i = 1; i < count; i++){
        tmp = va_arg(args, double);
        printf("%lf, ", tmp);
        result += tmp;
    }
    va_end(args);
    putchar('\n');
    for(double i = 0; i < count; i++){
        tmp = va_arg(copied, double);
        printf("%lf, ", tmp);
        result_bak += tmp;
    }
    va_end(copied);
    putchar('\n');

    return result / count;
}

int main(int argc, char *argv[])
{
    int result;
    // 由于 100.0 处被跳过, 最后访问越界
    result = sum(12, 1, 2, 3, 4, 5, 6, 7, 8, 100.0, 10, 11, 12);
    printf("\n%d\n", result);
    // 正确使用方法
    result = sum(3, 888, 999, 1000);
    printf("\n%d\n", result);

    avg(6, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0);
    return 0;
}

stddef.h

定义了一些基本的类型与宏, 此头文件常被其他头文件包含.

类型

ptrdiff_t

指针相减的差值, 有符号整数.

size_t

无符号整数, 用来表示一个 “大小”, 也是 sizeof 返回的结果.

wchar_t

宽字符, 占两个字节. 由于字符编码不统一, 不建议使用.

NULL

空指针, 指向 0 地址:

#define NULL ((void *) 0)
offsetof(类型, 成员名)

返回成员在类型中的字节偏移量.

例如:

struct f32array {
    size_t n;
    float *body;
}

// offsetof(struct f32array, n) == 0
// offsetof(struct f32array, body) == 4

stdio.h

EOF

定义为文件结束符, #define -1 EOF.

FOPEN_MAX

可以同时打开的最大文件数目

FILENAME_MAX

文件名的最大长度

SEEK_CUR

用在 fseek() 函数中, 用于定位文件读写位置. 此宏表示 “当前读写位置”

SEEK_SET

用在 fseek() 函数中, 用于定位文件读写位置. 此宏表示 “文件开头”

SEEK_END

用在 fseek() 函数中, 用于定位文件读写位置. 此宏表示 “文件结尾”

BUFSIZ

当前系统设置的标准文件缓冲区大小. 单位: 字节.

_IOFBF

全缓冲模式, 当缓冲区满时才会发生真实 I/O, 同时清空缓冲区.

_IOLBF

行缓冲模式, 当缓冲区满或者遇到换行符时, 发生真实 I/O, 同时清空缓冲区.

_IONBF

不缓冲模式, 直接进行真实 I/O.

C99 说它们是宏, 让他们高兴
stdin

标准输入流.

stdout

标准输出流.

stderr

标准错误流.

类型

fpos_t

用于存储文件读写位置的类型. 为整数的别名. 可能是 intlong long.

FILE

一个打开的文件类型.

函数

标准输入输出
int getchar(void)

stdin 读取一个字节, 作为返回值返回.

将读写位置正向移动一个字节.

注解

C 语言中没有严谨的 “字符串” 类型. 对于大家常称为字符串的以 \0 结尾的字符数组, 它也是以字节为单位进行处理的. 如果使用了超过 0xff 的编码, 那么一个字符也需要进行多次读取.

char* gets(char* buffer)

stdin 读取一行, 存入到 buffer 中. 并且将 buffer 作为返回值返回. 如果发生错误, 则返回值为 NULL, 包含换行符.

警告

由于未限制行的长度, 可能存在内存溢出的风险, 不推荐使用.

应当使用 fgets(), 可以限制一次读取的字节量.

int putchar(int ch)

ch 写入到 stdout 中. 如果失败, 返回非零值.

int puts(char* str)

str 写入到 stdout 中. 如果失败, 返回非零值.

void perror(char* error_name)

error_name 输出到 stderr 中. error_name 作为输出的错误信息的前缀. 在前缀后, 会根据全局数组 sys_errlist 的最后一个值, 附加 error_msg.

实际上的输出信息是:

${error_name}: ${error_msg}
int scanf(char* fmt, ...)

stdin 读取格式化信息. 格式化字符串参考后文的 格式化输入输出

int printf(char* fmt, ...)

stdout 输出格式化信息. 格式化字符串参考后文的 格式化输入输出

读写文件

注解

文件的打开模式

文件的打开模式使用选项字符串来指定, 会有三种不同角度的选项:

// 读写方式
"r"     // 只读, 可读不可泄, 读写指针在文件头部, 如果文件不存在则报错.
"w"     // 只写, 可写不可读, 读写指针在文件头部, 如果文件不存在则创建, 如果文件已存在则覆盖.
"a"     // 追加, 可写不可读, 读写指针在文件末尾, 如果文件不存在则会创建.

// 打开方式
"t"     // 以文本文件方式打开, 根据系统换行符设置进行换行.
"b"     // 以二进制模式打开, 不做任何多余的事.

"+"     // 可更新, 加上此符号, 取消掉读或写的限制, 但其他设置不变.
FILE* fopen(const char *path, const char *mode)

mode 方式打开 path 路径对应的文件. 返回 FILE * 类型的指针, 之后的读写等操作通过这个指针进行.

文件操作完毕后需要 fclose() 来关闭此文件.

FILE* freopen(const char *path, const char *mode, FILE *stream)

stream 指针指向一个新打开的文件.

参数:
  • path – 将被打开的新文件
  • mode – 文件打开模式
  • stream – 原有的文件指针
int fclose(FILE *stream)

关闭 stream 所指的文件. 会先刷新该文件读写的缓冲区.

int fgetc(FILE* stream)

从打开的可读的文本或二进制流 stream 中读取一个字节, 将其返回并将读写指针后移一个字节.

如果读取到文件末尾, 那么会得到 EOF

char* fgets(char* buffer, int size, FILE* stream)

从打开的可读的文本流 stream 中读取一个字符串, 此字符串以换行符结尾(当然, 最末尾还是 \0), 将其存入 buffer 中, size 通常代表 buffer 的大小, 当读取到的字节数达到 size - 1 后, 读取将中断.

无论是 \r\n 还是 \n 换行, 在程序中都用 \n 存储换行符.

  • 如果读取成功, 那么读写指针将后移至下一行的行首, 函数将返回读取到的字符数组的首地址(指针).
  • 如果读取失败, 那么函数指针将返回空指针( (void*) 0).
  • 如果读取到文件末尾, 也会返回空指针.
参数:
  • buffer – 字节缓冲区
  • size – 字节数目
int fscanf(FILE *stream, const char *format, ...)

类似于 scanf() 但是将从 stream 所指定的流中输入.

size_t fread(void *ptr, size_t size, size_t n, FILE *stream)

stream 中读取 nsize 大小的字节块, 存入 ptr 所指的内存空间中.

int fputc(int ch, FILE *stream)

ch 字符输出到 stream 流.

int fputs(const char *str, FILE *stream)

str 字符串输出到 stream 流. \0 不会被输出.

int fprintf(FILE *stream, const char *format, ...)

类似于 printf() 但是将输出到 stream 所指定的流中.

int fwrite(void *ptr, size_t size, size_t n, FILE *stream)

将指针 ptr 所指的内存空间中的前 nsize 大小的字节块输出到 stream 流中.

int fflush(FILE *stream)

刷新 stream 的输出缓冲区.

文件定位
int feof(FILE *stream)

检测 stream 的读写指针是否已经到达 EOF 位置. 这并不会造成文件读写位置的移动.

int fgetpos(FILE *stream, fpos_t *pos)

读取 stream 的读写位置, 将其存为 fpos_t 的指针 pos 所指的值.

int fsetpos(FILE *stream, const fpos_t *pos)

stream 的读写位置设置为 pos 所指的值.

int fseek(FILE *stream, long offset, int whence)

stream 的读写位置设置为相对于 whenceoffset 个字节的偏移量.

whence 的值可选用

  • SEEK_SET 文件开始
  • SEEK_CUR 当前位置
  • SEEK_END 文件末尾
参数:
  • offset – 偏移量
  • whence – 基准位置
long ftell(FILE *stream)

返回 stream 所指的文件当前读写位置.

void rewind(FILE *stream)

stream 的读写位置重设为文件开头.

缓冲设置
void setbuf(FILE *stream, char *buffer)

stream 的缓冲区设置为 buffer 所指的内存区域. 这个区域至少有 BUFSIZ 字节大小.

int setvbuf(FILE *stream, char *buffer, int bufmode, size_t size)

更详细地设置缓冲区.

参数:
  • bufmode

    缓冲模式, 可选值:

  • size – 设置缓冲区大小, 单位: 字节.
处理字符串
int sprintf(const char* str, const char* fmt, ...)

类似于 printf(), 不过是将格式化的结果存储到 str 字符串中.

int sscanf(const char* str, const char* fmt, ...)

类似 scanf(), 不过是从 str 字符串中读取格式化信息.

无需打开的文件操作
int remove(const char *filepath)

filepath 路径所指的文件删除

int rename(const char *old, const char *new)

old 路径所指的文件重命名(移动)

临时文件
FILE *tmpfile(void)

wb+ 模式创建一个临时文件, 文件名由操作系统决定.

char *tmpnam(char *buffer)

生成一个可用的临时文件路径, 之后可以自行使用 fopen() 创建并打开.

buffer 将存储生成的文件名. 如果传入 NULL, 则会在返回值中返回文件路径字符串的首地址.

如果生成失败, 返回值为 NULL.

va_list 相关
vprintf(const char *fmt, va_list args)

va_list 格式的参数格式化输出.

vfprintf(FILE *stream, const char *fmt, va_list args)

格式化输出到指定的流.

vfprintf(char *str, const char *fmt, va_list args)

格式化输出到指定的字符串.

格式化输入输出

格式化输入时的说明符

格式化输入时一个格式化说明符由 %[*][宽度][修饰符]<类型符> 组成, 类型符是必须的, 其他的可选.

格式化类型符用于限定数据的类型, 决定了字符串的解析方式:

格式化类型符 含义
d int 类型的整数
c char 类型的字符. 但只能有一个字节长.
f float 类型的浮点数. 是 整数.小数 形式的
e E float 类型的浮点数. 是 有效数字e幂次 形式的, E 表示 有效数字E幂次.
s 一个 \0 结尾的 C-style 字符串
u unsigned int 类型的无符号整数
o int 类型的八进制整数
x int 类型的十六进制整数

格式化修饰符修饰了数据的类型:

格式化修饰符 含义
l long 修饰符, 可将整型转为长整型, 单精度浮点数转为双精度
L 修饰 f, e, 表示 long double 类型
h short 修饰符, 可将整型转为短整型.

宽度限定了读入/输出的字符串的最大宽度: “n” 表示最多读取 n 个字符

* 表示这个说明符仅做占位, 不会将值保存到参数中.

格式化输出时的说明符

格式化输出时, 一个格式说明符由 %[标志][宽度][.精度][修饰符]<类型符> 组成, 类型符是必须的.

格式化类型符用于限定数据的类型, 决定了字符串的解析方式:

格式化类型符 含义
d int 类型的整数
c char 类型的字符. 但只能有一个字节长.
f float 类型的浮点数. 是 整数.小数 形式的
e E float 类型的浮点数. 是 有效数字e幂次 形式的
g G 根据浮点数位数不同, 选择输出宽度最短的 e 或 f 表示法
s 一个 \0 结尾的 C-style 字符串
u unsigned int 类型的无符号整数
o int 类型的八进制整数
x int 类型的十六进制整数
p 指针类型, 根据字长的不同, 输出对应位数的十六进制无符号整数

格式化修饰符修饰了数据的类型:

格式化修饰符 含义
l long 修饰符, 可将整型转为长整型, 单精度浮点数转为双精度
L 修饰 f, e, 表示 long double 类型
h short 修饰符, 可将整型转为短整型.

宽度决定了整个输出字符段的长度, 如果输出长度短于此值, 则用前导空格填充, 如果长于此值, 则输出不会截断. 如果该位置为一个 * 星号, 则表示在参数列表中增加一个整型参数, 用于指定此说明符的宽度.

精度对于整数, f 型浮点数和 e 型浮点数等的影响不同:

  • 整数: 同宽度
  • f, e 型浮点数: 保留的小数位数
  • g 型浮点数: 有效位数
  • s 字符串: 最大的字节数. 如果采用了超过 0xff 的字节编码, 会导致超过限定的字符被截断.
  • 如果该位置为一个 * 星号, 则表示在参数列表中增加一个整型参数, 用于指定此说明符的精度.

标志用于确定该输出字段的样式:

  • - 指定左对齐, 默认右对齐
  • + 显示正号, 默认只显示负号
  • `` `` 空格, 当此说明符不输出字符时, 用一个空格代替
  • # 强制保留一些数据特征: - 八进制保留前缀 0 - 十六进制保留前缀 0x - 浮点数保留小数点, 即便小数位全部为 0
  • 0 指定用 0 填充空位, 默认用空格

stdlib.h

EXIT_FAILURE

当程序出错时, 调用 exit() 传入此值:

#define EXIT_FAILURE 1
EXIT_SUCCESS

当程序顺利运行完成时, 调用 exit() 传入此值:

#define EXIT_SUCCESS 0
RAND_MAX

rand() 返回的最大值. 资讯型常量, 修改无用.

MB_CUR_MAX

多字符字符集中的最大字符数.

类型

div_t
int quot

除法的商

int rem

除法的余数

ldiv_t
long int quot

除法的商

long int rem

除法的余数

函数

字符转换
double atof(char *str)

将 str 指向的字符串解析为浮点数:

atof("3.1415926") == 3.1415926;
atof("3.1415926e10") == 3.1415926e10;

如果无法解析, 则返回 0.0.

int atoi(char *str)

将 str 指向的字符串解析为整数:

atoi("123123") == 123123;

如果无法解析, 则返回 0.

如果要解析其他进制表示的整数, 用 strtoi()

long int atol(char *str)

解析长整数

double strtod(char *str, char **endp)

将一个字符串解析为一个浮点数, 如果字符串尾部有不可解析的字符, 则会将其地址存入 endp 如果字符串完全不可解析, 则返回 0.0:

char **p;
strtod("1.4e9 people in China", p) == 1.4e9;
// p -> char *pointer -> " people in China"
long int strtol(char *str, char **endp, int base)

将一个字符串解析为一个长整数, 如果字符串尾部有不可解析的字符, 则会将其地址存入 endpointer 如果字符串完全不可解析, 则返回 0`; ``base 可接受 0, 2~32 为基底:

strtol("100", NULL, 10) == 100;
strtol("100", NULL, 8) == 0100;
strtol("100", NULL, 16) == 0x100;
strtol("100", NULL, 2) == 4;
strtol("100", NULL, 0) == 100; // 十进制
unsigned long int strtoul(char *str, char **endp, int base)

类似 strtol(), 不过解析的是无符号长整型.

内存
void *calloc(size_t items, size_t size)

在堆中分配 items * size 字节大小的连续内存, 返回其首地址, 并将内存置零. 如果失败, 返回 NULL

参数:
  • items (size_t) – 为 items 个元素分配内存
  • size (size_t) – 每一个元素的内存大小, 单位字节.
void *malloc(size_t size)

在堆中分配 size 字节大小的连续内存, 返回其首地址, 不会 将内存置零. 如果失败, 返回 NULL

参数:
  • size (size_t) – 将分配的内存大小, 单位字节.
void *realloc(void *p, size_t new_size)

p 所指的内存释放, 分配一块新的 new_size 字节大小的内存, 并返回新的地址.

void free(void *p)

释放 p 所指向的内存, 无论它是通过 calloc(), malloc() 还是 realloc() 分配的.

随机数
void srand(unsigned int seed)

seed 初始化随机数生成器.

int rand(void)

返回 0 ~ RAND_MAX 之间的随机整数.

操作系统
void exit(int status_code)

终止当前进程, 关闭所有相关的文件描述符, 向父进程发送信号.

参数:
  • status_code (int) – 发送的信号值
void abort(void)

终止当前进程, 发送 SIGABRT 信号而不进行善后工作 [1] .

[1]https://stackoverflow.com/questions/397075/what-is-the-difference-between-exit-and-abort
int atexit(void (*func)(void))

注册一个函数, 让这个函数在程序结束时调用.

这个函数必须是无返回值, 无参数的函数.

char *getenv(const char *envname)

读取一个环境变量.

int system(char *command)

在主机的 Shell 环境中运行 command 指令. 返回系统指令的退出码.

无法连接输入输出.

搜索与排序

C 标准库提供了快速排序和二分查找.

void *binsearch(const void *key, void *base, size_t items, size_t size, int (*compare)(const void *, const void *))

二分查找, 所有的操作都基于指针.

参数:
  • key – 查找目标的指针, 指向一个已定义的对象.
  • base – 指针, 指向查找区域的起点
  • items (size_t) – 查找范围, 元素的数目
  • size (size_t) – 查找范围, 元素的尺寸, 单位字节.
  • compare – 用于对比两个元素大小的函数.
返回:

指向找到的值的指针.

void qsort(void *base, size_t items, size_t size, int (*compare)(const void *, const void *))

快速排序, 所有操作都基于指针.

参数:
  • base – 指针, 排序区域的起点
  • items (size_t) – 排序范围, 元素个数
  • size (size_t) – 排序范围, 元素尺寸, 单位字节
  • compare – 对比两个元素大小的函数

关于函数 compare:

int compare(const void *x, const void *y)
{
    return (*(int *)x - *(int *)y);
}
compare 的单调性对排序查找的影响
如果 x < y, 返回负值, (单调递增) 那么, 排序后的数组将会从小到大排列, 反之从大到小排列; 可以查找一个从小到大排序的数组.
绝对值
int abs(int x)
返回:\(|x|\)
long int labs(long int x)
返回:\(|x|\)
整数除法
div_t div(int a, int b)

整数除法, 返回一个 div_t 结构体, 储存了商与余数.

返回:\(a \div b\)
ldiv_t div(long int a, long int b)

整数除法, 返回一个 ldiv_t 结构体, 储存了商与余数. 与 div() 不同的是, 此函数处理长整型.

返回:\(a \div b\)
宽字符

由于字符编码不统一, 不建议使用. 建议使用 ICU 等第三方库.

string.h

string.h 中的功能, 与其说是处理字符串, 不如说是在处理字节数组, 像复制一块内存之类的函数.

如果想要格式化字符串, 需要使用 stdio.h 中的 sprintf() 函数.

函数

内存区域
void *memchr(const void *str, int ch, size_t size)

在 str 所指的内存区域前 size 字节中查找第一个出现 ch 字节的位置

返回:第一次出现 ch 的地址 (包括此字节)
int memcmp(const void *a, const void *b, size_t size)

比较 a, b 的前 size 个字节. 从低位到高位一个字节一个字节地比较, 当低位出现一个较小的字节时, 判断为小于, 以此类推.

返回:
  • -1, 表示 a < b
  • 0, 表示 a == b
  • 1, 表示 a > b
void *memcpy(void *dest, const void *src, size_t size)

从 src 到 dest 复制前 size 个字节.

参数:
  • src – 源内存区域
  • dest – 目标内存区域
  • size – 将复制的字节数目
void *memmove(void *dest, const void *src, size_t size)

从 src 移动 size 个字节到 dest 中. 如果 dest 与 src 在 size 字节内有重叠, 则此函数将保证 dest 被正确的修改, 而 src 函数可能被更改. 否则此函数作用与 memcpy() 相同.

void *memset(void *dest, int ch, size_t size)

将 dest 的前 size 字节用 ch 填充. 这个函数也常用于设置其他类型的数组.

参数:
  • ch – ch 虽然用 int 类型传入, 但是实际却是当作 unsigned int 类型.
字符串
char *strcat(char *dest, const char *src)

将 src 追加到 dest 后. dest 是一个 C-style 字符串, 要求能够容纳追加后的字符串.

返回:dest 的地址
char *strncat(char *dest, const char *src, size_t n)

将 src 追加到 dest 后. dest 是一个 C-style 字符串, 要求能够容纳追加后的字符串. 最多追加 n 个字节.

参数:
  • n (size_t) – 最大追加的字节数
返回:

dest 的地址

char *strchr(const char *str, int ch)

在参数 str 所指的字符串中搜索第一次出现字符 ch 的位置

参数:
  • ch – 虽然以 int 类型传入, 但是实际上用作 unsigned int 类型.
返回:

返回 ch 出现的地址(包括此字符)

char *strrchr(const char *str, int ch)

搜索 str 中最后一次出现字符 ch 的地址.

size_t strcspn(const char *a, const char *b)

搜索 a 中第一次出现 b 中字符的位置. 返回的是偏移量, 基准位置是 a 的首地址.

返回:b 中包含的字符第一次在 a 中出现的地址偏移量
size_t strspn(const char *a, const char *b)

在 a 中检索第一个不在 b 中出现的字符的地址偏移量

char *strpbrk(const char *a, const char *b)

类似于 strcspn(), 在 a 中检索 b 中的字符, 返回第一次出现的字符的地址.

返回:b 中包含的字符第一次在 a 中出现的地址
char *strstr(const char *str, const char *target)

在 str 中搜索第一次出现 target 字串的地址.

char *strtok(char *str, const char *delimeter)

将 str 拆分成子字符串, delimeter 作为分隔符. 一次调用将会返回一个子串, 当字符串无法再被拆分将会返回 NULL.

参数:
  • delimeter – 分隔符
返回:

一次调用返回一个子串, 当无法继续拆分时返回 NULL.

char *strcmp(const char *a, const char *b)

类似于 memcmp(), 但只针对字符串, 以 \0 终止.

char *strcmp(const char *a, const char *b, size_t n)

类似于 memcmp(), 但只针对字符串, 比较前 n 个字节.

int strcoll(const char *a, const char *a)

比较两个字符串的 “大小”, 根据 LC_COLLATE 的设置 [2]

char *strcpy(char *dest, const char *src)

将 src 复制到 dest

char *strncpy(char *dest, const char *src, size_t n)

将 src 复制到 dest, 最多 n 个字节.

char *strerror(int errno)

传入 C 错误码, 在内部数组中搜索并返回对应的错误信息.

参数:
  • errno – 常用 errno.h 中的 errno 宏.
返回:

错误码对应的字符串消息

size_t strlen(const char *str)

返回 str 的长度, 单位字节. 以 \0 结束.

size_t strxfrm(char *dest, const char *src, size_t n)

根据 LC_COLLATE 区域设置 [2] , 将 src 转换, 并将结果存入 dest 中.

[2](1, 2) 参考 https://zh.cppreference.com/w/c/locale/LC_categories, 定义在 locale.h 中.

time.h

time.h 头文件提供了与时间日期相关的类型, 函数, 结构体.

类型

time_t

time_t 是适合存储时间的类型, 一般为 unsigned int. 是 time() 的返回值的类型.

time.h 中声明的许多函数都会接受一个名为 timer 的 time_t 类型参数.

clock_t

适合于存储处理器计时的类型. 一般为 long int.

struct tm

一个存储时间日期信息的结构体. 由 localtime() 返回. 具有以下成员:

int tm_year

年. 注意, 是从 1900 年开始计算的. 例如, 若得到的值为 119, 则实际上表示 2019 年.

int tm_mon

月. 范围 0~11. 是从 0 开始计算的. 需要注意.

int tm_mday

在某月的第几日. 范围 1~31. 除了这个成员, 其他都是从 0 开始计算.

int tm_wday

在某一周的第几日. 范围 0~6.

int tm_yday

在某一年的第几日. 范围 0~365.

int tm_hour

二十四小时制. 0~23.

int tm_min

分钟. 0~59.

int tm_sec

秒. 0~59.

int tm_isdst

是否为夏令时. 返回 1 或 0.

下面两个成员在定义了 __USE_MISC 后才会被定义.

char* tm_zone

显示当前时区.

long int tm_gmtoff

与 UTC (太平洋标准时间) 相差的秒数. 东为正, 西为负.

long int CLOCKS_PER_SEC

当前处理器每秒对应的时钟个数.

函数

clock_t clock(void)

返回从程序开始运行到此时的 CPU 时钟数. 进程挂起的时间不计算在内.

算式 返回值 / CLOCKS_PER_SEC 将数值单位转化为秒.

time_t time(time_t* timer)

返回当前系统时间. (Unix 时间戳). 如果指针 timer 不是 NULL, 则会将返回值存储在 timer 中.

参数:
  • timer (time_t*) – time_t 的指针.
double difftime(time_t time1, time_t time0)

返回 time1 - time0 的值. 单位为秒.

struct tm* gmtime(time_t *timer)

将 timer 解析为 tm 结构体, 使用 UTC 时区(格林威治时间)

struct tm* localtime(time_t *timer)

将 timer 解析为 tm 结构体, 使用 本地 时区

time_t mktime(struct tm *tp)

将 tm 结构体解析为依据本地时区的 time_t 时间戳.

size_t strftime(char *str, size_t maxsize, char *fmt, struct tm *tp)

根据 fmt 将 tm 结构体转化为字符串.

格式字符串支持的转义符号:

符号 含义 举例
%Y 2019
%m 月 (01-12) 03
%d 日 (01-31) 07
%H 时 (00-23) 00
%M 分 (00-59) 07
%S 秒 (00-59) 55
%A 星期名称 Thursday
%a 星期缩写 Thu
%B 月份名称 March
%c 完整的日期时间表示  
%I 12 小时制小时 (01-12) 12
%j 一年的第几天 (01-366)  
%p AM 或 PM AM
%U 一年的第几周 (00-53) 以第一个星期日开始  
%w 一周的星期几 (0-6) 4
%W 一年的第几周 (00-53) 以第一个星期一开始  
%x 日期 07/03/19
%X 时间 00:18:22
%y 年份, 后两位. 19
%Z 时区.  
参数:
  • str (char *) – 将生成的字符串存入目标字符数组.
  • maxsize (size_t) – 最大可写入的字符数, 一般传入字符数组的长度.
  • fmt (char *) – 格式字符串.
  • tp (struct tm *) – 存储日期时间信息的 tm 结构体.
返回:

如果写入的字符数(包括结束符)小于 maxsize, 则返回去除结束符的写入字符数. 否则返回 0.

返回类型:

size_t

示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
#include <stdio.h>
#include <time.h>

#ifdef _WIN32
#include <Windows.h>
#define sleep(ms) Sleep((ms) * 1000)
/**
 * 挂起操作和操作系统有关,
 * 而且 GCC 与 MSVC 分别使用秒和毫秒
 */
#else
#include <unistd.h>
#endif /* _WIN32 */

void show_struct_tm(void)
{
    time_t timer;
    struct tm* date;
    time(&timer);
    date = localtime(&timer);
    printf("year is %d\n", date->tm_year);
    printf("mon is %d\n", date->tm_mon);
    printf("mday is %d\n", date->tm_mday);
    printf("wday is %d\n", date->tm_wday);
    printf("yday is %d\n", date->tm_yday);
    printf("hour is %d\n", date->tm_hour);
    printf("min is %d\n", date->tm_min);
    printf("sec is %d\n", date->tm_sec);
    printf("isdst is %d\n", date->tm_isdst);
    // GLIBC 才包含以下两个成员, 非 C 标准.
    // printf("zone is %s\n", date->tm_zone);
    // printf("gmtoff is %ld\n", date->tm_gmtoff);
    printf("::::%s\n\n", __func__);
}

void show_clock_per_sec(void)
{
    clock_t clocker;
    clocker = clock();
    printf("clock: %ld\n", clocker);
    printf("CLOCKS_PER_SEC = %ld\n", CLOCKS_PER_SEC);
    printf("::::%s\n\n", __func__);
}

void show_difftime(void)
{
    time_t a, b;
    time(&a);
    sleep(3);
    time(&b);
    printf("TIME1 - TIME2 = %lf\n", difftime(b, a));
    printf("::::%s\n\n", __func__);
}

void show_strtime(void)
{
    time_t timer;
    struct tm *tp;
    char buffer[255] = {'\0'};
    time(&timer);
    tp = localtime(&timer);

    strftime(buffer, 255, "%Y-%m-%d %H:%M:%S", tp);
    printf("%s:::: %s\n", "%Y-%m-%d %H:%M:%S", buffer);
    strftime(buffer, 255, "%c", tp);
    printf("%s:::: %s\n", "%c", buffer);
    strftime(buffer, 255, "%x, %X", tp);
    printf("%s:::: %s\n", "%x, %X", buffer);
    strftime(buffer, 255, "%y", tp);
    printf("%s:::: %s\n", "%y", buffer);
    strftime(buffer, 255, "%I %p", tp);
    printf("%s:::: %s\n", "%I %p", buffer);
    strftime(buffer, 255, "%A, %a, %B", tp);
    printf("%s:::: %s\n", "%A, %a, %B", buffer);
    strftime(buffer, 255, "%Z", tp);
    printf("%s:::: %s\n", "%Z", buffer);

    printf("::::%s\n\n", __func__);
}

int main(int argc, char* argv[])
{
    show_struct_tm();
    show_clock_per_sec();
    show_difftime();
    show_strtime();
    return 0;
}

开发工具

  • 调试, 优化工具;
  • 三方库 等.
测试内存使用错误
Valgrind
运行效率评测
Gprof
查找库
Pkg-config
生成文档
Doxygen 或 Sphinx-doc
构建工具
  • Make: 处理 Makefile
  • CMake: 为跨平台的 C/C++ 程序项目生成 Makefile.
  • Autotools: Autoconf, Automake, libtool. GNU 世界的 Makefile 生成器.
非标准库
libcURL, libGLib, libGSL, libSQLite3, libXML2, GNU Argp.

编译器内置宏

C 标准

__LINE__

此宏所处的源代码行号. 在同一个文件中, 在不同行中, 值有所不同.

__FILE__

源代码文件名.

__DATE__

编译日期.

__TIME__

编译时间.

__STDC__

当编译器被要求完全遵守 C 标准, 禁用编译器扩展时, 此值设为 1, 否则为 0.

__cpludplud

当编译代码为 C++ 时, 此宏被定义.

__func__

当前所处的函数名.

C99 标准

GCC

只想要简单查看可以使用命令 gcc -dM -E - < /dev/null. (其实就是传入空源文件, 查看预处理结果).

或者阅读文档 https://gcc.gnu.org/onlinedocs/cpp/Predefined-Macros.html

Clang

GCC 类似, clang -dM -E - < /dev/null.

确定编译器版本

https://sourceforge.net/p/predef/wiki/Compilers/ 总结了各个编译器预定义的与自身版本相关的宏.

确定操作系统

对应的操作系统会定义对应的宏.

__linux__
_WIN32      // Windows 系统 32 位或 64 位 程序
_WIN64      // 特指 Windows 系统 64 位 程序
__APPLE__

附录

字符编码

Unicode和UTF-8

为了统一全世界各国语言文字和专业领域符号( 例如数学符号、 乐谱符号) 的编码, ISO 制定了 ISO 10646 标准, 也称为 UCS( Universal Character Set)。 UCS 编码的长度是 31 位, 可以表示 231 个字符。 如果两个字符编码的高位相同, 只有低 16 位不同, 则它们属于一个平面( Plane), 所以一个平面由 216 个字符组成。 目前常用的大部分字符都位于第一个平面( 编码范围是 U-00000000 ~ U-0000FFFD ), 称为 BMP( Basic Multilingual Plane) 或 Plane 0, 为了向后兼容, 其中编号为 0 ~ 256 的字符和 Latin-1 相同。 UCS 编码通常用 U-xxxxxxxx 这种形式表示, 而 BMP 的编码通常用 U+xxxx 这种形式表示, 其中 x 是十六进制数字。 在 ISO 制定 UCS 的同时, 另一个由厂商联合组织也在着手制定这样的编码, 称为 Unicode, 后来两家联手制定统一的编码, 但各自发布各自的标准文档, 所以 UCS 编码和 Unicode 码是相同的。

有了字符编码, 另一个问题就是这样的编码在计算机中怎么表示。 现在已经不可能用一个字节表示一个字符了, 最直接的想法就是用四个字节表示一个字符, 这种表示方法称为 UCS-4 或 UTF-32, UTF 是 Unicode Transformation Format 的缩写。 一方面这样比较浪费存储空间, 由于常用字符都集中在 BMP, 高位的两个字节通常是 0 , 如果只用 ASCII 码或 Latin-1, 高位的三个字节都是 0 。 另一种比较节省存储空间的办法是用两个字节表示一个字符, 称为 UCS-2 或 UTF-16, 这样只能表示 BMP 中的字符, 但 BMP 中有一些扩展字符, 可以用两个这样的扩展字符表示其它平面的字符, 称为 Surrogate Pair。 无论是 UTF-32 还是 UTF-16 都有一个更严重的问题是和 C 语言不兼容, 在 C 语言中 0 字节表示字符串结尾, 库函数 strlenstrcpy 等等都依赖于这一点, 如果字符串用 UTF-32 存储, 其中有很多 0 字节并不表示字符串结尾, 这就乱套了。

UNIX 之父 Ken Thompson 提出的 UTF-8 编码很好地解决了这些问题, 现在得到广泛应用。 UTF-8 具有以下性质:

  1. 编码为 U+0000 ~ U+007F 的字符只占一个字节, 就是 0x00 ~ 0x7F , 和 ASCII 码兼容。
  2. 编码大于 U+007F 的字符用 2 ~ 6 个字节表示, 每个字节的最高位都是 1, 而 ASCII 码的最高位都是 0, 因此非 ASCII 码字符的表示中不会出现 ASCII 码字节( 也就不会出现 0 字节)。
  3. 用于表示非 ASCII 码字符的多字节序列中, 第一个字节的取值范围是 0xC0 ~ 0xFD , 根据它可以判断后面有多少个字节也属于当前字符的编码。 后面每个字节的取值范围都是 0x80 ~ 0xBF , 见下面的详细说明。
  4. UCS 定义的所有 231 个字符都可以用 UTF-8 编码表示出来。
  5. UTF-8 编码最长 6 个字节, BMP 字符的 UTF-8 编码最长三个字节。
  6. 0xFE0xFF 这两个字节在 UTF-8 编码中不会出现。

具体来说, UTF-8 编码有以下几种格式:

U-00000000 – U-0000007F:  0xxxxxxx
U-00000080 – U-000007FF:  110xxxxx 10xxxxxx
U-00000800 – U-0000FFFF:  1110xxxx 10xxxxxx 10xxxxxx
U-00010000 – U-001FFFFF:  11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
U-00200000 – U-03FFFFFF:  111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
U-04000000 – U-7FFFFFFF:  1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx

第一个字节要么最高位是 0( ASCII 字节), 要么最高两位都是 1, 最高位之后 1 的个数决定后面有多少个字节也属于当前字符编码, 例如 111110xx , 最高位之后还有四个 1, 表示后面有四个字节也属于当前字符的编码。 后面每个字节的最高两位都是 10, 可以和第一个字节区分开。 这样的设计有利于误码同步, 例如在网络传输过程中丢失了几个字节, 很容易判断当前字符是不完整的, 也很容易找到下一个字符从哪里开始, 结果顶多丢掉一两个字符, 而不会导致后面的编码解释全部混乱了。 上面的格式中标为 x 的位就是 UCS 编码, 最后一种 6 字节的格式中 x 位有 31 个, 可以表示 31 位的 UCS 编码, UTF-8 就像一列火车, 第一个字节是车头, 后面每个字节是车厢, 其中承载的货物是 UCS 编码。 UTF-8 规定承载的 UCS 编码以大端表示, 也就是说第一个字节中的 x 是 UCS 编码的高位, 后面字节中的 x 是 UCS 编码的低位。

例如 U+00A9© 字符)的二进制是 10101001 ,编码成 UTF-8 是 11000010 10101001\xC2\xA9 ),但不能编码成 11100000 10000010 10101001 , UTF-8 规定每个字符只能用尽可能少的字节来编码。

在Linux C编程中使用Unicode和UTF-8

目前各种 Linux 发行版都支持 UTF-8 编码, 当前系统的语言和字符编码设置保存在一些环境变量中, 可以通过 locale 命令查看:

$ locale
LANG=en_US.UTF-8
LC_CTYPE="en_US.UTF-8"
LC_NUMERIC="en_US.UTF-8"
LC_TIME="en_US.UTF-8"
LC_COLLATE="en_US.UTF-8"
LC_MONETARY="en_US.UTF-8"
LC_MESSAGES="en_US.UTF-8"
LC_PAPER="en_US.UTF-8"
LC_NAME="en_US.UTF-8"
LC_ADDRESS="en_US.UTF-8"
LC_TELEPHONE="en_US.UTF-8"
LC_MEASUREMENT="en_US.UTF-8"
LC_IDENTIFICATION="en_US.UTF-8"
LC_ALL=

常用汉字也都位于 BMP 中,所以一个汉字的存储通常占 3 个字节。例如编辑一个 C 程序:

#include <stdio.h>

int main(void)
{
    printf("你好\n");
    return 0;
}

源文件是以 UTF-8 编码存储的:

$ od -tc nihao.c
0000000   #   i   n   c   l   u   d   e       <   s   t   d   i   o   .
0000020   h   >  \n  \n   i   n   t       m   a   i   n   (   v   o   i
0000040   d   )  \n   {  \n  \t   p   r   i   n   t   f   (   " 344 275
0000060 240 345 245 275   \   n   "   )   ;  \n  \t   r   e   t   u   r
0000100   n       0   ;  \n   }  \n
0000107

其中八进制的 344 375 240 (十六进制 e4 bd a0 )就是 “你” 的 UTF-8 编码,八进制的 345 245 275``( 十六进制 ``e5 a5 bd ) 就是 “好”。 把它编译成目标文件, "你好\n" 这个字符串就成了这样一串字节: e4 bd a0 e5 a5 bd 0a 00 , 汉字在其中仍然是 UTF-8 编码的, 一个汉字占 3 个字节, 这种字符在 C 语言中称为多字节字符(Multibyte Character)。 运行这个程序相当于把这一串字节 write 到当前终端的设备文件。 如果当前终端的驱动程序能够识别 UTF-8 编码就能打印出汉字, 如果当前终端的驱动程序不能识别 UTF-8 编码( 比如一般的字符终端) 就打印不出汉字。 也就是说, 像这种程序, 识别汉字的工作既不是由 C 编译器做的也不是由 libc 做的, C 编译器原封不动地把源文件中的 UTF-8 编码复制到目标文件中, libc 只是当作以 0 结尾的字符串原封不动地 write 给内核, 识别汉字的工作是由终端的驱动程序做的。

但是仅有这种程度的汉字支持是不够的, 有时候我们需要在 C 程序中操作字符串里的字符, 比如求字符串 "你好\n" 中有几个汉字或字符, 用 strlen 就不灵了, 因为 strlen 只看结尾的 0 字节而不管字符串里存的是什么, 求出来的是字节数 7 。 为了在程序中操作 Unicode 字符, C 语言定义了宽字符( Wide Character) 类型 wchar_t 和一些库函数。 在字符常量或字符串字面值前面加一个 L 就表示宽字符常量或宽字符串, 例如定义 wchar_t c = L'你'; , 变量 c 的值就是汉字 “你” 的 31 位 UCS 编码, 而 L"你好\n" 就相当于 {L'你', L'好', L'\n', 0}wcslen 函数就可以取宽字符串中的字符个数。 看下面的程序:

#include <stdio.h>
#include <locale.h>

int main(void)
{
    if (!setlocale(LC_CTYPE, "")) {
        fprintf(stderr, "Can't set the specified locale! "
            "Check LANG, LC_CTYPE, LC_ALL.\n");
        return 1;
    }
    printf("%ls", L"你好\n");
    return 0;
}

宽字符串 L"你好\n" 在源代码中当然还是存成 UTF-8 编码的,但编译器会把它变成 4 个 UCS 编码 0x00004f60 0x0000597d 0x0000000a 0x00000000 保存在目标文件中,按小端存储就是 60 4f 00 00 7d 59 00 00 0a 00 00 00 00 00 00 00 ,用 od 命令查看目标文件应该能找到这些字节:

$ gcc hihao.c
$ od -tx1 a.out

printf%ls 转换说明表示把后面的参数按宽字符串解释, 不是见到 0 字节就结束, 而是见到 UCS 编码为 0 的字符才结束, 但是要 write 到终端仍然需要以多字节编码输出, 这样终端驱动程序才能识别, 所以 printf 在内部把宽字符串转换成多字节字符串再 write 出去。 事实上, C 标准并没有规定多字节字符必须以 UTF-8 编码, 也可以使用其它的多字节编码, 在运行时根据环境变量确定当前系统的编码, 所以在程序开头需要调用 setlocale 获取当前系统的编码设置, 如果当前系统是 UTF-8 的, printf 就把 UCS 编码转换成 UTF-8 编码的多字节字符串再 write 出去。 一般来说, 程序在做内部计算时通常以宽字符编码, 如果要存盘或者输出给别的程序, 或者通过网络发给别的程序, 则采用多字节编码。

关于 Unicode 和 UTF-8 本节只介绍了最基本的概念,部分内容出自 [Unicode FAQ] ,读者可进一步参考这篇文章。

GNU Free Documentation License Version 1.3, 3 November 2008

GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>

Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.

This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below.

  1. Additional Definitions.

As used herein, “this License” refers to version 3 of the GNU Lesser General Public License, and the “GNU GPL” refers to version 3 of the GNU General Public License.

“The Library” refers to a covered work governed by this License, other than an Application or a Combined Work as defined below.

An “Application” is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library.

A “Combined Work” is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the “Linked Version”.

The “Minimal Corresponding Source” for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version.

The “Corresponding Application Code” for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work.

1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL.

2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version:

  1. under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or
  2. under the GNU GPL, with none of the additional permissions of this License applicable to that copy.

3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following:

  1. Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License.
  2. Accompany the object code with a copy of the GNU GPL and this license document.

4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following:

  1. Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License.
  2. Accompany the Combined Work with a copy of the GNU GPL and this license document.
  3. For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document.
  4. Do one of the following:
  1. Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.
  2. Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user’s computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version.
  1. Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.)

5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following:

  1. Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License.
  2. Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work.

6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.

Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation.

If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy’s public statement of acceptance of any version is permanent authorization for you to choose that version for the Library.

参考书目

[ThinkCpp]

How To Think Like A Computer Scientist: Learning with C++. Allen B. Downey.

[GroudUp]

Programming from the Ground Up: An Introduction to Programming using Linux Assembly Language. Jonathan Bartlett.

[K&R]

The C Programming Language. Brian W. Kernighan和Dennis M. Ritchie. 2.

[Standard C]

Standard C: A Reference. P. J. Plauger和Jim Brodie.

[Standard C Library]

The Standard C Library. P. J. Plauger.

[C99 Rationale]

Rationale for International Standard - Programming Languages - C. 5.10.

[UNIX编程艺术]

The Art of UNIX Programming. Eric Raymond.

[C99]

ISO/IEC 9899: Programming Languages - C. 2.

[数字逻辑基础]

Fundamentals of Digital Logic with VHDL Design. Stephen Brown和Zvonko Vranesic. 2.

[IATLC]

Introduction to Automata Theory, Languages, and Computation. John E. Hopcroft、 Rajeev Motwani和Jeffrey D. Ullman. 2.

[Dragon Book]

Compilers: Principles, Techniques, & Tools. Alfred V. Aho、Monica S. Lam、Ravi Sethi和Jeffrey D. Ullman. 2.

[SICP]

Structure and Interpretation of Computer Programs. Harold Abelson、Gerald Jay Sussman和Julie Sussman. 2.

[人月神话]

The Mythical Man-Month: Essays on Software Engineering. Frederick P. Brooks, Jr.. Anniversary Edition.

[CodingStyle]

Linux内核源代码目录下的Documentation/CodingStyle文件.

[GDB]

Debugging with GDB: The GNU Source-Level Debugger. 9. Richard Stallman、 Roland Pesch和Stan Shebs.

[算法导论]

Introduction to Algorithms. 2. Thomas H. Cormen、Charles E. Leiserson、Ronald L. Rivest和Clifford Stein.

[TAOCP]

The Art of Computer Programming. Donald E. Knuth.

[编程珠玑]

Programming Pearls. 2. Jon Bentley.

[OOSC]

Object-Oriented Software Construction. Bertrand Meyer.

[算法+数据结构=程序]

Algorithms + Data Structures = Programs. Niklaus Wirth.

[AssemblyHOWTO]

Linux Assembly HOWTO(http://tldp.org/HOWTO/Assembly-HOWTO/)很不幸,目前tldp.org被我们伟大的防火墙屏蔽了,请自己找代理访问. Konstantin Boldyshev和Francois-Rene Rideau.

[x86Assembly]

Introduction to 80x86 Assembly Language and Computer Architecture. Richard C. Detmer.

[GNUmake]

  1. Managing Projects with GNU make. Robert Mecklenburg.

[SmashStack]

Smashing The Stack For Fun And Profit,网上到处都可以搜到这篇文章. Aleph One.

[BeganFORTRAN]

The New C: It All Began with FORTRAN(http://www.ddj.com/cpp/184401313). Randy Meyers.

[具体数学]

Concrete Mathematics. 2. Ronald L. Graham、Donald E. Knuth和Oren Patashnik.

[APUE2e]

Advanced Programming in the UNIX Environment. 2. W. Richard Stevens和 Stephen A. Rago.

[ULK]

Understanding the Linux Kernel. 3. Daniel P. Bovet和 Marco Cesati.

[TCPIP]

TCP/IP Illustrated, Volume 1: The Protocols. W. Richard Stevens.

[UNPv13e]

UNIX Network Programming, Volume 1: The Sockets Networking API. 3. W. Richard Stevens、 Bill Fenner和Andrew M. Rudoff.

[Unicode FAQ]

UTF-8 and Unicode FAQ, http://www.cl.cam.ac.uk/~mgk25/unicode.html. Markus Kuhn.