《Go 語言程式設計》讀書筆記(四)介面

KevinYan發表於2019-12-28

介面概述

  • 一個具體的型別可以準確的描述它所代表的值並且展示出對型別本身的一些操作方式就像數字型別的算術操作,切片型別的索引、附加和取範圍操作。總的來說,當你拿到一個具體的型別時你就知道它的本身是什麼和你可以用它來做什麼。

  • 在Go語言中還存在著另外一種型別:介面型別。介面型別是一種抽象的型別。它不會暴露出它所代表的物件的內部結構和這個物件支援的基礎操作的集合;它只會展示出自己的方法。也就是說當你有看到一個介面型別的值時,你不知道它是什麼,唯一知道的就是可以透過它的方法來做什麼。

  • fmt.Printf它會把結果寫到標準輸出和fmt.Sprintf它會把結果以字串的形式返回,實際上,這兩個函式都使用了另一個函式fmt.Fprintf來進行封裝。fmt.Fprintf這個函式對它的計算結果會被怎麼使用是完全不知道的。

package fmt

func Fprintf(w io.Writer, format string, args ...interface{}) (int, error)
func Printf(format string, args ...interface{}) (int, error) {
    return Fprintf(os.Stdout, format, args...)
}
func Sprintf(format string, args ...interface{}) string {
    var buf bytes.Buffer
    Fprintf(&buf, format, args...)
    return buf.String()
}

​ Fprintf函式中的第一個引數也不是一個檔案型別。它是io.Writer型別這是一個介面型別定義如下:

package io

type Writer interface {
    Write(p []byte) (n int, err error)
}

io.Writer型別定義了函式Fprintf和這個函式呼叫者之間的約定,只要是實現了io.Writer介面的型別都可以作為 Fprintf 函式的第一個引數。

  • 一個型別可以自由的使用另一個滿足相同介面的型別來進行替換被稱作可替換性(LSP里氏替換)。這是一個物件導向的特徵。

介面定義

  • io.Writer型別是用的最廣泛的介面之一,因為它提供了所有的型別寫入bytes的抽象,包括檔案型別,記憶體緩衝區,網路連結,HTTP客戶端,壓縮工具,雜湊等等。io包中定義了很多其它有用的介面型別。Reader可以代表任意可以讀取bytes的型別,Closer可以是任意可以關閉的值,例如一個檔案或是網路連結。
package io
type Reader interface {
    Read(p []byte) (n int, err error)
}
type Closer interface {
    Close() error
}
  • 可以透過組合已有介面型別來定義新的介面型別,比如 io 包中的
  type ReadWriter interface {
      Reader
      Writer
  }
  type ReadWriteCloser interface {
      Reader
      Writer
      Closer
  }

上面用到的語法和結構內嵌相似,我們可以用這種方式命名另一個介面,而不用宣告它所有的方法。這種方式稱為介面內嵌,我們可以像下面這樣,不使用內嵌來宣告io.ReadWriter介面。

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

或者甚至使用種混合的風格:

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

這三種方式定義的io.ReadWriter是完全一樣的。

介面實現

  • 一個型別如果擁有一個介面需要的所有方法,那麼這個型別就實現了這個介面。例如,os.File型別實現了io.Reader,Writer,Closer,和ReadWriter介面。bytes.Buffer實現了Reader,Writer,和ReadWriter這些介面,但是它沒有實現Closer介面因為它不具有Close方法。Go的程式設計師經常會簡要的把一個具體的型別描述成一個特定的介面型別。舉個例子,bytes.Buffer是io.Writer;os.Files是io.ReadWriter。
  • 介面實現的規則非常簡單:表達一個型別屬於某個介面只要這個型別實現這個介面。
var w io.Writer
w = os.Stdout           // OK: *os.File has Write method
w = new(bytes.Buffer)   // OK: *bytes.Buffer has Write method
w = time.Second         // compile error: time.Duration lacks Write method

var rwc io.ReadWriteCloser
rwc = os.Stdout         // OK: *os.File has Read, Write, Close methods
rwc = new(bytes.Buffer) // compile error: *bytes.Buffer lacks Close method
  • 這個規則甚至適用於等式右邊本身也是一個介面型別
