从认知心理学看代码可读性

2025-10-15
编程

最近我发现,讨论代码可读性时,认知心理学提供了一个很好的视角。那些我们熟知的最佳实践背后都有共同的心理学基础。

代码是给人读的,而人脑处理信息的方式是有规律的。理解这些规律,就能明白为什么有些代码读起来轻松,有些代码读起来费劲;就能在面对具体问题时做出更好的判断,而不是机械地遵循教条。

认知负荷理论:理解代码复杂度的本质

认知负荷理论(Cognitive Load Theory)由 John Sweller 在 1980 年代提出,最初用于教学设计。核心观点很简单:人的工作记忆容量有限,当认知负荷超过容量时,学习和理解效率会急剧下降。

三种认知负荷

这个理论把认知负荷分成三种:**内在负荷(Intrinsic Load)**来自问题本身的固有复杂度,**外在负荷(Extraneous Load)**来自信息呈现方式不当带来的额外负担,**相关负荷(Germane Load)**来自构建和强化心智模型的过程。这三种负荷有个总的限制:内在负荷 + 外在负荷 + 相关负荷 ≤ 工作记忆容量

应用到读代码的场景:内在负荷对应问题本身的复杂度,比如分布式事务的一致性、复杂的业务逻辑;外在负荷对应代码写得烂带来的额外负担,比如糟糕的命名、混乱的结构、不一致的风格;相关负荷对应理解代码的过程,包括理解抽象、建立心智模型、学习领域知识。

外在负荷完全可以消除,且应该尽量消除。这是我们优化代码可读性的主战场。同样的逻辑,写法不同,理解难度可能相差几倍。而相关负荷是"好的"负荷,好代码应该帮助读者高效地建立正确的心智模型。如果外在负荷太高,留给理解真正问题的容量就不够了。所以好代码的目标是最小化外在负荷,优化相关负荷。

为什么我们需要抽象

在读代码这个任务上,内在负荷并不是一成不变的。同样的需求可以有不同的设计,不同的设计带来不同的理解问题的视角。一个好的抽象能让复杂问题变简单,从而降低内在负荷。

举个例子:假设你要管理一堆相互依赖的状态变化,直接去处理这些状态转换会很复杂(高内在负荷)。但如果引入"状态机"这个概念来理解,问题就清晰多了。虽然学习"状态机"需要一些时间(相关负荷),但理解问题本身变简单了(内在负荷降低)。这就是好的抽象的价值:用相关负荷(学习抽象)来换取更低的内在负荷(问题变简单了)

从代码可读性的角度看,这解释了为什么设计模式有价值。它们提供理解复杂问题的"心智工具",降低问题的内在复杂度。比如 Observer 模式让"多对多的依赖关系"变得容易理解。领域驱动设计(DDD)的价值也在这里:找到合适的领域抽象,让复杂的业务逻辑从"一团乱麻"变成"清晰的概念组合"。当然,设计模式和DDD还有其他方面的价值,这里只讨论它们对代码可读性的贡献。

理解了内在负荷和相关负荷可以相互转化,我们就能明白什么是好抽象,什么是过度抽象。好抽象是降低的内在负荷大于引入的相关负荷,总认知负荷降低了。而过度抽象恰恰相反:内在负荷没降低,相关负荷却增加了,比如把简单的 5 行代码拆成 3 个函数需要跳来跳去才能理解,为了"可扩展性"引入根本用不到的复杂设计模式,为了避免"可能的"重复搞出过于通用的抽象。

工作记忆容量限制:代码可读性的核心挑战

认知心理学的一个有趣的发现是 Miller’s Law(7±2 法则),它告诉我们:人的工作记忆容量有限,一次只能处理 5-9 个信息单元。

这个工作记忆有点像 CPU 的寄存器:容量很小,但处理速度很快。而长期记忆更像硬盘:容量大,但访问速度慢。读代码时,我们主要依赖工作记忆来理解逻辑,如果工作记忆装不下,就得频繁去"硬盘"(长期记忆)里翻找之前看过的内容,效率就低了。

