Go程序设计语言 学习笔记 第二章 程序结构

2.1 名称

Go中函数、变量、常量、类型、语句标签、包的名称都遵循一个规则:名称的开头是一个字母(Unicode中的字符即可)或下划线,后面可以跟任意数量的字符、数字和下划线,并区分大小写。

Go中有25个关键字,只能用在语法允许的地方,不能作为名称:

另外,还有三十几个内置的预声明的常量、类型和函数:

这些名称不是预留的,可以在声明中使用它们。对其中的名称进行重声明会有冲突的风险。

如如果一个实体在函数中声明,它只在函数局部有效。如果声明在函数外,它将对包里面的所有源文件可见。实体第一个字母的大小写决定其可见性是否跨包。如果名称以大写字母开头,它是导出的,意味着它对包外是可见和可访问的,像fmt包中的Printf。包名本身总是由小写字母组成。

名称本身没有长度限制,但习惯以及Go的风格倾向于使用短名称,特别是作用域较小的局部变量,你更喜欢看到一个变量叫i而不是theLoopIndex。通常,名称的作用域越大,就使用越长且更有意义的名称。

风格上,当遇到由单词组合的名称时,Go使用驼峰式风格,更喜欢使用大写字母而不是下划线。像ASCII和HTML这样的首字母缩写通常使用相同的大小写,所以一个函数可以叫做htmlEscape、HTMLEscape或escapeHTML,但不会是escapeHtml。

2.2 声明

声明给一个程序实体命名,并且设定其部分或全部属性。有4个主要的声明:变量(var)、常量(const)、类型(type)、函数(func)。本章讨论变量和类型。常量放在第3章讨论,函数放在第5章讨论。

Go程序存储一在一个或多个以.go为后缀的文件里。每一个文件以package声明开头,表明文件属于哪个包。package声明后面是import声明,然后是包级别的类型、变量、 常量、函数的声明,不区分顺序。例如,下面程序声明一个常量、一个函数、一对变量:

go 复制代码
package main

import "fmt"

const boilingF = 212.0

func main() {
	var f = boilingF
	var c = (f - 32) * 5 / 9
	fmt.Printf("boiling point = %g℉ or %g℃\n", f, c)
	// 输出:
	// boiling point = 212℉ or 100摄氏度
}

常量boiling是一个包级别的声明(main包),f和c是属于main函数的局部变量。包级别的实体名字不仅对包含其声明的源文件可见,而且对于同一个包里面的所有源文件都可见。而局部声明仅仅在声明所在的函数内部可见,并且可能对于函数中的一小块区域可见。

函数的声明包含一个名字、一个参数列表、一个可选的返回值列表,以及函数体。如果函数不返回任何内容,返回值列表可以省略。

以下fTOc封装了温度转换逻辑,这样它可以只定义一次而在多个地方使用:

go 复制代码
// ftoc输出两个华氏温度-摄氏温度的转换
package main

import "fmt"

func main() {
	const freezingF, boilingF = 32.0, 212.0
	fmt.Printf("%g℉ = %g℃\n", freezingF, fTOc(freezingF)) // "32℉ = 0℃"
	fmt.Printf("%g℉ = %g℃\n", boilingF, fTOc(boilingF))   // "212℉ = 100℃"
}

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

2.3 变量

var声明城建一个具体类型的变量,然后给它附加一个名字,设置它的初始值:

类型和表达式可以省略一个,但不能都省略。如果类型省略,它的类型将由初始化表达式决定;如果表达式省略,其初始值对应于类型的零值------对于数字是0,对于布尔值是false,对于字符串是"",对于接口和引用类型(slice、指针、map、通道、函数)是nil。对于一个像数组或结构体的复合类型,零值是其所有元素或成员的零值。

零值机制保障所有的变量是良好定义的,Go里面不存在未初始化变量。例如:

go 复制代码
var s string;
fmt.Println(s) // ""

输出空字符串,而不是错误或不可预料的行为。Go程序员经常花费精力来使复杂类型的零值有意义,以便变量一开始就处于可用状态。

可以声明一个变量列表,并选择使用对应的表达式列表对其初始化。忽略类型允许声明多个不同类型的变量:

go 复制代码
var i, j, k int // int, int, int
var b, f, s = true, 2.3, "four" // bool, float64, string

初始值设定可以是字面量值或任意表达式。包级别的初始化在main开始之前进行(2.6.2),局部变量初始化和声明一样在函数执行期间进行。

变量可以通过调用返回多个值的函数进行初始化:

go 复制代码
var f, err = os.Open(name) // os.Open返回一个文件和一个错误

2.3.1 短变量声明

