Go 部落格
實驗、簡化、提交
簡介
這是 2019 年 GopherCon 大會上我演講的部落格文章版本。
我們正攜手共進,邁向 Go 2 的道路,但我們都不知道這條道路的終點是什麼,甚至有時不知道該往哪個方向走。這篇文章會探討我們實際上是如何發現 Go 2 的道路並走下去的。這便是整個過程的樣子。

我們使用現有的 Go 來進行實驗,以深入了解它,瞭解哪些地方運作良好,哪些地方不理想。然後我們嘗試可能的變更,以深入瞭解它,再次瞭解哪些地方運作良好,哪些地方不理想。根據從這些實驗中學到的經驗,我們進行簡化。然後我們再次進行實驗。然後我們再次簡化。以此類推。以此類推。
簡化的四個 R
在此過程中,有四種主要方式可以簡化撰寫 Go 程式的整體體驗:變形、重新定義、移除和限制。
透過變形簡化
我們簡化的第一種方法是將現有的內容重新整構成新的形式,以便整體上最後變得更簡單。
我們撰寫的每個 Go 程式都作為測試 Go 本身的實驗。在 Go 的早期階段,我們快速地了解到撰寫如這個 addToList
函數的程式碼是常見的
func addToList(list []int, x int) []int {
n := len(list)
if n+1 > cap(list) {
big := make([]int, n, (n+5)*2)
copy(big, list)
list = big
}
list = list[:n+1]
list[n] = x
return list
}
我們會為位元組切片與字串切片等等撰寫相同的程式碼。我們的程式太複雜,因為 Go 太簡單。
於是我們在我們的程式中擷取許多像是 addToList
的函數,並將它們重新整合成由 Go 本身提供的其中一個函數。加入 append
讓 Go 語言稍微變得更複雜,但總體來說,在考慮學習 append
的成本後,它讓撰寫 Go 程式的整體體驗變得更簡單。
這是一個其他範例。針對 Go 1,我們查看了 Go 發行版裡的許多開發工具,並將它們重新整合成一個新的命令。
5a 8g
5g 8l
5l cgo
6a gobuild
6cov gofix → go
6g goinstall
6l gomake
6nm gopack
8a govet
現在 go
命令如此重要,我們很容易忘了我們曾經這麼長時間沒有它,以及這樣包含了多少額外的作業。
我們加入了程式碼和複雜度到 Go 發行版中,但總體上我們簡化了撰寫 Go 程式的體驗。新的結構也為其他的有趣實驗創造了空間,我們稍後會看到。
透過重新定義來簡化
我們簡化的第二種方法是重新定義我們已經擁有的功能,讓它能做更多。像透過重新整組來簡化一樣,透過重新定義來簡化能讓程式更簡單,但現在無需學習任何新東西。
例如,append
原本被定義為只從切片中讀取。在將內容附加到位元組切片時,你可以將位元組從另一個位元組切片附加,但無法將位元組從字串附加。我們重新定義了 append 以允許從字串附加,卻沒有加入任何新內容到語言中。
var b []byte
var more []byte
b = append(b, more...) // ok
var b []byte
var more string
b = append(b, more...) // ok later
透過移除來簡化
我們簡化的第三種方法是移除功能,當它變得比我們預期中不那麼有用或不那麼重要時。移除功能表示要學習的東西少了一樣、要修復的錯誤少了一樣、被分散注意力或錯誤使用的事情少了一樣。當然,移除也迫使用戶更新現有的程式,也許讓它們變得更複雜,以彌補移除的部分。但整體結果仍然可以使撰寫程式 Go 語言的流程更簡單。
的一個範例是,當我們從語言中移除了非阻斷通道作業的布林型態
ok := c <- x // before Go 1, was non-blocking send
x, ok := <-c // before Go 1, was non-blocking receive
這些作業使用 select
也能做得到,這會讓人混淆,需要決定要使用哪種形式。移除它們簡化了語言,卻沒有減少它的功能。
透過限制來簡化
我們亦可透過限制允許的內容來簡化。自第一日起,Go 就對 Go 原始碼檔案的編碼進行限制:它們必須是 UTF-8。此限制簡化了所有嘗試讀取 Go 原始碼檔案的程式。這些程式不必擔心以 Latin-1、UTF-16、UTF-7 或其他方式編碼的 Go 原始碼檔案。
另一項重要限制是程式格式化的 gofmt
。並未拒絕並非使用 gofmt
格式化的 Go 程式碼,但我們已制定一項公約,即重寫 Go 程式的工具會將它們保留在 gofmt
形式中。如果您也讓程式保持在 gofmt
形式中,則這些重寫工具就不會進行任何格式變更。當您比較前後差異時,您會看到的唯一不同就是真正的變更。此限制簡化了程式重寫工具,並促成了 goimports
、gorename
等許多成功的實驗。
Go 開發流程
此實驗簡化循環是我们過去十年來所做的完善典範。但它有一個問題:它太簡單了。我們不能只實驗和簡化。
我們必須交付結果。我們必須讓它可用。當然,使用它可以進行更多實驗,並可能進行更多簡化,而這項流程會不斷循環。

