C Primer Plus(第6版)中文版(请购买本书新版(ISBN:978-7-115-52163-7)购买地址https://www.epubit.com/bookDetails?id=UB6c9655f9db317)

978-7-115-39059-2
作者: 【美】Stephen Prata(史蒂芬 普拉达)
译者: 姜佑
编辑: 傅道坤
分类: C语言

图书目录:

详情

本书在上一版本的基础上进行了全面更新,全面讲述了C语言编程的相关概念和知识,并且涵盖了C编程语言最近的变动发展、以及C11标准。本书适合希望系统学习C语言的读者,也适用于精通其他编程语言并希望进一步掌握和巩固C编程技术的程序员。

图书摘要

版权信息

书名:C Primer Plus(第6版)中文版

ISBN:978-7-115-39059-2

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

• 著    [美]史蒂芬 普拉达(Stephen Prata)

  译    姜 佑

  责任编辑 傅道坤

• 人民邮电出版社出版发行  北京市丰台区成寿寺路11号

  邮编 100164  电子邮件 315@ptpress.com.cn

  网址 http://www.ptpress.com.cn

• 读者服务热线:(010)81055410

  反盗版热线:(010)81055315


本书详细讲解了C语言的基本概念和编程技巧。

全书共17章。第1章、第2章介绍了C语言编程的预备知识。第3章~第15章详细讲解了C语言的相关知识,包括数据类型、格式化输入/输出、运算符、表达式、语句、循环、字符输入和输出、函数、数组和指针、字符和字符串函数、内存管理、文件输入输出、结构、位操作等。第16章、第17章介绍C预处理器、C库和高级数据表示。本书以完整的程序为例,讲解C语言的知识要点和注意事项。每章末尾设计了大量复习题和编程练习,帮助读者巩固所学知识和提高实际编程能力。附录给出了各章复习题的参考答案和丰富的参考资料。

本书可作为C语言的教材,适用于需要系统学习C语言的初学者,也适用于巩固C语言知识或希望进一步提高编程技术的程序员。


Stephen Prata曾在加利福尼亚的马林学院(肯特菲尔德)教授天文学、物理学和程序设计课程,现已退休。他在加州理工学院获得学士学位,在加州大学伯克利分校获得博士学位。他最早接触程序设计,是为了利用计算机给星团建模。Stephen撰写和与他人合著了十几本图书,其中包括C++ Primer PlusUNIX Primer Plus

谨将本书献给我的父亲William Prata。

感谢Pearson的Mark Taber一直都非常关注本书。感谢Danny Kalev在技术上提供的帮助和建议。


1984年C Primer Plus 第1版刚问世时,使用C语言编程的人并不多。C语言从那时开始流行,许多人在本书的帮助下掌握了C语言。实际上,C Primer Plus 各个版本累计销售量已超过55万册。

C语言从早期的非正式的K&R标准,发展到1990 ISO/ANSI标准,进而发展到2011 ISO/IEC标准。本书也随着逐渐成熟,发展到现在的第6版。在所有这些版本中,我的目标是致力于编写一本指导性强、条理清晰而且有用的C语言教程。

我希望撰写一本友好、方便使用、便于自学的指南。为此,本书采用以下写作策略。

为了获得最佳的学习效果,学习本书时,读者应尽量扮演一个积极的角色。不仅要仔细阅读程序示例,还要亲自动手录入程序并运行。C是一种可移植性很高的语言,但有时在你的系统中运行的结果和在我们的系统中运行的结果不同。经常改动程序的某些部分,运行后看看有什么效果。偶尔出现警告也不必理会,主要是看一下执行错误操作会出现什么状况。在学习的过程中应该多提出问题和多练习。用得越多,学的知识就越牢固。

希望本书能帮助读者轻松愉快地学习C语言。


本章介绍以下内容:

  • C的历史和特性
  • 编写程序的步骤
  • 编译器和链接器的一些知识
  • C标准

欢迎来到C语言的世界。C是一门功能强大的专业化编程语言,深受业余编程爱好者和专业程序员的喜爱。本章为读者学习这一强大而流行的语言打好基础,并介绍几种开发C程序最可能使用的环境。

我们先来了解C语言的起源和一些特性,包括它的优缺点。然后,介绍编程的起源并探讨一些编程的基本原则。最后,讨论如何在一些常见系统中运行C程序。

1972年,贝尔实验室的丹尼斯·里奇(Dennis Ritch)和肯·汤普逊(Ken Thompson)在开发UNIX操作系统时设计了C语言。然而,C语言不完全是里奇突发奇想而来,他是在B语言(汤普逊发明)的基础上进行设计。至于B语言的起源,那是另一个故事。C语言设计的初衷是将其作为程序员使用的一种编程工具,因此,其主要目标是成为有用的语言。

虽然绝大多数语言都以实用为目标,但是通常也会考虑其他方面。例如,Pascal的主要目标是为更好地学习编程原理提供扎实的基础;而BASIC的主要目标是开发出类似英文的语言,让不熟悉计算机的学生轻松学习编程。这些目标固然很重要,但是随着计算机的迅猛发展,它们已经不是主流语言。然而,最初为程序员设计开发的C语言,现在已成为首选的编程语言之一。

在过去40多年里,C语言已成为最重要、最流行的编程语言之一。它的成长归功于使用过的人都对它很满意。过去20多年里,虽然许多人都从C语言转而使用其他编程语言(如,C++、Objective C、Java等),但是C语言仍凭借自身实力在众多语言中脱颖而出。在学习C语言的过程中,会发现它的许多优点(见图1.1)。下面,我们来看看其中较为突出的几点。

图1.1 C语言的优点

C是一门流行的语言,融合了计算机科学理论和实践的控制特性。C语言的设计理念让用户能轻松地完成自顶向下的规划、结构化编程和模块化设计。因此,用C语言编写的程序更易懂、更可靠。

C是高效的语言。在设计上,它充分利用了当前计算机的优势,因此C程序相对更紧凑,而且运行速度很快。实际上,C语言具有通常是汇编语言才具有的微调控制能力(汇编语言是为特殊的中央处理单元设计的一系列内部指令,使用助记符来表示;不同的CPU系列使用不同的汇编语言),可以根据具体情况微调程序以获得最大运行速度或最有效地使用内存。

C是可移植的语言。这意味着,在一种系统中编写的C程序稍作修改或不修改就能在其他系统运行。如需修改,也只需简单更改主程序头文件中的少许项即可。大部分语言都希望成为可移植语言,但是,如果经历过把IBM PC BASIC程序转换成苹果BASIC(两者是近亲),或者在UNIX系统中运行IBM大型机的FORTRAN程序的人都知道,移植是最麻烦的事。C语言是可移植方面的佼佼者。从8位微处理器到克雷超级计算机,许多计算机体系结构都可以使用C编译器(C编译器是把C代码转换成计算机内部指令的程序)。但是要注意,程序中针对特殊硬件设备(如,显示监视器)或操作系统特殊功能(如,Windows 8或OS X)编写的部分,通常是不可移植的。

由于C语言与UNIX关系密切,UNIX系统通常会将C编译器作为软件包的一部分。安装Linux时,通常也会安装C编译器。供个人计算机使用的C编译器很多,运行各种版本的Windows和Macintosh(即,Mac)的PC都能找到合适的C编译器。因此,无论是使用家庭计算机、专业工作站,还是大型机,都能找到针对特定系统的C编译器。

C语言功能强大且灵活(计算机领域经常使用这两个词)。例如,功能强大且灵活的UNIX操作系统,大部分是用C语言写的;其他语言(如,FORTRAN、Perl、Python、Pascal、LISP、Logo、BASIC)的许多编译器和解释器都是用C语言编写的。因此,在UNIX机上使用FORTRAN时,最终是由C程序生成最后的可执行程序。C程序可以用于解决物理学和工程学的问题,甚至可用于制作电影的动画特效。

C语言是为了满足程序员的需求而设计的,程序员利用C可以访问硬件、操控内存中的位。C语言有丰富的运算符,能让程序员简洁地表达自己的意图。C没有Pascal严谨,但是却比C++的限制多。这样的灵活性既是优点也是缺点。优点是,许多任务用C来处理都非常简洁(如,转换数据的格式);缺点是,你可能会犯一些莫名其妙的错误,这些错误不可能在其他语言中出现。C语言在提供更多自由的同时,也让使用者承担了更大的责任。

另外,大多数C实现都有一个大型的库,包含众多有用的C函数。这些函数用于处理程序员经常需要解决的问题。

人无完人,金无足赤。C语言也有一些缺点。例如,前面提到的,要享受用C语言自由编程的乐趣,就必须承担更多的责任。特别是,C语言使用指针,而涉及指针的编程错误往往难以察觉。有句话说的好:想拥有自由就必须时刻保持警惕。

C语言紧凑简洁,结合了大量的运算符。正因如此,我们也可以编写出让人极其费解的代码。虽然没必要强迫自己编写晦涩的代码,但是有兴趣写写也无妨。试问,除C语言外还为哪种语言举办过年度混乱代码大赛[1]

瑕不掩瑜,C语言的优点比缺点多很多。我们不想在这里多费笔墨,还是来聊聊C语言的其他话题。

早在20世纪80年代,C语言就已经成为小型计算机(UNIX系统)使用的主流语言。从那以后,C语言的应用范围扩展到微型机(个人计算机)和大型机(庞然大物)。如图1.2所示,许多软件公司都用C语言来开发文字处理程序、电子表格、编译器和其他产品,因为用C语言编写的程序紧凑而高效。更重要的是,C程序很方便修改,而且移植到新型号的计算机中也没什么问题。

图1.2 C语言的应用范围

无论是软件公司、经验丰富的C程序员,还是其他用户,都能从C语言中受益。越来越多的计算机用户已转而求助C语言解决一些安全问题。不一定非得是计算机专家也能使用C语言。

20世纪90年代,许多软件公司开始改用C++来开发大型的编程项目。C++在C语言的基础上嫁接了面向对象编程工具(面向对象编程是一门哲学,它通过对语言建模来适应问题,而不是对问题建模以适应语言)。C++几乎是C的超集,这意味着任何C程序差不多就是一个C++程序。学习C语言,也相当于学习了许多C++的知识。

虽然这些年来C++和JAVA非常流行,但是C语言仍是软件业中的核心技能。在最想具备的技能中,C语言通常位居前十。特别是,C语言已成为嵌入式系统编程的流行语言。也就是说,越来越多的汽车、照相机、DVD播放机和其他现代化设备的微处理器都用C语言进行编程。除此之外,C语言还从长期被FORTRAN独占的科学编程领域分得一杯羹。最终,作为开发操作系统的卓越语言,C在Linux开发中扮演着极其重要的角色。因此,在进入21世纪的第2个10年中,C语言仍然保持着强劲的势头。

简而言之,C语言是最重要的编程语言之一,将来也是如此。如果你想拿下一份编程的工作,被问到是否会C语言时,最好回答“是”。

在学习如何用C语言编程之前,最好先了解一下计算机的工作原理。这些知识有助于你理解用C语言编写程序和运行C程序时所发生的事情之间有什么联系。

现代的计算机由多种部件构成。中央处理单元(CPU)承担绝大部分的运算工作。随机存取内存(RAM)是存储程序和文件的工作区;而永久内存存储设备(过去一般指机械硬盘,现在还包括固态硬盘)即使在关闭计算机后,也不会丢失之前储存的程序和文件。另外,还有各种外围设备(如,键盘、鼠标、触摸屏、监视器)提供人与计算机之间的交互。CPU负责处理程序,接下来我们重点讨论它的工作原理。

CPU的工作非常简单,至少从以下简短的描述中看是这样。它从内存中获取并执行一条指令,然后再从内存中获取并执行下一条指令,诸如此类(一个吉赫兹的CPU一秒钟能重复这样的操作大约十亿次,因此,CPU能以惊人的速度从事枯燥的工作)。CPU有自己的小工作区——由若干个寄存器组成,每个寄存器都可以储存一个数字。一个寄存器储存下一条指令的内存地址,CPU使用该地址来获取和更新下一条指令。在获取指令后,CPU在另一个寄存器中储存该指令,并更新第1个寄存器储存下一条指令的地址。CPU能理解的指令有限(这些指令的集合叫作指令集)。而且,这些指令相当具体,其中的许多指令都是用于请求计算机把一个数字从一个位置移动到另一个位置。例如,从内存移动到寄存器。

下面介绍两个有趣的知识。其一,储存在计算机中的所有内容都是数字。计算机以数字形式储存数字和字符(如,在文本文档中使用的字母)。每个字符都有一个数字码。计算机载入寄存器的指令也以数字形式储存,指令集中的每条指令都有一个数字码。其二,计算机程序最终必须以数字指令码(即,机器语言)来表示。

简而言之,计算机的工作原理是:如果希望计算机做某些事,就必须为其提供特殊的指令列表(程序),确切地告诉计算机要做的事以及如何做。你必须用计算机能直接明白的语言(机器语言)创建程序。这是一项繁琐、乏味、费力的任务。计算机要完成诸如两数相加这样简单的事,就得分成类似以下几个步骤。

1.从内存位置2000上把一个数字拷贝到寄存器1。

2.从内存位置2004上把另一个数字拷贝到寄存器2。

3.把寄存器2中的内容与寄存器1中的内容相加,把结果储存在寄存器1中。

4.把寄存器1中的内容拷贝到内存位置2008。

而你要做的是,必须用数字码来表示以上的每个步骤!

如果以这种方式编写程序很合你的意,那不得不说抱歉,因为用机器语言编程的黄金时代已一去不复返。但是,如果你对有趣的事情比较感兴趣,不妨试试高级编程语言。

高级编程语言(如,C)以多种方式简化了编程工作。首先,不必用数字码表示指令;其次,使用的指令更贴近你如何想这个问题,而不是类似计算机那样繁琐的步骤。使用高级编程语言,可以在更抽象的层面表达你的想法,不用考虑CPU在完成任务时具体需要哪些步骤。例如,对于两数相加,可以这样写:

total = mine + yours;

对我们而言,光看这行代码就知道要计算机做什么;而看用机器语言写成的等价指令(多条以数字码形式表现的指令)则费劲得多。但是,对计算机而言却恰恰相反。在计算机看来,高级指令就是一堆无法理解的无用数据。编译器在这里派上了用场。编译器是把高级语言程序翻译成计算机能理解的机器语言指令集的程序。程序员进行高级思维活动,而编译器则负责处理冗长乏味的细节工作。

编译器还有一个优势。一般而言,不同CPU制造商使用的指令系统和编码格式不同。例如,用Intel Core i7(英特尔酷睿i7)CPU编写的机器语言程序对于ARM Cortex-A57 CPU而言什么都不是。但是,可以找到与特定类型CPU匹配的编译器。因此,使用合适的编译器或编译器集,便可把一种高级语言程序转换成供各种不同类型CPU使用的机器语言程序。一旦解决了一个编程问题,便可让编译器集翻译成不同CPU使用的机器语言。

简而言之,高级语言(如C、Java、Pascal)以更抽象的方式描述行为,不受限于特定CPU或指令集。而且,高级语言简单易学,用高级语言编程比用机器语言编程容易得多。

1964年,控制数据公司(Control Data Corporation)研制出了CDC 6600计算机。这台庞然大物是世界上首台超级计算机,当时的售价是600万美元。它是高能核物理研究的首选。然而,现在的普通智能手机在计算能力和内存方面都超过它数百倍,而且能看视频,放音乐。

1964年,在工程和科学领域的主流编程语言是FORTRAN。虽然编程语言不如硬件发展那么突飞猛进,但是也发生了很大变化。为了应对越来越大型的编程项目,语言先后为结构化编程和面向对象编程提供了更多的支持。随着时间的推移,不仅新语言层出不穷,而且现有语言也会发生变化。

目前,有许多C实现可用。在理想情况下,编写C程序时,假设该程序中未使用机器特定的编程技术,那么它的运行情况在任何实现中都应该相同。要在实践中做到这一点,不同的实现要遵循同一个标准。

C语言发展之初,并没有所谓的C标准。1978年,布莱恩·柯林汉(Brian Kernighan)和丹尼斯·里奇(Dennis Ritchie)合著的The C Programming Language(《C语言程序设计》)第1版是公认的C标准,通常称之为K&R C经典C。特别是,该书中的附录中的“C语言参考手册”已成为实现C的指导标准。例如,编译器都声称提供完整的K&R实现。虽然这本书中的附录定义了C语言,但却没有定义C库。与大多数语言不同的是,C语言比其他语言更依赖库,因此需要一个标准库。实际上,由于缺乏官方标准,UNIX实现提供的库已成为了标准库。

随着C的不断发展,越来越广泛地应用于更多系统中,C社区意识到需要一个更全面、更新颖、更严格的标准。鉴于此,美国国家标准协会(ANSI)于1983年组建了一个委员会(X3J11),开发了一套新标准,并于1989年正式公布。该标准(ANSI C)定义了C语言和C标准库。国际标准化组织于1990年采用了这套C标准(ISO C)。ISO C和ANSI C是完全相同的标准。ANSI/ISO标准的最终版本通常叫作C89(因为ANSI于1989年批准该标准)或C90(因为ISO于1990年批准该标准)。另外,由于ANSI先公布C标准,因此业界人士通常使用ANSI C。

在该委员会制定的指导原则中,最有趣的可能是:保持C的精神。委员会在表述这一精神时列出了以下几点:

在最后一点上,标准委员会的用意是:作为实现,应该针对目标计算机来定义最合适的某特定操作,而不是强加一个抽象、统一的定义。在学习C语言过程中,许多方面都反映了这一哲学思想。

1994年,ANSI/ISO联合委员会(C9X委员会)开始修订C标准,最终发布了C99标准。该委员会遵循了最初C90标准的原则,包括保持语言的精练简单。委员会的用意不是在C语言中添加新特性,而是为了达到新的目标。第1个目标是,支持国际化编程。例如,提供多种方法处理国际字符集。第2个目标是,“调整现有实践致力于解决明显的缺陷”。因此,在遇到需要将C移至64位处理器时,委员会根据现实生活中处理问题的经验来添加标准。第3个目标是,为适应科学和工程项目中的关键数值计算,提高C的适应性,让C比FORTRAN更有竞争力。

这3点(国际化、弥补缺陷和提高计算的实用性)是主要的修订目标。在其他方面的改变则更为保守,例如,尽量与C90、C++兼容,让语言在概念上保持简单。用委员会的话说:“……委员会很满意让C++成为大型、功能强大的语言”。

C99的修订保留了C语言的精髓,C仍是一门简洁高效的语言。本书指出了许多C99修改的地方。虽然该标准已发布了很长时间,但并非所有的编译器都完全实现C99的所有改动。因此,你可能发现C99的一些改动在自己的系统中不可用,或者只有改变编译器的设置才可用。

维护标准任重道远。标准委员会在2007年承诺C标准的下一个版本是C1X,2011年终于发布了C11标准。此次,委员会提出了一些新的指导原则。出于对当前编程安全的担忧,不那么强调“信任程序员”目标了。而且,供应商并未像对C90那样很好地接受和支持C99。这使得C99的一些特性成为C11的可选项。因为委员会认为,不应要求服务小型机市场的供应商支持其目标环境中用不到的特性。另外需要强调的是,修订标准的原因不是因为原标准不能用,而是需要跟进新的技术。例如,新标准添加了可选项支持当前使用多处理器的计算机。对于C11标准,我们浅尝辄止,深入分析这部分内容已超出本书讨论的范围。

注意


本书使用术语ANSI C、ISO C或ANSI/ISO C讲解C89/90和较新标准共有的特性,用C99或C11介绍新的特性。有时也使用C90(例如,讨论一个特性被首次加入C语言时)。

C是编译型语言。如果之前使用过编译型语言(如,Pascal或FORTRAN),就会很熟悉组建C程序的几个基本步骤。但是,如果以前使用的是解释型语言(如,BASIC)或面向图形界面语言(如,Visual Basic),或者甚至没接触过任何编程语言,就有必要学习如何编译。别担心,这并不复杂。首先,为了让读者对编程有大概的了解,我们把编写C程序的过程分解成7个步骤(见图1.3)。注意,这是理想状态。在实际的使用过程中,尤其是在较大型的项目中,可能要做一些重复的工作,根据下一个步骤的情况来调整或改进上一个步骤。

图1.3 编程的7个步骤

在动手写程序之前,要在脑中有清晰的思路。想要程序去做什么首先自己要明确自己想做什么,思考你的程序需要哪些信息,要进行哪些计算和控制,以及程序应该要报告什么信息。在这一步骤中,不涉及具体的计算机语言,应该用一般术语来描述问题。

对程序应该完成什么任务有概念性的认识后,就应该考虑如何用程序来完成它。例如,用户界面应该是怎样的?如何组织程序?目标用户是谁?准备花多长时间来完成这个程序?

除此之外,还要决定在程序(还可能是辅助文件)中如何表示数据,以及用什么方法处理数据。学习C语言之初,遇到的问题都很简单,没什么可选的。但是,随着要处理的情况越来越复杂,需要决策和考虑的方面也越来越多。通常,选择一个合适的方式表示信息可以更容易地设计程序和处理数据。

再次强调,应该用一般术语来描述问题,而不是用具体的代码。但是,你的某些决策可能取决于语言的特性。例如,在数据表示方面,C的程序员就比Pascal的程序员有更多选择。

设计好程序后,就可以编写代码来实现它。也就是说,把你设计的程序翻译成C语言。这里是真正需要使用C语言的地方。可以把思路写在纸上,但是最终还是要把代码输入计算机。这个过程的机制取决于编程环境,我们稍后会详细介绍一些常见的环境。一般而言,使用文本编辑器创建源代码文件。该文件中内容就是你翻译的C语言代码。程序清单1.1是一个C源代码的示例。

程序清单1.1 C源代码示例

#include <stdio.h>
int main(void)
{
     int dogs;

     printf("How many dogs do you have?\n");
     scanf("%d", &dogs);
     printf("So you have %d dog(s)!\n", dogs);

     return 0;
}

在这一步骤中,应该给自己编写的程序添加文字注释。最简单的方式是使用C的注释工具在源代码中加入对代码的解释。第2章将详细介绍如何在代码中添加注释。

接下来的这一步是编译源代码。再次提醒读者注意,编译的细节取决于编程的环境,我们稍后马上介绍一些常见的编程环境。现在,先从概念的角度讲解编译发生了什么事情。

前面介绍过,编译器是把源代码转换成可执行代码的程序。可执行代码是用计算机的机器语言表示的代码。这种语言由数字码表示的指令组成。如前所述,不同的计算机使用不同的机器语言方案。C编译器负责把C代码翻译成特定的机器语言。此外,C编译器还将源代码与C库(库中包含大量的标准函数供用户使用,如printf()scanf())的代码合并成最终的程序(更精确地说,应该是由一个被称为链接器的程序来链接库函数,但是在大多数系统中,编译器运行链接器)。其结果是,生成一个用户可以运行的可执行文件,其中包含着计算机能理解的代码。

编译器还会检查C语言程序是否有效。如果C编译器发现错误,就不生成可执行文件并报错。理解特定编译器报告的错误或警告信息是程序员要掌握的另一项技能。

传统上,可执行文件是可运行的程序。在常见环境(包括Windows命令提示符模式、UNIX终端模式和Linux终端模式)中运行程序要输入可执行文件的文件名,而其他环境可能要运行命令(如,在VAX中的VMS[2])或一些其他机制。例如,在Windows和Macintosh提供的集成开发环境(IDE)中,用户可以在IDE中通过选择菜单中的选项或按下特殊键来编辑和执行C程序。最终生成的程序可通过单击或双击文件名或图标直接在操作系统中运行。

程序能运行是个好迹象,但有时也可能会出现运行错误。接下来,应该检查程序是否按照你所设计的思路运行。你会发现你的程序中有一些错误,计算机行话叫作bug。查找并修复程序错误的过程叫调试。学习的过程中不可避免会犯错,学习编程也是如此。因此,当你把所学的知识应用于编程时,最好为自己会犯错做好心理准备。随着你越来越老练,你所写的程序中的错误也会越来越不易察觉。

将来犯错的机会很多。你可能会犯基本的设计错误,可能错误地实现了一个好想法,可能忽视了输入检查导致程序瘫痪,可能会把圆括号放错地方,可能误用C语言或打错字,等等。把你将来犯错的地方列出来,这份错误列表应该会很长。

看到这里你可能会有些绝望,但是情况没那么糟。现在的编译器会捕获许多错误,而且自己也可以找到编译器未发现的错误。在学习本书的过程中,我们会给读者提供一些调试的建议。

创建完程序后,你发现程序有错,或者想扩展程序的用途,这时就要修改程序。例如,用户输入以Zz开头的姓名时程序出现错误、你想到了一个更好的解决方案、想添加一个更好的新特性,或者要修改程序使其能在不同的计算机系统中运行,等等。如果在编写程序时清楚地做了注释并采用了合理的设计方案,这些事情都很简单。

编程并非像描述那样是一个线性的过程。有时,要在不同的步骤之间往复。例如,在写代码时发现之前的设计不切实际,或者想到了一个更好的解决方案,或者等程序运行后,想改变原来的设计思路。对程序做文字注释为今后的修改提供了方便。

许多初学者经常忽略第1步和第2步(定义程序目标和设计程序),直接跳到第3步(编写代码)。刚开始学习时,编写的程序非常简单,完全可以在脑中构思好整个过程。即使写错了,也很容易发现。但是,随着编写的程序越来越庞大、越来越复杂,动脑不动手可不行,而且程序中隐藏的错误也越来越难找。最终,那些跳过前两个步骤的人往往浪费了更多的时间,因为他们写出的程序难看、缺乏条理、让人难以理解。要编写的程序越大越复杂,事先定义和设计程序环节的工作量就越大。

磨刀不误砍柴工,应该养成先规划再动手编写代码的好习惯,用纸和笔记录下程序的目标和设计框架。这样在编写代码的过程中会更加得心应手、条理清晰。

生成程序的具体过程因计算机环境而异。C是可移植性语言,因此可以在许多环境中使用,包括UNIX、Linux、MS-DOS(一些人仍在使用)、Windows和Macintosh OS。有些产品会随着时间的推移发生演变或被取代,本书无法涵盖所有环境。

首先,来看看许多C环境(包括上面提到的5种环境)共有的一些方面。虽然不必详细了解计算机内部如何运行C程序,但是,了解一下编程机制不仅能丰富编程相关的背景知识,还有助于理解为何要经过一些特殊的步骤才能得到C程序。

用C语言编写程序时,编写的内容被储存在文本文件中,该文件被称为源代码文件source code file)。大部分C系统,包括之前提到的,都要求文件名以.c结尾(如,wordcount.cbudget.c)。在文件名中,点号(.)前面的部分称为基本名basename),点号后面的部分称为扩展名extension)。因此,budget是基本名,c是扩展名。基本名与扩展名的组合(budget.c)就是文件名。文件名应该满足特定计算机操作系统的特殊要求。例如,MS-DOS是IBM PC及其兼容机的操作系统,比较老旧,它要求基本名不能超过8个字符。因此,刚才提到的文件名wordcount.c就是无效的DOS文件名。有些UNIX系统限制整个文件名(包括扩展名)不超过14个字符,而有些UNIX系统则允许使用更长的文件名,最多255个字符。Linux、Windows和Macintosh OS都允许使用长文件名。

