天天看點

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

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

本節我們要開講 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 語言的接口特性來模拟,這就是下一節我們要講的主題。

原文釋出時間為: 2018-11-23

本文作者: 碼洞

本文來自雲栖社群合作夥伴“

碼洞

”,了解相關資訊可以關注“

”。