云原生系列Go語言篇-類型虹茶、方法和接口

本文來自正在規(guī)劃的Go語言&云原生自我提升系列疑故,歡迎關(guān)注后續(xù)文章。

通過前面章節(jié)的學(xué)習(xí)俏讹,我們知道Go是一種靜態(tài)類型語言当宴,包含有內(nèi)置類型和用戶定義類型。和大部分現(xiàn)代編程語言一樣泽疆,Go允許我們對類型關(guān)聯(lián)方法户矢。它也具備類型抽象,可以編寫沒有顯式實現(xiàn)的方法殉疼。

然而梯浪,Go處理方法、接口和類型的方式與現(xiàn)行大部分其它語言大相徑庭瓢娜。Go的設(shè)計者鼓勵軟件開發(fā)者所提倡的最佳實踐挂洛,避免繼承眠砾、鼓勵組合抹锄。本章中我們會學(xué)習(xí)類型、方法和接口荠藤,了解如何使用它們來構(gòu)建可測試伙单、易維護的程序。

Go的類型

復(fù)合類型一章中我們學(xué)習(xí)過如何定義結(jié)構(gòu)體類型:

type Person struct {
    FirstName string
    LastName string
    Age int
}

讀作聲明名為Person的用戶自定義類型哈肖,緊接著的是結(jié)構(gòu)體字面量的底層類型吻育。除結(jié)構(gòu)體字面量為,也可以使用任意原始類型或復(fù)合類型來定義一個類型淤井。舉例如下:

type Score int
type Converter func(string)Score
type TeamScores map[string]Score

Go語言允許我們在包以下的塊級聲明類型布疼。但僅能在其作用域內(nèi)訪問該類型。唯一的特例是導(dǎo)出的包級類型币狠。我們在模塊游两、包和導(dǎo)入一章中會深入討論。

注:為更易于討論類型漩绵,我們先做一些名詞解釋贱案。抽象類型abstract type)指定類型的功能,但不包含如何實現(xiàn)止吐。具象類型concrete type)指定了做什么以及如何做宝踪。也就是說它存在存儲數(shù)據(jù)的方式并且提供了對該類型所聲明的所有方法的實現(xiàn)。雖然在Go中都是抽象或具象的碍扔,但有些語言是允許混合類型的瘩燥,比如Java中帶默認方法的抽象類或接口。

方法

和大部分現(xiàn)代語言一樣不同,Go支持對用戶定義類型添加方法厉膀。

類型的方法在包級定義:

type Person struct {
    FirstName string
    LastName string
    Age int
}

func (p Person) String() string {
    return fmt.Sprintf("%s %s, age %d", p.FirstName, p.LastName, p.Age)
}

方法聲明和函數(shù)聲明一樣,只是加了一個接收器(receiver)說明二拐。接收器位于func關(guān)鍵字和方法名之間服鹅。和所有其它變量聲明一樣,接收器名稱位于類型之前卓鹿。按慣例菱魔,接收器名稱為類型名的縮寫,通常是其第一個字母吟孙。使用thisself被視為不地道的做法澜倦。

和函數(shù)一樣,方法名稱不能重載杰妓。對不同類型可使用相同的方法名藻治,但對相同類型的不同方法不能使用相同名稱。從帶方法重載的編程語言轉(zhuǎn)過來的會覺得這存在局限巷挥,但不復(fù)用名稱是Go哲學(xué)的一部分桩卵,以使代碼保持清晰。

我們會在模塊、包和導(dǎo)入一章中討論包雏节,注意方法必須其關(guān)聯(lián)類型的同一包中聲明胜嗓,Go不允許對不由你控制的類型添加方法。雖然可以在相同包下的不同文件中的按類型聲明定義方法钩乍,最好是把類型定義和關(guān)聯(lián)方法放在一起以便更容易跟進實現(xiàn)辞州。

方法調(diào)用對于熟悉其它語言的開發(fā)者應(yīng)當不會陌生:

p := Person {
    FirstName: "Fred",
    LastName:"Fredson",
    Age: 52,
}
output := p.String()

指針接收器和值接收器

指針一章中講到了,Go使用指針類型的參數(shù)表示函數(shù)中可能會修改參數(shù)寥粹。對于方法接收器也同樣適用变过。存在指針接收器(類型為指針)或值接收器(類型為值類型)。通過以下規(guī)則可協(xié)助決定使用哪種接收器:

  • 如果方法會修改接收器涝涤,則必須使用指針接收器媚狰。
  • 如果方法需要處理nil實例(參見為nil實例編寫方法一節(jié)),則必須使用指針接收器阔拳。
  • 如果方法不修改接收器崭孤,則可使用值接收器。

對于不修改接收器的方法是否使用值接收器取決于該類型上所聲明的其它方法衫生。只要該類型有一個指針接收器的方法裳瘪,通常會保持連續(xù)性對所有方法都使用指針接收器,不管具體的方法是否修改接收器罪针。

下面有一些簡單代碼演示指針和值接收器彭羹。我們先看一個帶兩個方法的類型,一個使用值接收器泪酱,另一個使用指針接收器:

type Counter struct {
    total             int
    lastUpdated time.Time
}

func (c *Counter) Increment() {
    c.total++
    c.lastUpdated = time.Now()
}

func (c Counter) String() string {
    return fmt.Sprintf("total: %d, last updated: %v", c.total, c.lastUpdated)
}

可以使用如下代碼測試這些方法派殷。讀者可在The Go Playground運行這段代碼:

var c Counter
fmt.Println(c.String())
c.Increment()
fmt.Println(c.String())

得到的輸出結(jié)果如下:

total: 0, last updated: 0001-01-01 00:00:00 +0000 UTC
total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC m=+0.000000001

讀者可能注意到了即使c是值類型也能調(diào)用指針接收器方法。在通過值類型本地變量使用指針接收器時墓阀,Go會自動將其轉(zhuǎn)化為指針類型毡惜,c.Increment()被相應(yīng)地轉(zhuǎn)化為了(&c).Increment()

但對函數(shù)傳值的規(guī)則不變斯撮。如果對變量傳遞值類型经伙,再通過傳入值調(diào)用指針接收器方法,會使用拷貝來調(diào)用方法勿锅∨聊ぃ可在The Go Playground中調(diào)試如下代碼:

func doUpdateWrong(c Counter) {
    c.Increment()
    fmt.Println("in doUpdateWrong:", c.String())
}

func doUpdateRight(c *Counter) {
    c.Increment()
    fmt.Println("in doUpdateRight:", c.String())
}

func main() {
    var c Counter
    doUpdateWrong(c)
    fmt.Println("in main:", c.String())
    doUpdateRight(&c)
    fmt.Println("in main:", c.String())
}

運行代碼輸出結(jié)果如下:

in doUpdateWrong: total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC
    m=+0.000000001
in main: total: 0, last updated: 0001-01-01 00:00:00 +0000 UTC
in doUpdateRight: total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC
    m=+0.000000001
in main: total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC m=+0.000000001

doUpdateRight的參數(shù)為*Counter類型,這是一個指針實例溢十】迳玻可以看到對其可調(diào)用IncrementString方法。Go中指針接收器和值接收器都被看作是指針實例的方法集张弛。對值實例荒典,只有值接收器方法才在方法集中±医伲現(xiàn)在聽上去有點繞,但討論接口時我們還會回看這一概念寺董。

最后一點覆糟,不要為Go結(jié)構(gòu)體編寫getter和setter方法,除非是實現(xiàn)接口需要(我們會在接口快速教程一節(jié)中講到接口)螃征。Go語言鼓勵直接訪問字段搪桂。把方法留給業(yè)務(wù)邏輯。在想要通過單次操作更新多個字段或更新不是直接賦新值的情況例外盯滚。前面定義的Increment方法演示了這兩種情況。

為nil實例編寫方法

