面向非 Go 开发者的 Go 入门
摘要:我曾多次为刚接触这门语言的开发者做过 Go 入门介绍——本文就是将那次分享整理成的技术文章。文章会探讨你为什么会想使用 Go,并简要概览标准库和语言本身。
几年前,我通过将自己的业余项目 Gifty Weddings 的服务端从 Python 移植到 Go 来学习 Go。这是一种很有趣的学习方式,大概花了“两周的公交通勤时间”就基本掌握了 Go 并完成了代码移植。
自那以后,我非常喜欢使用这门语言,在工作中以及 GoAWK 和 zztgo 等业余项目中都大量使用了它。我目前就职于 Compass.com,自我入职以来,公司对 Go 的使用大幅增长——200 多个服务中约有一半是用 Go 编写的。
本文会介绍我认为 Go 的一些亮点,非常简要地概览标准库,然后深入核心语言。但如果你只是想感受一下真实的 Go 代码是什么样子,可以直接跳到 HTTP 服务器示例部分。
为什么选择 Go?
如下面的 Google Trends 图表所示,过去几年 Go 变得非常流行,部分原因在于语言本身的简洁性,但更重要的或许是其出色的工具链。

以下是我喜欢用 Go 编程的一些原因(也许你也会喜欢):
- 小而简洁的核心语言。 Go 的体量与 C 相当,语言规范非常易读,只有约 50 页(相比之下 Java 规范有 770 页)。这让它易于学习,也便于传授给他人。
- 高质量的标准库,尤其是在服务端和网络任务方面。详情见下文。
- 一流的并发支持,通过 goroutine(类似线程,但更轻量)以及用于启动 goroutine 的
go关键字、用于在它们之间通信的 channel,以及负责调度协调的运行时来实现。 - 编译为原生代码,在所有主流平台上都能生成易于部署的二进制文件。
- 垃圾回收无需繁琐调参(针对低延迟进行了优化)。
- 静态类型,但具备类型推断,可以避免大量“类型重复”。
- 出色的文档,简洁明了,并包含大量可运行的示例。
- 优秀的工具链。只需输入
go build即可构建项目,输入go test即可发现并运行测试,等等。还有 CPU 和内存分析、代码覆盖率、交叉编译——全部无需外部工具。 - 编译速度快。该语言从设计之初就将快速编译作为目标。事实上,联合创始人 Rob Pike 曾开玩笑说,“Go 是在等待一次大型 [C++] 编译时构想出来的。”
- 非常稳定的语言和标准库,并有严格的兼容性承诺,保证所有 Go 1 程序在后续的 Go 1.x 版本上无需修改即可运行。
- 备受欢迎。根据 Stack Overflow 2019 年的调查,它是第三大最受期待的编程语言,因此很容易招到愿意使用它的开发者。
- 在云工具中被广泛使用。 Docker 和 Kubernetes 都是用 Go 编写的,Dropbox、DigitalOcean、Cloudflare 以及许多其他公司也在大量使用 Go。
标准库
Go 的标准库功能丰富、跨平台且文档完善。与 Python 类似,Go 也是“开箱即用”,无需任何第三方依赖就能立即构建实用的服务端和命令行工具。以下是一些亮点(偏向我用过的部分):
- 输入/输出:系统调用、文件与目录、带缓冲的 I/O。
- HTTP:可用于生产环境的客户端和服务端、TLS、HTTP/2、简单路由、URL 和 Cookie 解析。
- 字符串:各种基础操作、原始字节处理、Unicode 转换。
- 编码:JSON、XML、CSV、base64、hex、二进制等。
- 模板:简单而强大的文本模板和自动转义的 HTML 模板。
- 时间:API 简洁但设计精良的日期和时间函数。
- 正则表达式:非回溯的正则库。
- 排序:通用的集合排序函数。
- 数据库:
database/sql接口,具体实现由第三方库提供。 - 加密库:安全、快速的 AES、分组密码、哈希等实现。
- 图像:读取和写入 JPEG、PNG 和 GIF,执行基本的图像合成。
- 大数:任意精度的 整数和浮点数。
- 归档与压缩:tar、zip、gzip、bzip2 等。
- 简单的命令行 flag 库。
- Go 源码工具:解析器、AST、代码格式化。
- 反射:强大的运行时反射支持。
在第三方包方面,Go 的典型理念几乎与 JavaScript 动辄引入大量 npm 包的做法相反。Go 团队在 Google 的技术负责人 Russ Cox 曾谈及软件依赖问题,Go 联合创始人 Rob Pike 也常说,“少量复制胜过少量依赖。”可以说,大多数 Gopher 在使用第三方库时都相当克制。
不过,自从我最初写下这次分享以来,Go 团队已经设计并推出了模块(modules),这是 Go 官方对于如何管理和锁定依赖版本的解决方案。我觉得它用起来很顺手,并且能与所有常规的 go 子命令配合使用。
语言特性
接下来,我们来看看 Go 本身是什么样子,逐步走一遍这门语言。
Hello world
Go 拥有类似 C 的语法、强制使用大括号,并且没有分号(形式文法除外)。项目通过导入和包来组织——编译单元是由包含一个或多个 .go 文件的目录构成。下面是一个“hello world”示例:
package main
import "fmt"
func main() {
fmt.Println("Hello, world!")
}有些争议的特性
Go 有一些初见时会让人不太适应的地方,但习惯之后会发现其实很不错。
首先是代码格式:只需运行go fmt,它就会把大括号和空白(还有制表符!)放到该放的位置。这是避免风格之争、保持代码格式一致的好办法。
首字母大写的名称是公开的(即“导出的”),小写名称则仅在包内可见。这一点初看很奇怪,但规则很容易理解,还能减少类似 Java 中 public static void 那样的关键字噪音。根本不需要 public 关键字——示例如下:
package people
type Person struct {
Name string // fields Name and Age are exported ("public")
Age int
hairColor color // hairColor is not exported ("private")
}
func New() *Person { ... } // New is exported
func doThing() { ... } // doThing is not exported另一个让一些开发者不太习惯的地方是:警告即错误!Go 内置工具的可选项很少,在其他编译器中属于警告的内容,在 Go 中就是错误(换句话说,Go 没有警告)。
因此,就连未使用的局部变量或未使用的导入都会导致编译错误——这在开发过程中可能有点烦人,但能保持代码整洁,也避免了开发者为该开启哪些编译器警告而争执。
更有争议的特性
还有一些特性——或者说缺失的特性——争议更大,那就是:Go 没有异常,也没有用户自定义泛型。
传统意义上的异常在 Go 中并不存在。从一开始,其信条就是错误即值,应该像其他值一样显式地传递、返回和处理。因此,与其抛出 FileNotFound 异常,不如检查错误值:
f, err := os.Open("filename.ext")
if err != nil {
log.Fatal(err)
}
// do something with the open file f这确实会让代码更啰嗦(把 if err != nil 设为快捷输入吧),但好处是每一层的错误处理都变得显式。你可以选择添加上下文、记录日志、将其转换为更高层的错误对象,甚至直接忽略——但你必须显式地处理它。
Go 经常因缺少的第二样东西是用户自定义泛型。所以你无法定义自己类型安全的 OrderedMap<int>。不过,由于内置的 slice 和 map 类型本身就支持静态类型的泛型,在很多场景下你并不会感到太不便。
另外值得注意的是,泛型其实正在推进中:Go 团队希望以一种非常符合 Go 风格、并权衡成本的方式来加入泛型,而不是生硬地拼凑上去。已经有了一份草案提案、一个实验性实现,甚至还有一篇相关的类型理论论文《Featherweight Go》。所以,如果在未来 12 到 18 个月内看到 Go 支持某种形式的泛型,我也不会感到意外。
好了,关于 Go 没有什么就说到这里。下面来看看它拥有哪些特性(其中不少是独有的)。
简洁的类型推断
Go 通过 := 运算符提供了简洁的类型推断来声明变量,称为“短变量声明”。类型推断让它用起来有点像脚本语言,因为需要敲的(咳)类型更少了。示例如下:
package main
import "fmt"
// Output: 3 4 hello 5
func main() {
var i int = 3
j := 4 // j is an int
s := "hello" // s is a string
a := add(2, 3) // a is an int
fmt.Println(i, j, s, a)
}
func add(x, y int) int {
return x + y
}另一方面,Go 不会进行任何自动类型转换,即使是不同位宽或有无符号的整数之间也不会。引用 FAQ 中将 Go 与 C 对比的回答:
在 C 语言中,数值类型之间自动转换带来的便利,被其造成的混乱所抵消。表达式何时是无符号的?值有多大?会不会溢出?结果是否可移植、是否与执行机器无关?
for 循环与 range
Go 只有一个循环关键字 for,可用于 while 循环、传统的 C 风格循环以及 range 循环(Go 的“for each”)。当用 range 遍历 slice 或 map 时,Go 会将索引(或 map 的键)作为第一项,将值作为第二项返回。
下面是一些循环示例——目前为止都平平无奇:
// C style "for"
for i := 0; i < 10; i++ {
fmt.Println(i)
}
// Like "while"
for safe.IsLocked() {
time.Sleep(5 * time.Second)
}
// Loop through elements of array or slice
for index, person := range people {
fmt.Println(index, person)
}
// If you don't care about the index
for _, person := range people { ... }
// Loop through keys/values of a map
for word, count := range counts { ... }slice 很好用
在 Go 中,slice 是对数组某一部分的引用——其内部表示非常简单:一个数据指针、一个长度和一个容量。Slice 是泛型的,所以你可以有 float64 的 slice,记作 []float64,也可以有 Person 结构体的 slice,记作 []Person。
你可以用类似 Python 的语法对 slice 进行“切片”,例如 slice[:5] 会返回一个查看前五个元素的新 slice。新 slice 仍然指向同一个底层数组,因此效率与直接操作指针相当,但却是内存安全的——运行时会防止你越过 slice 末尾或做其他危险操作。
Go 提供了一个内置的泛型函数 append(),用于向 slice 追加单个元素并返回新的 slice。如果底层数组(即容量)不够大,它会分配一个大小翻倍的新数组并将元素复制过去。
slice 的用法如下所示:
// Create array and slice pointing into it
nums := []int{3, 4, 5, 6}
// Slice the slice
fmt.Println(nums[1:3]) // Output: [4 5]
fmt.Println(nums[:2]) // Output: [3 4]
fmt.Println(nums[2:]) // Output: [5 6]
// Append: may reallocate underlying array
nums = append(nums, 7, 8)
fmt.Println(nums) // Output: [3 4 5 6 7 8]slice 的功能相当精简,有一点让我(从 Python 转过来)感到怀念的是列表推导。为什么仅仅为了从列表中过滤几个元素,就需要写一个 for 循环加一个 if 语句?我曾问过 Go 团队的一位成员为什么缺少这类特性,他说因为 Go 是一门“系统级语言”,他们希望你能掌控内存分配。例如,你可以使用make()来预先分配 slice 的底层数组以提高效率。
Map
Go 的 map 是一个将键映射到值的无序哈希表。与 slice 一样,它们是泛型且类型安全的,例如你可以有一个 map[string]int,意思是“键为 string、值为 int 的 map”。
map 数据类型提供了获取、设置、删除、存在性判断和遍历等操作。与 slice 一样,你也可以通过 make() 并指定“大小提示”来控制内存分配。
关于 map 还有很多可说的,你也可以了解一下它的实现,但先通过代码来感受一下:
phrase := "the foo foo bar the foo"
counts := make(map[string]int)
for _, word := range strings.Fields(phrase) {
counts[word]++
}
fmt.Println(counts)
// Output: map[foo:3 bar:1 the:2]
// map literal
maths := map[string]float64{
"pi": 3.14,
"tau": 6.28,
}指针,但是安全的
Go 有指针,但与 C 和 C++ 不同,它们是安全的。你无法指向不存在的内存,运行时也会阻止你解引用 nil 指针。事实上,Go 完全没有指针运算——如果你想对某个数据进行索引,就必须使用安全的 slice,或者退回到低层的 unsafe 包(我从未需要过它)。
指针使用与 C 相同的 * 和 & 语法,* 用于获取指针地址处的值,& 用于取得变量的地址。
一个不错的语法细节是,Go 没有 C 风格的 -> 运算符:要解引用结构体指针并获取字段,同样使用 .。示例如下:
p := new(Person) // p is a "pointer to Person"
p.Name = "Joe Bloggs"
p.Age = 42
pers := *p // dereference p back to Person
// More succinct alternatives
p = &Person{"Joe Bloggs", 42}
p = &Person{Name: "Joe Bloggs", Age: 42}
pers = Person{"Joe Bloggs", 42}
p = &persdefer
Go 有一个独特的关键字 defer,它会在当前函数返回(或因运行时“panic”而退出)之前执行给定的函数调用。如果多次调用 defer,这些函数会按后调用先执行的顺序被调用。它用于资源清理,可以替代 C++ 中的 RAII 或 Python 中的 with 语句。
据我所知,defer 是 Go 独有的控制流语句,与其显式的错误处理方式非常契合。你可以进一步了解它,但这里先给出一个常见任务的简单示例——打开和关闭文件:
f, err := os.Open("file")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// read from fGoroutine
Goroutine 是 Go 的并发机制:它们类似于线程,但要轻量得多——你可以轻松同时运行 10 万甚至 100 万个 goroutine。Go 运行时会调度这些 goroutine,在需要时(例如 I/O 就绪时)唤醒它们并在操作系统线程上执行。
Go 并发模型的一个巧妙之处在于,所有标准库函数都提供简单、同步的 API——而如果你需要并发,就显式地使用 goroutine。这避免了“有色函数”问题——即某些语言中一套异步、一套同步的两套 API。
要启动一个 goroutine,只需写 go backgroundFunc(),Go 运行时就会在新的 goroutine 上启动 backgroundFunc。下面是一个处理函数的简单示例,它记录用户注册信息,然后在后台向其发送邮件(这与我在业余项目中使用的真实代码类似):
func ProcessSignup(u *User) {
u.SignedUpAt = time.Now()
u.Save(db)
go SendEmail(u.email, "Thanks for signing up!", "signup.html")
}Channel
启动 goroutine 并不会返回 promise 或 goroutine ID——如果你想在 goroutine 之间通信或发出任务完成的信号,就必须显式地使用 channel。Channel 是 Go 中 goroutine 间主要的通信机制,正如 Go 箴言所说,“不要通过共享内存来通信,而要通过通信来共享内存。”
channel 是一个类型安全且线程安全的队列,既可以传递数据,也可以用于同步——从 channel 读取的 goroutine 会一直等待,直到另一个 goroutine 向其写入。
下面是一个并行化简单“数组求和”任务的示例——这个例子在实际中几乎肯定不会因使用 goroutine 而获益,但可以让你了解其思路:
func main() {
s := []int{7, 2, 8, -9, 4, 0}
c := make(chan int)
go sum(s[:len(s)/2], c) // first half
go sum(s[len(s)/2:], c) // second half
// Receive both results from channel
x, y := <-c, <-c
fmt.Println(x, y, x+y)
}
func sum(s []int, c chan int) {
sum := 0
for _, v := range s {
sum += v
}
c <- sum // Send sum back to main
}channel 是非常强大的构造,关于它们还有很多可讲的(带缓冲与无缓冲、已关闭的 channel 等),但这部分就留给《Effective Go》吧。
类型与方法
Go 支持用户自定义类型,类型可以拥有方法,但没有类(有人会说 Go 不是一门 classy 的语言)。Go 有 struct 和接口(下文会讨论),但没有继承。所有面向对象的特性都是通过组合来实现的——不过也有诸如嵌入这类工具提供了另一种思路。
在类型上定义的方法会带有一个“接收者”参数,类似于 Python 中的 self 和其他语言中的 this。但它们有一些独特的性质(例如,接收者可以是指针也可以是值)。你也可以随意命名接收者,不过通常会以所属类型的首字母来命名。
下面是一个带有两个字段并实现了 String 方法的简单结构体:
type Person struct {
Name string
Age int
}
func (p *Person) String() string {
return fmt.Sprintf("%s (%d years)", p.Name, p.Age)
}
// Output: Bob (42 years)
func main() {
p := &Person{"Bob", 42}
fmt.Println(p.String()) // but .String() is optional; see below
}接口
接口与 Java 等其他语言中的接口略有不同,在那些语言中你必须显式地声明 class MyThing implements ThatInterface。而在 Go 中,只要你在某个类型上定义了接口的所有方法,该类型就隐式地实现了该接口,你可以在任何需要该接口的地方使用它——完全不需要 implements 关键字。
Go 的这种方式常被称为“静态鸭子类型”,它是结构化类型的一种形式(TypeScript 是另一门使用结构化类型的流行语言)。
接口在标准库和 Go 代码中无处不在。最常见的两个例子是 Stringer 接口,它让 Printf 等函数能够生成值的字符串形式;以及 io.Reader 和 io.Writer 接口,它们让你可以将文件、HTTP 服务、gzip 文件、字符串缓冲区等都视作可读或可写的流。
下面是 Stringer 和 Writer 接口的定义——两者都是非常简单的单方法接口(小接口在 Go 中非常常见)。你实际上不需要自己定义它们,这段代码只是展示语法:
// Defined in package "fmt"
type Stringer interface {
String() string
}
// Defined in package "io"
type Writer interface {
Write(p []byte) (n int, err error)
}
// ...
func main() {
p := &Person{"Bob", 42}
fmt.Println(p.String())
// Equivalent (Person implements Stringer, which Println looks for)
fmt.Println(p)
}怎么强调接口在 Go 中的重要性都不为过。它们被用来让算法更通用、让函数更易于测试。请在《Effective Go》中阅读更多相关内容。
HTTP 服务器示例
在结束之前,这里有几个展示用 Go 编写 HTTP 服务器有多简单的小程序。而且它们可不只是玩具——Go 的 net/http 包已经可以用于生产环境(不像许多其他语言自带的 Web 服务器,总要在说明上注明“请勿用于生产”)。
下面是一个非常基础的 HTTP 服务器,只有一个路由,会回显 user 查询字符串参数。请注意 http.ResponseWriter 作为 io.Writer 被传给 fmt.Fprintf 的用法:
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", handler)
fmt.Println("listening on port 8080")
http.ListenAndServe(":8080", nil)
}
func handler(w http.ResponseWriter, r *http.Request) {
user := r.URL.Query().Get("user")
if user == "" {
user = "world"
}
fmt.Fprintf(w, "Hello, %s", user)
}作为一个稍复杂的例子,这里我们用几行代码构建了一个带有基于正则表达式的自定义路由器的 HTTP 服务器。
更新:这类自定义路由代码如今已不再需要。Go 1.22 为 http.ServeMux 带来了增强,可以直接匹配类似 /user/{userId} 的路径。
// NOTE: use the new http.ServeMux routing in Go 1.22!
package main
import (
"fmt"
"net/http"
"regexp"
)
type route struct {
pattern *regexp.Regexp
handler func(w http.ResponseWriter, r *http.Request, matches []string)
}
func home(w http.ResponseWriter, r *http.Request, matches []string) {
fmt.Fprintf(w, "Home")
}
func user(w http.ResponseWriter, r *http.Request, matches []string) {
user := matches[1]
fmt.Fprintf(w, "User ID: %s", user)
}
func main() {
routes := []route{
{regexp.MustCompile(`^/$`), home},
{regexp.MustCompile(`^/user/(\w+)$`), user},
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
for _, route := range routes {
matches := route.pattern.FindStringSubmatch(r.URL.Path)
if len(matches) >= 1 {
route.handler(w, r, matches)
return
}
}
http.NotFound(w, r)
})
fmt.Println("listening on port 8080")
http.ListenAndServe(":8080", nil)
}go 工具
最近有人问我最喜欢的开发者工具是什么。起初我说,“也许是 Sublime Text?”但后来我改了主意:我认为我(目前)最喜欢的开发者工具是 go 命令。无需 Makefile,它就能快速完成以下所有事情:
go build # build your project, produce a static executable
go run # quick way to build and run, for development
go fmt # format your .go files in the standard way
go test # find and run your tests
go test -bench=. # run all your benchmarks too
go mod init # initialize a "Go modules" project
go get github.com/foo/bar # fetch and install the "bar" package还有更多命令——请阅读完整文档。
但对我来说最神奇的是,只要设置两个环境变量 GOOS 和 GOARCH,然后运行 go build,Go 就能为指定的操作系统和架构交叉编译你的项目。下面这行命令就能在 macOS 或 Windows 机器上生成可部署的 Linux 二进制文件:
GOOS=linux GOARCH=amd64 go build是不是很酷?自 Turbo Pascal 以来,开发还没这么轻松过……
总结
关于 Go 及其生态还有很多可说的,但希望这篇介绍能对来自其他语言背景的读者有所帮助。想要入门,我强烈推荐官方的 Go 之旅。想要深入学习,请阅读《Effective Go》,然后再看优秀的《The Go Programming Language》一书。
对了,用 Go 来写代码吧!
随机一篇博客
评论
登录后参与讨论