《The Go Programming Language》第1+2章

1.2. 命令行参数

1. os.Args:获取命令行参数

Go 通过 os.Args 提供命令行参数:

var Args []string

其中:

  • os.Args[0]:程序名
  • os.Args[1:]:用户传入的参数

例如:

fmt.Println(os.Args[1:])

os.Args 的类型是 []string,也就是字符串切片。

2. 切片基础

切片可以理解为一种带长度信息的动态数组视图。

s[i]       // 访问元素
s[m:n]     // 获取子切片,包含 m,不包含 n
len(s)     // 获取元素数量

切片表达式遵循左闭右开区间:

s[m:n] // 包含索引 m 到 n-1

边界可以省略:

s[:n] // s[0:n]
s[m:] // s[m:len(s)]
s[:]  // s[0:len(s)]

3. for 循环

Go 只有 for 一种循环语句。

类似 C/C++ 的形式

for initialization; condition; post {
    // loop body
}

例如:

for i := 1; i < len(os.Args); i++ {
    // ...
}

与 C/C++ 的主要区别:

  • 不需要括号包围条件。
  • 大括号是强制的。
  • ++-- 是语句,不是表达式。(表达式 expression 会产生值,表达式没有值),因此不能写 j = i++
  • ++i--i 非法,只能写成 i++i--

类似 while 的形式

for condition {
    // ...
}

无限循环

for {
    // ...
}

可以使用 breakreturn 退出。

4. range:遍历切片

range 遍历切片时,每次产生两个值:

for index, value := range slice {
    // ...
}

遍历 os.Args[1:]

for _, arg := range os.Args[1:] {
    // ...
}

这里的 _ 是空标识符,用于丢弃不需要的索引。

range 的典型返回形式为:

index, element

对于数组、切片和字符串,index 是索引;对于 map,则是键和值。

5. 变量声明与零值

var 声明

var s string

变量未显式初始化时,会获得对应类型的零值:

  • 数值类型:0
  • string""
  • boolfalse
  • 指针、切片、map、函数、接口:nil

短变量声明

s, sep := "", ""

:= 会根据右侧表达式推断类型,只能用于函数内部。

以下写法在本例中等价:

s := ""
var s string
var s = ""
var s string = ""

通常:

  • 大部分情况使用 1 + 2
  • 少部分初始值明确的情况使用 4
  • 3 使用较少

6. 字符串拼接与复合赋值

Go 中 string+ 运算符用于拼接:

s + sep + arg

复合赋值:

s += sep + arg

等价于:

s = s + sep + arg

字符串是不可变值。每次拼接都会生成新的字符串,因此大量循环拼接可能产生较高开销。

由于字符串不可变,它内部的字符内容就不能修改。
所以执行拼接时,并不是在原来的字符串后面追加字符,而是创建一个新的字符串对象,把两个字符串的内容都复制过去。
cpp 的 std::string 是可变的,s += "def"; 会改变 s 的内容。当然拼接可能触发新的内存分配,所以可变不代表 s += "def" 一定完全不产生新内存。字符串字面量(const char*) 和 const string 是不可变的.

7. 使用 strings.Join 拼接字符串

strings.Join 用指定分隔符连接字符串切片:

func Join(elems []string, sep string) string

例如:

fmt.Println(strings.Join(os.Args[1:], " "))

它将:

[]string{"Go", "语言", "入门"}

连接为:

Go 语言 入门

相比循环中使用 +=strings.Join 更适合处理已存在的字符串切片。

8. 三种 echo 实现

使用索引遍历

func main() {
    var s, sep string

    for i := 1; i < len(os.Args); i++ {
        s += sep + os.Args[i]
        sep = " "
    }

    fmt.Println(s)
}

使用 range

func main() {
    s, sep := "", ""

    for _, arg := range os.Args[1:] {
        s += sep + arg
        sep = " "
    }

    fmt.Println(s)
}

使用 strings.Join

func main() {
    fmt.Println(strings.Join(os.Args[1:], " "))
}

第三种实现最直接:命令行参数本身已经是 []string,只需要指定分隔符即可。

9. 调试输出

如果不要求输出为一行字符串,只是查看切片内容,可以直接使用:

fmt.Println(os.Args[1:])

fmt.Println 会使用默认格式输出切片,结果通常类似:

[hello world]

1.3. 查找重复的行

主要学习:

  • 标准输入:os.Stdin
  • 文件输入:os.Open
  • 行扫描:bufio.Scanner
  • 哈希表:map[string]int
  • 格式化输出:fmt.Printf
  • 错误处理:err != nil

dup1:从标准输入读取并统计重复行

counts := make(map[string]int)
input := bufio.NewScanner(os.Stdin)

for input.Scan() {
    counts[input.Text()]++
}

这里的 counts 是一个 map[string]int,相当于 C++ 里的:

unordered_map<string, int> counts;

Go 中创建 map 通常使用:

make(map[string]int)

含义是创建一个空的 map,键类型是 string,值类型是 int

map 的零值行为

counts[input.Text()]++

如果某一行还没有出现过,counts[line] 会得到 int 的零值 0,所以可以直接 ++

等价于:

line := input.Text()
counts[line] = counts[line] + 1

这一点比 C++ 的 unordered_map<string, int> 也很像:访问不存在的 key 时会创建默认值。


bufio.Scanner

Scanner 用来把输入切成一段一段,默认按“行”扫描。

常用形式:

input := bufio.NewScanner(os.Stdin)

函数定义可以理解为:

func NewScanner(r io.Reader) *Scanner

常用方法:

func (s *Scanner) Scan() bool
func (s *Scanner) Text() string
func (s *Scanner) Err() error

典型用法:

for input.Scan() {
    line := input.Text()
}

其中:

  • Scan() 成功读到下一行时返回 true
  • 到达 EOF 或出错时返回 false
  • Text() 返回当前扫描到的文本,不包括行尾换行符
  • 严格代码里应在循环后检查 input.Err()

示例暂时忽略了 input.Err(),是为了简化代码。


if 语句

Go 的 iffor 一样,条件不加括号:

if n > 1 {
    fmt.Printf("%d\t%s\n", n, line)
}

Go 要求大括号,即使只有一条语句也不能省略。


遍历 map:range

for line, n := range counts {
    if n > 1 {
        fmt.Printf("%d\t%s\n", n, line)
    }
}

map 使用 range,每次得到一对:

key, value

需要注意:Go 的 map 遍历顺序不确定,不能依赖输出顺序。

如果需要稳定顺序,要额外把 key 放进切片排序,再按顺序输出。


fmt.Printf 与格式化输出

fmt.Printf("%d\t%s\n", n, line)

函数定义大致是:

func Printf(format string, a ...any) (n int, err error)

常用格式:

格式 含义
%d 十进制整数
%x 十六进制整数
%o 八进制整数
%b 二进制整数
%f 浮点数
%t 布尔值
%c 字符 / rune
%s 字符串
%q 带引号的字符串或字符
%v 默认格式
%T 值的类型
%% 字面量 %

Go 里常见约定:

  • Printf:按格式字符串输出,不自动换行
  • Println:自动用空格分隔参数,并在末尾换行
  • Fprintf:输出到指定目标,例如 os.Stderr

dup2:从标准输入或多个文件读取

dup2 支持两种输入来源:

files := os.Args[1:]

if len(files) == 0 {
    countLines(os.Stdin, counts)
} else {
    for _, arg := range files {
        f, err := os.Open(arg)
        ...
        countLines(f, counts)
        f.Close()
    }
}

