C程序设计新思维

978-7-115-38628-1
作者: 【美】Ben Klemens
译者: 赵铁成徐波
编辑: 胡俊英陈冀康
分类: C语言

图书目录:

详情

本书汇集了编写高效率、实用的C程序所需的工具和技巧。读者将能够学会如何实用shell、makefiles、漂亮的文本编辑器、调试器、内存管理等工具和技术。这本书与那些教科书的不同,在于对这门语言及其开发环境进行了拾遗补漏。书中讲解的方式是,使用提供了链表结构和XML解析器的现成的库,而不是把这些从头再写一次。这本书也体现了如何编写易读代码和用户友好的函数接口。

图书摘要

Simplified Chinese Edition, jointly published by O’Reilly Media, Inc. and Posts & Telecom Press, 2015. Authorized translation of the English edition, 2013 O’Reilly Media, Inc., the owner of all rights to publish and sell the same.

Copyright© 2013 by O’Reilly Media, Inc.

All rights reserved including the rights of reproduction in whole or in part in any form.

本书中文简体版由O’Reilly Media, Inc.授权人民邮电出版社出版。未经出版者书面许可,对本书的任何部分不得以任何方式复制或抄袭。

版权所有,侵权必究。


C语言已经有40年的历史了。经过长时间的发展和普及,C语言的应用场景有了很大的变化,一些旧观念应该被淡化或者不再被推荐。

本书展现了传统C语言教科书所不具有的最新的相关技术。全书分为开发环境和语言两个部分,从编译、调试、测试、打包、版本控制等角度,以及指针、语法、文本、结构、面向对象编程、库等方面,对C程序设计的核心知识进行查缺补漏和反思。本书鼓励读者放弃那些对大型机才有意义的旧习惯,拿起新的工具来使用这门与时俱进的简洁语言。

本书适合有一定基础的C程序员或C语言学习者阅读,也适合想要深入理解C语言特性的读者参考。


Ben Klemens 自从于加州理工学院获得社会科学博士后就一直从事统计分析和人口的计算机辅助建模工作。他的观点是,写代码一定应该是趣味横生的,并先后非常愉快地为布鲁金斯学会、世界银行、美国国家精神健康中心等机构写过分析和建模代码(主要是C代码)。他作为布鲁金斯学会的非常驻研究员,并与自由软件基金会一道,做了很多工作来确保有创意的程序员保留其作品使用权的权利。他目前为美国联邦政府工作。


本书封面上的动物是斑袋貂(Spilocuscus maculatus),一种生活在热带澳大利亚、新几内亚和附近的小岛的雨林和红树林的有袋目哺乳动物。它头部圆形,耳朵小而隐藏,皮毛厚实,有一条有助于攀援的可卷曲的尾巴。卷曲的尾巴是典型的特征;尾巴上部贴近身体的部分覆盖绒毛,而下半部分的内侧覆盖粗糙的鳞甲,适合抓住树枝。它的眼睛可以看到黄色、橙色到红色,视野像蛇一样狭窄。

斑袋貂通常非常胆小,所以很少被人发现。它是夜间活动的,在夜间捕猎和进食,白天在树枝上自己搭建的巢穴睡眠。它行动缓慢,有时很懒——因此经常被误认为是树懒、负鼠,甚至是猴子。

典型的斑袋貂是独居动物,独自筑巢和进食。与其他个体,特别是具有竞争性的雄性之间的互动,常是有侵略性和冲突性的。雄性斑袋貂以气味标识它们的领地以警告其他雄性,从他们的身体和气味排泄腺散发出穿透性的麝香气味。他们在树枝和嫩枝上散布唾液以通知他们领地的其他个体以缓冲相互交往。如果他们在自己的领地遇到其他的雄性个体,他们会发出吠叫、咆哮和嘶叫的声音,并且直立起来保护他们的领地。

斑袋貂具有非特异性的牙齿排列,使得他们可以食用很宽范围的植物产品。已知它们可以吃花朵、小动物,并且偶尔也吃蛋。斑袋貂的天敌包括蟒蛇和一些掠食性鸟类。


Is it really punk rock

Like the party line?

它真的是朋克摇滚么,

就像政治路线?

——选自Wilo的歌曲“Too Far Apart(遥远)”

C仅有为数不多的关键词,并且显得略微粗糙,但是它很棒[1]!你可以用C来做任何事情。它就像一把吉他上的C、G和D弦,你可以很快就掌握其基本原理,然后就得用你的余生来打磨和提高。不理解它的人害怕它的威力,并认为它粗糙得不够安全。实际上它在所有的编程语言排名中一直都被认为是最流行的语言,所以根本不需要任何企业或组织花钱去推广它。

这门语言已经有40年的历史了,可以说已经进入了中年。创造它的人是少数对抗管制的人,遵从完美的punk rock精神;但那是20世纪70年代的事情了,现在它已历尽沧桑,成为社会的主流。

当punk rock变成主流的时候人们会怎样?在其从20世纪70年代出现后的几十年里,punk rock已经从边缘走向中心:The Clash、The Offspring、Green Day和The Strokes等乐队已经在全世界卖出了几百万的唱片(此处仅作有限的举例),我也在家附近的超市里听过被称为grunge的一些精简乐器版本的punk rock分支。Sleater-Kinney乐队的前主唱还经常在自己那个很受欢迎的喜剧节目中讽刺punk rocker音乐人。对这种持续的进化,一种反应是画一条界限,将原来的风格称为punk rock,而将其余的东西称为面向大众的粗浅的punk。传统主义者还是可以播放来自20世纪70年代的唱片,但如果唱片的音轨磨损了,他们可以购买数码修复的版本,就像他们为自己的小孩购买Ramones牌的连帽衫一样。

外行是不明白的。有些人听到punk这个词脑海里就勾画出20世纪70年代并不具有的景象,经常的历史错觉就是那个时代的孩子们真的在做什么不同的事情。喜欢欣赏1973年Iggy Pop 的黑胶唱片的传统主义者一直是那么兴趣盎然,但是他们有意无意地加强了那种punk rock已经停滞不前的刻板印象。

回到C的世界里,这里既有挥舞着ANSI’89标准大旗的传统主义者,也有那些拥抱变化,甚至都没有意识到如果回到20世纪90年代,他们写的代码都不可能被成功编译与运行的人。外行人不会知道个中缘由。他们看到从20世纪80年代起至今还在印刷的书籍和20世纪90年代起至今还存于网上的教程,他们听到的都是坚持当年的软件编写方式的、死硬的传统主义者的言论,他们甚至都不知道语言本身和别的用户都在一直进化。非常可惜,他们错过了一些好东西。

这是一本打破传统并保持C语言punk精神的书。我对将本书的代码和1978年Kernighan和Ritchie出版的书[2]中的C标准进行对比毫无兴趣。既然连我的电话机都有512M字节内存,为什么还在我的书里花费章节讲述如何为可执行文件减少几K的字节呢?我正在一个廉价的红色上网本上写这本书,而它却可以每秒运行3 200 000 000条指令,那为什么我还要操心8位和16位所带来的一个操作的差异呢?我们更应该关注如何做到快速编写代码并且让我们的合作者们更容易看懂。毕竟我们是在使用C语言,所以我们那些易读但是并没有被完美优化的代码运行起来还是会比很多繁琐的语言明显地快。

Q:这本书与其他书有什么不同?

A:C语言的教科书都非常相像(我曾经读过很多这样的教科书,包括[Griffiths, 2012]、[Kernighan,1978]、[Kernighan,1988]、[Kochan,2004]、[Oualline,1997]、[Perry,1994]、[Prata,2004]和[Ullman,2004])。多数教材都是在C99标准发布并简化了很多用法之后写成的,你可以看到现在出版的这些教材的第N版仅仅在一些标注上做了一点说明,而不是认真反思了如何使用这门语言。他们都提到你可以拥有一些库来编写你自己的代码,但是他们都是在现在常用的、可以保障库的可靠性和可移植性的安装与开发环境之前编写的。那些教科书现在仍然有效并且具有自己的价值,但是现代的C代码已经看起来和那些教科书里面的不太一样了。

这本书与那些教科书的不同,在于对这门语言及其开发环境进行了拾遗补漏。书中讲解的方式是,使用提供了链表结构和XML解析器的现成的库,而不是把这些从头再写一次。这本书也体现了如何编写易读代码和用户友好的函数接口。

Q:这本书的目标读者是谁?我需要是一个编程大师么?

A:你必须有某种语言的编程经验,或许是Java,或者类似Perl的某种脚本语言。这样我就没有必要再和你讲为什么你不应该写一个很长的没有任何子函数的函数了。

你最好有一定的C语言基础,但是没有必要特别精通——既然我将描述那些细节,如果你从来没有学过它们也许更好。如果你是白纸一张,只是对C语法充满敬意,那它还是非常简单易学的,而且你也可以用搜索引擎找到很多在线教材;如果你有其他语言的经验,用一两个小时就可以基本掌握。

请允许我介绍我写的另一本关于统计和科学计算的教科书,Modeling with Data [Klemens,2008]。那本书不仅提供了很多关于如何处理数值和统计模型的内容,它还可以用作一本独立的C语言的教材,并且我认为那本书还避免了很多早期教材的缺点。

Q:我是个编写应用软件的程序员,不是一个研究操作系统内核的人。为什么我应该用C而不是像Python这类可以快速编程的脚本语言呢?

A:如果你是一个应用软件程序员的话,这本书就是为你准备的。我知道人们经常认定C是一种系统语言,这让我觉得真是缺少了点punk的反叛精神——他是谁啊?要他告诉我们要用什么语言?

像“我们的语言几乎和C一样快,但更容易编写”这样的言论很多,简直成了刻板的套话。好吧,C显然是和C一样快,并且这本书的目的是告诉你C也比以前的教科书所暗示的那样容易使用。你没必要使用malloc,也没必要向20世纪90年代的系统程序员那样深深卷入内存管理,我们已经有处理字符串的手段,甚至核心语法也进化到了支持更易读的代码的境界。

我当初正式学习C语言是为了加速一个用脚本语言R编写的仿真程序。和众多的脚本语言一样,R具有C接口并且鼓励用户在宿主语言[4]太慢的时候使用。最终我的程序里有太多的从R语言到C语言的调用,最后我索性放弃了宿主语言。随后发生的事情你已经知道,就是我在写这本关于现代C语言技术的书。

Q:如果原本使用脚本语言的应用软件程序员能喜欢这本书当然好,但我是一名内核黑客。我五年级的时候就自学了C语言,有时做梦都在正确编译。那这本书还有什么新鲜的么?

A:C语言在过去的20年里真的进化了很多。就像我下面要讨论的那样,像“要支持所有C编译器”等必要的工作准则也变化了不少,因为自从ANSI标准发布后又发布了两个新的C语言标准。也许你应该读一下第10章,找找有什么能叫你感到惊讶的。

并且,开发环境也进化升级了。Autotools已经改变了代码发布的方式,意味着可以更加可靠地调用其他的库,意味着我们的代码可以花费更少的时间在重建常用结构和函数上,而是更多地调用本书下面将讨论的库。

Q:我实在忍不住要问,为什么这本书中差不多有三分之一的篇幅都没有C代码?

