模块化 C 语言

如何通过C语言,编写可持续且可复用的代码。

版权声明

Copyright c 1994, Robert Strandh. Permission is granted to make and distribute verbatim copies of this manual provided the copyright notice and this permission notice are preserved on all copies. Permission is granted to copy and distribute modified versions of this manual under the conditions for verbatim copying, provided that the entire resulting derived work is distributed under the terms of a permission notice identical to this one. Permission is granted to copy and distribute translations of this manual into another language, under the above conditions for modified versions, except that this permission notice may be stated in a translation approved by Robert Strandh.

1 引言

本文讲述如何使用C语言去编写可复用、且具有可维护性代码。

可复用的代码对于避免软件开发之中重复的工作是必要的,软件工程师会因此获益,使用现有的组件,而不是随意拼凑的乱起八糟的代码。于此同时,可复用的代码因被组合到可复用软件的共享库中的可能性,从而提升了开发效率。因此当我们讨论可复用性时,是在讨论如何避免在两个以上的程序之间重复同样的劳动。

可维护的代码大同小异,但与可复用代码的目的稍有不同。可维护的代码,不变的变量是可维护性而变动的是时间。我们在这里同样尝试避免重复劳动,但是是优化同一段代码(而不是将不同的代码可复用化)。为了使之成为可能,代码必须易读且在可预知的情况下容易修改。

本文面向使用C语言编写软件的程序员,但不面向使用C语言编写系统的程序员。究其原因,系统编程比起软件编程,性能方面的重要性要超过可复用性和可维护性。这两个目标往往不共戴天,但也有特例。

软件有很多种,本文面向使用编制符号程序(symbolic programming)的软件,与大量数字运算的数字化编程(numeric (number-crunching))和信息管理编程(administrative (management information systems) programming)相对。编制符号程序指处理非常复杂数据之间的对象与关系。这种对象通常对应现实世界中的人,摩托车,调节器,或是从计算机科学中衍生出来的纯粹抽象对象例如:二叉树,文件系统和用户账号。

2 C 语言

夫 C 语言对于编写软件来说并不是一个很好的选择,但也不是太差。然而,自从C语言被标榜为用于编写操作系统的语言,他就拥有了大量的特性,这些特性在不加以限制的前提下,使之更难以复用与维护。同时C语言也有一些特性使之可以写出非常棒的代码,但这些特性在实战中很少被使用。本文将指出一些应该避免使用的特性,并阐明不同的特性。

有一些重要的使用C语言编写程序的优点。第一,用C语言编制程序的高效与否是相对的。现有的编译器非常优秀,对于Unix的开发环境也非常的舒适。通过标准化C,编译器可以改变函数参数的个数和类型。使用static关键字也使得封装信息成为了可能。接口的声明和实现可以通过头文件清晰的分离。

但不幸的是,同样也有一些重要的使用C语言编写程序的缺点。其中最为突出的是对于自动化内存管理、垃圾回收机制(garbage collection)的缺失。对于垃圾回收的缺失有一种趋势,将抽象化逐渐架空,且同时有将一个模块的实现细节暴露给客户的问题。而且对于垃圾回收机制的缺失,直接致使了大部分程序员尝试去编写会导致长周期维护问题的随心所欲、不统一的( Ad Hoc )内存管理代码。例如:在一个需要有大量动态内存分配的程序中,程序员会为了效率问题使用大量的指针去管理对象。然而,大量的指针操作使何时可以安全释放对象变得难以得知。而程序员们盲目地应对这个问题,有的依靠复制对象因而保证仅有一次使用它们的次数,因此他们可以在任何时候安心释放。虽然拷贝对象可以解决很多野指针的问题,但他不可避免的使的对象共享变为了不可能。非共享的对象、以及依赖于对象拷贝将会导致本应一致的对象的不一致性,而这是我们希望避免的。

  大量的对象拷贝一定是一种糟糕的解决是否在释放野指针指向对象的方法,新标准C++ 11/14中大量讨论的完美转发(perfect forward)和引用参数已经在试图冗余的对象拷贝最后一部分残兵剩将。
  释放已悬挂的对象非常危险,甚至只要程序中任何一处有这样一个内存管理上的错误,这个程序就是可能随时崩溃的。一个语言是否支持GC直接意味着编码编译出来软件的健壮性。如今GC的性能已有了飞跃的提升,例如最典型的Java的GC技术以及微软.Net的GC技术,新生的语言几乎无不支持GC,而指针操作被列入黑名单之中(这不意味着某个语言不可使用指针操作,而是这一过程异常的繁琐)。