逻辑是:

  • 没有命令行参数:从标准输入读
  • 有命令行参数:把每个参数当作文件名,逐个打开并读取

os.Open 和错误处理

f, err := os.Open(arg)
if err != nil {
    fmt.Fprintf(os.Stderr, "dup2: %v\n", err)
    continue
}

函数定义:

func Open(name string) (*File, error)

Go 常见的错误处理模式是:

value, err := someFunc()
if err != nil {
    // handle error
}

其中:

  • nil 类似 C/C++ 中的 NULL / nullptr
  • err == nil 表示没有错误
  • err != nil 表示发生错误

Go 不使用异常作为主要错误处理机制,而是显式返回 error


标准错误:os.Stderr

fmt.Fprintf(os.Stderr, "dup2: %v\n", err)

Fprintf 的函数定义大致是:

func Fprintf(w io.Writer, format string, a ...any) (n int, err error)

这里把错误信息输出到标准错误,而不是标准输出。


自定义函数:countLines

func countLines(f *os.File, counts map[string]int) {
    input := bufio.NewScanner(f)
    for input.Scan() {
        counts[input.Text()]++
    }
}

函数签名:

func countLines(f *os.File, counts map[string]int)

几点注意:

  • *os.File 是文件指针
  • map[string]int 传入函数后,函数内部修改会影响外部 map
  • Go 的函数可以先调用、后声明
  • 参数类型写在参数名后面

和 C++ 对比,map 的传递效果有点像传了一个引用句柄。函数收到的是 map 描述符的拷贝,但它们指向同一份底层哈希表。


map 是引用类型

这一点很重要:

func countLines(f *os.File, counts map[string]int)

虽然 Go 是值传递,但 map 本身是引用类型。传进去后:

counts[line]++

会修改调用者看到的同一个 map 数据。

类似地,Go 中常见的引用语义类型还有:

  • map
  • slice
  • channel
  • 函数值
  • 指针
  • 接口值内部也有动态值引用语义

dup3:一次性读取整个文件

dup3 不再使用流式扫描,而是把整个文件读入内存:

data, err := ioutil.ReadFile(filename)

函数定义:

func ReadFile(filename string) ([]byte, error)

然后转成字符串,再按换行符切分:

for _, line := range strings.Split(string(data), "\n") {
    counts[line]++
}

strings.Split 的函数定义:

func Split(s string, sep string) []string

例如:

strings.Split("a\nb\nc", "\n")

得到:

[]string{"a", "b", "c"}

注意这里返回的是 []string,也就是字符串切片。


[]bytestring 的转换

ReadFile 返回的是:

[]byte

也就是字节切片。strings.Split 需要的是 string,所以需要显式转换:

string(data)

完整形式:

strings.Split(string(data), "\n")

这和 C/C++ 里处理 char* / std::string / buffer 的问题有些类似,但 Go 把文本字符串和原始字节序列区分得更明确。


ioutil.ReadFile 的现代替代

书中使用:

ioutil.ReadFile(filename)

在 Go 1.16 以后,io/ioutil 已经不推荐继续使用。现代写法是:

os.ReadFile(filename)

函数定义:

func ReadFile(name string) ([]byte, error)

也就是说,现代版 dup3 可以写成:

data, err := os.ReadFile(filename)

书里的代码仍然适合理解概念,但新代码建议使用 os.ReadFile

2.1. 命名

命名规则

Go 中函数名、变量名、常量名、类型名、包名、语句标号等,都遵循同一套词法规则:

  • 必须以 Unicode 字母或 _ 开头
  • 后面可以跟字母、数字或 _
  • 大小写敏感
  • 不能使用关键字作为名字

例如:

heapSort
Heapsort

这是两个不同的标识符。


关键字

Go 有 25 个关键字,不能作为自定义名字使用:

break       default     func        interface   select
case        defer       go          map         struct
chan        else        goto        package     switch
const       fallthrough if          range       type
continue    for         import      return      var

这些关键字只在固定语法结构中使用,例如:

func main() {
    if true {
        return
    }
}

预定义名字

Go 还有一些预定义名字,包括内建常量、类型和函数。

内建常量

true false iota nil

内建类型

int int8 int16 int32 int64
uint uint8 uint16 uint32 uint64 uintptr
float32 float64 complex64 complex128
bool byte rune string error

内建函数

make len cap new append copy close delete
complex real imag
panic recover

这些名字不是关键字,因此可以被重新定义。

例如:

func f() {
    len := 10
    fmt.Println(len)
}

这在语法上合法,但通常不推荐,因为它会遮蔽内建函数 len,导致当前作用域内不能再调用:

len(s)

所以原则是:可以重定义,但除非有明确理由,否则不要这么做。


作用域

Go 的名字作用域大致分两类:

定义位置 可见范围
函数内部 只在函数内部有效
函数外部 当前包内所有文件可见

例如:

func f() {
    x := 1 // 只在 f 内部有效
}

var y = 2 // 当前包内可见

Go 的包不是单个文件,而是同一目录下具有相同 package 声明的一组文件。因此包级名字可以在同一个包的多个文件之间共享。


导出规则:首字母大写

Go 没有 C++ 那种 public / private / protected 访问控制关键字。

它用名字首字母大小写决定包外可见性:

命名 包外是否可见 示例
首字母大写 可导出,包外可访问 fmt.Printf
首字母小写 不导出,仅包内可见 parseRequestLine

例如:

func ExportedName() {}
func unexportedName() {}

外部包只能访问:

pkg.ExportedName()

不能访问:

pkg.unexportedName()

这一点是 Go 命名规则里最重要的设计之一:命名同时承担了访问控制的作用。


包名习惯

包名通常使用小写字母:

package fmt
package os
package strings
package net
package http

一般不使用下划线,也不使用驼峰。

例如更推荐:

package imageutil

而不是:

package image_util
package imageUtil

驼峰命名

Go 推荐使用驼峰命名,而不是下划线命名。

推荐:

heapSort
parseRequestLine
QuoteRuneToASCII

不推荐:

heap_sort
parse_request_line
quote_rune_to_ASCII

缩略词命名

对于常见缩略词,Go 习惯保持缩略词整体大小写一致。

例如 HTMLASCIIURLHTTPID

推荐:

htmlEscape
HTMLEscape
escapeHTML
serveHTTP
userID

不推荐:

escapeHtml
serveHttp
userId

也就是说,不把 HTML 写成 Html,不把 ID 写成 Id

这个习惯在标准库和主流 Go 项目里非常常见。

2.2. 声明

声明的作用

声明语句用于定义程序中的实体,以及这些实体的部分或全部属性。

Go 主要有四类声明:

声明 含义
var 变量声明
const 常量声明
type 类型声明
func 函数声明

本章主要关注 vartype,后面章节会继续讲 constfunc


Go 源文件的基本结构

一个 .go 文件通常按这个顺序组织:

package main

import "fmt"

const boilingF = 212.0

func main() {
    ...
}

也就是:

package 声明

import 声明

包级 var / const / type / func 声明

其中:

  • package 必须出现在源文件开头
  • import 用来导入其它包
  • varconsttypefunc 可以出现在包级作用域
  • 包级声明的先后顺序通常不重要

例如函数可以在调用之后再声明:

func main() {
    fmt.Println(fToC(212.0))
}

func fToC(f float64) float64 {
    return (f - 32) * 5 / 9
}

