第三部分 声明、类型、语句与控制结构


第8条 使用一致的变量声明形式

和Python、Ruby等动态脚本语言不同,Go语言沿袭了静态编译型语言的传统:使用变量之前需要先进行变量的声明。

这里大致列一下Go语言常见的变量声明形式:

var a int32
var s string = "hello"
var i = 13
n := 17
var (
    crlf       = []byte("\r\n")
    colonSpace = []byte(": ")
)

Go语言有两类变量。

  • 包级变量(package variable):在package级别可见的变量。如果是导出变量,则该包级变量也可以被视为全局变量
  • 局部变量(local variable):函数或方法体内声明的变量,仅在函数或方法体内可见。


8.1 包级变量的声明形式

包级变量只能使用带有var关键字的变量声明形式,但在形式细节上仍有一定的灵活度。我们从声明变量时是否延迟初始化这个角度对包级变量进行一次分类。

1. 声明并同时显式初始化

下面是摘自Go标准库中的代码(Go 1.12):

// $GOROOT/src/io/pipe.go
var ErrClosedPipe = errors.New("io: read/write on closed pipe")

// $GOROOT/src/io/io.go
var EOF = errors.New("EOF")
var ErrShortWrite = errors.New("short write")

2. 声明但延迟初始化
对于声明时并不显式初始化的包级变量,我们使用最基本的声明形式:

var a int32
var f float64

虽然没有显式初始化,但Go语言会让这些变量拥有初始的“零值”。如果是自定义的类型,保证其零值可用是非常必要的,这一点将在后文中详细说明。

3. 声明聚类与就近原则
Go语言提供var块用于将多个变量声明语句放在一起,并且在语法上不会限制放置在var块中的声明类型。但是我们一般将同一类的变量声明放在一个var块中,将不同类的声明放在不同的var块中;或者将延迟初始化的变量声明放在一个var块,而将声明并显式初始化的变量放在另一个var块中。笔者称之为“声明聚类”。比如下面Go标准库中的代码:

// $GOROOT/src/net/http/server.go
var (
    bufioReaderPool   sync.Pool
    bufioWriter2kPool sync.Pool
    bufioWriter4kPool sync.Pool
)

var copyBufPool = sync.Pool {
    New: func() interface{} {
        b := make([]byte, 32*1024)
        return &b
    },
}
...

// $GOROOT/src/net/net.go
var (
    aLongTimeAgo = time.Unix(1, 0)
    noDeadline = time.Time{}
    noCancel   = (chan struct{})(nil)
)

var threadLimit chan struct{}
...

变量声明最佳实践中还有一条:就近原则,即尽可能在靠近第一次使用变量的位置声明该变量。就近原则实际上是变量的作用域最小化的一种实现手段。



8.2 局部变量的声明形式

与包级变量相比,局部变量多了一种短变量声明形式,这也是局部变量采用最多的一种声明形式。下面我们来详细看看。

1. 对于延迟初始化的局部变量声明,采用带有var关键字的声明形式
比如标准库strings包中byteReplacer的方法Replace中的变量buf:

// $GOROOT/src/strings/replace.go
func (r *byteReplacer) Replace(s string) string {
    var buf []byte // 延迟分配
    for i := 0; i < len(s); i++ {
        b := s[i]
        if r[b] != b {
            if buf == nil {
                buf = []byte(s)
            }
            buf[i] = r[b]
        }
    }
    if buf == nil {
        return s
    }
    return string(buf)
}

另一种常见的采用带var关键字声明形式的变量是error类型的变量err(将error类型变量实例命名为err也是Go的一个惯用法),尤其是当defer后接的闭包函数需要使用err判断函数/方法退出状态时。示例代码如下:

func Foo() {
    var err error
    defer func() {
        if err != nil {
            ...
        }
    }()

    err = Bar()
    ...
}

2. 对于声明且显式初始化的局部变量,建议使用短变量声明形式
短变量声明形式是局部变量最常用的声明形式,它遍布Go标准库代码。对于接受默认类型的变量,可以使用下面的形式:

a := 17
f := 3.14
s := "hello, gopher!"

对于不接受默认类型的变量,依然可以使用短变量声明形式,只是在“:=”右侧要进行显式转型

a := int32(17)
f := float32(3.14)
s := []byte("hello, gopher!")

3. 尽量在分支控制时应用短变量声明形式

// $GOROOT/src/net/net.go
func (v *Buffers) WriteTo(w io.Writer) (n int64, err error) {
    // 笔者注:在if循环控制语句中使用短变量声明形式
    if wv, ok := w.(buffersWriter); ok {
        return wv.writeBuffers(v)
    }
    // 笔者注:在for条件控制语句中使用短变量声明形式
    for _, b := range *v {
        nb, err := w.Write(b)
        n += int64(nb)
        if err != nil {
            v.consume(n)
            return n, err
        }
    }
    v.consume(n)
    return n, nil
}

这样的应用方式体现出“就近原则”,让变量的作用域最小化了。

由于良好的函数/方法设计讲究的是“单一职责”,因此每个函数/方法规模都不大,很少需要应用var块来聚类声明局部变量。当然,如果你在声明局部变量时遇到适合聚类的应用场景,你也应该毫不犹豫地使用var块来声明多个局部变量。比如:

// $GOROOT/src/net/dial.go
func (r *Resolver) resolveAddrList(ctx context.Context, op, network,
                            addr string, hint Addr) (addrList, error) {
    ...
    var (
        tcp      *TCPAddr
        udp      *UDPAddr
        ip       *IPAddr
        wildcard bool
    )
    ...
}

