第十六章:接口-編程思維

image

本篇翻譯自《Practical Go Lessons》 Chapter 16: Interfaces

1 你将在本章學到什麼?

  • 什麼是類型接口?
  • 如何定義接口。
  • “實現一個接口”是什麼意思?
  • 接口的優點

2 涵蓋的技術概念

  • 接口 interface
  • 具體實現 concrete implementation
  • 實現一個接口
  • 接口的方法集

3 介紹

剛開始編程時,接口似乎很難理解。通常,新手程序員并不能完全理解接口的潛力。本節旨在解釋什麼是接口,它的有趣之處在哪裡,以及如何創建接口。

4 接口的基本定義

  • 接口是定義一組行為的契約
  • 接口是一個純粹的設計對象,它們隻是定義了一組行為(即方法),而沒有給出這些行為的任何實現
  • 接口是一種類型,它定義了一組方法而不實現它們

“實現” = “編寫方法的代碼”,這是一個示例接口類型(來自标準包 io):

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

這裡我們有一個名為 Reader 的接口類型,它指定了一種名為 Read 的方法。該方法沒有具體實現,唯一指定的是方法名稱及其簽名(參數類型和結果類型)。

4.0.0.1 接口類型的零值

接口類型的零值為 nil,例子:

var r io.Reader
log.Println(r)
// 2021/11/28 12:27:52 <nil>

5 基本示例

type Human struct {
    Firstname string
    Lastname string
    Age int
    Country string
}

type DomesticAnimal interface {
    ReceiveAffection(from Human)
    GiveAffection(to Human)
}
  • 首先,我們聲明一個名為 Human 的類型
  • 我們聲明了一個名為 DomesticAnimal 的新類型接口
  • 這種類型的接口有一個由兩個方法組成的方法集:ReceiveAffectionGiveAffect

DomesticAnimal 是一個契約。

  • 它告訴開發者,要成為 DomesticAnimal,我們至少需要有兩種行為:ReceiveAffectionGiveAffection

讓我們創建兩個類型:

type Cat struct {
    Name string
}

type Dog struct {
    Name string
}

我們有兩種新類型。為了讓他們遵守我們的接口 DomesticAnimal 的契約,
我們必須為每種類型定義接口指定的方法。

我們從 Cat 類型開始:

func (c Cat) ReceiveAffection(from Human) {
    fmt.Printf("The cat named %s has received affection from Human named %s\n", c.Name, from.Firstname)
}

func (c Cat) GiveAffection(to Human) {
    fmt.Printf("The cat named %s has given affection to Human named %s\n", c.Name, to.Firstname)
}

現在 Cat 類型實現了 DomesticAnimal 接口。我們現在對 Dog 類型做同樣的事情:

func (d Dog) ReceiveAffection(from Human) {
    fmt.Printf("The dog named %s has received affection from Human named %s\n", d.Name, from.Firstname)
}

func (d Dog) GiveAffection(to Human) {
    fmt.Printf("The dog named %s has given affection to Human named %s\n", d.Name, to.Firstname)
}

我們的 Dog 類型現在正确地實現了 DomesticAnimal 接口。現在我們可以創建一個函數,它接受一個帶有參數的接口:

func Pet(animal DomesticAnimal, human Human) {
    animal.GiveAffection(human)
    animal.ReceiveAffection(human)
}

Pet 函數将 DomesticAnimal 類型的接口作為第一個參數,将 Human 作為第二個參數。

在函數内部,我們調用了接口的兩個函數。

讓我們使用這個函數:

func main() {

    // Create the Human
    var john Human
    john.Firstname = "John"


    // Create a Cat
    var c Cat
    c.Name = "Maru"

    // then a dog
    var d Dog
    d.Name = "Medor"

    Pet(c, john)
    Pet(d,john)
}
  • DogCat 類型實現了接口 DomesticAnimal 的方法
  • 也就是說 DogCat 類型的任何變量都可以看作DomesticAnimal

隻要 Cat 實現的方法的函數簽名與接口定義一緻就可以,不強制要求完全相同變量名和返回名。所以我們将函數 func (c Cat) ReceiveAffection(from Human) {...} 改成 func (c Cat) ReceiveAffection(f Human) {...} 也是可以的

6 編譯器在看着你!

遵守類型 T 的接口契約意味着實現接口的所有方法。讓我們試着欺騙編譯器看看會發生什麼:

// ...
// let's create a concrete type Snake
type Snake struct {
    Name string
}
// we do not implement the methods ReceiveAffection and GiveAffection intentionally
//...


