1 值、指针和引用
<备注> 博文中引用的Go源码基于的Go发布版本:go version go1.15.4 linux/amd64。
1.1 传值还是传引用
Go语言只有一种参数传递规则,那就是值拷贝,这种规则包括两种含义:
(1)函数参数传递时使用的是值拷贝。
(2)实例赋值给接口变量,接口对实例的引用是值拷贝。
有时明明是值拷贝的地方,结果却修改了变量的内容,有以下两种情况:
(1)直接传递的是指针。指针传递同样是值拷贝,但指针和指针副本的值指向的地址是同一个地址,所以能修改实参值。
(2)参数是复合数据类型,这些复合数据类型内部有指针类型的元素,此时参数的值拷贝并不影响指针的指向。
Go复合类型中 chan、map、slice、interface 等内部实现都是通过指针指向具体的数据,这些类型的变量在作为函数参数传递时,实际上相当于指针的副本。下面看一下 runtime 里面的具体定义。
- chan 的底层数据结构如下
//源码位置: src/runtime/chan.go
type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
closed uint32
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex
}
从 chan 在runtime里面的数据结构可知,通道元素的存放地址是由 buf 指针确定的,chan 内部的数据也是间接通过指针访问的。
- map 的底层数据结构如下
//源码位置: src/runtime/map.go
// A header for a Go map.
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/gc/reflect.go.
// Make sure this stays in sync with the compiler's definition.
count int // # live cells == size of map. Must be first (used by len() builtin)
flags uint8
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed
buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)
extra *mapextra // optional fields
}
从map 在runtime 里面的数据结构同样可以清除地看到,其通过 buckets 指针来间接引用 map 中的存储结构。
- slice 的底层数据结构如下
//源码位置: src/reflect/value.go
// SliceHeader is the runtime representation of a slice.
// It cannot be used safely or portably and its representation may
// change in a later release.
// Moreover, the Data field is not sufficient to guarantee the data
// it references will not be garbage collected, so programs must keep
// a separate, correctly typed pointer to the underlying data.
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
可以看到,slice 一样使用 uintptr 指针指向底层存放数据的数组。
- interface 的底层数据结构如下
//源码位置: src/runtime/value.go
// emptyInterface is the header for an interface{} value.
type emptyInterface struct {
typ *rtype
word unsafe.Pointer
}
// nonEmptyInterface is the header for an interface value with methods.
type nonEmptyInterface struct {
// see ../runtime/iface.go:/Itab
itab *struct {
ityp *rtype // static interface type
typ *rtype // dynamic concrete type
hash uint32 // copy of typ.hash
_ [4]byte
fun [100000]unsafe.Pointer // method table
}
word unsafe.Pointer
}
同样可以看到,接口内部也是通过一个指针指向实例值或地址的副本。
1.2 函数名的意义
Go语言的函数名和匿名函数字面量的值有3层含义:
(1)类型信息,表明其数据类型是函数类型。
(2)函数名代表函数的执行代码的起始地址。
(3)可以通过函数名进行函数调用,函数调用格式为 func_name(param_list)。在底层执行层面包含以下4部分内容。
- 准备好参数。
- 修改PC(程序计数器)的值,跳转到函数代码起始位置开始执行。
- 复制值到函数的返回值栈区。
- 通过RET指令返回到函数调用的下一条指令处继续执行。
1.3 引用语义
C++语言里面的引用的含义就是变量的别名,Go语言规范中并没有引用的概念,但为了论述方便,闭包对外部变量的引用,我们可以认为是建立了一个和外部变量同名的“引用”,该引用和外部变量指向相同的地址。还有一种解释就是Go语言针对闭包,显式地扩大了形参的作用域,使其在函数返回的闭包中仍然可见。这两种论述都没有错,本质上描述的是同一件事情,就是闭包可以访问和改变外部环境中的变量。至于是“同名引用”,还是“扩大作用域”,这些只是对闭包这个语言特性的规范表述。
示例如下:
package main
import (
"fmt"
)
func fa(a int) func(int) int {
fmt.Printf("fa-->&a=%p, a=%d\n", &a, a)
return func(i int) int {
fmt.Printf("func-->&a=%p, a=%d\n", &a, a)
a += i
return a
}
}
func main() {
//f是一个闭包,包括对函数fa形式参数a的“同名引用”
f1 := fa(1)
f2 := fa(10)
fmt.Printf("f1 type: %T\n", f1)
fmt.Printf("f2 type: %T\n", f2)
fmt.Println("f1(1)=", f1(1))
fmt.Println("f1(2)=", f1(2))
fmt.Println("f2(1)=", f2(1))
fmt.Println("f2(2)=", f2(2))
}
运行结果:
fa-->&a=0xc000018080, a=1
fa-->&a=0xc000018090, a=10
f1 type: func(int) int
f2 type: func(int) int
func-->&a=0xc000018080, a=1
f1(1)= 2
func-->&a=0xc000018080, a=2
f1(2)= 4
func-->&a=0xc000018090, a=10
f2(1)= 11
func-->&a=0xc000018090, a=11
f2(2)= 13
《代码说明》
(1)在 fa() 函数中,返回值类型是一个函数类型,返回的匿名函数中引用了外部变量a,我们知道 函数+环境变量 = 闭包。在main() 函数中,f1、f2 是两个不同的闭包实例,它们的类型都是一个函数类型:func(int) int。
(2)闭包中引用的环境变量会跟随闭包实例的生命周期一直存在。
2 编程习惯
接下来主要说明一下Go语言代码式样,包括代码风格和习惯用法。有些规则是强制要求的,有些规则是非强制的“潜规则”。遵照这些规则写出来的代码看起来“地道”、“纯正”,一看就是Gopher写的。
2.1 干净与强迫症
Go在代码干净上有着近乎苛刻的要求,主要体现在如下几个方面:
(1)编译器不能通过未使用的局部变量(包括未使用的标签),如有,则会报编译错误。
(2)“import” 未使用的包同样不能编译通过。
(3)所有的控制结构、函数和方法定义的左花括号"{" 必须放到行尾,而不能另起一行。
(4)提供 go fmt 工具格式化代码,使所有的代码风格保持统一。
Go语言对代码的干净和整洁要求到了强迫症的要求,但这是一种好的约束,虽然很多人难以接受。
2.2 comma, ok 表达式
常见的几个 comma, ok 表达式如下。
1. 获取 map 值
获取 map 中不存在键的值不会发生异常,而是会返回值类型的零值,如果想确定 map 中是否存在key,则可以使用获取 map 值的 comma, ok 表达式语法。示例如下:
func main() {
m := make(map[string]string)
v, ok := m["some"]
//通过ok进行判断
if ok != false {
fmt.Println("m[\"some\"]=", v)
} else {
fmt.Println("m[\"some\"] is nil")
}
}
运行结果:
m["some"] is nil
2. 读取 chan 的值
读取已经关闭的通道,不会阻塞,也不会引起panic,而是一直返回该通道元素类型的零值。怎么判断通道已经关闭了呢?有两种方法,一种是读取通道的 comma, ok 表达式,如果通道已经关闭,则ok的返回值是false;另一种就是通过 range 循环迭代。示例如下:
func main() {
c := make(chan int)
go func(){
c <- 1
c <- 2
close(c)
}()
//方式1
for{
//使用comma, ok 表达式判断通道是否关闭
v, ok := <- c
if ok {
fmt.Println(v)
} else {
fmt.Println("channel closed")
break
}
}
//方式2
/*for v := range c {
fmt.Println(v)
}
fmt.Println("channel closed")*/
}
运行结果:
1
2
channel closed
3. 类型断言(type assertion)
接口的类型断言通常可以使用 comma, ok 表达式来确定接口是否绑定了某个实例类型,或者判断接口绑定的实例类型是否实现了另一个接口。示例如下:
//如下代码片段摘自标准包 src/net/http/request.go
//判断接口body绑定的实例是否实现了另一个接口类型io.ReadCloser(Line855)
rc, ok := body.(io.ReadCloser)
if !ok && body != nil {
rc = ioutil.NopCloser(body)
}
//判断接口body绑定的实例类型是否是*maxBytesReader具体类型(Line1188)
if _, ok := r.Body.(*maxBytesReader); !ok {
maxFormSize = int64(10 << 20) // 10 MB is a lot of text.
reader = io.LimitReader(r.Body, maxFormSize+1)
}
2.3 简写模式
Go语言很多重复引用或声明可以用 “()” 进行简写。
1. import 多个包
示例如下:
//推荐写法
import (
"fmt"
"bytes"
"bufio"
)
//不推荐写法
import "fmt"
import "bytes"
import "bufio"
2. 多个变量声明
包中多个相关全局变量声明时,建议使用 “()” 进行合并声明。示例如下:
//推荐写法
var (
bufioReaderPool sync.Pool
bufioWriter2kPool sync.Pool
bufioWriter4kPool sync.Pool
)
//不推荐的写法
var bufioReaderPool sync.Pool
var bufioWriter2kPool sync.Pool
var bufioWriter4kPool sync.Pool
2.4 包中的函数或方法设计
很多包的开发者会在包的内部实现两个“同名”的函数或方法,一个首字母大写,用于导出API供外部调用;另一个首字母小写,用于实现具体逻辑。一般首字母大写的函数调用首字母小写的函数,同时包装一些功能;首字母小写的函数负责更多的底层细节。
大部分情况下我们不需要两个“同名”且只是首字母大小写不同的函数,只有在函数逻辑很复杂,而且函数在包的内、外都被调用的情况下,才考虑拆分为两个函数进行实现。一方面减少单个函数的复杂性,另一方面进行调用隔离。
这种编程技术在标准库 src/database/sql 里面体现得最明显。示例如下:
//DB 的导出方法
func (db *DB) Begin() (*Tx, error)
func (db *DB) Conn(ctx context.Context) (*Conn, error)
func (db *DB) Exec(query string, args ...interface{}) (Result, error)
func (tx *Tx) Prepare(query string) (*Stmt, error)
func (db *DB) Query(query string, args ...interface{}) (*Rows, error)
//DB 的内部同名未导出方法
func (db *DB) begin(ctx context.Context, opts *TxOptions, strategy connReuseStrategy) (tx *Tx, err error)
func (db *DB) conn(ctx context.Context, strategy connReuseStrategy) (*driverConn, error)
func (db *DB) exec(ctx context.Context, query string, args []interface{}, strategy connReuseStrategy) (Result, error)
func (db *DB) prepare(ctx context.Context, query string, strategy connReuseStrategy) (*Stmt, error)
func (db *DB) query(ctx context.Context, query string, args []interface{}, strategy connReuseStrategy) (*Rows, error)
2.5 多值返回函数
多值返回函数里如果有error、bool 类型的返回值,则应该将 error 或 bool 作为最后一个返回值。这是一种编程风格,没有对错。Go标准库的写法也是遵循这样的规则。当绝大数人都遵循这种写法时,如果不遵循这种“潜规则”,则写出的代码让别人阅读起来就觉得很别扭了。示例如下:
//分析Go语言标准库bytes 的源码可以看到这种习惯用法
//cd /usr/local/go/src/bytes/;ls
//egrep 'func.*, error\)|func.*, bool\)' -r * -n|grep -v 'test'
buffer.go:106:func (b *Buffer) tryGrowByReslice(n int) (int, bool) {
buffer.go:334:func (b *Buffer) ReadByte() (byte, error) {
reader.go:67:func (r *Reader) ReadByte() (byte, error) {
reader.go:117:func (r *Reader) Seek(offset int64, whence int) (int64, error) {
参考
《Go语言核心编程》