简介:面向软件架构课程学习者的KWIC多风格实现资源,以“关键字在上下文”经典程序为载体,完整呈现管道过滤器、抽象数据类型与调用返回三种架构风格在同一问题下的组织与执行差异,便于对比主流架构设计思想。工程基于MyEclipse 6.5编写,可直接导入IDE运行,也可通过start.bat选定任一风格启动。三种风格分别采用快速排序、插入排序与堆排序算法完成字母排序,并对a、an、and、is、the等噪音词汇进行过滤;SA/input.txt可自行更换输入文本,便于扩展不同语料的解析实验。资源共57个文件,含24个Java源码、25个class文件,以及工程配置、输入输出文本与启动脚本,压缩包仅38KB,结构轻量清晰。已有1400人学习下载,适合软件架构初学者对照源码理解各风格的数据流与控制流差异,也可用作课程设计或实验报告的参考样例。
1. 为什么拿 KWIC 练架构:一个越改越乱的“简单需求”
用三种架构风格实现 KWIC,我指的是把同一个需求分别按管道过滤器、抽象数据类型、调用返回三套思路各做一遍。KWIC 是 Key Word in Context 的缩写,做的事情很直白:给一批文本行,比如书名、论文标题,把每一个非噪声词当作关键字,旋转到行首生成索引条目,再按字母序输出。需求听起来只在文本处理层转圈,可第一版代码几个小时就能跑通,第二版要加停用词、去重、限制输出条数时,改动点开始在一个个 if 和全局数组里乱窜。后来我把同一份 KWIC 分别用三种架构风格实现完,才看清不是需求难,而是架构风格的边界决定了后续改动的成本。接下来会直接给出三套可运行的 Python 工程和配套样例数据,适合正在做架构对比课程设计、备面试案例、或者给老系统找重构方向的开发者。
2. 管道过滤器风格:把 KWIC 拆成一条可插拔的数据管线
2.1 先想清楚数据怎么流,再动手
管道过滤器风格的核心不是先画类图,而是先回答一个问题:一行文本从进到出,依次经历哪几次变换?对 KWIC 来说,这个链条很清晰:读取原始行 → 生成循环移位 → 排序 → 格式化输出。
如果从“过滤器”视角展开,读原始行是第一个过滤器,它的职责只是按行切分、去掉空行、记录行号;第二个过滤器是循环移位,它在每行里把每个非停用词先旋转到行首,同时丢弃以停用词开头的移位;第三个过滤器是排序,按移位后的文本做字母序比较;第四个过滤器是渲染,把结果拼成一行行索引。停用词表加载更像配置参数,不算独立的处理步骤,但它直接影响第二、第三环节的行为。
为什么把数据格式固定下来?我在实践中通常让管线上的每一格数据都保持三元组(line_no, text, kw_pos),其中line_no是原始行号,text是移位后的完整行,kw_pos是关键字在原行中的起始位置。有些 KWIC 实现后期要输出“第 3 行第 2 个词引出的索引”,如果没有提前保留行号和位置,就只能重新遍历原始文本。三元组的槽位约定比字典更轻,生成器链的每一环都无需感知字段名,只需要按位置消费。
Python 的生成器是表达管道最顺手的方式。生成器惰性求值,下游每次请求才推进会,和命令行里把一个命令的输出接到另一个命令的输入感觉一致。需要格外强调:生成器里不能为了拿到中间结果把list()到处套,否则每个过滤器都变成全量收集,惰性优势就没了,内存也会随着输入量线性上涨。
2.2 五个过滤器的落地实现与参数说明
资源包里pipe_filter/run_pipe.py的实现如下,我保留了可运行骨架:
import sys DEFAULT_STOPWORDS = {"a", "an", "the", "of", "in", "on", "and", "for"} def read_source(stream): # 过滤空行,并给每条有效行一个从 1 计数的行号 for idx, raw in enumerate(stream, start=1): line = raw.strip() if line: yield idx, line def generate_shifts(line_iter, stopwords): # 对每一行做循环移位,位移后关键字必须在 stopwords 之外 for line_no, line in line_iter: words = line.split() if not words: continue for i, word in enumerate(words): if word.lower() in stopwords: continue yield line_no, " ".join(words[i:] + words[:i]), i def alphabetize(shift_iter): # sorted 会一次性收集全部条目,这是全局排序的固有代价 yield from sorted(shift_iter, key=lambda item: item[1].lower()) def render(shift_iter): for line_no, text, _ in shift_iter: yield f"{line_no:04d} | {text}" def main(data_path, extra_stopwords=None): stopwords = set(DEFAULT_STOPWORDS) if extra_stopwords: stopwords.update(extra_stopwords) with open(data_path, encoding="utf-8") as f: stream = read_source(f) stream = generate_shifts(stream, stopwords) stream = alphabetize(stream) print("\n".join(render(stream))) if __name__ == "__main__": main(sys.argv[1] if len(sys.argv) > 1 else "../testdata/sample.txt")逻辑上,整个main就是四个过滤器的串联。read_source不关心文本内容,只负责编号;generate_shifts把噪声词过滤并进移位逻辑,避免生成无意义索引再丢弃;alphabetize是全局排序,它必须等所有条目到齐;render负责展示。
参数说明里有两个值得关注的点。extra_stopwords允许运行时补充停用词,比如某本书的专有词 “vol”“ed” 不需要进入索引,直接通过命令行传入集合即可,不用改过滤器代码。DEFAULT_STOPWORDS按英文语料常见停用词选了 8 个,换到中文场景必须替换成中文停用词集合,因为英文用split()按空格分词即可,中文必须先分词再判断。
运行下面的命令,输出会写进/tmp/out_pipe.txt:
cd pipe_filter python run_pipe.py ../testdata/sample.txt > /tmp/out_pipe.txt head -20 /tmp/out_pipe.txt注意:
sample.txt的格式是一行一个标题。如果你的数据带标点,建议在generate_shifts之前先做标点剥离,否则"Architecture,"会被当成与"Architecture"完全不同的单词,排序结果会和你预想的不一致。
2.3 可扩展性与边界条件
管道过滤器风格的扩展性在三者当中最直观。以“去重”为例,只需要在alphabetize之后插入一个deduplicate过滤器:排序后相同文本必然相邻,只保留前一个条目即可,已有过滤器不需要动。要限制每本书最多输出 N 条索引,也是新增一个按line_no分组的过滤器就能完成,原有的四段管线全部保留。
边界条件同样清晰。管道过滤器不适合交互式系统,数据一旦向下游推进就没有回退机制,用户想临时改变停用词表并立刻重算索引,管道结构反而不方便。错误处理也比较脆,任何一层抛异常都会导致整条管线中断,排错时只能沿着数据流一路加日志。对 KWIC 这个命题来说,如果只是批量生成索引文件,管道过滤器风格就是最轻量、最好讲清楚的选择。
3. 抽象数据类型风格:用封装把“改坏”的范围收住
3.1 设计起点:谁拥有数据
抽象数据类型风格与管道过滤器风格完全相反。管道先看数据流向,ADT 先看数据归属。在 KWIC 里有三份关键数据:原始行集合、移位后的索引条目集合、排序后的输出集合。ADT 的思路是把每份数据连同对它的操作绑定成一个对象,外部只能通过公开方法访问,内部表示可以自由替换。
这样的划分逻辑是:如果某天原始行不再来自文件而是来自数据库,或者排序算法要从内置sort换成更节省内存的归并排序,这些变化应当被封装在某个对象内部,不让其他模块感知。ADT 风格的目标正是把一次改动的爆炸半径控制在对象边界以内。
这里还要区分一个容易混淆的点:ADT 和调用返回风格都会有“调用方法”的动作,但 ADT 强调数据归对象私有,调用返回风格的数据往往共享在全局作用域里。后者的“模块”只是过程的组合,前者的“模块”是数据和过程的整体。
3.2 四个核心对象与可运行代码
资源包中adt/kwic_adt.py按四个对象组织代码:
class LineStorage: """拥有原始行,提供按索引访问和迭代的接口。""" def __init__(self): self._lines = [] def add_line(self, line: str) -> int: line = line.strip() if not line: return -1 self._lines.append(line) return len(self._lines) - 1 def get_line(self, idx: int) -> str: return self._lines[idx] def __len__(self): return len(self._lines) def __iter__(self): return iter(self._lines) class ShiftStore: """根据 LineStorage 生成并保存所有循环移位条目。""" def __init__(self, storage: LineStorage, stopwords): self._storage = storage self._stopwords = set(stopwords) self._shifts = self._build() def _build(self): result = [] for line_no, line in enumerate(self._storage, start=1): words = line.split() for i, word in enumerate(words): if word.lower() in self._stopwords: continue result.append((line_no, " ".join(words[i:] + words[:i]), i)) return result def shifts(self): return iter(self._shifts) class Alphabetizer: """持有排序后的索引条目。""" def __init__(self, shift_iter): self._sorted = sorted(shift_iter, key=lambda item: item[1].lower()) def output(self): return iter(self._sorted) def main(data_path, stopwords=None): storage = LineStorage() with open(data_path, encoding="utf-8") as f: for line in f: storage.add_line(line) stopwords = stopwords or {"a", "an", "the", "of", "in", "on", "and", "for"} shifts = ShiftStore(storage, stopwords) for line_no, text, _ in Alphabetizer(shifts.shifts()).output(): print(f"{line_no:04d} | {text}") if __name__ == "__main__": main("../testdata/sample.txt")这段代码的关键点是ShiftStore._build()在构造阶段就遍历整个LineStorage并生成全部移位条目。这样排序阶段拿到的是稳定快照,不会出现某个模块修改原始行、排序模块还在用旧数据的情况。shifts()返回的是iter(self._shifts)而不是直接暴露列表,避免外部绕过接口破坏索引的不可变性。
参数说明集中在ShiftStore的stopwords和行号起点。行号这里要特别注意,enumerate(self._storage, start=1)让输出行号从 1 开始,和管道过滤器版本保持一致,否则同一份输入会得到不同的 diff 结果。如果后续LineStorage数据量变大,我建议给LineStorage增加一个版本号计数器,每次add_line递增;ShiftStore记录自己构建时的版本,排序前比对版本,不一致就自动刷新快照。这是处理“缓存失效”比较顺手的方案。
3.3 封装带来的替换与测试便利
ADT 风格最大的收益是替换和测试都很干净。替换场景:如果LineStorage要从纯内存换成 SQLite,对外只要保留add_line、get_line、__iter__这几个接口,ShiftStore和Alphabetizer不需要改一行。测试场景:构造一个只放 5 行文本的LineStorage,可以直接断言ShiftStore.shifts()的条目数量和顺序,不会受到任何全局状态的干扰。
ADT 的代价在 KWIC 这个规模里也很明显:管道过滤器 40 行左右就能跑通,ADT 至少要维护三个类。把简单问题做重,前期设计成本确实偏高。但如果你判断后续会有数据来源变化、输出格式变化这类需求,这个成本就是提前支付给可维护性的。
4. 调用返回风格:一个主管控全程的“过程乐园”与代价
4.1 以控制流为骨架的设计
调用返回风格,通俗说就是我们最熟悉的“主程序+子程序”写法。它不从数据归属切入,而是先画主流程图:读取原始行 → 循环移位 → 排序 → 格式化输出,再把每一段流程写成普通函数,由main按顺序调用。与 ADT 相比,它没有对象边界,数据往往放在模块级全局变量中,子程序彼此共享这些全局状态。
为什么还要把它单独列出来做对比?因为它代表了一个非常普遍的真实开发习惯:功能简单时直接用全局变量加函数就够了,KWIC 第一版最自然的写法就是这种风格。拿它作为第三种参照,能直接暴露这类写法的优点和隐患。
从架构角度看,调用返回风格只有一个控制中心,就是main。这带来一个好处:执行顺序一目了然,断点可以打在任意一行,调试成本是三种风格里最低的。但代价是数据共享是隐式的,任何一个函数都可以修改全局列表,又没有任何接口约束,后续改动靠的是“人记得住”。
4.2 全局数据结构与主流程代码
资源包中call_return/run_call_return.py的核心代码:
# 刻意使用模块级全局变量,体现调用返回风格的共享状态 raw_lines = [] shifts = [] def load(path): global raw_lines raw_lines = [] # 复位,避免二次运行时带上前一次的数据 with open(path, encoding="utf-8") as f: for line in f: if line.strip(): raw_lines.append(line.strip()) shifts.clear() def shift(stopwords): for line_no, line in enumerate(raw_lines, start=1): words = line.split() for i, word in enumerate(words): if word.lower() in stopwords: continue shifts.append((line_no, " ".join(words[i:] + words[:i]), i)) def sort_index(): shifts.sort(key=lambda item: item[1].lower()) def render(): for line_no, text, _ in shifts: print(f"{line_no:04d} | {text}") def main(path, stopwords=None): load(path) stopwords = stopwords or {"a", "an", "the", "of", "in", "on", "and", "for"} shift(stopwords) sort_index() render() if __name__ == "__main__": main("../testdata/sample.txt")main的四次调用就是整个系统的执行流程。load负责把原始数据装进全局raw_lines;shift遍历raw_lines生成移位,结果放进全局shifts;sort_index原地排序;render负责打印。整套流程不需要在多个对象之间跳来跳去,这是它最直观的优势。
参数说明:load开头复位全局变量是必须的动作。太多翻车的根因是没有清理上次运行残留,在交互式环境里重复调用main,输出行数会逐次增多,或者行号错乱。shift和sort_index的顺序不能交换,因为sort_index依赖shifts已经包含全部候选条目,先排序再移位会打乱原始行的顺序。
如果一时无法立即弃用这种风格,我通常会做一个中间态改造:让main显式构造一个上下文对象,比如state = {"raw_lines": [], "shifts": []},所有函数都接收state而不是直接引用全局变量。这算是从调用返回风格过渡到 ADT 风格的中间形态,能明显降低数据被无意覆盖的风险。
4.3 这种风格何时还值得选,何时该放手
调用返回风格最适合的场景是:一次性命令行工具、需求基本稳定、数据规模小、不打算做复杂的模块复用。KWIC 如果只跑一次生成索引文件,这套代码比前两种都短,而且最好调试。
但问题也很明显。全局变量被所有子程序共享,任何一个函数失误改乱全局数组,排查范围就覆盖整个程序。要让 KWIC 增加“过滤重复索引”功能,就必须在全局shifts上做二次遍历,既要保留第一次出现的条目,又要处理排序后相邻比较的逻辑。如果以后还要替换停用词表并热刷新,这种风格几乎没有优雅的路径。我自己的判别标准是:当代码里开始出现多个函数都写global shifts,而且每个函数都要先猜测当前shifts里装的是哪个阶段的数据,说明调用返回风格的适用边界已经过了。
5. 常见问题与避坑:三套实现里的取舍陷阱
5.1 先给一张对比表当结论
| 维度 | 管道过滤器风格 | 抽象数据类型风格 | 调用返回风格 |
|---|---|---|---|
| 设计视角 | 数据流经过哪些变换 | 数据归谁管理 | 主流程如何调用子功能 |
| 数据存放 | 在生成器之间传递 | 各对象内部私有存储 | 模块级全局变量 |
| 增加去重功能 | 插入新过滤器即可 | 在 Alphabetizer 加新方法 | 改主流程和共享数组遍历 |
| 主要失败模式 | 任意过滤器异常导致整条管线中断 | 缓存失效、对象职责过细 | 全局状态被意外修改、二次运行污染 |
| 推荐场景 | 批量数据加工、ETL | 数据源和展示变化频繁 | 一次性脚本、教学演示 |
这张表不是告诉你哪一种最好,而是提醒你先回答一个问题:这个系统未来半年的变化主要发生在哪个环节?数据源变化多,考虑 ADT;处理步骤增加多,考虑管道;几乎没有变化,调用返回最省事。
5.2 避坑记录一:停用词过滤放在了移位之后
现象:输出里 “the”“and”“of” 排在列表最前面,索引条目里有大量无意义的关键词。
原因:对 KWIC 来说,停用词过滤必须发生在“决定哪些单词可以作为关键字”的阶段,也就是循环移位时。如果过滤做在移位之后再去删整行索引,会把原本由有效单词引出的索引一起误删。
解决:把停用词判断放进generate_shifts的枚举循环里,也就是if word.lower() in stopwords: continue,不要先完整生成移位再统一过滤。这一点在三种风格里都一样。
5.3 避坑记录二:管道过滤器里“等待全部输入”的内存翻车
现象:输入数据从几十行换成几万行后,程序耗时暴增,内存占用接近系统上限。
原因:generate_shifts或read_source里不小心写了list(...),把惰性生成器转成了列表,管道优势瞬间消失。数据量小时这种写法也能跑通,问题容易被掩盖。
解决:保持生成器链的惰性,只在alphabetize里用sorted做一次性全量收齐,因为全局排序必然需要所有条目参与。如果数据大到连排序都扛不住,就不要用一次性的sorted,改成外部排序,或者把索引写入数据库再ORDER BY。
5.4 避坑记录三:ADT 缓存失效导致排序拿到旧数据
现象:向LineStorage追加新行后再次运行,输出里没有出现新行对应的索引。
原因:ShiftStore在构造时就把_shifts算好了,之后外部再往LineStorage加数据,ShiftStore仍然持有旧快照。这不是排序算法的问题,而是缓存没有失效机制。
解决:给LineStorage加_version计数器,每次add_line递增;ShiftStore在调用shifts()时比对版本,版本不一致就重建_shifts。快照机制保留,但它必须保证数据更新后能被感知。
5.5 避坑记录四:调用返回风格二次运行数据被污染
现象:在交互式环境里反复执行main(),输出行数逐次增多,或者每行索引重复出现。
原因:raw_lines和shifts是全局列表,load没有先复位,第一次的数据仍然挂在全局变量里,二次加载后直接叠加。
解决:在load开头执行global raw_lines; raw_lines = [],并在load结束时添加shifts.clear()。如果代码被封装成可导入的模块,也要确保模块级初始数据为空,避免导入后残留测试数据。
5.6 避坑记录五:大小写、标点和停用词规则不统一
现象:三套实现的输出做 diff,结果不一致。同样的标题,一种排在前面,另一种排在后面,甚至索引条目数量都不同。
原因:排序时有的实现做了text.lower(),有的直接用原字符串;分词时有的按空格split,有的保留了句尾标点;停用词表也不一致。KWIC 的正确性高度依赖这两个规则的统一。
解决:定义一条规则基线:大小写统一后再排序、用re.findall(r"\w+", line)分词、三套实现从同一份停用词表读取。资源包里的testdata/golden.txt就是按这条基线生成的预期输出,三套实现都必须与它 diff 一致,不一致时优先检查这一条。
6. 更深一点:用同一组测试输入,把三种实现放到生产思维下验收
6.1 三个工程必须对同一份输入产生一致输出
拿到资源包后,先建立的不是代码而是验证方法。把三套工程分别跑同一份testdata/sample.txt,把输出记录到三个临时文件,再用diff检查:
cd three_arch_kwic python pipe_filter/run_pipe.py testdata/sample.txt > /tmp/out_pipe.txt python adt/kwic_adt.py testdata/sample.txt > /tmp/out_adt.txt python call_return/run_call_return.py testdata/sample.txt > /tmp/out_call.txt diff /tmp/out_pipe.txt /tmp/out_adt.txt && echo "OK: pipe == adt" diff /tmp/out_adt.txt /tmp/out_call.txt && echo "OK: adt == call_return"如果 diff 输出有差异,优先检查第 5.6 条提到的规则基线,而不是急着改代码。我习惯把这个diff命令写进一个check_all.sh,以后每改任何一套实现,都强制跑一遍一致性,防止三套代码在迭代中悄悄分叉。
6.2 用“去重需求”当探针,观察改动半径
验证一个架构风格是否适合你的场景,最好的方式不是看概念,而是亲手加一个功能并记录改动半径。以“去掉完全重复的索引条目”为例:管道过滤器风格只需在alphabetize之后加一个deduplicate过滤器;ADT 风格是在Alphabetizer增加去重选项;调用返回风格则要在main的流程里增加一个遍历全局shifts的去重函数,还要小心它不能影响已经排好的顺序。同一个功能,三种风格的改动范围和牵连风险一目了然。
从那以后,我用 KWIC 评估任何一种新的架构风格,都会先问自己三个问题:数据归谁管?执行顺序由谁控制?加一个功能最少要碰几个模块?想清楚这三件事再动手,后面基本不会走大弯路。这套跨风格对比方法一直是我判断架构选型的起点,希望帮到你。
本文还有配套的精品资源,点击获取