在函数中,一种称作短变量声明的可选形式可以用来声明和初始化局部变量。它使用name := expression的形式,name的类型由expression的类型决定。这里是lissajous函数(1.4)中的三个短变量声明:

go 复制代码
anim := gif.GIF{LoopCount: nframe}
freq := rand.Float64() * 3.0
t := 0.0

因其短小、灵活,因此在局部变量的声明和初始化中主要使用短声明。var声明通常是为那些跟初始化表达式类型不一致的局部变量保留的,或者用于后面才对变量赋值以及变量初始值不重要的情况。

go 复制代码
i := 100 // 一个int类型的变量
var boiling float64 = 100 // 一个float64类型的变量

var names []string
var err error
var p Point

与var声明一样,多个变量可以以短变量声明的方式声明和初始化:

go 复制代码
i, j := 0, 1

只有当它们对于可读性有帮助的时候才使用多个初始化表达式来进行变量声明,例如短小且天然一组的for循环的初始化。

:=表示声明,=表示赋值。一个多变量的声明不能和多重赋值(2.4.1)搞混,后者将右边的值赋给左边的对应变量:

go 复制代码
i, j = j, i // 交换i和j的值

与普通的var类似,短变量声明也可以用来调用像os.Open那样返回两个或多个值的函数:

go 复制代码
f, err := os.Open(name)
if err != nil {
    return err
}
// ...使用 f...
f.Close()

一个容易被忽略但重要的地方是:短变量声明不需要声明所有在左边的变量。在如下代码中, 第一条语句声明了in和err,第二条语句仅声明了out,但向已有的err变量赋了值:

go 复制代码
in, err := os.Open(infile)
// ...
out, err := os.Create(outfile)

短变量声明最少声明一个新变量,否则,代码编译将无法通过:

go 复制代码
f, err := os.Open(infile)
// ...
f, err := os.Create(outfile) // 编译错误:没有新的变量

第二个语句应使用普通赋值语句。

只有在同一个语法块中已经存在变量的情况下,短声明的行为才和赋值操作一样,外层的声明将被忽略:

go 复制代码
package main

import "fmt"

func main() {
	i := 1
	if true {
		i, err := test() // i在块内重定义,覆盖了外层i
		if err != nil {
			fmt.Println(err)
		}

		fmt.Println("inner i is ", i)
	}
	fmt.Println("outer i is ", i)
}

func test() (int, error) {
	return 5, nil
}

执行它:

上例中,如果test函数只返回一个int,内层的短声明会变成i := test(),结果也一样。

2.3.2 指针

变量是存储值的地方。借助声明创建的变量使用名字来区分,例如x,像x[i]或者x.f这样的表达式用来读取一个变量的值,除非它们出现在赋值操作符的左边,这时是给变量赋值。

指针的值是一个变量的地址。一个指针指示值所保存的位置。不是所有值都有地址,但是所有变量都有。使用指针,可以在无须知道变量名的情况下,间接读取或更新变量的值。

如果一个变量声明为var x int,表达式&x(x的地址)获取一个指向整型变量的指针,它的类型是整型指针(*int)。如果值叫作p,我们说p指向x,或者p包含x的地址。p指向的变量写成*p。表达式*p获取变量的值,一个整型,因为*p代表一个变量,所以它也可以出现在赋值操作符的左边,用于更新变量的值:

go 复制代码
x := 1
p := &x // p是整型指针,指向x
fmt.Println(*p) // "1"
*p = 2 // 等于x = 2
fmt.Println(x) // 结果"2"

聚合类型中的变量(结构体的成员或数组中的元素)也是变量,它们也有地址。

代表变量的表达式,是唯一可以应用取地址操作符&的表达式。

指针类型的零值是nil。测试p!=nil,结果是true说明p指向一个变量。指针是可比较的,两个指针当且仅当指向同一个变量或者两者都是nil时才相等:

go 复制代码
var x, y int
fmt.Println(&x == &x, &y == &y, &x == nil) // "true true false"

函数返回局部变量的地址是安全的。下例中,通过调用f产生的局部变量v即使在调用返回后依然存在,指针p依然引用它:

go 复制代码
var p = f()

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

每次调用f都会返回一个不同的值:

go 复制代码
fmt.Println(f() == f()) // "false"

传指针给函数可以间接修改指针指向的值:

go 复制代码
func incr(p *int) int {
    *p++ // 递增p指向的值;p自身保持不变
    return *p
}

v := 1
incr(&v) // 副作用:v现在等于2
fmt.Println(incr(&v)) // "3"(v现在是3)