这点和 C/C++ 不同,Go 不需要提前写函数声明或头文件原型。


包级声明和局部声明

Go 中声明的位置决定作用域。

const boilingF = 212.0

func main() {
    var f = boilingF
    var c = (f - 32) * 5 / 9
}

这里:

const boilingF = 212.0

是包级声明,整个包内都可以访问。

而:

var f = boilingF
var c = (f - 32) * 5 / 9

是局部声明,只在 main 函数内部可见。

注意:Go 的“包”不是单个文件,而是同一目录下同属一个 package 的多个 .go 文件。因此包级名字可以跨文件访问。


var 声明

基本形式:

var name type = expression

例如:

var f float64 = boilingF

如果初始值可以推断类型,可以省略类型:

var f = boilingF

如果没有显式初始值,则使用类型零值:

var s string // ""
var n int    // 0
var ok bool  // false

函数内部也常用短变量声明:

f := boilingF

不过 := 只能用于函数内部,不能用于包级作用域。


const 声明

常量声明使用:

const name = expression

例如:

const boilingF = 212.0

也可以一次声明多个:

const freezingF, boilingF = 32.0, 212.0

常量在编译期确定,不可修改。

这个例子中:

const freezingF, boilingF = 32.0, 212.0

两个常量都是局部常量,只在 main 函数内可见。


func 声明

函数声明的基本形式:

func name(parameter-list) result-list {
    body
}

例如:

func fToC(f float64) float64 {
    return (f - 32) * 5 / 9
}

其中:

func fToC(f float64) float64

表示:

  • 函数名:fToC
  • 参数:f float64
  • 返回值类型:float64

如果没有返回值,返回值列表可以省略:

func main() {
    ...
}

也就是:

func main()

函数调用

调用函数时,把实参传给形参:

fToC(freezingF)
fToC(boilingF)

对应函数定义:

func fToC(f float64) float64

这里的 freezingFboilingF 会作为参数传入 f

Go 的参数传递是值传递。也就是说,函数收到的是实参值的一份拷贝。对于 float64 这种基础类型,这一点很直接。


return

有返回值的函数需要使用 return 返回结果:

func fToC(f float64) float64 {
    return (f - 32) * 5 / 9
}

如果函数没有返回值,可以不写 return,执行到函数末尾自动返回调用者:

func main() {
    fmt.Println("done")
}

2.3. 变量

var 声明用于创建一个变量,并指定它的类型和初始值。一般形式是:

var name type = expression

其中 type= expression 可以省略一个。

如果省略类型,Go 会根据右侧表达式推导类型:

var i = 100      // int
var s = "hello"  // string

如果省略初始值,变量会被初始化为该类型的零值:

var i int     // 0
var ok bool   // false
var s string  // ""

引用类类型的零值是 nil,例如:

var p *int          // nil
var names []string  // nil
var m map[string]int // nil

数组和结构体的零值则是“递归零值”:每个元素或字段都是对应类型的零值。

Go 里不存在未初始化变量。下面这段代码会稳定打印空字符串,而不是像 C/C++ 中未初始化变量那样产生不确定行为:

var s string
fmt.Println(s) // ""

Go 程序员通常会尽量让类型的零值有意义。例如一个结构体即使没有显式初始化,也最好处于一个可安全使用或可明确判断的状态。


多变量声明

一条 var 语句可以声明多个变量:

var i, j, k int

也可以同时声明不同类型的变量,让编译器根据表达式推导:

var b, f, s = true, 2.3, "four"

这里的类型分别是:

bool, float64, string

初始化表达式可以是字面量,也可以是函数调用。例如:

var f, err = os.Open(name)

os.Open 返回两个值:

func Open(name string) (*File, error)

所以这里 f*os.Fileerrerror

包级变量会在 main 执行前完成初始化;局部变量会在程序执行到声明语句时初始化。


2.3.1. 简短变量声明

函数内部常用简短变量声明:

name := expression

例如:

anim := gif.GIF{LoopCount: nframes}
freq := rand.Float64() * 3.0
t := 0.0

:= 会声明变量,并根据右侧表达式推导类型。它只能用于函数内部,不能用于包级作用域。

局部变量通常优先用 :=,因为短、直接:

i := 100

但如果需要显式指定类型,或者想使用零值初始化,就更适合用 var

var boiling float64 = 100 // 明确要 float64
var names []string        // nil slice
var err error             // nil error
var p Point               // Point 的零值

注意:

i := 100

推导出来是 int。如果你想要 float64,就需要写成:

var boiling float64 = 100

:== 的区别

:= 是声明,= 是赋值。

i, j := 0, 1 // 声明
i, j = j, i // 赋值,交换 i 和 j

Go 支持多重赋值,右侧表达式会先求值,再统一赋给左侧变量,所以交换变量不需要临时变量。

简短变量声明也常用于接收函数的多个返回值:

f, err := os.Open(name)
if err != nil {
    return err
}
f.Close()

:= 至少要声明一个新变量

简短变量声明左侧不一定全是新变量。只要同一作用域里至少有一个新变量,就可以使用 :=

in, err := os.Open(infile)
// ...
out, err := os.Create(outfile)

第一句声明了 inerr。第二句中,out 是新变量,err 已经存在,所以第二句等价于“声明 out,同时给 err 重新赋值”。

但下面这样不行:

f, err := os.Open(infile)
// ...
f, err := os.Create(outfile) // compile error: no new variables

因为 ferr 都已经在同一作用域声明过了。应该改成普通赋值:

f, err = os.Create(outfile)

还有一个容易踩的点::= 只会复用同一词法作用域里的变量。如果变量来自外层作用域,内层使用 := 会重新声明一个新变量,遮蔽外层变量。

var err error

if cond {
    err := doSomething() // 新的 err,不是外面的 err
    _ = err
}

这和 C/C++ 的局部变量遮蔽类似。

func main() {
    var err error

    err, name := doSomething()
    
    _ = err
    _ = name
}

当前作用域(main 函数体)里已经有 err,name 是新的变量,所以 := 会复用已有的 err(即对于 err 发生了赋值),同时声明新的 name


2.3.2. 指针

变量对应一块保存值的存储空间。指针保存的是另一个变量的地址。

x := 1
p := &x          // p 的类型是 *int,指向 x
fmt.Println(*p)  // "1"
*p = 2           // 等价于 x = 2
fmt.Println(x)   // "2"

这里:

  • &x 表示取 x 的地址
  • *int 表示“指向 int 的指针”
  • *p 表示访问 p 指向的变量
  • *p = 2 表示修改 p 指向的变量

Go 的指针和 C/C++ 指针很像,但少了一个危险能力:Go 不支持指针算术。不能写 p++p + 1

结构体字段和数组元素也是变量,因此也可以取地址:

p := &arr[i]
q := &s.Field

一个可以取地址的表达式通常称为“可寻址”。


指针的零值和比较

任何指针类型的零值都是 nil

var p *int
fmt.Println(p == nil) // true

指针可以比较。两个指针相等,表示它们指向同一个变量,或者都是 nil

var x, y int
fmt.Println(&x == &x, &x == &y, &x == nil)
// true false false

返回局部变量地址是安全的

在 C/C++ 中,返回局部变量地址通常是错误的。但 Go 中这是安全的:

var p = f()

func f() *int {
    v := 1
    return &v
}

虽然 v 是函数 f 内部的局部变量,但只要返回的指针仍然引用它,它就会继续存在。

每次调用 f 都会创建一个新的变量:

fmt.Println(f() == f()) // false

这背后依赖的是 Go 编译器的逃逸分析和垃圾回收,而不是程序员手动管理内存。


指针参数

指针常用于让函数修改调用者的变量:

func incr(p *int) int {
    *p++ // 等价于 (*p)++,增加的是 p 指向的变量
    return *p
}

v := 1
incr(&v)
fmt.Println(incr(&v)) // "3"

这里 p 本身没有变化,变化的是 *p,也就是 v

这类似 C/C++ 中传指针或引用。不过 Go 中没有引用参数语法,只能显式传指针。

指针会带来别名问题。例如:

p := &v

此后 v*p 是同一个变量的两个名字。程序复杂以后,变量可能在哪里被修改就不那么直观了。

类似的别名不只来自指针,slicemapchan 等引用类型也会共享底层数据。


flag 包中的指针

标准库 flag 包使用指针保存命令行选项的值。下面是带选项的 echo

package main

import (
    "flag"
    "fmt"
    "strings"
)

var n = flag.Bool("n", false, "omit trailing newline")
var sep = flag.String("s", " ", "separator")

func main() {
    flag.Parse()
    fmt.Print(strings.Join(flag.Args(), *sep))
    if !*n {
        fmt.Println()
    }
}

flag.Boolflag.String 的签名大致是:

func Bool(name string, value bool, usage string) *bool // value 是默认值
func String(name string, value string, usage string) *string

所以:

n   // *bool
sep // *string

使用时要解引用:

*n
*sep

程序开始时,这些变量保存默认值。调用:

flag.Parse()

之后,命令行参数会更新这些变量(这就是为什么返回指针)。普通的非选项参数通过下面函数取得:

func Args() []string

例如:

./echo4 -s / a bc def

输出:

a/bc/def

-s / 修改了分隔符,a bc def 是普通参数。


2.3.3. new 函数

除了 var,Go 还可以用内建函数 new 创建变量:

p := new(int)
fmt.Println(*p) // "0"
*p = 2
fmt.Println(*p) // "2"

new(T) 会创建一个类型为 T 的匿名变量,把它初始化为零值,然后返回它的地址,类型是 *T

可以把它理解成:

func new(T) *T

当然真实的 new 是内建函数,不是普通函数。

下面两个函数行为相同:

func newInt() *int {
    return new(int)
}
func newInt() *int {
    var dummy int
    return &dummy
}

所以 new 没有什么神秘之处,它只是“不起名字地创建一个零值变量,并返回指针”。

每次调用 new 通常都会得到不同变量的地址:

p := new(int)
q := new(int)
fmt.Println(p == q) // false

不过对于大小为 0 的类型,例如 struct{},不要依赖地址是否不同。

实际写 Go 时,new 用得不算多。对于结构体,更常见的是用复合字面量:

p := &Point{X: 1, Y: 2}

这通常比下面写法更清楚:

p := new(Point)
p.X = 1
p.Y = 2

另外,new 不是关键字,只是预定义名字,因此可以被遮蔽:

func delta(old, new int) int {
    return new - old
}

这段代码合法,但在函数内部就不能再使用内建的 new 了。一般不建议随便遮蔽预定义名字。


2.3.4. 变量的生命周期

变量的生命周期指的是变量在运行时有效存在的时间。

包级变量的生命周期是整个程序运行期间:

var global *int

局部变量的生命周期是动态的:从声明开始,到不再可达为止。函数参数和返回值变量也属于局部变量,每次函数调用都会创建新的实例。

例如:

for t := 0.0; t < cycles*2*math.Pi; t += res {
    x := math.Sin(t)
    y := math.Sin(t*freq + phase)
    img.SetColorIndex(size+int(x*size+0.5), size+int(y*size+0.5),
        blackIndex)
}

t 是循环变量,xy 是每次循环体执行时创建的局部变量。

Go 的格式习惯里,如果函数调用跨多行,最后一个参数后通常保留逗号:

img.SetColorIndex(
    size+int(x*size+0.5), size+int(y*size+0.5), // <-- 这里需要逗号
    blackIndex, // <-- 这里需要逗号
)

这可以配合 Go 的自动分号插入规则,也方便以后增删参数。


生命周期由可达性决定

Go 有垃圾回收。一个变量能否被回收,大致取决于它是否还能被访问到。

如果从包级变量、当前函数的局部变量出发,沿着指针或引用关系还能找到某个变量,它就是可达的;否则就是不可达的,可以被回收。

这意味着:生命周期不等于词法作用域。

局部变量可能在函数返回后仍然存在:

func f() *int {
    v := 1
    return &v
}

也可能在作用域结束后很快不可达,被回收。


逃逸分析

编译器会自动决定变量分配在栈上还是堆上。这个决定不是由 varnew 决定的,而是由变量是否逃逸决定的。

var global *int

func f() {
    var x int
    x = 1
    global = &x
}

这里 x 是局部变量,但它的地址被保存到了包级变量 global 中。函数返回后,x 仍然可以通过 global 访问,因此 x 逃逸了,通常需要分配到堆上。

再看另一个例子:

func g() {
    y := new(int)
    *y = 1
}

虽然这里用了 new,但 y 没有逃出函数。函数返回后,*y 不再可达,编译器可以选择把它分配在栈上。

所以:

var x int

不一定在栈上。

new(int)

也不一定在堆上。

真正关键的是变量是否逃逸。

写正确程序时通常不用关心这些;做性能优化时,逃逸分析才值得关注。


内存管理上的实际提醒

Go 有 GC,所以你不需要像 C/C++ 那样手动 freedelete。但这不代表完全不用考虑内存。

如果把短生命周期对象的指针放到长生命周期对象里,比如全局变量、长期存在的 map、缓存、goroutine 闭包中,就可能让本该释放的对象一直可达,从而影响内存占用。

也就是说,Go 帮你处理内存安全,但不替你保证内存使用一定高效。

2.4. 赋值

赋值语句用于更新变量的值。最普通的形式是:

x = 1

左边是要被更新的变量,右边是新值表达式。

Go 中很多表达式都可以出现在赋值语句左边,只要它表示一个可修改的位置:

x = 1                       // 命名变量
*p = true                   // 通过指针间接修改变量
person.name = "bob"         // 结构体字段
count[x] = count[x] * scale // 数组、slice 或 map 的元素

这里的重点是:赋值左边不一定只是变量名,也可以是“能定位到变量”的表达式。


复合赋值

很多二元运算符都有对应的复合赋值形式:

count[x] = count[x] * scale

可以写成:

count[x] *= scale

类似的还有:

x += 1
x -= 1
x *= 2
x /= 2
x %= 2

好处不只是少写一点。对于复杂的左值表达式,复合赋值可以避免重复计算左侧表达式。

例如:

a[f()] += 1

这里 f() 不会因为展开成 a[f()] = a[f()] + 1 而被调用两次。

类似于:

tmp := f()
a[tmp] = a[tmp] + 1

++--

数值变量支持自增和自减:

v := 1
v++ // v = v + 1
v-- // v = v - 1

注意:Go 中 ++-- 是语句,不是表达式。

所以不能写:

x = i++ // illegal
++i     // illegal

只能写:

i++
i--

这点和 C/C++ 很不一样。Go 故意去掉了 i++ 作为表达式的用法,减少求值顺序上的复杂性。


2.4.1. 元组赋值

Go 支持一次给多个变量赋值:

x, y = y, x

这叫元组赋值。

