入门 Go 语言需要多久?答案是 —— 读完这篇文章的时间!不妨找一个周末的下午,踏上 Go 之旅吧!
更新记录:
- 2016.12.12: 完成重制
- 2016.11.02: 增加重点理解和参考链接
- 2016.08.11: 完成初稿
任务目标
- 了解 Go 的设计哲学和与其他面向对象语言在编程思路上的不同
- 掌握基本的语法和基本的数据类型与结构
- 完成开发环境的安装和配置
- 了解包的概念
- 学习如何搜索相关库
写在前面
最初知道 Go,自然是因为是 Google 创造的语言,还想着按照这样的套路,百度可以出个 Ba,腾讯可以出个 Te,阿里可以出个 Al,我自己可以出一个 Wd(虽然并没有这个打算)。后来仔细了解了一下,觉得『嘿,这真是个好东西』,找回了当年学 C 的感觉不说,当时的各种痛点基本都解决了。而工作之后被动态语言折磨得死去活来,也让我更渴望 Keep It Simple Stupid 的编程语言及配套工具。
软件工程随着时间的推移为了照顾之前的旧代码旧设计积累了太多太多的繁文缛节,把原来大道至简的计算机科学弄成了拄着几十条拐杖的老头:
- 为什么我们需要几十上百个保留字?
- 为了省几行代码创造各种语法糖,为此增加如此多的记忆成本有多少意义?
- 为什么我们需要一层一层封装,像制造洋葱一样写代码?
- 我们是不是被面向对象洗脑了,很多事情其实用函数式思维来解决更好不是么?
重点是,这些东西本质上没有办法减少任何实际问题的难度,为什么不能简简单单干净漂亮地把事情做好,而是去追求所谓大而全呢?一门好的编程语言应该是程序员的武器,而不是负担;应该是辅助思考的工具,不是记忆成本。人总是会犯错的,正道并不是通过各种各样的封装把问题『藏』起来,而是干脆暴露出来,解决它们。
我非常推崇 Unix 的编程哲学:追求简洁、清晰、透明、低复杂度,通过拼接组合功能,避免标新立异。而 Go 作为 Unix 哲学的继承和实践者,无疑是一种大道至简、重剑无锋价值观的回归。
废话不多说,我们赶紧开始吧!(注:本文的实例较少,更多实例可以参考 IV 我的笨办法学 Go)
安装
Linux
这里以 Ubuntu 为例(毕竟现在的云服务器基本都是 Ubuntu Linux)进行讲解,安装非常简单,直接上命令:
|
如果最后一条命令会显示 go 版本,那么第一步配置就完成了。
第二步我们需要配置 $GOPATH
这个环境变量,这个变量类似于指定 Go 项目的 workspace,比方说新建一个 ~/Go
文件夹,然后在 ~/.bashrc
中添加 export GOPATH=$HOME/Go
即可(别忘了 source ~/.bashrc
)
好消息是,并没有第三步,我们可以开始 Hello World 了!
Mac
Mac 下的安装和 Linux 相比更为简单一些,因为有直接的安装包,双击然后一路下一步就好。不过这里我们还是绕点远路,配合 zsh 把 Go 环境搭建起来。上命令!
|
如果最后一条命令会显示 go 版本,那么第一步配置就完成了。
第二步我们需要配置 $GOPATH
这个环境变量,这个变量类似于指定 Go 项目的 workspace,比方说新建一个 ~/Go
文件夹,然后在 ~/.zshrc
中添加 export GOPATH=$HOME/Go
即可(别忘了 source ~/.zshrc
)
好消息是,并没有第三步,我们可以开始 Hello World 了!
更新
本系列写了没几天 Go 就更新到了 1.7 版本,所以这里也更新一下,方法很简单,直接下载覆盖即可
|
应该可以正常看到输出为 go version go1.7 darwin/amd64
了,即更新完成。
卸载与 QA
最后提一下删除 Go 的方法,把对应的文件夹和配置文件清理掉即可,非常绿色。
最后的最后,遇到问题可以在 Go Nuts(需要FQ),进行提问,也可以在此了解各类最新动态。
开发环境配置
虽然我们可以直接根据使用命令行和文本编辑器进行编程,但是有了 IDE 的帮助,除了代码高亮之外,智能提示也是很好的辅助。这里我们没有选用诸如 Eclipse + 插件这样的解决方案,而是直接用跨平台的的 Visual Studio Code 配合 Go 自带的一些工具完成一个全功能的 IDE 搭建。
配置很简单,在 VSCode 的应用商店中搜索 Go for Visual Studio Code
扩展,安装完成之后配置对应的 GOPATH
并安装指定的应用包,即可拥有以下功能(对我来说已经足够了)
- Completion Lists (using
gocode
) - Signature Help (using
godoc
) - Snippets
- Quick Info (using
godef
) - Goto Definition (using
godef
) - Find References (using
guru
) - File outline (using
go-outline
) - Workspace symbol search (using
go-symbols
) - Rename (using
gorename
) - Build-on-save (using
go build
andgo test
) - Lint-on-save (using
golint
orgometalinter
) - Format (using
goreturns
orgoimports
orgofmt
) - Generate unit tests squeleton (using
gotests
) - Add Imports (using
gopkgs
) - Debugging (using
delve
)
从代码自动排版到错误提示一应俱全,与此同时非常轻量,可谓居家旅行必备神器。
常用命令
Go 已经自带了很多非常好用的工具,也可以通过简单的命令进行调用,当然也可以据此轻松配置自己喜欢的编辑器。完整的命令列表可以通过输入 go
来查看,这里简单介绍一下。
go build hello.go
就可以编译出最终执行文件,这样直接执行./hello
就可以看到结果go clean
可以清理编译后的文件go doc fmt
可以查看 fmt 包的文档go env
显示 Go 相关的环境变量go fmt
利用 gofmt 工具自动排版代码go get
下载并安装 packagego install
编译并安装 packagego list
列出 packagego run hello.go
编译并运行 Go 程序go test fmt
测试 fmt packagego tool
运行指定的 Go 工具,包括 addr2line, asm, cgo, compile, cover, dist, doc, fix, link, nm, objdump, pack, pprof, tour, trace, vet, yacc
其实这些工具已经基本上集成到 Visual Studio 的 Go 插件中了,只要简单配置一下,就可以自动运行或者通过快捷键调用。
Hello World
环境配置好了,就可以来写我们的第一个 Go 程序了,在 ~/Go
文件夹下新建一个名为 hello.go
的文件,内容为
|
然后我们执行 go run hello.go
就可以看到输出了
|
从这个简单的程序中,我们知道:
- 非注释的第一行代码定义包名,每个程序属于一个 package。每个 Go 应用都包含一个名为 main 的包
- 用
import
关键字来引用包,这里的fmt
包含了格式化输入输出的相关函数 - 用
func
关键词来声明函数,而main
函数是每一个可执行程序必须包含的,一般来说会最先执行(有init()
函数除外) - 和 C 语言一样,用
//
来进行单行注释,用/* ... */
来进行多行注释 - 不用分号
- 当标识符(包括常量、变量、类型、函数名、结构字段等等)以一个大写字母开头,如:Group1,那么使用这种形式的标识符的对象就可以被外部包的代码所使用(客户端程序需要先导入这个包),这被称为导出(像面向对象语言中的 public);标识符如果以小写字母开头,则对包外是不可见的,但是他们在整个包的内部是可见并且可用的(像面向对象语言中的 private )
很简单对不对!接下来我们先简单了解一下 Go 的设计哲学,然后就正式进入快速入门教程。
设计哲学
在这里大言不惭『设计哲学』虽然颇为不自量力,但是一脉相承于 Unix 的哲学让我不得不写点什么。
Go 从设计之初就力求干净与自然,避免令人困惑,就好像一个刚从学校走出来的小姑娘,看一眼就让人喜欢。没有太多花里胡哨的东西,就是简简单单的整洁大方。
不到 30 个关键字很快就可以上手;即可以面向过程也可以面向对象;非侵入式接口无须声明直接实现即可;基于 CSP 的 goroutine 简单明快。
不再有什么继承什么构造析构函数什么虚函数什么函数重载,简简单单的组合和空接口就可以实现,正所谓大道至简。
有错误当即解决,不再一层一层 try catch,写个代码就不要像鸵鸟一样了嘛。丰富的标准库基本已经为我们准备好了十八般武器。灵活的指针和极快的编译速度,让静态语言有了动态语言的感觉
叫我怎么不爱 Go!
基本语法
基本语法其实非常简单清晰,这里直接以要点的形式列出:
- 一行一个语句,不用写分号。如果一行写多个语句,需要用分号隔开,但是并不鼓励这种做法
- 标识符的第一个字符必须是字母或者下划线,从第二个开始才能用数字
- 行注释以
//
开始,块注释为/**/
- Go 中有 25 个关键字:break, default, func, interface, select, case, defer, go, map, struct, chan, else, goto, package, switch, const, fallthrough, if, range, type, continue, for, import, return var
- Go 中有 36 个预定义标识符:append, bool, byte, cap, close, complex, complex64, complex128, uint, uint8, uint16, uint32, uint64, uintptr, copy, false, float32, float64, imag, int, int8, int16, int32, int64, iota, len, make, new, nil, panic, print, println, real, recover, string, true
- 空标识符
_
是一个占位符,可以用来丢弃不需要的值 - 数据类型有以下几种,非常简洁:
- 布尔型
bool
,值为 true 或者 false - 数字类型
int
,float
,原生支持复数,如果后面跟了数字,就是指位数 - 字符串类型
string
,用 UTF8 编码 - 派生类型:指针、数组、结构体、联合、函数、切片、接口、Map、Channel
- 类型转换采用 type(value) 的形式,只要合法,就一定会转换成功,哪怕会有精度丢失
- 几个比较特殊的:
byte
类似uint8
rune
类似int32
uint
32 位或 64 位int
与uint
大小一样uintptr
无符号整型,用于存放一个指针
- 布尔型
- 变量声明使用
var
关键字,模板为var identifier type
,也就是类型在后面,比如var a int
标准声明,使用默认值 0var b int = 10
声明且赋值var c = 10
不指明类型,根据赋值类型自动判断d := 10
省略var
而使用:=
,这里的d
不能是已经声明过的- 可以用
&
来取得值对应的地址(也就是指针),这个后面会详细介绍 - Go 会自动用 0 或空字符串来初始化
- 常量声明使用
const
关键字,模板为const identifier [type] = value
,其中类型是可选的,因为 Go 可以自动推断出类型,比如const a string = "hello"
显式定义const b = "world"
隐式定义
- 特殊常量
iota
,每一个 const 出现是会被重置为 0,每出现一次iota
,其值会加一,可以用作枚举值
因为文字描述比较模糊,这里给出一个 iota
的用法
|
对应的输出为 0 6 2 100 4 5 6
,请仔细感受一下这个加一的过程。Go 的运算符也比较『正常』,这里简单点一下
- 算术运算符:
+
,-
,*
,/
,%
,++
,--
- 关系运算符:
==
,!=
,>
,<
,>=
,<=
- 逻辑运算符:
&&
,||
,!
- 赋值运算符:
=
,+=
,-=
,*=
,/=
,%=
,<<=
,>>=
,&=
,^=
,|=
- 位运算符:
&
,|
,^
,<<
,>>
- 其他运算符:
&
(返回变量的存储地址),*
指针变量
运算符优先级也没有什么特别的地方,正常用一般不会有太多『意外』。如果上面的内容令你有些困惑,不要紧,接下来会简单进行介绍,但是最快最准确的方法,是去官方文档里查阅对应内容。
整型与浮点数
Go 中提供了 11 种整型,包括 5 种有符号的和 5 种无符号的,再加上 1种用于存储指针的整型类型。byte
相当于 unit8
,单个字符(即 Unicode 码点)提倡使用 rune
来代替 int32
,不过一般来说我们只需要使用 int
即可,会根据平台来自动决定位数。
要处理大整数时,我们可以使用 big.Int
或 big.Rat
类型,但是处理的速度要比 int
慢得多。
Go 中提供了 2 种类型的浮点类型和 2 种类型的复数类型。一般我们会使用 math
包来处理 float64
类型的数据。对于复数类型,我们一般使用 math/cmplx
包来处理,默认类型是 complex128
字符串
字符串的处理我们一般使用 strings
和 strconv
这两个包,如果要处理 UTF-8,那么 utf8
是需要了解的。Go 中的字符串都是以 UTF-8 编码的 Unicode 文本,虽然这样可能带来的问题是我们不再能够用数组下标来定位某个字符,但是我们可以通过码点切片([]rune
)来进行索引。
一些常见的操作有:
s[n]
字符串 s 中索引位置为 n(uint8 类型)处的原始字节s[n:m]
从位置 n 到位置 m-1 处取得的字符串len(s)
字符串 s 中的字节数len([]rune(s))
字符串 s 中字符的个数,使用utf8.RuneCountInString()
会更快[]rune(s)
将字符串 s 转换成一个 Unicode 码点string(char)
将一个[]rune
或者[]int32
转换成字符串,这里需要保证都是码点[]byte(s)
无副本地将字符串 s 转换成一个原始字节的切片数组
Go 中的字符串比较实际上是在内存中一个字节一个字节地比较字符串。对于字符串操作,有一个很常见的场景是把多个字符串拼接起来,除了使用 +=
操作符,Go 中还有两种比较好的方式:
- 准备好一个字符串切片(
[]string
),然后使用strings.Join()
函数一次性完成串联 - 使用
bytes.Buffer
的WriteString()
方法把我们需要的内容写入到 buffer 中,然后使用bytes.Buffer.String()
方法生成字符串
如果需要格式化字符串,我们一般使用 fmt
包,格式指令主要有:
%b
一个二进制的整数值%c
一个 Unicode 字符的码点值%d
一个十进制数值%e
/%E
以科学计数法 e/E 表示的值%f
一个浮点数值%o
一个八进制表示的数字%p
一个十六进制表示的值的地址%s
字符串%t
使用 true 或 false 输出布尔值
其他常用的字符串相关的包有 unicode
和正则表达式包 regexp
指针
Go 具有指针。 指针保存了变量的内存地址。类型 *T
是指向类型 T
的值的指针。其零值是 nil
。例如:var p *int
&
符号会生成一个指向其作用对象的指针。
|
*
符号表示指针指向的底层的值。
|
这也就是通常所说的“间接引用”或“非直接引用”。与 C 不同,Go 没有指针运算。
|
数组与切片
Go 中的数组是按值传递的,也就是说会复制一份,所以传递大数组开销很大,不过我们一般都使用切片,因为传递一个切片的成本很低。这里需要强调两个符号:
&
作为一元操作符,会取得对应变量的地址,常被称为取址操作符*
作为一元操作符,会返回其保存的地址所指向的内存的值,常被称为内容操作符、间接操作符或者解引用操作符
new(Type)
和 &Type{}
是等价的,都会分配一个 Type 类型的空值,并返回一个指向该值的指针。
创建数组的语法为:
|
一般来说,切片比数组更加灵活、强大且方便,创建切片的语法为:
|
但是实际上切片的底层仍然是一个固定长度的数组,但是会自动根据我们的需求来进行扩展核收缩。下面是一些示例:
|
slice 由函数 make 创建。这会分配一个零长度的数组并且返回一个 slice 指向这个数组: a := make([]int, 5) // len(a)=5
为了指定容量,可传递第三个参数到 make
:
|
例子
|
slice 的零值是 nil
。一个 nil 的 slice 的长度和容量是 0。
|
向 slice 添加元素是一种常见的操作,因此 Go 提供了一个内建函数 append
。 内建函数的文档对 append 有详细介绍。func append(s []T, vs ...T) []T
- append 的第一个参数 s 是一个类型为 T 的数组,其余类型为 T 的值将会添加到 slice。
- append 的结果是一个包含原 slice 所有元素加上新添加的元素的 slice。
- 如果 s 的底层数组太小,而不能容纳所有值时,会分配一个更大的数组。 返回的 slice 会指向这个新分配的数组。
|
for 循环的 range 格式可以对 slice 或者 map 进行迭代循环。可以通过赋值给 _
来忽略序号和值。如果只需要索引值,去掉“, value”的部分即可。
|
如果想要排序和搜索切片,一般使用 sort
包来进行对切片的排序和搜索。如果要对自定义的结构体排序,只需要对应实现 Len()
, Less()
和 Swap()
三个函数。
映射
和 Map/Dictionary 类似,保存键值对的无序集合,所有的键需要是唯一的而且必须支持 ==
和 !=
操作,一些常用的操作有:
m[k] = v
创建一个 k-v 的映射记录,如果已存在,则更新数据Delete(m, k)
删除 m 中键为 k 的映射v := m[k]
取出 m 中键为 k 的映射,赋值给 vv, found := m[k]
取出 m 中键为 k 的映射,复制为 v,found
用来表示映射否存在len(m)
返回 m 中 k-v 映射记录的个数
映射可以通过如下方式创建:
|
一些例子为:
|
在 map m 中插入或修改一个元素:m[key] = elem
。获得元素:elem = m[key]
。删除元素:delete(m, key)
。通过双赋值检测某个键存在:elem, ok = m[key]
如果 key 在 m 中,ok
为 true 。否则, ok 为 false
,并且 elem 是 map 的元素类型的零值。同样的,当从 map 中读取某个不存在的键时,结果是 map 的元素类型的零值。
|
如果我们要按顺序遍历一个 map,那么可以先把所有的 key 取出来放到一个切片中,排序之后,然后再一个一个取出来。
类型转换与断言
Go 可以在相互兼容的数据类型中进行类型转换,对于非数值类型不会丢失精度,对于数值类型可能会丢失精度。转换的方式很简单:
|
一个字符串可以转换成一个 []byte
或者一个 []rune
,也可以进行反过来的转换。
除了类型转换,另一个很有用的特性是类型断言。在 Go 中 interface{}
类型用于表示空接口,实际上可以用于表示任意 Go 类型的值。于是我们可以使用类型开关、类型断言或者 reflect
包进行类型检查,然后把数据转换成我们需要的值,比如:
|
比如
|
分支语句
Go 中的条件语句主要分三种:if
, switch
和 select
,比较特别的是 select
,会随机执行一个可运行的 case。如果没有 case 可运行,它将阻塞,直到有 case 可运行。
if
if 语句除了没有了 ( )
之外(甚至强制不能使用它们),看起来跟 C 或者 Java 中的一样,而 { }
是必须的。if
语句可以在条件之前执行一个简单的语句。由这个语句定义的变量的作用域仅在 if 范围之内。在 if 的便捷语句定义的变量同样可以在任何对应的 else 块中使用。
|
switch
对于 switch 语句来说,除非以 fallthrough 语句结束,否则分支会自动终止。switch 的条件从上到下的执行,当匹配成功的时候停止。
|
没有条件的 switch 同 switch true
一样。这一构造使得可以用更清晰的形式来编写长的 if-then-else 链。
|
switch 用于类型开关
switch 还可以用于类型开关,帮助我们处理不同类型的数据,直接看一个例子就很清晰了:
|
但大部分 Go 程序应该都不需要类型断言和类型开关,即使需要,应该也很少用到。其中一个使用案例是,我们传入一个满足某个接口的值,同时想检查下它是否满足另外一个接口。另一个使用案例是,数据来自于外部源但必须转换成 Go 语言的数据类型。为了简化维护,最好总是将这些代码与其他程序分开。这样就使得程序完全地工作于 Go 语言的数据类型之上,也意味着任何外部源数据的格式或类型改变所导致的代码维护工作可以控制在小范围内。
select
select 是 Go 中的一个控制结构,类似于用于通信的 switch 语句。每个 case 必须是一个通信操作,要么是发送要么是接收。select 随机执行一个可运行的 case。如果没有 case 可运行,它将阻塞,直到有 case 可运行。一个默认的子句应该总是可运行的。例如
|
其中:
- 每个case都必须是一个通信
- 所有channel表达式都会被求值
- 所有被发送的表达式都会被求值
- 如果任意某个通信可以进行,它就执行;其他被忽略。
- 如果有多个case都可以运行,Select会随机公平地选出一个执行。其他不会执行。否则:
- 如果有default子句,则执行该语句。
- 如果没有default字句,select将阻塞,直到某个通信可以运行;Go不会重新对channel或值进行求值。
循环语句
Go 只有一种循环结构——for
循环。基本的 for 循环除了没有了 ( )
之外(甚至强制不能使用它们),看起来跟 C 或者 Java 中做的一样,而 { }
是必须的。跟 C 或者 Java 中一样,可以让前置、后置语句为空。基于此可以省略分号:C 的 while 在 Go 中叫做 for
。如果省略了循环条件,循环就不会结束,因此可以用更简洁地形式表达死循环。
|
并发与通信
goroutine 是程序中与其他 goroutine 完全相互独立而并发执行的函数或者方法调用。每一个 Go 程序都至少有一个 goroutine,即会执行 main
包中的 main()
函数的主 goroutine。goroutine 非常像轻量级的线程或者协程,可以大批量被创建,并共享相同的地址空间,同时 Go 提供了锁原语来保证数据能够安全的跨 goroutine 共享。不过我们推荐使用通信来进行并发编程。
Go 语言的通道是一个双向或者单向的通信管道,它们可用于在两个或者多个 goroutine 之间通信。但是需要注意的是,优秀的程序员只有在并发程序带来的优点明显超过其所带来的负担才编写并发程序。
可以使用以下语句创建 goroutine:
|
被调用函数的执行会立即进行,但它是在另一个 goroutine 上执行,并且当前 goroutine 的执行会从下一条语句中立即恢复。不同 goroutine 协作的通信语法为:
|
非阻塞的发送可以使用 select
语句来达到,或者在一些情况下使用带缓冲的通道。通道的创建语法为:
|
我们来看一个简单的例子:
|
函数
函数可以没有参数或接受多个参数,注意类型名在变量名之后。当两个或多个连续函数的命名参数是同一类型,则除了最后一个类型之外,其他都可以省略,函数可以返回任意数量的返回值,比如 swap
函数
Go 的返回值可以被命名,并且像变量那样使用。返回值的名称应当具有一定的意义,可以作为文档使用。没有参数的 return 语句返回结果的当前值。也就是直接
返回。直接返回语句仅应当用在像下面这样的短函数中。在长的函数中它们会影响代码的可读性。
在函数中,:=
简洁赋值语句在明确类型的地方,可以用于替代 var 定义。函数外的每个语句都必须以关键字开始(var
、func
、等等),:=
结构不能使用在函数外。
|
defer, panic 和 recover
defer 语句会延迟一个函数的执行,会在外围函数返回之前但是返回值计算之后执行。如果一个函数中有多个 defer 语句,会以后进先出的顺序执行,一个最常用的应用是用完文件后将其关闭。
Go 语言中的错误处理的惯用方法是将错误以函数或者方法的最后一个返回值的形式将其返回,并在调用它的地方检查返回的错误值。
而 panic
则用于处理那些『不可能』发生的事情,在早期开发阶段这是很好的特性,但是一旦上线运行,要尽量保证程序运行,就要配合 recover
使用。当 panic()
函数被调用时,外围函数或者方法的执行会立即中止,然后延迟执行的方法都会被调用。一层一层往上,直到 main
函数不再有可以返回的调用者,就把调用栈信息输出到 os.Stderr
。在这个过程中,如果有一个延迟执行的函数中包含 recover()
函数,那么就回停止向上传播(不过我们建议还是手动调用 panic()
让其继续传播,或把一个 panic 转换成 error)
对于能够健壮地应多异常的 Web 服务器而言,我们必须保证每个页面响应函数都有一个调用 recover()
的匿名函数。
试一试
- 尝试用 Go 写一个简单的计算器程序
- 尝试利用上类型转换、分支语句核循环语句
- 尝试用 Go 写一个矩阵计算器,利用 goroutine 完成计算,尽量不阻塞主线程
- 利用数组记录历史记录
总结
这节课我们了解了 Go 语言中的基础概念,但是对于更多高级的特性涉及得不多,尤其是并发和错误处理的部分,接下来我们会结合例子来详细介绍一下 Go 中的高级特性:函数的高级用法、面向对象和并发。