Go 語言 結(jié)構(gòu)體

2023-03-14 16:52 更新

原文鏈接:https://gopl-zh.github.io/ch4/ch4-04.html


4.4. 結(jié)構(gòu)體

結(jié)構(gòu)體是一種聚合的數(shù)據(jù)類型,是由零個或多個任意類型的值聚合成的實體。每個值稱為結(jié)構(gòu)體的成員。用結(jié)構(gòu)體的經(jīng)典案例是處理公司的員工信息,每個員工信息包含一個唯一的員工編號、員工的名字、家庭住址、出生日期、工作崗位、薪資、上級領(lǐng)導等等。所有的這些信息都需要綁定到一個實體中,可以作為一個整體單元被復制,作為函數(shù)的參數(shù)或返回值,或者是被存儲到數(shù)組中,等等。

下面兩個語句聲明了一個叫Employee的命名的結(jié)構(gòu)體類型,并且聲明了一個Employee類型的變量dilbert:

type Employee struct {
    ID        int
    Name      string
    Address   string
    DoB       time.Time
    Position  string
    Salary    int
    ManagerID int
}

var dilbert Employee

dilbert結(jié)構(gòu)體變量的成員可以通過點操作符訪問,比如dilbert.Name和dilbert.DoB。因為dilbert是一個變量,它所有的成員也同樣是變量,我們可以直接對每個成員賦值:

dilbert.Salary -= 5000 // demoted, for writing too few lines of code

或者是對成員取地址,然后通過指針訪問:

position := &dilbert.Position
*position = "Senior " + *position // promoted, for outsourcing to Elbonia

點操作符也可以和指向結(jié)構(gòu)體的指針一起工作:

var employeeOfTheMonth *Employee = &dilbert
employeeOfTheMonth.Position += " (proactive team player)"

相當于下面語句

(*employeeOfTheMonth).Position += " (proactive team player)"

下面的EmployeeByID函數(shù)將根據(jù)給定的員工ID返回對應(yīng)的員工信息結(jié)構(gòu)體的指針。我們可以使用點操作符來訪問它里面的成員:

func EmployeeByID(id int) *Employee { /* ... */ }

fmt.Println(EmployeeByID(dilbert.ManagerID).Position) // "Pointy-haired boss"

id := dilbert.ID
EmployeeByID(id).Salary = 0 // fired for... no real reason

后面的語句通過EmployeeByID返回的結(jié)構(gòu)體指針更新了Employee結(jié)構(gòu)體的成員。如果將EmployeeByID函數(shù)的返回值從*Employee指針類型改為Employee值類型,那么更新語句將不能編譯通過,因為在賦值語句的左邊并不確定是一個變量(譯注:調(diào)用函數(shù)返回的是值,并不是一個可取地址的變量)。

通常一行對應(yīng)一個結(jié)構(gòu)體成員,成員的名字在前類型在后,不過如果相鄰的成員類型如果相同的話可以被合并到一行,就像下面的Name和Address成員那樣:

type Employee struct {
    ID            int
    Name, Address string
    DoB           time.Time
    Position      string
    Salary        int
    ManagerID     int
}

結(jié)構(gòu)體成員的輸入順序也有重要的意義。我們也可以將Position成員合并(因為也是字符串類型),或者是交換Name和Address出現(xiàn)的先后順序,那樣的話就是定義了不同的結(jié)構(gòu)體類型。通常,我們只是將相關(guān)的成員寫到一起。

如果結(jié)構(gòu)體成員名字是以大寫字母開頭的,那么該成員就是導出的;這是Go語言導出規(guī)則決定的。一個結(jié)構(gòu)體可能同時包含導出和未導出的成員。

結(jié)構(gòu)體類型往往是冗長的,因為它的每個成員可能都會占一行。雖然我們每次都可以重寫整個結(jié)構(gòu)體成員,但是重復會令人厭煩。因此,完整的結(jié)構(gòu)體寫法通常只在類型聲明語句的地方出現(xiàn),就像Employee類型聲明語句那樣。

一個命名為S的結(jié)構(gòu)體類型將不能再包含S類型的成員:因為一個聚合的值不能包含它自身。(該限制同樣適用于數(shù)組。)但是S類型的結(jié)構(gòu)體可以包含?*S?指針類型的成員,這可以讓我們創(chuàng)建遞歸的數(shù)據(jù)結(jié)構(gòu),比如鏈表和樹結(jié)構(gòu)等。在下面的代碼中,我們使用一個二叉樹來實現(xiàn)一個插入排序:

gopl.io/ch4/treesort

type tree struct {
    value       int
    left, right *tree
}

// Sort sorts values in place.
func Sort(values []int) {
    var root *tree
    for _, v := range values {
        root = add(root, v)
    }
    appendValues(values[:0], root)
}

// appendValues appends the elements of t to values in order
// and returns the resulting slice.
func appendValues(values []int, t *tree) []int {
    if t != nil {
        values = appendValues(values, t.left)
        values = append(values, t.value)
        values = appendValues(values, t.right)
    }
    return values
}

func add(t *tree, value int) *tree {
    if t == nil {
        // Equivalent to return &tree{value: value}.
        t = new(tree)
        t.value = value
        return t
    }
    if value < t.value {
        t.left = add(t.left, value)
    } else {
        t.right = add(t.right, value)
    }
    return t
}

結(jié)構(gòu)體類型的零值是每個成員都是零值。通常會將零值作為最合理的默認值。例如,對于bytes.Buffer類型,結(jié)構(gòu)體初始值就是一個隨時可用的空緩存,還有在第9章將會講到的sync.Mutex的零值也是有效的未鎖定狀態(tài)。有時候這種零值可用的特性是自然獲得的,但是也有些類型需要一些額外的工作。

如果結(jié)構(gòu)體沒有任何成員的話就是空結(jié)構(gòu)體,寫作struct{}。它的大小為0,也不包含任何信息,但是有時候依然是有價值的。有些Go語言程序員用map來模擬set數(shù)據(jù)結(jié)構(gòu)時,用它來代替map中布爾類型的value,只是強調(diào)key的重要性,但是因為節(jié)約的空間有限,而且語法比較復雜,所以我們通常會避免這樣的用法。

seen := make(map[string]struct{}) // set of strings
// ...
if _, ok := seen[s]; !ok {
    seen[s] = struct{}{}
    // ...first time seeing s...
}

4.4.1. 結(jié)構(gòu)體字面值

結(jié)構(gòu)體值也可以用結(jié)構(gòu)體字面值表示,結(jié)構(gòu)體字面值可以指定每個成員的值。

type Point struct{ X, Y int }

p := Point{1, 2}

這里有兩種形式的結(jié)構(gòu)體字面值語法,上面的是第一種寫法,要求以結(jié)構(gòu)體成員定義的順序為每個結(jié)構(gòu)體成員指定一個字面值。它要求寫代碼和讀代碼的人要記住結(jié)構(gòu)體的每個成員的類型和順序,不過結(jié)構(gòu)體成員有細微的調(diào)整就可能導致上述代碼不能編譯。因此,上述的語法一般只在定義結(jié)構(gòu)體的包內(nèi)部使用,或者是在較小的結(jié)構(gòu)體中使用,這些結(jié)構(gòu)體的成員排列比較規(guī)則,比如image.Point{x, y}或color.RGBA{red, green, blue, alpha}。

其實更常用的是第二種寫法,以成員名字和相應(yīng)的值來初始化,可以包含部分或全部的成員,如1.4節(jié)的Lissajous程序的寫法:

anim := gif.GIF{LoopCount: nframes}

在這種形式的結(jié)構(gòu)體字面值寫法中,如果成員被忽略的話將默認用零值。因為提供了成員的名字,所以成員出現(xiàn)的順序并不重要。

兩種不同形式的寫法不能混合使用。而且,你不能企圖在外部包中用第一種順序賦值的技巧來偷偷地初始化結(jié)構(gòu)體中未導出的成員。

package p
type T struct{ a, b int } // a and b are not exported

package q
import "p"
var _ = p.T{a: 1, b: 2} // compile error: can't reference a, b
var _ = p.T{1, 2}       // compile error: can't reference a, b

雖然上面最后一行代碼的編譯錯誤信息中并沒有顯式提到未導出的成員,但是這樣企圖隱式使用未導出成員的行為也是不允許的。

結(jié)構(gòu)體可以作為函數(shù)的參數(shù)和返回值。例如,這個Scale函數(shù)將Point類型的值縮放后返回:

func Scale(p Point, factor int) Point {
    return Point{p.X * factor, p.Y * factor}
}

fmt.Println(Scale(Point{1, 2}, 5)) // "{5 10}"

如果考慮效率的話,較大的結(jié)構(gòu)體通常會用指針的方式傳入和返回,

func Bonus(e *Employee, percent int) int {
    return e.Salary * percent / 100
}

如果要在函數(shù)內(nèi)部修改結(jié)構(gòu)體成員的話,用指針傳入是必須的;因為在Go語言中,所有的函數(shù)參數(shù)都是值拷貝傳入的,函數(shù)參數(shù)將不再是函數(shù)調(diào)用時的原始變量。

func AwardAnnualRaise(e *Employee) {
    e.Salary = e.Salary * 105 / 100
}

因為結(jié)構(gòu)體通常通過指針處理,可以用下面的寫法來創(chuàng)建并初始化一個結(jié)構(gòu)體變量,并返回結(jié)構(gòu)體的地址:

pp := &Point{1, 2}

它和下面的語句是等價的

pp := new(Point)
*pp = Point{1, 2}

不過&Point{1, 2}寫法可以直接在表達式中使用,比如一個函數(shù)調(diào)用。

4.4.2. 結(jié)構(gòu)體比較

如果結(jié)構(gòu)體的全部成員都是可以比較的,那么結(jié)構(gòu)體也是可以比較的,那樣的話兩個結(jié)構(gòu)體將可以使用==或!=運算符進行比較。相等比較運算符==將比較兩個結(jié)構(gòu)體的每個成員,因此下面兩個比較的表達式是等價的:

type Point struct{ X, Y int }

p := Point{1, 2}
q := Point{2, 1}
fmt.Println(p.X == q.X && p.Y == q.Y) // "false"
fmt.Println(p == q)                   // "false"

可比較的結(jié)構(gòu)體類型和其他可比較的類型一樣,可以用于map的key類型。

type address struct {
    hostname string
    port     int
}

hits := make(map[address]int)
hits[address{"golang.org", 443}]++

4.4.3. 結(jié)構(gòu)體嵌入和匿名成員

在本節(jié)中,我們將看到如何使用Go語言提供的不同尋常的結(jié)構(gòu)體嵌入機制讓一個命名的結(jié)構(gòu)體包含另一個結(jié)構(gòu)體類型的匿名成員,這樣就可以通過簡單的點運算符x.f來訪問匿名成員鏈中嵌套的x.d.e.f成員。

考慮一個二維的繪圖程序,提供了一個各種圖形的庫,例如矩形、橢圓形、星形和輪形等幾何形狀。這里是其中兩個的定義:

type Circle struct {
    X, Y, Radius int
}

type Wheel struct {
    X, Y, Radius, Spokes int
}

一個Circle代表的圓形類型包含了標準圓心的X和Y坐標信息,和一個Radius表示的半徑信息。一個Wheel輪形除了包含Circle類型所有的全部成員外,還增加了Spokes表示徑向輻條的數(shù)量。我們可以這樣創(chuàng)建一個wheel變量:

var w Wheel
w.X = 8
w.Y = 8
w.Radius = 5
w.Spokes = 20

隨著庫中幾何形狀數(shù)量的增多,我們一定會注意到它們之間的相似和重復之處,所以我們可能為了便于維護而將相同的屬性獨立出來:

type Point struct {
    X, Y int
}

type Circle struct {
    Center Point
    Radius int
}

type Wheel struct {
    Circle Circle
    Spokes int
}

這樣改動之后結(jié)構(gòu)體類型變的清晰了,但是這種修改同時也導致了訪問每個成員變得繁瑣:

var w Wheel
w.Circle.Center.X = 8
w.Circle.Center.Y = 8
w.Circle.Radius = 5
w.Spokes = 20

Go語言有一個特性讓我們只聲明一個成員對應(yīng)的數(shù)據(jù)類型而不指名成員的名字;這類成員就叫匿名成員。匿名成員的數(shù)據(jù)類型必須是命名的類型或指向一個命名的類型的指針。下面的代碼中,Circle和Wheel各自都有一個匿名成員。我們可以說Point類型被嵌入到了Circle結(jié)構(gòu)體,同時Circle類型被嵌入到了Wheel結(jié)構(gòu)體。

type Circle struct {
    Point
    Radius int
}

type Wheel struct {
    Circle
    Spokes int
}

得益于匿名嵌入的特性,我們可以直接訪問葉子屬性而不需要給出完整的路徑:

var w Wheel
w.X = 8            // equivalent to w.Circle.Point.X = 8
w.Y = 8            // equivalent to w.Circle.Point.Y = 8
w.Radius = 5       // equivalent to w.Circle.Radius = 5
w.Spokes = 20

在右邊的注釋中給出的顯式形式訪問這些葉子成員的語法依然有效,因此匿名成員并不是真的無法訪問了。其中匿名成員Circle和Point都有自己的名字——就是命名的類型名字——但是這些名字在點操作符中是可選的。我們在訪問子成員的時候可以忽略任何匿名成員部分。

不幸的是,結(jié)構(gòu)體字面值并沒有簡短表示匿名成員的語法, 因此下面的語句都不能編譯通過:

w = Wheel{8, 8, 5, 20}                       // compile error: unknown fields
w = Wheel{X: 8, Y: 8, Radius: 5, Spokes: 20} // compile error: unknown fields

結(jié)構(gòu)體字面值必須遵循形狀類型聲明時的結(jié)構(gòu),所以我們只能用下面的兩種語法,它們彼此是等價的:

gopl.io/ch4/embed

w = Wheel{Circle{Point{8, 8}, 5}, 20}

w = Wheel{
    Circle: Circle{
        Point:  Point{X: 8, Y: 8},
        Radius: 5,
    },
    Spokes: 20, // NOTE: trailing comma necessary here (and at Radius)
}

fmt.Printf("%#v\n", w)
// Output:
// Wheel{Circle:Circle{Point:Point{X:8, Y:8}, Radius:5}, Spokes:20}

w.X = 42

fmt.Printf("%#v\n", w)
// Output:
// Wheel{Circle:Circle{Point:Point{X:42, Y:8}, Radius:5}, Spokes:20}

需要注意的是Printf函數(shù)中%v參數(shù)包含的#副詞,它表示用和Go語言類似的語法打印值。對于結(jié)構(gòu)體類型來說,將包含每個成員的名字。

因為匿名成員也有一個隱式的名字,因此不能同時包含兩個類型相同的匿名成員,這會導致名字沖突。同時,因為成員的名字是由其類型隱式地決定的,所以匿名成員也有可見性的規(guī)則約束。在上面的例子中,Point和Circle匿名成員都是導出的。即使它們不導出(比如改成小寫字母開頭的point和circle),我們依然可以用簡短形式訪問匿名成員嵌套的成員

w.X = 8 // equivalent to w.circle.point.X = 8

但是在包外部,因為circle和point沒有導出,不能訪問它們的成員,因此簡短的匿名成員訪問語法也是禁止的。

到目前為止,我們看到匿名成員特性只是對訪問嵌套成員的點運算符提供了簡短的語法糖。稍后,我們將會看到匿名成員并不要求是結(jié)構(gòu)體類型;其實任何命名的類型都可以作為結(jié)構(gòu)體的匿名成員。但是為什么要嵌入一個沒有任何子成員類型的匿名成員類型呢?

答案是匿名類型的方法集。簡短的點運算符語法可以用于選擇匿名成員嵌套的成員,也可以用于訪問它們的方法。實際上,外層的結(jié)構(gòu)體不僅僅是獲得了匿名成員類型的所有成員,而且也獲得了該類型導出的全部的方法。這個機制可以用于將一些有簡單行為的對象組合成有復雜行為的對象。組合是Go語言中面向?qū)ο缶幊痰暮诵?,我們將?.3節(jié)中專門討論。



以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號