Go 学习笔记(35)— Go 接口 interface (接口声明、接口初始化、接口方法调用、接口运算、类型断言、类型查询、空接口)
1. 接口概念
接口是雙方約定的一種合作協議。接口實現者不需要關心接口會被怎樣使用,調用者也不需要關心接口的實現細節。接口是一種類型,也是一種抽象結構,不會暴露所含數據的格式、類型及結構。
接口內部存放的具體類型變量被稱為接口指向的“實例”。接口只有聲明沒有實現,所以定義一個新接口,通常又變成聲明一個新接口, 定義接口和聲明接口二者通用,代表相同的意思。
最常使用的接口字面量類型就是空接口 interface{} ,由于空接口的方法集為空,所以任意類型都被認為實現了空接口,任意類型的實例都可以賦值或傳遞給空接口,包括非命名類型的實例。
Go 接口背后的本質是一種“契約”,通過契約我們可以將代碼雙方的耦合降至最低。Go 慣例上推薦盡量定義小接口,一般而言接口方法集合中的方法個數不要超過三個,單一方法的接口更受 Go 社區青睞。小接口有諸多優點,比如,抽象程度高、易于測試與實現、與組合的設計思想一脈相承、鼓勵你編寫組合的代碼,等等。
這種“小接口”的 Go 慣例也已經被 Go 社區項目廣泛采用。作者統計了早期版本的 Go 標準庫(Go 1.13 版本)、Docker 項目(Docker 19.03 版本)以及 Kubernetes 項目(Kubernetes 1.17 版本)中定義的接口類型方法集合中方法數量,你可以看下:
從圖中我們可以看到,無論是 Go 標準庫,還是 Go 社區知名項目,它們基本都遵循了“盡量定義小接口”的慣例,接口方法數量在 1~3 范圍內的接口占了絕大多數。
圖片來源:https://time.geekbang.org/column/article/471952
注意:非命名類型由于不能定義自己的方法, 所以方法集為空,因此其類型變量除了傳遞給空接口,不能傳遞給任何其他接口。
Go 語言提供了另外一種數據類型即接口,它把所有的具有共性的方法定義在一起,任何其他類型只要實現了這些方法就是實現了這個接口。
/* 定義接口 */
type interface_name interface {method_name1 [return_type]method_name2 [return_type]method_name3 [return_type]...method_namen [return_type]
}/* 定義結構體 */
type struct_name struct {/* variables */
}/* 實現接口方法 */
func (struct_name_variable struct_name) method_name1() [return_t
ype] {/* 方法實現 */
}
...
func (struct_name_variable struct_name) method_namen() [return_t
ype] {/* 方法實現*/
}
使用示例:
package mainimport ("fmt"
)type Phone interface {call()
}type NokiaPhone struct {
}
type IPhone struct {
}func (nokiaPhone NokiaPhone) call() {fmt.Println("I am Nokia, I can call you!")
}func (iPhone IPhone) call() {fmt.Println("I am iPhone, I can call you!")
}
func main() {var phone Phonephone = new(NokiaPhone)phone.call() // I am Nokia, I can call you!phone = new(IPhone)phone.call() // I am iPhone, I can call you!
}
2. 接口聲明
Go 語言的接口分為接口字面量類型和接口命名類型, 接口的聲明使用 interface 關鍵字。
接口字面量類型的聲明語法如下:
interface {MethodSignature1MethodSignature2
}
使用接口字面量的場景很少,一般只有空接口 interface{} 類型變量的聲明才會使用。
接口命名類型使用 type 關鍵字聲明,語法如下:
type InterfaceName interface {方法名1( 參數列表1 ) 返回值列表1方法名2( 參數列表2 ) 返回值列表2
}
對各個部分的說明:
- 接口類型名:使用
type將接口定義為自定義的類型名。Go語言的接口在命名時,一般會在單詞后面添加er,如有寫操作的接口叫Writer,有字符串功能的接口叫Stringer,有關閉功能的接口叫Closer等; - 方法名:當方法名首字母是大寫時,且這個接口類型名首字母也是大寫時,這個方法可以被接口所在的包(package)之外的代碼訪問;
- 參數列表、返回值列表:參數列表和返回值列表中的參數變量名可以被忽略;
2.1 方法聲明
接口定義使用方法聲明,而不是方法簽名,因為方法名是接口的組成部分。例如:
// 方法聲明=方法名+方法簽名
MethodName (InputTypeList )OutputTypeList
接口中的“方法聲明”非常類似于 C 語言中的函數聲明的概念, Go 編譯器在做接口匹配判斷時是嚴格校驗方法名稱和方法簽名的。
接口定義大括號內可以是方法聲明的集合,也可以嵌入另一個接口類型匿名字段,還可以是二者的混合。接口支持嵌入匿名接口宇段,就是一個接口定義里面可以包括其他接口, Go 編譯器會自動進行展開處理,有點類似 C 語言中宏的概念。例如:
type Reader interface {Read(p []byte) (n int , err error)
}type Writer interface {Write(p []byte) (n int , err error)
}// 如下3 種聲明是等價的,最終的展開模式都是第 3 種格式
type ReadWriter interface {ReaderWriter
}type ReadWriter interface {ReaderWrite(p []byte) (n int , err error)
}type ReadWriter interface {Read(p []byte) (n int, err error)Write(p []byte) (n int, err error)
}
Writer 這個接口可以調用 Write() 方法寫入一個字節數組( []byte ),返回值告知寫入字節數( n int )和可能發生的錯誤( err error )
我們在接口類型的方法集合中聲明的方法,它的參數列表不需要寫出形參名字,返回值列表也是如此。也就是說,方法的參數列表中形參名字與返回值列表中的具名返回值,都不作為區分兩個方法的憑據。
type MyInterface interface {M1(int) errorM2(io.Writer, ...string)
}
比如下面的 MyInterface 接口類型的定義與上面的 MyInterface 接口類型定義都是等價的:
type MyInterface interface {M1(a int) errorM2(w io.Writer, strs ...string)
}type MyInterface interface {M1(n int) errorM2(w io.Writer, args ...string)
}
不過,Go 語言要求接口類型聲明中的方法必須是具名的,并且方法名字在這個接口類型的方法集合中是唯一的。
Go 1.14 版本以后,Go 接口類型允許嵌入的不同接口類型的方法集合存在交集,但前提是交集中的方法不僅名字要一樣,它的函數簽名部分也要保持一致,也就是參數列表與返回值列表也要相同,否則 Go 編譯器照樣會報錯。
比如下面示例中 Interface3 嵌入了 Interface1 和 Interface2,但后兩者交集中的 M1 方法的函數簽名不同,導致了編譯出錯:
type Interface1 interface {M1()
}
type Interface2 interface {M1(string) M2()
}type Interface3 interface{Interface1Interface2 // 編譯器報錯:duplicate method M1M3()
}
2.2 新接口類型聲明特點
- 接口的命名一般以“
er”結尾; - 接口定義的內部方法聲明不需要
func引導; - 在接口定義中,只有方法聲明沒有方法實現;
3. 接口初始化
接口類型一旦被定義后,它就和其他 Go 類型一樣可以用于聲明變量,比如:
var err error // err是一個error接口類型的實例變量
var r io.Reader // r是一個io.Reader接口類型的實例變量
接口只有被初始化為具體的類型時才有意義。沒有初始化的接口變量,其默認值是 nil 。
var i io.Reader
fmt.Printf("%T\n", i) // nil
這些類型為接口類型的變量被稱為接口類型變量,如果沒有被顯式賦予初值,接口類型變量的默認值為 nil。如果要為接口類型變量顯式賦予初值,我們就要為接口類型變量選擇合法的右值。
接口綁定具體類型的實例的過程稱為接口初始化。接口變量支持兩種直接初始化方法, 具體如下。
3.1 實例賦值接口
如果具體類型實例的方法集是某個接口的方法集的超集,則稱該具體類型實現了接口,可以將該具體類型的實例直接賦值給接口類型的變量,此時編譯器會進行靜態的類型檢查。接口被初始化后,調用接口的方法就相當于調用接口綁定的具體類型的方法,這就是接口調用的語義。
3.2 接口變量賦值接口變量
已經初始化的接口類型變量 a 直接賦值給另一種接口變量 b ,要求 b 的方法集是 a 的方法集的子集。此時 Go 編譯器會在編譯時進行方法集靜態檢查。這個過程也是接口初始化的一種方式,此時接口變量 b 綁定的具體實例是接口變量 a 綁定的具體實例的副本。例如:
file , := os .OpenFile ( "notes.txt", os .O_RDWR | os .O_CREATE , 0755 )
var rw io.ReadWriter = file
//io.ReadWriter 接口可以直接賦值給 io.Writer 接口變量
var w io.Writer = rw
4. 接口方法調用
接口方法調用和普通的函數調用是有區別的。接口方法調用的最終地址是在運行期決定的,將具體類型變量賦值給接口后,會使用具體類型的方法指針初始化接口變量,當調用接口變量的方法時,實際上是間接地調用實例的方法。接口方法調用不是一種直接的調用,有一定的運行時開銷。
直接調用未初始化的接口變量的方法會產生 panic 。例如:
package maintype Printer interface {Print()
}type S struct{}func (s S) Print() {println("print")
}func main() {var i Printer// 沒有初始化的接口調用其方法會產生 panic// panic: runtime error: invalid memory address or nil pointer dereference// i.Print()// i 必須初始化i = S{}i.Print()
}
5. 接口運算
編程過程中有時需要確認已經初始化的接口變量指向實例的具體類型是什么,也需要檢查運行時的接口類型。 Go 語言提供兩種語法結構來支持這兩種需求,分別是類型斷言和類型查詢。
Go 的語言中提供了斷言的功能。Go 中的所有程序都實現了 interface{} 的接口,這意味著,所有的類型如 string , int , int64 甚至是自定義的 struct 類型都就此擁有了 interface{} 的接口,那么在一個數據通過 func funcName(interface{}) 的方式傳進來的時候,也就意味著這個參數被自動的轉為 interface{} 的類型。
func funcName(a interface{}) string {return string(a)
}
編譯器報錯:
cannot convert a (type interface{}) to type string: need type as
sertion
此時,意味著整個轉化的過程需要類型斷言。
5.1 類型斷言
接口類型斷言的語法形式如下:
var i interface
i.(T)
i必須是接口變量,如果是具體類型變量,則編譯器會報non - interface type xxx on leftT可以是接口類型名,也可以是具體類型名。
那么這句代碼的含義就是斷言存儲在接口類型變量 i 中的值的類型為 T。
func main() {a := 1v := a.(int)fmt.Println(a)
}
報錯:
invalid type assertion: a.(int) (non-interface type int on left)
修改后的代碼:
func main() {a := 1v, ok := interface{}(a).(int) // 將 a 轉換為接口類型if ok {fmt.Printf("v type is %T\n", v)}fmt.Println(a)
}
在 Go 語言中,interface{} 代表空接口,任何類型都是它的實現類型。現在你只要知道,任何類型的值都可以很方便地被轉換成空接口的值就行了。
你可能會對這里的 {} 產生疑惑,為什么在關鍵字 interface 的右邊還要加上這個東西?
請記住,一對不包裹任何東西的花括號,除了可以代表空的代碼塊之外,還可以用于表示不包含任何內容的數據結構(或者說數據類型)。
比如你今后肯定會遇到的 struct{},它就代表了不包含任何字段和方法的、空的結構體類型。而空接口 interface{} 則代表了不包含任何方法定義的、空的接口類型。
當然了,對于一些集合類的數據類型來說,{} 還可以用來表示其值不包含任何元素,比如空的切片值 []string{},以及空的字典值 map[int]string{}。
接口查詢的兩層語義
- 如果
TypeNname是一個具體類型名,則類型斷言用于判斷接口變量i綁定的實例類型是否就是具體類型TypeNname。 - 如果
TypeName是一個接口類型名,則類型斷言用于判斷接口變量i綁定的實例類型是否同時實現了TypeName接口。
Go 中的 interface 類型是不能直接轉換成其他類型的,需要使用到斷言。
package mainfunc main() {var itf interface{} = 1i, ok := itf.(string)println("值:", i, "; 斷言結果", ok)j, ok := itf.(int)println("值:", j, "; 斷言結果", ok)
}
接口斷言的兩種語法表現:
5.1.1 直接賦值模式
o := i.(TypeName)
分析:
TypeName是具體類型名,此時如果接口i綁定的實例類型就是具體類型TypeName,則變量o的類型就是TypeName, 變量o的值就是接口綁定的實例值的副本(當然實例可能是指針值,那就是指針值的副本) 。TypeName是接口類型名, 如果接口i綁定的實例類型滿足接口類型TypeName,則變量o的類型就是接口類型TypeName,o底層綁定的具體類型實例是i綁定的實例的副本(當然實例可能是指針值,那就是指針值的副本〉。- 如果上述兩種情況都不滿足, 則程序拋出
panic。
示例代碼:
package mainimport "fmt"type Inter interface {Ping()Pang()
}type Anter interface {InterString()
}type St struct {Name string
}func (St) Ping() {println("ping")
}func (*St) Pang() {println("pang")
}func main() {st := &St{"abcd"}var i interface{} = st// 判斷i 綁定的實例是否實現了接口類型Intero := i.(Inter)o.Ping()o.Pang()/*如下語句會引發 panic ,因為 i 沒有實現接口Anterp := i.(Anter)p.String()*/// 判斷 i 綁定的實例是否就是具體類型 Sts := i.(*St)fmt.Printf("%s", s.Name)
}
由于可能出現 panic,所以我們并不推薦使用這種類型斷言的語法形式。
關于類型斷言,需要注意兩點:
- 如果
i是一個非接口值,那么必須在做類型斷言之前把它轉換為接口值。因為Go中的任何類型都是空接口類型的實現類型,所以一般會這樣做:interface{}(i).(TypeNname)。 - 如果類型斷言的結果為否,就意味著該類型斷言是失敗的,失敗的類型斷言會引發 panic(運行時異常),解決方法是:
var i1, ok := interface{}(i).(TypeNname)
其中 ok 值體現了類型斷言的成敗,如果成功,i1 就會是經過類型轉換后的 TypeNname 類型的值,否則它將會是 TypeNname 類型的零值(或稱為默認值)
func main() {a := "1"// var b intb, ok := interface{}(a).(int)if ok {fmt.Printf("b type is %v", b)} else {fmt.Printf("b is %v", b) // b is 0}
}
5.1.2 comma,ok 表達式模式
if o, ok := i.(TypeName); ok {
}
語義分析:
TypeName是具體類型名,此時如果接口i綁定的實例類型就是具體類型TypeName,則ok為true, 變量o的類型就是TypeName,變量o的值就是接口綁定的實例值的副本(當然實例可能是指針值,那就是指針值的副本) 。TypeName是接口類型名, 此時如果接口i綁定的實例的類型滿足接口類型TypeName, 則ok為true,變量o的類型就是接口類型TypeName,o底層綁定的具體類型實例是i綁定的實例的副本(當然實例可能是指針值,那就是指針值的副本)。- 如果上述兩個都不滿足,則
ok為false, 變量o是TypeName類型的“零值”,此種條件分支下程序邏輯不應該再去引用。因為此時的。沒有意義。
value, ok := a.(string)
總的來說:如果斷言失敗,那么 ok 的值將會是 false ,但是如果斷言成功 ok 的值將會是 true ,同時value 將會得到所期待的正確的值。
var a int64 = 13
var i interface{} = a
v1, ok := i.(int64)
fmt.Printf("v1=%d, the type of v1 is %T, ok=%t\n", v1, v1, ok) // v1=13, the type of v1 is int64, ok=true
v2, ok := i.(string)
fmt.Printf("v2=%s, the type of v2 is %T, ok=%t\n", v2, v2, ok) // v2=, the type of v2 is string, ok=false
v3 := i.(int64)
fmt.Printf("v3=%d, the type of v3 is %T\n", v3, v3) // v3=13, the type of v3 is int64
v4 := i.([]int) // panic: interface conversion: interface {} is int64, not []int
fmt.Printf("the type of v4 is %T\n", v4)
修改上述代碼:
func main() {st := &St{"abcd"}var i interface{} = st// 判斷i 綁定的實例是否實現了接口類型Interif o, ok := i.(Inter); ok {o.Ping()o.Pang()}// i 沒有實現接口 Anterif p, ok := i.(Anter); ok {p.String()}// 判斷 i 綁定的實例是否就是具體類型 Stif s, ok := i.(*St); ok {fmt.Printf("%s", s.Name)}
}
另外一個完整的示例如下:
package mainimport "fmt"/*
func funcName(a interface{}) string {
return string(a)
}
*/
func funcName(a interface{}) string {value, ok := a.(string)if !ok {fmt.Println("It is not ok for type string")return ""}fmt.Println("The value is ", value)return value
}
func main() {// str := "123"// funcName(str)//var a interface{}//var a string = "123"var a int = 10funcName(a)
}
5.2 類型查詢
類型查詢,就是根據變量,查詢這個變量的類型。為什么會有這樣的需求呢?
Go 中有一個特殊的類型 interface{},這個類型可以被任何類型的變量賦值,如果想要知道到底是哪個類型的變量賦值給了 interface{} 類型變量,就需要使用類型查詢來解決這個需求,示例代碼如下:
func main() {var x interface{} = 13switch x.(type) {case nil:println("x is nil")case int:println("the type of x is int")case string:println("the type of x is string")case bool:println("the type of x is string")default:println("don't support the type")}
}
輸出結果:
the type of x is int
不過,通過 x.(type),我們除了可以獲得變量 x 的動態類型信息之外,也能獲得其動態類型對應的值信息,現在我們把上面的例子改造一下:
func main() {var x interface{} = 13switch v := x.(type) {case nil:println("v is nil")case int:println("the type of v is int, v =", v)case string:println("the type of v is string, v =", v)case bool:println("the type of v is bool, v =", v)default:println("don't support the type")}
}
這里我們將 switch 后面的表達式由 x.(type) 換成了 v := x.(type) 。對于后者,你千萬不要認為變量 v 存儲的是類型信息,其實 v 存儲的是變量 x 的動態類型對應的值信息,這樣我們在接下來的 case 執行路徑中就可以使用變量 v中的值信息了。
輸出結果
the type of v is int, v = 13
package mainimport ("fmt"
)func main() {// 定義一個interface{}類型變量,并使用string類型值”abc“初始化var a interface{} = "abc"// 在switch中使用 變量名.(type) 查詢變量是由哪個類型數據賦值。switch v := a.(type) {case string:fmt.Println("字符串")case int:fmt.Println("整型")default:fmt.Println("其他類型", v)}
}
如果使用 .(type) 查詢類型的變量不是 interface{} 類型,則在編譯時會報如下錯誤:
cannot type switch on non-interface value a (type string)
如果在 switch 以外地方使用 .(type),則在編譯時會提示如下錯誤:
use of .(type) outside type switch
所以,使用 type 進行類型查詢時,只能在 switch 中使用,且使用類型查詢的變量類型必須是 interface{}。
接口類型查詢的語法格式如下:
switch v := i.(type) {case typel :xx xxcase type2 :xx xxdefault :xx xx
}
類型查詢和類型斷言
- 類型查詢和類型斷言具有相同的語義,只是語法格式不同。二者都能判斷接口變量綁定的實例的具體類型,以及判斷接口變量綁定的實例是否滿足另一個接口類型。
- 類型查詢使用
case字句一次判斷多個類型,類型斷言一次只能判斷一個類型,當然類型斷言也可以使用if-else-if語句達到同樣的效果。
示例如下:
func main() {var t interface{}t = functionOfSomeType()switch t := t.(type) {default:fmt.Printf("unexpected type %T", t) // %T prints whatever type t hascase bool:fmt.Printf("boolean %t\n", t) // t has type boolcase int:fmt.Printf("integer %d\n", t) // t has type intcase *bool:fmt.Printf("pointer to boolean %t\n", *t) // t has type *boolcase *int:fmt.Printf("pointer to integer %d\n", *t) // t has type *int}
}
或者使用 if-else-if 代替
func sqlQuote(x interface{}) string {if x == nil {return "NULL"} else if _, ok := x.(int); ok {return fmt.Sprintf("%d", x)} else if _, ok := x.(uint); ok {return fmt.Sprintf("%d", x)} else if b, ok := x.(bool); ok {if b {return "TRUE"}return "FALSE"} else if s, ok := x.(string); ok {return sqlQuoteString(s) // (not shown)} else {panic(fmt.Sprintf("unexpected type %T: %v", x, x))}
}
5.3 類型斷言和查詢總結
package mainimport ("fmt"
)var container = []string{"aaa", "bbb", "ccc"}func main() {container := map[string]string{"a": "aaa", "b": "bbb", "c": "ccc"}// 方式1。類型斷言_, ok1 := interface{}(container).([]string)_, ok2 := interface{}(container).(map[string]string)// %T 表示該值的 Go 類型if !(ok1 || ok2) {fmt.Printf("Error: unsupported container type: %T\n", container)return}fmt.Printf("The element is %#v , (container type: %T)\n", container["a"], container)// 方式2。elem, err := getElement(container)if err != nil {fmt.Printf("Error: %s\n", err)return}fmt.Printf("The element is %#v , (container type: %T)\n", elem, container)
}// 空接口包含所有的類型,輸入的參數均會被轉換為空接口
// 函數入參已經聲明 containerI 為 interface 類型,所以不需要再次進行 interface{}(container) 轉換
func getElement(containerI interface{}) (elem string, err error) {// 變量類型會被保存在t中// 方式2。類型查詢switch t := containerI.(type) {case []string:elem = t[1]case map[string]string:elem = t["a"]default:err = fmt.Errorf("unsupported container type: %T", containerI)return}return
}
6. 空接口
如果一個接口類型定義中沒有一個方法,那么它的方法集合就為空,比如下面的 EmptyInterface 接口類型:
type EmptyInterface interface {}
這個方法集合為空的接口類型就被稱為空接口類型,但通常我們不需要自己顯式定義這類空接口類型,我們直接使用 interface{} 這個類型字面值作為所有空接口類型的代表就可以了。
Go 語言沒有泛型, 如果一個函數需要接收任意類型的參數, 則參數類型可以使用空接口類型,空接口不是真的為空,接口有類型和值兩個概念。
package mainimport "fmt"type Inter interface {Ping()Pang()
}type St struct{}func (St) Ping() {println("ping")
}func (*St) Pang() {println("pang")
}func main() {var st *St = nilvar it Inter = stfmt.Printf("%p\n", st) // 0x0fmt.Printf("%p\n", it) // 0x0if it != nil {it.Pang() // pang// 下面的語句會導致panic// 方法轉換為函數調用,第一個參數是St 類型,由于 *St 是nil ,無法獲取指針所指的對象值,所以導致panic// it.Ping()}
}
這個程序暴露出 Go 語言的一點瑕疵, fmt.Printf("%p\n", it) 的結果是 0x0 ,但 it! = nil 的判斷結果卻是 true 。
空接口有兩個字段, 一個是實例類型, 另一個是指向綁定實例的指針,只有兩個都為 nil 時,空接口才為 nil 。
Go 規定:如果一個類型 T 的方法集合是某接口類型 I 的方法集合的等價集合或超集,我們就說類型 T 實現了接口類型 I,那么類型 T 的變量就可以作為合法的右值賦值給接口類型 I 的變量。
如果一個變量的類型是空接口類型,由于空接口類型的方法集合為空,這就意味著任何類型都實現了空接口的方法集合,所以我們可以將任何類型的值作為右值,賦值給空接口類型的變量,
var any interface{}
any = true
any = 12.34
any = "hello"
any = map[string]int{"one": 1}
any = new(bytes.Buffer)// orvar i interface{} = 15 // ok
i = "hello, golang" // ok
type T struct{}
var t T
i = t // ok
i = &t // ok
參考書籍:
- Go 語言核心編程
- Go 語言圣經
總結
以上是生活随笔為你收集整理的Go 学习笔记(35)— Go 接口 interface (接口声明、接口初始化、接口方法调用、接口运算、类型断言、类型查询、空接口)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 试管婴儿怀双胞胎的几率高吗
- 下一篇: 久旱逢甘露作者是谁啊?