A:的确。良好的C实践需要具备精良的C工具[5]。如果你没有使用调试器(独立的或者集成在你的IDE中),你就是在自讨苦吃。如果你告诉我追踪内存的泄露是不可能的,那么就意味着你还没有听说过Valgrind,一个用来确切地指出是哪一行产生内存泄漏并发生错误的系统。Python及其附带工具有内建的包管理器;而本书将告诉你,属于C的、事实上的跨平台打包系统,即Autotools,它是一个独立的系统。

如果你在使用一个不错的集成开发环境(IDE)作为这些工具的调用界面,你仍然可以从了解“IDE如何处理环境变量以及其他隐藏的细节,同时还能为你处理错误抛出”等问题中受益。

Q:你所谈论的一些工具有点老了。难道没有更现代的工具能替代这些基于shell的工具么?

A:如果我们嘲笑那些仅仅因为事物是新的就对其抵制的人,那么我们也没有理由仅仅因为事物是旧的就加以抵制。

其实很容易找到可靠的来源证明第一件六弦的吉他出现在1200年左右,第一个四弦的小提琴出现在大约1550年,带键盘的钢琴出现在1700年左右。有趣的是,你今天听到的多数(如果不是全部)音乐都与以上乐器中的某种有关。Punk rock当初并没有拒绝吉他,只不过用得更加有创造性,比如将吉他的输出接连到新的滤波器上。

Q:我能上Internet,一两秒的功夫就能找到命令和语法的细节。那么说真的,为什么我还要读这本书?

A:的确。在 Linux或Mac机器上你只要用一个带有 man operator 的命令行就能查到运算符优先级表,那么我为什么还要把它放在这本书里?

我可以和你上同样的Internet,我甚至花了很多的时间阅读网上的内容。所以我有了一个之前没谈到的、准备现在讲的好主意:当介绍一个新工具的时候,比如gprof或者GDB,我给你那些你必须知道的方向,然后你可以去自己习惯的搜索引擎中查找相关问题。这也是其他教科书所没有的。

除非特别地说明,本书的内容遵从ISO C99和C11标准。为了使你明白这意味着什么,下面给你一点C语言的历史背景,让我们回顾一下主要的C标准(而忽略一些小的改版和订正)。

K&R(1978前后)

Dennis Ritchie、Ken Thompson以及一些其他的贡献者发明了C语言并编写了Unix操作系统。Brian Kernighan和Dennis Ritchie最终在他们的书中写下了第一版关于这个语言的描述,同时这也是C语言的第一个事实上的标准[Kernighan,1978]。

ANSI C89

后来Bell实验室向美国国家标准协会(ANSI)交出了这个语言的管理权。1989年,ANSI出版了他们的标准,并在K&R的基础上做出了一定的提高。K&R的书籍的第二版包含了这个语言的完整规格,也就是说在几万名程序员的桌子上都有这个标准的印刷版[Kernighan,1988]。1990年ANSI标准被ISO基本接受,没有做重大的改变,但是人们似乎更喜欢用ANSI 89这个词来称呼这个标准(或者用来做很棒的T恤衫标语)。

10年过去了。C成为了主流,考虑到几乎所有的PC、每台Internet服务器的基础代码或多或少都是用C编写的,这已经是人类的努力可以达到的最大的主流了。

在此期间,C++分离出来并大获成功(虽然也不是那么大)。C++是C身上发生的最好的事情了。当所有其他的语言都在试图添加一些额外的语法以跟随面向对象的潮流,或者跟随其作者脑袋里的什么新花招的时候,C就是恪守标准。需要稳定和可移植性的人使用C,需要越来越多特性以便可以像在百元大钞里洗澡一样无休止地沉溺于其中的人使用C++,这样每个人都很高兴。

ISO C99

10年之后C标准经历了一次主要的改版。为数值和科学计算增添了一些附加功能,比如复数的标准数据类型以及相关的函数。一些从C++中产生的便利措施被采纳,包括单行注释(实际上起源于C的前期语言,BCPL),以及可以在for循环的开头声明变量。因为一些新添加的关于如何声明和初始化的语法,以及一些表示法上的便利,使用泛型函数变得更加容易。出于安全考量以及并不是所有人都说英语原因,一些特性也被做了调整。

当你想着单单C89的影响就有那么大,以及全球是如何运行C代码时,你很难想出ISO能够拒绝某种新事物而不被广泛批评——甚至拒绝做出改变也会被唾骂。的确,这个标准是有争论的。有两种常用的方式来表达一个复数(直角坐标和极坐标)——那么ISO会采用哪一个?既然所有的好代码都没采用变长的宏输入机制来编写,为什么我们还需要这个机制?换句话说,纯洁主义者批评ISO是在背叛那些为C语言增加更多特性的趋势。

当我写这本书的时候,多数的编译器在支持C99的同时都增加或减少了一些特性;比如long double 类型看起来就引发了很多问题。然而,这里还是有一个明显的特例:Microsoft至今拒绝在其Visual Studio C++编译器中添加C99支持。在“1.2 在Windows中编译C”节中讲述了几种在Windows环境中编译C的方法,所以不能使用Visual Studio最多也就是有点不方便,并且这好比一个行业奠基人告诉我们不能使用ISO标准的C,而只能支持punk rock。

C11

觉察到了对所谓背叛行业趋势的批评,ISO组织在第三版的标准中做出了为数不多的几个重大改变。我们有了可以编写泛型函数的方法,并且对安全性和非英语支持做出了进一步的改进。

我是在2012年写的这本书,就在C11标准在2011年12月发布之后的不久,此时已经有了一些来自编译器和库的支持。

事物的规律就是这样,伴随着C语言的进化,这门语言同时也和Unix操作系统一起协同发展,并且你将会从本书中看到,这种相互协同的发展对日常工作是有意义的。如果某件事情在Unix命令行中很容易利用,那么很有可能是因为这件事情在C中也很容易实现;某些Unix工具之所以存在也是为了帮助C代码的编写。

Unix

C和Unix都是在20世纪70年代由Bell实验室设计的。在20世纪的多数时间里,Bell一直面临垄断调查,并且Bell有一项与美国联邦政府达成的协议,就是Bell将不会把自身的研究扩张到软件领域。所以Unix被免费发放给学者们去剖析和重建。Unix这个名字是一个商标,原本由Bell实验室持有,但随后就像一张棒球卡一样在数家公司之间转卖。

随着其代码被不断检视、重新实现,并被黑客们以不同的方式改进,Unix的变体迅速增加。因此带来了一点不兼容的问题,即程序或脚本变得不可移植,于是标准化工作的迫切性很快就变得显而易见。

POSIX

这个标准最早由电气电子工程师协会(IEEE)在1988年建立,提供了一个类Unix操作系统的公共基础。它定义的规格中包括shell脚本如何工作,像ls、grep之类的命令行应该如何工作,以及C程序员希望能用到的一些C库等。举个例子,命令行用户用来串行运行命令的管道机制被详细地定义了,这意味着C语言的popen(打开管道)函数是POSIX标准,而不是ISO C标准。POSIX标准已经被改版很多次了;本书编写的时候是POSIX:2008标准,这也是当我谈到POSIX标准的时候所指代的。POSIX标准的操作系统必须通过提供C99命令来提供C编译器。

这本书用到POSIX标准的时候,我会告诉大家。

除了来自Microsoft的一系列操作系统产品,当前几乎所有你可以列举出的操作系统都是建立在POSIX兼容的基础上:Linux、Mac OS X、iOS、WebOS、Solaris、BSD——甚至Windows Servers也提供POSIX子系统。对于那些例外的操作系统,“1.2在Windows中编译C”节将告诉你如何安装POSIX子系统。

最后,有两个POSIX的实现版本因为有较高的流行度和影响力,值得我们注意。

BSD

在Bell实验室发布Unix给学者们剖析之后,加州大学伯克利分校的一群好人做了很多明显的改进,最终重写了整个Unix基础代码,产生了伯克利软件发行版(Berkeley Software Distribution,BSD)。如果你正在使用一台Apple公司生产的电脑,你实际上在使用一个带有迷人图形界面的BSD。BSD在几个方面超越了POSIX,因此我们还会看到,有一两个函数虽然不属于POSIX,但是如此有用而不容忽略(其中最重要的救命级函数是asprintf)。

GNU

这个缩写代表GNU’s Not Unix,代表了另一个独立实现和改进Unix环境的成功故事。大多数的Linux发行版使用GNU工具。有趣的是,你可以在你的POSIX机器上使用GNU编译器组合(GNU Compiler Collection,gcc)——甚至BSD也用它。并且,gcc对C和POSIX的几个方面做了一点扩充并成为事实上的标准,当本书中需要使用这些扩充的时候我会加以说明。

从法律意义上说,BSD授权比GNU授权稍微宽容。由于很多群体对这些授权的政治和商业意义深感担心,实际上你会经常发现多数工具同时提供GNU和BSD版本。例如,GNU 的编译器组合(gcc)和BSD 的clang都可以说是顶级的C编译器。来自两个阵营的贡献者紧密跟随对方的工作,所以我们可以认为目前存在的差异将会随着时间逐渐消失。

法律解读

美国法律不再提供版权注册系统:除了很少的特例,只要某人写下什么就自然获得了该内容的版权。

发行某个库必然要通过将其从一个硬盘复制到另一个硬盘这样的操作,而且即便带有一点争议,现实中还是存在几种常用机制允许你有权利复制一个有版权的内容。

  • GNU公共许可证:其允许无限制地复制和使用源代码和可执行文件。不过有一个前提:如果你发行一个基于GPL许可证的源代码程序或库,你也必须将你的程序的源代码伴随程序发行。注意,如果你是在非商业环境下使用这样的程序,你不需要发行源代码。像用gcc编译你的源代码之类的运行GPL许可证的程序本身并不会使你具有发行源代码的义务,因为这个程序的数据(比如你编译出的可执行文件)并不认为是基于或派生于gcc的。[例如:GNU科学计算库。]
  • 次级GPL许可证:与GPL有很多相似,但是具有一个明显的区别:如果你以共享库的方式连接一个LGPL库,你的代码不算做派生的代码,也没有发行源代码的义务。也就是说,你可以采用不暴露源代码的方式发行一个与LGPL库连接的程序。[例如:Glib。]
  • BSD许可证:要求你在放弃对已带有BSD许可部分的版权的同时,保持自己工作的版权,但是不要求你再发行那些BSD许可的源代码。[例如Libxml2,就是带有与BSD许可类似的MIT许可证。]

本书使用如下排版约定:

斜体(italic)

用来表示新术语、URL、email地址、文件名、文件扩展名等。

等宽字体()

用来表示程序列表,同时在段落中引用的程序元素(例如变量、函数名、数据库、数据类型、环境变量、声明和关键字等)也用该格式表示。

等宽黑体()

用于表示需要用户逐字符输入的命令或其他文本。

等宽斜体()

用于表示应该以用户提供的值或根据上下文决定的值加以替换的文本。

这个图标代表诀窍、建议和一般性的说明。


自己动手:

这里是一个练习,帮助你从实践中学习,并给你一个把手放在键盘上的指标。


这个图标表示警告或错误。

