主題 達人專欄

Go 語言是物件導向嗎?淺談 struct

解凍豬腳 | 2021-11-15 19:15:01 | 巴幣 2610 | 人氣 2501

 
本篇文章有不少程式碼,如果這裡的排版讓你感到閱讀困難,請服用 HackMD 好讀版

「使用者必須把 code 寫得漂亮」,這件事情可以說是 Golang 對於每個使用者的基本要求,更是這個語言一向奉行的原則。要是把所有的變數都散開來定義就會讓程式碼看起來很醜,這時候充分地把物件包裝起來就顯得特別重要。

如果你曾經學過 Python、Java、C#、Ruby 等等的物件導向語言,你也許會知道可以把同類的內容封裝成一個 class,接著再產生一個實體物件來使用。

比如說,我們可以在 Python 裡面先定義一個 class 叫做 Dog,定義每個 Dog 都具有 age、name、color 等等屬性,以及 sleep、eat 等方法,等到遇上了要使用 Dog 的場合,只要產生一個 Dog 的實體,接著便能利用這個實體做那些 Dog 能做的事。這就好像描述好了一個功能齊全的資料型態,要用的時候才宣告一個實際的變數來用。

然而 Golang 實際上並不是物件導向程式語言。如果要做到類似封裝、繼承、多型的功能,就得要利用 struct 和 interface 來達成。礙於篇幅,interface 的部分就等到之後再說了。


► 結構體的宣告和屬性

熟悉 C 語言的朋友應該對 struct 不陌生,你可以在《跟著豬腳 C 起來》系列的〈一次宣告一堆變數的方法〉篇裡看到完整的教學。如果我們有一堆重複的變數需要按照特定的格式來儲存,就可以利用 struct 來預先定義好那所謂的「格式」。

就拿前面提到的 dog 當作例子吧!假設今天我們有個很大的房間,可以容納一堆狗。我希望產生很多隻狗,這些狗有各自的屬性。那麼,我們必須先定義一隻狗擁有的屬性:

type dog struct {
    color int
    birth time.Time
}

於是我們就創造了「dog」這樣一個資料型態。

如此一來,以後當我們宣告一個 dog 型態的變數,這個變數就會自帶 color 跟 birth,也就是「顏色」和「出生時間」這兩個屬性了。正如在〈Go 語言的資料型態〉篇所言,我們可以把各種顏色定義成常數,利用在這個場合:

const (
    ColorYellow = iota
    ColorBlack
    ColorWhite
    ColorRainbow
    ColorTransparent
)

func getColor(color int) string {
    switch color {
    case ColorYellow:
        return "yellow"
    case ColorBlack:
        return "black"
    case ColorWhite:
        return "white"
    case ColorRainbow:
        return "rainbow"
    case ColorTransparent:
        return "transparent"
    default:
        return "unknown"
    }
}

只要用 dog{} 就可以直接表示一個產生好的結構實體了,用一個 []dog 來存放大量的 dog:

func main() {
    var dogs []dog
    dogs = append(dogs, dog{color: ColorBlack, birth: time.Now()})
    dogs = append(dogs, dog{color: ColorWhite, birth: time.Now()})
    dogs = append(dogs, dog{color: ColorWhite, birth: time.Now()})
    dogs = append(dogs, dog{color: ColorYellow, birth: time.Now()})
    dogs = append(dogs, dog{color: ColorTransparent, birth: time.Now()})
    dogs = append(dogs, dog{color: ColorRainbow, birth: time.Now()})
    fmt.Printf("剛才房裡新增了 %d 隻狗:\n", len(dogs))
    for index, value := range dogs {
        fmt.Printf("編號 %d, 顏色 %s, 產生時間: %v\n", index, getColor(value.color), value.birth)
    }
}

如果覺得整個結構縮減到一行會讓人難以閱讀,這些屬性之間可以換行:

myDog := dog{
    color: ColorYellow,
    birth: time.Now(),
}
fmt.Println("狗勾的顏色:", getColor(myDog.color))
fmt.Println("狗勾的出生時間:", myDog.birth)

也可以把產生實體的行為寫成函式,直接 return 一個實體回來:

func NewDog(color int) dog {
    return dog{
        color: color,
        birth: time.Now(),
    }
}

func main() {
    myDog := NewDog(ColorWhite)
    fmt.Printf("狗勾顏色: %s, 狗勾出生時間: %v", getColor(myDog.color), myDog.birth)
}


► 屬性的「繼承」