w = rwc                 // OK: io.ReadWriteCloser has Write method
rwc = w                 // compile error: io.Writer lacks Close method
  • 因為ReadWriter和ReadWriteCloser包含Writer的方法,所以任何實現了ReadWriter和ReadWriteCloser的型別必定也實現了Writer介面
  • 對於一些命名的具體型別T;它一些方法的接收者是型別T本身然而另一些則是一個*T的指標。在T型別的變數上呼叫一個*T的方法是合法的,編譯器隱式的獲取了它的地址。但這僅僅是一個語法糖:T型別的值不擁有所有*T指標的方法。
  • interface{}型別,它沒有任何方法,但實際上interface{}被稱為空介面型別是不可或缺的。因為空介面型別對實現它的型別沒有要求,所以所有型別都實現了interface{},我們可以將任意一個值賦給空介面型別。
var any interface{}
any = true
any = 12.34
any = "hello"
any = map[string]int{"one": 1}
any = new(bytes.Buffer)

介面值

  • 介面值由兩個部分組成,一個具體的型別和那個型別的值。它們被稱為介面的動態型別和動態值。

  • 像Go語言這種靜態型別的語言,型別是編譯期的概念;因此一個型別不是一個值,提供每個型別資訊的值被稱為型別描述符。

  • 在Go語言中,變數總是被一個定義明確的值初始化,一個介面的零值就是它的型別和值的部分都是nil。

    img

  • 在你非常確定介面值的動態型別是可比較型別時(比如基本型別)才可以使用==!=對兩個介面值進行比較。如果兩個介面值的動態型別相同,但是這個動態型別是不可比較的(比如切片),將它們進行比較就會失敗並且panic:

var x interface{} = []int{1, 2, 3}
fmt.Println(x == x) // panic: comparing uncomparable type []int
  • 下面4個語句中,變數w得到了3個不同的值。(開始和最後的值是相同的)
var w io.Writer
w = os.Stdout
w = new(bytes.Buffer)
w = nil

第一個語句定義了變數w:

var w io.Writer

在Go語言中,變數總是被一個定義明確的值初始化,即使介面型別也不例外。對於一個介面的零值就是它的型別和值的部分都是nil,如圖 7.1。

一個介面值基於它的動態型別被描述為空或非空,所以這是一個空的介面值。你可以透過使用w==nil或者w!=nil來判讀介面值是否為空。呼叫一個空介面值上的任意方法都會產生panic:

w.Write([]byte("hello")) // panic: nil pointer dereference

第二個語句將一個*os.File型別的值賦給變數w:

w = os.Stdout

這個賦值過程呼叫了一個具體型別到介面型別的隱式轉換,這和顯式的使用io.Writer(os.Stdout)是等價的。這類轉換不管是顯式的還是隱式的,都會刻畫出操作到的型別和值。這個介面值的動態型別被設為*os.File指標的型別描述符(os.Stdout 是指向 os.File 的指標),它的動態值持有os.Stdout的複製;這是一個指向處理標準輸出的os.File型別變數的指標。

img

呼叫一個包含*os.File型別指標的介面值的Write方法,使得(*os.File).Write方法被呼叫。這個呼叫輸出“hello”。

w.Write([]byte("hello")) // "hello"

第三個語句給介面值賦了一個*bytes.Buffer型別的值

w = new(bytes.Buffer)

現在動態型別是*bytes.Buffer並且動態值是一個指向新分配的緩衝區的指標(圖7.3)。

img

Write方法的呼叫也使用了和之前一樣的機制:

w.Write([]byte("hello")) // writes "hello" to the bytes.Buffers

這次型別描述符是*bytes.Buffer,所以呼叫了(*bytes.Buffer).Write方法,並且接收者是該緩衝區的地址。這個呼叫把字串“hello”新增到緩衝區中。

最後,第四個語句將nil賦給了介面值:

w = nil

這個重置將它所有的部分都設為nil值,把變數w恢復到和它之前定義時相同的狀態圖,在圖7.1中可以看到。

一個包含nil指標的介面不是nil介面

一個不包含任何值的nil介面值和一個剛好包含nil指標的介面值是不同的。這個細微區別產生了一個容易絆倒每個Go程式設計師的陷阱。

思考下面的程式。當debug變數設定為true時,main函式會將f函式的輸出收集到一個bytes.Buffer型別中。