我們在 2009 年 11 月 10 日首次向各位提供 Go。然後,在各位的協助下,我們在 2012 年 3 月共同推出了 Go 1。從那以後,我們共推出了十二個 Go 版本。所有這些都是重要的里程碑,讓我們能夠進行更多實驗,進一步了解 Go,並當然讓 Go 可供產品使用。
當我們推出 Go 1 時,我們便明確將重點轉移到使用 Go,我們需要在嘗試任何更涉及語言變更的簡化之前,更深入瞭解此版本的語言。我們需要花時間進行實驗,才能真正瞭解哪些運作良好而哪些不行。
當然,自 Go 1 以後我們已發行了十二個版本,因此我們仍然持續進行實驗、簡化和發行。但是我們著重於在不進行重大語言變更,也不會中斷現有 Go 程式的情況下,找出簡化 Go 開發的方法。例如,Go 1.5 推出了第一個並行垃圾收集器,而後續版本則對其進行改善,簡化了 Go 開發,並將暫停時間移除作為一個持續的考量。
在 2017 年的 Gophercon,我們宣布在五年實驗後,再次到了該考慮將有助於簡化 Go 開發的大幅變更。我們邁向 Go 2 的道路實際上與邁向 Go 1 的道路相同:實驗、簡化並交付,朝向簡化 Go 開發的總體目標。
對於 Go 2,我們認為最重要的具體主題是錯誤處理、泛型和依賴關係。自那時起,我們已體認到開發人員工具是另一項重要的主題。
本篇文章的其餘內容將探討,我們在這些領域的職務如何遵循此路徑。在此過程中,我們將稍作繞道,暫停檢查 Go 1.13 中針對錯誤處理即將發行的技術細節。
錯誤
光是撰寫一個所有狀況都能正常運作的程式就夠困難了,更何況是所有輸入都是有效且正確,且程式依賴的內容都沒有失敗的情況。如果您在組合中新增錯誤,撰寫一個無論發生什麼狀況都能正常運作的程式會更困難。
在思考 Go 2 的部分中,我們希望深入了解 Go 是否能讓這份工作變得更簡單。
有兩個不同的面向可能會被簡化:錯誤值和錯誤語法。我們將依序檢視,而我承諾會進行技術性繞道,專注於 Go 1.13 錯誤值變更。
錯誤值
錯誤值必須從某個地方開始。以下是 os
套件第一個版本的 Read
函式
export func Read(fd int64, b *[]byte) (ret int64, errno int64) {
r, e := syscall.read(fd, &b[0], int64(len(b)));
return r, e
}
當時還沒有 File
類型,也沒有錯誤類型。Read
和套件中的其他函式直接從基礎 Unix 系統呼叫傳回一個 errno int64
。
這段程式碼於 2008 年 9 月 10 日下午 12:14:00 被提交。當時所有事情都只是實驗,而且程式碼變更很快。兩小時零五分鐘後,API 就發生了變更
export type Error struct { s string }
func (e *Error) Print() { … } // to standard error!
func (e *Error) String() string { … }
export func Read(fd int64, b *[]byte) (ret int64, err *Error) {
r, e := syscall.read(fd, &b[0], int64(len(b)));
return r, ErrnoToError(e)
}
這個新的 API 引入了第一個 Error
類型。一個錯誤保留了一個字串,能夠傳回該字串,也能夠將其列印至標準錯誤輸出裝置。
此處的目的在於推廣成超過整數代碼。我們從過去的經驗得知,作業系統錯誤號碼的表示方式過於受限,簡化程式不一定要將所有關於錯誤的詳細資料都限定成 64 位元。過去使用錯誤字串對我們而言運作良好,因此我們在此也這麼做。這個新的 API 持續了七個月。
在隔年的四月,在有更多使用介面經驗後,我們決定進一步推廣,並允許使用者定義的錯誤實作,方法是讓 os.Error
類型本身成為一個介面。我們透過移除 Print
方法來簡化。
兩年後推出 Go 1 時,根據 Roger Peppe 的建議,os.Error
變成內建的 error
類型,而 String
方法則重新命名為 Error
。從那時開始就沒有任何變更。但是我們已經撰寫了很多 Go 程式,因此我們嘗試了很多方法來實作和使用錯誤。
錯誤是值
賦予 error
簡單的介面,並允許多種不同的實作方式表示我們可以使用完整的 Go 語言來定義和檢查錯誤。我們想要表達:error 是值,與其他任何 Go 值相同。
以下是範例。在 Unix 上,嘗試撥接網路連線會最終使用 connect
系統呼叫。系統呼叫會回傳 syscall.Errno
,這是一個命名整數類型,它表示系統呼叫錯誤號,並實作 error
介面
package syscall
type Errno int64
func (e Errno) Error() string { ... }
const ECONNREFUSED = Errno(61)
... err == ECONNREFUSED ...
syscall
套件同時定義了主機作業系統所定義之錯誤號的命名常數。在這個範例中,此系統上的 ECONNREFUSED
為數字 61。從函式取得錯誤的程式碼可以測試該錯誤是否為 ECONNREFUSED
,方式是使用一般 值相等性。
往上提升一個層級,在 os
套件中,系統呼叫失敗會使用一個更大的錯誤結構來記錄執行錯誤的作業,並加上錯誤。有許多這樣的結構。這個 SyscallError
結構會描述執行特定系統呼叫之錯誤,而不會記錄額外的資訊
package os
type SyscallError struct {
Syscall string
Err error
}
func (e *SyscallError) Error() string {
return e.Syscall + ": " + e.Err.Error()
}
再往上提升一個層級,在 net
套件中,任何網路錯誤會使用一個更大的錯誤結構,其中記錄周遭網路作業的詳細資料,例如撥接或偵聽,以及所涉及的網路和位址
package net
type OpError struct {
Op string
Net string
Source Addr
Addr Addr
Err error
}
func (e *OpError) Error() string { ... }
將這些加在一起,net.Dial
等作業所回傳的錯誤可以用字串格式化,但它們同時也是結構化的 Go 資料值。在這個範例中,錯誤是 net.OpError
,它在 os.SyscallError
中加入內容,而 os.SyscallError
又在 syscall.Errno
中加入內容
c, err := net.Dial("tcp", "localhost:50001")
// "dial tcp [::1]:50001: connect: connection refused"
err is &net.OpError{
Op: "dial",
Net: "tcp",
Addr: &net.TCPAddr{IP: ParseIP("::1"), Port: 50001},
Err: &os.SyscallError{
Syscall: "connect",
Err: syscall.Errno(61), // == ECONNREFUSED
},
}
當我們說錯誤是值時,我們的用意是完整的 Go 語言可以用來定義它們,而且完整的 Go 語言也可以用來檢查它們。
以下是來自 net 套件的範例。事實證明,當您嘗試建立 socket 連線時,在大部分時間內都能連線或收到連線拒絕的訊息,但有時可能會因為沒有好的理由而收到虛假的 EADDRNOTAVAIL
。Go 會透過重試機制來保護使用者程式不會受此失敗模式影響。為此,它必須檢查錯誤結構,才能找出位於最深層的 syscall.Errno
是否為 EADDRNOTAVAIL
。
以下為程式碼
func spuriousENOTAVAIL(err error) bool {
if op, ok := err.(*OpError); ok {
err = op.Err
}
if sys, ok := err.(*os.SyscallError); ok {
err = sys.Err
}
return err == syscall.EADDRNOTAVAIL
}
net.OpError
包裹可以透過 類型斷言 移除。然後,可以透過第二次類型斷言移除 os.SyscallError
包裹。然後,函式會檢查已移除包裹的錯誤是否等於 EADDRNOTAVAIL
。
我們從多年經驗中學到的一件事,就是從實驗 Go 錯誤中獲得,定義 error
介面的任意實作非常有幫助,可以完整使用 Go 語言建立和解構錯誤,而且不需要使用任何單一實作。
這些屬性——即錯誤是值,而且沒有強制執行單一的錯誤實作——很重要,因此需要保留這些屬性。
不強制執行單一的錯誤實作,讓每個人都能試驗錯誤可能會提供的其他功能,從而產生許多套件,例如 github.com/pkg/errors、gopkg.in/errgo.v2、github.com/hashicorp/errwrap、upspin.io/errors、github.com/spacemonkeygo/errors,等等。
然而,不受約束的實驗有一個問題,即身為客戶端,您必須針對可能會遇到的所有可能的實作的聯集來進行程式設計。一個值得在 Go 2 中探索的簡化方式,就是以約定成俗的選用介面形式,定義慣常加入的功能的標準版本,以便不同的實作可以互用。
Unwrap
這些套件中經常加入的功能,是可以在呼叫時從錯誤中移除內容、傳回錯誤內部的方法。不同的套件對此作業使用不同的名稱和意義,有時它移除一層內容,有時則移除可能的多層內容。
對於 Go 1.13,我們引入了一項慣例,即錯誤實作將可移除的內容加入到內部錯誤,應該實作會傳回內部錯誤的 Unwrap
方法,以解除內容的包裝。如果沒有適當的內部錯誤可以向呼叫者公開,則錯誤不應該有 Unwrap
方法,或者 Unwrap
方法應該傳回 nil。
// Go 1.13 optional method for error implementations.
interface {
// Unwrap removes one layer of context,
// returning the inner error if any, or else nil.
Unwrap() error
}
呼叫此選用方法的方式,就是呼叫輔助函數 errors.Unwrap
,它會處理例如錯誤本身為 nil 或根本沒有 Unwrap
方法等情況。
package errors
// Unwrap returns the result of calling
// the Unwrap method on err,
// if err’s type defines an Unwrap method.
// Otherwise, Unwrap returns nil.
func Unwrap(err error) error
我們可以使用 Unwrap
方法撰寫更簡單、更通用的 spuriousENOTAVAIL
版本。一般版本會執行迴圈,呼叫 Unwrap
來移除內容,直到 EADDRNOTAVAIL
出現,或沒有錯誤為止,取代尋找特定的錯誤包裝器實作,例如 net.OpError
或 os.SyscallError
func spuriousENOTAVAIL(err error) bool {
for err != nil {
if err == syscall.EADDRNOTAVAIL {
return true
}
err = errors.Unwrap(err)
}
return false
}
不過,這個迴圈太過常見,所以 Go 1.13 定義了第二個函數 errors.Is
,它會重複解除錯誤包裝,尋找特定的目標。因此,我們可以使用單一次的 errors.Is
呼叫,來取代整個迴圈
func spuriousENOTAVAIL(err error) bool {
return errors.Is(err, syscall.EADDRNOTAVAIL)
}
在這個階段,我們甚至可能不會定義函數;直接在呼叫網站呼叫 errors.Is
,會具備同樣的清晰度,而且更為簡單。
Go 1.13 也引入了函數 errors.As
,它會解除包裝,直到找到特定的實作類型為止。
如果您想要撰寫可搭配任意包裝錯誤運作的程式碼,errors.Is
是了解包裝程式版本的錯誤相等性檢查
err == target
→
errors.Is(err, target)
而 errors.As
是了解包裝程式版本的錯誤類型斷言
target, ok := err.(*Type)
if ok {
...
}
→
var target *Type
if errors.As(err, &target) {
...
}
要 Unwrap 還是不要 Unwrap?
是否讓錯誤能被 Unwrap 是 API 決策,就像是否滙出結構欄位也是 API 決策一樣。有時候對呼叫程式碼公開那個細節是合適的,而有時候則不然。如果是的話,實作 Unwrap。如果不是的話,不要實作 Unwrap。
到目前為止,fmt.Errorf
尚未對呼叫程式檢查公開使用 %v
格式化的基礎錯誤。也就是說,不可能 Unwrap fmt.Errorf
的結果。考慮以下範例
// errors.Unwrap(err2) == nil
// err1 is not available (same as earlier Go versions)
err2 := fmt.Errorf("connect: %v", err1)
如果 err2
回傳給呼叫程式,該呼叫程式從未有辦法開啟 err2
並存取 err1
。我們在 Go 1.13 中維持這個特性。
針對您確實要允許 Unwrap fmt.Errorf
結果的時候,我們也新增了一個新的列印動詞 %w
,其格式化方式就像 %v
,需要錯誤值引數,並且會讓產生的錯誤的 Unwrap
方法回傳該引數。在我們的範例中,假設我們將 %v
取代為 %w
// errors.Unwrap(err4) == err3
// (%w is new in Go 1.13)
err4 := fmt.Errorf("connect: %w", err3)
現在,如果 err4
回傳給呼叫程式,呼叫程式可以使用 Unwrap
來擷取 err3
。
重要的是要注意,像「總是使用 %v
(或永遠不實作 Unwrap
)」或「總是使用 %w
(或總是實作 Unwrap
)」這類絕對規則,就像「絕不滙出結構欄位」或「總是滙出結構欄位」這類絕對規則一樣都是錯誤的。反之,正確的決定取決於呼叫程式是否應該針對使用 %w
或實作 Unwrap
公開的附加資訊進行檢查並依賴這些資訊。
為了來說明這一點,標準函式庫中所有已經有已滙出 Err
欄位的錯誤包裝類型,現在也有回傳該欄位的 Unwrap
方法,但未滙出錯誤欄位的實作沒有,而且使用 %v
的 fmt.Errorf
現有使用方式仍然使用 %v
,而不是 %w
。
錯誤值列印(已放棄)
隨著針對 Unwrap 的設計草稿,我們也發布了一個 針對用於更豐富錯誤列印的選用方法的設計草稿,包括堆疊架構資訊和對於當地語系化、已翻譯的錯誤支援。
// Optional method for error implementations
type Formatter interface {
Format(p Printer) (next error)
}
// Interface passed to Format
type Printer interface {
Print(args ...interface{})
Printf(format string, args ...interface{})
Detail() bool
}
這個不太像 Unwrap
那樣簡單,我不會在這裡詳細說明。在與 Go 社群討論設計時,我們了解到,這個設計不夠簡潔。對於個別錯誤類型來說太難實作,而且對現有的程式幫助不大。整體來說,它並沒有簡化 Go 開發。
因此,我們放棄了這個列印設計。
錯誤語法
那是錯誤值。讓我們簡短地看一下錯誤語法,另一個放棄的實驗。
以下是標準函式庫中 compress/lzw/writer.go
的一些程式碼
// Write the savedCode if valid.
if e.savedCode != invalidCode {
if err := e.write(e, e.savedCode); err != nil {
return err
}
if err := e.incHi(); err != nil && err != errOutOfCodes {
return err
}
}
// Write the eof code.
eof := uint32(1)<<e.litWidth + 1
if err := e.write(e, eof); err != nil {
return err
}
一眼看去,這個程式碼有一半都是錯誤檢查。我讀起來都很吃力。我們知道,撰寫和閱讀起來都很費力的程式碼很容易誤讀,成為難以找到的錯誤的溫床。舉例來說,這三個錯誤檢查中有一個和其他兩個不一樣,如果用快速瀏覽的方式,很容易漏掉這個事實。如果你在除錯這個程式碼,需要花多久才能注意到這一點?
去年在 Gophercon,我們提出了一個草案設計,建議使用關鍵字 check
來表示新的控制流程結構。Check
使用函式呼叫或表達式的錯誤結果。如果錯誤為非 nil,check
會傳回那個錯誤。否則 check
會評估呼叫的其他結果。我們可以用 check
來簡化 lzw 程式碼
// Write the savedCode if valid.
if e.savedCode != invalidCode {
check e.write(e, e.savedCode)
if err := e.incHi(); err != errOutOfCodes {
check err
}
}
// Write the eof code.
eof := uint32(1)<<e.litWidth + 1
check e.write(e, eof)
相同程式碼的這個版本使用了 check
,移除了程式碼的四行,更重要的是,它突出了呼叫 e.incHi
允許傳回 errOutOfCodes
。
也許最重要的是,這個設計還允許定義執行程序錯誤處理區塊,以在後續的檢查失敗時執行。這會讓你只需撰寫一次共用的新增背景程式碼,就像以下的程式碼片段所示
handle err {
err = fmt.Errorf("closing writer: %w", err)
}
// Write the savedCode if valid.
if e.savedCode != invalidCode {
check e.write(e, e.savedCode)
if err := e.incHi(); err != errOutOfCodes {
check err
}
}
// Write the eof code.
eof := uint32(1)<<e.litWidth + 1
check e.write(e, eof)
簡單來說,check
是一種簡短的 if
語法陳述方式,而 handle
就像 defer
,但僅針對錯誤傳回路徑。與其他程式碼的例外情況不同的是,這個設計保留了 Go 一個重要的特性:每個可能會失敗的呼叫在程式碼中都會明確標記,現在使用的是 check
關鍵字,而不是 if err != nil
。
這個設計最大的問題是,handle
與 defer
的重疊部分太多,而且混淆不清。
五月時,我們發佈了 一個新的設計,簡化了三個地方:為了避免與 defer
混淆,這個設計捨棄了 handle
,改用 defer
;為了符合 Rust 和 Swift 的類似想法,這個設計將 check
改名為 try
;為了允許以既有剖析器(例如 gofmt
)可辨識的方式進行實驗,它將 check
(現在是 try
)從關鍵字改為內建函式。
現在,相同的程式碼看起來會像這樣
defer errd.Wrapf(&err, "closing writer")
// Write the savedCode if valid.
if e.savedCode != invalidCode {
try(e.write(e, e.savedCode))
if err := e.incHi(); err != errOutOfCodes {
try(err)
}
}
// Write the eof code.
eof := uint32(1)<<e.litWidth + 1
try(e.write(e, eof))
六月時,我們大半的時間都花在 GitHub 上公開討論這項提案。
check
或 try
的基本概念是縮短每次執行錯誤檢查時重複的語法長度,尤其是讓 return
語句不要顯示出來,保持錯誤檢查明確,同時還能更清楚地強調有趣的變異。然而,在公開意見徵求討論期間,一個有趣的問題在於,沒有明確的 if
陳述句和 return
,就沒有地方可以放置偵錯列印功能,沒有地方可以放置中斷點,也沒有程式碼可以顯示為未在程式碼覆蓋率結果中執行。我們追求的優點是以讓這些狀況變得更複雜為代價。整體而言,從這個以及其他考量因素來看,並不完全清楚整體結果是否會讓 Go 開發變得更簡單,因此我們放棄了這個實驗。
這就是關於錯誤處理的全部,這也是今年的一項重點工作。
泛型
現在讓我們來看看一個稍具爭議性的小東西:泛型。
我們為 Go 2 識別的第二個重要議題是使用型別參數撰寫程式碼的一些方式。這會讓撰寫泛型資料結構,以及撰寫能與任何類型的切片、任何類型的通道或任何類型的對應運作的泛型函式,變得有可能。例如,以下是一個泛型的通道篩選器
// Filter copies values from c to the returned channel,
// passing along only those values satisfying f.
func Filter(type value)(f func(value) bool, c <-chan value) <-chan value {
out := make(chan value)
go func() {
for v := range c {
if f(v) {
out <- v
}
}
close(out)
}()
return out
}
從 Go 開始開發以來,我們就一直在考慮泛型,我們在 2010 年撰寫並拒絕了第一個具體設計。我們在 2013 年底之前又撰寫並拒絕了三種設計。四項放棄的實驗,但並非失敗的實驗,我們從中學習,就像我們從 check
和 try
中學到的一樣。每次我們都學到,通往 Go 2 的道路並非完全在那個方向上,而且我們注意到了其他可能有趣的方向可以探索。但到了 2013 年,我們決定必須關注其他疑慮,因此我們將整個議題暫時擱置幾年。
去年我們再次開始探索和實驗,並於去年夏季的 Gophercon 上提出了基於契約概念的 新設計。我們持續實驗和簡化,並與程式語言理論專家合作,以更深入了解該設計。
總體而言,我滿懷希望,我們朝著一個好的方向邁進,朝著一種可以簡化 Go 開發的設計邁進。即便如此,我們也可能會發現這個設計也不行。我們可能不得不放棄這個實驗,並根據我們學到的知識調整我們的路徑。我們拭目以待。
在 Gophercon 2019 上,Ian Lance Taylor 談到了我們為什麼可能會想要在 Go 中加入泛型,並簡短預覽了最新的設計草稿。有關詳細資訊,請參閱他的部落格文章「為何需要泛型?」
相依關係
我們為 Go 2 識別的第三個重要議題是相依關係管理。
2010 年我們發布了一個稱為 goinstall
的工具,我們稱之為「套件安裝實驗。」它會下載相依關係並將它們儲存在 GOROOT 中,即你的 Go 發行版樹狀結構中。
當我們測試 goinstall
時,我們得知應將 Go 發行版與已安裝套件分開存放,如此一來便可以變更為新的 Go 發行版,而不會遺失所有 Go 套件。因此,我們在 2011 年引入了 GOPATH
,為一個環境變數,它會指定到何處尋找未在主要 Go 發行版中找到的套件。
新增 GOPATH 會為 Go 套件創造更多放置空間,但透過將 Go 發行版與您的 Go 程式庫分開,整體來說簡化了 Go 開發流程。
相容性
goinstall
實驗會刻意略過套件版本控制的概念。相反地,goinstall
總會下載最新的副本。我們執行這項操作,以便我們可以專注於套件安裝的其他設計問題。
Goinstall
成為 go get
,做為 Go 1 的一部分。當人們詢問版本時,我們鼓勵他們透過建立額外工具來進行實驗,而且他們真的這麼做了。我們鼓勵套件作者為其使用者提供和我們為 Go 1 程式庫所做的相同回溯相容性。引用 Go 常見問答集
“打算公開使用的套件應試著在與時俱進時維護回溯相容性。
若需要不同的功能,請新增一個新名稱,而不變更舊名稱。
若需要完全中斷,請建立具有新匯入路徑的新套件。”
這個慣例會限制作者可以執行的動作,簡化使用套件的整體體驗:避免對 API 進行中斷變更;為新功能提供新名稱;並為全新的套件設計提供新的匯入路徑。
當然,人們持續進行實驗。其中一項最有趣的實驗是由 Gustavo Niemeyer 展開。他建立了一個名為 gopkg.in
的 Git 重新導向器,它會為不同的 API 版本提供不同的匯入路徑,協助套件作者遵循這個慣例,為全新的套件設計提供新的匯入路徑。
例如,GitHub 儲存庫中的 Go 原始程式碼 go-yaml/yaml 在語意版本標籤 v1 和 v2 中具有不同的 API。gopkg.in
伺服器會提供這些 API 及不同的匯入路徑 gopkg.in/yaml.v1 和 gopkg.in/yaml.v2。
提供回溯相容性的慣例使新版套件可以使用於舊版套件,即使到了今天,這樣的慣例仍然讓 go get
的簡單規則—“總是下載最新副本”—發揮良好的作用。
版本控制與供應
但在生產環境中,您需要更精確地說明相依性的版本,才能讓組建可複製。
許多人嘗試過這樣做是什麼樣子,建置符合他們需求的工具,包含 Keith Rarick 的 goven
(2012) 和 godep
(2013)、Matt Butcher 的 glide
(2014) 和 Dave Cheney 的 gb
(2015)。所有這些工具都使用您將依賴套件複製到您自己的原始碼管理儲存庫的模型。用這些套件用於導入的機制各不相同,但它們都比看似地更複雜。
經過一場全體性討論之後,我們採用 Keith Rarick 的提案,在不使用 GOPATH 技巧的情況下,加入明顯地支援指引拷貝來的依賴項。這是透過重整來簡化:如 addToList
和 append
,這些工具已經實作這個概念,但是它比它需要地還笨拙。加入對廠商目錄的明顯支援使這些使用整體來說更簡單。
使用 go
指令附送廠商目錄使我們進一步嘗試廠商化,我們發現我們已經引入了幾個問題。最嚴重的是我們遺失了 套件的唯一性。之前,在任何已知的建置過程中,一個導入路徑可能出現在許多不同的套件中,而且所有的導入都指向相同的目標。現在有了廠商化,不同套件中相同的導入路徑可能會指向不同的廠商提供的套件拷貝,而所有這些拷貝都會出現在最後產生的二進位檔中。
當時候,我們不熟悉這個特性的名稱:套件的唯一性。這只是 GOPATH 模型運作的方式。在這個特性消失之前,我們沒有完全欣賞它的價值。
這裡我們可以拿 check
和 try
錯誤語法提議來做個平行比較。在這種情況下,我們依賴於可見 return
敘述陳述的運作方式,這種運作方式是我们直到考慮 移除它之前所沒有體會到的。
當我們加入廠商目錄時,有許多不同的工具可用於管理依賴項。我們認為,針對廠商目錄格式與廠商化元資料有一個明確的協議,就能讓各種工具之間能夠互通,就像針對如何將 Go 程式儲存在文字檔中的協議,就能讓 Go 編譯器、文字編輯器與 goimports
與 gorename
等工具之間可以互通一樣。
這最後證明是天真的樂觀。廠商化工具在細微的語義上有著差異。互通性需要變更所有工具讓它們在語義上達成共識,很可能會讓各自的使用者崩潰。終究沒有達成共識。
Dep
於 2016 年的 Gophercon,我們開始著手定義一個統一的工具來管理相依性。作為此項計畫的一部分,我們面向各種使用者進行調查,以了解他们在相依性管理方面需要什麼,而團隊也開始著手開發一個新的工具,這項工具後成為dep
。
Dep
的目標是取代所有現有的相依性管理工具。目標是透過將現有的不同工具重塑為一個統一的工具,達到簡化的效果。它部分達成目標。此外,dep
還透過在專案樹狀結構的頂層僅放置一個供應商目錄,為其使用者還原套件的獨特性。
不過,dep
還引入了嚴重的問題,而我們花了些時間才完全發覺。問題在於dep
採用了glide
的設計選擇,支援並鼓勵在不變更匯入路徑的情況下,對特定套件進行不相容的變更。
以下是一個範例。假設您正在建置自己的程式,且您需要一個組態檔案,於是您使用流行的 Go YAML 套件的版本 2

