小土刀

【计算机系统导论】2.2 编程语言

学习计算机系统的一个难点在于概念本身的交叉关联,这意味着我们很难在『完全』弄懂一个概念之后再学习新的概念,而是需要一直带着疑问进行学习和探索,直到把知识融会贯通。在了解完编码之后,就可以把编程语言作为理解计算机系统的敲门砖了。


注:本章可能会涉及若干目前难以理解的概念,但是不要担心,随着学习的深入,各种疑惑将会迎刃而解。

2.2.1 编程迷思

无论是计算机科班出身的学生,还是半路出家的爱好者,因为现在编程难度的大幅度降低,很多时候并不需要理解底层的实现就已经可以写出过得去的代码。但是网上的一些错误理解以及教材中由于内容编排对概念所做的抽象,导致了许多『想当然』的问题。要深入理解计算机系统,得先把这些『迷思』弄清楚,这样接下来的旅程会好走很多。

计算机不只是执行程序的机器

计算机脱胎于图灵机的构想,简单来说,就是能够执行有限逻辑数学过程的计算模型。图灵机的概念很有意思,但是这里由于篇幅问题不再深入,感兴趣的话可以从维基百科[1]入门,然后就可以看看《图灵的秘密》[2]这本书,从生平到提出图灵机的论文研读都非常不错。

图灵机中最重要的两个『物理』硬件是纸带和读写头(这里的『物理』指的是相对于图灵机其他部分而言)。这种抽象非常简单明了,但是很容易给人一种错误印象,即由图灵机发展而来的现代计算机,就是执行程序的机器而已。

计算机学科的发展,与其说是众人拾柴火焰高,不如说是天才引导的历程。真正奠定现代计算机基础的则是冯诺依曼[3],1945 年发表的 101 页报告[4],不但提出了二进制的构想,更将计算机分成五大组件(存储器、控制器、运算器、输入、输出),我们现在使用的大部分计算机都符合冯诺依曼架构,『计算机之父』之名绝不为过。

当然,这个世界上总是少不了『既生瑜,何生亮』的桥段,与冯诺依曼架构(也称为普林斯顿架构)一时瑜亮另一种架构叫做哈佛架构[5],它和冯诺依曼架构最大的区别在于能够同时访问数据和指令。虽然在计算机体系架构中黯然退场,但是哈佛结构在移动计算中扮演了非常重要的角色,ARM 架构可能是知名度最高的当红炸子鸡了。

和图灵机相比,这两种架构最重要的突破就是增加的存储器,这使得程序和数据的存储成为可能,也因此衍生出来了数据传输(即 IO)的概念,再加上六十年代末出现的计算机网络,计算机要完成的工作,远不止执行程序这么简单。

凡事有利有弊,冯诺依曼架构也有缺陷,甚至可以这么理解,目前计算机系统的诸多漏洞和不稳定,是在设计之初就注定的。比方说缓存溢出可以执行攻击者预订好的程序,给系统带来巨大的安全风险。虽然我们可以采用各种各样的技术来进行防范,但是道高一尺魔高一丈,比方说采用返回导向编程[6]的堆栈溢出攻击,在出现之后长达十多年里,主流操作系统都毫无防范之力!不过,我们在『读厚』部分能够亲自体验一把漏洞攻击,知己知彼,百战不殆嘛。

很多东西并不像看起来那样简单

学习算法的时候肯定离不开思考时间复杂度和空间复杂度,但 $O(n^3)$ 真的很糟糕,$O(1)$ 真的就很好吗?虽然在单纯的算法分析中是如此,但是在计算机系统中,算法只是一小部分。假设一个 $O(1)$ 的算法会导致死锁,虽然看起来比 $O(n^3)$ 的算法好得多,然而真正执行起来,可能就是无尽的等待了。

程序执行并不是一锤子买卖,从算法到数据表示再到程序流程,从内存到缓存再到运算器。不理解计算机系统本身,不理解程序是如何编译执行,又怎么能够写出好程序呢?

前面提到冯诺依曼架构带来了溢出的问题,二进制和十进制的差异也是的计算机中的数学,和理论上的数学有细微的差异。不要小看这点差异,如果因为忽视了它们而采用了错误的假定,基本是不可能得出准确的结果的,不过话说回来,很多时候计算机中也没有什么『准确的结果』,更多的是『可以表示的结果』。

我们知道,在纸面上看 $(x+1)^2 \ge 0$ 是一定的,但是在计算机中就不一定了,比方说:

# dawang at wdxtub.local in ~ [9:00:52]
$ lldb
(lldb) print (233333 + 1) * (233333 + 1)
(int) $0 = -1389819292

简单来说,溢出了,就成了负数。但是因为浮点数的表示方法和整数不同,并不会出现因为溢出而变成负数的问题。

那为啥我们不干脆都用浮点数?因为浮点数也有自己的问题,比方说 $(x+y)+z = x + (y+z)$ 在浮点数运算就不一定了:

# dawang at wdxtub.local in ~ [9:05:02]
$ lldb
(lldb) print (1e20 + -1e20) + 3.14
(double) $0 = 3.1400000000000001
(lldb) print 1e20 + (-1e20 + 3.14)
(double) $1 = 0

交换一下顺序结果就完全不同了,这又是为什么?因为浮点数的表示方法虽然可以避免溢出(极端情况还是会),但会损失部分精度。

如果一定要在计算机系统中找一个关键词,在我看来一定是『权衡』,在之后的学习过程中,我们会常常看到因为实际与理论的差异不得不做出的妥协,而真正的智慧结晶,则是在妥协的同时找到最接近完美的权衡,可谓『带着镣铐跳舞』。

内存里多的是我们不知道的事

很多著名网站都是由于内存错误『引发』的,比方说 stackoverflow 和 segmentfault。虽然现代编程语言大多采用了比较完善的内存保护的机制,但是从 C 时代流传下来的这些错误名称则随着时间推移成为了经典,颇有『为人不识 XX 兰,阅尽 XX 也枉然』的既视感。

的确,无论是 C 或者 C++ 都没有提供任何内存保护机制,再加上强大且危险的指针,出现溢出或者段错误实在是家常便饭。这类问题的问题在于,很难确定是程序本身的问题,还是编译器或者系统的问题。好吧,虽然大部分时候是程序的问题,即便如此也很难发现根源,毕竟我们的思考方式没办法做到和计算机一样。

我们可见的内存并不是物理内存,而是一个非物理的抽象概念。不但需要考虑边界,还得负责空间的分配和管理。假如程序的问题出在动态内存分配上,想要找出来就不那么简单的,毕竟 RAM 中的 R 意思是随机(Random),要在随机中找确定,难免要花大把的时间。

更『可怕』的是,要想真正理解计算机系统中的诸多概念,得去读机器代码,当然不用读 0 和 1 啦,可是汇编是少不了的。汇编虽然是机器相关的,好在现在 Intel 的 CPU 基本一统江湖,我们不必考虑不同平台的差异。但是在学习的过程中一定能深深感受到,能编写机器无关的代码,是多么幸福的事情。汇编相比高级编程语言更加反直觉,在这里我只能鼓励大家硬着头皮上了。

2.2.2 罗素悖论

编程语言的基础核心来自于逻辑,来自PROGRAMMING LANGUAGES & TYPE SYSTEMS文章从罗素悖论角度解释,为什么我们引入类型系统,然后才有了今天的编程语言,这对深入理解编程语言来源,破除语言误区有很大帮助。

著名的罗素悖论是:一个集合到底包含不包含它自己?

举个例子,如下集合 a 包含 a 本身:a = { a, b }

但是,我们常识中对树形结构的了解,一个节点(枝)是由其他节点组成的(左 右或子),但肯定不是由它自己组成的,因此我们又认为集合a不应该包含a本身:

a={任何除了a的元素} 或 a={b}

我们总结下面:

  1. 集合包含他们自己
  2. 集合不包含他们自己

如果有很多集合,这些很多集合也可以表现为一个大集合,那么我们得到如下描述:

  1. 所有集合的集合应该包含他们自己。
  2. 所有集合的集合不应该包含他们自己。

从前面推论我们已经知道,我们倾向于第二条为真,但是注意第二句就发生了逻辑矛盾,如果我们需要统计不包含自己的所有集合,必须首先统计其主集合,因为主集合实际上是包含了所有集合的,但是主集合也是一种集合,而集合是不应该包含他们自己的,结果这里发生矛盾,这就是著名的罗素悖论。

解决罗素悖论是引入类型理论,引入不同类型的层次,每个层次结构中的层只是由同一类型中先前层次组成的。这就诞生了我们今天现代语言Java, C#, Ruby, Haskell 等等,都是采取类型理论,实现特定的属性和层次。

2.2.3 实验环境配置

这部分需要根据教材需要重写

Linux

这里以 Ubuntu 为例(毕竟现在的云服务器基本都是 Ubuntu Linux)进行讲解,安装非常简单,直接上命令:

# 下载安装包
wget https://storage.googleapis.com/golang/go1.6.3.linux-amd64.tar.gz
# 解压到 /usr/local(或者也可以解压到自定义目录,就是需要对应配置一下路径)
sudo tar -C /usr/local -xzf go1.6.3.linux-amd64.tar.gz
# 更新 PATH 环境变量,在 ~/.bashrc 中添加下面这行
export PATH=$PATH:/usr/local/go/bin
# 启用更新
source ~/.bashrc
# 检测版本
go version

如果最后一条命令会显示 go 版本,那么第一步配置就完成了。

第二步我们需要配置 $GOPATH 这个环境变量,这个变量类似于指定 Go 项目的 workspace,比方说新建一个 ~/Go 文件夹,然后在 ~/.bashrc 中添加 export GOPATH=$HOME/Go 即可(别忘了 source ~/.bashrc

好消息是,并没有第三步,我们可以开始 Hello World 了!

Mac

Mac 下的安装和 Linux 相比更为简单一些,因为有直接的安装包,双击然后一路下一步就好。不过这里我们还是绕点远路,配合 zsh 把 Go 环境搭建起来。上命令!

# 下载安装包,wget 可能需要通过 homebrew 安装
wget https://storage.googleapis.com/golang/go1.6.3.darwin-amd64.tar.gz
# 解压到 /usr/local(或者也可以解压到自定义目录,就是需要对应配置一下路径)
sudo tar -C /usr/local -xzf go1.6.3.darwin-amd64.tar.gz
# 更新 PATH 环境变量,在 ~/.zshrc 中添加下面这行(注意要添加在原 PATH 之后)
export PATH=$PATH:/usr/local/go/bin
# 启用更新
source ~/.zshrc
# 检测版本
go version

如果最后一条命令会显示 go 版本,那么第一步配置就完成了。

第二步我们需要配置 $GOPATH 这个环境变量,这个变量类似于指定 Go 项目的 workspace,比方说新建一个 ~/Go 文件夹,然后在 ~/.zshrc 中添加 export GOPATH=$HOME/Go 即可(别忘了 source ~/.zshrc

好消息是,并没有第三步,我们可以开始 Hello World 了!

更新

本系列写了没几天 Go 就更新到了 1.7 版本,所以这里也更新一下,方法很简单,直接下载覆盖即可

# 下载安装包,wget 可能需要通过 homebrew 安装
wget https://storage.googleapis.com/golang/go1.7.darwin-amd64.tar.gz
# 删除老版本
sudo rm -rf /usr/local/go
# 解压到 /usr/local(或者也可以解压到自定义目录,就是需要对应配置一下路径)
sudo tar -C /usr/local -xzf go1.7.darwin-amd64.tar.gz
# 检测版本
go version

应该可以正常看到输出为 go version go1.7 darwin/amd64 了,即更新完成。

开发环境配置

虽然我们可以直接根据使用命令行和文本编辑器进行编程,但是有了 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 and go test)
  • Lint-on-save (using golint or gometalinter)
  • Format (using goreturns or goimports or gofmt)
  • 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 下载并安装 package
  • go install 编译并安装 package
  • go list 列出 package
  • go run hello.go 编译并运行 Go 程序
  • go test fmt 测试 fmt package
  • go 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 的文件,内容为

package main import "fmt" func main() {
// Say Hello fmt.Printf("Hello World! This is wdxtub!\n") }

然后我们执行 go run hello.go 就可以看到输出了

dawang:~/Go$ go run hello.go Hello World! This is wdxtub!

从这个简单的程序中,我们知道:

  • 非注释的第一行代码定义包名,每个程序属于一个 package。每个 Go 应用都包含一个名为 main 的包
  • import 关键字来引用包,这里的 fmt 包含了格式化输入输出的相关函数
  • func 关键词来声明函数,而 main 函数是每一个可执行程序必须包含的,一般来说会最先执行(有 init() 函数除外)
  • 和 C 语言一样,用 // 来进行单行注释,用 /* ... */ 来进行多行注释
  • 不用分号
  • 当标识符(包括常量、变量、类型、函数名、结构字段等等)以一个大写字母开头,如:Group1,那么使用这种形式的标识符的对象就可以被外部包的代码所使用(客户端程序需要先导入这个包),这被称为导出(像面向对象语言中的 public);标识符如果以小写字母开头,则对包外是不可见的,但是他们在整个包的内部是可见并且可用的(像面向对象语言中的 private )

很简单对不对!接下来我们先简单了解一下 Go 的设计哲学,然后就正式进入快速入门教程。

2.2.4 Go 语言快速入门

软件工程随着时间的推移为了照顾之前的旧代码旧设计积累了太多太多的繁文缛节,把原来大道至简的计算机科学弄成了拄着几十条拐杖的老头:

  • 为什么我们需要几十上百个保留字?
  • 为了省几行代码创造各种语法糖,为此增加如此多的记忆成本有多少意义?
  • 为什么我们需要一层一层封装,像制造洋葱一样写代码?
  • 我们是不是被面向对象洗脑了,很多事情其实用函数式思维来解决更好不是么?

重点是,这些东西本质上没有办法减少任何实际问题的难度,为什么不能简简单单干净漂亮地把事情做好,而是去追求所谓大而全呢?一门好的编程语言应该是程序员的武器,而不是负担;应该是辅助思考的工具,不是记忆成本。人总是会犯错的,正道并不是通过各种各样的封装把问题『藏』起来,而是干脆暴露出来,解决它们。

我非常推崇 Unix 的编程哲学:追求简洁、清晰、透明、低复杂度,通过拼接组合功能,避免标新立异。而 Go 作为 Unix 哲学的继承和实践者,无疑是一种大道至简、重剑无锋价值观的回归。

基本语法其实非常简单清晰,这里直接以要点的形式列出:

  • 一行一个语句,不用写分号。如果一行写多个语句,需要用分号隔开,但是并不鼓励这种做法
  • 标识符的第一个字符必须是字母或者下划线,从第二个开始才能用数字
  • 行注释以 // 开始,块注释为 /**/
  • 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 位
      • intuint 大小一样
      • uintptr 无符号整型,用于存放一个指针
  • 变量声明使用 var 关键字,模板为 var identifier type,也就是类型在后面,比如
    • var a int 标准声明,使用默认值 0
    • var 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 的用法

package main
import "fmt"
func main() {
const (
a = iota
b = 3 << iota
c
d = 100
e
f
g
)
fmt.Println(a, b, c, d, e, f, g)
}

对应的输出为 0 6 2 100 4 5 6,请仔细感受一下这个加一的过程。Go 的运算符也比较『正常』,这里简单点一下

  • 算术运算符:+, -, *, /, %, ++, --
  • 关系运算符:==, !=, >, <, >=, <=
  • 逻辑运算符:&&, ||, !
  • 赋值运算符:=, +=, -=, *=, /=, %=, <<=, >>=, &=, ^=, |=
  • 位运算符:&, |, ^, <<, >>
  • 其他运算符:&(返回变量的存储地址),* 指针变量

运算符优先级也没有什么特别的地方,正常用一般不会有太多『意外』。如果上面的内容令你有些困惑,不要紧,接下来会简单进行介绍,但是最快最准确的方法,是去官方文档里查阅对应内容。

整型与浮点数

Go 中提供了 11 种整型,包括 5 种有符号的和 5 种无符号的,再加上 1种用于存储指针的整型类型。byte 相当于 unit8,单个字符(即 Unicode 码点)提倡使用 rune 来代替 int32,不过一般来说我们只需要使用 int 即可,会根据平台来自动决定位数。

要处理大整数时,我们可以使用 big.Intbig.Rat 类型,但是处理的速度要比 int 慢得多。

Go 中提供了 2 种类型的浮点类型和 2 种类型的复数类型。一般我们会使用 math 包来处理 float64 类型的数据。对于复数类型,我们一般使用 math/cmplx 包来处理,默认类型是 complex128

字符串

字符串的处理我们一般使用 stringsstrconv 这两个包,如果要处理 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 中还有两种比较好的方式:

  1. 准备好一个字符串切片([]string),然后使用 strings.Join() 函数一次性完成串联
  2. 使用 bytes.BufferWriteString() 方法把我们需要的内容写入到 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

& 符号会生成一个指向其作用对象的指针。

i := 42
p = &i

* 符号表示指针指向的底层的值。

fmt.Println(*p) // 通过指针 p 读取 i
*p = 21 // 通过指针 p 设置 i

这也就是通常所说的“间接引用”或“非直接引用”。与 C 不同,Go 没有指针运算。

package main
import "fmt"
func main() {
i, j := 42, 2701
p := &i // point to i
fmt.Println(*p) // read i through the pointer
*p = 21 // set i through the pointer
fmt.Println(i) // see the new value of i
p = &j // point to j
*p = *p / 37 // divide j through the pointer
fmt.Println(j) // see the new value of j
}

数组与切片

Go 中的数组是按值传递的,也就是说会复制一份,所以传递大数组开销很大,不过我们一般都使用切片,因为传递一个切片的成本很低。这里需要强调两个符号:

  • & 作为一元操作符,会取得对应变量的地址,常被称为取址操作符
  • * 作为一元操作符,会返回其保存的地址所指向的内存的值,常被称为内容操作符、间接操作符或者解引用操作符

new(Type)&Type{} 是等价的,都会分配一个 Type 类型的空值,并返回一个指向该值的指针。

创建数组的语法为:

[length]Type
[N]Type{value1, value2, ..., valueN}
[...]Type{value1, value2, ..., valueN}

一般来说,切片比数组更加灵活、强大且方便,创建切片的语法为:

make([]Type, length, capacity)
make([]Type, length)
[]Type{}
[]Type{value1, value2, ..., valueN}

但是实际上切片的底层仍然是一个固定长度的数组,但是会自动根据我们的需求来进行扩展核收缩。下面是一些示例:

package main
import "fmt"
func main() {
p := []int{2, 3, 5, 7, 11, 13}
fmt.Println("p ==", p)
for i := 0; i < len(p); i++ {
fmt.Printf("p[%d] == %d\n", i, p[i])
}
fmt.Println("p[1:4] ==", p[1:4])
// 省略下标代表从 0 开始
fmt.Println("p[:3] ==", p[:3])
// 省略上标代表到 len(s) 结束
fmt.Println("p[4:] ==", p[4:])
}

slice 由函数 make 创建。这会分配一个零长度的数组并且返回一个 slice 指向这个数组: a := make([]int, 5) // len(a)=5 为了指定容量,可传递第三个参数到 make

b := make([]int, 0, 5) // len(b)=0, cap(b)=5
b = b[:cap(b)] // len(b)=5, cap(b)=5
b = b[1:] // len(b)=4, cap(b)=4

例子

package main
import "fmt"
func main() {
a := make([]int, 5)
printSlice("a", a)
b := make([]int, 0, 5)
printSlice("b", b)
c := b[:2]
printSlice("c", c)
d := c[2:5]
printSlice("d", d)
}
func printSlice(s string, x []int) {
fmt.Printf("%s len=%d cap=%d %v\n",
s, len(x), cap(x), x)
}

slice 的零值是 nil。一个 nil 的 slice 的长度和容量是 0。

package main
import "fmt"
func main() {
var z []int
fmt.Println(z, len(z), cap(z))
if z == nil {
fmt.Println("nil!")
}
}

向 slice 添加元素是一种常见的操作,因此 Go 提供了一个内建函数 append。 内建函数的文档对 append 有详细介绍。func append(s []T, vs ...T) []T

  • append 的第一个参数 s 是一个类型为 T 的数组,其余类型为 T 的值将会添加到 slice。
  • append 的结果是一个包含原 slice 所有元素加上新添加的元素的 slice。
  • 如果 s 的底层数组太小,而不能容纳所有值时,会分配一个更大的数组。 返回的 slice 会指向这个新分配的数组。
package main
import "fmt"
func main() {
var a []int
printSlice("a", a)
// append works on nil slices.
a = append(a, 0)
printSlice("a", a)
// the slice grows as needed.
a = append(a, 1)
printSlice("a", a)
// we can add more than one element at a time.
a = append(a, 2, 3, 4)
printSlice("a", a)
}
func printSlice(s string, x []int) {
fmt.Printf("%s len=%d cap=%d %v\n",
s, len(x), cap(x), x)
}

for 循环的 range 格式可以对 slice 或者 map 进行迭代循环。可以通过赋值给 _ 来忽略序号和值。如果只需要索引值,去掉“, value”的部分即可。

package main
import "fmt"
var pow = []int{1, 2, 4, 8, 16, 32, 64, 128}
func main() {
for i, v := range pow {
fmt.Printf("2**%d = %d\n", i, v)
}
}

如果想要排序和搜索切片,一般使用 sort 包来进行对切片的排序和搜索。如果要对自定义的结构体排序,只需要对应实现 Len(), Less()Swap() 三个函数。

映射

和 Map/Dictionary 类似,保存键值对的无序集合,所有的键需要是唯一的而且必须支持 ==!= 操作,一些常用的操作有:

  • m[k] = v 创建一个 k-v 的映射记录,如果已存在,则更新数据
  • Delete(m, k) 删除 m 中键为 k 的映射
  • v := m[k] 取出 m 中键为 k 的映射,赋值给 v
  • v, found := m[k] 取出 m 中键为 k 的映射,复制为 v,found 用来表示映射否存在
  • len(m) 返回 m 中 k-v 映射记录的个数

映射可以通过如下方式创建:

make(map[KeyType]ValueType, initialCapacity)
make(map[KeyType]ValueType)
map[KeyType]valueType{}
map[KeyType]valueType{key1: value1, key2: value2, ..., keyN: valueN}

一些例子为:

package main
import "fmt"
type Vertex struct {
Lat, Long float64
}
var m map[string]Vertex
var mmm = map[string]Vertex{
"Bell Labs": {40.68433, -74.39967},
"Google": {37.42202, -122.08408},
}
func main() {
m = make(map[string]Vertex)
m["Bell Labs"] = Vertex{
40.68433, -74.39967,
}
fmt.Println(m["Bell Labs"])
var mm = map[string]Vertex{
"Bell Labs": Vertex{
40.68433, -74.39967,
},
"Google": Vertex{
37.42202, -122.08408,
},
}
fmt.Println(mm)
}

在 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 的元素类型的零值。

package main
import "fmt"
func main() {
m := make(map[string]int)
m["Answer"] = 42
fmt.Println("The value:", m["Answer"])
m["Answer"] = 48
fmt.Println("The value:", m["Answer"])
delete(m, "Answer")
fmt.Println("The value:", m["Answer"])
v, ok := m["Answer"]
fmt.Println("The value:", v, "Present?", ok)
}

如果我们要按顺序遍历一个 map,那么可以先把所有的 key 取出来放到一个切片中,排序之后,然后再一个一个取出来。

类型转换与断言

Go 可以在相互兼容的数据类型中进行类型转换,对于非数值类型不会丢失精度,对于数值类型可能会丢失精度。转换的方式很简单:

resultOfType := Type(expression)

一个字符串可以转换成一个 []byte 或者一个 []rune,也可以进行反过来的转换。

除了类型转换,另一个很有用的特性是类型断言。在 Go 中 interface{} 类型用于表示空接口,实际上可以用于表示任意 Go 类型的值。于是我们可以使用类型开关、类型断言或者 reflect 包进行类型检查,然后把数据转换成我们需要的值,比如:

resultOfType, boolean := expression.(Type) // 安全类型断言
resultOfType := expression.(Type) // 非安全类型断言,失败时 panic()

比如

var i interface{} = 99
var s interface{} = []string{"left", "right"}
j := i.(int)
fmt.Printf("%T->%d\n", j, j)
if i, ok := i.(int); ok {
fmt.Printf("%T->%d\n", i, j) // i 是一个 int 类型的影子变量
}
if s, ok := s.([]string); ok {
fmt.Printf("%T->%q\n", s, s) // s 是一个 []string 类型的影子变量
}

分支语句

Go 中的条件语句主要分三种:if, switchselect,比较特别的是 select,会随机执行一个可运行的 case。如果没有 case 可运行,它将阻塞,直到有 case 可运行。

if

if 语句除了没有了 ( ) 之外(甚至强制不能使用它们),看起来跟 C 或者 Java 中的一样,而 { } 是必须的。if 语句可以在条件之前执行一个简单的语句。由这个语句定义的变量的作用域仅在 if 范围之内。在 if 的便捷语句定义的变量同样可以在任何对应的 else 块中使用。

package main
import (
"fmt"
"math"
)
func sqrt(x float64) string {
if x < 0 {
return sqrt(-x) + "i"
}
return fmt.Sprint(math.Sqrt(x))
}
func pow(x, n, lim float64) float64 {
if v := math.Pow(x, n); v < lim {
return v
} else {
fmt.Printf("%g >= %g\n", v, lim)
}
// 这里开始就不能使用 v 了
return lim
}
func main() {
fmt.Println(sqrt(2), sqrt(-4))
fmt.Println(
pow(3, 2, 10),
pow(3, 3, 20),
)
}

switch

对于 switch 语句来说,除非以 fallthrough 语句结束,否则分支会自动终止。switch 的条件从上到下的执行,当匹配成功的时候停止。

package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Print("Go runs on ")
switch os := runtime.GOOS; os {
case "darwin":
fmt.Println("OS X.")
case "linux":
fmt.Println("Linux.")
default:
// freebsd, openbsd,
// plan9, windows...
fmt.Printf("%s.", os)
}
}

没有条件的 switch 同 switch true 一样。这一构造使得可以用更清晰的形式来编写长的 if-then-else 链。

package main
import (
"fmt"
"time"
)
func main() {
t := time.Now()
switch {
case t.Hour() < 12:
fmt.Println("Good morning!")
case t.Hour() < 17:
fmt.Println("Good afternoon.")
default:
fmt.Println("Good evening.")
}
}

switch 用于类型开关

switch 还可以用于类型开关,帮助我们处理不同类型的数据,直接看一个例子就很清晰了:

func classifier(items...interface{}) {
for i, x := range items {
switch x.(type) {
case bool:
fmt.Printf("param #%d is a bool\n", i)
case float64:
fmt.Printf("param #%d is a float64\n", i)
case int, int8, int16, int32, int64:
fmt.Printf("param #%d is a int\n", i)
case uint, uint8, uint16, uint32, uint64:
fmt.Printf("param #%d is a unsigned int\n", i)
case nil:
fmt.Printf("param #%d is a nil\n", i)
case string:
fmt.Printf("param #%d is a string\n", i)
default:
fmt.Printf("param #%d is a unknown\n", i)
}
}

但大部分 Go 程序应该都不需要类型断言和类型开关,即使需要,应该也很少用到。其中一个使用案例是,我们传入一个满足某个接口的值,同时想检查下它是否满足另外一个接口。另一个使用案例是,数据来自于外部源但必须转换成 Go 语言的数据类型。为了简化维护,最好总是将这些代码与其他程序分开。这样就使得程序完全地工作于 Go 语言的数据类型之上,也意味着任何外部源数据的格式或类型改变所导致的代码维护工作可以控制在小范围内。

select

select 是 Go 中的一个控制结构,类似于用于通信的 switch 语句。每个 case 必须是一个通信操作,要么是发送要么是接收。select 随机执行一个可运行的 case。如果没有 case 可运行,它将阻塞,直到有 case 可运行。一个默认的子句应该总是可运行的。例如

package main
import "fmt"
func main() {
var c1, c2, c3 chan int
var i1, i2 int
select {
case i1 = <-c1:
fmt.Printf("received ", i1, " from c1\n")
case c2 <- i2:
fmt.Printf("sent ", i2, " to c2\n")
case i3, ok := (<-c3): // same as: i3, ok := <-c3
if ok {
fmt.Printf("received ", i3, " from c3\n")
} else {
fmt.Printf("c3 is closed\n")
}
default:
fmt.Printf("no communication\n")
}
}

其中:

  • 每个case都必须是一个通信
  • 所有channel表达式都会被求值
  • 所有被发送的表达式都会被求值
  • 如果任意某个通信可以进行,它就执行;其他被忽略。
  • 如果有多个case都可以运行,Select会随机公平地选出一个执行。其他不会执行。否则:
    • 如果有default子句,则执行该语句。
    • 如果没有default字句,select将阻塞,直到某个通信可以运行;Go不会重新对channel或值进行求值。

循环语句

Go 只有一种循环结构——for 循环。基本的 for 循环除了没有了 ( ) 之外(甚至强制不能使用它们),看起来跟 C 或者 Java 中做的一样,而 { } 是必须的。跟 C 或者 Java 中一样,可以让前置、后置语句为空。基于此可以省略分号:C 的 while 在 Go 中叫做 for。如果省略了循环条件,循环就不会结束,因此可以用更简洁地形式表达死循环。

// 无限循环,类似于 while(1)
for {
block
}
// 相当于 while 循环
for booleanExpression {
block
}
// 标准 for 循环
for optionalPrestatement; booleanExpress; optionalPostStatement {
block
}
// 一个字符一个字符迭代字符串
for index, char := range aString {
block
}
// 一个字符一个字符迭代字符串
for index := range aString {
block // char, size := utf8.DecodeRuneInString(aString[index:])
}
// 数组或者切片迭代
for index, item := range anArrayOrSlice {
block
}
// 数组或者切片迭代
for index := range anArrayOrSlice {
block // item := anArrayOrSlice[index]
}
// 映射迭代
for key, value := range aMap {
block
}
// 映射迭代
for key := range aMap {
block // value := aMap[key]
}
// 通道迭代
for item := range aChannel {
block
}

并发与通信

goroutine 是程序中与其他 goroutine 完全相互独立而并发执行的函数或者方法调用。每一个 Go 程序都至少有一个 goroutine,即会执行 main 包中的 main() 函数的主 goroutine。goroutine 非常像轻量级的线程或者协程,可以大批量被创建,并共享相同的地址空间,同时 Go 提供了锁原语来保证数据能够安全的跨 goroutine 共享。不过我们推荐使用通信来进行并发编程。

Go 语言的通道是一个双向或者单向的通信管道,它们可用于在两个或者多个 goroutine 之间通信。但是需要注意的是,优秀的程序员只有在并发程序带来的优点明显超过其所带来的负担才编写并发程序。

可以使用以下语句创建 goroutine:

go function(arguments)
go func(parameters) { block } (arguments)

被调用函数的执行会立即进行,但它是在另一个 goroutine 上执行,并且当前 goroutine 的执行会从下一条语句中立即恢复。不同 goroutine 协作的通信语法为:

channel <- value // 阻塞发送
<- channel // 接收并将其丢弃
x := <- channel // 接收并将其保存
x, ok := <- channel // 接收并将其保存,同时检查通道是否已关闭或者是否为空

非阻塞的发送可以使用 select 语句来达到,或者在一些情况下使用带缓冲的通道。通道的创建语法为:

make(chan Type) // 没有声明容量的通道是同步的,会阻塞直到发送者准备好发送以及
// 接受者准备好接收
make(chan Type, capacity) // 有容量的通道则是异步的

我们来看一个简单的例子:

func createCounter(start int) chan int {
next := make(chan int)
go func(i int) {
for {
next <- i
i++
}
} (start)
return next
}
counterA := createCounter(2)
counterB := createCounter(102)
for i := 0; i < 5; i++ {
a := <- counterA
fmt.Printf("(A->%d, B->%d)", a, <-counterB)
}
fmt.Println()

函数

函数可以没有参数或接受多个参数,注意类型名在变量名之后。当两个或多个连续函数的命名参数是同一类型,则除了最后一个类型之外,其他都可以省略,函数可以返回任意数量的返回值,比如 swap 函数

Go 的返回值可以被命名,并且像变量那样使用。返回值的名称应当具有一定的意义,可以作为文档使用。没有参数的 return 语句返回结果的当前值。也就是直接返回。直接返回语句仅应当用在像下面这样的短函数中。在长的函数中它们会影响代码的可读性。

在函数中,:= 简洁赋值语句在明确类型的地方,可以用于替代 var 定义。函数外的每个语句都必须以关键字开始(varfunc、等等),:= 结构不能使用在函数外。

package main
import "fmt"
var c, python, java bool
var i, j int = 1, 2
func add(x int, y int) int {
return x + y
}
func anotheradd(x, y int) int {
return x + y
}
func swap(x, y string) (string, string){
return y, x
}
func split(sum int) (x, y int) {
x = sum * 4 / 9
y = sum - x
return
}
// 可变参数
func MinimumInt1(first int, rest ...int) int {
for _, x := range rest {
if x < first {
first = x
}
}
return first
}
func main() {
fmt.Println(add(42, 13))
a, b := swap("hello", "world")
fmt.Println(a, b)
var k int
fmt.Println(k, c, python, java)
fmt.Println(i, j)
y := 3
cpp, ruby, scala := true, false, "yes"
fmt.Println(y, cpp, ruby, scala)
fmt.Println(MinimumInt1(5, 3), MinimumInt1(7, 3, 02, 4, 0, -8))
}

defer, panic 和 recover

defer 语句会延迟一个函数的执行,会在外围函数返回之前但是返回值计算之后执行。如果一个函数中有多个 defer 语句,会以后进先出的顺序执行,一个最常用的应用是用完文件后将其关闭。

Go 语言中的错误处理的惯用方法是将错误以函数或者方法的最后一个返回值的形式将其返回,并在调用它的地方检查返回的错误值。

panic 则用于处理那些『不可能』发生的事情,在早期开发阶段这是很好的特性,但是一旦上线运行,要尽量保证程序运行,就要配合 recover 使用。当 panic() 函数被调用时,外围函数或者方法的执行会立即中止,然后延迟执行的方法都会被调用。一层一层往上,直到 main 函数不再有可以返回的调用者,就把调用栈信息输出到 os.Stderr。在这个过程中,如果有一个延迟执行的函数中包含 recover() 函数,那么就回停止向上传播(不过我们建议还是手动调用 panic() 让其继续传播,或把一个 panic 转换成 error)

对于能够健壮地应多异常的 Web 服务器而言,我们必须保证每个页面响应函数都有一个调用 recover() 的匿名函数。

捧个钱场?

热评文章