C-流畅编程-全-

C 流畅编程(全)

原文:zh.annas-archive.org/md5/66227fce3534099e72c9c35a19800638

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

你拿起这本书是为了提升你的编程技能。这是好事,因为你肯定会从本书提供的实际知识中受益。如果您在 C 编程方面有丰富的经验,您将了解良好设计决策的细节及其利弊。如果您对 C 编程相对较新,您将找到有关设计决策的指导,并看到这些决策如何逐步应用于运行代码示例以构建规模较大的程序。

本书回答了如何结构化一个 C 程序、如何处理错误处理或如何设计灵活接口等问题。随着您对 C 编程的了解加深,通常会出现以下问题:

  • 我应该返回任何错误信息吗?

  • 我应该使用全局变量errno来做到这一点吗?

  • 我应该选择少数带有许多参数的函数还是反之?

  • 我如何构建一个灵活的接口?

  • 我如何构建基本的事物,比如一个迭代器?

对于面向对象的语言,这些问题的大部分在《设计模式:可重用面向对象软件的元素》(Erich Gamma,Richard Helm,Ralph Johnson 和 John Vlissides 著,Prentice Hall 出版,1997 年)中得到了很大程度上的回答。设计模式为程序员提供了关于对象如何互动以及哪个对象拥有其他类型对象的最佳实践。此外,设计模式展示了如何将这些对象组合在一起。

然而,对于像 C 这样的过程式编程语言,大部分这些设计模式无法像《四人帮》描述的那样实现。在 C 中没有本地的面向对象机制。在 C 编程语言中可以模拟继承或多态性,但这可能不是第一选择,因为这种模拟使得习惯于 C 编程而不习惯使用类似 C++这样的面向对象语言及其继承和多态性概念的程序员感到陌生。这样的程序员可能希望坚持他们习惯的本地 C 编程风格。然而,使用本地 C 编程风格,不是所有面向对象设计模式的指导都可用,或者至少没有提供非面向对象编程语言的具体实现的想法。

这就是我们的立场:我们希望在 C 中编程,但我们不能直接使用设计模式文档中记录的大多数知识。本书展示了如何弥补这一差距,并为 C 编程语言实施实际设计知识。

为什么我写这本书

让我告诉你为什么我收集在这本书中的知识对我来说非常重要,以及为什么这样的知识很难找到。

在学校里,我把 C 编程作为我的第一门编程语言。就像每个新的 C 程序员一样,我想知道为什么数组从索引 0 开始,我首先随机尝试如何放置操作符*&,最终让 C 指针魔法起作用。

在大学里,我学习了 C 语法的实际工作原理,以及它如何在硬件上转换成位和字节。有了这些知识,我能够编写非常有效的小程序。然而,我仍然很难理解为什么更长的代码看起来像这样,而且我肯定不会想出以下的解决方案:

typedef struct INTERNAL_DRIVER_STRUCT* DRIVER_HANDLE;
typedef void (*DriverSend_FP)(char byte);
typedef char (*DriverReceive_FP)();
typedef void (*DriverIOCTL_FP)(int ioctl, void* context);

struct DriverFunctions
{
  DriverSend_FP fpSend;
  DriverReceive_FP fpReceive;
  DriverIOCTL_FP fpIOCTL;
};

DRIVER_HANDLE driverCreate(void* initArg, struct DriverFunctions f);
void driverDestroy(DRIVER_HANDLE h);
void sendByte(DRIVER_HANDLE h, char byte);
char receiveByte(DRIVER_HANDLE h);
void driverIOCTL(DRIVER_HANDLE h, int ioctl, void* context);

查看这样的代码引发了许多问题:

  • 为什么在struct中需要函数指针?

  • 函数为什么需要那个DRIVER_HANDLE

  • IOCTL 是什么,为什么我不能使用单独的函数来代替?

  • 为什么需要显式的创建和销毁函数?

当我开始编写工业应用程序时,这些问题就出现了。我经常遇到这样的情况:我意识到我不具备如何在函数中实现迭代器或如何处理错误的 C 编程知识。我意识到,虽然我了解 C 语法,但我不知道如何应用它。我试图做些事情,但只是以笨拙的方式或根本不行。我需要的是关于如何用 C 编程语言实现特定任务的最佳实践。例如,我需要知道以下内容:

  • 如何以简单的方式获取和释放资源?

  • 对于错误处理,使用goto是个好主意吗?

  • 我应该设计我的界面以便灵活应对,还是应该在需要时直接更改?

  • 我应该使用assert语句,还是应该返回错误代码?

  • 在 C 语言中,迭代器是如何实现的?

有趣的是,我意识到,虽然我的经验丰富的同事对这些问题有许多不同的答案,但没有人能指引我找到关于这些设计决策及其利弊的文件。

所以我转向互联网,再次感到惊讶:尽管 C 编程语言已经存在了几十年,但很难找到这些问题的正确答案。我发现,虽然有很多关于 C 编程语言基础和语法的文献,但在高级 C 编程主题或如何编写适合工业应用的优美 C 代码方面的内容并不多。

这本书就是起作用的地方。它教会你如何从编写基本的 C 程序进阶到编写考虑错误处理并对需求和设计变化灵活的大规模 C 程序。这本书使用设计模式的概念逐步为你提供设计决策及其利弊。这些设计模式应用于运行代码示例,教你类似之前示例代码如何演化,以及为何最终呈现如此形式。

展示的模式可以应用于任何 C 编程领域。由于我来自嵌入式编程多线程实时环境的领域,一些模式偏向于该领域。不过,你会看到这些模式的一般思想可以应用于其他 C 编程领域,甚至超出 C 编程的范围。

模式基础

本书提供的设计指导以模式的形式呈现。将知识和最佳实践以模式形式呈现的想法源自建筑师 Christopher Alexander 的作品The Timeless Way of Building(Oxford University Press,1979)。他使用经过验证的小片段解决他领域中的重大问题:如何设计和建造城市。这种应用模式的方法被软件开发领域采纳,其中模式会议如 Pattern Languages of Programs(PLoP)会议旨在扩展模式知识体系。特别是 Gang of Four 的书籍Design Patterns: Elements of Reusable Object-Oriented Software(Prentice Hall,1997)产生了重大影响,并使设计模式的概念为软件开发人员所熟知。

但是模式究竟是什么?有很多定义,如果你对此深感兴趣,那么 Frank Buschmann 等人的书籍Pattern-Oriented Software Architecture: On Patterns and Pattern Languages(Wiley,2007)可以为你提供准确的描述和细节。对于本书的目的而言,模式为实际问题提供了经过验证的解决方案。本书展示的模式具有表 P-1 所示的结构。

Table P-1. 本书中模式的分解方式

模式部分 描述
名称 这是模式的名称,应易于记忆。目标是程序员在日常语言中使用这些名称(就像 Gang of Four 模式那样,程序员会说:“抽象工厂创建对象”)。本书中的模式名称均大写。
上下文 上下文部分为模式设定背景,告诉你在什么情况下可以应用该模式。
问题 问题部分提供了关于你想解决的问题的信息。它以粗体字体类型开始主要的问题陈述,然后添加关于为什么这个问题难以解决的详细信息。(在其他模式格式中,这些详细信息会放入一个名为“forces”的单独部分。)
解决方案 本节提供了如何解决问题的指导。它以粗体字体类型陈述解决方案的主要思想,并继续详细说明解决方案。它还提供了一个代码示例,以便提供非常具体的指导。
后果 本节列出了应用所描述解决方案的利弊。在应用一个模式时,你应该确认由此产生的后果是否可以接受。
已知用途 已知用途为你提供了提议的解决方案是好的并且在实际应用中有效的证据。它们还向你展示了具体的例子,帮助你理解如何应用这个模式。

将设计指导以模式的形式呈现的一个主要好处是,这些模式可以依次应用。如果你有一个庞大的设计问题,很难找到一个能够解决这个问题的指导文件和一个解决方案。相反,你可以将你的庞大和非常具体的问题看作许多较小和更通用问题的总和,并且可以通过依次应用一个又一个模式逐步解决这些问题。你只需检查模式的问题描述,并应用适合你的问题并且你可以接受其后果的模式。这些后果可能会导致另一个问题,然后你可以通过应用另一个模式来解决它。这样,你就可以逐步设计你的代码,而不是在甚至写下第一行代码之前就想出一个完整的前期设计。

如何阅读本书

你应该已经了解 C 编程的基础知识。你应该知道 C 的语法及其工作原理——例如,这本书不会教你什么是指针或者如何使用它。本书提供了关于高级主题的提示和指导。

本书的各章节都是独立的。你可以按任意顺序阅读它们,可以简单地挑选你感兴趣的主题。你将在下一节找到所有模式的概述,然后可以跳转到你感兴趣的模式。因此,如果你确切地知道你在寻找什么,你可以从那里开始。

如果你不是在寻找一个特定的模式,而是想要了解可能的 C 设计选项的概述,请阅读本书的第一部分。那里的每一章都专注于特定主题,从错误处理和内存管理等基础主题开始,然后转向更高级和具体的主题,如接口设计或跨平台代码。每章节都介绍了与该主题相关的模式,并提供了一个运行的代码示例,逐步展示这些模式如何应用。

本书的 第二部分 展示了两个应用了 第一部分 中许多模式的较大运行示例。在这里,您可以学习如何通过模式的应用逐步构建一些较大的软件部件。

模式概览

你将在本书中介绍的所有模式概览都可以在表 P-2 到 P-10 中找到。这些表显示了模式的简短形式,仅包含核心问题的简要描述,后跟关键字“因此”,再后跟核心解决方案。

表 P-2. 错误处理模式

模式名称 摘要
“函数分割” 函数具有多个责任,这使得函数难以阅读和维护。因此,将其拆分。将看似有用的函数部分单独提取出来,创建一个新函数,并调用该函数。
“守卫条款” 函数由于将前置条件检查与函数的主要程序逻辑混合在一起而难以阅读和维护。因此,检查是否具有强制性的前置条件,如果这些前置条件不满足,则立即从函数中返回。
“武士原则” 在返回错误信息时,您假设调用者会检查此信息。然而,调用者可以简单地忽略此检查,错误可能会被忽略。因此,要么从函数中胜利返回,要么干脆不返回。如果有一种情况,您知道无法处理错误,则中止程序。
“Goto 错误处理” 如果函数在不同位置获取和清理多个资源,则代码变得难以阅读和维护。因此,将所有资源清理和错误处理放在函数末尾。如果无法获取资源,则使用 goto 语句跳转到资源清理代码。
“清理记录” 如果代码获取并清理多个资源,特别是这些资源彼此依赖,那么要使代码易于阅读和维护是很困难的。因此,只要成功,调用资源获取函数,并存储哪些函数需要清理。根据这些存储的值调用清理函数。
“基于对象的错误处理” 一个函数具有多个责任,如资源获取、资源清理和资源使用,使得该代码难以实现、阅读、维护和测试。因此,将初始化和清理放入单独的函数中,类似于面向对象编程中构造函数和析构函数的概念。

表 P-3. 返回错误信息的模式

模式名称 摘要
“返回状态码” 你需要一个机制来向调用者返回状态信息,以便调用者能够做出反应。你希望这个机制简单易用,并且调用者能够清楚地区分可能发生的不同错误情况。因此,使用函数的返回值来返回状态信息。返回一个代表特定状态的值。你和调用者必须对这个值的含义有共同的理解。
“返回相关错误” 一方面,调用者应该能够对错误做出反应;另一方面,你返回的错误信息越多,你和调用者的代码处理错误的负担就越重,这会使代码变得更长。长代码更难阅读和维护,并带来额外错误的风险。因此,只有当信息对调用者是相关的时,才向调用者返回错误信息。错误信息只有在调用者能够对其做出反应时才是相关的。
“特殊返回值” 你希望返回错误信息,但显式返回状态码并不是一个选项,因为这意味着你不能使用函数的返回值来返回其他数据。你必须通过输出参数返回这些数据,这会使调用函数更加困难。因此,使用函数的返回值来返回函数计算的数据。预留一个或多个特殊值在发生错误时返回。
“记录错误” 你希望在发生错误时能够轻松找出其原因。然而,你不希望因此使你的错误处理代码变得复杂。因此,使用不同的渠道提供对调用代码和开发者相关的错误信息。例如,将调试错误信息写入日志文件,而不将详细的调试错误信息返回给调用者。

表格 P-4. 内存管理模式

模式名称 摘要
“栈优先” 决定变量的存储类别和内存部分(栈、堆等)是每个程序员经常要做的决定。如果每个变量都要详细考虑所有可能的替代方案的利弊,这将是很费力的。因此,默认情况下将变量放在栈上,以便从栈变量的自动清理中获益。
“永久内存” 持有大量数据并在函数调用之间传输数据很困难,因为必须确保数据的内存足够大且其生命周期跨越函数调用。因此,将数据放入整个程序生命周期都可用的内存中。
“延迟清理” 如果需要大量内存或者不知道所需大小的内存,则需要动态内存。然而,处理动态内存的清理是一件麻烦事,并且是许多编程错误的来源。因此,分配动态内存,并在程序结束时让操作系统处理释放。
“专用所有权” 使用动态内存的巨大力量伴随着要正确清理内存的重大责任。在较大的程序中,确保所有动态内存正确清理变得困难。因此,在实现内存分配时,清晰地定义和记录将要进行清理的位置及执行清理的对象。
“分配包装器” 每次动态内存分配可能会失败,因此应在代码中检查分配并做出相应反应。这很麻烦,因为您的代码中有许多这样的检查位置。因此,包装分配和释放调用,并在这些包装函数中实现错误处理或额外的内存管理组织。
“指针检查” 导致访问无效指针的编程错误会引发程序的不受控行为,此类错误难以调试。然而,因为您的代码频繁使用指针,存在引入此类编程错误的风险。因此,明确地使未初始化或已释放的指针无效,并始终在访问之前检查指针的有效性。
“内存池” 频繁地从堆中分配和释放对象会导致内存碎片化。因此,持有整个程序生命周期中的大块内存。在运行时,从该内存池中检索固定大小的块,而不是直接从堆中分配新内存。

表 P-5. 从 C 函数返回数据的模式

模式名称 摘要
“返回值” 你希望分离的函数部分彼此不独立。与面向过程编程一样,一些部分产生的结果需要其他部分使用。你希望分离的函数部分需要共享一些数据。因此,简单地使用 C 语言机制来检索函数调用结果的信息:返回值。C 语言中返回数据的机制会复制函数结果,并提供调用者访问此副本的方式。
“输出参数” C 只支持从函数调用返回单一类型,这使得返回多个信息变得复杂。因此,通过使用指针模拟按引用参数传递的方式,在单个函数调用中返回所有数据。
“聚合实例” C 只支持从函数调用返回单一类型,这使得返回多个信息变得复杂。因此,将所有相关数据放入一个新定义的类型中。定义这个聚合实例来包含你想要分享的所有相关数据。在你组件的接口中定义它,让调用者直接访问实例中存储的所有数据。
“不可变实例” 你希望从组件向调用者提供大块不可变数据中包含的信息。因此,有一个实例(例如一个struct),包含要在静态内存中共享的数据。将这些数据提供给希望访问它的用户,并确保他们无法修改它。
“调用者拥有的缓冲区” 你希望向调用者提供复杂或大型数据(其大小已知),并且该数据不是不可变的(它在运行时会改变)。因此,要求调用者向返回大型复杂数据的函数提供缓冲区及其大小。在函数实现中,如果缓冲区大小足够大,将所需数据复制到缓冲区中。
“被调用者分配” 你希望向调用者提供复杂或大小未知的数据,并且该数据不是不可变的(它在运行时会改变)。因此,在提供大型复杂数据的函数内部分配一个具有所需大小的缓冲区。将所需数据复制到缓冲区中,并返回指向该缓冲区的指针。

Table P-6. 数据生命周期和所有权的模式

模式名称 摘要
“无状态软件模块” 你希望向调用者提供逻辑相关的功能,并尽可能地简化调用者使用该功能。因此,保持函数简单,在实现中不要建立状态信息。将所有相关函数放入一个头文件中,并向调用者提供你软件模块的接口。
“带全局状态的软件模块” 您希望结构化逻辑相关代码,这些代码需要共享状态信息,并使调用者尽可能轻松地使用该功能。因此,使用一个全局实例让相关函数共享公共资源。将所有操作此实例的函数放入一个头文件中,并向调用者提供这个软件模块的接口。
“调用者拥有实例” 您希望提供多个调用者或线程访问依赖于彼此的功能,调用者与您的函数交互会构建状态信息。因此,要求调用者传递一个实例,用于存储资源和状态信息,并传递给您的函数。提供明确的函数来创建和销毁这些实例,以便调用者可以确定它们的生命周期。
“共享实例” 您希望提供多个调用者或线程访问依赖于彼此的功能,调用者与您的函数交互会构建状态信息,而调用者希望共享这些信息。因此,要求调用者传递一个实例,用于存储资源和状态信息,并传递给您的函数。在多个调用者之间使用相同的实例,并在您的软件模块中保持该实例的所有权。

表格 P-7. 灵活 API 的模式

模式名称 摘要
“头文件” 您希望您实现的功能可以被其他实现文件中的代码访问,但希望隐藏调用者不应看到的实现细节。因此,在您的 API 中提供函数声明,为您希望向用户提供的任何功能提供接口。将任何内部函数、内部数据和函数定义(实现)隐藏在实现文件中,并不将此实现文件提供给用户。
“句柄” 您需要在函数实现中共享状态信息或操作共享资源,但不希望调用者看到或甚至访问所有这些状态信息和共享资源。因此,提供一个函数来创建调用者操作的上下文,并返回该上下文内部数据的抽象指针。要求调用者将该指针传递给所有您的函数,然后这些函数可以使用内部数据来存储状态信息和资源。
“动态接口” 应该能够调用具有略有不同行为的实现,但不应该需要复制任何代码,甚至是控制逻辑实现和接口声明。因此,在你的 API 中为这些有差异的功能定义一个通用接口,并要求调用者提供一个回调函数,然后在你的函数实现中调用这个回调函数。
“函数控制” 你希望调用具有略有不同行为的实现,但不想重复任何代码,甚至是控制逻辑实现或接口声明。因此,在你的函数中添加一个参数,传递关于函数调用的元信息,并指定要执行的实际功能。

表 P-8. 灵活迭代器接口的模式

模式名称 概要
“索引访问” 你希望让用户以便捷的方式迭代你的数据结构中的元素,并且可以在内部更改数据结构而不需要更改用户代码。因此,提供一个函数,接受一个索引来访问底层数据结构中的元素,并返回此元素的内容。用户在循环中调用此函数来迭代所有元素。
“游标迭代器” 你希望为用户提供一个迭代接口,即使在迭代过程中元素发生变化也能保持稳健,并且在稍后可以更改底层数据结构而无需更改用户代码。因此,创建一个迭代器实例,指向底层数据结构中的一个元素。一个迭代函数以此迭代器实例作为参数,检索迭代器当前指向的元素,并修改迭代实例以指向下一个元素。然后用户迭代调用此函数以逐个检索元素。
“回调迭代器” 你希望提供一个稳健的迭代接口,用户不需要在代码中实现循环来迭代所有元素,并且在稍后可以更改底层数据结构而无需更改用户代码。因此,使用你现有的数据结构特定操作来在你的实现内部迭代所有元素,并在此迭代过程中调用一些提供的用户函数处理每个元素。这个用户函数以元素内容作为参数,然后可以对这个元素执行操作。用户只需调用一个函数来触发迭代,整个迭代过程在你的实现内部完成。

表 P-9. 模块化程序中文件组织的模式

模式名称 摘要
“包含保护” 多次包含同一个头文件很容易,但如果其中包含类型或某些宏,则在编译时会导致重定义错误。因此,保护您的头文件内容免受多次包含的影响,以便使用头文件的开发人员不必关心它是否多次包含。使用交叉锁定的 #ifdef 语句或 #pragma once 语句来实现这一点。
“软件模块目录” 将代码分割为不同的文件会增加代码库中的文件数量。将所有文件放在一个目录中会使得在大型代码库中特别难以保持对所有文件的概览。因此,将属于紧密耦合功能的头文件和实现文件放入一个目录中。将该目录命名为通过头文件提供的功能的名称。
“全局包含目录” 要包含来自其他软件模块的文件,必须使用诸如 ../othersoftwaremodule/file.h 的相对路径。您必须知道其他头文件的确切位置。因此,在代码库中有一个全局目录,其中包含所有软件模块的 API。将此目录添加到工具链中的全局包含路径中。
“自包含组件” 从目录结构中不可能看到代码的依赖关系。任何软件模块都可以简单地包含来自任何其他软件模块的头文件,因此无法通过编译器检查代码的依赖关系。因此,识别包含类似功能的软件模块,并应将这些软件模块放入一个共同的目录中,并为调用者相关的头文件指定一个指定的子目录。
“API 复制” 您希望独立开发、版本化和部署代码库的各个部分。但是,为了实现这一目标,您需要明确定义代码部分之间的接口,并能够将该代码分隔到不同的存储库中。因此,为了使用另一个组件的功能,复制其 API。分别构建该其他组件并复制构建产物及其公共头文件。将这些文件放入您的组件内的一个目录,并配置该目录为全局包含路径。

表 P-10. 逃离 #ifdef 地狱的模式

模式名称 摘要
“避免变体” 在每个平台上使用不同的函数使得代码更难阅读和编写。程序员需要最初理解、正确使用和测试这些多个函数,以实现跨多个平台的单一功能。因此,请使用在所有平台上都可用的标准化函数。如果没有标准化函数,则考虑不实现该功能。
“隔离原语” 使用#ifdef语句组织的代码变体使得代码难以阅读。很难跟踪程序流程,因为为多个平台实现了多次。因此,请隔离您的代码变体。在实现文件中,将处理变体的代码放入单独的函数中,并从主程序逻辑中调用这些函数,这样主程序只包含平台无关的代码。
“原子原语” 包含变体并由主程序调用的函数仍然很难理解,因为所有复杂的#ifdef代码只是为了在主程序中摆脱它。因此,请使您的原语是原子的。每个函数仅处理一种变体。如果处理多种变体,例如操作系统变体和硬件变体,则为其使用单独的函数。
“抽象层” 您希望在代码库的多个位置使用处理平台变体的功能,但不希望复制该功能的代码。因此,请为每个需要平台特定代码的功能提供一个 API。在头文件中仅定义平台无关的函数,并将所有平台特定的#ifdef代码放入实现文件中。函数的调用者仅需包含您的头文件,而不必包含任何平台特定文件。
“拆分变体实现” 平台特定的实现仍然包含#ifdef语句,用于区分代码变体。这使得很难看到并选择应该为哪个平台构建哪部分代码。因此,将每个变体实现放入单独的实现文件中,并根据需要选择为哪个平台编译。

本书中使用的约定

本书使用以下排版约定:

斜体

表示新术语、网址、电子邮件地址、文件名和文件扩展名。

粗体

用于突出每种模式的问题和解决方案。

等宽字体

用于程序清单,以及段落中引用程序元素如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

注意

此元素表示一般注释。

警告

此元素指示警告或注意事项。

使用代码示例

本书中的代码示例展示了重点在于展示模式及其应用的核心思想的短代码片段。这些代码片段本身不会编译,因为为了简化,省略了几个部分(例如,包含文件)。如果您有兴趣获取完全可编译的完整代码,可以从 GitHub 上下载,网址为https://github.com/christopher-preschern/fluent-c

如果您在使用代码示例时有技术问题或疑问,请发送电子邮件至bookquestions@oreilly.com

本书旨在帮助您完成工作。一般来说,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您复制了大量代码,否则您无需联系我们请求许可。例如,编写一个使用本书中多个代码片段的程序不需要许可。销售或分发 O'Reilly 书籍中的示例代码需要许可。引用本书并引用示例代码回答问题不需要许可。将本书中大量示例代码整合到产品文档中需要许可。

我们感谢您的使用,但通常不需要署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Fluent C by Christopher Preschern (O’Reilly). Copyright 2023 Christopher Preschern, 978-1-492-09733-4.”

如果您觉得您对代码示例的使用超出了合理使用范围或以上给出的许可,请随时与我们联系,电子邮件地址为permissions@oreilly.com

本书中的模式均展示了应用这些模式的现有代码示例。以下列表显示了这些代码示例的引用:

O’Reilly 在线学习

注意

超过 40 年来,O’Reilly Media为企业的成功提供技术和业务培训、知识和见解。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。欲了解更多信息,请访问https://oreilly.com

如何联系我们

请将有关本书的评论和问题发送至出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或当地电话)

  • 707-829-0104(传真)

我们有一个关于本书的网页,上面列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/fluent-c

发送电子邮件至bookquestions@oreilly.com以评论或询问有关本书的技术问题。

欲了解有关我们图书和课程的新闻和信息,请访问https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

在 Twitter 上关注我们:https://twitter.com/oreillymedia

在 YouTube 上关注我们:https://www.youtube.com/oreillymedia

致谢

我要感谢我的妻子 Silke,她现在甚至知道什么是模式 😃 我也要感谢我的女儿 Ylvi。她们两个让我的生活更加幸福,也确保我不会整天坐在电脑前工作,而是享受生活。

这本书得益于许多模式爱好者的帮助而得以问世。我要感谢所有参与欧洲程序模式语言会议作家工作坊的参与者,他们为我的模式提供了反馈。特别感谢以下人员,在会议的指导过程中提供了非常有帮助的反馈:Jari Rauhamäki, Tobias Rauter, Andrea Höller, James Coplien, Uwe Zdun, Thomas Raser, Eden Burton, Claudius Link, Valentino Vranić, 和 Sumit Kalra。特别感谢我的工作同事,尤其是 Thomas Havlovec,他确保我在模式中正确理解了 C 编程的细节。Robert Hanmer, Michael Weiss, David Griffiths, 和 Thomas Krug 花了很多时间审查这本书,并为我提供了改进的额外想法——非常感谢!同时也感谢 O'Reilly 团队的所有成员,在这本书的问世过程中给予了我很多帮助。特别要感谢我的开发编辑 Corbin Collins 和制作编辑 Jonathon Owen。

本书的内容基于以下论文,这些论文在欧洲程序模式语言会议上被接受并由 ACM 出版。这些论文可以在网站http://www.preschern.com上免费获取。

第一部分:C 模式

模式让你的生活更轻松。它们减轻了你必须应对每一个设计决策的负担。模式向你解释了经过充分验证的解决方案,在本书的第一部分,你将找到这样的经过充分验证的解决方案及其应用所带来的后果。接下来的每个章节都专注于 C 编程的特定主题,介绍该主题上的模式,并展示它们在运行示例中的应用。

第一章:错误处理

错误处理是编写软件的重要部分,如果处理不当,软件将变得难以扩展和维护。像 C++ 或 Java 这样的编程语言提供了“异常”和“析构函数”,使错误处理变得更加容易。这些机制在 C 语言中并非原生支持,而关于在 C 中良好错误处理的文献则广泛分布在互联网上。

本章提供了关于良好错误处理的汇总知识,以 C 错误处理模式的形式和一个运行示例来应用这些模式。这些模式提供了良好的实践设计决策,并详细说明了何时应用它们及其带来的后果。对于程序员来说,这些模式消除了做出许多细粒度决策的负担。相反,程序员可以依赖这些模式中呈现的知识,并将它们作为编写良好代码的起点。

图 1-1 展示了本章涵盖的模式概述及其关系,而表 1-1 提供了模式的摘要。

模式图示/error-handling.png

图 1-1. 错误处理模式概述

表 1-1. 错误处理模式

模式名称 摘要
分割函数 函数具有多个责任,这使得函数难以阅读和维护。因此,将其拆分。从函数中找出一个看似独立有用的部分,创建一个新函数,并调用该函数。
卫语句 如果函数将前置条件检查与函数的主要程序逻辑混合在一起,那么这个函数将变得难以阅读和维护。因此,检查是否存在强制前置条件,并在不满足这些前置条件时立即返回函数。
武士道原则 在返回错误信息时,你假设调用者会检查这些信息。然而,调用者可以简单地忽略此检查,错误可能会被忽略。因此,要么成功返回,要么一败涂地。如果存在无法处理错误的情况,则终止程序。
Goto 错误处理 如果在函数内部获取并清理多个资源,代码会变得难以阅读和维护。因此,将所有资源清理和错误处理放在函数末尾。如果无法获取资源,则使用goto语句跳转到资源清理代码。
清理记录 如果代码获取并清理多个资源,特别是这些资源彼此依赖,那么很难使代码易于阅读和维护。因此,调用资源获取函数,只要它们成功,存储需要清理的函数。根据这些存储的值调用清理函数。
基于对象的错误处理 在一个函数中具有多个责任,如资源获取、资源清理和使用该资源,使得该代码难以实现、阅读、维护和测试。因此,将初始化和清理分别放入不同的函数中,类似于面向对象编程中构造函数和析构函数的概念。

运行示例

你想实现一个函数,它能解析文件以查找特定关键词,并返回关键词被找到的信息。

在 C 语言中,指示错误情况的标准方法是通过函数的返回值提供这些信息。为了提供额外的错误信息,传统的 C 函数通常会设置errno变量(见errno.h)为特定的错误代码。调用者可以检查errno以获取有关错误的信息。

然而,在下面的代码中,你只需使用返回值而不是errno,因为你不需要非常详细的错误信息。你提出了以下初始代码片段:

int parseFile(char* file_name)
{
  int return_value = ERROR;
  FILE* file_pointer = 0;
  char* buffer = 0;

  if(file_name!=NULL)
  {
    if(file_pointer=fopen(file_name, "r"))
    {
      if(buffer=malloc(BUFFER_SIZE))
      {
        /* parse file content*/
        return_value = NO_KEYWORD_FOUND;
        while(fgets(buffer, BUFFER_SIZE, file_pointer)!=NULL)
        {
          if(strcmp("KEYWORD_ONE\n", buffer)==0)
          {
            return_value = KEYWORD_ONE_FOUND_FIRST;
            break;
          }
          if(strcmp("KEYWORD_TWO\n", buffer)==0)
          {
            return_value = KEYWORD_TWO_FOUND_FIRST;
            break;
          }
        }
        free(buffer);
      }
      fclose(file_pointer);
    }
  }
  return return_value;
}

在代码中,你必须检查函数调用的返回值以了解是否发生错误,因此你的代码最终会出现深层嵌套的if语句。这导致以下问题:

  • 函数过长且混合了错误处理、初始化、清理和功能性代码。这使得维护代码变得困难。

  • 主要读取和解释文件数据的代码深层嵌套在if子句中,这使得跟踪程序逻辑变得困难。

  • 清理函数与其初始化函数相隔甚远,这使得容易忘记某些清理工作。特别是如果函数包含多个返回语句。

要改进事情,你首先执行一个功能分割。

功能分割

上下文

你有一个执行多个操作的函数。例如,它分配资源(如动态内存或某个文件句柄),使用这些资源,并清理它们。

问题

该函数具有多个责任,这使得函数难以阅读和维护。

这样的函数可能负责分配资源、对这些资源进行操作并清理这些资源。也许清理甚至散布在函数中,并在某些地方重复。特别是在处理资源分配失败的错误处理时,这种函数很难阅读,因为很多情况下会导致嵌套的if语句。

在一个函数中处理多个资源的分配、清理和使用会容易忘记清理资源,特别是如果稍后更改代码。例如,如果在代码中间添加了返回语句,则很容易忘记清理在函数的那一点上已经分配的资源。

解决方案

将其拆分开来。拿出似乎可以独立使用的函数部分,创建一个新函数,并调用该函数。

要找出函数中要隔离的部分,只需检查是否可以给它一个有意义的名称,并且是否将责任分离。例如,这可能导致一个函数仅包含功能代码,另一个函数仅包含错误处理代码。

函数是否应该分割的一个很好的指标是,如果它包含对同一资源的清理多次出现在函数中。在这种情况下,最好将代码拆分为一个函数来分配和清理资源,以及一个函数来使用这些资源。使用资源的被调函数可以轻松地具有多个返回语句,而无需在每个返回语句之前清理资源,因为这在另一个函数中已经完成了。以下是示例代码:

void someFunction()
{
  char* buffer = malloc(LARGE_SIZE);
  if(buffer)
  {
    mainFunctionality(buffer);
  }
  free(buffer);
}

void mainFunctionality()
{
  // implementation goes here
}

现在,你有两个函数而不是一个。当然,调用函数不再是自包含的,而是依赖于另一个函数。你必须确定将另一个函数放在哪里。第一步是将它放在与调用函数相同的文件中,但如果这两个函数没有紧密耦合,可以考虑将被调函数放入单独的实现文件,并包含该函数的头文件声明。

结果

你改进了代码,因为两个短函数比一个长函数更容易阅读和维护。例如,代码更易于阅读,因为清理函数与需要清理的函数更接近,并且资源分配和清理不会与主程序逻辑混合。这使得主程序逻辑更易于维护,并在以后扩展其功能时更易于扩展。

现在,由于调用函数在单个点上进行了清理,因此被调函数现在可以轻松包含多个返回语句。

如果被调函数使用了许多资源,则还必须将所有这些资源传递给该函数。有很多函数参数会使代码难以阅读,并且在调用函数时意外更改参数的顺序可能导致编程错误。为了避免这种情况,你可以在这种情况下使用聚合实例。

已知用途

下面的示例展示了这种模式的应用:

  • 几乎所有的 C 代码都包含应用这种模式的部分和不适用这种模式的部分,因此很难维护。根据 Robert C. Martin(Prentice Hall,2008)的书籍《Clean Code: A Handbook of Agile Software Craftsmanship》,每个函数应该只有一个责任(单一责任原则),因此资源处理和其他程序逻辑应始终拆分为不同的函数。

  • 这种模式在 Portland Pattern Repository 中称为函数包装器。

  • 对于面向对象编程,模板方法模式还描述了一种通过拆分代码来结构化代码的方式。

  • 函数何时以及何地拆分的标准在马丁·福勒(Martin Fowler)的《重构:改善既有代码的设计》(Addison-Wesley, 1999)中有详细描述,被称为提取方法模式。

  • 游戏 NetHack 在其函数 read_config_file 中应用了这种模式,该函数处理资源,并调用函数 parse_conf_file,后者再对这些资源进行操作。

  • OpenWrt 代码在多个地方使用此模式进行缓冲区处理。例如,负责 MD5 计算的代码分配了一个缓冲区,将此缓冲区传递给另一个函数,该函数对该缓冲区进行操作,然后清理该缓冲区。

应用于运行示例

您的代码看起来已经好多了。现在不再是一个巨大的函数,而是拥有两个具有明确职责的大型函数。一个函数负责获取和释放资源,另一个函数负责搜索关键字,如下面的代码所示:

int searchFileForKeywords(char* buffer, FILE* file_pointer)
{
  while(fgets(buffer, BUFFER_SIZE, file_pointer)!=NULL)
  {
    if(strcmp("KEYWORD_ONE\n", buffer)==0)
    {
      return KEYWORD_ONE_FOUND_FIRST;
    }
    if(strcmp("KEYWORD_TWO\n", buffer)==0)
    {
      return KEYWORD_TWO_FOUND_FIRST;
    }
  }
  return NO_KEYWORD_FOUND;
}

int parseFile(char* file_name)
{
  int return_value = ERROR;
  FILE* file_pointer = 0;
  char* buffer = 0;

  if(file_name!=NULL)
  {
    if(file_pointer=fopen(file_name, "r"))
    {
      if(buffer=malloc(BUFFER_SIZE))
      {
        return_value = searchFileForKeywords(buffer, file_pointer);
        free(buffer);
      }
      fclose(file_pointer);
    }
  }
  return return_value;
}

if 嵌套的深度减少了,但函数 parseFile 仍包含三个检查资源分配错误的 if 语句,这实在太多了。您可以通过实现守卫条款来使该函数更加清晰。

守卫条款

上下文

您有一个函数执行一个仅在某些条件(如有效输入参数)下才能成功完成的任务。

问题

由于将前置条件检查与函数的主程序逻辑混合在一起,使得函数难以阅读和维护。

分配资源总是需要进行清理。如果分配了一个资源,然后后来发现函数的另一个前置条件未满足,那么这个资源也必须清理掉。

如果函数中分散存在多个前置条件检查(特别是这些检查实现在嵌套的 if 语句中),那么很难跟踪程序流程。当存在许多此类检查时,函数本身会变得非常长,这本身就是一个代码发臭的迹象。

代码发臭

如果代码结构混乱或以难以维护的方式编程,那么代码就会“发臭”。代码发臭的例子包括非常长的函数或重复的代码。更多代码发臭的例子和对策可以在马丁·福勒的《重构:改善既有代码的设计》(Addison-Wesley, 1999)中找到。

解决方案

检查是否具有强制性前置条件,如果不满足这些前置条件,则立即从函数返回。

例如,检查输入参数的有效性,或者检查程序是否处于允许执行函数其余部分的状态。仔细考虑您希望设置的调用函数前置条件的类型。一方面,对于允许作为函数输入的内容非常严格会让您的生活更加轻松,但另一方面,如果对可能的输入更加宽容(如 Postel 法则所述:“在你的行为上要保守,在你接受他人的输入时要宽容”),这会让调用者的生活更轻松。

如果有很多前置条件检查,可以调用一个单独的函数来执行这些检查。无论如何,在分配任何资源之前执行这些检查非常重要,因为这样在函数中返回时不需要清理资源。

在函数接口中清楚地描述函数的前置条件。最好的文档位置是在声明函数的头文件中描述这种行为。

如果调用者重视未满足的前置条件,您可以向调用者提供错误信息。例如,您可以返回状态码,但请确保仅返回相关错误。以下代码显示了一个不返回错误信息的示例:

someFile.h

/* This function operates on the 'user_input', which must not be NULL */
void someFunction(char* user_input);

someFile.c

void someFunction(char* user_input)
{
  if(user_input == NULL)
  {
    return;
  }
  operateOnData(user_input);
}

影响

当未满足前置条件时立即返回,使代码比嵌套的if结构更易读。在代码中非常清楚地表明,如果未满足前置条件,则函数执行不会继续。这使得前置条件非常清晰地与代码的其余部分分离开来。

然而,某些编码指南禁止在函数中间返回。例如,对于必须正式证明的代码,通常只允许在函数的最末尾使用返回语句。在这种情况下,可以保留一个清理记录,这也是如果希望有一个集中的错误处理位置的更好选择。

已知使用情况

以下示例展示了这种模式的应用:

  • 《Portland Pattern Repository》中描述了守卫条款。

  • Klaus Renzel 的文章“Error Detection”(1997 年第二届 EuroPLoP 会议论文集)描述了非常相似的错误检测模式,建议引入前置条件和后置条件检查。

  • 游戏 NetHack 在其代码中的多处使用了这种模式,例如在placebc函数中。该函数给 NetHack 英雄戴上一条链,作为惩罚,减慢英雄的移动速度。如果没有链条对象可用,函数将立即返回。

  • OpenSSL 代码也采用了这种模式。例如,在SSL_new函数中,如果输入参数无效,函数将立即返回。

  • Wireshark 代码中的capture_stats负责在侦听网络数据包时收集统计信息,首先检查其输入参数的有效性,如果参数无效,则立即返回。

应用到运行示例

以下代码展示了 parseFile 函数如何应用守卫条款来检查函数的前置条件:

int parseFile(char* file_name)
{
  int return_value = ERROR;
  FILE* file_pointer = 0;
  char* buffer = 0;

  if(file_name==NULL) ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
  {
    return ERROR;
  }
  if(file_pointer=fopen(file_name, "r"))
  {
    if(buffer=malloc(BUFFER_SIZE))
    {
      return_value = searchFileForKeywords(buffer, file_pointer);
      free(buffer);
    }
    fclose(file_pointer);
  }
  return return_value;
}

1

如果提供了无效的参数,我们立即返回,因为尚未获取任何资源,所以不需要清理工作。

该代码使用返回状态码来实现守卫条款。在特定情况下,比如空参数,它返回常量 ERROR。调用者现在可以检查返回值,以知道是否提供了无效的 NULL 参数。但这样的无效参数通常表示编程错误,检查编程错误并在代码内传播此信息并不是一个好主意。在这种情况下,简单地应用武士道原则更为简单。

武士道原则

上下文

你的代码中有一些复杂的错误处理,而且有些错误非常严重。你的系统不执行安全关键的操作,高可用性并不十分重要。

问题

当返回错误信息时,你假设调用者会检查这些信息。然而,调用者可以简单地省略此检查,错误可能会被忽略。

在 C 语言中,不强制检查调用函数的返回值,你的调用者可以简单地忽略函数的返回值。如果在你的函数中发生的错误非常严重,而且调用者无法优雅地处理,你不希望调用者决定是否以及如何处理错误。相反,你希望确保一定会采取某种行动。

即使调用者处理了错误情况,程序往往仍然会崩溃或出现一些错误。错误可能会简单地显示在其他地方——也许是调用者的调用者代码中,可能不正确地处理错误情况。在这种情况下,处理错误会掩盖错误,这使得调试错误以找出根本原因变得更加困难。

你的代码中可能会很少发生一些错误。为了这些情况返回状态码并在调用者的代码中处理它们,会使代码变得不够清晰,因为它会分散注意力,远离主程序逻辑和调用者代码的实际目的。调用者可能需要编写许多行代码来处理很少发生的情况。

返回这种错误信息还带来了如何实际返回信息的问题。使用返回值或输出参数来返回错误信息会使函数的签名变得更复杂,使代码更难理解。因此,你不希望为函数添加仅用于返回错误信息的额外参数。

解决方案

要么函数成功返回,要么不返回(武士道原则)。如果有一种情况,你知道错误无法处理,那么就中止程序。

不要使用输出参数或返回值返回错误信息。您已经掌握了所有的错误信息,所以立即处理错误。如果发生错误,只需让程序崩溃。通过使用assert语句以结构化的方式中止程序。此外,您可以像下面的代码中所示使用assert语句提供调试信息:

void someFunction()
{
  assert(checkPreconditions() && "Preconditions are not met");
  mainFunctionality();
}

这段代码检查assert语句中的条件,如果条件不为真,则会打印assert语句及右侧的字符串到stderr,并且程序会中止。在不检查NULL指针并访问这类指针的情况下,以较少结构的方式中止程序也是可以的。确保程序在发生错误的地方崩溃即可。

通常情况下,守卫条款是在发生错误时中止程序的良好选择。例如,如果您知道发生了编码错误(如果调用者提供了NULL指针),则应中止程序并记录调试信息,而不是向调用者返回错误信息。但是,并不是每种错误都应该中止程序。例如,像无效的用户输入这样的运行时错误绝对不应导致程序中止。

调用者必须充分了解函数的行为,因此您必须在函数的 API 文档中记录函数在哪些情况下会导致程序崩溃。例如,函数文档必须说明如果函数的参数是NULL指针时程序是否会崩溃。

当然,并非所有错误或所有应用程序领域都适合采用武士原则。在某些意外用户输入的情况下,您不希望让程序崩溃。但是,在编程错误的情况下,迅速失败并让程序崩溃是合适的。这使得程序员更容易找到错误。

然而,这种崩溃并不一定要显示给用户。如果您的程序只是较大应用程序的一部分而且不是关键部分,则您可能仍希望程序崩溃。但是在整个应用程序的上下文中,您的程序可能会悄悄地失败,以免干扰其余的应用程序或用户。

发行版可执行文件中的断言

当使用assert语句时,会讨论是否仅在调试可执行文件中激活它们,或者是否还应在发布可执行文件中激活它们。可以通过在包含assert.h之前在您的代码中定义宏NDEBUG或直接在工具链中定义宏来停用assert语句。停用发布可执行文件中的assert语句的主要论据是,当测试调试可执行文件时,您已经捕获了使用assert的编程错误,因此在发布可执行文件中不需要因为assert而导致程序中止的风险。在发布可执行文件中也激活assert语句的主要论据是,您无论如何都会为不能优雅处理的关键错误使用它们,这些错误甚至在由您的客户使用的发布可执行文件中也不应被忽视。

后果

错误因为它在出现的地方被处理了,所以不能被忽视。调用者不必担心必须检查此错误,因此调用者代码变得更简单。然而,现在调用者无法选择如何对错误做出反应。

在某些情况下,中止应用程序是可以接受的,因为快速崩溃比后续的不可预测行为更好。但是,您必须考虑如何向用户呈现此类错误。也许用户会在屏幕上看到中止语句。然而,对于使用传感器和执行器与环境进行交互的嵌入式应用程序,您必须更加小心,并考虑中止程序对环境的影响以及这是否可以接受。在许多这种情况下,应用程序可能需要更强大,简单地中止应用程序将是不可接受的。

在程序中止并在错误显示的地方记录错误可以更容易地找到和修复错误,因为错误没有伪装。因此,长期以来,通过应用这种模式,您最终会得到更强大和无缺陷的软件。

已知的用途

以下示例显示了此模式的应用:

  • 建议将调试信息字符串添加到assert语句的类似模式称为断言上下文,并在《C 语言模式》(Adam Tornhill 著,Leanpub,2014)中进行了描述。

  • Wireshark 网络嗅探器在其代码中应用了这种模式。例如,函数register_capture_dissector使用assert来检查解析器的注册是否唯一。

  • Git 项目的源代码使用assert语句。例如,用于存储 SHA1 哈希值的函数使用assert来检查文件路径是否正确。

  • OpenWrt 代码负责处理大数字,在其函数中使用assert语句来检查前置条件。

  • Pekka Alho 和 Jari Rauhamäki 在文章“分布式控制系统中的轻量级容错和解耦设计模式”中提出了名为“让它崩溃”的类似模式。该模式针对分布式控制系统,建议让单个故障安全进程崩溃,然后快速重启。

  • C 标准库函数strcpy不会检查有效的用户输入。如果向函数提供NULL指针,它会崩溃。

应用于运行示例

parseFile函数现在看起来好多了。现在不再返回错误代码,而是使用了简单的assert语句。这使得以下代码更简洁,调用代码的调用者不必再检查返回值:

int parseFile(char* file_name)
{
  int return_value = ERROR;
  FILE* file_pointer = 0;
  char* buffer = 0;

  assert(file_name!=NULL && "Invalid filename");
  if(file_pointer=fopen(file_name, "r"))
  {
    if(buffer=malloc(BUFFER_SIZE))
    {
      return_value = searchFileForKeywords(buffer, file_pointer);
      free(buffer);
    }
    fclose(file_pointer);
  }
  return return_value;
}

虽然消除了不需要资源清理的if语句,但代码仍然包含需要清理的嵌套if语句。此外,如果malloc调用失败,您尚未处理错误情况。通过使用 Goto 错误处理可以改善所有这些情况。

使用 Goto 错误处理

上下文

您有一个获取和清理多个资源的函数。也许您已经尝试通过应用守护条款、函数拆分或武士原则来减少复杂性,但由于资源获取,代码中仍然存在深度嵌套的if结构。您甚至可能为资源清理编写了重复的代码。

问题

如果在函数内的不同位置获取并清理多个资源,则代码变得难以阅读和维护。

这样的代码变得困难,因为通常每个资源获取都可能失败,并且每个资源清理只有在成功获取资源时才能调用。要实现这一点,需要大量的if语句,当实现不良时,在单个函数中嵌套的if语句使代码难以阅读和维护。

由于必须清理资源,所以当出现问题时在函数中间返回并不是一个好选择。这是因为必须在每个返回语句之前清理已获取的所有资源。因此,您将在代码中多个点上清理同一资源,但不希望有重复的错误处理和清理代码。

解决方案

请在函数末尾进行所有资源清理和错误处理。如果无法获取资源,请使用goto语句跳转到资源清理代码。

按照需要的顺序获取资源,并在函数末尾按相反顺序清理资源。对于资源清理,为每个清理函数设置单独的标签,可以在发生错误或无法获取资源时跳转到该标签,但不要多次跳转,并且只像以下代码中所示那样向前跳转:

void someFunction()
{
  if(!allocateResource1())
  {
    goto cleanup1;
  }
  if(!allocateResource2())
  {
    goto cleanup2;
  }
  mainFunctionality();
cleanup2:
  cleanupResource2();
cleanup1:
  cleanupResource1();
}

如果您的编码标准禁止使用 goto 语句,则可以在您的代码周围使用 do{ ... }while(0); 循环来模拟它。发生错误时,使用 break 跳转到循环的末尾,在那里放置您的错误处理。然而,通常这种解决方法是一个坏主意,因为如果您的编码标准不允许使用 goto,那么您也不应该模拟它,以便继续按照自己的风格编程。您可以使用清理记录作为 goto 的替代方案。

无论如何,使用 goto 的情况可能仅仅是您的函数已经太复杂的一个指标,例如使用基于对象的错误处理分割函数可能是一个更好的想法。

goto:好还是坏?

关于使用 goto 是否好还是坏有很多讨论。最著名的反对使用 goto 的文章是由 Edsger W. Dijkstra 撰写的,他认为它会模糊程序流程。如果 goto 用于在程序中来回跳转,那么这是正确的,但在 C 语言中,goto 不能像 Dijkstra 所写的编程语言那样被滥用。(在 C 语言中,您只能在函数内部使用 goto。)

后果

函数是单一返回点,主程序流程与错误处理和资源清理分离良好。不再需要嵌套的 if 语句来实现此功能,但并非所有人都习惯和喜欢阅读 goto 语句。

如果使用 goto 语句,必须小心,因为很容易用于除错误处理和清理之外的其他事情,这肯定会使代码难以阅读。此外,必须特别小心确保在正确的标签上有正确的清理函数。将清理函数放错标签是一个常见的陷阱。

已知用途

下面的示例展示了这种模式的应用:

  • Linux 内核代码主要使用基于 goto 的错误处理。例如,Linux 设备驱动程序 由 Alessandro Rubini 和 Jonathan Corbet(O’Reilly,2001)描述了用于编程 Linux 设备驱动程序的基于 goto 的错误处理。

  • The CERT C 编码标准 由 Robert C. Seacord(Addison-Wesley Professional,2014)建议在错误处理中使用 goto

  • 使用 do-while 循环来模拟 goto 的方法在 Portland Pattern Repository 中描述为 Trivial Do-While-Loop 模式。

  • OpenSSL 代码使用 goto 语句。例如,处理 X509 证书的函数使用 goto 跳转到中央错误处理器。

  • Wireshark 代码使用 goto 语句从其 main 函数跳转到该函数末尾的中央错误处理器。

应用于运行示例

尽管很多人强烈反对使用 goto 语句,但与之前的代码示例相比,错误处理要好得多。在下面的代码中,没有嵌套的 if 语句,并且清理代码与主程序流程分离良好:

int parseFile(char* file_name)
{
  int return_value = ERROR;
  FILE* file_pointer = 0;
  char* buffer = 0;

  assert(file_name!=NULL && "Invalid filename");
  if(!(file_pointer=fopen(file_name, "r")))
  {
    goto error_fileopen;
  }
  if(!(buffer=malloc(BUFFER_SIZE)))
  {
    goto error_malloc;
  }
  return_value = searchFileForKeywords(buffer, file_pointer);
  free(buffer);
error_malloc:
  fclose(file_pointer);
error_fileopen:
  return return_value;
}

现在,假设您不喜欢goto语句,或者您的编码指南禁止使用它们,但仍然需要清理您的资源。还有其他替代方案。例如,您可以简单地使用清理记录。

清理记录

上下文

您有一个函数用于获取和清理多个资源。也许您已经尝试通过应用守护条款、函数分割或武士原则来减少复杂性,但由于资源获取,您的代码仍然具有深度嵌套的if结构。您甚至可能为资源清理使用了重复的代码。您的编码标准不允许您实现 Goto 错误处理,或者您不想使用goto

问题

如果该代码获取和清理多个资源(特别是这些资源彼此依赖),那么要使代码易于阅读和维护就很困难。

这很困难,因为通常每个资源获取都可能失败,并且只有在成功获取资源时才能调用每个资源清理。要实现这一点,需要大量的if语句,而且如果实现不当,在单个函数中嵌套if语句会使代码难以阅读和维护。

因为您必须清理资源,所以在函数执行过程中出现问题时立即返回并不是一个好选择。这是因为在每个返回语句之前都必须清理已获取的所有资源。因此,代码中会出现多个地方需要清理相同的资源,但您不希望重复处理错误和清理代码。

解决方案

只要成功调用资源获取函数,并存储需要清理的函数。根据这些存储的值调用清理函数。

在 C 语言中,可以使用if语句的惰性求值来实现这一点。只需在单个if语句内调用一系列函数,只要这些函数成功执行即可。对于每个函数调用,将获取的资源存储在变量中。在if语句的主体中对资源进行操作,并且仅当成功获取资源时,才在if语句后进行所有资源清理。以下代码展示了这样的一个例子:

void someFunction()
{
  if((r1=allocateResource1()) && (r2=allocateResource2()))
  {
    mainFunctionality();
  }
  if(r1) ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
  {
    cleanupResource1();
  }
  if(r2) ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
  {
    cleanupResource2();
  }
}

1

为了使代码更易于阅读,您可以选择将这些检查放在清理函数内部。如果您必须向清理函数提供资源变量,这是一个不错的方法。

结果

现在,您不再有嵌套的if语句,但仍然在函数末尾有一个中心点进行资源清理。这使得代码更易于阅读,因为主程序流不再被错误处理所遮蔽。

此外,该函数易于阅读,因为它具有单一的退出点。然而,需要许多变量来跟踪哪些资源成功分配,使得代码变得更加复杂。也许聚合实例可以帮助结构化资源变量。

如果正在获取许多资源,则在单个if语句中调用许多函数。这使得if语句非常难以阅读,甚至更难调试。因此,如果正在获取许多资源,则基于对象的错误处理是一个更好的解决方案。

采用基于对象的错误处理的另一个原因是,先前的代码仍然很复杂,因为它包含了主功能以及资源分配和清理的单个函数。因此,一个函数具有多个责任。

已知用途

以下示例展示了该模式的应用:

  • 在波特兰模式库中,介绍了一个类似的解决方案,其中每个被调用的函数向回调列表注册一个清理处理程序。在清理时,调用回调列表中的所有函数。

  • OpenSSL 函数dh_key2buf使用延迟评估的if语句来跟踪分配的字节,然后稍后进行清理。

  • Wireshark 网络嗅探器的函数cap_open_socket使用延迟评估if语句并将在此if语句中分配的资源存储在变量中。在清理时,这些变量会被检查,如果资源分配成功,则清理资源。

  • OpenWrt 源代码的nvram_commit函数在if语句中分配其资源,并将这些资源存储在该if语句的变量中。

应用于运行示例

现在,不再使用goto语句和嵌套的if语句,而是使用单个if语句。不使用goto语句的优势在于,错误处理与主程序流程很好地分离:

int parseFile(char* file_name)
{
  int return_value = ERROR;
  FILE* file_pointer = 0;
  char* buffer = 0;

  assert(file_name!=NULL && "Invalid filename");
  if((file_pointer=fopen(file_name, "r")) &&
     (buffer=malloc(BUFFER_SIZE)))
  {
    return_value = searchFileForKeywords(buffer, file_pointer);
  }
  if(file_pointer)
  {
    fclose(file_pointer);
  }
  if(buffer)
  {
    free(buffer);
  }
  return return_value;
}

尽管如此,代码看起来仍然不太好。这个函数具有太多的责任:资源分配、资源释放、文件处理和错误处理。这些责任应该分为不同的函数,并采用基于对象的错误处理。

基于对象的错误处理

上下文

您有一个函数负责获取和清理多个资源。也许您已经尝试通过应用守护条款、函数拆分或武士原则来减少复杂性,但由于资源获取而仍然存在深层嵌套的if结构。您甚至可能因资源清理而重复使用代码。但是,也许您已经通过使用 Goto 错误处理或清理记录摆脱了嵌套的if语句。

问题

在一个函数中具有多个责任,如资源获取、资源清理和资源使用,使得该代码难以实现、阅读、维护和测试。

所有这些变得困难是因为通常每个资源获取都可能失败,并且每个资源清理只能在成功获取资源时调用。为了实现这一点,需要大量的if语句,而且实现不当时,在单个函数中嵌套的if语句使得代码难以阅读和维护。

因为您必须清理资源,所以在函数中间发生错误时返回并不是一个好选择。这是因为在每个返回语句之前都必须清理已经获取的所有资源。因此,您会在代码中的多个点上清理相同的资源,但是您不希望有重复的错误处理和清理代码。

即使您已经有了清理记录或跳转到错误处理,该函数仍然很难阅读,因为它混合了不同的责任。该函数负责获取多个资源、错误处理和清理多个资源。然而,一个函数应该只有一个责任。

解决方案

将初始化和清理分开成单独的函数,类似于面向对象编程中构造函数和析构函数的概念。

在您的主函数中,只需调用一个函数来获取所有资源,一个函数来对这些资源进行操作,以及一个函数来清理这些资源。

如果获取的资源不是全局的,那么您必须将资源沿函数传递。当您拥有多个资源时,可以通过传递一个包含所有资源的聚合实例来进行传递。如果您想要隐藏来自调用者的实际资源,则可以使用句柄来在函数之间传递资源信息。

如果资源分配失败,将此信息存储在一个变量中(例如,如果内存分配失败,则为NULL指针)。在使用或清理资源时,首先检查资源是否有效。将此检查放在被调用的函数中,而不是在您的主函数中,因为这样可以使您的主函数更易读:

void someFunction()
{
  allocateResources();
  mainFunctionality();
  cleanupResources();
}

后果

现在该函数很容易阅读。虽然它需要分配和清理多个资源以及对这些资源的操作,但这些不同的任务仍然被很好地分离到不同的函数中。

拥有像对象一样的实例,将它们沿函数传递被称为“基于对象”的编程风格。这种风格使得过程式编程更类似于面向对象编程,因此用这种风格编写的代码对于习惯于面向对象的程序员来说也更加熟悉。

在主函数中,不再有多个返回语句的理由,因为不再有嵌套的if语句来处理资源分配和清理的逻辑。当然,你并没有消除关于资源分配和清理的逻辑。所有这些逻辑仍然存在于分离的函数中,但不再与资源操作混合在一起。

现在不再有单一的函数,而是多个函数。虽然这可能对性能产生负面影响,但通常影响不大。性能影响很小,对大多数应用程序来说并不重要。

已知用途

以下示例展示了这种模式的应用:

  • 这种清理形式被应用于面向对象编程中,其中构造函数和析构函数被隐式调用。

  • OpenSSL 代码使用了这种模式。例如,使用函数BUF_MEM_newBUF_MEM_free来实现缓冲区的分配和清理,在整个代码中调用这些函数来处理缓冲区。

  • OpenWrt 源代码的show_help函数在上下文菜单中显示帮助信息。该函数调用一个初始化函数来创建一个struct,然后操作该struct并调用一个函数来清理该struct

  • Git 项目的cmd__windows_named_pipe函数使用句柄创建管道,然后操作该管道,并调用一个单独的函数清理管道。

应用于运行示例

最终你得到以下代码,其中parseFile函数调用其他函数来创建和清理解析器实例:

typedef struct
{
  FILE* file_pointer;
  char* buffer;
}FileParser;

int parseFile(char* file_name)
{
  int return_value;
  FileParser* parser = createParser(file_name);
  return_value = searchFileForKeywords(parser);
  cleanupParser(parser);
  return return_value;
}

int searchFileForKeywords(FileParser* parser)
{
  if(parser == NULL)
  {
    return ERROR;
  }
  while(fgets(parser->buffer, BUFFER_SIZE, parser->file_pointer)!=NULL)
  {
    if(strcmp("KEYWORD_ONE\n", parser->buffer)==0)
    {
      return KEYWORD_ONE_FOUND_FIRST;
    }
    if(strcmp("KEYWORD_TWO\n", parser->buffer)==0)
    {
      return KEYWORD_TWO_FOUND_FIRST;
    }
  }
  return NO_KEYWORD_FOUND;
}

FileParser* createParser(char* file_name)
{
  assert(file_name!=NULL && "Invalid filename");
  FileParser* parser = malloc(sizeof(FileParser));
  if(parser)
  {
    parser->file_pointer=fopen(file_name, "r");
    parser->buffer = malloc(BUFFER_SIZE);
    if(!parser->file_pointer || !parser->buffer)
    {
      cleanupParser(parser);
      return NULL;
    }
  }
  return parser;
}

void cleanupParser(FileParser* parser)
{
  if(parser)
  {
    if(parser->buffer)
    {
      free(parser->buffer);
    }
    if(parser->file_pointer)
    {
      fclose(parser->file_pointer);
    }
    free(parser);
  }
}

在代码中,主程序流程中不再有if级联。这使得parseFile函数更易于阅读、调试和维护。主函数不再处理资源分配、资源释放或错误处理的细节。相反,这些细节都放在单独的函数中,使得每个函数只负责一项职责。

比较最终代码示例与第一个代码示例的美观之处。逐步应用的模式有助于使代码更易于阅读和维护。在每一步中,嵌套的if级联被移除,错误处理方法也得到了改进。

概要

本章向您展示了如何在 C 中执行错误处理。函数分割建议您将函数分割为更小的部分,以便更轻松地处理这些部分的错误。函数的守卫条款检查函数的前置条件,如果不满足则立即返回。这样做可以减少函数其余部分的错误处理义务。除了从函数返回之外,您还可以中止程序,遵循武士原则。在处理更复杂的错误处理时,特别是与获取和释放资源结合时,您有几个选择。使用 Goto 错误处理可以在函数中向前跳转到错误处理部分。而不是跳转,清理记录存储信息,哪些资源需要清理,并在函数结束时执行它。一种更接近面向对象编程的资源获取方法是基于对象的错误处理,它使用类似构造函数和析构函数的独立初始化和清理函数的概念。

有了这些错误处理模式,您现在具备了编写处理错误情况的小程序的技能,确保代码易于维护。

进一步阅读

如果您已经准备好进一步了解,以下是一些资源,可以帮助您深入了解错误处理的知识。

  • 波特兰模式库提供了许多关于错误处理以及其他主题的模式和讨论。大多数错误处理模式针对异常处理或如何使用断言,但也介绍了一些 C 模式。

  • 总结了错误处理的全面概述,在 Thomas Aglassinger(1999 年,奥卢大学)的硕士论文“结构化和面向对象编程语言中的错误处理”中有详细描述。该论文阐述了不同类型的错误如何产生;讨论了 C、Basic、Java 和 Eiffel 编程语言的错误处理机制;并提供了这些语言中的错误处理最佳实践,例如资源清理顺序与其分配顺序相反。论文还提到了几个第三方解决方案,例如使用setjmplongjmp命令进行异常处理的 C 库。

  • 展示了针对商业信息系统定制的 15 种面向对象的错误处理模式,文章名为“面向商业信息系统的错误处理”,由 Klaus Renzel 撰写,大多数模式也可以应用于非面向对象的领域。介绍的模式涵盖了错误检测、错误日志记录和错误处理。

  • 在 Adam Tornhill 的书《C 中的模式》(Leanpub,2014)中,展示了一些 Gang of Four 设计模式的实现,包括 C 代码片段。该书进一步以 C 模式的形式提供了最佳实践,其中一些涵盖了错误处理。

  • Andy Longshaw 和 Eoin Woods的文章《错误生成、处理和管理的模式》和《更多关于错误生成、处理和管理的模式》中呈现了一系列用于错误日志记录和错误处理的模式。大多数模式针对基于异常的错误处理。

Outlook

下一章将向您展示如何处理查看返回错误信息的较大程序,这些错误信息跨接口返回给其他函数。这些模式告诉您应该返回哪种类型的错误信息以及如何返回它。

第二章:返回错误信息

前一章重点讨论了错误处理。本章继续讨论这个问题,但侧重于如何通知代码用户检测到的错误。

对于每个较大的程序,程序员必须决定如何处理自己代码中出现的错误,如何处理第三方代码中出现的错误,如何在代码中传递这些错误信息,以及如何向用户呈现这些错误信息。

大多数面向对象的编程语言都提供了异常的便捷机制,为程序员提供了额外的返回错误信息通道,但 C 语言并不本地提供这样的机制。有办法在 C 语言中模拟异常处理,甚至在异常之间实现继承,例如在 Axel-Tobias Schreiner(2011)的书籍Object-Oriented Programming with ANSI-C中所描述的方式。但对于在传统 C 代码上工作的 C 程序员或者想要坚持他们习惯的本地 C 风格的 C 程序员来说,引入这样的异常机制并不是正确的方法。相反,这些 C 程序员需要指导如何使用 C 语言本地已经存在的错误处理机制。

本章提供了关于如何在函数之间和跨接口传输错误信息的指导。图 2-1 显示了本章涵盖的模式概述及其关系,表 2-1 提供了模式摘要。

返回错误信息模式概览

图 2-1. 返回错误信息模式概览

表 2-1. 返回错误信息的模式

模式名称 摘要
返回状态码 您希望有一种机制将状态信息返回给调用者,以便调用者可以对其做出反应。您希望该机制易于使用,并且调用者应能够清楚地区分可能发生的不同错误情况。因此,使用函数的返回值来返回状态信息。返回代表特定状态的值。作为调用方和被调用方,您都必须对该值的含义有共同的理解。
返回相关错误 一方面,调用者应能够对错误做出反应;另一方面,您返回的错误信息越多,您和调用者的代码就必须处理错误处理,这使得代码变得更长。更长的代码更难阅读和维护,并带来额外错误的风险。因此,只有当信息对调用者有关联时,才将错误信息返回给调用者。只有当调用者能够对信息做出反应时,错误信息才与调用者相关。
特殊返回值 你想要返回错误信息,但又不想显式返回状态码,因为这会使得函数难以返回其他数据。你可以向函数添加输出参数,但这会使调用函数更加困难。因此,使用函数的返回值来返回函数计算的数据。为错误发生时保留一个或多个特殊值来返回。
记录错误 你希望确保在出现错误时能够轻松找出其原因。但是,你不希望因此使你的错误处理代码变得复杂。因此,使用不同的渠道返回对调用代码重要的错误信息和对开发人员重要的错误信息。例如,将调试错误信息写入日志文件,并且不将详细的调试错误信息返回给调用者。

运行示例

你希望实现一个软件模块,该模块提供存储由字符串标识的键对应的字符串值的功能。换句话说,你想要实现类似于 Windows 注册表的功能。为了保持简单,以下代码不包含键之间的层次关系,并且只讨论创建注册表元素的功能:

注册表 API

/* Handle for registry keys */
typedef struct Key* RegKey;

/* Create a new registry key identified via the provided 'key_name' */
RegKey createKey(char* key_name);

/* Store the provided 'value' to the provided 'key' */
void storeValue(RegKey key, char* value);

/* Make the key available for being read (by other
 functions that are not part of this code example) */
void publishKey(RegKey key);

注册表实现

#define STRING_SIZE 100
#define MAX_KEYS 40

struct Key
{
  char key_name[STRING_SIZE];
  char key_value[STRING_SIZE];
};

/* file-global array holding all registry keys */
static struct Key* key_list[MAX_KEYS];

RegKey createKey(char* key_name)
{
  RegKey newKey = calloc(1, sizeof(struct Key));
  strcpy(newKey->key_name, key_name);
  return newKey;
}

void storeValue(RegKey key, char* value)
{
  strcpy(key->key_value, value);
}

void publishKey(RegKey key)
{
  int i;
  for(i=0; i<MAX_KEYS; i++)
  {
    if(key_list[i] == NULL)
    {
      key_list[i] = key;
      return;
    }
  }
}

针对上述代码,你不确定在内部错误或例如无效的函数输入参数值的情况下应该如何向调用者提供错误信息。你的调用者不确定调用是否成功或是否失败,最终使用以下代码:

RegKey my_key = createKey("myKey");
storeValue(my_key, "A");
publishKey(my_key);

调用者的代码非常简短且易于阅读,但调用者不知道是否发生了任何错误,也无法对错误做出反应。为了给调用者这种可能性,你希望在你的代码中引入错误处理,并为调用者提供错误信息。你首先想到的一个想法是让调用者了解软件模块中出现的任何错误。为此,你使用返回状态码。

返回状态码

上下文

你正在实现一个进行一些错误处理的软件模块,并且希望向调用者返回错误和其他状态信息。

问题

你希望有一种机制来向调用者返回状态信息,以便调用者可以对其做出反应。你希望这种机制使用简单,并且调用者能够清楚地区分可能发生的不同错误情况。

在早期的 C 语言中,错误信息通过全局变量 errno 的错误码传递。调用者必须重置全局变量 errno,然后调用函数,并且函数通过设置全局变量 errno 来指示错误,调用者在函数调用后必须检查此变量。

然而,与使用 errno 相比,你需要一种返回状态信息的方法,使调用者更容易检查错误。调用者应从函数签名中看到将返回的状态信息以及期望的状态信息类型。

此外,返回状态信息的机制应该在多线程环境中安全使用,只有被调用的函数应该有能力影响返回的状态信息。换句话说,应该可以使用该机制并仍然具有可重入函数。

解决方案

使用函数的返回值来返回状态信息。返回一个代表特定状态的值。作为调用者和被调用者,必须互相理解该值的含义。

通常,返回的值是一个数字标识符。调用者可以根据该标识符检查函数返回值,并据此做出反应。如果函数必须返回其他函数结果,则以输出参数的形式提供给调用者。

在你的 API 中定义数字状态标识符,可以使用 enum 或者使用 #define。如果有许多状态码或者你的软件模块包含多个头文件,则可以有一个单独的头文件,仅包含状态码,并由其他头文件包含。

将状态标识符命名为有意义的名称,并使用注释说明其含义。确保在整个 API 中一致地命名你的状态码。

以下代码展示了使用状态码的示例:

调用者使用状态码的代码

ErrorCode status = func();
if(status == MAJOR_ERROR)
{
  /* abort program */
}
else if(status == MINOR_ERROR)
{
  /* handle error */
}
else if(status == OK)
{
  /* continue normal execution */
}

调用者 API 提供状态码

typedef enum
{
  MINOR_ERROR,
  MAJOR_ERROR,
  OK
}ErrorCode;

ErrorCode func();

调用者实现提供状态码

ErrorCode func()
{
  if(minorErrorOccurs())
  {
    return MINOR_ERROR;
  }
  else if(majorErrorOccurs())
  {
    return MAJOR_ERROR;
  }
  else
  {
    return OK;
  }
}

结果

现在你有了一种返回状态信息的方法,使调用者非常容易检查发生的错误。与 errno 相比,调用者不必在函数调用的步骤中设置和检查错误信息。相反,调用者可以直接根据函数调用的返回值检查信息。

返回状态码可以安全地在多线程环境中使用。调用者可以确保只有被调用的函数会影响返回的状态,而没有其他侧信道。

函数签名非常清楚地说明了如何返回状态信息。这对调用者、编译器或静态代码分析工具都很清楚,它们可以检查调用者是否检查了函数返回值及其可能发生的所有状态。

由于函数现在在不同的错误情况下提供不同的结果,这些结果必须进行测试。与没有任何错误处理的函数相比,必须进行更广泛的测试。此外,调用者需要负担检查这些错误情况的责任,这可能会增加调用者代码的大小。

任何 C 函数只能返回函数签名中指定类型的一个对象,且该函数现在返回状态码。因此,你必须使用更复杂的技术来返回其他函数结果。你可以使用输出参数来做到这一点,但这种方法的缺点是需要额外的参数,或者你可以返回包含状态信息和其他函数结果的聚合实例。

已知的使用情况

以下示例展示了该模式的应用:

  • Microsoft 使用 HRESULT 返回状态信息。HRESULT 是一个唯一的状态码。使状态码唯一的优势是可以在许多函数间传递状态信息,同时仍然可以找出该状态的来源。但使状态码唯一需要额外的努力来分配状态号并跟踪允许谁使用哪些状态号。HRESULT 的另一个特殊之处在于,它通过使用一些专用于返回此信息的位来编码特定信息,如错误的严重性。

  • Apache 可移植运行时代码定义了类型 apr_status_t 以返回错误信息。以此方式返回错误信息的任何函数在成功时返回 APR_SUCCESS,或通过 #define 语句指定的唯一定义的错误代码返回其他值。

  • OpenSSL 代码在多个头文件(dsaerr.hkdferr.h,……)中定义了状态码。例如,状态码 KDF_R_MISSING_PARAMETERKDF_R_MISSING_SALT 详细通知调用者有关缺少或错误输入参数的情况。每个文件中的状态码仅为属于该文件的特定一组函数定义,并且状态码值在整个 OpenSSL 代码中不是唯一的。

  • 错误代码模式在 Portland 模式库中有描述。它描述了通过显式使用函数的返回值返回错误信息的想法。

应用于运行示例

现在,在你的代码中提供错误信息给调用者。在下面的代码中,你检查可能出错的情况并将该信息提供给调用者:

Registry API

/* Error codes returned by this registry */
typedef enum
{
  OK,
  OUT_OF_MEMORY,
  INVALID_KEY,
  INVALID_STRING,
  STRING_TOO_LONG,
  CANNOT_ADD_KEY
}RegError;

/* Handle for registry keys */
typedef struct Key* RegKey;

/* Create a new registry key identified via the provided 'key_name'.
 Returns OK if no problem occurs, INVALID_KEY if the 'key'
 parameter is NULL, INVALID_STRING if 'key_name' is NULL,
 STRING_TOO_LONG if 'key_name' is too long, or OUT_OF_MEMORY
 if no memory resources are available. */
RegError createKey(char* key_name, RegKey* key);

/* Store the provided 'value' to the provided 'key'.
 Returns OK if no problem occurs, INVALID_KEY if the 'key'
 parameter is NULL, INVALID_STRING if 'value' is NULL, or
 STRING_TOO_LONG if 'value' is too long. */
RegError storeValue(RegKey key, char* value);

/* Make the key available for being read. Returns OK if no
 problem occurs, INVALID_KEY if 'key' is NULL, or CANNOT_ADD_KEY
 if the registry is full and no more keys can be published. */
RegError publishKey(RegKey key);

Registry implementation

#define STRING_SIZE 100
#define MAX_KEYS 40

struct Key
{
  char key_name[STRING_SIZE];
  char key_value[STRING_SIZE];
};

/* file-global array holding all registry keys */
static struct Key* key_list[MAX_KEYS];

RegError createKey(char* key_name, RegKey* key)
{
  if(key == NULL)
  {
    return INVALID_KEY;
  }

  if(key_name == NULL)
  {
    return INVALID_STRING;
  }

  if(STRING_SIZE <= strlen(key_name))
  {
    return STRING_TOO_LONG;
  }

  RegKey newKey = calloc(1, sizeof(struct Key));
  if(newKey == NULL)
  {
    return OUT_OF_MEMORY;
  }

  strcpy(newKey->key_name, key_name);
  *key = newKey;
  return OK;
}

RegError storeValue(RegKey key, char* value)
{
  if(key == NULL)
  {
    return INVALID_KEY;
  }

  if(value == NULL)
  {
    return INVALID_STRING;
  }

  if(STRING_SIZE <= strlen(value))
  {
    return STRING_TOO_LONG;
  }

  strcpy(key->key_value, value);
  return OK;
}

RegError publishKey(RegKey key)
{
  int i;
  if(key == NULL)
  {
    return INVALID_KEY;
  }

  for(i=0; i<MAX_KEYS; i++)
  {
    if(key_list[i] == NULL)
    {
      key_list[i] = key;
      return OK;
    }
  }

  return CANNOT_ADD_KEY;
}

现在调用者可以对提供的错误信息做出反应,例如,可以向应用程序的用户提供有关出错原因的详细信息:

Caller’s code

  RegError err;
  RegKey my_key;

  err = createKey("myKey", &my_key);
  if(err == INVALID_KEY || err == INVALID_STRING)
  {
    printf("Internal application error\n");
  }
  if(err == STRING_TOO_LONG)
  {
    printf("Provided registry key name too long\n");
  }
  if(err == OUT_OF_MEMORY)
  {
    printf("Insufficient resources to create key\n");
  }

  err = storeValue(my_key, "A");
  if(err == INVALID_KEY || err == INVALID_STRING)
  {
    printf("Internal application error\n");
  }
  if(err == STRING_TOO_LONG)
  {
    printf("Provided registry value to long to be stored to this key\n");
  }

  err = publishKey(my_key);
  if(err == INVALID_KEY)
  {
    printf("Internal application error\n");
  }
  if(err == CANNOT_ADD_KEY)
  {
    printf("Key cannot be published, because the registry is full\n");
  }

现在调用者可以对错误做出反应,但是注册表软件模块的代码以及调用者的代码大小都增加了一倍多。通过为错误代码映射到错误文本的功能提供单独的函数,可以稍微简化调用者代码,但大部分代码仍需处理错误处理。

可以看出,错误处理并非没有代价。在实现错误处理时投入了大量的工作。这也可以从注册表 API 中看出来。函数的注释变得更长,因为它们必须描述可能发生的错误情况。调用者还必须花费大量精力考虑如果出现特定错误应该怎么办。

当向调用者提供如此详细的错误信息时,你会使调用者负担得要对这些错误做出反应,并考虑哪些错误是需要处理的,哪些是无关紧要的。因此,必须特别小心,一方面提供调用者必要的错误信息,另一方面不要向调用者提供过多不必要的信息。

接下来,在你的代码中,你希望考虑这些因素,并且只提供对调用者实际有用的错误信息。因此,你只返回相关的错误。

返回相关的错误

背景

你正在实现一个软件模块来执行一些错误处理,并且你希望向调用者返回错误信息。

问题

一方面,调用者应能够对错误做出反应;另一方面,返回的错误信息越多,你和你的调用者的代码就越多地涉及错误处理,这会使代码变得更长。代码越长,阅读和维护起来就越困难,并且会带来额外的错误风险。

为了向调用者返回错误信息,检测错误并返回信息并不是你的唯一任务。你还必须在 API 文档中记录返回的错误。如果不这样做,调用者将不知道可以期望和处理哪些错误。文档化错误行为是必须要做的工作。错误类型越多,需要做的文档工作就越多。

如果实现发生变化,向调用者提供非常详细的、与实现相关的错误信息,并在你的代码中稍后添加额外的错误信息,这意味着随着实现变化,你必须语义上改变文档化的接口,以记录返回的错误信息。对于现有的调用者来说,这样的变化可能是不可取的,因为他们将不得不调整其代码以对新引入的错误信息做出反应。

为调用者提供详细的错误信息也并非总是件好事。每返回一个错误信息都意味着调用者要做额外的工作。调用者必须决定错误信息是否相关,以及如何处理它。

解决方案

只有当错误信息对调用者有意义时,才将其返回给调用者。如果调用者可以对该信息做出反应,那么错误信息才对调用者有意义。

如果调用者无法对错误信息做出反应,那么为其提供这种机会(或负担)就是不必要的。

有几种方法可以只返回相关的错误信息。一种极端的方式是根本不返回任何错误信息。例如,当你有一个函数cleanupMemory (void* handle)来清理内存时,如果清理成功,则无需返回信息,因为调用者无法在代码中对这样的清理错误做出反应(大多数情况下重新调用清理函数并不是解决方案)。因此,该函数根本不返回任何错误信息。为了确保函数内部的错误不会被忽视,甚至可以在错误发生时中止程序(侍者原则)。

或者想象一下,你将错误返回给调用者的唯一原因是让调用者能够记录此错误。在这种情况下,不要将错误返回给调用者,而是自己简单地记录错误,以便为调用者简化生活。

如果已经返回状态码,则应仅返回对调用者有关的错误信息。调用的函数中产生的其他错误可以总结为一个内部错误代码。此外,无需必须将调用的函数的详细错误代码全部返回。它们可以总结为一个内部错误代码,如下面的代码所示:

调用者的代码

ErrorCode status = func();
if(status == MAJOR_ERROR || status == UNKNOWN_ERROR)
{
  /* abort program */
}
else if(status == MINOR_ERROR)
{
  /* handle error */
}
else if(status == OK)
{
  /* continue normal execution*/
}

API

typedef enum
{
  MINOR_ERROR,
  MAJOR_ERROR,
  UNKNOWN_ERROR,
  OK
}ErrorCode;

ErrorCode func();

实施

ErrorCode func()
{
  if(minorErrorOccurs())
  {
    return MINOR_ERROR;
  }
  else if(majorErrorOccurs())
  {
    return MAJOR_ERROR;
  }
  else if(internalError1Occurs() || internalError2Occurs())
  {
    return UNKNOWN_ERROR; ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
  }
  else
  {
    return OK;
  }
}

1

如果internalError1Occursinternal​Er⁠ror2Occurs,你返回相同的错误信息,因为对于调用者来说,发生哪种实现特定的错误是无关紧要的。调用者将以相同的方式对两种错误做出反应(在前面的例子中,反应是中止程序)。

如果需要更详细的错误信息以进行调试,可以记录错误。如果意识到只返回相关错误后,错误情况并不多,那么与其使用错误代码,不如使用特殊的返回值来返回错误信息可能是一个更好的解决方案。

后果

不返回有关内部错误种类的详细信息对于调用者来说是一种解脱。调用者不必费心考虑如何处理所有可能发生的内部错误,因此更有可能对返回的所有错误做出反应。此外,测试人员也会感到高兴,因为现在函数返回的错误信息更少,需要测试的错误情况也更少。

如果调用者使用非常严格的编译器或静态代码分析工具来验证调用者是否检查了所有可能的返回值,调用者就不需要显式处理无关的错误(例如,一个带有许多贯穿情况和一个中心错误处理代码用于所有内部错误的开关语句)。相反,调用者只处理一个内部错误代码,或者如果在错误发生时中止程序,调用者就不需要处理任何错误。

不返回详细的错误信息会使调用者无法将此错误信息显示给用户或保存此错误信息以便开发者进行调试。然而,为了调试目的,最好直接在发生错误的软件模块中记录错误信息,而不是让调用者来处理。

如果在函数中不返回关于发生的错误的所有信息,而是只返回你认为对调用者相关的信息,那么可能会出现错误。你可能会忘记一些对调用者来说必要的信息,也许这会导致一个更改请求来添加这些信息。但是如果返回状态码,则可以轻松地添加额外的错误代码而无需更改函数签名。

已知用途

以下示例展示了此模式的应用:

  • 对于安全相关的代码,在错误情况下只返回相关信息非常常见。例如,如果一个用于验证用户的函数返回详细信息说明为什么验证不起作用,因为用户名或密码无效,那么调用者可以使用该函数来检查哪些用户名已被使用。为了避免通过这些信息打开侧信道,通常只返回关于验证是否成功的二进制信息。例如,用于在 B&R Automation Runtime 操作系统中验证用户的函数rbacAuthenticateUserPassword返回类型为bool,如果验证成功则返回true,否则返回false。不返回关于为何验证未能成功的详细信息。

  • 游戏 NetHack 的函数FlushWinFile将文件刷新到磁盘,并调用 Macintosh 函数FSWrite,该函数会返回错误代码。然而,NetHack 的包装器明确忽略了错误代码,而FlushWinFile的返回类型是void,因为使用该函数的代码无法根据错误情况作出相应反应。因此,错误信息没有传递。

  • OpenSSL 函数EVP_CIPHER_do_all使用内部函数OPENSSL_init_crypto初始化密码套件,并返回状态码。然而,EVP_CIPHER_do_all函数忽略了这些详细的错误信息,因为其返回类型为void。因此,通过包装函数将返回详细错误信息的策略更改为仅返回相关错误,这种情况下即为不返回任何错误信息。

应用于运行示例

当你只返回相关的错误信息时,你的注册码看起来像下面这样。为了简化,这里只显示了createKey函数:

实现函数 *createKey*

RegError createKey(char* key_name, RegKey* key)
{
  if(key == NULL || key_name == NULL)
  {
    return INVALID_PARAMETER; ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
  }

  if(STRING_SIZE <= strlen(key_name))
  {
    return STRING_TOO_LONG;
  }

  RegKey newKey = calloc(1, sizeof(struct Key));
  if(newKey == NULL)
  {
    return OUT_OF_MEMORY;
  }

  strcpy(newKey->key_name, key_name);
  *key = newKey;
  return OK;
}

1

现在不再返回INVALID_KEYINVALID_STRING,而是对所有这些错误情况返回INVALID_PARAMETER

现在调用方无法处理特定的无效参数,这也意味着调用方不必考虑如何单独处理这些错误情况。现在调用方的代码变得更简单,因为现在少了一个需要处理的错误情况。

这很好,因为如果函数返回INVALID_KEYINVALID_STRING,调用方该怎么办呢?对调用方来说,再次调用函数没有任何意义。在这两种情况下,调用方可以接受调用函数未能成功,并向用户报告或中止程序。由于调用方没有理由对这两种错误做出不同的反应,您已经解除了调用方思考两种不同错误情况的负担。现在调用方只需考虑一种错误情况,然后相应地做出反应。

为了使事情更加简单,您接下来应用了武士原则。与其返回所有这些错误码,不如通过中止程序处理其中一些错误:

函数createKey的声明

/* Create a new registry key identified via the provided 'key_name'
 (must not be NULL, max. STRING_SIZE characters). Stores a handle
 to the key in the provided 'key' parameter (must not be NULL).
 Returns OK on success, or OUT_OF_MEMORY in case of insufficient memory. */
RegError createKey(char* key_name, RegKey* key);

函数createKey的实现

RegError createKey(char* key_name, RegKey* key)
{
  assert(key != NULL && key_name != NULL); ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
  assert(STRING_SIZE > strlen(key_name)); ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)

  RegKey newKey = calloc(1, sizeof(struct Key));
  if(newKey == NULL)
  {
    return OUT_OF_MEMORY;
  }

  strcpy(newKey->key_name, key_name);
  *key = newKey;
  return OK;
}

1

而不是返回INVALID_PARAMETERSTRING_TOO_LONG,如果提供的参数之一不符合您的预期,则现在中止程序。

在字符串过长的情况下中止看起来有些过激。但是,类似于NULL指针,对于您的函数来说,过长的字符串是无效的输入。如果您的注册表不是通过 GUI 从用户那里获取其字符串输入,而是从调用方代码获取固定输入,那么对于过长的字符串,此代码仅在编程错误的情况下中止,这是完全合理的行为。

接下来,您意识到createKey函数仅返回两种不同的错误码:OUT_OF_MEMORYOK。通过简单地提供这种类型的错误信息,您的代码可以变得更加优美。

特殊返回值

背景

您有一个计算某些结果的函数,如果执行函数时发生错误,您希望向调用方提供错误信息。您只想返回相关的错误。

问题

您想返回错误信息,但又不想显式地返回状态码,因为这会使您的函数难以返回其他数据。您可以向函数添加输出参数,但这会使调用函数变得更加困难。

对于您来说,根本不提供任何错误信息也不是一个选项。您希望向调用方提供一些错误信息,并希望调用方能够对这些错误做出反应。您希望向调用方提供的错误信息并不多。它可能只是关于函数调用是否成功的二进制信息。为这样简单的信息返回状态码会显得有些过度。

你不能应用武士原则并中止程序,因为在你的函数中发生的错误并不严重。或者也许你想让调用者决定如何处理错误,因为调用者可以优雅地处理错误。

解决方案

使用函数的返回值返回函数计算的数据。保留一个或多个特殊值以便在发生错误时返回。

例如,如果你的函数返回一个指针,那么可以使用NULL指针作为保留的特殊值来指示发生了某些错误。根据定义,NULL指针是一个无效指针,所以你可以确保这个特殊值不会与函数计算出的有效指针混淆。以下代码展示了在使用指针时如何返回错误信息:

被调用者的实现

void* func()
{
  if(somethingGoesWrong())
  {
    return NULL;
  }
  else
  {
    return some_pointer;
  }
}

调用者的代码

pointer = func();
if(pointer != NULL)
{
  /* operate on the pointer */
}
else
{
  /* handle error */
}

你必须确保在 API 中记录返回的特殊值的含义。在某些情况下,常见的约定规定了哪些特殊值表示错误。例如,通常使用负整数值来表示错误。但即使在这种情况下,特定返回值的含义也必须记录。

你必须确保指示错误信息的特殊值是在没有错误时不会发生的值。例如,如果一个函数以摄氏度为整数值返回温度值,那么沿用 UNIX 惯例,任何负值表示错误并不是一个好主意。相反,使用例如-300 来表示错误会更好,因为温度不可能低于-273 摄氏度。

结果

即使返回值用于返回函数的计算结果,函数现在也可以通过返回值返回错误信息。不需要使用额外的输出参数来提供错误信息。

有时候你没有很多特殊值来编码错误信息。例如,对于指针来说,只有NULL指针用于指示错误信息。这导致了只能告知调用者一切是否顺利或是否出现问题的情况。这种做法的缺点是你不能返回详细的错误信息。但好处是你不会诱使返回不必要的错误信息。在许多情况下,只提供出现错误的信息是足够的,因为调用者无法对更详细的信息做出反应。

如果在稍后的时间点意识到需要提供更详细的错误信息,则可能不再可能,因为已经没有未使用的特殊值了。您将不得不更改整个函数签名,而不是返回状态码以提供额外的错误信息。更改函数签名可能并非总是一个选择,因为您的 API 可能必须保持兼容性以供现有调用者使用。如果预期将来会有这样的更改,请不要立即使用特殊返回值,而是立即返回状态码。

有时程序员假设清楚哪些返回值表示错误。例如,对于一些程序员来说,NULL指针表示错误是显而易见的。对于其他程序员来说,−1 表示错误是显而易见的。这带来了一个危险的情况,即程序员假设所有人都清楚哪些值表示错误。然而,这些只是假设。在任何情况下,API 中应该详细记录哪些值表示错误,但有时程序员会忘记这样做,错误地假设这一点是绝对清楚的。

已知用途

下面的示例展示了此模式的应用场景:

  • NetHack 游戏的getobj函数如果没有错误会返回某个对象的指针,如果有错误会返回NULL。为了指示没有对象返回的特殊情况,该函数返回指向一个名为zeroobj的全局对象的指针,该对象是函数定义返回类型的一个对象,且已知给调用者。调用者可以检查返回的指针是否与指向全局对象zeroobj的指针相同,从而区分指向任何有效对象和携带某些特殊含义的zeroobj指针。

  • C 标准库函数getcharstdin读取一个字符。该函数的返回类型是int,允许返回比简单字符更多的信息。如果没有更多字符可用,该函数返回EOF,通常定义为−1。由于字符不能接受负整数表示,EOF可以清楚地区分于常规函数结果,并因此用于指示无更多字符可用的特殊情况。

  • 大多数 UNIX 或 POSIX 函数使用负数来指示错误信息。例如,POSIX 函数write在错误时返回写入的字节数或−1。

应用于运行示例

使用特殊返回值,你的代码看起来像下面这样。为了简单起见,只显示了createKey函数:

函数的声明 *createKey*

/* Create a new registry key identified via the provided 'key_name'
 (must not be NULL, max. STRING_SIZE characters).
 Returns a handle to the key or NULL on error. */
RegKey createKey(char* key_name);

函数的实现 *createKey*

RegKey createKey(char* key_name)
{
  assert(key_name != NULL);
  assert(STRING_SIZE > strlen(key_name));

  RegKey newKey = calloc(1, sizeof(struct Key));
  if(newKey == NULL)
  {
    return NULL;
  }

  strcpy(newKey->key_name, key_name);
  return newKey;
}

现在,createKey函数简单得多了。它不再返回状态码,而是直接返回句柄,不需要输出参数来返回这些信息。函数的 API 文档也变得简单得多,因为不需要描述额外的参数,也不需要详细描述函数结果将如何返回给调用者。

对于你的调用者而言,情况也简单得多了。调用者不再需要将句柄作为输出参数提供,而是直接通过返回值检索这个句柄,这使得调用者的代码更加可读,从而更易于维护。

但是,现在你面临的问题是,与你可以在返回状态码时提供的详细错误信息相比,函数输出的唯一错误信息是它是否成功运行。关于错误的内部详细信息被丢弃了,如果以后需要这些详细信息,例如作为调试信息,那么就无法获取。为了解决这个问题,你可以记录错误

记录错误

背景

你有一个处理错误的函数。你只想向你的调用者返回相关的错误,以便在代码中对其做出反应,但你希望保留详细的错误信息以供以后调试使用。

问题

你希望确保在出现错误时能够轻松找出其原因。但是,你不希望由于这个原因使得你的错误处理代码变得复杂。

一种做法是向调用者返回非常详细的错误信息,比如直接向调用者返回表示编程错误的错误信息。为此,你可以向调用者返回状态码,然后调用者向用户显示详细的错误代码。用户可能会通过某种服务热线与你联系,询问错误代码的含义以及如何解决问题。然后你会得到详细的错误信息来调试代码,找出问题出在哪里。

然而,这种方法的主要缺点是,那些完全不关心错误信息的调用者,仅为了向你提供这些错误信息而必须向用户提供错误信息。用户也实际上并不关心这样详细的错误信息。

此外,返回状态码的缺点是,你必须使用函数的返回值来返回错误信息,并且必须使用额外的输出参数来提供实际的函数结果。在某些情况下,你可以通过特殊的返回值提供错误信息,但这并不总是可行。你不希望为你的函数添加额外的参数,仅仅是为了提供错误信息,因为这会使调用者的代码更加复杂。

解决方案

使用不同的通道提供与调用代码相关的错误信息和与开发人员相关的错误信息。例如,将调试错误信息写入日志文件,而不将详细的调试错误信息返回给调用者。

如果发生错误,程序的用户必须提供已记录的调试信息,以便你能轻松找出错误的原因。例如,用户必须通过电子邮件发送给你一个日志文件。

或者,你可以在你和调用者之间的接口处记录错误,并且还向调用者返回相关的错误。例如,调用者可以被告知发生了某种内部错误,但是调用者看不到具体发生了什么样的错误。因此,调用者仍然可以在代码中处理错误,而不需要知道如何处理非常详细的错误,而你也不会丢失有价值的调试信息。

为了不丢失有价值的调试信息,你应该记录有关编程错误和意外错误的信息。对于这些错误,存储其严重性和错误发生位置的信息非常有价值,例如源代码文件名和行号,或者回溯信息。C 语言提供了特殊的宏来获取当前行号(__LINE__)、当前函数(__func__)或当前文件(__FILE__)的信息。以下代码使用了__func__宏进行日志记录:

void someFunction()
{
  if(something_goes_wrong)
  {
     logInFile("something went wrong", ERROR_CODE, __func__);
  }
}

为了获得更详细的日志记录,甚至可以追踪函数调用并记录它们的返回信息。这样做可以更容易地通过这些日志反向工程化错误情况,但当然,这种日志记录也会引入计算开销。要追踪函数调用的返回值,可以使用以下代码:

#define RETURN(x)          \
do {                       \
 logInFile(__func__, x);  \
 return x;                \
} while (0)

int soneFunction()
{
  RETURN(-1);
}

日志信息可以存储在文件中,就像前面的代码所示。你必须处理诸如内存不足以存储文件或程序在写入文件时崩溃等特殊情况。处理这些情况并不是一件容易的事情,但对于你的日志记录机制来说,拥有健壮的代码非常重要,因为以后你将依赖日志文件进行调试。如果这些文件中的数据不正确,那么在追踪编码错误时可能会被误导。

结果

你可以获取调试信息,而不需要调用者处理或传输这些信息。这样可以大大简化调用者的生活,因为调用者不必处理或传输详细的错误信息。相反,你可以自己提供详细的错误信息。

在某些情况下,你可能只想记录发生的某些错误或情况,但这对调用者完全无关。因此,你甚至不需要将任何错误信息返回给调用者。例如,如果发生错误时中止程序,调用者根本不需要对错误做出反应,而且你仍然可以确保不会丢失宝贵的调试信息,如果你记录错误的话。因此,在返回错误信息时,函数不需要额外的必填参数,这样调用函数会更容易,帮助调用者保持代码的整洁。

你不会丢失这些宝贵的错误信息,仍然可以用于调试目的来追踪编程错误。为了不丢失这些调试信息,你可以通过不同的渠道提供它,例如通过日志文件。但是,你必须考虑如何获取这些日志文件。你可以要求用户通过电子邮件发送日志文件,或者更高级的方法是实施一些自动错误报告机制。然而,无论采用哪种方法,都不能百分之百确保日志信息真的会传回给你。如果用户不想这样,他们可以阻止它。

已知用途

下面的示例展示了此模式的应用:

  • Apache Web 服务器代码使用函数ap_log_error来写入与请求或连接相关的错误到错误日志中。这样的日志条目包含错误发生的文件名和代码行,以及由调用者提供给函数的自定义字符串。日志信息存储在服务器上的error_log文件中。

  • B&R Automation Runtime 操作系统使用一个日志系统,允许程序员在代码的任何位置通过调用函数eventLogWrite向用户提供日志信息。这样就可以在不需要将此信息返回到整个调用堆栈直到某个中央日志组件的情况下向用户提供信息。

  • 书籍《C 中的模式》(Leanpub,2014)中的断言上下文模式建议在出现错误时中止程序,并通过在assert调用中添加字符串语句来记录崩溃原因或位置的信息。如果assert失败,则将打印包含assert语句的代码行,其中包括添加的字符串。

应用于运行示例

应用模式后,你将获得注册表软件模块的最终代码。这段代码向调用者提供相关的错误信息,但不需要调用者处理任何内部错误情况:

注册表 API

/* max. size of string parameters (including NULL-termination) */
#define STRING_SIZE 100

/* Error codes returned by this registry */
typedef enum
{
  OK,
  CANNOT_ADD_KEY
}RegError;

/* Handle for registry keys */
typedef struct Key* RegKey;

/* Create a new registry key identified via the provided 'key_name'
 (must not be NULL, max. STRING_SIZE characters).  Returns a handle
 to the key or NULL on error. */
RegKey createKey(char* key_name);

/* Store the provided 'value' (must not be NULL, max. STRING_SIZE characters)
 to the 'key' (MUST NOT BE NULL) */
void storeValue(RegKey key, char* value);

/* Make the 'key' (must not be NULL) available for being read.
 Returns OK if no problem occurs or CANNOT_ADD_KEY if the
 registry is full and no more keys can be published. */
RegError publishKey(RegKey key);

注册表实现

#define MAX_KEYS 40

struct Key
{
  char key_name[STRING_SIZE];
  char key_value[STRING_SIZE];
};

/* macro to log debug info and to assert */
#define logAssert(X)                        \
if(!(X))                                    \
{                                           \
 printf("Error at line %i", __LINE__);     \
 assert(false);                            \
}

/* file-global array holding all registry keys */
static struct Key* key_list[MAX_KEYS];

RegKey createKey(char* key_name)
{
  logAssert(key_name != NULL)
  logAssert(STRING_SIZE > strlen(key_name))

  RegKey newKey = calloc(1, sizeof(struct Key));
  if(newKey == NULL)
  {
    return NULL;
  }

  strcpy(newKey->key_name, key_name);
  return newKey;
}

void storeValue(RegKey key, char* value)
{
  logAssert(key != NULL && value != NULL)
  logAssert(STRING_SIZE > strlen(value))

  strcpy(key->key_value, value);
}

RegError publishKey(RegKey key)
{
  logAssert(key != NULL)

  int i;
  for(i=0; i<MAX_KEYS; i++)
  {
    if(key_list[i] == NULL)
    {
      key_list[i] = key;
      return OK;
    }
  }

  return CANNOT_ADD_KEY;
}

与先前示例中的运行代码相比,这段代码更短,原因如下:

  • 代码不再检查编程错误,而是在发生编程错误时中止程序。代码中不再优雅地处理无效参数如NULL指针;相反,API 文档指出句柄不能为NULL

  • 代码仅返回对调用者相关的错误。例如,createKey函数不返回状态码,而是在错误时简单地返回一个句柄和NULL,因为调用者不需要更详细的错误信息。

尽管代码更短,但 API 注释增加了。现在的注释更清楚地指明了在发生错误时函数的行为。除了您的代码外,调用者的代码也变得更简单,因为现在调用者不必为如何应对不同种类错误信息而做出多种决策:

调用者的代码

RegKey my_key = createKey("myKey");
if(my_key == NULL)
{
  printf("Cannot create key\n");
}

storeValue(my_key, "A");

RegError err = publishKey(my_key);
if(err == CANNOT_ADD_KEY)
{
  printf("Key cannot be published, because the registry is full\n");
}

与运行示例中的早期代码相比,这更短,因为:

  • 在发生错误时中止函数的返回值不需要被检查。

  • 不需要直接返回详细错误信息的函数直接返回请求的项。例如,createKey()现在返回一个句柄,调用者不再需要提供输出参数。

  • 表示编程错误的错误代码,例如提供的参数无效,不再返回,因此不必由调用者检查。

最终的运行示例代码显示了重要的是考虑在代码中应该处理哪些类型的错误以及如何处理这些错误。仅仅返回所有类型的错误并要求调用者应对所有这些错误并不总是最佳解决方案。调用者可能对详细的错误信息不感兴趣,或者可能不希望在应用程序中对错误做出反应。也许错误足够严重,以至于在发生错误的地方可以决定中止程序。在设计软件组件的 API 时必须考虑这些措施,使代码更简单。

总结

本章展示了如何处理软件中不同函数和不同部分的错误。返回状态码模式为调用者提供了表示发生错误的数值代码。仅返回相关错误仅在调用者能够在代码中对这些错误作出反应时向调用者返回错误信息,并且特殊返回值是实现这一目的的一种方式。记录错误提供了一个额外的通道,用于提供不打算传递给调用者的错误信息,而是用于用户或调试目的。

这些模式为您提供了更多处理错误情况的工具,并可以指导您在实现更大代码片段时的初步步骤。

进一步阅读

如果您已准备好获取更多信息,请参阅以下资源,以帮助您进一步了解返回错误信息的知识:

  • 托马斯·阿格拉辛格(芬兰奥卢大学,1999 年)的硕士论文结构化和面向对象编程语言中的错误处理全面概述了一般的错误处理,并描述了错误处理的最佳实践,其中包括多种编程语言的代码示例。

  • 波特兰模式存储库提供了许多关于错误处理以及其他主题的模式和讨论。大多数错误处理模式都针对异常处理,但也介绍了一些 C 语言的习惯用法。

  • 文章“生成、处理和管理错误的模式”和“更多生成、处理和管理错误的模式”由安迪·朗肖和伊恩·伍兹提供,重点介绍了基于异常的错误记录和错误处理的模式。

研究展望

接下来的章节提供了关于如何处理动态内存的指导。为了在函数间返回更复杂的数据并在应用程序中组织更大的数据及其生命周期,您需要处理动态内存,并需要关于如何执行此操作的建议。

第三章:内存管理

每个程序都会将一些值存储在内存中以在程序后面使用它们。这种功能对于现代编程语言来说是如此普遍,以至于现代编程语言使其尽可能简单。C++ 编程语言以及其他面向对象的编程语言提供了构造函数和析构函数,这使得分配和清理内存非常简单。Java 编程语言甚至配备了垃圾回收器,确保程序不再使用的内存可以释放给其他程序使用。

相比之下,C 编程在程序员需要手动管理内存的方式上是特殊的。程序员必须决定是将变量放在栈上、堆上还是静态内存中。此外,程序员必须确保堆变量在使用后手动清理,而没有像析构函数或本地垃圾回收器等机制,这会使这些任务中的一些变得更容易。

有关如何执行这些任务的指导广泛分布在互联网上,这使得回答以下问题变得非常困难:“该变量应该放在栈上还是堆上?”为了回答这个问题以及其他问题,本章节介绍了如何在 C 程序中处理内存的模式。这些模式指导何时使用栈、何时使用堆以及何时如何清理堆内存。为了更容易理解模式的核心思想,本章在整个章节中应用了一个运行中的代码示例。

图 3-1 显示了本章讨论的模式及其关系概述,而 表 3-1 则提供了这些模式的摘要。

内存管理模式概览

图 3-1. 内存管理模式概览

表 3-1. 内存管理模式

模式名称 摘要
先栈后堆 对于变量来说,决定存储类别和内存区段(栈、堆等)是每个程序员经常要做的决定。如果每个变量都要详细考虑所有可能替代方案的利弊,那会很累人。因此,默认情况下将变量放在栈上,以便从自动清理栈变量中获益。
永久内存 在函数调用之间传输大量数据并持久保存是困难的,因为您必须确保数据的内存足够大且生命周期跨越函数调用。因此,将您的数据放入整个程序生命周期内都可用的内存中。
惰性清理 如果需要大量内存或者预先不知道所需大小的内存,则需要动态内存。然而,处理动态内存的清理是一件麻烦事,并且是许多编程错误的根源。因此,分配动态内存,并且让操作系统在程序结束时处理清理。
专属所有权 使用动态内存的强大力量伴随着正确清理该内存的责任。在较大的程序中,确保所有动态内存正确清理变得困难。因此,在实现内存分配时,明确定义和记录何时以及由谁来清理它非常重要。
分配包装器 每次动态内存分配可能失败,因此您应该在代码中检查分配情况以做出相应反应。这很繁琐,因为您的代码中有许多这样的检查点。因此,封装分配和释放调用,并在这些包装函数中实现错误处理或附加的内存管理组织。
指针检查 导致访问无效指针的编程错误会导致不受控制的程序行为,这些错误很难调试。然而,因为您的代码频繁使用指针,很可能引入这类编程错误。因此,显式地使未初始化或释放的指针无效,并且在访问之前始终检查指针的有效性。
内存池 频繁从堆中分配和释放对象会导致内存碎片化。因此,在整个程序生命周期中持有一个大块内存。在运行时,从该内存池中检索固定大小的块,而不是直接从堆中分配新内存。

数据存储及动态内存问题

在 C 语言中,您有多种选项可以存放数据:

  • 在堆栈中存放数据。堆栈是为每个线程保留的固定大小内存(在线程创建时分配)。调用线程中的函数时,堆栈顶部的块会被保留用于函数参数和函数中使用的自动变量。函数调用后,该内存会自动清理。要将数据放入堆栈中,只需在使用它们的函数中声明变量。只要这些变量不超出范围(函数块结束时),就可以访问这些变量:

    void main()
    {
      int my_data;
    }
    
  • 可以将数据放入静态内存。静态内存是一个固定大小的内存,其分配逻辑在编译时确定。要使用静态内存,只需在变量声明前加上 static 关键字。这些变量在整个程序生命周期内都可用。即使没有 static 关键字,全局变量也是如此:

    int my_global_data;
    static int my_fileglobal_data;
    void main()
    {
      static int my_local_data;
    }
    
  • 如果你的数据是固定大小且不可变的,你可以直接将其存储在代码所在的静态内存中。经常情况下,固定的字符串值就是这样存储的。这些数据在整个程序的生命周期内都是可用的(即使在下面的例子中,指向这些数据的指针超出了作用域):

    void main()
    {
      char* my_string = "Hello World";
    }
    
  • 你可以在堆上分配动态内存来存储数据。堆是系统上所有进程可用的全局内存池,程序员可以随时从中分配和释放内存:

    void main()
    {
      void* my_data = malloc(1000);
      /* work with the allocated 1000 byte memory */
      free(my_data);
    }
    

分配动态内存是事情可能出错的起点,并解决可能出现的问题是本章的重点。在 C 程序中使用动态内存会带来许多必须解决或至少考虑的问题。以下是动态内存的主要问题概述:

  • 分配的内存必须在稍后某个时间点释放。如果没有为你分配的所有内存这样做,你将消耗比需要更多的内存,并且会产生所谓的内存泄漏。如果这种情况经常发生,并且你的应用程序运行时间很长,最终可能导致没有额外的内存可用。

  • 释放内存超过一次是一个问题,可能导致未定义的程序行为,这是非常糟糕的。最糟糕的情况是,在你犯错的实际代码行中可能没有任何问题,但在随机时间后,你的程序可能会崩溃。这些错误是调试时的一个麻烦。

  • 尝试访问已释放的内存也是一个问题。很容易释放一些内存,然后在稍后错误地引用指向该内存的指针(即所谓的悬空指针)。同样,这会导致难以调试的错误情况。最好的情况是,程序会简单地崩溃。最糟糕的情况是,它不会崩溃,并且该内存已经属于其他人。与使用该内存相关的错误是安全风险,并可能在程序执行后的某个时候显示为某种难以理解的错误。

  • 你必须处理分配数据的生命周期和所有权。你必须知道谁在何时清理哪些数据,在 C 语言中尤其棘手。在 C++中,可以简单地在构造函数中为对象分配数据,在析构函数中释放它们。结合 C++的智能指针,甚至可以在对象超出作用域时自动清理它们。然而,在 C 中这是不可能的,因为我们没有析构函数。我们不会在指针超出作用域时收到通知,并且应清理内存。

  • 与堆内存工作相比,与堆栈或静态内存工作需要更多时间。堆内存的分配必须受到竞争条件的保护,因为其他进程使用同一内存池。这使得分配变慢。访问堆内存也更慢,因为与之相比,堆栈内存被更频繁地访问,因此更可能已经驻留在缓存或 CPU 寄存器中。

  • 堆内存的一个巨大问题是它会变得碎片化,这在图 3-2 中有所描述。 如果你分配了内存块 A、B 和 C,然后释放了内存块 B,你的整体空闲堆内存就不再是连续的。 如果你想要分配一个大的内存块 D,你将无法获得那块内存,尽管总内存是足够的。 然而,由于可用内存不是连续的,你的malloc调用将失败。 在长时间运行的内存受限系统中(如嵌入式实时系统),碎片化是一个巨大的问题。

内存碎片化

图 3-2. 内存碎片化

处理这些问题并不容易。 以下各节中的模式逐步描述了如何避免动态分配或以可接受的方式使用它。

运行示例

你想要实现一个简单的程序,用凯撒密码加密一些文本。 凯撒密码用另一个字母替换每个字母,这个字母在字母表中向下移动了一定数量的位置。 例如,如果固定的位置数为 3,那么字母 A 将被字母 D 替换。 你开始实现一个执行凯撒加密的函数:

/* Performs a Caesar encryption with the fixed key 3.
   The parameter 'text' must contain a text with only capital letters.
   The parameter 'length' must contain the length of the text excluding
   NULL termination. */
void caesar(char* text, int length)
{
  for(int i=0; i<length; i++)
  {
    text[i] = text[i]+3; ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
    if(text[i] > 'Z')
    {
      text[i] = text[i] - 'Z' + 'A' - 1; ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/2.png)
    }
  }
}

1

在 C 中,字符以数值形式存储,你可以通过向字符添加数值来将字符向字母表下移。

2

如果我们超出字母Z,我们将重新从字母表的开头开始。

现在你只是想检查你的函数是否有效,并且你需要提供一些文本来做到这一点。 你的函数接受一个指向字符串的指针。 但是你应该把那个字符串存储在哪里呢? 应该动态分配还是使用栈内存? 你意识到最简单的解决方案是首先使用栈来存储字符串。

先用栈

背景

你想在程序中存储数据并在以后的某个时间点访问它。 你事先知道它的最大大小,并且数据大小不是很大(只有几个字节)。

问题

决定变量的存储类和内存段(栈、堆等)是每个程序员经常必须做出的决定。 如果对于每个变量都必须详细考虑所有可能替代方案的利弊,那么这将变得很繁琐。

在你的 C 程序中存储数据,你有很多可能性,其中最常见的是在栈上、静态内存中或动态内存中存储。 每种可能性都有其特定的优点和缺点,决定变量存储在哪里非常重要。 它影响变量的生命周期,并确定变量是否会自动清理或者你是否必须手动清理。

这个决定还影响了作为程序员的所需的努力和纪律。你希望尽可能地简化生活,因此如果对存储数据没有特殊要求,你希望使用需要最少的分配、释放和由于潜在编程错误导致的错误修复的内存类型。

解决方案

默认将变量放在堆栈上,以从堆栈变量的自动清理中获益。

所有在代码块内声明的变量默认为所谓的自动变量,这些变量被放置在堆栈上,并在代码块结束时(变量超出作用域时)自动清理。可以通过在变量前加上auto存储类说明符来明确声明变量为自动变量,但这很少这样做,因为这已经是默认行为。

你可以将来自堆栈的内存传递给其他函数(例如,调用者拥有缓冲区),但请确保不要返回此类变量的地址。变量在函数结束时超出作用域并自动清理。返回这样一个变量的地址将导致悬空指针,并且访问它将导致未定义的程序行为,可能导致程序崩溃。

下面的代码展示了一个非常简单的例子,其中包含堆栈上的变量:

void someCode()
{
  /* This variable is an automatic variable that is put on the stack and
 that will run out of scope at the end of the function */
  int my_variable;

  {
    /* This variable is an automatic variable that is put on the stack and
 that will run out of scope right after this code block, which is
 after the first '}' */
    int my_array[10];
  }
}

变长数组

前面代码中的数组大小是固定的。在编译时只将固定大小的数据放在堆栈上是非常常见的,但也可以在运行时决定堆栈变量的大小。这可以通过使用alloca()等函数(它不是 C 标准的一部分,并且如果分配太多可能会导致堆栈溢出),或者使用变长数组(其大小由变量指定的常规数组)来实现,这在 C99 标准中引入。

影响

将数据存储在堆栈上使得访问数据变得容易。与动态分配的内存相比,无需使用指针。这可以消除与悬空指针相关的程序错误的风险。此外,没有堆碎片化,内存清理更容易。这些变量是自动变量,这意味着它们会自动清理。无需手动释放内存,这消除了内存泄漏或意外多次释放内存的风险。通常,通过简单地将变量放在堆栈上,可以消除大多数与不正确的内存使用相关的难以调试的错误。

与动态内存相比,堆栈上的数据可以快速分配和访问。在分配时无需通过复杂的数据结构管理可用内存。也无需确保与其他线程的互斥,因为每个线程都有自己的堆栈。此外,堆栈数据通常可以快速访问,因为该内存经常被使用,并且通常在高速缓存中。

然而,使用堆栈的一个缺点是它的限制性。与堆内存相比,堆栈内存非常有限(取决于堆栈大小的构建设置,可能只有几 KB)。如果你在堆栈上放置了太多数据,就会导致堆栈溢出,通常会导致程序崩溃。问题在于你无法知道剩余的堆栈内存有多少。根据已调用的函数已使用的堆栈内存量,你可能只剩下很少的内存。你必须确保放置在堆栈上的数据不要太大,并且必须提前知道其大小。

与堆栈上缓冲区相关的编程错误可能会导致重大安全问题。如果在堆栈上产生缓冲区溢出,攻击者可以轻松利用它来覆盖堆栈上的其他数据。如果攻击者成功覆盖了函数处理后返回的地址,那么攻击者可以执行任何他们想要的代码。

此外,在堆栈上存储数据并不适合满足所有需求。如果你需要返回大量数据,比如文件内容或缓冲区的网络消息给调用者,那么你不能简单地返回堆栈上某个数组的地址,因为一旦从函数返回,该变量就会被清除。为了返回大量数据,必须使用其他方法。

已知的用途

以下示例展示了此模式的应用:

  • 几乎每个 C 程序都会在堆栈上存储一些东西。在大多数程序中,默认情况下会使用堆栈存储,因为这是最简单的解决方案。

  • C 语言的auto存储类说明符指定变量是自动变量并放在堆栈上,是默认的存储类说明符(通常在代码中省略,因为它默认为这种方式)。

  • 《小内存软件:有限内存系统的模式》(Addison-Wesley,2000)一书中由 James Noble 和 Charles Weir 描述了其内存分配模式,对于内存放置的选择,应该选择最简单的方式,也就是对于 C 程序员来说使用堆栈。

应用于运行示例

好了,这很简单。你现在将需要用于存储文本的内存放在堆栈上,并将该内存提供给你的凯撒密码函数:

#define MAX_TEXT_SIZE 64

void encryptCaesarText()
{
  char text[MAX_TEXT_SIZE];
  strlcpy(text, "PLAINTEXT", MAX_TEXT_SIZE);
  caesar(text, strnlen(text, MAX_TEXT_SIZE));
  printf("Encrypted text: %s\n", text);
}

这是一个非常简单的解决方案。你不必处理动态内存分配。也不需要清理内存,因为一旦text超出作用域,它就会自动清理。

接下来,你想加密一个较大的文本。使用当前解决方案并不容易,因为内存存储在堆栈上,而且通常堆栈内存不多。根据你的平台,可能只有几 KB。尽管如此,你仍然希望能够加密更大的文本。为了避免处理动态内存,你决定尝试使用永久内存。

永久内存

上下文

如果你的程序中需要长时间使用固定大小的大量数据,那么堆栈存储是一个合适的选择。

问题

持有大量数据并在函数调用之间传输数据是困难的,因为您必须确保数据的内存足够大且生命周期跨越函数调用。

使用堆栈会很方便,因为它会为您处理所有内存清理工作。但将数据放入堆栈上并不适合您,因为这不允许您在函数之间传递大数据。这也是一种低效的方式,因为将数据传递给函数意味着复制数据。在程序的每个需要它的地方手动分配内存并在不再需要时释放它的替代方法是可行的,但这很繁琐且容易出错。特别是要掌握所有数据的生命周期并知道何时以及何处释放数据是一项复杂的任务。

如果您在像安全关键应用这样的环境中操作,那么既不使用堆栈中的内存,也不使用动态内存是一个好选择,因为两者都可能耗尽内存,并且您无法事先轻松知道。但在其他应用程序中,您可能也有代码部分,您希望确保不会耗尽内存。例如,对于错误日志代码,您肯定希望确保所需的内存可用,否则您无法依赖于您的日志信息,这使得查找错误变得困难。

解决方案

将您的数据放入在整个程序生命周期内可用的内存中。

最常见的方法是使用静态内存。要么用static存储类说明符标记您的变量,要么如果您希望变量具有更大的作用域,则将其声明在任何函数之外(但只有在确实需要更大的作用域时才这样做)。静态内存在程序启动时分配,并在整个程序生命周期内可用。以下代码提供了一个示例:

#define ARRAY_SIZE 1024

int global_array[ARRAY_SIZE]; /* variable in static memory, global scope */
static int file_global_array[ARRAY_SIZE]; /* variable in static memory with
 scope limited to this file */

void someCode()
{
  static int local_array[ARRAY_SIZE]; /* variable in static memory with
 scope limited to this function */
}

作为使用静态变量的替代方法,在程序启动时,您可以调用一个初始化函数来分配内存,并在程序结束时调用一个去初始化函数来释放该内存。这样,您在整个程序的生命周期内都可以使用这些内存,但您必须自己处理分配和释放的问题。

无论您是在程序启动时自己分配内存还是使用静态内存,访问此内存时都必须小心。因为它不在堆栈上,所以在每个线程中没有单独的内存副本。在多线程情况下,访问该内存时必须使用同步机制。

您的数据具有固定大小。与运行时动态分配的内存相比,您的永久内存的大小在运行时不能更改。

后果

你不必担心内存的生命周期和手动释放内存的位置。规则很简单:让内存在整个程序生命周期内保持活动状态。甚至使用静态内存可以完全摆脱你的分配和释放负担。

现在你可以在那段内存中存储大量数据,甚至将其传递给其他函数。与首次使用堆栈相比,你现在甚至可以向函数的调用者提供数据。

但是,你必须在编译时或最迟在启动时知道需要多少内存,因为你在程序启动时分配它。对于大小未知的内存或在运行时将扩展的内存,永久内存并不是最好的选择,应该使用堆内存。

使用永久内存,启动程序会花费更长的时间,因为所有的内存都必须在那时分配。但一旦你拥有了那段内存,就会有所回报,因为在运行时不再需要分配内存。

分配和访问静态内存不需要由操作系统或运行时环境维护的复杂数据结构来管理堆。因此,内存使用效率更高。永久内存的另一个巨大优势是你不会使堆碎片化,因为你不会一直分配和释放内存。但是不这样做的缺点是阻塞内存,这取决于你的应用程序,可能并不总是需要。一个更灵活的避免内存碎片化的解决方案将是使用内存池。

永久内存的一个问题是你的每个线程没有其自己的副本(如果你使用静态变量)。因此,你必须确保内存不会同时被多个线程访问。尽管在不可变实例的特殊情况下,这并不是一个大问题。

已知的应用

以下示例展示了这种模式的应用:

  • 游戏 NetHack 使用静态变量来存储在游戏整个生命周期中所需的数据。例如,游戏中发现的神器信息存储在静态数组artifact_names中。

  • Wireshark 网络嗅探器的代码在其函数cf_open_error_message中使用静态缓冲区来存储错误消息信息。一般来说,许多程序在其错误记录功能中使用静态内存或在程序启动时分配的内存。这是因为在发生错误时,你希望至少该部分工作正常,并且不会耗尽内存。

  • OpenSSL 代码使用静态数组OSSL_STORE_str_reasons来保存有关在处理证书时可能发生的错误情况的错误信息。

应用于运行示例

你的代码基本上保持不变。唯一改变的是,在变量text的声明前加入了static关键字,并增加了文本的大小:

#define MAX_TEXT_SIZE 1024

void encryptCaesarText()
{
  static char text[MAX_TEXT_SIZE];
  strlcpy(text, "LARGETEXTTHATCOULDBETHOUSANDCHARACTERSLONG", MAX_TEXT_SIZE);
  caesar(text, strnlen(text, MAX_TEXT_SIZE));
  printf("Encrypted text: %s\n", text);
}

现在,您的文本不存储在堆栈上,而是驻留在静态内存中。在执行此操作时,您应记住这意味着变量只存在一次并保留其值(即使在多次进入函数时也是如此)。对于多线程系统来说,这可能是一个问题,因为在访问该变量时必须确保互斥。

您目前没有多线程系统。但是,您的系统需求发生了变化:现在您希望能够从文件中读取文本,对其进行加密并显示加密后的文本。您不知道文本的长度可能有多长。因此,您决定使用动态分配:

void encryptCaesarText()
{
  /* open file (omit error handling to keep the code simple) */
  FILE* f = fopen("my-file.txt", "r");

  /* get file length */
  fseek(f, 0, SEEK_END);
  int size = ftell(f);

  /* allocate buffer */
  char* text = malloc(size);

  ...
}

但是,该如何继续编写代码呢?您在堆上分配了文本。但是如何清理这些内存?作为第一步,您意识到完全可以由其他人来清理该内存:操作系统。因此,您选择了懒惰的清理。

懒惰的清理

上下文

您希望在程序中存储一些数据,这些数据很大(也许您甚至事先不知道其大小)。数据的大小在运行时很少改变,并且在程序的几乎整个生命周期中都需要这些数据。您的程序是短暂的(不会长时间运行而不重新启动)。

问题

如果需要大量内存和预先不知道所需大小的内存,则需要动态内存。然而,处理动态内存的清理是一件麻烦事,并且是许多编程错误的根源。

在许多情况下——例如,如果您有未知大小的大数据——您不能将数据放在堆栈或静态内存中。因此,您必须使用动态内存并处理其分配。现在问题是如何清理这些数据。清理这些数据是程序错误的主要来源。您可能会意外地过早释放内存,导致悬空指针。您可能会意外地两次释放相同的内存。这些编程错误都可能导致未定义的程序行为,例如在以后的某个时间点程序崩溃。这些错误非常难以调试,C 程序员花费了太多时间来解决这些情况。

幸运的是,大多数类型的内存都带有某种自动清理机制。返回函数时,堆栈内存会自动清理。静态内存和堆内存在程序终止时会自动清理。

解决方案

分配动态内存并让操作系统在程序结束时处理清理。

当您的程序结束并且操作系统清理您的进程时,大多数现代操作系统还会清理您分配但未释放的任何内存。利用这一点,让操作系统完全负责跟踪哪些内存仍然需要清理,然后实际清理它,如下面的代码所示:

void someCode()
{
  char* memory = malloc(size);
  ...
  /* do something with the memory */
  ...
  /* don't care about freeing the memory */
}

这种方法乍一看似乎很粗暴。你故意制造内存泄漏。然而,这种编码风格在其他具有垃圾回收器的编程语言中也是常用的。你甚至可以在 C 语言中包含一些垃圾收集库,以便在自动内存清理的同时使用这种编码风格(尽管会导致时间行为不太可预测)。

故意制造内存泄漏可能是一些应用的选择,尤其是那些不运行很长时间且不经常分配内存的应用。但对于其他应用程序来说,这将不是一个选择,你需要专门的内存所有权并处理其释放。如果你之前使用了懒惰的清理,一种简单的方法来清理内存就是使用分配包装器,并且在程序结束时有一个函数来清理所有分配的内存。

后果

显而易见的优势在于,你可以享受动态内存的好处,而无需处理内存释放。这对程序员来说使生活变得轻松得多。此外,你不会浪费任何处理时间在释放内存上,这可以加快程序关闭过程的速度。

然而,这是以其他运行进程的代价为代价的,它们可能需要你没有释放的内存。也许你甚至无法再分配任何新的内存,因为没有剩下多少了,而你却没有释放本应该释放的内存。特别是如果你经常分配内存,这将成为一个主要问题,不清理内存将不是一个好的解决方案。相反,你应该专注于所有权,并释放内存。

通过这种模式,你接受你故意制造内存泄漏并且接受它。虽然这可能对你来说没问题,但对其他人调用你的函数可能不可接受。如果你编写的库可以供他人使用,那么在其中存在内存泄漏是不可接受的。此外,如果你希望在代码的其他部分保持非常干净,并且例如使用类似于valgrind的内存调试工具来检测内存泄漏,那么如果程序的其他部分混乱并且不释放其内存,你将难以解释工具的结果。

这种模式很容易被用作不实现正确内存清理的借口,即使在应该这样做的情况下也是如此。因此,你应该仔细检查你是否真的处于一个不需要释放内存的上下文中。如果未来你的程序代码可能会发展并且需要清理内存,那么最好不要从懒惰的清理开始,而是从一开始就正确地进行内存的专门所有权。

已知的用途

以下示例展示了此模式的应用:

  • Wireshark 函数 pcap_free_datalinks 在某些情况下故意不释放所有内存。原因是 Wireshark 代码的一部分可能是使用不同编译器和不同的 C 运行时库构建的。释放由这样的代码分配的内存可能导致崩溃。因此,该内存明确地不被释放。

  • 公司 B&R 的 Automation Runtime 操作系统的设备驱动程序通常没有卸载功能。它们分配的所有内存永远不会被释放,因为这些驱动程序在运行时不会被卸载。如果应该使用不同的驱动程序,则整个系统将重新启动。这使得显式释放内存变得不必要。

  • 用于存储太阳图像进行科学处理的 NetDRMS 数据管理系统的代码,在错误情况下并没有显式释放所有内存。例如,如果发生错误,则函数 EmptyDir 不会清理所有与访问文件相关的内存或其他资源,因为这样的错误会导致更严重的错误和程序中止。

  • 使用垃圾回收库的任何 C 代码都应用了这种模式,并通过显式垃圾回收克服了内存泄漏的缺点。

应用于运行示例

在您的代码中,您简单地省略了使用任何 free 函数调用。此外,您重构了代码,使文件访问功能分开到不同的函数中:

/* Returns the length of the file with the provided 'filename' */
int getFileLength(char* filename)
{
  FILE* f = fopen(filename, "r");
  fseek(f, 0, SEEK_END);
  int file_length = ftell(f);
  fclose(f);
  return file_length;
}

/* Stores the content of the file with the provided 'filename' into the
 provided  'buffer' (which has to be least of size 'file_length'). The
 file must only contain capital letters with no newline in between
 (that's what our caesar function accepts as input). */
void readFileContent(char* filename, char* buffer, int file_length)
{
  FILE* f = fopen(filename, "r");
  fseek(f, 0, SEEK_SET);
  int read_elements = fread(buffer, 1, file_length, f);
  buffer[read_elements] = '\0';
  fclose(f);
}

void encryptCaesarFile()
{
  char* text;
  int size = getFileLength("my-file.txt");
  if(size>0)
  {
    text = malloc(size);
    readFileContent("my-file.txt", text, size);
    caesar(text, strnlen(text, size));
    printf("Encrypted text: %s\n", text);
    /* you don't free the memory here */
  }
}

您分配了内存,但是没有调用 free 来释放它。相反,您让指向内存的指针超出作用域,并且有一个内存泄漏。然而,这不是问题,因为您的程序在之后立即结束,并且操作系统会清理内存。

那种方法似乎相当粗糙,但在一些情况下完全可以接受。如果您需要在程序的整个生命周期内保持内存,或者如果您的程序生命周期很短,并且您确信您的代码不会在其他地方进化或被重用,那么简单地不必处理内存清理可能是一种能极大简化您生活的解决方案。但是,您必须非常小心,确保您的程序不会发展并变得长寿。在这种情况下,您肯定需要找到另一种方法。

这正是您接下来要做的。您希望加密多个文件。您希望加密当前目录中的所有文件。您很快意识到,您必须更频繁地分配内存,并且在此期间不释放任何内存不再是一个选项,因为这可能成为您的程序或其他程序的问题。

问题出现在代码中内存应该在哪里被释放。谁负责做这件事?您肯定需要专用所有权。

专用所有权

上下文

在你的程序中有大量未知大小的数据,并且你使用动态内存来存储它。你不需要整个程序的生命周期都使用该内存,并且你经常需要分配不同大小的内存,因此不能使用惰性清理。

问题

使用动态内存的巨大力量伴随着必须妥善清理内存的巨大责任。在较大的程序中,确保所有动态内存得到正确清理变得困难。

清理动态内存时存在许多陷阱。你可能会太早清理它,而之后其他人仍然希望访问该内存(悬空指针)。或者你可能意外地频繁释放内存。这些编程错误都会导致意外的程序行为,比如在以后某个时间点程序崩溃,并且这些错误是安全问题,可能会被攻击者利用。此外,这些错误非常难以调试。

然而,你确实需要清理内存,因为随着时间推移,如果分配新的内存而不释放它,你会使用太多内存。然后你的程序或其他进程会因为内存不足而运行失败。

解决方案

在实现内存分配时,立即定义并记录它将在何处进行清理,并记录谁将执行清理。

在代码中明确记录谁拥有内存以及它将有效多长时间是非常重要的。最好的情况是,在写下你的第一个malloc之前,你应该问自己那些内存将会被释放。你还应该在函数声明中写一些注释,明确表明如果内存缓冲区由该函数传递,那么谁负责清理它。

在其他编程语言(如 C++)中,你可以使用代码结构来记录这一点。指针结构如unique_ptrshared_ptr使得可以从函数声明中看到谁负责清理内存。由于 C 语言中没有这样的结构,必须格外小心地在代码注释中记录这一责任。

如果可能,同一个函数负责分配和释放,就像在基于对象的错误处理中一样,代码中有一个确切的调用点用于调用类似构造函数和析构函数的分配和释放:

#define DATA_SIZE 1024
void function()
{
  char* memory = malloc(DATA_SIZE);
  /* work with memory */
  free(memory);
}

如果分配和释放的责任分散在代码中,如果内存的所有权被转移,情况就变得复杂。在某些情况下,这是必要的,例如,如果只有分配函数知道数据的大小,并且其他函数需要该数据:

/* Allocates and returns a buffer that has to be freed by the caller */
char* functionA()
{
  char* memory = malloc(data_size); ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
  /* fill memory */
  return memory;
}

void functionB()
{
  char* memory = functionA();
  /* work with the memory */
  free(memory); ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/2.png)
}

1

调用者分配一些内存。

2

调用者负责清理内存。

如果可能,请避免将分配和释放内存的责任放在不同的函数中。但无论如何,请记录谁负责清理工作,以便明确。

描述与内存所有权相关的更具体情况的其他模式是调用者拥有缓冲区或调用者拥有实例,在这些模式中,调用者负责分配和释放内存。

结果

最后,您可以分配内存并正确处理其清理。这使您具有灵活性。您可以临时从堆中使用大量内存,稍后再让其他人使用该内存。

但当然,这种好处伴随着额外的成本。您必须处理内存的清理工作,这使得您的编程工作更加困难。即使具有专用所有权,也可能出现与内存相关的编程错误,并导致难以调试的情况。此外,释放内存需要一些时间。明确记录内存将被清理的位置有助于防止其中一些错误,并且通常使代码更易于理解和维护。为了进一步避免与内存相关的编程错误,您还可以使用分配包装器和指针检查。

随着动态内存的分配和释放,堆碎片化和分配和访问内存所需的时间增加的问题浮出水面。对于某些应用程序,这可能根本不是问题,但对于其他应用程序,这些话题非常严重。在这种情况下,内存池可以提供帮助。

已知用途

以下示例展示了此模式的应用:

  • Kamran Amini(Packt,2019)的书《Extreme C》建议,分配内存的函数也应负责释放它,并且应将拥有内存的函数或对象作为注释进行文档化。当然,如果您有包装函数,则调用分配包装器的函数应该调用清理包装器。

  • 数值计算环境 MATLAB 的函数mexFunction的实现清楚地记录了它拥有并将释放的内存。

  • NetHack 游戏明确为函数的调用者文档化了是否必须释放某些内存。例如,函数nh_compose_ascii_screenshot分配并返回一个字符串,调用者必须释放它。

  • 用于“Community ID 流哈希”Wireshark 解析器的功能清楚地记录了谁负责释放内存。例如,函数communityid_calc分配了一些内存,并要求调用者释放它。

应用于运行示例

encryptCaesarFile函数的功能没有改变。您唯一改变的是现在也调用free来释放内存,并且您现在在代码注释中清楚地记录了谁负责清理哪些内存。此外,您还实现了encryptDirectoryContent函数,该函数加密当前工作目录中的所有文件:

/* For the provided 'filename', this function reads text from the file and
 prints the Caesar-encrypted text. This function is responsible for
 allocating and deallocating the required buffers for storing the
 file content */
void encryptCaesarFile(char* filename)
{
  char* text;
  int size = getFileLength(filename);
  if(size>0)
  {
    text = malloc(size);
    readFileContent(filename, text, size);
    caesar(text, strnlen(text, size));
    printf("Encrypted text: %s\n", text);
    free(text);
  }
}

/* For all files in the current directory, this function reads text
 from the file and prints the Caesar-encrypted text. */
void encryptDirectoryContent()
{
  struct dirent *directory_entry;
  DIR *directory = opendir(".");
  while ((directory_entry = readdir(directory)) != NULL)
  {
    encryptCaesarFile(directory_entry->d_name);
  }
  closedir(directory);
}

此代码打印当前目录所有文件的凯撒加密内容。请注意,该代码仅适用于 UNIX 系统,并且由于简化起见,如果目录中的文件没有预期的内容,则未实现特定的错误处理。

当不再需要内存时,现在也清理内存。请注意,程序在运行时期间所需的所有内存并非同时分配。在程序中运行时任何时候分配的最大内存是文件中所需的内存之一。这使得程序的内存占用明显较小,尤其是如果目录包含许多文件。

前述代码未处理错误处理。例如,如果没有更多可用内存会发生什么?代码将会崩溃。您希望对此类情况进行某种形式的错误处理,但在每个分配内存的点上检查malloc返回的指针可能会很麻烦。您需要的是一个分配包装器。

分配包装器

上下文

您在代码中的多个地方分配动态内存,并且希望在内存耗尽等错误情况下作出反应。

问题

每次动态内存分配都可能失败,因此您应该在代码中检查分配情况以做出相应反应。这很麻烦,因为您的代码中有许多地方需要进行这样的检查。

如果请求的内存不可用,malloc函数会返回NULL。一方面,如果不检查malloc的返回值,如果没有可用内存并且访问了NULL指针,您的程序将崩溃。另一方面,在每个分配位置检查返回值会使您的代码更复杂,因此更难阅读和维护。

如果您将这些检查分布到代码库中的各个位置,稍后想要在分配错误的情况下更改行为,则必须在许多不同的地方触及代码。此外,简单地向现有函数添加错误检查违反了单一责任原则,该原则规定一个函数应该只负责一件事情(而不是多个事情,如分配和程序逻辑)。

另外,如果以后想要更改分配方法,也许是显式初始化所有分配的内存,则在代码中广泛调用分配函数会使这一过程变得非常困难。

解决方案

封装分配和释放调用,并在这些包装函数中实现错误处理或额外的内存管理组织。

mallocfree调用实现一个包装函数,并且仅在这些包装函数中调用内存分配和释放。在包装函数中,您可以在一个中心点实现错误处理。例如,您可以检查分配的指针(参见指针检查),并在错误情况下中止程序,如下面的代码所示:

void* checkedMalloc(size_t size)
{
  void* pointer = malloc(size);
  assert(pointer);
  return pointer;
}

#define DATA_SIZE 1024
void someFunction()
{
  char* memory = checkedMalloc(DATA_SIZE);
  /* work with the memory */
  free(memory);
}

作为中止程序的替代方法,你可以记录错误。对于记录调试信息,使用宏而不是包装函数可以更加方便。这样一来,你可以毫不费力地记录文件名、函数名或错误发生的代码行号。有了这些信息,程序员可以很容易地确定代码中出现错误的部分。此外,使用宏而不是包装函数还可以避免额外的函数调用(不过在大多数情况下这不重要,或者编译器会内联该函数)。使用分配和释放的宏甚至可以构建类似构造函数的语法:

#define NEW(object, type)                   \
do {                                        \
 object = malloc(sizeof(type));            \
 if(!object)                               \
 {                                         \
 printf("Malloc Error: %s\n", __func__); \
 assert(false);                          \
 }                                         \
} while (0)

#define DELETE(object) free(object)

typedef struct{
  int x;
  int y;
}MyStruct;

void someFunction()
{
  MyStruct* myObject;
  NEW(myObject, MyStruct);
  /* work with the object */
  DELETE(myObject);
}

除了在包装器函数中处理错误情况之外,您还可以做其他事情。例如,您可以跟踪程序分配的内存,并将其信息存储在列表中,连同代码文件和代码行号一起(为此,您还需要像前面的示例中那样的free包装器)。这样,如果您想查看当前分配的内存(以及您可能忘记释放的部分),则可以轻松地打印调试信息。但如果您需要此信息,您也可以简单地使用像 valgrind 这样的内存调试工具。此外,通过跟踪分配的内存,您还可以实现一个释放所有内存的函数——如果您以前使用惰性清理,这可能是使程序更清洁的选项。

将所有内容放在一个地方并不总是解决方案。也许您的应用程序有一些非关键部分,您不希望整个应用程序在发生分配错误时中止。在这种情况下,拥有多个分配包装器可能适合您。一个包装器仍然可以在错误发生时断言,并且可以用于应用程序必须正常工作的关键分配。另一个用于应用程序的非关键部分的包装器,在错误发生时可能会返回状态码,以便优雅地处理该错误情况。

后果

错误处理和其他内存处理现在集中在一个地方。在需要分配内存的代码处,现在只需调用包装函数,无需在代码中显式处理错误。但这仅适用于某些类型的错误处理。如果在错误发生时中止程序效果很好,但如果您对错误做出反应并继续以降低的功能继续运行程序,则仍需从包装器返回一些错误信息,并对其做出反应。对于这种情况,分配包装器不能让生活更轻松。但是,在这种情况下,包装器中仍然可以实现一些日志功能以改善您的情况。

这个包装函数对于测试带来了优势,因为你可以有一个中心点来改变你的内存分配函数的行为。此外,你可以模拟这个包装器(用其他测试函数替换包装器调用),同时仍然保留对malloc(可能来自第三方代码)的其他调用。

使用包装函数将错误处理部分与调用代码分离是一个好的实践,因为这样调用者就不会诱惑在处理其他程序逻辑的代码中直接实现错误处理。在一个函数中做几件事情(程序逻辑和广泛的错误处理)将违反单一责任原则。

有一个分配包装器允许你一致地处理分配错误,并且如果以后你想要改变错误处理行为或内存分配行为,这将更容易。如果你决定想要记录额外的信息,那么只需在代码中的一个地方进行修改。如果以后决定不直接调用malloc而是使用内存池,那么有了包装器就容易得多。

已知用途

以下示例展示了该模式的应用:

  • 由 David R. Hanson(Addison-Wesley,1996)著作的C 接口与实现一书,在内存池的实现中使用了一个用于分配内存的包装函数。包装函数简单地调用assert来在出现错误时终止程序。

  • GLib 提供了g_mallocg_free等内存相关函数。使用g_malloc的好处在于,在出现错误时终止程序(武士原则)。因此,调用者不需要检查每个函数调用的返回值来分配内存。

  • GoAccess 实时 Web 日志分析器实现了函数xmalloc来包装带有一些错误处理的malloc调用。

  • 分配包装器是装饰器模式的一个应用,该模式在设计模式:可复用面向对象软件的元素一书中由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(Prentice Hall,1997)描述。

应用于运行示例

现在,在代码中不是直接调用到处的mallocfree,而是使用包装函数:

/* Allocates memory and asserts if no memory is available */
void* safeMalloc(size_t size)
{
  void* pointer = malloc(size);
  assert(pointer); ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
  return pointer;
}

/* Deallocates the memory of the provided 'pointer' */
void safeFree(void *pointer)
{
  free(pointer);
}

/* For the provided file 'filename', this function reads text from the file
   and prints the Caesar-encrypted text. This function is responsible for
   allocating and deallocating the required buffers for storing the
   file content */
void encryptCaesarFile(char* filename)
{
  char* text;
  int size = getFileLength(filename);
  if(size>0)
  {
    text = safeMalloc(size);
    readFileContent(filename, text, size);
    caesar(text, strnlen(text, size));
    printf("Encrypted text: %s\n", text);
    safeFree(text);
  }
}

1

如果分配失败,你要坚持武士原则并终止程序。对于像你的应用程序这样的应用,这是一个有效的选择。如果没有办法优雅地处理错误,那么直接终止程序是正确和合适的选择。

使用分配包装器的好处是,现在您有一个集中处理分配错误的中心点。您无需在代码中的每个分配后写检查指针的代码行。您还有一个用于释放代码的包装器,在将来可能会派上用场,例如,如果您决定跟踪应用程序当前分配的内存。

分配后,现在您检查检索到的指针是否有效。之后,您不再检查指针的有效性,并且还相信穿越函数边界时收到的指针是有效的。只要没有编程错误潜入,这是可以接受的,但如果意外访问无效指针,则情况变得难以调试。为了改进您的代码并确保安全,您决定使用指针检查。

指针检查

上下文

您的程序包含许多地方用于分配和释放内存,以及许多地方用于使用指针访问该内存或其他资源。

问题

导致访问无效指针的编程错误会导致不受控制的程序行为,这类错误很难调试。但是,由于您的代码经常使用指针,很有可能引入这类编程错误。

C 编程需要大量与指针的斗争,代码中使用指针的地方越多,引入编程错误的可能性就越多。使用已释放的指针或使用未初始化的指针将导致难以调试的错误情况。

任何这类错误情况都非常严重。它导致不受控制的程序行为,(如果您幸运的话)可能导致程序崩溃。如果不够幸运,您将在程序执行期间的后续时间点遇到错误,并且需要花费一周的时间来准确定位和调试。您希望您的程序对这类错误更加健壮。您希望减轻这类错误的严重性,并且希望如果这类错误在运行中发生,能更容易找到其原因。

解决方案

明确地使未初始化或已释放的指针失效,并且在访问它们之前始终检查指针的有效性。

在变量声明时,将指针变量显式设置为NULL。同样,在调用free后立即将其显式设置为NULL。如果您使用的分配包装器使用宏来包装free函数,则可以直接在宏内部将指针设置为NULL,以避免在每次释放时为使指针失效而添加额外的代码行。

使用包装函数或宏来检查指针是否为NULL,并在遇到NULL指针时中止程序并记录错误信息,以便进行调试。如果中止程序不适合你,那么在遇到NULL指针时,你可以选择不访问指针并尝试优雅地处理错误。这将允许你的程序以降低的功能继续执行,如下面的代码所示:

void someFunction()
{
  char* pointer = NULL; /* explicitly invalidate the uninitialized pointer */
  pointer = malloc(1024);

  if (pointer != NULL) /* check pointer validity before accessing it */
  {
    /* work with pointer*/
  }

  free(pointer);
  pointer = NULL; /* explicitly invalidate the pointer to freed memory */
}

结果

你的代码对于与指针相关的编程错误有了更多的保护。每一个能够被识别且不会导致未定义程序行为的这类错误,都可能为你节省数小时甚至数天的调试工作。

然而,这并不是无偿的。你的代码变得更长、更复杂。你在这里应用的策略就像是同时系上腰带和吊裤带。你做了一些额外的工作来更安全。你对每个指针访问都增加了额外的检查。这使得代码更难阅读。在访问指针之前检查其有效性,你至少需要增加一行代码。如果你不中止程序,而是选择以降低的功能继续执行,那么你的程序将变得更加难以阅读、维护和测试。

如果你意外地对同一个指针多次调用free,那么第二次调用将不会导致错误情况,因为在第一次调用后你使指针无效,随后在NULL指针上调用free不会造成任何伤害。尽管如此,你仍然可以像这样记录错误信息,以便能够准确定位错误的根本原因。

但即使这样,你也不能完全防止所有与指针相关的错误。例如,你可能会忘记释放一些内存,导致内存泄漏。或者你可能会访问一个没有正确初始化的指针,但至少你会检测到这一点,并且可以相应地做出反应。这里可能存在的一个缺点是,如果你决定优雅地降低程序的执行,并继续执行,那么你可能会掩盖一些难以在后期找到的错误情况。

已知用途

下面的例子展示了这种模式的应用:

  • C++智能指针的实现在释放智能指针时使封装的原始指针失效。

  • Cloudy 是一个用于物理计算(光谱综合)的程序。它包含一些数据插值的代码(Gaunt 因子)。该程序在访问指针之前会检查它们的有效性,并在调用free后显式将指针设置为NULL

  • GNU 编译器集合(GCC)的 libcpp 在释放指针后使其失效。例如,实现文件macro.c中的指针就是这样做的。

  • MySQL 数据库管理系统的函数HB_GARBAGE_FUNC将指针ph设置为NULL,以避免在后续的多次访问或释放中意外访问它。

应用于运行示例

现在你有了以下代码:

/* For the provided file 'filename', this function reads text from the file
   and prints the Caesar-encrypted text. This function is responsible for
   allocating and deallocating the required buffers for storing the
   file content */
void encryptCaesarFile(char* filename)
{
  char* text = NULL; ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
  int size = getFileLength(filename);
  if(size>0)
  {
    text = safeMalloc(size);
    if(text != NULL) ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/2.png)
    {
      readFileContent(filename, text, size);
      caesar(text, strnlen(text, size));
      printf("Encrypted text: %s\n", text);
    }
    safeFree(text);
    text = NULL; ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
  }
}

1

在指针无效的地方,你需要将其显式设置为NULL,以防万一。

2

在访问指针text之前,你检查它是否有效。如果它无效,你不使用该指针(不对其进行解引用)。

Linux 超配

请注意,拥有有效的内存指针并不总是意味着可以安全地访问该内存。现代 Linux 系统采用了超配原则。这个原则为分配内存的程序提供虚拟内存,但这种虚拟内存与物理内存没有直接对应关系。只有当你访问该内存时,才会检查所需的物理内存是否可用。如果可用的物理内存不足,Linux 内核会关闭消耗大量内存的应用程序(可能是你的应用程序)。超配的好处在于,不太需要检查分配是否成功(因为通常不会失败),你可以为了安全起见分配大量内存,即使你只需要一点。但超配的缺点是,即使有一个有效的指针,你也不能确定你的内存访问是否有效,并且不会导致崩溃。另一个缺点是,你可能会懒得检查分配返回值,以及弄清楚和分配实际需要的内存量。

接下来,你还希望显示凯撒加密后的文件名以及加密后的文本。你决定不直接从堆中分配所需的内存,因为你担心重复分配小内存块(用于文件名)和大内存块(用于文件内容)会导致内存碎片化。为了避免直接分配动态内存,你实现了一个内存池。

内存池

上下文

在你的程序中,你经常需要从堆中分配和释放动态内存,用于大致相同大小的元素。你在编译时或启动时并不知道这些元素在程序中的具体位置和使用时间。

问题

频繁从堆中分配和释放对象会导致内存碎片化。

在分配对象时,特别是那些大小差异很大的对象,同时又释放其中一些时,堆内存会变得碎片化。即使你的代码中的分配大致相同大小,它们可能与并行运行的其他程序的分配混合在一起,导致分配大小差异很大且碎片化。

malloc函数只有在有足够的连续空闲内存可用时才能成功。这意味着,即使有足够的空闲内存可用,如果内存碎片化,并且没有足够大小的连续内存块可用,malloc函数也可能失败。内存碎片化意味着内存使用效率不高。

对于长时间运行的系统(如大多数嵌入式系统),碎片化是一个严重问题。如果系统运行了几年并分配和释放了许多小块,那么将不再能够分配更大的内存块。这意味着对于这样的系统,您一定要解决碎片化问题,如果不接受系统不时需要重启的话。

在使用动态内存时(尤其是与嵌入式系统结合使用时),另一个问题是从堆中分配内存需要一些时间。其他进程也尝试使用同一堆,因此分配必须进行互锁,并且其所需的时间变得非常难以预测。

解决方案

在整个程序生命周期中持有大块内存。在运行时,从该内存池检索固定大小的块,而不是直接从堆中分配新内存。

内存池可以放置在静态内存中,或者可以在程序启动时从堆中分配并在程序结束时释放。从堆中分配的优势在于,如果需要,可以分配额外的内存以增加内存池的大小。

实现函数以从该池中检索和释放预配置的固定大小的内存块。所有需要该大小内存的代码都可以使用这些函数(而不是 mallocfree)来获取和释放动态内存:

#define MAX_ELEMENTS 20;
#define ELEMENT_SIZE 255;

typedef struct
{
  bool occupied;
  char memory[ELEMENT_SIZE];
}PoolElement;

static PoolElement memory_pool[MAX_ELEMENTS];

/* Returns memory of at least the provided 'size' or NULL
 if no memory chunk from the pool is available */
void* poolTake(size_t size)
{
  if(size <= ELEMENT_SIZE)
  {
    for(int i=0; i<MAX_ELEMENTS; i++)
    {
      if(memory_pool[i].occupied == false)
      {
        memory_pool[i].occupied = true;
        return &(memory_pool[i].memory);
      }
    }
  }
  return NULL;
}

/* Gives the memory chunk ('pointer') back to the pool */
void poolRelease(void* pointer)
{
  for(int i=0; i<MAX_ELEMENTS; i++)
  {
    if(&(memory_pool[i].memory) == pointer)
    {
      memory_pool[i].occupied = false;
      return;
    }
  }
}

上述代码展示了内存池的简单实现,当然还有许多改进的方法。例如,可以将空闲内存槽存储在列表中以加快获取此类槽的速度。此外,可以使用互斥锁或信号量确保其在多线程环境中工作。

关于内存池,您必须知道将存储哪种类型的数据,因为您必须在运行时知道内存块的大小。您也可以使用这些块来存储较小的数据,但这样会浪费一些内存。

作为具有固定大小内存块的替代方案,您甚至可以实现一个内存池,允许检索可变大小的内存块。通过这种替代方案,尽管可以更好地利用内存,但最终仍会遇到与堆内存相同的碎片化问题。

后果

您解决了碎片化问题。使用固定大小内存块池,您可以确保一旦释放一个块,另一个块将可用。但是,您必须事先知道要在池中存储哪些类型的元素及其大小。如果决定在池中存储较小的元素,那么会浪费内存。

当使用可变大小的池时,你不会为较小的元素浪费内存,但是池中的内存会被碎片化。与直接使用堆相比,这种碎片化情况仍稍微好一些,因为你是该内存的唯一使用者(其他进程不使用相同的内存)。此外,你不会使其他进程使用的内存碎片化。然而,碎片化问题仍然存在。

无论你在池中使用可变大小还是固定大小的块,都会带来性能上的好处。从池中获取内存比从堆中分配更快,因为不需要与其他进程竞争获取内存。此外,访问池中的内存可能会更快,因为你的程序使用的池中所有内存都紧密相连,这减少了由于操作系统的分页机制而产生的时间开销。然而,初始创建池需要一些时间,并且会增加程序的启动时间。

在你的池内,释放内存以便在程序的其他地方重用它。但是,你的程序始终持有整个池的内存,并且这些内存对其他程序不可用。如果你不需要所有这些内存,从整体系统的角度来看,你就浪费了它。

如果池最初是固定大小的,则在运行时可能无法再获取更多的池内存块,即使堆中可能有足够的内存。如果池在运行时可以增加其大小,则检索内存块时,如果需要增加池的大小,则可能会意外增加检索内存的时间。

在安全或安全关键领域要注意内存池。池使得你的代码更难以测试,并且使得代码分析工具更难以发现与访问该内存相关的错误。例如,工具难以检测到你是否错误地访问了池的获取内存块边界之外的内存。你的进程还拥有池中那些你打算访问的内存块之前和之后直接位于边界的其他内存块,这使得代码分析工具难以意识到跨越内存池块边界访问数据是无意的。实际上,如果受影响的代码没有使用内存池,OpenSSL Heartbleed 漏洞可能已通过代码分析进行预防(参见 David A. Wheeler,“如何防止下一个 Heartbleed”,2020 年 7 月 18 日[最初发布于 2014 年 4 月 29 日],https://dwheeler.com/essays/heartbleed.xhtml)。

已知用途

以下示例展示了此模式的应用:

  • UNIX 系统为其进程对象使用固定大小的池。

  • 由 David R. Hanson 编写的书籍C Interfaces and Implementations(Addison-Wesley, 1996)展示了一个内存池实现的示例。

  • 内存池模式也在 Bruce P. Douglass(Addison-Wesley,2002)的书籍《实时设计模式:面向实时系统的稳健可扩展架构》和 James Noble 和 Charles Weir(Addison-Wesley,2000)的书籍《小内存软件:限制内存系统的模式》中有描述。

  • Android ION 内存管理器在其文件ion_system_heap.c中实现了内存池。在释放内存部分时,调用者可以选择实际释放该部分内存,如果这部分内存对安全至关重要。

  • H. M. MacDougall(MIT Press,1987)的书籍《模拟计算机系统:技术和工具》中描述的 smpl 离散事件模拟系统使用事件的内存池。这比为每个事件分配和释放内存更有效,因为处理每个事件只需要很短的时间,并且在模拟中有大量事件。

应用于运行示例

为了保持简单,您决定实现一个具有固定最大内存块大小的内存池。您不必处理多线程和从多个线程同时访问该池的情况,因此您可以简单地使用内存池模式的确切实现。

您最终得到了以下凯撒加密的最终代码:

#define ELEMENT_SIZE 255
#define MAX_ELEMENTS 10

typedef struct
{
  bool occupied;
  char memory[ELEMENT_SIZE];
}PoolElement;

static PoolElement memory_pool[MAX_ELEMENTS];

void* poolTake(size_t size)
{
  if(size <= ELEMENT_SIZE)
  {
    for(int i=0; i<MAX_ELEMENTS; i++)
    {
      if(memory_pool[i].occupied == false)
      {
        memory_pool[i].occupied = true;
        return &(memory_pool[i].memory);
      }
    }
  }
  return NULL;
}

void poolRelease(void* pointer)
{
  for(int i=0; i<MAX_ELEMENTS; i++)
  {
    if(&(memory_pool[i].memory) == pointer)
    {
      memory_pool[i].occupied = false;
      return;
    }
  }
}

#define MAX_FILENAME_SIZE ELEMENT_SIZE

/* Prints the Caesar-encrypted 'filename'.This function is responsible for
 allocating and deallocating the required buffers for storing the
 file content.
 Notes: The filename must be all capital letters and we accept that the
 '.' of the filename will also be shifted by the Caesar encryption. */
void encryptCaesarFilename(char* filename)
{
  char* buffer = poolTake(MAX_FILENAME_SIZE);
  if(buffer != NULL)
  {
    strlcpy(buffer, filename, MAX_FILENAME_SIZE);
    caesar(buffer, strnlen(buffer, MAX_FILENAME_SIZE));
    printf("\nEncrypted filename: %s ", buffer);
    poolRelease(buffer);
  }
}

/* For all files in the current directory, this function reads text from the
 file and prints the Caesar-encrypted text. */
void encryptDirectoryContent()
{
  struct dirent *directory_entry;
  DIR *directory = opendir(".");
  while((directory_entry = readdir(directory)) != NULL)
  {
    encryptCaesarFilename(directory_entry->d_name);
    encryptCaesarFile(directory_entry->d_name);
  }
  closedir(directory);
}

使用您的代码的最终版本,您现在可以执行凯撒加密,而无需遇到 C 语言中动态内存处理的常见陷阱。您确保您使用的内存指针是有效的,如果没有可用内存,您会断言,并且甚至避免在预定义内存区域之外出现碎片化。

查看代码,您会意识到它变得非常复杂。您只是想使用一些动态内存,并且不得不实现几十行代码来做到这一点。请记住,您的大部分代码可以在代码库中为任何其他分配重复使用。然而,一个接一个地应用模式并不是免费的。每添加一个模式都会增加一些额外的复杂性。然而,目的并不是应用尽可能多的模式。目的是只应用解决您问题的那些模式。例如,如果碎片化对您来说不是一个大问题,请不要使用自定义内存池。如果可以保持事情简单,那么请直接使用mallocfree直接分配和释放内存。或者更好的是,如果有选择的话,根本不要使用动态内存。

摘要

本章介绍了处理 C 程序中内存的模式。首选模式建议尽可能将变量放在堆栈上。永久内存是关于使用与程序生命周期相同的内存,以避免复杂的动态分配和释放。惰性清理还通过建议简单地不处理它来使程序员更轻松地释放内存。专属所有权则定义了内存何时以及由谁释放。分配包装提供了一个处理分配错误和使指针失效的中心点,从而使实现指针检查成为可能。如果碎片化或长时间分配成为问题,内存池则提供了帮助。

使用这些模式,程序员不再需要在内存使用和清理的详细设计决策上花费大量精力。相反,程序员可以简单依赖这些模式的指导,轻松处理 C 程序中的内存管理。

进一步阅读

与其他高级 C 编程主题相比,关于内存管理的文献非常丰富。大多数文献集中于分配和释放内存的语法基础,但以下几本书也提供了一些高级指导:

  • James Noble 和 Charles Weir 的书 小内存软件:有限内存系统的模式(Addison-Wesley, 2000)包含了许多精心制作的关于内存管理的模式。例如,这些模式描述了不同的内存分配策略(在启动时或运行时)以及诸如内存池或垃圾收集器等策略。所有模式还为多种编程语言提供了代码示例。

  • Fedor G. Pikus 的书 C++实用设计模式(Packt, 2019)并非专门为 C 编写,但 C 和 C++中使用的内存管理概念相似,因此也提供了相关指导,说明如何在 C 中管理内存。书中包含了一章专注于内存所有权,并解释如何使用 C++机制(如智能指针)明确谁拥有哪些内存。

  • Kamran Amini 的书 Extreme C(Packt, 2019)涵盖了许多 C 编程主题,如编译过程、工具链、单元测试、并发、进程内通信以及基本的 C 语法。书中还有一章讲述堆和栈内存,并描述了这些内存在代码段、数据段、堆栈段或堆段中的特定细节。

  • Bruce P. Douglass 的书 实时设计模式:稳健可扩展的实时系统架构(Addison-Wesley, 2002)包含了针对实时系统的模式。其中一些模式涉及内存的分配和清理。

展望

下一章将指导如何在一般情况下跨接口边界传输信息。该章节介绍了各种模式,详细阐述了 C 语言为函数之间传输信息提供的机制类型,以及应该使用哪些机制。

第四章:返回 C 函数的数据

返回函数调用的数据是在编写超过 10 行且需要可维护的任何类型的代码时所面临的任务。返回数据是一个简单的任务——你只需传递你想要在两个函数之间共享的数据——而在 C 语言中,你只有直接返回一个值或者通过模拟的“按引用传递”参数来返回数据的选项。选择不多,指导也不多,对吧?错!即使是从 C 函数返回数据的简单任务已经很棘手了,你可以采用多种方式来构造你的程序和函数参数。

特别是在 C 语言中,你必须自行管理内存分配和释放,将复杂数据在函数之间传递变得棘手,因为没有析构函数或垃圾收集器来帮助你清理数据。你必须问自己:数据应该放在堆栈上,还是应该分配?是调用者分配还是被调用者分配?

本章提供了如何在函数之间共享数据的最佳实践。这些模式帮助 C 编程初学者了解在 C 语言中返回数据的技术,也帮助高级 C 程序员更好地理解为什么要应用这些不同的技术。

图 4-1 展示了本章讨论的模式及其关系的概述,而 表 4-1 提供了这些模式的摘要。

返回信息的模式

图 4-1. 返回信息的模式概述

表 4-1. 返回信息的模式

模式名称 摘要
返回值 你想要拆分的函数部分彼此并不独立。通常在过程化编程中,某些部分生成一个结果,然后另一部分需要这个结果。你想要拆分的函数部分需要共享一些数据。因此,简单地使用一个 C 机制来获取函数调用结果的信息:返回值。在 C 语言中返回数据的机制会复制函数结果,并允许调用者访问这个副本。
输出参数 C 只支持从函数调用中返回单一类型,这使得返回多个信息片段变得复杂。因此,通过使用指针模拟按引用参数,通过单个函数调用返回所有数据。
聚合实例 C 只支持从函数调用中返回单一类型,这使得返回多个信息片段变得复杂。因此,将所有相关数据放入一个新定义的类型中。定义这个聚合实例以包含你想要共享的所有相关数据。在你组件的接口中定义它,让调用者直接访问存储在实例中的所有数据。
不可变实例 您想从您的组件向调用者提供大块不可变数据中保存的信息。因此,有一个实例(例如一个struct),其中包含要共享的数据在静态内存中。向希望访问它的用户提供此数据,并确保他们不能修改它。
调用者拥有的缓冲区 您想向调用者提供已知大小的复杂或大数据,并且该数据不是不可变的(在运行时更改)。因此,要求调用者提供一个缓冲区及其大小给返回大型复杂数据的函数。在函数实现中,如果缓冲区大小足够大,则将所需数据复制到缓冲区中。
被调函数分配 您想向调用者提供未知大小的复杂或大数据,并且该数据不是不可变的(在运行时更改)。因此,在提供大型复杂数据的函数内部分配一个具有所需大小的缓冲区。将所需数据复制到缓冲区中,并返回指向该缓冲区的指针。

运行示例

您希望为用户实现以太网驱动程序的诊断信息显示功能。首先,您直接将此功能添加到包含以太网驱动程序实现的文件中,并直接访问包含所需信息的变量:

void ethShow()
{
  printf("%i packets received\n", driver.internal_data.rec);
  printf("%i packets sent\n", driver.internal_data.snd);
}

后来,您意识到显示以太网驱动程序诊断信息的功能很可能会增长,因此决定将其放入单独的实现文件中,以保持代码整洁。现在,您需要一些简单的方法来将信息从您的以太网驱动组件传输到诊断组件。

一个解决方案是使用全局变量来传输这些信息,但如果使用全局变量,则分割实现文件的努力将是无用的。您将文件分割,因为您希望显示这些代码部分不是紧密耦合的——使用全局变量会重新引入紧密耦合。

一个更好的且非常简单的解决方案如下:让您的以太网组件具有 getter 函数,以返回所需信息作为返回值。

返回值

上下文

您希望将代码拆分为单独的函数,因为将所有内容放在一个函数和一个实现文件中是不良实践,因为这样会使代码难以阅读和调试。

问题

您希望拆分的函数部分并非彼此独立。与面向过程编程一样,某些部分提供一个结果,然后其他部分需要这个结果。您希望拆分的函数部分需要共享某些数据。

您希望有一个共享数据的机制,使您的代码易于理解。您希望在代码中明确表示数据在函数之间共享,并确保函数不通过代码中未清晰可见的边路渠道进行通信。因此,对于将信息返回给调用方而言,使用全局变量不是您的良好解决方案,因为全局变量可以从代码的任何其他部分访问和修改。而且,从函数签名中也不清楚使用哪个确切的全局变量来返回数据。

全局变量还有一个缺点,即它们可以用于存储状态信息,这可能导致相同函数调用的不同结果。这使得代码更难理解。除此之外,使用全局变量返回信息的代码也不具备可重入性,并且在多线程环境中使用也不安全。

解决方案

只需使用 C 语言中用于检索函数调用结果信息的机制:返回值。在 C 中返回数据的机制复制函数结果并为调用方提供对此副本的访问。

图 4-2 和以下代码展示了如何实现返回值。

返回值草图

图 4-2. 返回值

调用方代码

int my_data = getData();
/* use my_data */

被调用方代码

int getData()
{
  int requested_data;
  /* .... */
  return requested_data;
}

结果

返回值允许调用方获取函数结果的副本。除了函数实现外,没有其他代码可以修改此值,并且由于它是一个副本,此值仅由调用函数使用。与使用全局变量相比,更清晰地定义了哪些代码影响从函数调用中检索到的数据。

此外,通过不使用全局变量而使用函数结果的副本,函数可以是可重入的,并且可以在多线程环境中安全使用。

但是,对于内置的 C 类型,函数只能返回函数签名中指定类型的单个对象。不可能定义返回三个不同 int 对象的函数,例如。如果您希望返回的信息比仅包含一个简单的标量 C 类型更多,则必须使用聚合实例或输出参数。

此外,如果您希望从数组返回数据,则返回值不是您想要的,因为它不会复制数组的内容,而只会复制到数组的指针。调用方可能会得到一个指向超出作用域的数据的指针。对于返回数组,您必须使用其他机制,如调用方拥有的缓冲区或被调用方分配。

请记住,每当简单的返回值机制足够时,您应始终选择这种最简单的选项来返回数据。您不应选择更强大但也更复杂的模式,例如输出参数、聚合实例、调用方拥有的缓冲区或被调用方分配。

已知的使用情况

以下示例显示了此模式的应用:

  • 您可以在任何非void函数中找到此模式。以此方式返回数据。

  • 每个 C 程序都有一个main函数,它已经为其调用者(如操作系统)提供了返回值。

应用于正在运行的示例

应用返回值很简单。现在,在一个与以太网驱动程序分离的实现文件中,您有一个新的诊断组件,该组件从以太网驱动程序获取诊断信息,如下面的代码所示:

以太网驱动 API

/* Returns the number of total received packets*/
int ethernetDriverGetTotalReceivedPackets();

/* Returns the number of total sent packets*/
int ethernetDriverGetTotalSentPackets();

调用者的代码

void ethShow()
{
  int received_packets = ethernetDriverGetTotalReceivedPackets();
  int sent_packets = ethernetDriverGetTotalSentPackets();
  printf("%i packets received\n", received_packets);
  printf("%i packets sent\n", sent_packets);
}

这段代码易于阅读,如果您想添加额外信息,可以简单地添加额外的函数来获取这些信息。这正是您接下来要做的事情。您希望显示有关发送的数据包的更多信息。您希望向用户显示成功发送了多少数据包以及失败了多少。您的第一次尝试是编写以下代码:

void ethShow()
{
  int received_packets = ethernetDriverGetTotalReceivedPackets();
  int total_sent_packets = ethernetDriverGetTotalSentPackets();
  int successfully_sent_packets = ethernetDriverGetSuccesscullySentPackets();
  int failed_sent_packets = ethernetDriverGetFailedPackets();
  printf("%i packets received\n", received_packets);
  printf("%i packets sent\n", total_sent_packets);
  printf("%i packets successfully sent\n", successfully_sent_packets);
  printf("%i packets failed to send\n", failed_sent_packets);
}

使用这段代码,您最终意识到,有时与您预期的不同,successfully_sent_packets加上failed_sent_packets的结果会高于total_sent_packets。这是因为您的以太网驱动程序在一个单独的线程中运行,在您调用获取信息的函数之间,以太网驱动程序继续工作并更新其数据包信息。因此,例如,如果以太网驱动程序在您调用ethernetDriverGet​To⁠tal​SentPacketsethernetDriverGetSuccesscullySentPackets之间成功发送一个数据包,则显示给用户的信息不一致。

一个可能的解决方案是确保在调用函数获取数据包信息时,以太网驱动程序不在工作中。例如,您可以使用互斥锁或信号量来确保这一点,但是对于获取数据包统计信息这样简单的任务,您希望不是您来解决这个问题。

作为一个更简单的替代方法,您可以使用输出参数在一个函数调用中返回多个信息片段。

输出参数

上下文

您希望将来自您组件的相关信息数据提供给调用者,这些信息数据可能在不同的函数调用之间发生变化。

问题

C 仅支持从函数调用返回单一类型,这使得返回多个信息片段变得复杂。

使用全局变量传递表示信息的数据也不是一个好的解决方案,因为使用全局变量返回信息的代码不可重入,并且在多线程环境中使用是不安全的。此外,全局变量可以从代码的任何其他部分访问和修改,并且在使用全局变量时,从函数签名中不清楚哪些确切的全局变量用于返回数据。因此,全局变量会使您的代码难以理解和维护。此外,使用多个函数的返回值也不是一个好的选择,因为要返回的数据是相关联的,因此将其分割到多个函数调用中会使代码变得不可读。

因为数据片段相关联,调用者希望检索所有这些数据的一致快照。在多线程环境中使用多个返回值时会出现问题,因为数据可以在运行时更改。在这种情况下,您必须确保数据在调用者多次函数调用之间不会更改。但是,您无法知道调用者是否已经完成了所有数据的读取,或者调用者是否会使用另一个函数调用检索另一条信息。因此,您无法确保数据在调用者的函数调用之间不会修改。如果使用多个函数提供相关信息,则不知道数据不得更改的时间跨度。因此,通过这种方法,您无法保证调用者将检索到信息的一致快照。

如果需要大量准备工作来计算相关数据片段,则使用多个具有返回值的函数也可能不是一个好的解决方案。例如,如果要从地址簿返回指定人员的家庭和移动电话号码,并且有单独的函数来检索这些号码,则必须分别搜索此人员的地址簿条目以进行每个函数调用。这需要不必要的计算时间和资源。

解决方案

通过使用指针模拟按引用传递的参数,在一个函数调用中返回所有数据。

C 不支持使用返回值返回多个类型,也不原生支持按引用传递的参数,但可以通过模拟按引用传递的参数来实现,如图 4-3 和以下代码所示。

输出参数草图

图 4-3. 输出参数

调用者的代码

int x,y;
getData(&x,&y);
/* use x,y */

被调用者的代码

void getData(int* x, int* y)
{
  *x = 42;
  *y = 78;
}

一个单一功能,具有许多指针参数。在函数实现中,解引用指针并将要返回给调用者的数据复制到指向的实例中。在函数实现中,确保在复制时数据不会更改。这可以通过互斥来实现。

后果

现在,所有表示相关信息的数据都在一个单一的函数调用中返回,并且可以保持一致(例如通过 Mutex 或信号量保护数据)。该函数是可重入的,可以安全地在多线程环境中使用。

对于每个额外的数据项,都将额外的指针传递给函数。这样做的缺点是,如果要返回大量数据,函数的参数列表会变得越来越长。一个函数有许多参数是一种代码味道,因为它使得代码难以阅读。这就是为什么很少使用多个 Out-Parameters 来返回一个函数的原因,而是通过聚合实例返回相关的信息来清理代码。

此外,对于每个数据片段,调用者必须将指针传递给函数。这意味着对于每个数据片段,都必须在堆栈上放置一个额外的指针。如果调用者的堆栈内存非常有限,这可能会成为一个问题。

Out-Parameters 的缺点在于,当仅查看函数签名时,无法明确识别它们作为 Out-Parameters。从函数签名中,调用者只能猜测当他们看到一个指针时,它可能是一个 Out-Parameter。但是这样的指针参数也可能是函数的输入。因此,必须在 API 文档中明确描述哪些参数是输入,哪些是输出。

对于简单的标量 C 类型,调用者可以简单地将变量的指针作为函数参数传递。由于指定的指针类型,函数实现中提供了解释指针的所有信息。要返回复杂类型(如数组)的数据,必须提供 Caller-Owned Buffer,或者 Callee Allocates 并传达有关数据的额外信息,例如其大小。

已知的使用情况

以下示例展示了此模式的应用:

  • Windows 的RegQueryInfoKey函数通过函数的 Out-Parameters 返回有关注册表键的信息。调用者提供unsigned long指针,函数将键的子键数和键值的大小等信息写入指向的unsigned long变量中。

  • Apple 的 C 程序的 Cocoa API 使用额外的NSError参数来存储在函数调用期间发生的错误。

  • 实时操作系统 VxWorks 的函数userAuthenticate使用 Return Values 来返回信息,例如提供的登录名的密码是否正确。此外,该函数采用 Out-Parameter 来返回与提供的登录名关联的用户 ID。

应用于运行示例

通过应用 Out-Parameters,您将获得以下代码:

以太网驱动程序 API

/* Returns driver status information via out-parameters.
   total_sent_packets   --> number of packets tried to send (success and fail)
   successfully_sent_packets --> number of packets successfully sent
   failed_sent_packets  --> number of packets failed to send */
void ethernetDriverGetStatistics(int* total_sent_packets,
      int* successfully_sent_packets, int* failed_sent_packets); ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)

1

要获取关于发送数据包的信息,你只需调用以太网驱动程序的一个函数,并且以太网驱动程序可以确保在此调用中传递的数据是一致的。

调用者的代码

void ethShow()
{
  int total_sent_packets, successfully_sent_packets, failed_sent_packets;
  ethernetDriverGetStatistics(&total_sent_packets, &successfully_sent_packets,
                              &failed_sent_packets);
  printf("%i packets sent\n", total_sent_packets);
  printf("%i packets successfully sent\n", successfully_sent_packets);
  printf("%i packets failed to send\n", failed_sent_packets);

  int received_packets = ethernetDriverGetTotalReceivedPackets();
  printf("%i packets received\n", received_packets);
}

你还考虑在同一个函数调用中检索received_packets和发送的数据包,但是你意识到一个函数调用变得越来越复杂。一个带有三个输出参数的函数调用已经很复杂了,写起来和读起来都不容易。在调用函数时,参数的顺序很容易混淆。增加第四个参数并不能使代码变得更好。

为了使代码更易读,可以使用一个聚合实例。

聚合实例

上下文

你希望向调用者提供从你的组件到调用者的相关信息片段。这些信息片段在不同的函数调用之间可能会发生变化。

问题

C 只支持从函数调用中返回单一类型,这使得返回多个信息片段变得复杂。

使用全局变量传递表示你的信息片段的数据也不是一个好的解决方案,因为使用全局变量返回信息的代码不具备可重入性,并且在多线程环境中使用起来也不安全。除此之外,全局变量可以从代码的任何其他部分访问和修改,在使用全局变量时,从函数签名中无法清楚地知道哪些具体的全局变量用于返回数据。因此,全局变量会使你的代码难以理解和维护。同时,使用多个函数的返回值也不是一个好选择,因为你要返回的数据是相关的,所以将其分散在多个函数调用中会使代码变得不太可读。

使用多个输出参数的单个函数也不是一个好主意,因为如果有多个这样的输出参数,很容易混淆,你的代码会变得难以阅读。此外,你想表明这些参数是密切相关的,甚至可能需要将同一组参数提供给或由其他函数返回。如果使用函数参数显式执行此操作,那么在稍后添加额外参数时,必须修改每个这样的函数。

因为数据片段相关联,调用者希望在多线程环境中检索到所有这些数据的一致快照。当使用多返回值时,这就成为一个问题,因为数据可能在运行时更改。在这种情况下,您必须确保数据在调用者的多个函数调用之间不会更改。但您无法知道调用者是否已完成读取所有数据,或者调用者是否会在另一个函数调用中检索另一条信息。因此,您无法确保数据在调用者的函数调用之间不被修改。如果使用多个函数提供相关信息,则不知道数据不得更改的时间跨度。因此,通过这种方法,您无法保证调用者将检索到信息的一致快照。

如果需要大量准备工作来计算相关数据片段,则使用具有返回值的多个函数也可能不是一个好的解决方案。例如,如果您希望从通讯录中为指定的人返回家庭电话和移动电话号码,并且您有单独的函数来检索这些号码,则必须分别搜索该人的通讯录条目。这需要不必要的计算时间和资源。

解决方案

将所有相关数据放入新定义的类型中。定义此聚合实例以包含您希望共享的所有相关数据。在您组件的接口中定义它,让调用者直接访问实例中存储的所有数据。

要实现此操作,请在您的头文件中定义一个struct,并将从被调用函数返回的所有类型定义为此struct的成员。在函数实现中,像在图 4-4 中展示的那样,将要返回的数据复制到struct成员中。在函数实现中,确保在复制数据时数据不会更改。可以通过互斥锁或信号量来实现这一点。

聚合实例草图

图 4-4. 聚合实例

要将struct实际返回给调用者,有两个主要选项:

  • 将整个struct作为返回值传递。C 不仅允许将内置类型作为函数的返回值传递,还允许像struct这样的用户定义类型。

  • 使用 Out-Parameter 传递struct的指针。但是,当仅传递指针时,会出现谁提供和拥有所指向内存的问题。这个问题在“调用者拥有缓冲区”和“被调用者分配”中得到了解决。与直接让调用者访问聚合实例的指针相比,您可以考虑通过使用句柄来隐藏struct,从而避免这个问题。

下面的代码显示了通过传递整个struct的变体:

调用者的代码

struct AggregateInstance my_instance;
my_instance = getData();
/* use my_instance.x
 use my_instance.y, ... */

被调用者的代码

struct AggregateInstance
{
  int x;
  int y;
};

struct AggregateInstance getData()
{
  struct AggregateInstance inst;
  /* fill inst.x and inst.y */
  return inst; ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
}

1

返回时,即使 inst 是一个 struct,其内容也会被复制,调用者可以在 inst 超出范围后访问复制的内容。

结果

现在,调用者可以通过单个函数调用通过聚合实例检索多个表示相关信息的数据。该函数是可重入的,在多线程环境中可以安全使用。

这为调用者提供了相关信息的一致快照。这也使调用者的代码更清晰,因为他们不必调用多个函数或一个带有多个输出参数的函数。

通过使用返回值在函数之间传递数据时,所有这些数据都放在堆栈上。将一个 struct 传递给 10 个嵌套函数时,这个 struct 在堆栈上会出现 10 次。在某些情况下这不是问题,但在其他情况下是—特别是如果 struct 太大,并且您不希望通过每次复制整个 struct 来浪费堆栈内存。因此,通常不直接传递或返回 struct,而是传递或返回指向该 struct 的指针。

当将指向 struct 的指针传递给函数时,或者如果 struct 包含指针,则必须记住 C 不会为您执行深拷贝的工作。C 只复制指针值,而不复制它们指向的实例。这可能不是您想要的,因此一旦涉及指针,您必须处理提供和清理指向的内存。这个问题在“调用者拥有缓冲区”和“被调用者分配”中得到解决。

已知用途

下面的示例展示了此模式的应用:

  • 文章 “Argument Passing Patterns” 由 Uwe Zdun 描述了这种模式,包括 C++ 示例,称为上下文对象,以及书籍 Refactoring: Improving the Design of Existing Code 由 Martin Fowler (Addison-Wesley, 1999) 描述为参数对象。

  • 游戏 NetHack 的代码将怪物属性存储在聚合实例中,并提供一个函数来检索此信息。

  • 文本编辑器 sam 的实现在将 structs 传递给函数和从函数返回它们时进行复制,以保持代码简洁。

应用于运行示例

使用聚合实例,您将获得以下代码:

以太网驱动程序 API

struct EthernetDriverStat{
  int received_packets;         /* Number of received packets */
  int total_sent_packets;       /* Number of sent packets (success and fail)*/
  int successfully_sent_packets;/* Number of successfully sent packets */
  int failed_sent_packets;      /* Number of packets failed to send */
};

/* Returns statistics information of the Ethernet driver */
struct EthernetDriverStat ethernetDriverGetStatistics();

调用者的代码

void ethShow()
{
  struct EthernetDriverStat eth_stat = ethernetDriverGetStatistics();
  printf("%i packets received\n", eth_stat.received_packets);
  printf("%i packets sent\n", eth_stat.total_sent_packets);
  printf("%i packets successfully sent\n",eth_stat.successfully_sent_packets);
  printf("%i packets failed to send\n", eth_stat.failed_sent_packets);
}

现在您只需调用以太网驱动程序一次,以太网驱动程序可以确保在此调用中交付的数据是一致的。此外,您的代码看起来更整洁,因为属于一起的数据现在集中在一个单独的 struct 中。

接下来,你想向用户显示更多有关以太网驱动程序的信息。你想要向用户展示数据包统计信息所属的以太网接口,因此你想要显示包括驱动程序的文本描述在内的驱动程序名称。这两者都包含在存储在以太网驱动程序组件中的字符串中。该字符串相当长,你并不确切知道它的长度。幸运的是,在运行时该字符串不会更改,因此你可以访问一个不可变实例。

不可变实例

背景

你的组件包含大量数据,另一个组件想要访问这些数据。

问题

你想要将组件中大块不可变数据的信息提供给调用者。

对每个调用者复制数据将会浪费内存,因此通过返回聚合实例或将所有数据复制到输出参数中提供所有数据,由于堆栈内存限制而不可行。

通常,仅返回指向此类数据的指针是棘手的。你会面临这样的问题:使用指针,可以修改这些数据,并且当多个调用者读取和写入相同数据时,你必须想出机制来确保你要访问的数据是一致的和最新的。幸运的是,在你的情况下,要提供给调用者的数据在编译时或启动时是固定的,并且在运行时不会改变。

解决方案

有一个实例(例如一个struct),其中包含要共享的数据放在静态内存中。将这些数据提供给想要访问它们的用户,并确保他们不能修改它。

在编译时或启动时编写要包含在实例中的数据,并且不再在运行时更改它。你可以直接在程序中硬编码写入数据,或者在程序启动时初始化它(参见“具有全局状态的软件模块”以获取初始化变体和“永久内存”以获取存储变体)。如图 4-5 所示(#fig_immutable),即使多个调用者(和多个线程)同时访问该实例,它们也不必担心对方,因为实例不会更改,因此始终处于一致状态并包含所需信息。

实现一个返回数据指针的函数。或者,你甚至可以直接将包含数据的变量作为全局变量放入你的 API 中,因为数据在运行时不会改变。但是,与全局变量相比,获取函数更好,因为它使编写单元测试更容易,并且在未来代码行为更改的情况下(如果你的数据不再是不可变的),你不必更改接口。

不可变实例草图

图 4-5. 不可变实例

为确保调用者不修改数据,在返回数据指针时,使指向的数据为const,如下面的代码所示:

调用者的代码

const struct ImmutableInstance* my_instance;
my_instance = getData(); ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
/* use my_instance->x,
   use my_instance->y, ... */

1

调用者获得引用但不拥有内存。

被调用者 API

struct ImmutableInstance
{
  int x;
  int y;
};

被调用者的实现

static struct ImmutableInstance inst = {12, 42};
const struct ImmutableInstance* getData()
{
   return &inst;
}

结果

调用者可以调用一个简单的函数来访问甚至复杂或大型的数据,而无需关心此数据存储在何处。 调用者不必提供用于存储此数据的缓冲区,不必清理内存,并且不必关心数据的生命周期——它始终存在。

调用者可以通过检索到的指针读取所有数据。 用于检索指针的简单函数是可重入的,并且可以安全地用于多线程环境中。 此外,由于数据在运行时不会更改,并且只读取数据的多个线程没有问题,因此还可以安全地访问数据。

但是,在不采取进一步措施的情况下,不能在运行时更改数据。 如果调用者需要能够更改数据,则可以实施类似写时复制的方法。 如果一般情况下数据可以在运行时更改,则不可变实例不是一个选择,而是必须使用调用者拥有的缓冲区或被调用者分配的复杂和大型数据共享。

已知用途

以下示例展示了此模式的应用:

  • 在他的文章“Java 中的模式:值模式”,Kevlin Henney 详细描述了类似的不可变对象模式,并提供了 C++代码示例。

  • 游戏 NetHack 的代码将不可变的怪物属性存储在不可变实例中,并提供一个用于检索这些信息的函数。

应用于运行示例

通常,返回指向组件内存储数据的指针是棘手的。 这是因为如果多个调用者访问(甚至可能写入)此数据,则普通指针不是您的解决方案,因为您永远不知道您拥有的指针是否仍然有效以及此指针中包含的数据是否一致。 但在这种情况下,我们很幸运,因为我们有一个不可变的实例。 驱动程序名称和描述都是在编译时确定的信息,并且之后不会更改。 因此,我们可以简单地检索指向此数据的常量指针:

以太网驱动程序 API

struct EthernetDriverInfo{
  char name[64];
  char description[1024];
};

/* Returns the driver name and description */
const struct EthernetDriverInfo* ethernetDriverGetInfo();

调用者的代码

void ethShow()
{
  struct EthernetDriverStat eth_stat = ethernetDriverGetStatistics();
  printf("%i packets received\n", eth_stat.received_packets);
  printf("%i packets sent\n", eth_stat.total_sent_packets);
  printf("%i packets successfully sent\n",eth_stat.successfully_sent_packets);
  printf("%i packets failed to send\n", eth_stat.failed_sent_packets);

  const struct EthernetDriverInfo* eth_info = ethernetDriverGetInfo();
  printf("Driver name: %s\n", eth_info->name);
  printf("Driver description: %s\n", eth_info->description);
}

作为下一步,除了以太网接口的名称和描述外,您还希望向用户显示当前配置的 IP 地址和子网掩码。 这些地址作为字符串存储在以太网驱动程序中。 这两个地址都是在运行时可能会更改的信息,因此您不能简单地返回一个指向不可变实例的指针。

尽管以太网驱动程序将这些字符串打包到聚合实例中并简单返回此实例(在返回结构体时,结构体中的数组会被复制),但对于大量数据来说,这种解决方案并不常见,因为它会消耗大量堆栈内存。通常使用指针代替。

使用指针是您寻找的确切解决方案:使用Caller-Owned Buffer

Caller-Owned Buffer

上下文

您有一些大数据想要在不同组件之间共享。

问题

您想要向调用方提供复杂或大型的已知大小数据,并且该数据不是不可变的(在运行时会发生变化)。

因为数据在运行时发生变化(也许是因为您为调用方提供了写入数据的函数),所以您不能简单地向调用方提供指向静态数据的指针(就像不可变实例的情况)。如果您仅向调用方提供这样的指针,那么您可能会遇到问题,因为在多线程环境中,另一个调用者可能同时写入该数据,导致读取的数据不一致(部分被覆写)。

将所有数据简单地复制到聚合实例中,并通过返回值传递给调用方并不可行,因为数据量大,无法通过堆栈传递,堆栈内存非常有限。

如果仅返回指向聚合实例的指针,则不再有堆栈内存限制的问题,但您必须记住,C 语言不会为您执行深度复制的工作。C 仅返回指针。您必须确保函数调用后,指向的数据(存储在聚合实例或数组中)仍然有效。例如,您不能将数据存储在函数内的自动变量中,并提供指向这些变量的指针,因为函数调用后,这些变量会超出作用域。

现在出现了一个问题,即数据应该存储在何处。必须澄清调用方或被调用方应提供所需的内存,并由哪一个负责管理和清理内存。

解决方案

要求调用方为函数提供缓冲区及其大小,以便将所需数据复制到缓冲区(如果缓冲区大小足够大)。

确保在复制时数据不会更改。可以通过互斥锁或信号量来实现互斥。这样,调用方就拥有了缓冲区中数据的快照,并且是该快照的唯一所有者,因此即使原始数据在此期间发生更改,也可以一致地访问此快照。

调用方可以分别将缓冲区及其大小作为单独的函数参数提供,或者调用方可以将缓冲区及其大小打包到聚合实例中,并将聚合实例的指针传递给函数。

由于调用者必须向函数提供缓冲区及其大小,所以调用者必须事先知道大小。为了让调用者知道缓冲区应有的大小,API 中必须包含大小需求。可以通过将大小定义为宏或在 API 中定义包含所需大小缓冲区的struct来实现此目的。

图 4-6 和下面的代码展示了调用者拥有缓冲区的概念。

调用者拥有的缓冲区示意图

图 4-6. 调用者拥有的缓冲区

调用者的代码

struct Buffer buffer;

getData(&buffer);
/* use buffer.data */

被调用者的 API

#define BUFFER_SIZE 256
struct Buffer
{
  char data[BUFFER_SIZE];
};

void getData(struct Buffer* buffer);

被调用者的实现

void getData(struct Buffer* buffer)
{
  memcpy(buffer->data, some_data, BUFFER_SIZE);
}

结果

可以通过单个函数调用向调用者提供大型复杂数据。该函数是可重入的,并且可以安全地在多线程环境中使用。此外,由于调用者是缓冲区的唯一所有者,所以调用者可以在多线程环境中安全地访问数据。

调用者提供了一个期望大小的缓冲区,并且甚至可以决定缓冲区的内存类型。调用者可以将缓冲区放在堆栈上(参见“先入栈”),并从堆栈内存的优势中受益,变量超出作用域后,堆栈内存将被清理。或者,调用者可以将内存放在堆上,以确定变量的生命周期或避免浪费堆栈内存。此外,调用函数可能仅具有由其调用函数获取的缓冲区的引用。在这种情况下,可以简单地传递此缓冲区,而无需拥有多个缓冲区。

在函数调用期间不执行分配和释放内存的耗时操作。调用者可以确定这些操作何时发生,因此函数调用变得更快更可预测。

从 API 中可以清楚地看出,调用者专有缓冲区的所有权。调用者必须提供缓冲区并在使用后进行清理。如果调用者分配了缓冲区,则调用者负责在使用后释放它。

调用者必须事先知道缓冲区的大小,并且因为已知这个大小,函数可以安全地在缓冲区中操作。但在某些情况下,调用者可能不知道需要的确切大小,如果被调用者分配,则会更好。

已知的使用情况

下面的例子展示了此模式的应用:

  • NetHack 代码使用此模式将保存游戏进度的信息提供给实际将游戏进度存储在磁盘上的组件。

  • B&R 自动化运行时操作系统在函数中使用此模式来检索 IP 地址。

  • C 标准库函数fgets从流中读取输入并将其存储在提供的缓冲区中。

应用于运行示例

现在你向以太网驱动函数提供一个由调用者拥有的缓冲区,并且函数将其数据复制到这个缓冲区中。你必须事先知道缓冲区的大小。在获取 IP 地址字符串的情况下,这不是问题,因为字符串有固定大小。所以你可以简单地将 IP 地址的缓冲区放在堆栈上,并将这个堆栈变量提供给以太网驱动程序。或者,也可以在堆上分配缓冲区,但在这种情况下并不需要,因为 IP 地址的大小是已知的,并且数据的大小足够小,可以适应堆栈:

以太网驱动程序 API

struct IpAddress{
  char address[16];
  char subnet[16];
};

/* Stores the IP information into 'ip', which has to be provided
 by the caller*/
void ethernetDriverGetIp(struct IpAddress* ip);

调用者的代码

void ethShow()
{
  struct EthernetDriverStat eth_stat = ethernetDriverGetStatistics();
  printf("%i packets received\n", eth_stat.received_packets);
  printf("%i packets sent\n", eth_stat.total_sent_packets);
  printf("%i packets successfully sent\n",eth_stat.successfully_sent_packets);
  printf("%i packets failed to send\n", eth_stat.failed_sent_packets);

  const struct EthernetDriverInfo* eth_info = ethernetDriverGetInfo();
  printf("Driver name: %s\n", eth_info->name);
  printf("Driver description: %s\n", eth_info->description);

  struct IpAddress ip;
  ethernetDriverGetIp(&ip);
  printf("IP address: %s\n", ip.address);
}

接下来,你想要扩展你的诊断组件,以便打印最后接收到的数据包的转储。现在,这是一条信息,太大了不能放在堆栈上,因为以太网数据包大小可变,你事先不知道数据包的缓冲区大小。因此,对于你来说,调用者拥有的缓冲区不是一个选择。

当然,你可以简单地有函数 EthernetDriverGetPacketSize()EthernetDriverGetPacket(buffer),但在这里,你将会遇到一个问题,即在两个函数调用之间,以太网驱动程序可能会接收到另一个数据包,这会使你的数据不一致。此外,这种解决方案并不太优雅,因为你需要调用两个不同的函数来实现一个目的。相反,如果被调用者分配会更容易。

被调用者分配

上下文

你有大量数据希望在不同的组件之间共享。

问题

你希望向调用者提供复杂或大量数据的大小未知,并且这些数据在运行时不是不可变的。

数据在运行时会发生变化(也许是因为你提供给调用者写数据的函数),因此你不能简单地提供一个指向静态数据的指针给调用者(这就是不可变实例的情况)。如果你简单地向调用者提供这样一个指针,你会遇到一个问题,即一个调用者读取的数据可能是不一致的(部分被覆盖),因为在多线程环境下,另一个调用者可能同时写入该数据。

简单地将所有数据复制到聚合实例中,并通过返回值传递给调用者不是一个选择。通过返回值,你只能传递已知大小的数据,并且因为数据很大,不能通过堆栈传递,堆栈的内存非常有限。

如果仅返回指向聚合实例的指针,则不再存在堆栈内存限制的问题,但必须记住 C 不会为您执行深度复制的工作。C 只返回指针。您必须确保在函数调用后,指向的数据(存储在聚合实例或数组中)仍然有效。例如,您不能将数据存储在函数内的自动变量中,并提供指向这些变量的指针,因为在函数调用后,这些变量会超出作用域并被清理。

现在出现了数据应存储在何处的问题。必须澄清是调用者还是被调用者应提供所需的内存,以及哪个部分负责管理和清理内存。

您要提供的数据量在编译时不是固定的。例如,您希望返回先前未知大小的字符串。这使得使用调用者拥有的缓冲区变得不切实际,因为调用者事先不知道缓冲区的大小。调用者可以事先询问所需的缓冲区大小(例如,使用 getRequiredBufferSize() 函数),但这也是不切实际的,因为为了检索一个数据片段,调用者必须进行多次函数调用。此外,在这些函数调用之间,您要提供的数据可能会发生变化,然后调用者再次提供错误大小的缓冲区。

解决方案

在提供大型复杂数据的函数内部分配所需大小的缓冲区。将所需数据复制到缓冲区中并返回指向该缓冲区的指针。

将缓冲区的指针和大小作为输出参数提供给调用者。在函数调用后,调用者可以操作缓冲区,知道其大小,并独占缓冲区所有权。调用者决定其生命周期,因此负责清理,如 图 4-7 和接下来的代码所示。

Callee Allocates Sketch

图 4-7. 被调用者分配

调用者的代码

char* buffer;
int size;
getData(&buffer, &size);
/* use buffer */
free(buffer);

被调用者的代码

void getData(char** buffer, int* size)
{
  *size = data_size;
  *buffer = malloc(data_size);
  /* write data to buffer */ ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
}

1

在将数据复制到该缓冲区时,请确保其在此期间不会更改。这可以通过互斥锁或信号量来实现。

或者,将缓冲区的指针和大小放入作为返回值提供的聚合实例中。为了让调用者更清楚地知道在聚合实例中有一个需要释放的指针,API 可以提供一个额外的清理函数。同时提供清理函数时,API 看起来已经非常类似于具有句柄的 API,这样既保持了 API 的兼容性,又带来了灵活性的额外好处。

无论被调用函数是通过聚合实例还是通过输出参数提供缓冲区,都必须向调用者明确表明,调用者拥有缓冲区并负责释放它。这种专有所有权必须在 API 中得到很好的文档记录。

后果

调用者可以通过单个函数调用检索以前未知大小的缓冲区。该函数是可重入的,可以安全地在多线程环境中使用,并为调用者提供有关缓冲区及其大小的一致信息。知道大小后,调用者可以安全地操作数据。例如,调用者甚至可以处理通过这些缓冲区传输的未终止字符串。

调用者拥有缓冲区,确定其生命周期,并负责释放它(就像使用句柄一样)。从接口上看,必须非常明确地指出调用者必须这样做。使这一点变得清晰的一种方法是在 API 中记录它。另一种方法是有一个显式的清理函数,以使需要清理的内容更加明显。这样的清理函数还有额外的优势,即分配内存的组件也负责释放它。如果两个相关组件使用不同的编译器或在不同平台上运行,这一点非常重要——在这种情况下,分配和释放内存的函数可能在组件之间不同,这使得分配和释放的组件必须是同一个。

调用者无法确定缓冲区应使用哪种类型的内存——这在拥有调用者缓冲区时是可能的。现在调用者必须使用在函数调用内部分配的内存类型。

分配需要时间,这意味着与拥有调用者缓冲区相比,函数调用变得更慢且不太可预测。

已知用途

以下示例显示了此模式的应用:

  • malloc函数正是如此。它分配一些内存并将其提供给调用者。

  • strdup函数接受字符串作为输入,分配重复的字符串并返回它。

  • getifaddrs Linux 函数提供有关配置的 IP 地址信息。存储此信息的数据存储在函数分配的缓冲区中。

  • NetHack 代码使用此模式来检索缓冲区。

应用于运行示例

您的诊断组件的最终代码检索由被调用方分配的缓冲区中的数据包数据:

以太网驱动程序 API

struct Packet
{
  char data[1500]; /* maximum 1500 byte per packet */
  int size;        /* actual size of data in the packet */
};

/* Returns a pointer to a packet that has to be freed by the caller */
struct Packet* ethernetDriverGetPacket();

调用者的代码

void ethShow()
{
  struct EthernetDriverStat eth_stat = ethernetDriverGetStatistics();
  printf("%i packets received\n", eth_stat.received_packets);
  printf("%i packets sent\n", eth_stat.total_sent_packets);
  printf("%i packets successfully sent\n",eth_stat.successfully_sent_packets);
  printf("%i packets failed to send\n", eth_stat.failed_sent_packets);

  const struct EthernetDriverInfo* eth_info = ethernetDriverGetInfo();
  printf("Driver name: %s\n", eth_info->name);
  printf("Driver description: %s\n", eth_info->description);

  struct IpAddress ip;
  ethernetDriverGetIp(&ip);
  printf("IP address: %s\n", ip.address);

  struct Packet* packet = ethernetDriverGetPacket();
  printf("Packet Dump:");
  fwrite(packet->data, 1, packet->size, stdout);
  free(packet);
}

通过这个最终版本的诊断组件,我们可以看到如何从另一个函数中获取信息的所有展示方式。将所有这些方式混合在一段代码中可能不是您实际想做的,因为在栈上有一个数据片段,而在堆上有另一个数据片段会让人有些困惑。一旦分配了缓冲区,您不希望混合不同的方法,因此在单个函数中同时使用调用方拥有缓冲区被调用者分配可能不是您想要的。相反,选择一个适合所有需求的方法,并在一个函数或组件中坚持使用它。这样可以使您的代码更统一且更易于理解。

然而,如果您只需从另一个组件获取单个数据片段,并且可以选择使用本章早期介绍的更简单的替代方法来检索数据,那么请始终这样做,以保持代码简洁。例如,如果可以选择将缓冲区放在栈上,请这样做,因为这样可以省去释放缓冲区的工作。

总结

本章展示了在 C 语言中如何从函数返回数据以及如何处理缓冲区的不同方法。最简单的方式是使用返回值返回单个数据片段,但如果需要返回多个相关数据片段,那么应该使用输出参数或者更好的聚合实例。如果返回的数据在运行时不会改变,可以使用不可变实例。在返回缓冲区中的数据时,如果缓冲区的大小事先已知,可以使用调用方拥有缓冲区;如果大小事先不知道,则可以使用被调用者分配

通过本章的模式,C 程序员可以掌握一些基本工具和指导,用于在函数之间传输数据以及处理返回、分配和释放缓冲区。

展望

下一章将介绍如何将较大的程序组织成软件模块,并且讨论这些软件模块如何处理数据的生命周期和所有权。这些模式概述了用于构建较大 C 代码片段的基本组成部分。

第五章:数据生命周期和所有权

如果我们看看像 C 这样的过程式编程语言,那么其中没有原生的面向对象机制。这在某种程度上增加了编程的难度,因为大多数设计指导都是针对面向对象软件(例如四人帮设计模式)。

本章讨论了如何在 C 程序中结构化包含对象样元素的模式。对于这些对象样元素,模式特别关注谁负责创建和销毁它们——换句话说,它们特别关注生命周期和所有权。这个主题对于 C 语言尤为重要,因为 C 语言没有自动析构函数和垃圾回收机制,因此需要特别注意资源的清理。

但是,“对象样元素”究竟是什么意思,对于 C 语言来说有什么含义呢?术语对象在面向对象编程语言中有明确定义,但对于非面向对象编程语言来说,并不清楚对象一词的含义。对于 C 语言,对象的简单定义如下:

“一个对象是一个命名的存储区域。”

Kernighan 和 Ritchie

通常这样的对象描述了具有标识和属性的相关数据集,用于存储现实世界中找到的事物的表示。在面向对象编程中,对象还具有多态性和继承能力。本书描述的对象样元素不涉及多态性或继承,因此我们将不再使用对象一词。相反,我们将简单地将这样的对象样元素视为数据结构的一个实例,并进一步称其为实例

这些实例通常不能独立存在,而通常伴随相关的代码片段,使得操作这些实例成为可能。此代码通常组合到一组头文件以供其接口使用,并且一组实现文件用于其实现。在本章中,与面向对象类似的所有这些相关代码的总和,通常定义了可以在实例上执行的操作,将被称为软件模块

在编写 C 语言程序时,数据的描述实例通常被实现为抽象数据类型(例如,通过具有访问struct成员函数的struct实例)。这样一个实例的例子是 C 标准库FILE struct,它存储诸如文件指针或文件中位置的信息。相应的软件模块是stdio.h API 及其实现的函数(如fopenfclose),它们提供对FILE实例的访问。

图 5-1 展示了本章讨论的模式及其关系的概述,而表 5-1 则提供了这些模式的总结。

模式图/lifetime-ownership.png

图 5-1. 生命周期和所有权模式概述

表 5-1. 生存期和所有权的模式

模式名称 摘要
无状态软件模块 你希望为你的调用者提供逻辑相关的功能,并尽可能地让调用者使用这些功能变得简单。因此,在你的实现中保持函数简单,并且不在实现中建立状态信息。将所有相关函数放入一个头文件中,并向调用者提供这个接口到你的软件模块。
具有全局状态的软件模块 你希望结构化你的逻辑相关代码,需要共享状态信息,并尽可能地简化调用者使用这些功能的方式。因此,拥有一个全局实例来让你的相关函数共享公共资源。将所有操作该实例的函数放入一个头文件中,并向调用者提供这个接口到你的软件模块。
调用者拥有的实例 你希望为多个调用者或线程提供访问功能,这些功能依赖于彼此,并且调用者与你的函数的交互会建立起状态信息。因此,要求调用者传递一个实例,该实例用于存储资源和状态信息,并将其传递给你的函数。提供显式函数来创建和销毁这些实例,以便调用者可以确定它们的生命周期。
共享实例 你希望为多个调用者或线程提供访问功能,这些功能依赖于彼此,并且调用者与你的函数的交互会建立起状态信息,你的调用者希望共享这些信息。因此,要求调用者传递一个实例,该实例用于存储资源和状态信息,并将其传递给你的函数。使用同一个实例来为多个调用者服务,并在你的软件模块中保持该实例的所有权。

作为一个运行的例子,在这一章中,你想要为你的以太网网络接口卡实现一个设备驱动程序。以太网网络接口卡安装在操作系统上,因此你可以使用 POSIX 套接字函数来发送和接收网络数据。你想要为用户构建一些抽象,因为你想要提供一个比套接字函数更简单的方式来发送和接收数据,并且因为你想要为你的以太网驱动程序添加一些额外的功能。因此,你想要实现一个封装所有套接字细节的东西。为了实现这一点,从一个简单的无状态软件模块开始。

无状态软件模块

背景

你想要为调用者提供相关功能的函数。这些函数不操作在函数之间共享的常规数据,并且它们不需要准备像必须在函数调用之前初始化的内存之类的资源。

问题

你希望为你的调用者提供逻辑相关的功能,并尽可能地让调用者使用这些功能变得简单。

你希望调用者可以简单地访问你的功能。调用者不应该处理提供的函数的初始化和清理方面,并且调用者不应该面对实现细节。

你不一定需要函数在保持向后兼容性的同时非常灵活,相反,这些函数应该提供一个易于使用的抽象,用于访问实现的功能。

组织头文件和实现文件有许多选项,如果你必须为每个实现的功能都这样做,那么这个过程将变得非常繁琐。

解决方案

保持函数简单,不要在实现中累积状态信息。将所有相关函数放入一个头文件中,并为调用者提供软件模块的接口。

函数之间不会进行内部或外部状态信息的通信或共享,并且状态信息不会在函数调用之间存储。这意味着函数计算结果或执行操作,不依赖于 API(头文件)中的其他函数调用或之前的函数调用。唯一的通信发生在调用者和被调用函数之间(例如,以返回值的形式)。

如果函数需要任何资源,比如堆内存,那么这些资源必须对调用者透明地处理。它们必须在使用之前被获取、隐式初始化,并在函数调用内释放。这使得可以完全独立地调用这些函数。

尽管函数是相关的,因此它们被放在一个 API 中。相关意味着这些函数通常由调用者一起使用(接口隔离原则),并且如果它们变化,它们是因为相同的原因变化(公共闭合原则)。这些原则在 Robert C. Martin 的书《Clean Architecture》(Prentice Hall,2018)中有所描述。

将相关函数的声明放入一个头文件中,并将函数的实现放入一个或多个实现文件,但放入同一个软件模块目录中。这些函数之间是相关的,因为它们在逻辑上属于同一组,但它们不共享公共状态,也不影响彼此的状态,因此不需要通过全局变量在函数之间共享信息,也不需要通过传递实例来封装这些信息。这就是为什么每个单独的函数实现可以放入单独的实现文件中。

下面的代码展示了一个简单的无状态软件模块的示例:

调用者的代码

int result = sum(10, 20);

API(头文件)

/* Returns the sum of the two parameters */
int sum(int summand1, int summand2);

实现

int sum(int summand1, int summand2)
{
  /* calculate result only depending on parameters and
 not requiring any state information */
  return summand1 + summand2;
}

调用方调用 sum 并检索函数结果的副本。如果您使用相同的输入参数两次调用该函数,函数会提供完全相同的结果,因为无状态软件模块中没有维护状态信息。在这种特殊情况下,也不会调用保存状态信息的其他函数。

图 5-2 展示了无状态软件模块的概述。

无状态软件模块生命周期

图 5-2. 无状态软件模块

影响

您拥有一个非常简单的接口,调用方无需处理软件模块的初始化或清理任何内容。调用方可以独立调用其中的一个函数,无需考虑先前的函数调用或程序的其他部分,例如同时访问软件模块的其他线程。没有状态信息使得理解函数的操作变得更加简单。

调用方无需关心所有权问题,因为没有任何东西可以拥有——这些函数没有状态。函数调用中所需的资源在函数调用内部分配和清理,对调用方来说是透明的。

但并非所有功能都能通过这样简单的接口提供。如果 API 中的函数共享状态信息或数据(例如一个函数必须为另一个函数分配所需的资源),那么必须采用不同的方法,例如带有全局状态的软件模块或调用方拥有的实例,以便共享此信息。

已知用途

这类相关函数被集成到一个 API 中,这种情况通常出现在 API 内的函数不需要共享信息或状态信息时。以下示例展示了这种模式的应用:

  • math.h 中提供的 sincos 函数都包含在同一个头文件中,并且仅根据函数输入计算它们的结果。它们不维护状态信息,每次使用相同的输入调用时都会产生相同的输出。

  • string.h 中的 strcpystrcat 函数不依赖于彼此。它们不共享信息,但它们彼此之间是相关的,因此它们是单个 API 的一部分。

  • Windows 头文件 VersionHelpers.h 提供了有关当前运行的 Microsoft Windows 版本的信息。例如 IsWindows7OrGreaterIsWindowsServer 函数提供相关信息,但这些函数仍然不共享信息,彼此独立。

  • Linux 头文件 parser.h 提供了诸如 match_intmatch_hex 的函数。这些函数尝试从子串中解析整数或十六进制值。这些函数相互独立,但它们仍然属于同一个 API。

  • NetHack 游戏的源代码也多次应用了这种模式。例如,vision.h头文件包含函数来计算玩家是否能看到游戏地图上的特定物品。函数couldsee(x,y)cansee(x,y)计算玩家是否能清楚看到该物品,并且玩家是否朝向该物品。这两个函数彼此独立,不共享状态信息。

  • 头文件模式展示了这种模式的变体,更侧重于 API 的灵活性。

  • 由 Markus Voelter 等人(Wiley,2007 年)的书籍Remoting Patterns中称为 Per-Request Instance 的模式解释了分布式对象中间件中服务器应为每次调用激活新的 servant,并且在 servant 处理请求后,返回结果并且停用 servant。此类对服务器的调用不维护状态信息,类似于无状态软件模块的调用,但区别在于无状态软件模块不涉及远程实体。

应用于运行示例

您的第一个设备驱动程序具有以下代码:

API(头文件)

void sendByte(char data, char* destination_ip);
char receiveByte();

实现

void sendByte(char data, char* destination_ip)
{
  /* open socket to destination_ip, send data via this socket and close
 the socket */
}

char receiveByte()
{
  /* open socket for receiving data, wait some time and return
 the received data */
}

您的以太网驱动程序的用户无需处理如何访问套接字等实现细节,可以简单地使用提供的 API。该 API 中的两个函数可以在任何时候独立调用,调用方可以获取函数提供的数据,而无需处理资源的所有权和释放。使用该 API 简单但也非常有限。

接下来,您希望为驱动程序提供额外的功能。您希望用户能够查看以太网通信是否正常工作,因此您希望提供显示发送或接收字节数的统计信息。使用简单的无状态软件模块,您无法实现这一点,因为您没有保留内存来存储从一个函数调用到另一个函数调用的状态信息。

要实现这一点,您需要一个具有全局状态的软件模块。

具有全局状态的软件模块

上下文

您希望为调用方提供相关功能的函数。这些函数操作之间共享的公共数据,并且它们可能需要准备资源(如必须在使用功能之前初始化的内存),但这些函数不需要任何依赖于调用方的状态信息。

问题

您希望将需要共享状态信息的逻辑相关代码进行结构化,并使调用方尽可能轻松地使用该功能。

您希望让调用方能够轻松访问您的功能。调用方不应处理函数的初始化和清理方面,并且不应直接面对实现细节。调用方不一定要意识到这些函数访问公共数据。

您不一定需要使函数在保持向后兼容性的同时对未来更改非常灵活——相反,函数应该提供易于使用的抽象,以访问实现的功能。

解决方案

有一个全局实例,让您的相关函数实现共享共同资源。将所有操作该实例的函数放入一个头文件中,并向调用者提供您的软件模块的此接口。

将函数声明放在一个头文件中,并将所有软件模块的实现放入一个实现文件中的软件模块目录中。在此实现文件中,有一个全局实例(文件全局静态struct或几个文件全局静态变量——参见永恒内存),用于保存应为函数实现提供的公共共享资源。然后,您的函数实现可以访问这些共享资源,类似于面向对象编程语言中的私有变量的工作方式。

软件模块的初始化和资源的生命周期由软件模块透明地管理,并且独立于其调用者的生命周期。如果需要初始化资源,那么可以在启动时初始化它们,或者可以使用延迟获取,在需要资源之前初始化资源。

调用者从函数调用语法中并不意识到这些函数操作于共享资源上,因此您应该为调用者记录这一点。在您的软件模块内部,对这些文件全局资源的访问可能需要通过同步原语(如互斥体)来保护,以便能够从不同线程的多个调用者进行调用。在您的函数实现中进行这种同步,这样调用者就不必处理同步方面的问题。

以下代码显示了一个简单的具有全局状态的软件模块示例:

调用者的代码

int result;
result = addNext(10);
result = addNext(20);

API(头文件)

/* Adds the parameter 'value' to the values accumulated
 with previous calls of this function. */
int addNext(int value);

实现

static int sum = 0;

int addNext(int value)
{
  /* calculation of the result depending on the parameter
 and on state information from previous function calls */
  sum = sum + value;
  return sum;
}

调用者调用addNext并获取结果的副本。当使用相同的输入参数两次调用函数时,由于函数维护状态信息,可能会产生不同的结果。

图 5-3 显示了具有全局状态的软件模块的概述。

草图/alt=具有全局状态生命周期的软件模块

图 5-3. 具有全局状态的软件模块

后果

现在,您的函数可以共享信息或资源,即使调用者无需传递包含此共享信息的参数,并且调用者无需负责分配和清理资源。为了在您的软件模块中实现此信息共享,您实现了 C 语言版本的单例模式。请注意单例模式——许多人评论了此模式的缺点,并且通常被称为反模式。

在 C 语言中,这样的具有全局状态的软件模块非常普遍,因为在变量前加上关键字 static 很容易,一旦这样做了,就得到了单例模式。在某些情况下这是可以接受的。如果您的实现文件很短,具有文件全局变量与面向对象编程中的私有变量相当相似。如果您的函数不需要状态信息或不在多线程环境中操作,则可能完全没问题。但是,如果多线程和状态信息变成问题,并且您的实现文件变得越来越长,那么您就有麻烦了,具有全局状态的软件模块就不再是一个好的解决方案了。

如果您的具有全局状态的软件模块需要初始化,则必须在系统启动时或在资源的第一次使用之前使用延迟获取来初始化。但是,这样做的缺点是函数调用的持续时间会有所变化,因为在第一次调用时会隐式调用额外的初始化代码。无论如何,资源获取对调用者来说是透明的。资源由您的软件模块拥有,因此调用者不需要负担资源的所有权,也不需要显式获取或释放资源。

然而,并非所有功能都能通过这样简单的接口提供。如果 API 内的函数共享调用者特定的状态信息,则必须采取不同的方法,例如调用者拥有的实例。

已知应用

以下示例展示了此模式的应用:

  • string.h 函数 strtok 将字符串分割成标记。每次调用该函数时,都会提供字符串的下一个标记。为了保存关于下一个要提供的标记的状态信息,该函数使用静态变量。

  • 使用可信平台模块(TPM),可以累积已加载软件的哈希值。TPM-Emulator v0.7 代码中相应的函数使用静态变量存储这些累积的哈希值。

  • math 库使用状态来生成随机数。每次调用 rand 都会基于前一次 rand 调用计算新的伪随机数。首先必须调用 srand 来设置种子(伪随机数生成器的初始静态信息),然后再调用 rand

  • 不可变实例可以看作是具有全局状态的软件模块的一部分,特殊情况是实例在运行时不被修改。

  • NetHack 游戏的源代码在编译时定义了一个静态列表,存储关于物品(剑、盾)的信息,并提供访问此共享信息的函数。

  • 《远程模式》一书(Markus Voelter 等人著,Wiley,2007 年)中称为静态实例的模式建议,提供具有与调用者生命周期解耦的远程对象。例如,可以在启动时初始化远程对象,然后在请求时提供给调用者。具有全局状态的软件模块提出了相同的静态数据概念,但不适合为不同的调用者提供多个实例。

应用于运行示例

现在你有以下代码用于你的以太网驱动程序:

API(头文件)

void sendByte(char data, char* destination_ip);
char receiveByte();
int getNumberOfSentBytes();
int getNumberOfReceivedBytes();

实现

static int number_of_sent_bytes = 0;
static int number_of_received_bytes = 0;

void sendByte(char data, char* destination_ip)
{
  number_of_sent_bytes++;
  /* socket stuff */
}

char receiveByte()
{
  number_of_received_bytes++;
  /* socket stuff */
}

int getNumberOfSentBytes()
{
  return number_of_sent_bytes;
}

int getNumberOfReceivedBytes()
{
  return number_of_received_bytes;
}

API 看起来与无状态软件模块的 API 非常相似,但在这个 API 背后现在存在着保持信息的功能,这些信息是在函数调用之间需要的,用于发送和接收字节的计数器。只要只有一个用户(一个线程)使用这个 API,一切都很好。然而,如果有多个线程,那么使用静态变量总是会遇到竞态条件的问题,如果不为对静态变量的访问实现互斥,则可能会出现问题。

现在你想要以更高效的方式来实现以太网驱动程序,并且你希望发送更多的数据。你可以简单地频繁调用你的sendByte函数来实现,但在你的以太网驱动实现中,这意味着每次调用sendByte时都会建立套接字连接,发送数据,然后再关闭套接字连接。建立和关闭套接字连接占据了大部分通信时间。

这样做效率低下,你更希望只打开一次套接字连接,然后通过多次调用你的sendByte函数来发送所有数据,然后再关闭套接字连接。但现在你的sendByte函数需要准备和拆除阶段。这种状态不能存储在具有全局状态的软件模块中,因为一旦你有多个调用者(即多个线程),你可能会遇到多个调用者同时想要发送数据的问题,甚至可能是发送到不同的目的地。

为了实现这一点,为每个调用者提供一个调用者拥有的实例。

调用者拥有的实例

上下文

你希望为调用者提供具有相关功能的函数。这些函数在它们之间共享的公共数据上运行,它们可能需要资源的准备,比如必须在使用你的功能之前初始化的内存,并且它们在彼此之间共享调用者特定的状态信息。

问题

你希望提供多个调用者或线程访问依赖于彼此的功能,调用者与你的函数的交互构建起状态信息。

也许一个函数必须在另一个函数之前调用,因为它会影响存储在软件模块中的状态,而后者需要另一个函数。这可以通过具有全局状态的软件模块来实现,但仅在只有一个调用方时才有效。在具有多个调用方的多线程环境中,无法有一个集中的软件模块来持有所有调用方相关的状态信息。

尽管如此,你仍然希望隐藏调用方的实现细节,并且希望让调用方尽可能简单地访问你的功能。必须明确定义调用方是否负责分配和清理资源。

解决方案

要求调用方传递一个实例,用于存储资源和状态信息,并传递给你的函数。提供显式函数来创建和销毁这些实例,以便调用方可以确定它们的生命周期。

要实现一个可以从多个函数访问的实例,可以在所有需要共享资源或状态信息的函数中传递一个 struct 指针。现在函数可以使用 struct 成员,类似于面向对象语言中的私有变量,来存储和读取资源和状态信息。

可以在 API 中声明 struct,以便调用方可以方便地直接访问其成员。或者,可以在实现中声明 struct,并且只能在 API 中声明 struct 的指针(如 Handle 建议的方式)。调用方不知道 struct 的成员(它们就像是私有变量),只能通过函数对 struct 进行操作。

因为实例必须由多个函数操作,而你无法知道调用方何时完成函数调用,所以实例的生命周期必须由调用方确定。因此,将所有权委托给调用方,并提供用于创建和销毁实例的显式函数。调用方与实例之间存在聚合关系。

聚合与关联

如果一个实例在语义上与另一个实例相关联,则这些实例是关联的。更强的关联类型是聚合,其中一个实例拥有另一个实例。

以下代码显示了一个简单调用方拥有的实例的示例:

Caller’s code

struct INSTANCE* inst;
inst = createInstance();
operateOnInstance(inst);
/* access inst->x or inst->y */
destroyInstance(inst);

API(头文件)

struct INSTANCE
{
  int x;
  int y;
};

/* Creates an instance which is required for working
 with the function 'operateOnInstance' */
struct INSTANCE* createInstance();

/* Operates on the data stored in the instance */
void operateOnInstance(struct INSTANCE* inst);

/* Cleans up an instance created with 'createInstance' */
void destroyInstance(struct INSTANCE* inst);

实现

struct INSTANCE* createInstance()
{
  struct INSTANCE* inst;
  inst = malloc(sizeof(struct INSTANCE));
  return inst;
}

void operateOnInstance(struct INSTANCE* inst)
{
  /* work with inst->x and inst->y */
}

void destroyInstance(struct INSTANCE* inst)
{
  free(inst);
}

函数 operateOnInstance 处理由前一个函数调用 createInstance 创建的资源。这两个函数调用之间的资源或状态信息由调用方传输,调用方必须为每个函数调用提供 INSTANCE,并且还必须通过调用 destroy​In⁠stance 清理所有资源。

图 5-4 显示了调用方拥有的实例的概述。

调用方拥有的实例生命周期

图 5-4. 调用方拥有的实例

后果

现在你的 API 函数更加强大,因为它们可以共享状态信息并操作共享数据,同时仍然可用于多个调用者(即多个线程)。每个创建的调用者拥有实例都有其自己的私有变量,即使在多线程环境中由多个调用者创建了多个这样的调用者拥有实例,也不会出现问题。

然而,要实现这一点,你的 API 变得更加复杂。你必须显式调用create()destroy()来管理实例的生命周期,因为 C 语言不支持构造函数和析构函数。这使得处理实例变得更加困难,因为调用者获取所有权并负责清理实例。由于必须手动使用destroy()调用来完成此操作,而不是像面向对象编程语言中那样通过自动析构函数,这很容易导致内存泄漏的常见陷阱。对象基础错误处理解决了这个问题,建议调用者还应该有一个专门的清理函数来使这个任务更加明确。

此外,与无状态软件模块相比,调用每个函数变得稍微复杂一些。每个函数都需要一个引用实例的附加参数,并且函数不能以任意顺序调用——调用者必须知道首先调用哪个函数。这通过函数签名明确表示出来。

已知应用

下面的例子展示了此模式的应用:

  • 调用者拥有实例的一个示例是glibc库提供的双向链表。调用者使用g_list_alloc创建列表,然后可以使用g_list_insert向此列表插入项目。完成列表操作后,调用者需使用g_list_free清理列表。

  • 这种模式由 Robert Strandh 在文章《Modular C》中描述。它描述了如何编写模块化的 C 程序。文章强调了在应用程序中识别抽象数据类型的重要性,这些类型可以通过函数进行操作或访问。

  • Windows API 在菜单栏中创建菜单有一个创建菜单实例的函数 (CreateMenu),操作菜单的函数(如 InsertMenu​Item),以及销毁菜单实例的函数 (DestroyMenu)。所有这些函数都有一个参数传递菜单实例的句柄。

  • Apache 的处理 HTTP 请求的软件模块提供了创建所有必需的请求信息 (ap_sub_req_lookup_uri)、处理它 (ap_run_sub_req) 和销毁它 (ap_destroy_sub_req) 的函数。这些函数接受一个指向请求实例的 struct 指针,以便共享请求信息。

  • NetHack 游戏的源代码使用struct实例来表示怪物,并提供了用于创建和销毁怪物的函数。NetHack 代码还提供了从怪物获取信息的函数(is_starting_petis_vampshifter)。

  • 从《Remoting Patterns》(马库斯·沃尔特等人,Wiley,2007)的模式中称为客户端相关实例,建议为分布式对象中间件提供由客户端控制生命周期的远程对象。服务器为客户端创建新实例,客户端可以使用这些实例、传递它们或销毁它们。

应用于运行示例

现在您的以太网驱动程序有以下代码:

API(头文件)

  struct Sender
  {
    char destination_ip[16];
    int socket;
  };

  struct Sender* createSender(char* destination_ip);
  void sendByte(struct Sender* s, char data);
  void destroySender(struct Sender* s);

实施

struct Sender* createSender(char* destination_ip)
{
  struct Sender* s = malloc(sizeof(struct Sender));
  /* create socket to destination_ip and store it in Sender s*/
  return s;
}

void sendByte(struct Sender* s, char data)
{
  number_of_sent_bytes++;
  /* send data via socket stored in Sender s */
}

void destroySender(struct Sender* s)
{
  /* close socket stored in Sender s */
  free(s);
}

调用者可以首先创建一个发送方,然后发送所有数据,最后销毁发送方。因此,调用者可以确保每次sendByte()调用时不必重新建立套接字连接。调用者拥有所创建的发送方的所有权,完全控制发送方的生存期,并负责清理它:

Caller’s code

struct Sender* s = createSender("192.168.0.1");
char* dataToSend = "Hello World!";
char* pointer = dataToSend;
while(*pointer != '\0')
{
  sendByte(s, *pointer);
  pointer++;
}
destroySender(s);

接下来,假设您不是此 API 的唯一用户。可能有多个线程使用您的 API。只要一个线程为发送到 IP 地址 X 创建发送方,另一个线程为发送到 Y 创建发送方,我们就没问题了,以太网驱动程序为这两个线程创建独立的套接字。

但是,假设两个线程希望向同一接收方发送数据。现在以太网驱动程序出现了问题,因为在特定端口上,每个目标 IP 只能打开一个套接字。解决此问题的方法是不允许两个不同的线程向同一目标发送数据 —— 第二个线程创建发送方可能会收到错误。但也可以允许两个线程使用同一个发送方发送数据。

为了实现这一点,只需构建一个共享实例。

共享实例

上下文

您希望为调用者提供与调用者相关功能的函数。这些函数操作共享的公共数据,并且可能需要准备资源(如必须在使用功能之前初始化的内存)。功能可以在多个调用者之间共享的多个上下文中调用。

问题

您希望为多个调用者或线程提供访问功能的函数,这些函数相互依赖,并且调用者与您的函数的交互会建立起调用者希望共享的状态信息。

在软件模块中存储状态信息的全局状态不是一个选项,因为有多个调用者想要建立不同的状态信息。在调用者拥有的实例中存储状态信息也不是一个选项,因为要么您的某些调用者想要访问和操作同一实例,要么您不想为每个调用者创建新实例以保持资源成本低。

但是,您希望隐藏来自调用者的实现细节,并希望使调用者尽可能简单地访问您的功能。必须明确定义调用者是否负责分配和清理资源。

解决方案

要求调用者传递一个实例,用于存储资源和状态信息,并将该实例的所有权保留在您的软件模块中。

就像具有调用者所有权的实例一样,现在提供一个struct指针或句柄,然后调用者可以将其传递给函数调用。创建实例时,调用者现在还必须提供标识符(例如,唯一名称)以指定要创建的实例类型。通过此标识符,您可以知道是否已存在这样的实例。如果存在,则不创建新实例,而是返回已创建并返回给其他调用者的实例的struct指针或句柄。

要知道一个实例是否已经存在,您必须在软件模块中维护一个已创建实例的列表。这可以通过实现具有全局状态的软件模块来实现。除了是否已创建实例之外,还可以存储当前访问哪些实例的信息,或者至少可以存储当前访问实例的调用者数量。这些额外的信息是必需的,因为当每个人都完成对实例的访问时,您有责任清理它,因为您是其专用所有者。

您还必须检查您的函数是否可以同时由不同调用者在同一实例上调用。在一些更简单的情况下,可能没有需要通过不同调用者互斥访问的数据,因为它只是读取。在这种情况下,可以实现一个不可变实例,它不允许调用者更改实例。但在其他情况下,您必须为通过实例共享的资源在函数中实现互斥排除。

以下代码显示了一个简单共享实例的示例:

Caller1 的代码

struct INSTANCE* inst = openInstance(INSTANCE_TYPE_B);
/* operate on the same instance as caller2 */
operateOnInstance(inst);
closeInstance(inst);

Caller2 的代码

struct INSTANCE* inst = openInstance(INSTANCE_TYPE_B);
/* operate on the same instance as caller1 */
operateOnInstance(inst);
closeInstance(inst);

API(头文件)

struct INSTANCE
{
  int x;
  int y;
};

/* to be used as IDs for the function openInstance */
#define INSTANCE_TYPE_A 1
#define INSTANCE_TYPE_B 2
#define INSTANCE_TYPE_C 3

/* Retrieve an instance identified by the parameter 'id'. That instance is
 created if no instance of that 'id' was yet retrieved from any
 other caller. */
struct INSTANCE* openInstance(int id);

/* Operates on the data stored in the instance. */
void operateOnInstance(struct INSTANCE* inst);

/* Releases an instance which was retrieved with 'openInstance'.
 If all callers release an instance, it gets destroyed. */
void closeInstance(struct INSTANCE* inst);

实现

#define MAX_INSTANCES 4

struct INSTANCELIST
{
  struct INSTANCE* inst;
  int count;
};

static struct INSTANCELIST list[MAX_INSTANCES];

struct INSTANCE* openInstance(int id)
{
  if(list[id].count == 0)
  {
    list[id].inst =  malloc(sizeof(struct INSTANCE));
  }
  list[id].count++;
  return list[id].inst;
}

void operateOnInstance(struct INSTANCE* inst)
{
  /* work with inst->x and inst->y */
}

static int getInstanceId(struct INSTANCE* inst)
{
  int i;
  for(i=0; i<MAX_INSTANCES; i++)
  {
    if(inst == list[i].inst)
    {
      break;
    }
  }
  return i;
}

void closeInstance(struct INSTANCE* inst)
{
  int id = getInstanceId(inst);
  list[id].count--;
  if(list[id].count == 0)
  {
    free(inst);
  }
}

调用者通过调用openInstance来检索INSTANCE。此函数调用可能会创建INSTANCE,也可能已经通过先前的函数调用创建,并且可能也被另一个调用者使用。然后,调用者可以将INSTANCE传递给operateOnInstance函数调用,以提供该函数所需的资源或状态信息。完成后,调用者必须调用closeInstance,以便在没有其他调用者操作INSTANCE时清理资源。

图 5-5 显示了共享实例的概述。

共享实例的生命周期

图 5-5. 共享实例

后果

多个调用者现在可以同时访问单个实例。这往往意味着您必须在实现中处理互斥,以免为用户带来此类问题。这意味着函数调用的持续时间会有所变化,因为调用者永远不知道另一个调用者当前是否正在使用相同的资源并阻塞它们。

您的软件模块,而不是调用者,拥有实例的所有权,并且您的软件模块负责清理资源。调用者仍然负责释放资源,以便您的软件模块知道何时清理一切——就像拥有者拥有的实例一样,这是内存泄漏的一个陷阱。

因为软件模块拥有这些实例的所有权,它可以在无需调用者启动清理的情况下清理这些实例。例如,如果软件模块从操作系统接收到关闭信号,它可以清理所有实例,因为它拥有它们的所有权。

已知的用途

以下示例展示了此模式的应用:

  • 使用共享实例的一个示例是stdio.h文件函数。文件可以通过函数fopen被多个调用者打开。调用者获取文件的句柄并可以从中读取或写入(freadfprintf)。文件是一个共享资源。例如,所有调用者都共享文件中的一个全局光标位置。当调用者完成对文件的操作时,必须使用fclose关闭它。

  • 此模式及其面向对象编程语言的实现细节在 Kevlin Henney 的文章"C++ Patterns: Reference Accounting"中被称为计数句柄。它描述了如何访问堆上的共享对象以及如何透明地处理其生命周期。

  • Windows 注册表可以通过函数RegCreateKey(如果已存在则打开键)同时由多个线程访问。该函数提供一个句柄,其他函数可以使用它来操作注册表键。完成注册表操作后,必须由所有打开该键的人调用RegCloseKey函数关闭它。

  • Windows 功能CreateMutex用于从多个线程访问共享资源(Mutex)。使用 Mutex,可以实现进程间同步。完成 Mutex 操作后,每个调用者必须使用CloseHandle函数关闭它。

  • B&R Automation Runtime 操作系统允许多个调用者同时访问设备驱动程序。调用者使用函数DmDeviceOpen来选择可用设备之一。设备驱动程序框架检查所选驱动程序是否可用,然后为调用者提供一个句柄。如果多个调用者操作同一驱动程序,则它们共享句柄。然后,调用者可以同时与驱动程序交互(发送或读取数据,通过 IO 控制进行交互等),并在此交互后通过调用DmDeviceClose告知设备驱动程序框架已完成操作。

应用于运行示例

现在,驱动程序还额外实现了以下功能:

API(头文件)

struct Sender* openSender(char* destination_ip);
void sendByte(struct Sender* s, char data);
void closeSender(struct Sender* s);

实现

struct Sender* openSender(char* destination_ip)
{
  struct Sender* s;
  if(isInSenderList(destination_ip))
  {
    s = getSenderFromList(destination_ip);
  }
  else
  {
    s = createSender(destination_ip);
  }
  increaseNumberOfCallers(s);
  return s;
}

void sendByte(struct Sender* s, char data)
{
  number_of_sent_bytes++;
  /* send data via socket stored in Sender s */
}

void closeSender(struct Sender* s)
{
  decreaseNumberOfCallers(s);
  if(numberOfCallers(s) == 0)
  {
    /* close socket stored in Sender s */
    free(s);
  }
}

运行示例的 API 并没有发生太大变化——现在驱动程序提供打开/关闭函数而不是创建/销毁函数。通过调用这样的函数,调用者检索发送者的句柄,并指示驱动程序此时操作发送者,但驱动程序不一定在那时创建此发送者。可能早些时候由驱动程序的其他调用(可能由不同的线程执行)执行过这个操作。同样,关闭调用实际上可能不会销毁发送者。该发送者的所有权仍在驱动程序实现中,它可以决定何时销毁发送者(例如,当所有调用者关闭发送者时,或者接收到某些终止信号时)。

现在有一个共享实例而不是一个调用者拥有的实例,这对调用者来说基本上是透明的。但是驱动程序的实现发生了变化——它必须记住是否已经创建了特定的发送者,并提供此共享实例,而不是创建一个新的。在打开发送者时,调用者不知道这个发送者是新创建的还是检索到的现有发送者。根据这一点,函数调用的持续时间可能会有所不同。

在所示的运行驱动程序示例中展示了单个示例中不同类型的所有权和数据生命周期。我们看到一个简单的以太网驱动程序通过添加功能而发展。首先,一个无状态软件模块就足够了,因为驱动程序不需要任何状态信息。接下来,需要这样的状态信息,并且通过在驱动程序中具有全局状态的软件模块来实现。然后,需要更高效的发送功能以及多个调用者为这些发送功能服务,并首先通过调用者拥有的实例实现,然后通过共享实例实现。

摘要

本章中的模式展示了不同的结构化 C 程序的方式以及程序中不同实例的生命周期。Table 5-2 提供了模式的概述并比较了它们的后果。

表 5-2. 比较生命周期和所有权的模式

无状态软件模块 具有全局状态的软件模块 调用者拥有的实例 共享实例
函数间资源共享 不可能 单一资源集合 每个实例的资源集合(即每个调用者) 多个调用者共享的每个实例的资源集合
资源所有权 无需拥有 软件模块拥有静态数据 调用者拥有实例 软件模块拥有实例并提供引用
资源生命周期 没有资源比函数调用更长 静态数据永远存在于软件模块中 实例存在直到调用者销毁它们 实例存在直到软件模块销毁它们
资源初始化 无需初始化 在编译时或启动时 创建实例时由调用者 第一个调用者打开实例时由软件模块

有了这些模式,C 程序员就能对将程序组织成软件模块的设计选项和在构建实例时关于所有权和生命周期的设计选项有一些基本指导。

进一步阅读

本章中的模式涵盖了如何提供对实例的访问以及谁拥有这些实例的问题。Markus Voelter 等人的书籍 Remoting Patterns(Wiley, 2007)中的一些模式子集也涵盖了类似的主题。该书介绍了构建分布式对象中间件的模式,其中三种模式专注于远程服务器创建的对象的生命周期和所有权。与此相比,本章介绍的模式关注的是不同的上下文。它们不是远程系统的模式,而是用于本地过程化程序的模式。它们专注于 C 编程,但也适用于其他过程化编程语言。尽管如此,这些模式的一些基本思想与 Remoting Patterns 中的思想非常相似。

展望

本章介绍了软件模块的不同接口类型,特别关注如何使接口灵活。这些模式详细阐述了简单性和灵活性之间的权衡。

第六章:灵活 API

在编写软件时,设计具有适当灵活性和适当抽象级别的接口是最重要的事情之一,因为接口代表了一个合同,一旦系统开始运行往往就无法更改。因此,将稳定的声明放入接口中并抽象实现细节非常重要,这些细节在以后可能需要更改的情况下应具有灵活性。

对于面向对象的编程语言,你会发现有很多关于如何设计接口的指导(例如,设计模式形式上的)。但对于像 C 这样的过程化编程语言,关于如何设计接口的详细指导却很难找到。在这里,本章的模式就有了用武之地。

图 6-1 展示了本章涵盖的四种模式及相关模式,表 6-1 包含了这四种模式的简要描述。请注意,并非所有模式在所有可能的情境下都应该应用。通常建议设计一个系统,使其不比必要复杂。这意味着只有在你的 API 已经需要或将来可能需要的情况下,才应该应用一些提出的模式。如果不太可能需要,那么为了尽可能保持 API 的简单性,可能就不应该应用这些模式。

灵活 API 模式概述

图 6-1. 灵活 API 模式概述

表 6-1. 灵活 API 的模式

模式名称 摘要
头文件 你希望你实现的功能可以被其他实现文件中的代码访问,但你希望隐藏实现细节不让调用者看到。因此,在你的 API 中为任何你想要提供给用户的功能提供函数声明。将任何内部函数、内部数据和函数定义(实现)隐藏在你的实现文件中,不将这些实现文件提供给用户。
处理 在你的函数实现中需要共享状态信息或操作共享资源,但你不希望调用者看到或甚至访问所有这些状态信息和共享资源。因此,设计一个函数来创建调用者操作的上下文,并返回该上下文的内部数据的抽象指针。要求调用者将该指针传递给所有你的函数,这样函数可以使用内部数据来存储状态信息和资源。
动态接口 应该可以调用具有稍有不同行为的实现,但不应该需要复制任何代码,甚至不包括控制逻辑实现和接口声明。因此,在你的 API 中为这些不同的功能定义一个通用接口,并要求调用者提供一个回调函数来处理这些功能,然后在你的函数实现中调用这个回调函数。
函数控制 你想调用具有稍有不同行为的实现,但不想复制任何代码,甚至不包括控制逻辑实现或接口声明。因此,在你的函数中添加一个参数,传递有关函数调用的元信息,并指定要执行的实际功能。

作为一个运行的例子,在本章中,你想为你的以太网网络接口卡实现一个设备驱动程序。该卡的固件提供了几个寄存器,你可以用它们发送或接收数据,并且你可以配置该卡。你想建立一些关于这些硬件细节的抽象,并且你想确保 API 的用户不会受到影响,即使你更改了实现的某些部分。为了实现这一点,你构建了一个由头文件组成的 API。

头文件

背景

你在 C 语言中编写了一个较大的软件程序。你将这个软件程序拆分为多个函数,并在多个文件中实现这些函数,因为你希望使你的程序模块化且易于维护。

问题

你希望实现的功能可以被其他实现文件中的代码访问,但你希望隐藏来自调用者的实现细节。

与许多面向对象的语言不同,C 语言不提供任何内置支持来定义 API、抽象功能或强制调用者只能访问这种抽象。C 语言只提供了一种机制,即将文件包含到其他文件中。

调用你的代码的人可以使用这种机制简单地包含你的实现文件。但是调用者可以访问该文件中的所有内部数据,例如你仅打算在内部使用的具有文件范围的变量或函数。一旦调用者使用这些内部功能,稍后更改它可能就不容易了,因此在你可能不希望发生的地方,代码变得紧密耦合。如果调用者包含了实现文件,内部变量和函数的名称可能会与调用者使用的名称冲突。

解决方案

为你的 API 提供功能声明,以便向用户提供任何想要提供的功能。隐藏任何内部函数、内部数据和函数定义(实现)在你的实现文件中,并且不要将此实现文件提供给用户。

在 C 语言中,通常约定任何使用你软件函数的人仅使用头文件中定义的函数(**.h* 文件),而不使用实现中的其他函数(你的 *.c 文件)。在某些情况下,可以部分强制执行此抽象(例如,无法使用来自另一个文件的 static 函数),但 C 语言不完全支持这种强制执行。因此,不访问其他实现文件的约定比强制执行机制更为重要。

在头文件中,确保包含所有函数所需的相关构件。调用者使用头文件功能时不需要包含其他头文件。如果有常用声明(如数据类型或 #define),这些声明需放入单独的头文件中,并在需要的其他头文件中包含它。为确保头文件在编译单元中不被多次包含,使用包含保护。

仅在相关时将函数放入同一头文件中。如果函数操作相同句柄或在相同领域中执行操作(如数学计算),则应将它们放入同一头文件中。总体而言,如果能想到相关的使用情况需要所有函数,则应将它们放入同一头文件中。

在头文件中清晰地记录 API 的行为。用户不需要查看实现即可理解 API 提供的函数如何工作。

下面的代码展示了头文件的示例:

API(h 文件)

/* Sorts the numbers of the 'array' in ascending order.
 'length' defines the number of elements in the 'array'. */
void sort(int* array, int length);

实现(c 文件)

void sort(int* array, int length)
{
  /* here goes the implementation*/
}

影响

对于调用者相关的内容(.h* 文件),与调用者无需关心的实现细节(.c* 文件)之间有非常明确的分离。因此,你为调用者抽象了一些功能。

拥有多个头文件将影响构建时间。一方面,这使得你可以将实现分割到单独的文件中,且工具链可以进行增量构建,仅重新构建已更改的文件。另一方面,与将所有代码放入一个文件相比,完全重建将稍微增加构建时间,因为需要打开和读取所有文件。

如果发现你的函数需要更多相互交互或者需要在不同上下文中调用需要不同内部状态信息的情况,那么需要考虑如何通过 API 实现。句柄在这种情况下可以提供帮助。

您的函数的调用者现在依赖于抽象层,并可能依赖于这些函数的行为不会改变的事实。API 可能必须保持稳定。要添加新功能,您可以始终向 API 添加新函数。但在某些情况下,您可能希望扩展现有函数,为了能够应对这样的未来变化,您必须考虑如何使函数灵活,同时保持它们的稳定性。句柄、动态接口或功能控制可以在这种情况下有所帮助。

已知用途

以下示例展示了此模式的应用:

  • 几乎所有比简单的“Hello World”程序更大的 C 程序都包含头文件。

  • 在 C 中使用头文件类似于在 Java 中使用接口或在 C++ 中使用抽象类。

  • Pimpl 惯用法描述了如何隐藏私有实现细节并将它们不放入头文件中。您可以在 Portland Pattern Repository 中找到该惯用法的描述。

应用于运行示例

您的第一个设备驱动程序 API 如下所示:

void sendByte(char byte);
char receiveByte();
void setIpAddress(char* ip);
void setMacAddress(char* mac);

您的 API 的用户不必应对诸如如何访问以太网寄存器之类的实现细节,您可以自由更改这些细节而不影响用户。

现在您对驱动程序的需求发生了变化。您的系统有第二个相同的以太网网络接口卡,并且应该可以操作这两个接口。以下是两种实现此目标的直接选项:

  • 您复制您的代码,并为每个网络接口卡编写一段代码。在复制的代码中,您只修改要访问的确切接口的地址。然而,这种代码复制从来不是一个好主意,并且使您的代码维护困难得多。

  • 您向每个函数添加一个参数来解析网络接口卡(例如,设备名称字符串)。但很可能不止一个参数需要在函数之间共享,而将每个参数传递给每个函数使得您的 API 使用起来很麻烦。

支持多个以太网网络接口卡的更好想法是向您的 API 引入句柄。

句柄

上下文

您希望向您的调用者提供一组函数,并且这些函数操作共享资源或它们共享状态信息。

问题

在您的函数实现中,您必须共享状态信息或操作共享资源,但您不希望您的调用者看到或甚至访问所有这些状态信息和共享资源。

那些状态信息和共享资源应该对您的调用者保持不可见,因为以后您可能想要更改它或添加到它,而无需更改调用者的代码。

在面向对象的编程语言中,函数可以操作的数据是通过类成员变量来实现的。如果不希望调用者能够访问这些数据,则可以将这些类成员变量设为私有。然而,C 并不本地支持类和私有成员变量。

在你的实现文件中仅具有保持静态全局变量的全局状态的软件模块对你来说不是一个选项,因为应该能够在多个上下文中调用你的函数。每个调用者的函数调用应该能够建立它们的状态信息。尽管这些信息对你的调用者应该保持不可见,但你需要一种方法来识别哪些信息属于哪个特定的调用者,以及如何在你的函数实现中访问这些信息。

解决方案

有一个函数用于创建调用者操作的上下文,并返回指向该上下文内部数据的抽象指针。要求调用者将该指针传递给所有你的函数,然后这些函数可以使用内部数据来存储状态信息和资源。

你的函数知道如何解释这个抽象指针,这是一种不透明数据类型,也称为处理。然而,你指向的数据结构不应成为应用程序接口的一部分。应用程序接口仅提供将隐藏数据传递到函数的功能。

处理可以实现为指向聚合实例的指针,如一个struct。该struct应包含所有必需的状态信息或其他变量——通常它保存与面向对象编程中对象的成员变量类似的变量。该struct应在你的实现中隐藏起来。应用程序接口仅包含指向struct的指针的定义,如下面的代码所示:

应用程序接口

typedef struct SORT_STRUCT* SORT_HANDLE;

SORT_HANDLE prepareSort(int* array, int length);
void sort(SORT_HANDLE context);

实施

struct SORT_STRUCT
{
  int* array;
  int length;
  /* other parameters like sort order */
};

SORT_HANDLE prepareSort(int* array, int length)
{
  struct SORT_STRUCT* context = malloc(sizeof(struct SORT_STRUCT));
  context->array = array;
  context->length = length;

  /* fill context with required data or state information */

  return context;
}

void sort(SORT_HANDLE context)
{
  /* operate on context data */
}

在你的应用程序接口中有一个函数用于创建一个处理。该函数将处理返回给调用者。然后调用者可以调用你的应用程序接口中需要处理的其他函数。在大多数情况下,你还需要一个函数来删除处理,清理所有分配的资源。

结果

现在可以在你的函数之间共享状态信息和资源,而无需让调用者担心它,也不会让调用者有机会让代码依赖于这些内部。

支持多个数据实例。你可以多次调用创建处理的函数以获取多个上下文,然后你可以独立地使用这些上下文进行工作。

如果稍后更改对处理操作的函数,并且必须共享不同或额外的数据,那么可以简单地更改struct的成员,而无需更改调用者的代码。

你的函数声明明确显示它们紧密耦合,因为它们都需要处理。这一方面可以很容易地看出哪些函数应该放在同一个头文件中,另一方面,也让调用者非常容易地发现哪些函数应该一起应用。

现在,通过 Handle,你需要调用方为所有函数调用提供一个额外的参数,而每个额外的参数会使代码变得更难阅读。

已知的用途

以下示例展示了该模式的应用:

  • C 标准库在 stdio.h 中包含了 FILE 的定义。这个 FILE 在大多数实现中被定义为指向一个 struct 的指针,而这个 struct 不是头文件的一部分。FILE 句柄由函数 fopen 创建,并且打开的文件可以调用多个其他函数(fwritefread 等)。

  • OpenSSL 代码中的 struct AES_KEY 用于在几个与 AES 加密相关的函数之间交换上下文(AES_set_decrypt_keyAES_set_encrypt_key)。这个 struct 及其成员并没有在实现中隐藏,而是作为头文件的一部分,因为 OpenSSL 的其他部分需要知道这个 struct 的大小。

  • Subversion 项目的日志功能代码操作的是一个 Handle。logger_t 结构在日志功能的实现文件中定义,而该结构的指针则在相应的头文件中定义。

  • 这种模式在 David R. Hanson 的《C 接口与实现》(Addison-Wesley, 1996)中被描述为不透明指针类型,在 Adam Tornhill 的《C 中的模式》(Leanpub, 2014)中被描述为“第一类抽象数据类型模式”。

应用于正在运行的示例

现在,你可以支持任意数量的以太网接口卡。每个创建的驱动程序实例都会产生自己的数据上下文,然后通过 Handle 传递给函数。现在,你的设备驱动程序 API 如下所示:

/* the INTERNAL_DRIVER_STRUCT contains data shared by the functions (like
 how to select the interface card the driver is responsible for) */
typedef struct INTERNAL_DRIVER_STRUCT* DRIVER_HANDLE;

/* 'initArg' contains information for the implementation to identify
 the exact interface for the driver instance */
DRIVER_HANDLE driverCreate(void* initArg);
void driverDestroy(DRIVER_HANDLE h);
void sendByte(DRIVER_HANDLE h, char byte);
char receiveByte(DRIVER_HANDLE h);
void setIpAddress(DRIVER_HANDLE h, char* ip);
void setMacAddress(DRIVER_HANDLE h, char* mac);

你的需求再次发生了变化。现在你需要支持多个不同供应商的以太网网络接口卡,例如来自不同供应商的卡。这些卡提供类似的功能,但在访问寄存器的细节上有所不同,因此需要针对驱动程序进行不同的实现。支持这一点的两个直接选项如下:

  • 你有两个单独的驱动程序 API。这种方法的缺点在于,用户在运行时构建选择驱动程序的机制会很麻烦。此外,拥有两个单独的 API 会导致代码重复,因为至少两个设备驱动程序共享一个通用控制流程(例如,用于创建或销毁驱动程序)。

  • 你在 API 中添加了像 sendByteDriverAsendByteDriverB 这样的函数。然而,通常你希望你的 API 尽可能精简,因为在单个 API 中拥有所有驱动函数可能会让 API 用户感到困惑。此外,用户的代码依赖于通过你的 API 包含的所有函数签名,如果代码依赖于某些东西,那么这些东西应该尽可能精简(正如接口隔离原则所述)。

支持不同以太网网络接口卡的更好方法是提供一个动态接口。

动态接口

上下文

你或你的调用者希望实现多个遵循相似控制逻辑但在行为上有所不同的功能。

问题

应该可以调用具有略有不同行为的实现,但不应该需要复制任何代码,甚至不是控制逻辑实现和接口声明。

你希望能够在以后为已声明的接口添加额外的实现行为,而无需要求使用现有实现行为的调用者更改其代码。

也许你不仅希望为调用者提供不同的行为而不复制自己的代码,还希望为调用者提供一种机制来引入他们自己的实现行为。

解决方案

在你的 API 中为不同的功能定义一个共同的接口,并要求调用者为该功能提供一个回调函数,然后在你的函数实现中调用它。

要在 C 中实现这样的接口,需要在你的 API 中定义函数签名。然后调用者根据这些签名实现函数,并通过函数指针将它们附加。它们可以被永久附加和存储在你的软件模块内,或者可以在每次函数调用时附加,如下面的代码所示:

API

/* The compare function should return true if x is smaller than y, else false */
typedef bool (*COMPARE_FP)(int x, int y);

void sort(COMPARE_FP compare, int* array, int length);

实现

void sort(COMPARE_FP compare, int* array, int length)
{
  int i, j;
  for(i=0; i<length; i++)
  {
    for(j=i; j<length; j++)
    {
      /* call provided user function */
      if(compare(array[i], array[j]))
      {
        swap(&array[i], &array[j]);
      }
    }
  }
}

调用者

#define ARRAY_SIZE 4

bool compareFunction(int x, int y)
{
  return x<y;
}

void sortData()
{
  int array[ARRAY_SIZE] = {3, 5, 6, 1};
  sort(compareFunction, array, ARRAY_SIZE);
}

一定要清楚地记录函数签名的定义旁边,函数实现应该具有什么行为。还要记录如果没有附加这样的函数实现到你的函数调用中会发生什么行为。也许你会中止程序(武士原则),或者你会提供一些默认功能作为后备。

结果

调用者可以使用不同的实现,而且仍然没有代码重复。控制逻辑、接口和接口文档都没有重复。

实现可以在以后由调用者添加而无需更改 API。这意味着 API 设计者和实现提供者的角色可以完全分开。

在你的代码中,你现在执行调用者的代码。因此,你必须相信调用者知道函数该做什么。如果调用者的代码中存在错误,你的代码可能会被怀疑,毕竟错误行为发生在你的代码上下文中。

使用函数指针意味着你有一个特定于平台和编程语言的接口。只有当调用者的代码也是用 C 编写时,才能使用这种模式。你不能向使用 Java 代码编写应用程序的调用者添加编组功能并提供给他们这个接口。

已知用途

以下示例展示了这种模式的应用场景

  • 詹姆斯·格伦宁在文章“嵌入式 C 的 SOLID 设计”中描述了这种模式及其变体,称为动态接口和每种类型的动态接口。

  • 提供的解决方案是策略设计模式的 C 版本。您可以在 Adam Tornhill 的书籍 Patterns in C(Leanpub, 2014)和 David R. Hanson 的书籍 C Interfaces and Implementations(Addison-Wesley, 1996)中找到该模式的替代 C 实现。

  • 设备驱动框架通常使用函数指针,在驱动程序启动时驱动程序将其函数插入其中。Linux 内核中的设备驱动程序通常工作方式如此。

  • Subversion 项目源代码中的函数 svn_sort__hash 根据某些键值对列表进行排序。该函数以函数指针 comparison_func 作为参数。comparison_func 必须返回信息,即两个提供的键值中哪个大于另一个。

  • OpenSSL 函数 OPENSSL_LH_new 创建哈希表。调用者必须提供一个指向哈希函数的函数指针,用作在哈希表上操作时的回调。

  • Wireshark 代码包含函数指针 proto_tree_foreach_func,在遍历树结构时作为函数参数提供。该函数指针用于决定在树元素上执行哪些操作。

应用于运行示例

您的驱动程序 API 现在支持多个不同的以太网网络接口卡。这些网络接口卡的具体驱动程序必须实现发送和接收功能,并在单独的头文件中提供它们。API 用户可以将这些特定的发送和接收功能包含并附加到 API 中。

您的 API 的用户可以带入他们自己的驱动程序实现,这是一个优点。因此,作为 API 设计者,您独立于驱动程序实现的提供者。集成新驱动程序不需要任何 API 更改,这意味着您作为 API 设计者不需要做任何工作。这一切都可以通过以下 API 实现:

typedef struct INTERNAL_DRIVER_STRUCT* DRIVER_HANDLE;
typedef void (*DriverSend_FP)(char byte);      /* this is the           */
typedef char (*DriverReceive_FP)();            /* interface definition */

struct DriverFunctions
{
  DriverSend_FP fpSend;
  DriverReceive_FP fpReceive;
};

DRIVER_HANDLE driverCreate(void* initArg, struct DriverFunctions f);
void driverDestroy(DRIVER_HANDLE h);
void sendByte(DRIVER_HANDLE h, char byte);   /* internally calls fpSend    */
char receiveByte(DRIVER_HANDLE h);           /* internally calls fpReceive */
void setIpAddress(DRIVER_HANDLE h, char* ip);
void setMacAddress(DRIVER_HANDLE h, char* mac);

再次,需求发生了变化。现在不仅要支持以太网网络接口卡,还要支持其他接口卡(如 USB 接口卡)。从 API 的角度来看,这些接口具有一些相似的功能(发送和接收数据函数),但也有一些完全不同的功能(例如,USB 接口没有 IP 地址需要设置,但可能需要其他配置)。

对此的一个简单解决方案是为不同的驱动程序类型提供两个不同的 API。但这将复制发送/接收和创建/销毁功能的代码。

在一个抽象的单一 API 中支持不同类型的设备驱动的更好解决方案是引入功能控制。

功能控制

上下文

您希望实现多个功能,这些功能遵循类似的控制逻辑,但在行为上有所不同。

问题

您希望调用具有稍有不同行为的实现,但您不希望复制任何代码,甚至不是控制逻辑实现或接口声明。

调用者应该能够使用您实现的特定现有行为。甚至可以在以后添加新行为而无需触及现有实现或需要修改现有调用者代码。

对于您而言,拥有动态接口并不是一个选项,因为您不希望为调用者提供附加其自己实现的灵活性。这可能是因为接口应更易于调用者使用。或者这可能是因为您不能轻松地附加调用者的实现,例如,如果您的调用者使用另一种编程语言访问您的功能。

解决方案

向您的函数添加一个参数,该参数传递有关函数调用的元信息,并指定要执行的实际功能。

与动态接口相比,您不需要调用者提供实现,而是调用者从现有实现中进行选择。

要实现此模式,您通过添加额外的参数(例如,一个enum#define整数值)来应用基于数据的抽象,该参数指定函数的行为。然后在实现中对参数进行评估,并根据参数值调用不同的实现。

API

#define QUICK_SORT 1
#define MERGE_SORT 2
#define RADIX_SORT 3

void sort(int algo, int* array, int length);

实现

void sort(int algo, int* array, int length)
{
  switch(algo)
  {
    case QUICK_SORT: ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
      quicksort(array, length);
    break;
    case MERGE_SORT:
      mergesort(array, length);
    break;
    case RADIX_SORT:
      radixsort(array, length);
    break;
  }
}

1

在以后添加新功能时,您只需添加一个新的enum#define值,并选择相应的新实现即可。

结果

调用者可以使用不同的实现方式,并且没有代码重复。控制逻辑、接口或接口文档都不会重复。

很容易在以后添加新功能。无需触及现有实现,现有调用者的代码也不会受到更改的影响。

与动态接口相比,此模式更易于在不同程序或平台(例如远程过程调用)之间选择功能,因为 API 未通过程序特定的指针传递。

在一个函数中提供不同实现行为的选择时,您可能会被诱惑将不太相关的多个功能打包到单个函数中。这违反了单一责任原则。

已知用途

以下示例展示了此模式的应用:

  • 设备驱动程序通常使用功能控制来传递不适合于常见初始化/读写功能的特定功能。对于设备驱动程序,这种模式通常称为 I/O-Control。该概念在 Elecia White 的书《Making Embedded Systems: Design Patterns for Great Software》(O’Reilly, 2011)中有所描述。

  • 某些 Linux 系统调用已扩展以具有标志,这些标志根据标志的值扩展系统调用的功能,而不会破坏旧代码。

  • Martin Reddy(Morgan Kaufmann,2011)在书籍《API Design for C++* 中描述了通用数据驱动 API 的概念。

  • OpenSSL 代码使用函数 CTerr 记录错误。此函数接受一个 enum 参数,用于指定错误应如何以及在哪里记录。

  • POSIX 套接字函数 ioctl 接受一个数值参数 cmd,该参数决定套接字上将执行哪些实际操作。参数的允许值在头文件中定义和记录,自头文件首次发布以来,已添加了许多额外的值和函数行为。

  • Subversion 项目的函数 svn_fs_ioctl 执行一些特定于文件系统的输入或输出操作。该函数将 struct svn_fs_ioctl_code_t 作为参数。此 struct 包含一个数值,该数值决定应执行哪种类型的操作。

应用于运行示例

下面的代码展示了您的设备驱动程序 API 的最终版本:

Driver.h

typedef struct INTERNAL_DRIVER_STRUCT* DRIVER_HANDLE;
typedef void (*DriverSend_FP)(char byte);
typedef char (*DriverReceive_FP)();
typedef void (*DriverIOCTL_FP)(int ioctl, void* context);

struct DriverFunctions
{
  DriverSend_FP fpSend;
  DriverReceive_FP fpReceive;
  DriverIOCTL_FP fpIOCTL;
};

DRIVER_HANDLE driverCreate(void* initArg, struct DriverFunctions f);
void driverDestroy(DRIVER_HANDLE h);
void sendByte(DRIVER_HANDLE h, char byte);
char receiveByte(DRIVER_HANDLE h);
void driverIOCTL(DRIVER_HANDLE h, int ioctl, void* context);
/* the parameter "context" is required to pass information like the
 value of the IP address to configure to the implementation */

EthIOCTL.h

#define SET_IP_ADDRESS  1
#define SET_MAC_ADDRESS 2

UsbIOCTL.h

#define SET_USB_PROTOCOL_TYPE   3

想要使用以太网或 USB 特定功能(例如,实际通过接口发送或接收数据的应用程序)的用户必须知道他们操作的驱动程序类型,以便调用正确的 I/O 控制,还必须包含 EthIOCTL.hUsbIOCTL.h 文件。

图 6-2 显示了我们设备驱动程序 API 最终版本的源代码文件的包含关系。请注意,EthApplication.c 代码不依赖于 USB 特定的头文件。例如,如果添加了额外的 USB-IOCTL,所示代码中的 EthApplication.c 甚至不需要重新编译,因为它所依赖的文件都未更改。

sketches/function-control.png

图 6-2. 函数控制的文件关系

请记住,在本章中呈现的所有代码片段中,这些设备驱动程序的最后、最灵活的代码片段可能并非始终适合您的需求。您通过增加接口的复杂性来获得更大的灵活性,尽管您必须使代码尽可能灵活,但也应尽量保持简单。

总结

本章讨论了 C 语言的四种 API 模式,并展示了它们在设备驱动程序设计中的应用示例。头文件告诉您在 c 文件中隐藏实现细节的基本概念,同时在您的 h 文件中提供了一个明确定义的接口。Handle 模式涉及将不透明数据类型在函数之间传递以共享状态信息的广为人知的概念。动态接口通过允许通过回调函数注入调用特定代码来避免重复程序逻辑成为可能。函数控制使用额外的函数参数来指定函数调用中应执行的实际操作。这些模式展示了通过引入抽象使接口更灵活的基本 C 设计选项。

进一步阅读

如果您准备进一步学习,以下是一些资源,可以帮助您进一步了解设计 API 的知识。

  • 文章 “SOLID Design for Embedded C”,作者 James Grenning,介绍了五个 SOLID 设计原则的一般概念,并展示了如何为 C 接口实现灵活性。这篇文章的独特之处在于它是唯一一篇专门讨论 C 接口主题的文章,同时还包含了详细的代码片段。

  • 书籍 Patterns in C,作者 Adam Tornhill(Leanpub,2014),介绍了几种包含 C 代码片段的设计模式。这些模式包括 Gang of Four 模式(如策略或观察者)的 C 版本,以及特定于 C 的模式和习惯用法。该书并未专门关注接口,但部分模式描述了接口层面的交互。

  • 书籍 *API Design for *C++**,作者 Martin Reddy(Morgan Kaufmann,2011),涵盖了接口设计原则,带有 C++示例的面向对象接口模式,以及像测试和文档等接口质量问题。该书主要讨论了 C++设计,但部分内容也适用于 C。

  • 书籍 C Interfaces and Implementations,作者 David R. Hanson(Addison-Wesley,1996),介绍了接口设计,包括在 C 中实现特定组件的 C 代码。

展望

下一章详细介绍了如何找到特定类型应用程序的正确抽象级别和正确接口:它描述了如何设计和实现迭代器。

第七章:灵活的迭代器接口

在任何程序中,对一组元素进行迭代是一个常见操作。一些编程语言提供原生构造来迭代元素,而面向对象的编程语言则通过设计模式的形式提供有关如何实现通用迭代功能的指导。然而,对于像 C 这样的过程式编程语言,很少有这种类型的指导。

动词“迭代”意味着多次执行相同的操作。在编程中,它通常表示在多个数据元素上运行相同的程序代码。这种操作经常是必需的,这就是为什么在 C 语言中对数组进行迭代得到原生支持的原因,如下面的代码所示:

for (i=0; i<MAX_ARRAY_SIZE; i++)
{
  doSomethingWith(my_array[i]);
}

如果您想要迭代不同的数据结构,例如红黑树,那么您必须实现自己的迭代函数。您可以为这个函数配备数据结构特定的迭代选项,例如遍历树的深度优先或广度优先。关于如何实现这些特定数据结构以及这些数据结构的迭代接口的文献是可用的。如果您使用这样一个特定于数据结构的接口进行迭代,并且您的底层数据结构发生变化,那么您必须调整您的迭代函数以及调用此函数的所有代码。在某些情况下,这样做是可以接受的,甚至是必需的,因为您希望执行特定于底层数据结构的某种特殊迭代,也许是为了优化代码的性能。

在其他情况下,如果您必须在组件边界之间提供迭代接口,那么泄露实现细节的抽象不是一个选择,因为这可能需要将来更改接口。例如,如果您向客户出售提供迭代功能的组件,并且客户使用这些功能编写代码,那么他们很可能期望,如果您为他们提供使用不同数据结构的新版本组件,他们的代码可以不经修改地正常工作。在这种情况下,您甚至会在实现中投入额外的工作,以确保与客户的接口保持兼容,使他们无需更改(甚至可能无需重新编译)其代码。

这就是我们在本章开始的地方。我将向您展示三种模式,说明您作为迭代器实现者如何向用户(客户)提供稳定的迭代接口。这些模式不描述特定类型数据结构的特定迭代器。相反,这些模式假定在您的实现中,您已经有从底层数据结构检索元素的函数。这些模式展示了您可以如何抽象这些函数,以提供稳定的迭代接口的选项。

Figure 7-1 展示了本章涵盖的模式概述及其关系,Table 7-1 提供了这些模式的摘要。

迭代器模式概述

Figure 7-1. 迭代器接口模式概述

Table 7-1. 迭代器接口模式

模式名称 摘要
索引访问 您希望使用户能够以便捷的方式迭代您数据结构中的元素,并且可以更改数据结构的内部而不导致用户代码的变化。因此,提供一个函数,该函数接受一个索引来访问底层数据结构中的元素,并返回此元素的内容。用户在循环中调用此函数以迭代所有元素。
游标迭代器 您希望为用户提供一个迭代接口,即使在迭代过程中元素发生变化,也能保持稳健,并且允许您稍后更改底层数据结构而无需更改用户的代码。因此,创建一个迭代器实例,该实例指向底层数据结构中的一个元素。迭代函数以此迭代器实例作为参数,检索迭代器当前指向的元素,并修改迭代器实例以指向下一个元素。然后用户通过迭代调用此函数以逐个检索元素。
回调迭代器 您希望提供一个稳健的迭代接口,不需要用户在代码中实现循环来迭代所有元素,并且允许您稍后更改底层数据结构而无需更改用户的代码。因此,使用您现有的数据结构特定操作在您的实现中迭代所有元素,并在此迭代期间调用一些提供的用户函数来处理每个元素。这个用户函数以元素内容作为参数,可以对此元素执行其操作。用户只需调用一个函数来触发迭代,整个迭代过程在您的实现内部完成。

运行示例

您实现了应用程序的访问控制组件,具有一个可以随机访问任何元素的底层数据结构。具体而言,在以下代码中,您有一个包含帐户信息(如登录名和密码)的结构体数组:

struct ACCOUNT
{
  char loginname[MAX_NAME_LENGTH];
  char password[MAX_PWD_LENGTH];
};
struct ACCOUNT accountData[MAX_USERS];

下一个代码示例展示了用户如何访问此结构体以读取特定信息,例如登录名:

void accessData()
{
  char* loginname;

  loginname = accountData[0].loginname;
  /* do something with loginname */

  loginname = accountData[1].loginname;
  /* do something with loginname */
}

当然,您可以简单地不担心抽象访问您的数据结构,并允许其他程序员直接检索指向此struct的指针以循环遍历struct元素并访问struct中的任何信息。但是,这是一个坏主意,因为您的数据结构中可能存在您不希望向客户提供的信息。如果您必须随时间保持与客户的接口稳定,那么您将无法删除一旦向客户透露的信息,因为您的客户可能使用该信息,而您不希望破坏客户的代码。

为了避免这个问题,一个更好的主意是让用户只能访问所需的信息。一个简单的解决方案是提供索引访问。

索引访问

上下文

您有一组存储在可以随机访问的数据结构中的元素。例如,您有一个数组或数据库,其中包含随机检索单个元素的函数。用户想要迭代这些元素。

问题

您希望使用户能够方便地迭代您数据结构中的元素,并且应该可以在不影响用户代码的情况下更改数据结构的内部。

用户可能是编写代码的人,其代码未与您的代码库版本化和发布,因此您必须确保将来的实现版本也能与用户根据当前版本编写的代码配合使用。因此,用户不应能访问任何内部实现细节,例如您用于保存元素的底层数据结构,因为您可能希望稍后更改它。

解决方案

提供一个函数,该函数接受索引以访问底层数据结构中的元素,并返回该元素的内容。用户在循环中调用此函数以迭代所有元素,如图 7-2 所示。

sketches/index-access-sketch.png

图 7-2. 索引访问迭代

这种方法的等效物是,在数组中,用户只需使用索引来检索一个数组元素的值或迭代所有元素。但是,当您有一个接受这样的索引的函数时,也可能迭代更复杂的底层数据结构,而无需用户了解。

为了实现这一点,仅向用户提供他们感兴趣的数据,不要透露底层数据结构的所有元素。例如,不要返回指向整个struct元素的指针,而是仅返回用户感兴趣的struct成员的指针:

调用者的代码

void* element;

element = getElement(1);
/* operate on element 1 */

element = getElement(2);
/* operate on element 2 */

迭代器 API

#define MAX_ELEMENTS 42

/* Retrieve one single element identified by the provided 'index' */
void* getElement(int index);

后果

用户可以通过使用索引检索元素,方便地在其代码中循环遍历元素。他们不必处理从中获取数据的内部数据结构。如果实现中发生了更改(例如,检索的 struct 成员被重命名),用户无需重新编译其代码。

对底层数据结构的其他更改可能会变得更加困难。例如,如果底层数据结构从数组(随机访问)更改为链表(顺序访问),那么每次都必须迭代列表直到到达请求的索引位置。这样做根本不高效,为了确保还允许底层数据结构的这些更改,最好使用游标迭代器或回调迭代器。

如果用户仅检索可以作为 C 函数返回值的基本数据类型,则用户隐式地检索该元素的副本。如果底层数据结构中的相应元素在此期间发生更改,则这不会影响用户。但是,如果用户检索更复杂的数据类型(如字符串),则与直接提供对底层数据结构的访问相比,使用索引访问具有优势,即您可以以线程安全的方式复制当前数据元素并提供给用户,例如使用调用者拥有的缓冲区。如果您不在多线程环境中操作,则可以简单地为复杂数据类型返回指针。

在访问一组元素时,用户通常希望遍历所有元素。如果其他人在此期间向底层数据添加或删除元素,则用户对访问元素的索引的理解可能会变得无效,并且在迭代过程中可能会无意中检索元素两次。解决此问题的一种简单方法是将用户感兴趣的所有元素简单复制到数组中,并将此独占数组提供给用户,用户可以方便地在此数组上进行循环。用户将拥有该副本的专有所有权,甚至可以修改元素。但如果不明确要求这样做,则复制所有元素可能不值得。一个更方便的解决方案,用户在迭代过程中不必担心底层数据顺序的更改,是提供一个回调迭代器。

已知用途

以下示例展示了此模式的应用:

  • James Noble 在他的文章 “迭代器与封装” 中描述了外部迭代器模式。这是该模式中描述的概念的面向对象版本。

  • Mark Allen Weiss 的书籍《Java 数据结构与问题解决》(Addison-Wesley, 2006)描述了这种方法,并称其为具有类似数组接口的访问方式。

  • Wireshark 代码的函数service_response_time_get_column_name返回统计表的列名。用户提供的索引参数用于寻址要返回的列名。列名在运行时不能更改,因此即使在多线程环境中,访问数据或迭代列名的方式也是安全的。

  • Subversion 项目包含用于建立字符串表的代码。可以使用函数svn_fs_x__string_table_get访问这些字符串。此函数接受一个索引作为参数,用于定位要检索的字符串。检索到的字符串被复制到提供的缓冲区中。

  • OpenSSL 函数TXT_DB_get_by_index从文本数据库中检索选择的字符串,并将其存储在提供的缓冲区中。

应用于运行示例

现在你有了一个清晰的抽象来读取登录名,并且不向用户透露内部实现细节:

char* getLoginName(int index)
{
  return accountData[index].loginname;
}

用户不必处理访问底层struct数组。这有利于他们更容易访问所需数据,并且他们不能使用未经授权的任何信息。例如,他们不能访问您将来可能想要更改的struct的子元素,并且只有在没有人访问这些数据时才能更改,因为您不希望破坏用户的代码。

有人使用此接口,比如想要编写一个检查是否有以字母“X”开头的登录名的函数,编写以下代码:

bool anyoneWithX()
{
  int i;
  for(i=0; i<MAX_USERS; i++)
  {
    char* loginName = getLoginName(i);
    if(loginName[0] == 'X')
    {
      return true;
    }
  }
  return false;
}

在使用普通数组存储数据结构变更后,你对自己的实现感到满意,因为你需要一个更方便的方法来插入和删除账户数据,而在普通数组中存储数据时这相当困难。现在登录名不再存储在单个普通数组中,而是存储在一个底层数据结构中,该数据结构提供了一个从一个元素到下一个元素的操作,而不提供随机访问元素的操作。更具体地说,你有一个可以访问的链表,如下面的代码所示:

struct ACCOUNT_NODE
{
  char loginname[MAX_NAME_LENGTH];
  char password[MAX_PWD_LENGTH];
  struct ACCOUNT_NODE* next;
};

struct ACCOUNT_NODE* accountList;

struct ACCOUNT_NODE* getFirst()
{
  return accountList;
}

struct ACCOUNT_NODE* getNext(struct ACCOUNT_NODE* current)
{
  return current->next;
}

void accessData()
{
  struct ACCOUNT_NODE* account = getFirst();
  char* loginname = account->loginname;
  account = getNext(account);
  loginname = account->loginname;
  ...
}

这使得在当前接口中情况变得困难,该接口每次提供一个可以随机索引访问的登录名。要进一步支持这一点,你必须通过调用getNext函数来模拟索引,并计算直到达到索引元素为止。这是非常低效的。所有这些麻烦只是因为你设计的接口方式不够灵活而必要。

为了简化操作,提供一个游标迭代器来访问登录名。

游标迭代器

上下文

你有一组存储在可以随机或顺序访问的数据结构中的元素。例如,你有一个数组、一个链表、一个哈希映射或一个树形数据结构。用户想要迭代这些元素。

问题

你希望为用户提供一个迭代接口,即使在迭代过程中元素发生变化,也能保持稳定,并且能够在以后改变底层数据结构而无需修改用户的代码。

用户可能是那些编写代码但不会与您的代码库版本化和发布的人,因此您必须确保您的实现的将来版本也能与用户针对当前版本编写的代码兼容。因此,用户不应该能够访问任何内部实现细节,例如您用来保存元素的底层数据结构,因为您可能希望稍后更改它。

此外,在多线程环境下操作时,您希望为用户提供稳健且明确定义的行为,以防止用户在迭代过程中元素内容发生更改。即使是像字符串这样的复杂数据,用户也不应该担心其他线程在用户想要读取数据时更改它。

即使您需要额外的实现工作来实现所有这些,您也不会介意,因为许多用户将使用您的代码,如果您可以通过在您的代码中实现来减少用户的实现工作,那么总体工作量将会减少。

解决方案

创建一个指向底层数据结构中元素的迭代器实例。迭代函数将此迭代器实例作为参数,检索迭代器当前指向的元素,并修改迭代实例以指向下一个元素。然后用户迭代调用此函数以逐个检索元素,如图 7-3 所示。

sketches/cursor-iterator-sketch.png

图 7-3. 使用光标迭代器进行迭代

迭代器接口需要两个函数来创建和销毁迭代器实例,以及一个函数来执行实际的迭代并检索当前元素。显式创建/销毁函数使得您可以拥有一个实例,在其中存储内部迭代数据(位置、当前元素的数据)。然后用户需要将此实例传递给所有迭代函数调用,如下面的代码所示:

调用者的代码

void* element;
ITERATOR* it = createIterator();

while(element = getNext(it))
{
  /* operate on element */
}

destroyIterator(it);

迭代器 API

/* Creates an iterator and moves it to the first element */
ITERATOR* createIterator();

/* Returns the element currently pointed to and sets the iterator to the
 next element. Returns NULL if the element does not exist. */
void* getNext(ITERATOR* iterator);

/* Cleans up an iterator created with the function createIterator() */
void destroyIterator(ITERATOR* iterator),

如果您不希望用户能够访问此内部数据,则可以隐藏它并为用户提供一个句柄。这样即使更改迭代实例的内部数据,也不会影响用户。

在检索当前元素时,可以直接提供基本数据类型作为返回值。复杂数据类型可以作为引用返回,也可以复制到迭代器实例中。将它们复制到迭代器实例中使您可以获得数据的一致性,即使在底层数据结构在此期间发生变化(例如,因为在多线程环境中被其他人修改)。

结果

只需调用getNext方法,用户就可以简单地迭代数据,只要检索到有效元素即可。他们无需处理从中获取这些数据的内部数据结构,也无需担心元素索引或元素的最大数量。但是,无法索引元素也意味着用户无法随机访问元素(这可以通过索引访问来完成)。

即使底层数据结构发生变化,例如从链表变为像数组这样的随机访问数据结构,那么这种变化可以隐藏在迭代器的实现中,用户无需更改或重新编译代码。

无论用户检索哪种类型的数据——简单的或复杂的数据类型——他们无需担心,如果在此期间更改或删除底层元素,检索的元素将变为无效。为了实现这一点,用户现在必须显式调用函数来创建和销毁迭代器实例。与索引访问相比,需要更多的函数调用。

当访问一组元素时,用户通常希望迭代所有元素。如果其他人在此期间向底层数据添加元素,则用户在迭代期间可能会错过此元素。如果这对您来说是一个问题,并且您希望确保元素在迭代期间根本不会更改,则更容易使用回调迭代器。

已知用途

以下示例展示了该模式的应用:

  • James Noble 在他的文章"迭代器和封装"中将这种迭代器的面向对象版本描述为魔术饼干模式。

  • Jed Liu 等人在文章"可中断迭代器"中将所述的概念描述为游标对象

  • 此类迭代用于文件访问。例如,getline C 函数迭代文件中的行,并将迭代器位置存储在FILE指针中。

  • OpenSSL 代码提供了函数ENGINE_get_firstENGINE_get_next来迭代加密引擎列表。每个调用都将ENGINE struct的指针作为参数。此struct存储了迭代中的当前位置。

  • Wireshark 代码包含函数 proto_get_first_protocolproto_get_next_protocol。这些函数使用户能够遍历网络协议列表。函数接受一个 void 指针作为输出参数,用于存储和传递状态信息。

  • Subversion 项目中的生成文件之间差异的代码包含函数 datasource_get_next_token。此函数需要在循环中调用,以从提供的数据源对象中获取下一个差异标记,数据源对象存储迭代位置。

应用于运行示例

你现在有以下函数来检索登录名:

struct ITERATOR
{
  char buffer[MAX_NAME_LENGTH];
  struct ACCOUNT_NODE* element;
};

struct ITERATOR* createIterator()
{
  struct ITERATOR* iterator = malloc(sizeof(struct ITERATOR));
  iterator->element = getFirst();
  return iterator;
}

char* getNextLoginName(struct ITERATOR* iterator)
{
  if(iterator->element != NULL)
  {
    strcpy(iterator->buffer, iterator->element->loginname);
    iterator->element = getNext(iterator->element);
    return iterator->buffer;
  }
  else
  {
    return NULL;
  }
}

void destroyIterator(struct ITERATOR* iterator)
{
  free(iterator);
}

以下代码展示了如何使用此接口:

bool anyoneWithX()
{
  char* loginName;
  struct ITERATOR* iterator = createIterator();
  while(loginName = getNextLoginName(iterator)) ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
  {
    if(loginName[0] == 'X')
    {
      destroyIterator(iterator); ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/2.png)
      return true;
    }
  }
  destroyIterator(iterator); ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/2.png)
  return false;
}

1

应用程序不再需要处理索引和元素的最大数量。

2

在这 在这种情况下,销毁迭代器所需的清理代码导致了代码重复。

接下来,你不仅想要实现 anyoneWithX 函数,还想实现一个额外的函数,例如,告诉你有多少个登录名以字母“Y”开头。你可以简单地复制代码,修改 while 循环的主体,并统计“Y”的出现次数,但这种方法会导致代码重复,因为你的两个函数都包含创建和销毁迭代器以及执行循环操作的相同代码。为了避免这种代码重复,可以使用回调迭代器。

回调迭代器

背景

你有一组存储在数据结构中的元素,可以随机或顺序访问。例如,你有一个数组、一个链表、一个哈希映射或一个树数据结构。用户希望遍历这些元素。

问题

你希望提供一个强健的迭代接口,不需要用户在代码中实现遍历所有元素的循环,同时能够在以后更改底层数据结构而无需更改用户的代码。

用户可能是编写不版本化且与您的代码库一起发布的代码的人,因此必须确保您的实现的未来版本也能与用户针对当前版本编写的代码兼容。因此,用户不应该能够访问任何内部实现细节,例如用于存储元素的底层数据结构,因为你可能在以后想要更改它。

此外,在多线程环境中操作时,您希望为用户提供稳健且明确定义的行为,以便在用户迭代过程中元素内容发生变化时。即使是像字符串这样的复杂数据,用户也不应该担心其他线程在用户想要读取它时更改该数据。此外,您希望确保用户只对每个元素进行一次迭代。即使其他线程试图在迭代期间创建新元素或删除现有元素,这一点也应该保持不变。

你并不在乎是否需要额外的实现工作来实现所有这些,因为许多用户将使用您的代码,如果您可以通过在您的代码中实现它来减少用户的实现工作,那么总体工作量将会减少。

您希望尽可能地简化用户访问元素的方式。特别是,用户不应该处理诸如索引与元素之间的映射或可用元素数量等迭代细节。此外,他们不应该在其代码中实现循环,因为这会导致用户代码中的重复,因此对于您来说,索引访问或游标迭代器都不是选项。

解决方案

利用您现有的数据结构特定操作,在您的实现中迭代遍历所有元素,并在此迭代期间调用一些提供的用户函数处理每个元素。此用户函数以元素内容作为参数,可以对该元素执行其操作。用户只需调用一个函数来触发迭代,整个迭代过程都发生在您的实现中,如图 7-4 所示。

sketches/callback-iterator-sktech.png

图 7-4. 使用回调迭代器进行迭代

要实现这一点,您必须在您的接口中声明一个函数指针。声明的函数接受应作为参数进行迭代的元素。用户实现这样的函数并将其传递给您的迭代函数。在您的实现中,您遍历所有元素,并且对于每个元素,您将调用用户的函数,参数为当前元素。

您可以向您的迭代函数和函数指针声明添加一个额外的void*参数。在您的迭代函数实现中,您简单地将该参数传递给用户的函数。这使得用户可以向函数传递一些上下文信息:

调用者的代码

void myCallback(void* element, void* arg)
{
  /* operate on element */
}

void doIteration()
{
  iterate(myCallback, NULL);
}

迭代器 API

/* Callback for the iteration to be implemented by the caller. */
typedef void (*FP_CALLBACK)(void* element, void* arg);

/* Iterates over all elements and calls callback(element, arg)
 on each element. */
void iterate(FP_CALLBACK callback, void* arg);

有时用户并不想迭代所有元素,而是想找到一个特定的元素。为了使这种用例更有效率,你可以在迭代函数中添加一个中断条件。例如,你可以声明用户函数的函数指针,该函数操作返回类型为bool的元素,并且如果用户函数返回值为true,则停止迭代。然后用户可以在找到所需元素时发出信号,并节省迭代其余元素所需的时间。

当在多线程环境中实现迭代函数时,请确保覆盖以下情况:在迭代期间,当前元素被改变、新增元素被添加或其他线程删除元素。在这种情况下,你可以向当前迭代的用户返回状态码,或者在迭代期间通过锁定写访问来防止这些变化。

因为实现可以确保在迭代过程中数据不会改变,所以不需要复制用户操作的元素。用户只需获取指向这些数据的指针,并且使用原始数据进行操作。

结果

现在,用户用于迭代所有元素的代码只有一行。所有实现细节,如元素索引和元素的最大数量,都被隐藏在迭代器实现内部。用户甚至不必实现一个循环来迭代元素。他们也不必创建或销毁迭代器实例,也无需处理从中收集元素的内部数据结构。即使在实现中更改底层数据结构的类型,他们也不必重新编译代码。

如果在迭代过程中底层元素发生变化,则迭代器实现可以相应地做出反应,从而确保用户在迭代时访问一致的数据集,而无需在用户代码中处理锁定功能。所有这些都是因为控制流不会在用户代码和迭代器代码之间跳转。控制流保持在迭代器实现内部,因此迭代器实现可以检测到迭代过程中元素的变化并做出相应的反应。

用户可以迭代所有元素,但迭代循环是在迭代器实现内部实现的,因此用户不能像索引访问一样随机访问元素。

在回调函数中,你的实现会在每个元素上运行用户代码。在某种程度上,这意味着你必须相信用户的代码能够正确执行。例如,如果你的迭代器实现在迭代期间锁定所有元素,那么你期望用户代码能够快速处理检索到的元素,并且不执行耗时的操作,因为在此期间,所有访问该数据的调用都将被锁定。

使用回调意味着你必须有一个特定于平台和编程语言的接口,因为你调用由调用者实现的代码,并且只有在该代码使用相同的调用约定(即提供函数参数和返回数据的相同方式)时才能这样做。这意味着,在 C 语言中实现迭代器时,只有当用户代码也是用 C 语言编写时,才能使用此模式。例如,你不能将一个 C 回调迭代器提供给使用 Java 编写代码的用户(尽管可以通过其他迭代器模式之一努力做到这一点)。

当使用回调阅读代码时,程序流程更难以跟踪。例如,与直接在代码中有一个简单的 while 循环相比,仅看到一个带有回调参数的用户代码行时,可能更难找出程序在迭代元素时的情况。因此,关键是给迭代函数命名,以清楚表明该函数执行迭代操作。

已知的用途

以下示例展示了此模式的应用:

  • James Noble 在他的文章 “迭代器与封装” 中描述了这个迭代器的面向对象版本为内部迭代器模式。

  • Subversion 项目的函数 svn_iter_apr_hash 遍历作为参数提供给函数的哈希表中的所有元素。对于哈希表的每个元素,会调用一个由调用者提供的函数指针,如果该调用返回 SVN_ERR_ITER_BREAK,则停止迭代。

  • OpenSSL 函数 ossl_provider_forall_loaded 遍历一组 OpenSSL 提供者对象。该函数接受一个函数指针作为参数,并且对每个提供者对象调用该函数指针。可以为迭代调用提供一个 void* 参数,然后为迭代中的每个调用提供该参数,以便用户可以传递自己的上下文。

  • Wireshark 函数 conversation_table_iterate_tables 遍历“会话”对象列表。每个这样的对象存储关于嗅探网络数据的信息。该函数接受一个函数指针和一个 void* 作为参数。对于每个会话对象,使用该 void* 作为上下文调用函数指针。

应用于运行示例

现在提供以下函数来访问登录名:

typedef void (*FP_CALLBACK)(char* loginName, void* arg);

void iterateLoginNames(FP_CALLBACK callback, void* arg)
{
  struct ACCOUNT_NODE* account = getFirst(accountList);
  while(account != NULL)
  {
    callback(account->loginname, arg);
    account = getNext(account);
  }
}

以下代码展示了如何使用此接口:

void findX(char* loginName, void* arg)
{
  bool* found = (bool*) arg;
  if(loginName[0] == 'X')
  {
    *found = true;
  }
}

void countY(char* loginName, void* arg)
{
  int* count = (int*) arg;
  if(loginName[0] == 'Y')
  {
    (*count)++;
  }
}

bool anyoneWithX()
{
  bool found=false;
  iterateLoginNames(findX, &found); ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
  return found;
}

int numberOfUsersWithY()
{
  int count=0;
  iterateLoginNames(countY, &count); ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
  return count;
}

1

应用程序不再包含显式的循环语句。

作为可能的增强,回调函数可以具有确定迭代是否继续或停止的返回值。有了这样的返回值,例如,可以在 findX 函数遍历以“X”开头的第一个用户后停止迭代。

总结

本章展示了实现提供迭代功能的接口的三种不同方法。表 7-2 提供了这三种模式的概述,并比较了它们的后果。

表 7-2. 迭代器模式比较

索引访问 光标迭代器 回调迭代器
元素访问 允许随机访问 仅允许顺序访问 仅允许顺序访问
数据结构更改 底层数据结构只能轻松更改为另一个随机访问数据结构 底层数据结构可以轻松更改 底层数据结构可以轻松更改
通过接口泄露的信息 元素数量;随机访问数据结构的使用 迭代器位置(用户可以在稍后的某个时刻停止并继续迭代) -
代码重复 用户代码中的循环;用户代码中的索引增量 用户代码中的循环 -
鲁棒性 实现健壮的迭代行为较困难 实现健壮的迭代行为较困难 由于控制流仅停留在迭代代码中,插入/删除/修改操作可以在迭代期间简单地被锁定(但会在此期间阻塞其他迭代)
平台 接口可用于不同的语言和平台 接口可用于不同的语言和平台 只能与实现具有相同调用约定的相同语言和平台一起使用

进一步阅读

如果你准备深入了解,这里有一些资源可以帮助你进一步了解迭代器接口设计。

  • 关于 C 中迭代器的最相关工作是 James Aspnes 的在线版本的大学课堂笔记。这些课堂笔记描述了不同的 C 迭代器设计,讨论了它们的优缺点,并提供了源代码示例。

  • 对于其他编程语言的迭代器有更多的指导,但许多概念也可以应用于 C。例如,James Noble 的文章"迭代器和封装"描述了如何设计面向对象迭代器的八种模式,Mark Allen Weiss(Addison-Wesley,2006 年)的书《Java 数据结构与问题解决》描述了 Java 的不同迭代器设计,Mark Jason Dominus(Morgan Kaufmann,2005 年)的书《高阶 Perl》描述了 Perl 的不同迭代器设计。

  • 文章"循环模式"由 Owen Astrachan 和 Eugene Wallingford 包含描述实现循环的最佳实践模式,包括 C++和 Java 代码片段。大部分想法也适用于 C。

  • 书籍C 接口与实现(David R. Hanson 著,Addison-Wesley,1996 年)描述了 C 语言中常见数据结构(如链表或哈希表)的实现及其接口。当然,这些接口也包含遍历这些数据结构的函数。

Outlook

下一章重点讨论如何在大型程序中组织代码文件。一旦您应用了前几章的模式来定义接口并编写其实现,您就会得到许多文件。它们的文件组织必须得到解决,以实现模块化、大规模的程序。

第八章:模块化程序中的文件组织

任何实现较大软件并希望使该软件易于维护的程序员都会面临如何使软件模块化的问题。这个问题的最重要部分与软件模块之间的依赖关系有关,例如,由 Robert C. Martin 在《Clean Code: A Handbook of Agile Software Craftsmanship》(Prentice Hall,2008)中描述的 SOLID 设计原则或由 Gang of Four 在《Design Patterns: Elements of Reusable Object-Oriented Software》(Prentice Hall,1997)中描述的设计模式提供了相关答案。

然而,使软件模块化也引发了如何组织源文件的问题,使得某人能够使软件模块化的问题尚未得到很好的解答,这导致代码库中存在糟糕的文件结构。稍后将这样的代码库变得模块化是很困难的,因为您不知道应该将哪些文件分隔成不同的软件模块或不同的代码库。此外,作为程序员,找到包含您应该使用的 API 的文件也很困难,因此您可能会引入不应使用的 API 的依赖关系。这对于 C 语言来说尤为严重,因为 C 语言不支持标记仅供内部使用的 API 并限制对其的访问的任何机制。

其他编程语言中有这样的机制,并且有关如何结构化文件的建议。例如,Java 编程语言提供了的概念。Java 为开发人员提供了一种默认的方式来组织这些包的类以及包内的文件。对于其他编程语言,如 C 语言,没有关于如何结构化文件的这样的建议。开发人员必须自行决定如何结构化包含 C 函数声明的头文件以及包含 C 函数定义的实现文件。

本章展示了如何通过为 C 程序员提供指导来解决这个问题,特别是如何结构化实现文件,特别是如何结构化头文件(API),以便允许开发大型、模块化的 C 程序。

图 8-1 显示了本章涵盖的模式概述,而 表 8-1 提供了这些模式的简短描述。

如何组织您的代码文件的模式概述

图 8-1. 如何组织您的代码文件的模式概述

表 8-1. 如何组织您的代码文件的模式

模式名称 摘要
包含保护 多次包含头文件很容易,但如果包含同一个头文件,则会导致编译错误,因为其中的类型或某些宏在编译过程中会被重新定义。因此,请保护您的头文件内容,防止多次包含,这样使用头文件的开发人员就无需担心是否多次包含。使用互锁的#ifdef语句或#pragma once语句来实现此功能。
软件模块目录 将代码拆分为不同的文件会增加代码库中的文件数量。将所有文件放在一个目录中将使得难以对所有文件进行概览,特别是对于大型代码库而言。因此,将属于紧密耦合功能的头文件和实现文件放在一个目录中。将该目录命名为提供通过头文件提供的功能的名称。
全局包含目录 要包含来自其他软件模块的文件,您必须使用类似../othersoftwaremodule/file.h的相对路径。您必须知道其他头文件的确切位置。因此,在您的代码库中有一个全局目录,包含所有软件模块的 API。将此目录添加到工具链中的全局包含路径中。
自包含组件 从目录结构中无法看出代码的依赖关系。任何软件模块都可以简单地包含来自任何其他软件模块的头文件,因此无法通过编译器检查代码中的依赖关系。因此,请识别包含类似功能并应一起部署的软件模块。将这些软件模块放入共同目录,并为调用者相关的头文件设立一个指定的子目录。
API 复制 您希望独立开发、版本化和部署代码库的各个部分。然而,为了实现这一点,您需要在代码部分之间有清晰定义的接口,并能够将该代码分开到不同的存储库中。因此,要使用另一个组件的功能,请复制其 API。单独构建该其他组件并复制构建产物及其公共头文件。将这些文件放入组件内的一个目录,并将该目录配置为全局包含路径。

运行示例

想象一下,您希望实现一个打印某些文件内容哈希值的软件片段。您可以从以下简单哈希函数的代码开始:

main.c

#include <stdio.h>

static unsigned int adler32hash(const char* buffer, int length)
{
  unsigned int s1=1;
  unsigned int s2=0;
  int i=0;

  for(i=0; i<length; i++)
  {
    s1=(s1+buffer[i]) % 65521;
    s2=(s1+s2) % 65521;
  }
  return (s2<<16) | s1;
}

int main(int argc, char* argv[])
{
  char* buffer = "Some Text";
  unsigned int hash = adler32hash(buffer, 100);
  printf("Hash value: %u", hash);
  return 0;
}

前面的代码简单地将固定字符串的哈希输出打印到控制台输出。接下来,您希望扩展该代码。您希望读取文件的内容并打印文件内容的哈希值。您可以简单地将所有这些代码添加到main.c文件中,但这将使文件变得非常长,并且随着代码的增长,它将使代码更加难以维护。

相反,最好拥有单独的实现文件,并使用头文件访问它们的功能。现在,您可以通过以下代码读取文件内容并打印文件内容的哈希值。为了更容易看到哪些代码部分发生了变化,跳过未更改的实现:

main.c

#include <stdio.h>
#include <stdlib.h>
#include "hash.h"
#include "filereader.h"

int main(int argc, char* argv[])
{
  char* buffer = malloc(100);
  getFileContent(buffer, 100);
  unsigned int hash = adler32hash(buffer, 100);
  printf("Hash value: %u", hash);
  return 0;
}

hash.h

/* Returns the hash value of the provided "buffer" of size "length".
 The hash is calculated according to the Adler32 algorithm. */
unsigned int adler32hash(const char* buffer, int length);

hash.c

#include "hash.h"

unsigned int adler32hash(const char* buffer,  int length)
{
  /* no changes here */
}

filereader.h

/* Reads the content of a file and stores it in the  provided "buffer"
 if is is long enough according to its provided "length" */
void getFileContent(char* buffer, int length);

filereader.c

#include <stdio.h>
#include "filereader.h"

void getFileContent(char* buffer, int length)
{
  FILE* file = fopen("SomeFile", "rb");
  fread(buffer, length, 1, file);
  fclose(file);
}

将代码组织成单独的文件使代码更加模块化,因为代码中的依赖关系现在可以显式地放入同一文件中。您的代码库文件当前都存储在同一个目录中,如图 8-2 所示。

fluc 0802

图 8-2. 文件概述

现在您有了单独的头文件,可以在实现文件中包含这些头文件。但是,如果多次包含头文件,则可能会遇到构建错误。为了解决此问题,可以安装包含保护。

包含保护

上下文

您将实现分割为多个文件。在实现中,包含头文件以获取其他要调用或使用的代码的前向声明。

问题

很容易多次包含头文件,但如果类型或特定宏是其中的一部分,则包含相同头文件会导致编译错误,因为在编译过程中它们会被重新定义。

在 C 语言中,在编译期间,#include 指令允许 C 预处理器完全复制包含的文件到您的编译单元中。例如,如果在头文件中定义了一个 struct 并且该头文件多次包含,则该 struct 定义将被多次复制并且在编译单元中出现多次,这将导致编译错误。

为了避免这种情况,您可以尝试不多次包含文件。但是,在包含头文件时,通常不清楚该头文件内是否包含其他额外的头文件。因此,很容易多次包含文件。

解决方案

保护头文件的内容免受多次包含,以便使用头文件的开发人员无需关心其是否被多次包含。使用互锁的 #ifdef 语句或 #pragma once 语句可以实现此目的。

以下代码展示了如何使用包含保护:

somecode.h

#ifndef SOMECODE_H
#define SOMECODE_H
 /* put the content of your headerfile here */
#endif

othercode.h

#pragma once
 /* put the content of your headerfile here */

在构建过程中,互锁的 #ifdef 语句或 #pragma once 语句保护头文件内容,防止在编译单元中多次编译。

#pragma once 语句在 C 标准中未定义,但大多数 C 预处理器都支持。但请注意,如果切换到具有不同 C 预处理器的不同工具链,可能会出现该语句的问题。

虽然嵌套的#ifdef语句适用于所有 C 预处理器,但它带来了一个难题,即你必须为定义的宏使用一个唯一的名称。通常会使用与头文件名称相关的命名方案,但如果重命名文件并忘记更改包含保护的名称,可能会导致名称过时。此外,在使用第三方代码时可能会遇到问题,因为你的包含保护名称可能会冲突。避免这些问题的方法是不使用头文件的名称,而是使用一些其他唯一的名称,例如当前时间戳或 UUID。

后果

作为一个包含头文件的开发者,你现在无需关心文件是否会被多次包含。这让生活变得更加轻松,特别是在有嵌套#include语句的情况下,因为很难准确知道哪些文件已经被包含。

现在你必须使用非标准的#pragma once语句,或者你必须为你的嵌套#ifdef语句提出一个独特的命名方案。虽然文件名在大多数情况下作为唯一名称有效,但是在使用第三方代码时仍可能出现类似名称的问题。此外,当重命名自己的文件时,#define语句的名称可能不一致,但某些集成开发环境会在此时提供帮助。它们在创建新的头文件时已经创建了一个包含保护,或在重命名头文件时会自动调整#define的名称。

#ifdef语句的嵌套防止了当同一个文件多次包含时的编译错误,但并未阻止多次打开和复制被包含的文件到编译单元中。这是编译时间中不必要的部分,可以进行优化。优化的一种方法是在每个#include语句周围添加额外的包含保护,但这样会使得文件的包含更加繁琐。对于大多数现代编译器来说,这是不必要的,因为它们会自行优化编译过程(例如,通过缓存头文件内容或记住已经包含的文件)。

已知的应用

下面的示例展示了此模式的应用:

  • 几乎所有由多个文件组成的 C 代码都应用了这种模式。

  • 《大规模 C++软件设计》(John Lakos 著,Addison-Wesley 出版,1996 年)一书描述了通过在每个#include语句周围添加额外的保护来优化包含保护的性能。

  • Portland 模式库描述了包含保护模式,并描述了通过在每个#include语句周围添加额外的保护来优化编译时间的模式。

应用于运行示例

下面代码中的包含保护确保即使头文件被多次包含,也不会出现构建错误:

hash.h

#ifndef HASH_H
#define HASH_H
/* Returns the hash value of the provided "buffer" of size "length".
 The hash is calculated according to the Adler32 algorithm. */
unsigned int adler32hash(const char* buffer, int length);
#endif

filereader.h

#ifndef FILEREADER_H
#define FILEREADER_H
/* Reads the content of a file and stores it in the provided "buffer"
 if is is long enough according to its provided "length" */
void getFileContent(char* buffer, int length);
#endif

作为代码的下一个特性,您希望还打印由另一种哈希函数计算的哈希值。仅仅添加另一个hash.c文件来实现另一种哈希函数是不可能的,因为文件名必须是唯一的。给新文件取另一个名字是一个选项。然而,即使这样做了,您对情况仍然不满意,因为现在一个目录中有越来越多的文件,这使得难以获得文件的概览并查看哪些文件是相关的。为了改善情况,您可以使用软件模块目录。

软件模块目录

背景

您将源代码分割为不同的实现文件,并利用头文件从其他实现文件中使用功能。越来越多的文件被添加到您的代码库中。

问题

将代码分割成不同文件会增加代码库中文件的数量。将所有文件放在一个目录中会使得在大型代码库中保持所有文件的概览变得困难。

将文件放入不同的目录中会引发一个问题,即您想将哪些文件放入哪个目录。应该很容易找到属于一起的文件,并且应该知道如果需要添加更多文件,则应将文件放在哪里。

解决方案

将属于紧密耦合功能的头文件和实现文件放入一个目录中。将该目录命名为通过头文件提供的功能。

此目录及其内容进一步称为软件模块。经常,软件模块包含所有在使用句柄操作实例的代码。在这种情况下,软件模块是面向对象类的非面向对象等效物。将软件模块的所有文件放入一个目录中相当于将类的所有文件放入一个目录中。

软件模块可以包含一个单独的头文件和一个单独的实现文件或多个这样的文件。将文件放入一个目录的主要标准是目录内文件的高内聚性和与其他软件模块目录的低耦合性。

当您只在软件模块内使用头文件和在软件模块外使用头文件时,通过命名文件可以清楚地表明哪些头文件不应在软件模块外部使用(例如,通过给它们添加后缀internal,如图 8-3 和以下代码所示):

fluc 0803

图 8-3 文件概览

somecode.c

#include "somecode.h"
#include "morecode.h"
#include "../othersoftwaremodule/othercode.h"
...

morecode.c

#include "morecode.h"
...

othercode.c

#include "othercode.h"
...

前面的代码摘录显示了如何包含文件,但未显示实现。请注意,可以轻松地包含来自同一软件模块的头文件。为了包含其他软件模块的头文件,需要知道这些软件模块的路径。

当您的文件分布在不同的目录中时,您必须确保您的工具链配置为编译所有这些文件。也许您的集成开发环境自动编译代码库子目录中的所有文件,但您可能需要调整构建设置或操作 Makefile 来编译新目录中的文件。

配置包含目录和编译文件

现代 C 编程 IDE 通常提供了一个无忧的环境,让 C 程序员可以专注于编程,不一定需要涉及构建过程。这些 IDE 提供了构建设置,允许您轻松配置包含实现文件和包含文件的目录。这使得 C 程序员可以专注于编程,而不是编写 Makefile 和编译器命令。本章假设您有这样的 IDE,并且不关注 Makefile 及其语法。

影响

将代码文件分割到不同的目录中,可以让不同目录中具有相同文件名。在使用第三方代码时特别方便,否则这些文件名可能会与您自己代码库中的文件名冲突。

然而,不建议在不同目录中使用类似的文件名。特别是对于头文件,建议使用唯一的文件名,以确保要包含的文件不依赖于包含路径的搜索顺序。为了使文件名唯一,您可以为软件模块的所有文件使用一个短且唯一的前缀。

将所有与软件模块相关的文件放入一个目录中,可以更容易地找到相关文件,因为您只需知道软件模块的名称即可。软件模块内的文件数量通常足够少,可以快速定位该目录中的文件。

大多数代码依赖都局限于每个软件模块,因此现在高度依赖的文件都在同一个目录中。这使得试图理解代码某一部分的程序员更容易看到哪些其他文件也相关。软件模块目录外的任何实现文件通常与理解该软件模块的功能无关。

已知用途

下面的示例展示了这种模式的应用:

  • Git 源代码在其目录中结构化了一些代码,然后其他代码使用相对路径包含这些头文件。例如,kwset.c 包含 compat/obstack.h

  • Netdata 实时性能监控和可视化系统将其代码文件组织到诸如 databaseregistry 等目录中,每个目录包含少量文件。要包含来自另一个目录的文件,使用相对包含路径。

  • 网络映射工具 Nmap 将其软件模块组织成目录,例如 ncatndiff。使用相对路径包含来自其他软件模块的头文件。

应用于运行示例

代码基本上保持不变。只是为新哈希函数添加了一个新的头文件和一个新的实现文件。文件的位置已经改变,从包含路径可以看出。除了将文件放入单独的目录中外,它们的名称也更改为使文件名唯一:

main.c

#include <stdio.h>
#include <stdlib.h>
#include "adler/adlerhash.h"
#include "bernstein/bernsteinhash.h"
#include "filereader/filereader.h"

int main(int argc, char* argv[])
{
  char* buffer = malloc(100);
  getFileContent(buffer, 100);

  unsigned int hash = adler32hash(buffer, 100);
  printf("Adler32 hash value: %u", hash);

  unsigned int hash = bernsteinHash(buffer, 100);
  printf("Bernstein hash value: %u", hash);

  return 0;
}

bernstein/bernsteinhash.h

#ifndef BERNSTEINHASH_H
#define BERNSTEINHASH_H
/* Returns the hash value of the provided "buffer" of size "length".
 The hash is calculated according to the D.J. Bernstein algorithm. */
unsigned int bernsteinHash(const char* buffer, int length);
#endif

bernstein/bernsteinhash.c

#include "bernsteinhash.h"

unsigned int bernsteinHash(const char* buffer, int length)
{
  unsigned int hash = 5381;
  int i;
  for(i=0; i<length; i++)
  {
    hash = 33 * hash ^ buffer[i];
  }
  return hash;
}

将代码文件分割成单独的目录非常常见。这样做可以更容易地找到文件,并且可以拥有具有相似文件名的文件。但是,与其具有相似的文件名,甚至将文件名前缀与软件模块每个文件名唯一化可能更好。如果没有这些前缀,您最终将得到图 8-4 中显示的目录结构和文件名。

fluc 0804

图 8-4. 文件概述

所有属于一起的文件现在都在同一个目录中。这些文件按目录结构良好组织,并且可以使用相对路径访问其他目录中的头文件。

然而,相对路径带来了一个问题,即如果您想要重命名其中一个目录,您还必须修改其他源文件以修复其包含路径。这是您不希望的依赖性,通过拥有全局包含目录可以摆脱这种依赖性。

全局包含目录

上下文

您有头文件,并且将代码结构化为软件模块目录。

问题

要包含来自其他软件模块的文件,您必须使用像 ../othersoftwaremodule/file.h 这样的相对路径。您必须知道其他头文件的确切位置。

如果其他头文件的路径发生变化,您必须更改包含该头文件的代码。例如,如果其他软件模块被重命名,您也必须更改代码。因此,您对其他软件模块的名称和位置有依赖性。

作为开发者,您希望清楚地看到哪些头文件属于您应该使用的软件模块的 API,哪些头文件是内部头文件,外部人员不应该使用。

解决方案

在代码库中有一个全局目录,包含所有软件模块的 API。将此目录添加到您的工具链的全局包含路径中。

将所有只由一个软件模块使用的实现文件和所有头文件放在该软件模块的目录中。如果一个头文件也被其他代码使用,那么将其放在通用目录 /include 中,如图 8-5 和下面的代码所示。

fluc 0805

图 8-5. 文件概述

配置的全局包含路径为 /include

somecode.c

#include <somecode.h>
#include <othercode.h>
#include "morecode.h"
...

morecode.c

#include "morecode.h"
...

othercode.c

#include <othercode.h>
...

上述代码摘录显示了如何包含文件。请注意,不再使用相对路径。为了在这段代码中更清晰地表明从全局包含路径包含哪些文件,所有这些文件在#include语句中都使用尖括号包含。

#include 语法

对于所有包含的文件,也可以使用带引号的语法(#include "stdio.h")。大多数 C 预处理器首先通过相对路径查找这些包含文件,如果找不到,则在系统配置的全局目录中查找并被工具链使用。在 C 中,通常使用尖括号的语法(#include <stdio.h>),仅在从代码库外部包含文件时搜索全局目录。但如果它们没有通过相对路径包含,则该语法也可以用于您自己的代码库中的文件。

全局包含路径必须在工具链的构建设置中配置,或者如果您手动编写 Makefile 和编译器命令,则必须在那里添加包含路径。

如果此目录中的头文件数量增加,或者有仅被少数软件模块使用的非常特定的头文件,您应考虑将代码库拆分为自包含组件。

结果

很明确哪些头文件应由其他软件模块使用,哪些头文件是内部的,仅应在此软件模块内部使用。

现在不再需要使用相对目录来包含来自其他软件模块的文件。但是其他软件模块的代码不再位于单个目录中,而是分散在您的代码库中。

将所有 API 放入一个目录中可能会导致此目录中有许多文件,这将使查找属于一起的文件变得困难。您必须小心,不要让整个代码库的所有头文件都放在一个包含目录中。这将削弱具有软件模块目录的好处。如果软件模块 A 是唯一需要软件模块 B 接口的模块,那该怎么办?使用建议的解决方案,您将把软件模块 B 的接口放入全局包含目录中。然而,如果没有其他人需要这些接口,则可能不希望它们对代码库中的每个人都可用。为了避免这个问题,请使用自包含组件。

已知用途

以下示例展示了此模式的应用:

  • OpenSSL 的代码有一个/include目录,其中包含多个软件模块中使用的所有头文件。

  • 游戏 NetHack 的代码将所有的头文件放在目录/include中。实现部分未按软件模块组织,而是放在一个单独的/src目录中。

  • Linux 的 OpenZFS 代码有一个名为/include的全局目录,其中包含所有头文件。此目录在实现文件所在的目录中的 Makefile 中配置为包含路径。

应用于运行示例

您的代码库中头文件的位置发生了变化。您将它们移动到了配置在工具链中的全局包含目录。现在您可以简单地包含文件,而无需搜索相对文件路径。请注意,由于此原因,现在使用尖括号而不是引号来进行#include语句:

main.c

#include <stdio.h>
#include <stdlib.h>
#include <adlerhash.h>
#include <bernsteinhash.h>
#include <filereader.h>

int main(int argc, char* argv[])
{
  char* buffer = malloc(100);
  getFileContent(buffer, 100);

  unsigned int hash = adler32hash(buffer, 100);
  printf("Adler32 hash value: %u", hash);

  hash = bernsteinHash(buffer, 100);
  printf("Bernstein hash value: %u", hash);

  return 0;
}

在您的代码中,您现在使用文件组织和全局包含路径/include在您的工具链中进行配置,如图 8-6 所示。

fluc 0806

图 8-6. 文件概览

现在,即使您重命名其中一个目录,也无需触及实现文件。因此,您将实现进一步解耦。

接下来,您希望扩展代码。您希望使用哈希函数不仅对文件内容进行哈希处理,而且在另一个应用程序上下文中使用,基于哈希函数计算伪随机数。您希望能够独立开发这两个应用程序,两者都使用哈希函数,甚至可能由独立的开发团队进行开发。

与另一个开发团队共享一个全局包含目录是不可行的选择,因为您不希望在不同团队之间混合代码文件。您希望尽可能地将这两个应用程序分开。为此,请将它们组织为自包含组件。

自包含组件

上下文

您有软件模块目录,可能还有一个全局包含目录。软件模块的数量不断增加,您的代码变得越来越大。

问题

从目录结构中无法看出代码的依赖关系。任何软件模块都可以简单地包含来自任何其他软件模块的头文件,因此无法通过编译器检查代码的依赖关系。

通过使用相对路径可以包含头文件,这意味着任何软件模块都可以包含来自任何其他软件模块的头文件。

随着软件模块数量的增长,保持对其的概述变得困难。就像之前使用软件模块目录一样,在一个单一目录中有太多文件时,现在您有太多软件模块目录。

与依赖项类似,无法从代码结构中看出代码的责任。如果多个开发团队共同开发代码,您可能希望定义谁负责哪个软件模块。

解决方案

识别包含类似功能的软件模块,并应共同部署。将这些软件模块放入一个共同的目录中,并为调用者相关的头文件指定一个子目录。

此外,这样一个包含所有头文件的软件模块组将被称为组件。与软件模块相比,组件通常更大,并且可以独立于代码库的其余部分部署。

在对软件模块进行分组时,请检查您的代码中哪些部分可以独立于代码库的其余部分部署。查看由单独团队开发的代码的哪些部分,因此这些部分可能以只对代码库的其余部分松散耦合的方式开发。这样的软件模块组是组件的候选者。

如果你有一个全局包含目录,请将你的组件中所有的头文件从那个目录移动到你的组件指定目录中(例如,myComponent/include)。使用该组件的开发人员可以将此路径添加到其工具链中的全局包含路径中,或者可以相应地修改 Makefile 和编译器命令。

您可以使用工具链检查一个组件中的代码是否只使用允许使用的功能。例如,如果您有一个抽象操作系统的组件,您可能希望所有其他代码都使用该抽象,并且不使用特定于操作系统的功能。您可以配置您的工具链,将操作系统特定函数的包含路径仅设置为抽象操作系统接口的目录。然后,一个不熟悉的开发人员,不知道有一个操作系统抽象层,并尝试直接使用操作系统特定功能的开发人员,将不得不使用这些函数声明的相对包含路径来使代码编译通过(这将希望能够阻止开发人员这样做)。

图 8-7 和下面的代码展示了文件结构和包含文件路径。

fluc 0807

图 8-7. 文件概览

配置的全局包含路径:

  • /somecomponent/include

  • /nextcomponent/include

somecode.c

#include <somecode.h>
#include <othercode.h>
#include "morecode.h"
...

morecode.c

#include "morecode.h"
...

othercode.c

#include <othercode.h>
...

nextcode.c

#include <nextcode.h>
#include <othercode.h> // use API of other component
...

结果

软件模块组织良好,易于找到彼此相关的软件模块。如果组件分离得当,那么应该清楚每个组件应该添加哪种新代码。

将所有归属于同一目录的内容放在单个目录中,这样在工具链中配置该组件的特定内容会更加容易。例如,您可以为代码库中创建的新组件设置更严格的编译器警告,并且可以自动检查组件之间的代码依赖关系。

在多团队开发代码时,组件目录使得在团队之间设置责任更加容易,因为这些组件通常彼此之间的耦合度非常低。即使整体产品的功能可能不依赖于这些组件,也比在软件模块级别上分割责任更容易。

已知用途

以下示例展示了此模式的应用:

  • GCC 代码具有独立的组件,每个组件都有自己的目录,收集其头文件。例如,/libffi/includelibcpp/include

  • 操作系统 RIOT 将其驱动程序组织到良好分离的目录中。例如,目录/drivers/xbee/drivers/soft_spi中的每个都包含一个include子目录,其中包含该软件模块的所有接口。

  • Radare 反向工程框架具有良好分离的组件,每个组件都有自己的include目录,其中包含其所有接口。

应用于运行示例

您添加了使用其中一个哈希函数的伪随机数实现。除此之外,您还分离了代码的三个不同部分:

  • 哈希函数

  • 文件内容的哈希计算

  • 伪随机数计算

现在代码的所有三个部分都已经很好地分离开来,可以很容易地由不同的团队开发,甚至可以独立部署:

main.c

#include <stdio.h>
#include <stdlib.h>
#include <adlerhash.h>
#include <bernsteinhash.h>
#include <filereader.h>
#include <pseudorandom.h>

int main(int argc, char* argv[])
{
  char* buffer = malloc(100);
  getFileContent(buffer, 100);

  unsigned int hash = adler32hash(buffer, 100);
  printf("Adler32 hash value: %u", hash);

  hash = bernsteinHash(buffer, 100);
  printf("Bernstein hash value: %u", hash);

  unsigned int random = getRandomNumber(50);
  printf("Random value: %u", random);

  return 0;
}

randrandomapplication/include/pseudorandom.h

#ifndef PSEUDORANDOM_H
#define PSEUDORANDOM_H
/* Returns a pseudo random number lower than the
 provided maximum number (parameter `max')*/
unsigned int getRandomNumber(int max);
#endif

randomapplication/pseudorandom/pseudorandom.c

#include <pseudorandom.h>
#include <adlerhash.h>

unsigned int getRandomNumber(int max)
{
  char* seed = "seed-text";
  unsigned int random = adler32hash(seed, 10);
  return random % max;
}

现在您的代码具有以下目录结构。请注意,代码文件的每个部分都与其他部分很好地分离开来。例如,所有与哈希相关的代码都在一个目录中。对于使用这些函数的开发人员来说,很容易找到这些函数的 API,这些 API 在include目录中,如图 8-8 所示。

fluc 0808

图 8-8. 文件概述

对于此代码,在工具链中配置了以下全局包含目录:

  • /hashlibrary/include

  • /fileapplication/include

  • /randomapplication/include

现在代码已经很好地分离到不同的目录中,但仍然存在可以移除的依赖关系。查看包含路径。您有一个代码库,所有包含路径都用于所有代码。然而,对于哈希函数的代码,没有必要使用文件处理的包含路径。

此外,您编译所有代码并简单地将所有对象链接到一个可执行文件中。但是,您可能希望将该代码拆分并独立部署。您可能希望有一个打印哈希输出的应用程序,以及一个打印伪随机数的应用程序。这两个应用程序应该独立开发,但两者都应该使用例如相同的哈希函数代码,您不希望重复。

为了解耦应用程序,并以一种定义良好的方式访问来自其他部分的功能,而无需共享私有信息(例如这些部分的包含路径),您应该有一个 API 复制。

API 复制

上下文

您有一个大型的代码库,由不同团队开发。在代码库中,功能通过组织在软件模块目录中的头文件抽象出来。最好的情况是您有组织良好的自包含组件,并且这些接口已经存在一段时间,因此您相当确信它们是稳定的。

问题

您希望能够独立开发、版本化和部署代码库中的各个部分。然而,要做到这一点,您需要在代码部分之间具有明确定义的接口,并能够将该代码分隔到不同的存储库中。

如果您有自包含的组件,那么您已经接近成功。这些组件具有明确定义的接口,并且所有这些组件的代码已经在单独的目录中,因此它们可以轻松地检入到单独的存储库中。

但是组件之间仍然存在目录结构依赖:配置的包含路径。该路径仍然包括另一个组件代码的完整路径,例如,如果该组件的名称发生更改,您必须更改配置的包含路径。这是您不希望存在的依赖关系。

解决方案

为了使用另一个组件的功能,请复制其 API。单独构建另一个组件并复制生成的文件和其公共头文件。将这些文件放入您组件内的一个目录,并将该目录配置为全局包含路径。

复制代码似乎是一个坏主意。通常情况下确实如此,但在这里,您只复制另一个组件的接口。您复制头文件的函数声明,因此没有多个实现。想想当您安装第三方库时会发生什么:您也会复制其接口以访问其功能。

除了复制的头文件外,在构建组件时,您还必须使用其他构建生成物件。您可以将另一个组件版本化并部署为一个单独的库,您需要将其链接到您的组件中。图 8-9 和下面的代码展示了所涉文件的概览。

fluc 0809

图 8-9. 文件概览

somecomponent的配置全局包含路径:

  • /include

  • /include-from-nextcomponent

somecode.c

#include <somecode.h>
#include <othercode.h>
#include "morecode.h"
...

morecode.c

#include "morecode.h"
...

othercode.c

#include <othercode.h>
...

nextcomponent的配置全局包含路径:

  • /include

nextcode.c

#include <nextcode.h>
...

请注意,上述代码现在分成了两个不同的代码块。现在可以将代码拆分并放入单独的代码库,或者换句话说:拥有单独的代码库。组件之间不再涉及目录结构的依赖关系。然而,现在你面临的情况是,即使实现发生变化,不同版本的组件也必须确保它们的接口保持兼容。根据你的部署策略,你必须定义你想要提供哪种类型的接口兼容性(API 兼容或 ABI 兼容)。为了在保持兼容性的同时保持接口的灵活性,可以使用句柄、动态接口或函数控制。

接口兼容性

如果不需要在调用方的代码中做任何更改,则应用程序编程接口(API)保持兼容。如果例如向现有函数添加另一个参数,或者更改返回值或参数的类型,则会破坏 API 兼容性。

如果不需要重新编译调用方的代码,则应用程序二进制接口(ABI)保持兼容。如果例如更改编译代码的平台,或者将编译器更新到具有与先前编译器版本不同的函数调用约定的新版本,那么就会破坏 ABI 兼容性。

后果

现在,组件之间不再涉及目录结构的依赖关系。可以重命名其中一个组件,而无需更改其他组件代码的包含指令(或者现在称之为其他代码库)。

现在代码可以检入不同的代码库,完全不需要知道其他组件的路径以包含它们的头文件。要获取另一个组件的头文件,只需复制它。因此,最初你必须知道从哪里获取头文件和构建产物。也许其他组件提供了某种设置安装程序,或者只是提供了所有所需文件的版本列表。

为了利用拆分的代码库的主要优势:独立开发和版本控制,你需要达成接口兼容的协议。对于提供这些接口的组件的开发有一定限制,因为一旦一个函数可以被其他人使用,它就不能再自由修改了。即使是兼容性的更改,比如向现有头文件添加新函数,也可能变得更加困难。这是因为你会在不同版本的头文件中提供不同功能集,这使得你的调用方更难知道应该使用哪个版本的头文件。同时,这也使得编写可以与任何版本头文件一起使用的代码变得困难。

你购买了分离代码库的灵活性,但必须应对 API 兼容性要求以及构建过程中的更多复杂性(复制头文件、保持同步、链接其他组件、版本化接口)。

版本号

版本接口的方式应指定新版本是否带来不兼容的更改。通常使用 语义化版本 来指示版本号中是否存在主要更改。使用语义化版本,您的接口有一个三位数的版本号(例如,1.0.7),只有第一个数字的更改表示有不兼容的更改。

已知应用

以下示例展示了这种模式的应用:

  • Wireshark 复制独立部署的 Kazlib 的 API,以使用其异常仿真功能。

  • B&R Visual Components 软件从底层的 Automation Runtime 操作系统中访问功能。Visual Components 软件是独立部署和版本化的,与 Automation Runtime 分开。为了访问 Automation Runtime 的功能,其公共头文件被复制到 Visual Components 代码库中。

  • Education First 公司开发数字学习产品。在他们的 C 代码中,构建软件时将包含文件复制到全局包含目录,以解耦其代码库中的组件。

应用于运行示例

现在代码的不同部分已经很好地分离。哈希实现具有明确定义的接口,用于打印文件哈希和生成伪随机数的代码。此外,这些代码部分已经分离到不同的目录中。甚至其他组件的 API 也被复制,以便所有需要被一个组件访问的代码都位于自己的目录中。每个组件的代码甚至可以存储在自己的代码库中,并且可以独立部署和版本化,与其他组件分开。

实现本身没有改变。只是其他组件的 API 被复制了,代码库的包含路径也改变了。哈希代码现在甚至与主应用程序隔离开来。哈希代码被视为独立部署的组件,只与应用程序的其余部分链接。示例 8-1 展示了您的主应用程序的代码,现在已从哈希库中分离出来。

示例 8-1. 主应用程序的代码

main.c

#include <stdio.h>
#include <stdlib.h>
#include <adlerhash.h>
#include <bernsteinhash.h>
#include <filereader.h>
#include <pseudorandom.h>

int main(int argc, char* argv[])
{
  char* buffer = malloc(100);
  getFileContent(buffer, 100);

  unsigned int hash = adler32hash(buffer, 100);
  printf("Adler32 hash value: %u\n", hash);

  hash = bernsteinHash(buffer, 100);
  printf("Bernstein hash value: %u\n", hash);

  unsigned int random = getRandomNumber(50);
  printf("Random value: %u\n", random);

  return 0;
}

randomapplication/include/pseudorandom.h

#ifndef PSEUDORANDOM_H
#define PSEUDORANDOM_H
/* Returns a pseudorandom number lower than the provided maximum number
 (parameter `max')*/
unsigned int getRandomNumber(int max);
#endif

randomapplication/pseudorandom/pseudorandom.c

#include <pseudorandom.h>
#include <adlerhash.h>

unsigned int getRandomNumber(int max)
{
  char* seed = "seed-text";
  unsigned int random = adler32hash(seed, 10);
  return random % max;
}

fileapplication/include/filereader.h

#ifndef FILEREADER_H
#define FILEREADER_H
/* Reads the content of a file and stores it in the provided "buffer"
 if is is long enough according to its provided "length" */
void getFileContent(char* buffer, int length);
#endif
_fileapplication/filereader/filereader.c_
#include <stdio.h>
#include "filereader.h"

void getFileContent(char* buffer, int length)
{
  FILE* file = fopen("SomeFile", "rb");
  fread(buffer, length, 1, file);
  fclose(file);
}

此代码具有图 8-10 中显示的目录结构和包含路径以及以下代码示例。请注意,此代码库不再包含关于哈希实现的源代码。通过包含复制的头文件访问哈希功能,然后在构建过程中将 .a 文件链接到代码中。

fluc 0810

图 8-10. 文件概述

配置包含路径:

  • /hashlibrary

  • /fileapplication/include

  • /randomapplication/include

示例 8-2 中的哈希实现现在由其自己的存储库管理。每当代码更改时,都可以发布哈希库的新版本。这意味着必须将编译为该库的对象文件复制到其他代码中,只要哈希库的 API 不改变,就不需要做更多事情。

示例 8-2. 哈希库的代码

inc/adlerhash.h

#ifndef ADLERHASH_H
#define ADLERHASH_H
/* Returns the hash value of the provided "buffer" of size "length".
 The hash is calculated according to the Adler32 algorithm. */
unsigned int adler32hash(const char* buffer, int length);
#endif

adler/adlerhash.c

#include "adlerhash.h"

unsigned int adler32hash(const char* buffer, int length)
{
  unsigned int s1=1;
  unsigned int s2=0;
  int i=0;

  for(i=0; i<length; i++)
  {
    s1=(s1+buffer[i]) % 65521;
    s2=(s1+s2) % 65521;
  }
  return (s2<<16) | s1;
}

inc/bernsteinhash.h

#ifndef BERSTEINHASH_H
#define BERNSTEINHASH_H
/* Returns the hash value of the provided "buffer" of size "length".
 The hash is calculated according to the D.J. Bernstein algorithm. */
unsigned int bernsteinHash(const char* buffer, int length);
#endif

bernstein/bernsteinhash.c

#include "bernsteinhash.h"

unsigned int bernsteinHash(const char* buffer, int length)
{
  unsigned int hash = 5381;
  int i;
  for(i=0; i<length; i++)
  {
    hash = 33 * hash ^ buffer[i];
  }
  return hash;
}

此代码具有图 8-11 所示的目录结构和包含路径。请注意,关于文件处理或伪随机数计算的源代码不再是此代码库的一部分。这里的代码库是通用的,也可以在其他上下文中使用。

fluc 0811

图 8-11. 文件概述

配置包含路径:

  • /include

从简单的哈希应用程序开始,我们最终得到了这段代码,可以让您分开开发和部署哈希代码,与其应用程序分开。更进一步,这两个应用程序甚至可以分成独立的部分,可以分别部署。

按照本示例建议的目录结构组织并不是使代码模块化的最重要问题。本章和本示例中未明确解决的许多更重要问题,例如代码依赖性,可以通过应用 SOLID 原则来解决。但是,一旦设置了使代码模块化的依赖关系,如本示例所示的目录结构将使得更容易分割代码的所有权,并使其能够独立于代码库的其他部分进行版本控制和部署。

概述

本章介绍了如何结构化源代码和头文件,以构建大型模块化的 C 程序的模式。

包含保护模式确保头文件不会被多次包含。软件模块目录建议将一个软件模块的所有文件放入一个目录。全局包含目录建议将所有被多个软件模块使用的头文件放入一个全局目录。对于更大的程序,自包含组件建议为每个组件使用一个全局头文件目录。为了解耦这些组件,API 复制建议复制头文件和从其他组件使用的构建工件。

所提出的模式在某种程度上是相互构建的。如果先前的模式已经应用,那么本章后面的模式可以更容易地应用。在将所有模式应用到代码库后,代码库达到了一个高度的灵活性,可以分开开发和部署其部分内容。然而,这种灵活性并非总是需要的,并且它并非免费的:每个模式都会向您的代码库增加复杂性。特别是对于非常小的代码库,可能不需要分开部署其部分内容,因此可能不需要应用 API Copy。甚至只应用 Header Files 和 Include Guard 就可能已经足够了。不要盲目地应用所有模式。相反,只有在面对模式描述的问题并且解决这些问题值得增加复杂性时,才应用它们。

将这些模式作为编程词汇的一部分,C 程序员拥有一个工具箱,并逐步指导如何构建模块化的 C 程序并组织它们的文件。

展望

下一章涵盖了许多大型程序的一个方面:处理多平台代码。本章介绍了如何实现代码的模式,使得在多处理器架构或多操作系统中有一个单一的代码库更容易。

第九章:逃离#ifdef地狱

C 语言广泛应用于需要高性能或接近硬件编程的系统中。与接近硬件编程相关的是处理硬件变体的必要性。除了硬件变体,一些系统在代码中支持多个操作系统或处理多个产品变体。解决这些问题的常用方法是使用 C 预处理器的#ifdef语句来区分代码中的变体。C 预处理器提供了这种功能,但使用它需要按照良好的结构化方式使用。

然而,这也揭示了 C 预处理器及其#ifdef语句的弱点。C 预处理器不支持强制其使用规则的方法。这很遗憾,因为它很容易被滥用。通过添加另一个#ifdef,很容易为代码添加另一个硬件变体或另一个可选功能。此外,#ifdef语句很容易被滥用来添加仅影响单个变体的快速错误修复。这使得不同变体的代码更加多样化,并导致需要为每个变体单独修复代码的情况越来越多。

在这种非结构化和临时的方式中使用#ifdef语句是通往地狱的必定路径。代码变得难以阅读和维护,所有开发人员都应该避免。本章介绍了几种摆脱这种情况或完全避免的方法。

本章详细介绍了如何在 C 代码中实现操作系统变体或硬件变体。它讨论了五种处理代码变体的模式以及如何组织或甚至摆脱#ifdef语句。这些模式可以视为组织此类代码的入门或重构非结构化#ifdef代码的指南。

图 9-1 展示了逃离#ifdef噩梦的方式,表 9-1 提供了本章讨论的模式的简要总结。

逃离噩梦的方法

图 9-1. 逃离#ifdef地狱的方式

表格 9-1. 逃离#ifdef地狱的模式

模式名称 摘要
避免变体 在每个平台上使用不同的函数使得代码更难读写。程序员需要最初理解、正确使用和测试这些多个函数,以实现跨多个平台的单一功能。因此,应使用在所有平台上都可用的标准化函数。如果没有标准化函数,则考虑不实现此功能。
隔离原语 使用#ifdef语句组织代码变体使得代码难以阅读。很难跟踪程序流程,因为为多个平台多次实现了代码。因此,隔离您的代码变体。在您的实现文件中,将处理变体的代码放入单独的函数,并从主程序逻辑调用这些函数,这样主程序逻辑只包含与平台无关的代码。
原子原语 包含变体的函数仍然难以理解,因为所有复杂的#ifdef代码仅放入该函数以便在主程序中摆脱它。因此,将您的原语设为原子。每个函数仅处理一种变体类型。如果处理多种类型的变体,例如操作系统变体和硬件变体,则为每种类型单独创建函数。
抽象层 你希望在代码库中的多个地方使用处理平台变体的功能,但不希望复制该功能的代码。因此,为每个需要特定于平台的功能提供一个 API。在头文件中只定义与平台无关的函数,并将所有特定于平台的#ifdef代码放入实现文件中。调用您函数的人只需包含您的头文件,而不需要包含任何特定于平台的文件。
拆分变体实现 仍然包含#ifdef语句的特定于平台的实现以区分代码变体。这使得难以看到并选择应为哪个平台构建的代码部分。因此,将每个变体实现放入单独的实现文件中,并选择每个文件为哪个平台编译。

运行示例

假设你想要实现将一些文本写入文件的功能,该文件将存储在新创建的目录中,具体取决于配置标志,可能是当前目录或主目录。为了使事情更复杂,您的代码应该在 Windows 系统和 Linux 系统上运行。

你的第一次尝试是创建一个包含所有配置和操作系统代码的实现文件。为了做到这一点,该文件包含许多#ifdef语句来区分代码变体:

#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#ifdef __unix__
  #include <sys/stat.h>
  #include <fcntl.h>
  #include <unistd.h>
#elif defined _WIN32
  #include <windows.h>
#endif

int main()
{
  char dirname[50];
  char filename[60];
  char* my_data = "Write this data to the file";
  #ifdef __unix__
    #ifdef STORE_IN_HOME_DIR
      sprintf(dirname, "%s%s", getenv("HOME"), "/newdir/");
      sprintf(filename, "%s%s", dirname, "newfile");
    #elif defined STORE_IN_CWD
      strcpy(dirname, "newdir");
      strcpy(filename, "newdir/newfile");
    #endif
    mkdir(dirname,S_IRWXU);
    int fd = open (filename, O_RDWR | O_CREAT, 0666);
    write(fd, my_data, strlen(my_data));
    close(fd);
  #elif defined _WIN32
    #ifdef STORE_IN_HOME_DIR
      sprintf(dirname, "%s%s%s", getenv("HOMEDRIVE"), getenv("HOMEPATH"),
              "\\newdir\\");
      sprintf(filename, "%s%s", dirname, "newfile");
    #elif defined STORE_IN_CWD
      strcpy(dirname, "newdir");
      strcpy(filename, "newdir\\newfile");
    #endif
    CreateDirectory (dirname, NULL);
    HANDLE hFile = CreateFile(filename, GENERIC_WRITE, 0, NULL,
                              CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL);
    WriteFile(hFile, my_data, strlen(my_data), NULL, NULL);
    CloseHandle(hFile);
  #endif
  return 0;
}

这种代码是混乱的。程序逻辑完全重复。这不是操作系统无关的代码;相反,它只是将两个不同的操作系统特定实现放入一个文件中。特别是,不同操作系统和不同目录创建位置的正交代码变体使代码变得丑陋,因为它们导致嵌套的#ifdef语句,这些语句非常难以理解。在阅读代码时,你必须不断地在这些行之间跳跃。你必须跳过其他#ifdef分支中的代码,以便跟随程序逻辑。这种重复的程序逻辑促使程序员只在当前工作的代码变体中修复错误或添加新功能。这导致代码片段和变体的行为分离,使代码难以维护。

从哪里开始?如何整理这一混乱局面?作为第一步,如果可能的话,你可以使用标准化函数以避免变体。

避免变体

上下文

当你编写可在多个操作系统平台或多个硬件平台上使用的可移植代码时,你可能会调用一些函数,这些函数在一个平台上可用,但在另一个平台上的语法和语义可能不完全相同。因此,你需要实现不同的代码变体——每个平台一个。现在你在不同平台上有不同的代码片段,并且在代码中使用#ifdef语句区分这些变体。

问题

为每个平台使用不同的函数使得代码更难阅读和编写。程序员需要首先理解、正确使用和测试这些多个函数,才能在多个平台上实现单一功能。

很多时候,我们的目标是在所有平台上实现行为完全相同的功能,但是当使用依赖于平台的函数时,这一目标变得更加困难,并且可能需要编写额外的代码。这是因为函数的语法和语义在各平台间可能略有不同。

对多个平台使用多个函数使得代码更难编写、阅读和理解。用#ifdef语句区分不同函数使得代码变得更长,并要求读者在多个行之间跳跃以查明单个#ifdef分支的代码功能。

对于你需要编写的任何代码片段,你可以问自己这样一个问题:这是否值得努力?如果所需功能并不重要,并且如果特定于平台的函数使得实现和支持该功能非常困难,那么有可能选择根本不提供该功能。

解决方案

使用在所有平台上都可用的标准化函数。如果没有标准化函数可用,则考虑不实现该功能。

可以使用的良好的标准化函数示例包括 C 标准库函数和 POSIX 函数。考虑要支持的平台,并检查这些标准化函数在所有平台上是否可用。如果可能的话,应该使用这些标准化函数,而不是更特定的依赖平台的函数,如下面代码所示:

调用者的代码

#include <standardizedApi.h>

int main()
{
  /* just a single function is called instead of multiple via
 ifdef distinguished functions */
  somePosixFunction();
  return 0;
}

标准化的 API

  /* this function is available on all operating systems
 that adhere to the POSIX standard */
  somePosixFunction();

再次强调,如果没有您想要的标准化函数,那么您可能不应该实现所请求的功能。如果仅有依赖于平台的函数可用于您要实现的功能,则可能不值得进行实现、测试和维护工作。

然而,在某些情况下,即使没有标准化的函数可用,您也必须在产品中提供功能。这意味着您必须在不同平台上使用不同的函数,或者甚至在一个平台上实现已在另一个平台上可用的功能。为了以结构化的方式实现这一点,对于您的代码变体,请使用隔离原语,并将其隐藏在一个抽象层后面。

例如,为了避免变体,您可以使用 C 标准库文件访问函数如 fopen,而不是使用操作系统特定的函数如 Linux 的 open 或 Windows 的 CreateFile 函数。另一个例子,您可以使用 C 标准库的时间函数。避免使用操作系统特定的时间函数,如 Windows 的 GetLocalTime 和 Linux 的 localtime_r,而应该使用 time.h 中的标准化 localtime 函数。

结果

代码写起来和读起来都很简单,因为可以为多个平台使用同一段代码。在编写代码时,程序员不必理解不同平台的不同函数,而在阅读代码时也不必在 #ifdef 分支之间跳转。

由于相同的代码片段在所有平台上都被使用,功能不会有所不同。但标准化的函数可能不是在每个平台上实现所需功能的最有效或高性能方式。一些平台可能提供其他平台特定的函数,例如使用该平台上的专用硬件来实现更高性能。这些优势可能无法被标准化函数利用。

已知的用途

下面的例子展示了这种模式的应用:

  • VIM 文本编辑器的代码使用操作系统独立的函数 fopenfwritefreadfclose 来访问文件。

  • OpenSSL 代码将当前的本地时间写入其日志消息中。为此,它使用操作系统独立函数 localtime 将当前的 UTC 时间转换为本地时间。

  • OpenSSL 函数 BIO_lookup_ex 查找要连接的节点和服务。此函数在 Windows 和 Linux 上编译,并使用操作系统独立函数 htons 将值转换为网络字节顺序。

应用于运行示例

对于访问文件功能,您处于幸运的位置,因为现在有可用的操作系统无关函数。现在您有以下代码:

#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#ifdef __unix__
  #include <sys/stat.h>
#elif defined _WIN32
  #include <windows.h>
#endif

int main()
{
  char dirname[50];
  char filename[60];
  char* my_data = "Write this data to the file";
  #ifdef __unix__
    #ifdef STORE_IN_HOME_DIR
      sprintf(dirname, "%s%s", getenv("HOME"), "/newdir/");
      sprintf(filename, "%s%s", dirname, "newfile");
    #elif defined STORE_IN_CWD
      strcpy(dirname, "newdir");
      strcpy(filename, "newdir/newfile");
    #endif
    mkdir(dirname,S_IRWXU);
  #elif defined _WIN32
    #ifdef STORE_IN_HOME_DIR
      sprintf(dirname, "%s%s%s", getenv("HOMEDRIVE"), getenv("HOMEPATH"),
              "\\newdir\\");
      sprintf(filename, "%s%s", dirname, "newfile");
    #elif defined STORE_IN_CWD
      strcpy(dirname, "newdir");
      strcpy(filename, "newdir\\newfile");
    #endif
    CreateDirectory(dirname, NULL);
  #endif
  FILE* f = fopen(filename, "w+"); ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
  fwrite(my_data, 1, strlen(my_data), f);
  fclose(f);
  return 0;
}

1

函数fopenfwritefclose属于 C 标准库的一部分,可以在 Windows 和 Linux 上使用。

该代码中的标准化文件相关函数调用已经简化了事情。现在不再需要为 Windows 和 Linux 分别提供独立的文件访问调用,而是有了一个通用代码。通用代码确保这些调用在两个操作系统上执行相同的功能,并且不存在在 bug 修复或添加功能后两种不同实现运行不同的危险。

然而,由于您的代码仍然被#ifdef所主导,因此阅读起来非常困难。因此,请确保您的主程序逻辑不会被代码变体混淆。使用隔离的原语将代码变体与主程序逻辑分开。

隔离的原语

背景

您的代码调用特定于平台的函数。您为不同平台编写了不同的代码片段,并使用#ifdef语句区分代码变体。您不能简单地避免变体,因为没有可用的标准化函数可以在所有平台上以统一的方式提供所需功能。

问题

使用#ifdef语句组织代码变体会使代码变得难以阅读。程序流程非常难以跟踪,因为为多个平台实现了多次。

在尝试理解代码时,通常只关注一个平台,但#ifdef强制您在代码行之间跳转,以找到感兴趣的代码变体。

#ifdef语句还使代码难以维护。这些语句促使程序员仅修复他们感兴趣的平台上的代码,并因危险破坏其他代码而不敢触碰其他代码。但仅为一个平台修复错误或引入新功能意味着代码在其他平台上的行为会分歧。另一种选择——以不同方式在所有平台上修复此类错误——需要在所有平台上测试代码。

使用许多代码变体测试代码很困难。每个新的#ifdef语句都会使测试工作量加倍,因为必须测试所有可能的组合。更糟糕的是,每个这样的语句都会使可以构建和必须测试的二进制文件数量加倍。这带来了一个物流问题,因为构建时间增加,向测试部门和客户提供的二进制文件数量也增加。

解决方案

隔离您的代码变体。在实现文件中,将处理变体的代码放入单独的函数中,并从主程序逻辑中调用这些函数,这样主程序逻辑只包含与平台无关的代码。

每个函数应该只包含程序逻辑或仅处理变体。不能有函数同时包含两者。因此,函数中要么根本没有#ifdef语句,要么有带有单个依赖于变体的函数调用的#ifdef分支。这样的变体可以是构建配置中打开或关闭的软件功能,也可以是平台变体,如下面的代码所示:

void handlePlatformVariants()
{
  #ifdef PLATFORM_A
    /* call function of platform A */
  #elif defined PLATFORM_B ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
    /* call function of platform B */
  #endif
}

int main()
{
  /* program logic goes here */
  handlePlatformVariants();
  /* program logic continues */
}

1

类似于else if语句,互斥的变体可以使用#elif表达得很好。

每个#ifdef分支每次调用一个函数应该使得能够找到处理变体的函数的良好抽象粒度。通常,粒度正好在可用的特定于平台或特性的函数包装级别上。

如果处理变体的函数仍然复杂并包含#ifdef级联(嵌套的#ifdef语句),则有助于确保仅有原子变体。

结果

现在可以轻松跟踪主程序逻辑,因为代码变体已与其分离。在阅读主代码时,不再需要在行之间跳跃以查明代码在特定平台上的作用。

要确定代码在特定平台上的作用,必须查看实现此变体的调用函数。将该代码放在单独调用的函数中具有优势,因为可以从文件中的其他位置调用它,从而避免了代码重复。如果其他实现文件中也需要此功能,则必须实现抽象层。

不应在处理变体的函数中引入任何程序逻辑,因此更容易精确定位不在所有平台上发生的错误,因为可以轻松识别代码中平台行为不同的位置。

由于主程序逻辑与变体实现明确分离,代码重复不再是问题。不再有重复程序逻辑的诱惑,因此不会意外地仅在这些复制中的一个中进行错误修复。

已知用途

以下示例展示了此模式的应用:

  • VIM 文本编辑器的代码隔离了将数据转换为网络字节顺序的函数htonl2。VIM 的程序逻辑在实现文件中将htonl2定义为宏。该宏根据平台的字节序编译方式不同。

  • OpenSSL 函数BIO_ADDR_make将套接字信息复制到内部struct中。该函数使用#ifdef语句处理特定于操作系统和特性的变体,区分 Linux/Windows 和 IPv4/IPv6。该函数将这些变体与主程序逻辑隔离开来。

  • GNUplot 的函数load_rcfile从初始化文件中读取数据,并将操作系统特定的文件访问操作与其余代码隔离开来。

应用于运行示例

现在你有了孤立的基元,你的主程序逻辑变得更容易阅读,不需要读者在各种变体之间跳来跳去了:

void getDirectoryName(char* dirname)
{
  #ifdef __unix__
    #ifdef STORE_IN_HOME_DIR
      sprintf(dirname, "%s%s", getenv("HOME"), "/newdir/");
    #elif defined STORE_IN_CWD
      strcpy(dirname, "newdir/");
    #endif
  #elif defined _WIN32
    #ifdef STORE_IN_HOME_DIR
      sprintf(dirname, "%s%s%s", getenv("HOMEDRIVE"), getenv("HOMEPATH"),
              "\\newdir\\");
    #elif defined STORE_IN_CWD
      strcpy(dirname, "newdir\\");
    #endif
  #endif
}

void createNewDirectory(char* dirname)
{
  #ifdef __unix__
    mkdir(dirname,S_IRWXU);
  #elif defined _WIN32
    CreateDirectory (dirname, NULL);
  #endif
}

int main()
{
  char dirname[50];
  char filename[60];
  char* my_data = "Write this data to the file";
  getDirectoryName(dirname);
  createNewDirectory(dirname);
  sprintf(filename, "%s%s", dirname, "newfile");
  FILE* f = fopen(filename, "w+");
  fwrite(my_data, 1, strlen(my_data), f);
  fclose(f);
  return 0;
}

现在代码变体已经很好地隔离开来了。main函数的程序逻辑非常容易阅读和理解,没有变体的干扰。然而,新函数getDirectoryName仍然被#ifdef主导,不容易理解。只有原子基元可能会有所帮助。

原子基元

上下文

你在代码中用#ifdef语句实现了变体,并将这些变体放入了单独的函数中,以处理这些变体。这些基元将变体从主程序流中分离出来,使得主程序结构清晰,易于理解。

问题

包含变体并被主程序调用的函数仍然很难理解,因为所有复杂的#ifdef代码只是为了在主程序中摆脱它。

处理所有种类的变体在一个函数中变得困难,一旦有许多不同的变体需要处理。例如,如果单个函数使用#ifdef语句区分不同的硬件类型和操作系统,则添加新的操作系统变体会变得困难,因为必须为所有硬件变体添加。每种变体不能再在一个地方处理;相反,随着不同变体数量的增加,工作量也会成倍增加。这是一个问题。在代码中添加新的变体应该是简单的。

解决方案

确保你的基元是原子的。每个函数只处理一种变体。如果你处理多种变体,例如操作系统变体和硬件变体,那么应该为每种情况编写独立的函数。

让其中一个函数调用另一个已经抽象化了一种变体的函数。如果你抽象了一个平台依赖和一个特性依赖,那么让处理特性的函数调用处理平台的函数,因为通常你会在所有平台上提供这些特性。因此,平台相关函数应该是最原子化的函数,如下面的代码所示:

void handleHardwareOfFeatureX()
{
  #ifdef HARDWARE_A
   /* call function for feature X on hardware A */
  #elif defined HARDWARE_B || defined HARDWARE_C
   /* call function for feature X on hardware B and C */
  #endif
}

void handleHardwareOfFeatureY()
{
  #ifdef HARDWARE_A
   /* call function for feature Y on hardware A */
  #elif defined HARDWARE_B
   /* call function for feature Y on hardware B */
  #elif defined HARDWARE_C
   /* call function for feature Y on hardware C */
  #endif
}

void callFeature()
{
  #ifdef FEATURE_X
    handleHardwareOfFeatureX();
  #elif defined FEATURE_Y
    handleHardwareOfFeatureY();
  #endif
}

如果有一个函数明显需要在多种变体之间提供功能,并处理所有这些变体,那么函数的范围可能有问题。也许函数过于通用或者功能不单一。按照“函数分割”模式建议,将函数进行拆分。

在包含程序逻辑的主代码中调用原子基元。如果你希望在具有明确定义接口的其他实现文件中使用原子基元,则应该使用抽象层。

结果

现在每个函数只处理一种变体。这使得每个函数易于理解,因为不再存在#ifdef语句的级联。每个函数现在只抽象一种变体,只做这一件事。因此,函数遵循单一责任原则。

没有#ifdef级联使得程序员在一个函数中处理额外的变体不那么诱人,因为开始一个#ifdef级联比扩展现有级联的可能性小。

通过独立的功能,每种变体都可以轻松地扩展为另一种变体。为了实现这一点,在一个函数中只需要添加一个#ifdef分支即可,而处理其他种类变体的函数则不需要改动。

已知用途

下面的示例展示了此模式的应用:

  • OpenSSL 实现文件threads_pthread.c包含了线程处理的函数。有用于抽象操作系统的独立函数,以及用于抽象 pthread 是否可用的独立函数。

  • SQLite 的代码包含用于抽象特定操作系统文件访问(例如fileStat函数)的函数。代码用其他独立函数抽象与文件访问相关的编译时特性。

  • Linux 函数boot_jump_linux调用另一个函数,根据处理器体系结构执行不同的引导操作,通过该函数中的#ifdef语句选择配置资源(USB、网络等)进行清理。

应用于运行示例

现在,对于函数确定目录路径,使用原子原语,你有以下代码:

void getHomeDirectory(char* dirname)
{
  #ifdef __unix__
    sprintf(dirname, "%s%s", getenv("HOME"), "/newdir/");
  #elif defined _WIN32
    sprintf(dirname, "%s%s%s", getenv("HOMEDRIVE"), getenv("HOMEPATH"),
            "\\newdir\\");
  #endif
}

void getWorkingDirectory(char* dirname)
{
  #ifdef __unix__
    strcpy(dirname, "newdir/");
  #elif defined _WIN32
    strcpy(dirname, "newdir\\");
  #endif
}

void getDirectoryName(char* dirname)
{
  #ifdef STORE_IN_HOME_DIR
    getHomeDirectory(dirname);
  #elif defined STORE_IN_CWD
    getWorkingDirectory(dirname);
  #endif
}

现在代码变体被非常好地隔离。为了获取目录名,现在没有一个复杂的函数有许多#ifdef,而是有几个只有一个#ifdef的函数。这使得理解代码变得更容易,因为现在每个函数只执行一件事情,而不是用#ifdef级联区分几种变体。

函数现在非常简单易读,但你的实现文件仍然很长。此外,一个实现文件既包含主程序逻辑,又包含区分变体的代码,这使得并行开发或在程序逻辑旁边独立测试变体代码几乎不可能。

为了改进,将实现文件分成依赖于变体和独立于变体的文件。为此,创建一个抽象层。

抽象层

上下文

在你的代码中,你有用#ifdef语句区分的平台变体。你可能有用于分离变体和确保具有原子原语的隔离原语,以确保在你的程序逻辑中分离变体。

问题

您希望在代码库中的多个位置使用处理平台变体的功能,但不希望复制该功能的代码。

您的调用者可能习惯于直接使用特定于平台的函数,但您不再希望如此,因为每个调用者都必须自行实现平台变体。通常情况下,调用者不应该处理平台变体。在调用者的代码中,不需要了解不同平台的实现细节,调用者也不需要使用任何 #ifdef 语句或包含任何特定于平台的头文件。

您甚至在考虑与不负责平台无关代码的不同程序员分开开发和测试平台相关代码。

您希望能够在不要求调用此代码的调用者关心此更改的情况下稍后更改特定于平台的代码。如果平台相关代码的程序员为一个平台修复错误或添加额外的平台,则不应要求调用者的代码进行更改。

解决方案

为每个需要特定于平台代码的功能提供一个 API。在头文件中仅定义平台无关函数,并将所有特定于平台的 #ifdef 代码放入实现文件中。您的函数的调用者仅包含您的头文件,不必包含任何特定于平台的文件。

尝试为抽象层设计稳定的 API,因为以后更改 API 将需要更改调用者的代码,有时这是不可能的。然而,设计稳定的 API 非常困难。对于平台抽象,请试着查看不同的平台,甚至是您尚未支持的平台。在了解它们的工作方式和差异之后,您可以创建一个 API 来为这些平台的特性进行抽象。这样,即使在为不同平台添加支持时,您也不需要后续更改 API。

确保彻底记录 API。添加注释到每个函数以描述其功能。此外,如果在整个代码库中未明确定义的话,请描述这些函数支持的平台。

下面的代码展示了一个简单的抽象层:

caller.c

#include "someFeature.h"

int main()
{
  someFeature();
  return 0;
}

someFeature.h

/* Provides generic access to someFeature.
 Supported on platform A and platform B. */
void someFeature();

someFeature.c

void someFeature()
{
  #ifdef PLATFORM_A
    performFeaturePlatformA();
  #elif defined PLATFORM_B
    performFeaturePlatformB();
  #endif
}

结果

抽象特性可以从代码的任何地方使用,而不仅仅是从单个实现文件中。换句话说,现在您有了调用者和被调用者的明确角色。被调用者必须处理平台变体,而调用者可以是平台无关的。

这种设置的好处是调用者无需处理特定于平台的代码。调用者只需包含提供的头文件,而无需包含任何特定于平台的头文件。缺点是调用者不能直接再使用所有特定于平台的函数。如果调用者习惯于这些函数,则可能不满意使用抽象功能,并可能发现使用或功能上不理想。

现在,平台特定的代码可以分开开发,甚至可以单独进行测试。现在测试工作量是可管理的,即使有多个平台,因为您可以模拟硬件特定代码,以便为平台独立代码编写简单的测试。

当为所有平台特定函数构建这种 API 时,这些函数和 API 的总和构成了代码库的平台抽象层。通过平台抽象层,非常清楚哪些代码是平台相关的,哪些是平台独立的。平台抽象层还清楚地表明,为了支持额外的平台,必须触及代码的哪些部分。

已知用途

以下示例展示了此模式的应用:

  • 大多数在多个平台上运行的大规模代码都有硬件抽象层。例如,诺基亚的 Maemo 平台有这样一个抽象层,用于抽象加载的实际设备驱动程序。

  • lighttpd web 服务器的sock_addr_inet_pton函数将 IP 地址从文本转换为二进制形式。该实现使用#ifdef语句区分 IPv4 和 IPv6 的代码变体。API 的调用者看不到这种区别。

  • gzip 数据压缩程序的getprogname函数返回调用程序的名称。获取该名称的方法取决于操作系统,并通过实现中的#ifdef语句进行区分。调用者无需关心函数在哪个操作系统上调用。

  • 硬件抽象用于本科论文“开源实时以太网堆栈的硬件抽象—设计、实现和评估”中描述的 Time-Triggered Ethernet 协议。硬件抽象层包含用于访问中断和定时器的函数。这些函数标记为inline以避免性能损失。

应用于运行示例

现在您有了一个更简化的代码片段。每个函数仅执行一个操作,并且您隐藏了关于不同变体的实现细节在 API 背后:

directoryNames.h

/* Copies the path to a new directory with name "newdir"
 located in the user's home directory into "dirname".
 Works on Linux and Windows. */
void getHomeDirectory(char* dirname);

/* Copies the path to a new directory with name "newdir"
 located in the current working directory into "dirname".
 Works on Linux and Windows. */
void getWorkingDirectory(char* dirname);

directoryNames.c

#include "directoryNames.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void getHomeDirectory(char* dirname)
{
  #ifdef __unix__
    sprintf(dirname, "%s%s", getenv("HOME"), "/newdir/");
  #elif defined _WIN32
    sprintf(dirname, "%s%s%s", getenv("HOMEDRIVE"), getenv("HOMEPATH"),
            "\\newdir\\");
  #endif
}

void getWorkingDirectory(char* dirname)
{
  #ifdef __unix__
    strcpy(dirname, "newdir/");
  #elif defined _WIN32
    strcpy(dirname, "newdir\\");
  #endif
}

directorySelection.h

/* Copies the path to a new directory with name "newdir" into "dirname".
 The directory is located in the user's home directory, if STORE_IN_HOME_DIR
 is set or it is located in the current working directory, if STORE_IN_CWD
 is set. */
void getDirectoryName(char* dirname);

directorySelection.c

#include "directorySelection.h"
#include "directoryNames.h"

void getDirectoryName(char* dirname)
{
  #ifdef STORE_IN_HOME_DIR
    getHomeDirectory(dirname);
  #elif defined STORE_IN_CWD
    getWorkingDirectory(dirname);
  #endif
}

directoryHandling.h

/* Creates a new directory of the provided name ("dirname").
 Works on Linux and Windows. */
void createNewDirectory(char* dirname);

directoryHandling.c

#include "directoryHandling.h"
#ifdef __unix__
  #include <sys/stat.h>
#elif defined _WIN32
  #include <windows.h>
#endif

void createNewDirectory(char* dirname)
{
  #ifdef __unix__
    mkdir(dirname,S_IRWXU);
  #elif defined _WIN32
    CreateDirectory (dirname, NULL);
  #endif
}

main.c

#include <stdio.h>
#include <string.h>
#include "directorySelection.h"
#include "directoryHandling.h"

int main()
{
  char dirname[50];
  char filename[60];
  char* my_data = "Write this data to the file";
  getDirectoryName(dirname);
  createNewDirectory(dirname);
  sprintf(filename, "%s%s", dirname, "newfile");
  FILE* f = fopen(filename, "w+");
  fwrite(my_data, 1, strlen(my_data), f);
  fclose(f);
  return 0;
}

你的主程序逻辑文件最终完全独立于操作系统;特定于操作系统的头文件甚至没有包含在这里。使用抽象层分离实现文件使文件更易于理解,并且使得可以在代码的其他部分重用这些函数。此外,开发、维护和测试可以分为依赖于平台和独立于平台的代码。

如果你在抽象层后面有隔离的基元,并且根据它们抽象的种类进行了组织,那么你最终会得到一个硬件抽象层或操作系统抽象层。现在你有了比以前更多的代码文件,特别是处理不同变体的文件,你可能需要考虑将它们组织成软件模块目录。

现在使用抽象层 API 的代码非常干净,但在该 API 下面的实现仍然包含不同变体的#ifdef代码。这种做法的缺点是,如果需要支持额外的操作系统,这些实现必须进行修改并且会增长。为了避免在添加另一个变体时修改现有实现文件,可以分离变体实现。

分离变体实现

上下文

你将平台变体隐藏在一个抽象层后面。在特定于平台的实现中,你使用#ifdef语句区分代码变体。

问题

特定于平台的实现仍然包含#ifdef语句来区分代码变体。这使得很难看到并选择应该为哪个平台构建代码的哪一部分。

因为将不同平台的代码放在单个文件中,所以无法按文件选择特定于平台的代码。然而,像 Make 这样的工具通常负责通过 Makefile 选择应该编译哪些文件,以生成不同平台的变体。

当从高层次观点看代码时,不可能看出哪些部分是特定于平台的,哪些不是,但在将代码移植到另一个平台时,能够迅速看出哪些代码需要修改是非常理想的。

开闭原则表明,为了引入新特性(或移植到新平台),不应该必须修改现有代码。代码应该对这些修改开放。然而,通过#ifdef语句区分平台变体要求在引入新平台时必须修改现有实现,因为必须在现有函数中放置另一个#ifdef分支。

解决方案

将每个变体实现放入单独的实现文件中,并根据需要为每个文件选择要为哪个平台编译。

同一平台的相关函数仍然可以放在同一个文件中。例如,可以有一个文件收集 Windows 上所有 socket 处理函数的函数,以及一个类似的文件在 Linux 上也这样做。

对于每个平台单独的文件,可以使用 #ifdef 语句确定在特定平台上编译哪些代码是可以接受的。例如,someFeatureWindows.c 文件可以在整个文件中使用类似于 Include Guards 的 #ifdef _WIN32 语句:

someFeature.h

/* Provides generic access to someFeature.
 Supported on platform A and platform B. */
  someFeature();

someFeatureWindows.c

#ifdef _WIN32
  someFeature()
  {
    performWindowsFeature();
  }
#endif

someFeatureLinux.c

#ifdef __unix__
  someFeature()
  {
    performLinuxFeature();
  }
#endif

除了在整个文件中使用 #ifdef 语句之外,还可以使用其他独立于平台的机制,例如 Make,在文件层面上决定在特定平台上编译哪些代码。如果你的 IDE 能帮助生成 Makefile,那么这种替代方案可能更为舒适,但要注意,当更改 IDE 时,可能需要重新配置在新 IDE 中要在哪些平台上编译哪些文件。

对于每个平台单独的文件,问题就来了,应该把这些文件放在哪里以及如何命名:

  • 一种选择是将每个软件模块的平台特定文件放在一起,并以一种清晰表明它们覆盖哪个平台的方式命名(例如 fileHandlingWindows.c)。这种软件模块目录的优势在于软件模块的实现位于同一位置。

  • 另一种选择是将代码库中所有平台特定的文件放入一个目录,并为每个平台创建一个子目录。这样做的好处是,一个平台的所有文件都在同一个地方,你可以更轻松地配置你的 IDE 来决定在哪个平台上编译哪些文件。

影响

现在,可以在代码中完全不使用任何 #ifdef 语句,而是通过工具(如 Make)在文件层面上区分不同的变体。

在每个实现文件中,现在只有一种代码变体,因此在阅读代码时不需要在不同的 #ifdef 分支之间跳转。这样阅读和理解代码会更加容易。

当在一个平台上修复 bug 时,无需触碰其他平台的文件。当移植到新平台时,只需添加新文件,无需修改现有文件或现有代码。

容易识别哪部分代码依赖于特定平台,以及哪些代码必须添加以便在新平台上进行移植。要么所有平台特定的文件在一个目录中,要么文件命名方式明确表明它们是依赖于特定平台的。

然而,将每个变体放入单独的文件中会创建许多新文件。你拥有的文件越多,构建过程就变得越复杂,代码编译时间就越长。你需要考虑如何组织文件结构,例如使用软件模块目录。

已知用法

以下示例展示了此模式的应用:

  • 书籍《编写可移植代码:开发多平台软件入门》(Brian Hook 著,No Starch Press,2005 年)中介绍的简单音频库使用单独的实现文件为 Linux 和 OS X 提供访问线程和互斥锁的功能。实现文件使用 #ifdef 语句确保仅编译适合该平台的正确代码。

  • Apache Web 服务器的多处理模块负责处理对 Web 服务器的访问,为 Windows 和 Linux 分别实现了单独的实现文件。实现文件使用 #ifdef 语句确保仅编译适合该平台的正确代码。

  • U-Boot bootloader 的代码将其支持的每个硬件平台的源代码放入单独的目录中。每个目录包含文件 cpu.c,其中包含重置 CPU 的函数。一个 Makefile 决定应该编译哪个目录(以及哪个 cpu.c 文件)——这些文件中没有 #ifdef 语句。U-Boot 的主程序逻辑调用该函数来重置 CPU,在这一点上不必关心硬件平台的细节。

应用于运行示例

分离变体实现后,您将得到以下用于创建目录并向文件写入数据的功能的最终代码:

directoryNames.h

/* Copies the path to a new directory with name "newdir"
 located in the user's home directory into "dirname".
 Works on Linux and Windows. */
void getHomeDirectory(char* dirname);

/* Copies the path to a new directory with name "newdir"
 located in the current working directory into "dirname".
 Works on Linux and Windows. */
void getWorkingDirectory(char* dirname);

directoryNamesLinux.c

#ifdef __unix__
  #include "directoryNames.h"
  #include <string.h>
  #include <stdio.h>
  #include <stdlib.h>

  void getHomeDirectory(char* dirname)
  {
    sprintf(dirname, "%s%s", getenv("HOME"), "/newdir/");
  }

  void getWorkingDirectory(char* dirname)
  {
    strcpy(dirname, "newdir/");
  }
#endif

directoryNamesWindows.c

#ifdef _WIN32
  #include "directoryNames.h"
  #include <string.h>
  #include <stdio.h>
  #include <windows.h>

  void getHomeDirectory(char* dirname)
  {
    sprintf(dirname, "%s%s%s", getenv("HOMEDRIVE"), getenv("HOMEPATH"),
            "\\newdir\\");
  }

  void getWorkingDirectory(char* dirname)
  {
    strcpy(dirname, "newdir\\");
  }
#endif

directorySelection.h

/* Copies the path to a new directory with name "newdir" into "dirname".
 The directory is located in the user's home directory, if STORE_IN_HOME_DIR
 is set or it is located in the current working directory, if STORE_IN_CWD
 is set. */
void getDirectoryName(char* dirname);

directorySelectionHomeDir.c

#ifdef STORE_IN_HOME_DIR
  #include "directorySelection.h"
  #include "directoryNames.h"

  void getDirectoryName(char* dirname)
  {
    getHomeDirectory(dirname);
  }
#endif

directorySelectionWorkingDir.c

#ifdef STORE_IN_CWD
  #include "directorySelection.h"
  #include "directoryNames.h"

  void getDirectoryName(char* dirname)
  {
    return getWorkingDirectory(dirname);
  }
#endif

directoryHandling.h

/* Creates a new directory of the provided name ("dirname").
 Works on Linux and Windows. */
void createNewDirectory(char* dirname);

directoryHandlingLinux.c

#ifdef __unix__
  #include <sys/stat.h>

  void createNewDirectory(char* dirname)
  {
    mkdir(dirname,S_IRWXU);
  }
#endif

directoryHandlingWindows.c

#ifdef _WIN32
  #include <windows.h>

  void createNewDirectory(char* dirname)
  {
    CreateDirectory(dirname, NULL);
  }
#endif

main.c

#include "directorySelection.h"
#include "directoryHandling.h"
#include <string.h>
#include <stdio.h>

int main()
{
  char dirname[50];
  char filename[60];
  char* my_data = "Write this data to the file";
  getDirectoryName(dirname);
  createNewDirectory(dirname);
  sprintf(filename, "%s%s", dirname, "newfile");
  FILE* f = fopen(filename, "w+");
  fwrite(my_data, 1, strlen(my_data), f);
  fclose(f);
  return 0;
}

此代码中仍然存在 #ifdef 语句。每个实现文件都有一个大的 #ifdef,以确保为每个平台和变体编译正确的代码。或者,决定应编译哪些文件可以放入 Makefile 中。这样可以摆脱 #ifdef,但您只需使用另一种机制来选择变体。决定使用哪种机制并不那么重要。如本章所述,更重要的是隔离和抽象变体。

使用其他机制处理变体时,代码文件看起来更清晰,但复杂性仍然存在。将复杂性放入 Makefile 中是个好主意,因为 Makefile 的目的是决定要构建哪些文件。在其他情况下,最好使用 #ifdef 语句。例如,如果正在构建特定于操作系统的代码,可能会使用专有的 Windows IDE 和 Linux IDE 来决定要构建哪些文件。在这种情况下,在代码中使用 #ifdef 语句的解决方案要干净得多;通过 #ifdef 语句只需配置一次要为哪个操作系统构建哪些文件,并且在切换到另一个 IDE 时无需更改。

运行示例的最终代码非常清晰地展示了如何逐步改进具有特定操作系统变体或其他变体的代码。与第一个代码示例相比,这段最终代码可读性强,可以轻松扩展以增加额外功能或移植到其他操作系统,而无需触及现有代码中的任何部分。

摘要

本章介绍了如何处理 C 代码中的变体,如硬件或操作系统变体,以及如何组织和摆脱#ifdef语句。

避免变体模式建议使用标准化函数而不是自行实现的变体。每当适用时,都应该应用这种模式,因为它一举解决了代码变体的问题。然而,并非总是有标准化函数可用,在这种情况下,程序员必须实现自己的函数来抽象变体。作为一种起步,孤立的原始元素建议将变体放入单独的函数中,而原子原始元素建议在这些函数中仅处理一种类型的变体。抽象层进一步采取了隐藏原始元素实现在 API 后面的附加步骤。分割变体实现建议将每个变体放入单独的实现文件中。

将这些模式作为编程词汇的一部分,C 程序员可以获得一个工具箱和逐步指导,以便处理 C 代码中的各种变体,从而组织代码并摆脱#ifdef地狱。

对于经验丰富的程序员来说,有些模式可能看起来像是显而易见的解决方案,这是一件好事。模式的一个任务是教育人们如何做正确的事情;一旦他们知道如何做正确的事情,模式就不再必要,因为人们会直观地按照模式建议的方式去做。

进一步阅读

如果你已经准备好继续学习,这里有一些资源可以帮助你进一步了解平台和变体抽象。

  • 由 Brian Hook(No Starch Press,2005 年)撰写的书籍《编写可移植代码:多平台软件开发入门》描述了如何在 C 语言中编写可移植代码。该书涵盖了操作系统变体和硬件变体,并通过针对特定情况的建议,如处理字节顺序、数据类型大小或行分隔符标记,来提供指导。

  • 文章“#ifdef 被认为有害”由 Henry Spencer 和 Geoff Collyer 撰写,是最早对使用#ifdef语句持怀疑态度的文章之一。该文章详细阐述了在结构不清晰地使用它们时可能出现的问题,并提供了替代方案。

  • 文章“编写可移植代码”由 Didier Malenfant 描述了如何构建可移植代码以及应该将哪些功能放置在抽象层下面。

展望

现在你已经掌握了更多的模式。接下来,你将学习如何应用这些模式,以及前几章的模式。下一章将涵盖更大的代码示例,展示所有这些模式的应用。

第二部分:模式故事

讲故事是一种固有和自然的信息传递方式。在模式的世界里,有时很难看到描述的模式如何在现实世界中应用。为了展示这种模式应用的示例,本书的第二部分讲述了应用第一部分中的 C 编程模式来实现更大程序的故事。您将学习如何逐步构建这些程序,看到这些模式如何通过提供设计决策的指导使您的生活更加轻松。

第十章:实现日志功能

在设计软件时,选择适当的模式在适当的情况下帮助很大。但有时很难找到正确的模式并决定何时应用它。您可以在本书的第 I 部分中的上下文和问题部分找到相关指导。但通常通过查看具体示例来理解如何做某事要容易得多。

本章讲述了将本书第 I 部分的模式应用于一个抽象自工业强度实现的日志系统的运行示例的故事。为了使示例代码易于理解,没有涵盖原始工业强度代码的所有方面。例如,代码设计不关注性能或可测试性方面。尽管如此,示例很好地展示了如何逐步构建一个日志系统,通过应用模式。

模式故事

想象一下,您在现场有一个需要维护的 C 程序。如果发生错误,您开车前往客户那里进行调试。这在客户还在同一城市时效果很好。现在客户搬到另一个城市,开车需要几个小时,这完全不能令人满意。

您更喜欢从您的办公桌解决问题,以节省时间和神经。在某些情况下,您可以利用远程调试。在其他情况下,您需要详细的关于发生错误的确切软件状态的数据,这在 sporadic 错误的情况下尤为困难。

也许您已经猜到了避免长时间车程的解决方案。您的解决方案是实现日志功能,并在发生错误时要求客户发送包含调试信息的日志文件。换句话说,您想实现日志错误模式,以便能够分析错误发生后的 bug,这使您更容易修复这些 bug 而无需重现它们。尽管听起来很简单,但要实现日志功能,您需要做出许多关键的设计决策。

文件组织

要开始,请组织您预计需要的头文件和实现文件。您已经有一个庞大的代码库,因此希望将这些文件清晰地与其余代码分开。您应该如何组织这些文件?您应该将所有与日志相关的文件放入同一个目录中吗?您应该将所有代码的头文件放入单个目录中吗?

为了回答这些问题,您搜索了关于组织文件的模式,并在第六章和第八章找到了它们。您阅读了这些模式的问题陈述,并信任所描述解决方案中提供的知识。最终,您得到了以下三个很好地解决了您问题的模式:

模式名称 摘要
软件模块目录 将属于紧密耦合功能的头文件和实现文件放入一个目录中。以提供给用户的头文件功能命名该目录。
头文件 在您的 API 中提供功能声明,以提供给用户您想要提供的任何功能。隐藏任何内部函数、内部数据和您的函数定义(实现)在您的实现文件中,不要将此实现文件提供给用户。
全局包含目录 在您的代码库中有一个全局目录,其中包含所有软件模块的 API。将此目录添加到您工具链中的全局包含路径中。

为您的实现文件创建一个软件模块目录,并将您的日志软件模块的头文件放入已存在的全局包含目录中。将此头文件放入全局包含目录中的好处是调用者一定知道他们应该使用哪个头文件。

您的文件结构应如图 10-1 所示。

fluc 1001

图 10-1. 文件结构

有了这个文件结构,您可以将任何仅涉及您的日志软件模块的实现文件放入logger目录中。您可以将可以从程序的其他部分使用的接口放入inc目录中。

中心化日志函数

作为起步,实现一个中心化的错误日志记录函数,该函数接受自定义的错误文本,在文本中添加当前时间戳,并将其打印到标准输出。时间戳信息将使您稍后分析错误文本更加容易。

将函数声明放入logger.h文件中。为了保护您的头文件免受多次包含的影响,请添加一个包含保护。在该代码中不需要存储任何信息或进行初始化;只需实现一个无状态的软件模块。拥有无状态的日志记录器带来很多好处:您的日志记录代码保持简单,并且在多线程环境中调用代码变得更加容易。

模式名称 摘要
包含保护 保护您的头文件内容免受多次包含的影响,以便使用头文件的开发人员不必关心它是否被多次包含。使用交错的#ifdef语句或#pragma once语句来实现这一点。
无状态的软件模块 保持函数简单,在您的实现中不积累状态信息。将所有相关函数放入一个头文件中,并为调用者提供您软件模块的接口。

logger.h

#ifndef LOGGER_H
#define LOGGER_H
void logging(const char* text);
#endif

调用者的代码

logging("Some text to log");

要在logger.h文件中实现该函数,在其中调用printf来将时间戳和文本写入stdout。但是,如果您的函数的调用者提供了无效的日志输入,比如NULL指针,该怎么办?您应该检查这样的无效输入并向调用者提供错误信息吗?遵循武士原则,根据该原则,您不应返回关于编程错误的错误信息。

模式名称 摘要
武士原则 从函数中返回,要么胜利,要么不返回。如果有一种情况,你知道错误无法处理,那么终止程序。

将提供的文本转发给printf函数,如果输入无效,您的程序将简单地崩溃,这使得调用者可以找出关于无效输入的编程错误:

logger.c

void logging(const char* text)
{
  time_t mytime = time(NULL);
  printf("%s %s\n", ctime(&mytime), text);
}

如果您在多线程程序的上下文中调用该函数会怎样?调用函数的字符串是否可以被其他线程更改,或者在日志函数完成之前字符串是否需要保持不变?在上述代码示例中,调用者必须将text作为logging函数的输入,并负责确保该字符串在函数返回之前是有效的。因此,这里有一个调用者拥有的缓冲区。该行为必须在函数接口中进行文档化。

模式名称 摘要
调用者拥有的缓冲区 要求调用者为返回大型复杂数据的函数提供缓冲区及其大小。在函数实现中,如果缓冲区大小足够大,则将所需数据复制到缓冲区中。

logger.h

/* Prints the current timestamp followed by the provided string to stdout.
 The string must be valid until this function returns. */
void logging(const char* text);

日志源过滤器

现在想象每个软件模块都调用日志函数以记录一些信息。输出可能会变得非常混乱,特别是在多线程程序中。

为了更容易获取您正在寻找的信息,您希望能够配置代码,使其仅打印已配置软件模块的日志信息。为此,请向您的函数添加一个额外的参数,用于标识当前的软件模块。添加一个函数以启用打印软件模块的输出。如果调用该函数,将来该软件模块的所有日志输出都将被打印:

logger.h

/* Prints the current timestamp followed by the provided string to stdout.
 The string must be valid until this function returns. The provided module
 identifies the software-module that calles this function. */
void logging(const char* module, const char* text);

/* Enables printing output for the provided module. */
bool enableModule(const char* module);

Caller’s code

logging("MY-SOFTWARE-MODULE", "Some text to log");

您将如何跟踪应该打印哪些软件模块的日志信息?您应该将该状态信息存储在全局变量中,还是每个全局变量都是一种代码异味?或者为了避免全局变量,应该向所有函数传递一个额外的参数来存储这些状态信息?在整个程序生命周期内是否应分配所需的内存?这些问题的答案涉及使用永久内存实现具有全局状态的软件模块。

模式名称 摘要
具有全局状态的软件模块 有一个全局实例,让您的相关函数共享公共资源。将所有操作此实例的函数放入一个头文件中,并向调用者提供此接口以访问您的软件模块。
永久内存 将数据放入整个程序生命周期可用的内存中。

logger.c

#define MODULE_SIZE 20
#define LIST_SIZE 10
typedef struct
{
  char module[MODULE_SIZE];
}LIST;
static LIST list[LIST_SIZE];

前面示例中的列表由以下功能启用软件模块来填充:

logger.c

bool enableModule(const char* module)
{
  for(int i=0; i<LIST_SIZE; i++)
  {
    if(strcmp(list[i].module, "") == 0)
    {
      strcpy(list[i].module, module);
      return true;
    }
    if(strcmp(list[i].module, module) == 0)
    {
      return false;
    }
  }
  return false;
}

前面的代码如果列表中的某个槽为空并且该名称尚未在列表中,则将软件模块名称添加到列表中。调用者通过返回值看到是否发生错误,但看不到发生了哪些错误。不返回状态码;只返回相关的错误,因为调用者在描述的错误情况下没有不同的反应场景。您还应该在函数定义中记录此行为。

模式名称 摘要
返回值 只需使用用于检索函数调用结果信息的 C 机制之一:返回值。在 C 中返回数据的机制是复制函数结果并提供调用者对此副本的访问。
只返回相关的错误 只有当错误信息对调用者有关时,才将错误信息返回给调用者。只有当调用者能够对此信息做出反应时,错误信息才对调用者有关。

logger.h

/* Enables printing output for the provided module. Returns true on success
 and false on error (no more modules can be enabled or module was already
 enabled). */
bool enableModule(const char* module);

条件记录

现在,在列表中激活了软件模块后,您可以根据激活的模块条件性地记录信息,如下代码所示:

logger.c

void logging(const char* module, const char* text)
{
  time_t mytime = time(NULL);
  if(isInList(module))
  {
    printf("%s %s\n", ctime(&mytime), text);
  }
}

但是您如何实现 isInList 函数呢?有多种方法可以迭代列表。您可以使用游标迭代器,提供 getNext 方法来抽象底层数据结构。但是这里是否有必要呢?毕竟,在您自己的软件模块中,只需遍历一个数组。因为迭代的数据未跨 API 边界传递,这里可以应用一个更简单的解决方案。索引访问直接使用索引访问您希望迭代的元素:

模式名称 摘要
索引访问 提供一个函数,接受索引来访问底层数据结构中的元素,并返回此元素的内容。用户在循环中调用此函数以遍历所有元素。

logger.c

bool isInList(const char* module)
{
  for(int i=0; i<LIST_SIZE; i++)
  {
    if(strcmp(list[i].module, module) == 0)
    {
      return true;
    }
  }
  return false;
}

现在,您所有的软件模块特定记录代码都已编写完成。该代码简单地通过增加索引来迭代数据结构。与您的 enableModule 函数中已经使用的相同类型的迭代。

多个记录目的地

接下来,您希望为日志条目提供不同的目标位置。到目前为止,所有输出都记录在stdout上,但您希望调用者能够配置您的代码直接记录到文件中。这样的配置通常在开始记录要执行的操作之前完成。从一个允许您为所有未来的日志记录配置日志目标的函数开始:

logger.h

/* All future log messages will be logged to stdout */
void logToStdout();

/* All future log messages will be logged to a file */
void logToFile();

要实现此日志目标选择,您可以简单地使用ifswitch语句调用正确的函数,具体取决于配置的日志目标。但是每次添加另一个日志目标时,您都必须修改该代码片段。这不符合开闭原则的良好解决方案。更好的解决方案是实现一个动态接口。

模式名称 概要
动态接口 为 API 中不同功能定义一个通用接口,并要求调用者提供该功能的回调函数,然后在您的函数实现中调用该函数。

logger.c

typedef void (*logDestination)(const char*);
static logDestination fp = stdoutLogging;

void stdoutLogging(const char* buffer)
{
  printf("%s", buffer);
}

void fileLogging(const char* buffer)
{
  /* not yet implemented */
}

void logToStdout()
{
  fp = stdoutLogging;
}

void logToFile()
{
  fp = fileLogging;
}

#define BUFFER_SIZE 100
void logging(const char* module, const char* text)
{
  char buffer[BUFFER_SIZE];
  time_t mytime = time(NULL);
  if(isInList(module))
  {
    sprintf(buffer, "%s %s\n", ctime(&mytime), text);
    fp(buffer);
  }
}

现有代码发生了很多变化,但现在可以添加额外的日志目标而无需更改logging函数。在前面的代码中,stdoutLogging函数已经实现,但fileLogging函数还未完成。

文件记录

要记录到文件,您可以每次记录消息时简单地打开和关闭文件。但这并不是很高效,如果要记录大量信息,这种方法会花费大量时间。那么您有什么替代方案呢?您可以打开文件一次,然后保持打开状态。但是您如何知道何时打开文件?何时关闭它呢?

在查看本书中的模式后,您找不到解决您问题的模式。然而,通过快速的谷歌搜索,您会找到解决您问题的模式:延迟获取。在首次调用您的fileLogging函数时,打开文件一次,然后保持打开状态。您可以将文件描述符存储在永久存储中。

模式名称 概要
延迟获取 首次使用时隐式初始化对象或数据(见《面向模式的软件架构:第 3 卷:资源管理模式》Michael Kirchner 和 Prashant Jain [Wiley, 2004])
永久存储 将数据放入整个程序生命周期内可用的内存中。

logger.c

void fileLogging(const char* buffer)
{
  static int fd = 0; ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
  if(fd == 0)
  {
    fd = open("log.txt", O_RDWR | O_CREAT, 0666);
  }
  write(fd, buffer, strlen(buffer));
}

1

这些static变量只初始化一次,而不是每次调用函数时都初始化。

为了使示例代码保持简单,它并不针对线程安全性。为了线程安全,代码必须使用互斥锁保护延迟获取,以确保获取只发生一次。

那么关闭文件怎么样?对于某些应用程序(比如本章中的应用程序),不关闭文件是一个有效的选择。想象一下,你希望在应用程序运行时记录,当你关闭应用程序时,你依赖操作系统清理你留下的文件。如果你担心在系统崩溃时信息没有被存储,你甚至可以定期刷新文件内容。

跨平台文件

到目前为止的代码在 Linux 系统上实现了向文件的日志记录,但你还想在 Windows 平台上使用你的代码,而当前的代码尚不能工作。

为了支持多个平台,你首先考虑避免变体,这样你只有所有平台的通用代码。这对于通过简单使用在 Linux 和 Windows 系统上都可用的fopenfwritefclose函数来编写文件是可能的。

模式名称 摘要
避免变体 使用所有平台上都可用的标准化函数。如果没有标准化函数,考虑不实现这个功能。

然而,你希望使文件日志记录代码尽可能高效,并且使用特定于平台的函数来访问文件更有效。但是如何实现特定于平台的代码呢?复制你的代码库以获得一个完整的 Windows 版本和一个完整的 Linux 版本不是一个选择,因为未来的更改和维护可能会变成一场噩梦。

你决定在你的代码中使用#ifdef语句来区分平台。但这难道不也是代码复制吗?毕竟,当你的代码中有大量的#ifdef块时,这些语句中的所有程序逻辑都是重复的。如何在支持多个平台的同时避免代码重复呢?

再次,这些模式为你指明了方向。首先,为需要依赖于特定平台函数的功能定义平台无关的接口。换句话说,定义一个抽象层。

模式名称 摘要
Abstraction Layer 为每个需要特定于平台代码的功能提供一个 API。在头文件中只定义平台无关的函数,并将所有特定于平台的#ifdef代码放入实现文件中。调用者只需要包含你的头文件,而不需要包含任何特定于平台的文件。

logger.c

void fileLogging(const char* buffer)
{
  void* fileDescriptor = initiallyOpenLogFile();
  writeLogFile(fileDescriptor, buffer);
}

/* Opens the logfile at the first call.
 Works on Linux and on Windows systems */
void* initiallyOpenLogFile()
{
  ...
}

/* Writes the provided buffer to the logfile.
 Works on Linux and on Windows systems */
void writeLogFile(void* fileDescriptor, const char* buffer)
{
  ...
}

在这个抽象层后面,你有你代码变体的隔离原语。这意味着你不在几个函数中使用#ifdef语句,而是为一个函数坚持使用一个#ifdef。你是否应该在整个函数实现中使用#ifdef语句,还是仅在特定于平台的部分中使用?

解决方案是两者都有。你应该有原子原语。函数应该以一种粒度存在,使其仅包含特定于平台的代码。如果不是这样,那么你可以进一步拆分这些函数。这是保持平台相关代码可管理性的最佳方式。

模式名称 概要
独立原语 隔离您的代码变体。在您的实现文件中,将处理变体的代码放入单独的函数中,并从主程序逻辑中调用这些函数,这样主程序逻辑中只包含平台无关的代码。
原子原语 使您的原语变成原子的。每个函数只处理一种变体。如果您处理多种变体,例如操作系统变体和硬件变体,则应分别为其创建函数。

下面的代码展示了原子原语的实现:

logger.c

void* initiallyOpenLogFile()
{
#ifdef __unix__
  static int fd = 0;
  if(fd == 0)
  {
    fd = open("log.txt", O_RDWR | O_CREAT, 0666);
  }
  return fd;
#elif defined _WIN32
  static HANDLE hFile = NULL;
  if(hFile == NULL)
  {
    hFile = CreateFile("log.txt", GENERIC_WRITE, 0, NULL,
                       CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL);
  }
  return hFile;
#endif
}

void writeLogFile(void* fileDescriptor, const char* buffer)
{
#ifdef __unix__
  write((int)fileDescriptor, buffer, strlen(buffer));
#elif defined _WIN32
  WriteFile((HANDLE)fileDescriptor, buffer, strlen(buffer), NULL, NULL);
#endif
}

上述代码看起来并不美观。但再怎么说,任何依赖于平台的代码很少看起来好看。有什么其他方法可以使该代码更易于阅读和维护吗?改进的一种可能方法是将变体实现分割为单独的文件。

模式名称 概要
分割变体实现 将每个变体实现放入单独的实现文件中,并根据需要选择为哪个平台编译。

fileLinux.c

#ifdef __unix__
void* initiallyOpenLogFile()
{
  static int fd = 0;
  if(fd == 0)
  {
    fd = open("log.txt", O_RDWR | O_CREAT, 0666);
  }
  return fd;
}

void writeLogFile(void* fileDescriptor, const char* buffer)
{
  write((int)fileDescriptor, buffer, strlen(buffer));
}
#endif

fileWindows.c

#ifdef _WIN32
void* initiallyOpenLogFile()
{
  static HANDLE hFile = NULL;
  if(hFile == NULL)
  {
    hFile = CreateFile("log.txt", GENERIC_WRITE, 0, NULL,
                       CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL);
  }
  return hFile;
}

void writeLogFile(void* fileDescriptor, const char* buffer)
{
  WriteFile((HANDLE)fileDescriptor, buffer, strlen(buffer), NULL, NULL);
}
#endif

这两个显示的代码文件与将 Linux 和 Windows 代码混合在单个函数内的代码相比,要容易得多。而且,与通过#ifdef语句在平台上有条件地编译代码相比,现在可以消除所有#ifdef语句,并使用 Makefile 选择要编译的文件。

使用记录器

在你的日志功能做出最后更改后,你的代码现在可以将消息记录到配置的软件模块,可以输出到stdout或跨平台文件中。以下代码展示了如何使用日志功能:

enableModule("MYMODULE");
logging("MYMODULE", "Log to stdout");
logToFile();
logging("MYMODULE", "Log to file");
logging("MYMODULE", "Log to file some more");

在完成所有这些编码决策并实施它们之后,您感到非常宽慰。您从键盘上抬起双手,欣赏着代码。您对一些最初似乎困扰您的问题如何轻松解决感到惊讶。利用这些模式的好处在于,它们消除了您自己做出数百个决策的负担。

以前的长途汽车驾驶修复错误已成为过去。现在,您只需通过日志文件获取所需的调试信息。这让您的客户感到高兴,因为他们可以更快地修复错误。更重要的是,它让您的生活更加美好。您可以提供更专业的软件,并且现在有时间早点下班。

概要

你通过逐步应用第一部分中提出的模式来构建此日志功能的代码,以便逐一解决各种问题。刚开始时,你对如何组织文件或如何处理错误处理有很多问题。这些模式指引了你的方向。它们为你提供了指导,并使得构建这段代码变得更加容易。它们还帮助你理解代码看起来和表现出来的原因。图 10-2 显示了模式帮助你做出的决策概述。

当然,你的代码仍有许多潜在的功能改进空间。例如,该代码不处理文件的最大大小或日志文件的轮换,并且不支持配置日志级别以跳过非常详细的日志记录。为了保持简单和易于理解,这些功能没有涵盖在内,但可以添加到代码示例中。

下一章将讲述如何将这些模式应用于构建另一个更大的工业级代码片段的另一个故事。

fluc 1002

图 10-2. 故事中应用的模式

第十一章:构建用户管理系统

本章讲述了将本书第一部分的模式应用于运行示例的故事。通过该示例,它说明了如何通过模式做出设计选择能够为程序员提供好处和支持。本章的运行示例是从一个工业级别的用户管理系统的实现中抽象出来的。

模式故事

想象一下,你刚从大学毕业,开始为一家软件开发公司工作。你的老板交给你一个软件规范,用于存储用户名和密码,并告诉你要实现它。该软件应该提供检查用户提供的密码是否正确以及创建、删除和查看现有用户的功能。

你渴望向老板展示你是一位优秀的程序员,但在你开始之前,脑海中充满了问题。你应该把所有的代码都写到一个文件中吗?你从学习中知道这是不好的实践,但是什么是一个好的文件数量?你将代码的哪些部分放到同一个文件中?你应该检查每个函数的输入参数吗?你的函数是否应该返回详细的错误信息?在大学里,你学会了如何构建一个能运行的软件程序,但你没有学会如何编写可维护的优秀代码。那么你该怎么做?你如何开始呢?

数据组织

要回答你的问题,首先通过查阅本书中的模式来获取如何构建良好的 C 程序的指导。从系统中存储用户名和密码的部分开始。你现在的问题应该集中在如何在程序中存储数据上。你应该将它存储在全局变量中吗?还是应该将数据保存在函数内的局部变量中?还是应该分配动态内存?

首先,考虑您的应用程序中要解决的确切问题:您不确定如何存储用户名数据。目前不需要使这些数据持久化;您只是想在运行时能够构建和访问这些数据。此外,您不希望函数的调用者必须处理数据的显式分配和初始化。

接下来,寻找解决你特定问题的模式。查阅第五章关于数据生命周期和所有权的 C 模式,该章节讨论了谁负责持有哪些数据的问题,其中包括所有模式的问题部分,并找到一个非常匹配你问题并描述的后果对你可接受的模式。这个模式是具有全局状态的软件模块模式,建议使用全局变量的永久内存形式,作用域限制在文件内,以便从该文件内访问该数据。

模式名称 概要
具有全局状态的软件模块 使用一个全局实例来让相关函数共享公共资源。将所有操作此实例的函数放入一个头文件中,并为调用者提供你的软件模块的此接口。
永久内存 将数据放入整个程序生命周期内可用的内存中。
#define MAX_SIZE 50
#define MAX_USERS 50

typedef struct
{
  char name[MAX_SIZE];
  char pwd[MAX_SIZE];
}USER;

static USER userList[MAX_USERS]; ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)

1

userList包含用户数据。它在实现文件中可访问。因为它保留在静态内存中,所以不需要手动分配它(这会使代码更灵活,但也更复杂)。

存储密码

在这个简化的示例中,我们将密码保留为明文。在实际应用中绝对不能这样做。存储密码时,应该存储明文密码的加盐哈希值

文件组织

接下来,为你的调用方定义一个接口。确保以后可以轻松更改你的实现而不需要调用方更改任何代码。现在你必须决定程序的哪一部分应该在接口中定义,哪一部分应该在你的实现文件中定义。

使用头文件解决这个问题。只将对调用方有用的东西放入接口(.h文件)中。其余的都放入你的实现文件(.c文件)中。为了防止头文件被多次包含,实现一个包含保护。

模式名称 概述
头文件 在你的 API 中提供函数声明,以提供给用户所需的任何功能。将任何内部函数、内部数据和函数定义(实现)隐藏在你的实现文件中,并且不向用户提供此实现文件。
包含保护 保护头文件内容,防止多次包含,使得使用头文件的开发人员不必关心它是否被多次包含。使用一个交互锁定的#ifdef语句或#pragma once语句来实现这一点。

user.h

#ifndef USER_H
#define USER_H

#define MAX_SIZE 50

#endif

user.c

#include "user.h"

#define MAX_USERS 50

typedef struct
{
  char name[MAX_SIZE];
  char pwd[MAX_SIZE];
}USER;

static USER userList[MAX_USERS];

现在调用方可以使用定义的MAX_SIZE来知道提供给软件模块的字符串可以有多长。按照惯例,调用方知道.h文件中的所有内容都可以使用,但.c文件中的内容则不应使用。

接下来,确保你的代码文件与调用者的代码良好分离,以避免名称冲突。你是否应该将所有文件放入一个目录中,或者例如将所有.h文件放在整个代码库中的一个目录中,以便更容易地包含它们?

创建一个软件模块目录,将所有与软件模块相关的文件(接口和实现)放入一个目录中。

模式名称 概述
软件模块目录 将属于紧密耦合功能的头文件和实现文件放入一个目录中。将该目录命名为通过头文件提供的功能。

有了图 11-1 所示的目录结构,现在可以轻松地找到所有与你的代码相关的文件。现在你不必担心你的实现文件名与其他文件名冲突的问题了。

fluc 1101

图 11-1. 文件结构

认证:错误处理

现在是实现首个访问数据功能的时候了。首先实现一个函数,检查提供的密码是否与先前保存的用户密码匹配。通过在头文件中声明函数并在函数声明旁边用代码注释文档化函数的行为来定义函数的行为。

函数应告诉调用者提供的密码是否正确。通过函数的返回值告知调用者。但是应该返回哪些信息呢?是否应该向调用者提供任何发生的错误信息?

只返回相关的错误,因为对于任何与安全相关的功能,通常只提供必须提供的信息,不多提供。不要让调用者知道提供的用户不存在还是提供的密码错误。相反,只需告诉调用者认证是否成功。

模式名称 概要
返回值 简单地使用 C 语言中用于获取函数调用结果信息的机制:返回值。C 语言中返回数据的机制复制函数结果并提供调用者访问这个副本的方式。
返回相关的错误 只有当错误信息对调用者有意义时才将错误信息返回给调用者。如果调用者能够对信息做出反应,则错误信息对调用者有意义。

user.h

/* Returns true if the provided username exists and
 if the provided password is correct for that user. */
bool authenticateUser(char* username, char* pwd);

这段代码非常清楚地定义了函数返回的值,但是没有指定在输入无效的情况下的行为。应该如何处理像NULL指针这样的无效输入?你应该检查NULL指针,还是应该简单地忽略无效输入?

要求用户提供有效的输入,因为无效的输入将是用户的编程错误,这样的错误不应被忽视。根据武士原则,在遇到无效输入时应中止程序,并在头文件中记录这种行为。

模式名称 概要
武士原则 要么成功返回函数,要么根本不返回。如果有一个情况是你知道无法处理错误的情况,那么就中止程序。

user.h

/* Returns true if the provided username exists and
 if the provided password is correct for that user,
 returns false otherwise. Asserts in case of invalid
 input (NULL string) */
bool authenticateUser(char* username, char* pwd);

user.c

bool authenticateUser(char* username, char* pwd)
{
  assert(username);
  assert(pwd);

  for(int i=0; i<MAX_USERS; i++)
  {
    if(strcmp(username, userList[i].name) == 0 &&
       strcmp(pwd, userList[i].pwd) == 0)
    {
      return true;
    }
  }
  return false;
}

使用武士原则,你从调用者身上卸下检查特定返回值指示无效输入的负担。相反,对于无效输入,程序崩溃。你选择使用显式的assert语句,而不是让程序以不受控制的方式崩溃(例如将无效输入传递给strcmp函数)。在安全关键应用的上下文中,你希望程序在错误情况下也能有定义的行为。

乍一看,让程序崩溃似乎是一个残酷的解决方案,但这种行为下,带有无效参数的调用不会被忽视。长期来看,这种策略使代码更加可靠。它不会让诸如无效参数之类的微妙错误在调用者的代码中显现出来。

认证:错误日志记录

接下来,跟踪那些提供了错误密码的调用者。如果你的authenticateUser函数失败,记录错误信息,以便稍后进行安全审计。关于日志记录,可以使用第十章中的代码,或者实现一个更简单的日志记录版本如下所示。

模式名称 概述
记录错误 使用不同的通道提供对调用代码相关和开发者相关的错误信息。例如,将调试错误信息写入日志文件,不要将详细的调试错误信息返回给调用者。

在不同平台(例如 Linux 和 Windows)上提供这种日志机制是困难的,因为不同的操作系统提供了不同的文件访问函数。此外,跨平台代码的实现和维护也很困难。那么,如何尽可能简单地实现您的日志功能呢?确保避免使用变体,并使用所有平台都可用的标准化函数。

模式名称 概述
避免使用变体 使用所有平台都可用的标准化函数。如果没有标准化函数,则考虑不实现该功能。

幸运的是,C 标准定义了访问文件的函数,可以在 Windows 和 Linux 系统上使用。虽然各操作系统提供了访问文件的特定函数,可能更高效或提供特定于操作系统的功能,但这里并不需要。只需使用 C 标准定义的文件访问函数即可。

要实现您的日志功能,请在提供错误密码时调用以下函数:

user.c

static void logError(char* username)
{
  char logString[200];
  sprintf(logString, "Failed login. User:%s\n", username);
  FILE* f = fopen("logfile", "a+"); ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/fln-c/img/1.png)
  fwrite(logString, 1, strlen(logString), f);
  fclose(f);
}

1

使用跨平台的函数fopenfwritefclose。这些代码可以在 Windows 和 Linux 平台上工作,而且没有复杂的#ifdef语句来处理不同的平台变体。

用于存储日志信息时,代码使用堆栈优先,因为日志消息足够小,可以放在堆栈上。对于您来说,这也是最简单的选择,因为您不必处理内存清理。

模式名称 概述
堆栈优先 默认情况下将变量放在堆栈上,以便从自动清理堆栈变量中获益。

添加用户:错误处理

查看整个代码后,您现在有一个函数可以检查密码是否正确匹配用户名列表中的用户名,但是您的用户列表仍然为空。为了填充用户列表,实现一个允许调用者添加新用户的函数。

确保用户名是唯一的,并让调用者知道是否成功添加了新用户,无论是因为用户名已经存在还是因为用户列表中没有更多空间。

现在您需要决定如何通知调用者有关这些错误情况。您应该使用返回值返回此信息,还是应该设置errno变量?此外,您将向调用者提供什么类型的信息,并使用什么数据类型返回该信息?

在这种情况下,返回状态码,因为您有不同的错误情况,希望通知调用者这些不同的情况。此外,在参数无效的情况下,中止程序(武士原则)。在接口中定义错误代码,以便您和调用者了解这些错误代码如何映射到不同的错误情况,以便调用者可以相应地做出反应。

模式名称 概述
返回状态码 使用函数的返回值来返回状态信息。返回一个代表特定状态的值。调用者和被调用者都必须彼此理解这个值的含义。

user.h

typedef enum{
  USER_SUCCESSFULLY_ADDED,
  USER_ALREADY_EXISTS,
  USER_ADMINISTRATION_FULL
}USER_ERROR_CODE;

/* Adds a new user with the provided `username' and the provided password
 `pwd' (asserts on NULL). Returns USER_SUCCESSFULLY_ADDED on success,
 USER_ALREADY_EXISTS if a user with the provided username already exists
 and USER_ADMINISTRATION_FULL if no more users can be added. */
USER_ERROR_CODE addUser(char* username, char* pwd);

接下来,实现addUser函数。检查是否已经存在这样的用户,然后添加用户。为了分离这些任务,执行函数分割,将不同的任务和责任分为不同的函数。首先,实现一个函数来检查用户是否已经存在。

模式名称 概述
函数分割 将函数分割为几部分。从看起来单独有用的函数部分开始,创建一个新函数并调用该函数。

user.c

static bool userExists(char* username)
{
  for(int i=0; i<MAX_USERS; i++)
  {
    if(strcmp(username, userList[i].name) == 0)
    {
      return true;
    }
  }
  return false;
}

现在可以在添加新用户的函数内调用此函数,以便仅在用户尚不存在时添加新用户。您应该在函数的开始处还是在添加用户到列表之前检查现有用户?这两种选择中哪一种会使您的函数更易于阅读和维护?

在函数的开头实现守卫条款,如果由于用户已存在而无法执行操作,则立即返回。在函数开头进行检查可以更容易地跟踪程序流程。

模式名称 概述
守护条款 检查是否存在前置条件,如果这些前置条件未满足,则立即从函数中返回。

user.c

USER_ERROR_CODE addUser(char* username, char* pwd)
{
  assert(username);
  assert(pwd);

  if(userExists(username))
  {
    return USER_ALREADY_EXISTS;
  }

  for(int i=0; i<MAX_USERS; i++)
  {
    if(strcmp(userList[i].name, "") == 0)
    {
      strcpy(userList[i].name, username);
      strcpy(userList[i].pwd, pwd);
      return USER_SUCCESSFULLY_ADDED;
    }
  }

  return USER_ADMINISTRATION_FULL;
}

到目前为止,已实现的代码片段使您能够在用户管理中填充用户,并检查所提供的密码是否正确。

迭代

接下来,通过实现一个迭代器,提供一些功能以读取所有用户名。虽然你可能只想提供一个接口,让调用者通过索引访问userList数组,但如果底层数据结构发生变化(例如改为链表),或者调用者想在另一个调用者修改数组时访问数组,那么你将会遇到麻烦。

为了为调用者提供解决上述问题的迭代器接口,请实现一个光标迭代器,它使用一个句柄来隐藏调用者对底层数据结构的访问。

模式名称 摘要
光标迭代器 创建一个指向底层数据结构中元素的迭代器实例。迭代函数以此迭代器实例作为参数,获取迭代器当前指向的元素,并修改迭代器实例以指向下一个元素。用户随后通过反复调用此函数来逐个获取元素。
句柄 有一个函数来创建上下文,在该上下文上进行操作,并返回对内部数据的抽象指针。要求调用者将该指针传递给所有函数,这些函数可以使用内部数据来存储状态信息和资源。

user.h

typedef struct ITERATOR* ITERATOR;

/* Create an iterator instance. Returns NULL on error. */
ITERATOR createIterator();

/* Retrieves the next element from an iterator instance. */
char* getNextElement(ITERATOR iterator);

/* Destroys an iterator instance. */
void destroyIterator(ITERATOR iterator);

调用者完全控制何时创建和销毁迭代器。因此,您拥有调用者拥有实例的专有所有权。调用者只需创建迭代器句柄并使用它来访问用户名列表。如果创建失败,则特殊返回值NULL指示此情况。使用此特殊返回值而不是显式错误代码使函数使用更加简便,因为不需要额外的函数参数来返回错误信息。当调用者完成迭代时,可以销毁句柄。

模式名称 摘要
专有所有权 在实现内存分配时,明确定义何时进行清理以及谁会执行清理。
调用者拥有实例 要求调用者传递一个实例,该实例用于存储资源和状态信息,并将其传递给您的函数。提供显式函数来创建和销毁这些实例,以便调用者可以确定它们的生命周期。
特殊返回值 使用函数的返回值来返回函数计算的数据。保留一个或多个特殊值,以便在发生错误时返回。

因为接口为调用者提供了显式函数来创建和销毁迭代器,这自然地导致在实现中为初始化和清理迭代器资源编写单独的函数。基于对象的错误处理带来的优势是在函数中良好地分离了职责,如果将来需要扩展,这将使得函数更易于扩展。您可以在以下代码中看到此分离,其中所有初始化代码都在一个函数中,所有清理代码都在另一个函数中。

模式名称 概述
基于对象的错误处理 将初始化和清理分开放入单独的函数中,类似于面向对象编程中构造函数和析构函数的概念。

user.c

struct ITERATOR
{
  int currentPosition;
  char currentElement[MAX_SIZE];
};

ITERATOR createIterator()
{
  ITERATOR iterator = (ITERATOR) calloc(sizeof(struct ITERATOR),1);
  return iterator;
}

char* getNextElement(ITERATOR iterator)
{
  if(iterator->currentPosition < MAX_USERS)
  {
    strcpy(iterator->currentElement,userList[iterator->currentPosition].name);
    iterator->currentPosition++;
  }
  else
  {
    strcpy(iterator->currentElement, "");
  }
  return iterator->currentElement;
}

void destroyIterator(ITERATOR iterator)
{
  free(iterator);
}

在实现上述代码时,您应如何向调用者提供用户名数据?您应该简单地向调用者提供指向该数据的指针吗?如果将数据复制到缓冲区中,谁应该分配它?

在这种情况下,被调用者分配字符串缓冲区。这使得调用者可以完全访问该字符串,而无需担心userList中的数据可能被其他调用者同时更改。此外,调用者避免了访问可能被其他调用者同时更改的数据。

模式名称 概述
被调用者分配 在提供大型复杂数据的函数内分配一个具有所需大小的缓冲区。将所需的数据复制到缓冲区中,并返回指向该缓冲区的指针。

使用用户管理系统

您现在已完成用户管理代码。以下代码展示了如何使用该用户管理系统:

char* element;
addUser("A", "pass");
addUser("B", "pass");
addUser("C", "pass");

ITERATOR it = createIterator();

while(true)
{
  element = getNextElement(it);
  if(strcmp(element, "") == 0)
  {
    break;
  }

  printf("User: %s ", element);
  printf("Authentication success? %d\n", authenticateUser(element, "pass"));
}

destroyIterator(it);

在本章中,这些模式帮助您设计了这个最终代码片段。现在您可以告诉老板,您已完成了实现存储用户名和密码请求系统的任务。通过利用基于模式的设计来设计该系统,您依赖于已被证明在使用中的文档化解决方案。

概述

您通过应用第一部分中展示的模式一步一步地构建了本章的代码。一开始,您对如何组织文件和如何处理错误有很多问题。模式向您展示了解决问题的途径。它们为您提供了指导,并使得构建这段代码变得更加容易。它们还解释了代码看起来和行为方式的原因。在本章中,您应用了图 11-2 中展示的模式。在图中,您可以看到您需要做出多少决策,以及这些决策受模式指导的情况。

构建的用户管理系统包含基本功能,如添加、查找和验证用户。再次强调,该系统可以添加许多其他功能,比如更改密码功能,避免明文存储密码,或检查密码是否符合某些安全标准。本章节未涉及高级功能,以便更容易理解模式应用。

fluc 1102

图 11-2. 故事中应用的模式

第十二章:结论

你学到了什么

阅读本书后,你现在熟悉了几个高级 C 编程概念。当查看更大的代码示例时,你现在知道为什么代码看起来是这个样子的。你现在了解了在那些代码中做出的设计决策的理由。例如,在本书的前言中呈现的以太网驱动程序示例代码中,你现在理解为什么有一个显式的driverCreate方法以及为什么有一个DRIVER_HANDLE来保存状态信息。第一部分的模式指导了在这个示例及本书讨论的许多其他示例中做出的决策。

第二部分中的模式故事展示了应用本书中的模式的好处以及如何通过逐步应用模式来逐步增强代码。当面对下一个 C 编程问题时,查阅模式的问题部分,看看是否有一个匹配你的问题。如果是这样,你非常幸运,因为那么你可以从模式提供的指导中受益。

进一步阅读

这本书帮助 C 编程新手成为高级 C 程序员。以下是一些特别帮助我提高 C 编程技能的其他书籍:

  • Clean Code: A Handbook of Agile Software Craftsmanship by Robert C. Martin (Prentice Hall, 2008) 讨论了实现持久高质量代码的基本原则。对于任何程序员来说,它都是一本必读之作,涵盖了测试、文档、代码风格等主题。

  • Test-Driven Development for Embedded C by James W. Grenning (Pragmatic Bookshelf, 2011) 通过一个运行示例解释如何在硬件接近的程序环境中使用 C 实现单元测试。

  • Expert C Programming by Peter van der Linden (Prentice Hall, 1994) 是一本早期的关于高级 C 编程指导的书籍。它详细描述了 C 语法的工作原理以及如何避免常见陷阱。它还讨论了诸如 C 内存管理的概念,并告诉你链接器的工作原理。

  • 与我的书密切相关的是 Patterns in C by Adam Tornhill (Leanpub, 2014)。它也展示了模式,并专注于如何在 C 中实现四人帮设计模式。

结语

与刚从学校毕业的 C 程序员相比,你现在具备了高级的知识,知道如何编写更大规模和工业级别的 C 代码。现在你可以:

  • 执行错误处理,即使你没有像异常这样的机制

  • 管理你的内存,即使你没有垃圾回收器和析构函数来清理内存

  • 实现灵活的接口,即使你没有本地的抽象机制

  • 组织文件和代码,即使你没有类或包的机制

现在你能够使用 C 进行工作,尽管它缺少现代编程语言的某些便利性。

posted @ 2024-06-18 17:34  绝不原创的飞龙  阅读(5)  评论(0编辑  收藏  举报