有些場合下,也許你會需要定義一些共同的屬性,比如我希望房間裡面能有貓、狗、倉鼠,他們同樣都是動物,但又各自有一些不太一樣的屬性。我們知道,動物可能會有出生時間、身長、睡眠狀態,而在貓身上你在意的是貓的爪子長度和移動速度、在狗身上你在意的是吃了多少骨頭和吠叫的最大音量、在倉鼠身上你在意的是牠嘴裡藏了多少葵花籽。我們可以這麼定義:

type animal struct {
    birth  time.Time
    length int
    asleep bool
}

type cat struct {
    basicProperty animal
    pawLength     int
    speed         int
}

type dog struct {
    basicProperty animal
    boneCount     int
    barkVolume    int
}

type hamster struct {
    basicProperty animal
    seedCount     int
}

func main() {
    myCat := cat{
        basicProperty: animal{
            birth:  time.Now(),
            length: 50,
            asleep: false,
        },
        pawLength: 3,
        speed:     17,
    }
    fmt.Println("可愛a卯咪:")
    fmt.Println("出生時間:", myCat.basicProperty.birth)
    fmt.Println("身長:", myCat.basicProperty.length, "cm")
    fmt.Println("睡眠狀態:", myCat.basicProperty.asleep)
    fmt.Println("爪長:", myCat.pawLength, "mm")
    fmt.Println("速度:", myCat.speed, "m/s")

    myCat.basicProperty.asleep = true
    fmt.Println("你的卯咪睡著了")
}

這種做法,其實就是單純地把一個 struct 塞到另一個 struct 裡面而已。這樣寫起來畢竟有些囉唆,你會發現這裡的 birth、length、asleep 和 pawLength、speed 並不在同一個層級上,這對一些強迫症患者來說確實難受。

這樣的窘境有解法嗎?有!你只需要在定義的時候把 animal 直接寫上去而不取名,它就會把 animal 底下的屬性全都挪來用了:

type animal struct {
    birth  time.Time
    length int
    asleep bool
}

type cat struct {
    animal
    pawLength int
    speed     int
}

func main() {
    myCat := cat{
        animal: animal{
            birth:  time.Now(),
            length: 50,
            asleep: false,
        },
        pawLength: 3,
        speed:     17,
    }
    fmt.Println("可愛a卯咪:")
    fmt.Println("出生時間:", myCat.birth)
    fmt.Println("身長:", myCat.length, "cm")
    fmt.Println("睡眠狀態:", myCat.asleep)
    fmt.Println("爪長:", myCat.pawLength, "mm")
    fmt.Println("速度:", myCat.speed, "m/s")

    myCat.asleep = true
    fmt.Println("你的卯咪睡著了")
}

直接把名字給省了,這樣就相當於把 animal 嵌進去,感覺就像是讓 cat 直接具有 animal 的屬性。


► 替結構體定義方法

上面這些僅僅是結構體的「屬性」,我們只有做到把一堆不同型態的變數集合起來存取而已。如果想要實踐物件導向的思維,有時候我們會需要讓這個物件具備「方法」,比如說我們希望讓這隻狗後空翻,那麼我們就得讓產生出來的 dog 能夠有個 flip() 之類的函式可以執行。

在 Golang 裡,我們想要替結構體定義方法,只需要在定義函式的時候,前面加上結構體的名字和變數就可以了:

func (d dog) flip(times int) {
    if d.asleep {
        fmt.Println("狗勾正在睡覺")
        return
    }
    for i := 0; i < times; i++ {
        fmt.Println("狗勾翻了一圈")
    }
    if times > 3 {
        fmt.Println("牠快累死了")
    }
}

在定義函式 flip 的時候,前面加上一個 (d dog),代表我們把 flip() 函式定義在 dog 這個結構裡,並且用 d 來代表結構體自己,這裡的 d 就像是物件導向程式語言常見的 self 一樣,誰來執行 flip 函式,d 就會成為誰。定義好之後,所有的 dog 實體就都具備 flip 的功能了。

dog1 := dog{
    animal: animal{
        birth:  time.Now(),
        length: 80,
        asleep: false,
    },
    barkVolume: 70,
    boneCount:  3,
}
dog2 := dog{
    animal: animal{
        birth:  time.Now(),
        length: 75,
        asleep: true,
    },
    barkVolume: 65,
    boneCount:  4,
}
fmt.Println("嘗試叫 dog1 後空翻:")
dog1.flip(7)
fmt.Println("嘗試叫 dog2 後空翻:")
dog2.flip(5)


► 不使用指標所帶來的問題

