位元詩人 [Golang] 程式設計教學:用介面 (Interface) 實踐繼承和多型

Golang物件
Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

在前一章中,我們介紹 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 的型別檢查,使程式喪失靜態型別所帶來的益處。筆者的建議是,謹慎地使用空介面,只有在自己明確知道為什麼要用空介面時才使用。

關於作者

身為資訊領域碩士,位元詩人 (ByteBard) 認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

位元詩人喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,位元詩人將所學寫成文章,放在這個網站上和大家分享。