今天上午,著名 AI 科学家 Andrej Karpathy 在 X 上分享的一篇文章引起了广泛关注和讨论。这篇文章的核心论点是「认知负荷很重要」,即在写代码时,应该考虑之后阅读者和维护者能否更轻松地理解这些代码。Karpathy 认为「这可能是最真实,但最少被实践的观点。」毕竟相当多开发者都乐于在自己的项目或工作中「炫技」,甚至以花哨复杂、难以理解为荣。
很多读者对此表示了认同,并分享了自己的观点和经历。
Hyperbolic 联合创始人及 CTO Yuchen Jin 顺势分享了一本书《软件设计的哲学》。他指出:「复杂性是软件的主要敌人。」这本书将复杂性定义为:软件系统结构中任何会使系统难以理解和修改的东西。而认知负荷是复杂性的一个重要因素。
开发者 Aryan Agal 给出了一个更为具体的建议:避免循环代码调用,让代码的结构像树一样。
langwatch.ai 开发者 Rogerio Chaves 则吐嘈说:最喜欢增加别人认知负荷的是中级开发者,初级和高级开发者都会尽力让自己的代码清晰明白,目标就仅仅是解决问题。
也有人思考 AI 编程中的认知负荷问题。
不过,也有人表示,聪明开发者在代码中炫的技其实很有趣。
以下是这篇文章的中文版。文章作者为软件开发与服务公司 Inktech 的 CTO Artem Zakirullin,他同时也是一位资深开发者。
认知负荷很重要
在软件开发领域,有太多的流行词和最佳实践了,但让我们关注一些最基本的东西吧。真正重要的东西是开发者在处理代码时感到的困惑度。
困惑会浪费时间和金钱。困惑是由高认知负荷(cognitive load)引起的。这不是一些花哨的抽象概念,而是一种基本的人类约束。
认知负荷
认知负荷是开发者为了完成一项任务所需的思考量。
阅读代码时,你会将变量值、控制流逻辑和调用序列等内容放入头脑中。普通人的工作记忆中大约可以容纳四个这样的块。
相关讨论:https://github.com/zakirullin/cognitive-load/issues/16
一旦认知负荷达到这个阈值,就很难再理解各种事情。
假设我们的任务是修复一个完全不熟悉的项目。我们被告知该项目的贡献者包括一个非常聪明的开发者,他使用了很多炫酷的架构、花哨的软件库和时髦的技术。也就是说,那位开发者给我们造成了高认知负荷。
我们应该尽可能减少项目中的认知负荷。
认知负荷的类型
内在型:来自任务本身固有的难度。这种认知负荷无法减少,并且也正是软件开发的核心。
外来型:源自信息呈现的方式。这种认知负荷的产生因素与任务并不直接相关,比如某个聪明开发者的奇怪癖好。这种认知负荷可以大幅减少。这也是本文关注的认知负荷。
复杂条件
ifval > someConstant // 🧠+
&& (condition2 || condition3) // 🧠+++, 上一个条件应该为真,c2 或 c3 之一必须为真
&& (condition4 && !condition5) { // 🤯, 这个会让我们的头脑混乱不清
...
}
引入一些名称有意义的中间变量
isValid = val > someConstant
isAllowed = condition2 || condition3
isSecure = condition4 && !condition5// 🧠, 我们不需要记住这些条件,这里存在描述性变量
if isValid && isAllowed && isSecure {
...
}
继承的噩梦
当我们需要为我们的管理员用户更改一些内容时:🧠
AdminController extends UserController extends GuestController extends BaseController
哦,一部分功能在 BaseController 中,让我们看看:🧠+
GuestController 中引入了基本的角色机制:🧠++
UserController 中一部分内容被修改了:🧠+++
最后,AdminController,让我们编写代码吧!🧠++++(认知负荷越来越高)
哦,等等,还有个 SuperuserController 是对 AdminController 的扩展。如果修改 AdminController,我们会破坏继承类中的某些东西,所以让我们首先研究下 SuperuserController:🤯
优先使用组合而不是继承。这里不会深入详情,但这个视频《继承的缺陷》值得一看:https://www.youtube.com/watch?v=hxGOiiR9ZKg
小方法、类或模块太多了
在这里,方法、类和模块的含义是可以互换的。
事实证明,「方法应该少于 15 行代码」或「类应该很小」之类所谓的警句是有些错误的。
深模块(Deep module)—— 接口简单,功能复杂
浅模块(Shallow module)—— 相对于它提供的小功能而言,接口相对复杂
浅模块太多会使项目难以理解。我们不仅要记住每个模块的功能,还要记住它们的所有交互。要了解浅模块的目的,我们首先需要查看所有相关模块的功能。🤯
信息隐藏至关重要,并且我们不会在浅模块中隐藏太多复杂性。
我有两个实验性项目,差不多都有 5K 行代码。第一个有 80 个浅类,而第二个只有 7 个深类。我已经一年半没有维护过这些项目了。
当我回头进行维护时,我意识到很难理清第一个项目中这 80 个类之间的所有交互。我必须重建大量的认知负荷才能开始写代码。另一方面,我能够快速掌握第二个项目,因为它只有几个深类和一个简单的接口。
正如《软件设计的哲学》的作者、斯坦福计算机科学教授 John K. Ousterhout 说的那样:「最好的组件是那些提供强大功能但接口简单的组件。」
UNIX I/O 的接口就非常简单。它只有五个基本调用:
此接口的现代实现有数十万行代码。许多复杂性都隐藏在了引擎盖下。但由于其接口简单,因此非常易于使用。这个深模块示例取自《软件设计哲学》一书。
特性丰富的语言
当我们最喜欢的编程语言发布了新特性时,我们会感到兴奋。我们会花一些时间学习这些特性,并在此基础上构建代码。
如果新特性很多,我们可能会花半小时玩几行代码,以使用这个或那个特性。这有点浪费时间。但更糟糕的是,当你稍后回来时,你得重新构建那个思考过程!
你不仅要理解这个复杂的程序,你还得理解为什么程序员决定从可用的特性中选择这种方式来解决问题。
此处引用 Rob Pike 说的一句话:
通过限制选择的数量来减少认知负荷。
只要语言特性彼此正交,它们就是可以接受的。
来自一位有 20 年 C++ 经验的工程师的想法
前几天,我在看我的 RSS 阅读器时发现,我的「C++」标签下有三百多篇未读文章。从去年夏天到现在,我一篇关于 C++ 语言的文章都没读过,感觉好极了!
我使用 C++ 已经有 20 年了,它几乎占了我生命的三分之二。我的大部分经验都是在处理这种语言最阴暗的角落(比如各种未定义的行为)。这些经验并不能重复使用,而且现在全部扔掉还真有点让人毛骨悚然。
比如,你能想象吗,在 requires ((!P
你不能为一个琐碎的类型分配空间,然后不费吹灰之力就在那里 memcpy 一组字节 —— 这不会启动对象的生命周期。在 C++20 之前就是这种情况。C++20 解决了这个问题,但这门语言的认知负荷却有增无减。
尽管问题得到了解决,但认知负荷却在不断增加。我应该知道修复了什么,什么时候修复的,以及修复前的情况。毕竟我是专业人士。当然,C++ 擅长遗留问题支持,这也意味着你将面对遗留问题。例如,上个月我的一位同事向我询问 C++03 中的一些行为。🤯
有 20 种初始化方式。增加了统一初始化语法。现在我们有 21 种初始化方式。顺便问一下,有人还记得从初始化列表中选择构造函数的规则吗?关于隐式转换,信息损失最小,但如果值是静态已知的,那么...... 🤯
这种认知负荷的增加并不是由手头的业务任务造成的。它不是领域的内在复杂性。它只是由于历史原因而存在(外在认知负荷)。
我不得不想出一些规则。比如,如果那行代码不那么明显,而我又必须记住标准,那我最好不要那样写。顺便说一句,该标准长达 1500 页。
我绝不是在指责 C++。我喜欢这门语言。只是我现在累了。
分层架构
抽象本应隐藏复杂性,但在这里它只是增加了间接性。从一个调用跳转到另一个调用,以便读取并找出出错和遗漏的地方,这是快速解决问题的重要要求。由于这种架构的层解耦(uncoupling),需要指数级的额外跟踪(通常是不连贯的)才能找到故障发生点。每一个这样的跟踪都会占用我们有限的工作记忆空间。🤯
这种架构起初很有直觉意义,但每次我们尝试将其应用到项目中时,都是弊大于利。最后,我们放弃了这一切,转而采用古老的依赖倒置原则。没有需要学习的端口 / 适配器术语,没有不必要的水平抽象层,没有无关的认知负担。
如果你认为这样的分层可以让你快速替换数据库或其他依赖关系,那就大错特错了。改变存储会带来很多问题,相信我们,对数据访问层进行抽象是最不需要担心的事情。抽象最多只能节省 10% 的迁移时间(如果有的话),真正的痛苦在于数据模型不兼容、通信协议、分布式系统挑战和隐式接口。
因此,如果将来没有回报,为什么要为这种分层架构付出高认知负荷的代价呢?
不要为了架构而增加抽象层。只要出于实际原因需要扩展点,就应该添加抽象层。抽象层不是免费的,它们需要占用我们有限的工作记忆。
领域驱动设计(DDD)
领域驱动设计有一些很好的观点,尽管它经常被曲解。人们说「我们用领域驱动设计来写代码」,这有点奇怪,因为领域驱动设计是关于问题空间的,而不是关于解决方案空间的。
无处不在的语言、领域、有边界的上下文、聚合、事件风暴都是关于问题空间的。它们旨在帮助我们了解有关领域的见解并抽象出边界。DDD 使开发人员、领域专家和业务人员能够使用统一的语言进行有效沟通。我们往往不关注 DDD 的这些问题空间方面,而是强调特定的文件夹结构、服务、资源库和其他解决方案空间技术。
我们解释 DDD 的方式很可能是独特而主观的。如果我们在这种理解的基础上构建代码,也就是说,如果我们创造了大量无关的认知负荷,那么未来的开发人员就注定要失败。
示例
我们的架构是标准的 CRUD 应用程序架构,是 Postgres 基础上的 Python 单体应用:https://danluu.com/simple-architectures/
Instagram 如何在仅有 3 名工程师的情况下将用户数量扩展到 1400 万:https://read.engineerscodex.com/p/how-instagram-scaled-to-14-million
我们觉得「哇,这些人真是聪明绝顶」的公司大部分都失败了:https://kenkantzer.com/learnings-from-5-years-of-tech-startup-code-audits/
连接整个系统的一个功能。如果你想知道系统是如何工作的,那就去读读吧:https://www.infoq.com/presentations/8-lines-code-refactoring/
这些架构非常枯燥,也很容易理解。任何人都可以轻松掌握。
让初级开发人员参与架构审查。他们会帮助你找出需要花费脑力的地方。
熟悉项目中的认知负荷
如果你已经将项目的心智模型内化到了你的长期记忆中,你就不会体验到高认知负荷。
需要学习的心智模型越多,新开发人员实现价值所需的时间就越长。
新人加入项目后,请尝试衡量他们的困惑程度(结对编程可能会有所帮助)。如果他们的困惑时间连续超过 40 分钟,那么你的代码中就有需要改进的地方。
如果你能保持较低的认知负荷,新人就能在加入公司的几个小时内为你的代码库做出贡献。
结论
试想一下,我们在第二章中的推论实际上并不正确。如果是这样的话,那么我们刚刚否定的结论,以及前一章中我们认为有效的结论,可能也不正确。
你感觉到了吗?你不仅要在文章中跳来跳去才能理解其中的意思(浅模块),而且整个段落也很难理解。我们刚刚给你的大脑造成了不必要的认知负担。不要这样对待你的同事。
我们应该减少任何超出工作本身的认知负荷。
对于认知负荷,你有什么看法呢?
原文链接:https://minds.md/zakirullin/cognitive
“掌”握科技鲜闻 (微信搜索techsina或扫描左侧二维码关注)