每次使用变量的地址或复制一个指针,我们就创建了新的别名或者方式来标记同一变量。例如,上例的*p是v的别名。指针别名允许我们不用变量的名字来访问变量,这时非常有用的,但它是双刃剑:为了找到所有访问变量的语句,需要知道所有别名。不仅指针产生别名,当复制其他引用类型(像slice、map、通道,甚至包含引用类型的结构体、数组和接口)的值的时候,也会产生别名。

指针对于flag包很关键,它使用程序的命令行参数来设置整个程序内某些变量的值。为了说明,下面这个变种的echo命令使用两个可选标识参数:-n使echo忽略正常输出时结尾的换行符,-s sep使用sep替换默认参数输出时使用的空格分隔符。

go 复制代码
// echo4输出其命令行参数
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.Bool函数创建一个新的布尔标识变量。它有三个参数:标识的名字("n"),变量的默认值(false),以及当用户提供非法标识、非法参数抑或-h或-help参数时输出的消息。flag.String也同理。变量sep和n是指向标识变量的指针,它们必须解引用访问。

使用标识前,必须调用flag.Parse来更新标识变量的默认值。非标识参数也可从flag.Args()返回的字符串slice来访问。如果flag.Parse遇到错误,它输出一条帮助消息,然后调用os.Exit(2)来结束程序。

运行上例:

标识必须放在命令后,非标识参数前:

2.3.3 new函数

另外一种创建变量的方式是使用内置new函数。表达式new(T)创建一个未命名的T类型变量,初始化为T类型的零值,并返回其地址(地址类型为*T)。

go 复制代码
p := new(int) // *int类型的p,指向未命名的int变量
fmt.Println(*p) // 输出"0"
*p = 2 // 把未命名的int设置为2
fmt.Println(*p) // 输出"2"

使用new创建的变量和取其地址的普通局部变量没有什么不同,只是不需要引入(和声明)一个虚拟的名字,通过new(T)就可以直接在表达式中使用。因此new只是语法上的便利,不是一个基础概念。

下面两个newInt函数有同样的行为:

go 复制代码
func newInt() *int {
    return new(int)
}

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

每一次调用new返回一个具有唯一地址的不同变量:

go 复制代码
p := new(int)
q := new(int)
fmt.Println(p == q) // "false"

这个规则有一个例外:两个变量的类型不携带任何信息且是零值,例如struct{}或[0]int,当前的实现里面,它们有相同的地址。

因为最常见的未命名变量都是结构体类型,它的语法(4.4.1)比较复杂,所以new函数使用得相对较少。

new是一个预声明的函数,不是一个关键字,所以它可以重定义为其他类型:

go 复制代码
func delta(old, new int) int {
    return new - old
}

自然,在delta函数内,内置的new函数是不可用的。

2.3.4 变量的生命周期

生命周期指程序执行过程中变量存在的时间段。包级别的生命周期是整个程序的执行时间。相反,局部变量有一个动态的生命周期:每次执行声明语句时创建一个新的实体,变量一直生存到它变得不可访问,这时它占用的存储空间被回收。函数的参数和返回值也是局部变量,它们在其闭包函数被调用时创建。

例如,1.4节中的lissajous函数中:

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

变量t是每次for循环开始创建的,变量x和y在循环的每次迭代中创建。

垃圾回收器如何知道一个变量是否应该被回收?基本思路是每一个包级别的变量,以及每一个当前执行函数的局部变量,可以作为追溯该变量的路径的源头,通过指针和其他方式引用可以找到变量。如果变量的路径不存在,那么变量变得不可访问,因此它不会影响任何其他的计算过程。

因为变量的生命周期是通过它是否可达来确定的,所以局部变量可在包含它的循环的一次迭代之外继续存活。即使包含它的循环已经返回,它的存在还可能延续(但循环中的变量名在循环外不可见)。

编译器可以选择使用堆或栈上的空间来分配,令人惊奇的是,这个选择不是基于使用var或new关键字来声明变量。

go 复制代码
var global *int

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

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

这里,x一定使用堆空间,因为它在f函数返回后还可以从global变量访问,尽管它被声明为一个局部变量。这种情况我们说x从f中逃逸。相反,当g函数返回时,变量*y变得不可访问,可回收。因为*y没有从g中逃逸,所以编译器可以安全地在栈上分配*y,即便使用new函数创建它。逃逸的概念使你不需要额外费心来写正确的代码,但它在性能优化的时候是有好处的,因为每一次变量逃逸都需要一次额外的内存分配过程。

垃圾回收对于写出正确的程序有巨大的帮助,但是免不了考虑内存的负担。不需要显式分配和释放内存,但是变量的生命周期是写出高效程序所必须清楚的。例如,在长生命周期对象中保持短生命周期对象不必要的指针,特别是在全局变量中,会阻止垃圾回收器回收短生命周期的对象空间。

2.4 赋值

赋值语句用来更新变量所指的值,它最简单的形式由赋值符=,以及符号左边的变量和右边的表达式组成。

go 复制代码
x = 1 // 有名称的变量
*p = true // 间接变量
person.name = "bob" // 结构体成员
count[x] = count[x] * scale // 数组或slice或map的元素

每一个算术和二进制位操作有一个对应的赋值操作符,例如,最后的那个语句可以重写成:

go 复制代码
count[x] *= scale

它避免了在表达式中重复变量本身。

数字变量也可通过++和--语句进行递增和递减:

go 复制代码
v := 1
v++ // 等同于v = v + 1; v变成2
v-- // 等同于v = v - 1; v变成1

2.4.1 多重赋值

多重赋值允许几个变量一次性被赋值。在实际更新变量前,右边所有的表达式被推演,例如当交换两个变量的值时:

go 复制代码
x, y = y, x
a[i], a[j] = a[j], a[i]

或者计算两个整数的最大公约数:

go 复制代码
func gcd(x, y int) int {
    for y != 0 {
        x, y = y, x%y
    }
    return x
}

或者计算斐波那契数列的第n个数:

go 复制代码
func flb(n int) int {
    x, y := 0, 1
    for i := 0; i < n; i++ {
        x, y = y, x+y
    }
    return x
}

多重赋值也可以使一个普通的赋值序列变得紧凑:

go 复制代码
i, j, k = 2, 3, 5

从风格上考虑,如果表达式比较复杂,则避免使用多重赋值形式;一系列独立的语句更易读。

有些表达式产生多个值(例如一个有多个返回值的函数调用),当在一个赋值语句中使用这样的表达式时,左边的变量个数需要和表达式产生的值一样多:

go 复制代码
f, err = os.Open("foo.txt") // 函数调用返回两个值

通常函数使用额外的返回值来指示一些错误情况,例如通过os.Open返回的error类型,或者一个通常叫做ok的bool类型变量。如果map查询(4.3)、类型断言(7.10)、通道接收动作(8.4.2)出现在两个结果的赋值语句中,都会产生一个额外的布尔型结果:

go 复制代码
v, ok = m[key] // map查询
v, ok = x.(T) // 类型断言
v, ok = <-ch // 通道接收

像变量声明一样,可以将不需要的值赋给空标识符:

go 复制代码
_, err = io.Copy(dst, src) // 丢弃字节个数
_, ok = x.(T) // 检查类型但丢弃结果

2.4.2 可赋值性

赋值语句是显式形式的赋值,但是程序中很多地方的赋值是隐式的:一个函数调用隐式地将参数的值赋给对应参数的变量;一个return语句隐式地将return操作数赋值给结果变量。复合类型的字面量表达式,例如slice(4.2):

go 复制代码
medals := []string{"gold", "silver", "bronze"}

隐式地给每一个元素赋值,它可以写成下面这样:

go 复制代码
medals[0] = "gold"
medals[1] = "silver"
medals[2] = "bronze"

map和通道的元素尽管不是普通变量,但它们也遵循相似的隐式赋值。

不管是隐式还是显式赋值,如果左边(变量)和右边(值)的类型相同,它就是合法的。通俗地说,赋值只有在值对于变量类型是可赋值的时才合法。

可赋值性根据类型不同有着不同的规则,我们会在引入新类型的时候解释相应的规则。对已经讨论过的类型,规则很简单:类型必须精准匹配,nil可以被赋给任何接口变量或引用类型。常量(3.6)有更灵活的可赋值性规则来避免显式的转换。

两个值使用==和!=进行比较与可赋值性相关:任何比较中,第一个操作数相对于第二个操作数的类型必须是可赋值的,或者可以反过来赋值。我们也将解释新类型的可比较性的相关规则。

2.5 类型声明

变量或表达式的类型定义这些值应有的特性,例如大小(多少位或多少个元素等)、在内部如何表达、可以对其进行何种操作以及它们所关联的方法。

一些变量使用相同的表达方式,但含义相差非常大。例如int可以用于表示循环的索引、时间戳、文件描述符、月份;float64可以表示每秒多少米的速度或精确到几位小数的温度;string可以表示密码或颜色的名字。

type声明定义一个新的命名类型,它和某个已有类型使用同样的底层类型,这样它们就不会在无意中混用:

go 复制代码
type name underlying-type

类型的声明通常现在包级别,这里命名的类型在整个包中可见,如果名字是导出的(开头使用大写字母),其他的包也可以访问它。

为了说明类型声明,我们把不同计量单位的温度值转换为不同的类型:

go 复制代码
// 包tempconv进行摄氏温度和华氏温度的转换计算
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)
}

这个包定义了两种温度类型,它们分别对应两种温度计量单位。即使使用相同的底层类型float64,它们也是不同的类型,所以它们不能使用算术表达式进行比较和合并。区分这些类型可以防止无意间合并不同计量单位的温度值;从float64转换为Celsius(t)或Fahrenheit(t)需要显式类型转换。Celsius(t)和Fahrenheit(t)是类型转换,而非函数调用。它们不会改变值和表达方式,但改变了显式意义。

对每个类型T,都有一个对应的类型转换操作T(x)将值x转换为类型T。如果两个类型具有相同的底层类型或二者都是指向相同底层类型对象的未命名指针类型,则二者是可以相互转换的。类型转换不改变类型值的表达方式(即底层表达),仅改变类型。如果x对于类型T是可赋值的,类型转换也是允许的,但是通常是不必要的。

数字类型间的转换,字符串和一些slice类型见的转换是允许的。这些转换会改变值的表达方式。例如,从浮点型转换为整型会丢失小数部分,从字符串转换成字节([]byte)slice会分配一份字符串数据副本。任何情况下,运行时的转换不会失败。

命名类型的底层类型决定了它的结构和表达方式,以及它支持的内部操作集合。因此,Celsius和Fahrenheit类型可以使用与float64相同的算术操作符:

go 复制代码
fmt.Printf("%g\n", BoilingC-FreezingC) // "100" ℃
boilingF := CToF(BoilingC)
fmt.Printf("%g\n", boilingF-CToF(FreezingC)) // "180" ℉
fmt.Printf("%g\n", boilingF-FreezingC) // 编译错误:类型不匹配

通过==和<之类的比较操作符,命名类型的值可以与其相同类型的值或者底层类型相同的未命名类型的值相比较(如字面值,否则需要显式转换)。但是不同命名类型的值不能直接比较:

go 复制代码
var c Celsius
var f Fahrenheit
fmt.Println(c == 0) // "true"
fmt.Println(f >= 0) // "true"
fmt.Println(c == f) // 编译错误:类型不匹配
fmt.Println(c == Celsius(f)) // "true"!

注意最后一种情况。无论名字如何,类型转换Celsius(f)没有改变参数的值,只改变其类型。测试结果是真,因为c和f的值都是0。

命名类型提供了概念上的便利,避免重复写复杂的类型。当底层类型是float64这样简单的类型时,好处就不大了。

下面的声明中,Celsius参数c出现在函数名前面,名字叫String的方法关联到Celsius类型,该函数返回c变量的数字值,后面跟着摄氏温度的符号℃:

go 复制代码
// 名为String的方法,作用于Celsius类型上
func (c Celsius) String() string { return fmt.Sprintf("%g℃", c) }

很多类型都声明这样一个String方法,在变量通过fmt包作为字符串输出时,它可以控制类型值的显示方式(7.1):

go 复制代码
c := FToC(212.0)
fmt.Println(c.String()) // "100℃"
fmt.Println(c) // "100℃",fmt.Println以%v输出
fmt.Printf("%v\n", c) // "100℃";不需要显式调用字符串
fmt.Printf("%s\n", c) // "100℃"
fmt.Printf("%g\n", c) // "100";不调用字符串
fmt.Println(float64(c)) // "100";不调用字符串

2.6 包和文件

在Go语言中包的作用和其他语言中的库或模块作用类似,用于支持模块化、封装、编译隔离和重用。一个包的源代码保存在一个或多个以.go结尾的文件中,它所在目录名的尾部就是包的导入路径,例如,gopl.io/ch1/helloworld包的文件存储在目录$GOPATH/src/gopl.io/ch1/helloworld中。

每一个包给它的声明提供独立的命名空间,例如,在image包中,Decode标识符和unicode/utf16包中的标识符一样,但是关联了不同的函数。为了从包外部引用一个函数,我们必须明确修饰标识符来指明所指的是image.Decode或utf16.Decode。

我们可以通过一条简单的规则来管理包中的标识符是否对外可见:导出的标识符以大写字母开头。

假设温度转换软件很受欢迎,我们想把它作为新包贡献给Go社区,要怎么做呢?

我们创建一个叫gopl.io/ch2/tempconv的包,包中假设有两个文件(现实中,这样的小包可能只需要一个文件)。

将类型、它们的常量及方法的声明放在tempconv.go文件中:

go 复制代码
// tempconv包负责摄氏温度与华氏温度的转换
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中:

go 复制代码
package tempconv

