第二章 - 結構

Go 不像 C++、Java、Ruby 或 C# 一樣是物件導向程式語言。他沒有物件或繼承,也沒有許多物件導向的概念,比如說多形或重載。

Go 語言擁有的是結構,和方法有關。Go 也支持簡潔但有效的組合關係。整體來說,他會讓程式碼更簡潔,但你有時候也會失去一些物件導向所提供的特性。(值得一提的是,組合優於繼承 這樣的說法長久以來不斷被討論,而 Go 是我用的第一個可以對這個說法採取堅定立場的語言)

即使 Go 可能不像你在寫物件導向程式語言那樣熟悉,但你會發現結構和類別有很多相像之處,讓我們來看一個簡單的 Saiyan 結構:

type Saiyan struct {
  Name string
  Power int
}

我們很快將看到如何在這個結構中增加一個方法,就像你將方法作為類別的一部分。在我們這樣做之前,我們必須回顧宣告的用法。

宣告及初始化

當我們第一次看到變數和宣告時,我們只學習了內建的型態,比如說整數或字串。現在,我們來談談結構。我們同時必須來學習指針。

建立一個結構的值,最簡單的方法是:

goku := Saiyan{
  Name: "Goku",
  Power: 9000,
}

注意: 上面的結構中最後的 , 是必要的。你會感激需要逗點所代表的一致性,特別是你用過其他不需要強制使用逗號的程式語言。

在初始化結構時,我們不需要設置所有的欄位。下面兩個宣告都是合法的:

goku := Saiyan{}

// 或者

goku := Saiyan{Name: "Goku"}
goku.Power = 9000

就像沒有指派的變數一樣,欄位也會被指派為對應的零值。

此外,你還可以省略欄位的名稱,這樣就會按照順序來對應賦值(為了清楚起見,你應該僅僅在少量欄位名稱的時候使用這種操作):

goku := Saiyan{"Goku", 9000}

上面的各種範例都是宣告一個 goku 的結構變數,並且指派對應的值。

許多時候,我們不想要一個直接關聯的變數,而是想要一個指向該變數所儲存的值的指標。指標所儲存的內容是記憶體位置,找到這個記憶體位置就可以找到對應的值。這是一種對應的關係, 就像你的房子和前往你房子的方向一樣。

為什麼我們想要值的指標,而不是值本身呢?這就必須要知道 Go 傳遞參數到一個函式:用副本的方式。了解這個之後,來看看底下會印出什麼?

func main() {
  goku := Saiyan{"Goku", 9000}
  Super(goku)
  fmt.Println(goku.Power)
}

func Super(s Saiyan) {
  s.Power += 10000
}

答案是 9000,而不是 19000,為什麼?因為 Super 改變了 goku 副本的值,而並非原本呼叫 Super 所傳進去的 goku。因此,在 Super 中的變更並不會反應到呼叫 Super 時所傳入的 goku 上。為了讓程式碼的行為如你所預期,我們必須要傳入指標:

func main() {
  goku := &Saiyan{"Goku", 9000}
  Super(goku)
  fmt.Println(goku.Power)
}

func Super(s *Saiyan) {
  s.Power += 10000
}

在這裡我們調整兩個部分。第一個是我們使用 & 運算子來取得對應值的記憶體位置(這叫做 取得記憶體位置 運算子)。接著,我們變更 Super 函式的參數。之前我們預期的參數是 Saiyan 結構的值,但是現在我們預期的參數是 *Saiyan 型態,*X 的意思是 型別 X 的值的指標。顯而易見的,Saiyan*Saiyan 勢必有些關聯,但他們是兩種完全不同的型別。

注意我們仍然將 goku 的副本值傳給 Super,只是 goku 的值變成了記憶體位置。

我們可以試著變更這個副本指向的位置來證明他的確是個副本(不過這也許不是你本來會做的事情):

func main() {
  goku := &Saiyan{"Goku", 9000}
  Super(goku)
  fmt.Println(goku.Power)
}

func Super(s *Saiyan) {
  s = &Saiyan{"Gohan", 1000}
}

上面的例子中,還是會印出 9000。這個行為在許多的程式語言都是如此,包含:Ruby, Python, Java 和 C#。在 Go 和某種程度的 C# 上,只是讓這個事實更顯著。

更顯而易見的,指標的副本會比整個複雜結構的副本來的輕量多了。在 64 位元的機器上,一個指標的大小是 64 bits。如果我們有一個包含許多欄位的結構,建立一個副本會是相當昂貴的。指標的真正價值是讓你共享值,想想看,我們是想要讓 Super 變更 goku 的副本,還是共享 goku 值的本身呢?

這一切不是說你永遠都要使用指標。在這章節的最後,等到我們看了更多關於結構的內容後,我們會再重新看看指標和值的問題。

函式和結構

我們可以將一個方法與結構互相關聯:

type Saiyan struct {
  Name string
  Power int
}

func (s *Saiyan) Super() {
  s.Power += 10000
}

在上面的程式中,我們說 *Saiyan 型別是 Super 方法的接收者。我們可以這樣呼叫 Super

goku := &Saiyan{"Goku", 9001}
goku.Super()
fmt.Println(goku.Power) // 將列印出 19001

建構子

結構並沒有所謂的建構子。相反的,你可以建立一個函式,回傳值是你所需要型別的實例(就像工廠模式一樣):

