软件开发之道——聊聊UNIX哲学
汉语里有个成语「吃一堑,长一智」,在日积月累的实践中,工程师们总会从过往的经验中得到一些知识,来避免可能的错误,提高工作的效率。软件工程领域也不例外,还没开始工作的学生们也常会听到诸如模块化、重构、解耦合等等概念。在上个世纪六、七十年代,UNIX黑客们就曾总结出一份UNIX软件开发的指南,并命名为「UNIX哲学」。但是几十年过去了,计算机的发展日新月异,这些原则仍然适用吗?
概览
按英文wiki的说法,1994年Peter H. Salus曾总结了三条UNIX程序开发原则:
- 编写只做一件事并做好的程序
- 编写协同工作的程序
- 编写能处理文本流的程序,因为文本流是通用的接口
这三条原则单独拿出来看可能会让人摸不着头脑,尤其是第三条。怎么去理解这三条原则?
模块化与组合
我想凡是学习过现代的高级语言的程序员都知道,如果有某个数字在一段代码中多次反复出现,那么可以把它提取成一个 变量 ;如果有一个过程反复出现,那么可以将其提炼成一个 函数 。这样做有什么好处?最直观的一点是,如果需要修改一个值或过程,只用修改一处而不是修改所有用到的地方。大部分高级语言都支持将代码组织成多个小的模块,如果一个模块出了问题,可以在一个小范围内修改,就像有一节水管漏水,只需要更换漏水的那一节而不是整个供水系统。
如果把这种模块化的原则从代码层面推广到软件层面呢?看一个简单示例:
这段shell命令可以用来统计当前文件夹下的文件数量。其中 ls -l
用于列出当前目录下的文件和子目录, grep "^-"
从文本中查找 -
开头的行(ls的输出格式中这代表文件), wc -l
则可以统计文本的行数, |
用于将上一个程序的输入传递给下一个程序做输入。这是个简单的例子,但却完美诠释了UNIX哲学。这些程序都很小巧并且专注于做好一件事,单独使用它们都无法满足我的需求——统计文件数量,但是它们都被设计为可以与其它程序协作——通过文本流沟通,我不需要再开发一个新的程序,只是把已有的这三个程序组合起来,就做到了我想做的事。
形式的美与现实的困境
如果这个世界上的每个程序都是小巧玲珑的,能够相互配合,像组织良好的代码一样,没有冗余,听上去似乎很优雅。如果说代码复用节约了程序员的时间,软件层面的复用不是更加节省了程序员群体的工作量吗?
最近我做了个将Org文档转换到微信公众号文章格式的小工具,它的功能很简单,只不过在解析Org文档后将其转换成HTML并且内联CSS样式,在草草地完成了核心功能后我把它上传到了Github,没过几天我收到了一位用户的反馈,其中一个问题是:本地Org文档的图片都是相对本地的文件路径,能否支持自动上传OSS。
老实说一开始我没想过会有人使用我的工具并给我反馈,收到反馈后我开始抽时间解决这些问题,这并不困难,但是这个过程引起了我的一些反思。
如何定义「一件事」?
怎样算是「一件事」?这并没有一个标准,开发者理解的一件事和用户的理解很可能是不同的。
除了这个文档转换的小工具,近期我投入最多精力的应用是一个输入法程序,仅以中文输入法为例,它似乎应该专注于将用户的输入转换到中文字符这一件事上。我发现市面上的一些输入法有一个功能,即按下引号键(可以引申到其它的左右区配的标点符号),可以一次性输入中文的左右引号,并将光标保持在引号中间。在引号上还有个特别的功能是,由于中文引号区分左右而在英文键盘上只有一个引号键,当用户奇数次按这个键时,输入左引号;偶数次按这个键,输入右引号。这个功能似乎很方便,并且都属于转换用户输入到中文字符这一个功能。
但是不巧,帮助用户更好地编辑文本也是文本编辑器的活,很多编辑器也支持按左符号键自动输入右符号,有时候输入法的功能会和编辑器起冲突!
如果你去搜索输入法的英文,会发现它叫「Input Method Editor」。
回到如何解决用户提出的问题的话题上来,我写这个工具最初的目的只是希望它处理下Org文档,可以让我直接复制到公众号平台发布,上传图片到OSS的工具有很多,由于我插入图片比较少,所以我在需要插入图片的时候直接在shell跑一个命令,将图片上传再把OSS链接贴回Org文档内。
既然已经有很多用于上传本地图片到OSS了,那么这显然不应该是我的小工具该做的了,于是我提供了一个配置项,用户可以指定一个外部程序,当我的工具在遍历Org的AST时,将本地图片链接通过 stdio 传递给外部程序,并用其返回的文本替换已有的本地图片路径。完成这个功能后我感到很完美,我遵循了先贤的教诲,我的程序没有做多余的事,并且可以文本流的方式与别的程序配合使用。但在用户看来,这可能很不合理,我只想下载一个应用,完成我要做的事情,怎么我下载的软件还要借助别的程序一起完成这个功能呢?
用户都是专家?
不久前我在v2ex看了篇帖子,大致内容是有个在一个Github仓库里提了个issue,愤愤地表示「为什么不能给我下个下载链接让我下载后双击打开直接用」。当然我并不认为开发者有义务做什么,只是这使用联想到一些人常把开源软件和难用联系到一起,在我看来一个可能的原因是,开发者常常认为用户也是和自己一样的专家。
仍然以我的小工具为例,如果有一个非程序员用户来使用它,他得先了解 stdio
、 shell
等各种概念,但可能他只是期望打打字,动动鼠标点击几下就完成工作。这种工具对他来说实在太糟糕了。计算机的广泛应用是在图形界面操作系统兴起之后,在今天大部分计算机用户的眼里,软件就是双击鼠标可以打开的一个界面,例如一个文件查看器,双击打开就能看一个目录下有多少文件,文件是什么类型,再双击又可以浏览文件内容,至于shell、管道是什么这些东西并没有人关心。
术业有专攻,既便是计算机专业的用户,也顶多只是自己所研究领域的专家,就在我写这一段文字的前一天,我看到一位使用neovim的程序员发帖说明了自己为什么从neovim的native LSP(需要安装多个插件配合)换回到CoC(一个相对大而全的插件)。
时代因素
UNIX哲学是前人总结的经验之谈,计算机行业是个新兴行业同时也是飞速发展的行业,过去的经验有没有一些是由当时的客观条件促成的呢?我个人的看法是,至少有两点影响了UNIX哲学:
- 过去的硬件相对现在性能差,价格高
- 过去使用计算机的人大都是专家
早期的计算机是一种昂贵的设备,只有一些专业人士可以接触,最早的硬盘只有MB级别的容量,对比之下今天的一些手机应用安装包就 超过1G !重复造轮子在当时看来不仅不优雅,还是不可原谅的浪费行为。同时当时计算机还没有「飞入寻常百姓家」,在技术人员眼里,通过管道组合命令实现需求只是家常便饭,而且相比使用一个融合了太多功能彼此之间高度耦合的庞然大物要自由灵活得多。
好的案例
如果要我说一个我认为的好的软件的案例,我会说是 Vim 和 Emacs 。可能有人会反驳,尤其是Emacs,它不是号称什么都能做吗?看网页、炒股、煮咖啡,甚至有人戏称它是一个伪装成编辑器的操作系统。是的,这看上去破坏了前面说的第一条原则,一个软件只应做好一件事,而Emacs却能做无数件事!Vim和Emacs都支持通过脚本来拓展,事实上它们的本体都很好地专注在文本编辑这件事上(尤其是Vim),同时它们都内嵌了一个脚本语言的解释器,很多强大的功能是通过插件来实现的,这也可以看做是核心程序和插件程序的组合。
在Vim和Emacs里,应用本体变成了一个库,提供接口给插件调用,核心部分和一些功能的具体实现分离开来,这其实也体现了UNIX哲学的另一个原则——分离原则。Vim诞生于1991年,Emacs则是1975年,而当微软发布在2016提出了LSP(Language Server Protocol)之后,这两个古老的软件都通过社区插件支持了这个流行的协议,而它们的本体却无需做什么大改动。
再说回「不是所有用户都是专家」的问题,的确,即使在程序员群体中,Vim和Emacs也常被抱怨门槛过高,要花很多时间去配置,要学习一门脚本语言,为什么不直接用开箱即用的IDE?对这个问题,我只能说这不是开发者的错,也不是使用者的问题。好像有点「非战之罪」的味道,用户群体的需求不同,知识背景也不同,很难找出一个好的平衡,依据过往的经验,只能说,尽量保持应用的小和精,为用户提供最大的灵活性,同时如果有精力可以提供一个开箱即用的「整合包」,类似SpaceVim和Spacemacs做的那样。
文本
很多商业软件提供配置功能,在一个图形界面上点击、选择就可以决定启用或关闭哪些功能,看上去给了用户很大的自由度,然而用户根本无法掌控自己的配置。如果用户说:「我现在有两台电脑,我要在它们之间同步配置」,这些封闭的应用会说:「来注册账户吧,使用本公司出品的云服务,轻松同步应用配置」。商业应用总是倾向于捆绑用户,向用户推售「全家桶」。
相比之下,UNIX哲学推荐使用文本格式控制应用行为,用户可以阅读,可以使用其他程序自由地编辑。可以使用git、rsync、nix之类的工具来同步配置,用户可以自由选择,而不是必需使用某家企业的服务。
文本是最简单、通用的格式,如前文所述,通过管道机制连接不同程序的输入与输出,大大增强了UNIX程序的可组合性和灵活性。
KISS principle
亲吻原则?不不不,KISS是「Keep it simple, stupid!」的缩写(计算机人总爱搞一些奇怪的缩写)。保持简单和笨拙。
雕琢前先要有原型,跑之前先学会走
Paul Graham在其文集《黑客与画家》讲过一个故事,在创业时期,他的团队使用Lisp开发了一个让用户以「所见即所得」的方式搭建网上商店的应用,他们每一两天就发布一个新版本,总是更快地将新功能推送给用户,最终打败了当时所有的竞争对手。存在于脑海中的优秀的设计、创新的想法和宏伟的构想,如果连第一个能运行的原型都没有做出来,那就只是空中楼阁,没有任何意义。
先解决问题本身比完善的设计更为重要。我个人的经验是,如果你陷入对一个复杂、精致的系统的幻想之中,先起身去喝杯茶清醒清醒,回来后强迫自己在短时间内写一个「残次品」出来,相对来说,「能跑就行」是一个软件的优秀品质。
过早优化是万恶之源
可能程序员多多少少都有点完美主义,总是希望能做到尽善尽美,在最开始编写一个程序的时候就想着各种「优化」。有些程序员可能读过Martin Fowler的经典之作《重构》,但可能只读了/记住了一部分。例如,我见过一些程序员在写新功能时总是先全部写到一个大函数里,等到这个函数行数多了之后,就将其几乎全部的内容提取成另一个函数,我难以理解这种行为,这既没有增加代码的可读性,新提取的函数也没有可重用性。只能理解为,在他看来「提取函数」是一种好的行为,要在系统出问题前先做些「好事」。
题外话:《重构》是本好书,强烈推荐没有读过的程序员去读一读。这里提到它是因为我发现一些程序员经常把重构的目的和重构的手段弄混了,最极端的是认为重构只有提取函数和提取变量两种方式,并认为做这两件事就是好的。
除了代码本身的过早「优化」,还有一种对程序性能的过早「优化」。一些程序员总对性能有极大的焦虑,在程序初期就对一些细节做优化,缓存、内联,用上各种手段,甚至于为此用上一些「黑魔法」,牺牲了代码的可读性。可事实上,整个系统都没有做过Benchmark,也就是说,根本不知道性能瓶颈在哪里就开始了所谓的「优化」,最后往往是无用功。
最后
在技术日新月异的当下,UNIX哲学仍然具有一定的指导意义,只是要辩证地看待它,结合实际灵活应用才是关键。说到底,软件工程是一个实践学科,经验、原则是在实践中积累和验证的,「Talk is cheap」,就在实践中继续探索吧。