func main(){

    var snake Snake
    snake.Name = "Joe"

    Pet(snake, john)
}
  • 我們創建了一個新類型的 Snake
  • 該類型沒有實現 DomesticAnimal 動物的任何方法
  • 在主函數中,我們創建了一個新的 Snake 類型的變量
  • 然後我們用這個變量作為第一個參數調用 Pet 函數

結果是編譯失敗:

./main.go:70:5: cannot use snake (type Snake) as type DomesticAnimal in argument to Pet:
    Snake does not implement DomesticAnimal (missing GiveAffection method)

編譯器在未實現的按字母順序排列的第一個方法處檢查停止。

7 例子:database/sql/driver.Driver

我們來看看 Driver 接口(來自包database/sql/driver

type Driver interface {
    Open(name string) (Conn, error)
}
  • 存在不同種類的 SQL 數據庫,因此 Open 方法有多種實現。
  • 為什麼?因為你不會使用相同的代碼來啟動到 MySQL 數據庫和 Oracle 數據庫的連接。
  • 通過構建接口,你可以定義一個可供多個實現使用的契約。

8 接口嵌入

你可以将接口嵌入到其他接口中。讓我們舉個例子:

// the Stringer type interface from the standard library
type Stringer interface {
    String() string
}
// A homemade interface
type DomesticAnimal interface {
    ReceiveAffection(from Human)
    GiveAffection(to Human)
    // embed the interface Stringer into the DomesticAnimal interface
    Stringer
}

在上面的代碼中,我們将接口 Stringer 嵌入到接口 DomesticAnimal 中。
因此,已經實現了 DomesticAnimal 的其他類型必須實現 Stringer 接口的方法。

  • 通過接口嵌入,你可以在不重複的情況下向接口添加功能。
  • 這也是有代價的,如果你從另一個模塊嵌入一個接口,你的代碼将與其耦合
    • 其他模塊接口的更改将迫使你重寫代碼。
    • 請注意,如果依賴模塊遵循語義版本控制方案,則這種危險會得到緩和
    • 你可以毫無畏懼地使用标準庫中的接口

9 來自标準庫的一些有用(和著名)的接口

9.1 Error 接口

type error interface {
    Error() string
}

這個接口類型被大量使用,用于當函數或方法執行失敗是返會error類型接口:

func (c *Communicator) SendEmailAsynchronously(email *Email) error {
    //...
}

要創建一個 error ,我們通常調用: fmt.Errorf() 返回一個 error 類型的結果,或者使用 errors.New()函數。
當然,你也可以創建實現error接口的類型。

9.2 fmt.Stringer 接口

type Stringer interface {
    String() string
}

使用 Stringer 接口,你可以定義在調用打印方法時如何将類型打印為字符串(fmt.Errorf(),fmt.Println, fmt.Printf, fmt.Sprintf...)

這有一個示例實現

type Human struct {
    Firstname string
    Lastname string
    Age int
    Country string
}

func (h Human) String() string {
    return fmt.Sprintf("human named %s %s of age %d living in %s",h.Firstname,h.Lastname,h.Age,h.Country)
}

Human 現在實現了 Stringer 接口:

package main

func main() {
    var john Human
    john.Firstname = "John"
    john.Lastname = "Doe"
    john.Country = "USA"
    john.Age = 45

    fmt.Println(john)
}

輸出:

human named John Doe of age 45 living in the USA

9.3 sort.Interface 接口

type Interface interface {
    Len() int
    Less(i, j int) bool
    Swap(i, j int)
}

通過在一個類型上實現 sort.Interface 接口,可以對一個類型的元素進行排序(通常,底層類型是一個切片)。

這是一個示例用法(來源:sort/example_interface_test.go):

type Person struct {
    Age int
}
// ByAge implements sort.Interface for []Person based on
// the Age field.
type ByAge []Person

func (a ByAge) Len() int           { return len(a) }
func (a ByAge) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age }
  • ByAge 類型實現了 sort.Interface
    • 底層類型是 Person 的一個切片
  • 接口由三個方法組成:
    • Len() int:返回集合内的元素數
    • Less(i, j int) bool:如果索引 i 處的元素應該排在索引 j 處的元素之前,則返回 true
    • Swap(i, j int):交換索引 i & j 處的元素;換句話說,我們應該将位于索引 j 的元素放在索引 i 處,而位于索引 i 的元素應該放在索引 j 處。
      然後我們可以使用 sort.Sort 函數對 ByAge 類型的變量進行排序
