Karpathy力薦博客:寫代碼的時候,請心疼一下讀代碼的同事

機器之心報導

編輯:Panda、張倩

今天上午,著名 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 || !Q)) 和 requires (!(P || Q)) 中,標記 || 的含義是不同的。前者是約束析取,後者是古老的邏輯或運算符,它們的行為是不同的。

你不能為一個瑣碎的類型分配空間,然後不費吹灰之力就在那裡 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