剛剛講到了指針實例酗电,讀者可能會想如果對nil實例調(diào)用方法會出現(xiàn)什么情況魄藕。對于大部分編程語言,這都會導(dǎo)致報錯撵术。(Objective-C允許對nil實例調(diào)用方法背率,但什么也不會做。)

Go則有些不一樣嫩与。它會嘗試調(diào)用這一方法寝姿。如果方法帶值接收器,會panic(我們在錯誤處理一章的panic和recover中會討論)划滋,原因是該指針沒有指向的值饵筑。但如查方法帶的是指針接收器,如果編寫了方法處理nil實例則可正常執(zhí)行处坪。

在部分情況下根资,nil接收器會讓代碼簡化。下面是二叉樹的一個實現(xiàn)同窘,使用了nil作為接收器:

type IntTree struct {
    val         int
    left, right *IntTree
}

func (it *IntTree) Insert(val int) *IntTree {
    if it == nil {
        return &IntTree{val: val}
    }
    if val < it.val {
        it.left = it.left.Insert(val)
    } else if val > it.val {
        it.right = it.right.Insert(val)
    }
    return it
}

func (it *IntTree) Contains(val int) bool {
    switch {
    case it == nil:
        return false
    case val < it.val:
        return it.left.Contains(val)
    case val > it.val:
        return it.right.Contains(val)
    default:
        return true
    }
}

注:Contains方法不修改*IntTree玄帕,但使用指針接收品進行了聲明。這是為了演示前面所講的對nil接收的支持想邦。帶值接收器的方法無法檢測nil裤纹,在出現(xiàn)了nil接收器時會像前面說的那樣panic。

下面是使用這個二叉樹的代碼丧没∮ソ罚可在The Go Playground中執(zhí)行:

func main() {
    var it *IntTree
    it = it.Insert(5)
    it = it.Insert(3)
    it = it.Insert(10)
    it = it.Insert(2)
    fmt.Println(it.Contains(2))  // true
    fmt.Println(it.Contains(12)) // false
}

Go支持對nil接收器調(diào)用方法是非常聰明的做法,在某些場景也非常有用骂铁,比如二叉樹節(jié)點的示例吹零。但大部分時候用處沒有那么大。指針接收器類似于函數(shù)指針參數(shù)拉庵,是傳入方法中的指針拷貝灿椅。就像傳入函數(shù)中的nil參數(shù),如果修改了指針副本,并不會改變原始值茫蛹。也就是說不能編寫指針接收器方法處理nil讓原始指針變?yōu)榉?code>nil操刀。如方法帶指針接收器又不支持nil,請進行nil檢測并返回錯誤(在錯誤處理一章討論錯誤)婴洼。

方法也是函數(shù)

Go語言中的方法和函數(shù)很像骨坑,可以在有函數(shù)類型變量或參數(shù)時隨時可用方法替換。

下面看一個簡單示例:

type Adder struct {
    start int
}

func (a Adder) AddTo(val int) int {
    return a.start + val
}

我們按通常的方式創(chuàng)建一個該類型的實例并調(diào)用其方法:

myAdder := Adder{start: 10}
fmt.Println(myAdder.AddTo(5)) // prints 15

也可以將方法賦給變量或傳給func(int)int類型的參數(shù)柬采。這稱為方法值(method value):

f1 := myAdder.AddTo
fmt.Println(f1(10))           // prints 20

方法值和閉包有點像欢唾,因其可訪問所創(chuàng)建實例中的字段值。

可通過該類型本身創(chuàng)建一個函數(shù)粉捻。這稱為方法表達式:

f2 := Adder.AddTo
fmt.Println(f2(myAdder, 15))  // prints 25

在方法表達式中第一個參數(shù)是方法的接收器礁遣,函數(shù)簽名為func(Adder, int) int

方法值和方法表達式不是對特殊情況的靈光乍現(xiàn)肩刃。我們會在隱式接口讓依賴注入更簡單一節(jié)中學(xué)習(xí)到如何依賴注入時使用它們祟霍。

函數(shù) vs. 方法

因為可以將方法用作函數(shù),讀者可能會想什么時候用方法盈包、什么時候用函數(shù)沸呐。

區(qū)別在于函數(shù)是否依賴于其它數(shù)據(jù)。我們已多次提到呢燥,包級狀態(tài)應(yīng)是不可變的崭添。在邏輯中出現(xiàn)值是在啟用時配置并在執(zhí)行過程中發(fā)生改變時,這些值應(yīng)放到結(jié)構(gòu)體中疮茄,這段邏輯應(yīng)使用方法進行實現(xiàn)滥朱。如果邏輯只依賴于入?yún)ⅲ瑒t應(yīng)使用函數(shù)力试。

類型徙邻、包、模塊畸裳、測試和依賴注入是相互關(guān)聯(lián)的概念缰犁。本章稍后會講到依賴注入。有關(guān)包和模塊參見模塊怖糊、包和導(dǎo)入一章帅容,測試參見編寫測試一章。

類型聲明不是繼承

除了根據(jù)內(nèi)置Go類型和結(jié)構(gòu)體字面量聲明類型外伍伤,也可以根據(jù)另一個自定義類型聲明用戶自定義類型:

type HighScore Score
type Employee Person

很多概念會被看成是面向?qū)ο蟛⑴牵绕涫抢^承。這時父類型中聲明的方法和狀態(tài)可在子類型中使用扰魂,也可使用子類型的值來替換父類型麦乞。(那些計算機科學(xué)家的讀者蕴茴,我知道子類型不是繼承。但大部分編程語言使用繼承來實現(xiàn)子類型姐直,所以在日常使用中經(jīng)常會混為一談倦淀。)

根據(jù)另一種類型聲明類型看起來像是繼承,但并不是声畏。這兩種類型有共同的底層類型撞叽,但僅此而已。這些類型沒有等級之分插龄。在具有繼承的語言中愿棋,子實例可用在任何使用父級實例的地方。但在Go語言中并不是這樣辫狼。不能將HighScore類型的實例賦給Score類型的變量初斑,反過來也是,除非進行類型轉(zhuǎn)換膨处,也不能在沒有做類型轉(zhuǎn)換的情況下將它們賦值給int類型的變量。此外砂竖,為Score所定義的方法并沒在HighScore上進行定義:

// 使用無類型常量賦值沒有問題
var i int = 300
var s Score = 100
var hs HighScore = 200
hs = s                  // compilation error!
s = i                   // compilation error!
s = Score(i)            // ok
hs = HighScore(s)       // ok

對于底層類型為內(nèi)置類型的自定義類型真椿,可以使用這些內(nèi)置類型的運算符。上例中可以看到乎澄,可對它們賦與底層類型兼容的字面量以及常量突硝。

小貼士:對底層類型相同的類型做類型轉(zhuǎn)化會保留同樣的底層存儲,但關(guān)聯(lián)的方法不同置济。

類型是可執(zhí)行文檔

雖然都清楚應(yīng)聲明結(jié)構(gòu)體類型來存儲一組關(guān)聯(lián)數(shù)據(jù)解恰,但何時聲明基于內(nèi)置類型或其它自定義類型的自定義類型就不那么清楚了。簡單的回答是類型即文檔浙于。通過為一個概念提供名稱并描述所需數(shù)據(jù)類型讓代碼更清晰护盈。對方法傳入Percentage類型的參數(shù)會比int類型讓讀代碼的人更清楚用途,這樣在調(diào)用時就不太可能傳入無效值羞酗。

這一邏輯同樣適用基于另一個自定義類型聲明新的自定義類型腐宋。在底層數(shù)據(jù)相同,但所執(zhí)行的操作不同時檀轨,使用兩種類型胸竞。基于另一個進行聲明會避免重復(fù)并且也會讓人清楚這兩種類型是相關(guān)聯(lián)的参萄。

ioto(有時)用于枚舉

