如何建立起合适的抽象解决问题?
我看Pandoc,ffmpeg,qemu的代码完美,优雅,像是本来就在那里然后被人发现似的。还有编译器,标准库,ninja和cmake的架构。他们怎么获得这方面的知识并且建立合理抽象(没有太高而失去掌控力,也没有太低以至于发明一门更难的编程语言)解决痛点的?
你观察到的这种“完美感”,其实揭示了软件设计中一个深刻的真相:优秀的抽象往往不是“发明”出来的,而是“发现”出来的。那些代码看起来像是本来就存在,是因为它们忠实地反映了问题领域的固有结构。
要理解如何建立这种恰到好处的抽象,可以从几个维度来拆解:
1. 深刻理解问题领域(Domain Expertise)
Pandoc、ffmpeg、qemu 的作者之所以能建立优秀的抽象,首要前提是他们对问题领域本身有极深刻的理解。
不是先想抽象,而是先理解问题:他们不会坐在电脑前凭空设计一个万能抽象,而是先穷尽问题的各种可能。比如,Pandoc 的作者 John MacFarlane 本身就是一位语言学家,他对标记语言的异同、文档结构的本质有深刻认识。他明白,无论是什么文档格式,其核心都可以归结为“文档模型”——一种包含标题、段落、链接等元素的抽象语法树。
寻找“不变性”:在纷繁复杂的格式转换中,什么是永恒不变的?是“文档”这个概念本身的结构。Pandoc 的抽象核心就是那个中间文档表示(AST)。这个抽象不高,因为它没有试图解释所有格式的每一个细节;它也不低,因为它足够表达所有格式的共同特征,让你不必为每个格式对(如 Markdown 转 HTML、Markdown 转 LaTeX)都单独写一套转换逻辑。
2. 从具体到抽象的演化过程
代码不是一次写成的,抽象是重构出来的。这个过程通常分几步:
第一步:直接解决问题。先写出能跑的代码,哪怕很具体、很暴力。比如先硬编码处理几种特定情况。
第二步:发现重复模式。当你开始处理第二种、第三种类似情况时,会发现之前的代码逻辑在重复,只有细节不同。
第三步:提炼共同点。这时你会开始思考:“这段逻辑的核心流程是什么?哪些部分是变化的?” 将不变的核心流程写成函数或类,将变化的部分作为参数或接口传入。这就是抽象诞生的一刻。
第四步:定义接口(契约)。当变化的部分也变得复杂时,你会定义清晰的接口。比如,ffmpeg 里的 AVFormatContext 和 AVCodec,就是给各种封装格式和编码格式定义的统一“插槽”。任何人想支持新格式,只需要实现这个“插槽”规定的几个函数即可。
3. 分层与关注点分离
你提到的编译器、Ninja/CMake,都体现了优秀的分层思想。
编译器:经典的“三段论”(前端、优化器、后端)本身就是最高层次的抽象。
前端:将源代码(如 C++、Rust)转换成中间表示(IR)。这一层的抽象是:无论什么高级语言,我都能把它降维成同一种 IR。
优化器:只跟 IR 打交道。它的抽象是:我不关心你来自 C++ 还是 Rust,我只优化这个中间代码。
后端:将优化后的 IR 转换成目标机器码(如 x86、ARM)。它的抽象是:我不关心你是什么语言,我只管把 IR 翻译成当前 CPU 能懂的指令。
结果:这种分层让编译器不至于变成一个复杂无比的怪物。每一层都只负责一件事,接口(IR)清晰稳定,各层可以在内部自由演化。
Ninja/CMake:它们是对构建过程的完美分层。
CMake 处于“元构建”层,它的抽象是关于“项目逻辑”的(如库依赖、可执行文件、编译选项),是给人用的,是跨平台的。
Ninja 处于“构建执行”层,它的抽象是关于“文件依赖和图”的,追求极致的速度,是给机器用的。
如果不分层,直接在 Makefile 里写复杂逻辑,很快就会失控。
4. 克制与“恰如其分”的把握
如何做到“没有太高而失去掌控力,也没有太低以至于发明一门更难的编程语言”?
做减法,而非加法:好的抽象是在寻找最小公共集,而不是最大功能集。Pandoc 的 AST 只包含所有格式共有的元素(段落、强调、链接等),而不是把每个格式特有的复杂排版指令都塞进去。对于特有功能,可以降级处理或作为原始数据保留。
关注数据流,而非控制流:好的抽象通常定义数据的形状(结构体),然后让数据流过一系列变换函数。这种模式(管道-过滤器)比复杂的继承体系更容易理解和掌控。ffmpeg 的整个数据处理流程就是典型的管道式抽象。
可测试性:如果一个抽象导致代码很难写单元测试,那它很可能太复杂或耦合度太高。好的抽象应该让每个组件可以独立测试。
如何获得这种能力?
多读经典代码:带着问题去读。问自己:“如果让我来实现,我会怎么做?为什么他这么做?他的抽象边界划在哪里?” 你提到的 Pandoc、ffmpeg、qemu 就是最好的教材。
多写,多重构:不要试图一次写出完美的抽象。先写一个能用的版本,然后随着需求增加,不断重构,让抽象自己“浮现”出来。抽象是发现的过程,而不是创造的过程。
学习不同领域的模型:研究函数式编程的代数数据类型、数据库的关系模型、操作系统的虚拟内存抽象(每个进程都有独立的地址空间)。这些跨领域的模型能帮助你建立“寻找不变性”的直觉。
用英语或伪代码先描述:在写代码前,试着用自然语言精确描述你的核心流程和数据结构。如果描述起来很绕、很吃力,那说明你的抽象可能有问题。清晰的抽象应该能用清晰的语言表达。
总结来说,建立起合适的抽象,本质上是通过深入理解问题,识别其深层结构中的“不变性”,然后用分层和接口的方式,将这些“不变性”固化为代码,同时将“变化”隔离在外部。这需要耐心、经验和对简洁的追求。
有些抽象设计完美得像是被发现的
你这种感觉非常敏锐,而且完全正确。很多伟大的程序员都有类似的体验:真正优雅的抽象,感觉不像是在创造,更像是在挖掘——那些结构和规律似乎本来就存在于问题深处,只是被他们揭示了出来。
这种"被发现"的感觉,其实揭示了一个深刻的认知科学现象:伟大的抽象往往映射着问题领域的某种内在真理或自然规律。
为什么会有"被发现"的感觉?
1. 数学般的必然性
当抽象足够完美时,它会产生一种"如果我不这么做,还能怎么做?"的必然感。就像勾股定理,它不是数学家发明的,而是被发现的——直角三角形的边长关系本就如此。
看看UNIX的"一切皆文件"抽象:
键盘是文件,你可以从中read
显示器是文件,你可以write给它
网络连接是文件,你可以read/write
进程间管道也是文件
这个抽象一旦被点破,你会觉得:"当然应该是这样!这不就是操作系统该有的样子吗?" 这种必然感,正是"被发现"错觉的来源。
2. 与问题结构的同构性
最好的抽象会与问题的内在结构形成同构关系。就像地图不是领土,但优秀的地图在关键特征上与领土形成对应。Pandoc的AST之所以感觉"天然存在",是因为它捕捉到了"文档"这个概念的本质结构——无论什么格式,文档本质上就是由块级元素和行内元素构成的树。
哲学层面的思考
这其实触及了一个古老的哲学问题:数学和逻辑是发明的还是发现的?
柏拉图主义者认为:数学真理独立存在,我们只是发现它们
形式主义者认为:数学是人类创造的符号游戏
在软件设计中,类似的张力也存在。那些感觉"被发现的"抽象,往往是因为它们如此深刻地切中了问题的本质,以至于发明和发现的边界模糊了。
真实世界中的例子
SQL:关系数据库查询语言
基于关系代数和关系演算——这些都是数学上早已存在的理论
Codd的关系模型不是凭空创造,而是发现了数据关系可以这样优雅地表达
所以SQL的核心概念如此稳固,40多年来没有根本性变化
TCP/IP协议栈:
分层模型反映了网络通信的自然层次——从物理信号到应用数据
每一层解决特定范围的问题,边界清晰
这种分层不是随便划分的,而是映射了信息传递的自然层级
如何培养这种"发现"的直觉?
1. 寻找"最小完整集"
当你设计一个系统时,问自己:"为了完整解决这个问题,我最少需要定义多少个概念?"
比如RESTful API设计:HTTP方法(GET/POST/PUT/DELETE)加上资源URI,就构成了操作网络资源的完整语义。这个集合不能再小了,再小就会缺失功能;也无需再大了,再大就是冗余。
2. 关注边界条件和异常
异常往往是抽象漏洞的指示器。如果某个边界情况需要特殊处理,说明你的抽象可能没有完全捕捉到问题域的真实结构。
比如文件系统的"权限":为什么需要读/写/执行三种权限?这个三位一体的设计并非随意,它对应了文件可以被执行的三种基本操作。Unix的设计者发现了这个内在分类,而不是发明了它。
3. 寻找跨领域的重复模式
当你发现不同领域出现相似的结构时,可能触及了更本质的模式。比如:
观察者模式在GUI编程中出现,在事件驱动系统中出现,在消息队列中也出现
这不是巧合,而是反映了"多个对象依赖某个对象状态变化"这个自然关系
4. 用"剃刀"不断削减
奥卡姆剃刀在这里极其重要:如无必要,勿增实体。每当你添加一个概念,问自己:这个概念是问题域本身要求的,还是我的设计引入的杂质?
一个练习方法
找一段你觉得优雅的代码(比如Pandoc的某个模块),尝试:
剥离抽象:如果去掉这个抽象,直接解决问题,代码会变成什么样?
分析痛点:为什么那种直接的方式是痛苦的?那个抽象解决了什么核心痛点?
追溯源头:思考这个抽象对应了问题域的哪个固有特征?
比如分析ffmpeg的AVCodecContext:
剥离抽象:为每个编码器单独写一套参数处理函数
痛点:每加一个新编码器就要重写大量重复代码,且无法统一处理
源头:所有编码器都需要配置参数、处理数据、管理状态——这是"编解码器"这个事物固有的属性,不是ffmpeg强加的
最后
当你真正理解了问题领域,那些优雅的抽象就不再是需要设计的东西,而是像地图上的河流山川一样自然地显现出来。这就是为什么有些人说:"我并没有设计它,我只是让问题自己表达出了解决方案。"
这种能力并非天赋,而是深度思考、广泛类比、持续重构的结果。你提到的那些项目和作者,正是通过这种对问题本质的不懈追寻,才让他们的代码看起来像是"本来就存在"的。
这就是架构师和计算机操作员(很多程序员实则是计算机操作员)的差别
一语中的。这个区分非常深刻,也确实触及了软件行业里一个核心却少有人言明的分层。
让我展开聊聊这个差别的本质:
操作员:在现有抽象内工作
计算机操作员(这里指代那些停留在使用层面的程序员)的工作特征是:
1. 消费抽象,但不质疑抽象
他们知道用ORM可以操作数据库,但很少思考ORM为何要这样设计,它掩盖了什么,泄露了什么
他们熟练使用React的useState,但从未想过为什么Hooks要这样设计,它的闭包陷阱背后是什么原理
他们调用AWS的API部署服务,但不去思考这些API的边界在哪里,为什么这样划分
2. 在框架划定的跑道上奔跑
解决问题的方式是搜索"如何用XX框架做YY"
当遇到框架无法处理的场景,第一反应是找插件,而不是思考框架的抽象是否在此处失效
他们的技能树是"熟练掌握XX工具"
3. 代码是拼凑出来的,而不是生长出来的
代码像是用预制件搭建的房子,看起来能住,但每个构件都不是为这个房子定制的
遇到bug时,经常陷入"调一下这里,试一下那里"的试错循环,因为对底层机制缺乏理解
架构师:在抽象之间和之上工作
架构师的思维方式完全不同:
1. 理解抽象的边界和代价
他们知道ORM在复杂查询时会泄露,所以知道何时该手写SQL
他们理解Hooks的闭包捕获机制,所以能解释为什么依赖数组如此重要
他们明白云服务的API是对分布式系统复杂性的封装,所以能预判在极端条件下哪些抽象会失效
2. 在问题域和解决方案域之间架桥
他们不先想"我用什么框架",而是先想"这个问题本质上是什么"
他们有能力将业务问题映射到技术模型上,当映射不吻合时,知道是调整模型还是调整技术
他们的工具箱里不是"Spring Boot怎么做",而是"我需要一个控制反转容器,因为..."
3. 代码是推导出来的,而不是试出来的
每个抽象的选择都有其必然性:"因为这个业务的核心是不变性,所以我们选择不可变数据结构"
他们写代码时能预见未来六个月的修改会在哪个点切入,所以在那个点预留了接口
他们的bug修复往往是一针见血的,因为能从抽象层面定位问题
一个具体的对比
假设我们要设计一个日志系统:
操作员思维:
"我搜一下'Java 最好的日志框架',哦,Logback 很流行,那就用它。配置文件拷过来改改,在代码里到处写 LoggerFactory.getLogger... 好了,完成。"
架构师思维:
"日志系统需要解决几个问题:谁在什么时候做了什么?这些日志要流向哪里?怎么在不影响主业务流程的前提下收集?怎么在排查问题时能快速关联相关日志?"
然后他们会:
定义抽象:设计一个
Logger接口,不依赖任何具体实现考虑横切关注点:用 AOP 或装饰器模式自动注入调用上下文
设计管道:思考日志从产生、格式化、过滤到输出的整个流程
预留扩展点:让日志的消费者(控制台、文件、ELK)可以灵活替换
权衡性能:异步写入怎么保证不丢日志?缓冲区多大合适?
结果是,操作员六个月的日志系统可能需要重写,而架构师六年的日志系统只需要修改配置。
为什么会有这种差距?
1. 思考深度不同
操作员思考"How":这个按钮怎么实现?
架构师思考"Why"和"What":为什么用户需要这个按钮?这背后是什么业务需求?这个按钮的本质操作是什么?
2. 抽象层级不同
操作员在代码层面思考
架构师在系统层面思考
3. 时间尺度不同
操作员关注今天能交付
架构师关注系统在未来两年的演化路径
如何从操作员成长为架构师?
质疑一切:每当你用一行代码,问自己:"为什么这个API要这样设计?它隐藏了什么复杂性?"
阅读源代码:不只是文档,而是你每天用的框架、库的源码。去看看那些抽象是怎么实现的。
跨领域学习:学学编译器原理,即使你写业务代码;学学数据库实现,即使你只是增删改查。这些会给你全新的抽象视角。
用英文思考:当你遇到问题,先不用想"用XX框架怎么做",而是用自然语言描述问题的本质。
重构别人的代码:接手一个烂摊子时,别抱怨,试着理解它为什么变成这样,然后思考如何用更好的抽象重构。
最后,这个区分不是要制造阶层,而是指明一条成长路径。每个架构师都曾是操作员,但并非每个操作员都能成为架构师。区别不在于年限,而在于是否开始思考"为什么"。
正如你说的,那些完美的抽象像是被发现的——当你开始从问题中"发现"解决方案,而不是从框架中"寻找"解决方案时,你就已经在向架构师迈进了。