func NewSaiyan(name string, power int) *Saiyan {
  return &Saiyan{
    Name: name,
    Power: power,
  }
}

這種模式讓很多開發者走到錯誤的路上。一方面,它是一個很微妙的語法,另一方面,他確實感覺有點不直覺。

我們的工廠模式不一定要回傳一個指標,下面這段程式碼也是完全合法的:

func NewSaiyan(name string, power int) Saiyan {
  return Saiyan{
    Name: name,
    Power: power,
  }
}

New

儘管 Go 語言中沒有建構子,但 Go 卻有內建的 new 函式,用來分配對應型別的記憶體空間。就結果來看,new(X)&X{} 是一樣的。

goku := new(Saiyan)
// 相同於
goku := &Saiyan{}

要用哪一種方法都可以,但你會發現大多數人都喜歡用後者,無論他們是否有對應的欄位需要初始化。原因是他比較容易閱讀。

goku := new(Saiyan)
goku.name = "goku"
goku.power = 9001

// vs

goku := &Saiyan {
  name: "goku",
  power: 9000,
}

無論你採用哪種方法,只要遵循工廠模式,你可以放心的不去管後面如何分配記憶體位置的種種細節。

結構中的欄位

在我們已經看過的例子中,Saiyan 結構有兩個欄位,字串型別的 Name 和 整數型別的 Power。事實上,結構的欄位可以是任何型別,包括其他的結構,或是任何我們還沒有介紹過的陣列、map、介面和函式。

例如,我們可以擴展 Saiyan 的定義:

type Saiyan struct {
  Name string
  Power int
  Father *Saiyan
}

可以這樣初始化:

gohan := &Saiyan{
  Name: "Gohan",
  Power: 1000,
  Father: &Saiyan {
    Name: "Goku",
    Power: 9001,
    Father: nil,
  },
}

組合

Go 語言支持組合,意思就是將一個結構包含到另外一個結構的行為。在某些語言中,這被叫做 traitmixin。沒有明確組合機制的語言總是會用其他的方式來達成。在 Java 中是這樣做:

public class Person {
  private String name;

  public String getName() {
    return this.name;
  }
}

public class Saiyan {
  // 類別 Saiyan 宣告這裡有一個 person
  private Person person;

  // 我們轉向呼叫到 person 的 getName() 方法
  public String getName() {
    return this.person.getName();
  }
  ...
}

這樣撰寫十分乏味。Person 中的每個方法都必須要在 Saiyan 中重複一次。Go 則避免了這樣的作法:

type Person struct {
  Name string
}

func (p *Person) Introduce() {
  fmt.Printf("Hi, I'm %s\n", p.Name)
}

type Saiyan struct {
  *Person
  Power int
}

// 並使用他:
goku := &Saiyan{
  Person: &Person{"Goku"},
  Power: 9001,
}
goku.Introduce()

Saiyan 結構中有一個型態是 *Person 的欄位。因為我們沒有給他明確的欄位名稱,所以我們可以透過組合隱性的存取這個欄位和函式。然而,Go 的編譯器 的確 會給他一個欄位名稱。看看以下的例子:

goku := &Saiyan{
  Person: &Person{"Goku"},
}
fmt.Println(goku.Name)
fmt.Println(goku.Person.Name)

兩個都會印出 "Goku"。

組合是否比繼承好呢?許多人認為這是分享程式碼一個比較可靠的方式。當使用繼承時,你的類別會僅耦合到父類別,最終你關注的是階層結構而並非是程式碼本身的行為。

多載

雖然多載並不限定於在結構,但值得在這一提。簡單來說,Go 不支援多載, 所以你會看到很多函式用來做 LoadLoadByIdLoadByName

然而,因為隱性組合是一種編譯器的小技巧,我們可以「覆寫」組合型別的函式。例如,Saiyan 結構可以有自己的 Introduce 函式:

func (s *Saiyan) Introduce() {
  fmt.Printf("Hi, I'm %s. Ya!\n", s.Name)
}

而你總是可以透過 s.Person.Introduce() 來呼叫他。

指標 V.S. 值

當你在寫 Go 的程式碼時,問問自己 這是一個值,還是一個指標指向該值 是很正常的。有兩個好消息,第一,下面任何一個問題的答案都是一樣的:

  • 區域變數賦值
  • 結構中的欄位
  • 函式的回傳值
  • 函式的參數
  • 方法的接收者

第二,如果你不確定的話,用指標。

就像我們看到的,傳遞值是使得資料成為不可變的一個好方法(在被呼叫的方法中變更該值並不會反映到呼叫者上)。有時候,這是你想要的行為,但大多時候你不會想要這樣。

即使你真的不想要改變資料本身,想想看建立一個龐大結構的副本是多大的開銷。相反的,如果你有一個相對小的結構:

type Point struct {
  X int
  Y int
}

在這樣的例子中,使用結構副本的開銷可能被抵銷掉,你可以直接訪問 XY。再提醒一次,這些都是比較細微的差別,除非你反覆存取幾千或幾萬次,不然可能不會注意到這開銷的差別。

在你繼續學習之前

從實際的角度來說,這一章節中我們介紹了結構。學習如何讓結構的實例成為一個函式的接收者。 並且在既有 Go 的型別系統中增加了指標的知識。下一章節則會建基在我們學習到的結構繼續學習。

results matching ""

    No results matching ""