就算ANSI C已经是如上所述有诸多限制的一门用于编写软件的语言,本文仍然会给出一些能帮助您写出更好C程序的有帮助的原则。

3 可复用性和可维护性

3.1 可复用性

可复用性指软件组件的质量已足够泛性,以至于可以独立于当前解决的软件问题。同时,因为他是独立的,它可以在任何程序中被使用。但独立性对于可复用性来说是非充分的,他需要足以容易理解,使得拼接代码这一行为变得不再值得去做。多目的的组件相比较于单目的的组件更难让维护者去理解。自然就能推导出组件需要一个非常特定的目的,而为了实现这个目的应该去实现一个函数或类型。

在结构化编程的时代,组建的目的常常表现为一个特定的函数。这个方法对于数字计算问题有一定的效果,总的来说应该是因为操作的数据类型非常简单且是标准化的(例如:整数,浮点型数,复数,和他们的数组类型)。

而在编制符号程序编程中,数据对象远比上述的要复杂的多。通常在不同的程序中对象并不以相同的方式呈现,因此需要提供一些参数接口去适应不同的应用程序。对于这种类型的编程,看起来使用操作对象的类型去创建组件更为合适。因此一个组件通常都包含一个对象的定义,以及所有对于这个对象的操作。在本文中我们将用“模块化”一词指代这样的一个组件,而模块化编程是可复用性的敲门砖。

就可复用性而言,一个典型的模块如何实现的无关紧要,只有接口是被用来重复使用的,因此当我们思考可复用性时,我们应该思考我们思考模块暴露给外部世界的唯一途径。

仅仅是设计接口就已经在强化可复用性。可复用性与那唯一的接口息息相关。

3.2 可维护性

可维护性指程序可被更新和迭代的可能性,有证据表明更新一个特定程序占用的以及其他修改,例如修复bug,所使用的时间占到程序开发总时间的3/4。因此我们需要编写不仅是开箱即用,而是容易更改并扩展的新版本的程序。

通常,维护代码的人和编写程序的人是两个人,因此一个编写他人可以预测结果且可修改的代码的方法非常重要。

然而模块化就是复用性的敲门砖,但要真要说将一个程序变为良好的可维护性的话,模块化还不够,组件的实现仍有可能是不够明确与难以更新的。因此可维护性除了接口外还取决于一个模组实现的方法。

对于一个组件来说,接口更多代表了可复用性,而内部实现更多代表了可维护性。

4 如何写出可复用的代码

本文主要的目的就是向程序员表达一种编写C语言时的思考方向。编写可复用代码是一种给思维模式,你总是应该意识到你写下的代码的后果。掌握这项技能可能需要数长几年之久的时间,而仅仅是像本文所表述的看上去这么简单。

我尝试去例举出一连串的可复用模块,但它一定不是完全的。同时,举例一个表格就让你能明白在特定实战中该如何发挥也是不可能的。但我们仍然提供了一个形如点检表一样表格,使得程序员们可以通过按布点检来验证所写的代码是否真的可复用。点检表请见 第七章【“烹饪书”】,第24页

4.1 函数化的模块与数据化模块

有两种不同的编写模块的方法,第一种是去实现一个特定的函数,这个函数并不是数学含义而是广义上能完成一系列任务的函数。第二种是创建一个包含所有操作的对象类型,在这种情况下我们称模块化是一种抽象数据类型。

这两种不同方法创建的模块将应用程序分为更小的部分。

自上而下的设计是旧时使用结构化编程时流行的法子。这个方法以思考决定软件做什么为起点。简短的对软件所作所为的叙述之后会被提取并提炼成几个子任务,以此递归,直至底层代码。那些使用这个方法的模块往往是函数化的,也就是说模块的目的是去关心软件特定的函数。通过自上而下实现的函数化的模块通常是用来解决特定软件内特定问题的。这一现象是他们的方法直接导致的结果:他们只实现程序工作的一小部分。

自下而上的设计则是一种构建模块的更为现代化的设计。主要思路是尽可能长时间不去考虑程序做什么,取而代之的是我们先定义一些程序可能会用到的对象类型,我们称他们为“抽象数据类型”(ADT)。下一步是定义一系列对于这些数据类型的操作,而不是这些抽象数据类型将如何在软件中被使用。使用这种方法,我们和数据而不是函数进行对话。

4.1.1 函数化模块

自上而下设计与阶梯式问题提炼鼓励模块去实现函数。大多数传统设计方法都会导致实现函数的模块。

函数式的问题是必须操作相同类型的数据,因此你不可能在模块内部隐藏一个数据定义。因为同ADT一样数据定义反映了实现细节,我们必须将实现暴露在全局的情况下,而这一行为严重阻碍了可复用性和可维护性。