或是:

// $GOROOT/src/reflect/type.go
// 笔者注:这是一个非常长的函数,因此将所有var声明都聚合在函数的开始处了
func StructOf(fields []StructField) Type {
    var (
        hash       = fnv1(0, []byte("struct {")...)
        size       uintptr
        typalign   uint8
        comparable = true
        hashable   = true
        methods    []method
        fs   = make([]structField, len(fields))
        repr = make([]byte, 0, 64)
        fset = map[string]struct{}{}
        hasPtr    = false
        hasGCProg = false
    )
    ...
}


小结

使用一致的变量声明是Go语言的一个最佳实践,我们用图8-1来对变量声明形式做个形象的小结。

从图8-1中我们看到,要想做好代码中变量声明的一致性,需要明确要声明的变量是包级变量还是局部变量、是否要延迟初始化、是否接受默认类型、是否为分支控制变量,并结合聚类和就近原则



第9条 使用无类型常量简化代码

常量是现代编程语言中最常见的语法元素。在类型系统十分严格的Go语言中,常量还兼具特殊的作用,这一条将介绍Go常量究竟能给我们日常的Go编码提供哪些帮助。



9.1 Go常量溯源

Go语言是站在C语言等编程语言的肩膀之上诞生的,它原生提供常量定义的关键字constGo语言中的const整合了C语言中宏定义常量const只读变量枚举常量三种形式,并消除了每种形式的不足,使得Go常量成为类型安全对编译器优化友好的语法元素。Go中所有与常量有关的声明都通过const来进行,例如:

// $GOROOT/src/os/file.go
const (
    O_RDONLY int = syscall.O_RDONLY
    O_WRONLY int = syscall.O_WRONLY
    O_RDWR   int = syscall.O_RDWR
    O_APPEND int = syscall.O_APPEND
    ...
)

上面这段标准库中的代码通过const声明了一组常量,如果非要进一步细分,可以将这组常量视为枚举的整型常量

然而你可能没想到,上面对常量的声明方式仅仅是Go标准库中的少数个例,绝大多数情况下,Go常量在声明时并不显式指定类型,也就是说使用的是无类型常量(untyped constant)。比如:

// $GOROOT/src/io/io.go
const (
    SeekStart   = 0
    SeekCurrent = 1
    SeekEnd     = 2
)

无类型常量是Go语言在语法设计方面的一个“微创新”,也是“追求简单”设计哲学的又一体现,它可以让你的Go代码更加简洁。接下来我们就来看看无类型常量是如何简化Go代码编写的。



9.2 有类型常量带来的烦恼

Go是对类型安全要求十分严格的编程语言。Go要求,两个类型即便拥有相同的底层类型(underlying type),也仍然是不同的数据类型,不可以被相互比较或混在一个表达式中进行运算:

type myInt int

func main() {
    var a int = 5
    var b myInt = 6
    fmt.Println(a + b) // 编译器会给出错误提示:invalid operation: a + b (mismatched  types int and myInt)
}

我们看到,Go在处理不同类型的变量间的运算时不支持隐式的类型转换。Go的设计者认为,隐式转换带来的便利性不足以抵消其带来的诸多问题。要解决上面的编译错误,必须进行显式类型转换

type myInt int

func main() {
    var a int = 5
    var b myInt = 6
    fmt.Println(a + int(b)) // 输出:11
}

而将有类型常量与变量混合在一起进行运算求值时也要遵循这一要求,即如果有类型常量与变量的类型不同,那么混合运算的求值操作会报错:

type myInt int
const n myInt = 13
const m int = n + 5        // 编译器错误提示:cannot use n + 5 (type myInt) as type  int in const initializer

func main() {
    var a int = 5
    fmt.Println(a + n)     // 编译器错误提示:invalid operation: a + n (mismatched  types int and myInt)
}

唯有进行显式类型转换才能让上面的代码正常工作:

type myInt int
const n myInt = 13
const m int = int(n) + 5

func main() {
    var a int = 5
    fmt.Println(a + int(n)) // 输出:18
}

有类型常量给代码简化带来了麻烦,但这也是Go语言对类型安全严格要求的结果。



9.3 无类型常量消除烦恼,简化代码

现在有下面这些字面值:

5
3.1415926
"Hello, Gopher"
'a'
false

我们从中挑选三个字面值以魔数的形式直接参与变量赋值运算:

type myInt int
type myFloat float32
type myString string

func main() {
    var j myInt = 5
    var f myFloat = 3.1415926
    var str myString = "Hello, Gopher"

    fmt.Println(j)    // 输出:5
    fmt.Println(f)    // 输出:3.1415926
    fmt.Println(str)  // 输出:Hello, Gopher
}

可以看到这三个字面值无须显式类型转换就可以直接赋值给对应的三个自定义类型的变量,这等价于下面的代码:

var j myInt = myInt(5)
var f myFloat = myFloat(3.1415926)
var str myString = myString("Hello, Gopher")

但显然之前的无须显式类型转换的代码更为简洁。

Go的无类型常量恰恰就拥有像字面值这样的特性,该特性使得无类型常量在参与变量赋值和计算过程时无须显式类型转换,从而达到简化代码的目的:

const (
    a  = 5
    pi = 3.1415926
    s  = "Hello, Gopher"
    c  = 'a'
    b  = false
)

type myInt int
type myFloat float32
type myString string

