Go语言圣经 —— 第二章 程序结构

  • 参考Go语言圣经(中文版),个人电子笔记,仅供学习交流之用。
  • 这一章节的重要部分是指针、元组赋值、包的处理这三块,另外Go语言灵活的变量声明语句也是值得学习的,这四块的内容是Go语言与其他编程语言存在差异的地方,其他的内容与其他编程语言的学习过程所遇到的相关概念类似。

2.1 命名

  • 函数名、变量名、常量名、类型名、语句标点和报名等所有的命名都必须以字母或下划线开头,区分大小写。
  • 名字的开头字母的大小写决定了名字在包外的可见性。如果一个名字是大写字母开头的(必须是在函数外部定义的包级名字;包级函数名本身也是包级名字),那么它将是导出的,即可以被外部的包访问。包本身的名字一般是全小写字母。

2.2 声明

  • Go语言主要有四种类型的声明语句:varconsttypefunc,分别对应变量、常量、类型和函数实体对象的声明。
  • 一个Go语言程序对应一个或多个.go文件,每个源文件以包的声明语句开始package name,说明该源文件属于哪个包。
  • 包一级范围声明语句声明的名字可在整个包对应的每个源文件中访问,而不是仅仅在其声明语句所在的源文件中访问。局部声明的名字只能在函数内部进行访问使用。
  • 一个函数的声明由一个函数名、参数列表、一个可选的返回值列表和包含函数定义的函数体组成。

2.3 变量

  • var声明语句可以创建一个特定类型的变量:
var name type = xxx
  • 其中type= xxx两个部分可以省略其中一个,如果省略的是类型信息,那么将根据初始化表达式来推导变量的类型信息。如果初始化表达式被省略,那么将用类型的零值初始化该变量。

    数值类型变量对应的零值是\(0\),布尔类型变量对应的零值是\(false\),字符串类型变量对应的零值是空字符串,接口或引用类型(包括\(slice\)\(map\)\(chan\)和函数)变量对应的零值是\(nil\)。数组或结构体等聚合类型对应的零值是每个元素或字段都是对应的该类型的零值。

  • 可以在一条声明语句中同时声明一组变量,或用一组初始化表达式声明并初始化一组变量。如果省略每个变量的类型,就可以声明多个类型不同的变量:

var i, j, k int
var b, f, s = true, 2.3, "four" // bool, float64, string
  • 初始化表达式可以是字面量或任意的表达式。在包级别声明的变量会在进入main函数前完成初始化,局部变量将在声明语句被执行时完成初始化。
  • 一组变量也可以通过调用一个函数,由函数返回的多个返回值完成初始化:
var f, err = os.Open(name) // os.Open return a file and an err

2.3.1 简短变量声明

  • 在函数内部,有一种称为简短变量声明语句的形式可用于声明和初始化局部变量。可用“名字 := 表达式”来声明变量,变量的类型根据表达式来完成推导。
anim := git.GIF{LoopCount: nframes}
freq := rand.Float64() * 3.0
t := 0.0
  • 简短变量声明被广泛应用于大部分的局部变量的声明和初始化。var形式的声明语句往往用于需要显示指定变量类型的地方,或者因为变量稍后会被重新赋值而初始值无关紧要的地方。
i := 100 // an int
var boiling float64 = 100 // a float64
var names []string
var err error
var p Point
  • var形式声明语句一样,简短变量声明语句也可以用来声明和初始化一组变量,但是这种同时声明多个变量的方式应该限制旨在可以提高代码可读性的地方使用,比如for语句的循环的初始化语句部分。
i, j = j, i // swap(i, j)
  • 和普通的var形式的变量声明语句一样,简短变量声明语句也可以用函数的返回值来声明和初始化变量:
f, err := os.Open(name)
if err != nil {
	return err
}
// ...use f...
f.Close()
  • 简短变量声明左边的变量可能并不是外你全部都是在该语句中声明的。如果有一些已经在相同的词法域声明过了,那么简短变量声明语句对这些已经声明过的变量就只有赋值行为。
