Go基础-结构体

摘要

Go 语言在数据结构上最重要的概念 —— 结构体。如果说 Go 语言的基础类型是原子,那么结构体就是分子。分子是原子的组合,让形式有限的基础类型变化出丰富多样的形态结构。结构体里面装的是基础类型、切片、字典、数组以及其它类型的结构体等等。

Go 语言内置的高级数据结构都是由结构体来完成的。

定义

结构体和其它高级语言里的「类」比较类似。

1
2
3
4
5
type Circle struct {
x int
y int
Radius int
}

Circle 结构体内部有三个变量,分别是圆心的坐标以及半径。特别需要注意是结构体内部变量的大小写

  • 首字母大写是公开变量
  • 首字母小写是内部变量
  • 分别相当于类成员变量的 Public 和 Private 类别
  • 内部变量只有属于同一个 package(简单理解就是同一个目录)的代码才能直接访问。

变量的创建

创建一个结构体变量有多种形式

  • KV 形式: 显示指定结构体内部字段的名称和初始值来初始化结构体
    • 可以只指定部分字段的初值,甚至可以一个字段都不指定
    • 那些没有指定初值的字段会自动初始化为相应类型的「零值」
  • 顺序形式Z: 不指定字段名称来顺序字段初始化
    • 需要显示提供所有字段的初值,一个都不能少
  • 使用全局的 new() 函数来创建一个「零值」结构体,所有的字段都被初始化为相应类型的零值。
  • 零值初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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, // 注意这里的逗号不能少
}
// 指定部分字段的初值
var c1 Circle = Circle {
Radius: 50,
}
// 一个字段都不指定
var c2 Circle = Circle {}
// 顺序形式
var c3 Circle = Circle {100, 100, 50}
// 通过new创建
var c4 *Circle = new(Circle)
// 零值初始化
var c5 Circle

fmt.Printf("%+v\n", c)
fmt.Printf("%+v\n", c1)
fmt.Printf("%+v\n", c2)
fmt.Printf("%+v\n", c3)
fmt.Printf("%+v\n", c4)
fmt.Printf("%+v\n", c5)
}

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

指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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() 函数来创建一个「零值」结构体,所有的字段都被初始化为相应类型的零值。

零值结构体和 nil 结构体

nil 结构体是指结构体指针变量没有指向一个实际存在的内存。这样的指针变量只会占用 1 个指针的存储空间,也就是一个机器字的内存大小。

1
var c *Circle = nil

而零值结构体是会实实在在占用内存空间的,只不过每个字段都是零值。如果结构体里面字段非常多,那么这个内存空间占用肯定也会很大。

内存大小

Go 语言的 unsafe 包提供了获取结构体内存占用的函数 Sizeof()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 个字节。

拷贝

结构体之间可以相互赋值,它在本质上是一次浅拷贝(只拷贝一层)操作,拷贝了结构体内部的所有字段。结构体指针之间也可以相互赋值,它在本质上也是一次浅拷贝操作,不过它拷贝的仅仅是指针地址值,结构体的内容是共享的。

  • 深拷贝:
    • 值类型:拷贝数据
  • 浅拷贝:
    • 拷贝地址–>指针
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package main

import "fmt"


type T struct {
id int
}

type Circle struct {
x int
y int
Radius int
// 如果内层是结构体,不是slice,是会拷贝的
ID []T
}

func main() {
var c1 Circle = Circle{Radius: 50, ID: []T{T{id: 1}, T{id: 5}}}
var c2 Circle = c1
// 输出的两个值相等
fmt.Printf("%+v\n", c1)
fmt.Printf("%+v\n", c2)

c1.Radius = 100
c1.ID[1].id = 100
fmt.Printf("%+v\n", c1)
fmt.Printf("%+v\n", c2)

var c3 *Circle = &Circle{Radius: 50, ID: []T{T{id: 1}, T{id: 5}}}
var c4 *Circle = c3
fmt.Printf("%+v\n", c3)
fmt.Printf("%+v\n", c4)

c3.Radius = 100
c4.ID[1].id = 50
fmt.Printf("%+v\n", c3)
fmt.Printf("%+v\n", c4)
}

---------------
{x:0 y:0 Radius:50 ID:[{id:1} {id:5}]}
{x:0 y:0 Radius:50 ID:[{id:1} {id:5}]}
{x:0 y:0 Radius:100 ID:[{id:1} {id:100}]}
{x:0 y:0 Radius:50 ID:[{id:1} {id:100}]}

&{x:0 y:0 Radius:50 ID:[{id:1} {id:5}]}
&{x:0 y:0 Radius:50 ID:[{id:1} {id:5}]}
&{x:0 y:0 Radius:100 ID:[{id:1} {id:50}]}
&{x:0 y:0 Radius:100 ID:[{id:1} {id:50}]}

数组和切片

数组与切片在内存形式上是有区别的。数组只有「体」,切片除了「体」之外,还有「头」部。切片的头部和内容体是分离的,使用指针关联起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 语言支持值传递,也支持指针传递。值传递涉及到结构体字段的浅拷贝,指针传递会共享结构体内容,只会拷贝指针地址,规则上和赋值是等价的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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 语言不是面向对象的语言(面向接口?),它里面不存在类的概念,自然不会有面向对象的特性,但是可以通过其他方式实现。

Go 语言中,结构体正是类的替代品。类可以附加很多成员方法,结构体也可以。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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++ 语言里,值访问使用句点 . 操作符,而指针访问需要使用箭头 -> 操作符。

指针方法

1
2
3
func (c *Circle) expand() {
c.Radius *= 2
}

结构体指针方法和值方法在调用时形式上是没有区别的,只不过一个可以改变结构体内部状态,而另一个不会。指针方法使用结构体值变量可以调用,值方法使用结构体指针变量也可以调用。

通过指针访问内部的字段需要 2 次内存读取操作,第一步是取得指针地址,第二部是读取地址的内容,它比值访问要慢。但是在方法调用时,指针传递可以避免结构体的拷贝操作,结构体比较大时,这种性能的差距就会比较明显。

还有一些特殊的结构体它不允许被复制,比如结构体内部包含有锁时,这时就必须使用它的指针形式来定义方法,否则会发生一些莫名其妙的问题。

内嵌结构体

结构体作为一种变量它可以放进另外一个结构体作为一个字段来使用,这种内嵌结构体的形式在 Go 语言里称之为「组合」。下面我们来看看内嵌结构体的基本使用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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

匿名内嵌结构体

还有一种特殊的内嵌结构体形式,内嵌的结构体不提供名称。这时外面的结构体将直接继承内嵌结构体所有的内部字段和方法,就好像把子结构体的一切全部都揉进了父结构体一样。匿名的结构体字段将会自动获得以结构体类型的名字命名的字段名称

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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.xc.Point.x 也是等价的

结构体没有多态性

Go 语言不是面向对象语言在于它的结构体不支持多态,它不能算是一个严格的面向对象语言。多态是指父类定义的方法可以调用子类实现的方法,不同的子类有不同的实现,从而给父类的方法带来了多样的不同行为。

Go 语言的结构体明确不支持这种形式的多态,外结构体的方法不能覆盖内部结构体的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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 语言的接口特性来模拟

通过函数实现默认值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import "fmt"

type Person struct {
Name string
Age int
}

func NewDefaultPerson() Person {
return Person{
Name: "张三",
Age: 20,
}
}

func NewPerson(name string, age int) Person {
return Person{
Name: name,
Age: age,
}
}

func main() {
person1 := NewDefaultPerson()
person2 := NewPerson("lisi", 30)
fmt.Println(person1, person2)
}