关键规则是:右边所有表达式会先求值,然后再统一赋给左边变量。

所以交换变量可以直接写:

x, y = y, x

所以下面的变量不存在 x 赋值给 y,y 的值丢失的问题。右边先取得所有表达式的值,因此不需要临时变量。

数组或 slice 元素也可以这样交换:

a[i], a[j] = a[j], a[i]

用元组赋值写 GCD

欧几里得算法可以写得很紧凑:

func gcd(x, y int) int {
    for y != 0 {
        x, y = y, x%y
    }
    return x
}

这句:

x, y = y, x%y

不是先改 x 再算 x%y。右边的 yx%y 会先用旧值算完,然后再一起赋给 xy

如果你有 C/C++ 基础,可以把它理解成编译器帮你做了安全的临时值保存。


用元组赋值写 Fibonacci

斐波那契数列也很适合用元组赋值:

func fib(n int) int {
    x, y := 0, 1
    for i := 0; i < n; i++ {
        x, y = y, x+y
    }
    return x
}

每轮循环里:

x, y = y, x+y

表示状态从:

(x, y)

更新成:

(y, x+y)

写起来比单独引入临时变量更自然。


不要滥用元组赋值

元组赋值也可以用于普通批量赋值:

i, j, k = 2, 3, 5

但如果右边表达式很复杂,或者左右变量数量很多,就不建议这么写。Go 的风格偏向清晰,不鼓励为了“短”牺牲可读性。

比较适合使用元组赋值的场景:

x, y = y, x        // 交换
x, y = y, x%y      // 状态转移
i, j := 0, len(s)-1 // 简单初始化

不适合把一堆无关变量强行塞在一行。


多返回值赋值

Go 函数可以返回多个值,因此赋值语句常用于接收多个返回值:

f, err = os.Open("foo.txt")

os.Open 的签名是:

func Open(name string) (*File, error)

因此左边必须有两个变量:

f, err

常见模式是:

f, err := os.Open("foo.txt")
if err != nil {
    return err
}

Go 没有异常作为主要错误处理方式,很多函数会把错误作为最后一个返回值返回。


ok 惯用法

除了 error,Go 里还有一种常见的多返回值模式:额外返回一个 bool,通常命名为 ok

map 查询

v, ok = m[key]

如果 key 存在,oktrue;否则 okfalsev 是值类型的零值。

只接收一个值也可以:

v = m[key]

但这样无法区分:

  • key 不存在
  • key 存在,但值刚好是零值

例如 map[string]int 中,查不到和查到值为 0 都会得到 0

golang 里读取 m[key],其中 key 不存在,不会创建元素,会返回 value 类型的零值
只有赋值操作会导致创建,如 m[key] = xxx;
m[key] ++ = tmp = m[key],tmp ++,m[key] = tmp; 相当于最后一步进行复制操作
这和 cpp 不同,cpp 的 map/unordered_map 只要调用了 operator[] 就会创建元素然后值初始化

类型断言

v, ok = x.(T)

如果 x 的动态类型是 Toktrue

只写一个返回值时:

v = x.(T)

如果断言失败,会发生 panic。

channel 接收

v, ok = <-ch

如果 channel 已关闭且没有剩余值,okfalse

只接收一个值时:

v = <-ch

如果 channel 关闭,会得到元素类型的零值;如果 channel 还没值,则可能阻塞。


空白标识符 _

如果多返回值中有不需要的,可以用 _ 丢弃:

_, err = io.Copy(dst, src)

io.Copy 的签名是:

func Copy(dst Writer, src Reader) (written int64, err error)

如果只关心错误,不关心复制了多少字节,就可以丢弃第一个返回值。

另一个例子:

_, ok = x.(T)

表示只检测类型断言是否成功,不需要具体值。


2.4.2. 可赋值性

赋值不只发生在显式的 = 语句中。很多地方都会发生隐式赋值。

例如函数调用:

f(x)

这里实参 x 会被赋给函数的形参。

返回语句:

return x

这里 x 会被赋给函数的返回值变量。

复合字面量:

medals := []string{"gold", "silver", "bronze"}

它隐式地给 slice 元素赋值,类似:

medals[0] = "gold"
medals[1] = "silver"
medals[2] = "bronze"

map 和 channel 中也有类似的隐式赋值行为。


类型必须可赋值

不管是显式赋值还是隐式赋值,右边的值必须能赋给左边的变量。也就是:右值对左值类型是“可赋值的”。

目前可以先记一个简单规则:类型通常必须完全匹配。

例如:

var x int
var y int32

x = y // illegal

即使 intint32 都是整数类型,也不能直接赋值。需要显式转换:

x = int(y)

nil 可以赋给指针或引用类类型:

var p *int = nil
var s []string = nil
var m map[string]int = nil
var ch chan int = nil
var f func() = nil

但不能赋给普通数值类型:

var x int = nil // illegal

常量的赋值规则更宽松

常量比较特殊。无类型常量可以根据上下文自动适配目标类型。

例如:

var x int = 1
var y float64 = 1

这里的 1 不是固定的 int,而是一个无类型整数常量,因此可以赋给 int,也可以赋给 float64

但变量不行:

var i int = 1
var f float64 = i // illegal

需要显式转换:

var f float64 = float64(i)

常量的细节后面讲到常量时会展开。


可比较性和可赋值性有关

==!= 也受类型规则限制。两个值能不能比较,不只是看它们“看起来像不像”,还要看类型规则是否允许。

例如:

var x int
var y int32

fmt.Println(x == y) // illegal

需要显式转换:

fmt.Println(x == int(y))

另外,后面会看到有些类型本身不能比较,例如 slice、map、function。它们不能直接用 == 比较,除了和 nil 比较。

var s []int
fmt.Println(s == nil) // ok

但不能:

fmt.Println(s == s) // illegal

小结

赋值这一节可以抓住这些点:

  • = 更新变量值
  • +=*= 等复合赋值可以避免重复左值表达式
  • ++-- 是语句,不是表达式
  • 元组赋值会先计算右边所有表达式,再统一赋值
  • 多返回值函数常配合 value, errvalue, ok
  • _ 可以丢弃不需要的返回值
  • 赋值不仅出现在 =,函数调用、return、复合字面量里也有隐式赋值
  • Go 的赋值通常要求类型匹配,不做随意隐式类型转换
  • 常量的赋值规则比变量更灵活

2.5. 类型

类型决定一个值能做什么:它如何存储、占多少空间、支持哪些运算符、能不能比较、有哪些方法。即使两个值底层都是 intfloat64,在程序语义上也可能完全不是一回事。

比如同样是 int,可能表示:

index
timestamp
fileDescriptor
month

同样是 float64,也可能表示:

metersPerSecond
celsius
fahrenheit

如果都直接用基础类型,编译器无法帮你区分这些概念。Go 的类型声明就是为了解决这个问题。


类型声明

类型声明的一般形式是:

type name underlying-type

例如:

type Celsius float64
type Fahrenheit float64

这里声明了两个新类型:

Celsius
Fahrenheit

它们的底层类型都是 float64,但它们本身是不同类型。

这点很重要:Celsius 不是 float64 的别名,Fahrenheit 也不是 float64 的别名;它们是两个新的命名类型。

如果类型名首字母大写,并且声明在包级作用域,那么这个类型可以被包外访问。


用类型区分概念

温度转换包可以这样写:

package tempconv

import "fmt"

type Celsius float64
type Fahrenheit float64

