Go 語言實戰: 編寫可維護 Go 語言程式碼建議
目錄
-
1. 指導原則
- 1.1 簡單性
- 1.2 可讀性
- 1.3 生產力
-
2. 識別符號
- 2.1 選擇標識是為了清晰, 而不是簡潔
- 2.2 識別符號長度
- 2.3 不要用變數型別命名變數
- 2.4 使用一致的命名風格
- 2.5 使用一致的宣告樣式
- 2.6 成為團隊的合作者
-
3. 註釋
- 3.1 關於變數和常量的註釋應描述其內容而非其目的
- 3.2 公共符號始終要註釋
-
4. 包的設計
- 4.1 一個好的包從它的名字開始
- 4.2 避免使用類似
base
、common
或util
的包名稱 - 4.3 儘早
return
而不是深度巢狀 - 4.4 讓零值更有用
- 4.5 避免包級別狀態
-
5. 專案結構
- 5.1 考慮更少,更大的包
- 5.2 保持
main
包內容盡可能的少
-
6. API 設計
- 6.1 設計難以被誤用的 API
- 6.2 為其預設用例設計 API
- 6.3 讓函式定義它們所需的行為
-
7. 錯誤處理
- 7.1 透過消除錯誤來消除錯誤處理
- 7.2 錯誤只處理一次
- 8. 併發
- 8.1 保持自己忙碌或做自己的工作
- 8.2 將併發性留給呼叫者
- 8.3 永遠不要啟動一個停止不了的
goroutine
介紹
大家好,
我在接下來的兩個會議中的標的是向大家提供有關編寫 Go 程式碼最佳實踐的建議。
這是一個研討會形式的演講,不會有幻燈片, 而是直接從檔案開始。
貼士: 在這裡有最新的文章連結
https://dave.cheney.net/practical-go/presentations/qcon-china.html
編者的話
- 終於翻譯完了 Dave 大神的這一篇《Go 語言最佳實踐》
- 耗時兩周的空閑時間
- 翻譯的同時也對 Go 語言的開發與實踐有了更深層次的瞭解
- 有興趣的同學可以翻閱 Dave 的另一篇博文《SOLID Go 語言設計》(第六章節也會提到)
正文
1. 指導原則
如果我要談論任何程式語言的最佳實踐,我需要一些方法來定義 “什麼是最佳”。 如果你昨天來到我的主題演講,你會看到 Go 團隊負責人 Russ Cox 的這句話:
Software engineering is what happens to programming when you add time and other programmers. (軟體工程就是你和其他程式員花費時間在程式設計上所發生的事情。)
— Russ Cox
Russ 作出了軟體程式設計與軟體工程的區分。 前者是你自己寫的一個程式。 後者是很多人會隨著時間的推移而開發的產品。 工程師們來來去去,團隊會隨著時間增長與縮小,需求會發生變化,功能會被新增,錯誤也會得到修複。 這是軟體工程的本質。
我可能是這個房間裡 Go 最早的使用者之一,~ 但要爭辯說我的資歷給我的看法更多是假的~。 相反,今天我要提的建議是基於我認為的 Go 語言本身的指導原則:
- 簡單性
- 可讀性
- 生產力
註意:
你會註意到我沒有說效能或併發。 有些語言比 Go 語言快一點,但它們肯定不像 Go 語言那麼簡單。 有些語言使併發成為他們的最高標的,但它們並不具有可讀性及生產力。
效能和併發是重要的屬性,但不如簡單性,可讀性和生產力那麼重要。
1.1. 簡單性
我們為什麼要追求簡單? 為什麼 Go 語言程式的簡單性很重要?
我們都曾遇到過這樣的情況: “我不懂這段程式碼”,不是嗎? 我們都做過這樣的專案: 你害怕做出改變,因為你擔心它會破壞程式的另一部分; 你不理解的部分,不知道如何修複。
這就是複雜性。 複雜性把可靠的軟體中變成不可靠。 複雜性是殺死軟體專案的罪魁禍首。
簡單性是 Go 語言的最高標的。 無論我們編寫什麼程式,我們都應該同意這一點: 它們很簡單。
1.2. 可讀性
Readability is essential for maintainability.
(可讀性對於可維護性是至關重要的。)
— Mark Reinhold (2018 JVM 語言高層會議)
為什麼 Go 語言的程式碼可讀性是很重要的?我們為什麼要爭取可讀性?
Programs must be written for people to read, and only incidentally for machines to execute. (程式應該被寫來讓人們閱讀,只是順便為了機器執行。)
— Hal Abelson 與 Gerald Sussman (計算機程式的結構與解釋)
可讀性很重要,因為所有軟體不僅僅是 Go 語言程式,都是由人類編寫的,供他人閱讀。執行軟體的計算機則是次要的。
程式碼的讀取次數比寫入次數多。一段程式碼在其生命週期內會被讀取數百次,甚至數千次。
The most important skill for a programmer is the ability to effectively communicate ideas. (程式員最重要的技能是有效溝通想法的能力。)
— Gastón Jorquera [1]
可讀性是能夠理解程式正在做什麼的關鍵。如果你無法理解程式正在做什麼,那你希望如何維護它?如果軟體無法維護,那麼它將被重寫; 最後這可能是你的公司最後一次投資 Go 語言。
~ 如果你正在為自己編寫一個程式,也許它只需要執行一次,或者你是唯一一個曾經看過它的人,然後做任何對你有用的事。~ 但是,如果是一個不止一個人會貢獻編寫的軟體,或者在很長一段時間內需求、功能或者環境會改變,那麼你的標的必須是你的程式可被維護。
編寫可維護程式碼的第一步是確保程式碼可讀。
1.3. 生產力
Design is the art of arranging code to work today, and be changeable forever. (設計是安排程式碼到工作的藝術,並且永遠可變。)
— Sandi Metz
我要強調的最後一個基本原則是生產力。開發人員的工作效率是一個龐大的主題,但歸結為此; 你花多少時間做有用的工作,而不是等待你的工具或迷失在一個外國的程式碼庫裡。Go 程式員應該覺得他們可以透過 Go 語言完成很多工作。
有人開玩笑說,Go 語言是在等待 C ++ 語言程式編譯時設計的。快速編譯是 Go 語言的一個關鍵特性,也是吸引新開發人員的關鍵工具。雖然編譯速度仍然是一個持久的戰場,但可以說,在其他語言中需要幾分鐘的編譯,在 Go 語言中只需幾秒鐘。這有助於 Go 語言開發人員感受到與使用動態語言的同行一樣的高效,而且沒有那些語言固有的可靠性問題。
對於開發人員生產力問題更為基礎的是,Go 程式員意識到編寫程式碼是為了閱讀,因此將讀程式碼的行為置於編寫程式碼的行為之上。 Go 語言甚至透過工具和自定義強制執行所有程式碼以特定樣式格式化。這就消除了專案中學習特定格式的摩擦,並幫助發現錯誤,因為它們看起來不正確。
Go 程式員不會花費整天的時間來除錯不可思議的編譯錯誤。他們也不會將浪費時間在複雜的構建指令碼或在生產中部署程式碼。最重要的是,他們不用花費時間來試圖瞭解他們的同事所寫的內容。
當他們說語言必須擴充套件時,Go 團隊會談論生產力。
2. 識別符號
我們要討論的第一個主題是識別符號。 識別符號是一個用來表示名稱的花哨單詞; 變數的名稱,函式的名稱,方法的名稱,型別的名稱,包的名稱等。
Poor naming is symptomatic of poor design. (命名不佳是設計不佳的癥狀。)
— Dave Cheney
鑒於 Go 語言的語法有限,我們為程式選擇的名稱對我們程式的可讀性產生了非常大的影響。 可讀性是良好程式碼的定義質量,因此選擇好名稱對於 Go 程式碼的可讀性至關重要。
2.1. 選擇識別符號是為了清晰,而不是簡潔
Obvious code is important. What you can do in one line you should do in three.
(清晰的程式碼很重要。在一行可以做的你應當分三行做。(if/else 嗎?))
— Ukiah Smith
Go 語言不是為了單行而最佳化的語言。 Go 語言不是為了最少行程式而最佳化的語言。我們沒有最佳化原始碼的大小,也沒有最佳化輸入所需的時間。
Good naming is like a good joke. If you have to explain it, it’s not funny.
(好的命名就像一個好笑話。如果你必須解釋它,那就不好笑了。)
— Dave Cheney
清晰的關鍵是在 Go 語言程式中我們選擇的標識名稱。讓我們談一談所謂好的名字:
-
好的名字很簡潔。 好的名字不一定是最短的名字,但好的名字不會浪費在無關的東西上。好名字具有高的信噪比。
-
好的名字是描述性的。 好的名字會描述變數或常量的應用,而不是它們的內容。好的名字應該描述函式的結果或方法的行為,而不是它們的操作。好的名字應該描述包的目的而非它的內容。描述東西越準確的名字就越好。
-
好的名字應該是可預測的。 你能夠從名字中推斷出使用方式。~ 這是選擇描述性名稱的功能,但它也遵循傳統。~ 這是 Go 程式員在談到習慣用語時所談論的內容。
讓我們深入討論以下這些屬性。
2.2. 識別符號長度
有時候人們批評 Go 語言推薦短變數名的風格。正如 Rob Pike 所說,“Go 程式員想要正確的長度的識別符號”。 [1]
Andrew Gerrand 建議透過對某些事物使用更長的標識,向讀者表明它們具有更高的重要性。
The greater the distance between a name’s declaration and its uses, the longer the name should be. (名字的宣告與其使用之間的距離越大,名字應該越長。)
— Andrew Gerrand [2]
由此我們可以得出一些指導方針:
- 短變數名稱在宣告和上次使用之間的距離很短時效果很好。
- 長變數名稱需要證明自己的合理性; 名稱越長,需要提供的價值越高。冗長的名稱與頁面上的重量相比,訊號量較小。
- 請勿在變數名稱中包含型別名稱。
- 常量應該描述它們持有的值,而不是該如何使用。
- 對於迴圈和分支使用單字母變數,引數和傳回值使用單個字,函式和包級別宣告使用多個單詞
- 方法、介面和包使用單個詞。
- 請記住,包的名稱是呼叫者用來取用名稱的一部分,因此要好好利用這一點。
我們來舉個慄子:
type Person struct {
Name string
Age int
}
// AverageAge returns the average age of people.
func AverageAge(people []Person) int {
if len(people) == 0 {
return 0
}
var count, sum int
for _, p := range people {
sum += p.Age
count += 1
}
return sum / count
}
在此示例中,變數 p
的在第 10
行被宣告並且也只在接下來的一行中被取用。 p
在執行函式期間存在時間很短。如果要瞭解 p
的作用只需閱讀兩行程式碼。
相比之下, people
在函式第 7
行引數中被宣告。 sum
和 count
也是如此,他們用了更長的名字。讀者必須檢視更多的行數來定位它們,因此他們名字更為獨特。
我可以選擇 s
替代 sum
以及 c
(或可能是 n
)替代 count
,但是這樣做會將程式中的所有變數份量降低到同樣的級別。我可以選擇 p
來代替 people
,但是用什麼來呼叫 for ... range
迭代變數。如果用 person
的話看起來很奇怪,因為迴圈迭代變數的生命時間很短,其名字的長度超出了它的值。
貼士:
與使用段落分解檔案的方式一樣用空行來分解函式。 在AverageAge
中,按順序共有三個操作。 第一個是前提條件,檢查people
是否為空,第二個是sum
和count
的累積,最後是平均值的計算。
2.2.1. 背景關係是關鍵
重要的是要意識到關於命名的大多數建議都是需要考慮背景關係的。 我想說這是一個原則,而不是一個規則。
兩個識別符號 i
和 index
之間有什麼區別。 我們不能斷定一個就比另一個好,例如
for index := 0; index < len(s); index++ {
//
}
從根本上說,上面的程式碼更具有可讀性
for i := 0; i < len(s); i++ {
//
}
我認為它不是,因為就此事而論, i
和 index
的範圍很大可能上僅限於 for 迴圈的主體,後者的額外冗長性 (指 index
) 幾乎沒有增加對於程式的理解。
但是,哪些功能更具可讀性?
func (s *SNMP) Fetch(oid []int, index int) (int, error)
或
func (s *SNMP) Fetch(o []int, i int) (int, error)
在此示例中, oid
是 SNMP
物件 ID
的縮寫,因此將其縮短為 o
意味著程式員必須要將檔案中常用符號轉換為程式碼中較短的符號。 類似地將 index
替換成 i
, 模糊了 i
所代表的含義,因為在 SNMP
訊息中,每個 OID
的子值稱為索引。
貼士: 在同一宣告中長和短形式的引數不能混搭。
2.3. 不要用變數型別命名你的變數
你不應該用變數的型別來命名你的變數, 就像您不會將寵物命名為 “狗” 和“貓”。 出於同樣的原因,您也不應在變數名字中包含型別的名字。
變數的名稱應描述其內容,而不是內容的型別。 例如:
var usersMap map[string]*User
這個宣告有什麼好處? 我們可以看到它是一個 map
,它與 *User
型別有關。 但是 usersMap
是一個 map
,而 Go 語言是一種靜態型別的語言,如果沒有定義變數, 不會讓我們意外地使用到它,因此 Map
字尾是多餘的。
接下來, 如果我們像這樣來宣告其他變數:
var (
companiesMap map[string]*Company
productsMap map[string]*Products
)
usersMap
, companiesMap
和 productsMap
三個 map
型別變數,所有對映字串都是不同的型別。 我們知道它們是 map
,我們也知道我們不能使用其中一個來代替另一個 – 如果我們在需要 map[string]*User
的地方嘗試使用 companiesMap
, 編譯器將丟擲錯誤異常。 在這種情況下,很明顯變數中 Map
字尾並沒有提高程式碼的清晰度,它只是增加了要輸入的額外樣板程式碼。
我的建議是避免使用任何類似變數型別的字尾。
貼士:
如果users
的描述性都不夠用,那麼usersMap
也不會。
此建議也適用於函式引數。 例如:
type Config struct {
//
}
func WriteConfig(w io.Writer, config *Config)
命名 *Config
引數 config
是多餘的。 我們知道它是 *Config
型別,就是這樣。
在這種情況下,如果變數的生命週期足夠短,請考慮使用 conf
或 c
。
如果有更多的 *Config
,那麼將它們稱為 original
和 updated
比 conf1
和 conf2
會更具描述性,因為前者不太可能被互相誤解。
貼士:
不要讓包名竊取好的變數名。
匯入識別符號的名稱包括其包名稱。 例如,context
包中的Context
型別將被稱為context.Context
。 這使得無法將context
用作包中的變數或型別。
func WriteLog(context context.Context, message string)
上面的慄子將會編譯出錯。 這就是為什麼
context.Context
型別的通常的本地宣告是ctx
。 例如。
func WriteLog(ctx context.Context, message string)
2.4. 使用一致的命名方式
一個好名字的另一個屬性是它應該是可預測的。 在第一次遇到該名字時讀者就能夠理解名字的使用。 當他們遇到常見的名字時,他們應該能夠認為自從他們上次看到它以來它沒有改變意義。
例如,如果您的程式碼在處理資料庫請確保每次出現引數時,它都具有相同的名稱。 與其使用 d * sql.DB
, dbase * sql.DB
, DB * sql.DB
和 database * sql.DB
的組合,倒不如統一使用:
db *sql.DB
這樣做使讀者更為熟悉; 如果你看到 db
,你知道它就是 *sql.DB
並且它已經在本地宣告或者由呼叫者為你提供。
類似地,對於方法接收器: 在該型別的每個方法上使用相同的接收者名稱。 在這種型別的方法內部可以使讀者更容易使用。
註意:
Go 語言中的短接收者名稱慣例與目前提供的建議不一致。 這隻是早期做出的選擇之一,已經成為首選的風格,就像使用CamelCase
而不是snake_case
一樣。貼士:
Go 語言樣式規定接收器具有單個字母名稱或從其型別派生的首字母縮略詞。 你可能會發現接收器的名稱有時會與方法中引數的名稱衝突。 在這種情況下,請考慮將引數名稱命名稍長,並且不要忘記一致地使用此新引數名稱。
最後,某些單字母變數傳統上與迴圈和計數相關聯。 例如, i
, j
和 k
通常是簡單 for
迴圈的迴圈歸納變數。 n
通常與計數器或累加器相關聯。 v
是通用編碼函式中值的常用簡寫, k
通常用於 map
的鍵, s
通常用作字串型別引數的簡寫。
與上面的 db
示例一樣,程式員認為 i
是一個迴圈歸納變數。 如果確保 i
始終是迴圈變數,而且不在 for
迴圈之外的其他地方中使用。 當讀者遇到一個名為 i
或 j
的變數時,他們知道迴圈就在附近。
貼士:
如果你發現自己有如此多的巢狀迴圈,i
,j
和k
變數都無法滿足時,這個時候可能就是需要將函式分解成更小的函式。
2.5. 使用一致的宣告樣式
Go 至少有六種不同的方式來宣告變數
var x int = 1
var x = 1
var x int; x = 1
var x = int(1)
x := 1
我確信還有更多我沒有想到的。 這可能是 Go 語言的設計師意識到的一個錯誤,但現在改變它為時已晚。 透過所有這些不同的方式來宣告變數,我們如何避免每個 Go 程式員選擇自己的風格?
我想就如何在程式中宣告變數提出建議。 這是我盡可能使用的風格。
- 宣告變數但沒有初始化時,請使用
var
。 當宣告變數稍後將在函式中初始化時,請使用var
關鍵字。
var players int // 0
var things []Thing // an empty slice of Things
var thing Thing // empty Thing struct
json.Unmarshall(reader, &thing)
var
表示此變數已被宣告為指定型別的零值。 這也與使用 var
而不是短宣告語法在包級別宣告變數的要求一致 – 儘管我稍後會說你根本不應該使用包級變數。
- 在宣告和初始化時,使用
:=
。 在同時宣告和初始化變數時,也就是說我們不會將變數初始化為零值,我建議使用短變數宣告。 這使得讀者清楚地知道:=
左側的變數是初始化過的。
為瞭解釋原因,讓我們看看前面的例子,但這次是初始化每個變數:
var players int = 0
var things []Thing = nil
var thing *Thing = new(Thing)
json.Unmarshall(reader, thing)
在第一個和第三個例子中,因為在 Go 語言中沒有從一種型別到另一種型別的自動轉換; 賦值運運算元左側的型別必須與右側的型別相同。 編譯器可以從右側的型別推斷出宣告的變數的型別,上面的例子可以更簡潔地寫為:
var players = 0
var things []Thing = nil
var thing = new(Thing)
json.Unmarshall(reader, thing)
我們將 players
初始化為 0
,但這是多餘的,因為 0
是 players
的零值。 因此,要明確地表示使用零值, 我們將上面例子改寫為:
var players int
第二個宣告如何? 我們不能省略型別而寫作:
var things = nil
因為 nil 沒有型別。 [2] 相反,我們有一個選擇,如果我們要使用切片的零值則寫作:
var things []Thing
或者我們要建立一個有零元素的切片則寫作:
var things = make([]Thing, 0)
如果我們想要後者那麼這不是切片的零值,所以我們應該向讀者說明我們透過使用簡短的宣告形式做出這個選擇:
things := make([]Thing, 0)
這告訴讀者我們已選擇明確初始化事物。
下麵是第三個宣告,
var thing = new(Thing)
既是初始化了變數又引入了一些 Go 程式員不喜歡的 new
關鍵字的罕見用法。 如果我們用推薦地簡短宣告語法,那麼就變成了:
thing := new(Thing)
這清楚地表明 thing
被初始化為 new(Thing)
的結果 – 一個指向 Thing
的指標 – 但依舊我們使用了 new
地罕見用法。 我們可以透過使用緊湊的文字結構初始化形式來解決這個問題,
thing := &Thing{}
與 new(Thing)
相同,這就是為什麼一些 Go 程式員對重覆感到不滿。 然而,這意味著我們使用指向 Thing{}
的指標初始化了 thing
,也就是 Thing
的零值。
相反,我們應該認識到 thing
被宣告為零值,並使用地址運運算元將 thing
的地址傳遞給 json.Unmarshall
var thing Thing
json.Unmarshall(reader, &thing)
貼士:
當然,任何經驗法則,都有例外。 例如,有時兩個變數密切相關,這樣寫會很奇怪:
var min int
max := 1000
如果這樣宣告可能更具可讀性
min, max := 0, 1000
綜上所述:
在沒有初始化的情況下宣告變數時,請使用 var 語法。
宣告並初始化變數時,請使用 :=
。
貼士:
使複雜的宣告顯而易見。
當事情變得複雜時,它看起來就會很複雜。例如
var length uint32 = 0x80
這裡
length
可能要與特定數字型別的庫一起使用,並且length
明確選擇為uint32
型別而不是短宣告形式:
length := uint32(0x80)
在第一個例子中,我故意違反了規則, 使用
var
宣告帶有初始化變數的。 這個決定與我的常用的形式不同,這給讀者一個線索, 告訴他們一些不尋常的事情將會發生。
2.6. 成為團隊合作者
我談到了軟體工程的標的,即編寫可讀及可維護的程式碼。 因此,您可能會將大部分職業生涯用於你不是唯一作者的專案。 我在這種情況下的建議是遵循專案自身風格。
在檔案中間更改樣式是不和諧的。 即使不是你喜歡的方式,對於維護而言一致性比你的個人偏好更有價值。 我的經驗法則是: 如果它透過了 gofmt
, 那麼通常不值得再做程式碼審查。
貼士:
如果要在程式碼庫中進行重新命名,請不要將其混合到另一個更改中。 如果有人使用git bisect
,他們不想透過數千行重新命名來查詢您更改的程式碼。
未完待續,下週三繼續給大家帶來最新的譯文
- 如有翻譯有誤或者不理解的地方,請評論指正
- 待更新的譯註之後會做進一步修改翻譯
- 翻譯:田浩
- 郵箱:llitfkitfk@gmail.com