Go 之禪

【程序人生編者按】多年前,Python的核心開發成員之一Tim Peter寫下的《Python之禪》,成為Python編程和設計的指導原則。

本文作者作為Go語言的開源貢獻者及項目成員之一,將著眼於Python之禪,提出他所理解的Go語言之“禪”,其中他談到“Go語言的成功在很大程度上要歸功於處理錯誤的明確方式”,為何他這麼說?咱們一起看看。

Go 之禅

作者 | Dave Cheney,Go語言的開源貢獻者及項目成員

出品 | CSDN(ID:CSDNnews)

最近,我時常思考這個問題:如何編寫優秀的代碼?假定沒有人主動試圖編寫不好的代碼,這個問題隨之而來:我們怎麼知道現在所編寫的就是優秀的Go代碼?

如果優秀代碼與不良代碼之間存在連續性,又怎麼區分優秀的那部分?其屬性、特性、標誌、模型和習語又是什麼?

Go 之禅

關於 Go

Go的習語

這就說到了Go的習語——所謂習語,指的是其語法遵從時代的風格。非習語是不遵從流行風格的,是不時尚的。

更重要的是:說誰的代碼不符合習語,並不能解釋它為什麼不算習語。原因何在?就像所有真相一樣,答案可以在字典上找到。

習語:一組由用法確定其含義的單詞,不能以單個單詞的含義推導其含義。

習語是共享值(shared values)的標誌,Go的習語並非我們從書本習得的內容,而是通過加入Go社區而學到的。

我對Go的習語準則的關注點在於,在很多情況下,習語是排他性的。也就是說,“不能兩者兼備。”畢竟,在批評某人作品不符合習語時,我們不就是這個意思麼?他們做法有誤,看起來有問題,不符合時代風格。

我提出Go的習語在編寫優秀Go代碼的教學中並非合宜機制的原因在於,習語是定義好的東西,本質上來說,是告訴對方他們犯錯了。如果我們所給出的建議,不是在代碼編寫者最有意願接受意見的節點質疑他們,並讓他們感到不適,這樣不是更好麼?

諺語

除了有問題的習語之外,Go學家還有什麼其他文化習慣?也許我們可以談談Rob Pike精彩的Go諺語,這些算是合適的教學工具嗎,能否告知新手怎樣編寫優秀的Go代碼嗎?

一般來說,我認為不算。並不是要駁斥Pike,只是單純針對Go諺語,比如Segoe Kensaku的原著,這是觀察,而不是價值陳述。

Go諺語的目標是揭露語言設計相關的深層真相,但類似空接口這樣的建議,對於一款沒有結構化類型的編程語言來說,新手又能從中獲益多少呢?

重要的是要認識到,在一個增長的社區中,任何時候學習Go語言的人遠遠超出聲稱掌握該語言的人,因此在這種情況下,諺語可能不是最好的教學工具。

工程價值

Dan Luu發現了Mark Lucovsky關於Windows團隊在Windows NT到Windows 2000之間這段時間內關於工程文化的一個演講。之所以提到它,是因為Lukovsky將文化描述為一種評估設計和權衡取捨的常用方法。Go 之禅

討論文化的方式有很多,但就工程文化而言,Lucovsky的描述很恰當。其中心思想是在未知的設計空間中使用價值指導決策。NT團隊的價值觀是:可移植性、可靠性、安全性以及可擴展性。工程價值粗略翻譯一下,就是完成工作的方式。

Go的價值觀

Go語言明確的價值觀是什麼?Go程序員詮釋這個世界的方式如何定義,其核心信念或者哲學是什麼?如何公佈,如何傳授,如何執行,如何隨著時間變化?

作為新上手Go語言的程序員,你如何灌輸Go的工程價值?或者,如果你是一位經驗豐富的Go語言專家,又該如何將自己的價值觀傳給子孫後代?正因如此,我們很清楚,知識轉移的過程並不是可選的,沒有血液和新的觀念,我們的社區將變得短視。

其他語言的價值觀

為了設定場景來了解這一點,我們可以看一下其他語言,瞭解一下它們的工程價值。