很多編程語言都有枚舉的概念卫枝,可用于指定具有一組有限值的類型。Go語言沒有枚舉類型讹挎。它有一個iota校赤,可用于對一組常量賦遞增的值吆玖。

注:iota的概念來自于APL語言(A Programming Language的簡寫)。APL嚴重依賴于自己的標記法痒谴,因此要求電腦使用特制鍵盤衰伯。比如(~R?R°.×R)/R←1↓ιR是一段APL程序,用于查找變量R的值之內(nèi)的質(zhì)數(shù)积蔚。

對于Go這種關(guān)注可讀性的語言意鲸,從一種將極簡發(fā)揮到病態(tài)的語言中借用概念可能看起來很諷刺,但這正是我們應(yīng)該學(xué)習(xí)不同編程語言的原因:靈感無處不在尽爆。

在使用iota時怎顾,最佳實踐是基于int定義一個用于表示所有有效值的類型:

type MailCategory int

接著使用const代碼塊來定義一組該類型的值:

const (
    Uncategorized MailCategory = iota
    Personal
    Spam
    Social
    Advertisements
)

const代碼塊中的第一個常量指定了類型并將值設(shè)置為iota。隨后的各行既沒有指定類型也沒有賦值漱贱。Go編譯器遇到這種情況時槐雾,會為隨后的 所有變量賦值,每一行中對iota做遞增幅狮。也就是對第一個常量(Uncategorized)賦值0募强,第二個常量(Personal)賦值1,以此類推崇摄。在新的const代碼中擎值,iota又重新設(shè)置為0.

下面是作者見過的有關(guān)iota最好的建議:

不要使用iota定義(在各處)顯式定義了值的常量。例如逐抑,來實現(xiàn)某部分規(guī)格而其中又明確哪個常量的值為多少時鸠儿,應(yīng)當顯式地寫下常量值。僅將iota用作“內(nèi)部”使用厕氨。換句話說进每,通過名稱而不是值進行引用的常量。這樣可以享受在任何時間或列表的任意位置插入新變量的好處,而又不會產(chǎn)生任何風(fēng)險。

Danny van Heumen

需要知道Go不會阻止你(或其他人)為自定義類型創(chuàng)建其它值稀余。此外如果在字面量列表中間插入新的標識符,所有后續(xù)的都會重新編號肉瓦。如這些常量表示其它系統(tǒng)或數(shù)據(jù)庫中的值時可能會讓程序出現(xiàn)不易察覺的問題。由于存在這兩個限制胃惜,基于iota的枚舉僅在希望區(qū)分一組值而不在意背后的值時才有意義泞莉。如果實際的值很重要,則應(yīng)顯式定義船殉。

警告:因可對常量賦字面量表達式鲫趁,你可能會看到如下這種建議使用iota的示例代碼:

type BitField int

const (
    Field1 BitField = 1 << iota // assigned 1
    Field2                      // assigned 2
    Field3                      // assigned 4
    Field4                      // assigned 8
)

雖然很聰明,但在使用這種模式時要小心利虫。如果這么做挨厚,應(yīng)寫好注釋堡僻。前面提到過,如果在意值的話使用iota可能會易錯疫剃。你一定不希望未來的維護者在列表中間插入一個常量钉疫,導(dǎo)致代碼崩潰。

注意iota是從0開始巢价。如果使用一組常量表示不同一配置狀態(tài)牲阁,零值可能會有用。在前面的MailCategory類型中就是這樣壤躲。郵件到達時為未分類城菊,因此零值正好適用。如果對于常量沒有能自圓其說的默認值碉克,通常的做法是將常量代碼塊中的第一個iota賦值給_凌唬,它表示值無效。這樣更容易發(fā)現(xiàn)未正常初始化的變量漏麦。

使用內(nèi)嵌實現(xiàn)組合

軟件工程關(guān)于“組合優(yōu)于繼承”的建議可追溯到1994年的由Gamma客税、Helm、Johnson和Vlissides所著《設(shè)計模式》一書(艾迪生-韋斯利出版社)撕贞,他們還有一個響當當?shù)拿朑ang of Four(或 GoF)霎挟。Go語言中沒有繼承,鼓勵通過內(nèi)置的組合和改進實現(xiàn)代碼復(fù)用:

type Employee struct {
    Name         string
    ID           string
}

func (e Employee) Description() string {
    return fmt.Sprintf("%s (%s)", e.Name, e.ID)
}

type Manager struct {
    Employee
    Reports []Employee
}

func (m Manager) FindNewEmployees() []Employee {
    // do business logic
}

注意Manager中包含一個Employee類型字段麻掸,但沒有給該字段命名。這樣Employee就成為了內(nèi)嵌字段赐纱。內(nèi)嵌字段中聲明的字段或方法在包含它的結(jié)構(gòu)體中可直接進行調(diào)用脊奋。這樣下面就是合法代碼;

m := Manager{
    Employee: Employee{
        Name:         "Bob Bobson",
        ID:             "12345",
    },
    Reports: []Employee{},
}
fmt.Println(m.ID)            // prints 12345
fmt.Println(m.Description()) // prints Bob Bobson (12345)

注:在結(jié)構(gòu)體中不止可以內(nèi)嵌結(jié)構(gòu)體,還可以嵌套其它任意類型疙描。這樣會將嵌套類型的方法上發(fā)給外層結(jié)構(gòu)體诚隙。

如果外層結(jié)構(gòu)體中有同名字段或方法,需要使用嵌套字段類型來調(diào)用被隱藏的方法起胰。比如有如下的類型:

type Inner struct {
    X int
}

type Outer struct {
    Inner
    X int
}

只能顯式地指定Inner才能訪問Inner內(nèi)的X

o := Outer{
    Inner: Inner{
        X: 10,
    },
    X: 20,
}
fmt.Println(o.X)       // prints 20
fmt.Println(o.Inner.X) // prints 10

嵌套不是繼承

編程語言中內(nèi)置嵌套是很罕見的(作者并不了解有支持它的知名語言)久又。很多熟知繼承(在很多語言中都支持)的開發(fā)者會按照繼承來理解嵌套。這背后是坑效五。并不能將Manager類型的變量賦值給Employee類型的變量地消。如果要訪問Manager中的Employee字段,必須顯式指定畏妖÷鲋矗可在The Go Playground中運行如下代碼:

var eFail Employee = m        // compilation error!
var eOK Employee = m.Employee // ok!

得到的錯誤如下:

cannot use m (type Manager) as type Employee in assignment

此外,Go對具象類型沒有動態(tài)調(diào)度(dynamic dispatch)的支持戒劫。嵌套字段的方法并不知道它是嵌套的半夷。如果嵌套字段方法調(diào)用了該字段的另一個方法婆廊,而恰巧外層結(jié)構(gòu)體具有同名方法,嵌套字段的方法并不會調(diào)用外層結(jié)構(gòu)中的方法巫橄。我們在如下代碼中進行了演示淘邻,請在The Go Playground中運行:

type Inner struct {
    A int
}

func (i Inner) IntPrinter(val int) string {
    return fmt.Sprintf("Inner: %d", val)
}

func (i Inner) Double() string {
    return i.IntPrinter(i.A * 2)
}

type Outer struct {
    Inner
    S string
}

func (o Outer) IntPrinter(val int) string {
    return fmt.Sprintf("Outer: %d", val)
}

func main() {
    o := Outer{
        Inner: Inner{
            A: 10,
        },
        S: "Hello",
    }
    fmt.Println(o.Double())
}

運行以上代碼的輸出如下:

Inner: 20

雖然在具象類型中嵌套另一種具象類型不能將外層類型當成內(nèi)層類型處理,嵌套字段方法卻能成為外層結(jié)構(gòu)體的方法集湘换。這樣外層結(jié)構(gòu)體就可以實現(xiàn)接口了宾舅。
本文來自正在規(guī)劃的Go語言&云原生自我提升系列,歡迎關(guān)注后續(xù)文章枚尼。