in, err := os.Open(infile) // 声明了in, err两个变量
// ...
out, err := os.Create(outfile) // 声明了out变量,仅对err进行赋值操作
  • 简短变量声明中必须至少要声明一个新的变量,下面的代码将不能编译通过:
f, err := os.Open(infile)
// ...
f, err := os.Create(outfile) // compile error: no new variables
  • 简短变量声明语句只有在同级词法域声明过的变量才和赋值操作语句等价,如果变量是在外部词法域声明的,那么简短变量声明语句将会在当前词法域重新声明一个新的变量。

2.3.2 指针

  • 一个变量对应一个保存了变量对应类型值的内存空间。普通变量在声明语句创建时被绑定到了一个变量名,比如x就是一个变量。但是还有很多变量始终以表达式方式引入,例如x[i]x.f变量,所有这些表达式一般都是读取一个变量的值,除非它们出现在了赋值语句的左侧,这种时候是给对应变量进行赋值
  • 一个指针的值是另一个变量的地址,一个指针对应变量在内存中的存储地址。并不是每一个都会有一个内存地址,但是对于每一个变量必然有对应的内存地址。通过指针,我们可以直接读或更新对应变量的值,而不需要直到该变量的名字(如果变量有名字的话)。
  • 如果用var x int声明语句声明了一个x变量,那么&x表达式(x变量的地址)将产生一个指向该整数变量的指针,指针对应的数据类型是*int,指针被称之为“指向int类型的指针”。如果指针名字为p,那么可以说“p指针指向变量x”,或者说“p指针保存了x变量的内存地址”。同时*p表达式对应p指针指向的变量的值。一般*p表达式读取指针指向的变量的值,这里为int类型的值,同时因为*p对应一个变量,所以该表达式也可以出现子赋值语句的左侧,表示更新指针所指向的变量的值。
x := 1
p := &x // p, of type *int, points to x
fmt.Println(p) // 0x1d9f4f81a008
fmt.Println(*p) // 1
*p = 2
fmt.Println(x) // 2
  • 对于聚合类型每个成员——比如结构体的每个字段、或者是数组的每个元素——也都是对应一个变量,因此可以被取地址&
  • 变量有时候被称为可寻址的值。即使变量由表达式临时生成,那么表达式也必须接收&取地址操作。
  • 任何类型的指针的零值都是nil。如果p != nil为真,那么p是指向某个有效变量。指针之间也可以进行相等测试,只有当它们指向同一个变量(指向同一块内存)或全部是nil时才相等。
var x, y int
fmt.Println(&x == &x, &x == &y, &x == nil) // true false false
  • 在Go语言中,返回函数中局部变量的地址也是安全的。例如下面的代码,调用f函数时创建局部变量v,在局部变量地址被返回之后依然有效,并不会因此而导致p成为一个nil空指针,因为p依旧引用这个变量:
var p = f()
func f() *int {
	v := 1
	return &v
}
  • 每次调用f函数都会返回不同的结果,即f() == f()的返回值为false,如果函数中生成的局部变量的地址被返回后变量消失,那么接收该函数的指针就会变为野指针。
  • 因为指针包含了一个变量的地址,因此如果将指针作为参数调用函数,那么将可以在函数中通过该指针更新变量的值。类似于C++实现swap函数。
  • 每次我们对一个变量取地址,或者复制指针,我们都是在为原变量创建了新的别名。例如,*p就是变量v的别名。指针特别有价值的点在于我们可以不用名字而访问一个变量:要找到一个变量的所有访问者并不容易,我们必须知道变量全部的别名(Go语言的垃圾回收器所做的工作)。不仅仅是指针会创建别名,很多其他引用类型也会创建别名,例如\(slice\)\(map\)\(chan\),甚至结构体、数组和接口都会创建所引用变量的别名。
  • 指针是实现标准库中flag包的关键技术,它使用命令行参数来设置对应变量的值,而这些对应命令行标志参数的变量可能会零散分布在整个程序中。在echo的前期版本中,就包含了两个可选的命令行参数:-n用于忽略行尾的换行符,-s sep用于指定分隔字符(默认是空格),下面是第四个版本:
package main

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

var n = flag.Bool("n", false, "omit trailing newline")
/*
 Param:
	 "n": 所检测的命令行标志参数的名字 "n"
	 false: 该标志参数的默认值
	 "omit...": 该标志参数对应的描述信息
*/
var sep = flag.String("s", " ", "sqparator")

func main() {
	flag.Parse()
	fmt.Print(strings.Join(flag.Args(), *sep))
	if !*n {
		fmt.Println()
	}
}
  • 调用flag.Bool函数会创建一个新的对应布尔型标志参数的变量。如果用户在命令行输入了一个无效的标志参数,或者输入-h-help参数,那么就会将打印所有标志参数的名字、默认值和描述信息。类似地,调用flag.String函数将创建一个对应字符串类型的标志参数变量。程序中的nsqp变量分别是指向对应命令行标志参数变量的指针,因此必须用*sep*n来读取其中的值。
  • 当程序运行时,必须在使用标志参数对应的变量之前先调用flag.Parse()函数,用于个更新每个标志参数对应变量的值(此前为默认值)。对于非标志参数的普通命令行参数可以通过调用flag.Args[]来访问,返回值对应一个字符串类型的slice。如果在flag.Parse()函数解析命令行参数时发生错误,默认将打印相关的提示信息,然后自动调用os.Exit(2)终止进程。
└─[$]> ./echo4 a bc def        
a bc def
└─[$]> ./echo4 -s / a bc def
a/bc/def
└─[$]> ./echo4 -n a bc def  
a bc def%                                                                                                                                
└─[$]> ./echo4 -h         
Usage of ./echo4:
  -n    omit trailing newline
  -s string
        sqparator (default " ")

2.3.3 new函数

  • 另一个创建变量的方法是调用内建的new函数。表达式new(T)将创建一个T类型的匿名变量,初始化为T类型的零值,然后返回变量的地址,返回的指针类型为*T
p := new(int) // p, of type *int, points to an anonymous int variable
fmt.Println(*p) // 0
*p = 2
fmt.Println(*p) // 2

匿名变量指的是该该变量没有直接可以完成索引的变量名,可以由指针完成相关的操作。

  • new创建变量和普通变量声明方式没有本质区别,除了不需要声明变量名以外,我们还可以在表达式中使用new(T)。下面两个newInt函数作用完全相同:
func newInt() *int {
	return new(int)
}

func newInt() *int {
	var dummy int
	return & dummy
}
  • 注意:虽然new函数会创建匿名变量,但是每次调用new函数都会返回一个新的T类型的指针,因此:
p := new(int)
q := new(int)
fmt.Println(p == q) // false

2.3.4 变量的生命周期

  • 变量的生命周期指的是在程序运行期间变量有效存在的作用域时间段。对于在包一级声明的变量来说,它的生命周期域整个程序的运行周期是一致的。相比之下,局部变量的生命周期则是动态的:从声明语句开始,知道该变量不再被引用为止,然后变量的存储空间可能被回收。函数的的参数变量和返回值变量都是局部变量
  • Go语言的垃圾回收机制怎么确定一个变量是否后续不再被引用?
    从每个包级的变量和每个当前运行函数的每一个局部变量开始,通过指针或引用的访问路径遍历,是否可以找到该变量。如果不存在这样的访问路径,这就说明该变量是不可达的,也就是说该变量是否存在不影响程序的最终计算结果。
  • 编译器会自动选择在栈上还是堆上分配局部变量的存储空间,这个选择不是由变量的声明方式决定的。
var global *int

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

func g() {
	y := new(int)
	*y = 1
}
  • f函数里的x变量必须在堆上,因为它在函数退出后依然可以通过global变量找到,虽然它是在函数内部当以的,在Go中,这被称为x局部变量从函数f()中逃逸了。相反,当g()函数返回时,变量y就是不可达的,会被回收。因此,*y并没有从函数中逃逸,编译器可以选择在栈上分配*y的存储空间(也可以选择在堆上分配内存空间,进而由GC回收),虽然这里用的是new方式。
  • 不需要因为正确性而考虑变量的逃逸行为,逃逸的变量需要额外分配内存,同时对性能的优化可能会产生影响。