const debug = true

func main() {
    var buf *bytes.Buffer
    if debug {
        buf = new(bytes.Buffer) // enable collection of output
    }
    f(buf) // NOTE: subtly incorrect!
    if debug {
        // ...use buf...
    }
}

// If out is non-nil, output will be written to it.
func f(out io.Writer) {
    // ...do something...
    if out != nil {
        out.Write([]byte("done!\n"))
    }
}

我們可能會預計當把變數debug設定為false時可以禁止對輸出的收集,但是實際上在out.Write方法呼叫時程式發生了panic:

if out != nil {
    out.Write([]byte("done!\n")) // panic: nil pointer dereference
}

當main函式呼叫函式f時,它給f函式的out引數賦了一個*bytes.Buffer的空指標,所以out的動值是nil。然而,它的動態型別是*bytes.Buffer,意思就是out變數是一個包含空指標值的非空介面(如圖7.5),所以防禦性檢查out!=nil的結果依然是true。

img

動態分配機制依然決定(*bytes.Buffer).Write的方法會被呼叫,但是這次的接收者的值是nil。對於一些如*os.File的型別,nil是一個有效的接收者(§6.2.1),但是*bytes.Buffer型別不在這些型別中。這個方法會被呼叫,但是當它嘗試去獲取緩衝區時會發生panic。

問題在於儘管一個nil的*bytes.Buffer指標有實現這個介面的方法,它也不滿足這個介面具體的行為上的要求。特別是這個呼叫違反了(*bytes.Buffer).Write方法的接收者非空的隱含先覺條件,所以將nil指標賦給這個介面是錯誤的。解決方案就是將main函式中的變數buf宣告的型別改為io.Writer,(它的零值動態型別和動態值都為 nil)因此可以避免一開始就將一個不完全的值賦值給這個介面:

var buf io.Writer
if debug {
    buf = new(bytes.Buffer) // enable collection of output
}
f(buf) // OK

error 介面

  • 預定義的error型別實際上就是interface型別,這個型別有一個返回錯誤資訊的單一方法:

    type error interface {
        Error() string
    }
  • 建立一個error最簡單的方法就是呼叫errors.New函式,它會根據傳入的錯誤資訊返回一個新的error。整個errors包僅只有4行:

package errors

func New(text string) error { return &errorString{text} }

type errorString struct { text string }

func (e *errorString) Error() string { return e.text }

每個New函式的呼叫都分配了一個獨特的和其他錯誤不相同的例項。我們也不想要重要的error例如io.EOF和一個剛好有相同錯誤訊息的error比較後相等。

fmt.Println(errors.New("EOF") == errors.New("EOF")) // "false"

呼叫errors.New函式是非常稀少的,因為有一個方便的封裝函式fmt.Errorf,它還會處理字串格式化。

package fmt

import "errors"

func Errorf(format string, args ...interface{}) error {
    return errors.New(Sprintf(format, args...))
}

型別斷言

  • 型別斷言是一個使用在介面值上的操作。語法上它看起來像x.(T)被稱為斷言型別。這裡x表示一個介面值,T表示一個型別(介面型別或者具體型別)。一個型別斷言會檢查操作物件的動態型別是否和斷言型別匹配。

  • x.(T)中如果斷言的型別T是一個具體型別,型別斷言檢查x的動態型別是否和T相同。如果是,型別斷言的結果是x的動態值,當然它的型別是T。換句話說,具體型別的型別斷言從它的操作物件中獲得具體的值。如果x 的動態型別與 T 不相同,會丟擲panic。

var w io.Writer
w = os.Stdout
f := w.(*os.File)      // success: f == os.Stdout
c := w.(*bytes.Buffer) // panic: interface holds *os.File, not *bytes.Buffer
  • 相反斷言的型別T是一個介面型別,然後型別斷言檢查是否x的動態型別滿足T。如果這個檢查成功了,這個結果仍然是一個有相同型別和值部分的介面值,但是結果介面值的動態型別為T。換句話說,對一個介面型別的型別斷言改變了型別的表述方式,改變了可以獲取的方法集合(通常更大),但是它保護了介面值內部的動態型別和值的部分。

  • 在下面的第一個型別斷言後,w和rw都持有os.Stdout因為它們每個值的動態型別都是*os.File,但是變數的型別是io.Writer只對外公開出檔案的Write方法,變數rw的型別為 io.ReadWriter,只對外公開檔案的Read方法。