我们举个例子,在一个窗口化的系统中,我们有一个控制窗口大小并且能移动窗口的模块,这些是显而易见的窗口化系统的功能;因为这些控制内部数据结构的函数同时要显示显示窗口,他们必须双方都知晓数据类型的细节。因此我们不得不将这个数据结构放在同时被这两个模块包含的头文件中,但当我们放在头文件中的那一刻起,对于用户就是完全暴露的了,我们创建了窗口实现的公共细节,也就是说,因为头文件中包含有只考虑实现细节的部分(例如:对于窗口的大小和尺寸),我们现在已经将这些细节暴露给了所有关心去使用它们的人,接着会收到对于可复用性和可维护性缺失而导致的不满。

4.1.2 数据模块

另一种创建模块的方式是考虑数据,这种模块包含所有对于某个特定数据类型的操作。例如,如果“人”是一种数据类型,那么这个模块就会去实现包含例如计算这个人生日或是地址的操作。

数据模块能够屏蔽内部细节,其对于客户端唯一可见的是接口,也就是说操作,也就是实际的实现是不可见的,因此就可以在不影响客户端的情况下被修改。

4.2 接口 vs 实现

一个可复用的组件含有两个不同的部分:接口和实现。

接口描述模块如何被使用。也就是说,如果模块是一个数据模块,他描述可以对该对象可做的所有操作,这些操作应该在对实现毫无依赖的前提下通过一个抽象方法去描述。事实上,他应该做到在未修改实现的前提下就能修改接口的程度,例如:让我们假设我们正在写一个“集合”模块,那么对于这个集合会有例如:床i就那一个空集合,创建一个单例集合,将两个集合取并、交集。不管选取何种实现,不论是链表还是哈希表,都应该能正常运行。

子啊C语言中,我们尽可能将接口信息放置在头文件中,而C源文件用于储存实现细节。通常,如果使用C的结构体去实现一个数据类型。结构的声明应当被放置在.c文件中,将结构体声明放置在头文件中通常意味着将实现细节暴露给了模块的客户,而这是与模块化编程的本质相违背的。

为什么我如此强烈反对将实现细节暴露给客户端?为了回答这个问题,我们将会将模块比做成战术,而将接口比喻为策略。正如战术应当保证不确定与随机应变,正如一个实现是可以随时升级和更新的,当我们更新模块是,我们希望能对客户端做到尽可能少的影响。而接口犹如策略一样,是长久的不动的,对于客户端来说接口的改动应该是尽可能屈指可数的。

4.3 函数式 (FP)vs. 命令式(IP)接口与实现

本质上来说,有两种操作ADT的方法。

其中一种,操作表现得像函数,也就是说他们直接接受参数然后返回一个构造对象,没有对接受的参数做出任何修改。举个例子,如果有一个操作取两个集合的并集,那一个函数式的操作会在没有修改这两个集合任何一个的情况下,返回第三个是这两个集合合集的集合。这种方式的接口在函数式编程社区非常受欢迎,因为这是引用透明性(Referential transparency)的前提,并提高程序的性能。

另一个接口风格是命令式,在那种风格中,操作不返回任何新的值,取而代之的,操作像程序指令一样,也就是说有一个或多个参数在这一过程中被更改。举个例子:重新假设我们希望获得两个集合的交集,但这是通过命令式的范式,这是取交的函数将会返回void,然后这个操作被定义为,会更改第一个输入的参数为取的交集的结果,而第二个参数并不会被更改。

无论你选择函数式还是命令式接口,实现的细节必须服从于接口。如果你选择了函数式接口,你就绝不能修改任何参数。这一限制可能暗示了你需要为返回值分配新的内存。如果是这样的话,内存销毁将会带来一系列问题,由于共享对象的在场,我们必须明确两点:对象销毁由谁来负责,对象何时销毁。而另一种可能是使用垃圾回收器,例如 cgc。

如果你选择了命令式,就必须指明实现会改变哪一个对象,这也会因为共享对象而产生问题。例如:两个变量同时包含了一个对象,则其中一个的改变就会影响到另一方,因此如果x,y双放都包含有一些列的元素,那么将x与其他集合做交集时,y的元素也一定会受到影响。这一限制使得数据类型的声明变得更具有欺骗性。他不包括,例如:将一个空集合当作是NULL指针。很容易明白如果x指向了为空集的y,那么当y与其他非空集合进行交运算时,我们的原则规定在这一操作之后x和y必须相等,但我们其实完全没有办法修改x的值。