func main() {
    var j myInt = a
    var f myFloat = pi
    var str myString = s
    var e float64 = a + pi

    fmt.Println(j)    // 输出:5
    fmt.Println(f)                // 输出:3.1415926
    fmt.Println(str)              // 输出:Hello, Gopher
    fmt.Printf("%T, %v\n", e, e)  // float64, 8.1415926
}

无类型常量使得Go在处理表达式混合数据类型运算时具有较大的灵活性,代码编写也有所简化,我们无须再在求值表达式中做任何显式类型转换了。

除此之外,无类型常量也拥有自己的默认类型:无类型的布尔型常量、整数常量、字符常量、浮点数常量、复数常量、字符串常量对应的默认类型分别为bool、int、int32(rune)、float64、complex128和string。当常量被赋值给无类型变量、接口变量时,常量的默认类型对于确定无类型变量的类型接口对应的动态类型是至关重要的。示例如下。

const (
    a = 5
    s = "Hello, Gopher"
)

func main() {
    n := a
    var i interface{} = a

    fmt.Printf("%T\n", n)         // 输出:int
    fmt.Printf("%T\n", i)         // 输出:int
    i = s
    fmt.Printf("%T\n", i)         // 输出:string
}


小结

所有常量表达式的求值计算都可以在编译期而不是在运行期完成,这样既可以减少运行时的工作,也能方便编译器进行编译优化。当操作数是常量时,在编译时也能发现一些运行时的错误,例如整数除零、字符串索引越界等。

无类型常量是Go语言推荐的实践,它拥有和字面值一样的灵活特性,可以直接用于更多的表达式而不需要进行显式类型转换,从而简化了代码编写



第10条 使用iota实现枚举常量

C家族的主流编程语言(如C++、Java等)都提供定义枚举常量的语法。比如在C语言中,枚举是一个具名的整型常数的集合。下面是使用枚举定义的Weekday类型:

// C语法
enum Weekday {
    SUNDAY,
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY
};

int main() {
    enum Weekday d = SATURDAY;
    printf("%d\n", d); // 6
}

C语言针对枚举类型提供了很多语法上的便利,比如:如果没有显式给枚举常量赋初始值,那么枚举类型的第一个常量的值为0,后续常量的值依次加1。与使用define宏定义的常量相比,C编译器可以对专用的枚举类型进行严格的类型检查,使得程序更为安全。

枚举的存在代表了一类现实需求:

  • 有限数量标识符构成的集合,且多数情况下并不关心集合中标识符实际对应的值;
  • 注重类型安全。

与其他C家族主流语言(如C++、Java)不同,Go语言没有提供定义枚举常量的语法。我们通常使用常量语法定义枚举常量,比如要在Go中定义上面的Weekday类型,可以这样写:

const (
    Sunday    = 0
    Monday    = 1
    Tuesday   = 2
    Wednesday = 3
    Thursday  = 4
    Friday    = 5
    Saturday  = 6
)

如果仅仅能支持到这种程度,那么Go就算不上是“站在巨人的肩膀上”了。Go的const语法提供了“隐式重复前一个非空表达式”的机制,来看下面的代码:

const (
    Apple, Banana = 11, 22
    Strawberry, Grape
    Pear, Watermelon
)

常量定义的后两行没有显式给予初始赋值,Go编译器将为其隐式使用第一行的表达式,这样上述定义等价于:

const (
    Apple, Banana = 11, 22
    Strawberry, Grape  = 11, 22
    Pear, Watermelon  = 11, 22
)

不过这显然仍无法满足枚举的要求,Go在这个机制的基础上又提供了神器iota。有了iota,我们就可以定义满足各种场景的枚举常量了。

iota是Go语言的一个预定义标识符,它表示的是const声明块(包括单行声明)中每个常量所处位置在块中的偏移值从零开始)。同时,每一行中的iota自身也是一个无类型常量,可以像无类型常量那样自动参与不同类型的求值过程,而无须对其进行显式类型转换操作。


下面是Go标准库中sync/mutex.go中的一段枚举常量的定义:

// $GOROOT/src/sync/mutex.go (go 1.12.7)
const (
    mutexLocked = 1 << iota
    mutexWoken
    mutexStarving
    mutexWaiterShift = iota
    starvationThresholdNs = 1e6
)

这是一个很典型的诠释iota含义的例子,我们逐行来看。

  • mutexLocked = 1 << iota:这里是const声明块的第一行iota的值是该行在const块中的偏移量,因此iota的值为0,我们得到mutexLocked这个常量的值为1 << 0,即1。
  • mutexWoken:这里是const声明块的第二行,由于没有显式的常量初始化表达式,根据const声明块的“隐式重复前一个非空表达式”机制,该行等价于mutexWoken = 1 << iota。由于该行是const块中的第二行,因此偏移量iota的值为1,我们得到mutexWoken这个常量的值为1<< 1,即2。
  • mutexStarving:该常量同mutexWoken,该行等价于mutexStarving = 1 << iota,由于在该行的iota的值为2,因此我们得到mutexStarving这个常量的值为 1 << 2,即4。
  • mutexWaiterShift = iota:这一行的常量初始化表达式与前三行不同,由于该行为第四行,iota的偏移值为3,因此mutexWaiterShift的值就为3。

位于同一行的iota即便出现多次,其值也是一样的

const (
    Apple, Banana = iota, iota + 10 // 0, 10 (iota = 0)
    Strawberry, Grape               // 1, 11 (iota = 1)
    Pear, Watermelon                // 2, 12 (iota = 2)
)

如果要略过iota = 0,而从iota = 1开始正式定义枚举常量,可以效仿下面的代码:

// $GOROOT/src/syscall/net_js.go,go 1.12.7