現在假設您的程式匯入了 Kubernetes 應用程式介面程式。結果發現,Kubernetes 大量使用 YAML,且使用相同流行套件的版本 1

版本 1 和版本 2 的 API 互不相容,不過它們也有不同的匯入路徑,因此給定的匯入指涉何者並無二義性。Kubernetes 取得版本 1,您的組態剖析器取得版本 2,且一切順利運作。
Dep
放棄這個模型。yaml 套件的版本 1 和版本 2 現在將有相同的匯入路徑,導致發生衝突。將相同的匯入路徑用於兩個互不相容的版本,並結合套件的獨特性,會導致無法建置您先前可以建置的這個程式

我們花了些時間才了解這個問題,這是因為我們長期以來一直使用「新的 API 表示新的匯入路徑」慣例,以致於我們將它視為理所當然。dep 實驗協助我們更進一步了解此慣例,且我們給它一個名稱:匯入相容性規則
「如果舊套件和新套件具有相同的匯入路徑,則新套件必須向下相容於舊套件。」
Go 模組
我們採用 dep 實驗中運作良好的部分,並針對我們學到的不理想部分,使用稱為vgo
的新設計進行實驗。在vgo
中,套件遵循匯入相容性規則,讓我們得以提供套件的獨特性,但不會損及我們剛剛檢視的建置。這也讓我們簡化設計的其他部分。
除了還原匯入相容性規則之外,vgo
設計的另一個重點是將一群套件的概念賦予一個名稱,並允許這個群組與原始碼儲存庫的界線分開。一群 Go 套件的名稱為模組,所以我們現在將系統稱為 Go 模組。
Go 模組現已整合到 go
指令中,避免到處複製供應商目錄。
取代 GOPATH
使用 Go 模組,即表示不再將 GOPATH 作為全域名稱空間。從離開 GOPATH 開始,將現有 Go 使用和工具轉換至模組,幾乎所有的繁瑣工作都是由於這項變更造成的。
GOPATH 的基本概念是,GOPATH 目錄樹是正在使用的版本的事實全球來源,而且在目錄間移動時,正在使用的版本不會變更。但全域 GOPATH 模式與每項專案可重製建置的生產需求直接衝突,而這項需求本身就簡化 Go 的開發和部署經驗,在許多重要的方面上。
每項專案可重製建置表示,當你在專案 A 的結帳中工作時,你會取得與專案 A 的其他開發人員在該提交時所取得的相同一組相依性版本,這由 go.mod
檔案所定義。當你切換至在專案 B 的結帳中工作時,現在你會取得該專案所選取的相依性版本,這與專案 B 的其他開發人員所取得的相同。但這些版本很可能與專案 A 不同。在從專案 A 移至專案 B 時,相依性版本集合的變更對於讓你的開發與專案 A 和專案 B 上其他開發人員的開發保持同步來說有其必要性。不再能只有一個單一的全域 GOPATH。
採用模組的複雜性,大部分都直接由於失去一個全域 GOPATH 而產生。套件的原始程式碼在哪?之前,答案只取決於你的 GOPATH 環境變數,而大多數人都很少變更該變數。現在,答案取決於你在處理哪一個專案,而這可能會頻繁變更。為了配合這個新的慣例,一切都需要更新。
大多數開發工具都使用 go/build
套件尋找和載入 Go 原始程式碼。我們已讓該套件繼續運作,但 API 沒有預期到模組,而我們為了避免 API 變更而新增的因應措施,比我們想要的還慢。我們已發佈替代品:golang.org/x/tools/go/packages
。開發人員工具現在應該改用它。它支援 GOPATH 和 Go 模組,而且使用起來更快速、簡單。於一到兩次發佈後,我們可能會將它移至標準函式庫中,但目前 golang.org/x/tools/go/packages
穩定且可用。
Go 模組代理
模組簡化 Go 開發的方法之一,是將一組套件的概念與儲存這些套件的底層原始程式碼存放庫分開。
當我們與 Go 使用者討論依賴項時,幾乎所有在公司中使用 Go 的人都詢問如何處理透過自己的伺服器路由 `go get` 封包擷取,以更好地控制可使用的程式碼。甚至連開放原始碼開發人員也擔心依賴項會莫名其妙地消失或改變,而讓自己的建置中斷。在有模組之前,使用者已經嘗試採用複雜的解決方案來解決這些問題,包括攔截 Go 指令執行的版本控制指令。
Go 模組設計讓使用者更容易融入對特定模組版本的要求,並建立模組代理的觀念。
公司現在可以輕鬆執行自己的模組代理,並使用自訂規則來決定要允許什麼樣的模組,以及快取副本的儲存位置。開放原始碼 Athens 專案 即建立了這種代理,而且 Aaron Schlesinger 也在 2019 年 Gophercon 會議上對此進行說明。(影片上線後,我們會在這裡加入連結。)
對於個人開發人員和開放原始碼團隊,Google 中的 Go 團隊已經 啟動一個代理,做為所有開放原始碼 Go 封包的公開鏡像,而且 Go 1.13 模組模式預設會使用該代理。Katie Hockman 在 2019 年 Gophercon 會議中 說明這個系統。
Go 模組狀態
Go 1.11 導入模組作為實驗性的選用預覽。我們持續進行測試與簡化。Go 1.12 發布多項改善事項,而 Go 1.13 將發布更多改善事項。
現在我們認為大部分使用者都適合使用模組,但我們尚未準備要關閉 GOPATH。我們會持續測試、簡化和修改。
我們完全了解 Go 使用者社群在 GOPATH 周圍建立了將近十年的經驗、工具和工作流程,要把所有這些都轉換成 Go 模組會花一段時間。
但我們仍認為模組現在非常適合大多數使用者,而且我鼓勵您們在 Go 1.13 發布時可以仔細查看。
一個資料點是,Kubernetes 專案有很多依賴項,並且已轉移到使用 Go 模組來管理這些依賴項。您或許也可以這麼做。如果您做不到,請透過 提交錯誤報告 讓我們知道哪些地方不適合您或有太多複雜性,我們會進行測試並簡化。
工具
錯誤處理、泛型函數和依賴管理這三個特性至少會花上幾年的時間,而我們也將專注於這上面。錯誤處理即將完成,接下來將是模組,之後應該是泛型函數。
但讓我們想像一下,幾年後,當我們完成實驗與簡化並發行錯誤處理、模組和泛型函數時,會發生什麼事?預測未來很困難,但我想一旦這三項特性發行後,可能會開啟一段重大變化較為沉寂的時期。那時,我們可能會將重點轉移到使用更進階的工具簡化 Go 的開發。
部分工具開發工作已經在進行中,因此,這篇文章將從探討該議題作為結尾。
當我們協助更新 Go 社群已有的所有工具以理解 Go 模組時,我們注意到有大量僅執行一個小工作並有助於開發的工具,這並無法妥善服務使用者。各個個別工具的結合太過困難、呼叫太過緩慢,且使用差異太大。
我們開始努力將最常使用的開發協助程式統一至單一工具,目前稱為 gopls
(發音為「go, please」)。Gopls
使用 語言伺服器協定(LSP),並搭配任何支援 LSP 的整合開發環境或文字編輯器,而這在本質上涵蓋了目前市面上的所有選項。
Gopls
標誌著 Go 專案的重點擴充,從提供獨立的編譯器式命令行工具(例如 go vet 或 gorename)擴展至提供完整的 IDE 服務。Rebecca Stambler 在 Gophercon 2019 以一場演講提供了有關 gopls
和 IDE 的更多詳情。(影片上線後,我們會在此處加入連結)
在 gopls
之後,我們也有構想以擴充的方式恢復 go fix
的使用,以及讓 go vet
更具幫助。
尾聲

因此,Go 2 的路徑便在於此。我們將實驗和簡化。再實驗和再簡化。然後發行。然後再實驗和再簡化。並循環進行。這條路徑可能看起來或感覺起來像是繞圈圈。但是,每一次的實驗和簡化,我們都會更了解 Go 2 的樣貌,並朝著它邁進一步。即使是我們捨棄的實驗(例如 try
、我們最初的四種泛型函數設計,或 dep
),也不是浪費時間。它們幫助我們了解在發行之前需要簡化哪些內容,有時它們也會幫助我們更了解我們曾經視為理所當然的事物。
在某個時間點,我們將會意識到我們已經實驗夠了、簡化夠了,並發行夠了,屆時,我們便會有 Go 2 了。
致所有 Go 社群成員,感謝你們在我們在這條道路上協助我們進行實驗、簡化、出貨並找到我們自己的方法。
下一篇文章:Contributor's Summit 2019
上一篇文章:為何使用泛型?
網誌目錄