Go语言的垃圾回收机制不需要显式地分配或释放内存,但是要编写高效的程序依然需要了解变量的生命周期。例如,如果将指向短生命周期对象的指针保存到具有长生命周期的对象中,特别是保存到全局变量时,会组织堆短生命周期对象的垃圾回收,进而造成资源浪费,影响性能。

2.4 赋值

x = 1
*p = true
person.name = "alice"
count[x] = count[x] * scale
count[x] *= scale
i++
i--

2.4.1 元组赋值

  • 元组赋值是另一种形式的赋值语句,它允许同时更新多个变量的值。在赋值之前,赋值语句右边的所有表达式都将先进行求值,然后再统一更新左边对应变量的值
// swap
x, y = y, x
a[i], a[j] = a[j], a[i]
  • 欧几里得算法求解最大公因数:
// Version 1
func gcd(a int, b int) int {
	if b == 0 {
		return a
	}
	return gcd(b, a % b)
}
// Version 2
func gcd(a int, b int) int {
	for b != 0 {
		a, b = b, a % b
	}
	return a
}
  • 计算斐波那契数列的第N个数:
func fib(n int) int {
	x, y := 0, 1
	for i := 0; i < n; i++ {
		x, y = y, x + y
	}
	return x
}
  • 元组赋值也可以使得一系列琐碎赋值变得更加紧凑,一般应用在for循环初始化部分:
i, j, k = 2, 3, 5
  • 有些表达式会产生多个值,例如调用一个有多个返回值的函数。当这样一个函数调用出现在元组赋值右边的表达式中时(右边不能再有其他表达式),左边变量的数目必须与右边一致(可以用_来完成无用变量的占位)
f, err = os.Create(outfile)
  • 通常这类函数会用额外的返回值来表达某种错误类型,例如os.Open时用额外的返回值返回一个error类型的错误,还有一些是用来返回布尔值,通常被称为ok
v, ok = m[key] // map lookup
v, ok = x.(T) // type assertion
v, ok = <- ch // channel receive
  • map查找、类型断言或通道接收出现在赋值语句的右边时,并不一定时产生两个结果,也可能只产生一个结果。即同一个操作(如 map 查找、类型断言、通道接收)可以根据你提供的变量个数,决定“产生”一个值还是两个值。对于只产生一个结果的情形,map查找失败时会返回零值,类型断言失败时会发送运行时panic异常,通道接收失败时会返回零值(阻塞不算失败)。
v = m[key] // map 查找,失败时返回零值
v = x.(T) // type断言,失败时 panic 异常
v = <- ch // 管道接收,失败时返回零值

_, ok = m[key] // map 返回两个值
_, ok = mm[""], false // map 返回一个值
_ = mm[""] // map 返回一个值

2.4.2 可赋值性

  • 赋值语句(=+=++)是显式的赋值形式,但是程序中还有很多隐式的赋值行为:函数调用会隐式地将调用参数的值赋值给函数的参数变量,一个返回语句将隐式地将返回操作的值赋值给一个结果变量,一个复合类型的字面量也会产生赋值行为。
// Version 1
medals := []string{"gold", "silver", "bronze"} // 隐式地对slice的每一个元素进行赋值操作
// Version 2
medals[0] = "gold"
medals[1] = "silver"
medals[2] = "bronze"
  • map和chan的元素,虽然不是普通的变量,但也有类似的隐式赋值行为。
  • 不论是隐式还是显式地赋值,在赋值语句左边的变量和右边最终求解得到的值必须有相同的数据类型。只有右边的值对于左边的变量是可赋值的,赋值语句才允许。
  • 一般数据类型的可赋值规则:类型必须完全匹配,nil可以复制给任何指针或引用类型的变量。常量则有更灵活的赋值规则,这样可以避免不必要的显式类型转换。
  • 对于两个值是否可以用==!=进行比较也和可赋值能力有关系:对于任何类型的值的相等比较,第二个值必须是对第一个值类型对应的变量是可赋值的,反之亦然。

