《快學 Go 語言》第 8 課 —— 程式大廈是如何構建起來的

碼洞發表於2018-11-23

本節我們要開講 Go 語言在資料結構上最重要的概念 —— 結構體。如果說 Go 語言的基礎型別是原子,那麼結構體就是分子。分子是原子的組合,讓形式有限的基礎型別變化出豐富多樣的形態結構。結構體裡面裝的是基礎型別、切片、字典、陣列以及其它型別的結構體等等。

《快學 Go 語言》第 8 課 —— 程式大廈是如何構建起來的

因為結構體的存在,Go 語言的變數才有了更加豐富多彩的形式,Go 語言程式的高樓大廈正是通過結構體一層層組裝起來的。

結構體型別的定義

結構體和其它高階語言裡的「類」比較類似。下面我們使用結構體語法來定義一個「圓」型

type Circle struct {
  x int
  y int
  Radius int
}
複製程式碼

Circle 結構體內部有三個變數,分別是圓心的座標以及半徑。特別需要注意是結構體內部變數的大小寫,首字母大寫是公開變數,首字母小寫是內部變數,分別相當於類成員變數的 Public 和 Private 類別。內部變數只有屬於同一個 package(簡單理解就是同一個目錄)的程式碼才能直接訪問。

結構體變數的建立

建立一個結構體變數有多種形式,我們先看結構體變數最常見的建立形式

package main

import "fmt"

type Circle struct {
	x int
	y int
	Radius int
}

func main() {
	var c Circle = Circle {
		x: 100,
		y: 100,
		Radius: 50// 注意這裡的逗號不能少
	}
	fmt.Printf("%+v\n", c)
}

----------
{x:100 y:100 Radius:50}
複製程式碼

通過顯示指定結構體內部欄位的名稱和初始值來初始化結構體,可以只指定部分欄位的初值,甚至可以一個欄位都不指定,那些沒有指定初值的欄位會自動初始化為相應型別的「零值」。這種形式我們稱之為 「KV 形式」。

package main

import "fmt"

type Circle struct {
	x int
	y int
	Radius int
}

func main() {
	var c1 Circle = Circle {
		Radius: 50,
	}
	var c2 Circle = Circle {}
	fmt.Printf("%+v\n", c1)
	fmt.Printf("%+v\n", c2)
}

----------
{x:0 y:0 Radius:50}
{x:0 y:0 Radius:0}
複製程式碼

結構體的第二種建立形式是不指定欄位名稱來順序欄位初始化,需要顯示提供所有欄位的初值,一個都不能少。這種形式稱之為「順序形式」。

package main

import "fmt"

type Circle struct {
	x int
	y int
	Radius int
}

func main() {
	var c Circle = Circle {100, 100, 50}
	fmt.Printf("%+v\n", c)
}

-------
{x:100 y:100 Radius:50}
複製程式碼

結構體變數和普通變數都有指標形式,使用取地址符就可以得到結構體的指標型別

package main

import "fmt"

type Circle struct {
	x int
	y int
	Radius int
}

func main() {
	var c *Circle = &Circle {100, 100, 50}
	fmt.Printf("%+v\n", c)
}

-----------
&{x:100 y:100 Radius:50}
複製程式碼

注意上面的輸出,指標形式多了一個地址符 &,表示列印的物件是一個指標型別。介紹完了結構體變數的指標形式,下面就可以引入結構體變數建立的第三種形式,使用全域性的 new() 函式來建立一個「零值」結構體,所有的欄位都被初始化為相應型別的零值。

package main

import "fmt"

type Circle struct {
	x int
	y int
	Radius int
}

func main() {
	var c *Circle = new(Circle)
	fmt.Printf("%+v\n", c)
}

----------
&{x:0 y:0 Radius:0}
複製程式碼

注意 new() 函式返回的是指標型別。下面再引入結構體變數的第四種建立形式,這種形式也是零值初始化,就數它看起來最不雅觀。

package main

import "fmt"

type Circle struct {
	x int
	y int
	Radius int
}

func main() {
	var c Circle
	fmt.Printf("%+v\n", c)
}

複製程式碼

最後我們再將三種零值初始化形式放到一起對比觀察一下

var c1 Circle = Circle{}
var c2 Circle
var c3 *Circle = new(Circle)
複製程式碼