例如:C++(以及其替代方案Rust)都認為,程序員不必為自己不使用的功能付費。假如某個程序不使用該語言某些需要大量計算的功能,就不應強迫該程序承擔該功能的成本。該值從語言擴展到其標準庫,並用來作為標準,判斷C++編寫的所有代碼的設計。

在Java、Ruby和Smalltalk中,一切都是對象的核心價值推動著消息傳遞、信息隱藏及多態性等程序設計過程。大眾認為:將程序風格甚至功能風格這些東西,一併塞入這些語言的設計是錯誤的,或者就如Go學家所言,這些是非習語。

回到我們自己的社區,Go程序員所綁定的工程價值是什麼?我們社區中的討論通常很不可靠,因此從最初的原則衍生出一整套價值觀會是個巨大的挑戰。共識非常關鍵,但隨著討論貢獻者人數增長,難度也呈指數增長。但如果有人替我們完成了艱難的工作呢?

幾十年前,Tim Peters寫下Python之禪:PEP-20。Peter試圖記錄他所目睹的Guido van Rossum作為Python的BDFL所實現的工程價值。

本文將繼續著眼於Python之禪,並提出疑問:是否有什麼東西可以描述Go程序員的工程價值?

Go 之禅

好的程序包始於好名字

“命名空間是個絕妙的主意——我們應當好好利用它!” —— Python之禪,第19條

這是相當明確的,Python程序員應當使用命名空間,大量使用。

在Go的說法中,命名空間是一個程序包。我懷疑將組件分到程序包中是否有利於設計和潛在複用。但是,關於正確的方法還可能有問題,尤其將要使用另一種語言長達數十年之久。

在Go語言中,每個程序包都應當有其目標用途,而瞭解其用途的最佳方式莫過於通過名稱這個名詞。一個程序包的名稱描述了其提供的內容,因此要重新詮釋Peter這段話,我們可以說:每個Go程序包都應當有單獨的目的。

這不是新主意,我已經說了有一陣子,但為什麼應當這樣做,而不是將程序包用於細分類法呢?原因在於改變。

“設計是安排代碼到工作的藝術,並且永遠可變。”——Sandi Metz

改變正是我們身處這盤棋的名字,作為程序員,我們要做的事情就是管理變更。做得好的時候,稱之為設計或者架構,而做得差了,就稱之為技術負債或者遺留代碼。

如果你所編寫的代碼只執行一次,對於一組固定輸入的內容可以完美運行的話,不會有人在意代碼優劣,畢竟程序的最終輸出結果才是企業所關心的。

但這種事永遠不會發生。軟件存在Bug、需求變更、輸入變更,而很少有程序只是為單次運行而編寫,因此程序總會隨著時間而變更。或許是你,或許是其他人,總有人要負責更改和維護代碼。

那麼如何讓程序變更更容易些呢?到處放置接口?到處使用模擬?還是使用有害的依賴注入?也許對於某些類別的程序,這些做法是有用的,但這樣的程序不算多。對大多程序而言,預先設計一些靈活的方法比工程設計更為重要。

相反,如果我們預設要替換組件,而不是增強組件。則,要了解何時需要替換什麼,最好的辦法就是知道何時該組件沒有完成預設功能。

一個優秀的程序包始於選擇一個好名字,將程序包的名稱想象成電梯推介,單用一個詞就能描述其提供的內容。當名稱與需求不再相符時,需要找個替代名稱。

簡單性非常重要

“簡單優於複雜。” —— Python之禪,第三條

PEP-20中提到,簡單優於複雜,我完全同意。數年前,我就發了這條推:

大多編程語言一開始都想要簡單,但最終屈從於強大。—— 2014年12月2日

至少在當時,在我的觀察中,從未見過一種語言不是旨在簡潔的。每種新語言都為其固有的簡單性提供了理由和誘因。但在我的研究中,我發現對於Go語言同時代的多種語言來說,簡單並非其核心價值。也許這只是奚落,但是否這些語言要麼不簡單,要麼並未考慮過這種可能性——它們並未考慮過將簡單作為其核心價值。

就當我老派吧,但何時簡單過時了?為什麼商用軟件的開發行業會不斷愉快地忘掉這個基礎事實。

