注:学习《Go语言圣经》笔记,PDF点击下载,建议看书。
Go语言小白学习笔记,几乎是书上的内容照搬,大佬看了勿喷,以后熟悉了会总结成自己的读书笔记。
声明语句定义了程序的各种实体对象以及部分或全部的属性。 Go语言主要有四种类型的声明语句: var、 const、 type和func, 分别对应变量、 常量、 类型和函数实体对象的声明。 这一章我们重点讨论变量和类型的声明, 第三章将讨论常量的声明, 第五章将讨论函数的声明。
一个Go语言编写的程序对应一个或多个以.go为文件后缀名的源文件中。 每个源文件以包的声明语句开始, 说明该源文件是属于哪个包。 包声明语句之后是import语句导入依赖的其它包,然后是包一级的类型、 变量、 常量、 函数的声明语句, 包一级的各种类型的声明语句的顺序无关紧要( 译注: 函数内部的名字则必须先声明之后才能使用) 。 例如, 下面的例子中声明了一个常量、 一个函数和两个变量:
gopl.io/ch2/boiling
package main
import "fmt"
const boilingF = 212.0
func main() {
var f = boilingF
var c = (f - 32) * 5 / 9
fmt.Printf("boiling point = %g`F or %g`C\n", f, c)
//Output
//boiling point = 212`F or 100`C
}
其中常量boilingF是在包一级范围声明语句声明的, 然后f和c两个变量是在main函数内部声明的声明语句声明的。 在包一级声明语句声明的名字可在整个包对应的每个源文件中访问, 而不是仅仅在其声明语句所在的源文件中访问。 相比之下, 局部声明的名字就只能在函数内部很小的范围被访问。
一个函数的声明由一个函数名字、 参数列表( 由函数的调用者提供参数变量的具体值) 、 一个可选的返回值列表和包含函数定义的函数体组成。 如果函数没有返回值, 那么返回值列表是省略的。 执行函数从函数的第一个语句开始, 依次顺序执行直到遇到renturn返回语句, 如果没有返回语句则是执行到函数末尾, 然后返回到函数调用者。
我们已经看到过很多函数声明和函数调用的例子了, 在第五章将深入讨论函数的相关细节,这里只简单解释下。 下面的fToC函数封装了温度转换的处理逻辑, 这样它只需要被定义一次, 就可以在多个地方多次被使用。 在这个例子中, main函数就调用了两次fToC函数, 分别是使用在局部定义的两个常量作为调用函数的参数。
gopl.io/ch2/ftoc
package main
import "fmt"
func main() {
const freezingF, boilingF = 32.0, 212.0
fmt.Printf("%g`F = %g`C\n", freezingF, fToC(freezingF)) // 32`F = 0`C
fmt.Printf("%g`F = %g`C\n", boilingF, fToC(boilingF)) // 212`F = 100`C
}
func fToC(f float64) float64 {
return (f - 32) * 5 / 9
}
一个变量对应一个保存了变量对应类型值的内存空间。 普通变量在声明语句创建时被绑定到一个变量名, 比如叫x的变量, 但是还有很多变量始终以表达式方式引入, 例如x[i]或x.f变量。所有这些表达式一般都是读取一个变量的值, 除非它们是出现在赋值语句的左边, 这种时候是给对应变量赋予一个新的值。
一个指针的值是另一个变量的地址。 一个指针对应变量在内存中的存储位置。 并不是每一个值都会有一个内存地址, 但是对于每一个变量必然有对应的内存地址。 通过指针, 我们可以直接读或更新对应变量的值, 而不需要知道该变量的名字( 如果变量有名字的话) 。
如果用“var x int”声明语句声明一个x变量, 那么&x表达式( 取x变量的内存地址) 将产生一个指向该整数变量的指针, 指针对应的数据类型是 *int , 指针被称之为“指向int类型的指针”。如果指针名字为p, 那么可以说“p指针指向变量x”, 或者说“p指针保存了x变量的内存地址”。同时 *p 表达式对应p指针指向的变量的值。 一般 *p 表达式读取指针指向的变量的值, 这里为int类型的值, 同时因为 *p 对应一个变量, 所以该表达式也可以出现在赋值语句的左边, 表示更新指针所指向的变量的值。
对于聚合类型每个成员——比如结构体的每个字段、 或者是数组的每个元素——也都是对应一个变量, 因此可以被取地址。
变量有时候被称为可寻址的值。 即使变量由表达式临时生成, 那么表达式也必须能接受 & 取地址操作。
任何类型的指针的零值都是nil。 如果 p != nil 测试为真, 那么p是指向某个有效变量。 指针之间也是可以进行相等测试的, 只有当它们指向同一个变量或全部是nil时才相等。
在Go语言中, 返回函数中局部变量的地址也是安全的。 例如下面的代码, 调用f函数时创建局部变量v, 在局部变量地址被返回之后依然有效, 因为指针p依然引用这个变量。
因为指针包含了一个变量的地址, 因此如果将指针作为参数调用函数, 那将可以在函数中通过该指针来更新变量的值。 例如下面这个例子就是通过指针来更新变量的值, 然后返回更新后的值, 可用在一个表达式中( 译注: 这是对C语言中 ++v 操作的模拟, 这里只是为了说明指针的用法, incr函数模拟的做法并不推荐) :
每次我们对一个变量取地址, 或者复制指针, 我们都是为原变量创建了新的别名。 例如, *p 就是是 变量v的别名。 指针特别有价值的地方在于我们可以不用名字而访问一个变量, 但是这是一把双刃剑: 要找到一个变量的所有访问者并不容易, 我们必须知道变量全部的别名( 译注: 这是Go语言的垃圾回收器所做的工作) 。 不仅仅是指针会创建别名, 很多其他引用类型也会创建别名, 例如slice、 map和chan, 甚至结构体、 数组和接口都会创建所引用变量的别名。
指针是实现标准库中flag包的关键技术, 它使用命令行参数来设置对应变量的值, 而这些对应命令行标志参数的变量可能会零散分布在整个程序中。 为了说明这一点, 在早些的echo版本中, 就包含了两个可选的命令行参数: -n 用于忽略行尾的换行符, -s sep 用于指定分隔字符( 默认是空格) 。 下面这是第四个版本, 对应包路径为gopl.io/ch2/echo4。
gopl.io/ch2/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 变量分别是指向对应命令行标志参数变量的指针, 因此必须用 *sep 和 *n 形式的指针语法间接引用它们。
当程序运行时, 必须在使用标志参数对应的变量之前调用先flag.Parse函数, 用于更新每个标志参数对应变量的值( 之前是默认值) 。 对于非标志参数的普通命令行参数可以通过调用flag.Args()函数来访问, 返回值对应对应一个字符串类型的slice。 如果在flag.Parse函数解析命令行参数时遇到错误, 默认将打印相关的提示信息, 然后调用os.Exit(2)终止程序。
元组赋值是另一种形式的赋值语句, 它允许同时更新多个变量的值。 在赋值之前, 赋值语句右边的所有表达式将会先进行求值, 然后再统一更新左边对应变量的值。 这对于处理有些同时出现在元组赋值语句左右两边的变量很有帮助, 例如我们可以这样交换两个变量的值:
或者是计算两个整数值的的最大公约数( GCD) ( 译注: GCD不是那个敏感字, 而是greatest common divisor的缩写, 欧几里德的GCD是最早的非平凡算法) :
func gcd(x, y int) int {
for y != 0 {
x, y = y, x%y
}
return x
}
或者是计算斐波纳契数列( Fibonacci) 的第N个数:
func fib(n int) int {
x, y := 0, 1
for i := 0; i < n; i++ {
x, y = y, x+y
}
return x
}
但如果表达式太复杂的话, 应该尽量避免过度使用元组赋值; 因为每个变量单独赋值语句的写法可读性会更好。
有些表达式会产生多个值, 比如调用一个有多个返回值的函数。 当这样一个函数调用出现在元组赋值右边的表达式中时( 译注: 右边不能再有其它表达式) , 左边变量的数目必须和右边一致。
通常, 这类函数会用额外的返回值来表达某种错误类型, 例如os.Open是用额外的返回值返回一个error类型的错误, 还有一些是用来返回布尔值, 通常被称为ok。 在稍后我们将看到的三个操作都是类似的用法。 如果map查找 、 类型断言 或通道接收出现在赋值语句的右边, 它们都可能会产生两个结果, 有一个额外的布尔结果表示操作是否成功:
译注: map查找( §4.3) 、 类型断言( §7.10) 或通道接收( §8.4.2) 出现在赋值语句的右边时, 并不一定是产生两个结果, 也可能只产生一个结果。 对于值产生一个结果的情形, map查找失败时会返回零值, 类型断言失败时会发送运行时panic异常, 通道接收失败时会返回零值( 阻塞不算是失败) 。 例如下面的例子:
Go语言中的包和其他语言的库或模块的概念类似, 目的都是为了支持模块化、 封装、 单独编译和代码重用。 一个包的源代码保存在一个或多个以.go为文件后缀名的源文件中, 通常一个包所在目录路径的后缀是包的导入路径; 例如包gopl.io/ch1/helloworld对应的目录路径是$GOPATH/src/gopl.io/ch1/helloworld。
每个包都对应一个独立的名字空间。 例如, 在image包中的Decode函数和在unicode/utf16包中的 Decode函数是不同的。 要在外部引用该函数, 必须显式使用image.Decode或utf16.Decode形式访问。
包还可以让我们通过控制哪些名字是外部可见的来隐藏内部实现信息。 在Go语言中, 一个简单的规则是: 如果一个名字是大写字母开头的, 那么该名字是导出的( 译注: 因为汉字不区分大小写, 因此汉字开头的名字是没有导出的) 。
为了演示包基本的用法, 先假设我们的温度转换软件已经很流行, 我们希望到Go语言社区也能使用这个包。 我们该如何做呢?
让我们创建一个名为gopl.io/ch2/tempconv的包, 这是前面例子的一个改进版本。 ( 我们约定我们的例子都是以章节顺序来编号的, 这样的路径更容易阅读) 包代码存储在两个源文件中, 用来演示如何在一个源文件声明然后在其他的源文件访问; 虽然在现实中, 这样小的包一般只需要一个文件。
我们把变量的声明、 对应的常量, 还有方法都放到tempconv.go源文件中:
gopl.io/ch2/tempconv
// 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
// 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.CToF的形式访问。 而包级别的名字, 例如在一个文件声明的类型和常量, 在同一个包的其他源文件也是可以直接访问的, 就好像所有代码都在一个文件一样。 要注意的是tempconv.go源文件导入了fmt包, 但是conv.go源文件并没有, 因为这个源文件中的代码并没有用到fmt包。
要将摄氏温度转换为华氏温度, 需要先用import语句导入gopl.io/ch2/tempconv包, 然后就可以使用下面的代码进行转换了:
在每个源文件的包声明前仅跟着的注释是包注释( §10.7.4) 。 通常, 包注释的第一句应该先是包的功能概要说明。 一个包通常只有一个源文件有包注释( 译注: 如果有多个包注释, 目前的文档工具会根据源文件名的先后顺序将它们链接为一个包注释) 。 如果包注释很大, 通常会放到一个独立的doc.go文件中。
在Go语言程序中, 每个包都是有一个全局唯一的导入路径。 导入语句中类似"gopl.io/ch2/tempconv"的字符串对应包的导入路径。 Go语言的规范并没有定义这些字符串的具体含义或包来自哪里, 它们是由构建工具来解释的。 当使用Go语言自带的go工具箱时( 第十章) , 一个导入路径代表一个目录中的一个或多个Go源文件。
除了包的导入路径, 每个包还有一个包名, 包名一般是短小的名字( 并不要求包名是唯一的) , 包名在包的声明处指定。 按照惯例, 一个包的名字和包的导入路径的最后一个字段相同, 例如gopl.io/ch2/tempconv包的名字一般是tempconv。
要使用gopl.io/ch2/tempconv包, 需要先导入:
gopl.io/ch2/cf
// Cf converts its numeric argument to Celsius and Fahrenheit.
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))
}
}
导入语句将导入的包绑定到一个短小的名字, 然后通过该短小的名字就可以引用包中导出的全部内容。 上面的导入声明将允许我们以tempconv.CToF的形式来访问gopl.io/ch2/tempconv包中的内容。 在默认情况下, 导入的包绑定到tempconv名字( 译注: 这包声明语句指定的名字) , 但是我们也可以绑定到另一个名称, 以避免名字冲突 。
如果导入了一个包, 但是又没有使用该包将被当作一个编译错误处理。 这种强制规则可以有效减少不必要的依赖, 虽然在调试期间可能会让人讨厌, 因为删除一个类似log.Print(“gothere!”)的打印语句可能导致需要同时删除log包导入声明, 否则, 编译器将会发出一个错误。在这种情况下, 我们需要将不必要的导入删除或注释掉。
不过有更好的解决方案, 我们可以使用golang.org/x/tools/cmd/goimports导入工具, 它可以根据需要自动添加或删除导入的包; 许多编辑器都可以集成goimports工具, 然后在保存文件的时候自动运行。 类似的还有gofmt工具, 可以用来格式化Go源文件。
如果包中含有多个.go源文件, 它们将按照发给编译器的顺序进行初始化, Go语言的构建工具首先会将.go文件根据文件名排序, 然后依次调用编译器编译。
对于在包级别声明的变量, 如果有初始化表达式则用表达式初始化, 还有一些没有初始化表达式的, 例如某些表格数据初始化并不是一个简单的赋值过程。 在这种情况下, 我们可以用一个特殊的init初始化函数来简化初始化工作。 每个文件都可以包含多个init初始化函数
这样的init初始化函数除了不能被调用或引用外, 其他行为和普通函数类似。 在每个文件中的init初始化函数, 在程序开始执行时按照它们声明的顺序被自动调用。
每个包在解决依赖的前提下, 以导入声明的顺序初始化, 每个包只会被初始化一次。 因此,如果一个p包导入了q包, 那么在p包初始化的时候可以认为q包必然已经初始化过了。 初始化工作是自下而上进行的, main包最后被初始化。 以这种方式, 可以确保在main函数执行之前, 所有依赖的包都已经完成初始化工作了。
下面的代码定义了一个PopCount函数, 用于返回一个数字中含二进制1bit的个数。 它使用init初始化函数来生成辅助表格pc, pc表格用于处理每个8bit宽度的数字含二进制的1bit的bit个数, 这样的话在处理64bit宽度的数字时就没有必要循环64次, 只需要8次查表就可以了。 ( 这并不是最快的统计1bit数目的算法, 但是它可以方便演示init函数的用法, 并且演示了如果预生成辅助表格, 这是编程中常用的技术) 。
gopl.io/ch2/popcount
package popcount
// pc[i] is the population count of i.
var pc [256]byte
func init() {
for i := range pc {
pc[i] = pc[i/2] + byte(i&1)
}
}
// PopCount returns the population count (number of set bits) of 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)])
}