函数是结构化编程的最小模块单元,它将复杂算法过程分解为若干较小任务,隐藏相关细节,使得程序结构更加清晰,易于维护。函数被设计成相对独立、通过接收输入参数完成一段算法,输出或存储相关结果。因此函数也是代码复用和测试的基本单元。
函数
函数基础
关键字 func 用于定义函数,同样,左花括号不能另起一行:
- 无需前置声明
- 支持不定长变参
- 支持任意个数返回值(0 个、1 个,多个)
- 支持命名返回值
- 支持匿名函数和闭包
- 不支持命名嵌套定义
- 不支持同名函数重载
- 不支持默认参数
函数属于第一类对象(指可以在运行期创建,可用作函数参数或者返回值,可存入变量的实体),具有相同签名(参数及返回值列表)的视为同一类型:
1 | package main |
从阅读和代码维护的角度来说,使用命名类型更加方便。函数只能判断其是否为 nil,不支持其他比较操作。从函数返回局部变量指针是安全的,编译器会通过逃逸分析(escape analysis)来决定是否在堆上分配内存。
1 | $ cat heap.go |
1 | $ go build -gcflags "-l -m" heap.go |
函数内联(inline)对内存分配有一定影响,如果出现内联,那么会直接在栈上分配内存。
在避免冲突的情况下,函数命名要本着精简短小、望文知意的原则:
- 通常是动词和介词加上名词
- 避免不必要的缩写
- 避免使用类型关键字
- 避免歧义
- 避免只能通过大小写区分的同名函数
- 避免与内置函数同名,这会导致误用
- 避免使用数字,除非是特定专有名字
- 避免添加作用域提示前缀
- 统一使用 camel/pascal case 拼写风格
- 统一使用相同术语,保持一致性
- 使用习惯用语,比如 init 表示初始化,is/has 返回布尔结果
- 使用反义词组命名行为相反的函数,比如 get/set,min/max 等
另外,函数和方法的命名规则稍有些不同,方法通过选择符调用,且具备状态上下文,可以使用更简单的动词命名。
参数
Go 对参数的处理偏向保守,不支持有默认值的可选参数,不支持命名实参。调用时必须按照签名顺序传递指定类型和数量的实参,就算以 _
命名的参数也不能忽略。在函数形参列表中,相邻的同类型参数可以合并。函数可以视为函数局部变量,因此不能在相同层次定义同名变量。
不管是指针、引用类型还是其他类型参数,都是值拷贝传递(pass-by-value)。区别无非是拷贝目标对象,还是拷贝指针。在函数调用前,会为形参和返回值分配内存空间,并将实参拷贝到形参内存。
1 | package main |
被复制的指针会延长目标对象生命周期,还可能会导致它被分配到堆上,那么其性能就得加上堆内存分配和垃圾回收的成本。在栈上复制小对象只需很少的指令即可,远比运行时进行堆内存分配要快的多。
要实现传出参数,通常建议使用返回值。也可以使用二级指针,如果函数参数过多,建议将其重构为一个复合结构类型,也算是变相实现可选参数和命名实参功能。将过多的参数独立成 option struct
,既便于扩展参数集,也方便通过 newOption()
函数设置默认配置。
变参
变参本质上就是一个切片,只能接收一到多个同类型的参数,且必须放到参数列表尾部。将切片本身作为变参时,需要进行展开。如果是数组,需要先将其转换为切片。
1 | package main |
由于变参是切片,那么参数复制的仅仅是切片自身,并不包括底层数组,也因此可修改原数据。如果需要,可以使用内置函数 copy 底层数据。
1 | package main |
1 | ./var_param_2 |
返回值
有返回值的函数,必须有明确的 return 终止语句。有 panic 或者死循环(无break),则无需 return 终止语句。Go 函数支持多返回值,因此函数可以返回更多状态,尤其是 error 模式。但是需要注意,不能用数组、切片接收多返回值,但是可以用 _
忽略掉不想要的返回值。
也可以对返回值进行命名,这样让函数声明更加清晰,同时也会改善帮助文档和代码编辑器提示。命名返回值和参数一样,可当做局部变量使用,最后由 return 语句隐式返回。
1 | package main |
这些特殊的局部变量会被不同层级的同名变量遮蔽,但是编译器能够检查到此类错误,只需要改为显式的 return 即可。
1 | package main |
除了遮蔽问题,还需要注意,如果对返回值命名,则需要对全部返回值命名,否则编译器会搞不清楚状况。它无法确认 return 语句中的返回值是如何对应的(此时编译器会跳过未命名的返回值,无法准确匹配)。如果返回值类型能够明确表明其含义,就不要对其进行命名。
匿名函数
匿名函数是指没有定义名字符号的函数。除了没有名字外,匿名函数和普通函数完全相同。最大的区别是函数内部定义匿名函数,形成类似嵌套的效果,匿名函数可以直接调用,保存到变量,作为参数或返回值。普通函数和匿名函数都可以作为结构体字段,或者经通道传递。不曾使用的匿名函数会被编译器当做错误。
闭包(closure)是指在其词法上下文中引用了自由变量的函数,或者说是函数和其引用环境的组合体。如下所示:
1 | package main |
这里 test 返回的匿名函数会引用上下文环境变量。当该函数在 main 中执行时,依然能够正确读取该值,这种现象就称为闭包。对于一个闭包,返回的不仅仅是匿名函数,还包括所引用的环境变量的指针。所以说,闭包是匿名函数和引用环境的组合体:
1 | package main |
1 | $ ./closure_pointer |
正是因为闭包通过指针引用环境变量,那么可能会导致其生命周期延长,甚至被分配到堆内存中,而且还具有 延迟求值
的特性:
1 | package main |
1 | 0xc00008e000 2 |
在这个例子中,由于每次添加的匿名函数都是引用的同一个变量,添加操作仅仅是将匿名函数放到列表中,并未执行该匿名函数,当在 main 中真正执行这些匿名函数时,它读取的就是该变量 i 的最新值。要解决这个问题,就是每次使用不同的环境变量变量或者传参复制,让各自闭包环境各不相同。多个匿名函数引用同一环境变量,也会让事情变得更加复杂,任何修改行为都会影响其他函数取值,在并发模式下可能需要做同步处理。
闭包让我们不用传递参数就可以读取或修改环境状态,当然也要为此付出额外代价,对于性能要求较高的场合,需要慎重使用。匿名函数也是一种常见的重构手段,可将大函数分解成多个相对独立的匿名函数块,然后用相对简洁的调用完成逻辑流程,以实现框架和细节分离。
延迟调用
语句 defer 向当前函数注册稍后执行的函数调用,这些调用被称为延迟调用。它们在当前函数执行结束前才被执行,常用于资源的释放,解除锁定、以及错误处理等操作。延迟调用注册的是调用,因此必须提供所需参数(哪怕为空)。参数值在注册时被复制缓存起来。如过对状态敏感,可改用指针或闭包。
1 | package main |
1 | $ ./defer |
多个延迟注册按 FILO 次序执行:
1 | package main |
编译器通过插入额外指令来实现延迟调用执行,而 return 和 panic 语句都会终止当前函数流程,引发延迟调用。另外,先是由 return 语句更新返回值:
1 | package main |
需要牢记,延迟调用在函数结束时才被执行,不合理的使用方式会浪费更多资源,甚至造成逻辑错误。相比直接用 CALL 指令调用函数,延迟调用会花费更大的代价,这其中包括注册、调用等操作,还有额外的缓存开销。
错误处理
官方推荐的做法是返回 error 状态。标准库将 error
定义为接口类型,以便实现自定义错误类型。
1 | type error interface { |
按照惯例,error 总是最后一个返回参数,标准库提供了相关创建函数,可以方便地创建包含简单错误文本的 error 对象。应该通过错误变量,而非文本内容来判定错误类型。
1 | package main |
与 errors.New 类似的还有 fmt.Errorf,它返回一个格式化内容的错误对象。错误变量的字符串内容通常全部小写,没有结束标点,以便于嵌入到其他格式化字符串中进行输出。
有时候我们需要自定义错误类型,以容纳更多上下文状态信息,这样的话,还可以基于类型做判断:
1 | package main |
自定义错误类型通常以 Error 为名称后缀,在用 switch 按类型匹配时,需要注意 case 的顺序。应将自定义类型放在前面,有限匹配更具体的错误类型。大量函数和方法返回 error,使得调用代码变得很难看,代码里有一堆的检查语句,解决思路有:
- 使用专门的检查函数处理逻辑错误,简化检查代码
- 在不影响逻辑的情况下,使用 defer 延后处理错误状态(err 退化赋值)
- 在不中断逻辑的情况下,将错误作为内部状态保存,等待最终提交时再做处理
与 error 相比,panic/recover 在使用方法上更接近 try/catch 结构化异常。它们都是内置函数而非语句,panic 会立即中断当前函数流程,执行延迟调用,而在延迟调用函数中,recover 可以捕获并返回 panic 提交的错误对象。
1 | func panic(v interface{}) |
由于 panic 参数是空接口类型,因此可以使用任何对象作为错误状态。而 recover 的结果也需要做转型才能获得具体的信息。无论是否执行 recover,所有延迟调用都会被执行,但中断性错误会沿调用堆栈向外传递,要么被外层捕获,要么导致进程崩溃。
1 | package main |
连续调用 panic,仅最后一个会被 recover 捕获。在延迟函数中 panic,不会影响后续延迟调用执行,而 recover 之后 panic,可以再次被捕获。另外,recover 必须在延迟函数中执行才能正常工作。考虑到 recover 的特性,如果要保护代码片段,那么只能将其重构为函数调用:
1 | package main |
调试阶段,可以使用 runtime/debug.PrintStack 函数输出完整的调用堆栈信息。除非是不可恢复的、导致系统无法正常工作的错误,否则不建议使用 panic。
数据
字符串
字符串是不可变字节(byte)序列,其本身是一个复合结构。头部指针指向字节数组,但没有 NULL 结尾,默认以 UTF-8 编码存储 Unicode 字符,字面量允许使用十六进制、八进制和 UTF 编码格式。
1 | type stringStruct struct { |
字符串默认值不是 nil,而是 ""
。使用 `` 定义不做转义处理的原始字符串,支持跨行。编译器不会解析原始字符串内的注释语句,且前置缩进空格也属于字符串内容。
支持 !=、==、<、>、+、+=
操作符。允许以索引号访问字节数组(非字符),但不能获取元素地址。len(str) 返回的也是字符串所占据的字节数(并不一定等于字符数)。以切片语法(起始和结束索引号)返回子串时,其内部依旧指向原字节数组:
1 | package main |
1 | abc bcd cdefg |
使用 for 遍历字符串时,分 byte 和 rune 两种方式:
1 | package main |
1 | $ ./for_string |
要修改字符串,需要将其转换为可变类型([]rune 或 []byte),待完成后再转换回来。但是不管如何转换,都需要重新分配内存,并复制数据。
1 | package main |
1 | $ ./string_covt |
在上面这个例子中,pp
函数是为了打印 字符串
、slice
的内部数据保存地址。对 string 而言,即打印的是 stringStruct 结构体中的 str 成员的值。
某些时候,转换操作会拖累算法性能,可尝试使用非安全的方法进行改善:
1 | import "unsafe" |
这种方法是利用了 []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 | package main |
1 | $ ./rune |
除 []rune
外,还可以直接在 rune、byte、string 间进行转换。
1 | package main |
要知道,字符串字节数组里存储的内容,并不一定就是合法的 UTF-8 文本。标准库 unicode 里提供了丰富的操作函数,例如 ValidString 可以验证字符串是否为合法的 UTF-8 文本,另外还可以用 RuneCountInString 替代 len 返回准确的 Unicode 字符数量。
数组
定义数组类型时,数组长度必须是非负整形常量表达式,长度是类型的组成部分。以下都是数组的初始化方式:
1 | package main |
1 | [0 0 0 0] [2 5 0 0] [5 0 0 10] [1 2 3] [10 0 0 100] |
其他的一些数组使用规则:
- 对于结构等复合类型,可省略元素初始化类型标签:
- 在定义多维数组时,仅第一维允许使用
...
- 内置函数 len 和 cap 都返回第一维度长度
- 如果元素类型支持
==
或!=
操作符,那么数组也支持该操作
另外,要分清指针数组和数组指针的区别,指针数组是指元素为指针类型的数组,数组指针是获取数组变量的地址:
1 | package main |
1 | $ ./point_array |
数组指针可以直接用来操作元素。特别需要注意,与 C 数组变量隐式作为指针使用不同,Go 数组是值类型,赋值和传参操作都会复制整个数组数据。如果需要,可以改用指针或者切片,以避免数据复制。
1 | package main |
1 | x: 0xc00001a090, [10 20] |
切片
切片本身并非动态数组或者数组指针,它内部通过指针引用底层数组,设定相关的属性将数据读写操作限定在指定区域内:
1 | type slice struct { |
切片本身是一个只读对象,其工作机制类似数组指针的一种包装。可基于数组或者数组指针创建切片,以开始和结束索引确定所引用的数组片段,不支持反向索引,实际范围是一个右半开区间。通过 x[low:high:max]
的形式创建切片,len = high - low,cap = max - low。属性 cap 表示切片所引用数组片段的真实长度,len 用于限定可读写的元素数量,另外数组必须是 addressable,否则会引发错误。
和数组一样,切片同样使用索引号访问元素内容,起始索引为 0,而非对应的底层数组真实索引的位置:
1 | package main |
可以直接创建切片对象,无需预先准备数组。因为是引用类型,需要使用 make 函数或显式初始化语句,它会自动完成底层数组内存分配。
1 | package main |
1 | ./make_slice |
需要注意以下两种定义方式的区别,前者定义了一个 []int 类型的变量,并未执行初始化操作,其值为 nil,而后者利用初始化表达式完成了全部创建过程,只不过其 len 和 cap 都为 0。另外可以直接对 nil 切片执行 slice[:] 操作,同样返回 nil。
1 | package main |
1 | $ ./slice_init |
切片不支持比较操作,即使元素类型支持也不行,仅能判断其是否为 nil。另外可以获取元素地址,但是不能像数组那样直接用 slice 指针访问元素内容。
切片只是很小的结构体对象,用来代替数组传参可避免复制开销。还有 make 函数允许在运行期动态指定数组长度,绕开了数组类型必须使用编译期常量的限制。但是并非所有时候都适合用切片替代数组,因为切片底层数组可能会在堆上分配内存,而且小数组在栈上拷贝的消耗也未必比 make 代价大。
reslice 操作是指将切片视为 [cap]slice
数据源,据此创建新的切片对象,不能超过 cap,但是不受 len 限制。新建的切片对象依旧指向原底层数组,也就是说修改对所有关联切片可见。
append 函数用于向切片尾部(slice[len])添加数据,返回新的切片对象。数据被追加到原底层数组,如果超过 cap 限制,则为新切片对象重新分配数组。
1 | package main |
1 | $ ./append |
- 需要注意,是超出切片 cap 的限制,而非底层数组长度的限制
- 新分配数组长度是原 cap 的 2 倍,而非原数组的 2 倍(也并非总是 2 倍,对于较大的切片,会尝试扩容 1/4,以节约内存)
- 正因为存在重新分配底层数组的缘故,在某些场合建议预留足够多的空间,避免中途内存分配和数据复制的开销
- 向 nil 切片追加数据时,会为其分配底层数组内存。
在两个切片对象间复制数据,允许指向同一底层数组,允许目标区间重叠。最终所复制的长度以较短的切片长度为准:
1 | package main |
另外,也可以直接从字符串中复制数据到 []byte。如果切片长时间引用大数组中很小的片段,那么建议新建独立的切片,复制出所需要的数据,以便原数组内存可被及时回收。
字典
字典(哈希表)是一种使用频率极高的数据结构,将其作为语言内置类型,从运行时层面进行优化,可以获得更高性能。作为无序键值对集合,字典要求 key 必须是支持相等运算符(== 和 =!)的数据类型,比如数字、字符串、指针、数组、结构体以及对应接口类型。字典是引用类型,使用 make 函数或初始化表达语句来创建。
1 | package main |
map 基本操作如下:
1 | package main |
访问不存在的键,默认返回零值,不会引发错误。但是推荐使用 ok-idiom 模式,通过零值无法判断键值是否存在,因为存储的 value 本身就可能是零值。
对于字典类型,需要注意如下事项:
- 对字典进行迭代时,每次返回的键值次序可能并不相同。
- 函数 len 返回当前键值对数量,cap 不接受字典类型
- 因为内存安全和哈希算法等缘故,字典被设计成
not addressable
,因此不能直接修改 value 成员(结构或数组)
下面两种赋值方式都是不行的:
1 | package main |
1 | # command-line-arguments |
正确做法是返回整个 value,待修改后再设置字典键值,或直接用指针类型:
1 | package main |
- 不能对 nil 字典进行写操作,但是可以读
- 同样需要注意,内容为空的字典,与 nil 字典是不同的
- 在迭代期间删除或新增键值是安全的
运行时会对字典并发操作做出检查,如果某个任务正在对字典进行写操作,那么其他任务就不能对该字典执行并发操作(读、写、删除),否则会导致进程崩溃。可以用 sync.RWMutex
实现同步,避免读写操作同时进行。
1 | package main |
1 | $ ./map_sync |
使用数据竞争(data race)检查此类问题,可以输出详细的检测信息:
1 | $ go run -race map_sync.go |
字典对象本身就是指针包装,传参时无需再次取地址:
1 | package main |
1 | $ ./map_assign |
在创建时预先准备好足够空间有助于提升性能,减少扩张时的内存分配和重新哈希操作。对于海量小对象,应直接用字典存储键值数据拷贝,而非指针。这有助于减少需要扫描的对象数量,大幅缩短垃圾回收时间。另外,字典不会收缩内存,所以适当替换成新对象是必要的。
结构体
结构体(struct)是将多个不同类型命名字段(field)序列打包成一个复合类型。字段名必须唯一,可以使用 _
补位。支持使用指针类型成员。字段名、排列顺序属于类型组成部分。除了对齐处理外,编译器不会优化,调整内存布局。
可以按照顺序初始化全部字段,或者使用命名方式初始化指定字段。推荐使用命名初始化,这样在扩充结构字段或者调整字段顺序时,不会导致初始化语句出错。
可以直接定义匿名结构类型变量,或者定义匿名结构类型字段。但是由于其缺少类型标识,在作为字段类型时无法直接初始化,稍显麻烦。
1 | package main |
其实也可以在初始化语句中再次定义,但是那样看上去会非常丑陋。对于 struct 而言,只有在所有类型全部支持时,才可以做相等操作。可以使用指针直接操作结构字段,但是不能是多级指针。
1 | package main |
空结构(struct{})是指没有字段的结构类型,它比较特殊,因为无论是其自身,还是作为数组元素类型,其长度都是 0。实际上,这类长度为 0 的对象通常都指向 runtime.zerobase 变量。空结构可以作为通道元素类型,用于事件通知。
1 | package main |
匿名字段是指没有名字,仅有类型的字段,也被称作嵌入字段或嵌入类型。从编译器来看,这只是隐式地以类型名作为字段名字,可以直接引用匿名字段中的成员,但是初始化时必须当做独立字段。
1 | package main |
如果嵌入其他包中的类型,则隐式字段名字不包含包名。不仅仅是结构体,除接口指针和多级指针以外的任何命名类型都可以作为匿名字段。另外由于未命名类型没有名字标识,也就无法作为匿名字段。也不能将基础类型和其指针类型同时嵌入,因为两者隐式名字相同。
虽然可以像普通字段那样访问匿名字段成员,但是会存在重名问题。默认情况下,编译器会从当前显式命名字段开始,逐步向内查找匿名字段成员。如果匿名字段成员被外层同名字段遮蔽,那么必须使用显式字段名。
1 | package main |
如果多个相同层级的匿名字段成员重名,就只能使用显式字段名访问,因为编译器无法确定目标。
严格来说,Go 并不是传统意义上的面对对象编程语言,或者说仅实现了最小面对对象机制。匿名嵌入不是继承,无法实现多态处理。虽然配合方法集,可以用接口来实现一些类似操作,但是其本质上是不同的。
字段标签(tag)并不是注释,而是用来对字段进行描述的元数据。尽管它不属于数据成员,但是是类型的组成部分。在运行期,可用作反射获取标签信息,它常被用作格式校验,数据库关系映射等。
不管结构体包含多少字段,其内存总是一次性分配,各字段在相邻的地址空间按定义顺序排列。当然,对于引用类型、字符串和指针,结构内存只包含其基本(头部)数据。还有,所有匿名字段也包含在内。
借助 unsafe 包中的相关函数,可以输出所有字段的偏移量和长度。在分配内存时,字段需要做对齐处理,通常以所有字段中最长的基础类型宽度为标准。比较特殊的是空结构类型字段,如果它是最后一个字段,那么编译器会将其当做长度为 1 的类型做对齐处理,以便其地址不会越界,避免引发垃圾回收错误。