2.5 类型

  • 下面或者说中文版原书所说的“新的类型”可以理解为用户自定义的结构体或别名
  • 变量或表达式的类型定义了对应存储值得属性特征,例如数值在内存的存储大小,它们在内部是如何表达的,是否支持一些操作符,以及它们自己关联的方法集等。
  • 在任何程序中都会存在一些变量有着相同的内部结构,但是却表示完全不同的概念。
  • 一个类型声明语句创建了一个新的类型名称,和现有类型具有相同的底层结构。新命名的类型提供了一个方法,用来分隔不同概念的类型,这样即使它们底层类型相同也不是兼容的。
type 类型名字 底层类型
  • 类型声明语句一般出现在包一级,因此如果新创建的类型名称的首字符是大写,则在外部包也可以使用,为了说明类型声明,我们将不同温度单位分别定义为不同的类型:
// Package tempconv performs Celsius and Fahrenheit temperature computations.
package tempconv

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,但是它们属于不同的类型,因此它们不能使用==!=进行比较或者出现在同一个表达式中。类似于强制类型转换,我们也需要Celsius(t)Fahrenheit(t)形式的显式类型转换操作才能将float64转为对应的类型,它们是类型转换操作,并非是函数调用,类型转换不改变值本身,但是会使它们发生语义上的变化。
  • 对于每一个类型T,都有一个对应的类型转换操作T(x),用于将x转换到T类型。(如果T是指针类型,转换操作为(*int)(0))。只有当两个类型的底层基础类型相同时才能进行类型转换,或者是两者都是指向相同底层结构的指针类型,这些转换只改变类型不会影响值本身。如果x是可以赋值给T类型的值,那么x必然可以被转换为T类型。
  • 数值类型之间的转型是允许的,并且字符串和一些特定类型的\(slice\)之间也可以相互转换,这些不同于上面这段话所说的底层基础类型相同,它们可能改变值。此类类型转换可能在编译时出现错误,但是一旦通过了编译,运行时就一定不会出现转换失败的错误。
  • 底层数据类型决定了内部结构和表达方式,也决定了是否可以像底层类型一样支持内置运算符的操作。例如,CelsiusFahenheit类型的算数运算行为和float64一样。
fmt.Printf("%g\n", BoilingC - FreezingC) // 100
boilingF := CToF(BoilingC)
fmt.Printf("%g\n", boilingF - CToF(FreezingC)) // 180
fmt.Printf("%g\n", boilingF - FreezingC) // compile error: type mismatch
  • 比较运算符==<也可以用来比较一个命名类型的变量和另一个有着相同类型的变量,或有着相同底层类型的未命名类型的值之间做比较。但是如果两个值有着不同的类型,则不能直接进行比较:
var c Celsius
var f Fahrenheit
fmt.Println(c == 0) // true
fmt.Println(f == 0) // true
fmt.Println(f == c) // compile error: type mismatch
fmt.Println(c == Celsius(f)) // true
// Celsius(f) 本质属于类型转换,并且由于是自定义类型,因此一定不会有数值改变

2.6 包和文件

  • Go语言中的包和其他语言的库或模块的概念类似,目的是为了支撑模块化、封装、单独编译和代码重用。一个包的源代码保存在一个或多个.go源文件中,通常一个包所在目录路径的后缀是包的导入路径。(正常开发测试使用相对路径即可)
  • 每个包都对应一个独立的命名空间。例如,在image包中的Decode函数和在unicode/utf16包中的Decode函数是不同的。要在外部引用该函数,必须显式地使用image.Decodeutf16.Decode形式访问。
  • 包可以让我们通过控制哪些名字是外部可见的来隐藏内部实现信息。(通过改变常量或函数名的首字母大小写决定对外部的可导出性)
    tempconv.go