“構建軟件設計的方式有兩種:一種是簡單化,使得缺陷歸零;另一種是複雜化,以至於看不出明顯的缺陷。第一種方法要困難得多。—— C. A. R. Hoare,《皇帝的舊衣》,在1980年的圖靈獎演講上。

簡單並不意味著容易,我們都知道。通常比起易於構建,還需要做更多事情才能使其易於使用。

“簡單性是可靠性的前提。” —— Edsger W Dijkstra,EWD 498,1975年6月18日

為什麼要追求簡單性?為什麼令Go程序簡單非常重要?簡單並不意味著粗糙,而是可讀性和可維護性。簡單並不意味著單純,而是可靠、相關且易於理解。

“控制複雜性是計算機編程的本質。” —— Brian W. Kernighan,軟件工具(1976)

Python是否遵守其簡單性準則尚有爭議,但Go語言則堅持將簡單性作為其核心價值。我認為大家都同意這一點:就Go語言而言,簡單的代碼比聰明的代碼更合理。

Go 之禅

避免程序包的級別狀態

“明瞭優於隱晦。” —— Python之禪,第二條

我認為這一條更像是Peter的希冀而非現實,Python中的很多內容並不明瞭,包括裝飾器和特殊方法等等。毫無疑問,這些功能很強大,它們有存在的理由。每個功能都是有人足夠在意,並努力實現的,尤其是複雜的功能。但大量使用這些功能會讓閱讀者難以預測其實現成本。

好消息是:作為Go程序員,我們是有選擇的,可以選擇將代碼寫得明瞭。明瞭可能意味著許多,也許你會認為,明瞭只是一種官僚和冗長的優雅表達方式,但這只是膚淺的解釋。只關注頁面語法,苦惱每行長度和殫精竭慮思考表達是錯誤的。在我看來,更有價值的地方在於:明白與耦合及狀態相關。

耦合是衡量某種東西依賴其他東西的程度。如果兩者緊密耦合,就會一同運動。影響前者的某個動作會直接反映在後者身上。想象一列火車,每節車廂相連——即緊密耦合:引擎所去之處,車廂隨後跟著。

另一種描述耦合的方法是單詞“聚合”。聚合力衡量著兩者自然結合的程度。我們討論有粘性的爭論,或者有聚合力的團隊時,指的是它們所有部件都像設計的一樣天然聚合在一起。

為什麼耦合很重要?就像火車一樣,當你需要更改某段代碼時,與之緊密相關的所有代碼都必須更改。一個很好的例子就是,某人發佈了他們API的新版,現在你所有代碼都不適配了。

API是不可避免的耦合源,但有更多隱蔽的耦合形式。大家都知道,如果API的簽名發生更改,則相關調用的傳入和傳出數據也會發生更改。就在功能的簽名中:我獲取這些類的值,並返回其他類的值。但如果API以其他方式傳輸數據會怎樣?如果每次你調用API時,結果都是基於上次調用該API的結果,即便參數沒有更改,又會怎樣?

這就是狀態,狀態管理是計算機科學的問題。