读代码时,我们需要在脑海中记住:变量的值、状态、分支条件、上下文等。如果超过了工作记忆容量,就会忘记前面的内容,需要回头翻看,思维流程被打断,理解效率急剧下降。

这法则可以用来解释许多常见的代码编写规范。

比如一个函数不应该太长。原因很简单:信息太多时,工作记忆不够用,读到后面就忘了前面的内容,需要回头翻看。

再比如为什么要拆分函数,为什么要封装类。它们的作用就是信息压缩,把占用多个工作记忆单元的信息压缩成 1 个信息单元,显著减少工作记忆占用。

命名为什么如此重要?当命名足够准确时,我们只要看到变量名或者函数名就能直接判断它的行为,这样可以做到完全不占用工作记忆。

不要使用过深的嵌套。这是因为在阅读过程中每进入一层嵌套时,外层的信息作为 context 需要保存在工作记忆中,嵌套过深很容易导致工作记忆不够用。而 early return 模式能很好的解决这个问题,它的价值在于:处理完某种情况后立即 return,该分支占用的记忆单元被释放,不用继续记住

互相调用的函数要放在一起。一方面避免读到调用函数的时候,被调用的函数信息已经因为工作记忆不够用而忘记了;另一方面被调用的函数读完很快就被用掉了,可以安全地从工作记忆中释放掉,快速腾出空间。

其他的还有诸如减少变量的作用域、函数参数列表不要过长、变量的定义和使用不要离太远,都是类似的道理,不多说了。

格式塔理论

格式塔揭示了代码格式的重要性:大脑会自动把零散信息组织成有意义的整体。我们可以利用这个特性来减少工作记忆占用。

比如接近性原则:大脑会自动根据东西的间隔将它们分组。我们在编码代码时相关代码放在一起,用空行分隔不同的逻辑组。效果很明显:

 1// 没分组:20 个独立信息单元
 2func handleRequest(req Request) {
 3    validateAuth(req)
 4    validateInput(req)
 5    validatePermission(req)
 6    user := getUser(req.UserID)
 7    data := fetchData(req.DataID)
 8    cache := checkCache(data.ID)
 9    result := process(user, data, cache)
10    saveResult(result)
11    updateCache(result)
12    sendNotification(user)
13    logRequest(req)
14    updateMetrics(req)
15}
16
17// 分成 4 组:只需记住 4 个逻辑单元
18func handleRequest(req Request) {
19    // Validation
20    validateAuth(req)
21    validateInput(req)
22    validatePermission(req)
23    
24    // Data fetching
25    user := getUser(req.UserID)
26    data := fetchData(req.DataID)
27    cache := checkCache(data.ID)
28    
29    // Processing
30    result := process(user, data, cache)
31    saveResult(result)
32    updateCache(result)
33    
34    // Post-processing
35    sendNotification(user)
36    logRequest(req)
37    updateMetrics(req)
38}

大脑看到后者时会自动识别出"四个阶段",而不是"12 行代码"。这就是信息压缩。

还有相似性原则,这是说大脑很擅长识别相似的模式,保持一致的命名风格、代码结构,大脑就能识别出"这是同一模式"。

 1// 一致的模式:大脑只需记住一个模式
 2getUserName(id)
 3getUserAge(id)
 4getUserEmail(id)
 5getUserAddress(id)
 6
 7// vs 混乱的风格:每个都要单独理解
 8getUserName(id)
 9fetch_user_age(id)
10GetUserEmail(id)
11user_address(id)

这也是为什么代码规范很重要:不是为了好看,是为了降低认知负担。

重复代码的可接受性

说到这里,我想讨论一个有争议的话题:重复代码。

传统观点认为重复是坏的(DRY 原则),但从工作记忆的角度看,并非所有重复都是坏的

