微型-C-语言项目-全-
微型 C 语言项目(全)
原文:Tiny C Projects
译者:飞龙
前置材料
前言
C 语言编程是否仍然相关?
每当我读到 C 语言正在变得过时的时候,总会有一篇新的文章出现,讲述 C 语言如何继续成为最受欢迎、需求量最大的编程语言之一——即使它已经度过了 50 岁生日。抛开贬低不谈,C 语言是系统编程、网络、游戏和编写微控制器的主要语言。甚至那些酷孩子们吹嘘的时髦语言,其核心可能最初也是用 C 语言编写的。它不会在短时间内消失。
我经常把 C 语言称为计算机编程语言的拉丁语。它的语法甚至一些关键字被其他语言大量借用。正如了解拉丁语有助于你理解并学习法语、意大利语、西班牙语和其他语言一样,了解 C 语言可以使你轻松理解并学习其他编程语言。但不要止步于此!磨练你的 C 语言技能与锻炼肌肉一样重要。那么,还有什么比不断编写小型、有用的程序来完善你的 C 语言编程能力更好的方法呢?
我为什么写这本书?
我认为学习编程的最佳方式是使用小型演示程序。每个程序都专注于语言的一个特定部分。代码简短且易于输入,它能强调一个观点。如果小程序能做些令人印象深刻、启发人心或愚蠢的事情,那就更好了。
我的编程方法与其他我读过的编程书籍形成对比。这些冗长的书籍通常只列出一个巨大的程序,以强调所有概念。当你对正在发生的事情一无所知时,输入 100 行代码是令人沮丧的,而且它错过了编程中更令人愉悦的一个方面:即时反馈。
某种程度上,编写小程序的习惯一直伴随着我,即使在我写 C 语言编程书籍或教授在线 C 语言编程课程之后也是如此。多年来,我在我的博客c-for-dummies.com/blog上编写小程序。我这样做是为了为我的读者和学习者提供补充材料,也是因为我喜欢编程。
当然,为了使小程序有意义,它们必须存在于古老的命令行、文本模式环境中。图形有限。动画单调。然而,兴奋感仍然存在——尤其是在只有几行代码就能展示有用功能的时候。
我的编程方法与我编写代码的方式相似:从小处着手,逐步扩展代码。因此,这本书中的程序可能最初只有十几行代码,输出一个简单的信息。从那里开始,这个过程逐渐扩展。最终,一个有用的程序出现了,所有这些都在保持小程序紧凑的同时,沿途教授有用的知识。
谁知道何时会突然有灵感,你决定编写一个实用的命令行工具来提高你的工作效率?有了 C 语言编程的知识、愿望和几个小时的时间,你可以实现它。我希望这本书能给你提供足够的灵感。
致谢
我原本想成为一名小说作家。一度,我与一位喜欢我的作品的杂志编辑有私人通信,但从未发表过任何作品。然后,我得到了在计算机图书出版社 CompuSoft 的一份工作。在那里,我将自学编程技能与我对写作的热爱结合起来,帮助编写了一系列技术书籍。正是在那里,我学会了如何为初学者写作并在文本中注入幽默。
六年后,我写了《DOS For Dummies》,这本书彻底改变了计算机图书出版行业。这本书表明,通过使用幽默,技术类标题可以成功地传授给初学者信息。整个行业都发生了变化,而“Dummies”现象至今仍在继续。
由于互联网和人类对印刷材料的厌恶,计算机图书行业已经衰落。尽管如此,这仍然是一次伟大的旅程,我有很多人要感谢:Dave Waterman,因为他在 CompuSoft 雇佣了我并教会了我技术写作的基础;Bill Gladstone 和 Matt Wagner,因为他们是我的代理人;Mac McCarthy,因为提出了《DOS For Dummies》这个疯狂的想法;以及 Becky Whitney,因为她是我长期以来的、最喜欢的编辑。她教会了我关于写作的更多东西——或许只是教会了我如何以使她作为编辑的工作变得容易的方式进行写作。我感谢你们所有人。
最后,向所有审稿人致谢:Adam Kalisz、Adhir Ramjiawan、Aditya Sharma、Alberto Simões、Ashley Eatly、Chris Kolosiwsky、Christian Sutton、Clifford Thurber、David Sims、Glen Sirakavit、Hugo Durana、Jean-François Morin、Jeff Lim、Joel Silva、Joe Tingsanchali、Juan Rufes、Jura Shikin、K. S. Ooi、Lewis Van Winkle、Louis Aloia、Maciej Jurkowski、Manu Raghavan Sareena、Marco Carnini、Michael Wall、Mike Baran、Nathan McKinley-Pace、Nitin Gode、Patrick Regan、Patrick Wanjau、Paul Silisteanu、Phillip Sorensen、Roman Zhuzha、Sanchir Kartiev、Shankar Swamy、Sriram Macharla 和 Vitosh Doynov,你们的反馈帮助使这本书变得更好。
关于本书
谁应该阅读这本书?
本书假设您对 C 语言有良好的了解。您不需要成为专家,但一个初学者可能会觉得进度有些吃力。虽然我解释了使用的技术和编写这些小程序的方法,但我并没有深入探讨 C 语言基本方面的运作原理。
我选择的操作系统是 Linux。尽管我在 Linux 机器上运行了代码,但我是在 Windows 10/11 下运行的 Ubuntu Linux 上开发的程序。这些程序也可以在 Macintosh 上运行。本书中的所有程序都是文本模式,需要终端窗口和了解各种 shell 命令,尽管没有太多技术性或特定的内容。第一章涵盖了在命令提示符环境下的编码和构建的细节。
总结:本书是为任何热爱 C 语言、喜欢编程并从编写小型、有用和有趣的程序中找到乐趣的人而写的。
本书是如何组织的:一个路线图
本书分为 15 章。第一章涉及配置和设置,以确保您能够正确开始,并能够在不发疯的情况下编码和创建程序。
从第二章到第十五章,每章都涵盖一种特定的程序类型。章节基于程序的想法构建,通常先展示一个简单版本,然后扩展程序以提供更多功能。有时还会介绍其他程序,每个程序都遵循主要主题或以其他方式协助主要程序实现其目标。
软件硬件要求
任何现代版本的 C 编译器都与本书兼容。代码不涉及任何新的 C 语言关键字。一些函数是针对 GNU 编译器的。这些在文本中提到,如果您的 C 编译器缺少 GNU 扩展,则提供替代方法。
构建任何程序都不需要第三方库。Linux 发行版之间的差异或 Windows 10/11 与 macOS 之间的差异在创建此处展示的代码中不起重要作用。
在线资源
我的个人 C 编程网站是c-for-dummies.com,每周更新。自 2013 年以来,我一直在保持每周 C 语言课程的习惯,每个课程都涵盖 C 编程的特定主题,提供编码技巧建议,并提供每月练习挑战。请查看博客以获取有关 C 的最新信息和反馈,以及本书的更多详细信息。
我还在 LinkedIn Learning 教授各种 C 编程课程。这些课程从入门级到高级主题,例如使用各种 C 语言库、指针和网络编程。访问www.linkedin.com/learning/instructors/dan-gookin查看我的课程。
关于代码
本书包含许多源代码示例,无论是编号列表还是与普通文本并列。在这两种情况下,源代码都使用固定宽度字体格式化,如这样,以将其与普通文本区分开来。
在许多情况下,原始源代码已被重新格式化;我们添加了换行符并重新调整了缩进,以适应书籍中的可用页面空间。在极少数情况下,即使这样也不够,列表中还包括了行续续标记(➥)。此外,当代码在文本中描述时,源代码中的注释通常已从列表中删除。许多列表旁边都有代码注释,突出显示重要概念。
您可以从本书的 liveBook(在线)版本中获取可执行的代码片段livebook.manning.com/book/tiny-c-projects。书中示例的完整代码可在 Manning 网站www.manning.com和 GitHubgithub.com/dangookin/Tiny_C_Projects上下载。
liveBook 讨论论坛
购买 《Tiny C 项目》 包括免费访问 liveBook,曼宁的在线阅读平台。使用 liveBook 的独家讨论功能,你可以在全球范围内或特定章节或段落中添加评论。为自己做笔记、提出和回答技术问题,以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问 livebook.manning.com/book/tiny-c-projects/discussion。你还可以在 livebook.manning.com/discussion 了解更多关于曼宁论坛和行为准则的信息。
曼宁对读者的承诺是提供一个平台,让读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议你尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要这本书还在印刷,论坛和以前讨论的存档将可通过出版社的网站访问。
关于作者

丹·古金自计算机蒸汽动力时代以来就开始撰写关于技术的文章。他将自己的写作爱好与对奇巧小玩意儿的迷恋相结合,创作出既具有信息量又富有娱乐性的书籍。他已经撰写了超过 170 本书籍,印刷量数百万册,并被翻译成 30 多种语言,丹可以证明他创作计算机书籍的方法似乎很有效。
也许他最著名的作品是 1991 年出版的原始版 《DOS 入门》,它成为了世界上销量最快的计算机书籍,一度每周的销量超过了《纽约时报》的#1 畅销书榜单(尽管作为参考书籍,它不能列入《纽约时报》的畅销书榜单)。从那本书衍生出了整个 《入门》 书系,这一系列书籍至今仍是一个出版现象。
丹的流行书籍包括 《PC 入门》、《Android 入门》、《Word 入门》 和 《笔记本电脑入门》。他最受欢迎的编程书籍是 《C 入门》,支持网站为 c-for-dummies.com。丹还在领英学习网站上提供在线培训,他的许多课程涵盖了各种主题。
丹在加州圣地亚哥大学获得了传播/视觉艺术学位。他住在太平洋西北部,在那里他担任爱达荷州库尔德阿莱恩市的市议员。丹喜欢在业余时间园艺、骑自行车、木工,并惹恼那些自认为很重要的人。
关于封面插图
《微型 C 项目》封面上的图像被标注为“卡尼奥拉女子”,或“卡尼奥拉妇女”,取自雅克·格拉塞·德·圣索沃尔的收藏,该收藏于 1797 年出版。每一幅插图都是手工精心绘制和着色的。
在那些日子里,仅凭人们的服饰就能轻易地识别出他们居住的地方以及他们的职业或社会地位。曼宁通过基于几个世纪前丰富多样的地域文化的书封面来庆祝计算机行业的创新精神和主动性,这些文化通过如这一系列图片的图片被重新带回生活。
1 配置和设置
这第一章完全是可选的。如果你已经知道如何构建 C 代码,特别是如果你熟悉在命令提示符下工作,那么就别浪费时间,愉快地跳到第二章。否则,就坚持下去。
-
复习 C 语言开发周期
-
使用集成开发环境(IDE)构建代码
-
在终端窗口中探索命令行编程的激动人心之处,就像爷爷那样
-
查看链接库和提供命令行参数的选项
这份材料的目的是为了复习,尽管如果你从未使用过命令行编程,那么你将会有一个惊喜:我发现命令行编程既快又简单,特别是对于这本书中创建的小程序来说。这段代码非常适合命令行环境。
你还在阅读吗?很好。当你的 C 编程技能生锈或只是想确认你所知道的是否足以成功导航本书的其余部分时,这一章就起到了复习的作用。我很感激你还在这里。否则,这些页面将是空白的。
那么为什么技能会生锈呢?是铁和氧气吗?计算机术语领域需要为糟糕的技能创造新术语,这些术语应该非常令人反感,以至于被广泛接受。我会对这个话题进行沉思,也许在章节末尾添加一个类似的问题。
1.1 C 语言开发周期
根据目前在大英博物馆展出的古代美索不达米亚泥板,开发 C 语言程序需要四个步骤。这些步骤在图 1.1 中得到了说明,你可以清楚地看到用楔形文字书写的 C 语言开发周期。

图 1.1 C 语言开发周期,由大英博物馆提供
作为复习,并且因为我们都不懂巴比伦语,以下是翻译:
-
首先创建源代码文件。
-
将源代码编译成目标代码。
-
链接库以创建程序文件。
-
最后,运行程序进行测试,失望或高兴。
第 4 步是我相当自由的翻译。原文是:“运行程序并通过吃一头牛而欢欣鼓舞。”我还省略了对异教神祇的提及。
这些步骤提供了一个简单的过程概述。由于不可避免的错误、错误、糟糕的失误和缺乏牛,步骤更多。以下各节将描述细节。
1.1.1 编辑源代码
C 语言源代码是纯文本。使文件成为 C 源代码文件而不是无聊的普通文本文件的是.c 文件扩展名;所有 C 源代码文件都使用这个文件扩展名。眼码使用.see 扩展名。海军码使用.sea。了解它们之间的区别。
使用文本编辑器来编写源代码。不要使用文字处理器,这就像用直升机修剪树木一样。不要让令人兴奋的视觉图像让你分心;你的目标是使用最适合这项工作的工具。任何纯文本编辑器都可以工作,尽管好的编辑器提供了诸如颜色编码、模式匹配和其他使过程更简单的特性。我更喜欢 VIM 文本编辑器,它可以在vim.org找到。VIM 可以作为文本模式(终端窗口)程序和 GUI 或窗口版本提供。
集成开发环境(IDEs)内置了文本编辑器,这就是 IDE 中的 I 所代表的意义:集成。除非有选项可以更改它,否则你将一直使用这个编辑器。例如,在 Visual Studio Code 中,你可以获取一个扩展来将你喜欢的编辑器命令带到 IDE 中。
作为一个小贴士,.c 文件扩展名定义了 C 语言源代码文件类型,操作系统通常将其与你的 IDE 关联。在我的系统中,我将.c 文件与我最喜欢的 VIM 文本编辑器关联。这个技巧允许我双击一个 C 源代码文件图标,然后它就会在我的文本编辑器中打开,而不是让 IDE 加载。
1.1.2 编译、链接、构建
编写源代码后,你构建程序。这个过程结合了两个原始步骤,只有老程序员之家的一小部分程序员还记得:编译和链接。今天的许多代码婴儿只想到编译,但链接仍然存在。
在源代码文件尽可能完美之后,你将其编译成目标代码:编译器消耗源代码文件中的文本,对其进行处理,然后输出一个目标代码文件。目标代码文件传统上具有 .o(“点-零”)文件扩展名,除非你的编译器或 IDE 选择异端的 .obj 扩展名。
源代码中冒犯编译器的项会被标记为警告或错误。错误会以一个既粗鲁又有帮助的消息终止进程。警告也可能阻止目标代码的创建,但通常编译器会耸耸肩,仍然创建一个目标代码文件,认为你足够聪明,可以回头解决问题。你可能并不这么认为,这就是为什么我告诫你一定要认真对待编译器警告。
目标代码与 C 库文件链接或组合以构建程序。任何错误都会停止进程,这需要通过重新编辑源代码、编译和链接来解决。
现在,编译和链接的原始单独步骤被合并成一个称为构建的单一步骤。编译和链接仍然会发生。无论需要多少步骤,结果都是创建一个程序。
运行程序。
当我的努力在构建过程中没有警告或错误地成功时,我非常紧张。当我运行程序并且它第一次运行正常时,我更加怀疑。尽管如此,这种情况确实发生了。准备好感到高兴或者证实你的怀疑。当事情出错时,这通常是大多数情况,你会重新编辑源代码文件,编译,链接,然后再次运行。实际上,实际的 C 程序开发周期看起来更像图 1.2。

图 1.2 程序开发周期的真正本质。(图片由加利福尼亚州公路安全部提供。)
为了消遣一下,Unix 中的原始 C 编译器被称为 cc。猜猜它代表什么?
原始 Unix 链接器被称为ld。它可能代表“link dis。”ld程序仍然存在于今天的 Linux 和其他类 Unix 系统中。它由编译器内部调用——除非代码充满了错误,在这种情况下,编译器会叫它的朋友 Betsy 嘲笑你的 C 代码多么糟糕。
好吧。ld程序很可能是 Link eDitor 的缩写。请立即停止撰写那封电子邮件。
1.2 集成开发环境(IDE)
大多数程序员更喜欢在一个集成开发环境,或IDE中工作——这个程序是用于创建软件的软件,就像一个制作烤面包机的烤面包机,它不仅制作烤面包机,还制作面包。
IDE 将编辑器、编译器和运行环境结合在一个程序中。使用 IDE 对于创建 GUI 程序是必须的,在这些程序中,你可以构建图形元素,如窗口和对话框,然后将其添加到你的代码中,而无需手动编写所有代码。程序员们喜欢 IDE。
1.2.1 选择一个 IDE
你不需要 IDE 来制作本课程中展示的程序。我建议你使用命令提示符,但你固执己见,喜欢你的 IDE——而且你还在阅读——所以我不得不写关于它。
我推荐的 C 编程 IDE 是 Visual Studio Code,可在code.visualstudio.com找到。它适用于 Windows、macOS 和 Linux。
Visual Studio Code 可能会让人感到不知所措,所以我还推荐 Code::Blocks,可在codeblocks.org找到。它的最佳版本仅适用于 Windows。确保你获得了一个包含编译器的 Code::Blocks 版本。默认的是 MinGW,这很好。更好的是,为 Windows 获取clang,它可以在 LLVM 网站上找到:llvm.org。你必须手动说服 Code::Blocks 接受clang作为其编译器;详细信息将在下一节中提供。
如果你使用 Linux,你已经有了一个编译器,gcc,这是默认的。即便如此,我仍然建议获取 LLVM clang 编译器。它非常复杂。它具有详细的错误消息以及修复代码的建议。如果我是机器人,我会坚持使用 clang 来编译我大脑的软件。使用你发行版的包管理器立即获取这个出色的编译器!
1.2.2 使用 Code::Blocks
虽然我更喜欢 Visual Studio Code,但如果你是初学者,我推荐使用 Code::Blocks。在 Code::Blocks IDE 中构建你的第一个程序之前,请确认编译器的路径是否正确。对于标准安装,路径是:
C:\Program Files (x86)\CodeBlocks\MinGW\bin
确保为 Code::Blocks 指定此地址以便找到默认的编译器 MinGW,就是我刚才提到的。或者,如果你没有遵循设置程序的推荐,请设置正确的编译器路径。例如,可以大胆使用 LLVM clang 作为编译器。如果是这样,请设置正确的编译器路径,这样每次点击构建按钮时 Code::Blocks 都不会崩溃。
要设置路径,请遵循 Code::Blocks 中的这些指示。不要偷懒!缺少编译器错误信息是我从无法使用 Code::Blocks 的读者那里收到的最常见的电子邮件投诉信息之一。请在 Code::Blocks 中按照以下步骤操作:
-
选择设置 > 编译器。
-
在编译器设置对话框中,点击工具链可执行文件选项卡。
-
将编译器的地址写入编译器安装目录的文本框中。
-
点击确定。
在完成这些步骤后,IDE 应该对编译器感到满意。如果不满意——是的,你猜对了——找一些牛来。
一旦设置了编译器,你就可以使用 Code::Block 的内置编辑器来创建你的代码。编辑器使用彩色编码,匹配括号和其他成对元素,并为 C 库函数提供内联上下文辅助。一切都很不错。
创建并保存源代码后,Code::Blocks 使用构建命令来编译和链接源代码。消息将在窗口的另一部分输出,你可以在这里阅读操作是否成功或失败。
图 1.3 显示了 Code::Blocks 的工作空间。其展示可以自定义,尽管在图中要寻找用于构建、运行或组合构建和运行的按钮的标注项。

图 1.3 Code::Blocks IDE 窗口中的重要内容
与所有 IDE 一样,Code::Blocks 更喜欢你在开始编码时创建一个新的项目。过程如下:
-
点击文件 > 新建 > 项目。
-
在“从模板新建”窗口中,选择控制台应用程序图标,然后点击“Go”按钮。
-
选择 C 作为编程语言,然后点击下一步。
-
为项目输入一个标题,这个标题也是项目文件夹树的名称。
-
选择创建项目的文件夹。
-
点击下一步。
-
选择创建发布配置。除非你打算使用 Code::Blocks 调试器(它真的很酷,但不是必需的),否则不需要调试配置。
-
点击完成以创建项目框架。
Code::Blocks 会创建各种文件夹并创建一个预写的源代码文件 main.c。你可以用你自己的内容替换这个文件的内容。我发现整个过程很繁琐,但这是 IDE 喜欢的工作方式。
作为替代,你可以使用文件 > 新建 > 空文件命令在编辑器中打开一个新的源代码文件。立即将文件保存为 .c 文件扩展名以激活编辑器的巧妙功能。然后你可以继续创建单个程序,而无需忍受完整项目的庞大和沉重。
现有的文件——比如那些你为本书或其他邪恶目的从 GitHub 盗取的文件——可以直接打开。直接打开任何文件的目的在于,你不需要创建项目的大量工作和开销来创建这样的小型程序。
要在 Code::Blocks 中执行快速编译和链接,请点击构建按钮。此步骤会检查警告和错误,但不会运行创建的程序。如果一切顺利,点击运行按钮以在命令提示符窗口中查看输出,如图 1.4 所示。

图 1.4 命令提示符窗口
完成操作后,请关闭命令提示符窗口。记得要这么做!有些人使用 Code::Blocks 时会遇到的一个常见问题是他们看不到输出窗口。这种困境很可能是由于已经打开了一个输出窗口。确保在测试运行你的程序后,关闭那个小小的终端窗口。
如果你感到自信,可以使用组合构建和运行按钮(参见图 1.3)而不是通过单独的构建和运行命令来工作。当你点击构建和运行时,代码会构建并立即运行,除非你满是错误,在这种情况下,你需要修复它们。
1.2.3 使用 XCode
Macintosh 上的 XCode IDE 是一款顶级应用程序,用于构建从 macOS 程序到那些运行在手机和手表上的微型应用程序的一切。你可以使用这个复杂的工具来编写本书中提供的简单命令行、文本模式实用程序。考虑到 XCode 的强大功能,这有点不切实际,但这种障碍并没有阻止数百万的苹果粉丝这么做。
如果你的 Macintosh 没有 XCode,你可以从 App Store 中免费获取一个副本。如果提示,请确保选择添加命令行工具。
要在 XCode 中创建文本模式的 C 语言项目,请遵循以下指示:
-
选择文件 > 新建 > 项目。
-
为项目选择命令行工具模板。
-
点击下一步。
-
为项目输入一个名称。
-
确保选择 C 作为语言。
-
点击下一步按钮。
-
确认文件夹位置。
-
点击创建按钮。
XCode 会构建一个项目骨架,提供包含源代码的 main.c 文件,你可以愉快地将其替换为自己的代码。
唉,与其他 IDE 不同,你无法在 XCode 中打开单个 C 源代码文件,然后构建并运行它。这就是我建议在 Mac 上使用命令行编程的原因,特别是对于本书中介绍的这些小型文本模式实用程序。参考下一节。
要在 XCode 中构建和运行,请点击图 1.5 所示的运行图标。输出显示在项目窗口的底部,如图所示。

图 1.5 XCode 的窗口。(眯眼才能看清楚。)
虽然项目文件可能位于您在步骤 7 中选择的文件夹中,但生成的程序是在 XCode 的文件夹系统中创建并隐藏起来的。这种隐藏尝试使得运行和测试本书中展示的命令行程序变得不方便。具体来说,在提示符下设置命令行选项或执行 I/O 重定向需要跳过太多的障碍。对我来说,这种尴尬使得使用 XCode 作为 IDE 的选择仅限于受虐狂和狂热的苹果用户。
1.3 命令行编译
欢迎来到计算机的早期年代。在文本模式下编辑、构建和运行 C 程序很怀旧,但效果很好,效率很高。您必须了解命令行的工作原理,这是我相信所有 C 程序员都应该天生就知道的。确实,很难找到一个值得这个称号的 C 程序员,他们缺乏 Unix 或 Linux 文本模式编程的知识。
1.3.1 访问终端窗口
每个 Linux 发行版都自带一个终端窗口。MacOS 有一个终端程序。即使是 Windows 10 也自带命令行外壳,但最好安装 Windows Subsystem for Linux (WSL)并使用 Ubuntu bash 外壳以与其他平台保持一致。从未有过如此好的文本模式编程时代。打开一个 Tab,脱掉你的凉鞋吧!
-
要在 Linux 中启动终端窗口,请在 GUI 的程序菜单中查找终端程序。它可能被称为终端、Term、Xterm 或类似名称。
-
在 Mac 上,启动终端应用程序,该程序位于实用工具文件夹中。您可以通过点击菜单中的“前往”>“实用工具”或按 Shift+Command+U 快捷键从 Finder 访问此文件夹。
-
在 Windows 10 中,打开 Microsoft Store 并搜索 Ubuntu 应用程序。下载是免费的,但要使其工作,您还必须安装 WSL。安装子系统的说明散布在互联网的各个角落。
Windows 10 的 Ubuntu 应用程序如图 1.6 所示。与所有其他终端窗口一样,它可以进行自定义:您可以重置字体大小、行数和列数、屏幕颜色等。请注意,传统的文本模式屏幕支持 80 列和 24 行的文本。

图 1.6 Windows 中的 Linux——这样的亵渎
如果您计划使用终端窗口进行程序生产,我建议保留终端程序的快捷方式以便快速访问。例如,在 Windows 中,我将 Ubuntu 壳的快捷方式固定在任务栏上。在 Mac 上,每次我登录 OS X 时,我的终端窗口都会自动启动。完成此类任务的说明隐藏在互联网上。
1.3.2 检查基本 shell 命令
我敢打赌你了解一些 shell 命令。很好。以防还有疑问,表 1.1 列出了你应该熟悉的命令,以便在命令提示符下轻松工作。这些命令没有上下文或进一步的信息,这有助于保持命令提示符神秘而强大的氛围。
表 1.1 值得关注的 shell 命令
| 命令 | 它的作用 |
|---|---|
| cd | 切换到指定的目录。如果没有参数,命令会切换到你的家目录。 |
| cp | 复制一个文件。 |
| exit | 从终端窗口注销,可能会关闭窗口。 |
| ls | 列出当前目录中的文件。 |
| man | 调用名为 shell 命令或 C 语言函数的手册页面(在线文档)。这是需要了解的最有用的命令。 |
| mkdir | 创建一个新的目录。 |
| mv | 将文件从一个目录移动到另一个目录。也可以用来重命名文件。 |
| pwd | 打印当前工作目录。 |
| unlink | 删除指定的文件。 |
表 1.1 中列出的每个命令都有选项和参数,例如文件名和路径名。大多数内容都是小写输入,拼写错误是不可原谅的。(一些 shell 提供拼写检查和命令补全。)
另一个需要了解的命令是 make,它有助于构建更大的项目。这个命令在本书的后续部分会有介绍。我本想列出章节参考,但我还没有写完这一章。
另一个重要的事情是了解包管理器的工作方式,尽管在许多 Linux 发行版中,你可以从 GUI 包管理器中获取命令行包。如果不是这样,熟悉命令行包管理器的工作方式。
例如,在 Ubuntu Linux 中,使用apt命令来搜索、安装、更新和删除命令行软件。各种神奇选项使得这些操作得以实现。哦,并且apt命令必须从超级用户账户运行;屏幕上的说明会解释细节。
我的最终建议是理解文件命名约定。在 GUI 中输入空格和其他奇怪字符很容易,但在命令提示符下,它们可能会很麻烦。大部分情况下,使用反斜杠字符\作为转义符来前缀空格。你也可以利用文件名补全:在bash、zsh和其他 shell 中,输入文件名的一部分,然后按 Tab 键自动输出剩余的名称。
文件命名约定也涵盖了路径名。了解相对路径和绝对路径之间的区别,这在运行程序和管理文件时很有帮助。
我相信你可以在某处找到一本好书来帮助你提高 Linux 知识。这里是对来自 Manning Publications 的书籍的义务推荐:《一个月午餐学 Linux》,作者史蒂文·奥瓦迪亚(2016 年)。记住,在图书馆是免费的。
1.3.3 探索文本屏幕编辑器
要正确地吸引命令提示符,您必须知道如何使用文本模式编辑器。许多 Linux 系统默认安装了文本模式编辑器。那些没有安装的可以从您发行版的包管理器中获取。在 Mac 上,您可以使用 Homebrew 系统添加苹果认为不值得随操作系统一起发货的文本模式程序;更多关于 Homebrew 的信息请访问brew.sh。
我最喜欢的文本模式编辑器是VIM,它是经典 vi 编辑器的改进版本。它有一个在文本模式下运行的终端窗口版本,以及一个完整的 GUI 版本。该程序适用于所有操作系统。
大多数程序员对VIM不满的是,它是一个模式编辑器,这意味着您必须在文本编辑和输入模式之间切换。这种双重性让一些程序员疯狂,但对我来说这没关系。
另一个流行的文本模式编辑器是Emacs。像VIM一样,它也作为文本模式编辑器和 GUI 编辑器提供。我不使用Emacs,因此无法详细阐述其优点。
无论您获得哪种文本编辑器,请确保它提供 C 语言颜色编码以及其他有用的功能,如匹配对:括号、方括号和大括号。在许多编辑器中,可以自定义功能,例如编写一个启动脚本,将编辑器调整到您喜欢的样子。例如,我更喜欢在代码中使用四个空格的制表符,我可以通过配置主目录中的.vimrc 文件来设置它。
1.3.4 使用 GUI 编辑器
虽然这可能有些令人震惊,但在命令提示符下工作时使用 GUI 编辑器却很方便。这种安排是我的首选编程模式:我在编辑器的辉煌图形窗口中编写代码,然后在阴暗的文本模式终端窗口中构建和运行。这种安排给了我 GUI 编辑器的力量,同时还能检查文本模式输出,如图 1.7 所示。

图 1.7 一个桌面,其中有一个编辑器和终端窗口按照这种方式排列
使用 GUI 编辑器的唯一限制是,在您在另一个窗口构建之前,必须记得在一个窗口中保存源代码。当您在终端中运行文本模式编辑器时,这个提醒并不是一个大问题,因为您在退出时保存。但是,在桌面上的两个不同窗口之间切换时,很容易忘记保存。
1.3.5 编译和运行
Linux 中的命令行编译器是 gcc,它是 Unix 石器时代原始 cc 编译器的 GNU 版本。正如我之前所写的,我推荐使用 clang 编译器而不是 gcc。它提供了更好的错误报告和建议。使用您发行版的包管理器获取 clang 或访问llvm.org。在本章的剩余部分以及本书的其余部分,我的假设是您使用 clang 作为您的编译器。
要构建代码,包括编译和链接步骤,请使用以下命令:
clang -Wall source.c
编译器名为 clang。-Wall 开关激活所有警告——总是一个好主意。source.c 代表源代码文件名。我刚才列出的命令在成功时生成一个名为 a.out 的程序文件。警告也可能产生程序文件;自行运行风险自负。错误消息表明你必须解决的问题的严重问题;不会生成程序文件。
如果你想设置输出文件名,请使用-o 选项后跟输出文件名:
clang -Wall source.c -o program
在成功的情况下,之前的命令生成一个名为 program 的程序文件。
编译器将程序的执行位以及与你的账户匹配的文件权限设置为可执行。一旦你的程序创建完成,就可以运行了。
要运行程序,你必须指定其完整路径名。记住,在 Linux 中,除非程序存在于搜索路径上的目录中,否则必须指定完整路径名。对于当前目录中的程序,使用./前缀,如下所示:
./a.out
此命令运行当前目录下名为 a.out 的程序文件,如下所示:
./cypher
此命令运行当前目录下名为 cypher 的程序。
单点号是当前目录的缩写。斜杠将路径名与文件名分开。一起,./强制 shell 在当前目录中查找并运行指定的程序。因为程序的执行位已设置,其二进制数据被加载到内存中并执行。
1.4 库和编译器选项
作为一位渴望提高技艺的人,你必须意识到各种编译器选项——特别是那些链接库的选项。这些库将一个普通的 C 程序扩展到具有更大能力的领域。
所有 C 程序都会链接到标准 C 库。这个库包含了诸如printf()等函数背后的动力。然而,对于初学者 C 程序员来说,一个常见的误解是包含动力的实际上是头文件。不,链接器通过将编译器创建的目标代码与 C 语言库相结合来构建程序。
还有一些其他库可供使用,除了标准 C 库外,还可以链接以构建复杂和有趣的程序。这些库为你的程序增加了更多功能,提供访问互联网、图形、特定硬件以及其他许多有用功能。有成百上千的库可供使用,每个库都能帮助扩展你程序的可能性。使用这些库的关键是理解它们是如何链接的,这也引发了编译器选项或命令行开关的问题。
如你所料,添加选项和链接库的方法在 IDE 和命令提示符创建程序的方法之间有所不同。
1.4.1 在 IDE 中链接库和设置其他选项
使用 IDE 不方便的一个领域是设置编译器选项或指定命令行参数的任务。设置选项包括链接库。你必须不仅发现链接选项隐藏在哪里,还要确认库在文件系统中的位置,并确保它与编译器兼容。
我没有时间,而且这真的不是这本书的主题,去具体说明每个 IDE 以及它们如何为构建的程序设置命令行参数,或者如何设置特定选项,例如链接额外的编译器。毕竟,我已经足够推崇命令行编程了——得到提示了吗!但如果你们坚持,或者只是喜欢看到事情可以有多难,请继续阅读。为了简洁起见,我将坚持使用 Code::Blocks,因为我对它最熟悉。其他 IDE 也有类似选项和设置。我希望如此。
Code::Blocks 中的编译器选项可以在设置对话框中找到:点击“设置”>“编译器”来查看对话框,如图 1.8 所示。这是你指定另一个要链接的库的同一位置。

图 1.8 在 Code::Blocks 的设置对话框中查找有用内容
预设选项列在编译器标志选项卡上,如图 1.8 所示。这个选项卡是编译器设置选项卡的一个子选项卡,如图中也已指出。每个选项的命令行开关显示在描述性文本的末尾。
使用“其他编译器选项”选项卡来指定在编译器标志选项卡上找不到的任何选项。我想不出任何具体的选项你可能想添加,但这个选项卡就是它们所在的地方。
点击“链接器设置”选项卡(参见图 1.8)来添加库。点击“添加”按钮来浏览要链接的库。你必须知道库文件所在的文件夹。与命令行编译不同,库文件的默认目录不会自动搜索。同样,头文件文件通常与库文件位于同一目录树中。
要在 Code::Blocks 中指定程序的命令行参数,请使用“项目”>“设置程序参数”命令。这里的问题是菜单中的撇号位置不正确;它应该读作“程序的”。我之所以提到这一点,是因为我的编辑器否则会询问我。
在选择了语法错误的“设置程序参数”命令后,你会看到“选择目标”对话框。使用“程序参数”文本字段来指定在 IDE 中运行的程序所需的参数。这里的限制是,你的命令行程序必须在 Code::Blocks 中作为项目构建。否则,设置命令行参数的选项不可用。
请注意,本书中展示的小程序是为在命令提示符下运行而设计的,这在 IDE 中设置参数时显得有些奇怪。因为 IDE 创建了一个程序,你可以直接导航到程序文件夹,在命令提示符下直接运行程序。如果可能的话,发现你的 IDE 是否允许你快速访问包含程序可执行文件的文件夹。或者,就屈服于在终端窗口中编程的不可避免地简单和自我实现的快乐吧。
1.4.2 使用命令行编译器选项
在终端窗口的命令提示符下键入编译器选项和程序参数既简单又明显:没有额外的设置、菜单、鼠标点击或其他选项需要寻找。再次强调,这些是本书中展示的程序以及许多小型 C 项目在命令提示符下编程的理由之一。
在一系列命令行选项中,值得注意的一个是 -l(小写的 L)。此开关用于链接库。-l 后面直接跟库的名称,例如:
clang -Wall weather.c -lcurl
在这里,名为 curl 的 libcurl 库与标准 C 库一起链接,基于 weather.c 源代码文件构建程序。(你不需要指定标准 C 库,因为它默认已经链接。)
要指定输出文件名,使用前面在本章中提到的 -o 开关:
clang -Wall weather.c -lcurl -o weather
在某些编译器中,选项顺序很重要。如果你在使用 -l 开关时看到一连串的链接错误,请改变参数顺序,将 -l 放在最后指定:
clang -Wall weather.c -o weather -lcurl
在命令行中,编译器会搜索默认目录以查找库文件和头文件的位置。在 Unix 和 Linux 中(但不是 OS/X),这些位置如下:
-
头文件:/usr/include
-
图书馆文件:/usr/lib
你安装的自定义库和头文件可以在这这些位置找到:
-
头文件:/usr/local/include
-
图书馆文件:/usr/local/lib
编译器会自动搜索这些目录以查找头文件和库。如果库文件存在于其他位置,你可以在 -l 开关之后指定其路径名。
为你的程序指定命令行参数并不涉及任何麻烦。与 IDE 不同,参数直接在程序名称后键入:
./weather KSEA
在这里,天气程序在当前目录下以单个参数 KSEA 运行。简单。容易。我不会再使用更高级的形容词。
1.5 测验
我决定不添加测验。
2 每日问候
你的电脑一天从你登录开始。原始术语是登录,但由于树木如此稀少,标志如此众多,这个术语在 2007 年被布什政府所改变。不管这种令人讨厌的联邦过度干预,你可以在登录或打开终端窗口后,通过一个小小的 C 程序定制一个愉快的问候。为了实现这一点,你需要:
-
回顾 Linux 启动过程。
-
发现在哪里添加你的问候信息。
-
编写一个简单的问候程序。
-
修改你的问候程序以添加时间。
-
更新时间戳以显示当前月相。
-
用一句俏皮话增强你的问候信息。
本章创建和扩展的程序特定于 Linux、macOS 和 Windows Subsystem for Linux (WSL),在这些系统中,启动脚本可用于配置终端窗口。稍后的部分将解释哪些启动脚本适用于更流行的 shell。本章不会涉及在 GUI shell 启动时创建每日问候信息。
我猜你可以在 Windows 终端屏幕、命令提示符中添加一条启动信息。这是可能的,但这个过程让我感到无聊,而且只有那些狂热的 Windows 极客才会关心,所以我就不具体说明了。如果你有这个愿望,问候程序仍然会在 Windows 命令提示符中运行。否则,你可以直接向我个人提出投诉;我的电子邮件地址可以在本书的引言中找到。我保证不会回复任何抱怨的 Windows 用户发来的邮件。
2.1 shell 启动
Linux 的启动过程漫长、复杂且非常激动人心。我确信你急于阅读所有这些细节。但本书是关于 C 编程的。你必须寻找一本关于 Linux 的书籍,以了解唤醒 Linux 计算机所涉及的完整、热烈的步骤。与创建每日问候相关的激动人心的事情发生在操作系统完成早晨例程之后,当 shell 开始运行时。
2.1.1 理解 shell 的位置
Linux 系统上的每个用户账户都被分配了一个默认 shell。这个 shell 曾经是 Linux 的唯一接口。我记得在 1990 年代早期启动了一个 Red Hat Linux 的早期版本,我看到的第一个——也是唯一一件事——就是一个文本模式的屏幕。今天事情都是图形化的,shell 被移到了一个终端窗口中。在这里它仍然很重要,这对于 C 编程来说是个好消息。
默认 shell 是由某个东西配置的。我太懒了,不想在这里写关于它的事情。再次强调,这不是一本关于 Linux 的书。简单地说,你的账户很可能使用的是 bash shell——这是“Bourne again shell”这个词的碰撞,所以我的“bash shell”写作是多余的(就像 ATM 机一样),但如果不这样写看起来又很别扭。
要确定默认 shell,启动一个终端窗口。在提示符下,输入命令 echo $SHELL:
$ echo $SHELL
/bin/bash
这里,输出确认分配给用户的 shell 是 bash。$SHELL 参数代表分配给启动 shell 的环境变量,在这里是 /bin/bash。此输出可能不反映当前 shell——例如,如果您随后运行了 sh 或 zsh 或类似命令以启动另一个 shell。
要确定当前 shell,请输入命令 ps -p $$:
$ ps -p $$
PID TTY TIME CMD
7 tty1 00:00:00 bash
此输出显示 shell 命令是 bash,这意味着无论 $SHELL 变量的分配如何,当前 shell 都是 bash。
要更改 shell,请使用 chsh 命令。命令后跟新的 shell 名称。更改 shell 只会影响您的账户,并适用于您在发出命令后打开的任何新终端窗口。今天关于 Linux 的内容就到这里。
2.1.2 探索各种 shell 启动脚本
当 shell 启动时,它会处理位于各种启动脚本中的命令。其中一些脚本可能是全局的,位于系统目录中。其他脚本可能特定于您的账户,位于您的主目录中。
启动脚本配置终端。它们允许您自定义令人讨厌的纯文本体验,例如添加颜色、创建快捷方式以及执行您可能需要每次打开终端窗口时手动执行的各种任务。位于您主目录中的任何启动脚本文件都是您可以配置的。
综上所述,一般建议不要干涉启动 shell 脚本。为了强调这一点,shell 脚本文件隐藏在您的 home 目录中。文件名以单个点开头。点前缀隐藏文件,使其不会出现在标准目录列表中。这种隐蔽性使得文件便于使用,同时又能防止普通用户尝试干涉它们。
因为您想干涉 shell 启动脚本,特别是要添加个性化的问候语,所以有必要知道脚本名称。这些名称可能因 shell 而异,尽管在表 2.1 中显示的启动脚本通常是首选的。
表 2.1 关于 Linux shell 脚本的枯燥信息
| Shell | 名称 | 命令 | 启动文件名 |
|---|---|---|---|
| Bash | Bash,“Bourne again shell” | /bin/bash | .bash_profile |
| Tsch | Tee C shell | /bin/tsch | .tcshrc |
| Csh | C shell | /bin/csh | .cshrc |
| Ksh | Korn shell | /bin/ksh | .profile |
| Sh | Bourne shell | /bin/sh | .profile |
| Zsh | Z shell | /bin/zsh | .zshrc |
例如,对于 bash shell,我建议编辑启动脚本 .bash_profile 以添加您的问候语。其他启动脚本可能在 shell 启动时运行,但这是您可以修改的脚本。
要查看您的 shell 启动脚本,请在终端窗口中使用 cat 命令。在命令后跟 shell 的启动文件名。例如:
$ cat ~/.bash_profile
~/ 路径名是您主目录的快捷方式。在您发出前面的命令后,shell 启动脚本的内容会散布在文本屏幕上。如果不是这样,文件可能不存在,您需要创建它。
当你看到文件内容时,你可以在混乱中单独放置你的问候程序。脚本的其他部分不应该被篡改——除非你擅长用脚本语言编码并制作出色的启动脚本,这你可能不是。
2.1.3 编辑 shell 启动脚本
Shell 启动脚本是纯文本文件。它们由 shell 命令、程序名称和各种指令组成,这使得脚本像编程语言一样工作。脚本像任何文本文件一样进行编辑。
我可以就 shell 脚本写几页精彩的文字,但我有一个小时后要去看牙医,这本书是关于 C 编程的。尽管如此,你应该注意启动 shell 脚本的两个相关方面:第一行和文件的权限。
为了解释启动脚本中的文本行,文件的第一行指示 shell 使用一个特定的程序来处理文件中剩余的行。传统上,Unix shell 脚本的第一行是:
#!/bin/sh
这一行以#开头,这使得它成为一条注释。感叹号,酷孩子们告诉我读作“bang”,指示 shell 使用/bin/sh 程序(原始 Bourne shell)来处理文件中剩余的文本行。命令可以是任何东西,从 bash 这样的 shell 到expect这样的实用程序。
所有 shell 脚本都设置了可执行权限位。如果文件存在,这个设置已经完成。否则,如果你正在创建 shell 脚本,必须在文件创建后赋予它可执行位。使用 chmod 命令并带上+x 开关,然后跟脚本文件名:
chmod +x .bash_profile
发出此命令仅在你最初创建脚本时需要。
在启动脚本中,我的建议是将你的问候程序单独放在脚本末尾的一行上。你甚至可以在该行之前加上注释,以#字符开始。酷孩子们告诉我#读作“hash”。
为了练习,编辑终端窗口的启动脚本:打开一个终端窗口,并使用你喜欢的文本编辑器打开 shell 的启动脚本,如表 2.1 中所述。例如,在我的 Linux 系统中,我输入:
vim ~/.bash_profile
在脚本底部添加以下两行,在所有看起来令人印象深刻和诱人的内容之后:
# startup greetings
echo "Hello" $LOGNAME
第一行以#开头。(我希望你在心里说的是“hash”。)这个标签将这一行标记为注释。
第二行输出文本“Hello”,后跟环境变量$LOGNAME 的内容。这个变量代表你的登录账户名。
这里是示例输出:
Hello dang
我的账户登录名是dang,如所示。当终端窗口首次打开时,这一行文本是 shell 启动脚本生成的最终输出。本章剩余部分生成的 C 程序将替换这一行,输出它们愉快和有趣的消息。
当将你的问候语程序添加到启动脚本中时,指定其路径名非常重要,以免 shell 脚本解释器崩溃。路径可以是完整的,如下所示:
/home/dang/cprog/greetings
或者可以使用 ~/ 主目录快捷方式:
~/cprog/greetings
在这两种情况下,程序名为 greetings,它位于 cprog 目录中。
2.2 简单问候
所有主要的编程项目最初都很简单,并且倾向于发展成为复杂、丑陋的怪物。我确信 Excel 是作为一个快速而粗糙的文本模式计算器开始的——现在看看它。无论如何,良好的编程实践不是一开始就编写所有你需要的东西。不,最好的做法是从简单而愚蠢的东西开始,这正是本节的目的。
2.2.1 编写问候语
你能制作的最基本的问候语程序是对每个自摩西以来每本 C 语言入门书籍中出现的愚蠢 Hello World 程序的简单重复。列表 2.1 展示了你可以为你的问候语程序编写的版本。
列表 2.1 greet01.c 的源代码
#include <stdio.h>
int main()
{
printf("Hello, Dan!\n");
return(0);
}
不要构建。不要运行。如果你这样做,请使用以下命令构建一个名为 greetings 的程序:
clang -Wall greet01.c -o greetings
你可以用你喜欢的但性能稍逊的编译器替换 clang。成功后,生成的程序命名为 greetings。将此程序添加到你的 shell 启动脚本中,添加最后一行,如下所示:
greetings
确保在程序名称前加上路径名——无论是完整的路径名,如下所示:
/home/dang/bin/greetings
或者是一个相对路径名:
~/bin/greetings
启动脚本不能神奇地定位程序文件,除非你指定路径,例如示例中显示的我的个人 ~/bin 目录。(我还使用我的 shell 启动脚本将我的个人 ~/bin 目录添加到搜索路径——这是在另一本书中找到的另一个 Linux 技巧。)
启动脚本更新后,你打开的下一个终端窗口将运行一个启动脚本,输出以下行,让你的日子更加愉快:
Hello, Dan!
如果你的名字不是 Dan,那么问候语比愉快更让人困惑。
2.2.2 添加名称作为参数
问候语程序的初始版本不够灵活。这可能是你没有编写它,而是急于对其进行一些定制修改的原因。
考虑列表 2.2 中提供的适度改进。此代码更新允许你向程序提供一个参数,使其更加灵活。
列表 2.2 greet02.c 的源代码
#include <stdio.h>
int main(int argc, char *argv[])
{
if( argc<2) ❶
puts("Hello, you handsome beast!");
else
printf("Hello, %s!\n",argv[1]); ❷
return(0);
}
❶ 程序名称的参数计数始终为 1;如果是这样,则输出默认消息。
❷ 程序名称后面的第一个单词表示为 argv[1],并在此输出。
将此代码构建成一个程序,并将其按照古老卷轴中以及上一节中所述的方式放入你的 shell 启动脚本中。
greetings Danny
当你打开一个新的终端窗口时,程序现在输出以下消息:
Hello, Danny!
这条新消息比原始消息更加愉快,但仍需一些改进。
2.3 问候时间
我为我的旧 DOS 计算机编写的第一个程序是在每次开机时向我问候。这个程序与上一节中创建的程序类似,这意味着它很无聊。为了增加趣味性,并受我在现实生活中与人类互动的启发,我添加了代码,使问候语反映一天中的时间。你也可以这样做,并且可以以不同的精度实现。
2.3.1 获取当前时间
真的有谁知道现在是什么时间吗?计算机可以猜测。它通过每隔一段时间与互联网时间服务器接触来保持半准确的时间。否则,计算机的时钟每天都会偏差几分钟。相信我,计算机是糟糕的时钟,但这并不意味着你不能从其内部提取当前时间。
C 库中充满了时间函数,所有这些函数都在 time.h 头文件中定义。time_t 数据类型也在该头文件中定义。这个正整数值(long 数据类型,printf() 占位符 %ld)存储了 Unix 纪元,即自 1970 年 1 月 1 日午夜以来的滴答声秒数。
Unix 纪元是在你的问候程序中可以使用的一个很好的值。例如,想象一下,每天当你启动终端时,看到以下愉快的消息:
Hello, Danny, it's 1624424373
尽量抑制任何情绪。
当然,必须将 time_t 值转换为更有用的东西。列表 2.3 展示了一些示例代码。请注意,许多时间函数,如 time01.c 代码中使用的 time() 和 ctime(),需要 time_t 变量的地址。是的,它们是指针。
列表 2.3 time01.c 的源代码
#include <stdio.h>
#include <time.h> ❶
int main()
{
time_t now;
time(&now); ❷
printf("The computer thinks it's %ld\n",now);
printf("%s",ctime(&now)); ❸
return(0);
}
❶ 需要 time.h 头文件,否则编译器会对你不满。
❷ time() 函数需要 time_t 变量的地址,这里用 & 地址运算符作为前缀。
❸ ctime() 函数需要一个指针参数,并返回一个附加了换行符的字符串。
下面是程序输出的示例:
The computer thinks it's 1624424373
Tue Jun 22 21:59:33 2021
输出显示了自 1970 年以来的滴答声秒数。这个相同的值被 ctime() 函数吞没,以输出格式化的时间字符串。这个结果在你的问候程序中可能是可以接受的,但时间数据可以进一步定制。解锁特定时间细节的关键在于 localtime() 函数,正如列表 2.4 中的代码所展示的。
列表 2.4 time02.c 的源代码
#include <stdio.h>
#include <time.h>
int main()
{
time_t now;
struct tm *clock; ❶
time(&now);
clock = localtime(&now);
puts("Time details:");
printf(" Day of the year: %d\n",clock->tm_yday);
printf(" Day of the week: %d\n",clock->tm_wday); ❷
printf(" Year: %d\n",clock->tm_year+1900); ❸
printf(" Month: %d\n",clock->tm_mon+1); ❹
printf("Day of the month: %d\n",clock->tm_mday);
printf(" Hour: %d\n",clock->tm_hour);
printf(" Minute: %d\n",clock->tm_min);
printf(" Second: %d\n",clock->tm_sec);
return(0);
}
❶ 因为 localtime() 返回一个指针,所以最好将结构声明为指针。
❷ 一周的第一天是周日,值为 0。
❸ 你必须将 1900 加到 tm_year 成员上以获取当前年份;你会忘记这一点。
❹ tm_mon 成员的范围是 0 到 11。
我用大量的空格格式化了列表 2.4 中的代码,这样你可以轻松地识别 tm 结构的成员。这些变量代表了 localtime() 函数从一个 time_t 值中提取的时间片段。确保你记得根据列表 2.4 调整一些值:年值 tm_year 必须加 1900 以反映当前的有效年份;月份值 tm_mon 从零开始,而不是从一。
输出很简单,所以我无需展示——除非你给我一张 5 美元的支票。然而,代码的目的是展示你可以如何获取有用的时间信息,以便在终端问候中适当地点缀。
2.3.2 混合一天中的通用时间
我多年前为我的 DOS 计算机编写的程序叫做 GREET.COM。它是我的计算机 AUTOEXEC.BAT 程序的一部分,每次我启动我那可靠的旧 IBM PC 时都会运行。因为我喜欢怀旧,所以我保留了这个程序的副本。用 x86 汇编编写的它仍然可以在 DOSBox 下运行。啊,数字过去的甜美香气。闻起来像臭氧。
可惜,我不再拥有 GREET.COM 程序的源代码。根据记忆(和反汇编),我看到代码获取了当前的小时数,并输出相应的时间问候:早上好,下午好,或晚上好。你可以用同样的技巧编码——尽管是在为你的当前计算机编写的 C 语言中,而不是在古老的 IBM PC 的 x86 汇编语言中。
从本章的第一部分汇集资源,列表 2.5 展示了我旧问候程序的当前版本。
列表 2.5 greet03.c 的源代码
#include <stdio.h>
#include <time.h>
int main(int argc, char *argv[])
{
time_t now;
struct tm *clock;
int hour;
time(&now);
clock = localtime(&now);
hour = clock->tm_hour; ❶
printf("Good ");
if( hour < 12 ) ❷
printf("morning");
else if( hour < 17 ) ❸
printf("afternoon");
else ❹
printf("evening");
if( argc>1 ) ❺
printf(", %s",argv[1]);
putchar('\n');
return(0);
}
❶ 这个语句是为了方便,避免反复使用 clock->tm_hour。
❷ 在中午之前,说“早上好”。
❸ 从中午到下午 5:00,说“下午好”。
❹ 否则,就是晚上。
❺ 检查并输出第一个命令行参数。
假设编译好的程序名为 greetings,用户输入 Danny 作为命令行参数,并且现在是下午 4 点,以下是代码的输出:
Good afternoon, Danny
这段代码有效地复制了我几十年前编写的 GREET.COM 程序的内容。输出的是一个与当前时间相符的愉快问候。
为了增加幽默感,你可以添加对早间时间的测试,比如午夜到凌晨 4:00。输出一些俏皮的文字,比如“工作到很晚吗?”或“你还在吗?”哦,多么的幽默!我希望你的肚子不会疼。
2.3.3 添加特定时间信息
当你打开终端窗口时,另一种款待自己的方式是输出一个详细的时间字符串。完成这个任务的简单方法是输出问候语,然后是 ctime() 函数生成的时间字符串。以下是相关的两行代码:
printf(“Good day, %s\n”,argv[1]);
printf(“It’s %s”,ctime(&now));
这两个语句反映了本章前面展示的代码,所以你能够理解。尽管如此,程序还是有点懒散。最好结合使用 strftime() 函数,该函数根据您的指定格式化时间戳字符串。
strftime()函数的工作方式类似于printf(),它使用一个特殊的字符串来格式化时间信息。函数的输出被保存在一个缓冲区中,你的代码可以在以后使用。示例代码 2.6 展示了这一点。
列表 2.6 greet04.c 的源代码
#include <stdio.h>
#include <time.h>
int main(int argc, char *argv[])
{
time_t now;
struct tm *clock;
char time_string[64]; ❶
time(&now);
clock = localtime(&now); ❷
strftime(time_string,64,"Today is %A, %B %d, %Y%nIt is %r%n",clock);
printf("Greetings");
if( argc>1 )
printf(", %s",argv[1]);
printf("!\n%s",time_string);
return(0);
}
❶ 由strftime()函数填充的字符串存储
❷ 你必须填充一个 localtime() tm 结构,才能使 strftime()函数正常工作。
你可以查看strftime()的man页面,以发现所有有趣的占位符及其功能。就像printf()函数一样,占位符以%字符为前缀。格式化字符串中的任何其他文本都按原样输出。以下是示例代码 2.6 中strftime()语句的亮点:

输出反映了生成并存储在 time_string[]缓冲区中的时间字符串。时间字符串在前面章节中提到的通用问候语之后出现:
Greetings, Danny!
Today is Wednesday, June 23, 2021
It is 04:24:47 PM
到目前为止,一些“宅男”可能会说,所有这些输出都可以通过使用 shell 脚本语言轻松完成,因为 shell 启动和配置文件的母语就是 shell 脚本语言。是的,这样的人存在。然而,作为一个 C 程序员,你的任务是向问候语提供更多的洞察力和功能。使用悲伤的小 shell 脚本语言时,这些添加是不可能的。所以就这样。
2.4 当前月相
我的直觉是大多数程序员在夜间工作效率最高。那么,当你可以只是伸出头来窗外仰望时,为什么还要费心编写一个月相问候呢?
你是对的:这种努力太麻烦了,尤其是当你可以编写一个 C 程序来在室内安全地获得月相的良好近似值时。你甚至可以在每次打开终端窗口时,用这个有趣的片段取悦自己。外面?那只是过誉了。
2.4.1 观察月相
古代玛雅人编写了第一个月相算法,可能是在 COBOL 语言中。我本想在这里打印一段代码,但直接表达象形文字更简单:它是一个小个子蹲在石头上,伸出长长的舌头,戴着节日帽,脸上表情愤怒。程序员们对此姿势很熟悉。
月球在绕地球运行时经过不同的相位。这些相位是基于从地球看到的月亮暴露在阳光下的程度。图 2.1 展示了月球的轨道。阳光的一面总是朝向太阳,尽管从地球上看,我们看到月亮的不同部分被照亮。这些就是月亮的相位。

图 2.1 月球轨道影响从地球看到的照明侧面可见的部分。
从地球人的视角看,月相被命名并如图 2.2 所示。在 28 天的旅程中,月亮的相位从新月(无照明)变为满月,然后再回到新月。此外,一半的时间,月亮在白天可见(通常几乎看不见)。

图 2.2 从地球看到的月相
图 2.2 中显示的相位遵循月亮从新月到满月再回到新月的进程。后者衰减的相位发生在早晨,这也是为什么它们只受到名叫韦恩的男性的欢迎。
2.4.2 编写月相算法
现在不向外看,你能说出月相吗?
是的,我假设你现在在晚上读这本书。程序员是可以预测的。如果你在白天读这本书——甚至在户外——那就恭喜你了。不管时间如何,月亮都有一个当前的相位。不是情绪化的青少年相位,而是前面章节中提到的月亮多少被照亮的状态。
要确定月相而无需向外看或查阅参考,你可以使用一个算法。这些算法在互联网上很常见,也刻在玛雅的石板上。关键是月亮的可预测周期,它可以映射到日、月和年。算法的精确度取决于许多因素,例如你的位置和一天中的时间。如果你想非常精确,你必须使用复杂的几何和混乱的东西,即使半闭一只眼我也不想看。
列表 2.7 展示了 moon_phase() 函数。它包含了我多年前找到的算法,可能是在旧的 ARPANET 上。我的观点是:我不知道它从哪里来。它大部分是准确的,这是我典型的月相算法的特点,这些算法不使用复杂和令人恐惧的数学函数。
列表 2.7:moon_phase() 函数
int moon_phase(int year,int month,int day)
{
int d,g,e;
d = day;
if(month == 2)
d += 31;
else if(month > 2)
d += 59+(month-3)*30.6+0.5;
g = (year-1900)%19;
e = (11*g + 29) % 30;
if(e == 25 || e == 24)
++e;
return ((((e + d)*6+5)%177)/22 & 7);
}
列表 2.7 中提出的算法需要三个参数:整数 year(年)、month(月)和 day(日)。这些与 localtime() tm 结构的成员中的值相同:tm_year+1900 表示年,tm_mon 表示月(从 1 月开始为 0),tm_day 表示月份中的日,从 1 开始。
这就是我将要解释算法如何工作的方法:我不会。说真的,我对此一无所知。我只是从某处抄下了公式,而且——天哪——它大部分是有效的。大部分。
将列表 2.7 中的代码插入到你的首选问候程序中。如果你在 main() 函数上方粘贴它,它就不需要原型。否则,将其原型化如下:
int moon_phase(int year,int month,int day);
函数返回一个范围在 0 到 7 之间的整数,代表前面图 2.2 中显示的八个月相,并按此顺序。一个表示这些相位的字符串数组,与 moon_phase() 函数返回的值相匹配,如下所示:
char *phase[8] = {
"waxing crescent", "at first quarter",
"waxing gibbous", "full", "waning gibbous",
"at last quarter", "waning crescent", "new"
};
你可以自己编写剩余的代码。我在本书的代码仓库中包含了它,作为 moon.c,如引言中所述,但你还没有阅读过。
拥有这些知识,你可以轻松地将月相作为输出添加到你的终端程序初始问候中。然而,有一件事你不想做,那就是使用这个月相算法来准确预测月相。说真的,这只是一种娱乐。不要用这个算法发射载人火箭到月球。我在看着你,意大利。
2.4.3 将月相添加到你的问候中
您可以将moon_phase()函数添加到本章中列出的问候系列程序中的任何源代码示例中。您需要获取基于时间的数据,这是moon_phase()函数进行计算所需的。您还需要一个字符串数组,根据函数返回的值输出当前的月相文本。
列表 2.6 显示了 greet04.c 的源代码,是修改的最佳候选。进行以下更改:
在 main()函数中声明一个整数变量 mp,用于存储从moon_phase()函数返回的值:
int mp;
在现有代码中的最后一个printf()语句之后,在return之前添加以下两个语句:
mp = moon_phase(clock->tm_year+1900,clock->tm_mon,clock->tm_mday);
printf("The moon is %s\n",phase[mp]);
您可以将这些语句合并为一个单独的printf()语句,从而消除对 mp 变量的需求:将moon_phase()函数调用(第一行)插入到printf()语句中的括号内。结果是代码行非常长,这就是为什么我将其拆分的原因。我宁愿选择可读性,也不愿选择长代码行。
您可以在本书的 GitHub 存储库中找到 greet05.c 的最终副本。以下是示例输出:
$ greetings Danny
Greetings, Danny!
Today is Thursday, June 24, 2021
It is 10:02:33 PM
The moon is full
想象一下您的用户在终端窗口开始时看到这样丰富信息的喜悦。他们会靠在椅背上微笑,当他们说“我欣赏这些闪光的细节,我的程序员朋友。很高兴今晚不用外出。谢谢。”时,会点头表示感谢。
2.5 一句箴言
幸运程序自早期以来一直是 shell 启动脚本的一部分,那时一些 Unix 终端还是脚踏式供电的。它今天仍然可用,可以从您发行版的包管理器中轻松安装;搜索“fortune”。
“幸运”这个名字来自幸运饼干。想法是生成一句箴言,或巧言妙语,您可以用它作为新鲜的动力开始您的一天。这些灵感来自一些中国餐馆提供的甜点,它们的作用是固定纸条,而不是提供任何营养价值。
这里是一个数字幸运饼干的例子,来自幸运程序:
$ fortune
There is no logic in the computer industry.
--Dan Gookin
如果您有一个箴言数据库和一个渴望随机抽取一个的程序,您就可以复制幸运程序输出。
2.5.1 创建箴言存储库
幸运程序附带一个或多个格言数据库。幸运饼干的信息就是从这里检索并输出到屏幕上的。您可以借鉴这个列表,但这是一种作弊行为。这也很愚蠢,因为幸运程序已经编写好了。您将一无所学。真丢脸!
您的目标是编写您自己的箴言数据库版本。它不必是引言或幽默,也可以包含有关使用计算机的建议、关于 IT 安全的提醒以及其他重要信息,如当前的流行发型。
我可以想象几种配置列表的方法。这种规划对于编写好的代码至关重要:一个组织良好的列表意味着你需要的编码工作更少。目标是随机从存储库中抽取一个短语,这意味着一个组织良好的文件是必须的。图 2.3 概述了编写代码从列表或数据库中随机抽取简短短语的过程。

图 2.3 从文件中读取随机简短引言的过程
我可以想象几种格式化文件的方法,如表 2.2 所述。
表 2.2 存储短语以方便访问的方法
| 文件格式/数据 | 优点 | 缺点 |
|---|---|---|
| 基本文本文件 | 使用现有工具简单维护 | 每次程序运行时都必须读取和索引文件。 |
| 带有初始项目计数的格式化文件 | 项目计数可以立即读取 | 随着列表的修改,项目计数必须更新。 |
| 带索引条目的散列表 | 读取和访问每个记录都很容易 | 你可能需要一个单独的程序来维护列表,这将需要更多的编码工作。 |
我更喜欢使用基本文本文件来创建我的列表,这意味着需要更多的开销来获取一个随机条目。这也意味着我不需要编写列表维护程序。另一个好处是任何人都可以编辑短语文件,随意添加和删除条目。
不考虑其他所有选项,我的方法是逐行读取文件,将每一行存储和索引在内存中。使用这种方法,文件只需要读取一次,所以我选择这样做。缺点?我必须管理内存位置,也就是所说的指针。
不要担心,亲爱的读者。
我的方法(暂时忘记指针)的好处是你可以使用任何文本文件来创建你的列表。短行文本的文件效果最佳;否则,你必须在终端屏幕上换行,这会更多的工作。pithy.txt 文件可以在本书的 GitHub 仓库中找到。
2.5.2 随机读取简短短语
我的短语问候程序从存储库文件中读取文本行,为每个读取的字符串分配存储空间。随着文本行的读取和存储,创建了一个索引。这个索引是一个指针数组,但它是通过在读取文件时动态分配存储空间创建的。这种方法很复杂,因为它涉及到那些令人恐惧的指针-指针东西(双星号表示)和大量使用 malloc() 和 realloc() 函数。我发现这样的活动很有趣,但我同样喜欢纳豆。就是这样。
就像编程中的任何复杂主题一样,处理项目的最佳方式是逐步编码。第一步是读取文本文件并输出其内容。列表 2.8 中的代码通过从 pithy.txt 文件中读取文本行来完成这个第一个任务。记住,这只是一个开始。指针疯狂是在之后添加的。
列表 2.8 pithy01.c 的源代码
#include <stdio.h>
#include <stdlib.h>
#define BSIZE 256
int main()
{
const char filename[] = "pithy.txt"; ❶
FILE *fp;
char buffer[BSIZE]; ❷
char *r;
fp = fopen(filename,"r");
if( fp==NULL )
{
fprintf(stderr,"Unable to open file %s\n",filename);
exit(1);
}
while( !feof(fp) ) ❸
{
r = fgets(buffer,BSIZE,fp); ❹
if( r==NULL )
break;
printf("%s",buffer); ❺
}
fclose(fp);
return(0);
}
❶ 假设文件 pithy.txt 与程序位于同一目录下。
❷ 缓冲区用于从文件中读取文本;大小是一个猜测,设置为定义的常量BSIZE(第 4 行)。
❸ 循环直到文件不为空
❹ 变量r确保*fgets()*不会搞乱并读取到文件末尾;如果是这样,循环停止。
❺ 输出文件中的所有行
pithy01.c 的目的是从文件中读取所有行。仅此而已。每行存储在字符数组buffer[]中,然后输出。相同的缓冲区被反复使用。
程序的输出是文件pithy.txt的内容转储。对于一个发布程序,你的代码必须确保确认并使pithy.txt(或你选择的任何文件)的正确路径可用。
编译并运行以证明其工作。修复任何问题。当一切就绪时,继续下一步:使用指针并分配内存以存储读取的字符串。记住,最终的程序将所有文件中的字符串存储在内存中。因为字符串的数量是未知的,这种分配方法比猜测数组大小更有效。
要进行下一个改进,引入一个新的变量entry。它是一个字符指针,必须根据从文件中读取的行的大小进行分配。一旦分配,buffer[]的内容就复制到由指针entry引用的内存块中。输出的是这个字符串,而不是buffer[]的内容。
另一个改进是计算从文件中读取的项目数量。为此任务,在*while*循环中添加、初始化并递增*int*变量items。
这里是代码的更新:添加一行以包含string.h头文件,这是*strcpy()*函数所需的:
#include <string.h>
在代码的变量声明部分,添加*char*指针entry和*int*变量items:
char *r,*entry;
int items;
在*while*循环之前,将变量items初始化为零:
items = 0;
在*while*循环内部,为变量entry分配内存。必须测试指针以确保内存可用。然后将buffer[]的内容复制到entry中,输出entry的内容,并将items变量递增。以下是替换原始程序中现有的*printf()*语句的代码段:
entry = (char *)malloc(sizeof(char) * strlen(buffer)+1); ❶
if( entry==NULL )
{
fprintf(stderr,"Unable to allocate memory\n");
exit(1);
}
strcpy(entry,buffer);
printf("%d: %s",items,entry);
items++;
❶ 为字符串预留足够的存储空间,再加一个用于空字符
这些更新可以在在线存储库中的pithy02.c中找到,它们只通过在每个读取的行前加上其项目编号来更改输出,从文件中读取的第一行开始编号为零。虽然这个更新看起来很小,但对于继续下一步,即动态地将从文件中读取的所有字符串存储到内存中,是必要的。
现在程序的状态是,它分配一系列缓冲区以存储读取的字符串。然而,这些缓冲区的地址在内存中丢失。为了解决这个问题,需要一个指针指针。指针指针,或指针的地址,跟踪所有字符串的内存位置。这种改进是代码获得 NC-17 评级的地方。
为了跟踪存储在内存中的字符串,对 pithy02.c 进行以下改进,现在它变成了 pithy03.c:
添加一个第二个 int 变量 x,它将在后面的 for 循环中使用。还要添加指针-指针变量 list_base:
int items,x;
char **list_base;
list_base 变量跟踪代码中稍后分配的 entry 指针。但首先,list_base 指针本身必须被分配。在文件打开后和 while 循环之前插入此代码:
list_base = (char **)malloc(sizeof(char *) * 100);
if( list_base==NULL )
{
fprintf(stderr,"Unable to allocate memory\n");
exit(1);
}
图 2.4 说明了第一个语句分配变量 list_base 时发生的情况。它是一个指向指针的指针,需要使用 ** 符号。它引用的是字符指针。列表的大小为 100 个条目,这对于现在来说已经足够了。

图 2.4 指针-指针缓冲区的分配方式
在 while 循环中,删除 printf() 语句。输出语句发生在循环之外。在 strcpy() 语句下面添加此语句:
*(list_base+items) = entry;
使用 items 计数提供的偏移量,此语句将存储在指针变量 entry 中的地址复制到位于 list_base 位置的列表中。只复制地址,而不是整个字符串。这个语句代表了疯狂的指针操作——但它确实有效。图 2.5 展示了疯狂的工具箱看起来是什么样子。

图 2.5 list_base 和 items 变量帮助存储由 entry 指针分配的字符串。
最后,在关闭文件后,使用这个 for 循环输出所有项目:
for( x=0; x<items; x++ )
printf("%s",*(list_base+x));
在这个循环中,变量 x 设置列表中的偏移量:*(list_base+x) 引用从文件中读取并现在存储在内存中的每一行文本。
到目前为止,程序有效地从文件中读取所有文本,将文本存储在内存中,并跟踪每个字符串。在从文件中随机抽取字符串之前,必须注意当从文件中读取超过 100 行时的情况。
当为 list_base 变量分配内存时,只能在该内存块中存储 100 个指针。如果变量 items 的值超过 100,就会发生内存溢出。为了防止这种灾难,代码必须重新分配 list_base 的内存。这样,如果读取的文件包含超过 100 行的文本,它们可以在内存中存储,而不会让程序自己出问题。
要重新分配内存或增加已创建缓冲区的大小,请使用 realloc() 函数。它的参数是现有缓冲区的指针和新缓冲区的大小。如果成功,旧缓冲区的内容将被复制到新的更大的缓冲区中。为了增加 list_base 的大小,它必须重新分配到另一个 100 个 char 指针大小的块。
只需对代码进行一次修改即可更新。以下行被插入到 while 循环的末尾,就在 items 变量增加之后:
if( items%100==0 ) ❶
{
list_base = (char **)realloc(list_base,sizeof(char *)*(items+100)); ❷
if( list_base==NULL )
{
fprintf(stderr,"Unable to reallocate memory\n");
exit(1);
}
}
❶ 每当 items 能被 100 整除时……
❷ . . . 现有存储通过 100 个指针大小的块增加。
这次更新被保存为 pithy04.c。代码运行与从 pithy03.c 生成的程序相同,尽管如果读取的文件包含超过 100 行的文本,每一行都会被正确地分配、存储和引用,而不会出现灾难。
程序现在已准备好执行其任务:从文件中选择并输出一个随机项。最后一步是移除代码末尾的for循环;它不再需要,因为程序只需要从文件中输出一行随机文本。
首先包括 time.h 头文件:
#include <time.h>
将其int变量 x 的声明替换为对新变量的声明:
int items,saying;
在代码末尾添加了三行,位于return语句之上:
srand( (unsigned)time(NULL) );
saying = rand() % (items-1);
printf("%s",*(list_base+saying));
这是代码的最终更新,可在在线存储库中作为 pithy05.c 找到。运行时,程序从文件中提取一行随机文本,并输出其内容。
正如我在这部分之前所写的,这种方法只是解决问题的一种方式。它快速且有效,这对于在 shell 启动脚本中添加一句精炼的格言来说已经足够好了。
最后一点:程序不会直接释放任何内存。通常,函数的末尾会有free()语句,每个分配的内存块一个。因为整个代码都位于main()函数中,所以释放内存不是必要的。分配的内存会在程序退出时释放。然而,如果分配发生在函数中,则必须释放分配,否则可能会丢失内存块并可能导致内存溢出。
2.5.3 将短语添加到问候代码中
如果你的目标是修改一个用于 shell 启动脚本的单一问候程序,你的下一个任务就是将精简系列程序中的代码添加到你的问候程序中。这样的任务将使所有努力都集中在单个程序中,并且所有输出都将在 shell 启动脚本的单行中。
因为pithy程序有点有趣,所以我不会将其整合到之前的问候程序代码中。相反,我将将其作为 shell 启动脚本中的一行单独留下。这样,我也可以在需要轻松或需要轻松时刻时从命令提示符运行程序。你可以自己尝试将pithy程序整合到你的问候程序中。
3 北约输出
如果你从未在电话中拼过你的名字,那你就很幸运了。或者也许你的名字叫玛丽·史密斯,但你住在必须不断大声拼读的街道或城市。如果是这样,你就求助于你自己的拼写字母表,比如,“N,就像 Nancy”或“K,就像刀子。”作为一个程序员,你可以通过阅读本章来减轻这种挫败感,其中你
-
理解北约音标字母表以及他们为什么要这样做。
-
将单词翻译成拼写字母表。
-
读取文件将单词翻译成音标字母表。
-
向后翻译北约字母表成单词。
-
读取文件以翻译北约字母表。
-
学习到在日语中纳豆是一种美味的发酵大豆酱。
最后一个要点在本章中没有涉及。我只是喜欢吃纳豆,现在我可以把它作为业务费用报销。
总之。
所有这些混乱的辉煌结论是不仅学习一些新的编程技巧,而且可以自豪地大声拼读单词,用“November”而不是“Nancy”。
3.1 北约字母表
不仅是任何名叫纳撒尼尔的人的好听的昵称,北约还代表北大西洋公约组织。它是一群国家组成的相互防御联盟。
在二战后建立,等等。我可以继续说,但重点是北约要求其成员国之间有一些共同点。你知道,这样当汉斯弹药不足时,皮埃尔可以给他子弹,而且子弹可以装进枪里。诸如此类的东西。
北约国家之间共享的一个共同点是有一个大声拼读单词的方法。这样,汉斯就不需要说,“子弹!B,就像香肠;U,就像超级;L,就像皮短裤……”。以此类推。相反,汉斯会说,“Bravo,Uniform,Lima,Lima,Echo,Tango。”这样,皮埃尔就能理解汉斯,即使是在周围的枪声中。
表 3.1 列出了北约音标字母表,描述了每个字母对应的单词。这些单词被选择为独特且不易误解。其中有两个单词(Alfa 和 Juliett)故意拼写错误,以避免混淆——并且为了混淆。
表 3.1 北约音标字母表。
| 信件 | 北约 | 信件 | 北约 |
|---|---|---|---|
| A | 阿尔法 | N | 诺瓦 |
| B | 布拉沃 | O | 奥斯卡 |
| C | 查理 | P | 爸爸 |
| D | 德尔塔 | Q | 魁北克 |
| E | 雷声 | R | 罗密欧 |
| F | 狐狸 | S | 西班牙 |
| G | 高尔夫 | T | 塔戈 |
| H | 酒店 | U | 制服 |
| I | 印度 | V | 维克托 |
| J | 朱丽叶 | W | 威士忌 |
| K | 基洛 | X | X 射线 |
| L | 利马 | Y | 美国佬 |
| M | 米克 | Z | 朱鲁 |
北约不是唯一的音标字母表,但它可能是最常用的。关键是统一性。作为程序员,你不需要记住这些单词,尽管作为一个极客,你可能会的。不过,程序可以输出北约代码——或者根据你如何编写 C 代码将其翻译回单词。奥斯卡基洛。
3.2 北约翻译程序
你编写的任何北约翻译程序都必须有一个字符串数组,就像下面展示的那样:
const char *nato[] = {
"Alfa", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot",
"Golf", "Hotel", "India", "Juliett", "Kilo", "Lima",
"Mike", "November", "Oscar", "Papa", "Quebec", "Romeo",
"Sierra", "Tango", "Uniform", "Victor", "Whiskey",
"Xray", "Yankee", "Zulu"
};
数组的表示法nato[]意味着一个指针数组,这是编译器如何在内存中构建这种结构的方式。数组的数据类型是char,因此指针引用存储在内存中的字符数组——字符串。它被归类为常量,因为创建一个字符串指针数组并随后修改它们是不明智的。nato[]数组填充了字符串的内存位置,如图 3.1 所示。

图 3.1 如何通过数组指针引用内存中的字符串
例如,在图中,字符串Alfa(以空字符终止,\0)存储在地址 0x404020。这个内存位置存储在 nato[]数组中,而不是字符串本身。是的,字符串出现在数组的声明中,但在运行时它存储在内存的另一个地方。对于数组中的所有元素,这种结构都是相同的:每个元素对应一个字符串的内存位置,从 Alfa 到 Zulu。
NATO 数组的美妙之处在于其内容是顺序排列的,当你从'A'的值中减去时,它们与 ASCII 值'A'到'Z'相匹配。(有关此操作的更多详细信息,请参阅第四章。)这种巧合使得提取对应 NATO 单词的字符变得非常容易。
3.2.1 编写 NATO 翻译器
一个简单的 NATO 翻译器在列表 3.1 中展示。它使用fgets()函数从标准输入中收集一个单词。一个while循环逐个字符地遍历单词。在这个过程中,任何字母字符都会被isalpha()函数检测到。如果找到,该字母就被用作 nato[]数组的参考。结果是输出 NATO 音标字母术语。
列表 3.1 nato01.c 的源代码
#include <stdio.h>
#include <ctype.h>
int main()
{
const char *nato[] = {
"Alfa", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot",
"Golf", "Hotel", "India", "Juliett", "Kilo", "Lima",
"Mike", "November", "Oscar", "Papa", "Quebec", "Romeo",
"Sierra", "Tango", "Uniform", "Victor", "Whiskey",
"Xray", "Yankee", "Zulu"
};
char phrase[64];
char ch;
int i;
printf("Enter a word or phrase: ");
fgets(phrase,64,stdin); ❶
i = 0;
while(phrase[i]) ❷
{
ch = toupper(phrase[i]); ❸
if(isalpha(ch)) ❹
printf("%s ",nato[ch-'A']); ❺
i++;
if( i==64 ) ❻
break;
}
putchar('\n');
return(0);
}
❶ 将从 stdin,标准输入存储到位置 phrase 的 63 个字符(加上空字符)
❷ 循环直到在字符串中找到空字符
❸ 将字符 ch 转换为大写
❹ 当字符 ch 是字母时为真
❺ ch-'A'将字母转换为 0 到 25 的值,与相应的数组元素匹配。
❻ 如果长字符串没有空字符,那么当达到缓冲区大小时就退出。
构建并运行程序后,程序会提示输入。无论输入什么文本(最多 63 个字符),都会将其翻译并输出为音标字母。例如,“Howdy”会变成:
Hotel Oscar Whiskey Delta Yankee
输入一个较长的短语,例如“Hello, World!”,会得到:
Hotel Echo Lima Lima Oscar Whiskey Oscar Romeo Lima Delta
因为代码中忽略了非字母字符,所以不会为它们生成输出。
使用此代码将翻译成另一个音标字母非常容易。你所要做的就是用你自己的音标字母替换 nato[]数组。例如,以下是你可以用于执法音标字母的数组:
const char *fuzz[] = {
"Adam", "Boy", "Charles", "David", "Edward", "Frank",
"George", "Henry", "Ida", "John", "King", "Lincoln",
"Mary", "Nora", "Ocean", "Paul", "Queen", "Robert",
"Sam", "Tom", "Union", "Victor", "William",
"X-ray", "Young", "Zebra"
};
3.2.2 读取和转换文件
我不确定将文件中的所有文本翻译成北约语音字母表的需要。这是一个你可以承担的 C 项目,主要是为了练习,但从实际的角度来看,这几乎没有意义。我的意思是,听到三个小时的全是北约字母表的 安东尼与克莉奥帕特拉 会很无聊,尽管如果你是戏剧/IT 双专业,可以试一试。不过,这是一本书,而我是一个书呆子,所以这个话题将为了你们的提高而被探讨。
列表 3.2 展示了消耗文件并将每个字符转换为北约语音字母表对应项的代码。文件名在命令提示符中提供。如果没有提供,程序将显示适当的错误消息并退出。否则,与 nato01.c 中的代码类似,该代码逐个字符地处理文件,输出匹配的北约单词。
列表 3.2 nato02.c 的源代码
#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>
int main(int argc, char *argv[])
{
const char *nato[] = {
"Alfa", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot",
"Golf", "Hotel", "India", "Juliett", "Kilo", "Lima",
"Mike", "November", "Oscar", "Papa", "Quebec", "Romeo",
"Sierra", "Tango", "Uniform", "Victor", "Whiskey",
"Xray", "Yankee", "Zulu"
};
FILE *n;
int ch;
if( argc<2 ) ❶
{
fprintf(stderr,"Please supply a text file argument\n");
exit(1);
}
n = fopen(argv[1],"r"); ❷
if( n==NULL )
{
fprintf(stderr,"Unable to open '%s'\n",argv[1]);
exit(1);
}
while( (ch=fgetc(n))!=EOF ) ❸
{
if(isalpha(ch)) ❹
printf("%s ",nato[toupper(ch)-'A']); ❺
}
putchar('\n');
fclose(n);
return(0);
}
❶ 如果参数少于两个,则缺少文件名选项。
❷ 打开在命令提示符中提供的文件名,引用为 argv[1]
❸ 逐个字符从文件中读取,将其存储在变量 ch 中。EOF 标记文件末尾
❹ 仅处理文本字符
❺ 使用字符的大写版本,减去 'A' 的值来索引 nato[] 数组
记住处理来自文件的文本时使用整数变量。标记文件末尾的 EOF 标志是一个 int 值,而不是 char 值。代码中的 while 语句小心地从文件中提取一个字符,并评估该字符以确定何时结束操作。
要运行程序,请在程序名称后输入文件名参数。文本文件是首选。输出显示为单行文本,反映了文件中每个字符的语音字母表单词。
在 Macintosh 上,为了增加乐趣,可以将程序的输出通过 say 命令进行管道传输:
nato02 antony_and_cleopatra.txt | say
这样,文件提供的语音字母表内容就会被 Mac 从头到尾朗读出来。坐下来享受吧。
3.3 从北约到英语
语音字母表的翻译应该在您的脑海中完成。有人拼写他们的家乡:印度,西拉,西拉,阿尔法,魁北克,尤尼弗莫,阿尔法,酒店。听众知道如何写下这个单词,正确地拼写它。这个单词是 Issaquah,这是我曾经居住过的一个城市。我不得不经常拼写这个名字。这个操作的美丽之处在于,即使不知道北约字母表的人也能理解正在拼写的内容,多亏了首字母。
然而,编写代码以扫描语音字母表单词并将它们翻译成正确的单个字符则更困难。这个过程涉及解析输入并逐词检查是否有单词与词典中找到的术语匹配。
3.3.1 将北约输入转换为字符输出
要确定音标字母术语是否出现在文本块中,你必须解析文本。字符串被分隔成单词块。只有在你提取出单词后,你才能将它们与音标字母术语进行比较。
要进行繁重的工作,使用 strtok() 函数解析文本流中的单词。我假设函数名翻译为“字符串标记器”或“字符串到千克”,这毫无意义。
strtok() 函数根据一个或多个分隔符字符将字符串解析成块。定义在 string.h 头文件中,手册页的格式是:
char *strtok(char *str, const char *delim);
第一个参数,str,是要扫描的字符串。第二个参数,delim,是包含可以分隔或限定你想要解析的字符块的单独字符的字符串。返回值是一个 char 指针,它引用找到的字符块。例如:
match = strtok(string," ");
此语句扫描缓冲字符串中持有的字符,直到遇到空格字符停止。是的,第二个参数是一个完整的字符串,即使只需要单个字符。char 指针匹配持有找到的单词(或文本块)的地址,在空格或另一个分隔符本应出现的地方终止。当没有找到任何内容时,返回 NULL 常量。
要继续扫描相同的字符串,第一个参数被替换为 NULL 常量:
match = strtok(NULL," ");
NULL 参数通知函数使用之前传递的字符串并继续标记化操作。下一列表中的代码展示了如何使用 strtok() 函数。
列表 3.3 word_parse01.c 的源代码
#include <stdio.h>
#include <string.h>
int main()
{
char sometext[64];
char *match;
printf("Type some text: ");
fgets(sometext,64,stdin);
match = strtok(sometext," "); ❶
while(match) ❷
{
printf("%s\n",match);
match = strtok(NULL," "); ❸
}
return(0);
}
❶ 初始调用 strtok(), 传入要搜索的字符串。
❷ 当返回值不是 NULL 时循环。
❸ 在第二次调用 strtok() 时,使用 NULL 以继续搜索相同的字符串。
在此代码中,用户被提示输入一个字符串。strtok() 函数使用单个空格作为分隔符从字符串中提取单词。以下是一个示例运行:
Type some text: This is some text
This
is
some
text
当字符串中出现除空格之外的分隔符时,它们会被包含在字符块匹配中:
Type some text: Hello, World!
Hello,
World!
要避免捕获标点符号字符,你可以设置此分隔符字符串:
match = strtok(sometext," ,.!?:;\"'");
在这里,第二个参数列出了常见的标点符号字符,包括双引号字符,它必须被转义("). 结果是分隔的单词被截断,如下所示:
Type some text: Hello, World!
Hello
World
你可能会在程序的输出中找到一些尾随的空白行。这些额外的换行符对于匹配文本是好的,因为空白行无论如何都不会匹配任何内容。
要创建一个音标字母输入转换器,你需要修改此代码以执行与北约音标字母术语数组的字符串比较。strcmp() 函数处理这个任务,但你必须考虑两个因素。
首先,strcmp() 是大小写敏感的。一些 C 库具有一个 strcasecmp() 函数,它执行不区分大小写的比较,尽管这个函数不是 C 标准的一部分。其次,字符串长度可能不同。例如,如果您选择不在 strtok() 函数中计算标点符号字符(" ,.!?:;"'”)——或者当出现未预期的标点符号字符时——比较将失败。
在这两种情况下,我认为最好创建一个独特的字符串比较函数,一个专门设计用来检查解析的单词是否与音标字母表术语匹配的函数。这个函数,isterm(),将在下面展示。
列表 3.4 isterm() 函数
char isterm(char *term)
{
const char *nato[] = {
"Alfa", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot",
"Golf", "Hotel", "India", "Juliett", "Kilo", "Lima",
"Mike", "November", "Oscar", "Papa", "Quebec", "Romeo",
"Sierra", "Tango", "Uniform", "Victor", "Whiskey",
"Xray", "Yankee", "Zulu"
};
int x;
char *n,*t;
for( x=0; x<26; x++)
{
n = nato[x]; ❶
t = term; ❷
while( *n!='\0' ) ❸
{
if( (*n|0x20)!=(*t|0x20) ) ❹
break; ❺
n++; ❻
t++; ❻
}
if( *n=='\0' ) ❼
return( *nato[x] ); ❽
}
return('\0');
}
❶ 将指针 n 设置为当前北约单词
❷ 指针 t 指向传递的术语。
❸ 循环直到北约术语结束
❹ 逻辑地将每个字母转换为大写并比较;有关此和其他 ASCII 技巧的更多信息,请参阅第五章。
❺ 对于没有匹配的情况,循环中断,并比较 nato[] 中的下一个术语。
❻ 遍历每个字母
❼ 当指针 n 是空字符时,术语已匹配。
❽ 返回北约术语的第一个字母
isterm() 函数接受一个单词作为其参数。如果单词与北约音标字母表术语匹配,则返回值是单个字符;否则,返回空字符。
要创建一个新的北约翻译程序,将 isterm() 函数添加到您的源代码文件中,在现有代码的下方。您必须包含 stdio.h 和 string.h 头文件。然后添加以下 main() 函数来构建一个新的程序,nato03.c,如下所示。
列表 3.5 nato03.c 中的 main() 函数
int main()
{
char phrase[64];
char *match;
char ch;
printf("NATO word or phrase: ");
fgets(phrase,64,stdin);
match = strtok(phrase," ");
while(match)
{
if( (ch=isterm(match))!='\0' )
putchar(ch);
match = strtok(NULL," ");
}
putchar('\n');
return(0);
}
代码扫描输入行中的任何匹配的音标字母表术语。isterm() 函数处理这项工作。匹配的字符被返回并输出。以下是一个示例运行:
NATO word or phrase: india tango whiskey oscar romeo kilo sierra
ITWORKS
如果没有匹配的字符,输入的句子将输出一个空行。混合字符的输出如下:
NATO word or phrase: Also starring Zulu as Kono
Z
如果您想在代码中添加翻译特殊字符的功能,您可以自行完成。请注意,北约音标字母表缺少带有标点的术语,尽管如果您正在创建自己的文本翻译程序,检查特殊字符可能是有必要的。
3.3.2 从文件中读取北约输入
读取输入以检测和翻译字母语言是愚蠢的,但这是一个很好的练习。读取整个文件以检测字母语言甚至更愚蠢。我尽量不把它看作是必需的,而更看作是编程实践:你能扫描文件以查找特定单词,然后报告它们的存在吗?采用这个概念来证明完成这样一个程序是合理的。
与读取一行文本一样,为了在文件中处理文本以寻找北约字母表单词,你需要 isterm() 函数。文件逐行读取,并且每行的内容都类似于在 nato03.c 中展示的代码。将 nato02.c 中的文件命令混合在一起,我创建了一个子程序,nato04.c。它位于本书的 GitHub 仓库中。以类似弗兰肯斯坦的方式组装这样的程序对我来说很有吸引力。这是 Stack Overflow 成功的哲学。
nato04.c 的核心通过使用两个 while 循环处理打开的文件,如下一列表所示。如果你一直跟随本章中北约程序系列,许多语句对你来说都很熟悉。
列表 3.6 使用嵌套循环处理文件中的单词
while( !feof(n) ) ❶
{
fgets(phrase,64,n); ❷
match = strtok(phrase," ,.!?=()[]{}'\""); ❸
while(match) ❹
{
if( (ch=isterm(match))!='\0' ) ❺
putchar(ch);
match = strtok(NULL," ,.!?=()[]{}'\"");
}
}
putchar('\n');
❶ 循环直到打开的文件处理器 n 的末尾
❷ 读取最多 63 个字符的文本行
❸ 过滤掉很多字符
❹ 循环直到读取行中的所有单词
❺ 将匹配的单词发送到 isterm() 函数
所有这些拼凑的代码的结果是,从文件中提取任何匹配的北约音标字母表术语,并为每个术语弹出相应的字母。正如你可能猜到的,很少有文件中隐藏着北约术语,所以输出通常是空的。尽管如此,我还是使用了 nato04.c 源代码文件作为输入来运行代码:
$ nato04 nato04.c
ABCDEFGHIJKLMNOPQRSTUVWXYZ
让程序很高兴的是,它找到了 nato[] 数组的文本,并按顺序吞噬了所有字母表术语,然后吐出了字母表本身。太棒了。
nato04.c 中的代码有一个问题,那就是 fgets() 函数每次只读取每行的字符片段。在源代码中,如果文件中的一行文本短于指定的字符数(63 个字符加上一个空字符),则文本行将读取到并包括换行符。如果文件中的一行文本长于 fgets() 函数指定的数量,则文本将被截断。当你寻找单词时截断文本是件坏事,尽管这比截断一头大象要好一些。
为了更好地处理文件,并确保单词不会被苛刻的 fgets() 函数分割,我已经重新调整了代码,使其逐个字符读取文件。在这种方法中,代码更像是一个程序过滤器。(过滤器在第四章中有介绍。)随着每个字符的消化,文件中的单词被组装起来。
列表 3.7 展示了处理打开文件(由 FILE 处理器 n 表示)的 while 循环。字符存储在 int 变量 ch 中,通过使用 fgetc() 函数逐个读取。整数变量 offset 跟踪存储在 word[] 缓冲区中的读取字符。这个缓冲区长 64 个字符。如果发生缓冲区溢出,程序将终止。我的意思是,给我一个超过 64 个字符的单词。如果你能合法地找到一个,请增加缓冲区大小。
列表 3.7 逐个处理文件中的单词
offset = 0;
while( (ch=fgetc(n))!=EOF ) ❶
{
if( isalpha(ch) ) ❷
{
word[offset] = ch; ❸
offset++;
if( offset>=64 ) ❹
{
fprintf(stderr,"Buffer overflow\n");
return(1);
}
}
else ❺
{
if( offset > 0 ) ❻
{
word[offset] = '\0'; ❼
putchar( isterm(word) ); ❽
offset=0; ❾
}
}
}
putchar('\n');
❶ 循环直到文件有可读字节
❷ 单词以字母表中的一个字母开头。
❸ 存储字符以构建单词
❹ 检查溢出;如果发生溢出则退出
❺ 找到非字母字符,意味着单词的结束。
❻ 确认 word[]缓冲区中有些文本
❼ 限制字符串长度!
❽ 处理单词,返回有效字符或空字符(不打印)
❾ 重置偏移量以存储下一个单词
列表 3.7 中显示的代码是 nato05.c 源代码文件的一部分,可在本书的 GitHub 仓库中找到。该程序的工作方式与 nato04.c 类似,尽管从文件中读取的长行文本没有被分割——这可能会分割一个有效的单词。通过逐字符处理文件的文本,这种分割就不会发生(除非单词非常长)。
程序的输出与处理源代码文件文本时的 nato04.c 完全相同:
$ nato05 nato05.c
ABCDEFGHIJKLMNOPQRSTUVWXYZ
任何程序,nato05.c 的代码都可以进行改进。按照目前的编写方式,代码依赖于非字母字符来终止一个单词:当检查的字符(int值)在'A'到'Z'或'a'到'z'的范围内时,isalpha()函数返回 TRUE。这个规则消除了缩写(don’t,o’clock),尽管这种缩写很少会包含在音标字母表中。
除了查看文件中的北约音标字母表术语外,该代码还提供了一个如何扫描任何文件以查找特定单词的实际示例。将其视为您可能创建的其他程序的灵感来源。或者,只需享受您对北约音标字母表的新知识,这样当别人打电话要求您拼写城市名称时,您就可以自豪地展示出来。
4 凯撒密码
凯撒写道,“Gallia est omnis divisa in partes tres。”如果他想要这条信息保密,他会写成,“Tnyyvn rfg bzavf qvivfn va cnegrf gerf。”这种微妙的加密很容易构思,但即使是有文化的间谍,如果没有密钥,也无法翻译这种混乱的拉丁文。在接收端,当解密方法已知时,信息会迅速解码,……可怜的高卢人。这种编码方法今天被称为凯撒密码。
凯撒密码绝对不安全,但它是一个有趣的编程练习。它还打开了 C 语言中过滤器和过滤器编程的概念之门。本章涵盖了过滤器的概念,包括如下内容:
-
处理流输入和输出
-
编程一个简单的输入/输出(I/O)过滤器
-
旋转字符 13 个位置
-
在特定增量中移动字符
-
编写一个十六进制输入过滤器
-
创建一个北约音标字母过滤器
-
编写一个查找单词的过滤器
过滤器存在于命令提示符的领域。使用特殊的命令字符在提示符处应用过滤器,将输入和输出从标准 I/O 设备中重定向。因此,我强烈建议你在这个章节中放弃你钟爱的 IDE,一头扎入命令行编程的领域。这样做让你几乎成为超级极客,而且在你受邀参加的少数几次聚会上,你可以吹嘘自己的成就。
4.1 I/O 过滤器
你还记得在计算机营里唱关于 I/O 的歌吗?这样欢乐的原因是为了强调计算机蜂巢的存在是为了吸收输入并创建修改后的输出。关键是 I 和 O 之间发生了什么,而不仅仅是斜杠字符。不,重要的是修改输入以生成某种有用输出的机制。
I/O 过滤器是一个消耗标准输入的程序,对其进行一些操作,然后输出修改后的结果。它不是一个交互式程序:输入像一股温柔的溪流流入过滤器。过滤器做一些神奇的事情,比如去除所有的虫子和污垢,然后生成输出:纯净、干净的水(尽管所有这些动作都在数字级别上发生,即使是虫子)。
4.1.1 理解流 I/O
要最好地实现一个过滤器,你必须接受流 I/O 的概念,这对许多 C 程序员来说很难理解。那是因为你与计算机程序的经验是在交互层面上的。然而,在 C 语言中,输入和输出是在流级别上工作的。
流 I/O意味着所有 I/O 都在程序中连续流动,没有停顿,就像花园水管中的水一样。代码不知道你何时暂停或停止输入。它只识别到文件结束(EOF)字符时流结束。
多亏了行缓冲,代码可能只是随意关注换行字符(\n,当你按下 Enter 键时)的出现。一旦遇到,换行符可能会刷新输出缓冲区,但否则流 I/O 不会炫耀输入了什么文本或它是如何生成的;所有处理的是流,你可以想象它就像一个长长的字符游行,如图 4.1 所示。

图 4.1 一串文本——不像游行那样欢快,但你能理解这个概念。
流 I/O 可能会让你感到沮丧,但它有自己的位置。为了帮助你接受它,理解输入可能并不总是来自标准输入设备(键盘)。同样,输出可能并不总是发送到标准输出设备(显示器)。标准输入设备 stdin 只是几个输入源之一。例如,输入也可以来自文件、另一个程序或特定的设备,如调制解调器。
列表 4.1 中的代码演示了许多 C 语言初学者如何构建一个假想交互式程序。假设输入是交互式的。相反,输入是从流中读取的(参见图 4.1)。尽管代码可能会提示输入单个字母,但它实际上是在读取输入流中的下一个字符。其他事情无关紧要——没有考虑其他因素。
列表 4.1 stream_demo.c
#include <stdio.h>
int main()
{
int a,b;
printf("Type a letter:");
a = getchar(); ❶
printf("Type a letter:");
b = getchar(); ❷
printf("a='%c', b='%c'\n",a,b);
return(0);
}
❶ 从标准输入读取单个字符
❷ 从标准输入读取下一个单个字符
程序员的愿望是读取两个字符,每个字符都在自己的提示符下输入。但实际情况是,getchar() 函数从输入流中取出每个字符,这包括第一个输入的字母加上Enter 键的按下(换行符)。以下是一个示例运行:
Type a letter:a
Type a letter:a='a', b='
'
第一个字符由 getchar() 读取,即字母 a。然后用户按下 Enter 键,这成为第二个 getchar() 语句读取的下一个字符。你可以在输出中的 b 处看到这个字符(分为两行)。看看图 4.2,它说明了用户输入的输入流以及代码是如何读取它的。

图 4.2 输入流包含两个由两个 getchar() 函数读取的字符。
如果你在第一个提示符中输入ab,你会看到以下输出:
Type a letter:ab
Type a letter:a='a', b='b'
两个 getchar() 函数依次从流中读取字符。如果用户输入a和b,这些字符会从流中取出,无论屏幕上的提示如何,如图 4.3 所示。图中的换行符(在输入流中显示)不会被代码读取,但用于刷新缓冲区。它允许代码在用户无需等待 EOF 的情况下处理输入。

图 4.3 从输入流中读取了另外两个字符。
理解流 I/O 有助于你正确编写 C 程序,并欣赏 I/O 过滤器是如何工作的。即便如此,你可能仍然对如何构建交互程序感到好奇。秘密在于避免流 I/O 并直接访问终端。Ncurses 库是你可以使用的一个工具,可以使程序完全交互。这个库是 vi、top 和其他全屏文本模式程序的基础。如果你想为 Linux 编写交互式全屏文本模式程序,请查看 Ncurses。当然,我为此主题写了一本书,你可以在亚马逊上订购:Dan Gookin 的 Ncurses 编程指南。
自我推销够了。——编辑
流 I/O 的另一个方面是缓冲。当你按下 Enter 键处理 stream_demo.c 这样的拟交互程序输入时,你会看到一些这样的缓冲。事实上,当程序首次提示输出时,I/O 缓冲的一个方面就存在了:
Type a letter:
这段文本出现并且输出停止是因为缓冲。在 C 语言中,输出到标准输出设备(stdout)是按行缓冲的。这种配置意味着流输出存储在缓冲区中,直到缓冲区满了或者流中遇到换行符,之后文本才会输出。正是换行符的存在使得在 stream_demo.c 程序中输出停止。
另一种类型的缓冲是块缓冲。当此模式激活时,输出不会出现直到缓冲区满了或者程序结束。即使流中出现换行符,块缓冲也会将字符存储在流中,la-di-da。
使用定义在 stdio.h 头文件中的setbuf()函数设置 I/O 设备的缓冲。此函数覆盖了终端的默认行缓冲,并使用特定的内存块建立块缓冲。实际上,它禁用了给定文件句柄(或标准 I/O 设备)的行缓冲,并激活了块缓冲。
下一个列表中的代码使用setbuf()函数将输出从行缓冲更改为块缓冲。setbuf()语句有助于展示输出流(stdout)是如何受到影响的。
列表 4.2 buffering.c
#include <stdio.h>
int main()
{
char buffer[BUFSIZ]; ❶
int a,b;
setbuf(stdout,buffer); ❷
printf("Type a letter:");
a = getchar();
printf("Type a letter:");
b = getchar();
printf("a='%c', b='%c'\n",a,b);
return(0);
}
❶ 标准输出的一个存储库;BUFSIZ 在 stdio.h 中定义。
❷ 将标准输出提交到块缓冲
如果你构建并运行 buffering.c,你将看不到输出。相反,getchar()函数提示输入,因此程序等待。输出被保留,存储在字符数组缓冲区中,等待文本填满缓冲区或程序结束。
这里是代码的一个示例运行,其中没有出现提示。然而,用户似乎足够有先见之明,在闪烁的光标处输入 ab。只有在按下 Enter 键后,程序才会结束,缓冲区才会刷新,从而揭示标准输出:
ab
Type a letter:Type a letter:a='a', b='b'
顺便说一下,一些 C 程序员使用 fflush() 函数强制输出或清除输入流。这个函数在 stdio.h 头文件中定义,将名为文件句柄的流(如 stdin 或 stdout)清空。我发现它不可靠,并且强制流 I/O 以某种方式模拟交互式 C 程序的方法很笨拙。使用这种技术(我承认我在其他一些书中推荐过)被称为 kludge。这个术语意味着使用 fflush() 清空输入或输出缓冲区可能是一个可行的解决方案,但不是最好的。
4.1.2 编写简单的过滤器
过滤器修改流输入并生成流输出。它们在字符级别操作流:一个微小的字符进入,以某种方式被处理,然后另一个字符出来,或者什么也不出来。执行过滤器魔法的两个最常用的函数是 getchar() 和 putchar(),这两个函数都在 stdio.h 头文件中定义。
getchar() 函数从标准输入读取单个字符。对于大多数编译器,getchar() 是一个宏,等同于 fgetc() 函数:
c = fgetc(stdin);
fgetc() 函数从一个打开的文件句柄中读取单个字符(字节)。在上行中,stdin 被用作标准输入设备。返回的整数值存储在 int 变量 c 中。这个变量 必须 声明为整数数据类型,而不是字符。原因是重要的值,特别是文件结束(EOF)标记,是整数值。将函数的返回值赋给 char 变量意味着 EOF 不会被正确解释。
putchar() 函数将单个字符发送到标准输出。与 getchar() 类似,putchar() 通常定义为展开为 fputc() 函数的宏:
r = fputc(c,stdout);
fputc() 函数将整数值 c 发送到由 stdout 表示的打开文件句柄,即标准输出设备。返回值 r 是写入的字符或错误时的 EOF。与 fgetc() 类似,变量 r 和 c 必须是整数。
列表 4.3 中提供了一个什么也不做的过滤器。它使用 while 循环处理输入,直到遇到 EOF(文件结束)标记。在这个配置中,从标准输入读取一个字符并存储在 int 变量 ch 中。然后比较这个字符的值与定义的 EOF 常量。只要读取的字符不是 EOF,循环就会继续。这种循环可以用其他方式构建,但使用这种方法可以确保不会意外地输出 EOF。
列表 4.3 io_filter.c
#include <stdio.h>
int main()
{
int ch; ❶
while( (ch = getchar()) != EOF) ❷
putchar(ch); ❸
return(0);
}
❶ I/O 处理整数,而不是字符。
❷ 读取输入直到遇到文件结束;EOF 是一个整数值。
❸ 输出
io_filter.c 程序的结果是不做任何事情。它的工作方式就像管道:水进去,水出来。字符没有经过修改;putchar() 函数输出输入的字符 ch。即便如此,该程序展示了创建一个执行有用操作的过滤器的基本结构。
如果你单独运行过滤器程序,你会看到输入被回显到输出:按下 Enter 清空输出缓冲区,导致回显文本出现:
hello
hello
按下 EOF 键来停止程序。在 Linux 中,EOF 键是 Ctrl+D。在 Windows 中,按 Ctrl+Z 作为 EOF。
要让过滤器执行某些操作,请在 io_filter.c 源代码中构建 while 循环。目标是修改字符输入,在发送到输出之前。否则:膨胀。
例如,你可以修改输入,以便检测并替换所有元音字母为星号字符。这种修改在 while 循环中进行,因为它处理输入流。以下是完成此任务的一种方法:
while( (ch = getchar()) != EOF)
{
switch(ch)
{
case 'a':
case 'A':
case 'e':
case 'E':
case 'i':
case 'I':
case 'o':
case 'O':
case 'u':
case 'U':
putchar('*');
break;
default:
putchar(ch);
}
}
此修改的完整源代码可在本书的 GitHub 仓库中找到,名为 censored.c。以下是一个示例运行:
hello
h*ll*
练习 4.1
现在你已经有了 io_filter.c 中的基本过滤器框架,你可以进行自己的修改,测试你的过滤器编程技能。这里有一个你可以自己编写的挑战:编写一个将小写字符转换为大写的过滤器。这种过滤器的效果是生成全大写输出。这个练习的解决方案可以在本书的 GitHub 仓库中找到,名为 allcaps.c。
练习 4.2
编写一个过滤器,随机化字符文本,修改标准输入以生成大写或小写输出,而不考虑原始字符的大小写。我已经将这个练习的潜在解决方案包含在这本书的 GitHub 仓库中,名为 ransom.c。
4.1.3 在命令提示符下使用过滤器
你不能在 IDE 中测试过滤器,所以如果你还没有这样做,请转到命令提示符。你需要使用的 I/O 重定向工具如表 4.1 所示。这些单字符命令修改流,改变输入或输出的流程——或者两者都改变!
表 4.1 I/O 重定向字符及其功能
| 字符 | 名称 | 功能 |
|---|---|---|
| > | 大于 | 重定向输出(实际上不用于过滤器) |
| < | 小于 | 重定向输入 |
| | | 管道 | 通过另一个程序发送输出 |
假设你已经完成了练习 4.2,其中你创建了一个随机化字符文本的过滤器。这个过滤器程序名为 hostage。要使用这个过滤器,你必须指定程序的完整路径名。对于以下命令,假设过滤器存储在输入命令的同一目录中;./ 前缀指示操作系统在当前目录中查找程序:
echo "Give me all your money" | ./hostage
echo 命令将一串文本发送到标准输出。然而,管道字符拦截了标准输出,将其从标准输出设备(终端窗口)发送出去。相反,echo 命令的输出被提供给名为 ransom 的程序作为输入。结果是,过滤器将文本字符串作为其输入进行处理:
gIvE ME AlL yoUR mONey
另一种将文本通过过滤器的方式是使用输入重定向。在这个配置中,过滤器程序名称首先出现。然后是输入重定向字符 <(小于),以及输入源,例如一个文本文件:
./hostage < file.txt
在上面,file.txt 的内容被重定向为人质程序的输入,该程序使用随机的大写和小写字母输出文件的文本。
输出重定向字符在过滤器中实际上不起作用。相反,它将程序的输出发送到文件或设备:程序(或构造)的输出为文件提供文本。如果文件存在,它将被覆盖。否则,将创建一个新文件:
echo “Give me all your money” | ./hostage > ransom_note.txt
在上面,echo 命令的文本通过人质过滤器进行处理。输出通常会发送到标准输出设备,但相反,它被重定向并保存到 ransom_note.txt 文件中。
请记住,输出重定向不会为过滤器提供输入。请使用管道将一个程序(或某些其他来源)的输出发送到过滤器。
4.2 与凯撒并肩作战前线
朱利叶斯·凯撒并没有发明以他的名字命名的密码。这项技术虽然古老但有效,尤其是在一个大部分文盲的人群中:凯撒可以发送一个加密的信件,如果它落入敌人手中,坏蛋们将毫无头绪。愚蠢的比利时人。然而,一旦被正确的人收到,文本立即被解密,可怜的高卢人再次受到同情。
图 4.4 展示了密码的工作原理,它是一种简单的字母移位。它基于一个起始对,如图中所示,例如 A 到 D。这种关系在整个字母表中继续,根据起始对来移位字母:A 到 D,B 到 E,C 到 F,以此类推。

图 4.4 凯撒密码基于字母移位。
当你知道密码的起始对时,信息很容易被解码。实际上,如果你曾经获得过一个秘密解码环,你可能已经使用过这种类型的密码:起始对是给出的,然后整个信息逐字进行编码或解码:
EH VXUH WR GULQN BRXU RYDOWLQH.
惊讶的是,凯撒密码,也称为替换密码,至今仍在使用。诚然,它很脆弱,但请不要告诉邻居。rot13 过滤器可能是最常见的,你可以在下一节中了解到它。然而,编写这样的过滤器很有趣,它在加密技术领域也有其位置。
4.2.1 旋转 13 个字符
Unix 高手们所熟知的最常见的凯撒密码是 rot13 过滤器。请说“rote 13”,而不是“rot 13”。谢谢。
rot13 程序作为一个过滤器工作。如果它没有包含在你的 Linux 发行版中,请使用你的包管理器找到它以及其他古老而巧妙的命令行工具。
rot13 这个名字来源于字符替换模式:拉丁字母(和 ASCII)有 26 个字符,从 A 到 Z。如果你执行 A 到 N 的字符替换,字母表的上半部分与下半部分互换,如图 4.5 所示。程序“旋转 13”个字符。这种翻译的美丽之处在于,运行两次rot13过滤器可以将文本恢复到原始状态。这样,相同的过滤器既可以加密也可以解密消息。

图 4.5 rot13过滤器将字母表的上半部分与下半部分互换,有效地将字符“旋转”了 13 个位置。
在旧 ARPANET 以及早期的互联网上,rot13 被用作消息服务中的过滤器,以隐藏剧透、笑点和其他人们可能不想立即阅读的信息。图 4.6 展示了消息上rot13过滤器的运行情况。在原始文本中,笑话以标准文本形式出现,笑点被隐藏。应用rot13过滤器后,笑话文本被隐藏,但笑点被揭示,让人忍俊不禁。

图 4.6 应用rot13过滤器到文本的效果,乱序和还原
这种类型的凯撒密码很容易编写,因为你要么从给定字符的 ASCII 值中加上或减去 13,这取决于字符在字母表中的位置:上半部分或下半部分。加法或减法运算适用于大写和小写字母。
在列表 4.4 中,caesar01.c 的代码使用 isalpha()函数来排除字母表中的字母。toupper()函数将字母转换为大写,以便测试从 A 到 M 范围内的字符。如果是这样,这些字符将向上位移 13 个位置:ch+= 13。否则,else 语句会捕获字母表中的高字母,将它们向下位移。
列表 4.4 caesar01.c
#include <stdio.h>
#include <ctype.h>
int main()
{
int ch;
while( (ch = getchar()) != EOF)
{
if( isalpha(ch)) ❶
{
if( toupper(ch)>='A' && toupper(ch)<='M') ❷
ch+= 13; ❸
else
ch-= 13; ❹
}
putchar(ch);
}
return(0);
}
❶ 只处理字母字符
❷ 搜索“A”到“M”或“A”到“m”
❸ 对字母表下半部分向上旋转(位移)
❹ 否则,向下旋转(位移)
与所有过滤器一样,你可以使用 I/O 重定向命令(字符)在命令提示符下看到它的实际效果。具体细节请参阅 4.1.3 节。如果 caesar01.c 源代码的程序命名为 caesar01,以下是一个示例运行:
$ echo "Hail, Caesar!" | ./caesar01
Unvy, Pnrfne!
当程序直接运行时,它处理你输入的文本作为标准输入:
$ ./caesar01
Unvy, Pnrfne!
Hail, Caesar!
由于rot13过滤器对相同的文本进行解码和编码,你可以通过将文本通过它运行两次来测试程序。在下面的命令行构造中,文本通过程序运行一次,然后再次运行。由于rot13过程的神奇之处,结果是原始文本:
$ echo "Hail, Caesar!" | ./caesar01 | ./caesar01
Hail, Caesar!
记住,rot13 过滤器并不是为了完全保护信息安全而设计的。尽管如此,它提供了一个方便且常见的方法来隐藏某些内容,但不一定是加密到无法触及:
凯撒为什么越过鲁比孔河?
Gb trg gb gur bgure fvqr.
4.2.2 设计更复杂的凯撒密码
凯撒没有使用 rot13 过滤器来加密他的信息,主要是因为他从可靠的 Commodore 64 系统升级到 Linux 的过程中从未升级过。不,他更喜欢 A 到 D 的移位。有时它只是一个 A 到 B 的移位。无论如何,编写这样的程序比 rot13 过滤器方便的 13 个字符移位要复杂得多。
根据除 13 之外的其他值正确转换字母意味着字母会回绕。例如,A 到 D 的转换意味着 Z 会回绕到 ASCII 表中 Z+3 的某个字符。因此,为了使转换继续进行,字母移位必须从 Z 回绕到 C(参见图 4.4)。你必须在代码中考虑这种回绕,确保字符包含在字母表的 26 个字母变化中——包括大写和小写。
为了处理这种回绕,特别是对于 A 到 D 的转换,你的代码必须构建一个复杂的 if 条件,使用逻辑比较来处理超出范围的字符。图 4.7 展示了这种表达式是如何工作的。它测试值大于 'Z' 且小于 'a',但也大于 'z'。这种排列是由于字符按照 ASCII 标准进行编码。(有关 ASCII 的更多详细信息,请参阅第五章。)

图 4.7 在执行 A 到 D 移位时检测溢出字符
当 if 语句检测到字符超出范围时,其值必须减少 26,将其回绕到 'A' 或 'a',具体取决于字母的原始大小写。
由于大写字母 'Z' 与小写字母 'a' 的接近,这个 if 语句测试之所以有效,是因为这种特定的移位只有三个字符。从图 4.7 中,你可以看到 ASCII 表只在大写字母 'Z' 和小写字母 'a' 之间设置了六个字符。对于更大的字符移位,必须进行更复杂的测试。
列表 4.5 展示了如何编码 A 到 D 字符移位密码,包括将溢出字符回绕的复杂 if 语句。否则,字符将根据变量 shift 的值进行移位,该值计算为 'D' - 'A'。这种移位以反向表达,以便正确计算为三。因此,代码中的每个字母字符都加三——除非字符超出范围。
列表 4.5 caesar02.c
#include <stdio.h>
#include <ctype.h>
int main()
{
int shift,ch;
shift = 'D' - 'A'; ❶
while( (ch = getchar()) != EOF)
{
if( isalpha(ch)) ❷
{
ch+=shift; ❸
if( (ch>'Z' && ch<'a') || ch>'z') ❹
ch-= 26; ❺
}
putchar(ch);
}
return(0);
}
❶ 从 A 移动到 D,这里之所以反向操作,是因为数学
❷ 特殊处理字母
❸ 移动字母
❹ 确定新字符是否超出范围
❺ 如果是这样,则调整其值回到范围内
这里是一个示例运行:
Now is the time for all good men...
Qrz lv wkh wlph iru doo jrrg phq...
与 rot13 过滤器不同,你不能运行相同的程序两次来解码 A 到 D 的移位。相反,为了解码信息,你必须从 D 移回到 A。为此需要两个更改。在列表 4.5 中显示的代码中,首先更改移位计算:
shift = 'A' - 'D';
第二,越界测试必须检查字母表的底部,看看字符的值是否已经低于 'A' 或 'a':
if( ch<'A' || (ch>'Z' && ch<'a'))
ch+= 26;
如果字符在字母表的底部回绕,其值将增加 26,这样它就会回绕到 Z 的末尾,纠正溢出。
最终程序作为 caesar03.c 存储在这个书的 GitHub 仓库中。以下是一个示例运行:
Now is the time for all good men
Klt fp qeb qfjb clo xii dlla jbk
Qrz lv wkh wlph iru doo jrrg phq...
Now is the time for all good men...
前两行显示了 D 到 A 的转换,即过滤器如何编码纯文本。接下来的两行显示了 D 到 A 的转换如何解密 caesar02.c 代码的原始 A 到 D 转换。(参考之前显示的输出。)
与任何过滤器一样,您可以将输出通过两个过滤器来恢复原始文本:
$ echo "Hail, Caesar!" | ./caesar02 | ./caesar03
Hail, Caesar!
当然,编写更凯撒密码的最佳方式是让用户确定要转换的字母。为了让这个过滤器工作,需要命令行参数;过滤器不是交互式的,所以用户没有机会提供其他输入。
命令行参数提供了转换的两个字母,从参数 1 到参数 2。然后代码计算出这个过程,对投入标准输入的任何文本执行转换。
让用户决定选项总是好的。提供这个功能意味着大部分代码都用于解释命令行选项:你必须检查选项是否存在,然后确认这两个选项都是字母表中的字母。这样的代码可以在 GitHub 仓库中的 caesar04.c 文件中找到。在这个源代码文件中检查两个命令行参数的额外步骤消耗了 16 行代码。
一旦设置了两个转换字符,它们就被保存在char变量 a 和 b 中。然后一个while循环根据提供的两个字符的转换值处理文本。因为转换可以是向上或向下,为了最好地检查范围外的值,循环必须分别处理大写和小写字符。这种方法最好用于检测转换溢出并正确处理它。程序的核心while循环和 caesar04.c 程序中的各种测试在下一列表中显示。
列表 4.6 caesar04.c 中的while循环执行字符转换
while( (ch = getchar()) != EOF)
{
if( isupper(ch) ) ❶
{
ch+= shift;
if( ch>'Z' ) ch-=26; ❷
if( ch<'A' ) ch+=26; ❷
putchar(ch);
}
else if( islower(ch) )
{
ch+= shift;
if( ch>'z' ) ch-=26; ❷
if( ch<'a' ) ch+=26; ❷
putchar(ch);
}
else
{
putchar(ch);
}
}
❶ 大小写字符必须以不同的方式处理。
❷ 适当调整双向溢出
这里是caesar04程序的一个 A 到 R 转换的示例运行:
$ ./caesar04 A R
This is a test
Kyzj zj r kvjk
要反转,R 到 A 的转换由命令行参数指定:
$ ./caesar04 R A
Kyzj zj r kvjk
This is a test
作为改进,可能更好的是有一个单独的参数来指定字符转换,比如RA而不是像刚才那样分开的 R 和 A。然而,就像大多数程序员一样,修改代码是一个永恒的过程。我把这个任务留给你。
4.3 深入过滤器疯狂
在我的编程生涯中,我创建了许多过滤器。想到你可以完成的一些有趣的事情,真是令人惊讶。嗯,对那些书呆子来说很有趣。非书呆子现在正在读一本浪漫小说。让我来破坏一下:他的工作对他来说比她更重要。就这样。帮你省下了 180 页无聊的内容。
无论过滤器做什么,编写过滤器的方法总是相同的:读取标准输入,修改它,然后生成标准输出。
在本章结束之前(我必须快点,因为我的工作很重要),我提供了一些不同的过滤器想法,以帮助激发你的创造力。可能性是无限的。
4.3.1 构建十六进制输出过滤器
就算一个字符流入过滤器,并不意味着另一个字符必须始终流出。一些过滤器可能对每个输入字符输出多个字符。其他过滤器可能不会输出任何文本修改,例如 more 过滤器。
more 过滤器是一个方便的文本阅读工具。它用于分页输出。将输出通过 more 过滤器后,每满一屏文本会提示输入:
cat long.txt | more
如上所示,文件 long.txt 的内容通过 cat 命令输出。more 过滤器在每满一屏文本后暂停显示。这个过滤器在 Unix 中足够流行,以至于微软“借用”它并将其包含在其文本模式操作系统 MS-DOS 中。
对于生成比输入更多输出的过滤器,考虑以下列表。该代码接受标准输入,并输出每个字符的十六进制值。printf() 语句生成两位十六进制值。
列表 4.7 hexfilter01.c
#include <stdio.h>
int main()
{
int ch;
while( (ch=getchar()) != EOF )
{
printf("%02X ",ch); ❶
}
return(0);
}
❶ 以两位十六进制字节输出字符,带前导零
hexfilter01.c 的代码运行良好,但它输出时确实存在一个问题:两位字符格式显示为长字符串文本。通常文本值会分布在两行之间。更好的方法是监控输出,以避免在行尾分割十六进制值。
练习 4.3
假设终端屏幕宽度为 80 个字符,修改 hexfilter01.c 代码,使输出不会在两行之间分割十六进制值。此外,当遇到换行符时,输出行应以换行符结束。这个练习的解决方案可以在 GitHub 仓库中找到,作为 hexfilter02.c。请在查看我的解决方案之前自己尝试这个练习。
4.3.2 创建北约过滤器
第三章介绍了北约音标字母表,它——惊喜——也可以用作过滤器。例如,过滤器读取标准输入,提取所有字母字符。对于每一个,过滤器输出相应的北约术语。这个程序是另一个过滤器,它不仅仅进行单字符交换的例子。
要进行音标字母表的翻译,代码必须借用第三章中提到的 nato[] 术语数组。该数组在列表 4.8 中显示。它与标准 I/O 过滤器 while 循环结合使用。在循环中,isalpha() 函数检测字母字符。进行一些数学运算以获得数组中正确的术语偏移量,从而输出每个处理的字母的正确术语。
列表 4.8 nato01.c
#include <stdio.h>
#include <ctype.h>
int main()
{
char *nato[] = {
"Alfa", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot",
"Golf", "Hotel", "India", "Juliett", "Kilo", "Lima",
"Mike", "November", "Oscar", "Papa", "Quebec", "Romeo",
"Sierra", "Tango", "Uniform", "Victor", "Whiskey",
"Xray", "Yankee", "Zulu"
};
char ch;
while( (ch=getchar()) != EOF)
{
if(isalpha(ch))
printf("%s ",nato[toupper(ch)-'A']); ❶
if( ch=='\n' ) ❷
putchar(ch);
}
putchar('\n');
return(0);
}
❶ 将字符转换为 nato[] 数组中的偏移量
❷ 遇到时输出换行符以保持输出整洁
这里是一个示例运行:
$ ./nato
hello
Hotel Echo Lima Lima Oscar
重要的是要知道,此过滤器会忽略任何非字母字符(除换行符外)。在过滤器中忽略输入是合法的;过滤器不需要根据输入生成一对一的输出。
4.3.3 过滤单词
过滤器在字符输入/输出上操作,但这种限制并不阻止过滤器影响单词、句子或其他文本块。关键是存储到达的输入。一旦适当的文本块(如单词或句子)被组装好,过滤器就可以对其进行处理。
例如,为了按单词切割标准输入,你编写一个过滤器,收集字符直到遇到单词边界——例如空格、逗号、制表符或句号。输入必须被存储,因此必须进行进一步的测试以确保存储不会溢出。一旦缓冲区包含一个单词(或你需要的任何大小的文本块),它就可以发送到标准输出或以过滤器需要的方式处理数据。
在列表 4.9 中,一个 64 字符的缓冲区 word[]存储单词。while循环被分成if-else条件。if测试标记单词的结束,使用空字符首字母大写 word[]缓冲区,确认一个完整的单词准备输出,然后输出单词。else测试构建单词,确保缓冲区不会溢出。结果是提取单词并将每个单词单独放在一行上的过滤器。
列表 4.9 word_filter.c
#include <stdio.h>
#include <ctype.h>
#define WORDSIZE 64 ❶
int main()
{
char word[WORDSIZE];
int ch,offset;
offset = 0; ❷
while( (ch = getchar()) != EOF)
{
if( isspace(ch) ) ❸
{
word[offset] = '\0'; ❹
if( offset>0 ) ❺
printf("%s\n",word); ❻
offset = 0; ❼
}
else ❽
{
word[offset] = ch; ❾
offset++; ❿
if( offset==WORDSIZE-1 ) ⓫
{
word[offset] = '\0'; ⓬
printf("%s\n",word); ⓭
offset = 0; ⓮
}
}
}
return(0);
}
❶ 单词大小在这里设置;这样,你可以在一个地方更新缓冲区大小,并且代码的其他部分也会更新以反映这一变化。
❷ 初始化偏移量值
❸ isspace()函数对空白字符返回 TRUE,标记单词的结束。
❹ 总是首字母大写你的字符串!
❺ 确保缓冲区中有文本可以打印
❻ 将缓冲区的内容(一个单词,希望如此)单独输出到一行
❼ 重置偏移量
❽ 处理可打印字符,填充缓冲区。
❾ 存储字符
❿ 增加偏移量
⓫ 检查潜在的溢出,缓冲区已满
⓬ 首字母大写字符串!
⓭ 输出单词,清空缓冲区
⓮ 重置偏移量
要构建单词,word_filter.c 中的代码依赖于 ctype.h 头文件中定义的isspace()函数。当输入遇到空白字符时,该函数返回 TRUE。这些字符包括空格、制表符和换行符。这些空白字符触发单词边界,尽管代码可以被修改以考虑其他字符。
这里是一个示例运行:
$ ./word_filter
Is this still the Caesarean Cipher chapter?
Is
this
still
the
Caesarean
Cipher
chapter?
在代码中两次看到使用空字符首字母大写 word[]缓冲区的语句:
word[offset] = '\0';
在 C 语言中,所有字符串都必须以空字符结尾,即\0。特别是当你构建自己的字符串时,就像在 word_filter.c 代码中所做的那样,确认创建的字符串已首字母大写。如果没有,你会得到溢出和各种丑陋的输出——以及可能发生的不良后果。
5 编码和解码
容易将编码和解码的主题与加密混淆。这些是类似的过程,但加密的目的是隐藏和保护信息。编码是为了传输可能对介质过于复杂的信息,或在不同系统之间进行转换,或其他无害的目的。无论如何,编码和解码的过程具有充满动作和神秘性的潜力。
嗯,也许不是。
然而,在计算机电信的早期,编码和解码是常规操作。我记得通过调制解调器传输我的第一个程序:16 千字节,传输耗时 16 分钟。该程序由二进制数据组成,但它以纯文本的形式传输。它需要在发送端进行编码,在接收端进行解码。今天仍然会发生这样的魔法,尽管可能要快得多。
要探索编码和解码的概念,无论其刺激和危险,你必须:
-
欣赏字符在计算机上的表示
-
学习各种 ASCII 编码技巧
-
玩弄字符表示
-
将纯文本转换为十六进制字节进行数据传输
-
将十六进制字节反向翻译回文本(或数据)
-
通过添加校验和来改进编码技术
-
探索 URL 编码方法
这些项目都不枯燥,不像那本关于用熨斗可以做的 100 个有趣且合法的家庭项目的书。但如果你想了解更多关于加密的信息,请参阅第四章。
5.1 纯文本的概念
计算机不知道文本。char数据类型仅仅是一个很小的整数,其值从 0 到 255(无符号)或-128 到 127(有符号)。只有char数据类型的表示才使其看起来像字符。
在 C 语言中,putchar()函数将一个值作为字符输出。该函数的man页面声明该函数的参数为一个整数,尽管它作为字符出现在标准输出设备上。
printf()函数对字符的理解更深入一些。它将char数据类型作为字符输出,但仅当在格式字符串中使用%c 占位符时。如果你用%d 替换,即十进制整数输出占位符,数据将作为数字输出。
但输出的是什么东西?计算机如何知道将特定的值与给定的字符匹配?答案是古老的数字缩写词 ASCII。
5.1.1 理解 ASCII
重要的是要注意 ASCII 的发音是“ass-key”。没错:ass 和 key。尽管你可以嘲笑,但如果你说“ask two”,每个人都会知道你是个傻瓜。
不必注意,ASCII 代表美国信息交换标准代码。是的,这是一个由整天坐着创造标准的人制定的标准。尽管该标准在 20 世纪 60 年代初开发,但直到 20 世纪 80 年代中期,几乎地球上的每台计算机才开始一致使用 ASCII 代码。
通过采用 ASCII 标准为字符分配代码,计算机可以在无需任何翻译的情况下交换基本信息。在 1970 年代末得到广泛采用之前,计算机必须运行翻译程序,才能从一个系统正确地读取甚至一个文本文件到下一个系统。但如今,你那昂贵的 Macintosh 上的文本文件在我的便宜 Linux 盒子上也能轻松阅读,我的朋友 Don 在他的商店后面用 499 美元组装了这个盒子。
ASCII 的工作方式是为常见的字符和符号分配代码,即整数值。这种翻译起源于电报时代,当时代码必须一致,以便消息能够被翻译——编码和解码——否则“洞口帮”会再次抢劫 12:10,因为老 Hamer McCleary 在 Belle Fourche 车站打瞌睡。
ASCII 代码的设计巧妙,这对任何一组人类来说都是惊人的。这种模式允许发生各种有趣和创造性的事情,如第 5.1.4 节所述。图 5.1 列出了 ASCII 代码表,以常见的四“棒”形式展示。看看你是否能发现任何模式。

图 5.1 显示十进制、八进制、十六进制和字符值的 ASCII 表
从图 5.1 中,你可以看到 ASCII 代码的范围是从 0 到 127。这些是二进制值 000-0000 到 111-1111。对于 C 语言的char数据类型,这些值都是正数,无论变量是signed还是unsigned。
ASCII 表中的四列,或“棒”,代表不同类型的字符类别。再次强调,代码是有组织的,这可能是由于早期那些令人厌恶的计算机字符代码的一些教育,这些代码后来被扔进垃圾桶,并用喷气发动机烧毁。
第一根棒包含非打印控制代码,这就是为什么它在图 5.1 中的输出看起来如此单调。关于控制代码的更多信息,请参阅第 5.1.2 节。
ASCII 表第二根棒中的字符是为了排序目的而选择的。前几个字符与电传打字机上的字符相同,即转换后的数字键。这些在今天的大部分情况下仍然适用:Shift+1 是感叹号(!),Shift+3 是井号(#),等等。
第三根棒包含大写字母,还有一些符号。
第四根棒包含小写字母,以及剩余的符号。
关于 ASCII 表和这些代码周围的奇迹和魔法将在接下来的几节中介绍。
练习 5.1
拥有一个 ASCII 表对任何程序员来说都是至关重要的。与其在 Etsy 上卖我那漂亮的 ASCII 墙图,我决定你必须自己编写 ASCII 表。让输出看起来与图 5.1 中所示完全一致——这恰好是我自己的 ASCII 程序输出的结果,看起来就像墙图。我经常运行我的 ASCII 程序作为参考,因为这样的信息很有用,而程序是一种快速保存这些信息的方法,尽管我在 Etsy 上没有赚钱。
我对这个练习的解决方案的源代码可以在本书的在线仓库中找到,文件名为 asciitable01.c。但在你盲目模仿我所做的一切之前,请先尝试自己创建一个。
5.1.2 探索控制码
我发现 ASCII 码的第一组是最有趣的,从历史和幽默的角度来看都是如此。控制码的名字真是可爱!“文本结束”?试着在会议上用这个,但最好说“控制 C”,这样一些人可能就会明白了。
“文本结束”是 Ctrl+C 控制码的官方名称,ASCII 码 3。表 5.1 列出了详细信息。其中一些代码或它们的键盘等效可能对你来说很熟悉。
表 5.1 ASCII 控制码
| 十进制 | 八进制 | 十六进制 | 名称 | Ctrl | Esc | 定义 |
|---|---|---|---|---|---|---|
| 0 | 0 | 00 | NULL | ^@ | \0 | 空字符 |
| 1 | 1 | 01 | SOH | ^A | 文件头开始 | |
| 2 | 2 | 02 | STX | ^B | 文本开始 | |
| 3 | 3 | 03 | ETX | ^C | 文本结束 | |
| 4 | 4 | 04 | EOT | ^D | 传输结束 | |
| 5 | 5 | 05 | ENQ | ^E | 询问,“谁?” | |
| 6 | 6 | 06 | ACK | ^F | 确认 | |
| 7 | 7 | 07 | BEL | ^G | \a | 铃 |
| 8 | 10 | 08 | BS | ^H | \b | 退格 |
| 9 | 11 | 09 | HT | ^I | \t | 水平制表符 |
| 10 | 12 | 0A | LF | ^J | \n | 换行 |
| 11 | 13 | 0B | VT | ^K | \v | 垂直制表符 |
| 12 | 14 | 0C | FF | ^L | \f | 表格馈送 |
| 13 | 15 | 0D | CR | ^M | \r | 回车 |
| 14 | 16 | 0E | SO | ^N | 位移输出 | |
| 15 | 17 | 0F | SI | ^O | 位移输入 | |
| 16 | 20 | 10 | DLE | ^P | 数据链路转义 | |
| 17 | 21 | 11 | DC1 | ^Q | 设备控制一,XON | |
| 18 | 22 | 12 | DC2 | ^R | 设备控制二 | |
| 19 | 23 | 13 | DC3 | ^S | 设备控制三,XOFF | |
| 20 | 24 | 14 | DC4 | ^T | 设备控制四 | |
| 21 | 25 | 15 | NAK | ^U | 负确认 | |
| 22 | 26 | 16 | SYN | ^V | 同步空闲 | |
| 23 | 27 | 17 | ETB | ^W | 传输块结束 | |
| 24 | 30 | 18 | CAN | ^X | 取消 | |
| 25 | 31 | 19 | EM | ^Y | 媒体结束 | |
| 26 | 32 | 1A | SUB | ^Z | 替换 | |
| 27 | 33 | 1B | ESC | ^[ | \e | 转义 |
| 28 | 34 | 1C | FS | ^\ | 文件分隔符 | |
| 29 | 35 | 1D | GS | ^] | 组分隔符 | |
| 30 | 36 | 1E | RS | ^^ | 记录分隔符 | |
| 31 | 37 | 1F | US | ^_ | 单位分隔符 |
表 5.1 列出了每个 ASCII 码的十进制、八进制(基 8)和十六进制值。名称列显示了古老的电传打字机名称,以及代码的原始和被遗忘的用途。尽管如此,一些控制码至今仍在使用:计算机的蜂鸣声仍然是控制码 7,即“铃”,键盘等效 Ctrl+G 和转义序列\a(用于警报或警报)。
Ctrl 列显示了在终端窗口中使用的控制键组合。现代描述使用单词Ctrl来表示控制,尽管过去的灰白头发、穿着凉鞋的 Unix 程序员更喜欢使用撇号字符,^。这个表达式解释了为什么按 Ctrl+D 作为 Linux 的 EOF 字符会在终端窗口中输出D,这个字符的原始名称是“传输结束”,这是有道理的。(不要只是为了看到D 字符而按 Ctrl+D;这样做会关闭终端窗口。)
一些控制键快捷方式直接映射到键盘上的其他键,主要用于终端窗口。例如,Ctrl+M 是回车/换行键:按下 Ctrl+M 与按下回车键相同。其他映射的控制键包括:
-
Ctrl+I 到制表键
-
Ctrl+H 到退格键
-
Ctrl+[到 Esc
这些快捷方式可能并不适用于所有情况,但表 5.1 展示了它们是如何映射的。
表 5.1 中的 Esc 列列出了某些常见控制代码的 C 转义字符等效。记住,如果你使用格式\xnn(其中nn是字符的十六进制 ASCII 码),任何代码都可以在 C 中被指定为转义字符序列。
许多控制键在现代计算机中已经失去了它们的作用。回顾电传打字机时代——Linux 当前终端窗口的根源所在——它们曾经是重要的。事实上,Ctrl+S/Ctrl+Q(XON, XOFF)键仍然可以用来暂停和恢复文本的滚动显示。只是现代终端显示文本的速度如此之快,以至于现在使用这些键已经没有意义了。
小心在代码中输出控制字符。其中一些具有可预测的功能,特别是表 5.1 中的 Esc 列中的那些。这些转义序列在 C 中很有用。但向标准输出发送奇怪的控制代码可能会损坏显示。例如,在某些终端上,输出^L(代码 12,换页)会清除显示。当发送到打印机——即使是现代打印机——^L 会弹出一张纸。
作为提示——因为我知道有一天你会故意或可能意外地尝试输出一个控制代码——如果一个控制代码弄乱了终端显示,发出reset命令。输入reset并按 Enter 键,终端会尝试从你弄乱的地方恢复过来。
最后一个控制代码没有出现在 ASCII 表的最后一栏中(参见表 5.1)。这是字符代码 127,通常称为 Del(删除)或 Rub Out。像代码 0 到 31 一样,它是不可打印的,但它的输出不会弄乱显示。这个字符是电传打字机时代的遗留物,当时它被用作备份和删除字符;退格代码(8 或^H)只是移动光标,是一种非破坏性的备份。
字节中的其他 128 个字符代码是什么?
即使在微型计算机时代,一个字节数据由 256 个可能的码组成,从 0 到 255。ASCII 字符定义了 0 到 127 的码的标准。其他码是非标准的——虽然 ASCII 没有定义,但许多早期的计算机用户会错误地将它们标记为这样的码。
在 IBM PC 上,128 到 255 的码被称为扩展 ASCII。这些码为所有 PC 兼容机(或多或少)输出一致的字符,但不适用于 Apple II、Commodore 64 或其他那个时代的流行和脆弱的系统。即使那时,通过在 PC 上更换新的代码页,也可以更改扩展 ASCII 码。这种字符的多样性导致了巨大的混乱。幸运的是,当时的计算机行业状态是一致的混乱,所以很少有人注意到。
今天,任何大于 127 的字符码都按照 Unicode 进行了标准化。这些码定义了你从未见过或听说的几乎所有字符。有关更多和令人兴奋的细节,请参阅第八章。
5.1.3 生成非字符输出
当作为字符输出时,char变量显示为字符。谢天谢地:计算机显示原始数据的时代已经过去了——除了电影,在那里计算机仍然有闪烁的灯光和输出一行又一行的数字的显示器。然后,电影中的显示器在显示文本时会发出噪音,而“黑客”在应该使用鼠标的电脑上无休止地打字。愚蠢的好莱坞。
使用除%c 之外的转换字符,你可以编写输出char数据为十进制或十六进制值的代码——甚至可以使用相同的变量:
printf("%c = %d = %x\n",ch,ch,ch);
在这个声明中,变量 ch 被输出三次:一次作为其字符值,一次作为十进制整数,再次作为十六进制整数。如果你对八进制感兴趣,可以使用%o 来输出以 8 为基数的值。实际上,如果你为练习 5.1 编写了代码,你可能使用了类似这样的printf()语句。
但二进制怎么办?
标准 C 库缺少二进制输出函数。因此,这是你的工作,编写一个。或者,你也可以依赖我使用的,我的binString()函数。
列表 5.1 显示了binString()函数的 8 位版本,该函数是为了输出存储在char数据类型中的值。该函数使用位与(&)运算符来确定字符字节中左边的位是否开启(1)。如果是,字符'1'将被放置到 b[]缓冲区中;否则,设置为'0'。然后变量 a 中的值向左移动一个位位置,操作重复进行。在检查位时,字符串 b[]被填充为一和零。这个字符串被声明为静态的,因此其值可以被返回,并且二进制字符串可以被调用binString()函数的任何语句使用。
列表 5.1 8 位binString()函数
char *binString(char a)
{
static char b[9]; ❶
int i;
i = 0; ❷
while( i<8 ) ❸
{
b[i] = a&0x80 ? '1' : '0'; ❹
a <<= 1; ❺
i++;
}
b[i] = '\0'; ❻
return(b);
}
❶ 字符串是静态的,因此其值被保留;九个字符允许一个 8 位字节,加上一个用于终止空字符的额外元素。
❷ 初始化索引变量
❸ 对 8 位字节中的每个位进行循环
❹ 三元运算符根据变量 a 最左边的位值将 1 或 0 设置到字符串中。
❺ 变量 a 的值向左移动一个位位置。
❻ 此时,i 等于 8,因此字符串被截断。
8 位binString()函数可以被编织到代码中,以二进制形式输出 ASCII 表中的值,这是生成非字符输出的另一种方式——比单调的十进制、性感的十六进制或过时的八进制更有趣。
要查看binString()函数的实际效果,请参考本书在线仓库中包含的源代码文件 binascii01.c。其程序输出带有二进制数据的 ASCII 表。
作为一名书呆子,我喜欢由二进制值创造的图案以及它们与十六进制的关系。事实上,我发现将十六进制和二进制之间转换很容易,我经常在脑海中这样做。这种关系在表 5.2 中得到了说明,这使得理解下一节中揭示的一些常见 ASCII 转换技巧变得容易。
表 5.2 二进制到十六进制的转换
| 二进制 | 十六进制 | 二进制 | 十六进制 |
|---|---|---|---|
| 0000 | 0 | 1000 | 8 |
| 0001 | 1 | 1001 | 9 |
| 0010 | 2 | 1010 | A |
| 0011 | 3 | 1011 | B |
| 0100 | 4 | 1100 | C |
| 0101 | 5 | 1101 | D |
| 0110 | 6 | 1110 | E |
| 0111 | 7 | 1111 | F |
图 5.2 说明了二进制位位置,这有助于继续我对二进制-十六进制关系的书呆子般的热爱。例如,注意偶数以 0 作为第一个二进制位。 (像十进制一样,二进制位从右到左、从低到高排序。)奇数在第一个位置有 1 位。

图 5.2 字节中的位位置及其如何分解为值
我觉得其他一些事情也很酷:二进制 1010 是十六进制 A,也就是十进制的 10。双“10”数字是一个很好的线索。二进制 1011 是十六进制 B,或者十进制的 11。如果你查看表 5.2 和图 5.2,会发现其他模式也很明显——但要注意,如果你过于享受这类事物,也可能变成一个书呆子。
5.1.4 玩转 ASCII 转换技巧
设计 ASCII 表的那些人,为字符分配代码,对人类来说很聪明。要么他们是协调值和字符时欣赏知识的杰出天才,要么他们只是中了幸运大奖。我两种情况都不在乎。但我将利用这种偶然性。
哦,我讨厌这个词serendipity。
我利用的一个技巧是将数字 0 到 9 映射到十六进制值 0x30 到 0x39。这种安排使得对字符值进行简单数学运算以将它们转换为数值变得容易。例如:
printf("%d\n",'9' - '0');
这个printf()语句从'9'减去'0',看起来像字符值,但被编译器视为 0x39 - 0x30。结果是作为十进制值九输出,这正是'9'所代表的。
如果char变量 a 包含一个数字字符,你可以使用以下方法提取其整数值:
b = a - '0';
你可以用类似的方法用字母表中的字母来获取它们在 0 到 25 的范围内,尽管 A 或 a 的十六进制值并不那么吸引人。例如,假设一个大写字母在 char 变量 ch 中:
offset = ch - 'A';
在这里,偏移量的值等于字符 ch 中大写字母的数量,从 A 到 Z 的 0 到 25。这个操作的例子在第三章中可以看到,其中使用字母表中的一个字母来引用 nato[] 数组。请参阅本书在线仓库中的 nato01.c 和 nato02.c,以了解第三章的内容。
ASCII 表的列一和三(参考表 5.1)显示了不同数字序列中的相同字符。第一列的控制代码使用字符 ^@ 通过 ^_(下划线)和第三列使用字符 @ 通过 (下划线)。因此,表示控制代码的一种方法是将字符值加上十六进制值 0x40。在下面的 printf() 语句中,char 变量 cc 持有控制代码值(0x00 到 0x1F),以 ^@ 通过 ^ 的形式输出:
printf("Control code: ^%c\n",cc+0x40);
以下语句反映了表达相同输出的另一种方式:
printf("Control code: ^%c\n",cc+'@');
如果你比较 ASCII 表的第三和第四列(再次参考图 5.1),你会看到大写和小写字符之间的差异正好是 32 或 0x20。这种安排允许进行一些有趣的字符操作,以在大小写字母之间切换:
-
要将大写字母转换为小写,你需要将字节的第六位重置。
-
要将小写字母转换为大写,你需要将字节的第六位设置。
图 5.3 通过字母 A 和 a 说明了位设置和重置的过程。对于拉丁字母表中的所有字母,这种关系都成立:设置或重置第六位会改变字符的大小写。

图 5.3 字节的第六位如何影响字母的大小写
要神奇地操作字节的第六位,你使用位运算符,&(与)或 |(或)。你很可能在学习 C 语言时跳过了这些运算符。真遗憾。
要将大写转换为小写,你必须设置第六位。这个操作由字节第六位的 |(或)运算符处理。表达式是:
c = c | 0x20;
上面的代码中,char 变量 c 中的大写字母被转换为其小写等价物。代码也可以简写为:
c |= 0x20;
要将小写字母转换为大写,你必须重置(改变为零)字节的第六位。为了处理这个操作,使用 & 位运算符,它屏蔽了位:
c = c & 0xdf;
此外:
c &= 0xdf;
0x20 的二进制表示为 01000000。0xdf 的二进制表示为 10111111。
列表中展示的源代码演示了这些技术。sentence[] 中的样本字符串被处理了两次。第一次,一个 while 循环从字符串中提取大写字符,通过位运算 | 0x20 将它们转换为小写。第二次 while 循环针对小写字母,通过 & 0xdf 操作将它们转换为大写。指针 s 用于逐个字符地遍历 sentence[] 数组。
列表 5.2 casetricks01.c 的源代码
#include <stdio.h>
int main()
{
char sentence[] = "ASCII makes my heart beat faster\n";
char *s;
s = sentence;
while(*s)
{
if( *s>='A' && *s<='Z' ) ❶
putchar( *s | 0x20 ); ❷
else
putchar(*s);
s++;
}
s = sentence;
while(*s)
{
if( *s>='a' && *s<='z' ) ❸
putchar( *s & 0xdf ); ❹
else
putchar(*s);
s++;
}
return(0);
}
❶ 过滤掉大写文本
❷ 输出小写字符
❸ 过滤掉小写文本
❹ 输出大写字符
这里是样本输出:
ascii makes my heart beat faster
ASCII MAKES MY HEART BEAT FASTER
在我的代码中,我经常回退到使用 ctype 函数 tolower() 或 toupper() 来进行转换。但位运算也能同样完成这个任务,而且它们还能让你的代码看起来超级神秘。
5.2 十六进制编码器/解码器
我的第一次电信文件传输耗时 16 分钟。这是在一位朋友的 TRS-80 计算机和我的计算机之间进行的,使用标准电话线路上的模拟调制解调器。如果你想要装出一副震惊的表情,传输速度是 300 BPS。
传输的数据是纯文本。它可以是二进制,因为坦白说,电话线路并不关心字节中哪些位代表字符。然而,我还是坐了 15 分钟,看着乱七八糟的信息流过我的屏幕,神奇地变成了一个程序:原始二进制被编码为两位十六进制值,然后传输,然后我的计算机上的另一个程序消化了这些十六进制字节,将它们转换回二进制数据。
这种十六进制编码的另一个例子可以在那个时代的计算机杂志中找到。文章展示了你可以键入的惊人程序;十六进制字节列在页面上。爱好者们急切地将一个字节接一个字节地输入到他们的键盘上,一个十六进制解码程序吞噬了所有数字,创建了一个二进制程序—— fingers crossed——第一次运行并执行了一些奇妙任务。那些都是好日子。
顺便说一下,十六进制编码既不是加密也不是压缩。它只是以可打印的方式表达二进制数据的一种方法。
5.2.1 编写简单的十六进制编码器/解码器
将 ASCII 和二进制转换为十六进制最重要的部分是确保以可靠的方式转换回原始格式。毕竟,需要某种类型的验证来确保数据成功从编码中提取出来。
将任何信息转换为十六进制的一种方法就是编写一个过滤器,例如下一个列表中展示的过滤器。(如果你需要复习过滤器,请参考第四章。)该过滤器处理每个输入的字节 (int ch)。printf() 语句的转换字符 %02X 将字节输出为带前导零的两位十六进制值。代码只有在处理完所有输入后才输出换行符,这意味着转换是一个长的十六进制字节字符串。
列表 5.3 hexenfilter01.c 的源代码
#include <stdio.h>
int main()
{
int ch;
while( (ch=getchar()) != EOF )
{
printf("%02X",ch);
}
putchar('\n');
return(0);
}
下面是一个在命令提示符下使用标准输入(键盘)的示例运行,假设程序名称为 hexe 并且它位于当前目录中:
$ ./hexe
Hello there, hex!
48656C6C6F2074686572652C20686578210A
图 5.4 说明了输出过程中发生了什么,每个输入字符是如何被转换成十六进制字节的。

图 5.4 简单十六进制编码器的输出情况
此过滤器可以处理不仅仅是纯文本。你可以从任何文件类型重定向输入,包括二进制可执行文件:
$ ./hexe < hexe
这种串行十六进制数字方法的问题在于,输出结果仅对解码程序有用。我不期望用户输入一长串十六进制数字。这样的任务将是一场噩梦。
要解码长字符串文本的十六进制过滤器,你必须编写一个程序,将两位十六进制值正确地转换为它们的字节值等效。此类程序所做出的假设是它接收到的信息类型与编码器生成的是完全相同的——这是一个巨大的假设,我绝不会在任何计划发布的实用工具程序中做出这样的假设。
翻译工作的一大部分是识别并将十六进制数字转换为它们的整数值。为了完成这个任务,我展示了 tohex() 函数,如下所示。它查找 ASCII 字符 0 到 9 和 A 到 F,并将它们转换为它们的整数等效值。超出范围的任何内容都会生成返回值-1。 (该函数不转换小写十六进制数字,在这个例子中解码并不需要。)
列表 5.4 tohex() 函数。
int tohex(c)
{
if( c>='0' && c<='9' ) ❶
return(c-'0'); ❷
if( c>='A' && c<='F' ) ❸
return(c-'A'+0xA); ❹
return(-1); ❺
}
❶ 移除数字 0 到 9
❷ 返回数字的整数值
❸ 移除字母 A 到 F
❹ 返回字符的十六进制值:‘A’==0x0A
❺ 所有其他字符返回-1。
tohex() 函数只解决了战斗的一部分。其余的工作是读取标准输入,将每两个十六进制数字组装成一个字节。为了完成这个任务,我编写了一个无限 while 循环,如下所示。它获取两个字符,将它们组合在一起,然后输出结果值,该值可以是二进制或纯文本。
列表 5.5 解码十六进制过滤器的无限行
while(1) ❶
{
ch = getchar(); ❷
if( ch==EOF ) break; ❸
a = tohex(ch); ❹
if( a<0 ) break; ❺
a<<=4; ❻
ch = getchar(); ❼
if( ch==EOF ) break;
b = tohex(ch);
if( b<0) break;
putchar(a+b); ❽
}
❶ 无限循环依赖于 EOF 的存在来终止。
❷ 读取一个字符并立即 . . .
❸ . . . 检查 EOF,如果找到则退出循环
❹ 将字符转换为十六进制值
❺ 如果字符不是十六进制则退出
❻ 将值左移四位以表示字节值的高位
❼ 对下一个字符重复此过程,但不进行移位
❽ 输出结果字节
整个代码块都可以在这个书的在线仓库中找到,作为 hexdefilter01.c。如果你知道一些要输入的十六进制值,可以直接运行:
$ ./hexd
48656C6C6F2C20776F726C64210A
Hello, world!
The program stops when it encounters a nonhex digit or when the EOF is encountered, which helps match it up perfectly with the output from the hexenfilter01.c program. In fact—the true test of encoding and decoding—you can pump output through both filters and end up with the original data:
$ echo "This proves whether it works!" | ./hexe | ./hexd
This proves whether it works!
Text is echoed to standard input but first piped through the hexe (hexenfilter01.c) program, assumed to be in the current directory. This encoded output is then piped through the hexd (hexdefilter01.c) program. The output is the original text.
These simple filters process information, whipping it into one long string of hexadecimal characters. This type of hex encoding may work for transferring a silly game on a 300 BPS modem in the last century, but good luck getting a user to type in all those bytes without crossing their eyes. No, additional formatting is necessary for a better hex encoder/decoder.
5.2.2 编码更好的十六进制编码器/解码器
I prefer a hex encoding method that shows its details in a neat, orderly manner. Especially back in the old days, if you were typing in row upon row of hex bytes published in a computer magazine to input a program, you didn’t need to see the Great Wall of Hex.
A good approach to hex-encoding data, especially if the information is to be presented both for a human and a decoding program, is to format the output in neat rows and columns. For example:
HEX ENCODE v1.0 ❶
54 68 69 73 20 69 73 20 61 6E 20 65 78 61 6D 70 6C 65 ❷
20 6F 66 20 68 65 78 20 65 6E 63 6F 64 69 6E 67 20 69 ❷
6E 20 61 20 66 6F 72 6D 61 74 74 65 64 20 6D 61 6E 6E ❷
65 72 2E 20 49 20 61 70 70 6C 61 75 64 20 79 6F 75 20 ❷
66 6F 72 20 62 65 69 6E 67 20 61 20 6E 65 72 64 20 61 ❷
6E 64 20 64 65 63 6F 64 69 6E 67 20 74 68 69 73 20 65 ❷
78 61 6D 70 6C 65 2E 0A
HEX ENCODE END ❸
❶ Title text with version number
❷ 清晰且一致地输出十六进制值行
❸ 终止行
This output is from a filter, though it’s formatted to be more presentable and predictable. It still has its flaws, which I’ll get into eventually, but it’s a better encoder despite the data output being a series of hexadecimal digits, just like that of the filter presented in the preceding section.
列表 5.6 展示了 Hex Encode 1.0 程序的源代码。它基于典型的 I/O 过滤器,尽管它根据代码中定义的常量 BYTES_PER_LINE 格式化输出。变量 bytes 跟踪输出的数字,确保十六进制数字对逐行保持一致。当输出的数字数量等于定义的常量 BYTES_PER_LINE 时,此值将被重置,并输出一行新的十六进制数字。输出的最后一行标记了编码的结束。
列表 5.6 hexencode01.c 的源代码
#include <stdio.h>
#define BYTES_PER_LINE 18 ❶
int main()
{
int ch,bytes;
bytes = 0; ❷
printf("HEX ENCODE v1.0\n"); ❸
while( (ch=getchar()) != EOF )
{
printf(" %02X",ch);
bytes++;
if( bytes == BYTES_PER_LINE) ❹
{
putchar('\n'); ❺
bytes = 0; ❻
}
}
printf("\nHEX ENCODE END\n"); ❼
return(0);
}
❶ Set this value as a defined constant so that it can be updated easily.
❷ 初始化字节计数器
❸ Outputs the header line before processing standard input
❹ Checks for the end of the line
❺ If so, outputs a newline . . .
❻ . . . 并重置字节计数器
❽ 在处理完标准输入(包括 EOF)后,输出尾行
The hex-encoding code works like any filter, waiting for the EOF or, when using standard input, a press of the Ctrl+D key to terminate. Here is sample output:
$ echo "Hello, World!" | ./hexencode
HEX ENCODE v1.0
48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 21 0A
HEX ENCODE END
编写编码程序是容易的部分。更难的是解码,你必须正确解释格式,将十六进制数字转换回字节。就像任何复杂的编码装置一样,我一步一步地完成这样的任务。
编写十六进制解码器的第一步是编写一个一次处理一行输入的过滤器。这个原型程序在下一列表中显示。在解码方面,它是不完整的。它从标准输入中提取一行文本并存储在 line[] 缓冲区中。缓冲区填满后输出,这很无聊。然而,程序的将来版本将使用 line[] 缓冲区来处理编码值。
列表 5.7 hexdecode01.c 的源代码
#include <stdio.h>
#define BYTES_PER_LINE 18
#define LENGTH (BYTES_PER_LINE*3+1) ❶
int main()
{
char line[LENGTH];
int x,ch;
x = 0;
while( (ch=getchar()) != EOF )
{
line[x] = ch; ❷
x++; ❸
if( ch=='\n' || x==LENGTH) ❹
{
if( line[x-1]=='\n') ❺
line[x-1] = '\0';
else
line[x] = '\0';
printf("%s\n",line); ❻
x = 0;
}
}
return(0);
}
❶ 计算缓冲区大小,为字节数乘以使用的空格数,再加一个空字符
❷ 将传入的字符存储在缓冲区中
❸ 增加偏移量
❹ 检查换行符(因为解码文件是格式化的)或缓冲区已满
❺ 将换行符替换为空字符;否则,截断字符串
❻ 输出未修改的行
如其所述,hexdecode01.c 的源代码逐行处理任何输入。这些行在 LENGTH 个字符处被截断,其计算方式是前面显示的 hexencode 程序输出的行的确切长度。对传入的数据没有进行其他处理,因此程序的输出看起来与输入的完全一样。在这里,您可以看到由 hexencode 程序创建的文件 sample.txt,它被 hexdecode 程序原样输出:
$ ./hexdecode < sample.txt
HEX ENCODE v1.0
54 68 69 73 20 69 73 20 61 6E 20 65 78 61 6D 70 6C 65
20 6F 66 20 68 65 78 20 65 6E 63 6F 64 69 6E 67 20 69
6E 20 61 20 66 6F 72 6D 61 74 74 65 64 20 6D 61 6E 6E
65 72 2E 20 49 20 61 70 70 6C 61 75 64 20 79 6F 75 20
66 6F 72 20 62 65 69 6E 67 20 61 20 6E 65 72 64 20 61
6E 64 20 64 65 63 6F 64 69 6E 67 20 74 68 69 73 20 65
78 61 6D 70 6C 65 2E 0A
HEX ENCODE END
程序运行正常,所以代码完成了过程中的第一步。为了改进代码,接下来的更改确认输入数据格式正确。毕竟,这是一个特定编码数据格式的解码程序。这种改进利用了 hexencode 程序的第一行和最后一行输出(如示例输出所示):初始行 HEX ENCODE 1.0 必须被检测到,否则文件格式不正确,不需要进一步处理。同样,最后的行 HEX ENCODE END 被测试以确定行处理何时结束。
必须向 hexdecode01.c 添加几个小的代码块来做出这些改进。首先,新代码使用了 exit() 和 strncmp() 函数,这需要包含两个头文件:
#include <stdlib.h>
#include <string.h>
需要一个新的变量声明,指针 r。这个指针持有 fgets() 函数的返回值,该函数用于确定输入是否有效:
char *r
变量声明之后是一段代码,用于读取初始文本行。fgets() 函数从标准输入(stdin)读取行,然后使用 if 语句进行测试。如果 fgets() 的返回值为 NULL 或字符串不与所需的十六进制编码头匹配,则输出错误消息并终止程序:
r = fgets(line,LENGTH,stdin); ❶
if( r==NULL || strncmp(line,"HEX ENCODE",10)!=0 ) ❷
{
f>printf(stderr,"Invalid HEX ENCODE data\n"); ❸
exit(1);
}
❶ 吞吐输入的第一行。
❷ 在无效输入时,fgets() 返回 NULL;否则,strncmp() 函数对文本的第一行与所需文本进行精确比较。
❸ 将错误信息发送到标准错误设备,以避免输出混淆。
我省略了文本第一行的版本测试,我将它留到稍后改进代码时使用,这部分内容将在下一节中介绍。
最后一段文本添加在 while 循环中,就在输出 line[] 值的 printf() 语句之前。这些语句检查格式化十六进制编码中的终止行。如果找到,循环将中断而不会输出最后一行:
if( strncmp(line,"HEX ENCODE END",13)==0 )
break;
所有这些修改都包含在源代码文件 hexdecode02.c 中,该文件可在本书的在线仓库中找到。
编译并运行后,输出与早期程序类似,但可以立即识别出格式不正确的十六进制编码文件。因此,如果你在自己的源代码文件上运行程序,你会看到以下输出:
$ ./hexdecode < hexdecode02.c
Invalid HEX ENCODE data
否则,输出看起来与第一个版本相同。读取并输出十六进制字节行,无需进一步处理:
$ ./hexdecode < sample.txt
54 68 69 73 20 69 73 20 61 6E 20 65 78 61 6D 70 6C 65
20 6F 66 20 68 65 78 20 65 6E 63 6F 64 69 6E 67 20 69
6E 20 61 20 66 6F 72 6D 61 74 74 65 64 20 6D 61 6E 6E
65 72 2E 20 49 20 61 70 70 6C 61 75 64 20 79 6F 75 20
66 6F 72 20 62 65 69 6E 67 20 61 20 6E 65 72 64 20 61
6E 64 20 64 65 63 6F 64 69 6E 67 20 74 68 69 73 20 65
78 61 6D 70 6C 65 2E 0A
最后的改进是处理十六进制数字,将它们转换为值。这个更改只需要一个新变量和一个额外的语句块。新变量是整数 hex,可以添加到现有的 int 变量声明中:
int x,ch,hex;
为了将十六进制字符对转换为字节,将 while 循环的 printf() 语句替换为嵌套的 while 循环。这个内部循环处理 line[] 缓冲区,解析出十六进制数字对。我使用 strtok() 函数来处理解析或“标记化”,如果 tok 就代表这个意思。使用这个函数可以节省很多开销,减少了几个语句。
在嵌套的 while 循环中,一个 sscanf() 函数将解析出的十六进制数字,现在被视为一个 2 个字符的字符串,转换为整数值。生成的值被发送到标准输出。这个过程会一直重复,直到整行被处理,这正是 strtok() 函数的美丽之处:
r = strtok(line," "); ❶
while(r) ❷
{
sscanf(r,"%02X",&hex); ❸
printf("%c",hex); ❹
r = strtok(NULL," "); ❺
}
❶ 解析字符串(文本输入行),通过空格分隔其内容
❷ 只要 strtok() 函数返回非 NULL 值,循环
❸ 将 2 个字符的十六进制字符串转换为整数值
❹ 输出整数值(可能不是 ASCII)
❺ 继续扫描相同的字符串
代码的最后一次修改可在仓库中找到,文件名为 hexdecode03.c。它完成了项目。我将其命名为 hexdecode 的程序可以正确解码由 hexencode 程序编码的数据。
为了测试程序,我首先对程序文件进行了编码,然后进行了解码。第一步是编码程序文件,将输出保存以供以后使用:
$ ./hexencode < hexdecode > hexdecode.hex
此命令处理hexdecode程序文件中的二进制数据。输出被重定向到一个名为 hexdecode.hex 的新文件。此文件是纯文本,但格式如本节所示:带有标题、十六进制数字行和尾部。
要解码文件并将其转换回二进制数据,请使用以下命令:
cat hexdecode.hex | ./hexdecode > hexdecode.bin
cat命令输出了之前创建的编码文件,hexdecode.hex。这个输出(它是纯文本)通过hexdecode程序。结果是现在为二进制数据,因此标准输出看起来很丑陋,被重定向到一个新文件,hexdecode.bin。
为了确保原始的 hexdecode 程序文件和编码/解码数据文件 hexdecode.bin 是相同的,我使用了diff命令:
diff hexdecode hexdecode.bin
因为diff程序不生成输出,所以可以确认原始二进制文件被编码成十六进制字符对的文本文件,并且成功解码回其原始二进制格式。hexencode/hexdecode过滤器工作正常。然后,如果它们不起作用,我就不会写所有这些内容。没有剧透。
5.2.3 添加一点错误检查
我对我的 hexencode/hexdecode 系列过滤器最初的努力感到非常满意。然而,当我开始查看编码信息并试图弄清楚它可能被破坏的方式时,我的心情发生了变化。毕竟,在成功创建任何程序之后,作为一个 C 语言程序员,你必须立即想出如何破坏它。
假设你是一个拥有电脑(当然,没有社交生活)的青少年,你渴望输入从Compute!杂志上新鲜出炉的Laser Blaster游戏。你一行行地输入,一个十六进制数字接一个十六进制数字。你出错了吗?如果是,错误发生在哪个点?
为了帮助跟踪输入错误,早期的杂志中的十六进制转储在每一行的末尾提供了一个校验和数字。这个校验和仅仅是该行中所有字节的值的总和,有时取模 0x100 以使其看起来像另一个两位十六进制值。当用户输入代码时,他们可以运行校验和(或者他们的十六进制解码程序会)来确定是否出错,以及哪一行需要重新读取,以及是否整个操作需要从头开始。是的,这就是 Jolt Cola 以 12 瓶装出现的原因。
checksum01.c 的源代码在下一列表中展示。它演示了如何执行一种简单的校验和。从数组 hexbytes[]中连续的每个值都累积在int变量 checksum 中。这个结果以模 0x100 输出,以保持其字节大小的一致性。
列表 5.8 checksum01.c 的源代码
#include <stdio.h>
int main()
{
int hexbytes[] = {
0x41, 0x42, 0x43, 0x44, 0x45, ❶
0x46, 0x47, 0x48, 0x49, 0x4A ❶
};
int x,checksum;
checksum = 0; ❷
for( x=0; x<10; x++ )
{
checksum += hexbytes[x]; ❸
printf(" %02X",hexbytes[x]);
}
printf("\nChecksum = %02X\n",checksum%0x100); ❹
return(0);
}
❶ 只是一些随机的十六进制值;总共 10 个
❷ 在这里初始化校验和变量
❸ 累积总和
❹ 输出校验和,但限制为字符大小值
编写像 checksum01.c 这样的程序是我解决更大编程项目时经常采取的方法。每当我向任何程序添加一个新功能时,我都想确保它能正常工作。如果我把这个功能添加到现有代码中,这个过程可能会引入其他问题,从而复杂化错误追踪。
下面是 checksum01.c 程序的示例输出:
41 42 43 44 45 46 47 48 49 4A
Checksum = B7
存在着更复杂的方法来计算校验和,包括一些聪明的变体,甚至可以告诉你哪个具体值是错误的。但不要在意!
在 hexencode/hexdecode 程序中添加校验和需要修改两个源代码文件。是的,现在是 2.0 版本,现在具有(适度)的错误检查功能。因此,不仅两个程序必须计算和输出校验和字节,版本号也必须更新并验证。如果你想更进一步,可以让 hexdecode 程序仍然解码 1.0 版本的文件而不应用校验和。还有更多的工作要做!
练习 5.2
将源代码更新到 hexencode01.c,以添加一个校验和十六进制值,并将其输出到每行的末尾。别忘了最后一行的校验和(提示,提示)。哦,别忘了更新版本号到 2.0。我的解决方案可以在本书的在线仓库中找到,名为 hexencode02.c。
你对练习 5.2 的解决方案的代码可能看起来并不完全像我的一样,但输出应该类似于以下内容:
HEX ENCODE v2.0
54 68 69 73 20 69 73 20 61 6E 20 65 78 61 6D 70 6C 65 8F
20 6F 66 20 68 65 78 20 65 6E 63 6F 64 69 6E 67 20 69 4A
6E 20 61 20 66 6F 72 6D 61 74 74 65 64 20 6D 61 6E 6E 9F
65 72 2E 20 49 20 61 70 70 6C 61 75 64 20 79 6F 75 20 12
66 6F 72 20 62 65 69 6E 67 20 61 20 6E 65 72 64 20 61 37
6E 64 20 64 65 63 6F 64 69 6E 67 20 74 68 69 73 20 65 8C
78 61 6D 70 6C 65 2E 0A BF
HEX ENCODE END
这个输出类似于程序的第一版(1.0)输出,但每行的末尾都出现了一个额外的十六进制值。这个值是校验和。
解码这些数据,将其转换回二进制,显然需要更新 hexdecode 程序:首先,它必须检查版本号。如果编码数据显示“v2.0”,解码器必须检查字节值并确认该行是否已正确解码。如果没有,解码将停止,并将信息标记为无效。是的,我让你自己进行这个更改作为下一个练习。
练习 5.3
将 hexdecode03.c 的源代码转换为处理由 hexencode01.c(练习 5.2)创建的程序设置的额外校验和字节。你必须正确地考虑并使用校验和字节,以确保正确读取编码文本文件的每一行。我的解决方案命名为 hexdecode04.c,可在在线仓库中找到。请在作弊之前自己尝试这个练习,看看我是如何做到的。我的代码中的注释解释了正在发生的事情——以及一个甚至让我都感到惊讶的幸运转折。
很遗憾,我的解决方案并不完美,正如你可以在我的代码注释中读到的那样。进一步的修改可能有助于将代码引向正确的方向。这是一个我可能在未来的博客中探讨的话题,尤其是在我吃过很多蛋糕之后。
5.3 URL 编码
另一种文本编码类型,你可能以前见过并感到害怕,就是 URL 编码。也称为百分号编码,这种编码格式通过使用可打印字符和一些百分号来保留网页地址和在线表单内容。这种编码避免了某些字符出现在 URL 中可能会冒犯我们的网络霸主。
具体来说,对于网页地址,当引用可能被 Web 服务器错误解释的内容时,会使用 URL 编码,例如二进制值、嵌入的网页、空格或其他隐蔽数据。URL 编码允许这些信息以纯文本形式发送,并在稍后正确解码。
与任何其他编码一样,你可以用 C 编写 URL 编码转换程序。你需要知道的是所有的 URL 编码规则。
5.3.1 了解所有 URL 编码规则
为了帮助你将你所看到的内容与 URL 编码的外观联系起来,这里有一个例子:
https%3A%2F%2Fc-for-dummies.com%2Fblog%2F
所有编码都是纯文本;URL 编码是可读的。尽管每个字符都可以进行编码,但只有特殊字符以两位十六进制值的形式呈现,前面加上百分号——例如,%24 表示美元符号字符,ASCII 码 0x24。
尽管存在关于这种编码方法的规则,但 HTML 5 标准如下定义:
-
字母数字字符不进行翻译(0 到 9,A 到 Z,大小写)。
-
字符 -(破折号)、.(点)、_(下划线)和*(星号)被保留。
-
空格被转换为+(加号)字符,尽管也使用了%20 代码。
-
所有其他字符都表示为它们的十六进制 ASCII 值,前面加上百分号。
-
如果要编码的数据宽度超过一个字节,例如 Unicode 字符,它将被分成字节大小的值,每个值都是一个两位十六进制数,前面加上百分号。这一点可能对所有宽字符值并不一致。
这些规则存在细微的变体,但你应该能理解其大意。这些信息足以让你体验编写自己的 URL 编码和解码程序的乐趣。
5.3.2 编写 URL 编码器
编写 URL 编码程序的关键,在这个版本中是一个过滤器,是首先捕获异常。对于不需要翻译的字符,直接按照原样输出。一旦这些项目被消除,程序输出的所有其他字符都必须遵循百分号十六进制编码方法。
urlencoder01.c 的源代码如下所示。它是一个标准的过滤器,逐个字符处理输入。首先处理四个 URL 编码异常(- . _ ),然后是空格。isalnum()函数排除所有字母数字字符。剩余的内容使用%-十六进制格式输出,如代码中的printf()*语句所示。
列表 5.9 urlencoder01.c 的源代码
#include <stdio.h>
#include <ctype.h>
int main()
{
int ch;
while( (ch=getchar()) != EOF )
{
if( ch=='-' || ch=='.' || ch=='_' || ch=='*' ) ❶
putchar(ch);
else if( ch==' ') ❷
putchar('+');
else if( isalnum(ch) ) ❸
putchar(ch);
else
printf("%%%02X",ch); ❹
}
return(0);
}
❶ 这些字符是可以的;直接输出。
❷ 空格输出为+字符。
❸ 以原样输出字母数字字符。
❹ 需要使用%%来输出百分号,后跟一个两位十六进制值,如果需要,前面有一个前导零。
下面是程序的示例运行,我将其命名为urlencoder:
$ ./urlencoder
https:/ /c-for-dummies.com/blog/
https%3A%2F%2Fc-for-dummies.com%2Fblog%2F%0A*^D*$
在这里,过滤器在提示符下运行,因此所有键盘输入都显示在输出中。这种方法解释了为什么你会看到末行结尾的换行符%0A,然后是 Ctrl+D 键(^D)来终止输入。命令提示符$紧接着出现。
如果你习惯了看到 URL 编码,并且理解了 URL 的基本组成部分,你可能认识一些常见的代码:
-
%3A 代表冒号,:
-
%2F 代表正斜杠,/
我经常看到的其他代码有:
-
%3F 代表问号,?
-
%26 代表和号,&
当然,除了是一个书呆子之外,你不需要记住这些常见的 URL 编码。相反,你可以编写自己的 URL 解码器,这也是一个书呆子的标志,但有可能带来收入。
5.3.3 创建 URL 解码器
我希望你会发现创建 URL 解码器并不太难。与编码器不同,过滤器唯一关心的输入字符是百分号。哦,当然,你可以测试“非法”字符,如超出范围的字符;我将额外的编码留给你自己。
去除十六进制数字的关键是扫描%字符。一旦遇到,可以使用类似于 tohex()的函数,如 5.2.1 节中所述,来翻译接下来的两个十六进制数字。再次强调,可以进行更多测试以确定字符是否为合法的十六进制数字——但你应该明白了。
在下一列表中展示的是我对 URL 解码器的快速且简单的解决方案。它使用了一个修改过的tohex()函数,这个函数也检查小写十六进制数字。否则,此代码扫描的唯一“坏”输入字符是 EOF。
列表 5.10 urldecoder01.c 的源代码
#include <stdio.h>
int tohex(int c)
{
if( c>='0' && c<='9' )
return(c-'0');
if( c>='A' && c<='F' )
return(c-'A'+0xA);
if( c>='a' && c<='f' ) ❶
return(c-'a'+0xA);
return(-1);
}
int main()
{
int ch,a,b;
while( (ch=getchar()) != EOF )
{
if( ch=='%' ) ❷
{
ch = getchar();
if( ch==EOF ) break; ❸
a = tohex(ch); ❹
ch = getchar(); ❺
if( ch==EOF ) break;
b = tohex(ch);
putchar( (a<<4)+b ); ❻
}
else
putchar(ch);
}
return(0);
}
❶ 修改以添加小写
❷ 检查%符号并获取下一个两个字符
❸ 在 EOF 时退出
❹ 将十六进制数字转换为整数
❺ 获取下一个字符
❻ 输出正确的字符值
从 url_decoder01.c 源代码创建的程序将 URL 编码转换为,处理遇到的%值。然而,它的问题在于不知道如何处理格式不正确的 URL 编码文本。需要进行一些错误检查……但我已经达到了本章分配的页数——而且快到午夜了,我也没有 Ritalin 了。
练习 5.4
你的任务是改进 5.10 列表中显示的 URL 解码器。为此,确保不要过滤掉不需要的字符。当发生此类违规行为时,使用适当的错误消息退出程序。此外,检查 tohex()函数的返回值,以确保它正确读取十六进制值。
您可以在本书的在线源代码仓库中找到我的解决方案。文件名为 urldecoder02.c。请亲自尝试这个练习。不要作弊。您知道该怎么做。
6 个密码生成器
你厌倦了这些提示吗?你知道当某些网站要求你为你的账户应用密码时吗?“确保它至少有一个大写字母、一个数字、一个符号和一些象形文字。”或者,“这里有一个你无法输入、更不用说记住的推荐密码。”这真是令人沮丧。
我希望你能认识到将密码应用于数字账户的重要性。我相信你已经熟悉了常见的规则:不要使用容易被猜到的密码。不要使用任何与你容易关联的单词或术语。不要为每个账户设置相同的密码。这些告诫虽然繁琐但很重要。
设置一个稳固的密码是当今的必需品。作为一名 C 语言程序员,你可以通过以下方式增强你疲惫的密码库:
-
理解密码策略
-
创建基本、混乱的密码
-
确保密码具有所需的字符
-
在 Mad Libs 的世界中绕道而行
-
使用随机词汇构建密码
当然,从本质上讲,密码不过是一串字符。身份验证是对输入密码与存储在加密数据库中的密码逐字符、区分大小写的比较。是的,这个过程比这更复杂;我假设在某个时候,这个过程涉及到一只在跑步机上跑步的松鼠。尽管如此,一旦解密,就是那古老的比较打开了数字之门。设置好密码的目的是创造一个无人知晓或能猜到的密钥。
6.1 密码策略
Unix 系统始终对账户有密码要求。我的意思是,看看 Unix 精英们!你们信任他们吗?更好的问题是:他们彼此信任吗?可能不是,因为 Unix 登录一直要求输入用户名和密码。
尽管几十年来都知道计算机安全,但微软直到 1996 年 Windows 95 从实验室城堡中逃出时才要求 Windows 有密码。即使那时,我收到用户最常见的问题之一就是如何避免输入 Windows 密码。与 Unix 和其他多用户系统不同,PC 用户不习惯于安全。他们无知的一个证明是 1990 年代的病毒泛滥,但我跑题了。Windows 用户只是想访问电脑。其中许多人故意避免使用密码。
进入互联网。
随着我们生活中的更多部分被数字领域所吸收,创建和使用密码——严肃的密码——变得必不可少。是的,起初,这些只是为了满足最低要求而设置的愚蠢密码。但随着坏人的日益复杂化,密码需要更多的复杂性。
6.1.1 避免基本和无用的密码
懒惰的 Windows 95 用户可能仍然在我们中间。无能的密码每天都在使用。愚蠢的人类。你将在表 6.1 中找到一个最常见的 10 个密码列表。这些甚至不是愚蠢或脆弱的密码——只是最常见的。稍作思考。
表 6.1 愚蠢的密码
| 排名 | 密码 | 评论 |
|---|---|---|
| 1. | 123456 | 对于“必须六位长”的密码来说是最基本的。 |
| 2. | 123456789 | 一个“超过六位长”的密码。 |
| 3. | qwerty | 键盘,顶部行,左边。 |
| 4. | password | 一个永恒的经典。没有人会猜到! |
| 5. | 12345 | 有些人就是太懒了。 |
| 6. | qwert123 | 必须包含字母和数字。 |
| 7. | 1q2w3e | 键盘,数字和字符,顶部左方。 |
| 8. | 12345678 | 更不聪明的数字。 |
| 9. | 111111 | 重复且不聪明的数字。 |
| 10. | 1234567890 | 很可能是在使用数字键盘输入这个数字。 |
使用这些密码无用的原因是每个坏蛋都知道并首先尝试它们。而且你知道吗?有时候它们是有效的!每天都有很多案例记录下来,一些高官的在线安全被破坏,因为那个笨蛋太懒,使用了方便的密码。似乎这样的人应该被黑客攻击。
未列在表 6.1 中,但仍然非常愚蠢的是,以下个人信息片段被愚蠢地用作密码:
-
你的出生年份
-
当前年份
-
你的名字
-
你最喜欢的运动队的名字
-
一个诅咒词
-
单词 sex
-
你的城市或街道名称或街道号码
列表还在继续。这些项目需要避免——这就是为什么社交媒体上的那些测验会问你这样的愚蠢问题。相信我——承认你在高中的最好朋友是谁,并不比掷骰子告诉你你是哪个星球大战角色更有用。坏蛋们很聪明。人类很愚蠢。人们提供的常见答案后来被用来破解他们的密码。
在设计更好的密码时,了解这些技巧很重要。毕竟,有人尝试猜测你的密码比尝试字母、数字和符号的组合来猜测密码要容易得多。要聪明。
练习 6.1
编写一个程序,暴力猜测密码password。让代码遍历所有字母组合aaaaaaaa到zzzzzzzz,直到匹配密码。当然,最终它会匹配,但这个练习的目的是看看这个过程需要多长时间。我编写的解决方案在我的最快电脑上破解密码大约需要 8 分钟(没有生成输出)。
我的解决方案命名为 brutepass01.c,并且可以在本书的在线仓库中找到。它使用递归遍历字母表,就像里程表上的里程一样旋转。代码中的注释解释了我的疯狂。
6.1.2 增加密码复杂性
为了帮助你聪明地处理密码,你善良而体贴的系统管理员制定了一些规则。这些规则最初很简单:
请设置一个密码。
然后增加了复杂性:
你的密码必须包含字母和数字。
随着坏蛋们越来越擅长猜测密码或应用暴力破解方法,更多的细节被添加:
你的密码必须至少包含一个大写字母。
你的密码必须至少有八个(或更多)字符长。
您的密码必须包含一个符号。
这些建议增加了复杂性,使得猜测或暴力破解密码变得困难。即便如此,一些网站甚至提供了更多令人烦恼的具体规则。例如,图 6.1 显示了在我银行创建新密码的规则。这些规则几乎是最复杂的。

图 6.1 银行密码限制几乎是最令人讨厌的。
为了增加更多的安全性,许多服务采用双因素认证。这项技术涉及将确认码发送到您的手机或由应用程序或专用设备生成的代码值。这一额外的安全级别确保即使您的密码被泄露,第二因素的安全密钥也能保护您的信息安全。
6.1.3 应用单词策略
研究表明,您典型的混乱密码在阻止坏人方面并不比由几个单词随意组合并使用数字或符号分隔的密码更有效。例如,这个密码:
fbjKehL@g4jm7Vy$Glup
与此相比,没有提供额外的安全性:
Bob3monkeys*spittoon
第二个密码的优点是更容易记住和输入。然而,在测试中,密码破解软件破解第二个、更易读的密码所需的时间与破解无用的混乱密码所需的时间相同,甚至更长。
这种更好的密码创建方法是我所说的单词策略:将三个或更多单词连在一起,混合使用大小写字母,添加数字和符号。实际上,图 6.1 中显示的密码要求允许本节中显示的两种密码类型,但单词策略更好。
单词策略还有哈希的优势。例如,您可以将特定的密码与您常访问的网站和服务关联起来。如果密码被泄露,您会立即识别出源头。这种情况就发生在我身上,当我收到一封邮件说“我知道你的密码。”坏人列出了密码——这是我曾经使用过的。我认出这是我的旧 Yahoo!密码,在我更换密码后,黑客盗取了 Yahoo!用户数据库。我知道密码已被泄露,并且根据密码中使用的单词,我知道了源头。我对这个发现并不感到惊讶或担忧。
6.2 复杂密码的混乱
您可能认为编写输出典型混乱文本密码的代码相对容易。确实如此。您可能在您的数字青年时期编写过这样的程序:一个愚蠢的随机字符生成器。但就像所有容易的事情一样,这并不是编写密码程序的好方法。不要让这个练习的愚蠢性使您气馁。
6.2.1 构建一个愚蠢的随机密码程序
列表 6.1 展示了我的随机密码生成器,一个愚蠢的版本,标题为 randomp01.c,因为我的电脑上已经有一个 silly.c 的文件名。它从感叹号到波浪号切断了可打印字符的 ASCII 谱,编码为 33 到 126。 (有关 ASCII 的有趣细节,请参阅第五章。)这个值设置了随机数的范围。输出的字符值加上感叹号字符,使其回到可打印范围内。
列表 6.1 randomp01.c 的源代码
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main()
{
int x;
char ch;
const int length = 10; ❶
srand( (unsigned)time(NULL) );
for( x=0; x<length; x++ )
{
ch = rand() % ('~' - '!' + 1); ❷
putchar( ch+'!' ); ❸
}
putchar('\n');
return(0);
}
❶ 将密码长度设置为 10 个字符
❷ 设置随机值的范围到可打印字符
❸ 输出可打印的字符
程序的输出是令人愉快的随机,但作为密码实际上毫无用处:
aVd["o_rG2
首先,祝你好运记住它。其次,打字时好运。第三,希望网站允许所有字符的输出;双引号可疑。显然,必须应用一些条件到输出上。
6.2.2 向密码程序添加条件
网上大多数随机密码生成程序产生的是字母、数字和符号的混乱组合,就像一个荒谬的节日沙拉,但——就像真正的沙拉一样——据说对你有好处。显然,正在进行某种智能编程,这与前一部分中展示的愚蠢的随机字符生成形成对比。
生成的密码字符仍然可以是随机的,但它们必须是以下类型:大写字母、小写字母、数字、符号。每种类型的数量取决于密码长度,并且字符类型的比例会有所不同。
为了改进这个愚蠢的密码程序并使其更智能,考虑将密码的内容限制如下:
-
一个大写字母
-
六个小写字母
-
一个数字
-
两个符号
总字符数为 10,这对于密码来说是个不错的选择。
随机字母和数字很容易生成,但为了避免违反任何字符限制,我会提供以下符号是安全的,尽管你可以自由地创建自己的列表:
! @ # $ % * _ -
现在的任务是限制密码到给定的限制。
练习 6.2
编写代码生成一个限制在本节列出的字符(总共 10 个)的随机密码。将代码命名为 randomp02.c。在解决方案中包含这四个函数:uppercase(), lowercase(), number(), 和 symbol(). uppercase() 函数返回 A 到 Z 范围内的随机字符。lowercase() 函数返回小写字母,A 到 Z。number() 函数返回 0 到 9 的字符。symbol() 函数从一个安全符号数组中随机抽取一个字符并返回它。密码在 main() 函数中输出,使用以下模式:一个大写字母,六个小写字母,一个数字,两个符号。
作为一个小贴士,我使用定义的常量来创建模式:
#define UPPER 1
#define LOWER 6
#define NUM 1
#define SYM 2
这些定义的常量在代码更新时可以节省时间。
6.2.3 改进密码
我对练习 6.2 的解决方案生成的输出如下:
Tmxlqeg8#@
Gdnqgrs2@%
Whizxxb9-*
这些生成随机、混乱密码的勇敢尝试是成功的,但缺乏灵感。此外,它们可能很容易被破解,因为它们的模式是可预测的:它们都以一个大写字母开头,接着是六个小写字母,一个数字,最后是两个符号。知道这个模式后,编写密码破解程序会更容易。
输出随机字符的更好方法是将其打乱。为此改进,密码必须存储在数组中,而不是直接输出(这是我解决 6.2 练习时所做的)。因此,将 randomp02.c 转换为 randomp03.c 的第一步是存储生成的密码——仍然使用之前相同的函数和模式。
列表 6.2 展示了来自我更新的代码 randomp03.c 中的 main() 函数。password[] 缓冲区被创建,等于存储的字符数——所有之前在代码中定义的常量——加上一个用于终止的空字符。我将 randomp02.c 版本中的 for 循环替换为 while 循环,用必要的字符填充数组。字符串被终止并输出。
列表 6.2 对 randomp03.c 的 main() 函数的改进
int main()
{
char password[ UPPER+LOWER+NUM+SYM+1 ]; ❶
int x;
srand( (unsigned)time(NULL) ); ❷
x = 0; ❸
while( x<UPPER ) ❹
password[x++] = uppercase();
while( x<UPPER+LOWER ) ❺
password[x++] = lowercase();
while( x<UPPER+LOWER+NUM ) ❻
password[x++] = number();
while( x<UPPER+LOWER+NUM+SYM ) ❼
password[x++] = symbol();
password[x] = '\0'; ❽
printf("%s\n",password); ❾
return(0);
}
❶ 密码所需的存储空间,加上一个空字符
❷ 初始化随机数生成器
❸ 初始化索引变量 x
❹ 获取大写字母并将它们放入 password[] 数组
❺ 获取小写字母
❻ 获取数字
❼ 获取符号
❽ 使用空字符终止字符串
❾ 输出密码
程序的输出没有改变,但这个增量步骤存储了密码。将密码存储在缓冲区中后,它可以传递给一个新的函数,scramble(),该函数随机化缓冲区中的字符。
我的 scramble() 函数在列表 6.3 中展示。它使用一个临时缓冲区 key[] 来确定哪些字符需要随机化。此数组以空字符初始化。一个 while 循环旋转,生成 0 到 9 范围内的随机值——与传递数组 p[] 和局部数组 key[] 中的元素数量相同。如果一个随机元素包含空字符,则从传递数组中存储一个字符在该位置。while 循环重复,直到传递数组中的所有字符都被复制。一个最终的 for 循环更新传递的数组。
列表 6.3 用于随机化数组字符的 scramble() 函数
void scramble(char p[])
{
const int size = UPPER+LOWER+NUM+SYM+1; ❶
char key[size];
int x,r;
for( x=0; x<size; x++ ) ❷
key[x] = '\0';
x = 0; ❸
while(x<size-1) ❹
{
r = rand() % (size-1); ❺
if( !key[r] ) ❻
{
key[r] = p[x]; ❼
x++; ❽
}
}
for( x=0; x<size; x++ ) ❾
p[x] = key[x];
}
❶ 计算缓冲区大小
❷ 使用空字符初始化数组
❸ 传递数组的索引
❹ 循环直到传递的数组被完全处理(减去一个空字符)
❺ 生成一个随机值,从 0 到缓冲区大小(减去一个空字符)
❻ 如果元素 r 中的随机值是空字符...
❼ ... 它将原始字符复制到其新的随机位置。
❽ 更新索引
❾ 将随机化后的数组复制到传递的数组
调用 scramble() 函数时,需要从 randomp03.c 文件中更新代码。首先,在 main() 函数之前某个位置添加 scramble() 函数。这个位置消除了在源代码文件中更早地原型化函数的需要。然后,在 main() 函数中的 printf() 语句之前插入以下行:
scramble(password);
完整的源代码作为 randomp04.c 在书籍的在线存储库中可用。以下是示例输出:
z%Wea#zhuX
哈哈!这仍然是一个难以记忆或输入的糟糕密码,但它幸运地是随机的。
可以进一步修改代码以调整密码长度和不同类型字符的具体数量。我最初想过提供命令行开关来设置选项数量和整体密码长度。例如:
pass_random -u1 -l6 -n1 -s2
这些参数设置了一个大写字母,六个小写字母,一个数字和两个符号。这些选项在创建密码时提供了更多的灵活性。你可以进一步扩展这个想法,并指定要包含在随机密码中的符号。哦!我可以疯狂地编写这个程序,但就我个人而言,我更喜欢在密码中使用单词,所以我将进入下一部分。
6.3 密码中的单词
我多年前就放弃了混乱的密码。我更喜欢的方法是将几个随机单词连在一起,加上必要的首字母和符号,以及所需的长度。这种方法更容易记忆和输入。事实上,我仍然记得我旧的计算器服务密码,它只是由两个单词和一个数字分隔。
6.3.1 以 Mad Libs 风格生成随机单词
要构建一个随机单词密码生成器,你需要一个输出随机单词的程序。如果它们要成为合法的单词,你很可能需要某种类型的列表来从中提取单词。编写一个单词生成函数是一个好方法,同时这也给你一个机会创建一个你喜欢的单词列表,愚蠢的单词,或者你在沃尔玛经常说的单词。
列表 6.4 突出了在源代码文件 randwords01.c 中出现的 add_word() 函数。该函数包含数组 vocabulary[] 中的十二个单词(实际上是字符串指针)。变量 r 在 0 到数组元素数量之间的随机值范围内:sizeof(vocabulary) 返回数组占用的字节数。这个值除以 sizeof(char *),即数组中每个元素的大小——一个 char 指针。结果是 12,这意味着 r 包含一个从 0 到 11 的随机数。这个表达式确保无论数组中有多少单词,计算出的随机数都在正确的范围内。该函数返回随机数组元素,一个指向字符串的指针。
列表 6.4 randwords01.c 中的 add_word() 函数
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define repeat(a) for(int x=0;x<a;x++) ❶
const char *add_word(void)
{
const char *vocabulary[] = {
"orange", "grape", "apple", "banana",
"coffee", "tea", "juice", "beverage",
"happy", "grumpy", "bashful", "sleepy"
};
int r;
r = rand() % (sizeof(vocabulary)/sizeof(char *)); ❷
return( vocabulary[r] ); ❸
}
int main()
{
srand( (unsigned)time(NULL) );
repeat(3) ❹
printf("%s ", add_word() );
putchar('\n');
return(0);
}
❶ 这个宏使得 main() 函数中的 for 循环更易于阅读。
❷ 生成一个随机值,从零到数组中元素的数量(减一)
❸ 返回随机元素——单词
❹ 输出三个随机单词
代码三次调用 add_word() 函数,尽管没有提供防止相同单词重复的保证,如下所示:
banana grape grape
作为一名 C 语言程序员,你可以添加代码以防止输出中出现重复的单词,但我认为重复的单词也是随机的。尽管如此,这段代码只是更长过程的一步。
在编写 randwords01.c 代码后,我受到了模仿著名 Mad Libs 字词游戏的启发。Mad Libs 是企鹅兰登书屋 LLC 的注册商标,在此仅用于教育目的。请不要起诉我。
编写 Mad Libs 程序的第一步,同时避免诉讼,是制作几个类似于 randwords01.c 代码中使用的 add_word() 函数。你必须为 Mad Libs 中发现的每个单词类别编写一个函数,例如:add_noun(), add_verb(), 和 add_adjective()。每个函数都包含自己的词汇数组,填充了相应的单词类型:名词、动词和形容词。main() 函数根据需要调用每个函数,以填充类似 Mad Libs 的句子中的空白,并精心制作以避免法律风险,如这里故意制作得弱智且不幽默的示例。
列表 6.5 来自 madlib01.c 的 main() 函数
int main()
{
srand( (unsigned)time(NULL) ); ❶
printf("Will you please take the %s %s ", ❷
add_adjective(), ❸
add_noun() ❹
);
printf("and %s the %s?\n", ❺
add_verb(), ❻
add_noun() ❼
);
return(0);
}
❶ 为随机数生成器设置种子
❷ 输出句子的第一部分
❸ 填充形容词空白
❹ 填充一个名词空白
❺ 输出句子的最后一部分
❻ 填充动词空白
❼ 填充另一个名词空白
是的,我的 Mad Libs 原型很尴尬。如果你真的想享受一次好的 Mad Libs,可以获取 Leonard Stern 和 Roger Price 的书籍,因为他们不会让他们的律师来找我。尽管如此,代码是有效的,从每个函数中随机获取一个单词。输出并不好笑,就像任何 Mad Libs 游戏,取决于好的单词选择:
Will you please take the ripe dog and slice the necklace?
为了给各种函数添加更丰富的词汇多样性,可以将代码进一步扩展,并从词汇文本文件中读取单词。例如,noun.txt 文件包含数十个或数百个名词,每个名词单独一行。这种格式使列表易于访问、查看和编辑。可以创建类似文件用于其他单词类型:verb.txt、adjective.txt 等。
为了读取文件并从中随机抽取单词,你可以借鉴第二章中介绍的技术:“pithy saying” 系列程序以读取文本文件的代码结束,存储了文件的所有行,然后随机选择一行文本进行输出。这种方法可以用于更新 Mad Libs 程序,其中扫描三个单独的文件以获取随机单词。第二章的代码被整合到我的更新版 Mad Libs 程序中的 build_vocabulary() 函数中。
在下一个列表中,您可以看到我的更新版 Mad Libs 程序 madlib02.c 中的main()函数。为了处理众多问题,并将大量工作有效地转移到build_vocabulary()函数,我选择使用结构来保存有关各种类型单词的信息。遗憾的是,输出与 madlib01.c 程序生成的可悲文本相同。
列表 6.6 madlib02.c 中的main()函数
int main()
{
struct term noun = {"noun.txt",NULL,0,NULL}; ❶
struct term verb = {"verb.txt",NULL,0, NULL};
struct term adjective = {"adjective.txt",NULL,0, NULL};
build_vocabulary(&noun); ❷
build_vocabulary(&verb);
build_vocabulary(&adjective);
srand( (unsigned)time(NULL) );
printf("Will you please take the %s %s ",
add_word(adjective), ❸
add_word(noun)
);
printf("and %s the %s?\n",
add_word(verb),
add_word(noun)
);
return(0);
}
❶ 声明和定义结构,节省了大量代码。
❷ build_vocabulary()函数从文件中读取单词并创建一个内存中的列表,每个单词都有一个索引。这些信息保存在针对每种单词类型的特定术语结构中。
❸ add_word()函数的英文读起来很好,使代码易于理解。
main()函数首先定义三个术语结构来保存和引用要填充 Mad Libs 的单词类型。结构中的每个成员都被定义,两个指针项设置为 NULL 常量。
这里是无聊的输出:
Will you please take the pretty car and yell the motorcycle?
您可以查看本书在线仓库中找到的整个源代码文件。它命名为 madlib02.c。如果您在编辑器中可以看到此代码,那么在接下来的几页中讨论细节时会很有帮助。
madlib02.c 代码中的工作马是build_vocabulary()函数。它依赖于术语结构,该结构在外部定义,以便在源代码文件中的所有函数中可见:
struct term {
char filename[16]; ❶
FILE *fp; ❷
int items; ❸
char **list_base; ❹
};
❶ 表示要打开的文件名的字符串
❷ 指向在 filename 成员中列出的打开文件的 FILE 指针
❸ 从文件中提取的单词总数
❹ 包含指向从文件中提取的每个单词的指针的内存块
通过将这些项放入一个结构中,每次调用build_vocabulary()函数只需要一个参数。build_vocabulary()函数基于 pithy05.c 的源代码(在第二章中介绍)。对代码进行了大量重整,以使用传递的结构成员而不是局部变量,但大部分代码保持不变。以下是原型:
void build_vocabulary(struct term *t);
结构作为指针传递,struct term *t,这允许函数修改结构成员并保留更新后的数据。否则,当结构直接传递(而不是作为指针)时,任何更改都会在函数结束时丢失。因为传递了一个指针,所以在函数内部使用结构指针表示法(->)。
build_vocabulary()函数执行以下任务:
-
打开 t->filename 成员,在成功的情况下将 FILE 指针保存在变量 t->fp 中。
-
为 t->list_base 成员分配存储空间,该成员最终引用从文件中读取的所有字符串。
一个while循环从文件中读取每个字符串(单词)。它执行以下任务:
-
获取字符串并双检查以确认没有遇到文件结束符 EOF。
-
为字符串分配内存。
-
将字符串复制到分配的内存中。
-
从字符串中删除换行符(\n)。这一步在原始的 pithy05.c 代码中没有找到。这是确保返回的单词不包含换行符所必需的。
-
确认 t->list_base 缓冲区没有满。如果是这样,则将缓冲区重新分配到更大的大小。
最后一步发生在while循环完成后:
-
关闭打开的文件。
函数结束时,结构体的 items 成员包含从文件中读取的所有单词的计数。list_base 成员包含存储在内存中每个字符串的地址。
列表 6.6 中的main()函数也引用了add_word()函数。这个函数不需要作为参数传递指针,因为它不会修改结构体的内容。以下是 add_word()函数:
char *add_word(struct term t) ❶
{
int word;
word = rand() % t.items; ❷
return( *(t.list_base+word) ); ❸
}
❶ 函数返回一个char指针,即一个字符串。
❷ 生成一个介于零和项目数量之间的随机值
❸ 引用存储在 t.list_base 中的随机单词,并返回其地址
大部分 add_word()函数存在于原始的 pithy05.c 代码中。它被设置为函数,因为它是用不同的结构体调用的,每个结构体代表一个语法单词类别。
这些程序可以在任何需要程序运行时从文件中检索并引用存储的单词的应用中使用。通过在内存中保留单词,可以在不重新读取文件的情况下多次访问列表。
6.3.2 构建随机单词密码生成器
您可以根据前述部分中显示的两个 Mad Libs 程序的版本制作两种不同类型的随机单词密码程序。第一个程序(madlib01.c)使用数组存储一系列随机单词。然而,为了增加多样性,您还可以使用第二个(madlib02.c)代码来利用存储您最喜欢的密码单词的文件。
随机单词密码生成器的简单版本与早期的randomp系列程序类似,特别是来自 randomp04.c 的源代码。目标是创建返回特定密码片段的函数:一个随机单词、一个随机数字和一个随机符号。单词应该已经是混合大小写。我的版本命名为 passwords01.c,可以在本书的在线存储库中找到。在编辑器窗口中打开它,以便在文本中跟随。
number()和symbol()函数是从早期代码中保留下来的,尽管现在每个函数都返回一个字符串而不是单个字符。一个static char数组用于存储要返回的两个字符字符串:随机字符存储在数组的第一个元素中,而空字符存储在第二个元素中,使数组成为一个字符串。以下是这两个函数:
char *number(void)
{
static char n[2]; ❶
n[0] = rand() % 10 + '0'; ❷
n[1] = '\0'; ❸
return(n);
}
char *symbol(void)
{
char sym[8] = "!@#$%*_-";
static char s[2]; ❶
s[0] = sym[rand() % 8]; ❹
s[1] = '\0'; ❸
return( s );
}
❶ 当函数结束时,保留static数组的所有内容。
❷ 生成一个随机字符,0 到 9 之间,将其存储为数组 n[]的第一个元素
❸ 在字符串末尾添加一个空字符
❹ 从 sym[]数组中抽取一个随机字符,并将其设置为数组 n[]的第一个元素
为了生成密码的单词,我从 madlib01.c 中借用了 add_noun() 函数,并将其修改为反映一系列带有几个大写字母的随机单词:
char *add_word(void)
{
char *vocabulary[] = {
"Orange", "Grape", "Apple", "Banana",
"coffee", "tea", "juice", "beverage",
"happY", "grumpY", "bashfuL", "sleepY"
};
int r;
r = rand() % 12;
return( vocabulary[r] );
}
我不需要从 randomp04.c 代码中提取 scramble(), uppercase(), 和 lowercase() 函数。这里显示的 main() 函数将所有内容组装成最终的密码字符串。
列表 6.7 passwords01.c 的 main() 函数
int main()
{
char password[32]; ❶
srand( (unsigned)time(NULL) );
password[0] = '\0'; ❷
strcpy(password,add_word()); ❸
strcat(password,number()); ❹
strcat(password,add_word()); ❺
strcat(password,symbol()); ❻
strcat(password,add_word()); ❼
printf("%s\n",password);
return(0);
}
❶ 存储密码的地方
❷ 初始化字符串,以便 strcpy() 函数不会崩溃
❸ 将生成的第一个单词复制到 password[] 数组中
❹ 添加一个数字
❺ 添加第二个单词
❻ 添加一个符号
❼ 添加最后一个单词
这里是一个示例运行:
juice9grumpY%Grape
代码中没有做任何操作来防止单词重复,输出可能缺少一个大写字母。但如果你不喜欢这些单词,可以在 add_word() 函数中扩展 vocabulary[] 数组来添加更多单词。或者,更好的是,设计一个系统,其中包含包含你想要在密码中使用的单词的文件,就像第二个 Mad Libs 程序那样工作。实际上,你可以使用相同的单词文件,noun.txt、verb.txt 和 adjective.txt。
我为 passwords02.c 的源代码从 passwords01.c 和 madlib02.c 中提取了元素——特别是从文件中读取单词并将其存储在内存中的 build_vocabulary() 函数。
您可以通过查看以下列表中 passwords02.c 的 main() 函数来了解两个源代码文件是如何合并的。是的,我在这段代码上偷懒了,其中 main() 函数的前半部分是从 Mad Libs 程序中拉取的,后半部分是从 passwords02.c 中来的。输出是一个包含三个随机单词、一个随机数字和一个随机符号的字符串。
列表 6.8 passwords02.c 的 main() 函数
int main()
{
char password[32]; ❶
struct term noun = {"noun.txt",NULL,0,NULL}; ❷
struct term verb = {"verb.txt",NULL,0,NULL};
struct term adjective = {"adjective.txt",NULL,0,NULL};
build_vocabulary(&noun);
build_vocabulary(&verb);
build_vocabulary(&adjective);
srand( (unsigned)time(NULL) );
password[0] = '\0'; ❸
strcpy(password,add_word(noun));
strcat(password,number());
strcat(password,add_word(verb));
strcat(password,symbol());
strcat(password,add_word(adjective));
printf("%s\n",password);
return(0);
}
❶ 从第一个 passwords 代码中窃取
❷ 从 madlib02.c 中窃取
❸ 建立字符串时总是大写!
这里是一个示例运行,它与 passwords01.c 代码的程序输出没有区别:
eyeball9yell!ripe
顺便说一句,这个密码输出比我的任何 Mad Libs 程序的输出都要有趣得多。然而,它仍然存在密码问题:大写字母在哪里?
练习 6.3
将 passwords02.c 中的另一个函数添加到源代码中,以创建一个新的源代码文件 passwords03.c。这个新函数,check_caps(),检查一个字符串中是否有大写字母。如果没有找到大写字母,该函数将字符串中的某个随机位置的小写字母转换为大写字母。我的解决方案作为 passwords03.c 在线提供,但在你偷偷溜去看我是如何做到的之前,请先自己尝试这个练习。
7 字符串工具
人们常说,而且有充分的理由,C 编程语言缺少字符串数据类型。这样的功能会很不错。这将更容易保证程序中的每个字符串都是真实的,并且所有字符串函数都能干净利落且无缺陷地工作。但这样的说法是不真实的。在 C 语言中,字符串是一个字符数组,弱类型,任何 C 程序员都容易出错。
是的,C 语言中存在实用的字符串函数。一个巧妙的程序员可以轻松地拼凑出任何字符串函数,模仿在其他一些更高层次的编程语言中可用的功能,但在 C 语言中却缺乏。然而,任何处理 C 语言中缺失字符串函数的创造性方法都必须处理语言对字符串概念的狭隘认识。因此,需要一些额外的训练,包括:
-
审视 C 语言中字符串的糟糕之处
-
理解字符串长度是如何测量的
-
创建有趣且有用的字符串函数
-
构建自己的字符串函数库
-
探索虚构的面向对象编程
尽管您可以在 C 语言中使用字符串,但抱怨和轻蔑仍然存在——这是合理的。C 字符串是柔软的东西。在创建或操作字符串时,即使是经验丰富的程序员也容易出错。然而,字符串作为有效的数据形式存在,并且对于通信是必要的。因此,准备好增强您的字符串知识并建立您的编程武器库。
7.1 C 语言中的字符串
您所称之为字符串的东西在 C 语言中并不存在,就像 int 或 double 这样的数据类型一样。没有程序员会担心整数变形或不正确地编码实数的二进制格式。这些数据类型——int、double,甚至 char——是原子。字符串是一个分子。它必须专门构建。
从技术上讲,字符串是一种特殊的字符数组类型。它有一个起始字符,位于内存中的某个地址。内存中的每个后续字符都是字符串的一部分,直到遇到空字符 \0 为止。这种临时结构在 C 语言中被用作字符串——尽管它仍然很柔软。如果您需要进一步了解柔软的概念,表 7.1 提供了比较性综述。
表 7.1 柔软的东西
| 事物 | 为什么它会变得柔软 |
|---|---|
| 街道交叉口限制线 | 因为很少有汽车停在限制线上。大多数只是滚过去。 |
| 爷爷说“不” | 给它点时间。做个可爱的表情有帮助。 |
| 建筑许可证 | 根据您与市长的关系友好程度,等待时间不同。 |
| 食物 | 在飞机上并不意味着同一件事。 |
| 性格 | 对自己来说很好;对相亲来说却不好。 |
| 肥胖 | 自 1940 年以来,精算表尚未更新。 |
| 社交媒体上的名声 | 等待几个小时。 |
| 海绵蛋糕 | 设计使然。 |
7.1.1 理解字符串
将您认为的字符串与字符数组区分开来很重要。尽管所有字符串都是字符数组,但并非所有字符数组都是字符串。例如:
char a[3] = { 'c', 'a', 't' };
这个语句创建了一个 char 数组 a[]。它包含三个字符:c-a-t。这个数组不是字符串。然而,下面的 char 数组是一个字符串:
char b[4] = { 'c', 'a', 't', '\0'};
数组 b[] 包含四个字符:c-a-t 加上空字符。这个终止的空字符使数组成为字符串。它可以由任何 C 语言字符串函数处理或作为字符串输出。
为了节省你的时间,并且让键盘的单引号键不会磨损,C 编译器允许你通过将字符放在双引号中来构建字符串:
char c[4] = "cat";
数组 c[] 是一个字符串。它由四个字符组成,c-a-t,加上编译器自动添加的空字符。尽管这个字符没有出现在声明中,但你必须在为字符串分配存储空间时考虑到它——总是!如果你这样声明字符串:
char d[3] = "cat";
编译器为 c-a-t 分配了三个字符,但没有为空字符分配。这个声明可能会被编译器标记——或者可能不会。无论如何,字符串是不规则的,并且,如果没有终止的空字符,操作或输出字符串会产生不可预测的甚至可能荒谬的结果。
因为编译器会自动为字符串分配存储空间,所以以下声明格式最常使用:
char e[] = "cat";
使用空括号时,编译器会计算字符串的存储空间,并为数组分配适当数量的元素,包括空字符。
尤其是在构建自己的字符串时,你必须注意考虑到终止的空字符:必须为它分配存储空间,并且你的代码必须确保字符串中的最后一个字符是 \0。
这里有一些字符串注意事项:
-
在分配字符串存储空间时,始终为空字符添加一个。字符串可以直接作为 char 数组声明分配,或者通过如 malloc() 这样的内存分配函数。
-
在使用字符串存储时,请记住,存储空间中的最后一个字符必须是空字符,无论缓冲区是否已满。
-
fgets() 函数通常用于读取字符串输入,它在其第二个参数大小中自动考虑了空字符。因此,如果你在 fgets() 语句中将值 32 作为大小参数使用,该函数在自动添加空字符以终止输入字符串之前存储最多 31 个字符。
-
没有终止的空字符,字符串函数会继续处理字节,直到遇到下一个随机的空字符。结果可能是垃圾输出——更糟糕的是,可能会引发段错误。
-
忘记空字符的一个问题是,通常内存中已经充满了空字符。缓冲区可能会溢出,但内存中已经存在的随机空字符可以防止输出看起来糟糕——以及你的错误被检测到。永远不要依赖于内存中静止的空字符。
-
空字符是终止字符串所必需的,但不是必须检查的。编译器不会检查它——它怎么能呢?这种缺乏确认、字符串包含的缺失,使得 C 语言中的字符串变得灵活。
7.1.2 测量字符串
这个部分的标题对我奶奶来说有完全不同的定义。不,她不编程,但她编织。在编织中,字符串更长,但在编程中,你不会使用 skein 这个词。相反,你会在字符计数上纠结。
在内存中存储的字符串比其文本多一个字符,这个额外的字符是终止字符串的空字符。它是字符串的一部分,但不在字符串“内部”。
根据 strlen()函数,字符串的长度仅为其字符数减去一个非打印的空字符。
那么,字符串有多长?
strlen()的 man 页面描述了其目的:
The strlen() function calculates the length of the string . . .
excluding the terminating null byte ('\0').
strlen()计算字符串中的字符数,转义字符被计为一个字符。例如,换行符\n 是一个字符,尽管它占据了两个字符位置。制表符\t 也是一个字符,尽管终端在输出时可能将其转换为多个空格。
不论我挑剔什么,strlen()返回的值可以在代码的其他地方使用,以操作字符串中的所有字符,而不会违反终止空字符或重复计算转义字符。如果你想将空字符包含在字符串的大小中,可以使用 sizeof 运算符,但请注意,这个技巧只适用于静态分配的字符串(否则,返回指针大小)。
在以下列表中,我们比较了 strlen()和 sizeof 返回的值。在代码的第 6 行声明了一个字符串 s[],它包含 10 个字符。第 8 行的 printf()语句输出字符串的 strlen()值。第 9 行的 printf()语句输出字符串的 sizeof 值。
列表 7.1 string_size.c 的源代码
#include <stdio.h>
#include <string.h>
int main()
{
char s[] = "0123456789"; ❶
printf("%s is %lu characters long\n",s,strlen(s));
printf("%s occupies %zu bytes of storage\n",s,sizeof(s));
return(0);
}
❶ 10 个字符
这里是输出:
0123456789 is 10 characters long
0123456789 occupies 11 bytes of storage
strlen()函数返回字符串中的字符数;sizeof 返回字符串占用的存储量——本质上相当于 strlen()+1,但如果字符串小于其分配的缓冲区大小,sizeof 返回缓冲区大小而不是 strlen()+1。如果你将此更改应用到代码的第 6 行:
char s[20] = "0123456789"; ❶
❶ 现在 20 个字符的存储
这里是更新后的输出:
0123456789 is 10 characters long
0123456789 occupies 20 bytes of storage
尽管缓冲区的大小增加了,但空字符仍然位于 s[]数组中的第 10 个元素(第 11 个字符)。缓冲区的其余部分被认为是垃圾,但仍然被 sizeof 运算符报告为字符串的“大小”。
测量字符串也与关于空字符串和空字符串是什么的伟大哲学辩论有关。这种差异在其他编程语言中也很相关,在这些语言中,字符串可以明确地定义为 null 或空。在 C 中,由于其弱数据类型和松散的字符串,这种差异不太明显。考虑以下情况:
char a[5] = { '\0' };
char b[5];
在这两个数组 a[]和 b[]中,哪个是空字符串,哪个是空字符串?
你可能认为 C 不关心哪个字符串是哪个。显然,数组 a[]已初始化,而 b[]未初始化。其余的讨论是语义问题,但根据计算机科学,a[]是空字符串,b[]是空字符串。
在下一个列表中,我执行了一个测试,比较了两个字符串,空数组 a[]和空数组 b[],以查看编译器是否注意到空字符串或空字符串之间的差异。使用strcmp()函数,当两个字符串相同时会返回零。
列表 7.2 empty-null.c 的源代码
#include <stdio.h>
#include <string.h>
int main()
{
char a[5] = { '\0' }; ❶
char b[5]; ❷
if( strcmp(a,b)==0 ) ❸
puts("Strings are the same");
else
puts("Strings are not the same");
printf("Length: a=%lu b=%lu\n",strlen(a),<linearrow />strlen(b)); ❹
printf("Storage: a=%zu b=%zu\n",sizeof(a), ),<linearrow /sizeof(b)); ❺
return(0);
}
❶ 空字符串
❷ 空字符串(未初始化)
❸ 如果两个字符串相同
❹ 根据 strlen()的大小
❺ 根据 sizeof 的大小
程序的输出描述了字符串在内部是如何被看到的:
Strings are not the same
Length: a=0 b=4
Storage: a=5 b=5
当然,总有可能字符串 b[]在内存中的垃圾数据与字符串 a[]的内容匹配。因此,即使这个输出也不能真正被信任。我的意思是,为什么 strlen(b)返回值是 4?
就 C 语言中的字符串而言,我更喜欢将空字符串视为未初始化的。空字符串是一个更容易理解的概念。毕竟,在 C 中,有一个只包含终止空字符的字符串是完全合法的:这样的字符串长度为零。它可以被所有字符串函数操作。然而,除了这些好奇之处,你可以将“空字符串”和“空字符串”的争论留给其他更时尚编程语言的伟大维齐尔。
7.1.3 C 字符串函数回顾
许多 C 语言函数理解和处理字符串。假设字符串是一个char数组,并且正确终止。这种格式是诸如printf(), puts(), fgets(), 等函数处理字符串的方式。
字符串函数在 string.h 头文件中声明。标准 C 库字符串操作函数列在表 7.2 中。
表 7.2 常见 C 库字符串函数
| 函数 | 描述 |
|---|---|
| strcat**() | 将一个字符串附加到另一个字符串上,将两者连接起来 |
| strncat**() | 将两个字符串连接起来,但限于一定数量的字符 |
| strchr**() | 返回指向字符串中特定字符位置的指针 |
| strcmp**() | 比较两个字符串,匹配时返回零 |
| strncmp**() | 比较两个字符串,直到给定长度 |
| strcoll**() | 使用区域信息比较两个字符串 |
| strcpy**() | 将一个字符串中的字符复制到另一个字符串或缓冲区中 |
| strncpy**() | 从一个字符串复制指定数量的字符到另一个字符串 |
| strlen**() | 返回字符串中的字符数 |
| strpbrk() | 定位一个字符串中在另一个字符串中找到的第一个字符实例 |
| strrchr() | 返回指向字符串中字符的指针,从字符串的末尾开始测量 |
| strspn() | 返回指定字符在字符串中的位置,这些字符在另一个字符串中找到 |
| strcspn() | 返回指定字符在字符串中的位置,这些字符在另一个字符串中未找到 |
| strstr() | 返回一个字符串在另一个字符串中的位置 |
| strtok() | 根据分隔符字符解析字符串(反复调用) |
| strxfrm() | 根据区域信息将一个字符串转换为另一个字符串 |
许多字符串函数都有一个“n”伴随函数,例如 strcat() 和 strncat()。n 表示函数计算字符或通过设置字符串大小值来尝试避免溢出。
尽管表 7.2 只列出了常见的 C 库字符串函数,但你的编译器的库可能还有更多。例如,strcasecmp() 函数与 strcmp() 函数类似,但在比较时忽略字母的大小写。(见第十一章。)此外,strfry() 函数专门在 GNU C 库中可用。它随机交换字符串中的字符,类似于第六章中讨论的 scramble() 函数。
为了确保你总是感到警觉或困惑,一些编译器可能还会提供 strings.h 头文件。此头文件定义了一些额外的字符串函数,例如 strcasecmp(),与某些 C 库一起使用。我在本章中不涵盖这些函数。
7.1.4 返回与直接修改
在 C 中操作字符串的函数有两种方式可以使其更改。第一种是返回字符串的修改版本。第二种是直接操作传递的字符串。选择方法实际上取决于函数的目的。
例如,strcat() 函数将一个字符串追加到另一个字符串。以下是手册页格式:
char *strcat(char *dest, const char *src);
字符串 src(源)被追加到字符串 dest(目标)的末尾。该函数假定 dest 缓冲区有足够的空间来成功追加字符串。成功后,字符串 dest 包含 dest 和 src。该函数返回对 dest 的指针。strcat() 函数是直接操作传递的字符串的示例。
在下一个列表中,main() 函数中有两个字符串,s1[] 和 s2[]。strappend() 函数的任务是将这两个字符串粘合在一起,并返回对新、更长字符串的指针。秘密在于 strappend() 函数内部有 strcat() 函数,并且返回值(dest 的地址)。
列表 7.3 strcat.c 的源代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char *strappend(char *dest, char *src)
{
return( strcat(dest,src) ); ❶
}
int main()
{
char s1[32] = "This is another "; ❷
char s2[] = "fine mess!";
char *s3;
s3 = strappend(s1,s2);
printf("%s\n",s3);
return(0);
}
❶ strcat() 函数返回指针 dest,即新组合的字符串。
❷ 数组 s1[] 包含足够的空间来存储两个字符串。
程序的输出显示了连接后的字符串:
This is another fine mess!
在这个例子中,函数并没有真正创建一个新的字符串。它只是返回指向第一个传入的字符串的指针,现在它包含了两个字符串。
练习 7.1
修改 strcat.c 的源代码。从代码中移除 strcat() 函数,用你自己的代码替换它,将参数 src 的内容粘接到参数 dest 的末尾。不要使用 strcat() 函数来完成这个任务!相反,确定结果字符串的大小,并为它分配存储空间。strappend() 函数返回创建的字符串的地址。
你可以进一步修改代码,使得字符串 s1[] 只包含显示的文本;它不需要为新字符串分配存储空间。正确的分配是在 strappend() 函数中完成的。
我对这个练习的解决方案可以在在线存储库中找到,名为 strappend.c。代码中的注释解释了我的方法。记住,这段代码演示了字符串函数如何创建一个新的字符串,而不是修改作为参数传递的字符串。
7.2 字符串函数众多
C 语言有大量的字符串函数,但显然对于那些其他编程语言的“大人物”来说还不够。他们贬低 C 语言在字符串操作方面的能力。当然,任何你觉得 C 库中缺失的函数——那些在其他一些更时髦的编程语言中快乐地居住的函数——都可以轻松编写。你只需要记住包含那个至关重要的终止空字符,然后在 C 语言中就可以实现任何与字符串相关的事情。
在本节中,我介绍了一些字符串函数,其中一些在其他编程语言中存在,而另一些则是由于一些个人脑部缺陷而不得不创建的。无论如何,这些函数证明了在那些新出现的编程语言中你可以用字符串做的任何事情,在 C 语言中同样可以做到。
哦!有一点需要说明:在其他语言中找到的函数有时被称为 方法,因为它们与面向对象编程相关。嗯,la-di-da。我可以把我的车叫做蝙蝠车,但它仍然是一辆现代汽车。
7.2.1 改变大小写
在其他语言中,字符串中的文本大小写转换函数很常见。在 C 语言中,ctype 函数 toupper() 和 tolower() 分别将单个字符、字母转换为大写或小写。这些函数可以轻松地应用于整个字符串。你所需要做的就是编写一个处理这个任务的函数。
以下列表展示了 strupper.c 的源代码,该代码将字符串的小写字母转换为大写。字符串在函数内部被修改,其中 while 循环处理每个字符。如果字符在 'a' 到 'z' 的范围内,其第六位被重置(变为零),从而将其转换为小写。(这种位操作在第五章中有讨论。)strupper() 函数避免使用任何 ctype 函数。
列表 7.4 strupper.c 的源代码
#include <stdio.h>
void strupper(char *s)
{
while(*s) ❶
{
if( *s>='a' && *s<='z' ) ❷
{
*s &= 0xdf; ❸
}
s++;
}
}
int main()
{
char string[] = "Random STRING sample 123@#$";
printf("Original string: %s\n",string);
strupper(string);
printf("Uppercase string: %s\n",string);
return(0);
}
❶ 循环直到 *s 指向空字符(字符串的结尾)
❷ 仅更改小写字母
❸ 将第六位重置以转换为大写
这是程序的输出:
Original string: Random STRING sample 123@#$
Uppercase string: RANDOM STRING SAMPLE 123@#$
*strupper()*函数也可以通过执行基本数学运算将字符转换为大写。由于 ASCII 表的布局,以下语句也有效:
*s -= 32;
从每个字符的 ASCII 值中减去 32 也将它转换为小写。
修改*strupper()*函数以创建一个将字符转换为小写的函数很容易。以下是一个*strlower()*函数可能的样子:
void strlower(char *s)
{
while(*s)
{
if( *s>='A' && *s<='Z' ) ❶
{
*s += 32; ❷
}
s++;
}
}
❶ 仅转换大写字母
❷ 加 32 以进行转换
显示*strlower()*函数完整源代码的在线仓库位于本书的在线资源库中,名为strlower.c。
练习 7.2
编写一个名为*strcaps()*的函数,该函数将字符串中每个单词的首字母大写。处理文本“这是一个示例字符串”或类似的字符串,其中包含多个用小写写的单词,包括至少一个单字母单词。该函数直接修改字符串,而不是生成一个新的字符串。
我的解决方案可以在在线仓库中找到,名为strcaps.c。它包含解释我的方法的注释。
7.2.2 反转字符串
改变字符串中字符顺序的关键是知道字符串的长度——它的起始位置和结束位置。
对于字符串的起始位置,使用字符串的变量名,它持有基本地址。字符串的结束地址没有存储在任何地方;代码必须找到字符串的终止空字符,然后使用数学方法计算字符串的大小。在其他编程语言中不需要这种数学计算,因为字符串的每个方面都是完全已知的。此外,在 C 语言中,字符串可能是不规则的,这会使这个过程变得不可能。
图 7.1 说明了字符串在内存中的样子,数组表示法和指针表示法指出了它的一些部分。终止的空字符标志着字符串的结束,无论其位置如何,都从字符串的起始位置测量为偏移量 n。

图 7.1 内存中字符串的测量
定位字符串的末尾最简单的方法是使用*strlen()*函数。将函数的返回值加到字符串在内存中的起始位置,以找到字符串的末尾。
对于 DIY 爱好者,你可以自己编写字符串结束函数或循环来定位字符串的末尾。假设字符指针s引用字符串的起始位置,而*int变量len被初始化为零。如果是这样,这个*while*循环将定位字符串的末尾,即空字符所在的位置:
while(*s++)
len++;
在这个循环完成后,指针s引用字符串的终止空字符,而len的值等于字符串的长度(减去空字符)。以下是循环的更易读版本:
while( *s != '\0' )
{
len++;
s++;
}
找到字符串末尾最愚蠢的方法是使用 sizeof 操作符。操作符并不愚蠢,但当它用于 char 指针参数时,sizeof 返回指针(内存地址变量)占用的字节数,而不是指针引用的缓冲区的大小。例如,在我的计算机上,指针宽度为 4 字节,因此无论 s 指向的缓冲区大小如何,sizeof(s) 总是返回 4。
在获得字符串长度后,反转过程在字符串的背景中工作,将每个字符复制到另一个缓冲区中,从前往后。结果是包含原始字符串反向字符顺序的新字符串。
下一个列表中所示的 strrev() 函数创建了一个新的反转字符串。首先,一个 while 循环计算字符串的大小(参数 s)。其次,根据原始字符串的大小分配新字符串的存储空间。我不需要在 malloc() 语句中 +1 来为空字符腾出空间,因为变量 len 已经引用了空字符的偏移量。最后,一个 while 循环在填充字符串 reversed 时以反向顺序处理字符串 s。
列表 7.5 strrev() 函数
char *strrev(char *s)
{
int len,i;
char *reversed;
len = 0; ❶
while( *s ) ❷
{
len++;
s++;
}
reversed = malloc( sizeof(char) * len ); ❸
if( reversed==NULL )
{
fprintf(stderr,"Unable to allocate memory\n");
exit(1);
}
s--; ❹
i = 0; ❺
while(len) ❻
{
*(reversed+i) = *s; ❼
i++; ❽
len--; ❾
s--; ❿
}
*(reversed+i) = '\0'; ⓫
return(reversed); ⓬
}
❶ 变量 len 包含了空字符的偏移量以及字符串的长度。
❷ 循环直到 *s 指向传递的字符串终止的空字符
❸ 为反转字符串分配存储空间;与传递的字符串长度相同
❹ 将 s 回退到终止的空字符;现在它指向传递的字符串中的最后一个字符
❺ 在新字符串中索引,并反转
❻ 复制原始字符串中的字符数
❼ 复制字符
❽ 增加反转字符串的偏移量
❾ 减少原始字符串的偏移量
❿ 回退指针
⓫ 总是使用空字符为新构造的字符串加顶!
⓬ 在这里不要释放指针!其数据必须保留。
此函数包含在 strrev.c 的源代码中,可在本书的在线仓库中找到。在 main() 函数中,输出一个示例字符串,然后输出反转后的字符串以进行比较。以下是一个示例运行:
Before: A string dwelling in memory
After: yromem ni gnillewd gnirts A
此代码仅展示了创建字符串反转函数的一种方法,尽管对于所有变体,通用方法都是相同的:反向处理原始字符串以创建新字符串。
7.2.3 字符串修剪
字符串截断函数在其他编程语言中很受欢迎。例如,我记得我在 BASIC 编程时代使用的 LEFT\(、RIGHT\) 和 MID$ 命令。C 语言中没有类似的功能,但它们很容易创建。图 7.2 展示了每个命令的作用。

图 7.2 提取字符串的部分:左、中、右
每个函数至少需要两个参数:要切片的字符串和字符计数。中间提取函数还需要一个偏移量。对于我的方法,我决定返回一个包含所需块的新字符串,这样就不会破坏原始字符串。
下一个列表展示了我对 left() 函数的创造,它从传入的字符串 s 中提取 len 个字符。与每个修剪函数一样,为新字符串分配了存储空间。left() 函数是最容易编码的,因为它将传入字符串 s 的前 len 个字符复制到目标字符串 buf 中。buf 的地址被返回。
列表 7.6 left() 函数
char *left(char *s,int len)
{
char *buf; ❶
int x;
buf = malloc(sizeof(char)*len+1); ❷
if( buf==NULL )
{
fprintf(stderr,"Memory allocation error\n");
exit(1);
}
for(x=0;x<len;x++) ❸
{
if( *(s+x)=='\0' ) ❹
break;
*(buf+x) = *(s+x); ❺
}
*(buf+x) = '\0'; ❻
return(buf);
}
❶ 用于新字符串的存储空间
❷ 为新字符串分配存储空间,加上一个空字符
❸ 复制 len 个字符
❹ 检查是否存在意外的空字符,并在找到时终止循环
❺ 复制字符
❻ 为新创建的字符串添加结束符
图 7.3 展示了 left() 函数的内部结构。

图 7.3 left() 函数切割字符串的方式
与 left() 函数不同,切割字符串的右侧需要程序知道字符串的结束位置。从前面章节中,你可能会记得 C 语言不跟踪字符串的尾部。你的代码必须找到那个终止的空字符。对于 right() 函数,我从一个空字符开始向后计数,以截断字符串的右侧。
我的 right() 函数如下所示。它从列表 7.6 中显示的 left() 函数借用了其分配例程。在创建缓冲区之后,代码寻找字符串的末尾,将指针起始位置移动到该位置。然后从 start 减去 len 的值,将指针重新定位到所需的右端字符串块的开头。然后,将 len 个字符复制到新字符串中。
列表 7.7 right() 函数
char *right(char *s,int len)
{
char *buf;
char *start;
int x;
buf = (char *)malloc(sizeof(char)*len+1);
if( buf==NULL )
{
fprintf(stderr,"Memory allocation error\n");
exit(1);
}
start = s; ❶
while(*start!='\0') ❷
start++;
start -= len; ❸
if( start < s ) ❹
exit(1);
for(x=0;x<len;x++) ❺
*(buf+x) = *(start+x);
*(buf+x) = '\0'; ❻
return(buf);
}
❶ 使用指针 start 作为偏移量,保留变量 s 中的地址以供以后使用
❷ 搜索字符串的末尾
❸ 调整指针起始位置以引用字符串右端开始的位置
❹ 检查下溢并退出,如果为真
❺ 将字符串的最右侧部分复制到新缓冲区中
❻ 为新创建的字符串添加结束符
right() 函数的操作在图 7.4 中展示。

图 7.4 展示了提取字符串右侧的计算
最后一个字符串修剪函数实际上是唯一需要的:当给定适当的参数时,mid() 函数可以轻松地替代 left() 或 right() 函数。实际上,这两个函数可以基于 mid() 定义为宏。我将在接下来的几段中详细讨论这个话题。
我的 mid() 函数有三个参数:
char *mid(char *s, int offset, int len);
指针 s 引用要切割的字符串。整数偏移量是提取开始的字符位置。整数 len 是要提取的字符数。
完整的 mid() 函数在列表 7.8 中展示。它从传入的字符串 s 直接逐字符复制到新的字符串缓冲区 buf。然而,关键在于在传递字符时添加偏移量值:
*(buf+x) = *(s+offset-1+x)
偏移值必须减 1,以考虑到字符串中的字符从偏移 0 开始,而不是偏移 1。如果我要为该函数编写文档,我需要解释有效偏移量参数的范围是 1 到字符串长度。与 C 编码不同,你不想从零开始——尽管你可以。再次强调,在函数的文档中说明这一点。
列表 7.8 mid() 函数
char *mid(char *s, int offset, int len)
{
char *buf;
int x;
buf = (char *)malloc(sizeof(char)*len+1);
if( buf==NULL )
{
fprintf(stderr,"Memory allocation error\n");
exit(1);
}
for(x=0;x<len;x++) ❶
{
*(buf+x) = *(s+offset-1+x); ❷
if( *(buf+x)=='\0') ❸
break;
}
*(buf+x) = '\0'; ❹
return(buf);
}
❶ 复制 len 个字符
❷ 偏移量值减 1,因为第一个字符的偏移是 0,而不是偏移 1。
❸ 捕获任何溢出并停止
❹ 总是给你自己创建的字符串加上标题。
图 7.5 展示了 mid() 函数的工作方式。

图 7.5 mid() 函数的操作
如我之前所写,通过使用 mid() 函数的特定格式,可以轻松地复制 left() 和 right() 函数。如果你要为 left() 函数编写宏,可以使用此格式:
#define left(s,n) mid(s,1,n)
当偏移量为 1 时,此 mid() 函数返回字符串 s 的最左侧 len 个字符。(记住,在 mid() 函数中,偏移值会减 1。)
要创建与 mid() 等效的 right() 函数,需要在调用中获取字符串的长度:
#define right(s,n) mid(s,strlen(s)-n,n)
第二个参数是字符串的长度(由 strlen() 函数获得),减去所需的字符数。我在宏中调用 strlen() 函数让我感到烦恼,但我的观点更多的是要展示字符串切片的真正强大函数是 mid() 函数。
你可以在本书在线仓库中找到所有这些字符串修剪函数——left(), right(), 和 mid()——位于 trimming.c 源代码文件中。
7.2.4 分割字符串
我编写我的字符串分割函数是出于愤怒。另一位程序员,一位那些花哨新语言的信徒嘲笑说:“你甚至不能用少于 20 行代码在 C 中分割字符串。”
接受挑战。
虽然我可以轻松地用少于 20 行代码在 C 中编写字符串分割函数,但我必须承认的一个观点是,这样的函数至少需要四个参数:
int strsplit(char *org,int offset,char **s1,char **s2)
指针 org 引用要分割的字符串。整数偏移量是分割的位置。最后两个指针,s1 和 s2,包含分割的两部分。这些指针通过引用传递,允许函数访问和修改其内容。
下一个列表显示了 my strsplit() 函数,它有 15 行简洁的代码——没有任何混淆。我使用了通常使用的空白和缩进。获取原始字符串的大小并用于为 s1 和 s2 分配存储空间。然后,strncpy() 函数将原始字符串的各个部分复制到单独的字符串中。函数在成功时返回 1,在出错时返回 0。
列表 7.9 strsplit() 函数
int strsplit(char *org,int offset,char **s1,char **s2)
{
int len;
len = strlen(org); ❶
if(offset > len) ❷
return(0);
*s1 = malloc(sizeof(char) * offset+1); ❸
*s2 = malloc(sizeof(char) * len-offset+1); ❹
if( s1==NULL || s2==NULL ) ❺
return(0);
strncpy(*s1,org,offset); ❻
strncpy(*s2,org+offset,len-offset); ❻
return(1);
}
❶ 获取原始字符串的长度
❷ 如果偏移量参数超出范围,则返回零——错误。
❸ 为拆分字符串 1 分配存储空间,参数 s1 取反
❹ 为拆分字符串 2 分配存储空间,计算适当的大小
❺ 如果任一分配失败,则返回错误
❻ 将适当数量的字符复制到新字符串中
列表 7.9 中所示的 strsplit() 函数是我的第一个版本,我的目标是看看我能用多少行代码实现。它调用 C 库字符串函数执行一些基本操作,这意味着这个版本的 strsplit() 函数依赖于 string.h 头文件。我编写了另一个版本,避免了使用字符串库函数,尽管其代码显然更长。
列表 7.9 中所示的 strsplit() 函数可以在在线存储库中的 strsplit.c 源代码文件中找到。
7.2.5 在另一个字符串中插入一个字符串
当我最初考虑编写字符串插入函数时,我想我会使用两个 C 库字符串函数来完成这项任务:strcpy() 和 strcat(). 这些函数可以一步一步地构建字符串:strcpy() 函数将一个字符串复制到另一个,在另一个数组或内存块中复制一个字符串。strcat() 函数将一个字符串附加到另一个字符串的末尾,创建一个更大的字符串。插入的字符串被拼接起来:原始字符串,加上插入的文本,再加上原始字符串的其余部分。
函数将具有以下声明:
int strinsert(char *org, char *ins, int offset);
指针 org 是原始字符串,它必须足够大以容纳要插入的文本。指针 ins 是要插入的字符串;整数偏移量是字符串 ins 插入到字符串 org 中的位置(从 1 开始)。
我的 strinsert() 函数在成功时返回 1,在错误时返回 0。
这没有奏效。
使用 strcpy() 和 strcat() 的问题是我必须拆分原始字符串,保存剩余部分,然后构建最终字符串。这一步需要一个临时缓冲区来存储字符串 org 在偏移量位置上的剩余部分,如图 7.6 所示。然后,将字符串 ins 连接到字符串 org 的新末尾,然后将原始字符串 org 的末尾连接到结果。很混乱。

图 7.6 使用字符串库函数的过程使得插入字符串过于复杂。
此外,如图 7.6 所示,希望用户已经为原始字符串 org 分配了足够的存储空间以容纳要插入的文本。对我来说,这个希望太冒险了,所以我改变了我的方法。以下是函数更新的声明:
char *strinsert(char *org, char *ins, int offset);
函数的返回值是一个新创建的字符串,这避免了字符串 org 需要足够大以容纳插入字符串 ins 的必要性。返回字符串,即在函数内创建它,也避免了需要临时存储字符串 org 的剩余部分以供稍后连接。
在这种新的方法中,我逐个字符地构建新的字符串,在构建新字符串时在偏移量字符处插入字符串 ins。图 7.7 阐述了函数改进版本的运行方式。

图 7.7 将一个字符串插入另一个字符串的改进技术
而不是使用 strcat() 和 strcpy() 函数,我改进的 strinsert() 函数将字符顺序地从字符串 org 复制到一个新创建的缓冲区 new 中。一旦字符计数等于偏移量,就从字符串 ins 复制字符到新创建的缓冲区。之后,计数从字符串 org 继续进行。
你可以在本书在线仓库中找到完整的 strinsert() 函数,它从参数 org 和 ins 构建字符串 new,使用 C 库的 strlen() 函数;否则,字符串使用函数内的语句构建。
列表 7.10 strinsert() 函数
char *strinsert(char *org, char *ins, int offset)
{
char *new;
int size,index,append;
size = strlen(org)+strlen(ins); ❶
if( offset<0 ) ❷
return(NULL);
new = malloc(sizeof(char) * size+1); ❸
if( new==NULL )
{
fprintf(stderr,"Memory allocation error\n");
exit(1);
}
offset -= 1; ❹
index = 0; ❺
append = 0; ❻
while( *org ) ❼
{
if( index==offset ) ❽
{
while( *ins ) ❾
{
*(new+index) = *ins;
index++;
ins++;
}
append = 1; ❿
}
*(new+index) = *org; ⓫
index++;
org++;
}
if( !append ) ⓬
{
while( *ins )
{
*(new+index) = *ins;
index++;
ins++;
}
}
*(new+index) = '\0'; ⓭
return(new);
}
❶ 获取新字符串的大小
❷ 如果偏移量是一个愚蠢的值,则返回空字符串
❸ 为新字符串分配存储空间
❹ 减少偏移量值以考虑字符串从 0 开始而不是 1
❺ 跟踪通过新字符串的进度
❻ 状态变量用于跟踪 ins 字符串是否已被插入
❼ 遍历原始字符串
❽ 立即检查偏移量值以处理 offset = 0 的情况
❾ 插入 ins 字符串,将其添加到字符串 new 中
❿ 标记字符串已被插入
⓫ 从原始字符串继续构建新字符串
⓬ 确认已插入字符串;如果没有,则将字符串 ins 追加
⓭ 总是首字母大写你自己创建的字符串!
在函数中,我尝试处理偏移量参数大于字符串 org 长度的情况。我无法让它正常工作,所以我决定将超出范围的值作为一个特性:如果偏移量的值大于字符串 org,则无论偏移量值如何,都将字符串 ins 追加到字符串 org。
你可以在本书在线仓库的源代码文件 strinsert.c 中找到 strinsert() 函数。以下是程序的输出,其中字符串“fine”(加上一个空格)被插入到字符串“Well, this is another mess!”的偏移量 23 处:
Before: Well, this is another mess!
After: Well, this is another fine mess!
7.2.6 在字符串中计数单词
要解决在字符串中计数单词的难题,你必须编写能够识别单词开始位置的代码。如果你完成了练习 7.2,你已经编写了这样的代码,它将字符串中单词的第一个字符转换为大写。可以将 strcaps() 函数修改为计数单词而不是将字符转换为大写。
下一个列表展示了针对练习 7.2 的解决方案的更新(你后悔按顺序阅读这一章了吗?),其中 strwords() 函数按顺序逐个消费字符串的字符。变量 inword 决定当前字符是否在单词内部。每次新单词开始时,变量 count 就会增加。
列表 7.11 源代码文件 strwords.c 中的 strwords() 函数
#include <stdio.h>
#include <ctype.h>
int strwords(char *s)
{
enum { FALSE, TRUE }; ❶
int inword = FALSE; ❷
int count;
count = 0; ❸
while(*s) ❹
{
if( isalpha(*s) ) ❺
{
if( !inword ) ❻
{
count++; ❼
inword = TRUE; ❽
}
}
else
{
inword = FALSE; ❾
}
s++;
}
return(count);
}
int main()
{
char string[] = "This is a sample string";
printf("The string '%s' contains %d words\n",
string,
strwords(string)
);
return(0);
}
❶ 创建常量 FALSE=0 和 TRUE=1
❷ 假设代码没有在单词内部读取
❸ 初始化单词计数
❹ 遍历字符串 s
❺ 当前字母是否为字母?
❻ 确认正在处理的不是一个单词
❼ 在单词内部增加计数
❽ 重置 inword 变量
❾ 对于非字母字符,inword 是 FALSE
这里是 strwords.c 程序的一个示例运行:
The string 'This is a sample string' contains 5 words
如果你将字符串中的 word 改为 isn’t,这里是将修改后的输出:
The string 'This isn't a sample string' contains 6 words
嗯嗯。
练习 7.3
修改列表 7.11 中所示的 strwords() 函数,使其能够处理缩写。这个任务有一个简单的解决方案,它是在第一本 C 语言编程书籍《C 程序设计语言》中提出的,由 Brian Kernighan 和 Dennis Ritchie 编写(Pearson,1988 年)。不作弊,看看你是否能完成同样的任务。
我的解决方案名为 strwords2.c,并且可以在本书的在线仓库中找到。
7.2.7 将制表符转换为空格
在 Linux 中使用的终端足够智能,能够输出制表符到显示器的下一个虚拟制表位。这些制表位默认设置为 8 个字符。一些 shell,如 bash 和 zsh,具有 tabs 命令,可以将制表位设置为不同的字符间隔:例如,tabs 4 设置一个宽度为 4 个字符的终端制表位。
以下 printf() 语句输出包含两个制表符的字符串:
printf("Hello\tHi\tHowdy\n");
这里是输出:
Hello Hi Howdy
制表符没有扩展为固定数量的字符。相反,它被 shell 解释,并在显示器的 8 个字符间隔的下一个虚拟制表位处扩展。这种效果确保了使用制表符对齐文本或创建表格时,列能够完美对齐。
显然,你不需要将制表符转换为空格来在终端上输出。但你可以编写一个函数,该函数可以在程序输出中设置可变宽度的制表位。这些制表位的宽度是通过输出空格来创建的;本书不涉及终端硬件编程。
要设置制表位,你必须知道文本输出在屏幕上的位置——当前列值。这个值与所需的制表位宽度进行比较,使用以下公式:
spaces = tab - (column % tab)
这里是这个语句是如何工作的:

(column % tab) 表达式返回自上次制表位间隔(tab)以来基于光标当前列偏移量(column)的空格数。为了获得到下一个制表位的空格数,从这个值中减去制表位宽度。结果是输出下一个字符与制表位对齐所需的空格数。
制表位计算公式作为 strtabs() 函数中的一个语句存在,该函数在下一列表中显示。该函数输出一个字符串,仔细检查每个字符是否有制表符,\t。当遇到时,计算下一个制表位偏移量,并输出给定数量的空格。
列表 7.12 源代码文件 strtabs.c 中的 strtabs() 函数
#include <stdio.h>
void strtabs(const char *s, int tab)
{
int column,x,spaces;
column = 0; ❶
while(*s) ❷
{
if( *s == '\t') ❸
{
spaces = tab - (column % tab); ❹
for( x=0; x<spaces; x++ ) ❺
putchar(' ');
column += spaces; ❻
}
else ❼
{
putchar(*s);
if( *s=='\n' ) ❽
column = 0;
else
column++;
}
s++;
}
}
/* calculate and display a tab */
int main()
{
const char *text[3] = {
"Hello\tHi\tHowdy\n",
"\tLa\tLa\n",
"Constantinople\tConstantinople\n"
};
int x,y;
for(y=4;y<32;y*=2) ❾
{
printf("Tab width: %d\n",y);
for(x=0;x<3;x++)
{
strtabs(text[x],y);
}
}
return(0);
}
❶ 列变量跟踪当前列位置。
❷ 遍历字符串
❸ 捕获制表符
❹ 计算输出到下一个制表位所需的空格数
❺ 输出所需空格
❻ 更新列偏移量
❼ 在此处处理其他字符
❽ 如果输出换行符,则重置列值
❾ 嵌套循环以在三个不同的制表符位置输出三个示例字符串:4、8 和 16 个空格
程序的输出生成三个字符串,使用三个不同的制表符设置的不同制表符模式。以下是输出:
Tab width: 4
Hello Hi Howdy
La La
Constantinople Constantinople
Tab width: 8
Hello Hi Howdy
La La
Constantinople Constantinople
Tab width: 16
Hello Hi Howdy
La La
Constantinople Constantinople
当终端窗口遇到制表符时,它不会像 strtabs.c 程序那样将制表符转换为多个空格。对于终端窗口,光标本身在屏幕上移动所需的字符位置;不会输出空格。为了证明这一点,可以查看生成制表符的某些程序的输出,并查看原始数据。你会看到制表符字符(ASCII 9),而不是一系列空格。
7.3 字符串库
将所有字符串操作函数投入使用或处理任何特定的函数集合的最好方法之一是创建自己的自定义库。这是一种与他人共享函数或以实用方式为自己准备函数的方法。
你可能已经了解其他库并可能使用过它们,例如数学库。创建这些工具并不困难,了解如何创建它们也不是秘密:如果你知道如何编译代码,你就可以创建一个库。
所有的 C 库都是由某人创建的——一些聪明的程序员将函数和其他必需元素拼凑在一起,以便让你分享他们的天才。即使是 C 标准库也是由 C 程序员编写的,他们是编程领域的最高统治者。
使用你的字符串库就像使用其他库一样简单;你的字符串库在构建时被链接到目标代码文件中。函数由自定义头文件原型化和支持。对于你的库,一切工作方式与其他库相同。
对于字符串库,我将包括本章中展示的许多函数。如果你有额外的、喜欢的字符串函数,也请随意添加。本节中的说明解释了细节,并提供了创建你自己的自定义库的技巧。
7.3.1 编写库源文件和头文件
创建库从源代码编辑器开始。你的目标是至少创建两个文件:
-
源代码文件
-
包含函数原型的头文件
源代码是一个或多个文件(取决于你的工作方式),包含库中所有函数——仅仅是函数。你不需要main()函数,因为你正在构建一个库,而不是一个程序。这个文件就像任何其他源代码文件一样编译,但它不会被链接。你只需要目标代码文件(.o),就可以创建库。
库还需要一个头文件,该头文件包含函数原型的定义常量、必要的包含文件以及其他有助于函数的元素。例如,如果一个函数使用一个结构体,那么它必须在源代码文件和头文件中声明。使用你的库的程序员需要结构定义来使函数工作。头文件是放置这些元素的地方,也是程序员使用你的库时引用这些元素的地方。
在图 7.13 中,你可以看到 mystring.c 源代码文件的第一部分,其中包含了许多在本章中演示的字符串函数。该文件包含描述性注释,这些注释可以扩展以显示版本历史、提供提示和示例。源代码文件中的#include 指令对于函数是必需的,就像在任何源代码文件中一样。此外,看看我如何无力地尝试为每个函数编写文档,显示参数和返回值?是的,这些信息可以进一步扩展;文档是好的。
列表 7.13 mystring.c 库源代码文件的第一部分
/* mystring library */ ❶
/* 10 September 2021 */ ❶
/* Dan Gookin, dan@gookin.com */ ❶
#include <stdio.h> ❷
#include <stdlib.h> ❷
#include <string.h> ❷
#include <ctype.h> ❷
/* Return the left portion of a string ❸
s = string ❸
len = length to cut from the left ❸
return value: new string ❸
*/
char *left(char *s,int len) ❹
{
❶ 介绍库文件的注释
❷ 本文件中函数所需的头文件
❸ 介绍和描述每个函数的注释
❹ 函数本身(继续)
源代码文件中函数的顺序并不重要——除非一个函数引用了另一个函数。在这种情况下,确保被引用的函数出现在引用它的函数之前(上方)。
你的库的伴随头文件没有在库的源代码文件中列出(参见图 7.13)。头文件对于为使用你的库的程序员提供支持是必要的;只有当头文件中的项目(例如定义的常量)在代码中被引用时,你才需要在源代码文件中包含库的头文件。头文件的关键是函数原型、结构体、全局/外部变量定义、宏和定义的常量。
就像库的源代码文件一样,我建议注释头文件以记录其各个部分。这对你的程序员朋友们会有帮助。此外,我在头文件中添加了定义的版本号常量,如下所示。
列表 7.14 支持 mystring 库的 mystring.h 头文件
/* mystring library header file */ ❶
/* 10 September 2021 */ ❶
/* Dan Gookin, dan@gookin.com */ ❶
#define mystring_version "1.0" ❷
#define mystring_version_major 1 ❷
#define mystring_version_minor 0 ❷
char *left(char *s,int len); ❸
char *mid(char *s, int offset, int len); ❸
char *right(char *s,int len); ❸
void strcaps(char *s); ❸
char *strinsert(char *org, char *ins, int offset); ❸
void strlower(char *s); ❸
char *strrev(char *s); ❸
int strsplit(char *org,int offset,char **s1,char **s2); ❸
void strtabs(const char *s, int tab); ❸
void strupper(char *s); ❸
int strwords(char *s); ❸
❶ 在注释中介绍你的库头文件
❷ 版本号定义的常量
❸ 函数原型
源代码文件和头文件都是使用库所必需的。
7.3.2 创建库
库是从目标代码文件创建的。ar(归档)实用程序是将目标代码文件转换为库的工具。因此,创建库的第一步是编译——但不链接——你的库的源代码文件。一旦你有编译后的目标代码,你使用ar实用程序来创建库。
对于这个示例,我使用的是 mystring.c 源代码文件,该文件可以从本书的在线代码仓库中获取。要将源代码编译成对象代码,指定 -c 开关。此开关对所有 C 编译器都可用。以下是 clang 的命令格式:
clang -Wall -c mystring.c
-c 开关指示 clang “仅编译”。源代码文件被编译成对象代码 mystring.o,但不会链接以创建程序。这一步骤会为所有源代码文件重复进行,尽管你可以在一个命令中指定所有这些:
clang -Wall -c first.c second.c third.c
对于这个命令,创建了三个对象代码文件:first.o、second.o 和 third.o。
下一步是使用归档工具 ar 来构建库。此命令后跟三个参数:命令开关、库文件名,以及最后构建库所需的对象代码文件。例如:
ar -rcs libmystring.a mystring.o
这里是开关的功能:
-
-c——创建归档
-
-s——索引归档
-
-r——将文件(们)插入到归档中
你可以指定它们为 -rcs 或 -r -c -s——两种方式都可以。
库文件名将是 libmystring.a。ar 工具使用对象代码文件 mystring.o 来创建库。如果需要多个对象代码文件,请在 mystring.o 之后指定它们。
成功后,ar 工具会创建名为 libmystring.a 的库。这种命名格式遵循 Linux 中的约定:libname.a。库以 lib 开头,然后是 name,即库的名称。文件扩展名是点-a。
.a 扩展名以及本节中概述的创建库的过程是为静态库设计的,而不是动态库。静态模型最适合此类库,此类库仅由命令行程序使用,并且不需要动态库的功能。我在本书中不涉及动态库。
7.3.3 使用字符串库
要使用除标准 C 库之外的库,必须在构建时指定其名称。-l(小写的 L)开关紧随库名称之后。名称是库文件名中使用的唯一部分,不是前三个字母(lib)或 .a 扩展名。
如果你已经将库复制到了 /usr/local/lib 文件夹中,链接器会在这里搜索它。否则,-L(大写的 L)开关会指示链接器在特定目录中查找库文件。对于与你的程序在同一文件夹中创建的库,例如在本书的示例中工作,指定 -L(破折号-大写的 L-点)开关以指示链接器在当前目录中查找。例如:
clang -Wall -L. libsample.c -lmystring
当clang将 libsample.c 源代码构建成程序时,它将链接器指向当前目录(-L.)以查找库文件 libmystring.h(-lmystring)。此命令的格式很重要;必须指定-l 开关最后,否则你会看到链接器错误。(一些编译器可能足够智能,能够将库开关识别为任何命令行参数,尽管我的经验让我建议将-l 开关放在最后。)
下一个列表显示了在本书的在线仓库中可找到的 libsample.c 源代码。第 8 行的strcaps()函数是 mystring 库的一部分。其原型可以在 mystring.h 头文件中找到(也包含在仓库中),尽管包含该函数代码的是库。第 2 行显示了双引号中的头文件,这指示编译器在当前目录中查找它。
列表 7.15 libsample.c 的源代码
#include <stdio.h>
#include "mystring.h" ❶
int main()
{
char string[] = "the great american novel";
strcaps(string); ❷
printf("%s\n",string);
return(0);
}
❶ 在当前目录中查找包含strcaps()函数原型的这个头文件
❷ 此函数位于 libmystring.a 库中,在构建时链接。
下面是使用前面显示的命令构建和链接程序时的输出:
The Great American Novel
就像你可以将个人库的副本放在/usr/local/lib 文件夹中一样,你可以在/usr/local/include 文件夹中放置库的头文件副本。这一步避免了使用双引号来设置头文件的位置;与/usr/local/lib 一样,编译器会扫描/usr/local/include 文件夹以查找头文件。
7.4 一种类似 OOP 的方法
C 是一种过程式编程语言。不优雅地说,这个描述意味着 C 代码从上到下运行,一件事情接着另一件事情发生。像 C 这样的旧编程语言也是过程式的。这个列表包括 BASIC、Fortran、COBOL 和其他遗迹。但不要让它们的古老性欺骗了你!在 Y2K 危机期间,COBOL 程序员赚了大钱。
较新的编程语言是面向对象的。它们以不同的方式处理编程任务,你可以在关于这些流行和趋势的数字方言的精彩书籍中了解到这一点。不要深入细节,保持这个讨论模糊以避免吹毛求疵,面向对象编程(OOP)涉及方法而不是函数。"方法"像函数一样工作,尽管它们通常是它们所操作的数据类型的一部分。
例如,如果你想在 Java 编程语言中获取字符串的长度,你使用这个构造:
Len = Str.length()
字符串变量命名为 Str。点操作符访问附加到所有字符串对象上的length()方法。(明白了吗?)返回的结果是字符串 Str 中的字符数。等效的 C 语言语句是:
len = strlen(str);
点操作符也在 C 中使用,特别是在结构体中。字符串的一个成员可以是……一个函数。惊喜。
7.4.1 向结构体添加函数
结构体包含特定数据类型的成员:int,float,char,等等。结果证明,函数也是一种数据类型,它可以作为结构体的成员。
作为比较,如果你在 C 语言中已经有一段时间了,你知道许多函数可以接受另一个函数作为参数;qsort() 函数使用另一个函数(其名称作为地址)作为其参数之一。与作为参数的函数一样,指定函数作为结构体成员需要使用特定的格式:
type (*name)(arguments)
类型是一个数据类型,函数返回的值或 void 表示没有返回值。
名称是函数的名称,这实际上是一个指针。在这个格式中,函数的名称后面不跟括号。相反,参数项列表出了传递给函数的任何参数。
为了形成一个清晰的画面,这里是一个结构定义,其中包含一个函数作为其成员之一:
struct str {
char *string;
unsigned long (*length)(const char *);
};
str 结构体的函数成员被引用为 length。它接受一个 const char 指针——一个字符串——作为其参数。它返回一个 unsigned long 值。这个声明仅仅创建了一个作为 str 结构体成员的函数,该结构体还包含一个字符串成员。为了使函数成员工作,它必须被分配给一个特定的函数。在这种情况下,我心中的函数是 strlen(),它接受一个 const char 指针作为参数并返回一个 unsigned long 值。
创建一个结构体仅仅定义了其成员。要使用结构体,必须创建一个该结构体类型的变量。在这里,创建了结构体 str 变量 str1:
struct str str1;
并且其成员必须被赋予值。以下是长度成员的赋值方式:
str1.length = &strlen;
长度成员的函数是 strlen()。它指定时没有括号,前缀是和符号以获得其地址。一旦分配,函数成员就可以像任何函数一样被调用。例如:
len = str1.length(str1.string);
成员 str1.length 是一个函数(秘密中是 strlen())。它操作的是同一结构体中的字符串成员,str1.string。返回的值,即字符串的长度,存储在变量 len 中。
下面的列表展示了 struct_funct.c 源代码中所有这些疯狂的步骤。此文件可在本书的在线仓库中找到。
列表 7.16 struct_funct.c 的源代码
#include <stdio.h>
#include <string.h> ❶
int main()
{
struct str {
char *string;
unsigned long (*length)(const char *); ❷
};
struct str str1; ❸
char s[] = "Heresy";
str1.string = s; ❹
str1.length = &strlen; ❺
printf("The string '%s' is %lu characters long\n",
str1.string,
str1.length(str1.string) ❻
);
return(0);
}
❶ 必须包含 string.h 头文件以定义 strlen() 函数
❷ 结构体 str 的函数成员
❸ 创建了一个 str 结构体类型的变量 str1。
❹ 字符串成员被分配。
❺ 函数被分配,没有括号,并且前缀是地址运算符。
❻ 函数在 printf() 语句中被调用。
这是程序的输出:
The string 'Heresy' is 6 characters long
我必须承认,表达式 str1.length(str1.string) 并不能神奇地将 C 语言转变为面向对象编程语言。然而,对于那些努力使 C 语言更接近 OOP 的勇敢程序员来说,这是他们采取的方法。他们甚至可能拼凑宏来使这个装置看起来更整洁,例如 str.length(),这正是我所期望的。尽管如此,C 语言并不是为了提供这样的结构而创建的。大多数想要使用 OOP 的程序员会转向 C++、C# 和 Python 等语言。
7.4.2 创建字符串“对象”
我冒着在 C 编程世界中受到异端指控并被放逐的风险,但可以进一步扩展使 C 语言更接近 OOP 的想法。考虑一下,你可以创建一个字符串结构“对象”。C 语言的问题是实现必须通过函数来完成。
例如,你可以编写一个函数来创建伪字符串对象。这个函数需要一个字符串结构作为参数。这样的结构可能看起来像这样:
struct string {
char *value;
int length;
};
这个例子很简单。你可以添加其他字符串描述符作为结构成员,也许还可以添加一些函数。但是,对于一个伪字符串对象的演示,这个结构已经足够了。
要创建这个假字符串对象,需要一个 string_create() 函数。这个函数接收一个指向字符串结构的指针以及字符串的内容(文本):
int string_create(struct string *s, char *v)
指针是必要的,以便函数可以直接修改结构。如果没有指针,函数中对传递的结构所做的任何更改都将被丢弃。传递的字符串 v 最终将与其他信息一起纳入结构中。
下一个列表展示了 string_create() 函数。它根据对象是否成功创建返回 TRUE 或 FALSE 值:获取并存储字符串的长度在结构的长度成员中。这个值用于为字符串分配存储空间。我认为为字符串分配特定的存储空间比复制传递的字符串指针更好,因为指针可能会在未来发生变化。
列表 7.17 string_create() 函数
int string_create(struct string *s, char *v)
{
if( s==NULL ) ❶
return(FALSE);
s->length = strlen(v); ❷
s->value = malloc( sizeof(char) * s->length +1 ); ❸
if( s->value==NULL )
return(FALSE);
strcpy(s->value,v); ❹
return(TRUE); ❺
}
❶ 确认字符串可用;如果不可用,则返回 FALSE
❷ 赋予字符串的长度
❸ 为字符串分配存储空间
❹ 将原始字符串复制到新分配的存储空间
❺ 成功时返回 TRUE
正如创建对象一样,必须存在一个伴随的 string_destroy() 函数。这个函数移除对象,这意味着释放字符串的存储空间,并将任何其他结构成员设置为 0。
下一个列表展示了 string_destroy() 函数,它使用唯一的参数作为要清除的字符串结构。该函数执行三项操作:释放分配的内存,将值指针赋值为 NULL(这确认了内存已被释放),并将字符串的长度设置为 0。这个函数不会像面向对象的语言那样销毁结构变量。
列表 7.18 string_destroy() 函数
void string_destroy(struct string *s)
{
free(s->value); ❶
s->value = NULL; ❷
s->length = 0; ❸
}
❶ 释放字符串存储内存
❷ 将指针赋值为 NULL,这可以在以后用来测试字符串结构的有效性
❸ 将字符串长度重置为零
当然,在销毁字符串结构变量之后,它可以被重新使用或重新分配。关键是对于“对象”,要有创建函数和销毁函数,这模仿了一些面向对象编程语言处理对象的方式。
该书在线仓库中可用的源代码文件 string_object.c 展示了这两个函数。在代码中,你可以看到字符串结构是在外部声明的,这使得所有函数都可以访问其定义。
有可能扩展字符串结构,添加更多描述字符串的成员——包括函数成员。我将这个话题留给你进一步探索,但请记住,面向对象编程语言可供你学习和玩耍。强迫 C 语言适应这种模式是一个考虑因素,但我更建议关注语言的优势,而不是假装它是其他东西。
8 Unicode 和宽字符
在一开始是莫尔斯电码,这是一种将电脉冲——长和短——转换成字符串和可读文本的简单方法。莫尔斯并不是第一个电子编码方法,但它可能是最著名的。它在 1840 年开发,以塞缪尔·莫尔斯的名字命名,他帮助发明了电报,并且也与《太空迷航员》中的史密斯博士有惊人的相似之处。
在莫尔斯电码之后的 30 年左右出现了博多电码。它也用于电报通信,博多(baw-DOH)使用 5 位序列来表示字母表中的字母。后来,这种代码被修改成默里电码,用于带有键盘的电传打字机和早期的计算机。然后是 IBM 的二元编码十进制(BCD),用于他们的主机计算机。最终,ASCII 编码标准在 20 世纪末解决了所有人的文本编码问题。
本章的主题是字符编码,这是一种将字符的字母汤赋予代码值以在计算机中进行数字表示的艺术。这一努力的成果是 Unicode,它几乎为人类历史上几乎所有的可想象书写文字赋予了值。为了帮助在 C 语言中探索 Unicode,在本章中你将:
-
复习各种计算机编码系统
-
研究 ASCII 文本、代码页和 Unicode
-
为你的程序设置区域设置细节
-
理解不同的字符类型,例如 UTF-8
-
使用宽字符和字符串
-
执行宽字符文件 I/O
我真的看不到任何新的文本编码格式会取代 Unicode。这是一个稳固的系统,每年都会分配新的字符。它唯一的限制似乎是在各种字体中的实施不完善。因此,尽管你可以在 Linux 终端窗口中编程 Unicode,但文本输出可能不会准确显示。为了最好地解决这个问题,确保你的终端程序窗口允许你更改字体,这样你就可以见证 Unicode 可以产生的有趣、奇特和令人印象深刻的效果。
8.1 计算机中的文本表示
计算机理解数字,这些数字以字节的形式组织在内存中,由处理器操作,并长期存储在媒体上。系统实际上并不关心文本,并且对拼写一无所知。尽管如此,为了与人类沟通,许多这些字节值对应于打印字符。正是这种字符编码的一致性使得计算机能够与人类沟通并交换信息,尽管它们天生并不愿意这样做。
8.1.1 复习早期文本格式
这些天你只有在电影中才会听到莫尔斯电码。一定发生了重要的事情,通信只能通过敲击管道或其他同样绝望和愚蠢的方式来进行。其中一个角色用陈词滥调回应说他们的莫尔斯电码知识“生疏了”,但信息被解码,观众印象深刻,这一天得以挽救。
摩尔斯电码由一系列的短划线和点组成,长或短的脉冲,用于编码字母和数字。大小写之间没有区别。一些常见的代码在“极客”中是众所周知的,例如 S-O-S,尽管我无法立即记住哪个三位点划组合属于 S 或 O。我想我可以查看表 8.1 来确定哪个是哪个。
表 8.1 摩尔斯电码
| 字符 | 代码 | 字符 | 代码 | 字符 | 代码 |
|---|---|---|---|---|---|
| A | .- | M | -- | Y | -.-- |
| B | -... | N | -. | Z | --.. |
| C | -.-. | O | --- | 1 | .----- |
| D | -.. | P | .--. | 2 | ..--- |
| E | . | Q | --.- | 3 | ...-- |
| F | ..-. | R | .-. | 4 | ....- |
| G | --. | S | ... | 5 | ..... |
| H | .... | T | - | 6 | -.... |
| I | .. | U | ..- | 7 | --... |
| J | .--- | V | ...- | 8 | ---.. |
| K | -.- | W | .-- | 9 | ----. |
| L | .-.. | X | -..- | 0 | ----- |
我将避免涉及有关划线或点的长度以及空格等技术细节的细节。尽管如此,我可以提出的一个“极客”观点是,编码设计得使得常用字母有更少的单位,例如 E、T、I、A、N 等等。
下一个列表显示了toMorse()函数,该函数根据输入字符输出摩尔斯电码字符串。字符字符串存储在两个const char数组中,分别对应 morse_alpha[]的 A 到 Z 序列和 morse_digit[]的 0 到 9 序列。一个if-else结构使用 ctype 函数提取字母和数字字符;所有其他字符都被忽略。
列表 8.1 toMorse()函数
void toMorse(char c)
{
const char *morse_alpha[] = { ❶
".-", "-...", "-.-.", "-..", ".", "..-.",
"--.", "....", "..", ".---", "-.-", ".-..",
"--", "-.", "---", ".--.", "--.-", ".-.",
"...", "-", "..-", "...-", ".--", "-..-",
"-.--", "--.."
};
const char *morse_digit[] = { ❶
"-----", ".----", "..---", "...--", "....-",
".....", "-....", "--...", "---..", "----."
};
if( isalpha(c) ) ❷
{
c = toupper(c); ❸
printf("%s ",morse_alpha[c-'A']); ❹
}
else if( isdigit(c) ) ❺
{
printf("%s ",morse_digit[c-'0']); ❻
}
else if( c==' ' || c=='\n' ) ❼
{
putchar('\n');
}
else ❽
return;
}
❶ 将数组声明为const char以防止代码在其它情况下修改它们;这种类型的结构不喜欢被操作。
❷ 提取字母字符
❸ 转换为大写;摩尔斯电码不区分大小写。
❹ 从'A'减去字符以获得正确的数组元素偏移量
❺ 检查数字 0 到 9
❻ 从'0'减去数字以获得正确的数组元素偏移量
❼ 对于空格和换行符,输出换行符
❽ 忽略非摩尔斯字符;不生成输出。
toMorse()函数可以很容易地设置为一个过滤器,该过滤器将文本输入转换为摩尔斯电码字符串进行输出。这种过滤器可以在本书的在线仓库中找到的源代码文件 morse_code_filter.c 中找到。
另一种文本编码方案是博多码。这个术语对你来说可能很陌生——除非你是那些曾经用“波特”来称呼他们的拨号调制解调器速度的老手。一个 300 波特调制解调器以每秒 300 个字符的速度缓慢移动。因为波特并不完全代表每秒字符数,所以更快的调制解调器(以及今天的宽带)是以每秒比特数(BPS)来衡量的,而不是波特。
不管怎样。
Baudot 方案以 5 位块对文本进行编码。这位工程师和发明家唐纳德·穆雷将此代码改编为 Murray 代码,用于电传打字机。这些机器具有 QWERTY 键盘,常被用作早期计算机系统的输入设备。具体来说,穆雷开发了纸带以存储和读取按键。纸带上的孔代表字符,如图 8.1 所示。

图 8.1 带有孔的纸带,代表 Baudot-Murray 代码
Baudot-Murray 代码的 International Telegraph Alphabet No. 2 (ITA2)标准于 1928 年推出。在美国,该标准被称为 US-TTY(TTY 代表电传打字机)。由于它宽度为 5 位,无法提供足够的值来处理完整的字符集。因此,该代码需要一个转换字符来在字母和符号集之间切换。
表 8.2 列出了字母集的 Baudot-Murray 十六进制代码。代码 0x1B 切换到数字集字符,如表 8.3 所示。代码 0x1B 或代码 0x1F 切换回。
表 8.2 ITA2 和 US-TTY 的 Baudot-Murray 代码,字母集
| Code | Character | Code | Character | Code | Character | Code | Character |
|---|---|---|---|---|---|---|---|
| 0x00 | \0 | 0x08 | \r | 0x10 | T | 0x18 | O |
| 0x01 | E | 0x09 | D | 0x11 | Z | 0x19 | B |
| 0x02 | \n | 0x0A | R | 0x12 | L | 0x1A | G |
| 0x03 | A | 0x0B | J | 0x13 | W | 0x1B | shift |
| 0x04 | space | 0x0C | N | 0x14 | H | 0x1C | M |
| 0x05 | S | 0x0D | F | 0x15 | Y | 0x1D | X |
| 0x06 | I | 0x0E | C | 0x16 | P | 0x1E | V |
| 0x07 | U | 0x0F | K | 0x17 | Q | 0x1F | del |
表 8.3 ITA2 和 US-TTY 的 Baudot-Murray 代码,数字集
| Code | Character | Code | Character | Code | Character | Code | Character |
|---|---|---|---|---|---|---|---|
| 0x00 | \0 | 0x08 | /r | 0x10 | 5 | 0x18 | 9 |
| 0x01 | 3 | 0x09 | $ | 0x11 | " | 0x19 | ? |
| 0x02 | \n | 0x0A | 4 | 0x12 | ) | 0x1A | & |
| 0x03 | - | 0x0B | ' | 0x13 | 2 | 0x1B | shift |
| 0x04 | space | 0x0C | , | 0x14 | # | 0x1C | . |
| 0x05 | \a | 0x0D | ! | 0x15 | 6 | 0x1D | / |
| 0x06 | 8 | 0x0E | : | 0x16 | 0 | 0x1E | ; |
| 0x07 | 7 | 0x0F | ( | 0x17 | 1 | 0x1F | letters |
不要试图理解 Baudot-Murray 中使用的字符映射。如果你对 ASCII 代码的排列方式感到满意(参考第五章),那么表 8.2 和 8.3 中显示的文本编码尤其令人困惑。记住,这些代码是为了与早期系统保持一致性而创建的。也许在编码中可以找到一些道理。谁知道呢?
我非常兴奋地想写一个程序来在 ASCII 和 Baudot-Murray 编码之间进行转换。这种转换的问题在于生成的代码几乎是暴力破解,字符到字符的交换。再加上字符集的转换,这样的编程任务变成了一个没有实际目的的噩梦。
8.1.2 发展为 ASCII 文本和代码页
除了在电传打字机上使用的 Baudot-Murray 代码之外,IBM 为其大型机发明了一种文本编码标准:扩展二进制编码十进制交换码(EBCDIC)。这个方案是第一个 8 位字符编码标准之一,尽管它主要用于 IBM 大型机。
对于输入,IBM 系统使用穿孔卡片。因此,EBCDIC 编码方案被设计出来,目的是为字符分配代码,目的是防止卡片上的穿孔孔洞聚集在一起。这种方法是必要的,以防止卡片撕裂或孔洞相互连接。为了达到这个目标,EBCDIC 代码在其序列中具有间隔;许多 EBCDIC 字符代码是空白的。
随着计算从穿孔卡片转向,众多程序员欢呼雀跃。美国标准协会于 1963 年开发了新的编码标准——ASCII。这是一个 7 位标准,ASCII 增加了逻辑性——更重要的是——对文本编码的同情心。
7 位 ASCII 代码至今仍在使用,尽管今天的字节始终由 8 位组成。这个额外的位意味着现代计算机可以为字节的数据编码 256 个字符,其中只有一半(代码 0 至 127)由 ASCII 标准化。
你可以在第四章中了解更多关于 ASCII 的乐趣。本节的重点是关于 128 至 255 的字符代码,所谓的扩展 ASCII 字符集。
扩展 ASCII 从未成为官方标准,而且在所有 8 位计算机之间也不一致。在 20 世纪 70 年代末和 80 年代初,这些额外的 128 个字符在字节中被映射到各种微型计算机上的非 ASCII 字符上。代码的可用性意味着在典型的计算机上可以生成更多符号,包括常见的字符,如×、÷、±、希腊字母、分数、带重音的字符、双字母符号等。
图 8.2 列出了 20 世纪 80 年代初原始 IBM PC 系列计算机上可用的扩展 ASCII 字符集。尽管字符种类丰富,但这些 128 个额外符号仍不足以代表所有可用或期望的字符——这只是个开头。

图 8.2 原始 IBM PC“扩展 ASCII”字符集
为了容纳更多字符,早期的计算机使用了代码页。代码页代表一组不同的字符,包括 ASCII(代码 0 至 127)和 8 位字符代码,128 至 255。
图 8.2 中显示的 128 至 255 代码字符代表 IBM PC 代码页 437。其他代码页使用不同的符号。最终,为特定外国语言和字母表制作了代码页。中文、日文、阿拉伯文和其他字符集在各种代码页上都有特色。
在古老的 MS-DOS 操作系统中有可用的命令允许切换代码页字符集,尽管计算机仍然限制在每次只能使用一个代码页的字符。在系统配置文件(CONFIG.SYS)中,COUNTRY 命令设置区域详细信息,包括可用的代码页。在命令提示符下,使用 CHCP 命令来检查当前代码页以及将字符集更改为新的代码页。
Linux 不使用代码页,主要是因为它实现了 Unicode(见下一节)。然而,Windows 仍然使用与原始 IBM PC 相同的扩展 ASCII 代码页。当程序输出从 128 到 255 的字符代码值时,你可以查看这些遗留字符。
下面的列表中的源代码生成了图 8.2 的内容。程序的核心由嵌套的 for 循环组成,输出代表传统扩展 ASCII 字符集或代码页 1 的行和列。printf()语句中的格式化确保输出以方便的表格形式出现。
列表 8.2 extended_ascii.c 的源代码
#include <stdio.h>
int main()
{
int x,y;
printf(" "); ❶
for(x=0; x<16; x++)
printf(" %X ",x); ❷
putchar('\n');
for( x=0x80;x<0x100; x+=0x10 ) ❸
{
printf(" 0x%2x ",x);
for( y=0; y<0x10; y++ )
{
printf(" %c ",x+y); ❹
}
putchar('\n');
}
return(0);
}
❶ 使用六个空格对齐输出
❷ 输出标题行
❸ 输出左侧列
❹ 内部循环输出字符,计算正确的偏移量 x+y
由 extended_ascii.c 源代码生成的程序在 Windows 计算机上运行效果最佳。如果你在 Linux 或 Mac 上运行它,表格将是空的或填充着问号。字符并没有丢失;只是在 Linux/Unix 环境中没有生成,除非在代码中设置了特定的区域设置。这一主题将在本章后面讨论。
交换代码页和探索扩展 ASCII 字符集的任务不再需要来生成花哨的文本。随着 20 世纪 90 年代 Unicode 的出现,自早期电报时代以来的所有文本编码不一致性终于得到了解决。
8.1.3 深入 Unicode
回到 20 世纪 80 年代,那些坐在那里思考如何做些新奇事情的计算科学家们撞上了好运。他们考虑了创建一种一致的方式来编码文本的可能性——不仅限于 ASCII 或拉丁字母字符,而是这个星球上已知的所有涂鸦、符号和装饰品,无论是过去还是现在。结果在 20 世纪 90 年代揭晓,那就是 Unicode。
Unicode 的原始目的是将字符宽度从 8 位扩展到 16 位。这种变化并没有将字符数量翻倍——它将可能的字符编码从 256 增加到超过 65,000。但即使这个巨大的数量也不够。
现在,Unicode 标准包括数百万个字符,包括象形文字和表情符号,其中一部分在图 8.3 中展示。新字符不断被添加,几乎每年都在增加。例如,在 2021 年,添加了 838 个新字符。

图 8.3 各种 Unicode 字符
当前 Unicode(截至 2022 年)的代码空间由 1,114,111 个代码点组成。代码空间是整个 Unicode 的范围。你可以将代码点视为字符。然而,并非每个代码点都分配了字符:代码空间中的许多块是空的。一些代码点被设计为覆盖或长音符号,以与其他字符结合。在众多代码点中,前 128 个代码点与 ASCII 标准相一致。
Unicode 字符以 U+nnnn 的格式引用,其中 nnnn 是代码点的十六进制值。代码空间被组织成代表各种语言或文字的代码面板。大多数引用 Unicode 字符的网页,如 unicode-table.com,在浏览字符集合时使用这些代码平面或块。
要将 Unicode 代码点——比如 U+2665——转换为 C 中的字符,你必须遵循一种编码格式。这些编码格式中最受欢迎的是 Unicode 转换格式,UTF。存在几种 UTF 的变体:
-
UTF-8 使用 8 位块(字节)来存储字符值,多个字节包含某些值的代码。字节数量不同,但它们都是 8 位块。
-
UTF-16 使用 16 位块(单词)来存储字符值。这种格式不像 UTF-8 那样受欢迎。
-
UTF-32 使用 32 位块(长单词)。所有字符都由 32 位表示,无论它们是否需要存储空间。这种格式并不那么受欢迎,因为它占用的空间比许多代码点所需的更多。
这些编码格式在设置区域设置时发挥作用,这是在 C 中处理 Unicode 文本的关键。有关区域设置的更多信息,请参阅第 8.2.1 节。
字符本身被描述为宽字符,或可能需要多个字节来生成输出的字符。这个主题将在第 8.2.2 节中介绍。
最后,请注意,并非每种字体都支持整个 Unicode 字体集。缺失的字符将根据字体以空格、问号或方框的形式输出。当你运行本章后面的某些程序时,可能会遇到这个问题。解决方案是为终端窗口设置另一种字体,或者配置终端窗口,使其能够输出 Unicode 文本。
8.2 宽字符编程
仅将宽字符输出到控制台是不行的。你可以尝试。也许你会幸运,尤其是在 Windows 上。但要正确地在你的 C 程序中输出和编程 Unicode 文本,你必须首先设置区域设置。这个设置通知计算机程序能够处理宽字符。
在设置区域设置后,代码必须访问和使用宽字符进行其文本 I/O。这个过程是某些文本模式程序如何在终端窗口中输出花哨的 Unicode 字符,以及电子邮件消息甚至短信显示表情符号和其他有趣字符的方式。一旦你学会了本节中介绍的步骤,你的程序也可以做到这一点。
8.2.1 设置区域
程序中的区域设置确定诸如语言、日期和时间格式、货币符号等特定于语言或地区的详细信息。此功能和它的伙伴允许你在不深入研究,例如,文化中的千位分隔符或货币符号的情况下编写适用于不同地区的程序。
对于宽字符输出,设置正确的区域允许你的代码使用宽字符——Unicode 字符集。是的,设置区域是秘密。
要在 Linux 环境中查看当前的区域设置,请在终端窗口中输入 locale 命令。以下是看到的输出:
LANG=C.UTF-8
LANGUAGE=
LC_CTYPE="C.UTF-8"
LC_NUMERIC="C.UTF-8"
LC_TIME="C.UTF-8"
LC_COLLATE="C.UTF-8"
LC_MONETARY="C.UTF-8"
LC_MESSAGES="C.UTF-8"
LC_PAPER="C.UTF-8"
LC_NAME="C.UTF-8"
LC_ADDRESS="C.UTF-8"
LC_TELEPHONE="C.UTF-8"
LC_MEASUREMENT="C.UTF-8"
LC_IDENTIFICATION="C.UTF-8"
LC_ALL=
UTF-8 字符格式是允许 Unicode 文本输入/输出的格式——尽管要在代码中启用 UTF-8 输出,你必须使用在 locale.h 头文件中声明的 setlocale() 函数。以下是格式:
char *setlocale(int category, const char *locale);
第一个参数,类别,是一个定义的常量,表示你想要设置的区域哪个方面。使用 LC_ALL 来设置所有类别。LC_CTYPE 类别专门用于文本。
第二个参数是要设置的特定区域详细信息的字符串。例如,对于文本,你可以指定 "en_US.UTF-8",这将激活英语的 8 位 Unicode 字符集。也可以指定一个空字符串。
setlocale() 函数返回一个表示请求的特定信息的字符串。你不需要使用该字符串;设置区域对于宽字符输入/输出就足够了。
注意,setlocale() 函数在某些 Windows 编译器中不可用。在 Windows 中访问 Unicode 字符的方法与本章中描述的不同。
下一个列表显示了一个使用 setlocale() 函数输出区域详细信息的微小程序——特别是正在使用的字符集。第 8 行使用 setlocale() 函数返回一个描述当前区域的字符串,保存在变量 locale 中。一个 printf() 语句输出了区域字符串。以这种方式使用,setlocale() 函数不会更改区域设置;它只报告信息。
列表 8.3 locale_function.c 的源代码
#include <stdio.h>
#include <locale.h> ❶
int main()
{
char *locale; ❷
locale = setlocale(LC_ALL,""); ❸
printf("The current locale is %s\n",locale); ❹
return(0);
}
❶ setlocale() 函数需要 locale.h 头文件。
❷ 指向字符串的指针以保留函数的输出
❸ 同步 GPU 以完成工作
❹ 输出区域详细信息
这里是示例输出:
The current locale is C.UTF-8
C 代表 C 语言。如果不是,它应该是。UTF-8 是字符编码。
设置区域后,输出 Unicode 字符的下一步是理解宽字符的概念。
8.2.2 探索字符类型
要启用访问 Unicode 的巨大字符集的魔法,你必须熟悉计算机领域使用的三种字符类型:
-
单字节字符
-
宽字符
-
多字节字符
单字节字符提供了生成文本的传统方式。这些是 8 位值,char数据类型,等于一个字节的存储空间。尽管char值的范围从 0 到 255(无符号),但只有 0 到 127 的值使用 ASCII 标准分配了字符。
宽字符数据类型使用超过 8 位来编码文本。字节数可能因字符而异。在 C 中,wchar_t数据类型处理宽字符,而宽字符(wchar)函数族操作这些字符。
多字节字符需要几个字节来表示一个字符。这个描述包括宽字符,但也包括需要前缀字节或引导单元,然后是另一个字节序列来表示单个字符的字符。这种多字节字符可能用于特定的应用程序和计算机平台。本书不涉及此类内容。
要表示单字节字符,你使用 C 中的char数据类型。例如:
char hash = '#';
哈希字符被分配给char变量 hash。字符代码是 35 十进制,23 十六进制。
要表示宽字符,请使用wchar_t数据类型。其定义可以在 wchar.h 头文件中找到,必须在你的代码中包含此头文件。此头文件还原型化了各种宽字符函数。(见下一节。)
以下语句声明了宽字符 yen:
wchar_t yen = 0xa5;
日元字符¥是 U+00a5。这个值被分配给wchar_t变量 yen。编译器不会让你直接分配字符:
wchar_t yen = L'¥';
L 前缀将字符定义为长(宽)。这个前缀的作用类似于应用于long整数值的 L 后缀:123L 表示 123 作为一个long int值指定的值。虽然这个 L 前缀技巧可以与表示为宽字符的 ASCII 字符一起工作,但你的 C 编译器很可能在尝试编译包含此类字符的源代码文件时失败;我看到的警告是“非法字符编码。”你的编辑器也可能不允许你直接输入或粘贴宽字符。
L 前缀也用于声明宽字符字符串。以下是一个宽字符字符串的例子:
wchar_t howdy[] = L"Hello, planet Earth!";
上面的字符串“Hello, planet Earth!”由宽字符组成,归功于 L 前缀。wchar_t数据类型声明了宽字符串 howdy。
与单字符一样,你不能在宽字符串中插入特殊字符。以下声明被标记为非法字符编码:
wchar_t monetary[] = L"$¥€₤";
这样的字符串可以这样组成:
wchar_t monetary[] = {
0x24, 0xa5, 0x20ac, 0xa3, 0x0
};
上述十六进制值代表美元符号、日元、欧元和英镑字符,之后跟着空字符作为字符串的终止符。
要输出宽字符和宽字符串,请使用 wprintf()函数。这个函数与标准库的printf()函数类似,但它处理宽字符串。宽字符和宽字符串使用特殊的占位符:
-
%lc 占位符代表一个宽字符。
-
%ls 占位符代表宽字符串。
占位符中的小写 L 识别目标变量为宽字符或 wchar_t 数据类型。这个字符类似于 %ld 占位符中的小 L,用于表示长十进制整数值。
8.2.3 生成宽字符输出
要在 C 中输出宽字符,你将使用在 wchar.h 头文件中声明的函数,该头文件还方便地定义了 wchar_t 数据类型。这些函数与标准字符串函数(来自 string.h)平行,大多数伴随函数以 w 或其他细微差异为前缀。例如,宽字符版本的 printf() 是 wprintf()。
哦,你还需要 locale.h 头文件,因为宽字符函数必须首先设置区域设置才能激活。有关使用 setlocale() 函数的详细信息,请参阅第 8.2.1 节。
下一个示例展示了使用 wprintf() 函数的传统“Hello, world!”类型程序源代码,并加入了我自己的宽字符处理。由于输出是 ASCII,尽管是宽 ASCII,因此不需要 setlocale() 函数。stdio.h 头文件也不需要,因为代码中没有使用其任何函数。
列表 8.4 hello_wworld01.c 的源代码
#include <wchar.h> ❶
int main()
{
wprintf(L"Hello, wide world!\n"); ❷
return(0);
}
❶ 宽字符定义和函数
❷ wprintf() 函数类似于 printf() 函数。对于由宽字符组成的字符串,需要 L 前缀。尽管这里的文本是 ASCII,但内部使用宽字符来表示文本。
这是程序的输出:
Hello, wide world!
没有什么令人惊讶的,但不要让缺乏悬念让你产生错误的熟悉感。为了帮助你轻松地使用宽字符函数,你可以分两步修改代码。
首先,在代码中较早的位置将字符串设置为它的声明:
wchar_t hello[] = L"Hello, wide world!\n";
wchar_t 数据类型定义了一个由宽字符串中的字符组成的数组 hello[]。如果省略 L 前缀,编译器会抛出一个数据类型不匹配错误。是的,这是一个错误:代码无法编译。要创建宽字符串,你需要 wchar_t 数据类型和双引号中包含的文本上的 L 前缀。
第二,修改 wprintf() 语句以输出字符串:
wprintf(L"%ls",hello);
格式化字符串需要 L 前缀,因为所有宽字符函数都处理宽字符。%ls 占位符代表一个宽字符字符串。参数 hello 引用了宽字符 hello[] 数组的地址。
这两个对 hello_wworld01.c 代码的更新可以在在线存储库中的源代码文件 hello_wworld02.c 中找到。输出与第一个程序相同。
要输出单个宽字符,使用 putwchar() 函数。它的工作方式类似于 putchar(),并且是几个以 w 开头的宽字符函数之一。
下一个列表中的代码输出四个扑克牌花色:黑桃、红心、梅花和方块。它们的 Unicode 值被分配为 suits[]数组的元素。需要setlocale()函数,因为这些不是 ASCII 字符。在 for 循环中,putwchar()函数输出字符。最后的putwchar()函数输出一个换行符——宽换行符。
列表 8.5 suits.c 的源代码
#include <wchar.h>
#include <locale.h>
int main()
{
const int count = 4;
wchar_t suits[count] = {
0x2660, 0x2665, 0x2663, 0x2666 ❶
};
int x;
setlocale(LC_CTYPE,"en_US.UTF-8"); ❷
for( x=0; x<count; x++ )
putwchar(suits[x]); ❸
putwchar('\n');
return(0);
}
❶ 四个扑克牌花色的 Unicode 值
❷ 设置区域设置,因为这些不是 ASCII 字符。
❸ putwchar()函数输出每个宽字符值。
这是代码的输出:
♠♥♣♦
在我的 Linux 系统中,输出是单色的。但在我的 Macintosh 上,红心和方块符号是红色的。这种差异基于所使用的字体。Mac 似乎在其终端窗口中提供了比我的 Linux 发行版更好的 Unicode 字符选择。
suits.c 的代码示例说明了创建了多个 Unicode 字符串并输出。创建 suits[]数组的技术是如何从头开始构建宽字符字符串,尽管 suits[]是一个字符数组而不是字符串,它必须以空字符结尾。
在下面的列表中,在main()函数中声明了三个 Unicode 字符串。每个字符串都以换行符和空字符结尾。fputws()函数将字符串作为输出发送到stdout设备(文件句柄,在 stdio.h 中定义)。这个函数与fputs()函数等效。
列表 8.6 wide_hello.c 的源代码
#include <stdio.h> ❶
#include <wchar.h>
#include <locale.h>
int main()
{
wchar_t russian[] = { ❷
0x41f, 0x440, 0x438, 0x432, 0x435, 0x442, '!' , '\n', '\0'
};
wchar_t chinese[] = {
0x4f31, 0x597d, '\n', '\0'
};
wchar_t emoji[] = {
0x1f44b, '\n', '\0'
};
setlocale(LC_ALL,"en_US.UTF-8");
fputws(russian,stdout); ❸
fputws(chinese,stdout);
fputws(emoji,stdout);
return(0);
}
❶ 定义stdout所需的
❷ 每个数组都创建为一个字符串,包括换行符和空字符。
❸ fputws()函数需要一个宽字符串和文件句柄作为参数。
图 8.4 显示了 wide_hello.c 程序生成的输出。这个截图来自我的 Macintosh,其中终端应用程序正确地生成了所有 Unicode 字符。在 Linux 中输出看起来相似,但在 Windows 10 Ubuntu Linux 中,只有西里尔文文本被输出;其余文本以方框中的问号出现。这些通用字符意味着图 8.4 中显示的 Unicode 字符在终端的指定字体中不可用。

图 8.4 wide_hello.c 程序的适当解释输出
一些字体无法正确渲染 Unicode 字符集的部分,这是你在编码宽文本输出时应该始终考虑的问题。
并非你输出的每个字符串都需要所有宽字符文本字符,例如列表 8.6 中显示的字符串。实际上,你通常可能只在一个可打印的普通 ASCII 文本字符串中找到一个字符。下面演示了一种将此类字符偷偷放入字符串的方法。在这里,日元字符(¥)被声明为一个单独的wchar_t变量 yen。这个值通过使用%lc 占位符在wprintf()函数中输出。
列表 8.7 yen01.c 的源代码
#include <wchar.h>
#include <locale.h>
int main()
{
wchar_t yen = 0xa5; ❶
setlocale(LC_CTYPE,"en_US.UTF-8");
wprintf(L"That will be %lc500\n",yen); ❷
return(0);
}
❶ 字符由其 Unicode 值指定,U-00A5。
❷ %lc 占位符表示 yen 中的宽字符值。
下面是代码的输出:
That will be ¥500
在代码中,我将 LC_CTYPE 区域设置值设置为 en_US.UTF-8,这对于英语语言是合适的,因为它在美国被广泛使用。你不需要设置日语区域设置(ja_JP.UTF-8)来输出字符。
在字符串中插入非 ASCII Unicode 字符的另一种方法是替换。例如,你可以创建一个 ASCII 文本的宽字符字符串,然后在字符串输出之前插入一个特定的字符。
要修改列表 8.7,首先创建一个包含不可输入 Unicode 字符占位符的宽字符字符串:
wchar_t s[] = L"That will be $500\n";
在宽字符字符串 s[]的第 13 个元素中,我使用美元符号代替我需要的日元符号。下一步是将此元素替换为正确的宽字符:
s[13] = 0xa5;
这个任务之所以有效,是因为字符串 s[]中的所有字符都是宽字符。字符代码 0xa5 替换了美元符号。然后使用此语句输出字符串:
wprintf(L"%ls",s);
代码更新被命名为 yen02.c,并可在本书的在线仓库中找到。如果你进行这种技巧,确保正确记录 0xa5 的值,以免混淆可能后来检查你代码的其他程序员。
练习 8.1
使用前面描述的方法,并在源代码文件 yen02.c 中可用,在字符串中替换一个 Unicode(不可输入)字符。创建一个输出以下文本的程序:
I ♥ to code.
心形符号的 Unicode 值是 U+2665,在 suits.c 源代码中之前已显示。
我的解决方案作为 code_love.c 在在线仓库中可用。
8.2.4 接收宽字符输入
宽字符输入函数在 wchar.h 头文件中定义,与它们的输出对应函数一起,在上一节中已介绍。与宽字符输出函数一样,这些输入函数与标准输入函数平行。例如,getwchar()函数接收宽字符输入,就像getchar()函数接收普通字符输入一样。或者应该称之为瘦字符输入?
宽字符输入的难点在于如何生成宽字符。标准键盘输入像往常一样工作——字符被解释为其宽值。一些键盘有 Unicode 字符键,例如£或€符号。检查你的 Linux 终端程序是否允许使用花哨的字符输入方法,通常可以通过右键菜单进行。当这些工具对你不可用时,剩下的唯一技巧是从其他地方复制和粘贴 Unicode 字符,例如网页或某些 Unicode 友好的应用程序的输出。
mood.c 的源代码如下所示。它使用getwchar()函数处理标准输入,包括宽字符。单字符输入在wprintf()语句中被回显。%lc 占位符代表wchar_t变量 mood。
列表 8.8 mood.c 的源代码
#include <locale.h>
#include <wchar.h>
int main()
{
wchar_t mood; ❶
setlocale(LC_CTYPE,"en_US.UTF-8");
wprintf(L"What is your mood? "); ❷
mood = getwchar(); ❸
wprintf(L"I feel %lc, too!\n",mood); ❹
return(0);
}
❶ 单个宽字符变量 mood 持有输入。
❷ 这个字符串是 ASCII 编码的,但 L 前缀使其由宽字符组成。
❸ 从标准输入获取宽字符并将其存储在 wchar_t 变量 mood 中
❹ %lc 占位符代表宽字符 mood。
由 mood.c 创建的程序从标准输入读取,尽管你输入的任何文本在内部都使用宽字符表示。因此,无论你输入的是 Unicode 字符还是任何其他键盘字符,程序都会运行,如下例所示:
What is your mood? 7
I feel 7, too!
然而,真正的测试是输入一个 Unicode 字符,特别是表情符号。在某些 Linux 版本中(不是 Windows 版本),你可以在终端窗口中右键单击(或控制单击)以访问输入表情符号字符。
在 Windows 中,按键盘上的 Windows 和点号键来弹出表情符号调色板。这个技巧在 Ubuntu Linux 的 shell 窗口中也有效。
在 Macintosh 上,按 Ctrl+Command+Space 键盘快捷键可以看到一个弹出表情符号调色板,如图 8.5 所示。从该调色板中,你可以选择一个表情符号来代表你的心情,然后它就会出现在输出字符串中。

图 8.5 在终端应用中使用 Macintosh 表情符号输入面板
作为最后的手段,你可以从另一个程序或网站上复制并粘贴所需的字符。只要终端窗口的字型有该字符,它就会出现在程序的输出中。
getwchar() 函数以与 getchar() 相同的方式处理流输入;它不是一个交互式函数。请参阅第四章以获取有关 C 中流 I/O 的信息。对于宽字符,规则与标准 char 数据类型相同。
要读取多个字符,请使用 fgetws() 函数。这个函数是 fgets() 的宽字符版本,具有类似的参数集。以下是 man 页面的格式:
wchar_t *fgetws(wchar_t *ws, int n, FILE *stream);
第一个参数是一个 wchar_t 缓冲区,用于存储输入。然后是缓冲区大小,这是输入字符数减去一个空字符(自动添加),最后是文件流,例如 stdin 用于标准输入。
fgetws() 函数在成功时返回缓冲区的地址,否则返回 NULL。
下一个列表中展示的 wide_string_in.c 的源代码说明了如何使用 fgetws() 函数。宽字符缓冲区输入存储从标准输入设备(stdin)读取的宽字符。wprintf() 函数输出存储在输入缓冲区中的字符。
列表 8.9 wide_in.c 的源代码
#include <stdio.h> ❶
#include <wchar.h>
#include <locale.h>
int main()
{
const int size = 32; ❷
wchar_t input[size]; ❸
setlocale(LC_CTYPE,"UTF-8");
wprintf(L"Type some fancy text: ");
fgetws(input,size,stdin); ❹
wprintf(L"You typed: '%ls'\n",input); ❺
return(0);
}
❶ 对于定义 stdin 是必需的
❷ 使用常量设置缓冲区大小
❸ 宽字符输入缓冲区
❹ 从标准输入将大小字符读入输入缓冲区
❺ 使用 %ls 占位符输出宽字符字符串
从 wide_in.c 源代码创建的程序就像任何基本的 I/O 程序一样工作——这可能是你第一次学习 C 语言编程时编写的。区别在于读取、存储和输出的是宽字符。因此,你可以让你的文本变得复杂,如下面的示例运行所示:
Type some fancy text: 你好,世界
You typed: '你好,世界
'
就像标准输入和 fgets() 函数一样,换行符会保留在输入字符串中。你可以在输出中看到它的效果,最后的单引号出现在下一行。
另一个我不情愿要介绍的宽输入函数是 wscanf(). 这个函数基于 scanf(),这可能是我最不喜欢的 C 语言输入函数,尽管它有其用途。然而,这个函数很难处理,因为你必须恰好获取输入数据,否则它就像一个有倒刺的专业足球运动员一样崩溃。
这里是关于 wscanf() 的手册页:
int wscanf(const wchar_t *restrict format, ...);
这种格式与 scanf() 函数相同,尽管格式化字符串(第一个参数)由宽字符组成。如果你使用这个函数,你可能会至少一次或两次忘记格式化字符串上的 L 前缀。
列表 8.10 展示了一个愚蠢的 I/O 程序,我可能在初学者编程书中使用它。涉及的唯一 Unicode 字符是英镑符号 (£),它在代码的早期被声明。否则,请注意 wscanf() 函数如何使用 L 前缀来指定格式化字符串。所有语句都输出宽字符。输入也可以是宽字符,尽管只有 ASCII 数字 0 到 9 对代码有实际意义。
列表 8.10 wscanf.c 的源代码
#include <wchar.h>
#include <locale.h>
int main()
{
const wchar_t pound = 0xa3; ❶
int quantity;
float total;
setlocale(LC_CTYPE,"en_US.UTF-8");
wprintf(L"How many crisps do you want? ");
wscanf(L"%d",&quantity); ❷
total = quantity * 1.4; ❸
wprintf(L"That'll be %lc%.2f\n", ❹
pound,
total
);
return(0);
}
❶ 英镑字符被定义为 wchar_t 常量。
❷ 就像 scanf() 语句一样,但使用宽字符格式化字符串
❸ 随机数学,只是为了让代码做些事情
❹ %lc 占位符输出英镑符号;%.2f 将金额格式化为两位小数。
这里是一个示例运行:
How many crisps do you want? 2
That'll be £2.80
它们必须是很好吃的薯片。
练习 8.2
源代码文件 wide_in.c(列表 8.9)处理输入字符串。但是当字符串的长度小于允许的最大字符数时,换行符会保留在字符串中。你的任务是修改源代码,以便从输出中移除字符串中的任何换行符。
完成这个任务的其中一种方法是你编写自己的输出函数。这太简单了。相反,你必须创建一个函数,该函数可以移除由 fgetws() 函数添加的换行符,从而有效地裁剪字符串。
我的解决方案可以在本书的在线仓库中找到,名为 wide_in_better.c。请在查看我的解决方案之前,先自己尝试这个练习。
8.2.5 在文件中处理宽字符
wchar.h 头文件还定义了标准 C 库中可用的宽字符文件 I/O 函数的宽字符等效函数——例如,fputwc() 将宽字符发送到流中,相当于 fputc(). 这些宽字符函数与标准库文件 I/O 函数配对,如 fopen(). 这种混合创造了一个激动人心的宽字符和非宽字符的混合,所以要注意你的字符串!
与标准 I/O 一样,你的宽字符文件函数必须设置区域设置。文件必须以读取、写入或两者兼有的方式打开。宽字符文件 I/O 函数用于从文件中获取和放置文本。WEOF 常量用于标识宽字符文件结束符,wint_t 数据类型。一旦文件活动完成,文件就会被关闭。如果你在 C 中处理过文件 I/O,这个操作应该很熟悉。
例如,考虑输出希腊字母表的 24 个大写字母(alpha 到 omega,Α (U+0391) 到 Ω (U+03A9)),并将字母表保存到文件中的代码。Unicode 值逐个递增,尽管存在空白区域 U+03A2。这些值与小写希腊字母表平行,它从 U+03B1 开始。大写空白区域保持了大小写值的平行,因为在希腊语中使用了两个小写 sigma 字符。这些 Unicode 值在代码中通过常量表示:
const wchar_t alpha = 0x391;
const wchar_t omega = 0x3a9;
const wchar_t no_sigma = 0x3a2;
文件创建后,大写希腊字符逐个写入文件,使用下一个列表中显示的 for 循环。常量 alpha 和 omega 代表第一个和最后一个字符的 Unicode 值。wchar_t 常量 no_sigma 在循环中的 if 测试中使用,以便跳过其字符(U+03A2,即空白)。
列表 8.11 将大写希腊字母写入文件的循环
wprintf(L"Writing the Greek alphabet...\n"); ❶
for( a=alpha; a<=omega; a++ ) ❷
{
if( a==no_sigma ) ❸
continue; ❹
fputwc(a,fp); ❺
fputwc(a,stdout); ❻
}
fputwc('\0',fp); ❼
❶ 通知用户正在发生的事情
❷ 遍历希腊字母表
❸ 测试空白区域和 ...
❹ ... 跳过这个非字符,继续循环
❺ 将希腊字母写入文件(FILE 指针 fp)
❻ 同时将字符发送到标准输出
❼ 向文件写入一个空字符,以便宽字符串文件输入函数可以在以后读取它
列表 8.11 中未显示的其余代码,可在本书的在线仓库中找到,源代码文件为 greek_write.c。缺少打开和关闭文件的语句,以及各种变量声明。以下是示例输出:
Writing the Greek alphabet...
ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ
Done
设置了区域设置后,文件包含希腊大写字母而不是垃圾数据。因为终端窗口足够智能,可以识别 Unicode,所以你可以使用 cat 命令来转储文件:
$ cat alphabeta.wtxt
ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ $
文件名为 alphabeta.wtxt。我创造了 wtxt 扩展名用于宽文本文件。你也会看到文件内容缺少换行符,这就是为什么命令提示符 ($) 出现在 Omega 之后。
下面是 hexdump 工具的输出,以显示文件的原始字节:
0000000 91ce 92ce 93ce 94ce 95ce 96ce 97ce 98ce
0000010 99ce 9ace 9bce 9cce 9dce 9ece 9fce a0ce
0000020 a1ce a3ce a4ce a5ce a6ce a7ce a8ce a9ce
0000030 0000
0000031
从文件中读取宽字符有几种方法。因为我将空字符写在了字母表的末尾,所以可以使用 fgetws() 函数来读取文本行。这个函数是 fgets() 函数的宽字符兄弟。
下面的列表显示了文件读取代码,位于本书在线仓库中源代码文件 greek_read01.c 中。传统的文件 I/O 命令打开文件。设置区域设置。然后 fgetws() 函数执行其魔法,读取大写字母的宽字符串。输出该行,然后关闭文件。
列表 8.12 greek_read01.c 的源代码
#include <stdio.h>
#include <stdlib.h>
#include <wchar.h>
#include <locale.h>
int main()
{
const char *file = "alphabeta.wtxt"; ❶
const int length = 64; ❷
FILE *fp;
wchar_t line[length]; ❸
fp = fopen(file,"r"); ❹
if( file==NULL ) ❺
{
fprintf(stderr,"Unable to open %s\n",file);
exit(1);
}
setlocale(LC_CTYPE,"en_US.UTF-8");
wprintf(L"Reading from %s:\n",file); ❻
fgetws(line,length,fp); ❼
wprintf(L"%ls\n",line); ❽
fclose(fp);
return(0);
}
❶ 要打开的文件
❷ 定义输入缓冲区的常量
❸ 宽字符输入缓冲区
❹ 打开文件进行读取
❺ 处理任何错误
❻ 告诉用户正在发生什么
❼ 从文件中读取一行文本(直到空字符)
❽ 输出读取的行
因为 greek_write.c 的源代码在字母表末尾添加了一个空字符,所以 greek_read01.c 中的 fgetws() 函数会一次性从文件中读取文本:就像 fgets() 函数一样,fgetws() 在遇到空字节、换行符或缓冲区填满时停止读取。以下是程序的输出:
Reading from alphabeta.wtxt:
ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ
要一次从文件中读取一个宽字符,请使用 fgetwc() 函数,它是 fgetc() 的宽字符对应函数。与 fgetc() 一样,fgetwc() 返回的值不是一个字符,甚至不是一个宽字符。它是一个宽整数。以下是 fgetwc() 函数的 man 页面格式:
wint_t fgetwc(FILE *stream);
函数的参数是一个打开的文件句柄,或者 stdin 用于标准输入。返回的值是 wint_t 数据类型。与 fgetc() 一样,原因是宽字符文件结束标记 WEOF 可能会被遇到,而 wchar_t 类型无法正确解释它。
要将 greek_read01.c 的代码修改为从文件中读取单个字符,只需要进行几个更改:
移除了 line[] 缓冲区和长度常量。取而代之的是,声明了一个单个 wint_t 变量:
wint_t ch;
要从文件中读取,将 fgetws() 语句以及 wprintf() 语句替换为以下语句:
while( (ch=fgetwc(fp)) != WEOF )
putwchar(ch);
putwchar('\n');
while 循环的条件从打开的文件句柄 fp 中读取一个字符(一个 wint_t 值)。这个值与宽字符文件结束标记 WEOF 进行比较。只要字符不是文件结束,循环就会重复。
循环的唯一语句是 putwchar(ch),它输出读取的字符。最后的 putwchar() 语句输出换行符,清理输出。
greek_read02.c 的完整源代码可在本书在线仓库中找到。该程序的输出与使用 fgetws() 函数读取字母表的程序版本相同。
Reading from alphabeta.wtxt:
ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ
练习 8.3
以我的希腊字母程序为指南,编写代码将西里尔字母表写入文件。你可以选择编写一个程序从你创建的文件中读取西里尔字母表,尽管cat命令同样有效。
西里尔字母表的第一字母 A,其 Unicode 编码为 U+0410。最后一个字母是Я,其 Unicode 编码为 U+042F。这些都是大写字母。与希腊字母不同,Unicode 序列中不包含空格。
我的解决方案被称为 cyrillic.c,并且可以在本书的在线代码仓库中找到。
9 十六进制转储器
我亲自看过,但我就是看不到存储在介质上的文件。在以前,你可以取出软盘并看到实际的介质。然而,介质上的数据仍然以微小的电子粒子形式编码,肉眼甚至穿着衣服的眼睛都看不见。不,唯一能够窥视文件原始内容的方法是使用工具,比如 hexdump。
是的,hexdump 是一个 Linux 工具,作为默认安装的一部分提供。它在技术人员中非常受欢迎,功能强大。没有必要重新创建它——除非你想改进它。或者你可能想扩展你的编程知识,并在过程中学习一些技巧,例如:
-
在基本级别上检查存储
-
正确输出字节数据
-
从文件中读取原始数据
-
调整和定位程序输出
-
添加和处理命令行开关
本章的目标并不是模仿十六进制转储工具,而是通过自己动手操作来了解它能做什么,并更加欣赏它。在这个过程中,你将发现更多关于编写此类工具以及如何优化自己的程序以符合你偏好的方法。
9.1 字节和数据
计算机对字节一无所知。数字信息以比特形式存储——比特是二进制和数字这两个词的碰撞。二进制数字是 1 和 0,比特要么是 1 要么是 0。字节将比特聚集成方便、快乐的组,它们代表更大的数字。
从计算机的早期就提出了一个问题:一个字节可以容纳多少比特?如果技术人员想要这样做,计算机的整个存储空间可以是一个长达数十亿的比特的单字节。这样的长度将是非常不切实际的。因此,技术人员将比特组织成更小的块,字节大小从几个比特到每个字节超过十几个比特不等。
今天,标准是每字节 8 位。但即使如此,在处理计算机中的信息时,也需要更大的存储容量。
9.1.1 回顾存储单位和大小混乱
在计算机爱好者梳着油亮的背头、系着细黑领带、戴着圆框眼镜、珍视口袋保护器的时候——是的,甚至女性也是如此——字节,或者称为 音节,由任意数量的比特组成,这取决于系统硬件。我记得使用过 12 位字节的巨型机。我知道有使用 6 位字节的更小、定制的系统。当微型计算机热潮在 20 世纪 80 年代初将这些机器转变为必备的商业计算机时,计算机世界就确定了 8 位字节。
在 C 语言中,8 位字节直接对应于 char 数据类型。尽管你不会找到任何公开承认“字节就是 char”的 C 语言大人物,但这基本上是正确的。(即便如此,请记住,在 C 语言中,数据类型是依赖于实现的。)
计算机处理比字节更大的值,这需要将它们组织成称为words(16 位)、doublewords(32 位)、quadwords(64 位)和double-quad words(128 位)的块。这种单词混乱的混乱在表 9.1 中总结,除了双倍四倍字,因为其值无法放入表格中。
表 9.1 位宽描述和细节
| 位宽 | 描述 | 数据类型 | 值范围(有符号) | 值范围(无符号) |
|---|---|---|---|---|
| 8 | 字节 | char | -128 到 127 | 0 到 255 |
| 16 | 字词 | short | -32,768 到 32,767 | 0 到 65,535 |
| 32 | 双字 | Int | -2,147,483,648 到 2,147,483,647 | 0 到 4,294,967,295 |
| 64 | 四倍字 | long | -9,223,372,036,854,775,808 到 9,233,372,036,854,775,807 | 0 到 18,446,744,073,709,551,615 |
表 9.1 中显示的值与数据块的字宽相关。例如,双字的范围从 -2³¹ 到 2³¹ - 1。如果 C 语言中可用 128 位整数(某些语言扩展提供了它),其有符号值范围从 -2¹²⁷ 到 2¹²⁷ - 1。如果这本书的边距足够宽,我会把具体的值全部写出来。或者——更好的是——如果这本书有一个中心折页,我相信打印出的 2¹²⁷ - 1 的值会对一些程序员有吸引力。
你可以快速拼凑一个 C 程序来揭示各种数据类型及其位宽的值。为此,你需要知道每种数据类型的大小。例如,使用这个表达式来获取字节的位数:
unsigned long byte = sizeof(char)*8;
sizeof运算符返回先前描述的 C 语言数据类型char使用的字节数。这个值乘以八以获得位数。结果存储在无符号长变量 byte 中;sizeof运算符返回无符号long值。类似的语句用于 word/short、doubleword/int和 quadword/long变量和数据类型。
使用这个printf()语句来输出值:
printf("%11s %2lu bits %21.f\n",
"Byte",
byte,
pow(2,byte)
);
printf()函数的格式字符串确保输出的值被适当地间隔,并格式化为表格。每个数据类型都有几个输出细节的语句,结果是一个列出数据大小、位数以及十进制大小值的表格。pow()函数将 2 的位数提升到 pow(2,byte)。pow()函数需要包含 math.h 头文件。
包含所有printf()语句以输出数据类型表的源代码文件 byte_sizes.c 可在本书的在线存档中找到。它需要你链接数学库:在 Linux 中,确保你在编译器的最终命令行选项中指定-lm 开关以链接数学(m)库。以下是示例输出:
Byte 8 bits 256
Word 16 bits 65536
Doubleword 32 bits 4294967296
Quadword 64 bits 18446744073709551616
你不需要执行 byte_sizes.c 代码中使用的数学和开销。原因是编译器本身有一个限制。具体来说,限制值被设置为在相应命名的 limits.h 头文件中定义的常量。
下面的列表输出了在 limits.h 中定义的常用常量。请在您的系统上运行此代码以查看值和范围,尽管对于大多数程序员来说,这些值与表 9.1 中显示的值一致。代码的关键在于识别 limits.h 中定义的常量。这些定义的常量如下所示。
列表 9.1 limits.c 的源代码
#include <stdio.h>
#include <limits.h>
int main()
{
printf("Char:\n");
printf("\tNumber of bits: %d\n",CHAR_BIT); ❶
printf("\tSigned minimum: %d\n",SCHAR_MIN); ❷
printf("\tSigned maximum: %d\n",SCHAR_MAX); ❸
printf("\tUnsigned max: %d\n",UCHAR_MAX); ❹
printf("Short:\n");
printf("\tSigned minimum: %d\n",SHRT_MIN);
printf("\tSigned maximum: %d\n",SHRT_MAX);
>printf("\tUnsigned max: %d\n",USHRT_MAX);
printf("Int:\n");
printf("\tSigned minimum: %d\n",INT_MIN);
printf("\tSigned maximum: %d\n",INT_MAX);
printf("\tUnsigned max: %u\n",UINT_MAX); ❺
printf("Long:\n");
printf("\tSigned minimum: %ld\n",LONG_MIN); ❻
printf("\tSigned maximum: %ld\n",LONG_MAX);
printf("\tUnsigned max: %lu\n",ULONG_MAX); ❼
printf("Long long:\n");
printf("\tSigned minimum: %lld\n",LLONG_MIN); ❽
printf("\tSigned maximum: %lld\n",LLONG_MAX);
printf("\tUnsigned max: %llu\n",ULLONG_MAX); ❾
return(0);
}
❶ char 类型似乎是唯一具有“BIT”定义常量的类型。
❷ 有符号 char 最大值
❸ 有符号 char 最小值
❹ 无符号 char 最大值;零是最小值。
❺ 无符号整数最大值使用 %u 占位符。
❻ 长整型需要 %ld 占位符。
❼ 无符号长整型需要 %lu 占位符。
❽ 双长整型需要 %lld。
❾ 双无符号长整型的占位符是 %llu。
输出将在这里显示,但练习的重点是,可以从 limits.h 头文件中定义的常量中获取这些最小和最大值;您的代码不需要进行数学计算:
Char:
Number of bits: 8
Signed minimum: -128
Signed maximum: 127
Unsigned max: 255
Short:
Signed minimum: -32768
Signed maximum: 32767
Unsigned max: 65535
Int:
Signed minimum: -2147483648
Signed maximum: 2147483647
Unsigned max: 4294967295
Long:
Signed minimum: -9223372036854775808
Signed maximum: 9223372036854775807
Unsigned max: 18446744073709551615
Long long:
Signed minimum: -9223372036854775808
Signed maximum: 9223372036854775807
Unsigned max: 18446744073709551615
当您需要一个特定大小的整数时,最好使用特定的整数类型变量。以下类型是可用的:
-
int8_t 用于 8 位整数
-
int16_t 用于 16 位整数
-
int32_t 用于 32 位整数
-
int64_t 用于 64 位整数
使用这些类型声明的变量总是具有指示的特定宽度。这些 typedef 值(即 _t 后缀表示的含义)在 stdint.h 头文件中定义,对于大多数 C 编译器,该头文件会自动包含在 stdio.h 中。因此,请随意使用这些数据类型定义来利用特定宽度的整数值。
这些精确的整数宽度类型的原因是历史性的。当我最初学习编程 C 语言时,int 数据类型是 16 位宽。如今,它是 32 位。然而,int16_t 和 int32_t 类型总是设置为指示的宽度。
考虑到整数宽度的多样性,字节仍然是计算机中的基本计数单位。内存容量、媒体存储、文件大小——所有这些量都是以 8 位字节、char 值来衡量的。这个标准产生了两种字节计数系统:一种基于 2 的幂(二进制),另一种基于 10 的幂(十进制)。
计算字节的传统方式,我成长过程中所熟悉的方式,是千字节系统:当我还是一个准极客时,1 K 是 1 千字节的数据,或 1024 字节。值 1,024 是 2¹⁰,这似乎对计算机极客来说足够好了;1,024 对于数字会计目的来说足够接近 1,000,额外的 24 字节通常被政府以税收的形式拿走。当时告诉初学者 1 K 是“大约 1,000 字节”是合适的。唉,这种逻辑的二元定义现在不再适用了。
今天,1,024 字节被称为千字节。如果您提到千字节,专家现在声称这个值是 1,000 字节。
-
千字节(KB)是 1,000 字节。
-
千字节(KiB)是 1,024 字节。
变化原因在于,术语kilo、mega、giga等在描述非计算机世界的数量时,确实意味着一千、一百万和十亿。为了保持一致,我们的数字统治者规定,术语kilobyte也必须恰好意味着 1000 字节。传统的 1,024 字节,或 2¹⁰,被降级为愚蠢的术语kibibyte,听起来像狗粮。
其他让我烦恼的值包括 2²⁰或 1,048,576 的 mebibyte (MiB)和 2³⁰或 1,073,741,824 的 gibibyte (GiB)。对我来说,这些仍然是 megabyte (MB)和 gigabyte (GB)。其他都是愚蠢的、盲目从众的疯狂行为。
9.1.2 输出字节值
忘掉那些犹豫不决的 C 语言统治者,在本节中接受一个字节的大小与字符相同。当你分配 1 K 的内存时,你正在为单个块预留 1,024(是的)个char大小的内存块。输出 0 到 255 范围内的值,你就是在输出一个字节。要处理内存,你处理字节,char大小的块。这些信息很常见;全世界的技术爱好者都接受这一点。
练习 9.1
编写代码输出从 0 到 255 的char值。每个值单独占一行。这个练习可能看起来毫无意义地简单,但我强烈建议你尝试一下。来吧!这只需要几行代码。将你的解决方案保存为 byte_values01.c。
这里是我的解决方案的输出,中间省略了一大段数字:
0
1
2
3
...
253
254
255
在查看我的解决方案之前,你尝试了for循环吗?你首先尝试使用char变量,然后尝试使用unsigned char吗?你使用一种一开始看起来不明显的技术强制输出吗?
严肃地说:如果你还没有尝试编写解决方案,现在就试试。
我的解决方案将在下一部分列出。它使用了一个无限循环的while,精心构造以确保当变量 a 的值等于 255 时循环终止。
列表 9.2 byte_values01.c 的源代码
#include <stdio.h>
int main()
{
unsigned char a; ❶
a = 0;
while( 1 ) ❷
{
printf("%d\n",a); ❸
if( a==255 ) ❹
break;
a++; ❺
}
return(0);
}
❶ unsigned char的范围是从 0 到 255。
❷ 无限循环
❸ 输出值
❹ 当值达到 255 时,中断循环
❺ 否则,增加变量 a 的值
列表 9.2 中显示的解决方案不是我的第一次尝试。不,就像你(如果你实际上完成了练习)一样,我从for循环开始:
for( a=0; a<=255; a++ )
printf("%d\n",a);
这个for循环永远不会终止。编译器可能会警告,但循环没有结束。尽管unsigned char的最大值是 255,看起来条件似乎已经满足,但它永远不会:变量 a 的值会从 255 不断回绕到 0。
此外,如果你想检查字节、输出它们或以其他方式处理它们的值,你必须使用int数据类型。所有char或字节数值都容易适应整数大小的块。int 数据类型避免了char可能发生的任何回绕,这可能是像getchar()和putchar()这样的函数使用整数而不是char类型的原因之一。
下一个列表通过将 256 个字符值存储在字符数组 data[]中来修改列表 9.2 中的源代码。两个 for 循环处理数组,第一个用于填充它,第二个用于输出其值。尽管数组只存储字节值,但 int 变量 b 用于存储这些值。
列表 9.3 byte_values02.c 的源代码
#include <stdio.h>
int main()
{
unsigned char data[256]; ❶
int b;
for( b=0; b<256; b++ ) ❷
data[b] = b;
for( b=0; b<256; b++ ) ❸
printf("%d\n",data[b]);
return(0);
}
❶ 存储所有字节值的空间
❷ 用 0 到 255 的值填充数组
❸ 将数组输出,每个值单独一行
从 byte_values02.c 输出的内容与第一个程序相同,但新的格式,即使用数组存储值,允许对存储的数据进行修改和操作。目标是准确以可读的格式呈现数据。这样做的不优雅术语是dump。
9.1.3 数据导出
Dump 既是名词也是动词,两者都不太令人愉快。以食物准备为例:没有任何一种食物准备会使用到这个词dump。这个术语不够优雅,也很粗俗,而且被丢弃的东西通常被认为是没有用的——除非它是数据。
在数字领域,dump是从一个地方到另一个地方的数据移动。你可能熟悉臭名昭著的术语core dump,这是当你的程序严重出错时,操作系统试图通过将内存和处理器内容导出到打印机来检查数据以确定错误所在的情况。别担心——你不会。
早期计算机爱好者可能还记得screen dump这个术语。它是将屏幕上的所有文本发送到打印机的副本。当 IBM 在他们的第一台 PC 键盘上添加 Print Screen 键时,他们限制了该术语的使用。突然之间,屏幕导出变成了“print screen”,尽管按这个键仍然会将屏幕上的所有文本导出到打印机。
在 C 语言中,要导出数据,你需要将其从一个位置复制到另一个位置。你可以导出内存块,尽管只有程序可以访问的内存。更常见的是,你将文件的内容以十六进制形式输出到屏幕上。程序员可以检查原始数据,并希望从中获得对正在发生的事情的洞察或获取一些其他有用的信息。我在检查文件导出时经历了许多“啊哈!”的时刻。
要从内存中导出数据,你可以修改现有的源代码文件 byte_values01.c。第一个更改是将数据以十六进制形式导出。人类对十进制字节值很熟悉,但 0 到 255 的十六进制值都整齐地打包成两位数序列。此外,大多数极客都认识十六进制值及其二进制等效值。这种关系使得故障排除变得容易。对于非极客参考,表 9.2 列出了十六进制值及其与二进制的关系,还加入了十进制值。
表 9.2 十进制、十六进制和二进制值
| 十进制 | 十六进制 | 二进制 | 十进制 | 十六进制 | 二进制 |
|---|---|---|---|---|---|
| 0 | 0 | 0000 | 8 | 8 | 1000 |
| 1 | 1 | 0001 | 9 | 9 | 1001 |
| 2 | 2 | 0010 | 10 | A | 1010 |
| 3 | 3 | 0011 | 11 | B | 1011 |
| 4 | 4 | 0100 | 12 | C | 1100 |
| 5 | 5 | 0101 | 13 | D | 1101 |
| 6 | 6 | 0110 | 14 | E | 1110 |
| 7 | 7 | 0111 | 15 | F | 1111 |
十六进制转储简短且有用。毕竟,一个可能不理解“01001000 01100101 01101100 01101100 01101111 00101100 00100000 01101110 01100101 01110010 01100100 00100001”的极客当然能理解“48 65 6C 6F 2C 20 6E 65 72 64 21”。
要输出十六进制,byte_values02.c 的源代码第 12 行进行了修改:将 %d 占位符替换为 %02X,以输出一个带前导零的 2 位大写十六进制值:
printf("%02X\n",data[b]);
更新后的代码的输出范围从 00 到 FF,涵盖了字节值的全部范围。但它仍然输出在单个列中,这并不高效。
第二个更改在每个字节值输出前填充一个空格,并消除了换行符:
printf(" %02X",data[b]);
为了保持输出整洁,在第二个 for 循环之后添加了一个 putchar() 语句:
putchar('\n');
代码的输出现在全部显示在单个屏幕上,但不够优雅:
00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10 11 12 13 14 15 16 17 18 19 1
A 1B 1C 1D 1E 1F 20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F 30 31 32 33 34
35 36 37 38 39 3A 3B 3C 3D 3E 3F 40 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F
50 51 52 53 54 55 56 57 58 59 5A 5B 5C 5D 5E 5F 60 61 62 63 64 65 66 67 68 69 6
A 6B 6C 6D 6E 6F 70 71 72 73 74 75 76 77 78 79 7A 7B 7C 7D 7E 7F 80 81 82 83 84
85 86 87 88 89 8A 8B 8C 8D 8E 8F 90 91 92 93 94 95 96 97 98 99 9A 9B 9C 9D 9E 9F
A0 A1 A2 A3 A4 A5 A6 A7 A8 A9 AA AB AC AD AE AF B0 B1 B2 B3 B4 B5 B6 B7 B8 B9 B
A BB BC BD BE BF C0 C1 C2 C3 C4 C5 C6 C7 C8 C9 CA CB CC CD CE CF D0 D1 D2 D3 D4
D5 D6 D7 D8 D9 DA DB DC DD DE DF E0 E1 E2 E3 E4 E5 E6 E7 E8 E9 EA EB EC ED EE EF
F0 F1 F2 F3 F4 F5 F6 F7 F8 F9 FA FB FC FD FE FF
为了进一步改进代码,每输出 16 个字节就输出一个换行符,因为 16 是十六进制的一个快乐值。代码第二个 for 循环的以下修改添加了换行符,该换行符结合了最近添加的 putchar() 语句:
for( b=0; b<256; b++ )
{
printf(" %02X",data[b]);
if( (b+1)%16==0 )
putchar('\n');
}
if 测试使用变量 b 的值来确定何时添加换行符。将 b 的值加一(b+1),以避免在第一个值(零)之后出现换行符。否则,每当 b 的值能被 16 整除时,就输出一个换行符。以下是结果:
00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F
20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F
30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F
40 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F
50 51 52 53 54 55 56 57 58 59 5A 5B 5C 5D 5E 5F
60 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F
70 71 72 73 74 75 76 77 78 79 7A 7B 7C 7D 7E 7F
80 81 82 83 84 85 86 87 88 89 8A 8B 8C 8D 8E 8F
90 91 92 93 94 95 96 97 98 99 9A 9B 9C 9D 9E 9F
A0 A1 A2 A3 A4 A5 A6 A7 A8 A9 AA AB AC AD AE AF
B0 B1 B2 B3 B4 B5 B6 B7 B8 B9 BA BB BC BD BE BF
C0 C1 C2 C3 C4 C5 C6 C7 C8 C9 CA CB CC CD CE CF
D0 D1 D2 D3 D4 D5 D6 D7 D8 D9 DA DB DC DD DE DF
E0 E1 E2 E3 E4 E5 E6 E7 E8 E9 EA EB EC ED EE EF
F0 F1 F2 F3 F4 F5 F6 F7 F8 F9 FA FB FC FD FE FF
完整的源代码文件作为 byte_values03.c 在在线仓库中可用。输出效果更好,但仍可进行一些改进。因为数据转储是顺序的,所以很容易看到模式和参考行和列。然而,数据并不总是看起来这么漂亮。
练习 9.2
分两步改进 byte_values03.c 中的代码。首先,添加一个初始列,显示字节值的偏移量。将此值输出为 5 位十六进制数。然后输出 16 字节的行。
其次,添加一个额外的空格来分隔第八列和第九列。这个空格使行和列更容易阅读。
我解决方案的输出,byte_values04.c,如下所示:
00000 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00010 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F
00020 20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F
00030 30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F
00040 40 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F
00050 50 51 52 53 54 55 56 57 58 59 5A 5B 5C 5D 5E 5F
00060 60 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F
00070 70 71 72 73 74 75 76 77 78 79 7A 7B 7C 7D 7E 7F
00080 80 81 82 83 84 85 86 87 88 89 8A 8B 8C 8D 8E 8F
00090 90 91 92 93 94 95 96 97 98 99 9A 9B 9C 9D 9E 9F
000A0 A0 A1 A2 A3 A4 A5 A6 A7 A8 A9 AA AB AC AD AE AF
000B0 B0 B1 B2 B3 B4 B5 B6 B7 B8 B9 BA BB BC BD BE BF
000C0 C0 C1 C2 C3 C4 C5 C6 C7 C8 C9 CA CB CC CD CE CF
000D0 D0 D1 D2 D3 D4 D5 D6 D7 D8 D9 DA DB DC DD DE DF
000E0 E0 E1 E2 E3 E4 E5 E6 E7 E8 E9 EA EB EC ED EE EF
000F0 F0 F1 F2 F3 F4 F5 F6 F7 F8 F9 FA FB FC FD FE FF
完成练习 9.2 后,十六进制显示看起来更好。
最后的改进是添加一个第三列 ASCII,在字节值之后。这个附加信息将可显示的 ASCII 文本的十六进制字节进行交叉引用,为人类快速扫描转储以获取相关信息提供了一个方便的方法。
由于流输出,向输出添加 ASCII 列的过程很复杂。每一行都必须顺序处理:先输出 16 个字节的十六进制值,然后以可打印的 ASCII 字符形式输出相同的字节。为了解决这个问题,我发明了 line_out() 函数,该函数位于源代码文件 byte_values05.c 中,该文件可在在线仓库中找到。
line_out()函数有三个参数,如下所示:表示字节计数的偏移量,数据块长度,以及数据本身作为一个unsigned char指针。大部分代码是从早期的 byte_values04.c 源代码中提取的,尽管变量 a 跟踪循环中的进度,并与数据指针一起使用来获取特定的字节值:(data+a)。这个函数输出一个转储的行,因此它从main()*函数中调用以输出所有数据。
列表 9.4 line_out()函数
void line_out(int offset, int length, unsigned char *data)
{
int a;
printf("%05X ",offset); ❶
for( a=0; a<length; a++ ) ❷
{
printf(" %02X",*(data+a)); ❸
if( (a+1)%8==0 ) ❹
putchar(' ');
}
putchar(' '); ❺
for( a=0; a<length; a++ ) ❻
{
if( *(data+a)>=' ' && *(data+a)<='~' ) ❼
putchar( *(data+a) ); ❽
else
putchar(' '); ❾
}
putchar('\n');
}
❶ 输出偏移值
❷ 第一个循环输出十六进制值。
❸ 十六进制值的计算基于数据的起始位置加上循环值。
❹ 在第八个十六进制字节输出后,为了可读性添加额外的空格
❺ 在十六进制列之后添加另一个空格
❻ 第二个循环输出 ASCII 值(如果有)。
❼ 检查可打印字符范围
❽ 输出一个可打印字符
❾ 否则,输出一个空格
line_out()函数并不完美,我将在后面的部分讨论这个问题,但至少目前它是可行的。以下是一些示例输出:
00000 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00010 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F
00020 20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F !"#$%&'()*+,-./
00030 30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F 0123456789:;<=>?
00040 40 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F @ABCDEFGHIJKLMNO
00050 50 51 52 53 54 55 56 57 58 59 5A 5B 5C 5D 5E 5F PQRSTUVWXYZ[\]^_
00060 60 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F `abcdefghijklmno
00070 70 71 72 73 74 75 76 77 78 79 7A 7B 7C 7D 7E 7F pqrstuvwxyz{|}~
00080 80 81 82 83 84 85 86 87 88 89 8A 8B 8C 8D 8E 8F
00090 90 91 92 93 94 95 96 97 98 99 9A 9B 9C 9D 9E 9F
000A0 A0 A1 A2 A3 A4 A5 A6 A7 A8 A9 AA AB AC AD AE AF
000B0 B0 B1 B2 B3 B4 B5 B6 B7 B8 B9 BA BB BC BD BE BF
000C0 C0 C1 C2 C3 C4 C5 C6 C7 C8 C9 CA CB CC CD CE CF
000D0 D0 D1 D2 D3 D4 D5 D6 D7 D8 D9 DA DB DC DD DE DF
000E0 E0 E1 E2 E3 E4 E5 E6 E7 E8 E9 EA EB EC ED EE EF
000F0 F0 F1 F2 F3 F4 F5 F6 F7 F8 F9 FA FB FC FD FE FF
ASCII 列出现在最右边,反映了中心列中显示的十六进制字节的可打印字符值。不可打印字符以空格形式出现。
练习 9.3
很遗憾,从byte_values系列程序中得到的样本输出是可预测的——从 0x00 到 0xFF 的 256 字节值的一块。为什么不稍微加点料,并用随机值重新填充 data[]缓冲区呢?
将 byte_values05.c 的源代码修改为一个新的源代码文件,byte_values06.c。让main()函数用 256 个随机值填充 data[]数组,每个值在 0 到 255 的范围内。运行程序几次以确认程序正确地解释了存储的字节的十六进制和 ASCII 值。
9.2 转储那个文件!
转储实用程序旨在深入查看文件的数据。好吧,这是一个文件转储实用程序。这个细节是操作系统一眼就能提供的信息。不,你可以从目录列表中得知文件名、大小和日期。文件类型基于文件名扩展名,因此可能会误导。不,唯一深入查看文件并检查其暗藏数据的方法是转储。
Linux 的hexdump实用程序在文件转储任务上表现得相当出色。所以,这一章就结束了。
严肃地说,使用这个实用程序并不能帮助你学习如何编写自己的文件实用程序,按照你喜欢的定制方式。我称这个新实用程序为dumpfile。它的工作方式与hexdump类似,但按照我的喜好来操作。
9.2.1 读取文件数据
可以将 dumpfile 实用程序编写为过滤器,就像 Linux 的 hexdump 一样。作为一个过滤器,hexdump 会处理所有输入,无论它来自文件还是来自某个程序的输出。如果你对这样的任务感兴趣,请查看第四章以获取有关 Linux 环境中过滤器的信息。你可以将本章中提供的 dumpfile 代码作为过滤器进行修改,尽管我更希望 dumpfile 作为传统的命令行实用程序工作。
从文件中读取数据的实用程序使用两种方法。第一种是在命令提示符中指定文件名——通常作为第一个(并且往往是唯一)参数。第二种方法是在实用程序启动后提示输入文件名,或者如果命令行参数中缺少文件名,则提示输入文件名。目前,我假设文件名参数作为命令行参数提供。因此,实用程序必须检查是否存在这样的参数。这种确认需要 main() 函数指定并使用其参数:
int main(int argc, char *argv[])
argc 的值始终至少为 1,这是程序的文件名。如果用户输入了任何参数,argc 的值将大于 1。程序首先确认是否存在参数。如果没有,则会向标准错误设备(stderr)发送警告消息,并终止程序:
if( argc<2 )
{
fprintf(stderr,"Format: dumpfile filename\n");
exit(1);
}
exit() 函数需要包含 stdlib.h 头文件。否则,你可以在代码的这一部分使用 return(1) 来退出 main() 函数。我更喜欢使用 exit(),因为它可以在任何函数中用来终止程序,并且它与 atexit() 或 on_exit() 等其他函数相关联,这使得使用 exit() 相比于 return 关键字具有战略优势。此外,它也更短,更容易输入。
在确认参数数量后,argv[1] 中持有的字符串被用于 fopen() 函数来读取文件数据。这一步不仅打开了文件,而且在成功的情况下,还会确定文件是否存在。我使用 char 指针 filename 来引用 argv[1] 中的字符串,这有助于提高可读性:
filename = argv[1];
fp = fopen(filename,"r");
if( fp==NULL )
{
fprintf(stderr,"Unable to open file '%s'\n",filename);
exit(1);
}
我处理文件数据的第一选择是使用 fgets() 函数一次读取 16 个字节;16 是输出行中十六进制字节的数量。但如果我想直接使用现有的 line_out() 函数,就不能让数据中的第 16 个字节是空字符。这个字节是 fgets() 函数添加到它所读取的缓冲区中的,除非首先遇到换行符。
我的第二个选择是使用 fread(). 虽然 fgets() 是一个字符串读取函数,但 fread() 会以给定的块大小消耗数据。它可以轻松地将 16 字节的缓冲区填满原始数据,这正是我所需要的。即便如此,我还是选择了使用 fgetc() 函数,它一次读取一个字符。将这个函数放在 while 循环中,它会吞噬字符,将它们添加到 16 字节的缓冲区中,并在遇到文件结束符(EOF)时处理该条件。
以下列表显示了源代码文件 dumpfile01.c 中 main() 函数的核心。while 循环重复,直到找到文件指针 fp 的文件结束(EOF)。从文件中获取字节值 ch,并立即检查 EOF 标记。检测到 EOF 后,测试变量 index 的值是否为零,这意味着缓冲区仍有数据要打印。如果是这样,则调用 line_out() 函数。否则,文件仍有数据要读取,并将字符 ch 存储在缓冲区中。一旦缓冲区满(index==length),则调用 line_out() 函数。完整的代码可以在在线存储库中找到,作为 dumpfile01.c。
列表 9.5 来自 dumpfile01.c 的字符读取循环
while( !feof(fp) ) ❶
{
ch = fgetc(fp); ❷
if( ch==EOF ) ❸
{
if( index != 0 ) ❹
line_out(offset,index,buffer); ❺
break; ❻
}
buffer[index] = ch; ❼
index++; ❽
if( index==length ) ❾
{
line_out(offset,length,buffer); ❿
offset+=length; ⓫
index = 0; ⓬
}
}
❶ 循环直到遇到文件结束
❷ 获取一个字符
❸ 立即检查文件结束
❹ 如果索引为零,缓冲区为空;否则 . . .
❺ . . . 输出十六进制转储的最后一行。
❻ 终止循环
❼ 存储字符
❽ 增加缓冲区中的索引
❾ 如果缓冲区已满,则输出一行十六进制转储
❿ 输出行
⓫ 调整偏移量
⓬ 将索引重置为开始读取下一个长度(16)字节
读取文件中的所有字节后,while 循环终止,文件被关闭,程序结束。
我创建了一个测试数据文件 bytes.dat 以供读取。它可在在线存储库中找到,与本章的所有源代码文件一起。此文件包含从 0x00 到 0xFF 的顺序字节值,我使用这些值来测试和调试从 dumpfile01.c 源代码文件创建的程序。以下是一些示例输出:
00000 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00010 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F
00020 20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F !"#$%&'()*+,-./
00030 30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F 0123456789:;<=>?
00040 40 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F @ABCDEFGHIJKLMNO
00050 50 51 52 53 54 55 56 57 58 59 5A 5B 5C 5D 5E 5F PQRSTUVWXYZ[\]^_
00060 60 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F `abcdefghijklmno
00070 70 71 72 73 74 75 76 77 78 79 7A 7B 7C 7D 7E 7F pqrstuvwxyz{|}~
00080 80 81 82 83 84 85 86 87 88 89 8A 8B 8C 8D 8E 8F
00090 90 91 92 93 94 95 96 97 98 99 9A 9B 9C 9D 9E 9F
000A0 A0 A1 A2 A3 A4 A5 A6 A7 A8 A9 AA AB AC AD AE AF
000B0 B0 B1 B2 B3 B4 B5 B6 B7 B8 B9 BA BB BC BD BE BF
000C0 C0 C1 C2 C3 C4 C5 C6 C7 C8 C9 CA CB CC CD CE CF
000D0 D0 D1 D2 D3 D4 D5 D6 D7 D8 D9 DA DB DC DD DE DF
000E0 E0 E1 E2 E3 E4 E5 E6 E7 E8 E9 EA EB EC ED EE EF
000F0 F0 F1 F2 F3 F4 F5 F6 F7 F8 F9 FA FB FC FD FE FF
9.2.2 修复不均匀输出
在数字领域中,只有少数文件的大小是 16 的倍数。对于这些文件,dumpfile 程序工作得非常完美。确实,程序可以处理从任何大小的文件中读取字节,但当文件大小不是 16 的精确倍数时,它会对输出产生丑陋的影响。
这里你可以看到 dumpfile 工具输出的末尾应用于莎士比亚的 第 18 首十四行诗:
....
00230 72 65 61 74 68 65 20 6F 72 20 65 79 65 73 20 63 reathe or eyes c
00240 61 6E 20 73 65 65 2C 0A 53 6F 20 6C 6F 6E 67 20 an see, So long
00250 6C 69 76 65 73 20 74 68 69 73 2C 20 61 6E 64 20 lives this, and
00260 74 68 69 73 20 67 69 76 65 73 20 6C 69 66 65 20 this gives life
00270 74 6F 20 74 68 65 65 2E 0A to thee.
在偏移量 0x00270(最后一行),你可以看到文件的最后一个字节,0A,紧接着是行的 ASCII 列。文本“to thee”在它应该对齐的地方左侧好几个空格——如果文件正好在 16 字节边界结束。
为了解决这个问题,必须修改 line_out() 函数。它必须知道当一行输出不匹配默认的 16 字节输出长度时。说到这里,到目前为止展示的所有代码中,输出宽度始终是 16 字节。在 main() 函数中将此值指定为常量:
const int length = 16;
在此处定义的常量值仅在 main() 函数内部可见。因为这个值现在也与 line_out() 函数相关,所以我将其重新定义为常量。以下预处理指令创建了它:
#define SIZE 16
这个更改可以在更新的源代码文件 dumpfile02.c 中找到。
在下一个列表中,你可以看到如何使用定义的常量 SIZE 在 *line_out() 函数中,以帮助测试输出最后行是否短于 16 字节。此更改需要在两个现有的 *for 循环之间添加一个 if 语句。if 决策有助于平衡最后输出行的剩余部分,以便 ASCII 列对齐。
列表 9.6 更新 *line_out() 函数以处理短的最后行
if( length<SIZE ) ❶
{
for( ; a<SIZE; a++ ) ❷
{
printf(" "); ❸
if( (a+1)%8==0 ) ❹
putchar(' ');
}
}
❶ 如果行中少于 SIZE (16) 字节 . . .
❷ 使用变量 a 继续循环
❸ 输出三个空格
❹ 在第 8 个和第 16 个字节后添加一个额外的空格
*for 循环(参见图表 9.6)缺少初始化条件,因为它只是继续使用变量 a 的当前值,就像它离开前面的循环一样。循环输出一组三个空格以平衡任何缺失的十六进制字节值。if( (a+1)%8==0 ) 测试考虑了每八字节后添加的额外空格,这分隔了两个十六进制列。
完整的源代码可在仓库中找到,作为 dumpfile02.c。以下是使用之前使用的相同文件,但改进后的代码的输出:
...
00230 72 65 61 74 68 65 20 6F 72 20 65 79 65 73 20 63 reathe or eyes c
00240 61 6E 20 73 65 65 2C 0A 53 6F 20 6C 6F 6E 67 20 an see, So long
00250 6C 69 76 65 73 20 74 68 69 73 2C 20 61 6E 64 20 lives this, and
00260 74 68 69 73 20 67 69 76 65 73 20 6C 69 66 65 20 this gives life
00270 74 6F 20 74 68 65 65 2E 0A to thee.
练习 9.4
编程是否永远完成?为了进一步更新 dumpfile02.c 的源代码,修改 *main() 函数,以便如果缺少文件名参数,程序会提示输入。
重要的是你的代码能够识别用户只是按下 Enter 键或以其他方式忽略文件名提示。程序尝试打开一个 NULL 字符串文件名是没有意义的。除了这个要求之外,你不需要对文件名进行其他验证,因为 *fopen() 语句会自动这样做。我的解决方案可在在线仓库中找到,作为 dumpfile03.c。
9.3 命令行选项
你可以为 *dumpfile 程序添加什么?首先,简化的输出如何,只显示十六进制字节?或者对于老用户,添加一个选项来以八进制(基数 8)显示字节如何?你可能还能想到更多要添加的功能,比如彩色编码输出?显然,这样的复杂性需要帮助系统提供一些文档。哦,我可以继续说!
作为命令行实用程序,选项和功能由 *switches 控制——激活、停用或指定数量和限制的附加命令行参数。在 Linux 中,这些开关的格式为:-a,其中字母前面有一个连字符或短横线。(Windows 使用斜杠字符 (/),这是微软多年前做出的一个愚蠢的决定,在比尔·盖茨有资格投票之前。)
在 Linux 中,你可以指定多个开关:
dumpfile -a -b -c
这些可以一起使用:
dumpfile -abc
一些开关可以有选项:
dumpfile -q:5
你可以通过测试和循环来劳作,检查开关。或者,你可以利用一个方便的 C 库特性:*getopt() 函数。它帮助你的程序处理开关,这样你就不必编写代码。
9.3.1 使用 getopt() 函数
getopt() 函数帮助您的代码处理命令行开关。我确信它被几乎所有现有的 Linux 命令行工具使用,包括来自多元宇宙的几个工具。以下是它的 man 页面格式:
int getopt(int argc, char * const argv[], const char *optstring);
前两个参数与 main() 函数的 argc 和 *argv[] 参数相同。最后一个参数,optstring,是有效开关字符的列表。例如:
getopt(argc,argv,"abc");
这里有效的开关是 -a、-b 和 -c。函数被反复调用,每次返回一个有效字符的 ASCII 码(一个 int 值),未知选项的字符 '?',或者当函数耗尽所有命令行选项时返回 -1。
伴随的 getopt_long() 函数处理完整的单词开关,尽管在本章中我只探索了 getopt() 函数来处理传统的、单字符的开关。
getopt() 和 getopt_long() 都要求在您的代码中包含 unistd.h 头文件。
列表 9.7 显示了在将 getopt() 函数添加到我的 dumpfile 代码之前,我用作测试的代码。全局变量 opterr 设置为零,以确保 getopt() 不输出其自己的错误消息。getopt() 函数本身位于 while 循环的条件中。函数的返回值与 -1 进行比较,表示已检查所有命令行参数,这会停止循环。否则,变量 r 中返回的值在 switch-case 结构中使用,以指示设置了哪个选项。这种设置通常是 getopt() 函数的实现方式。
列表 9.7 options01.c 的源代码
#include <stdio.h>
#include <unistd.h> ❶
int main(int argc, char *argv[])
{
int r;
opterr = 0; ❷
while( (r=getopt(argc,argv,"abc")) != -1 ) ❸
{
switch(r) ❹
{
case 'a': ❺
puts("alfa option set");
break;
case 'b':
puts("bravo option set");
break;
case 'c':
puts("charlie option set");
break;
case '?': ❻
printf("Switch '%c' is invalid\n",optopt);
break;
default: ❼
puts("Unknown option");
}
}
return(0);
}
❶ 需要 unistd.h 头文件才能使用 getopt() 函数。
❷ 抑制 getopt() 的错误输出
❸ 扫描参数,重复循环直到处理完所有参数
❹ 检查返回的字符
❺ case 语句检查每个有效选项字母。
❻ 对于未知/无效选项,返回一个问号。
❼ 我怀疑 default 条件永远不会满足。
当测试从 options01.c 源代码构建的程序时,乐趣开始了。首先,尝试不使用任何选项:
$ ./options
没有生成输出。很好。
所有选项都在这里指定:
$ ./options -a -b -c
alfa option set
bravo option set
charlie option set
它们可以按任何顺序指定:
$ ./options -c -a -b
charlie option set
alfa option set
bravo option set
或者一个单独的配对,但紧凑在一起:
$ ./options -cb
charlie option set
bravo option set
getopt() 函数允许您以这种方式读取选项,而不必自己编写复杂的比较和处理代码。当然,到目前为止的代码对选项没有任何操作。下一步是添加表示选项尝试完成的开关的变量。
在我从 options01.c 更新到 options02.c 的过程中,我添加了三个 int 变量:alfa、bravo 和 charlie。每个变量都在 while 循环中的 getopt() 语句之前初始化:
alfa = 0;
bravo = 0;
charlie = 0;
在 switch-case 结构中,移除 puts() 语句,并用将变量值设置为 1(TRUE)的语句替换它们,以表示激活状态:
alfa = 1;
接下来,在 while 循环之后,添加一系列 if 语句以输出结果:
if( alfa ) puts("alfa option set");
if( bravo ) puts("bravo option set");
if( charlie ) puts("charlie option set");
if( alfa+bravo+charlie==0 ) puts("No options set");
最后的 if 语句在未设置任何选项时显示一条消息。
选项 02.c 的源代码可以在本书的在线仓库中找到。以下是一些示例运行结果:
$ ./options
No options set
因为可以在新代码中检查开关,所以很容易识别缺少选项的情况。
设置所有选项的输出与代码的第一个版本相同:
$ ./options -a -b -c
alfa option set
bravo option set
charlie option set
对于开关的剩余变体,其输出与原始程序相同。区别在于程序现在能够识别设置并检查变量以执行所需的任何魔法操作。
9.3.2 更新转储文件程序代码
要向实用程序添加命令行选项,你必须知道这些选项的作用。然后你使用像getopt()这样的函数来扫描和设置选项。最后,必须在代码中实现这些选项。
对于dumpfile程序,以下是我提供的选项:
-
-a 用于缩写输出
-
-o 用于八进制输出
-
-h 用于帮助
这些开关可以像之前展示的那样,通过options系列源代码文件进行处理。然而,对于dumpfile程序,第一个参数是一个文件名。实际上,它必须是一个文件名:为了帮助处理命令行开关,如果缺少文件名(如果你完成了 9.4 练习),程序将不再提示输入文件名。此外,文件名必须始终是第一个参数,argv[1]。技术上讲,它是第二个参数,因为程序文件名是第一个或 argv[0]。
添加和处理参数的第一步是修改main()函数。如果在 9.4 练习中添加了提示缺少文件名的步骤,现在应该移除。代码被优化,假设第一个参数是文件名。在main()函数中的 while 循环之前添加以下语句:
if( argc<2 )
{
puts("Format: dumpfile filename [options]");
exit(1);
}
如果程序通过了这个if测试,接下来的新代码块将检查是否指定了-h “帮助”开关。如果没有,程序可能会尝试打开文件-h。因此,对-h 作为第一个参数进行了快速比较。如果找到,将调用help()函数:
filename = argv[1];
if( strcmp(filename,"-h")==0 )
help();
因为程序假设第一个参数是文件名,所以即使你在代码的其他地方使用getopt()函数查找-h 开关,这一步也是必要的。实际上,如果getopt()函数不可用,这种比较方式就是测试开关的方法。如果-h 开关是第一个参数,将调用help()函数并输出有用的文本。程序结束。否则,程序可以继续测试选项。
要处理剩余的开关,我使用一个单独的int变量 options。这个变量是在外部声明的——一个全局变量,它允许所有函数访问其值:
int options;
与 options 系列程序一样,在 dumpfile 的更新代码中,三个有效的开关——-a, -o 和 -h——都在 while 循环中进行了测试,如下所示。我仅使用一个外部整数变量 options 来跟踪设置,它被初始化为零,以及其他在 main() 函数中其他地方使用的变量。对于两个开关,宏会改变变量 options 的值:set_abbr() 用于 -a 和 set_oct() 用于 -o。如果指定了帮助开关,则调用 help() 函数,输出文本并终止程序。
列表 9.8 在 dumpfile04.c 中的 main() 函数内的 while 循环
offset = index = options = 0; ❶
while( (r=getopt(argc,argv,"aosh")) != -1 ) ❷
{
switch(r)
{
case 'a':
set_abbr(); ❸
break;
case 'o':
set_oct(); ❹
break;
case 'h':
help(); ❺
case '?':
printf("Switch '%c' is invalid\n",optopt);
break;
default:
puts("Unknown option");
}
}
❶ 在 main() 函数中其他地方使用 offset 和 index 变量。
❷ 有效的开关有 a, o, s 和 h。
❸ 对于 -a 开关,set_abbr() 宏修改变量 options。
❹ 对于 -o 开关,set_oct() 宏修改变量 options。
❺ 对于 -h,调用 help() 函数并退出程序。
通过使变量 options 成为外部变量,line_out() 函数无需修改。否则,我必须向列表中添加另一个参数,一个用于接受变量 options 以检查命令行开关的参数。只有一个变量 options 还可以避免向 line_out() 函数添加更多参数。它的声明最终会变得混乱。不,这种情况是那些罕见的情况下全局变量是解决问题的有效解决方案之一。
宏 set_abbr() 和 set_oct() 允许代码通过设置特定位来修改变量 options。每个设置宏都有一个配套的 test 宏,可以在 line_out() 函数中使用。当选项被设置时,测试宏返回 TRUE (1),这使得宏可以用作 if 条件来激活功能。
下一个列表显示了在源代码文件开头定义的宏。首先,声明了 options 变量,然后为选项、ABBR 和 OCT 分配了二进制值。最后,定义了设置和 test 宏,使用位逻辑来设置和评估变量 options 中的位。
列表 9.9 创建宏来修改和测试变量 options
int options; ❶
#define SIZE 16 ❷
#define ABBR 1 ❸
#define OCT 2 ❹
#define set_abbr() options|=ABBR ❺
#define test_abbr() ((options&ABBR)==ABBR) ❻
#define set_oct() options|=OCT ❼
#define test_oct() ((options&OCT)==OCT) ❽
❶ 在使用之前声明外部变量
❷ 大小值在代码的其他地方使用,每行 16 字节。
❸ 缩写状态是位 1。
❹ 八进制输出状态是位 2。
❺ 使用位逻辑或来设置变量 options 中的位 1 (ABBR)
❻ 使用位逻辑与来测试变量 options 中的位 1 (ABBR)
❼ 使用位逻辑或来设置变量 options 中的位 2 (OCT)
❽ 使用位逻辑与来测试变量 options 中的位 2 (OCT)
定义常量 ABBR 和 OCT 代表变量 options 中的位位置,它们不重叠。每个位都可以设置或检查而不改变其他位。这种方法允许以相同的方式添加更多选项,直到 int 变量的完整位宽。
宏增加了可读性,但更重要的是,通过创建宏,我使代码更新更容易。例如,更改选项只需在一个位置进行,而不是在代码中到处查找引用。
修改后的dumpfile程序的完整代码可在在线仓库中找到,作为 dumpfile04.c。我还没有讨论的是help()函数。它在这里展示。接下来的几节将涵盖实现-a 和-o 选项所需的代码。
列表 9.10 dumpfile04.c 中的help()函数
void help(void)
{
puts("dumpfile - output a file's raw data");
puts("Format: dumpfile filename [options]");
puts("Options:");
puts("-a abbreviated output ");
puts("-o output octal instead of hex");
puts("-h display this text");
exit(1);
}
9.3.3 设置缩略输出
dumpfile程序当前的输出对于想要检查文件中字节的极客来说很好。它显示了偏移量列、十六进制字节以及 ASCII 码的字符表示。这种展示方式是我所偏好的,尽管有时只需要简单的字节转储。为了实现这个目标,用户可以指定-a 开关以获得缩略程序输出。
-a 选项背后的机制已经在 dumpfile04.c 源代码中存在。所需做的只是实现代码的开关部分:在缩略输出激活时,line_out()函数中的一些项被抑制。对于这些项,添加了一个带有test_abbr()宏作为条件的 if 语句。结果是,只有当没有指定-a 选项时,才会激活输出的一部分。
必须在line_out()函数的三个地方进行修改。第一个是为初始列,它输出偏移量。只有当test_abbr()宏返回零时,才会执行printf()语句。使用非操作符(!)来否定宏:
if( !test_abbr() )
{
printf("%05X ",offset);
}
如果指定了-a 选项,则跳过printf()语句。否则,对于正常输出,它会被执行。
接下来,在输出十六进制字节的for循环中,现有的代码为了可读性添加了额外的空格:
if( (a+1)%8==0 )
putchar(' ');
这个空格对于纯十六进制转储是不需要的。同样,test_abbr()宏被添加到 if 条件中,以在指定-a 选项时禁用空格输出。以下是修改内容:
if( (a+1)%8==0 && !test_abbr() )
putchar(' ');
当行长度小于 LENGTH 常量时,会添加另一个空格。这个语句(在 dumpfile05.c 源代码文件的 37 行)不需要修改,因为 ASCII 列也被抑制了。
最后,需要修改的line_out()函数的最后一部分是输出 ASCII 列的for循环。这段代码被一个类似于第一列的if测试包围:
if( !test_abbr() )
{
putchar(' ');
for( a=0; a<length; a++ )
{
if( *(data+a)>=' ' && *(data+a)<='~' )
putchar( *(data+a) );
else
putchar(' ');
}
}
每次使用test_abbr()宏时,它前面都会加上非操作符(!)。这可能会让你考虑重新编写宏,使其评估结果相反。虽然我可以这样做,但我选择与两个宏保持一致,当开关激活时,它们返回 1。
添加-a 开关的完整源代码可在在线仓库中找到,作为 dumpfile05.c。以下是对 bytes.dat 文件的示例运行,该文件包含从 0 到 255 的顺序值:
$ ./dumpfile bytes.dat -a
00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F
20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F
30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F
40 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F
50 51 52 53 54 55 56 57 58 59 5A 5B 5C 5D 5E 5F
60 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F
70 71 72 73 74 75 76 77 78 79 7A 7B 7C 7D 7E 7F
80 81 82 83 84 85 86 87 88 89 8A 8B 8C 8D 8E 8F
90 91 92 93 94 95 96 97 98 99 9A 9B 9C 9D 9E 9F
A0 A1 A2 A3 A4 A5 A6 A7 A8 A9 AA AB AC AD AE AF
B0 B1 B2 B3 B4 B5 B6 B7 B8 B9 BA BB BC BD BE BF
C0 C1 C2 C3 C4 C5 C6 C7 C8 C9 CA CB CC CD CE CF
D0 D1 D2 D3 D4 D5 D6 D7 D8 D9 DA DB DC DD DE DF
E0 E1 E2 E3 E4 E5 E6 E7 E8 E9 EA EB EC ED EE EF
F0 F1 F2 F3 F4 F5 F6 F7 F8 F9 FA FB FC FD FE FF
9.3.4 激活八进制输出
与年轻的程序员相比,老程序员对八进制的吸引力更大。我正处于一个临界点,在那个年龄,八进制被介绍给我,当时我还是一个年轻的程序员,但我们从未有机会去约会。
八进制是基数为 8 的计数系统,它与三个数据位很好地匹配。在微型计算机时代之前,这种计数基在大型机编程中普遍使用。您仍然可以在 Linux 目录列表的文件权限位中看到八进制的痕迹。八进制计数基如表 9.3 所示。
表 9.3 八进制、十进制和十六进制值
| 八进制 | 十进制 | 十六进制 | 二进制 | 八进制 | 十进制 | 十六进制 | 二进制 |
|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0000 | 10 | 8 | 8 | 1000 |
| 1 | 1 | 1 | 0001 | 11 | 9 | 9 | 1001 |
| 2 | 2 | 2 | 0010 | 12 | 10 | A | 1010 |
| 3 | 3 | 3 | 0011 | 13 | 11 | B | 1011 |
| 4 | 4 | 4 | 0100 | 14 | 12 | C | 1100 |
| 5 | 5 | 5 | 0101 | 15 | 13 | D | 1101 |
| 6 | 6 | 6 | 0110 | 16 | 14 | E | 1110 |
| 7 | 7 | 7 | 0111 | 17 | 15 | F | 1111 |
与许多编程语言一样,C 语言巧妙地处理八进制值。要指定八进制,您使用零前缀:01 是八进制 1,010 是八进制 10(十进制 8),依此类推。您的源代码编辑器可能足够聪明,能够识别八进制值并相应地突出显示。
八进制值的 printf() 和 scanf() 占位符是 %o。像其他占位符一样,它具有宽度值和零填充。
为了满足老用户的需要,我在 dumpfile 程序中添加了一个八进制输出开关。这个开关需要对代码进行几个更新,不仅包括八进制输出,还包括程序输出的间距和对齐。
需要三个更改来激活 -o 开关,将 dumpfile05.c 源代码文件更新到其下一个迭代版本,dumpfile06.c。这些更改中的每一个都在 line_out() 函数中找到。test_oct() 宏用作 if 条件,当指定 -o 开关时返回 TRUE。
当八进制开关处于活动状态时,第一列需要输出八进制值而不是十六进制值。这个决定是在 test_abbr() 宏为真(或假)时输出的列的基础上做出的。一个 if-else 结构处理不同的输出:
if( !test_abbr() )
{
if( test_oct() )
printf("%05o ",offset);
else
printf("%05X ",offset);
}
%05o 占位符将变量 offset 的值以五个字符宽的八进制数输出,左端填充零。
下一个更改发生在输出字节的 fo 循环中。这基本上是相同类型的决策:当 test_oct() 宏返回 TRUE 时,输出八进制值而不是十进制值:
if( test_oct() )
printf(" %03o",*(data+a));
else
printf(" %02X",*(data+a));
占位符 %03o 输出一个宽度为三个八进制数字,左端填充零的八进制值。对输出的影响是,现在每一行的字节宽度比典型的 80 列屏幕要宽。尽管如此,如果用户需要八进制输出,程序会提供。
当输出最后一行小于 16 字节时,进行最后的更改。因为八进制值以三个字符宽输出而不是两个字符宽,所以每个缺失的字节需要四个空格来对齐 ASCII 列:
if( test_oct() )
printf(" ");
else
printf(" ");
这些更改包含在源代码文件 dumpfile06.c 中,可在本书的在线仓库中找到。以下是 dumpfile 程序在 bytes.dat 文件上使用 -a 和 -o 开关指定的输出:
000 001 002 003 004 005 006 007 010 011 012 013 014 015 016 017
020 021 022 023 024 025 026 027 030 031 032 033 034 035 036 037
040 041 042 043 044 045 046 047 050 051 052 053 054 055 056 057
060 061 062 063 064 065 066 067 070 071 072 073 074 075 076 077
100 101 102 103 104 105 106 107 110 111 112 113 114 115 116 117
120 121 122 123 124 125 126 127 130 131 132 133 134 135 136 137
140 141 142 143 144 145 146 147 150 151 152 153 154 155 156 157
160 161 162 163 164 165 166 167 170 171 172 173 174 175 176 177
200 201 202 203 204 205 206 207 210 211 212 213 214 215 216 217
220 221 222 223 224 225 226 227 230 231 232 233 234 235 236 237
240 241 242 243 244 245 246 247 250 251 252 253 254 255 256 257
260 261 262 263 264 265 266 267 270 271 272 273 274 275 276 277
300 301 302 303 304 305 306 307 310 311 312 313 314 315 316 317
320 321 322 323 324 325 326 327 330 331 332 333 334 335 336 337
340 341 342 343 344 345 346 347 350 351 352 353 354 355 356 357
360 361 362 363 364 365 366 367 370 371 372 373 374 375 376 377
仅使用 -o 开关的输出太宽,无法作为文本显示。图 9.1 展示了在 100 列 x 24 行尺寸的终端窗口中输出的外观。

图 9.1 使用 -o 开关的 dumpfile 程序输出有点宽。
练习 9.5
有关在 dumpfile 程序中添加一个额外的开关,您怎么看?-v 开关通常用于输出程序的版本号。我建议将这些值设置为定义的常量:分别设置主版本号和次版本号,或者一个完整的版本号字符串。
将 -v 开关以及代码(version() 函数)添加到输出版本号。程序执行此任务后可以退出。并且请记住,一些用户可能会将 -v 开关作为程序的唯一参数。我对这个练习的解决方案可以在在线仓库中找到,文件名为 dumpfile07.c。
10 目录树
在所有编程任务中,我羞于承认我最喜欢编写文件实用程序。普通用户不知道操作系统提供的关于文件的大量信息。这些信息非常详细,触手可及,渴望被摘取。此外,探索文件和目录可以打开你对计算机存储工作原理的理解。探索这个领域可能会激发你编写自己有趣的文件实用程序。如果不行,你可以继续阅读本章——这是你对文件系统和存储的入门。
这里的目标是创建一个目录树程序。输出显示了子目录在分层文件系统中的位置。除了接触到单词 hierarchical(我竟然能神奇地既拼对又打出来)之外,在本章中你还将学习如何:
-
检查文件信息
-
解码文件模式和权限
-
读取目录条目
-
使用递归来探索目录结构
-
从完整路径名中提取目录名
-
输出目录树
-
避免将单词 hierarchical 与 hieroglyphical 混淆
在深入了解细节之前,请注意,GUI 术语更喜欢使用术语 folder 而不是 directory。作为一名 C 程序员,你必须使用术语 directory,而不是 folder。所有处理文件和目录的 C 函数都使用 directory 或包含缩写“dir。”不要退缩,使用术语 folder。
目录树实用程序的目的在于输出目录结构的地图。地图详细说明了哪些目录是彼此的父目录和子目录。与多年前相比,今天的目录结构充满了许多组织。当涉及到保存文件时,用户更加关注。程序针对这种类型的组织,并提供提示以帮助用户使用子目录概念。
即使目录地图看似微不足道,探索目录树的过程非常适合其他有用的磁盘实用程序。例如,第十一章涵盖了文件查找实用程序,它严重依赖于本章提供的信息,以使实用程序真正有用。
10.1 文件系统
所有媒体存储的核心是文件系统。文件系统描述了数据在媒体上的存储方式,如何访问文件,以及关于文件本身的许多技术细节。
大多数用户唯一一次处理文件系统概念的情况是在格式化媒体时。选择文件系统是格式化过程的一部分,因为它决定了媒体如何格式化以及需要遵循哪些协议。这一步对于兼容性是必要的:并非每个文件系统都与每个计算机操作系统兼容。因此,用户可以选择媒体格式所用的文件系统,以便在操作系统之间进行共享,例如 Linux 和 PC 或 Macintosh。
文件系统的职责是组织存储。它将文件的数据写入媒体的一个或多个位置。这些信息与其他文件详细信息(如文件名、大小、日期(创建、修改、访问)、权限等)一起记录。
一些文件详细信息可以通过现有实用程序或各种 C 库函数轻松获取。但大多数文件系统的机制都是针对在媒体上保存、检索和更新文件数据的。所有这些操作都在操作系统的监督下自动进行。
对于大多数程序员来说,好消息是无需了解文件在媒体上存储的细节。即使你完全成为技术宅,理解各种文件系统之间的细微差别,并在技术聚会上吹嘘高性能文件系统(HPFS)的好处,但操作文件系统所需的媒体访问级别超出了典型 C 程序操作的范围。有函数可用于探索文件的详细信息。这些函数将在下一节中介绍。
除了了解文件系统的名称以及可能的一些关于文件系统工作方式的细节外,如果您好奇,可以使用计算机上的常用工具来查看正在使用哪些文件系统。在 Linux 终端窗口中,使用 man fs 命令查看 Linux 如何使用文件系统以及可用的不同文件系统。/proc/filesystems 目录列出了您的 Linux 安装中可用的文件系统。
Windows 将其文件系统信息隐藏在磁盘管理控制台中。要访问此窗口,请按照以下步骤操作:
-
按下键盘上的 Windows 键以打开开始菜单。
-
输入磁盘管理。
-
从搜索结果列表中,选择创建和格式化硬盘分区。
图 10.1 展示了我的一台 Windows 电脑上的磁盘管理控制台。可用媒体以表格形式呈现,其中文件系统列列出了所使用的文件系统;图中只显示了 NTFS。

图 10.1 磁盘管理控制台显示了 PC 可用媒体所使用的文件系统。
在 Macintosh 上,您可以使用磁盘实用程序浏览可用媒体以了解正在使用哪种文件系统。此应用程序位于实用程序目录中:在 Finder 中,点击“前往”>“实用程序”以查看目录并访问磁盘实用程序应用程序。
如果编写文件系统既容易又必要,我会进一步探讨这个主题。目前,请理解文件系统是计算机中存储在媒体上的数据的宿主。例如,目录树程序使用文件系统,但在 C 语言中,此类实用程序无需了解文件系统类型即可完成其工作。
10.2 文件和目录详细信息
要在命令提示符下收集目录详细信息,请使用 ls 命令。它在所有 shell 中都有,可以追溯到古希腊人使用的第一个史前 Unix 版本,当时该命令被称为λσ。输出是当前目录中文件名的列表:
$ ls
changecwd.c dirtree04.c fileinfo03.c readdir01.c subdir01.c subdir05.c
dirtree01.c extractor.c fileinfo04.c readdir02.c subdir02.c subdir06.c
dirtree02.c fileinfo01.c fileinfo05.c readdir03.c subdir03.c
dirtree03.c fileinfo02.c getcwd.c readdir04.c subdir04.c
要获取更多详细信息,请指定-l(长)开关:
$ ls -l
total 68
-rwxrwxrwx 1 dang dang 292 Oct 31 16:26 changecwd.c
-rwxrwxrwx 1 dang dang 1561 Nov 4 21:14 dirtree01.c
-rwxrwxrwx 1 dang dang 1633 Nov 5 10:39 dirtree02.c
...
此输出显示了每个文件的详细信息,包括权限、所有权、大小、日期以及其他你可以用来吓唬那些电脑文盲朋友的琐事。这不是秘密信息;ls -l 命令输出的详细信息就像数据库一样存储在目录中。实际上,存储介质上的目录真的是数据库。它们的记录不是特定的文件,而是 inode。
inode 不是一个苹果产品。不,它是一组描述文件的数据集合。尽管你的 C 程序不能轻易访问低级文件系统细节,但你可以轻松检查文件 inode 数据。inode 的名称与文件名相同。但除了名称之外,inode 还包含大量关于文件的详细信息。
10.2.1 收集文件信息
要获取文件详细信息以及读取目录,你需要访问 inode 数据。执行此操作的命令行程序称为 stat。以下是在 stat 程序文件 fileinfo 上的部分输出示例:
File: fileinfo
Size: 8464 Blocks: 24 IO Block: 4096 regular file
Device: eh/14d Inode: 11258999068563657 Links: 1
Access: (0777/-rwxrwxrwx) Uid: ( 1000/ dang) Gid: ( 1000/ dang)
Access: 2021-10-23 21:11:17.457919300 -0700
Modify: 2021-10-23 21:11:00.071527400 -0700
Change: 2021-10-23 21:11:00.071527400 -0700
这些详细信息存储在目录数据库中。实际上,输出的一部分显示了文件的 inode 编号:11258999068563657. 当然,fileinfo 这个名字作为参考要容易得多。
要在你的 C 程序中读取相同的信息,你使用 stat() 函数。它在 sys/stat.h 头文件中声明。以下是 man 页面格式:
int stat(const char *pathname, struct stat *statbuf);
路径名是一个文件名或完整路径名。statbuf 参数是 stat 结构的地址。以下是一个典型的 stat()函数语句,其中包含文件名 char 指针、fs 作为 stat 结构,以及 int 变量 r 捕获返回值:
r = stat(filename,&fs);
在失败的情况下,返回值是-1。否则,返回 0,并且 stat 结构 fs 充满有关文件的详细信息——inode 数据。表 10.1 列出了 stat 结构的常见成员,尽管不同的文件系统和操作系统可能会添加或更改特定成员。
表 10.1 stat()函数的 statbuf 结构成员
| 成员 | 数据类型(占位符) | 详细信息 |
|---|---|---|
| st_dev | dev_t (%lu) | 包含文件的媒体(设备)ID |
| st_ino | ino_t (%lu) | Inode 编号 |
| st_mode | mode_t (%u) | 文件类型、模式、权限 |
| st_nlink | nlink_t (%lu) | 链接数量 |
| st_uid | uid_t (%u) | 文件所有者的用户 ID |
| st_gid | gid_t (%u) | 组的用户 ID |
| st_rdev | dev_t (%lu) | 特殊文件类型的设备 ID |
| st_size | off_t (%lu) | 文件大小(字节) |
| st_blksize | blksize_t (%lu) | 文件系统的块大小 |
| st_blocks | blkcnt_t (%lu) | 分配的文件块(512 字节块) |
| st_atime | struct timespec | 文件最后访问时间 |
| st_mtime | struct timespec | 文件最后修改时间 |
| st_ctime | struct timespec | 文件状态最后更改的时间 |
大多数 stat 结构体成员都是整数;我在表 10.1 中指定了printf()占位符类型。它们都是unsigned,尽管一些值是unsigned long。注意long unsigned值,因为编译器会哀叹使用不正确的占位符来表示这些值。
The timespec 结构体作为time_t指针访问。它包含两个成员:tv_sec 和 tv_nsec,分别代表秒和纳秒。稍后将会展示如何使用ctime()函数访问这个结构体的示例。
下面的列表展示了一个示例程序,fileinfo01.c,该程序输出文件(或 inode)的详细信息。stat 结构体的每个成员都会访问作为命令行参数提供的文件。大部分代码都是错误检查——例如,确认是否提供了文件名参数,并检查stat()函数的返回状态。
列表 10.1 文件 info01.c 的源代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <time.h>
int main(int argc, char *argv[]) ❶
{
char *filename;
struct stat fs;
int r;
if( argc<2 ) ❷
{
fprintf(stderr,"Specify a filename\n");
exit(1);
}
filename = argv[1]; ❸
printf("Info for file '%s'\n",filename);
r = stat(filename,&fs); ❹
if( r==-1 ) ❺
{
fprintf(stderr,"Error reading '%s'\n",filename);
exit(1);
}
printf("Media ID: %lu\n",fs.st_dev); ❻
printf("Inode #%lu\n",fs.st_ino); ❻
printf("Type and mode: %u\n",fs.st_mode); ❻
printf("Hard links = %lu\n",fs.st_nlink); ❻
printf("Owner ID: %u\n",fs.st_uid); ❻
printf("Group ID: %u\n",fs.st_gid); ❻
printf("Device ID: %lu\n",fs.st_rdev); ❻
printf("File size %lu bytes\n",fs.st_size); ❻
printf("Block size = %lu\n",fs.st_blksize); ❻
printf("Allocated blocks = %lu\n",fs.st_blocks); ❻
printf("Access: %s",ctime(&fs.st_atime)); ❼
printf("Modification: %s",ctime(&fs.st_mtime)); ❼
printf("Changed: %s",ctime(&fs.st_ctime)); ❼
return(0);
}
❶ 文件名作为程序参数提供。
❷ 确认第一个参数
❸ 使用字符指针 filename 引用参数有助于提高可读性。
❹ 调用stat()函数
❺ 检查错误
❻ 输出 stat 结构体 fs 的成员
❼ 时间结构使用ctime()函数输出它们的值。
fileinfo01.c 程序输出的信息与命令行 stat 实用工具输出的信息相匹配。以下是在同一文件 fileinfo 上运行此代码程序的示例输出:
Info for file 'fileinfo'
Media ID: 14
Inode #7318349394555950
Type and mode: 33279
Hard links = 1
Owner ID: 1000
Group ID: 1000
Device ID: 0
File size 8464 bytes
Block size = 4096
Allocated blocks = 24
Access: Tue Oct 26 15:55:10 2021
Modification: Tue Oct 26 15:55:10 2021
Changed: Tue Oct 26 15:55:10 2021
这些细节与本章前面展示的stat命令的输出相同。stat命令确实会查找设备 ID、所有者 ID 和组 ID 的详细信息,你的代码也可以做到这一点。但有一个有趣的项目是结构成员 st_mode,类型和模式值。上面输出显示的值是 33279。这个整数值包含了很多细节——位字段,你可以在stat命令的输出中看到这些细节的解释。你的代码也可以检查这个值以确定文件类型及其权限。
10.2.2 探索文件类型和权限
检查文件(或 inode)的 st_mode 值是确定文件是普通文件、目录或其他特殊类型文件的方法。记住,在 Linux 环境中,一切都是文件。使用stat()函数是确定 inode 表示哪种类型文件的方法。
stat 结构体中 st_mode 成员的位字段也描述了文件的权限。虽然你可以编写一系列复杂的位运算逻辑操作来找出 st_mode 值位中的具体细节,但我建议你使用 sys/stat.h 头文件中可用的便捷宏。
例如,S_ISREG()宏对于普通文件返回 TRUE。为了更新 fileinfo01.c 代码以测试普通文件,添加以下语句:
printf("Type and mode: %X\n",fs.st_mode);
if( S_ISREG(fs.st_mode) )
printf("%s is a regular file\n",filename);
else
printf("%s is not a regular file\n",filename);
如果 fs.st_mode 变量上的 S_ISREG()测试返回 TRUE,则属于if语句的printf()语句会输出确认文件是常规文件的文本。else条件处理其他类型的文件,例如目录。
在我对代码的更新中,从在线存档中可获得的 fieinfo02.c,我移除了原始代码中的所有printf()语句。前面显示的五个语句替换了原始的 printf()语句,因为这次更新的重点是确定文件类型。以下是关于 fileinfo02.c 源代码文件的示例输出:
Info for file 'fileinfo02.c'
Type and mode: 81FF
Fileinfo02.c is a regular file
如果我指定单个点(.),代表当前目录,我会看到以下输出:
Info for file '.'
Type and mode: 41FF
. is a directory
在上面的输出中,st_mode 值以及 S_ISREG()宏的返回值都会发生变化;目录不是常规文件。实际上,你可以通过使用 S_ISDIR()宏来专门测试目录:
printf("Type and mode: %X\n",fs.st_mode);
if( S_ISREG(fs.st_mode) )
printf("%s is a regular file\n",filename);
else if( S_ISDIR(fs.st_mode) )
printf("%s is a directory\n",filename);
else
printf("%s is some other type of file\n",filename);
我已经对文件 info02.c 中的代码进行了这些修改和添加,改进后的代码保存在 fileinfo03.c 中,可在本书的在线仓库中找到。
可以通过使用文件模式宏的全套,在代码中进行进一步的修改,这些宏列在表 10.2 中。这些是常见的宏,尽管你的 C 编译器和操作系统可能提供更多。使用这些宏通过文件类型来识别文件。
表 10.2 sys/stat.h 中定义的宏,用于帮助确定文件类型
| 宏 | 对此类文件为真 |
|---|---|
| S_ISBLK() | 块特殊设备,例如/dev 目录中的大容量存储 |
| S_ISCHR() | 字符特殊设备,例如管道或/dev/null 设备 |
| S_ISDIR() | 目录 |
| S_ISFIFO() | FIFO(命名管道)或套接字 |
| S_ISREG() | 常规文件 |
| S_ISLNK() | 符号链接 |
| S_ISSOCK() | 套接字 |
文件类型细节并不是 stat 结构体中 st_mode 成员包含的唯一信息。此值还揭示了文件的权限。文件权限是指确定谁可以做什么的访问位。有三个访问位,称为八进制位,是可用的:
-
读取(r)
-
写入(w)
-
执行(x)
读取权限意味着文件以只读方式访问:可以读取文件的数据但不能修改。写入权限允许读取和写入文件。执行权限用于程序文件,例如你的 C 程序(由编译器或链接器自动设置),shell 脚本(手动设置)和目录。这些都是标准的 Linux 内容,所以如果你需要更多信息,可以查找一本关于 Linux 的糟糕书籍以获取详细信息。
在 Linux 中,chmod命令设置和重置文件权限。这些权限可以在使用带有-l(小写的 L)开关的ls命令的文件的长列表中看到:
$ ls -l fileinfo
-rwxrwxrwx 1 dang dang 8464 Oct 26 15:55 fileinfo
第一部分信息,-rwxrwxrwx,表示文件类型和权限,这些在图 10.2 中有详细说明。接下来是硬链接数(1),所有者(dang)和组(dang)。值 8,464 是字节数,然后是日期和时间戳,最后是文件名。

图 10.2 在长目录列表中解码文件权限位
文件使用三组文件权限八进制值。这些集合基于用户分类:
-
拥有者
-
组
-
其他
你是所创建文件的拥有者。作为计算机上的用户,你也是某个组的成员。使用 id 命令查看你的用户名和 ID 号,以及你所属的组(名称和 ID)。查看 /etc/group 文件以查看系统上的完整组列表。
文件所有者授予自己对其文件的完全访问权限。设置组权限是一次性授予多个系统用户访问权限的一种方法。第三个字段,其他,适用于不是所有者或不在指定组中的任何人。
在长目录列表中,文件的所有者和组如前所述显示。此值是从文件的 stat 结构的 st_mode 成员中解释的。与获取文件类型一样,你可以使用 sys/stat.h 头文件中可用的定义常量和宏来测试每个用户分类的权限。
我计算了 sys/stat.h 中可用的九个权限定义常量,这涵盖了每个权限八进制值(三个)和三种权限类型:读、写和执行。这些在表 10.3 中显示。
表 10.3 用于权限的 sys/stat.h 头文件中定义的常量
| 定义常量 | 权限八进制值 |
|---|---|
| S_IRUSR | 拥有者读权限 |
| S_IWUSR | 拥有者写权限 |
| S_IXUSR | 拥有者执行权限 |
| S_IRGRP | 组读权限 |
| S_IWGRP | 组写权限 |
| S_IXGRP | 组执行权限 |
| S_IROTH | 其他读权限 |
| S_IWOTH | 其他写权限 |
| S_IXOTH | 其他执行权限 |
好消息是这些定义的常量遵循一个命名模式:每个定义的常量都以 S_I 开头。I 后面跟着 R、W 或 X,分别代表读、写或执行。这个字母后面跟着 USR、GRP、OTH,分别代表拥有者(用户)、组和其他。这种命名约定总结在图 10.3 中。

图 10.3 sys/stat.h 中定义的权限常量的命名约定
例如,如果你想测试组用户的读权限,你使用 S_IRGRP 定义常量:S_I 加 R 代表读,GRP 代表组。这个定义常量用于与位运算符进行 if 测试,以测试 st_mode 成员上的权限位:
If( fs.st_mode & S_IRGRP )
fs_st_mode(文件的模式,包括类型和权限)中的值与 S_IRGRP 定义常量中的位进行比较。如果测试为真,意味着位被设置,则文件为“其他”组设置了只读权限。
列表 10.2 使用测试宏和定义的常量为命令行参数提供的文件工作。这个更新版本的 fileinfo 系列程序输出指定文件的文件类型和权限。一个 if else-if else 结构处理表 10.2 中列出的不同文件类型。三组 if 测试输出三个不同组的权限。你看到本节中讨论的所有宏和定义的常量都在代码中使用。代码看起来很长,但它包含了很多复制粘贴的信息。
列表 10.2 fileinfo04.c 的源代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <time.h>
int main(int argc, char *argv[])
{
char *filename;
struct stat fs;
int r;
if( argc<2 )
{
fprintf(stderr,"Specify a filename\n");
exit(1);
}
filename = argv[1];
r = stat(filename,&fs);
if( r==-1 )
{
fprintf(stderr,"Error reading '%s'\n",filename);
exit(1);
}
❶
printf("File '%s' is a ",filename);
if( S_ISBLK(fs.st_mode) ) ❷
printf("block special\n");
else if( S_ISCHR(fs.st_mode) )
printf("character special\n");
else if( S_ISDIR(fs.st_mode) )
printf("directory\n");
else if( S_ISFIFO(fs.st_mode) )
printf("named pipe or socket\n");
else if( S_ISREG(fs.st_mode) )
printf("regular file\n");
else if( S_ISLNK(fs.st_mode) )
printf("symbolic link\n");
else if( S_ISSOCK(fs.st_mode) )
printf("socket\n");
else
printf("type unknown\n");
printf("Owner permissions: "); ❸
if( fs.st_mode & S_IRUSR )
printf("read ");
if( fs.st_mode & S_IWUSR )
printf("write ");
if( fs.st_mode & S_IXUSR )
printf("execute");
putchar('\n');
printf("Group permissions: "); ❹
if( fs.st_mode & S_IRGRP )
printf("read ");
if( fs.st_mode & S_IWGRP )
printf("write ");
if( fs.st_mode & S_IXGRP )
printf("execute");
putchar('\n');
printf("Other permissions: "); ❺
if( fs.st_mode & S_IROTH )
printf("read ");
if( fs.st_mode & S_IWOTH )
printf("write ");
if( fs.st_mode & S_IXOTH )
printf("execute");
putchar('\n');
return(0);
}
❶ 新内容从这里开始。
❷ 确定文件类型,一个长的 if-else 结构
❸ 测试所有者权限位
❹ 测试组权限位
❺ 测试其他权限位
我从列表 10.2 中的源代码创建的程序命名为 a.out,这是默认的。以下是原始 fileinfo 程序的示例运行:
$ ./a.out fileinfo
File 'fileinfo' is a regular file
Owner permissions: read write execute
Group permissions: read write execute
Other permissions: read write execute
这里显示的信息对应于 ls -l 列表输出的 -rwxrwxrwx。
这里是系统目录 /etc 的输出:
$ ./a.out /etc
File '/etc' is a directory
Owner permissions: read write execute
Group permissions: read execute
Other permissions: read execute
从这个输出中,文件类型被正确地识别为目录。所有者权限是 rwx(所有者是 root)。组和其它权限是 r-x,这意味着计算机上的任何人都可以读取和访问(执行)目录。
练习 10.1
列表 10.2(fileinfo04.c)中的 if-else 结构包含了很多重复。看到代码中的重复语句,我强烈建议使用一个函数。这个练习的任务是编写一个输出文件权限的函数。
调用函数 permissions_out()。它接受一个 mode_t 参数,该参数是 stat 结构中的 st_mode 成员。以下是原型:
void permissions_out(mode_t stm);
使用该函数输出三个访问级别(所有者、组、其它)的权限字符串。如果设置了位,使用字符 r、w、x 表示读取、写入和执行访问;对于未设置的项目,使用破折号 (-)。这个输出与 ls -l 列表显示的相同,但没有标识文件类型的开头字符。
对于这个函数,存在一个简单的方法,我希望你能找到它。如果找不到,你可以在源代码文件 fileinfo05.c 中查看我的解决方案,该文件可在在线仓库中找到。请在查看我的解决方案之前自己尝试这个练习;我的代码中的注释解释了我的哲学。如果你更喜欢,可以使用 fileinfo 系列程序来执行 stat() 函数的基本操作。
10.2.3 阅读目录
目录是一个文件数据库,但如果你想吸引一个书呆子,你可以称它们为 inode。就像文件一样,目录数据库存储在媒体上。但是你不能使用 fopen() 函数来打开和读取目录的内容。不,相反,你使用 opendir() 函数。以下是它的 man 页面格式:
DIR *opendir(const char *filename);
opendir() 函数接受一个单一参数,一个表示要检查的目录路径名的字符串。指定当前目录和父目录的快捷方式 . 和 .. 也是有效的。
函数返回一个指向 DIR 处理句柄的指针,类似于 fopen() 命令使用的 FILE 处理句柄。由于 FILE 处理句柄代表一个文件流,DIR 处理句柄代表一个目录流。
发生错误时,返回 NULL 指针。全局 errno 值被设置,指示函数遇到的具体错误。
opendir() 函数有一个配套的 closedir() 函数,类似于 fclose() 函数是 fopen() 的配套。closedir() 函数需要一个参数,即打开的目录流的 DIR 处理句柄,在 man 页面格式示例中幽默地称为“dirp”:
int closedir(DIR *dirp);
是的,我知道互联网上拼作“derp”。
成功时,closedir() 函数返回 0。否则,返回值 -1,并设置全局 errno 变量,等等。
opendir() 和 closedir() 函数都在 dirent.h 头文件中进行了原型声明。
在以下列表中,你可以看到 opendir() 和 closedir() 函数被投入使用。当前目录 "." 被打开,因为它始终有效。
列表 10.3 readdir01.c 的源代码
#include <stdio.h>
#include <stdlib.h>
#include <dirent.h>
int main()
{
DIR *dp; ❶
dp = opendir("."); ❷
if(dp == NULL) ❸
{
puts("Unable to read directory");
exit(1);
}
puts("Directory is opened!");
closedir(dp); ❹
puts("Directory is closed!");
return(0);
}
❶ 目录句柄
❷ 打开当前目录,无论是什么目录
❸ 打开失败时退出程序
❹ 然后将其关闭
列表 10.3 中的代码只是打开和关闭当前目录。无聊!要访问目录中存储的文件,你使用另一个函数,readdir()。这个函数也在 dirent.h 头文件中进行了原型声明。以下是 man 页面格式:
struct dirent *readdir(DIR *dirp);
函数接受一个打开的 DIR 处理句柄作为其唯一参数。返回值是 dirent 结构的地址,其中包含有关目录条目的详细信息。这个函数被反复调用以从目录流中读取文件条目(inode)。在读取目录中的最后一个条目后,返回 NULL 值。
很遗憾,dirent 结构不像我希望的那样丰富。表 10.4 列出了两个一致的成员,尽管一些 C 库提供了更多成员。任何额外的成员都是特定于编译器或操作系统的,不应依赖于你计划发布到野外的代码。POSIX.1 标准仅要求两个必需的成员:d_ino 用于条目的 inode 和 d_name 用于条目的文件名。
表 10.4 dirent 结构的常见成员
| 成员 | 数据类型(占位符) | 描述 |
|---|---|---|
| d_ino | ino_t (%lu) | Inode 编号 |
| d_reclen | unsigned short (%u) | 记录长度 |
最好的结构成员是 d_name,它在所有编译器和平台上都一致可用。这个成员在 readdir02.c 的源代码中使用,如下一列表所示。对 readdir01.c 的这次更新删除了两个愚蠢的 puts() 语句。添加了一个 readdir() 语句,以及一个 printf() 函数来输出当前目录中找到的第一个文件的名称。
列表 10.4 readdir02.c 的源代码
#include <stdio.h>
#include <stdlib.h>
#include <dirent.h>
int main()
{
DIR *dp;
struct dirent *entry; ❶
dp = opendir(".");
if(dp == NULL)
{
puts("Unable to read directory");
exit(1);
}
entry = readdir(dp); ❷
printf("File %s\n",entry->d_name); ❸
closedir(dp);
return(0);
}
❶ dirent 结构被创建为一个指针,一个内存地址。
❷ 该条目被读取并存储在 dirent 结构体中的 entry 成员。
❸ 输出 d_name 成员。
由 readdir02.c 源代码生成的程序只输出一个文件——很可能是当前目录本身的条目,即单个点。显然,如果你想要一个真正的目录读取程序,你必须修改代码。
与使用 fread()函数从常规文件读取数据一样,readdir()函数会被反复调用。当函数返回指向 dirent 结构的指针时,目录中就有另一个条目可用。只有当函数返回 NULL 时,才表示已读取完整目录。
要将代码从 readdir02.c 更新到 readdir03.c,你必须将readdir()语句改为 while 循环的条件。然后,将printf()语句设置在while循环内。以下是更改的行:
while( (entry = readdir(dp)) != NULL )
{
printf("File %s\n",entry->d_name);
}
while循环会一直重复,直到readdir()返回的值不是 NULL。通过这次更新,程序现在会输出当前目录中的所有文件。
要获取目录中文件的更多信息,请使用本章之前介绍过的stat()函数。readdir()函数的 dirent 结构体包含文件名,位于 d_name 成员中。当知道这个细节时,你使用stat()函数来获取文件类型以及其他信息。
下一个示例展示了readdir系列程序的最终版本。它结合了本章之前介绍过的代码,创建了一个粗糙的目录列表程序。条目逐个读取,stat()函数返回文件类型、大小和访问日期的特定值。
列表 10.5 readdir04.c 的源代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <dirent.h>
#include <time.h>
int main()
{
DIR *dp;
struct dirent *entry;
struct stat fs;
int r;
char *filename;
dp = opendir(".");
if(dp == NULL)
{
puts("Unable to read directory");
exit(1);
}
while( (entry = readdir(dp)) != NULL )
{
filename = entry->d_name; ❶
r = stat( filename,&fs ); ❷
if( r==-1 )
{
fprintf(stderr,"Error reading '%s'\n",filename);
exit(1);
}
if( S_ISDIR(fs.st_mode) ) ❸
printf(" Dir %-16s ",filename); ❹
else
printf("File %-16s ",filename); ❺
printf("%8lu bytes ",fs.st_size); ❻
printf("%s",ctime(&fs.st_atime)); ❼
}
closedir(dp);
return(0);
}
❶ 保存目录条目的名称以提高可读性和易于访问
❷ 为当前文件名/目录条目填充 stat 结构
❸ 从其他文件类型中调用目录
❹ 以 16 字符宽度左对齐输出目录文件名
❺ 将标准文件名与目录文件名对齐
❻ 以 8 字符宽度输出文件大小
❼ 输出访问时间,这会自动添加换行符
此代码表明,要真正读取目录,你需要readdir()和stat()函数。它们一起获取目录中文件的详细信息——如果你计划探索目录或编写类似文件工具(如目录树),这将是有用的信息。
以下是 readdir04.c 源代码生成的程序示例输出:
Dir . 4096 bytes Sat Oct 30 16:44:34 2021
Dir .. 4096 bytes Fri Oct 29 21:55:05 2021
File a.out 8672 bytes Sat Oct 30 16:44:34 2021
File fileinfo 8464 bytes Tue Oct 26 15:55:22 2021
File fileinfo01.c 966 bytes Sat Oct 30 16:24:49 2021
File readdir01.c 268 bytes Fri Oct 29 19:30:10 2021
顺便提一下,目录条目出现的顺序取决于操作系统。一些操作系统按字母顺序排序条目,因此readdir()函数按该顺序获取文件名。这种行为并不一致,因此不要依赖它来输出你的目录读取程序。
10.3 子目录探索
目录可以通过三种方式引用:
-
作为命名路径
-
作为指向父目录的..快捷方式
-
作为当前目录中的一个目录条目,一个子目录
无论采用何种方法,路径名要么是直接的,要么是相对的。直接路径是一个完全命名的路径,从根目录、您的家目录或当前目录开始。相对路径名使用 .. 快捷方式表示父目录——有时,很多。
As an example, a full pathname could be:
/home/dang/documents/finances/bank/statements
这个直接路径显示了从根目录分支的目录,通过我的家目录,直到 statements 目录。
如果我还有一个目录,/home/dang/documents/vacations,但我使用的是前面的语句目录,从语句到 vacations 的相对路径是:
../../../vacations
第一个 .. 代表 bank 目录。第二个 .. 代表 finances 目录。第三个 .. 代表 documents 目录,其中 vacations 存在作为一个子目录。这种结构演示了一个相对路径。
这些关于路径的细节是使用 Linux 命令提示符的基本部分。当涉及到您的 C 程序以及它们如何探索和访问目录时,理解这些项目至关重要。
10.3.1 使用目录探索工具
除了使用 opendir() 函数读取目录和 readdir() 检查目录条目外,您的代码可能需要更改目录。此外,程序可能想知道它当前正在哪个目录中运行。有两个 C 库函数可以满足这些需求:chdir() 和 getcwd()。我首先介绍 getcwd(),因为它可以用来确认 chdir() 函数是否完成了其工作。
The getcwd() function obtains the directory in which the program is operating. Think of the name as Get the Current Working Directory. It works like the pwd command in the terminal window. This function is prototyped in the unistd.h header file. Here is the man page format:
char *getcwd(char *buf, size_t size);
缓冲区 buf 是一个大小为字符的字符数组或缓冲区。它用于保存当前目录字符串,一个从根目录的绝对路径。这里有一个提示:您可以使用定义的常量 BUFSIZ 作为缓冲区的大小,以及 getcwd() 的第二个参数。一些 C 库定义了常量 PATH_MAX,它可以从 limits.h 头文件中获取。由于其可用性不一致,我建议使用 BUFSIZ。 (PATH_MAX 定义常量在第十一章中介绍。)
The return value from getcwd() is the same character string saved in buf, or NULL upon an error. For the specific error, check the global errno variable.
以下列表显示了一个小型演示程序,getcwd.c,它输出当前工作目录。我使用定义的常量 BUFSIZ 为字符数组 cwd[] 设置大小。函数被调用,然后输出字符串。
列表 10.6 getcwd.c 的源代码
#include <stdio.h>
#include <unistd.h>
int main()
{
char cwd[BUFSIZ]; ❶
getcwd(cwd,BUFSIZ);
printf("The current working directory is %s\n",cwd); ❷
return(0);
}
❶ 定义常量 BUFSIZ 在 stdio.h 头文件中定义。
❷ 输出当前工作目录
当运行时,程序以完整路径名输出当前工作目录。缓冲区填充了您从 pwd 命令中看到的相同文本。
第二个有用的目录函数是*chdir()*。这个函数的工作方式类似于 Linux 中的*cd*命令。如果你支付了看电影的“高级价格”,你可能在 MS-DOS 中使用了*chdir*命令,尽管*cd*也是可用的,并且输入更快。
与*getcwd()*一样,*chdir()*函数在unistd.h头文件中进行了原型化。以下是*man*页面格式:
int chdir(const char *path);
唯一的参数是一个表示要更改到的目录(路径)的字符串。成功时返回值为 0,-1 表示错误。正如你可能已经猜到的,全局变量errno被设置为指示具体出了什么问题。
我在下一列表中展示的changecwd.c源代码中使用了两个目录遍历函数。*chdir()*函数将目录更改为由双点表示的父目录。*getcwd()*函数获取新目录的完整路径名,并输出结果。
列表 10.7 changecwd.c的源代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
char cwd[BUFSIZ];
int r;
r = chdir(".."); ❶
if( r==-1 )
{
fprintf(stderr,"Unable to change directories\n");
exit(1);
}
getcwd(cwd,BUFSIZ); ❷
printf("The current working directory is %s\n",cwd); ❸
return(0);
}
❶ 更改为父目录
❷ 获取父目录的路径
❸ 输出父目录的路径
生成的程序输出程序运行的目录的父目录的路径名。
你会在changecwd.c的源代码中注意到,我没有麻烦返回到原始目录。这样的编码是不必要的。在使用*chdir()*函数时,需要记住的一个重要事情是目录更改仅在程序的环境中发生。程序可能更改到媒体上的所有目录,但当它完成后,目录与程序开始时相同。
10.3.2 深入子目录
当你知道子目录的完整路径时,更改到子目录很容易。用户可以提供绝对路径,或者可以将它硬编码到程序中。但是,当程序不知道其目录位置时会发生什么?
父目录始终是已知的;你可以使用双点缩写(..)来访问除了顶级目录之外每个目录的父目录。向上移动很容易。向下移动需要做更多的工作。
通过使用本章前面介绍的工具找到子目录:扫描当前目录以查找子目录条目。一旦知道,将子目录名称插入到*chdir()*函数中,以访问该子目录。
下一列表中的subdir01.c的代码构建了一个程序,该程序列出命名目录中的潜在子目录。代码的一部分是从本章前面列出的其他示例中提取的:需要并检查目录参数。然后打开命名目录并读取其条目。如果找到任何子目录,则列出它们。
列表 10.8 subdir01.c的源代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <dirent.h>
int main(int argc, char *argv[])
{
DIR *dp;
struct dirent *entry;
struct stat fs;
int r;
char *dirname,*filename;
if( argc<2 ) ❶
{
fprintf(stderr,"Missing directory name\n");
exit(1);
}
dirname = argv[1]; ❷
dp = opendir(dirname); ❸
if(dp == NULL)
{
fprintf(stderr,"Unable to read directory '%s'\n",
dirname
);
exit(1);
}
while( (entry = readdir(dp)) != NULL ) ❹
{
filename = entry->d_name; ❺
r = stat( filename,&fs ); ❻
if( r==-1 ) ❼
{
fprintf(stderr,"Error on '%s'\n",filename);
exit(1);
}
if( S_ISDIR(fs.st_mode) ) ❽
printf("Found directory: %s\n",filename); ❾
}
closedir(dp);
return(0);
}
❶ 确认存在命令行参数(目录名)
❷ 为第一个参数分配一个指针dirname以提高可读性
❸ 打开目录并检查错误
❹ 读取目录中的条目
❺ 为每个条目分配一个指针filename以提高可读性
❻ 获取 inode 详细信息
❼ 检查错误
❽ 测试文件是否为目录(子目录)
❾ 输出目录的名称
从源代码 subdir01.c 生成的程序读取作为命令行参数提供的目录,然后输出在该目录中找到的任何子目录。以下是使用我的主目录进行的示例运行输出:
$ ./subdir /home/dang
Found directory: .
Found directory: ..
Error on '.bash_history'
下面是根目录的输出:
$ ./subdir /home/dang
Found directory: .
Found directory: ..
Error on 'bin'
在这两个示例中,stat() 函数失败了。你的代码可以检查 errno 变量,该变量在函数返回 -1 时设置,但我可以立即告诉你错误是什么:传递给 stat() 函数的第一个参数必须是路径名。在程序中,只提供了目录的名称,而不是路径名。例如,在前面示例运行中找到的 .bash_history 子目录和 bin 目录在当前目录中并不存在。
解决方案是程序切换到指定的目录。只有当你切换到目录时,代码才能正确读取文件——除非你费力地构建完整的路径名。我太懒了,不想这么做,所以为了修改代码,我在 dirname = argv[1] 语句之后添加了以下语句:
r = chdir(dirname);
if( r==-1 )
{
fprintf(stderr,"Unable to change to %s\n",dirname);
exit(1);
}
此外,你必须包含 unistd.h 头文件,这样编译器就不会对 chdir() 函数提出抱怨。
通过对代码的这些更新,这些更新可在在线存储库的子目录 subdir02.c 中找到,程序现在可以正常运行:
$ ./subdir /home/dang
Found directory: .
Found directory: ..
Found directory: .cache
Found directory: .config
Found directory: .ddd
Found directory: .lldb
Found directory: .ssh
Found directory: Dan
Found directory: bin
Found directory: prog
Found directory: sto
记住:要从目录中读取文件,你必须要么切换到该目录(容易),要么手动为文件构造完整的路径名(不那么容易)。
练习 10.2
每个目录都有点和点点的条目。此外,许多目录还包含隐藏的子目录。Linux 中的所有隐藏文件都以单个点开头。本练习的任务是修改 subdir02.c 的源代码,使程序不输出任何以单个点开头的文件。我的解决方案可在在线存储库中找到,作为 subdir03.c。
10.3.3 使用递归进行更深入的挖掘
直到我编写了我的第一个目录树探索程序,我才完全理解和欣赏递归的概念。事实上,目录探险是向任何程序员教授递归的机制以及它如何有益的绝佳方式。
作为复习,递归 是函数调用自身的惊人能力。它看起来很愚蠢,就像一个无限循环。然而,在函数内部存在一个逃生门,它允许递归函数展开。只要展开机制正常工作,递归在编程中就用于解决各种奇妙的问题,而不仅仅是让初学者感到困惑。
当 subdir 程序遇到子目录时,它可以切换到该目录以继续挖掘更多目录。为此,调用读取当前目录的相同函数,但使用子目录的路径。这个过程在图 10.4 中展示。一旦目录中的条目数用尽,过程将以返回父目录结束。最终,函数返回,回溯到原始目录,程序完成。

图 10.4 递归发现目录的过程
我对递归的问题总是如何展开它。深入子目录的探索让我意识到,一旦所有目录都处理完毕,控制权会返回父目录。即使如此,作为一个习惯于在内存紧张的环境中工作的经验丰富的汇编语言程序员,我担心会崩溃堆栈。到目前为止还没有发生——好吧,至少在我正确编码的时候没有。
要将 subdir 系列程序修改为递归目录探险者,你必须移除程序的核心,即探索子目录的部分,并将其设置为函数。我称这样的函数为 dir(). 它的参数是目录名:
void dir(const char *dirname);
dir() 函数使用 while 循环来处理目录条目,寻找子目录。当找到时,函数再次被调用(在自身内部)以继续处理目录条目,寻找另一个子目录。当条目用尽时,函数返回,最终回到原始目录。
以下列表实现了图 10.4 中的程序流程,以及 subdir 程序的早期版本,以创建一个单独的 dir() 函数。当找到子目录时,它会递归调用(在函数的 while 循环内)。同时修改了 main() 函数,以便在没有提供命令行参数时假定当前目录(".")。
列表 10.9 subdir04.c 的源代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <dirent.h>
#include <unistd.h>
#include <string.h>
void dir(const char *dirname) ❶
{
DIR *dp;
struct dirent *entry;
struct stat fs;
char *filename;
char directory[BUFSIZ];
if( chdir(dirname)==-1 ) ❷
{
fprintf(stderr,"Unable to change to %s\n",dirname);
exit(1);
}
getcwd(directory,BUFSIZ); ❸
dp = opendir(directory); ❹
if( dp==NULL )
{
fprintf(stderr,"Unable to read directory '%s'\n",
directory
);
exit(1);
}
printf("%s\n",directory); ❺
while( (entry=readdir(dp)) != NULL ) ❻
{
filename = entry->d_name; ❼
if( strncmp( filename,".",1)==0 ) ❽
continue;
stat(filename,&fs); ❾
if( S_ISDIR(fs.st_mode) ) ❿
dir(filename); ⓫
}
closedir(dp);
}
int main(int argc, char *argv[])
{
if( argc<2 )
{
dir("."); ⓬
}
else
dir(argv[1]); ⓭
return(0);
}
❶ 函数的唯一参数是目录名,dirname。
❷ 确认程序可以切换到命名目录
❸ 获取完整路径名
❹ 确认目录可以打开
❺ 输出目录的名称
❻ 遍历目录的条目,寻找子目录
❼ 保存找到的文件名以提高可读性
❽ 忽略点(.)和点点(..)条目以及隐藏文件
❾ 获取找到的目录条目的详细信息(inode)
❿ 检查子目录
⓫ 递归地再次调用 dir() 函数
⓬ 如果没有提供参数,则假定当前目录
⓭ 使用参数作为命名目录
不要费心输入 subdir04.c 的代码。(还有谁会从书中输入代码吗?)甚至不要费心从在线仓库中获取源代码。程序不会让你的电脑崩溃,但它包含几个缺陷。
例如,以下是在我的主目录上的一个示例运行:
$ ./subdir ~
/home/dang
/mnt/c/Users/Dan
/mnt/c/Users/Dan/3D Objects
Unable to change to AppData
你可以看到起始目录的输出是正确的,/home/dang。接下来,程序跳转到 Windows 中我的用户配置文件目录的符号链接(从 Linux 命令行)。到目前为止,一切顺利;它跟随符号链接到 /mnt/c/Users/Dan。它成功进入 3D Objects 目录,但随后它迷路了。存在 AppData 目录,但它不是代码应该分支的下一个合适的子目录。
问题是什么?
缺陷存在于图 10.4 以及列表 10.9 中显示的源代码中:当 dir() 函数开始时,它发出 chdir() 函数来更改到命名的目录 dirname。但是,当 dir() 函数完成处理子目录后,它并没有将目录改回到父目录/原始目录。
要更新代码并使程序返回父目录,请在 dir() 函数的末尾添加以下语句:
if( chdir("..")==-1 )
{
fprintf(stderr,"Parent directory lost\n");
exit(1);
}
更新后的代码可以在在线存储库中找到,作为 subdir05.c。在我的主目录上的一次示例运行输出了许多页的目录,几乎正确。
几乎。
结果表明,从 subdir05.c 创建的程序可能会迷路,特别是与符号链接相关。代码跟随符号链接,但当它尝试返回父目录时,它要么丢失了位置,要么去了错误的父目录。问题在于在 dir() 函数末尾添加到代码中的 chdir() 语句块。父目录并不具体:
chdir("..");
这条语句更改到父目录,但使用父目录的完整路径会更好。事实上,当我玩弄代码时,我发现最好在整个 dir() 函数中处理完整路径名。一些更改是必要的。
我最后的更新将 dir() 函数重新定义为以下内容:
void dir(const char *dirpath, const char *parentpath);
为了提高可读性,我将参数名称更改为反映这两个都是完整路径名。第一个是扫描目录的完整路径名。第二个是父目录的完整路径名。两者都是 const char 类型,因为在函数内部不会修改这两个字符串。
列表 10.10 显示了更新的 dir() 函数。大多数更改涉及删除 char 变量 directory 并将其替换为参数 dirpath。现在也不再需要在函数中更改到命名的目录,现在假设 dirpath 参数代表当前目录。进一步的注释可以在代码中找到。
列表 10.10 subdir06.c 中的更新 dir() 函数
void dir(const char *dirpath,const char *parentpath)
{
DIR *dp;
struct dirent *entry;
struct stat fs;
char subdirpath[BUFSIZ]; ❶
dp = opendir(dirpath); ❷
if( dp==NULL )
{
fprintf(stderr,"Unable to read directory '%s'\n",
dirpath
);
exit(1);
}
printf("%s\n",dirpath); ❸
while( (entry=readdir(dp)) != NULL ) ❹
{
if( strncmp( entry->d_name,".",1)==0 ) ❺
continue;
stat(entry->d_name,&fs); ❻
if( S_ISDIR(fs.st_mode) ) ❼
{
if( chdir(entry->d_name)==-1 ) ❽
{
fprintf(stderr,"Unable to change to %s\n",
entry->d_name
);
exit(1);
}
getcwd(subdirpath,BUFSIZ); ❾
dir(subdirpath,dirpath); ❿
}
}
closedir(dp); ⓫
if( chdir(parentpath)==-1 ) ⓬
{
if( parentpath==NULL ) ⓭
return;
fprintf(stderr,"Parent directory lost\n");
exit(1);
}
}
❶ 存储要更改的新目录,存储完整路径名
❷ 程序已经在所需的目录中,因此而不是改变到它,代码尝试打开目录并读取条目。
❸ 输出当前目录路径
❹ 读取目录中的所有条目
❺ 避免任何点条目
❻ 获取每个目录条目(inode)的信息
❼ 检查子目录条目
❽ 更改到子目录
❾ 获取递归调用中子目录的完整路径名
❿ 递归地以子目录和当前目录作为参数调用函数
⓫ 在读取所有条目后关闭当前目录
⓬ 返回到父目录——完整路径名
⓭ 检查 NULL,如果是,则直接返回
更新 dir() 函数需要更新 main() 函数。它需要做更多的工作:main() 函数必须获取当前目录的完整路径名或 argv[1] 的值,以及目录的父目录。这里展示了 main() 函数的更新。
列表 10.11 subdir06.c 的更新后的 main() 函数
int main(int argc, char *argv[])
{
char current[BUFSIZ];
if( argc<2 )
{
getcwd(current,BUFSIZ); ❶
}
else
{
strcpy(current,argv[1]); ❷
if( chdir(current)==-1 ) ❸
{
fprintf(stderr,"Unable to access directory %s\n",
current
);
exit(1);
}
getcwd(current,BUFSIZ); ❹
}
dir(current,NULL); ❺
return(0);
}
❶ 对于没有参数的情况,获取并存储当前目录的完整路径
❷ 复制第一个参数;希望是一个目录
❸ 切换到目录并检查错误
❹ 获取目录的完整路径名
❺ 调用函数;在 dir() 中检查 NULL。
完整的源代码文件可在在线仓库中找到,文件名为 subdir06.c。它接受目录参数或无参数,如果没有参数,则当前目录会被追踪。
尽管程序使用完整路径名,但它仍然可能会迷路。特别是对于符号链接,代码可能会偏离你预期的位置。某些类型的链接,如 Mac OS X 中的别名,不被识别为目录,因此会被跳过。并且在处理系统目录时,特别是包含块或字符文件的目录,程序的堆栈可能会溢出并生成段错误。
10.4 目录树
古老的 MS-DOS 操作系统具有 TREE 工具。它以节日般、图形化的方式(对于文本屏幕)显示当前目录结构的地图。此命令在 Windows 中仍然可用。在 Windows 的 CMD(命令提示符)程序中,键入 TREE,你会看到如图 10.5 所示的输出:目录以层次结构的形式出现,以节日般的方式用线条连接父目录和子目录,同时通过缩进来显示目录深度。

图 10.5 TREE 命令的输出
创建目录树程序的机制你已经知道了。subdir06.c 的源代码以与图 10.5 所示的输出相同的方式处理目录和子目录。缺少的是缩短的目录名、文本模式图形和缩进。你可以添加这些项目,创建自己的目录树工具。
10.4.1 提取目录名
为了模仿旧的 TREE 工具,dir() 函数必须从完整路径名中提取目录名。因为使用了完整路径名,并且字符串不以反斜杠结尾,所以从字符串中的最后一个反斜杠到最后一个空字符之间的所有内容都符合目录名的条件。
从完整的路径名中提取当前目录名的简单方法是保存在父目录中找到的名称:entry->d_name 结构成员包含目录的名称,正如它在父目录列表中显示的那样。为了进行这种修改,dir() 函数需要另一个参数,即短目录名。这种修改简单易编码,这就是为什么这种方法被称为简单方法。
容易方法的缺点是 main() 函数在程序启动时没有参数时获取完整的目录路径。因此,即使你选择容易的方法,你仍然必须在 main() 函数中从完整的路径名中提取目录名。因此,我的方法是为从路径末尾提取目录名(或文件名)编写一个新函数。
当我为程序添加新功能时,例如从路径末尾提取目录名时,我会编写测试代码。在下一个列表中,你可以看到 extract() 函数的测试代码。它的任务是遍历路径名以提取最后一部分——假设字符串的最后一部分(在最后的/分隔符字符之后)是目录名。哦,该函数还假设环境是 Linux;如果你使用 Windows,则指定反斜杠(两个:\)作为路径分隔符,尽管 Windows 10 也可能识别正斜杠。
列表 10.12 extractor.c 的源代码
#include <stdio.h>
#include <string.h>
const char *extract(char *path)
{
const char *p;
int len;
len = strlen(path);
if( len==0 ) ❶
return(NULL);
if( len==1 & *(path+0)=='/' ) ❷
return(path);
p = path+len; ❸
while( *p != '/' ) ❹
{
p--;
if( p==path ) ❺
return(NULL);
}
p++; ❻
if( *p == '\0' ) ❼
return(NULL);
else
return(p); ❽
}
int main()
{
const int count=4;
const char *pathname[count] = { ❾
"/home/dang",
"/usr/local/this/that",
"/",
"nothing here"
};
int x;
for(x=0; x<count; x++)
{
printf("%s -> %s\n",
pathname[x],
extract(pathname[x])
);
}
return(0);
}
❶ 如果字符串为空,则返回 NULL
❷ 对根目录执行特殊测试
❸ 将指针 p 定位在字符串 path 的末尾
❹ 回退 p 以找到分隔符;对于 Windows,使用 \ 作为分隔符
❺ 如果 p 回退得太远,则返回 NULL
❻ 在分隔符字符上递增 p
❼ 测试字符串是否为空或格式不正确,并返回 NULL
❽ 返回最终目录名开始的地址
❾ 测试字符串的各种配置
extract() 函数在传递的路径名字符串中回退。指针 p 搜索 / 分隔符。它离开函数时引用字符串 path 中最终目录名开始的位置。在出现错误时,返回 NULL。main() 函数中的一系列测试字符串使 extract() 函数发挥作用。以下是输出:
/home/dang -> dang
/usr/local/this/that -> that
/ -> /
nothing here -> (null)
extract() 函数成功处理每个字符串,返回最后一部分,即目录名。它甚至可以捕获格式不正确的字符串,正确地返回 NULL。
对于我的第一个目录树程序版本,我在对 subdir 系列程序的最后更新 subdir06.c 中添加了 extract() 函数。extract() 函数在 dir() 函数内部调用,就在读取目录条目的主要 while 循环之前,替换了该行上的现有 printf() 语句:
printf("%s\n",extract(dirpath));
这个更新被保存为 dirtree01.c。生成的程序 dirtree 输出目录,但只输出它们的名称,而不是完整的路径名。输出几乎是一个目录树程序,但没有为每个子目录级别进行适当的缩进。
10.4.2 监控目录深度
从旧 TREE 命令中获取的复杂输出,如图 10.5 所示,看起来比实际复杂。要完全模拟它,代码需要使用宽字符输出(在第八章中介绍)。此外,还需要监控目录的深度,以及输出目录中的最后一个子目录时。实际上,要完全模拟 TREE 命令,需要对 dirtree 程序进行大量重构,主要是为了保存目录条目以便稍后输出。
是的,所以我不会去那里——不会完全去。
而不是重构整个代码,我想添加一些缩进,使我的 dirtree 系列目录输出看起来更“树形”。这个添加需要监控目录深度,以便每个子目录都缩进一点。为了监控目录深度,更新了 dir() 函数的定义:
void dir(const char *dirpath,const char *parentpath, int depth);
我认为三个参数对于一个函数来说是最大的。如果参数更多,对我来说很明显,真正应该传递给函数的是结构体。实际上,我编写了一个版本的 dirtree 程序,它将目录条目存储在结构体数组中。然而,这段代码变得过于复杂,所以我决定只修改前面显示的 dir() 函数。
为了完成代码的修改,还需要进行三个更改。首先,在 main() 函数中,dir() 函数最初调用时第三个参数为零:
dir(current,NULL,0);
零将缩进深度设置为程序开始时的值;第一个目录是顶级目录。
其次,必须修改 dir() 函数内的递归调用,添加第三个参数深度:
dir(subdirpath,dirpath,depth+1);
对于递归调用,这意味着程序正在深入一个目录级别,缩进级别深度增加一。
最后,必须在 dir() 函数内部对深度变量进行处理。我选择添加一个循环,为每个深度级别输出三个空格。这个循环需要为 dir() 函数声明一个新的变量,整数 i(用于缩进):
for( i=0; i<depth; i++ )
printf(" ");
这个循环出现在输出目录名称的 printf() 语句之前,就在 while 循环之前。结果是,每个子目录在输出目录树时都缩进了三个空格。
dirtree02.c 的源代码可在在线仓库中找到。以下是程序对我 prog(编程)目录的输出:
prog
asm
c
blog
clock
debug
jpeg
opengl
wchar
xmljson
zlib
python
每个子目录缩进了三个空格。c 目录的子子目录进一步缩进。
练习 10.3
修改 dirtree02.c 的源代码,使其不是使用空格缩进,而是使用文本模式图形显示子目录。例如:
prog
+--asm
+--c
| +--blog
| +--clock
| +--debug
| +--jpeg
| +--opengl
| +--wchar
| +--xmljson
| +--zlib
+--python
这些图形不如 MS-DOS TREE 命令中的那些花哨(或精确),但它们是一个改进。这个修改只需要几行代码。我的解决方案可以在在线仓库中找到,文件名为 dirtree03.c。
11 文件查找器
在古代,我编写过最流行的 MS-DOS 工具之一是快速文件查找器。当然,它并不特别快。但给定一个文件名时,它可以在 PC 的硬盘上找到任何位置的文件。这个程序包含在我早期许多计算机书籍提供的配套软盘上。是的,软盘。
在今天的操作系统上,查找文件是一个大问题。Windows 和 Mac OS X 都提供了强大的文件查找工具,不仅可以通过名称,还可以通过日期、大小和内容来定位文件。Linux 命令提示符提供了自己的文件查找工具,这些工具与它们的图形界面工具一样强大(如果不是更强大)。对于一位初学者 C 程序员,或者任何想要提高他们的 C 语言技能的人来说,使用这些工具是有用的,但仅仅使用这些工具并不能提高你的编程技能。
搜索文件,以及可能对它们进行某些操作,依赖于第十章中介绍的目录探险工具。从这个基础出发,你可以通过以下方式扩展你的 C 语言知识:
-
审查其他文件查找工具
-
探索查找文本的方法
-
在目录树中定位文件
-
使用通配符匹配文件
-
查找文件名重复项
当我编写一个实用程序时,尤其是与已经可用的类似实用程序时,我会寻找改进。许多命令行工具都有一系列选项和功能。这些开关使命令变得强大,但超出了我的需求。我发现选项的丰富性令人不知所措。对我而言,构建一个更具体的实用程序版本会更好。虽然这样的程序可能没有像过去那些经验丰富的 C 程序员编写的程序那样强大,但它符合我的需求。通过编写自己的文件工具,你可以更多地了解 C 语言编程,并且你可以使用这个工具——并根据自己的工作流程进行定制。
11.1 大型文件搜索
我个人文件查找工具是基于对现有 Linux 文件查找工具的挫败感——特别是 find 和 grep。
这些命令没有问题,一些精心挑选的咒骂词就能解决。然而,我发现我无法记住命令格式和选项。在需要使用这些文件查找工具时,我总是不断查阅文档。我明白这种承认可能会让我被赶出社区计算机俱乐部。
find 命令功能强大。在 Linux 中,这种强大意味着有大量的选项,通常比字母表中的字母还要多——大小写字母。这种复杂性解释了为什么许多极客宁愿使用图形界面文件搜索工具,而不是终端窗口来查找丢失的文件。
这是 find 命令的看似简单的格式:
find path way-too-many-options
是的。很简单。
假设你想要定位一个名为 budget.csv 的文件,它位于你的主目录树中的某个位置。以下是你要使用的命令:
find ~ -name budget.csv -print
路径名是~,代表你的主目录。-name 开关用于标识要查找的文件,budget.csv。最后的开关,-print(大家容易忘记的),指示find命令将结果发送到标准输出。你可能认为输出应该是必要的默认选项,但find命令可以对找到的文件做更多的事情,而不仅仅是将它们的名称发送到标准输出。
find命令期望的输出可能单独出现在一行上,这是幸运的。更常见的情况是,你必须筛选出一系列错误和重复的匹配。最终找到所需的文件,并显示其路径:
/home/dang/documents/financial/budget.csv
是的,你可以创建一个别名来指向你经常使用的特定find工具格式。不,我不会就find命令有多强大和有用或为什么我不把它与美味的棒棒糖进行比较进行辩论。
另一个文件查找命令是grep,我特别使用它来定位包含特定文本片段的文件。实际上,我在写这本书的时候多次使用 grep 来在头文件中定位定义的常量。从/usr/include 目录,以下是查找各种头文件中 time_t 定义常量的命令:
grep -r "time_t" *
-r 开关指示grep递归地遍历目录。要查找的字符串是 time_t,而*通配符指示程序搜索所有文件名。
当执行这个命令时,会输出许多行文本,因为 time_t 定义的常量在多个头文件中被引用。即使这个技巧也没有找到我想要的特定定义,但它确实指明了正确的方向。
这些工具——find和grep(以及它的更好伴侣,egrep)——非常棒且功能强大。然而,我想要一个友好且易于使用的工具,无需经常检查man页面或参考厚重的命令行参考书籍。这就是为什么我编写了自己的版本,这些版本在本章中有所介绍。
有了你对 C 语言的知识,你可以轻松地编写满足你需求的特定文件查找工具,无论是复杂还是简单。然后,如果你忘记了任何选项,你只能怪自己。
11.2 文件查找器
我寻找文件的目标是输入这样的命令:
find thisfile.txt
工具深入当前目录树,逐个子目录地搜索,寻找特定的文件。如果找到,将输出完整的路径名——对我非常有用。添加使用通配符定位文件的能力,我就再也不需要使用find命令了——在特定格式下查找文件。
哦,对了——我想我的工具名字不能叫find,因为 Linux 中已经使用了这个名字。那叫ff,代表Find File怎么样?
11.2.1 编写文件查找器工具
第十章涵盖了目录探索的过程,使用递归的 dir() 函数来深入子目录。在这个函数的基础上构建是创建文件查找工具的完美选择。目标是扫描目录,并将找到的文件与用户提供的匹配文件名进行比较。
本章中介绍的 Find File 工具不使用第十章中的相同 dir() 函数。不,递归目录查找函数需要修改以定位特定文件,而不是所有文件。我已将函数重命名为 find(),因为我知道这个名字会激怒查找工具。
我的 find() 函数具有与第十章中的 dir() 相同的前两个参数。但如下一列表所示,这个更新的函数添加了一个第三个参数 match,以帮助寻找命名的文件。dir() 和 find() 之间的其他差异在列表中已注释说明。
列表 11.1 递归 find() 函数
void find(char *dirpath,char *parentpath,char *match)
{
DIR *dp;
struct dirent *entry;
struct stat fs;
char subdirpath[PATH_MAX]; ❶
dp = opendir(dirpath);
if( dp==NULL )
{
fprintf(stderr,"Unable to read directory '%s'\n",
dirpath
);
exit(1);
}
while( (entry=readdir(dp)) != NULL )
{
if( strcmp(entry->d_name,match)==0 ) ❷
{
printf("%s/%s\n",dirpath,match); ❸
count++; ❹
}
stat(entry->d_name,&fs);
if( S_ISDIR(fs.st_mode) )
{
if( strncmp( entry->d_name,".",1)==0 ) ❺
continue;
if( chdir(entry->d_name)==-1 )
{
fprintf(stderr,"Unable to change to %s\n",
entry->d_name
);
exit(1);
}
getcwd(subdirpath,BUFSIZ);
find(subdirpath,dirpath,match); ❻
}
}
closedir(dp);
if( chdir(parentpath)==-1 )
{
if( parentpath==NULL )
return;
fprintf(stderr,"Parent directory lost\n");
exit(1);
}
}
❶ 使用 limits.h 中的最大路径大小值(参见文本中的讨论)。
❷ 对找到的文件名与传递的文件名进行比较
❸ 输出任何匹配的文件名
❹ 增加外部 count 变量
❺ 避免检查隐藏文件
❻ 再次进行递归调用,这次将传递的文件名作为第三个参数
除了列表 11.1 中提到的添加之外,我还使用了定义的 PATH_MAX 常量,这需要包含 limits.h 头文件。因为并非每个 C 库都实现了 PATH_MAX,所以需要一些预处理指令:
#ifndef PATH_MAX
#define PATH_MAX 256
#endif
PATH_MAX 的值因操作系统而异。例如,在 Windows 上可能是 260 字节,但在我使用的 Ubuntu Linux 版本中,它是 1024 字节。我见过高达 4096 字节的,所以 256 似乎是一个不会导致任何问题的好值。如果你想定义一个更高的值,请随意定义。
我的 Find File 工具也计算匹配的文件数。为了跟踪,我使用了定义在外的变量 count。我非常不愿意使用全局变量,但在这个情况下,让 count 成为外部变量是跟踪找到的文件的有效方法。否则,我可以在 find() 函数中将 count 包含为第四个参数,但作为一个递归函数,保持其值的一致性会引入各种混乱。
包含 find() 函数的源代码文件名为 findfile01.c,其中 main() 函数在以下列表中显示。main() 函数的职责是从命令行获取文件名,检索当前路径,调用 find() 函数,然后报告结果。main() 函数如下所示。
列表 11.2 findfile01.c 中的 main() 函数
int main(int argc, char *argv[])
{
char current[PATH_MAX];
if( argc<2 ) ❶
{
fprintf(stderr,"Format: ff filename\n");
exit(1);
}
getcwd(current,PATH_MAX);
if( chdir(current)==-1 )
{
fprintf(stderr,"Unable to access directory %s\n",
current
);
exit(1);
}
count = 0; ❷
printf("Searching for '%s'\n",argv[1]);
find(current,NULL,argv[1]); ❸
printf(" Found %d match",count); ❹
if( count!=1 ) ❺
printf("es");
putchar('\n');
return(0);
}
❶ 需要一个命令行参数。
❷ 初始化外部 int 变量 count,用于跟踪找到的文件数量
❸ 调用函数,指定文件名参数为第三个参数
❹ 报告结果
❺ 对于除了 1 以外的任何 count 值,神奇地添加“es”
find() 和 main() 都包含在源代码文件 findfile01.c 中,该文件可在本书的在线仓库中找到。我已经将源代码构建到名为 ff 的程序文件中。以下是一些示例运行:
$ ff a.out
Searching for 'a.out'
/Users/Dan/code/a.out
/Users/Dan/code/communications/a.out
/Users/Dan/code/communications/networking/a.out
/Users/Dan/Tiny C Projects/code/08_unicode/a.out
/Users/Dan/Tiny C Projects/code/11_filefind/a.out
Found 5 matches
查找文件 工具会定位我主目录树中的所有 a.out 文件:
$ ff hello
Searching for 'hello'
Found 0 matches
在前面的例子中,该工具没有找到任何名为 hello 的文件:
$ ff *.c
Searching for 'finddupe01.c'
/Users/Dan/Tiny C Projects/code/11_filefind/finddupe01.c
Found 1 match
该工具尝试定位当前目录中所有具有 .c 扩展名的文件。而不是返回所有文件,你只能看到第一个匹配的文件报告:finddupe01.c。这里的问题是代码没有识别通配符;它只找到特定的文件名。
要匹配具有通配符的文件,你必须了解一个称为 glob 的概念。与同名的 1958 年恐怖电影《 Blob》的主角不同,了解 glob 不会让你丧命。
11.2.2 理解 glob
glob 可以是来自外太空的粘稠物质,但在计算机世界中,它简称为 global。具体来说,glob 是一种使用通配符指定或匹配文件名的方法。我知道的大多数人都更喜欢说“通配符”而不是“glob”。但在编程领域,术语是 glob,过程是 globbing,而 globbing 的人被称为 globbers。值得关注的 C 库函数是 glob()。
作为复习,文件名通配符如下:
-
? 匹配单个字符
-
- 匹配多个字符的组
在 Windows 中,通配符匹配会自动进行。但在 Linux 环境中,必须激活通配符功能才能展开通配符。如果不这样做,*和? 通配符将被字面地解释,这并不是大多数用户所期望的。
要确保 globbing 是激活的,请输入 set -o 命令。在输出中,noglob 选项应设置为 off:
noglob off
如果你看到选项是开启的,请使用此命令:
set +o noglob
当通配符激活时,shell 会展开 ? 和 * 通配符以匹配文件。在上一个部分中,提供的输入是 *.c。然而,程序只处理了一个名为 finddupe01.c 的文件。文件名是匹配的,但它不是目录中唯一的 *.c 文件名。这是怎么回事?
下一个列表中的代码有助于你理解在命令提示符中输入通配符时 globbing 的工作方式。从 glob01.c 生成的程序遍历所有输入的命令行选项,除了第一个项目,即程序文件名。
列表 11.3 glob01.c 的源代码
#include <stdio.h>
int main(int argc, char *argv[])
{
int x;
if( argc>1 ) ❶
{
for( x=1; x<argc; x++ ) ❷
printf("%s\n",argv[x]);
}
return(0);
}
❶ 如果在提示符下只输入程序名,无需烦恼。
❷ 遍历所有参数
这里是使用 glob01.c 创建的程序的一个示例运行,该程序被命名为 a.out:
$ ./a.out this that the other
this
that
the
other
该程序忠实地回显所有命令行选项。现在尝试运行相同的程序,但指定一个通配符:
$ ./a.out *.c
finddupe01.c
finddupe02.c
finddupe03.c
finddupe04.c
finddupe05.c
findfile01.c
findfile02.c
glob01.c
glob02.c
*.c 通配符(globby 事物)由 shell 展开,将当前目录中每个匹配的文件名作为命令行参数传递给程序。而不是提供一个单一的参数 *.c,而是提供了多个参数。
使用通配符匹配的问题在于,你的程序实际上并不知道是否提供了多个命令行参数,或者输入了一个通配符并进行了展开。此外,由于通配符参数被转换成多个匹配的文件,你无法知道指定了哪个通配符。或许存在某种方法,因为我知道一些能够以惊人方式执行通配符匹配的实用程序,但我还没有发现这种魔法的本质。
而不是感到困惑,你可以依赖 glob() 函数为你执行模式匹配。以下是 man 页面的格式:
int glob(const char *pattern, int flags, int (*errfunc) (const char *epath, int eerrno), glob_t *pglob);
函数有四个参数:
-
const char *pattern 是一个路径名通配符模式,用于匹配。
-
int flags 是用于自定义函数行为的选项,通常是一系列逻辑 OR 一起定义的常量。
-
int (*errfunc) 是一个错误处理函数的名称(及其两个参数),这是必要的,因为 glob() 函数可能有些古怪。指定 NULL 以使用默认的错误处理器。
-
glob_t *pglob 是一个包含匹配文件详细信息的结构。两个有用的成员是 gl_pathc,它列出了匹配文件的数量,以及 gl_pathv,它作为指向当前目录中匹配文件名的指针列表的基址。
glob() 函数在成功时返回零。其他返回值包括定义的常量,你可以测试这些常量以确定函数是否出错或未能找到任何匹配的文件。
关于 glob() 函数的更多精彩细节可以在 man 页面中找到。请特别注意 flags 参数,因为它容易引发各种问题。
你必须在源代码中包含 glob.h 头文件,以使编译器知道 glob() 函数。
在下一个列表中,glob02.c 的源代码使用 glob() 函数在当前目录中搜索匹配的文件。用户被提示输入。输入字符串被清除任何换行符。调用 glob() 函数处理输入,搜索与指定的任何通配符匹配的文件名。最后,一个 while 循环输出匹配的文件名。
列表 11.4 glob02.c 的源代码
#include <stdio.h>
#include <stdlib.h>
#include <glob.h>
#include <limits.h> ❶
#ifndef PATH_MAX ❷
#define PATH_MAX 256
#endif
int main()
{
char filename[PATH_MAX];
char *r;
int g; ❸
glob_t gstruct; ❹
char **found; ❺
printf("Filename or wildcard: "); ❻
r = fgets(filename,PATH_MAX,stdin);
if( r==NULL )
exit(1);
while( *r!='\0' )
{
if( *r=='\n' )
{
*r = '\0';
break;
}
r++;
}
g = glob(filename, GLOB_ERR , NULL, &gstruct); ❼
if( g!=0 ) ❽
{
if( g==GLOB_NOMATCH )
fprintf(stderr,"No matches for '%s'\n",filename);
else
fprintf(stderr,"Some kinda glob error\n");
exit(1);
}
printf("Found %zu filename matches\n",[CA]gstruct.gl_pathc); ❾
found = gstruct.gl_pathv; ❿
while(*found) ⓫
{
printf("%s\n",*found); ⓬
found++; ⓭
}
return(0);
}
❶ 对于 PATH_MAX 的定义——如果可用
❷ 如果 PATH_MAX 没有定义,则创建它
❸ glob() 的返回值
❹ 在 glob() 函数中指定的结构
❺ 一个指向匹配文件名列表的双指针
❻ 提示输入文件名通配符;这段代码是为了验证输入并删除换行符。
❼ 对 glob() 函数的调用,大多数情况下默认,除了 GLOB_ERR 标志
❽ 检查错误,特别是没有匹配的文件名
❾ 使用结构成员 gl_pathc 输出匹配项;占位符 %zu 用于 size_t 值。
❿ gl_pathv 成员是指针列表的基址,被分配给找到的双指针。
⓫ 当 found 引用的字符串不为 NULL 时循环
⓬ 输出匹配的文件名
⓭ 增加找到的指针以引用列表中的下一个项目
记住,通配符输入必须由用户提供,因为程序不会将通配符输入解释为命令行参数。以下是一个示例运行:
Filename or wildcard: find*
Found 7 filename matches
finddupe01.c
finddupe02.c
finddupe03.c
finddupe04.c
finddupe05.c
findfile01.c
findfile02.c
程序成功找到了所有以 find 开头的文件。现在可以将源代码中使用的技术整合到查找文件实用程序中,以便在搜索中使用通配符。
11.2.3 使用通配符查找文件
需要对查找文件实用程序进行一些修改,以便利用通配符。为了辅助glob()函数,现在必须在提示符下输入匹配的文件名,类似于前一小节中的 glob02.c 程序。然后必须将glob()函数集成到find()函数中,以帮助搜索子目录中的匹配文件名。
对main()函数的修改可以在源代码文件 findfile02.c 中找到,该文件可在在线仓库中找到。这些更新反映了从 glob02.c 源代码文件中添加的语句,主要是接受和确认有关通配符的输入。其余的修改如下所示,其中glob()函数被集成到find()函数中。在这个版本的代码中,字符串参数 match 可以是特定文件名或包含通配符的文件名。
列表 11.5 源代码文件 findfile02.c 中的find()函数
void find(char *dirpath,char *parentpath,char *match)
{
DIR *dp;
struct dirent *entry;
struct stat fs;
char subdirpath[PATH_MAX];
int g;
glob_t gstruct;
char **found;
dp = opendir(dirpath);
if( dp==NULL )
{
fprintf(stderr,"Unable to read directory '%s'\n",
dirpath
);
exit(1);
}
g = glob(match, GLOB_ERR, NULL, &gstruct); ❶
if( g==0 ) ❷
{
found = gstruct.gl_pathv;
while(*found)
{
printf("%s/%s\n",dirpath,*found);
found++;
count++;
}
}
while( (entry=readdir(dp)) != NULL ) ❸
{
stat(entry->d_name,&fs);
if( S_ISDIR(fs.st_mode) ) ❹
{
if( strncmp( entry->d_name,".",1)==0 )
continue;
if( chdir(entry->d_name)==-1 )
{
fprintf(stderr,"Unable to change to %s\n",
entry->d_name
);
exit(1);
}
getcwd(subdirpath,BUFSIZ);
find(subdirpath,dirpath,match);
}
}
closedir(dp);
if( chdir(parentpath)==-1 )
{
if( parentpath==NULL )
return;
fprintf(stderr,"Parent directory lost\n");
exit(1);
}
}
❶ 使用glob()在目录中查找匹配的文件
❷ 成功后,输出找到的文件(这里而不是下面)
❸ 此循环仍然必要,以查找和探索子目录。
❹ 只在这里查找目录文件;匹配的文件已经输出。
在其最终版本中,查找文件实用程序(源代码文件 findfile02.c)会提示输入,可以是特定文件或通配符。当前目录及其所有子目录中的所有文件都会被搜索,并报告结果:
$ ff
Filename or wildcard: *.c
Searching for '*.c'
/Users/Dan/code/0424a.c
/Users/Dan/code/0424b.c
...
/Users/Dan/Tiny C Projects/code/11_filefind/sto/unique04.c
/Users/Dan/Tiny C Projects/code/11_filefind/sto/unique05.c
/Users/Dan/Tiny C Projects/code/11_filefind/sto/unique06.c
Found 192 matches
在这里,查找文件实用程序在我的主目录中找到了 192 个 C 源代码文件。
$ ff
Filename or wildcard: *deposit*
Searching for '*deposit*'
/Users/Dan/Documents/bank deposit.docx
Found 1 match
在这里显示的示例运行中,查找文件实用程序找到了我的银行存款文件。程序中包含的glob()函数允许有效地使用通配符。尽管当输入完整名称时,程序仍然可以定位特定文件:
$ ff
Filename or wildcard: ch03.docx
Searching for 'ch03.docx'
/Users/Dan/Documents/Word/text/ch03.docx
Found 1 match
正如我之前写的,我经常使用这个实用程序,因为它简单,并且能生成我想要的结果。我不希望继续修改这个实用程序,这可能会最终导致我重新发明find程序。不,相反,查找文件的概念可以进一步扩展到定位重复文件。
11.3 重复文件查找器
我最喜欢的 MS-DOS 共享软件实用程序之一是finddupe。我在 Windows 中找不到类似的东西(尽管我没有积极寻找)。该实用程序的版本仍然可在 Windows 的命令行界面中使用。它不仅通过名称,还通过内容查找重复文件。finddupe是清理和组织大容量存储设备上文件的便捷工具。
我从未费心编写自己的finddupe实用程序,主要是因为现有的工具很棒。即便如此,我经常思考这个过程:程序不仅必须扫描所有目录,还必须记录文件名。从记录的文件名列表中,每个都必须与其他列表中的文件进行比较,以查看是否存在相同的名称。一想到比较文件内容的过程,我就感到不寒而栗。
尽管如此,这个话题仍然吸引了我:你是如何扫描子目录中的文件,然后检查是否找到任何重复的名称的?
创建Find Dupe实用程序的过程大量借鉴了第十章中介绍的子目录扫描工具,以及本章前面使用过的工具。但其余的代码——记录和扫描保存的文件列表——是新的领域:必须创建一个文件列表。该列表必须被扫描以查找重复项,然后输出重复项及其路径名。
11.3.1 构建文件列表
就像任何编程项目一样,我尝试了多次才成功地构建了一个包含子目录中找到的文件的列表。很明显,我需要某种结构来保存文件信息:名称、路径等等。但是,我是创建一个动态数组(分配指针)的结构,使用链表,使结构数组外部化,还是放弃并成为一名乳牛农场主呢?
对我来说,将任何变量外部化都是最后的手段。有时这是唯一的选择,但永远不应该是因为它容易做到而选择。正如本章前面所展示的,有时这是必要的,因为实现变量的其他方式很笨拙。特别是与递归一起使用时,外部变量可以解开一些否则处于圣诞树灯水平上的结。
剩下的两个选项是传递一个动态分配的列表或使用链表。我编写了几次代码,其中将动态分配的结构列表传递给递归函数。它失败了,这是很容易理解的,因为指针在递归中可能会丢失。因此,我唯一剩下的选择是创建一个链表。
链表结构必须有一个成员,指向列表中的下一个项目,即节点。这个成员成为存储找到的文件名及其路径的结构的一部分。以下是它的定义:
struct finfo {
int index;
char name[BUFSIZ];
char path[PATH_MAX];
struct finfo *next;
};
我最初将这个结构命名为 fileinfo。我本想保留这个名称,但这本书的边距只有那么宽,我不喜欢源代码的换行。所以,我决定使用 finfo。这个结构包含四个成员:
-
index,它记录找到的文件数量(避免使用外部变量)
-
name,包含找到的文件名
-
path,包含文件的完整路径
-
next,它引用链表中的下一个节点,或 NULL 表示列表的末尾
这个结构必须声明为外部,以便代码中的所有函数都理解其定义。
我对这个程序的第一次构建仅仅是看看它是否工作:即链表是否被正确分配、填充,并且从递归函数中返回。在下一部分中,你可以看到 main() 函数。它为链表分配第一个节点。这个结构必须为空;是递归函数 find() 构建了链表。main() 函数获取递归函数的起始目录。完成后,一个 while 循环输出链表引用的文件名。
列表 11.6 从 finddupe01.c 的 main() 函数
int main()
{
char startdir[PATH_MAX];
struct finfo *first,*current; ❶
first = malloc( sizeof(struct finfo) * 1 ); ❷
if( first==NULL ) ❸
{
fprintf(stderr,"Unable to allocate memory\n");
exit(1);
}
first->index = 0; ❹
strcpy(first->name,""); ❹
strcpy(first->path,""); ❹
first->next = NULL; ❹
getcwd(startdir,PATH_MAX); ❺
if( chdir(startdir)==-1 )
{
fprintf(stderr,"Unable to access directory %s\n",
startdir
);
exit(1);
}
find(startdir,NULL,first); ❻
current = first; ❼
while( current ) ❽
{
if( current->index > 0 ) ❾
printf("%d:%s/%s\n", ❿
current->index,
current->path,
current->name
);
current = current->next; ⓫
}
return(0);
}
❶ 需要一个指针用于基础(第一个)和检查列表中的项(当前)。
❷ 分配基础指针
❸ 确认指针已分配
❹ 将第一个节点填充为空值
❺ 获取 find() 函数调用的当前目录
❻ 调用递归函数
❼ 将当前指针设置为列表的开始
❽ 当当前指针不为 NULL 时循环
❾ 跳过列表中的第一个项目,零
❿ 输出索引值、路径名和文件名
⓫ 引用列表中的下一个项目
while 循环跳过了链表中的第一个节点,即空项。我可以通过将当前初始化语句替换为以下内容来避免 if( current->index > 0) 文本(前面已展示):
current = first->next;
我现在才想到这个变化,所以你不会在源代码文件中找到它。无论如何,链表中的第一个节点被跳过了。
我为 Find Dupe 代码编写的 find() 函数是基于本章前面介绍的 Find File 工具中的 find() 函数。find() 函数的第三个参数被替换为链表当前节点的指针。该函数的任务是在当前目录中找到文件时创建新节点,并填充它们的结构。
下一部分显示了 Find Dupe 实用程序的 find() 函数,该函数在列表 11.6 中展示的 main() 函数中被调用。当在当前目录中找到文件时,该函数在列表中为新节点分配存储空间。这是函数的唯一添加。
列表 11.7 从 finddupe01.c 的 find() 函数
void find( char *dirpath, char *parentpath, struct finfo *f)
{
DIR *dp;
struct dirent *entry;
struct stat fs;
char subdirpath[PATH_MAX];
int i;
dp = opendir(dirpath); ❶
if( dp==NULL )
{
fprintf(stderr,"Unable to read directory '%s'\n",
dirpath
);
exit(1);
/* will free memory as it exits */
}
while( (entry=readdir(dp)) != NULL )
{
stat(entry->d_name,&fs);
if( S_ISDIR(fs.st_mode) ) ❷
{
if( strncmp( entry->d_name,".",1)==0 )
continue;
if( chdir(entry->d_name)==-1 )
{
fprintf(stderr,"Unable to change to %s\n",
entry->d_name
);
exit(1);
}
getcwd(subdirpath,BUFSIZ);
find(subdirpath,dirpath,f);
}
else ❸
{
f->next = malloc( sizeof(struct finfo) * 1); ❹
if( f->next == NULL )
{
fprintf(stderr,
"Unable to allocate new structure\n");
exit(1);
}
i = f->index; ❺
f = f->next; ❻
f->index = i+1; ❼
strcpy(f->name,entry->d_name); ❽
strcpy(f->path,dirpath); ❾
f->next = NULL; ❿
}
}
closedir(dp);
if( chdir(parentpath)==-1 )
{
if( parentpath==NULL )
return;
fprintf(stderr,"Parent directory lost\n");
exit(1);
}
}
❶ 获取当前目录——未改变
❷ 测试子目录和递归
❸ 如果不是子目录,则保存文件信息
❹ 分配链表中的下一个节点(并进行错误检查)
❺ 保存当前索引值
❻ 引用新分配的节点
❼ 更新索引值
❽ 保存文件名
❾ 保存路径名
❿ 初始化下一个指针;函数的其余部分与前面的示例相同。
find() 函数根据目录条目是子目录还是文件做出简单的决定。当找到子目录时,函数会递归调用。否则,在链表中分配一个新的节点,并记录文件条目信息。
finddupe01.c 的完整源代码可以在在线仓库中找到。以下是我在工作目录中样本运行的输出:
1:/Users/Dan/code/11_filefind/.finddupe01.c.swp
2:/Users/Dan/code/11_filefind/a.out
3:/Users/Dan/code/11_filefind/finddupe01.c
4:/Users/Dan/code/11_filefind/finddupe02.c
5:/Users/Dan/code/11_filefind/finddupe03.c
6:/Users/Dan/code/11_filefind/finddupe04.c
7:/Users/Dan/code/11_filefind/finddupe05.c
8:/Users/Dan/code/11_filefind/findfile01.c
9:/Users/Dan/code/11_filefind/findfile02.c
10:/Users/Dan/code/11_filefind/glob01.c
11:/Users/Dan/code/11_filefind/glob02.c
12:/Users/Dan/code/11_filefind/sto/findword01.c
13:/Users/Dan/code/11_filefind/sto/findword02.c
输出能够记录当前目录以及 sto 子目录中的文件。然而,当我切换到父目录(代码)并再次运行程序时,输出没有变化。它应该变化:我的代码目录在各个子目录中有超过 100 个文件。那么为什么输出没有变化呢?
我在猫的帮助下思考这个错误,试图找到解决方案。经过几声喵喵叫后,我想到:问题在于递归函数,这应该是我首先应该注意的线索。
当 find() 函数返回,或“展开”时,使用的是指针 f 的先前值,而不是递归调用中分配的新值。每次函数切换到父目录时,链表中创建的结构就会丢失,因为指针 f 被重置为传递给函数的原始值。唉。
幸运的是,解决方案很简单:返回指针 f。
更新代码只需要三个更改和一个添加。首先,find() 函数的数据类型必须从 void 更改为 struct finfo*:
struct finfo *find( char *dirpath, char *parentpath, struct finfo *f)
第二,递归调用必须捕获函数的返回值:
f = find(subdirpath,dirpath,f);
实际上,这个更改更新了指针 f,以反映递归调用后的新值。
第三,在 chdir() 函数的错误检查中的 return 语句必须指定变量 f 的值:
return(f);
最后,find() 函数必须在末尾有一个语句来返回指针 f 的值:
return(f);
这些更新可以在在线仓库的源代码文件 finddupe02.c 中找到。
构建代码后,程序能够准确扫描子目录,并且在返回父目录时保留链表。输出完整且准确:通过链表记录了当前目录及其子目录中找到的所有文件。
11.3.2 定位重复项
在 Find Dupe 程序中创建链表的目的是为了找到重复项。在某个时刻,必须扫描列表,并确定哪些文件名是重复的,以及重复项在哪些目录中找到。
我想到了几种完成这个任务的方法。其中大多数涉及创建第二个文件名列表。但我不想一个接一个地构建列表。相反,我在 finfo 结构中添加了一个新的成员,repeat,如下所示更新结构定义:
struct finfo {
int index;
int repeat; ❶
char name[BUFSIZ];
char path[PATH_MAX];
struct finfo *next;
};
❶ 新成员用于跟踪重复的文件名
repeat 成员跟踪一个名称重复的次数。它在 find() 函数中初始化为 1,因为每个节点被创建。毕竟,每个找到的文件名至少存在一次。
为了跟踪重复的文件名,在创建列表后扫描列表时,repeat 成员会增加。在 main() 函数中,嵌套循环就像冒泡排序一样工作。它按顺序将列表中的每个节点与列表中的其余节点进行比较。
为了执行第二次扫描,我需要在main()函数中声明另一个struct finfo 变量。这个变量scan,除了first 和*current 外,还用于扫描链表:
struct finfo *first,*current,*scan;
在输出列表的while循环之前添加嵌套的while循环。此嵌套循环使用current 指针处理整个链表。scan 指针在内层while循环中使用,以比较 current->name 成员与后续的 name 成员。当找到匹配项时,具有重复名称的文件的 current->repeat 结构成员递增,如下所示。
列表 11.8 finddupe03.c 中main()函数中添加的嵌套while循环
current = first;
while( current ) ❶
{
if( current->index > 0 ) ❷
{
scan = current->next; ❸
while( scan ) ❹
{
if( strcmp(current->name,scan->name)==0 ) ❺
{
current->repeat++; ❻
}
scan = scan->next; ❼
}
}
current = current->next; ❽
}
❶ 循环遍历列表,直到 current 的值为 NULL
❷ 跳过第一个空条目
❸ 获取下一个条目的地址,扫描从这里开始
❹ 循环直到扫描引用列表中的最后一个(NULL)节点
❺ 比较文件名
❻ 如果名称相同,则递增当前条目的重复计数器
❼ 继续扫描
❽ 继续在整个列表中递增,比较每个节点与列表中的其余部分
这些嵌套循环更新包含相同文件名的结构的 repeat 成员。它们后面跟着现有的输出列表的while循环。该循环中的printf()语句更新为输出重复值:
printf("%d:%s/%s (%d)\n",
current->index,
current->path,
current->name,
current->repeat
);
所有这些更改都可在 finddupe03.c 源代码文件中找到,该文件可在在线仓库中获取。输出尚未显示重复文件。对Find Dupe系列源代码文件的这一增量改进仅输出相同的完整文件列表,但文件路径字符串末尾显示了重复次数:
163:/Users/Dan/code/sto/secret01.c (1)
Find Dupe程序的下一个更新可在源代码文件 finddupe04.c 中找到。从在线仓库获取此源代码文件,并在编辑器中显示它。随着文本的进行,回顾我将进行的两个改进。
首先,在main()函数顶部声明一个新的int变量 found,这是我更喜欢设置变量声明的地方:
int found = 0;
当在嵌套的while循环中发现重复的文件名时,found 的值重置为 1:
if( strcmp(current->name,scan->name)==0 )
{
current->repeat++;
found = 1;
}
找到的值不需要累积;它实际上是一个布尔变量。当它保持在 0 时,没有找到重复的文件名,然后执行以下语句:
if( !found )
{
puts("No duplicates found");
return(1);
}
输出“未找到重复项”的消息,然后程序以返回值 1 退出。
当 found 的值重置为 1 时,检测到重复的文件名。在main()函数中的第二个while循环继续处理列表。此循环更新为捕获并输出重复项,如下所示。同样,scan 变量在嵌套的while*循环中使用,但这次用于输出重复的文件名及其路径名。
列表 11.9 finddupe04.c 中main()函数中的第二个嵌套while循环
current = first; ❶
while( current )
{
if( current->index > 0 )
{
if( current->repeat > 1 ) ❷
{
printf("%d duplicates found of %s:\n", ❸
current->repeat,
current->name
);
printf(" %s/%s\n", ❹
current->path,
current->name
);
scan = current->next; ❺
while( scan )
{
if( strcmp(scan->name,current->name)==0 )
{
printf(" %s/%s\n",
scan->path,
scan->name
);
}
scan = scan->next;
}
}
}
current = current->next;
}
❶ 如前所述,遍历整个文件列表
❷ 寻找重复计数大于 1 的项
❸ 输出给定文件名的重复数量
❹ 输出当前文件名及其路径
❺ 开始嵌套循环以输出匹配文件名的名称和路径
对 finddupe04.c 代码的这次更新输出了一个更短的列表,只显示那些重复的文件名,并列出所有重复的名称及其路径。
例如,在我的编程树中,我看到程序输出中有五个 a.out 文件的重复:
5 duplicates found of a.out:
/Users/Dan/code/a.out
/Users/Dan/code/communications/a.out
/Users/Dan/code/communications/networking/a.out
/Users/Dan/Tiny C Projects/code/08_unicode/a.out
/Users/Dan/Tiny C Projects/code/11_filefind/a.out
问题在于,重复项也显示了重复。因此,对于 a.out 文件的多次出现,输出继续如下:
4 duplicates found of a.out:
/Users/Dan/code/communications/a.out
/Users/Dan/code/communications/networking/a.out
/Users/Dan/Tiny C Projects/code/08_unicode/a.out
/Users/Dan/Tiny C Projects/code/11_filefind/a.out
3 duplicates found of a.out:
/Users/Dan/code/communications/networking/a.out
/Users/Dan/Tiny C Projects/code/08_unicode/a.out
/Users/Dan/Tiny C Projects/code/11_filefind/a.out
这种输出效率低下,因为它反复列出重复项。原因是链表中重复的文件名结构中的重复成员大于 1。因为这个值在第一个重复的文件名输出时不会改变,所以代码捕获了所有重复项。
这个问题让我很沮丧,因为我既不想创建另一个结构成员,也不想回到康复中心。我的目标是避免在一个已经复杂且嵌套的while循环中产生异常。
我对这个问题思考了一段时间,但最终灵感来了,一个一行解决方案呈现在眼前:
scan->repeat = 0;
这条语句被添加到第二个嵌套while循环中,如列表 11.9 所示。它在检测到匹配的文件名之后出现:
while( scan )
{
if( strcmp(scan->name,current->name)==0 )
{
printf(" %s/%s\n",
scan->path,
scan->name
);
scan->repeat = 0;
}
scan = scan->next;
}
在嵌套while循环中,在输出重复的文件名之后,其重复值被重置为 0。这种更改防止了重复的文件名在输出中再次出现。这种更改在源代码文件 finddupe05.c 中可用。
Find Dupe程序已完成:它扫描当前目录结构,列出匹配的文件名,显示所有重复项的完整路径名。
就像所有代码一样,Find Dupe系列可以进一步改进。例如,可以将文件大小添加到 finfo 结构中。可以输出文件名匹配和文件大小匹配。你也可以全力以赴,尝试匹配文件内容。所需系统的基本框架已在现有代码中提供。剩下的只是你改进它的愿望和必要的时间来预见那些注定会在过程中发生的不期而遇的事情。
12 假日检测器
无论何时,似乎总有一个假日即将到来。这可能是一个宗教假日、国庆日或某些其他节日活动。许多人可能会从工作中得到一天假期来庆祝。对于程序员来说,假日也是一个庆祝活动,但不是从工作中:编码者仍然在编码,但这是一个更加愉快的体验,因为其他人都在度假,这意味着更少的干扰。
你的电脑不在乎一天是否是假日。它并不是无知的;它只是不知道。为了帮助电脑理解哪一天是假日,以及帮助你完成其他依赖于知道哪些天是假日的编程项目,你必须:
-
理解操作系统如何使用返回值
-
在 C 中处理日期编程
-
回顾主要假日
-
计算常规假日
-
处理不规则假日
-
确定复活节何时到来
-
测试你的假日函数
这些任务有助于构建检测和报告特定年份某一天假日的例行程序。这种实用工具本身可能并不特别有用,但在编程日期或执行其他需要知道假日何时到来的任务时,它就派上用场了。例如,我编写了一个股票追踪器,其中知道哪些天不要获取股票数据是有用的,因为市场关闭。我的垃圾回收提醒 shell 脚本使用我的假日程序来查看垃圾回收日是否已改变。
本章中介绍的例程在第十三章介绍的日历程序中也发挥作用。
12.1 操作系统想要它的手续费
你是否曾好奇为什么 main() 是一个整数函数?多年前,C 程序员随意地将其声明为 void 函数。真是令人震惊!老程序员可能仍然会在他们的代码中使用 void main()。哎呀,即使是备受尊敬的 K&R 第一版——《C 程序设计语言》(Prentice-Hall)——甚至都没有费心去强制转换 main() 函数。尽量别让自己陷入混乱。
将 main() 函数强制转换为 int 的原因是它必须向操作系统返回一个值。就像任何高利贷者一样,当操作系统将其一些资源(内存和处理器时间)释放给另一个程序时,它希望得到一些回报,比如利息——手续费,或“手续费”。这个回报就是一个整数值。这个值通常被忽略(只是别错过付款),或者被用于某些巧妙和创新的方式。无论如何,这个值是必需的。
12.1.1 理解退出状态与终止状态
停止程序的方式不止一种。最自然的方式是程序正常结束,在 main() 函数的末尾使用返回语句将其值传递回操作系统。这个值官方上被称为程序的退出状态。
如果程序在main()函数退出之前停止,它有一个终止状态。例如,在main()函数之外嵌套的exit()语句会停止程序。在这种情况下,exit()传递给操作系统的值被称为终止状态。
终止状态。退出状态。是的,那些书呆子喜欢挑刺。关键是程序退出的方式会影响返回值的解释。许多生成其他程序(进程)的函数使用终止状态而不是退出状态。终止状态通常是 0 表示成功,否则为-1。这个值与可能的退出状态不同。在编写程序时要注意这个差异,尤其是在你选择与书呆子对话时。
12.1.2 设置返回值
在main()函数中的return语句负责将一个值发送回操作系统。将一个整数值发送到母舰对于main()函数至关重要:错过它,编译器就会用它的瘦手指指着你说,就像唐纳德·萨瑟兰在《人体互换》结尾时的尖叫。
下一个列表显示了return01.c的源代码。这个程序只有一个任务:向操作系统返回一个值。如果没有指定命令行参数,则返回零。
列表 12.1 return01.c的源代码
#include <stdlib.h> ❶
int main(int argc, char *argv[])
{
if( argc>1 ) ❷
{
return( strtol(argv[1],NULL,10) ); ❸
}
else
{
return(0); ❹
}
}
❶ 不需要stdio.h头文件,因为代码中没有使用 I/O 函数。
❷ 如果存在命令行选项,尝试将其转换为整数
❸ 将字符串argv[1]转换为整数(长整型)值
❹ 当没有参数时,返回零
return01.c中的strtol()函数将命令提示符中持有的字符串argv[1],第一个参数,转换为十进制整数。如果字符串无法转换(它不包含数字),则返回值 0。
程序通过return语句放弃其值。也可以使用exit()函数,但这个值是退出状态,而不是终止状态。(我写那是为了那些书呆子;别担心这里的差异。)
这里是一个示例运行:
是的,代码没有输出,即使你指定了参数。而且,是的,返回的值被操作系统消耗。它可供 shell 解释。尽管操作系统像高利贷者一样,但很少或几乎不做任何关于程序返回值的事情。这项工作可以由其他程序完成,但具体来说,退出状态可供 shell 脚本使用。
12.1.3 解释返回值
程序输出的值留在了操作系统的门口。尽管不需要对这个值做任何事情,但它仍然可供 shell 使用——直到另一个程序存入另一个值。
要演示如何从 shell 访问返回值,重新运行return01.c程序,输入程序名称,假设为return01,以及一个作为参数的值,例如:
$ ./return01 27
程序将值 27 返回给操作系统。这个值通过 shell 脚本变量$?访问。要查看它,请输入 echo 命令后跟 $?:
$ echo $?
27
Shell 脚本可以使用这个值来确定某些操作的结果。唉,在 Linux 中,非 shell 脚本程序很难读取它所启动的另一个程序的返回值。这样的任务是有可能的,我本可以轻易地偏离主题来描述这些激动人心的细节,但这超出了本章的范围。
下面的列表中的 return02.c 的源代码试图捕获 return01 程序返回的值。使用 system() 函数以返回值 99 执行 return01。程序的目的在于展示 system() 函数不会捕获程序的返回值。
列表 12.2 return02.c 的源代码
#include <stdio.h>
#include <stdlib.h>
int main()
{
int r;
r = system("./return01 99"); ❶
printf("The return value is %d\n",r); ❷
return(r);
}
❶ 运行 return01 程序并设置返回值为 99
❷ 报告 system() 函数返回的值
system() 函数的单个参数是你会在命令提示符中输入的内容。该函数可以返回各种值,尽管如果调用成功,返回的值是启动来运行程序的 shell 的终止状态。这个值不是运行程序的返回值。以下是一个示例运行:
The return value is 25344
执行 system() 函数后,shell 将值 25344 返回给操作系统。
在 Windows 中,system() 函数的行为不同。与 Linux 不同,它返回运行任何程序生成的值。以下是同一代码在 Windows 中构建的示例输出,指定了选项 99:
The return value is 99
作为一位资深的 MS-DOS/Windows 程序员,我记得很久以前在各种程序中使用这个技巧与 system() 函数。因为 system()在 Linux 中的行为不同,依赖于该函数来报告程序的返回值并不是你应该做的事情。
是的,我知道:Linux 中的 system() 函数确实返回运行程序的退出状态——shell 的。我想要表达的观点是,该函数不能用来检查另一个程序的返回值。
其他启动进程的函数——fork(), popen(), 等等——的行为与 system() 类似:启动的程序可能生成退出状态,但这个值不会被调用该函数的报告。
如我之前所述,可以启动一个进程并捕获其返回值。如果你对过程好奇,请访问我的博客并搜索 wait()函数:c-for-dummies.com/blog.
12.1.4 使用预设的返回值
C 语言的大佬们想让你知道,退出状态为 0 表示成功;一切按计划进行。退出状态为 1 表示出了问题。我在我的代码中使用这种一致性,但不要使用在 stdlib.h 头文件中可用的定义常量:
EXIT_FAILURE
EXIT_SUCCESS
这两个值分别定义为 1 和 0,代表失败和成功。定义的常量是一致的——对所有编译器和平台都相同。
下面的列表显示了 return03.c 的源代码,它生成一个随机整数,0 或 1。这个值用于确定返回的退出状态是 EXIT_FAILURE 还是 EXIT_SUCCESS。
列表 12.3 return03.c 的源代码
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main()
{
int r;
srand( (unsigned)time(NULL) ); ❶
r = rand() % 2; ❷
if(r) ❸
{
fprintf(stderr,"Welp, this program screwed [CA]up!\n"); ❹
return(EXIT_FAILURE);
}
else
{
printf("Everything went ducky!\n"); ❺
return(EXIT_SUCCESS);
}
}
❶ 初始化随机数生成器
❷ 生成一个随机值并将其存储在 r 中
❸ 使用 r 测试成功(零)或失败(全)
❹ 将错误输出到标准错误设备——出于传统
❺ 将非错误消息发送到标准输出。
程序的输出取决于生成的随机数。为了确认值,你可以在命令提示符下使用$?变量:
$ ./a.out
Welp, this program screwed up!
$ echo $?
1
还有:
$ ./a.out
Everything went ducky!
$ echo $?
0
记住,返回值不必局限于 0 和 1。许多程序和实用程序返回不同的值,每个值都可以由 shell 脚本解释以确定发生了什么。这些值的解释取决于程序的目的,以帮助它完成其功能。
12.2 所有关于今天
很久以前,美国的国庆节在特定的日子。我记得,当我年轻的时候,我可以在林肯的生日和乔治·华盛顿的生日都放假。作为一个孩子,我会在二月份为了任天堂 Switch 而放弃两天学校假期。
嗯,也许不是。
在你确定哪一天是假日之前,你需要一个参考点。这个点就是今天,从操作系统获取的当前日期。或者你也可以用任何旧日期填充 tm 结构,并从这里开始工作。通过调用适当的 C 语言函数,这两项内容都很容易获得。
12.2.1 获取今天的日期
早期个人电脑时代的标志之一是提示:
The current date is: Tue 1-01-1980
Enter the new date: (mm-dd-yy)
MS-DOS 不知道今天是否是假日,因为它甚至不知道今天是星期几!用户必须输入当前日期。最终,技术被添加到主板上以保留当前日期和时间。这种设置就是现代计算机的工作方式,但有了互联网时间服务器来保持时钟准确。你的 C 代码可以使用这些信息来获取计算机所知的当前日期和时间。
下面的列表显示了 C 语言的典型时间代码。当前纪元值——自 1970 年 1 月 1 日以来的秒数——通过time()函数获取并存储在time_t变量中。这个变量用于localtime()函数填充 tm 结构,即今天。tm 结构的成员包含个别的时间 tidbits 值,这些值被输出。
列表 12.4 getdate01.c 的源代码
#include <stdio.h>
#include <time.h>
int main()
{
time_t now;
struct tm *today;
int month,day,year,weekday;
now = time(NULL); ❶
today = localtime(&now); ❷
month = today->tm_mon+1; ❸
day = today->tm_mday;
weekday = today->tm_wday;
year = today->tm_year+1900; ❹
printf("Today is %d, %d %d, %d\n", ❺
weekday,
month,
day,
year
);
return(0);
}
❶ 获取自 1970 年 1 月 1 日以来的秒数——Unix 纪元
❷ 将今天的 tidbits 填充到 tm 结构中
❸ tm_mon 成员从 1 月为 0 开始。
❹ tm_year 成员从 1901 年开始。
❺ 输出从 tm 结构获得的价值
如果你编写过任何与时间相关的程序,这个代码的方法应该很熟悉。输出显示了当前日期的以下格式:
Today is 1, 12 6, 2021
当然,输出可以通过人类来使其可读。除非您是真正的极客,否则您可能不会将“1”识别为星期一的价值。
练习 12.1
将 getdate01.c 的代码更新为输出星期几和月份的字符串。这个改进需要向代码中添加两个字符串数组,以及其他更新,包括对printf()函数的更新。
我的解决方案在在线仓库中作为 getdate02.c 提供。请在您看到我是如何做到的之前,自己尝试这个练习。我代码中的注释解释了正在发生的事情——包括您可能会忘记的一个重要观点。
12.2.2 获取任何旧日期
time()函数获取当前时间,一个包含从 1970 年 1 月 1 日以来经过的秒数的time_t值。这个值本身没有用,这就是为什么像localtime()这样的函数可以帮助您整理细节的原因。但除了今天之外的其他日期怎么办?
可以回填一个 tm 结构体。您为各种成员分配值,然后使用mktime()函数将这些时间片段转换为time_t值。此外,mktime()函数会为您填充未知细节,例如星期几。如果您计划确定假日是哪一天,这些信息至关重要。
这是mktime()函数的man页格式:
time_t mktime(struct tm *tm);
函数传递了一个部分填充的 tm 结构体的地址。返回一个time_t值,但更重要的是,剩余的 tm 结构体被填充了关键细节。
mktime()函数在 time.h 头文件中声明。
作为快速参考,表 12.1 显示了 tm 结构体的常见成员。
表 12.1 tm 结构体的成员
| 成员 | 参考 | 范围/备注 |
|---|---|---|
| tm_sec | 秒 | 0 到 60(60 允许闰秒) |
| tm_min | 分钟 | 0 到 59 |
| tm_hour | 小时 | 0 到 23 |
| tm_mday | 月份中的天数 | 1 到 31 |
| tm_mon | 月份 | 0 到 11 |
| tm_year | 年 | 当前年份减去 1900 |
| tm_wday | 星期 | 0 到 6,星期日到星期六 |
| tm_yday | 一年中的天数 | 0 到 365;0 代表 1 月 1 日 |
| tm_isdst | 夏令时 | 正值表示夏令时;零表示不是;负值表示数据不可用 |
假设您想找出 2022 年 4 月 12 日的星期几。下一列表中显示的代码尝试通过填充 tm 结构体的三个成员:tm_mon、tm_day 和 tm_year 来实现这一点。对 tm_mon 成员进行了调整,它使用 0 代表 1 月,对 tm_year 成员进行了调整,它从 1900 年开始计数。一个printf()语句以 mm/dd/yyyy 格式输出结果,同时也访问了新填充的 tm_wday 成员以输出星期几字符串。
列表 12.5 getdate03.c 的源代码
#include <stdio.h>
#include <time.h>
int main()
{
struct tm day;
const char *days[] = {
"Sunday", "Monday", "Tuesday", "Wednesday",
"Thursday", "Friday", "Saturday", "Sunday"
};
day.tm_mon = 4-1; ❶
day.tm_mday = 12; ❷
day.tm_year = 2022-1900; ❸
mktime(&day); ❹
printf("%02d/%02d/%04d is on a %s\n", ❺
day.tm_mon+1,
day.tm_mday,
day.tm_year+1900,
days[day.tm_wday]
);
return(0);
}
❶ 我使用这种格式是因为 0 代表 1 月,所以 4(四月)减去 1 促使我再次检查。
❷ 月份的第 12 天
❸ 这种格式使得我想要的日期 2022 年可读。
❹ 转换部分填充的 tm 结构体
❺ 输出结果
这是程序的输出:
09/11/0122 is on a Tuesday
啊,122 年的 9 月 11 日,无论那天是星期二与否,都是意料之外的。这是我 Linux 机器的输出。在 Macintosh 上,我看到了这样的输出:
08/22/5839 is on a Thursday
奇怪的是,5839 年的 8 月 22 日是在星期四。计算机很神奇,不仅知道确切的日期,而且我们的爬行动物统治者将继续使用公历。显然,出了点问题。这类错误很令人沮丧,尤其是在代码干净编译的情况下。
问题在于 tm 结构中包含垃圾数据,这些数据被错误地解释或与预设的三个值冲突。我的解决方案是在设置日期、月份和年份的语句下方也设置小时、分钟和秒的值,添加以下三行代码:
day.tm_hour = 0;
day.tm_min = 0;
day.tm_sec = 0;
这种更改可以在源代码文件 getdate04.c 中找到,该文件可在在线仓库中找到。构建后,这是输出:
04/12/2022 is on a Tuesday
我使用了一个油腻的老车库日历来确认,2022 年 4 月 12 日确实是一个星期二。
学到的教训是,如果你知道月份、日期和年份,就可以通过填充六个 tm 结构成员,如这里概述的那样,并调用mktime()函数来获取日期的详细信息。然而,即使如此,你也可能得到错误的日期。
12.3 祝大家节日快乐
看起来每一天都是假日、节日、圣人日,或者是为了某个原因、名人、英雄或历史人物而宣布的日子。你可能在当地电视上看到发胶娃娃欢快地宣布,“嗯,今天是全国模仿猫头鹰叫声日……”或者类似的胡言乱语。这种填充内容之所以可能,是因为每一天都是某种庆祝活动——而且这是新闻淡季。
为了本章的目的,假日必须是一件大事,比如全国假日,每个人都放假。我个人的重大假日标志是邮件不送。排除每个星期日,这些假日很少,通常每月只有一个。这就是我想要我的假日检测器报告的假日类型,尽管你可以自由地修改代码以列出任何假日——包括每个星期日。
12.3.1 回顾美国的假日
美国有一些假日,尽管并非每个国家假日都对每个人都是休息日。相反,我考虑表 12.2 中显示的具体假日。对于这些假日,大多数人都有休息日,政府办公室关闭,银行关闭,学校放假,邮件不送,人们也不会像平时那样频繁地打电话和发电子邮件。
表 12.2 美国国家假日
| 假日 | 日期 | 备注 |
|---|---|---|
| 新年 | 1 月 1 日 | 如果这个假日发生在周末,则为周五或周一的假日。 |
| 马丁·路德·金纪念日 | 一月的第三个星期一 | |
| 华盛顿诞辰 | 二月第三个星期一 | 非正式称为总统日。 |
| 复活节 | 三月或四月的星期日 | 由于是根据农历计算,所以日期会有所变化。 |
| 阵亡将士纪念日 | 五月的最后一个星期一 | |
| 黑奴解放日 | 6 月 19 日 | 当这个假日落在周末时,为周五/周一假日。 |
| 独立日 | 7 月 4 日 | 当这个假日落在周末时,为周五/周一假日。 |
| 劳动节 | 九月的第一个星期一 | |
| 哥伦布日 | 十月的第二个星期一 | 也被称为原住民日。并非所有政府办公室都放假。 |
| 退伍军人节 | 11 月 11 日 | 当这个假日落在周末时,为周五/周一假日。 |
| 感恩节 | 11 月的第四个星期四 | |
| 圣诞节 | 12 月 25 日 | 当这个假日落在周末时,为周五/周一假日。 |
一些假日,如独立日和圣诞节,是特定于某一天和日期的,尽管假日通常在周末之后的周五或周一庆祝。其他假日则根据月份的周数或其他因素变动,如表 12.2 所示。
在计算假日时,你可以设置两个日期:假日的实际日期和假日被观察的日期。我所见到的多数日历都显示了这两个日期,例如圣诞节和圣诞节观察日。在本章后面将展示如何添加此类编程,例如当假日落在星期日时,庆祝活动在星期一。
12.3.2 在英国发现假日
作为一名叛乱者,我并不知道在英国(或者任何其他国家,实际上)哪些日子被庆祝为假日。从狄更斯的作品中,我知道在英格兰圣诞节至少是一个节日。我怀疑英国人不会像我们在美国那样庆祝乔治·华盛顿的生日——好吧,也许不是以同样的方式。其他英国的假日似乎都是银行假日,很可能是为了庆祝英国最大的银行。即使不在银行工作的人也能得到一天的休息,据说是这样。
表 12.3 列出了互联网上报道的英国国家假日。其中只有三个与特定日期相关:新年、圣诞节和节礼日。如果这些假日中的任何一个是星期六或星期日,那么放假日将是下一个星期一。如果圣诞节或节礼日发生在周末,可能会看到星期一和星期二都放假,不知何故,星期二通常是圣诞节的放假日。
表 12.3 英国国家假日
| 假日 | 日期 |
|---|---|
| 新年 | 1 月 1 日 |
| 耶稣受难日 | 复活节前的星期五 |
| 复活节星期一 | 复活节后的星期一 |
| 五一银行假日 | 五月的第一个星期一 |
| 春季银行假日 | 五月的最后一个星期一 |
| 夏季银行假日 | 八月的最后一个星期一 |
| 圣诞节 | 12 月 25 日 |
| 节礼日 | 12 月 26 日 |
复活节假日会根据复活节的日期而变动。你必须使用算法来计算这些假日:复活节和耶稣受难日。此类代码将在本章后面展示。
别担心,我的英国、爱尔兰、苏格兰和威尔士的朋友们:我不会编写任何代码来检测英国的假日。那是你们的工作。根据本章提供的信息,这项任务是可以完成的。
12.4 今天是假日吗?
人类对即将到来的假日有很多线索。例如,每年八月,Costco 的购物者都会兴奋地看到圣诞节的装饰品上架。还有谁能忘记三月初,到处都是绿色的三叶草和快乐的精灵提醒我们复活节呢?这些文化线索对计算机来说毫无意义——除非你,程序员,愿意帮忙。
对于一个计算机假日检测器来说,需要三个及时的小贴士:
-
月份编号
-
月份中的某一天
-
星期几
知道这三个项目后,计算机可以识别一个日期为假日。
在本章的剩余部分,我将使用美国的假日。所展示的相同技术也可以用来检测其他国家的假日,前提是它们遵循太阳历的一致性。我不会介绍如何将农历假日映射到太阳历假日,除了在本章后面的复活节。
12.4.1 报告常规日期假日
最容易报告的假日是可预测的——我称之为常规日期假日。每个这样的假日都固定在特定的月份和日期:
-
元旦,1 月 1 日
-
独立日,6 月 19 日
-
独立日,7 月 4 日
-
退伍军人节,11 月 11 日
-
圣诞节,12 月 25 日
报告这些日期,我使用 isholiday() 函数。下面是这个函数的原型:
int isholiday(struct tm *d)
函数的唯一参数是一个 tm 结构的地址,这个结构是由 localtime() 函数返回并由 mktime() 函数使用的。在 isholiday() 函数的这个阶段重用这个结构很方便。
下面的 isholiday() 函数返回一个整数值:非假日为 0,假日为 1。该函数直接比较月份和日期值来报告常规日期假日,如列表所示。请注意,所使用的月份值从 1 月开始为零。
列表 12.6 isholiday() 函数
int isholiday(struct tm *d)
{
if( d->tm_mon==0 && d->tm_mday==1) ❶
return(1);
if( d->tm_mon==5 && d->tm_mday==19) ❷
return(1);
if( d->tm_mon==6 && d->tm_mday==4) ❸
return(1);
if( d->tm_mon==10 && d->tm_mday==11) ❹
return(1);
if( d->tm_mon == 11 && d->tm_mday == 25) ❺
return(1);
return(0);
}
❶ 元旦
❷ 独立日
❸ 独立日
❹ 退伍军人节
❺ 圣诞节
main() 函数调用 time() 和 localtime() 函数来获取当前时间信息并将其打包到 tm 结构中。这个结构被传递给 isholiday() 并报告结果。你可以在在线仓库中找到完整的源代码,即 isholiday01.c。下面是一个示例运行:
Today is 12/09/2021, not a holiday
对于我对 isholiday() 函数的第一个更新,我希望该函数能够报告假日的名称。为了实现这个改进,必须放弃使用 tm 结构作为 isholiday() 函数的参数。相反,我使用了一个新的结构 holiday,它具有以下成员:
struct holiday {
int month;
int day;
char *name;
};
月份和日期成员与 tm 结构的 tm_mon 和 tm_mday 成员相匹配。名称成员是一个 char 指针,用于存储假日的名称。分配给这个指针的字符串在 isholiday() 函数中声明,如下所示。在那里,你还可以看到对每个 if 判断的更新,现在它将 holiday 结构的名称成员分配过去。
列表 12.7 更新后的 isholiday() 函数以返回假日名称
int isholiday(struct holiday *h) ❶
{
char *n[] = { ❷
"New Years Day",
"Juneteenth",
"Independence Day",
"Veterans Day",
"Christmas"
};
if( h->month==0 && h->day==1)
{
h->name = n[0]; ❸
return(1); ❹
}
if( h->month==5 && h->day==19) ❺
{
h->name = n[1];
return(1);
}
if( h->month==6 && h->day==4)
{
h->name = n[2];
return(1);
}
if( h->month==10 && h->day==11)
{
h->name = n[3];
return(1);
}
if( h->month== 11 && h->day == 25)
{
h->name = n[4];
return(1);
}
return(0); ❻
}
❶ 假期结构必须作为指针传递,因为在这个函数中修改了名称成员。
❷ 按时间顺序分配给假日的字符串
❸ 分配名称成员
❹ 对于真正的假期返回 1
❺ 对五个假日中的每一个重复此模式。
❻ 当日期不是假日时返回 0
main() 函数也进行了更新,以分配那里声明的假期结构中的值。输出语句也进行了修改,以输出命名的假期。例如:
Today is 12/25/2021, Christmas
此更新的完整源代码可在在线存储库中找到,即 isholiday02.c。
目前检测到的假日是绝对的。如果你在创建日历(见第十三章)并想用红色标记假日,isholiday() 函数会正确报告值。但如果你想记录假日是如何庆祝的,就需要更多的编码。
特别地,当这些假日中的任何一个落在周末时,通常是那个周末的前一个星期五或后一个星期一,大家都会休息一天:当独立日(7 月 4 日)在星期日时,国家会在 7 月 5 日星期一休息。尽管当这种类型的假日落在星期二、星期三或星期四时,前一天或后一天并不被视为假日,尽管有些人,主要是懒惰的人,会额外休息几天。
为了更新 isholiday02.c 的代码,并改进 isholiday() 函数,需要进行一些更改。这些更改考虑了假日落在周末的情况。
首先是对假期结构进行更新,它添加了一个新成员 wday。这个成员与 tm 结构中的 tm_wday 成员相呼应。它表示一周中的某一天——0 代表星期日,6 代表星期六。以下是更新的定义:
struct holiday {
int month;
int day;
int wday;
char *name;
};
因为测试只需要两天,所以我添加了两个定义的常量:
#define FRIDAY 5
#define MONDAY 1
当新年假日在星期五庆祝时,日期是前一年的 12 月 31 日。这种差异使得新年假日的测试比其他星期五/星期一测试要复杂一些。下面的列表显示了进行新年假日测试所需的代码,由于前一年的重叠,这个测试并不像其他假日测试那样优雅。
列表 12.8 检测新年假日和任何星期五/星期一庆祝的语句
if( h->month==11 && h->day==31 && h->wday==FRIDAY ) ❶
{
h->name = n[0];
return(2); ❷
}
if( h->month==0 && h->day==1 ) ❸
{
h->name = n[0];
return(1); ❹
}
if( h->month==0 && h->day==2 && h->wday==MONDAY ) ❺
{
h->name = n[0];
return(2); ❻
}
❶ 特别检查 12 月 31 日星期五
❷ 对于“庆祝”假期返回 2
❸ 检查新年假日
❹ 对于实际假期返回 1
❺ 特别检查 1 月 2 日星期一
❻ 对于“庆祝”假期返回 2
isholiday() 函数的新返回代码是 2,如列表 12.8 所示。这个值在 main() 函数中被独特处理,该函数位于完整的更新源代码文件 isholiday03.c 中。以下是一个非假日的示例运行:
Today is 12/09/2021, not a holiday
对于一个假日:
Today is 12/25/2021, Christmas
周一假期:
Today is 12/26/2022, Christmas observed
然而,在代码中,我发现了一些让我烦恼的事情:确定新年之后,接下来的四个假日都使用了类似的表达式。例如,Juneteenth 的构造在下一个列表中显示。这段代码的结构与测试下一个三个假日的结构相同。唯一不同的是具体的日期值。这有很多重复的代码。
列表 12.9 检测 Juneteenth 和其他假日的语句
if( h->month==5 ) ❶
{
if( h->day>17 && h->day<21 ) ❷
{
if( h->day==18 && h->wday==FRIDAY ) ❸
{
h->name = n[1];
return(2); ❹
}
if( h->day==20 && h->wday==MONDAY ) ❺
{
h->name = n[1];
return(2); ❻
}
if( h->day==19 ) ❼
{
h->name = n[1];
return(1); ❽
}
}
}
❶ Juneteenth 总是在六月。
❷ 关注相关日期,包括之前(18),当天(19)和之后(20)
❸ 检查庆祝日之前的日期
❹ 对于庆祝日返回 2
❺ 检查庆祝日之后的日期
❻ 对于庆祝日返回 2
❼ 检查实际假日
❽ 对于假日返回 1
每当我看到我的代码中有这样的重复时,它都会大声呼吁创建一个函数。我创建的函数名为weekend()。以下是它的原型:
int weekend(int holiday, int mday, int wday)
函数有三个参数。整数 holiday 是假日发生的月份中的日期。整数 mday 和 wday 分别是月份中的日期和星期中的日期值。这三个项目代表了isholiday()函数中每个假日测试从源代码文件 isholiday03.c 中变化的不同值。
下面的列表显示了weekend()函数。它包含了列表 12.9 中显示的大部分代码,重复的语句,但已修改为使用变量而不是具体的月份日期值。此代码评估假日之前和之后的日期,星期五和星期一,以确定庆祝日。函数中没有处理的是假日名称的字符串赋值。
列表 12.10 isholiday04.c 中的weekend()函数
int weekend(int holiday, int mday, int wday)
{
if( mday>holiday-2 && mday<holiday+2 ) ❶
{
if( mday==holiday-1 && wday==FRIDAY ) ❷
return(2);
if( mday==holiday+1 && wday==MONDAY ) ❸
return(2);
if( mday==holiday ) ❹
return(1);
}
return(0); ❺
}
❶ 窄化搜索的日期
❷ 测试假日之前的星期五
❸ 测试假日之后的星期一
❹ 测试假日日期本身
❺ 对于没有匹配项返回 0
这个函数的更新可以在在线仓库中找到,文件名为 isholiday04.c。isholiday()函数也进行了更新,以便将大部分工作传递给weekend()函数。代码比之前读起来更清晰。
可以对isholiday()函数进行进一步的改进。但首先,必须处理不规则假日。
12.4.2 处理不规则假日
与具体日期的假日不同,不规则假日发生在每月的特定周和日。除了感恩节在星期四之外,这些假日都是星期一。这些假日是不规则的,因为它们每年都落在日期范围内,所以程序必须更深入地思考这些假日发生的时间。作为回顾,以下是美国的非规则假日:
-
马丁·路德·金纪念日,一月的第三个星期一
-
国庆日,二月的第三个星期一
-
阵亡将士纪念日,五月的最后一个星期一
-
劳动节,九月的第一个星期一
-
哥伦布日,十月的第二个星期一
-
感恩节,十一月的第四个星期四
与常规的日期假日不同,你不需要担心观察日的变动;这些都是特定星期的假日。这种一致性意味着可以为每个假日计算一个月中的日期范围。我已经在表 12.4 中总结了每周的日期范围。
表 12.4 某周星期一假日的日期范围
| 月份中的周 | 周一范围 |
|---|---|
| 第一 | 1 至 7 |
| 第二 | 8 至 14 |
| 第三 | 15 至 21 |
| 第四 | 22 至 28 |
| 最后 | 25 及以上 |
第四周和最后一周之间的差异出现在那些有五个周一的月份中,例如 5 月,如图 12.1 所示:当 5 月 31 日是星期一时,它是第五个周一。5 月 24 日仍然在第四周(参见表 12.3),但在这种月份配置中,由于 31 日是星期一,它是最后一天。这就是为什么最后一周的日期范围与第四周不同的原因。

图 12.1 五个周一的 5 月配置
对于感恩节,该月的最后一个星期四可能从 22 日到 28 日的任何一天。这个值在表 12.3 的第四行中显示,也适用于星期四。
当这些最终的不规则假日被编码时,isholiday() 函数几乎完成。为了帮助这样做,我创建了一些宏并添加了定义的常量 THURSDAY:
#define FRIDAY 5
#define MONDAY 1
#define THURSDAY 4
#define FIRST_WEEK h->day<8
#define SECOND_WEEK h->day>7&&h->day<15
#define THIRD_WEEK h->day>14&&h->day<22
#define FOURTH_WEEK h->day>21&&h->day<29
#define LAST_WEEK h->day>24&&h->day<32
工作日假日落在星期五、星期一或星期四,因此定义的常量增加了代码的可读性。
这里显示的宏与表 12.3 中呈现的日期值相关。变量 h->day 在 isholiday() 函数中使用。这些宏增加了函数的可读性。例如,此代码没有使用宏:
if( h->day>14&&h->day<22 )
{
h->name = n[1];
return(1);
}
但这段代码与之前的代码片段做的是同样的事情,但可读性要好得多:
if( THIRD_WEEK )
{
h->name = n[1];
return(1);
}
为了避免任何混淆,isholiday() 函数的整个更新代码在下一个列表中显示。我承认它有点长,但它显示了捕获美国 12 个年度假日的所有代码,除了复活节,它将在下一节中介绍。除了元旦之外,请注意常规和不规则假日使用的模式。列表中没有显示 weekend() 和 main() 函数。
列表 12.11 isholiday() 函数
int isholiday(struct holiday *h)
{
char *n[] = {
"New Years Day",
"Martin Luther King Day",
"Presidents Day",
"Memorial Day",
"Juneteenth",
"Independence Day",
"Labor Day",
"Columbus Day",
"Veterans Day",
"Thanksgiving",
"Christmas"
};
int r;
if( h->month==11 && h->day==31 && h->wday==FRIDAY ) ❶
{
h->name = n[0];
return(2);
}
if( h->month==0 && h->day==1 )
{
h->name = n[0];
return(1);
}
if( h->month==0 && h->day==2 && h->wday==MONDAY )
{
h->name = n[0];
return(2);
}
if( h->month==0 && h->wday==MONDAY ) ❷
{
if( THIRD_WEEK )
{
h->name = n[1];
return(1);
}
}
if( h->month==1 && h->wday==MONDAY ) ❸
{
if( THIRD_WEEK )
{
h->name = n[2];
return(1);
}
}
if( h->month==4 && h->wday==MONDAY ) ❹
{
if( LAST_WEEK )
{
h->name = n[3];
return(1);
}
}
if( h->month==5 ) ❺
{
r = weekend(19,h->day,h->wday);
h->name = n[4];
return(r);
}
if( h->month==6 ) ❻
{
r = weekend(4,h->day,h->wday);
h->name = n[5];
return(r);
}
if( h->month==8 && h->wday==MONDAY ) ❼
{
if( FIRST_WEEK )
{
h->name = n[6];
return(1);
}
}
if( h->month==9 && h->wday==MONDAY) ❽
{
if( SECOND_WEEK )
{
h->name = n[7];
return(1);
}
}
if( h->month==10 ) ❾
{
r = weekend(11,h->day,h->wday);
h->name = n[8];
return(r);
}
if( h->month==10 && h->wday==THURSDAY ) ❿
{
if( FOURTH_WEEK )
{
h->name = n[9];
return(1);
}
}
if( h->month==11 ) ⓫
{
r = weekend(25,h->day,h->wday);
h->name = n[10];
return(r);
}
return(0);
}
❶ 元旦
❷ 马丁·路德·金纪念日
❸ 国庆日
❹ 阵亡将士纪念日
❺ 美国解放日
❻ 独立日
❼ 劳动节
❽ 哥伦布日
❾ 退伍军人节
❿ 感恩节
⓫ 圣诞节
这里是程序非假日运行的一个示例:
Today is 2/20/2022, not a holiday
以及假日的一个示例:
Today is 2/21/2022, Presidents Day
在更彻底地测试这段代码后,我发现了一个计算退伍军人节和感恩节的缺陷,这两个节日都发生在 11 月。以下是相关的代码片段:
if( h->month==10 )
{
r = weekend(11,h->day,h->wday);
h->name = n[8];
return(r);
}
if( h->month==10 && h->wday==THURSDAY )
{
if( FOURTH_WEEK )
{
h->name = n[9];
return(1);
}
}
第一个 if 测试捕获了 11 月所有的日期并返回。这种退出意味着下一个针对 11 月的 if 测试 h->month==10 永远不会发生。哎呀。
为了解决这个问题,必须对 11 月进行单个if测试。然后可以对感恩节和退伍军人节进行测试。以下是更新的代码:
if( h->month==10 )
{
if( h->wday==THURSDAY && FOURTH_WEEK )
{
h->name = n[9];
return(1);
}
r = weekend(11,h->day,h->wday);
h->name = n[8];
return(r);
}
进行了此更改后,代码现在忠实地报告了感恩节和退伍军人节。所有这些更新和添加都可以在完整的源代码列表 isholiday05.c 中找到,该列表可在在线存储库中找到。
剩下的唯一假日是最难计算的:复活节。
练习 12.2
在代码的主要更新中,添加了代表一年中各月份的常量。在isholiday()函数中使用这些常量,以便进行此比较
if( h->month==0 && h->wday==MONDAY )
现在看起来是这样的:
if( h->month==JANUARY && h->wday==MONDAY )
我的解决方案在在线存储库中作为 isholiday06.c 提供。为了加分,看看你是否可以使用枚举常量,这是我所做的事情。
12.4.3 计算复活节
复活节每年都在不同的日期,因为它是在西方文化中基于农历的最后一个剩余假日。在阳历中,复活节的日期可以是 3 月 22 日或 4 月 25 日,总是在星期日。
对于农历,复活节是春分后的第一个新月后的第一个星期日。这个日期基于犹太人的逾越节。所以,首先是春分,当太阳返回北半球,哈迪斯从冥界释放珀耳塞福涅。下一个满月——可能要过几周——必须过去,然后下一个星期五是逾越节,复活节在星期日。
在我多年前编写的原始假日检测程序中,我硬编码了复活节的日期。这很容易,但不是一种持久的解决方案。
与确定月亮相位(参见第二章)一样,复活节的日期最好通过算法来计算。与月亮算法一样,我对我的复活节算法正在发生什么一无所知;我只是把它记下来。但与月亮相位算法不同,复活节算法非常精确。
只是一个猜测:在接下来的列表中,你看到的大部分内容都与将月亮的周期映射到太阳年以及计算闰年有关。多么奇妙!传递给 easter()函数的值代表一个年份。没有返回值,因为函数本身输出复活节的日期。构建此代码需要包含 math.h 头文件,这意味着你需要在许多平台上链接数学库:在命令提示符构建时使用-lm(小写的 L)开关,指定在构建时最后使用。
列表 12.12 来自源代码文件 easter01.c 的easter()函数
void easter(int year) ❶
{
int Y,a,c,e,h,k,L; ❷
double b,d,f,g,i,m,month,day; ❸
Y = year; ❹
a = Y%19;
b = floor(Y/100);
c = Y%100;
d = floor(b/4);
e = (int)b%4;
f = floor((b+8)/25);
g = floor((b-f+1)/3);
h = (19*a+(int)b-(int)d-(int)g+15)%30;
i = floor(c/4);
k = c%4;
L = (32+2*e+2*(int)i-h-k)%7;
m = floor((a+11*h+22*L)/451);
month = floor((h+L-7*m+114)/31); ❺
day = ((h+L-7*(int)m+114)%31)+1; ❻
printf("In %d, Easter is ",Y); ❼
if(month == 3)
printf("March %d\n",(int)day);
else
printf("April %d\n",(int)day);
}
❶ 仅接受年份值作为唯一参数
❷ 许多整型变量
❸ 许多双变量
❹ 数学运算持续了一段时间。
❺ 获取复活节的月份,即 3 月(三月)或 4 月(四月)
❻ 获取月份
❼ 输出结果
包含 easter() 函数的完整源代码文件 easter01.c 可在在线仓库中找到。列表 12.13 中缺少的是 main() 函数。它包含一个循环,该循环使用从 2018 年到 2035 年的年份值调用 easter() 函数:
In 2018, Easter is April 1
In 2019, Easter is April 21
In 2020, Easter is April 12
In 2021, Easter is April 4
In 2022, Easter is April 17
In 2023, Easter is April 9
In 2024, Easter is March 31
In 2025, Easter is April 20
In 2026, Easter is April 5
In 2027, Easter is March 28
In 2028, Easter is April 16
In 2029, Easter is April 1
In 2030, Easter is April 21
In 2031, Easter is April 13
In 2032, Easter is March 28
In 2033, Easter is April 17
In 2034, Easter is April 9
In 2035, Easter is March 25
将 easter() 合并到 isholiday() 函数中需要太多的工作。相反,我寻求将其作为由 isholiday() 调用的伴随函数包含进来——就像代码中已经存在的 weekend() 函数一样。
必须修改 easter() 函数以接受一个日期值,并根据给定的年份返回 1 或 0,表示日期是否与复活节匹配。为了开始这段旅程,需要对现有的 isholiday 代码进行一些更改。首先,必须修改假日结构以包括一个年份成员:
struct holiday {
int month;
int day;
int year;
int wday;
char *name;
};
第二,必须在 main() 函数中分配年份成员的值:
h.year = today->tm_yeari+1900;
记得将 1900 加到年份值上!
第三,必须在 isholiday() 函数中调用 easter()。在函数开始时,将复活节字符串添加到 n[] 指针数组中。我选择将其添加到末尾,这样不会影响函数其他部分的现有数组编号。"Easter" 字符串是数组声明中的最后一个,即 n[11]。
在 isholiday() 函数中的这些语句调用 easter() 函数。它们是函数中的最后几条语句,就在最终返回之前:
r = easter(h);
if( r==1 )
{
h->name = n[10];
return(r);
}
下一个列表显示了更新的 easter() 函数,该函数已修改以接受一个假日结构指针作为参数,并返回 1 或 0,分别表示当前日期是否为复活节。
列表 12.13:更新后的 easter() 函数,如源代码文件 isholiday07.c 中的样子
int easter(struct holiday *hday) ❶
{
int Y,a,c,e,h,k,L; ❷
double b,d,f,g,i,m,month,day;
Y = hday->year;
a = Y%19;
b = floor(Y/100);
c = Y%100;
d = floor(b/4);
e = (int)b%4;
f = floor((b+8)/25);
g = floor((b-f+1)/3);
h = (19*a+(int)b-(int)d-(int)g+15)%30;
i = floor(c/4);
k = c%4;
L = (32+2*e+2*(int)i-h-k)%7;
m = floor((a+11*h+22*L)/451);
month = floor((h+L-7*m+114)/31)-1; ❸
day = ((h+L-7*(int)m+114)%31)+1;
if( hday->month==month && hday->day==day ) ❹
return(1); ❺
else
return(0); ❻
}
❶ 函数定义已更改,接受结构指针 hday 并返回一个整型值。
❷ 我不能使用变量 h 作为函数的参数,因为它已经在算法中使用,并且我不想对其进行修改。
❸ 从最终月份值中减去 1,因为在这段代码中 1 月份是 0
❹ 测试今天是否是复活节
❺ 如果是,则返回 1
❻ 否则返回 0
最后,记得添加 math.h 头文件,以免编译器因为 easter() 函数中使用的 floor() 函数而出错。并且确保在构建代码时链接 math 库,-lm(小写的 L)。所有这些更改和更新都可以在源代码文件 isholiday07.c 中找到,该文件可在本书的在线仓库中获取。
代码运行方式与之前相同,但现在它能够识别复活节。以下是对 2022 年复活节的示例运行:
Today is 4/17/2022, Easter
12.4.4 运行日期关卡
为了测试 isholiday() 函数,必须运行它通过日期关卡。这个测试是指一个程序,它为给定年份生成从 1 月 1 日到 12 月 31 日的日期。目标是确保 isholiday() 函数能够正确反应,报告国家假日。
下一个列表显示了 gauntlet01.c 的代码。它包含两个字符串常量数组,用于表示月份和星期。mdays[]数组列出了每个月的天数,假设年份不是闰年;代码中二月只有 28 天。日期通过嵌套循环输出:外循环处理月份,内循环处理月份中的天数。
列表 12.14 gauntlet01.c 的源代码
#include <stdio.h>
int main()
{
const char *month[] = {
"January", "February", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December"
};
const char *weekday[] = {
"Sunday", "Monday", "Tuesday", "Wednesday",
"Thursday", "Friday", "Saturday"
};
int mdays[] = { 31, 28, 31, 30, 31, 30, 31, 31, ❶
30, 31, 30, 31 };
enum { SU, MO, TU, WE, TH, FR, SA }; ❷
int start_day,dom,doy,year,m;
start_day = SA; ❸
doy = 1; ❹
year = 2022; ❺
for( m=0; m<12; m++ ) ❻
{
for( dom=1; dom<=mdays[m]; dom++ ) ❼
{
printf("%s, %s %d, %d\n",
weekday[ (doy+start_day-1) % 7], ❽
month[m],
dom,
year
);
doy++; ❾
}
}
return(0);
}
❶ 确定每个月的天数,假设不是闰年
❷ 1 月 1 日的快捷方式,星期几的起始日
❸ 设置 2022 年的起始日,星期六
❹ 年的第一天
❺ 要输出的年份(非闰年)
❻ 遍历一年的 12 个月
❼ 遍历每个月的每一天
❽ 用于确定正确星期几的恐怖数学
❾ 增加年份的天数
代码中的数学确定正确的星期几。这个细节基于设置为 1 月 1 日正确星期几的 start_day 变量,即星期六——代码中的枚举常量 SA。年中的日子变量 doy 用于这个计算,在内循环中递增以跟踪每年的每一天。
gauntlet01.c 的源代码可在在线仓库中找到。以下是简化的输出:
Saturday, January 1, 2022
Sunday, January 2, 2022
Monday, January 3, 2022
Tuesday, January 4, 2022
...
Tuesday, December 27, 2022
Wednesday, December 28, 2022
Thursday, December 29, 2022
Friday, December 30, 2022
Saturday, December 31, 2022
这些日期都经过验证,与 2022 年的日期和星期完全匹配。我还更改了一些代码中的变量来测试其他年份,并且一切正常。
下一步是将 isholiday(), weekend(), 和 easter() 函数添加到代码中——整个 isholiday 包——以确认全年都能正确跟踪所有假日。随着 gauntlet 代码遍历每年的每一天,isholiday() 函数被调用。只有假日会被输出。作为复习,以下是 2022 年美国的全国假日及其日期:
-
新年:星期六,1 月 1 日
-
马丁·路德·金纪念日:星期一,1 月 17 日
-
乔治·华盛顿诞辰/总统日:星期一,2 月 21 日
-
复活节:星期日,4 月 17 日
-
阵亡将士纪念日:星期一,5 月 30 日
-
独立日:星期日,6 月 19 日
-
独立日观察日:星期一,6 月 20 日
-
独立日:星期一,7 月 4 日
-
劳动节:星期一,9 月 5 日
-
哥伦布日:星期一,10 月 10 日
-
退伍军人节:星期五,11 月 11 日
-
感恩节:星期四,11 月 24 日
-
圣诞节:星期日,12 月 25 日
-
圣诞节观察日:星期一,12 月 26 日
代码的更新可以在在线仓库中找到,作为 gauntlet02.c。它只对main()函数的输出格式进行了细微的更改。请记住,此代码需要链接数学库,-lm(小写的 L),以便复活节中的数学函数表现良好。以下是输出:
Saturday, January 1, 2022 is New Years Day
Monday, January 17, 2022 is Martin Luther King Day
Monday, February 21, 2022 is Presidents Day
Sunday, April 17, 2022 is Easter
Monday, May 30, 2022 is Memorial Day
Sunday, June 19, 2022 is Juneteenth
Monday, June 20, 2022 Juneteenth is observed
Monday, July 4, 2022 is Independence Day
Monday, September 5, 2022 is Labor Day
Monday, October 10, 2022 is Columbus Day
Friday, November 11, 2022 is Veterans Day
Thursday, November 24, 2022 is Thanksgiving
Sunday, December 25, 2022 is Christmas
Monday, December 26, 2022 Christmas is observed
isholiday() 函数可以集成到您的各种源代码文件中,或者您可以将其作为单独的模块链接到特殊程序中。这个过程在第十三章中有详细说明,该章节涵盖了输出彩色日历。
13 日历
不仅玛雅人发明了自己的日历。几乎每一种早期文化都为日子的流逝提供了一种分类形式。玛雅人在 2012 年获得了知名度,因为那是他们伟大的日历周期之一——长计数,或称 b’ak’tun 的结束。这并不是世界的末日——更像是翻过了一页那种廉价的保险公司日历。真糟糕。
大多数文化最初使用阴历,最终转向阳历,要么完全转向,要么不情愿地转向。希伯来、穆斯林、东正教和中国日历至今仍在使用,具有不同的年份值和阴历特征。尤利乌斯·凯撒试图更新罗马日历系统——在参议院多次对他进行攻击之前。教皇格里高利在 1582 年引入了我们的现代日历系统。
即使有日历工具在手,编写自己的日历工具也有助于磨练你在 C 语言中的时间编程技能。在本章中,你将学习到:
-
欣赏
cal程序 -
计算假日
-
编写周、月和年实用工具的代码
-
输出彩色文本
-
使用颜色编码重要日期
是的,Unix 自蒸汽动力时代起就提供了 cal 程序。然而,对于所有 C 语言程序员来说,理解日期和时间编程非常重要。通过在这些工具上练习,你可以更好地编写自己的定制日期程序。你还可以将这些技术应用于任何依赖于日期计算的程序。
13.1 日历程序
为 AT&T Unix(System V)开发的日历程序称为 cal。Linux 继承了这个优秀的工具。在不指定任何选项的情况下,默认输出显示当前月份的格式:
$ cal
December 2021
Su Mo Tu We Th Fr Sa
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 *20* 21 22 23 24 25
26 27 28 29 30 31
当前日期以反相显示,例如上面的第 20 天。
你可以在 cal 后面加上年份参数,以获取给定年份的完整 12 个月份日历:
$ cal 1993
你可以添加月份参数,以查看特定年份特定月份的日历:
$ cal 10 1960
月份可以用数字或名称指定。要查看接下来三个月的输出,请指定 -A2 参数:
$ cal -A2
像许多经典的 Unix 工具一样,cal 程序被许多容易忘记的选项和难以记忆的开关所累赘。
程序的输出是一致的:第一行是完整的月份名称和年份。下一行是星期几的标题。然后程序输出六行文本作为日历。当某个月没有第六周时,输出行的最后一行是空的。
cal 程序唯一不做的就是以横向输出日历。这项工作由更新的版本 ncal 程序处理:
$ ncal
December 2021
Su 5 12 19 26
Mo 6 13 *20* 27
Tu 7 14 21 28
We 1 8 15 22 29
Th 2 9 16 23 30
Fr 3 10 17 24 31
Sa 4 11 18 25
ncal 程序的优势在于它以四个月宽的网格输出整个年份,这使得在文本屏幕上阅读更加容易。当 cal 程序输出整个年份时,它使用三个月宽的网格。
你可以使用这些实用程序,继续你的 Linux 冒险之旅,但这样你又能学到什么呢?此外,你可以自定义日历输出,以满足你的偏好。就像任何编程项目一样,可能性是无限的——只要咖啡和薯片不耗尽。
日历趣闻
-
当尤利乌斯·凯撒在公元前 46 年采用日历时,这一年变成了 445 天。这次改变是为了使新历法与太阳年对齐。它成为了历史上最长的一年。
-
英文月份名称源自古老的罗马历法:Ianuarius(一月),Februarius(二月),Martius(三月),Aprilis(四月),Maius(五月),Iunius(六月),Quintilis(七月),Sextilis(八月),September,October,November,和 December。
-
一些宗教仪式仍然基于儒略历日期——特别是在东正教中。
-
当教皇格列高利在 1582 年采用当前的格里高利历法时,10 月 4 日紧接着就是 10 月 15 日。
-
大不列颠采用格里高利历的影响反映在cal程序 9 月 1752 年的输出中。输入cal 9 1752可以看到缩短的月份,因为旧历法已经调整为新历法。
-
即使是改进后的格里高利历,每年也会偶尔添加闰秒。
-
在特定月份中,星期五落在 13 号的出现次数每年从一次到三次不等。
-
在非闰年中,二月和三月共享相同的日期模式——当然,直到 3 月 29 日为止。
-
一个恒星年基于地球绕太阳一周所需的时间。它的值是 365.256363 天。
-
阴历年由 12 个月亮周期组成。它长 354.37 天。
-
每过几年,阴历就会增加闰月,以重新同步月亮周期与阳历。
-
一个银河年有 230,000,000(太阳)年那么长。这是太阳绕银河系运行的时间——或者是一个学步儿童找到一双匹配的袜子所需的时间。
13.2 应知的好日期
熟悉库中时间函数的 C 程序员知道,可以从操作系统的当前时间戳中轻松提取日期和时间信息:有年份、月份、月份中的天数和星期中的天数等值。这些就是你需要构建当前周或月日历的所有信息。但下个月怎么办?1978 年的 7 月怎么办?为了这些细节,你的代码必须更加努力工作。
进行日期计算很困难,因为有些月份有 30 天,有些有 31 天。每四年一次,二月决定再增加一天——但即使是这个闰日也不是一致的。为了正确编程日期,你必须编写一些工具。
13.2.1 创建常量和枚举日期
与我的大多数编程相比,日期编程似乎引入了大量的常量——特别是星期和月份名称的字符串和符号常量。对于我的日期编程,我使用这两种类型的常量,并试图在所有与日期和时间相关的函数中保持一致性。
对于星期和月份名称,我使用 const char* 指针——字符串常量。星期常量是:
const char *weekday[] = {
"Sunday", "Monday", "Tuesday", "Wednesday",
"Thursday", "Friday", "Saturday"
};
也可以使用简短版本:
const char *weekday[] = {
"Sun", "Mon", "Tue", "Wed",
"Thu", "Fri", "Sat"
};
这里是我的最喜欢的月份常量:
const char *month[] = {
"January", "February", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December"
};
每个语句创建一个指针数组;每个字符串的存储在程序运行时由程序分配。剩下的是一个地址数组。每个数组与从 localtime() 函数返回的 tm_wday 和 tm_mon 结构体成员的顺序相匹配。例如,一月的 tm_mon 成员编号为 0,月份数组 month[] 的零元素是一月的字符串。
const 分类器将这些数组声明为不可变,这防止了它们在代码的其他地方被意外更改。字符串可以传递给函数,但不要更改它们!这样做会导致不可预测的行为,但不是当它们被分类为常量时。
与这两个数组配对,我也使用枚举常量来表示星期和月份值。C 语言的 enum 关键字使得创建这些常量变得容易。
不要告诉我你因为觉得它奇怪而避免了 enum 关键字。我这样做太久了。然而,enum 帮助你定义常量,类似于数组以相同的数据类型定义变量组的方式。对于星期和月份名称,enum 提供了一个有用的工具来创建这些常量,并使你的代码更易于阅读。
作为复习,枚举关键字后面跟着一组大括号,其中包含枚举(编号)常量。值是按顺序分配的,从 0 开始:
enum { FALSE, TRUE };
在这里,常量 FALSE 被定义为 0;TRUE 被定义为 1。
你可以使用赋值运算符来改变数字顺序:
enum { ALPHA=1, GAMMA=5, DELTA, EPSILON, THETA };
此语句将常量 ALPHA 定义为 1。常量 GAMMA 被设置为 5,其余的常量按顺序编号:DELTA 是 6,EPSILON 是 7,THETA 是 8。
从 localtime() 函数报告的星期值从 0 开始,代表星期日。以下是用于在代码中声明星期值使用的 enum 语句:
enum { SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY };
对于 12 个月份,你可以将 enum 语句拆分到多行,就像你可以将任何语句拆分到 C 语言中的多行一样:
enum { JANUARY, FEBRUARY, MARCH, APRIL,
MAY, JUNE, JULY, AUGUST,
SEPTEMBER, OCTOBER, NOVEMBER, DECEMBER };
就像星期一样,localtime() 函数使用 0 来表示一月。这些枚举常量可以随时在你的代码中使用。例如:
printf(“%s\n”,month[JANUARY]);
使用本节之前定义的 month[] 数组,以及枚举常量 JANUARY,前面的语句输出文本“一月”。这种结构是自我文档化的,比使用 month[0] 或其他同样模糊的引用没有说明 0 可能意味着的内容更容易阅读。
13.2.2 查找星期几
到达目的地后,时间旅行者首先问的问题是,“现在是哪一年?”这个问题提供了一个宏观的答案,但它也帮助制作设计团队了解如何视觉上误解历史上的各个时代。它还允许当地人可预测地回答,“你在说什么,穿着银色睡衣的陌生人?”
对于日历编程来说,知道当前年份很重要。绘制日历还需要知道月份、日期,以及至关重要的星期几。日期和星期几的信息是解锁月份第一天的关键。其他时间片段可以很容易地从 time() 和 localtime() 函数报告的数据中获得。
在下一个列表中,time() 函数获取当前的纪元值,一个 time_t 数据类型。localtime() 函数使用这个值填充一个 tm 结构,date。然后解释并输出月份、月份日、年份和星期几的值,显示当前的日期和星期。
列表 13.1 weekday01.c 的源代码
#include <stdio.h>
#include <time.h>
int main()
{
const char *weekday[] = { ❶
"Sunday", "Monday", "Tuesday", "Wednesday",
"Thursday", "Friday", "Saturday"
};
const char *month[] = { ❷
"January", "February", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December"
};
time_t now; ❸
struct tm *date; ❹
time(&now); ❺
date = localtime(&now); ❻
printf("Today is %s %d, %d, a %s\n", ❼
month[ date->tm_mon ],
date->tm_mday,
date->tm_year+1900,
weekday[ date->tm_wday ]
);
return(0);
}
❶ 星期的字符串常量
❷ 年份的月份字符串常量
❸ 存储时钟滴答的变量
❹ 存储时间片段的变量
❺ 获取当前时钟滴答值
❻ 将 tm 日期结构填充为单独的时间值
❼ 输出结果
weekday01.c 中声明的字符串常量在本章中会被使用。请记住将它们定义为 const char 变量;你不想与字符串的内容纠缠,否则可能会引发各种混乱。
从列表 13.1 中的代码构建的程序输出一个简单的字符串,反映了当前的日期和星期:
Today is May 1, 2022, a Sunday
你可以使用程序生成的日期信息绘制日历——当前月份的日历。为了了解接下来的七月如何在日历上布局,你必须应用一些数学知识。为了帮助你,并避免所有那些无聊的试错,你可以从互联网上偷取一个算法。
在台式计算机出现之前,我记得我的小学老师演示了一个算法,用于找到任何日期、月份和年份的星期几。这个算法足够简单,以至于你可以在脑海中完成数学运算而不会爆炸。我忘记了老师写在黑板上的内容,但这里是从互联网上新鲜偷来的算法:
int t[] = { 0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4 };
year -= month<3;
r = ( year + year/4 - year/100 + year/400 + t[month-1] + day) % 7
数组 t[] 包含算法的魔法。我不确定数据引用的是什么,尽管我的猜测是它可能是一种月份模式索引。对于一月份和二月份,年份值减去 1。然后变量 r 捕获星期几,星期日为 0。我假设表达式中的大部分年份操作都是为了补偿闰年。此外,此算法假定一月份的值为 1,而不是 0。这些差异可以根据以下列表中的 dayoftheweek() 函数进行调整。
列表 13.2 The dayoftheweek() 函数
int dayoftheweek(int m,int d,int y) ❶
{
int t[] = { ❷
0, 3, 2, 5, 0, 3,
5, 1, 4, 6, 2, 4
};
int r;
y -= m<2; ❸
r = ( y + y/4 - y/100 + y/400 + t[m] + d) % 7; ❹
return(r);
}
❶ 月份值 m 从 0 到 11,对应于从一月到十二月;d 是月份中的日期,y 是完整的年份值(tm_year+1900)。
❷ 魔法数组
❸ m<2 的评估结果是 1 或 0,这被添加到年份变量中。
❹ 算法的其余部分,其中 m 作为元素编号使用,没有修改
我将 weekday01.c 中的 main() 函数更新为调用 dayoftheweek() 函数。为月份、日期和年份变量设置了特定值,这些值被传递给函数。然后输出结果。这些修改可在在线仓库中找到,作为源代码文件 weekday02.c。以下是一些示例输出:
February 3, 1993 is a Wednesday
获取这些四个日期细节——年、月、日和星期几——对于创建日历至关重要。下一步是计算月份的第一天,其余日期随后流动。
练习 13.1
如果你像我一样,你可能出于好奇玩过 weekday02.c 的源代码,输入你的生日或其他重要日期。但为什么还要不断更新源代码呢?
本练习的任务是修改 weekday02.c 的源代码,以便将命令行参数解释为要查找星期几的月份、日期和年份。如果你的地区不喜欢这种参数顺序——你可以更改它!以下是我解决方案的示例运行,我称之为 weekday:
$ weekday 10 19 1987
October 19, 1987 is a Monday
我的解决方案可在在线仓库中找到,作为 weekday03.c。
13.2.3 计算月份的第一天
今天是这个月的第 20 天——任何月份。它是一个星期一。这个月的第一天是星期几?
噢……
快点!使用图 13.1 中的便捷插图来帮助你计算。如果今天是 20 日星期一,那么对于任何一个月,如果 20 日是星期一,那么这个月的第一天总是星期三。

图 13.1 20 日是星期一的月份
当给定月份的某一天及其星期几时,计算机可以轻松地计算出这个月的第一天是星期几。以下是我设计的公式,用于在给定当前星期几和月份的日期的情况下确定月份的第一天的星期几:
first = weekday - ( day % 7 ) + 1;
为了与图 13.1 中的公式一起工作,假设今天是 23 日——正如我写这篇文本的时候。它是一个星期四,数字值为 4:
first = 4 - ( 23 % 7 ) + 1
first = 4 - ( 2 ) + 1
first = 3
当一个月的 23 日落在星期四时,第一天是星期三(值为 3)。请参考图 13.1 以确认。
为了测试我的月份第一天算法,下一个列表显示了获取当前日期的代码。它使用星期几和月份的日期值来执行算法,输出月份的第一天是星期几。
列表 13.3 first01.c 的源代码
#include <stdio.h>
#include <time.h>
int main()
{
const char *weekday[] = {
"Sunday", "Monday", "Tuesday", "Wednesday",
"Thursday", "Friday", "Saturday"
};
time_t now;
struct tm *date;
int first;
time(&now); ❶
date = localtime(&now); ❷
first = date->tm_wday - ( date->tm_mday % 7 ) + 1; ❸
printf("The first of this month was on a %s\n", ❹
weekday[first]
);
return(0);
}
❶ 获取当前时钟滴答值
❷ 填充 tm 结构的日期
❸ 执行算法
❹ 输出结果
first01.c 的源代码可在在线仓库中找到,但不要为此兴奋。如果当前工作日值大于月初的工作日值,程序将正常工作,就像在我的电脑上一样:
The first of this month was on a Wednesday
如果当前工作日值小于月初的工作日值,代码将失败。例如,如果今天是星期二(2)而月初是星期五(5),你会看到如下令人愉快的输出:
Segmentation fault (core dumped)
核心转储的原因是第一个存储的值低于 0。可以通过检查第一个的负值来纠正这个错误:
first = WEDNESDAY - ( 12 % 7 ) + 1;
if( first < 0 )
first += 7;
在这次代码更新中,我使用枚举常量 WEDNESDAY 作为工作日,12 作为月份的日期。月初是在星期六。以下是代码的输出:
The first of this month was on a Saturday
找出月初的星期几可能看起来很愚蠢。毕竟,从前面的部分中,你可以找到定位任何月份日期的星期几的代码。问题是,你通常不会给出月初的日期。当然,你可以编写更多的代码,在修改当前月份的日期后调用 dayoftheweek() 函数。但我发现使用算法对我来说效果最好。
练习 13.2
是时候编写另一个函数了!从源代码文件 thefirst02.c 中提取 main() 函数的算法部分,并将其设置为它自己的函数 thefirst()。此函数的原型如下:
int thefirst(int wday, int mday)
变量 wday 是星期几,mday 是月份的日期。返回的值是月初的工作日,范围是 0 到 6。
我的解决方案作为 thefirst03.c 可在在线仓库中找到。我在 main() 函数中编写了代码来报告当当前天是 25 号,星期六时的月初。代码中的注释解释了我的方法。
13.2.4 识别闰年
讨论日期编程时,不可避免地要提到关于闰年的棘手问题。二月天数的不同数量是宇宙试图告诉我们,如果一切都在完美的平衡中,那么什么都不会存在的另一个例子。
当我处理月份中的天数时,我通常会编写一个这样的数组:
int mdays[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
此数组包含从一月到十二月的每月天数。对于二月,值为 28。但平均每四年中就有一个二月有 29 天——这是闰年中的额外闰日。
要确定哪些年份是闰年,并调整 mdays[],你必须做一些数学计算。以下是按消除顺序排列的闰年规则:
-
如果年份既能被 100 整除也能被 400 整除,则它是闰年。
-
如果年份只能被 100 整除,则它不是闰年。
-
如果年份能被 4 整除,则它是闰年。
通常,闰年规则是按相反顺序列出的:如果年份能被 4 整除,则它是闰年,除非年份能被 100 整除,在这种情况下它不是闰年,除非年份也能被 400 整除,在这种情况下它是闰年。
明白了?
不,倒着列出规则更容易,这也有助于编写下一个显示的闰年函数february()。它的目的是返回二月的天数,然后将该值设置到一个数组,如 mdays[](前面已显示)。计算闰年的规则以一系列基于传递的年份值的if测试的形式出现在函数中。
列表 13.4 february()函数
int february(int year)
{
if( year%400==0 ) ❶
return(29);
if( year%100==0 ) ❷
return(28);
if( year%4!=0 ) ❸
return(28);
return(29); ❹
}
❶ 如果年份能被 400(包括 100)整除,那么它是一个闰年。
❷ 如果年份能被 100 整除,那么它是一个闰年。
❸ 如果年份不是四的倍数,则它不是闰年。
❹ 否则,它是一个闰年。
我在源代码文件 leapyear01.c 中使用了february()函数,该文件可在在线仓库中找到。在main()函数中,一个循环测试了 1584 年至 2101 年的年份,这涵盖了从格里高利历开始到蜥蜴人最终入侵的时间。如果年份是闰年,即february()函数返回 29,则输出其值。以下是样本运行的末尾部分:
...
1996
2000
2004
2008
2012
2016
2020
2024
2028
2032
代码准确地识别了 2000 年是一个闰年。
february()函数将在本章后面演示的程序中使用,以更新 mdays[]数组,以反映给定年份二月应有的天数。
13.2.5 获取正确的时区
在处理日期时,需要考虑的一个奇怪问题是计算机的时区。这个值是根据系统的区域设置设置的。它反映了当地的白天时间,这是在 C 语言中编程日期和时间时访问的。
通常,时区细节会被忽略;你想要从time()函数获取的是计算机或其他设备的当前日期和时间。然而,如果你的代码没有考虑到格林威治标准时间(GMT)和你的本地时区之间的差异,你所进行的时间计算可能会不准确。
例如,我的时区是太平洋标准时间。如果不小心,八小时的时间差会导致结果偏差八小时。信不信由你,这种时间准确性对于程序输出准确的日历是必要的。
为了强调这一担忧,考虑以下列表中的源代码。它将一个 time_t 值初始化为 0,这是 Unix 纪元的黎明,或者说 1970 年 1 月 1 日的午夜。这个值通过一个printf()语句输出,该语句使用ctime()函数将time_t值转换为人类可读的字符串。
列表 13.5 timezone01.c 的源代码
#include <stdio.h>
#include <time.h>
int main()
{
time_t epoch = 0; ❶
printf("Time is %s\n",ctime(&epoch) ); ❷
return(0);
}
❶ 将time_t值预设为零,Unix 纪元的黎明
❷ 输出纪元的时间字符串
当程序运行时,我在我的计算机上看到以下文本:
Time is Wed Dec 31 16:00:00 1969
输出显示在纪元开始前八小时(午夜,1 月 1 日),因为我的计算机时区设置为 GMT-8(格林威治标准时间减去八小时),或太平洋标准时间。输出是准确的:当英国 1 月 1 日午夜时,这里西海岸的美国是前一天下午 4:00。
在 Linux 中,您可以通过检查/etc/localtime 符号链接来检查计算机的时区信息。使用ls -l(连字符-L)命令:
ls -l /etc/localtime
下面是我系统上看到的相关输出部分:
/etc/localtime -> /usr/share/zoneinfo/America/Los_Angeles
我的时间区域设置与洛杉矶相同,尽管我住的地方人们要友好得多。您看到的输出是您系统的本地时间,这是 Linux 首次配置时设置的值。
您的代码无需查找/etc/localtime 符号链接来确定计算机的时区或尝试更改此设置。相反,您可以编写代码临时设置 TZ(时区)环境变量为 GMT。要更新 timezone01.c 的源代码,您必须添加两个函数:putenv()和tzset()。
putenv()向程序的本地环境添加一个环境变量;更改不会影响 shell,因此您不需要在代码的后续部分撤销它。man页面的格式是
int putenv(char *string);
字符串是要添加的环境条目。在这种情况下,它是 TZ=GMT,表示“时区等于格林威治标准时间”,即您想要的时区。此函数需要包含 stdlib.h 库。
tzset()函数设置程序的时间区域——但仅在运行期间。该函数不会以其他方式更改系统。以下是man页面的格式:
void tzset(void);
tzset()函数不需要任何参数,因为它使用 TZ 环境变量来设置程序的时间区域。为了使此函数正常工作,必须包含 time.h 头文件。
要更新 timezone01.c 的代码,请在printf()语句之前添加以下两个语句:
putenv("TZ=GMT");
tzset();
并且不要忘记包含 stdlib.h 头文件,以便于putenv()函数。这些更改可以在在线仓库中的源代码文件 timezone02.c 中找到。以下是程序的输出:
Time is Thu Jan 1 00:00:00 1970
现在的输出反映了真正的 Unix 纪元,因为程序内部将时区更改为 GMT。
此代码将在本章后面部分使用,当生成完整的年历表时。如果不进行此调整,日历将输出错误年份,根据您的本地时区,可能是在期望年份之前或之后。时区调整确保日历正确对齐。您也可以在其他依赖于精确时间日期计算的程序中使用这个技巧。
13.3 日历实用工具
Linux 的cal程序的功能比你想象的要多。它令人印象深刻。鉴于其丰富的选项和开关,cal可以以特定格式输出给定区域给定范围内的日期。与其他我模仿的 Linux 命令行程序一样,我的日历程序的目标是具体,而不是编写一个可以做所有事情的程序。
我最初编写日历程序是因为我想以比cal程序生成的更宽的格式看到当前月份的输出。此外,我只是想看看我是否可以为任何给定的月份编写日历。结果是我的month程序,我使用它的频率比cal高得多。
在任何日历工具中,立即需要做出的一个决定是周从星期一还是星期日开始。cal 程序(正如你可能猜到的)有设置周开始日的选项。在本章中我的日历程序系列中,假设周从星期日开始。
13.3.1 生成一周
我认为最简单的日历只会输出当前日期——类似于这样:
September 2022
Friday
23
大多数人都希望日历能提供更多功能。但我的第一个日历程序并不是从当前月份开始,而是显示当前周。这段代码依赖于知道当前月份的日期和星期几。这是我想要在最终程序中看到的输出:
December / January - Week 52
Sun Mon Tue Wed Thu Fri Sat
[26] 27 28 29 30 31 1
当前日期是 12 月 26 日。这个月(和年份)在星期五结束,星期六是 1 月的第一天,也是新年的开始。这是当年的第 52 周。
在输出所有这些信息之前,我想从小处着手,只输出当前周。一个循环输出了从星期日到星期六的每一天。无论当前是星期几,输出都从星期日开始。今天的日期用括号突出显示。
localtime() 函数会报告关于当前星期的详细信息。我用来确定星期日日期的公式是:
sunday = day_of_the_month - weekday;
月份的日期值可以在 tm 结构的 tm_mday 成员中找到。今天的星期值是 tm_wday 成员。例如,如果今天是星期四的 16 日,公式如下:
sunday = 16 - 4;
星期日的日期是 12 日,这在第 13.1 节中提到的月份日历中得到了验证。然后使用这个值在循环中输出一周的七天:
for( d=sunday; d<sunday+7; d++ )
我以四个字符宽的空间输出连续的日期。这个空间允许今天的日期被方括号包围输出。
我在 week01.c 程序中的完整代码将在下一部分列出。它从 time() 和 localtime() 函数读取数据,输出当前月份(但不包括年份),并输出当前周的日期。我使用变量 day、month 和 weekday 作为 tm 结构相关成员的可读性简写。
列表 13.6 week01.c 的源代码
#include <stdio.h>
#include <time.h>
int main()
{
const char *months[] = {
"January", "February", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December"
};
time_t now;
struct tm *date;
int day,weekday,month,sunday,d;
time(&now); ❶
date = localtime(&now); ❷
day = date->tm_mday; ❸
month = date->tm_mon;
weekday = date->tm_wday;
sunday = day - weekday; ❹
printf(" %s\n",months[month]); ❺
printf("Sun Mon Tue Wed Thu Fri Sat\n"); ❻
for( d=sunday; d<sunday+7; d++ ) ❼
{
if( d==day ) ❽
printf("[%2d]",d);
else
printf(" %2d ",d); ❾
}
putchar('\n');
return(0);
}
❶ 获取当前时间的时钟滴答数
❷ 将 time_t 值转换为 tm 结构成员
❸ 为方便和可读性设置日期、月份和星期值
❹ 计算星期日的日期
❺ 输出第一行,当前月份
❻ 输出星期表头行
❼ 遍历从星期日到星期日 + 7 的每一天
❽ 对于当前日期,在括号中输出其值
❾ 输出每隔一天不带括号
列表 13.6 的源代码可以在在线仓库中找到,作为 week01.c。它的核心由三行输出组成,第三行由循环生成。循环从星期日开始输出星期,当前日期突出显示,如示例输出所示:
September
Sun Mon Tue Wed Thu Fri Sat
12 13 14 15 [16] 17 18
当然,这段代码并不完美。如果月初不是星期日,你会看到如下输出:
September
Sun Mon Tue Wed Thu Fri Sat
-3 -2 -1 0 1 [ 2] 3
同样,在月底,你可以看到如下输出:
September
Sun Mon Tue Wed Thu Fri Sat
26 27 [28] 29 30 31 32
对于我对代码的第一次更新,我在输出中添加了另一个决策:在for循环中,如果变量 d 的值小于 1,则输出空格而不是日期值。同样,当日期值大于当前月的天数时,也会输出空格。
确定月底需要更多的代码。具体来说,你必须添加一个 mdays[]数组,该数组列出了每个月的天数,还需要添加february()函数,这个函数在本章前面已经介绍过。这个函数是必要的,以确保当前年份二月的天数是正确的。
mdays[]数组被添加到main()函数的变量声明部分:
int mdays[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
february()函数也被添加到源代码中。在调用localtime()函数之后,调用february()函数来更新 mdays[]数组,元素一:
mdays[1] = february(date->tm_year+1900);
以下代码显示了main()函数中更新的for循环。第一个if决策输出超出范围的日期的空格。else部分由代码的第一个版本的原始if-else决策组成。
列表 13.7 week02.c 中找到的更新后的for循环
for( d=sunday; d<sunday+7; d++ )
{
if( d<1 || d>mdays[month] ) ❶
printf(" ");
else
{
if( d==day ) ❷
printf("[%2d]",d);
else
printf(" %2d ",d); ❸
}
}
❶ 如果日期 d 超出范围,小于 1,或大于当前月的天数,则输出空格
❷ 输出带括号的当前日
❸ 输出其他天数时不加括号
这个源代码的更新可以在在线仓库中找到,作为 week02.c。它准确地解决了日期溢出问题,如下面的示例输出所示:
September
Sun Mon Tue Wed Thu Fri Sat
1 [ 2] 3
月份结束时,输出现在看起来是这样的:
September
Sun Mon Tue Wed Thu Fri Sat
26 27 [28] 29 30
当今天是星期六且是第一天时,会出现令人愉快的尴尬输出:
January
Sun Mon Tue Wed Thu Fri Sat
[ 1]
我不希望这个程序显示多周,这最终会使其变成一个月程。不,更希望的是输出前一个月的最后几天,如下所示:
December / January
Sun Mon Tue Wed Thu Fri Sat
26 27 28 29 30 31 [ 1]
由于两个月份的日期都出现在输出中,因此两个月份都列在标题中。当前日期被突出显示,以便精明的用户(也就是你)可以知道这个星期是去年最后一周,但今天的日期是新年第一天。
从 week02.c 中更新的代码需要添加一个新变量 pmonth,它包含上个月的值。pmonth 的计算发生在读取并存储当前月值的变量 month 之后:
pmonth = month-1;
if( pmonth<0 )
pmonth=11;
上个月的值是当前月值的减一。如果是 1 月(0),则上个月的值是负数。if测试捕获了这个条件,在这种情况下,pmonth 的值被设置为 11,即 12 月。
接下来,进行一系列测试以确定要输出哪些月份名称:单个月份、当前和上个月,或者当前和下个月。这些测试在此处展示。
列表 13.8 确定要输出哪些月份的测试(来自 week03.c)
if( sunday<1 ) ❶
printf(" %s / %s\n",months[pmonth],months[month]);
else if( sunday+6 > mdays[month] ) ❷
{
if( month==11 ) ❸
printf(" %s / %s\n",months[month],months[0]);
else
printf(" %s / %s\n",months[month],months[month+1]); ❹
}
else
printf(" %s\n",months[month]); ❺
❶ 当计算上个月的天数时,显示上个月和当前月
❷ 测试是否输出下个月的天数
❸ 对于十二月,直接输出十二月和一月
❹ 对于其他月份,输出当前月和下个月的名称
❺ 输出中不显示前一个月或下一个月的日期。
要输出前一个月或下个月的日期,必须在 main() 函数中的 for 循环进行修改。再次使用 if else-if else 结构,如下一列表所示。进行计算以生成前一个月的尾随日期和下一个月的后续日期。
列表 13.9 更新的 for 循环(来自 week03.c)
for( d=sunday; d<sunday+7; d++ )
{
if( d<1 ) ❶
printf(" %2d ",mdays[pmonth]+d); ❷
else if( d>mdays[month] ) ❸
printf(" %2d ",d-mdays[month]); ❹
else ❺
{
if( d==day )
printf("[%2d]",d);
else
printf(" %2d ",d);
}
}
❶ 上个月仍有要输出的天数。
❷ 使用上个月的天数减去变量 d 的值来输出日期
❸ 如果变量 d 的值大于当前月份的天数 . . .
❹ . . . 使用 d 减去当前月的天数来输出下个月的天数
❺ 最终的 else 块以原样输出当前月的天数。
这些决策看起来很混乱,但它们是必要的,以填充重叠月份的正确日期。完整的源代码可以从在线仓库中获取,作为 week03.c。以下是一个示例运行:
December / January
Sun Mon Tue Wed Thu Fri Sat
[26] 27 28 29 30 31 1
在上面,当今天是 12 月 26 日时,输出当前周的下一月和该月的第一天。当上个月的天数出现在该周时,也会显示类似的输出:
November / December
Sun Mon Tue Wed Thu Fri Sat
28 29 [30] 1 2 3 4
然后:
November / December
Sun Mon Tue Wed Thu Fri Sat
28 29 30 1 2 [ 3] 4
到目前为止,程序已经基本完成。然而,作为一个极客,我总是寻找改进代码的方法。我能想到的唯一要添加的功能是输出当前的周数。
每年有 52 周,尽管它们并不遵循固定的模式。毕竟,新年的第一周可能包含来自十二月的几天。据我所知,当 1 月 1 日在星期三或更早的时候,它就是新年的第一周。否则,1 月 1 日是上一年的第 52 周。
在闰年中,当 1 月 1 日在星期四时,会发生异常。尽管它可能是前一年的第 52 周,但闰年可以有 53 周。下一个有 53 周的年份是 2032 年——所以请保留这本书!
我第一次尝试计算当前周数的结果是这个公式:
weekno = (9 + day_of_the_year - weekday) / 7;
年内天数值存储在 tm 结构的成员 tm_yday 中。星期值是 tm 结构的成员 tm_wday,其中星期天为零。表达式除以 7,四舍五入为整数并存储在变量 weekno 中。
必须测试 weekno 的值,以确定新年的第一周——特别是当 1 月 1 日在周中较晚的时候。在这种情况下,由方程返回的 weekno 值为 0。它应该是 52,因为技术上它是上一年的最后一周。因此,在输出值之前需要进行一些调整:
if( weekno==0 )
weekno = 52;
要完成来自 week03.c 的代码更新,你必须从输出当前月或一对月份的 printf() 语句中删除所有换行符。然后跟上一个新的 printf() 语句:
printf(" - Week %d\n",weekno);
最终程序以 week04.c 的形式存储在在线仓库中。以下是一个示例运行:
December / January - Week 52
Sun Mon Tue Wed Thu Fri Sat
[26] 27 28 29 30 31 1
这是同一周 1 月 1 日的输出:
December / January - Week 52
Sun Mon Tue Wed Thu Fri Sat
26 27 28 29 30 31 [ 1]
顺便说一句,你也可以使用strftime()函数来获取当前的周数。占位符是%W,但它将周一作为一周的第一天。周数值被设置到一个字符串中,必须将其转换为整数才能进行任何数学运算。就像我选择用于代码更新的公式一样,strftime()函数在年初的第一周返回 0。
13.3.2 显示月份
月份程序是我写的第一个日历程序。我使用它来帮助我的 C 编程博客文章(c-for-dummies.com/blog),我提前写好并安排在以后发布。显然,我可以用cal程序,它默认输出当前月份:
December 2021
Su Mo Tu We Th Fr Sa
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 *27* 28 29 30 31
哦,cal程序还能做很多事情。但我没有让它的高度灵活性阻止我。以下是我程序的输出,我称之为month:
December 2021
Sun Mon Tue Wed Thu Fri Sat
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 [27] 28 29 30 31
输出稍微宽一些,我觉得更易读——甚至在我需要阅读眼镜之前。毕竟,我的目标是输出当前月份。cal程序输出的尺寸设计得如此之好,以至于整个年份可以以三个月宽、三列深的方式显示。我的month程序可以输出三个月份,但文本无法适应 80 列屏幕。我在本章的后面会提到这个问题。
一个月的日期实际上是一个网格:行代表周,列代表星期。它不是一个完整的网格,因为起始点位于特定的列;输出的第一行是特殊的。其余的日期通过网格流动,直到最后一天,输出停止。
下面的列表展示了我的测试代码,以确保月份程序能够正常工作。它输出了 2021 年 12 月。重点是嵌套循环:while循环使用变量 day 遍历月份中的每一天。内层的for循环处理周。第一周是特殊的,它为上个月的天数输出空白。
列表 13.10 month01.c 的源代码
#include <stdio.h>
int main()
{
int mdays,today,first,day,d;
mdays = 31; ❶
today = 27; ❷
first = 3; ❸
printf("December 2021\n");
printf("Sun Mon Tue Wed Thu Fri Sat\n");
day = 1; ❹
while( day<=mdays ) ❺
{
for( d = 0; d < 7; d++) ❻
{
if( d<first && day==1 ) ❼
{
printf(" "); ❽
}
else ❾
{
if( day == today ) ❿
printf("[%2d]",day);
else
printf(" %2d ",day); ⓫
day++; ⓬
if( day>mdays ) ⓭
break;
}
}
putchar('\n');
}
return(0);
}
❶ 预设本月的天数(例如十二月)
❷ 将今天设置为 27 号
❸ 本月的第一个星期三。
❹ 从 1 开始,即本月的第一天
❺ 遍历月份中的每一天
❻ 遍历一周,从周日(0)到周六(6)
❼ 检查月份的第一周
❽ 输出空白,并且不增加天数计数器!
❾ 在第一周/天过去后输出日期
❿ 突出显示今天
⓫ 正常输出日期
⓬ 增加天数计数器
⓭ 在月底的最后一天退出循环
从列表 13.10 中,在 for 循环中可以看到,这个月的第一周与其他周的处理方式不同。在月份的第一天之前不应有任何输出。变量 first 保存的是星期值——3 代表星期三——因此,在月份第一天之前的 if 测试是 TRUE:
if( d<first && day==1 )
{
printf(" ");
}
变量 d 跟踪一周中的天数,从星期日到星期六(0 到 6)。变量 first 保存的是月份第一天的星期。变量 day 代表月份中的某一天。
当遇到月份的第一天时,if 决策的 else 部分接管,输出月份网格的其余部分。此版本月份程序的示例输出在前面已展示。源代码文件 month01.c 可在在线仓库中找到。
我对变量 mdays、today 和 first 进行了调整,以确保月份程序输出各种月份配置。改进代码的下一步是使用当前月份的数据。这一改进需要几个步骤。
首先,代码必须包含前面在本章中介绍的 february() 和 thefirst() 函数。您需要添加 february() 函数以完成包含当前年份每月天数的 mdays[] 数组。另一个函数可以告诉您月份的第一天是星期几。
第二,变量声明被更新,包括月份名称常量、mdays[] 数组和其他报告当前月份日期所需的变量:
const char *months[] = {
"January", "February", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December"
};
int mdays[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
time_t now;
struct tm *date;
int month,today,weekday,year,first,day,d;
第三,调用了 time() 和 localtime() 函数以获取当前日期的详细信息:
time(&now);
date = localtime(&now);
第四,当前日期信息被打包到变量 month、today、weekday 和 year 中。通过调用 february() 函数更新了二月份的天数,并将变量 first 设置为月份第一天的星期:
month = date->tm_mon;
today = date->tm_mday;
weekday = date->tm_wday;
year = date->tm_year+1900;
mdays[1] = february(year);
first = thefirst(weekday,today);
第五,更新了输出当前月份和年份的 printf() 语句:
printf("%s %d\n",months[month],year);
最后,原始源代码文件中的 mdays 变量必须替换为最终版本中的 mdays[month]。
代码的这个更新版本被命名为 month02.c,可在在线仓库中找到。与原始的静态程序不同,这个版本会输出当前月份。
练习 13.3
month 程序的输出将当前月份和年份作为顶部标题列出,但右对齐。更新代码以创建一个新的函数,center()。该函数的目的是在特定宽度内输出文本字符串。以下是使用的原型:
void center(char *text,int width);
该函数计算字符串 text 的长度,然后进行复杂的数学计算以在给定的宽度内居中字符串。如果字符串比宽度长,则输出并截断到宽度。
对 month02.c 代码的这次更新不仅仅是编写 center() 函数。确保以正确的字符串参数调用该函数,并且结果应输出在日历的顶部。我的解决方案被命名为 month03.c,并且可在在线仓库中找到。
Exercise 13.4
No, you’re not quite done with the month program. Your final task is to modify the main() function from month03.c (see the preceding exercise) so that any command-line arguments are parsed as a month-and-year value. Both values must be present and valid; otherwise, the current month is output. My solution is available in the online repository as month04.c.
13.3.3 Displaying a full year
The issue with outputting a full year has nothing to do with fancy date coding; the math and functions required are already presented so far in this chapter. The problem is getting the output correct—rows and columns.
Figure 13.2 shows the output from a year program that uses the same format as the months program, shown earlier in this chapter. You see three columns by four rows of months. Steam output generates the text, one row at a time. Some coordination is required to produce the visual effect you see in the figure. Further, the output is far too wide for a typical 80-column text screen. So, while the math and functions might be known, fine-tuning the output is the big issue.

Figure 13.2 Output from a year program that uses the same format as the month program
Rather than go hog-wild and attempt to code a multicolumn year program all at once, I sought to first code a long vertical column for the current year. The code, year01.c, is available in the online repository. It uses the existing center() and february() functions.
The main() function consists of two parts. The first part initializes all variables to a specific year. I chose the year 2000. The code sets the weekday for January 1, which starts the entire year. Once established, the second part of the main() function consists of a loop to output the months.
The following listing shows the initialization portion of the main() function. The code is cobbled together from the month series of programs, though the program doesn’t scan command-line input.
Listing 13.11 Initialization in the main() function from year01.c
const char *months[] = { ❶
"January", "February", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December"
};
int mdays[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
struct tm date;
int month,weekday,year,day,dow;
const int output_width = 27;
char title[output_width];
date.tm_year = 2000-1900; ❷
date.tm_mon = 0;
date.tm_mday = 1;
date.tm_hour = 0; ❸
date.tm_min = 0;
date.tm_sec = 0;
putenv("TZ=GMT"); ❹
tzset();
mktime(&date); ❺
weekday = date.tm_wday; ❻
year = date.tm_year+1900; ❼
mdays[1] = february(year); ❽
❶ Constants and stuff from earlier date code
❷ Y2K is hardcoded here, minus 1900 for the tm structure.
❸ Remember to set hours, minute, and seconds.
❹ You must set the time zone, or else January 1 may fall in the previous year.
❺ Updates the tm date structure, specifically with the weekday value
❻ Uses weekday for readability and to save typing molecules
❼ Adjusts the year value
❽ Sets the proper number of days in February
It’s important that the time zone be set to GMT, as shown in listing 13.11. In my original code, I forgot to do this step—even though I warned about doing so earlier in this chapter—and the oversight caused lots of grief. As I was testing the code late in the evening, the years and dates were off. Only by asserting GMT as the time zone does the calendar year properly render, no matter what your time zone.
下一个展示了 main() 函数的嵌套循环。它们由一个外部 for 循环处理月份和一个内部 while 循环处理月份中的天数组成。变量 dow 计算工作日。它是手动更新的,而不是在循环中,因为每个月的第一天工作日并不相同。
列表 13.12 year01.c 中 main() 函数的输出循环
dow = 0; ❶
for( month=0; month<12; month++ ) ❷
{
sprintf(title,"%s %d",months[month],year); ❸
center(title,output_width);
printf("Sun Mon Tue Wed Thu Fri Sat\n");
day = 1; ❹
while( day<=mdays[month] ) ❺
{
if( dow<weekday && day==1 ) ❻
{
printf(" ");
dow++;
}
else
{
printf(" %2d ",day); ❼
dow++; ❽
if( dow > 6 ) ❾
{
dow = 0; ❿
putchar('\n'); ⓫
}
day++; ⓬
if( day>mdays[month] ) ⓭
break;
}
}
weekday = dow; ⓮
dow = 0; ⓯
printf("\n\n");
}
❶ 星期几循环变量,星期几
❷ 外部循环遍历年份的月份。
❸ 输出居中的月份和年份,以及星期几标题行
❹ 初始化月份的第一天
❺ 遍历月份中的每一天
❻ 第一周是特殊的;变量星期几持有该月的第一天工作日。在此之前的输出为空白。
❷ 输出日期
❽ 增加星期几的天数,从星期日(0)到星期六(6)
❾ 检查星期几溢出
❿ 将星期几重置为星期日(0)
⓫ 为下一周输出一个换行符
⓬ 增加月份的天数计数器
⓭ 测试月份的结束
⓮ 设置下一个月的第一天
⓯ 将星期几重置为下一个月的星期日
变量 dow 与变量 weekday 一起使用,以输出一月份的第一周。之后,变量 weekday 和 dow 被更新,以便正确设置下一个月的开始日。
完整代码可在在线仓库中找到,作为 year01.c。以下是输出的一部分:
January 2000
Sun Mon Tue Wed Thu Fri Sat
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
February 2000
Sun Mon Tue Wed Thu Fri Sat
1 2 3 4 5
6 7 8 9 10 11 12
. . .
每个月都跟随,全部在一页长的文本中。输出对于 2000 年是准确的,但谁想重温那段时光呢?
练习 13.5
修改 year01.c 代码,使其接受一个命令行参数以输出年份。如果没有提供命令行参数,则输出当前年份。所有必要的更改都发生在 main() 函数中。记住,年份输入和 tm_year 值相差 1900。
我的解决方案命名为 year02.c,并可在在线仓库中找到。代码中的注释解释了我的方法。
13.3.4 将整年放入网格中
要在文本屏幕上以网格形式输出整年的月份,需要逐行输出。year01.c 代码中使用的这种方法不起作用;流输出不允许你在文本屏幕上回退或移动光标。每一行必须一次处理,输出不同月份的不同日期需要多个步骤。因此,我丢弃了 year01.c 代码的大部分内容,从头开始。
日历仍然按月递进。但月份被组织成列。对于每一列,输出每个月的单独行。图 13.3 阐述了这种方法,每个月一次输出一行:两个标题行,一个月的特殊第一周行,然后是该月的剩余周。每个月必须输出六周,即使该月只有五周的日期。

图 13.3 输出多列显示的方法
要开始编写代码,我从现有的 year 源代码文件中复制了 center() 和 february() 函数。main() 函数保留了大部分为更新 year02.c 以读取命令行参数所需的设置。在此基础上,我构建了其余的代码。
从上到下,第一个更改是添加一个定义的常量,COLUMNS:
#define COLUMNS 3
这个符号常量设置了输出列的宽度,但用户不应该更改这个值:COLUMNS 的有效值限于 12 的因子。你可以将其定义为 2、3、4、6 或甚至 12。但如果你使用其他值,代码中的数组将会溢出。
下一个需要更新的函数是 center()。如本章前面所述,该函数在给定的宽度内居中月份和年份,但不填充文本的其余部分。为了使月份在网格中对齐,标题行一必须以一致的大小输出。下一个列表显示了为逐行输出所需的 center() 函数更新。宽度参数居中文本并设置两侧填充的空格数。
列表 13.13 更新的 center() 函数
void center(char *text,int width)
{
int s,length,indent;
length = strlen(text);
if( length < width )
{
indent = (width-length)/2;
for(s=0;s<indent;s++)
putchar(' ');
while( *text ) ❶
{
putchar(*text); ❷
text++; ❸
s++; ❹
}
for(;s<width;s++) ❺
putchar(' ');
}
else
{
for(s=0;s<width;s++)
putchar(*text++);
}
}
❶ 用输出字符串的每个字符代替 puts() 函数
❷ 输出每个字符
❸ 增加指针
❹ 跟踪变量 s 以确定最终输出宽度
❺ 输出空格以匹配宽度值
在更新 center() 函数后,我的方法是通过单独输出第一行来测试——只是为了看看它是否工作。程序输出了标题行一,月份和年份。我使用了以下代码:
for( month=0; month<12; month+=COLUMNS ) ❶
{
for( c=0; c<COLUMNS; c++ )
{
sprintf(title,"%s %d",months[month+c],year);
center(title,output_width);
printf(" "); ❷
}
putchar('\n');
}
❶ 跳过每个 COLUMN 月份以输出行
❷ 三个空格
prntf() 语句输出三个空格,以保持每个月/年标题在网格中分开。这个程序作为测试,以确保网格按我想要的顺序输出。以下是一个示例运行,减去一些空格以适应这一页:
January 2021 February 2021 March 2021
April 2021 May 2021 June 2021
July 2021 August 2021 September 2021
October 2021 November 2021 December 2021
添加星期标题行是下一步。它需要在外部月份循环内部添加第二个 for 循环。实际上,输出中的每一行代表代码中的一个 for 循环。这些语句插入在结束上一个 for 循环的 putchar('\n') 语句之后,这也为分隔列添加了空格:
for( c=0; c<COLUMNS; c++ )
{
printf("Sun Mon Tue Wed Thu Fri Sat ");
}
到这一点,我对自己有信心,可以以网格的形式输出年日历。关键是使用顺序的 for 循环,每个月一行。每个 for 循环中的最后一个语句填充空格,以保持每个月网格在每列中分开。
最难输出的行是月份的第一周。与其他章节中的其他日历程序一样,月份的第一天从特定的星期几开始。我可以用 first() 函数确定每个月的起始星期几,但相反,我在 main() 函数中创建了一个数组:
int dotm[12];
dotm[](月份的日期)数组保存了每年每个月的起始日。它的值与 weekday 变量相同,0 到 6。weekday 变量已经保存了 1 月 1 日的周几,存储在 dotm[] 数组的第 0 个元素中。然后一个 for 循环填充剩余月份的值:
dotm[0] = weekday;
for( month=1; month<12; month++ )
{
dotm[month] = (mdays[month-1]+dotm[month-1]) % 7;
}
for 循环中的语句将上个月的天数 mdays[month-1] 与上个月每周的起始日 dotm[month-1] 相加。这个总和模 7,得到变量 month 表示的月份的每周起始日。当循环完成后,dotm[] 数组将保存给定年份每个月第一天的起始周几。
列表 13.14 显示了生成每年每个月第一行的下一个嵌套 for 循环。dotm[] 数组中的起始值决定了哪一天开始这个月。从 1 开始,月份的日期存储在变量 day 中。
列表 13.14 第三层嵌套 for 循环,输出每年每个月的第一周
for( c=0; c<COLUMNS; c++ )
{
day = 1; ❶
for( dow=0; dow<7; dow++ ) ❷
{
if( dow<dotm[month+c] ) ❸
{
printf(" ");
}
else
{
printf(" %2d ",day); ❹
day++; ❺
}
}
printf(" "); ❻
dotm[month+c] = day; ❼
}
putchar('\n');
❶ 初始化月份的日期
❷ 遍历每周的日期
❸ 如果月份的第一天周几尚未发生,则输出一个空格
❹ 否则,输出日期,就像在其他日历程序中做的那样
❺ 增加月份的日期
❻ 在输出月份的周之后,填充两个空格
❼ 保存下个行星期日位置的月份日期
列表 13.14 中显示的大多数 for 循环是从本章前面展示的代码中借用的。不同的是,保存下一天数的月份输出:dotm[month+c] = day。这个值,可用变量 day,替换了 dotm[] 数组中的月份起始日。它用于输出下一行,设置下个星期日的月份值。
最后的 for 循环负责输出每个月的 2 到 6 行。它包括一个嵌套的 for 循环,用于处理每周的每一天,外部的 for 循环处理每一周。以下列表显示了详细信息,它再次使用 dotm[] 数组来保存每个后续周的起始日。
列表 13.15 main() 函数的最终 for 循环
for( week=1; week<6; week++ ) ❶
{
for( c=0; c<COLUMNS; c++ ) ❷
{
day = dotm[month+c]; ❸
for( dow=0; dow<7; dow++ ) ❹
{
if( day <= mdays[month+c] ) ❺
printf(" %2d ",day);
else
printf(" "); ❻
day++;
}
printf(" "); ❼
dotm[month+c] = day; ❽
}
putchar('\n'); ❾
}
putchar('\n'); ❿
❶ 每个月 6 周,无论该月是否有第六周
❷ 首先按列输出——每个列然后每个周(外部循环)。
❸ 更新星期日输出的月份日期
❹ 最内层(第四层嵌套)的循环输出工作日。
❺ 对于当前月份的有效日期,输出日期数字
❻ 输出超过最后一天日期的空白
❼ 在周之间填充两个空格
❽ 更新下个星期日的日期
❾ 行周结束
❿ 月份结束——本行月份与下一行月份之间的空格
因为每周的起始日已经保存在 dotm[] 数组中,所以列表 13.15 中显示的三层嵌套循环很容易输出每行的周和更大网格行中的每个月的周。
year 程序的更新代码可在在线仓库中找到,文件名为 year03.c。输出结果如图 13.2 所示。我已经调整了 COLUMNS 值为 2 和 4,代码仍然表现良好。它还能处理命令行参数中的年份。但它的宽度太宽了!
是的,你可以调整操作系统的终端窗口。尽管如此,我还是喜欢像爷爷那样舒适的 80 行 x 24 列的窗口。尽管我可以调整星期的输出宽度,使其像 cal 程序一样变窄,但可能更好的方法是通过颜色编码输出。
13.4 彩色日历
文本模式不必像在 20 世纪 80 年代初其不受欢迎的高峰期那样无聊。是的,许多人因为便宜而使用纯文本显示。早期的图形系统,按照今天的标准来看,是如此原始,以至于价格昂贵。早期的 PC 单色显示器可以输出正常或高强度的文本(亮度),反色和下划线。一些数据终端以彩色输出文本,一些家用电脑也是如此。
随着成本的降低,彩色文本变得更加常见。早期的文字处理器以各种颜色突出显示屏幕上的文本,以显示不同的属性和字体。彩色文本程序、数据库、电子表格等都非常流行——直到图形操作系统接管。然后彩色文本退居次要位置,一直如此。
彩色文本可以有助于程序的可视化。当文本以不同颜色显示时,更容易识别屏幕上的不同部分。再加上 Unicode 的花哨字符,文本终端的输出潜力就不仅仅是字母和数字了。
13.4.1 理解终端颜色
终端窗口中的文本输出可以是任何无聊的默认设置,例如绿色背景,但你的选项不仅限于终端窗口的设置。你的程序可以生成各种颜色——最多 64 种组合,包括 8 种前景色和 8 种背景色,其中许多令人烦恼或看不见。要实现这种彩虹魔法,程序需要输出 ANSI 颜色序列。由于大多数终端都兼容 ANSI 颜色,你只需要知道正确的 ANSI 转义序列。
ANSI 转义序列是一系列字符,第一个是转义字符,ASCII 27,十六进制 1B。这个字符必须直接输出;你不能按键盘的 Esc 键来完成这个技巧。其余的字符遵循一个模式,这些是代表各种颜色的数字代码。最后的字符是 m,它表示转义序列的结束,如图 13.4 所示。

图 13.4 ANSI 颜色文本转义序列的格式
按照 ANSI 顺序输出的文本将显示为指定的属性或颜色。要更改颜色,发出一个新的转义序列。要恢复终端颜色,给出一个重置转义序列。
表 13.1 列出了使用 ANSI 转义序列可用的基本字符效果或属性。转义字符列为例值 \x1b,以及它在 C 中的字符表示。
表 13.1 ANSI 文本效果
| 效果 | 代码 | 序列 |
|---|---|---|
| 重置 | 0 | \x1b0m |
| 粗体 | 1 | \x1b[1m |
| 细体 | 2 | \x1b[2m |
| 下划线 | 4 | \x1b[4m |
| 闪烁 | 5 | \x1b[5m |
| 反白显示 | 7 | \x1b[7m |
表 13.1 中显示的并非所有属性在所有终端窗口中都可用。为了以防万一,下一个列表中显示的测试程序为转义序列创建定义的常量字符串,然后逐行输出每个字符串。
列表 13.16 ansi01.c 的源代码
#include <stdio.h>
#define RESET "\x1b[0m"
#define BOLD "\x1b[1m"
#define FAINT "\x1b[2m"
#define UNDERLINE "\x1b[4m"
#define BLINK "\x1b[5m"
#define INVERSE "\x1b[7m"
int main()
{
printf("%sBold text%s\n",BOLD,RESET);
printf("%sFaint text%s\n",FAINT,RESET);
printf("%sUnderline text%s\n",UNDERLINE,RESET);
printf("%sBlinking text%s\n",BLINK,RESET);
printf("%sInverse text%s\n",INVERSE,RESET);
return(0);
}
在我的各种计算机上运行 ansi01.c 得到了混合的结果。Mac 终端窗口显示的输出最好,包括闪烁的文本,这非常令人烦恼。Windows 10/11 中的 Ubuntu Linux 很好地显示了下划线文本。其余的计算机则是一个混合体。再次提醒,如果你的操作系统提供的终端程序显示的结果不够出色,你可以获取另一个终端程序。
ANSI 颜色代码序列显示在第 13.2 表中。30s 代码表示前景颜色;40s 代码表示背景颜色。
表 13.2 ANSI 颜色代码转义序列
| 颜色 | 前景代码 | 背景代码 | 前景序列 | 背景序列 |
|---|---|---|---|---|
| 黑色 | 30 | 40 | \x1b[30m | \x1b[40m |
| 红色 | 31 | 41 | \x1b[31m | \x1b[41m |
| 绿色 | 32 | 42 | \x1b[32m | \x1b[42m |
| 黄色 | 33 | 43 | \x1b[33m | \x1b[43m |
| 蓝色 | 34 | 44 | \x1b[34m | \x1b[44m |
| 品红色 | 35 | 45 | \x1b[35m | \x1b[45m |
| 青色 | 36 | 46 | \x1b[36m | \x1b[46m |
| 白色 | 37 | 47 | \x1b[37m | \x1b[47m |
代码可以组合在一个序列中,如图 13.4 所示。例如,如果你想在蓝色背景上显示红色文本,可以使用序列 \x1b[31;44m,其中 31 是红色前景的代码,44 是蓝色背景的代码。
下一个代码列表中的 ansi02.c 代码遍历了所有前景和背景颜色的排列组合。运行程序以确保终端窗口能够输出颜色,并查看在 C 中进行颜色文本输出的巧妙之处。(好吧,这是一个终端功能,而不是 C 编程语言的一部分。)
列表 13.17 ansi02.c 的源代码
#include <stdio.h>
int main()
{
int f,b;
for( f=0 ; f<8; f++ ) ❶
{
for( b=0; b<8; b++ ) ❷
{
printf("\x1b[%d;%dm %d:%d ", ❸
f+30,b+40,f+30,b+40 ❹
);
}
printf("\x1b[0m\n"); ❺
}
return(0);
}
❶ 遍历前景值
❷ 遍历背景值
❸ 输出转义序列和两个值
❹ 更新这里的数字
❺ 重置并开始新的一行
从 ansi02.c 生成的输出——这里不展示,因为这本书不是彩色的——是一个所有颜色组合的网格。具有相同前景和背景颜色的输出使文本不可见,但它确实存在。
这种颜色输出可以用于你的文本模式程序中,以使屏幕更加生动,或者吸引对输出中某个部分的注意。请记住,输出仍然是流式的,一个字符接一个字符。此外,并非所有终端都能正确渲染字符属性。
13.4.2 生成紧凑且多彩的日历
如果删除日期之间的空格,可以在文本屏幕上挤入更多月份。在纯文本屏幕上,这样的操作将使月份的数据输出对除了最疯狂的极客之外的所有人都没有用。然而,如果你改变每一天的颜色,你可以输出没有空格的月份。
在图 13.5 中,你可以看到我的年份程序(到目前为止)的单月输出,cal 程序,然后是从没有日期间隔的年份程序版本。哪个最容易阅读?

图 13.6 颜色编码的日期使得紧凑的日历变得有用。
要更新年份系列程序以输出更紧凑的年历,请从 year03.c 源代码开始。彩色输出不需要额外的头文件或库——只需将 ANSI 转义序列添加到输出彩色即可。这些更新可以在在线仓库中找到的源代码文件 year04.c 中找到。随着我逐一审查代码的每个更新,请跟我一起学习。
首先,我添加了以下定义的常量,这些常量有助于输出颜色、前景和背景:
#define BOLD 1
#define BLACK 0
#define CYAN 6
#define WHITE 7
#define FG 30
#define BG 40
更新的年份程序只使用列出的颜色。将 FG 和 BG 常量添加到其他值中,以创建各种前景和背景颜色组合。
其次,为了输出日期,我添加了 color_output() 函数,如下一列表所示。它的任务是每月以不同颜色输出每隔一天的日期。if 条件语句在奇数和偶数日之间交替,变量 d 作为参数传递。前面定义的常量在 printf() 语句中用于设置颜色输出。
列表 13.18 来自 year04.c 的 color_output() 函数
void color_output(int d)
{
if( d%2 ) ❶
printf("\x1b[%d;%dm%2d", ❷
FG+BLACK,
BG+WHITE,
d
);
else
printf("\x1b[%d;%dm%2d", ❸
FG+WHITE,
BG+CYAN,
d
);
}
❶ 当 d 的值为奇数时,条件为真。
❷ 以黑色前景和白色背景输出奇数日
❸ 以白色前景和青色背景输出偶数日
除了添加 color_output() 函数外,输出当前日的 printf() 函数也必须替换。它们从以下内容变为:
printf(“ %2d “,day);
到以下内容:
color_output(day);
我还改变了月份和日期字符串的长度。月份名称被缩短以更好地适应更紧凑的布局:
const char *months[] = {
"Jan", "Feb", "March", "April",
"May", "June", "July", "Aug",
"Sep", "Oct", "Nov", "Dec"
};
星期几的标题重置为两个字符长。像月份的日期一样,星期几的标题也必须进行颜色编码。我无法想出一个聪明的办法来编码星期几的标题而不创建另一个数组,因此一系列 printf() 语句输出这些天,交替加粗和正常属性:
printf("\x1b[%dm%s",BOLD,"Su");
printf("\x1b[0m%s","Mo");
printf("\x1b[%dm%s",BOLD,"Tu");
printf("\x1b[0m%s","We");
printf("\x1b[%dm%s",BOLD,"Th");
printf("\x1b[0m%s","Fr");
printf("\x1b[%dm%s",BOLD,"Sa");
printf("\x1b[0m ");
最后,月份之间的空间减少到两个。各种 putchar('\n') 语句被替换为 printf() 语句,这些语句还输出 ANSI 转义序列以将颜色重置回正常。此更改避免了输出行末的颜色溢出。实际上,在编码颜色输出时必须注意颜色溢出:始终终止颜色输出,当不再需要彩色文本时重置它。重置序列是 \x1b[0m;。
由 year04.c 生成的程序输出出现在图 13.6 中。由于终端窗口设置加粗颜色,图像中的 BOLD 属性看起来较淡。再次强调,颜色输出因终端而异。
练习 13.6
year04.c 代码的输出中缺少的内容,以及图 13.6 中也缺少的内容,是突出显示当年的当前日。
本练习的任务是修改 year04.c 的源代码以检测当年的当前日,并以特殊颜色输出这一天。显然,如果日历没有显示当前年份,你的代码将不会突出显示今天的日期。因此,你的解决方案必须检测当前年份是否显示。
我的解决方案命名为 year05.c,可在在线存储库中找到。文本中的注释解释了我所做的工作。我选择的当前年份的颜色是红色文本和黑色背景。
13.4.3 突出显示节假日
到年份系列程序的最后一步,以及对于本章和前一章,都是生成带有突出显示节假日的年度日历。此程序需要更新 year04.c 源代码,并且包含来自第十二章的 isholiday() 函数。输出使用 isholiday() 的返回值来对节假日日期进行颜色编码,使其在输出中可见。
要完成这个任务,需要三个单独的文件:
-
新的源代码文件 year05.c,它调用 isholiday() 函数并对假日日期进行着色编码
-
一个源代码文件 isholiday.c,包含 isholiday() 函数及其支持函数
-
一个头文件 holiday_year.h,其中包含最终程序所需的资源:要包含的头文件、定义的常量、假日结构定义以及 isholiday() 函数的原型
这些文件可在在线存储库中找到。在我介绍代码更改时,请查看它们。
要将 year04.c 源代码更新为 year05.c,需要几个更新。首先是添加 color_holiday() 函数,该函数以红色背景上的白色文本输出假日的值:
void color_holiday(int d)
{
printf("\x1b[%d;%dm%2d",
FG+WHITE,
BG+RED,
d
);
}
接下来,更新输出月份第一天的 for 循环,以扫描任何假日。以下列表显示了更新——特别是如何填充假日结构 h 以使 isholiday() 函数调用。注意,如果假日落在今天的日期上,使用的颜色是假日颜色,而不是今天日期的颜色。
列表 13.19 year 06.c 中第一周第一天的更新 for 循环
for( c=0; c<COLUMNS; c++ )
{
h.month = month+c; ❶
h.year = year;
h.name = NULL;
day = 1; ❷
for( dow=0; dow<7; dow++ ) ❸
{
if( dow<dotm[month+c] ) ❹
{
printf(" ");
}
else
{
h.day = day; ❺
h.wday = dow;
if( isholiday(&h)==1 ) ❻
color_holiday(day); ❼
else if( today->tm_year+1900==year && ❽
today->tm_mon==month+c &&
today->tm_mday==day
)
color_today(day);
else
color_output(day); ❾
day++; ❿
}
}
printf("\x1b[0m "); ⓫
dotm[month+c] = day; ⓬
}
printf("\x1b[0m\n"); ⓭
❶ 这些项目在整个第一周中是一致的。
❷ 月份从第一天开始
❸ 在第一周内循环,从周日到周六
❹ 在月份开始前输出空白
❺ 更新假日结构 h,包含当前日期和星期几
❻ 测试假日
❼ 着色假日
❽ 测试今天的日期和颜色
❾ 输出常规日期
❿ 增加天数计数器
⓫ 重置颜色输出
⓬ 更新下周的第一天
⓭ 重置颜色输出
在下一个 for 循环中进行的更改类似于列表 13.19 中所示,该循环输出该月的剩余日期。
要构建程序,你必须将 year06.c 和 isholiday.c 构建成一个单独的程序。我使用以下命令,生成一个名为 year 的程序文件。另外,别忘了链接数学库,如最后一个参数所示:
clang -Wall year06.c isholiday.c -o year -lm
程序的输出显示当前年份——或者通过命令提示符指定的任何年份——突出显示所有假日和今天的日期,前提是今天不是假日。输出紧凑,几乎整个年份都能适应标准终端窗口。这种类型的输出只有在给日期着色编码时才有效。
14 彩票选择
在我还是一个 C 程序员新手的时候,我从拉斯维加斯旅行回来,渴望编写自己的 keno 程序。keno 是一种随机数字游戏,是彩票和宾果游戏的结合。你从 1 到 80 的范围内选择几个数字。奖金取决于你选择了多少个数字并且猜对了多少。
在编写代码的过程中,很明显,赌场提供的奖金远远低于真实概率。例如,如果你选择了 10 个数字并且猜对了,你将赢得 20 万美元。但在 80 个数字中选择 10 个数字的概率是 1:8,911,712。你应该赢得 8,911,712 美元,对吧?但至少他们有一美元的杀手虾鸡尾酒。或者他们曾经有过。
编程机会游戏的过程让你了解到几个有趣和有用的编程领域,包括这些:
-
理解概率和概率
-
计算概率
-
探索随机数字
-
模拟抽取彩票球
-
运行模拟来测试概率
我承认我不是一个数学天才。我理解数学,但我微积分只得了个 D,这是一个及格的分数,所以这就是我的极限。在概率等领域,我并不擅长。毕竟,是计算机在做数学。你的工作是输入正确的方程式,并做所有那些防止计算机崩溃的编程工作。这个技能的概率相当高。
14.1 对数学不好的征税
我玩 Powerball,尽管我的理性大脑知道我赢得的机会很小。我的情感大脑争辩说:“好吧,总得有人赢!”满意了,我扔下 20 美元在一张随机彩票上,幻想着我将如何处理那些永远不会出现的财富。
正是这种希望让人们继续玩机会游戏。无论是彩票、keno 还是任何赌场游戏(除了扑克和可能还有 21 点),人们更多地依赖欲望而不是对数学的清晰理解。这是因为数学并不对你有利。
14.1.1 玩彩票
传言说,彩票资助了中国的长城。即使这个传言是不真实的,政府几个世纪以来一直使用彩票来资助各种项目。早期的美国使用彩票来资助国防。
彩票也被用于其他目的。热那亚大议会使用彩票来选择其成员,从更大的池子中抽取几个名字。市民会赌赢家,称这种游戏为“彩票”。它最终变得如此受欢迎,以至于彩票是通过抽取数字而不是名字来举行的。
一个好彩票的目标是筹集资金,无论是为了一个项目还是作为奖金分配。一部分资金总是用来支付赢家。为了保持彩票的成功和受欢迎,奖金通常会在许多赢家之间分配。对于大多数人来说,在购买了价值 20 美元的彩票后看到两三美元的回报就是“赢”。
在多州 Powerball 彩票中,数字印在手掌大小的球上,并依次从机器中抽取。抽取了五个白色球,范围从 1 到 69,然后抽取一个单独的红色“强力球”,范围从 1 到 26。有多种边注可供选择,但目标是匹配抽取的所有五个数字,加上红色强力球,以赢得大奖。如果没有人猜对所有六个数字,奖金就会滚存——有时会积累到数亿美元。
本章中模拟的彩票类型是一种随机数彩票,类似于 Powerball。随机数被抽取来代表 Powerball 彩票中的球。对于模拟来说,重要的是不要重复抽取相同的数字,这在物理彩票中是不可能的。本章提供了两种防止重复抽取数字的方法。
14.1.2 理解赔率
为了减少你对潜在彩票赢利的喜悦,我必须讨论赔率。这些数字解释了某事发生或不发生的概率比率。我不希望深入数学,也不讨论统计赔率和赌博赔率之间的区别。只需盯着图 14.1 看。

图 14.1 一些数学公式解释了赔率。
假设你在投掷骰子上下注。以下是计算你猜对数字的概率,即六个数字中的一个的概率:
odds = 1 / (1+5) = 1/6 = 0.166...
你有 16.6%的几率猜对。为了计算你输的概率,将图 14.1 顶部方程中的分子改为输的几率代替赢的几率。以下是投掷骰子的数学计算:
odds = 5 / (1+5) = 5/6 = 0.833...
你有 83.3%的几率会输。看看这样陈述赔率是如何让人所有的希望都破灭的吗?这真是令人沮丧。
赔率也可以用冒号比表示,如图 14.1 底部所示。对于骰子示例,你赢得的概率是 1 比 5,通常表示为 5:1 或“五比一”。赔率不是 1:6,因为有一个选择会赢但五个会输。因此,赔率以 5:1 表示,具有相同的赢/输百分比:16.6 和 83.3。
对于像 Powerball 这样的游戏,赔率是在抽取数字时计算的,同时也考虑到球不是按任何顺序抽取的。为了正确计算赔率,必须考虑这些因素。
例如,如果你只能对一球下注(Powerball 的最小投注是三个数字),赔率是 68:1 或 1/(68+1),即 1.45%的赢率。如果你对抽取两球下注,第二个球的赔率变为 67:1,然后是第三个球的 66:1,以此类推。如果你做数学计算,你会得到一个非常小的数字:
1/69 * 1/68 * 1/67 * 1/66 * 1/65 = 7.415e-10
反转结果,你会发现你获胜的概率是 1:1,348,621,560。这个值的问题在于必须考虑抽取的数字的排列。如果你的猜测是 1、2、3、5 和 8,第一个球可以是这些数字中的任何一个。第二个球可以是这些数字中的任意四个,以此类推。从抽取数字的球数(69、68、67、66、65)必须除以 5 * 4 * 3 * 2 * 1,即 5!(五阶乘):
( 69 * 68 * 67 * 66 * 65 ) / (5 * 4 * 3 * 2 * 1 ) = 11,238,513
从 69 个球中正确选择五个数字的概率是 1:11,268,513。顺便说一下,如果你成功准确地选择了五个数字,Powerball 彩票会支付 100 万美元。这个概率是 11 倍。
14.1.3 编程赔率
在大学时,我避免使用电脑,因为我认为你必须是一个数学天才才能理解它们。胡说!是电脑在做数学。前面一节介绍了计算赔率的公式。下一步是编写程序。
下一个列表显示了简单赔率计算器的代码。你输入某事发生的概率,例如猜测骰子的正确点数。然后你输入它不发生的概率。电脑使用前面展示的公式(参见图 14.1)来输出结果。源代码可在在线存储库中找到,作为 odds01.c。
列表 14.1 odds01.c 的源代码
#include <stdio.h>
int main()
{
int ow,ol; ❶
printf("Chances of happening: ");
scanf("%d",&ow);
printf("Chances of not happening: ");
scanf("%d",&ol);
printf("Your odds of winning are[CA] %2.1f%%, or %d:%d\n", ❷
(float)ow/(float)(ow+ol)*100, ❸
ow,
ol
);
return(0);
}
❶ ow = 获胜赔率,ol = 失败赔率
❷ 在格式字符串中使用两个百分号来输出单个百分号。
❸ 方程式
要测试程序,使用本节前面展示的骰子示例:
Chances of happening: 1
Chances of not happening: 5
Your odds of winning are 16.7%, or 1:5
如果你猜中骰子的六个面之一,它发生的概率是 1,不发生的概率是 5。获胜的赔率是 16.7%,或者说五分之一。
假设你想计算从一副牌中抽到红桃的赔率:
Chances of happening: 13
Chances of not happening: 39
Your odds of winning are 25.0%, or 13:39
因为红桃是四种花色之一,所以你的赔率是 25% 或四分之一——尽管程序没有减少这个比例。即便如此,答案仍然是准确的。
要计算多次抽取,例如在彩票中,需要更多的数学知识:必须将球的数量相乘,以及猜测的数字的排列。这个公式之前已经展示过,但以下代码块中进行了编码。总项目的乘积在变量 i 中计算;抽取项目的乘积在变量 d 中计算。
列表 14.2 odds02.c 的源代码
#include <stdio.h>
int main()
{
int items,draw,x;
unsigned long long i,d; ❶
printf("Number of items: ");
scanf("%d",&items);
printf("Items to draw: ");
scanf("%d",&draw);
i = items;
d = draw;
for(x=1;x<draw;x++) ❷
{
i *= items-x; ❸
d *= draw-x; ❹
}
printf("Your odds of drawing %d ",draw);
printf("items from %d are:\n",items);
printf("\t1:%.0f\n",(float)i/(float)d); ❺
return(0);
}
❶ 即使是无符号长整型值也可能不足以处理某些计算的赔率。
❷ 循环遍历抽取次数
❸ 获得每个项目的乘积,数值递减
❹ 获得每个抽取排列的乘积,数值递减
❺ 将变量转换为以获得准确的结果
我不得不在代码中不断增大变量 i 和 d 的存储空间,从 int 到 long,再到 unsigned long。多个值的乘积增长得很快。尽管如此,代码仍然为 Powerball 赔率(不包括 Powerball 本身)提供了准确的结果:
Number of items: 69
Items to draw: 5
Your odds of drawing 5 items from 69 are:
1:11238513
这个结果与之前显示的值匹配,11,238,513。像往常一样,代码可以进行许多修改。
练习 14.1
odds02.c 的源代码中缺少了一个错误检查。如果用户输入了 10 项但抽取了 12 项会发生什么?如果输入的任一值为 0 会发生什么?本练习的任务是修改代码以确认任一值的输入不是 0,并且抽取的项目数量不超过可用的项目数量。
我的解决方案,注释详尽,可在在线存储库中的 odds03.c 文件中找到。以 odds02.c 的源代码作为起点。
练习 14.2
代码的另一个良好改进是向输出中添加逗号。毕竟,哪个更好:1:11238513 还是 1:11,238,513?人类的眼睛喜欢逗号。
本练习的任务是向中奖号码的数值输出中添加逗号。我建议你编写一个函数,该函数接受一个浮点数作为输入。假设该值没有小数部分。返回一个表示该值的字符串,但每三位放置一个逗号,如之前所示。我的解决方案是 commify() 函数,可在在线存储库中找到的源代码文件 oddsd04.c 中找到。
14.2 这里是你的中奖号码
你在 fortune cookie 的祝福语中找到的彩票号码很可能是计算机生成的。我发现这种发展令人失望。相反,想象一位智慧的老妇人坐在充满香气的房间里,积极与灵界交流以获得灵感,这不是很迷人吗?但是,事实是这些数字是从计算机中喷涌而出的——随机生成的。当然,它们可能是正确的猜测并赢得你的财富,但几率很小。
要让计算机选择你的彩票中奖号码,需要编程随机数。这些数必须模拟产生 Powerball 实际抽取号码的神奇彩票球机的随机性。与现实世界不同,你的彩票模拟必须确保抽取的值在范围内。此外,你不能抽取相同的数字两次。你的彩票选择必须像现实世界一样是唯一的。
14.2.1 生成随机值
我想不出一款不依赖于随机数的电脑游戏。即使是复杂的棋类软件也必须决定它的第一步。转动古老的随机数生成器就是做出这个决定的方法。
计算机不会生成真正的随机数。这些值被称为伪随机,因为如果你有所有数据,你可以预测这些值。尽管如此,随机数生成对于设置一个有趣的游戏——或者选择彩票号码——仍然是核心的。所需的工具是rand()函数,该函数在 stdlib.h 头文件中进行了原型定义:
int rand(void);
该函数不接受任何参数,并返回一个范围在 0 到 RAND_MAX 之间的整数值。对于大多数编译器,这个值被设置为 0x7ffffffff 或 2,147,483,647。该函数的改进版本 random() 与 rand() 类似,尽管这个函数不是标准 C 库的一部分。
下面的源代码类似于我多年前用 BASIC 编写的第一个程序。它输出一个随机数网格,五行五列。rand() 函数生成变量 r 中保存的值,并在 printf() 语句中输出。
列表 14.3 random01.c 的源代码
#include <stdio.h>
#include <stdlib.h> ❶
int main()
{
const int rows = 5;
int x,y,r;
for( x=0; x<rows; x++ ) ❷
{
for( y=0; y<rows; y++ )
{
r = rand(); ❸
printf("%d ",r); ❹
}
putchar('\n'); ❺
}
return(0);
}
❶ 对于 rand() 函数
❷ 嵌套循环处理网格
❸ 获取随机整数
❹ 输出随机整数
❺ 结束行
列表 14.3 中显示的代码完成了它的任务。它生成了 25 个随机值,输出看起来非常糟糕:
1804289383 846930886 1681692777 1714636915 1957747793
424238335 719885386 1649760492 596516649 1189641421
1025202362 1350490027 783368690 1102520059 2044897763
1967513926 1365180540 1540383426 304089172 1303455736
35005211 521595368 294702567 1726956429 336465782
这些数字非常大,在 rand() 函数生成的范围之内,从 0 到 RAND_MAX。要输出不同范围的值,你可以使用取模运算符。以下是我使用的表达式:
value = rand() % range;
变量的值在 0 和范围值之间。如果你想使值在 1 和范围之间,我使用这个版本的表达式:
value = rand() % range + 1;
要将随机数输出设置为 1 到 100 之间的值,需要更改两个语句来修改 random01.c 的源代码:
r = rand() % 100 +1;
printf("%3d ",r);
第一条语句限制了 rand() 函数的输出范围在 1 到 100 之间。第二条语句对输出进行了对齐,将值限制在一个三个字符宽的框架内,后面跟一个空格。这些更改已纳入源代码文件 random02.c,可在在线仓库中找到。以下是更新后的输出结果:
84 87 78 16 94
36 87 93 50 22
63 28 91 60 64
27 41 27 73 37
12 69 68 30 83
可惜,如果你运行程序两次,会生成相同的数字。这个结果对你的彩票选择不利,因为目标是随机。
如果你曾经编写过随机数代码,你知道解决方案是给随机数生成器设置种子。srand() 函数,也在 stdlib.h 头文件中声明,负责这项任务:
void srand(unsigned int seed);
种子参数是一个正整数,rand() 函数在它的随机数计算中使用这个值。srand() 函数只需要调用一次。它通常与 time() 函数一起使用,该函数返回当前的时钟滴答值作为种子:
srand( (unsigned)time(NULL) );
time() 函数被转换为 unsigned 类型,并传递了 NULL 参数。这种格式确保了时钟滴答值被 srand() 函数正确消耗,并且每次程序运行时都会生成一个新的随机数序列。
(如果你使用 random() 函数,它有一个类似的种子函数,srandom().)
random02.c 代码的改进包含在 random03.c 中,可在在线仓库中找到。同时还包括了 time.h 头文件。以下是一个示例运行结果:
8 53 95 12 93
76 92 59 45 21
32 65 73 95 85
62 55 9 89 16
59 13 33 61 74
这里还有一个示例运行结果,只是为了展示不同的随机数序列:
14 49 92 92 56
80 95 41 57 66
8 99 62 86 73
26 32 23 55 38
98 66 94 20 98
顺便说一下,因为使用了 time_t 值(由 time() 函数返回),如果你连续快速运行程序,你会看到相同的值生成。这是用时钟滴答值初始化随机数生成器的弱点,但这不应该对大多数应用造成问题。
14.2.2 抽取彩票球
69 个球落入摇奖机,编号为 1 到 69。球在搅拌时上下跳动,紧张地搅拌了几分钟。通过某种魔法,从摇奖机中抽取一个球,沿着管子滚到滑梯上。渴望但愚蠢的人们集中注意力,见证揭示的数字。不,这很可能不是他们选择的数字——但他们还有四次机会!希望仍然很高。这个过程就是 Powerball 彩票的工作方式。
对于我的彩票模拟,我使用 Powerball 的基本前提:随机抽取 1 到 69 范围内的五个数字。第六个数字,Powerball,增加了另一个复杂度,稍后可以编程实现,但不是在本章中。
抽取彩票号码就像抽取任何随机序列的项目一样,例如玩扑克牌。我在下一个列表中展示了模拟的第一个尝试,即 lotto01.c 的源代码。它借鉴了本章前面展示的随机序列程序,但使用一个 for 循环来输出 1 到 69 范围内的五个随机数。
列表 14.4 lottt01.c 的源代码
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main()
{
const int balls = 69, draw = 5; ❶
int x,r;
srand( (unsigned)time(NULL) ); ❷
printf("Drawing %d numbers from %d balls:\n", ❸
draw,
balls
);
for( x=0; x<draw; x++ ) ❹
{
r = rand() % balls+1; ❺
printf("%2d\n",r); ❻
}
return(0);
}
❶ 设置常数以表示总球数和抽取的数字
❷ 初始化随机数生成器
❸ 通知用户
❹ 循环抽取指定数量的球
❺ 在范围内生成随机值
❻ 输出值
有时我想,用于在幸运饼干上生成彩票赢家的代码可能和列表 14.4 中展示的代码一样简单。以下是输出:
Drawing 5 numbers from 69 balls:
17
64
38
1
26
确实,输出可以更美观。更新将在几页后展示。但如果你经常运行代码,最终你会看到如下输出:
Drawing 5 numbers from 69 balls:
44
19
19
10
33
因为代码没有检查之前抽取的数字,值可能会重复。这样的输出不仅不现实——而且不吉利。
代码无法确定抽取的值是否重复,除非将抽取的值存储并检查。为此,需要一个数组,其维度与抽取的球数相同。每个抽取的随机值都必须存储在数组中,然后检查数组以确保没有两个值重复。
对于我对这个问题的第一个方法,我使用 winners[] 数组,如下所示,这是对 lotto01.c 代码的更新。一个 for 循环用随机值填充数组。接下来,一个嵌套的 for 循环像冒泡排序一样工作,将数组中的每个值与其他值进行比较。当两个值匹配时,第二个值被替换为一个新的随机值,然后循环重置以再次扫描。
列表 14.5 lottt02.c 的源代码
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main()
{
const int balls = 69, draw = 5;
int x,y;
int winners[draw]; ❶
srand( (unsigned)time(NULL) );
printf("Drawing %d numbers from %d balls:\n",
draw,
balls
);
for( x=0; x<draw; x++ ) ❷
{
winners[x] = rand()%balls+1;
}
for( x=0; x<draw-1; x++ ) ❸
for( y=x+1; y<draw ; y++ ) ❹
if( winners[x]==winners[y] ) ❺
{
winners[y] = rand()%balls + 1; ❻
y = draw; ❼
x = -1; ❽
}
for( x=0; x<draw; x++ ) ❾
printf("%2d\n",winners[x]);
return(0);
}
❶ 将数组维度设置为存储抽取的数量
❷ 用随机值填充数组,从第一个球开始
❸ 外循环遍历数组到倒数第二个元素,draw-1。
❹ 内循环从 x+1 元素遍历到数组的最后一个元素。
❺ 将每个值与所有其他值进行比较
❻ 对于匹配,再次抽取重复的值
❼ 通过设置终止值强制 y 循环停止
❽ 将 x 循环重置回起始位置(-1,因为每次循环运行时循环都会增加 x)
❾ 输出结果
改进的彩票程序版本会检查重复的值并替换它们。输出看起来与程序的第一版相同,但没有数字重复。你现在可以准备好投下你的钱,为财富的机会而战,但代码中仍有改进的空间。
练习 14.3
现有版本的 lotto 程序的输出很俗气。它看起来根本不像幸运饼干背面的祝福。有两种方法可以改进它:对数字进行排序并将它们输出到单行以提高可读性。例如:
Drawing 5 numbers from 69 balls:
5 - 10 - 14 - 19 - 33
现在的输出是线性的,可以打印并节省那位老妇人时间,让她可以和她的孙子辈们共度时光。我为此练习的解决方案命名为 lotto03.c,并在在线仓库中可用。
14.2.3 避免重复数字,另一种方法
任何彩票模拟的关键是确保没有两个数字被抽取两次。前面的部分提供了一种方法。另一种方法,我多次使用的方法,是在数组中模拟所有数字或球。当生成随机数时,数组元素会更新以反映球不再可用。我发现这种方法编码起来更容易,尽管可能不是那么容易解释。
图 14.2 展示了一个初始化为所有零的数组 numbers[]。数组的元素代表彩票中的球。当一个元素的值为零时,意味着该球尚未被抽取。当一个球被抽取时,数组中对应的元素被设置为 1,如图所示。例如,如果随机数生成器返回 12,则数组中的第 12 个元素被设置为 1。

图 14.2 代表彩票球的数组元素
为了确认一个数字是否可用于抽取,代码会测试相关的数组元素。如果元素是 0,则该数字可用,并将其设置为 1。如果元素是 1,则跳过并生成另一个随机数。以下代码执行此测试:
for( x=0; x<draw; x++ )
{
do
r=rand()%balls;
while( numbers[r]==1 );
numbers[r] = 1;
}
numbers[] 数组代表模拟的彩票球。它的大小设置为可用球的数量,69。变量 draw 是要抽取的球的数量——在这个例子中是五个。
当随机数组元素 numbers[r] 等于 1 时,do-while 循环会重复。这个测试确保球不会被抽取两次。否则,如果元素是零,意味着球可用,它会被“抽取”通过将其值设置为 1:numbers[r] = 1。这个语句将球标记为已抽取,并防止它再次被抽取。
变量 balls 通过取模运算符帮助截断 rand() 函数的返回值:r=rand()%balls。然而,这个值并没有加 1。因为代码处理数组,第一个值必须是 0。因此,抽取的数字范围是 0 到 balls 减 1,在这个例子中是 68。这个结果可以在输出时进行调整,以反映真实的彩票球号。
模拟彩票抽取的其余代码如下所示。初始化 numbers[] 数组,抽取球,然后输出结果。因为 numbers[] 数组在最终的 for 循环中按顺序处理,所以输出前不需要对中奖号码进行排序。
列表 14.6 lotto04.c 的源代码
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main()
{
const int balls = 69, draw = 5;
int x,r,count;
int numbers[balls];
srand( (unsigned)time(NULL) );
printf("Drawing %d numbers from %d balls:\n",
draw,
balls
);
for( x=0; x<balls; x++ ) ❶
{
numbers[x] = 0;
}
for( x=0; x<draw; x++ ) ❷
{
do
r=rand()%balls;
while( numbers[r]==1 );
numbers[r] = 1;
}
count = 0;
for( x=0; x<balls; x++ ) ❸
{
if( numbers[x] ) ❹
{
printf(" %d",x+1); ❺
count++;
if( count<draw ) ❻
printf-");
}
}
putchar('\n');
return(0);
}
❶ 初始化数组
❷ 选择随机值
❸ 处理数组以筛选中奖号码
❹ 如果元素非零(1),则抽中了球。
❺ 输出球号,加一以考虑数组从元素 0 开始
❻ 在最后一个数字之前,输出一个破折号分隔符
列表 14.6 中所示的 lotto04.c 源代码文件可在在线存储库中找到。以下是输出结果:
Drawing 5 numbers from 69 balls:
1 - 25 - 37 - 39 - 40
没有重复的数字,输出是排序的。祝你好运!
14.3 永远不要告诉我概率
如果你能永远地玩彩票该多好。或者也许你足够古怪,以至于相信你可以购买 11,238,513 张彩票,每张彩票上的数字组合都不同,并且以某种方式脱颖而出。但系统根本不会这样运作。哦,我可以大谈特谈各种“赢得”彩票的技术,但这一切都是胡说八道。
幸运的是,你不需要购买一大堆彩票来看到你玩游戏的表现如何。计算机不仅可以生成彩票选择,还可以将这些选择与其他选择匹配。你可以运行模拟来确定计算机猜测它选择的数字需要多少次随机抽取。只要编码正确,你就可以测试概率。唉,你只是赢不到钱。
14.3.1 创建 lotto() 函数
要模拟彩票的多次抽取,你必须修改现有的 lotto 代码,以便在函数中抽取球,我称之为 lotto()。这种代码改进允许函数被重复调用,以表示原始号码的匹配以及所做的猜测。
我多次努力编写了 lotto() 函数:它应该返回抽取的随机数字,还是应该将它们作为数组传入?我最终选择传入一个数组,这在函数中作为一个指针工作。这种方法允许直接修改数组的元素,因此函数不返回任何内容。
下面的 lotto() 函数与本章前面展示的 lotto 系列程序中的 main() 函数使用类似的语句:numbers[] 数组现在位于 lotto() 函数内部,因为其内容在调用之间不需要保留。初始化数组后,一个 for 循环设置代表抽取数字的随机元素值。此操作之后,第二个 for 循环处理整个 numbers[] 数组,从传入的数组中填充元素。
列表 14.7 来自 lotto05.c 的 lotto() 函数
void lotto(int *a) ❶
{
int numbers[BALLS]; ❷
int x,y,r;
for( x=0; x<BALLS; x++ ) ❸
{
numbers[x] = 0;
}
for( x=0; x<DRAW; x++ ) ❹
{
do
r=rand()%BALLS;
while( numbers[r]==1 );
numbers[r] = 1;
}
y = 0; ❺
for( x=0; x<BALLS; x++ ) ❻
{
if( numbers[x] ) ❼
{
*(a+y) = x; ❽
y++; ❾
}
if( y==DRAW ) ❿
break;
}
}
❶ 在此函数中,数组作为指针进行引用。
❷ 此数组仅限于 lotto() 函数内部使用。
❸ 初始化 numbers[] 数组
❹ 随机抽取数组中的项目
❺ 变量 y 作为传入数组的索引。
❻ 将抽取的随机数字填充到传入数组的元素中
❼ 如果球已经被抽取 . . .
❽ . . . 设置传入数组中的元素编号
❾ 增加索引
❿ 如果传入的数组已满,则提前中断循环
定义常量 BALLS 和 DRAW 与早期版本的 lotto 程序中显示的 const int 值相同。这些值被定义为常量,以便在源代码文件中的所有函数中可用。
main() 函数调用 lotto() 函数,然后输出传入数组的元素。下一个列表显示了 main() 函数,它再次基于本章前面展示的 lotto 系列程序的部分内容。
列表 14.8 来自 lotto05.c 的 main() 函数
int main()
{
int x;
int match[DRAW]; ❶
srand( (unsigned)time(NULL) );
printf("Trying to match:");
lotto(match); ❷
for( x=0; x<DRAW; x++ ) ❸
{
printf(" %d",match[x]+1);
if( x<DRAW-1 )
printf(" -");
}
putchar('\n');
return(0);
}
❶ 将数组作为 lotto 函数的参数使用
❷ 调用 lotto() 函数,填充 match 数组
❸ 输出数组的元素,即彩票“中奖者”
lotto05.c 的完整源代码可在在线仓库中找到。以下是一个示例运行:
Trying to match: 32 - 33 - 45 - 55 - 61
输出看起来与其他 lotto 程序类似,尽管现在可以通过设置 lotto() 函数在同一代码中抽取多个彩票数字。毕竟,上面的提示说,“试图匹配。”程序生成的下一步是获取另一组随机彩票球抽取,以查看它们是否与第一次抽取的数字匹配。
14.3.2 匹配彩票选择
lotto() 函数允许代码反复抽取彩票数字,试图匹配原始抽取。为此,我在 lotto05.c 代码中复制了 for 循环和输出语句,但使用了第二个数组 guess[]。这个更改出现在源代码文件 lotto06.c 中,它输出第二轮彩票数字,以查看两次抽取是否匹配。以下是示例输出:
Trying to match: 2 - 18 - 38 - 47 - 69
Your guess: 6 - 10 - 34 - 35 - 49
我在这里不展示完整的源代码,因为它没有做任何新的事情——它只是重复了相同的代码块,但使用了新的数组 guess[]。这个数组被传递给 lotto() 函数,然后输出,如前所述。结果是两次彩票数字抽取。它们匹配吗?可能不匹配。
即使两个数组匹配,你也必须进行视觉检查以确认。在前面的示例输出中,它们没有匹配。但为什么自己工作,当计算机不仅无聊,而且过于渴望呢?
为了比较两组彩票球抽取,我使用了winner() 函数,如下所示。作为参数,它消耗两个数组,作为整数指针引用。嵌套 for 循环将第一个数组中的每个数组值与第二个数组中的每个数组值进行比较。使用指针表示法进行比较。当找到匹配时,变量 c 增加。返回的总匹配数,范围从 0 到 DRAW。
列表 14.9 从 lotto07.c 的 winner() 函数
int winner(int *m, int *g) ❶
{
int x,y,c;
c = 0; ❷
for( x=0; x<DRAW; x++ ) ❸
for( y=0; y<DRAW; y++ ) ❹
{
if( *(m+x) == *(g+y) ) ❺
c++; ❻
}
return(c); ❼
}
❶ 两个数组都作为整数指针传递,m 为匹配,g 为猜测。
❷ 将匹配计数初始化为 0
❸ 遍历第一个数组中的所有 DRAW 数字
❹ 遍历第二个数组中的每个 DRAW 数字
❺ 比较每个元素值
❻ 如果两个值匹配,则增加变量 y
❼ 返回匹配数
在 lotto() 函数填充数组 guess[] 后,main() 函数立即调用 winner() 函数:
lotto(guess);
c = winner(match,guess);
数组是通过名称传递的。在 winner() 函数中,这些数组被识别为整数指针。回到 main() 函数,输出数组 guess[] 的值,以及一个最终的 printf() 语句,报告匹配数。
完整的代码可在在线仓库中找到,作为 lotto07.c。以下是一个示例运行:
Trying to match: 20 - 27 - 34 - 41 - 59
Your draw: 1 - 19 - 27 - 33 - 48
You matched 1 numbers
真是幸运,在我第一次运行代码(如上所示)时,两个模拟彩票抽奖之间的一个值匹配了。winner() 函数返回变量 c 中的一个值,因为两个数组共享值 27。我很高兴我不需要多次运行代码来展示匹配。然而,正是这一步反复运行程序激发了我编写程序的最终版本,这在下一节中介绍。
14.3.3 测试赔率
在 Powerball 游戏中,你只能匹配一个球来获胜。不,你必须匹配一个球和 Powerball 才能赢得一些微不足道的金额。同样,对于两个球:两个球加上 Powerball 等于一些适度的回报。然而,你可以匹配三个主要数字,以 2 美元的赌注赢得 7 美元。哎呀!当然,我没有编写任何关于 Powerball 的无聊代码,所以我的 lotto 程序很简单,奖金始终为零。
匹配一个数字和 Powerball 的概率是 1:92。这个值意味着如果你玩 92 次,你可能会至少匹配一次一个值和 Powerball,但这并不是保证。我不会涉及数学,但可能需要几百次才能看到匹配,或者你可能会在第一次就匹配。正是这种不可预测性吸引了人们去赌博——即使赔率愚蠢地高。
为了避免反复运行彩票程序,我决定编写一个循环,直到至少有两个数字匹配为止输出猜测。下一列表显示了更新版——最终版 lotto 系列程序的 main() 函数。lotto() 和 winner() 函数保持不变,但我向 main() 函数添加了一个常量,tomatch。它设置在 do-while 循环停止抽取随机彩票球之前需要匹配的最小球数。直到找到匹配,否则不输出任何内容,这可以节省几秒钟的处理时间。
列表 14.10 从 lotto08.c 中的 main() 函数
int main()
{
const int tomatch = 2; ❶
int x,c,count;
int match[DRAW],guess[DRAW]; ❷
srand( (unsigned)time(NULL) );
printf("Trying to match:"); ❸
lotto(match);
for( x=0; x<DRAW; x++ )
{
printf(" %d",match[x]+1);
if( x<DRAW-1 )
printf(" -");
}
putchar('\n');
count = 0; ❹
do
{
lotto(guess); ❺
c = winner(match,guess); ❻
count++; ❼
} while( c<tomatch ); ❽
printf("It took %d times to match %d balls:\n", ❾
count,
c
);
for( x=0; x<DRAW; x++ ) ❿
{
printf(" %d",guess[x]+1);
if( x<DRAW-1 )
printf(" -");
}
putchar('\n');
return(0);
}
❶ 确定要匹配的球数
❷ 两个数组——一个用于存储要匹配的数字,另一个用于存储猜测
❸ 输出要匹配的数字
❹ 跟踪尝试了多少次抽奖
❺ 获取模拟的彩票抽奖结果
❻ 检查是否有球匹配
❼ 增加计数
❽ 只要匹配的球数少于目标,就继续循环
❾ 通知用户结果,需要多少次抽奖
❿ 输出中奖抽奖结果
lotto08.c 的完整代码可在在线仓库中找到。程序会持续抽取随机彩票号码,直到变量 tomatch 中存储的最小匹配值被满足。以下是一个示例运行过程:
Trying to match: 1 - 5 - 21 - 33 - 37
It took 5 times to match 2 balls:
1 - 30 - 37 - 63 - 66
根据输出,计算机进行了五次循环找到了两个匹配——1 和 37。你可以多次运行程序,看看需要多少次循环才能从总共 69 个号码中至少匹配到五个球。再次强调,我不知道确切的概率,但肯定小于 100。
当你修改代码时,有趣的部分就来了:将 tomatch 常量改为 5,然后运行程序。以下是修改后我的示例输出:
Trying to match: 15 - 33 - 47 - 59 - 60
It took 5907933 times to match 5 balls:
15 - 33 - 47 - 59 - 60
在上面,在达到五个球完全匹配之前,需要旋转 do-while 循环 5,907,933 次。
我不知道这段代码是否能让任何人相信玩彩票的无用。问题从来不是数学;而是人类对概率和机会的误解。每次“总有人会赢”的观念都战胜了逻辑和常识。
练习 14.4
计算机无意识地、毫不费力地模拟了您愿意让它执行的所有彩票球抽奖。lotto08.c 代码显示,即使尝试匹配五个球中的五个,程序运行也相当快。然而,总是可以进行更多的编码,特别是为了满足好奇的心。
本练习的任务是修改 lotto08.c 代码,目标是确定匹配 69 个可能号码中的所有五个球所需的平均游戏次数。运行模拟 100 次,每次记录需要多少次重复调用 lotto() 函数才能实现匹配。存储每个值,然后报告制作匹配所需的平均游戏次数。
以下是来自我的解决方案的示例输出,该解决方案可在在线仓库中找到,作为 lotto09.c:
Trying to match: 9 - 32 - 33 - 42 - 64
For 100 times, the average count to match 5 balls is 11566729
平均而言,需要调用lotto()函数 11,566,729 次才能匹配上原始抽取的数字。记得在本章前面提到,从 69 个彩票球中抽取相同五个数字的计算概率是 11,238,513。差得真是不多。
我解决方案中的注释解释了我的方法,尽管请在看到我所做之前尝试这个练习。修改并不复杂,因为大部分必要的编码已经在lotto08.c源代码文件中。
哦!解决方案程序运行需要一段时间。在我的最快系统上,我计时了几乎 9 分钟才生成结果。请耐心等待。
15 井字棋
在 1983 年电影《战争游戏》的高潮部分,即将引发第三次世界大战的计算机被指示与自己玩井字棋。计算机意识到这个游戏很愚蠢,因为经验丰富的玩家通常以平局结束游戏,因此它决定核战争是徒劳的。它决定不炸毁世界。这个结论应该会给本章增添一些兴奋感,因为你可以将任何井字棋游戏——即使是计算机模拟的——等同于核战争。
井字棋的游戏玩法很简单。很容易编写代码。如果你还没有这样做,现在就是时候编写你自己的游戏版本了。当然,当你考虑以下任务时,它会变得更加复杂:
-
编写游戏循环
-
编程玩家的回合
-
确定游戏何时结束
-
添加计算机作为玩家
-
给计算机一些智能
当你编程像井字棋这样的文本模式游戏时,你面临的最大挑战是 C 语言中的 I/O 不是交互式的。除非你使用第三方库,如 Ncurses,否则你必须依赖流 I/O 来编写你的程序。它可以工作,但流 I/O 会为代码带来潜在的问题,代码必须处理这些问题,否则用户可能会遇到麻烦。
15.1 一个愚蠢的儿童游戏
没有人知道井字棋游戏的确切起源,所以我想到编造一些有趣的事实:在古埃及,一种类似于井字棋的游戏是在一个木制钉板上用从敌人截断的脚趾雕刻的标记玩的。罗马人喜欢玩一种叫做“tria ordine”的游戏,涉及在大理石板上排列鹅卵石。奖品是打对手的脸。在中世纪的欧洲,挪威的孩子玩一种将鱼扔进篮子的游戏,这与井字棋无关,但味道很糟糕。
是的,我编造了所有这些。
井字棋最早的书面记载来自 19 世纪末,当时使用的是“noughts and crosses”这个名字。即使在今天,这仍然是除美国以外的英联邦国家的游戏名称。美国名称井字棋,最初是 tick-tack-toe,起源于 20 世纪初。第一个井字棋计算机程序是在 20 世纪 50 年代初编写的。
那就是今天的历史课——一些部分是真的,但其他部分大多是假的。
15.1.1 玩井字棋
根据计算机作者协会的要求,我必须解释井字棋游戏,尽管你对它非常熟悉。即便如此,请记住——与在纸上、在泥土中或在雾蒙蒙的镜子上玩不同——编写这个游戏需要你回顾游戏玩法。
图 15.1 显示了标准的井字棋网格:两条垂直线与两条水平线相交。这个网格包含九个方格,它们成为战场。这些方格在图中编号,从一到九,并为每个方格的位置提供了方便的记忆法:顶部、中间和底部,以及左、中、右。

图 15.1 井字棋游戏网格,方格编号并标注
玩家轮流在一个九宫格方块中放置标记。在选择谁先手(一个优势)之后,玩家交替在方块中标记 X 或 O。传统上,第一个玩家标记 X,尽管这个选择并不是规则。
胜利者是第一个在其标记连成一行的情况下放置三个标记的玩家。如果这个目标未能实现,游戏就是平局,或者称为“猫的游戏”。除了最愚蠢的人类之外,所有人都能实现平局,所以绝望的成年人会和小孩子玩游戏,让自己感觉胜利。
经验丰富的玩家知道先手是有利的。此外,在第一回合或“回合”中标记中心方块是最佳策略。否则,优秀的玩家会尝试设置一个方块三角形,如图 15.2 所示,这保证了胜利,因为他们的对手只能阻止一条腿。

图 15.2 获胜三角形的排列
无论策略如何,井字棋只有八条通往胜利的路径:三行、三列或两条对角线。尽管游戏种类繁多,但只有这八种可能性定义了胜利者。由于网格中有九个方块,胜利可以在九步或更少的步骤中实现,这使得游戏易于学习,快速玩耍,并且短时间内很有趣。
15.1.2 以数学方式接近游戏
作为一名极客,我不得不讨论有关井字棋游戏的数学细节。其中一些细节在你编写自己的游戏时会有所体现——特别是,如果你敢于编写一个具有一定智能的计算机对手。
井字棋游戏可能的排列总数是 19,683。不要相信我;有人做了数学计算。这个数字考虑了每个九宫格方块可以放置 X、O 或者为空。记住,游戏网格是三进制,而不是二进制。我在本节的末尾再次提到这一点。
19,683 这个数字并不包括实际的游戏玩法,因为 X 和 O 会跟随对方并消除方块;随着游戏的进行,排列的数量会减少。实际上,这个游戏有 3,200 种可能的排列。去掉那些游戏已经获胜或平局的情况,数字进一步减少到 2,460。
通过消除由于旋转或镜像游戏网格而产生的重复项,进行最后的减少。当这些重复项被移除后,井字棋游戏排列的总数降至 120。由于这个值比 19,683 更容易处理,许多程序员选择在内存中创建所有 120 种排列,并使用这个数据库在游戏过程中指导计算机。
处理 120 种排列的编码方法是创建一个游戏树。这个结构包含所有可能的玩法,程序可以从中选择一条通往胜利的路径。从某种意义上说,这种方法就像一张巨大的作弊表,计算机根据所有可能性来抄袭其下一步动作,并倾向于只探索通往胜利或平局的路径。
我对计算机游戏玩法的处理并不像遵循游戏树那样聪明。相反,我选择模仿人们玩游戏的方式:移动以获胜或移动以阻止。在本章的后面部分,我将扩展这一技术。
最后,重要的是要记住游戏网格是三元的:空白、X 或 O。显然,你使用数组在网格中存储值。最初,我使用值 0、1 和 2 分别代表空白、X 和 O。这种方法在检查行、列和对角线时使数学变得复杂。因此,我改用 0 表示空白,但用-1 表示 O 和+1 表示 X。你可以在下一节中了解更多关于这些选择的信息。
15.2 基本游戏
对于我的井字棋实现,我首先编写了游戏网格的代码。实际上,我编写了许多输出井字棋网格的程序,但从未费心编写任何游戏玩法,可能是因为游戏本身并不好玩。
任何交互式文本模式游戏的核心都是一个游戏循环。它接受新的移动输入,更新网格,并确定何时满足胜利条件。虽然也提供了其他退出循环的选项,但正是胜利条件打破了循环。
在这一轮中,我正在编写一个人类对人类版本的游戏。它包含输出游戏网格、提示输入和确定胜者的函数。在本章后面部分将介绍添加计算机作为对手的更新版本。
15.2.1 创建游戏网格
编程井字棋网格是初学者在学习 C 语言编程时执行的基本任务之一。毕竟,网格代表了二维数组的真实生活示例,具有行和列。它可以以多种方式实现,如图 15.3 所示。

图 15.3 展示了文本模式井字棋游戏网格的各种呈现选项
在决定使用彩色文本来显示网格会更有趣之前,我实验了图 15.3 中显示的每种变体。彩色文本输出在第十三章中介绍。它涉及向标准输出发送 ANSI 转义序列,这些序列被大多数终端解释为颜色。我选择的网格显示在图 15.3 的右下角,彩色方格。
创建了七个颜色常量来实现我想要的颜色,如表 15.1 所示。对于三种方格可能性:空白、X 和 O,每个都使用两个不同的值。交替的值有助于设置棋盘图案,这有助于我避免添加丑陋的 ASCII 线艺术来构建游戏网格。
表 15.1 用于创建井字棋游戏网格的颜色常量和它们的值
| 常量名称 | 代码 | 输出 |
|---|---|---|
| bfwb[] | \x1b[32;47m | 空白方格,绿色前景/白色背景 |
| bf[] | \x1b[32m | 空白方格,绿色前景 |
| xfwb[] | \x1b[31;47m | X 方格,红色前景/白色背景 |
| xf[] | \x1b[31m | X 方格,红色前景 |
| ofwb[] | \x1b[34;47m | O 方格,蓝色前景/白色背景 |
| of[] | \x1b[34m | O 方块,蓝色前景 |
| reset[] | \x1b[0m | 关闭颜色值 |
每个序列设置前景或前景-背景组合。背景颜色用于每隔一个方块,以创建棋盘图案。最后的 reset[] 序列从输出中移除颜色,避免了输出行之间的颜色溢出。
下一个列表显示了 ttt01.c 的源代码,这是本章所有代码的基础。showgrid() 函数输出带有交替颜色的游戏网格,并为每个位置编号,从一至九。一个 switch-case 测试确定方块是否被 O(-1)、X(+1)或空白(0)占据。在 main() 函数中,网格在 grid[] 数组中初始化,然后输出。这个小程序的目的是确保输出看起来很好。
列表 15.1 ttt01.c 的源代码
#include <stdio.h>
void showgrid(int *g) ❶
{
const char bfwb[] = "\x1b[32;47m"; ❷
const char bf[] = "\x1b[32m";
const char xfwb[] = "\x1b[31;47m";
const char xf[] = "\x1b[31m";
const char ofwb[] = "\x1b[34;47m";
const char of[] = "\x1b[34m";
const char reset[] = "\x1b[0m";
int x;
for( x=0; x<9; x++ ) ❸
{
switch( *(g+x) ) ❹
{
case -1: ❺
if( x%2 ) ❻
printf("%s O %s",ofwb,reset);
else ❼
printf("%s O %s",of,reset);
break;
case 1: ❽
if( x%2 )
printf("%s X %s",xfwb,reset);
else
printf("%s X %s",xf,reset);
break;
default: ❾
if( x%2 )
printf("%s %d %s",bfwb,x+1,reset);
else
printf("%s %d %s",bf,x+1,reset);
}
if( (x+1)%3==0 ) ❿
putchar('\n');
}
putchar('\n');
}
int main()
{
int grid[] = { ⓫
0, 0, 0,
0, 0, 0,
0, 0, 0
};
puts("Tic-Tac-Toe");
showgrid(grid); ⓬
return(0);
}
❶ 将 grid[] 数组作为整数指针传递。
❷ 定义网格输出的颜色常量
❸ 遍历整个网格,九个方块
❹ 测试每个方块的价值:-1 表示 O,+1 表示 X,0 表示空白
❺ O 占据了方块。
❻ 输出带有背景(和 O)的方块
❼ 输出没有背景的方块
❽ 重复相同的输出给 X
❾ 为未占据的方块编号,为人类眼睛增加 1
❿ 每隔三个方块,添加一个换行符
⓫ 在这里初始化游戏网格。
⓬ 输出网格
showgrid() 函数处理游戏网格中的方块。对于每个可能的值——-1、+1 或 0——输出有两个选项。第一个是在奇数方块上触发的,其中应用了背景颜色。对于偶数方块,不使用背景颜色。这种效果是在一致的图案中输出当前游戏状态,无需额外的文本字符来构建网格。
这里是一个示例运行:
Tic-Tac-Toe
1 * 2 * 3
*4* 5 *6*
7 * 8 * 9
网格中的数字有助于在游戏过程中参考方块。最终,它们会被 X 和 O 字符替换,这不仅告知用户同一个方块不能被两次使用,还显示了游戏的进度。
你可以在这里停下来,只是欣赏你的作品。但不行。下一步是添加游戏玩法。
15.2.2 添加游戏玩法
我不确定每个游戏是否都以这种方式进行,但所有我编写的文本模式游戏都包含一个主要游戏循环。循环检查输入,更新游戏区域,并确定何时游戏结束。
通常,游戏循环是无尽的。终止条件是赢得游戏、输掉游戏或玩家放弃。
要更新现有的 ttt01.c 代码,游戏循环必须显示网格,提示输入,然后更新 grid[] 数组。这个循环在下一个列表中显示,位于输出游戏标题的 puts() 语句下方。必须声明两个整数变量:ply 和 p。
列表 15.2 main() 函数中的游戏循环
ply = 0; ❶
while(1) ❷
{
showgrid(grid); ❸
p = prompt(ply); ❹
if( p==0 ) ❺
break;
grid[p-1] = ply%2 ? -1 : 1; ❻
ply++; ❼
}
❶ 轮次,或回合,从零开始。
❷ 循环是无尽的,依赖于胜利或退出命令来中断。
❸ 输出网格
❹ 接受输入,返回放置标记的方格
❺ 如果用户输入零,则游戏退出。
❻ 在网格上设置标记,从 p 中减去一以获得数组偏移量,并使用当前回合来确定是 O (-1) 还是 X (+1) 在玩游戏
❼ 将 ply 增加到下一个回合
prompt() 函数获取用户输入,即放置标记的方格或零以退出游戏。零返回值被测试以中断循环,结束游戏。否则,grid[] 数组将被更新。
变量 ply(当前回合)的值决定了是 X 还是 O 在玩游戏。假设 X 先走。当 ply%2 为 0 时,则在网格中生成 O 或 -1;否则,设置 X 或 +1。
文本模式游戏必须依赖于流 I/O 来完成其功能。如果输入有限并且对用户有意义,则这种技巧是可能的。对于我的井字棋游戏,只允许数字输入。我依赖于 scanf() 函数,虽然我讨厌它,但它完成了工作。
下面的列表显示了 prompt() 函数,该函数在前面列表 15.2 中显示的无限 while 循环中从 main() 函数中被调用。函数的参数是当前回合,游戏的下一个回合。该值被测试以确定是 X 还是 O 在玩游戏。输入范围从 1 到 9(人类数字,而不是实际的数组偏移量),0 表示玩家想要退出。超出范围的值被解释为 0。
列表 15.3 prompt() 函数
int prompt(int p)
{
int square;
printf("%c's turn: Pick a square, 0 to quit: ",
p%2 ? 'O' : 'X' ❶
);
scanf("%d",&square); ❷
if( square<0 || square>9 ) ❸
return(0);
return(square);
}
❶ 使用变量 p 中的 ply 值来确定当前是 X 还是 O 在玩游戏
❷ 获取数字输入
❸ 对于超出范围的值,返回 0(退出)
main() 函数使用 prompt() 函数的返回值将 X 或 O 设置到网格中。完整的源代码可在在线存储库中找到,作为 ttt02.c。以下是一个示例运行:
Tic-Tac-Toe
1 * 2 * 3
*4* 5 *6*
7 * 8 * 9
X's turn: Pick a square, 0 to quit: 5
1 * 2 * 3
*4* X *6*
7 * 8 * 9
O's turn: Pick a square, 0 to quit: 1
O * 2 * 3
*4* X *6*
7 * 8 * 9
X's turn: Pick a square, 0 to quit: 2
O * X * 3
*4* X *6*
7 * 8 * 9
O's turn: Pick a square, 0 to quit: 5
O * X * 3
*4* O *6*
7 * 8 * 9
X's turn: Pick a square, 0 to quit: 0
代码成功地在网格上放置了 X 或 O,轮流进行。从这次示例运行中可以看出,O 在 X 已经占用了中心方格之后,仍然能够捕获中心方格。代码还缺少确定游戏何时结束的方法;游戏将继续进行,直到用户输入零以退出。
15.2.3 限制输入到空白方格
ttt02.c 代码有很多改进的空间。目前对我来说,优先级是限制游戏只限于网格中的空白方格。例如,如果中心方格被 X 占用,玩家 O 就无法选择该方格。这次更新需要一些修改。为了防止方格被重新占用,必须更新 prompt() 函数以及 main() 函数中的游戏循环。
更新的 prompt() 函数如下所示。必须将 grid[] 数组作为参数传递,以便函数能够确定一个方格是否被占用。此外,-1 被添加为返回值,以标记一个方格被占用或输入值超出范围。否则,返回值从 1 到 9,用于选择一个空方格,或 0 用于退出。
列表 15.4 更新的*prompt()*函数
int prompt(int p, int *g) ❶
{
int square;
printf("%c's turn: Pick a square, 0 to quit: ",
p%2 ? 'O' : 'X'
);
scanf("%d",&square);
if( square<0 || square>9 )
{
puts("Value out of range"); ❷
return(-1); ❸
}
if( square==0 ) ❹
return(square);
if( *(g+square-1) != 0 ) ❺
{
printf("Square %d is occupied, try again\n", ❻
square
);
return(-1); ❼
}
return(square); ❽
}
❶ 在这里,数组grid[]被用作指针变量g。
❷ 通知用户值超出范围
❸ 对于无效输入返回-1
❹ 在这里测试 0 以退出;否则,返回值被使用,并在数组grid[]上使用不当。
❺ 如果选择的值已被占用,或者不是零;请注意,减去 1,因为输入是 1 到 9,尽管数组元素编号为 0 到 8。
❻ 通知用户方块已被占用,请重试
❼ 对于无效输入返回-1
❽ 返回选择的方块,该方块是空的
为了使更新的*prompt()*函数工作,必须修改调用函数的语句。必须立即处理不良输入。因此,我选择将函数放入*while*循环中,其中*prompt()*的返回值是条件:
while( (p = prompt(ply,grid)) == -1 )
;
*while*循环会重复调用*prompt()*函数,直到返回值是-1。只有有效的输入——0 或开放的方块编号——才会中断循环。*main()*函数的其余部分保持不变。
更新的源代码可以在在线存储库中找到,作为ttt03.c。以下是一个示例运行:
Tic-Tac-Toe
1 * 2 * 3
*4* 5 *6*
7 * 8 * 9
X's turn: Pick a square, 0 to quit: 5
1 * 2 * 3
*4* X *6*
7 * 8 * 9
O's turn: Pick a square, 0 to quit: 5
Square 5 is occupied, try again
O's turn: Pick a square, 0 to quit: 1
O * 2 * 3
*4* X *6*
7 * 8 * 9
X's turn: Pick a square, 0 to quit: 9
O * 2 * 3
*4* X *6*
7 * 8 * X
O's turn: Pick a square, 0 to quit: 0
在第二次移动时,程序成功阻止了 O 选择 X 的方块。它输出一条消息显示问题,并敦促玩家再试一次。
15.2.4 确定胜者
游戏目前运行良好,玩家可以来回选择方块并设置他们的标记。但是代码不知道何时你赢了。此外,由于游戏循环是无限的,最终你会耗尽开放的方块,游戏不会停止,程序也不知道何时叫平局,或者猫的游戏。需要进行修复。
为了确定胜者,我编写了*winner()*函数。该函数检查游戏网格中可能获胜的八个切片,如图 15.4 所示。为了使一个切片被识别为胜者,它所有的方块必须包含相同的值——X 为+1,O 为-1。给定切片的总和必须是+3 或-3 才能赢得游戏。

图 15.4 定义井字棋胜利的八个切片
*winner()*函数接受游戏网格作为参数。每个方块都按列、行和对角线进行检查,如图 15.4 所示。函数原始版本中的数学表示法很笨拙。例如,为了测试左列,我使用了以下语句:
slice[0] = *(g+0) + *(g+3) + *(g+6);
切片数组slice[]的元素 0 持有第一列的总和——方块 0、3 和 6。然而,我发现(g+n)表示法很笨拙且令人困惑:每个方块都由整数指针g加上数组中的偏移量表示。因为我编写代码时经常需要参考地图(见图 15.1),所以我选择创建一些定义的常量来更容易地引用各种方块:
#define TL *(g+0)
#define TC *(g+1)
#define TR *(g+2)
#define ML *(g+3)
#define MC *(g+4)
#define MR *(g+5)
#define BL *(g+6)
#define BC *(g+7)
#define BR *(g+8)
这些定义的常数的助记符,也出现在图 15.1 中,使定义切片更容易。它们还在程序开发的后期发挥作用,当电脑试图阻止或赢得胜利时。
下一个列表显示了winner()函数。它的参数是游戏网格。slice[]数组包含八种可能的胜利组合的总和,每个切片中三个方格的值总和。如果一个切片包含相同的标记,它的值是-3(O 胜利)或+3(X 胜利)。一个for循环测试这些可能性。当发生胜利时,函数返回 1,否则返回 0。
列表 15.5 winner()函数
int winner(int *g)
{
int slice[8]; ❶
int x;
slice[0] = TL + ML + BL; ❷
slice[1] = TC + MC + BC;
slice[2] = TR + MR + BR;
slice[3] = TL + TC + TR;
slice[4] = ML + MC + MR;
slice[5] = BL + BC + BR;
slice[6] = TL + MC + BR;
slice[7] = TR + MC + BL;
for( x=0; x<8; x++ ) ❸
{
if( slice[x]==-3 ) ❹
{
showgrid(g); ❺
puts(">>> O wins!"); ❻
return(1); ❼
}
if( slice[x]==3 ) ❽
{
showgrid(g);
puts(">>> X wins!");
return(1);
}
}
return(0); ❾
}
❶ 八种可能的胜利方式;slice[]数组持有总和。
❷ 计算每个切片的列、行和对角线
❸ 审查总和
❹ 检查 O 胜利
❺ 输出胜利的游戏网格
❻ 通知用户
❼ 退出时返回 1,表示玩家有 1
❽ 重复相同的序列以实现 X 胜利
❾ 如果没有人有 1,则返回 0
winner()函数必须集成到游戏循环中的main()函数内,以报告胜利。它还提供了一种在用户输入 0 退出游戏之外终止循环的另一种方式。
在添加winner()函数后,游戏循环的另一个更改是为while循环设置一个终止条件。毕竟,井字棋游戏只有九次回合(回合)是可能的,假设是平局。
在游戏循环之后,我添加了另一个 if 测试以确定游戏是否为平局。这些项目在下一个代码列表中列出,显示了main()函数的更新代码。
列表 15.6 在main()函数中更新游戏循环
ply = 0;
while(ply<9) ❶
{
showgrid(grid);
while( (p = prompt(ply,grid)) == -1 )
;
if( p==0 )
break;
grid[p-1] = ply%2 ? -1 : 1;
if( winner(grid) ) ❷
break; ❸
ply++;
}
if( ply==9 ) ❹
{
showgrid(grid); ❺
puts("Cat's game!"); ❻
}
❶ 将循环限制为九次回合
❷ 调用winner()函数,当检测到胜利时返回 1
❸ 停止循环
❹ 测试循环是否以无胜利结束
❺ 输出网格以显示平局
❻ 通知用户
完整的更新可以在在线仓库中找到,作为 ttt04.c。现在游戏允许两名玩家竞争。它准确报告了胜利者,并确定游戏何时以平局结束。以下是示例输出:
Tic-Tac-Toe
1 * 2 * 3
*4* 5 *6*
7 * 8 * 9
X's turn: Pick a square, 0 to quit: 5
1 * 2 * 3
*4* X *6*
7 * 8 * 9
O's turn: Pick a square, 0 to quit: 2
1 * O * 3
*4* X *6*
7 * 8 * 9
X's turn: Pick a square, 0 to quit: 1
X * O * 3
*4* X *6*
7 * 8 * 9
O's turn: Pick a square, 0 to quit: 9
X * O * 3
*4* X *6*
7 * 8 * O
X's turn: Pick a square, 0 to quit: 4
X * O * 3
*X* X *6*
7 * 8 * O
O's turn: Pick a square, 0 to quit: 7
X * O * 3
*X* X *6*
O * 8 * O
X's turn: Pick a square, 0 to quit: 6
X * O * 3
*X* X *X*
O * 8 * O
>>> X wins!
想想当你用电脑玩井字棋时可以节省多少纸张!当然,大多数用户不想与人类挑战者玩游戏,可能是因为他们没有朋友。井字棋游戏的真正对手是……一台电脑。
15.3 电脑玩游戏
在电影《战争游戏》中,天才程序员被问及他的井字棋游戏是否有电脑可以自己玩的游戏配置。有的。关键是输入玩家数量为 0。电脑自己玩,意识到游戏毫无意义,然后我们进入 DEFCON 5。
显然,任何编写井字棋计算机版本的程序员都会被迫提供与我们的勇敢电影英雄相同的“玩家数量为零”的选项。谁不想看到计算机与自身斗智呢?这个功能不仅使游戏更有趣,而且考验程序员的逻辑:当计算机与自身对战时,游戏是否总是以平局结束?
15.3.1 选择玩家数量
为井字棋程序设置玩家数量的决策树确实很丑陋。我尝试让它变得美观,但面对三个选项进行筛选,编码选择有限。
提示输入很容易编写:
Number of players (0, 1, 2):
在 main() 函数中设置,程序标题输出后立即提示输入玩家数量:0、1 或 2。如果输入无效数字,程序将退出。
然而,在游戏循环中,决策是基于玩家数量的:
-
当玩家数量为 0 时,计算机进行每一轮。
-
当玩家数量为 1 时,计算机轮流进行每轮。
-
当玩家数量为 2 时,人类轮流进行,就像游戏 ttt04.c 版本一样。
以下列表显示了更新的 main() 函数。输入玩家数量,然后一个 if-else 结构筛选玩家,确保人类和计算机轮流进行。如果玩家数量为 1,游戏在计算机和玩家之间交替进行,玩家先手。
列表 15.7 更新的 main() 函数
int main()
{
int grid[] = {
0, 0, 0,
0, 0, 0,
0, 0, 0
};
int ply,p,players; ❶
srand( (unsigned)time(NULL) ); ❷
puts("Tic-Tac-Toe");
printf("Number of players (0, 1, 2): "); ❸
scanf("%d",&players);
if( players<0 || players>2 ) ❹
return(1);
ply = 0;
while(ply<9)
{
showgrid(grid);
if( players==0 ) ❺
{
p = computer(grid); ❻
}
else if( players==1 ) ❼
{
if( ply%2 ) ❽
{
p = computer(grid);
}
else ❾
{
while( (p = prompt(ply,grid)) == -1 )
;
}
}
else ❿
{
while( (p = prompt(ply,grid)) == -1 )
;
}
if( p==0 )
break;
grid[p-1] = ply%2 ? -1 : 1;
if( winner(grid) )
break;
ply++;
}
if( ply==9 )
{
showgrid(grid);
puts("Cat's game!");
}
return(0);
}
❶ 变量 players 跟踪玩家数量:0、1 或 2。
❷ 为计算机游戏设置随机种子
❸ 提示输入
❹ 在无效输入时退出程序
❺ 未指定玩家数量。
❻ 计算机始终自己玩,每轮都如此。
❼ 指定一个玩家。
❽ 在奇数回合,计算机进行游戏。
❾ prompt() 函数处理玩家的回合。
❿ 对于两位玩家,prompt() 函数处理两个回合。
computer() 函数处理计算机的玩法,即使两个玩家都是计算机。prompt() 函数处理人类玩家的交互。
代码尚未完成。必须编写 computer() 函数,这将在下一节中介绍。然而,为了完成本节的更新,你必须添加指令以包含 stdlib.h 和 time.h 头文件,这些文件支持 main() 函数中的 srand() 语句,以及 computer() 函数中的 rand() 语句。
15.3.2 编写一个愚蠢的对手
在游戏开发的这个阶段,computer() 函数不需要隐藏狡猾的智慧或对如何赢得游戏的深入了解。因此,我编写了一个纯随机选择程序,如下一列表所示。该函数检查网格中是否有可用的随机方格,并在该位置设置其标记。随机值返回——与人类玩家选择兼容的范围内——在 main() 函数中将标记设置在。
列表 15.8 computer() 函数
int computer(int *g)
{
int r;
do
{
r = rand() % 9; ❶
} while( *(g+r) != 0 ); ❷
r++; ❸
printf("The computer moves to square %d\n",r); ❹
return(r);
}
❶ 生成一个随机值,0 到 8
❷ 确认方格是空的,否则继续循环
❸ 为人类玩家以及与 prompt() 函数的一致性增加方格值
❹ 通知用户
完整的代码,包括上一节更新的 main() 函数以及 computer() 函数,可以在在线仓库中找到,文件名为 ttt05.c。
在程序演化的这个阶段,电脑总是第二个出手的,用 O 表示其移动。以下是一些示例输出:
Tic-Tac-Toe
Number of players (0, 1, 2): 1
1 * 2 * 3
*4* 5 *6*
7 * 8 * 9
X's turn: Pick a square, 0 to quit: 5
1 * 2 * 3
*4* X *6*
7 * 8 * 9
The computer moves to square 6
1 * 2 * 3
*4* X *O*
7 * 8 * 9
X's turn: Pick a square, 0 to quit: 3
1 * 2 * X
*4* X *O*
7 * 8 * 9
The computer moves to square 2
1 * O * X
*4* X *O*
7 * 8 * 9
X's turn: Pick a square, 0 to quit: 7
1 * O * X
*4* X *O*
X * 8 * 9
>>> X wins!
可以给承认愚蠢的 computer() 函数增加一些智能,但事实上并没有这样的东西。我会提供一个示例运行,让你相信电脑很聪明,但相反,你应该自己运行代码,将玩家数量设置为 0,并查看输出。偶尔,电脑看起来像是聪明的。相信我——它不是。
练习 15.1
电脑抱怨说,在一对一的对战中,它总是第二个出手的,这很不公平。为了解决这个问题,更新 ttt05.c 中的 main() 函数,以便进行随机选择,确定哪个玩家先出手:电脑或人类。
这里是我解决方案输出的第一部分:
Tic-Tac-Toe
Number of players (0, 1, 2): 1
A flip of the bitcoin says that the computer goes first
1 * 2 * 3
*4* 5 *6*
7 * 8 * 9
The computer moves to square 3
1 * 2 * X
*4* 5 *6*
7 * 8 * 9
当只有一个玩家被选中,即人机对战时,才需要随机选择谁先出手。我的解决方案可以在在线仓库中找到,文件名为 ttt06.c。
15.3.3 增加一些智能
大多数编写电脑玩井字棋的程序员都使用游戏树。他们绘制每一个移动及其后果,所有大约 120 种游戏排列。我也考虑了这种方法,但感觉工作量很大。由于懒惰,我为电脑玩井字棋并希望获胜想出了一个自己的方法。
我的代码为电脑玩家提供了三个智能点。首先,如果它是第一个回合(回合值为 0)并且电脑先出手,它应该抓住中心方格。这个更新是针对 computer() 函数的:
if( p==0 )
{
puts("The computer snags the center");
return(5);
}
变量 p 是 main() 函数中游戏循环的当前回合值。当其值为 0 时,电脑是第一个出手的,所有方格都是开放的。输出一条消息,并函数返回 5,即中心方格。这个值应该是 4,因为这是 grid[] 数组中的偏移量,但 computer() 函数必须与用户的 prompt() 函数兼容,并返回 1 到 9 范围内的值。(记住,prompt() 返回 0 以退出游戏。)
这个 if 测试可以改进为在第二回合检查中心方格:如果电脑是第二个出手的,但它的对手太笨拙而无法抓住中心方格,它应该去占据它。以下是 if 决策的更新:
if( p==0 || (p==1 && MC==0) )
{
puts("The computer snags the center");
return(5);
}
if 条件读取为:“如果是第一回合——或者如果是第二回合且中心方格(MC)仍然是空的——则占据中心方格。”在这个游戏中,中心方格是一个强势位置。事实上,占据中心是孩子在第一次玩井字棋时学会的第一个技巧。
第二项智慧是在中心方块被占据时玩一个角落方块。这个移动在第二个移动时提供了最好的防御。这里的 if 决定很简单:
if( p==1 && TL==0 )
{
puts("The computer moves to square 1");
return(1);
}
if 条件读取:“如果是第二个回合(回合)且左上角(TL)方块为空,则取它。”返回值 1。在 computer() 函数的这个点,中心方块已经被占据——这是保证的。前面的 if 条件排除了 MC 不是 0 的可能性。因此,在第二个回合,p==1,左上角(TL)方块最可能是空的。if 条件仍然测试它,并防御性地移动到左上角方块。
第三项智慧是扫描游戏网格以寻找阻止或获胜的移动。在计算机求助于随机移动之前,它会扫描游戏网格上所有八个可能的获胜切片。如果这些切片中的任何一个包含两个相同的标记加上一个空方块,则填充空方块,以便计算机获胜或阻止获胜。
我最初写了两个函数,towin() 和 toblock(),用于执行游戏网格扫描。最终,我意识到这两个函数工作方式相同,只是查找不同的值。towin() 函数希望计算机的标记总和为 2 或 -2;toblock() 函数希望对手的标记总和为 2 或 -2。我编写了 three() 函数来处理这两种情况:
int three(int *g, int p)
函数的参数是 g,游戏网格,和 p,要查找的标记:-1 代表 O,+1 代表 X。
three() 函数的语句是重复的,每个块代表建立胜利的八个切片中的一个。在本章前面定义的常量代表特定的方块。以下是一个典型的块:
if( TL + ML + BL == p*2 )
{
if( TL==0 ) return 0;
if( ML==0 ) return 3;
if( BL==0 ) return 6;
}
定义了常量 TL、ML 和 BL 代表网格的第一列。如果它们的总和等于变量 p 的两倍,则该列包含两个匹配的标记和一个空白。无论 p 是 -1 代表 O 还是 +1 代表 X,这个结果都成立。
在确定一个切片为潜在的获胜或阻止后,函数返回一个表示空白方块的值:如果是左上角方块,则返回 TL,0。如果中间左边的方块为空,ML==0,则返回其偏移量。这种逻辑允许计算机根据变量 p 的值获胜或阻止。
three() 函数继续对八个切片中的每一个进行类似的测试。返回的值是要选择的方块,报告给下一个列表中显示的 computer() 函数。代码首先检查获胜,然后检查阻止。如果这两个测试都不成功(返回 -1),则计算机随机选择一个可用的方块,就像以前一样。
列表 15.9 更新的 computer() 函数
int computer(int p,int *g) ❶
{
int r;
if( p==0 || (p==1 && MC==0) ) ❷
{
puts("The computer snags the center");
return(5);
}
if( p==1 && TL==0 ) ❸
{
puts("The computer moves to square 1");
return(1);
}
if( p%2 ) ❹
r = three(g,-1); ❺
else
r = three(g,1); ❻
if( r==-1 ) ❼
{
if( p%2 ) ❽
r = three(g,1); ❾
else
r = three(g,-1); ❿
}
if( r==-1 ) ⓫
{
do
{
r = rand() % 9;
} while( *(g+r) != 0 );
}
r++; ⓬
printf("The computer moves to square %d\n",r); ⓭
return(r);
}
❶ 变量 p 是当前回合,g 是游戏网格。
❷ 如果中心方块为空,则抓取它
❸ 在第二轮,如果角落方块为空,则抓取它
❹ 使用回合值检测获胜:0 代表 O 的回合,1 代表 X。
❺ 检查 O 的获胜(-1)
❻ 检查 X 的获胜(+1)
如果没有检测到胜利,three() 返回 -1;检查是否有阻挡(你希望在阻挡之前获胜)。
❽ 确定下一个是 X 还是 O 在移动
❾ 为 X 阻挡
❿ 为 O 阻挡
⓫ 如果 r 等于 -1e,计算机没有获胜或阻挡;是时候随机选择一个格子了。
⓬ 将 r 增加以表示适当的偏移量,1 到 9
⓭ 通知用户
computer() 函数中的智能从上到下工作:首先是中心格检查,然后计算机尝试抓住角落格。之后,首先检查 three() 函数以获胜,然后以阻挡。当这些努力失败时,返回值 -1 显示,计算机使用随机数生成器。
main() 函数也必须更新,以反映 computer() 函数的新参数。需要两个更新来修改这个语句:
p = computer(grid);
进入这个语句:
p = computer(ply,grid);
在 computer() 函数中,ply 参数用于调用 three() 函数。因为这个变量的值决定了函数是阻挡还是获胜,因为在程序中,X 总是先走。
所有更改,包括完整的 three() 函数,都可以在在线仓库的源代码文件 ttt07.c 中找到。计算机玩家并不完美聪明,但足够聪明,可以构成挑战——至少在几场比赛中,肯定可以击败一个小孩或愚蠢的成人。
当然,真正的测试是在计算机与自己对战时。理论上,它应该每次都平局。但程序仍然使用随机数生成来规划其初始游戏。具体来说,在下面显示的计算机对计算机输出中,看看计算机如何抓住中心格以及左上角格?这些是优势性和防御性移动:
Tic-Tac-Toe
Number of players (0, 1, 2): 0
1 * 2 * 3
*4* 5 *6*
7 * 8 * 9
The computer snags the center
1 * 2 * 3
*4* X *6*
7 * 8 * 9
The computer moves to square 1
O * 2 * 3
*4* X *6*
7 * 8 * 9
The computer moves to square 3
O * 2 * X
*4* X *6*
7 * 8 * 9
The computer moves to square 7
O * 2 * X
*4* X *6*
O * 8 * 9
The computer moves to square 4
O * 2 * X
*X* X *6*
O * 8 * 9
The computer moves to square 6
O * 2 * X
*X* X *O*
O * 8 * 9
The computer moves to square 9
O * 2 * X
*X* X *O*
O * 8 * X
The computer moves to square 8
O * 2 * X
*X* X *O*
O * O * X
The computer moves to square 2
O * X * X
*X* X *O*
O * O * X
Cat's game!
从输出中,你可以看到计算机在与自己对战时表现不错。它并不特别聪明,但挑战性足够大——游戏以平局结束。
在这一点上对代码的进一步更新将导致游戏树策略,其中你在一个复杂的树形决策结构中规划最佳的第二、第三和第四步。然而,在某个时候,玩游戏会采用阻挡和获胜的策略。
我考虑的一个狡猾的改进是让计算机作弊。例如,它可以替换对手的棋子或阻止对手选择一个获胜的格子。尽管这样的修改很有趣,但它涉及到重写大量现有的代码。不过,这个任务就留给你们了,虽然不是正式的练习。


浙公网安备 33010602011771号