零值結構體和 nil 結構體

nil 結構體是指結構體指標變數沒有指向一個實際存在的記憶體。這樣的指標變數只會佔用 1 個指標的儲存空間,也就是一個機器字的記憶體大小。

var c *Circle = nil
複製程式碼

而零值結構體是會實實在在佔用記憶體空間的,只不過每個欄位都是零值。如果結構體裡面欄位非常多,那麼這個記憶體空間佔用肯定也會很大。

結構體的記憶體大小

Go 語言的 unsafe 包提供了獲取結構體記憶體佔用的函式 Sizeof()

package main

import "fmt"
import "unsafe"

type Circle struct {
	x int
	y int
	Radius int
}

func main() {
	var c Circle = Circle {Radius: 50}
	fmt.Println(unsafe.Sizeof(c))
}

-------
24
複製程式碼

Circle 結構體在我的 64位機器上佔用了 24 個位元組,因為每個 int 型別都是 8 位元組。在 32 位機器上,Circle 結構體只會佔用 12 個位元組。

結構體的拷貝

結構體之間可以相互賦值,它在本質上是一次淺拷貝操作,拷貝了結構體內部的所有欄位。結構體指標之間也可以相互賦值,它在本質上也是一次淺拷貝操作,不過它拷貝的僅僅是指標地址值,結構體的內容是共享的。

package main

import "fmt"

type Circle struct {
	x int
	y int
	Radius int
}

func main() {
	var c1 Circle = Circle {Radius: 50}
	var c2 Circle = c1
	fmt.Printf("%+v\n", c1)
	fmt.Printf("%+v\n", c2)
	c1.Radius = 100
	fmt.Printf("%+v\n", c1)
	fmt.Printf("%+v\n", c2)

	var c3 *Circle = &Circle {Radius: 50}
	var c4 *Circle = c3
	fmt.Printf("%+v\n", c3)
	fmt.Printf("%+v\n", c4)
	c1.Radius = 100
	fmt.Printf("%+v\n", c3)
	fmt.Printf("%+v\n", c4)
}

---------------
{x:0 y:0 Radius:50}
{x:0 y:0 Radius:50}
{x:0 y:0 Radius:100}
{x:0 y:0 Radius:50}
&{x:0 y:0 Radius:50}
&{x:0 y:0 Radius:50}
&{x:0 y:0 Radius:50}
&{x:0 y:0 Radius:50}

複製程式碼

試試解釋一下上面的輸出結果

無處不在的結構體

通過觀察 Go 語言的底層原始碼,可以發現所有的 Go 語言內建的高階資料結構都是由結構體來完成的。

切片頭的結構體形式如下,它在 64 位機器上將會佔用 24 個位元組

type slice struct {
  array unsafe.Pointer  // 底層陣列的地址
  len int // 長度
  cap int // 容量
}
複製程式碼

字串頭的結構體形式,它在 64 位機器上將會佔用 16 個位元組

type string struct {
  array unsafe.Pointer // 底層陣列的地址
  len int
}
複製程式碼

字典頭的結構體形式

type hmap struct {
  count int
  ...
  buckets unsafe.Pointer  // hash桶地址
  ...
}
複製程式碼

結構體中的陣列和切片

在陣列與切片章節,我們自習分析了陣列與切片在記憶體形式上的區別。陣列只有「體」,切片除了「體」之外,還有「頭」部。切片的頭部和內容體是分離的,使用指標關聯起來。請讀者嘗試解釋一下下面程式碼的輸出結果

package main

import "fmt"
import "unsafe"

type ArrayStruct struct {
	value [10]int
}

type SliceStruct struct {
	value []int
}

func main() {
	var as = ArrayStruct{[...]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}}
	var ss = SliceStruct{[]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}}
	fmt.Println(unsafe.Sizeof(as), unsafe.Sizeof(ss))
}

-------------
80 24
複製程式碼

注意程式碼中的陣列初始化使用了 [...] 語法糖,表示讓編譯器自動推導陣列的長度。

結構體的引數傳遞

函式呼叫時引數傳遞結構體變數,Go 語言支援值傳遞,也支援指標傳遞。值傳遞涉及到結構體欄位的淺拷貝,指標傳遞會共享結構體內容,只會拷貝指標地址,規則上和賦值是等價的。下面我們使用兩種傳參方式來編寫擴大圓半徑的函式。