接下来,我们来看一下具体的应用,假设有一个名为concrete.c的源文件,其中的C源代码如程序清单1.2所示。

程序清单1.2 c程序

#include <stdio.h>
int main(void)
{
     printf("Concrete contains gravel and cement.\n");

     return 0;
}

如果看不懂程序清单1.2中的代码,不用担心,我们将在第2章学习相关知识。

C编程的基本策略是,用程序把源代码文件转换为可执行文件(其中包含可直接运行的机器语言代码)。典型的C实现通过编译和链接两个步骤来完成这一过程。编译器把源代码转换成中间代码,链接器把中间代码和其他代码合并,生成可执行文件。C使用这种分而治之的方法方便对程序进行模块化,可以独立编译单独的模块,稍后再用链接器合并已编译的模块。通过这种方式,如果只更改某个模块,不必因此重新编译其他模块。另外,链接器还将你编写的程序和预编译的库代码合并。

中间文件有多种形式。我们在这里描述的是最普遍的一种形式,即把源代码转换为机器语言代码,并把结果放在目标代码文件(或简称目标文件)中(这里假设源代码只有一个文件)。虽然目标文件中包含机器语言代码,但是并不能直接运行该文件。因为目标文件中储存的是编译器翻译的源代码,这还不是一个完整的程序。

目标代码文件缺失启动代码startup code)。启动代码充当着程序和操作系统之间的接口。例如,可以在MS Windows或Linux系统下运行IBM PC兼容机。这两种情况所使用的硬件相同,所以目标代码相同,但是Windows和Linux所需的启动代码不同,因为这些系统处理程序的方式不同。

目标代码还缺少库函数。几乎所有的C程序都要使用C标准库中的函数。例如,concrete.c中就使用了printf()函数。目标代码文件并不包含该函数的代码,它只包含了使用printf()函数的指令。printf()函数真正的代码储存在另一个被称为的文件中。库文件中有许多函数的目标代码。

链接器的作用是,把你编写的目标代码、系统的标准启动代码和库代码这3部分合并成一个文件,即可执行文件。对于库代码,链接器只会把程序中要用到的库函数代码提取出来(见图1.4)。

图1.4 编译器和链接器

简而言之,目标文件和可执行文件都由机器语言指令组成的。然而,目标文件中只包含编译器为你编写的代码翻译的机器语言代码,可执行文件中还包含你编写的程序中使用的库函数和启动代码的机器代码。

在有些系统中,必须分别运行编译程序和链接程序,而在另一些系统中,编译器会自动启动链接器,用户只需给出编译命令即可。

接下来,了解一些具体的系统。

由于C语言因UNIX系统而生,也因此而流行,所以我们从UNIX系统开始(注意:我们提到的UNIX还包含其他系统,如FreeBSD,它是UNIX的一个分支,但是由于法律原因不使用该名称)。

1.在UNIX系统上编辑

UNIX C没有自己的编辑器,但是可以使用通用的UNIX编辑器,如emacs、jove、vi或X Window System文本编辑器。

作为程序员,要负责输入正确的程序和为储存该程序的文件起一个合适的文件名。如前所述,文件名应该以.c结尾。注意,UNIX区分大小写。因此,budget.cBUDGET.cBudget.c是3个不同但都有效的C源文件名。但是BUDGET.C是无效文件名,因为该名称的扩展名使用了大写C而不是小写c

假设我们在vi编译器中编写了下面的程序,并将其储存在inform.c文件中:

#include <stdio.h>
int main(void)
{
     printf("A .c is used to end a C program filename.\n");

     return 0;
}

以上文本就是源代码,inform.c是源文件。注意,源文件是整个编译过程的开始,不是结束。

2.在UNIX系统上编译

虽然在我们看来,程序完美无缺,但是对计算机而言,这是一堆乱码。计算机不明白#includeprintf是什么(也许你现在也不明白,但是学到后面就会明白,而计算机却不会)。如前所述,我们需要编译器将我们编写的代码(源代码)翻译成计算机能看懂的代码(机器代码)。最后生成的可执行文件中包含计算机要完成任务所需的所有机器代码。

以前,UNIX C编译器要调用语言定义的cc命令。但是,它没有跟上标准发展的脚步,已经退出了历史舞台。但是,UNIX系统提供的C编译器通常来自一些其他源,然后以cc命令作为编译器的别名。因此,虽然在不同的系统中会调用不同的编译器,但用户仍可以继续使用相同的命令。

编译inform.c,要输入以下命令:

cc inform.c

几秒钟后,会返回UNIX的提示,告诉用户任务已完成。如果程序编写错误,你可能会看到警告或错误消息,但我们先假设编写的程序完全正确(如果编译器报告void的错误,说明你的系统未更新成ANSI C编译器,只需删除void即可)。如果使用ls命令列出文件,会发现有一个a.out文件(见图1.5)。该文件是包含已翻译(或已编译)程序的可执行文件。要运行该文件,只需输入:

a.out

输出内容如下:

A .c is used to end a C program filename.

图1.5 用UNIX准备C程序

如果要储存可执行文件(a.out),应该把它重命名。否则,该文件会被下一次编译程序时生成的新a.out文件替换。

如何处理目标代码?C编译器会创建一个与源代码基本名相同的目标代码文件,但是其扩展名是.o。在该例中,目标代码文件是inform.o。然而,却找不到这个文件,因为一旦链接器生成了完整的可执行程序,就会将其删除。如果原始程序有多个源代码文件,则保留目标代码文件。学到后面多文件程序时,你会明白到这样做的好处。

GNU项目始于1987年,是一个开发大量免费UNIX软件的集合(GNU的意思是“GNU’s Not UNIX”,即GNU不是UNIX)。GNU编译器集合(也被称为GCC,其中包含GCC C编译器)是该项目的产品之一。GCC在一个指导委员会的带领下,持续不断地开发,它的C编译器紧跟C标准的改动。GCC有各种版本以适应不同的硬件平台和操作系统,包括UNIX、Linux和Windows。用gcc命令便可调用GCC C编译器。许多使用gcc的系统都用cc作为gcc的别名。

LLVM项目成为cc的另一个替代品。该项目是与编译器相关的开源软件集合,始于伊利诺伊大学的2000份研究项目。它的Clang编译器处理C代码,可以通过clang调用。有多种版本供不同的平台使用,包括Linux。2012年,Clang成为FreeBSD的默认C编译器。Clang也对最新的C标准支持得很好。

GNU和LLVM都可以使用-v选项来显示版本信息,因此各系统都使用cc别名来代替gccclang命令。以下组合:

cc -v

显示你所使用的编译器及其版本。

gccclang命令都可以根据不同的版本选择运行时选项来调用不同C标准。

gcc -std=c99 inform.c[3]
gcc -std=c1x inform.c
gcc -std=c11 inform.c

第1行调用C99标准,第2行调用GCC接受C11之前的草案标准,第3行调用GCC接受的C11标准版本。Clang编译器在这一点上用法与GCC相同。

Linux是一个开源、流行、类似于UNIX的操作系统,可在不同平台(包括PC和Mac)上运行。在Linux中准备C程序与在UNIX系统中几乎一样,不同的是要使用GNU提供的GCC公共域C编译器。编译命令类似于:

gcc inform.c

注意,在安装Linux时,可选择是否安装GCC。如果之前没有安装GCC,则必须安装。通常,安装过程会将cc作为gcc的别名,因此可以在命令行中使用cc来代替gcc

欲详细了解GCC和最新发布的版本,请访问http://www.gnu.org/software/gcc/index.html

C编译器不是标准Windows软件包的一部分,因此需要从别处获取并安装C编译器。可以从互联网免费下载Cygwin和MinGW,这样便可在PC上通过命令行使用GCC编译器。Cygwin在自己的视窗运行,模仿Linux命令行环境,有一行命令提示。MinGW在Windows的命令提示模式中运行。这和GCC的最新版本一样,支持C99和C11最新的一些功能。Borland的C++编译器5.5也可以免费下载,支持C90。

源代码文件应该是文本文件,不是字处理器文件(字处理器文件包含许多额外的信息,如字体和格式等)。因此,要使用文本编辑器(如,Windows Notepad)来编辑源代码。如果使用字处理器,要以文本模式另存文件。源代码文件的扩展名应该是.c。一些字处理器会为文本文件自动添加.txt扩展名。如果出现这种情况,要更改文件名,把txt替换成c

通常,C编译器生成的中间目标代码文件的扩展名是.obj(也可能是其他扩展名)。与UNIX编译器不同,这些编译器在完成编译后通常不会删除这些中间文件。有些编译器生成带.asm扩展名的汇编语言文件,而有些编译器则使用自己特有的格式。

一些编译器在编译后会自动运行链接器,另一些要求用户手动运行链接器。在可执行文件中链接的结果是,在原始的源代码基本名后面加上.exe扩展名。例如,编译和链接concrete.c源代码文件,生成的是concrete.exe文件。可以在命令行输入基本名来运行该程序:

C>concrete

许多供应商(包括微软、Embarcadero、Digital Mars)都提供Windows下的集成开发环境,或称为IDE(目前,大多数IDE都是C和C++结合的编译器)。可以免费下载的IDE有Microsoft Visual Studio Express和Pelles C。利用集成开发环境可以快速开发C程序。关键是,这些IDE都内置了用于编写C程序的编辑器。这类集成开发环境都提供了各种菜单(如,命名、保存源代码文件、编译程序、运行程序等),用户不用离开IDE就能顺利编写、编译和运行程序。如果编译器发现错误,会返回编辑器中,标出有错误的行号,并简单描述情况。

初次接触Windows IDE可能会望而生畏,因为它提供了多种目标target即运行程序的多种环境。例如,IDE提供了32位Windows程序、64位Windows程序、动态链接库文件(DLL)等。许多目标都涉及Windows图形界面。要管理这些(及其他)选择,通常要先创建一个项目project),以便稍后在其中添加待使用的源代码文件名。不同的产品具体步骤不同。一般而言,首先使用【文件】菜单或【项目】菜单创建一个项目。选择正确的项目形式非常重要。本书中的例子都是一般示例,针对在简单的命令行环境中运行而设计。Windows IDE提供多种选择以满足用户的不同需求。例如,Microsoft Visual Studio提供【Win32控制台应用程序】选项。对于其他系统,查找一个诸如【DOS EXE】、【Console】或【Character Mode】的可执行选项。选择这些模式后,将在一个类控制台窗口中运行可执行程序。选择好正确的项目类型后,使用IDE的菜单打开一个新的源代码文件。对于大多数产品而言,使用【文件】菜单就能完成。你可能需要其他步骤将源文件添加到项目中。

通常,Windows IDE既可处理C也可处理C++,因此要指定待处理的程序是C还是C++。有些产品用项目类型来区分两者,有些产品(如,Microsoft Visual C++)用.c文件扩展名来指明使用C而不是C++。当然,大多数C程序也可以作为C++程序运行。欲了解C和C++的区别,请参阅参考资料IX。

你可能会遇到一个问题:在程序执行完毕后,执行程序的窗口立即消失。如果不希望出现这种情况,可以让程序暂停,直到按下Enter键,窗口才消失。要实现这种效果,可以在程序的最后(return这行代码之前)添加下面一行代码:

getchar();

该行读取一次键的按下,所以程序在用户按下Enter键之前会暂停。有时根据程序的需要,可能还需要一个击键等待。这种情况下,必须用两次getchar()

getchar();
getchar();

例如,程序在最后提示用户输入体重。用户键入体重后,按下Enter键以输入数据。程序将读取体重,第1个getchar()读取Enter键,第2个getchar()会导致程序暂停,直至用户再次按下Enter键。如果你现在不知所云,没关系,在学完C输出后就会明白。到时,我们会提醒读者使用这种方法。

虽然许多IDE在使用上大体一致,但是细节上有所不同。就一个产品的系列而言,不同版本也是如此。要经过一段时间的实践,才会熟悉编译器的工作方式。必要时,还需阅读使用手册或网上教程。

Microsoft Visual Studio和C标准


在Windows软件开发中,Microsoft Visual Studio及其免费版本Microsoft Visual Studio Express都久负盛名,它们与C标准的关系也很重要。然而,微软鼓励程序员从C转向C++和C#。虽然Visual Studio支持C89/90,但是到目前为止,它只选择性地支持那些在C++新特性中能找到的C标准(如,long long类型)。而且,自2012版本起,Visual Studio不再把C作为项目类型的选项。尽管如此,本书中的绝大多数程序仍可用Visual Studio来编译。在新建项目时,选择C++选项,然后选择【Win32控制台应用程序】,在应用设置中选择【空项目】。几乎所有的C程序都能与C++程序兼容。所以,本书中的绝大多数C程序都可作为C++程序运行。或者,在选择C++选项后,将默认的源文件扩展名.cpp替换成.c,编译器便会使用C语言的规则代替C++。

许多Linux发行版都可以安装在Windows系统中,以创建双系统。一些存储器会为Linux系统预留空间,以便可以启动Windows或Linux。可以在Windows系统中运行Linux程序,或在Linux系统中运行Windows程序。不能通过Windows系统访问Linux文件,但是可以通过Linux系统访问Windows文档。

目前,苹果免费提供Xcode开发系统下载(过去,它有时免费,有时付费)。它允许用户选择不同的编程语言,包括C语言。

Xcode凭借可处理多种编程语言的能力,可用于多平台,开发超大型的项目。但是,首先要学会如何编写简单的C程序。在Xcode 4.6中,通过【File】菜单选择【New Project】,然后选择【OS X Application Command Line Tool】,接着输入产品名并选择C类型。Xcode使用Clang或GCC C编译器来编译C代码,它以前默认使用GCC,但是现在默认使用Clang。可以设置选择使用哪一个编译器和哪一套C标准(因为许可方面的事宜,Xcode中Clang的版本比GCC的版本要新)。

UNIX系统内置Mac OS X,终端工具打开的窗口是让用户在UNIX命令行环境中运行程序。苹果在标准软件包中不提供命令行编译器,但是,如果下载了Xcode,还可以下载可选的命令行工具,这样就可以使用clanggcc命令在命令行模式中编译。

本书采用多种方式编排内容,其中最直接的方法是介绍A主题的所有内容、介绍B主题的所有内容,等等。这对参考类书籍来说尤为重要,读者可以在同一处找到与主题相关的所有内容。但是,这通常不是学习的最佳顺序。例如,如果在开始学习英语时,先学完所有的名词,那你的表达能力一定很有限。虽然可以指着物品说出名称,但是,如果稍微学习一些名词、动词、形容词等,再学习一些造句规则,那么你的表达能力一定会大幅提高。

为了让读者更好地吸收知识,本书采用螺旋式方法,先在前几个章节中介绍一些主题,在后面章节再详细讨论相关内容。例如,对学习C语言而言,理解函数至关重要。因此,我们在前几个章节中安排一些与函数相关的内容,等读者学到第9章时,已对函数有所了解,学习使用函数会更加容易。与此类似,前几章还概述了一些字符串和循环的内容。这样,读者在完全弄懂这些内容之前,就可以在自己的程序中使用这些有用的工具。

在学习C语言之前,先介绍一下本书的格式。

本书用类似在屏幕上或打印输出时的字体(一种等宽字体),表示文本程序和计算机输入、输出。前面已经出现了多次,如果读者没有注意到,字体如下所示:

#include <stdio.h>
int main(void)
{
     printf("Concrete contains gravel and cement.\n");

     return 0;
}

在涉及与代码相关的术语时,也使用相同的等宽字体,如stdio.h。本书用等宽斜体表示占位符,可以用具体的项替换这些占位符。例如,下面是一个声明的模型:

type_name variable_name;

这里,可用int替换type_name,用zebra_count替换variable_name

本书用相同的字体表示计算机的输出,粗体表示用户输入。例如,下面是第14章中一个程序的输出:

Please enter the book title.
Press [enter] at the start of a line to stop.

My Life as a Budgie
Now enter the author.

Mack Zackles

如上所示,以标准计算机字体显示的行表示程序的输出,粗体行表示用户的输入。

可以通过多种方式与计算机交互。在这里,我们假设读者使用键盘键入内容,在屏幕上阅读计算机的响应。

1.特殊的击键

通常,通过按下标有Enterc/rReturn或一些其他文字的键来发送指令。本书将这些按键统一称为Enter键。一般情况下,我们默认你在每行输入的末尾都会按下Enter键。尽管如此,为了标示一些特定的位置,本书使用[enter]显式标出Enter键。方括号表示按下一次Enter键,而不是输入enter

除此之外,书中还会提到控制字符(如,Ctrl+D)。这种写法的意思是,在按下Ctrl键(也可能是Control键)的同时按下D键。

2.本书使用的系统

C语言的某些方面(如,储存数字的空间大小)因系统而异。本书在示例中提到“我们的系统”时,通常是指在iMac上运行OS X 10.8.4,使用Xcode 4.6.2开发系统的Clang 3.2编译器。本书的大部分程序都能使用Windows7系统的Microsoft Visual Studio Express 2012和Pelles C 7.0,以及Ubuntu13.04 Linux系统的GCC 4.7.3进行编译。

3.读者的系统

你需要一个C编译器或访问一个C编译器。C程序可以在多种计算机系统中运行,因此你的选择面很广。确保你使用的C编译器与当前使用的计算机系统匹配。本书中,除了某些示例要求编译器支持C99或C11标准,其余大部分示例都可在C90编译器中运行。如果你使用的编译器是早于ANSI/ISO的老式编译器,在编译时肯定要经常调整,很不方便。与其如此,不如换个新的编译器。

大部分编译器供应商都为学生和教学人员提供特惠版本,详情请查看供应商的网站。

本书包含一些强调特定知识点的特殊元素,提示、注意、警告,将以如下形式出现在本书中:

边栏


边栏提供更深入的讨论或额外的背景,有助于解释当前的主题。

提示


提示一般都短小精悍,帮助读者理解一些特殊的编程情况。

警告


用于警告读者注意一些潜在的陷阱。

注意


提供一些评论,提醒读者不要误入歧途。

C是强大而简洁的编程语言。它之所以流行,在于自身提供大量的实用编程工具,能很好地控制硬件。而且,与大多数其他程序相比,C程序更容易从一个系统移植到另一个系统。

C是编译型语言。C编译器和链接器是把C语言源代码转换成可执行代码的程序。