// Package tempconv performs Celsius and Fahrenheit temperature computations.
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)}
func (f Fahrenheit) String() string	{return fmt.Sprintf("%g℉", f)}

conv.go

package tempconv

// CToF converts a Celsius temperature to Fahrenheit.
func CToF(c Celsius) Fahrenheit {
	return Fahrenheit(c * 9 / 5 + 32)
}

// FToC converts a Fahrenheit temperature to Celsius.
func FToC(f Fahrenheit) Celsius {
	return Celsius((f - 32) * 5 / 9)
}
  • 通过上述组织方式,我们将类型的声明、常量、基础方法集放在了tempconv.go源文件中,转换函数放在了conv.go源文件中。每个源文件以包的声明开始,用来指定包的名称。当包被导入的时候,包内的成员将通过类似tempconv.CToF的形式访问。而包级别的名字,例如在一个文件声明的类型和常量,在同一个包的其他源文件也是可以直接访问的
  • 因为包级别的常量名都是以大写字母开头的,它们就可以像tempconv.AbsoluteZeroC这样被外部的代码访问:
    test.go
package main

import (
	"fmt"
	"temp/tempconv"
)


func main() {
	fmt.Printf("Brrrr! %v\n", tempconv.AbsoluteZeroC)	// Brrrr! -273.15℃
	fmt.Println((tempconv.CToF(tempconv.BoilingC)))		// 212℉
}
  • 上述目录结构如下:
temp/
├── go.mod           # module temp
├── test.go          # package main
└── tempconv/
    ├── conv.go      # package tempconv
    └── tempconv.go  # package tempconv

2.6.1 导入包

  • 在Go语言程序中,每个包都会有一个全局唯一的导入路径(绝对路径)。导入语句中类似“gopl.io/ch2/tempconv”的字符串对应包的导入路径。Go语言的规范并没有定义这些字符串的具体含义和包来自哪里,它们是由构建工具来解释的。
  • 除了包的导入路径,每个包还有一个包名,包名一般是短小的名字(并不要求包名是唯一的),包名在包的声明处指定。按照惯例,一个包的名字和包的导入路径的最后一个字段相同,例如“gopl.io/ch2/tempconv”包的名字一般是tempconv。
  • golang如何导入本地包 - guanyubo - 博客园
// CF converts its numeric argument to Celsius and Fahrenheit.
package main

import (
	"cf/tempconv"
	"fmt"
	"os"
	"strconv"
)


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))
	}
}
/*
	┌─[chenxing@chenxing-VMware-Virtual-Platform] - [~/Desktop/go/ch2/cf] - [Mon Aug 03, 16:28]
	└─[$]> ./cf 100      
	100℉ = 37.77777777777778℃, 100℃ = 212℉
*/ 
  • 针对于导入而未使用的包,会在编译时发生错误,我们可以使用goimports导入工具,它根据需要自动添加或删除导入的包;许多编辑器都可以集成goimports工具,在保存文件时自动运行。类似的还有gofmt工具,可以用来格式化.go源程序。

2.6.2 包的初始化

  • 包的初始化首先时解决包变量的依赖顺序,然后按照包级变量声明出现的顺序依次初始化:
var a = b + c // a 第三个初始化, 为 3
var b = f()   // b 第二个初始化, 为 2
var c = 1     // c 第一个初始化, 为 1

func f() int {return c + 1}
  • 如果包中含有很多.go源文件,它们将按照发给编译器的顺序进行初始化,Go语言的构建工具首先会将.go文件根据文件名排序,然后依次调用编译器编译。
  • 对于包级别声明的变量,如果有初始化表达式则用表达式初始化,还有一些没有初始化表达式的,例如某些表格数据初始化并不是一个简单的赋值过程。在这种情况下,我们可以用一个特殊的init初始化函数来简化初始化工作。每个文件都可以包含多个Init初始化函数。