const (
    AbsoluteZeroC Celsius = -273.15
    FreezingC     Celsius = 0
    BoilingC      Celsius = 100
)

func CToF(c Celsius) Fahrenheit {
    return Fahrenheit(c*9/5 + 32)
}

func FToC(f Fahrenheit) Celsius {
    return Celsius((f - 32) * 5 / 9)
}

这里 CelsiusFahrenheit 都基于 float64,因此它们仍然可以做浮点运算。但因为它们是不同类型,不能直接混用:

var c Celsius = 100
var f Fahrenheit = 212

fmt.Println(c + f) // compile error: mismatched types

这正是我们想要的效果:防止把摄氏温度和华氏温度误混在一起计算。


类型转换不是单位换算

这点很容易混淆。

Celsius(f)

只是类型转换,不是华氏转摄氏。它只改变编译器眼中的类型,不改变数值本身

例如:

var f Fahrenheit = 212
fmt.Println(Celsius(f)) // 212°C 的语义,不是 100°C

真正的单位换算应该调用函数:

FToC(f) // 100°C

也就是说:

Celsius(f) // 改类型,不改数值
FToC(f)    // 做计算,改数值

这是本节最值得记住的区别。

Celsius 和 Fahrenheit 底层都是浮点类型,从存储的值来讲没什么区别
但是类型的定义以及相关方法/函数的定义,赋予了它们不用的语义


类型转换

对于类型 T,转换形式是:

T(x)

例如:

Celsius(100)
Fahrenheit(c*9/5 + 32)
float64(c)

如果两个类型有相同的底层类型,通常可以显式转换:

var c Celsius = 100
var x float64 = float64(c)

数值类型之间也可以转换:

var i int = int(3.14) // 3

注意,浮点数转整数会丢弃小数部分。

字符串和某些 slice 类型之间也可以转换,例如后面会看到:

[]byte("hello")
string([]byte{'h', 'i'})

这些转换在语法合法时不会在运行时“转换失败”。如果不允许,通常编译期就会报错。


底层类型决定支持的运算

CelsiusFahrenheit 的底层类型都是 float64,所以它们支持类似 float64 的算术运算。

fmt.Printf("%g\n", BoilingC-FreezingC) // 100

因为 BoilingCFreezingC 都是 Celsius,所以可以相减。

但下面这样不行:

boilingF := CToF(BoilingC)

fmt.Printf("%g\n", boilingF-CToF(FreezingC)) // 180
fmt.Printf("%g\n", boilingF-FreezingC)       // compile error

boilingFFahrenheitFreezingCCelsius。即使它们底层都是 float64,也不能直接相减。

这就是命名类型的价值:让编译器帮你检查语义错误

Celsius、Fahrenheit 和 float64 三者之间不能直接混合运算;但是同一种命名类型内部的值之间,可以使用底层类型支持的运算。


比较

同一命名类型的值可以比较:

var c Celsius
fmt.Println(c == 0) // true

这里 0无类型常量,可以适配成 Celsius

同理:

var f Fahrenheit
fmt.Println(f >= 0) // true

但不同命名类型不能直接比较:

fmt.Println(c == f) // compile error

需要显式转换:

fmt.Println(c == Celsius(f)) // true

这里为 true 只是因为 cf 都是零值。Celsius(f) 并没有做华氏到摄氏的换算,只是把 f 的数值按 Celsius 类型解释。


命名类型的两个价值

命名类型主要有两个作用。

第一,区分语义。

type Celsius float64
type Fahrenheit float64

这可以让编译器阻止混用不同单位。

第二,为类型定义方法。

Go 的方法不是写在 class 里面,而是通过接收者绑定到某个类型上。例如:

func (c Celsius) String() string {
    return fmt.Sprintf("%g°C", c)
}

这里 (c Celsius) 叫接收者,表示 StringCelsius 类型的方法。

可以这样调用:

c := FToC(212.0)
fmt.Println(c.String()) // "100°C"

String 方法和格式化输出

如果一个类型定义了:

String() string

那么它通常满足 fmt.Stringer 接口:

type Stringer interface {
    String() string
}

fmt 包在打印时会优先使用这个方法。

例如:

func (c Celsius) String() string {
    // fmt.Sprintf 返回字符串
    // %g 是浮点数的简洁格式化。
    // 它主要用于打印浮点数:去掉不必要的 0,并在需要时使用科学计数法。
    return fmt.Sprintf("%g°C", c)
}

那么:

c := FToC(212.0)

fmt.Println(c.String()) // "100°C"
// %v: fmt 根据值的类型选择一个合适的默认格式;如果类型实现了 fmt.Stringer,优先调用 String()。
fmt.Printf("%v\n", c)   // "100°C"
fmt.Printf("%s\n", c)   // "100°C"
fmt.Println(c)          // "100°C"

这些都会使用 String 方法。

但下面不会:

fmt.Printf("%g\n", c)   // "100"
fmt.Println(float64(c)) // "100"

原因是:

  • %g 要求按数值格式打印,不调用 String
  • float64(c) 已经把值转回了 float64,不再是 Celsius

类型别名补充

书中这里讲的是类型定义

type Celsius float64

它会创建一个新类型

Go 里还有类型别名:

type MyFloat = float64

这不是创建新类型,而是给已有类型起另一个名字。MyFloatfloat64 完全是同一个类型。

区别是:

type A int  // 新类型
type B = int // 类型别名

所以:

var a A
var i int

a = i // 不行,需要 A(i)

但:

var b B
var i int

b = i // 可以,因为 B 就是 int

类型别名主要用于代码迁移、重构、兼容旧 API,不是日常建模的主要工具。


小结

这一节最关键的是:Go 的 type 声明可以基于已有类型创建新的命名类型。

要点是:

  • type Name underlyingType 创建新类型
  • 新类型和底层类型不是同一个类型
  • 底层类型决定存储结构和支持的基本运算
  • 不同命名类型即使底层相同,也不能直接混用
  • T(x) 是类型转换,不是业务语义上的换算
  • Celsius(f) 不等于 FToC(f)
  • 命名类型可以绑定方法,例如 String() string
  • 定义了 String 方法后,fmt 打印时通常会优先使用它
  • type A = B 是类型别名,不是新类型

2.6. 包和文件

Go 的包类似其他语言里的库或模块,用来组织代码、隐藏实现、复用逻辑。一个包由一个或多个 .go 文件组成,这些文件通常放在同一个目录下,并且使用相同的 package 声明。

例如:

package tempconv

表示当前文件属于 tempconv 包。

同一个包里的多个文件共享包级名字。也就是说,一个文件里声明的类型、常量、变量、函数,另一个同包文件可以直接使用,不需要 import


包是独立命名空间

不同包可以有同名函数,互不冲突。

例如:

image.Decode
utf16.Decode

这两个 Decode 是不同包里的名字。外部使用时必须带上包名。

包也承担封装职责。Go 没有 public / private 关键字,而是用首字母大小写控制可见性:

CToF       // 导出,包外可访问
fToC       // 不导出,只能包内访问
BoilingC   // 导出
boilingC   // 不导出

注意:只有包级名字才谈得上导出。函数内部的局部变量不存在包外访问的问题。


一个包可以拆成多个文件

温度转换包可以拆成两个文件。

tempconv.go 放类型、常量和方法:

// Package tempconv performs Celsius and Fahrenheit conversions.
package tempconv

import "fmt"

type Celsius float64
type Fahrenheit float64

