Go導出標識符:那些鮮為人知的細節
前不久,在“Go+用戶組”微信群里看到有開發者向七牛云老板許式偉反饋七牛云Go SDK中的某些類型沒有導出,導致外部包無法使用的問題(如下圖)[1]:
圖片
七牛開發人員迅速對該問題做出了“更正”,將問題反饋中涉及的類型saveasArgs和saveasReply改為了導出類型,即首字母大寫:
圖片
不過,這看似尋常的問題反饋與修正卻引發了我的一些思考。
我們大膽臆想一下:如果saveasReply類型的開發者是故意將saveasReply類型設置為非導出的呢?看一下“更正”之前的saveasReply代碼:
type saveasReply struct {
Fname string `json:"fname"`
PersistenId string `json:"persistentId,omitempty"`
Bucket string `json:"bucket"`
Duration int `json:"duration"` // ms
}
有讀者可能會問:那為什么還將saveasReply結構體的字段設置為導出字段呢?請注意每個字段后面的結構體標簽(struct tag)。這顯然是為了進行JSON 編解碼,因為目前Go的encoding/json包僅會對導出字段進行編解碼處理。
除了這個原因,原開發者可能還希望包的使用者能夠訪問這些導出字段,而又不想完全暴露該類型。我在此不對這種設計的合理性進行評價,而是想探討這種做法是否可行。
我們對Go導出標識符的傳統理解是:導出標識符(以大寫字母開頭的標識符)可以在包外被訪問和使用,而非導出標識符(以小寫字母開頭的標識符)只能在定義它們的包內訪問。這種機制幫助開發者控制類型和函數的可見性,確保內部實現細節不會被隨意訪問,從而增強封裝性。
但實際上,Go的導出標識符機制是否允許在某些情況下,即使類型本身是非導出的,其導出字段依然可以被包外的代碼訪問呢?該類型的導出方法呢?這些關于Go導出標識符的細節可能是鮮少人探討的,在這篇博文中,我們將系統地了解這些機制,希望能為各位小伙伴帶來更深入的理解。
1. Go對導出標識符的定義
我們先回顧一下Go語言規范(go spec)對導出標識符的定義[2]:
圖片
我們通常使用英文字母來命名標識符,因此可以將上述定義中的第一句理解為:以大寫英文字母開頭的標識符即為導出標識符。
注:Unicode字符類別Lu(Uppercase Letter)包含所有的大寫字母。這一類別不僅包括英文大寫字母,還涵蓋多種語言的大寫字符,例如希臘字母、阿拉伯字母、希伯來字母和西里爾字母等。然而,我非常不建議大家使用非英文大寫字母來表示導出標識符,因為這可能會挑戰大家的認知習慣。
而第二句后半部分的描述往往被我們忽視或理解不夠到位。一個類型的字段名和方法名可以是導出的,但并沒有明確要求其關聯的類型本身也必須是導出的。
這為我們提供了進一步探索Go導出標識符細節的機會。接下來,我們就用具體示例看看是否可以在包外訪問非導出類型的導出字段以及導出方法。
2. 在包外訪問非導出類型的導出字段
我們首先定義一個帶有導出字段的非導出類型myStruct,并將它放在mypackage里:
// go-exported-identifiers/field/mypackage/mypackage.go
package mypackage
type myStruct struct {
Field string // 導出的字段
}
// NewMyStruct1是一個導出的函數,返回myStruct的指針
func NewMyStruct1(value string) *myStruct {
return &myStruct{Field: value}
}
// NewMyStruct1是一個導出的函數,返回myStruct類型變量
func NewMyStruct2(value string) myStruct {
return myStruct{Field: value}
}
然后我們在包外嘗試訪問myStruct類型的導出字段:
// go-exported-identifiers/field/main.go
package main
import (
"demo/mypackage"
"fmt"
)
func main() {
// 通過導出的函數獲取myStruct的指針
ms1 := mypackage.NewMyStruct1("Hello1")
// 嘗試訪問Field字段
fmt.Println(ms1.Field) // Hello1
// 通過導出的函數獲取myStruct類型變量
ms2 := mypackage.NewMyStruct1("Hello2")
// 嘗試訪問Field字段
fmt.Println(ms2.Field) // Hello2
}
在go-exported-identifiers/field目錄下編譯運行該示例:
$go run main.go
Hello1
Hello2
我們看到,無論是通過myStruct的指針還是實例副本,都可以成功訪問其導出變量Field。這個示例的關鍵就是:我們使用了短變量聲明直接通過調用myStruct的兩個“構造函數(NewXXX)”得到了其指針(ms1)以及實例副本(ms2)。在這個過程中,我們沒有在main包中顯式使用mypackage.myStruct這個非導出類型。
采用類似的方案,我們接下來再看看是否可以在包外訪問非導出類型的導出方法。
3. 在包外訪問非導出類型的導出方法
我們為非導出類型添加兩個導出方法M1和M2:
// go-exported-identifiers/method/mypackage/mypackage.go
package mypackage
import "fmt"
type myStruct struct {
Field string // 導出的字段
}
// NewMyStruct1是一個導出的函數,返回myStruct的指針
func NewMyStruct1(value string) *myStruct {
return &myStruct{Field: value}
}
// NewMyStruct1是一個導出的函數,返回myStruct類型變量
func NewMyStruct2(value string) myStruct {
return myStruct{Field: value}
}
func (m *myStruct) M1() {
fmt.Println("invoke *myStruct's M1")
}
func (m myStruct) M2() {
fmt.Println("invoke myStruct's M2")
}
然后,試著在外部包中調用M1和M2方法:
// go-exported-identifiers/method/main.go
package main
import (
"demo/mypackage"
)
func main() {
// 通過導出的函數獲取myStruct的指針
ms1 := mypackage.NewMyStruct1("Hello1")
ms1.M1()
ms1.M2()
// 通過導出的函數獲取myStruct類型變量
ms2 := mypackage.NewMyStruct2("Hello2")
ms2.M1()
ms2.M2()
}
在go-exported-identifiers/method目錄下編譯運行這個示例:
$go run main.go
invoke *myStruct's M1
invoke myStruct's M2
invoke *myStruct's M1
invoke myStruct's M2
我們看到,無論是通過非導出類型的指針,還是通過非導出類型的變量復本都可以成功調用非導出類型的導出方法。
提及方法,我們會順帶想到接口,非導出類型是否可以實現某個外部包定義的接口呢?我們繼續往下看。
4. 非導出類型實現某個外部包的接口
在Go中,如果某個類型T實現了某個接口類型I的方法集合中的所有方法,我們就說T實現了I,T的實例可以賦值給I類型的接口變量。
在下面示例中,我們看看非導出類型是否可以實現某個外部包的接口。
在這個示例中mypackage包中的內容與上面示例一致,主要改動的是main.go,我們來看一下:
// go-exported-identifiers/interface/main.go
package main
import (
"demo/mypackage"
)
// 定義一個導出的接口
type MyInterface interface {
M1()
M2()
}
func main() {
var mi MyInterface
// 通過導出的函數獲取myStruct的指針
ms1 := mypackage.NewMyStruct1("Hello1")
mi = ms1
mi.M1()
mi.M2()
// 通過導出的函數獲取myStruct類型變量
// ms2 := mypackage.NewMyStruct2("Hello2")
// mi = ms2 // compile error: mypackage.myStruct does not implement MyInterface
// ms2.M1()
// ms2.M2()
}
在這個main.go中,我們定義了一個接口MyInterface,它的方法集合中有兩個方法M1和M2。根據類型方法集合的判定規則,*myStruct類型實現了MyInterface的所有方法,而myStruct類型則不滿足,沒有實現M1方法,我們在go-exported-identifiers/interface目錄下編譯運行這個示例,看看是否與我們預期的一致:
$go run main.go
invoke *myStruct's M1
invoke myStruct's M2
如果我們去掉上面代碼中對ms2的注釋,那么將得到Compiler error: mypackage.myStruct does not implement MyInterface。
注:關于一個類型的方法集合的判定規則,可以參考我的極客時間《Go語言第一課》[3]專欄的第25講[4]。
接下來,我們再來考慮一個場景,即非導出類型用作嵌入字段的情況,我們要看看該非導出類型的導出方法和導出字段是否會promote到外部類型中。
5. 非導出類型用作嵌入字段
我們改造一下示例,新版的帶有嵌入字段的結構見下面mypackage包的代碼:
// go-exported-identifiers/embedded_field/mypackage/mypackage.go
package mypackage
import "fmt"
type nonExported struct {
Field string // 導出的字段
}
// Exported 是導出的結構體,嵌入了nonExported
type Exported struct {
nonExported // 嵌入非導出結構體
}
func NewExported(value string) *Exported {
return &Exported{
nonExported: nonExported{
Field: value,
},
}
}
// M1是導出的函數
func (n *nonExported) M1() {
fmt.Println("invoke nonExported's M1")
}
// M2是導出的函數
func (e *Exported) M2() {
fmt.Println("invoke Exported's M2")
}
這里新增一個導出類型Exported,它嵌入了一個非導出類型nonExported,后者擁有導出字段Field,以及兩個導出方法M1。我們也Exported類型定義了一個方法M2。
下面我們再來看看main.go中是如何使用Exported的:
// go-exported-identifiers/embedded_field/main.go
package main
import (
"demo/mypackage"
"fmt"
)
// 定義一個導出的接口
type MyInterface interface {
M1()
M2()
}
func main() {
ms := mypackage.NewExported("Hello")
fmt.Println(ms.Field) // 訪問嵌入的非導出結構體的導出字段
ms.M1() // 訪問嵌入的非導出結構體的導出方法
var mi MyInterface = ms
mi.M1()
mi.M2()
}
在go-exported-identifiers/embedded_field目錄下編譯運行這個示例:
$go run main.go
Hello
invoke nonExported's M1
invoke nonExported's M1
invoke Exported's M2
我們看到,作為嵌入字段的非導出類型的導出字段與方法會被自動promote到外部類型中,通過外部類型的變量可以直接訪問這些字段以及調用這些導出方法。這些方法還可以作為外部類型方法集中的一員,來作為滿足特定接口類型(如上面代碼中的MyInterface)的條件。
Go 1.18增加了泛型支持,那么非導出類型是否可以用作泛型函數和泛型類型的類型實參呢?最后我們來看看這個細節。
6. 非導出類型用作泛型函數和泛型類型的類型實參
和前面一樣,我們先定義用于該示例的帶有導出字段和導出方法的非導出類型:
// go-exported-identifiers/generics/mypackage/mypackage.go
package mypackage
import "fmt"
// 定義一個非導出的結構體
type nonExported struct {
Field string
}
// 導出的方法
func (n *nonExported) M1() {
fmt.Println("invoke nonExported's M1")
}
func (n *nonExported) M2() {
fmt.Println("invoke nonExported's M2")
}
// 導出的函數,用于創建非導出類型的實例
func NewNonExported(value string) *nonExported {
return &nonExported{Field: value}
}
現在我們將其用于泛型函數,下面定義了泛型函數UseNonExportedAsTypeArgument,它的類型參數使用MyInterface作為約束,而上面的nonExported顯然滿足該約束,我們通過構造函數NewNonExported獲得非導出類型的實例,然后將其傳遞給UseNonExportedAsTypeArgument,Go會通過泛型的類型參數自動推導機制推斷出類型實參的類型:
// go-exported-identifiers/generics/main.go
package main
import (
"demo/mypackage"
)
// 定義一個用作約束的接口
type MyInterface interface {
M1()
M2()
}
func UseNonExportedAsTypeArgument[T MyInterface](item T) {
item.M1()
item.M2()
}
// 定義一個帶有泛型參數的新類型
type GenericType[T MyInterface] struct {
Item T
}
func NewGenericType[T MyInterface](item T) GenericType[T] {
return GenericType[T]{Item: item}
}
func main() {
// 創建非導出類型的實例
n := mypackage.NewNonExported("Hello")
// 調用泛型函數,傳入實現了MyInterface的非導出類型
UseNonExportedAsTypeArgument(n) // ok
// g := GenericType{Item: n} // compiler error: cannot use generic type GenericType[T MyInterface] without instantiation
g := NewGenericType(n)
g.Item.M1()
}
但由于目前Go泛型還不支持對泛型類型的類型參數的自動推導,所以直接通過g := GenericType{Item: n}來初始化一個泛型類型變量將導致編譯錯誤!我們需要借助泛型函數的推導機制將非導出類型與泛型類型進行結合,參見上述示例中的NewGenericType函數,通過泛型函數支持的類型參數的自動推導間接獲得GenericType的類型實參。在go-exported-identifiers/generics目錄下編譯運行這個示例,便可得到我們預期的結果:
$go run main.go
invoke nonExported's M1
invoke nonExported's M2
invoke nonExported's M1
7. 非導出類型使用導出字段以及導出方法的用途
前面的諸多示例證明了:即使類型本身是非導出的,但其內部的導出字段以及它的導出方法依然可以在外部包中使用,并且在實現接口、嵌入字段、泛型等使用場景下均有效。
到這里,你可能會提出這樣一個問題:會有Go開發者使用非導出類型結合導出字段或方法的設計嗎?
其實這種還是很常見的,在Go標準庫中就有不少,只不過它們更多是包內使用,類似于非導出類型xxxImpl和它的Wrapper類型XXX的關系,或是xxxImpl或嵌入到XXX中,就像這樣:
// 包內實現
type xxxImpl struct { // 非導出的實現類型
// 內部字段
}
// 導出的包裝類型
type XXX struct {
impl *xxxImpl // 包含實現類型
// 其他字段
}
// 或者通過嵌入方式
type XXX struct {
*xxxImpl // 嵌入實現類型
// 其他字段
}
但也有一些可以包外使用的,比如實現了某個接口,并通過接口值返回,提供給外部使用,例如下面的valueCtx,它實現了Context接口,并通過WithValue返回,供調用WithValue的外部包使用:
//$GOROOT/src/context/context.go
func WithValue(parent Context, key, val any) Context { // 構造函數,實現接口
if parent == nil {
panic("cannot create context from nil parent")
}
if key == nil {
panic("nil key")
}
if !reflectlite.TypeOf(key).Comparable() {
panic("key is not comparable")
}
return &valueCtx{parent, key, val}
}
// A valueCtx carries a key-value pair. It implements Value for that key and
// delegates all other calls to the embedded Context.
type valueCtx struct {
Context
key, val any
}
func (c *valueCtx) Value(key any) any {
if c.key == key {
return c.val
}
return value(c.Context, key)
}
這么做的目的是什么呢?大約有如下幾點:
- 隱藏實現細節
非導出類型的主要作用是防止外部直接使用和依賴其內部實現細節。通過限制類型的直接使用,庫作者可以保持實現的靈活性,隨時調整或重構類型的內部邏輯,而無需擔心破壞外部調用代碼; 還可以避免暴露多余的API,使庫的接口更加簡潔。
- 控制實例的創建和管理
通過非導出類型,開發者還可以確保外部代碼無法直接實例化類型,而必須通過導出的構造函數或工廠函數,就像前面舉的示例那樣。這種模式可以保證對象始終以特定的方式初始化,避免錯誤使用。同時,它還可以用來實現更復雜的初始化邏輯,如依賴注入或資源管理。
- 在接口實現中的作用
非導出類型可以用來實現導出的接口,從而將接口的實現細節完全隱藏。對于用戶來說,只需要關心接口的定義,而無需關注其實現。
8. 小結
本文探討了Go語言中的導出標識符及其相關細節,特別是非導出類型如何與其導出字段和導出方法結合使用。
盡管某些類型是非導出的,其內部的導出字段和方法依然可以在包外訪問。此外,非導出類型在實現接口、嵌入字段和泛型中也展現出良好的應用。這種設計不僅促進了封裝和接口實現的靈活性,還允許開發者通過構造函數返回非導出類型的實例,從而有效控制實例的創建與管理。這種方式幫助隱藏實現細節,簡化外部接口,使得代碼結構更加清晰。
本文涉及的源碼可以在這里[5]下載。
參考資料
[1] 七牛云Go SDK中的某些類型沒有導出,導致外部包無法使用的問題(如下圖): https://github.com/qiniu/go-sdk/blob/bb391c9d9ea2c115494df5c38d058cb3b673a29f/qvs/record.go#L41
[2] Go語言規范(go spec)對導出標識符的定義: https://go.dev/ref/spec#Exported_identifiers
[3] 《Go語言第一課》: http://gk.link/a/10AVZ
[4] 第25講: https://time.geekbang.org/column/article/466221
[5] 這里: https://github.com/bigwhite/experiments/tree/master/go-exported-identifier