接口快速教程

雖然Go并發(fā)(在并發(fā)一章講解)是聚光燈下的寵兒贴浙,便Go設(shè)計中真正的明星是其隱式接口,也是Go中唯一的抽象類型署恍。下面就來學(xué)習(xí)它的偉大之處崎溃。

我們先快速學(xué)習(xí)如何聲明接口。接口的內(nèi)在很簡單盯质。和其它自定義類型一樣袁串,可以使用type關(guān)鍵字進行定義。

以下是fmt包中Stringer接口的定義:

type Stringer interface {
    String() string
}

在接口聲明中呼巷,接口字面量(interface)位于接口類型之后囱修。其中包含具象類型實現(xiàn)接口所必須定義的方法。接口中定義的方法稱為接口的方法集王悍。

和其它類型一樣破镰,接口可在任意作用域中聲明。

接口通常以er結(jié)尾压储。像剛剛看到的fmt.Stringer鲜漩,但還有很多,比如io.Reader集惋、io.Closer孕似、io.ReadCloserjson.Marshalerhttp.Handler刮刑。

接口是類型安全的鴨子類型

至此的內(nèi)容和其它語言中的接口還沒有什么分別喉祭。Go接口的特別之處在于其隱式實現(xiàn)。具象類型不聲明它實現(xiàn)了某個接口雷绢。如果具象類型的方法集包含了某個接口方法集中的所有方法泛烙,則具象類型實現(xiàn)了該接口。也就是說具象類型可以賦值給聲明為接口類型的變量或字段习寸。

這一隱式行為將接口變?yōu)镚o語言中最有魅力的類型胶惰,因為它同時保障了類型安全和解耦,橋接了靜態(tài)語言和動態(tài)語言的功能霞溪。

要理解背后的原因孵滞,就要先討論編程語言中為什么有接口中捆。前面提到《設(shè)計模式》教育開發(fā)者組合優(yōu)于繼承。該書中的另一條建議是“面向接口而非實現(xiàn)坊饶⌒刮保”這樣做就依賴于行為而不是實現(xiàn),在需要時可以切換實現(xiàn)匿级。代碼可以不段演進蟋滴,因為需求總是在變。

Python痘绎、Ruby和JavaScript這樣的動態(tài)語言沒有接口津函。這些語言的開發(fā)者使用“鴨子類型”,這基于一個表達“如果像鴨子一樣走也像鴨子一樣叫孤页,那么就是鴨子尔苦。”概念就是只函數(shù)能找到調(diào)用方法就可以對其使用某一類型的實例傳參:

class Logic:
    def process(self, data):
        # business logic
    
def program(logic):
    # get data from somewhere
    logic.process(data)
    
logicToUse = Logic()
program(logicToUse)

鴨子類型一開始聽起來可能很怪行施,但它已成功用于構(gòu)建大型系統(tǒng)了允坚。如果你使用靜態(tài)類型語言編程,這聽起來簡直離經(jīng)叛道蛾号。不指定顯式類型稠项,很難知道會有什么樣的功能。因為新開發(fā)者接收項目或是老開發(fā)忘了代碼的功能鲜结,就要查代碼確定真正的依賴展运。

Java開發(fā)者使用另一種模式。他們定義接口精刷,創(chuàng)建該接口的實現(xiàn)乐疆,但僅在客戶端代碼中引用接口:

public interface Logic {
    String process(String data);
}

public class LogicImpl implements Logic {
    public String process(String data) {
        // business logic
    }
}

public class Client {
    private final Logic logic;
    // this type is the interface, not the implementation

    public Client(Logic logic) {
        this.logic = logic;
    }

    public void program() {
        // get data from somewhere
        this.logic.process(data);
    }
}

public static void main(String[] args) {
    Logic logic = new LogicImpl();
    Client client = new Client(logic);
    client.program();
}

動態(tài)語言開發(fā)者看到Java中的顯式接口會納悶在有顯式依賴時如何在未來重構(gòu)代碼。切換到不同邏輯的新實現(xiàn)意味著要按新的接口來重寫代碼贬养。

Go語言的開發(fā)者覺得兩種都沒有錯。如果應(yīng)用要不斷增長和變化琴庵,需要有靈活性來改變實現(xiàn)误算。但為了讓人們理解代碼的功能(因為會有新人來維護同樣的代碼),需要指明代碼的依賴迷殿。這便出現(xiàn)了隱式接口儿礼。Go是上述兩種方式的合體:

type LogicProvider struct {}

func (lp LogicProvider) Process(data string) string {
    // business logic
}

type Logic interface {
    Process(data string) string
}

type Client struct{
    L Logic
}

func(c Client) Program() {
    // get data from somewhere
    c.L.Process(data)
}

main() {
    c := Client{
        L: LogicProvider{},
    }
    c.Program()
}

在這段Go代碼中,有一個接口庆寺,但僅有調(diào)用者(Client)知道蚊夫,LogicProvider中沒有進行任何聲明來表示它實現(xiàn)了該接口。這足以為未來新邏輯的編寫者提供可執(zhí)行文檔來確保傳入client的類型符合其要求懦尝。

小貼士:接口指定了調(diào)用者之需知纷∪榔裕客戶端代碼定義接口指定其所需的功能。

這不是說接口無法共享琅轧。我們已經(jīng)看到在標準庫中有多個接口用于輸入和輸出伍绳。標準接口很強大,如果編寫代碼使用io.Readerio.Writer乍桂,不論是寫入本地磁盤的文件還是在內(nèi)存中寫值都可正常操作冲杀。

此外,使用標準接口鼓勵采用裝飾器模式睹酌。Go中隨處可見接收接口實例并返回實現(xiàn)相同接口其它類型的工廠函數(shù)权谁。例如,定義了如下的函數(shù):

func process(r io.Reader) error

可以使用如下代碼處理文件中的數(shù)據(jù):

r, err := os.Open(fileName)
if err != nil {
    return err
}
defer r.Close()
return process(r)

os.Open返回的os.File實例符合io.Reader接口憋沿,可在任何代碼中用于讀取數(shù)據(jù)旺芽。如果為gzip壓縮文件,可以將io.Reader封裝在另一個io.Reader中:

r, err := os.Open(fileName)
if err != nil {
    return err
}
defer r.Close()
gz, err = gzip.NewReader(r)
if err != nil {
    return err
}
defer gz.Close()
return process(gz)

這時讀取非壓縮文件同樣的代碼可用于讀取壓縮文件卤妒。

小貼士:如果標準庫中的接口描述的是你代碼所需的甥绿,那么就使用它!

實現(xiàn)了某個接口的類型完全可以指定不屬于接口的其它方法则披。一組客戶端代碼可能用不到它們共缕,但另一組可能就需要了。例如士复,io.File類型同時實現(xiàn)了io.Writer接口图谷。如果你的代碼只需要讀取文件,使用io.Reader接口來引用文件實例阱洪,忽略其它方法便贵。

嵌套和接口

就像可以在結(jié)構(gòu)體中嵌套類型一樣,也可以在接口中嵌套接口冗荸。例如承璃,io.ReadCloser接口由io.Readerio.Closer組成:

type Reader interface {
        Read(p []byte) (n int, err error)
}

type Closer interface {
        Close() error
}

type ReadCloser interface {
        Reader
        Closer
}

注:就像我們可以在結(jié)構(gòu)體中嵌套具象類型一樣,也可以在結(jié)構(gòu)體中嵌套接口蚌本。我們在編寫測試一章的Go語言的Stub一節(jié)會看到用法盔粹。

接收接口,返回結(jié)構(gòu)體

