0%

Go 语言学习笔记(2):函数与数据

函数是结构化编程的最小模块单元,它将复杂算法过程分解为若干较小任务,隐藏相关细节,使得程序结构更加清晰,易于维护。函数被设计成相对独立、通过接收输入参数完成一段算法,输出或存储相关结果。因此函数也是代码复用和测试的基本单元。

函数

函数基础

关键字 func 用于定义函数,同样,左花括号不能另起一行:

  • 无需前置声明
  • 支持不定长变参
  • 支持任意个数返回值(0 个、1 个,多个)
  • 支持命名返回值
  • 支持匿名函数和闭包
  • 不支持命名嵌套定义
  • 不支持同名函数重载
  • 不支持默认参数

函数属于第一类对象(指可以在运行期创建,可用作函数参数或者返回值,可存入变量的实体),具有相同签名(参数及返回值列表)的视为同一类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

func hello() {
println("hello")
}

func exec(f func()) {
f()
}

func main() {
exec(hello)
}

从阅读和代码维护的角度来说,使用命名类型更加方便。函数只能判断其是否为 nil,不支持其他比较操作。从函数返回局部变量指针是安全的,编译器会通过逃逸分析(escape analysis)来决定是否在堆上分配内存

1
2
3
4
5
6
7
8
9
10
11
12
$ cat heap.go
package main

func test() *int {
a := 0x100
return &a
}

func main() {
p := test()
println(p, *p)
}
1
2
3
$ go build -gcflags "-l -m" heap.go
# command-line-arguments
./heap.go:4:2: moved to heap: a

函数内联(inline)对内存分配有一定影响,如果出现内联,那么会直接在栈上分配内存。

在避免冲突的情况下,函数命名要本着精简短小、望文知意的原则:

  • 通常是动词和介词加上名词
  • 避免不必要的缩写
  • 避免使用类型关键字
  • 避免歧义
  • 避免只能通过大小写区分的同名函数
  • 避免与内置函数同名,这会导致误用
  • 避免使用数字,除非是特定专有名字
  • 避免添加作用域提示前缀
  • 统一使用 camel/pascal case 拼写风格
  • 统一使用相同术语,保持一致性
  • 使用习惯用语,比如 init 表示初始化,is/has 返回布尔结果
  • 使用反义词组命名行为相反的函数,比如 get/set,min/max 等

另外,函数和方法的命名规则稍有些不同,方法通过选择符调用,且具备状态上下文,可以使用更简单的动词命名。

参数

Go 对参数的处理偏向保守,不支持有默认值的可选参数,不支持命名实参。调用时必须按照签名顺序传递指定类型和数量的实参,就算以 _ 命名的参数也不能忽略。在函数形参列表中,相邻的同类型参数可以合并。函数可以视为函数局部变量,因此不能在相同层次定义同名变量。

不管是指针、引用类型还是其他类型参数,都是值拷贝传递(pass-by-value)。区别无非是拷贝目标对象,还是拷贝指针。在函数调用前,会为形参和返回值分配内存空间,并将实参拷贝到形参内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

func test(x *int) {
fmt.Printf("pointer %p, target %v", &x, x)
}

func main() {
a := 0x100
p := &a
fmt.Printf("pointer: %p, target: %v\n", &p, p)
test(p)
}

被复制的指针会延长目标对象生命周期,还可能会导致它被分配到堆上,那么其性能就得加上堆内存分配和垃圾回收的成本。在栈上复制小对象只需很少的指令即可,远比运行时进行堆内存分配要快的多。

要实现传出参数,通常建议使用返回值。也可以使用二级指针,如果函数参数过多,建议将其重构为一个复合结构类型,也算是变相实现可选参数和命名实参功能。将过多的参数独立成 option struct,既便于扩展参数集,也方便通过 newOption() 函数设置默认配置。

变参

变参本质上就是一个切片,只能接收一到多个同类型的参数,且必须放到参数列表尾部。将切片本身作为变参时,需要进行展开。如果是数组,需要先将其转换为切片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import "fmt"

func test(a ...int) {
fmt.Printf("%T, %v\n", a, a)
}

func main() {
test(1, 2, 3, 4)

a := make([]int, 3)
a[0] = 5
test(a...)

b := [3]int{10, 20, 30}
test(b[:]...)
}