package main

import "fmt"

type Circle struct {
	x int
	y int
	Radius int
}

func expandByValue(c Circle) {
	c.Radius *= 2
}

func expandByPointer(c *Circle) {
	c.Radius *= 2
}

func main() {
	var c = Circle {Radius: 50}
	expandByValue(c)
	fmt.Println(c)
	expandByPointer(&c)
	fmt.Println(c)
}

---------
{0 0 50}
{0 0 100}
複製程式碼

從上面的輸出中可以看到通過值傳遞,在函式裡面修改結構體的狀態不會影響到原有結構體的狀態,函式內部的邏輯並沒有產生任何效果。通過指標傳遞就不一樣。

結構體方法

Go 語言不是物件導向的語言,它裡面不存在類的概念,結構體正是類的替代品。類可以附加很多成員方法,結構體也可以。

package main

import "fmt"
import "math"

type Circle struct {
 x int
 y int
 Radius int
}

// 面積
func (c Circle) Area() float64 {
 return math.Pi * float64(c.Radius) * float64(c.Radius)
}

// 周長
func (c Circle) Circumference() float64 {
 return 2 * math.Pi * float64(c.Radius)
}

func main() {
 var c = Circle {Radius: 50}
 fmt.Println(c.Area(), c.Circumference())
 // 指標變數呼叫方法形式上是一樣的
 var pc = &c
 fmt.Println(pc.Area(), pc.Circumference())
}

-----------
7853.981633974483 314.1592653589793
7853.981633974483 314.1592653589793
複製程式碼

Go 語言不喜歡型別的隱式轉換,所以需要將整形顯示轉換成浮點型,不是很好看,不過這就是 Go 語言的基本規則,顯式的程式碼可能不夠簡潔,但是易於理解。 Go 語言的結構體方法裡面沒有 self 和 this 這樣的關鍵字來指代當前的物件,它是使用者自己定義的變數名稱,通常我們都使用單個字母來表示。 Go 語言的方法名稱也分首字母大小寫,它的許可權規則和欄位一樣,首字母大寫就是公開方法,首字母小寫就是內部方法,只能歸屬於同一個包的程式碼才可以訪問內部方法。 結構體的值型別和指標型別訪問內部欄位和方法在形式上是一樣的。這點不同於 C++ 語言,在 C++ 語言裡,值訪問使用句點 . 操作符,而指標訪問需要使用箭頭 -> 操作符。

結構體的指標方法

如果使用上面的方法形式給 Circle 增加一個擴大半徑的方法,你會發現半徑擴大不了。

func (c Circle) expand() {
  c.Radius *= 2
}
複製程式碼

這是因為上面的方法和前面的 expandByValue 函式是等價的,只不過是把函式的第一個引數挪了位置而已,引數傳遞時會複製了一份結構體內容,起不到擴大半徑的效果。這時候就必須要使用結構體的指標方法

func (c *Circle) expand() {
  c.Radius *= 2
}
複製程式碼

結構體指標方法和值方法在呼叫時形式上是沒有區別的,只不過一個可以改變結構體內部狀態,而另一個不會。指標方法使用結構體值變數可以呼叫,值方法使用結構體指標變數也可以呼叫。

通過指標訪問內部的欄位需要 2 次記憶體讀取操作,第一步是取得指標地址,第二部是讀取地址的內容,它比值訪問要慢。但是在方法呼叫時,指標傳遞可以避免結構體的拷貝操作,結構體比較大時,這種效能的差距就會比較明顯。

還有一些特殊的結構體它不允許被複制,比如結構體內部包含有鎖時,這時就必須使用它的指標形式來定義方法,否則會發生一些莫名其妙的問題。

內嵌結構體

結構體作為一種變數它可以放進另外一個結構體作為一個欄位來使用,這種內嵌結構體的形式在 Go 語言裡稱之為「組合」。下面我們來看看內嵌結構體的基本使用方法

package main

import "fmt"

type Point struct {
	x int
	y int
}

func (p Point) show() {
  fmt.Println(p.x, p.y)
}

type Circle struct {
	loc Point
	Radius int
}