package counter
var count int
func Increment(n int) int {
count += n
return count

假設我們有個簡單的`counter`程序包,可以調用`Increment`來增加計數,甚至在Increment值為零的情況下,返回初始值。

假設必須測試代碼,如何在每次測試後重置計數器?假設想要並行測試,能否做到?現在假設想要每次計算不止一個內容,可以做到嗎?

不行,答案是將`count`變量封裝為一種類型。

package counter

type Counter struct {
count int
}

func (c *Counter) Increment(n int) int {
c.count += n
return c.count

現在想象下,這個問題不限於計數器,還包括應用的主要業務邏輯。能否單獨測試?能否並行測試?能否一次使用多個實例?如果答案還是否,原因在於程序包的級別狀態。

避免程序包的級別狀態。通過提供一種類所需要的依賴項,作為該類的字段,而不是用程序包變量,這樣可以減少耦合和怪異操作。

為故障做計劃

“錯誤不應悄悄忽略。” —— Python之禪,第10條

有人說,支持異常處理的語言遵循武士道準則,即成功返回或根本不返回。在基於異常的語言中,函數僅返回有效結果。如果未能成功,則控制流程將會採取完全不同的途徑。

未經檢查的異常很明顯屬於不安全的編程模型,在不知道哪些語句可能拋出異常的情況下,如何在存在錯誤的情況下編寫出健壯的代碼呢?Java試圖通過引入“經過檢查的異常”這樣的概念,降低異常的不安全程度,就我所知,這種做法在其他主流語言中並未重現。有許多語言都在使用異常的概念,但除了Java這個個例之外,全都使用未經檢查的異常。

Go語言選擇了不一樣的方式。Go編程者認為,健壯的程序是由處理故障的案例構成。在Go語言相關的設計空間中,服務器程序、多線程程序、網絡輸入處理、意外數據處理、超時、連接失敗及數據損壞,對於想要創建健壯程序的程序員來說,這些是他們的首要也是核心任務。

“我認為,錯誤處理應當是明確的,是語言的核心價值。” —— Peter Bourgon, GoTime #91

我想贊同Peter的說法,Go語言的成功在很大程度上要歸功於處理錯誤的明確方式。Go程序員認為,要先考慮故障案例。我們首先解決“如果……會怎樣”的案例。這樣我們會在編寫代碼時優先處理故障,而不是在生產環境中處理故障。

if err != nil {
return err

與代碼的冗長相比,在故障出現時,刻意處理每個故障情況的價值更高。關鍵在於明確處理各個錯誤的文化價值。

儘早返回,避免深層嵌套

“扁平優於嵌套。” —— Python之禪,第五條

這是個明智的建議,來自以縮進為主要控制流形式的某個語言。我們如何針對Go語言來解釋該建議?`gofmt`控制著Go程序的整體空白,因此無需執行任何操作。

我曾提過程序包名稱,這裡有些建議可以避免複雜的程序包層次。根據我的經驗,程序員越是嘗試細分和分類其Go代碼庫,越有可能陷入程序包輸入循環的死角。

Python之禪第五條建議的最佳應用就是關於功能裡的控制流。簡而言之,就是避免需要深層縮進的控制流。

“視線是順著觀察者暢通視角的一條直線。” —— May Ryer, Code: Align the happy path to the left edge

May Ryer將這個想法描述為視線編程,即:

  • 如果不滿足前提條件,則通過保護語句儘早返回。

  • 將成功的返回語句放在函數的末尾,而非放在條件塊中。

  • 通過提取函數及方法,降低其整體縮進級別。

該建議的關鍵在於你所關心的內容,即函數完成的功能永遠不會超出屏幕,承擔滑出你視線的風險。這種風格還有一個附加作用,就是你的團隊無需對代碼行長產生無意義的爭論了。

每次縮進時,都會向程序員堆棧添加另一個先決條件,從而消耗其7 ±2個短期記憶插槽之一。避免深層嵌套,將函數保持在屏幕左端的位置就是成功的辦法。

如果覺得慢,請使用基準測試來驗證

“面對不確定性,拒絕妄加猜測。” —— Python之禪,第12條

編程是基於數學和邏輯的,這兩個概念極少涉及“機會”這個要素。但作為程序員,我們每天都會有很多猜測。這個變量是做什麼的,這個參數是做什麼的,如果我在這裡傳回nil會怎樣,如果我調用兩次Register會如何?事實上,現代編程中存在很多猜測,尤其是使用未編寫過的庫時。

“API應當易於使用,並且不易於被濫用。” —— Josh Bloch

根據我的瞭解,幫助程序員避免猜測的最佳方式之一就是,在構建API時專注於默認用例。使調用者儘可能輕鬆地執行最常用的案例。不過,我曾寫過也談起過許多API設計的內容,因此我對於第12條的解釋便是:不要猜測性能。

無論對Knuth的建議你有什麼看法,但Go語言成功的驅動力之一便是其高效的執行力。你可以用Go語言編寫高效程序,人們也因此選擇Go。關於性能誤解很多,我的請求是:當你尋求代碼性能優化時,或者面臨一些教條式的建議——比如defer很慢、CGO很貴、用原子別用互斥鎖時,不要猜。

不要因為過時的教條讓你的代碼複雜化,而且如果你覺得慢,請先用基準測試驗證。Go語言提供了出色的基準測試和性能分析工具,都可以免費獲得,用它們來確認自己的瓶頸。

在運行goroutine之前,瞭解停止機制

談到這,我認為已經挖掘出了PEP-20中珍貴的點,而且很可能將其解釋擴展至其上。這很好,因為儘管這也是一種有用的修辭手法,但最終我們討論的還是兩種不同的語言。

“你輸入g o,中間一個空格,然後一個函數調用,三次點擊,不能更短了,三擊就可以開啟一個子過程。” —— Rob Pike, Simplicity is Complicated, dotGo 2015

接下來的兩個建議,我將專門針對goroutines,Goroutines是語言的簽名功能,也是我們對於一流併發性的答案。它們很易於使用,只需在語句前加一個單詞,即可異步啟動該功能。非常簡單,無需線程,沒有堆棧大小,沒有線程池執行程序,沒有ID和完成狀態追蹤。

Goroutine成本很低,由於運行時可以將其複用在一個小的線程池中(無需管理),因此可以輕鬆容納數十萬乃至數百萬個goroutine,這就開啟了在競爭性併發模型(如線程或事件回調)下並不可行的設計。

但儘管如此,goroutine並非零成本,在啟動10^6個goroutine時,至少會有幾千字節的堆棧開始累加。這並不代表不應使用數百萬個goroutine,只要設計有需求還是應當這樣做的,但進行追蹤非常關鍵,因為10^6的數量級,無論是什麼都會消耗巨量的資源。

Goroutine是Go語言中資源所有權的關鍵,要想有用,Goroutine必須做些事情,這意味著它幾乎持續引用資源或者佔有其所有權,包括鎖和網絡連接、帶有數據的緩衝區、通道發送端。在goroutine處於活動狀態時,鎖是閉合的,網絡連接保持打開,緩衝區保留,且通道的接收端也會持續等待更多數據。

釋放這些資源的最簡途徑便是將其與goroutine的生存週期關聯。goroutine退出時,釋放資源。因此,在編寫那三個字母(g o和空格)前,開啟goroutine幾乎微不足道,但要確保能夠回答以下問題:

  • 何種情況下goroutine會停止?Go語言中不存在一種方式告知gorouinte退出,即不存在stop或kill功能,這是有理由的。如果我們無法命令goroutine停止,則需換個方式,禮貌地要求它。最終方式幾乎總是歸結於通道操作,當通道關閉時,通道中的範圍循環會退出。一個通道會在關閉時成為可選項。從一個goroutine到另一個的信號,最佳表達方式就是關閉通道。

  • 發生這種情況需要什麼條件?如果通道既是goroutine之間的通訊途徑,又是其完成的信號機制,程序員面臨的下一個問題就是,誰來關閉通道,何時關閉?

  • 使用什麼信號來獲知goroutine已經停止?當發出停止goroutine的信號時,相對於goroutine的引用框架來說,停止會在未來某個時刻發生。就人類的感知而言,可能很快,但計算機每秒執行數十億條指令,並且從各個goroutine的角度來看,其指令執行是不同步的。解決方案通常是通道返回信號或者需要fan in的等待組。


將併發留給調用者

在編寫任何嚴肅的Go程序時都很可能要涉及併發問題。這就觸發了一個問題,我們編寫的許多庫和代碼都屬於每個連接或worker模式使用單個goroutine的形式。你會如何管理這些goroutine的生命週期?

`net/http`是一個很好的例子,關閉擁有監聽socket的服務器相對來講是直接了當的,但這個接受socket所產生的goroutine又當如何呢?`net/http`確實在請求對象中提供了`context`對象,可用於向正在監聽的代碼發送信號,告知取消請求,從而停止goroutine,但關於這些工作何時完畢還不太清楚。調用`context.Cancel`是一回事,瞭解取消已經完成是另一回事。

關於`net/http`,我要說的一點是,它是良好實踐的反例。由於每個連接都是由`net/http.Server`類型內部所產生的goroutine處理的,駐留在`net/http`程序包之外的程序就無法控制接收socket所產生的goroutine了。

這是一個還在發展的設計領域,需要類似go-kit的`run.Group`,以及Go團隊的 `ErrGroup`提供的執行、取消、等待等功能異步運行。

更大的設計準則是針對庫編寫者的,或是任何編寫異步運行代碼者,將開啟goroutine的責任留給你的調用者吧,讓他們自行選擇想要如何開啟、追蹤、等待函數執行。

Go 之禅

編寫測試鎖定程序包API的行為

測試是關於你的軟件要做什麼、不做什麼的合約,程序包級別的單元測試應當鎖定程序包API的行為,它們在代碼中描述了程序包承諾要完成的功能,如果每個輸入的排列都有一個單元測試,你會在代碼中而,不是文檔中定義代碼要完成的功能。

簡單輸入`go test`就可以聲明此合約,任何階段你都能高度確定人們在變更前所依賴的行為,在變更後依然有效。

測試會鎖定API行為,任何添加、修改或移除公共API的更改都必須包括對其測試的更改。

Go 之禅

適度是一種美德

Go語言是一種簡單的語言,只有25個關鍵字。在某些方面,會使得該語言的內置功能脫穎而出。同樣,這些也是該語言的賣點:輕量級併發以及結構化類型。

我認為,我們大家都經歷過立即上手嘗試所有Go語言的功能而帶來的困惑。誰會對通道如此熱衷,以至於要儘可能使用它們?個人而言,我發現結果難以測試,脆弱且最終過於複雜,難道只有我這麼想嗎?

我在goroutine上也有過類似經歷,在試圖將工作分解成很小的單元時,我創建了一堆難以管理的goroutine,並且最終發現它們大多總是block的,等著依次處理——代碼最終是順序的。我給代碼增加了很多複雜性,卻幾乎沒有給實際工作帶來任何好處。有人有類似經歷嗎?

在嵌入方面我也有類似經驗,最初我誤將其理解為繼承,之後通過將已經承擔多個職責的複雜類型組合成更復雜的巨大類型,來重建脆弱的基類問題。

這可能是最不可行的建議,但我認為這很重要。建議總是一樣,一切都要適度,Go的功能也不例外。如果可以,不要追求goroutine或者通道,或者嵌套結構、匿名函數,或者過渡程序包,給所有安上接口等,相反簡單的方法比聰明的方法更有效。

Go 之禅

可維護性很重要

我想談談PEP-20的這條:

“可讀性很重要。” —— Python之禪,第七條

關於可讀性的重要性不僅在Go語言中,而是在所有編程語言中都有很多討論。像我這樣站在臺上倡導Go語言都會使用簡單、可讀性、清晰度和生產力等詞,但最終它們一個意思——“可維護性”。

我們真正的目標是編寫可維護的代碼,即在最初作者完成之後還能持續的代碼。代碼不止在投入的節點存在,而是作為未來價值的基礎。並不是說可讀性不重要,而是說可維護性更重要。

Go語言不是為聰明人優化的,也不是為程序行數最少而優化的。我們沒有針對磁盤上的源代碼進行優化,也沒有針對程序鍵入編輯器的時間優化。相對,我們優化的方向是讓代碼可讀性增強,因為代碼閱讀者才是維護代碼的人。

如果是給自己編寫程序,也許只用運行一次,或者你是程序的唯一讀者,也是程序唯一服務的人。但如果該軟件是超過一個人貢獻的程序,或者人們要持續使用很久的程序,則其需求、功能或者運行環境都可能發生變化,則你的目標必須是程序具有可維護性。如果無法維護,只能重寫,那麼這可能是你的公司最後一次將資源投給Go語言。

離開公司後,你之前努力構建的工作可以維護嗎?如何做才能讓其他人之後更容易維護你所編寫的代碼?

原文鏈接:https://dave.cheney.net/2020/02/23/the-zen-of-go

本文整理自筆者的GopherCon Israel 2020演講。

本文為 CSDN 翻譯,轉載請註明來源出處。

Go 之禅Go 之禅

☞數字化轉型太太太難?AI、IoT重拳出擊!

☞堪稱奇蹟!8天誕生一個產品,這家創業公司做到了

☞快速搭建對話機器人,就用這一招!

☞“抗疫”新戰術:世衛組織聯合IBM、甲骨文、微軟構建了一個開放數據的區塊鏈項目!

☞詳Kubernetes在邊緣計算領域的發展

☞原來疫情發生後,全球加密社區為了抗擊冠狀病毒做了這麼多事情!

☞據說,這是當代極客們的【技術風向標】...


分享到:


相關文章: