Go學習筆記-結構體

如果說 Go 語言的基礎類型是原子,那么結構體就是分子。分子是原子的組合,讓形式有限的基礎類型變化出豐富多樣的形態結構。結構體里面裝的是基礎類型、切片、字典、數組以及其它類型的結構體等等。

因為結構體的存在,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)
    c3.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 語言里了,我們需要轉變思維。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容