这是一本试图帮助你解决实际问题的书。总的来说,你可以在你的程序和文档中用本书的代码。除非你复制了太多的部分,你并不需要得到我的许可。例如,你的程序里使用了几段本书的代码并不需要得到许可。但是销售或发行含有O’Relly出版书籍中的源代码的确需要许可。通过引用本书及其源代码的方式来回答一个问题不需要得到许可。在你的文档中合并大量来自本书的代码不需要得到许可。

本书中用到的示范代码可以在以下地址找到:http://excamples.oreilly.com/063620025108/。

我们感谢,但并不要求,您的引用。一个引用通常包括书名、作者、出版商以及ISBN书号。例如,“21世纪C语言,Ben Klemens(O’Reilly)。版权2013 Ben Klemens, 978-1-449-32714-9。”

如果你感觉你对示范代码的使用可能超出了上面列举的合理情况,你可以随时通过以下邮件地址联系我:permissions@oreilly.com。

Safari网上书店(www.safaribooksonline.com)是一个点播方式的数字图书馆,可以下载世界顶级技术和商业作家的专业书籍和视频。

专业技术人员、软件开发者、Web设计者、商业和创意人士使用Safari网上书店作为他们首选的研究、解决问题、学习和认证培训的信息资源。

Safari网上书店提供了为组织、政府机关和个人提供了一系列的产品组合和定价套餐。订阅人可以从一个可统一检索的出版商的数据库中找到几千本书籍、培训视频和预发布的手稿,比如O’Reilly Medial,Prentice Hall Professional、Addison-Wesley Professional、Microsoft Press、Sams、Que、Peachpit Press、Focal Press、Cisco Press、John Wiley & Sons、Syngress、Morgan Kaufmann、IBM Redboks、Packet、Adobe Press、FT Press、Apress、Manning、New Riders、McGraw-Hill、Jones & Bartlett、Course Technology,等等。请登录其网站了解Safari网上书店的详情。

如有对本书的评论或问题,请联系出版商:

美国:

O’Reilly Media, Inc.

1005 Gravenstein Highway North

Sebastopol, CA 95472

中国:

北京市西城区西直门南大街2号成铭大厦C座807室(100035)

奥莱利技术咨询(北京)有限公司

我们为本书准备了一个网页,其中列举了订正、例子和其他附加信息,地址是:http://oreil.ly/21st_century_c

如若评论或询问关于本书的技术问题,请将电子邮件发送至:bookquestions@oreilly.com。

关于我们出版的书籍、教材、会议和新闻的进一步的信息,请访问我们的网站:http://www.oreilly.com

我们在Facebook上的地址:http://facebooks/com/oreilly

请在Twitter上关注我们:http://twitter/com/oreillymedia

请观看我们在YouTube上的视频:http://www.youtube.com/oreillymedia

Nora Albert:慷慨的支持,豚鼠。

Bruce Field、Dave Kitabjian、Sarah Weissman:严谨和彻底的审阅。

Patricj Hall:Unicode知识。

Nathan Jepson和Shawn Wallace:社论。

Rolando Rodriguez:测试、试用和调查。

Rachel Steely:出品。

Ulrik Sverdrup:指出我们可以使用重复指定初始化来设定默认值。

[1] it rocks,此处原文为双关语,借用英语中rock的不同含义,即“摇滚乐”和“很棒”。标题中的punk rock为流行于20世纪70年代的一种摇滚乐风格,以狂野反叛为特色,国内也称为“朋克”。——译者注

[2] 从下文可以看到,该书的出版被认为是C语言诞生的标志性事件,业内常成为K&R——译者注

[3] 这里作者将问题与解答比喻为C语言函数入口的参考引用——译者注

[4] 这里指调用C语言的语言——译者注

[5] 即工欲善其事,必先利其器——译者注


在脚本语言花园围墙外的旷野里,有大量解决那些C语言的烦恼的工具,当然你必须自己去寻找。我其实觉得你必须这么做,因为其中一些工具对于你轻松编写代码是完全必要的。如果你不使用调试器(无论是独立存在的还是集成于IDE环境的),你简直就是对自己施暴。

当你编译自己的程序的时候,如果在使用外部库的时候感觉不是很方便,就可能很快失去兴趣。虽然这并不难,但你还是要学学如何去做。幸运的是,你面前已经有了这本书。

本书第一部分的内容简述如下:

第1章讲述如何设定基本开发环境,包括找到一个包管理器并利用这个工具安装所用的工具。这些背景知识足够我们体会有趣的内容,比如用从别处得到库来编译程序。整个过程非常标准化,包括一点环境变量的设定和配置。

第2章介绍调试、文档管理和测试工具,因为直到调试、编档和测试的时候才能看到一段代码的好处。

第3章讨论Autotools,这是一个用于打包并发布你的程序的工具。这章选择了一种比较详尽的介绍方法,因此还包含了编写shell脚本和makefile的方法。

我们可不能像某些人那样把生活搞得太复杂。第4章介绍Git,一个用来追踪你和同事们的硬盘文件版本的微小改变的工具,以便使你尽可能简单地融合不同的版本。

其他的语言也是现在C语言开发环境的重要因素,因为太多的语言提供了C接口。第5章提供了一些如何编写这些接口的建议,并给你一个基于Python的扩展例子。


小心啦,亲爱的,因为我要用点技术。

——选自Iggy Pop的歌曲“Search and Destroy(寻找与毁灭)”

仅仅有C标准库是不足以成就大事的。

实际上C的生态环境已经扩展到了C标准之外,也就是说,如果你想顺利完成本书的练习(即“自己动手”),你就必须了解如何轻松调用那些常用却并非ISO标准的库。不幸的是,这一点恰恰被多数教科书所忽略掉,而需要你自己去寻求解决办法。这也是为什么你发现C的诽谤者们会告诉你一些自相矛盾的言论,比如C已经有40年的历史了,所以你必须自己完成程序的每个部分——他们恐怕根本从来没真正连接过一个库。

本章概要如下:

针对IDE用户,有一点需要特别说明:你可能不会去使用make命令,但并不因此就说这一章和你没有关系。因为在任何编译方法中make命令都会被执行,所以IDE环境也有一个类似的方法。如果你知道make命令是如何工作的,将更加容易去配置IDE环境。

嘿,老兄,如果你还没有在使用包管理器的话,那你可真的错过了好东西。

有几个原因促使我讲述包管理器。

首先,你们中的一些人可能连基本的包管理工具都没有安装。为了这部分读者,我把这部分放在本书的最开始,因为你们需要尽快得到这些工具。一个好的包管理器将为你快速安装POSIX子系统、任何你听说过的语言的编译器、游戏、常用的办公室效率工具,以及几百种C库,等等。

其次,作为一个C程序员,包管理器是我们用来安装常用工作库的关键手段。

再次,当你写过一定的代码,你将开始渴望发布你的代码,从而完成一个从包的下载者到包的贡献者的嬗变。本书将送你一程,为你讲解怎么为自动安装过程准备安装包,这样当一个包库的管理者决定在包库中包含你的代码的时候,他也能顺利地制作最终的包。

如果你是一位Linux用户,你的计算机已经带了包管理器,而且你已知道使用它获取软件是多么容易。对于Windows用户,我将详细讲解Cygwin。Mac用户有几种选择,比如Fink和Macports。所有的Mac下的包管理器都依赖于Apple的Xcode包,也就是一般在系统安装光盘中所使用的(或者含有安装程序的目录,视情况而定),或通过注册为Apple的开发者而得到。

有了包管理器,那么我们需要什么包呢?这里有一个常见的列表。因为每个系统有不同的组织模式,他们中的一些可能也是以不同的方式组合起来的、作为基本包默认安装的,或者有着奇怪的名字。如果你对一个包不是很确定,不妨下载下来安装一下,毕竟系统由于安装了太多的软件包导致不稳定或者变慢的时代已经过去。不过,你可能没有足够的带宽(或者甚至没有足够的硬盘空间)来安装每个可以找到的包,所以还是需要一点判断和选择的。要是你发现错过了什么东西,随时可以折返回来寻找。必须准备的包如下。

还有一些软件你应该安装,这些工具在随后的几章里我们会频繁用到:

当然,还有C库这些库可以使你免于“重新发明轮子”的麻烦(或者,用更加精确的比喻:重新发明火车头)。你可能想要更多的库,但是本书所使用的一般也就限于如下几种:

C库包的命名机制并没有统一标准,所以你必须弄清楚你的包管理器把一个库打进子包的习惯。典型的习惯是,一方面为最终用户准备一个包,另一方面同时为将要在自己程序里使用这些库的开发者也准备一个包;所以要确认选定了基本包和-dev或-devel的包。有的系统把文档也分散进一个单独的包中。也有的要求你单独下载调试符号表。而且一旦需要,gdb工具将在第一次运行并在发现你缺少调试符号表的时候引导你逐步完成。

如果你正在使用POSIX系统,那么完成前面的工具安装后,你已经拥有一个完整的开发系统,可以进入编程阶段了。对于Windows用户,我们将简短说明一下这个安装的系统是如何与Windows主系统互动的。

在多数系统中,C享有一个中心的、贵宾礼遇的地位,以至于所有其他工具都处于从属的地位;但是在Windows机器中,C语言却被奇怪地忽略了。

所以我不得不花点时间讨论如何来准备好一台Windows机器以便用来写C程序。如果你现在不需要在Windows机器上编程,你可以直接跳到“1.3库的路径”。

这并非是针对Microsoft的,请不要用这样的角度来理解这一节。我无意去推测Microsoft的动机和商业战略。不过,如果你想在Windows机器上用C来工作,你需要知道实际状况(坦白地说,是不太友好)以及如何应对。

因为C和Unix是共同进化的,很难在谈到其中一个的时候不提及另一个。我个人认为从POSIX开始会比较容易些。并且,读者中那些希望在Windows机器上编译来自其他环境的代码的人会发现这么讲述也很自然。

总的来说,文件系统的世界可以(稍微有点重叠地)分为两个阵营:

POSIX兼容并不一定意味着整个系统的外观和使用习惯都和Unix机器一样。比如,典型的Mac用户并不会意识到他们正在用一个界面漂亮的标准BSD系统,但是知道这一点的用户可以到Accessories->Uilities目录中,打开Terminal程序,并为他们心仪的内容运行ls、grep和make。

说实话,我怀疑很多系统是不是真的100%符合标准的要求(比如要具备一个Fortran′77编译器)。为了我们的目的,我们需要一个可以像POSIX shell那样运行的shell程序、一堆工具(sed、grep、make等)、一个C99编译器,以及一些标准C库之外的附加库,比如fork和iconv。这些都可以被添加进主系统。包管理器相关的脚本、Autotools和几乎所有的编写可移植代码的工具都在某种程度上依赖于上面这些工具,所以即便你不想整天盯着命令行,安装那些工具也是会带来一些方便的。

在服务器级的操作系统和Windows 7的全功能版本中,Microsoft提供了一个叫作SUA的子系统(Subsystem for Unix-based Application)——该系统以前称为INTERIX,用来提供通常的POSIX系统调用、Korn shell,以及gcc。这个子系统一般并不是默认提供的,但是可以作为一个插件安装。但是在其他的Windows版本,包括Windows 8上,并不提供SUA,所以我们不能依赖Microsoft为它自己的操作系统提供一个POSIX子系统。

既然这样,可以尝试Cygwin。

