Go 语言自 C 一脉相承,又吸收了些时髦的东西。最重要的是,它依旧简单。本系列文章是《Go 语言学习笔记》一书的读书笔记。
Go 特征简介
- 语法简单:Go 语言与 C99、C11 相似之处颇多,有时候被称为
NextC
。Go 语言从 0 开始,没有历史包袱,在汲取众多经验教训后,可以从头规划一个规则严谨、条例简单的世界 - 并发模型:Goruntime 是 Go 最为显著的特征,它用类似于协程的方式来处理并发单元,却又在运行时层面做了更深度的优化处理。这使得语法上的并发编程变得极为容易,无需处理回调,无须关注运行时切换,仅一个关键字,简单而自然。搭配 channel,实现 CSP 模型,将并发单元间的数据耦合解开,各司其职
- 内存分配:Go 使用了 tcmalloc(为并发而设计的高性能内存分配组件)。除偶尔因性能问题而被迫采用对象池和自主内存管理外,基本无需参与内存管理操作
- 垃圾回收:垃圾回收一直是一个难题,每次升级,垃圾回收器必然是核心组件里修改最多的部分。Go Team 一直在为好用的垃圾回收算法而努力
- 静态链接:Go 刚发布时,静态链接被当做优点宣传,只需编译后的一个可执行文件,无需附加任何东西就能部署。将运行时、依赖库直接打包到可执行文件内部,简化了部署和发布操作。这种简单方式对于编写系统软件有着极大的好处,因为库依赖一直都是一个麻烦事
- 标准库:功能完善、质量可靠的标准库为编程语言提供了充足动力。优秀的第三方资源也是语言生态圈的重要组成部分
- 工具链:完整的工具链对日常开发极为重要,无论是编译、格式化、错误检查、帮助文档、还是第三方包下载、更新,都有对应的工具。内置完整的测试框架,包括单元测试、性能测试、代码覆盖率、数据竞争,以及用来调用的 pprof。除此之外,还可以通过环境变量输出运行时监控信息,尤其是垃圾回收和并发调度跟踪,可以帮助我们改进算法,获得更佳的运行器表现
初识 Go
源文件
- 源文件使用 utf-8 编码,对 unicode 支持良好
- 每个源文件都属于包的一部分,在文件头部用 package 声明所属包的名称
- 以
.go
作为文件扩展名,语句结束分号会被默认省略 - 支持 C 样式注释
- 入口函数 main 没有参数,且必须放在 main 包中
- 用 import 导入标准库或第三方包。如果导入某个包,但是没有使用,编译器会报错
- 可以直接运行,或将源文件编译为可执行文件后运行
以下是 Go 语言的 hello, world
:
1 | package main |
1 | $ ls |
变量
- 使用 var 定义变量,支持类型推断
- 基础数据类型划分清晰明确,有助于编写跨平台应用
- 编译器确保变量总是被初始化为零值,避免出现意外情况
- 在函数内部,还可以省略 var 关键字,使用更简单的定义模式
- 编译器将未使用的局部变量定义为错误
1 | package main |
表达式
Go 仅有三种流控制语句:
- if 语句
1 | package main |
- switch 语句
1 | package main |
- for 语句
1 | package main |
在迭代遍历时,for…range 除元素外,还可以返回索引
1 | package main |
函数
- 函数可以定义多个返回值,甚至对其命名
1 | func div(a, b int) (int, error) { |
- 函数是第一类型,可以将其作为参数或者返回值
1 | package main |
- 用 defer 定义延迟调用,无论函数是否出错,它都确保结束前被调用
1 | package main |
数据
- 切片实现类似动态数组的功能
1 | package main |
- 将字典类型内置,可从运行时层面获得性能优化
1 | package main |
上一个例子中使用了
ok-idiom
模式,在多个返回返回值中使用一个名为 ok 的布尔值来表示操作是否成功。因为很多操作默认都返回零值,所以需要额外说明结构体可以匿名嵌入其他类型,然后直接访问匿名字段的成员
1 | package main |
方法
- 可以为当前包内的任意类型定义方法
1 | package main |
- 可以直接调用匿名字段的方法,这种方式可实现与继承类似的功能
1 | package main |
接口
- 接口采用了 duck type,也就是说无须在实现类型上添加显式声明
1 | package main |
- 空接口类型 interface{} 类似于 OOP 里的 Object 类型,可以接收任意类型对象
并发
- 整个运行时完全并发设计,凡是你能看到的,几乎都在以 goroutine 方式运行
- 这是一种比普通线协程或线程更加高效的设计,能够轻松创建和运行成千上万的并发任务
1 | package main |
- 通道(channel)与 goroutine 搭配,实现用通信代替内存共享的 CSP 模型
1 | package main |
类型
变量
从计算机的角度来看,变量是一段或多段用来存储数据的内存。作为静态数据类型语言,Go 变量总是有固定的数据类型,类型决定了变量内存的长度和存储格式,只能修改变量的值,无法改变类型。实际上,编译后的机器码从不使用变量名,而是直接通过内存地址来访问目标数据。保存在符号表中的变量名等信息可以被删除,或用于输出更详细的错误信息。
变量的定义
- 关键字 var 用于定义变量,类型被放置于变量名之后。另外运行时内存分配操作会确保变量自动初始化为该类型的零值,避免出现不可预测的行为
1 | var x int |
- 如果显式提供初始值,可省略变量类型,由编译器推断
1 | var y = false |
- 可一次定义多个变量,包括用不同初始值定义不同类型
1 | var x, y int |
- 依照惯例,建议以多组方式整理多行变量定义
1 | var ( |
- 除 var 关键字外,还可以使用更加简短的变量定义和初始化语法。简短定义在函数多返回值,以及 if/for/switch 等语句中定义局部变量非常方便
1 | x := 100 |
简短模式有些限制:
1: 定义变量,同时显式初始化
2:不能提供数据类型
3:只能用在函数内部
需要注意,简短模式并不总是重新定义变量,也可能是部分退化的赋值操作。
1 | package main |
1 | $ ./short_var |
退化赋值的前提条件是:最少有一个新变量被定义,且必须是同一作用域。以下两种情况都不是退化赋值:
1 | package main |
1 | $ go build short_var_err1.go |
1 | package main |
1 | $ ./short_var_err2 |
在处理函数错误返回值时,退化赋值允许我们重复使用 err 变量,这简化了代码的写法。
多变量赋值
- 在进行多变量赋值操作时,首先计算出所有右值,然后再依次完成赋值操作
- 赋值操作,必须确保左右值类型相同
未使用错误
- 编译器将未使用的局部变量当做错误,这有助于培养良好的编码习惯
命名
对变量、常量、函数、自定义类型进行命名,通常优先选用有实际含义,易于阅读和理解的字母或单词组合。命名建议:
- 以字母或下划线开始,由字母、数字、下划线构成
- 区分大小写
- 使用驼峰拼写格式
- 局部变量优先使用短名
- 不要使用保留关键字
- 不建议使用与预定义常量、类型、内置函数相同的名字
- 专有名词通常会全部大写
符号名字首字母大小写决定了其作用域,首字母大写的为导出成员,可以被外部包引用,而小写则仅能在包内使用。c
Go 提供了一个名为 _
的特殊成员,通常用作忽略占位符使用,可用作表达式左值,无法读取内容。空标识符可用来临时规避编译器对未使用变量和导入包的错误检查。空标识符是预置成员,不能重新定义。
常量
常量表示运行时恒定不可变的值,通常是一些字面量。常量值必须是编译器可确定的字符、字符串、数字和布尔值。可指定常量类型,或由编译器通过初始化值推断,不支持 C/C++ 数字类型后缀。未曾使用的常量不会引发编译错误。
1 | package main |
如果显式指定类型,必须确保常量的左右值类型一致,需要时可以做显式转换。右值不能超过常量类型取值范围,否则会引发溢出错误。
1 | const ( |
常量值也可以是某些编译器能计算出结果的表达式,如 unsafe.Sizeof, len, cap 等。
在常量组中,如果不指定类型和初始化值,则与上一行非空常量右值相同。
1 | package main |
枚举
Go 并没有明确意义上的 enum 定义,但是通过 itoa 标识符可以实现一组自增常量值,从而实现枚举类型。iota 的值是当前常量表达式在常量组中的索引,从 0 开始计数。
1 | package main |
可以在多常量定义中使用多个 iota,它们各自单独计数,只需要保证每行常量的列数量相同即可。
1 | package main |
如果中断 iota,必须显式恢复。且恢复后的枚举值仍按行序递增(而不是从上一个值开始递增):
1 | package main |
在实际编码中,建议使用自定义类型实现用途明确的枚举类型。但这并不能将取值范围限定在预定义的枚举值内:
1 | package main |
常量小结
- 不同于变量在运行期分配存储内存(非优化状态),常量通常会被编译器在预处理阶段直接展开,作为指令数据使用
- 数字常量不会分配存储空间,无须像变量那样通过内存寻址来取值,因此无法获取地址
- 另外定义常量时,是否指定类型也会对编译器产生影响
1 | package main |
1 | $ go build const3.go |
基本类型
清晰完备的预定义基础类型,使得开发跨平台应用时无需过多考虑符号和长度差异。
类型 | 长度 | 默认值 |
---|---|---|
bool | 1 | false |
byte | 1 | 0 |
int, uint | 4 / 8 | 0 |
int8, uint8 | 1 | 0 |
int16, uint16 | 2 | 0 |
int32, uint32 | 4 | 0 |
int64, uint64 | 8 | 0 |
float32 | 4 | 0.0 |
float64 | 8 | 0.0 |
complex64 | 8 | |
complex128 | 16 | |
rune | 4 | false |
uintptr | 4 / 8 | 0 |
string | “” | |
array | ||
struct | ||
function | nil | |
interface | nil | |
map | nil | |
slice | nil | |
channel | nil |
- 支持八进制、十六进制以及科学计数法,标准库 math 定义了各数字类型的取值范围
- 标准库 strconv 可以在不同进制(字符串)间转换
- 使用浮点数时,需要注意小数位的有效精度问题
别名
byte 其实是 uint8 的别名,而 rune 则是 uint32 的别名。别名类型无须转换,可直接赋值。但是这并不表示,拥有相同底层结构的就属于别名。例如在 64 位体系结构中,int 和 int64 底层结构完全相同,但是分属于不同类型,需显式转换。
引用类型
所谓引用类型,特指 slice、map、channel 这三种预定义类型。相比数字、数组等类型,引用类型拥有更复杂的存储结构,除分配内存外,它们还需要初始化一系列属性,例如指针、长度等。
内置函数 new 按照指定类型长度分配内存(内存中填充该类型的零值),返回指针,它并不关心类型内部构造和初始化方式。而引用类型则必须使用 make 函数创建,编译器会将 make 转换为目标类型专用的创建函数,以确保完成全部内存分配和相关属性初始化。
1 | package main |
类型转换
隐式转换造成的问题远远大于它所带来的好处。除常量、别名类型以及未命名类型外,Go 强制要求使用显式转换。加上不支持操作符重载,所以总是能确定语句及表达式的明确含义。同样,不能把非 bool 类型结果当做 true/false 使用。
如果转换的目标是指针、单向通道或者没有返回值的函数类型,必须使用括号,以避免造成语法分解错误。
1 | x := 100 |
自定义类型
使用关键字 type 定义用户自定义类型,包括基于现有基础类型创建,或者是结构体、函数类型等。和 var、const 一样,多个 type 定义可以合并成组,可以在函数或代码块内定义局部类型。
1 | package main |
即便指定了基础类型,也只表明它们拥有相同的底层数据结构,两者之间不存在任何关系,属于两种完全不同的类型。除操作符外,自定义类型不会继承基础类型的其他信息(包括方法)。不能视作别名,不能隐式转换,不能直接用于比较表达式。
1 | package main |
1 | ./data.go:7:6: cannot use d (type data) as type int in assignment |
与有明确标识符的 bool、int、string 等类型相比,数组、切片、字典、通道等类型与具体元素类型或长度等属性有关,因此称为未命名类型。可以用 type 为其提供具体名称,将其改变为命名类型。具有相同声明的未命名类型被视为同一类型:
- 具有相同基类型的指针
- 具有相同元素类型和长度的数组
- 具有相同元素类型的切片
- 具有相同键值类型的字典
- 具有相同数据类型及操作方向的通道
- 具有相同字段序列(字段名、字段类型、标签以及字段顺序)的结构体
- 具有相同签名(参数和返回值列表,不包括参数名)的函数
- 具有相同方法集(方法名、方法签名,不包括顺序)的接口
未命名类型的转换规则如下:
- 所属类型相同
- 基础类型相同,且其中一个是未命名类型
- 数据类型相同,将双向通道赋值给单向通道,且其中一个为未命名类型
- 将默认值 nil 赋值给切片、字典、通道、指针、函数或接口
- 对象实现了目标接口
表达式
保留字
Go 语言仅包含 25 个保留关键字,这体现了 Go 语法规则的简洁性。
运算符
运算符和表达式用来串联数据和指令,算是最基础的算法。一元运算符的优先级最高,二元则分成 5 个级别,从高到低分别是:
1:* / % << >> & &^
2:+ - | ^
3:== != < <= > >=
4:&&
5:||
相同优先级的二元运算符,从左往右依次计算。对于二元运算符而言,操作数类型必须相同,如果其中一个是无显式类型声明的常量,那么该常量操作数会自动转型。
- 位移右操作数必须是无符号整数,或者可以转换的无显式类型常量。如果是非常量位移表达式,那么会优先将无显式类型的常量左操作数转型:
1 | package main |
1 | # command-line-arguments |
二进制位运算符比较特别的是
bit clear
,即&^
,例如1011 &^ 1101 = 0010
。自增、自减不再是运算符,只能作为独立语句,不能用于表达式(表达式通常是求值代码,可作为右值或参数使用,而语句完成一个行为。表达式可以作为语句使用,而语句却不能作为表达式使用)。
不能将内存地址和指针混淆。内存地址是内存中每个字节单元的唯一编号,而指针则是一个实体。指针会分配内存空间,相当于一个专门用来保存地址的整型变量。取地址运算符
&
用于获取对象的地址,而指针运算符*
用于间接引用目标对象。并非所有对象都能进行取地址操作,变量总是可寻址的。指针类型支持相等运算符,但不能做加减运算和类型转换。如果两个指针指向同一个地址,或者都为 nil,那么它们相等。
可通过
unsafe.Pointer
将指针转换为 uintptr 后进行加减运算,但可能会造成非法访问。unsafe.Pointer
类似于 C 语言中的void*
,可用来转换指针类型,它能安全地持有对象或对象成员,但 uintptr 不行。后者仅仅是一种特殊整型,并不引用目标对象,无法阻止垃圾回收器回收对象内存指针没有专门指向成员的
->
运算符,统一使用.
表达式
1 | package main |
- 零长度对象的地址是否相等和具体的实现相关,但是肯定不等于 nil。
初始化
对复合类型(数组、切片、字典、结构体)变量初始化时,有一些语法限制:
- 初始化表达式必须包含类型标签
- 左花括号必须在类型尾部,不能另起一行
- 多个成员初始值以逗号分隔
- 允许多行,但每行以逗号或者右花括号结束
流控制
if…else 中的条件表达式必须是布尔类型,可省略括号,且左花括号不能另起一行。比较特别的是对初始化语句的支持,可定义块局部变量(有效范围包含整个 if/else 块)或执行初始化函数。
与 if 类似,switch 语句也用于选择执行,但具体使用的场景会有所不同。条件表达式支持非常量,这要比 C 更加灵活。switch 同样支持初始化语句,按照从上到下,从左到右的顺序匹配 case 执行。只有全部匹配失败时,才会执行 default 块。无须显式执行 break 语句,case 执行完毕后自动中断。如须贯通后续 case,须执行 fallthrough,但不再匹配后续条件表达式。注意,fallthrough 必须放在 case 块结尾,可使用 break 语句阻止。
1 | package main |
1 | package main |
需要注意,相邻的空 case 不构成多条件匹配,此时仅表示相应的 case 内容为空。另外,不能出现重复的 case 常量值。
有些时候,switch 还被用来替换 if 语句,被省略的 switch 条件表达式默认为 true,继而与 case 表达式结果匹配:
1 | package main |
仅有 for 一种循环语句,但是常用方式都支持。初始化语句仅被执行一次。
1 | for i := 0; i < 3; i++ { |
可用 for...range
完成数据迭代,支持字符串、数组、数组指针、切片、字典、通道类型,返回索引、键值数据:
1 | package main |
不同于 Python,在 Go 中没有相关接口实现自定义迭代类型,除非基础类型就是以上类型之一。
无论普通 for 循环,还是 range 迭代,其定义的局部变量都会重复使用。另外需要注意,range 会复制目标数据,受直接影响的是数组,可改用数组指针或切片类型。
1 | package main |
运行结果:
1 | ./range |
如果 range 目标表达式是函数调用,也仅被执行一次。
使用 goto 前,必须先定义标签,标签区分大小写,且未使用的标签会引发编译错误。且不能跳转到其他函数或者内层代码块内。和 goto 定点跳转不同,break、continue 用于中断代码块执行:
- break:用于 swtich、for、select 语句,终止整个语句块执行
- continue:仅用于 for 循环,终止后续逻辑,立即进入下一轮循环
配合标签,break 和 continue 可在多层嵌套中指定目标层级:
1 | package main |
1 | $ ./break_label |