前言
在前一章中,我們介紹 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
,並分別加入兩個相異的變數 p1
和 p2
,由於這兩個變數都符合我們宣告的介面,不會引發程式的錯誤。最後,我們分別呼叫兩個變數的 X
和 Y
方法,印出點所在的位置。
在介面中嵌入介面
如同類別,我們也可以在介面中嵌入另一個介面,如下例:
type IPoint interface {
X() float64
Y() float64
SetX(float64)
SetY(float64)
}
type IPoint3D interface {
IPoint
Z() float64
SetZ(float64)
}
在本例中,IPoint3D 除了繼承 IPoint 所有的方法宣告外,另外加入了自己特有的 Z
及 SetZ
方法。嵌入是介面用來繼承介面的手法。
介面可滿足多型的需求
初學物件導向程式的程式設計者,往往無法馬上體會介面的用處。實際上,介面相當重要。
對於 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
套件的 Printf
及 Sprintf
函式中,以下是實例:
package main
import (
"fmt"
)
func main() {
fmt.Println(fmt.Sprintf("%d %s %t", 1, "Hello", true))
}
空介面的設計,某種程度上可使 Go 程式更靈活;然而,空介面會略去 Go 的型別檢查,使程式喪失靜態型別所帶來的益處。筆者的建議是,謹慎地使用空介面,只有在自己明確知道為什麼要用空介面時才使用。