const (
    AbsoluteZeroC Celsius = -273.15
    FreezingC     Celsius = 0
    BoilingC      Celsius = 100
)

func (c Celsius) String() string    { return fmt.Sprintf("%g°C", c) }
func (f Fahrenheit) String() string { return fmt.Sprintf("%g°F", f) }

conv.go 放转换函数:

package tempconv

func CToF(c Celsius) Fahrenheit {
    return Fahrenheit(c*9/5 + 32)
}

func FToC(f Fahrenheit) Celsius {
    return Celsius((f - 32) * 5 / 9)
}

这两个文件都属于:

package tempconv

所以 conv.go 可以直接使用 CelsiusFahrenheit,不需要写:

tempconv.Celsius

因为它们在同一个包内。

fmt 只在 tempconv.go 中使用,所以只需要在那个文件里导入:

import "fmt"

Go 的 import 是文件级的,不是包级的。哪个文件用到哪个包,哪个文件就导入。


包外访问导出名字

如果别的包想使用 tempconv,需要先导入:

import "gopl.io/ch2/tempconv"

然后通过包名访问导出的名字:

fmt.Println(tempconv.CToF(tempconv.BoilingC))

输出类似:

212°F

也可以访问导出的常量:

fmt.Printf("Brrrr! %v\n", tempconv.AbsoluteZeroC)

因为 AbsoluteZeroC 首字母大写,所以包外可见。


包注释

包声明前面的注释叫包注释:

// Package tempconv performs Celsius and Fahrenheit conversions.
package tempconv

Go 文档工具会使用这类注释生成文档。惯例是第一句以:

Package 包名 ...

开头。

一个包通常只需要一个包注释。如果注释很长,常放在单独的 doc.go 文件中。


2.6.1. 导入包

每个包都有一个导入路径:

"gopl.io/ch2/tempconv"

导入路径用于告诉构建工具去哪里找这个包。使用 Go 工具链时,导入路径通常对应某个目录

同时,每个包还有一个包名:

package tempconv

导入路径(import path)用于定位包所在目录;包名(package name)决定代码中引用这个包时使用的默认名字。两者通常相同,但没有强制关系。

通常包名是导入路径最后一段:

gopl.io/ch2/tempconv  ->  tempconv

但这只是惯例,不是强制规则。


使用导入的包

下面是一个命令行温度转换程序:

package main

import (
    "fmt"
    "os"
    "strconv"

    "gopl.io/ch2/tempconv"
)

func main() {
    for _, arg := range os.Args[1:] {
        t, err := strconv.ParseFloat(arg, 64)
        if err != nil {
            fmt.Fprintf(os.Stderr, "cf: %v\n", err)
            os.Exit(1)
        }

        f := tempconv.Fahrenheit(t)
        c := tempconv.Celsius(t)

        fmt.Printf("%s = %s, %s = %s\n",
            f, tempconv.FToC(f), c, tempconv.CToF(c))
    }
}

这里用到了几个包:

fmt       // 格式化输出
os        // 命令行参数、标准错误、退出程序
strconv   // 字符串和数值转换
tempconv  // 自己写的温度转换包

strconv.ParseFloat 的签名是:

func ParseFloat(s string, bitSize int) (float64, error)

这里:

t, err := strconv.ParseFloat(arg, 64)

把命令行参数字符串转成 float64。第二个参数 64 表示按 float64 精度解析。

然后:

f := tempconv.Fahrenheit(t)
c := tempconv.Celsius(t)

这两句是类型转换,不是温度换算。它们只是把同一个数值分别解释成华氏温度和摄氏温度。

真正的单位转换发生在:

tempconv.FToC(f)
tempconv.CToF(c)

示例运行:

$ ./cf 32
32°F = 0°C, 32°C = 89.6°F

$ ./cf 212
212°F = 100°C, 212°C = 413.6°F

$ ./cf -40
-40°F = -40°C, -40°C = -40°F

-40 是个有趣的点:华氏和摄氏在 -40 这个数值上相等。


未使用的导入会报错

Go 不允许导入包后不用:

import "log"

如果代码里没有使用 log,编译会失败。

这看起来有点严格,但好处是依赖保持干净。调试时删除了:

log.Print("got here")

也要顺手删除:

import "log"

实际开发中一般交给工具处理:

gofmt
goimports

其中:

  • gofmt:格式化代码
  • goimports:自动添加和删除 import,并格式化代码

现在很多编辑器都会在保存时自动运行 goimports


现代 Go 补充:module 替代 GOPATH 为主

原文里提到:

$GOPATH/src/gopl.io/ch1/helloworld

这是早期 Go 项目常见的组织方式。

现代 Go 主要使用 module。项目根目录通常有:

go.mod

里面声明模块路径:

module example.com/myproject

然后包的导入路径基于 module 路径和目录路径组合出来。

例如目录:./pkg/tempconv(相对于go.mod)

go.mod

module example.com

导入时写:

import "example.com/pkg/tempconv"

导入路径不是“由目录路径直接决定”,而是 由 module 路径 + 包目录相对路径组成。


2.6.2. 包的初始化

包初始化发生在 main 函数执行之前。

包级变量会先初始化。初始化顺序要满足依赖关系:

var a = b + c // 第三个初始化,a = 3
var b = f()   // 第二个初始化,b = 2
var c = 1     // 第一个初始化,c = 1

func f() int { return c + 1 }

虽然 a 写在最前面,但它依赖 bc,所以要等它们先初始化。

如果没有依赖关系,包级变量按声明顺序初始化。

如果一个包有多个 .go 文件,Go 工具通常会按文件名排序后交给编译器。实际代码里不要依赖复杂的跨文件初始化顺序,能简单就简单。


init 函数

有些包级变量不能用简单表达式初始化,这时可以用特殊函数:

func init() {
    // initialization
}

init 函数不需要调用,程序启动时会自动执行。每个文件可以有多个 init 函数,它们按声明顺序执行。

init 不能被普通代码调用,也不能被引用。

适合放在 init 里的逻辑包括:

  • 初始化查找表
  • 注册驱动或插件
  • 检查包级配置
  • 做一次性准备工作

但不要滥用。init 是隐式执行的,太多会让程序启动流程难追踪。


包初始化顺序

如果包 p 导入包 q,那么:

  • q 先初始化
  • p 后初始化

每个包只初始化一次。

整体顺序是自底向上:

被依赖的包 -> 依赖它的包 -> main 包 -> main 函数

因此在 main.main 执行前,所有导入的包都已经完成初始化。


示例:用 init 初始化查找表

下面这个包统计一个 uint64 中二进制 1 的个数:

package popcount

var pc [256]byte

func init() {
    for i := range pc {
        pc[i] = pc[i/2] + byte(i&1)
    }
}

func PopCount(x uint64) int {
    return int(pc[byte(x>>(0*8))] +
        pc[byte(x>>(1*8))] +
        pc[byte(x>>(2*8))] +
        pc[byte(x>>(3*8))] +
        pc[byte(x>>(4*8))] +
        pc[byte(x>>(5*8))] +
        pc[byte(x>>(6*8))] +
        pc[byte(x>>(7*8))])
}

pc 是一个长度为 256 的表:

var pc [256]byte

pc[i] 表示数字 i 的二进制表示中有多少个 1

初始化公式:

pc[i] = pc[i/2] + byte(i&1)

含义是:

  • i/2 相当于右移一位
  • i&1 取最低位
  • i 的 1-bit 数量 = i/2 的 1-bit 数量 + 最低位是否为 1