話雖如此,實務上我們並不會直接只用 dog{} 來產生實體。如果你嘗試執行以下的程式碼:

type dog struct {
    asleep bool
}

func main() {
    myDog := dog{
        asleep: false,
    }
    myDog.sleep()
    fmt.Println(myDog.asleep) // -> false
}

func (d dog) sleep() {
    d.asleep = true
}

你會發現即使執行了 sleep(),你的 myDog 仍然會告訴你它的 asleep 仍然是 false,搞得好像 sleep() 完全不起作用一樣。這其實是因為我們宣告的是「結構體變數」,而不是「結構體變數的指標」。當我們像上面一樣執行 myDog.sleep() 的時候,系統會把 myDog 內的值全部複製一份下來,然後成為一個新的變數傳到 d 裡面。

所以當你把 d 的 asleep 設為 true,根本就不會影響到原來的 myDog,這個行為就好像:

func setAsZero(number int) {
    number = 0
}

func main() {
    myNumber := 7
    setAsZero(myNumber)
    fmt.Println(myNumber) // -> 7
}

因此,我們應該要把 myDog 宣告成 *dog 型態,並且用 &dog{…} 來表示,除了產生實體以外,直接取得它的記憶體位址:

type dog struct {
    asleep bool
}

func main() {
    myDog := &dog{
        asleep: false,
    }
    myDog.sleep()
    fmt.Println(myDog.asleep) // -> true
}

func (d *dog) sleep() {
    d.asleep = true
}

如此一來,傳進 sleep() 的就會是地址的值,而不是另外多複製一份變數進去,你也能藉此確保你修改的對象是傳進去的結構體變數本身。


► nil pointer

既然都提到指標了,就順帶講講使用不慎可能會遇上的 nil pointer 問題吧。

我們先前曾經說過,除了直接使用「:=」運算子來宣告並賦值以外,我們還可以使用「var 名稱 型態」的方式來宣告變數。倘若用 var 關鍵字來宣告 int、float64、string 等等類型,系統會自動賦予它一個 zero-value 作為初始值,比如當你宣告一個 int,只要你沒有自己指定,那它的預設值就會是 0。

但如果你用 var 宣告了一個 *dog,並且嘗試直接對這個變數做一個 dog 可以做的事,就會發生 panic 而導致程式被強制中斷,因為你只是宣告了一個「可以用來放 dog 地址的變數」而已,記憶體並沒有任何一個實際的 dog 變數被產生:

func main() {
    var myDog *dog
    myDog.sleep() // -> panic: runtime error
}

這就好像你買了一個空的狗籠,卻嘗試朝著這狗籠呼喚名字一樣,裡面根本就沒有狗,當然會失敗。若要產生實體,你可以使用大括號 {} 或者是 new() 來達成(使用 new 的話會得到一個產生好的結構變數的地址),以下的五種方法都是 OK 的:

var myDog = new(dog)
var myDog *dog = new(dog)
var myDog = &dog{}
myDog := new(dog)
myDog := &dog{}

也就是說,我們剛才提到那一個會回傳 dog 回來的建構式 NewDog(color int) 的例子,如果改成回傳 *dog 會更好;當我們需要存放多個 dog 的時候,比起 []dog 而言,用 []*dog 來存放多個 *dog 的做法也更好。



關於結構體大概就是這樣了。結構體可以說是 Golang 的其中一種核心功能,實作起來還可以和 package、interface 互相搭配來實現其他功能,這些就等下次談到 interface 的時候再接著講吧。


縮圖素材原作者:Renée French(CC BY-SA 3.0)
送禮物贊助創作者 !
194
留言

創作回應

小紅帽向創作者進行贊助 ✦
快沒石頭ㄌ,都拿去委託ㄌQQ
2021-11-15 19:16:24
解凍豬腳
不要這樣講
你送我這麼多東西我抖內怎麼還收得下去 [e3]
2021-11-16 13:41:36
魔化鬼鬼
1000巴幣奉上
2021-11-15 19:26:44
解凍豬腳
讓 Golang 偉大起來
2021-11-16 13:41:50
迅疾747向創作者進行贊助 ✦
優質文[e19]
2021-11-15 19:58:38
解凍豬腳
臺灣好像很多人不知道有這語言,心裡希望大家都來學 Golang
2021-11-18 20:43:34
勳章向創作者進行贊助 ✦
2021-11-16 00:28:15
勳章向創作者進行贊助 ✦
豬腳作業教ㄇ
2021-11-16 00:29:58
解凍豬腳
感謝贊助
教啦哪次不教
2021-11-16 13:42:27

更多創作