用C语言编程可能费力、困难,让你感到沮丧,但是它也可以激发你的兴趣,让你兴奋、满意。我们希望你在愉快的学习过程中爱上C。

复习题的参考答案在附录A中。

1.对编程而言,可移植性意味着什么?

2.解释源代码文件、目标代码文件和可执行文件有什么区别?

3.编程的7个主要步骤是什么?

4.编译器的任务是什么?

5.链接器的任务是什么?

我们尚未要求你编写C代码,该练习侧重于编程过程的早期步骤。

1.你刚被MacroMuscle有限公司聘用。该公司准备进入欧洲市场,需要一个把英寸单位转换为厘米单位(1英寸=2.54厘米)的程序。该程序要提示用户输入英寸值。你的任务是定义程序目标和设计程序(编程过程的第1步和第2步)。

[1] 国际C语言混乱代码大赛(IOCCC,The International Obfuscated C Code Contest)。这是一项国际编程赛事,从1984年开始,每年举办一次(1997、1999、2002、2003和2006年除外),目的是写出最有创意且最让人难以理解的C语言代码。——译者注

[2] VAX(Virtual Address eXtension)是一种可支持机器语言和虚拟地址的32位小型计算机。VMS(Virtual Memory System)是旧名,现在叫OpenVMS,是一种用于服务器的操作系统,可在VAX、Alpha或Itanium处理器系列平台上运行。——译者注

[3] GCC最基本的用法是:gcc [options] [filenames],其中options是所需的参数,filenames是文件名。——译者注


本章介绍以下内容:

  • 运算符:=
  • 函数:main()printf()
  • 编写一个简单的C程序
  • 创建整型变量,为其赋值并在屏幕上显示其值
  • 换行字符
  • 如何在程序中写注释,创建包含多个函数的程序,发现程序的错误
  • 什么是关键字

C程序是什么样子的?浏览本书,能看到许多示例。初见C程序会觉得有些古怪,程序中有许多{、cp->tort*ptr++这样的符号。然而,在学习C的过程中,对这些符号和C语言特有的其他符号会越来越熟悉,甚至会喜欢上它们。如果熟悉与C相关的其他语言,会对C语言有似曾相识的感觉。本章,我们从演示一个简单的程序示例开始,解释该程序的功能。同时,强调一些C语言的基本特性。


程序的输出是否在屏幕上一闪而过?某些窗口环境会在单独的窗口运行程序,然后在程序运行结束后自动关闭窗口。如果遇到这种情况,可以在程序中添加额外的代码,让窗口等待用户按下一个键后才关闭。一种方法是,在程序的return语句前添加一行代码:

这行代码会让程序等待击键,窗口会在用户按下一个键后才关闭。在第8章中会详细介绍getchar()的内容。

我们来看一个简单的C程序,如程序清单2.1所示。该程序演示了用C语言编程的一些基本特性。请先通读程序清单2.1,看看自己是否能明白该程序的用途,再认真阅读后面的解释。

程序清单2.1 first.c程序

getchar();
#include <stdio.h>
int main(void)                    /* 一个简单的C程序 */
{
    int num;                     /* 定义一个名为num的变量 */
    num = 1;                     /* 为num赋一个值 */

    printf("I am a simple ");    /* 使用printf()函数 */
    printf("computer.\n");
    printf("My favorite number is %d because it is first.\n",num);

    return 0;
}

如果你认为该程序会在屏幕上打印一些内容,那就对了!光看程序也许并不知道打印的具体内容,所以,运行该程序,并查看结果。首先,用你熟悉的编辑器(或者编译器提供的编辑器)创建一个包含程序清单2.1中所有内容的文件。给该文件命名,并以.c作为扩展名,以满足当前系统对文件名的要求。例如,可以使用first.c。现在,编译并运行该程序(查看第1章,复习该步骤的具体内容)。如果一切运行正常,该程序的输出应该是:

I am a simple computer.
My favorite number is 1 because it is first.

总而言之,结果在意料之中,但是程序中的\n%d是什么?程序中有几行代码看起来有点奇怪。接下来,我们逐行解释这个程序。

程序调整

我们会把程序清单2.1的程序分析两遍。第1遍(快速概要)概述程序中每行代码的作用,帮助读者初步了解程序。第2遍(程序细节)详细分析代码的具体含义,帮助读者深入理解程序。

图2.1总结了组成C程序的几个部分[1],图中包含的元素比第1个程序多。

图2.1 C程序解剖

本节简述程序中的每行代码的作用。下一节详细讨论代码的含义。

#include<stdio.h>      ←包含另一个文件

该行告诉编译器把stdio.h中的内容包含在当前程序中。stdio.h是C编译器软件包的标准部分,它提供键盘输入和屏幕输出的支持。

int main(void)         ←函数名

C程序包含一个或多个函数,它们是C程序的基本模块。程序清单2.1的程序中有一个名为main()的函数。圆括号表明main()是一个函数名。int表明main()函数返回一个整数,void表明main()不带任何参数。这些内容我们稍后详述。现在,只需记住intvoid是标准ANSI C定义main()的一部分(如果使用ANSI C之前的编译器,请省略void;考虑到兼容的问题,请尽量使用较新的C编译器)。

/* 一个简单的C程序 */    ←注释

注释在/**/两个符号之间,这些注释能提高程序的可读性。注意,注释只是为了帮助读者理解程序,编译器会忽略它们。

{         ←函数体开始

左花括号表示函数定义开始,右花括号(})表示函数定义结束。

int num;         ←声明

该声明表明,将使用一个名为num的变量,而且numint(整数)类型。

num = 1;         ←赋值表达式语句

语句num = 1;把值1赋给名为num的变量。

printf("I am a simple ");  ←调用一个函数

该语句使用printf()函数,在屏幕上显示I am a simple,光标停在同一行。printf()是标准的C库函数。在程序中使用函数叫作调用函数

printf("computer.\n");     ←调用另一个函数

接下来调用的这个printf()函数在上条语句打印出来的内容后面加上“computer”。代码\n告诉计算机另起一行,即把光标移至下一行。

printf("My favorite number is %d because it is first.\n", num);

最后调用的printf()num的值(1)内嵌在用双引号括起来的内容中一并打印。%d告诉计算机以何种形式输出num的值,打印在何处。

return 0;     ←return语句

C函数可以给调用方提供(或返回)一个数。目前,可暂时把该行看作是结束main()函数的要求。

}        ←结束

必须以右花括号表示程序结束。

   RADIUS1 = 20.4;

浏览完程序清单2.1后,我们来仔细分析这个程序。再次强调,本节将逐行分析程序中的代码,以每行代码为出发点,深入分析代码背后的细节,为更全面地学习C语言编程的特性夯实基础。

   CIRCUM = 6.28 * RADIUSl;

1.#include指令和头文件

#include<stdio.h>

这是程序的第1行。#include <stdio.h>的作用相当于把stdio.h文件中的所有内容都输入该行所在的位置。实际上,这是一种“拷贝-粘贴”的操作。include文件提供了一种方便的途径共享许多程序共有的信息。

#include这行代码是一条C预处理器指令preprocessor directive)。通常,C编译器在编译前会对源代码做一些准备工作,即预处理preprocessing)。

所有的C编译器软件包都提供stdio.h文件。该文件中包含了供编译器使用的输入和输出函数(如,printf())信息。该文件名的含义是标准输入/输出头文件。通常,在C程序顶部的信息集合被称为头文件header)。

在大多数情况下,头文件包含了编译器创建最终可执行程序要用到的信息。例如,头文件中可以定义一些常量,或者指明函数名以及如何使用它们。但是,函数的实际代码在一个预编译代码的库文件中。简而言之,头文件帮助编译器把你的程序正确地组合在一起。

ANSI/ISO C规定了C编译器必须提供哪些头文件。有些程序要包含stdio.h,而有些不用。特定C实现的文档中应该包含对C库函数的说明。这些说明确定了使用哪些函数需要包含哪些头文件。例如,要使用printf()函数,必须包含stdio.h头文件。省略必要的头文件可能不会影响某一特定程序,但是最好不要这样做。本书每次用到库函数,都会用#include指令包含ANSI/ISO标准指定的头文件。

注意

为何不内置输入和输出

读者一定很好奇,为何不把输入和输出这些基本功能内置在语言中。原因之一是,并非所有的程序都会用到I/O(输入/输出)包。轻装上阵表现了C语言的哲学。正是这种经济使用资源的原则,使得C语言成为流行的嵌入式编程语言(例如,编写控制汽车自动燃油系统或蓝光播放机芯片的代码)。#include中的#符号表明,C预处理器在编译器接手之前处理这条指令。本书后面章节中会介绍更多预处理器指令的示例,第16章将更详细地讨论相关内容。

2.main()函数

int main(void)

程序清单2.1中的第2行表明该函数名为main。的确,main是一个极其普通的名称,但是这是唯一的选择。C程序一定从main()函数开始执行(目前不必考虑例外的情况)。除了main()函数,你可以任意命名其他函数,而且main()函数必须是开始的函数。圆括号有什么功能?用于识别main()是一个函数。很快你将学到更多的函数。就目前而言,只需记住函数是C程序的基本模块。

intmain()函数的返回类型。这表明main()函数返回的值是整数。返回到哪里?返回给操作系统。我们将在第6章中再来探讨这个问题。

通常,函数名后面的圆括号中包含一些传入函数的信息。该例中没有传递任何信息。因此,圆括号内是单词void(第11章将介绍把信息从main()函数传回操作系统的另一种形式)。

如果浏览旧式的C代码,会发现程序以如下形式开始:

main()

C90标准勉强接受这种形式,但是C99和C11标准不允许这样写。因此,即使你使用的编译器允许,也不要这样写。

你还会看到下面这种形式:

void main()

一些编译器允许这样写,但是所有的标准都未认可这种写法。因此,编译器不必接受这种形式,而且许多编译器都不能这样写。需要强调的是,只要坚持使用标准形式,把程序从一个编译器移至另一个编译器时就不会出什么问题。

3.注释

/*一个简单的程序*/

在程序中,被/* */两个符号括起来的部分是程序的注释。写注释能让他人(包括自己)更容易明白你所写的程序。C语言注释的好处之一是,可将注释放在任意的地方,甚至是与要解释的内容在同一行。较长的注释可单独放一行或多行。在/**/之间的内容都会被编译器忽略。下面列出了一些有效和无效的注释形式:

/* 这是一条C注释。 */
/* 这也是一条注释,
    被分成两行。*/
/*
    也可以这样写注释。
*/

/* 这条注释无效,因为缺少了结束标记。

C99新增了另一种风格的注释,普遍用于C++和Java。这种新风格使用//符号创建注释,仅限于单行。

// 这种注释只能写成一行。
int rigue; // 这种注释也可置于此。

因为一行末尾就标志着注释的结束,所以这种风格的注释只需在注释开始处标明//符号即可。

这种新形式的注释是为了解决旧形式注释存在的潜在问题。假设有下面的代码:

/*
    希望能运行。
*/
x = 100;
y = 200;
/* 其他内容已省略。 */

接下来,假设你决定删除第4行,但不小心删掉了第3行(*/)。代码如下所示:

/*
    希望能运行。
y = 200;
/*其他内容已省略。 */

现在,编译器把第1行的/*和第4行的*/配对,导致4行代码全都成了注释(包括应作为代码的那一行)。而//形式的注释只对单行有效,不会导致这种“消失代码”的问题。

一些编译器可能不支持这一特性。还有一些编译器需要更改设置,才能支持C99或C11的特性。

考虑到只用一种注释风格过于死板乏味,本书在示例中采用两种风格的注释。

4.花括号、函数体和块

{
    ...
}

程序清单2.1中,花括号把main()函数括起来。一般而言,所有的C函数都使用花括号标记函数体的开始和结束。这是规定,不能省略。只有花括号({})能起这种作用,圆括号(())和方括号([])都不行。

花括号还可用于把函数中的多条语句合并为一个单元或块。如果读者熟悉Pascal、ADA、Modula-2或者Algol,就会明白花括号在C语言中的作用类似于这些语言中的beginend

5.声明

int num;

程序清单2.1中,这行代码叫作声明declaration)。声明是C语言最重要的特性之一。在该例中,声明完成了两件事。其一,在函数中有一个名为num变量variable)。其二,int表明num是一个整数(即,没有小数点或小数部分的数)。int是一种数据类型。编译器使用这些信息为num变量在内存中分配存储空间。分号在C语言中是大部分语句和声明的一部分,不像在Pascal中只是语句间的分隔符。

int是C语言的一个关键字keyword),表示一种基本的C语言数据类型。关键字是语言定义的单词,不能做其他用途。例如,不能用int作为函数名和变量名。但是,这些关键字在该语言以外不起作用,所以把一只猫或一个可爱的小孩叫int是可以的(尽管某些地方的当地习俗或法律可能不允许)。

示例中的num是一个标识符identifier),也就是一个变量、函数或其他实体的名称。因此,声明把特定标识符与计算机内存中的特定位置联系起来,同时也确定了储存在某位置的信息类型或数据类型。

在C语言中,所有变量都必须先声明才能使用。这意味着必须列出程序中用到的所有变量名及其类型。

以前的C语言,还要求把变量声明在块的顶部,其他语句不能在任何声明的前面。也就是说,main()函数体如下所示:

int main() //旧规则
{
    int doors;
    int dogs;
    doors = 5;
    dogs = 3;
    // 其他语句
}

C99和C11遵循C++的惯例,可以把声明放在块中的任何位置。尽管如此,首次使用变量之前一定要先声明它。因此,如果编译器支持这一新特性,可以这样编写上面的代码:

int main()            // 目前的C规则
{
    // 一些语句
    int doors;
    doors = 5; // 第1次使用doors
    // 其他语句
    int dogs;
    dogs = 3; // 第1次使用dogs
    // 其他语句
}

为了与旧系统更好地兼容,本书沿用最初的规则(即,把变量声明都写在块的顶部)。

现在,读者可能有3个问题:什么是数据类型?如何命名?为何要声明变量?请往下看。

数据类型

C语言可以处理多种类型的数据,如整数、字符和浮点数。把变量声明为整型或字符类型,计算机才能正确地储存、读取和解释数据。下一章将详细介绍C语言中的各种数据类型。

命名

给变量命名时要使用有意义的变量名或标识符(如,程序中需要一个变量数羊,该变量名应该是sheep_count而不是x3)。如果变量名无法清楚地表达自身的用途,可在注释中进一步说明。这是一种良好的编程习惯和编程技巧。

C99和C11允许使用更长的标识符名,但是编译器只识别前63个字符。对于外部标识符(参阅第12章),只允许使用31个字符。〔以前C90只允许6个字符,这是一个很大的进步。旧式编译器通常最多只允许使用8个字符。〕实际上,你可以使用更长的字符,但是编译器会忽略超出的字符。也就是说,如果有两个标识符名都有63个字符,只有一个字符不同,那么编译器会识别这是两个不同的名称。如果两个标识符都是64个字符,只有最后一个字符不同,那么编译器可能将其视为同一个名称,也可能不会。标准并未定义在这种情况下会发生什么。

可以用小写字母、大写字母、数字和下划线(_)来命名。而且,名称的第1个字符必须是字母或下划线,不能是数字。表2.1给出了一些示例。

表2.1 有效和无效的名称

有效的名称 无效的名称
wiggles $Z]**
cat2 2cat
Hot_Tub Hot-Tub
taxRate tax rate
_kcab don’t

操作系统和C库经常使用以一个或两个下划线字符开始的标识符(如,_kcab),因此最好避免在自己的程序中使用这种名称。标准标签都以一个或两个下划线字符开始,如库标识符。这样的标识符都是保留的。这意味着,虽然使用它们没有语法错误,但是会导致名称冲突。

C语言的名称区分大小写,即把一个字母的大写和小写视为两个不同的字符。因此,starsStarsSTARS都不同。

为了让C语言更加国际化,C99和C11根据通用字符名(即UCN)机制添加了扩展字符集。其中包含了除英文字母以外的部分字符。欲了解详细内容,请参阅附录B的“参考资料VII:扩展字符支持”。

声明变量的4个理由

一些更老的语言(如,FORTRAN和BASIC的最初形式)都允许直接使用变量,不必先声明。为何C语言不采用这种简单易行的方法?原因如下。

如果要声明变量,应该声明在何处?前面提到过,C99之前的标准要求把声明都置于块的顶部,这样规定的好处是:把声明放在一起更容易理解程序的用途。C99允许在需要时才声明变量,这样做的好处是:在给变量赋值之前声明变量,就不会忘记给变量赋值。但是实际上,许多编译器都还不支持C99。

6.赋值

num = 1;

程序清单中的这行代码是赋值表达式语句[2]。赋值是C语言的基本操作之一。该行代码的意思是“把值1赋给变量num”。在执行int num;声明时,编译器在计算机内存中为变量num预留了空间,然后在执行这行赋值表达式语句时,把值储存在之前预留的位置。可以给num赋不同的值,这就是num之所以被称为变量variable)的原因。注意,该赋值表达式语句从右侧把值赋到左侧。另外,该语句以分号结尾,如图2.2所示。

图2.2 赋值是C语言中的基本操作之一

7.printf()函数

printf("I am a simple ");
printf("computer.\n");
printf("My favorite number is %d because it is first.\n", num);

这3行都使用了C语言的一个标准函数:printf()。圆括号表明printf是一个函数名。圆括号中的内容是从main()函数传递给printf()函数的信息。例如,上面的第1行把I am a simple传递给printf()函数。该信息被称为参数,或者更确切地说,是函数的实际参数actual argument),如图2.3所示。〔在C语言中,实际参数(简称实参)是传递给函数的特定值,形式参数(简称形参)是函数中用于储存值的变量。第5章中将详述相关内容。〕printf()函数用参数来做什么?该函数会查看双引号中的内容,并将其打印在屏幕上。

图2.3 带实参的printf()函数

第1行printf()演示了在C语言中如何调用函数。只需输入函数名,把所需的参数填入圆括号即可。当程序运行到这一行时,控制权被转给已命名的函数(该例中是printf())。函数执行结束后,控制权被返回至主调函数calling function),该例中是main()

第2行printf()函数的双引号中的\n字符并未输出。这是为什么?\n的意思是换行。\n组合(依次输入这两个字符)代表一个换行符newline character)。对于printf()而言,它的意思是“在下一行的最左边开始新的一行”。也就是说,打印换行符的效果与在键盘按下Enter键相同。既然如此,为何不在键入printf()参数时直接使用Enter键?因为编辑器可能认为这是直接的命令,而不是储存在源代码中的指令。换句话说,如果直接按下Enter键,编辑器会退出当前行并开始新的一行。但是,换行符仅会影响程序输出的显示格式。

换行符是一个转义序列escape sequence)。转义序列用于代表难以表示或无法输入的字符。如,\t代表Tab键,\b代表Backspace键(退格键)。每个转义序列都以反斜杠字符(\)开始。我们在第3章中再来探讨相关内容。

这样,就解释了为什么3行printf()语句只打印出两行:第1个printf()打印的内容中不含换行符,但是第2和第3个printf()中都有换行符。

第3个printf()还有一些不明之处:参数中的%d在打印时有什么作用?先来看该函数的输出:

My favorite number is 1 because it is first.

对比发现,参数中的%d被数字1代替了,而1就是变量num的值。%d相当于是一个占位符,其作用是指明输出num值的位置。该行和下面的BASIC语句很像:

PRINT "My favorite number is "; num; " because it is first."

实际上,C语言的printf()比BASIC的这条语句做的事情多一些。%提醒程序,要在该处打印一个变量,d表明把变量作为十进制整数打印。printf()函数名中的f提醒用户,这是一种格式化打印函数。printf()函数有多种打印变量的格式,包括小数和十六进制整数。后面章节在介绍数据类型时,会详细介绍相关内容。

8.return语句

return 0;

return语句[3]是程序清单2.1的最后一条语句。int main(void)中的int表明main()函数应返回一个整数。C标准要求main()这样做。有返回值的C函数要有return语句。该语句以return关键字开始,后面是待返回的值,并以分号结尾。如果遗漏main()函数中的return语句,程序在运行至最外面的右花括号(})时会返回0。因此,可以省略main()函数末尾的return语句。但是,不要在其他有返回值的函数中漏掉它。因此,强烈建议读者养成在main()函数中保留return语句的好习惯。在这种情况下,可将其看作是统一代码风格。但对于某些操作系统(包括Linux和UNIX),return语句有实际的用途。第11章再详述这个主题。

在看过一个具体的程序示例后,我们来了解一下C程序的基本结构。程序由一个或多个函数组成,必须有main()函数。函数由函数头和函数体组成。函数头包括函数名、传入该函数的信息类型和函数的返回类型。通过函数名后的圆括号可识别出函数,圆括号里可能为空,可能有参数。函数体被花括号括起来,由一系列语句、声明组成,如图2.4所示。本章的程序示例中有一条声明,声明了程序使用的变量名和类型。然后是一条赋值表达式语句,变量被赋给一个值。接下来是3条printf()语句[4],调用printf()函数3次。最后,main()return语句结束。

图2.4 函数包含函数头和函数体

简而言之,一个简单的C程序的格式如下:

#include <stdio.h>
int main(void)
{
    语句
    return 0;
}

(大部分语句都以分号结尾。)

编写可读性高的程序是良好的编程习惯。可读性高的程序更容易理解,以后也更容易修改和更正。提高程序的可读性还有助于你理清编程思路。

前面介绍过两种提高程序可读性的技巧:选择有意义的函数名和写注释。注意,使用这两种技巧时应相得益彰,避免重复啰嗦。如果变量名是width,就不必写注释说明该变量表示宽度,但是如果变量名是video_routine_4,就要解释一下该变量名的含义。

提高程序可读性的第3个技巧是:在函数中用空行分隔概念上的多个部分。例如,程序清单2.1中用空行把声明部分和程序的其他部分区分开来。C语言并未规定一定要使用空行,但是多使用空行能提高程序的可读性。

提高程序可读性的第4个技巧是:每条语句各占一行。同样,这也不是C语言的要求。C语言的格式比较自由,可以把多条语句放在一行,也可以每条语句独占一行。下面的语句都没问题,但是不好看:

int main( void ) { int four; four
=
4
;
printf(
       "%d\n",
four); return 0;}

分号告诉编译器一条语句在哪里结束、下一条语句在哪里开始。如果按照本章示例的约定来编写代码(见图2.5),程序的逻辑会更清晰。

图2.5 提高程序的可读性

本章的第1个程序相当简单,下面的程序清单2.2也不太难。

程序清单2.2 fathm_ft.c程序

// fathm_ft.c -- 把2英寻转换成英尺
#include <stdio.h>
int main(void)
{
    int feet, fathoms;

    fathoms = 2;
    feet = 6 * fathoms;
    printf("There are %d feet in %d fathoms!\n", feet, fathoms);
    printf("Yes, I said %d feet!\n", 6 * fathoms);

    return 0;
}

