graph TD
A[早起活动] --> B[起床]
A --> C[刷牙]
A --> D[洗脸]
A --> E[吃早饭]
A --> F[早自习]
E --> E1[拿饭碗]
E --> E2[去食堂]
E --> E3[排队买饭]
E --> E4[吃饭]
E --> E5[刷碗]
E --> E6[离开食堂]
01 程序设计与 C 语言
在开始学习程序设计时,初学者首先遇到的问题是:什么是程序?什么是程序设计语言?本章首先讨论这些问题,帮助读者直观建立对程序、程序设计与程序设计语言的基本认知,而后简单介绍本书中讨论程序设计问题时所用的程序设计语言——C 语言,并通过一个简单实例介绍C语言程序的基本情况和相关概念。最后介绍在实际程序设计中必然会遇到的一些共性问题。
1.1 程序和程序语言
程序的概念源于生活,通常指完成某项事务的一套既定活动方式或执行流程。从表述角度看,程序是对一系列动作执行过程的精确描述。日常生活中可以找到许多”程序”实例。例如,一个学生早上起床后的行为可以描述为:
- 起床
- 刷牙
- 洗脸
- 吃饭
- 早自习
这是一个线性流程的程序,由一系列更简单的活动(基本步骤)组成。这是最简单的程序形式,描述这类程序本质上就是给出由若干基本步骤组成的执行序列。如果按顺序实施这些步骤所描述的动作,其整体效果就完成了一项事务或工作。
再看一个更复杂的过程:到图书馆借教学参考书。这一常见过程可以描述为:
- 进入图书馆
- 查书目
- 填写索书单
- 交图书馆工作人员取书
- 如果该书已经借出,可以有两种选择
- 回到第2步(重新查书目借其他参考书)
- 放弃借书,离开图书馆
- (如果图书还在,工作人员找到要借的书)办理借书手续
- 离开图书馆
这个程序比前一个复杂,其复杂性在于它不再是平铺直叙的动作序列,需要根据实际情况分支处理,还可能出现循环执行的动作。
深入分析这个实例会发现,该流程还可以进一步细化,以处理更多实际可能遇到的情况。不难找出上述描述遗漏了许多边界情况,譬如:查找图书目录时没有找到所需的书籍;填写好索书单时已经到了图书馆的下班时间;借书时发现自己没有带借书证;工作人员查到该读者的借书册数已经达到限额,或发现该读者有逾期未还的图书,因此拒绝出借,等等。
从这些日常生活的例子中,我们可以提炼出”程序”概念的几个直观特征。现实生活中存在大量此类程序性活动,当我们身处其中时,通常需要按部就班地一步步完成一系列动作。对这类工作、事务或活动的具体执行步骤进行描述,就形成了一个”程序”。
任何程序描述都基于一组预先定义的”基本动作”,这些动作是程序执行者能够理解并直接完成的。例如,在上面有关借书的程序描述中,我们把”查书目”当做一个基本动作。如果读者不知道如何查书目(比如刚入学的新生),那么面向这类读者的流程描述就需要将”查书目”这个动作进一步分解。例如,通过图书馆的计算机检索系统查看图书目录的过程可以描述为:
- 通过图书馆的计算机进入书目检索系统;
- 输入有关要检索图书的信息;
- 从检索到的图书列表中选择目标图书。
这就是程序的细化过程,也称为功能分解。如果借书的人不知道如何启动计算机检索系统,那么上述流程还需要继续分解。这种自顶向下、逐步细化的分解过程,正是本书后续讨论计算机程序设计的核心思想。
任何正常的程序都有明确的开始和结束。在执行一个程序的过程中,执行者(无论是人还是计算机)必须严格按照程序描述依次执行所有动作,执行到结束步骤时,整个任务即告完成。
本书将要深入探讨的计算机程序,同样具备上述所有特征。
计算机、程序与程序设计
日常生活中的程序性活动与计算机程序的执行逻辑高度相似,这为我们理解计算机的工作方式提供了很好的类比。当然,日常生活中的流程允许一定的灵活性和变通,而计算机对程序的执行则是绝对严格、一丝不苟的,计算机只会逐字逐句地执行程序中的指令,没有任何自主判断的空间。
计算机是人类发明的自动计算设备,它所能完成的所有工作统称为”计算”。实际上,计算机的核心功能是执行一组预先定义的基本操作,每个操作只能完成一项极其简单的任务,比如一次整数的加减乘除运算、一次数据传输交换或者一次数值之间的比较。为了让人类能够指挥计算机工作,每种计算机都定义了一套指令集,其中每条指令对应一个基本操作。而所谓的”计算机程序”,就是由这些指令按照一定逻辑组成的序列。
作为物理设备,计算机的基本原理其实非常简单,其最本质的特征就是能够自动执行预先编写好的程序。人与计算机交互的基本方式是:根据自身需求,按照计算机规定的格式编写程序,然后将程序提交给计算机执行。提交后,计算机将严格按照程序指令依次执行,直到程序结束。这一过程通常简称为”程序执行”。
计算机是一种通用计算设备,通过运行不同的程序,它可以转变为处理特定问题、完成特定任务的专用机器。这种通用性与专用性的统一至关重要:这使得计算机既可以实现大规模标准化生产,又可以通过运行不同程序,在不同时间处理不同任务,甚至同时处理多个任务。这正是计算机强大能力的根源。
编写计算机程序的工作称为程序设计或编程,其最终产出就是程序。自计算机诞生之日起,程序设计就成为了一项核心工作。
如今,计算机的飞速发展及其在各个领域的广泛应用,已经深刻改变了人类社会的方方面面,这已是不争的事实。计算机之所以能产生如此巨大的影响,不仅因为人类发明并制造了这种强大的机器,更因为人们为它开发了数量庞大、功能各异的程序,这些程序能够指挥计算机完成从简单到复杂的各种任务。目前主流的计算机硬件种类并不多,正是种类繁多、功能丰富的软件程序赋予了计算机无限的生命力。
程序设计语言及其发展
从上述介绍中,我们可以看到程序描述面临的一个核心问题。比如在图书馆借书的例子中,我们需要考虑各种细节情况,并明确每个基本动作的具体含义。而计算机只能执行预先定义的有限个基本动作,每个动作的功能都非常单一。如果要用计算机处理复杂问题,就需要编写极其冗长的程序,并且必须精确描述每一个动作的执行细节,不允许有任何错误或歧义。这正是程序设计所有困难的根源。
要让计算机完成一项任务,就必须详细描述整个执行过程,明确每一个动作的内容和执行顺序。为此,我们需要一套系统、精确的描述方式,这就是程序设计语言。
我们通常所说的语言是指人类日常使用的自然语言,如汉语、英语等。自然语言是人类在长期发展中自然形成的,是人与人之间交流信息的主要工具和媒介。人们通过口头语言进行即时交流,通过书面语言撰写文章、书籍,实现更大范围的信息传播。在前面的日常生活流程例子中,我们使用汉语来描述流程,这些描述是给人看、让人执行的。
为了与计算机交流、指挥计算机工作,我们同样需要一种描述方式,它必须意义清晰、便于人类使用,同时能够被计算机处理。这种专门用于编写计算机程序的语言就是程序设计语言,它属于人造语言。程序设计语言也常简称为编程语言或程序语言,本书后续内容中在上下文明确的情况下将简称为”语言”。
有人可能会问:小学生都已经学会用数学语言描述计算过程了,程序设计语言还有什么特殊之处呢?答案是肯定的。程序设计语言最突出的特点是:它不仅能够被人类理解和掌握,用于描述计算过程,还能够被计算机”理解”,计算机可以按照程序描述自动执行计算任务。程序设计语言既是人类描述计算过程的工具,也是人与计算机之间信息交流的媒介。通过编写程序,人类可以指挥计算机完成各种特定的计算和处理任务。
在计算机内部,所有信息都以二进制编码的形式存储和处理,需要计算机执行的程序也不例外。每一种计算机都定义了一套二进制指令编码,这种编码形式称为机器语言,是计算机唯一能够直接理解和执行的语言。用机器语言编写的程序称为机器语言程序。
在计算机诞生初期,人们只能直接使用机器语言编写程序。对于人类来说,二进制机器语言极其难用:编写程序效率极低,写出的程序晦涩难懂,不仅难以保证正确性,而且发现和修正错误也非常困难。下面是一台假想计算机上的指令系列:
00000001000000001000 -- 将单元 1000 的数据装入寄存器 0
00000001000100001010 -- 将单元 1010 的数据装入寄存器 1
00000101000000000001 -- 将寄存器 1 的数据乘到寄存器 0 原有数据上
00000001000100001100 -- 将单元 1100 的数据装入寄存器 1
000010000001 -- 将寄存器 1 的数据加到寄存器 0 原有数据上
00000010000000001110 -- 将寄存器 0 里的数据存入单元 1110
这段代码实现的是计算算术表达式 \(a×b+c\)(其中 \(a\)、\(b\)、\(c\) 分别代表地址为 1000、1010 和 1100 的存储单元),并将结果存入地址 1110 的存储单元的功能。
一个复杂程序可能包含数百万甚至数千万条指令,执行流程错综复杂。在机器语言层面理解这样的程序,几乎是人力所不能及的。为了解决这个问题,人们很快发明了符号化的汇编语言。用汇编语言编写的程序需要通过专门的汇编器翻译成机器语言,才能被计算机执行。下面是用某种假想的汇编语言写的程序,它完成与上面程序同样的工作:
load0 a -- 将单元 a 的数据装入寄存器 0
load1 b -- 将单元 b 的数据装入寄存器 1
mult0 1 -- 将寄存器 1 的数据乘到寄存器 0 原有数据上
load1 c -- 将单元 c 的数据装入寄存器 1
add0 1 -- 将寄存器 1 的数据加到寄存器 0 原有数据上
save0 d -- 将寄存器 0 里的数据存入单元 d
汇编语言的每条指令都与一条机器语言指令一一对应,但它使用了助记符来表示指令功能,用符号名来表示存储单元。这使得每条指令的意义更加清晰,整个程序也更容易理解。然而,汇编语言仍然缺乏高层结构,程序只是由一条条基本指令堆砌而成,缺乏组织性。因此,用汇编语言编写复杂程序仍然非常困难,写出的程序可读性差,错误也难以排查。
1954 年,第一个高级程序设计语言 FORTRAN 诞生,标志着程序设计进入了一个新时代。FORTRAN 采用完全符号化的描述方式,使用与数学表达式相近的形式来描述数据计算。它引入了带类型的变量,作为计算机存储单元的抽象模型;同时还提供了循环、子程序等流程控制机制。这些高级机制使得程序员可以将复杂程序分解为若干较小、更容易管理的模块,摆脱了大量底层细节的困扰,不仅大大简化了复杂程序的编写,还提高了程序的可读性和可维护性。FORTRAN 语言一经诞生便受到了广泛欢迎。
高级语言及其实现
高级程序设计语言采用更符合人类思维习惯的描述方式,大大降低了编程的门槛,使得更多人能够并愿意参与到程序设计中来。使用高级语言编写程序的效率显著提高,催生了大量应用软件系统,反过来又进一步推动了计算机应用的普及和发展。计算机应用的发展又反过来促进了计算机工业的飞速进步。可以说,高级程序设计语言的诞生和发展,是计算机能够发展到今天的关键因素之一。
自 FORTRAN 诞生以来,人们已经提出了数千种程序设计语言,其中大部分是试验性语言,只有少数几种得到了广泛应用。随着技术的发展,如今绝大多数程序都是用高级语言编写的,“程序设计语言”通常也特指高级程序设计语言。
在高级语言(例如 C 语言)的层面上,描述前面同样的程序片段只需要一行:
d = a * b + c;这行代码指示计算机计算等号右侧表达式的值,并将结果存入变量 d 所代表的存储单元中。这种表示方式与人们熟悉的数学形式非常接近,具有极好的可读性。高级语言程序完全采用这种符号化形式,彻底摆脱了繁琐的二进制编码和具体计算机的硬件细节。此外,高级语言还提供了丰富的高级程序结构,帮助程序员更好地组织复杂程序。与机器语言和汇编语言相比,编程效率和程序质量都得到了极大的提升。
当然,计算机仍然不能直接执行高级语言程序。因此,在设计一种高级语言之后,还需要开发相应的语言处理软件,称为高级语言系统,也称为该语言的实现。在高级语言的发展过程中,人们也研究出了多种语言实现技术。高级语言的基本实现方式有两种:编译和解释。
编译:首先为目标语言开发一个编译器,它的功能是将用该高级语言编写的源程序完整地翻译成目标计算机的机器语言程序。编写好源程序后,将其提交给编译器处理,生成对应的机器语言目标程序。之后,直接运行这个目标程序即可完成所需的计算任务。
解释:为目标语言开发一个解释器,它不需要预先将源程序翻译成机器语言,而是直接读入源程序,逐行解释并执行其中的指令。有了解释器,我们只需将源程序提交给运行着解释器的计算机,即可直接执行程序。
在实际应用中,编译的方式更为常见,同时也有许多语言采用解释方式实现。本书后续将详细介绍 C 语言的编译实现方式。
随着计算机科学技术的发展,新的程序设计语言不断涌现,旧的语言逐渐被淘汰,而仍在使用的语言也在不断演进。以 FORTRAN 为例,在过去的 50 多年里,它经历了多次重大版本更新,如今的 FORTRAN 90、FORTRAN 95 等版本与最初的版本已经大相径庭。其他历史悠久的语言也都是如此。
推动语言发展的因素有很多。随着程序设计实践的不断深入,人们对如何更好地进行程序设计、需要什么样的语言特性有了越来越深刻的认识,这些认识很多都被融入到了新语言的设计中。另一个重要推动因素是应用需求。新的应用领域往往对程序设计语言提出新的要求,这促使人们不断改进现有语言或设计新的语言。
目前世界上应用最广泛的语言包括 FORTRAN、C、C++、Pascal、Ada、Java 等,这些语言被称为通用语言,因为它们具有许多共同的特性。还有一些语言比较特殊,它们在语法形式和编程范式上与常规语言有很大差异,彼此之间也各不相同。这些非常规语言各有特色,适用于不同的领域,拥有特定的用户群体。典型的例子包括 LISP、Smalltalk、Prolog、ML 等。这些语言虽然不如常规语言普及,但在程序设计语言和计算机科学的发展史上都起到了极其重要的作用,有些至今仍然发挥着重要影响。
程序及其抽象层次
无论是日常生活中的非形式化流程,还是计算机执行的程序,其描述都涉及一个核心问题:抽象层次。
首先,程序中的基本”指令”是什么?在前面的日常生活例子中,洗脸、查书目等是基本动作。而在编写计算机程序时,基本动作必须是计算机能够直接执行的操作。比如,编写机器语言程序时,基本动作就是计算机指令集中的单条指令;编写高级语言程序时,基本动作则是该语言提供的基本语句和函数。学习高级语言编程,首先要掌握语言提供的基本功能,包括它们的语法形式和执行效果。
其次,程序描述语言需要满足什么要求?描述日常生活流程时,我们使用的是自然语言。自然语言词汇丰富、表达能力强,但它高度依赖于接收者的知识和常识。如果让诸葛亮按照我们描述的流程去图书馆借书,他肯定无法理解和执行,因为他不具备相关的背景知识。自然语言的描述往往不够精确,很多细节需要接收者根据自己的知识来补充。这虽然提高了信息传递的效率,但也容易产生误解。而计算机程序必须采用严格、精确、无歧义的描述形式,这正是所有程序设计语言必须满足的基本要求。
程序可以在不同的抽象层次上进行描述。以刷牙为例,我们通常只用一个词来描述这个动作,但实际上刷牙是一个非常复杂的过程。我们可以将其分解为取杯子、接水、拿牙刷、挤牙膏、漱口、刷牙、清洗牙刷等一系列更细的动作,甚至可以进一步将每个动作分解为一系列肌肉收缩和舒张的指令。
程序需要分解到哪个层次,取决于所使用的程序设计语言提供的基本功能。同时,程序的描述方式也必须考虑人类的理解能力。一个复杂程序可能包含数万、数十万甚至数百万行代码。如果仅仅在高级语言的基本语句层面描述程序,会导致抽象层次过低,不仅难以理解程序的整体功能,也难以保证程序的正确性和可维护性。这就像如果我们看到的是一长串肌肉伸缩动作的描述,很难意识到这是在描述刷牙这个动作一样。
因此,开发复杂程序时,我们需要采用分层抽象的方法,在不同的层次上描述程序的功能。随着程序规模的扩大,程序的组织结构变得越来越重要。
还是用学生早上起床的例子来说明。我们首先应该在最高层次上描述整个流程,也就是前面给出的五个步骤:
- 起床
- 刷牙
- 洗脸
- 吃早饭
- 早自习
这就把一个复杂程序分解为若干相对简单的部分。如果需要进一步细化,那么我们就降到下一细节层次,将一个高层动作分解为一系列低层基本动作。例如,可以把”吃早饭”分解为下面的动作序列:
拿饭碗
去食堂
排队买饭
吃饭
刷碗
离开食堂
必要时还可以继续分解,比如将”排队买饭”进一步分解为”排队、选餐、付款”等步骤。在分解过程中,我们需要保留各个抽象层次,形成清晰的层次结构。图 1 展示了这种自顶向下的功能分解结构。这种层次结构不仅有助于理解程序的执行流程、发现程序中的错误,还能大大提高程序的可修改性。例如,学校的食堂改为快餐份饭,由于整个程序已分解为一些具有逻辑独立性的部分,我们只需要修改”排队买饭”这个子流程即可,其他部分无需改动。
编程所需要掌握的正是这种自顶向下、逐步细化的方法。我们需要从问题需求出发,先设计程序的高层结构,然后逐步分解为更小的功能模块。当功能分解到足够细的粒度后,就可以使用程序设计语言的基本结构来描述具体的执行步骤了。在学习程序设计的过程中,掌握正确的程序分析和构造方法至关重要,本书将对此进行详细讨论。
具体语言和程序设计
本章后续部分将进入技术性讨论。为了具体阐述程序设计的各种问题,我们需要选择一种合适的程序设计语言作为载体。几乎所有程序设计教材都会选择一种高级语言作为教学语言,本书选用 C 语言,因为它非常适合作为学习程序设计的入门语言。
在后续章节中,我们将系统地展开程序设计的学习:从最基本的数据描述和简单表达式开始,学习如何编写最简单的程序;然后学习程序的基本流程控制结构,以及如何用这些结构解决更复杂的计算问题;接着介绍程序的模块化组织方法,以及 C 语言实现模块化的核心机制——函数。在掌握了基本的程序设计技术之后,我们将学习数据的组织方法,因为复杂的计算任务往往需要处理复杂多样的数据。
在系统学习程序设计和C语言之前,本章剩余部分将先介绍一些基本背景知识,为后续学习做好铺垫。1.2节简要介绍C语言的历史和特点;1.3节通过一个简单例子展示C程序的基本结构;1.4节和1.5节介绍程序开发的基本过程和核心能力要求。这两小节的内容具有普遍适用性,虽然完全理解需要一定的编程经验,但提前了解可以帮助读者建立对程序开发的整体认识。
1.2 C 语言简介
C 语言是由贝尔实验室的 Dennis Ritchie 于 1973 年设计的,最初的目标是用于编写操作系统和其他复杂的系统软件。C 语言最初被用于在 PDP-11 计算机上开发 UNIX 操作系统,20 世纪 70 年代后成为 UNIX 系统的标准开发语言,并随着 UNIX 的普及而被广泛接受和使用。 80 年代后,C 语言被移植到大型机、工作站等各种平台上,逐渐成为开发系统软件和复杂应用软件的通用语言。
随着微型计算机的普及和性能提升,越来越多的人开始从事微机应用开发,迫切需要一种既能开发系统软件又能开发应用软件的语言。C 语言很好地满足了这一需求,因此在微机软件开发中得到了极其广泛的应用,成为最流行的系统开发语言,被用于开发从简单工具到复杂大型系统的各类软件。
在基于Intel及其兼容芯片的微机上,有许多优秀的商业C语言系统,包括 Borland 公司早期的 Turbo C 及其后续的 Borland C/C++ 系列,以及微软公司的 Microsoft C 和 Visual C/C++ 系列。其他常用的还有 Watcom C/C++、Symantec C/C++ 等,此外还有许多免费或开源的 C 语言实现。其他类型的微机也都有相应的 C 语言系统。几乎所有的 UNIX 和 Linux 工作站都将 C 语言作为标准开发语言,大型计算机上也都提供 C 语言支持。
C 语言的特点
C 语言之所以能够被全球计算机界广泛接受,源于其独特的设计特点。总体而言,C 语言的设计成功地融合了当时人们对程序设计语言的理解,以及开发操作系统等复杂系统软件的实际需求。C 语言的主要特点包括:
简单易学:C 语言语法简洁,入门门槛低,掌握少量基础知识就可以开始编写程序。它很好地体现了程序库的思想,将输入输出等常用功能封装在标准函数库中,使得语言本身更加精简。这不仅简化了编译器的实现,还使得C语言具有极好的可移植性——人们早已用 C 语言编写了它自己的编译器,可以很容易地将其移植到不同的计算机平台上,这极大地促进了 C 语言的传播。
功能强大:C 语言提供了丰富的流程控制和数据定义机制,能够满足构建各种复杂程序的需求。其灵活的函数机制允许程序员将复杂程序分解为若干相对独立的函数,从而有效降低了程序的复杂度,便于开发和维护。
支持分块开发:C 语言提供了一套预处理指令,支持程序的模块化和分块开发。借助这些机制,大型软件系统可以由多个开发人员或团队并行开发,最后集成在一起。这种开发模式是大型软件项目所必需的,C 语言也因此被用于开发许多规模庞大的软件系统。
执行效率高:C 语言的另一个突出特点是能够生成执行效率极高的程序。在很多对性能要求极高的场景中,人们仍然不得不使用汇编语言,因为大多数高级语言生成的程序效率无法满足要求。C 语言的设计使其生成的程序效率接近汇编语言,同时它还提供了一组接近硬件的低级操作,可以用于编写需要直接与硬件交互的底层代码。这使得 C 语言经常被用作汇编语言的替代品,大大提高了底层程序的开发效率。
C 语言的成就得到了国际计算机界的广泛认可。一方面,它在程序设计语言研究领域具有重要地位,催生了众多后继语言,许多新语言都从 C 语言中借鉴了大量优秀的设计思想。另一方面,C 语言对计算机工业和应用的发展也起到了巨大的推动作用。正因如此,C 语言的设计者获得了计算机科学领域的最高荣誉——图灵奖。
C 语言的发展和标准化
在设计 C 语言之初,设计者主要将其定位为汇编语言的替代品,用于编写操作系统,因此在设计中更强调灵活性和便利性。当时 C 语言的很多语法规定不够严格,允许一些不规范的编程方式,这留下了一些安全隐患。使用这种语言时,程序员需要自己注意所有问题,程序的正确性主要依靠人来保证,编译器无法提供太多帮助。
随着 C 语言用户群体的不断扩大,其中大部分用户对语言的理解远不如设计者深入,这些缺点也日益凸显。其后果是,用 C 语言开发的复杂程序中常常存在难以发现和修复的深层错误。
随着应用的发展,人们迫切希望 C 语言能够成为一种更安全、更可靠,并且不依赖于特定计算机和操作系统的标准化语言。为此,美国国家标准局(ANSI)于 20 世纪 80 年代成立了专门的 C 语言标准化委员会,并于 1988 年颁布了 ANSI C 标准。该标准随后被国际标准化组织和各国标准化机构采纳,也成为了中国的国家标准。此后,标准化工作继续推进,1999 年通过了 ISO/IEC 9899:1999 标准,简称 C99。C99 对 ANSI C 进行了修订和扩充,本书最后一章将介绍其主要内容。
语言的演进是一个非常困难的过程。虽然人们认识到 C 语言早期版本中的一些问题,但已经存在的海量软件是一笔巨大的财富,不能轻易抛弃,而彻底改造语言将耗费巨大的人力物力,几乎是不可能的。此外,大量老用户已经形成了固定的编程习惯,不可能在短时间内改变。因此,新标准必须尽可能保持与旧版本的向后兼容性。
ANSI C 标准基本兼容旧版本的 C 程序,这是对现实的妥协。但新标准也明确指出,那些过时的特性终将被废弃,希望程序员尽量避免使用。C99 就正式废弃了老版本中的一些不良特性。
今天学习 C 语言,理所当然应该采用最新的标准,避免学习那些已经过时的特性。原因主要有两点:第一,过时的特性终将被废弃,养成使用它们的习惯将来还需要改正,浪费时间和精力;第二,过时的特性往往会妨碍编译器对程序的错误检查。编程是一项复杂的工作,人很容易犯错误。拒绝编译器的检查就是拒绝计算机的帮助,其后果可能非常严重,会让你为程序中的隐藏错误付出大量的时间和精力。
教材和参考书
关于 ANSI C 的参考书籍,除了标准本身外,最重要的当属 C 语言设计者 B.W. Kernighan 和 D.M. Ritchie 合著的《C 程序设计语言(第2版)》。这本书不仅系统地讨论了 C 程序设计,还包含了完整的语言参考手册和标准库详细说明。书中还有一个附录专门总结了 ANSI C 与旧版本 C 语言的差异,供有经验的程序员参考。不过,这本书假定读者已经具备一定的编程经验,并至少熟悉一种程序设计语言,因此不适合作为零基础的入门教材。其中译本于 2001 年由机械工业出版社出版。
本书的所有讨论都基于 ANSI C 标准,并特别指出了使用过时特性可能带来的问题。目前市场上仍有不少书籍使用旧版本的C语言编写示例,其中一些说法和解释已经过时,读者在阅读时需要注意辨别。
学习编程不仅要掌握语言本身,更重要的是学习和运用人们在几十年编程实践中总结出的宝贵经验,包括正确的思考方法和良好的编程习惯。在学习过程中,养成良好的编程习惯至关重要,这一点怎么强调都不为过。本书在很多地方都给出了相关建议,希望读者能够认真对待。
1.3 一个简单的 C 程序
从下一章开始,我们将详细讨论 C 语言的各种语法结构,以及程序设计的各个方面。在正式开始之前,我们先来看一个最简单的 C 程序例子,了解 C 程序的基本结构,并以此为基础介绍程序开发的基本过程。下面是一个简单程序:
#include <stdio.h>
int main() {
printf("Good morning!\n");
return 0;
}用 C 语言编写的程序称为 C 程序。上面这个简单程序由两部分组成:第一行是预处理指令,它告诉编译器本程序使用了 C 语言标准库提供的功能,因此需要包含标准库的头文件stdio.h。这方面的细节将在第 5 章详细介绍。空行之后是程序的主体部分,它定义了程序的入口函数main,其功能是在屏幕上输出一行文字Good morning!。
C 程序的加工和执行
C 是一种编译型高级语言,用 C 语言编写的程序称为源程序。源程序易于人类编写、阅读和修改,但计算机不能直接执行。计算机只能识别和执行特定的二进制机器语言程序,因此必须先将 C 源程序转换成机器语言程序,计算机才能执行。
这个转换过程称为 C 程序的构建(build),由 C 语言系统(即 C 语言实现)完成。每个 C 语言系统都包含了构建 C 程序所需的工具,主要包括编译器和链接器,还有一些辅助工具。
源程序的构建通常分为两个阶段:第一阶段是编译,由编译器对源程序进行词法分析、语法分析和代码生成,产生对应的机器语言目标模块,保存为目标文件。目标文件还不能直接运行,因为它缺少C程序运行所必需的运行时系统,并且通常还会调用标准函数库中的函数,比如上面例子中的 printf 函数。
因此,还需要进行第二阶段——链接。链接器将编译生成的目标模块与 C 运行时系统、所需的函数库模块等链接在一起,生成最终的可执行程序。图 2 说明了 C 程序的基本构建过程。
graph LR
A[C 源程序] -->|编译器编译| B[目标模块]
B --> O[链接] -->|链接器链接| D((可执行程序))
C[C 语言函数库] --> O
对上面的简单 C 程序进行构建后,就会生成一个可执行文件。运行这个可执行文件,就会在屏幕上看到程序的输出结果:
Good morning!
如果修改程序中双引号内的文字,程序就会输出修改后的内容。例如:
#include <stdio.h>
int main() {
printf("Hello, world!\n");
return 0;
}这一程序构建后执行,就会输出:
Hello, world!
构建 C 程序的具体命令和操作方式取决于所使用的 C 语言系统,详细信息可以参考系统的官方文档。在一些系统中,你需要通过命令行来执行编译和链接命令,除了源程序文件名外,通常还需要指定一些其他参数。这些命令比较复杂,使用起来不太方便。此外,编写和修改源程序还需要一个文本编辑器。
为了简化程序开发过程,人们开发了集成开发环境(Integrated Development Environment, IDE)。如今,大多数 C 语言系统都包含了 IDE。IDE 将编辑器、编译器、链接器、调试器等所有开发工具集成在一起,提供统一的用户界面。IDE 通常采用图形用户界面,提供专门的代码编辑器,并通过菜单和工具栏提供编译、链接、运行、调试等常用命令。使用 IDE 可以极大地提高程序开发效率,几乎所有主流的微机 C 语言系统都提供了功能完善的 IDE 。
需要注意的是,使用 IDE 并没有改变程序构建的基本过程,只是将原来需要手动执行的命令变成了图形界面上的按钮操作。随着技术的发展,IDE 也在不断演进,变得越来越强大和复杂。熟练掌握 IDE 的使用方法,能够进一步提高开发效率。
程序格式
一般来说,C 程序由一系列可打印字符组成,可以使用任何文本编辑器编写和修改,也可以使用专门的 IDE。为了便于阅读,通常将程序分成若干行,每行的长度可以不同。
C 语言是一种自由格式语言,除了少数语法限制外,你可以自由选择程序的书写格式,决定在哪里换行、在哪里加空格等。这些格式上的变化不会影响程序的功能,但这绝不意味着程序格式不重要。
程序不仅是给计算机执行的,更是给人读的——首先是给程序员自己读的。良好的格式对于程序的可读性至关重要。经过多年的编程实践,人们已经形成了统一的共识:对于长而复杂的程序,必须采用良好的书写格式,清晰地展现程序的层次结构和各部分之间的逻辑关系。
人们普遍采用的程序格式方式是:
- 在程序里适当加入空行,分隔程序中处于同一层次的不同部分;
- 同层次的不同部分对齐排列,下一层次的内容通过适当缩进(在一行开始加几个空格),使程序结构更清晰;
- 在程序里增加一些说明性信息(注释),这方面情况后面介绍。
上面的程序示例就遵循了这些规范,其中将花括号内的内容缩进几格,就是为了直观地体现程序的嵌套结构。
初学编程时就应该注意程序格式,养成良好的书写习惯。虽然对于非常简单的程序,格式的优势并不明显,但对于稍复杂的程序,良好的格式会带来极大的便利。有些人为了省事,不注意程序格式,结果在后续的调试和维护中遇到了很多不必要的麻烦。因此,再次强调:请从写第一个程序开始,就养成良好的格式习惯。
1.4 程序开发过程
本节讨论程序开发的完整过程,重点介绍程序调试(testing)和排错(debugging)的基本概念和方法。调试和排错是程序开发过程中必不可少的阶段。对于初学者来说,由于缺乏编程经验,可能难以完全理解下面的内容。但这些内容非常重要,建议读者在学习了后续章节、编写了一些程序之后,再回来重读本节,相信会有更深刻的理解。
程序的开发过程
用计算机解决问题的过程如 图 3 所示,有关过程大致是:
- 分析问题,设计一种解决问题的途径(解决方案)。
- 根据设想的解决方案,用某种编辑器(或IDE)编写出所需程序。
- 用编译器加工源程序。如能正确完成就进入下一步;如发现错误就需要定位错误,回到第 2 步去修改程序。
- 反复工作到编译工作正确完成,编译中发现的错误都已排除,所有警告信息都已处理(排除了,或已弄清不是错误),就可以做链接。如果链接发现错误,还需返回前面步骤,修改程序后重新编译。
- 链接正常产生可执行程序后,就可以开始调试执行,用一些实际数据考查程序的执行效果。如果执行中发生问题,或发现得到的结果不正确,就需要设法确定错误原因,回到前面步骤:修改程序,重新编译,重新链接等。
重复进行上述过程,直到确信程序正确为止。上面论述中出现的一些术语见下面解释。
graph TD
%% 定义主流程节点
A[分析问题] --> B[编制程序]
B --> C[编译]
C --> D[链接]
D --> E[调试运行]
E --> F((完成))
%% 定义虚线反馈流程及标注
C -.->|编译中发现错误,转回修改源程序| B
D -.->|链接中发现错误,转回修改源程序| B
E -.->|调试运行中发现程序编写有错误,修改源程序。| B
E -.->|调试运行中发现问题分析本身有错误,重新分析问题| B
程序错误
排除程序错误的过程称为 debugging,这个词的由来有一个有趣的故事。在计算机发展早期,有一次一台计算机突然停止运行,技术人员经过仔细排查,发现是一只飞蛾钻进了计算机内部,被电流烧焦后造成了电路短路。从此,人们就把排查和排除计算机故障的工作称为 debugging,意为”找虫子”。后来这个词也被用来指代排查和修正程序中的错误。
不过,对于程序设计来说,这个词其实并不准确。程序中的所有错误都是程序员自己造成的,并没有什么”虫子”在捣乱。学习编程首先要认清这一点:排错本质上是找出并修正自己在编程过程中犯下的错误。
初学者在程序出错时,往往倾向于认为是系统或计算机的问题,常说”我的程序肯定没错,一定是系统坏了”。而有经验的程序员都知道,程序出错几乎肯定是自己的问题,需要仔细检查代码找出错误。
程序中的错误可以分为两大类:一类是语法错误,即程序的书写形式不符合语言规范,这类错误能够被编译器或链接器检查出来;另一类是逻辑错误,即程序的语法正确,能够成功构建为可执行程序,但程序的执行结果不符合预期,或者在执行过程中出现异常。程序调试的目标就是找出并消除这两类错误。
程序加工,有关错误的排除
如果编译器或链接器在构建过程中发现错误,就会生成相应的错误信息。每发现一个错误,系统都会输出一条错误信息,指明错误的类型和大致位置(通常是源程序的行号),有时还会提供一些额外的提示信息。不同的 C 语言系统在错误检查能力和错误信息格式上可能有所不同,但无论如何,当系统输出错误信息时,都应该仔细阅读,并检查错误信息所指位置附近的代码,找出真正的错误原因并修正。
编译器能发现的错误(编译错误)主要有两类:
局部语法错误:比如缺少分号、括号等必要符号,或者关键字拼写错误等。对于这类错误,编译器通常能准确指出错误的位置,但给出的错误原因不一定正确。编译器是逐个字符地扫描源程序的,当它扫描到某个位置时发现程序有问题,就会将该位置标记为错误发生的位置。因此,实际的错误要么发生在编译器指定的位置,要么发生在该位置之前。排查时应该从指定位置开始向前检查。有些错误可能要等到编译器扫描到很远的位置时才会被发现,因此实际错误可能出现在指定位置之前很远的地方。此外,一个实际错误往往会导致编译器产生多条错误信息,这是因为错误会使编译器进入非正常状态,进而引发一系列连锁错误。经验表明,排错的基本原则是:每次编译后先解决第一个错误。解决一个错误后,重新编译,很多后续的错误信息可能会自动消失。
上下文关系错误:程序中的很多元素都有上下文依赖关系,比如变量必须先定义后使用。如果编译器发现某个未定义的标识符,就会报出此类错误。最常见的原因是变量名或函数名拼写错误,有时也确实是忘记了定义。这类错误通常比较容易排查和修正。
编译器总是会提供错误发生的位置信息,这是排查错误最重要的线索。为了帮助程序员发现潜在问题,很多编译器还会进行一些超出语言标准的额外检查。如果发现代码中有可疑之处,就会输出警告信息(warning)。警告信息不一定表示程序有错误,但往往预示着存在潜在的问题。因此,绝不能忽视警告信息,必须认真分析每一个警告的原因。只有在确认代码确实没有问题的情况下,才可以忽略相应的警告。
链接器也可能发现一些错误,称为链接错误。链接错误通常与目标模块之间、目标模块与函数库或运行时系统之间的依赖关系有关。例如,如果不小心把 main 函数写成了 mian,编译时不会报错,但链接时会提示找不到 main 函数。这是因为 C 程序的运行时系统会从 main 函数开始执行程序,如果程序中没有定义 main 函数,就无法启动。
链接错误通常都与标识符的名字有关,链接器只能指出哪个名字有问题,但无法指出错误在源程序中的具体位置。对于小程序,这类错误很容易排查;对于较大的程序,可以使用编辑器的字符串查找功能来定位错误,很多 IDE 还提供了更强大的查找和替换功能。
程序运行中的错误
成功构建可执行程序后,下一步就是运行程序进行测试,检查它是否正确实现了所有预期的功能。程序运行中也可能出错,出错情况可能有多种:
非法操作错误:程序执行了操作系统禁止的操作,比如访问非法内存地址。这类错误的表现取决于操作系统。在现代操作系统中,违规程序通常会被强制终止,并弹出错误提示。而在早期的 DOS 等缺乏内存保护的系统中,这类错误可能会导致整个系统崩溃。这类错误往往比较隐蔽,排查起来比较困难。C 语言由于允许直接操作内存,比较容易产生这类错误,这是它的一个主要缺点。本书后续章节会提醒读者在相关地方特别注意。
死循环:程序进入了无限循环状态,无休止地重复执行某一段代码,无法正常结束。死循环的表现通常是程序启动后长时间没有响应,或者反复输出相同的内容。当然,程序长时间没有响应不一定是死循环。比如,当程序等待用户输入时,就会进入等待状态,直到用户输入完成后才会继续执行,这是正常现象。另外,有些复杂的计算任务本身就需要运行较长时间。因此,需要仔细分析和判断程序是否真的进入了死循环。
运行时异常:程序在执行过程中遇到了无法处理的情况,被迫终止运行,并输出动态错误信息。例如,算术运算中的除以零错误。
逻辑错误:程序能够正常执行并结束,但输出的结果不正确。这是最常见也是最难排查的一类错误。
编程中出错是非常正常的事情,调试和排错是程序开发过程中不可或缺的重要环节。
排除程序中的错误
如前所述,程序错误可以分为两类:一类是静态错误,这类错误可以通过静态检查源代码发现。编译器和链接器能够发现的错误都属于静态错误。当系统给出错误信息后,通过检查相关位置的上下文,通常比较容易确定错误原因并修正。排查静态错误需要熟悉 C 语言的语法规则和上下文约束。
另一类是动态运行错误,这类错误只在程序执行时才会出现,因此确认和修正起来要困难得多。要找出动态错误的原因,需要根据程序的代码逻辑和运行结果进行深入的分析和推理。发现动态错误后,首先应该分析错误的现象,列出所有可能的原因,然后逐一排查,逐步缩小范围。
当找到可能的错误原因后,应该设计一些测试用例,通过运行程序来验证自己的判断。同时,尽量找出能够触发错误的最简单的测试用例。通过一系列的测试和分析,简单程序中的大部分错误都能够被找到并修正。
如果无法直接确定错误原因,就需要使用动态调试技术。最基本的动态调试方法是在程序中插入打印语句,输出关键变量的值,观察程序执行过程中变量的变化情况。通过分析关键变量的变化,往往能够找到导致错误的线索。
几乎所有的 C 语言系统都提供了更专业的动态调试支持,特别是 IDE,通常都集成了功能强大的调试器。我们可以在 IDE 中以调试模式运行程序,调试器提供了单步执行、变量监视、断点设置、中断执行等功能。下面简单介绍这些功能:
单步执行(追踪):常规情况下,程序启动后会自动运行直到结束或进入死循环。而单步执行允许我们一条语句一条语句地执行程序,控制程序的执行进度,在每一步检查程序的状态,从而发现问题所在。
变量监视:在单步执行过程中,我们可以监视指定变量的值,观察它们在程序执行过程中的变化。
断点设置:我们可以在程序的任意位置设置断点,当程序执行到断点处时会自动暂停,此时可以检查程序的状态。检查完成后,可以让程序继续执行或终止执行。
中断执行:如果发现程序进入了死循环等非正常状态,或者在程序运行过程中需要检查某个时刻的状态,可以随时发出中断命令,让程序暂停在当前执行点。
功能强大的 IDE 能够极大地提高调试效率,因此,在学习编程的过程中,熟练掌握开发工具的使用也非常重要。不同的 IDE 虽然各有特色,但在程序开发和调试的核心功能上基本一致,掌握了一个 IDE,再学习其他 IDE 就会非常容易。
当然,IDE 只是一个工具,它能够帮助我们找到错误的线索,但最终确认和修正错误还是要靠程序员自己。另一方面,不能因为有了 IDE 就忽视了良好编程习惯的培养。良好的编程习惯是至关重要、不可替代的,IDE 只是让编程变得更方便,并没有改变编程工作的本质。
决定程序质量的最终因素还是人。好的 IDE 并不能自动造就优秀的程序员,现在很多人虽然使用着强大的 IDE,但写出的程序质量却很差。要写出高质量的程序,最重要的是理解程序设计的规律,采用正确的方法,不断积累经验。程序不是代码的简单堆砌,程序的设计和组织才是最重要的,程序规模越大,这一点就越明显。本书后续章节将反复强调这一点。
关于调试,还有一个非常重要的观点需要记住。荷兰计算机科学家、图灵奖获得者 Dijkstra 有一句名言:“调试只能证明程序中有错误,而不能证明程序没有错误”。一个程序是否正确,这是一个非常深刻且难以回答的问题,关于这一问题已经有大量的理论和实践研究。在进入程序设计的世界之前,请大家首先记住这一点。
1.5 问题与程序设计
掌握了程序设计语言,我们该如何着手编写程序呢?程序设计本质上是一种智力劳动,是用计算机解决问题的一种方式。初学编程时,编写简单程序与做数学应用题或物理练习题有很多相似之处:都是面对一个需要解决的问题,最终给出一个符合要求的解决方案。
一般来说,解决问题的过程分为三步:第一步是分析问题,设计解决方案;第二步是用程序设计语言精确地描述这个解决方案;第三步是在计算机上运行程序,验证它是否能够正确解决问题。如果在第三步发现错误,就需要分析错误原因,然后回到前面的步骤进行修正:如果是程序实现有问题,就修改代码并重新编译运行;如果是解决方案本身有问题,就需要重新设计方案并编写程序。这个过程如 图 3 所示。
这个工作的第一步与其他领域的问题解决过程类似,不同之处在于,程序设计需要从计算和程序的角度来思考问题,这是本书讨论的重点。第二步和第三步是程序设计特有的问题。将头脑中的解决方案转化为精确的程序代码,往往不是一件容易的事情,需要仔细的思考和规划。此外,程序设计语言有严格的语法规则,将解决方案用符合语法的形式表达出来也需要付出努力,并且在这个过程中很容易犯错误。
前面关于调试和排错的讨论,主要关注第二步和第三步之间的迭代过程。而如果发现解决方案本身有错误,则需要对问题进行更深入的重新分析。
在程序设计领域,解决小问题与解决大问题、编写课程练习与开发实际应用之间并没有不可逾越的鸿沟。开发大型实际软件系统时,前期的需求分析和设计工作所占的比重会大大增加,首先要把问题分析清楚,明确系统需要做什么。这需要我们不断学习和积累经验。
本课程涉及的内容非常广泛,包括语言知识的掌握、解决问题的思维方法、具体的编程技巧,以及实际的开发工具使用技能。我们把几个重要方面列在这里:
问题分析能力:特别是从计算和程序的角度分析问题的能力。要学会从问题出发,通过逐步分析和分解,将原问题转化为计算机能够解决的形式,并在此基础上设计出合理的解决方案。这方面的学习是永无止境的,未来的世界特别需要既懂计算机又懂专业领域知识的复合型人才。虽然课程中的问题都比较简单,但它们是解决复杂问题的基础。
语言掌握能力:熟练掌握所使用的程序设计语言,熟悉各种语法结构的形式和意义。语言是编程的工具,要写好程序,必须先掌握好工具。需要注意的是,掌握语言不是靠背诵定义,而是靠大量的编程实践。就像只靠听课学不会开车一样,只靠看书、读代码、抄代码也不可能真正学会编程。要学会编程,必须反复亲身实践从分析问题到编写程序、调试运行的整个过程,在实践中思考和解决遇到的各种问题。
程序设计能力:能够写出正确、清晰、可维护的好程序。虽然很多人都会写程序,但真正能写出高质量程序的人并不多。经过多年的实践,人们对什么是好程序已经形成了共识。例如,在解决相同问题时,越简单的程序越好。这涉及到算法的选择、语言的合理使用,以及程序结构的设计等多个方面。除了正确性之外,好程序还应该结构清晰、易于阅读和理解,并且当需求变化时能够容易地修改。本书后续章节将反复强调这些质量要求。
调试排错能力:能够快速发现和修正程序中的错误。初步写出的程序几乎总会包含错误,虽然编译器和调试器能够提供一些帮助,但最终确认和修正错误还是要靠程序员自己。特别是对于编译器的警告信息,以及系统无法自动检查的逻辑错误,更需要依靠程序员的经验和能力。这种能力也需要在学习过程中有意识地培养和锻炼。
工具使用能力:熟悉所使用的编程工具和开发环境。程序开发离不开各种工具,熟悉工具的使用能够大大提高开发效率。目前大多数读者可能会使用IDE进行编程练习,熟练掌握IDE的各种功能非常重要。
本章讨论的重要概念
程序,程序语言,基本动作,计算,程序的执行,机器语言,汇编语言,高级语言,FORTRAN,程序设计语言,编译和解释,C 语言,ANSI C 标准,C99,C 程序,自由格式语言,程序的格式,程序的加工,程序执行,源程序,编译,目标模块和目标文件,链接,可执行程序(文件),集成式程序开发环境(IDE),程序调试,程序排误,出错信息,语法错误,上下文关系,警告信息,链接错误,死循环,语义错误,静态错误,动态运行错误,追踪,监视,断点,中断执行。
练习
习题 1 设法找一找有关程序语言发展的文章或者书籍,或是计算机相关辞典的有关条目,读一读,了解程序语言的历史、发展、现状等方面的情况。
习题 2 设法找到 ANSI C 标准或者中国国家标准 GB/T 15272-94《程序设计语言 C》,浏览这些标准的目录,了解在定义一个程序语言时需要说明哪些东西。
习题 3 熟悉自己学习 C 语言程序设计时准备使用的编译系统或者集成开发环境,了解该系统的基本使用方法、基本操作(命令式或窗口菜单的图形界面方式),弄清楚如何取得联机帮助信息。设法找到并翻阅这个系统的手册,了解手册的结构和各个部分的基本内容。了解在该系统中编一个简单程序的基本步骤。
习题 4 输入本章正文中给出的简单 C 程序例子(注意程序格式),在自用的系统中做出一个 C 语言源程序文件;对这个源程序进行加工,得到对应的可执行程序;运行这个程序,看一看它的效果(输出了什么信息等)。
习题 5 在 习题 4 写好的程序里随便加入一些空格、制表符、换行等字符,看看在什么情况下会出现问题(例如出现错误或输出改变),什么情况下不会。如果出错,请仔细考察系统产生的出错信息和自己所做修改的关系,学习阅读 C 系统给出的错误信息行。
习题 6 对 习题 4 写好的程序随意做一点与 习题 5 中不同的修改(正文中提出的错误例子,或者其他修改),看看编译加工过程中会得到什么信息。弄清错误信息行和自己所做修改的联系,随后重新把程序修改正确。重复上述步骤。
习题 7 修改上述程序里位于两个双引号之间的字符序列,看看程序的输出发生了什么变化。修改程序使它能输出你想输出的东西。
习题 8 如果你所用的系统中可以用中文,在上述程序的一对双引号之间写一句中文,看看程序能否正确加工,程序的输出是什么,能否正确输出中文信息。