Go语言--使用“陷阱2”

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语言核心编程》

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值