例如:

6  = 110b
3  = 011b
6 的 1-bit 数量 = 3 的 1-bit 数量 + 0

PopCount 处理 uint64 时,把它拆成 8 个 byte,每个 byte 查一次表:

pc[byte(x>>(0*8))]
pc[byte(x>>(1*8))]
...
pc[byte(x>>(7*8))]

这样不需要逐 bit 循环 64 次。


用匿名函数初始化变量

有时可以不用 init,而是直接用匿名函数初始化包级变量:

var pc [256]byte = func() (pc [256]byte) {
    for i := range pc {
        pc[i] = pc[i/2] + byte(i&1)
    }
    return
}()

这种写法的好处是:初始化逻辑和变量绑定在一起,减少包级可变状态的分散。

现代 Go 里也常写成:

var pc = func() [256]byte {
    var pc [256]byte
    for i := range pc {
        pc[i] = pc[i/2] + byte(i&1)
    }
    return pc
}()

如果初始化只服务于某个变量,这种写法通常比 init 更局部、更清楚。


range 只取索引

这里:

for i := range pc {
    ...
}

只需要索引 i,不需要元素值。

完整形式也可以写成:

for i, _ := range pc {
    ...
}

但 Go 风格里,如果只需要索引,通常直接省略第二个值

for i := range pc

更简洁。


小结

包和文件这一节可以记住:

  • 一个包可以由多个 .go 文件组成
  • 同一包内的包级名字可以跨文件直接访问
  • 每个文件都必须有 package 声明
  • import 是文件级的,哪个文件用哪个包就在哪个文件导入
  • 首字母大写的包级名字会被导出
  • 导入路径用于定位包,包名用于代码中引用
  • 未使用的 import 会导致编译错误
  • goimports 可以自动维护 import
  • 包级变量在 main 前初始化
  • init 函数会在程序启动时自动执行
  • 包按依赖顺序初始化,每个包只初始化一次
  • 简单初始化优先用变量初始化表达式;复杂但局部的初始化可以考虑匿名函数;确实需要副作用时再用 init

2.7. 作用域

作用域说的是:一个名字在源码的哪些位置可以被使用。

不要将作用域和生命周期混为一谈。

  • 声明语句的作用域对应的是一个源代码的文本区域;它是一个编译时的属性。
  • 一个变量的生命周期是指程序运行时变量存在的有效时间段,在此时间区域内它可以被程序的其他部分引用;是一个运行时的概念。

例如局部变量的名字可能出了函数就不可见,但它的值可能因为被指针引用而继续存在。


词法块

Go 的作用域由词法块决定。

显式词法块就是花括号里的区域:

func main() {
    x := 1
    {
        y := 2
        fmt.Println(x, y)
    }
    // y 在这里不可见
}

除了显式 {},Go 还有一些隐式词法块:

  • 全局词法块
  • 包级词法块
  • 每个源文件的词法块
  • 每个 forifswitch 的隐式块
  • switch / select 的每个分支块

不同名字的作用域

常见作用域可以这样理解:

int, len, true

这些预定义名字在全局作用域。

var x int
func f() {}

包级声明在整个包内可见,包括同包的其他 .go 文件。

import "fmt"

导入的包名只在当前源文件可见。别的同包文件如果也要用 fmt,需要自己导入。

func f(x int) {
    y := x + 1
}

函数参数和局部变量只在函数内部或更小的局部块中可见。


内层声明会遮蔽外层声明

如果内外层有同名声明,编译器从最内层往外找。先找到哪个,就用哪个。

func f() {}

var g = "g"

func main() {
    f := "f"
    fmt.Println(f) // "f"
    fmt.Println(g) // "g"
    fmt.Println(h) // compile error: undefined: h
}

这里局部变量 f 遮蔽了包级函数 f

这种写法合法,但要少用。遮蔽太多会让代码不好读,尤其是 errctxcancelcwd 这类变量。


for 中的作用域

for 会创建隐式作用域。循环初始化部分的变量,在条件、后置语句和循环体中都可见:

for i := 0; i < len(s); i++ {
    fmt.Println(i)
}
// i 在这里不可见

也可以在循环体里声明同名变量,遮蔽外层变量:

x := "hello"

for _, x := range x {
    x := x + 'A' - 'a'
    fmt.Printf("%c", x)
}

这段代码里有多个 x,分别属于不同作用域。能编译,但不是值得模仿的风格。


if 中的作用域

if 可以带一个初始化语句:

if x := f(); x == 0 {
    fmt.Println(x)
}

这里的 x 只在整个 if-else 链中可见,出了 if 就不可见。

注意:是整个 if- else if - else 链,不是定义变量的分支

例如:

if x := f(); x == 0 {
    fmt.Println(x)
} else if y := g(x); x == y {
    fmt.Println(x, y)
} else {
    fmt.Println(x, y)
}

fmt.Println(x, y) // compile error

第一个 if 中声明的 x,在后面的 else ifelse 里也可见;y 从它声明的 else if 开始,在后续 else 中也可见。


包级声明顺序不影响作用域

包级声明的顺序通常不影响使用:

func main() {
    fmt.Println(f())
}

func f() int {
    return 1
}

函数可以先调用后声明。

这和 C/C++ 不同,Go 不需要函数原型。

但变量或常量不能递归初始化自己,否则会编译错误。


if 初始化语句的常见坑

下面代码是错的:

if f, err := os.Open(fname); err != nil {
    return err
}

f.ReadByte() // compile error: undefined f
f.Close()    // compile error: undefined f

f 的作用域只在 if 语句内,所以后面不能用。

正确写法通常是:

f, err := os.Open(fname)
if err != nil {
    return err
}
f.ReadByte()
f.Close()

Go 风格倾向于:先处理错误并返回,让正常路径保持少缩进。

不太推荐为了延长 f 的可见性而把正常逻辑塞进 else

if f, err := os.Open(fname); err != nil {
    return err
} else {
    f.ReadByte()
    f.Close()
}

能写,但不够 Go。


:= 遮蔽外层变量的坑

:= 只能复用“当前词法作用域”里的已有变量,不能复用外层作用域的变量。
也就是用 := 赋值,被赋值的变量必须位于相同作用域

这个问题很常见:

var cwd string

func init() {
    // 无法复用外层 cwd,因此创建新变量 cwd
    cwd, err := os.Getwd()
    if err != nil {
        log.Fatalf("os.Getwd failed: %v", err)
    }
}

看起来像是在给包级变量 cwd 赋值,其实不是。

因为 err 是新变量,所以 := 会在当前函数作用域内重新声明:

cwd
err

于是局部 cwd 遮蔽了包级 cwd,包级变量没有被更新。

正确写法:

var cwd string

func init() {
    var err error
    cwd, err = os.Getwd()
    if err != nil {
        log.Fatalf("os.Getwd failed: %v", err)
    }
}

这里先声明 err,然后使用普通赋值 =,这样 cwd 指向的就是包级变量。


小结

作用域这一节记住几条就够了:

  • 作用域是编译期概念,生命周期是运行时概念
  • Go 按词法块决定名字可见范围
  • 内层声明会遮蔽外层同名声明
  • import 只在当前文件可见
  • 包级声明在整个包内可见
  • forifswitch 都会产生隐式作用域
  • if x := ...; ... 中的 x 只在 if-else 链内可见
  • 小心 := 意外遮蔽外层变量
  • 想给外层变量赋值时,用 =,不要误用 :=

评论