經(jīng)常會聽到Go語言老開發(fā)說代碼應(yīng)該“接收接口程癌,返回結(jié)構(gòu)體舷嗡。”意思是函數(shù)所調(diào)用的業(yè)務(wù)邏輯應(yīng)由接口觸發(fā)嵌莉,但函數(shù)的輸出應(yīng)為具象類型进萄。多們已經(jīng)講解過為什么函數(shù)應(yīng)接收接口:接口會讓代碼更靈活并顯式聲明具體要使用的功能。

如果創(chuàng)建一個返回接口的API,就會浪費掉隱式接口的一個主要優(yōu)勢:解耦中鼠。應(yīng)當限制客戶端代碼所依賴的第三方接口可婶,因為代碼會永久依賴包含這些接口的模塊,以及該模塊的所有依賴兜蠕。(在模塊扰肌、包和導(dǎo)入一章中會講解模塊和依賴。)這會限制未來的靈活性熊杨。為避免出現(xiàn)耦合曙旭,需要編寫另一個接口并通過類型轉(zhuǎn)換將一個接口轉(zhuǎn)換為另一個。依賴具象類型會產(chǎn)生依賴晶府,而在應(yīng)用層中使用依賴注入層又會影響效果桂躏。我們會在隱式接口讓依賴注入更簡單一節(jié)中進一步討論依賴注入。

另一個避免返回接口的原因是版本化川陆。如果返回了具象類型剂习,可添加新方法和新字段,已有代碼可正常運行较沪。但對接口情況就不一樣了鳞绕。對接口添加方法意味著需要更新該接口的所有實現(xiàn),否則代碼會崩潰尸曼。如果API向后不兼容们何,應(yīng)增加主版本號。

不要寫根據(jù)入?yún)⒎祷亟涌诒澈蟛煌瑢嵗膯蝹€工廠函數(shù)控轿,而嘗試為每個具象類型編寫單獨的工廠函數(shù)冤竹。在某些場景下(比如可返回一種或多種類型詞法單元的解析器),不可避免地要返回接口茬射。

錯誤就是一種例外鹦蠕。我們在錯誤處理一章中會學(xué)到,Go的方法和函數(shù)聲明error接口類型的返回參數(shù)在抛。針對error钟病,很可能返回接口的不同實現(xiàn),因此需要使用接口處理所有可能的選項刚梭,因為接口是Go語言中唯一的抽象類型档悠。

這一模式有一個潛在的不足。在指針一章的降低垃圾回收器的工作量一節(jié)中討論過望浩,減少堆內(nèi)存分配會通過減少垃圾回收器的工作量而提升性能。返回結(jié)構(gòu)體避免堆內(nèi)存的分配惰说,因此是好事磨德。但在調(diào)用帶接口類型參數(shù)的函數(shù)時,每個接口參數(shù)都會發(fā)生堆內(nèi)存分配。權(quán)衡抽象更重要還是性能更重要可能會常伴你的編程生涯典挑。如果程序太慢且進做過性能測試酥宴,又發(fā)現(xiàn)性能問題是由于接口參數(shù)所導(dǎo)致的堆內(nèi)存分配,那么應(yīng)重寫為使用具象類型參數(shù)的函數(shù)您觉。如果對函數(shù)傳入了同一接口的多種實現(xiàn)拙寡,這表示在創(chuàng)建帶有重復(fù)邏輯的多個函數(shù)。

接口和nil

指針一章中討論過指針琳水,我們還討論過指針類型的零值nil肆糕。我們還使用nil來表示接口類型的零值,這并不簡單因為它用于具象類型在孝。

為使用接口為nil诚啃,其類型和值都必須為nil。以下代碼前兩行打印true私沮,最后一行打倒false

var s *string
fmt.Println(s == nil) // prints true
var i interface{}
fmt.Println(i == nil) // prints true
i = s
fmt.Println(i == nil) // prints false

讀者可在The Go Playground中自行運行始赎。

在Go運行時中,接口通過指針對實現(xiàn)仔燕,一個為其底層類型造垛,另一個為底層值。只要類型為非nil晰搀,那么接口也不是nil五辽。(因變量不能沒類型,如果值指針非nil厕隧,類型指針要一定是非nil奔脐。)

對于接口nil表明我們是否可調(diào)用其方法。前面講到可對nil具象實例調(diào)用方法吁讨,貌似在對接口變量賦nil具象實例時可以調(diào)用其方法髓迎。如果接口為nil,調(diào)用其任意方法會panic(我們在錯誤處理一章的panic和recover中會討論)建丧。如果接口非nil排龄,則可調(diào)用其方法。(但注意如果值為nil翎朱,而所賦類型的方法無法正確處理nil橄维,仍會panic。)

因非nil類型的接口實例不等于nil拴曲,很容易在類型不是nil時知道接口關(guān)聯(lián)值是否為nil争舞。必須要使用反射才能知道(在使用反射檢查接口值是否為nil一節(jié)中討論)。

空接口無信息量

有時在靜態(tài)類型語言中澈灼,需要有方式說明變量存儲任意類型的值竞川。Go使用interface{}來進行表示:

var i interface{}
i = 20
i = "hello"
i = struct {
    FirstName string
    LastName string
} {"Fred", "Fredson"}

應(yīng)該注意interface{}不是特例語法店溢。空接口類型只是說明變量可以存儲實現(xiàn)了零個或多個方法類型的值委乌。只是它能匹配Go中的所有類型床牧。因為空接口并沒有所表示值的任何信息,并不能做些什么遭贸「昕龋空接口的一個常見用途是作為從外部數(shù)據(jù)源讀取的模式不定的數(shù)據(jù)的占位符,比如說JSON文件:

// 一對花括號表示interface{}類型壕吹,另一個是實例化map實例
data := map[string]interface{}{}
contents, err := ioutil.ReadFile("testdata/sample.json")
if err != nil {
    return err
}
defer contents.Close()
json.Unmarshal(contents, &data)
// 內(nèi)容現(xiàn)在就放到data中了

interface{}的另一個用法是作為存儲用戶創(chuàng)建數(shù)據(jù)結(jié)構(gòu)中值的一種方式著蛙。這是因為當前缺乏用戶定義的泛型(1.18版本中已添加泛型)。如查需要切片算利、數(shù)組或map之外的數(shù)據(jù)結(jié)構(gòu)册踩,而又不希望只支持一種類型,需要使用類型為interface{}的字段來存儲其值效拭≡菁可以嘗試在The Go Playground中運行如下代碼:

type LinkedList struct {
    Value interface{}
    Next    *LinkedList
}

func (ll *LinkedList) Insert(pos int, val interface{}) *LinkedList {
    if ll == nil || pos == 0 {
        return &LinkedList{
            Value: val,
            Next:    ll,
        }
    }
    ll.Next = ll.Next.Insert(pos-1, val)
    return ll
}

警告:對于鏈表插入而言這不是一種高效的實現(xiàn),但對以學(xué)習(xí)足夠了缎患。不要在真實代碼中使用它慕的。

如果看到函數(shù)接收空接口,很可是使用了反射(在惡龍三劍客:反射挤渔、Unsafe 和 Cgo一章中討論)來輸入或讀者數(shù)據(jù)肮街。有上面的例子中,json.Unmarshal函數(shù)的第二個參數(shù)聲明為interface{}類型判导。

這些場景應(yīng)該相對少見嫉父。避免使用interface{}。我們看到眼刃,Go設(shè)計為強類型語言绕辖,而嘗試繞過這一點是不純正的做法。

如果發(fā)現(xiàn)需要將值存入空接口擂红,可能會想如何讀回該值仪际。這時需要使用類型斷言和類型判斷 。

類型斷言和類型判斷

Go提供了兩種方式查看接口類型變量是否有具體的具象類型或具象類型是否實現(xiàn)了其它接口昵骤。我們先來學(xué)習(xí)類型斷言树碱。類型斷言說明某具象類型是否實現(xiàn)了該接口,或是接口具象類型是否也實現(xiàn)了另一個接口变秦〕砂瘢可在The Go Playground:中運行如下代碼:

type MyInt int

func main() {
    var i interface{}
    var mine MyInt = 20
    i = mine
    i2 := i.(MyInt)
    fmt.Println(i2 + 1)
}

以上代碼中,變量i2的類型為MyInt蹦玫。

你可能會想如果類型斷言出錯會發(fā)生什么赎婚。那樣代碼會panic雨饺。可在The Go Playground中運行如下代碼:

i2 := i.(string)
fmt.Println(i2)

運行上述代碼會產(chǎn)生以下的panic:

panic: interface conversion: interface {} is main.MyInt, not string

可以看到Go對于具象類型還是很謹慎的惑淳。即使兩種炮灰攻的春天的底層類型一致,類型斷言也必須匹配底層值的類型饺窿。下面的代碼會panic歧焦。可在The Go Playground中運行如下代碼:

i2 := i.(int)
fmt.Println(i2 + 1)

顯然崩潰非我們之所欲肚医。應(yīng)當使用逗號ok語法來進行避免绢馍,在逗號ok語句一節(jié)中我們在檢測字典中是否為零值是使用過:

i2, ok := i.(int)
if !ok {
    return fmt.Errorf("unexpected type for %v",i)
}
fmt.Println(i2 + 1)

如果類型轉(zhuǎn)換成功布爾值ok設(shè)為true。而如果失敗肠套,ok會設(shè)為false舰涌,另一個變量(本例中為i2)設(shè)為零值。然后在if語句中處理預(yù)期外的條件你稚,但在純正的Go語言中瓷耙,我們對錯誤處理代碼進行縮進。我們會在錯誤處理一章中討論刁赖。

注:類型斷言與類型轉(zhuǎn)換不同搁痛。類型轉(zhuǎn)換可用于具象類型和接口,在編譯時進行檢查宇弛。類型斷言只能用于接口類型鸡典,在運行時檢查。因其在運行時檢查枪芒,可能會出現(xiàn)失敗彻况。轉(zhuǎn)換修改類型,斷言揭示問題舅踪。

即使是絕對確定類型斷言有效纽甘,也請使用逗號ok語句。我們無法預(yù)知其他人(或是半年后的你)會如何復(fù)用這段代碼硫朦。遲早未驗證的類型斷言會在運行時出錯贷腕。

在接口可能為多種類型之一時,使用類型判斷:

func doThings(i interface{}) {
    switch j := i.(type) {
    case nil:
        // i為nil咬展,j的類型為interface{}
    case int:
        // j的類型為int
    case MyInt:
        // j的類型為MyInt
    case io.Reader:
        // j的類型為io.Reader
    case string:
        // j是string
    case bool, rune:
        // i為bool或rune類型泽裳,因此j的類型為interface{}
    default:
        // 不知道i是什么類型,因此j的類型為interface{}
    }
}

類型判斷和switch語句很像破婆,我們在代碼塊涮总,遮蔽和控制結(jié)構(gòu)一章中學(xué)習(xí)過。取代指定布爾運算祷舀,我們指定一個接口類型的變量在其后接.(type)瀑梗。通常將待檢測變量賦給另一個僅在switch中有效的變量烹笔。

注:因類型判斷的目的是從已有變量獲取新變量,將進行判斷的變量賦值給同名變量是一種純正的做法(i := i.(type))抛丽,這也是代碼遮蔽是好做法的極少的案例之一谤职。為了讓注釋可讀性更強,本例沒有使用代碼遮蔽亿鲜。

新變量類型取決于匹配得是哪個分支允蜈。可在一個分支中使用nil來查看該接口是否沒有關(guān)聯(lián)類型蒿柳。如果在一個分支中有多個類型饶套,新變量的類型為interface{}。和switch語句一樣垒探,可有一個default分支在沒有指定類型時進行匹配妓蛮。否則新變量為匹配分支的類型。

小貼士:如果不知道底層類型圾叼,需要使用反射蛤克。在惡龍三劍客:反射、Unsafe 和 Cgo一章中會討論反射褐奥。

少用類型斷言和類型判斷

雖然從接口變量中提取具象實現(xiàn)看起來很方便咖耘,但應(yīng)減少使用這種技術(shù)。大部分情況撬码,對參數(shù)或返回值作所提供類型對待儿倒,而不是其它類型。不然函數(shù)的API無法精確聲明其執(zhí)行任務(wù)所需的類型呜笑。如果需要另一種類型夫否,則應(yīng)進行指定。

話雖這么說叫胁,但類型斷言和類型判斷在有些場景下是非常有用的凰慈。類型斷言的一個常見用途是查看接口后的具象類型是否實現(xiàn)了另一個接口。這允許我們指定可選接口驼鹅。例如微谓,標準庫使用了這一技術(shù)來在調(diào)用io.Copy函數(shù)時做更高效的拷貝。這個函數(shù)有兩個類型分別為io.Writerio.Reader的類型输钩,調(diào)用io.copyBuffer函數(shù)來完成工作瞧挤。如果io.Writer參數(shù)還實現(xiàn)了io.WriterTo沙热,或是io.Reader參數(shù)還實現(xiàn)了io.ReaderFrom,函數(shù)中大部分的工作都可以略過:

// copyBuffer is the actual implementation of Copy and CopyBuffer.
// if buf is nil, one is allocated.
func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
    // If the reader has a WriteTo method, use it to do the copy.
    // Avoids an allocation and a copy.
    if wt, ok := src.(WriterTo); ok {
        return wt.WriteTo(dst)
    }
    // Similarly, if the writer has a ReadFrom method, use it to do the copy.
    if rt, ok := dst.(ReaderFrom); ok {
        return rt.ReadFrom(src)
    }
    // function continues...
}

另一個可選接口的用處是演進API。在上下文一章中我們會討論上下文衡奥。上下文是傳遞給函數(shù)的參數(shù)你弦,可用于管理取消等標準方式。這在Go 1.7中添加,也就是老代碼不支持前联。包括舊的數(shù)據(jù)庫驅(qū)動。

在Go 1.8中娶眷,在database/sql/driver包中定義了與已有接口上下文感知相似的內(nèi)容似嗤。例如,StmtExecContext定義了一個名為ExecContext的方法届宠,它是StmtExec方法具有上下文感知的替代双谆。在將Stmt的實現(xiàn)傳入標準庫數(shù)據(jù)庫代碼時,它檢查其是否實現(xiàn)了StmtExecContext席揽。如果實現(xiàn)了則調(diào)用ExecContext。若未實現(xiàn)谓厘,Go標準庫提供了新代碼提供的取消支持的備用實現(xiàn):

func ctxDriverStmtExec(ctx context.Context, si driver.Stmt,
                       nvdargs []driver.NamedValue) (driver.Result, error) {
    if siCtx, is := si.(driver.StmtExecContext); is {
        return siCtx.ExecContext(ctx, nvdargs)
    }
    // fallback code is here
}

可選接口技術(shù)有一個不足幌羞。我們前面學(xué)過接口實現(xiàn)使用裝飾器模式封裝相同接口行為層其它實現(xiàn)是常見行為。問題是如果可選接口由其中一個封裝實現(xiàn)進行實現(xiàn)竟稳,就無法通過類型斷言或類型判斷進行檢測了属桦。例如,標準庫有bufio包提供帶緩沖讀取他爸∧舯觯可以通過將其傳遞給bufio.NewReader函數(shù)來緩沖其它的io.Reader實現(xiàn),并使用返回的*bufio.Reader诊笤。如果傳入的io.Reader還實現(xiàn)了io.ReaderFrom系谐,將其封裝到帶緩沖讀取接口則會截斷優(yōu)化。