// CToF把摄氏温度转换为华氏温度
func CToF(c Celsius) Fahrenheit {
	return Fahrenheit(c*9/5 + 32)
}

// FToC把华氏温度转换为摄氏温度
func FToC(f Fahrenheit) Celsius {
	return Celsius((f - 32) * 5 / 9)
}

在文件开头用package声明定义包的名称。当导入包时,它的成员通过诸如tempconv.CToF等方式被引用。如果包级别的名字(类型、常量)在包的一个文件中声明,包中其他文件也可见。注意,tempconv.go导入fmt包,但conv.go没有,因为它没有用到fmt包,如果conv.go也用到了fmt包,那它也要导入。

因为上例中包级别的常量名字以大写字母开头,因此它们也可通过修饰过的名称来访问:

go 复制代码
fmt.Printf("Brrrr! %v\n", tempconv.AbsoluteZeroC) // "Brrrr! -273.15℃"

为了将某个包里的摄氏温度转换为华氏温度,导入包gopl.io/ch2/tempconv,然后编写以下代码:

go 复制代码
fmt.Println(tempconf.CToF(tempconv.BoilingC)) // "212℉"

package声明前面紧挨着的文档注释(10.7.4)对整个包进行描述。习惯上,应该在开头用一句话对包进行总结性的描述。一个包里只有一个文件应该包含该包的文档注释。扩展的文档注释通常放到一个文件中,按惯例名字叫作doc.go。

练习2.1:添加类型、常量和函数到tempconv包中,处理以开尔文为单位(K)的温度值,0K=-273.15℃,变化1K和变化1℃是等价的:

tempconv.go文件:

go 复制代码
package tempconv

import "fmt"

type Celsius float64
type Fahrenheit float64
type K 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)
}

func (k K) String() string {
	return fmt.Sprintf("%gK", k)
}

conv.go文件:

go 复制代码
package tempconv

// CToF把摄氏温度转换为华氏温度
func CToF(c Celsius) Fahrenheit {
	return Fahrenheit(c*9/5 + 32)
}

// FToC把华氏温度转换为摄氏温度
func FToC(f Fahrenheit) Celsius {
	return Celsius((f - 32) * 5 / 9)
}

// CToK把摄氏温度转换为开尔文温度
func CToK(c Celsius) K {
	return K(c + 273.15)
}

// KToC把开尔文温度转换为摄氏温度
func KToC(k K) Celsius {
	return Celsius(k - 273.15)
}

// FToK把华氏温度转换为开尔文温度
func FToK(f Fahrenheit) K {
	return K((f-32)*5/9 + 273.15)
}

// KToF把开尔文温度转换为华氏温度
func KToF(k K) Fahrenheit {
	return Fahrenheit((k-273.15)*9/5 + 32)
}

2.6.1 导入

Go程序里,每一个包通过导入路径(import path)的唯一字符串来标识。它们出现在诸如"gopl.io/ch2/tempconv"之类的import声明中。Go语言的规范没有定义字符串的具体含义,这依赖于工具来解释。当使用go工具(ch10)时,一个导入路径标注一个目录,目录中包含构成包的一个或多个Go源文件。除了导入路径外,每个包还有一个包名,它以短名字的形式(且不必是唯一的)出现在包的声明中。按约定,包名是导入路径的最后一段,这样可以方便地预测gopl.io/ch2/tempconv的包名是tempconv。

为了使用gopl.io/ch2/tempconv必须导入它:

go 复制代码
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))
	}
}

上例import引入了包名为tempconv的包,导入声明中也可以设定一个可选的包名来避免冲突(10.4)。

执行以上cf程序:

如果导入一个没有被引用的包,会触发错误。这个检查可以帮助消除代码演进过程中不再需要的依赖,但它可能给调试过程带来一些麻烦,如注释掉一条诸如log.Print("got here!")之类的代码,可能去除了对于log包的唯一一个引用,导致编译器报错,此时需要注释掉或删掉不必要的import。

练习2.2:写一个类似cf的单位转换程序,如将英寸和米、磅和千克的转换。

以下是英寸和米的转换程序:

go 复制代码
package lengthconv

import "fmt"

type meter float64
type inch float64

func MToI(m meter) inch {
	return inch(m / 0.0254)
}

func IToM(i inch) meter {
	return meter(i * 0.0254)
}

func (m meter) String() string {
	return fmt.Sprintf("%gm", m)
}

func (i inch) String() string {
	return fmt.Sprintf("%g\"", i)
}

2.6.2 包初始化

包的初始化从包级别的变量开始,这些变量根据依赖的顺序进行初始化,然后按照声明顺序初始化:

go 复制代码
var a = b + c // 最后把a初始化为3
var b = f() // 通过调用f接着把b初始化为2
var c = 1 // 首先初始化为1

func f() int {
    return c + 1
}

如果包由多个.go文件组成,初始化按照编译器收到文件的顺序进行:go工具会在调用编译器前对.go文件排序。

对于包级别的变量,生命周期从其被初始化开始,但对于其他变量,如数据表,初始化表达式由于过于简单,无法设置它的初始值,此时可使用init函数初始化它。任何文件都能包含任意数量的声明如下的函数:

go 复制代码
func init() {
    /* ... */
}

这个init函数不能被调用和被引用,另一方面,它也是普通的函数。在每个文件里,当程序启动时,init函数按照它们声明的顺序自动执行。

包的初始化按照在程序中导入的顺序来进行,依赖顺序优先,每次初始化一个包。如果包p导入了包q,可以确保q在p之前已完全初始化。初始化过程是自下而上的,main包最后初始化。这种方式下,在程序的main函数开始前,所有的包已初始化完毕。

下面的包定义了一个PopCount函数,它返回一个int64数字中被置位的个数,这称为种群统计。它使用init函数针对每一个可能的8位值预计算一个结果表pc,这样PopCount只需要将8个快查表的结果相加而不用进行64步的计算(这不是最快的统计位算法,只是用来方便说明init函数):

go 复制代码
package popcount

// pc[i]是i的种群统计
var pc [256]byte

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

// PopCount返回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))])
}

注意,init中的range循环只使用索引;值不是必需的,所以没必要包含进来。循环可写为以下形式:

go 复制代码
    for i, _ := range pc {

练习2.3:使用循环重写PopCount来代替单个表达式。对比两个版本的效率(11.4会展示如何系统性地对比不同实现的性能)。

循环版:

go 复制代码
// 循环版
func PopCountLoop(x uint64) int {
	ans := 0
	for i := 0; i < 8; i++ {
		ans += int(pc[byte(x>>(i*8))])
	}
	return ans
}

性能对比:

go 复制代码
package main

import (
	"ch2/popcount"
	"fmt"
	"time"
)

func main() {
	start := time.Now()
	for i := uint64(0); i < 1000000; i++ {
		popcount.PopCount(i)
	}
	fmt.Println("no loop version: ", time.Since(start).Nanoseconds(), "ns")

	start = time.Now()
	for i := uint64(0); i < 1000000; i++ {
		popcount.PopCountLoop(i)
	}
	fmt.Println("loop version: ", time.Since(start).Nanoseconds(), "ns")
}

练习2.4:写一个用于统计位的PopCount,它在其实际参数的64位上执行移位操作,每次判断最右边的位,进而实现统计功能。把它与快查表版本的性能进行对比。

统计位版:

go 复制代码
// 位运算版
func PopCountLoopBit(x uint64) int {
	ans := 0
	for i := 0; i < 64; i++ {
		ans += int(((x >> i) & 1))
	}
	return ans
}

性能比较:

go 复制代码
package main

import (
	"ch2/popcount"
	"fmt"
	"time"
)

func main() {
	start := time.Now()
	for i := uint64(0); i < 1000000; i++ {
		popcount.PopCount(i)
	}
	fmt.Println("no loop version: ", time.Since(start).Nanoseconds(), "ns")

	start = time.Now()
	for i := uint64(0); i < 1000000; i++ {
		popcount.PopCountLoopBit(i)
	}
	fmt.Println("loop version: ", time.Since(start).Nanoseconds(), "ns")
}

执行它:

练习2.5:使用x&(x-1)可以消除x最右边的非0位,利用该特点写一个PopCount,然后评价它的性能:

go 复制代码
// x&(x-1)版
func PopCountClearLastOne(x uint64) int {
	ans := 0
	for x != 0 {
		ans++
		x &= (x - 1)
	}
	return ans
}

运行它:

其性能介于2.1和2.2之间,更接近2.1。

2.7 作用域

不要将作用域和生命周期混淆。声明的作用域是声明在程序文本中出现的区域,它是一个编译时属性。变量的生命周期是变量在程序执行期间能被程序的其他部分所引用的起止时间,它是一个运行时属性。

语法块是由大括号围起来的一个语句序列,如一个循环体或函数体。在语法块内部声明的变量对块外部不可见。块把声明包围起来,并决定了它的可见性。

包级别(函数外)的声明,可以被同一个包里的任何文件引用,导入的包是文件级别的,它们可以在同一个文件内引用,但不能在另一个没有导入该包的文件中引用。

一个程序可以包含多个同名声明,前提是它们不在不同块中。例如可以声明一个和包级别变量同名的局部变量。但不要滥用,重声明所涉及的作用域越广,越可能影响其他代码。

当编译器遇到一个名字的引用时,将从最内层的块到全局块寻找其声明。如果没有找到,会报"undeclared name"错误;如果内层和外层块都存在这个声明,内层的将先被找到。此时,内层声明将覆盖外部声明,使它不可访问:

go 复制代码
func f() {}

var g = "g"

func main() {
    f := "f"
    fmt.Println(f) // "f";局部变量f覆盖了包级函数f
    fmt.Println(g) // "g";包级变量
    fmt.Println(h) // 编译错误:未定义h
}

在函数里,块可能嵌套很深,下面程序里有三个称为x的不同变量声明(这个例子只是用来说明作用域的规则,风格并不完美!):

go 复制代码
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"(每次迭代一个字母)
        }
    }
}

