3.1 具有可测性的C模块的那些元素
本书中的示例将采用模块这个概念。在我们的目的之下,模块就是系统中一个完备的部分,它有明确定义的接口。这个定义并没有讲一个模块有多大。在本书中,我们只会用很小的模块。这些示例中的模块会恰好与编译单元相同,然而在现实世界中,并不是所有的模块都与某个单一的编译单元相对应。你会发现可测试的代码需要模块化。你还会发现TDD会自然而然地产生模块化设计。
可测试性对于测试有相当大的正面影响。为了创建模块化的C程序,我们需要抽象数据类型这个概念。Barbara Liskov在她所著的《Programming with Abstract Data Types》[Lis74]一书中这样定义抽象数据类型(Abstract Data Type,ADT):“抽象数据类型只用可能对它进行的那些操作来间接定义,并且这些操作的效果(也可能是代价)有数学上的约束。”
在抽象数据类型(ADT)中,一个模块的数据被当做私有成员来对待。它被封装起来。我们可以使用几种模块化方法来封装模块的数据。第一个选择是用静态的只有在.c文件中可见的变量,这样只有同一编译单元中的函数才能访问它们。数据只能间接地通过在.h文件中有原型定义的那些模块的公共接口来访问。这个方式适用于只有一套数据需要处理的模块,有时称为单一实例模块(single-instance module)。
当一个模块要为不同的客户管理不同的数据时,可以使用多实例模块(multiple-instance module)。在多实例模块中,必须要初始化数据结构并把它传回给客户以保持其上下文。这里就是抽象数据类型发挥作用的地方。可以用typedef在头文件中提前声明结构体,像这样:
只要没有代码去解引用它,编译器会很乐意让我们把定义不完全的指针传来传去。可以在实现CircularBuffer的.c文件中定义结构体的成员,这样就有效地隐藏了数据,从而使得只有那些以该数据结构的完备性为责任的模块才能操作它。如果你熟悉POSIX接口(Portable Operating System Interface of UNIX)中的pthread库,就会明白它使用的就是这种技术。Unix中的FILE(文件)是另一个抽象数据类型的例子。
当用TDD来创建C模块时,我们会用到以下文件及惯例:
用头文件来定义模块的接口。对于单一实例模块,头文件由函数原型构成。对于抽象数据类型,除了函数原型,还会有一个用typedef来指向提前声明的数据结构的指针。正如多次提到的,隐藏数据结构是为了隐藏模块的数据细节。
源文件用来包含对接口的实现。它同时会包含任何所需的私有辅助函数和隐藏的数据。模块的实现会管理模块数据的完备性。对于抽象数据类型,提前声明的数据结构成员会在源文件中定义。
测试文件用来包含测试用例,以保持测试代码和产品代码分离。每个模块都至少有一个测试文件,一个测试文件通常仅包含一个,但有时会是几个测试组。测试组围绕组中所有测试通用的数据来组织。当一些测试需求的建立与其他测试显著不同时,我们就需要有多个测试组,甚至多个测试文件。
模块初始化及清理函数。每个管理着隐藏数据的模块都应该有初始化及清理函数。抽象数据类型完全隐藏的内部结构必然需要它们。C++把这个想法内置到构建和析构函数中。按照惯例,本书会为每个模块建立Create(创建)和Destroy(销毁)函数。对于由独立函数组成的模块,如strlen()和sprintf()这样的没有内部状态的模块将不会需要初始化与清理。
遵循这些实践及习惯,代码将变得更容易测试并容易阅读和扩展。并不是完全不能测试那些可以随意访问数据结构/函数的代码,只是那会更难一点。在第一个例子中,本书将会用单一实例模块来测试驱动开发一个LED(发光二极管)驱动程序。我们以后再使用抽象数据类型。