不是算法的问题,是编码的问题。
有那么几天,我确信自己的系统里藏着一个深层的、神秘的 bug。我的日志输出成了乱码。一个数据转换步骤产出的文件,结果出了些微妙的错误。一个子进程返还给我的不是我预期的输出,而是一堆莫名其妙的东西。我跑去寻找逻辑里的缺陷,藏在系统某个精巧部分里的缺陷。
逻辑里根本没有缺陷。这个 bug 是:我和我的电脑,对于该怎么拼写这件事,意见不一致。
那个不在代码里的 bug
最让人抓狂的是,一切看上去都是对的。我能读产生乱码的那段代码,却找不出任何不对劲的地方。那些失败既奇怪又前后不一:有些文本好端端地通过了,有些通过时被打乱了,有些文件能完美打开,另一些却出了微妙的损坏。这个现象里没有任何一处指向某一行我能去修复的代码。
那种前后不一,我后来才知道,是一个常见的线索。但在当时,它看上去就只是我的程序被闹鬼了。
真正出错的地方:两个程序,两套字母表
底下的真相是这样的。计算机并不把文本当作字母来存储。它把文本存成数字,而要把这些数字再变回字符,你需要一张事先约定好的表——一种编码——它规定哪个数字对应哪个字符。如果一个系统的两个部分用了不同的表,同样的字节对它们各自就意味着不同的东西,于是一方写下的文本会被另一方误读。
那就是我整个 bug 的全部。我的操作系统默认用的是一种较旧的、遗留的编码,而我手头在用的工具和文件假定用的是那种现代的、近乎通用的编码。只要两张表碰巧一致——对于最普通的字符它们确实一致——一切就正常运转;而一旦有任何东西踏出那块重叠区域,它就立刻崩坏。文本从来不是被故意破坏的。它只是被用一种和写它时不同的语言读了出来。
为什么它这么难看出来
编码不匹配是一类格外讨厌的 bug,因为它往往不会干净利落地失败。一个宽容的转换不会抛出一个指向问题所在的异常,而是会产出几乎正确的输出,一直正确到它撞上两种编码意见相左的某个字符为止——然后它会悄无声息地只把那一部分搅烂,再继续往下走。
所以那些失败是断断续续的,并且取决于内容,而这恰恰是那种会把你引向错误地方去找的特征。断断续续、依赖内容的 bug 感觉上一定住在你的逻辑里,住在那个让你引以为傲的复杂部分里。可这一个住在管道里,住在一个我甚至从未注意到自己在依赖的默认设置里。
这类工作不光鲜的真相
我曾以为,构建一个学习系统中最难的部分会是那些有意思的部分——模型、数学、策略。结果,真正的难点里有相当大的一部分,是这样的东西。文本编码。换行符。在不同机器上意味着不同东西的文件路径。一切之下那些枯燥的基础设施,在那里,一个搞错了的默认设置就能悄悄吃掉你一周的人生,而你却盯着上层那些优雅的代码,确信问题一定在那儿。
这让人谦卑,是一种有用的谦卑。最常把我拦下来的,很少是数学。是那些管道。
修复办法:别再信任默认设置,强制达成一致
修复并不微妙。我不再依赖系统每个部分碰巧默认用的那种编码,而是在每一个边界上都显式地强制使用同一种——那种现代的、通用的编码:每一次文件读取、每一次文件写入、每一次子进程启动。一旦每个组件都被白纸黑字地强制就同一张表达成一致,乱码就停了。
在那个主要修复之外,我还加了一个更小的、防御性的习惯:从一开始就把某些麻烦的字符挡在我的日志之外——这不是把编码搞对的替代品,而是一道兜底。日志是当其他一切都着了火时,你最需要能看得清楚的那个地方,而我不想让自己赖以诊断问题的那个工具,一再栽在同一类问题手里。
教训:默认设置,是一个你没做过的决定
更深的那个教训比这个 bug 活得更久。你技术栈里的每一个“默认”,都是一个决定——通常是某个并不了解你具体处境的人做的,往往是许多年前,出于一些如今可能已不再适用的理由。大多数时候,那些被你继承下来的决定都没问题,你永远不用去想它们。这正是它们危险的部分原因。当其中某一个碰巧对你的情形悄悄地错了的时候,它几乎是隐形的,因为你从未有意识地选择过它——而你从未选择过的那个东西,是你最后才想到要去看的地方。
我把将近一周的时间,输给了一个我都不知道自己做过的选择。如今,当某样东西以一种毫无逻辑可言的方式失败时,我最先问的问题之一,不再只是“我的 bug 在哪儿?”它也是“我正在假定什么,却从未真正决定过它?”
— 没有信号,没有收益,不构成投资建议。