Go 在 Google:服務(wù)于軟件工程的語言設(shè)計(翻譯)(二)
原文:Go at Google: Language Design in the Service of Software Engineering
地址:https://talks.golang.org/2012/splash.article
作者:Rob Pike
翻譯:Jayce Chant(博客:jaycechant.info,公眾號ID:jayceio)
Rob Pike:Unix 小組成員,參與了 Plan 9 計劃,1992 年和 Ken Thompson 共同開發(fā)了 UTF-8。他和 Ken Thompson 也是 Go 語言最早期的設(shè)計者。

譯文較長,分三篇推送,這里是第 8~13 小節(jié)。
第一篇(1~7 小節(jié)):Go 在 Google:服務(wù)于軟件工程的語言設(shè)計(翻譯)(一)
8. 包
Go 的包系統(tǒng)設(shè)計,將庫、命名空間、模塊的一些特性結(jié)合在一起,變成一個統(tǒng)一的結(jié)構(gòu)。
譯者注:2012 年 Go 1.0 ,包管理使用的還是最簡單的 GOPATH 模式。之后這種基于 Google 單一代碼庫的設(shè)計造成了各種不便,第三方包管理工具百花齊放。2015 年 Go 1.5 引入 Vendor 機制,到后面發(fā)現(xiàn)還是沒有解決問題。第三方工具 dep 一度最有希望轉(zhuǎn)正,結(jié)果 2018 年官方推出 vgo (后改名 Go Modules 并入
go工具鏈)統(tǒng)一了機制,到 2020 年的 1.14 正式宣布 Go Modules "ready for production"。跟 20102 相比,現(xiàn)在 Go 的包管理已經(jīng)有了很多變化,最主要的是引入了 module 的概念。
每一個 Go 源文件,例如 "encoding/json/json.go",都會以一個 package 語句開始,像這樣:
package?json
其中 json 是 『包名』,一個簡單的標(biāo)識符。包名通常是簡明扼要的。
要使用一個包,導(dǎo)入語句里的包路徑標(biāo)識了要導(dǎo)入的文件?!郝窂健坏暮x并未在語言中指定,但在實踐中,按照慣例,它是源包在代碼庫里的目錄路徑,以斜杠 / 分隔,像:
import?"encoding/json"
然后,在導(dǎo)入的源文件(importing,調(diào)用方)里引用時,用包名(區(qū)別于路徑)來修飾(qualify)被導(dǎo)入(imported)包的包中成員:
var?dec?=?json.NewDecoder(reader)
這種設(shè)計清晰明確。Name 對比 pkg.Name ,人們總是可以從語法中判斷出一個名字是否來自本地包。(這一點后面會有更多的介紹。)
在我們的例子中,包的路徑是 "encoding/json",而包名是 json。在標(biāo)準(zhǔn)倉庫之外,慣例是將項目或公司名稱放在命名空間的根部:
import?"google/base/go/log"
重要的是要認(rèn)識到包的路徑是唯一的,但對包名卻沒有這樣的要求。路徑必須唯一地標(biāo)識要導(dǎo)入的包,而包名只是一個約定,讓包的調(diào)用方可以引用它的內(nèi)容。包名不需要是唯一的,可以在每個導(dǎo)入(importing)的源文件里,通過在導(dǎo)入語句中提供一個本地標(biāo)識符來重命名。下面兩個導(dǎo)入都引用了包名為 log 的包,但要在同一個源文件里導(dǎo)入它們,必須(在本地)重命名其中一個包。
import?"log"?//?標(biāo)準(zhǔn)包
import?googlelog?"google/base/go/log"?//?Google專用包
每個公司可能都有自己的 log 包,沒有必要讓包名獨一無二。恰恰相反:Go 的風(fēng)格建議保持包名短小精悍、清晰明確,而不是擔(dān)心重名 。
還有一個例子:在 Google 的代碼庫里,有很多個 server 包。
9. 遠(yuǎn)程包
Go 包系統(tǒng)的一個重要特性是,包的路徑一般可以是任意字符串,可以用它標(biāo)識托管代碼倉庫的站點 URL ,以此來引用遠(yuǎn)程代碼庫。
下面是使用 github 上的 doozer 包的方法。go get 命令使用 go 構(gòu)建工具從站點獲取倉庫并安裝它。一旦安裝完畢,它就可以像其他普通的包一樣被導(dǎo)入和使用。
$?go?get?github.com/4ad/doozer?//?獲取包的?Shell?命令
import?"github.com/4ad/doozer"?//?Doozer?調(diào)用方的?import?語句
var?client?doozer.Conn?????????//?調(diào)用方對包的引用
值得注意的是,go get 命令以遞歸的方式下載依賴,正是因為依賴關(guān)系是顯式的所以才可以這樣實現(xiàn)。另外,區(qū)別于其它語言使用的集中式包注冊,Go 導(dǎo)入路徑的命名空間分配依賴于 URL,這使得包的命名是去中心化的,因而是可擴展的。
10. 語法
語法就是一門編程語言的用戶界面。**盡管語法對語義影響有限,而語義很可能才是語言更重要的組成部分,但語法決定了語言的可讀性,繼而決定了語言的清晰度。**同時,語法對工具鏈而言至關(guān)重要:如果一門語言難以解析,就很難為其編寫自動化工具。
因此,Go 在設(shè)計時就考慮了語言的清晰度和工具鏈,并且擁有簡潔的語法。與 C 家族的其他語言相比,它的語法規(guī)模不大,只有 25 個關(guān)鍵字(C99 有 37 個;C++11 有 84 個;而且這兩個數(shù)字還在繼續(xù)增加)。更重要的是,語法很規(guī)范,所以很容易解析(應(yīng)該說大多數(shù)規(guī)范;也有個別怪異的語法我們本可以改善結(jié)果發(fā)現(xiàn)得太晚)。與 C 和 Java,尤其是 C++ 不同,Go 可以在沒有類型信息或符號表的情況下進行解析;不需要類型相關(guān)的上下文。語法容易推導(dǎo),工具自然就容易編寫。
Go 語法里有一個細(xì)節(jié)會讓 C 程序員感到驚訝,那就是聲明語法更接近 Pascal 而不是 C。聲明的名稱出現(xiàn)在類型之前,并且使用了更多關(guān)鍵字(譯者注:指 var 和 type關(guān)鍵字):
var?fn?func([]int)?int
type?T?struct?{?a,?b?int?}
對比 C 語言
int?(*fn)(int[]);
struct?T?{?int?a,?b;?}
無論對人還是對計算機來說,由關(guān)鍵字引入的聲明都更容易解析,而且使用 類型語法 而不是 C 那樣的 表達(dá)式語法 ,對解析有很大的幫助:它增加了語法,但消除了歧義。你還有另外一個選擇:對于初始化聲明,可以丟棄 var 關(guān)鍵字,直接從表達(dá)式中推斷變量的類型。這兩個聲明是等價的;第二個聲明更短也更地道:
var?buf?*bytes.Buffer?=?bytes.NewBuffer(x)?//?顯式指定類型
buf?:=?bytes.NewBuffer(x)??????????????????//?類型推斷
在 golang.org/s/decl-syntax 有一篇博客文章,詳細(xì)介紹了 Go 的聲明語法,以及為什么它與 C 語言如此不同。
對于簡單的函數(shù)來說,函數(shù)語法是很直接的。這個例子聲明了函數(shù) Abs,它接受一個類型為 T 的變量 x,并返回一個 float64 的值:
func?Abs(x?T)?float64
//?譯者補充調(diào)用示例:
//?假定已經(jīng)初始化了一個變量?t,類型為?T,下同
absT?:=?Abs(t)
方法(method)只是有一個特殊參數(shù)的函數(shù),這個特殊參數(shù)就是它的接收者(receiver),可以通過點號 . 傳遞給函數(shù)。方法聲明的語法將接收者放在函數(shù)名前面的括號里。下面是同一個函數(shù),現(xiàn)在定義成 T 類型的方法:
func?(x?T)?Abs()?float64
//?譯者補充調(diào)用示例:
absT?:=?t.Abs()
而這里是一個函數(shù)變量(閉包),參數(shù)類型為 T;Go 有一等函數(shù)(first-class function)和閉包:
譯者注:一等函數(shù)是指函數(shù)可以作為普通變量,可以作為其他函數(shù)的參數(shù)和返回值;作為對比, Java 只有類是一等公民,其他語言成分必須作為類的成員。
negAbs?:=?func(x?T)?float64?{?return?-Abs(x)?}
//?譯者補充調(diào)用示例:
negT?:=?negAbs(t)
最后,在 Go 里函數(shù)可以返回多個值。常見的做法是將函數(shù)結(jié)果和錯誤值作為一對返回,就像這樣:
func?ReadByte()?(c?byte,?err?error)
c,?err?:=?ReadByte()
if?err?!=?nil?{?...?}
錯誤處理我們后面再聊。
Go 缺少了一個特性,那就是它不支持函數(shù)的默認(rèn)參數(shù)(default function arguments)。這是一個故意的簡化。經(jīng)驗告訴我們,默認(rèn)參數(shù)會讓修復(fù) API 顯得太容易,仿佛只要添加更多參數(shù)就可以彌補設(shè)計上的缺陷,結(jié)果導(dǎo)致添加了過多的參數(shù),參數(shù)之間的關(guān)系變得難以拆分、甚至無法理解。缺少默認(rèn)參數(shù)的情況下,因為一個函數(shù)無法承載整個接口,就需要定義更多的函數(shù)或方法,但這會導(dǎo)致 API 更清晰、更容易理解。這些函數(shù)也都需要單獨命名,這使得有哪些函數(shù)、分別接受哪些參數(shù)一目了然,同時也鼓勵人們對命名進行更多的思考,這是清晰度和可讀性的一個關(guān)鍵方面。
作為缺少默認(rèn)參數(shù)的補償,Go 支持易用的、類型安全的可變參數(shù)函數(shù)(variadic functions)。
11. 命名
Go 采用了一種不同尋常的方法來定義標(biāo)識符的可見性(所謂可見性,是指一個包的調(diào)用方是否可以通過標(biāo)識符使用包內(nèi)的成員)。不同于使用 private 和 public 等關(guān)鍵字,在 Go 里,命名本身就帶有信息:標(biāo)識符首字母的大小寫決定了標(biāo)識符的可見性。如果首字母是大寫字母,標(biāo)識符就會被導(dǎo)出(公共);否則就是私有的:
首字母大寫: Name對包的調(diào)用方可見首字母小寫: name(或_Name)對包的調(diào)用方不可見
這條規(guī)則適用于變量、類型、函數(shù)、方法、常量、字段 ...... 所有一切。這就是全部規(guī)則。
這個設(shè)計不是一個容易做的決定。我們糾結(jié)了一年多的時間,去考慮用什么符號指定標(biāo)識符可見性。而一旦我們決定使用命名的大小寫,我們很快就意識到它已經(jīng)成為了語言里最重要的特性之一。名稱畢竟是給包的調(diào)用方使用的;把可見性放在名稱里而不是類型里,意味著只要看一眼,就能確定一個標(biāo)識符是否公共 API 的一部分 。在使用 Go 一段時間之后,再去看其他語言,還要查找聲明才能發(fā)現(xiàn)這些信息,就會覺得很累贅。
目標(biāo)仍然是清晰度:程序源碼要簡單直接地表達(dá)程序員的意圖。
另一個簡化是,Go 有一個非常緊湊的作用域(scope)層次結(jié)構(gòu):
全局(預(yù)先聲明的標(biāo)識符,像 int和string)包(包的所有源文件都在同一個作用域) 文件(僅用于導(dǎo)入包的重命名,實踐中不是特別重要) 函數(shù)(跟其它語言一樣) 代碼塊(跟其它語言一樣)
沒有什么命名空間(name space)作用域、類(class)作用域或者其它結(jié)構(gòu)的作用域。在 Go 里,名稱只來自很少的地方,而且所有名稱都遵循相同的作用域?qū)哟危涸谠创a的任意位置,一個標(biāo)識符只表示一個語言對象,和它的用法無關(guān)。(唯一的例外是語句標(biāo)簽 (用作 break 等語句的目標(biāo));它們總是具有函數(shù)作用域。)
這使代碼更清晰。例如,請注意到方法聲明了一個顯式的接收者(explicit receiver),訪問該類型的字段和方法必須用到它。沒有隱式的(implicit) this 。也就是說,我們總是寫:
rcvr.Field
(其中 rcvr 是給接收者變量隨便起的名稱)所以在詞法上(lexically),該類型的所有元素,總是綁定到一個接收者類型的值上。類似地,對于導(dǎo)入的名稱,包的限定符總是存在;人們寫的是 io.Reader 而不是 Reader 。這樣不僅清楚,而且釋放了標(biāo)識符 Reader 作為一個有用的名稱,可以在任何包中使用。事實上,在標(biāo)準(zhǔn)庫中有多個導(dǎo)出的標(biāo)識符都叫 Reader,類似的還有很多 Printf,但具體引用了哪一個永遠(yuǎn)不會弄混。
最后,這些規(guī)則結(jié)合在一起,保證除了頂層的預(yù)定義名稱如 int 之外,每個名稱(點號 . 前的第一部分)總是在當(dāng)前包中聲明。
簡而言之,名稱總是本地的(local)。在 C、C++ 或 Java 里,名稱 y 可以指向任何東西。在 Go 里,y (甚至大寫的 Y )總是在包內(nèi)定義,而 x.Y 的解釋很清楚:在本地找到x ,Y 就在里面。
這些規(guī)則為可伸縮性提供了很重要的特性,因為它們保證了在一個包里添加導(dǎo)出的名稱永遠(yuǎn)不會破壞這個包的調(diào)用方。命名規(guī)則解耦了包,提供了可伸縮性、清晰度和健壯性。
關(guān)于命名還有一個方面需要提及:方法查找總是只按名稱,而不是按方法的簽名(類型)。換句話說,一個類型永遠(yuǎn)不可能有兩個同名的方法。給定一個方法 x.M ,永遠(yuǎn)只有一個 M 與 x 關(guān)聯(lián)。同樣,這使得只給定名稱就能很容易地識別引用了哪個方法。這也使得方法調(diào)用的實現(xiàn)變得簡單。
譯者注:換句話說,Go 不支持函數(shù)和方法重載。
Go 的內(nèi)置函數(shù)其實是有重載的。
make和len這些函數(shù),參數(shù)類型不同,具體的行為也不一樣。make甚至還有一個到三個參數(shù)的三個版本。這些函數(shù)根據(jù)參數(shù)不同,在編譯時被替換成了不同的函數(shù)實現(xiàn)。但為了保持代碼清晰,實現(xiàn)簡單和運行高效,Go 不支持用戶代碼的函數(shù)重載。
12. 語義
Go 語句的語義一般跟 C 語言類似。它是一種帶有指針等特性的、編譯型、靜態(tài)類型的過程式語言。設(shè)計上,習(xí)慣 C 族語言的程序員應(yīng)該會感到熟悉。在推出一門新語言時,目標(biāo)受眾能夠快速學(xué)會它是很重要的;將 Go 植根于 C 家族有助于確保年輕程序員能很容易學(xué)會 Go(他們大多數(shù)都知道 Java、JavaScript,也許還有 C)。
盡管如此,Go 對 C 的語義還是做了很多小的改變,主要是出于健壯性的考慮。這些變化包括:
沒有指針運算 沒有隱式數(shù)字轉(zhuǎn)換 總是檢查數(shù)組邊界 沒有類型別名(聲明 type X int之后,X和int是不同的類型,而不是別名)++和--是語句(statements)而不是表達(dá)式(expressions)賦值不是表達(dá)式 對棧上變量取址是合法的(甚至是被鼓勵的) 其它
譯者注:
Go 在 1.9 還是引入了類型別名,語法是
type X = int。用來解決遷移、升級等重構(gòu)場景下,類型重命名的兼容性問題,以及方便引用外部導(dǎo)入的類型。實際上,類型別名僅在代碼中存在,編譯時會全部替換成實際的類型,不會產(chǎn)生新類型。
語句和表達(dá)式的差別是:語句是計算機角度的一個可執(zhí)行動作,不一定有值;表達(dá)式是數(shù)學(xué)角度的可求值算式,一定有值,這個值可以放在賦值符號的右邊,或者成為更大的表達(dá)式的一部分。
不再區(qū)分語句和表達(dá)式,是編程語言演化的其中一個趨勢,這可以增強語言的表達(dá)能力。一般的做法,是增加求值規(guī)則(像語句的值是語句中最后一個表達(dá)式的值),給原本沒有值的語句提供一個值,這樣就可以通過拼接非常復(fù)雜的表達(dá)式,用很少的代碼解決問題。例如,如果賦值語句有值,那么
e = d = c = b = a = 10?就是合法的;因為賦值運算符從右到左結(jié)合,這些賦值最后都會成功,都是 10。但這很容易引起表達(dá)式的 濫用 和 誤用。人們有可能寫出非常難以理解的復(fù)雜表達(dá)式?;蛘咭驗椴皇煜つ承ū緛硎钦Z句的)表達(dá)式的求值規(guī)則而制造難以排查的錯誤。
Go 首先追求代碼的清晰明確,而不是追求單純的表達(dá)能力強或者代碼行數(shù)少,所以反其道而行,反而去掉了某些語句的值。
棧上分配的內(nèi)存會在函數(shù)返回后被回收,對棧上的變量取址并返回,會導(dǎo)致函數(shù)外部引用到已被回收的內(nèi)存。這就是懸掛指針問題,困擾著大多數(shù)有指針的語言。Go 的解決方案是,在編譯期做逃逸分析,識別出可能超出當(dāng)前作用域的指針引用,將對應(yīng)的內(nèi)存分配到堆上。所以在 Go 里面,取址操作不用考慮變量究竟是棧上還是堆上的,編譯器會反過來配合你。當(dāng)然,如果是高頻操作,可能要考慮一下拷貝和 GC 哪個開銷大,傳值(棧上分配,需要拷貝,不需要 GC)還是 傳指針(如果發(fā)生逃逸,堆上分配,不需要拷貝,需要 GC)。
還有一些更大的變化,遠(yuǎn)離了傳統(tǒng)的 C、C++ 甚至 Java 的模式。這些包括在語言級別上支持:
并發(fā) 垃圾回收 接口類型 反射 類型判斷(type switches)
下面的章節(jié)主要從軟件工程的角度簡要討論 Go 中的兩個主題:并發(fā) 和 垃圾回收。關(guān)于語言語義和用途的完整討論,請參見 golang.org 網(wǎng)站上的更多資料。
13. 并發(fā)
web 服務(wù)器運行在多核機器上,并有大量的調(diào)用方,這可以稱之為一個典型的 Google 程序;而并發(fā)對于這種現(xiàn)代計算環(huán)境非常重要。C++ 或 Java 都不是特別適合這類軟件,它們在語言層面上缺乏足夠的并發(fā)支持。
Go 有作為一等公民的通道(channel),實現(xiàn)了 CSP (譯者注:Communicating Sequential Processes,通信順序進程)的一個變種。選擇 CSP 的部分原因是熟悉(我們其中一個人曾經(jīng)研究過某種基于 CSP 思想的前輩語言),同時也是因為 CSP 很容易被添加到過程化編程模型中,而無需對模型進行深入的修改。也就是說,給定一個類似于 C 的語言,CSP 基本就能夠以正交的方式添加到語言中,提供額外的表達(dá)能力,而不限制該語言的其他用途。 總之,語言的其他部分可以保持『普通』。
這個方法就是,將獨立執(zhí)行的函數(shù),與其他普通的過程式代碼結(jié)合。
這樣得到的語言允許我們將 并發(fā) 和 計算 平滑地結(jié)合起來。假設(shè)有一個 web 服務(wù)器,必須驗證每次客戶端調(diào)用的安全證書;在 Go 里面,很容易利用 CSP 來構(gòu)造這樣一個軟件:用獨立的執(zhí)行過程來管理客戶端,同時還能火力全開為昂貴的加密計算提供編譯型語言的高執(zhí)行效率。
綜上所述,CSP 對于 Go 和 Google 來說都很實用。在編寫 web 服務(wù)器這種典型的 Go 程序時,這個模型是再適合不過了。
有一個重要的注意事項:在并發(fā)的情況下,Go 并不是純粹的內(nèi)存安全(purely memory safe)語言。內(nèi)存共享是合法的,在通道上傳遞指針也是符合慣例的(同時也是高效的)。
一些 并發(fā) 和 函數(shù)式編程 的專家對于 Go 在并發(fā)計算的上下文沒有采用『只寫一次(write-once)』來處理值語義感到失望,看起來沒有其它并發(fā)語言(如 Erlang)那么像回事。同樣地,原因主要還是在于對問題領(lǐng)域的熟悉度和適用性。Go 的并發(fā)特性在大多數(shù)程序員熟悉的上下文中都能很好地發(fā)揮作用。Go 可以實現(xiàn)簡單、安全的并發(fā)編程,但并不禁止不良的編程方式。 我們提供約定俗成的做法作為彌補,訓(xùn)練程序員將消息傳遞視為所有權(quán)控制的一種實現(xiàn)方式。我們的座右銘是:『不要通過共享內(nèi)存來通信,要通過通信來共享內(nèi)存』。
譯者注:『只寫一次(write-once)』變量,在某些語言的實現(xiàn)里又叫『單次賦值(single-assignment)』變量(Erlang),或者『不可變(immutable)』變量(函數(shù)式編程)。換言之,這種變量只能在初始化時賦值(寫入)一次,之后不能再修改;如果需要新的值,只能創(chuàng)建新的變量。這樣可以避免在并發(fā)上下文意外修改了變量的值。
雖然都不能修改,但還是要區(qū)分它和常量的區(qū)別。常量是在編譯期就已經(jīng)存在并確定了值;而不可變變量雖然賦值后不可修改,但其創(chuàng)建 / 賦值的時機和具體的值還是在運行時決定的。
這其實是來自函數(shù)式編程『無副作用(side effect)』和『不修改狀態(tài)(state)』的概念,雖然可以保證程序的正確性,卻跟 C 家族的過程式編程模型差異很大,照搬過來需要對這個模型進行比較大的改動,這就違背 Go 的設(shè)計初衷了。
從我們對 Go 和 并發(fā)編程 的新手程序員的有限了解來看,這是一種實用的做法。程序員享受著并發(fā)支持給網(wǎng)絡(luò)軟件帶來的簡單性,而簡單性產(chǎn)生了健壯性。
譯者在網(wǎng)上看到一種說法:『Java 里多種同步方法、各種 Lock、并發(fā)調(diào)度等一系列復(fù)雜的功能在 Golang 里 都不存在,只靠 goroutine 和 channel 去處理并發(fā)?!?,這種說法是錯的。
如上面所說,CSP 模型是以基本正交的方式添加到 C 家族的過程式編程模型里的,增加了新的、簡潔的表達(dá)方式,但并沒有限制原本的做法。
Go 常用的并發(fā)控制的工具,除了內(nèi)置的消息通道
chan(CSP 模型),還有:
sync包提供的同步原語(其中包括互斥鎖和讀寫互斥鎖sync.Mutex和sync.RWMutex,還有其它三個原語sync.WaitGroup,sync.Once和sync.Cond。實際上你去看chan的源碼,也是基于 runtime 內(nèi)部的mutex實現(xiàn)的);上下文 context.Context其它擴展包中提供的工具 可以看到,在 C 家族里常見的并發(fā)控制方式,基本都有提供,只是不再像 Java 那樣以關(guān)鍵字的方式,而是以內(nèi)置包的方式提供。
Go 把 CSP 模型實現(xiàn)并把支持上升到內(nèi)置類型和關(guān)鍵字的層面,卻并沒有強迫程序員必須使用這個模型。
推薦閱讀