const (
    _ = iota
    IPV6_V6ONLY                     // 1
    SOMAXCONN                       // 2
    SO_ERROR                        // 3
)

如果要定义非连续枚举值,也可以使用类似方式略过某一枚举值

const (
    _ = iota                        // 0
    Pin1
    Pin2
    Pin3
    _                               // 相当于_ = iota,略过了4这个枚举值
    Pin5                            // 5
)

iota的加入让Go在枚举常量定义上的表达力大增,主要体现在如下几方面。

(1)iota预定义标识符能够以更为灵活的形式为枚举常量赋初值
Go提供的iota预定义标识符可以参与常量初始化表达式的计算,这样我们能够以更为灵活的形式为枚举常量赋初值,而传统C语言的枚举仅能以已经定义了的常量参与到其他常量的初始值表达式中。比如:

// C代码
enum Season {
    spring,
    summer = spring + 2,
    fall = spring + 3,
    winter = fall + 1
};

在阅读上面这段C代码时,如果要对winter进行求值,我们还要向上查询fall的值和spring的值。

(2)Go的枚举常量不限于整型值,也可以定义浮点型的枚举常量
C语言无法定义浮点类型的枚举常量,但Go语言可以,这要归功于Go无类型常量

const (
    PI   = 3.1415926              // π
    PI_2 = 3.1415926 / (2 * iota) // π/2
    PI_4                          // π/4
)

(3)iota使得维护枚举常量列表更容易
我们使用传统的枚举常量声明方式声明一组颜色常量:

const (
    Black  = 1
    Red    = 2
    Yellow = 3
)

常量按照首字母顺序排序。假如我们要增加一个颜色Blue,根据字母序,这个新常量应该放在Red的前面,但这样一来,我们就需要手动将从Red开始往后的常量的值都加1,十分费力。

const (
    Blue   = 1
    Black  = 2
    Red    = 3
    Yellow = 4
)
我们使用iota重新定义这组颜色枚举常量:

const (
    _ = iota
    Blue
    Black
    Red
    Yellow
)

现在无论后期增加多少种颜色,我们只需将常量名插入对应位置即可,无须进行任何手工调整

(4)使用有类型枚举常量保证类型安全

枚举常量多数是无类型常量,如果要严格考虑类型安全,也可以定义有类型枚举常量。下面是Go标准库中一段定义有类型枚举常量的例子:

// $GOROOT/src/time/time.go

type Weekday int

const (
    Sunday Weekday = iota
    Monday
    Tuesday
    Wednesday
    Thursday
    Friday
    Saturday
)

这样,后续要使用Sunday、Saturday这些有类型枚举常量时,必须匹配Weekday类型的变量


最后,举一个“反例”:在一些枚举常量名称与其初始值有强烈对应关系的时候,枚举常量会直接使用显式数值作为常量的初始值。这样的情况极其少见,我在Go标准库中仅找到这一处:

// $GOROOT/bytes/buffer.go

const (
    opRead      readOp = -1
    opInvalid   readOp = 0
    opReadRune1 readOp = 1
    opReadRune2 readOp = 2
    opReadRune3 readOp = 3
    opReadRune4 readOp = 4
)


第11条 尽量定义零值可用的类型

11.1 Go类型的零值

当通过声明或调用new为变量分配存储空间,或者通过复合文字字面量或调用make创建新值,且不提供显式初始化时,Go会为变量或值提供默认值

Go语言中的每个原生类型都有其默认值,这个默认值就是这个类型的零值。下面是Go规范定义的内置原生类型的默认值(零值)

  • 所有整型类型:0
  • 浮点类型:0.0
  • 布尔类型:false
  • 字符串类型:""
  • 指针、interface、切片(slice)、channel、map、function:nil

另外,Go的零值初始是递归的,即数组、结构体等类型的零值初始化就是对其组成元素逐一进行零值初始化。



11.2 零值可用

我们知道了Go类型的零值,接下来了解可用。Go从诞生以来就一直秉承着尽量保持“零值可用”的理念,来看两个例子。

第一个例子是关于切片的:

var zeroSlice []int
zeroSlice = append(zeroSlice, 1)
zeroSlice = append(zeroSlice, 2)
zeroSlice = append(zeroSlice, 3)
fmt.Println(zeroSlice) // 输出:[1 2 3]

我们声明了一个[]int类型的切片zeroSlice,但并没有对其进行显式初始化,这样zeroSlice这个变量就被Go编译器置为零值nil。按传统的思维,对于值为nil的变量,我们要先为其赋上合理的值后才能使用。但由于Go中的切片类型具备零值可用的特性,我们可以直接对其进行append操作,而不会出现引用nil的错误。


第二个例子是通过nil指针调用方法:

// chapter3/sources/call_method_through_nil_pointer.go

func main() {
    var p *net.TCPAddr
    fmt.Println(p) //输出:<nil>
}

我们声明了一个net.TCPAddr的指针变量,但并未对其显式初始化,指针变量p会被Go编译器赋值为nil。在标准输出上输出该变量,fmt.Println会调用p.String()。

我们来看看TCPAddr这个类型的String方法实现:

// $GOROOT/src/net/tcpsock.go
func (a *TCPAddr) String() string {
    if a == nil {
        return "<nil>"
    }
    ip := ipEmptyString(a.IP)
    if a.Zone != "" {
        return JoinHostPort(ip+"%"+a.Zone, itoa(a.Port))
    }
    return JoinHostPort(ip, itoa(a.Port))
}

我们看到Go标准库在定义TCPAddr类型及其方法时充分考虑了“零值可用”的理念,使得通过值为nil的TCPAddr指针变量依然可以调用String方法

在Go标准库和运行时代码中还有很多践行“零值可用”理念的好例子,最典型的莫过于sync.Mutex和bytes.Buffer了。

我们先来看看sync.Mutex。在C语言中,要使用线程互斥锁,我们需要这么做:

pthread_mutex_t mutex; // 不能直接使用

// 必须先对mutex进行初始化
pthread_mutex_init(&mutex, NULL);

// 然后才能执行lock或unlock
pthread_mutex_lock(&mutex);
pthread_mutex_unlock(&mutex);

但是在Go语言中,我们只需这么做:

var mu sync.Mutex
mu.Lock()
mu.Unlock()

Go标准库的设计者很贴心地将sync.Mutex结构体的零值设计为可用状态,让Mutex的调用者可以省略对Mutex的初始化而直接使用Mutex。

Go标准库中的bytes.Buffer亦是如此:

// chapter3/sources/bytes_buffer_write.go
func main() {
    var b bytes.Buffer
    b.Write([]byte("Effective Go"))
    fmt.Println(b.String()) // 输出:Effective Go
}

可以看到,我们无须对bytes.Buffer类型的变量b进行任何显式初始化,即可直接通过b调用Buffer类型的方法进行写入操作。这是因为bytes.Buffer结构体用于存储数据的字段buf支持零值可用策略的切片类型:

// $GOROOT/src/bytes/buffer.go
type Buffer struct {
    buf      []byte
    off      int
    lastRead readOp
}


小结

Go语言零值可用的理念给内置类型、标准库的使用者带来很多便利。不过Go并非所有类型都是零值可用的,并且零值可用也有一定的限制,比如:

  • 在append场景下,零值可用的切片类型不能通过下标形式操作数据:
    var s []int
    s[0] = 12         // 报错!
    s = append(s, 12) // 正确
    
  • 另外,像map这样的原生类型也没有提供对零值可用的支持:
    var m map[string]int
    m["go"] = 1 // 报错!
    
    m1 := make(map[string]int)
    m1["go"] = 1 // 正确
    
  • 另外零值可用的类型要注意尽量避免值复制
    var mu sync.Mutex
    mu1 := mu // 错误: 避免值复制
    foo(mu) // 错误: 避免值复制
    
  • 我们可以通过指针方式传递类似Mutex这样的类型:
    var mu sync.Mutex
    foo(&mu) // 正确
    

保持与Go一致的理念,给自定义的类型一个合理的零值,并尽量保持自定义类型的零值可用,这样我们的Go代码会更加符合Go语言的惯用法。



第12条 使用复合字面值作为初值构造器

在Golang中,复合字面值是指可以包含其他值的值。例如,结构体、数组、切片和Map等。

在上一条中,我们了解到零值可用对于编写出符合Go惯用法的代码是大有裨益的。但有些时候,零值并非最好的选择,我们有必要为变量赋予适当的初值以保证其后续以正确的状态参与业务流程计算,尤其是Go语言中的一些复合类型的变量

Go语言中的复合类型包括结构体、数组、切片和map。对于复合类型变量,最常见的值构造方式就是对其内部元素进行逐个赋值,比如:

var s myStruct
s.name = "tony"
s.age = 23

var a [5]int
a[0] = 13
a[1] = 14
...
a[4] = 17

sl := make([]int, 5, 5)
sl[0] = 23
sl[1] = 24
...
sl[4] = 27

m := make(map[int]string)
m[1] = "hello"
m[2] = "gopher"
m[3] = "!"

但这样的值构造方式让代码显得有些烦琐,尤其是在构造组成较为复杂的复合类型变量的初值时。Go提供的复合字面值(composite literal)语法可以作为复合类型变量的初值构造器。上述代码可以使用复合字面值改写成下面这样:

s := myStruct{"tony", 23}
a := [5]int{13, 14, 15, 16, 17}
sl := []int{23, 24, 25, 26, 27}
m := map[int]string {1:"hello", 2:"gopher", 3:"!"}

显然,最初的代码得到了大幅简化。

复合字面值由两部分组成:

  • 一部分是类型,比如上述示例代码中赋值操作符右侧的myStruct、[5]int、[]int和map[int]string;
  • 另一部分是由大括号{}包裹的字面值

这里的字面值形式仅仅是Go复合字面值作为值构造器的基本用法。

下面来分别看看复合字面值对于不同复合类型的高级用法。



12.1 结构体复合字面值

使用go vet工具对Go源码进行过静态代码分析的读者可能会知道,go vet工具中内置了一条检查规则:composites。此规则用于检查源码中使用复合字面值对结构体类型变量赋值的行为。如果源码中使用了从另一个包中导入的struct类型,但却未使用field:value形式的初值构造器,则该规则认为这样的复合字面值是脆弱的。因为一旦该结构体类型增加了一个新的字段,即使是未导出的,这种值构造方式也将导致编译失败,也就是说,应该将

err = &net.DNSConfigError{err}
替换为
err = &net.DNSConfigError{Err: err}

显然,Go推荐使用field:value复合字面值形式对struct类型变量进行值构造,这种值构造方式可以降低结构体类型使用者与结构体类型设计者之间的耦合,这也是Go语言的惯用法。在Go标准库中,通过field:value格式的复合字面值进行结构体类型变量初值构造的例子比比皆是,比如:

// $GOROOT/src/net/http/transport.go
var DefaultTransport RoundTripper = &Transport{
    Proxy: ProxyFromEnvironment,
    DialContext: (&net.Dialer{
        Timeout:   30 * time.Second,
        KeepAlive: 30 * time.Second,
        DualStack: true,
    }).DialContext,
    MaxIdleConns:          100,
    IdleConnTimeout:       90 * time.Second,
    TLSHandshakeTimeout:   10 * time.Second,
    ExpectContinueTimeout: 1 * time.Second,
}

// $GOROOT/src/io/pipe.go
type pipe struct {
    wrMu sync.Mutex
    wrCh chan []byte
    rdCh chan int

    once sync.Once
    done chan struct{}
    rerr onceError
    werr onceError
}

func Pipe() (*PipeReader, *PipeWriter) {
    p := &pipe{
        wrCh: make(chan []byte),
        rdCh: make(chan int),
        done: make(chan struct{}),
    }
    return &PipeReader{p}, &PipeWriter{p}
}

这种field:value形式的复合字面值初值构造器颇为强大。与之前普通复合字面值形式不同,field:value形式字面值中的字段可以以任意次序出现,未显式出现在字面值的结构体中的字段将采用其对应类型的零值。以上面的pipe类型为例,Pipe函数在使用复合字面值对pipe类型变量进行初值构造时仅对wrCh、rdCh和done进行了field:value形式的显式赋值,这样pipe结构体中的其他变量的值将为其类型的初值,如wrMu。

从上面例子中还可以看到,通过在复合字面值构造器的类型前面增加&,可以得到对应类型的指针类型变量,如上面例子中的变量p的类型即为Pipe类型指针。



12.2 数组/切片复合字面值

与结构体类型不同,数组/切片使用下标(index)作为field:value形式中的field,从而实现数组/切片初始元素值的高级构造形式

// [10]float{-1, 0, 0, 0, -0.1, -0.1, 0, 0.1, 0, -1}
fnumbers := [...]float{-1, 4: -0.1, -0.1, 7:0.1, 9: -1}

// $GOROOT/src/sort/search_test.go
var data = []int{0: -10, 1: -5, 2: 0, 3: 1, 4: 2, 5: 3, 6: 5, 7: 7,
       8: 11, 9: 100, 10: 100, 11: 100, 12: 1000, 13: 10000}
var sdata = []string{0: "f", 1: "foo", 2: "foobar", 3: "x"}

不同于结构体复合字面值较多采用field:value形式作为值构造器,数组/切片由于其固有的特性,采用index:value为其构造初值,主要应用在少数场合,比如为非连续(稀疏)元素构造初值(如上面示例中的numbers、fnumbers)、让编译器根据最大元素下标值推导数组的大小(如上面示例中的fnumbers)等。

另外在编写单元测试时,为了更显著地体现元素对应的下标值,可能会使用index:value形式来为数组/切片进行值构造,如上面标准库单元测试源码中的data和sdata。



12.3 map复合字面值

和结构体、数组/切片相比,map类型变量使用复合字面值作为初值构造器就显得自然许多,因为map类型具有原生的key:value构造形式

// $GOROOT/src/time/format.go
var unitMap = map[string]int64{
    "ns": int64(Nanosecond),
    "us": int64(Microsecond),
    "µs": int64(Microsecond), // U+00B5 = 微符号
    "μs": int64(Microsecond), // U+03BC = 希腊字母μ
    "ms": int64(Millisecond),
    ...
}


// $GOROOT/src/net/http/server.go
var stateName = map[ConnState]string{
    StateNew:      "new",
    StateActive:   "active",
    StateIdle:     "idle",
    StateHijacked: "hijacked",
    StateClosed:   "closed",
}

对于数组/切片类型而言,当元素为复合类型时,可以省去元素复合字面量中的类型,比如:

type Point struct {
    x float64
    y float64
}

sl := []Point{
    {1.2345, 6.2789}, // Point{1.2345, 6.2789}
    {2.2345, 19.2789}, // Point{2.2345, 19.2789}
}

但是对于map类型(这一语法糖在Go 1.5版本中才得以引入)而言,当key或value的类型为复合类型时,我们可以省去key或value中的复合字面量中的类型

// Go 1.5之前版本
m := map[Point]string{
    Point{29.935523, 52.891566}:   "Persepolis",
    Point{-25.352594, 131.034361}: "Uluru",
    Point{37.422455, -122.084306}: "Googleplex",
}


// Go 1.5及之后版本
m := map[Point]string{
    {29.935523, 52.891566}:   "Persepolis",
    {-25.352594, 131.034361}: "Uluru",
    {37.422455, -122.084306}: "Googleplex",
}

m1 := map[string]Point{
    "Persepolis": {29.935523, 52.891566},
    "Uluru":      {-25.352594, 131.034361},
    "Googleplex": {37.422455, -122.084306},
}

对于key或value为指针类型的情况,也可以省略“&T”

m2 := map[string]*Point{
    "Persepolis": {29.935523, 52.891566},   // 相当于value为&Point{29.935523, 52.891566}
    "Uluru":      {-25.352594, 131.034361}, // 相当于value为&Point{-25.352594, 131.034361}
    "Googleplex": {37.422455, -122.084306}, // 相当于value为&Point{37.422455, -122.084306}
}

fmt.Println(m2) // map[Googleplex:0xc0000ae050 Persepolis:0xc0000ae030 Uluru:0xc0000ae040]


总结

对于零值不适用的场景,我们要为变量赋予一定的初值。对于复合类型,我们应该首选Go提供的复合字面值(composite literal)作为初值构造器。对于不同复合类型,我们要记住下面几点:

  • 使用field:value形式的复合字面值为结构体类型的变量赋初值;
  • 在为稀疏元素赋值或让编译器推导数组大小的时候,多使用index:value的形式数组/切片类型变量赋初值;
  • 使用key:value形式的复合字面值为map类型的变量赋初值。(Go 1.5版本后,复合字面值中的key和value类型均可以省略不写。)


第13条 了解切片实现原理并高效使用

每当你花费大量时间使用某种特定工具时,深入了解它并了解如何高效地使用它是很值得的。——佚名

slice,中文多译为切片,是Go语言在数组之上提供的一个重要的抽象数据类型。在Go语言中,对于绝大多数需要使用数组的场合,切片实现了完美替代。并且和数组相比,切片提供了更灵活、更高效的数据序列访问接口。


13.1 切片是什么

Go数组是值语义的,这意味着一个数组变量表示的是整个数组,这点与C语言完全不同。

  • 在C语言中,数组变量可视为指向数组第一个元素的指针。
  • 而在Go语言中传递数组是纯粹的值拷贝,对于元素类型长度较大或元素个数较多的数组,如果直接以数组类型参数传递到函数中会有不小的性能损耗。

这时很多人会使用数组指针类型来定义函数参数,然后将数组地址传进函数,这样做的确可以避免性能损耗,但这是C语言的惯用法,在Go语言中,更地道的方式是使用切片

切片之于数组就像是文件描述符之于文件。在Go语言中,数组更多是“退居幕后”,承担的是底层存储空间的角色;而切片则走向“前台”,为底层的存储(数组)打开了一个访问的“窗口”

下面是切片在Go运行时(runtime)层面的内部表示:

//$GOROOT/src/runtime/slice.go
type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

我们看到每个切片包含以下三个字段。

  • array:指向下层数组某元素的指针,该元素也是切片的起始元素。
  • len:切片的长度,即切片中当前元素的个数。
  • cap:切片的最大容量,cap >= len。

在运行时中,每个切片变量都是一个runtime.slice结构体类型的实例

当切片作为函数参数传递给函数时,实际传递的是切片的内部表示,也就是上面的runtime.slice结构体实例,因此无论切片描述的底层数组有多大,切片作为参数传递带来的性能损耗都是很小且恒定的,甚至小到可以忽略不计,这就是函数在参数中多使用切片而不用数组指针的原因之一。而另一个原因就是切片可以提供比指针更为强大的功能,比如下标访问、边界溢出校验、动态扩容等。而C程序员最喜爱的指针本身在Go语言中的功能受到了限制,比如不支持指针算术运算等。



13.2 切片的高级特性:动态扩容

append会根据切片的需要,在当前底层数组容量无法满足的情况下,动态分配新的数组,新数组长度会按一定算法扩展(参见$GOROOT/src/runtime/slice.go中的growslice函数)。新数组建立后,append会把旧数组中的数据复制到新数组中,之后新数组便成为切片的底层数组,旧数组后续会被垃圾回收掉。

这样的append操作有时会给Gopher带来一些困惑,比如通过语法u[low: high]形式进行数组切片化而创建的切片,一旦切片cap触碰到数组的上界,再对切片进行append操作,切片就会和原数组解除绑定.



13.3 尽量使用cap参数创建切片

append操作是一件利器,它让切片类型部分满足了“零值可用”的理念。但从append的原理中我们也能看到重新分配底层数组并复制元素的操作代价还是挺大的,尤其是当元素较多的情况下。

那么如何减少或避免为过多内存分配和复制付出的代价呢?一种有效的方法是根据切片的使用场景对切片的容量规模进行预估,并在创建新切片时将预估出的切片容量数据以cap参数的形式传递给内置函数make:

s := make([]T, len, cap)


小结

切片是Go语言提供的重要数据类型,也是Gopher日常编码中最常使用的类型之一。切片是数组的描述符,在大多数场合替代了数组,并减少了数组指针作为函数参数的使用。

append在切片上的运用让切片类型部分支持了“零值可用”的理念,并且append对切片的动态扩容将Gopher从手工管理底层存储的工作中解放了出来。

在可以预估出元素容量的前提下,使用cap参数创建切片可以提升append的平均操作性能,减少或消除因动态扩容带来的性能损耗。



第14条 了解map实现原理并高效使用

对于C程序员出身的Gopher来说,map类型是和切片、interface一样能让他们感受到Go语言先进性的重要语法元素。map类型也是Go语言中最常用的数据类型之一。


14.1 什么是map

map对value的类型没有限制,但是对key的类型有严格要求:key的类型应该严格定义了作为“==”和“!=”两个操作符的操作数时的行为,因此函数、map、切片不能作为map的key类型。

map类型不支持“零值可用”,未显式赋初值的map类型变量的零值为nil。对处于零值状态的map变量进行操作将会导致运行时panic:

var m map[string]int // m = nil
m["key"] = 1         // panic: assignment to entry in nil map

我们必须对map类型变量进行显式初始化后才能使用它。和切片一样,创建map类型变量有两种方式:一种是使用复合字面值,另一种是使用make这个预声明的内置函数。

(1)使用复合字面值创建map类型变量

// $GOROOT/src/net/status.go
var statusText = map[int]string{
    StatusOK:                   "OK",
    StatusCreated:              "Created",
    StatusAccepted:             "Accepted",
    ...
}

(2)使用make创建map类型变量

// $GOROOT/src/net/client.go
icookies = make(map[string][]*Cookie)

// $GOROOT/src/net/h2_bundle.go
http2commonLowerHeader = make(map[string]string, len(common))