在錯誤處理中也存在這種情況讨跟。在前面講到纪他,它們實現(xiàn)了error接口。錯誤可通過封裝其它錯誤來包含額外的信息晾匠。類型判斷或類型斷言無法檢測或匹配封裝的錯誤茶袒。如果希望在處理返回錯誤的不同具體實現(xiàn)時有不同的行為,使用errors.Iserrors.As來測試或訪問封裝的錯誤凉馆。

類型判斷語句提供了區(qū)分接口要求有不同處理的各實現(xiàn)的能力薪寓。只對接口所能提供某些有效類型最為有用。確保在處理開發(fā)時尚不知道的實現(xiàn)時對switch添加一個default分支澜共。這會防止我們在添加新的接口實現(xiàn)時忘記更新switch語句:

func walkTree(t *treeNode) (int, error) {
    switch val := t.val.(type) {
    case nil:
        return 0, errors.New("invalid expression")
    case number:
        // we know that t.val is of type number, so return the
        // int value
        return int(val), nil
    case operator:
        // we know that t.val is of type operator, so
        // find the values of the left and right children, then
        // call the process() method on operator to return the
        // result of processing their values.
        left, err := walkTree(t.lchild)
        if err != nil {
            return 0, err
        }
        right, err := walkTree(t.rchild)
        if err != nil {
            return 0, err
        }
        return val.process(left, right), nil
    default:
        // if a new treeVal type is defined, but walkTree wasn't updated
        // to process it, this detects it
        return 0, errors.New("unknown node type")
    }
}

可在The Go Playground中查看完整的實現(xiàn)向叉。

注:可以進一步保護自己不出現(xiàn)意外的接口實現(xiàn),通過不導(dǎo)出接口以及至少不導(dǎo)出其中一個方法咳胃,如果導(dǎo)出接口植康,就可以在另一個包的結(jié)構(gòu)體中嵌套它,讓結(jié)構(gòu)體實現(xiàn)該接口展懈。我們會在中模塊销睁、包和導(dǎo)入一章中討論包及標識符導(dǎo)出供璧。

函數(shù)類型是接口的橋梁

關(guān)于類型聲明還差一件事沒有討論。很容易陷入對整型或字符串添加方法冻记,但Go對任意自定義類型添加方法睡毒,包括自定義的函數(shù)類型。這聽起來像是學(xué)術(shù)上的鉆牛角尖冗栗,但實際上卻是非常有用的演顾。這會允許函數(shù)實現(xiàn)接口。最常見的用法是HTTP處理器隅居。HTTP handler用于處理HTTP服務(wù)請求钠至。由接口定義:

type Handler interface {
    ServeHTTP(http.ResponseWriter, *http.Request)
}

通過將類型轉(zhuǎn)換為http.HandlerFunc,任何簽名為func(http.ResponseWriter,*http.Request)的函數(shù)都可以用作http.Handler

type HandlerFunc func(http.ResponseWriter, *http.Request)

func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    f(w, r)
}

這樣可以使用函數(shù)胎源、方法或閉包實現(xiàn)HTTP處理器棉钧,用完全相同的代碼路徑作為符合http.Handler接口的其它類型。

Go中的函數(shù)是一級概念涕蚤,因此傳入為函數(shù)的參數(shù)宪卿。同時,Go鼓勵使用小接口万栅,僅一個方法的接口可輕易替換函數(shù)類型的參數(shù)佑钾。問題是:什么時候使用或方法指定函數(shù)類型的入?yún)ⅲ裁磿r候使用接口呢烦粒?

如果一個函數(shù)可能依賴入?yún)⒅形粗付ǖ钠渌暮瘮?shù)或狀態(tài)休溶,使用接口參數(shù)、定義函數(shù)類型來成為函數(shù)到接口的橋梁扰她。這正是http包中的做法邮偎,很可能Handler只是需要配置的外加工調(diào)用的入口。但如果它是一個簡單函數(shù)(類似sort.Slice所用的那個)义黎,那么函數(shù)類型的參數(shù)是個好選擇禾进。

隱式接口讓依賴注入更簡單

只要做過編程,不論老手還是新手都可以很快知道應(yīng)用需要隨時間發(fā)生變化廉涕。一種用于讓解耦變輕松的技術(shù)稱為依賴注入泻云。依賴注入的概念是代碼應(yīng)顯式指定其需執(zhí)行任務(wù)的功能。這比想象中要更古早狐蜕,1996年Robert Martin寫了一篇名為依賴反轉(zhuǎn)原理的文章宠纯。

Go顯式接口一個出人意料的好處是它讓依賴注入成為解耦代碼的優(yōu)秀方式。雖然其它語言的開發(fā)者經(jīng)常使用大型层释、復(fù)雜的框架來注入依賴婆瓜,事實是Go不需要其它庫就可以輕松實現(xiàn)依賴注入。我們通過簡單示例來了解如何使用隱式接口借助依賴注入編寫應(yīng)用。

為更好理解這一概念并學(xué)習(xí)如何在Go中實現(xiàn)依賴注入廉白,我們構(gòu)建一個簡單的web應(yīng)用个初。(我們會在標準庫一章中講解Go內(nèi)置的HTTP服務(wù)器,這里可以當成是預(yù)覽猴蹂。)先來編寫一個工具函數(shù)院溺,日志工具:

func LogOutput(message string) {
    fmt.Println(message)
}

應(yīng)用還需要數(shù)據(jù)存儲。我們來創(chuàng)建一個簡單版本:

type SimpleDataStore struct {
    userData map[string]string
}

func (sds SimpleDataStore) UserNameForID(userID string) (string, bool) {
    name, ok := sds.userData[userID]
    return name, ok
}

再定義一個工廠函數(shù)來創(chuàng)建一個SimpleDataStore實例:

func NewSimpleDataStore() SimpleDataStore {
    return SimpleDataStore{
        userData: map[string]string{
            "1": "Fred",
            "2": "Mary",
            "3": "Pat",
        },
    }
}

接收來我們會編寫一些業(yè)務(wù)邏輯查找用戶并進行問候和道別磅轻。我們的業(yè)務(wù)邏輯需要用到一些數(shù)據(jù)珍逸,因此需要有數(shù)據(jù)存儲。我們還想要業(yè)務(wù)邏輯記錄何時調(diào)用聋溜,因此需要日志工具谆膳。但是我們不想強制它依賴于LogOutputSimpleDataStore,因為我們未來可能會使用其它日志工具或數(shù)據(jù)存儲撮躁。業(yè)務(wù)邏輯需要的正是描述其依賴的接口:

type DataStore interface {
    UserNameForID(userID string) (string, bool)
}

type Logger interface {
    Log(message string)
}

為讓LogOutput函數(shù)符合接口摹量,我們定義一個函數(shù)類型并添加方法:

type LoggerAdapter func(message string)

func (lg LoggerAdapter) Log(message string) {
    lg(message)
}

非常巧的是,我們的LoggerAdapterSimpleDataStore剛好符合業(yè)務(wù)邏輯所需要的接口馒胆,但兩種類型都不知道它的功能。

現(xiàn)在依賴已定義好我們來看業(yè)務(wù)邏輯的實現(xiàn):

type SimpleLogic struct {
    l  Logger
    ds DataStore
}

func (sl SimpleLogic) SayHello(userID string) (string, error) {
    sl.l.Log("in SayHello for " + userID)
    name, ok := sl.ds.UserNameForID(userID)
    if !ok {
        return "", errors.New("unknown user")
    }
    return "Hello, " + name, nil
}

func (sl SimpleLogic) SayGoodbye(userID string) (string, error) {
    sl.l.Log("in SayGoodbye for " + userID)
    name, ok := sl.ds.UserNameForID(userID)
    if !ok {
        return "", errors.New("unknown user")
    }
    return "Goodbye, " + name, nil
}