我们建议解决使用头部数据(header of an object)存放于对象之中来解决这一问题。因为即便是一个空的集合,空的列表等等,都可以通过操作而产生的证明他是空对象,而真正的集合或列表在他之后才会被包含在头数据中。这一方案还有一个额外的好处,那就是你还可以在头数据中储存其他信息,例如表的长度,集合元素的个数,元素的比较函数等等。

尽管这一解决方案看起来像是对内存的浪费,但隐藏实现细节是值得的。我们必须进我们所能去避免所有会给客户端暴露实现细节的可能性。否则我们就会确定客户端可以了解到内部设计的细节,从而可维护性和可复用性大打折扣。

4.4 避免独断的限制

我们希望去限制数据类型可能的大小。例如,Berkeley Unix就强制把文件名限制为256个字符,因为256已经足够了;或者你会想到将一行文本最长限制为1024字符,因为没有人会写这么长的一行。

然而,设想一个模块的具体使用情况并不是一件好事,新版本的Unix已经去除了许多文件名的限制,而模块可能因此重写。更糟的是,可能大家谁也没注意到,然后哪天谁用了个更长的文件名就崩溃和给出了错误的结果。又或是有些人把那个行模块用在了非人类打字(例如AI)上,然后不用做可读的用途,那每一行的长度肯定会远远超过1024个字符。

一个复用性基本的原则因而就是避免任何的刻意限制。当然,限制必须是存在的,而诀窍是避免独断的那些。不读端的例如:将文件名长度定义为2的32次方,因为这有可能是机器本身可读地址的限制。

4.5 尽可能少的假设

模块应该尽可能少的假设,这是一个将模块实现视为模块固定用途的实现代码编写者常犯的一个错误。

举个例子,我们现在尝试着去编写一个以字符串前开头两个字为基础的哈希函数,这一尝试假设字符串是随机且可以仅通过两个字符就能区别他们。然而这个函数很可能用在某一个有系统性开头命名的组件上,例如某一个在函数命名开头不变的前缀,这种情况下假设完全不成立,且对运行效果有很大的影响。

另一个例子假设有一个模组要去读取文本文件,因此假设不存在其他的字符存在。在本例中,你可以把它当成是由Unix Lex工具生成的语句分析器。如此一个语句分析器以一个0为其读取结束的标志。但我们不可能去分析自然带有很多0的二进制输入。

如果一个算法比其该是的更复杂,这带来的后果将比其看起来的要严重得多。虽然没有强制很严格的限制,但是这个模块的大小一定超过了其应有的(因为变复杂了)。例如,我们提供一种通过文字查询特定出现文本或模板的模块,一种最直接的实现就是去查询所有可能出现的位置。但对于巨型的文本和模板这种方法是不可取的,通常线性算法和这种方法一样。

尽可能避免如下的解决方案,注意:

  • 假设Unix目录名长度有限制

  • 假设文本输入

  • 假设固定的行长度

  • 假设只存在小容量的对象

  • 假设只存在一小部分对象

4.6 尽可能少的传入参数

要给可复用的模块的接口参数详细说明,可能会需要去设计非常多的参数供用户去经可能使用该组件去应对众多情况。然而太多的参数会使一个用户对于去了解参数的意义感到厌烦,其结果是参数没有最优化而导致性能的降低。而最糟糕的情况是可能有潜在的用户决定去写一个更容易理解的特定模块,但重写意味着编程效能的浪费。

举个例子,一个哈希表的模块可能含有一个去决定哈希表大小的参数。然而可能存在这样一个潜在的用户,他不知道取什么值比较好,如果用户猜错了的话可能会导致很低的性能效益,而设定的太高又会浪费内存。

对于这个问题的解决方案就是尽可能动态的改变哈希表。我们应该给空表一个最小值,并且给定一个增长值。在这些情况下,不仅用户不用再去关心这个值的大小,程序表现得也会更好。

4.7 模块的预编译

模块需要可以被预编译并镶嵌在某个库中,且不没有任何访问客户端的代码。这一原则规定了代码可以在多个程序中被共享,这同样避免了在客户端代码更改时模块的重新编译。

例如:一个模块可能含有一个可以决定数据结构大小的参数,客户端模块在代码中给这个参数取了一个特定的值,因此,这个参数的值之前这个模块无法被编译,而且这个模块在这个参数修改时需要每次都被重新编译。

解决方法是避免会影响到编译的参数。

4.8 泛型 vs. 多态

有一些模块定义的数据类型中包含着其他数据类型,这样的模块被称为容器。例如:堆、列表、队列、哈希表、树等等。其中一定包含着两种包含其他类型对象的方法。即:多态、和泛型。C语言这两种范式都不支持,但通过C的预编译功能的巧妙方法去实现这两种方法和泛型指针,我们同样可以达到一样的目的。