func main() {
    people := []Person{
        {"Bob", 31},
        {"John", 42},
        {"Michael", 17},
        {"Jenny", 26},
    }

    sort.Sort(ByAge(people))
}

10 隐式實現

接口是隐式實現的。當你聲明一個類型時,你不必指定它實現了哪些接口。

11 PHP 和 JAVA

在其他語言中,你必須指定接口實現。
這是 Java 中的一個示例:

// JAVA
public class Cat implements DomesticAnimal{
    public void receiveAffection(){
        //...
    }
    public void giveAffection(){
        //..
    }
}

這是 PHP 中的另一個示例:

//PHP
<?php

class Cat implements DomesticAnimal {
    public function receiveAffection():void {
        // ...
    }
    public function giveAffection():void {
        // ...
    }
}
?>

你可以看到,在聲明實現接口的類時,必須添加關鍵字"implements"

你可能會問 Go 運行時如何處理這些隐式接口實現。我們将後面解釋接口值的機制。

12 空接口

Go 的空接口是你可以編寫的最簡單、體積更小的接口。它的方法集正好由 0 個方法組成。

interface{}

也就是說,每種類型都實現了空接口。你可能會問為什麼需要這麼無聊的空接口。根據定義,空接口值可以保存任何類型的值。如果你想構建一個接受任何類型的方法,它會很有用。
讓我們從标準庫中舉一些例子。

  • log 包中,你有一個 Fatal 方法,可以将任何類型的輸入變量作為輸入:
func (l *Logger) Fatal(v ...interface{}) { }
  • fmt 包中,我們還有許多方法将空接口作為輸入。例如 Printf 函數:
func Printf(format string, a ...interface{}) (n int, err error) { }

12.1 類型轉換

接受空接口作為參數的函數通常需要知道其輸入參數的有效類型。
為此,該函數可以使用“類型開關”,這是一個 switch case 将比較類型而不是值。
這是從标準庫(文件 runtime/error.go,包 runtime)中獲取的示例:

// printany prints an argument passed to panic.
// If panic is called with a value that has a String or Error method,
// it has already been converted into a string by preprintpanics.
func printany(i interface{}) {
    switch v := i.(type) {
    case nil:
        print("nil")
    case bool:
        print(v)
    case int:
        print(v)
    case int8:
        print(v)
    case int16:
        print(v)
    case int32:
        print(v)
    case int64:
        print(v)
    case uint:
        print(v)
    case uint8:
        print(v)
    case uint16:
        print(v)
    case uint32:
        print(v)
    case uint64:
        print(v)
    case uintptr:
        print(v)
    case float32:
        print(v)
    case float64:
        print(v)
    case complex64:
        print(v)
    case complex128:
        print(v)
    case string:
        print(v)
    default:
        printanycustomtype(i)
    }
}

image

12.2 關于空接口的使用

  • 你應該非常小心地使用空接口。
  • 當你别無選擇時,請使用空接口。
  • 空接口不會向将使用你的函數或方法的人提供任何信息,因此他們将不得不參考文檔,這可能會令人沮喪。

你更喜歡哪種方法?

func (c Cart) ApplyCoupon(coupon Coupon) error  {
    //...
}

func (c Cart) ApplyCoupon2(coupon interface{}) (interface{},interface{}) {
    //...
}

ApplyCoupon 方法嚴格指定它将接受和返回的類型。而 ApplyCoupon2 沒有在輸入和輸出中指定它的類型。作為調用方,ApplyCoupon2 的使用難度比 ApplyCoupon 大。

13 實際應用:購物車存儲

13.1 規則說明

你建立了一個電子商務網站;你必須存儲和檢索客戶購物車。必須支持以下兩種行為:

  1. 通過 ID 獲取購物車
  2. 将購物車數據放入數據庫

為這兩種行為提出一個接口。還要創建一個實現這兩個接口的類型(不要實現方法中的邏輯)。

13.2 答案

這是一個設計的接口:

type CartStore interface {
    GetById(ID string) (*cart.Cart, error)
    Put(cart *cart.Cart) (*cart.Cart, error)
}

實現接口的類型:

type CartStoreMySQL struct{}

func (c *CartStoreMySQL) GetById(ID string) (*cart.Cart, error) {
    // implement me
}

func (c *CartStoreMySQL) Put(cart *cart.Cart) (*cart.Cart, error) {
    // implement me
}

另一種實現接口的類型:

type CartStorePostgres struct{}