var w io.Writer
w = os.Stdout
rw := w.(io.ReadWriter) // success: *os.File has both Read and Write
w = new(ByteCounter)
rw = w.(io.ReadWriter) // panic: *ByteCounter has no Read method
  • 如果斷言操作的物件是一個nil介面值,那麼不論被斷言的型別是什麼這個型別斷言都會失敗。
  • 經常地我們對一個介面值的動態型別是不確定的,並且我們更願意去檢驗它是否是一些特定的型別。如果型別斷言出現在一個有兩個結果的賦值表示式中,例如如下的定義,這個型別斷言不會在失敗的時候發生panic,代替地返回的第二個返回值是一個標識型別斷言是否成功的布林值:
var w io.Writer = os.Stdout
f, ok := w.(*os.File)      // success:  ok, f == os.Stdout
b, ok := w.(*bytes.Buffer) // failure: !ok, b == nil

type switch

介面被以兩種不同的方式使用。在第一個方式中,以io.Reader,io.Writer,fmt.Stringer,sort.Interface,http.Handler,和error為典型,一個介面的方法表達了實現這個介面的具體型別間的相似性,但是隱藏了代表的細節和這些具體型別本身的操作。重點在於方法上,而不是具體的型別上。

第二個方式利用一個介面值可以持有各種具體型別值的能力並且將這個介面認為是這些型別的union(聯合)。型別斷言用來動態地區別這些型別。在這個方式中,重點在於具體的型別滿足這個介面,而不是在於介面的方法(如果它確實有一些的話),並且沒有任何的資訊隱藏。我們將以這種方式使用的介面描述為discriminated unions(可辨識聯合)。

一個型別開關像普通的switch語句一樣,它的運算物件是x.(type)-它使用了關鍵詞字面量type-並且每個case有一到多個型別。一個型別開關基於這個介面值的動態型別使一個多路分支有效。這個nil的case和if x == nil匹配,並且這個default的case和如果其它case都不匹配的情況匹配。一個對sqlQuote的型別開關可能會有這些case

switch x.(type) {
    case nil:       // ...
    case int, uint: // ...
    case bool:      // ...
    case string:    // ...
    default:        // ...
}

型別開關語句有一個擴充套件的形式,它可以將提取的值繫結到一個在每個case範圍內的新變數上。

switch x := x.(type) { /* ... */ }

使用型別開關的擴充套件形式來重寫sqlQuote函式會讓這個函式更加的清晰:

func sqlQuote(x interface{}) string {
    switch x := x.(type) {
    case nil:
        return "NULL"
    case int, uint:
        return fmt.Sprintf("%d", x) // x has type interface{} here.
    case bool:
        if x {
            return "TRUE"
        }
        return "FALSE"
    case string:
        return sqlQuoteString(x) // (not shown)
    default:
        panic(fmt.Sprintf("unexpected type %T: %v", x, x))
    }
}

儘管sqlQuote接受一個任意型別的引數,但是這個函式只會在它的引數匹配型別開關中的一個case時執行到結束;其它情況的它會panic出“unexpected type”訊息。雖然x的型別是interface{},但是我們把它認為是一個int,uint,bool,string,和nil值的discriminated union(可識別聯合)

使用建議

  • 介面只有當有兩個或兩個以上的具體型別必須以相同的方式進行處理時才需要。

  • 當一個介面只被一個單一的具體型別實現時有一個例外,就是由於它的依賴,這個具體型別不能和這個介面存在在一個相同的包中。這種情況下,一個介面是解耦這兩個包的一個好的方式。

  • 因為在Go語言中只有當兩個或更多的型別須以相同的方式進行處理時才有必要使用介面,它們必定會從任意特定的實現細節中抽象出來。結果就是有更少和更簡單方法(經常和io.Writer或 fmt.Stringer一樣只有一個)的更小的介面。當新的型別出現時,小的介面更容易滿足。對於介面設計的一個好的標準就是 ask only for what you need(只考慮你需要的東西)。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
公眾號:網管叨bi叨 | Golang、Laravel、Docker、K8s等學習經驗分享

相關文章