代码是对的,但我们读不懂

我在工程团队中经常听到一句话,包括我自己的团队。

我们其实没检查。但它能工作。

一年前这句话会让我停下来。现在它就这么滑过去了。在过去六个月左右的时间里,某些东西发生了变化。软件中最古老的安全网——人类总是可以阅读代码——不再起作用了。不是因为代码变得无法阅读,而是因为跳过阅读变得可以接受了。没有人决定它应该这样。它只是悄悄地不再是规则了,一次一个合理的决定。

我想认真对待这个问题,因为我不认为这是纪律问题。我认为这是一个新的抽象层,我们在没有应用之前对每个抽象层所做的测试就接受了它。

先做一个区分,因为这实际上是问题的关键。代码并不是不可读的。任何单独的代码片段你都可以打开并理解。我们失去的是阅读整个系统的能力,去看一眼就理解它在做什么。各个部分仍然是可读的。但它们组合在一起形成的整体形态不再可读。

1、我们过去能够检查的东西

工程学一直在放弃不再需要手工完成的工作。我们不再写汇编。我们不再管理内存。我们不再阅读编译器产生的机器码。每一次我们都放弃了一些可见性,并获得了很好的回报。

每一次这样的交换都有一个共同的特质。我们停止检查的东西是安全的,可以停止检查。计算器每次返回相同的答案。编译器是确定性的,如果你需要,你可以检查它做了什么并理解其逻辑。我们不再查看不是因为查看变得不可能,而是因为它变得不必要了。抽象层下面的工作是确定的,被恰当地检查过一次,从此永远以相同的方式运行。

这就是让这一切运作的安静条件。我们移除不再需要检查的工作。

AI移除的却是我们仍然需要能够检查的工作。

并非全部。代理产生的很多内容你可以安全地放手——测试、迁移、胶水代码,就像你放弃汇编一样。问题在于那些你仍然需要理解的部分,而它并不会自我标识为与众不同的部分。

它也不会大声地失败。一个出错的编译器往往会明显地崩溃。AI生成的错误代码通常看起来和正确的代码一模一样。它能编译,能通过明显的测试,读起来像是一个有能力的工程师写的。错误在于判断,而不是语法,而判断不会在diff中显现。

而且数量很大,以任何审查流程都无法吸收的规模涌入。所以我们停止检查,理由很合理——太多了,而且大部分都没问题。我们得到的是一个全新的抽象层,却不具备让旧的抽象层值得放心忽略的那些特性。

2、那个看起来很安全的模式

这就是我们栽跟头的地方。

我们当时在构建一些常规的东西。我们的核心系统会触发事件,我们将这些事件和各类数据属性同步到一个处理客户消息的第三方平台。这个模式是可重复的。你连接一个事件,证明它能工作,然后对所有其他事件重复这个模式。

有很多其他事件。几十个事件,每个都是相同想法上的微小变体。所以我们做了明智的事。我们正确地构建了一个,确认了模式成立,然后让代理完成剩下的。因为每个案例都和之前的非常相似,而且第一个是正确的,我们没有详细审查其余的部分。

每一个单独的片段都是正确的。代理忠实地复制了模式。每个事件都同步了,每个属性都落在了正确的位置。如果你审查了其中任何一个,你都会批准它。

但代理从未进行整合。一个人类在构建第十个、第二十个、第四十个相同的东西时,会开始感受到它的重量,并寻找一个中心位置来统一路由。这种本能不是整洁癖。这是当你知道自己以后还得回来理解这个东西时会做的事情。代理没有"以后"。它生成第四十个案例就像第一个一样轻松,就在它所在的位置直接连线。结果就是,我们的事件同步分散在整个系统中,没有一个地方可以让你一眼就理解它。

没有东西坏了。一切都能工作。而我们再也无法通过检查来回答一个基本问题:我们从这里发出去什么,什么时候?

3、可读性是整体的属性

这就是我一直强调的区别。正确性是局部的。可读性是全局的。

你可以验证每一个单独的更改,却仍然错过了整个系统已经变得不可解释的事实。没有哪个正确的决定能保护整体的可读性,因为整体从来不是任何一个任务的问题。没有人负责整合,所以没有人整合,而每一个正确的片段都让整体图景稍微更难阅读了一些。

