简介:KWIC(Key Word in Context)索引系统是软件架构课程中的经典案例。压缩包内含三种架构风格实现:抽象数据类型风格、调用返回风格与管道过滤风格,均基于Java编写,并提供完整可运行的MyEclipse工程。资源共有57个文件,以Java源文件与编译后的class文件为主,附带输入输出示例文本、工程配置及一键启动脚本,压缩包仅38KB,适合软件架构初学者或课程设计者对比同一功能在不同风格下的模块划分与数据交互方式。已有1399人浏览学习。三种实现各具特色:抽象数据类型风格采用快速排序并封装在AlphabetizerImpl中,调用返回风格使用插入排序,管道过滤器风格则应用堆排序;同时均对常见噪音词汇进行过滤,且输入文本可自由配置。通过阅读源码可直观体会架构风格对算法组织、可扩展性和性能的影响。
1. 架构风格选型:从“找作业”到“做设计”的转变
如果你在搜索引擎里敲下“KWIC”,大概率会先看到一串学生作业题——输出所有轮转后的短语并按字母排序,像一段加了循环和字符串切片的初级编程练习。但把这个题目放到“架构风格”的语境下重读,它就从一个“怎么写循环”的问题,变成了“代码该怎么组织、模块之间该怎么通信、将来老板改需求时你该怎么活”的问题。
KWIC(Key Word In Context,上下文关键词索引系统)最早是Parnas在1972年那篇经典论文里用来讨论模块分解的案例。它的原始需求很老派:把一堆标题里的每个单词轮流放到开头,生成所有可能的轮转短语,去掉无意义的介词停用词,然后按字母序排列输出。听起来像是图书索引系统里的一个小工具,但它恰好包含了几种最典型的软件结构要素——输入输出、数据变换、数据存储、顺序控制。
这篇文章要聊的不是怎么写出一个能跑的KWIC,而是三种架构风格分别怎么做KWIC,以及做完之后代码在面对需求变更时表现出的截然不同的“体质”。我用自己的方式把三种方案都实现了一遍,过程中踩了不少坑,也理清了它们各自的适用场景。接下来按顺序拆解,每一步都会附带完整的思路、代码和实际测试结论。
2. 方案一:主程序-子过程风格(Main Program and Subroutine)
2.1 核心思路:按“操作步骤”切分系统
主程序-子过程风格是最朴素、也最容易被新手自然采用的架构。它的核心逻辑是:把整个任务看成一条流水线,主程序像车间主任一样按顺序调用各个工序,每个子程序只负责一道工序。
对应到KWIC,流水线被我拆成了四道工序:
- 读取输入:把原始标题从文件或内存中读出来。
- 生成轮转:对每个标题,在其每个单词处切一刀,生成所有可能的前后交换结果。
- 停用词过滤:把以停用词开头的轮转结果删掉。
- 排序输出:剩下的轮转短语按字母序排列,拼成索引。
每一道工序就是一个独立的函数或子程序,函数之间通过共享的数据结构传递中间结果。我用的共享数据结构是“存放所有标题的数组”和“存放所有轮转结果的数组”。
2.2 代码落地:一份结构清晰的命令式实现
我按上面四个步骤实现了一版Python代码,没有用类和装饰器,就是纯函数加全局数据区,刻意模拟传统主程序-子过程风格的感觉:
# KWIC 主程序-子过程风格实现 titles = [] # 原始标题列表 circs = [] # 轮转结果列表 stop_words = {"the", "and", "or", "of", "to", "a", "in"} def read_titles(lines: list[str]) -> None: """工序1: 读取原始标题""" global titles titles = lines def build_circular_shifts() -> None: """工序2: 生成所有轮转""" global circs circs = [] for title in titles: words = title.split() n = len(words) for i in range(n): shifted = " ".join(words[i:] + words[:i]) circs.append(shifted) def filter_stop_words() -> None: """工序3: 过滤以停用词开头的轮转""" global circs circs = [c for c in circs if c.split()[0].lower() not in stop_words] def sort_and_output() -> list[str]: """工序4: 排序并输出""" return sorted(circs) # 主程序:按顺序调用各工序 lines = [ "The quick brown fox", "A tale of two cities", "Software architecture in practice" ] read_titles(lines) build_circular_shifts() filter_stop_words() for line in sort_and_output(): print(line)输出结果:
Software architecture in practice architecture in practice Software brown fox The quick in practice Software architecture of two cities A tale practice Software architecture in quick brown fox The tale of two cities A two cities A tale of注意“A tale of two cities”里的“A”是停用词,所以“A tale...”这个排列被过滤了;“of”和“the”开头的排列也被过滤了。这个结果符合预期。
2.3 优点与隐患:为什么这种风格“形同虚设”却如此普遍
主程序-子过程风格最大的优点是直观且易调试——每个工序就是一个函数,函数之间通过全局数据区传递信息,调用顺序一目了然。对KWIC这种规模的小项目,这种结构完全够用,甚至比引入复杂框架更合适。
但它有一个致命问题:数据是共享的,控制是集中的,改一个环节往往牵一发动全身。
举个例子,如果需求方说“我想看所有轮转结果,别过滤停用词”,那可以用参数控制;但如果说“我想在轮转的时候同时保留原始标题的行号,方便回溯来源”,麻烦就出现了——行号字段需要贯穿所有四个工序,build_circular_shifts 要改,filter_stop_words 要改,sort 也要跟着改。这就是典型的“修改从一个点扩散到全局”的症状。
还有一个我认为更隐蔽的问题:这种风格天然会把“数据表示”暴露给所有模块。任何一个函数都可以直接修改全局数据区的内容,模块之间完全没有隔离。在KWIC这种小系统中这不是一个严重问题,但当系统规模扩大时,这几乎就是结构崩塌的开始。
3. 方案二:管道-过滤器风格(Pipes and Filters)
3.1 核心思路:把每道工序变成独立“过滤器”
管道-过滤器风格的核心思想是:系统被拆成一系列过滤器,每个过滤器处理一种数据变换,过滤器之间通过管道连接。前一个过滤器的输出是后一个过滤器的输入,数据不是在“共享区”中转,而是在管道中有方向地流动。
对应到KWIC,我把四个工序都改成过滤器:
- 读取过滤器(Reader):负责把原始标题一行行输出到管道。
- 轮转过滤器(CircularShifter):从管道读标题,生成轮转结果,输出到下一根管道。
- 停用词过滤器(StopWordFilter):读入轮转结果,过滤掉以停用词开头的,输出。
- 排序过滤器(Alphabetizer):读入过滤后的轮转结果,排序输出到终端。
关键区别在于:每个过滤器只依赖“上游数据格式”和“下游数据格式”,不依赖上游过滤器的具体实现。数据以“数据流”的形式在各过滤器间流动。
3.2 代码落地:定义数据流边界,让每个过滤器的输入输出都清晰
管道-过滤器风格的关键在于“数据流边界”的约定。我在实现时定义了一个最简单也最可靠的接口——每个过滤器接收一个可迭代对象,返回一个可迭代对象。这样管道的接缝自然就出现了:
# KWIC 管道-过滤器风格实现 from typing import Iterable, Iterator StopWords = {"the", "and", "or", "of", "to", "a", "in"} def read_filter(lines: Iterable[str]) -> Iterator[str]: """过滤器1: 读取原始标题""" yield from lines def circular_shift_filter(titles: Iterable[str]) -> Iterator[str]: """过滤器2: 生成所有轮转""" for title in titles: words = title.split() n = len(words) for i in range(n): yield " ".join(words[i:] + words[:i]) def stop_word_filter(circs: Iterable[str]) -> Iterator[str]: """过滤器3: 过滤以停用词开头的轮转""" for c in circs: if c.split()[0].lower() not in StopWords: yield c def alphabetizer_filter(circs: Iterable[str]) -> Iterator[str]: """过滤器4: 排序输出""" yield from sorted(circs) # 管道组装:像Shell管道一样把过滤器串起来 source = [ "The quick brown fox", "A tale of two cities", "Software architecture in practice" ] pipeline = alphabetizer_filter( stop_word_filter( circular_shift_filter( read_filter(source) ) ) ) for line in pipeline: print(line)这段代码和方案一处理同样的输入,应该产生完全相同的输出。我用嵌套调用的方式模拟管道,而不是显式定义复杂的管道类,目的是尽量保持轻量。如果项目中需要可视化管道或动态组装,可以在此基础上做一层关于“管道”的封装。
3.3 优点与隐患:灵活的数据流,但小心“链式回调嵌套地狱”
管道-过滤器风格最大的优势是模块可独立替换、可复用、可单独测试。因为每个过滤器只关心自己的输入输出格式,你可以把排序过滤器替换成逆序排序过滤器,或者调整过滤器的顺序以支持“先过滤再排序”的预处理,而不用修改其他过滤器。这一点在方案一里是比较难做到的。
它还有另一个我很喜欢的特性:支持惰性数据流。因为过滤器返回的是迭代器而不是完整的列表,KWIC处理超大型语料时内存占用非常可控。我可以从file object一路yield到排序器,期间不会出现“把所有数据都load到内存”的情况。这一点在真实场景下极其重要——KWIC的原始用途本来就是处理图书标题库,数据量一上来,方案一的全局数组方案就会变得很吃力。
但它也有问题。第一个问题是调试比较麻烦:如果某个过滤器的输出不对,你得在管道中手动加日志或者打印每个阶段的输出,无法像主程序-子过程风格那样直接设断点观察全局变量。第二个问题是错误处理没有统一的机制,管道中任何一个环节挂了,整个数据流都断了,而且很难定位具体是哪个环节出的问题。
还有一个我实际踩过的坑:嵌套调用过深。当过滤器数量超过五六个时,代码的可读性会急剧下降。我在整理KWIC时只用了四个过滤器所以问题不明显,但你可以试着想象一个由十几个过滤器组成的ETL管道,直接嵌套会导致一套噩梦般的缩进层级。真实的工业级做法是用一个“管道调度器”来统一管理过滤器的连接顺序,而不是简单嵌套。但那就引出了另一个命题——为了灵活性牺牲了直观性,这个取舍需要根据项目规模来决定。
4. 方案三:面向对象风格(Object-Oriented)
4.1 核心思路:按“数据与职责”切分系统
面向对象风格主张:把系统看成一组协作的对象,每个对象封装自己的数据和行为,通过对象间的方法调用进行通信。对应到KWIC,我不再按“操作步骤”拆分系统,而是按“职责主体”拆分:
- 标题存储对象(TitleStorage):保存原始标题,提供添加、获取标题的接口。
- 轮转生成器对象(CircularShiftGenerator):从标题存储对象读取数据,生成轮转结果,内部持有轮转列表。
- 停用词过滤器对象(StopWordFilter):判断给定轮转是否应该被过滤。
- 排序器对象(Alphabetizer):接收轮转列表,输出排序结果。
- 主控制对象(KWICController):负责对象间的协作流程。
在这种风格下,数据不再“裸露”地传来传去,而是由各自的对象负责保管和维护。主控对象只负责“调度”而不过问具体的数据变换逻辑。
4.2 代码落地:类的边界就是需求变化的边界
我实现了一版面向对象风格的KWIC。这里的关键决策是:把“停用词过滤”从“轮转生成”中独立出来,而不是像很多版本那样直接在生成时过滤。这样做的好处是——如果将来需要一个“不过滤停用词的索引”,轮转生成器对象可以原封不动被复用。
# KWIC 面向对象风格实现 class TitleStorage: def __init__(self): self._titles = [] def add_title(self, title: str) -> None: self._titles.append(title) def get_titles(self) -> list[str]: return list(self._titles) class CircularShiftGenerator: def __init__(self, storage: TitleStorage): self._storage = storage self._shifts = [] def generate(self) -> None: self._shifts = [] for title in self._storage.get_titles(): words = title.split() n = len(words) for i in range(n): self._shifts.append(" ".join(words[i:] + words[:i])) def get_shifts(self) -> list[str]: return list(self._shifts) class StopWordFilter: def __init__(self, stop_words: set[str]): self._stop_words = stop_words def filter(self, shifts: list[str]) -> list[str]: return [s for s in shifts if s.split()[0].lower() not in self._stop_words] class Alphabetizer: @staticmethod def sort(shifts: list[str]) -> list[str]: return sorted(shifts) class KWICController: def __init__(self, storage: TitleStorage, generator: CircularShiftGenerator, filter_: StopWordFilter, alphabetizer: Alphabetizer): self._storage = storage self._generator = generator self._filter = filter_ self._alphabetizer = alphabetizer def run(self) -> list[str]: self._generator.generate() shifts = self._generator.get_shifts() filtered = self._filter.filter(shifts) return self._alphabetizer.sort(filtered) if __name__ == "__main__": storage = TitleStorage() for title in [ "The quick brown fox", "A tale of two cities", "Software architecture in practice" ]: storage.add_title(title) generator = CircularShiftGenerator(storage) stop_words = {"the", "and", "or", "of", "to", "a", "in"} filter_ = StopWordFilter(stop_words) alphabetizer = Alphabetizer() controller = KWICController(storage, generator, filter_, alphabetizer) for line in controller.run(): print(line)4.3 优点与隐患:扩展的乐园,但过度设计的深渊也在招手
从可扩展性角度看,面向对象风格在三种风格中是最强的。每一种对象都是一个独立的演化单元,“停用词过滤规则要改成依赖上下文判断”,那么只需要去修改StopWordFilter内部逻辑,Controller、Storage、Generator都不受影响。“标题来源要从文件变成数据库”,只需要改storage的构造方式,甚至不需要改其他对象。
它也有显而易见的代价:代码量和概念数量显著增加。方案一里四个函数就解决的问题,到了这里变成了五个类、一个主控制器、和一堆“对象间协作”的约定。对于KWIC这种规模的需求,面向对象风格看起来确实是“杀鸡用牛刀”。
我在实现过程中的体验是:真正需要想清楚的并不是“要建哪些类”,而是“哪些变化是未来可能需要应对的”。如果你预判未来需求会频繁变化、且变化点分散在不同领域,那么为每个变化点建立对象边界是值得的;如果需求基本固定,那引入更多层次只会增加认知负担。面向对象风格的核心收益是“推迟决策”的能力——它允许你在不破坏整体结构的前提下,逐步替换系统中的某个局部。
5. 三种架构风格的对比与选型建议
5.1 一张表看透三种风格的差异
为了更直观地展示三种方案的差异,我整理了一张对照表,从多个维度评估它们在KWIC场景中的表现:
| 维度 | 主程序-子过程风格 | 管道-过滤器风格 | 面向对象风格 |
|---|---|---|---|
| 模块划分依据 | 按操作步骤 | 按数据处理阶段 | 按职责主体 |
| 数据传递方式 | 共享全局数据区 | 管道数据流 | 对象间消息调用 |
| 模块耦合程度 | 高(共享数据耦合) | 低(只依赖数据格式) | 中低(依赖接口定义) |
| 可测试性 | 一般 | 很好(每环独立测试) | 好(每个类单独测试) |
| 可扩展性 | 差(改性一处牵连多处) | 中(方便增删过滤器) | 好(局部替换对象) |
| 调试难度 | 易(设断点看全局) | 难(数据流不透明) | 中(对象间协作稍复杂) |
| 实现成本 | 低 | 低 | 高 |
| 适用场景 | 需求固定、规模小 | 数据流清晰、批次处理 | 需求变化多、系统持续演进 |
这个表格的三个结论很直接:主程序-子过程风格胜在简单直接,管道-过滤器风格胜在灵活解耦和惰性流式处理,面向对象风格胜在隔离变化和长期演进。
5.2 我踩过的坑:过度追求“风格正确”反而误事
初次实现时,我走了一个误区——为了展示三种风格差异,在面向对象版本里强行加入了抽象基类、工厂模式和依赖注入容器。结果代码量翻了三倍,真正与KWIC业务相关的逻辑不到200行,剩下的全是为“设一个可能永远用不到的扩展点”而付出的基础设施成本。这是三个版本中调试最痛苦的一版。
所以如果你的目标是“用架构风格解决KWIC”,而不是“展示架构风格的教科书案例”,我的建议是遵守以下选型逻辑:
- 团队第一次接触这个项目、需求基本明确,优先考虑主程序-子过程风格。简单直接,不折腾就是最大的效率。
- 如果数据量可能不断增长,或者你将来的输入源不再只是内存数组,管道-过滤器风格会让数据流处理顺畅很多。但管道数量超过五条时,务必引入显式的管道调度器,别用嵌套硬堆。
- 如果这个KWIC系统是某个大型系统里一个未来会长期演进的子模块,面向对象风格提供的对象边界能帮你把频繁变化的部分隔离起来。但请坚持“让每个类承担一个明确职责”的原则,别让类多到没人记得住。
6. 变体与扩展:从KWIC看架构风格的通用规律
KWIC这个案例最迷人的地方在于:它虽然小,却完整地映射了大型系统的组织难题。做完三种实现后,我对架构风格的理解也有一些新的延伸,这部分可以作为大家的扩展思路:
- 管道-过滤器风格的现实投影:如果你把KWIC的四个过滤器换成正则清洗、HTML标签剥离、情感分类器、关键词抽取器,它就是一个标准的信息抽取管道。很多文本处理框架里的Processor链,本质就是在跑管道-过滤器风格。
- 面向对象风格的现实投影:把TitleStorage换成UserRepository,把CircularShiftGenerator换成RecommendationEngine,把Controller换成Facade层,这就几乎是一个标准的领域模型设计。对象边界的本质就是把数据与行为绑定在一起,让变化局部化。
- 三种风格并不互斥:真实项目中这几种风格往往共存。比如管道-过滤器负责数据采集和清洗,面向对象负责业务模型,外层用主程序风格串联启动流程。架构风格是一种“局部组织原则”,不是非得全局唯一。
7. 最后的实操心得
回到开头的那个问题:用三种架构风格实现KWIC,收获到底是什么?
对我来说,这个练习最大的价值不在“能跑”,而在于它迫使你想清楚每一种系统组织方式的取舍。KWIC虽然简单,但“输入—变换—过滤—排序—输出”的骨架几乎出现在所有信息处理类项目中。你今天在KWIC上学会的,是如何在变更来临时让代码系统有序地“迎接”变化,而不是在混乱中疲于修补。
实际做下来,我的体会是:架构风格没有绝对的好坏,只有合适的错配。哪怕是同一个KWIC需求,在不同语境下,三种风格都可能有一个“最优解”——一个小工具用主程序风格最快交付是最优解;一个要对接多数据源的批处理任务用管道-过滤器是最优解;一个要长期维护并且经常加新规则的服务用面向对象是最优解。
最后分享一个决定架构风格前常用的小技巧:先把未来可能的需求变化列成清单,再挑出一种风格让这些变化中的大多数都能被局限在单个模块里。如果找不到这样一个风格,那就说明这个系统的核心不确定性太高,盲选哪种架构风格都只是赌博。先用最小成本搭出主程序-子过程风格的骨架跑通业务,再在需要的地方逐步演进,永远是最稳妥的路径。
本文还有配套的精品资源,点击获取