全部文章
Architecture 软件设计 架构

软件设计(三):模块设计

战略编程需要愿意为好的设计投资,这里先给出一笔投资的方向--模块怎么切,接口怎么收,判断的标准是这个模块深不深

更新于 2026年8月19日 2,311 字 约 6 分钟
ARCHITECTURE 软件设计(三):模 块设计 软件设计 · 架构 § 04
阅读进度 0% 继续阅读

模块的成本是它的接口,收益是它的功能,在好的模块中,强大的功能往往藏在一个小接口后,接口每窄一分,全系统要背的复杂性就少一分

一.动机

切模块就是在管理复杂性,根据第一篇文章中的内容,总复杂性C=∑pcp⋅tp\sum_{p} c_{p} \cdot t_{p},而模块化则是唯一能够把大块c变成小t的技术,让每个开发者在任何时刻只面对整体复杂性的一小部分。

理想状态:每个模块完全是独立的,在任何一个模块中干活不需要知道其他模块里的任何事。这个时候整个系统的复杂性就相当于最差的那个模块的复杂性。但这个现实中是达不到的,模块之间必须相互调用,而调用就会产生依赖:方法签名改了,所有调用点都需要跟着改。

所以可以得到模块化设计的目的:把模块间的依赖降低到最少。

为了管理依赖,需要把每个模块想成两半:

  1. 接口:其它开发者为了用你必须知道的一切,描述模块做什么,不说怎么做
  2. 实现:兑现接口的代码,改实现只要不碰接口,其它模块零感知

看一个例子。假设你写了一个平衡树模块:里面是复杂的旋转、分裂、再平衡逻辑,但使用者看到的只有 insert / remove / fetch 三个操作,调用时给个 key 和 value 就行。树怎么保持平衡,接口里一个字都不提——这就是好接口的样子。推论也很直接:在模块里干活的人,必须懂本模块的接口+实现,加上被调用模块的接口;但不应该需要懂任何其他模块的实现。

模块这个词的范围其实很宽:类是模块,方法和函数是模块,子系统和服务也是模块

二.概念

2.1 接口

接口里有什么,抽象就漏什么

接口不只是方法签名,它包含两类信息:

1.正式部分(编译器能查)

方法的参数名和类型、返回值类型、抛出的异常;类的全部 public 方法签名和 public 变量。这些是显式写在代码里的,语言能检查调用是否匹配。

非正式部分(只能靠注释)

2.高层行为与使用约束

「这个函数会删掉参数指定的文件」「方法 A 必须在方法 B 之前调用」——语言无法理解也无法强制。对大多数接口,非正式部分比正式部分更大、更复杂。判断标准:使用这个模块需要知道的任何信息,都是接口的一部分。

2.2 抽象:省略掉不重要的细节的简化视图

和模块化隐形不离的是抽象。

在软件设计哲学这本书中对抽象的定义是:每个模块通过接口提供一个抽象:接口是功能的简化视图,实现细节对抽象来说不重要,所以被省略。

但这里最关键的词是不重要,省略的不重要细节越多越好,但只能省略真的不重要的,抽象有两种翻车的方式,都和不重要有关。

1.翻车1:装进了不重要的细节

抽象变得比必要的更复杂,使用者的认知负荷白白上升,接口里每一个多余的参数,概念,都是要背的税。

2.翻车2:漏掉了重要的细节

看着简单,其实不够用。只看接口的人拿不到正确使用它所需的信息——晦涩由此产生,未知的未知随之而来。

翻车2有个教科书级的例子,文件系统,文件在磁盘上怎么存储对用户来说是不重要的,可以放心省略,但文件系统会为了性能把数据缓存在内存里延迟写盘,对数据库这类应用性命攸关,数据库类应用必须要知道数据什么时候真正落盘,才能保证崩溃后不丢失数据。所以何时刷盘的规则必须出现在文件系统的接口中,否则就是假抽象。

设计抽象的全部功夫,就在于搞清楚什么是重要的,然后想办法让重要的信息尽可能少。

抽象并不是程序员的专利,微波炉把交流电如何转微波,腔体内均匀加热的复杂电子学,给用户简化为了几个旋钮,这也是一种抽象。

三.深模块 vs 浅模块:一笔成本收益的账

最好的模块往往是功能强大,接口简单的,一个模块的收益是它的功能,接口是它的成本。

