struct 和 interface:結構體與介面都實現了哪些功能?

Swenson1992發表於2021-02-04

結構體

結構體定義

結構體是一種聚合型別,裡面可以包含任意型別的值,這些值就是我們定義的結構體的成員,也稱為欄位。在 Go 語言中,要自定義一個結構體,需要使用 type+struct 關鍵字組合。
定義了一個結構體型別,名稱為 person,表示一個人。這個 person 結構體有兩個欄位:name 代表這個人的名字,age 代表這個人的年齡。

type person struct {
    name string
    age uint
}

在定義結構體時,欄位的宣告方法和平時宣告一個變數是一樣的,都是變數名在前,型別在後,只不過在結構體中,變數名稱為成員名或欄位名。

結構體的成員欄位並不是必需的,也可以一個欄位都沒有,這種結構體成為空結構體。

根據以上資訊,可以總結出結構體定義的表示式,如下面的程式碼所示:

type structName struct{
    fieldName typeName
    ....
    ....
}

其中:

  • type 和 struct 是 Go 語言的關鍵字,二者組合就代表要定義一個新的結構體型別。
  • structName 是結構體型別的名字。
  • fieldName 是結構體的欄位名,而 typeName 是對應的欄位型別。
  • 欄位可以是零個、一個或者多個。

    小提示:結構體也是一種型別,比如 person 結構體和 person 型別其實是一個意思。

定義好結構體後就可以使用了,因為它是一個聚合型別,所以比普通的型別可以攜帶更多資料。

結構體宣告使用

結構體型別和普通的字串、整型一樣,也可以使用同樣的方式宣告和初始化。

下面宣告瞭一個 person 型別的變數 p,因為沒有對變數 p 初始化,所以預設會使用結構體裡欄位的零值。

var p person

當然在宣告一個結構體變數的時候,也可以通過結構體字面量的方式初始化,如下面的程式碼所示:

p:=person{"Golang",5}

採用簡短宣告法,同時採用字面量初始化的方式,把結構體變數 p 的 name 初始化為“Golang”,age 初始化為 5,以逗號分隔。

宣告瞭一個結構體變數後就可以使用了,執行以下程式碼,驗證 name 和 age 的值是否和初始化的一樣。

fmt.Println(p.name,p.age)

在 Go 語言中,訪問一個結構體的欄位和呼叫一個型別的方法一樣,都是使用點操作符“.”。

採用字面量初始化結構體時,初始化值的順序很重要,必須和欄位定義的順序一致。

在 person 這個結構體中,第一個欄位是 string 型別的 name,第二個欄位是 uint 型別的 age,所以在初始化的時候,初始化值的型別順序必須一一對應,才能編譯通過。也就是說,在示例 {“Golang”,5} 中,表示 name 的字串Golang必須在前,表示年齡的數字 5 必須在後。

那麼是否可以不按照順序初始化呢?當然可以,只不過需要指出欄位名稱,如下所示:

p:=person{age:5,name:"Golang"}

其中,第一位是整型的 age,也可以編譯通過,因為採用了明確的 field:value 方式進行指定,這樣 Go 語言編譯器會清晰地知道要初始化哪個欄位的值。

有沒有發現,這種方式和 map 型別的初始化很像,都是採用冒號分隔。Go 語言儘可能地重用操作,不發明新的表示式,便於我們記憶和使用。

當然也可以只初始化欄位 age,欄位 name 使用預設的零值,如下面的程式碼所示,仍然可以編譯通過。

p:=person{age:30}

欄位結構體

結構體的欄位可以是任意型別,也包括自定義的結構體型別,比如下面的程式碼:

type person struct {
    name string
    age uint
    addr address
}
type address struct {
    province string
    city string
}

在這個示例中,定義了兩個結構體:person 表示人,address 表示地址。在結構體 person 中,有一個 address 型別的欄位 addr,這就是自定義的結構體。

通過這種方式,用程式碼描述現實中的實體會更匹配,複用程度也更高。對於巢狀結構體欄位的結構體,其初始化和正常的結構體大同小異,只需要根據欄位對應的型別初始化即可,如下面的程式碼所示:

p:=person{
    age:5,
    name:"Golang",
    addr:address{
        province: "北京",
        city:     "北京",
    },
}

如果需要訪問結構體最裡層的 province 欄位的值,同樣也可以使用點操作符,只不過需要使用兩個點,如下面的程式碼所示:

fmt.Println(p.addr.province)

第一個點獲取 addr,第二個點獲取 addr 的 province。

介面

介面的定義

介面是和呼叫方的一種約定,它是一個高度抽象的型別,不用和具體的實現細節繫結在一起。介面要做的是定義好約定,告訴呼叫方自己可以做什麼,但不用知道它的內部實現,這和我們見到的具體的型別如 int、map、slice 等不一樣。

介面的定義和結構體稍微有些差別,雖然都以 type 關鍵字開始,但介面的關鍵字是 interface,表示自定義的型別是一個介面。也就是說 Stringer 是一個介面,它有一個方法 String() string,整體如下面的程式碼所示:

type Stringer interface {
    String() string
}

提示:Stringer 是 Go SDK 的一個介面,屬於 fmt 包。

針對 Stringer 介面來說,它會告訴呼叫者可以通過它的 String() 方法獲取一個字串,這就是介面的約定。至於這個字串怎麼獲得的,長什麼樣,介面不關心,呼叫者也不用關心,因為這些是由介面實現者來做的。

介面的實現

介面的實現者必須是一個具體的型別,繼續以 person 結構體為例,讓它來實現 Stringer 介面,如下程式碼所示:

func (p person) String()  string{
    return fmt.Sprintf("the name is %s,age is %d",p.name,p.age)
}

給結構體型別 person 定義一個方法,這個方法和介面裡方法的簽名(名稱、引數和返回值)一樣,這樣結構體 person 就實現了 Stringer 介面。

注意:如果一個介面有多個方法,那麼需要實現介面的每個方法才算是實現了這個介面。

實現了 Stringer 介面後就可以使用了。首先先定義一個可以列印 Stringer 介面的函式,如下所示:

func printString(s fmt.Stringer){
    fmt.Println(s.String())
}

這個被定義的函式 printString,它接收一個 Stringer 介面型別的引數,然後列印出 Stringer 介面的 String 方法返回的字串。

printString 這個函式的優勢就在於它是面向介面程式設計的,只要一個型別實現了 Stringer 介面,都可以列印出對應的字串,而不用管具體的型別實現。

因為 person 實現了 Stringer 介面,所以變數 p 可以作為函式 printString 的引數,可以用如下方式列印:

printString(p)

結果為

the name is Golang,age is 5

現在讓結構體 address 也實現 Stringer 介面,如下面的程式碼所示:

func (addr address) String()  string{
    return fmt.Sprintf("the addr is %s%s",addr.province,addr.city)
}

因為結構體 address 也實現了 Stringer 介面,所以 printString 函式不用做任何改變,可以直接被使用,列印出地址,如下所示:

printString(p.addr)

結果為

輸出:the addr is 北京北京

這就是面向介面的好處,只要定義和呼叫雙方滿足約定,就可以使用,而不用管具體實現。介面的實現者也可以更好的升級重構,而不會有任何影響,因為介面約定沒有變。

值接收者和指標接收者

如果要實現一個介面,必須實現這個介面提供的所有方法,也知道定義一個方法,有值型別接收者和指標型別接收者兩種。二者都可以呼叫方法,因為 Go 語言編譯器自動做了轉換,所以值型別接收者和指標型別接收者是等價的。但是在介面的實現中,值型別接收者和指標型別接收者不一樣,下面詳細分析二者的區別。
測試如下呼叫

printString(&p)

測試後會發現,把變數 p 的指標作為實參傳給 printString 函式也是可以的,編譯執行都正常。這就證明了以值型別接收者實現介面的時候,不管是型別本身,還是該型別的指標型別,都實現了該介面。

值接收者(p person)實現了 Stringer 介面,那麼型別 person 和它的指標型別*person就都實現了 Stringer 介面。

現在,把接收者改成指標型別,如下程式碼所示:

func (p *person) String()  string{
    return fmt.Sprintf("the name is %s,age is %d",p.name,p.age)
}

修改成指標型別接收者後會發現,測試的程式碼 printString(p) 程式碼編譯不通過,提示如下錯誤:

./main.go:17:13: cannot use p (type person) as type fmt.Stringer in argument to printString:
    person does not implement fmt.Stringer (String method has pointer receiver)

意思就是型別 person 沒有實現 Stringer 介面。這就證明了以指標型別接收者實現介面的時候,只有對應的指標型別才被認為實現了該介面。
如下表格總結這兩種接收者型別的介面實現規則:

方法接收者 實現介面的型別
(p person) person 和 *person
(p *person) *person

可以這樣解讀:

  • 當值型別作為接收者時,person 型別和*person型別都實現了該介面。
  • 當指標型別作為接收者時,只有*person型別實現了該介面。

可以發現,實現介面的型別都有*person,這也表明指標型別比較萬能,不管哪一種接收者,它都能實現該介面。

工廠函式

工廠函式一般用於建立自定義的結構體,便於使用者呼叫,還是以 person 型別為例,用如下程式碼進行定義:

func NewPerson(name string) *person {
    return &person{name:name}
}

定義了一個工廠函式 NewPerson,它接收一個 string 型別的引數,用於表示這個人的名字,同時返回一個*person。

通過工廠函式建立自定義結構體的方式,可以讓呼叫者不用太關注結構體內部的欄位,只需要給工廠函式傳參就可以了。

用下面的程式碼,即可建立一個*person 型別的變數 p1:

p1:=NewPerson("小宋")

工廠函式也可以用來建立一個介面,它的好處就是可以隱藏內部具體型別的實現,讓呼叫者只需關注介面的使用即可。

現在以 errors.New 這個 Go 語言自帶的工廠函式為例,演示如何通過工廠函式建立一個介面,並隱藏其內部實現,如下程式碼所示:

//工廠函式,返回一個error介面,其實具體實現是*errorString
func New(text string) error {
    return &errorString{text}
}
//結構體,內部一個欄位s,儲存錯誤資訊
type errorString struct {
    s string
}
//用於實現error介面
func (e *errorString) Error() string {
    return e.s
}

其中,errorString 是一個結構體型別,它實現了 error 介面,所以可以通過 New 工廠函式,建立一個 *errorString 型別,通過介面 error 返回。

這就是面向介面的程式設計,假設重構程式碼,哪怕換一個其他結構體實現 error 介面,對呼叫者也沒有影響,因為介面沒變。

繼承和組合

在 Go 語言中沒有繼承的概念,所以結構、介面之間也沒有父子關係,Go 語言提倡的是組合,利用組合達到程式碼複用的目的,這也更靈活。

以 Go 語言 io 標準包自帶的介面為例,講解型別的組合(也可以稱之為巢狀),如下程式碼所示:

type Reader interface {
    Read(p []byte) (n int, err error)
}
type Writer interface {
    Write(p []byte) (n int, err error)
}
//ReadWriter是Reader和Writer的組合
type ReadWriter interface {
    Reader
    Writer
}

ReadWriter 介面就是 Reader 和 Writer 的組合,組合後,ReadWriter 介面具有 Reader 和 Writer 中的所有方法,這樣新介面 ReadWriter 就不用定義自己的方法了,組合 Reader 和 Writer 的就可以了。

不止介面可以組合,結構體也可以組合,現在把 address 結構體組合到結構體 person 中,而不是當成一個欄位,如下所示:

type person struct {
    name string
    age uint
    address
}

直接把結構體型別放進來,就是組合,不需要欄位名。組合後,被組合的 address 稱為內部型別,person 稱為外部型別。修改了 person 結構體後,宣告和使用也需要一起修改,如下所示:

p:=person{
        age:5,
        name:"Golang",
        address:address{
            province: "北京",
            city:     "北京",
        },
    }
//像使用自己的欄位一樣,直接使用
fmt.Println(p.province)

因為 person 組合了 address,所以 address 的欄位就像 person 自己的一樣,可以直接使用。

型別組合後,外部型別不僅可以使用內部型別的欄位,也可以使用內部型別的方法,就像使用自己的方法一樣。如果外部型別定義了和內部型別同樣的方法,那麼外部型別的會覆蓋內部型別,這就是方法的覆寫。

小提示:方法覆寫不會影響內部型別的方法實現。

型別斷言

有了介面和實現介面的型別,就會有型別斷言。型別斷言用來判斷一個介面的值是否是實現該介面的某個具體型別。
如下所示:

func (p *person) String()  string{
    return fmt.Sprintf("the name is %s,age is %d",p.name,p.age)
}
func (addr address) String()  string{
    return fmt.Sprintf("the addr is %s%s",addr.province,addr.city)
}

可以看到,*person 和 address 都實現了介面 Stringer,然後通過下面的示例講解型別斷言:

    var s fmt.Stringer
    s = p1
    p2:=s.(*person)
    fmt.Println(p2)

如上所示,介面變數 s 稱為介面 fmt.Stringer 的值,它被 p1 賦值。然後使用型別斷言表示式 s.(person),嘗試返回一個 p2。如果介面的值 s 是一個person,那麼型別斷言正確,可以正常返回 p2。如果介面的值 s 不是一個 *person,那麼在執行時就會丟擲異常,程式終止執行。

小提示:這裡返回的 p2 已經是 *person 型別了,也就是在型別斷言的時候,同時完成了型別轉換。

在上面的示例中,因為 s 的確是一個 *person,所以不會異常,可以正常返回 p2。但是如果再新增如下程式碼,對 s 進行 address 型別斷言,就會出現一些問題:

a:=s.(address)
fmt.Println(a)

這個程式碼在編譯的時候不會有問題,因為 address 實現了介面 Stringer,但是在執行的時候,會丟擲如下異常資訊:

panic: interface conversion: fmt.Stringer is *main.person, not main.address

這顯然不符合初衷,本來想判斷一個介面的值是否是某個具體型別,但不能因為判斷失敗就導致程式異常。考慮到這點,Go 語言提供了型別斷言的多值返回,如下所示:

    a,ok:=s.(address)
    if ok {
        fmt.Println(a)
    }else {
        fmt.Println("s不是一個address")
    }

型別斷言返回的第二個值“ok”就是斷言是否成功的標誌,如果為 true 則成功,否則失敗。

總結

結構體是對現實世界的描述,介面是對某一類行為的規範和抽象。通過它們,可以實現程式碼的抽象和複用,同時可以面向介面程式設計,把具體實現細節隱藏起來,讓寫出來的程式碼更靈活,適應能力也更強。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
?

相關文章