C-现代指南-全-

C 现代指南(全)

原文:Modern C

译者:飞龙

协议:CC BY-NC-SA 4.0

Level 0. 遭遇

这个级别的吉祥物是乌鸦,地球上最聪明的非人类物种之一。它们能够进行复杂的社会仪式和工具使用。

这本书的第一级可能是你第一次接触编程语言 C。它为你提供了关于 C 程序、它们的目的、结构和如何使用它们的大致知识。它并不旨在提供一个完整的概述,它不能,甚至不试图这样做。相反,它旨在给你一个大致的概念,提出问题,促进思想和概念。这些将在更高层次中详细解释。

Chapter 1. 开始

本章涵盖

  • 命令式编程简介

  • 编译和运行代码

在本章中,我将向您介绍一个简单的程序,我们选择它是因为它包含了 C 语言中的许多构造。如果您已经有了编程经验,您可能会发现其中的一些部分感觉像是无用的重复。如果您缺乏这样的经验,您可能会被一连串的新术语和概念所淹没。

在任何情况下,都要有耐心。对于那些有编程经验的人来说,很可能存在一些您没有意识到的微妙细节,或者您对语言所做的假设可能并不正确,即使您以前已经编写过 C 程序。对于那些第一次接触编程的人来说,请放心,大约 10 页之后,您的理解将大大提高,您应该对编程代表什么有一个更清晰的认识。

对于一般编程,尤其是这本书来说,以下引用总结了道格拉斯·亚当斯的《银河系漫游指南》中的宝贵智慧[1986]:

Takeaway B

别慌张。

这不值得。文本中有很多交叉引用、链接和旁支信息,并且在最后有一个索引。如果你有问题,就按照那些线索去找。或者,只是休息一下。

在 C 语言中编程是让计算机完成一些特定的任务。一个 C 程序通过下达命令来完成这些任务,就像我们在许多人类语言中使用祈使语气表达这样的命令一样;因此,这种组织计算机程序的方式被称为命令式编程。为了开始并了解我们在说什么,考虑我们的第一个程序列表 1.1:

Listing 1.1. C 程序的一个初步示例
 **1**   /* This may look like nonsense, but really is -*- mode: C -*- */
 **2**   #include <stdlib.h>
 **3**   #include <stdio.h>
 **4**
 **5**   /* The main thing that this program does. */
 **6**   int **main**(void) {
 **7**     // Declarations
 **8**     double A[5] = {
 **9**       [0] = 9.0,
**10**       [1] = 2.9,
**11**       [4] = 3.E+25,
**12**       [3] = .00007,
**13**     };
**14**
**15**     // Doing some work
**16**     for (**size_t** i = 0; i < 5; ++i) {
**17**         **printf**("element %zu is %g, \tits square is %g\n",
**18**                i,
**19**                A[i],
**20**                A[i]*A[i]);
**21**     }
**22**
**23**     return **EXIT_SUCCESS**;
**24**   }

1.1. 命令式编程

你可能看到这是一种语言,包含一些奇怪的词,如mainincludefor等等,它们以独特的方式排列和着色,并与大量奇怪的字符、数字和文本(“做一些工作”)混合在一起,看起来像普通的英语。它旨在在我们人类程序员和机器之间建立联系,告诉它该做什么:下达“命令”。

Takeaway 1.1

C 是一种命令式编程语言

在这本书中,我们不仅会遇到 C 编程语言,还会遇到一些来自英语方言的词汇,C 术语,帮助我们谈论 C的语言。第一次出现时,不可能立即解释每个术语。但我会及时解释每个术语,并且它们都有索引,你可以轻松地作弊并跳转C*到更详细的文本,风险自负.([1])

¹

如此特殊的 C 术语用C标记,如下所示。

如您可能从第一个例子中猜到的,这样的 C 程序有不同的组件,它们形成了一些混合层。让我们从内部开始尝试理解它。运行此程序的可视结果是输出您计算机命令行上的 5 行文本。在我的计算机上,使用此程序看起来像这样:

终端
**0**   > ./getting-started
**1**   element 0 is 9,         its square is 81
**2**   element 1 is 2.9,       its square is 8.41
**3**   element 2 is 0,         its square is 0
**4**   element 3 is 7e-05,     its square is 4.9e-09
**5**   element 4 is 3e+25,     its square is 9e+50

我们可以轻松地在我们程序中识别出程序输出的文本部分(在 C 术语中称为打印^C):第 17 行之间的引号部分。真正的动作发生在这一行和第 20 行之间。C 称这为语句C*,这有点名不副实。其他语言会使用*指令*这个术语,它更好地描述了其目的。这个特定的语句是对名为**printf**的*函数*C调用^C*:

getting-started.c
**17**    **printf**("element %zu is %g, \tits square is %g\n",
**18**           i,
**19**           A[i],
**20**           A[i]*A[i]);

在这里,printf函数接收四个参数C*,用一对*括号*C*括起来,( ... )

  • 看起来奇怪的文本(在引号之间)是一种所谓的字符串字面量C*,它作为输出的*格式*C。在文本中有三个标记(格式说明符C*),它们指示输出中插入数字的位置。这些标记以一个`%`字符开始。此格式还包含一些特殊的*转义字符*C,它们以反斜杠开始:\t 和\n。

  • 在逗号字符之后,我们找到了单词 i。i 所代表的内容将打印在第一个格式说明符%zu 的位置上。

  • 另一个逗号将下一个参数 A[i]分隔开来。这个参数所代表的内容将打印在第二个格式说明符,即第一个%g 的位置上。

  • 最后,再次用逗号分隔,出现 A[i]*A[i],对应于最后一个%g。

我们稍后会解释所有这些参数的含义。只需记住,我们已经确定了程序的主要目的(在终端上打印一些行)并且它“指示”printf函数来实现这个目的。其余的部分是一些糖分,用于指定要打印哪些数字以及打印多少个。

1.2. 编译和运行

如前所述,程序文本表达了我们希望计算机执行的操作。因此,它只是我们写下来并存储在硬盘上的另一段文本,但程序文本本身不能被你的计算机理解。有一个特殊的程序,称为编译器,它将 C 文本翻译成你的机器可以理解的东西:二进制代码**C*或*可执行文件**C。这个翻译程序看起来是什么样子以及这种翻译是如何进行的,在这个阶段解释得太复杂了.^([2]) 即使整本书也无法解释大部分内容;那将是另一本书的主题。然而,目前我们不需要深入了解,因为我们有为我们做所有工作的工具。

²

实际上,翻译本身是在几个步骤中完成的,从文本替换,经过适当的编译,到链接。尽管如此,将所有这些捆绑在一起的工具传统上被称为编译器,而不是翻译器,后者更准确。

收获 1.2

C 是一种编译型编程语言

编译器的名称及其命令行参数在很大程度上取决于你将在其上运行程序的平台**C*。这有一个简单的理由:目标二进制代码是*平台相关**C的:也就是说,其形式和细节取决于你想要在其上运行它的计算机。PC 和手机有不同的需求,你的冰箱和机顶盒使用的“语言”也不相同。事实上,这就是 C 存在的一个原因:C 为所有不同的机器特定语言提供了一种抽象级别(通常被称为汇编器**^C)。

收获 1.3

正确的 C 程序在不同平台上是可移植的

在这本书中,我们将投入大量精力向你展示如何编写“正确”的 C 程序,以确保其可移植性。不幸的是,有些平台声称自己是“C”平台,但实际上并不符合最新的标准;还有一些符合标准的平台接受不正确的程序,或者提供对 C 标准的扩展,这些扩展并不广泛可移植。因此,在一个平台上运行和测试程序并不总是能保证其可移植性。

编译器的任务是确保前面展示的小程序,一旦为适当的平台翻译,将在你的 PC、你的手机、你的机顶盒上正确运行,甚至可能在你冰箱上运行。

话虽如此,如果你有一个 POSIX 系统(如 Linux 或 macOS),那么一个名为c99的程序可能存在,并且实际上它是一个 C 编译器。你可以尝试使用以下命令编译示例程序:

终端
**0**   > c99 -Wall -o getting-started getting-started.c -lm

编译器应该无怨无悔地完成其工作,并在你的当前目录下输出一个名为getting-started的可执行文件.^([[Exs 1]]) 在示例行中,

^([Exs 1])

在你的终端中尝试编译命令。

  • c99 是编译程序。

  • -Wall 告诉它警告我们它发现的任何不寻常之处。

  • -o getting-started 告诉它将编译器输出存储在名为 getting-started 的文件中。

  • getting-started.c 命名了源文件,包含我们编写的 C 代码的文件。请注意,文件名末尾的 .c 扩展名指的是 C 编程语言。

  • -lm 告诉它在必要时添加一些标准数学函数;我们稍后会需要这些。

现在我们可以执行我们新创建的可执行文件。输入

终端
**0**   > ./getting-started

你应该看到与我之前展示的完全相同的输出。这就是便携性的含义:无论你在哪里运行那个程序,它的行为应该是相同的。

如果你运气不好,编译命令没有工作,你将不得不在你的系统文档中查找你的编译器的名称。你可能甚至需要安装一个编译器。3 编译器的名称各不相同。以下是一些可能有效的常见替代方案:

³

这在特定情况下是必要的,尤其是如果你有一个使用微软操作系统的系统。微软的本地编译器尚未完全支持 C99,而且我们在这本书中讨论的许多功能可能无法工作。关于替代方案的讨论,你可能想看看 Chris Wellons 的博客条目“四种在 Windows 上编译 C 的方法”(nullprogram.com/blog/2016/06/13/)。

终端
**0**   > clang -Wall -lm -o getting-started getting-started.c
**1**   > gcc -std=c99 -Wall -lm -o getting-started getting-started.c
**2**   > icc -std=c99 -Wall -lm -o getting-started getting-started.c

这些中的一些,即使它们存在于你的计算机上,也可能不会在没有抱怨的情况下编译程序。[[Exs 2]]

^([Exs 2])

使用这本书开始编写关于你测试的文本报告。记下哪些命令对你有效。

在 列表 1.1 中的程序中,我们展示了一个理想的世界:一个在所有平台上都能正常工作并产生相同结果的程序。不幸的是,当你自己编程时,你经常会遇到一个只部分工作且可能产生错误或不可靠结果的程序。因此,让我们看看 列表 1.2 中的程序。它看起来与上一个非常相似。

列表 1.2. 一个有缺陷的 C 程序示例
 **1**   /* This may look like nonsense, but really is -*- mode: C -*- */
 **2**
 **3**   /* The main thing that this program does. */
 **4**   void **main**() {
 **5**     // Declarations
 **6**     int i;
 **7**     double A[5] = {
 **8**       9.0,
 **9**       2.9,
**10**       3.E+25,
**11**       .00007,
**12**     };
**13**
**14**     // Doing some work
**15**     for (i = 0; i < 5; ++i) {
**16**        **printf**("element %d is %g, \tits square is %g\n",
**17**               i,
**18**               A[i],
**19**               A[i]*A[i]);
**20**     }
**21**
**22**     return 0;
**23**   }

如果你在这个程序上运行你的编译器,它应该给你一些类似的诊断信息

终端
**0**   > c99 -Wall -o bad bad.c
**1**   bad.c:4:6: warning: return type of 'main' is not 'int' [-Wmain]
**2**   bad.c: In function 'main':
**3**   bad.c:16:6: warning: implicit declaration of function 'printf' [-Wimplicit-function...
**4**   bad.c:16:6: warning: incompatible implicit declaration of built-in function 'printf' ...
**5**   bad.c:22:3: warning: 'return' with a value, in function returning void [enabled by de...

这里有很多很长的“警告”行,甚至长到无法适应终端屏幕。最后,编译器生成了一个可执行文件。不幸的是,当我们运行程序时的输出是不同的。这是一个我们必须小心并注意细节的信号。

clang 比起 gcc 更加挑剔,并给出了更长的诊断行:

终端
 **0**   > clang -Wall -o getting-started-badly bad.c
 **1**   bad.c:4:1: warning: return type of 'main' is not 'int' [-Wmain-return-type]
 **2**   void main() {
 **3**   ^
 **4**   bad.c:16:6: warning: implicitly declaring library function 'printf' with type
 **5**         'int (const char *, ...)'
 **6**        printf("element %d is %g, \tits square is %g\n", /*@\label{printf-start-badly}*/
 **7**        ^
 **8**   bad.c:16:6: note: please include the header <stdio.h> or explicitly provide a declaration
 **9**         for 'printf'
**10**   bad.c:22:3: error: void function 'main' should not return a value [-Wreturn-type]
**11**     return 0;
**12**     ^      ~
**13**   2 warnings and 1 error generated.

这是一件好事!它的 诊断输出**^C 非常具有信息量。特别是,它给出了两个提示:它期望 main 函数有不同的返回类型,并且它期望我们有一个类似于 列表 1.1 中的第 3 行的行来指定 printf 函数的来源。注意 clanggcc 不同,没有生成可执行文件。它认为第 22 行的问题具有致命性。把这看作是一个特性。

根据你的平台,你可以强制编译器拒绝产生此类诊断的程序。对于 gcc,这样的命令行选项将是 -Werror

因此,我们已经看到了 列表 1.1 和 1.2 之间的两个不同点,这两个修改将一个良好、符合标准、可移植的程序变成了一个不好的程序。我们还看到编译器在那里帮助我们。它将问题锁定在程序中引起麻烦的行上,并且凭借一些经验,你将能够理解它在告诉你什么.^([[Exs 3]]) ^([[Exs 4]])

^([Exs 3])

逐步纠正 列表 1.2。从第一条诊断行开始,修复那里提到的代码,重新编译,等等,直到你有一个无瑕疵的程序。

^([Exs 4])

这两个程序之间还有一个我们尚未提到的第三个区别。找到它。

取得成果 1.4

一个 C 程序应该无警告地干净编译。

概述

  • C 是为了给计算机下命令而设计的。因此,它在程序员(我们)和计算机之间进行调解。

  • C 必须编译后才能执行。编译器提供了我们理解的语言(C)与特定平台特定需求之间的翻译。

  • C 提供了一个抽象级别,提供了可移植性。一个 C 程序可以在许多不同的计算机架构上使用。

  • C 编译器在那里帮助你。如果你在程序中警告你关于某事,要听从它。

第二章. 程序的主要结构

本章涵盖

  • C 语法

  • 声明标识符

  • 定义对象

  • 使用语句指导编译器

与前一章中的小例子相比,真正的程序将更加复杂,并包含额外的结构,但它们的结构将非常相似。列表 1.1 已经包含了 C 程序的大部分结构元素。

在 C 程序中,有两个方面的考虑:语法方面(我们如何指定程序以便编译器理解它?)和语义方面(我们指定什么以便程序执行我们想要它执行的操作?)。在接下来的几节中,我们将介绍语法方面(语法)和三个不同的语义方面:声明部分(是什么事物),对象定义(事物在哪里),和语句(事物应该做什么)。

2.1. 语法

从其整体结构来看,我们可以看到 C 程序由不同类型的文本元素组成,这些元素按照某种语法组合在一起。这些元素包括:

  • 特殊词汇: 在列表 1.1 中,我们使用了以下特殊词汇:#includeintvoiddoubleforreturn。在这本书的程序文本中,它们通常会被加粗。这些特殊词汇代表 C 语言强加的概念和特性,这些特性不能更改。

    ¹

    在 C 术语中,这些是指令C*、*关键字C保留^C标识符。

  • 标点符号^C: C 语言使用几种类型的标点符号来结构化程序文本。

    • 有五种括号类型:{ ... }( ... )[ ... ]/* ... */< ... >。括号用于将程序的某些部分组合在一起,并且应该始终成对出现。幸运的是,在 C 语言中< ... >括号很少见,并且仅在我们示例中所示,位于同一逻辑行文本上。其他四种括号不受单行限制;它们的内含可能跨越多行,就像我们之前使用printf时那样。

    • 有两种不同的分隔符或终止符:逗号和分号。当我们使用printf时,我们看到逗号用于分隔该函数的四个参数;在第 12 行我们也看到逗号也可以跟在元素列表的最后一个元素后面。

    getting-started.c
    **12**       [3] = .00007,
    

    C 语言新手的困难之一是相同的标点符号用于表达不同的概念。例如,成对出现的{}[]在列表 1.1 中各自用于三个不同的目的).^([[Exs 1]])

    ^([Exs 1])

    找出这些两种括号的不同用法。

    Takeaway 2.1

    标点符号可以具有多种不同的含义

  • 注释^C: 我们之前看到的/* ... */构造告诉编译器,它内部的所有内容都是注释;例如,查看第 5 行:

    getting-started.c
    **5**   /* The main thing that this program does. */
    

    注释被编译器忽略。这是解释和记录代码的完美地方。这种内联文档可以(并且应该)极大地提高代码的可读性和可理解性。另一种形式的注释是所谓的 C++风格注释,如第 15 行所示。这些注释以//开始,延伸到行尾。

  • 字面量^C: 我们程序包含几个引用固定值的项,这些值是程序的一部分:013459.02.93.E+25.00007"element %zu is %g, \tits square is %g\n"。这些被称为字面量^C

  • 标识符^C: 这些是我们(或 C 标准)给程序中某些实体赋予的“名称”。在这里我们有 A、i、mainprintfsize_tEXIT_SUCCESS。标识符在程序中可以扮演不同的角色。在其他方面,它们可能指代

    • 数据对象^C(如 A 和 i)。这些也被称为 变量^C

    • 类型^C* 别名,如 size_t,指定了新对象“种类”,这里是指 i。注意名称末尾的 _t。这种命名约定是 C 标准用来提醒你该标识符指的是一个类型的。

    • 函数,如 mainprintf

    • 常量,如 EXIT_SUCCESS

  • 函数^C: 两个标识符指的是函数:mainprintf。正如我们之前所看到的,printf 是程序用来产生一些输出的。函数 main 由此被 定义^C:也就是说,它的 声明^C int main(void) 后面跟着一个 ^C,用 { ... } 包围,描述了这个函数应该做什么。在我们的例子中,这个函数 定义^C 从第 6 行到第 24 行。main 在 C 程序中有一个特殊的作用,我们将会遇到:它必须始终存在,因为它是程序执行的开始点。

  • 运算符^C: 在众多的 C 运算符中,我们的程序只使用了几个:

    • = 用于 初始化^C* 和 赋值^C*,

    • < 用于比较,

    • ++ 用于 递增 一个变量(将其值增加 1),以及

    • * 用于乘以两个值。

正如在自然语言中一样,我们在这里看到的 C 程序的词法元素和语法必须与这些构造的实际意义区分开来。然而,与自然语言不同的是,这种意义是严格指定的,通常没有歧义的空间。在接下来的几节中,我们将深入研究 C 区分的三个主要语义类别:声明、定义和语句。

2.2. 声明

在我们可以在程序中使用特定的标识符之前,我们必须向编译器提供一个 声明^C*,指定该标识符应该代表什么。这就是标识符与关键字的不同之处:关键字是由语言预定义的,不能声明或重新定义。

Takeaway 2.2

程序中的所有标识符都必须声明

我们在程序中实际声明的三个标识符是:main、A 和 i。稍后,我们将看到其他标识符(printfsize_tEXIT_SUCCESS)的来源。我们已经提到了 main 函数的声明。这三个声明,单独作为“仅声明”,看起来是这样的:

int **main**(void);
double A[5];
**size_t** i;

这三个标识符遵循一个模式。每个都有一个标识符(main、A 或 i)以及与该标识符相关联的某些属性:

  • i 的 类型^C* 是 size_t

  • main 还额外跟随着括号,( ... ),因此声明了一个 int 类型的函数。

  • A 后跟方括号 [ ... ],因此声明了一个 数组**^C。数组是相同类型几个项的聚合;这里由 5 个类型为 double 的项组成。这些 5 个项是有序的,可以通过数字(称为 索引**^C)从 04 来引用。

每个这些声明都以一个 类型**^C 开头,这里 intdoublesize_t。我们稍后会看到这代表什么。目前,只需知道这指定了所有三个标识符在语句的上下文中将作为某种“数字”使用。

i 和 A 的声明声明了 变量**^C,它们是命名项,允许我们存储 值**^C。它们最好被想象成一种可能包含特定类型“某物”的盒子:

从概念上讲,区分盒子本身(对象)、规范(其 类型)、盒子内容(其 )以及写在上面的名称或标签(标识符)是很重要的。在这些图中,如果我们不知道一个项的实际值,我们会放 ??

对于其他三个标识符,printfsize_tEXIT_SUCCESS,我们没有看到任何声明。实际上,它们是预声明的标识符,但正如我们在尝试编译列表 1.2 时所看到的,这些标识符的信息并非凭空而来。我们必须告诉编译器它们可以从哪里获取这些信息。这是在程序的开始处,在第 2 行和第 3 行完成的:printfstdio.h 提供,而 size_tEXIT_SUCCESS 来自 stdlib.h。这些标识符的真实声明在计算机上的某个位置以这些名称指定的 .h 文件中指定。它们可能如下所示:

<stdio.h>

<stdlib.h>

int **printf**(char const format[static 1], ...);
typedef unsigned long **size_t**;
#define **EXIT_SUCCESS** 0

由于这些预声明特性的具体细节不太重要,这些信息通常在这些 包含文件**^C头文件**^C 中对你隐藏。如果你需要了解它们的语义,通常在相应的文件中查找它们不是一个好主意,因为这些文件往往难以阅读。相反,你应该在你的平台提供的文档中进行搜索。对于勇敢的人,我总是推荐查看当前的 C 标准,因为所有这些标准都来源于此。对于不那么勇敢的人,以下命令可能会有所帮助:

终端
**0**   > apropos printf
**1**   > man printf
**2**   > man 3 printf

声明仅描述一个特性,但不会创建它,因此重复声明并不会造成太大伤害,但会增加冗余。

收获 2.3

标识符可能有多个一致的声明

显然,如果在程序的同一部分有多个针对同一标识符的矛盾声明,这将变得非常令人困惑(对我们或编译器来说),因此通常不允许这样做。C 对“程序的同一部分”的含义非常具体:作用域**^C 是一个标识符可见的程序的某个部分。

收获 2.4

声明绑定到它们出现的作用域中

标识符的作用域由语法明确描述。在列表 1.1 中,我们有不同作用域中的声明:

  • A 在main函数的定义内部可见,从第 8 行的声明开始,到包含该声明的最内层{ ... }块的结束},即第 24 行。

  • i 的可见性更受限制。它绑定到它声明的for结构中。它的可见性从第 16 行的声明开始,延伸到与第 21 行的for关联的{ ... }块的末尾。

  • main函数没有被包含在{ ... }块中,因此从它的声明开始直到文件结束都是可见的。

在术语的轻微滥用中,前两种作用域被称为块作用域,因为作用域受的限制。第三种类型,用于main,它不在{ ... }对中,被称为文件作用域。文件作用域中的标识符通常被称为全局变量

2.3. 定义

通常,声明只指定一个标识符所引用的对象类型,而不是标识符的具体值,也不是它所引用的对象可以在哪里找到。这个重要的角色由一个定义来填补。

取得成果 2.5

声明指定标识符,而定义指定对象

我们稍后将会看到,在现实生活中事情要复杂一些,但到目前为止,我们可以简化为:我们总是初始化我们的变量。初始化是一种语法结构,它增强声明并提供对象的初始值。例如,

**size_t** i = 0;

是一个声明,其中 i 的初始值为0

在 C 语言中,这样的带有初始化器的声明也定义了具有相应名称的对象:也就是说,它指示编译器提供存储空间,以便变量的值可以存储在其中。

取得成果 2.6

对象在初始化的同时被定义

我们现在可以用一个值,在这个例子中是0,来完成我们的盒子可视化:

图片

A 稍微复杂一些,因为它有几个组成部分:

getting-started.c
 **8**   double A[5] = {
 **9**     [0] = 9.0,
**10**     [1] = 2.9,
**11**     [4] = 3.E+25,
**12**     [3] = .00007,
**13**   };

这将 A 中的5个元素初始化为9.02.90.00.000073.0E+25,顺序如下:

图片

我们在这里看到的初始化器的形式被称为指定:一对带有整数的方括号,该整数指定数组中的哪个元素用相应的值进行初始化。例如,[4] = 3.E+25将数组 A 的最后一个元素设置为3.E+25。作为一条特殊规则,初始化器中没有列出的任何位置都被设置为0。在我们的例子中,缺失的[2]被填充为0.0。^([2])

²

我们稍后将会看到这些带有小数点(.)和指数(E+25)的数字字面量是如何工作的。

取得成果 2.7

初始化器中缺失的元素默认为 0

你可能已经注意到,数组位置,索引**^C,对于第一个元素不是从 1 开始,而是从 0 开始。将数组位置想象成对应数组元素从数组开始处的距离。

Takeaway 2.8

对于具有 n 个元素的数组,第一个元素的索引为 0,最后一个元素的索引为 n-1。

对于一个函数,如果其声明后面跟着包含函数代码的花括号 { ... },则我们有一个定义(而不是只有声明):

int **main**(void) {
  ...
}

在我们之前的例子中,我们已经看到了两个不同特征的名称:对象**^C,i 和 A,以及 函数**^Cmainprintf。与对象或函数声明不同,对于相同的标识符允许有多个,对象或函数的定义必须是唯一的。也就是说,为了让 C 程序能够运行,任何使用到的对象或函数都必须有一个定义(否则执行将不知道在哪里查找它们),并且定义不能超过一个(否则执行可能会变得不一致)。

Takeaway 2.9

每个对象或函数必须有一个确切的定义。

2.4. 语句

main 函数的第二部分主要由 语句 组成。语句是告诉编译器如何处理之前已声明的标识符的指令。我们有

getting-started.c
**16**   for (**size_t** i = 0; i < 5; ++i) {
**17**      **printf**("element %zu is %g, \tits square is %g\n",
**18**             i,
**19**             A[i],
**20**             A[i]*A[i]);
**21**   }
**22**
**23**   return **EXIT_SUCCESS**;

我们已经讨论了与 printf 调用对应的行。还有其他类型的语句:forreturn 语句,以及一个增量操作,由 运算符**^C ++ 表示。在下一节中,我们将深入探讨三类语句的细节:迭代(多次执行某项操作)、函数调用(将执行委托到其他地方)和 函数返回(从函数被调用的位置恢复执行)。

2.4.1. 迭代

for 语句告诉编译器程序应该执行 printf 行多次。这是 C 提供的最简单的 域迭代**^C 形式。它有四个不同的部分。

要重复执行的代码称为 循环体**^C:它是跟随 for ( ... ){ ... } 块。其他三个部分是 ( ... ) 部分内的内容,由分号分隔:

  1. 我们已经讨论过的 循环变量**^C i 的声明、定义和初始化。这个初始化是在整个 for 语句的其余部分执行之前只执行一次。

  2. 循环条件**^C,i < 5 指定了 for 迭代应该持续多长时间。这告诉编译器只要 i 严格小于 5 就继续迭代。循环条件在每次执行循环体之前都会进行检查。

  3. 另一个语句,++i,在每次迭代后执行。在这种情况下,它每次将 i 的值增加 1

如果我们将所有这些放在一起,我们要求程序在块中执行五次部分,分别将 i 的值设置为 01234,在每次迭代中。我们可以将每个迭代与 i 的特定值相对应的事实使得这是一个对 域**^C 0、...、4 的迭代。在 C 中做这件事的方式不止一种,但 for 是完成这项任务最简单、最干净、最好的工具。

Takeaway 2.10

域迭代应该用 for 语句编码。

除了我们刚才看到的方式之外,for 语句还可以用几种其他方式来编写。通常,人们会在 for 之前或甚至重用相同的变量为几个循环定义循环变量的定义。不要这样做:为了帮助偶尔的读者和编译器理解你的代码,了解这个变量对于给定的那个 for 循环有迭代计数器的特殊意义是很重要的。

Takeaway 2.11

循环变量应该在 for* 的初始部分定义。*

2.4.2. 函数调用

函数调用 是特殊的语句,它暂停当前函数(开始时通常是 main)的执行,然后将控制权交给命名的函数。在我们的例子中

getting-started.c
**17**      **printf**("element %zu is %g, \tits square is %g\n",
**18**             i,
**19**             A[i],
**20**             A[i]*A[i]);

被调用的函数是 printf。函数调用通常不仅提供函数的名称,还提供 参数。在这里,这些是长链字符,i、A[i] 和 A[i]*A[i]。这些参数的 被传递给函数。在这种情况下,这些值是 printf 打印的信息。这里的重点是“值”:尽管 i 是一个参数,但 printf 永远不能改变 i 本身。这种机制称为 按值调用。其他编程语言也有 按引用调用,这是一种调用函数可以改变变量值的机制。C 不实现按引用传递,但它有另一种机制将变量的控制权传递给另一个函数:通过取地址和传递指针。我们将在稍后看到这些机制。

2.4.3. 函数返回

main 中的最后一个语句是一个 return。它告诉 main 函数在完成后 返回 到它被调用的语句。在这里,由于 main 的声明中有 int,一个 return 必须 向调用语句发送一个 int 类型的值。在这种情况下,那个值是 EXIT_SUCCESS

尽管我们看不到它的定义,但 printf 函数必须包含一个类似的 return 语句。在我们在第 17 行调用函数的点,main 中的语句执行暂时暂停。执行在 printf 函数中继续,直到遇到一个 return。从 printf 返回后,main 中的语句从停止的地方继续执行。

图 2.1 展示了我们小程序的执行示意图:其控制流程。首先,由我们的平台提供的进程启动例程(在左侧)调用用户提供的函数main(中间)。然后,main函数反过来调用printf函数,这是一个属于C 库的函数(在右侧)。一旦遇到return,控制权返回到main;当我们到达main中的return时,它将控制权传递回启动例程。从程序员的角度来看,这种控制权的转移是程序执行的结束。

图 2.1. 小程序的执行

图 2.1 的替代文本

摘要

  • C 语言区分了程序的词汇结构(标点符号、标识符和数字)、语法结构(语法)和语义(意义)。

  • 所有标识符(名称)都必须声明,以便我们知道它们所代表的概念的特性。

  • 所有对象(我们处理的事物)和函数(我们用来处理事物的方法)都必须定义;也就是说,我们必须指定它们是如何以及在哪里产生的。

  • 语句指示了事情将如何进行:迭代(for)重复执行某些任务的变体,函数调用(printf(...)**)将任务委托给一个函数,函数返回(**return** something;`)返回到我们来的地方。

第 1 级:熟悉

本级别的吉祥物,常见的乌鸦,是一种非常社交的鸱科动物,以其解决问题的能力而闻名。乌鸦会组成团队,甚至成年后也会进行游戏。

本级别将使你熟悉 C 编程语言:也就是说,它将为你提供足够的知识来编写和使用好的 C 程序。“好”在这里指的是对语言的现代理解,避免了 C 早期方言的大部分陷阱,并为你提供一些之前不存在且在大多数现代计算机架构上可移植的结构,从你的手机到大型机。完成这些章节后,你应该能够编写满足日常需求的简短代码:不是非常复杂,但有用且可移植。

系好安全带

在许多方面,C 是一种宽容的语言;程序员可以选择自己射中脚或其他身体部位,而 C 将不会努力阻止他们。因此,暂时,我们将引入一些限制。我们将尝试在本级别中不发放枪支,并将枪柜的钥匙放在你够不到的地方,用大而显眼的感叹号标记其位置。

C 中最危险的构造是所谓的类型转换**^C,所以我们将在本级别跳过它们。然而,还有许多其他陷阱不容易避免。我们将以一种可能对你来说不熟悉的方式处理其中的一些,特别是如果你在上个千年学习 C 基础知识,或者如果你在一个多年未升级到当前 ISO C 的平台上学 C。

  • 经验丰富的 C 程序员: 如果你已经有一些 C 编程经验,以下内容可能需要一些适应,甚至可能引起过敏反应。如果你在阅读这里的一些代码时突然出现皮疹,请深呼吸并尽量放松,但请不要跳过这些页面。

  • 不熟悉的 C 程序员: 如果你不是经验丰富的 C 程序员,以下讨论的大部分内容可能对你来说有点难以理解:例如,我们可能会使用你尚未听说过的术语。如果是这样,这对你来说是一个旁白,你可以跳到第三章的开始部分,稍后再回来,当你觉得稍微舒服一些的时候。但请确保在本级别结束之前这样做。

在这个级别上,“适应”我们的方法可能涉及我们呈现材料的重点和顺序:

  • 我们将主要关注整数类型的无符号**^C版本。

  • 我们将分步骤介绍指针:首先,作为函数的参数(第 6.1.4 节),然后是它们的状态(有效或无效,第 6.2 节),然后在下一个级别(第十一章),使用它们的全部潜力。

  • 我们将尽可能关注数组的用法。

你可能会对我们的某些风格考虑感到惊讶,我们将在以下要点中讨论。在下一级,我们将用整整一章(第九章)来讨论这些问题,所以请耐心等待,暂时接受它们。

  1. 我们将类型修饰符和限定符绑定到左侧。 我们希望从视觉上区分标识符和它们的类型。因此,我们通常会这样写

    char* name;
    

    其中char*是类型,name 是标识符。我们还应用左结合规则到限定符上,并写成

    char const* const path_name;
    

    这里,第一个const限定符限定其左侧的char*使其成为指针,第二个const再次限定其左侧的内容。

  2. 我们不使用连续声明。 它们会模糊类型声明符的绑定。例如:

    unsigned const*const a, b;
    

    这里,b 的类型是unsigned const:也就是说,第一个const应用于类型,第二个const只应用于 a 的声明。这样的规则非常令人困惑,你有更重要的事情要学习。

  3. 我们使用数组表示法来表示指针参数。 我们在指针不能为空的情况下这样做。例如:

    /* These emphasize that the arguments cannot be null. */
    **size_t** **strlen**(char const string[static 1]);
    int **main**(int argc, char* argv[argc+1]);
    /* Compatible declarations for the same functions. */
    **size_t** **strlen**(const char *string);
    int **main**(int argc, char **argv);
    

    第一个强调了strlen必须接收一个有效的(非空)指针,并将至少访问字符串中的一个元素。第二个总结了main接收一个指向char的指针数组的现实,即程序名称、argc-1 个程序参数和一个终止数组的空指针。请注意,前面的代码是有效的。第二组声明只为编译器已知的功能添加了额外的等效声明。

  4. 我们使用函数表示法来表示函数指针参数。 同样地,我们知道函数指针不能为空时,我们会这样做:

    /* This emphasizes that the ![](https://github.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/mdn-c/img/box_v.jpg)![](https://github.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/mdn-c/img/box_v.jpg)handler'' argument cannot be null. */
    int **atexit**(void handler(void));
    /* Compatible declaration for the same function.              */
    int **atexit**(void (*handler)(void));
    

    在这里,atexit的第一个声明强调了它在语义上接收一个名为 handler 的函数作为参数,并且不允许空函数指针。技术上,函数参数 handler 被“重写”为一个函数指针,就像数组参数被重写为对象指针一样,但对于功能描述来说这并不重要。请注意,前面的代码是有效的,第二个声明只是为atexit添加了一个等效的声明。

  5. 我们尽可能地将变量定义在其首次使用的地方。 缺乏变量初始化,尤其是对于指针,是新手 C 程序员的主要陷阱之一。这就是为什么我们应该尽可能地将变量的声明与其首次赋值结合起来:C 为我们提供这种目的的工具是定义:一个声明与初始化的结合。这给一个值命名,并在它首次使用的地方引入这个名称。这对于for循环尤其方便。一个循环的迭代变量在语义上与另一个循环的迭代变量是不同的对象,因此我们在for循环内部声明变量,以确保它保持在循环的作用域内。

  6. 我们使用前缀记法来表示代码块。 为了能够轻松地阅读代码块,重要的是要捕捉到关于它的两个信息:它的目的和它的范围。因此:

    • 所有{都与其引入它们的语句或声明在同一行上。

    • 代码内部缩进一级。

    • 结束的}与引入代码块的语句在同一级别上开始新的一行。

    • }之后继续的代码块语句在同一行上。示例:

    int **main**(int argc, char* argv[argc+1]) {
      **puts**("Hello world!");
      if (argc > 1) {
        while (**true**) {
          **puts**("some programs never stop");
        }
      } else {
        do {
          **puts**("but this one does");
        } while (**false**);
      }
      return **EXIT_SUCCESS**;
    }
    

第三章。一切都是关于控制

本章涵盖

  • 使用if进行条件执行

  • 遍历域

  • 进行多选

在我们的入门示例中,列表 1.1,我们看到了两种不同的结构,允许我们控制程序执行的流程:函数和for迭代。函数是一种无条件转移控制的方式。调用无条件地将控制转移到函数,而return语句无条件地将控制转移回调用者。我们将在第七章中再次回到函数。

for语句与其它不同,因为它有一个控制条件(例如,示例中的i < 5),它决定了依赖的代码块或语句({ printf(...) })何时以及是否执行。C 有五种条件控制语句iffordowhileswitch。我们将在本章中探讨这些语句:if根据布尔表达式引入条件执行fordowhile迭代的不同形式;而switch是基于整数值的多选

C 还有一些其他条件语句,我们将在后面讨论:三元运算符^C,表示为形式为 cond ? A : B 的表达式(第 4.4 节),编译时预处理器条件语句#if/#ifdef/#ifndef/#elif/#else/#endif(第 8.1.5 节),以及用关键字_Generic表示的类型泛型表达式(第 16.6 节)。

3.1. 条件执行

我们将要查看的第一个结构是由关键字 if 指定的。它看起来是这样的:

   if (i > 25) {
     j = i - 25;
   }

在这里,我们将 i 与值 25 进行比较。如果它大于 25,则 j 被设置为 i - 25 的值。在这个例子中,i > 25 被称为 控制表达式^C,而 { ... } 部分被称为 依赖块^C

表面上,这种 if 语句的形式类似于我们之前遇到的 for 语句。但它的工作方式不同:括号内只有一个部分,它决定了依赖语句或块是否只运行一次或根本不运行。

if 构造的更一般形式如下:

   if (i > 25) {
     j = i - 25;
   } else {
     j = i;
   }

它还有一个依赖语句或块,如果控制条件不满足,则执行。在语法上,这是通过引入另一个关键字 else 来实现的,它将两个语句或块分开。

if (...) ... else ... 是一个 选择语句^C。它根据 ( ... ) 的内容选择两个可能 代码路径^C 中的一个。其一般形式是

   if (condition) statement0-**or**-block0
   else statement1-**or**-block1

条件(控制表达式)的可能性有很多。它们可以从简单的比较开始,如本例所示,到非常复杂的嵌套表达式。我们将在 第 4.3.2 节 中介绍所有可以使用的原语。

if 语句中最简单的此类条件指定可以在以下示例中看到,这是 列表 1.1 中的 for 循环的一个变体:

   for (**size_t** i = 0; i < 5; ++i) {
     if (i) {
       **printf**("element %zu is %g, \tits square is %g\n",
              i,
              A[i],
              A[i]*A[i]);
     }
   }

在这里,决定是否执行 printf 的条件仅仅是 i:一个数值本身可以被视为一个条件。只有当 i 的值为非 0 时,文本才会被打印。^([[例 1]])

^([例 1])

if (i) 条件添加到程序中,并将输出与之前进行比较。

评估数值条件有两个简单的规则:

摘要 3.1

0 的值代表逻辑假。

摘要 3.2

任何非 0 的值都代表逻辑真。

操作符 ==!= 允许我们测试相等性和不等性。a == b 当且仅当 a 的值等于 b 的值时为真,否则为假;a != b 当 a 等于 b 时为假,否则为真。了解数值如何作为条件进行评估后,我们可以避免冗余。例如,我们可以重写

   if (i != 0) {
     ...
   }

如下:

   if (i) {
      ...
   }

哪个版本更易读是一个关于 编码风格^C* 的问题,可能会引起无果的争论。虽然第一个版本可能对偶尔阅读 C 代码的读者来说更容易阅读,但后者在假设对 C 的类型系统有一定了解的项目中通常更受欢迎。

<stdbool.h>

stdbool.h 中指定的类型 bool,是我们想要存储真值时应使用的类型。它的值是 falsetrue。技术上,false 只是 0 的另一个名称,true1。使用 falsetrue(而不是数字)来强调一个值是要被解释为条件的。我们将在 第 5.7.4 节 中了解更多关于 bool 类型的内容。

重复的比较很快就会变得难以阅读,并使你的代码杂乱无章。如果你有一个依赖于真值的条件,直接使用那个真值作为条件。同样,我们可以通过重写类似的内容来避免重复

   bool b = ...;
   ...
   if ((b != **false**) == **true**) {
     ...
   }

as

   bool b = ...;
   ...
   if (b) {
     ...
   }

通常:

要点 3.3

不要与 0 比较false true

直接使用真值可以使你的代码更清晰,并说明了 C 语言的一个基本概念:

要点 3.4

所有标量都有真值。

这里,标量^C* 类型包括所有我们已遇到的数值类型,如 size_t、bool 和 int,以及 指针^C* 类型;参见 表 3.1 了解本书中常用类型。我们将在 第 6.2 节 回到这些类型。

表 3.1. 本书使用的标量类型
级别 名称 其他 类别 哪里 printf
0 size_t 无符号 <stddef.h> "%zu" "%zx"
0 double 浮点 内置 "%e" "%f" "%g" "%a"
0 signed int 有符号 内置 "%d"
0 unsigned 无符号 内置 "%u" "%x"
0 bool _Bool 无符号 <stdbool.h> "%d" 作为 0 或 1
1 ptrdiff_t 有符号 <stddef.h> "%td"
1 char const* 字符串 内置 "%s"
1 char 字符 内置 "%c"
1 void* 指针 内置 "%p"
2 unsigned char 无符号 内置 "%hhu" "%02hhx"

3.2. 迭代

之前,我们遇到了用于遍历域的 for 语句;在我们的入门示例中,它声明了一个变量 i,其值设置为 01234。这个语句的一般形式是

   for (clause1; condition2; expression3) statement-**or**-block

这个语句实际上非常通用。通常,clause1 是一个赋值表达式或变量定义。它用于声明迭代域的初始值。condition2 测试迭代是否应该继续。然后,expression3 更新 clause1 中使用的迭代变量。它在每个迭代的末尾执行。一些建议:

  • 因为我们要在 for 循环的上下文中严格定义迭代变量(cf. 要点 2.11),所以 clause1 在大多数情况下应该是一个变量定义。

  • 因为 for 语句有四个不同的部分,相对复杂,且不易于视觉上捕捉,所以 statement-or-block 通常应该是一个 { ... } 块。

让我们看看更多的例子:

   for (**size_t** i = 10; i; --i) {
     something(i);
   }
   for (**size_t** i = 0, stop = upper_bound(); i < stop; ++i) {
     something_else(i);
   }
   for (**size_t** i = 9; i <= 9; --i) {
     something_else(i);
   }

第一个for循环将i10递减到1(包含1)。条件再次只是变量i的评估;不需要对值0进行冗余测试。当i变为0时,它将评估为假,循环将停止。第二个for循环声明了两个变量,istop。与之前一样,i是循环变量,stop是我们比较的条件,当i大于或等于stop时,循环终止。

第三个for循环看起来似乎会无限进行下去,但实际上它是从9递减到0。实际上,在下一章中,我们将看到 C 语言中的“大小”(具有size_t类型的数字)永远不会是负数.^([[Exs 2]])

^([Exs 2])

尝试想象当i的值为0并且通过--运算符进行递减时会发生什么。

注意,所有三个for语句都声明了名为i的变量。只要它们的范围不重叠,这三个具有相同名称的变量就可以愉快地并排存在。

C 语言中还有两个迭代语句,whiledo

   while (condition) statement-**or**-block
   do statement-**or**-block while(condition);

以下示例展示了第一个的典型用法。它实现了所谓的赫伦近似法来计算数字x的乘法逆元

   #include <tgmath.h>

   double const eps = 1E-9;            // Desired precision
   ...
   double const a = 34.0;
   double x = 0.5;
   while (**fabs**(1.0 - a*x) >= eps) {    // Iterates until close
     x *= (2.0 - a*x);                 // Heron approximation
   }

它会一直迭代,直到给定的条件评估为真。do循环与它非常相似,除了它在依赖块之后检查条件:

   do {                               // Iterates
     x *= (2.0 - a*x);                // Heron approximation
   } while (**fabs**(1.0 - a*x) >= eps);  // Iterates until close

这意味着如果条件评估为假,while循环将根本不会运行其依赖块,而do循环将在终止前运行一次。

for语句一样,使用dowhile时建议使用{ ... }块变体。这两个之间也存在微妙的语法差异:do总是需要在while(条件)之后有一个分号;来终止语句。稍后,我们将看到这是一个在多重嵌套语句的上下文中非常有用的语法特性;参见第 10.2.1 节。

所有三个迭代语句通过breakcontinue语句变得更加灵活。一个break语句会停止循环,而无需重新评估终止条件或执行break语句之后的依赖块:

while (**true**) {
  double prod = a*x;
  if (**fabs**(1.0 - prod) < eps) {      // Stops if close enough
    break;
  }
  x *= (2.0 - prod);                 // Heron approximation
}

这样,我们可以将乘积 a*x 的计算、停止条件的评估以及 x 的更新分开。因此,while的条件变得很简单。同样的事情也可以使用for循环来完成,C 程序员中有一种传统是将其写成以下形式:

for (;;) {
  double prod = a*x;
  if (**fabs**(1.0 - prod) < eps) {      // Stops if close enough
    break;
  }
  x *= (2.0 - prod);                 // Heron approximation
}

for(;;)在这里等同于while(true)。事实上,for循环的控制表达式(;;之间的中间部分)可以省略,并被解释为“总是true”,这只是 C 语言规则中的历史遗迹,没有其他特殊目的。

continue 语句使用得较少。像 break 一样,它跳过执行依赖块的其余部分,因此当前迭代中依赖块后的所有语句都不会执行。然而,然后它重新评估条件,如果条件为真,则从依赖块的开始继续:

for (**size_t** i =0; i < max_iterations; ++i) {
  if (x > 1.0) {   // Checks if we are on the correct side of 1
    x = 1.0/x;
    continue;
  }
  double prod = a*x;
  if (**fabs**(1.0 - prod) < eps) {     // Stops if close enough
    break;
  }
  x *= (2.0 - prod);                // Heron approximation
}

<tgmath.h>

在这些例子中,我们使用一个标准的宏 fabs,它包含在 tgmath.h 头文件中^([1]). 它计算 double 类型的绝对值。列表 3.1 是一个完整的程序,实现了相同的算法,其中 fabs 已被替换为对某些固定数字的几个显式比较:例如,eps1m24 定义为 1 – 2^(–24),或 eps1p24 为 1 + 2^(–24)。我们将在稍后 (章节 5.3) 看到这些定义中使用的常数 0x1P-24 和类似的常数是如何工作的。

¹

“tgmath”代表 类型通用的数学函数

在第一阶段,当前研究数字 a 与当前估计值 x 的乘积与 1.5 和 0.5 进行比较,然后 x 乘以 0.5 或 2,直到乘积接近 1。然后,在代码中显示的 Heron 近似在第二次迭代中使用,以接近并计算乘法逆的高精度值。

程序的整体任务是计算命令行上提供的所有数字的逆。一个程序执行的例子如下所示:

终端
**0**    > ./heron 0.07 5 6E+23
**1**    heron: a=7.00000e-02, x=1.42857e+01, a*x=0.999999999996
**2**    heron: a=5.00000e+00, x=2.00000e-01, a*x=0.999999999767
**3**    heron: a=6.00000e+23, x=1.66667e-24, a*x=0.999999997028

为了处理命令行上的数字,程序使用另一个库函数 stdlib.h 中的 strtod。^([[Exs 3]])^([[Exs 4]])^([[Exs 5]])

^([Exs 3])

通过添加对 x 的中间值的 printf 调用来分析 列表 3.1。

^([Exs 4])

描述 列表 3.1 中参数 argc 和 argv 的使用。

^([Exs 5])

打印出 eps1m01 的值,并观察当它们略有变化时的输出。

<stdlib.h>

顺序排序算法

你能做

  1. 归并排序(带递归)

  2. 快速排序(带递归)

在具有排序键如 double 或你喜欢的字符串的数组上?

如果不知道你的程序是否正确,那么你将一无所获。因此,你能提供一个简单的测试例程来检查结果数组是否真的已排序吗?

这个测试例程应该只扫描一次数组,并且应该比你的排序算法快得多。

列表 3.1. 计算数字的乘法逆
 **1**   #include <stdlib.h>
 **2**   #include <stdio.h>
 **3**
 **4**   /* lower and upper iteration limits centered around 1.0 */
 **5**   static double const eps1m01 = 1.0 - 0x1P-01;
 **6**   static double const eps1p01 = 1.0 + 0x1P-01;
 **7**   static double const eps1m24 = 1.0 - 0x1P-24;
 **8**   static double const eps1p24 = 1.0 + 0x1P-24;
 **9**
**10**   int **main**(int argc, char* argv[argc+1]) {
**11**     for (int i = 1; i < argc; ++i) {        // process args
**12**       double const a = **strtod**(argv[i], 0);  // arg -> double
**13**       double x = 1.0;
**14**       for (;;) {                    // by powers of 2
**15**         double prod = a*x;
**16**         if (prod < eps1m01) {
**17**           x *= 2.0;
**18**         } else if (eps1p01 < prod) {
**19**           x *= 0.5;
**20**         } else {
**21**           break;
**22**         }
**23**       }
**24**       for (;;) {                    // Heron approximation
**25**         double prod = a*x;
**26**         if ((prod < eps1m24) || (eps1p24 < prod)) {
**27**           x *= (2.0 - prod);
**28**         } else {
**29**           break;
**30**         }
**31**       }
**32**       **printf**("heron: a=%.5e,\tx=%.5e,\ta*x=%.12f\n",
**33**              a, x, a*x);
**34**     }
**35**     return **EXIT_SUCCESS**;
**36**   }

3.3. 多重选择

C 语言提供的最后一个控制语句是 switch 语句,它也是一种 选择^C* 语句。它主要用于当一系列 if-else 构造过于繁琐时:

   if (arg == 'm') {
     **puts**("this is a magpie");
   } else if (arg == 'r') {
     **puts**("this is a raven");
   } else if (arg == 'j') {
     **puts**("this is a jay");
   } else if (arg == 'c') {
     **puts**("this is a chough");
   } else {
     **puts**("this is an unknown corvid");
   }

在这种情况下,我们有一个比 false-true 决策更复杂的选项,并且可以有多个结果。我们可以将其简化如下:

   switch (arg) {
     case 'm': **puts**("this is a magpie");
               break;
     case 'r': **puts**("this is a raven");
                  break;
     case 'j': **puts**("this is a jay");
               break;
     case 'c': **puts**("this is a chough");
               break;
     default: **puts**("this is an unknown corvid");
   }

我们根据 arg 变量的值选择一个 puts 调用。像 printf 一样,函数 putsstdio.h 提供。它输出一个包含作为参数传递的字符串的行。我们为字符 'm'、'r'、'j'、'c' 和一个 后备**^C 情况提供特定的案例,标记为 default。如果 arg 不匹配任何 case 值,则触发默认情况.^([[Exs 6]])

^([Exs 6])

在程序中测试示例 switch 语句。看看如果你省略了一些 break 语句会发生什么。

<stdio.h>

语法上,switch 如此简单:

   switch (expression) statement-**or**-block

它的语义相当简单:casedefault 标签作为 跳转目标**^C。根据表达式的值,控制继续到相应标记的语句。如果我们遇到一个 break 语句,它所在的整个 switch 语句终止,并且控制转移到 switch 之后的下一个语句。

根据这个规范,switch 语句可以比迭代 if-else 结构更广泛地使用:

   switch (count) {
     default:**puts**("++++ ..... +++");
     case 4: **puts**("++++");
     case 3: **puts**("+++");
     case 2: **puts**("++");
     case 1: **puts**("+");
     case 0:;
   }

一旦我们跳入代码块,执行将继续,直到遇到一个 break 或块的末尾。在这种情况下,因为没有 break 语句,我们最终会运行所有后续的 puts 语句。例如,当 count 的值为 3 时,输出是一个由三条线组成的三角形:

终端
**0**    +++
**1**    ++
**2**    +

switch 语句的结构可能比 if-else 更灵活,但它也有另一种限制:

Takeaway 3.5

case 值必须是整数常量表达式

在 第 5.6.2 节 中,我们将详细看到这些表达式是什么。现在,只需知道这些必须是我们在源代码中直接提供的固定值就足够了,例如前一个例子中的 43210。特别是,变量如 count 只允许在 switch 部分使用,不允许在单个 case 中使用。

随着 switch 语句的更大灵活性,也伴随着代价:它更容易出错。特别是,我们可能会不小心跳过变量定义:

Takeaway 3.6

case 标签不能超出变量定义的范围

数值导数

我们将大量处理的是数值算法的概念。为了亲自动手,看看你是否能实现一个函数 double F(double x) 的数值导数 double f(double x)

使用一个示例函数 F 来实现这个练习,对于 F 的一个很好的选择是,你知道其导数的函数,例如 sincossqrt。这允许你检查你的结果是否正确。

π

计算π的 N 位小数。

总结

  • 数值可以直接用作if语句的条件;0 表示“假”,而所有其他值都是“真”。

  • 有三种不同的迭代语句:fordowhilefor是领域迭代的首选工具。

  • switch语句执行多重选择。如果没有通过break终止,一个case会运行到下一个。

第四章。表达计算

本章涵盖

  • 执行算术运算

  • 修改对象

  • 处理布尔值

  • 使用三元运算符进行条件编译

  • 设置评估顺序

我们已经使用了一些简单的表达式^C 的例子。这些是计算基于其他值的值的代码片段。最简单的这种表达式是算术表达式,它们与我们学校学过的类似。但还有其他一些,特别是比较运算符,如==!=,我们之前已经看到过。

在本章中,我们将对这些值和对象进行计算,它们大多数将是size_t类型,我们已经遇到过。这样的值对应于“大小”,因此它们是不能为负数的数字。它们可能的值范围从0开始。我们想要表示的是所有非负整数,通常在数学中用 N、N[0]或“自然数”表示。不幸的是,计算机是有限的,所以我们不能直接表示所有自然数,但我们可以进行合理的近似。有一个很大的上限SIZE_MAX,它是我们在size_t中可以表示的上限。

取得成果 4.1

类型 size_t 表示的范围是 [0, SIZE_MAX]*。

SIZE_MAX的值相当大。根据平台的不同,它可能是以下之一

  • 2¹⁶ – 1 = 65535

  • 2³² – 1 = 4294967295

  • 2⁶⁴ – 1 = 18446744073709551615

第一个值是一个最小要求;如今,这样的小值只会出现在一些嵌入式平台上。其他两个值在当今更为常见:第二个仍然在一些 PC 和笔记本电脑上使用,而大多数较新的平台都使用第三个。这样的值选择对于不太复杂的计算来说已经足够大了。标准头文件stdint.h提供了SIZE_MAX,这样你就不必自己找出那个值,也不必相应地专门化你的程序。

<stdint.h>

我们提到的“不能为负的数字”的概念,对应于 C 语言中的无符号整数类型**C*。像`+`和`!=`这样的符号和组合被称为*运算符**C,而它们所应用的对象被称为操作数**^C;因此,在类似+ a b 的例子中,+是运算符,而 a 和 b 是其操作数。

想要了解所有 C 运算符的概述,请参阅以下表格:表 4.1 列出了操作值的运算符,表 4.2 列出了操作对象的运算符,表 4.3 列出了操作类型的运算符。在使用这些运算符时,您可能需要从一个表格跳转到另一个表格。例如,如果您想要计算一个表达式,如a + 5,其中a是类型为unsigned的某个变量,您首先需要查看表 4.2 的第三行,以了解a是如何被评估的。然后,您可以使用表 4.1 的第三行来推断出a5的值在算术操作中被组合:a +。如果您对这些表格中的所有内容都不理解,请不要沮丧。许多提到的概念尚未介绍;它们被列在这里,作为整本书的参考。

表 4.1. 值运算符 “形式”列给出了操作的语法形式,其中@代表运算符,a 和可能 b 表示作为操作数的值。对于算术和位操作,结果类型是 a 和 b 类型协调的类型。对于一些运算符,别名列给出了运算符的另一种形式,或列出具有特殊意义的运算符组合。大多数运算符和术语将在以后讨论。
类型限制
运算符 别名 形式 a b 结果
--- --- --- --- --- --- ---
a 窄化 宽化 提升类型
+ - a@b 指针 整数 指针 算术
+ - * / a@b 算术 算术 算术 算术
+ - * / @a 算术 算术 算术
% a@b 整数 整数 整数 算术
~ compl @a 整数 整数 位操作
& bitand a@b 整数 整数 整数 位操作
| bitor
^ xor
<< >> a@b 整数 正数 整数 位操作
== < > <= >= a@b 标量 标量 0,1 比较
!= not_eq a@b 标量 标量 0,1 比较
!!a a 标量 0,1 逻辑
!a not @a 标量 0,1 逻辑
&& || and or a@b 标量 标量 0,1 逻辑
. a@m 结构 成员
* @a 指针 对象 引用
[] a[b] 指针 整数 对象 成员
-> a@m 结构指针 对象 成员
() a(b ...) 函数指针 调用
sizeof @ a size_t 大小,ICE
_Alignof alignof @(a) size_t 对齐,ICE
表 4.2. 对象运算符 *形式列给出了操作的语法形式,其中 @ 代表运算符,o 表示对象,a 表示作为操作数的适当附加 (如果有)。类型列中的附加 要求对象 o 可寻址。
运算符 别名 形式 类型 结果
o 数组* 指针 数组降级
o 函数 指针 函数降级
o 其他 评估
= o@a 非数组 赋值
+= -= *= /= o@a 算术 算术
+= -= o@a 指针 算术
%= o@a 整数 算术
++ -- @o o@ 算术或指针 算术
&= and_eq o@a 整数
|= or_eq
^= xor_eq
<<= >>= o@a 整数
. o@m struct 对象 成员
[] o[a] 数组 对象 成员
& @o 任何* 指针 地址
sizeof @ o 数据对象,非 VLA size_t 大小,ICE
sizeof @ o VLA size_t 大小
_Alignof alignof @(o) 非函数 size_t 对齐,ICE
表 4.3. 类型运算符 这些运算符返回一个整数常量(ICE)类型为 size_t。它们具有类似函数的语法,操作数在括号中。

| 运算符 | 别名 | 形式 | T 的类型 | 结果 |   |
| --- | --- | --- | --- | --- |
| sizeof |   | sizeof(T) | 任何 | 大小 |
| _Alignof | alignof | _Alignof(T) | 任何 | 对齐 |
|   | offsetof | offsetof(T,m) | struct | 成员偏移 |

4.1. 算术

算术运算符是操作值的运算符 表 4.1 中的第一组。

4.1.1. +, -, 和 *

算术运算符 +-* 主要按我们预期的样子工作,分别计算两个值的和、差和积:

   **size_t** a = 45;
   **size_t** b = 7;
   **size_t** c = (a - b)*2;
   **size_t** d = a - b*2;

这里,c 必须等于 76,d 必须等于 31。正如您从这个小例子中可以看到的,子表达式可以通过括号分组,以强制执行运算符的优先绑定。

此外,运算符 +- 有单目变体。-b 给出 b 的相反数:一个值 a,使得 b + a 等于 0。+a 简单地提供 a 的值。以下也给出 76

   **size_t** c = (+a + -b)*2;

即使我们在计算中使用无符号类型,通过运算符 - 的取反和差分也是 明确定义的**^C。也就是说,无论我们向这种减法中输入什么值,我们的计算总是会有一个有效的结果。事实上,size_t 的一个神奇属性是 +-* 算术总是在可能的地方工作。只要最终的数学结果在范围 [0, SIZE_MAX] 内,那么该结果将是表达式的值。

摘要 4.2

无符号算术始终有明确定义。

4.3 摘要

+**、 -**、和 * size_t 上的运算如果可以表示为 size_t *,则提供数学上正确的结果。

当结果不在这个范围内,因此不能表示C*为**`size_t`**值时,我们称之为算术*溢出*C*。溢出可能发生在,例如,如果我们乘以两个值,它们的数学乘积大于SIZE_MAX。我们将在下一章中看看 C 如何处理溢出。

4.1.2. 除法和余数

运算符/%稍微复杂一些,因为它们对应于整数除法和余数操作。你可能不像对其他三个算术运算符那样熟悉它们。a/b的结果是 b 可以放入 a 中的次数,而a%b是在从 a 中移除最大数量的 b 之后剩余的值。/%是一对运算符:如果我们有 z = a / b,余数a%b可以计算为a- z*b:

4.4 摘要

对于无符号值, a == (a/b)``*b + (a%b).

对于%运算符的一个熟悉例子是时钟上的小时。比如说我们有一个 12 小时制的时钟:8:00 后的 6 小时是 2:00。大多数人能够计算 12 小时或 24 小时制时钟上的时间差。这种计算对应于% 12:在我们的例子中,(8 + 6) % 12 == 2^([[Exs 1]])。%的另一个类似用途是计算小时中的分钟,形式为a % 60

^([Exs 1])

使用 24 小时制时钟进行一些计算,例如 10:00 后的 3 小时和 20:00 后的 8 小时。

对于这两个操作,只有一个值是不允许的:0。除以零是被禁止的。

4.5 摘要

无符号 / % 只有在第二个操作数不是 0*时才有定义。

%运算符也可以更好地解释无符号类型上的加法和乘法算术。如前所述,当无符号类型被赋予其范围之外的值时,它被认为是溢出^C*。在这种情况下,结果会减少,就像使用了%运算符一样。结果值“环绕”类型的范围。对于size_t,范围是0SIZE_MAX,因此

4.6 摘要

size_t 的算术运算隐式执行 %(SIZE_MAX+1)*.*

4.7 摘要

在溢出的情况下,无符号算术会环绕。

这意味着对于size_t值,SIZE_MAX + 1等于0,而0 - 1等于SIZE_MAX

这种“环绕”是使-运算符对无符号类型起作用的魔法。例如,将值-1解释为size_t等于SIZE_MAX;因此,将-1加到一个值上就相当于一个+ SIZE_MAX,这会环绕到

  • a + SIZE_MAX - (SIZE_MAX+1) = a - 1

/% 操作符有一个很好的特性,即它们的结果总是小于或等于它们的操作数:

Takeaway 4.8

无符号 / % *的结果总是小于操作数。

因此

Takeaway 4.9

无符号 / % 不会溢出

4.2. 操作符修改对象

我们已经看到的一个重要操作是赋值:a = 42. 如你所见,这个操作符不是对称的:它在右边有一个值,在左边有一个对象。在一种奇怪的语言滥用中,C 语言术语通常将右边称为 rvalue**^C(右值)和左边称为 lvalue**^C(左值)。我们将尽可能避免这种词汇:提到值和对象就足够了。

C 语言还有其他赋值操作符。对于任何二元操作符 @,我们看到的五个操作符都具有以下语法

   an_object @= some_expression;

它们只是将算术操作符 @ 和赋值组合的方便缩写;见表 4.2。一个大致等价的形式是

   an_object = (an_object @ (some_expression));

换句话说,有操作符 +=-=***=/=%=。例如,在一个 for 循环中,可以使用 += 操作符:

   for (**size_t** i = 0; i < 25; i += 7) {
     ...
   }

这些操作符的语法有点挑剔。不允许在字符之间有空格:例如,i + = 7 而不是 i += 7 是一个语法错误。

Takeaway 4.10

操作符的所有字符必须直接相连

我们已经看到了其他两个修改对象的操作符:递增操作符**^C ++递减操作符**^C --

  • ++i 等价于 i += 1

  • --i 等价于 i -= 1

所有这些赋值操作符都是真正的操作符。它们返回一个值(但不是对象!):修改后的对象值。如果你足够疯狂,可以写一些像

   a = b = c += ++d;
   a = (b = (c += (++d))); // Same

但一次性修改多个对象的组合通常是不受欢迎的。除非你想要使你的代码晦涩难懂,否则不要这样做。涉及表达式的对象的变化被称为 副作用**^C

Takeaway 4.11

值表达式的副作用是邪恶的

Takeaway 4.12

在一个语句中不要修改超过一个对象

对于递增和递减操作符,还有两种其他形式:后缀递增**^C后缀递减**^C。它们与我们看到的操作符不同,在于它们提供给周围表达式的结果。这些操作符的前缀版本(++a 和 --a)先执行操作然后返回结果,就像相应的赋值操作符(a+=1 和 a-=1)一样;后缀操作返回操作之前的值,然后执行对象的修改。对于任何它们,变量的效果都是相同的:递增或递减的值。

所有这些都表明,带有副作用的表达式求值可能难以跟踪。不要这样做。

4.3. 布尔上下文

几个运算符根据某些条件是否得到验证返回 01,请参阅 表 4.1。它们可以分为两类:比较和逻辑评估。

4.3.1. 比较

在我们的例子中,我们已经看到了比较运算符 ==!=<>。而后两个运算符在其操作数之间执行严格的比较,而运算符 <=>= 分别执行“小于等于”和“大于等于”比较。所有这些运算符都可以用于控制语句,正如我们之前看到的,但它们实际上比这更强大。

收获 4.13

比较运算符返回值 false true*。

记住,falsetrue 仅仅是 01 的花哨名称。因此,它们可以用于算术或数组索引。在下面的代码中,c 将始终是 1,如果 a 和 b 相等,则 d 将是 1,否则为 0

   **size_t** c = (a < b) + (a == b) + (a > b);
   **size_t** d = (a <= b) + (a >= b) - 1;

在下一个例子中,数组元素 sign[false] 将包含大于或等于 1.0 的 largeA 中的值的数量,而 sign[true] 将包含严格小于的值:

   double largeA[N] = { 0 };
   ...
   /* Fill largeA somehow */

   **size_t** sign[2] = { 0, 0 };
   for (**size_t** i = 0; i < N; ++i) {
       sign[(largeA[i] < 1.0)] += 1;
   }

最后,还有一个标识符 not_eq,可以用作 != 的替代。这个特性很少使用。它追溯到一些字符没有在所有计算机平台上正确出现的时候。要使用它,你必须包含文件 iso646.h

<iso646.h>

4.3.2. 逻辑

逻辑运算符作用于已经表示 falsetrue 值的值。如果它们不是,则首先应用描述条件执行的规则(收获 3.1)。运算符 !not)逻辑上否定其操作数,运算符 &&and)是逻辑与,运算符 ||or)是逻辑或。这些运算符的结果总结在 表 4.4 中。

表 4.4. 逻辑运算符
a not a a and b false true a or b false true
false true false false false false false true
true false true false true true true true

与比较运算符类似,

收获 4.14

逻辑运算符返回值 false true*。

再次提醒,这些值仅仅是 01,因此可以用作索引:

double largeA[N] = { 0 };
...
/* Fill largeA somehow */

**size_t** isset[2] = { 0, 0 };
for (**size_t** i = 0; i < N; ++i) {
  isset[!!largeA[i]] += 1;
}

在这里,表达式!!largeA[i]应用了!运算符两次,因此只是确保largeA[i]被评估为真值(要点 3.4)。因此,数组元素isset[0]isset[1]将分别保存等于0.0`和不等值的数量。

运算符&&||有一个称为短路评估**^C的特殊属性。这个野蛮的术语表示第二个操作数的评估被省略,如果它对于操作的结果不是必需的:

// This never divides by 0.
if (b != 0 && ((a/b) > 1)) {
  ++x;
}

在这里,在执行过程中有条件地省略了 a/b 的评估,从而永远不会发生除以零的情况。等效的代码将是

if (b) {
  // This never divides by 0.
  if (a/b > 1) {
    ++x;
  }
}

4.4. 三元或条件运算符

三元运算符if语句类似,但它是一个返回所选分支值的表达式:

   **size_t** size_min(**size_t** a, **size_t** b) {
     return (a < b) ? a : b;
   }

与运算符&&||类似,第二个和第三个操作数只有在真正需要时才会被评估。tgmath.h中的宏sqrt计算非负值的平方根。用负值调用它将引发域错误**^C

<tgmath.h>

#include <tgmath.h>

#ifdef **__STDC_NO_COMPLEX__**
# error "we need complex arithmetic"
#endif

double **complex** sqrt_real(double x) {
  return (x < 0) ? **CMPLX**(0, **sqrt**(-x)) : **CMPLX**(**sqrt**(x), 0);
}

在这个函数中,sqrt只调用一次,并且对该调用的参数永远不会是负数。因此,sqrt_real总是表现良好;永远不会传递任何坏值给sqrt

复数运算及其使用的工具需要包含complex.h头文件,该文件由tgmath.h间接包含。它们将在第 5.7.7 节中介绍。

<complex.h>

<tgmath.h>

在前面的例子中,我们还看到了通过预处理器指令**^C实现的条件编译。#ifdef结构确保只有在宏__STDC_NO_COMPLEX__被定义的情况下才会遇到#error条件。

4.5. 评估顺序

在迄今为止的运算符中,我们已经看到&&||?:条件了它们的一些操作数的评估。这特别意味着对于这些运算符,它们的操作数有一个评估顺序:第一个操作数,因为它是对剩余操作数的条件,总是首先评估:

要点 4.15

&&**、 ||**、 ?:**和 , *首先评估它们的第一操作数。

逗号 (,) 是我们尚未介绍的唯一运算符。它按顺序评估其操作数,结果是右操作数的值。例如,(f(a), f(b))首先评估f(a),然后评估f(b);结果是f(b)`的值。请注意,逗号 字符 在 C 中扮演其他语法角色,这些角色不使用相同的评估约定。例如,分隔初始化的逗号不具有与分隔函数参数的逗号相同的属性。

逗号运算符在干净的代码中很少有用,并且是初学者的陷阱:A[i, j]不是矩阵 A 的二维索引,而是结果为A[j]

收获 4.16

不要使用 , 运算符

其他运算符没有评估限制。例如,在一个表达式如 f(a)+g(b)中,没有预先建立的顺序指定是先计算 f(a)还是 g(b)。如果函数 f 或 g 有副作用(例如,如果 f 在幕后修改了 b),则表达式的结果将取决于选择的顺序。

收获 4.17

大多数运算符不会按顺序执行其操作数

那个顺序可能取决于你的编译器,那个编译器的特定版本,编译时选项,或者仅仅是围绕表达式的代码。不要依赖于任何这样的特定顺序:它会让你吃苦头。

对于函数参数也是如此。在类似

**printf**("%g and %g\n", f(a), f(b));

我们不知道最后两个参数中哪一个先被评估。

收获 4.18

函数调用不会按顺序执行其参数表达式

唯一可靠的方法是不依赖于算术表达式的评估顺序,就是禁止副作用:

收获 4.19

在表达式内部调用的函数不应有副作用

Union-Find

Union-Find 问题处理基础集上的分区表示。我们将使用数字 0, 1, ... 来识别基础集的元素,并将分区表示为一个森林数据结构,其中每个元素都有一个“父节点”,它是同一分区内的另一个元素。这样的分区中的每个集合都由一个称为集合根的指定元素来识别。

我们想要执行两个主要操作:

  • 一个 Find 操作接收基础集的一个元素,并返回相应集合的根。

  • 一个 Union^([a]) 操作接收两个元素,并将这些元素所属的两个集合合并为一个。

    ^a

    C 还有一个称为 union 的概念,我们将在后面看到,它与当前讨论的操作完全不同。因为 union 是一个关键字,所以我们在这里使用大写字母来命名操作。

你能否在一个名为 size_t 的基础类型索引表中实现一个名为 parent 的森林数据结构?在这里,表中 SIZE_MAX 的值表示一个位置代表了一棵树的根;另一个数字表示对应树的父节点位置。开始实现的一个重要特性是一个初始化函数,它使 parent 成为单例分区:也就是说,每个元素都是其私有集合的根的分区。

使用这个索引表,你能实现一个 Find 函数,对于给定的索引,找到其树的根吗?

你能否实现一个 FindReplace 函数,将路径上(包括)所有 parent 条目更改为特定值?

你能否实现一个 FindCompress 函数,将所有找到的根的父节点条目更改为根?

你能实现一个联合函数,将两个给定元素的树合并成一个吗?使用 FindCompress 对一边,FindReplace 对另一边。

概述

  • 算术运算符执行数学运算。它们作用于值。

  • 赋值运算符修改对象。

  • 比较运算符比较值并返回01

  • 函数调用和大多数运算符以非特定的顺序评估它们的操作数。只有&&||?:在评估它们的操作数时施加顺序。

第五章. 基本值和数据

本章涵盖

  • 理解抽象状态机

  • 与类型和值一起工作

  • 初始化变量

  • 使用命名常量

  • 类型的二进制表示

我们现在将重点从“如何做事”(语句和表达式)转移到 C 程序操作的事物上:^C 和数据^C。一个具体程序在某一时刻必须表示值。人类有类似的策略:如今我们使用十进制表示法,在纸上使用印度-阿拉伯数字系统来写数字。但我们还有其他系统来写数字:例如,罗马数字(i,ii,iii,iv,等等)或文本表示法。知道单词十二表示值 12 是一个非平凡的步骤,这提醒我们,欧洲语言不仅在十进制中,也在其他系统中表示数字。英语和德语混合了 12 进制,法语使用了 16 进制和 20 进制。对于像我这样的非母语法语使用者来说,可能很难自发地将quatre vingt quinze(四乘二十加十五)与值 95 联系起来。

类似地,计算机上值的表示可能因架构而异,或者是由程序员赋予值的类型决定的。“文化”上的差异。因此,如果我们想编写可移植的代码,我们应该主要对值进行推理,而不是对表示进行推理。

如果你已经在 C 语言和操作字节和位方面有一些经验,你将需要在这章的大部分内容中努力“忘记”你的知识。思考你计算机上值的具体表示会比你想象的更阻碍你。

5.1 要点

C 程序主要推理值而不是它们的表示

特定值的表示在大多数情况下不应是你的关注点;编译器在那里是为了在值和表示之间组织翻译。

在本章中,我们将看到这个翻译的不同部分应该如何工作。你通常在程序中“争论”的理想世界是 C 的抽象状态机(第 5.1 节)。它为你程序的执行提供了一个与程序运行平台基本独立的视角。这个机器的状态组成部分,即对象,都具有固定的解释(它们的类型)和随时间变化的价值。C 的基本类型在第 5.2 节中描述,随后是描述我们如何表达这些基本类型的特定值(第 5.3 节),如何在表达式中组装类型(第 5.4 节),如何确保我们的对象最初具有期望的值(第 5.5 节),如何给重复出现的值命名(第 5.6 节),以及这些值在抽象状态机中的表示(第 5.7 节)。

5.1. 抽象状态机

一个 C 程序可以看作是一种操纵值的机器:程序中变量在特定时间点的特定值,以及计算表达式产生的中间值。让我们考虑一个基本示例:

   double x = 5.0;
   double y = 3.0;
   ...
   x = (x * 1.5) - y;
   **printf**("x is \%g\n", x);

在这里,我们有两个变量,x 和 y,它们的初始值分别为5.03.0。第三行计算了一些表达式:一个子表达式

   x

评估 x 并提供5.0的值;

   (5.0 * 1.5)

结果为7.5

   y

评估 y 并提供3.0的值;

   7.5 - 3.0

结果为4.5

   x = 4.5

将 x 的值改为4.5

   x

再次评估 x,但现在提供4.5的值;以及

   **printf**("x is \%g\n", 4.5)

将文本行输出到终端。

并非所有操作及其产生的值都可以从你的程序内部观察到。只有当它们存储在可寻址的内存中或写入输出设备时,才是可观察的。在示例中,在某种程度上,printf语句“观察”了上一行所执行的操作,通过评估变量 x 并将该值的字符串表示写入终端来实现。但其他子表达式及其结果(如乘法和减法)并不是以这种方式可观察的,因为我们从未定义一个变量来保存这些值。

你的 C 编译器在优化过程中可以跳过任何步骤,只要它确保实现最终结果。在这里,在我们的玩具示例中,基本上有两种可能性。第一种是变量 x 在程序后面的部分不再使用,它获得的价值只与我们的printf语句相关。在这种情况下,我们代码片段的唯一效果是输出到终端,编译器可能会(并且会!)用等效的

   **printf**("x is 4.5\n");

也就是说,它将在编译时完成所有计算,生成的可执行文件将只打印一个固定的字符串。所有剩余的代码甚至变量的定义都消失了。

另一种可能性是 x 可能在以后被使用。那么一个不错的编译器可能会做如下操作

   double x = 4.5;
   **printf**("x is 4.5\n");

或者可能是

   **printf**("x is 4.5\n");
   double x = 4.5;

因为要在以后某个时刻使用 x,它是否在printf之前或之后赋值并不重要。

为了优化是有效的,重要的是 C 编译器生成的可执行文件能够重现可观察的状态**C*。这些包括一些变量的内容(以及我们稍后将会看到的类似实体)以及它们在程序执行过程中的输出。这个整个变化机制被称为*抽象状态机**C

为了解释抽象状态机,我们首先必须探讨(我们处于什么状态)、类型(这个状态代表什么)和表示(如何区分状态)的概念。正如术语抽象所暗示的,C 的机制允许不同的平台根据它们的需求和能力以不同的方式实现给定程序的抽象状态机。这种宽容性是 C 优化潜力的关键之一。

5.1.1. 值

C 中的是一个抽象实体,通常存在于你的程序之外,该程序的特定实现,以及程序特定运行期间值的表示。例如,0的值和概念应该在所有 C 平台上始终产生相同的效果:将此值添加到另一个值x将再次是x,并且在控制表达式中评估值0将始终触发控制语句的false分支。

到目前为止,我们大多数关于值的例子都是某种类型的数字。这不是偶然的,而是与 C 的一个主要概念相关。

5.2 总结

所有值都是数字或者可以转换为数字

这个属性实际上涉及到 C 程序中所有的值,无论是我们打印的字符或文本、真值、我们采取的度量,还是我们调查的关系。将这些数字视为独立于你的程序及其具体实现的数学实体。

程序执行的数据包括在给定时刻所有对象的汇编值。程序执行的状态由以下因素决定:

  • 可执行文件

  • 当前执行点

  • 数据

  • 除了干预,例如来自用户的 IO

如果我们从最后一点抽象出来,一个从相同执行点运行相同数据的可执行文件必须给出相同的结果。但是,由于 C 程序应在系统之间具有可移植性,我们想要的不仅仅是这样。我们不希望计算的結果依赖于可执行文件(这是平台特定的),而理想情况下,只依赖于程序规范本身。实现这种平台独立性的一个重要步骤是类型的概念^C*。

5.1.2. 类型

类型是 C 与值关联的附加属性。到目前为止,我们已经看到了几种这样的类型,最显著的是size_t,还有double和 bool。

Takeaway 5.3

所有值都有一个静态确定的类型

Takeaway 5.4

对值的可能操作由其类型决定

Takeaway 5.5

值的类型决定了所有操作的结果

5.1.3. 二进制表示和抽象状态机

不幸的是,计算机平台的多样性并不允许 C 标准完全规定给定类型的操作结果。标准没有完全规定的事项,例如,有符号类型的符号表示(符号表示),以及double浮点操作执行的精度(浮点表示).^([1]) C 只对表示施加属性,以便可以从两个不同的来源预先推断出操作的结果:

¹

其他国际标准对这些表示更为严格。例如,POSIX [2009]标准强制执行特定的符号表示,而 ISO/IEC/IEEE 60559 [2011]标准化了浮点表示。

  • 操作数的值

  • 一些描述特定平台的特征值

例如,当检查SIZE_MAX的值以及操作数时,可以完全确定size_t类型的操作。我们称表示给定平台给定类型值的模型为该类型的二进制表示**^C

Takeaway 5.6

类型的二进制表示决定了所有操作的结果

通常,我们需要的所有信息来确定该模型都可通过任何 C 程序获得:C 库头文件通过命名值(如SIZE_MAX)、运算符和函数调用提供必要的信息。

Takeaway 5.7

类型的二进制表示是可观察的

这种二进制表示仍然是一个模型,因此在某种意义上是一个抽象表示,因为它并不完全确定值在计算机内存中或磁盘或其他持久存储设备中的存储方式。这种表示是对象表示。与二进制表示不同,只要我们不打算在主内存中组合对象的值,或者不需要在不同平台模型之间进行通信,对象表示通常对我们来说并不重要。在稍后的第 12.1 节中,我们将看到,如果我们知道这样一个对象存储在内存中并且知道其地址,我们甚至可以观察到对象表示。

因此,所有计算都是通过程序中指定的值、类型及其二进制表示来固定的。程序文本描述了一个抽象状态机**^C,它规定了程序如何从一个状态切换到下一个状态。这些转换仅由值、类型和二进制表示决定。

摘要 5.8(as-if)

程序执行 就像 遵循抽象状态机

5.1.4. 优化

如何让具体的可执行程序遵循抽象状态机的描述,这取决于编译器创建者的自由裁量权。大多数现代 C 编译器生成的代码并不严格遵循精确的代码规定:它们在可能的情况下都会作弊,并且只尊重抽象状态机的可观察状态。例如,一系列使用常数值的加法操作

   x += 5;
   /* Do something else without x in the meantime. */
   x += 7;

在许多情况下可以像这样执行,即

   /* Do something without x. */
   x += 12;

或者

   x += 12;
   /* Do something without x. */

只要结果没有可观察的差异,编译器就可以对执行顺序进行这样的更改:例如,只要我们不打印 x 的中间值,并且只要我们不使用这个中间值进行其他计算。

但是,这种优化也可能被禁止,因为编译器无法证明某个操作不会导致程序终止。在我们的例子中,很多都取决于 x 的类型。如果 x 的当前值接近类型的上限,看起来无害的操作 x += 7可能会产生溢出。根据类型的不同,这种溢出会被以不同的方式处理。正如我们所见,无符号类型的溢出不是问题,压缩操作的结果将始终与两个单独的操作一致。对于其他类型,例如有符号整数类型(signed)和浮点类型(double),溢出可能会引发异常并终止程序。在这种情况下,优化不能执行。

正如我们之前提到的,这种在程序描述和抽象状态机之间的允许的灵活性是一个非常宝贵的特性,通常被称为优化。结合其语言描述的相对简单性,这实际上是 C 语言能够超越其他具有更多旋钮和哨声的编程语言的主要特性之一。这次讨论的重要后果可以总结如下:

5.9 要点

类型决定了优化机会

5.2. 基本类型

C 语言有一系列基本类型,以及从它们构造 派生类型**^C 的方法,这些方法我们将在 第六章 中描述。

主要由于历史原因,基本类型系统有些复杂,指定这些类型的语法并不完全直接。存在一个一级的指定,完全使用语言的保留字完成,例如 signedintdouble。这一级主要根据 C 的内部结构组织。在其之上是一个二级的指定,通过头文件实现,我们已看到了一些例子:size_t 和 bool。这一级是根据类型语义组织的,指定特定类型为程序员带来的属性。

我们将从这类类型的一级指定开始。正如我们之前讨论的 (要点 5.2),C 语言中的所有基本值都是数字,但存在不同种类的数字。作为一个主要区别,我们有两种不同的数字类别,每个类别有两个子类别:无符号整数**C*、*带符号整数**C实数浮点数**^C复数浮点数**^C。这四个类别中每个都包含几个类型。它们根据它们的 精度**^C 不同,这决定了特定类型允许的有效值范围。表 5.1 包含了 18 种基类型的概述。

²

这里使用的 精度 一词在 C 标准的定义下具有限制性意义。它与浮点计算的 精度 不同。

表 5.1. 根据四种主要类型类别的基类型。具有灰色背景的类型不允许进行算术运算;在进行算术运算之前会被提升。类型* char 是特殊的,因为它可以是无符号的或带符号的,这取决于平台。表中所有*类型都被视为不同的类型,即使它们具有相同的类别和精度。
类别 系统名称 其他名称 等级
整数 无符号 _Bool bool
unsigned char 1
无符号短整型 2
无符号整型 unsigned 3
无符号长整型 4
无符号长整型 5
[无]符号 char 1
有符号字符 1
有符号短整型 短整型 2
带符号 有符号整型 signedint 3
signed long long 4
signed long long long long 5
浮点数 实数 float
double
long double
Complex float _Complex float complex
double _Complex double complex
long double _Complex long double complex

如您从表中可以看到,有六种类型我们无法直接用于算术运算,这些被称为窄类型C*。在它们被用于算术表达式之前,这些类型会被**提升**C*到更宽的类型之一。如今,在任何一个实际的平台上,这种提升将是一个与窄类型值相同的signed int,无论窄类型是有符号的还是无符号的。

取得要点 5.10

在算术运算之前,窄整数类型会被提升到 signed int.

注意,在窄整数类型中,我们有两个突出的成员:char和 bool。第一个是 C 语言处理可打印字符的类型,第二个用于存储真值,falsetrue。正如我们之前所说的,对于 C 语言,即使是这些也只是某种数字。

剩下的 12 个未提升类型很好地分为四类。

取得要点 5.11

基本类型中的每一类都有三种不同的未提升类型。

与许多人认为的相反,C 标准没有规定这 12 种类型的精度:它只是限制了它们。它们的值取决于许多实现定义**^C的因素。

标准规定了一件事,即有符号类型可能值的范围必须根据它们的rank相互包含:

但这种包含关系不必是严格的。例如,在许多平台上,intlong的值集是相同的,尽管这些类型被认为是不同的。对于六个无符号类型也有类似的包含关系:

但请记住,对于任何算术或比较操作,窄无符号类型会被提升为signed int,而不是unsigned int,正如这张图可能暗示的那样。

有符号和无符号类型范围的比较更为复杂。显然,无符号类型永远不会包含有符号类型的负值。对于非负值,我们有以下类型值的包含关系:

这意味着对于给定的 rank,有符号类型的非负值可以适合无符号类型。在任何现代平台上,这种包含关系是严格的:无符号类型有值不能适合有符号类型。例如,一个常见的最大值对是signed int的 2³¹–1 = 2 147 483 647 和unsigned int的 2³²–1 = 4 294 967 295。

由于整数类型之间的关系取决于平台,以可移植的方式为特定目的选择“最佳”类型可能是一项繁琐的任务。幸运的是,我们可以从编译器实现中获得一些帮助,它为我们提供了typedef,如size_t,这些代表某些特性。

5.12 摘要

使用 size_t *表示大小、基数或序数。

记住,无符号类型是最方便的类型,因为它们是唯一具有与数学属性一致定义的算术类型的类型:模运算。它们不能在溢出时发出信号,并且可以最好地优化。它们在 5.7.1 节中更详细地描述。

5.13 摘要

使用 unsigned *表示不能为负的小量。

如果你的程序确实需要可能为正也可能为负但没有分数的值,请使用有符号类型(参见 5.7.5 节)。

5.14 摘要

使用 signed *表示带有符号的小量。

5.15 摘要

使用 ptrdiff_t *表示带有符号的大差异。

如果你想使用像0.53.77189E+89这样的值进行分数计算,请使用浮点类型(参见 5.7.7 节)。

5.16 摘要

使用 double *进行浮点计算。

5.17 摘要

使用 double complex *进行复杂数学运算。

C 标准定义了许多其他类型,其中还包括其他用于特殊用例的算术类型。表 5.2 列出了一些。第二对表示平台支持的宽度最大的类型。这也是预处理器进行任何算术或比较的类型。

表 5.2. 一些用于特定用例的语义算术类型
类型 头文件 定义上下文 含义
size_t stddef.h “大小”和基数类型
ptrdiff_t stddef.h 大小差异的类型
uintmax_t stdint.h 最大宽度的无符号整数,预处理器
intmax_t stdint.h 最大宽度的有符号整数,预处理器
time_t time.h time(0), difftime(t1, t0) 自纪元以来的日历时间(秒)
clock_t time.h clock() 处理器时间

两个类型time_tclock_t用于处理时间。它们是语义类型,因为时间计算的精度可能因平台而异。要在算术中使用以秒为单位的时间,可以使用函数difftime:它计算两个时间戳的差异。clock_t值表示平台的处理器时钟周期模型,因此时间单位通常小于一秒;可以使用CLOCKS_PER_SEC将这些值转换为秒。

5.3. 指定值

我们已经看到了几种指定数值常量(字面量**^C)的方法:

123 十进制整数常量**^C. 对我们大多数人来说是最自然的选择。
077 八进制整数常量**^C. 这是由一系列数字指定的,第一个数字是 0,后面的数字在 0 到 7 之间。例如,077 的值是 63。这种指定方式仅具有历史价值,现在很少使用。只有一种八进制字面量常用:0 本身。
0xFFFF 十六进制整数常量**^C. 这是由以 0x 开头,后跟 0 到 9 和 a 到 f 之间的数字序列指定的。例如,0xbeaf 的值是 48815。a 到 f 和 x 也可以写成大写,0XBEAF。
1.7E-13 十进制浮点常量**^C. 与带有小数点的版本非常熟悉。但还有一种带有指数的“科学”表示法。在一般形式中,mEe 被解释为 m · 10^e.
0x1.7aP-13 十六进制浮点常量**^C. 通常用于描述易于指定具有精确表示的浮点值的形式。一般形式 0XhPe 被解释为 h · 2^e. 这里,h 被指定为十六进制分数。指数 e 仍然被指定为十进制数。
'a' 整数字符常量**^C. 这些是放在单引号之间的字符,例如 'a' 或 '?'. 它们的值仅由 C 标准隐式确定。例如,'a' 对应于拉丁字母中的字符 a 的整数代码。在字符常量中,反斜杠字符 \ 有特殊含义。例如,我们已经看到了用于换行符的 '\n'。
"hello" 字符串字面量**^C. 它们指定文本,例如用于 printfputs 函数的文本。同样,反斜杠字符 \ 与字符常量一样具有特殊含义.^([3])

³

如果在 printf 函数的上下文中使用,另一个字符也变成了“特殊”字符:百分号字符。如果你想在 printf 中打印字面量 %,你必须重复它。

除了最后的之外,都是数值常量:它们指定数字.^([4]) 字符串字面量是一个例外,可以在编译时指定文本。如果我们不允许将字符串字面量分成块,将更大的文本集成到我们的代码中可能会很繁琐:

你可能已经注意到复数不包含在这个列表中。我们将在 5.3.1 节 中看到如何指定它们。

**puts**("first line\n"
     "another line\n"
     "first and "
     "second part of the third line");
Takeaway 5.18

连续的字符串字面量会被连接起来。

数字规则要复杂一些。

Takeaway 5.19

数值字面量从不为负。

也就是说,如果我们写像 -34-1.5E-23 这样的东西,前面的符号不是数字的一部分,而是应用于其后数字的否定运算符。我们很快就会看到这在哪里很重要。虽然这听起来可能很奇怪,但指数中的负号被认为是浮点字面量的一部分。

我们已经看到 (摘要 5.3),所有的字面量不仅必须有值,还必须有类型。不要把常量具有正值的事实与它的类型混淆,它的类型可以是 signed

摘要 5.20

十进制整数常量是有符号的

这是一个重要的特性:我们可能期望表达式 -1 是一个有符号的负值。

为了确定整数字面量的确切类型,我们始终有一个 first fit 规则。

摘要 5.21

十进制整数常量具有适合它的三个有符号类型中的第一个类型

这条规则可能会有意想不到的效果。假设在一个平台上,最小的 signed 值是 2¹⁵ = –32768,最大值是 2¹⁵ –1 = 32767. 因此,常量 32768 不适合 signed,因此是 signed long。因此,表达式 -32768 的类型是 signed long。因此,该平台上 signed 类型的最小值不能写成字面量常量.^([[Exs 1]])

^([Exs 1])

证明如果 signed long long 的最小值和最大值具有类似特性,平台的最小整数值不能写成带负号的字面量组合。

摘要 5.22

相同的值可以有不同的类型

推断八进制或十六进制常量的类型要复杂一些。如果值不适合有符号类型,它们也可以是无符号类型。在早期示例中,十六进制常量 0x7FFF 的值是 32767,因此类型是 signed。除了十进制常量外,常量 0x8000(用十六进制表示的值为 32768)是一个unsigned,表达式 -0x8000 再次是 unsigned.^([[Exs 2]])

^([Exs 2])

证明如果最大的unsigned是 2¹⁶ –1,那么 -0x8000 也有值 32768。

摘要 5.23

不要使用八进制或十六进制常量来表示负值

因此,只剩下一种选择留给负值。

摘要 5.24

使用十进制常量来表示负值

整数常量可以强制转换为无符号类型或具有最小宽度的类型。这是通过在字面量后附加 ULLL 来实现的。例如,1U 的值是 1,类型是 unsigned1L 是 signed long,而 1ULL 有相同的值 1 但类型是 unsigned long long.^([[Exs 3]]) 注意,我们用打字机字体表示 C 常量,如 1ULL,并将它们与它们的数学值 1 区分开来,后者用正常字体表示。

^([Exs 3])

证明表达式 -1U, -1UL, 和 -1ULL 分别具有三个非提升的无符号类型的最小值和类型。

一个常见的错误是尝试将十六进制常量赋给一个 signed 类型,并期望它将表示一个负值。考虑一个如 int x = 0xFFFFFFFF 的声明。这是在假设十六进制值与有符号值 -1 的 二进制表示 相同的情况下进行的。在大多数具有 32 位 signed 的架构上,这将是正确的(但并非所有架构都是这样);但随后没有任何保证将有效值 +4294967295 转换为值 -1。 表 5.3 包含了一些有趣的常量、它们的值和它们的类型示例。

表 5.3. 常量及其类型的示例。这是在假设 signedunsigned 使用常见的 32 位表示的情况下。
常量 x 类型 x 的值
2147483647 +2147483647 有符号 2147483647
2147483648 +2147483648 signed long 2147483648
4294967295 +4294967295 signed long 4294967295
0x7FFFFFFF +2147483647 有符号 2147483647
0x80000000 +2147483648 无符号 +2147483648
0xFFFFFFFF +4294967295 无符号 +1
1 +1 有符号 1
1U +1 无符号 +4294967295

记住值 0 非常重要。它如此重要,以至于有大量的等效表示法:0, 0x0, 和 '\0' 都表示相同的值,一个 0 的类型为 signed int。0 没有十进制整数的表示法:0.0 值 0 的十进制表示法,但它被视为类型为 double 的浮点值。

Takeaway 5.25

不同的字面量可以具有相同的值。

对于整数,这个规则看起来几乎是显而易见的,但对于浮点常量来说则不那么明显。浮点值只是它们所表示的值的近似,因为小数部分的二进制位可能被截断或四舍五入。

Takeaway 5.26

十进制浮点常量的有效值可能与字面值不同。

例如,在我的机器上,常量 0.2 的值为 0.2000000000000000111,因此常量 0.20.2000000000000000111 具有相同的值。

十六进制浮点常量已经被设计出来,因为它们更好地对应于浮点值的二进制表示。实际上,在大多数现代架构上,这样的常量(位数不是太多)将正好对应于字面量值。不幸的是,这些怪物对于普通人来说几乎不可读。例如,考虑两个常量 0x1.99999AP-30xC.CCCCCCCCCCCCCCDP-6。第一个对应于 1.60000002384 * 2^(–3),第二个对应于 12.8000000000000000002 * 2^(–6);因此,以十进制浮点数表示,它们的值分别约为 0.20000000298 和 0.200000000000000000003。所以这两个常量的值非常接近,而它们作为十六进制浮点常量的表示似乎把它们放在了很远的地方。

最后,浮点常量可以跟字母 f 或 F 一起使用来表示 float,或者跟 l 或 L 一起使用来表示 long double。否则,它们是 double 类型。请注意,不同类型的常量通常会导致相同的字面量有不同的值。以下是一个典型的例子:

float double long double
字面量 0.2F 0.2 0.2L
0x1.99999AP-3F 0x1.999999999999AP-3 0xC.CCCCCCCCCCCCCCDP-6L
收获 5.27

字面量具有值、类型和二进制表示。

5.3.1. 复合常量

复合类型不一定被所有 C 平台支持。可以通过检查 __STDC_NO_COMPLEX__ 来验证这一点。要完全支持复合类型,应包含头文件 complex.h。如果你使用 tgmath.h 进行数学函数,这已经隐式地完成了。

<complex.h>

<tgmath.h>

不幸的是,C 提供了没有字面量来指定复合类型常量的功能。它只有几个宏^([5]),这些宏可能有助于操作这些类型。

我们将在 第 5.6.3 节 中看到宏到底是什么。现在,只需把它们当作编译器已经关联了一些特定属性的名称即可。

指定复数值的第一个可能性是宏 CMPLX,它包含一个复数值,其中包含实部和虚部。例如,CMPLX(0.5, 0.5) 是一个实部和虚部均为一半的 double complex 值。类似地,有 CMPLXF 用于 float complexCMPLXL 用于 long double complex

另一个更方便的可能性是由宏 I 提供的,它代表一个类型为 float complex 的常量值,使得 I*I 的值为 –1。在程序中,通常使用大写单字符宏名称来表示整个程序中固定的数字。单独来看,这并不是一个很棒的想法(单字符名称的供应有限),但你绝对应该让 I 保持原样。

收获 5.28

I 是虚数单位保留的。

我可以用类似于常规数学记法的复数类型常数来指定。例如,0.5 + 0.5``*I 将是double complex类型,而0.5F + 0.5F*I 是float complex类型。如果我们将floatdouble常数混合用于实部和虚部,编译器会隐式地将结果转换为更宽的类型。

复数

你能将导数(挑战 2)扩展到复数域:也就是说,接收和返回double complex值的函数吗?

5.4. 隐式转换

正如我们在示例中所见,操作数的类型会影响操作表达式(如-1-1U)的类型:第一个是signed int,第二个是unsigned int。后者对于初学者来说可能特别令人惊讶,因为unsigned int没有负值,所以-1U 的值是一个很大的正整数。

Takeaway 5.29

一元 -+ 的类型与其提升的参数相同。

因此,这些运算符是类型通常不改变的例子。在它们改变的情况下,我们必须依赖 C 的隐式转换策略:也就是说,将具有特定类型的值移动到具有另一种、所需类型的值。考虑以下示例,再次假设-2147483648 和 2147483647 分别是signed int的最小值和最大值:

double          a = 1;             // Harmless; value fits type
signed short    b = -1;            // Harmless; value fits type
signed int      c = 0x80000000;    // Dangerous; value too big for type
signed int      d = -0x80000000;   // Dangerous; value too big for type
signed int      e = -2147483648;   // Harmless; value fits type
unsigned short  g = 0x80000000;    // Loses information; has value 0

在这里,a 和 b 的初始化是无害的。相应的值完全在所需类型的范围内,因此 C 编译器可以静默地转换它们。

c 和 d 的下一个转换有问题。正如我们所见,0x80000000 是unsigned int类型,不能放入signed int中。因此,c 接收到的值是实现定义的,我们必须知道我们的平台在这种情况下决定做什么。它可能只是重新使用右侧值的位模式,或者终止程序。至于所有实现定义的特性,所选择的解决方案应由您的平台文档化,但请注意,这可能会随着编译器新版本的出现而改变,或者可能由编译器参数切换。

对于 d 的情况,情况更加复杂:0x80000000 的值是 2147483648,我们可能会预期-0x80000000 只是-2147483648。但是,由于实际上-0x80000000 再次是 2147483648,因此出现了与 c 相同的问题。[^([Exs 4])(#ch05fn-ex04)]

^([Exs 4])

假设unsigned int的最大值是 0xFFFFFFFF,证明-0x80000000 == 0x80000000。

然后,e 再次无害。这是因为我们使用了取反的十进制字面量-2147483648,它具有signed long类型,其值实际上是-2147483648(之前已显示)。由于这个值可以放入signed int中,转换可以无问题地进行。

g 的最后例子在后果上是不明确的。对于无符号类型来说,值太大时,会根据模数进行转换。这里特别地,如果我们假设unsigned short的最大值是 2¹⁶ –1,那么结果值是 0。是否这种“缩小”转换是期望的结果往往很难判断。

收获 5.30

避免缩小转换。

收获 5.31

不要在算术中使用窄类型。

对于具有两个操作数的运算符,如加法和乘法,类型规则变得更加复杂,因为这些运算符可能具有不同的类型。以下是一些涉及浮点类型的操作示例:在这里,前两个例子是无害的:整数常量1很好地适合doublecomplex float类型。事实上,对于大多数这样的混合操作,只要一个类型的范围适合另一个类型的范围,结果就有更宽的范围的类型。

1       + 0.0  // Harmless; double
1       + I    // Harmless; complex float
**INT_MAX** + 0.0F // May lose precision; float
**INT_MAX** + I    // May lose precision; complex float
**INT_MAX** + 0.0  // Usually harmless; double

下两个例子有问题,因为INT_MAX,即signed int的最大值,通常不会适合floatcomplex float。例如,在我的机器上,INT_MAX + 0.0F 与INT_MAX + 1.0F 相同,其值为 2147483648。最后一行显示,对于double操作,这在大多数平台上都会正常工作。然而,在一个现有的或未来的平台中,如果int是 64 位,可能会出现类似的问题。

由于整数类型的值范围没有严格的包含关系,推导混合有符号和无符号值的操作类型可能会很棘手:

-1    < 0    // True, harmless, same signedness
-1L   < 0    // True, harmless, same signedness
-1U   < 0U   // False, harmless, same signedness
-1    < 0U   // False, dangerous, mixed signedness
-1U   < 0    // False, dangerous, mixed signedness
-1L   < 0U   // Depends, dangerous, same or mixed signedness
-1LL  < 0UL  // Depends, dangerous, same or mixed signedness

前三个比较是无害的,因为即使它们混合了不同类型的操作数,它们也没有混合符号。由于这些情况的可能值范围很好地包含彼此,C 语言简单地将其转换为更宽的类型并执行比较。

接下来的两个情况是明确的,但可能不是初学者程序员所期望的。实际上,对于这两个情况,所有操作数都被转换为unsigned int。因此,所有取反的值都被转换为大的无符号值,比较的结果是false

最后两个比较甚至更加有问题。在UINT_MAXLONG_MAX的平台,0U 被转换为0L,因此第一个结果是true。在其他LONG_MAX < UINT_MAX的平台,-1L 被转换为-1U(即UINT_MAX),因此第一个比较是false。对于最后两个比较的第二个,也有类似的观察,但请注意,这两个结果可能并不相同。

最后两个比较的例子可能会引发关于有符号或无符号类型的无休止的辩论。但它们只表明了一件事:混合有符号和无符号操作数的语义并不总是清晰的。在某些情况下,隐式转换的任何一种选择都可能有问题。

取得成果 5.32

避免使用不同符号位的操作数进行操作。

取得成果 5.33

尽可能使用无符号类型。

取得成果 5.34

选择算术类型,以确保隐式转换是无害的。

5.5. 初始化器

我们已经看到(第 2.3 节),初始化器是对象定义的一个重要部分。初始化器帮助我们保证程序执行始终处于定义状态:即每次我们访问一个对象时,它都有一个已知的值,该值决定了抽象机的状态。

取得成果 5.35

所有变量都应该初始化。

只有少数例外情况遵循这个规则:变长数组(VLA);参见第 6.1.3 节,它不允许有初始化器,以及必须高度优化的代码。后者主要发生在使用指针的情况下,所以这目前对我们来说还不相关。对于到目前为止我们能写的绝大多数代码,现代编译器都能够追踪到一个值的来源,即它的最后赋值或初始化。多余的初始化或赋值将简单地被优化掉。

对于整数和浮点数这样的标量类型,初始化器只包含可以转换为该类型的表达式。我们已经看到了很多这样的例子。这样的初始化器表达式可以可选地被 {} 包围。以下是一些例子:

double a = 7.8;
double b = 2 * a;
double c = { 7.8 };
double d = { 0 };

其他类型的初始化器必须有这些 {}。例如,数组初始化器包含不同元素的初始化器,每个初始化器后面都跟着一个逗号:

double A[] = { 7.8, };
double B[3] = { 2 * A[0], 7, 33, };
double C[] = { [0] = 6, [3] = 1, };

正如我们所见,由于没有长度指定,具有不完整类型**^C的数组通过初始化器来完成,以完全指定长度。在这里,A 只有一个元素,而 C 有四个。对于前两个初始化器,应用于标量的初始化元素是从标量在列表中的位置推断出来的:例如,B[1]被初始化为7。对于 C 这样的指定初始化器来说,它们无疑是首选的,因为它们使代码对声明中的微小变化更具鲁棒性。

取得成果 5.36

对于所有聚合数据类型,使用指定初始化器。

如果你不知道如何初始化类型为 T 的变量,则默认初始化器**^CT a = {0}几乎总是可以做到的。

例外的是变长数组;参见第 6.1.3 节。

取得成果 5.37

{0} 是所有非 VLA 对象类型的有效初始化器。

几件事情确保了这一点。首先,如果我们省略了指定(对于 struct.membername 参见 [第 6.3 节] 或数组的 [n] 参见 [第 6.1 节]),初始化就只是按照 声明顺序 完成^C:也就是说,默认初始化器中的 0 指的是声明的第一个成员,然后所有其他成员默认初始化为 0。然后,标量初始化器的 {} 形式确保 { 0 } 对它们也是有效的。

可能你的编译器会警告你关于这一点:令人烦恼的是,一些编译器实现者不知道这个特殊规则。它是作为 C 标准中的通用初始化器而明确设计的,因此这是少数几个我会关闭编译器警告的情况之一。

在初始化器中,我们通常必须指定对程序有特定意义的值。

5.6. 命名常量

即使在小程序中,一个常见的问题是它们使用特殊值来完成某些目的,这些值在文本中被重复使用。如果由于某种原因这个值发生变化,程序就会崩溃。以下是一个人工设置的例子,其中我们有一些字符串数组,^([7]),我们想在它们上执行一些操作:

这使用了一个 指针,类型 char const*const,来引用字符串。我们稍后会看到这种特定技术是如何工作的。

在这里,我们在几个地方使用了常量 3,并且具有三种不同的“意义”,这些意义之间关联性不大。例如,向我们的松鸦集合添加一个新成员将需要两次单独的代码更改。在实际设置中,代码中可能还有许多其他地方依赖于这个特定的值,在一个大型代码库中,这可能非常难以维护。

5.38 总结

所有具有特定意义的常量都必须命名。

区分相等但相等只是巧合的常量同样重要。

5.39 总结

所有具有不同意义的常量都必须区分开来。

C 语言在指定命名常量方面出奇地少,其术语甚至导致了对哪些构造有效导致编译时常量的很多混淆。因此,在我们深入研究 C 语言提供的唯一合适的命名常量:枚举常量(第 5.6.2 节)之前,我们首先必须弄清楚这些术语(第 5.6.1 节)。后者将帮助我们用更具有说明性的内容替换示例中 3 的不同版本。

char const*const bird[3] = {
  "raven",
  "magpie",
  "jay",
};
char const*const pronoun[3] = {
  "we",
  "you",
  "they",
};
char const*const ordinal[3] = {
  "first",
  "second",
  "third",
};
...
for (unsigned i = 0; i < 3; ++i)
    **printf**("Corvid %u is the %s\n", i, bird[i]);
...
for (unsigned i = 0; i < 3; ++i)
    **printf**("%s plural pronoun is %s\n", ordinal[i], pronoun[i]);

其次,通用的机制通过简单的文本替换:宏(第 5.6.3 节)来补充这个特性。如果它们的替换由基本类型的字面量组成,就像我们看到的,宏只会导致编译时常量。如果我们想为更复杂的数据类型提供类似于常量的概念,我们必须将它们作为临时对象提供(第 5.6.4 节)。

5.6.1. 只读对象

不要将术语常量与 C 语言中具有非常特定意义的对象混淆。例如,在之前的代码中,bird、pronoun 和 ordinal 根据我们的术语不是常量;它们是const-限定对象。这个限定符**^C指定我们没有权利更改此对象。对于 bird,数组的条目以及实际的字符串都不能修改,如果你尝试这样做,你的编译器应该会给出诊断:

摘要 5.40

具有const-限定类型的对象是只读的。*

这并不意味着编译器或运行时系统可能不会更改此类对象的值:程序的其他部分可能看到没有限定符的对象并更改它。你不能直接编写你的银行账户摘要(但只能读取它),并不意味着它将随着时间的推移保持不变。

另有一类只读对象,遗憾的是,它们的类型并不能保护它们不被修改:字符串字面量。

图片

摘要 5.41

字符串字面量是只读的。

如果今天引入,字符串字面量的类型肯定会是char const[],一个const-限定字符数组。不幸的是,const关键字是在字符串字面量之后被引入到 C 语言的,因此为了向后兼容,它保持原样.^([8])

存在第三类只读对象:临时对象。我们将在第 13.2.2 节中看到它们。

数组如 bird 也使用另一种技术来处理字符串字面量。它们使用一种指针**^C类型,char const*const,来“引用”字符串字面量。此类数组的可视化如下:

图片

也就是说,字符串字面量本身并没有存储在数组 bird 中,而是在其他某个地方,而 bird 只是引用了那些地方。我们将在第 6.2 节和第十一章中更晚些时候看到这个机制是如何工作的。

5.6.2. 枚举

C 语言有一个简单的机制来命名我们需要的整数,称为枚举**^C

enum corvid { magpie, raven, jay, corvid_num, };
char const*const bird[corvid_num] = {
  [raven]  = "raven",
  [magpie] = "magpie",
  [jay]    = "jay",
};
...
for (unsigned i = 0; i < corvid_num; ++i)
    **printf**("Corvid %u is the %s\n", i, bird[i]);

这声明了一个新的整数类型enum corvid,我们已知其四个不同的值。

摘要 5.42

枚举常量具有显式或位置值。

正如你可能猜到的,位置值从0开始,因此在我们这个例子中,乌鸦的值为 0,喜鹊为 1,乌鸫为 2,corvid_num 为 3。这个最后的 3 显然是我们感兴趣的 3。

图片

注意,这使用了与之前不同的数组条目顺序,这是使用枚举方法的一个优点:我们不需要手动跟踪数组中使用的顺序。枚举类型中固定的顺序会自动完成这项工作。

现在,如果我们想添加另一个 corvid,我们只需将其放入列表中,在任何地方在 corvid_num 之前:

列表 5.1. 枚举类型和相关字符串数组
enum corvid { magpie, raven, jay, chough, corvid_num, };
char const*const bird[corvid_num] = {
  [chough] = "chough",
  [raven]  = "raven",
  [magpie] = "magpie",
  [jay]    = "jay",
};

对于大多数其他窄类型,实际上对声明枚举类型的变量并不感兴趣;对于索引和算术运算,它们无论如何都会转换为更宽的整数。甚至枚举常量本身也不是枚举类型:

摘要 5.43

枚举常量是 signed int. 类型。

因此,真正的兴趣在于常数,而不是新创建的类型。因此,我们可以为所需的任何signed int常量命名,甚至不需要为类型名提供标签^C:

   enum { p0 = 1, p1 = 2*p0, p2 = 2*p1, p3 = 2*p2, };

为了定义这些常数,我们可以使用整数常量表达式(ICE)^C (ICE). 这种 ICE 提供了一个编译时的整数值,并且受到很多限制。不仅其值必须在编译时确定(不允许函数调用),而且没有任何对象的评估必须作为值的一个操作数参与:

   signed const o42 = 42;
   enum {
     b42 = 42,       // Ok: 42 is a literal.
     c52 = o42 + 10, // Error: o42 is an object.
     b52 = b42 + 10, // Ok: b42 is not an object.
   };

这里,o42 是一个对象,const-修饰的,但仍然,所以 c52 的表达式不是一个“整数常量表达式。”

摘要 5.44

整数常量表达式不评估任何对象

因此,原则上,一个 ICE 可以由任何整数文字、枚举常量、_Alignofoffsetof子表达式以及最终的一些sizeof子表达式组成.^([9])

我们将在第 12.7 节和第 12.1 节中处理这两个概念。

即使值是一个 ICE,为了能够用它来定义枚举常量,你必须确保该值适合于一个signed

5.6.3. 宏定义

不幸的是,在 C 语言的严格意义上,没有其他机制可以声明除了signed int之外的其他类型的常数。相反,C 语言提出了另一种强大的机制,它引入了程序代码的文本替换:^C. 宏是通过预处理器^C #define 引入的:

# define M_PI 3.14159265358979323846

这个宏定义的效果是在以下程序代码中将标识符 M_PI 替换为double常量。这样的宏定义由五个不同的部分组成:

  1. 行首的#字符,必须是行上的第一个非空白字符。

  2. 关键字 define

  3. 要声明的标识符,这里为 M_PI

  4. 替换文本,这里为3.14159265358979323846

  5. 一个终止的换行符

通过这个技巧,我们可以为unsignedsize_tdouble的常量声明文本替换。实际上,size_t的实现限制SIZE_MAX被定义,以及其他我们已经看到的许多系统特性:EXIT_SUCCESSfalsetruenot_eq、bool、complex 等。在本书的彩色电子版中,这些 C 标准宏都打印为暗红色

这些例子来自 C 标准的拼写并不代表在大多数软件项目中普遍使用的约定。大多数项目都有相当严格的规则,使得宏在视觉上与其周围内容区分开来。

摘要 5.45

宏名全部大写。

只有在你有充分的理由时才偏离该规则,特别是在达到第 3 级之前。

5.6.4. 复合字面量

对于没有描述其常量的类型,事情变得更加复杂。我们必须在宏的替换侧使用复合字面量**^C。这种复合字面量的形式是

   (T){ INIT }

即,一个类型,后面跟一个初始化器。以下是一个示例:

# define CORVID_NAME /**/        \
(char const*const[corvid_num]){  \
  [chough] = "chough",           \
  [raven] = "raven",             \
  [magpie] = "magpie",           \
  [jay] = "jay",                 \
}

有了这个,我们可以省略鸟数组并重写我们的for循环:

for (unsigned i = 0; i < corvid_num; ++i)
    **printf**("Corvid %u is the %s\n", i, CORVID_NAME[i]);

而在宏定义中的复合字面量可以帮助我们声明类似于所选类型常量的东西,但这并不是 C 的狭义意义上的常量。

摘要 5.46

复合字面量定义了一个对象。

  • 总体而言,这种形式的宏有一些陷阱:

  • 复合字面量不适合用于 ICE。

  • 在我们这里的目的,为了声明命名常量,类型 T 应该是const-限定*^C*。这确保了优化器有更多余地来生成这样的宏替换的良好二进制代码。

  • 宏名和复合字面量的()之间必须有空格,这里用/********/`注释表示。否则,这将被解释为函数式宏定义的开始。我们将在稍后看到这些。

  • 行尾的退格字符\可以用来将宏定义延续到下一行。

  • 宏定义的末尾绝对不能有;。记住,这只是一次文本替换。

摘要 5.47

不要在宏内部隐藏一个终止的分号。

此外,为了宏的可读性,请同情一下你代码中偶尔的读者:

摘要 5.48

宏的续行标记应右缩进到同一列。

正如你在示例中看到的,这有助于轻松可视化宏定义的整个范围。

5.7. 二进制表示

一个类型的二进制表示是一个模型,它描述了该类型可能具有的值。它不同于描述给定类型值的内存对象表示

摘要 5.49

相同的值可能有不同的二进制表示。

5.7.1. 无符号整数

我们已经看到,无符号整数类型是那些具有标准算术运算的优美、封闭数学描述的算术类型。它们在算术运算下是封闭的:

摘要 5.50

无符号算术运算可以很好地进行环绕。

在数学术语中,它们实现了一个 [N],即模某个数 N 的整数集。可表示的值是 0, . . . , N – 1. 最大值 N – 1 完全决定了这样的无符号整数类型,并通过名称中带有终止符 _MAX 的宏提供。对于基本的无符号整数类型,这些是 UINT_MAXULONG_MAX,和 ULLONG_MAX,它们通过 limits.h 提供。正如我们所见,size_t 的值来自 stdint.hSIZE_MAX

<limits.h>

<stdint.h>

非负整数值的二进制表示总是如术语所示:这样的数由二进制位 b[0], b[1]. . . , b**[p–1] 称为 位**^C 表示。每个位都有 0 或 1 的值。这样的数的值是通过以下方式计算的

方程式 5.1.

在那个二进制表示中,值 p 被称为基础类型的 精度**^C。位 b[0] 被称为 最低有效位**^C,即 LSBb**[p–1]最高有效位**^C (MSB).

b**[i] 位中,值为 1 的位中,具有最小索引 i 的位被称为 最低有效位设置**^C,而具有最高索引的位是 最高有效位设置**^C。例如,对于 p = 16 的无符号类型,值 240 将有 b[4] = 1,b[5] = 1,b[6] = 1,和 b[7] = 1. 所有其他二进制表示的位都是 0,最低有效位设置 ib[4],最高有效位设置是 b[7]。从 (5.1) 我们可以立即看出,2^p 是第一个不能用该类型表示的值。因此 N = 2^p

取得 5.51

任何整数类型的最值形式为 2^p – 1。

注意,对于非负值表示的讨论,我们没有争论类型的符号。这些规则同样适用于有符号和无符号类型。只有对于无符号类型,我们很幸运,我们之前所说的已经完全足够描述这样的无符号类型。

取得 5.52

无符号整数类型的算术运算由其精度决定

最后,表 5.4 展示了本书中一些常用标量的界限。

表 5.4. 本书使用的标量类型的界限
名称 [最小值,最大值] 位置 典型值
size_t [0, SIZE_MAX] <stdint.h> [0, 2^w –1], w = 32, 64
double DBL_MIN, ±DBL_MAX] <float.h> [±2(–w–2)**,*±2*w], w = 1024
signed [INT_MIN, INT_MAX] <limits.h> [–2^w**, 2^w –1], w = 31
unsigned [0, UINT_MAX] <limits.h> [0, 2^w –1], w = 32
bool [false, true] <stdbool.h> [0, 1]
ptrdiff_t [PTRDIFF_MIN, PTRDIFF_MAX] <stdint.h> [–2^w**, 2^w –1], w = 31, 63
char [CHAR_MIN, CHAR_MAX] <limits.h> [0, 2^w –1], w = 7, 8
unsigned char [0, UCHAR_MAX ] <limits.h> [0, 255]

5.7.2. 位集和位运算符

这种无符号类型的简单二进制表示使我们能够将其用于与算术无关的另一个目的:作为位集。位集是对无符号值的不同解释,我们假设它表示基础集 V = {0, . . . , p–1} 的子集,并且如果位 b**[i] 存在,则元素 i 是集合的成员。

有三个二元运算符作用于位集:|&^。它们分别代表 集合并 A B集合交 A B,和 对称差 AΔB

表 5.5. 位运算符的效果
Bit op Value Hex b[15] ... b[0] Set op Set
V 65535 0xFFFF 1111111111111111 {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
A 240 0x00F0 0000000011110000 {4, 5, 6, 7}
~A 65295 0xFF0F 1111111100001111 V ** A {0, 1, 2, 3, 8, 9, 10, 11, 12, 13, 14, 15}
-A 65296 0xFF10 1111111100010000 {4, 8, 9, 10, 11, 12, 13, 14, 15}
B 287 0x011F 0000000100011111 {0, 1, 2, 3, 4, 8}
A|B 511 0x01FF 0000000111111111 A B {0, 1, 2, 3, 4, 5, 6, 7, 8}
A&B 16 0x0010 0000000000010000 A B
A^B 495 0x01EF 0000000111101111 AΔB {0, 1, 2, 3, 5, 6, 7, 8}

例如,让我们选择 A = 240,代表集合 {4, 5, 6, 7},以及 B = 287,位集 {0, 1, 2, 3, 4, 8};参见 表 5.5。对于这些操作的结果,基础集的总大小以及因此的精度 p 不需要。至于算术运算符,有相应的赋值运算符 &=|=^=。^([[Exs 5]])^([[Exs 6]])^([[Exs 7]])^([[Exs 8]])

^([Exs 5])

证明 A ** B 可以通过 A - (A&B) 来计算。

^([Exs 6])

证明 V + 1 等于 0。

^([Exs 7])

证明 A^B 等价于 (A - (A&B)) + (B - (A&B)) 和 A + B - 2*(A&B).

^([Exs 8])

证明 A|B 等价于 A + B - (A&B).

还有另一个操作符作用于值的位:补码操作符 ~。补码 ~A 将具有值 65295,并对应于集合 {0, 1, 2, 3, 8, 9, 10, 11, 12, 13, 14, 15}。这个位补码始终取决于类型的精度 p。^([[Exs 9]])^([[Exs 10]])

^([Exs 9])

证明 ~B 可以通过 V - B 来计算。

^([Exs 10])

证明 -B = ~B + 1.

所有这些运算符都可以用标识符来写:bitorbitandxoror_eqand_eqxor_eqcompl,如果你包含头文件 iso646.h

<iso646.h>

位集的典型用法是用于 标志,这些变量控制程序的一些设置:

enum corvid { magpie, raven, jay, chough, corvid_num, };
#define FLOCK_MAGPIE  1U
#define FLOCK_RAVEN 2U
#define FLOCK_JAY     4U
#define FLOCK_CHOUGH  8U
#define FLOCK_EMPTY   0U
#define FLOCK_FULL   15U

int **main**(void) {
  unsigned flock = FLOCK_EMPTY;

  ... 

  if (something) flock |= FLOCK_JAY;
  ...

  if (flock&FLOCK_CHOUGH)
    do_something_chough_specific(flock);
}

在这里,每种乌鸦类型的常量都是 2 的幂,因此它们在二进制表示中恰好有一个位被设置。乌鸦群成员可以通过以下运算符处理:|= 向乌鸦群添加乌鸦,& 与其中一个常量一起使用来测试特定的乌鸦是否存在。

注意运算符 &&&||| 之间的相似性:如果我们将 unsigned 的每个位 b**[i] 视为一个真值,& 同时执行其参数所有位的 逻辑与。这是一个很好的类比,应该有助于你记住这些运算符的特定拼写。另一方面,请记住,运算符 ||&& 有短路求值,所以一定要清楚地区分它们。

5.7.3. 位移运算符

下一个运算符集在无符号值的解释(作为数字和位集)之间建立了一座桥梁。左移操作 << 对应于数值乘以相应的 2 的幂。例如,对于 A = 240,集合 {4, 5, 6, 7},A << 2 是 240 · 2² = 240 · 4 = 960,这代表集合 {6, 7, 8, 9}。结果中不适合该类型二进制表示的位被简单地省略。在我们的例子中,A << 9 将对应于集合 {13, 14, 15, 16}(和值 122880),但由于没有位 16,结果集合是 {13, 14, 15},值 57344。

因此,对于这种位移操作,精度 p 仍然很重要。不仅是不适合的位被丢弃,而且它还限制了右操作数的可能值:

Takeaway 5.53

位移操作的第二操作数必须小于精度。

存在一个类似的右移操作 >>,它将二进制表示向低有效位移动。类似地,这对应于除以 2 的幂的整数除法。对于小于或等于位移值的位,结果中省略这些位。注意,对于这个操作,类型的精度并不重要.^([[Exs 11]])

^([Exs 11])

证明在操作 x>>n 中“丢失”的位对应于余数 x % (1ULL << n`)。

  • 再次,也存在相应的赋值运算符 <<=>>=

  • 左移运算符 << 的主要用途是指定 2 的幂。在我们的例子中,我们现在可以替换掉 #define

#define FLOCK_MAGPIE (1U << magpie)
#define FLOCK_RAVEN    (1U << raven)
#define FLOCK_JAY      (1U << jay)
#define FLOCK_CHOUGH   (1U << chough)
#define FLOCK_EMPTY     0U
#define FLOCK_FULL    ((1U << corvid_num)-1)

这使得示例对枚举的变化更加健壮。

5.7.4. 布尔值

C 中的布尔数据类型也被视为无符号类型。记住,它只有 0 和 1 两个值,所以没有负值。为了与古老的程序保持向后兼容,基本类型被称为_Bool。名称 bool 以及常量falsetrue只有通过包含stdbool.h才能使用。除非你真的需要维护一个非常旧的<stdbool.h>代码库,否则你应该使用后者。

图片

stdbool.h

将布尔视为无符号类型是对概念的拉伸。将该类型的变量赋值不遵循要点 4.6 中的模数规则,而是遵循布尔值的特殊规则(要点 3.1)。

你可能很少需要布尔变量。它们只有在你想确保赋值后值总是减少到falsetrue时才有用。C 的早期版本没有布尔类型,许多经验丰富的 C 程序员仍然不使用它。

5.7.5. 带符号整数

带符号类型比无符号类型要复杂一些。C 实现必须决定两个问题:

  • 在算术溢出时会发生什么?

  • 带符号类型的符号如何表示?

带符号和无符号类型根据它们的整数等级成对出现,有两个显著的例外来自表 5.1:char 和 bool。带符号类型的二进制表示受上面看到的包含图的约束。

要点 5.54

正数与符号无关地表示。

或者,换句话说,正数在带符号类型中的表示与相应的无符号类型相同。这就是为什么任何整数类型的最大值都可以如此容易地表示出来(要点 5.51):带符号类型也有一个精度,p,它决定了类型的最大值。

标准规定的下一件事是,带符号类型有一个额外的位,即 符号位**^C。如果是 0,则表示正数;如果是 1,则表示负数。不幸的是,关于如何使用这样的符号位来获得负数有不同的概念。C 允许三种不同的 符号表示**^C

  • 符号和幅度**^C

  • 一补码**^C

  • 二进制补码**^C

现在的前两种可能只有历史或异国情调的相关性:对于符号和幅度,幅度被视为正值,符号位简单地指定存在负号。一补码取相应的正值并对所有位进行补码。这两种表示都有缺点,即两个值评估为 0:有一个正 0 和一个负 0.^([10])

^{(10)}

由于这两个概念在现代架构中已经完全废弃,因此正在努力在 C 标准的下一个修订版中将其删除。

图片

在现代平台上常用的是二进制补码表示法。它执行与无符号类型相同的算术,但无符号值的上半部分(高位为 1 的值)被解释为负数。以下两个函数基本上是解释无符号值作为有符号值所需的所有内容:

bool is_negative(unsigned a) {
  unsigned const int_max = **UINT_MAX**/2;
  return a > int_max;
}
bool is_signed_less(unsigned a, unsigned b) {
  if (is_negative(b) && !is_negative(a)) return **false**;
  else return a < b;
}

表 5.6 展示了如何构建我们示例值 240 的负数。对于无符号类型,-A 可以计算为 ~A + 1。^([[Exs 12]])^([[Exs 13]])^([[Exs 14]]) 二进制补码表示法对有符号类型和无符号类型执行完全相同的位操作。它只 解释 高位为负的表示。

^([Exs 12])

证明对于无符号算术,A + ~A 是最大值。

^([Exs 13])

证明对于无符号算术,A + ~A 是 -1.

^([Exs 14])

证明对于无符号算术,A + (~A + 1) == 0.

表 5.6. 16 位无符号整数类型的取反
Op Value b[15] ... b[0]
A 240 0000000011110000
~A 65295 1111111100001111
+1 65295 0000000000000001
-A 65296 1111111100010000

以这种方式完成,有符号整数算术将再次表现得相当好。不幸的是,有一个陷阱使得有符号算术的结果难以预测:溢出。在无符号值被强制回绕的地方,有符号溢出的行为是 未定义**^C。以下两个循环看起来很相似:

   for (unsigned i = 1; i; ++i) do_something();
   for (  signed i = 1; i; ++i) do_something();

我们知道第一次循环会发生什么:计数器增加到UINT_MAX,然后回绕到 0。所有这些可能需要一些时间,但在UINT_MAX-1次迭代之后,循环停止,因为 i 将到达 0。

对于第二次循环,看起来很相似。但是,因为这里的溢出行为是未定义的,编译器被允许 假装 它永远不会发生。由于它也知道起始值是正的,它可能假设只要程序有定义的行为,i 就永远不会是负的或 0。as-if 规则 (取得成果 5.8) 允许它优化第二次循环为

   while (**true**) do_something();

那是对的,一个 无限循环

取得成果 5.55

一旦抽象状态机达到一个未定义的状态,就无法再对执行的延续做出任何假设。

不仅如此,编译器被允许对操作本身做它想做的事情(“未定义?那我们就定义它”),但它也可以假设它永远不会达到这样的状态,并据此得出结论。

通常,一个达到未定义状态的程序被称为“具有”或“显示” 未定义行为。这种说法有点不幸;在许多这样的情况下,程序并不“显示”任何明显的异常迹象。相反,一些不好的事情正在发生,你可能很长时间都不会注意到。

取得要点 5.56

避免所有操作未定义行为是你的责任

使事情变得更糟的是,在某些 平台 上,使用某些 标准编译器选项,编译看起来似乎是正确的。由于行为是未定义的,在这样的平台上,有符号整数算术可能基本上与无符号整数相同。但是改变平台、编译器或某些选项可能会改变这一点。突然之间,你多年来一直运行的程序会突然崩溃。

基本上,我们到本章为止所讨论的内容总是具有明确的行为,因此抽象状态机总是处于一个明确的状态。有符号算术改变了这一点,所以除非你需要它,否则避免使用它。我们说,如果一个程序在其正常结束之前突然终止,那么它执行了一个 trap^C*(或者简称为 traps)。

取得要点 5.57

有符号算术可能会产生严重的陷阱

对于有符号类型,可能已经溢出的一个操作是取反。我们已经看到 INT_MAX 除了符号位外所有位都设置为 1。INT_MIN 然后具有“下一个”表示:符号位设置为 1,其他所有值设置为 0。相应的值不是 -INT_MAX.^([[Exs 15]])

^([Exs 15])

证明 INT_MIN+INT_MAX 等于 –1。

取得要点 5.58

在二进制补码表示法中, INT_MIN < -INT_MAX.

或者,用另一种说法,在二进制补码表示法中,正值 -INT_MIN 超出了范围,因为操作的结果 大于 INT_MAX

取得要点 5.59

有符号算术的取反可能会溢出

对于有符号类型,位操作与二进制表示一起工作。因此,位操作的结果特别依赖于符号表示。实际上,位操作甚至允许我们检测符号表示:

char const* sign_rep[4] =
  {
    [1] = "sign and magnitude",
    [2] = "ones' complement",
    [3] = "two's complement",
    [0] = "weird",
  };
enum { sign_magic = -1&3, };
...
**printf**("Sign representation: %s.\n", sign_rep[sign_magic]); 

那么位移操作就变得非常混乱。对于负值,这种操作的语义并不明确。

取得要点 5.60

使用无符号类型进行位操作

5.7.6. 固定宽度整数类型

我们迄今为止看到的整数类型的精度可以通过使用 limits.h 中的宏间接检查,例如 UINT_MAXLONG_MIN。C 标准只为我们提供了它们的最小精度。对于无符号类型,这些是

<limits.h>

类型
---
bool
无符号字符
无符号短整型
unsigned
无符号长整型
无符号长长整型

在通常情况下,这些保证应该给你足够的信息;但在某些技术约束下,这些保证可能不足,或者你可能想强调特定的精度。这可能是在你想使用无符号量来表示已知最大大小的位集合时的情况。如果你知道 32 位足以满足你的集合,根据你的平台,你可能想选择unsignedunsigned long来表示它。

C 标准在stdint.h中为精确宽度整数类型提供了名称。正如名称所示,它们具有精确规定的“宽度”,对于提供的无符号类型,保证其与精度相同。

<stdint.h>

要点 5.61

如果提供了类型 uintN_t ,它是一个具有精确 N 位宽度和精度的无符号整数类型

要点 5.62

如果提供了类型 intN_t ,它是有符号的,具有二进制补码表示,具有精确的 N 位宽度和N* – 1 的精度*。

这些类型中没有任何一个是保证存在的,但对于一组方便的 2 的幂,如果存在具有相应属性的类型,则必须提供typedef

要点 5.63

如果存在具有所需属性的 N = 8,16,32 64的值的类型,则必须提供类型 uintN_t *intN_t**。

现在,平台通常提供uint8_tuint16_tuint32_tuint64_t无符号类型以及int8_tint16_tint32_tint64_t有符号类型。它们的存在和界限可以通过无符号类型的宏UINT8_MAX,...,UINT64_MAX和分别对应的有符号类型的宏INT8_MININT8_MAX,...,INT64_MININT64_MAX来测试.^([[Exs 16]])

^([Exs 16])

如果它们存在,所有这些宏的值都由类型的属性规定。将这些值视为N的封闭公式。

为了编码请求类型的文字,有宏UINT8_C,...,UINT64_C,和INT8_C,...,INT64_C,分别。例如,在uint64_tunsigned long的平台,INT64_C(1)展开为 1UL。

要点 5.64

对于提供的任何固定宽度类型,还提供了 _MIN (仅限有符号),最大 _MAX,和文字 _C

由于我们无法知道此类固定宽度类型背后的类型,猜测用于printf和朋友的正确格式说明符将很困难。头文件inttypes.h为我们提供了相应的宏。例如,对于N = 64,我们提供了PRId64PRIi64PRIo64PRIu64PRIx64,和PRIX64,分别对应printf格式"%d","%i","%o","%u","%x"和"%X":

<inttypes.h>

**uint32_t** n = 78;
**int64_t** max = (-**UINT64_C**(1))>>1;    // Same value as INT64_MAX 
**printf**("n is %" **PRIu32** ", and max is %" **PRId64** "\n", n, max);

如您所见,这些宏扩展为字符串字面量,这些字符串字面量与其他字符串字面量组合成格式字符串。这当然不是 C 编码之美竞赛的最佳候选人。

5.7.7. 浮点数据

而整数接近数学概念中的 (无符号)或 (有符号),浮点类型接近 (非复数)或 (复数)。它们与这些数学概念的不同之处有两点。首先,存在一个大小限制,即可以表示的内容。这与我们所看到的整数类型类似。例如,包含文件 float.h 有常数 DBL_MINDBL_MAX,为我们提供了 double 的最小值和最大值。但请注意,这里的 DBL_MIN 是严格大于 0.0 的最小数;最小的负 double 值是 -DBL_MAX

<float.h>

但实数()在我们要在物理系统上表示它们时还有另一个困难:它们可以有无限扩展,例如值 ,它在十进制表示中有无限重复的数字 3,或者π的值,它是“超越”的,因此在任何表示中都有无限扩展并且以任何方式都不重复。

C 和其他编程语言通过截断扩展来处理这些困难。扩展被截断的位置是“浮动”(因此得名)并且取决于所讨论的数字的大小。

在一种简化的观点中,浮点值是从以下值计算得出的:

s 符号(±1)
e 指数,一个整数
f[1], . . . , f**[p] 值 0 或 1,尾数位

对于指数,我们有 e**[min]ee**[max]p,尾数的位数,被称为精度。浮点值由以下公式给出:

图片

peminemax 是类型相关的,因此在每个数字中并未明确表示。它们可以通过宏如 DBL_MANT_DIG(对于 p,通常是 53)DBL_MIN_EXPe**[min],-1021)和 DBL_MAX_EXPe**[max],1024)来获得。

例如,如果我们有一个数,其 s = –1,e = –2,f[1] = 1,f[2] = 0,和 f[2] = 1,其值是

图片

这对应于十进制值 -0.15625。从该计算中,我们还可以看到浮点值总是可以表示为一个分母中有某个 2 的幂次的分数。^([[例 17]])

^([例 17])

证明所有可表示的浮点值,其中 e > p,都是 2^(e–p) 的倍数。

在这样的浮点表示中要记住的一个重要事情是,值在中间计算过程中可能会被截断。

摘要 5.65

浮点运算既不是 结合律 ,也不是 交换律 ,也不是 分配律 *。

因此,它们失去了我们在做纯数学时习惯的所有美好的代数属性。由此产生的问题,如果我们操作具有非常不同数量级的值时尤其明显。[[例 18]]) 例如,将一个非常小的浮点值 x 与小于 – p 的指数相加到值 y > 1 上,只会再次返回 y。因此,在没有进一步调查的情况下,很难断言两个计算结果是否“相同”。这类调查通常是尖端研究问题,因此我们无法期望能够断言相等或不相等。我们只能告诉结果“接近”。

^([例 18])

打印以下表达式的结果:1.0E-13 + 1.0E-13(1.0E-13 + (1.0E-13 + 1.0)) - 1.0.

Takeaway 5.66

永远不要比较浮点数的相等性

<tgmath.h>

复数类型的表示简单且与相应实浮点类型的数组相同。要访问复数的实部和虚部,tgmath.h头文件中也提供了两个类型通用的宏:crealcimag。对于三种复数类型中的任何一种类型 z,我们有 z == creal(z) + cimag(z)*I.^([11])

¹¹

我们将在第 8.1.2 节中了解这样的函数式宏。

概述

  • C 程序在抽象状态机上运行,该状态机主要独立于启动它的特定计算机。

  • 所有基本 C 类型都是数字类型,但并非所有这些类型都可以直接用于算术。

  • 值具有类型和二进制表示。

  • 当需要时,值类型会隐式转换为适合它们被使用的特定位置的需求。

  • 变量在使用前必须显式初始化。

  • 整数运算在没有溢出的情况下给出精确值。

  • 浮点运算只给出近似结果,这些结果在一定的二进制位数后截断。

第六章. 导出数据类型

本章涵盖

  • 将对象分组到数组中

  • 使用指针作为不透明类型

  • 将对象组合到结构体中

  • 使用typedef为类型赋予新名称

C 语言中的所有其他数据类型都源自我们已知的基类型。有四种推导数据类型的方法。其中两种被称为聚合数据类型,因为它们组合了一个或多个其他数据类型的多个实例:

  • 数组: 这些组合了具有相同基本类型的项 (第 6.1 节)。

  • 结构体: 这些组合了可能具有不同基本类型的项 (第 6.3 节)。

推导数据类型的两种其他策略更为复杂:

  • 指针: 指向内存中对象的实体。指针是涉及最复杂的概念,我们将推迟对它们的全面讨论,直到 第十一章。在这里,在 第 6.2 节 中,我们只将它们作为不透明数据类型进行讨论,甚至不提及其真正履行的目的。

  • 联合体: 这些在相同内存位置上覆盖不同基本类型的项。联合体需要更深入地理解 C 的内存模型,并且在程序员日常生活中的用途不大,因此它们只会在 第 12.2 节 中稍后介绍。

有一种第五种策略引入了类型的新名称:typedef (第 6.4 节)。与前面的四种不同,这并不在 C 的类型系统中创建一个新类型,而只是为现有类型创建一个新名称。因此,它类似于使用 #define 定义宏的定义;因此选择了这个关键字来表示这个特性。

6.1. 数组

数组允许我们将相同类型的对象组合成一个封装的对象。我们将在 第十一章 中看到指针类型,但许多初来乍到的 C 语言程序员对数组和指针感到困惑。这是完全正常的:在 C 语言中,数组和指针密切相关,要解释它们,我们面临一个 鸡生蛋还是蛋生鸡 的问题:在许多情况下,数组 看起来像 指针,而指针指向数组对象。我们选择了可能不寻常的介绍顺序:我们将从数组开始,尽可能长时间地停留在数组上,然后再介绍指针。这可能会让一些人觉得“不对”,但请记住,这里所说的每一件事都必须基于 as-if 规则 (第 5.8 节的要点) 来理解:我们将首先以与 C 语言对抽象状态机的假设一致的方式描述数组。

6.1 节要点

数组不是指针。

之后,我们将看到这两个概念是如何相互关联的,但此刻重要的是要无偏见地阅读本章关于数组的内容;否则,你将推迟你对 C 语言更好理解的提升。

6.1.1. 数组声明

我们已经看到了如何声明数组:通过在另一个声明后放置类似 [**N] 的内容。例如:

   double a[4];
   signed b[N];

在这里,a 包含 4 个类型为 double 的子对象,而 b 包含 N 个类型为 signed 的对象。我们用以下这样的图表来可视化数组,其中包含一系列其基本类型的框:

这里的点号 表示两个框之间可能存在未知数量的类似项。

组成数组的类型本身又可以是数组,形成 多维数组**^C。由于 [] 左结合,这些声明的可读性变得有点困难。以下两个声明声明了完全相同类型的变量:

   double C[M][N];
   double (D[M])[N];

C 和 D 都是数组类型的 M 对象double[N]。这意味着我们必须从内向外读取嵌套数组声明来描述其结构:

我们还看到了如何通过一对[]访问和初始化数组元素,在先前的例子中,a[0]是一个double类型的对象,可以在我们想要使用的地方使用,例如,一个简单的变量。正如我们所看到的,C[0]本身是一个数组,所以C[0][0],它与(C[0])[0]相同,也是一个double类型的对象。

初始化器可以使用指定初始化器(也使用[]表示法)来选择初始化应用的具体位置。示例代码列表 5.1 中包含这样的初始化器。在开发过程中,指定初始化器有助于使我们的代码能够抵御数组大小或位置的小变化。

6.1.2. 数组操作

数组实际上只是与我们之前看到的类型不同的对象。

Takeaway 6.2

数组在条件中的评估结果为 true*。

这个事实来自于数组降级操作,我们将在后面看到。另一个重要的特性是我们不能像其他对象一样评估数组。

Takeaway 6.3

存在数组对象,但没有数组值。

所以数组不能作为表 4.1 中值运算符的操作数,也没有在数组(自身)上声明算术运算。

Takeaway 6.4

数组不能进行比较。

数组也不能出现在表 4.2 中对象运算符的值的一侧。大多数对象运算符同样被排除在外,不能将数组作为对象操作数,要么是因为它们假设了算术运算,要么是因为它们有第二个值操作数,这个操作数也必须是数组。

Takeaway 6.5

数组不能被赋值。

从表 4.2 中,我们还知道只剩下四个运算符可以作用于数组作为对象运算符。我们知道运算符[]。^([1]) 将在后面介绍数组降级操作、地址运算符&sizeof运算符。

¹

关于数组和[]的真正 C 语言术语故事要复杂一些。让我们将as-if规则(总结 5.8)应用到我们的解释中。所有 C 程序都表现得好像[]是直接应用于数组对象。

6.1.3. 数组长度

数组分为两类:固定长度数组**C*(FLAs)和*可变长度数组**C(VLAs)。前者是 C 语言从开始就存在的概念;这个特性与其他许多编程语言共享。后者是在 C99 中引入的,并且相对而言是 C 语言特有的,并且对其使用有一些限制。

Takeaway 6.6

VLAs 不能有初始化器。

Takeaway 6.7

VLAs 不能在函数外部声明。

因此,让我们从另一端开始,看看哪些数组实际上是 FLA,这样它们就不会受到这些限制。

Takeaway 6.8

FLA 的长度由一个整数常量表达式 (ICE) 或初始化器确定。

对于这些选择中的第一个,长度在编译时通过 ICE(在 第 5.6.2 节 中介绍)是已知的。对于 ICE 没有类型限制:任何整数类型都可以。

Takeaway 6.9

数组长度指定必须是严格正数。

另一个重要的特殊情况会导致 FLA:当没有任何长度指定时。如果 [] 被留空,数组的长度将根据其初始化器(如果有)确定:

   double E[] = { [3] = 42.0,  [2] = 37.0, };
   double F[] = { 22.0,  17.0, 1, 0.5, };

在这里,E 和 F 都属于类型 double[4]。由于这种初始化器的结构可以在编译时确定,而不必知道项目值,因此数组仍然是 FLA。

所有其他数组变量声明都会导致 VLAs。

Takeaway 6.10

长度不是整数常量表达式的数组是 VLA。

数组的长度可以使用 sizeof 运算符计算。该运算符提供了任何对象的大小,^([2]) 因此可以使用简单的除法计算数组的长度.^([3])

²

之后,我们将看到这些大小的度量单位是什么。

³

注意,sizeof 运算符有两种不同的语法形式。如果应用于对象,如这里所示,它不需要括号,但如果我们将其应用于类型,则需要括号。

Takeaway 6.11

数组 A 的长度是 (sizeof* A)/(sizeof A[0])**.

即,它是数组对象的总大小除以数组元素的大小。

6.1.4. 数组作为参数

对于数组作为函数参数的情况,还有一个特殊情况。正如我们在 printf 原型中看到的那样,这些参数可能有空的 []。由于此类参数不可能有初始化器,因此无法确定数组维度。

Takeaway 6.12

函数数组参数的内维丢失。

Takeaway 6.13

不要在数组参数上使用 sizeof 运算符传递给函数。

数组参数甚至更加奇特,因为我们不能产生 数组值 (总结 6.3),数组参数不能按值传递,因此作为这样的数组参数就没有太多意义。

Takeaway 6.14

数组参数的行为就像数组是 通过引用传递^C.

以 列表 6.1 中的示例为例。

列表 6.1. 带有数组参数的函数
#include <stdio.h>

void swap_double(double a[static 2]) {
  double tmp = a[0];
  a[0] = a[1];
  a[1] = tmp;
}
int **main**(void) {
  double A[2] = { 1.0, 2.0, };
  swap_double(A);
  **printf**("A[0] = %g, A[1] = %g\n", A[0], A[1]);
}

在这里,swap_double(A) 将直接作用于数组 A,而不是副本。因此,程序将交换 A 的两个元素的值。

线性代数

数组用于解决的一些最重要的问题源于线性代数。

你现在能编写执行向量到向量或矩阵到向量乘积的函数吗?

关于高斯消元法或矩阵求逆的迭代算法呢?

6.1.5. 字符串是特殊的

我们已经遇到过一种特殊的数组,它与其他数组不同,甚至有字面量:字符串**^C

摘要 6.15

字符串是一个 *0*-终止的 char 数组。

也就是说,像 "hello" 这样的字符串总是比可见的元素多一个,它包含值 0,所以这里的数组长度为 6。

像所有数组一样,字符串不能被赋值,但可以从字符串字面量初始化:

   char jay0[] = "jay";
   char jay1[] = { "jay" };
   char jay2[] = { 'j', 'a', 'y', 0, };
   char jay3[4] = { 'j', 'a', 'y', }; 

这些都是等效的声明。请注意,并非所有 char 数组都是字符串,例如

char jay4[3] = { 'j', 'a', 'y', };
char jay5[3] = "jay";

这两个都在 'y' 字符后截断,因此不是 0-终止的。

我们简要地看到了字符串在整数类型中的基本类型 char。它是一种窄整数类型,可以用来编码所有 基本字符集**^C 中的字符。这个字符集包含了拉丁字母、阿拉伯数字以及我们在 C 语言中使用的标点符号。它通常不包含特殊字符(例如,äá),以及来自完全不同的书写系统的字符)。

现在,绝大多数平台使用美国信息交换标准代码(ASCII)来编码 char 类型的字符。只要我们停留在基本字符集,我们就不必了解特定的编码方式:所有操作都在 C 语言及其标准库中完成,这些库透明地使用这种编码。

<string.h>

为了处理 char 数组和字符串,标准库中提供了一系列函数,这些函数包含在头文件 string.h 中。那些只需要数组参数的函数以 mem 开头命名,而那些除了需要数组参数外还需要参数是字符串的函数以 str 开头命名。列表 6.2 使用了一些接下来将要描述的函数。

列表 6.2. 使用一些字符串函数
 **1**    #include <string.h>
 **2**    #include <stdio.h>
 **3**    int **main**(int argc, char* argv[argc+1]) {
 **4**      **size_t** const len = **strlen**(argv[0]); // Computes the length 
 **5**      char name[len+1];                   // Creates a VLA 
 **6**                                          // Ensures a place for 0 
 **7**      **memcpy**(name, argv[0], len);         // Copies the name 
 **8**      name[len] = 0;                      // Ensures a 0 character 
 **9**      if (!**strcmp**(name, argv[0])) {
**10**       **printf**("program name \"%s\" successfully copied\n",
**11**              name);
**12**     } else {
**13**       **printf**("copying %s leads to different string %s\n",
**14**              argv[0], name);
**15**     }
**16**   }

操作 char 数组的函数如下:

  • memcpy(target, source, len) 可以用来将一个数组复制到另一个数组。这些数组必须是不同的数组。要复制的 char 字符数必须作为第三个参数 len 提供。

  • memcmp(s0, s1, len) 按字典顺序比较两个数组。也就是说,它首先扫描两个数组中恰好相等的初始段,然后返回两个不同第一个字符之间的差异。如果在 len 范围内没有找到不同的元素,则返回 0

  • memchr(s, c, len) 在数组 s 中搜索字符 c 的出现。

接下来是字符串函数:

  • strlen(s) 返回字符串 s 的长度。这仅仅是第一个 0 字符的位置,而不是数组的长度。确保 s 确实是一个字符串:它是 0-终止的。

  • strcpy(target, source)与 **memcpy** 的工作方式类似。它只复制源字符串的长度,因此不需要 len 参数。再次强调,源必须以0` 结尾。此外,目标必须足够大,可以容纳复制的内容。

  • strcmp(s0, s1) 按字典顺序比较两个数组,类似于 memcmp,但可能不会考虑某些语言特性。比较在 s0 或 s1 中遇到的第一个 0 字符处停止。再次强调,两个参数都必须以 0 结尾。

  • strcoll(s0, s1) 按字典顺序比较两个数组,并尊重语言特定的环境设置。我们将在第 8.6 节中学习如何正确设置它 section 8.6。

  • strchr(s, c)memchr 类似,只是字符串 s 必须以 0 结尾。

  • strspn(s0, s1) 返回 s0 中由 s1 中也出现的字符组成的初始段长度。

  • strcspn(s0, s1) 返回 s0 中由 s1 中不出现的字符组成的初始段长度。

取得成果 6.16

使用非字符串的字符串函数会有未定义的行为.

在现实生活中,此类误用的常见症状可能包括:

  • 由于 strlen 或类似扫描函数没有遇到 0 字符,所以运行时间较长

  • 由于此类函数尝试访问数组对象边界之外的元素,会发生段错误

  • 数据似乎随机损坏,因为函数在它们不应该写入数据的地方写入数据

换句话说,要小心谨慎,确保所有字符串确实都是字符串。如果你知道字符数组的长度,但不知道它是否以 0 结尾,可以使用 memchr 和指针算术(见第十一章)作为 strlen 的安全替代品。类似地,如果一个字符数组不是字符串,最好使用 memcpy 来复制它.^([[Exs 1]])

^([Exs 1])

使用 memchrmemcmp 来实现一个带有边界检查的 strcmp 版本。

在到目前为止的讨论中,我一直隐瞒了一个重要的细节:函数的原型。对于字符串函数,它们可以写成

**size_t** **strlen**(char const s[static 1]);
char* **strcpy**(char target[static 1], char const source[static 1]);
signed **strcmp**(char const s0[static 1], char const s1[static 1]);
signed **strcoll**(char const s0[static 1], char const s1[static 1]);
char* **strchr**(const char s[static 1], int c);
**size_t** **strspn**(const char s1[static 1], const char s2[static 1]);
**size_t** **strcspn**(const char s1[static 1], const char s2[static 1]);

除了 strcpystrchr 的奇特返回类型外,这看起来是合理的。参数数组是未知长度的数组,所以 [static 1]s 对应于至少包含一个 char 的数组。strlenstrspnstrcspn 将返回一个大小,而 strcmp 将根据参数的排序顺序返回负值、0 或正值。

当我们查看数组函数的声明时,画面变暗了:

void* **memcpy**(void* target, void const* source, **size_t** len);
signed **memcmp**(void const* s0, void const* s1, **size_t** len);
void* **memchr**(const void *s, int c, **size_t** n);

你缺少关于指定为void*的实体的知识。这些是指向未知类型对象的指针。只有在第 2 级、第十一章中,我们才会看到为什么以及如何出现这些关于指针和void类型的新概念。

邻接矩阵

G 的邻接矩阵是一个矩阵 A,它在元素 A[i][j]中持有值 truefalse,如果从节点 i 到节点 j 有弧。

在这一点上,你能否使用邻接矩阵在图 G 中进行广度优先搜索?你能找到连通分量吗?你能找到生成树吗?

最短路径

将图 G 的邻接矩阵的概念扩展到距离矩阵 D,该矩阵在从点 i 到点 j 的距离时持有。用非常大的值,例如SIZE_MAX标记直接弧的缺失。

你能否找到作为输入的两个节点 x 和 y 之间的最短路径?

6.2. 指针作为不透明类型

我们现在已经看到指针的概念在几个地方出现,特别是在作为 void* 参数和返回类型,以及作为 char const*const 来操作字符串字面量的引用。它们的主要属性是它们不直接包含我们感兴趣的信息:相反,它们引用,或 指向 数据。C 语言中指针的语法总是具有奇特的 *

   char const*const p2string = "some text";

它可以像这样可视化:

图片

将此与之前包含我们想要表示的字符串所有字符的数组 jay0 进行比较:

   char const jay0 [] = "jay";

图片

在这次探索中,我们只需要了解指针的一些简单属性。指针的二进制表示完全取决于平台,与我们无关。

取得第 6.17 条经验教训

指针是不透明的对象

这意味着我们只能通过 C 语言允许的操作来处理指针。正如我所说,大部分这些操作将在以后介绍;在我们的第一次尝试中,我们只需要初始化、赋值和评估。

指针的一个特定属性,使其与其他变量区分开来的是其状态。

取得第 6.18 条经验教训

指针是有效的、空或不确定的

例如,我们的变量 p2string 总是有效的,因为它指向字符串字面量 "some text",并且由于第二个 const,这种关联永远不能改变。

任何指针类型的空状态对应于我们熟悉的老朋友 0,有时也被称为其别名 false

取得第 6.19 条经验教训

使用 0 进行初始化或赋值使指针变为空

以以下内容为例:

   char const*const p2nothing = 0;

我们像这样可视化这种特殊情况:

图片

注意,这与指向空字符串不同:

   char const*const p2empty = "";

图片

通常,我们将处于空状态的指针称为 空指针**^C。令人惊讶的是,处理空指针实际上是一个特性。

要点 6.20

在逻辑表达式中,指针如果为 null,则评估为false

注意,这样的测试无法区分有效的指针和不确定的指针。所以,指针的“真正”的“坏”状态是不确定的,因为这种状态是不可观察的。

要点 6.21

不确定的指针会导致未定义的行为。

一个不确定指针的例子可能如下所示:

   char const*const p2invalid;

图片

因为它未初始化,其状态是不确定的,任何使用它都会对你造成伤害,并使你的程序处于未定义状态(要点 5.55)。因此,如果我们不能确保指针是有效的,我们必须至少确保它被设置为 null。

要点 6.22

始终初始化指针。

6.3. 结构体

正如我们所见,数组将相同基类型的多个对象组合成更大的对象。在我们想要组合具有第一、第二……元素概念的信息时,这完全合理。如果这不是情况,或者如果我们必须组合不同类型的对象,那么通过关键字struct引入的结构体就派上用场了。

作为第一个例子,让我们回顾一下第 5.6.2 节中的乌鸦。在那里,我们使用枚举类型的一个技巧来跟踪我们对数组名称各个元素的解释。C 结构体通过为聚合中的所谓成员(或字段)命名,提供了一种更系统的方法:

struct birdStruct {
  char const* jay;
  char const* magpie;
  char const* raven;
  char const* chough;
};
struct birdStruct const aName = {
  .chough = "Henry",
  .raven = "Lissy",
  .magpie = "Frau",
  .jay = "Joe",
};

即,从第 1 行到第 6 行,我们有新类型的声明,表示为struct birdStruct。这个结构体有四个成员,其声明看起来与正常的变量声明完全一样。所以,我们不是声明四个绑定在一起数组的元素,而是为不同的成员命名并声明它们的类型。这种结构体类型的声明只解释了类型;它还不是该类型对象的声明,更不是该对象的定义。

然后,从第 7 行开始,我们声明并定义了一个新类型的变量(称为 aName)。在初始化和后续使用中,使用带点的符号(.)指定各个成员。与第 5.6.1 节中的bird[raven]不同,对于数组,我们使用aName.raven来表示结构体:

图片

请注意,在这个例子中,各个成员再次只引用字符串。例如,成员aName.magpie引用一个位于盒子外部的实体“Frau”,并且不被认为是struct本身的一部分。

现在,作为第二个例子,让我们看看组织时间戳的方法。日历时间是一种复杂的计数方式,按年、月、日、分和秒计算;不同的时间段,如月份或年份,可以有不同长度,等等。组织此类数据的一种可能方式是数组:

typedef int calArray[9];

图片

使用这种数组类型将是不明确的:我们会将年份存储在元素 [0] 还是 [5] 中?为了避免歧义,我们还可以再次使用我们的技巧,使用 enum。但 C 标准选择了不同的方法。在 time.h 中,它使用了一个看起来类似的 struct

<time.h>

struct **tm** {
  int **tm_sec**;  // Seconds after the minute      [0, 60]
  int **tm_min**;  // Minutes after the hour        [0, 59]
  int **tm_hour**; // Hours since midnight          [0, 23]
  int **tm_mday**; // Day of the month              [1, 31]
  int **tm_mon**;  // Months since January          [0, 11]
  int **tm_year**; // Years since 1900
  int **tm_wday**; // Days since Sunday             [0, 6]
  int **tm_yday**; // Days since January            [0, 365]
  int **tm_isdst**;// Daylight Saving Time flag
};

这个 struct命名成员,例如 tm_sec 用于秒和 tm_year 用于年。编码日期,例如本文写作的日期

终端
**0**        > LC_TIME=C date -u
**1**      Wed Apr  3 10:00:47 UTC 2019

这相对简单:

yday.c
**29**      struct **tm** today = {
**30**        .**tm_year** = 2019-1900,
**31**        .**tm_mon**  = 4-1,
**32**        .**tm_mday** = 3,
**33**        .**tm_hour** = 10,
**34**        .**tm_min**  = 0,
**35**        .**tm_sec**  = 47,
**36**      };

这创建了一个类型为 struct tm 的变量,并使用适当的值初始化其成员。结构体中成员的顺序或位置通常并不重要:使用带点.的前缀成员名称就足以指定相应数据应该放置的位置。

pg_093_alt.jpg

注意,与 calArray 相比,这种对今天的可视化有一个额外的“框”。实际上,一个合适的 struct 类型创建了一个额外的抽象级别。这个 struct tm 是 C 类型系统中的一个合适类型。

访问结构体的成员就像这样简单,并且具有类似的 . 语法:

yday.c
**37**      **printf**("this year is %d, next year will be %d\n",
**38**             today.**tm_year**+1900, today.**tm_year**+1900+1);

一个成员的引用,如 today.tm_year,可以出现在表达式中,就像任何相同基类型的变量一样。

struct tm 中还有三个我们甚至没有在初始化器列表中提到的成员:tm_wdaytm_ydaytm_isdst。由于我们没有提到它们,它们被自动设置为 0

要点 6.23

省略 struct 初始化器将相应的成员强制设置为 *0**。

这种情况甚至可以极端到除了一个成员外,所有成员都被初始化。

要点 6.24

一个 struct 初始化器必须至少初始化一个成员

在之前(要点 5.37),我们看到了有一个默认初始化器适用于所有数据类型:{0}

因此,当我们像这里一样初始化 struct tm 时,数据结构是不一致的;tm_wdaytm_yday 成员没有与剩余成员值相对应的值。一个将此成员设置为与其他成员值一致的函数可能如下所示

yday.c
**19**   struct **tm** time_set_yday(struct **tm** t) {
**20**     // tm_mdays starts at 1. 
**21**     t.**tm_yday** += DAYS_BEFORE[t.**tm_mon**] + t.**tm_mday** - 1;
**22**     // Takes care of leap years 
**23**     if ((t.**tm_mon** > 1) && leapyear(t.**tm_year**+1900))
**24**       ++t.**tm_yday**;
**25**     return t;
**26**   }

它使用当前月份之前的天数、tm_mday 成员以及闰年的校正来计算年份中的天数。这个函数有一个在我们当前水平上很重要的特性:它只修改函数参数 t 的成员,而不是原始对象。

要点 6.25

struct 参数是通过值传递的。

为了跟踪变化,我们必须将函数的结果重新分配给原始变量:

yday.c
**39**      today = time_set_yday(today);

之后,我们将看到如何通过指针类型克服函数的这种限制,但我们还没有到达那里。这里我们看到,对于所有结构体类型,赋值运算符=是明确定义的。不幸的是,它的比较对应物并没有。

取得成果 6.26

结构体可以用=赋值,但不能用==!=比较。

代码清单 6.3 展示了使用struct tm的完整示例代码。它不包含历史性的struct tm的声明,因为这是通过标准头文件time.h提供的。如今,对于各个成员的类型可能的选择可能不同。但许多时候在 C 语言中,我们不得不坚持多年前做出的设计决策。

<time.h>

代码清单 6.3. 一个操作 struct tm的示例程序
 **1**   #include <**time**.h>
 **2**   #include <stdbool.h>
 **3**   #include <stdio.h>
 **4**
 **5**   bool leapyear(unsigned year) {
 **6**     /* All years that are divisible by 4 are leap years, 
 **7**        unless they start a new century, provided they 
 **8**        are not divisible by 400\. */ 
 **9**     return !(year % 4) && ((year % 100) || !(year % 400));
**10**   }
**11**
**12**   #define DAYS_BEFORE                             \
**13**   (int const[12]){                                \
**14**     [0] = 0, [1] = 31, [2] = 59, [3] = 90,        \
**15**     [4] = 120, [5] = 151, [6] = 181, [7] = 212,   \
**16**     [8] = 243, [9] = 273, [10] = 304, [11] = 334, \
**17**   }
**18**
**19**   struct **tm** time_set_yday(struct **tm** t) {
**20**     // tm_mdays starts at 1. 
**21**     t.**tm_yday** += DAYS_BEFORE[t.**tm_mon**] + t.**tm_mday** - 1;
**22**     // Takes care of leap years 
**23**     if ((t.**tm_mon** > 1) && leapyear(t.**tm_year**+1900))
**24**       ++t.**tm_yday**;
**25**     return t;
**26**   }
**27**
**28**   int **main**(void) {
**29**     struct **tm** today = {
**30**       .**tm_year** = 2019-1900,
**31**       .**tm_mon**  = 4-1,
**32**       .**tm_mday** = 3,
**33**       .**tm_hour** = 10,
**34**       .**tm_min**  = 0,
**35**       .**tm_sec**  = 47,
**36**     };
**37**     **printf**("this year is %d, next year will be %d\n",
**38**            today.**tm_year**+1900, today.**tm_year**+1900+1);
**39**     today = time_set_yday(today);
**40**     **printf**("day of the year is %d\n", today.**tm_yday**);
**41**   }
取得成果 6.27

结构体布局是一个重要的设计决策。

几年后,当所有使用它的现有代码几乎不可能适应新情况时,你可能会后悔自己的设计。

struct的另一个用途是将不同类型的对象组合在一个更大的封装对象中。同样,对于以纳秒精度操作时间,C 标准已经做出了这样的选择:

struct **timespec** {
  **time_t** **tv_sec**; // Whole seconds ≥0
  long  **tv_nsec**; // Nanoseconds   [0, 999999999] 
};

这里我们看到的是我们在表 5.2 中看到的用于秒的透明类型time_t,以及用于纳秒的long。^([4]) 再次,这种选择的原因是历史性的;如今,选择的数据类型可能略有不同。要计算两个struct timespec时间之间的差异,我们可以轻松定义一个函数。

很不幸,即使是time_t的语义在这里也有所不同。特别是,tv_sec可以用于算术运算。

虽然difftime函数是 C 标准的一部分,但这里的这种功能非常简单,并不基于平台特定的属性。因此,任何需要它的人都可以轻松实现。^([[Exs 2]])

^([Exs 2])

编写一个名为 timespec_diff 的函数,用于计算两个timespec值之间的差异。

除了 VLA 之外,任何数据类型都可以作为结构体的成员。因此,结构体也可以嵌套,即一个struct的成员可以再次是(另一个)struct类型,较小的封装结构甚至可以在较大的结构体内部声明:

struct person {
  char name[256];
  struct stardate {
    struct**tm** date;
    struct**timespec** precision;
  } bdate;
};

声明struct stardate 的可见性与struct person 相同。一个struct本身(这里,person)不会为在最外层struct声明的大括号{}内定义的struct(这里,stardate)定义新的作用域。这可能与其他编程语言的规则大不相同,例如 C++。

取得成果 6.28

在嵌套声明中的所有struct声明具有相同的可见范围。

那就是,如果之前的嵌套 struct 声明在全局范围内出现,那么这两个 struct 都可以在整个 C 文件中可见。如果它们出现在函数内部,它们的可见性则绑定在它们所在的 {} 块中。

因此,一个更合适的版本如下:

struct stardate {
  struct **tm** date;
  struct **timespec** precision;
};
struct person {
  char name[256];
  struct stardate bdate;
};

这个版本将所有 struct 放在同一级别,因为它们最终都会在那里。

6.4. 类型的新名称:类型别名

正如我们在上一章中看到的,结构体不仅提供了一种将不同信息聚合到一个单元中的方法,而且还引入了一个新的类型名称。由于历史原因(再次!),我们为结构体引入的名称总是必须以关键字 struct 开头,这使得其使用略显笨拙。此外,许多 C 语言初学者在忘记 struct 关键字时遇到困难,编译器会向他们抛出一个难以理解的错误。

有一个通用工具可以帮助我们避免这种情况,即通过给一个现有的类型赋予一个符号名称:typedef。使用它,一个类型可以有多个名称,我们甚至可以重用结构体声明中使用的 标签名

   typedef struct birdStruct birdStructure;
   typedef struct birdStruct birdStruct;

然后,struct birdStruct、birdStruct 和 birdStructure 都可以互换使用。我最喜欢的这个特性的用法如下:

typedef struct birdStruct birdStruct;
struct birdStruct {
  ...
};

那就是,通过使用完全相同的名称在适当的 struct 声明之前使用 typedef。这之所以有效,是因为在 struct 与后续名称的组合中,标签 总是有效的,这是一个 结构体的前置声明

Takeaway 6.29

typedef 中使用与标签名相同的标识符来 前置声明 一个 struct

默认情况下,C++ 采用类似的方法,因此这种策略会使来自那里的代码更容易阅读。

typedef 机制也可以用于结构体以外的类型。对于数组,这可能看起来像这样

typedef double vector[64];
typedef vector vecvec[16];
vecvec A;
typedef double matrix[16][64];
matrix B;
double C[16][64];

在这里,typedef 只引入了一个现有类型的新的名称,因此 A、B 和 C 具有完全相同的类型:double[16][64]

Takeaway 6.30

typedef* 只创建一个类型的别名,但永远不会创建一个新类型。

C 标准也大量使用 typedef。我们在 第 5.2 节 中看到的语义整数类型,如 size_t,就是用这种机制声明的。标准经常使用以 _t 结尾的名称来为 typedef 命名。这种命名约定确保在标准的升级版本中引入此类名称时不会与现有代码冲突。因此,你不需要在自己的代码中引入此类名称。

Takeaway 6.31

_t 结尾的标识符名称是保留的*。

摘要

  • 数组将相同基类型的多个值组合成一个对象。

  • 指针指向其他对象,可以是空指针,也可以是不确定的。

  • 结构体将不同基类型的值组合成一个对象。

  • typedef 为现有类型提供新的名称。

第七章. 函数

本章涵盖了

  • 简单函数的介绍

  • main 一起工作

  • 理解递归

我们已经看到了 C 提供的不同方法来实现 条件执行:基于值的执行,选择程序的另一个分支而不是另一个分支以继续。潜在“跳转”到程序代码的另一部分(例如,到 else 分支)的原因是依赖于运行时数据的运行时决策。本章从讨论 无条件 的方法开始,将控制转移到代码的其他部分:它们本身不需要任何运行时数据来决定去哪里。

我们迄今为止看到的代码示例通常使用了 C 库中的函数,这些函数提供了我们不想(或无法)自己实现的特性,例如用于打印的 printf 和用于计算字符串长度的 strlen。这个函数概念背后的想法是,它们实现了一个特定的功能,一次性和永久性,然后我们可以依赖这个功能在其余代码中。

我们已经看到几个定义的函数是 main,它是程序执行的入口点。在本章中,我们将探讨如何编写我们自己可能提供与 C 库中函数类似功能的函数。

驱动函数概念的主要原因是 模块化代码分解

  • 函数避免了代码重复。特别是它们避免了容易引入的复制粘贴错误,并在修改某个功能时节省了在多个地方编辑的努力。因此,函数增加了可读性和可维护性。

  • 使用函数可以减少编译时间。给定的代码片段,当我们将其封装在函数中时,只会编译一次,而不是在每次使用时都编译。

  • 函数简化了未来代码的重用。一旦我们将代码提取到提供特定功能的函数中,它就可以轻松地应用于我们甚至在实现函数时都没有想到的其他地方。

  • 函数提供了清晰的接口。函数参数和返回类型清楚地指定了流入和流出计算的数据的来源和类型。此外,函数还允许我们指定计算的不可变条件:前置条件和后置条件。

  • 函数提供了一种自然的方式来制定使用“堆栈”中间值的算法。

除了函数之外,C 还提供了其他无条件转移控制的方法,这些方法主要用于处理错误条件或其他形式的异常,从常规控制流中:

  • exit_Exitquick_exitabort 终止程序执行(参见章节 8.7)。

  • goto 在函数体内转移控制(参见章节 13.2.2 和 14.5)。

  • setjmplongjmp 可以无条件地返回到调用上下文(参见第 17.5 节)。

  • 执行环境中的某些事件或对函数 raise 的调用可能会引发 信号,这些信号将控制权传递给一个专门的功能,一个 信号处理程序

7.1. 简单函数

我们已经使用了很多函数,并看到了其中一些的声明(例如在第 6.1.5 节中)和定义(如列表 6.3)。在这些函数中,括号 () 扮演着重要的语法角色。它们用于函数的声明和定义,用于封装参数声明列表。对于函数调用,它们包含该具体调用的参数列表。这种语法角色与数组中的 [] 类似:在声明和定义中,它们包含相应维度的尺寸。在像 A[i] 这样的指定中,它们用于指示访问数组中元素的位罝。

我们迄今为止所看到的所有函数都有一个 原型**^C:它们的声明和定义,包括参数类型列表和返回类型。为了说明这一点,让我们回顾一下列表 6.3 中的 leapyear 函数:

yday.c
 **5**   bool leapyear(unsigned year) {
 **6**     /* All years that are divisible by 4 are leap years, 
 **7**        unless they start a new century, provided they 
 **8**        are not divisible by 400\. */ 
 **9**     return !(year % 4) && ((year % 100) || !(year % 400));
**10**   }

该函数的声明(不带定义)可能看起来如下:

bool leapyear(unsigned year);

或者,我们甚至可以省略参数名和/或添加 存储指定符 extern:^([1])

¹

关于关键字 extern 的更多细节将在第 13.2 节中提供。

extern bool leapyear(unsigned);

对于此类声明来说,重要的是编译器可以看到参数(的)类型和返回类型,因此这里函数的原型是“接收一个 unsigned 并返回一个 bool 的函数。”

有两种特殊的约定使用关键字 void

  • 如果函数要无参数调用,则列表被关键字 void 替换,就像我们非常第一个例子中的 main (列表 1.1)。

  • 如果函数不返回值,则返回类型为 void:例如,swap_double。

这样的原型有助于编译器在函数将要被调用的地方。它只需要知道函数期望的参数。看看以下内容:

   extern double fbar(double);

   ...
   double fbar2 = fbar(2)/2;

在这里,对 fbar(2) 的调用与函数 fbar 的预期不直接兼容:它需要一个 double 但接收一个 signed int。但由于调用代码知道这一点,它可以在调用函数之前将 signed int 参数 2 转换为 double2.0。同样,对于在表达式中使用返回值的情况也适用:调用者知道返回类型是 double,因此对结果表达式应用了浮点除法。

C 语言有声明没有原型的函数的过时方式,但在这里你不会看到它们。你不应该使用它们;它们将在未来的版本中退役。

第七点总结 7.1

所有函数都必须有原型。

一个值得注意的例外是能够接收可变数量参数的函数,例如 printf。它们使用一种称为 可变参数 列表^(C) 的参数处理机制,该机制由头文件 stdargs.h 提供。

<stdargs.h>

我们将在后面(章节 16.5.2)看到这是如何工作的,但无论如何都要避免这个特性。从你对 printf 的使用经验中,你可以想象为什么这样的接口会带来困难。作为调用代码的程序员,你必须通过提供正确的 "%XX" 格式说明符来确保一致性。

在函数的实现中,我们必须注意为所有具有非void返回类型的函数提供返回值。一个函数中可以有多个return语句:

第七点总结 7.2

函数只有一个入口,但可以有多个 return

函数中的所有return语句必须与函数声明一致。对于期望返回值的函数,所有return语句都必须包含一个表达式;不期望返回值的函数,则不应包含表达式。

第七点总结 7.3

函数 return 必须与其类型一致。

但对于调用方的参数,同样适用于返回值。在返回之前,具有可以转换为预期返回类型的类型的值将被转换。

如果函数的类型是 void,则可以省略return(无表达式):

第七点总结 7.4

到达函数的 {} 块的末尾等同于一个没有表达式的 return 语句。

因为否则,返回值的函数将有一个不确定的返回值,所以这种结构仅允许用于不返回值的函数:

第七点总结 7.5

到达函数的 {} 块的末尾仅允许在 void 函数中进行。

7.2. main 是特殊的

也许你已经注意到了关于 main 的某些特殊性。它作为程序入口点具有非常特殊的作用:其原型由 C 标准强制执行,但由程序员实现。作为运行时系统和应用程序之间的枢纽,main 必须遵守一些特殊规则。

首先,为了满足不同的需求,它有几个原型,其中必须实现一个。两个总是可能的:

int **main**(void);
int **main**(int argc, char* argv[argc+1]);

然后,任何 C 平台都可能提供其他接口。两种变体相对常见:

  • 在一些嵌入式平台上,由于 main 不需要返回到运行时系统,返回类型可能是 void

  • 在许多平台上,第三个参数可以提供对“环境”的访问。

你不应该依赖于这种其他形式的存在。如果你想编写可移植的代码(你确实应该这样做),坚持使用两种“官方”形式。对于这些,int的返回值向运行时系统指示执行是否成功:EXIT_SUCCESSEXIT_FAILURE的值表示从程序员的角度看执行的成功或失败。这是唯一两个在所有平台上都能保证工作的值。

收获 7.6

使用EXIT_SUCCESSEXIT_FAILURE作为main的返回值

此外,对于main还有一个特殊的例外,因为它不需要显式的return语句:

收获 7.7

到达main的末尾等同于带有值EXIT_SUCCESSreturn语句

个人来说,我不是特别喜欢这种没有实际收益的例外;它们只是让程序的论点更加复杂。

库函数exitmain有特殊的关系。正如其名所示,对exit的调用会终止程序。其原型如下:

_Noreturn void **exit**(int status);

这个函数会像从main返回一样精确地终止程序。状态参数具有main中返回表达式的角色。

收获 7.8

调用exit(s)*等同于在main中评估return*`s``*。

我们还看到,exit的原型是特殊的,因为它有一个void类型。就像一个return语句一样,exit永远不会失败。

收获 7.9

exit永远不会失败,也不会返回到其调用者。

后者是特殊关键字_Noreturn的指示。这个关键字应该只用于这样的特殊函数。甚至还有一个相当打印的版本,宏noreturn,它包含在头文件stdnoreturn.h中。

<stdnoreturn.h>

main的第二个原型中,还有一个特性:argv,命令行参数的向量。我们查看了一些例子,展示了我们如何使用这个向量将命令行中的值传递给程序。例如,在列表 3.1 中,这些命令行参数被解释为程序的double数据:

因此,对于每个 argv [i],其中i = 0,...,argc,都是一个类似于我们之前遇到的指针。作为一个简单的初步近似,我们可以将它们看作是字符串。

收获 7.10

所有命令行参数都作为字符串传递

这取决于我们如何解释它们。在示例中,我们选择了函数strtod来解码存储在字符串中的双精度值。

在 argv 字符串中,有两个元素持有特殊值:

收获 7.11

main的参数中,argv[0]包含程序调用的名称

关于程序名称没有严格的规则,但通常它是程序可执行文件的名字。

收获 7.12

main 的参数 argv[argc**] 是 *0**。

在 argv 数组中,最后一个参数可以通过这个属性来识别,但这个特性并不很有用:我们有 argc 来处理这个数组。

7.3. 递归

函数的一个重要特性是封装:局部变量仅在离开函数时才可见和存活,无论是通过显式的 return 还是因为执行超出函数块的最后一个括号。它们的标识符(名称)不会与其他函数中的类似标识符冲突,并且一旦离开函数,我们留下的所有混乱都会被清理干净。

更好的是:每次我们调用一个函数,即使是之前调用过的,都会创建一个新的局部变量集(包括函数参数),并且这些变量会被重新初始化。即使我们在调用函数的层次结构中已经有一个调用仍然活跃,这也适用。直接或间接调用自身的函数被称为 递归,这个概念被称为 递归

递归函数对于理解 C 函数至关重要:它们展示了并使用了函数调用模型的基本特性,并且只有具备这些特性才能完全工作。作为一个例子,我们将查看一个实现欧几里得算法的示例,用于计算两个数的 最大公约数 (gcd):

euclid.h
 **8**   size_t gcd2(size_t a, size_t b) {
 **9**     assert(a <= b);
**10**     if (!a) return b;
**11**     size_t rem = b % a;
**12**     return gcd2(rem, a);
**13**   }

如您所见,这个函数很短,看起来很漂亮。但要理解它是如何工作的,我们需要彻底理解函数是如何工作的,以及我们如何将数学语句转换为算法。

给定两个整数 a, b > 0,gcd 被定义为能够同时整除 ab 的最大整数 c > 0。以下是公式:

  • gcd(a, b) = max{c | c|a and c|b}

如果我们再假设 a < b,我们就可以很容易地看出有两个 递归 公式成立:

方程式 7.1.

方程式 7.2.

也就是说,gcd 在我们减去较小的整数或用另一个数的模数替换较大的数时不会改变。这些公式自古希腊数学时代以来就被用来计算 gcd。它们通常归功于欧几里得(,约公元前 300 年),但可能在他之前就已经为人所知。

我们的 C 函数 gcd2 使用方程 (7.2)。首先(第 9 行),它检查执行此函数的先决条件是否得到满足:第一个参数是否小于或等于第二个参数。它是通过使用 assert.h 中的 assert 宏来做到这一点的。如果函数被带有不满足该条件的参数调用,程序将终止并显示一条信息性消息(我们将在 第 8.7 节 中看到更多关于 assert 的解释)。

<assert.h>

7.13 总结

将函数的所有先决条件明确化

然后,第 10 行检查 a 是否为 0,如果是,则返回 b。这是递归算法中的一个重要步骤:

要点 7.14

在递归函数中,首先检查终止条件

缺少终止检查会导致 无限递归;函数反复调用新的自身副本,直到所有系统资源耗尽,程序崩溃。在现代具有大量内存的系统上,这可能会花费一些时间,在此期间系统将完全无响应。你最好不要尝试。

否则,我们计算 b 模 a 的余数 rem(第 11 行)。然后,函数递归调用 rem 和 a,并直接返回该返回值。

图 7.1 展示了从初始调用 gcd2(18, 30) 发出的不同递归调用示例。这里,递归深度为四层。每一层实现其自己的 a、b 和 rem 变量的副本。

对于每次递归调用,模运算(要点 4.8)保证了先决条件总是自动满足。对于初始调用,我们必须自己确保这一点。这最好通过使用不同的函数,一个 包装器**^C 来完成:

euclid.h
**15**   size_t gcd(size_t a, size_t b) {
**16**     assert(a);
**17**     assert(b);
**18**     if (a < b)
**19**       return gcd2(a, b);
**20**     else
**21**       return gcd2(b, a);
**22**   }
要点 7.15

确保在包装函数中检查递归函数的先决条件

这样可以避免在每次递归调用时检查先决条件:assert 宏可以在最终的生产对象文件中禁用。

整数序列的递归定义的另一个著名例子是 斐波那契数,这是一种早在公元前 200 年就出现在印度文献中的数字序列。用现代术语来说,这个序列可以定义为

图 7.1. 递归调用 gcd2(18, 30)

方程 7.3。

方程 7.4。

方程 7.5。

斐波那契数列增长迅速。其前几个元素是 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 377, 610, 987。

使用黄金比例

方程 7.6。

可以证明

方程 7.7。

因此,从渐近的角度来看,我们有

方程 7.8。

因此,F**[n] 的增长是指数级的。

递归数学定义可以简单地转换为 C 函数:

fibonacci.c
**4**   size_t fib(size_t n) {
**5**     **if** (n < 3)
**6**       return 1;
**7**     else
**8**       return fib(n-1) + fib(n-2);
**9**   }

在这里,我们首先检查终止条件:调用参数 n 是否小于 3。如果是,则返回值是 1;否则,我们返回参数值为 n-1n-2 的调用之和。

图 7.2 展示了使用小参数值调用 fib 的一个示例。我们看到这导致了三个级别的相同函数的不同参数的堆叠调用。因为方程 (7.5) 使用了序列的两个不同值,所以递归调用的方案比 gcd2 的方案要复杂得多。特别是,有三个 叶子调用:满足终止条件的函数调用,因此它们本身不会进入递归.^([[例 1]])

^([例 1])

证明调用 fib(n)会引发 F**[n] 个叶子调用。

这样实现,斐波那契数的计算相当慢.^([[例 2]]) 实际上,很容易看出该函数本身的递归公式也导致函数执行时间的类似公式:

^([例 2])

使用不同的 n 值测量对 fib(n) 的调用时间。在 POSIX 系统上,您可以使用 /bin/time 来测量程序执行的时间。

图 7.2. 递归调用 fib(4)

方程式 7.9.

方程式 7.10.

方程式 7.11.

其中 C[0] 和 C[1] 是依赖于平台的常数。

因此,无论平台如何以及我们的实现多么巧妙,函数的执行时间始终会是类似

方程式 7.12.

与另一个平台相关的常数 C[2]。因此,fib(n)的执行时间与n呈指数关系,这在实践中通常排除了使用此类函数。

总结 7.16

多重递归可能导致指数级的计算时间

如果我们查看 图 7.2 中的嵌套调用,我们会看到 fib(2) 被调用两次,因此计算 fib(2) 的所有努力都被重复了。下面的 fibCacheRec 函数避免了这种重复。它接收一个额外的参数,cache,它是一个数组,用于存储所有已计算过的值:

fibonacciCache.c
 **4**   /* Compute Fibonacci number n with the help of a cache that may
 **5**      hold previously computed values. */
 **6**   size_t fibCacheRec(size_t n, size_t cache[n]) {
 **7**     if (!cache[n-1]) {
 **8**       cache[n-1]
 **9**         = fibCacheRec(n-1, cache) + fibCacheRec(n-2, cache);
**10**     }
**11**     return cache[n-1];
**12**   }
**13**
**14**   size_t fibCache(size_t n) {
**15**     if (n+1 <= 3) return 1;
**16**     /* Set up a VLA to cache the values. */
**17**     size_t cache[n];
**18**     /* A VLA must be initialized by assignment. */
**19**     cache[0] = 1; cache[1] = 1;
**20**     for (size_t i = 2; i < n; ++i)
**21**       cache[i] = 0;
**22**     /* Call the recursive function. */
**23**     return fibCacheRec(n, cache);
**24**   }

通过以存储空间换取计算时间,只有当值尚未被计算时,递归调用才会受到影响。因此,fibCache(i)`的执行时间与n成线性关系

方程式 7.13.

对于一个平台相关的参数 C[3]。^([[例 3]]) 只需改变实现我们的序列的算法,我们就能将执行时间从指数级降低到线性级!我们没有(也不会)讨论实现细节,也没有对执行时间进行具体测量.^([[例 4]])

^([例 3])

证明方程式 (7.13)。

^([例 4])

使用与 fib 相同的值测量 fibCache (n) 调用的时间。

总结 7.17

一个糟糕的算法永远不会导致性能良好的实现

总结 7.18

改进算法可以显著提高性能

为了好玩,fib2Rec 展示了实现斐波那契序列的第三个算法。它使用固定长度数组(FLA)而不是可变长度数组(VLA)。

fibonacci2.c
 **4**   void fib2rec(size_t n, size_t buf[2]) {
 **5**     if (n > 2) {
 **6**       size_t res = buf[0] + buf[1];
 **7**       buf[1] = buf[0];
 **8**       buf[0] = res;
 **9**       fib2rec(n-1, buf);
**10**     }
**11**   }
**12**
**13**   size_t fib2(size_t n) {
**14**     size_t res[2] = { 1, 1, };
**15**     fib2rec(n, res);
**16**     return res[0];
**17**   }

证明这个版本仍然正确留作练习.^([[Exs 5]]) 此外,到目前为止,我们只有一些基本的工具来评估这“更快”是否在任何我们想要赋予这个术语的意义上.^([[Exs 6]])

^([Exs 5])

使用迭代语句将 fib2rec 转换为非递归函数 fib2iter。

^([Exs 6])

使用与 fib 相同的值测量 fib2(n)调用的时间。

因式分解

现在我们已经介绍了函数,看看你是否能实现一个名为factor的程序,该程序接收命令行上的一个数字 N,并打印出

    N: F0 F1 F2 ...

其中 F0 以及等等都是 N 的所有素因子。

你的实现的核心应该是一个函数,给定一个size_t类型的值,返回其最小的素因子。

扩展此程序以接收此类数字的列表,并为每个数字输出此类行。

概述

  • 函数有一个原型,它决定了它们如何被调用。

  • 终止main和调用exit是相同的。

  • 每个函数调用都有自己的局部变量副本,并且可以递归调用。

第八章。C 库函数

本章涵盖

  • 进行数学运算、处理文件和字符串处理

  • 操作时间

  • 管理运行时环境

  • 终止程序

C 标准提供的功能分为两大块。一块是正确的 C 语言,另一块是C 库。我们已经查看了一些随 C 库提供的函数,包括printfputsstrtod,所以你应该有一个很好的预期:实现我们在日常编程中需要的特性的基本工具,并且我们需要清晰的接口和语义来确保可移植性。

在许多平台上,通过应用程序编程接口API)的明确指定也允许我们将编译器实现与库实现分开。例如,在 Linux 系统中,我们有不同编译器的选择,最常见的是gccclang,以及不同的 C 库实现,如 GNU C 库(glibc)、dietlibcmusl;理论上,这些选择中的任何一个都可以用来生成可执行文件。

我们将首先讨论 C 库及其接口的一般特性和工具,然后描述一些函数组:数学(数值)函数、输入/输出函数、字符串处理、时间处理、对运行时环境的访问和程序终止。

8.1. C 库及其函数的一般特性

大概来说,库函数针对一个或两个目的:

  • 平台抽象层: 抽象出平台特定属性和需求的函数。这些是需要特定平台位来实现基本操作(如 IO)的函数,没有对平台的深入了解就无法实现。例如,puts必须有一些关于“终端输出”以及如何访问它的概念。实现这些功能将超出大多数 C 程序员的认知,因为这需要操作系统或甚至处理器特定的魔法。庆幸的是,有些人已经为你做了这项工作。

  • 基本工具: 实现常见任务(如strtod)的函数,这些任务在 C 语言编程中经常出现,并且接口的固定性非常重要。这些函数应该相对高效地实现,因为它们被大量使用,并且应该经过良好的测试,没有错误,这样我们才能安全地依赖它们。原则上,任何经过验证的 C 程序员都应该能够实现这样的函数。[[例 1]])

    ^([例 1])

    编写一个名为 my_strtod 的函数,实现strtod对十进制浮点常数的功能。

类似于printf的函数可以视为具有双重目的:它可以有效地分为一个格式化阶段,提供基本工具,以及一个特定平台的输出阶段。有一个名为snprintf的函数(将在 14.1 节中详细解释)提供了与printf相同的格式化功能,但将结果存储在一个字符串中。这个字符串然后可以用puts打印出来,以产生与printf整体相同的输出。

在接下来的章节中,我们将讨论声明 C 库接口的不同头文件(8.1.1 节)、它提供的不同类型的接口(8.1.2 节)、它应用的多种错误策略(8.1.3 节)、一系列可选的接口,旨在提高应用程序的安全性(8.1.4 节),以及我们可以在编译时用来断言平台特定属性的工具(8.1.5 节)。

8.1.1. 头文件

C 库有很多函数,远远超过我们在这本书中能处理的范围。一个头文件^C*捆绑了多个功能的接口描述,主要是函数。我们将在这里讨论的头文件提供了 C 库的功能,但稍后我们可以创建自己的接口并将它们收集在头文件中(第十章)。

在这个层面上,我们将讨论 C 库中必要的函数,这些函数对于使用我们迄今为止看到的语言元素进行基本编程是必需的。当我们在更高层次讨论一系列概念时,我们将完成这次讨论。表 8.1 概述了标准头文件。

8.1.2. 接口

C 库中的大多数接口都指定为函数,但实现者可以自由选择将它们实现为宏,如果这样做是合适的。与我们在第 5.6.3 节中看到的不同,这使用了类似函数的第二个宏形式,函数式宏**^C

#define putchar(A) putc(A, stdout)
表 8.1. C 库头文件
名称 描述 章节
<assert.h> 断言运行时条件 8.7
<complex.h> 复数 5.7.7
<ctype.h> 字符分类和转换 8.4
<errno.h> 错误代码 8.1.3
<fenv.h> 浮点环境
<float.h> 浮点类型属性 5.7
<inttypes.h> 整数类型的格式化转换 5.7.6
<iso646.h> 运算符的备选拼写 4.1
<limits.h> 整数类型属性 5.1.3
<locale.h> 国际化 8.6
<math.h> 类型特定的数学函数 8.2
<setjmp.h> 非局部跳转 17.5
<signal.h> 信号处理函数 17.6
<stdalign.h> 对象对齐 12.7
<stdarg.h> 变量参数数量的函数 16.5.2
<stdatomic.h> 原子操作 17.6
<stdbool.h> 布尔值 3.1
<stddef.h> 基本类型和宏 5.2
<stdint.h> 精确宽度整数类型 5.7.6
<stdio.h> 输入和输出 8.3
<stdlib.h> 基本函数 2
<stdnoreturn.h> 不返回的函数 7
<string.h> 字符串处理 8.4
<tgmath.h> 类型通用的数学函数 8.2
<threads.h> 线程和控制结构 18
<time.h> 时间处理 8.5
<uchar.h> Unicode 字符 14.3
<wchar.h> 宽字符串 14.3
<wctype.h> 宽字符分类和转换 14.3

如前所述,这些只是文本替换,由于替换文本可能包含宏参数多次,因此将任何具有副作用的表达式传递给这样的宏或函数是不好的。希望我们之前关于副作用(要点 4.11)的讨论已经说服您不要这样做。

我们将要查看的一些接口具有指针作为参数或返回值。我们目前还不能完全处理这些,但在大多数情况下,我们可以通过传递已知的指针或为指针参数传递0来避免问题。作为返回值的指针只会在它们可以解释为错误条件的情况下出现。

8.1.3. 错误检查

C 库函数通常通过特殊的返回值来指示失败。指示失败的具体值可能不同,这取决于函数本身。通常,你必须查阅函数的手册页中的具体约定。表 8.2 给出了可能性的大致概述。有三个类别适用:指示错误的特殊值,指示成功的特殊值,以及那些在成功时返回某种正计数器,在失败时返回负值的函数。

表 8.2. C 库函数的错误返回策略 一些函数也可能通过errno宏的值来指示特定的错误条件。
失败返回 测试 典型情况 示例
0 !value 其他值有效 fopen
特殊错误代码 value == code 其他值有效 putsclockmktimestrtodfclose
非零值 value 不需要的值 fgetposfsetpos
特殊成功代码 value != code 失败条件的区分 thrd_create
负值 value < 0 正值是一个计数器 printf

典型的错误检查代码如下:

if (puts("hello world") == EOF) {
  perror("can't output to terminal:");
  exit(EXIT_FAILURE);
}

在这里,我们看到puts属于那些在出错时返回特殊值(EOF),即“文件结束”的函数类别。然后,stdio.h中的perror函数被用来提供依赖于特定错误的附加诊断。exit结束程序执行。不要把失败隐藏在地毯下。在编程中,

<stdio.h>

收获 8.1

失败总是一个选择。

收获 8.2

检查库函数的返回值以查找错误。

程序立即失败通常是确保在开发早期发现并修复错误的最佳方式。

收获 8.3

快速失败,尽早失败,经常失败。

C 有一个主要的跟踪 C 库函数错误的状态变量:一个叫做errno的恐龙。perror函数在幕后使用这个状态,以提供其诊断。如果一个函数以允许我们恢复的方式失败,我们必须确保错误状态也被重置;否则,库函数或错误检查可能会困惑:

comm.jpg

void puts_safe(char const s[static 1]) {
  static bool failed = false;
  if (!failed && puts(s) == EOF) {
    perror("can't output to terminal:");
    failed = true;
    errno = 0;
  }
}

8.1.4. 边界检查接口

许多 C 库函数在用不一致的参数集调用时容易受到缓冲区溢出的影响。这导致了(并且仍然导致)许多安全漏洞和利用,通常应该非常小心地处理。

C11 通过废弃或删除标准中的一些函数,并添加一系列可选的新接口来检查运行时参数的一致性来解决这类问题。这些是 C 标准附录 K 的边界检查接口。与大多数其他功能不同,它没有自己的头文件,而是向其他接口添加了接口。两个宏控制对这些接口的访问:__STDC_LIB_EXT1__指示是否支持此可选接口,__STDC_WANT_LIB_EXT1__将其打开。后者必须在包含任何头文件之前设置:

#if !__STDC_LIB_EXT1__
# error "This code needs bounds checking interface Annex K"
#endif
#define __STDC_WANT_LIB_EXT1__ 1

#include <stdio.h>

/* Use printf_s from here on. */

这种机制(现在仍然是)引起了大量争议,因此附录 K 是一个可选功能。许多现代平台有意识地选择不支持它。甚至有 O’Donell 和 Sebor [2015]进行的一项广泛研究,该研究得出结论,引入这些接口比解决的问题要多得多。在以下内容中,这样的可选功能将以灰色背景标记。

附录 K

边界检查函数通常使用它们所替代的库函数名称上的后缀 _s,例如printf_s用于printf。因此,你不应该为你的代码使用该后缀。

Takeaway 8.4
Takeaway 8.4

_s 结尾的标识符名称是保留的

如果这样的函数遇到不一致的情况,即运行时约束违规**^C,它通常应该在打印诊断信息后结束程序执行。

8.1.5. 平台先决条件

使用像 C 这样的标准化语言进行编程的一个重要目标是可移植性。我们应该尽可能少地对执行平台做出假设,并将其留给 C 编译器和库来填补空白。不幸的是,这并不总是可行的选择,在这种情况下,我们应该清楚地识别代码先决条件。

Takeaway 8.5

对于执行平台的遗漏先决条件必须终止编译

实现这一点的经典工具是预处理器条件**^C,正如我们之前看到的:

#if !__STDC_LIB_EXT1__
# error "This code needs bounds checking interface Annex K"
#endif

如你所见,这样的条件从一行上的# if标记序列开始,并以包含另一个序列# endif的行结束。中间的# error指令仅在条件(这里为!__STDC_LIB_EXT1__)为真时执行。它通过错误消息终止编译过程。我们可以放入此类构造的条件是有限的.^([[Exs 2]])

^([Exs 2])

编写一个前处理器条件,以测试int是否具有二进制补码表示。

Takeaway 8.6

仅在前处理器条件中评估宏和整数文字

作为这些条件的一个额外功能,未知的标识符将评估为0。所以,在上一个例子中,即使__STDC_LIB_EXT1__在那个时刻是未知的,表达式也是有效的。

Takeaway 8.7

在预处理器条件中,未知标识符的值为 0**.

如果我们要测试更复杂的条件,_Static_assert(一个关键字)和static_assert(来自头文件 assert.h 的宏)具有相似的效果,并且可供我们使用:

<assert.h>

#include <assert.h>
static_assert(sizeof(double) == sizeof(long double),
  "Extra precision needed for convergence.");

8.2. 数学

数学 函数math.h 头文件提供,但使用 tgmath.h 提供的类型通用宏要简单得多。基本上,对于所有函数,都有一个宏将调用例如 sin(x)pow(x, n) 的函数分配给检查其参数类型并返回相同类型的函数。

<math.h>

<tgmath.h>

定义的类型通用的宏太多,无法在此详细描述。表 8.3 提供了提供的函数的概述。

表 8.3. 数学函数 在本书的电子版中,类型通用的宏以红色显示,而普通函数以绿色显示。
函数 描述
abs, labs, llabs 整数的绝对值,|x|
acosh 双曲余弦函数
acos 反余弦
asinh 双曲反正弦
asin 反正弦
atan2 反正切,两个参数
atanh 双曲反正切
atan 反正切
cbrt 图片
ceil x
copysign y 复制符号到 x
cosh 双曲余弦
cos 余弦函数,cos x
div, ldiv, lldiv 整数除法的商和余数
erfc 补余误差函数,图片
erf 误差函数,图片
exp2 2^x
expm1 e**^x – 1
exp e**^x
fabs 浮点数的绝对值,|x|
fdim 正差
floor x
fmax 浮点数最大值
fma x · y + z
fmin 浮点数最小值
fmod 浮点数除法的余数
fpclassify 对浮点值进行分类
frexp 尾数和指数
hypot 图片
ilogb ⌊log[FLT_RADIX]^(x) 作为整数
isfinite 检查是否为有限值
isinf 检查是否为无穷大
isnan 检查是否为 NaN
isnormal 检查是否为正常值
ldexp x · 2^y
lgamma log[e] Γ(x)
log10 log[10]x
log1p log[e](1 + x)
log2 log[2]x
logb log[FLT_RADIX]^(x) 作为浮点数
log log[e] x
modf, modff, modfl 整数部分和小数部分
nan, nanf, nanl 对应类型的非数字 (NaN)
nearbyint 使用当前舍入模式找到最近的整数
nextafter, nexttoward 下一个可表示的浮点数值
pow x**^y
remainder 除法的有符号余数
remquo 有符号余数和除法的最后几位
rint, lrint, llrint 使用当前舍入模式最近的整数
round, lround, llround sign(x) ·⌊|x| + 0.5⌋
scalbn, scalbln x · FLT_RADIX^y
signbit 检查是否为负
sinh 双曲正弦
sin 正弦函数,sin x
sqrt
tanh 双曲正切
tan 正切函数,tan x
tgamma Γ函数,Γ(x)
trunc sign(x) ·⌊|x|⌋

现在,数值函数的实现应该是高质量的、高效的,并且具有良好的数值精度控制。尽管任何具有足够数值知识的程序员都可以实现这些函数,但你不应尝试替换或绕过它们。其中许多不仅作为 C 函数实现,还可以使用处理器特定的指令。例如,处理器可能具有sqrtsin函数的快速近似,或者通过低级指令实现*浮点乘加fma。特别是,有很好的机会这些低级指令被用于检查或修改浮点内部的所有函数,例如cargcrealfabsfrexpldexpllroundlroundnearbyintrintroundscalbntrunc。因此,替换它们或在手工编写的代码中重新实现它们通常是一个坏主意。

8.3. 输入、输出和文件操作

我们已经看到了一些与头文件stdio.h一起提供的 IO 函数:putsprintf。第二个函数允许你以方便的方式格式化输出,而第一个则更基本:它只是输出一个字符串(其参数)和一个换行符。

<stdio.h>

8.3.1. 无格式文本输出

有一个比puts更基本的功能:putchar,它输出单个字符。这两个函数的接口如下:

int putchar(int c);
int puts(char const s[static 1]);

int类型作为putchar的参数是一个历史事件,它不会对你造成太大的伤害。相比之下,返回类型为int是必要的,以便函数可以将其错误返回给调用者。特别是,如果成功,它返回参数 c,如果失败,则返回特定的负值EOFEnd Of File),该值保证不会对应于任何字符。

使用此函数,我们实际上可以自己重新实现puts

int puts_manually(char const s[static 1]) {
  for (size_t i = 0; s[i]; ++i) {
    if (putchar(s[i]) == EOF) return EOF;
  }
  if (putchar('\n') == EOF) return EOF;
  return 0;
}

这只是一个例子;它可能不如你的平台提供的puts高效。

到目前为止,我们只看到了如何输出到终端。通常,你可能会想将结果写入永久存储,而类型FILE*流**^C提供了一个抽象。有两个函数,fputsfputc,它们将无格式输出的概念推广到流中:

int fputc(int c, FILE* stream);
int fputs(char const s[static 1], FILE* stream);

在这里,FILE 类型中的 * 再次表示这是一个指针类型,我们不会深入细节。我们现在需要知道的是,指针可以被测试是否为空 (收获 6.20),因此我们将能够测试流是否有效。

标识符 FILE 代表一个 不透明类型^C*,对于这个类型,我们只知道在本书中将要看到的函数接口所提供的信息。它被实现为一个宏,以及将FILE这个名字用于流的使用不当,都是提醒我们这是历史上预标准化之前的一个接口。

收获 8.8

不透明类型通过函数接口指定

收获 8.9

不要依赖于不透明类型的实现细节

如果我们不进行任何特殊操作,将有两个输出流可用:stdoutstderr。我们已经在隐式地使用 stdout:这是 putcharputs 在底层使用的,并且这个流通常连接到终端。stderr 类似,默认情况下也连接到终端,可能具有略微不同的属性。无论如何,这两个流是紧密相关的。拥有两个流的目的在于能够区分“常规”输出(stdout)和“紧急”输出(stderr)。

我们可以用更通用的函数重写之前的函数:

int putchar_manually(int c) {
  return fputc(c, stdout);
}
int puts_manually(char const s[static 1]) {
  if (fputs(s,    stdout) == EOF) return EOF;
  if (fputc('\n', stdout) == EOF) return EOF;
  return 0;
}

注意到 fputsputs 的不同之处在于它不会在字符串中追加行结束字符。

收获 8.10

putsfputs 在行结束处理方面有所不同。

8.3.2. 文件和流

如果我们要将输出写入实际文件,我们必须通过函数 fopen 将文件附加到我们的程序执行中:

FILE* fopen(char const path[static 1], char const mode[static 1]);
FILE* freopen(char const path[static 1], char const mode[static 1],
              FILE *stream);

这可以像这里一样简单使用:

int main(int argc, char* argv[argc+1]) {
 FILE* logfile = fopen("mylog.txt", "a");
 if (!logfile) {
   perror("fopen failed");
   return EXIT_FAILURE;
 }
 fputs("feeling fine today\n", logfile);
 return EXIT_SUCCESS;
}

这将打开文件系统中的一个名为 "mylog.txt" 的文件,并通过变量 logfile 提供对其的访问。模式参数 "a" 用于追加文件:也就是说,如果文件存在,则保留其内容,并从该文件的当前末尾开始写入。

打开文件可能失败的原因有很多:例如,文件系统可能已满,或者进程可能没有权限在指定位置写入。我们检查这种错误条件 (收获 8.2) 并在必要时退出程序。

正如我们所见,perror 函数用于提供发生的错误诊断。它相当于以下内容:

fputs("fopen failed: some-diagnostic\n", stderr);

这个“some-diagnostic”可能(但不一定)包含更多帮助程序用户处理错误的信息。

附录 K

此外,还有边界检查替换函数 fopen_sfreopen_s,它们确保传递的参数是有效的指针。在这里,errno_tstdlib.h 中的一种类型,用于编码错误返回。新出现的 restrict 关键字仅适用于指针类型,目前不在我们的讨论范围内:

errno_t fopen_s(FILE* restrict streamptr[restrict],
                char const filename[restrict], char const mode[restrict
    ]);
errno_t freopen_s(FILE* restrict newstreamptr[restrict],
                  char const filename[restrict], char const mode[
    restrict],
                FILE* restrict stream);

打开文件有不同的模式;“a”只是几种可能性之一。表 8.4 包含了可能出现在该字符串中的字符概述。三个基础模式控制如果存在预存文件时会发生什么,以及流的位置。此外,还可以将三个修饰符附加到它们上。表 8.5 列出了所有可能的组合。

表 8.4. fopenfreopen 的模式和修饰符 至少必须有一个基础模式开始模式字符串,可选地后跟一个或多个其他三个。有关所有有效组合,请参阅 表 8.5。
模式 备注 fopen 后的文件状态
'a' 追加 w 文件未修改;位置在末尾
'w' 写入 w 如果有内容,则擦除文件内容
'r' 读取 r 文件未修改;位置在起始处
修饰符 备注 额外属性
'+' 更新 rw 打开文件以供读写
'b' 二进制 视为二进制文件;否则为文本文件
'x' 独占 如果不存在,则创建一个用于写入的文件
表 8.5. fopenfreopen 的模式字符串 这些是 表 8.4 中字符的有效组合。
"a" 如果需要,创建一个空文本文件;在文件末尾打开以供写入
"w" 创建一个空文本文件或擦除内容;打开以供写入
"r" 打开一个现有的文本文件以供读取
"a+" 如果需要,创建一个空文本文件;在文件末尾打开以供读写
"w+" 创建一个空文本文件或擦除内容;打开以供读写
"r+" 在文件开头打开一个现有的文本文件以供读写

|

"ab" "rb" "wb"
"a+b"    "ab+"
"r+b"    "rb+"
"w+b" "wb+"
与上面相同,但用于二进制文件而不是文本文件
"wx" "w+x" "wbx" "w+bx" "wb+x"

这些表格显示,流不仅可以用于写入,还可以用于读取;我们很快就会看到如何做到这一点。要知道哪个基础模式用于读取或写入,只需运用你的常识即可。对于 'a' 和 'w',如果文件位置在其末尾,则无法读取,因为没有内容;因此这些用于写入。对于 'r',应避免意外覆盖保存在起始位置的文件内容,因此这用于读取。

在日常编码中,修饰符的使用较少。应谨慎使用带有 '+' 的“更新”模式。同时读写并不容易,需要特别注意。对于 'b',我们将在第 14.4 节中更详细地讨论文本和二进制流之间的区别。

处理流的另外三个主要接口是 freopenfclosefflush

int fclose(FILE* fp);
int fflush(FILE* stream);

freopenfclose 的主要用途很简单:freopen 可以将给定的流关联到不同的文件,并最终更改模式。这特别有用,可以将标准流关联到文件。例如,我们上面的小程序可以重写为

int main(int argc, char* argv[argc+1]) {
 if (!freopen("mylog.txt", "a", stdout)) {
   perror("freopen failed");
   return EXIT_FAILURE;
 }
 puts("feeling fine today");
 return EXIT_SUCCESS;
}

8.3.3. 文本 IO

输出到文本流通常是缓冲**^C:也就是说,为了更有效地使用其资源,IO 系统可以延迟对流的物理写入。如果我们使用 fclose 关闭流,所有缓冲区都将保证被刷新**^C到它应该去的地方。在需要立即在终端看到输出或不想关闭文件但想确保所有已写入的内容都已正确到达目的地的地方,需要使用 fflush 函数。列表 8.1 展示了将 10 个点写入stdout的示例,所有写入之间的延迟大约为 1 秒.^([[Exs 3]])

^([Exs 3])

通过使用零、一个和两个命令行参数来运行程序,观察程序的行为。

列表 8.1. 清除缓冲输出
 **1**   #include <stdio.h>
 **2**
 **3**   /* delay execution with some crude code,
 **4**      should use thrd_sleep, once we have that*/
 **5**   void delay(double secs) {
 **6**     double const magic = 4E8;   // works just on my machine
 **7**     unsigned long long const nano = secs* magic;
 **8**     for (unsigned long volatile count = 0;
 **9**          count < nano;
**10**          ++count) {
**11**       /* nothing here */
**12**     }
**13**   }
**14**
**15**   int main(int argc, char* argv[argc+1]) {
**16**     fputs("waiting 10 seconds for you to stop me", stdout);
**17**     if (argc < 3) fflush(stdout);
**18**     for (unsigned i = 0; i < 10; ++i) {
**19**       fputc('.', stdout);
**20**       if (argc < 2) fflush(stdout);
**21**       delay(1.0);
**22**     }
**23**     fputs("\n", stdout);
**24**     fputs("You did ignore me, so bye bye\n", stdout);
**25**   }

对于文本文件,最常见的 IO 缓冲形式是行缓冲**^C。在这种模式下,只有在遇到文本行尾时才会进行物理写入。因此,通常使用 puts 写入的文本会立即出现在终端上;fputs 等待遇到输出中的 '\n'。关于文本流和文件,另一个有趣的事情是,程序中写入的字符与控制台设备或文件中到达的字节之间没有一一对应的关系。

8.11 要点

文本输入和输出转换数据

这是因为文本字符的内部和外部表示不一定相同。不幸的是,仍然有许多不同的字符编码;如果 C 库能够正确地进行转换,它负责进行转换。最臭名昭著的是,文件中的换行符编码是平台相关的:

8.12 要点

有三种常用的转换来编码换行符

C 语言为我们提供了一个非常适合的抽象,使用 '\n' 来实现这一点,无论平台如何。在进行文本 IO 时,你应该注意的另一个修改是,行尾前的空白可能会被抑制。因此,不能依赖于尾随空白**^C,如空格或制表符字符,应该避免使用:

8.13 要点

文本行不应包含尾随空白

C 库还提供了对文件系统内文件操作的支持,但非常有限:

int remove(char const pathname[static 1]);
int rename(char const oldpath[static 1], char const newpath[static 1]);

这些基本上就是它们名字所表示的意思。

表 8.6. printf 和类似函数的格式说明,具有一般语法 "%[FF][WW][.PP][LL]SS",其中[]包围的字段表示它是可选的。
FF 标志 转换的特殊形式
WW 字段宽度 最小宽度
PP 精度
LL 修饰符 选择类型宽度
SS 说明符 选择转换

8.3.4. 格式化输出

我们已经介绍了如何使用 printf 进行格式化输出。函数 fprintf 与之非常相似,但它有一个额外的参数,允许我们指定输出写入的流:

int printf(char const format[static 1], ...);
int fprintf(FILE* stream, char const format[static 1], ...);

使用三个点 ... 的语法表示这些函数可以接收任意数量的要打印的项。一个重要的约束是,这个数字必须与 % 说明符完全对应;否则行为是未定义的:

收获 8.14

printf 的参数必须与格式说明符完全对应。

使用语法 %[FF][WW][.PP][LL]SS`,一个完整的格式说明可以由五个部分组成:标志、宽度、精度、修饰符和说明符。有关详细信息,请参阅表 8.6。

说明符不是可选的,它选择要执行的输出转换类型。有关概述,请参阅表 8.7。

如您所见,对于大多数类型的值,都有多种格式可供选择。您应该选择最适合输出要传达的值的意义的格式。对于所有数值,这通常应该是十进制格式。

收获 8.15

使用 "%d" "%u" 格式来打印整数值。

另一方面,如果您对位模式感兴趣,请使用十六进制格式而不是八进制。它更好地符合现代具有 8 位字符类型的架构。

收获 8.16

使用 "%x" 格式来打印位模式。

还要注意,此格式接收无符号值,这也是仅使用无符号类型进行位集的另一个动力。看到十六进制值并关联相应的位模式需要训练。表 8.8 概述了数字、它们的值以及它们所代表的位模式。

对于浮点数格式,还有更多的选择。如果您没有特定的需求,通用格式对于十进制输出来说最容易使用。

表 8.7. printf 和类似函数的格式说明符
'd' 或 'i' 十进制 有符号整数
'u' 十进制 无符号整数
'o' 八进制 无符号整数
'x' 或 'X' 十六进制 无符号整数
'e' 或 'E' [-]d.ddd e±dd,“科学” 浮点数
'f' 或 'F' [-]d.ddd 浮点数
'g' 或 'G' 通用 e 或 f 浮点数
'a' 或 'A' [-]0xh.hhhh p±d,十六进制 浮点数
'%' % 字符 不转换任何参数。
'c' 字符 整数
's' 字符串 字符串
'p' 地址 void 指针
表 8.8. 十六进制值和位模式
数字 模式
0 0 0000
1 1 0001
2 2 0010
3 3 0011
4 4 0100
5 5 0101
6 6 0110
7 7 0111
8 8 1000
9 9 1001
A 10 1010
B 11 1011
C 12 1100
D 13 1101
E 14 1110
F 15 1111
取得 8.17 要点

使用 "%g" 格式来打印浮点数。

修饰符部分对于指定相应参数的确切类型非常重要。表 8.9 提供了我们迄今为止遇到的所有类型的代码。这个修饰符尤其重要,因为使用错误的修饰符解释值可能会导致严重损坏。printf 函数只通过格式说明符了解它们的参数,因此给函数错误的尺寸可能会导致它读取比参数提供的更多或更少的字节,或者解释错误的硬件寄存器。

取得 8.18 要点

使用不适当的格式说明符或修饰符会使行为未定义。

一个好的编译器应该会警告关于错误格式的信息;请认真对待此类警告。注意,也存在针对三种语义类型的特殊修饰符。特别是,组合 "%zu" 非常方便,因为我们不必知道 size_t 对应的基类型。

表 8.9. printf 和类似函数的格式修饰符 float 参数首先转换为 double
字符 类型 转换
"hh" char 类型 整数
"h" short 类型 整数
"" 有符号无符号 整数
"l" 长整数类型 整数
"ll" 长长整数类型 整数
"j" intmax_tuintmax_t 整数
"z" size_t 整数
"t" ptrdiff_t 整数
"L" 长双精度浮点数 浮点数
表 8.10. printf 和类似函数的格式标志
字符 含义 转换
"#" 替代形式,例如前缀 0x "aAeEfFgGoxX"
"0" 填充零 数字
"-" 左对齐 任何
" " 正值用空格表示,负值用减号表示 有符号
"+" 正值用加号表示,负值用减号表示 有符号

宽度(WW)和精度(.PP)可以用来控制打印值的总体外观。例如,对于通用的浮点数格式 "%g",精度控制有效数字的数量。格式 "%20.10g" 指定一个最多有 10 个有效数字的 20 字符输出字段。这些值如何具体解释因每个格式说明符而异。

标志可以更改输出变体,例如,使用符号前缀 ("%+d"),十六进制转换前缀 ("0x"),八进制 ("%#o"),用 0 填充,或者将输出在其字段内左对齐而不是右对齐。参见 表 8.10。记住,整数前面的零通常被解释为引入八进制数,而不是十进制数。因此,使用左对齐的零填充 "%-0" 不是好主意,因为它可能会让读者对应用的约定产生混淆。

如果我们知道我们写入的数字将被稍后从文件中读取回来,那么对于有符号类型,使用 "%+d",对于无符号类型,使用 "%#X",对于浮点数,使用 "%a" 是最合适的。它们保证了字符串到数字的转换将检测到正确的形式,并且存储在文件中的信息不会丢失。

取舍 8.19

使用 "%+d"**, "%#X"* 和 "%a" 进行必须稍后读取的转换。

附件 K

可选接口 printf_sfprintf_s 检查流、格式和任何字符串参数是否为有效指针。它们不检查列表中的表达式是否对应于正确的格式说明符:

int printf_s(char const format[restrict], ...);
int fprintf_s(FILE *restrict stream,
              char const format[restrict], ...);

这里是一个修改后的示例,用于重新打开 stdout

int main(int argc, char* argv[argc+1]) {
  int ret = EXIT_FAILURE;
  fprintf_s(stderr, "freopen of %s:", argv[1]);
  if (freopen(argv[1], "a", stdout)) {
    ret = EXIT_SUCCESS;
    puts("feeling fine today");
  }
  perror(0);
  return ret;
}

通过将文件名添加到输出字符串中,这提高了诊断输出。fprintf_s 用于检查流的合法性、格式和参数字符串的有效性。如果这两个流都连接到相同的终端,该函数可能会混合两个流的输出。

8.3.5. 未格式化文本输入

未格式化输入最好使用 fgetc 读取单个字符,使用 fgets 读取字符串。stdin 标准流始终定义,通常连接到终端输入:

int fgetc(FILE* stream);
char* fgets(char s[restrict], int n, FILE* restrict stream);
int getchar(void);

附件 K

此外,还有 getchargets_s,它们从 stdin 读取,但并没有为更通用的接口增加多少:

char* gets_s(char s[static 1], rsize_t n);

从历史上看,在puts专门化fputs的同一精神下,C 标准的先前版本有一个 gets 接口。这已经被移除,因为它固有不安全。

取舍 8.20

不要使用 gets.

以下列表显示了一个具有与 fgets 相等功能的功能。

列表 8.2. 基于 fgetc 实现 fgets
 **1**   char* fgets_manually(char s[restrict], int n,
 **2**                        FILE*restrict stream) {
 **3**     if (!stream) return 0;
 **4**     if (!n) return s;
 **5**     /* Reads at most n-1 characters */
 **6**     for (size_t pos = 0; pos < n-1; ++pos) {
 **7**        int val = fgetc(stream);
 **8**        switch (val) {
 **9**          /* EOF signals end-of-file or error */
**10**         case EOF: if (feof(stream)) {
**11**           s[i] = 0;
**12**           /* Has been a valid call */
**13**           return s;
**14**         } else {
**15**           /* Error */
**16**           return 0;
**17**         }
**18**         /* Stop at end-of-line. */
**19**         case '\n': s[i] = val; s[i+1] = 0; return s;
**20**         /* Otherwise just assign and continue. */
**21**         default: s[i] = val;
**22**       }
**23**    }
**24**    s[n-1] = 0;
**25**    return s;
**26**  }

再次强调,这样的示例代码并不是要替换该函数,而是要说明所讨论函数的性质:在这里,是错误处理策略。

取舍 8.21

fgetc 返回 int 以能够编码一个特殊的错误状态, EOF*,除了所有有效字符。

此外,仅检测到 EOF 的返回并不足以得出已到达流末尾的结论。我们必须调用 feof 来测试流的位置是否已达到文件结束标记。

取舍 8.22

只有在 “读取失败” 之后才能检测到文件结束。

列表 8.3 展示了使用输入和输出函数的示例。

列表 8.3. 一个将多个文本文件输出到stdout的程序
 **1**  #include <stdlib.h>
 **2**  #include <stdio.h>
 **3**  #include <errno.h>
 **4**  
 **5**  enum { buf_max = 32, };
 **6**  
 **7**  int main(int argc, char* argv[argc+1]) {
 **8**    int ret = EXIT_FAILURE;
 **9**    char buffer[buf_max] = { 0 };
**10**    for (int i = 1; i < argc; ++i) {        // Processes args
**11**      FILE* instream = fopen(argv[i], "r"); // as filenames
**12**    if (instream) {
**13**      while (fgets(buffer, buf_max, instream)) {
**14**        fputs(buffer, stdout);
**15**       }
**16**       fclose(instream);
**17**       ret = EXIT_SUCCESS;
**18**     } else {
**19**       /* Provides some error diagnostic. */
**20**       fprintf(stderr, "Could not open %s: ", argv[i]);
**21**       perror (0);
**22**       errno = 0;                       // Resets the error code
**23**     }
**24**   }
**25**   return ret;
**26** }

这是一个 cat 的小型实现,它读取命令行上给出的多个文件,并将内容输出到stdout.^([[Exs 4]])^([[Exs 5]])^([[Exs 6]])^([[Exs 7]])

^([Exs 4])

在什么情况下,这个程序将以成功或失败返回代码结束?

^([Exs 5])

令人惊讶的是,这个程序甚至可以处理包含超过 31 个字符的行。为什么?

^([Exs 6])

如果没有给出命令行参数,则让程序从stdin读取。

^([Exs 7])

如果第一个命令行参数是"-n",则让程序在所有输出行前加上行号。

8.4. 字符串处理和转换

C 中的字符串处理必须处理源和执行环境可能具有不同编码的事实。因此,拥有独立于编码的接口至关重要。最重要的工具由语言本身提供:整数字符常量,如'a'和'\n',以及字符串字面量,如"hello:\tx",应该在您的平台上始终正确执行。您可能还记得,没有比int更窄的类型常量;并且,作为一个历史遗迹,整数字符常量如'a'的类型是int,而不是您可能期望的char

如果必须处理字符类,处理这样的常数可能会变得繁琐。

因此,C 库通过头文件ctype.h提供了处理最常用类的函数和宏。它有分类器isalnumisalphaisblankiscntrlisdigitisgraphislowerisprintispunctisspaceisupperisxdigit,以及转换touppertolower。同样,由于历史原因,所有这些函数都接受int类型的参数,并返回int类型的值。参见表 8.11 以了解分类器的概述。函数touppertolower将字母字符转换为相应的字母大小写,并保留所有其他字符不变。

<ctype.h>

表格中有些特殊字符,如'\n'表示换行符,我们之前已经遇到过。所有特殊编码及其含义都在表 8.12 中给出。

整数字符常量也可以用数值编码:以八进制形式 '\037' 或十六进制形式 '\xFFFF' 表示。在第一种形式中,最多使用三个八进制数字来表示代码。在第二种形式中,x 后的任何可以解释为十六进制数字的字符序列都包含在代码中。在字符串中使用这些字符需要特别注意标记此类字符的结尾:"\xdeBruyn" 与 "\xde" "Bruyn"^([1]) 不同,而是对应于 "\xdeB" "ruyn",代码为 3563 的字符后面跟着四个字符 'r', 'u', 'y', 和 'n'。使用此功能在所有平台上编译是可移植的,只要存在代码为 3563 的字符。它是否存在以及实际是什么取决于平台和程序执行的特定设置。

¹

但请记住,连续的字符串字面量会被连接 (takeaway 5.18)。

表 8.11. 字符分类器 第三列指示 C 实现是否可以扩展这些类别以包含平台特定的字符,例如 'ä' 作为小写字母或 '€' 作为标点符号。
名称 含义 C 位置 扩展
islower 小写字母 'a' ... 'z'
isupper 大写字母 'A' ... 'Z'
isblank 空白字符 ' ', '\t'
isspace 空格 ' ', '\f', '\n', '\r', '\t', '\v'
isdigit 十进制 '0' ... '9'
isxdigit 十六进制 '0' ... '9', 'a' ... 'f', 'A' ... 'F'
iscntrl 控制字符 '\a', '\b', '\f', '\n', '\r', '\t', '\v'
isalnum 字母数字 isalpha(x)||isdigit(x)
isalpha 字母 islower(x)||isupper(x)
isgraph 图形字符 (!iscntrl(x)) && (x != ' ')
isprint 可打印字符 !iscntrl(x)
ispunct 标点符号 isprint(x)&&!(isalnum(x)||isspace(x))
表 8.12. 字符和字符串字面量中的特殊字符
''' 引号
'"' 双引号
'?' 问号
'\' 反斜杠
'\a' 警报
'\b' 退格
'\f' 进纸
'\n' 换行
'\r' 回车
'\t' 水平制表符
'\v' 垂直制表符
摘要 8.23

数值编码字符的解释取决于执行字符集。

因此,它们的使用并不完全可移植,应该避免使用。

以下函数 hexatridecimal 使用了一些这些函数来为所有字母数字字符提供 36 进制的数值。这与十六进制常量类似,只是所有其他字母在 36 进制中也有值:^([[Exs 8]])^([[Exs 9]])^([[Exs 10]])

^([Exs 8])

hexatridecimal 的第二次 return 做了一个关于 a 和 'A' 之间关系的假设。那是什么?

^([Exs 9])

描述一个假设未满足的错误场景。

^([Exs 10])

修复这个错误:即重写此代码,使其不对 a 和'A'之间的关系做出假设:

strtoul.c
 **8**   /* Supposes that lowercase characters are contiguous. */
 **9**   static_assert('z'-'a' == 25,
**10**                  "alphabetic characters not contiguous");
**11**   #include <ctype.h>
**12**   /* Converts an alphanumeric digit to an unsigned */
**13**   /* '0' ...  '9'  =>  0 ..  9u  */
**14**   /* 'A' ...  'Z'  => 10 ..  35u */
**15**   /* 'a' ...  'z'  => 10 ..  35u */
**16**   /* Other values =>    Greater */
**17**   unsigned hexatridecimal(int a) {
**18**     if (isdigit(a)) {
**19**       /* This is guaranteed to work: decimal digits
**20**          are consecutive, and isdigit is not
**21**          locale dependent. */
**22**     return a -  '0';
**23**     } else {
**24**       /* Leaves a unchanged if it is not lowercase */
**25**       a = toupper(a);
**26**       /* Returns value >= 36 if not Latin uppercase*/
**27**       return (isupper(a)) ? 10 + (a - 'A') : -1;
**28**     }
**29**   }

除了strtod之外,C 库还有strtoulstrtolstrtoumaxstrtoimaxstrtoullstrtollstrtoldstrtof,可以将字符串转换为数值。

在这里,名称末尾的字符对应于类型:u表示unsignedl(字母“el”)表示longd表示doublef表示 float,而[i|u]max表示intmax_tuintmax_t

所有具有整数返回类型的接口都有三个参数,例如strtoul

unsigned long int strtoul(char const nptr[restrict],
                          char** restrict endptr,
                          int base);

它将字符串 nptr 解释为以 base 为基数的数字。对于 base 来说,有趣的值是081016。最后三个分别对应于八进制、十进制和十六进制编码。第一个0是这三个的组合,其中基数根据将文本作为数字解释的常规规则来选择:“7”是十进制,“007”是八进制,“0x7”是十六进制。更准确地说,字符串被解释为可能由四个不同的部分组成:空白字符、符号、数字和一些剩余数据。

第二个参数可以用来获取剩余数据的位置,但这对于我们来说仍然过于复杂。目前,只需为该参数传递一个0即可确保一切正常工作。参数的一个方便的组合是strtoul(S, 0, 0),它将尝试将 S 解释为一个数字,无论输入格式如何。提供浮点值的三种函数工作方式类似,只是函数参数的数量限制为两个。

接下来,我们将演示如何从更基本的原始函数实现这样的函数。让我们首先看看 Strtoul_inner。它是使用十六进制循环从字符串计算大整数的strtoul实现的核心:

strtoul.c
**31**   unsigned long Strtoul_inner(char const s[static 1],
**32**                               size_t i,
**33**                               unsigned base) {
**34**     unsigned long ret = 0;
**35**     while (s[i]) {
**36**       unsigned c = hexatridecimal(s[i]);
**37**       if (c >= base) break;
**38**       /* Maximal representable value for 64 bit is
**39**          3w5e11264sgsf in base 36 */
**40**       if (ULONG_MAX/base < ret) {
**41**         ret = ULONG_MAX;
**42**         errno = ERANGE;
**43**         break;
**44**      }
**45**      ret *= base;
**46**      ret += c;
**47**      ++i;
**48**     }
**49**     return ret;
**50**   }

如果字符串表示的数字太大,无法用unsigned long表示,则此函数返回ULONG_MAX并将errno设置为ERANGE

现在,Strtoul 提供了strtoul的功能实现,只要不使用指针就可以做到这一点:

strtoul.c
**60**  unsigned long Strtoul(char const s[static 1], unsigned base) {
**61**    if (base > 36u) {             /* Tests if base          */
**62**      errno = EINVAL;             /* Extends the specification */
**63**      return ULONG_MAX;
**64**    }
**65**    size_t i = strspn(s, " \f\n\r\t\v"); /* Skips spaces      */
**66**    bool switchsign = false;      /* Looks for a sign         */
**67**    switch (s[i]) {
**68**    case '-' : switchsign = true;
**69**    case '+' : ++i;
**70**    }
**71**    if (!base || base == 16) {    /* Adjusts the base         */
**72**      size_t adj = find_prefix(s, i, "0x");
**73**      if (!base) base = (unsigned[]){ 10, 8, 16, }[adj];
**74**      i += adj;
**75**    }
**76**    /* Now, starts the real conversion*/
**77**    unsigned long ret = Strtoul_inner(s, i, base);
**78**    return (switchsign) ? -ret : ret;
**79**   }

它封装了 Strtoul_inner 并执行所需的先前调整:跳过空白字符,查找可选的符号,在基数参数为0的情况下调整基数,并跳过一个可能的0或 0x 前缀。还要注意,如果提供了负号,它将根据unsigned long算术正确地取反结果。^([[Exs 11]])

^([Exs 11])

实现 Strtoul 所需的 find_prefix 函数。

要跳过空格,Strtoul 使用strspn,这是string.h提供的字符串搜索函数之一。此函数返回第一个参数中完全由第二个参数中的任何字符组成的初始序列的长度。函数strcspn(“c”代表“补足”)以类似的方式工作,但它寻找第二个参数中不存在的初始字符序列。

<string.h>

此头文件提供了许多内存和字符串搜索函数:memchrstrchrstrpbrkstrrchrstrstrstrtok。但为了使用它们,我们需要指针,所以我们目前还不能处理它们。

8.5. 时间

第一类时间可以被归类为日历时间,具有通常出现在人类日历中的粒度和范围,例如约会、生日等。以下是一些处理时间和由time.h头文件提供的功能接口:

<time.h>

time_t time(time_t *t);
double difftime(time_t time1, time_t time0);
time_t mktime(struct tm tm[1]);
size_t strftime(char s[static 1], size_t max,
                char const format[static 1],
                struct tm const tm[static 1]);
int timespec_get(struct timespec ts[static 1], int base);

第一个简单地为我们提供了当前时间的time_t类型的时间戳。最简单的形式使用time(0)的返回值。正如我们所见,在程序执行的不同时刻取出的两个这样的时间可以用来通过difftime表达时间差。

让我们从人类的角度来看一下这一切都在做什么。正如我们所知,struct tm结构主要按照您预期的日历时间进行。它具有层次日期成员,如tm_year表示年份,tm_mon表示月份,等等,直到秒的粒度。但它有一个陷阱:成员是如何计算的。除了一个之外,所有成员都是从0开始的:例如,tm_mon设置为0表示一月,而tm_wday 0表示星期日。

不幸的是,存在例外:

  • tm_mday从 1 开始计算月份中的天数。

  • tm_year必须加 1900 才能得到格里高利日历中的年份。以这种方式表示的年份应在格里高利年份 0 到 9999 之间。

  • tm_sec的范围是从 0 到 60,包括 60,后者是用于罕见的闰秒情况。

有三个补充日期成员用于向struct tm中的时间值提供额外信息:

  • tm_wday表示星期几。

  • tm_yday表示一年中的某一天。

  • tm_isdst是一个标志,它告诉我们日期是否被认为是本地时区的夏令时。

所有这些成员的一致性可以通过函数mktime来强制执行。它分为三个步骤:

1.  层次日期成员被归一化到各自的范围内。

2.  tm_wdaytm_yday被设置为相应的值。

3.  如果 tm_isday 具有负值,则如果日期属于本地平台的夏令时,此值将修改为 1,否则为0

mktime还提供了一个额外的作用。它返回作为time_t的时间。time_t代表与struct tm相同的日历时间,但被定义为算术类型,更适合与这种类型进行计算。它在一个线性时间尺度上操作。time_t0time_t的开始被称为 C 术语中的纪元。通常这对应于 1970 年 1 月 1 日。

time_t的粒度通常到秒,但没有任何东西可以保证这一点。有时处理器硬件有特殊的时钟寄存器,它们遵循不同的粒度。difftime将两个time_t值之间的差异转换为以双精度值表示的秒。

附件 K

其他在 C 中操作时间的传统函数有点危险,因为它们操作全局状态。我们在这里不会讨论它们,但这些接口的变体已在附件 K 中以 _s 形式进行了审查:

errno_t asctime_s(char s[static 1], rsize_t maxsize,
                  struct tm const timeptr[static 1]);
errno_t ctime_s(char s[static 1], **rsize_t** maxsize,
                const **time_t** timer[static 1]);
struct tm *gmtime_s(time_t const timer[restrict static 1],
                    struct tm result[restrict static 1]);
struct tm *localtime_s(time_t const timer[restrict static 1],
                       struct tm result[restrict static 1]);

图 8.1 展示了所有这些函数是如何交互的:

图 8.1. 时间转换函数

有两个函数用于从time_tstruct tm的逆向操作:

  • localtime_s存储分解后的本地时间。

  • gmtime_s存储分解的时间,以通用时间,UTC 表示。

如所示,它们在转换时假设的时间区域不同。在正常情况下,localtime_smktime应该是彼此的逆;gmtime_s在逆向方向上没有直接对应物。

日历时间的文本表示也可用。asctime_s以固定格式存储日期,独立于任何区域设置、语言(它使用英语缩写)或平台依赖性。格式是形如的字符串

"Www Mmm DD HH:MM:SS YYYY\n"

strftime更灵活,允许我们使用格式说明符组合文本表示。

它与printf家族类似,但具有用于日期和时间的特殊%-代码;参见表 8.13。在这里,区域列表明不同的环境设置,如首选语言或时区,可能会影响输出。如何访问和最终设置这些将在第 8.6 节中解释。strftime接收三个数组:一个要填充结果字符串的char[max]数组,另一个包含格式的字符串,以及一个包含要表示的时间的struct tm const[1]。传递时间数组的原因只有在了解更多关于指针之后才会变得明显。

表 8.13. strftime格式说明符 在区域列中选定的可能根据区域运行时设置动态变化;参见第 8.6 节。在 ISO 8601 列中选定的由该标准指定。
Spec Meaning Locale ISO 8601
"%S" 秒 ("00" 到 "60")
"%M" 分钟 ("00" 到 "59")
"%H" 小时 ("00" 到 "23")。
"%I" 小时 ("01" 到 "12")。
"%e" 月份中的日 (" 1" 到 "31")
"%d" 月份中的日 ("01" 到 "31")
"%m" 月份 ("01" 到 "12")
"%B" 月份的全称 X
"%b" 缩写的月份名称 X
"%h" 等同于 "%b" X
"%Y"
"%y" 年 ("00" 到 "99")
"%C" 世纪数(年/100)
"%G" 基于周的年份;与 "%Y" 相同,除非 ISO 周数属于另一年 X
"%g" 与 "%G" 类似,("00" 到 "99") X
"%u" 星期几 ("1" 到 "7"),星期一为 "1"
"%w" 星期几 ("0" 到 "6",星期天为 "0")
"%A" 星期几的全称 X
"%a" 缩写的工作日名称 X
"%j" 一年中的日 ("001" 到 "366")
"%U" 一年中的周数 ("00" 到 "53"),从星期日开始
"%W" 一年中的周数 ("00" 到 "53"),从星期一开始
"%V" 一年中的周数 ("01" 到 "53"),从新年的前四天开始 X
"%Z" 时区名称 X
"%z" "+hhmm" 或 "-hhmm",相对于 UTC 的小时和分钟偏移量
"%n" 换行符
"%t" 水平制表符
"%%" 文字 "%"
"%x" 日期 X
"%D" 等同于 "%m/%d/%y"
"%F" 等同于 "%Y-%m-%d" X
"%X" 时间 X
"%p" "AM" 或 "PM" 之一:中午是 "PM",午夜是 "AM" X
"%r" 等同于 "%I:%M:%S %p" X
"%R" 等同于 "%H:%M"
"%T" 等同于 "%H:%M:%S" X
"%c" 日期和时间的首选表示 X

不透明的类型 time_t(以及作为结果 time 本身)只有秒级的粒度。

如果我们需要比这更高的精度,可以使用 struct timespectimespec_get 函数。这样,我们有一个额外的成员 tv_nsec,它提供了纳秒级的精度。第二个参数,base,由 C 标准定义了一个值:TIME_UTC。你应该期望使用该值的 timespec_get 调用与 time 调用保持一致。它们都指的是地球的参考时间。特定的平台可能为 base 提供额外的值,指定一个不同于墙上的时钟的时钟。这样的时钟可以是相对于你的计算机系统参与的行星或其他物理系统。^([2]) 通过使用只引用系统启动时间的 单调时钟,可以避免相对论和其他时间调整。CPU 时钟可以指程序执行被分配处理资源的时间。

²

请注意,相对于地球快速移动的物体,如卫星和宇宙飞船,可能会相对于 UTC 感知到相对论时间变化。

对于后者,C 标准库提供了一个额外的接口:

**clock_t** **clock**(void);

由于历史原因,这引入了另一种类型,clock_t。它是以 CLOCKS_PER_SEC 单位每秒给出的处理器时间。

有三个不同的接口,timetimespec_getclock,有点不幸。如果为其他形式的时钟提供预定义的常量,如 TIME_PROCESS_TIME 和 TIME_THREAD_TIME,将会很有益。

排序算法的性能比较

你能比较你的排序程序(挑战 1)的时间效率与几个数量级的数据大小吗?

请注意,在数据的创建中要有一些随机性,并且数据大小不要超过你电脑的可用内存。

对于这两个算法,你应该大致观察到与 N log N 成正比的行为,其中 N 是排序的元素数量。

8.6. 运行时环境设置

C 程序可以访问一个 环境列表**^C:一个字符串的名称-值对列表(通常称为 环境变量**^C),它可以从运行时环境传输特定信息。有一个历史函数 getenv 来访问这个列表:

char* getenv(char const name[static 1]);

根据我们目前的知识,使用这个函数我们只能测试一个名称是否存在于环境列表中:

bool havenv(char const name[static 1]) {
  return getenv(name);
}

相反,我们使用安全的函数 getenv_s

附录 K

errno_t getenv_s(size_t * restrict len,
                 char value[restrict],
                 rsize_t maxsize,
                 char const name[restrict]);

此函数将对应于名称(如果有的话)的值从环境复制到 value,一个 char[maxsize],前提是它适合。打印这样的值可能看起来像这样:

void printenv(char const name[static 1]) {
  if (getenv(name)) {
    char value[256] = { 0, };
    if (getenv_s(0, value, sizeof value, name)) {
      fprintf(stderr,
              "%s: value is longer than %zu\n",
              name, sizeof value);
    } else {
      printf("%s=%s\n", name, value);
    }
  } else {
    fprintf(stderr, "%s not in environment\n", name);
  }
}

如您所见,在检测到环境变量是否存在之后,可以将第一个参数设置为 0 来安全地调用 getenv_s。此外,可以保证只有当预期结果适合时,目标缓冲区才会被写入。可以使用 len 参数来检测所需的实际长度,并使用动态缓冲区分配来打印出更大的值。我们将等待更高层次来查看此类用法。

可用的环境变量取决于操作系统。常见的环境变量包括 "HOME" 用于用户的家目录,"PATH" 用于可执行文件的集合标准路径,以及 "LANG" 或 "LC_ALL" 用于语言设置。

语言或 locale**^C 设置是程序执行继承的执行环境的重要组成部分。在启动时,C 将区域设置强制为规范化值,称为 "C" 区域。它基本上有美国英语的选择,用于数字或时间日期。

<locale.h>

可以使用 locale.h 中的 setlocale 函数来设置或检查当前值:

char* setlocale(int category, char const locale[static 1]);

除了"C"之外,C 标准还规定了另一个有效的 locale 值的存在:空字符串""。这可以用来将有效 locale 设置为系统默认值。分类参数可以用来处理语言环境的全部或部分。表 8.14 概述了可能的值以及它们影响的 C 库部分。可能还有其他平台相关的分类可用。

表 8.14. setlocale函数的分类
LC_COLLATE 通过strcollstrxfrm进行字符串比较
LC_CTYPE 字符分类和处理函数;参见第 8.4 节。
LC_MONETARY 货币格式化信息,localeconv
LC_NUMERIC 格式化 I/O 的十进制点字符,localeconv
LC_TIME strftime;参见第 8.5 节
LC_ALL 所有上述内容

8.7. 程序终止和断言

我们已经看到了终止程序最简单的方法:从main的常规返回。

摘要 8.24

常规程序终止应使用从main返回的 return

使用main函数内的exit函数有点没有意义,因为它可以用return同样轻松地完成。

摘要 8.25

在可能终止常规控制流的函数中使用 exit

C 库有三个其他函数可以终止程序执行,按严重程度排序:

_Noreturn void quick_exit(int status);
_Noreturn void _Exit(int status);
_Noreturn void abort(void);

现在,从main(或对exit的调用)返回return已经提供了指定程序执行是否被认为是成功的机会。使用返回值来指定;只要你没有其他需求或者你并不完全理解这些其他函数的作用,就不要使用它们。真的:不要使用。

摘要 8.26

除非你不得不抑制库清理的执行,否则不要使用除了 exit 之外的函数来终止程序

程序终止时的清理很重要。运行时系统可以刷新和关闭已写入的文件,释放程序占用的其他资源。这是一个特性,应该很少被绕过。

甚至有一个机制可以安装你自己的处理程序**^C,它们将在程序终止时执行。可以使用两个函数来完成这个任务:

int atexit(void func(void));
int at_quick_exit(void func(void));

这些有我们尚未见过的语法:函数参数**^C。例如,第一个读取为“返回int并接收一个函数参数 func 的atexit函数。”^([3])

³

事实上,在 C 语言中,这种将函数参数 func 传递给函数atexit的概念与传递一个函数指针**^C是等价的。在描述此类函数时,你通常会看到指针变体。对我们来说,这种区别还不相关;将其视为通过引用传递函数会更简单。

我们在这里不会详细介绍。一个例子将展示如何使用它:

void sayGoodBye(void) {
  if (errno) perror("terminating with error condition");
  fputs("Good Bye\n", stderr);
}

int main(int argc, char* argv[argc+1]) {
  atexit(sayGoodBye);
  ...
}

这使用函数atexit来建立exit处理程序 sayGoodBye。在程序代码的正常终止后,此函数将被执行并给出执行状态。如果你需要一些尊重,这可能是一个很好的方式来给你的同事留下深刻印象。更严重的是,这是放置所有各种清理代码的理想位置,例如释放内存或将终止时间戳写入日志文件。请注意,调用语法为atexit(sayGoodBye)。sayGoodBye 本身没有():在这里,sayGoodBye 在那个点没有被调用;只是将函数的引用传递给了atexit

在罕见情况下,你可能想要绕过这些已建立的atexit处理程序。有一对其他函数,quick_exitat_quick_exit,可以用来建立替代的终止处理程序列表。如果正常处理程序的执行过于耗时,这样的替代列表可能很有用。请谨慎使用。

下一个函数,_Exit,更为严重:它抑制了两种类型的应用特定处理程序的执行。唯一执行的是平台特定的清理操作,例如文件关闭。请更加谨慎地使用它。

最后一个函数,abort,更为侵入性。它不仅不会调用应用程序处理程序,还会抑制某些系统清理的执行。请极其谨慎地使用它。

在本章的开头,我们探讨了_Static_assertstatic_assert,它们应该用于进行编译时断言。它们可以测试任何形式的编译时布尔表达式。另外两个标识符来自assert.h,可用于运行时断言:assertNDEBUG。第一个可以用于测试必须在某个时刻成立的表达式。它可能包含任何布尔表达式,并且可能是动态的。如果在编译时没有定义NDEBUG宏,每次执行通过此宏的调用时,都会评估表达式。来自第 7.3 节的 gcd 和 gcd2 函数展示了assert的典型用法:一个在每次执行中都应成立的条件。

<assert.h>

如果条件不成立,则会打印诊断信息,并调用abort。因此,这些内容都不应该进入生产可执行文件。从之前的讨论中,我们知道abort的使用通常是有害的,并且错误信息如下

终端
**0**      assertion failed in file euclid.h, function gcd2(), line 6

对于你的客户来说并不很有帮助。在调试阶段,它可以帮助你找到对变量值做出错误假设的地方。

收获 8.27

尽可能多地使用 assert 来确认运行时属性。

如前所述,NDEBUG会抑制表达式的评估和abort的调用。请使用它来减少开销。

收获 8.28

在生产编译中,使用 NDEBUG 来关闭所有 assert.

图像分割

除了 C 标准库之外,还有许多其他支持库,它们提供了非常不同的功能。其中有很多是某种形式的图像处理库。尝试找到一个合适的此类图像处理库,它是用 C 编写的或与 C 接口,并且允许你将灰度图像作为基类型为unsigned char的两维矩阵来处理。

这个挑战的目标是对这样的图像进行分割:将像素(矩阵的unsigned char元素)分组到一些“相似”的连接区域中。这种分割形成了一组像素的划分,就像我们在挑战 4 中看到的那样。因此,你应该使用并查集结构来表示区域,每个像素一个区域。

你能否实现一个统计函数,计算所有区域的统计量?这应该是一个数组(游戏中的第三个数组),对于每个根节点,它包含像素的数量和所有值的总和。

你能否实现一个区域合并标准?测试两个区域的平均值是否相差不远:比如说,不超过五个灰度值。

你能否实现一种逐行合并策略,对于图像中的一行上的每个像素,测试其区域是否应该合并到左侧和/或顶部?

你能否逐行迭代,直到没有更多变化:也就是说,结果区域/集合与各自的相邻区域都测试为负?

现在你已经有一个完整的图像分割函数,尝试将其应用于具有不同主题和大小的图像上,并且用不同的平均距离值来改变你的合并标准,而不是使用五个。

摘要

  • C 库通过一系列头文件进行接口。

  • 数学函数最好通过tgmath.h中的类型通用宏来使用。

  • 输入和输出(IO)通过stdio.h接口。有一些函数可以以文本或原始字节的形式进行 IO。文本 IO 可以是直接的或通过格式化来结构化。

  • 字符串处理使用ctype.h中的函数进行字符分类,使用stdlib进行数值转换,使用string.h进行字符串操作。

  • time.h中的时间处理有日历时间,它是为人类解释而结构化的,以及物理时间,它是以秒和纳秒为单位的结构化。

  • 标准 C 只有基本的接口来描述运行程序的执行环境;getenv提供了对环境变量的访问,而locale.h调节了人类语言的接口。

第二级. 认知

欧亚松鸡可能是独居的,或者成对出现。它以其模仿其他鸟鸣声、警觉性和散播种子以促进森林扩张而闻名。

现在我们已经足够深入地了解了 C 的核心。完成这一级应该能够让你专业地编写 C 代码;因此,它从关于 C 程序的编写和组织的基本讨论开始。然后它填补了我们之前跳过的主要 C 构造的空白:它全面解释了指针,使你熟悉 C 的内存模型和动态内存分配,并让你理解 C 的大多数库接口。

第九章. 风格

本章节涵盖

  • 编写可读的代码

  • 格式化代码

  • 命名标识符

程序服务于双方:首先,正如我们之前所看到的,它们服务于向编译器和最终可执行文件发出指令。但同样重要的是,它们为必须与之打交道的人(用户、客户、维护者、律师等等)记录了系统的预期行为。

因此,我们有一个首要指令:

摘要 C

所有 C 代码都必须可读。

那个指令的困难在于知道什么构成了“可读性”。并不是所有经验丰富的 C 程序员都同意,因此我们将从尝试建立一个最小必需列表开始。在讨论人类状况时,我们必须牢记的两个主要因素是:身体能力和文化负担。

9.1 摘要

短期记忆和视野范围都很小。

Torvalds 等人 [1996],Linux 内核的编码风格,是坚持这一方面并确实值得一看的例子,如果你还没有读过的话。其主要假设仍然有效:编程文本必须在一个相对较小的“窗口”(无论是控制台还是图形编辑器)中呈现,大约有 80 列的 30 行,形成一个 2,400 个字符的“表面”。所有不适合的内容都必须记住。例如,我们非常第一个程序在 列表 1.1 中符合这些限制。

通过对其幽默地引用 Kernighan 和 Ritchie [1978],Linux 编码风格也指出了另一个基本事实:

9.2 摘要

编码风格不是品味问题,而是文化问题。

忽略这一点很容易导致关于许多事情的无休止且毫无结果的争论。

摘要 9.3

当你进入一个成熟的项目时,你就进入了一个新的文化空间。

尝试适应居民的习惯。当你创建自己的项目时,你有一点点自由来建立自己的规则。但如果你希望其他人遵守这些规则,你必须小心不要偏离在相应社区中占主导地位的常识。

9.1. 格式化

C 语言本身对格式化问题相对宽容。在正常情况下,一个 C 编译器会愚蠢地解析一个整个程序,该程序写在单行上,最小化空白,并且所有标识符都由字母 l 和数字1组成。代码格式化的需求源于人类的无能。

收获 9.4

选择一个一致的策略来处理空白和其他文本格式。

格式化问题包括缩进、括号和各种括号({}, [], 和 ())的位置、操作符前后空格、尾随空格以及多行换行。人眼和大脑在习惯上相当独特,为了确保它们能够正常高效地工作,一切必须保持同步。

在级别 1 的介绍中,你看到了许多应用于本书代码中的编码风格规则。把它们作为一种风格的例子;你可能会在继续的过程中遇到其他风格。让我们回顾一些规则,并介绍一些尚未介绍的其他规则:

  • 我们使用前缀记法来表示代码块:也就是说,一个开括号 { 在一行的末尾。

  • 我们将类型修饰符和限定符绑定到左边。我们将函数 () 绑定到左边,但条件中的 () 与其关键字(如iffor)之间用空格隔开。

  • 三元表达式在?:周围有空格。

  • 标点符号(:, ;, 和 ,)前面没有空格,但后面有一个空格或一个新行。

如你所见,当写出来时,这些规则可能显得相当繁琐和任意。它们本身没有价值;它们是视觉辅助工具,帮助你和你合作者一眼就能理解新的代码。它们不是让你直接仔细输入的,但你应该掌握并学习可以帮助你的工具。

收获 9.5

让文本编辑器自动格式化你的代码。

我个人使用 Emacs(www.gnu.org/software/emacs/)来完成这项任务(是的,我真的很老)。对我来说,它是理想的,因为它可以自己理解 C 程序的结构。你的体验可能不同,但不要在日常生活中使用那些给你带来更少帮助的工具。文本编辑器、集成开发环境(IDE)和代码生成器都是为了我们而存在的,而不是相反。

在更大的项目中,你应该为所有流通和被他人阅读的代码强制执行这种格式化策略。否则,将难以追踪编程文本版本之间的差异。这可以通过命令行工具自动完成,这些工具会进行格式化。在这里,我长期偏好astyle(艺术风格sourceforge.net/projects/astyle/)。再次强调,你的体验可能不同;选择任何能确保任务完成的工具。

9.2. 命名

在命名方面,这种自动格式化工具的局限性达到了极限。

取得成果 9.6

为所有标识符选择一个一致的命名策略

命名的两个方面:一方面是技术限制,另一方面是语义约定。不幸的是,它们经常被混淆,成为无休止的意识形态争论的主题。

对于 C,适用各种技术限制;它们旨在帮助你,所以要认真对待。首先,我们针对 所有标识符:类型(struct 或不是),structunion 成员,变量,枚举,宏,函数,函数式宏。有如此多的 命名空间**^C 混乱,你必须小心。

特别是,头文件和宏定义之间的交互可能产生意想不到的效果。以下是一个看似无害的例子:

**1**   double memory_sum(size_t N, size_t I, double strip[N][I]);
  • N 是一个大写标识符,因此你的合作者可能会被诱惑定义一个宏 N 为一个大数字。

  • 当有人包含 complex.h 时,I 被用作 -1 的根。

  • 标识符 strip 可能会被 C 实现用于库函数或宏。

  • 标识符 memory_sum 可能会被 C 标准用于未来的类型名称。

<complex.h>

取得成果 9.7

任何在头文件中可见的标识符都必须符合规范

在这里,符合性是一个广泛的领域。在 C 术语中,如果标识符的意义由 C 标准固定,并且你无法重新定义它,则该标识符是 保留**^C 的:

  • 以下划线和第二个下划线或大写字母开头的名称保留用于语言扩展和其他内部使用。

  • 以下划线开头的名称保留用于文件作用域标识符以及 enumstructunion 标签。

  • 宏的名称全部为大写字母。

  • 所有具有预定义意义的标识符都已被保留,不能在文件作用域中使用。这包括许多标识符,例如 C 库中的所有函数,所有以 str 开头的标识符(如我们之前的 strip),所有以 E 开头的标识符,所有以 _t 结尾的标识符,以及许多其他标识符。

这些规则相对困难的原因是,你可能多年都不会发现任何违规行为;然后,突然之间,在新客户端机器上,在引入下一个 C 标准、编译器或进行简单系统升级后,你的代码崩溃了。

一种降低命名冲突概率的简单策略是尽可能少地暴露名称。

取得成果 9.8

不要污染标识符的全局空间

仅将类型和函数作为接口暴露,这些接口是 应用程序编程接口**^C (API**^C) 的一部分:即那些预期将被你的代码的用户使用。

对于其他人或项目使用的库,一个好的策略是使用不太可能引起冲突的命名前缀。例如,POSIX 线程 API 中的许多函数和类型都以前缀 pthread_ 开头。对于我的工具箱 P99,我使用前缀 p99_ 和 P99_ 用于 API 接口,p00_ 和 P00_ 用于内部。

有两种名称可能会与另一个程序员编写的宏产生不良交互,而你可能不会立即想到:

  • structunion的成员名称

  • 函数接口中的参数名称。

第一点是为什么标准结构中的成员通常在其名称前有一个前缀的原因:struct timespec的成员名称是tv_sec,因为一个未受过教育的用户可能会声明一个宏 sec,当包含time.h时可能会以不可预测的方式干扰。对于第二点,我们之前已经看到了一个例子。在 P99 中,我会指定这样的函数如下:

<time.h>

**1**   double p99_memory_sum(size_t p00_n, size_t p00_i,
**2**                         double p00_strip[p00_n][p00_i]);

当我们也将程序内部暴露给公众时,这个问题变得更糟。这发生在两种情况下:

  • 所说的inline函数,这些函数的定义(不仅是声明)在头文件中可见

  • 函数宏

我们将在稍后讨论这些特性,参见第 15.1 节和第十六章。

既然我们已经明确了命名的技术要点,我们将看看语义方面。

取得成果 9.9

名称必须是可识别的和快速可区分的

这有两个部分:可区分的和快速。比较表 9.1 中的标识符。

根据你个人的口味,这张表右侧的答案可能不同。这反映了我的口味:这样的名称的隐含上下文是我个人期望的一部分。n 和 m 在一侧与 ffs 和 clz 在另一侧之间的差异是一种隐含的语义。

表 9.1. 一些易于和难以区分的标识符的例子
可识别的 可区分的 快速
lllll1llOll llllll1l0ll
我的行号 我的列号
n m
ffs clz
lowBit highBit
p00Orb p00Urb
p00_orb p00_urb

对于我来说,因为我有深厚的数学背景,从 i 到 n 的单字母变量名,如 n 和 m,是整数变量。它们通常在非常有限的范围内出现,作为循环变量或类似的东西。单字母标识符是可以的(我们总是有声明在眼前),并且它们很容易区分。

函数名称 ffs 和 clz 是不同的,因为它们与其他所有可能用作函数名称的三字母缩写词竞争。顺便说一下,在这里,ffs 是 find first (bit) set 的缩写,但这对我来说并不立即明显。这意味着什么将更不清楚:哪个位是第一个,最显著的位还是最不显著的位?

有几种约定将多个单词组合在一个标识符中。其中最常用的有以下几种:

  • *驼峰命名法^C,使用内部大写字母来分隔单词。

  • *蛇形命名法^C,使用内部下划线来分隔单词。

  • *匈牙利命名法^C,在标识符的前缀中编码类型信息,例如 szName,其中 sz 代表 字符串以零结尾的

    ¹

    由 Simonyi [1976] 发明,Simonyi Károly 的博士论文。

如你所想,这些都不理想。前两个往往模糊了我们的视线:它们很容易用难以阅读的表达式填满宝贵的编程文本的一整行:

**1**   return theVerySeldomlyUsedConstant*theVerySeldomlyUsedConstant/
        number_of_elements;

匈牙利命名法反过来又倾向于使用类型或概念的晦涩缩写,产生难以发音的标识符,并且在 API 更改时完全崩溃。

因此,在我看来,这些规则或策略都没有绝对的价值。我鼓励你对此问题采取实用主义的方法。

取得 9.10

命名是一种创造性行为

它不容易被简单的技术规则所包含。

显然,标识符使用得越广泛,良好的命名就越重要。因此,对于声明通常不在程序员视线范围内的标识符来说,这一点尤为重要:构成 API 的全局名称。

取得 9.11

文件作用域的标识符必须是全面的

这里所说的 全面性 应该从标识符的类型中得出。类型名称、常量、变量和函数通常服务于不同的目的,因此应用不同的策略。

取得 9.12

类型名称标识一个概念

这种概念的例子包括 time 用于 struct timespecsize 用于 size_t,一组乌鸦用于 enum 乌鸦,person 用于收集有关人们数据的结构,list 用于项目的链表,dictionary 用于查询数据结构,等等。如果你在为数据结构、枚举或算术类型构思概念时遇到困难,你可能需要重新审视你的设计。

取得 9.13

全局常量标识一个工件

即,一个常数 因其某种原因而突出 出于其他相同类型的可能常数:它具有特殊的意义。它可能具有这种意义是因为一些超出我们控制的外部原因(M_PI 对于 π),因为 C 标准这么说(falsetrue),因为执行平台的限制(SIZE_MAX),为了真实(corvid_num),因为文化上的原因(fortytwo),或者作为设计决策。

通常,我们很快就会看到,文件作用域变量(全局变量)是不被看好的。尽管如此,它们有时是不可避免的,因此我们必须有一个命名它们的概念。

Takeaway 9.14

全局变量标识状态

这样的变量通常命名为 toto_initialized,以表示库 toto 已经被初始化,onError 用于文件作用域但内部变量,该变量在必须拆除的库中设置,visited_entries 用于收集共享数据的哈希表。

Takeaway 9.15

函数或功能宏标识一个动作

并非所有,但许多 C 标准库中的函数都遵循该规则,并使用动词作为它们名称的组成部分。以下是一些示例:

  • 一个比较两个字符串的标准函数是 strcmp

  • 一个查询属性的标准化宏是 isless

  • 访问数据成员的函数可以被称为 toto_getFlag。

  • 设置此类成员的对应函数将是 toto_setFlag。

  • 一个乘以两个矩阵的函数是 matrixMult。

摘要

  • 编码风格是一个文化问题。要有耐心和宽容。

  • 代码格式化是视觉习惯的问题。它应该由你的环境自动提供,这样你和你的同事就可以轻松地阅读和编写代码。

  • 变量、函数和类型的命名是一门艺术,并在你代码的全面性中扮演着核心角色。

第十章. 组织和文档

本章涵盖

  • 如何记录接口

  • 如何解释实现

作为一项重要的社会、文化和经济活动,编程需要一定的组织形式才能成功。就像编码风格一样,初学者往往低估了应该投入到代码、项目和文档中的努力:不幸的是,我们中的许多人不得不在写完代码一段时间后阅读自己的代码,却对它是什么毫无头绪。

记录或更普遍地说,解释程序代码不是一件容易的任务。我们必须在提供上下文和必要信息以及枯燥地陈述显而易见的事实之间找到正确的平衡。让我们看看以下两行:

**1**      u = fun4you(u, i, 33, 28);  // ;)
**2**      ++i;                        // incrementing i

第一行不太好,因为它使用了魔法常量,一个不说明正在发生什么的函数名,以及一个没有太多意义的变量名,至少对我来说是这样。笑脸注释表明程序员在编写这个时很开心,但这对于普通读者或维护者来说并不很有帮助。

在第二行,注释是多余的,它陈述了任何甚至不太有经验的程序员都知道的关于++操作符的知识。

将此与以下内容进行比较:

**1**   /* 33 and 28 are suitable because they are coprime. */
**2**   u = nextApprox(u, i, 33, 28);
**3**   /* Theorem 3 ensures that we may move to the next step. */
**4**   ++i;

在这里,我们可以推断出更多。我预计 u 是一个浮点值,可能是double:即,受近似过程的影响。这个过程分步骤进行,由 i 索引,并需要一些额外的参数,这些参数需要满足素性条件。

一般而言,我们按照重要性的顺序有什么为什么如何以何种方式规则:

摘要 10.1(什么)

函数接口描述做了什么

摘要 10.2(为什么)

接口注释记录了函数的目的

摘要 10.3(如何)

函数代码说明了函数是如何组织的。

摘要 10.4(以何种方式)

代码注释解释了函数细节是如何实现的

事实上,如果你考虑一个更大的库项目,该项目被其他人使用,你预计所有用户都会阅读接口规范(例如在man页面的概要部分),其中大多数人会阅读关于这些接口的解释(man页面的其余部分)。其中很少有人会查看源代码并了解如何以何种方式一个特定的接口实现以这种方式做事。

这些规则的一个直接后果是代码结构和文档是相辅相成的。接口规范和实现之间的区别尤为重要。

摘要 10.5

分离接口和实现

这条规则体现在使用两种不同的 C 源文件:头文件**C*,通常以".h"结尾;和*翻译单元**CTU),通常以".c"结尾。

语法注释在这两种源文件中有两个不同的角色,应该分开:

摘要 10.6

记录接口—解释实现

10.1. 接口文档

与 Java 和 Perl 等更现代的语言相比,C 没有“内置”的文档标准。但近年来,一个跨平台的公共领域工具在许多项目中得到了广泛采用:doxygen (www.doxygen.nl/)。它可以用来自动生成网页、PDF 手册、依赖图等等。但即使你不使用 doxygen 或其他等效工具,你也应该使用它的语法来记录接口。

摘要 10.7

彻底记录接口

Doxygen 有很多类别可以帮助你做到这一点,但更深入的讨论超出了本书的范围。只需考虑以下示例:

heron_k.h
**116**   /**
**117**    ** @brief use the Heron process to approximate @a a to the
**118**    ** power of ![](https://github.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/mdn-c/img/box_v.jpg)1/k![](https://github.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/mdn-c/img/box_v.jpg)
**119**    **
**120**    ** Or in other words this computes the @f$k^{th}@f$ root of @a a.
**121**    ** As a special feature, if @a k is ![](https://github.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/mdn-c/img/box_v.jpg)-1![](https://github.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/mdn-c/img/box_v.jpg) it computes the
**122**    ** multiplicative inverse of @a a.
**123**    **
**124**    ** @param a must be greater than ![](https://github.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/mdn-c/img/box_v.jpg)0.0![](https://github.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/mdn-c/img/box_v.jpg)
**125**    ** @param k should not be ![](https://github.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/mdn-c/img/box_v.jpg)0![](https://github.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/mdn-c/img/box_v.jpg) and otherwise be between
**126**    ** ![](https://github.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/mdn-c/img/box_v.jpg)DBL_MIN_EXP*FLT_RDXRDX![](https://github.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/mdn-c/img/box_v.jpg) and
**127**    ** ![](https://github.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/mdn-c/img/box_v.jpg)DBL_MAX_EXP*FLT_RDXRDX![](https://github.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/mdn-c/img/box_v.jpg).
**128**    **
**129**    ** @see FLT_RDXRDX
**130**    **/
**131**   double heron(double a, signed k);

Doxygen 为该函数生成在线文档,其外观类似于图 10.1,并且能够生成我们可以包含在这本书中的格式化文本:

heron_k.h

heron:使用 Heron 过程将a1/k 次幂近似

或者换句话说,这是计算 ak 次根。作为一个特殊功能,如果 k-1,它将计算 a 的乘法逆。

参数:

a 必须大于 0.0
k 应该
DBL_MIN_EXPFLT_RDXRDX 和 DBL_MAX_EXPFLT_RDXRDX。

另请参阅: FLT_RDXRDX

double heron(double a, signed k);

heron_k.h

FLT_RDXRDX: FLT_RADIX 的基数 2

这对于下面的一些代码在内部是必需的。

# define FLT_RDXRDX something
图 10.1. doxygen 生成的文档

如你所猜测的,以 @ 开头的单词对 doxygen 有特殊意义:它们是其关键字的开始。这里我们有 @param、@a 和 @brief。第一个记录函数参数,第二个在文档的其余部分引用该参数,最后一个提供了函数的简要概述。

此外,我们注意到在注释中存在一些标记能力,并且 doxygen 能够识别翻译单元 "heron_k.c" 中定义函数及其涉及实现的不同函数的调用图。

为了提供良好的项目组织,重要的是用户能够轻松地找到相关的部分,而无需四处搜索。

收获 10.8

将代码组织成具有强语义连接的单元。

最常见的方法是将处理特定数据类型的所有函数分组到一个头文件中。对于 struct brian 的典型头文件 "brian.h" 可能如下所示:

 **1**   #ifndef BRIAN_H
 **2**   #define BRIAN_H 1
 **3**   #include <time.h>
 **4**
 **5**   /** @file
 **6**    ** @brief Following Brian the Jay
 **7**    **/
 **8**
 **9**   typedef struct brian brian;
**10**   enum chap { sct, en, };
**11**   typedef enum chap chap;
**12**
**13**   struct brian {
**14**     struct timespec ts; /**< point in time */
**15**     unsigned counter;   /**< wealth        */
**16**      chap masterof;     /**< occupation    */
**17**   };
**18**
**19**   /**
**20**    ** @brief get the data for the next point in time
**21**    **/
**22**   brian brian_next(brian);
**23**
**24**   ...
**25**   #endif

该文件包含使用 struct 所必需的所有接口。它还包括可能需要编译这些接口并使用 include guards**^C(这里宏为 BRIAN_H)来防止多次包含的其他头文件。

10.2. 实现

如果你阅读的是优秀程序员编写的代码(你应该经常这样做!),你会注意到它通常注释很少。然而,如果读者具备 C 语言的基本知识,它可能仍然很容易阅读。优秀的编程只需要解释那些不明显的想法和前提(困难的部分)。代码的结构显示了它做什么以及如何做。

收获 10.9

直接实现。

C 程序是对要做什么的描述性文本。我们之前引入的实体命名规则对于使描述性文本可读和清晰起着至关重要的作用。另一个要求是,通过视觉上明显区分的 {} 块的结构,以及与之相关的综合控制语句,要有明显的控制流程。

收获 10.10

控制流程必须明显。

有许多方法可以混淆控制流程。以下是最重要的几种:

  • 嵌套跳转: – 隐藏在复杂的嵌套结构中的breakcontinuereturngoto^([1])语句,最终与循环结构结合。

    ¹

    这些内容将在第 13.2.2 节和第 14.5 节中讨论。

  • 飞蛾扑火表达式: – 控制表达式,以不寻常的方式组合大量运算符(例如,!!++``*p----> 0),以至于必须用放大镜检查才能理解控制流从这里开始。

在接下来的章节中,我们将关注两个对于 C 代码的可读性和性能至关重要的概念。一个可以是一个方便的工具,用于简写某个功能,但如果使用不当,也可能使使用它的代码变得晦涩,并触发微妙的错误(第 10.2.1 节)。正如我们之前所看到的,函数是 C 中模块化的主要选择。在这里,某些函数的特定属性特别重要:一个函数仅通过其接口与程序的其他部分交互。因此,纯函数对人类和编译器来说很容易理解,并且通常会导致相当高效的实现(第 10.2.2 节)。

10.2.1. 宏

我们已经知道一个可能被滥用的工具来混淆控制流:宏。正如你希望从第 5.6.3 节和第 8.1.2 节中回忆起来的那样,宏定义了可以包含几乎任何 C 文本的文本替换。由于我们将在这里展示的问题,许多项目完全禁止使用宏。尽管如此,C 标准的演变方向并不是这个。例如,正如我们所见,类型通用的宏是数学函数的现代接口(参见第 8.2 节);宏应该用于初始化常量(第 5.6.3 节)或用于实现编译器魔法(errno,第 8.1.3 节)。

因此,我们不应该否认这一点,而应该尝试驯服这个野兽,并制定一些简单的规则来限制可能的损害。

取得成果 10.11

宏不应该以令人惊讶的方式改变控制流。

在与初学者讨论时偶尔会出现的臭名昭著的例子包括这些:

 **1**   #define begin {
 **2**   #define end }
 **3**   #define forever for (;;)
 **4**   #define ERRORCHECK(CODE) if (CODE) return -1
 **5**
 **6**   forever
 **7**     begin
 **8**     // do something
 **9**     ERRORCHECK(x);
**10**     end

不要这样做。C 程序员的视觉习惯和我们的工具不太容易与这类东西配合,如果在复杂的代码中使用这类东西,它们几乎肯定会出错。

在这里,ERRORCHECK 宏尤其危险。其名称并不暗示其中可能隐藏着非局部跳转,例如return。其实现方式甚至更加危险。考虑以下两行:

**1**   if (a) ERRORCHECK(x);
**2**   else puts("a is 0!");

这些行被重写为

**1**   if (a) if (x) return -1;
**2**   else puts("a is 0!");

else-子句(所谓的悬挂 else)附加在最内层的 if 上,我们看不到。所以这相当于

**1**   if (a) {
**2**     if (x) return -1;
**3**     else puts("a is 0!");
**4**   }

这可能对普通读者来说相当令人惊讶。

这并不意味着在宏中完全不应用控制结构。只是它们不应该被隐藏,并且不应该产生令人惊讶的效果。这个宏本身可能并不那么明显,但它的使用并没有令人惊讶:

**1**   #define ERROR_RETURN(CODE) \
**2**   do {                       \
**3**     if (CODE) return -1;     \
**4**   } while (false)

以下宏的名称明确表示可能存在一个return。悬而未决的else问题通过替换后的文本得到处理:

**1**   if (a) ERROR_RETURN(x);
**2**   else puts("a is 0!");

下一个示例按照预期结构化了代码,其中else与第一个if相关联:

**1**   if (a) do {
**2**     if (CODE) return -1;
**3**   } while (false);
**4**   else puts("a is 0!");

do-while(false)-技巧显然很丑陋,你不应该滥用它。但这是一个标准的技巧,用于在不改变肉眼可见的块结构的情况下,用 {} 块包围一个或多个语句。

取走 10.12

函数式宏在语法上应该表现得像函数调用。

可能的陷阱包括:

  • if without else:** 已经演示过。

  • 尾随分号这些可以以令人惊讶的方式终止外部控制结构。

  • 逗号运算符:*** 逗号在 C 语言中是一个模糊的角色。在大多数情况下,它被用作列表分隔符,例如用于函数调用、枚举声明或初始化器。在表达式的上下文中,它是一个控制运算符。避免使用它。

  • 可续表达式: 当放入非平凡上下文中时,这些表达式将以意想不到的方式绑定到运算符上.^([[Exs 1]]) 在替换文本中,将参数和表达式用括号括起来。

    ^([Exs 1])

    考虑一个宏函数sum(a, b),它被实现为a+`b。`su`m(`5, 2`)*``7`的结果是什么?

  • 多重评估: 宏是文本替换。如果一个宏参数被使用两次(或更多),其效果会被执行两次.^([[Exs 2]])

    ^([例 2])

    max(a, b)的实现为((a) < (b) ? (b) : (a))。对于max(i++, 5)会发生什么?

10.2.2. 纯函数

Takeaway 10.13

函数参数是通过值传递的

也就是说,当我们调用一个函数时,所有参数都会被评估,并且参数(函数局部变量)会接收到这些值的初始化。然后函数执行它需要做的操作,并通过返回值发送计算结果。

目前,我们让两个函数操作同一对象的唯一可能性是声明一个对象,使得声明对两个函数都是可见的。这样的全局变量**^C有很多缺点:它们使代码缺乏灵活性(要操作的对象是固定的)、难以预测(修改的位置散布各处),并且难以维护。

Takeaway 10.14

全局变量是不受欢迎的。

具有以下两个特性的函数被称为纯**^C函数:

  • 函数除了返回值外没有其他影响。

  • 函数的返回值只取决于其参数。

对纯函数执行的唯一兴趣是其结果,而这个结果只取决于传递的参数。从优化的角度来看,纯函数可以被移动或甚至与其他任务并行执行。执行可以在参数可用时开始,必须在结果被使用之前完成。

会使函数失去纯性的影响包括所有那些除了提供返回值之外改变抽象状态机的操作。例如,

  • 函数通过除其参数之外的其他方式读取程序的可变状态。

  • 函数修改了一个全局对象。

  • 函数在调用之间保持持久内部状态.^([2])

    ²

    在对同一函数的多次调用之间,可以使用局部static变量来建立持久状态。我们将在第 13.2 节中看到这个概念。

  • 函数执行 IO.^([3])

    ³

    例如,通过使用printf可以发生这样的 IO。

纯函数是执行小任务的函数的一个非常好的模型,但一旦我们需要执行更复杂的任务,它们的局限性就变得相当明显。另一方面,优化器喜欢纯函数,因为它们对程序状态的影响可以简单地通过它们的参数和返回值来描述。纯函数对抽象状态机可能产生的影响非常局部且易于描述。

Takeaway 10.15

尽可能将小任务表示为纯函数。

对于纯函数,即使是在面向对象编程风格中,我们也可以走得很远,如果我们愿意在第一次尝试时接受一点数据复制的代价。考虑以下结构类型 rat,它被用来进行有理数运算:

rationals.h
 **8**   struct rat {
 **9**     bool sign;
**10**     size_t num;
**11**     size_t denom;
**12**   };

这是对这种类型的直接实现,而且你绝对不应该将其用作库,除非在这个学习经验的范围内。为了简单起见,它有一个与分子相同的类型(size_t)的分母,并通过成员.sign 跟踪数字的符号。第一个(纯)函数是 rat_get,它接受两个数字并返回一个表示它们商的有理数:

rationals.c
 **3**   rat rat_get(long long num, unsigned long long denom) {
 **4**     rat ret = {
 **5**       .sign = (num < 0),
 **6**       .num = (num < 0) ? -num : num,
 **7**       .denom = denom,
 **8**     };
 **9**     return ret;
**10**   }

如您所见,该函数相当简单。它只是使用正确的符号、分子和分母值初始化一个复合字面量。请注意,如果我们以这种方式定义一个有理数,几个表示将代表相同的有理数。例如,数字6by15.jpg2by5.jpg相同。

为了处理这种表示中的等价性,我们需要维护函数。主要思想是这些有理数应该始终是归一化的:也就是说,使用分子和分母具有最少因子的表示。这不仅更容易为人类所理解,而且在执行算术运算时也可能避免溢出:

rationals.c
**12**   rat rat_get_normal(rat x) {
**13**     size_t c = gcd(x.num, x.denom);
**14**     x.num /= c;
**15**     x.denom /= c;
**16**     return x;
**17**   }

在这里,gcd 函数正如我们之前所描述的那样。

另一个函数执行归一化的逆操作;它通过一个冗余因子乘以分子和分母:

rationals.c
**19**   rat rat_get_extended(rat x, size_t f) {
**20**     x.num *= f;
**21**     x.denom *= f;
**22**     return x;
**23**   }

这样,我们可以定义其他人应该使用的函数:rat_get_prod 和 rat_get_sum。

看看 rat_get_prod:

rationals.c
**25**   rat rat_get_prod(rat x, rat y) {
**26**     rat ret = {
**27**       .sign = (x.sign != y.sign),
**28**       .num = x.num * y.num,
**29**       .denom = x.denom * y.denom,
**30**     };
**31**     return rat_get_normal(ret);
**32**   }

它首先以简单的方式计算结果的一种表示:通过分别相乘分子和分母。然后,得到的表示可能没有归一化,因此我们在返回结果时调用 rat_get_normal。

现在,rat_get_sum 要复杂一些。在我们可以计算结果的分子之前,我们必须找到公共分母:

rationals.c
**34**   rat rat_get_sum(rat x, rat y) {
**35**     size_t c = gcd(x.denom, y.denom);
**36**     size_t ax = y.denom/c;
**37**     size_t bx = x.denom/c;
**38**     x = rat_get_extended(x, ax);
**39**     y = rat_get_extended(y, bx);
**40**     assert(x.denom == y.denom);
**41**
**42**     if (x.sign == y.sign) {
**43**       x.num += y.num;
**44**     } else if (x.num > y.num) {
**45**       x.num -= y.num;
**46**     } else {
**47**       x.num = y.num - x.num;
**48**       x.sign = !x.sign;
**49**     }
**50**     return rat_get_normal(x);
**51**   }

此外,我们必须跟踪两个有理数的符号,以了解我们应该如何将分子相加。

如您所见,这些函数都是纯函数,这确保了它们可以很容易地使用,即使在我们的实现中也是如此。我们唯一需要关注的是始终将函数的返回值分配给一个变量,例如在第 38 行。否则,由于我们不操作对象 x,而只是操作它的值,函数中的更改将会丢失.^([[Exs 3]]) ^([[Exs 4]])

^([Exs 3])

函数 rat_get_prod 可能会产生中间值,这可能导致它产生错误的结果,即使乘法的数学结果可以在 rat 中表示。这是怎么回事?

^([Exs 4])

重新实现 rat_get_prod 函数,使其每次数学结果值可以在 rat 中表示时都产生正确的结果。这可以通过两次调用 rat_get_normal 而不是一次来实现。

如前所述,由于重复复制,这可能会导致编译后的代码效率不如预期。但这根本不算什么:通过良好的编译器,复制操作的开销可以保持相对较低。当开启优化时,它们通常可以直接在结构体上操作,就像它从这样的函数返回一样。然后,这样的担忧可能完全过早,因为你的程序既短又简单,或者因为它的真正性能问题在于其他地方。通常,这对于我们迄今为止达到的编程技能水平来说应该完全足够。稍后,我们将学习如何通过使用 inline 函数 (第 15.1 节) 和许多现代工具链提供的 链接时间优化 来有效地使用这种策略。

列表 10.1 列出了我们迄今为止看到的 rat 类型的所有接口(第一组)。我们已经研究了其他函数的接口,这些函数在 指针 上工作。这些将在 第 11.2 节 中更详细地解释。

列表 10.1. 用于有理数计算的类型。
 **1**   #ifndef RATIONALS_H
 **2**   # define RATIONALS_H 1
 **3**   # include <stdbool.h>
 **4**   # include "euclid.h"
 **5**
 **6**   typedef struct rat rat;
 **7**
 **8**   struct rat {
 **9**     bool sign;
**10**     size_t num;
**11**     size_t denom;
**12**   };
**13**
**14**   /* Functions that return a value of type rat. */
**15**   rat rat_get(long long num, unsigned long long denom);
**16**   rat rat_get_normal(rat x);
**17**   rat rat_get_extended(rat x, size_t f);
**18**   rat rat_get_prod(rat x, rat y);
**19**   rat rat_get_sum(rat x, rat y);
**20**
**21**
**22**   /* Functions that operate on pointers to rat. */
**23**   void rat_destroy(rat* rp);
**24**   rat* rat_init(rat* rp,
**25**                 long long num,
**26**                 unsigned long long denom);
**27**   rat* rat_normalize(rat* rp);
**28**   rat* rat_extend(rat* rp, size_t f);
**29**   rat* rat_sumup(rat* rp, rat y);
**30**   rat* rat_rma(rat* rp, rat x, rat y);
**31**
**32**   /* Functions that are implemented as exercises. */
**33**   /** @brief Print @a x into @a tmp and return tmp. **/
**34**   char const* rat_print(size_t len, char tmp[len], rat const* x);
**35**   /** @brief Print @a x normalize and print. **/
**36**   char const* rat_normalize_print(size_t len, char tmp[len],
**37**                                   rat const* x);
**38**   rat* rat_dotproduct(rat rp[static 1], size_t n,
**39**                       rat const A[n], rat const B[n]);
**40**
**41**   #endif

摘要

  • 对于程序的每一部分,我们必须区分对象(我们在做什么?)、目的(我们为什么要这样做?)、方法(我们如何做?)和实现(我们以何种方式做?)。

  • 函数和类型接口是软件设计的精髓。更改它们是昂贵的。

  • 实现应该尽可能直接,并且其控制流程应该明显。应避免复杂的推理,并在必要时明确表达。

第十一章. 指针

本章涵盖

  • 指针操作简介

  • 使用指针与结构体、数组和函数

指针是深入理解 C 的第一个真正障碍。它们用于需要从代码的不同点访问对象或动态结构化数据的上下文中。

不经验丰富的程序员在指针和数组之间的混淆是出了名的,所以请小心,你可能会在正确使用这些术语时遇到困难。另一方面,指针是 C 最重要的特性之一。它们是一个很大的优势,可以帮助我们抽象出特定平台的位和奇偶性,并使我们能够编写可移植的代码。所以,当你处理本章内容时,请务必保持耐心,因为这对于理解本书的大部分内容至关重要。

术语 指针**^C 代表一种特殊的派生类型构造,它“指向”或“引用”某个东西。我们已经看到了这种构造的语法,即一个类型(引用类型**^C),后面跟着一个 * 字符。例如,p0 是一个指向 double 的指针:

double* p0;

理念是我们有一个变量(指针),它指向另一个对象的内存:

在本章中,我们需要区分指针(箭头左侧)和被指向的无名对象(箭头右侧)。

我们第一次使用指针将打破函数调用者的代码和函数内部代码之间的障碍,从而允许我们编写非纯函数。这个示例将是一个具有以下原型的函数:

void double_swap(double* p0, double* p1);

在这里,我们看到两个函数参数“指向”double类型的对象。在示例中,函数 double_swap 旨在交换(交换)这两个对象的内容。例如,当函数被调用时,p0 和 p1 可能分别指向由调用者定义的double变量 d0 和 d1,这些变量如下所示:

图片

通过接收有关两个此类对象的信息,函数 double_swap 可以有效地更改两个double对象的内容,而不会更改指针本身:

图片

使用指针,函数将能够直接更改调用函数的变量;没有指针或数组的纯函数无法做到这一点。

在本章中,我们将详细介绍与指针相关的不同操作(第 11.1 节)以及指针具有特定属性的特定类型:结构体(第 11.2 节)、数组(第 11.3 节)和函数(第 11.4 节)。

11.1. 指针操作

指针是一个重要的概念,因此有几种专门针对它们的 C 语言操作和特性。最重要的是,特定的运算符允许我们处理指针与其所指向的对象之间的“指向”和“被指向”关系(第 11.1.1 节)。此外,指针被认为是标量**^C:为它们定义了算术运算,偏移量加法(第 11.1.2 节)和减法(第 11.1.3 节);它们具有状态(第 11.1.4 节);并且它们有一个专门的“空”状态(第 11.1.5 节)。

11.1.1. 地址运算符和对象运算符

如果我们必须执行无法用纯函数表达的任务,事情会变得更加复杂。我们必须在不是函数变量的对象中“探索”。指针是进行这种抽象的合适工具。

因此,让我们使用之前提到的 double_swap 函数来交换两个double对象 d0 和 d1 的内容。对于调用,我们使用一元地址运算符**C*“**`&`**”。它允许我们通过其*地址**C来引用一个对象。对我们的函数的调用可能如下所示:

   double_swap(&d0, &d1);

地址运算符返回的类型是指针类型**^C,可以使用我们已看到的*符号指定。函数的一个实现可能如下所示:

void double_swap(double* p0, double* p1) {
  double tmp = *p0;
  *p0 = *p1;
  *p1 = tmp;
}

在函数内部,指针 p0 和 p1 持有函数应该操作的对象的地址:在我们的例子中,d0 和 d1 的地址。但函数对 d0 和 d1 这两个变量的名称一无所知;它只知道 p0 和 p1。

要访问它们,使用另一个与取地址运算符相反的构造:一元对象^C运算符*”:*p0 对应于第一个参数的对象。在上一次调用中,那将是 d0,同样*p1 是对象 d1.^([[Exs 1]])

^([Exs 1])

编写一个函数,该函数接收指向三个对象的指针,并循环地移动这些对象的值。

请注意,在double_swap的定义中,*字符扮演着两种不同的角色。在声明中,它创建了一个新类型(指针类型),而在表达式中,它取消引用^C 指向的对象,即指针引用^C 的对象。为了帮助区分这两个相同符号的用法,我们通常在它修改类型(如double*)时将*向左对齐且中间不留空格,如果它取消引用指针(*p0)时则向右对齐。

记住从第 6.2 节中提到的,除了持有有效的地址外,指针还可能是空或不确定的。

11.1 节要点

使用不确定或空指针时会有未定义的行为。

然而,在实践中,这两种情况通常会表现出不同的行为。第一种可能会访问内存中的随机对象并修改它。这通常会导致难以追踪的 bug,因为它会进入它不应该进入的对象。第二种,如果指针为空,则会在开发早期就表现出来,并且会优雅地崩溃我们的程序。请将此视为一个特性。

11.1.2. 指针加法

我们已经看到,一个有效的指针持有其引用类型对象的地址,但实际上 C 语言假设的更多:

11.2 节要点

有效的指针指向引用类型数组的第一个元素。

或者换句话说,指针不仅可以用来引用引用类型的单个实例,还可以用来引用长度为未知n的数组。

在语法上,指针和数组之间的这种纠缠关系又向前迈出了重要的一步。事实上,对于double_swap函数的指定,我们甚至可能不需要指针表示法。在我们已经使用的表示法中,它也可以写成

void double_swap(double p0[static 1], double p1[static 1]) {
  double tmp = p0[0];
  p0[0] = p1[0];
  p1[0] = tmp;
}

无论是使用数组表示法作为接口,还是使用[0]来访问第一个元素,都是简单的重写操作^C,这些操作都内置于 C 语言中。我们稍后会看到更多这方面的内容。

简单的加法运算允许我们访问数组的以下元素。此函数计算数组的所有元素之和:

double sum0(size_t len, double const* a) {
  double ret = 0.0;
  for (size_t i = 0; i < len; ++i) {
    ret += *(a + i);
  }
  return ret;
}

这里,表达式a+i 是一个指针,它指向数组的第i^(th)个元素:

指针加法可以以不同的方式完成,所以以下函数以完全相同的顺序累加数组:

double sum1(size_t len, double const* a) {
  double ret = 0.0;
  for (double const* p = a; p < a+len; ++p) {
    ret += *p;
  }
  return ret;
}
double sum2(size_t len, double const* a) {
  double ret = 0.0;
  for (double const*const aStop = a+len; a < aStop; ++a) {
    ret += *a;
  }
  return ret;
}

在函数 sum1 的迭代 i 中,我们有以下情况:

指针 p 遍历数组的元素,直到它大于或等于 a+len,这是第一个位于数组之外的指针值。

对于函数 sum2,我们有以下情况:

在这里,a 指向数组的 i^(th) 个元素。0^(th) 个元素在函数内部不再引用,但数组结束的信息保存在变量 aStop 中。

这些函数可以像以下这样调用:

double A[7] = { 0, 1, 2, 3, 4, 5, 6, };
double s0_7 = sum0(7, &A[0]);    // For the whole
double s1_6 = sum0(6, &A[1]);    // For the last 6
double s2_3 = sum0(3, &A[2]);    // For the 3 in the middle

不幸的是,没有方法知道隐藏在指针后面的数组长度,因此我们必须将长度作为参数传递给函数。我们在第 6.1.3 节中看到的sizeof技巧不起作用。

摘要 11.3

数组对象的长度不能从指针中重建

因此,这里我们看到与数组的一个主要区别。

摘要 11.4

指针不是数组

如果我们将数组通过指针传递给函数,保留数组的实际长度很重要。这就是为什么我们在这本书中更喜欢使用数组表示法作为指针接口:

double sum0(size_t len, double const a[len]);
double sum1(size_t len, double const a[len]);
double sum2(size_t len, double const a[len]);

这些指定了与之前显示的完全相同的接口,但它们向代码的普通读者阐明了 a 应该有 len 个元素。

11.1.3. 指针减法和差

我们之前讨论的指针算术主要涉及整数和指针的加法。还有一个相反的操作,可以从指针中减去一个整数。如果我们想向下遍历数组元素,我们可以使用这个:

double sum3(size_t len, double const* a) {
  double ret = 0.0;
  double const* p = a+len-1;
  do {
    ret += *p;
    --p;
  } while (p > a);
  return ret;
}

在这里,p 从 a+(len-1) 开始,在 i^(th) 次迭代中的情况如下:

注意,这个函数中的求和顺序是反转的.^([1])

¹

由于四舍五入的差异,结果可能略不同于本系列前三个函数。

还有一个操作,指针差**^C,它接受两个指针并计算它们之间元素数量的整数值。为了说明这一点,我们将 sum3 扩展到新版本,该版本检查错误条件(数组中的一个元素是无穷大)。在这种情况下,我们想要打印一个全面的错误信息并将罪魁祸首返回给调用者:^([2])

²

isinf 来自 math.h 头文件。

double sum4(size_t len, double const* a) {
  double ret = 0.0;
  double const* p = a+len-1;
  do {
    if (isinf(*p)) {
      fprintf(stderr,
             "element \%tu of array at \%p is infinite\n",
              p-a,           // Pointer difference!
              (void*)a);     // Prints the pointer value
      return *p;
    }
    ret += *p;
    --p;
  } while (p > a);
  return ret;
}

在这里,我们使用表达式 p-a 来计算数组中实际元素的位置。

这只允许两个指针指向同一数组对象的元素:

摘要 11.5

只能从数组对象的元素中减去指针

这种差值的值仅仅是相应数组元素的索引之差:

double A[4] = { 0.0, 1.0, 2.0, -3.0, };
double* p = &A[1];
double* q = &A[3];
assert(p-q == -2);

我们强调过,对于对象大小正确的类型是 size_t,这是一个无符号类型,在许多平台上与 unsigned 不同。这在指针差类型中也有对应:一般来说,我们不能假设一个简单的 int 就足够容纳所有可能的值。因此,标准头文件 stddef.h 为我们提供了另一个类型。在大多数架构上,它只是与 size_t 对应的有符号整数类型,但我们不必过于关心。

所有指针差都有类型 ptrdiff_t.

在早期(要点 11.1),我们看到了我们必须小心指针包含的地址(或没有包含的地址)。指针有一个值,即它们包含的地址,而这个值可能会改变。
要点 11.8
在大多数情况下,确保这一点最简单的方法是显式初始化指针变量(要点 6.22)。

|

使用 ptrdiff_t 来编码位置或大小的有符号差值。

11.1.4. 指针有效性

要点 11.7

<stddef.h>
函数 sum4 也展示了用于调试目的打印指针值的配方。我们使用格式字符 %p,并将指针参数通过 (void*``)a 转换为神秘的类型 void*。目前,将这个配方视为已知;我们还没有全部的负担来完全理解它(更多细节将在 第 12.4 节 中介绍)。

|

要点 11.6

|

如果指针没有有效的地址,将其设置为 0 非常重要,不应被遗忘。这有助于检查和跟踪指针是否已被设置。

打印时,将指针值转换为 void*,并使用格式* *%p**。
我们看到了一些不同类型 表示 的例子:也就是说,平台在对象中存储特定类型值的方式。例如,类型 size_t 的表示可能对另一个类型,例如 double,完全无意义。只要我们只直接使用变量,C 的类型系统就会保护我们免受这些表示的任何混淆;一个 size_t 对象将始终按此类访问,永远不会被解释为(无意义的)double

尽快将指针变量设置为 0

为了避免笨拙的比较(要点 3.3),在 C 程序中你经常会看到这样的代码:

char const* name = 0;

// Do something that eventually sets name

if (name) {
  printf("today's name is %s\n", name);
} else {
  printf("today we are anonymous\n");
}

因此,控制所有指针变量的状态非常重要。我们必须确保指针变量始终为空,除非它们指向我们想要操作的有效对象。

要点 11.10
|

|

|

指针有真值。

如果我们不小心使用它们,指针可能会打破这个障碍,并导致我们尝试将size_t的表示解释为double。更普遍地说,C 甚至为当它们被解释为特定类型时没有意义的位模式创造了一个术语:该类型的陷阱表示**^C。这种用词(trap)是为了恐吓。

Takeaway 11.11

访问具有其类型陷阱表示的对象具有未定义的行为。

如果你这样做,会发生丑陋的事情,所以请不要尝试。

因此,不仅指针必须设置为一个对象(或空),而且这样的对象还必须具有正确的类型。

Takeaway 11.12

当解引用时,指向的对象必须是指定的类型。

作为直接后果,指向数组界限之外的指针不得被解引用:

double A[2] = { 0.0, 1.0, };
double* p = &A[0];
printf("element %g\n", *p); // Referencing object
++p;                        // Valid pointer
printf("element %g\n", *p); // Referencing object
++p;                        // Valid pointer, no object
printf("element %g\n", *p); // Referencing non-object
                            // Undefined behavior

在这里,在最后一行,p 的值超出了数组的界限。即使这可能是一个有效对象的地址,我们也不知道它指向的对象是什么。所以即使在这个点上 p 是有效的,将其内容作为double类型访问也没有意义,C 通常禁止这种访问。

在前面的例子中,指针加法本身是正确的,只要我们不访问最后一行的对象。指针的有效值是数组元素的地址以及数组之外的地址。否则,像例子中的那样使用指针加法的for循环将无法可靠地工作。

Takeaway 11.13

一个指针必须指向一个有效对象或一个有效对象之后的位置,或者为空。

因此,这个例子只工作到最后一行,因为最后的++p 将指针值留在了数组之后的一个元素。这个例子版本仍然遵循与之前类似的模式:

double A[2] = { 0.0, 1.0, };
double* p = &A[0];
printf("element %g\n", *p); // Referencing object
p += 2;                     // Valid pointer, no object
printf("element %g\n", *p); // Referencing non-object 
                           // Undefined behavior

而在这个最后的例子中,增量操作可能会导致崩溃:

double A[2] = { 0.0, 1.0, };
double* p = &A[0];
printf("element %g\n", *p); // Referencing object
p += 3;                     // Invalid pointer addition
                            // Undefined behavior

11.1.5. 空指针

你可能想知道,在所有关于指针的讨论中,为什么还没有使用宏NULL。原因是,不幸的是,“值为 0 的通用指针”这个简单概念并没有取得很好的成功。

C 有一个与任何指针类型的 0 值相对应的空指针**^C概念。在这里,

³

注意nullNULL的不同大小写。

double const*const nix = 0;
double const*const nax = nix;

nix 和 nax 将是值为 0 的指针对象。但不幸的是,一个空指针常数**^C并不是你所期望的。

首先,这里的术语constant指的是编译时常数,而不是const-qualified对象。因此,出于这个原因,指针对象不是空指针常数。其次,这些常数的允许类型受到限制:它可以是任何整数类型或void类型的常数表达式。不允许其他指针类型,我们将在第 12.4 节中了解那种“类型”的指针。

C 标准中对宏 NULL 的可能扩展定义相当宽松;它只需要是一个空指针常量。因此,C 编译器可以选择以下任何一个用于它:

扩展 类型
0U 无符号
0 有符号
'\0'
值为 0 的枚举常量
0UL 无符号长整型
0L 有符号长整型
0ULL 无符号长整型
0LL 有符号长整型
(void*)0 void*

常用值是 00L,以及 (void*``)0。^([4])

理论上,NULL 的可能扩展甚至更多,例如 ((char)+0)((short)-0)

重要的是,NULL 后面的类型并没有由 C 标准规定。通常,人们用它来强调他们正在谈论的是一个指针常量,这在许多平台上并不是这样。在不完全掌握的情况下使用 NULL 甚至可能是危险的。这将在 第 16.5.2 节 中讨论的函数具有可变数量参数的上下文中出现。目前,我们将采取最简单的解决方案:

取得第 11.14 条经验

不要使用NULL.*

NULL 隐藏的比它阐明的多。要么使用 0,要么如果你真的想强调该值是一个指针,直接使用魔法令牌序列 (void*``)0

11.2. 指针和结构

结构类型的指针对于大多数 C 语言编程至关重要,因此已经制定了一些特定的规则和工具来简化这种典型用法。例如,让我们考虑一个任务,即规范化我们之前遇到的 struct timespec。在以下函数中使用指针参数允许我们直接操作对象:

timespec.c
**10**   /**
**11**    ** @brief compute a time difference
**12**    **
**13**    ** This uses a @c double to compute the time. If we want to
**14**    ** be able to track times without further loss of precision
**15**    ** and have @c double with 52 bit mantissa, this
**16**    ** corresponds to a maximal time difference of about 4.5E6
**17**    ** seconds, or 52 days.
**18**    **
**19**    **/
**20**   double timespec_diff(struct timespec const* later,
**21**                        struct timespec const* sooner){
**22**     /* Be careful: tv_sec could be an unsigned type */
**23**     if (later->tv_sec < sooner->tv_sec)
**24**       return -timespec_diff(sooner, later);
**25**     else
**26**       return
**27**          (later->tv_sec - sooner->tv_sec)
**28**          /* tv_nsec is known to be a signed type. */
**29**          + (later->tv_nsec - sooner->tv_nsec) * 1E-9;
**30**   }

为了方便,我们在这里使用一个新的运算符,->。其箭头符号旨在表示一个指针作为左操作数,指向底层 struct 的成员作为右操作数。它相当于 *.. 的组合。为了达到相同的效果,我们可能需要使用括号并写成 (``*a).tv_sec 而不是 a->tv_sec。这可能会很快变得有些笨拙,所以 -> 运算符是大家普遍使用的。

注意,像 a->tv_nsec 这样的结构不是指针,而是一个类型为 long 的对象,即这个数字本身。

作为另一个例子,让我们再次考虑我们在 第 10.2.2 节 中引入的有理数类型 rat。在 列表 10.1 中操作该类型指针的函数可以写成如下:

rationals.c
**95**   void rat_destroy(rat* rp) {
**96**     if (rp) *rp = (rat){ 0 };
**97**   }

函数 rat_destroy 确保对象中可能存在的所有数据都被擦除并设置为全零位 0

rationals.c
 **99**   rat* rat_init(rat* rp,
**100**                 long long num,
**101**                 unsigned long long denom) {
**102**     if (rp) *rp = rat_get(num, denom);
**103**     return rp;
**104**   }
rationals.c
**106**   rat* rat_normalize(rat* rp) {
**107**     if (rp) *rp = rat_get_normal(*rp);
**108**     return rp;
**109**   }
rationals.c
**111**   rat* rat_extend(rat* rp, size_t f) {
**112**     if (rp) *rp = rat_get_extended(*rp, f);
**113**     return rp;
**114**   }

其他三个函数是围绕我们已知的纯函数的简单包装器。我们使用两个指针操作来测试有效性,然后,如果指针有效,就引用相关的对象。因此,即使指针参数为空,这些函数也可以安全使用。^([[Exs 2]])^([[Exs 3]])

^([Exs 2])

实现函数 rat_print,如列表 10.1 中声明的那样。这个函数应该使用->来访问其 rat*参数的成员。打印输出应采用形式 –nom/denum

^([Exs 3])

通过结合 rat_normalize 和 rat_print 实现 rat_print_normalized。

所有四个函数都会检查并返回它们的指针参数。这是一种方便的策略来组合这样的函数,正如我们可以在以下两个算术函数的定义中看到:

rationals.c
**135**   rat* rat_rma(rat* rp, rat x, rat y) {
**136**     return rat_sumup(rp, rat_get_prod(x, y));
**137**   }

函数 rat_rma(“有理数乘加”)全面展示了其目的:将两个其他函数参数的乘积加到 rp 所引用的对象上。它使用以下函数进行加法:

rationals.c
**116**   rat* rat_sumup(rat* rp, rat y) {
**117**     size_t c = gcd(rp->denom, y.denom);
**118**     size_t ax = y.denom/c;
**119**     size_t bx = rp->denom/c;
**120**     rat_extend(rp, ax);
**121**     y = rat_get_extended(y, bx);
**122**     assert(rp->denom == y.denom);
123
**124**     if (rp->sign == y.sign) {
**125**       rp->num += y.num;
**126**     } else if (rp->num > y.num) {
**127**       rp->num -= y.num;
**128**     } else {
**129**       rp->num = y.num - rp->num;
**130**       rp->sign = !rp->sign;
**131**     }
**132**     return rat_normalize(rp);
**133**   }

函数 rat_sumup 是一个更复杂的例子,其中我们应用了两个维护函数到指针参数上。^([[Exs 4]])

^([Exs 4])

实现函数 rat_dotproduct,使其从列表 10.1 计算,并将该值返回到*rp。

对于结构类型指针还有一个特殊的规则:即使结构类型本身是未知的,也可以使用它们。这种不透明结构^C*通常用于严格分离库的接口和实现。例如,一个虚构的类型 toto 可以在包含文件中如下表示:

/* forward declaration of struct toto */
struct toto;
struct toto* toto_get(void);
void toto_destroy(struct toto*);
void toto_doit(struct toto*, unsigned);

程序员和编译器都不需要更多东西来使用类型struct toto。函数 toto_get 可以用来获取类型struct toto 的对象指针,无论它在定义函数的编译单元中是如何定义的。编译器能够这样做是因为它知道所有结构指针都有相同的表示,无论底层类型的具体定义如何。

通常,这样的接口会使用 null 指针是特殊的事实。在之前的例子中,toto_doit(0, 42)可能是一个有效的用例。这就是为什么许多 C 程序员不喜欢将指针隐藏在typedef中:

/* forward declaration of struct toto_s and user type toto */
typedef struct toto_s* toto;
toto toto_get(void);
void toto_destroy(toto);
void toto_doit(toto, unsigned);

这段代码是有效的 C 代码,但它隐藏了0是一个特殊值,toto_doit 可能会接收到的这一事实。

取得 11.15

不要将指针隐藏在typedef

这与我们之前所做的为struct引入一个typedef名称不同:

/* forward declaration of struct toto and typedef toto */
typedef struct toto toto;
toto* toto_get(void);
void toto_destroy(toto*);
void toto_doit(toto*, unsigned);

在这里,接口接收指针的事实仍然足够明显。

文本处理器

对于一个文本处理器,你能使用双链表来存储文本吗?想法是通过一个包含字符串(用于文本)和指向先前和后续块的指针的struct来表示一个“文本块”。

你能构建一个函数,在给定点将文本块分成两部分吗?

一个将两个连续的文本块连接起来的?

一个将整个文本运行并通过每行一个文本块的形式呈现的?

你能创建一个函数来打印整个文本,或者打印直到文本因为屏幕大小而被截断吗?

11.3. 指针和数组

我们现在能够克服理解数组和指针之间关系的主要障碍:C 语言使用相同的语法进行指针和数组元素访问,并且它将函数的数组参数重写为指针。这两个特性为经验丰富的 C 程序员提供了方便的快捷方式,但对于新手来说可能有点难以消化。

11.3.1. 数组和指针访问相同

以下语句无论 A 是数组还是指针都成立:

11.16 节要点

两个表达式 A[i**] *(A+i**) 是等价的。

如果它是一个指针,我们理解第二个表达式。这里,它只是说我们可以写出与 A[i]相同的表达式。将数组访问的概念应用到指针上应该可以提高你代码的可读性。等价性并不意味着突然在没有任何数组对象的地方出现了一个数组。如果 A 为空,A[i]应该优雅地崩溃,就像*(A+i)一样。

如果 A 是一个数组,*(A+i)展示了 C 语言中最重要规则之一的应用,称为数组到指针退化^C:

11.17 节要点(数组退化)

数组 A 的评估返回 &A[0]**.

事实上,这就是为什么没有“数组值”以及它们带来的所有困难(要点 6.3)。每当出现需要值的数组时,它都会退化成指针,我们就会失去所有额外的信息。

11.3.2. 数组和指针参数相同

由于退化,数组不能作为函数参数。没有方法可以用数组参数调用这样的函数;在调用函数之前,我们传入的数组会退化成一个指针,因此参数类型不匹配。

但我们已经看到了具有数组参数的函数声明,那么它们是如何工作的呢?C 语言通过将数组参数重写为指针来规避这个问题。

11.18 节要点

在函数声明中,任何数组参数都会重写为一个指针。

考虑这一点以及它对 C 语言编程的意义。理解这个“主要特性”(或性格缺陷)对于轻松编程至关重要。

回到我们第 6.1.5 节中的例子,使用数组参数编写的函数可以声明如下:

size_t strlen(char const* s);
char*  strcpy(char* target, char const* source);
signed strcmp(char const* s0, char const* s1);

这两种形式完全等价,任何 C 编译器都应该能够互换使用这两种形式。

使用哪种表示法是一个关于习惯、文化或其他社会背景的问题。在这本书中,我们遵循的规则是,如果我们认为数组表示法不能为空,就使用数组表示法;如果它对应于基类型的单个项,并且也可以为空以表示特殊条件,就使用指针表示法。

如果从语义上讲一个参数是一个数组,我们还注意如果可能的话,我们期望数组的大小是多少。为了使其成为可能,通常最好在数组/指针之前指定长度。一个如下的接口

double double_copy(size_t len,
                   double target[len],
                   double const source[len]);

这讲述了一个完整的故事。如果我们处理二维数组,这会变得更加有趣。一个典型的矩阵乘法可能看起来如下:

void matrix_mult(size_t n, size_t k, size_t m,
                 double C[n][m],
                 double A[n][k],
                 double B[k][m]) {
   for (size_t i = 0; i < n; ++i) {
     for (size_t j = 0; j < m; ++j) {
       C[i][j] = 0.0;
       for (size_t l = 0; l < k; ++l) {
         C[i][j] += A[i][l]*B[l][j];
       }
     }
   }
}

原型等同于不那么易读的,并注意一旦我们重写

void matrix_mult(size_t n, size_t k, size_t m,
                 double (C[n])[m],
                 double (A[n])[k],
                 double (B[k])[m]);
void matrix_mult(size_t n, size_t k, size_t m,
                 double (*C)[m],
                 double (*A)[k],
                 double (*B)[m]);

将最内层维度作为指针,参数类型不再是数组,而是一个指向数组的指针。因此,没有必要重写后续的维度。

Takeaway 11.19

只有数组参数的最内层维度会被重写。

最后,我们通过使用数组表示法获得了许多好处。我们无需任何麻烦就能将指向可变长度数组(VLA)的指针传递给函数。在函数内部,我们可以使用传统的索引来访问矩阵的元素。为了跟踪数组长度,不需要太多的技巧:

Takeaway 11.20

在数组参数之前声明长度参数。

它们必须在你首次使用它们的地方被知晓。

不幸的是,C 语言通常不保证具有数组长度参数的函数总是被正确调用。

Takeaway 11.21

函数的数组参数有效性必须由程序员保证。

如果数组长度在编译时已知,编译器可能能够发出警告。但是,当数组长度是动态的,你基本上只能靠自己:要小心。

11.4. 函数指针

对于另一个可以使用取地址运算符&的结构,我们可以使用函数。我们在讨论atexit函数(第 8.7 节)时看到了这个概念的出现,这是一个接收函数参数的函数。规则与之前描述的数组衰减规则类似:

Takeaway 11.22(函数衰减)

一个没有后续开括号(的函数 f 衰减为其起始指针。

在类型声明和作为函数参数时,函数和函数指针在语法上与数组相似:

typedef void atexit_function(void);
// Two equivalent definitions of the same type, which hides a pointer
typedef atexit_function* atexit_function_pointer;
typedef void (*atexit_function_pointer)(void);
// Five equivalent declarations for the same function
void atexit(void f(void));
void atexit(void (*f)(void));
void atexit(atexit_function f);
void atexit(atexit_function* f);
void atexit(atexit_function_pointer f);

关于函数声明语义上等效的写法哪种更易读,这肯定是一个值得广泛讨论的话题。第二个版本,使用(``*f)括号,很快就会变得难以阅读;第五个版本则因为将指针隐藏在类型中而不被看好。在其他的写法中,我个人稍微更喜欢第四个版本,而不是第一个版本。

C 库有几个接收函数参数的函数。我们已经看到了atexitat_quick_exitstdlib.h中的另一对函数提供了搜索(bsearch)和排序(qsort)的通用接口:

<stdlib.h>

typedef int compare_function(void const*, void const*);

void* bsearch(void const* key, void const* base,
              size_t n, size_t size,
              compare_function* compar);

void qsort(void* base,
           size_t n, size_t size,
           compare_function* compar);

它们都接收一个数组基作为参数,并在其上执行任务。第一个元素的地址作为void指针传递,因此所有类型信息都丢失了。为了能够正确处理数组,函数必须知道单个元素的大小(size)和元素的数量(n)。

此外,它们接收一个比较函数作为参数,该参数提供了关于元素之间排序顺序的信息。通过使用这样的函数指针,bsearchqsort函数非常通用,可以与任何允许对值进行排序的数据模型一起使用。基参数引用的元素可以是任何类型 T(intdouble、字符串或应用定义),只要 size 参数正确描述了 T 的大小,并且只要指向 compar 的函数知道如何一致地比较类型 T 的值。

这种函数的一个简单版本看起来可能是这样的:

int compare_unsigned(void const* a, void const* b){
  unsigned const* A = a;
  unsigned const* B = b;
  if (*A < *B) return -1;
  else if (*A > *B) return +1;
  else return 0;
}

习惯上,两个参数指向要比较的元素,如果认为 a 小于 b,则返回值严格为负,如果它们相等,则返回0,否则严格为正。

返回类型为int似乎暗示着int的比较可以更简单地完成:

/* An invalid example for integer comparison */
int compare_int(void const* a, void const* b){
  int const* A = a;
  int const* B = b;
  return *A - *B;     // may overflow!
}

但这是不正确的。例如,如果*A 很大,比如说INT_MAX,而*B 是负数,那么差值的数学值可能大于INT_MAX

由于存在void指针,使用这种机制时应该始终注意类型转换被封装,类似于以下内容:

/* A header that provides searching and sorting for unsigned. */

/* No use of inline here; we always use the function pointer. */
extern int compare_unsigned(void const*, void const*);

inline
unsigned const* bsearch_unsigned(unsigned const key[static 1],
                        size_t nmeb, unsigned const base[nmeb]) {
    return bsearch(key, base, nmeb, sizeof base[0], compare_unsigned);
}

inline
void qsort_unsigned(size_t nmeb, unsigned base[nmeb]) {
    qsort(base, nmeb, sizeof base[0], compare_unsigned);
}

在这里,bsearch(二分查找)搜索与 key[0]比较相等的元素并返回它,如果没有找到这样的元素,则返回一个空指针。它假设数组基础已经按照比较函数给出的顺序排序。这个假设有助于加快搜索速度。尽管这并没有在 C 标准中明确指定,但你可以预期对bsearch的调用不会超过log2次比较调用。

如果bsearch找到一个与*key 相等的数组元素,它将返回指向该元素的指针。请注意,这会在 C 的类型系统中钻一个洞,因为这将返回一个未限定的指向可能具有const限定符的元素的指针。请谨慎使用。在我们的例子中,我们简单地将返回值转换为unsigned const*,这样我们甚至不会在 bsearch_unsigned 的调用端看到未限定的指针。

qsort这个名字来源于快速排序算法。标准并没有强制选择排序算法,但预期的比较调用次数应该与n log2 的数量级相当,就像快速排序一样。没有对上限的保证;你可以假设其最坏情况复杂度至多为二次方,O(n²)。

虽然有一个通用的指针类型void*,可以用作对象类型的通用指针,但没有这样的通用类型或隐式转换存在于函数指针中。

总结 11.23

函数指针必须使用其确切类型。

这样的严格规则是必要的,因为具有不同原型函数的调用约定可能相当不同^([5]),而指针本身并不跟踪任何这些。

例如,平台应用程序二进制接口(ABI)可能通过特殊硬件寄存器传递浮点数。

以下函数存在一个微妙的问题,因为参数的类型与我们期望的比较函数的类型不同:

/* Another invalid example for an int comparison function */
int compare_int(int const* a, int const* b){
  if (*a < *b) return -1;
  else if (*a > *b) return +1;
  else return 0;
}

当你尝试使用此函数与qsort一起时,你的编译器应该会抱怨该函数类型不正确。我们之前给出的使用中间void const*参数的变体应该几乎与这个无效示例一样高效,但它也可以保证在所有 C 平台上都是正确的。

调用函数和函数指针使用(...)运算符的规则与数组和指针以及[...]运算符的规则类似:

总结 11.24

函数调用运算符 (...) 适用于函数指针。

double f(double a);

// Equivalent calls to f, steps in the abstract state machine
f(3);        // Decay → call
(&f)(3);     // Address of → call
(*f)(3);     // Decay → dereference → decay → call
(*&f)(3);    // Address of → dereference → decay → call
(&*f)(3);    // Decay → dereference → address of → call

所以从技术上来说,在抽象状态机的术语中,指针退化总是执行的,并且通过函数指针调用函数。第一个,“自然”的调用有一个对 f 标识符的隐藏评估,这导致了函数指针。

考虑到所有这些,我们可以几乎像使用函数一样使用函数指针:

// In a header
typedef int logger_function(char const*, ...);
extern logger_function* logger;
enum logs { log_pri, log_ign, log_ver, log_num };

这声明了一个全局变量 logger,它将指向一个打印日志信息的函数。使用函数指针将允许此模块的用户动态地选择特定的函数:

// In a .c file (TU)
extern int logger_verbose(char const*, ...);
static
int logger_ignore(char const*, ...) {
  return 0;
}
logger_function* logger = logger_ignore;

static
logger_function* loggers = {
  [log_pri] = printf,
  [log_ign] = logger_ignore,
  [log_ver] = logger_verbose,
};

在这里,我们正在定义实现此方法的工具。特别是,函数指针可以用作数组(此处为 loggers)的基本类型。注意,我们使用两个外部函数(printf和 logger_verbose)和一个static函数(logger_ignore)来初始化数组:存储类不是函数接口的一部分。

logger 变量可以被分配,就像任何其他指针类型一样。在启动时,我们可能有

if (LOGGER < log_num) logger = loggers[LOGGER];

然后,这个函数指针可以在任何地方用来调用相应的函数:

logger("Do we ever see line \%lu of file \%s?", __LINE__+0UL, __FILE__);

这个调用使用了特殊的宏__LINE____FILE__来表示行号和源文件名。我们将在第 16.3 节中更详细地讨论这些内容。

当使用函数指针时,你应该始终意识到这样做会在函数调用中引入间接引用。编译器首先必须获取 logger 的内容,然后才能调用它在其中找到的地址。这有一定的开销,应该避免在时间敏感的代码中这样做。

通用导数

你能将实数和复数导数(挑战 2 和 5)扩展,以便它们接收函数 F 和值 x 作为参数吗?

你能使用通用实数导数来实现牛顿法来寻找根吗?

你能找到多项式的实数零点吗?

你能找到多项式的复数零点吗?

通用排序

你能将你的排序算法(挑战 1)扩展到其他排序键吗?

你能将针对不同排序键的函数压缩为与 qsort 具有相同签名的函数:即,接收数据、大小信息和比较函数作为参数的通用指针?

你能将你的排序算法的性能比较(挑战 10)扩展到 C 库函数 qsort 吗?

摘要

  • 指针可以指向对象和函数。

  • 指针不是数组,但指向数组。

  • 函数的数组参数自动重写为对象指针。

  • 函数的函数参数自动重写为函数指针。

  • 函数指针类型在赋值或调用时必须完全匹配。

第十二章。C 内存模型

本章涵盖

  • 理解对象表示

  • 使用无类型指针和类型转换操作

  • 使用有效类型和对齐限制对象访问

指针为我们提供了一种对程序执行的环境和状态的抽象,即 C 内存模型。我们可以在(几乎)所有对象上应用一元运算符 & 来检索它们的地址,并使用它来检查和改变我们的执行状态。

¹

只有使用关键字 register 声明的对象没有地址;参见 第 13.2.2 节 关于 第 2 层。

通过指针访问对象仍然是一种抽象,因为从 C 的角度来看,没有对对象的“真实”位置的区分。它可能位于你的计算机的 RAM 中,或者在一个磁盘文件中,或者对应于月球上温度传感器的 IO 端口;你不应该关心。C 应该做正确的事情,不管怎样。

事实上,在现代操作系统上,通过指针获得的所有内容都是所谓的 虚拟内存,基本上是一种虚构,它将你的进程的 地址空间 映射到机器的物理内存地址。所有这些都是为了确保你的程序执行的一定属性:

  • 便携性: 你不需要关心特定机器上的物理内存地址。

  • 安全: 读取或写入你进程不拥有的虚拟内存将不会影响你的操作系统或任何其他进程。

C 必须关注的唯一事情是指针指向的对象的 类型。每个指针类型都源自另一个类型,即其基类型,并且每种这样的派生类型都是一个新的独立类型。

图 12.1. int32_t 的值-内存模型的不同级别。将此类型映射到具有二进制补码符号表示和小端对象表示的 32 位有符号整数的平台示例。

12fig01_alt.jpg

摘要 12.1

具有不同基类型的指针类型是不同的

除了提供物理内存的虚拟视图外,内存模型还简化了对象本身的视图。它假定每个对象是一系列字节,即 对象表示 (章节 12.1);^([2])参见 图 12.1 以了解示意图。检查这种对象表示的方便工具是 联合 (章节 12.2)。直接访问对象表示 (章节 12.3) 允许我们做一些微调;但另一方面,这也打开了抽象机器状态的不希望或故意的操作之门:用于此的工具是无类型指针 (章节 12.4) 和类型转换 (章节 12.5)。有效的类型 (章节 12.6) 和对齐 (章节 12.7) 描述了此类操作的形式限制和平台约束。

²

对象表示与我们在 章节 5.1.3 中看到的 二进制表示 相关,但不是同一件事。

12.1. 统一内存模型

尽管通常所有对象都有类型,但内存模型做了另一个简化:即所有对象都是 字节**^C 的集合。我们在数组上下文中引入的 sizeof 运算符用于衡量对象的大小,即它使用的字节数。有三种类型在定义上恰好使用一个字节的内存:字符类型 charunsigned charsigned char

摘要 12.2

sizeof(char) 定义为 1

不仅所有对象都可以在较低级别上按字符类型“计算”大小,它们甚至可以像这样的字符类型数组一样进行检查和操作。稍后我们将看到如何实现这一点,但此时我们只需注意以下内容:

摘要 12.3

每个对象 A 都可以被视为 unsigned char[sizeof A**]

摘要 12.4

字符类型的指针是特殊的

不幸的是,用于组成所有其他对象类型的类型都派生自 char,这是我们查看字符串字符的类型。这仅仅是一个历史事件,你不应该对此过分解读。特别是,你应该清楚地区分两种不同的使用场景。

取得要点 12.5

使用类型 char 用于字符和字符串数据。

取得要点 12.6

使用类型 unsigned char 作为所有对象类型的原子。

类型 signed char 比其他两种类型的重要性要小得多。

正如我们所见,sizeof运算符根据对象占用多少unsigned char来计算对象的大小。

取得要点 12.7

sizeof 运算符可以应用于对象和对象类型。

在之前的讨论中,我们还可以区分两种 sizeof 的语法变体:带括号和不带括号。虽然应用于对象的语法可以有两种形式,但类型的语法需要括号:

取得要点 12.8

类型 T 的所有对象的大小由 sizeof(T)*.* 给出

12.2. 联合

让我们看看如何检查对象的单个字节。我们首选的工具是 union。这些在声明上与 struct 类似,但具有不同的语义:

endianness.c
**2**   #include <inttypes.h>
**3**
**4**   typedef union unsignedInspect unsignedInspect;
**5**   union unsignedInspect {
**6**     unsigned val;
**7**     unsigned char bytes[sizeof(unsigned)];
**8**   };
**9**   unsignedInspect twofold = { .val = 0xAABBCCDD, };

这里的区别是,这样的union不会将不同类型的对象收集到一个更大的对象中,而是覆盖具有不同类型解释的对象。这样,它就是检查另一种类型对象的单个字节的最佳工具。

让我们首先尝试弄清楚我们期望的各个字节的值。在轻微的语言滥用中,让我们将对应于字节的未签名字段称为表示数字。由于我们将字节视为unsigned char类型,它们的值可以是0 . . . UCHAR_MAX,包括,因此我们将数字解释为以UCHAR_MAX+1为基数。在示例中,在我的机器上,类型unsigned的值可以用sizeof(unsigned) == 4这样的表示数字表示,我选择了最高到最低顺序的表示数字0xAA0xBB0xCC0xDD。完整的unsigned值可以使用以下表达式计算,其中CHAR_BIT是字符类型中的位数:

**1**   ((0xAA << (CHAR_BIT*3))
**2**       |(0xBB << (CHAR_BIT*2))
**3**       |(0xCC << CHAR_BIT)
**4**       |0xDD)

使用之前定义的 union,我们可以从两个不同的角度观察同一个双向对象:双向.val 将其表示为unsigned,而双向.bytes 将其表示为一个unsigned char数组。由于我们选择了双向.bytes 的长度正好等于双向.val 的大小,它正好表示其字节,因此为我们提供了检查unsigned对象表示的方法:所有其表示数字:

endianness.c
**12**      printf("value is 0x%.08X\n", twofold.val);
**13**      for (size_t i = 0; i < sizeof twofold.bytes; ++i)
**14**        printf("byte[%zu]: 0x%.02hhX\n", i, twofold.bytes[i]);

在我的计算机上,我得到的结果如下所示:^([3])

³

在你自己的机器上测试代码。

终端
**0**      ~/build/modernC% code/endianness
**1**      value is 0xAABBCCDD
**2**      byte[0]: 0xDD
**3**      byte[1]: 0xCC
**4**      byte[2]: 0xBB
**5**      byte[3]: 0xAA

对于我的机器,我们看到输出首先打印整数的低位表示数字,然后是次低位数字,依此类推。最后,打印最高位数字。因此,在我的机器上,此类整数的内存表示中,低位表示数字位于高位表示数字之前。

这不是标准化的,而是实现定义的行为。

12.9 总结

算术类型的表示数字的内存顺序是实现定义的

也就是说,平台提供商可能会决定提供一个存储顺序,首先存储最高位数字,然后逐个打印低位数字。存储顺序,即 字节序**^C,如我的机器所示,称为 小端序**^C。一个首先存储高位表示数字的系统称为 *大端序**C*。([4]) 两种顺序在现代处理器类型中都很常见。一些处理器甚至能够在运行时在这两种顺序之间切换。

这些名称来源于数字的大或小“端”首先存储的事实。

之前的输出还显示了另一个实现定义的行为:我使用了我的平台的一个特性,即一个表示数字可以通过使用两个十六进制数字来很好地打印。换句话说,我假设 UCHAR_MAX+1256,并且一个 unsigned charCHAR_BIT 的值位是 8。同样,这也是实现定义的行为:尽管大多数平台都有这些属性,^([5]) 但仍然有一些平台具有更宽的字类型。

尤其是所有 POSIX 系统。

12.10 总结

在大多数架构中, CHAR_BIT 8 并且 UCHAR_MAX 255**.

在示例中,我们研究了最简单的算术基类型,无符号整数的内存表示。其他基类型有更复杂的内存表示:有符号整数类型必须编码符号;浮点类型必须编码符号、尾数和指数;指针类型可能遵循适合底层架构的任何内部约定.^([[Exs 1]])^([[Exs 2]])^([[Exs 3]])

^([Exs 1])

设计一个类似的 union 类型来调查指针类型的字节,例如 double*

^([Exs 2])

使用这样的 union,调查数组中连续两个元素的地址。

^([Exs 3])

比较不同执行中相同变量的地址。

12.3. 内存和状态

所有对象的价值构成了抽象状态机的状态,因此也是特定执行的状态。C 的内存模型通过&操作符为(几乎)所有对象提供了一个类似唯一的位置,并且可以通过指针从程序的不同部分访问和修改该位置。

这样做使得确定一个执行过程的抽象状态变得非常困难,在许多情况下甚至是不可能的:

在这里,我们(以及编译器)只看到了函数 blub 的声明,没有定义。因此,我们无法得出太多关于该函数对其参数指向的对象做了什么结论。特别是,我们不知道变量 d 是否被修改,因此 c + d 的值可以是任何值。程序实际上必须检查内存中的对象 d,以找出 blub 调用后的值。

现在,让我们看看这样一个接收两个指针参数的函数:

**1**   double blub(double const* a, double* b);
**2**
**3**   int main(void) {
**4**     double c = 35;
**5**     double d = 3.5;
**6**     printf("blub is %g\n", blub(&c, &d));
**7**     printf("after blub the sum is %g\n", c + d);
**8**   }
**1**   double blub(double const* a, double* b) {
**2**     double myA = *a;
**3**     *b = 2*myA;
**4**     return *a;      // May be myA or 2*myA
**5**   }

此类函数可以在两种不同的假设下运行。首先,如果用两个不同的地址作为参数调用,*a将保持不变,返回值将与 myA 相同。但如果两个参数都是相同的,例如如果调用是 blub(&c, &c),对*b的赋值也会改变*a

通过不同的指针访问相同对象的现象称为别名**^C;它是错过优化的常见原因。在这两种情况下,无论是两个指针始终别名还是它们永远不会别名,执行的抽象状态都会大大减少,优化器通常可以充分利用这一知识。因此,C 强制限制可能的别名仅为相同类型的指针。

总结 12.11(别名)

除了字符类型外,只有相同基类型的指针可以别名。

要看到这条规则的实际效果,考虑对我们之前的例子进行轻微的修改:

**1**   size_t blob(size_t const* a, double* b) {
**2**     size_t myA = *a;
**3**     *b = 2*myA;
**4**     return *a;       // Must be myA
**5**   }

因为这里两个参数的类型不同,C 假设它们不指向同一对象。实际上,将函数作为 blob(&e, &e)调用将是错误的,因为这永远不会匹配 blob 的原型。因此,在return语句中,我们可以确信对象*a没有改变,并且我们已经在变量 myA 中持有所需的价值。

有一些方法可以欺骗编译器,并用指向同一对象的指针调用此类函数。我们将在稍后看到一些这些技巧。不要这样做:这是一条通往许多痛苦和绝望的道路。如果这样做,程序的行为将是不确定的,因此你必须保证(证明!)没有别名发生。

相反,我们应该尝试编写我们的程序,以保护我们的变量免受别名的影响,并且有一个简单的方法可以实现这一点。

总结 12.12

避免使用 & 操作符。

根据给定变量的属性,编译器可能会发现变量的地址永远不会被取用,因此变量根本不能别名。在第 13.2 节中,我们将看到哪些变量的属性或对象可能影响此类决策,以及register关键字如何保护我们免受意外取地址的影响。稍后,在第 15.2 节中,我们将看到restrict关键字如何允许我们指定指针参数的别名属性,即使它们具有相同的基类型。

12.4. 指向非特定对象的指针

正如我们所见,对象表示提供了将对象 X 视为一个unsigned char[sizeof X]数组的视图。该数组的起始地址(类型为unsigned char*)提供了访问去除原始类型信息的内存的途径。

C 语言发明了一种强大的工具来更通用地处理此类指针。这些是指向一种非类型void的指针。

摘要 12.13

任何对象指针都可以转换为void***并从其转换回来*。

注意,这仅涉及对象指针,而不是函数指针。想象一个void*指针,它持有现有对象的地址,就像一个指向存储实例的指针,该实例包含对象;参见图 12.1。作为此类层次结构的类比,你可以将电话簿中的条目想象成这样:一个人的名字对应于指向对象的标识符;他们与“手机”、“家庭”或“工作”条目的分类对应于类型;而他们的电话号码本身则是一种地址(其中,通常你对此不感兴趣)。但是,即使电话号码也抽象掉了其他电话具体位置的信息(这将是对象下面的存储实例),或者关于其他电话本身的特定信息,例如它是否是固定电话还是移动网络,以及网络需要做什么才能实际上将你连接到另一端的某人。

摘要 12.14

一个对象具有存储、类型和值

不仅转换为void*是明确定义的,而且它还保证了与指针值的行为良好。

摘要 12.15

将对象指针转换为void***然后再转换回相同类型是恒等操作*。

因此,当我们转换为void*时,唯一失去的是类型信息;值保持不变。

摘要 12.16 (avoid*²)

void**.

它完全移除了与地址相关联的任何类型信息。尽可能避免使用它。反过来,情况则不那么关键,特别是如果你有一个返回void*的 C 库调用。

void作为一个单独的类型不应用于变量声明,因为它不会导致我们可以对其执行任何操作的对象。

12.5. 显式转换

查看对象 X 的对象表示的一个方便方法是将指向 X 的指针以某种方式转换为unsigned char*类型的指针:

   double X;
   unsigned char* Xp = &X; // error: implicit conversion not allowed

幸运的是,不允许将double*隐式转换为unsigned char*。我们必须以某种方式明确地进行这种转换。

我们已经看到,在许多地方,某种类型的值会隐式转换为另一种类型的值(第 5.4 节),并且窄整数类型在执行任何操作之前首先转换为int。考虑到这一点,窄类型只在非常特殊的情况下才有意义:

  • 你必须节省内存。你需要使用一个非常大的小值数组。这里的非常大意味着可能是数百万或数十亿。在这种情况下,存储这些值可能会给你带来一些好处。

  • 你使用char来表示字符和字符串。但然后你不会对它们进行算术运算。

  • 你使用unsigned char来检查对象的字节。但然后,再次,你不会对它们进行算术运算。

指针类型的转换更为微妙,因为它们可能会改变对象的类型解释。对于数据指针,只允许两种形式的隐式转换:从和到void*的转换,以及向目标类型添加限定符的转换。让我们看看一些例子:

**1**   float f = 37.0;        // Conversion: to float
**2**   double a = f;          // Conversion: back to double
**3**   float* pf = &f;        // Exact type
**4**   float const* pdc = &f; // Conversion: adding a qualifier
**5**   void* pv = &f;         // Conversion: pointer to void*
**6**   float* pfv = pv;       // Conversion: pointer from void*
**7**   float* pd = &a;        // Error: incompatible pointer type
**8**   double* pdv = pv;      // Undefined behavior if used

前两个使用void*(pv 和 pfv)的转换已经有点棘手了:我们来回转换指针,但我们要注意 pfv 的目标类型必须与 f 相同,这样一切才能顺利进行。

然后是错误的部分。在 pd 的初始化中,编译器可以保护我们免受严重错误的侵害:将指针赋给具有不同大小和解释的类型可以并且将会导致严重损坏。任何符合规范的编译器必须对这一行给出诊断。正如你现在已经很好地理解的那样,你的代码不应该产生编译器警告(要点 1.4),你知道你应该在修复这种错误之前继续下去。

最后一行更糟糕:它有一个错误,但这个错误在语法上是正确的。这个错误可能没有被察觉的原因是,我们为 pv 的第一次转换已经从指针中移除了所有类型信息。所以,一般来说,编译器无法知道指针后面的对象类型是什么。

除了我们之前看到的隐式转换之外,C 还允许我们使用类型转换符casts**^C)显式转换。使用类型转换符,你是在告诉编译器你比它更了解情况,即指针后面的对象类型不是它所认为的那样,并且它应该闭嘴。在我遇到的大多数实际用例中,编译器是对的,程序员是错的:即使是经验丰富的程序员也倾向于滥用类型转换来隐藏有关类型的糟糕设计决策。

表达式 X 转换为类型 T 的形式为 (T)X。把它想象成“施法”。

要点 12.17

不要使用类型转换

它们剥夺了你宝贵的信息,如果你仔细选择类型,你将只需要它们在非常特殊的情况下。

有一种情况是这样的,当你想检查对象的字节级内容时。正如我们在 第 12.2 节 中看到的,围绕一个对象构造一个 union 可能并不总是可能(或者可能过于复杂),因此这里我们可以选择进行类型转换:

endianness.c
**15**      unsigned val = 0xAABBCCDD;
**16**      unsigned char* valp = (unsigned char*)&val;
**17**      for (size_t i = 0; i < sizeof val; ++i)
**18**        printf("byte[%zu]: 0x%.02hhX\n", i, valp[i]);

在那个方向(从“对象指针”到“字符类型指针”),类型转换大多是无害的。

12.6. 有效类型

为了应对指针可能提供的相同对象的多种视图,C 语言引入了有效类型的概念。它极大地限制了对象可以如何被访问。

要点 12.18(有效类型)

对象的有效成员类型可以在任何时候访问,只要字节表示是访问类型的有效值

因为 union 变量的有效类型是 union 类型,而不是任何成员类型,所以 union 成员的规则可以放宽:

要点 12.19

只要字节表示是访问类型的有效值,任何具有有效union类型的对象成员都可以在任何时候访问

对于我们迄今为止看到的所有对象,确定有效类型很容易:

要点 12.20

变量的有效类型是其声明的类型

之后,我们将看到另一类稍微复杂一些的对象。

注意,这个规则没有例外,我们也不能改变这种变量或复合字面量的类型。

要点 12.21

变量和复合字面量必须通过其声明的类型或通过字符类型的指针来访问

还要注意所有这些字符类型中的不对称性。任何对象都可以被视为由 unsigned char 组成,但没有任何 unsigned char 数组可以通过其他类型来使用:

   unsigned char A[sizeof(unsigned)] = { 9 };
   // Valid but useless, as most casts are
   unsigned* p = (unsigned*)A;
   // Error: access with a type that is neither the effective type nor a
   // character type
   printf("value \%u\n", *p);

在这里,访问 *p 是一个错误,之后的程序状态是未定义的。这与我们之前对 union 的处理形成鲜明对比:参见 第 12.2 节,在那里我们实际上可以将字节序列视为一个 unsigned charunsigned 的数组。

严格的规则有多个原因。在 C 标准中引入有效类型的最初动机是为了处理别名,正如我们在 第 12.3 节 中看到的。实际上,别名规则(要点 12.11)是从有效类型规则(要点 12.18)派生出来的。只要没有 union 参与,编译器就知道我们不能通过 size_t 访问 double,因此它可能会 假设 对象是不同的。

12.7. 对齐

指针转换的反向方向(从“字符类型指针”到“对象指针”)根本不是无害的,不仅仅是因为可能存在别名。这与 C 内存模型的另一个属性有关:对齐**C*。大多数非字符类型对象不能从任意字节位置开始;它们通常从*字边界**C开始。类型的对齐描述了该类型对象可以开始的可能的字节位置。

如果我们强制某些数据到错误的对齐,会发生真正糟糕的事情。为了看到这一点,请查看以下代码:

 **1**   #include <stdio.h>
 **2**   #include <inttypes.h>
 **3**   #include <complex.h>
 **4**   #include "crash.h"
 **5**
 **6**   void enable_alignment_check(void);
 **7**   typedef complex double cdbl;
 **8**
 **9**   int main(void) {
**10**     enable_alignment_check();
**11**     /* An overlay of complex values and bytes. */
**12**     union {
**13**       cdbl val[2];
**14**       unsigned char buf[sizeof(cdbl[2])];
**15**     } toocomplex = {
**16**       .val = { 0.5 + 0.5*I, 0.75 + 0.75*I, },
**17**     };
**18**     printf("size/alignment: %zu/%zu\n",
**19**            sizeof(cdbl), _Alignof(cdbl));
**20**     /* Run over all offsets, and crash on misalignment. */
**21**     for (size_t offset = sizeof(cdbl); offset; offset /=2) {
**22**       printf("offset\t%zu:\t", offset);
**23**       fflush(stdout);
**24**       cdbl* bp = (cdbl*)(&toocomplex.buf[offset]); // align!
**25**       printf("%g\t+%gI\t", creal(*bp), cimag(*bp));
**26**       fflush(stdout);
**27**       *bp *= *bp;
**28**       printf("%g\t+%gI", creal(*bp), cimag(*bp));
**29**       fputc('\n', stdout);
**30**     }
**31**   }

这是从一个类似于我们之前看到的union声明开始的。再次强调,我们有一个数据对象(在这种情况下是类型为complex double[2]的对象),我们用unsigned char数组覆盖它。除了这部分稍微复杂一些之外,乍一看并没有什么大问题。但是,如果我在我的机器上执行这个程序,我会得到

Terminal
**0**   ~/.../modernC/code (master % u=) 14:45 <516>$ ./crash
**1**   size/alignment: 16/8
**2**   offset 16: 0.75 +0.75I 0 +1.125I
**3**   offset 8: 0.5 +0I 0.25 +0I
**4**   offset 4: Bus error

程序崩溃,显示为总线错误**^C,这是“数据总线对齐错误”的快捷方式。真正的问题行是

crash.c
**23**       fflush(stdout);
**24**       cdbl* bp = (cdbl*)(&toocomplex.buf[offset]); // align!

在右侧,我们看到一个指针转换:将unsigned char*转换为complex double*。通过围绕它的for循环,这个转换从 toocomplex 的开始字节偏移量 offset 执行。这些是 2 的幂:168421。如上输出所示,似乎complex double对于其大小的一半的对齐仍然表现良好,但是当对齐为四分之一时,程序崩溃。

一些架构对不匹配的容忍度比其他架构更高,我们可能需要强制系统在出现此类条件下出错。我们在开始时使用以下函数来强制崩溃:

crash.c

enable_alignment_check: 为 i386 处理器启用对齐检查

英特尔 i386 处理器系列在接受数据不匹配方面相当宽容。这可能导致在其他不那么宽容的架构上移植时出现令人烦恼的 bug。

此函数还启用了对该系列或处理器的此类问题的检查,这样你可以确保及早发现此问题。

我在 Ygdrasil 的博客上找到了这段代码:http://orchistro.tistory.com/206

void enable_alignment_check(void);

如果你对便携式代码感兴趣(而且如果你还在这里,你很可能感兴趣),在开发阶段的早期错误实际上是非常有帮助的.^([7]) 因此,考虑崩溃一个功能。参见crash.h中提到的博客条目,其中对这一主题进行了有趣的讨论。

对于该函数内部使用的代码,请查阅crash.h的源代码以进行检查。

在前面的代码示例中,我们还看到了一个新的运算符,alignof(或者如果你没有包含stdalign.h,则为_Alignof),它为我们提供了特定类型的对齐。你很少会在实际代码中找到使用它的场合。

<stdalign.h>

另一个关键字可以用来强制在指定的对齐方式下进行分配:alignas(分别,_Alignas)。它的参数可以是类型或表达式。如果你知道你的平台可以在数据以某种方式对齐的情况下更有效地执行某些操作,这可能是有用的。

例如,为了强制将一个complex变量的对齐方式设置为与其大小一致,而不是之前看到的一半大小,你可以使用

alignas(sizeof(**complex** double)) complex double z;

或者如果你知道你的平台对float[4]数组有高效的向量指令:

alignas(sizeof(float[4])) float fvec[4];

这些运算符不能帮助克服有效类型规则(要点 12.18)。即使有

alignas(unsigned) unsigned char A[sizeof(unsigned)] = { 9 };

第 12.6 节末尾的例子仍然无效。

概述

  • 内存和对象模型有多个抽象层:物理内存、虚拟内存、存储实例、对象表示和二进制表示。

  • 每个对象都可以看作是一个unsigned char数组。

  • union用于在不同的对象表示上覆盖不同的对象类型。

  • 根据特定数据类型的需要,内存可以对齐不同。特别是,并不是所有unsigned char数组都可以用来表示任何对象类型。

第十三章. 存储

本章涵盖了

  • 使用动态分配创建对象

  • 存储和初始化的规则

  • 理解对象的生命周期

  • 处理自动存储

到目前为止,我们程序中处理的大多数对象都一直是变量:也就是说,在常规声明中声明的对象,具有特定的类型和一个指向该对象的标识符。有时它们在代码中的定义位置与声明位置不同,但即使是这样的定义也使用类型和标识符来引用它们。我们较少见到的另一类对象是使用类型指定但未使用标识符指定的:复合字面量,如在第 5.6.4 节中介绍。

所有这些对象,无论是变量还是复合字面量,都有一个lifetimeC,它取决于程序的语法结构。它们具有对象生命期和标识符可见性,要么跨越整个程序执行(全局变量、全局字面量和使用**`static`**声明的变量),要么绑定到函数内部的语句块中.([1])

¹

实际上,这有点简化;我们很快就会看到细节。

我们还看到,对于某些对象,区分不同的实例很重要:当我们在一个递归函数中声明一个变量时。递归调用层次结构中的每个调用都有自己的此类变量的实例。因此,区分另一个不是完全相同于对象的实体是有方便的,即存储实例。

在本章中,我们将处理另一种创建对象的机制,称为动态分配(章节 13.1)。实际上,这种机制创建的存储实例仅被视为字节数组,并且没有作为对象进行任何解释。只有当我们存储某些内容时,它们才会获得类型。

通过这些,我们几乎完全了解了不同的可能性,因此我们可以讨论存储持续时间、对象生命周期和标识符可见性的不同规则(章节 13.2);我们还将深入探讨初始化的规则(章节 13.4),因为这些规则对于不同创建的对象差异很大。

此外,我们提出两个旁白。第一个是对对象生命周期的更详细观察,这使我们能够在 C 代码的令人惊讶的点访问对象(章节 13.3)。第二个提供了对具体架构的内存模型实现的一瞥(章节 13.5),特别是如何在你的特定机器上处理自动存储。

13.1. malloc 和相关函数

对于必须处理数据集合不断增长的程序,我们迄今为止看到的对象类型过于受限。为了处理变化的用户输入、网络查询、大型交互图和其他不规则数据、大型矩阵和音频流,在需要时即时回收对象的存储实例并在不再需要时释放它们是方便的。这种方案称为动态分配^C,有时简称为分配

<stdlib.h>

以下一组函数,通过stdlib.h提供,已被设计为提供对分配存储的接口:

#include <stdlib.h>
void* malloc(**size_t** size);
void free(void* ptr);
void* calloc(**size_t** nmemb, **size_t** size);
void* realloc(void* ptr, **size_t** size);
void* aligned_alloc(**size_t** alignment, **size_t** size);

前两个函数,malloc(内存分配)和free,迄今为止是最突出的。正如它们的名称所表明的,malloc会即时为我们创建一个存储实例,而free则会将其销毁。另外三个函数是malloc的专用版本:calloc(清除分配)将新存储的所有位都设置为0realloc可以扩展或缩小存储空间,而aligned_alloc确保非默认对齐。

所有这些函数都使用void*:也就是说,对于没有已知类型信息的指针。能够为这一系列函数指定这种“非类型”可能是整个void*指针游戏的目的。使用它,它们可以普遍适用于所有类型。以下示例为double类型的向量分配大量存储空间,每个元素对应一个活着的人:^([[Exs 1]])

^([Exs 1])

不要尝试这种分配,但计算一下在你的平台上需要的空间大小。在你的平台上分配这样一个向量是否可行?

**size_t** length = livingPeople();
double* largeVec = malloc(length * sizeof *largeVec);
for (**size_t** i = 0; i < length; ++i) {
  largeVec[i] = 0.0;
}
...

free(largeVec);

因为 malloc 对将要存储的对象的后续使用或类型一无所知,存储的大小是以字节为单位的。在给定的惯用表达式中,我们只为 largeVec 的指针类型指定了一次类型信息。通过在 malloc 调用的参数中使用 sizeof *largeVec,我们确保将分配正确的字节数。即使我们后来将 largeVec 的类型更改为 size_t*,分配也会相应调整。

另一种我们经常会遇到的惯用表达方式严格地取我们想要创建的对象的类型的大小:一个长度为元素个数的 double 类型的数组:

double* largeVec = malloc(sizeof(double[length]));

我们已经被引入了类型转换所困扰,这些转换是显式的。重要的是要注意,对 malloc 的调用保持原样;从 void*malloc 的返回类型)到目标类型的转换是自动的,不需要任何干预。

Takeaway 13.1

不要对 malloc 及其相关函数的返回值进行类型转换。

不仅这种类型转换是多余的,而且当我们忘记包含头文件 stdlib.h 时,进行显式转换甚至可能适得其反:

<stdlib.h>

/* If we forget to include stdlib.h, many compilers
   still assume: */
int malloc();          // Wrong function interface! 
...
double* largeVec = (void*)malloc(sizeof(double[length]));
                              |
                        int <--
                         |
                 void* <--

较旧的 C 编译器会假设返回 int 并触发从 int 到指针类型的错误转换。我见过许多由这个错误引起的崩溃和微妙的错误,特别是在初学者的代码中,这些代码的作者一直遵循着不良的建议。

在之前的代码中,作为下一步,我们通过赋值初始化我们刚刚分配的存储:这里,所有 0.0。只有这些赋值使得 largeVec 的各个元素成为“对象”。这样的赋值提供了有效的类型和值。

Takeaway 13.2

通过 malloc 分配的存储是未初始化的,没有类型。

13.1.1. 具有可变数组大小的完整示例

让我们看看一个例子,使用通过 malloc 分配的动态数组比简单的数组变量提供了更多的灵活性。以下接口描述了一个名为 circular 的 double 类型的循环缓冲区:

circular.h

circular: 用于 double 类型的循环缓冲区的不可见类型

这种数据结构允许在尾部添加 double 类型的值,并在前面取出。每个这样的结构都有一个最大元素数量,可以存储在其中。

typedef struct circular circular;

circular.h

circular_append: 将具有值 value 的新元素追加到缓冲区 c

返回值:如果新元素可以追加,则返回 c,否则返回 0

circular* circular_append(circular* c, double value);

circular.h

circular_pop: 从 c 中移除最旧的元素并返回其值。

返回值:如果存在,则返回移除的元素,否则返回 0.0

double circular_pop(circular* c);

理念是,从0个元素开始,只要存储的元素数量不超过某个限制,就可以将新元素追加到缓冲区或从前面删除。可以使用以下函数访问存储在缓冲区中的单个元素:

circular.h

circular_element: 返回指向缓冲区c中位置pos的指针。

返回值:指向缓冲区中pos位置的指针,否则返回0

double* circular_element(circular* c,  **size_t** pos);

由于我们的类型 circular 将需要为循环缓冲区分配和释放空间,因此我们需要提供初始化和销毁此类类型实例的一致函数。这种功能由两对函数提供:

circular.h

circular_init: 使用最多max_len个元素的max_len初始化循环缓冲区c

仅在未初始化的缓冲区上使用此函数。

使用此函数初始化的每个缓冲区都必须通过调用 circular_destroy 销毁。

circular* circular_init(circular* c, **size_t** max_len);

circular.h

circular_destroy: 销毁循环缓冲区c

c必须通过调用 circular_init 初始化

void circular_destroy(circular* c);

circular.h

circular_new: 分配并初始化一个最多有len个元素的循环缓冲区。

使用此函数分配的每个缓冲区都必须通过调用 circular_delete 删除。

circular* circular_new(**size_t** len);

circular.h

circular_delete: 删除循环缓冲区c

c必须通过调用 circular_new 分配

void circular_delete(circular* c);

第一对应用于现有对象。它们接收指向此类对象的指针并确保为缓冲区分配或释放空间。第二对中的第一个创建对象并初始化它;最后一个销毁该对象然后释放内存空间。

如果我们使用常规数组变量,一旦创建了此类对象,我们可以在循环中存储的最大元素数量将是固定的。我们希望更加灵活,因此可以通过 circular_resize 函数提高或降低此限制,并且可以使用 circular_getlength 查询元素数量:

circular.h

circular_resize: 调整到容量max_len

circular* circular_resize(circular* c, **size_t** max_len);

circular.h

circular_getlength: 返回存储的元素数量。

**size_t** circular_getlength(circular* c);

然后,使用 circular_element 函数,它表现得像一个double数组:在当前长度内调用它,我们获得存储在该位置的元素的地址。

结构体的隐藏定义如下:

circular.c
 **5**   /** @brief the hidden implementation of the circular buffer type */
 **6**   struct circular {
 **7**     **size_t** start;    /**< Position of element 0 */ 
 **8**     **size_t** len;      /**< Number of elements stored */ 
 **9**     **size_t** max_len;  /**< Maximum capacity */ 
**10**     double* tab;     /**< Array holding the data */
**11**   };

理念是,指针成员 tab 将始终指向长度为 max_len 的数组对象。在某个时间点,缓冲区元素将从 start 开始,存储在缓冲区中的元素数量由成员 len 维护。在 tab 表中的位置是计算 max_len 的模。

以下表格表示这种循环数据结构的一个实例,其中 max_len=10,start=2,len=4

我们可以看到,缓冲区内容(四个数字6.07.781.099.0)连续放置在 tab 指向的数组对象中。

下面的方案表示了一个具有相同四个数字的循环缓冲区,但元素存储空间是环绕的。

这种数据结构的初始化需要调用malloc为 tab 成员提供内存。除此之外

circular.c
**13**   circular* circular_init(circular* c, **size_t** max_len) {
**14**     if (c) {
**15**       if (max_len) {
**16**         *c = (circular){
**17**           .max_len = max_len,
**18**           .tab = malloc(sizeof(double[max_len])),
**19**         };
**20**           // Allocation failed.
**21**         if (!c->tab) c->max_len = 0;
**22**       } else {
**23**         *c = (circular){ 0 };
**24**       }
**25**     }
**26**     return c;
**27**   }

注意这个函数总是检查指针参数 c 的有效性。此外,它通过在条件语句的两个分支中赋值复合字面量,保证初始化所有其他成员为0

库函数malloc可能会因为不同的原因失败。例如,内存系统可能因为之前的调用而耗尽,或者分配的回收大小可能太大。在一个通用系统(你很可能正在使用这样的系统来学习)中,这样的失败是罕见的(除非是故意引起的),但检查它仍然是一个好习惯。

要点 13.3

malloc通过返回一个空指针值来指示失败

这样的对象的销毁甚至更简单:我们只需检查指针,然后就可以无条件地freetab 成员。

circular.c
**29**   void circular_destroy(circular* c) {
**30**     if (c) {
**31**       free(c->tab);
**32**       circular_init(c, 0);
**33**     }
**34**   }

库函数free有一个友好的特性,即它接受一个空参数,并在该情况下不执行任何操作。

一些其他函数的实现使用一个内部函数来计算缓冲区的“循环”部分。它被声明为static,因此它只对那些函数可见,不会污染标识符命名空间(要点 9.8)。

circular.c
**50**   static **size_t** circular_getpos(circular* c, **size_t** pos) {
**51**     pos += c->start;
**52**     pos %= c->max_len;
**53**     return pos;
**54**   }

获取缓冲区元素的指针现在相当简单。

circular.c
**68**   double* circular_element(circular* c, **size_t** pos) {
**69**     double* ret = 0;
**70**     if (c) {
**71**       if (pos < c->max_len) {
**72**         pos = circular_getpos(c, pos);
**73**         ret = &c->tab[pos];
**74**       }
**75**     }
**76**   return ret;
**77**   }

在有了所有这些信息之后,你现在应该能够很好地实现除了一个之外的所有函数接口。更难的一个是 circular_resize。它从一些长度计算开始,然后处理请求会扩大或缩小表的情况。在这里,我们使用命名约定,用 o(旧)作为指向前变化特征的变量名的第一个字符,用 n(新)表示变化后的值。函数的末尾使用复合字面量,通过在情况分析期间找到的值来组合新的结构:

^([例 2])

编写缺失函数的实现。

circular.c
 **92**   circular* circular_resize(circular* c, **size_t** nlen) {
 **93**     if (c) {
 **94**       **size_t** len = c->len;
 **95**       if (len > nlen) return 0;
 **96**       **size_t** olen = c->max_len;
 **97**       if (nlen != olen) {
 **98**         **size_t** ostart = circular_getpos(c, 0);
 **99**         **size_t** nstart = ostart;
**100**         double* otab = c->tab;
**101**         double* ntab;
**102**         if (nlen > olen) {
circular.c
**138**         }
**139**         *c = (circular){
**140**           .max_len = nlen,
**141**           .start = nstart,
**142**           .len = len,
**143**           .tab = ntab,
**144**         };
**145**       }
**146**     }
**147**     return c;
**148**   }

让我们现在尝试填补前面代码中的空白,并查看扩大对象的第一种情况。这其中的关键部分是对realloc的调用:

circular.c
**103**         ntab = realloc(c->tab, sizeof(double[nlen]));
**104**         if (!ntab) return 0;

对于这个调用,realloc接收现有对象的指针和重定位应具有的新大小。它返回指向新对象(具有所需大小)的指针或 null。在下一行,我们检查后一种情况,如果无法重定位对象,则终止函数。

函数realloc具有有趣的特性:

  • 返回的指针可能与参数相同,也可能不同。是否可以在原地执行调整(例如,如果对象后面有空间,或者如果必须提供新对象)由运行时系统决定。但无论如何,即使返回的指针相同,对象也被视为新的(具有相同的数据)。这意味着特别是所有从原始对象派生的指针都无效。

  • 如果参数指针和返回的指针不同(即对象已被复制),则不需要(甚至不应该)对之前的指针进行任何操作。旧对象将得到妥善处理。

  • 尽可能地保留对象现有的内容:

    • 如果对象扩大,对象对应于之前大小的初始部分保持不变。

    • 如果对象缩小,重定位的对象内容与调用前的初始部分相对应。

  • 如果返回0(即重定位请求无法由运行时系统满足),则旧对象保持不变。因此,没有丢失任何内容。

现在我们知道新接收的对象具有我们想要的大小,我们必须确保 tab 仍然代表一个环形缓冲区。如果之前的情况与第一个表相同,早期(对应于缓冲区元素的这部分是连续的),我们不需要做任何事情。所有数据都得到了妥善保存。

如果我们的环形缓冲区已绕过,我们必须做一些调整:

circular.c
**105**         // Two separate chunks 
**106**         if (ostart+len > olen) {
**107**           **size_t** ulen = olen - ostart;
**108**           **size_t** llen = len - ulen;
**109**           if (llen <= (nlen - olen)) {
**110**             /* Copy the lower one up after the old end. */ 
**111**             memcpy(ntab + olen, ntab,
**112**                    llen*sizeof(double));
**113**           } else {
**114**             /* Move the upper one up to the new end. */ 
**115**             nstart = nlen - ulen;
**116**             memmove(ntab + nstart, ntab + ostart,
**117**                     ulen*sizeof(double));
**118**           }
**119**         }

以下表格展示了第一个子案例在修改前后的内容差异:下部分在新增的部分内部找到了足够的空间:

另一种情况,下部分无法适应新分配的部分,与这种情况类似。这次,缓冲区的上半部分被移向新表末尾:

虽然两种情况的处理方式有所不同,但都显示了细微的差别。第一种情况使用memcpy处理;复制操作的数据源和目标元素不能重叠,因此在这里使用memcpy是安全的。对于另一种情况,正如示例所示,源和目标元素可能重叠,因此需要使用更宽松的memmove函数。^([[Exs 3]])

^([Exs 3])

实现表的缩小:在调用realloc之前重新组织表内容非常重要。

13.1.2. 确保动态分配的一致性

就像在我们的代码示例中一样,对分配函数(如 mallocreallocfree)的调用应该始终成对出现。这不一定是在同一个函数内部,但在大多数情况下,简单地计数两者的发生次数应该给出相同的数字:

摘要 13.4

对于每一次分配,都必须有一个 释放.*

如果没有,这可能会表明 *内存泄漏^C:已分配对象的损失。这可能导致你的平台资源耗尽,表现为性能低下或随机崩溃。

摘要 13.5

对于每一次 释放,都必须有一个* malloccallocaligned_allocrealloc.*

但要注意,realloc 很容易模糊简单的分配计数:因为如果它用一个现有的对象被调用,它同时作为(旧对象的)释放和(新对象的)分配。

内存分配系统旨在简单,因此free 只允许用于用 malloc 分配的指针或空指针。

摘要 13.6

只使用 free 指针,它们是 malloccallocaligned_allocrealloc 返回的。

它们 必须不

  • 指向由其他方式分配的对象(即变量或复合字面量)

  • 还未被释放

  • 只指向分配对象的一个更小的部分。

否则,你的程序将会崩溃。说真的,这将完全破坏你的程序执行内存,这是你可以遇到的最糟糕的崩溃之一。请小心。

13.2. 存储持续时间、生命周期和可见性

我们在不同的地方看到,标识符的可见性和它所引用的对象的可访问性并不是同一件事。作为一个简单的例子,考虑列表 13.1 中的变量(s) x。

列表 13.1. 使用局部变量的遮蔽示例
 **1**     void squareIt(double* p) {
 **2**       *p *= *p;
 **3**     }
 **4**     int main(void) {
 **5**     double x = 35.0;
 **6**     double* xp = &x;
 **7**     {
 **8**       squareIt(&x);  /* Refers to double x */ 
 **9**       ...
**10**       int x = 0;     /* Shadow double x */ 
**11**       ...
**12**       squareIt(xp);  /* Valid use of double x */ 
**13**       ...
**14**     }
**15**     ...
**16**     squareIt(&x);    /* Refers to double x */ 
**17**     ...
**18**   }

在这里,第 5 行声明的标识符 x 的可见作用域从该行开始,延伸到函数 main 的末尾,但有一个明显的中断:从第 10 行到 14 行,这个可见性被另一个同名的变量,也命名为 x 的变量所 遮蔽**^C

摘要 13.7

标识符只在它们的声明范围内可见,从它们的声明开始。

摘要 13.8

标识符的可见性可以被从属作用域中同名的标识符所遮蔽。

我们还看到,标识符的可见性和它所代表的对象的可使用性并不是同一件事。首先,double x 对象 被 squareIt 的所有调用使用,尽管标识符 x 在函数定义的点不可见。然后,在第 12 行,我们传递double x 变量的地址到函数 squareIt,尽管在那里标识符被遮蔽。

另一个例子涉及带有存储类别extern的声明。这些总是指定一个静态存储持续时间的对象,预期在文件作用域中定义;^([2])参见列表 13.2。

²

实际上,这样的对象可以在另一个翻译单元的文件作用域中定义。

列表 13.2. 使用extern变量的阴影示例
 **1**   #include <stdio.h>
 **2**
 **3**   unsigned i = 1;
 **4**
 **5**   int main(void) {
 **6**     unsigned i = 2;        /* A new object */ 
 **7**     if (i) {
 **8**       extern unsigned i;   /* An existing object */ 
 **9**       printf("%u\n", i);
**10**     } else {
**11**       printf("%u\n", i);
**12**     }
**13**   }

这个程序有三个名为 i 的变量声明,但只有两个定义:第 6 行的声明和定义覆盖了第 3 行的声明。反过来,第 8 行的声明覆盖了第 6 行,但它引用的是第 3 行定义的对象。^([[Exs 4]])

^([Exs 4])

这个程序打印出哪个值?

Takeaway 13.9

每个变量的定义都创建了一个新的、独特的对象

因此,在下面的例子中,char数组 A 和 B 标识不同的对象,具有不同的地址。表达式 A == B必须始终为假:

 **1**   char const A[] = { 'e', 'n', 'd', '\0', };
 **2**   char const B[] = { 'e', 'n', 'd', '\0', };
 **3**   char const* c = "end";
 **4**   char const* d = "end";
 **5**   char const* e = "friend";
 **6**   char const* f = (char const[]){ 'e', 'n', 'd', '\0', };
 **7**   char const* g = (char const[]){ 'e', 'n', 'd', '\0', };

但总共有多少个不同的数组对象?这取决于。编译器有很多选择:

Takeaway 13.10

只读对象字面量可能重叠

在前面的例子中,我们有三个字符串字面量和两个复合字面量。这些都是对象字面量,它们是只读的:字符串字面量按定义是只读的,两个复合字面量是const修饰的。其中四个具有完全相同的基类型和内容('e', 'n', 'd', '\0'),所以指针 c, d, f 和 g 都可能初始化到同一个char数组的地址。编译器甚至可能节省更多内存:这个地址可能是&e[3],通过使用end出现在friend结尾的事实。

从这些例子中我们可以看到,一个对象的可访问性不仅是一个标识符或定义位置的词法属性(对于字面量),还取决于程序的执行状态。对象的生命周期有一个起点和一个终点:

Takeaway 13.11

对象在其生命周期之外无法访问

Takeaway 13.12

引用其生命周期之外的对象具有未定义的行为

一个对象的开头和结尾点的定义取决于我们用来创建它的工具。在 C 语言中,我们区分四种不同的对象存储持续时间静态C*,当它在编译时确定;**自动**C,当它在运行时自动确定;分配C*,当它通过函数调用**malloc**和类似函数显式确定;以及**线程**C,当它与某个执行线程绑定。

表 13.1 概述了声明与其存储类别、初始化、链接、存储持续时间和生命周期之间复杂的关系。目前不深入细节,它显示关键字的使用和底层术语相当令人困惑。

表 13.1. 标识符的存储类别、作用域、链接和关联对象的存储持续时间 暂定 表示如果没有其他带有初始化器的定义,则隐含定义。诱导 表示如果在该声明之前遇到了具有内部链接的另一个声明,则链接是内部的;否则,它是外部的。
范围 定义 链接 持续时间 生命周期
已初始化 文件 外部 静态 整个执行过程
extern,已初始化 文件 外部 静态 整个执行过程
复合字面量 文件 N/A 静态 整个执行过程
字符串字面量 任何 N/A 静态 整个执行过程
静态,已初始化 任何 内部 静态 整个执行过程
未初始化 文件 暂定 外部 静态 整个执行过程
extern,未初始化 任何 诱导 静态 整个执行过程
静态,未初始化 任何 暂定 内部 静态 整个执行过程
线程局部 文件 外部 线程 整个线程
extern 线程局部 任何 外部 线程 整个线程
静态 线程局部 任何 内部 线程 整个线程
复合字面量 N/A
非 VLA
非 VLA,auto 自动 定义块
寄存器
可变长度数组 (VLA) 自动 块定义到结束
函数返回数组 自动 表达式结束处

首先,与名称所暗示的相反,存储类别 extern 可能指代具有外部或内部 链接 的标识符.^([3]) 在这里,除了编译器之外,具有链接的标识符通常由另一个外部程序,即 链接器**^C 管理。这样的标识符在程序启动时初始化,甚至在进入 main 之前,链接器确保这一点。从不同对象文件访问的标识符需要 外部 链接,以便它们都能访问相同的对象或函数,这样链接器就能建立对应关系。

³

注意,链接是标识符的属性,而不是它们所代表的对象。

我们已经看到的重要具有外部链接的标识符是 C 库中的函数。它们位于系统 库**^C 中,通常称为 libc.so,而不是你创建的对象文件中。否则,没有与其他对象文件连接的全局、文件作用域的对象或函数应该具有 内部 链接。所有其他标识符都没有 链接.^([4])

对于 extern 关键字来说,可能更好的词是 linkage

然后,静态存储期并不等同于声明一个具有storage class static的变量。后者仅仅强制一个变量或函数具有内部链接。这样的变量可以声明在文件作用域(全局)或块作用域(局部)。^([5])你可能还没有明确调用你平台的链接器。通常,它的执行被隐藏在你调用的编译器前端后面,动态链接器可能只有在程序启动时才会被注意到,而且可能不会被察觉。

在这个上下文中,static可能是一个更好的关键字,即internal,理解任何形式的链接都意味着静态存储期。

对于前三种存储期类型,我们已经看到了很多例子。线程存储期(_Thread_localthread_local)与 C 的线程 API 相关,我们将在第十八章中看到,届时我们将讨论。

分配的存储期是直接的:此类对象的生存期从创建它的对应调用malloccallocreallocaligned_alloc开始。它以调用freerealloc来销毁它结束,或者,如果没有发出此类调用,则以程序执行结束结束。

存储期的另外两种情况需要额外的解释,因此我们将在下一节中更详细地讨论它们。

13.2.1. 静态存储期

具有静态存储期的对象可以通过两种方式定义:

  • 在文件作用域中定义的对象。变量和复合字面量可以具有这种属性。

  • 在函数块内部声明的变量,并且具有存储类指定符static

这些对象的生存期是整个程序执行期。因为它们在执行任何应用程序代码之前就被认为是活跃的,所以它们只能用编译时已知的表达式或可以被系统的进程启动程序解析的表达式来初始化。以下是一个例子:

 **1**   double A = 37;
 **2**   double* p 
 **3**      = &(double){ 1.0, };
 **4**   int main(void) {
 **5**     static double B;
 **6**   }

这定义了四个具有静态存储期的对象,即 A、p 和 B 以及第 3 行定义的复合字面量。其中三个具有double类型,一个具有double*类型。

所有四个对象都是从一开始就正确初始化的;其中三个是显式初始化的,B 则是通过0隐式初始化。

摘要 13.13

具有静态存储期的对象始终会被初始化。

p 的初始化是一个需要比编译器本身能提供的更多魔法的例子。它使用了另一个对象的地址。这种地址通常只能在执行开始时计算。这就是为什么大多数 C 实现需要链接器概念,正如我们之前讨论的那样。

B 的例子表明,具有整个程序执行生存期的对象不一定在整个程序中都是可见的。extern例子也表明,在别处定义的具有静态存储期的对象可以在狭窄的作用域内变得可见。

13.2.2. 自动存储期

这是最复杂的情况:自动存储期的规则是隐式的,因此需要最多的解释。有几个对象可以显式或隐式地定义,并属于这一类别:

  • 任何未声明为static的块作用域变量,声明为auto(默认)或register

  • 块作用域复合字面量

  • 函数调用返回的一些临时对象

自动对象的生存期最简单和最常见的情况是当对象不是可变长度数组(VLA)。

摘要 13.14

除非是 VLA 或临时对象,否则自动对象的生存期与其定义块的执行相对应。

即,大多数局部变量是在程序执行进入它们定义的作用域时创建的,并在离开该作用域时被销毁。但是,由于递归,同一对象的几个实例**^C可能同时存在:

摘要 13.15

每次递归调用都会创建一个自动对象的新的局部实例。

具有自动存储期的对象在优化方面有一个很大的优势:编译器通常可以看到此类变量的全部使用情况,并且利用这些信息,能够决定它是否可能产生别名。这就是autoregister变量之间的区别所在:

摘要 13.16

不允许对使用 register 声明的变量使用 & 运算符。

因此,我们不会意外地取register变量的地址(摘要 12.12)。作为简单后果,我们得到:

摘要 13.17

使用 register 声明的变量不能产生别名。

因此,使用register变量声明,编译器可以被迫告诉我们变量的地址在哪里,这样我们就可以识别可能具有某些优化潜力的地方。这对于所有不是数组且不包含数组的变量都适用。

摘要 13.18

在性能关键代码中将非数组局部变量声明为 register*。

数组在这里扮演着特殊角色,因为它们在几乎所有上下文中都会退化到其第一个元素的地址。因此,对于数组,我们需要能够取地址。

摘要 13.19

具有存储类 register 的数组是无用的。

还有一种情况需要特别处理数组的存在。一些函数的返回值确实可以是混合体:具有临时生存期的对象。正如你所知,函数通常返回值,而这些值是不可寻址的。但是,如果返回类型包含数组类型,我们必须能够隐式地取其地址,因此[]运算符是明确定义的。因此,以下函数返回的是一个临时对象,我们可以通过使用成员指定符.ory[0]`来隐式地取其地址:

 **1**   struct demo { unsigned ory[1]; };
 **2**   struct demo mem(void);
 **3**
 **4**   printf("mem().ory[0] is %u\n", mem().ory[0]);

C 语言中存在临时生存期的对象,唯一的原因是为了能够访问此类函数返回值的成员。不要将它们用于其他任何目的。

要点 13.20

临时生存期的对象是只读的

要点 13.21

临时生存期在包含表达式的末尾结束

也就是说,它们的生命在它们所在的表达式的评估结束时结束。例如,在上一个示例中,临时对象在printf的参数构造完毕后即不再存在。将此与复合字面量的定义进行比较:复合字面量将一直存在,直到printf的包含范围终止。

13.3. 脱离主题:在定义之前使用对象

以下章节将更详细地介绍自动对象是如何产生生命(或不是)的。这有点难,所以如果你现在不想处理,你可以跳过它,稍后再回来。为了理解第 13.5 节关于具体机器模型的内容,这是必需的,但那一节也是一个脱离主题的部分。此外,它引入了新的特性goto和标签,我们稍后在第 14.5 节处理错误时需要它们。

让我们回到普通自动对象生存期的规则(要点 13.14)。如果你仔细想想,这个规则相当特别:此类对象的生存期从其定义范围开始,而不是像人们可能预期的那样,在其定义在执行过程中首次遇到时开始。

为了说明区别,让我们看看列表 13.3,这是 C 标准文档中可以找到的一个示例的变体。

列表 13.3. 使用复合字面量的一个人为示例
 **3**   void **fgoto**(unsigned n) {
 **4**     unsigned j = 0;
 **5**     unsigned* p = 0;
 **6**     unsigned* q;
 **7**    **AGAIN**:
 **8**     if (p) printf("%u: p and q are %s, *p is %u\n",
 **9**                   j,
**10**                   (q == p) ? "equal" : "unequal",
**11**                   *p);
**12**     q = p;
**13**     p = &((unsigned){ j, });
**14**     ++j;
**15**     if (j <= n) goto **AGAIN**;
**16**   }

如果这个函数以 fgot`o(2)的形式被调用,我们将特别关注打印的行。在我的计算机上,输出看起来像这样:

终端
 **0**   1: p and q are unequal, *p is 0
 **1**   2: p and q are equal, *p is 1

虽然这段代码有点人为,它使用了我们尚未见过的构造goto。正如其名所示,这是一个跳转语句**C*。在这种情况下,它指示计算机在*标签**C AGAIN处继续执行。稍后,我们将看到使用goto更有意义的上下文。这里演示的目的只是跳过复合字面量的定义。

因此,让我们看看执行期间printf调用发生了什么。对于 n == 2,执行遇到相应的行三次;但由于 p 最初为0,在第一次通过时,printf调用本身被跳过。该行我们三个变量的值是

j p q printf
0 0 未确定 跳过
1 Addr of literal of j = 0 0 printed
2 Addr of literal of j = 1 Addr of literal of j = 0 printed

在这里,我们看到对于j==2的指针,p 和 q 持有在不同迭代中获得的地址。那么,为什么我的打印输出会说这两个地址都相等呢?这是巧合吗?还是因为我使用复合字面量在定义之前的地方,所以这是未定义的行为?

C 标准规定,这里显示的输出必须被生成。特别是,对于 j==2,p 和 q 的值相等且有效,它们所指向的对象的值为1。或者,换一种说法,在这个例子中,*p的使用是明确定义的,尽管在词法上*p的评估先于对象的定义。此外,恰好有一个这样的复合字面量,因此当 j==2时,地址是相等的。

Takeaway 13.22

对于不是 VLA 的对象,其生命周期从定义的作用域进入开始,到离开该作用域结束。

Takeaway 13.23

自动变量和复合字面量的初始化器在每次遇到定义时都会被评估。

在这个例子中,复合字面量被访问了三次,并依次设置为012的值。

对于 VLA,其生命周期由不同的规则给出。

Takeaway 13.24

对于可变长度数组(VLA),其生命周期从遇到定义开始,到离开可见作用域结束。

因此,对于 VLA,我们使用goto的奇怪技巧是不合法的:我们不允许在定义之前的代码中使用 VLA 的指针,即使我们仍然在同一个块内。这种特殊处理 VLA 的原因是它们的大小是运行时属性,因此当进入声明块时,为其分配空间是不可能的。

13.4. 初始化

在第 5.5 节中,我们讨论了初始化的重要性。确保程序从一个良好定义的状态开始,并在整个执行过程中保持这种状态是至关重要的。对象的存储持续时间决定了它是如何初始化的。

Takeaway 13.25

静态或线程存储持续期的对象默认进行初始化。

你可能还记得,这种默认初始化与通过0初始化对象的全部成员相同。特别是,对于可能有非平凡0值表示的基础类型,默认初始化效果很好:即指针和浮点类型。

对于其他对象,无论是自动的还是分配的,我们必须做些事情。

Takeaway 13.26

自动或分配存储期的对象必须显式初始化。

实现初始化的最简单方法是初始化器,它们将变量和复合字面量置于定义良好的状态,一旦它们变得可见。对于作为 VLA 或通过动态分配分配的数组,这是不可能的,因此我们必须通过赋值来提供初始化。原则上,我们可以在每次分配这样的对象时手动执行此操作,但这样的代码难以阅读和维护,因为初始化部分可能在视觉上分离定义和使用。避免这种情况的最简单方法是封装初始化到函数中:

取代 13.27

系统地为你的每个数据类型提供一个初始化函数。

这里,重点是 系统地:你应该有一个一致的约定来规定这些初始化函数应该如何工作以及它们应该如何命名。为了说明这一点,让我们回到 rat_init,这是我们的 rat 数据类型的初始化函数。它实现了这样的函数的特定 API:

  • 对于类型 toto,初始化函数命名为 toto_init

  • 这样的 _init 函数的第一个参数是要初始化的对象的指针。

  • 如果该对象的指针为空,函数不执行任何操作。

  • 可以提供其他参数来传递某些成员的初始值。

  • 函数返回它接收到的对象的指针或 0 如果发生错误。

具有这些属性,这样的函数可以很容易地用于指针的初始化器:

rat const* myRat = rat_init(malloc(sizeof(rat)), 13, 7);

注意这有几个优点:

  • 如果 malloc 调用失败并返回 0,唯一的影响是 myRat 被初始化为 0。因此 myRat 总是处于一个定义良好的状态。

  • 如果我们不希望对象之后被修改,我们可以从开始就将指针目标指定为 const。所有对新对象的修改都发生在右侧初始化表达式中。

由于这样的初始化可以出现在许多地方,我们也可以将其封装到另一个函数中:

 **1**   rat* rat_new(long long numerator,
 **2**                unsigned long long denominator) {
 **3**     return rat_init(malloc(sizeof(rat)),
 **4**                     numerator,
 **5**                     denominator);
 **6**   }

使用该函数的初始化方式如下

rat const* myRat = rat_new(13, 7);

宏爱好者像我一样甚至可以轻松定义一个类型通用的宏,它可以一次性完成这样的封装:

#define P99_NEW(T, ...) T ## _init(malloc(sizeof(T)), **__VA_ARGS__**)

有了这个,我们就可以将之前的初始化写成

rat const* myRat = P99_NEW(rat, 13, 7);

这的好处是至少与 rat_new 变体一样可读,但它避免了为所有我们定义的类型额外声明这样的函数。

许多人都对这样的宏定义持批评态度,因此一些项目可能不会接受这作为一个通用的策略,但你应该至少知道这种可能性存在。它使用了我们尚未遇到的两个宏特性:

  • 令牌的连接是通过 ## 运算符实现的。在这里,T ## _init 将 T 和 _init 合并成一个令牌:使用 rat,这会产生 rat_init;使用 toto,这会产生 toto_init。

  • 构造 ... 提供了一个可变长度的参数列表。在宏展开内部,传递给第一个参数之后的整个参数集都可以通过 __VA_ARGS__ 访问。这样,我们可以根据对应 _init 函数的要求传递任意数量的参数给 P99_NEW。

如果我们必须通过一个 for 循环来初始化数组,事情会变得更糟。在这里,也很容易通过函数来封装:

 **1**   rat* rat_vinit(**size_t** n, rat p[n]) {
 **2**     if (p)
 **3**       for (**size_t** i = 0; i < n; ++i)
 **4**         rat_init(p+i, 0, 1);
 **5**     return p;
 **6**   }

使用这样的函数,初始化再次变得简单:

rat* myRatVec = rat_vinit(44, malloc(sizeof(rat[44])));

在这里,将封装到函数中确实更好,因为重复大小很容易引入错误:

 **1**   rat* rat_vnew(**size_t** size) {
 **2**     return rat_vinit(size, malloc(sizeof(rat[size])));
 **3**   }

13.5. 旁白:机器模型

到目前为止,我们主要是在内部讨论 C 代码,使用语言的内部逻辑来描述正在发生的事情。本章是一个可选的旁白,与之前的讨论有所不同:它是对一个具体架构的机器模型的窥视。我们将更详细地看到,一个简单的函数是如何被转换成这个模型的,特别是自动存储持续时间是如何实现的。如果你现在实在无法忍受,你可以先跳过这一部分。否则,请记住不要慌张,深入其中。

传统上,计算机架构是用冯·诺伊曼模型描述的.^([6]) 在这个模型中,处理单元具有有限数量的硬件 寄存器,可以存储整数值,一个 主存储器,它存储程序以及数据,并且是线性可寻址的,以及一个有限的 指令集,它描述了可以使用这些组件执行的操作。

大约在 1945 年由 J. Presper Eckert 和 John William Mauchly 为 ENIAC 项目发明;首先由现代科学的先驱之一 John von Neumann(1903 – 1957,也被称为 Neumann János Lajos 和 Johann Neumann von Margitta)描述,他在 von Neumann [1945]。

通常用来描述 CPU 理解的机器指令的中间编程语言被称为 汇编器**^C,它们仍然在很大程度上基于冯·诺伊曼模型。没有一种唯一的汇编器语言(如 C,适用于所有平台),而是一整套 方言,它们考虑了不同的特定性:CPU、编译器或操作系统。我们这里使用的汇编器是 gcc 编译器用于 x86_64 处理器架构的汇编器.^([[Exs 5]]) 如果你不知道这是什么意思,不要担心;这只是一个此类架构的例子。

^([Exs 5])

查找哪些编译器参数可以为你平台的平台生成汇编输出。

清单 13.4 展示了函数 fgoto 从 清单 13.3 的汇编打印输出。这样的汇编代码在硬件寄存器和内存位置上操作 指令**^C。例如,行 movl $0, -16(%****rbp) 将值 0 存储到内存中,该内存位置位于寄存器 %****rbp 指示的位置下方 16 个字节处。汇编程序还包含 标签**^C,用于标识程序中的某些点。例如,fgoto 是函数的 入口点**^C,而 .L_AGAIN 是汇编中对应于 C 中的 goto 标签 AGAIN 的对应物。

你可能已经猜到了,在 # 字符之后的文本是注释,试图将单个汇编指令与其 C 对应物联系起来。

清单 13.4. fgoto 函数的汇编版本
**10**           .type   fgoto, @function
**11**   fgoto:
**12**           pushq   %rbp               # Save base pointer 
**13**           movq    %rsp, %rbp         # Load stack pointer 
**14**           subq    $48, %rsp          # Adjust stack pointer 
**15**           movl    %edi, -36(%rbp)    # fgoto#0 => n 
**16**           movl    $0, -4(%rbp)       # init j 
**17**           movq    $0, -16(%rbp)      # init p 
**18**   .L_AGAIN:
**19**           cmpq    $0, -16(%rbp)      # if (p)
**20**           je       **.L_ELSE**
**21**           movq    -16(%rbp), %rax    #  p ==> **rax**
**22**           movl    (%rax), %edx       # *p ==> **edx**
**23**           movq    -24(%rbp), %rax    # (   == q)?
**24**           cmpq    -16(%rbp), %rax    # (p  ==  )?
**25**           jne      **.L_YES**
**26**           movl     $.L_STR_EQ, %eax  # Yes 
**27**           jmp     **.L_NO**
**28**   .L_YES:
**29**           movl    $.L_STR_NE, %eax   # No 
**30**   .L_NO:
**31**           movl    -4(%rbp), %esi     # j     ==> printf#1
**32**           movl    %edx, %ecx         # *p    ==> printf#3
**33**           movq    %rax, %rdx         # eq/ne ==> printf#2
**34**           movl    $.L_STR_FRMT, %edi # frmt  ==> printf#0
**35**           movl    $0, %eax           # clear **eax**
**36**           call    **printf**
**37**   .L_ELSE:
**38**           movq    -16(%rbp), %rax    # p ==|
**39**           movq    %rax, -24(%rbp)    #      ==> q 
**40**           movl    -4(%rbp), %eax     # j ==|
**41**           movl    %eax, -28(%rbp)    #      ==> cmp_lit 
**42**           leaq    -28(%rbp), %rax    # &cmp_lit ==|
**43**           movq    %rax, -16(%rbp)    #             ==> p 
**44**           addl    $1, -4(%rbp)       # ++j 
**45**           movl    -4(%rbp), %eax     # if (j 
**46**           cmpl    -36(%rbp), %eax    #       <= n)
**47**           jbe     .L_AGAIN           # goto **AGAIN**
**48**           leave                      # Rearange stack 
**49**           ret                        # return statement

这个汇编函数使用了硬件寄存器 %eax, %ecx, %edi, %edx, %esi, %rax, %rbp, %rcx, %rdx%rsp。这比原始的冯·诺依曼机器要多得多,但主要思想仍然存在:我们有一些通用寄存器,用于表示程序执行状态的价值。另外两个寄存器有非常特殊的作用:%rbp(基指针)和%rsp(栈指针)。

函数处理内存中的一个预留区域,通常称为 栈**^C,该区域用于存储其局部变量和复合字面量。该区域的“上部”由 %rbp 寄存器指定,对象通过相对于该寄存器的负偏移来访问。例如,变量 n 可以从 %rbp 之前 -36 个位置找到,编码为 -36(%****rbp)。以下表格表示为函数 fgoto 预留的内存块布局以及在该函数执行的不同点存储的值。

这个例子特别有趣,可以用来学习自动变量以及它们在执行进入函数时是如何设置的。在这个特定的机器上,当进入 fgoto 时,有三个寄存器持有关于这个调用的信息:%edi 持有函数参数 n;%****rbp 指向调用函数的基地址;%rsp 指向内存中这个对 fgoto 的调用可能存储数据的位置顶部。

现在,让我们考虑上述汇编代码 (清单 13.4) 如何设置这些内容。一开始,fgoto 执行三条指令来正确设置其“世界”。它保存 %rbp 因为它需要这个寄存器用于自己的目的,然后将 %rsp 的值移动到 %rbp,然后减少 %rsp48。在这里,48 是编译器为 fgoto 需要的所有自动对象计算的字节数。由于这种简单的设置类型,该过程保留的空间未初始化,而是填充了垃圾数据。在接下来的三条指令中,三个自动对象(n、j 和 p)被初始化,但其他对象直到后来才初始化。

在此设置完成后,函数就绪可以运行。特别是,它可以轻松调用另一个函数:%****rsp 现在指向一个新内存区域的顶部,被调用的函数可以使用这个区域。这可以在标签 .L_NO 的中间部分看到。这部分实现了对 printf 的调用:它将函数应接收的四个参数按顺序存储在寄存器 %edi%esi%ecx%****rdx 中;清除 %eax;然后调用该函数。

总结来说,为函数的自动对象(无 VLA)设置内存区域(无需 VLA)只需要几条指令,无论函数实际使用了多少自动对象。如果函数有更多,则需要将魔法数字 48 修改为新区域的大小。

由于这种方式,产生了以下后果,

  • 自动对象通常从函数或作用域的开始就可用。

  • 自动变量的初始化不是强制的。

这很好地映射了 C 中自动对象的生存期和初始化规则。

早期的汇编器输出只是故事的一半,最多。它是在没有优化的情况下产生的,只是为了展示可以为此类代码生成做出的基本假设。当使用优化时,as-if 规则 (takeaway 5.8) 允许我们大幅度地重新组织代码。在完全优化的情况下,我的编译器产生的代码类似于 清单 13.5。

清单 13.5. fgoto 函数的优化汇编版本
**12**           .type    **fgoto**, @function
**13**   **fgoto**:
**14**           pushq    **%rbp**              # Save base pointer 
**15**           pushq    **%rbx**              # Save **rbx** register
**16**           subq     $8, **%rsp**          # Adjust stack pointer 
**17**           movl    **%edi**, **%ebp**         # **fgoto**#0 => n 
**18**           movl     $1, **%ebx**          # init j, start with 1
**19**           xorl    **%ecx**, **%ecx**         # 0    ==> printf#3
**20**           movl    **$.L_STR_NE**, **%edx**   # "ne" ==> printf#2
**21**           testl    **%edi**, **%edi**        # if (n > 0)
**22**           jne    **.L_N_GT_0**
**23**           jmp    **.L_END**
**24**   **.L_AGAIN**:
**25**           movl    **%eax**, **%ebx**         # j+1  ==> j 
**26**   **.L_N_GT_0**:
**27**           movl    **%ebx**, **%esi**         # j    ==> printf#1
**28**           movl    **$.L_STR_FRMT**, **%edi** # frmt ==> printf#0
**29**           xorl    **%eax**, **%eax**         # Clear **eax**
**30**           call    **printf**
**31**           leal    1(%**rbx**), **%eax**      # j+1  ==> **eax**
**32**           movl    **$.L_STR_EQ**, **%edx**   # "eq" ==> printf#2
**33**           movl    **%ebx**, **%ecx**         # j    ==> printf#3
**34**           cmpl    **%ebp**, **%eax**         # if (j <= n)
**35**           jbe     **.L_AGAIN**           # goto **AGAIN**
**36**   **.L_END**:
**37**           addq    $8, **%rsp**           # Rewind stack 
**38**           popq    **%rbx**               # Restore **rbx**
**39**           popq    **%rbp**               # Restore **rbp**
**40**           ret                        # return statement

如你所见,编译器已经完全重构了代码。这段代码只是重现了原始代码的效果:其输出与之前相同。但它不使用内存中的对象,不比较指针是否相等,并且没有复合字面量的痕迹。例如,它根本不实现 j=0 的迭代。这个迭代没有效果,所以它被简单地省略了。然后,对于其他迭代,它区分了一个 j=1 的版本,其中 C 程序中的指针 p 和 q 被认为是不同的。然后,对于一般情况,需要增加 j 并相应地设置 printf 的参数.^([[Exs 6]])^([[Exs 7]])

^([Exs 6])

利用 p 被反复赋以相同值的这一事实,编写一个 C 程序,使其更接近优化汇编版本的模样。

^([Exs 7])

即使是优化版本也还有改进的空间:循环的内部部分仍然可以缩短。编写一个 C 程序,在完全优化编译时探索这种潜力。

在这里我们所看到的代码都没有使用可变长度数组(VLA)。这些代码改变了情况,因为仅仅通过常数来修改%rsp的技巧在需要的内存不是固定大小时就不起作用了。对于 VLA,程序必须在执行期间从 VLA 边界的实际值计算大小,相应地调整%rsp,然后一旦执行离开 VLA 定义的作用域,就必须撤销对%rsp的修改。因此,这里%rsp调整的值不能在编译时计算,而必须在程序执行期间确定。

概述

  • 对于大量对象或大尺寸对象的存储可以动态分配和释放。我们必须仔细跟踪这种存储。

  • 标识符可见性和存储持续时间是不同的事情。

  • 初始化必须对每种类型系统地使用一致的策略。

  • C 为局部变量分配的策略很好地映射到函数栈的低级处理。

第十四章. 更复杂的处理和 IO

本章涵盖

  • 使用指针

  • 格式化输入

  • 处理扩展字符集

  • 使用二进制流进行输入和输出

  • 检查错误和清理

现在我们已经了解了指针及其工作原理,我们将对 C 库的一些特性进行新的探讨。C 的文本处理如果不使用指针就不完整,因此我们将从第 14.1 节的一个详细示例开始本章。然后我们将查看格式化输入的函数(第 14.1 节);这些函数需要指针作为参数,因此我们不得不推迟它们的介绍直到现在。然后我们将介绍一系列新函数来处理扩展字符集(第 14.3 节)和二进制流(第 14.4 节),最后我们通过讨论干净的错误处理(第 14.4 节)来结束本章和整个级别的讨论。

14.1. 文本处理

作为第一个例子,考虑以下程序,它从stdin读取一系列带有数字的行,并以逗号分隔的十六进制数的形式将这些相同的数字以规范化的方式写入stdout

numberline.c
**246**   int **main**(void) {
**247**     char lbuf[256];
**248**     for (;;) {
**249**       if (fgetline(sizeof lbuf, lbuf, **stdin**)) {
**250**         **size_t** n;
**251**         **size_t*** nums = numberline(**strlen**(lbuf)+1, lbuf, &n, 0);
**252**         int ret = fprintnumbers(**stdout**, "%#zX", ",\t", n, nums);
**253**         if (ret < 0) return **EXIT_FAILURE**;
**254**         **free**(nums);
**255**       } else {
**256**         if (lbuf[0]) {  /* a partial line has been read */ 
**257**           for (;;) {
**258**             int c = **getc**(**stdin**);
**259**             if (c == **EOF**) return **EXIT_FAILURE**;
**260**             if (c == '\n') {
**261**               **fprintf**(**stderr**, "line too long: %s\n", lbuf);
**262**               break;
**263**             }
**264**           }
**265**         } else break;   /* regular end of input */ 
**266**       }
**267**     }
**268**   }

这个程序将任务分为三个不同的任务:

  • fgetline 读取一行文本

  • numberline 将此类行分割成一系列size_t类型的数字

  • fprintnumbers 来打印它们

核心是 numberline 函数。它将接收到的 lbuf 字符串分割成数字,分配一个数组来存储它们,并且如果提供了指针参数 np,还通过 np 返回这些数字的计数:

numberline.c

numberline: 将字符串lbuf解释为用base表示的数字序列

返回值:一个新分配的包含在lbuf中找到的数字的数组

参数:

lbuf 应该是一个字符串
np 如果非空,则将数字的计数存储在*np
base 从 0 到 36 的值,与strtoul相同的解释

备注:调用此函数的调用者负责释放返回的数组。

**size_t*** numberline(**size_t** size, char const lbuf[restrict size],
                   **size_t***restrict np, int base);

该函数本身分为两部分,执行完全不同的任务。一个执行解析行的任务,即 numberline_inner。另一个,numberline 本身,只是第一个的包装,用于验证或确保第一个的前提条件。numberline_inner 函数将 C 库函数strtoull放入一个循环中,该循环收集数字并返回它们的计数。

现在我们来看strtoull函数第二个参数的用法。在这里,它是下一个变量的地址,而 next 用于跟踪数字结束的字符串中的位置。由于 next 是一个指向char的指针,因此strtoull的参数是一个指向指向char的指针的指针:

numberline.c
 **97**   static
 **98**   **size_t** numberline_inner(char const*restrict act,
 **99**                          **size_t** numb[restrict], int base){
**100**     **size_t** n = 0;
**101**     for (char* next = 0; act[0]; act = next) {
**102**       numb[n] = **strtoull**(act, &next, base);
**103**       if (act == next) break;
**104**       ++n;
**105**     }
**106**     return n;
**107**   }

假设strtoull被调用为strtoull("0789a", &next, base**)。根据参数 base 的值,该字符串被解释为不同。例如,如果 base 的值为10,则第一个非数字字符是字符串末尾的字符'a':

Base 数字 数字 *next
8 2 7 '8'
10 4 789 'a'
16 5 30874 '\0'
0 2 7 '8'

记住基数0的特殊规则。有效基数是从字符串中的第一个(或前两个)字符中推断出来的。在这里,第一个字符是'0',因此字符串被解释为八进制,解析在第一个非数字字符处停止:'8'。

numberline_inner 接收到的行可能有两个条件会导致解析结束:

  • act 指向一个字符串终止符:一个0字符。

  • 函数strtoull找不到数字,在这种情况下,next 被设置为 act 的值。

这两个条件作为for循环的控制表达式和内部if-break条件被发现。

注意,C 库函数strtoull有一个历史性的弱点:第一个参数的类型是char const*,而第二个参数的类型是char**,没有const修饰。这就是为什么我们必须将 next 类型指定为char*,而不能使用char const*。由于调用strtoull,我们可能会意外地修改只读字符串并导致程序崩溃。

Takeaway 14.1

strt**o... 字符串转换函数不是 const*-安全的。

现在,numberline 函数本身提供了围绕 numberline_inner 的粘合剂:

  • 如果 np 为空,则将其设置为指向一个辅助对象。

  • 检查输入字符串的有效性。

  • 一旦知道正确的长度,就会分配一个足够存储值的数组,并调整到适当的大小。

我们使用 C 库中的三个函数:memchrmallocrealloc。与前面的例子一样,mallocrealloc 的组合确保我们有一个必要长度的数组:

numberline.c
**109**   **size_t*** numberline(**size_t** size, char const lbuf[restrict size],
**110**                      **size_t***restrict np, int base){
**111**     **size_t*** ret = 0;
**112**     **size_t** n = 0;
**113**     /* Check for validity of the string, first. */ 
**114**     if (**memchr**(lbuf, 0, size)) {
**115**       /* The maximum number of integers encoded. 
**116**          To see that this may be as much look at 
**117**          the sequence 08 08 08 08 ... and suppose 
**118**          that base is 0\. */ 
**119**       ret = **malloc**(sizeof(**size_t**[1+(2*size)/3]));
**120**
**121**       n = numberline_inner(lbuf, ret, base);
**122**
**123**       /* Supposes that shrinking realloc will always succeed. */ 
**124**       **size_t** len = n ? n : 1;
**125**       ret = **realloc**(ret, sizeof(**size_t**[len]));
**126**     }
**127**     if (np) *np = n;
**128**     return ret;
**129**   }

memchr 的调用返回第一个值为 0 的字节的地址,如果有的话,或者如果没有,则返回 (void*``)0。在这里,这只是为了检查在第一个大小字节内实际上是否存在一个 0 字符。这样,它保证了所有底层使用的字符串函数(特别是 strtoull)都操作在以 0 结尾的字符串上。

使用 memchr,我们遇到了另一个有问题的接口。它返回一个 void*,它可能指向一个只读对象。

Takeaway 14.2

memchrstrchr 搜索函数不是 const*-安全的。

相比之下,返回字符串中索引位置的函数将是安全的。

Takeaway 14.3

strspnstrcspn 搜索函数是 const*-安全的。

不幸的是,它们有一个缺点,即不能用来检查一个 char-数组实际上是否是一个字符串。因此,它们不能在这里使用。

现在,让我们看看我们示例中的第二个函数:

numberline.c

fgetline: 读取最多-1字节大小的文本行。

\n 字符替换为 0

返回值: 如果成功读取整个行,则返回s。否则,返回0,并且s包含可以读取的最大部分行。*s*以空字符结尾。

char* fgetline(**size_t** size, char s[restrict size],
               **FILE***restrict stream);

这与 C 库函数 fgets 非常相似。第一个区别是接口:参数顺序不同,大小参数是 size_t 而不是 int。像 fgets 一样,如果从流中读取失败,它返回一个空指针。因此,在流上可以轻松检测文件结束条件。

更重要的是,fgetline 更优雅地处理另一个关键情况。它检测下一个输入行是否过长,或者流的最后一行是否没有以\n字符结束:

numberline.c
**131**   char* fgetline(**size_t** size, char s[restrict size],
**132**                  **FILE***restrict stream){
**133**     s[0] = 0;
**134**     char* ret = **fgets**(s, size, stream);
**135**     if (ret) {
**136**       /* s is writable so can be pos. */ 
**137**       char* pos = **strchr**(s, '\n');
**138**       if (pos) *pos = 0;
**139**       else ret = 0;
**140**     }
**141**     return ret;
**142**   }

函数的前两行保证 s 总是以空字符结尾:要么是通过 fgets 调用成功,要么是通过强制使其成为一个空字符串。然后,如果读取了某些内容,s 中可以找到的第一个 \n 字符将被替换为 0。如果没有找到,则读取了部分行。在这种情况下,调用者可以检测这种情况并再次调用 fgetline 来尝试读取剩余的行或检测文件结束条件.^([[Exs 1]])

^([Exs 1])

改进示例的 main 部分,使其能够处理任意长度的输入行。

除了 fgets,这个例子还使用了 C 库中的 strchr。这个函数缺乏 const-安全性在这里不是问题,因为 s 应该是可修改的。不幸的是,由于现有的接口,我们总是必须自己进行这种评估。

由于它涉及大量的详细错误处理,我们将详细介绍函数 fprintnumbers,它在 第 14.5 节 中。对于我们的目的,我们只限于讨论函数 sprintnumbers,它比它简单,因为它只写入字符串,而不是流,并且它只假设它接收到的缓冲区 buf 提供了足够的空间:

numberline.c

sprintnumbers: 在 buf 中打印一系列数字 nums,使用 printf 格式 form,并用 sep 字符分隔,并以换行符结尾。

返回值: 打印到 buf 中的字符数。

这假设 totbuf 足够大,并且 form 是一个适合打印 size_t 的格式。

int sprintnumbers(**size_t** tot, char buf[restrict tot],
                  char const form[restrict static 1],
                  char const sep[restrict static 1],
                  **size_t** len, **size_t** nums[restrict len]);

函数 sprintnumbers 使用 C 库中的一个我们尚未遇到的功能:sprintf。它的格式化能力与 printffprintf 相同,只是它不打印到流,而是打印到 char 数组:

numberline.c
**149**   int sprintnumbers(**size_t** tot, char buf[restrict tot],
**150**                     char const form[restrict static 1],
**151**                     char const sep[restrict static 1],
**152**                     **size_t** len, **size_t** nums[restrict len]) {
**153**     char* p = buf;   /* next position in buf */ 
**154**     **size_t** const seplen = **strlen**(sep);
**155**     if (len) {
**156**       **size_t** i = 0;
**157**       for (;;) {
**158**         p += **sprintf**(p, form, nums[i]);
**159**         ++i;
**160**         if (i >= len) break;
**161**         **memcpy**(p, sep, seplen);
**162**         p += seplen;
**163**       }
**164**     }
**165**     **memcpy**(p, "\n", 2);
**166**     return (p-buf)+1;
**167**   }

函数 sprintf 总是确保在字符串末尾放置一个 0 字符。它还返回该字符串的长度,这是在 0 字符之前写入的字符数。这在示例中用于更新缓冲区当前位置的指针。sprintf 仍然有一个重要的漏洞:

Takeaway 14.4

sprintf 没有提供防止缓冲区溢出的措施

即,如果我们传递一个不充足的缓冲区作为第一个参数,会发生不好的事情。在这里,在 sprintnumbers 中,就像 sprintf 本身一样,我们 假设 缓冲区足够大,可以容纳结果。如果我们不确定缓冲区能否容纳结果,我们可以使用 C 库函数 snprintf,而不是:

**1**   int **snprintf**(char*restrict s, **size_t** n, char const*restrict form, ...);

这个函数还确保不会写入超过 n 个字节。如果返回值大于或等于 n,字符串将被截断以适应。特别是,如果 n 是 0,则不会写入 s。

Takeaway 14.5

使用 snprintf *来格式化未知长度的输出。

总结来说,snprintf 有很多不错的特性:

  • 缓冲区 s 不会溢出。

  • 在成功调用后,s 是一个字符串。

  • 当 n 和 s 设置为 0 时,snprintf 只返回将要写入的字符串的长度。

通过使用它,一个简单的 for 循环来计算一行上打印的所有数字的长度如下所示:

numberline.c
**182**      /* Count the chars for the numbers. */ 
**183**      for (**size_t** i = 0; i < len; ++i)
**184**        tot += **snprintf**(0, 0, form, nums[i]);

我们将在后面看到如何在 fprintnumbers 的上下文中使用它。

字符串中的文本处理

我们已经讨论了很多关于文本处理的内容,现在让我们看看我们是否真的能使用它。

你能否在字符串中搜索一个特定的单词?

你能否在字符串中替换一个单词,并返回包含新内容的副本?

你能否为字符串实现一些正则表达式匹配函数?例如,找到一个字符类如 [A-Q][⁰-9],使用 *(表示“任何东西”)进行匹配,或使用 ?(表示“任何字符”)进行匹配。

你能否实现一个用于 POSIX 字符类(如 [[:alpha:]], [[:digit:]] 等)的正则表达式匹配函数?

你能否将这些功能组合起来,在字符串中搜索正则表达式?

你能否使用正则表达式对特定单词进行查询替换?

能否扩展正则表达式以实现分组?

能否扩展查询替换以实现分组?

14.2. 格式化输入

与用于格式化输出的 printf 函数族类似,C 库有一系列用于格式化输入的函数:fscanf 用于从任意流中读取,scanf 用于 stdinsscanf 用于字符串。例如,以下代码将从 stdin 读取一行包含三个 double 值:

**1**   double a[3];
**2**   /* Read and process an entire line with three double values. */ 
**3**   if (**scanf**(" %lg %lg %lg ", &a[0], &a[1], &a[2]) < 3) {
**4**      **printf**("not enough input values!\n");
**5**   }

表 14.1 到 14.3 提供了规范格式的概述。不幸的是,这些函数比 printf 更难使用,并且它们的约定在细微之处与 printf 有所不同。

表 14.1. scanf 和类似函数的格式规范,其一般语法为 [XX][WW][LL]SS
XX * 赋值抑制
WW 字段宽度 最大输入字符数
LL 修饰符 选择目标类型的宽度
SS 规范符 选择转换
  • 为了能够返回所有格式的值,参数是指向被扫描的类型指针。

  • 空白处理很微妙,有时会出乎意料。在格式中,空格字符 ' ' 匹配任何空白序列:空格、制表符和换行符。这样的序列可能是空的,或者包含多个换行符。

  • 字符串处理不同。因为 scanf 函数的参数本身就是指针,所以格式 "%c" 和 "%s" 都指向一个 char* 类型的参数。其中 "%c" 读取固定大小的字符数组(默认为 1),而 "%s" 匹配任何非空白字符序列,并添加一个终止的 0 字符。

  • 格式中类型的规范与 printf 相比有细微的差别,特别是在浮点类型方面。为了保持两者的一致性,最好对 double 使用 "%lg" 或类似格式,对 long double 使用 "%Lg",无论是 printf 还是 scanf

  • 有一个基本的工具可以识别字符类。例如,格式 "%[aeiouAEIOU]" 可以用来扫描拉丁字母中的元音。

表 14.2. scanf 和类似函数的格式说明符 使用'l'修饰符时,字符或字符集('c', 's', '[')的说明符将输入的多字节字符序列转换为宽字符wchar_t参数;参见 14.3 节。
SS 转换 指针到 跳过空格 类似于函数
'd' 十进制 有符号类型 strtol,基数 10
'i' 十进制、八进制或十六进制 有符号类型 strtol,基数 0
'u' 十进制 无符号类型 strtoul,基数 10
'o' 八进制 无符号类型 strtoul,基数 8
'x' 十六进制 无符号类型 strtoul,基数 16
'aefg' 浮点数 浮点数 strtod
'%' '%'字符 无赋值
'c' 字符 char memcpy
's' 非空白字符 字符 strcspn
" \f\n\r\t\v"
'[' 扫描集 字符串 strspnstrcspn
'p' 地址 void
'n' 字符计数 有符号类型
  • 在这样的字符类指定中,如果开头的反斜杠^出现在类中,它将否定该类。因此,"%[^\n]%*[\n]"扫描整个行(必须非空)然后丢弃行尾的换行符。

这些特性使得scanf函数族难以使用。例如,我们的看似简单的例子有一个缺陷(或特性),即它不仅限于读取单行输入,而且它还会愉快地接受分布在多行上的三个double值。([[Exs 2]]) 在大多数有规律输入模式的情况下,例如一系列数字,最好避免使用。

^([Exs 2])

修改示例中的格式字符串,使其只接受一行上的三个数字,由空格分隔,并且跳过终止的换行符(可能前面有空白)。

14.3. 扩展字符集

到目前为止,我们只使用了一组有限的字符来指定我们的程序或打印在控制台上的字符串字面量的内容:一组由拉丁字母、阿拉伯数字和一些标点符号字符组成。这种限制是一个历史事件,起源于美国计算机行业在早期市场的统治地位,另一方面,以及最初需要用非常有限的位数来编码字符的需求.^([1]) 正如我们在使用类型名char作为基本数据单元时所见,文本字符和不可分割的数据组件的概念在开始时并没有很好地分离。

¹

用于基本字符集的占主导地位的字符编码被称为 ASCII:American standard code for information interchange。

表 14.3. scanf 和类似函数的格式修饰符 注意,float*double* 参数的显著性不同于 printf 格式。
字符 类型
"hh" char 类型
"h" short 类型
"" signedunsignedfloatchar 数组和字符串
"l" long 整数类型,doublewchar_t 字符和字符串
"ll" long long 整数类型
"j" intmax_t, uintmax_t
"z" size_t
"t" ptrdiff_t
"L" long double

拉丁语,我们从其中继承了我们的字符集,作为一种口语已经很久远了。它的字符集不足以编码其他语言的语音特性。在欧洲语言中,英语有一个特性,它用字母组合(如 aiou,和 gh (fair enough))来编码缺失的音素,而不是用重音符号、特殊字符或连字符(如 fär ínó),就像大多数它的表亲一样。所以对于使用拉丁字母的其他语言,可能性已经相当有限;但对于使用完全不同书写系统(希腊语、俄语)或甚至完全不同概念(日语、中文)的语言和文化,这个受限的美国字符集显然是不够的。

在全球市场扩张的前几年,不同的计算机制造商、国家和组织为其各自的社区提供本地语言支持,或多或少是随机进行的,并且没有协调地添加了对图形字符、数学排版、乐谱等特殊支持。这完全是一团糟。因此,在不同系统、国家和文化之间交换文本信息在许多情况下是困难的,甚至是不可能的;编写可在不同语言 不同计算平台上下文中使用的可移植代码就像黑魔法一样。

幸运的是,这些长达数年的困难现在已经被主要克服,在现代系统中,我们可以编写使用“扩展”字符的统一方式的可移植代码。以下代码片段展示了这是如何工作的:

mbstrings-main.c
**87**   **setlocale**(**LC_ALL**, "");
**88**   /* Multibyte character printing only works after the locale 
**89**     has been switched. */ 
**90**   draw_sep(TOPLEFT " © 2014 jεnz 'g℧ztεt ", TOPRIGHT);

即,在我们的程序接近开始的地方,我们切换到“本地”区域设置,然后我们可以使用和输出包含 扩展字符 的文本:这里,是语音学(所谓的 IPA)。输出看起来类似于

实现这一点的手段相当简单。我们有一些宏,带有用于垂直和水平线的魔法字符串字面量,以及左上角和右上角:

mbstrings-main.c
**43**   #define VBAR "\u2502"      /**< a vertical bar character   */
**44**   #define HBAR "\u2500"      /**< a horizontal bar character */
**45**   #define TOPLEFT "\u250c"   /**< topleft corner character   */
**46**   #define TOPRIGHT "\u2510"  /**< topright corner character  */

以及一个格式化输出行的专用函数:

mbstrings-main.c

draw_sep: 使用水平线绘制由 startend 分隔的多字节字符串。

  void draw_sep(char const start[static 1],
                char const end[static 1]) {
    **fputs**(start, **stdout**);
    **size_t** slen = mbsrlen(start, 0);
    **size_t** elen = 90 - mbsrlen(end, 0);
    for (**size_t** i = slen; i < elen; ++i) **fputs**(HBAR, **stdout**);
    **fputs**(end, **stdout**);
    **fputc**('\n', **stdout**);
  }

这使用一个函数来计算多字节字符串(mbsrlen)中的打印字符数,以及我们的老朋友 fputsfputc 用于文本输出。

所有这一切都是从调用setlocale开始的,这一点很重要。否则,如果您将扩展集中的字符输出到终端,可能会看到垃圾数据。但是一旦您调用了setlocale并且系统安装良好,这些字符放在多字节字符串中“fär ínóff”应该不会出太大问题。

一个多字节字符是一系列字节,被解释为表示扩展字符集的单个字符,而一个多字节字符串是包含此类多字节字符的字符串。幸运的是,这些家伙与我们迄今为止处理的标准字符串兼容。

收获 14.6

多字节字符不包含空字节

收获 14.7

多字节字符串以空字符结尾

因此,许多标准字符串函数,如strcpy,对于多字节字符串来说都是现成的。尽管如此,它们也引入了一个主要困难:打印字符的数量不能再直接从char数组元素的数量或通过函数strlen来推断。这就是为什么在前面的代码中,我们使用了(非标准的)函数 mbsrlen:

mbstrings.h

mbsrlen:在mbs中解释 mb 字符串,并将其作为宽字符字符串返回其长度。

返回值:多字节字符串的长度,如果发生编码错误则返回-1

只要向此函数传递一个与mbs中 mb 字符起始状态一致的状态参数,此函数就可以集成到对字符串的一系列搜索中。该状态本身不会被此函数修改。

备注0状态表示可以不考虑任何上下文扫描mbs

**size_t** mbsrlen(char const*restrict mbs,
               **mbstate_t** const*restrict state);

如您从描述中可以看到,解析多字节字符串以获取单个多字节字符可能要复杂一些。特别是,我们通常需要通过 C 标准在头文件wchar.h中提供的类型mbstate_t来保持解析状态。^([2]) 此头文件提供了多字节字符串和字符的实用工具,以及宽字符类型wchar_t。我们将在后面看到这一点。

²

头文件uchar.h也提供了这种类型。

<wchar.h>

但首先,我们必须介绍另一个国际标准:ISO 10646,或称为Unicode [2017]。正如其命名所示,Unicode (www.joelonsoftware.com/articles/Unicode.html)试图提供一个统一的字符码框架。它提供了一个巨大的表格^([3]),基本上包含了迄今为止人类所构想的所有字符概念概念在这里非常重要:我们必须从特定类型中某个特定字符的打印形式或符号来理解,例如,“拉丁大写字母 A”可以在这个文本中以 A、A、A 或A的形式出现。其他类似的概念字符,如“希腊大写字母 Alpha”字符,甚至可能以相同的或相似的符号 A 打印。

³

现在,Unicode 大约有 110,000 个码点。

Unicode 将每个字符概念,或称为码点,放入其自己的语言或技术上下文中。除了字符的定义外,Unicode 还对它进行分类,例如,将其归类为一个大写字母,并将其与其他码点相关联,例如,指出Aa的大写形式。

如果您需要特定语言的特殊字符,那么您很可能在键盘上就能找到它们,并且可以将它们输入到用于 C 语言编码的多字节字符串中,就像这样。也就是说,您的系统可能被配置为直接将整个字节序列(例如ä)插入到文本中,并为您完成所有必要的操作。如果您没有或者不想使用这种方法,您可以使用我们之前用于宏 HBAR 的技术。在那里,我们使用了 C11 中引入的新转义序列(dotslashzero.net/2014/05/21/the-interesting-state-of-unicode-in-c/):一个反斜杠后跟一个u和四个十六进制数字来编码一个 Unicode 码点。例如,“拉丁小写字母 a 带重音符号”的码点是 228 或 0xE4。在多字节字符串中,这读作"\u00E4"。由于四个十六进制数字只能表示 65,536 个码点,因此还有指定 8 个十六进制数字的选项,这是通过反斜杠和一个大写U引入的,但您只有在非常专业的环境中才会遇到这种情况。

在前面的例子中,我们使用这样的 Unicode 规范编码了四个图形字符,这些字符很可能不在任何键盘上。有几个在线网站允许您查找您需要的任何字符的码点。

如果我们想要使用多字节字符和字符串进行比简单的输入/输出更复杂的操作,事情就会变得稍微复杂一些。简单的字符计数已经不再是微不足道的:strlen不会给出正确答案,其他字符串函数如strchrstrspnstrstr也不会按预期工作。幸运的是,C 标准为我们提供了一组替换函数,通常以wcs而不是 str 作为前缀,这些函数将作用于宽字符字符串。我们之前介绍的 mbsrlen 函数可以编码为

mbstrings.c
**30**   **size_t** mbsrlen(char const*s, **mbstate_t** const*restrict state) {
**31**     if (!state) state = MBSTATE;
**32**     **mbstate_t** st = *state;
**33**     **size_t** **mblen** = **mbsrtowcs**(0, &s, 0, &st);
**34**     if (**mblen** == -1) **errno** = 0;
**35**     return **mblen**;
**36**   }

这个函数的核心是使用库函数mbsrtowcs(“多字节字符串(mbs),可重置,到宽字符字符串(wcs)”),它构成了 C 标准提供的用于处理多字节字符串的原始函数之一:

**1**   **size_t** **mbsrtowcs**(**wchar_t***restrict dst, char const**restrict src,
**2**                    **size_t** len, **mbstate_t***restrict ps);

因此,一旦我们解密了名称的缩写,我们就知道这个函数应该将 mbs,src,转换为 wcs,dst。在这里,宽字符(wc)类型wchar_t用于精确编码扩展字符集中的单个字符,这些宽字符以与char组成普通字符串相同的方式形成 wcs:它们是这种宽字符的空终止数组。

C 标准对wchar_t使用的编码限制不多,但任何合理的环境现在都应该使用 Unicode 作为其内部表示。你可以使用以下两个宏来检查这一点:

mbstrings.h
**24**   #ifndef **__STDC_ISO_10646__**
**25**   # error "wchar_t wide characters have to be Unicode code points" 
**26**   #endif
**27**   #ifdef **__STDC_MB_MIGHT_NEQ_WC__**
**28**   # error "basic character codes must agree on char and wchar_t" 
**29**   #endif

现代平台通常使用 16 位或 32 位整数类型来实现wchar_t。如果你只使用可以用四个十六进制数字表示的 Unicode 码点,那么通常你不必太关心这一点。那些使用 16 位有效编码的平台不能使用\UXXXXXXXX 表示法中的其他码点,但这不应该让你感到烦恼。

宽字符和宽字符字符串字面量遵循与char和字符串相同的规则。对于两者,L 前缀表示宽字符或字符串:例如,L'ä'和 L'\u00E4'是相同的字符,两者都是wchar_t类型,L"b\u00E4"是一个包含宽字符 L'b'、L'ä'和0的三个元素的数组。

宽字符的分类也以与简单char类似的方式进行。头文件wctype.h提供了必要的函数和宏。

<wctype.h>

回到mbsrtowcs,这个函数解析多字节字符串 src 到对应于多字节字符(mbc)的片段,并将相应的码点分配给 dst 中的宽字符。参数 len 描述了结果 wcs 可能的最大长度。参数 state 指向一个变量,该变量存储 mbs 的潜在解析状态;我们将在稍后简要讨论这个概念。

如您所见,函数 mbsrtowcs 有两个特殊性。首先,当使用空指针调用 dst 时,它不会存储 wcs,而只是返回这样一个 wcs 的大小。其次,如果 mbs 没有正确编码,它可能会产生 编码错误。在这种情况下,函数返回 (size_t)-1 并将 errno 设置为值 EILSEQ(见 errno.h)。mbsrlen 的部分代码实际上是对该错误策略的修复,通过将 errno 再次设置为 0

<errno.h>

现在我们来看第二个函数,它将帮助我们处理 mbs:

mbstrings.h

mbsrdup: 将 s 中的字节序列解释为 mb 字符串,并将其转换为宽字符字符串。

返回值:一个新分配的适当长度的宽字符字符串,如果发生编码错误则返回 0

备注:此函数可以通过传递到该函数的与 c 中 mb 字符起始一致的 state 参数,集成到一系列对字符串的此类搜索中。该状态本身不会被此函数修改。

0 状态的 state 表示可以不考虑任何上下文扫描 s

**wchar_t*** mbsrdup(char const*s, **mbstate_t** const*restrict state);

此函数返回一个新分配的具有与输入的 mbs s 相同内容的 wcs。除了状态参数外,其实现很简单:

mbstrings.c
**38**   **wchar_t*** mbsrdup(char const*s, **mbstate_t** const*restrict state) {
**39**     **size_t** **mblen** = mbsrlen(s, state);
**40**     if (**mblen** == -1) return 0;
**41**     **mbstate_t** st = state ? *state : *MBSTATE;
**42**     **wchar_t*** S = **malloc**(sizeof(**wchar_t**[**mblen**+1]));
**43**     /* We know that s converts well, so no error check */ 
**44**     if (S) **mbsrtowcs**(S, &s, **mblen**+1, &st);
**45**     return S;
**46**   }

确定目标字符串的长度后,我们使用 malloc 分配空间,并使用 mbsrtowcs 复制数据。

为了更精细地控制 mbs 的解析,标准提供了函数 mbrtowc

**1**   **size_t** **mbrtowc**(**wchar_t***restrict pwc,
**2**                  const char*restrict s, **size_t** len,
**3**                  **mbstate_t*** restrict ps);

在此接口中,参数 len 表示在 s 中扫描单个多字节字符的最大位置。由于通常我们不知道这种多字节编码在目标机器上的工作方式,我们必须做一些有助于我们确定 len 的猜测工作。为了封装这种启发式方法,我们制定了以下接口。它的语义类似于 mbrtowc,但避免了 len 的指定:

mbstrings.h

mbrtow: 将 c 中的字节序列解释为 mb 字符,并通过 C 返回该宽字符。

返回值:mb 字符的长度,或者在发生编码错误时返回 -1

此函数可以通过将相同的 state 参数传递给所有调用此或类似函数的调用集成到一系列对字符串的此类搜索中。

备注0 状态的 state 表示可以不考虑任何上下文扫描 c

**size_t** mbrtow(**wchar_t***restrict C, char const c[restrict static 1],
              **mbstate_t***restrict state);

此函数返回字符串中第一个多字节字符识别的字节数,或者在出错时返回 -1mbrtowc 还有一个可能的返回值 -2,用于 len 不够大的情况。实现使用该返回值来检测这种情况并调整 len,直到它适合:

mbstrings.c
**14**   **size_t** mbrtow(**wchar_t***restrict C, char const c[restrict static 1],
**15**                 **mbstate_t***restrict state) {
**16**     if (!state) state = MBSTATE;
**17**     **size_t** len = -2;
**18**     for (**size_t** maxlen = **MB_LEN_MAX**; len == -2; maxlen *= 2)
**19**       len = **mbrtowc**(C, c, maxlen, state);
**20**     if (len == -1) **errno** = 0;
**21**     return len;
**22**   }

在这里,MB_LEN_MAX 是一个标准值,在大多数情况下是 len 的良好上限。

让我们现在转到使用 mbrtow 的容量来识别 mbc 并使用它来搜索 mbs 的函数:

mbstrings.h

mbsrwc:将 s 中的字节序列解释为 mb 字符串,并搜索宽字符 C

返回occurrence 次出现在 s 中,该位置开始一个与 C 对应的 mb 序列,或者在发生编码错误时返回 0

如果出现次数少于 occurrence,则返回最后一个此类位置。因此,特别是使用 SIZE_MAX(或 -1)将始终返回最后一个出现位置。

备注:此函数可以通过将相同的 状态 参数传递给对这一或类似函数的所有调用,并且只要搜索的延续从该函数返回的位置开始,就可以将其集成到一系列字符串搜索中。

状态 0 表示在无需考虑任何上下文的情况下,可以扫描 s

char const* mbsrwc(char const s[restrict static 1],
                   **mbstate_t***restrict state,
                   **wchar_t** C, **size_t** occurrence);
mbstrings.c
**68**   char const* mbsrwc(char const s[restrict static 1], **mbstate_t***restrict state
         ,
**69**                      **wchar_t** C, **size_t** occurrence) {
**70**     if (!C || C == **WEOF**) return 0;
**71**     if (!state) state = MBSTATE;
**72**     char const* ret = 0;
**73**
**74**     **mbstate_t** st = *state;
**75**     for (**size_t** len = 0; s[0]; s += len) {
**76**       **mbstate_t** backup = st;
**77**       **wchar_t** S = 0;
**78**       len = mbrtow(&S, s, &st);
**79**       if (!S) break;
**80**       if (C == S) {
**81**         *state = backup;
**82**         ret = s;
**83**         if (!occurrence) break;
**84**         --occurrence;
**85**       }
**86**     }
**87**     return ret;
**88**   }

正如我们所说,如果我们有一个一致的环境,所有这些多字节字符串和简单 IO 的编码都能正常工作:也就是说,如果它使用与源代码中相同的多字节编码,用于其他文本文件和终端。不幸的是,并非所有环境都使用相同的编码,因此在将文本文件(包括源文件)或可执行文件从一个环境传输到另一个环境时,您可能会遇到困难。除了大字符表的定义外,Unicode 还定义了三种现在广泛使用的编码,并希望最终取代所有其他编码:UTF-8UTF-16UTF-32,分别对应于 8 位、16 位和 32 位的 Unicode Transformation Format。自 C11 以来,C 语言包括对这些编码的基本直接支持,而无需依赖于区域设置。具有这些编码的字符串字面量可以编码为 u8"text",u"text" 和 U"text",它们分别具有类型 char[]char16_t[]char32_t[]

现代平台上的多字节编码很可能是 UTF-8,因此您不需要这些特殊字面量和类型。它们主要用于需要确保这些编码的上下文中,例如在网络通信中。在旧平台上的生活可能更困难;有关 Windows 平台的概述,请参阅 www.nubaria.com/en/blog/?p=289

14.4. 二进制流

在 第 8.3 节 中,我们简要提到了与流输入和输出也可以在 二进制 模式下进行,这与我们迄今为止使用的通常的 文本 模式不同。要了解差异,请记住,文本模式 IO 不会将我们传递给 printffputs 的字节一对一地写入目标文件或设备:

  • 根据目标平台,\n 字符可以编码为一个或多个字符。

  • 可以抑制在换行符之前的空间。

  • 多字节字符可以从执行字符集(程序的内部表示)转录到文件系统字符集。

对于从文本文件读取数据也有类似的观察。

如果我们操作的数据是有效的人类可读文本,所有这些都很好;我们可以认为自己很满意,因为 IO 函数与 setlocale 一起使这种机制尽可能透明。但如果我们对读取或写入与某些 C 对象中存在的二进制数据完全相同的数据感兴趣,这可能会相当麻烦,并导致严重困难。特别是,二进制数据可能会隐式映射到文件的换行符约定,因此写入此类数据可能会改变文件的内部结构。

如前所述,流可以以二进制模式打开。对于这种流,跳过了文件外部表示和内部表示之间的所有转换,并且这样的流中的每个字节都按原样写入或读取。从我们现在看到的接口来看,只有 fgetcfputc 可以便携地处理二进制文件。所有其他函数可能依赖于某种形式的换行符转换。

为了更容易地读取和写入二进制流,C 库有一些更适合的接口:

**1**   **size_t** **fread**(void* restrict ptr, **size_t** size, **size_t** nmemb,
**2**                **FILE*** restrict stream);
**3**   **size_t** **fwrite**(void const*restrict ptr, **size_t** size, **size_t** nmemb,
**4**                 **FILE*** restrict stream);
**5**   int **fseek**(**FILE*** stream, long int offset, int whence);
**6**   long int **ftell**(**FILE*** stream);

freadfwrite 的使用相对简单。每个流都有一个用于读取和写入的当前 文件位置。如果成功,这两个函数将从该位置开始读取或写入 size*nmemb 字节,然后更新文件位置为新值。这两个函数的返回值是已读取或写入的字节数,通常是 size*nmemb,因此如果返回值小于这个值,则表示发生了错误。

函数 ftellfseek 可以用来操作文件位置:ftell 返回从文件开始到当前位置的字节数,而 fseek 根据偏移量和 whence 参数定位文件。在这里,whence 可以有以下这些值:SEEK_SET 指的是文件开始,而 SEEK_CUR 指的是调用之前的当前文件位置。⁴

同样,也有 SEEK_END 用于文件末尾位置,但它可能存在平台定义的缺陷。

通过这四个函数,我们可以有效地在表示文件的流中前后移动,并读取或写入其任何字节。例如,这可以用来将大对象以内部表示形式写入文件,稍后用不同的程序读取,而不进行任何修改。

尽管这个接口有一些限制。为了便携性,流必须以二进制模式打开。在某些平台上,IO 总是二进制的,因为没有有效的转换要执行。所以,不幸的是,在这些平台上不使用二进制模式的程序可能运行可靠,但一旦移植到其他平台,就会失败。

摘要 14.8

在二进制模式下使用 freadfwrite 的打开流上。

由于这是与对象的内部表示一起工作的,因此它只能在具有相同表示(相同的字节序)的平台和程序执行之间移植:相同的端序。不同的平台、操作系统,甚至程序执行可以有不同的表示。

Takeaway 14.9

以二进制模式写入的文件在不同平台之间不可移植。

使用类型 long 表示文件位置限制了可以使用 ftellfseek 容易处理的文件大小,限制为 LONG_MAX 字节。在大多数现代平台上,这相当于 2GiB.^([[Exs 3]])

^([Exs 3])

编写一个名为 fseekmax 的函数,它使用 intmax_t 而不是 long,并通过组合对 fseek 的调用来实现大范围的查找值。

Takeaway 14.10

fseekftell 不适合用于非常大的文件偏移量。

14.5. 错误检查和清理

C 程序可能会遇到许多错误条件。错误可能是编程错误、编译器或操作系统软件中的错误、硬件错误,在某些情况下是资源耗尽(例如内存不足),或者这些错误的任何恶意组合。为了使程序可靠,我们必须检测这些错误条件并以优雅的方式处理它们。

作为第一个例子,考虑以下函数 fprintnumbers 的描述,它继续了我们之前在 14.1 节中讨论的函数系列:

numberline.c

fprintnumbers:在 stream 上打印一系列数字 nums,使用 printf 格式 form,由 sep 字符分隔,并以换行符结尾。

返回值:打印到 stream 的字符数,或者在出错时返回负的错误值。

如果 len0,则打印一个空行并返回 1

可能的错误返回值:

  • EOF(这是负值)如果 stream 没有准备好写入

  • -EOVERFLOW 如果需要写入超过 INT_MAX 个字符,包括 len 大于 INT_MAX 的情况。

  • -EFAULT 如果 streamnumb0

  • -ENOMEM 如果发生内存错误

这个函数将 errno 留在进入时的相同值。

  int fprintnumbers(**FILE***restrict stream,
                    char const form[restrict static 1],
                    char const sep[restrict static 1],
                    **size_t** len, **size_t** numb[restrict len]);

正如你所见,这个函数区分了四种不同的错误条件,这些条件通过返回负的常量值来指示。这些值的宏通常由平台在 errno.h 中提供,并且所有都以大写字母 E 开头。不幸的是,C 标准只提供了 EOF(这是负值)和 EDOMEILSEQERANGE,它们是正值。其他值可能提供也可能不提供。因此,在我们代码的初始部分,有一系列预处理语句为那些缺失的值提供了默认值:

<errno.h>

numberline.c
**36**   #include <limits.h>
**37**   #include <**errno**.h>
**38**   #ifndef **EFAULT**
**39**   # define **EFAULT EDOM**
**40**   #endif
**41**   #ifndef **EOVERFLOW**
**42**   # define **EOVERFLOW** (**EFAULT**-**EOF**)
**43**   # if **EOVERFLOW** > **INT_MAX**
**44**   #  error **EOVERFLOW** constant **is** too large
**45**   # endif
**46**   #endif
**47**   #ifndef **ENOMEM**
**48**   # define **ENOMEM** (**EOVERFLOW**+**EFAULT**-**EOF**)
**49**   # if **ENOMEM** > **INT_MAX**
**50**   #  error **ENOMEM** constant **is** too large
**51**   # endif
**52**   #endif

理想的情况是我们想要确保所有这些宏都有不同的值。现在函数的实现本身如下所示:

numberline.c
**169**   int fprintnumbers(**FILE***restrict stream,
**170**                     char const form[restrict static 1],
**171**                     char const sep[restrict static 1],
**172**                     **size_t** len, **size_t** nums[restrict len]) {
**173**     if (!stream)       return -**EFAULT**;
**174**     if (len && !nums)  return -**EFAULT**;
**175**     if (len > **INT_MAX**) return -**EOVERFLOW**;
**176**
**177**     **size_t** tot = (len ? len : 1)***strlen**(sep);
**178**     int err = **errno**;
**179**     char* buf = 0;
**180**
**181**     if (len) {
**182**       /* Count the chars for the numbers. */ 
**183**       for (**size_t** i = 0; i < len; ++i)
**184**         tot += **snprintf**(0, 0, form, nums[i]);
**185**       /* We return int so we have to constrain the max size. */ 
**186**       if (tot > **INT_MAX**) return error_cleanup(**EOVERFLOW**, err);
**187**     }
**188**
**189**     buf = **malloc**(tot+1);
**190**     if (!buf) return error_cleanup(**ENOMEM**, err);
**191**
**192**     sprintnumbers(tot, buf, form, sep, len, nums);
**193**     /* print whole line in one go */ 
**194**     if (**fputs**(buf, stream) == **EOF**) tot = **EOF**;
**195**     **free**(buf);
**196**     return tot;
**197**   }

错误处理几乎占用了整个函数的编码工作量。前三行处理函数进入时发生的错误,并反映了遗漏的先决条件,或者在附件 K 的语言中(见第 8.1.4 节),运行时约束违规^C*。

动态运行时错误处理稍微困难一些。特别是,C 库中的某些函数可能使用伪变量errno来传达错误条件。如果我们想捕获和修复所有错误,我们必须避免对执行的全局状态的任何更改,包括对errno的更改。这是通过在函数进入时保存当前值并在出错时通过调用小的函数 error_cleanup 来恢复它来实现的:

numberline.c
**144**   static inline int error_cleanup(int err, int prev) {
**145**     **errno** = prev;
**146**     return -err;
**147**   }

函数的核心计算在输入数组上的for循环中应该打印的总字节数。在循环体中,使用带有两个0参数的snprintf来计算每个数字的大小。然后,我们使用第 14.1 节中的函数 sprintnumbers 来生成一个长字符串,该字符串使用fputs打印。

注意,在成功调用malloc后没有错误退出。如果在调用fputs返回时检测到错误,信息将存储在变量 tot 中,但不会跳过对free的调用。因此,即使发生此类输出错误,也不会有分配的内存泄漏。在这里,处理可能的 IO 错误相对简单,因为fputs的调用接近free的调用。

函数 fprintnumbers_opt 需要更加小心:

numberline.c
**199**   int fprintnumbers_opt(**FILE***restrict stream,
**200**                     char const form[restrict static 1],
**201**                     char const sep[restrict static 1],
**202**                     **size_t** len, **size_t** nums[restrict len]) {
**203**     if (!stream)       return -**EFAULT**;
**204**     if (len && !nums)  return -**EFAULT**;
**205**     if (len > **INT_MAX**) return -**EOVERFLOW**;
**206**
**207**     int err = **errno**;
**208**     **size_t** const seplen = **strlen**(sep);
**209**
**210**     **size_t** tot = 0;
**211**     **size_t** mtot = len*(seplen+10);
**212**     char* buf = **malloc**(mtot);
**213**
**214**     if (!buf) return error_cleanup(**ENOMEM**, err);
**215**
**216**     for (**size_t** i = 0; i < len; ++i) {
**217**       tot += **sprintf**(&buf[tot], form, nums[i]);
**218**       ++i;
**219**       if (i >= len) break;
**220**       if (tot > mtot-20) {
**221**         mtot *= 2;
**222**         char* nbuf = **realloc**(buf, mtot);
**223**         if (buf) {
**224**           buf = nbuf;
**225**         } else {
**226**           tot = error_cleanup(**ENOMEM**, err);
**227**           goto **CLEANUP**;
**228**         }
**229**       }
**230**       **memcpy**(&buf[tot], sep, seplen);
**231**       tot += seplen;
**232**       if (tot > **INT_MAX**) {
**233**         tot = error_cleanup(**EOVERFLOW**, err);
**234**         goto **CLEANUP**;
**235**       }
**236**     }
**237**     buf[tot] = 0;
**238**
**239**     /* print whole line in one go */ 
**240**     if (**fputs**(buf, stream) == **EOF**) tot = **EOF**;
**241**    **CLEANUP**:
**242**     **free**(buf);
**243**     return tot;
**244**   }

它试图通过立即打印数字而不是先计算所需的字节数来进一步优化程序。这可能会遇到更多的错误条件,我们必须通过保证在最后发出对free的调用来处理它们。第一个这样的条件是我们最初分配的缓冲区太小。如果调用realloc来扩大它失败,我们必须谨慎撤退。同样,如果我们遇到不太可能的条件,即字符串的总长度超过INT_MAX,也是如此。

在这两种情况下,函数使用goto跳转到清理代码,然后调用free。在 C 语言中,这是一个成熟的技巧,确保清理发生,同时也避免了难以阅读的嵌套if-else条件。goto的规则相对简单:

摘要 14.11

标签 goto 在整个包含它们的函数中都是可见的

摘要 14.12

goto 只能跳转到同一函数内的标签

摘要 14.13

goto 不应跳过变量初始化

在编程语言中使用goto和类似跳转的方式一直备受争议,始于迪杰斯特拉的一篇文章[1968]。你仍然会找到一些人严重反对这里给出的代码,但让我们尽量务实:带或不带goto的代码可能都很丑陋且难以理解。主要思想是让函数的“正常”控制流主要不受干扰,并且用gotoreturn清楚地标记仅在异常情况下发生的控制流变化。稍后,在第 17.5 节中,我们将看到 C 语言中另一个允许对控制流进行更剧烈更改的工具:setjmp/longjmp,它使我们能够跳转到调用函数堆栈上的其他位置。

流中的文本处理

对于流中的文本处理,你能从stdin读取,将修改后的文本输出到stdout,并在stderr上报告诊断信息?计算单词列表的出现次数?计算正则表达式的出现次数?将一个单词的所有出现替换为另一个单词?

文本处理器复杂性

你能扩展你的文本处理器(挑战 12)以使用多字节字符吗?

你也能扩展它以进行正则表达式处理,例如搜索一个单词,执行一个单词对另一个单词的简单查询替换,使用正则表达式对特定单词进行查询替换,以及应用正则表达式分组?

概述

  • C 库有多个用于文本处理的接口,但我们必须注意const-资格和缓冲区溢出。

  • 使用scanf(和类似函数)进行格式化输入时,指针类型、字符串的空终止符、空白和换行分隔等问题可能很微妙。如果可能,你应该使用fgetsstrtod或类似更专业的函数的组合。

  • 扩展字符集最好通过使用多字节字符串来处理。在谨慎使用的情况下,它们可以像普通字符串一样用于输入和输出。

  • 应使用fwritefread将二进制数据写入二进制文件。这些文件是平台相关的。

  • 调用 C 库函数时应该检查错误返回值。

  • 处理错误条件可能导致复杂的案例分析。可以通过特定函数的代码块来组织,我们通过goto语句跳转到该代码块。

第 3 级. 经验

高山乌鸦生活在高海拔的稀薄空气中,并在喜马拉雅山脉 8000 米以上被观察到

在这一级,我们将更深入地探讨特定主题的细节。第一个,性能,是选择 C 语言而不是其他编程语言的主要原因之一。因此,第十五章对所有 C 软件设计师来说是必读的。

第二个主题是 C 语言特有的一个特性:函数式宏。由于它们的复杂性和明显的丑陋,其他编程社区对此非常反感。尽管如此,在一定程度上掌握它们是很重要的,因为它们允许我们提供易于使用的接口:例如,用于泛型编程和更复杂的参数检查。

第十七章和第十八章展示了如何减弱通常的顺序程序执行假设,以允许异步问题处理(使用长跳转或信号处理程序)或线程的并行执行。这些带来了与保证数据一致性相关的问题,因此我们以第十九章结束,该章更深入地探讨了原子数据和同步的一般处理。

第十五章. 性能

本章涵盖

  • 编写内联函数

  • 限制指针

  • 测量和检查性能

一旦你在 C 语言编程中感到更加自在,你可能会被诱惑去做一些复杂的事情来“优化”你的代码。无论你认为你在优化什么,你很可能做错:过早优化可能会在可读性、稳定性、可维护性等方面造成很大的损害。Knuth [1974] 提出了以下应该成为你整个这一级水平的座右铭的短语:

吸收点 D

过早优化是万恶之源

它的良好性能经常被引用为 C 语言被广泛使用的主要原因之一。虽然许多 C 程序在类似复杂性的代码中确实优于其他编程语言,但这种 C 语言的特点可能伴随着相当大的代价,尤其是在安全性方面。这是因为 C 语言在很多地方并不强制执行规则,而是将验证它们的负担放在程序员身上。这类情况的重要例子包括

  • 数组越界访问

  • 访问未初始化的对象

  • 在对象生命周期结束后访问对象

  • 整数溢出

这些可能导致程序崩溃、数据丢失、结果不正确、敏感信息泄露,甚至金钱或生命的损失。

吸收点 15.1

不要为了性能而牺牲安全

近年来,C 编译器已经变得越来越好;基本上,它们会抱怨所有在编译时可以检测到的问题。但代码中的严重问题仍然可能在试图变得聪明的情况下未被检测到。许多这些问题可以通过非常简单的方法避免,或者至少可以检测到:

  • 所有块作用域变量都应初始化,从而消除未初始化对象问题的一半。

  • 在适合的地方,应使用calloc而不是malloc进行动态分配。这避免了未初始化对象问题的另一部分。

  • 应为更复杂且动态分配的数据结构实现特定的初始化函数。这消除了未初始化对象问题的其余部分。

  • 接收指针的函数应使用数组语法并区分不同的情况:

    • 指向单个对象的指针 – 这些函数应使用static 1表示法,从而表明它们期望一个非空指针:

      void func(double a[static 1]);
      
    • 指向已知数量对象的集合的指针 – 这些函数应使用static N 表示法,从而表明它们期望一个指向至少该数量元素的指针:

      void func(double a[static 7]);
      
    • 指向未知数量对象的集合的指针 – 这些函数应使用 VLA 表示法:

      void func(**size_t** n, double a[n]);
      
    • 指向单个对象或空指针的指针 – 这样的函数必须保证即使在它接收到空指针时,执行仍然处于定义状态:

      void func(double * a);
      

      编译器构建者才开始实现对这些情况的检查,因此您的编译器可能还无法检测到这些错误。尽管如此,将这些错误记录下来并使它们对自己清晰,将有助于您避免越界错误。

  • 如果可能,应避免获取块作用域(局部)变量的地址。因此,在复杂代码中标记所有变量为register是一个好习惯。

  • 使用无符号整数类型作为循环索引,并显式处理溢出。例如,可以通过在增量操作之前将循环变量与类型的最大值进行比较来实现。

尽管一些城市传说可能暗示,但通常应用这些规则不会对您的代码性能产生负面影响。

摘要 15.2

优化器足够聪明,可以消除未使用的初始化。

摘要 15.3

函数指针参数的不同表示法会产生相同的二进制代码。

摘要 15.4

不获取局部变量的地址有助于优化器,因为它抑制了别名化。

一旦我们应用了这些规则并确保了我们的实现是安全的,我们就可以看看程序的性能了。什么构成了良好的性能以及我们如何衡量它,本身就是一个难题。关于性能的第一个问题始终是相关性:例如,将交互式程序的运行时间从 1 ms 提高到 0.9 ms 通常毫无意义,而且花费在这种改进上的任何努力可能都更适合投资在其他地方。

为了让我们具备评估性能瓶颈所必需的工具,我们将讨论如何衡量性能 (第 15.3 节)。这次讨论位于本章的末尾,因为在我们完全理解衡量性能之前,我们必须更好地理解用于提高性能的工具。

有许多情况,我们可以帮助我们的编译器(及其未来的版本)更好地优化代码,因为我们能够指定代码的某些属性,这些属性它无法自动推导。C 为此引入了一些关键字,它们在意义上非常特殊,因为它们约束的不是编译器而是程序员。它们都具有这样的属性:从有效代码中移除它们,即使它们存在,也不应该改变语义。正因为这个属性,它们有时被呈现为无用的甚至过时的特性。当你遇到这样的声明时,要小心:提出这种主张的人往往对 C、其内存模型或其优化可能性缺乏深刻的理解。特别是,他们似乎对因果关系也没有深刻的理解。

引入这些优化机会的关键字是 register(C90)、inlinerestrict(均来自 C99)和 alignas(分别对应 _Alignas,C11)。正如所示,它们都具有这样的属性:在有效的程序中,即使省略它们也不会改变其语义。

在 第 13.2 节 中,我们多少讨论了 register,所以我们将不会深入探讨。只需记住,它可以帮助避免在函数中局部定义的对象之间的别名。正如那里所述,我认为这是 C 社区中一个被大大低估的特性。我甚至向 C 委员会(Gustedt [2016]) 提出了关于如何使这个特性成为 C 未来改进核心的想法,这将包括任何对象类型的全局常量以及更多针对小型纯函数的优化机会。

在 第 12.7 节 中,我们也讨论了 C11 的 alignas 和相关的 alignof。它们可以帮助将对象定位在缓存边界上,从而提高内存访问。我们不会深入探讨这个专门特性。

剩下的两个特性,C99 的inline (第 15.1 节) 和 restrict (第 15.2 节),具有非常不同的可用性。第一个相对容易使用,没有危险。这是一个相当广泛使用的工具,可以确保短函数的代码可以直接集成并在函数的调用方进行优化。

后者,restrict,放宽了基于类型的别名考虑,以允许更好的优化。因此,它的使用很微妙,如果使用不当可能会造成相当大的损害。它通常出现在库接口中,但在用户代码中则很少见。

本章的剩余部分(第 15.3 节)深入探讨了性能测量和代码检查,使我们能够单独评估性能以及导致性能好或坏的原因。

15.1. 内联函数

对于 C 程序来说,编写模块化代码的标准工具是函数。正如我们所见,它们有几个优点:

  • 它们清楚地分离了接口和实现。因此,它们允许我们逐步改进代码,从修订到修订,或者如果认为有必要,从头开始重写功能。

  • 如果我们避免通过全局变量与代码的其他部分进行通信,我们就能确保函数访问的状态是局部的。这样,状态就只存在于调用的参数和局部变量中。因此,可以更容易地检测到优化机会。

不幸的是,从性能的角度来看,函数也有一些缺点:

  • 即使在现代平台上,函数调用也有一定的开销。通常,在调用函数时,会留出一些栈空间,并初始化或复制局部变量。控制流跳转到可执行文件中的不同点,这可能在或不在执行缓存中。

  • 根据平台的调用约定,如果函数的返回值是一个struct,整个返回值可能必须复制到函数调用者期望结果的地方。

如果,出于巧合,调用者(例如,fcaller)和被调用者(例如,fsmall)的代码都位于同一个翻译单元(TU)内,一个好的编译器可以通过内联来避免这些缺点。在这里,编译器做的是用 fsmall 本身的代码替换对 fsmall 的调用。这样就没有调用,因此也就没有调用开销。

更好的是,由于 fsmall 的代码现在被内联了,fsmall 的所有指令都可以在这个新的上下文中看到。编译器可以检测到,例如,

  • 永远不会执行的死分支

  • 重复计算已知结果的表达式

  • 函数(如调用)可能只能返回某种类型的值

15.5 节要点

内联可以打开许多优化机会

传统的 C 编译器只能内联它也了解定义的函数:只知道声明是不够的。因此,程序员和编译器构建者研究了通过使函数定义可见来增加内联的可能性。如果没有语言提供的额外支持,有两种策略可以实现这一点:

  • 将一个项目的所有代码连接成一个单独的大文件,然后在一个巨大的 TU 中编译所有这些代码。系统地做这样的事情并不像听起来那么简单:我们必须确保源文件的连接顺序不会产生定义循环,并且我们不会遇到命名冲突(例如,两个 TU,每个都有一个static函数 init)。

  • 应该内联的函数被放置在头文件中,然后由所有需要它们的 TU 包含。为了避免在每个 TU 中函数符号的多重定义,这些函数必须被声明为static

当第一种方法对于大型项目不可行时,第二种方法相对容易实施。尽管如此,它也有缺点:

  • 如果函数太大,编译器无法内联,它将在每个 TU 中单独实例化。也就是说,如此大的函数可能会有很多副本,从而增加最终可执行文件的大小。

  • 获取此类函数的指针将给出当前 TU 中特定实例的地址。在不同 TU 中获得的此类指针的比较不会视为相等。

  • 如果在头文件中声明的此类static函数在 TU 中没有使用,编译器通常会警告这种未使用的情况。因此,如果我们头文件中有许多这样的小函数,我们会看到很多警告,产生很多误报。

为了避免这些缺点,C99 引入了inline关键字。与名称可能暗示的相反,这并不强制函数内联,而只是提供了一种它可能内联的方式。

  • 声明为inline的函数定义可以在多个 TU 中使用,而不会引起多重符号定义错误。

  • 所有指向同一inline函数的指针都将视为相等,即使它们是在不同的 TU 中获得的。

  • 在特定 TU 中未使用的inline函数将完全从该 TU 的二进制文件中消失,并且特别是不会对其大小做出贡献。

后者通常是一个优点,但它有一个简单的问题:即使对于可能需要此类符号的程序,也不会发出函数的任何符号。有几个常见的情况需要符号:

  • 程序直接使用或存储函数的指针。

  • 编译器决定该函数太大或太复杂,无法内联。这种情况因多种因素而异,具体如下:

    • 用于编译的优化级别

    • 调试选项是开启还是关闭

    • 函数本身使用某些 C 库函数

  • 该函数是包含在库中的,该库与未知程序一起分发和链接。

为了提供这样的符号,C99 为inline函数引入了一条特殊的规则。

Takeaway 15.6

添加一个不带inline关键字的兼容声明确保了函数符号在当前 TU 中的发出。

例如,假设我们在一个头文件中有一个inline函数,比如say toto.h`:

 **1**   // Inline definition in a header file.
 **2**   // Function argument names and local variables are visible
 **3**   // to the preprocessor and must be handled with care.
 **4**   inline 
 **5**   toto* toto_init(toto* toto_x) {
 **6**     if (toto_x) {
 **7**       *toto_x = (toto){ 0 };
 **8**     }
 **9**     return  toto_x;
**10**   }

这样的函数是内联的完美候选者。它真的非常小,任何类型为 toto 的变量的初始化可能最好是在原地完成。调用开销与函数的内部部分相同,在许多情况下,函数的调用者甚至可以省略对if的测试。

Takeaway 15.7

inline函数定义在所有 TUs 中都是可见的。

此函数可能会被编译器在内所有看到此代码的 TUs 中内联,但它们中没有一个会有效地发出 toto_init 符号。但我们可以(并且应该)通过添加如下一行代码,在其中一个 TU,例如toto.c中强制发出:

**1**   #include "toto.h"
**2**
**3**   // Instantiate in exactly one TU.
**4**   // The parameter name is omitted to avoid macro replacement.
**5**   toto* toto_init(toto*);
Takeaway 15.8

inline定义放在头文件中

Takeaway 15.9

额外的声明不包含inline的,将正好放入一个 TU 中。

正如我们所言,inline函数的机制是为了帮助编译器做出决定是否有效地内联一个函数。在大多数情况下,编译器构建者为实现这一决定而实施的启发式方法是完全合适的,你无法做得更好。他们对你正在编译的平台了解得比你更好:也许当你编写代码时,这个平台甚至还不存在。因此,他们在比较不同可能性之间的权衡方面处于更好的位置。

一组可能从inline定义中受益的重要函数是纯函数,我们在第 10.2.2 节中遇到过。如果我们看一下鼠结构体的示例(列表 10.1),我们会看到所有这些函数隐式地复制函数参数和返回值。如果我们将这些函数作为inline在头文件中重写,所有这些复制都可以通过优化编译器避免。^([[Exs 1]]) ^([[Exs 2]])

^([Exs 1])

将第 10.2.2 节的示例重写为inline

^([Exs 2])

回顾第七部分中的函数示例,并为每个示例争论它们是否应该定义为inline

因此,inline函数可以成为构建可移植代码的宝贵工具,该代码表现出良好的性能;我们只是帮助编译器(们)做出适当的决定。不幸的是,使用inline函数也有缺点,这些缺点应该在我们的设计中加以考虑。

首先,15.7 意味着你对inline函数所做的任何更改都将触发你的项目及其所有用户的完整重建。

Takeaway 15.10

Only expose functions as inlineif you consider them to be stable.

第二,函数定义的全局可见性也有这样的效果,即函数的局部标识符(参数或局部变量)可能受到我们甚至不知道的宏的宏展开的影响。在示例中,我们使用了 toto_ 前缀来保护函数参数免受其他包含文件中宏的展开。

Takeaway 15.11

All identifiers that are local to an inlinefunction should be protected by a convenient naming convention.

第三,除了传统的函数定义之外,inline 函数没有特定的 TU 与之关联。而一个传统的函数可以访问 TU 本地(static 变量和函数)的状态和函数,对于一个 inline 函数,则不清楚这些引用指的是哪个 TU 的哪个副本。

Takeaway 15.12

inlinefunctions can’t access identifiers of staticfunctions.

Takeaway 15.13

inlinefunctions can’t define or access identifiers of modifiable static objects.

这里,重点是访问限制在 标识符 上,而不是对象或函数本身。将指向 static 对象的指针或函数传递给 inline 函数没有问题。

15.2. 使用 restrict 指定符

我们已经看到了许多使用关键字 restrict 来指定指针的 C 库函数的例子,我们也为我们的函数使用了这种指定。restrict 的基本思想相对简单:它告诉编译器,所讨论的指针是它指向的对象的唯一访问方式。因此,编译器可以做出假设,对象的变化只能通过相同的指针发生,对象不能意外地改变。换句话说,使用 restrict,我们是在告诉编译器,该对象不与编译器在此代码部分处理的任何其他对象别名。

Takeaway 15.14

A restrict-qualified pointer has to provide exclusive access.

在 C 中,这种情况很常见,这样的声明将验证此属性的负担放在了调用者身上。

Takeaway 15.15

A restrict-qualification constrains the caller of a function.

例如,考虑 memcpymemmove 之间的差异:

**1**   void* **memcpy**(void*restrict s1, void const*restrict s2,  **size_t** n);
**2**   void* **memmove**(void* s1, const void* s2, **size_t** n);

对于 memcpy,两个指针都是 restrict-qualified。因此,为了执行此函数,必须通过两个指针进行独占访问。不仅如此,s1 和 s2 必须有不同的值,并且它们都不能提供对另一个对象部分的访问。换句话说,通过两个指针 memcpy “看到”的两个对象不能重叠。假设这有助于优化函数。

相反,memmove 并不做出这样的假设。因此,s1 和 s2 可能相等,或者对象可能重叠。函数必须能够处理这种情况。因此,它可能效率较低,但更通用。

我们在 第 12.3 节 中看到,编译器决定两个指针是否实际上指向同一对象(关联)可能很重要。不同基类型的指针不应该关联,除非其中一个是字符类型。因此,fputs 的两个参数都声明为 restrict

**1**   int **fputs**(const char *restrict s, **FILE** *restrict stream);

虽然看起来不太可能有人会使用相同的指针值调用 fputs 的两个参数。

这种规范对于像 printf 和朋友这样的函数更重要:

**1**   int **printf**(const char *restrict format, ...);
**2**   int **fprintf**(**FILE** *restrict stream, const char *restrict format, ...);

格式参数不应与传递给 ... 部分的任何参数发生关联。例如,以下代码具有未定义的行为:

**1**   char const* format = "format printing itself: %s\n";
**2**   **printf**(format, format);   // Restrict violation

这个例子可能仍然会做你想象中的事情。如果你滥用流参数,你的程序可能会崩溃:

**1**   char const* format = "First two bytes in stdin object: %.2s\n";
**2**   char const* bytes = (char*)**stdin**; // Legal cast to char
**3**   **fprintf**(**stdin**, format, bytes); // Restrict violation

当然,这样的代码在现实生活中不太可能发生。但请记住,字符类型有关联的特殊规则,因此所有字符串处理函数都可能受到未优化的影响。你可以在许多涉及字符串参数的地方添加 restrict-限定符,并且你知道它们仅通过相关指针访问。

15.3. 测量和检查

我们多次讨论了程序的性能,但还没有讨论评估它的方法。确实,我们人类在预测代码性能方面臭名昭著。因此,关于性能的问题,我们的首要指令应该是:

吸收点 E

不要对代码的性能进行推测;要严格验证。

当我们深入一个可能对性能至关重要的代码项目时,第一步始终是选择解决当前问题的最佳算法。这应该在编码开始之前完成,因此我们必须通过争论(但不是推测!)这种算法的行为来进行第一次复杂度评估。

吸收点 15.16

算法的复杂度评估需要证明。

不幸的是,关于复杂度证明的讨论远远超出了本书的范围,所以我们无法深入探讨。但幸运的是,已经有许多其他书籍对此进行了讨论。感兴趣的读者可以参考 Cormen 等人的教科书 [2001] 或 Knuth 的宝藏。

吸收点 15.17

代码的性能评估需要测量。

实验科学中的测量是一个困难的话题,显然我们在这里不能详细讨论。但我们应该首先意识到,测量的行为会改变被观察到的对象。这在物理学中成立,因为测量物体的质量必然会使物体移动;在生物学中成立,因为收集物种样本实际上会杀死动物或植物;在社会学中成立,因为在测试之前询问性别或移民背景会改变测试对象的行为。不出所料,在计算机科学中也是如此,特别是在时间测量方面,因为所有这些时间测量都需要时间来完成。

取舍 15.18

所有测量都会引入偏差

最坏的情况下,时间测量的影响可能超过进行测量的额外时间。首先,例如对timespec_get的调用是一个对函数的调用,如果没有测量,这个函数就不会存在。编译器在执行此类调用之前必须采取一些预防措施,特别是保存硬件寄存器,并且必须放弃一些关于执行状态的假设。因此,时间测量可能会抑制优化机会。此外,此类函数调用通常转换为系统调用(对操作系统的调用),这可能会影响程序执行的许多属性,例如进程或任务调度,或者使数据缓存失效。

取舍 15.19

仪器改变编译时间和运行时属性

实验科学的艺术在于解决这些问题,并确保测量引入的偏差很小,从而使实验结果可以定性评估。具体来说,在我们能够对我们感兴趣的代码进行任何时间测量之前,我们必须评估时间测量本身引入的偏差。减少测量偏差的一般策略是重复进行实验几次,并收集关于结果的数据统计。在此背景下最常用的统计数据很简单。它们涉及实验的数量和它们的平均值(或平均数),以及它们的方差和有时它们的偏度。

让我们看看以下样本 S,它由 20 个时间测量组成,单位为秒 s

  • 0.7, 1.0, 1.2, 0.6, 1.3, 0.1, 0.8, 0.3, 0.4, 0.9, 0.5, 0.2, 0.6, 0.4, 0.4, 0.5, 0.5, 0.4, 0.6, 0.6

见图 15.1 中的该样本的频率直方图。这些值显示出相当大的变化

图 15.1. 我们样本的频率直方图,显示了每个测量值被获得频率

图片

大约在0.6(μ(S), 平均值),从0.1(最小值)到1.3(最大值)。事实上,这种变化非常重要,以至于我个人不敢断言关于这样一个样本的相关性。这些虚构的测量是糟糕的,但它们有多糟糕?

标准差 σ(S)衡量(再次,以秒为单位)观察到的样本与所有时间都恰好有相同结果的理想世界之间的偏差。标准差小表示我们观察到的现象很可能遵循那个理想。相反,如果标准差太高,现象可能不具有那个理想属性(有东西干扰了我们的计算),或者我们的测量可能不可靠(有东西干扰了我们的测量),或者两者兼而有之。

对于我们的例子,标准差是 0.31,与均值 0.6 相比是相当大的:这里的相对标准差 σ(S)/μ(S) 是 0.52(或 52%)。只有低百分比范围内的值才能被认为是好的

取得成果 15.20

运行时间的相对标准偏差必须在低百分比范围内。

我们可能感兴趣的最后一个统计量是偏度(对于我们的样本 S0.79)。它衡量样本的倾斜度(或不对称性)。一个围绕均值对称分布的样本将有一个偏度为 0,正值表示存在向右的“尾巴”。时间测量通常不是对称的。我们很容易在我们的样本中看到这一点:最大值 1.3 距离均值 0.7。因此,为了使样本围绕 0.6 的均值对称,我们需要一个 -0.1 的值,这是不可能的。

如果你对这些非常基本的统计概念不熟悉,你可能现在需要重新审视它们一下。在本章中,我们将看到所有这些我们感兴趣的统计量都可以用原点矩来计算:

图片

因此,零阶原点矩计算样本数量,第一阶是所有值的总和,第二阶是值的平方和,依此类推。

对于计算机科学,通过将待测量的代码放入for循环中,并在该循环前后放置测量,可以很容易地通过重复实验来自动化。因此,我们可以执行样本代码成千上万次,并计算循环迭代的平均时间。然后,我们希望时间测量可以忽略,因为实验的整体时间可能只有几秒钟,而时间测量本身可能只需要几毫秒。

在本章的示例代码中,我们将尝试评估对 timespec_get 的调用性能,以及收集测量统计信息的小工具的性能。列表 15.1 包含了我们想要调查的不同代码版本的几个 for 循环。时间测量收集在统计信息中,并使用从 timespec_get 获得的 tv_nsec 值。在这种方法中,我们引入的实验偏差是明显的:我们使用对 timespec_get 的调用来测量其自身的性能。但这种偏差很容易掌握:增加迭代次数可以减少偏差。我们在这里报告的实验是在迭代次数为 2²⁴ – 1 的值下进行的。

列表 15.1. 重复测量几个代码片段
**53**      **timespec_get**(&t[0], **TIME_UTC**);
**54**      /* Volatile for i ensures that the loop is effected */
**55**      for (**uint64_t** volatile i = 0; i < iterations; ++i) {
**56**        /* do nothing */
**57**      }
**58**      **timespec_get**(&t[1], **TIME_UTC**);
**59**      /* s must be volatile to ensure that the loop is effected */ 
**60**      for (**uint64_t** i = 0; i < iterations; ++i) {
**61**        s = i;
**62**      }
**63**      **timespec_get**(&t[2], **TIME_UTC**);
**64**      /* Opaque computation ensures that the loop is effected */
**65**      for (**uint64_t** i = 1; accu0 < upper; i += 2) {
**66**        accu0 += i;
**67**      }
**68**      **timespec_get**(&t[3], **TIME_UTC**);
**69**      /* A function call can usually not be optimized out. */
**70**      for (**uint64_t** i = 0; i < iterations; ++i) {
**71**        **timespec_get**(&tdummy, **TIME_UTC**);
**72**        accu1 += tdummy.**tv_nsec**;
**73**      }
**74**      **timespec_get**(&t[4], **TIME_UTC**);
**75**      /* A function call can usually not be optimized out, but
**76**         an inline function can. */
**77**      for (**uint64_t** i = 0; i < iterations; ++i) {
**78**        **timespec_get**(&tdummy, **TIME_UTC**);
**79**        stats_collect1(&sdummy[1], tdummy.**tv_nsec**);
**80**      }
**81**      **timespec_get**(&t[5], **TIME_UTC**);
**82**      for (**uint64_t** i = 0; i < iterations; ++i) {
**83**        **timespec_get**(&tdummy, **TIME_UTC**);
**84**        stats_collect2(&sdummy[2], tdummy.**tv_nsec**);
**85**      }
**86**      **timespec_get**(&t[6], **TIME_UTC**);
**87**      for (**uint64_t** i = 0; i < iterations; ++i) {
**88**        **timespec_get**(&tdummy, **TIME_UTC**);
**89**        stats_collect3(&sdummy[3], tdummy.**tv_nsec**);
**90**      }
**91**      **timespec_get**(&t[7], **TIME_UTC**);

但这个主要平凡的观察并不是目标;它只是作为我们想要测量的某些代码的例子。列表 15.1 中的 for 循环包含进行更复杂统计收集的代码。目标是能够逐步断言这种不断增加的复杂性如何影响计时。

timespec.c
struct **timespec** tdummy;
stats sdummy[4] = { 0 };

从第 70 行开始的循环只是累积值,因此我们可以确定它们的平均值。下一个循环(第 77 行)使用 stats_collect1 函数,该函数维护一个 运行平均值:即,它通过修改前一个平均值 δ(x**[n], μ[n–1]) 来计算一个新的平均值 μ[n],其中 x**[n] 是新的测量值,μ[n–1] 是前一个平均值。其他两个循环(第 82 行和第 87 行)分别使用 stats_collect2 和 stats_collect3 函数,它们分别使用类似公式来计算 第二第三 阶矩,从而计算方差和偏度。我们将在稍后讨论这些函数。

但首先,让我们看看我们用于代码仪表化的工具。

列表 15.2. 使用 timespec_diff 和 stats_collect2 收集时间统计信息
**102**      for (unsigned i = 0; i < loops; i++) {
**103**        double diff = timespec_diff(&t[i+1], &t[i]);
**104**         stats_collect2(&statistic[i], diff);
**105**      }

我们使用 第 11.2 节 中的 timespec_diff 来计算两次测量之间的时间差,并使用 stats_collect2 来汇总统计信息。然后,整个操作被另一个循环(未显示)包裹,该循环重复该实验 10 次。完成该循环后,我们使用 stats 类型的函数来打印结果。

列表 15.3. 使用 stats_mean 和 stats_rsdev_unbiased 打印时间统计信息
**109**     for (unsigned i = 0; i < loops; i++) {
**110**       double mean = stats_mean(&statistic[i]);
**111**       double rsdev  = stats_rsdev_unbiased(&statistic[i]);
**112**       **printf**("loop %u: E(t) (sec):\t%5.2e ± %4.02f%%,\tloop body %5.2e\n",
**113**              i, mean, 100.0*rsdev, mean/iterations);
**114**     }

显然,stats_mean 提供了对测量平均值访问的途径。函数 stats_rsdev_unbiased 返回 无偏相对标准差:即,一个无偏且与平均值归一化的标准差。

¹

这样,它是对预期时间标准差的真正估计,而不仅仅是我们的任意样本。

在我的笔记本电脑上,典型的输出如下所示:

终端
**0**     loop 0: E(t) (sec): 3.31e-02 ± 7.30%,  loop body 1.97e-09
**1**     loop 1: E(t) (sec): 6.15e-03 ± 12.42%, loop body 3.66e-10
**2**     loop 2: E(t) (sec): 5.78e-03 ± 10.71%, loop body 3.45e-10
**3**     loop 3: E(t) (sec): 2.98e-01 ± 0.85%,  loop body 1.77e-08
**4**     loop 4: E(t) (sec): 4.40e-01 ± 0.15%,  loop body 2.62e-08
**5**     loop 5: E(t) (sec): 4.86e-01 ± 0.17%,  loop body 2.90e-08
**6**     loop 6: E(t) (sec): 5.32e-01 ± 0.13%,  loop body 3.17e-08

在这里,行 0、1 和 2 对应于我们尚未讨论的循环,而行 3 到 6 对应于我们已讨论的循环。它们的相对标准偏差小于 1%,因此我们可以断言我们有一个好的统计量,并且右侧的时间是每次迭代的成本的较好估计。例如,在我的 2.1 GHz 笔记本电脑上,这意味着循环 3、4、5 或 6 的一次迭代执行分别需要大约 36、55、61 和 67 个时钟周期。因此,将简单的求和替换为 stats_collect1 的额外成本是 19 个周期,从那里到 stats_collect2 是 6 个周期,如果我们使用 stats_collect3,还需要额外的 6 个周期。

为了证明这是合理的,让我们看看 stats 类型:

**1**   typedef struct stats stats;
**2**   struct stats {
**3**     double moment[4];
**4**   };

在这里,我们为所有统计 预留一个 double。以下列表中的 stats_collect 函数展示了当我们收集一个新值并将其插入时,这些值是如何更新的。

列表 15.4. 收集到三阶矩的统计信息
**120**   /**
**121**    ** @brief Add value @a val to the statistic @a c.
**122**    **/
**123**   inline
**124**   void stats_collect(stats* c, double val, unsigned moments) {
**125**     double n  = stats_samples(c);
**126**     double n0 = n-1;
**127**     double n1 = n+1;
**128**     double delta0 = 1;
**129**     double delta  = val - stats_mean(c);
**130**     double delta1 = delta/n1;
**131**     double delta2 = delta1*delta*n;
**132**     switch (moments) {
**133**     default:
**134**       c->moment[3] += (delta2*n0 - 3*c->moment[2])*delta1;
**135**     case 2:
**136**       c->moment[2] += delta2;
**137**     case 1:
**138**       c->moment[1] += delta1;
**139**     case 0:
**140**       c->moment[0] += delta0;
**141**     }
**142**   }

如前所述,我们看到这是一个相对简单的算法,可以增量地更新矩。与原始方法相比,重要特性是我们通过使用当前均值估计的差异来避免数值不精确,并且可以不存储所有样本来完成此操作。这种方法最初由 Welford [1962] 描述,后来推广到更高阶矩;参见 Pébay [2008]。实际上,我们的 stats_collect1 等函数只是对该选择的矩数的一个实例化。

stats.h
**154**   inline
**155**   void stats_collect2(stats* c, double val) {
**156**      stats_collect(c, val, 2);
**157**   }

stats_collect2 的汇编列表显示,我们使用 25 个周期来执行此函数的发现似乎是合理的。它对应于一小部分算术指令、加载和存储操作.^([2])

²

此汇编器显示了 x86_64 汇编器的一些我们尚未见过的特性:浮点硬件寄存器和指令,以及 SSE 寄存器和指令。在这里,内存位置 (%****rdi)8(%****rdi)16(%****rdi) 对应于 c->moment[i],其中 i = 0、1、2,指令名称减去 v-前缀;sd-后缀显示执行的操作;vfmadd213sd 是一个浮点乘加指令。

列表 15.5. GCC 的 stats_collect2(c) 汇编器
      vmovsd 8(%**rdi**), %xmm1
      vmovsd (%**rdi**), %xmm2
      vaddsd .LC2(%**rip**), %xmm2, %xmm3
      vsubsd %xmm1, %xmm0, %xmm0
      vmovsd %xmm3, (%**rdi**) 
      vdivsd %xmm3, %xmm0, %xmm4
      vmulsd %xmm4, %xmm0, %xmm0
      vaddsd %xmm4, %xmm1, %xmm1
      vfmadd213sd 16(%**rdi**), %xmm2, %xmm0
      vmovsd %xmm1, 8(%**rdi**)
      vmovsd %xmm0, 16(%**rdi**)

现在,通过使用示例测量,我们仍然犯了一个系统性错误。我们将测量点放在 for 循环之外。这样做,我们的测量也形成了对应于循环本身的指令。列表 15.6 显示了我们之前讨论中跳过的三个循环。这些基本上是空的,试图测量这种循环的贡献。

列表 15.6. 使用 struct timespec 仪器化三个 for 循环
**53**      **timespec_get**(&t[0], **TIME_UTC**);
**54**      /* Volatile for i ensures that the loop is effected */
**55**      for (**uint64_t** volatile i = 0; i < iterations; ++i) {
**56**        /* do nothing */
**57**      }
**58**      **timespec_get**(&t[1], **TIME_UTC**);
**59**      /* s must be volatile to ensure that the loop is effected */
**60**      for (**uint64_t** i = 0; i < iterations; ++i) {
**61**        s = i;
**62**      }
**63**      **timespec_get**(&t[2], **TIME_UTC**);
**64**      /* Opaque computation ensures that the loop is effected */
**65**      for (**uint64_t** i = 1; accu0 < upper; i += 2) {
**66**        accu0 += i;
**67**      }
**68**      **timespec_get**(&t[3], **TIME_UTC**);

事实上,当我们试图测量没有内部语句的 for 循环时,我们面临一个严重的问题:一个没有效果的空循环可以在编译时被优化器消除。在正常的生产条件下,这是一件好事;但在这里,当我们想要测量时,这很烦人。因此,我们展示了三种不应该被优化掉的循环变体。第一个将循环变量声明为 volatile,这样所有对该变量的操作都必须由编译器发出。列表 15.7 和 15.8 展示了 GCC 和 Clang 的这个循环版本。我们看到,为了符合循环变量的 volatile 特性,两者都必须发出多个加载和存储指令。

列表 15.7. GCC 版本的 列表 15.6 中的第一个循环
.L510:
        movq 24(%**rsp**), %rax
        addq $1, %rax
        movq %rax, 24(%**rsp**)
        movq 24(%**rsp**), %rax
        cmpq %rax, %r12
        ja .L510
列表 15.8. Clang 版本的 列表 15.6 中的第一个循环
.LBB9_17:
        incq 24(%**rsp**)
        movq 24(%**rsp**), %rax
        cmpq %r14, %rax
        jb .LBB9_17

对于下一个循环,我们试图更加节省,只强制将一个 volatile 存储到一个辅助变量 s。正如我们在 列表 15.9 中可以看到的,结果是看起来相当高效的汇编代码:它由四个指令组成,一个加法、一个比较、一个跳转和一个存储。

列表 15.9. GCC 版本的 列表 15.6 中的第二个循环
.L509:
        movq %rax, s(%**rip**)
        addq $1, %rax
        cmpq %rax, %r12
        jne .L509

为了更接近真实测量的循环,在下一个循环中我们使用了一个技巧:我们执行索引计算和比较,其结果应该是对编译器透明的。列表 15.10 显示,这导致汇编代码类似于之前,但现在我们有一个额外的加法操作而不是存储操作。

列表 15.10. GCC 版本的 列表 15.6 中的第三个循环
.L500:
        addq %rax, %rbx
        addq $2, %rax
        cmpq %rbx, %r13
        ja .L500

表 15.1 总结了我们在本节收集的结果,并比较了各种测量的差异。正如我们所预期的,我们看到使用 volatile 存储的循环 1 比使用 volatile 循环计数器的循环快 80%。因此,使用 volatile 循环计数器不是一个好主意,因为它可能会降低测量的准确性。

另一方面,从循环 1 转到循环 2 的影响并不明显。我们看到的 6% 的提升小于测试的标准差,所以我们甚至不能确定是否有提升。如果我们真的想了解是否存在差异,我们就需要进行更多的测试,并希望标准差能够缩小。

但为了评估我们观察的时间影响,测量结果非常明确。for 循环的版本 1 和 2 的影响大约比调用 timespec_get 或 stats_collect 的影响低一到两个数量级。因此,我们可以假设对于循环 3 到 6 的值是测量函数预期时间的良好估计。

这些测量中存在一个强烈的平台依赖性成分:使用 timespec_get 进行时间测量。实际上,我们从这次经验中了解到,在我的机器上,时间测量和统计收集的成本具有相同的数量级。对我个人来说,这是一个令人惊讶的发现:当我写这一章时,我以为时间测量会花费更多。

³

2016 年配备最新系统和现代编译器的普通 Linux 笔记本电脑。

我们还了解到,像标准差这样的简单统计很容易获得,并且可以帮助我们断言性能差异。

取走 15.21

收集测量值的高阶矩以计算方差和偏度既简单又便宜。

因此,无论你将来在性能方面提出何种主张,或者看到他人提出此类主张,都要确保结果的可变性至少已经得到解决。

取走 15.22

运行时测量必须用统计方法加固。

摘要

  • 性能不应以正确性为代价。

  • inline 是优化小型、纯函数的合适工具。

    表 15.1. 测量值比较
    循环 每次迭代的秒数 差异 增减 结论性
    0 volatile 循环 1.97 10^(–09)
    1 volatile 存储 3.66 10^(–10) -1.60 10 ^(–09) -81%
    2 不透明加法 3.45 10^(–10) -2.10 10^(–11) -6%
    3 timespec_get 1.77 10^(–08) 1.74 10^(–08) +5043%
    4 加均值 2.62 10^(–08) 8.5 10^(–09) +48%
    5 加方差 2.90 10^(–08) 2.8 10^(–09) +11%
    6 加偏度 3.17 10^(–08) 2.7 10^(–09) +9%
  • restrict 有助于处理函数参数的别名属性。它必须谨慎使用,因为它对函数的调用方施加了可能在编译时无法强制执行的约束。

  • 性能改进的主张必须伴随着彻底的测量和统计。

第十六章. 函数式宏

本章涵盖

  • 检查参数

  • 访问调用上下文

  • 与可变参数宏一起工作

  • 类型通用编程

我们在 第 10.2.1 节 明确遇到了 函数式 宏,也隐式地遇到了。C 标准库中的某些接口通常通过使用它们来实现,例如 tgmath.h 中的类型通用接口。我们还看到,函数式宏可以轻易地使我们的代码变得晦涩,并需要一套特定的限制性规则。避免函数式宏带来的许多问题的最简单策略是仅在它们不可替代的地方使用它们,并在它们可替代的地方使用适当的手段。

<tgmath.h>

取走 16.1

尽可能,优先选择函数式宏而不是函数。

即,在具有已知类型的固定数量参数的情况下,我们应该以函数原型形式提供适当的安全类型接口。让我们假设我们有一个简单的具有副作用的功能:

unsigned count(void) {
  static counter = 0;
  ++counter;
  return counter;
}

现在考虑这个函数与宏一起使用来平方一个值:

#define square_macro(X) (X*X)   // Bad: do not use this.
...
  unsigned a = count();
  unsigned b = square_macro(count());

在这里,square_macro(count())的使用被替换为 count()*count(),两次执行 count:^([[Exs 1]])。这可能是初学者在那个时刻所期望的。

^([Exs 1])

证明 b == a*a + 3*a + 2

为了达到与函数式宏相同的表现,在头文件中提供一个inline定义就完全足够了:

inline unsigned square_unsigned(unsigned x) {  // Good 
  return x*x;
}
...
  unsigned c = count();
  unsigned d = square_unsigned(count());

这里,square_unsigned(count())导致 count 只执行一次.^([[Exs 2]])

^([Exs 2])

证明 d == c*c + 2*c + 1

但有许多情况下,函数式宏可以做得比函数更多。它们可以

  • 强制类型映射和参数检查

  • 跟踪执行

  • 提供具有可变参数数量的接口

  • 提供类型通用的接口

  • 为函数提供默认参数

在本章中,我将尝试解释如何实现这些功能。我们还将讨论 C 的另外两个明显可区分的特性:一个是_Generic,因为它在宏中很有用,没有它们使用起来会非常繁琐;另一个是可变参数函数,因为它们现在大多已经过时,不应该在新代码中使用。

本章的一个警告是,宏编程很快就会变得丑陋且几乎无法阅读,所以你需要耐心和善意来理解这里的一些代码。让我们举一个例子:

#define MINSIZE(X, Y) (sizeof(X)<sizeof(Y) ? sizeof(X) :sizeof(Y))

右侧,替换字符串,相当复杂。它有四个sizeof评估和一些组合它们的运算符。但这个宏的使用不应该很难:它只是计算参数的最小大小。

16.2 节要点

功能宏应提供一个简单的接口来执行复杂任务。

16.1. 函数式宏的工作原理

为了提供我们列出的特性,C 选择了与其他流行编程语言截然不同的路径:文本替换。正如我们所见,宏在编译的早期阶段被替换,称为预处理器处理。这种替换遵循 C 标准中指定的一系列严格规则,并且所有编译器(在同一平台上)都应该将任何源代码预处理成完全相同的中间代码。

让我们将以下内容添加到我们的示例中:

#define BYTECOPY(T, S) **memcpy**(&(T), &(S), MINSIZE(T, S))

现在我们有两个宏定义:MINSIZE 和 BYTECOPY。第一个有一个参数列表 (X, Y),它定义了两个参数 X 和 Y,以及替换文本

(sizeof(X)<sizeof(Y) ? sizeof(X) : sizeof(X))

指向 X 和 Y。同样,BYTECOPY 也有两个参数 T 和 S,替换文本从memcpy开始。

这些宏满足了我们对函数式宏的要求:它们只对每个参数评估一次,^([[Exs 3]]) 使用()括号包围所有参数,并且没有隐藏的影响,如意外的控制流。宏的参数必须是标识符。一个特殊的范围规则限制了这些标识符在替换文本中的有效性。

^([Exs 3])

为什么会这样?

当编译器遇到一个函数宏的名称后跟一个闭括号对,例如在BYTECOPY(A, B)中,它认为这是一个宏调用,并根据以下规则进行文本替换:

  1. 为了避免无限递归,宏的定义暂时被禁用。

  2. ()内的文本,即参数列表,会被扫描以查找括号和逗号。每个开括号(必须与一个闭括号)匹配。不在这样的额外()内的逗号用于将参数列表分隔成各个参数。对于这里处理的情形,参数的数量必须与宏定义中参数的数量相匹配。

  3. 每个参数都会递归展开,以处理可能出现在其中的宏。在我们的例子中,A 可能又是另一个宏,展开为某个变量名,例如 redA。

  4. 参数展开的结果文本片段被分配给参数。

  5. 替换文本的副本被创建,并将所有参数替换为它们的相应定义。

  6. 结果替换文本再次受到宏替换的影响。

  7. 这个最终的替换文本被插入到源代码中,代替宏调用。

  8. 宏的定义被重新启用。

这个过程乍一看可能有些复杂,但实际上很容易实现,并提供了一个可靠的替换序列。它保证了避免无限递归和复杂的局部变量赋值。在我们的例子中,BYTECOPY(A, B)的展开结果将是

**memcpy**(&(redA), &(B), (sizeof((redA))<sizeof((B))?sizeof((redA)):sizeof((B))
    ))

我们已经知道宏(函数式或非函数式)的标识符存在于它们自己的命名空间中。这是出于一个非常简单的理由:

摘要 16.3

宏替换在早期翻译阶段完成,在给组成程序的标记赋予任何其他解释之前。

因此,预处理阶段对关键字、类型、变量或后续翻译阶段的其它构造一无所知。

由于宏展开显式禁用了递归,甚至可以有使用与函数式宏相同标识符的函数。例如,以下有效的 C 代码:

**1**   inline
**2**   char const* string_literal(char const str[static 1]){
**3**     return str;
**4**   }
**5**   #define string_literal(S) string_literal("" S "")

它定义了一个接收字符数组作为参数的函数 string_literal,以及一个同名的宏,该宏以奇怪的参数排列调用函数,其理由我们很快就会看到。有一个更专门的规则有助于处理我们有一个宏和一个同名函数的情况。它与函数衰减 (takeaway 11.22) 类似。

Takeaway 16.4 (宏保留)

如果一个功能宏后面没有跟 *()**,则不会展开。

在前面的例子中,函数和宏的定义取决于它们出现的顺序。如果宏定义首先给出,它将立即展开成类似

 **1**   inline
 **2**   char const* string_literal("" char const str[static 1] ""){ // Error 
 **3**     return str;
 **4**   }

这是有误的。但如果我们将 string_literal 名称用括号括起来,它就不会展开,并保持为一个有效的定义。一个完整的例子可能如下所示:

 **1**   // header file 
 **2**   #define string_literal(S) string_literal("" S "")
  **3**   inline char const* (string_literal)(char const str[static 1]){
 **4**     return str;
 **5**   }
 **6**   extern char const* (*func)(char const str[static 1]);
 **7**   // One translation unit 
 **8**   char const* (string_literal)(char const str[static 1]);
 **9**   // Another translation unit 
**10**   char const* (*func)(char const str[static 1]) = string_literal;

即,函数的内联定义和实例化声明都受到周围括号的保护,并且不会展开功能宏。最后一行显示了该功能的另一种常见用法。这里 string_literal 后面没有跟 (), 因此应用了两个规则。首先,宏保留阻止了宏的展开,然后函数衰减 (takeaway 11.22) 将函数的使用评估为指向该函数的指针。

16.2. 参数检查

如我们之前所述,在具有固定数量且类型由 C 的类型系统良好建模的参数的情况下,我们应该使用函数而不是函数式宏。不幸的是,C 的类型系统并没有涵盖我们可能想要区分的所有特殊情况。

一个有趣的例子是我们想要传递给可能危险的函数(如 printf)的字符串字面量。正如我们在 第 5.6.1 节 中看到的,字符串字面量是只读的,但甚至没有 const 修饰。此外,像之前的 函数 string_literal 一样,具有 [static 1] 的接口并没有被语言强制执行,因为没有 [static 1] 的原型是等效的。在 C 中,没有方法可以规定函数接口的参数 str 应该满足以下约束:

  • 是一个字符指针

  • 必须非空

  • 必须不可变^([1])

    ¹

    const 只约束被调用的函数,而不是调用者。

  • 必须以 0 结尾

所有这些属性在编译时检查可能特别有用,但我们没有方法在函数接口中指定它们。

string_literal 填补了语言规范中的这一空白。在其展开中出现的奇怪空字符串字面量 "" X "" 确保字符串字面量只能用字符串字面量调用:

**1**   string_literal("hello");   // "" "hello" "" 
**2**   char word[25] = "hello";
**3**   ...
**4**   string_literal(word);      // "" word ""      // Error

宏和函数 string_literal 只是这种策略的一个简单例子。一个更有用的例子会是

macro_trace.h
**12**   /**
**13**    ** @brief A simple version of the macro that just does 
**14**    ** a @c fprintf or nothing 
**15**    **/ 
**16**   #if **NDEBUG**
**17**   # define TRACE_PRINT0(F, X) do { /* nothing */ } while (**false**)
**18**   #else
**19**   # define TRACE_PRINT0(F, X) **fprintf**(**stderr**, F, X)
**20**   #endif

一个可以在程序的调试构建上下文中使用的宏,用于插入调试输出:

macro_trace.c
**17**     TRACE_PRINT0("my favorite variable: %g\n", sum);

这看起来无害且高效,但它有一个陷阱:参数 F 可以是任何指向 char 的指针。特别是,它可能是一个位于可修改内存区域的格式字符串。这可能会导致错误或恶意的字符串修改导致格式无效,从而引发程序崩溃,或者可能泄露秘密。在 第 16.5 节 中,我们将更详细地了解为什么这对像 fprintf 这样的函数尤其危险。

在像示例中那样简单的代码中,我们向 fprintf 传递简单的字符串字面量,这些问题不应该发生。现代编译器实现能够追踪 fprintf(和类似函数)的参数,以检查格式说明符和其他参数是否匹配。

如果传递给 fprintf 的格式不是字符串字面量而是任何指向 char 的指针,则此检查将不起作用。为了抑制这种情况,我们可以强制在此处使用字符串字面量:

macro_trace.h
**22**   /**
**23**    ** @brief A simple version of the macro that ensures that the @c 
**24**    ** fprintf format is a string literal 
**25**    **
**26**    ** As an extra, it also adds a newline to the printout, so 
**27**    ** the user doesn't have to specify it each time. 
**28**    **/ 
**29**   #if **NDEBUG**
**30**   # define TRACE_PRINT1(F, X) do { /* nothing */ } while (**false**)
**31**   #else
**32**   # define TRACE_PRINT1(F, X) **fprintf**(**stderr**, "" F "\n", X)
**33**   #endif

现在,F 必须接收一个字符串字面量,然后编译器就可以进行工作并警告我们是否存在不匹配。

宏 TRACE_PRINT1 仍然存在弱点。如果它与 NDEBUG 设置一起使用,则参数将被忽略,因此不会检查一致性。这可能会导致长期效果,即不匹配在长时间内未被检测到,而在调试时突然出现。

因此,我们宏的下一个版本定义分为两个步骤。第一步使用类似的 #if/#else 概念来定义一个新的宏:TRACE_ON。

macro_trace.h
**35**   /**
**36**    ** @brief A macro that resolves to @c 0 or @c 1 according to @c 
**37**    ** NDEBUG being set 
**38**    **/ 
**39**   #ifdef **NDEBUG**
**40**   # define TRACE_ON 0
**41**   #else
**42**   # define TRACE_ON 1
**43**   #endif

与程序员可以将其设置为任何值的 NDEBUG 宏相反,这个新宏保证要么是 1 要么是 0。其次,TRACE_PRINT2 使用常规的 if 条件定义:

macro_trace.h
**45**   /**
**46**    ** @brief A simple version of the macro that ensures that the @c 
**47**    ** fprintf call is always evaluated 
**48**    **/ 
**49**   #define TRACE_PRINT2(F, X)                                       \
**50**   do { if (TRACE_ON) **fprintf**(**stderr**, "" F "\n", X); } while (**false**)

当其参数为 0 时,任何现代编译器都应该能够优化掉对 fprintf 的调用。它不应该省略对参数 F 和 X 的检查。因此,无论我们是在调试还是不是,宏的参数必须始终匹配,因为 fprintf 需要这样。

与之前使用空字符串字面量 "" 类似,还有其他技巧可以强制宏参数具有特定类型。其中一种技巧是添加适当的 0+0 强制参数为任何算术类型(整数、浮点或指针)。类似 +0.0F 的东西提升为浮点类型。例如,如果我们只想为调试打印一个值,而不跟踪值的类型,这可能就足够了:

macro_trace.h
**52**   /**
**53**    ** @brief Traces a value without having to specify a format 
**54**    **/ 
**55**   #define TRACE_VALUE0(HEAD, X) TRACE_PRINT2(HEAD " %Lg", (X)+0.0L)

它适用于任何整数或浮点数 X。对于 long double 的格式 "%Lg" 确保任何值都以合适的方式呈现。显然,HEAD 参数现在不能包含任何 fprintf 格式,但编译器会告诉我们是否有不匹配。

然后,复合字面量可以是一种方便的方式来检查参数 X 的值是否可以赋值给类型 T。考虑以下尝试打印指针值的第一个尝试:

macro_trace.h
**57**   /**
**58**    ** @brief Traces a pointer without having to specify a format 
**59**    **
**60**    ** @warning Uses a cast of @a X to @c void*
**61**    **/ 
**62**   #define TRACE_PTR0(HEAD, X)  TRACE_PRINT2(HEAD " %p", (void*)(X))

它尝试使用 "%p" 格式打印指针值,该格式期望一个通用的 void* 类型指针。因此,宏使用 类型转换 将 X 的值和类型转换为 void*。像大多数类型转换一样,如果 X 不是一个指针,这里的类型转换可能会出错:因为类型转换告诉编译器我们知道我们在做什么,实际上所有的类型检查都被关闭了。

这可以通过首先将 X 赋值给 void* 类型的对象来避免。赋值只允许一组受限的 隐式转换,这里是将任何指针转换为 void* 的转换:

macro_trace.h
**64**   /**
**65**    ** @brief Traces a pointer without specifying a format 
**66**    **/
**67**   #define TRACE_PTR1(HEAD, X)                     \
**68**   TRACE_PRINT2(HEAD " %p", ((void*){ 0 } = (X)))

技巧是使用类似于 ((T){ 0 } = (X)) 的方法来检查 X 是否可以赋值给类型 T。在这里,复合字面量 ((T){ 0 } 首先创建了一个类型 T 的临时对象,然后我们将 X 赋值给它。再次,现代优化编译器应该会优化掉临时对象的使用,并为我们进行类型检查。

16.3. 访问调用上下文

由于宏只是文本替换,它们可以与其调用者的上下文更紧密地交互。一般来说,对于常规功能,这不是所希望的,我们更希望调用者(函数参数的评估)和被调用者(函数参数的使用)之间的上下文有明确的分离。

然而,在调试的上下文中,我们通常希望打破这种严格的分离,以观察代码中特定点的部分状态。原则上,我们可以在宏内部访问任何变量,但通常我们想要有关调用环境的更多具体信息:特定调试输出来源的位置跟踪。

C 提供了几个用于此目的的构造。它有一个特殊的宏 __LINE__,它始终展开为源文件中实际行的十进制整数常量:

macro_trace.h
**70**   /**
**71**    ** @brief Adds the current line number to the trace 
**72**    **/ 
**73**   #define TRACE_PRINT3(F, X)                               \
**74**   do {                                                     \
**75**     if (TRACE_ON)                                          \
**76**       **fprintf**(**stderr**, "%lu: " F "\n", **__LINE__**+0UL, X);   \
**77**   } while (**false**)

同样,宏 __DATE____TIME____FILE__ 包含包含编译日期和时间以及当前 TU 名称的字符串字面量。另一个构造,__func__,是一个包含当前函数名称的局部 static 变量:

macro_trace.h
**79**   /**
**80**    ** @brief Adds the name of the current function to the trace 
**81**    **/ 
**82**   #define TRACE_PRINT4(F, X)                      \
**83**   do {                                            \
**84**     if (TRACE_ON)                                 \
**85**      **fprintf**(**stderr**, "%s:%lu: " F "\n",          \
**86**              **__func__**, **__LINE__**+0UL, X);          \
**87**   } while (**false**)

如果以下调用

macro_trace.c
**24**      TRACE_PRINT4("my favorite variable: %g", sum);

在源文件的第 24 行,并且 main 是其周围函数,则相应的输出看起来类似于以下内容:

终端
**0**      main:24: my favorite variable: 889

如果我们像这个例子中那样自动使用 fprintf,我们应该注意的一个潜在陷阱是,其列表中的所有参数都必须与指定符中给出的类型正确匹配。对于 __func__,这没有问题:根据其定义,我们知道这是一个 char 数组,所以 "%s" 指定符是合适的。__LINE__ 则不同。我们知道它是一个表示行号的十进制常量。因此,如果我们回顾 第 5.3 节 中十进制常量类型的规则,我们会看到类型取决于值。在嵌入式平台上,INT_MAX 可能小到 32767,而非常大的源代码(可能是自动生成的)可能有超过这个数量的行。一个好的编译器应该在出现这种情况时警告我们。

Takeaway 16.5

__LINE__ 中的行号可能不适合放入一个 int.

Takeaway 16.6

使用 __LINE__ 固有的很危险。

在我们的宏中,我们通过将类型固定为 unsigned long^([2]) 或在编译期间将数字转换为字符串来避免这个问题。

²

希望没有源代码会超过 40 亿行。

另一种来自调用上下文的信息类型对于跟踪通常非常有用:我们传递给宏作为参数的实际表达式。由于这通常用于调试目的,C 语言有一个特殊的操作符用于它:#。如果这样的 # 出现在宏参数的展开中,这个参数的实际参数将被 字符串化:也就是说,它的所有文本内容都被放入一个字符串字面量中。以下我们跟踪宏的变体有一个 #X

macro_trace.h
**91**   /**
**92**    ** @brief Adds a textual version of the expression that is evaluated 
**93**    **/ 
**94**   #define TRACE_PRINT5(F, X)                                      \
**95**   do {                                                            \
**96**    if (TRACE_ON)                                                  \
**97**     **fprintf**(**stderr**, "%s:" STRGY(**__LINE__**) ":(" #X "): " F "\n",   \
**98**             **__func__**, X);                                         \
**99**   } while (**false**)

在每次宏调用时,它被替换为第二个参数的文本。对于以下调用

macro_trace.c
**25**     TRACE_PRINT5("my favorite variable: %g", sum);
**26**     TRACE_PRINT5("a good expression: %g", sum*argc);

相应的输出看起来类似

终端
**0**     main:25:(sum): my favorite variable: 889
**1**     main:26:(sum*argc): a good expression: 1778

因为预处理阶段对这些参数的解释一无所知,这种替换完全是文本性的,并且应该像在源代码中一样出现,可能还有一些对空白的调整。

Takeaway 16.7

使用操作符 # 进行字符串化不会在其参数中展开宏。

由于前面提到的 __LINE__ 可能存在的问题,我们还想直接将行号转换为字符串。这有两个优点:它避免了类型问题,并且字符串化完全在编译时完成。正如我们所说的,# 操作符仅适用于宏参数,所以像 # __LINE__ 这样的简单使用不会产生预期的效果。现在考虑以下宏定义:

macro_trace.h
**89**   #define STRINGIFY(X) #X

字符串化在参数替换之前进行,STRINGIFY(**__LINE__**) 的结果是 "LINE";宏 __LINE__ 不会被展开。所以这个宏仍然不足以满足我们的需求。

现在,STRGY(__LINE__) 首先扩展为 STRINGIFY(25)(如果我们处于第 25 行)。然后它扩展为 "25",即字符串化的行号:

macro_trace.h
**90**   #define STRGY(X) STRINGIFY(X)

为了完整性,我们还将提及另一个仅在预处理阶段有效的运算符:## 运算符。它用于更加专业的用途:它是一个 标记连接运算符。当编写整个宏库,并且需要自动生成类型或函数名称时,它可能很有用。

16.4. 默认参数

C 库的一些函数有参数,这些参数大多数时候接收相同的无聊参数。对于 strtoul 和其相关函数来说就是这样。记住,这些函数接收三个参数:

**1**   unsigned long int **strtoul**(char const nptr[restrict],
**2**                             char** restrict endptr,
**3**                             int base);

第一个是我们要将其转换为 unsigned long 的字符串。endptr 将指向字符串中数字的末尾,base 是解释字符串的整数基数。有两个特殊约定适用:如果 endptr 可能是空指针,并且 base 是 0,则字符串被解释为十六进制(以 "0x" 开头)、八进制(以 "0" 开头)或十进制否则。

大多数时候,strtoul 都不带 endptr 功能,并且将符号基数设置为 0,例如在类似以下内容中

**1**   int **main**(int argc, char* argv[argc+1]) {
**2**     if (argc < 2) return **EXIT_FAILURE**;
**3**     **size_t** len = **strtoul**(argv[1], 0, 0);
**4**     ...
**5**   }

将程序的第一个命令行参数转换为长度值。为了避免这种重复,并让代码的读者专注于重要的事情,我们可以引入一个中间级别的宏,如果省略这些 0 参数,则提供这些参数:

generic.h
**114**
**115**   /**
**116**    ** @brief Calls a three-parameter function with default arguments 
**117**    ** set to 0 
**118**    **/ 
**119**   #define ZERO_DEFAULT3(...) ZERO_DEFAULT3_0(**__VA_ARGS__**, 0, 0, )
**120**   #define ZERO_DEFAULT3_0(FUNC, _0, _1, _2, ...) FUNC(_0, _1, _2)
**121**
**122**   #define **strtoul**(...) ZERO_DEFAULT3(**strtoul**, **__VA_ARGS__**)
**123**   #define **strtoull**(...) ZERO_DEFAULT3(**strtoull**, **__VA_ARGS__**)
**124**   #define **strtol**(...) ZERO_DEFAULT3(**strtol**, **__VA_ARGS__**)

在这里,宏 ZERO_DEFAULT3 通过后续添加和删除参数来工作。它应该接收一个函数名称和至少一个要传递给该函数的参数。首先,将两个零添加到参数列表中;然后,如果这导致超过三个组合参数,则省略多余的参数。因此,对于只有一个参数的调用,替换序列如下所示:

**strtoul**(argv[1])
//      ...
ZERO_DEFAULT3(**strtoul**, argv[1])
//            ... 
ZERO_DEFAULT3_0(**strtoul**, argv[1], 0, 0, )
//              FUNC   , _0     ,_1,_2,... 
**strtoul**(argv[1], 0, 0)

由于宏扩展中抑制递归的特殊规则,对 strtoul 的最终函数调用将不会进一步扩展,并将传递到下一个编译阶段。

如果我们用三个参数调用 strtoul

**strtoul**(argv[1], ptr, 10)
//      ...
ZERO_DEFAULT3(**strtoul**, argv[1], ptr, 10)
//            ...
ZERO_DEFAULT3_0(**strtoul**, argv[1], ptr, 10, 0, 0, )
//              FUNC   , _0     , _1 , _2, ... 
**strtoul**(argv[1], ptr, 10) 

替换序列实际上产生了与开始时完全相同的标记。

16.5. 可变长度参数列表

我们已经研究了接受可变长度参数列表的函数:printfscanf及其朋友。它们的声明在参数列表的末尾有...标记来指示这个特性:在已知参数的初始数量(例如printf的格式)之后,可以提供一个任意长度的额外参数列表。稍后,在第 16.5.2 节中,我们将简要讨论如何定义这样的函数。因为这个特性不安全,所以它是危险的,几乎已经过时,所以我们不会坚持这个特性。作为替代,我们将介绍一个类似的功能,即可变参数宏,这可以主要用于替换函数的特性。

16.5.1. 可变参数宏

可变长度参数宏,简称为可变参数宏,使用相同的标记...来指示这个特性。与函数一样,这个标记必须出现在参数列表的末尾:

macro_trace.h
**101**   /**
**102**    ** @brief Allows multiple arguments to be printed in the 
**103**    ** same trace 
**104**    **/ 
**105**   #define TRACE_PRINT6(F, ...)                            \
**106**   do {                                                    \
**107**     if (TRACE_ON)                                        \
**108**       **fprintf**(**stderr**, "%s:" STRGY(**__LINE__**) ": " F "\n",  \
**109**               **__func__**, **__VA_ARGS__**);                     \
**110**   } while (**false**) 

在这里,在 TRACE_PRINT6 中,这表示在格式参数 F 之后,可以提供一个非空的可选参数列表。这个展开的参数列表可以通过标识符__VA_ARGS__在展开中访问。因此,一个如下的调用

macro_trace.c
**27**     TRACE_PRINT6("a collection: %g, %i", sum, argc);

只是传递参数到fprintf并产生输出

Terminal
**0**   main:27: a collection: 889, 2

不幸的是,按照目前的写法,__VA_ARGS__中的列表不能为空或不存在。所以对于我们迄今为止所看到的,我们不得不为列表不存在的情况编写一个单独的宏:

macro_trace.h
**113**    ** @brief Only traces with a text message; no values printed 
**114**    **/
**115**   #define TRACE_PRINT7(...)                                     \
**116**   do {                                                          \
**117**    if (TRACE_ON)                                                \
**118**     **fprintf**(**stderr**, "%s:" STRGY(**__LINE__**) ": " **__VA_ARGS__** "\n",\
**119**             **__func__**);                                          \
**120**   } while (**false**)

但通过更多的努力,这两个功能可以被合并成一个宏:

macro_trace.h
**138**    ** @brief Traces with **or** without values 
**139**    **
**140**    ** This implementation has the particularity of adding a format 
**141**    ** @c "%.0d" **to** skip the last element of the list, which was 
**142**    ** artificially added. 
**143**    **/
**144**   #define TRACE_PRINT8(...)                        \
**145**   TRACE_PRINT6(TRACE_FIRST(**__VA_ARGS__**) "%.0d",    \
**146**                TRACE_LAST(**__VA_ARGS__**))

这里,TRACE_FIRST 和 TRACE_LAST 是宏,分别提供对列表中第一个和剩余参数的访问。它们相对简单。它们使用辅助宏,使我们能够区分第一个参数 _0 和其余的__VA_ARGS__。由于我们希望能够用一个或多个参数调用它们,它们向列表中添加了一个新的参数0。对于 TRACE_FIRST 来说,这很顺利。这个额外的0就像其余的参数一样被忽略:

macro_trace.h
**122**   /**
**123**    ** @brief Extracts the first argument from a list of arguments 
**124**    **/ 
**125**   #define TRACE_FIRST(...) TRACE_FIRST0(**__VA_ARGS__**, 0)
**126**   #define TRACE_FIRST0(_0, ...) _0

对于 TRACE_LAST 来说,这有点问题,因为它通过一个额外的值扩展了我们感兴趣的列表:

macro_trace.h
**128**   /**
**129**    ** @brief Removes the first argument from a list of arguments 
**130**    **
**131**    ** @remark This is only suitable in our context, 
**132**    ** since this adds an artificial last argument. 
**133**    **/ 
**134**   #define TRACE_LAST(...) TRACE_LAST0(**__VA_ARGS__**, 0)
**135**   #define TRACE_LAST0(_0, ...) **__VA_ARGS__** 

因此,TRACE_PRINT6 通过一个额外的格式说明符%.0d来补偿这一点,该说明符打印一个宽度为0int:即,什么也不打印。测试它在两种不同的使用情况

macro_trace.c
**29**     TRACE_PRINT8("a collection: %g, %i", sum, argc);
**30**     TRACE_PRINT8("another string");

给我们我们想要的 exactly what we want:

Terminal
**0**   main:29: a collection: 889, 2
**1**   main:30: another string

参数列表中的__VA_ARGS__部分也可以像任何其他宏参数一样进行字符串化:

macro_trace.h
**148**   /**
**149**    ** @brief Traces by first giving a textual representation of the 
**150**    ** arguments 
**151**    **/ 
**152**   #define TRACE_PRINT9(F, ...)                            \
**153**   TRACE_PRINT6("(" #**__VA_ARGS__** ") " F, **__VA_ARGS__**)

参数的文本表示

macro_trace.c
**31**     TRACE_PRINT9("a collection: %g, %i", sum***acos**(0), argc);

被插入,包括分隔它们的逗号:

Terminal
**0**      main:31: (sum*acos(0), argc) a collection: 1396.44, 2

到目前为止,我们具有可变数量参数的迹宏变体也必须在格式参数 F 中接收正确的格式说明符。这可能会是一项繁琐的工作,因为它迫使我们始终跟踪要打印的列表中每个参数的类型。一个内联函数和宏的组合可以在这里帮助我们。首先让我们看看这个函数:

macro_trace.h
**166**   /**
**167**    ** @brief A function to print a list of values 
**168**    **
**169**    ** @remark Only call this through the macro ::TRACE_VALUES, 
**170**    ** which will provide the necessary contextual information. 
**171**    **/ 
**172**   inline
**173**   void trace_values(**FILE*** s,
**174**                     char const func[static 1],
**175**                     char const line[static 1],
**176**                     char const expr[static 1],
**177**                     char const head[static 1],
**178**                     **size_t** len, long double const arr[len]) {
**179**     **fprintf**(s, "%s:%s:(%s) %s %Lg", func, line,
**180**             trace_skip(expr), head, arr[0]);
**181**     for (**size_t** i = 1; i < len-1; ++i)
**182**       **fprintf**(s, ", %Lg", arr[i]);
**183**     **fputc**('\n', s);
**184**   }

它在前面加上相同的标题信息后打印一系列 long double 值,就像我们之前所做的那样。但这次,函数通过一个已知长度为 len 的 long double 数组接收值列表。由于我们将很快看到的原因,该函数实际上总是跳过数组的最后一个元素。使用 trace_skip 函数,它还跳过参数 expr 的初始部分。

将上下文信息传递给函数的宏有两个级别。第一个只是以不同的方式按摩参数列表:

macro_trace.h
**204**   /**
**205**    ** @brief Traces a list of arguments without having to specify 
**206**    ** the type of each argument 
**207**    **
**208**    ** @remark This constructs a temporary array with the arguments 
**209**    ** all converted to @c long double. Thereby implicit conversion 
**210**    ** to that type is always guaranteed. 
**211**    **/ 
**212**   #define TRACE_VALUES(...)                       \
**213**   TRACE_VALUES0(ALEN(**__VA_ARGS__**),                \
**214**                 #**__VA_ARGS__**,                     \
**215**                 **__VA_ARGS__**,                      \
**216**                 0                                 \
**217**                 )

首先,借助我们即将看到的 ALEN,它评估列表中的元素数量。然后它将列表字符串化,并最终将列表本身以及一个额外的 0 附加到列表上。所有这些都被输入到 TRACE_VALUES0:

macro_trace.h
**219**   #define TRACE_VALUES0(NARGS, EXPR, HEAD, ...)                    \
**220**   do {                                                             \
**221**     if (TRACE_ON) {                                                \
**222**       if (NARGS > 1)                                               \
**223**         trace_values(**stderr**, **__func__**, STRGY(**__LINE__**),            \
**224**                      "" EXPR "", "" HEAD "", NARGS,               \
**225**                      (long double const[NARGS]){ **__VA_ARGS__** });   \
**226**       else                                                         \
**227**         **fprintf**(**stderr**, "%s:" STRGY(**__LINE__**) ": %s\n",           \
**228**                 **__func__**, HEAD);                                   \
**229**     }                                                              \

这里,没有 HEAD 的列表用作类型为 long double const[NARG] 的复合字面量的初始化器。我们之前添加的 0 确保初始化器永远不会为空。有了关于参数列表长度的信息,我们也能够进行情况区分,如果唯一的参数只是格式字符串。

我们还需要展示 ALEN:

macro_trace.h
**186**   /**
**187**    ** @brief Returns the number of arguments in the ... list 
**188**    **
**189**    ** This version works for lists with up to 31 elements. 
**190**    **
**191**    ** @remark An empty argument list is taken as one (empty) argument. 
**192**    **/ 
**193**   #define ALEN(...) ALEN0(**__VA_ARGS__**,                    \
**194**     0x1E, 0x1F, 0x1D, 0x1C, 0x1B, 0x1A, 0x19, 0x18,       \
**195**     0x17, 0x16, 0x15, 0x14, 0x13, 0x12, 0x11, 0x10,       \
**196**     0x0E, 0x0F, 0x0D, 0x0C, 0x0B, 0x0A, 0x09, 0x08,       \
**197**     0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, 0x00)
**198**
**199**   #define ALEN0(_00, _01, _02, _03, _04, _05, _06, _07,           \
**200**                 _08, _09, _0A, _0B, _0C, _0D, _0F, _0E,           \
**201**                 _10, _11, _12, _13, _14, _15, _16, _17,           \
**202**                 _18, _19, _1A, _1B, _1C, _1D, _1F, _1E, ...) _1E 

策略是取 __VA_ARGS__ 列表并附加一个递减数字列表 3130,...,0。然后,通过使用 ALEN0,我们返回该新列表的第 31 个元素。根据原始列表的长度,这个元素将是其中一个数字。实际上,很容易看出返回的数字正是原始列表的长度,前提是它至少包含一个元素。在我们的用例中,总是至少有一个格式字符串,因此空列表的边界情况不会发生。

16.5.2. 一个小插曲:可变参数函数

现在让我们简要地看看 可变参数函数:具有可变长度参数列表的函数。如前所述,这些函数通过在函数声明中使用 ... 操作符来指定,例如

int **printf**(char const format[static 1], ...);

这样的函数在其接口定义中存在一个基本问题。与普通函数不同,在调用方面,不清楚应该将参数转换为哪种类型。例如,如果我们调用 printf("%d", 0),编译器不会立即清楚调用函数期望哪种类型的 0。对于这种情况,C 语言有一套规则来确定参数转换的类型。这些规则几乎与算术规则相同:

取得 16.8 的要点

当传递给可变参数时,所有算术类型都像算术运算一样进行转换,除了 float 参数,它们会被转换为double

所以特别地,当它们被传递给可变参数时,例如charshort这样的类型会被转换为更宽的类型,通常是int

到目前为止,一切顺利:现在我们知道了如何调用这样的函数。但不幸的是,这些规则并没有告诉我们被调用函数应该期望接收哪种类型。

总结 16.9

可变函数必须接收关于可变列表中每个参数类型的有效信息。

printf函数通过在格式参数内部指定类型来规避这个困难。让我们看看以下简短的代码片段:

**1**   unsigned char zChar = 0;
**2**   **printf**("%hhu", zChar);

这会导致 zChar 被评估,提升为int类型,并作为参数传递给printf,然后它读取这个int并将其重新解释为unsigned char。这种机制是

  • 复杂: 因为函数的实现必须为所有基本类型提供专门的代码

  • 易出错: 因为每个调用都依赖于参数类型被正确传递给函数

  • 紧迫: 因为程序员必须检查每个参数的类型

尤其是后者可能导致严重的可移植性问题,因为常量在不同的平台上可能有不同的类型。例如,一个看似无害的调用

**printf**("%d: %s\n", 65536, "a small number"); // Not portable

在大多数平台上都能正常工作:那些具有超过 16 位int类型的平台。但在某些平台上,它可能在运行时失败,因为65536long。这种潜在失败的最坏例子是宏NULL

**printf**("%p: %s\n", **NULL**, "print of NULL"); // Not portable

正如我们在第 11.1.5 节中看到的,NULL仅保证是一个空指针常量。编译器实现者可以自由选择他们提供的变体:有些人选择(void)0,其类型为void*;大多数人选择0,其类型为int。在指针和int宽度不同的平台上,例如所有现代 64 位平台,这可能导致程序崩溃.^([3])

³

这是我们不应该使用NULL的原因之一 (总结 11.14)。

总结 16.10

使用可变函数不可移植,除非每个参数都被强制转换为特定类型。

这与我们之前在 TRACE_VALUES 示例中看到的可变宏的使用大不相同。在那里,我们将可变列表用作数组的初始化器,因此所有元素都会自动转换为正确的目标类型。

总结 16.11

避免在新接口中使用可变函数。

这些函数根本不值得麻烦。但是如果你必须实现一个可变参数函数,你需要 C 库头文件 stdarg.h。它定义了一个类型,va_list,以及四个可以作为 va_list 后面不同参数使用的函数式宏。它们的伪接口看起来像这样:

<stdarg.h>

**1**   void **va_start**(**va_list** ap, parmN);
**2**   void **va_end**(**va_list** ap);
**3**   *type* **va_arg**(**va_list** ap, *type*);
**4**   void **va_copy**(**va_list** dest, **va_list** src);

第一个示例展示了如何实际上避免编写可变参数函数的核心部分。对于任何涉及格式化打印的事情,我们应该使用现有的函数:

va_arg.c
**20**   **FILE*** iodebug = 0;
**21**
**22**   /**
**23**    ** @brief Prints to the debug stream @c iodebug 
**24**    **/ 
**25**   #ifdef __GNUC__
**26**   __attribute__((format(**printf**, 1, 2)))
**27**   #endif
**28**   int printf_debug(const char *format, ...) {
**29**     int ret = 0;
**30**     if (iodebug) {
**31**       **va_list** va;
**32**       **va_start**(va, format);
**33**       ret = **vfprintf**(iodebug, format, va);
**34**       **va_end**(va);
**35**     }
**36**     return ret;
**37**   }

我们使用 va_startva_end 做的唯一事情是创建一个 va_list 参数列表,并将此信息传递给 C 库函数 vfprintf。这完全免除了我们进行情况分析和跟踪参数的需要。条件 attribute 是编译器特定的(这里,对于 GCC 和其同类)。这种附加功能在应用已知参数约定并且编译器可以进行一些良好的诊断以确保参数有效性的情况下可能非常有用。

现在,我们将查看一个接收 n 个 double 值并求和的可变参数函数:^([[Exs 4]])

^([Exs 4])

只接收所有参数都是同一类型的可变参数函数可以被一个可变参数宏和一个接受数组的 inline 函数所替代。这样做吧。

va_arg.c
 **6**   /**
 **7**    ** @brief A small, useless function to show how variadic 
 **8**    ** functions work 
 **9**    **/ 
**10**   double sumIt(**size_t** n, ...) {
**11**     double ret = 0.0;
**12**     **va_list** va;
**13**     **va_start**(va, n);
**14**     for (**size_t** i = 0; i < n; ++i)
**15**       ret += **va_arg**(va, double);
**16**     **va_end**(va);
**17**     return ret;
**18**   }

va_list 通过使用列表之前的最后一个参数进行初始化。注意,通过某种魔法,va_start 接收 va 如此,而不是使用地址运算符 &。然后,在循环内部,列表中的每个值都通过使用 va_arg 宏来接收,该宏需要对其 类型 参数进行显式指定(这里,double)。此外,我们必须自己维护列表的长度,这里是通过将长度作为参数传递给函数来实现的。参数类型的编码(这里,隐式)和列表末尾的检测留给函数的程序员。

Takeaway 16.12

va_arg 机制不提供对 va_list 长度的访问。

Takeaway 16.13

可变参数函数需要一个特定的列表长度约定

16.6. 类型通用的编程

C11 对 C 语言的一个真正的贡献是直接语言支持类型通用的编程。C99 有 tgmath.h(见 第 8.2 节)用于类型通用的数学函数,但它没有提供很多来自己编写这样的接口。特定的附加功能是关键字 _Generic,它引入了以下形式的初等表达式:

<tgmath.h>

**1**   _Generic(*controlling expression*,
**2**     *type1*: *expression1*,
**3**     ... ,
**4**     *typeN*: *expressionN*)

这非常类似于一个 switch 语句。但是,控制表达式只取其类型(但请稍后查看),结果是 expression1 . . . expressionN 中的一个表达式,这些表达式由相应的类型特定的 type1 . . . typeN 选择,其中之一可能只是关键字 default

最简单的用例之一,也是 C 委员会主要考虑的,是使用_Generic通过提供函数指针之间的选择来实现类型通用宏接口。一个基本例子是tgmath.h接口,如fabs_Generic本身不是一个宏特性,但可以方便地在宏展开中使用。通过忽略复数浮点类型,这样的fabs宏可能看起来像这样:

**1**   #define **fabs**(X)        \
**2**   _Generic((X),          \
**3**     float: **fabsf**,        \
**4**     long double: **fabsl**,  \
**5**     default: **fabs**)(X)

这个宏区分了两种特定类型,floatlong double,分别选择对应的函数fabsffabsl。如果参数 X 是任何其他类型,它将被映射到fabsdefault情况。也就是说,其他算术类型,如double和整数类型,被映射到fabs.^([[Exs 5]])^([[Exs 6]])

^([Exs 5])

找出两个原因,说明这个宏展开中fabs的出现本身没有被展开。

^([Exs 6])

fabs宏扩展到涵盖复数浮点类型。

现在,一旦确定了结果函数指针,它就被应用于_Generic主要表达式后面的参数列表(X)

下面是一个更完整的例子:

generic.h
 **7**   inline
 **8**   double min(double a, double b) {
 **9**     return a < b ? a : b;
**10**   }
**11**
**12**   inline
**13**   long double minl(long double a, long double b) {
**14**     return a < b ? a : b;
**15**   }
**16**
**17**   inline
**18**   float minf(float a, float b) {
**19**     return a < b ? a : b;
**20**   }
**21**
**22**   /**
**23**    ** @brief Type-generic minimum for floating-point values 
**24**    **/ 
**25**   #define min(A, B)                               \
**26**   _Generic((A)+(B),                               \
**27**            float: minf,                           \
**28**            long double: minl,                     \
**29**            default: min)((A), (B))

它实现了两个实数值最小值的类型通用接口。定义了三个针对三种浮点类型的inline函数,并按与fabs类似的方式使用。不同之处在于,这些函数需要两个参数,而不仅仅是其中一个,因此_Generic表达式必须决定两种类型的组合。这是通过使用两个参数之和作为控制表达式来实现的。因此,参数提升和转换作用于加法操作中的参数,因此_Generic表达式选择两种类型中较宽的函数,或者如果两个参数都是整数,则选择double

与只有一个函数,比如long double相比,区别在于具体参数的类型信息没有丢失。

取得第 16.14 点

_Generic**表达式的结果类型是所选表达式的类型

这与例如三元运算符a?b:c 的情况形成对比。在这里,返回类型是通过结合类型 b 和 c 来计算的。对于三元运算符,必须这样做,因为 a 可能在不同的运行中不同,所以 b 或 c 可能被选中。由于_Generic根据类型做出选择,因此这个选择在编译时是固定的。因此,编译器可以提前知道选择的结果类型。

在我们的例子中,我们可以确信,所有使用我们的接口生成的代码永远不会使用比程序员预见的更宽的类型。特别是,我们的 min 宏应该始终导致编译器为相关类型内联适当的代码.^([[Exs 7]])^([[Exs 8]])

^([Exs 7])

将 min 宏扩展以涵盖所有宽整数类型。

^([Exs 8])

将 min 扩展以涵盖指针类型,同样。

摘要 16.15

使用 _Genericinline 函数一起使用增加了优化机会。

对“控制表达式的类型”这一说法的解释有点模糊,因此 C17 与 C11 相比对此进行了澄清。事实上,正如前面的例子所暗示的,这种类型是如果将其传递给函数的表达式的类型。这意味着特别是:

  • 如果有的话,类型限定符将从控制表达式的类型中删除。

  • 数组类型被转换为基类型的指针类型。

  • 函数类型被转换为函数指针。

摘要 16.16

_Generic 表达式中的类型表达式应该是无资格的类型:没有数组类型,也没有函数类型。

这并不意味着类型表达式不能是指向这些之一:有资格类型的指针、数组的指针或函数的指针。但一般来说,这个规则使得编写类型通用的宏更容易,因为我们不需要考虑所有限定符的组合。有 3 个限定符(指针类型有 4 个),所以否则每个基类型都会有 8(甚至 16)个不同的类型表达式。以下示例 MAXVAL 已经相对较长:它为所有 15 个可排序类型都有一个特殊案例。如果我们还必须跟踪资格,我们就必须专门化 120 个案例!

generic.h
**31**   /**
**32**    ** @brief The maximum value for the type of @a X 
**33**    **/ 
**34**   #define MAXVAL(X)                                        \
**35**   _Generic((X),                                            \
**36**            bool: (bool)+1,                                 \
**37**            char: (char)+**CHAR_MAX**,                          \
**38**            signed char: (signed char)+**SCHAR_MAX**,           \
**39**            unsigned char: (unsigned char)+**UCHAR_MAX**,       \
**40**            signed short: (signed short)+**SHRT_MAX**,          \
**41**            unsigned short: (unsigned short)+**USHRT_MAX**,     \
**42**            signed: **INT_MAX**,                                \
**43**            unsigned: **UINT_MAX**,                             \
**44**            signed long: **LONG_MAX**,                          \
**45**            unsigned long: **ULONG_MAX**,                       \
**46**            signed long long: **LLONG_MAX**,                    \
**47**            unsigned long long: **ULLONG_MAX**,                 \
**48**            float: **FLT_MAX**,                                 \
**49**            double: **DBL_MAX**,                                \
**50**            long double: **LDBL_MAX**)

这是一个例子,其中 _Generic 表达式被用于与之前不同的方式,当时我们“只是”选择了一个函数指针然后调用该函数。这里的结果是一个整数常量表达式。这永远不能通过函数调用实现,而且仅用宏来实现会非常繁琐。^([[Exs 9]]) 再次,通过转换技巧,我们可以消除我们可能不感兴趣的某些情况:

^([Exs 9])

为最小值编写一个类似的宏。

generic.h
**52**   /**
**53**    ** @brief The maximum promoted value for @a XT, where XT 
**54**    ** can be an expression or a type name 
**55**    **
**56**    ** So this is the maximum value when fed to an arithmetic 
**57**    ** operation such as @c +. 
**58**    **
**59**    ** @remark Narrow types are promoted, usually to @c signed, 
**60**    ** or maybe to @c unsigned on rare architectures. 
**61**    **/ 
**62**   #define maxof(XT)                               \
**63**   _Generic(0+(XT)+0,                              \
**64**            signed: **INT_MAX**,                       \
**65**            unsigned: **UINT_MAX**,                    \
**66**            signed long: **LONG_MAX**,                 \
**67**            unsigned long: **ULONG_MAX**,              \
**68**            signed long long: **LLONG_MAX**,           \
**69**            unsigned long long: **ULLONG_MAX**,        \
**70**            float: **FLT_MAX**,                        \
**71**            double: **DBL_MAX**,                       \
**72**            long double: **LDBL_MAX**)

在这里,控制表达式的特殊形式添加了一个额外的功能。表达式 0+(标识符)+0 在标识符是变量或它是类型时是有效的。如果是变量,则使用变量的类型,并且它被解释得就像任何其他表达式一样。然后对其应用整数提升,并推导出结果类型。

如果它是一个类型,(标识符)+0 被读取为将 +0 转换为类型标识符的转换。从左侧添加 0+ 仍然确保如果需要,执行整数提升,因此如果 XT 是类型 T 或类型 T 的表达式 X,结果相同。^([[Exs 10]])^([[Exs 11]])^([[Exs 12]])

^([Exs 10])

编写一个宏 PROMOTE(XT, A),它返回 A 的类型为 XT 的值。例如,PROMOTE(1u, 3) 将是 3u。

^([Exs 11])

编写一个宏 SIGNEDNESS(XT),根据 XT 类型的符号返回falsetrue。例如,SIGNEDNESS(1l)将是true

^([Exs 12])

编写一个宏 mix(A, B),计算 A 和 B 的最大值。如果两者具有相同的符号,则结果类型应该是两个中较宽的类型。如果两者具有不同的符号,则返回类型应该是一个可以容纳两种类型所有正值的无符号类型。

_Generic表达式中的类型表达式还有一个要求,即选择必须在编译时明确无误。

摘要 16.17

_Generic表达式中的类型表达式必须引用相互不兼容的类型。

摘要 16.18

_Generic表达式中的类型表达式不能是一个 VLA 的指针。

与函数指针调用变体不同的模型可能更方便,但它也有一些陷阱。让我们尝试使用_Generic来实现两个宏 TRACE_FORMAT 和 TRACE_CONVERT,它们在以下内容中使用:

macro_trace.h
**278**   /**
**279**    ** @brief Traces a value without having to specify a format 
**280**    **
**281**    ** This variant works correctly with pointers. 
**282**    **
**283**    ** The formats are tunable by changing the specifiers in 
**284**    ** ::TRACE_FORMAT. 
**285**    **/
**286**   #define TRACE_VALUE1(F, X)                                          \
**287**     do {                                                              \
**288**       if (TRACE_ON)                                                   \
**289**         **fprintf**(**stderr**,                                               \
**290**                 TRACE_FORMAT("%s:" STRGY(**__LINE__**) ": " F, X),       \
**291**                 **__func__**, TRACE_CONVERT(X));                          \
**292**     } while (**false**) 

TRACE_FORMAT 很简单。我们区分了六个不同的情况:

macro_trace.h
**232**   /**
**233**    ** @brief Returns a format that is suitable for @c fprintf 
**234**    **
**235**    ** @return The argument @a F must be a string literal, 
**236**    ** so the return value will be. 
**237**    **
**238**    **/ 
**239**   #define TRACE_FORMAT(F, X)                      \
**240**   _Generic((X)+0LL,                               \
**241**            unsigned long long: "" F " %llu\n",    \
**242**            long long: "" F " %lld\n",             \
**243**            float: "" F " %.8f\n",                 \
**244**            double: "" F " %.12f\n",               \
**245**            long double: "" F " %.20Lf\n",         \
**246**            default: "" F " %p\n")

default情况,当没有匹配算术类型时,假设参数具有指针类型。在这种情况下,为了成为 fprintf 的正确参数,指针必须转换为void*。我们的目标是通过 TRACE_CONVERT 实现这种转换。

首次尝试可能看起来像以下这样:

**1**   #define TRACE_CONVERT_WRONG(X)             \
**2**   _Generic((X)+0LL,                          \
**3**            unsigned long long: (X)+0LL,      \
**4**            ...                            \
**5**            default: ((void*){ 0 } = (X)))

这与 TRACE_PTR1 的技巧相同,用于将指针转换为void*。不幸的是,这个实现是错误的。

摘要 16.19

_Generic中的所有选择表达式 1 ... 表达式 N 必须有效

例如,如果 X 是一个unsigned long long,比如 1LL,那么default情况下的代码将是

((void*){ 0 } = (1LL))

这将是将非零整数赋值给指针,这是错误的.^([4])

记住,从非零整数到指针的转换必须通过强制类型转换来明确。

我们分两步解决这个问题。首先,我们有一个宏,它返回其参数、default或一个字面量零:

macro_trace.h
**248**   /**
**249**    ** @brief Returns a value that forcibly can be interpreted as 
**250**    ** pointer value 
**251**    **
**252**    ** That is, any pointer will be returned as such, but other 
**253**    ** arithmetic values will result in a @c 0. 
**254**    **/
**255**   #define TRACE_POINTER(X)                  \
**256**   _Generic((X)+0LL,                         \
**257**            unsigned long long: 0,           \
**258**            long long: 0,                    \
**259**            float: 0,                        \
**260**            double: 0,                       \
**261**            long double: 0,                  \
**262**            default: (X))

这有一个优点,即对 TRACE_POINTER(X)的调用始终可以赋值给void*。要么 X 本身是一个指针,可以赋值给void*,要么它是一种其他算术类型,宏调用的结果是0。综合起来,TRACE_CONVERT 看起来如下:

macro_trace.h
**264**   /**
**265**    ** @brief Returns a value that is promoted either to a wide 
**266**    ** integer, to a floating point, or to a @c void* if @a X is a 
**267**    ** pointer 
**268**    **/ 
**269**   #define TRACE_CONVERT(X)                                \
**270**   _Generic((X)+0LL,                                       \
**271**            unsigned long long: (X)+0LL,                   \
**272**            long long: (X)+0LL,                            \
**273**            float: (X)+0LL,                                \
**274**            double: (X)+0LL,                               \
**275**            long double: (X)+0LL,                          \
**276**            default: ((void*){ 0 } = TRACE_POINTER(X)))

概述

  • 函数式宏比内联函数更灵活。

  • 它们可以用来自动检查编译时参数,以补充函数接口,并从调用环境或默认参数提供信息。

  • 它们允许我们使用可变参数列表实现类型安全的功能。

  • _Generic结合使用,它们可以用来实现类型泛型接口。

第十七章. 控制流的变化

本章涵盖

  • 理解 C 中语句的正常顺序

  • 通过代码进行短跳和长跳

  • 函数控制流

  • 处理信号

程序执行的 control flow(见图 2.1 [kindle_split_009.html#ch02fig01])描述了程序代码中各个语句的 顺序:即,哪个语句在另一个语句之后执行。到目前为止,我们主要查看的是让我们可以从语法和控制表达式推导出这种控制流的代码。这样,每个函数都可以使用 基本块 的分层组合来描述。基本块是一系列语句的最大序列,一旦执行从这些语句中的第一个开始,就会无条件地继续到最后的语句,并且所有语句的执行都从第一个开始。

如果我们假设所有条件语句和循环语句都使用 {} 块,在简化的观点中,这样的基本块

  • 开始于 {}-块的开始或一个case或跳转标签

  • 结束于相应的 {} 块的末尾或下一个

    • 作为 case 或跳转标签目标的语句

    • 条件或循环语句的主体

    • return 语句

    • goto 语句

    • 调用具有特殊控制流的函数

注意,在这个定义中,没有对一般函数调用做出例外:这些函数被视为暂时挂起基本块的执行,但不会结束它。在那些具有特殊控制流并结束基本块的函数中,有一些是我们所熟知的:那些带有关键字 _Noreturn 的函数,例如 exitabort。另一个这样的函数是 setjmp,它可能多次返回,如后文所述。

仅由通过 if/else^([1]) 或循环语句拼接的基本块组成的代码,具有双重优势:对我们人类来说易于阅读,并且为编译器提供了更好的优化机会。两者都可以直接推导出基本块中变量和复合字面量的生命周期和访问模式,然后捕捉这些是如何通过基本块的分层组合融入其函数中的。

¹

switch/case 语句稍微复杂了一些。

这种结构化方法的理论基础很早就由 Nishizeki 等人给出了,用于 Pascal 程序 [1977],并由 Thorup [1995] 扩展到 C 和其他命令式语言。他们证明结构化程序(即,没有 goto 或其他任意跳转结构的程序)具有与程序语法的嵌套层次结构相匹配的控制流,可以从程序语法嵌套中推导出来。除非你不得不这样做,否则你应该坚持这种编程模型。

然而,一些特殊情况需要特殊措施。通常,程序控制流的更改可以来自

  • 条件语句: if/else, switch/case

  • 循环语句: do{}while(), while(), for()

  • 函数: 函数调用,return 语句,或 _Noreturn 规范

  • 短跳转: goto 和标签

  • 长跳转: setjmp/longjmp, getcontext/setcontext^([2])

    ²

    定义在 POSIX 系统。

  • 中断: 信号和信号处理器

  • 线程: thrd_create, thrd_exit

这些控制流的变化可能会混淆编译器对执行抽象状态的了解。大致来说,一个人类或机械读者需要跟踪的知识复杂性从上到下增加。到目前为止,我们只看到了前四个结构。这些对应于 语言 功能,这些功能由语法(如关键字)或运算符(如函数调用的 ())确定。后三个是由 C 库 接口引入的。它们提供了可以跨越函数边界的程序控制流的变化(longjmp),可以由程序外的事件(中断)触发,甚至可以建立并发控制流,另一个 执行线程

当对象受到意外控制流的影响时,可能会出现各种困难:

  • 对象可能在其生命周期外被使用。

  • 对象可能在使用前未被初始化。

  • 对象的值可能被优化(volatile)错误解释。

  • 对象可能被部分修改(sig_atomic_t, atomic_flag, 或 _Atomic 具有无锁属性和松散一致性)。

  • 对象的更新可能意外地排序(所有 _Atomic)。

  • 关键部分mtx_t)内必须保证执行是独占的。

由于访问构成程序状态的那些对象的访问变得复杂,C 语言提供了帮助处理这些困难的功能。在这个列表中,它们在括号中注明,我们将在以下章节中详细讨论。

17.1. 一个复杂的例子

为了说明这些概念中的大多数,我们将讨论一些核心示例代码:一个名为 basic_blocks递归下降解析器。以下列表展示了核心函数 descend。

列表 17.1. 代码缩进的递归下降解析器
**60**   static
**61**   char const* descend(char const* act,
**62**                       unsigned dp[restrict static 1], // Bad
**63**                       size_t len, char buffer[len],
**64**                       jmp_buf jmpTarget) {
**65**     if (dp[0]+3 > sizeof head) longjmp(jmpTarget, tooDeep);
**66**     ++dp[0];
**67**    NEW_LINE:                             // Loops on output
**68**     while (!act || !act[0]) {            //  Loops for input
**69**       if (interrupt) longjmp(jmpTarget, interrupted);
**70**       act = skipspace(fgets(buffer, len, stdin));
**71**       if (!act) {                        // End of stream
**72**         if (dp[0] != 1) longjmp(jmpTarget, plusL);
**73**         else goto ASCEND;
**74**       }
**75**     }
**76**     fputs(&head[sizeof head - (dp[0] + 2)], stdout); // Header
**77**
**78**     for (; act && act[0]; ++act) { // Remainder of the line
**79**       switch (act[0]) {
**80**       case LEFT:                   // Descends on left brace
**81**         act = end_line(act+1, jmpTarget);
**82**         act = descend(act, dp, len, buffer, jmpTarget);
**83**         act = end_line(act+1, jmpTarget);
**84**         goto NEW_LINE;
**85**       case RIGHT:                  // Returns on right brace
**86**         if (dp[0] == 1) longjmp(jmpTarget, plusR);
**87**         else goto ASCEND;
**88**       default:                     // Prints char and goes on
**89**         putchar(act[0]);
**90**       }
**91**     }
**92**     goto NEW_LINE;
**93**    ASCEND:
**94**     --dp[0];
**95**     return act;
**96**   }

这段代码有几个用途。首先,它显然展示了我们稍后讨论的几个特性:递归,短跳转(goto),长跳转(longjmp),以及中断处理。

但至少同样重要的是,这可能是我们在这本书中处理过的最困难的代码,对于一些人来说,这甚至可能是你见过的最复杂的代码。然而,尽管它有 36 行,它仍然可以放在一屏上,这本身就是 C 代码可以非常紧凑和高效的证明。理解它可能需要几个小时,但请不要绝望;你可能还没有意识到,但如果你已经彻底阅读了这本书,你已经为这个做好了准备。

该函数实现了一个 递归下降解析器,它识别在 stdin 上给出的文本中的 {} 构造,并根据 {} 的嵌套在输出中缩进文本。更正式地说,用 Backus-Nauer 形式(BNF)编写,此函数检测文本如下递归定义

³

这是计算机可读语言的规范化描述。在这里,程序递归定义为文本序列,可选地后跟另一个序列的程序,这些程序位于大括号内。

  • 程序 := some-text[⋆] ['{'程序'}'some-text[⋆]][⋆]

并且通过改变行结构和缩进来方便地打印出这样的程序。

程序的操作描述是处理文本,特别是以特殊方式缩进 C 代码或类似代码。如果我们从 列表 3.1 中将 文本 输入到这个程序中,我们会看到以下输出:

终端
 **0**     > ./code/basic_blocks < code/heron.c
 **1**   | #include <stdlib.h>
 **2**   | #include <stdio.h>
 **3**   | /* lower and upper iteration limits centered around 1.0 */
 **4**   | static double const eps1m01 = 1.0 - 0x1P-01;
 **5**   | static double const eps1p01 = 1.0 + 0x1P-01;
 **6**   | static double const eps1m24 = 1.0 - 0x1P-24;
 **7**   | static double const eps1p24 = 1.0 + 0x1P-24;
 **8**   | int main(int argc, char* argv[argc+1])
 **9**   >| for (int i = 1; i < argc; ++i)
**10**   >>| // process args
**11**   >>| double const a = strtod(argv[i], 0); // arg -> double
**12**   >>| double x = 1.0;
**13**   >>| for (;;)
**14**   >>>| // by powers of 2
**15**   >>>| double prod = a*x;
**16**   >>>| if (prod < eps1m01)        x *= 2.0;
**17**   >>>| else if   (eps1p01 < prod) x *= 0.5;
**18**   >>>| else break;
**19**   >>>|
**20**   >>| for (;;)
**21**   >>>| // Heron approximation
**22**   >>>| double prod = a*x;
**23**   >>>| if ((prod < eps1m24) || (eps1p24 < prod))
**24**   >>>| x *= (2.0 - prod);
**25**   >>>| else break;
**26**   >>>|
**27**   >>| printf("heron: a=%.5e,\tx=%.5e,\ta*x=%.12f\n",
**28**   >>| a, x, a*x);
**29**   >>|
**30**   >| return EXIT_SUCCESS;
**31**   >|

因此 basic_blocks “吞噬”大括号 {} 并使用一系列 > 字符缩进代码:每个嵌套级别的大括号 {} 添加一个 >

为了从高层次了解这个函数是如何实现这一点的,并且抽象掉你还不了解的所有函数和变量,请查看从第 79 行开始的 switch 语句以及围绕它的 for 循环。它根据当前字符进行切换。区分了三种不同的情况。最简单的是 default 情况:打印一个普通字符,字符前进,并开始下一次迭代。

另外两种情况处理 {} 字符。如果我们遇到一个开括号,我们知道我们必须使用一个额外的 > 来缩进文本。因此,我们再次递归调用相同的函数 descend;见第 82 行。另一方面,如果遇到一个闭括号,我们转到 ASCEND 并终止这个递归级别。递归深度本身由变量 dp[0] 处理,它在进入时(第 66 行)增加,在退出时(第 94 行)减少。

如果你第一次尝试理解这个程序,其余的都是噪音。这些噪音有助于处理异常情况,例如行尾或左右括号的过剩。我们将在稍后更详细地看到这一切是如何工作的。

17.2. 排序

在我们能够查看程序控制流如何以意想不到的方式改变细节之前,我们必须更好地理解 C 语句的正常顺序保证了什么,以及它没有保证什么。我们在第 4.5 节中看到,C 表达式的评估不一定遵循它们书写的字典顺序。例如,函数参数的评估可以按任何顺序发生。构成参数的不同表达式甚至可以根据编译器的自由裁量权或根据执行时的资源可用性交错。我们说函数参数表达式是非序列化的

建立仅适用于评估的宽松规则有几个原因。其中之一是允许轻松实现优化编译器。与其它编程语言相比,编译代码的效率一直是 C 语言的一个强项。

但另一个原因是,C 语言在缺乏令人信服的数学或技术基础时不会添加任意限制。在数学上,+运算符中的两个操作数 a 和 b 是可以自由交换的。强加一个评估顺序将破坏这一规则,并且关于 C 程序的讨论将变得更加复杂。

在没有线程的情况下,C 语言对这一点的形式化大部分是通过序列点来完成的。这些是程序语法规范中的点,它们强加了执行的序列化。但我们也将在后面看到一些额外的规则,这些规则强制某些表达式评估之间的顺序,而这些表达式并不暗示序列点。

在高层次上,一个 C 程序可以看作是一系列一个接一个达到的序列点,而这样的序列点之间的代码可以按任何顺序执行,可以交错,或者遵守某些其他顺序约束。例如,在最简单的情况下,当两个语句由一个;分隔时,序列点之前的语句是序列化的,在序列点之后的语句之前。

但是,即使序列点的存在也可能不会在两个表达式之间强加特定的顺序:它只强加存在某种顺序。为了说明这一点,考虑以下代码,它是定义良好的

sequence_point.c
 **3**   unsigned add(unsigned* x, unsigned const* y) {
 **4**     return *x += *y;
 **5**   }
 **6**   int main(void) {
 **7**     unsigned a = 3;
 **8**     unsigned b = 5;
 **9**     printf("a = %u, b = %u\n", add(&a, &b), add(&b, &a));
**10**   }

从第 4.5 节中记住,printf的两个参数可以按任何顺序评估,我们很快就会看到的序列点规则将告诉我们,对添加函数的调用强加序列点。因此,对于这段代码,我们有两种可能的输出。要么第一个加法操作完全执行,然后是第二个,要么反过来。对于第一种可能性,我们有

  • a 被改为8,并返回该值。

  • b 被改为13,并返回该值。

这样的执行输出是

Terminal
**0**      a = 8, b = 13

对于第二种,我们得到

  • b 被改为8,并返回该值。

  • a 被改为11,并返回该值。

输出如下

终端
**0**      a = 11, b = 8

即,尽管此程序的行为是定义的,但其结果并不完全由 C 标准确定。C 标准应用于此类情况的具体术语是这两个调用是有序不确定的。这不仅仅是一个理论讨论;两个常用的开源 C 编译器GCCClang在这个简单的代码上有所不同。让我再次强调:所有这些都是定义良好的行为。不要期望编译器会警告你这样的问题。

Takeaway 17.1

函数中的副作用可能导致不确定的结果

这里是一个所有用 C 语法定义的序列点的列表:

  • 语句的结束,无论是分号(;)还是闭合花括号(}

  • 在逗号运算符(,)之前的表达式结束^([4])

    小心:分隔函数参数的逗号不属于此类。

  • 声明的结束,无论是分号(;)还是逗号(,)^([5])

    这也适用于结束枚举常量声明的逗号。

  • ifswitchforwhile、条件评估(?:)和短路评估(||&&)的控制表达式的结束

  • 在评估函数标识符(通常是函数名)和函数调用的函数参数之后^([6])但在实际调用之前

    这将函数标识符与函数参数放在同一级别。

  • return语句的结束

除了序列点隐含的顺序限制之外,还有其他顺序限制。前两个或多或少是明显的,但仍然应该声明:

Takeaway 17.2

任何操作符的具体操作是在其所有操作数评估之后有序的

Takeaway 17.3

使用任何赋值、增量或减量运算符更新对象的效果是在其操作数评估之后有序的

对于函数调用,还有一个额外的规则,即函数的执行总是在任何其他表达式之前完成。

Takeaway 17.4

函数调用相对于调用者的所有评估是有序的

正如我们所看到的,这可能是有序不确定的,但仍然是有序的。

另一个导致有序不确定表达式的原因来自初始化器。

Takeaway 17.5

数组或结构类型初始化列表表达式是有序不确定的

最后但同样重要的是,一些序列点也适用于 C 库:

  • 在 IO 函数的格式说明符动作之后

  • 在任何 C 库函数返回之前^([7])

    注意,作为宏实现的库函数可能不会定义一个序列点。

  • 在调用用于搜索和排序的比较函数之前和之后

后两个代码片段为 C 库函数施加了类似于普通函数的规则。这是必要的,因为 C 库本身可能不一定是用 C 实现的。

17.3. 短跳转

我们已经看到了一个会中断 C 程序常见控制流特性的功能:goto。正如你希望从第 14.5 节中记得的那样,这是通过两个构造实现的:labels标记代码中的位置,goto语句跳转到这些标记的位置在同一函数内

我们还看到,这样的跳转对局部对象的生存期和可见性有复杂的含义。特别是,在循环内部和由goto重复的一组语句内部定义的对象的生存期存在差异。考虑以下两个代码片段:

see ISO 9899:2011 6.5.2.5 p16

**1**   size_t* ip = 0
**2**   while(something)
**3**     ip = &(size_t){ fun() };        /* Life ends with while    */
**4**                                     /* Good: resource is freed */
**5**   printf("i is %d", *ip)            /* Bad: object is dead     */

**1**   size_t* ip = 0
**2**   RETRY:
**3**     ip = &(size_t){ fun() };        /* Life continues           */
**4**   if (condition) goto RETRY;
**5**                                     /* Bad: resource is blocked */
**6**   printf("i is %d", *ip)            /* Good: object is alive    */

两者都是通过使用复合字面量在循环中定义局部对象。复合字面量的地址被分配给指针,因此对象可以在循环外部保持可访问,例如,可以在printf语句中使用。

它们看起来在语义上是等价的,但实际上并非如此。对于第一种情况,与复合字面量对应的对象仅存在于while语句的作用域内。

要點 17.6

每次迭代都会定义局部对象的一个新实例

因此,对*ip表达式中的对象的访问是无效的。在示例中省略printf时,while循环的优势在于可以重用复合字面量占用的资源。

对于第二个示例,没有这样的限制:复合字面量定义的作用域是整个周围的块。因此,对象在离开该块之前是活跃的(要点 13.22)。这并不一定是好事:对象占用了本可以重新分配的资源。

在不需要printf语句(或类似访问)的情况下,第一个代码片段更清晰,并且有更好的优化机会。因此,在大多数情况下,它更可取。

要点 17.7

goto应仅用于控制流的异常变化

在这里,exceptional通常意味着我们遇到了需要局部清理的过渡性错误条件,例如我们在第 14.5 节中展示的那样。但它也可能意味着特定的算法条件,正如我们在列表 17.1 中看到的那样。

在这里,两个标签NEW_LINEASCEND,以及两个宏LEFTRIGHT反映了解析的实际状态。NEW_LINE是打印新行时的跳转目标,而ASCEND在遇到}或流结束时使用。LEFTRIGHT在检测到左或右花括号时用作case标签。

在这里使用goto和标签的原因是,两种状态在函数中的两个不同位置被检测到,并且在不同级别的嵌套中。此外,标签的名称反映了它们的目的,从而提供了有关结构的信息。

17.4. 函数

函数descend比扭曲的局部跳转结构更复杂:它也是递归的。正如我们所见,C 处理递归函数相当简单。

17.8 小结

每个函数调用定义了一个局部对象的实例。

所以通常情况下,同时活跃的同函数的不同递归调用不会相互影响;每个人都拥有自己的程序状态副本。

图 17.1. 函数调用的控制流:return跳转到调用后的下一个指令。

但在这里,由于指针的存在,这个原则被削弱了。buffer 和 dp 指向的数据被修改了。对于 buffer 来说,这可能是不可避免的:它将包含我们正在读取的数据。但 dp 可以(并且应该)被一个简单的unsigned参数替换.^([[Exs 1]])我们的实现只有 dp 作为指针,因为我们想能够在发生错误时跟踪嵌套的深度。所以如果我们抽象出我们尚未解释的longjmp调用,使用这样的指针是糟糕的。程序的状态更难以跟踪,我们错过了优化机会.^([[Exs 2]])

^([Exs 1])

descend修改为接收一个unsigned深度而不是指针。

^([Exs 2])

将初始版本的汇编输出与你的没有 dp 指针的版本进行比较。

在我们的特定例子中,因为 dp 是restrict修饰的,并且没有传递给 longjump 的调用(稍后讨论),它只在开始时增加,在结束时减少,所以 dp[0]在函数返回之前恢复到其原始值。因此,从外部看,似乎descend根本没有改变这个值。

如果descend的函数代码在调用侧可见,一个好的优化编译器可以推断出 dp[0]在调用过程中没有改变。如果longjmp不是特殊的,这将是一个很好的优化机会。我们很快就会看到longjmp的存在如何使这种优化无效,并导致一个微妙的错误。

17.5. 长跳转

图 17.2. 使用setjmplongjmp的控制流:longjmp跳转到由setjmp标记的位置。

我们的函数descend也可能遇到无法修复的异常情况。我们使用枚举类型来命名它们。在这里,如果stdout无法写入,则达到eofOutinterrupted指的是程序运行时接收到的异步信号。我们将在后面讨论这个概念:

basic_blocks.c
**32**   /**
**33**    ** @brief Exceptional states of the parse algorithm
**34**    **/
**35**   enum state {
**36**     execution = 0,      //*< Normal execution
**37**     plusL,              //*< Too many left braces
**38**     plusR,              //*< Too many right braces
**39**     tooDeep,            //*< Nesting too deep to handle
**40**     eofOut,             //*< End of output
**41**     interrupted,        //*< Interrupted by a signal
**42**   };

我们使用函数 longjmp 来处理这些情况,并将相应的调用直接放在代码中我们认识到达到这种条件的地方:

  • tooDeep 在函数的开始很容易被识别。

  • plusL 在我们遇到输入流末尾而我们不在第一次递归级别时可以被检测到。

  • 当我们在第一次递归级别遇到关闭的 } 时,会发生 plusR

  • 如果对 stdout 的写入返回了文件结束(EOF)条件,则会到达 eofOut

  • 在从 stdin 读取每一行新内容之前都会检查 interrupted

由于 stdout 是按行缓冲的,所以我们只在写入 '\n' 字符时检查 eofOut。这发生在短函数 end_line 内部:

basic_blocks.c
**48**   char const* end_line(char const* s, jmp_buf jmpTarget) {
**49**     if (putchar('\n') == EOF) longjmp(jmpTarget, eofOut);
**50**     return skipspace(s);
**51**   }

函数 longjmp 伴随一个宏 setjmp,它用于建立 longjmp 调用可能引用的跳转目标。头文件 setjmp.h 提供以下原型:

<setjmp.h>

_Noreturn void longjmp(jmp_buf target, int condition);
int setjmp(jmp_buf target);    // Usually a macro, not a function

函数 longjmp 也有 _Noreturn 属性,因此我们可以确信,一旦我们检测到其中一个异常条件,当前对 descend 的调用将永远不会继续。

摘要 17.9

longjmp 永远不会返回到调用者

这对优化器来说是非常有价值的信息。在 descend 中,longjmp 在五个不同的地方被调用,编译器可以大大简化分支的分析。例如,在 !act 测试之后,可以假设在进入 for 循环时 act 是非空的。

正常的语法标签仅在它们声明的函数内作为 goto 目标有效。相比之下,一个 jmp_buf 是一个不透明的对象,可以在任何地方声明,只要它存在且其内容有效,就可以使用。在 descend 中,我们只使用一个类型的 跳转目标 jmp_buf,我们将其声明为一个局部变量。这个跳转目标是在作为 descend 接口的基函数 basic_blocks 中设置的;参见 列表 17.2。这个函数主要由一个处理所有不同条件的巨大 switch 语句组成。

列表 17.2. 递归下降解析器的用户界面
**100**   void basic_blocks(void) {
**101**     char buffer[maxline];
**102**     unsigned depth = 0;
**103**     char const* format =
**104**       "All %0.0d%c %c blocks have been closed correctly\n";
**105**     jmp_buf jmpTarget;
**106**     switch (setjmp(jmpTarget)) {
**107**     case 0:
**108**       descend(0, &depth, maxline, buffer, jmpTarget);
**109**       break;
**110**     case plusL:
**111**       format =
**112**         "Warning: %d %c %c blocks have not been closed properly\n";
**113**       break;
**114**     case plusR:
**115**       format =
**116**         "Error: closing too many (%d) %c %c blocks\n";
**117**       break;
**118**     case tooDeep:
**119**       format =
**120**         "Error: nesting (%d) of %c %c blocks is too deep\n";
**121**       break;
**122**     case eofOut:
**123**       format =
**124**         "Error: EOF for stdout at nesting (%d) of %c %c blocks\n";
**125**       break;
**126**     case interrupted:
**127**       format =
**128**         "Interrupted at level %d of %c %c block nesting\n";
**129**       break;
**130**     default:;
**131**       format =
**132**         "Error: unknown error within (%d) %c %c blocks\n";
**133**     }
**134**     fflush(stdout);
**135**     fprintf(stderr, format, depth, LEFT, RIGHT);
**136**     if (interrupt) {
**137**       SH_PRINT(stderr, interrupt,
**138**                "is somebody trying to kill us?");
**139**       raise(interrupt);
**140**     }
**141**   }

当我们通过正常控制流程来到这里时,会采取那个 switch0 分支。这是 setjmp 的一个基本原理。

摘要 17.10

*当通过正常控制流程到达时,对 * setjmp * 的调用会将调用位置标记为跳转目标并返回 * 0 *。

正如我们所说的,当调用longjmp时,jmpTarget 必须处于活动状态且有效。因此,对于auto变量,变量的声明范围不得被离开;否则它将是无效的。对于有效性,当我们调用longjmp时,setjmp的所有上下文都必须仍然处于活动状态。在这里,我们通过将 jmpTarget 声明在与setjmp调用相同的范围内来避免复杂性。

摘要 17.11

离开对 setjmp 的调用范围会使跳转目标无效。

一旦我们进入case 0并调用descend,我们可能会遇到一些异常条件,并调用longjmp来终止解析算法。这将控制权交回 jmpTarget 中标记的调用位置,就像我们从setjmp的调用中返回一样。唯一的可见差异是,现在返回值是我们传递给longjmp的第二个参数的条件。例如,如果我们在一个对descend的递归调用开始时遇到了tooDeep条件,并调用了longjmp(jmpTarget, **tooDeep**),我们会跳回到switch的控制表达式,并接收tooDeep的返回值。然后执行将继续在相应的case标签处继续。

摘要 17.12

longjmp 的调用会直接将控制权转移到由 setjmp 设置的位置,就像它返回了条件参数一样。

然而,请注意,已经采取了预防措施,以确保无法作弊并第二次重新采取正常路径。

摘要 17.13

0 作为 condition 参数传递给 longjmp 会被替换为 1**.

setjmp/longjmp机制非常强大,可以避免从函数调用中返回的整个级联。在我们的例子中,如果我们允许输入程序的嵌套深度最大为 30,那么当有 30 个对descend的活跃递归调用时,就会检测到tooDeep条件。常规的错误返回策略会return到这些调用中的每一个,并在每个级别上做一些工作。对longjmp的调用允许我们缩短所有这些返回,并直接在switchbasic_blocks中继续执行。

由于setjmp/longjmp允许做出一些简化的假设,这个机制出奇地高效。根据处理器架构的不同,它通常不需要超过 10 到 20 条汇编指令。库实现所遵循的策略通常很简单:setjmp将必要的硬件寄存器(包括栈和指令指针)保存在jmp_buf对象中,而longjmp则从那里恢复它们,并将控制权交回存储的指令指针.^([9])

对于这个词汇,你可能需要阅读或重新阅读第 13.5 节。

setjmp 在其返回方面所做的简化之一是。它的规范说明它返回一个 int 值,但这个值不能在任意表达式中使用。

Takeaway 17.14

setjmp 只能在条件表达式的简单比较中使用。

因此,它可以直接用在 switch 语句中,就像我们的例子一样,并且可以测试其 ==< 等等,但 setjmp 的返回值不能用于赋值。这保证了 setjmp 的值只与一组已知的值进行比较,并且从 longjmp 返回时环境的变化可能只是一个控制条件效果的特定硬件寄存器。

正如我们所说的,setjmp 调用通过保存和恢复执行环境所做的保存是最小的。只保存和恢复了一组必要的硬件寄存器。没有采取预防措施来使局部优化保持一致,甚至没有考虑到调用位置可能被第二次访问。

Takeaway 17.15

优化与 setjmp 的调用相互作用不好

如果你执行并测试示例中的代码,你会看到我们简单使用 setjmp 的确存在一个问题。如果我们通过提供一个缺少闭合 } 的部分程序来触发 plusL 条件,我们预计诊断信息将类似于

终端
**0**      Warning: 3 { } blocks have not been closed properly

根据你的编译优化级别,你可能会看到 3,而不是 0,无论输入程序如何。这是因为优化器基于假设 switch 情况是互斥的进行分析。它只期望深度值在执行通过 case 0descend 调用时发生变化。通过检查 descend(见第 17.4 节),我们知道深度值总是在返回之前恢复到其原始值,因此编译器可能假设该值不会通过此代码路径改变。然后,其他情况都不会改变深度,因此编译器可以假设深度对于 fprintf 调用始终是 0

因此,优化不能对在 setjmp 的正常代码路径中更改并在其中一个异常路径中引用的对象做出正确的假设。对此只有一个对策。

Takeaway 17.16

longjmp 期间修改的对象必须是 volatile*。

语法上,volatile 修饰符的作用类似于我们遇到的其它修饰符 constrestrict。如果我们用那个修饰符声明深度

  unsigned volatile depth = 0;

并相应地修改 descend 的原型,所有对这个对象的访问都将使用存储在内存中的值。试图对其值做出假设的优化将被阻止。

Takeaway 17.17

volatile 对象每次访问时都会从内存中重新加载。

Takeaway 17.18

volatile 对象在每次修改时都会存储到内存中。

因此,volatile 对象受到优化的保护,或者,如果我们从负面来看,它们会抑制优化。因此,只有在你确实需要它们时才应该将对象设置为 volatile.^([[Exs 3]])

^([Exs 3])

如果你的 descend 版本将深度作为值传递,并且遇到 plusL 条件,可能无法正确传播深度。确保它将该值复制到可以被 basic_blocks 中的 fprintf 调用使用的对象中。

最后,注意 jmp_buf 类型的某些细微差别。记住,它是一个不透明类型:你不应该对其结构或其单个字段做出任何假设。

17.19 总结

jmp_buftypedef 隐藏了一个数组类型。

由于它是一个不透明类型,我们不知道关于数组的基础类型,例如 jmp_buf_base。因此:

  • 类型为 jmp_buf 的对象不能被赋值。

  • jmp_buf 函数参数被重写为指向 jmp_buf_base 的指针。

  • 这样的函数总是引用原始对象,而不是副本。

在某种程度上,这模拟了一个按引用传递机制,对于像 C++ 这样的其他编程语言,有显式的语法。通常,使用这个技巧不是一个好主意:jmp_buf 变量的语义取决于它是局部声明的还是作为函数参数;例如,在 basic_blocks 中,该变量不可赋值,而在 descend 中,类似的功能参数是可修改的,因为它被重写为指针。此外,我们不能为函数参数使用现代 C 的更具体的声明,例如

  jmp_buf_base jmpTarget[restrict const static 1]

为了坚持指针在函数内部不应更改,它不能为 0,并且对其访问可以被认为是函数的唯一访问。截至今天,我们不会这样设计这个类型,你不应该尝试将这个技巧用于你自己的类型的定义。

17.6. 信号处理程序

正如我们所见,setjmp/longjmp 可以用来处理我们在代码执行过程中自己检测到的异常条件。一个 信号处理程序 是一种处理不同异常条件的工具:这些条件是由程序外部的事件触发的。技术上,这类外部事件有两种类型:硬件中断,也称为 陷阱同步信号,以及 软件中断异步信号

第一种情况发生在处理设备遇到它无法处理的严重故障时:例如,除以零,访问不存在的内存区,或在操作更宽整数类型的指令中使用未对齐的地址。此类事件是与程序执行 同步 的。它直接由故障指令引起,因此可以始终知道中断是在哪个特定指令中引发的。

第二种情况发生在操作系统或运行时系统决定我们的程序应该终止,因为某个截止日期已过,用户已发出终止请求,或者我们所知的世界即将结束。此类事件是 异步 的,因为它可能发生在多阶段指令的中间,使执行环境处于中间状态。

大多数现代处理器都内置了一个处理硬件中断的功能:一个 中断向量表。该表由平台所知的不同硬件故障进行索引。其条目是指向过程,中断处理程序 的指针,当特定故障发生时执行。因此,如果处理器检测到此类故障,执行将自动从用户代码切换,并执行中断处理程序。这种机制是不可移植的,因为故障的名称和位置在不同平台之间是不同的。处理起来很繁琐,因为要编写一个简单的应用程序,我们必须为所有中断提供所有处理程序。

C 的信号处理程序为我们提供了一个抽象,以可移植的方式处理硬件和软件两种类型的中断。它们的工作方式与我们描述的硬件中断类似,但

  • (一些)故障的名称是标准化的。

  • 所有故障都有一个默认处理程序(这通常是实现定义的)。

  • 并且(大多数)处理程序可以专门化。

在该列表的每一项中,都有括号内的 保留意见,因为仔细观察后,似乎 C 的信号处理程序接口相当基础;所有平台都有自己的扩展和特殊规则。

取走 17.20

C 的信号处理接口是最小的,并且仅应用于基本情况

处理的信号的控制流在 图 17.3 中显示。正常控制流在应用程序不可预见的地方被中断,信号处理程序函数介入并执行一些任务,然后控制恢复到中断时的确切位置和状态。

图 17.3. 中断 return 跳转到的位置是中断发生的位置。

接口定义在头文件 signal.h 中。C 标准区分了六个不同的值,称为 信号编号。以下是在那里给出的确切定义。其中三个值通常由硬件中断^([10)] 引起:

¹⁰

标准称为计算异常。

<signal.h>

SIGFPE 错误的算术运算,例如零除或导致溢出的操作

SIGILL 检测到无效的功能映像,例如无效的指令

SIGSEGV 对存储的无效访问

其他三个通常由软件或用户触发:

SIGABRT 非正常终止,例如由**abort**函数发起

SIGINT 接收到交互式注意信号

SIGTERM 发送给程序的终止请求

具体的平台将会有其他的信号编号;标准为该目的保留了所有以SIG开头的标识符。它们的使用在 C 标准中是未定义的,但因此并没有什么不好。这里的未定义确实意味着它所说的那样:如果你使用它,它必须由 C 标准以外的其他权威机构定义,例如你的平台提供商。因此,你的代码的可移植性会降低。

处理信号有两种标准方式,这两种方式也由符号常量表示。SIG_DFL 恢复特定信号的平台的默认处理程序,而SIG_IGN 表示要忽略该信号。然后,程序员可以编写自己的信号处理程序。我们解析器的处理程序看起来相当简单:

basic_blocks.c
**143**   /**
**144**    ** @brief A minimal signal handler
**145**    **
**146**    ** After updating the signal count, for most signals this
**147**    ** simply stores the signal value in "interrupt" and returns.
**148**    **/
**149**   static void signal_handler(int sig) {
**150**     sh_count(sig);
**151**     switch (sig) {
**152**     case SIGTERM: quick_exit(EXIT_FAILURE);
**153**     case SIGABRT: _Exit(EXIT_FAILURE);
**154**   #ifdef SIGCONT
**155**       // continue normal operation
**156**     case SIGCONT: return;
**157**   #endif
**158**     default:
**159**       /* reset the handling to its default */
**160**       signal(sig, SIG_DFL);
**161**       interrupt = sig;
**162**       return;
**163**     }
**164**   }

如您所见,这样的信号处理程序接收信号编号 sig 作为参数,并根据该编号switch。这里我们为信号编号SIGTERMSIGABRT提供了处理措施。所有其他信号都只是通过将那个编号的处理程序重置为其默认状态,将编号存储在我们的全局变量 interrupt 中,然后返回到中断发生的位置。

信号处理程序的类型必须与以下兼容:^([11])

¹¹

尽管如此,标准并没有定义这样的类型

sighandler.h
**71**   /**
**72**    ** @brief Prototype of signal handlers
**73**    **/
**74**   typedef void sh_handler(int);

也就是说,它接收一个信号编号作为参数,并且不返回任何内容。因此,这个接口相当有限,不允许我们传递足够的信息,特别是关于信号发生的位置和情况的信息。

信号处理程序是通过调用signal建立的,正如我们在函数signal_handler中看到的那样。在这里,它只是用来将信号处理程序的状态重置为默认状态。signalsignal.h提供的两个函数接口之一:

<signal.h>

sh_handler* signal(int, sh_handler*);
int raise(int);

signal的返回值是信号之前活动的处理程序,或者在发生错误时返回特殊值SIG_ERR。在信号处理程序内部,signal应该只用于更改接收到的相同信号编号的处理程序的状态。以下函数与signal具有相同的接口,但提供了关于调用成功程度的更多信息:

sighandler.c
 **92**   /**
 **93**    ** @ brief Enables a signal handler and catches the errors
 **94**    **/
 **95**   sh_handler* sh_enable(int sig, sh_handler* hnd) {
 **96**     sh_handler* ret = signal(sig, hnd);
 **97**     if (ret == SIG_ERR) {
 **98**       SH_PRINT(stderr, sig, "failed");
 **99**       errno = 0;
**100**     } else if (ret == SIG_IGN) {
**101**       SH_PRINT(stderr, sig, "previously ignored");
**102**     } else if (ret && ret != SIG_DFL) {
**103**       SH_PRINT(stderr, sig, "previously set otherwise");
**104**     } else {
**105**         SH_PRINT(stderr, sig, "ok");
**106**     }
**107**     return ret;
**108**   }

我们解析器的 main 函数使用这个循环来为它能够处理的全部信号号建立信号处理程序:

basic_blocks.c
**187**   // Establishes signal handlers
**188**   for (unsigned i = 1; i < sh_known; ++i)
**189**     sh_enable(i, signal_handler);

例如,在我的机器上,程序启动时提供以下信息:

Terminal
 **0**   sighandler.c:105: #1 (0 times),      unknown signal number, ok
 **1**   sighandler.c:105: SIGINT (0 times),  interactive attention signal, ok
 **2**   sighandler.c:105: SIGQUIT (0 times), keyboard quit, ok
 **3**   sighandler.c:105: SIGILL (0 times),  invalid instruction, ok
 **4**   sighandler.c:105: #5 (0 times),      unknown signal number, ok
 **5**   sighandler.c:105: SIGABRT (0 times), abnormal termination, ok
 **6**   sighandler.c:105: SIGBUS (0 times),  bad address, ok
 **7**   sighandler.c:105: SIGFPE (0 times),  erroneous arithmetic operation, ok
 **8**   sighandler.c:98: SIGKILL (0 times),  kill signal, failed: Invalid argument
 **9**   sighandler.c:105: #10 (0 times),     unknown signal number, ok
**10**   sighandler.c:105: SIGSEGV (0 times), invalid access to storage, ok
**11**   sighandler.c:105: #12 (0 times),     unknown signal number, ok
**12**   sighandler.c:105: #13 (0 times),     unknown signal number, ok
**13**   sighandler.c:105: #14 (0 times),     unknown signal number, ok
**14**   sighandler.c:105: SIGTERM (0 times), termination request, ok
**15**   sighandler.c:105: #16 (0 times),     unknown signal number, ok
**16**   sighandler.c:105: #17 (0 times),     unknown signal number, ok
**17**   sighandler.c:105: SIGCONT (0 times), continue if stopped, ok
**18**   sighandler.c:98: SIGSTOP (0 times),  stop process, failed: Invalid argument

第二个函数 raise 可以用来将指定的信号发送到当前执行。我们已经在 basic_blocks 的末尾使用过它,将我们捕获的信号发送到预安装的处理程序。

信号机制类似于 setjmp/longjmp:当前执行状态被记住,控制流传递给信号处理程序,从那里返回则恢复原始执行环境并继续执行。不同之处在于没有通过调用 setjmp 标记的特殊执行点。

Takeaway 17.21

信号处理程序可以在执行的任何点介入。

在我们这个例子中,有趣的信号号是软件中断 SIGABRTSIGTERMSIGINT,通常可以通过像 Ctrl-C 这样的魔法键组合发送给应用程序。前两个将调用 _Exitquick_exit。因此,如果程序收到这些信号,执行将被终止:第一个不会调用任何清理处理程序;第二个将通过与 at_quick_exit 注册的清理处理程序列表进行。

SIGINT 将选择信号处理程序的 default 情况,因此最终将返回到中断点。

Takeaway 17.22

从信号处理程序返回后,执行将精确地恢复到中断点。

如果中断发生在函数 descend 中,它将首先继续执行,就像什么都没发生一样。只有当处理完当前输入行并需要新的一行时,才会检查变量中断并调用 longjmp 来降低执行。实际上,中断前后唯一的不同之处在于变量中断的值已改变。

我们还对 C 标准未描述的信号号进行了特殊处理,即 SIGCONT,但在我的操作系统POSIX中。为了保持可移植性,使用此信号号受到保护。此信号旨在继续执行之前已停止的程序:即执行已被挂起的情况。在这种情况下,唯一要做的就是返回。根据定义,我们不希望程序状态有任何修改。

因此,与setjmp/longjmp机制相比,另一个区别是对于它,setjmp的返回值改变了执行路径。另一方面,信号处理程序不应该改变执行状态。我们必须发明一个合适的约定来从信号处理程序传递信息到正常程序。至于longjmp,可能被信号处理程序更改的对象必须具有volatile属性:编译器无法知道中断处理程序可能在何处介入,因此它关于通过信号处理改变变量的所有假设都可能是不正确的。

但是信号处理程序还面临另一个困难:

Takeaway 17.23

C 语句可能对应于多个处理器指令。

例如,一个double x 可以存储在两个常规机器字中,将 x 写入内存的赋值可能需要两个单独的汇编语句来写入两个 halves。

当考虑我们迄今为止讨论的正常程序执行时,将 C 语句拆分成多个机器语句没有问题。这样的细微之处不能直接观察到.^([12]) 使用信号时,情况就不同了。如果这样的赋值在信号发生时被拆分在中间,那么只有 x 的一半被写入,信号处理程序将看到它的不一致版本。一半对应于之前的值,另一半对应于新的值。这种僵尸表示(一半在这里,一半在那里)甚至可能不是double的有效值。

¹²

它们只能从程序外部观察到,因为这样的程序可能需要比预期更长的时间。

Takeaway 17.24

信号处理程序需要具有不可中断操作的类型。

在这里,术语不可中断操作指的是在信号处理程序上下文中始终看起来是不可分割的操作:要么看起来没有开始,要么看起来已经完成。这通常并不意味着它是不可分割的,只是我们无法观察到这样的分割。运行时系统可能需要在信号处理程序介入时强制该属性。

C 有三种不同类别的类型提供不可中断操作:

  1. 类型sig_atomic_t,一个最小宽度为 8 位的整型

  2. 类型atomic_flag

  3. 所有其他具有无锁属性的原子类型

第一个是所有历史 C 平台都有的。将其用作存储信号编号,如我们示例中的变量中断,是合适的,但除此之外,其保证相当有限。只有内存加载(评估)和存储(赋值)操作被认为是不可中断的;其他操作不是,并且宽度可能相当有限。

Takeaway 17.25

类型为sig_atomic_t的对象不应用作计数器。

这是因为简单的 ++ 操作实际上可能被分成三个步骤(加载、增加和存储),并且它可能很容易溢出。后者可能会触发硬件中断,如果我们已经在信号处理程序内部,这将是真的非常糟糕。

后两类仅在 C11 中引入,以展望线程(见第十八部分),并且仅在平台未定义特征测试宏 __STDC_NO_ATOMICS__ 并且已包含头文件 stdatomic.h 时存在。函数 sh_count 使用这些功能,我们将在稍后看到示例。

<stdatomic.h>

由于异步信号的信号处理程序不应以不受控制的方式访问或更改程序状态,因此它们不能调用会这样做其他函数。可以在这种环境中使用的函数被称为 异步信号安全。通常,从接口规范中很难知道一个函数是否具有此属性,而 C 标准只为少数几个函数保证了这一点:

  • 终止程序的 _Noreturn 函数 abort_Exitquick_exit

  • signal 对于调用信号处理程序的相同信号号

  • 一些作用于原子对象的函数(将在稍后讨论)

吸收要点 17.26

除非另有说明,否则 C 库函数不是异步信号安全

因此,根据 C 标准本身,信号处理程序不能调用 exit 或执行任何形式的 I/O,但它可以使用 quick_exitat_quick_exit 处理程序来执行一些清理代码。

如前所述,C 对信号处理程序的规范是最小的,并且通常特定平台会允许更多。因此,使用信号的便携式编程是繁琐的,并且通常应按级联方式处理特殊条件,就像我们在示例中看到的那样:

  1. 可以在本地检测和处理特殊条件可以通过使用 goto 对有限数量的标签进行处理。

  2. 当不需要或无法在本地处理特殊条件时,应从函数返回特殊值,例如返回空指针而不是对象的指针。

  3. 当异常返回会非常昂贵或复杂时,可以使用 setjmp/longjmp 来处理改变全局程序状态的特殊条件。

  4. 导致发出信号的异常条件可以由信号处理程序捕获,但应在处理程序的正常执行流程返回后处理。

由于 C 标准指定的信号列表本身是最小的,处理不同的可能条件变得复杂。以下显示了我们可以如何处理一组超出 C 标准指定的信号号的信号:

sighandler.c
 **7**   #define SH_PAIR(X, D) [X] = { .name = #X, .desc = "" D "", }
 **8**
 **9**   /**
**10**    ** @brief Array that holds names and descriptions of the
**11**    ** standard C signals
**12**    **
**13**    ** Conditionally, we also add some commonly used signals.
**14**    **/
**15**   sh_pair const sh_pairs[] = {
**16**     /* Execution errors */
**17**     SH_PAIR(SIGFPE, "erroneous arithmetic operation"),
**18**     SH_PAIR(SIGILL, "invalid instruction"),
**19**     SH_PAIR(SIGSEGV, "invalid access to storage"),
**20**   #ifdef SIGBUS
**21**     SH_PAIR(SIGBUS, "bad address"),
**22**   #endif
**23**     /* Job control */
**24**     SH_PAIR(SIGABRT, "abnormal termination"),
**25**     SH_PAIR(SIGINT, "interactive attention signal"),
**26**     SH_PAIR(SIGTERM, "termination request"),
**27**   #ifdef SIGKILL
**28**     SH_PAIR(SIGKILL, "kill signal"),
**29**   #endif
**30**   #ifdef SIGQUIT
**31**     SH_PAIR(SIGQUIT, "keyboard quit"),
**32**   #endif
**33**   #ifdef SIGSTOP
**34**     SH_PAIR(SIGSTOP, "stop process"),
**35**   #endif
**36**   #ifdef SIGCONT
**37**     SH_PAIR(SIGCONT, "continue if stopped"),
**38**   #endif
**39**   #ifdef SIGINFO
**40**     SH_PAIR(SIGINFO, "status information request"),
**41**   #endif
**42**   };

在这里,宏只是初始化了一个类型为 sh_pair 的对象:

sighandler.h
**10**   /**
**11**    ** @brief A pair of strings to hold signal information
**12**    **/
**13**   typedef struct sh_pair sh_pair;
**14**   struct sh_pair {
**15**     char const* name;
**16**     char const* desc;
**17**   };

使用 #ifdef 条件确保可以使用非标准的信号名称,并且 SH_PAIR 中的指定初始化器允许我们以任何顺序指定它们。然后可以使用数组的大小来计算 sh_known 已知的信号数量:

sighandler.c
**44**   size_t const sh_known = (sizeof sh_pairs/sizeof sh_pairs[0]);

如果平台对原子操作有足够的支持,此信息也可以用来定义一个原子计数器数组,以便我们可以跟踪特定信号被提升的次数:

sighandler.h
**31**   #if ATOMIC_LONG_LOCK_FREE > 1
**32**   /**
**33**    ** @brief Keep track of the number of calls into a
**34**    ** signal handler for each possible signal.
**35**    **
**36**    ** Don't use this array directly.
**37**    **
**38**    ** @see sh_count to update this information.
**39**    ** @see SH_PRINT to use that information.
**40**    **/
**41**   extern _Atomic(unsigned long) sh_counts[];
**42**
**43**   /**
**44**    ** @brief Use this in your signal handler to keep track of the
**45**    ** number of calls to the signal @a sig.
**46**    **
**47**    ** @see sh_counted to use that information.
**48**    **/
**49**   inline
**50**   void sh_count(int sig) {
**51**     if (sig < sh_known) ++sh_counts[sig];
**52**   }
**53**
**54**   inline
**55**   unsigned long sh_counted(int sig){
**56**     return (sig < sh_known) ? sh_counts[sig] : 0;
**57**   }

使用 _Atomic 指定的对象可以使用与具有相同基类型的其他对象相同的运算符,这里是指 ++ 运算符。通常,这样的对象可以保证避免与其他线程(稍后讨论)的竞争条件,并且如果类型具有 lock-free 属性,则不可中断。后者在这里通过特征测试宏 ATOMIC_LONG_LOCK_FREE 进行测试。

这里使用的用户界面是 sh_countsh_counted。如果可用,它们将使用计数器数组,否则将用平凡函数替换:

sighandler.h
**59**   #else
**60**   inline
**61**   void sh_count(int sig) {
**62**     // empty
**63**   }
**64**
**65**   inline
**66**   unsigned long sh_counted(int sig){
**67**     return 0;
**68**   }
**69**   #endif

摘要

  • 即使没有并行线程或异步信号,C 代码的执行也不总是线性序列的。因此,某些评估的结果可能取决于编译器的排序选择。

  • setjmp/longjmp 是处理一系列嵌套函数调用中异常条件的强大工具。它们可能与优化交互,并要求某些变量使用 volatile 修饰符进行保护。

  • C 处理同步和异步信号的方式是基本的。因此,信号处理器应该尽可能少做工作,只需在全局标志中标记中断条件类型。然后它们应该切换回中断上下文并处理中断条件。

  • 信息只能通过使用 volatile sig_atomic_tatomic_flag 或其他无锁原子数据类型传递到和从信号处理器。

第十八章。线程

本章涵盖

  • 线程间控制

  • 初始化和销毁线程

  • 使用线程局部数据

  • 关键数据和关键部分

  • 通过条件变量进行通信

线程是控制流的一种变体,允许我们并发地执行多个 任务。在这里,一个任务是指程序要执行的工作的一部分,不同的任务之间可以没有或只有很少的交互。

我们的主要例子将是一个我们称之为B9的原始游戏,它是康威生命游戏的变体(参见 Gardner [1970])。它模拟了一个原始“细胞”矩阵,这些细胞根据非常简单的规则出生、生活和死亡。我们将游戏分为四个不同的任务,每个任务都迭代进行。单元格通过生命周期计算所有单元格的出生或死亡事件。终端中的图形表示通过绘图周期进行,这些周期更新得尽可能快。在这些周期之间是用户不规则的按键,允许用户在选定的位置添加单元格。图 18.1 显示了 B9 这些任务的示意图。

四个任务包括:

  • 绘制: 将细胞矩阵的图片绘制到终端;参见图 18.2。

  • 输入: 捕获按键,更新光标位置,并创建单元格

  • 更新: 将游戏的状态从生命周期更新到下一个生命周期

  • 账户:更新任务紧密耦合,并计算每个单元格的活着的邻近单元格数量

图 18.1. B9五个线程的控制流程

图片

图 18.2. B9 的截图,显示了几个单元格和光标位置

图片

每个这样的任务都是由一个线程执行的,该线程遵循自己的控制流程,就像它自己的一个简单程序。如果平台有多个处理器或核心,这些线程可以同时执行。即使平台没有这种能力,系统也会交错执行线程。整体执行对用户来说将看起来像是任务处理的事件是并发的。这对于我们的例子至关重要,因为我们希望游戏无论玩家是否按键盘上的键都能看起来持续进行。

在 C 中处理线程通过两个主要函数接口进行,可以用来启动一个新线程然后等待该线程的终止:这里thrd_create的第二个参数是类型为thrd_start_t的函数指针。这个函数被执行

#include <threads.h>
typedef int (***thrd_start_t**)(void*);
int **thrd_create**(**thrd_t***, **thrd_start_t**, void*);
int **thrd_join**(**thrd_t**, int *);

在新线程的开始时。正如我们从typedef中可以看到的,该函数接收一个void*指针并返回一个int。类型thrd_t是一个不透明类型,它将标识新创建的线程。

在我们的例子中,main中的四个thrd_create调用创建了四个线程,这些线程对应于不同的任务。这些线程与main的原始线程并发执行。最后,main等待四个线程终止;它连接它们。四个线程简单地通过从它们启动的初始函数返回来达到终止。因此,我们的四个函数被声明为

static int update_thread(void*);
static int draw_thread(void*);
static int input_thread(void*);
static int account_thread(void*);

这四个函数由我们的主函数分别以线程的形式启动,并且所有四个线程都接收一个指向类型为 life 的对象的指针,该对象持有游戏的状态:

B9.c
**201**   /* Create an object that holds the game's data. */
**202**   life L = LIFE_INITIALIZER;
**203**   life_init(&L, n0, n1, M);
**204**   /* Creates four threads that all operate on that same object
**205**      and collects their IDs in "thrd" */
**206**   **thrd_t** thrd[4];
**207**   **thrd_create**(&thrd[0], update_thread,  &L);
**208**   **thrd_create**(&thrd[1], draw_thread,    &L);
**209**   **thrd_create**(&thrd[2], input_thread,   &L);
**210**   **thrd_create**(&thrd[3], account_thread, &L);
**211**   /* Waits for the update thread to terminate */
**212**   **thrd_join**(thrd[0], 0);
**213**   /* Tells everybody that the game is over */
**214**   L.finished = **true**;
**215**   **ungetc**('q', **stdin**);
**216**   /* Waits for the other threads */
**217**   **thrd_join**(thrd[1], 0);
**218**   **thrd_join**(thrd[2], 0);
**219**   **thrd_join**(thrd[3], 0);

四个线程函数中最简单的是 account_thread。由于其接口只接收void*,它的第一个动作是将它重新解释为 life 指针,然后进入一个while循环,直到其工作完成:

B9.c
 **99**  int account_thread(void* Lv) {
**100**    life*restrict L = Lv;
**101**    while (!L->finished) {
**102**      // Blocks until there is work
B9.c
**117**   return 0;
**118** }

那个循环的核心调用一个特定的任务函数,life_account,然后检查从它的角度来看,游戏是否应该结束:

B9.c
**108**    life_account(L);
**109**    if ((L->last + repetition) < L->accounted) {
**110**      L->finished = **true**;
**111**    }
**112**    // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

这里终止的条件是游戏是否之前进入了相同的重复游戏配置序列。

其他三个函数的实现类似。它们都将它们的参数重新解释为指向 life 的指针,并进入一个处理循环,直到它们检测到游戏已经结束。然后,在循环内部,它们有相对简单的逻辑来完成它们在这个特定迭代中的特定任务。例如,draw_thread 的内部部分看起来像这样:

B9.c
**79**     if (L->n0 <= 30) life_draw(L);
**80**     else life_draw4(L);
**81**     L->drawn++;
**82**     // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

18.1. 简单的线程间控制

我们已经看到了两种不同的线程控制工具。首先,thrd_join允许一个线程等待另一个线程完成。我们在main线程连接到其他四个线程时看到了这一点。这确保了这个main线程只有在所有其他线程都完成时才会终止,因此程序执行保持活跃和一致,直到最后一个线程消失。

另一个工具是 life 的成员 finished。这个成员包含一个 bool 值,当任何一个线程检测到终止游戏的条件时,该值都为true

与信号处理程序类似,几个线程对共享变量的同时冲突操作必须非常小心地处理。

取舍 18.1

如果线程 T[0] 写入一个同时被另一个线程 T[1] 读取或写入的非原子对象,执行的行为将变得未定义。

通常情况下,当我们谈论不同的线程时,甚至很难确定同时意味着什么(如稍后所讨论的)。我们避免这种情况的唯一机会是排除所有潜在的冲突访问。如果存在这种潜在的并发未受保护访问,我们称之为竞争条件

在我们的例子中,除非我们采取特定的预防措施,否则即使是更新一个如 finished 这样的 bool 值也可能在不同线程之间被分割。如果两个线程以交错的方式访问它,更新可能会搞混事情,导致程序状态未定义。编译器无法知道一个特定的对象是否可能受到竞争条件的影响,因此我们必须明确地告诉它。这样做最简单的方法是使用我们之前也看到过的信号处理工具:原子操作。在这里,我们的 life 结构有几个成员被指定为_Atomic

life.h
**40**     // Parameters that will dynamically be changed by
**41**     // different threads
**42**     _Atomic(**size_t**) constellations; //< Constellations visited
**43**     _Atomic(**size_t**) x0;             //< Cursor position, row
**44**     _Atomic(**size_t**) x1;             //< Cursor position, column
**45**     _Atomic(**size_t**) frames;         //< FPS for display
**46**     _Atomic(bool)   finished;      //< This game is finished.

这些成员的访问保证是原子的。在这里,这是我们已经知道的成员 finished,以及一些我们用来在输入绘制之间通信的其他成员,特别是光标的当前位置。

Takeaway 18.2

考虑到不同线程的执行,原子对象的标准操作是不可分割和可线性化的

在这里,线性化确保我们还可以就两个不同线程中计算的顺序进行论证。对于我们的例子,如果一个线程看到 finished 被修改(设置为true),它就知道设置它的线程已经执行了它应该做的所有操作。从这个意义上说,线性化将序列的纯粹语法属性扩展到了线程。

因此,对原子对象的操作也有助于我们确定哪些线程的部分不是同时执行的,这样它们之间就不会发生竞争条件。稍后,在第 19.1 节中,我们将看到如何将这一点形式化为“发生之前”关系。

因为原子对象在语义上与普通对象不同,声明它们的语法主要是原子指定符:正如我们所看到的,关键字_Atomic后跟包含原子从中派生的类型的括号。还有一种使用_Atomic作为原子修饰符的语法,类似于其他修饰符constvolatilerestrict。在下面的规范中,A 和 B 的两种不同声明是等效的:

   extern _Atomic(double (*)[45]) A;
   extern double (*_Atomic A)[45];
   extern _Atomic(double) (*B)[45];
   extern double _Atomic  (*B)[45];

它们指的是相同的对象 A,一个指向45double元素数组的原子指针,以及 B,一个指向45个原子double元素数组的指针。

修饰符记法有一个陷阱:它可能会暗示_Atomic修饰符与其他修饰符之间的相似性,但实际上它们并没有走得很远。考虑以下具有三个不同“修饰符”的例子:

   double var;
   // Valid: adding const qualification to the pointed-to type
   extern double    const* c = &var;
   // Valid: adding volatile qualification to the pointed-to type
   extern double volatile* v = &var;
   // Invalid: pointers to incompatible types
   extern double  _Atomic* a = &var;

因此,最好不要养成将原子视为修饰符的习惯。

Takeaway 18.3

使用指定符语法 _Atomic(T*)* 进行原子声明

对于_Atomic的另一个限制是它不能应用于数组类型:

   _Atomic(double[45]) C;   // Invalid: atomic cannot be applied to arrays.
   _Atomic(double) D[45];   // Valid: atomic can be applied to array base.

再次强调,这与类似“修饰”的类型不同:

   typedef double darray[45];
   // Invalid: atomic cannot be applied to arrays.
   darray _Atomic E;
   // Valid: const can be applied to arrays.
   darray const F = { 0 }; // Applies to base type
   double const F[45];     // Compatible declaration
Takeaway 18.4

没有原子数组类型

在本章的后面部分,我们还将看到另一个确保线性化的工具:mtx_t。但原子对象迄今为止是最有效率和易于使用的。

Takeaway 18.5

原子对象是强制消除竞争条件的特权工具

18.2. 无竞争初始化和销毁

对于任何由线程共享的数据,在并发访问之前将其初始化到一个良好控制的状态非常重要,并且在最终销毁后永远不应访问。对于初始化,有几种可能性,按优先顺序在此列出:

  1. 具有静态存储持续时间的共享对象在执行任何操作之前都会被初始化。

  2. 具有自动或分配存储持续时间的共享对象可以由创建它们的线程在发生任何共享访问之前正确初始化。

  3. 具有静态存储持续时间的共享对象,其中包含动态初始化的信息

    1. 在创建任何其他线程之前,应在启动时通过main初始化应可用的内容。

    2. 在启动时不可用必须使用call_once进行初始化。

因此,后者,call_once,仅在非常特殊的情况下才是必需的:

    void **call_once**(**once_flag*** flag, void cb(void));

atexit类似,call_once注册了一个回调函数 cb,该函数应在执行中的确切一点被调用。以下是一个基本示例,说明如何使用它:

   /* Interface */
   extern **FILE*** errlog;
   **once_flag** errlog_flag;
   extern void errlog_fopen(void);

   /* Incomplete implementation; discussed shortly */
   **FILE*** errlog = 0;
   **once_flag** errlog_flag = **ONCE_FLAG_INIT**;
   void errlog_fopen(void) {
     **srand**(**time**());
     unsigned salt = **rand**();
     static char const format[] = "/tmp/error-\%#X.log"
     char fname[16 + sizeof format];
     **snprintf**(fname, sizeof fname, format, salt);
     errlog = **fopen**(fname, "w");
     if (errlog) {
       **setvbuf**(errlog, 0, **_IOLBF**, 0);    // Enables line buffering
     }
   }

   /* Usage */

   /* ... inside a function before any use ... */
   **call_once**(&errlog_flag, errlog_fopen);
   /* ... now use it ... */
   **fprintf**(errlog, "bad, we have weird value \%g!\n", weird);

在这里,我们有一个全局变量(errlog),它需要动态初始化(调用timesrandrandsnprintffopensetvbuf)以进行初始化。使用该变量的任何操作都应该以调用call_once开始,该调用使用相同的once_flag(在此处,errlog_flag)和相同的回调函数(在此处,errlog_fopen)。

因此,与atexit相反,回调是与特定对象一起注册的,即once_flag类型之一。这种不透明类型保证了足够的状态来

  • 确定特定的call_once调用是否是所有线程中的第一个

  • 只有那时才调用回调

  • 永远不要再调用回调

  • 等待所有其他线程,直到唯一的回调调用完成

因此,任何使用线程都可以确信对象已正确初始化,而不会覆盖其他线程可能已执行过的初始化。所有流函数(但fopenfclose)都是无竞争的。

摘要 18.6

正确初始化的 FILE* 可以被多个线程无竞争地使用。

在这里,无竞争仅意味着你的程序将始终处于一个良好定义的状态;它并不意味着你的文件可能不包含来自不同线程的混乱输出行。为了避免这种情况,你必须确保fprintf或类似的调用始终打印整个行。

摘要 18.7

并发写操作应一次打印整个行。

对象的无竞争销毁可能更难以组织,因为初始化和销毁数据访问并不对称。虽然通常在对象的生存期开始时很容易确定只有一个用户(以及何时只有一个用户),但如果我们没有跟踪它,那么检查是否有其他线程使用该对象就变得困难。

摘要 18.8

共享动态对象的销毁和分配需要很多注意。

想象一下你宝贵的长达一小时的执行,就在结束时崩溃,试图将它的发现写入文件。

在我们的 B9 示例中,我们有一个简单的策略来确保变量 L 可以安全地被所有创建的线程使用。它在所有线程创建之前初始化,并且在所有创建的线程都连接后才会停止存在。

对于 once_flag 示例,变量 errlog,在从我们的线程之一关闭流时并不容易看到何时应该关闭。最简单的方法是等待我们确定没有其他线程存在,当我们退出整个程序执行时:

   /* Complete implementation */
   **FILE*** errlog = 0;
   static void errlog_fclose(void) {
     if (errlog) {
       **fputs**("*** closing log ***", errlog);
       **fclose**(errlog);
     }
   }

   **once_flag** errlog_flag = **ONCE_FLAG_INIT**;
   void errlog_fopen(void) {
     **atexit**(errlog_fclose);
     ...

这引入了另一个回调(errlog_fclose),确保在关闭文件之前打印最后一条消息。为了确保在初始化函数 errlog_fopen 进入时,该函数被注册到 atexit,以确保在程序退出时执行此函数。

18.3. 线程局部数据

避免竞态条件的最简单方法是严格分离线程访问的数据。所有其他解决方案,例如我们之前看到的原子操作以及我们稍后将要看到的互斥锁和条件变量,都要复杂得多,成本也高得多。访问线程局部数据的最佳方式是使用局部变量:

Takeaway 18.9

通过函数参数传递线程特定数据。

Takeaway 18.10

将线程特定状态保存在局部变量中。

如果这不可能实现(或者可能过于复杂),特殊的存储类和专用数据类型允许我们处理线程局部数据。_Thread_local 是一个存储类指定符,它强制为声明为该类的变量创建一个线程特定的副本。头文件 threads.h 也提供了一个宏 thread_local,它扩展为关键字。

<threads.h>

Takeaway 18.11

一个 thread_local 变量为每个线程都有一个单独的实例。

即,thread_local 变量必须像具有静态存储期的变量一样声明:它们在文件作用域中声明,或者如果不这样做,它们还必须额外声明 static(参见 第 13.2 节,表 13.1)。因此,它们不能动态初始化。

Takeaway 18.12

如果初始化可以在编译时确定,请使用 thread_local

如果存储类指定符不足以进行动态初始化和销毁,我们可以使用 线程特定存储tss_t。它将线程特定数据的标识抽象为一个不透明的 ID,称为键,以及设置或获取数据的访问器函数:

void* **tss_get**(**tss_t** key);           // Returns a pointer to an object
int **tss_set**(**tss_t** key, void *val);  // Returns an error indication

在创建键时,指定在线程结束时调用以销毁线程特定数据的函数为类型 tss_dtor_t 的函数指针:

typedef void (***tss_dtor_t**)(void*);           // Pointer to a destructor
int **tss_create**(**tss_t*** key, **tss_dtor_t** dtor); // Returns an error indication
void **tss_delete**(**tss_t** key);

18.4. 关键数据和关键部分

生命结构的其他部分不能轻易受到保护。它们对应于更大的数据,例如游戏的棋盘位置。你可能记得,数组不能使用_Atomic指定;即使我们能够使用一些技巧做到这一点,结果也不会很高效。因此,我们不仅声明了成员 Mv(用于游戏矩阵)和 visited(用于散列已访问的配置),还声明了一个特殊的成员 mtx:

life.h
**15**    **mtx_t** mtx;    //< Mutex that protects Mv
**16**    **cnd_t** draw;   //< cnd that controls drawing
**17**    **cnd_t** acco;   //< cnd that controls accounting
**18**    **cnd_t** upda;   //< cnd that controls updating
**19**
**20**    void*restrict Mv;            //< bool M[n0][n1];
**21**    bool (*visited)[life_maxit]; //< Hashing constellations

这个成员 mtx 具有特殊的类型mtx_t,一种互斥锁类型(用于互斥排他),它也包含在threads.h中。它的目的是保护关键数据:Mv,当它在代码的一个明确部分被访问时,一个临界区

<threads.h>

这种互斥锁最简单的用法是在输入线程的中心,列表 18.1 第 145 行,其中两个调用,mtx_lockmtx_unlock,保护了对生命数据结构 L 的访问。

列表 18.1. B9 的输入线程函数
**121**   int input_thread(void* Lv) {
**122**     termin_unbuffered();
**123**     life*restrict L = Lv;
**124**     enum { len = 32, };
**125**     char command[len];
**126**     do {
**127**       int c = **getchar**();
**128**       command[0] = c;
**129**       switch(c) {
**130**       case GO_LEFT : life_advance(L,  0, -1); break;
**131**       case GO_RIGHT: life_advance(L,  0, +1); break;
**132**       case GO_UP   : life_advance(L, -1,  0); break;
**133**       case GO_DOWN : life_advance(L, +1,  0); break;
**134**       case GO_HOME : L->x0 = 1; L->x1 = 1;    break;
**135**       case ESCAPE  :
**136**         **ungetc**(termin_translate(termin_read_esc(len, command)), **stdin**);
**137**         continue;
**138**       case '+':      if (L->frames < 128) L->frames++; continue;
**139**       case '-':      if (L->frames > 1)   L->frames--; continue;
**140**       case ' ':
**141**       case 'b':
**142**       case 'B':
**143**         **mtx_lock**(&L->mtx);
**144**         // VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV
**145**         life_birth9(L);
**146**         // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
**147**         **cnd_signal**(&L->draw);
**148**         **mtx_unlock**(&L->mtx);
**149**         continue;
**150**       case 'q':
**151**       case 'Q':
**152**       case **EOF**:      goto FINISH;
**153**       }
**154**       **cnd_signal**(&L->draw);
**155**     } while (!(L->finished || **feof**(**stdin**)));
**156**    FINISH:
**157**     L->finished = **true**;
**158**     return 0;
**159**   }

这个例程主要由输入循环组成,它反过来包含一个大 switch,用于处理用户键入键盘的不同字符。只有两个case需要这种保护:'b'和'B',它们触发在当前光标位置周围的 3 3 细胞群的强制“出生”。在所有其他情况下,我们只与原子对象交互,因此我们可以安全地修改这些。

锁定和解锁互斥锁的效果很简单。对mtx_lock的调用会阻塞调用线程的执行,直到可以保证没有其他线程处于由同一互斥锁保护的临界区中。我们说mtx_lock获取互斥锁并保持它,然后mtx_unlock释放它。使用 mtx 也提供了类似于使用原子对象的线性化,正如我们之前所看到的。一个获取了互斥锁 M 的线程可以依赖这样一个事实,即所有在其他线程释放相同的互斥锁 M 之前完成的操作都已经生效。

摘要 18.13

互斥锁操作提供线性化。

C 语言的互斥锁接口定义如下:

int **mtx_lock**(**mtx_t***);
int **mtx_unlock**(**mtx_t***);
int **mtx_trylock**(**mtx_t***);
int **mtx_timedlock**(**mtx_t***restrict, const struct **timespec***restrict);

另外两个调用使我们能够测试(mtx_trylock)是否有其他线程已经持有锁(因此我们可以避免等待)或等待(mtx_timedlock)最长的时间(因此我们可以避免永远阻塞)。后者只有在互斥锁被初始化为mtx_timed“类型”时才允许,如稍后所述。

有两个其他用于动态初始化和销毁的调用:

int **mtx_init**(**mtx_t***, int);
void **mtx_destroy**(**mtx_t***);

除了更复杂的线程接口外,使用mtx_init是强制性的;没有为mtx_t定义静态初始化。

摘要 18.14

每个互斥锁都必须使用mtx_init进行初始化。

mtx_init的第二个参数指定了互斥锁的“类型”。它必须是以下四个值之一:

  • mtx_plain

  • mtx_timed

  • mtx_plain|mtx_recursive

  • mtx_timed|mtx_recursive

如你所猜,使用mtx_plainmtx_timed控制了使用mtx_timedlock的可能性。额外的属性mtx_recursive使我们能够对同一线程连续多次调用mtx_lock和类似函数,而无需事先解锁。

收获 18.15

持有非递归互斥锁的线程不得调用任何针对该互斥锁的互斥锁函数。

名称mtx_recursive表明它主要用于在临界区入口调用mtx_lock并在退出时调用mtx_unlock的递归函数。

收获 18.16

递归互斥锁仅在持有线程对 mtx_unlock 的调用次数与其获取的锁的数量相同时才会释放。

收获 18.17

在线程终止之前必须释放已锁定的互斥锁。

收获 18.18

线程必须只在其持有的互斥锁上调用 mtx_unlock

从所有这些中,我们可以得出一个简单的经验法则:

收获 18.19

每个成功的互斥锁锁定对应于对 mtx_unlock 的精确一次调用。

根据平台的不同,互斥锁可能会绑定系统资源,每次调用mtx_init时都会分配该资源。这种资源可以是额外的内存(例如对malloc的调用)或某些特殊硬件。因此,一旦互斥锁达到其生命周期的末尾,释放这些资源就很重要。

收获 18.20

互斥锁必须在其生命周期的末尾被销毁。

因此,特别是,必须调用mtx_destroy

  • 在具有自动存储期的互斥锁的作用域结束之前

  • 在释放动态分配的互斥锁的内存之前

18.5. 通过条件变量进行通信

虽然我们已经看到输入不需要太多的保护来防止竞态条件,但对于会计任务来说情况相反(参见列表 18.2)。它的整个工作(通过调用 life_account 执行)是扫描整个位置矩阵,并为每个位置计算生命邻居的数量。

列表 18.2. B9 的会计线程函数
 **99**   int account_thread(void* Lv) {
**100**     life*restrict L = Lv;
**101**     while (!L->finished) {
**102**       // Blocks until there is work
**103**       **mtx_lock**(&L->mtx);
**104**       while (!L->finished && (L->accounted == L->iteration))
**105**         life_wait(&L->acco, &L->mtx);
**106**
**107**       // VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV
**108**       life_account(L);
**109**       if ((L->last + repetition) < L->accounted) {
**110**         L->finished = **true**;
**111**       }
**112**       // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
**113**
**114**       **cnd_signal**(&L->upda);
**115**       **mtx_unlock**(&L->mtx);
**116**     }
**117**     return 0;
**118**   }

同样,更新和绘图线程主要包含一个外层循环内的一个临界区:参见列表 18.3 和 18.4,它们执行操作。在那之后,我们还有一个调用 life_sleep 的调用,它暂停执行一段时间。这确保了这些线程只以与我们的图形帧率相对应的频率运行。

列表 18.3. B9 的更新线程函数
**35**   int update_thread(void* Lv) {
**36**     life*restrict L = Lv;
**37**     **size_t** changed = 1;
**38**     **size_t** birth9 = 0;
**39**     while (!L->finished && changed) {
**40**       // Blocks until there is work
**41**       **mtx_lock**(&L->mtx);
**42**       while (!L->finished && (L->accounted < L->iteration))
**43**         life_wait(&L->upda, &L->mtx);
**44**
**45**       // VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV
**46**       if (birth9 != L->birth9) life_torus(L);
**47**       life_count(L);
**48**       changed = life_update(L);
**49**       life_torus(L);
**50**       birth9 = L->birth9;
**51**       L->iteration++;
**52**       // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
**53**
**54**       **cnd_signal**(&L->acco);
**55**       **cnd_signal**(&L->draw);
**56**       **mtx_unlock**(&L->mtx);
**57**
**58**       life_sleep(1.0/L->frames);
**59**     }
**60**     return 0;
**61**   }
列表 18.4. B9 的绘图线程函数
**64**   int draw_thread(void* Lv) {
**65**     life*restrict L = Lv;
**66**     **size_t** x0 = 0;
**67**     **size_t** x1 = 0;
**68**     **fputs**(ESC_CLEAR ESC_CLRSCR, **stdout**);
**69**     while (!L->finished) {
**70**       // Blocks until there is work
**71**       **mtx_lock**(&L->mtx);
**72**       while (!L->finished
**73**              && (L->iteration <= L->drawn)
**74**              && (x0 == L->x0)
**75**              && (x1 == L->x1)) {
**76**         life_wait(&L->draw, &L->mtx);
**77**       }
**78**       // VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV
**79**       if (L->n0 <= 30) life_draw(L);
**80**       else life_draw4(L);
**81**       L->drawn++;
**82**       // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
**83**
**84**       **mtx_unlock**(&L->mtx);
**85**
**86**       x0 = L->x0;
**87**       x1 = L->x1;
**88**       // No need to draw too quickly
**89**       life_sleep(1.0/40);
**90**     }
**91**     return 0;
**92**   }

在所有三个线程中,临界区主要覆盖循环体。除了适当的计算外,在这些临界区中,线程实际上会暂停,直到需要新的计算。更确切地说,对于会计线程,有一个条件循环,只能在一次满足以下条件后离开:

  • 游戏结束,或者

  • 另一个线程已经增加了一个迭代计数

那个循环体的内容是对 life_wait 的调用,这是一个使调用线程暂停一秒钟或直到特定事件发生的函数:

life.c
**18**   int life_wait(**cnd_t*** cnd, **mtx_t*** mtx) {
**19**     struct **timespec** now;
**20**     **timespec_get**(&now, **TIME_UTC**);
**21**     now.**tv_sec** += 1;
**22**     return **cnd_timedwait**(cnd, mtx, &now);
**23**   }

它的主要成分是对 cnd_timedwait 的调用,该调用接受一个 条件变量 类型为 cnd_t、一个互斥锁和一个绝对时间限制。

这样的条件变量用于标识线程可能想要等待的条件。在这里,在我们的例子中,你看到了 life 的三个此类条件变量成员的声明:draw、acco 和 upda。这些中的每一个都对应于绘图、会计和更新在执行其适当任务之前需要的测试条件。正如我们所看到的,会计有

B9.c
**104**       while (!L->finished && (L->accounted == L->iteration))
**105**         life_wait(&L->acco, &L->mtx);

类似地,更新和绘制都有

B9.c
**42**       while (!L->finished && (L->accounted < L->iteration))
**43**         life_wait(&L->upda, &L->mtx);

B9.c
**72**       while (!L->finished
**73**              && (L->iteration <= L->drawn)
**74**              && (x0 == L->x0)
**75**              && (x1 == L->x1)) {
**76**         life_wait(&L->draw, &L->mtx);
**77**       }

这些循环中的条件反映了有工作要做的情况。最重要的是,我们必须确保不要混淆 条件变量,它作为条件的一种标识,以及 条件表达式。对 cnd_t 的等待函数的调用可能会返回,尽管与条件表达式无关的内容没有改变。

取得第 18.21 条经验

*从 cnd_t 等待返回后,必须再次检查该表达式。

因此,我们所有的 life_wait 调用都放在检查条件表达式的循环中。

在我们的例子中,这可能是显而易见的,因为我们底层使用的是 cnd_timedwait,返回可能只是因为调用超时。但即使我们使用无计时等待条件的接口,调用也可能提前返回。在我们的示例代码中,当游戏结束时,调用可能会最终返回,因此我们的条件表达式始终包含对 L->finished 的测试。

cnd_t 提供了四个主要控制接口:

int **cnd_wait**(**cnd_t***, **mtx_t***);
int **cnd_timedwait**(**cnd_t***restrict, **mtx_t***restrict, const struct **timespec** *
    restrict);
int **cnd_signal**(**cnd_t***);
int **cnd_broadcast**(**cnd_t***);

第一个与第二个类似,但没有超时,如果 cnd_t 参数从未被信号,线程可能永远不会从调用中返回。

cnd_signalcnd_broadcast 在控制的另一端。我们在 input_thread 和 account_thread 中看到了第一个的应用。它们确保等待相应条件变量的线程(cnd_signal)或所有线程(cnd_broadcast)被唤醒并从 cnd_waitcnd_timedwait 的调用中返回。例如,输入任务 signal 绘图任务,表明游戏配置中有变化,应该重新绘制棋盘:

B9.c
**155**      } while (!(L->finished || **feof**(**stdin**)));

等待条件函数的 mtx_t 参数在循环体中扮演着重要的角色。互斥锁必须由调用等待函数的线程持有。在等待期间,锁被临时释放,这样其他线程就可以执行它们的工作以断言条件表达式。在等待调用返回之前,锁被重新获取,这样就可以安全地访问关键数据,而不会发生竞争。

图 18.3 展示了输入线程和绘制线程、互斥锁以及相应的条件变量之间的典型交互。它显示在交互中涉及了六个函数调用:四个用于各自的临界区和互斥锁,两个用于条件变量。

图 18.3. 由互斥锁 L->mtx 和条件变量 L->draw 管理的输入和绘制线程之间的控制流程。临界区用灰色阴影表示。条件变量与互斥锁关联,直到等待者重新获取互斥锁。

在等待调用中,条件变量与互斥锁之间的耦合应该小心处理。

总结 18.22

条件变量只能与一个互斥锁同时使用

但最好的做法可能是永远不要更改与条件变量一起使用的互斥锁。

我们的例子还表明,对于同一个互斥锁可以有多个条件变量:我们同时使用三个不同的条件变量与我们的互斥锁。这在许多应用中将是强制性的,因为线程将访问相同资源的条件表达式取决于它们各自的角色。

在多个线程等待同一个条件变量并被cnd_broadcast调用唤醒的情况下,它们不会同时醒来,而是一个接一个地,随着它们重新获取互斥锁。

与互斥锁类似,C 的条件变量可能会绑定宝贵的系统资源。因此,它们必须动态初始化,并在其生命周期结束时销毁。

总结 18.23

cnd_t 必须动态初始化。

总结 18.24

cnd_t 必须在其生命周期结束时销毁。

这些接口很简单:

int **cnd_init**(**cnd_t** *cond);
void **cnd_destroy**(**cnd_t** *cond);

18.6. 更复杂的线程管理

在刚刚看到在main中创建和连接线程之后,我们可能会产生这样的印象,即线程以某种方式是有层次组织的。但实际上并非如此:仅仅知道线程的 ID,即其thrd_t,就足以处理它。只有一个线程具有一个特殊属性。

总结 18.25

main 返回调用 exit 将终止所有线程

如果我们在创建其他线程之后想要终止main,我们必须采取一些预防措施,以确保我们不提前终止其他线程。以下是对 B9 的main修改后的示例,展示了这种策略:

B9-detach.c
**210**
**211**   void B9_atexit(void) {
**212**     /* Puts the board in a nice final picture */
**213**     L.iteration = L.last;
**214**     life_draw(&L);
**215**     life_destroy(&L);
**216**   }
**217**
**218**   int **main**(int argc, char* argv[argc+1]) {
**219**     /* Uses command-line arguments for the size of the board */
**220**     **size_t** n0 = 30;
**221**     **size_t** n1 = 80;
**222**     if (argc > 1) n0 = **strtoull**(argv[1], 0, 0);
**223**     if (argc > 2) n1 = **strtoull**(argv[2], 0, 0);
**224**     /* Create an object that holds the game's data. */
**225**     life_init(&L, n0, n1, M);
**226**     **atexit**(B9_atexit);
**227**     /* Creates four threads that operate on the same object and
**228**        discards their IDs */
**229**     **thrd_create**(&(**thrd_t**){0}, update_thread,  &L);
**230**     **thrd_create**(&(**thrd_t**){0}, draw_thread,    &L);
**231**     **thrd_create**(&(**thrd_t**){0}, input_thread,   &L);
**232**     /* Ends this thread nicely and lets the threads go on nicely */
**233**     **thrd_exit**(0);
**234**   }

首先,我们必须使用函数thrd_exit来终止main。除了return之外,这确保了相应的线程仅终止,而不会影响其他线程。然后,我们必须将 L 设为全局变量,因为我们不希望它的生命周期随着main的终止而结束。为了安排必要的清理,我们还安装了一个atexit处理程序。修改后的控制流程如图 18.4 所示。

图 18.4. B9-detach 的五个线程的控制流程。最后返回的线程执行 atexit 处理程序。

由于这种不同的管理方式,创建的四个线程实际上从未真正连接。每个已经死亡但从未连接的线程都会消耗一些资源,这些资源会保留到执行结束。因此,一个好的编码风格是告诉系统一个线程永远不会连接:我们说我们 分离 相应的线程。我们通过在线程函数的开始处插入对 thrd_detach 的调用来实现这一点。我们也在那里启动账户线程,而不是像以前那样从 main 中启动。

B9-detach.c
**38**      /* Nobody should ever wait for this thread. */
**39**      **thrd_detach**(**thrd_current**());
**40**      /* Delegates part of our job to an auxiliary thread */
**41**      **thrd_create**(&(**thrd_t**){0}, account_thread, Lv);
**42**      life*restrict L = Lv;

有六个更多函数可以用来管理线程,其中我们已经遇到了 thrd_currentthrd_exitthrd_detach

**thrd_t** **thrd_current**(void);
int **thrd_equal**(**thrd_t**, **thrd_t**);
_Noreturn void **thrd_exit**(int);

int **thrd_detach**(**thrd_t**);
int **thrd_sleep**(const struct **timespec***, struct **timespec***);
void **thrd_yield**(void);

一个正在运行的 C 程序可能拥有的线程比它可用的处理元素要多得多。尽管如此,运行时系统应该能够通过在处理器上分配时间片来平滑地调度线程。如果一个线程实际上没有工作要做,它不应该要求时间片,而应该将处理资源留给可能需要它们的其他线程。这是控制数据结构 mtx_tcnd_t 的主要特性之一。

取走 18.26

在阻塞于 mtx_t cnd_t* 时,一个线程会释放处理资源。

如果这还不够,还有两个其他函数可以挂起执行:

  • thrd_sleep 允许一个线程暂停其执行一段时间,这样平台的其他线程可以在其间使用硬件资源。

  • thrd_yield 只终止当前时间片并等待下一个处理机会。

使用线程进行并行排序

你能否实现一个基于你的归并排序实现(挑战 1 和 14)的并行排序算法,使用两个线程?

也就是说,一个归并排序,它将输入数组分成两半,并在各自的线程中排序每一半,然后像以前一样按顺序合并这两个半部分。在每个线程内部使用不同的顺序排序算法作为基础。

你能否将这个并行排序推广到 P 线程,其中 P = 2^k,对于 k = 1, 2, 3, 4,其中 k 在命令行上给出?

你能否测量你并行化所获得的速度提升?它是否与你的测试平台的核心数相匹配?

摘要

  • 在并发访问之前确保共享数据被正确初始化是很重要的。这最好在编译时或在 main 中完成。作为最后的手段,可以使用 call_once 来触发初始化函数的执行,确保只执行一次。

  • 线程应优先只操作本地数据,通过函数参数和自动变量。如果不可避免,也可以创建thread_local对象或通过tss_create来创建线程特定的数据。只有在需要变量的动态构建和销毁时才使用后者。

  • 在线程之间共享的小关键数据应指定为_Atomic

  • 临界区(操作未受保护共享数据的代码路径)必须受到保护,通常是通过使用mtx_t互斥锁。

  • 线程之间的条件处理依赖通过cnd_t条件变量进行建模。

  • 没有能力依赖main进行事后清理的线程代码应使用thrd_detach,并将所有清理代码放在atexit和/或at_quick_exit处理程序中。

第十九章。原子访问和内存一致性

本章涵盖

  • 理解“发生之前”关系

  • 提供同步的 C 库调用

  • 维护顺序一致性

  • 与其他一致性模型一起工作

我们将通过描述构成 C 架构模型重要部分的概念来完成这一级,因此对于经验丰富的程序员来说是必须的。尝试理解最后一章,以增加你对事物工作方式的理解,而不仅仅是提高你的操作技能。即使我们不会深入所有辉煌的细节,^([1])事情可能会变得有点颠簸:请坐好并系好安全带。

¹

我们将把memory_order_consume一致性以及依赖顺序关系放在一边。

如果你回顾我们在前几章中看到的控制流图,你会发现程序执行不同部分的交互可能会相当复杂。我们具有不同级别的数据并发访问:

  • 简单直接的 C 代码表面上似乎是顺序的。变化的可见性仅在执行中的非常具体的点、序列点、直接数据依赖以及函数调用完成时得到保证。现代平台越来越多地利用提供的灵活性,在多个执行管道中混合或并行执行非顺序操作。

  • 长跳转和信号处理程序是顺序执行的,但存储的效果可能会在途中丢失。

  • 我们迄今为止看到的对原子对象的访问要求其变化在所有地方和始终可见。

  • 线程并排同时运行,如果它们不调节对数据的共享访问,就会危及数据一致性。除了对原子对象的访问外,它们还可以通过调用函数(如thrd_joinmtx_lock)进行同步。

但程序所做的不仅仅是访问内存。事实上,程序执行的抽象状态由以下内容组成:

  • 执行点(每个线程一个)

  • 中间值(计算表达式或评估对象的值)

  • 存储值

  • 隐藏状态

对此状态的更改通常描述为

  • 跳转: 改变执行点(短跳转、长跳转和函数调用)

  • 值计算: 改变中间值

  • 副作用: 存储值或进行 I/O 操作

或者它们可以影响隐藏状态,如 mtx_t 的锁状态或 once_flag 的初始化状态,或者对 atomic_flag 的设置或清除操作。

我们用 效果 这个术语来总结所有这些可能的抽象状态的改变。

取得成果 19.1

每次评估都有一个效果

这是因为任何评估都有一个概念,即在其之后将执行的下一次评估。即使是像

(void)0;

这样的表达式,丢弃了中间值,将执行点设置到下一个语句,因此抽象状态已经改变。

在复杂的环境中,将很难争论给定时刻执行的真正抽象状态。通常,整个程序执行的抽象状态甚至不可观察;在许多情况下,整体抽象状态的概念定义得并不好。这是因为我们实际上不知道在这个上下文中 时刻 的含义。在多个物理计算核心上执行的多线程执行中,它们之间没有真正的参考时间概念。因此,C 甚至不假设不同线程之间存在整体细粒度的时间概念。

作为一个类比,想象两个线程 A 和 B 是发生在两个不同行星上的事件,这两个行星以不同的速度围绕一颗恒星运行。这些行星(线程)上的时间(时间)是相对的,它们之间的同步只有在来自一个行星(线程)发出的信号到达另一个行星时才会发生。信号的传输本身就需要时间,当信号到达目的地时,其来源已经移动。因此,两个行星(线程)之间的相互知识总是部分的。

图 19.1. 通过原子同步的两个线程。圆圈表示对象 x 的修改。线程下面的条形表示 A 的状态信息,上面的表示 B 的状态信息。

19.1. “发生之前”关系

如果我们要争论一个程序的执行(其正确性、性能等),我们需要足够了解所有线程的状态的部分知识并且我们必须知道如何将这些部分知识拼接起来,以获得对整个程序的一致视图。

因此,我们将研究 Lamport [1978] 提出的一个关系。在 C 标准术语中,它是两个评估 EF 之间的 发生之前 关系,表示为 F → E。这是我们观察到的 事后 事件之间的属性。完全展开,它可能更准确地被称为 已知发生之前 关系。

其中一部分包括同一线程中相关的评估,这些评估通过已经引入的 sequenced-before 关系相关联:

收获 19.2

如果 F 在 E 之前进行排序,则 F → E

为了说明这一点,让我们重新审视我们的输入线程中的 列表 18.1。在这里,对 command[0] 的赋值在 switch 语句之前进行排序。因此,我们可以确定 switch 语句中的所有情况都是在赋值之后执行的,或者至少它们会被 感知 为在之后发生。例如,当将命令传递给 ungetc 下的嵌套函数调用时,我们可以确定这将提供修改后的值。所有这些都可以从 C 的语法中推断出来。

在线程之间,事件的排序由 同步 提供。有两种类型的同步:第一种是由原子操作隐含的,第二种是由某些 C 库调用提供的。让我们首先看看原子的情况。如果一个线程写入一个值,另一个线程读取被写入的值,那么原子对象可以用来同步两个线程。

原子操作保证局部一致性;参见 图 19.1。

收获 19.3

*原子对象 X 的修改集 X 是按照处理 X 的任何线程的 sequenced-before 关系进行的,顺序是一致的。

这个序列被称为 X 的 修改顺序。例如,对于图中的原子 x,我们有六个修改:初始化(值 11)、两次增加和三次赋值。C 标准保证两个线程 A 和 B 都以与这种修改顺序一致的方式感知 x 的所有更改。

在图例的例子中,我们只有两个同步点。首先,线程 B 在其 --x 操作结束时与线程 A 同步,因为在这里它读取(并修改)了 A 写入的值 31。第二个同步发生在 A 读取 B 写入的值 5并将其存储到 y 中时。

作为另一个例子,让我们研究输入线程(列表 18.1)和账户线程(列表 18.2)之间的相互作用。它们在不同的地方读取和修改字段 finished。为了论证的简单性,让我们假设 finished 只在这两个函数中被修改,没有其他地方。

两个线程只有在其中之一修改它的情况下才会通过这个原子对象进行同步:也就是说,将值 true 写入其中。这可以在两种情况下发生:

  • 输入线程遇到文件结束条件,无论是当 feof(stdin) 返回 true,还是遇到 EOF 情况。在这两种情况下,do 循环终止,并执行标签 FINISH 之后的代码。

  • 账户线程检测到允许重复的次数超过限制,并将 finished 设置为 true

这些事件不是互斥的,但使用原子对象可以保证两个线程中有一个会首先成功写入到已完成的变量。

  • 如果输入线程先写入,账户线程可能会在其一个while循环的评估中读取已完成的修改值。这次读取是同步的:也就是说,输入线程中的写入事件已知在这次读取之前已经发生。输入线程在写入操作之前所做的任何修改现在都对账户线程可见。

  • 如果账户线程先写入,输入线程可能会在其do循环的while中读取修改后的值。再次强调,这次读取与写入同步,并建立了一个“先发生”的关系,账户线程所做的所有修改都对输入线程可见。

注意,这些同步是有方向的:线程之间的每个同步都有一个“写入方”和“读取方”。我们将两个抽象属性附加到原子操作和某些称为释放语义(在写入方)、获取语义(在读取方)或获取-释放语义(在读取-写入方)的 C 库调用上。稍后我们将讨论具有这种同步属性的 C 库调用。

我们迄今为止看到的、修改对象的原子操作都必须具有释放语义,而所有读取操作都具有获取语义。稍后我们将看到具有放松属性的其它原子操作。

Takeaway 19.4

如果线程 T[E]中的获取操作 E 与另一个线程 T[F]中的释放操作 F 同步,那么如果 E 读取了 F 写入的值。

使用获取和释放语义的特殊构造的目的是强制这些操作之间的效应可见性。我们说,如果我们可以一致地用任何适当的读取操作或使用受效应 X 影响的状态的函数调用替换评估 E,那么效应 X 在评估 E 时是可见的。例如,在图 19.1 中,A 在 x = 31操作之前产生的效应用线程下方的横线表示。一旦 B 完成-x 操作,这些效应对 B 就是可见的。

Takeaway 19.5

如果 F 与 E 同步,那么在 E 之后发生的所有在 F 之前发生的效应 X 都必须在所有 G 评估中可见**.

正如我们在示例中看到的,有一些原子操作可以在一步中读取和写入。这些被称为读取-修改-写入操作:

  • 对任何_Atomic对象的atomic_exchangeatomic_compare_exchange_weak调用

  • 复合赋值或其功能等价物;任何算术类型_Atomic对象的递增和递减运算符

  • atomic_flagatomic_flag_test_and_set调用

这种操作可以在读取方面与一个线程同步,在写入方面与其它线程同步。我们迄今为止看到的所有这样的读取-修改-写入操作都具有获取和释放语义。

发生之前的关系通过传递性地关闭有序之前和同步于的关系。我们说F有意识地发生在E之前,如果存在nE[0] = F, E[1], ..., E[n–1], E[n] = E,其中E[i]是有序于E[i][+1]或与其同步的,对于所有 0 ≤ i < n

19.2. 提供同步的 C 库调用
具有同步特性的 C 库函数成对出现:释放方和获取方。它们总结在表 19.1 中。

19.6 要点

19.7 要点

观察到这个发生之前的关系是不同概念的组合。有序之前的关系可以在许多地方从语法中推断出来,特别是如果两个语句是同一个基本块中的成员。同步不同:除了线程启动和结束的两个例外,它是通过特定对象(如原子或互斥量)的数据依赖来推断的。

thrd_createthrd_join的这些同步特性使我们能够在图 18.1 中画出线条。在这里,我们不知道我们启动的线程之间事件的任何时间顺序,但在main中我们知道我们创建线程的顺序和我们将它们连接的顺序正好如图所示。我们还知道,在我们连接最后一个线程(账户线程)之后,任何这些线程对数据对象的影响都对main可见。所有这一切的预期结果是,一个线程中的效果在另一个线程中变得可见。

互斥量释放 互斥量获取
| |

| |

| |

| thrd_create(.., f, x) | f(x)的入口 |

表 19.1. 形成同步对的 C 库函数

| 通过线程 id 的thrd_exit或从 f 的return | id 的tss_t析构函数的开始 |

| call_once(&obj, g), 首次调用 | call_once(&obj, h), 所有后续调用 |
如果评估 F 发生在 E 之前,那么已知在 F 之前发生的所有效果也都是在 E 之前发生的。
如果我们断开线程并不用thrd_join同步,则同步只能在线程的结束和atexitat_quick_exit处理器的开始之间发生。
释放
我们只能得出一个评估发生在另一个评估之前的结论,如果我们有一个将它们链接起来的有序同步链。
---
id 的tss_t析构函数的结束

注意,对于前三个条目,我们知道哪些事件与哪些事件同步,即同步主要限于线程 id 执行的效果。特别是,通过传递性,我们可以看到thrd_exitreturn总是与对应线程 id 的thrd_join同步。

其他库函数稍微复杂一些。对于初始化实用工具call_once,第一次调用call_once(&obj, g),成功调用其函数 g 的返回,是对所有后续调用相同对象 obj 的释放操作。这确保了在调用g()期间执行的所有写操作都发生在任何其他调用 obj 之前。因此,所有其他此类调用也知道写操作(初始化)已经执行。

对于我们的例子(第 18.2 节),这意味着函数 errlog_fopen 只执行一次,所有可能执行call_once行的其他线程都将与第一次调用同步。因此,当任何线程从调用返回时,它们知道调用已经执行(要么是自己执行的,要么是比它快的另一个线程执行的)并且所有效果,如计算文件名和打开流,现在都是可见的。因此,执行调用的所有线程都可以使用 errlog,并且可以确信它已正确初始化。

对于互斥锁,释放操作可以是调用互斥锁函数mtx_unlock,或者进入条件变量的等待函数cnd_waitcnd_timedwait。互斥锁的获取操作是通过任何三个互斥锁调用mtx_lockmtx_trylockmtx_timedlock成功获取互斥锁,或者从等待函数cnd_waitcnd_timedwait返回。

收获 19.8

由相同互斥锁保护的关键节发生顺序。

我们的输入和会计线程(示例 18.1 和 18.2)访问相同的互斥锁L->mtx。在前一个示例中,它用于保护当用户输入空格、'b'或'B'时新细胞组的出生。在第二个示例中,整个 while 循环的内部块由互斥锁保护。

图 19.2 概述了由互斥锁保护的三个关键节点的序列。解锁操作(释放)与锁定操作(获取)之间的同步同步了两个线程。这保证了在第一次调用 life_account 时,账户线程对*L 所做的更改在输入线程调用 life_birth9 时可见。同样,第二次调用 life_account 可以看到在调用 life_birth9 期间发生的所有对*L 的更改。

图 19.2. 两个线程通过互斥锁同步三个关键节点。圆圈表示对象 mtx 的修改。

收获 19.9

在由互斥锁mut保护的关键节中,所有由mut保护的前一个关键节的效果都是可见的。

这些已知效果之一是执行点的推进。特别是,在从mtx_unlock返回时,执行点位于临界区之外,并且这种效果为下一个新获取锁的线程所知。

条件变量的等待函数与获取-释放语义不同;实际上,它们正好相反。

取得要点 19.10

cnd_wait cnd_timedwait 对互斥锁具有释放-获取语义。

即,在挂起调用线程之前,它们执行释放操作,然后,在返回时执行获取操作。另一个特殊性是同步是通过互斥锁进行的,不是通过条件变量本身。

取得要点 19.11

cnd_signal cnd_broadcast *的调用通过互斥锁进行同步。

如果信号线程没有将cnd_signalcnd_broadcast的调用放入由等待者相同的互斥锁保护的临界区,那么它不一定与等待线程同步。特别是,如果修改不是由互斥锁保护的,那么构成条件表达式的对象的非原子修改可能不会对被信号唤醒的线程可见。有一个简单的经验法则可以确保同步:

图 19.3. 三个不同原子对象的顺序一致性

19fig03_alt.jpg

取得要点 19.12

cnd_signal cnd_broadcast *的调用应该发生在由相同的互斥锁保护的临界区内部。

这就是我们大约在第 145 行看到的列表 18.1 中的内容。在这里,函数 life_birth 修改了*L 的较大、非原子部分,因此我们必须确保这些修改对所有其他与*L 一起工作的线程都是适当可见的。

第 154 行显示了cnd_signal的使用,它没有被互斥锁保护。这在这里是可能的,因为所有在其他switch情况中修改的数据都是原子的。因此,读取这些数据的其他线程,如L->frames,可以通过这些原子来同步,而不依赖于获取互斥锁。如果你使用这样的条件变量,请小心。

19.3. 顺序一致性

我们之前描述的原子对象的一致性,由发生之前关系保证,被称为获取-释放一致性。而我们所看到的 C 库调用总是与这种一致性同步,不多也不少,对原子对象的访问可以使用不同的一致性模型。

如你所记,所有原子对象都有一个与所有在相同对象上看到这些修改的修改顺序相一致的顺序一致性关系。顺序一致性的要求甚至比这更多;参见图 19.3。在这里,我们展示了所有顺序一致性操作的常见时间线。即使这些操作在不同的处理器上执行,原子对象在不同的内存银行中实现,平台也必须确保所有线程将这些操作视为与这个全局线性化一致。

Takeaway 19.13

所有具有顺序一致性的原子操作都发生在单个全局修改顺序中,无论它们应用于哪个原子对象。

因此,顺序一致性是一个非常严格的要求。不仅如此,它强制执行获取-释放语义(事件之间的因果部分排序),而且还把这个部分排序扩展到总排序。如果你对并行化程序的执行感兴趣,顺序一致性可能不是正确的选择,因为它可能会强制原子访问的顺序执行。

标准为原子类型提供了以下功能接口。它们应遵守其名称所给出的描述,并执行同步:

void **atomic_store**(A volatile* obj, C des);
C **atomic_load**(A volatile* obj);
C **atomic_exchange**(A volatile* obj, C des);
bool **atomic_compare_exchange_strong**(A volatile* obj, C *expe, C des);
bool **atomic_compare_exchange_weak**(A volatile* obj, C *expe, C des);
C **atomic_fetch_add**(A volatile* obj, M operand);
C **atomic_fetch_sub**(A volatile* obj, M operand);
C **atomic_fetch_and**(A volatile* obj, M operand);
C **atomic_fetch_or**(A volatile* obj, M operand);
C **atomic_fetch_xor**(A volatile* obj, M operand);
bool **atomic_flag_test_and_set**(**atomic_flag** volatile* obj);
void **atomic_flag_clear**(**atomic_flag** volatile* obj);

这里 C 是任何适当的数据类型,A 是对应的原子类型,M 是与 C 的算术兼容的类型。正如其名称所暗示的,对于获取和运算符接口,调用返回对象修改之前*obj 的值。因此,这些接口与相应的复合赋值运算符(+=)不等价,因为那会返回修改后的结果。

所有这些功能接口都提供顺序一致性

Takeaway 19.14

原子上的所有未指定其他操作的运算符和功能接口都具有顺序一致性。

注意,功能接口与运算符形式不同,因为它们的参数被volatile修饰。

对于原子对象还有一个不暗示同步的函数调用:

void **atomic_init**(A volatile* obj, C des);

它的效果与调用atomic_store或赋值操作相同,但来自不同线程的并发调用可能会产生竞态条件。将atomic_init视为一种廉价的赋值形式。

19.4. 其他一致性模型

可以通过一组互补的功能接口请求不同的一致性模型。例如,可以通过以下方式指定与后缀++运算符等效的仅具有获取-释放一致性的操作:

   _Atomic(unsigned) at = 67;
   ...
   if (**atomic_fetch_add_explicit**(&at, 1, **memory_order_acq_rel**)) {
     ...
   }
Takeaway 19.15

原子对象的同步功能接口具有附加_explicit的形式,允许我们指定它们的顺序一致性模型。

这些接口接受形式为memory_order类型符号常量的额外参数,该参数指定了操作的内存语义:

  • memory_order_seq_cst 请求顺序一致性。使用此选项等同于没有 _explicit 的形式。

  • memory_order_acq_rel 用于具有获取-释放一致性的操作。对于一般的原子类型,你通常会在读-改-写操作中使用它,例如 atomic_fetch_addatomic_compare_exchange_weak,或者对于 atomic_flagatomic_flag_test_and_set

  • memory_order_release 用于只有释放语义的操作。通常这会是 atomic_storeatomic_flag_clear

  • memory_order_acquire 用于只有获取语义的操作。通常这会是 atomic_load

  • memory_order_consume 用于具有比获取一致性更弱因果依赖性的操作。通常这也会是 atomic_load

  • memory_order_relaxed 用于不添加任何同步要求的操作。此类操作的唯一保证是它是不可分割的。此类操作的典型用例是用于不同线程的性能计数器,但我们只对最终累计计数感兴趣。

可以根据它们对平台施加的限制来比较一致性模型。图 19.4 显示了 memory_order 模型的含义顺序。

图 19.4. 一致性模型层次结构,从最少约束到最多约束

图片

memory_order_seq_cstmemory_order_relaxed 对所有操作都是可接受的,但其他 memory_order 有一些限制。只能在同步的一侧发生的操作只能指定那一侧的顺序。因此,仅存储(atomic_storeatomic_flag_clear)的两个操作可能不会指定获取语义。只有三个操作只执行加载,可能不会指定释放或消费语义:除了 atomic_load,这些是失败情况下的 atomic_compare_exchange_weakatomic_compare_exchange_strong。因此,后两个操作需要两个 memory_order 参数来指定它们的 _explicit 形式,以便它们可以区分成功和失败情况的要求:

bool
**atomic_compare_exchange_strong_explicit**(A volatile* obj, C *expe, C des,
                                        **memory_order** success,
                                        **memory_order** failure);
bool
**atomic_compare_exchange_weak_explicit**(A volatile* obj, C *expe, C des,
                                       **memory_order** success,
                                       **memory_order** failure);

在这里,成功一致性必须至少与失败一致性一样强;参见图 19.4。

到目前为止,我们隐含地假设同步的获取和释放方面是对称的,但实际上并非如此:虽然修改只有一个写者,但可以有多个读者。因为将新数据移动到多个处理器或核心是昂贵的,一些平台允许我们避免将原子操作之前发生的所有可见效果传播到所有读取新值的线程。C 语言的消费一致性就是为了映射这种行为设计的。我们不会深入探讨这个模型,你应该只在确定原子读取之前的一些效果不会影响读取线程时才使用它。

摘要

  • “发生之前”关系是推理不同线程之间时间顺序的唯一可能方式。它只能通过使用原子对象或非常具体的 C 库函数的同步来建立。

  • 顺序一致性是原子的一致性模型默认值,但不是其他 C 库函数的默认值。它还假设所有相应的同步事件都是完全有序的。这是一个可能代价高昂的假设。

  • 显式使用获取-释放一致性可能会导致更高效的代码,但需要对原子函数使用带有_explicit后缀的正确参数进行仔细设计。

Takeaways

A C 和 C++不同:不要混淆它们,也不要将它们混淆。
B 不要慌张。
1.1 C 是一种命令式编程语言。
1.2 C 是一种编译型编程语言。
1.3 正确的 C 程序可以在不同的平台上移植。
1.4 一个 C 程序应该无警告地干净编译。
2.1 标点符号可以有多种不同的含义。
2.2 程序中的所有标识符都必须声明。
2.3 标识符可以有多个一致的声明。
2.4 声明绑定到它们出现的范围。
2.5 声明指定标识符,而定义指定对象。
2.6 对象在初始化的同时被定义。
2.7 初始化器中缺失的元素默认为 0。
2.8 对于具有 n 个元素的数组,第一个元素的索引为 0,最后一个元素的索引为 n-1。
2.9 每个对象或函数必须有一个确切的定义。
2.10 应使用for语句编写域迭代。
2.11 循环变量应在for语句的初始部分定义。
3.1 值 0 表示逻辑假。
3.2 任何非 0 值表示逻辑真。
3.3 不要与 0、falsetrue进行比较。
3.4 所有标量都有一个真值。
3.5 case值必须是整数常量表达式。
3.6 case标签不得跳过变量定义。
4.1 类型size_t表示的范围是[0, SIZE_MAX]。
4.2 无符号算术始终有定义。
4.3 对于size_t上的+、-和*运算,如果结果可以表示为size_t,则提供数学上正确的结果。
4.4 对于无符号值,a == (a/b)*b + (a%b)。
4.5 无符号除法和取模运算仅在第二个操作数不为 0 时定义良好。
4.6size_t 的算术运算隐式执行计算 %(SIZE_MAX+1)。
4.7 在溢出情况下,无符号算术会进行环绕。
4.8 无符号除法和取模运算的结果总是小于操作数。
4.9 无符号除法和取模运算不会溢出。
4.10 运算符的所有字符必须直接相连。
4.11 值表达式中的副作用是邪恶的。
4.12 在一个语句中不要修改超过一个对象。
4.13 比较运算符返回值 falsetrue
4.14 逻辑运算符返回值 falsetrue
4.15 &&,
4.16 不要使用逗号运算符。
4.17 大多数运算符不会对其操作数进行排序。
4.18 函数调用不会对其参数表达式进行排序。
4.19 在表达式内部调用的函数不应有副作用。
5.1 C 程序主要处理值而不是它们的表示。
5.2 所有值都是数字或者可以转换为数字。
5.3 所有值都有一个静态确定的类型。
5.4 值上可能进行的操作由其类型决定。
5.5 值的类型决定了所有操作的结果。
5.6 类型的二进制表示决定了所有操作的结果。
5.7 类型二进制表示是可观察的。
5.8 (as-if) 程序执行 as if 遵循抽象状态机。
5.9 类型决定了优化机会。
5.10 在算术运算之前,窄整数类型会被提升为 signed int
5.11 四种基本类型类中的每一个都有三个不同的未提升类型。
5.12 使用 size_t 表示大小、基数或序数。
5.13 对于不能为负的小量,使用unsigned
5.14 对于带有符号的小量,使用signed
5.15 对于带有符号的大差异,使用ptrdiff_t
5.16 对于浮点计算,使用double
5.17 对于复杂数学计算,使用double complex
5.18 连续的字符串字面量会被连接。
5.19 数值字面量从不为负。
5.20 十进制整数常量是有符号的。
5.21 十进制整数常量具有适合它的三个有符号类型中的第一个。
5.22 同一个值可以有不同的类型。
5.23 不要使用八进制或十六进制常量来表示负值。
5.24 使用十进制常量来表示负值。
5.25 不同的字面量可以具有相同的值。
5.26 十进制浮点常量的有效值可能与其字面值不同。
5.27 字面量具有值、类型和二进制表示。
5.28 I 保留用于虚数单位。
5.29 一元减号(-)和加号(+)具有提升后的参数的类型。
5.30 避免缩窄转换。
5.31 在算术中不要使用窄类型。
5.32 避免具有不同符号的运算数。
5.33 在可能的情况下,使用无符号类型。
5.34 选择算术类型,以确保隐式转换无害。
5.35 所有变量都应该初始化。
5.36 为所有聚合数据类型使用指定初始化器。
5.37 {0}是所有非变长数组(VLA)对象类型的有效初始化器。
5.38 所有具有特定含义的常量都必须命名。
5.39 所有具有不同含义的常量都必须区分开来。
5.40 const-限定类型的对象是只读的。
5.41 字符串字面量是只读的。
5.42 枚举常量具有显式或位置值。
5.43 枚举常量是 signed int 类型。
5.44 整数常量表达式不评估任何对象。
5.45 宏名称全部为大写。
5.46 复合字面量定义了一个对象。
5.47 不要在宏内部隐藏终止的分号。
5.48 将宏的缩进续行符右缩进到同一列。
5.49 相同的值可能有不同的二进制表示。
5.50 无符号算术可以很好地回绕。
5.51 任何整数类型的最大值形式为 2^p – 1。
5.52 无符号整数类型的算术由其精度决定。
5.53 移位操作的第二操作数必须小于精度。
5.54 正值独立于符号表示。
5.55 一旦抽象状态机达到未定义状态,就无法再对执行的后续进行任何假设。
5.56 避免所有操作未定义行为是你的责任。
5.57 有符号算术可能会产生严重错误。
5.58 在二进制补码表示中,INT_MIN < -INT_MAX
5.59 对于有符号算术,取反可能会溢出。
5.60 对于位操作,使用无符号类型。
5.61 如果提供了 uintN_t 类型,它是一个具有精确 N 位宽度和精度的无符号整数类型。
5.62 如果提供了 intN_t 类型,它是有符号的,采用二进制补码表示,具有精确 N 位的宽度和 N – 1 的精度。
5.63 如果存在具有所需属性的 N = 8, 16, 32, 和 64 的值类型,则必须提供 uintN_t 和 intN_t 类型,分别对应无符号和有符号整数类型。
5.64 对于提供的任何固定宽度类型,还提供了 _MIN(仅限有符号)、最大 _MAX 和字面量 _C 宏。
5.65 浮点运算既不是结合律,也不是交换律,也不是分配律
5.66 永远不要比较浮点值是否相等。
6.1 数组不是指针。
6.2 在条件中评估数组为true
6.3 存在数组对象,但没有数组值。
6.4 数组不能进行比较。
6.5 数组不能被赋值。
6.6 可变长度数组(VLA)不能有初始化器。
6.7 可变长度数组(VLA)不能在函数外部声明。
6.8 可变长度数组(FLA)的长度由整数常量表达式(ICE)或初始化器确定。
6.9 数组长度规范必须是严格正数。
6.10 长度不是整数常量表达式的数组是可变长度数组(VLA)。
6.11 数组 A 的长度是(sizeof A)/(sizeof A[0])。
6.12 函数数组参数的最内层维度丢失。
6.13 不要在数组参数上使用sizeof运算符。
6.14 数组参数的行为就像数组是通过引用传递的。
6.15 字符串是一个以 0 结尾的char数组。
6.16 使用非字符串的字符串函数会有未定义行为。
6.17 指针是不透明对象。
6.18 指针是有效的、空的或不确定的。
6.19 使用 0 进行初始化或赋值会使指针变为空。
6.20 在逻辑表达式中,如果指针为空,则其评估结果为false
6.21 不确定指针会导致未定义行为。
6.22 总是初始化指针。
6.23 省略的结构体初始化器将相应的成员强制设置为 0。
6.24 结构体初始化器必须初始化至少一个成员。
6.25 结构体参数是通过值传递的。
6.26 结构体可以用=赋值,但不能用==或!=比较。
6.27 结构布局是一个重要的设计决策。
6.28 在嵌套声明中的所有 struct 声明具有相同的可见作用域。
6.29typedef 中使用与标签名相同的标识符来提前声明一个 struct
6.30 typedef 只创建一个类型的别名,但永远不会创建一个新类型。
6.31_t 结尾的标识符名称是保留的。
7.1 所有函数都必须有原型。
7.2 函数只有一个入口,但可以有多个 return 语句。
7.3 函数的 return 必须与其类型一致。
7.4 到达函数的 {} 块的末尾等同于没有表达式的 return 语句。
7.5 只有对于 void 函数,才允许到达函数的 {} 块的末尾。
7.6EXIT_SUCCESSEXIT_FAILURE 作为 main 的返回值。
7.7 到达 main 的末尾等同于带有值 EXIT_SUCCESSreturn 语句。
7.8 调用 exit(s) 等同于在 main 中评估 return 语句。
7.9 exit 从不失败,并且从不返回给其调用者。
7.10 所有命令行参数都作为字符串传递。
7.11 对于 main 的参数,argv[0] 包含程序调用的名称。
7.12 对于 main 的参数,argv[argc] 是 0。
7.13 使一个函数的所有先决条件都明确。
7.14 在递归函数中,首先检查终止条件。
7.15 确保包装函数中递归函数的先决条件。
7.16 多重递归可能导致指数级的计算时间。
7.17 一个糟糕的算法永远不会导致性能良好的实现。
7.18 改进算法可以显著提高性能。
8.1 失败始终是一个选项。
8.2 检查库函数的返回值以查找错误。
8.3 快速失败,尽早失败,频繁失败。
8.4 以 _s 结尾的标识符名称是保留的。
8.5 忽略执行平台的先决条件必须中止编译。
8.6 仅在预处理器条件中评估宏和整数文字。
8.7 在预处理器条件中,未知标识符评估为 0。
8.8 不透明类型通过功能接口指定。
8.9 不要依赖于不透明类型的实现细节。
8.10 putsfputs 在换行符处理上有所不同。
8.11 文本输入和输出转换数据。
8.12 有三种常用的转换用于编码换行符。
8.13 文本行不应包含尾随空白。
8.14 printf 的参数必须与格式说明符完全对应。
8.15 使用 "%d" 和 "%u" 格式来打印整数值。
8.16 使用 "%x" 格式来打印位模式。
8.17 使用 "%g" 格式来打印浮点值。
8.18 使用不适当的格式说明符或修饰符会使行为未定义。
8.19 对于以后需要读取的转换,使用 "%+d", "%#X", 和 "%a"。
8.20 不要使用 gets
8.21 fgetc 返回 int 以能够编码一个特殊错误状态,EOF,以及所有有效字符。
8.22 文件结束只能在使用失败的读取后检测到。
8.23 数值编码字符的解释取决于执行字符集。
8.24 正常程序终止应使用从 mainreturn
8.25 从可能终止常规控制流的函数中使用 exit
8.26 不要使用除 exit 之外的其他函数来终止程序,除非您必须抑制库清理的执行。
8.27 尽可能多地使用 assert 来确认运行时属性。
8.28 在生产编译中,使用 NDEBUG 来关闭所有 assert
C 所有 C 语言代码都必须易于阅读。
9.1 短时记忆和视野范围都很小。
9.2 编码风格不是品味问题,而是文化问题。
9.3 当你加入一个成熟的项目时,你就进入了一个新的文化空间。
9.4 选择一致的策略来处理空白和其他文本格式。
9.5 让你的文本编辑器自动格式化你的代码。
9.6 为所有标识符选择一致的命名策略。
9.7 在头文件中可见的任何标识符都必须符合规范。
9.8 不要污染标识符的全局空间。
9.9 名称必须易于识别且易于区分。
9.10 命名是一种创造性行为。
9.11 文件作用域的标识符必须全面。
9.12 类型名称标识一个概念。
9.13 全局常量标识一个工件。
9.14 全局变量标识状态。
9.15 函数或功能宏标识一个动作。
10.1 (what) 函数接口描述了做什么
10.2 (what for) 接口注释记录了函数的目的。
10.3 (how) 函数代码说明了函数是如何组织的。
10.4 (in which manner) 代码注释解释了函数细节是如何实现的。
10.5 分离接口和实现。
10.6 记录接口——解释实现。
10.7 详尽地记录接口。
10.8 以具有强语义连接的单元结构化代码。
10.9 逐字实现。
10.10 控制流必须明显。
10.11 宏不应以令人惊讶的方式改变控制流。
10.12 函数宏在语法上应像函数调用一样表现。
10.13 函数参数按值传递。
10.14 全局变量不受欢迎。
10.15 在可能的情况下,将小任务表示为纯函数。
11.1 使用 * 与不确定或空指针一起使用将产生未定义行为。
11.2 一个有效的指针指向引用类型数组的第一个元素。
11.3 无法从指针重建数组对象的长度。
11.4 指针不是数组。
11.5 只从数组对象的元素中减去指针。
11.6 所有指针差都具有类型 ptrdiff_t
11.7 使用 ptrdiff_t 来编码位置或大小的有符号差。
11.8 对于打印,将指针值转换为 void*,并使用格式 %p。
11.9 指针有真值。
11.10 一旦可能,就将指针变量设置为 0。
11.11 访问具有其类型陷阱表示的对象将产生未定义行为。
11.12 解引用时,指向的对象必须是指定的类型。
11.13 指针必须指向一个有效的对象或一个有效对象之后的对象或为空。
11.14 不要使用 NULL
11.15 不要在 typedef 中隐藏指针。
11.16 两个表达式 A[i] 和 *(A+i) 是等价的。
11.17 (数组衰减) 数组 A 的评估返回 &A[0]。
11.18 在函数声明中,任何数组参数都会重写为指针。
11.19 只重写数组参数的最内层维度。
11.20 在数组参数之前声明长度参数。
11.21 函数的数组参数的有效性必须由程序员保证。
11.22 (函数衰减) 没有后续开括号的函数 f 会衰减为其起始位置的指针。
11.23 函数指针必须使用其确切类型。
11.24 函数调用运算符 (...) 适用于函数指针。
12.1 具有不同基类型的指针类型是不同的。
12.2 根据定义,sizeof(char) 是 1。
12.3 每个对象 A 都可以看作是 unsigned char[sizeof(A)]。
12.4 字符类型指针是特殊的。
12.5 使用类型 char 用于字符和字符串数据。
12.6 使用类型 unsigned char 作为所有对象类型的原子。
12.7 sizeof 运算符可以应用于对象和对象类型。
12.8 类型 T 的所有对象的尺寸由 sizeof(T) 给出。
12.9 算术类型表示数字的内存顺序是实现定义的。
12.10 在大多数架构上,CHAR_BIT 是 8,UCHAR_MAX 是 255。
12.11 (Aliasing) 除了字符类型外,只有相同基类型的指针可以别名。
12.12 避免使用 & 运算符。
12.13 任何对象指针都可以转换为 void 并从 void 转换回来。
12.14 对象具有存储、类型和值。
12.15 将对象指针转换为 void 并再转换回相同类型是恒等操作。
12.16 (avoid²) Avoid* void*。
12.17 不要使用类型转换。
12.18 (Effective Type) 对象必须通过其有效类型或通过指向字符类型的指针来访问。
12.19 如果字节表示相当于访问类型的有效值,则可以随时访问具有有效 union 类型的对象的任何成员。
12.20 变量或复合字面量的有效类型是其声明类型。
12.21 变量和复合字面量必须通过其声明类型或通过指向字符类型的指针来访问。
13.1 不要对 malloc 和其相关函数的返回值进行类型转换。
13.2 通过malloc分配的存储空间未初始化且没有类型。
13.3 malloc通过返回空指针值来指示失败。
13.4 对于每个分配,必须有相应的free
13.5 对于每个free,必须有相应的malloccallocaligned_allocrealloc
13.6 只用指针调用free,这些指针由malloccallocaligned_allocrealloc返回。
13.7 标识符只在它们的声明作用域内可见,从它们的声明开始。
13.8 标识符的可见性可能被从属作用域中具有相同名称的标识符所遮蔽。
13.9 每个变量的定义都会创建一个新的、不同的对象。
13.10 只读对象字面量可以重叠。
13.11 对象有一个生命周期,在此生命周期之外它们无法被访问。
13.12 在对象的生命周期之外引用对象具有未定义的行为。
13.13 静态存储持续时间的对象总是初始化的。
13.14 除非是 VLA 或临时对象,否则自动对象的生存期与它们的定义块的执行相对应。
13.15 每次递归调用都会创建一个自动对象的新的局部实例。
13.16 对于声明为register的变量,不允许使用&运算符。
13.17 声明为register的变量不能有别名。
13.18 在性能关键代码中将非数组局部变量声明为register
13.19 存储类为register的数组是无用的。
13.20 临时生命周期的对象是只读的。
13.21 临时生命周期的结束是在包含表达式的末尾。
13.22 对于不是 VLA 的对象,其生命周期从进入定义的作用域开始,并在离开该作用域时结束。
13.23 自动变量和复合字面量的初始化器在每次遇到定义时都会被评估。
13.24 对于 VLA,生命周期从遇到定义开始,到离开可见作用域结束。
13.25 静态或线程存储持续期的对象默认初始化。
13.26 自动或分配存储持续期的对象必须显式初始化。
13.27 为你的每个数据类型系统地提供一个初始化函数。
14.1 字符串 strto... 转换函数不是 const-安全的。
14.2 memchrstrchr 搜索函数不是 const-安全的。
14.3 strspnstrcspn 搜索函数是 const-安全的。
14.4 sprintf 没有提供防止缓冲区溢出的措施。
14.5 在格式化未知长度的输出时使用 snprintf
14.6 多字节字符不包含空字节。
14.7 多字节字符串以空字符结尾。
14.8 在二进制模式下使用 freadfwrite 的打开流。
14.9 以二进制模式写入的文件在不同平台之间不可移植。
14.10 fseekftell 不适合非常大的文件偏移量。
14.11 goto 标签在包含它们的整个函数中可见。
14.12 goto 只能跳转到同一函数内的标签。
14.13 goto 不应跳过变量初始化。
D 过早优化是万恶之源。
15.1 不要为了性能而牺牲安全。
15.2 优化器足够聪明,可以消除未使用的初始化。
15.3 函数指针参数的不同表示方式导致相同的二进制代码。
15.4 不取局部变量的地址有助于优化器,因为它抑制了别名。
15.5 内联可以打开许多优化机会。
15.6 添加一个兼容的声明而不使用 inline 关键字确保在当前 TU 中发出函数符号。
15.7 内联函数的定义在所有 TUs 中都是可见的。
15.8 内联的定义放在头文件中。
15.9 没有使用inline的额外声明将放在恰好一个 TUs 中。
15.10 只有在你认为它们是稳定的时,才将函数暴露为inline
15.11 所有属于内联函数本地的标识符都应该通过方便的命名约定来保护。
15.12 内联函数不能访问静态函数的标识符
15.13 内联函数不能定义或访问可修改的静态对象的标识符
15.14 带有restrict限定符的指针必须提供独占访问。
15.15 当你考虑它们是稳定的时,才将函数暴露为inline
E 不要推测代码的性能;要严格验证。
15.16 算法复杂度评估需要证明。
15.17 代码的性能评估需要测量。
15.18 所有测量都会引入偏差。
15.19 仪器更改会改变编译时和运行时属性。
15.20 运行时间的相对标准偏差必须在低百分比范围内。
15.21 收集测量值的高阶矩以计算方差和偏斜是简单且经济的。
15.22 运行时测量必须通过统计方法来强化。
16.1 在可能的情况下,优先选择内联函数而不是功能宏。
16.2 功能宏应提供一个简单的接口来执行复杂任务。
16.3 宏替换是在早期翻译阶段完成的,在给程序组成的标记赋予任何其他解释之前。
16.4 (macro retention) 如果一个功能宏后面没有跟括号(()),则它不会被展开。
16.5 LINE中的行号可能无法放入一个int中。
16.6 使用LINE固有的很危险。
16.7 使用操作符#进行字符串化不会展开其参数中的宏。
16.8 当传递给可变参数时,所有算术类型都按算术运算的方式转换,除了 float 参数,它们被转换为 double
16.9 可变参数函数必须接收有关可变列表中每个参数类型的有效信息。
16.10 除非每个参数都强制转换为特定类型,否则使用可变参数函数是不可移植的。
16.11 避免在新的接口中使用可变参数函数。
16.12 va_arg 机制不提供对 va_list 长度的访问。
16.13 可变参数函数需要一个特定的约定来指定列表的长度。
16.14 _Generic 表达式的结果类型是所选表达式的类型。
16.15 使用 _Genericinline 函数一起使用可以增加优化机会。
16.16 _Generic 表达式中的类型表达式应该是无修饰的类型:没有数组类型,也没有函数类型。
16.17 _Generic 表达式中的类型表达式必须引用相互不兼容的类型。
16.18 在一个 _Generic 表达式中的类型表达式不能是一个指向 VLA 的指针。
16.19_Generic 中的所有选择 expression1 ... expressionN 必须是有效的。
17.1 函数中的副作用可能导致不确定的结果。
17.2 任何运算符的具体操作都排在所有操作数评估之后。
17.3 使用任何赋值、增量或减量运算符更新对象的效果都排在它的操作数评估之后。
17.4 函数调用相对于调用者的所有评估是有序的。
17.5 数组或结构类型初始化列表的表达式是有序不确定的。
17.6 每次迭代定义了一个局部对象的新实例。
17.7 goto 应仅用于控制流中的异常变化。
17.8 每个函数调用定义了一个局部对象的新实例。
17.9 longjmp 从不返回到调用者。
17.10 当通过正常控制流到达时,对 setjmp 的调用将标记调用位置为跳转目标,并返回 0。
17.11 离开对 setjmp 的调用范围会使跳转目标无效。
17.12longjmp 的调用将直接转移到由 setjmp 设置的位置,就像它返回了条件参数一样。
17.13 将 0 作为 longjmp 的条件参数时,会被替换为 1。
17.14 setjmp 只能在条件表达式的简单比较中使用。
17.15 优化与对 setjmp 的调用交互不良。
17.16longjmp 跨越时修改的对象必须是 volatile
17.17 volatile 对象在每次访问时都会从内存中重新加载。
17.18 volatile 对象在每次修改时都会存储到内存中。
17.19 jmp_buftypedef 隐藏了一个数组类型。
17.20 C 的信号处理接口是最基本的,并且仅应用于基本情况。
17.21 信号处理程序可以在执行的任何点介入。
17.22 从信号处理程序返回后,执行将从被中断的地方继续。
17.23 一个 C 语句可能对应于多个处理器指令。
17.24 信号处理程序需要具有不可中断操作的类型。
17.25 sig_atomic_t 类型的对象不应用作计数器。
17.26 除非另有指定,否则 C 库函数不是异步信号安全的。
18.1 如果线程 T[0] 写入一个同时被另一个线程 T[1] 读取或写入的非原子对象,则执行行为将变为未定义。
18.2 考虑到不同线程的执行,原子对象的常规操作是不可分割的且可线性化的。
18.3 使用 _Atomic(T**) 语法指定符进行原子声明。
18.4 没有原子数组类型。
18.5 原子对象是强制消除竞争条件的特权工具。
18.6 正确初始化的 FILE* 可以被多个线程安全地使用。
18.7 并发写操作应一次打印整行。
18.8 销毁和分配共享动态对象需要很多注意。
18.9 通过函数参数传递线程特定的数据。
18.10 将线程特定的状态保存在局部变量中。
18.11 thread_local 变量为每个线程都有一个单独的实例。
18.12 如果初始化可以在编译时确定,请使用 thread_local
18.13 互斥锁操作提供线性化。
18.14 每个互斥锁都必须使用 mtx_init 进行初始化。
18.15 持有非递归互斥锁的线程不得调用其互斥锁锁定函数。
18.16 递归互斥锁只有在持有线程对 mtx_unlock 的调用次数与其获取的锁次数相同时才会释放。
18.17 在线程终止之前必须释放已锁定的互斥锁。
18.18 线程必须只在对它持有的互斥锁上调用 mtx_unlock
18.19 每次成功的互斥锁锁定对应于对 mtx_unlock 的精确一次调用。
18.20 互斥锁必须在生命周期结束时被销毁。
18.21cnd_t 等待返回后,必须再次检查表达式。
18.22 条件变量只能与一个互斥锁同时使用。
18.23 cnd_t 必须动态初始化。
18.24 cnd_t 必须在其生命周期结束时被销毁。
18.25main 函数返回或调用 exit 会终止所有线程。
18.26 在阻塞于 mtx_tcnd_t 时,线程会释放处理资源。
19.1 每次评估都有一个效果。
19.2 如果 FE 之前顺序,则 F → E
19.3 原子对象 X 的修改集以与处理 X 的任何线程的顺序关系一致的顺序执行。
19.4 在线程 T[E] 中的获取操作 E 与释放操作 F 同步,如果另一个线程 T[F]E 读取了 F 写入的值。
19.5 如果 FE 同步,所有在 E 之后发生的评估 G 中必须可见的所有在 F 之前发生的效果 X
19.6 只有在我们有一个将它们连接起来的同步序列链时,我们才能得出一个评估发生在另一个评估之前的结论。
19.7 如果评估 F 发生在 E 之前,所有已知在 F 之前发生的效果也都是在 E 之前发生的。
19.8 由相同互斥锁保护的关键区是顺序发生的。
19.9 在由互斥锁 mut 保护的关键区中,所有由 mut 保护的前关键区的影响都是可见的。
19.10 cnd_waitcnd_timedwait 对互斥锁具有释放-获取语义。
19.11cnd_signalcnd_broadcast 的调用通过互斥锁进行同步。
19.12cnd_signalcnd_broadcast 的调用应发生在由等待者相同的互斥锁保护的临界区中。
19.13 所有具有顺序一致性的原子操作都发生在全局修改顺序中,无论它们应用于哪个原子对象。
19.14 所有原子上的操作符和未指定其他情况的函数式接口都具有顺序一致性。
19.15 原子对象的同步功能接口有一个形式,其中附加了 _ 显式,这允许我们指定它们的一致性模型。

参考文献列表

  • Douglas Adams。《银河系漫游指南》。双 LP 改编的录音带,1986 年。ISBN 0-671-62964-6。3

  • Thomas H. Cormen,Charles E. Leiserson,Ronald L. Rivest,和 Clifford Stein。《算法导论》。麻省理工学院出版社,第 2 版,2001 年。265

  • Edsger W. Dijkstra. 编辑信件:goto 语句被认为是有害的。《ACM 通讯》,第 11 卷第 3 期:147–148 页,1968 年 3 月。ISSN 0001-0782。doi: 10.1145/362929.362947。URL doi.acm.org/10.1145/362929.362947。252

  • Martin Gardner。数学游戏——约翰·康威的新单人纸牌游戏“生命”的神奇组合。《科学美国人》,第 223 期:120–123 页,1970 年 10 月。325

  • Jens Gustedt. 注册表改革——C 编程语言的命名常量,2016 年 8 月。URL www.open-std.org/jtc1/sc22/wg14/www/docs/n2067.pdf。259

  • ISO/IEC/IEEE 60559,编辑。信息技术——微处理器系统——浮点运算,第 60559:2011 卷。ISO,2011 年。URL www.iso.org/standard/57469.html。53

  • JTC1/SC22/WG14,编辑。编程语言 - C。ISO/IEC 9899 号。ISO,第 4 版,2018 年。URL www.iso.org/standard/74528.html。xii

  • Brian W. Kernighan 和 Dennis M. Ritchie。《C 编程语言》。普伦蒂斯-霍尔出版社,新泽西州恩格尔伍德克利夫斯,1978 年。xi,149

  • Donald E. Knuth. 使用 goto 语句进行结构化编程。在《计算调查》,第 6 卷。1974 年。257

  • Donald E. Knuth。《计算机编程艺术。第 1 卷:基本算法》。阿迪生-韦斯利出版社,第 3 版,1997 年。265

  • Leslie Lamport. 时间、时钟和分布式系统中事件排序。《ACM 通讯》,第 21 卷第 7 期:558–565 页,1978 年。347

  • T. Nishizeki,K. Takamizawa,和 N. Saito。从流程图中获得具有最少 goto 语句的程序的计算复杂性。《日本电气通信学会志》,第 60 卷第 3 期:259–260 页,1977 年。302

  • Carlos O’Donell 和 Martin Sebor。关于附件 K 边界检查接口的更新现场经验,2015 年 9 月。URL www.open-std.org/jtc1/sc22/wg14/www/docs/n1969.htm。117

  • Philippe Pébay。稳健、单遍并行计算协方差和任意阶统计矩的公式。技术报告 SAND2008-6212,桑迪亚国家实验室,2008 年。URL prod.sandia.gov/techlib/access-control.cgi/2008/086212.pdf。270

  • POSIX. ISO/IEC/IEEE 信息技术——便携式操作系统接口(POSIX)基础规范,第 9945:2009 卷。ISO,日内瓦,瑞士,2009 年。第 7 期。53

  • Charles Simonyi. 元编程:一种软件开发模型。技术报告 CSL-76-7,PARC,1976。URL www.parc.com/content/attachments/meta-programming-csl-76-7.pdf。153

  • Mikkel Thorup. 结构化程序具有小的树宽和良好的寄存器分配。信息与计算,142:318–332,1995。302

  • Linus Torvalds 等人。Linux 内核编码风格,1996。URL www.kernel.org/doc/Documentation/process/coding-style.rst。在多年中略有演变。149

  • Unicode,编辑。Unicode 标准。Unicode 联盟,加利福尼亚州山景城,美国,10.0.0 版本,2017。URL unicode.org/versions/Unicode10.0.0/。242

  • John von Neumann. EDVAC 报告的第一稿,1945。ENIAC 项目的内部文件。225

  • B. P. Welford. 关于计算校正平方和乘积的方法的笔记。技术计量学,4(3):419–420,1962。270

posted @ 2025-11-09 18:03  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报