然后可读性就不再是抽象的了。当我们在几周后回来添加更多事件时,有些事件在毫无意义的时间点触发。调试意味着手工重建什么从哪里发送,这正是分散的结构所造成的问题。在这个过程中我们发现了第二个问题。代理对哪些事件应该在什么时候发送做出了错误的假设,以一种任何单个diff都无法显示的方式误解了业务上下文。

路由错误是真正的bug。但不可读性是让它隐藏起来的原因。如果一切通过一个中心管道运行,错误的事件就会摆在眼前。可读性不是系统工作后你才添加的装饰。它是让下一个问题可以被发现的东西。

4、为什么人类会去整合

把这种整合本能称为好的工程学很诱人。我不这么认为。我认为这是具有长远视野的自利行为。

当你构建你知道自己将拥有的东西时,你会以不同的方式构建它。你将在深夜调试它,在六个月后扩展它,当有人问它为什么这样做时你要为它负责。那个未来塑造了工作。你让系统可理解,因为你是那个必须理解它的人。可读性不是你带给代码的美德。它是预期的所有权产生的结果。

这就是为什么外包构建如此难以真正做对的部分原因。写代码的人不是必须在其中生活的人,所以让系统保持可读的那种安静的激励缺失了,没有任何流程能完全替代它。

AI是这种情形的纯粹版本。它对明年需要维护的代码库没有任何利害关系,因为明年不会有"它"这个版本存在。每次访问都是一次冷启动。它不会因为理清自己写的东西而感到沮丧,它只是理清它,每次都是,对自己没有任何成本。你可以从这些工具在你指出一团糟时的回应中看到这种缺失。它们立刻同意,修复这个实例,然后什么也不会带到下一个文件中。没有任何东西将它们拉向可理解性,因为没有任何东西让它们为缺乏可理解性付出代价。

5、把激励重新放回人类手中

那么,对于一个曾经免费获得但已经停止获得的属性,你该怎么办?

不是去读每一行代码。那个边界已经消失了,不会回来了,假装不是这样只是怀旧。大多数生成的代码可以不读,就像你不读汇编一样。关键从来不是你读了多少,而是你让什么保持可读性。数据的流动方式、业务意图编码的位置、重构成本高昂的决策。现在的工作是有意地保护这些,因为写代码的东西不会保护它们。

第一步是所有权。让一个有名有姓的人负责解释一个系统,而不仅仅是交付它。拥有者构建可读的系统是因为他们预期会被问"它为什么这样做",所以重新创造这种预期,你就重新创造了大部分本能。这也是为什么在构建本身中,人类拥有数量所依附的结构。你建立中心管道,代理将每个案例接入其中。不是因为设计优先是一种美德,而是因为产生数量的东西没有理由收敛到单一的形态。

第二步是保持它的可解释性,并将其视为系统的一个真实属性,而不是锦上添花。如果没有人能说出为什么一段代码能工作,那就是一个缺陷,即使代码是正确的。当你发现不可读性时就修复它,就像修复一扇破窗一样,不要大张旗鼓。在那些错误答案代价高昂的部分,做老派的事情。让人们坐下来,让某人大声地从代码中解释一段代码在做什么。如果他们做不到,那就是发现。

第三步是停止只针对代理进行优化。有一种时髦的观点认为目标是一个对代理友好的代码库。这只对了一半。代码现在有两个读者——代理和必须为它负责的人类。目标不是让AI觉得容易工作的代码库。而是让一个人类在AI触及了每个部分之后仍然能理解的代码库。只为第一个读者优化,你就造就了这篇文章所讲的确切问题。

6、代价是什么,以及何时付出

我们抓住了我们的问题。它花掉了一个困惑的下午和一次重构,而不是一次事故。但那是伪装成险情的运气。

我一直在思考的是,AI并没有创造这个问题。它移除了曾经警告我们的摩擦。手工编写第四十个几乎相同的东西是很烦人的,而这种烦人的感觉就是停下来整合的信号。我们过去把结果归功于判断力。实际上很大程度上只是人们拒绝被第四十一次烦扰而已。

有两件事曾经几乎免费地让系统保持可读性。手工做重复工作的短期摩擦,以及有人将不得不与结果共存的长期现实。代理两者都感受不到。它从不烦扰,也从不回来。

所以可读性不再能自我维持了。它是一门你需要有意保护的纪律,否则你将看着它一次一个合理决定地离开。


原文链接: The code was right. We could not read it.

汇智网翻译整理,转载请标明出处