func init() { /* ... */ }
  • 这样的init函数除了不能被调用和引用外,其他行为和普通函数类似。在每个文件中的init初始化函数,在程序开始执行时按照它们声明的顺序被自动调用。
  • 每个包在解决依赖的前提下,以导入声明的顺序初始化,每个包只会被初始化一次。因此,如果一个p包导入了q包,那么p包初始化的时候可以认为q包已经完成了初始化。初始化工作是自下而上进行的,main包最后被初始化。以这种方式,可以确保在main函数执行之前,所有依赖的包都已经完成了初始化工作。
  • 下面的代码定义了一个PopCount函数,用于返回一个数字中含二进制\(1\)bit的个数。它使用init初始化函数来生成辅助表格pc,pc表格用于处理每个\(8\)bit宽度的数字含二进制的\(1\)bit的bit个数,这样的话在处理\(64\)bit宽度的数字时就没必要循环\(64\)次,只需要\(8\)次查表就可以了。(这不是最快的统计\(1\)bit数目的算法,但是它可以方便演示init函数的用法,并且演示了如何预生成表格)
package popcount

// pc[i] is the population count of i.
var pc [256]byte

func init() {
	for i := range pc {
		// range 循环只使用了索引,省略了没有用到的值部分
		// for i, _ := range pc {
		pc[i] = pc[i / 2] + byte(i & 1)
	}
}

// PopCount returns the population count (number of set bits) fo x.
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这类需要复杂处理的初始化,可以通过将初始化逻辑包装成一个匿名函数处理:
// pc[i] is the population count of i.
var pc [256]byte = func() (pc [256]byte) {
	for i := range pc {
		pc[i] = pc[i / 2] + byte(i & 1)
	}
	return
}()

2.7 作用域

  • 一条声明语句将程序中的实体和名字关联,比如一个函数或一个变量。声明语句的作用域是指源代码中可以有效使用这个名字的范围。
  • 不要把作用域和生命周期混为一谈,声明语句的作用域对应的是一个源代码的文本区域;它是一个编译时的属性。一个变量的生命周期是指程序运行时变量存在的有效时间段,在此事件区域内它可以被程序的其他部分引用,是一个运行时的概念。
  • 语法块是由花括号所包含的一系列语句,就像函数体或循环体一样,语法块内部声明的名字无法被外部语法块访问。语法块决定了内部声明的名字的作用域范围。
  • 对于导入的包,例如tempconv导入的fmt包,则是对应源文件级的作用域,因此只能在当前的文件中访问导入的fmt包,当前包的其他源文件无法访问在当前源文件导入的包。还有许多声明语句,比如tempconv.CToF函数中的变量c,则是局部作用域,它只能在函数内部访问。
  • 当编译器遇到一个名字引用时,它看起来像一个声明,它首先从最内层的词法域向全局作用域查找。如果查找失败,则报告“为声明的名字”这样的错误。如果改名字在内部和外部的块分别声明过,则内部块的声明首先被找到。在这种情况下,内部声明屏蔽(cover)了外部同名的声明,让外部的声明的名字无法被访问:
func f() {}
var g = "g"
func main() {
	f := "f"
	fmt.Println(f) // "f"; local var f shadows package-leverl func f
	fmt.Println(g) // "g"; package-level var
	fmt.Println(h) // compile error: undefined: h
}
  • 在函数中词法域可以深度嵌套,因此内部的一个声明可能屏蔽(shadow)外部的声明。还有许多语法块是if或for等控制语句构造的。下面的代码有三个不同的变量x,因为它们是定义在不同的词法域:
func main() {
	x := "hello!"
	for i := 0; i < len(x); i++ {
		x := x[i]
		if x != '!' {
			x := x + 'A' - 'a'
			fmt.Printf("%c", x) // "HELLO" (one letter per iteration)
		}
	}
}
  • for语句会创建两个词法域:花括号包含的是显示的部分是for循环的循环体部分词法域,另一个隐式的部分则是循环的初始化部分,比如用于迭代变量i的初始化。隐式的词法域部分的作用域还包含条件测试部分和循环后的迭代部分(i++),当然也包含循环体词法域。

Practice

posted @ 2026-08-04 18:56  chen_xing  阅读(4)  评论(0)    收藏  举报