认知负荷才是最重要的
这是一份持续更新的文档,最后更新时间: 2024年8月
简介
虽然有很多流行词和最佳实践,但让我们关注一些更基本的东西。真正重要的是开发人员在阅读代码时感到的困惑程度。
困惑会耗费时间和金钱。困惑是由高认知负荷引起的。这不是一个花哨的抽象概念,而是一个基本的人类约束。
由于我们花在阅读和理解代码上的时间远远多于编写代码的时间,我们应该不断问自己是否在代码中嵌入了过多的认知负荷。
认知负荷
认知负荷是指开发人员为完成任务需要思考的程度。
在阅读代码时,你会将变量值、控制流逻辑和调用序列等信息放入大脑中。普通人的工作记忆中大约可以保存四个这样的信息块。一旦认知负荷达到这个阈值,理解事物就会变得困难得多。
假设我们被要求修复一个完全陌生的项目。我们被告知一位非常聪明的开发人员参与了这个项目。使用了很多酷炫的架构、花哨的库和流行的技术。换句话说,前任作者为我们创造了高认知负荷。
我们应该尽可能减少项目中的认知负荷。
棘手的是,由于熟悉项目,前任作者可能没有经历高认知负荷。
熟悉度与简单性
问题在于熟悉度并不等同于简单性。它们感觉相似 — 在空间中移动时都不需要太多脑力 — 但原因完全不同。你使用的每一个"聪明"(读作:"自我放纵")和非惯用的技巧都会给其他人带来学习成本。一旦他们完成了学习,就会发现使用代码变得不那么困难。所以很难认识到如何简化你已经熟悉的代码。这就是为什么我试图让"新人"在他们变得过于制度化之前对代码进行批评!
前任作者很可能是一点一点地创造了这个巨大的混乱,而不是一次性完成的。所以你是第一个试图一次性理解全部内容的人。
在我的课上,我描述了我们有一天看到的一个庞大的SQL存储过程,WHERE子句中有数百行条件。有人问是如何让它变得这么糟糕的。我告诉他们:"当只有2或3个条件时,再添加一个不会有什么区别。当有20或30个条件时,再添加一个也不会有什么区别!"
除了你做出的深思熟虑的选择之外,代码库上没有其他"简化力量"在发挥作用。简化需要努力,而人们往往太急于求成。
感谢Dan North的上述评论。
当你为新人介绍你的项目时,试着衡量他们的困惑程度(结对编程可能会有帮助)。如果他们连续困惑超过~40分钟 - 那么你的代码就有需要改进的地方。
认知负荷和中断
认知负荷的类型
内在认知负荷 - 由任务的固有难度引起。无法减少,它是软件开发的核心。
外在认知负荷 - 由信息呈现方式引起。由与任务不直接相关的因素引起,比如聪明作者的怪癖。可以大大减少。我们将重点关注这种类型的认知负荷。
让我们直接跳到外在认知负荷的具体实际例子。
P.S. 欢迎贡献!
我们将认知负荷水平表示如下:
🧠
: 新鲜的工作记忆,零认知负荷
🧠++
: 工作记忆中有两个事实,认知负荷增加
🤯
: 工作记忆溢出,超过4个事实
复杂的条件语句
if val > someConstant // 🧠+
&& (condition2 || condition3) // 🧠+++, 前一个条件应为真,condition2或condition3之一必须为真
&& (condition4 && !condition5) { // 🤯, 到这里我们已经混乱了
...
}
引入具有有意义名称的中间变量:
isValid = val > someConstant
isAllowed = condition2 || condition3
isSecure = condition4 && !condition5
// 🧠, 我们不需要记住条件,有描述性的变量
if isValid && isAllowed && isSecure {
...
}
嵌套的if语句
if isValid { // 🧠+, 好的,嵌套代码只适用于有效输入
if isSecure { // 🧠++, 我们只对有效且安全的输入执行操作
stuff // 🧠+++
}
}
将其与提前返回进行比较:
if !isValid
return
if !isSecure
return
// 🧠, 我们不太关心早期返回,如果我们到达这里,那么一切都好
stuff // 🧠+
我们可以只关注快乐路径,从而将工作记忆从各种前提条件中解放出来。
继承噩梦
我们被要求为管理员用户更改一些内容: 🧠
AdminController extends UserController extends GuestController extends BaseController
哦,部分功能在BaseController
中,让我们看看: 🧠+
基本角色机制在GuestController
中引入: 🧠++
事情在UserController
中部分改变: 🧠+++
终于到了AdminController
,让我们开始编码吧! 🧠++++
哦,等等,还有SuperuserController
继承自AdminController
。通过修改AdminController
,我们可能会破坏继承类中的东西,所以让我们先深入研究SuperuserController
: 🤯
优先使用组合而不是继承。我们不会详细讨论 - 网上有大量相关材料。
太多小方法、类或模块
方法、类和模块在这个上下文中是可互换的
像"方法应该少于15行代码"或"类应该很小"这样的口号被证明有些错误。
深度模块 - 简单的接口,复杂的功能
浅层模块 - 相对于它提供的小功能而言,接口相对复杂
太多浅层模块会使理解项目变得困难。我们不仅要记住每个模块的责任,还要记住它们之间的所有交互。要理解浅层模块的用途,我们首先需要查看所有相关模块的功能。🤯
信息隐藏至关重要,而我们在浅层模块中隐藏的复杂性并不多。
我有两个宠物项目,它们都大约有5K行代码。第一个有80个浅层类,而第二个只有7个深度类。我有一年半没有维护这些项目了。
当我回来时,我意识到要理清第一个项目中这80个类之间的所有交互是极其困难的。在开始编码之前,我必须重建大量的认知负荷。另一方面,我能够快速掌握第二个项目,因为它只有几个具有简单接口的深度类。
最好的组件是那些提供强大功能但接口简单的组件。
John K. Ousterhout
UNIX I/O的接口非常简单。它只有五个基本调用:
open(path, flags, permissions)
read(fd, buffer, count)
write(fd, buffer, count)
lseek(fd, offset, referencePosition)
close(fd)
这个接口的现代实现有数十万行代码。很多复杂性都隐藏在底层。但由于其简单的接口,它很容易使用。
这个深度模块的例子来自John K. Ousterhout的书《软件设计的哲学》。这本书不仅涵盖了软件开发中复杂性的本质,还对Parnas的有影响力的论文《关于将系统分解为模块的标准》进行了最好的解释。这两本书都是必读的。其他相关阅读:可能是时候停止推荐《代码整洁之道》了,小函数被认为是有害的,线性代码更易读。
如果你认为我们支持责任太多的臃肿的上帝对象,那你就错了。
太多浅层微服务
我们可以将上述与规模无关的原则应用于微服务架构。太多浅层微服务不会带来任何好处 - 行业正朝着"宏服务"的方向发展,即不那么浅层的服务。最糟糕且最难修复的现象之一就是所谓的分布式单体,这通常是过度细粒度浅层分离的结果。
我曾经为一家创业公司提供咨询,那里一个由三名开发人员组成的团队引入了17个(!)微服务。他们的进度落后了10个月,似乎离公开发布还很远。每一个新需求都会导致4个以上微服务的变更。集成空间的诊断难度急剧上升。上市时间和认知负荷都达到了不可接受的高水平。🤯
这是处理新系统不确定性的正确方法吗?在开始时推导出正确的逻辑边界是非常困难的。关键是要在你能负责任地等待的最后时刻做出决定,因为那时你拥有最多的信息来做出决定。通过引入网络层,我们从一开始就使我们的设计决策很难逆转。该团队唯一的理由是:"F(M)AANG公司已经证明微服务架构是有效的"。嘿,你得停止做白日梦了。
Tanenbaum-Torvalds辩论认为Linux的单体设计存在缺陷且过时,应该使用微内核架构。确实,从"理论和美学"的角度来看,微内核设计似乎更优越。但在实践方面 - 三十年过去了,基于微内核的GNU Hurd仍在开发中,而单体Linux却无处不在。这个页面由Linux提供支持,你的智能茶壶也由Linux提供支持。由单体Linux。
一个精心设计的具有真正隔离模块的单体通常比一堆微服务更灵活。它也需要更少的认知努力来维护。只有当对独立部署的需求变得至关重要(例如开发团队扩展)时,你才应该考虑在模块(未来的微服务)之间添加网络层。
功能丰富的语言
当我们喜欢的语言发布新功能时,我们会感到兴奋。我们花一些时间学习这些功能,我们基于它们构建代码。
如果有很多功能,我们可能会花半个小时玩弄几行代码,以使用这个或那个功能。这有点浪费时间。但更糟糕的是,当你稍后回来时,你必须重新创建那个思考过程! 🤯
你不仅要理解这个复杂的程序,还要理解为什么程序员决定这是利用可用功能解决问题的方法。
前端认知负荷: 🧠
(新鲜,无需记忆事实)
QA 端认知负荷: 🧠
同样的规则适用于所有类型的数字状态(在数据库或其他地方) - 优先选择自描述字符串。我们已经不在640K内存的计算机时代,不需要为内存优化。
人们花时间争论
401
和403
的区别,根据自己的心智模型做决定。新开发人员加入时需要重新理解这个思考过程。你可能为代码记录了"为什么"(ADR),帮助新人理解所做的决定。但最终这并没有意义。我们可以将错误分为用户相关或服务器相关,但除此之外,界限是模糊的。
附:区分"身份验证"和"授权"通常很费脑力。我们可以使用更简单的术语如["登录"和"权限"]来减少认知负荷。
滥用 DRY 原则
不要重复自己 - 这是你作为软件工程师学到的第一批原则之一。它深深根植于我们心中,以至于我们无法容忍几行额外的代码。尽管总体上这是一个良好且基本的规则,但过度使用会导致我们无法承受的认知负荷。
如今,每个人都基于逻辑分离的组件构建软件。这些组件通常分布在代表不同服务的多个代码库中。当你努力消除任何重复时,可能最终会在不相关的组件之间创建紧密耦合。结果是,一个部分的更改可能会对其他看似不相关的区域产生意外后果。这也会妨碍在不影响整个系统的情况下替换或修改单个组件的能力。🤯
实际上,即使在单个模块内也会出现同样的问题。你可能会过早地提取共同功能,基于可能长期并不存在的感知相似性。这可能导致难以修改或扩展的不必要抽象。
Rob Pike 曾经说过:
少量复制好过少量依赖。
我们强烈倾向于不重新发明轮子,以至于准备导入大型、重量级的库来使用我们可以轻松自己编写的小函数。在决定何时导入外部库以及何时更适合编写简洁、独立的代码片段来完成较小任务时,要做出明智的决定。
与框架的紧密耦合
框架以自己的步调发展,在大多数情况下与我们项目的生命周期不匹配。
过度依赖框架,我们迫使所有即将加入的开发人员首先学习该框架(或其特定版本)。尽管框架使我们能够在几天内推出 MVP,但从长远来看,它们往往会增加不必要的复杂性和认知负荷。
更糟糕的是,在某些时候,当面对不适合架构的新需求时,框架可能成为重大限制。从这时起,人们最终会分叉框架并维护自己的自定义版本。想象一下,新人为了提供任何价值而必须构建(即学习这个自定义框架)的认知负荷量。🤯
我们绝不提倡从头开始发明一切!
我们可以以某种与框架无关的方式编写代码。业务逻辑不应驻留在框架内;相反,它应该使用框架的组件。将框架置于核心逻辑之外。像使用库一样使用框架。这将允许新贡献者从第一天开始就增加价值,而无需首先经历框架相关复杂性的残骸。
六边形/洋葱架构
所有这些东西都带来一定的工程兴奋。
我自己多年来一直是洋葱架构的热情支持者。我在这里那里使用它,并鼓励其他团队这样做。我们项目的复杂性上升了,仅文件数量就翻了一番。感觉我们在写很多胶水代码。在不断变化的需求下,我们不得不在多层抽象中进行更改,这一切变得乏味。🤯
从一个调用跳到另一个调用,阅读并找出哪里出错和缺少什么,是快速解决问题的重要要求。由于这种架构的层解耦,需要指数级额外的、常常不连贯的跟踪才能到达故障发生的地方。每一次这样的跟踪都会占用我们有限工作记忆中的空间。🤯
这种架构最初看起来很有道理,但每次我们尝试将其应用于项目时,都造成的危害远大于好处。最后,我们放弃了它,转而支持良好的旧依赖倒置原则。没有端口/适配器术语需要学习,没有不必要的水平抽象层,没有额外的认知负荷。
不要为了架构而添加抽象层。只在出于实际原因需要扩展点时添加它们。抽象层并非免费的,它们需要在我们的工作记忆中保存。
尽管这些分层架构加速了从传统的以数据库为中心的应用程序向某种与基础设施无关的方法转变,其中核心业务逻辑独立于任何外部因素,但这个想法绝非新颖。
这些架构并非根本性的,它们只是更基本原则的主观、有偏见的结果。为什么要依赖这些主观解释?相反,遵循基本原则:依赖倒置原则、隔离、单一真实来源、真正不变量、复杂性、认知负荷和信息隐藏。
DDD
领域驱动设计有一些很好的观点,尽管它经常被误解。人们说"我们用 DDD 写代码",这有点奇怪,因为 DDD 是关于问题空间,而不是解决方案空间。
通用语言、领域、限界上下文、聚合、事件风暴都是关于问题空间的。它们旨在帮助我们了解领域的洞察并提取边界。DDD 使开发人员、领域专家和业务人员能够使用单一、统一的语言进行有效沟通。我们倾向于强调特定的文件夹结构、服务、存储库和其他解决方案空间技术,而不是关注 DDD 的这些问题空间方面。
我们对 DDD 的解释很可能是独特和主观的。如果我们基于这种理解构建代码,即如果我们创建了大量额外的认知负荷 - 未来的开发人员就注定要失败。🤯
结论
理解和问题解决领域中认知负荷的复杂多面性质需要一种勤勉和战略性的方法,以便驾驭复杂性并优化心智容量分配。 🤯
你感觉到了吗?上面的陈述很难理解。我们刚刚在你的头脑中创造了不必要的认知负荷。不要对你的同事这样做。
我们应该减少任何超出我们所做工作固有认知负荷的额外认知负荷。