我們的結(jié)構(gòu)體有兩個字段凝果,一個是Logger祝迂,另一個是DataStoreSimpleLogic中沒有具象類型器净,因為它們沒有依賴型雳。稍后切換為其它提供者的新實現(xiàn)不會有問題,因為提供者與接口無關(guān)山害。這與Java這樣的顯式接口完全不同纠俭。雖然Java使用接口來解耦對其的實現(xiàn),顯式接口同時綁定客戶端和服務(wù)提供者浪慌。這會讓在Java(以及其它帶顯式接口的編程語言)中替換依賴遠比在Go中要困難冤荆。

在我們需要SimpleLogic實例時,會調(diào)用工廠函數(shù)权纤,傳入接口钓简、返回結(jié)構(gòu)體:

func NewSimpleLogic(l Logger, ds DataStore) SimpleLogic {
    return SimpleLogic{
        l:    l,
        ds: ds,
    }
}

注:SimpleLogic中的字段未導(dǎo)出。也就是說僅可用由SimpleLogic相同包的代碼訪問汹想。我們無法強制Go中的不可變性外邓,但限制哪段代碼可訪問這些字段使得不太可能出現(xiàn)意外修改。我們會模塊古掏、包和導(dǎo)入一章中導(dǎo)入或非導(dǎo)入標識符损话。

現(xiàn)在到API了。我們只有一個端點/hello,對提供了ID的用戶進行問候丧枪。(在真實應(yīng)用中請不要使用查詢參數(shù)進行身份認證光涂,這里只是一個快速示例)。我們的控制器需要問候的業(yè)務(wù)邏輯豪诲,因此這樣定義接口:

type Logic interface {
    SayHello(userID string) (string, error)
}

SimpleLogic結(jié)構(gòu)上已有這個方法顶捷,但具象類型是感知不到該接口的。此外屎篱,SimpleLogic的其它方法SayGoodbye沒在接口中服赎,因為控制器用不到它。接口由客戶端代碼持有交播,因此方法集按照客戶端代碼需求自定義:

type Controller struct {
    l     Logger
    logic Logic
}

func (c Controller) SayHello(w http.ResponseWriter, r *http.Request) {
    c.l.Log("In SayHello")
    userID := r.URL.Query().Get("user_id")
    message, err := c.logic.SayHello(userID)
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        w.Write([]byte(err.Error()))
        return
    }
    w.Write([]byte(message))
}

和其它類型的工廠方法一樣重虑,我們來編寫Controller

func NewController(l Logger, logic Logic) Controller {
    return Controller{
        l:     l,
        logic: logic,
    }
}

同樣是接收接口返回結(jié)構(gòu)體。

最后在main函數(shù)中拼裝這些組件秦士,啟動服務(wù)端:

func main() {
    l := LoggerAdapter(LogOutput)
    ds := NewSimpleDataStore()
    logic := NewSimpleLogic(l, ds)
    c := NewController(l, logic)
    http.HandleFunc("/hello", c.SayHello)
    http.ListenAndServe(":8080", nil)
}

main函數(shù)是這里唯一知道所有具象類型是哪些的缺厉。如果希望切換為不同的實現(xiàn),只需在這里修改隧土。通過依賴注入外部分依賴表示我們限制了演進代碼時所需做的修改提针。

依賴注入也是讓測試變簡單的偉大模式。這一點不奇怪曹傀,因為測試需要復(fù)用其它環(huán)境中的代碼辐脖,這些環(huán)境中輸入和輸出都受驗證功能約束。例如皆愉,我們可以通過注入捕獲日志輸出的類型并實現(xiàn)Logger接口來驗證日志輸出嗜价。在編寫測試一章中會進一步討論。

注:http.HandleFunc("/hello", c.SayHello)演示前面所說的兩個部分幕庐。

首先久锥,我們把SayHello方法看成函數(shù)。

其次异剥,http.HandleFunc函數(shù)接收函數(shù)并將其轉(zhuǎn)換為http.HandlerFunc函數(shù)類型瑟由,它聲明了一個方法來實現(xiàn)http.Handler接口,這是用于表示Go請求處理器的類型冤寿。我們從一個類型中取出方法错妖,轉(zhuǎn)換為另一個帶此方法的類型。干凈利落疚沐。

Wire

如果覺得手動編寫依賴注入代碼工作量太大暂氯,可以使用Wire,它是Google編寫的依賴注入輔助工具亮蛔。它使用代碼生成自動生成我們在main中所編寫的具體類型聲明痴施。

Go并不是面向?qū)ο螅ㄟ@很好)

我們已經(jīng)學(xué)了Go中類型的純正用法,可以看到很難將Go歸類為某種具體語言類型。很明顯它不是嚴格意義上的過程化語言辣吃。同時动遭,Go中沒有方法重載、繼承或是對象神得,所以它也不是面向?qū)ο笳Z言厘惦。Go具有函數(shù)類型和閉包,但它也不是函數(shù)式語言哩簿。如果硬要把Go向這些分類靠宵蕉,寫出的代碼會不倫不類。

如果一定要給Go打個標簽节榜,那最好的詞是實用羡玛。它吸收了各處的概念,旨在打造一款簡單宗苍、易讀且可供大團隊長期維護的語言稼稿。

小結(jié)

本章中我們講解了類型、方法讳窟、接口以及它們的最佳實踐让歼。下一章中我們會學(xué)習(xí)使用Go最具爭議的特性(錯誤處理)。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末丽啡,一起剝皮案震驚了整個濱河市谋右,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌碌上,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,682評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件浦徊,死亡現(xiàn)場離奇詭異馏予,居然都是意外死亡,警方通過查閱死者的電腦和手機盔性,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,277評論 3 395
  • 文/潘曉璐 我一進店門霞丧,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人冕香,你說我怎么就攤上這事蛹尝。” “怎么了悉尾?”我有些...
    開封第一講書人閱讀 165,083評論 0 355
  • 文/不壞的土叔 我叫張陵突那,是天一觀的道長。 經(jīng)常有香客問我构眯,道長愕难,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,763評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮猫缭,結(jié)果婚禮上葱弟,老公的妹妹穿的比我還像新娘。我一直安慰自己猜丹,他們只是感情好芝加,可當我...
    茶點故事閱讀 67,785評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著射窒,像睡著了一般藏杖。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上轮洋,一...
    開封第一講書人閱讀 51,624評論 1 305
  • 那天制市,我揣著相機與錄音,去河邊找鬼弊予。 笑死祥楣,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的汉柒。 我是一名探鬼主播误褪,決...
    沈念sama閱讀 40,358評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼碾褂!你這毒婦竟也來了兽间?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,261評論 0 276
  • 序言:老撾萬榮一對情侶失蹤正塌,失蹤者是張志新(化名)和其女友劉穎嘀略,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體乓诽,經(jīng)...
    沈念sama閱讀 45,722評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡帜羊,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,900評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了鸠天。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片讼育。...
    茶點故事閱讀 40,030評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖稠集,靈堂內(nèi)的尸體忽然破棺而出奶段,到底是詐尸還是另有隱情,我是刑警寧澤剥纷,帶...
    沈念sama閱讀 35,737評論 5 346
  • 正文 年R本政府宣布痹籍,位于F島的核電站,受9級特大地震影響晦鞋,放射性物質(zhì)發(fā)生泄漏词裤。R本人自食惡果不足惜刺洒,卻給世界環(huán)境...
    茶點故事閱讀 41,360評論 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望吼砂。 院中可真熱鬧逆航,春花似錦、人聲如沸渔肩。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,941評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽周偎。三九已至抹剩,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間蓉坎,已是汗流浹背澳眷。 一陣腳步聲響...
    開封第一講書人閱讀 33,057評論 1 270
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留蛉艾,地道東北人钳踊。 一個月前我還...
    沈念sama閱讀 48,237評論 3 371
  • 正文 我出身青樓,卻偏偏與公主長得像勿侯,于是被迫代替她去往敵國和親拓瞪。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 44,976評論 2 355

推薦閱讀更多精彩內(nèi)容