与程序清单2.1相比,以上代码有什么新内容?这段代码提供了程序描述,声明了多个变量,进行了乘法运算,并打印了两个变量的值。下面我们更详细地分析这些内容。

程序在开始处有一条注释(使用新的注释风格),给出了文件名和程序的目的。写这种程序说明很简单、不费时,而且在以后浏览或打印程序时很有帮助。

接下来,程序在一条声明中声明了两个变量,而不是一个变量。为此,要在声明中用逗号隔开两个变量(feetfathoms)。也就是说,

int feet, fathoms;

int feet;
int fathoms;

等价。

然后,程序进行了乘法运算。利用计算机强大的计算能力来计算6乘以2。C语言和许多其他语言一样,用*表示乘法。因此,语句

feet = 6 * fathoms;

的意思是“查找变量fathoms的值,用6乘以该值,并把计算结果赋给变量feet”。

最后,程序以新的方式使用printf()函数。如果编译并运行该程序,输出应该是这样:

There are 12 feet in 2 fathoms!
Yes, I said 12 feet!

程序的第1个printf()中进行了两次替换。双引号后面的第1个变量(feet)替换了双引号中的第1个%d;双引号后面的第2个变量(fathoms)替换了双引号中的第2个%d。注意,待输出的变量列于双引号的后面。还要注意,变量之间要用逗号隔开。

第2个printf()函数说明待打印的值不一定是变量,只要可求值得出合适类型值的项即可,如6 * fathoms

该程序涉及的范围有限,但它是把英寻[5]转换成英尺程序的核心部分。我们还需要把其他值通过交互的方式赋给feet,其方法将在后面章节中介绍。

到目前为止,介绍的几个程序都只使用了printf()函数。程序清单2.3演示了除main()以外,如何把自己的函数加入程序中。

程序清单2.3 two_func.c程序

//* two_func.c -- 一个文件中包含两个函数 */
#include <stdio.h>
void butler(void); /* ANSI/ISO C函数原型 */
int main(void)
{
    printf("I will summon the butler function.\n");
    butler();
    printf("Yes. Bring me some tea and writeable DVDs.\n");

    return 0;
}
void butler(void) /* 函数定义开始 */
{
    printf("You rang, sir?\n");
}

该程序的输出如下:

I will summon the butler function.
You rang, sir?
Yes. Bring me some tea and writeable DVDs.

butler()函数在程序中出现了3次。第1次是函数原型prototype),告知编译器在程序中要使用该函数;第2次以函数调用function call)的形式出现在main()中;最后一次出现在函数定义function definition)中,函数定义即是函数本身的源代码。下面逐一分析。

C90标准新增了函数原型,旧式的编译器可能无法识别(稍后我们将介绍,如果使用这种编译器应该怎么做)。函数原型是一种声明形式,告知编译器正在使用某函数,因此函数原型也被称为函数声明function declaration)。函数原型还指明了函数的属性。例如,butler()函数原型中的第1个void表明,butler()函数没有返回值(通常,被调函数会向主调函数返回一个值,但是bulter()函数没有)。第2个voidbutler(void)中的void)的意思是butler()函数不带参数。因此,当编译器运行至此,会检查butler()是否使用得当。注意,void在这里的意思是“空的”,而不是“无效”。

早期的C语言支持一种更简单的函数声明,只需指定返回类型,不用描述参数:

void butler();

早期的C代码中的函数声明就类似上面这样,不是现在的函数原型。C90、C99和C11标准都承认旧版本的形式,但是也表明了会逐渐淘汰这种过时的写法。如果要使用以前写的C代码,就需要把旧式声明转换成函数原型。本书在后面的章节会继续介绍函数原型的相关内容。

接下来我们继续分析程序。在main()中调用butler()很简单,写出函数名和圆括号即可。当butler()执行完毕后,程序会继续执行main()中的下一条语句。

程序的最后部分是butler()函数的定义,其形式和main()相同,都包含函数头和用花括号括起来的函数体。函数头重述了函数原型的信息:bulter()不带任何参数,且没有返回值。如果使用老式编译器,请去掉圆括号中的void

这里要注意,何时执行butler()函数取决于它在main()中被调用的位置,而不是butler()的定义在文件中的位置。例如,把butler()函数的定义放在main()定义之前,不会改变程序的执行顺序,butler()函数仍然在两次printf()调用之间被调用。记住,无论main()在程序文件处于什么位置,所有的C程序都从main()开始执行。但是,C的惯例是把main()放在开头,因为它提供了程序的基本框架。

C标准建议,要为程序中用到的所有函数提供函数原型。标准include文件(包含文件)为标准库函数提供了函数原型。例如,在C标准中,stdio.h文件包含了printf()的函数原型。第6章最后一个示例演示了如何使用带返回值的函数,第9章将详细全面地介绍函数。

现在,你可以编写一个简单的C程序,但是可能会犯一些简单的错误。程序的错误通常叫做bug,找出并修正错误的过程叫做调试debug)。程序清单2.4是一个有错误的程序,看看你能找出几处。

程序清单2.4 nogood.c程序

/* nogood.c -- 有错误的程序 */
#include <stdio.h>
int main(void)
(
    int n, int n2, int n3;

    /* 该程序有多处错误
    n = 5;
    n2 = n * n;
    n3 = n2 * n2;
    printf("n = %d, n squared = %d, n cubed = %d\n", n, n2, n3)

    return 0;
)

程序清单2.4中有多处语法错误。如果不遵循C语言的规则就会犯语法错误。这类似于英文中的语法错误。例如,看看这个句子:Bugs frustrate be can[6]。该句子中的英文单词都是有效的单词(即,拼写正确),但是并未按照正确的顺序组织句子,而且用词也不妥。C语言的语法错误指的是,把有效的C符号放在错误的地方。

nogood.c程序中有哪些错误?其一,main()函数体使用圆括号来代替花括号。这就是把C符号用错了地方。其二,变量声明应该这样写:

int n, n2, n3;

或者,这样写:

int n;
int n2;
int n3;

其三,main()中的注释末尾漏掉了*/(另一种修改方案是,用//替换/*)。最后,printf()语句末尾漏掉了分号。

如何发现程序的语法错误?首先,在编译之前,浏览源代码看是否能发现一些明显的错误。接下来,查看编译器是否发现错误,检查程序的语法错误是它的工作之一。在编译程序时,编译器发现错误会报告错误信息,指出每一处错误的性质和具体位置。

尽管如此,编译器也有出错的时候。也许某处隐藏的语法错误会导致编译器误判。例如,由于nogood.c程序未正确声明n2n3,会导致编译器在使用这些变量时发现更多问题。实际上,有时不用把编译器报告的所有错误逐一修正,仅修正第1条或前几处错误后,错误信息就会少很多。继续这样做,直到编译器不再报错。编译器另一个常见的毛病是,报错的位置比真正的错误位置滞后一行。例如,编译器在编译下一行时才会发现上一行缺少分号。因此,如果编译器报错某行缺少分号,请检查上一行。

语义错误是指意思上的错误。例如,考虑这个句子:Scornful derivatives sing greenly(轻蔑的衍生物不熟练地唱歌)。句中的形容词、名词、动词和副词都在正确的位置上,所以语法正确。但是,却让人不知所云。在C语言中,如果遵循了C规则,但是结果不正确,那就是犯了语义错误。程序示例中有这样的错误:

n3 = n2 * n2;

此处,n3原意表示n的3次方,但是代码中的n3被设置成n的4次方(n2 = n * n)。

编译器无法检测语义错误,因为这类错误并未违反C语言的规则。编译器无法了解你的真正意图,所以你只能自己找出这些错误。例如,假设你修正了程序的语法错误,程序应该如程序清单2.5所示:

程序清单2.5 stillbad.c程序

/* stillbad.c -- 修复了语法错误的程序 */
#include <stdio.h>
int main(void)
{
    int n, n2, n3;

    /* 该程序有一个语义错误 */
    n = 5;
    n2 = n * n;
    n3 = n2 * n2;
    printf("n = %d, n squared = %d, n cubed = %d\n", n, n2, n3);

    return 0;
}

该程序的输出如下:

n = 5, n squared = 25, n cubed = 625

如果对简单的立方比较熟悉,就会注意到625不对。下一步是跟踪程序的执行步骤,找出程序如何得出这个答案。对于本例,通过查看代码就会发现其中的错误,但是,还应该学习更系统的方法。方法之一是,把自己想象成计算机,跟着程序的步骤一步一步地执行。下面,我们来试试这种方法。

main()函数体一开始就声明了3个变量:nn2n3。你可以画出3个盒子并把变量名写在盒子上来模拟这种情况(见图2.6)。接下来,程序把5赋给变量n。你可以在标签为n的盒子里写上5。接着,程序把nn相乘,并把乘积赋给n2。因此,查看标签为n的盒子,其值是5,5乘以5得25,于是把25放进标签为n2的盒子里。为了模拟下一条语句(n3 = n2 * n2),查看n2盒子,发现其值是25。25乘以25得625,把625放进标签为n3的盒子。原来如此!程序中计算的是n2的平方,不是用n2乘以n得到n的3次方。

图2.6 跟踪程序的执行步骤

对于上面的程序示例,检查程序的过程可能过于繁琐。但是,用这种方法一步一步查看程序的执行情况,通常是发现程序问题所在的良方。

通过逐步跟踪程序的执行步骤,并记录每个变量,便可监视程序的状态。程序状态program state)是在程序的执行过程中,某给定点上所有变量值的集合。它是计算机当前状态的一个快照。

我们刚刚讨论了一种跟踪程序状态的方法:自己模拟计算机逐步执行程序。但是,如果程序中有10000次循环,这种方法恐怕行不通。不过,你可以跟踪一小部分循环,看看程序是否按照预期的方式执行。另外,还要考虑一种情况:你很可能按照自己所想去执行程序,而不是根据实际写出来的代码去执行。因此,要尽量忠实于代码来模拟。

定位语义错误的另一种方法是:在程序中的关键点插入额外的printf()语句,以监视制定变量值的变化。通过查看值的变化可以了解程序的执行情况。对程序的执行满意后,便可删除额外的printf()语句,然后重新编译。

检测程序状态的第3种方法是使用调试器。调试器(debugger)是一种程序,让你一步一步运行另一个程序,并检查该程序变量的值。调试器有不同的使用难度和复杂度。较高级的调试器会显示正在执行的源代码行号。这在检查有多条执行路径的程序时很方便,因为很容易知道正在执行哪条路径。如果你的编译器自带调试器,现在可以花点时间学会怎么使用它。例如,试着调试一下程序清单2.4。

关键字是C语言的词汇。它们对C而言比较特殊,不能用它们作为标识符(如,变量名)。许多关键字用于指定不同的类型,如int。还有一些关键字(如,if)用于控制程序中语句的执行顺序。在表2.2中所列的C语言关键字中,粗体表示的是C90标准新增的关键字,斜体表示的C99标准新增的关键字,粗斜体表示的是C11标准新增的关键字。

表2.2 ISO C关键字

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

如果使用关键字不当(如,用关键字作为变量名),编译器会将其视为语法错误。还有一些保留标识符reserved identifier),C语言已经指定了它们的用途或保留它们的使用权,如果你使用这些标识符来表示其他意思会导致一些问题。因此,尽管它们也是有效的名称,不会引起语法错误,也不能随便使用。保留标识符包括那些以下划线字符开头的标识符和标准库函数名,如printf()

编程是一件富有挑战性的事情。程序员要具备抽象和逻辑的思维,并谨慎地处理细节问题(编译器会强迫你注意细节问题)。平时和朋友交流时,可能用错几个单词,犯一两个语法错误,或者说几句不完整的句子,但是对方能明白你想说什么。而编译器不允许这样,对它而言,几乎正确仍然是错误。

编译器不会在下面讲到的概念性问题上帮助你。因此,本书在这一章中介绍一些关键概念帮助读者弥补这部分的内容。

在本章中,读者的目标应该是理解什么是C程序。可以把程序看作是你希望计算机如何完成任务的描述。编译器负责处理一些细节工作,例如把你要计算机完成的任务转换成底层的机器语言(如果从量化方面来解释编译器所做的工作,它可以把1KB的源文件创建成60KB的可执行文件;即使是一个很简单的C程序也要用大量的机器语言来表示)。由于编译器不具有真正的智能,所以你必须用编译器能理解的术语表达你的意图,这些术语就是C语言标准规定的形式规则(尽管有些约束,但总比直接用机器语言方便得多)。

编译器希望接收到特定格式的指令,我们在本章已经介绍过。作为程序员的任务是,在符合C标准的编译器框架中,表达你希望程序应该如何完成任务的想法。

C程序由一个或多个C函数组成。每个C程序必须包含一个main()函数,这是C程序要调用的第1个函数。简单的函数由函数头和后面的一对花括号组成,花括号中是由声明、语句组成的函数体。

在C语言中,大部分语句都以分号结尾。声明语句为变量指定变量名,并标识该变量中储存的数据类型。变量名是一种标识符。赋值表达式语句把值赋给变量,或者更一般地说,把值赋给存储空间。函数表达式语句用于调用指定的已命名函数。调用函数执行完毕后,程序会返回到函数调用后面的语句继续执行。

printf()函数用于输出想要表达的内容和变量的值。

一门语言的语法是一套规则,用于管理语言中各有效语句组合在一起的方式。语句的语义是语句要表达的意思。编译器可以检测出语法错误,但是程序里的语义错误只有在编译完之后才能从程序的行为中表现出来。检查程序是否有语义错误要跟踪程序的状态,即检查程序每执行一步后所有变量的值。

最后,关键字是C语言的词汇。

复习题的参考答案在附录A中。

1.C语言的基本模块是什么?

2.什么是语法错误?写出一个英语例子和C语言例子。

3.什么是语义错误?写出一个英语例子和C语言例子。

4.Indiana Sloth编写了下面的程序,并征求你的意见。请帮助他评定。

include studio.h
int main{void} / * 该程序打印一年有多少周 / *
(
    int s

    s := 56;
    print(There are s weeks in a year.);
    return 0;

5.假设下面的4个例子都是完整程序中的一部分,它们都输出什么结果?

a. printf("Baa Baa Black Sheep.");
   printf("Have you any wool?\n");
b. printf("Begone!\nO creature of lard!\n");
c. printf("What?\nNo/nfish?\n");
d. int num;
   num = 2;
   printf("%d + %d = %d", num, num, num + num);

6.在mainintfunctionchar=中,哪些是C语言的关键字?

7.如何以下面的格式输出变量wordslines的值(这里,3020350代表两个变量的值)?

There were 3020 words and 350 lines.

8.考虑下面的程序:

#include <stdio.h>
int main(void)
{
     int a, b;

     a = 5;
     b = 2; /* 第7行 */
     b = a; /* 第8行 */
     a = b; /* 第9行 */
     printf("%d %d\n", b, a);
     return 0;
}

请问,在执行完第7、第8、第9行后,程序的状态分别是什么?

9.考虑下面的程序:

#include <stdio.h>
int main(void)
{
     int x, y;

     x = 10;
     y = 5;      /* 第7行 */
     y = x + y;  /*第8行*/
     x = x*y;    /*第9行*/
     printf("%d %d\n", x, y);
     return 0;
}

请问,在执行完第7、第8、第9行后,程序的状态分别是什么?

纸上得来终觉浅,绝知此事要躬行。读者应该试着编写一两个简单的程序,体会一下编写程序是否和阅读本章介绍的这样轻松。题目中会给出一些建议,但是应该尽量自己思考这些问题。一些编程答案练习的答案可在出版商网站获取。

1.编写一个程序,调用一次printf()函数,把你的名和姓打印在一行。再调用一次printf()函数,把你的名和姓分别打印在两行。然后,再调用两次printf()函数,把你的名和姓打印在一行。输出应如下所示(当然要把示例的内容换成你的名字):

Gustav Mahler      ←第1次打印的内容
Gustav             ←第2次打印的内容
Mahler             ←仍是第2次打印的内容
Gustav Mahler      ←第3次和第4次打印的内容

2.编写一个程序,打印你的姓名和地址。

3.编写一个程序把你的年龄转换成天数,并显示这两个值。这里不用考虑闰年的问题。

4.编写一个程序,生成以下输出:

For he's a jolly good fellow!
For he's a jolly good fellow!
For he's a jolly good fellow!
Which nobody can deny!

除了main()函数以外,该程序还要调用两个自定义函数:一个名为jolly(),用于打印前3条消息,调用一次打印一条;另一个函数名为deny(),打印最后一条消息。

5.编写一个程序,生成以下输出:

Brazil, Russia, India, China
India, China,
Brazil, Russia

除了main()以外,该程序还要调用两个自定义函数:一个名为br(),调用一次打印一次“Brazil, Russia”;另一个名为ic(),调用一次打印一次“India, China”。其他内容在main()函数中完成。

6.编写一个程序,创建一个整型变量toes,并将toes设置为10。程序中还要计算toes的两倍和toes的平方。该程序应打印3个值,并分别描述以示区分。

7.许多研究表明,微笑益处多多。编写一个程序,生成以下格式的输出:

Smile!Smile!Smile!
Smile!Smile!
Smile!

该程序要定义一个函数,该函数被调用一次打印一次“Smile!”,根据程序的需要使用该函数。

8.在C语言中,函数可以调用另一个函数。编写一个程序,调用一个名为one_three()的函数。该函数在一行打印单词“one”,再调用第2个函数two(),然后在另一行打印单词“three”。two()函数在一行显示单词“two”。main()函数在调用one_three()函数前要打印短语“starting now:”,并在调用完毕后显示短语“done!”。因此,该程序的输出应如下所示:

starting now:
one
two
three
done!

[1] 原书图中叙述有误。根据C11标准,C语言有6种语句,已在图中更正。——译者注

[2] C语言是通过赋值运算符而不是赋值语句完成赋值操作。根据C标准,C语言并没有所谓的“赋值语句”,本书及一些其他书籍中提到的“赋值语句”实际上是表达式语句(C语言的6种基本语句之一)。本书把“赋值语句”均译为“赋值表达式语句”,以提醒初学者注意。——译者注

[3] 在C语言中,return语句是一种跳转语句。——译者注

[4] 市面上许多书籍(包括本书)都把这种语句叫作“函数调用语句”,但是历年的C标准中从来没有函数调用语句!值得一提的是,函数调用本身是一个表达式,圆括号是运算符,圆括号左边的函数名是运算对象。在C11标准中,这样的表达式是一种后缀表达式。在表达式末尾加上分号,就成了表达式语句。请初学者注意,这样的“函数调用语句”实质是表达式语句。本书的错误之处已在翻译过程中更正。——译者注

[5] 英寻,也称为㖊。航海用的深度单位,1英寻=6英尺=1.8米,通常用在海图上测量水深。——译者注

[6] 要理解该句子存在语法错误,需要具备基本的英文语法知识。——译者注


现在,相信读者已经熟悉了带参数的函数。要掌握函数,还要学习如何编写自己的函数(在此之前,读者可能要复习一下程序清单2.3中的butler()函数,该函数不带任何参数)。程序清单5.15中有一个pound()函数,打印指定数量的#号(该符号也叫作编号符号或井号)。该程序还演示了类型转换的应用。

程序清单5.15 pound.c程序

/* pound.c -- 定义一个带一个参数的函数   */
#include <stdio.h>
void pound(int n);    // ANSI函数原型声明
int main(void)
{
    int times = 5;
    char ch = '!';    // ASCII码是33
    float f = 6.0f;

    pound(times);     // int类型的参数
    pound(ch);        // 和pound((int)ch);相同
    pound(f);         // 和pound((int)f);相同

    return 0;
}

void pound(int n)     // ANSI风格函数头
{                     // 表明该函数接受一个int类型的参数
    while (n-- > 0)
        printf("#");
    printf("\n");
}

运行该程序后,输出如下:

#####
#################################
######

首先,看程序的函数头:

void pound(int n)

如果函数不接受任何参数,函数头的圆括号中应该写上关键字void。由于该函数接受一个int类型的参数,所以圆括号中包含一个int类型变量n的声明。参数名应遵循C语言的命名规则。

声明参数就创建了被称为形式参数formal argumentformal parameter,简称形参)的变量。该例中,形式参数是int类型的变量n。像pound(10)这样的函数调用会把10赋给n。在该程序中,调用pound(times)就是把times的值(5)赋给n。我们称函数调用传递的值为实际参数actual argumentactual parameter),简称实参。所以,函数调用pound(10)把实际参数10传递给函数,然后该函数把10赋给形式参数(变量n)。也就是说,main()中的变量times的值被拷贝给pound()中的新变量n

注意

实参和形参

在英文中,argument和parameter经常可以互换使用,但是C99标准规定了:对于actual argument或actual parameter使用术语argument(译为实参);对于formal argument或formal parameter使用术语parameter(译为形参)。为遵循这一规定,我们可以说形参是变量,实参是函数调用提供的值,实参被赋给相应的形参。因此,在程序清单5.15中,timespound()的实参,npound()的形参。类似地,在函数调用pound(times + 4)中,表达式times + 4的值是该函数的实参。

变量名是函数私有的,即在函数中定义的函数名不会和别处的相同名称发生冲突。如果在pound()中用times代替n,那么这个timesmain()中的times不同。也就是说,程序中出现了两个同名的变量,但是程序可以区分它们。

现在,我们来学习函数调用。第1个函数调用是pound(times)times的值5被赋给n。因此,printf()函数打印了5个井号和1个换行符。第2个函数调用是pound(ch)。这里,chchar类型,被初始化为!字符,在ASCII中ch的数值是33。但是pound()函数的参数类型是int,与char不匹配。程序开头的函数原型在这里发挥了作用。原型prototype)即是函数的声明,描述了函数的返回值和参数。pound()函数的原型说明了两点:

该例中,函数原型告诉编译器pound()需要一个int类型的参数。相应地,当编译器执行到pound(ch)表达式时,会把参数ch自动转换成int类型。在我们的系统中,该参数从1字节的33变成4字节的33,所以现在33的类型满足函数的要求。与此类似,最后一次调用是pound(f),使得float类型的变量被转换成合适的类型。

在ANSI C之前,C使用的是函数声明,而不是函数原型。函数声明只指明了函数名和返回类型,没有指明参数类型。为了向下兼容,C现在仍然允许这样的形式:

void pound(); /* ANSI C之前的函数声明 */

如果用这条函数声明代替pound.c程序中的函数原型会怎样?第1次函数调用,pound(times)没问题,因为timesint类型。第2次函数调用,pound(ch)也没问题,因为即使缺少函数原型,C也会把charshort类型自动升级为int类型。第3次函数调用,pound(f)会失败,因为缺少函数原型,float会被自动升级为double,这没什么用。虽然程序仍然能运行,但是输出的内容不正确。在函数调用中显式使用强制类型转换,可以修复这个问题:

pound ((int)f); // 把f强制类型转换为正确的类型

注意,如果f的值太大,超过了int类型表示的范围,这样做也不行。

本章介绍以下内容:

  • 关键字:static
  • 运算符:&、*(一元)
  • 如何创建并初始化数组
  • 指针(在已学过的基础上)、指针和数组的关系
  • 编写处理数组的函数
  • 二维数组

人们通常借助计算机完成统计每月的支出、日降雨量、季度销售额等任务。企业借助计算机管理薪资、库存和客户交易记录等。作为程序员,不可避免地要处理大量相关数据。通常,数组能高效便捷地处理这种数据。第 6 章简单地介绍了数组,本章将进一步地学习如何使用数组,着重分析如何编写处理数组的函数。这种函数把模块化编程的优势应用到数组。通过本章的学习,你将明白数组和指针关系密切。

前面介绍过,数组由数据类型相同的一系列元素组成。需要使用数组时,通过声明数组告诉编译器数组中内含多少元素和这些元素的类型。编译器根据这些信息正确地创建数组。普通变量可以使用的类型,数组元素都可以用。考虑下面的数组声明:

/* 一些数组声明*/
int main(void)
{
    float candy[365];     /* 内含365个float类型元素的数组 */
    char code[12];        /*内含12个char类型元素的数组*/
    int states[50];       /*内含50个int类型元素的数组 */
    ...
}

方括号([])表明candycodestates都是数组,方括号中的数字表明数组中的元素个数。

要访问数组中的元素,通过使用数组下标数(也称为索引)表示数组中的各元素。数组元素的编号从0开始,所以candy[0]表示candy数组的第1个元素,candy[364]表示第365个元素,也就是最后一个元素。读者对这些内容应该比较熟悉,下面我们介绍一些新内容。

使用const声明数组

有时需要把数组设置为只读。这样,程序只能从数组中检索值,不能把新值写入数组。要创建只读数组,应该用const声明和初始化数组。因此,程序清单10.1中初始化数组应改成:

这样修改后,程序在运行过程中就不能修改该数组中的内容。和普通变量一样,应该使用声明来初始化const数据,因为一旦声明为const,便不能再给它赋值。明确了这一点,就可以在后面的例子中使用const了。

数组通常被用来储存程序需要的数据。例如,一个内含12个整数元素的数组可以储存12个月的天数。在这种情况下,在程序一开始就初始化数组比较好。下面介绍初始化数组的方法。

只储存单个值的变量有时也称为标量变量scalar variable),我们已经很熟悉如何初始化这种变量:

const int days[MONTHS] = {31,28,31,30,31,30,31,31,30,31,30,31};
int fix = 1;
float flax = PI * 2;

代码中的PI已定义为宏。C使用新的语法来初始化数组,如下所示:

int main(void)
{
    int powers[8] = {1,2,4,6,8,16,32,64}; /* 从ANSI C开始支持这种初始化 */
    ...
}

如上所示,用以逗号分隔的值列表(用花括号括起来)来初始化数组,各值之间用逗号分隔。在逗号和值之间可以使用空格。根据上面的初始化,把1赋给数组的首元素(powers[0]),以此类推(不支持ANSI的编译器会把这种形式的初始化识别为语法错误,在数组声明前加上关键字static可解决此问题。第12章将详细讨论这个关键字)。

程序清单10.1演示了一个小程序,打印每个月的天数。

程序清单10.1 day_mon1.c程序

/* day_mon1.c -- 打印每个月的天数 */
#include <stdio.h>
#define MONTHS 12

int main(void)
{
    int days[MONTHS] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
    int index;

    for (index = 0; index < MONTHS; index++)
        printf("Month %2d has %2d days.\n", index + 1, days[index]);

    return 0;
}

该程序的输出如下:

Month 1 has 31 days.
Month 2 has 28 days.
Month 3 has 31 days.
Month 4 has 30 days.
Month 5 has 31 days.
Month 6 has 30 days.
Month 7 has 31 days.
Month 8 has 31 days.
Month 9 has 30 days.
Month 10 has 31 days.
Month 11 has 30 days.
Month 12 has 31 days.

这个程序还不够完善,每4年打错一个月份的天数(即,2月份的天数)。该程序用初始化列表初始化days[],列表(用花括号括起来)中用逗号分隔各值。

注意该例使用了符号常量MONTHS表示数组大小,这是我们推荐且常用的做法。例如,如果要采用一年13个月的记法,只需修改#define这行代码即可,不用在程序中查找所有使用过数组大小的地方。

注意

如果初始化数组失败怎么办?程序清单10.2演示了这种情况。

程序清单10.2 no_data.c程序

/* no_data.c -- 为初始化数组 */
#include <stdio.h>
#define SIZE 4
int main(void)
{
    int no_data[SIZE];  /* 未初始化数组 */
    int i;

    printf("%2s%14s\n",    "i", "no_data[i]");
    for (i = 0; i < SIZE; i++)
        printf("%2d%14d\n", i, no_data[i]);

    return 0;
}

该程序的输出如下(系统不同,输出的结果可能不同):

i     no_data[i]
0              0
1        4204937
2        4219854
3     2147348480

使用数组前必须先初始化它。与普通变量类似,在使用数组元素之前,必须先给它们赋初值。编译器使用的值是内存相应位置上的现有值,因此,读者运行该程序后的输出会与该示例不同。

注意

存储类别警告

数组和其他变量类似,可以把数组创建成不同的存储类别(storage class)。第12章将介绍存储类别的相关内容,现在只需记住:本章描述的数组属于自动存储类别,意思是这些数组在函数内部声明,且声明时未使用关键字static。到目前为止,本书所用的变量和数组都是自动存储类别。

在这里提到存储类别的原因是,不同的存储类别有不同的属性,所以不能把本章的内容推广到其他存储类别。对于一些其他存储类别的变量和数组,如果在声明时未初始化,编译器会自动把它们的值设置为0。

初始化列表中的项数应与数组的大小一致。如果不一致会怎样?我们还是以上一个程序为例,但初始化列表中缺少两个元素,如程序清单10.3所示:

程序清单10.3 somedata.c程序

/* some_data.c -- 部分初始化数组 */
#include <stdio.h>
#define SIZE 4
int main(void)
{
    int some_data[SIZE] = { 1492, 1066 };
    int i;

    printf("%2s%14s\n",    "i", "some_data[i]");
    for (i = 0; i < SIZE; i++)
        printf("%2d%14d\n", i, some_data[i]);

    return 0;
}

下面是该程序的输出:

i some_data[i]
0         1492
1         1066
2            0
3            0

如上所示,编译器做得很好。当初始化列表中的值少于数组元素个数时,编译器会把剩余的元素都初始化为0。也就是说,如果不初始化数组,数组元素和未初始化的普通变量一样,其中储存的都是垃圾值;但是,如果部分初始化数组,剩余的元素就会被初始化为0

如果初始化列表的项数多于数组元素个数,编译器可没那么仁慈,它会毫不留情地将其视为错误。但是,没必要因此嘲笑编译器。其实,可以省略方括号中的数字,让编译器自动匹配数组大小和初始化列表中的项数(见程序清单10.4)

程序清单10.4 day_mon2.c程序

/* day_mon2.c -- 让编译器计算元素个数 */
#include <stdio.h>
int main(void)
{
    const int days[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31 };
    int index;

    for (index = 0; index < sizeof days / sizeof days[0]; index++)
        printf("Month %2d has %d days.\n", index + 1, days[index]);

    return 0;
}

在程序清单10.4中,要注意以下两点。

下面是该程序的输出:

Month 1 has 31 days.
Month 2 has 28 days.
Month 3 has 31 days.
Month 4 has 30 days.
Month 5 has 31 days.
Month 6 has 30 days.
Month 7 has 31 days.
Month 8 has 31 days.
Month 9 has 30 days.
Month 10 has 31 days.

我们的本意是防止初始化值的个数超过数组的大小,让程序找出数组大小。我们初始化时用了10个值,结果就只打印了10个值!这就是自动计数的弊端:无法察觉初始化列表中的项数有误。

还有一种初始化数组的方法,但这种方法仅限于初始化字符数组。我们在下一章中介绍。

C99增加了一个新特性:指定初始化器designated initializer)。利用该特性可以初始化指定的数组元素。例如,只初始化数组中的最后一个元素。对于传统的C初始化语法,必须初始化最后一个元素之前的所有元素,才能初始化它:

int arr[6] = {0,0,0,0,0,212}; // 传统的语法

而C99规定,可以在初始化列表中使用带方括号的下标指明待初始化的元素:

int arr[6] = {[5] = 212}; // 把arr[5]初始化为212

对于一般的初始化,在初始化一个元素后,未初始化的元素都会被设置为0。程序清单10.5中的初始化比较复杂。

程序清单10.5 designate.c程序

// designate.c -- 使用指定初始化器
#include <stdio.h>
#define MONTHS 12
int main(void)
{
    int days[MONTHS] = { 31, 28, [4] = 31, 30, 31, [1] = 29 };
    int i;

    for (i = 0; i < MONTHS; i++)
        printf("%2d  %d\n", i + 1, days[i]);

    return 0;
}

该程序在支持C99的编译器中输出如下:

1     31
2     29
3     0
4     0
5     31
6     30
7     31
8     0
9     0
10    0
11    0
12    0

以上输出揭示了指定初始化器的两个重要特性。第一,如果指定初始化器后面有更多的值,如该例中的初始化列表中的片段:[4] = 31,30,31,那么后面这些值将被用于初始化指定元素后面的元素。也就是说,在days[4]被初始化为31后,days[5]days[6]将分别被初始化为3031。第二,如果再次初始化指定的元素,那么最后的初始化将会取代之前的初始化。例如,程序清单10.5中,初始化列表开始时把days[1]初始化为28,但是days[1]又被后面的指定初始化[1] = 29初始化为29

如果未指定元素大小会怎样?

int stuff[] = {1, [6] = 23};        //会发生什么?
int staff[] = {1, [6] = 4, 9, 10};  //会发生什么?

编译器会把数组的大小设置为足够装得下初始化的值。所以,stuff数组有7个元素,编号为0~6;而staff数组的元素比stuff数组多两个(即有9个元素)。

声明数组后,可以借助数组下标(或索引)给数组元素赋值。例如,下面的程序段给数组的所有元素赋值:

/* 给数组的元素赋值 */
#include <stdio.h>
#define SIZE 50
int main(void)
{
    int counter, evens[SIZE];

    for (counter = 0; counter < SIZE; counter++)
        evens[counter] = 2 * counter;
    ...
}

注意这段代码中使用循环给数组的元素依次赋值。C不允许把数组作为一个单元赋给另一个数组,除初始化以外也不允许使用花括号列表的形式赋值。下面的代码段演示了一些错误的赋值形式:

/* 一些无效的数组赋值 */
#define SIZE 5
int main(void)
{
    int oxen[SIZE] = {5,3,2,8};        /* 初始化没问题 */
    int yaks[SIZE];

    yaks = oxen;                   /* 不允许 */
    yaks[SIZE] = oxen[SIZE];       /* 数组下标越界 */
    yaks[SIZE] = {5,3,2,8};        /* 不起作用 */

oxen数组的最后一个元素是oxen[SIZE-1],所以oxen[SIZE]yaks[SIZE]都超出了两个数组的末尾。

在使用数组时,要防止数组下标超出边界。也就是说,必须确保下标是有效的值。例如,假设有下面的声明:

int doofi[20];

那么在使用该数组时,要确保程序中使用的数组下标在019的范围内,因为编译器不会检查出这种错误(但是,一些编译器发出警告,然后继续编译程序)。

考虑程序清单10.6的问题。该程序创建了一个内含4个元素的数组,然后错误地使用了-16的下标。

程序清单10.6 bounds.c程序

// bounds.c -- 数组下标越界
#include <stdio.h>
#define SIZE 4
int main(void)
{
    int value1 = 44;
    int arr[SIZE];
    int value2 = 88;
    int i;

    printf("value1 = %d, value2 = %d\n", value1, value2);
    for (i = -1; i <= SIZE; i++)
        arr[i] = 2 * i + 1;

    for (i = -1; i < 7; i++)
        printf("%2d %d\n", i, arr[i]);
    printf("value1 = %d, value2 = %d\n", value1, value2);
    printf("address of arr[-1]: %p\n", &arr[-1]);
    printf("address of arr[4]: %p\n", &arr[4]);
    printf("address of value1: %p\n", &value1);
    printf("address of value2: %p\n", &value2);

    return 0;
}

编译器不会检查数组下标是否使用得当。在C标准中,使用越界下标的结果是未定义的。这意味着程序看上去可以运行,但是运行结果很奇怪,或异常中止。下面是使用GCC的输出示例:

value1 = 44, value2 = 88
-1 -1
 0 1
 1 3
 2 5
 3 7
 4 9
 5 1624678494
 6 32767
value1 = 9, value2 = -1
address of arr[-1]:    0x7fff5fbff8cc
address of arr[4]:     0x7fff5fbff8e0
address of value1:     0x7fff5fbff8e0
address of value2:     0x7fff5fbff8cc

注意,该编译器似乎把value2储存在数组的前一个位置,把value1储存在数组的后一个位置(其他编译器在内存中储存数据的顺序可能不同)。在上面的输出中,arr[-1]value2对应的内存地址相同,arr[4]value1对应的内存地址相同。因此,使用越界的数组下标会导致程序改变其他变量的值。不同的编译器运行该程序的结果可能不同,有些会导致程序异常中止。

C语言为何会允许这种麻烦事发生?这要归功于C信任程序员的原则。不检查边界,C程序可以运行更快。编译器没必要捕获所有的下标错误,因为在程序运行之前,数组的下标值可能尚未确定。因此,为安全起见,编译器必须在运行时添加额外代码检查数组的每个下标值,这会降低程序的运行速度。C相信程序员能编写正确的代码,这样的程序运行速度更快。但并不是所有的程序员都能做到这一点,所以就出现了下标越界的问题。

还要记住一点:数组元素的编号从0开始。最好是在声明数组时使用符号常量来表示数组的大小:

#define SIZE 4
int main(void)
{
    int arr[SIZE];
    for (i = 0; i < SIZE; i++)
    ....

这样做能确保整个程序中的数组大小始终一致。

本章前面的程序示例都使用整型常量来声明数组:

#define SIZE 4
int main(void)
{
    int arr[SIZE];          // 整数符号常量
    double lots[144];       // 整数字面常量
    ...

在C99标准之前,声明数组时只能在方括号中使用整型常量表达式。所谓整型常量表达式,是由整型常量构成的表达式。sizeof表达式被视为整型常量,但是(与C++不同)const值不是。另外,表达式的值必须大于0

int n = 5;
int m = 8;
float a1[5];                 // 可以
float a2[5*2 + 1];           //可以
float a3[sizeof(int) + 1];   //可以
float a4[-4];                // 不可以,数组大小必须大于0
float a5[0];                 // 不可以,数组大小必须大于0
float a6[2.5];               // 不可以,数组大小必须是整数
float a7[(int)2.5];          // 可以,已被强制转换为整型常量
float a8[n];                 // C99之前不允许
float a9[m];                 // C99之前不允许

上面的注释表明,以前支持C90标准的编译器不允许后两种声明方式。而C99标准允许这样声明,这创建了一种新型数组,称为变长数组variable-length array)或简称VLA(C11放弃了这一创新的举措,把VLA设定为可选,而不是语言必备的特性)。

C99引入变长数组主要是为了让C成为更好的数值计算语言。例如,VLA简化了把FORTRAN现有的数值计算例程库转换为C代码的过程。VLA有一些限制,例如,声明VLA时不能进行初始化。在充分了解经典的C数组后,我们再详细介绍VLA。

气象研究员Tempest Cloud为完成她的研究项目要分析5年内每个月的降水量数据,她首先要解决的问题是如何表示数据。一个方案是创建60个变量,每个变量储存一个数据项(我们曾经提到过这一笨拙的方案,和以前一样,这个方案并不合适)。使用一个内含60个元素的数组比将建60个变量好,但是如果能把各年的数据分开储存会更好,即创建5个数组,每个数组12个元素。然而,这样做也很麻烦,如果Tempest决定研究50年的降水量,岂不是要创建50个数组。是否能有更好的方案?

处理这种情况应该使用数组的数组。主数组master array)有5个元素(每个元素表示一年),每个元素是内含12个元素的数组(每个元素表示一个月)。下面是该数组的声明:

float rain[5][12]; // 内含5个数组元素的数组,每个数组元素内含12个float类型的元素

理解该声明的一种方法是,先查看中间部分(粗体部分):

float rain[5][12]; // rain是一个内含5个元素的数组

这说明数组rain5个元素,至于每个元素的情况,要查看声明的其余部分(粗体部分):

float rain[5][12] ; // 一个内含12个float类型元素的数组

这说明每个元素的类型是float[12],也就是说,rain的每个元素本身都是一个内含12float类型值的数组。

根据以上分析可知,rain的首元素rain[0]是一个内含12float类型值的数组。所以,rain[1]rain[2]等也是如此。如果rain[0]是一个数组,那么它的首元素就是rain[0][0],第2个元素是rain[0][1],以此类推。简而言之,数组rain5个元素,每个元素都是内含12float类型元素的数组,rain[0]是内含12float值的数组,rain[0][0]是一个float类型的值。假设要访问位于23列的值,则使用rain[1][2]

图10.1 二维数组

该二维视图有助于帮助读者理解二维数组的两个下标。在计算机内部,这样的数组是按顺序储存的,从第1个内含12个元素的数组开始,然后是第2个内含12个元素的数组,以此类推。

我们要在气象分析程序中用到这个二维数组。该程序的目标是,计算每年的总降水量、年平均降水量和月平均降水量。要计算年总降水量,必须对一行数据求和;要计算某月份的平均降水量,必须对一列数据求和。二维数组很直观,实现这些操作也很容易。程序清单10.7演示了这个程序。

程序清单10.7 rain.c程序

/* rain.c  -- 计算每年的总降水量、年平均降水量和5年中每月的平均降水量 */
#include <stdio.h>
#define MONTHS 12        // 一年的月份数
#define YEARS   5        // 年数
int main(void)
{
    // 用2010~2014年的降水量数据初始化数组
    const float rain[YEARS][MONTHS] =
    {
        { 4.3, 4.3, 4.3, 3.0, 2.0, 1.2, 0.2, 0.2, 0.4, 2.4, 3.5, 6.6 },
        { 8.5, 8.2, 1.2, 1.6, 2.4, 0.0, 5.2, 0.9, 0.3, 0.9, 1.4, 7.3 },
        { 9.1, 8.5, 6.7, 4.3, 2.1, 0.8, 0.2, 0.2, 1.1, 2.3, 6.1, 8.4 },
        { 7.2, 9.9, 8.4, 3.3, 1.2, 0.8, 0.4, 0.0, 0.6, 1.7, 4.3, 6.2 },
        { 7.6, 5.6, 3.8, 2.8, 3.8, 0.2, 0.0, 0.0, 0.0, 1.3, 2.6, 5.2 }
    };
    int year, month;
    float subtot, total;

    printf(" YEAR    RAINFALL  (inches)\n");
    for (year = 0, total = 0; year < YEARS; year++)
    {                     // 每一年,各月的降水量总和
        for (month = 0, subtot = 0; month < MONTHS; month++)
             subtot += rain[year][month];
        printf("%5d %15.1f\n", 2010 + year, subtot);
        total += subtot;  // 5年的总降水量
    }
    printf("\nThe yearly average is %.1f inches.\n\n", total / YEARS);
    printf("MONTHLY AVERAGES:\n\n");
    printf(" Jan  Feb  Mar  Apr  May  Jun  Jul  Aug  Sep  Oct ");
    printf(" Nov  Dec\n");

    for (month = 0; month < MONTHS; month++)
    {                    // 每个月,5年的总降水量
        for (year = 0, subtot = 0; year < YEARS; year++)
             subtot += rain[year][month];
        printf("%4.1f ", subtot / YEARS);
    }
    printf("\n");

    return 0;
}

下面是该程序的输出:

YEAR     RAINFALL  (inches)
2010             32.4
2011             37.9
2012             49.8
2013             44.0
2014             32.9

The yearly average is 39.4 inches.

MONTHLY AVERAGES:

Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
7.3 7.3 4.9 3.0 2.3 0.6 1.2 0.3 0.5 1.7 3.6 6.7

学习该程序的重点是数组初始化和计算方案。初始化二维数组比较复杂,我们先来看较为简单的计算部分。

程序使用了两个嵌套for循环。第1个嵌套for循环的内层循环,在year不变的情况下,遍历month计算某年的总降水量;而外层循环,改变year的值,重复遍历month,计算5年的总降水量。这种嵌套循环结构常用于处理二维数组,一个循环处理数组的第1个下标,另一个循环处理数组的第2个下标:

for (year = 0, total = 0; year < YEARS; year++)
{ // 处理每一年的数据
    for (month = 0, subtot = 0; month < MONTHS; month++)
    ... // 处理每月的数据
    ... //处理每一年的数据
}

第2个嵌套for循环和第1个的结构相同,但是内层循环遍历year,外层循环遍历month。记住,每执行一次外层循环,就完整遍历一次内层循环。因此,在改变月份之前,先遍历完年,得到某月5年间的平均降水量,以此类推:

for (month = 0; month < MONTHS; month++)
{ // 处理每月的数据
    for (year = 0, subtot =0; year < YEARS; year++)
    ... // 处理每年的数据
    ... // 处理每月的数据
}

初始化二维数组是建立在初始化一维数组的基础上。首先,初始化一维数组如下:

sometype ar1[5] = {val1, val2, val3, val4, val5};

这里,val1val2等表示sometype类型的值。例如,如果sometypeint,那么val1可能是7;如果sometypedouble,那么val1可能是11.34,诸如此类。但是rain是一个内含5个元素的数组,每个元素又是内含12float类型元素的数组。所以,对rain而言,val1应该包含12个值,用于初始化内含12float类型元素的一维数组,如下所示:

{4.3,4.3,4.3,3.0,2.0,1.2,0.2,0.2,0.4,2.4,3.5,6.6}

也就是说,如果sometype是一个内含12double类型元素的数组,那么val1就是一个由12个double类型值构成的数值列表。因此,为了初始化二维数组rain,要用逗号分隔5个这样的数值列表:

const float rain[YEARS][MONTHS] =
{
    {4.3,4.3,4.3,3.0,2.0,1.2,0.2,0.2,0.4,2.4,3.5,6.6},
    {8.5,8.2,1.2,1.6,2.4,0.0,5.2,0.9,0.3,0.9,1.4,7.3},
    {9.1,8.5,6.7,4.3,2.1,0.8,0.2,0.2,1.1,2.3,6.1,8.4},
    {7.2,9.9,8.4,3.3,1.2,0.8,0.4,0.0,0.6,1.7,4.3,6.2},
    {7.6,5.6,3.8,2.8,3.8,0.2,0.0,0.0,0.0,1.3,2.6,5.2}
};

这个初始化使用了5个数值列表,每个数值列表都用花括号括起来。第1个列表的数据用于初始化数组的第1行,第2个列表的数据用于初始化数组的第2行,以此类推。前面讨论的数据个数和数组大小不匹配的问题同样适用于这里的每一行。也就是说,如果第1个列表中只有10个数,则只会初始化数组第1行的前10个元素,而最后两个元素将被默认初始化为0。如果某列表中的数值个数超出了数组每行的元素个数,则会出错,但是这并不会影响其他行的初始化。

初始化时也可省略内部的花括号,只保留最外面的一对花括号。只要保证初始化的数值个数正确,初始化的效果与上面相同。但是如果初始化的数值不够,则按照先后顺序逐行初始化,直到用完所有的值。后面没有值初始化的元素被统一初始化为0。图10.2演示了这种初始化数组的方法。

图10.2 初始化二维数组的两种方法

因为储存在数组rain中的数据不能修改,所以程序使用了const关键字声明该数组。

前面讨论的二维数组的相关内容都适用于三维数组或更多维的数组。可以这样声明一个三维数组:

int box[10][20][30];

可以把一维数组想象成一行数据,把二维数组想象成数据表,把三维数组想象成一叠数据表。例如,把上面声明的三维数组box想象成由10个二维数组(每个二维数组都是20行30列)堆叠起来。

还有一种理解box的方法是,把box看作数组的数组。也就是说,box内含10个元素,每个元素是内含20个元素的数组,这20个数组元素中的每个元素是内含30个元素的数组。或者,可以简单地根据所需的下标值去理解数组。

通常,处理三维数组要使用3重嵌套循环,处理四维数组要使用4重嵌套循环。对于其他多维数组,以此类推。在后面的程序示例中,我们只使用二维数组。

第9章介绍过指针,指针提供一种以符号形式使用地址的方法。因为计算机的硬件指令非常依赖地址,指针在某种程度上把程序员想要传达的指令以更接近机器的方式表达。因此,使用指针的程序更有效率。尤其是,指针能有效地处理数组。我们很快就会学到,数组表示法其实是在变相地使用指针。

我们举一个变相使用指针的例子:数组名是数组首元素的地址。也就是说,如果flizny是一个数组,下面的语句成立:

flizny == &flizny[0]; // 数组名是该数组首元素的地址

flizny&flizny[0]都表示数组首元素的内存地址(&是地址运算符)。两者都是常量,在程序的运行过程中,不会改变。但是,可以把它们赋值给指针变量,然后可以修改指针变量的值,如程序清单10.8所示。注意指针加上一个数时,它的值发生了什么变化(转换说明%p通常以十六进制显示指针的值)。

程序清单10.8 pnt_add.c程序

// pnt_add.c -- 指针地址
#include <stdio.h>
#define SIZE 4
int main(void)
{
    short dates[SIZE];
    short * pti;
    short index;
    double bills[SIZE];
    double * ptf;
    pti = dates;    // 把数组地址赋给指针
    ptf = bills;
    printf("%23s %15s\n", "short", "double");
    for (index = 0; index < SIZE; index++)
        printf("pointers + %d: %10p %10p\n", index, pti + index, ptf + index);
    return 0;
}

下面是该例的输出示例:

                   short        double
pointers + 0: 0x7fff5fbff8dc 0x7fff5fbff8a0
pointers + 1: 0x7fff5fbff8de 0x7fff5fbff8a8
pointers + 2: 0x7fff5fbff8e0 0x7fff5fbff8b0
pointers + 3: 0x7fff5fbff8e2 0x7fff5fbff8b8

第2行打印的是两个数组开始的地址,下一行打印的是指针加1后的地址,以此类推。注意,地址是十六进制的,因此dddc1a1a01。但是,显示的地址是怎么回事?

0x7fff5fbff8dc + 1是否是0x7fff5fbff8de?
0x7fff5fbff8a0 + 1是否是0x7fff5fbff8a8?

我们的系统中,地址按字节编址,short类型占用2字节,double类型占用8字节。在C中,指针加1指的是增加一个存储单元。对数组而言,这意味着加1后的地址是下一个元素的地址,而不是下一个字节的地址(见图10.3)。这是为什么必须声明指针所指向对象类型的原因之一。只知道地址不够,因为计算机要知道储存对象需要多少字节(即使指针指向的是标量变量,也要知道变量的类型,否则*pt就无法正确地取回地址上的值)。

图10.3 数组和指针加法

现在可以更清楚地定义指向int的指针、指向float的指针,以及指向其他数据对象的指针。

下面的等式体现了C语言的灵活性:

dates + 2 == &dates[2]       // 相同的地址
*(dates + 2) == dates[2]    // 相同的值

以上关系表明了数组和指针的关系十分密切,可以使用指针标识数组的元素和获得元素的值。从本质上看,同一个对象有两种表示法。实际上,C语言标准在描述数组表示法时确实借助了指针。也就是说,定义ar[n]的意思是*(ar + n)。可以认为*(ar + n)的意思是“到内存的ar位置,然后移动n个单元,检索储存在那里的值”。

顺带一提,不要混淆*(dates+2)*dates+2。间接运算符(*)的优先级高于+,所以*dates+2相当于(*dates)+2

*(dates + 2)  // dates第3个元素的值
*dates + 2    // dates第1个元素的值加2

明白了数组和指针的关系,便可在编写程序时适时使用数组表示法或指针表示法。运行程序清单10.9后输出的结果和程序清单10.1输出的结果相同。

程序清单10.9 day_mon3.c程序

/* day_mon3.c -- uses pointer notation */
#include <stdio.h>
#define MONTHS 12

int main(void)
{
    int days[MONTHS] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
    int index;

    for (index = 0; index < MONTHS; index++)
        printf("Month %2d has %d days.\n", index + 1, 
                        *(days + index));   //与 days[index]相同

    return 0;
}

这里,days是数组首元素的地址,days + index是元素days[index]的地址,而*(days + index)则是该元素的值,相当于days[index]for循环依次引用数组中的每个元素,并打印各元素的内容。

这样编写程序是否有优势?不一定。编译器编译这两种写法生成的代码相同。程序清单10.9要注意的是,指针表示法和数组表示法是两种等效的方法。该例演示了可以用指针表示数组,反过来,也可以用数组表示指针。在使用以数组为参数的函数时要注意这点。

声明数组形参

因为数组名是该数组首元素的地址,作为实际参数的数组名要求形式参数是一个与之匹配的指针。只有在这种情况下,C才会把int ar[]int * ar解释成一样。也就是说,ar是指向int的指针。由于函数原型可以省略参数名,所以下面4种原型都是等价的:

但是,在函数定义中不能省略参数名。下面两种形式的函数定义等价:

可以使用以上提到的任意一种函数原型和函数定义。

假设要编写一个处理数组的函数,该函数返回数组中所有元素之和,待处理的是名为marblesint类型数组。应该如何调用该函数?也许是下面这样:

total = sum(marbles); // 可能的函数调用
int sum(int *ar, int n);
int sum(int *, int);
int sum(int ar[], int n);
int sum(int [], int);

那么,该函数的原型是什么?记住,数组名是该数组首元素的地址,所以实际参数marbles是一个储存int类型值的地址,应把它赋给一个指针形式参数,即该形参是一个指向int的指针:

int sum(int * ar);    // 对应的函数原型
int sum(int *ar, int n)
{
    // 其他代码已省略
}

int sum(int ar[], int n);
{
    //其他代码已省略
}

sum()从该参数获得了什么信息?它获得了该数组首元素的地址,知道要在该位置上找出一个整数。注意,该参数并未包含数组元素个数的信息。我们有两种方法让函数获得这一信息。第一种方法是,在函数代码中写上固定的数组大小:

int sum(int * ar)    // 相应的函数定义
{
    int i;
    int total = 0;

    for (i = 0; i < 10; i++)   // 假设数组有10个元素
        total += ar[i];        // ar[i] 与 *(ar + i) 相同
    return total;
}

既然能使用指针表示数组名,也可以用数组名表示指针。另外,回忆一下,+=运算符把右侧运算对象加到左侧运算对象上。因此,total是当前数组元素之和。

该函数定义有限制,只能计算10个int类型的元素。另一个比较灵活的方法是把数组大小作为第2个参数:

int sum(int * ar, int n)       // 更通用的方法
{
    int i;
    int total = 0;

    for (i = 0; i < n; i++)    // 使用 n 个元素
        total += ar[i];        // ar[i] 和 *(ar + i) 相同
    return total;
}

这里,第1个形参告诉函数该数组的地址和数据类型,第2个形参告诉函数该数组中元素的个数。

关于函数的形参,还有一点要注意。只有在函数原型或函数定义头中,才可以用int ar[]代替int * ar

int sum (int ar[], int n);

int *ar形式和int ar[]形式都表示ar是一个指向int的指针。但是,int ar[]只能用于声明形式参数。第2种形式(int ar[])提醒读者指针ar指向的不仅仅是一个int类型值,还是一个int类型数组的元素。

注意

程序清单10.10演示了一个程序,使用sum()函数。该程序打印原始数组的大小和表示该数组的函数形参的大小(如果你的编译器不支持用转换说明%zd打印sizeof返回值,可以用%u%lu来代替)。

程序清单10.10 sum_arr1.c程序

// sum_arr1.c -- 数组元素之和
// 如果编译器不支持 %zd,用 %u 或 %lu 替换它
#include <stdio.h>
#define SIZE 10
int sum(int ar[], int n);
int main(void)
{
    int marbles[SIZE] = { 20, 10, 5, 39, 4, 16, 19, 26, 31, 20 };
    long answer;

    answer = sum(marbles, SIZE);
    printf("The total number of marbles is %ld.\n", answer);
    printf("The size of marbles is %zd bytes.\n",
        sizeof marbles);

    return 0;
}

int sum(int ar[], int n)     // 这个数组的大小是?
{
    int i;
    int total = 0;

    for (i = 0; i < n; i++)
        total += ar[i];
    printf("The size of ar is %zd bytes.\n", sizeof ar);

    return total;
}

该程序的输出如下:

The size of ar is 8 bytes.
The total number of marbles is 190.
The size of marbles is 40 bytes.

注意,marbles的大小是40字节。这没问题,因为marbles内含10个int类型的值,每个值占4字节,所以整个marbles的大小是40字节。但是,ar才8字节。这是因为ar并不是数组本身,它是一个指向marbles数组首元素的指针。我们的系统中用8字节储存地址,所以指针变量的大小是8字节(其他系统中地址的大小可能不是8字节)。简而言之,在程序清单10.10中,marbles是一个数组,ar是一个指向marbles数组首元素的指针,利用C中数组和指针的特殊关系,可以用数组表示法来表示指针ar

函数要处理数组必须知道何时开始、何时结束。sum()函数使用一个指针形参标识数组的开始,用一个整数形参表明待处理数组的元素个数(指针形参也表明了数组中的数据类型)。但是这并不是给函数传递必备信息的唯一方法。还有一种方法是传递两个指针,第1个指针指明数组的开始处(与前面用法相同),第2个指针指明数组的结束处。程序清单10.11演示了这种方法,同时该程序也表明了指针形参是变量,这意味着可以用索引表明访问数组中的哪一个元素。

程序清单10.11 sum_arr2.c程序

/* sum_arr2.c -- 数组元素之和 */
#include <stdio.h>
#define SIZE 10
int sump(int * start, int * end);
int main(void)
{
    int marbles[SIZE] = { 20, 10, 5, 39, 4, 16, 19, 26, 31, 20 };
    long answer;

    answer = sump(marbles, marbles + SIZE);
    printf("The total number of marbles is %ld.\n", answer);

    return 0;
}

/* 使用指针算法 */
int sump(int * start, int * end)
{
    int total = 0;

    while (start < end)
    {
        total += *start;    // 把数组元素的值加起来
        start++;            // 让指针指向下一个元素
    }

    return total;
}

指针start开始指向marbles数组的首元素,所以赋值表达式total += *start把首元素(20)加给total。然后,表达式start++递增指针变量start,使其指向数组的下一个元素。因为start是指向int的指针,start递增1相当于其值递增int类型的大小。

注意,sump()函数用另一种方法结束加法循环。sum()函数把元素的个数作为第2个参数,并把该参数作为循环测试的一部分:

for( i = 0; i < n; i++)

sump()函数则使用第2个指针来结束循环:

while (start < end)

因为while循环的测试条件是一个不相等的关系,所以循环最后处理的一个元素是end所指向位置的前一个元素。这意味着end指向的位置实际上在数组最后一个元素的后面。C保证在给数组分配空间时,指向数组后面第一个位置的指针仍是有效的指针。这使得while循环的测试条件是有效的,因为start在循环中最后的值是end[1]。注意,使用这种“越界”指针的函数调用更为简洁:

answer = sump(marbles, marbles + SIZE);

因为下标从0开始,所以marbles + SIZE指向数组末尾的下一个位置。如果end指向数组的最后一个元素而不是数组末尾的下一个位置,则必须使用下面的代码:

answer = sump(marbles, marbles + SIZE - 1);

这种写法既不简洁也不好记,很容易导致编程错误。顺带一提,虽然C保证了marbles + SIZE有效,但是对marbles[SIZE](即储存在该位置上的值)未作任何保证,所以程序不能访问该位置。

还可以把循环体压缩成一行代码:

total += *start++;

一元运算符*和++的优先级相同,但结合律是从右往左,所以start++先求值,然后才是*start。也就是说,指针start先递增后指向。使用后缀形式(即start++而不是++start)意味着先把指针指向位置上的值加到total上,然后再递增指针。如果使用*++start,顺序则反过来,先递增指针,再使用指针指向位置上的值。如果使用(*start)++,则先使用start指向的值,再递增该值,而不是递增指针。这样,指针将一直指向同一个位置,但是该位置上的值发生了变化。虽然*start++的写法比较常用,但是*(start++)这样写更清楚。程序清单10.12的程序演示了这些优先级的情况。

程序清单10.12 order.c程序

/* order.c -- 指针运算中的优先级 */
#include <stdio.h>
int data[2] = { 100, 200 };
int moredata[2] = { 300, 400 };
int main(void)
{
    int * p1, *p2, *p3;

    p1 = p2 = data;
    p3 = moredata;
    printf("  *p1 = %d,   *p2 = %d,     *p3 = %d\n",*p1, *p2, *p3);
    printf("*p1++ = %d, *++p2 = %d, (*p3)++ = %d\n",*p1++, *++p2, (*p3)++);
    printf("  *p1 = %d,   *p2 = %d,     *p3 = %d\n",*p1, *p2, *p3);

    return 0;
}

下面是该程序的输出:

  *p1 = 100,     *p2 = 100,       *p3 = 300
*p1++ = 100,   *++p2 = 200,   (*p3)++ = 300
  *p1 = 200,     *p2 = 200,       *p3 = 301

只有(*p3)++改变了数组元素的值,其他两个操作分别把p1p2指向数组的下一个元素。

从以上分析可知,处理数组的函数实际上用指针作为参数,但是在编写这样的函数时,可以选择是使用数组表示法还是指针表示法。如程序清单10.10所示,使用数组表示法,让函数是处理数组的这一意图更加明显。另外,许多其他语言的程序员对数组表示法更熟悉,如FORTRAN、Pascal、Modula-2或BASIC。其他程序员可能更习惯使用指针表示法,觉得使用指针更自然,如程序清单10.11所示。

至于C语言,ar[i]*(ar+i)这两个表达式都是等价的。无论ar是数组名还是指针变量,这两个表达式都没问题。但是,只有当ar是指针变量时,才能使用ar++这样的表达式。

指针表示法(尤其与递增运算符一起使用时)更接近机器语言,因此一些编译器在编译时能生成效率更高的代码。然而,许多程序员认为他们的主要任务是确保代码正确、逻辑清晰,而代码优化应该留给编译器去做。


说到注意事项,一定要牢记一点:千万不要解引用未初始化的指针。例如,考虑下面的例子:

为何不行?第2行的意思是把5储存在pt指向的位置。但是pt未被初始化,其值是一个随机值,所以不知道5将储存在何处。这可能不会出什么错,也可能会擦写数据或代码,或者导致程序崩溃。切记:创建一个指针时,系统只分配了储存指针本身的内存,并未分配储存数据的内存。因此,在使用指针之前,必须先用已分配的地址初始化它。例如,可以用一个现有变量的地址初始化该指针(使用带指针形参的函数时,就属于这种情况)。或者还可以使用第12章将介绍的malloc()函数先分配内存。无论如何,使用指针时一定要注意,不要解引用未初始化的指针!

可以对指针进行哪些操作?C提供了一些基本的指针操作,下面的程序示例中演示了8种不同的操作。为了显示每种操作的结果,该程序打印了指针的值(该指针指向的地址)、储存在指针指向地址上的值,以及指针自己的地址。如果编译器不支持%p转换说明,可以用%u%lu代替%p;如果编译器不支持用%td转换说明打印地址的差值,可以用%d%ld来代替。

程序清单10.13演示了指针变量的8种基本操作。除了这些操作,还可以使用关系运算符来比较指针。

int * pt;    // 未初始化的指针
*pt = 5;         // 严重的错误

程序清单10.13 ptr_ops.c程序

// ptr_ops.c -- 指针操作
#include <stdio.h>
int main(void)
{
    int urn[5] = { 100, 200, 300, 400, 500 };
    int * ptr1, *ptr2, *ptr3;

    ptr1 = urn;            // 把一个地址赋给指针
    ptr2 = &urn[2];        // 把一个地址赋给指针
                           // 解引用指针,以及获得指针的地址
    printf("pointer value, dereferenced pointer, pointer address:\n");
    printf("ptr1 = %p, *ptr1 =%d, &ptr1 = %p\n", ptr1, *ptr1, &ptr1);

    // 指针加法
    ptr3 = ptr1 + 4;
    printf("\nadding an int to a pointer:\n");
    printf("ptr1 + 4 = %p, *(ptr1 + 4) = %d\n", ptr1 + 4, *(ptr1 + 4));
    ptr1++;                // 递增指针
    printf("\nvalues after ptr1++:\n");
    printf("ptr1 = %p, *ptr1 =%d, &ptr1 = %p\n", ptr1, *ptr1, &ptr1);
    ptr2--;                // 递减指针
    printf("\nvalues after --ptr2:\n");
    printf("ptr2 = %p, *ptr2 = %d, &ptr2 = %p\n", ptr2, *ptr2, &ptr2);
    --ptr1;                // 恢复为初始值
    ++ptr2;                // 恢复为初始值
    printf("\nPointers reset to original values:\n");
    printf("ptr1 = %p, ptr2 = %p\n", ptr1, ptr2);
    // 一个指针减去另一个指针
    printf("\nsubtracting one pointer from another:\n");
    printf("ptr2 = %p, ptr1 = %p, ptr2 - ptr1 = %td\n", ptr2, ptr1, ptr2 - ptr1);
    // 一个指针减去一个整数
    printf("\nsubtracting an int from a pointer:\n");
    printf("ptr3 = %p, ptr3 - 2 = %p\n", ptr3, ptr3 - 2);

    return 0;
}
double * pd;    // 未初始化的指针
*pd = 2.4;      // 不要这样做

下面是我们的系统运行该程序后的输出:

pointer value, dereferenced pointer, pointer address:
ptr1 = 0x7fff5fbff8d0, *ptr1 =100, &ptr1 = 0x7fff5fbff8c8

adding an int to a pointer:
ptr1 + 4 = 0x7fff5fbff8e0, *(ptr1 + 4) = 500

values after ptr1++:
ptr1 = 0x7fff5fbff8d4, *ptr1 =200, &ptr1 = 0x7fff5fbff8c8

values after --ptr2:
ptr2 = 0x7fff5fbff8d4, *ptr2 = 200, &ptr2 = 0x7fff5fbff8c0

Pointers reset to original values:
ptr1 = 0x7fff5fbff8d0, ptr2 = 0x7fff5fbff8d8

subtracting one pointer from another:
ptr2 = 0x7fff5fbff8d8, ptr1 = 0x7fff5fbff8d0, ptr2 - ptr1 = 2

subtracting an int from a pointer:
ptr3 = 0x7fff5fbff8e0, ptr3 - 2 = 0x7fff5fbff8d8

下面分别描述了指针变量的基本操作。

图10.4 递增指向int的指针

注意,这里的减法有两种。可以用一个指针减去另一个指针得到一个整数,或者用一个指针减去一个整数得到另一个指针。

在递增或递减指针时还要注意一些问题。编译器不会检查指针是否仍指向数组元素。C只能保证指向数组任意元素的指针和指向数组后面第1个位置的指针有效。但是,如果递增或递减一个指针后超出了这个范围,则是未定义的。另外,可以解引用指向数组任意元素的指针。但是,即使指针指向数组后面一个位置是有效的,也能解引用这样的越界指针。

解引用未初始化的指针

假设

int urn[3];
int * ptr1, * ptr2;

下面是一些有效和无效的语句:

有效语句                无效语句
ptr1++; urn++;
ptr2 = ptr1 + 2; ptr2 = ptr2 + ptr1;
ptr2 = urn + 1; ptr2 = urn * ptr1;

基于这些有效的操作,C程序员创建了指针数组、函数指针、指向指针的指针数组、指向函数的指针数组等。别紧张,接下来我们将根据已学的内容介绍指针的一些基本用法。指针的第1个基本用法是在函数间传递信息。前面学过,如果希望在被调函数中改变主调函数的变量,必须使用指针。指针的第2个基本用法是用在处理数组的函数中。下面我们再来看一个使用函数和数组的编程示例。

编写一个处理基本类型(如,int)的函数时,要选择是传递int类型的值还是传递指向int的指针。通常都是直接传递数值,只有程序需要在函数中改变该数值时,才会传递指针。对于数组别无选择,必须传递指针,因为这样做效率高。如果一个函数按值传递数组,则必须分配足够的空间来储存原数组的副本,然后把原数组所有的数据拷贝至新的数组中。如果把数组的地址传递给函数,让函数直接处理原数组则效率要高。

传递地址会导致一些问题。C通常都按值传递数据,因为这样做可以保证数据的完整性。如果函数使用的是原始数据的副本,就不会意外修改原始数据。但是,处理数组的函数通常都需要使用原始数据,因此这样的函数可以修改原数组。有时,这正是我们需要的。例如,下面的函数给数组的每个元素都加上一个相同的值:

void add_to(double ar[], int n, double val)
{
    int i;
    for (i = 0; i < n; i++)
        ar[i] += val;
}

因此,调用该函数后,prices数组中的每个元素的值都增加了2.5

add_to(prices, 100, 2.50);

该函数修改了数组中的数据。之所以可以这样做,是因为函数通过指针直接使用了原始数据。

然而,其他函数并不需要修改数据。例如,下面的函数计算数组中所有元素之和,它不用改变数组的数据。但是,由于ar实际上是一个指针,所以编程错误可能会破坏原始数据。例如,下面示例中的ar[i]++会导致数组中每个元素的值都加1

int sum(int ar[], int n)    // 错误的代码
{
    int i;
    int total = 0;

    for( i = 0; i < n; i++)
        total += ar[i]++;    // 错误递增了每个元素的值
    return total;
}

在K&R C的年代,避免类似错误的唯一方法是提高警惕。ANSI C提供了一种预防手段。如果函数的意图不是修改数组中的数据内容,那么在函数原型和函数定义中声明形式参数时应使用关键字const。例如,sum()函数的原型和定义如下:

int sum(const int ar[], int n); /* 函数原型 */

int sum(const int ar[], int n) /* 函数定义 */
{
    int i;
    int total = 0;

    for( i = 0; i < n; i++)
        total += ar[i];
    return total;
}

以上代码中的const告诉编译器,该函数不能修改ar指向的数组中的内容。如果在函数中不小心使用类似ar[i]++的表达式,编译器会捕获这个错误,并生成一条错误信息。

这里一定要理解,这样使用const并不是要求原数组是常量,而是该函数在处理数组时将其视为常量,不可更改。这样使用const可以保护数组的数据不被修改,就像按值传递可以保护基本数据类型的原始值不被改变一样。一般而言,如果编写的函数需要修改数组,在声明数组形参时则不使用const;如果编写的函数不用修改数组,那么在声明数组形参时最好使用const

程序清单10.14的程序中,一个函数显示数组的内容,另一个函数给数组每个元素都乘以一个给定值。因为第1个函数不用改变数组,所以在声明数组形参时使用了const;而第2个函数需要修改数组元素的值,所以不使用const

程序清单10.14 arf.c程序

/* arf.c -- 处理数组的函数 */
#include <stdio.h>
#define SIZE 5
void show_array(const double ar[], int n);
void mult_array(double ar[], int n, double mult);
int main(void)
{
    double dip[SIZE] = { 20.0, 17.66, 8.2, 15.3, 22.22 };

    printf("The original dip array:\n");
    show_array(dip, SIZE);
    mult_array(dip, SIZE, 2.5);
    printf("The dip array after calling mult_array():\n");
    show_array(dip, SIZE);

    return 0;
}

/* 显示数组的内容 */
void show_array(const double ar[], int n)
{
    int i;

    for (i = 0; i < n; i++)
        printf("%8.3f ", ar[i]);
    putchar('\n');
}

/* 把数组的每个元素都乘以相同的值 */
void mult_array(double ar[], int n, double mult)
{
    int i;

    for (i = 0; i < n; i++)
        ar[i] *= mult;
}

下面是该程序的输出:

The original dip array:
  20.000    17.660    8.200    15.300    22.220
The dip array after calling mult_array():
  50.000    44.150    20.500   38.250    55.550

注意该程序中两个函数的返回类型都是void。虽然mult_array()函数更新了dip数组的值,但是并未使用return机制。

我们在前面使用const创建过变量:

const double PI = 3.14159;

虽然用#define指令可以创建类似功能的符号常量,但是const的用法更加灵活。可以创建const数组、const指针和指向const的指针。

程序清单10.4演示了如何使用const关键字保护数组:

#define MONTHS 12
...
const int days[MONTHS] = {31,28,31,30,31,30,31,31,30,31,30,31};

如果程序稍后尝试改变数组元素的值,编译器将生成一个编译期错误消息:

days[9] = 44;        /* 编译错误 */

指向const的指针不能用于改变值。考虑下面的代码:

double rates[5] = {88.99, 100.12, 59.45, 183.11, 340.5};
const double * pd = rates;     // pd指向数组的首元素

第2行代码把pd指向的double类型的值声明为const,这表明不能使用pd来更改它所指向的值:

*pd = 29.89;       // 不允许
pd[2] = 222.22;    // 不允许
rates[0] = 99.99;  // 允许,因为rates未被const限定

无论是使用指针表示法还是数组表示法,都不允许使用pd修改它所指向数据的值。但是要注意,因为rates并未被声明为const,所以仍然可以通过rates修改元素的值。另外,可以让pd指向别处:

pd++; /* 让pd指向rates[1] -- 没问题 */

指向const的指针通常用于函数形参中,表明该函数不会使用指针改变数据。例如,程序清单10.14中的show_array()函数原型如下:

void show_array(const double *ar, int n);

关于指针赋值和const需要注意一些规则。首先,把const数据或非const数据的地址初始化为指向const的指针或为其赋值是合法的:

double rates[5] = {88.99, 100.12, 59.45, 183.11, 340.5};
const double locked[4] = {0.08, 0.075, 0.0725, 0.07};
const double * pc = rates;  // 有效
pc = locked;                // 有效
pc = &rates[3];             // 有效

然而,只能把非const数据的地址赋给普通指针:

double rates[5] = {88.99, 100.12, 59.45, 183.11, 340.5};
const double locked[4] = {0.08, 0.075, 0.0725, 0.07};
double * pnc = rates;    // 有效
pnc = locked;            // 无效
pnc = &rates[3];         // 有效

这个规则非常合理。否则,通过指针就能改变const数组中的数据。

应用以上规则的例子,如show_array()函数可以接受普通数组名和const数组名作为参数,因为这两种参数都可以用来初始化指向const的指针:

show_array(rates, 5);        // 有效
show_array(locked, 4);       // 有效

因此,对函数的形参使用const不仅能保护数据,还能让函数处理const数组。

另外,不应该把const数组名作为实参传递给mult_array()这样的函数:

mult_array(rates, 5, 1.2);     // 有效
mult_array(locked, 4, 1.2);    // 不要这样做

C标准规定,使用非const标识符(如,mult_arry()的形参ar)修改const数据(如,locked)导致的结果是未定义的。

const还有其他的用法。例如,可以声明并初始化一个不能指向别处的指针,关键是const的位置:

double rates[5] = {88.99, 100.12, 59.45, 183.11, 340.5};
double * const pc = rates;  // pc指向数组的开始
pc = &rates[2];             // 不允许,因为该指针不能指向别处
*pc = 92.99;                // 没问题 -- 更改rates[0]的值

可以用这种指针修改它所指向的值,但是它只能指向初始化时设置的地址。

最后,在创建指针时还可以使用const两次,该指针既不能更改它所指向的地址,也不能修改指向地址上的值:

double rates[5] = {88.99, 100.12, 59.45, 183.11, 340.5};
const double * const pc = rates;
pc = &rates[2];     //不允许
*pc = 92.99;        //不允许

指针和多维数组有什么关系?为什么要了解它们的关系?处理多维数组的函数要用到指针,所以在使用这种函数之前,先要更深入地学习指针。至于第1个问题,我们通过几个示例来回答。为简化讨论,我们使用较小的数组。假设有下面的声明:

int zippo[4][2]; /* 内含int数组的数组 */

然后数组名zippo是该数组首元素的地址。在本例中,zippo的首元素是一个内含两个int值的数组,所以zippo是这个内含两个int值的数组的地址。下面,我们从指针的属性进一步分析。

显然,增加数组维数会增加指针的复杂度。现在,大部分初学者都开始意识到指针为什么是C语言中最难的部分。认真思考上述内容,看看是否能用所学的知识解释程序清单10.15中的程序。该程序显示了一些地址值和数组的内容。

程序清单10.15 zippo1.c程序

/* zippo1.c --  zippo的相关信息 */
#include <stdio.h>
int main(void)
{
    int zippo[4][2] = { { 2, 4 }, { 6, 8 }, { 1, 3 }, { 5, 7 } };

    printf("   zippo = %p,    zippo + 1 = %p\n",zippo, zippo + 1);
    printf("zippo[0] = %p, zippo[0] + 1 = %p\n",zippo[0], zippo[0] + 1);
    printf("  *zippo = %p,   *zippo + 1 = %p\n",*zippo, *zippo + 1);
    printf("zippo[0][0] = %d\n", zippo[0][0]);
    printf("  *zippo[0] = %d\n", *zippo[0]);
    printf("    **zippo = %d\n", **zippo);
    printf("      zippo[2][1] = %d\n", zippo[2][1]);
    printf("*(*(zippo+2) + 1) = %d\n", *(*(zippo + 2) + 1));

    return 0;
}

下面是我们的系统运行该程序后的输出:

   zippo = 0x0064fd38,     zippo + 1 = 0x0064fd40
 zippo[0]= 0x0064fd38,  zippo[0] + 1 = 0x0064fd3c
  *zippo = 0x0064fd38,    *zippo + 1 = 0x0064fd3c
zippo[0][0] = 2
  *zippo[0] = 2
    **zippo = 2
      zippo[2][1] = 3
*(*(zippo+2) + 1) = 3

其他系统显示的地址值和地址形式可能不同,但是地址之间的关系与以上输出相同。该输出显示了二维数组zippo的地址和一维数组zippo[0]的地址相同。它们的地址都是各自数组首元素的地址,因而与&zippo[0][0]的值也相同。

尽管如此,它们也有差别。在我们的系统中,int是4字节。前面讨论过,zippo[0]指向一个4字节的数据对象。zippo[0]1,其值加4(十六进制中,38+43c)。数组名zippo是一个内含2个int类型值的数组的地址,所以zippo指向一个8字节的数据对象。因此,zippo1,它所指向的地址加8字节(十六进制中,38+840)。

该程序演示了zippo[0]*zippo完全相同,实际上确实如此。然后,对二维数组名解引用两次,得到储存在数组中的值。使用两个间接运算符(*)或者使用两对方括号([])都能获得该值(还可以使用一个*和一对[],但是我们暂不讨论这么多情况)。

要特别注意,与zippo[2][1]等价的指针表示法是*(*(zippo+2) + 1)。看上去比较复杂,应最好能理解。下面列出了理解该表达式的思路:

zippo             ←二维数组首元素的地址(每个元素都是内含两个int类型元素的一维数组)
zippo+2           ←二维数组的第3个元素(即一维数组)的地址
*(zippo+2)        ←二维数组的第3个元素(即一维数组)的首元素(一个int类型的值)地址
*(zippo+2) + 1    ←二维数组的第3个元素(即一维数组)的第2个元素(也是一个int类型的值)地址
*(*(zippo+2) + 1) ←二维数组的第3个一维数组元素的第2个int类型元素的值,即数组的第3行第2列的值(zippo[2][1])

以上分析并不是为了说明用指针表示法(*(*(zippo+2) + 1))代替数组表示法(zippo[2][1]),而是提示读者,如果程序恰巧使用一个指向二维数组的指针,而且要通过该指针获取值时,最好用简单的数组表示法,而不是指针表示法。

图10.5以另一种视图演示了数组地址、数组内容和指针之间的关系。

图10.5 数组的数组

如何声明一个指针变量pz指向一个二维数组(如,zippo)?在编写处理类似zippo这样的二维数组时会用到这样的指针。把指针声明为指向int的类型还不够。因为指向int只能与zippo[0]的类型匹配,说明该指针指向一个int类型的值。但是zippo是它首元素的地址,该元素是一个内含两个int类型值的一维数组。因此,pz必须指向一个内含两个int类型值的数组,而不是指向一个int类型值,其声明如下:

int (* pz)[2];    // pz指向一个内含两个int类型值的数组

以上代码把pz声明为指向一个数组的指针,该数组内含两个int类型值。为什么要在声明中使用圆括号?因为[]的优先级高于*。考虑下面的声明:

int * pax[2];     // pax是一个内含两个指针元素的数组,每个元素都指向int的指针

由于[]优先级高,先与pax结合,所以pax成为一个内含两个元素的数组。然后*表示pax数组内含两个指针。最后,int表示pax数组中的指针都指向int类型的值。因此,这行代码声明了两个指向int的指针。而前面有圆括号的版本,*先与pz结合,因此声明的是一个指向数组(内含两个int类型的值)的指针。程序清单10.16演示了如何使用指向二维数组的指针。

程序清单10.16 zippo2.c程序

/* zippo2.c --  通过指针获取zippo的信息 */
#include <stdio.h>
int main(void)
{
    int zippo[4][2] = { { 2, 4 }, { 6, 8 }, { 1, 3 }, { 5, 7 } };
    int(*pz)[2];
    pz = zippo;

    printf("   pz = %p,    pz + 1 = %p\n",    pz, pz + 1);
    printf("pz[0] = %p, pz[0] + 1 = %p\n",    pz[0], pz[0] + 1);
    printf("  *pz = %p,   *pz + 1 = %p\n",    *pz, *pz + 1);
    printf("pz[0][0] = %d\n", pz[0][0]);
    printf("  *pz[0] = %d\n", *pz[0]);
    printf("    **pz = %d\n", **pz);
    printf("      pz[2][1] = %d\n", pz[2][1]);
    printf("*(*(pz+2) + 1) = %d\n", *(*(pz + 2) + 1));

    return 0;
}

下面是该程序的输出:

pz = 0x0064fd38,       pz + 1 = 0x0064fd40
pz[0] = 0x0064fd38,    pz[0] + 1 = 0x0064fd3c
  *pz = 0x0064fd38,      *pz + 1 = 0x0064fd3c
pz[0][0] = 2
  *pz[0] = 2
    **pz = 2
      pz[2][1] = 3
*(*(pz+2)  + 1) = 3

系统不同,输出的地址可能不同,但是地址之间的关系相同。如前所述,虽然pz是一个指针,不是数组名,但是也可以使用pz[2][1]这样的写法。可以用数组表示法或指针表示法来表示一个数组元素,既可以使用数组名,也可以使用指针名:

zippo[m][n] == *(*(zippo + m) + n)
pz[m][n] == *(*(pz + m) + n)


C和C++中const的用法很相似,但是并不完全相同。区别之一是,C++允许在声明数组大小时使用const整数,而C却不允许。区别之二是,C++的指针赋值检查更严格:

C++不允许把const指针赋给非const指针。而C则允许这样做,但是如果通过p1更改y,其行为是未定义的。

指针之间的赋值比数值类型之间的赋值要严格。例如,不用类型转换就可以把int类型的值赋给double类型的变量,但是两个类型的指针不能这样做。

int n = 5;
double x;
int * p1 = &n;
double * pd = &x;
x = n;              // 隐式类型转换
pd = p1;            // 编译时错误
const int y;
const int * p2 = &y;
int * p1;
p1 = p2;    // C++中不允许这样做,但是C可能只给出警告

更复杂的类型也是如此。假设有如下声明:

int * pt;
int (*pa)[3];
int ar1[2][3];
int ar2[3][2];
int **p2;    // 一个指向指针的指针

有如下的语句:

pt = &ar1[0][0];    // 都是指向int的指针
pt = ar1[0];        // 都是指向int的指针
pt = ar1;           // 无效
pa = ar1;           // 都是指向内含3个int类型元素数组的指针
pa = ar2;           // 无效
p2 = &pt;           // both pointer-to-int *
*p2 = ar2[0];       // 都是指向int的指针
p2 = ar2;           // 无效

注意,以上无效的赋值表达式语句中涉及的两个指针都是指向不同的类型。例如,pt指向一个int类型值,而ar1指向一个内含3个int类型元素的数组。类似地,pa指向一个内含3个int类型元素的数组,所以它与ar1的类型兼容,但是ar2指向一个内含2个int类型元素的数组,所以paar2不兼容。

上面的最后两个例子有些棘手。变量p2是指向指针的指针,它指向的指针指向int,而ar2是指向数组的指针,该数组内含2个int类型的元素。所以,p2ar2的类型不同,不能把ar2赋给p2。但是,*p2是指向int的指针,与ar2[0]兼容。因为ar2[0]是指向该数组首元素(ar2[0][0])的指针,所以ar2[0]也是指向int的指针。

一般而言,多重解引用让人费解。例如,考虑下面的代码:

int x = 20;
const int y = 23;
int * p1 = &x;
const int * p2 = &y;
const int ** pp2;
p1 = p2;      // 不安全 -- 把const指针赋给非const指针
p2 = p1;      // 有效 -- 把非const指针赋给const指针
pp2 = &p1;    // 不安全 –- 嵌套指针类型赋值

前面提到过,把const指针赋给非const指针不安全,因为这样可以使用新的指针改变const指针指向的数据。编译器在编译代码时,可能会给出警告,执行这样的代码是未定义的。但是把非const指针赋给const指针没问题,前提是只进行一级解引用:

p2 = p1; // 有效 -- 把非const指针赋给const指针

但是进行两级解引用时,这样的赋值也不安全,例如,考虑下面的代码:

const int **pp2;
int *p1;
const int n = 13;
pp2 = &p1;    // 允许,但是这导致const限定符失效(根据第1行代码,不能通过*pp2修改它所指向的内容)
*pp2 = &n;    // 有效,两者都声明为const,但是这将导致p1指向n(*pp2已被修改)
*p1 = 10; //有效,但是这将改变n的值(但是根据第3行代码,不能修改n的值)

发生了什么?如前所示,标准规定了通过非const指针更改const数据是未定义的。例如,在Terminal中(OS X对底层UNIX系统的访问)使用gcc编译包含以上代码的小程序,导致n最终的值是13,但是在相同系统下使用clang来编译,n最终的值是10。两个编译器都给出指针类型不兼容的警告。当然,可以忽略这些警告,但是最好不要相信该程序运行的结果,这些结果都是未定义的。

C const和C++ const

如果要编写处理二维数组的函数,首先要能正确地理解指针才能写出声明函数的形参。在函数体中,通常使用数组表示法进行相关操作。

下面,我们编写一个处理二维数组的函数。一种方法是,利用for循环把处理一维数组的函数应用到二维数组的每一行。如下所示:

int junk[3][4] = { {2,4,5,8}, {3,5,6,9}, {12,10,8,6} };
int i, j;
int total = 0;
for (i = 0; i < 3 ; i++)
    total += sum(junk[i], 4); // junk[i]是一维数组

记住,如果junk是二维数组,junk[i]就是一维数组,可将其视为二维数组的一行。这里,sum()函数计算二维数组的每行的总和,然后for循环再把每行的总和加起来。

然而,这种方法无法记录行和列的信息。用这种方法计算总和,行和列的信息并不重要。但如果每行代表一年,每列代表一个月,就还需要一个函数计算某列的总和。该函数要知道行和列的信息,可以通过声明正确类型的形参变量来完成,以便函数能正确地传递数组。在这种情况下,数组junk是一个内含3个数组元素的数组,每个元素是内含4个int类型值的数组(即junk是一个3行4列的二维数组)。通过前面的讨论可知,这表明junk是一个指向数组(内含4int类型值)的指针。可以这样声明函数的形参:

void somefunction( int (* pt)[4] );

另外,如果当且仅当pt是一个函数的形式参数时,可以这样声明:

void somefunction( int pt[][4] );

注意,第1个方括号是空的。空的方括号表明pt是一个指针。这样的变量稍后能以同样的方式用作junk。下面的程序示例中就是这样做的,如程序清单10.17所示。注意该程序清单演示了3种等价的原型语法。

程序清单10.17 array2d.c程序

// array2d.c -- 处理二维数组的函数
#include <stdio.h>
#define ROWS 3
#define COLS 4
void sum_rows(int ar[][COLS], int rows);
void sum_cols(int [][COLS], int);       // 省略形参名,没问题
int sum2d(int(*ar)[COLS], int rows);    // 另一种语法
int main(void)
{
    int junk[ROWS][COLS] = {
             { 2, 4, 6, 8 },
             { 3, 5, 7, 9 },
             { 12, 10, 8, 6 }
    };

    sum_rows(junk, ROWS);
    sum_cols(junk, ROWS);
    printf("Sum of all elements = %d\n", sum2d(junk, ROWS));

    return 0;
}

void sum_rows(int ar[][COLS], int rows)
{
    int r;
    int c;
    int tot;

    for (r = 0; r < rows; r++)
    {
        tot = 0;
        for (c = 0; c < COLS; c++)
             tot += ar[r][c];
        printf("row %d: sum = %d\n", r, tot);
    }
}

void sum_cols(int ar[][COLS], int rows)
{
    int r;
    int c;
    int tot;

    for (c = 0; c < COLS; c++)
    {
        tot = 0;
        for (r = 0; r < rows; r++)
             tot += ar[r][c];
        printf("col %d: sum = %d\n", c, tot);
    }
}

int sum2d(int ar[][COLS], int rows)
{
    int r;
    int c;
    int tot = 0;

    for (r = 0; r < rows; r++)
        for (c = 0; c < COLS; c++)
             tot += ar[r][c];

    return tot;
}

该程序的输出如下:

row 0: sum = 20
row 1: sum = 24
row 2: sum = 36
col 0: sum = 17
col 1: sum = 19
col 2: sum = 21
col 3: sum = 23
Sum of all elements = 80

程序清单10.17中的程序把数组名junk(即,指向数组首元素的指针,首元素是子数组)和符号常量ROWS(代表行数3)作为参数传递给函数。每个函数都把ar视为内含数组元素(每个元素是内含4int类型值的数组)的数组。列数内置在函数体中,但是行数靠函数传递得到。如果传入函数的行数是12,那么函数要处理的是12×4的数组。因为rows是元素的个数,然而,因为每个元素都是数组,或者视为一行,rows也可以看成是行数。

注意,armain()中的junk都使用数组表示法。因为arjunk的类型相同,它们都是指向内含4int类型值的数组的指针。

注意,下面的声明不正确:

int sum2(int ar[][], int rows);    // 错误的声明

前面介绍过,编译器会把数组表示法转换成指针表示法。例如,编译器会把ar[1]转换成ar+1。编译器对ar+1求值,要知道ar所指向的对象大小。下面的声明:

int sum2(int ar[][4], int rows);    // 有效声明

表示ar指向一个内含4个int类型值的数组(在我们的系统中,ar指向的对象占16字节),所以ar+1的意思是“该地址加上16字节”。如果第2对方括号是空的,编译器就不知道该怎样处理。

也可以在第1对方括号中写上大小,如下所示,但是编译器会忽略该值:

int sum2(int ar[3][4], int rows);  // 有效声明,但是3将被忽略

与使用typedef(第5章和第14章中讨论)相比,这种形式方便得多:

typedef int arr4[4];                // arr4是一个内含 4 个int的数组
typedef arr4 arr3x4[3];             // arr3x4 是一个内含3个 arr4的数组
int sum2(arr3x4 ar, int rows);      // 与下面的声明相同
int sum2(int ar[3][4], int rows);   // 与下面的声明相同
int sum2(int ar[][4], int rows);    // 标准形式

一般而言,声明一个指向N维数组的指针时,只能省略最左边方括号中的值:

int sum4d(int ar[][12][20][30], int rows);

因为第1对方括号只用于表明这是一个指针,而其他的方括号则用于描述指针所指向数据对象的类型。下面的声明与该声明等价:

int sum4d(int (*ar)[12][20][30], int rows); // ar是一个指针

这里,ar指向一个12×20×30int数组。


是否可以在声明数组时使用const变量?

C90标准不允许(也可能允许)。数组的大小必须是给定的整型常量表达式,可以是整型常量组合,如20、sizeof表达式或其他不是const的内容。由于C实现可以扩大整型常量表达式的范围,所以可能会允许使用const,但是这种代码可能无法移植。

C99/C11标准允许在声明变长数组时使用const变量。所以该数组的定义必须是声明在块中的自动存储类别数组。

读者在学习处理二维数组的函数中可能不太理解,为何只把数组的行数作为函数的形参,而列数却内置在函数体内。例如,函数定义如下:

#define COLS 4
int sum2d(int ar[][COLS], int rows)
{
    int r;
    int c;
    int tot = 0;

    for (r = 0; r < rows; r++)
        for (c = 0; c < COLS; c++)
             tot += ar[r][c];
    return tot;
}
const int SZ = 80;
...
double ar[SZ]; // 是否允许?

假设声明了下列数组:

int array1[5][4];
int array2[100][4];
int array3[2][4];

可以用sum2d()函数分别计算这些数组的元素之和:

tot = sum2d(array1, 5);    // 5×4 数组的元素之和
tot = sum2d(array2, 100);  // 100×4数组的元素之和
tot = sum2d(array3, 2);    // 2×4数组的元素之和

sum2d()函数之所以能处理这些数组,是因为这些数组的列数固定为4,而行数被传递给形参rowsrows是一个变量。但是如果要计算6×5的数组(即6行5列),就不能使用这个函数,必须重新创建一个CLOS5的函数。因为C规定,数组的维数必须是常量,不能用变量来代替COLS

要创建一个能处理任意大小二维数组的函数,比较繁琐(必须把数组作为一维数组传递,然后让函数计算每行的开始处)。而且,这种方法不好处理FORTRAN的子例程,这些子例程都允许在函数调用中指定两个维度。虽然FORTRAN是比较老的编程语言,但是在过去的几十年里,数值计算领域的专家已经用FORTRAN开发出许多有用的计算库。C正逐渐替代FORTRAN,如果能直接转换现有的FORTRAN库就好了。

鉴于此,C99新增了变长数组variable-length array,VLA),允许使用变量表示数组的维度。如下所示:

int quarters = 4;
int regions = 5;
double sales[regions][quarters];    // 一个变长数组(VLA)

前面提到过,变长数组有一些限制。变长数组必须是自动存储类别,这意味着无论在函数中声明还是作为函数形参声明,都不能使用staticextern存储类别说明符(第12章介绍)。而且,不能在声明中初始化它们。最终,C11把变长数组作为一个可选特性,而不是必须强制实现的特性。

注意

变长数组不能改变大小

变长数组中的“变”不是指可以修改已创建数组的大小。一旦创建了变长数组,它的大小则保持不变。这里的“变”指的是:在创建数组时,可以使用变量指定数组的维度。

由于变长数组是C语言的新特性,目前完全支持这一特性的编译器不多。下面我们来看一个简单的例子:如何编写一个函数,计算int的二维数组所有元素之和。

首先,要声明一个带二维变长数组参数的函数,如下所示:

int sum2d(int rows, int cols, int ar[rows][cols]); // ar是一个变长数组(VLA)

注意前两个形参(rowscols)用作第3个形参二维数组ar的两个维度。因为ar的声明要使用rowscols,所以在形参列表中必须在声明ar之前先声明这两个形参。因此,下面的原型是错误的:

int sum2d(int ar[rows][cols], int rows, int cols); // 无效的顺序

