開源技術教學文件網 用介面 (Interface) 實踐繼承和多型

最後修改日期為 JUN 13, 2019

前言

在前一章中,我們介紹 Go 的物件系統。然而,我們在前文的最後面提到,Go 缺乏繼承的機制,我們無法透過繼承來達到多型的效果。為了處理這個議題,Go 引入介面的機制,也就是本文的主題。

什麼是 Golang 介面?

介面 (interface) 是只有方法宣告,但缺乏方法實作的型別。以 Point 類別為例,其介面如下:

type IPoint interface {
    X() float64
    Y() float64
    SetX(float64)
    SetY(float64)
}

繼承介面並實作類別

在這個介面中,我們宣告了四個方法 (method)。使用 IPoint 的開頭 I 只是為了要和原來的 Point 型別區別,不是強制性規定。宣告介面後,要再自行實作類別以滿足此介面。我們來看如何用介面解決我們於前文中所碰到的議題:

package main
 
import (
    "fmt"
)
 
type IPoint interface {
    X() float64
    Y() float64
    SetX(float64)
    SetY(float64)
}
 
type Point struct {
    x float64
    y float64
}
 
func NewPoint(x float64, y float64) *Point {
    p := new(Point)
 
    p.SetX(x)
    p.SetY(y)
 
    return p
}
 
func (p *Point) X() float64 {
    return p.x
}
 
func (p *Point) Y() float64 {
    return p.y
}
 
func (p *Point) SetX(x float64) {
    p.x = x
}
 
func (p *Point) SetY(y float64) {
    p.y = y
}
 
type Point3D struct {
    // Point is embedded
    Point
    z float64
}
 
func NewPoint3D(x float64, y float64, z float64) *Point3D {
    p := new(Point3D)
 
    p.SetX(x)
    p.SetY(y)
    p.SetZ(z)
 
    return p
}
 
func (p *Point3D) Z() float64 {
    return p.z
}
 
func (p *Point3D) SetZ(z float64) {
    p.z = z
}
 
func main() {
    // Make a slice of IPoint
    points := make([]IPoint, 0)
 
    p1 := NewPoint(3, 4)
    p2 := NewPoint3D(1, 2, 3)
    // No error!
    points = append(points, p1, p2)
 
    for _, p := range points {
        fmt.Println(fmt.Sprintf("(%.2f %.2f)", p.GetX(), p.GetY()))
    }
}

在本例中,我們建立一個以 IPoint 為型別的切片 points,並分別加入兩個相異的變數 p1p2,由於這兩個變數都符合我們宣告的介面,不會引發程式的錯誤。最後,我們分別呼叫兩個變數的 XY 方法,印出點所在的位置。

在介面中嵌入介面

如同類別,我們也可以在介面中嵌入另一個介面,如下例:

type IPoint interface {
    X() float64
    Y() float64
    SetX(float64)
    SetY(float64)
}
 
type IPoint3D interface {
    IPoint
    Z() float64
    SetZ(float64)
}

在本例中,IPoint3D 除了繼承 IPoint 所有的方法宣告外,另外加入了自己特有的 ZSetZ 方法。嵌入是介面用來繼承介面的手法。

介面可滿足多型的需求

初學物件導向程式的程式設計者,往往無法馬上體會介面的用處。實際上,介面相當重要。

對於 Go 或其他靜態型別語言來說,一方面要滿足型別檢查,一方面希望程式更加靈活;透過介面,可使得靜態語言程式某種程度上像動態語言程式般,像是要在同一個陣列中加入不同型別的物件時,就可以透過介面來處理。

因為介面是在沒有繼承卻能實作子類型 (subtyping) 的手法。如果讀者去讀一些使用 Java 或 C# 等語言撰寫物件導向程式和設計模式的書,會發現這些書大量地使用介面來撰寫更易於維護的程式碼,反而較少使用繼承。

在這裡我們舉一個設計模式 (design pattern) 的例子,設計模式是一套讓物件導向程式更易於維護的方法。在本例中,我們實作 Builder 模式,此模式的用意在於建立一群相關的物件,將建立的過程集中在同一個 Builder 函式中,使程式碼易於維護。如下例:

package main
 
import (
    "fmt"
)
 
type IAnimal interface {
    Speak()
}
 
type AnimalType int
 
const (
    Duck AnimalType = iota
    Dog
    Tiger
)
 
type DuckClass struct{}
 
func NewDuck() *DuckClass {
    return new(DuckClass)
}
 
func (d *DuckClass) Speak() {
    fmt.Println("Pack pack")
}
 
type DogClass struct{}
 
func NewDog() *DogClass {
    return new(DogClass)
}
 