为什么接口是成本:

接口代表模块强加给系统其余部分的复杂性,接口越小越简单,那么引入的复杂性也就越小,并且接口内部有很多方面可以改而不影响任何人,改动不放大就是最值钱的设计,所以接口是好东西,但更多,更大的接口并不更好。

3.1 深模块的典范:Unix文件I/O

Unix及其后代(例如Linux)的文件I/O,是计科里最漂亮的深接口,基本调用只有五个,签名都很简单。

int     open  (const char* path, int flags, mode_t permissions);
ssize_t read  (int fd, void* buffer, size_t count);
ssize_t write (int fd, const void* buffer, size_t count);
off_t   lseek (int fd, off_t offset, int referencePosition);
int     close (int fd);
// open 返回一个整数文件描述符 fd,之后所有操作都用它指代这个文件
// 默认顺序读写(最常见的情况);想随机访问,用 lseek 挪一下位置

这五个函数背后解决的问题包括:文件在磁盘上怎么布局才能高效访问,目录和层级路径怎么解析,权限怎么限制,中断处理和后台代码怎么分工与通信、多文件并发访问的调度策略…所有对调用者全部不可见,更惊人的是:Unix I/O 的实现这些年被彻底重写过好几轮,而这五个内核调用没变过。接口窄,实现就有彻底翻新的自由——这就是深度换来的。

3.2 浅模块

浅模块的接口复杂度相对于提供的功能来说太高,不但没有管复杂性,还往里面加了。

一个极端例子:

private void addNullValueForAttribute(String attribute) {
    data.put(attribute, null);
}
// 这个方法没有提供任何抽象:全部功能都摆在接口上
// 文档写规范了会比方法体还长;敲这个调用比直接写 data.put 还费键盘

这个方法做的事从头到尾就一行,往一个叫data的Map里以attribute为键,塞一个null。

这个方法实现的细节只有一行data.put(attribute,null),而它的接口(方法名addNullValueForAttribute+参数attribute)把这些细节原封不动摆回了台面上。

并且更严重的问题是接口比实现还要啰嗦,直接写的话是data.put(attribute,null),调封装的话是addNullValueForAttribute(attribute),这是连打字都不划算。

链表类是常见的浅模块,插入删除一个元素就几行代码,接口的复杂度几乎等于实现的复杂度。

3.2.1 类炎(Classitis:小类崇拜)

编程界流行的是类应该小而不是深,学生被教导:类设计最重要的事是把大类拆成小类;方法也一样——任何超过 N 行的方法都应该拆开,N 可以低到 10。这套教条的极端形式称之为类炎,病根是一个错误观念:*类是好的,所以类越多越好。*想要更多功能?那就再造几个类。

类炎生产出的类,单个看起来都简单,加起来却是灾难,小类贡献不了多少功能,所以得要很多个;每个类自带一个接口;这些接口累积起来,在系统层面制造了巨大的复杂性,这正是积少成多的体现。

类炎最显眼的是Java类库,要从文件里读取序列化对象,必须创建3个不同的对象。

四.总结

设计接口时,先问最常见的情况是什么,然后把它做成默认,最省事的路径。

接口的有效复杂度 ≈ Σi (特性 i 的复杂度 × 开发者需要知道它的概率)

// Unix:随机访问的 lseek 存在,但只做顺序读写的人不需要知道它 → 概率≈0,贡献≈0
// Java:缓冲这个特性人人都需要,却被设成「显式包一层」→ 概率≈1,人人付费

接口是成本,实现是收益;好模块很深,浅模块不如不要;接口必须让常见操作最简单。

⚑

浅模块

接口的复杂度相对于它提供的功能太高。浅模块在复杂性战役里帮不上忙:它提供的好处(不用了解内部)被学习和使用其接口的成本抵消了。小模块容易变浅——「小」不是免罪金牌。

⚑

文档比代码还长

一个方法或类如果要把文档写规范,文档长度超过代码本身——说明这个抽象什么都没藏住,接口和实现一样复杂。它多半是个应该被合并或删除的浅模块。

⚑

拿「小」当设计目标

「类要小」「方法超过 N 行就拆」——按行数和类的数量论英雄,是类炎的病灶。拆之前先问:拆出来的每一半是深了还是浅了?两个浅模块换不来一个深模块。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

目录