有一些块是隐式的,for循环创建两个块,拿上例举例,第一个块是i := 0; i < len(x); i++,第二个块是显式的循环体本身。因此下面的例子也有三个名为x的变量:

go 复制代码
func main() {
    x := "hello"
    for _, x := range x {
        x := x + 'A' - 'a'
        fmt.Printf("%c", x) // "HELLO"(每次迭代一个字母)
    }
}

像for循环一样,除了本身的主体块外,if和switch语句也会创建隐式的块:

go 复制代码
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) // 编译错误:x与y在这里不可见

第二个if语句嵌套在第一个中,所以第一个语句的初始化部分声明的变量在第二个语句中是可见的。

在包级别,声明的顺序不重要,一个声明可以引用它后面的声明。

以下程序中:

go 复制代码
if f, err := os.Open(fname); err != nil { // 编译错误:未使用f
	return err
}
f.Stat() // 编译错误:未定义f
f.Close() // 编译错误:未定义f

f变量的作用域是if语句,所以f不能被接下来的语句访问。根据编译器不同,也可能收到其他报错:局部变量f没有使用。

所以需要在条件判断前声明f,使其在if语句后面可以访问:

go 复制代码
f, err := os.Open(fname)
if err != nil {
    return err
}
f.Stat()
f.Close()

你可能希望避免在外部块中声明f和err,方法是将Stat和Close的调用放到else块中:

go 复制代码
if f, err := os.Open(fname); err != nil {
    return err
} else {
    // f与err在这里可见
    f.Stat()
    f.Close()
}

通常Go中的做法是在if块中处理错误然后返回,这样成功执行的路径不会变得支离破碎。

短变量声明依赖一个明确的作用域。考虑以下程序:

go 复制代码
var cwd string

func init() {
    // 如果cwd是个局部变量,那么会正确地给cwd赋值、给err声明并初始化
    cwd, err := os.Getwd() // 编译错误:未使用cwd
    if err != nil {
        log.Fatalf("os.Getwd failed: %v", err)
    }
}

因为cwd和err在块内都未声明,因此:=语句将它们都声明为局部变量。内层cwd的声明让外部声明不可见,因此这个语句没有按预期更新包级别的cwd变量。

当前Go编译器检测到局部cwd变量没有被使用,然后报错,但是不必严格执行这种检查。我们使用cwd即可通过编译:

go 复制代码
var cwd string

func init() {
    cwd, err := os.Getwd() // 注意:错误
    if err != nil {
        log.Fatalf("os.Getwd failed: %v", err)
    }
    log.Printf("Working directory = %s", cwd)
}

全局cwd仍未初始化,这个日志输出让bug变得不明显。

有很多方法可以处理这种问题,最直接的方法是在另一个var声明中声明二人,避免使用:=:

go 复制代码
var cwd string

func init() {
    var err error
    cwd, err = os.Geted()
    if err != nil {
        log.Fatal("os.Get failed: %v", err)
    }
}
相关推荐
丫头,冲鸭!!!2 分钟前
B树(B-Tree)和B+树(B+ Tree)
笔记·算法
Re.不晚6 分钟前
Java入门15——抽象类
java·开发语言·学习·算法·intellij-idea
听忆.21 分钟前
手机屏幕上进行OCR识别方案
笔记
杜杜的man1 小时前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*1 小时前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
半桶水专家1 小时前
go语言中package详解
开发语言·golang·xcode
llllinuuu1 小时前
Go语言结构体、方法与接口
开发语言·后端·golang
cookies_s_s1 小时前
Golang--协程和管道
开发语言·后端·golang
Selina K1 小时前
shell脚本知识点记录
笔记·shell
王大锤43911 小时前
golang通用后台管理系统07(后台与若依前端对接)
开发语言·前端·golang