func (d *DogClass) Speak() {
    fmt.Println("Wow wow")
}
 
type TigerClass struct{}
 
func NewTiger() *TigerClass {
    return new(TigerClass)
}
 
func (t *TigerClass) Speak() {
    fmt.Println("Halum halum")
}
 
func New(t AnimalType) IAnimal {
    switch t {
    case Duck:
        return NewDuck()
    case Dog:
        return NewDog()
    case Tiger:
        return NewTiger()
    default:
        panic("Unknown animal type")
    }
}
 
func main() {
    animals := make([]IAnimal, 0)
 
    duck := New(Duck)
    dog := New(Dog)
    tiger := New(Tiger)
 
    animals = append(animals, duck, dog, tiger)
 
    for _, a := range animals {
        a.Speak()
    }
}

Golang 中隱藏版的運算子重載

運算子重載是指物件可以用內建運算子來操作,如同使用內建型別般。Go 的設計是避免過多的語法魔術,也不支援運算子重載。少數的例外是 String 函式,滿足 String 函式即可用 fmt 套件內的函式將物件印出。以下為實例:

package main
 
import (
    "fmt"
    "strconv"
)
 
type Vector []float64
 
func NewVector(args ...float64) Vector {
    return args
}
 
func (v Vector) String() string {
    out := "("
 
    for i, e := range v {
        out += strconv.FormatFloat(e, 'f', -2, 64)
 
        if i < len(v)-1 {
            out += ", "
        }
    }
 
    out += ")"
 
    return out
}
 
func main() {
    v := NewVector(1, 2, 3, 4, 5)
 
    fmt.Println(v)
}

用空介面當成萬用型別

除了先前所介紹的介面外,還有另外一種沒有任何方法宣告的空介面,寫做 interface{},空介面也是一種特殊型別,該型別可放入任何值;基本上,空介面和介面使用的時機不同,要將其視為兩種不同的概念。以下是一個合法的 Go 程式:

package main
 
import (
    "fmt"
)
 
func main() {
    var v interface{}
 
    // v is an integer.
    v = 1
    fmt.Println(v)
 
    // v becomes a string now.
    v = "Hello"
    fmt.Println(v)
 
    // v becomes a boolean value now.
    v = true
    fmt.Println(v)
}

然而,空介面並不代表我們可以不理會原本的型別系統,例如,以下程式會引發錯誤:

package main
 
import (
    "fmt"
)
 
func main() {
    var m interface{}
    var n interface{}
 
    m = 3
    n = 2
 
    // Error!
    fmt.Println(m + n)
}

我們必需要透過型別判定 (type assertion),告訴 Go 程式該變數實際的型別後,才能使用該變數的值:

package main
 
import (
    "fmt"
)
 
func main() {
    var im interface{}
    var in interface{}
 
    im = 3
    in = 2
 
    // type assertion
    m := im.(int)
    n := in.(int)
 
    fmt.Println(m + n)
}

在進行型別判定時,若不確定型別是否正確,則要進行額外的檢查。以下為實例:

package main
 
import (
    "fmt"
    "log"
)
 
func main() {
    var im interface{}
    var in interface{}
 
    im = 3
    in = 2
 
    // type assertion
    m, ok := im.(int)
    if !ok {
        log.Fatal("Wrong type")
    }
 
    n, ok := in.(int)
    if !ok {
        log.Fatal("Wrong type")
    }
 
    fmt.Println(m + n)
}

如果無法預先知道變數的型別,可用 switch 敘述進行動態判定。實例如下:

package main
 
import (
    "fmt"
)
 
func main() {
    var t interface{}
    t = 3
    switch t := t.(type) {
    default:
        fmt.Printf("unexpected type %T\n", t) // %T prints whatever type t has
    case bool:
        fmt.Printf("boolean %t\n", t) // t has type bool
    case int:
        fmt.Printf("integer %d\n", t) // t has type int
    case *bool:
        fmt.Printf("pointer to boolean %t\n", *t) // t has type *bool
    case *int:
        fmt.Printf("pointer to integer %d\n", *t) // t has type *int
    }
}

其實,Go 標準函式庫中也有使用空介面,例如,在 fmt 套件的 PrintfSprintf 函式中,以下是實例:

package main
 
import (
    "fmt"
)
 
func main() {
    fmt.Println(fmt.Sprintf("%d %s %t", 1, "Hello", true))
}

空介面的設計,某種程度上可使 Go 程式更靈活;然而,空介面會略去 Go 的型別檢查,使程式喪失靜態型別所帶來的益處。筆者的建議是,謹慎地使用空介面,只有在自己明確知道為什麼要用空介面時才使用。

分享本文
Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Yahoo
追蹤本站
Facebook Facebook Twitter