回归编程初心
编程很酷
控制计算机
只有当你会编程,你才真正开始成为计算机的主人。这是仅仅使用他人生产的应用程序永远也无法实现的。
一个不会编程的人甚至无法在Windows系统上自由地弹出一个窗口,让窗口里显示一串指定的文本。对于会编程的人只需要写一个以
.vbs为扩展名的文本文件,里面包含以下内容:MsgBox "Hello"然后双击启动它就够了。
VBS对我来说已经是非常遥远的回忆,但当我重新创建和运行这个脚本时,它仍然散发出一种魅力,尽管我完全知道这只是解释器在背后调用了MessageBox这样的Win32 API而已。
一定程度上,编程可以说是一种权力需求的满足:执行编写好的程序就如同帝王驱使自己的臣民。不过,由于要满足这种权力需求的认知成本有时很高昂,这最终使我们能够脱离这种低级趣味,转而迈向克服困难,寻求超越的道路。
知其所以然
计算机对于外行人来说充满着神秘感:这是一个内部原理不明的装置,正如生活中的其他装置一样。
按下抽水马桶上的按钮,马桶就会冲水,你不需要知道马桶是怎么工作的。人类只需要学习如何按下按钮,让马桶执行这套内置的冲水程序。计算机不过是具有交互界面的马桶,电脑是马桶,手机亦如是。
外行人知道行为与现象之间有联系,却不知道行为为什么会引发现象,行为的背后是什么,现象的背后是什么。计算机爱好者如果不懂编程,就仍然只是外行人,只不过比其他外行人多道听途说一些或者擅长依葫芦画瓢罢了——这跟文明未开化时主导各类仪式的祭祀也没多大差别。
掌握编程能让我们揭开面纱,充分理解计算机“引擎盖下”发生的事。你会逐渐意识到编写出的程序很多情况下只是理解过程中的副产品,或者说只是一份证明。如果你足够自信,你编程的动机就会因此大大减弱。如果你足够有经验,你就会意识到这种没来由的自信经常被现实的复杂所打败,所以你还是会选择编写程序进行验证。
此外,你还可能从编程活动中获得更高层次的感悟,从而能够采取一个新的视角来看待这个世界。比如Stephen Wolfram就写了一本A New Kind of Science。
编程不酷
代码的美丽与丑陋
我确实能从代码中感觉到“美”与“丑”:
- 好的抽象是美,坏的抽象是丑,缺乏抽象是丑。
- 理解透彻的人写出的代码是美,一知半解的人写出的代码是丑,货物崇拜编程写出的代码是丑。
- 经过深入思考,有理有据的约定是美,盲目追随流行,轻易订下的约定是丑。
- 纯函数是美,副作用是丑。
- 一致是美,不一致是丑。
- ……
某一天,我忘了是哪一天,我顿悟了。我意识到代码中所有的美其实都来自于编写代码的人对抗复杂性的努力,哪怕这种努力只是增减几个空格把文本对齐。反过来说,如果编写代码的人放弃了对抗复杂性(或者甚至不是人),那么代码就会显得丑陋。
考虑到形成这种审美观所要经受的折磨,很难期待人们能够普遍形成这种审美观。因此,在大多数情况下,我们都将面对由丑陋的,甚至是仅仅能够运行的代码构成的项目。这类项目的一大特点是,开发人员总是显得很勤快,因为他们被迫用战术上的勤奋来弥补战略上的失败。于是,项目的代码修改量和提交次数会轻易达到很高的量级,但这却并不意味着项目就更成熟。
妥协于失败的抽象
计算机领域有一些抽象简直烂完了,回过头看会觉得根本是脑子有问题的人设计出来的。
比如SQL,一个完全失败的抽象层。这玩意的语法设计烂到让语法铁路图成为文档中的必需品,可重用性低到开发者需要经常抄自己的旧代码。SQL如今唯一的正面作用可能是制造数据库管理技能可以在不同DBMS之间迁移的假象——这样就会有人错误地认为一个专攻MySQL的DBA也有搞定PostgreSQL的能力。
关系型数据库之间,除了一些基本概念是共通的以外,每个DBMS的底层设计都不一样。为了能在适配SQL的同时,又不丧失自身的优势,DBMS到处都在发生抽象泄漏。为了泄漏底层实现,每个DBMS都会选择在SQL的基础上发明自己的方言——不幸的是,这又是一个错误决定——倘若DBMS之后要适应更新的SQL标准,那么就会出现“同时存在做一件事情的两种方法”的情况,而且SQL标准通常还是两种方法里体验更差的那一个。出于“让我们遵循标准”这样的理由,你还得放着好的选项不用而去选择那个差的选项。如果标准SQL和方言之间还存在语法设计上的冲突,那么这种可耻的混乱将会达到极致。
如果DBMS都从头开始设计自己的接口,把SQL这个失败的抽象层去掉,所有这些问题都是可以避免的,每一个DBMS都会比现在容易使用。当然啦,设计新的抽象又难又累,需要大量试错,市场份额还可能会下降,何必冒这个险呢?所以我们还是继续用垃圾吧。
可怕的是,当一个抽象已经很失败的时候,还可以变得更加失败:在失败的抽象上继续建立新的抽象。哎呀,难道在一个坏掉的蛋糕上裱花会让它变回一个好蛋糕吗?ORM基本上就是数据库世界中的“I can fix him/her”,也难怪会有人把它比作计算机科学中的越南战争。如果说SQL只是在制造通用的假象,那么ORM就是试图让这个通用的假象成为现实——不幸的是,只要DBMS的底层设计不同,这就是不可能的。于是情况从糟糕变得难以理解:原本你只需要穿透一层失败的抽象就能解决的问题,现在你要穿透两层甚至更多失败的抽象才能做到了。
抽象问题
抽象可以认为是对复杂性的封装,它要么隐藏复杂性,要么让复杂性在表达时至少变得圆润一点。
操作系统是一个不错的抽象——抽象了硬件细节,留出了足够多的空间供使用者发挥。然而,操作系统提供的API仍然不容易使用,于是更高级的抽象才会存在。
Visual Basic也是一个很好的抽象——当我们今天这么说的时候,主要是指它的IDE。VB之父Alan Cooper的许多设计理念从VB一直延续到.NET时代的头几年,这铸就了Windows原生应用开发的黄金时代。Visual Studio的WinForms窗体设计器至今仍是我用过最好的微软产品——不过,微软最终还是把一切都搞砸了。
不难意识到,编程的魅力很多时候都与良好的抽象有关。我们可以进一步推出:大部分糟糕的代码都与不良的抽象有关。为了适应和利用不良的抽象,代码会充斥着不必要的噪音。
细粒度抽象
纯函数式编程的重点基本上就是提供一个细粒度的通用数学抽象工具箱,程序员用工具箱里的数学抽象搭积木。这些通用数学抽象的本质是语法,语法和函数的界限因此在纯函数式编程中非常模糊。理论上,只要工具箱得到移植,程序员可以在不同的语言里使用相同的语法编写程序。
这酷吗?也许有一点。不过,即使酷存在,这种体验也很短暂:当你开始真的用纯函数式的思维写程序时,酷很快就消失了。
纯函数式编程从未流行起来:
- 数学抽象太多,太难学习。
- 即使掌握了数学抽象,相比直接用编程语言的语法,在表达力上也没有优势。
- 在编程过程中总是会大量创建自定义函数,这几乎等同于大量创建语法。这会严重阻碍源代码的共享,因为代码此时是由一种为该任务专门发明的DSL写成的,以至于对编写者以外的人太缺乏可读性。即使是编写者自己,在三个月后也未必还能认得自己写的东西。
粗粒度抽象
让我们来思考一个极高级语言里可能出现的内置函数
GetWeather(),它返回一个内置类型Weather作为结果。在极高级语言之外的任何语言里,有这样一个内置函数都会成为问题,你会严重怀疑决定将它列为内置函数的人的水平:
- 该函数的抽象彻底掩盖了问题的复杂性。
- 该函数的实现需要连接到作为数据源的远程服务器,必然缺乏稳定性。
- 作为内置功能,该函数及其返回值需要在版本间保持稳定。当未来出现新需求时,它很可能无法及时适应。
- ……
现在我们透露一些关于这个函数的额外信息:
- 它是由具有无限资源的千人专职团队在背后维护的,支持你能想到的所有与气象相关的标准功能。
- 它是如此久经考验,适应所有极端情况,已经完美运行了三十年,几乎所有人都在用,是事实上的行业标准。
- 语言官方的目标是让它至少稳定运行到本世纪末,他们非常有信心能做到这一点。
- ……
你的想法是否会有一些改变?我猜大概是有,尤其是在这个函数完全免费供应的情况下。实际上,现在这个函数已经变酷了,因为你大概率不想自己实现
GetWeather()——你想把时间花在更有趣的地方,而不是处理这种枯燥无聊的类ETL任务产生的边缘情况。从这个角度看,我们并不排斥魔法般的黑盒抽象,而是只排斥那些假装自己是魔法的蛇油抽象。问题是蛇油常有,而魔法不常有。由于分辨蛇油和魔法有点累人,久而久之,我们已经不再相信魔法存在。
鱼和熊掌
我们知道
MessageBox()的参数比MsgBox()的参数要复杂,但我们并不关心。有些情况下,你突然得关心了,这时你就遇上了麻烦。于是MsgBox()的抽象反而变成了一道阻碍,它阻碍了你直接访问MessageBox()。一个好的抽象在行方便的同时,不会妨碍你定制细节,这意味着粗细粒度在某种程度上的结合。鱼和熊掌可以兼得吗?偶尔可以,但大多数情况下,除非抽象是你自己构建的,否则兼得的可能性会大大降低。这是因为抽象的构建和使用之间有一个反馈循环,只有在你能同时控制抽象的构建和使用时,这个循环才能有效运转起来。
别忘了抽象本身也是一种负担。你在一个来自第三方的抽象上的投入越高,你积累的沉没成本就越多,这项投资在你遇到阻碍时就越失败。最失败的情况下,你需要两个甚至更多的抽象,这些抽象服务于一个相似的目的,只是细节上不同。它们的接口独立,学习成本独立,维护成本独立,安全风险独立,功能覆盖独立,甚至可能互相干扰——毕竟它们是不同的抽象。
没有过早优化这回事
“过早优化是万恶之源”因为缺失上下文的断章取义,已经沦为了一句纯粹的屁话。这句话更准确的说法是,“无效的微优化既浪费开发时间,又牺牲代码质量,所以别这么做。”
我想从抽象角度再补充一件事,一件只有做过以高性能为目标的项目的人才知道的事:高性能项目之所以能做到高性能,是因为它们在一开始就采用了高性能的设计。对于高性能项目来说,根本没有过早优化这回事,这些项目的抽象从一开始就是专门面向性能而设计的。那些在事后才决定要优化性能表现的项目根本没有资格跟这些项目相提并论——差距从决定要使用的抽象起就已经甩开了,除非重写整个项目,否则根本没有办法追上。
于是,我们对好抽象的理解又上了一层:一个好的抽象应该也是考虑性能上限的抽象。因此,一个足够资深的软件工程师随手写的代码可能就已经同时考虑到了抽象和性能。不过,追求这种境界对普通程序员的要求实在太高了,所以最好还是忘了这件事,继续用“过早优化是万恶之源”来搪塞别人吧。
语言问题
编程语言本身就是问题。
很多编程语言都有一种割裂感,这种割裂感来自于编程语言的设计者不是编程语言的使用者。这导致编程语言的发展走向两个极端,一个语言要么特性极繁化,要么特性极简化。
特性极繁化的编程语言,很可能只是堆叠了一堆不知所谓的面向对象编程特性,而真正有用的语言特性却在这个语言里不可用。一个典型的例子是协程,你会发现很多编程语言都不支持在工程上有用的协程——确切地说是能够双向通信的无栈协程。这些“高级”编程语言要么只支持开销更大且控制权转移不透明的有栈协程,要么只支持单向通信的协程(迭代器),甚至只是装模做样支持下async/await语法就草草了事。仅仅这么一个语言特性就可以刷掉历史上95%的编程语言,如果我再要求更多语言特性马上就没几个语言能用了。
特性极简化的编程语言,本质上是对极繁化设计的反弹,遗憾的是总反弹到错误的方向。极简化制造的问题比极繁化更多。在极繁化语言里,使用者至少可以选择集体无视掉糟糕的语言特性。极简化语言最后往往要用样板代码替代缺失的语言特性,而样板代码是每个人都可以自己发明的,只会制造出更多问题。
为什么不能是自然语言
在编程这件事上,自然语言有且只有一个优点:你的母语是自然语言,此外就全是缺点了。
由于几乎所有编程语言都使用英文,中文世界时不时就会刮过一阵中文编程语言的风潮。中文编程语言的尝试从未称得上成功,也必然不可能成功,因为大多数只是把语法关键字改成中文而已,而那少部分也没凸显出中文相对英文的任何优势。最关键的是,认为编程语言采用母语就能产生什么变化这件事本身就是个错误,采用母语的编程语言仍然是编程语言,而不是自然语言。
编程语言相对于自然语言,几乎可以视作是一种DSL,只是比平常说的狭义上的DSL要更通用而已。在这种DSL里,你必须使用规定的语法格式来编写代码,并且对歧义零容忍。说穿了,编程语言是可执行的应用数学,语言中的所有要素都是用来代表数学符号的,而用自然语言来描述数学符号从来都不会比用数学自己的语言更简洁和精准。
如果强行用自然语言替代编程语言,会发生什么呢?日本漫画《游戏王》衍生出的集换式卡牌游戏就是很好的例子。随便找一张近年推出的游戏王卡片,上面都会用密密麻麻的自然语言写上卡片效果。然而,这些卡片效果不易理解,因为它实际上采用了自己的形式系统,只是用卡片发行地区的自然语言写成文本而已。在玩家之间,这种奇怪的文本格式被笑称为K语言。K语言里充满了用自然语言写成的关键字,这远不像编程语言那样容易解析,更别指望有语法高亮了。这导致玩家在阅读一张自己不认识的卡片时,需要在脑中先将自然语言文本解析为K语言语法,然后再从K语言语法转换为自己对游戏的理解。这不仅效率低下,还很容易出错。当玩家尝试DIY卡片时,还会意识到要编写K语言文本也很困难。因为微小的文本差异会变成完全不同的含义,编写者需要对K语言的理解相当深入才能写出合格的文本。你可以想象,如果卡片效果是用DSL来写的,那读写起来可要容易太多了。
极高级语言
其实只需要一种超越高级语言的通用极高级语言就能让我们回到双击VBS脚本的那个下午——表面上,任何需求的实现都像在VBS里调用
MsgBox()弹出消息框一样简单直接。引擎盖下,这些需求的实现方式又极为精妙,完美适配代码编写者的意图。极高级语言势必会将“编程时无需关心的细节”的定义进一步扩展。考虑到我们已经在很大程度上放任编译器来管理内存和优化代码的执行效率,倘若极高级语言会让我们放任更多决定,也不见得就是无法接受的。
为什么Python还不是极高级语言
即使在一众高级语言里,Python也是级别最高的语言之一:
- Python的语法已经非常接近自然语言,被称为“可执行的伪代码”。在通用编程语言里,我能想到比它更接近自然语言的也就BASIC了,但BASIC的语言特性远不及它。
- Python的标准库和生态环境极其强大,常见需求几乎都已经得到满足。在通用编程语言里,可能也就JavaScript的生态环境能跟它相提并论了,但JavaScript的标准库远不及它。
Python有多高级?
举个例子,我出于娱乐目的写过一个密码学程序,没有用Python。在密码学里,会需要进行超大整数的模幂运算,即
base ^ exponent % modulus。该运算在参数非常大的情况下是难以计算的,也就是说你不能先执行幂运算,然后再取模。你的编程语言支持大整数类型?可惜,它在密码学需要的大整数规模下要么会直接报错,要么慢到无法忍受。为了进行超大整数的模幂运算,你需要运用数学原理,找或者写一个高效的modPow()函数。在Python里,整数类型能够无缝自动扩容至大整数,这解决了整数和大整数是两种类型的问题。然后,其内置函数pow()除base和exp外还有可选的第三参数mod——这意味着该函数直接支持了高效模幂运算。此外,由于pow()是由C语言实现的,它也总是比那些需要自己实现该函数的语言效率高。你所需要的大部分方便,Python都已提前准备妥当,而其他编程语言则大概率没有。“Life is short, you need Python”并非虚言,倘若我用Python写相同的应用,大概可以节省不少时间。那么是什么阻碍已经如此方便的Python成为一个极高级语言?
首先,我们排除与DSL进行对比,因为这种对比并不是很有意义:SQL比通用编程语言更适合数据库查询又能说明什么呢?然后,我们不考虑在语言内创建DSL的能力,因为这项能力几乎等同于对卫生宏或函数式编程范式的支持程度,而在这块做得最好的Lisp仍然不是极高级语言。于是,我们就会发现,除了语言特性、标准库和生态环境外,它跟BASIC语言并没有本质区别,它只是提供更高级抽象的高级语言——高级语言的线性改良而已。
再拿模幂运算举例,一个极高级语言应该在编译时就“意识到”程序需要进行超大整数的模幂运算。所以,当代码中出现
base ^ exponent % modulus这个模式时,就应该自动处理了,而不是要求程序员主动调用甚至实现modPow()函数。众所周知,Python已经有幂运算的专用运算符**,谁会想到去用pow()函数呢?当你知道需要用专门的模幂运算来解决这个问题时,就已经太晚了,光是因为获取知识而产生的摩擦就已经太多了。此外,当你遇到非平凡的需求,Python的标准库和生态环境都没有提供良好解决方案的时候,Python的魔力也会消失。而非平凡的需求总是比我们预想的还要常见得多。
LLM
很容易将极高级语言的希望寄托于LLM,因为这似乎是唯一有希望的路径。
将LLM用于此类目的时,可以分为两种模式:
- LLM充当极高级语言到低级语言的转译器。这意味着LLM将创建抽象的实现。
- LLM充当极高级语言的运行时。这意味着LLM将执行抽象代表的行为。
如果把LLM封装成一个粗粒度函数,取决于它的实现,它可以是一个转译器,也可以是一个运行时。
充当转译器时,LLM生成的代码丑陋,会轻易超出人类的审查能力——如果我需要的只是可以运行的垃圾,那么直接用开源项目就好了。充当运行时时,LLM的执行效率低下,缺乏重复执行同一类任务时具有相同行为的保证。
目前,LLM的能力实在有限,以至于当你把它用于这些目的时制造的问题可能比解决的问题还多。LLM哪怕有模型供应商吹嘘的一半强大,Adobe全家桶和Office套件的一比一开源替代实现都应该像Linux之于Unix一样被制造出来了。
知识的悖论
越是想让LLM作为转译器发挥作用,就越会遇到这个问题:无法在不仔细审查生成代码的情况下,保证LLM建立的抽象与我们的期望相符。可如果审查者具有足够的知识,审查者可能就会发现自己编写代码比审查生成代码的效率要高。最有可能的是,审查者早就已经解决了相关问题,这就是他习得足够知识的原因。这种情况下,编写代码对于审查者来说很简单,简单到他无法理解为什么还要用LLM这样的额外中间层来降低结果的质量。
这个悖论可以总结为:
- 如果审查者不具有足够审查生成代码程度的知识(理想状态下显然是零知识),那么就不是在做软件工程——无法投入生产。
- 如果审查者具有足够审查代码程度的知识,那么很可能就不需要由LLM生成代码。
TDD无法解决这个悖论,并且实际上会制造更多问题:
- 测试可以很难写,编写足够详尽的测试会比审查生成的代码更花时间。
- 除非抽象像纯函数一样干净,或者测试像端到端一样遥远,否则无法在知识不足的情况下编写有意义的测试。
- 后续更新可能导致测试大量失败,更新测试同样费时费力。
- 现实中存在着难以自动化测试,甚至根本无法自动化测试的场景。
当TDD把事情搞得如此复杂,你就不想亲自实施TDD了,其必然结局是用LLM生成的测试代码来保证LLM生成的代码——最后陷入需要审查生成的大量测试代码(至少数倍于被测试代码)的处境,唯一的收获是审查测试代码所需的知识可能要相对少一点。
这在很大程度上将LLM从一个抽象的实现者降级为抽象的使用者。这使其功能局限于生成容易审查的胶水代码,这已严重偏离了我们对极高级语言的期望。
假装成功
让我们做一个思想实验,假装LLM已经发展到极致。方便起见,我们叫它终极LLM。现在,让我们暂时忘掉终极LLM是否在理论上有可能实现。
充当转译器时,终极LLM能够写出完美的代码,所以审查转译结果完全是浪费时间。充当运行时时,终极LLM的执行效率极高,重复执行同一类任务时总是具有相同的行为。
编程问题现在已经被解决了吗?还没有,因为我们还没有把任务交给终极LLM。只要任务没有像典型的CRUD一样肤浅,终极LLM的数据集里可能就没有。如果任务是在终极LLM训练完毕后才出现/更新的,那么终极LLM可能会在执行时遇上困难。这并不是说终极LLM的内插、外推对此完全无能为力,而是说它会在这些任务上降级到先前的糟糕水平。
不过既然它是终极LLM,它很可能已经克服了这项技术在上下文学习上的困难。这意味着可以通过把知识注入到上下文的方式补上终极LLM的不足,于是问题就变成了如何组织知识。
此外,即使终极LLM已经证明自己胜任某项任务,你可能仍想为这些任务准备知识。原因可能多种多样,比如单纯的信心不足、方便人类中途接管、结构化日志记录、企业的合规性要求等。
假设我们有一个自动化任务,自动化任务总是有这些性质:
- 几乎肯定会带来直接的生产力进步,这赋予人们足够的动机。
- 依赖于非常具体的知识,无法在交付知识上偷懒。
- 足够端到端,且自动化测试通常较为困难,手动测试很常见。
首先让我们尝试录制任务的演示视频,然后把视频作为上下文交给终极LLM。这预设了这项自动化任务的知识可以用演示视频清晰表达。不幸的是,这仍然会在足够复杂的任务上出问题,因为足够复杂的任务会遇到意想不到的边缘情况。所以我们必然会设置一个通用回退行为:在遇到视频中没有记录的情况时,呼叫人类解决。每遇到一种新的边缘情况,你都需要在合适的位置添加一个新视频或者在原有视频里将新片段剪辑进去。不过,此时很可能已经开发出一个工具,允许我们以可视化的方式组织上下文,然后编译成最终的上下文,这将提供很多方便。然而,如果边缘情况很多,甚至边缘情况的解决还会衍生出新的情况,那么用视频组织知识的效率着实有点低。若是未来还可能发生流程上的重大变更(经验表明,总是会发生),用视频组织知识的做法就显得太缺乏可持续性。
考虑到这是终极LLM,应该能用文字和图片传达知识,所以我们转而使用文字和图片来替代视频。由于文字和图片提供的信息量比视频低,所以现在对于知识组织者的文字和逻辑水平有了要求。人类喜欢偷懒,而终极LLM可以生成文字和编辑图片,所以会有相当比例的文字是由终极LLM生成的,图片则是由终极LLM从更大的图片里编辑得到的,人类只需要审查这些生成的内容就可以了。不难发现,由于可视化工具的存在,这时我们已经回退到了自然语言与可视化编程的组合。
可视化编程之前的主要问题是用图形表达编程原语的效率低下,可读性也很低,除了非常基础的控制流外都会遇到困难(会变成字面意义上的意大利面条代码)。被自然语言加持的可视化编程是否解决了这些问题?我猜应该解决了一部分,但自然语言仍然会在一些领域遭遇表达力不足的问题,因此剩余的难以用自然语言表达的部分还是需要回退到领域特定语言上,也就是代码形式上。此时就会出现一个悖论,使其面临与现在的可视化编程相同的窘境:既然时不时得回退到代码形式,为什么不保持一致,干脆全都写成代码呢?
于是,自然语言与可视化编程的组合进一步坍缩成极高级语言的代码形式。视频和图片等媒体文件现在只是作为代码引用的资源存在,而自然语言和编程语言在极高级语言里只是相互补充而已。