由于变参是切片,那么参数复制的仅仅是切片自身,并不包括底层数组,也因此可修改原数据。如果需要,可以使用内置函数 copy 底层数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import "fmt"

func test(a ...int) {
for i := range a {
a[i] += 100
}
}

func main() {
a := []int{10, 20, 30}
test(a...)
fmt.Println(a)
}
1
2
./var_param_2
[110 120 130]

返回值

有返回值的函数,必须有明确的 return 终止语句。有 panic 或者死循环(无break),则无需 return 终止语句。Go 函数支持多返回值,因此函数可以返回更多状态,尤其是 error 模式。但是需要注意,不能用数组、切片接收多返回值,但是可以用 _ 忽略掉不想要的返回值

也可以对返回值进行命名,这样让函数声明更加清晰,同时也会改善帮助文档和代码编辑器提示。命名返回值和参数一样,可当做局部变量使用,最后由 return 语句隐式返回

1
2
3
4
5
6
7
8
9
10
package main

func add(x, y int) (z int) {
z = x + y
return
}

func main() {
println(add(3, 5))
}

这些特殊的局部变量会被不同层级的同名变量遮蔽,但是编译器能够检查到此类错误,只需要改为显式的 return 即可。

1
2
3
4
5
6
7
8
9
10
11
12
package main

func add(x, y int) (z int) {
{
z := x + y
return // z is shadowed during return, 需要改为 return z
}
}

func main() {
println(add(3, 5))
}

除了遮蔽问题,还需要注意,如果对返回值命名,则需要对全部返回值命名,否则编译器会搞不清楚状况。它无法确认 return 语句中的返回值是如何对应的(此时编译器会跳过未命名的返回值,无法准确匹配)。如果返回值类型能够明确表明其含义,就不要对其进行命名。

匿名函数

匿名函数是指没有定义名字符号的函数。除了没有名字外,匿名函数和普通函数完全相同。最大的区别是函数内部定义匿名函数,形成类似嵌套的效果,匿名函数可以直接调用,保存到变量,作为参数或返回值。普通函数和匿名函数都可以作为结构体字段,或者经通道传递。不曾使用的匿名函数会被编译器当做错误。

闭包(closure)是指在其词法上下文中引用了自由变量的函数,或者说是函数和其引用环境的组合体。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
package main

func test(x int) func() {
return func() {
println(x)
}
}

func main() {
f := test(123)
f()
}

这里 test 返回的匿名函数会引用上下文环境变量。当该函数在 main 中执行时,依然能够正确读取该值,这种现象就称为闭包。对于一个闭包,返回的不仅仅是匿名函数,还包括所引用的环境变量的指针。所以说,闭包是匿名函数和引用环境的组合体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

func test(x int) func() {
println(&x)

return func() {
println(&x, x)
}
}

func main() {
f := test(0x100)
f()
}
1
2
3
$ ./closure_pointer
0xc00008e000
0xc00008e000 256

正是因为闭包通过指针引用环境变量,那么可能会导致其生命周期延长,甚至被分配到堆内存中,而且还具有 延迟求值 的特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

func test() []func() {
var s []func()

for i := 0; i < 2; i++ {
s = append(s, func() {
println(&i, i)
})
}

return s
}

func main() {
for _, f := range test() {
f()
}
}
1
2
0xc00008e000 2
0xc00008e000 2

在这个例子中,由于每次添加的匿名函数都是引用的同一个变量,添加操作仅仅是将匿名函数放到列表中,并未执行该匿名函数,当在 main 中真正执行这些匿名函数时,它读取的就是该变量 i 的最新值。要解决这个问题,就是每次使用不同的环境变量变量或者传参复制,让各自闭包环境各不相同。多个匿名函数引用同一环境变量,也会让事情变得更加复杂,任何修改行为都会影响其他函数取值,在并发模式下可能需要做同步处理。

闭包让我们不用传递参数就可以读取或修改环境状态,当然也要为此付出额外代价,对于性能要求较高的场合,需要慎重使用。匿名函数也是一种常见的重构手段,可将大函数分解成多个相对独立的匿名函数块,然后用相对简洁的调用完成逻辑流程,以实现框架和细节分离。

延迟调用

语句 defer 向当前函数注册稍后执行的函数调用,这些调用被称为延迟调用。它们在当前函数执行结束前才被执行,常用于资源的释放,解除锁定、以及错误处理等操作。延迟调用注册的是调用,因此必须提供所需参数(哪怕为空)。参数值在注册时被复制缓存起来。如过对状态敏感,可改用指针或闭包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

func main() {
x, y := 1, 2

defer func(a int) {
fmt.Printf("defer x, y = %d %d", a, y)
}(x)

x += 100
y += 200
}
1
2
$ ./defer
defer x, y = 1 202

多个延迟注册按 FILO 次序执行:

1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
defer println("a")
fmt.Println("c")
defer println("b")
}

编译器通过插入额外指令来实现延迟调用执行,而 return 和 panic 语句都会终止当前函数流程,引发延迟调用。另外,先是由 return 语句更新返回值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

func test() (z int) {
defer func() {
println("defer:", z)
z += 100
}()

return 100

}

func main() {
fmt.Println("test:", test())
}

需要牢记,延迟调用在函数结束时才被执行,不合理的使用方式会浪费更多资源,甚至造成逻辑错误。相比直接用 CALL 指令调用函数,延迟调用会花费更大的代价,这其中包括注册、调用等操作,还有额外的缓存开销

错误处理

官方推荐的做法是返回 error 状态。标准库将 error 定义为接口类型,以便实现自定义错误类型。

1
2
3
type error interface {
Error() string
}

按照惯例,error 总是最后一个返回参数,标准库提供了相关创建函数,可以方便地创建包含简单错误文本的 error 对象。应该通过错误变量,而非文本内容来判定错误类型

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

import (
"errors"
"log"
)

var errDivByZero = errors.New("division by zero")

func div(x, y int) (int, error) {
if y == 0 {
return 0, errDivByZero
}

return x / y, nil
}

func main() {
z, err := div(5, 0)
if err == errDivByZero {
log.Fatalln(err)
}
println(z)
}

与 errors.New 类似的还有 fmt.Errorf,它返回一个格式化内容的错误对象。错误变量的字符串内容通常全部小写,没有结束标点,以便于嵌入到其他格式化字符串中进行输出。

有时候我们需要自定义错误类型,以容纳更多上下文状态信息,这样的话,还可以基于类型做判断:

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
package main

import "fmt"

type DivError struct {
x, y int
}

func (DivError) Error() string {
return "division by zero"
}

func div(x, y int) (int, error) {
if y == 0 {
return 0, DivError{x, y}
}

return x / y, nil
}

func main() {
_, err := div(5, 0)

if err != nil {
switch e := err.(type) {
case DivError:
fmt.Println(e, e.x, e.y)
default:
fmt.Println(e)
}
}
}

自定义错误类型通常以 Error 为名称后缀,在用 switch 按类型匹配时,需要注意 case 的顺序。应将自定义类型放在前面,有限匹配更具体的错误类型。大量函数和方法返回 error,使得调用代码变得很难看,代码里有一堆的检查语句,解决思路有:

  • 使用专门的检查函数处理逻辑错误,简化检查代码
  • 在不影响逻辑的情况下,使用 defer 延后处理错误状态(err 退化赋值)
  • 在不中断逻辑的情况下,将错误作为内部状态保存,等待最终提交时再做处理

与 error 相比,panic/recover 在使用方法上更接近 try/catch 结构化异常。它们都是内置函数而非语句,panic 会立即中断当前函数流程,执行延迟调用,而在延迟调用函数中,recover 可以捕获并返回 panic 提交的错误对象。

1
2
func panic(v interface{})
func recover() interface{}

由于 panic 参数是空接口类型,因此可以使用任何对象作为错误状态。而 recover 的结果也需要做转型才能获得具体的信息。无论是否执行 recover,所有延迟调用都会被执行,但中断性错误会沿调用堆栈向外传递,要么被外层捕获,要么导致进程崩溃。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import "log"

func test() {
defer println("test.1")
defer println("test.2")

panic("i am dead")
}

func main() {
defer func() {
log.Println(recover())
}()

test()
}

连续调用 panic,仅最后一个会被 recover 捕获。在延迟函数中 panic,不会影响后续延迟调用执行,而 recover 之后 panic,可以再次被捕获。另外,recover 必须在延迟函数中执行才能正常工作。考虑到 recover 的特性,如果要保护代码片段,那么只能将其重构为函数调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

func test(x, y int) {
z := 0

func() {
defer func() {
if recover() != nil {
z = 0
}
}()

z = x / y
}()

println("x / y = ", z)
}

func main() {
test(5, 0)
}

调试阶段,可以使用 runtime/debug.PrintStack 函数输出完整的调用堆栈信息。除非是不可恢复的、导致系统无法正常工作的错误,否则不建议使用 panic。

数据

字符串

字符串是不可变字节(byte)序列,其本身是一个复合结构。头部指针指向字节数组,但没有 NULL 结尾,默认以 UTF-8 编码存储 Unicode 字符,字面量允许使用十六进制、八进制和 UTF 编码格式。

1
2
3
4
type stringStruct struct {
str unsafe.Pointer
len int
}

字符串默认值不是 nil,而是 ""。使用 `` 定义不做转义处理的原始字符串,支持跨行。编译器不会解析原始字符串内的注释语句,且前置缩进空格也属于字符串内容。

支持 !=、==、<、>、+、+= 操作符。允许以索引号访问字节数组(非字符),但不能获取元素地址。len(str) 返回的也是字符串所占据的字节数(并不一定等于字符数)。以切片语法(起始和结束索引号)返回子串时,其内部依旧指向原字节数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
"fmt"
"reflect"
"unsafe"
)

func main() {
s := "abcdefg"

s1 := s[:3]
s2 := s[1:4]
s3 := s[2:]

println(s1, s2, s3)
fmt.Printf("%#v\n", (*reflect.StringHeader)(unsafe.Pointer(&s)))
fmt.Printf("%#v\n", (*reflect.StringHeader)(unsafe.Pointer(&s1)))
}
1
2
3
abc bcd cdefg
&reflect.StringHeader{Data:0x10cd924, Len:7}
&reflect.StringHeader{Data:0x10cd924, Len:3}

使用 for 遍历字符串时,分 byte 和 rune 两种方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import "fmt"

func main() {
s := "我们"
for i := 0; i < len(s); i++ {
fmt.Printf("%d: [%c]\n", i, s[i])
}

for i, c := range s {
fmt.Printf("%d: [%c]\n", i, c)
}

}
1
2
3
4
5
6
7
8
9
$ ./for_string
0: [æ]
1: [ˆ]
2: [‘]
3: [ä]
4: [»]
5: [¬]
0: [我]
3: [们]

要修改字符串,需要将其转换为可变类型([]rune 或 []byte),待完成后再转换回来。但是不管如何转换,都需要重新分配内存,并复制数据。

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
package main

import (
"fmt"
"reflect"
"unsafe"
)

func pp(format string, ptr interface{}) {
p := reflect.ValueOf(ptr).Pointer()
h := (*uintptr)(unsafe.Pointer(p))
fmt.Printf(format, *h)
}

func main() {
s := "hello, world"
pp("s: %x\n", &s)

bs := []byte(s)
s2 := string(bs)

pp("string to []byte, bs: %x\n", &bs)
pp("[]byte to string, s2: %x\n", &s2)

rs := []rune(s)
s3 := string(rs)

pp("string to []rune, rs: %x\n", &rs)
pp("[]rune to string, s3: %x\n", &s3)
}
1
2
3
4
5
6
$ ./string_covt
s: 10ce705
string to []byte, bs: c00001a0b0
[]byte to string, s2: c00001a0c0
string to []rune, rs: c00001e030
[]rune to string, s3: c00001a0e0

在上面这个例子中,pp 函数是为了打印 字符串slice 的内部数据保存地址。对 string 而言,即打印的是 stringStruct 结构体中的 str 成员的值。

某些时候,转换操作会拖累算法性能,可尝试使用非安全的方法进行改善:

1
2
3
4
5
import "unsafe"

func toString(bs []byte) string {
return *(*string)(unsafe.Pointer(&bs))
}

这种方法是利用了 []byte 和 string 结构部分相同,以非安全的指针类型转换来实现类型变更,从而避免了底层数组复制。在高并发压力下,这种做法能有效改善执行性能,使用 unsafe 存在一定的风险,需要小心谨慎。

使用 append 函数,可以将 string 直接追加到 []byte 内。考虑到字符串只读特征,转换时复制数据到新分配的内存是可以理解的,但是性能也很重要,编译器会为某些场合进行专门的优化,避免额外分配和赋复制操作:

  • 将 []byte 转换为 string,去 map[string] 查询的时候
  • 将 string 转换为 []byte,进行 for range 迭代的时候,直接取字节赋值给局部变量

除类型转换为,动态构建字符串也容易造成性能问题。用加法操作符拼接字符串时,每次都必须重新分配内存。因此在构建超大字符串时性能较差。改进思路是预分配足够多的空间,常用方法有 strings.Join 函数,它会统计所有参数长度,并一次性完成内存分配。另外,通过 bytes.Buffer 也能完成类似操作,且性能相当。

类型 rune 专门用来存储 unicode 码点,它是 int32 的别名,相当于 UTF-32 编码格式。使用单引号的字面量,其默认类型就是 rune:

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func main() {
r := '我'
fmt.Printf("%T\n", r)

rs := "我们"
for i, s := range rs {
fmt.Printf("%d %T [%c]\n", i, s, s)
}
}
1
2
3
4
$ ./rune
int32
0 int32 [我]
3 int32 [们]

[]rune 外,还可以直接在 rune、byte、string 间进行转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import "fmt"

func main() {
r := '我'

s := string(r)
b := byte(r)

s2 := string(b)
r2 := rune(b)

fmt.Println(s, b, s2, r2)
}

要知道,字符串字节数组里存储的内容,并不一定就是合法的 UTF-8 文本。标准库 unicode 里提供了丰富的操作函数,例如 ValidString 可以验证字符串是否为合法的 UTF-8 文本,另外还可以用 RuneCountInString 替代 len 返回准确的 Unicode 字符数量。

数组

定义数组类型时,数组长度必须是非负整形常量表达式,长度是类型的组成部分。以下都是数组的初始化方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import "fmt"

func main() {
var a [4]int

b := [4]int{2, 5}
c := [4]int{5, 3: 10}

d := [...]int{1, 2, 3}
e := [...]int{10, 3: 100}

fmt.Println(a, b, c, d, e)
}
1
[0 0 0 0] [2 5 0 0] [5 0 0 10] [1 2 3] [10 0 0 100]

其他的一些数组使用规则:

  • 对于结构等复合类型,可省略元素初始化类型标签:
  • 在定义多维数组时,仅第一维允许使用 ...
  • 内置函数 len 和 cap 都返回第一维度长度
  • 如果元素类型支持 ==!= 操作符,那么数组也支持该操作

另外,要分清指针数组和数组指针的区别,指针数组是指元素为指针类型的数组,数组指针是获取数组变量的地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func main() {
x, y := 10, 20

a := [...]*int{&x, &y}
p := &a

fmt.Printf("%T, %v\n", a, a)
fmt.Printf("%T, %v\n", p, p)
}
1
2
3
$ ./point_array
[2]*int, [0xc00012c008 0xc00012c010]
*[2]*int, &[0xc00012c008 0xc00012c010]

数组指针可以直接用来操作元素。特别需要注意,与 C 数组变量隐式作为指针使用不同,Go 数组是值类型,赋值和传参操作都会复制整个数组数据。如果需要,可以改用指针或者切片,以避免数据复制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import "fmt"

func test(x [2]int) {
fmt.Printf("x: %p, %v\n", &x, x)
}

func main() {
a := [2]int{10, 20}

var b [2]int

b = a
fmt.Printf("x: %p, %v\n", &a, a)
fmt.Printf("x: %p, %v\n", &b, b)

test(a)
}
1
2
3
x: 0xc00001a090, [10 20]
x: 0xc00001a0a0, [10 20]
x: 0xc00001a0f0, [10 20]

切片

切片本身并非动态数组或者数组指针,它内部通过指针引用底层数组,设定相关的属性将数据读写操作限定在指定区域内:

1
2
3
4
5
type slice struct {
array unsafe.Pointer
len int
cap int
}

切片本身是一个只读对象,其工作机制类似数组指针的一种包装。可基于数组或者数组指针创建切片,以开始和结束索引确定所引用的数组片段,不支持反向索引,实际范围是一个右半开区间。通过 x[low:high:max] 的形式创建切片,len = high - low,cap = max - low。属性 cap 表示切片所引用数组片段的真实长度,len 用于限定可读写的元素数量,另外数组必须是 addressable,否则会引发错误。

和数组一样,切片同样使用索引号访问元素内容,起始索引为 0,而非对应的底层数组真实索引的位置:

1
2
3
4
5
6
7
8
9
10
package main

func main() {
x := [...]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s := x[2:5]

for i := 0; i < len(s); i++ {
println(s[i])
}
}

可以直接创建切片对象,无需预先准备数组。因为是引用类型,需要使用 make 函数或显式初始化语句,它会自动完成底层数组内存分配。

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func main() {
s1 := make([]int, 3, 5)
s2 := make([]int, 3)
s3 := []int{10, 20, 5: 30}

fmt.Println(s1, len(s1), cap(s1))
fmt.Println(s2, len(s2), cap(s2))
fmt.Println(s3, len(s3), cap(s3))
}
1
2
3
4
./make_slice
[0 0 0] 3 5
[0 0 0] 3 3
[10 20 0 0 0 30] 6 6

需要注意以下两种定义方式的区别,前者定义了一个 []int 类型的变量,并未执行初始化操作,其值为 nil,而后者利用初始化表达式完成了全部创建过程,只不过其 len 和 cap 都为 0。另外可以直接对 nil 切片执行 slice[:] 操作,同样返回 nil。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"fmt"
"reflect"
"unsafe"
)

func main() {
var a []int
b := []int{}

fmt.Printf("a: %#v\n", (*reflect.SliceHeader)(unsafe.Pointer(&a)))
fmt.Printf("b: %#v\n", (*reflect.SliceHeader)(unsafe.Pointer(&b)))
fmt.Printf("a size: %d\n", unsafe.Sizeof(a))
}
1
2
3
4
$ ./slice_init
a: &reflect.SliceHeader{Data:0x0, Len:0, Cap:0}
b: &reflect.SliceHeader{Data:0x119e428, Len:0, Cap:0}
a size: 24

切片不支持比较操作,即使元素类型支持也不行,仅能判断其是否为 nil。另外可以获取元素地址,但是不能像数组那样直接用 slice 指针访问元素内容。

切片只是很小的结构体对象,用来代替数组传参可避免复制开销。还有 make 函数允许在运行期动态指定数组长度,绕开了数组类型必须使用编译期常量的限制。但是并非所有时候都适合用切片替代数组,因为切片底层数组可能会在堆上分配内存,而且小数组在栈上拷贝的消耗也未必比 make 代价大。

reslice 操作是指将切片视为 [cap]slice 数据源,据此创建新的切片对象,不能超过 cap,但是不受 len 限制。新建的切片对象依旧指向原底层数组,也就是说修改对所有关联切片可见。

append 函数用于向切片尾部(slice[len])添加数据,返回新的切片对象。数据被追加到原底层数组,如果超过 cap 限制,则为新切片对象重新分配数组。

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"

func main() {
s := make([]int, 0, 100)
s1 := s[:2:4]
s2 := append(s1, 1, 2)

fmt.Printf("s1: %p: %v\n", &s1[0], s1)
fmt.Printf("s2: %p: %v\n", &s2[0], s2)
fmt.Printf("s data: %v\n", s[:10])
fmt.Printf("s1 cap: %d, s2 cap: %d\n", cap(s1), cap(s2))

s2 = append(s1, 3, 4, 5, 6)
fmt.Printf("s1: %p: %v\n", &s1[0], s1)
fmt.Printf("s2: %p: %v\n", &s2[0], s2)
fmt.Printf("s data: %v\n", s[:10])
fmt.Printf("s1 cap: %d, s2 cap: %d\n", cap(s1), cap(s2))

}
1
2
3
4
5
6
7
8
9
$ ./append
s1: 0xc0000b8000: [0 0]
s2: 0xc0000b8000: [0 0 1 2]
s data: [0 0 1 2 0 0 0 0 0 0]
s1 cap: 4, s2 cap: 4
s1: 0xc0000b8000: [0 0]
s2: 0xc0000b6040: [0 0 3 4 5 6]
s data: [0 0 1 2 0 0 0 0 0 0]
s1 cap: 4, s2 cap: 8
  • 需要注意,是超出切片 cap 的限制,而非底层数组长度的限制
  • 新分配数组长度是原 cap 的 2 倍,而非原数组的 2 倍(也并非总是 2 倍,对于较大的切片,会尝试扩容 1/4,以节约内存)
  • 正因为存在重新分配底层数组的缘故,在某些场合建议预留足够多的空间,避免中途内存分配和数据复制的开销
  • 向 nil 切片追加数据时,会为其分配底层数组内存。

在两个切片对象间复制数据,允许指向同一底层数组,允许目标区间重叠。最终所复制的长度以较短的切片长度为准:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import "fmt"

func main() {
s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

s1 := s[5:8]
n := copy(s[4:], s1)
fmt.Println(n, s)

s2 := make([]int, 6)
n = copy(s2, s)
fmt.Println(n, s2)
}

另外,也可以直接从字符串中复制数据到 []byte。如果切片长时间引用大数组中很小的片段,那么建议新建独立的切片,复制出所需要的数据,以便原数组内存可被及时回收。

字典

字典(哈希表)是一种使用频率极高的数据结构,将其作为语言内置类型,从运行时层面进行优化,可以获得更高性能。作为无序键值对集合,字典要求 key 必须是支持相等运算符(== 和 =!)的数据类型,比如数字、字符串、指针、数组、结构体以及对应接口类型。字典是引用类型,使用 make 函数或初始化表达语句来创建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import "fmt"

func main() {
m := make(map[string]int)
m["a"] = 1
m["b"] = 1

m2 := map[int]struct {
x int
}{
1: {x: 100},
2: {x: 200},
}

fmt.Println(m, m2)
}

map 基本操作如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

func main() {
m := map[string]int{
"a": 1,
"b": 2,
}

m["a"] = 10
m["c"] = 30

if v, ok := m["d"]; ok {
println(v)
}

delete(m, "d")
}

访问不存在的键,默认返回零值,不会引发错误。但是推荐使用 ok-idiom 模式,通过零值无法判断键值是否存在,因为存储的 value 本身就可能是零值

对于字典类型,需要注意如下事项:

  • 对字典进行迭代时,每次返回的键值次序可能并不相同。
  • 函数 len 返回当前键值对数量,cap 不接受字典类型
  • 因为内存安全和哈希算法等缘故,字典被设计成 not addressable,因此不能直接修改 value 成员(结构或数组)

下面两种赋值方式都是不行的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

func main() {
type user struct {
name string
age byte
}

m := map[int]user{
1: {"Tom", 19},
}

m[1].age += 1

p := &m[1]
p.age++
}
1
2
3
# command-line-arguments
./map_value.go:13:11: cannot assign to struct field m[1].age in map
./map_value.go:15:7: cannot take the address of m[1]

正确做法是返回整个 value,待修改后再设置字典键值,或直接用指针类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

func main() {
type user struct {
name string
age byte
}

m := map[int]user{
1: {"Tom", 19},
}

u := m[1]
u.age += 1
m[1] = u

m2 := map[int]*user{
1: &user{"Jack", 20},
}
m2[1].age++
}
  • 不能对 nil 字典进行写操作,但是可以读
  • 同样需要注意,内容为空的字典,与 nil 字典是不同的
  • 在迭代期间删除或新增键值是安全的

运行时会对字典并发操作做出检查,如果某个任务正在对字典进行写操作,那么其他任务就不能对该字典执行并发操作(读、写、删除),否则会导致进程崩溃。可以用 sync.RWMutex 实现同步,避免读写操作同时进行。

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
package main

import (
"time"
)

func main() {
m := make(map[string]int)

go func() {
for {
m["a"] += 1
time.Sleep(time.Millisecond)
}
}()

go func() {
for {
_ = m["b"]
time.Sleep(time.Millisecond)
}
}()

select {}
}
1
2
$ ./map_sync
fatal error: concurrent map read and map write

使用数据竞争(data race)检查此类问题,可以输出详细的检测信息:

1
$ go run -race map_sync.go

字典对象本身就是指针包装,传参时无需再次取地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
"fmt"
"unsafe"
)

func test(x map[string]int) {
fmt.Printf("x: %p\n", x)
}

func main() {
m := make(map[string]int)
test(m)
fmt.Printf("m: %p, %d\n", m, unsafe.Sizeof(m))

m2 := map[string]int{}
test(m2)
fmt.Printf("m2: %p, %d\n", m2, unsafe.Sizeof(m2))
}
1
2
3
4
5
$ ./map_assign
x: 0xc00010e030
m: 0xc00010e030, 8
x: 0xc00010e060
m2: 0xc00010e060, 8

在创建时预先准备好足够空间有助于提升性能,减少扩张时的内存分配和重新哈希操作。对于海量小对象,应直接用字典存储键值数据拷贝,而非指针。这有助于减少需要扫描的对象数量,大幅缩短垃圾回收时间。另外,字典不会收缩内存,所以适当替换成新对象是必要的。

结构体

结构体(struct)是将多个不同类型命名字段(field)序列打包成一个复合类型。字段名必须唯一,可以使用 _ 补位。支持使用指针类型成员。字段名、排列顺序属于类型组成部分。除了对齐处理外,编译器不会优化,调整内存布局。

可以按照顺序初始化全部字段,或者使用命名方式初始化指定字段。推荐使用命名初始化,这样在扩充结构字段或者调整字段顺序时,不会导致初始化语句出错

可以直接定义匿名结构类型变量,或者定义匿名结构类型字段。但是由于其缺少类型标识,在作为字段类型时无法直接初始化,稍显麻烦。

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
package main

import "fmt"

func main() {
u := struct {
name string
age byte
}{
name: "Tom",
age: 12,
}

type file struct {
name string
attr struct {
owner int
perm int
}
}

f := file{
name: "test.data",

// attr : {
// owner: 1,
// perm: 0755,
// }
}

f.attr.owner = 1
f.attr.perm = 0755

fmt.Println(u, f)
}

其实也可以在初始化语句中再次定义,但是那样看上去会非常丑陋。对于 struct 而言,只有在所有类型全部支持时,才可以做相等操作。可以使用指针直接操作结构字段,但是不能是多级指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import "fmt"

func main() {
u := struct {
name string
age byte
}{
name: "Tom",
age: 12,
}

p := &u
p.name = "Jack"
p.age++

fmt.Println(u)
}

空结构(struct{})是指没有字段的结构类型,它比较特殊,因为无论是其自身,还是作为数组元素类型,其长度都是 0。实际上,这类长度为 0 的对象通常都指向 runtime.zerobase 变量。空结构可以作为通道元素类型,用于事件通知。

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

func main() {
exit := make(chan struct{})

go func() {
println("hello, world!")
exit <- struct{}{}
}()

<-exit
println("end.")
}

匿名字段是指没有名字,仅有类型的字段,也被称作嵌入字段或嵌入类型。从编译器来看,这只是隐式地以类型名作为字段名字,可以直接引用匿名字段中的成员,但是初始化时必须当做独立字段

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

type attr struct {
perm int
}

type file struct {
name string
attr
}

func main() {
f := file{
name: "test.data",
attr: attr{
perm: 0755,
},
}

f.perm = 0644
println(f.perm)
}

如果嵌入其他包中的类型,则隐式字段名字不包含包名。不仅仅是结构体,除接口指针和多级指针以外的任何命名类型都可以作为匿名字段。另外由于未命名类型没有名字标识,也就无法作为匿名字段。也不能将基础类型和其指针类型同时嵌入,因为两者隐式名字相同

虽然可以像普通字段那样访问匿名字段成员,但是会存在重名问题。默认情况下,编译器会从当前显式命名字段开始,逐步向内查找匿名字段成员。如果匿名字段成员被外层同名字段遮蔽,那么必须使用显式字段名。

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

import "fmt"

type file struct {
name string
}

type data struct {
file
name string
}

func main() {
d := data{
name: "data",
file: file{"file"},
}

d.name = "data2"
d.file.name = "file2"
fmt.Println(d.name, d.file.name)
}

如果多个相同层级的匿名字段成员重名,就只能使用显式字段名访问,因为编译器无法确定目标。

严格来说,Go 并不是传统意义上的面对对象编程语言,或者说仅实现了最小面对对象机制。匿名嵌入不是继承,无法实现多态处理。虽然配合方法集,可以用接口来实现一些类似操作,但是其本质上是不同的

字段标签(tag)并不是注释,而是用来对字段进行描述的元数据。尽管它不属于数据成员,但是是类型的组成部分。在运行期,可用作反射获取标签信息,它常被用作格式校验,数据库关系映射等。

不管结构体包含多少字段,其内存总是一次性分配,各字段在相邻的地址空间按定义顺序排列。当然,对于引用类型、字符串和指针,结构内存只包含其基本(头部)数据。还有,所有匿名字段也包含在内。

借助 unsafe 包中的相关函数,可以输出所有字段的偏移量和长度。在分配内存时,字段需要做对齐处理,通常以所有字段中最长的基础类型宽度为标准。比较特殊的是空结构类型字段,如果它是最后一个字段,那么编译器会将其当做长度为 1 的类型做对齐处理,以便其地址不会越界,避免引发垃圾回收错误。