如果你计划从头编译安装Cygwin,你需要按照下面的步骤进行:

1. 写一个Windows下的C库,在该库中提供所有的POSIX函数。这需要你抹平一些Windows和POSIX系统之间的差异性,比如Windows中使用明确的盘符(例如:C:),而POSIX采用一个统一的文件系统。在这种情况下,需要为C:与 /cygdrive/c,D:与 /cygdrive/d等等路径表达之间建立别名。

2. 然后你就可以通过连接这个库来编译POSIX标准的程序了,可以尝试:制作一个Windows版本的ls、bash、grep、gcc、X、rxvt、libglib、perl、python,等等。

3. 一旦你已经编译了几百个程序和库,就需要安装一个包管理器以便用户可以从中选择需要安装的组件。

但作为Cygwin的用户,你所要做的只是从Cygwin的官方网站(http://cygwin.com/)下载该包管理器并安装相应的包。你当然想要前面列表的东西,外加一个体面的终端(试一下RXVT吧,或者安装X子系统并使用xterm),但是你将看到实际上这些奢侈品都与某种开发系统相似。现在你可以编译你的C代码了。

Microsoft在Visual Studio中提供了一个C++编译器,并兼容ANSI C。这是Microsoft提供的唯一一种编译C代码的途径了。很多Microsoft人士明确表示,对C99的支持是不会发生的(更别谈支持C11了)。Visual Studio是主流编译器中唯一至今还停留在C89标准的,因此我们必须从别处找到一个替代品。当然,Cygwin提供了gcc,如果你遵从并安装了Cygwin,那么你已经具有了一个完整的编译环境。

如果你在Cygwin下编译,程序就将依赖于Cygwin的库来提供POSIX函数,即cygwin.dll(不管你的代码实际上是否包含任何POSIX调用)。如果是在一台安装了Cygwin的机器上运行你的程序,那当然不会有问题。用户自然可以点击可执行文件并按照期望的行为运行,因为系统能够找到Cygwin的动态链接库。如果你随程序发布了cygwin1.dll,Cygwin编译的程序也可以在没有Cygwin的情况下运行。

在我的机器上,cygwin的路径是/bin/cygwin1.dll。cygwin1.dll文件有一个与GPL版权类似的授权(见“前言”),因此如果你从Cygwin中剥离发布DLL库,你必须发布你自己程序的源代码[2]。如果这对你是一个问题,那么就必须找到一个不依赖于cygwin1.dll的编译自己程序的方法,比如从代码中移除所有POSIX标准的函数然后使用后面将要讨论的MinGW。可以用cygcheck工具来找出程序所依赖的DLL,验证所生成的可执行文件是否连接了cygwin1.dll。

如果你的程序不需要POSIX函数(比如fork或popen之类),那么你可以用MinGW(Minimalist GNU for Windows),它提供了一个标准的C编译器和一些基础工具。Msys是与MinGW伴生的,提供了另外的一些工具,例如shell。

缺少POSIX风格的外表并不是MinGW的真正问题。Msys提供了一个POSIX shell,或者干脆忘掉命令行并尝试一下Code::blocks,这是一个在Windows上使用MinGW的IDE集成环境。Eclipse是一个功能更加丰富的可以配置为与MinGW一起工作的IDE集成环境,虽然这需要一点配置工作。

不过如果POSIX命令行能让你感觉更舒服些,那就安装Cygwin,下载提供MinGW版本的gcc,用这些编译环境而不是用与POSIX连接的、默认版本的Cygwin gcc。

如果你还没有遇到过Autotools,那也快了。编译包的三个标志性命令:./configure、make和make install。Msys为这样管理包提供了足够的机制。如果你已经下载了从Cygwin的命令行编译的包,那你可以用下面的命令来配置这个包,使用Cygwin的Mingw32编译器来产生与POSIX无关的代码:

然后像通常那样运行make和make install。

在MinGW下编译后,不管是通过命令行还是Autotools,你都会得到一个Windows本地的二进制代码。因为MinGW并不知道cygwin1.dll的存在,你的程序并不会有任何POSIX调用,你得到的将是一个真正的Windows程序,没有人会知道你是从POSIX环境编译出来的。

然而,MinGW的真正问题是缺乏预编译库[3]。如果你想摆脱cygwin1.dll,那么你也不能使用与Cygwin一起发行的libglib.dll版本。你将必须从源代码重新编译GLib成一个Windows DLL——但是Glib在国际化方面依赖GNU的gettext,所以你将先把那个库编译一遍。现代代码依赖于现代的库,所以你可能会发现自己花费了很多时间来处理类似的工作,而在别的系统上可能只是一行包管理器的命令。如果这样,我们就真的如某些人所说,C已经40岁了,你需要从头写每样东西。

所以,下面就是几句忠告。Microsoft已经离开了对话,任由其他人实现后grunge时代[4]的C编译器。Cygwin完成了这些工作并提供了全功能的包管理器,带有可以满足你的多数或全部工作的足够多的库,但这些都是伴随着POSIX风格的代码和Cygwin的DLL的。如果你觉得这是一个问题,那么你将不得不多做很多工作,以重建那些你体面的代码所需要的整个环境和库。

现在我们有了编译器,有POSIX的工具包,还有一个可以用来方便地安装几百个库的包管理器。现在我们着手解决用这些工具来编译我们程序的问题。

我们必须从编译器命令行开始,并很快会陷入一团混乱,但是我们还是可以用三个(有时候是三个半)相对简单的步骤结束。

1. 设置一个编译器配置变量表。

2. 设置一个要连接的库的变量表。所谓的半个步骤是指,有时你不得不设定一个唯一的变量,用来一边编译一边连接;或者有时不得不设定两个变量,分别用在编译时和运行时的连接。

3. 设置一个根据这些变量来协调编译的系统。

为了用一个库,你必须告诉编译器你将从库中两次导入函数:一次是为了编译,一次是为了连接。对于一个在标准位置的库,这两次声明都通过程序中的#include和-l编译选项发生。

例1-1展示了一个小例子,可以用来做一些神奇的(至少对于我来说是这样的;如果统计学术语对你来说就像希腊文一样,其实也没关系)算数。erf(x)是C99标准的错误处理函数,是与平均数为0、均方差为的从0到x的正则分布的积分紧密相关。这个例子中,我们用erf来验证一个在统计学家中流行的领域(一个标准大样本假设的95%置信区间)。我们把这个文件命名为erf.c。

例1-1 一个对标准库的一行调用(erf.c)

对#include行你应该已经很熟悉了。编译器将把math.h和stdio.h文件添加在源文件的这个地方,并因此导入了printf、erf和sqrt的声明。在math.h中的声明并没有erf函数做什么,只是说这个函数接收一个double型参数也返回一个double型值。这些已经足够编译器去检查我们使用的合法性并产生一个源文件,带着一个给计算机的标记:一旦你看到这个标记,你可以找到erf函数,并将这个标记用erf的返回值替代。

而连接器的任务是通过确实找到erf来解析那个标记,也就是在你硬盘的某个库里。

在math.h中的数学函数分散在他们自己的库中,你需要通过一个-lm编译选项告诉连接器。这里,-l是一个用来指示库需要连接的选项,而本例中的库有一个用单个字母表示的名字:m。你不用设定任何选项就可以使用printf,因为在连接命令行的末尾,有一个隐含的-lc选项来要求连接器连接标准libc库。随后,我们将看到GLib 2.0通过-lglib-2.0被连接进来,GNU科学计算库也通过-lgsl连接进来,依此类推。

所以,如果文件名为erf.c,那么完整的gcc编译器命令行,应该如下所示(这里包括几个选项,在后面将会详细介绍):

这样就能告诉编译器通过程序中的#include包含数学函数,并告诉连接器通过命令行中的-lm连接数学库。

-o选项用来给出输出文件的名字;否则我们将得到一个默认的可执行文件名a.out。

在后面你将看到我几乎每次都用到一些编译器选项,并且我也建议你使用它们。

在后文将介绍的makefile中,我通过设定一个变量CC=c99实现了这个效果。

警告

在Mac系统中,c99是一个特别修改的gcc版本,可能并不是你想要的。如果你有一个不是很理想的c99版本,或者它整个就被忽略了,那就自己建立一个。把一个叫做c99的文件放在你的搜索路径中的目录里:

或者如果你愿意,就是

并通过chmod +x c99让它成为可执行的文件。

提示

要坚持使用编译器警告。你可能已经熟知C语言标准,但是你不可能比你的编译器更挑剔和更了解C。旧的C教材连篇累牍地警告你注意=和==的差别,或者检查是否所有的变量在使用前都被初始化了。作为一本更加现代的书的作者,我却将以平常心来对待,因为我可以把所有的警告总结为一点:永远都要用你的编译器警告。

如果编译器建议你做一个改变,不要怀疑或试图碰运气而放弃修改。尽可能去:(1)理解为什么你得到警告,(2)修改代码直到不产生任何警告和错误。编译器信息是出了名的难懂的,所以如果你在第(1)步有困难,把警告信息贴在搜索引擎上,就能看到有多少人在你之前也面对了类似的问题。你可能想加上-Werror编译选项,这样你的编译器将把警告当作错误来处理。

在笔者的硬盘中有超过700 000个文件,声明sqrt和erf函数的头文件只是其中之一,而还有一个是包含了这些函数对应的被编译后的目标文件(你可以在任何POSIX标准系统中试一下find /-type f | wc –l来得到一个粗略的文件数)。编译器需要知道在哪个目录中去查找并找到正确的头文件和目标文件,当我们开始使用非标准C库的时候,这个问题就会变得更加严重。

在一个典型的安装中,库可能存放的地方至少有三个。

操作系统标准的路径一般不会引发什么问题,编译器也应该知道如何查找那些路径,并找到标准C库以及伴随其安装的任何文件。POSIX标准用“通常位置”来指代上面所说的目录。

但是对于其他的东西,你必须告诉编译器如何查找。这真是拜占庭风格:没有一个标准的方法去找到不按标准位置安装的库,这一点是人们对C比较恼火的地方。另一方面,编译器知道如何在通常的位置查找,而库的提供者倾向于将库安放在通常位置,所以你可能从来没有真正手工去配置地址。再者,也有几种方法使你可以指定路径。最后,一旦你把非标准库安装在系统上,你可以在一个shell脚本或makefiles中配置好然后再也不用去想这个。

假设你在计算机上安装了一个叫做Libuseful的库,并且你知道与之相关的文件放在/usr/local/目录,也就是你的系统管理员的官方主目录。你已经把#include <useful.h>放在了你的代码里;现在你必须把下一行放在你的命令行中:

任何其他顺序,比如gcc –lbroad –lgeneral specific.o,都可能失败。你可能认为连接器首先查看第一个目标——specific.o,将无法解析的函数、结构和变量名记入一个列表。然后连接器查看下一个目标——lbroad,并在这个目标内搜索列表中仍然缺失的项目,同时有可能在列表中添加新的项目;所有再在-lgeneral查找仍然缺失的项目。如果直到搜索完最后的目标仍然存在未解析的符号(包括在最后的隐含的-lc),连接器将终止运行并向用户给出最后剩下的未解析项目。

现在回到位置路径问题:要连接的库到底在哪里呢?如果它是用你安装操作系统其他部分同样的包管理器安装的,那么最可能在通常路径,你也不用去担心这个。

你可能想不清你自己的本地库应该放在何处,比如/usr/local还是/sw或者/opt。你无疑可以用硬盘搜索的方式来查找,比如在你的机器或POSIX环境中使用

来搜索/usr中以libuseful开头的文件。当你发现Libuseful库的共享目标文件在/some/path/lib,那么几乎可以肯定对应的头文件一定在/some/path/include。

在硬盘里到处找库文件是一件很恼人的事情,于是pkg-config通过将每个包自己报告的用于编译的配置和位置信息存在一个知识库中,解决了这个问题。在命令行中输入pkg-config;如果你得到一个错误提示说“没有指定包名字”,那么很好,说明你有pkg-config并且可以用它来做研究。例如,在我个人计算机的命令行上输入这两行命令:

得到下面两行输出:

这些就是我用来编译GSL和LibXML2所需要的所有选项。-l选项揭示出GNU科学计算库依赖于基本线性数学子程序库(BLAS),而GSL的BLAS库依赖于标准数学库。看起来所有这些库都在通常路径,因为这里没有-L选项,但是-I选项表明LibXML2的头文件的特殊位置。

回到命令行,shell提供了一个方法,就是当你把一个命令行用反引号包围时,这个命令行会被其自身的输出替代。就是说,输入:

编译器看到的是:

所以pkg-config会为我们做很多工作,但是这并不是标准配置的:我们期望所有人都拥有它,或者每个库都用它注册了。如果你没有pkg-config,你就必须自己研究,比如读这个库的手册,或者像前面那样搜索。

警告

有很多与路径有关的环境变量,比如CPATH、LIBRARY_PATH或者C_INCLUDE_PATH。你可以在.bashrc或别的用户定义的环境变量列表中设定它们。它们都不是标准的——连Linux和Mac中的gcc都分别使用不同的变量,别的编译器自然也使用自己的变量。我发现在每个项目的makefile或类似机制的基础上,用-I和-L来设定这些路径相对更容易。如果你喜欢这些路径变量,可以在你的编译器的帮助文件的末尾查找符合你的情况的相关变量。

即便用pkg-config,我们也显然需要某种工具帮我们把所有这些自动执行。每个元素都很容易理解,但是这可是一个冗长机械的琐碎工作。

编译器连接静态库的时候,是将库里的相关内容直接复制到最终的可执行文件中的。所以程序本身或多或少是一个独立的系统。而共享库与你的程序是在运行时连接的,就是说我们在运行时会遇到像编译器在编译时寻找库的路径那样的问题。甚至更糟的是,你的程序的用户也存在同样的问题。如果你的库在一个非标准的路径,那你需要找到一个修改运行时搜索库的路径的方法。有以下选择。

有些人反对过度使用LD_LIBRARY_PATH(万一有人把恶意伪装的库放到那个路径,在你没有察觉的情况下代替了真正的库怎么办?),但是如果你所有的库都在一个地方,在你虚拟的控制下添加一个目录到路径中就不是不合理的了。

到相应的makefile中。-L选项告诉编译器到哪里去找到库以解析符号;-Wl选项从gcc/Clang/icc传递选项到连接器,而连接器将给定的-R嵌入所连接的库的运行时搜索路径。不幸的是,pkg-config经常不知道运行时路径,所以你可能必须有手工输入这些信息。

makefile提供一个解决所有以上这些麻烦的方案。它基本上可以看作一组有组织的变量和shell脚本。POSIX标准的make程序输入makefile作为指令和变量,然后自动化处理那些冗长繁琐的命令行。在这部分讲解之后,就没有什么必要去直接调用编译器了。

在“3.2 makefile还是shell脚本”中,会讲述关于makefile的更多细节;这里,先给出一个最小的、实用的,并且能够编译一个依赖于一个库的基本程序的makefile:

用法:

提示

自己动手:下面是编程世界著名的hello.c程序,就两行:

把这个源文件和前面的makefile存在同一个目录中,然后试着按前面的步骤编译和运行程序。成功后,修改makefile使其能编译erf.c。

很快我们会介绍makefile的实际应用,但你可能注意到前述makefile里6行中有5行是关于变量设定的(目前多数被设定为空),意味着我们还需要多花一点时间在环境变量的细节上。

警告

历史上,曾经出现过两种主流的shell语法:一种基本上是基于Bourne shell的,另一种主要基于C shell。C shell的变量语法稍有不同,例如,采用set CFLAGS=”-g –Wall –O3”来设定CFLAGS的值。但是POSIX标准是写成Bourne类型的语法,也就是我这本书余下部分所采用的。

shell和make用$来指代变量的值,但是shell用$var,而make假设任何变量的名字多于一个字母:$(var)。所以,前面的makefile中,$(P):$(OBJECTS)将相当于

有几种办法来让make识别变量:

我自己经常忽略这个makefile中的第一行,P=program_name,取而代之的是在每个会话中通过export P=program_name来设定,这意味着我免不了有时还得编辑一下makefile。

你将看到正确的变量及其值。这是为什么shell不让你在等号附近放空格的原因:空格是用来区分命令行中的赋值操作的。

在这个方法中设定和导出变量应该在一行实现。如果你在命令行中执行了上面这条命令,再次运行env | grep PANTS,就会发现PANTS不再是一个被导出的变量了。

只要你愿意,你可以指定任意数量的变量:

这个技巧出现在shell规范的“简单命令”描述部分,也就是说赋值必须在一个实际的命令之前。这在你使用非命令的shell构造时很有意义。编写:

将失败并伴随一个晦涩的语法错误。正确的方式是:

对makefile而言,上面所有这些手段都是相等的;例外之处在于,被make调用的子程序只知道新的环境变量,而不知道任何makefile变量。

C中的环境变量

在C代码中,可以用getenv函数来得到环境变量。getenv非常简单易用,在C中快速设定变量时是非常容易的,所以你可以从命令行中尝试设定不同的值。

例1-2是一个打印示例程序,只要用户需要,随时打印一个信息到屏幕。环境变量msg用于设定要打印的信息,而通过reps设定重复的次数。请注意我们是如何设定它们的默认值10次和“Hello.”的,这些默认值一般在调用getenv时返回NULL(典型的含义是这个环境变量没有被设定)。

例1-2 环境变量提供了一个改变程序细节的快速方式(getenv.c)

就像之前看到的,我们可以用一行命令导出一个变量,这样可以使得向程序发送变量更加方便。用法:

你可能觉得这个用法很奇怪——程序的输入应该跟在程序名后面才对,真是可恨——先不管这些奇怪的事情,你可以看到程序自身进行了一些设置工作,我们几乎没费什么精力立即就得到了来自命令行的命名参数。

当你的程序能够跑得远一点以后,可以试一下配置getopt来按照通常的方法设定输入参数。

make也提供一些内置的变量。下面是其中一些(POSIX标准)变量的介绍,你可能在随后针对规则的学习中用到。

$@

返回完整的目标文件名。所谓目标(target),我的意思是指需要被生成的文件,比如从一个a.c文件中编译而得到的a.o文件,或者一个通过连接.o文件生成的程序。

$*

不带文件名后缀的输出文件。如果输出文件是prog.o,$\就是prog,而$\.c就成为prog.c。

$<

触发和制作该目标的文件的名称。如果我们正在制作prog.o,有可能是prog.c文件刚被修改,所以$<就是prog.c。

现在让我们专注地了解一下makefile的执行过程,并了解变量是如何影响这个过程的。

先不讨论变量的事情,来看一下makefile的代码片段,一般有以下形式:

如果目标被调用,通过命令make target,那么dependencies(支持项)将被检查。如果target是一个文件,dependencies也都是文件,并且target是比dependencies时间上更新的文件,那就是说这个文件已经是最新的,所以系统也不会再做什么。否则,针对target的处理将被执行,所有的dependencies将被运行或重新产生,target段落的script(脚本)部分也会被执行。

例如,在本文成书之前,我的博客上贴出了一系列的文章(在http://modelingwithdata.org)。每篇博客都是用HTML和PDF格式上传的,也都是用LaTeX产生的。我忽略了很多这个简单例子里的很多细节(比如latex2html的很多配置选项),但这是一个人们经常编写和运行的makefile。

警告

如果你将这些makefile片段从屏幕或者书本上复制到makefile文件中,不要忘记每行代码开头的留白部分必须是制表符(tab键)而不是空格(space键)。要怪就怪POSIX标准吧。

我们通过类似export f=tip-make的命令来设定f。然后在命令行输入make的时候,第一个目标all被检测到。就是说,make命令自身相当于make“第一个目标”。这个依赖于html、doc和publish,所以那些目标也被依次调用。如果我知道这还没有准备好递交给这个世界,我可以调用make html doc并仅完成这些步骤。

在之前那个简单的makefile中,我们仅有一组target/dependency/script。例如:

这个和我的博客里的makefile遵循同样的支持项和脚本执行次序,但是脚本是隐含的。这里,P=domath是被编译的程序,并且依赖于目标文件addition.o和subtraction.o。因为addition.o没有被列出来当作一个处理目标,所以make用一条如下所述的隐含的规则来从.c文件编译.o文件。对subtrction.o和domath.o也是一样的操作(因为GNU make隐含假设domath依赖于这里给出设置的domath.o)。一旦所有的目标文件被建立了,我们没有在$(P) target运行的脚本,那么GNU make填充它的默认脚本来连接.o文件而成一个可执行文件。

POSIX标准的make有一个特殊的从a.c源文件到a.o的编译方法:

这里$(CC)变量代表你的C编译器;POSIX标准规定一个默认的CC=c99,但是GNU make的当前版本设定为CC=cc,并且一般连接给gcc。在本段最早的那个最小的makefile中,$(CC)被明确设定为c99,$(CFLAGS)被按照之前的选项列表设定,$(LDFLAGS)没有被设定因此没有被任何事物替代。所以如果make认为它必须产生your_program.o,下面就是你需要运行的命令行,在给定的makefile中:

当GNU make觉得你需要从目标文件编译出一个可执行文件时,它用下面的方法实现:

如果想起在连接器中的次序问题,那么我将需要两个连接器变量。在前面的例子中,我们需要:

作为连接的相关操作。相较这个方法中的正确的汇编命令,我们可以看到我们需要设定LDLIBS=-lbroad –lgeneral。如果我们已经设定了LDFLAGS=-lboard –lgeneral,那么这个方法将产生cc –lbroad –lgenerl specifix,o,这个方法看起来是有问题的。请注意LDFLAGS也经常出现在从.c文件编译到.o文件的过程中。

提示

如果你想看到你的make内置的全部默认规则和变量的列表,可以尝试:

所以,这个游戏就是:找到合适的变量并把它们设置在makefile中。你还是要探究一下哪个才是正确的选项,但是最少你可以把它们写在makefile中,之后便再也不必去考虑。

如果你用一个IDE,或者CMAKE,或者任何POSIX标准make的替代品,你都可以做这个“找到合适的变量”的游戏。我还会继续讨论前面的最小makefile,在你的IDE中应该不难找到对应的变量。

或者,手工指定-I、-L和-l变量,如:

并可以欣赏make如何用他的C编译知识来做余下的工作。

选择什么连接器选项来建立共享库?

说实话,我根本不知道。不同的操作系统中是不同的,无论是类型不同还是年份不同,甚至在同一个系统中,那些规则也经常有点混乱。

不过,我们将在第3章中介绍的工具Libtool知道每个操作系统中的每个共享库的每个制作过程的细节。我建议你花点时间去了解Autotools,那么就可以一举解决共享目标文件的编译问题,而不是花时间在了解每种系统的正确编译器选项和连接过程上。

到目前为止我们讲的都是如何用make来编译自己的代码。而编译别人的代码则是另外一回事了。

让我们试一个简单的包,即包含大量数值计算函数的GNU科学计算库(GSL)。

GSL是用Autotools打包的,而Autotools是一系列为在任何机器上都能使用而准备库的工具,其原理是测试所有已知的特殊细节并实现适当的解决方法。Autotools主要关注现代的代码是如何发布的,“3.3用Autotools打包你的代码”将详细讲述如何用它来打包你自己的程序和库。但是到现在,我们可以从用户的角度,来欣赏快速安装有用的库文件的便利。

GSL经常是由包管理器预编译好的,但是为了掌握编译库的每一步的目的,这里我们先得到GSL的源代码并把它安装好,假设你具有计算机的root权限。

❶下载源文件的zip压缩包。如果你还没有wget,使用包管理器安装它,或者在你的浏览器里直接入这个URL。

❷解包:x=抽取,v=详细模式,z=用gzip解包,f=文件。

❸检测。如果configure步骤给你一个“缺失一个元素”的出错信息,那么用包管理器来获取它并重新运行configure。

❹将GSL安装到正确的位置上——需要具备相应权限。

如果你是在自己家里尝试这个,那么你可能已经有了root权限,上面步骤执行起来会很顺利。如果你是在工作场所并是在使用一个共享的服务器,则拥有超级用户权限的概率应该不会很大,由于最后一步要求超级用户权限,而你没有密码。如果这样,你就屏住呼吸直到下一部分吧。

它安装了吗?例1-3是一个使用GSL函数来找到95%的置信空间的小程序;编译一下这个例子,看看你能否把这个例子连接起来并运行。

例1-3 利用GSL来重做一次例1-1(gl_erf.c)

为了使用刚才安装的库,我们需要修改会用到这个库的程序的makefile文件,以指定库及其位置。

你可以采用下列语句的一种取决于你是否安装了pkg-config:

如果库没有被安装在标准的位置并且pkg-config也没有安装,你需要把路径加在定义行的开头,例如:CFLAGS=-I/usr/local/include和LDLIBS=-L/usr/locallib–Wl,-R/usr/local/lib。

你可能已经注意到了上一章节最后的关于为何你需要有root权限来安装POSIX系统到你的通常位置的警告。但是你在工作场所的共享机器里可能没有root权限,或者你被有特别的权限的某人控制。

那你就必须走入地下,制作一个属于自己的root目录。

第一步很简单,即创建这个目录:

由于我已经有了一个~/tech目录,用来保存我所有的技术文档、手册和源代码,所以我建立的是~/tech/root目录。名字其实无所谓,但是我还是喜欢用~/root作为本书的示范目录。

提示

shell可以将波浪线替换为你个人主目录的完整路径,节省你很多打字的时间。POSIX标准只要求shell在一个词或者一个冒号(你需要做为一个路径类型的变量)的开始这么做,但是多数的shell扩展支持了词中间的波浪线。其他的程序,比如make,或许能也或许不能识别你个人主目录的波浪线。

第二步,把新建的root系统添加到所有相关的路径上去。修改.bashrc或(其他shell的该配置文件)的PATH变量如下:

如果把你的新的目录的bin子目录添加在你的原来的PATH前面,这个子目录就将被首先查找,并且你放在那里的任何程序都将被率先找到。那么,你就可以把标准共享目录中的任何程序的替代版本放在那里。

手册

我记得以前的确有印刷版本的手册,不过现在它是在man命令中的。例如,用man strtok来阅读关于strtok函数的内容,一般包括需要包含什么样的头文件、输入参数,以及它的基本用法的解释。手册文档倾向于简洁明了,有时候缺乏示例,并且假设读者已经有了一些这个函数的基本用法的知识。如果你需要一个更加基本的教程,可以用常用的搜索引擎在Internet上找到几个(对于strtok这个例子,你可以参见“9.2 strtok的颂歌”)。GNU C库的手册,也很容易在网上找到,对初学者而言是非常易懂的。

  • 如果你无法想起要找的函数的名字,每个手册页都有一个一行长的简述,man -k searchterm将搜索那些简述。许多系统还提供apropos命令,它和man–k类似但是多了一些别的功能。为了进一步的利用,我经常把apropos命令的输出用管道导出给grep命令。
  • 手册分为几段。第1段是命令行命令,第3段是库函数。如果你的系统有一个命令行程序叫做printf,那么man printf将展示这个命令的文档,而man 3 printf将展示C库函数中的printf的文档。
  • 如果想了解更多关于man命令的用法(比如各段的完整列表),可以使用man man。
  • 你的文本编辑器或者IDE可能有某种快速调用手册页的方法。例如,vi的使用者可以把鼠标放在一个词语上,用K键来打来这个词语的手册页。

对于那些你想链接的C程序库,请注意在前面的makefile中的新搜索路径:

现在你已经有了一个本地的root目录,你也可以在别的系统上使用它,比如Java的CLASSPATH。

最后一步是在新的root目录上安装程序。如果你有源代码并使用Autotools,你只需要在合适的位置添--prefix=$HOME/root:

现在我们不需要sudo来执行做安装步骤,因为所有的工作都在你的自治领地里。

这些程序和库都在你的主目录中,所使用的许可权不会超出你所具有的范围,系统管理员不能抱怨你做了任何危害他人的事情。如果你的系统管理员还是有所抱怨,那么,可悲的是,你也许到了该和他分手的时候了。

到此,你应该已经看出编译过程的套路了。

1. 设定一个表述编译器选项的变量。

2. 设定一个表述连接器选项的变量,包括为你用的所有库配置的-l选项。

3. 用make命令或者你的IDE的操作来把这些变量转换为完整的编译和连接命令。

本章的余下部分将把以上步骤做最后一次,并采取一种非常简短的设置:仅仅用shell。如果你思维敏捷,可以通过摘录一些语句段落到解析器上来学习脚本,你也将可以同样地把C代码贴在你的命令行上。

gcc和Clang有一个用于包含头文件的方便的配置。例如:

这和在C源文件的开头放入下面这行等价的:

同样地也可以用clang –include stdio.h。

通过把以上加入我们的编译器调用,最终我们可以把hello.c程序写成只有一行:

通过以下方式顺利编译:

或者利用shell命令:

这个关于-include的窍门是随编译器不同的,并且把信息从代码转移到编译指令中。如果你认为这是一个坏习惯,那好,就忘了它吧。

很久以前,编译器处理一个哪怕不算复杂的程序要也要花费好几秒甚至几分钟的时间,所以减少一些编译器不得不做的工作,可以产生一些显而易见的好处。我现在的stdio.h和stdlib.h每个都有1,000多行(可以用wc –l /usr/include/stdlib.h验证),time.h也有400行,这就意味着下面这个仅7行的小程序,实际上是一个大约2,400行的程序:

而今,编译器已经不再以为2,400行是一个大问题,这种编译也就花费不到1秒。那么我们为何还要花时间去为一个特定的程序挑选正确的头文件呢?

如果你是一个gcc或Clang用户,当你拥有一个统一的头文件后,你就不必再书写甚至只有一行#include <allheads.h>的类似代码,因为你可以在CFLAGS中用-include allheads.h来代替它,然后不用再担心与项目无关的头文件了。

自己动手:为自己写一个通用的头文件,让我们称之为allheads.h,然后把你用过的所有头文件都扔到里面去,那么它就将看起来像:

我其实无法告诉你它确切长得什么样,因为我不知道你每天都用什么文件。

现在你有了这个集成的头文件,只需在所有文件的开头加上这么一句:

这样你就不用再操心头文件了。的确,加上这个头文件会扩展出10,000行额外的代码,而且其中大多数和手头的程序无关。但是你不会留意到,而且没用的声明也不会改变最终的可执行文件。

头文件还可以起到限定作用域的目的,但是这个通常在你写函数和结构时更重要,而不是对于那些库。作用域限定范围的目的不是为了缩小命名空间以防止计算机过热;而是为了减少作为程序员的你的认知负担。我猜你甚至都不熟悉你所使用的库中的大部分的函数,如果你都不了解,那么它们也不可能带来任何认知负担。其他的语言甚至不做区分并有成堆的关键词,比如R项目,就有752个启动时内部定义的关键词。

嵌入文档是POSIX标准的shell的一个特性,你可以用在C、Python、Perl,或者别的什么中,他们也使得这本书更加有用和有趣。并且,如果你想要有一个多语言的脚本,嵌入文档是一个实现的方便法门。在Perl中做解析,在C语言中做算数,然后用Gnuplot产生漂亮的图片,并且集成在一个文本文件里。

这是一个Python的例子。通常,你通过下面的方法告诉Python去运行一个脚本:

你可以使用‘-’以使用stdin作为输入文件:

提示

这里要用'-'而不仅仅是-,表明这是一个纯文本,而不是一个用来引出类似python–c “print ‘Hi’”中的c的开关。许多程序遵从GNU的习惯,使用两个横线来表示停止读取开关并把随后的输入读取为纯文本。因此:

也会起作用,但是这类事情会吓到大家。

理论上,你应该通过echo把一些过长的脚本放到命令行上,但是你很快就会看到有很多短小、不符合期望的解析在进行——比如,你可能需要用\”hi\”而不是”hi”。

那么,嵌入文档实际上根本就不会被解析。比如:

现在,回到C的世界中来:我们可以用嵌入文档来编译通过gcc或Clang贴到命令行的C代码,或者在一个多语言的脚本中的几行程序。

我们不再用makefile,所以我们需要一个单行的编译命令。为了让生活变得不那么痛苦,让我们给它起个别名。把它输出到你的命令行,或者把它添加在你的.bashrc、.zshrc,或者任何适合的地方:

这里的allheads.h是你之前放在一起的集成的头文件。当你写C代码的时候,用-include选项是你最不应该考虑的事情,并且我也发现当C代码中出现#的时候,bash的历史变得有点靠不住。

在编译行,你将认出 '-' 意味着用stdin而不是从一个命名的文件输入。-xc认为这是C代码,因为gcc代表GNU编译器组合,而不是GNU C编译器,也没有类似.c的输入文件名来提示它,我们必须指明这不是Java、Fortran、Objective C、Ada,或者C++(对Clang也一样,即使它的名字是有意在提示是C语言)。无论你在makefile中对LDLIBS和CFLAGS做了多少客制化,这里也要做。现在你已经扬帆出海了,可以在命令行中编译C代码了:

我们可以使用嵌入文档来粘贴简短的C程序到命令行,并写一点带有争议性的测试程序。不仅是你不需要一个makefile,你甚至也不需要一个输入文件。

不要认为这种事情就是你的基本工作状态。但是剪切粘贴代码段落到命令行是有趣的,并且在一个较长的shell脚本内可以用一步完成C也是非常棒的。

[1]  GUI——图形用户界面,如目前常见的Windows、iOS等操作系统。——译者注

[2]  Cygwin有Red Hat, Inc.运营的项目,该公司也允许用户购买不按照GPL版权发布自己源代码的权利。

[3] 虽然Msys、MinGW和一些其他作为包提供,这些包的数量与典型的包管理器提供的上百个包相比显得很苍白。值得注意的是,预编译的库并不是点击一下或者输入一个命令行就可以被安装好的。不过,当你读到这些的时候,我的抱怨可能已经被解除了,也就是说有了更多的MinGW包可用。

[4] 借用最新出现的grunge rock风格,指代最新的C编译器——译者注


他就是那个

喜欢我们所有歌曲的人,

他喜欢一起哼唱,

他喜欢边开枪边唱,

但是他不知道这歌的意义。

——选自Nirvana的歌曲“In Bloom(风华正茂)”

就像一首描述音乐的歌曲、一部刻画好莱坞的电影,指针就是一种描述其他数据的数据。我们很容易被指针搞崩溃,像引用的引用、别名、内存管理和malloc之类的东西,很容易把我们搞得天旋地转。但是,这些纷繁复杂的痛苦可以分解为独立的片段。例如,我们可以使用指针作为别名,这样就不需要再关注malloc,20世纪90年代的教科书常教导我们需要熟练掌握这个函数。一方面,C语法中星号的用法常常令人困惑。另一方面,C的语法也向我们提供了工具,用于处理那些格外复杂的指针细节,例如函数指针。

C提供了3种基本的内存管理模式,比大多数语言要多上2种,事实上我们真正需要关注的也只是其中一种。但是,在本书第12章中,我还要向读者介绍2种额外的内存模型。

自动

我们在第一次使用一个变量时对它进行声明,当离开自己的作用域之后变量就会被销毁。如果不使用static关键字,在函数内部所声明的所有变量都是自动变量。一般的编程语言只具有自动类型的数据。

静态

静态变量在程序的整个生命期内一直存在。数组的长度在一开始就是固定的,但它所包含的值却可以改变(因此它并不是完全静态的)。数据是在main函数启动之前被初始化的,因此所有的初始化值都必须是常量,并且不需要计算。在函数的外部所声明的(属于文件作用域)和函数内部用static关键字声明的变量都是静态变量。最后,如果忘了对一个静态变量进行初始化,它会默认初始化为全零(或NULL)。

手工

手工类型的变量涉及malloc和free函数,这也是许多段错误的根源。这种内存模型是让许多C编程员欲哭无泪的罪魁祸首。另外,这也是唯一允许在声明之后改变数组长度的内存类型。

下面这张表显示了三种内存模型的区别所在。在接下来的几章中,我们将详细讨论这些区别。

                       

静态

自动

手工

 在启动时设置为0         

◊   

    

    

 受作用域限制            

◊   

◊   

    

 可以在初始化时设置值     

◊   

◊   

    

 可以在初始化时设置非常量值

    

◊   

    

 用sizeof测量数组长度      

◊   

◊   

    

 在不同的函数调用之间保持值

◊   

    

◊   

 可以作为全局变量         

◊   

    

◊   

 在运行时设置数组长度     

    

◊   

◊   

 可以改变长度            

    

    

◊   

 对程序员的折磨          

    

    

◊   

表中有些特性适用于变量,例如改变长度和方便的初始化。有些特性是内存系统的技术性结果,例如是否可以在初始化时设置值。因此,如果我们想要一个不同的特性,例如能够在运行时改变长度,就不得不关注malloc函数和指针所指向的堆。如果我们可以抹掉这一切重新开始,我们就不会把这三组特性与相关联的技术性烦恼捆绑在一起。但是,我们必须面对这一切。

这些就是当我们把数据存放到内存时所浮现的相关问题。它与变量本身不同,可以产生另一层次的乐趣:

1.如果我们用static关键字在函数外部或者函数内部声明一个struct、char、int、double或其他类型变量,它就是静态的。否则,它们就是动态的。

2. 如果我们声明了一个指针,它本身具有一种内存类型,很可能如规则1所述的属于自动或静态类型。但是,这个指针可以指向三种数据类型的任何一种,包括指向由malloc函数所分配数据的静态指针和指向静态数据的自动指针。所有的组合都是可以成立的。

提示

自己动手:检查一些现有的代码,研究变量的分类:哪些数据位于静态内存、自动内存或手动内存,哪些变量是指向手工内存的指针、指向静态值的自动指针等。如果手头上没有现成的代码,可以用例6-6为素材完成这个练习。


堆栈和堆

任何函数都在内存中占据一个空间,称为函数帧,用以保存与这个函数有关的信息,例如当函数执行完成之后返回到哪里,以及保存所有自动分配的变量的空间。

当一个函数(例如main)调用另一个函数时,第一个函数的函数帧中的活动就会暂停,并且一个新的函数被添加到这个堆栈帧中。当函数执行完成时,它的帧就从这个堆栈帧中弹出。在这个过程中,保存在这个函数帧中的所有变量都会消失。

遗憾的是,堆栈的长度限制要比一般内存小得多,大约是2~3M(本书写作时在Linux系统下大致如此)。这点空间对于保存莎士比亚的所有悲剧作品已经足够,因此不必担心分配一个包含10000个整数的数组会出现问题。但是,我们很可能会用到更大的数据集,因此需要使用malloc为它们在其它地方分配空间。

通过malloc所分配的内存并不位于堆栈中,而是在系统中称为堆的空间中。堆的大小可能有限制,也可能没有限制。在普通的PC机上,可以粗略地认为堆的大小就是所有可用内存的大小。

本章主要讨论自动内存、手工内存和指针的交互,对静态变量的讨论较少,但是静态变量还是很有用处的,因此值得在此花点篇幅探讨一下它们的作用。

静态变量可以具有局部作用域。也就是说,我们可以让一个静态变量只存在于某个函数内部,但是当这个函数执行时,这个变量会保持它的值。因此,它可以作为内部计数器或可复用的临时空间。由于静态变量永远不会移动,因此指向一个静态变量的指针在函数完成执行之后仍然是有效的。

例6-1展示了一个传统的教科书例子:菲波那契数列。我们把前两个成员声明为0和1,以后的每个成员都是它之前两个成员的和。

例6-1 由一个状态机所生成的菲波那契数列(fibo.c)

可以看到main函数何其的简单。这里的fibonacci函数是一台小型的自己运行的机器。main函数只是驱动这个函数运行,由后者不断吐出另一个值。也就是说,fibonacci函数是个简单的状态机,而静态变量正是在C中实现状态机的关键技巧。

在一个必须做到线程安全的世界里,我们应该怎样使用静态的状态机呢?ISO C委员会看到了这个问题,因此C11包含了一种_Thread_local内存类型。只要把它放在声明中:

就可以为每个线程获取一个不同的计数器。第12章“12.2.2用Pthreads轻松实现线程”这一节我将详细讨论这个问题。

声明静态变量

静态变量(即便是位于函数内部)是在程序启动之前被初始化的,此时main函数还没有启动,因此我们不能用非常量值对它们进行初始化。

这确实是件麻烦的事情,但是我们可以用一个宏开始为0并在第一次使用时为它分配值的宏来轻易解决:

只要预期绝不会初始化为零(用指针的说法为NULL),这种方法就是有效的。如果初始化为零,它也会在下一次执行时重新进行初始化。因此,不管是什么情况,这个方法都是可行的。

当我们告诉计算机把A设置为B时,意思可能是下面两者之一:

在代码中,每次表示把A设置为B时,需要明确是为了创建一份拷贝还是创建一个别名。这绝不是C特有的问题。

对于C而言,我们总是创建一份拷贝。但是,如果我们复制了数据的地址,这个指针的一份拷贝就成为了这个数据的别名。这是一种精巧的实现别名的方式。

其他语言具有不同的习惯:LISP系的语言非常依赖于别名,并有专门的set命令用于复制。Python对于标量一般进行复制,对于列表则执行别名操作(除非使用了copy或deepcopy)。另外,事先知道期望的结果可以避免产生大量的错误。

GNU科学库包含了vector和matrix对象,它们都具有data成员,后者本身是一个double值的数组。我们假设有一些用typedef定义的vector/matrix对,并有一个包含了这些数据对的数组:

这样,第1个矩阵的第1个成员的表示即如下:

如果你熟悉这样的语法,就很容易接受它。但是,输入起来还是比较麻烦的。我们可以为它设置一个别名:

在上面所显示的两种类型的赋值中,这里的等号所表示的是别名类型的赋值:只有一个指针被复制,如果我们修改了*elmt1,your_data内部被指向的数据也会被修改。

别名操作是一种类似于malloc-free的体验,它可以使我们获得指针操作的灵活性同时又不必关心内存管理。

下面是没必要使用malloc函数的另一个例子。假设有一个接受一个指针变量作为输入的函数:

如果函数的用户过于紧密地把指针与malloc关联在一起,可能觉得自己必须分配内存才能把指针传递给这个函数:

事实上,最方便的用法是通过自动内存分配来完成任务:

自己动手:正如前面我提供的建议,每次编写一行表示把A设置到B这样的代码时,需要明确自己想要的是创建一个别名还是一份拷贝。研究下你手头上的一些代码(不管是用什么语言编写的),然后逐行检查,问问自己在哪些情况下把复制替换为别名是合理的。

如例6-2所示,复制一个结构的内容只需要一行操作代码就可以完成。

例6-2 不需要逐个复制结构的每个成员(copystructs.c)

❶ 修改d1,观察d2有没有发生变化。

❷ 这些断言都可以通过。

和以前一样,我们需要知道自己的赋值操作是创建了数据的一份拷贝还是在创建一个新的别名。这里是什么情况呢?我们修改了d1.b和d1.c,但d2中的b、c值并没有发生变化,因此它是创建了一份拷贝。但是,一个指针的拷贝仍然指向原先的数据,因此当我们修改d1.efg[0]时,这个修改还会影响指针d2.efg的拷贝。我的建议是如果需要对指针内容也进行复制的深拷贝,就需要一个结构复制函数。如果我们并不需要追踪任何指针,那么使用复制函数就有点小题大作了,只需要使用等号就可以。

既然d2 = d1就可以实现复制,如果像assert(d1==d2)这样的单行比较函数也能够完成任务就非常理想了,但这并不是标准的做法。然而,我们可以把两个结构看成是比特流来进行这种比较,通过memcmp来完成(前面要加上#include <string.h>):

如果d1的位列表与d2的位列表匹配,这个函数就返回0,这与strcmp(str1, str2)在两个字符串匹配时返回0的方式非常相似。对于数组,等号将会复制一个别名,而不是数据本身。在例6-3中,我们可以进行相同的创建拷贝测试,即修改原先的数据,并检查拷贝值。

例6-3 结构被复制,但是把一个数组设置为另一个数组只是创建了一个别名(copystructs2.c)

❶ 通过:当拷贝被修改时,原数据也被修改。

例6-4逐步显示了灾难发生的过程。两个函数自动分配两块内存:第一个函数分配了一个结构,第二个函数分配了一个较短的数组。作为自动分配的内存,我们知道在每个函数结束时,它们各自的内存块将被释放。

指定return x为返回语句的函数会把x的值返回给调用函数(C99 & C11§6.8.6.4(3))。这看上去相当简单,但是这个值必须被复制到调用函数中,而后者的函数帧即将要被销毁。如前所述,对于结构、数值甚至是指针类型,调用函数将得到返回值的一份拷贝。对于数组,调用函数将得到指向这个数组的指针,而不是数组中数据的拷贝。

最后一种情况是个很讨厌的陷阱,因为被返回的指针可能指向一块自动分配的数组数据,而后者在函数退出时将被销毁。返回一个指向一块可能已经被自动销毁的内存的指针是再糟糕不过的事情了。

例6-4 可以从一个函数中结构,但不能直接返回数组(automem.c)

❶ 这个初始化是通过指定的初始化值列表进行的。如果读者没有遇到过这种方法,在这几章中要抓紧熟悉。

❷ 这里是合法的。在函数退出时,会创建自动分配的out的一份拷贝,然后这个局部拷贝被销毁。

❸ 这条语句则是非法的。在这里,数组事实上是被当作指针看待的,因此在退出时,会创建指向out的指针的一份拷贝。但是一旦这块自动分配的内存被销毁,这个指针就指向一块坏数据。如果编译器够聪明的话,它会对这种情况提出警告。

❹ 回到调用get_even的那个函数,evens是个合法的指向int类型的指针,但它所指向的数据已经被释放。这可能会产生段错误、打印出垃圾值,在很幸运的情况下或许会打印出正确的值(只这一次)。

如果需要对数组进行拷贝,我们仍然可以通过一行代码来完成,但这样就回到了内存操纵的语法,如例6-5所示。

例6-5 复制一个数组需要使用memcpy,它已经过时,但是能够完成任务(memcpy.c)

现在回到内存部分,也就是在内存中直接处理地址。这些操作常常需要通过malloc手工分配内存。

避免与malloc有关的缺陷的最简单方法,就是避免使用malloc。过去(上世纪80年代和90年代),我们需要使用malloc处理各种类型的字符串操作,但在第9章将详细介绍怎样在完全避免使用malloc的情况下处理字符串。我们也需要malloc处理那些必须在运行时设置长度的数组,这是一种相当常见的情况,正如第7章“让声明流动起来”一节所述,这也是过时的方法。

下面是我粗略整理的使用malloc的原因列表。

1.为了改变一个现存的数组的长度,需要使用realloc,而重新分配仅对于那些一开始就是通过malloc分配的内存块才起作用。

2.如前面所解释的那样,我们无法从函数返回一个数组。

3.有些对象在它们的初始化函数之后很久仍然应该存在。不过,在第11章“11.2.3基于指向对象的指针编码”一节中,我们将把这些内存管理操作包装到new/copy/free函数中,使它们不至于产生不良影响。

4.自动内存是在函数帧的堆栈中分配的,它的长度限制可能只有几MB(甚至更少)。因此,大块的数据(即任何以MB为单位的数据)应该是堆中分配,而不应该在堆栈中分配。另外,我们很可能在某个函数中存储某种类型的对象数据,因此在实践中应该调用一个object_new函数而不是使用malloc本身对它进行操作。

5.有时候,我们发现函数会被要求返回一个指针。例如,在第12章“12.2.2用Pthreads轻松实现线程”一节中,模板要求我们编写一个返回void *的函数。为了避开这个麻烦,我们简单地返回了NULL,但有时候会遇到无法像这样简单处理的情况。另外,注意第10章“10.9从函数返回多个数据项”一节讨论了从一个函数返回结构,因此我们可以发送回相对复杂的返回值,而不需要进行内存分配,这就避免在函数内部进行内存分配的常见情况。

可见情况实际上并没有那么多,第5种情况是极为罕见的,第4条常常是第3条的一种特殊情况,因为巨量数据集一般会放在类似对象的数据结构中。在产品代码中,要尽可能少地使用malloc,使主代码不需要进一步处理内存管理。

好了,我们现在已经清楚,指针和内存分配是独立的概念,但是处理指针本身仍然可能存在问题,因为那些星号还是令人困惑。

指针声明语法的设计书面原因是为了让指针的声明形式和它的使用形式看上去相似。它们的具体含义取决于声明方式:

由于*i是个整数,因此我们只有通过int *i把*i声明为整数才是自然的。

就是这样,如果它可以帮到你,那就太好了。我并不确信能够发明一种歧义更少的方法来完成这个任务。

在唐·诺曼的The Design of Every day Things一书中,始终倡导一条常见的设计规则,即“功能截然不同的事物看上去应该明显不一样”[Norman 2002]。书中提供了飞机控制的例子,两个看上去相同的控制杆常常完成截然不同的任务。在危急情况下,这可能会诱使飞行员犯错。

在这里,C的语法也存在类似的尴尬,因为在声明中的*i和声明之外的*i所表示的含义截然不同。例如:

在我的脑海中,已经抛弃了让声明和使用看上去相似的规则。下面是我所采用的规则,它很好地满足了我的需要:当用于声明时,星号表示指针。不用于声明时,星号表示指针的值。

这里有一个合法的代码片断:

根据上面这个规则,在第2行代码中可以看到,这种初始化方式是正确的,因为*j是个声明,因此表示指针。在第3行代码中,*k也是个指针声明,因此把它赋值给j是合理的。在最后一行,*j不是出现在声明中,因此它表示一个普通的整数,并且我们可以把12赋值给它(i也会随之被修改)。

因此下面是第一个提示:记住在声明行中看到*i时,它是个指向某对象的指针。在非声明行看到*i时,它是指针所指向的值。

在稍后讨论一些指针运算之后,我将提供另一个提示,在处理奇怪的指针声明语法时会有用。

数组的某个成员可以用数组的基地址加上一个偏移量来表示。我们可以声明一个指针double *p;,把它作为基地址,然后就可以像数组一样在这个基地址上使用偏移量。在基地址上,我们可以找到第1个成员p[0]的内容,在基地址上前进一步可以找到第2个成员p[1]的内容,接下来以此类推。因此,只要提供一个指针以及两个相邻成员之间的距离,就可以把它作为数组使用了。

我们可以直接采用基地址加偏移量的书面形式,类似(p+1)。正如教科书所描述的那样,p[1]等同于*(p+1),这就解释了为什么数组的第1个成员是p[0] == *(p+0)。K & R(在他们的著作《C编程语言》中花了6页的篇幅解释这个问题(第2版,第5.4和5.5节)。

这个理论提示了一些规则,用于在实际应用中表述数组和它们的成员。

例6-6展示了这些规则的一些实际应用。

例6-6 一些简单的指针运算(arithmetic.c)

❶ 使用特殊形式*evens写入到evens[0]。

❷ 成员1的地址,赋值给一个新指针。

❸ 引用数组第1个成员的通常方式。

下面我再送你一个很好的技巧,这个技巧建立在指针运算规则“p+1表示数组中下一个成员的地址(&p[1])”的基础上。根据这个规则,我们不需要在遍历数组的循环中使用下标。在例6-7中我们就使用了一个备用指针来指向list的头部,然后用p++在数组中向前遍历,直到数组尾部的NULL标记,从而获得了整个数组值。如果你查看了接下来的指针声明的提示,会更容易理解这种用法。

例6-7 我们可以利用p++表示“前进到下一个指针”实现循环的流水化 (pointer_arithmetic1.c)

自己动手:如果不了解p++,你打算怎样实现这个目标?

如果目标是为了实现简洁的语法表示形式,基地址加偏移量这个技巧并不能提供太多的帮助,但它确实解释了C的许多工作原理。事实上,我们可以考虑一下使用结构,例如:

作为一种智力模型来分析,我们可以把list看成是基地址,list[0].b与基地址的距离正好用来表示b。也就是说,假设list的位置是整数(size_t)&list,b位于(size_t)&list + sizeof(int);,这样list[2].d的位置将是(size_t)&list + 6*sizeof(int) + 5*sizeof(double)。根据这种思路,结构就与数组非常相似了,区别是结构的成员是用名称而不是序号表示的,并且它们具有不同的类型和长度。

这个思路并不是非常正确,因为存在对齐这个因素,系统可能会决定数据需要位于某个特定长度的内存块中,因此字段尾部可能会填充一些额外的空间,使下一个字符从正确的位置开始,并且结构的尾部可能也会进行填充,使结构列表中的每个结构能够大致对齐[C99和C11,§6.7.2.1(15)和(17)]。stddef.h头文件定义了offsetof宏,它精确地描述了基地址加领偏移量的思路:list[2].d的实际地址是(size_t)&list + 2*sizeof(abcd_s) + offsetof(abcd_s, d)。

顺便说一下,在结构的起始处不可能出现填充,因此list[2].a肯定等于(size_t)&list+ 2*sizeof(abcd_s)。

下面是个笨拙的函数,它以递归的方式对列表中的成员进行计数,直到遇到值为0的成员。假设我们想把这个函数用于零值为合理数据的任何类型的列表,因此我们让它接受一个void指针(当然这不是一种好的思路)。

基地址加偏移量的规则解释了为什么这种做法是不行的。为了表示a_list[1],编译器需要知道a_list[0]的准确长度,这样才能知道应该从基地址偏移多少。但是,由于没有与之相关联的类型,它无法计算这个长度。

typedef作为一种教学工具

任何时候当我们遇到一种复杂的类型时,类似于指向某种类型的指针的指针的指针等情况,可以考虑用typedef进行简化。

例如,下面这个常见的定义:

有效地减少了字符串数组的视觉混乱,使它们的意图变得清晰。

在前面的指针运算p++例子中,char *list[]这样的声明是否很清楚地告诉你它表示一个字符串列表而*p是一个字符串?

例6-8对例6-7的for循环进行了重写,用string替换了char *。

例6-8 添加一个typedef声明使笨拙的代码稍稍变得清晰(pointer_arithmetic2.c)

list的声明行现在变得简单,很清晰地表示它是个字符串列表,并且string *p也很清晰地表示p是个指向字符串的指针。因此,*p表示一个字符串。

最后,我们仍然需要记住字符串是个指向字符的指针。例如,NULL是个合法的字符串值。

我们甚至可以更进一步,例如使用上面的typedef加上typedef stringlist string*,声明一个字符串的二维数组。这种方法有时候非常实用,但有时候只会增加记忆的负担。

从概念上讲,函数类型的语法实际上是指向一个特定类型的函数的指针。如果我们有一个头部类似下面这样的函数:

然后只要添加一个星号(并加上括号以保证优先级),就可以描述一个指向这种类型的函数的指针:

然后在前面加上typedef来定义一种类型:

现在我们可以把它当作一种类型使用,例如声明一个接受另一个函数作为其输入参数的函数,可以这样:

通过对函数指针类型的重新定义,那些接受其他函数作为输入的函数的表达—其中连环星号的书写曾是令人生畏的考验变得不再可怕。

最后需要说明的是,指针实际上要比教科书所描述的简单得多,因为它实际上只是一个位置或别名,根本不需要涉及不同类型的内存管理。像指向字符串的指针的指针这样的复杂构造总是会让人感到迷惑,但这只不过是因为我们以狩猎为生的祖先从来没有见到过这玩意而已。至少,C提供了typedef这个工具来处理它们。


相关图书

代码审计——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版)

相关文章

相关课程