代码的艺术:用工程思维驱动软件开发
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

2.7.2 模块的设计方法

1. 程序的构成

一段程序的构成如图2.8所示。

(1)程序由多个模块构成。

(2)每个模块内包含数据的定义、函数的实现和类的实现。

图2.8 程序的构成

2. 模块的形态

在实际代码中,模块长什么样子?

对于这个问题,我同样在多次现场交流中提出过。有些意外的是,确实有不少软件工程师答不上来。

下面我们看看在C语言、Python语言和Go语言中模块的表现形态。

(1)在C语言中:一个.c文件加上一个.h文件就构成了一个模块。.h文件用于声明模块对外的接口(包括需要外部看到的结构体定义),.c文件中包含的是模块的实现。

(2)在Python语言中:一个.py文件就构成了一个模块。一个文件要使用另外一个文件中的变量、函数和类,并需要用import来显式地声明。

(3)在Go语言中:一个Package是一个模块。一个Package内可能包含多个.go文件,但是这些文件之间是没有“隔离”的(不用import就可以直接访问另外一个文件中的变量或函数),所以不能将一个.go文件视为一个模块。

其他语言的模块形态这里不再一一赘述,大家可以参考以上几个语言的例子来判断。

3. 模块划分的重要性

在程序设计中,模块的切分非常重要,但是我发现在大部分的编程书中并没有对这点给予足够多的重视。

好的模块划分是让软件架构稳定的基础。如果模块划分得好,未来仅需要对模块内的实现做一些修改即可,对程序的改动量并不大;如果模块划分得不好,整个程序很可能要完全推翻重写。这就好比一座大楼,如果基础框架没有问题,若干年后只需要重新对内部做一次装修即可;但是如果大楼的基础框架存在问题,就需要将大楼完全推倒重建。两种结果的成本差异巨大!

模块划分的好坏极大地影响了软件的复杂度,而软件的复杂度决定了软件的可维护性。如果模块划分得不好,一段程序内的多个模块间会存在严重的耦合,这样的软件难以理解,也难以修改,往往牵一发而动全身。程序中的“耦合”并不是外部强加给软件工程师的,而是软件工程师自己造成的。模块间复杂的耦合关系就像蚕吐出的丝,最后把软件工程师自己给“绑”住了。我们在程序设计中要尽量避免“耦合”。

模块划分得好坏也决定了代码的可复用性。前面介绍“好代码的特性”时,有一条标准是关于代码的“共享”。如果一段程序内的模块划分得不够清楚,这段程序的模块是不可能被抽出来供其他程序使用的。

4. 模块设计的方法

说到模块的设计,“紧内聚,松耦合”是大家经常听到的一句话。这句话到底是什么意思呢?我曾多次在线下交流会上问过现场观众,发现没有几个人能解释清楚,他们更说不清楚在实践中如何落实这个原则。

关于模块设计,我这里给出三点说明。

(1)单一目的Single Purpose一个模块所提供的功能一定要聚焦和单一。不要把很多无关的功能都放在一个模块中。“单一目的”是模块设计中最重要的原则。只有做到了“单一目的”,才能实现“紧内聚”。

(2)明确对外接口。一个模块的对外接口是清晰和明确的。在2.6.5节中介绍了对外接口的重要性,对于一个模块来说,也要仔细设计它的对外接口。如前文所述,“全局变量”就是一种非常不好的接口方式。如果实现了“明确对外接口”,就可以做到“松耦合”。

(3)以数据为中心。在做程序的模块划分时,首先考虑有哪些数据类的模块,然后再考虑其他模块(如过程类的模块)。具体方法见本章2.7.3节“划分模块的方法”。在20多年前,GNU创始人Richard Stallman曾经到清华大学访问并发表演讲,现场有一位同学问他应该如何写程序。Richard说,应该从数据出发来思考。

5. 模块划分的误区

在模块划分方面,经常出现以下几种误区。

误区1:所有代码放在一个模块中,因为规模太小。

(1)对错误行为的描述:很多软件工程师把“代码量”作为划分模块的重要标准。例如,用Python语言编写某段程序,因为只有200行,于是都将其放在一个.py文件中。类似这样的情况我见过不少。