工整的重复代码,虽然看起来有一大片,但因为模式相同,实际上不占用多个心智单元。大脑很擅长识别模式:“哦,这 5 段代码都一样,只是参数不同”。这种理解成本远低于"理解一个复杂的抽象 + 在多个文件间跳转"。

 1// 工整的重复:一个模式,容易理解
 2result1 := validate(data1)
 3if result1.Error != nil {
 4    return result1.Error
 5}
 6
 7result2 := validate(data2)
 8if result2.Error != nil {
 9    return result2.Error
10}
11
12result3 := validate(data3)
13if result3.Error != nil {
14    return result3.Error
15}
16
17// vs 抽象后:需要理解 helper,跳转查看定义
18results := validateAll([]Data{data1, data2, data3})
19for _, r := range results {
20    if r.Error != nil {
21        return r.Error
22    }
23}

哪个更容易理解?很多时候是前者。

什么样的重复可以接受?简单、工整、模式清晰的重复,次数不多,每次都很直观不需要跳转就能看懂。这样的重复虽然看起来有一大片代码,但因为模式相同,大脑只需要识别一个模式,不会占用多个工作记忆单元。相反,复杂逻辑的重复、大量重复(10+ 次)、不工整的重复(每次都有细微差异需要仔细对比)就不能接受了,这会增加维护成本和认知负担。

DRY 是手段,不是目的。目的是降低维护成本和认知负担。有时候一点重复比"引入抽象 + 跳转理解"占用更少的工作记忆。

双重编码理论:视觉与语义的协同

最后聊一个有意思的理论:双重编码理论(Dual Coding Theory)。

Allan Paivio 发现:人脑有两个独立但相互关联的信息处理通道。

这个理论的关键洞察是:两个通道同时工作,当它们相互加强相互印证时,理解效率翻倍;但如果两个通道传递矛盾信息,就会造成认知混乱。

例如很多代码 format 工具都会把成员初始化的冒号(:)和批量赋值的等号(=)进行对齐,这些不是仅仅为了美观的“表面功夫”,而是可以理解成在用视觉通道传递信息,用对齐表达“一组相似的操作”,减少认知负担。

这个理论也可以用来解释 Yoda notation 为什么不好读。

1// 糟糕:视觉顺序和语言顺序冲突
2if (NULL == ptr) {
3    // ...
4}
5
6// 好:视觉顺序 = 语言顺序
7if (ptr == NULL) {
8    // ...
9}

语言通道理解:“当 ptr 为空时”,而视觉上从左到右是"空 等于 ptr",顺序是反的。两个通道传递的顺序冲突,就会增加认知负担,让人读起来总觉得哪里怪怪的。

再举个例子:视觉通道看到"一组相似的名字",语义通道就会期待"一组相似的行为",即相似的外观应该对应相似的语义,如果语义不符合这个期待,就会产生认知冲突。

 1// 好:看起来像,做的事也像
 2getUserName()      // 从缓存读
 3getUserAge()       // 从缓存读
 4getUserEmail()     // 从缓存读
 5
 6// 糟糕:看起来像,但行为不一致
 7getUserName()      // 从缓存读
 8getUserAge()       // 从缓存读
 9getUserAddress()   // 发起网络请求查数据库!
10
11// 也很糟糕:都是 get 开头,副作用不一致
12getUser()          // 纯读取
13getConnection()    // 创建连接(有副作用)

看起来相似的名字,却有完全不同的性能特征或副作用,这种不一致会让人掉坑里。

双重编码理论往深了说可能有点“玄学”,或者说对认知的理解可能是在潜意识层面了。比如大脑看到较长的一段代码就会下意识认为它的逻辑是复杂的、运行是慢的,包括潜意识会认为名字长的函数会做更复杂的事情。不过咱们日常编程也不必要考虑这么多,就不细说了。

总结

略。


欢迎加入技术讨论 QQ 群: 745157974

AI 辅助编程时代,哪些编程语言会更流行?

2025-11-03
编程 AI