C99/C11标准规定,可以省略原型中的形参名,但是在这种情况下,必须用星号来代替省略的维度:

int sum2d(int, int, int ar[*][*]); // ar是一个变长数组(VLA),省略了维度形参名

其次,该函数的定义如下:

int sum2d(int rows, int cols, int ar[rows][cols])
{
    int r;
    int c;
    int tot = 0;

    for (r = 0; r < rows; r++)
        for (c = 0; c < cols; c++)
             tot += ar[r][c];
    return tot;
}

该函数除函数头与传统的C函数(程序清单10.17)不同外,还把符号常量COLS替换成变量cols。这是因为在函数头中使用了变长数组。由于用变量代表行数和列数,所以新的sum2d()现在可以处理任意大小的二维int数组,如程序清单10.18所示。但是,该程序要求编译器支持变长数组特性。另外,该程序还演示了以变长数组作为形参的函数既可处理传统C数组,也可处理变长数组。

程序清单10.18 vararr2d.c程序

//vararr2d.c -- 使用变长数组的函数
#include <stdio.h>
#define ROWS 3
#define COLS 4
int sum2d(int rows, int cols, int ar[rows][cols]);
int main(void)
{
    int i, j;
    int rs = 3;
    int cs = 10;
    int junk[ROWS][COLS] = {
             { 2, 4, 6, 8 },
             { 3, 5, 7, 9 },
             { 12, 10, 8, 6 }
    };

    int morejunk[ROWS - 1][COLS + 2] = {
             { 20, 30, 40, 50, 60, 70 },
             { 5, 6, 7, 8, 9, 10 }
    };

    int varr[rs][cs];  // 变长数组(VLA)

    for (i = 0; i < rs; i++)
        for (j = 0; j < cs; j++)
             varr[i][j] = i * j + j;

    printf("3x5 array\n");
    printf("Sum of all elements = %d\n", sum2d(ROWS, COLS, junk));

    printf("2x6 array\n");
    printf("Sum of all elements = %d\n", sum2d(ROWS - 1, COLS + 2, morejunk));

    printf("3x10 VLA\n");
    printf("Sum of all elements = %d\n", sum2d(rs, cs, varr));

    return 0;
}

// 带变长数组形参的函数
int sum2d(int rows, int cols, int ar[rows][cols])
{
    int r;
    int c;
    int tot = 0;

    for (r = 0; r < rows; r++)
        for (c = 0; c < cols; c++)
             tot += ar[r][c];

    return tot;
}

下面是该程序的输出:

3x5 array
Sum of all elements = 80
2x6 array
Sum of all elements = 315
3x10 VLA
Sum of all elements = 270

需要注意的是,在函数定义的形参列表中声明的变长数组并未实际创建数组。和传统的语法类似,变长数组名实际上是一个指针。这说明带变长数组形参的函数实际上是在原始数组中处理数组,因此可以修改传入的数组。下面的代码段指出指针和实际数组是何时声明的:

    int thing[10][6];
    twoset(10,6,thing);
    ...
}

void twoset (int n, int m, int ar[n][m]) // ar是一个指向数组(内含m个int类型的值)的指针
{
    int temp[n][m];    // temp是一个n×m的int数组
    temp[0][0] = 2;    // 设置temp的一个元素为2
    ar[0][0] = 2;      // 设置thing[0][0]为2
}

如上代码所示调用twoset()时,ar成为指向thing[0]的指针,temp被创建为10×6的数组。因为arthing都是指向thing[0]的指针,ar[0][0]thing[0][0]访问的数据位置相同。

const和数组大小

变长数组还允许动态内存分配,这说明可以在程序运行时指定数组的大小。普通C数组都是静态内存分配,即在编译时确定数组的大小。由于数组大小是常量,所以编译器在编译时就知道了。第12章将详细介绍动态内存分配。

假设给带int类型形参的函数传递一个值,要传递int类型的变量,但是也可以传递int类型常量,如5。在C99标准以前,对于带数组形参的函数,情况不同,可以传递数组,但是没有等价的数组常量。C99新增了复合字面量compound literal)。字面量是除符号常量外的常量。例如,5int类型字面量,81.3double类型的字面量,'Y'char类型的字面量,"elephant"是字符串字面量。发布C99标准的委员会认为,如果有代表数组和结构内容的复合字面量,在编程时会更方便。

对于数组,复合字面量类似数组初始化列表,前面是用括号括起来的类型名。例如,下面是一个普通的数组声明:

int diva[2] = {10, 20};

下面的复合字面量创建了一个和diva数组相同的匿名数组,也有两个int类型的值:

(int [2]){10, 20}        // 复合字面量

注意,去掉声明中的数组名,留下的int [2]即是复合字面量的类型名。

初始化有数组名的数组时可以省略数组大小,复合字面量也可以省略大小,编译器会自动计算数组当前的元素个数:

(int []){50, 20, 90}    // 内含3个元素的复合字面量

因为复合字面量是匿名的,所以不能先创建然后再使用它,必须在创建的同时使用它。使用指针记录地址就是一种用法。也就是说,可以这样用:

int * pt1;
pt1 = (int [2]) {10, 20};

注意,该复合字面量的字面常量与上面创建的diva数组的字面常量完全相同。与有数组名的数组类似,复合字面量的类型名也代表首元素的地址,所以可以把它赋给指向int的指针。然后便可使用这个指针。例如,本例中*pt110pt1[1]20

还可以把复合字面量作为实际参数传递给带有匹配形式参数的函数:

int sum(const int ar[], int n);
...
int total3;
total3 = sum((int []){4,4,4,5,5,5}, 6);

这里,第1个实参是内含6int类型值的数组,和数组名类似,这同时也是该数组首元素的地址。这种用法的好处是,把信息传入函数前不必先创建数组,这是复合字面量的典型用法。

可以把这种用法应用于二维数组或多维数组。例如,下面的代码演示了如何创建二维int数组并储存其地址:

int (*pt2)[4];      // 声明一个指向二维数组的指针,该数组内含2个数组元素,
                    // 每个元素是内含4个int类型值的数组
pt2 = (int [2][4]) { {1,2,3,-9}, {4,5,6,-8} };

如上所示,该复合字面量的类型是int [2][4],即一个2×4int数组。

程序清单10.19把上述例子放进一个完整的程序中。

程序清单10.19 flc.c程序

// flc.c -- 有趣的常量
#include <stdio.h>
#define COLS 4
int sum2d(const int ar[][COLS], int rows);
int sum(const int ar[], int n);
int main(void)
{
    int total1, total2, total3;
    int * pt1;
    int(*pt2)[COLS];

    pt1 = (int[2]) { 10, 20 };
    pt2 = (int[2][COLS]) { {1, 2, 3, -9}, { 4, 5, 6, -8 } };

    total1 = sum(pt1, 2);
    total2 = sum2d(pt2, 2);
    total3 = sum((int []){ 4, 4, 4, 5, 5, 5 }, 6);
    printf("total1 = %d\n", total1);
    printf("total2 = %d\n", total2);
    printf("total3 = %d\n", total3);

    return 0;
}

int sum(const int ar [], int n)
{
    int i;
    int total = 0;

    for (i = 0; i < n; i++)
        total += ar[i];

    return total;
}

int sum2d(const int ar [][COLS], int rows)
{
    int r;
    int c;
    int tot = 0;

    for (r = 0; r < rows; r++)
        for (c = 0; c < COLS; c++)
             tot += ar[r][c];

    return tot;
}

要支持C99的编译器才能正常运行该程序示例(目前并不是所有的编译器都支持),其输出如下:

total1 = 30
total2 = 4
total3 = 27

记住,复合字面量是提供只临时需要的值的一种手段。复合字面量具有块作用域(第12章将介绍相关内容),这意味着一旦离开定义复合字面量的块,程序将无法保证该字面量是否存在。也就是说,复合字面量的定义在最内层的花括号中。

数组用于储存相同类型的数据。C把数组看作是派生类型,因为数组是建立在其他类型的基础上。也就是说,无法简单地声明一个数组。在声明数组时必须说明其元素的类型,如int类型的数组、float类型的数组,或其他类型的数组。所谓的其他类型也可以是数组类型,这种情况下,创建的是数组的数组(或称为二维数组)。

通常编写一个函数来处理数组,这样在特定的函数中解决特定的问题,有助于实现程序的模块化。在把数组名作为实际参数时,传递给函数的不是整个数组,而是数组的地址(因此,函数对应的形式参数是指针)。为了处理数组,函数必须知道从何处开始读取数据和要处理多少个数组元素。数组地址提供了“地址”,“元素个数”可以内置在函数中或作为单独的参数传递。第2种方法更普遍,因为这样做可以让同一个函数处理不同大小的数组。

数组和指针的关系密切,同一个操作可以用数组表示法或指针表示法。它们之间的关系允许你在处理数组的函数中使用数组表示法,即使函数的形式参数是一个指针,而不是数组。

对于传统的C数组,必须用常量表达式指明数组的大小,所以数组大小在编译时就已确定。C99/C11新增了变长数组,可以用变量表示数组大小。这意味着变长数组的大小延迟到程序运行时才确定。

数组是一组数据类型相同的元素。数组元素按顺序储存在内存中,通过整数下标(或索引)可以访问各元素。在C中,数组首元素的下标是0,所以对于内含n个元素的数组,其最后一个元素的下标是n-1。作为程序员,要确保使用有效的数组下标,因为编译器和运行的程序都不会检查下标的有效性。

声明一个简单的一维数组形式如下:

type name [ size ];

这里,type是数组中每个元素的数据类型,name是数组名,size是数组元素的个数。对于传统的C数组,要求size是整型常量表达式。但是C99/C11允许使用整型非常量表达式。这种情况下的数组被称为变长数组。

C把数组名解释为该数组首元素的地址。换言之,数组名与指向该数组首元素的指针等价。概括地说,数组和指针的关系十分密切。如果ar是一个数组,那么表达式ar[i]*(ar+i)等价。

对于C语言而言,不能把整个数组作为参数传递给函数,但是可以传递数组的地址。然后函数可以使用传入的地址操控原始数组。如果函数没有修改原始数组的意图,应在声明函数的形式参数时使用关键字const。在被调函数中可以使用数组表示法或指针表示法,无论用哪种表示法,实际上使用的都是指针变量。

指针加上一个整数或递增指针,指针的值以所指向对象的大小为单位改变。也就是说,如果pd指向一个数组的8字节double类型值,那么pd1意味着其值加8,以便它指向该数组的下一个元素。

二维数组即是数组的数组。例如,下面声明了一个二维数组:

double sales[5][12];

该数组名为sales,有5个元素(一维数组),每个元素都是一个内含12个double类型值的数组。第1个一维数组是sales[0],第2个一维数组是sales[1],以此类推,每个元素都是内含12个double类型值的数组。使用第2个下标可以访问这些一维数组中的特定元素。例如,sales[2][5]slaes[2]的第6个元素,而sales[2]sales的第3个元素。

C语言传递多维数组的传统方法是把数组名(即数组的地址)传递给类型匹配的指针形参。声明这样的指针形参要指定所有的数组维度,除了第1个维度。传递的第1个维度通常作为第2个参数。例如,为了处理前面声明的sales数组,函数原型和函数调用如下:

void display(double ar[][12], int rows);
...
display(sales, 5);

变长数组提供第2种语法,把数组维度作为参数传递。在这种情况下,对应函数原型和函数调用如下:

void display(int rows, int cols, double ar[rows][cols]);
...
display(5, 12, sales);

虽然上述讨论中使用的是int类型的数组和double类型的数组,其他类型的数组也是如此。然而,字符串有一些特殊的规则,这是由于其末尾的空字符所致。有了这个空字符,不用传递数组的大小,函数通过检测字符串的末尾也知道在何处停止。我们将在第11章中详细介绍。

复习题的参考答案在附录A中。

1.下面的程序将打印什么内容?

#include <stdio.h>
int main(void)
{
    int ref[] = { 8, 4, 0, 2 };
    int *ptr;
    int index;

    for (index = 0, ptr = ref; index < 4; index++, ptr++)
        printf("%d %d\n", ref[index], *ptr);
    return 0;
}

2.在复习题1中,ref有多少个元素?

3.在复习题1中,ref的地址是什么?ref + 1是什么意思?++ref指向什么?

4.在下面的代码中,*ptr*(ptr + 2)的值分别是什么?

  a.

int *ptr;
int torf[2][2] = {12, 14, 16};
ptr = torf[0];

  b.

int * ptr;
int fort[2][2] = { {12}, {14,16} };
ptr = fort[0];

5.在下面的代码中,**ptr**(ptr + 1)的值分别是什么?

  a.

int (*ptr)[2];
int torf[2][2] = {12, 14, 16};
ptr = torf;

  b.

int (*ptr)[2];
int fort[2][2] = { {12}, {14,16} };
ptr = fort;

6.假设有下面的声明:

int grid[30][100];

  a.用1种写法表示grid[22][56]的地址

  b.用2种写法表示grid[22][0]的地址

  c.用3种写法表示grid[0][0]的地址

7.正确声明以下各变量:

  a.digits是一个内含10个int类型值的数组

  b.rates是一个内含6个float类型值的数组

  c.mat是一个内含3个元素的数组,每个元素都是内含5个整数的数组

  d.psa是一个内含20个元素的数组,每个元素都是指向int的指针

  e.pstr是一个指向数组的指针,该数组内含20个char类型的值

8.

  a.声明一个内含6个int类型值的数组,并初始化各元素为12481632

  b.用数组表示法表示a声明的数组的第3个元素(其值为4

  c.假设编译器支持C99/C11标准,声明一个内含100个int类型值的数组,并初始化最后一个元素为-1,其他元素不考虑

  d.假设编译器支持C99/C11标准,声明一个内含100个int类型值的数组,并初始化下标为510111213的元素为101,其他元素不考虑

9.内含10个元素的数组下标范围是什么?

10.假设有下面的声明:

float rootbeer[10], things[10][5], *pf, value = 2.2;
int i = 3;

判断以下各项是否有效:

  a.rootbeer[2] = value;

  b.scanf("%f", &rootbeer );

  c.rootbeer = value;

  d.printf("%f", rootbeer);

  e.things[4][4] = rootbeer[3];

  f.things[5] = rootbeer;

  g.pf = value;

  h.pf = rootbeer;

11.声明一个800×600的int类型数组。

12.下面声明了3个数组:

double trots[20];
short clops[10][30];
long shots[5][10][15];

  a.分别以传统方式和以变长数组为参数的方式编写处理trots数组的void函数原型和函数调用

  b.分别以传统方式和以变长数组为参数的方式编写处理clops数组的void函数原型和函数调用

  c.分别以传统方式和以变长数组为参数的方式编写处理shots数组的void函数原型和函数调用

13.下面有两个函数原型:

void show(const double ar[], int n);         // n是数组元素的个数
void show2(const double ar2[][3], int n);    // n是二维数组的行数

  a.编写一个函数调用,把一个内含8392的复合字面量传递给show()函数。

  b.编写一个函数调用,把一个2行3列的复合字面量(839作为第1行,541作为第2行)传递给show2()函数。

1.修改程序清单10.7的rain.c程序,用指针进行计算(仍然要声明并初始化数组)。

2.编写一个程序,初始化一个double类型的数组,然后把该数组的内容拷贝至3个其他数组中(在main()中声明这4个数组)。使用带数组表示法的函数进行第1份拷贝。使用带指针表示法和指针递增的函数进行第2份拷贝。把目标数组名、源数组名和待拷贝的元素个数作为前两个函数的参数。第3个函数以目标数组名、源数组名和指向源数组最后一个元素后面的元素的指针。也就是说,给定以下声明,则函数调用如下所示:

double source[5] = {1.1, 2.2, 3.3, 4.4, 5.5};
double target1[5];
double target2[5];
double target3[5];
copy_arr(target1, source, 5);
copy_ptr(target2, source, 5);

copy_ptrs(target3, source, source + 5);

3.编写一个函数,返回储存在int类型数组中的最大值,并在一个简单的程序中测试该函数。

4.编写一个函数,返回储存在double类型数组中最大值的下标,并在一个简单的程序中测试该函数。

5.编写一个函数,返回储存在double类型数组中最大值和最小值的差值,并在一个简单的程序中测试该函数。

6.编写一个函数,把double类型数组中的数据倒序排列,并在一个简单的程序中测试该函数。

7.编写一个程序,初始化一个double类型的二维数组,使用编程练习2中的一个拷贝函数把该数组中的数据拷贝至另一个二维数组中(因为二维数组是数组的数组,所以可以使用处理一维数组的拷贝函数来处理数组中的每个子数组)。

8.使用编程练习2中的拷贝函数,把一个内含7个元素的数组中第3~第5个元素拷贝至内含3个元素的数组中。该函数本身不需要修改,只需要选择合适的实际参数(实际参数不需要是数组名和数组大小,只需要是数组元素的地址和待处理元素的个数)。

9.编写一个程序,初始化一个double类型的3×5二维数组,使用一个处理变长数组的函数将其拷贝至另一个二维数组中。还要编写一个以变长数组为形参的函数以显示两个数组的内容。这两个函数应该能处理任意N×M数组(如果编译器不支持变长数组,就使用传统C函数处理N×5的数组)。

10.编写一个函数,把两个数组中相对应的元素相加,然后把结果储存到第3个数组中。也就是说,如果数组1中包含的值是2458,数组2中包含的值是1046,那么该函数把34914赋给第3个数组。函数接受3个数组名和一个数组大小。在一个简单的程序中测试该函数。

11.编写一个程序,声明一个int类型的3×5二维数组,并用合适的值初始化它。该程序打印数组中的值,然后各值翻倍(即是原值的2倍),并显示出各元素的新值。编写一个函数显示数组的内容,再编写一个函数把各元素的值翻倍。这两个函数都以函数名和行数作为参数。

12.重写程序清单10.7的rain.c程序,把main()中的主要任务都改成用函数来完成。

13.编写一个程序,提示用户输入3组数,每组数包含5个double类型的数(假设用户都正确地响应,不会输入非数值数据)。该程序应完成下列任务。

  a.把用户输入的数据储存在3×5的数组中

  b.计算每组(5个)数据的平均值

  c.计算所有数据的平均值

  d.找出这15个数据中的最大值

  e.打印结果

  每个任务都要用单独的函数来完成(使用传统C处理数组的方式)。完成任务b,要编写一个计算并返回一维数组平均值的函数,利用循环调用该函数3次。对于处理其他任务的函数,应该把整个数组作为参数,完成任务c和d的函数应把结果返回主调函数。

14.以变长数组作为函数形参,完成编程练习13。

[1] 在最后一次while循环中执行完start++;后,start的值就是end的值。——译者注


相关图书

代码审计——C/C++实践
代码审计——C/C++实践
C/C++代码调试的艺术(第2版)
C/C++代码调试的艺术(第2版)
大规模C++软件开发 卷1:过程与架构
大规模C++软件开发 卷1:过程与架构
C/C++程序设计竞赛真题实战特训教程(图解版)
C/C++程序设计竞赛真题实战特训教程(图解版)
C/C++函数与算法速查宝典
C/C++函数与算法速查宝典
C程序设计教程(第9版)
C程序设计教程(第9版)

相关文章

相关课程