(2)对错误行为的反驳:首先,模块划分的原则和代码量没有任何关系。依据代码量来划分模块,违反了上面提到的“单一目的”原则。其次,程序的规模在早期是无法预估的。初始只有几百行的程序,经过一段时间的完善和发展,可能会达到几千行甚至几万行。如果在初期没有将模块划分好,等程序“长大”后再划分就已经晚了。这种“都放在一起”的模块其实也很难很好地“生长”。

误区2:把所有用到的附加功能都放在util模块中。

(1)对错误行为的描述:把程序所需要的各种辅助类的逻辑都放在一个叫作util的模块中,有时候也叫作common。

(2)对错误行为的反驳:这种做法也同样违反了“单一目的”的原则。util模块在开始建立的时候,可能很简单。但是一段时间后,这个模块会变得越来越“大”,越来越庞杂,最后成为一个非常难以维护的模块。其实,可以根据功能将util模块进一步划分为多个功能单一的模块。比如,将做文件处理有关的逻辑整合为file_util。

误区3:从“过程”的角度出发考虑模块的划分。

(1)对错误行为的描述:很多软件工程师在划分程序的模块时,是从程序执行的过程来考虑的。假如程序中有A、B、C三大处理环节,则相应地会划分为三个模块。也有一些软件工程师会将“系统初始化”作为一个独立的模块,而对于程序中的“数据”,则没有建立独立的模块,其定义和实现混杂在这些过程类的模块中。

(2)对错误行为的反驳:应该首先从“数据”的角度出发来考虑。具体方法将在本章2.7.3节“划分模块的方法”中介绍。

6. 题外话:C语言是面向过程的吗

我们常听到一种说法,C语言是面向过程的,C++语言是面向对象的。这种说法正确吗?

20多年前我在清华大学读书的时候,当时计算机系的蒋维杜老师告诉我:

“C语言不是面向过程的,而是‘基于对象’(Object Based)的。和‘面向对象’(Object Oriented)相比,‘基于对象’不支持‘继承’和‘多态’。”

而使用C语言,也可以实现对数据的封装。

在多次现场交流中,我会问一个问题:C语言中static关键字的作用是什么。大多数观众都能说出static是用于定义“静态局部变量”这一作用。例如,在以下程序中,num被定义为“静态局部变量”,以后在每次调用函数时就不再重新赋初值,而是保留上次函数调用结束时的值。

其实,这样的用法在真实代码实现中并不常见。而我个人的观点是,这种编写方式甚至是不利于程序的维护的。一个函数的实现应该是尽量“无状态”的。

而static的另外一个作用却很少有观众能回答上来,那就是当它放在全局变量的前面时,可以限制这个变量的访问范围。这个变量仅能被同一个.c文件内的代码访问,其他 .c文件是无法直接访问这个变量的。

在下面的例子中,使用static装饰了一个变量pTable,这个变量指向一个数据表。之后分别定义了一个“读接口”set()和一个“写接口”get()。这个模块外部只能通过这两个接口才能访问这个数据表,于是就实现了对数据的封装。

我个人一直对“面向对象”所提供的“多态”和“继承”这两种超级能力抱有非常谨慎和小心的态度。我也曾经花了不少时间去学习C++语言的各种复杂能力,但是后来却发现很多能力在工程实践中不一定是必需的,这些复杂的能力也给软件维护带来了困难。从软件可维护性的角度来看,如果能够实现同样的目的,方法应该是越简单越好,有时甚至要通过编程规范等手段来限制或禁止一些复杂方法的使用。

“继承”里面隐含着关于软件设计的一个巨大矛盾:软件到底是被一次设计出来的,还是被逐步发展出来的?如果要设计超过两层的继承关系,需要在早期就对多个类之间的关系有比较清楚的认识和设计。而在软件的实践中,在大多数情况下我们的认识是随着软件的发展而逐步深化的,不太可能在设计早期就能够看得这么清楚。而复杂的继承关系,对于软件后期的维护调整也是非常大的挑战。类的“继承”层级最好不要超过三层,而在大多数场景下只用两层就可以了。