4.8.1 泛型

一个包含有一个在模块代码编写时类型不确定的容器模块叫做“泛型”,取而代之的,它的类型是一种特殊形式的类型,类型的值在编译的时候通过客户端模组使用这一容器而初始化,例如:客户端需要一个整数类型的二叉树,则客户端此时就需要把泛型的二叉树模块拿出来,并实例化他的类型为整数型。在实例化之后,这个泛型模块就会如同其他正常的模块一样被编译。

泛型模块必须在编译时实例化(确定类型),每一个实例都是一套完全不同的代码。因此,泛型类型不能再运行时被共享,一个泛型对象同样不能被预编译并放入一个库中以备后用。在实例化之后总会存在一些编译操作。

编译器实例化的原因是为了确定对象的大小,泛型模块的宗旨是能存放任何大小的对象,

此时我不得不想到,那例如C++中的偏特化(template specialization)是否违背了泛型的初衷呢?或者只是模板的花拳绣腿呢?

因此举个例子:为了去移动或分配对象,我们就必须知道对象的大小;得知对象大小是移动内存的代码的依据,因此必须直至类型被得知了才能够进行代码的生成。因为最终代码在客户端代码出现之前是不可得知的,最终代码只有客户端的代码出现之后才会确定,最终的编译结果也因此必须在客户端代码出现之后才有。在另一方面,链接器不够灵活,不能在代码生成时进行链接,因此,所有的代码生成必须在链接之前全部完成。因此泛型模块在编译和链接这两个步骤中间被完成。

泛型模块破坏了模块的代码必须与客户端代码无关的原则,同时也破坏了一个模块必须能够单独编译并可以被放在共享库的原则。

在C中使用泛型的方法是通过预编译,将变量的类型在编译之前全部替换。想做到这样并不容易,尤其是避免所有的命名冲突同时又做到对私有数据的归纳概述。

4.8.2 多态

另一个可以被许多数据类型使用,并且可以放在其他模组里的方法是使用多态。多态模组可以编译好了放在其他库中,同时仍然可以接受不同的类型。

对于一个多态的模组,所有其包含的对象都必须是相同大小的。而通过将对象大小作为参数传入可以除去这一限制,但只推荐在特殊情况下这么做。举个例子,qsort函数就是一个多态模组。他支持传入任何类型的对象,并通过传入的比较函数将对象排序。

如果并不将对象大小作为参数传入模块函数,那么所有的对象都应该是相同大小的。唯一能实现这一目的的就是将所有的对象作为指针传入,这一操作被称为指针抽象。

4.9 指针抽象

如果我们有条理的间接使用指针进行对象操作,我们称之为指针抽象。指针抽象得益于它的复用性具有很多的优点。首先,它允许我们隐藏对象的实现细节,因为所有对于对象的操作都需要通过传递指针参数的方式提交给函数。第二,指针复制相比起明显的大对象赋值具有明显的效率提升。第三、它允许容器变为多态,即,由于指针都是相同大小的,就可以用来创建操作任意对象的模组。(译者注:例如STL的迭代器)最后,由于对象仅仅只存在一份原型,所以我们能保证对于一处的修改可以在所有引用位置起效。

由于通过指针抽象,对象只通过其指针进行引用,客户端不需要分开指针指向的对象和其本身的类型名称,客户端代码应该将指针视为对象,我们通过这一声明

typedef struct datatype *datatype;

并将其放置于头文件中,客户端代码就可以独享指针的操作了。

使用指针抽象的主要缺点是他需要更多在堆上的动态内存。而由于C语言通常不提供垃圾回收机制,使用大量分配的堆内存将会成为使用C语言的一个问题。因此,在操作这些堆内存分配的对象时我们需要格外地注意释放地对象是否在系统的其他地方尚存在引用。一个通常用来解决这个问题的方法是使用引用计数,否则确切掌握有多少指针产生的引用实在是有些困难。另一个方法是使用名为cgc的垃圾回收器(注:原文编写的事件较为久远,译者在搜索引擎中并未找到这款GC器)

另一个指针抽象带来的不便就是容器不再是类型安全的了。一个容器实际上是将其装载的对象转化为一个泛型指针,而这导致客户端代码必须使用一个类型转化才能访问到它本身的数据。

4.10 数据驱动编程

有时候我们通过由数据主导函数来进一步加强私有数据的封装,这种编程被称为数据驱动编程,加下来我将使用一个例子来说明。

Last updated