func (c *CartStorePostgres) GetById(ID string) (*cart.Cart, error) {
    // implement me
}

func (c *CartStorePostgres) Put(cart *cart.Cart) (*cart.Cart, error) {
    // implement me
}
  • 你可以為你使用的每個數據庫模型創建一個特定的實現
  • 添加對新數據庫引擎的支持很容易!你隻需要創建一個實現接口的新類型。

14 為什麼要使用接口?

14.1 易于升級

當你在方法或函數中使用接口作為輸入時,你将程序設計為易于升級的。未來的開發人員(或未來的你)可以在不更改大部分代碼的情況下創建新的實現。

假設你構建了一個執行數據庫讀取、插入和更新的應用程序。你可以使用兩種設計方法:

  1. 創建與你現在使用的數據庫引擎密切相關的類型和方法。
  2. 創建一個接口,列出數據庫引擎的所有操作和具體實現。
  • 在第一種方法中,你創建将特定實現作為參數的方法。
  • 通過這樣做,你将程序限制到一個實現。
  • 在第二種方法中,你創建接受接口的方法。
  • 改變實現就像創建一個實現接口的新類型一樣簡單。

14.2 提高團隊合作

團隊也可以從接口中受益。
在構建功能時,通常需要多個開發人員來完成這項工作。如果工作需要兩個團隊編寫的代碼進行交互,他們可以就一個或多個接口達成一緻。
然後,兩組開發人員可以處理他們的代碼并使用商定的接口。他們甚至可以 mock 其他團隊的返回結果。通過這樣做,團隊不會被阻塞。

14.3 Benefit from a set of routines

在自定義類型上實現接口時,你可以不需要開發就使用的附加功能。讓我們從标準庫中舉一個例子:sort 包。這并不奇怪。這個包是用來進行排序的。這是 go 源代碼的摘錄:

// go v.1.10.1
package sort
//..

type Interface interface {
    // Len is the number of elements in the collection.
    Len() int
    // Less reports whether the element with
    // index i should sort before the element with index j.
    Less(i, j int) bool
    // Swap swaps the elements with indexes i and j.
    Swap(i, j int)
}

// Sort sorts data.
// It makes one call to data.Len to determine n, and O(n*log(n)) calls to
// data.Less and data.Swap. The sort is not guaranteed to be stable.
func Sort(data Interface) {
    n := data.Len()
    quickSort(data, 0, n, maxDepth(n))
}

在第一行,我們聲明當前包:sort。在接下來的幾行中,程序員聲明了一個名為 Interface 的接口。這個接口 Interface 指定了三個方法:Len、Less、Swap

在接下來的幾行中,函數 Sort 被聲明。它将接口類型 data 作為參數。這是一個非常有用的函數,可以對給定的數據進行排序。

我們如何在我們的一種類型上使用這個函數?實現接口

假設你有一個 User 類型:

type User struct {
    firstname string
    lastname string
    totalTurnover float64
}

還有一個類型 Users ,它是 User 類型切片:

type Users []User

讓我們創建一個 Users 實例并用三個 User 類型的變量填充它:

user0 := User{firstname:"John", lastname:"Doe", totalTurnover:1000}
user1 := User{firstname:"Dany", lastname:"Boyu", totalTurnover:20000}
user2 := User{firstname:"Elisa", lastname:"Smith Brown", totalTurnover:70}

users := make([]Users,3)
users[0] = user0
users[1] = user1
users[2] = user2

如果我們想按營業額排序怎麼辦?我們可以從頭開始開發符合我們規範的排序算法。或者我們可以隻實現使用 sort 包中的内置函數.Sort 所需的接口。我們開始吧:

// Compute the length of the array. Easy...
func (users Users) Len() int {
  return len(users)
}

// decide which instance is bigger than the other one
func (users Users) Less(i, j int) bool {
  return users[i].totalTurnover < users[j].totalTurnover
}

// swap two elements of the array
func (users Users) Swap(i, j int) {
    users[i], users[j] = users[j], users[i]
}

通過聲明這些函數,我們可以簡單地使用 Sort 函數:

sort.Sort(users)
fmt.Println(users)
// will output :
[{Elisa Smith Brown 70} {John Doe 1000} {Dany Boyu 20000}]

15 一點建議

  1. 盡量使用标準庫提供的接口
  2. 方法太多的接口很難實現(因為它需要編寫很多方法)。

16 随堂測試

16.1 問題

  1. 舉一個接口嵌入另一個接口的例子。
  2. 判斷真假。嵌入接口中指定的方法不是接口方法集的一部分。
  3. 說出使用接口的兩個優點。
  4. 接口類型的零值是多少?

16.2 答案

  1. 舉一個接口嵌入另一個接口的例子。
type ReadWriter interface {
    Reader
    Writer
}
  1. 判斷真假。嵌入接口中指定的方法不是接口方法集的一部分。
    錯。接口的方法集是由兩個部分組成:
    1. 直接指定到接口中的方法
    2. 來自嵌入接口的方法
  2. 說出使用接口的兩個優點。
    1. 輕松地在開發人員之間拆分工作:
      1.定義接口類型
      2.一個人開發接口的實現
      3.另一個人可以在其功能中使用接口類型
      4.兩個人可以互不幹擾地工作。
    2. 易于升級
      1.當你創建一個接口時,你就創建了一個契約
      2.不同的實現可以履行這個契約。
      3.在一個項目的開始,通常有一個實現
      4.随着時間的推移,可能需要另一種實現方式。
  3. 接口類型的零值是多少?
    nil

17 關鍵要點

  • 接口就是契約
  • 它指定方法(行為)而不實現它們。
type Cart interface {
    GetById(ID string) (*cart.Cart, error)
    Put(cart *cart.Cart) (*cart.Cart, error)
}
  • 接口是一種類型(就像structs, arrays, maps,等)
  • 我們将接口中指定的方法稱為接口的方法集。
  • 一個類型可以實現多個接口。
  • 無需明确類型實現了哪個接口
    • 與其他需要聲明它的語言(PHP、Java 等)相反
  • 一個接口可能嵌入到另一個接口中;在這種情況下,嵌入的接口方法被添加到接口中。
  • 接口類型可以像任何其他類型一樣使用
  • 接口類型的零值為 nil
  • 任何類型實現空接口 interface{}
  • 空接口指定了 0 個方法
  • 要獲取空接口的具體類型,您可以使用 type switch:
switch v := i.(type) {
    case nil:
        print("nil")
    case bool:
        print(v)
    case int:
        print(v)
}
  • 當我們可以通過各種方式實現一個行為時,我們或許可以創建一個接口。
    • 例如:存儲(我們可以使用 MySQL、Postgres、DynamoDB、Redis 數據庫來存儲相同的數據)

Go 交叉編譯 (跨平台編譯)-編程思維

Golang 支持交叉編譯,在一個平台上生成另一個平台的可執行程序 一、Windows 下編譯 Linux 64位 和 Mac 可執行程序 SET CGO_ENABLED=0 SET GOOS=linux SET GOARCH=amd64 go build main.go SET CGO_ENABLED=0 SET

go 自定義http.Client - 動态修改請求Body-編程思維

前言 在對接Alexa Smart Home時,有的請求Payload中需要傳入Access Token,但是這個Token是由OAuth2 Client管理的,封裝Payload時并不知道Access Token。 所以使用自定義RoundTripper,在請求前取出Header裡的token,修改body,實現動态

第十五章:指針類型-編程思維

本篇翻譯自《Practical Go Lessons》 Chapter 15: Pointer type 1 你将在本章将學到什麼? 什麼是指針? 什麼時指針類型? 如何去創建并使用一個指針類型的變量。 指正類型變量的零值是什麼? 什麼是解除引用? slices, maps, 和 channels 有什麼特殊的地方?

第八章:變量、常量和基礎類型-編程思維

本篇翻譯自《Practical Go Lessons》 Chapter 8: Variables, constants and basic types 1 你将在本章中學到什麼? 什麼是變量?我們為什麼需要它們? 什麼是類型? 如何創建變量? 如何給變量賦值? 什麼是常量?常量和變量有什麼區别? 如何定義常量? 如何

第十五章:指針類型-編程思維

本篇翻譯自《Practical Go Lessons》 Chapter 15: Pointer type 1 你将在本章将學到什麼? 什麼是指針? 什麼時指針類型? 如何去創建并使用一個指針類型的變量。 指正類型變量的零值是什麼? 什麼是解除引用? slices, maps, 和 channels 有什麼特殊的地方?

第八章:變量、常量和基礎類型-編程思維

本篇翻譯自《Practical Go Lessons》 Chapter 8: Variables, constants and basic types 1 你将在本章中學到什麼? 什麼是變量?我們為什麼需要它們? 什麼是類型? 如何創建變量? 如何給變量賦值? 什麼是常量?常量和變量有什麼區别? 如何定義常量? 如何