小明最近新入职了一家研究区块链技术的 复杂美科技有限公司,岗位职责是用 Go 语言做区块链底层开发。
Go 之前都没接触过,现学现卖咯~这几天上班,小明启动了 3 条工作线程:
- 熟悉公司的产品
- 消化这份 《区块链技术指南》
- 学习 Go 语言
在此,总结下初步学习 Go 语言的一些心得。
考虑到公众号粉丝群众的职业分布,小明温馨提示:
为避免文章内容专业性太强,引起部分用户生理不适,非计算机从业者请直接拉到最下赞赏通道 ^_^ ……
由来
在接触 Go 以前,我用 C/C++、Python 作为后台的主要开发语言。
C/C++ 的问题:
- 开发效率低,对开发者要求高
Python 的问题:
- 动态语言,缺少编译过程,低级错误频出
- 性能差,不适合做高性能服务
我对 Go 的初体验:
- 有 C/Python 基础,学 Go 非常轻松
- 代码简洁,格式统一,阅读方便
- 天生支持高并发
- 略逊于 C/C++ 的高性能 + Python 的高开发效率
- 部署极其方便,不依赖其它库
格式化
格式化问题总是充满了争议,但却始终没有形成统一的定论
- 缩进用空格还是制表符 Tab?
- 大括号
{
要不要另起一行? - 代码结尾需不需要以分号
;
结尾?
虽说人们可以适应不同的编码风格, 但抛弃这种适应过程岂不更好?若所有人都遵循相同的编码风格,在这类问题上浪费的时间将会更少。
问题就在于如何实现这种设想,而无需冗长的语言风格规范。在 Go 中我们另辟蹊径,让机器来处理大部分的格式化问题。
举例来说,你无需花时间将结构体中的字段注释对齐,gofmt 将为你代劳。 假如 teso.go
有以下声明:
1 | type T struct { |
运行 gofmt test.go
,它将按列对齐为:
1 | type T struct { |
发明 go 语言的那帮老爷们简单粗暴终结了这三大影响程序员群体团结的争论:
- 缩进用 Tab
- 大括号
{
不要另起一行 - 代码结尾不要分号
;
包
每个 Go 程序都是由包组成的。
Go 源文件中的第一个语句必须是
1 | package 名称 |
如果是可执行程序,第一行必须是
1 | package main |
程序运行的入口是函数 main
。
1 | package main |
这个程序使用并导入了包 "fmt"
和 "math/rand"
。
按照惯例,包名与导入路径的最后一个目录一致。例如,"math/rand"
包由 package rand
语句开始。
导入
1 | import ( |
这个代码用圆括号组合了导入,这是打包
导入语句。
同样可以编写多个导入语句,例如:
1 | import "fmt" |
不过使用打包的导入语句是更好的形式。
函数
函数可以没有参数或接受多个参数。
1 | func add(x int, y int) int { |
add
接受两个 int 类型的参数。
类型在变量名 之后。(参考 这篇关于 Go 语法定义的文章了解类型以这种形式出现的原因。)
当两个或多个连续的函数命名参数是同一类型,则除了最后一个类型之外,其他都可以省略。
上面这个例子中,
x int, y int
可以被缩写为:
x, y int
多值返回
函数可以返回任意数量的返回值。
1 | func swap(x, y string) (string, string) { |
swap
函数返回了两个字符串。
命名返回值
Go 的返回值可以被命名,并且像变量那样使用。
1 | func split(sum int) (x, y int) { |
返回值的名称应当具有一定的意义,可以作为文档使用。
没有参数的 return
语句返回结果的当前值。也就是直接
返回。
变量
var
语句定义了一个变量的列表,跟函数的参数列表一样,类型在后面。
1 | package main |
就像在这个例子中看到的一样,var
语句可以定义在包
或函数
级别。
初始化变量
变量定义可以包含初始值,每个变量对应一个。
如果初始化是使用表达式,则可以省略类型;变量从初始值中获得类型。
1 | package main |
短声明变量
在函数中,:=
简洁赋值语句在明确类型的地方,可以用于替代 var
定义。
函数外的每个语句都必须以关键字开始(var
、func
等)
:=
结构不能使用在函数外。
1 | func main() { |
基本类型
1 | bool |
零值
变量在定义时没有明确的初始化时会赋值为零值。
- 数值类型为
0
- 布尔类型为
false
- 字符串为
""
(空字符串)
类型转换
表达式 T(v)
将值 v
转换为类型 T
。
一些关于数值的转换:
1 | var i int = 42 |
或者,更加简单的形式:
1 | i := 42 |
与 C 不同的是 Go 在不同类型之间的项目赋值时需要显式转换。
类型推导
在定义一个变量但不指定其类型时(使用没有类型的 var
或 :=
语句), 变量的类型由右值推导得出。
当右值定义了类型时,新变量的类型与其相同:
1 | var i int |
但是当右边包含了未指名类型的数字常量时,新的变量就可能是 int
、float64
或 complex128
,这取决于常量的精度:
1 | i := 42 // int |
常量
常量的定义与变量类似,只不过使用 const
关键字。
常量可以是字符、字符串、布尔或数字类型的值。
常量不能使用 :=
语法定义。
1 | const Pi = 3.14 |
数值常量
数值常量是高精度的值。
一个未指定类型的常量由上下文来决定其类型。
1 | package main |
控制结构
for
Go 只有一种循环结构: for
循环。
基本的 for 循环除了没有了 ( )
之外(甚至强制不能使用它们),看起来跟 C 或者 Java 中做的一样,而 { }
是必须的。
1 | sum := 0 |
跟 C 或者 Java 中一样,可以让前置、后置语句为空。
1 | sum := 1 |
死循环
如果省略了循环条件,循环就不会结束,因此可以用更简洁地形式表达死循环。
1 | for { |
if & else
if 语句除了没有了 ( )
之外(甚至强制不能使用它们),看起来跟 C 或者 Java 中的一样,而 { }
是必须的。(耳熟吗?)
跟 for
一样,if
语句可以在条件之前执行一个简单的语句。
由这个语句定义的变量的作用域仅在 if
范围之内。在 if
的便捷语句定义的变量同样可以在任何对应的 else
块中使用。
1 | func pow(x, n, lim float64) float64 { |
switch
switch 的条件从上到下的执行,当匹配成功的时候停止。
1 | switch i { |
当 i==0
时不会调用 f
。
没有条件的 switch
同 switch true
一样。
这一构造使得可以用更清晰的形式来编写长的 if-then-else
链。
1 | switch { |
defer
defer 语句会延迟函数的执行直到上层函数返回。
延迟调用的参数会立刻生成,但是在上层函数返回前函数都不会被调用。
延迟的函数调用被压入一个栈中。当函数返回时, 会按照 后进先出 的顺序调用被延迟的函数调用。
1 | package main |
输出:
1 | hello |
阅读 博文 了解更多关于 defer 语句的信息。
指针
Go 具有指针。 指针保存了变量的内存地址。
类型 *T
是指向类型 T
的值的指针。其零值是 nil
。
&
符号会生成一个指向其作用对象的指针。
1 | i := 42 |
*
符号表示指针指向的底层的值。
1 | fmt.Println(*p) // 通过指针 p 读取 i |
与 C 不同,Go 没有指针运算。
结构体
一个结构体(struct
)就是一个字段的集合。
结构体文法表示通过结构体字段的值作为列表来新分配一个结构体。
使用 Name:
语法可以仅列出部分字段。(字段名的顺序无关。)
特殊的前缀 &
返回一个指向结构体的指针。
1 | package main |
new
- 返回类型为 T 的指针
- 不会初始化内存,只会将内存置零
make
- 返回类型为 T 的值,不返回指针
- 内存已初始化 (而非置零)
- 它只用于创建切片、映射和信道
数组
类型 [n]T
是一个有 n
个类型为 T
的值的数组。
表达式 var a [10]int
定义变量 a
是一个有十个整数的数组。
数组的长度是其类型的一部分,因此数组不能改变大小。
数组在Go和C中的主要区别。在Go中:
- 数组是值。将一个数组赋予另一个数组会复制其所有元素。
- 若将数组传入某个函数,它将接收到该数组的一份副本而非指针。
- 数组的大小是其类型的一部分。类型 [10]int 和 [20]int 是不同的。
slice
除了矩阵变换这类需要明确维度的情况外,Go中的大部分数组编程都是通过切片来完成的。
切片保存了对底层数组的引用,若你将某个切片赋予另一个切片,它们会引用同一个数组。
若将切片传入某个函数,它将接收到该切片的一份指针。
一个 slice
会指向一个序列的值,并且包含了长度信息。
[]T
是一个元素类型为 T
的 slice
。
slice 的零值是 nil
。
一个 nil
的 slice 的长度和容量是 0。
切片slice
slice 可以重新切片,创建一个新的 slice 值指向相同的数组。
s[lo:hi]
:从 lo
到 hi-1
的 slice 元素,含两端。
s[lo:lo]
是空的,而s[lo:lo+1]
有一个元素。
构造 slice
slice 由函数 make 创建:
1 | a := make([]int, 5) // len(a)=5 |
为了指定容量,可传递第三个参数到 make
:
1 | package main |
向 slice 添加元素
1 | func append(s []T, vs ...T) []T |
第一个参数 s
是一个类型为 T
的数组,其余类型为 T
的值将会添加到 slice
。
append 的结果是一个包含原 slice 所有元素加上新添加的元素的 slice。
如果 s
的底层数组太小,而不能容纳所有值时,会分配一个更大的数组。 返回的 slice 会指向这个新分配的数组。
了解更多关于 slice 的内容,参阅文章 slice:使用和内幕。
range
for 循环的 range 格式可以对 slice 或者 map 进行迭代循环。
可以通过赋值给 _
来忽略序号
或 值
。如果只需要索引值,去掉, value
的部分即可。
1 | package main |
map
map 映射键到值。
map 在使用之前必须用 make
而不是 new
来创建;值为 nil
的 map 是空的,并且不能赋值。
1 | type Vertex struct { |
在 map m 中插入或修改一个元素:
1 | m[key] = elem |
获得元素:
1 | elem = m[key] |
删除元素:
1 | delete(m, key) |
通过 双赋值 检测某个键存在:
1 | elem, ok = m[key] |
如果 key 在 m 中,ok
为 true 。否则, ok 为 false
,并且 elem 是 map 的元素类型的零值。
同样的,当从 map 中读取某个不存在的键时,结果是 map 的元素类型的零值。
闭包
函数也是值。
Go 函数可以是闭包的。闭包是一个函数值,它来自函数体的外部的变量引用。
函数可以对这个引用值进行访问和赋值;换句话说这个函数被“绑定”在这个变量上。
例如,函数 adder 返回一个闭包。每个闭包都被绑定到其各自的 sum 变量上。
1 | package main |
方法
Go 没有类。然而,仍然可以在结构体类型上定义方法。
方法接收者出现在 func
关键字和方法名之间的参数中。
1 | package main |
接口
接口类型是由一组方法定义的集合。
1 | package main |
错误
Go
程序使用 error
值来表示错误状态。
与 fmt.Stringer
类似,error
类型是一个内建接口:
1 | type error interface { |
Readers
io 包指定了 io.Reader
接口,它表示从数据流结尾读取。
Go 标准库包含了这个接口的许多实现, 包括文件、网络连接、压缩、加密等等。
io.Reader 接口有一个 Read 方法:
1 | func (T) Read(b []byte) (n int, err error) |
Read 用数据填充指定的字节 slice,并且返回填充的字节数和错误信息。 在遇到数据流结尾时,返回 io.EOF 错误。
下例创建了一个 strings.Reader。 并且以每次 8 字节的速度读取它的输出。
1 | package main |
Web 服务器
包 http 通过任何实现了 http.Handler
的值来响应 HTTP 请求:
1 | package http |
在这个例子中,类型 Hello 实现了 http.Handler
。
访问 http://localhost:4000/
会看到来自程序的问候。
1 | package main |
goroutine
注意不要混淆并发和并行的概念:
- 并发是用可独立执行的组件构造程序的方法
- 并行则是为了效率在多CPU上平行地进行计算
尽管 Go 的并发特性能够让某些问题更易构造成并行计算, 但 Go仍然是种并发而非并行的语言,且Go的模型并不适合所有的并行问题。 关于其中区别的讨论,见 concurrency-is-not-parallelism
goroutine 是由 Go 运行时环境管理的轻量级线程。
A goroutine has a simple model: it is a function executing concurrently with other goroutines in the same address space。
开启一个新的 goroutine:
1 | go f(x, y, z) |
执行:
1 | f(x, y, z) |
f , x , y 和 z 是当前 goroutine 中定义的,但是在新的 goroutine 中运行 f
。
goroutine 在相同的地址空间中运行,因此访问共享内存必须进行同步。
channel
它将共享的值通过信道传递, 在任意给定的时间点,只有一个Go 程序能够访问该值,数据竞争从设计上就被杜绝了。
不要通过共享内存来通信,而应通过通信来共享内存。
“引用计数”通过为整数变量添加互斥锁来地实现,但作为一种高级方法,通过信道来控制访问能够让你写出更简洁、正确的程序。
channel 是有类型的管道,可以用 channel 操作符 <-
对其发送或者接收值。
1 | ch <- v // 将 v 送入 channel ch。 |
和 map 与 slice 一样,channel 使用前必须创建:
1 | ch := make(chan int) |
channel 可以是 带缓冲的。为 make 提供第二个参数作为缓冲长度来初始化一个缓冲 channel:
1 | ch := make(chan int, 100) |
默认情况下,在另一端准备好之前,发送和接收都会阻塞。这使得 goroutine 可以在没有明确的锁或竞态变量的情况下进行同步。
- 只有缓冲区满的时候发送者阻塞
- 只有缓冲区空的时候接收者阻塞
发送者可以 close
一个 channel 来表示再没有值会被发送了。接收者可以通过赋值语句的第二参数来测试 channel 是否被关闭:当没有值可以接收并且 channel 已经被关闭,那么经过
1 | v, ok := <-ch |
之后 ok 会被设置为 false
。
循环 for i := range c
会不断从 channel 接收值,直到它被关闭。
注意:
只有发送者才能关闭 channel,而不是接收者。向一个已经关闭的 channel 发送数据会引起 panic。
还要注意:
channel 与文件不同;通常情况下无需关闭它们。只有在需要告诉接收者没有更多的数据的时候才有必要进行关闭。
1 | package main |
select
select 语句使得一个 goroutine 在多个通讯操作上等待。
select 会 阻塞,直到条件分支中的某个可以继续执行,这时就会执行那个条件分支。当多个都准备好的时候,会随机选择一个。
默认选择
当 select 中的其他条件分支都没有准备好的时候,default
分支会被执行。
为了非阻塞的发送或者接收,可使用 default 分支:
1 | select { |
总结
现在只是掌握了基本的 go 语法,能看懂代码,以后要到项目中实战才能领略到 go 的威力,慢慢练吸 go 大法吧。