func main() {
	var c = Circle {
		loc: Point {
			x: 100,
			y: 100,
		},
		Radius: 50,
	}
	fmt.Printf("%+v\n", c)
	fmt.Printf("%+v\n", c.loc)
	fmt.Printf("%d %d\n", c.loc.x, c.loc.y)
	c.loc.show()
}

----------------
{loc:{x:100 y:100} Radius:50}
{x:100 y:100}
100 100
100 100
複製程式碼

匿名內嵌結構體

還有一種特殊的內嵌結構體形式,內嵌的結構體不提供名稱。這時外面的結構體將直接繼承內嵌結構體所有的內部欄位和方法,就好像把子結構體的一切全部都揉進了父結構體一樣。匿名的結構體欄位將會自動獲得以結構體型別的名字命名的欄位名稱

package main

import "fmt"

type Point struct {
	x int
	y int
}

func (p Point) show() {
	fmt.Println(p.x, p.y)
}

type Circle struct {
	Point // 匿名內嵌結構體
	Radius int
}

func main() {
	var c = Circle {
		Point: Point {
			x: 100,
			y: 100,
		},
		Radius: 50,
	}
	fmt.Printf("%+v\n", c)
	fmt.Printf("%+v\n", c.Point)
	fmt.Printf("%d %d\n", c.x, c.y) // 繼承了欄位
	fmt.Printf("%d %d\n", c.Point.x, c.Point.y)
	c.show() // 繼承了方法
	c.Point.show()
}

-------
{Point:{x:100 y:100} Radius:50}
{x:100 y:100}
100 100
100 100
100 100
100 100
複製程式碼

這裡的繼承僅僅是形式上的語法糖,c.show() 被轉換成二進位制程式碼後和 c.Point.show() 是等價的,c.x 和 c.Point.x 也是等價的。

Go 語言的結構體沒有多型性

Go 語言不是面嚮物件語言在於它的結構體不支援多型,它不能算是一個嚴格的面嚮物件語言。多型是指父類定義的方法可以呼叫子類實現的方法,不同的子類有不同的實現,從而給父類的方法帶來了多樣的不同行為。下面的例子呈現了 Java 類的多型性。

class Fruit {
  public void eat() {
    System.out.println("eat fruit");
  }
  
  public void enjoy() {
    System.out.println("smell first");
    eat();
    System.out.println("clean finally");
  }
}

class Apple extends Fruit {
  public void eat() {
    System.out.println("eat apple");
  }
}

class Banana extends Fruit {
  public void eat() {
    System.out.println("eat banana");
  }
}

public class Main {
  public static void main(String[] args) {
    Apple apple = new Apple();
    Banana banana = new Banana();
    apple.enjoy();
    banana.enjoy();
  }
}

----------------
smell first
eat apple
clean finally
smell first
eat banana
clean finally
複製程式碼

父類 Fruit 定義的 enjoy 方法呼叫了子類實現的 eat 方法,子類的方法可以對父類定義的方法進行覆蓋,父類的 eat 方法被隱藏起來了。

Go 語言的結構體明確不支援這種形式的多型,外結構體的方法不能覆蓋內部結構體的方法。比如我們用 Go 語言來改寫上面的水果例子觀察一下輸出結果。

package main

import "fmt"

type Fruit struct {}

func (f Fruit) eat() {
	fmt.Println("eat fruit")
}

func (f Fruit) enjoy() {
	fmt.Println("smell first")
	f.eat()
	fmt.Println("clean finally")
}

type Apple struct {
	Fruit
}

func (a Apple) eat() {
	fmt.Println("eat apple")
}

type Banana struct {
	Fruit
}

func (b Banana) eat() {
	fmt.Println("eat banana")
}

func main() {
	var apple = Apple {}
	var banana = Banana {}
	apple.enjoy()
	banana.enjoy()
}

----------
smell first
eat fruit
clean finally
smell first
eat fruit
clean finally
複製程式碼

enjoy 方法呼叫的 eat 方法還是 Fruit 自己的 eat 方法,它沒能被外面的結構體方法覆蓋掉。這意味著物件導向的程式碼習慣不能直接用到 Go 語言裡了,我們需要轉變思維。

物件導向的多型性需要通過 Go 語言的介面特性來模擬,這就是下一節我們要講的主題。

《快學 Go 語言》第 8 課 —— 程式大廈是如何構建起來的

關注公眾號「碼洞」,閱讀《快學 Go 語言》更多章節

相關文章