和切片一样,map也是引用类型,将map类型变量作为函数参数传入不会有很大的性能损耗,并且在函数内部对map变量的修改在函数外部也是可见的



14.2 map的基本操作

接下来,我们简要地了解一下map类型的基本操作及注意事项。

  1. 插入数据

面对一个非nil的map类型变量,我们可以向其中插入符合map类型定义的任意键值对。Go运行时会负责map内部的内存管理,因此除非是系统内存耗尽,我们不用担心向map中插入数据的数量。

m := make(map[K]V)
m[k1] = v1
m[k2] = v2
m[k3] = v3

如果key已经存在于map中,则该插入操作会用新值覆盖旧值:

m := map[string]int {
    "key1" : 1,
    "key2" : 2,
}

m["key1"] = 11 // 11会覆盖掉旧值1
m["key3"] = 3  // map[key1:11 key2:2 key3:3]
  1. 获取数据个数

和切片一样,map也可以通过内置函数len获取当前已经存储的数据个数:

m := map[string]int {
    "key1" : 1,
    "key2" : 2,
}

fmt.Println(len(m)) // 2
m["key3"] = 3
fmt.Println(len(m)) // 3
  1. 查找和数据读取

map类型更多用在查找和数据读取场合。所谓查找就是判断某个key是否存在于某个map中。我们可以使用“comma ok”惯用法来进行查找:

_, ok := m["key"]
if !ok {
    // "key"不在map中
}

这里我们并不关心某个key对应的value,而仅仅关心某个key是否在map中,因此我们使用空标识符(blank identifier)忽略了可能返回的数据值,而仅关心ok的值是否为true(表示在map中)。

如果要读取key对应的value的值,我们可能会写出下面这样的代码:

m := map[string]int
m["key1"] = 1
m["key2"] = 2

v := m["key1"]
fmt.Println(v) // 1

v = m["key3"]
fmt.Println(v) // 0

上面的代码在key存在于map中(如“key1”)的情况下是没有问题的。但是如果key不存在于map中(如“key3”),我们看到v仍然被赋予了一个“合法”值0,这个值是value类型int的零值。在这样的情况下,我们无法判定这个0是“key3”对应的值还是因“key3”不存在而返回的零值。为此我们还需要借助“comma ok”惯用法:

m := map[string]int

v, ok := m["key"]
if !ok {
    // "key"不在map中
}
fmt.Println(v)

我们需要通过ok的值来判定key是否存在于map中。只有当ok = true时,所获得的value值才是我们所需要的。

综上,Go语言的一个最佳实践是总是使用“comma ok”惯用法读取map中的值。

  1. 删除数据

我们借助内置函数delete从map中删除数据:

m := map[string]int {
    "key1" : 1,
    "key2" : 2,
}

fmt.Println(m) // map[key1:1 key2:2]
delete(m, "key2")
fmt.Println(m) // map[key1:1]

注意,即便要删除的数据在map中不存在,delete也不会导致panic。

  1. 遍历数据

我们可以像对待切片那样通过for range语句对map中的数据进行遍历:

// chapter3/sources/map_iterate.go
func main() {
    m := map[int]int{
        1: 11,
        2: 12,
        3: 13,
    }

    fmt.Printf("{ ")
    for k, v := range m {
        fmt.Printf("[%d, %d] ", k, v)
    }
    fmt.Printf("}\n")
}

$ go run map_iterate.go
{ [1, 11] [2, 12] [3, 13] }

上面的输出结果非常理想,给我们的表象是迭代器按照map中元素的插入次序逐一遍历。我们再来试试多遍历几次这个map:

// chapter3/sources/map_multiple_iterate.go

import "fmt"

func doIteration(m map[int]int) {
    fmt.Printf("{ ")
    for k, v := range m {
        fmt.Printf("[%d, %d] ", k, v)
    }
    fmt.Printf("}\n")
}

func main() {
    m := map[int]int{
        1: 11,
        2: 12,
        3: 13,
    }

    for i := 0; i < 3; i++ {
        doIteration(m)
    }
}
$go run map_multiple_iterate.go
{ [3, 13] [1, 11] [2, 12] }
{ [1, 11] [2, 12] [3, 13] }
{ [3, 13] [1, 11] [2, 12] }

我们看到对同一map做多次遍历,遍历的元素次序并不相同。这是因为Go运行时在初始化map迭代器时对起始位置做了随机处理。因此千万不要依赖遍历map所得到的元素次序。

如果你需要一个稳定的遍历次序,那么一个比较通用的做法是使用另一种数据结构来按需要的次序保存key,比如切片:

// chapter3/sources/map_stable_iterate.go

import "fmt"

func doIteration(sl []int, m map[int]int) {
    fmt.Printf("{ ")
    for _, k := range sl { // 按切片中的元素次序迭代
        v, ok := m[k]
        if !ok {
            continue
        }
        fmt.Printf("[%d, %d] ", k, v)
    }
    fmt.Printf("}\n")
}

func main() {
    var sl []int
    m := map[int]int{
        1: 11,
        2: 12,
        3: 13,
    }

    for k, _ := range m {
        sl = append(sl, k) // 将元素按初始次序保存在切片中
    }

    for i := 0; i < 3; i++ {
        doIteration(sl, m)
    }
}
$go run map_stable_iterate.go
{ [1, 11] [2, 12] [3, 13] }
{ [1, 11] [2, 12] [3, 13] }
{ [1, 11] [2, 12] [3, 13] }


posted @   guanyubo  阅读(8)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示