最近我发现,讨论代码可读性时,认知心理学提供了一个很好的视角。那些我们熟知的最佳实践背后都有共同的心理学基础。
代码是给人读的,而人脑处理信息的方式是有规律的。理解这些规律,就能明白为什么有些代码读起来轻松,有些代码读起来费劲;就能在面对具体问题时做出更好的判断,而不是机械地遵循教条。
认知负荷理论:理解代码复杂度的本质
认知负荷理论(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() // 创建连接(有副作用)
看起来相似的名字,却有完全不同的性能特征或副作用,这种不一致会让人掉坑里。
双重编码理论往深了说可能有点“玄学”,或者说对认知的理解可能是在潜意识层面了。比如大脑看到较长的一段代码就会下意识认为它的逻辑是复杂的、运行是慢的,包括潜意识会认为名字长的函数会做更复杂的事情。不过咱们日常编程也不必要考虑这么多,就不细说了。
总结
略。