写第一个 Go 程序,只需要一个命令行工具、一个 go.mod 文件和一个名为 main 的包。本文讲清 go run、go build、gofmt 和 go vet 各自做什么,以及 Go 为什么拒绝编译带有未使用导入的代码。
Go 自带一个叫 go 的工具。它负责构建、运行、格式化代码,检查常见错误,还管理依赖。你不用自己挑构建系统或格式化工具,因为这些都已经内置了。
本文先把这个工具装好,写出第一个程序,再逐个过一遍你每天都会敲的命令。下面每个程序都在 Go 1.26 上跑过,输出直接从运行结果里复制。
安装 Go 并确认能用
Go 可以从下载页 go.dev/dl 安装,也可以用系统的包管理器装。无论哪种方式,最后你的 PATH 里都会有一个 go 命令。检查一下:
$ go version
go version go1.26.2 linux/amd64
最后一部分是你的操作系统和处理器,所以你看到的可能是 darwin/arm64 或 windows/amd64。重要的是它显示 go1.26 或更高版本。
安装就这么多。老教程会让你建一个 GOPATH 目录,把所有代码都放在里面。现在不需要这样做了。有了模块之后,Go 项目可以放在磁盘上的任何目录里。
GOPATH 仍然存在,但它是 Go 自己用的地方。里面缓存着下载的依赖,go install 构建出的程序也放在那里。很长一段时间里,你都不用去看它。
第一个程序
Go 项目从一个目录和一条 go mod init 命令开始,这条命令把目录变成一个模块:
$ mkdir hello
$ cd hello
$ go mod init example.com/hello
go: creating new go.mod: module example.com/hello
然后在这个目录里新建 main.go 文件:
package main
import "fmt"
func main() {
fmt.Println("Hello, Go")
}
输出:
Hello, Go
用 go run . 运行它,这里的点表示“当前目录下的包”。所有工作由三行代码完成。
package main 声明这个文件属于哪个包。每个 Go 文件都以包声明开头。名为 main 的包很特殊:只有它会变成可以运行的程序。用其他名字,得到的就是供别的代码导入的库。
import "fmt" 导入标准库里的格式化包。fmt.Println 打印它的参数,参数之间加空格,末尾加换行。
func main() 是程序的起点。它不接受参数,也没有返回值。main 返回时,程序就结束了。
两部分缺一不可。main 包里没有 main 函数,就变不成程序,Go 会直接告诉你:
package main
import "fmt"
func Main() {
fmt.Println("Hello, Go")
}
构建失败,报错:
function main is undeclared in the main package
大写的 M 让它成了另一个函数。Go 的名字区分大小写,而且马上你就会看到,首字母的大小写是有含义的。
go run 和 go build
Go 提供两种运行程序的方式:go run 用来马上试一试,go build 用来做出可以交给别人的东西。
$ go run .
Hello, Go
$ go build
$ ls
go.mod hello main.go
$ ./hello
Hello, Go
go run 把程序编译成一个临时文件,运行它,然后删掉。go build 编译后保留结果,文件名取模块路径的最后一段,也就是 hello。
这个文件就是整个程序。把它复制到另一台同类型的 Linux 机器上,就能直接运行,哪怕那台机器从没装过 Go。不需要先装运行时,也不用在旁边附带一堆库文件。
你还可以为另一个操作系统构建程序,只要设置两个环境变量:
$ GOOS=windows GOARCH=amd64 go build
$ ls
go.mod hello hello.exe main.go
hello.exe 是一个 Windows 程序,在 Linux 上构建出来,没有额外安装任何东西。
用十岁孩子能懂的话说
想想菜谱和蛋糕。
脚本,比如 Python 程序,就是一份菜谱。拿到菜谱的人要想吃上蛋糕,得有厨房、烤箱,还得有人会看菜谱。要是他们的烤箱型号不一样,蛋糕可能就烤砸了。
编译好的 Go 程序是装在盒子里的成品蛋糕。烘焙已经在你的电脑上完成了。拿到的人只要打开盒子,不需要厨房。
准确的说法
Go 编译器把你的源代码,连同它导入的每个包,编译成针对某一种操作系统和某一种处理器的机器码,再链接成一个可执行文件。Go 运行时负责垃圾回收和调度 goroutine,它也被编译进同一个文件。
像这样只用纯 Go 包的程序是静态链接的:启动时不加载共享库。所以一个只打印一行的程序也有几 MB 大,也正因为如此,你可以把它复制到任何 GOOS 和 GOARCH 相符的地方。
Python 或 JavaScript 程序正好相反。你分发的是源代码,机器上得装好对应的解释器才能运行。
这个比喻的局限: 盒装蛋糕在哪儿都能吃,但 Go 二进制文件只能在构建时指定的系统上运行。Linux 二进制文件在 Windows 上启动不了。每种厨房都得单独烤一个蛋糕,而有了 GOOS 和 GOARCH,Go 让这件事变得很省事。
go.mod 记录了什么
go mod init 生成的 go.mod 文件很短。完整内容如下:
$ cat go.mod
module example.com/hello
go 1.26.2
module 行是模块路径,也是模块内所有导入路径的前缀。它长得像网址,因为发布出去的模块通常就放在某个网址上。如果项目永远不离开你的机器,起什么名字都行,但用域名风格,以后真要发布时就不会有麻烦。
go 行说明这个模块面向哪个版本的 Go 语言。go mod init 填入的是执行它的那个 Go 的确切版本,连补丁号都有,所以你的会和你的工具链一致。
这一行不只是说明文档,它会开启或关闭语言特性。把 main.go 改成一个对整数使用 range 的循环,for i := range 3,这是 Go 1.22 加入的特性。然后把 go 行改成更旧的版本,再运行:
$ go mod edit -go=1.21
$ go run .
# example.com/hello
./main.go:6:17: cannot range over 3 (untyped int constant): requires go1.22 or later (-lang was set to go1.21; check go.mod)
编译器从头到尾都是 Go 1.26。它拒绝这个循环,是因为 go.mod 说代码面向的是 1.21。Go 不断加新特性,老模块却能照旧构建,靠的就是这个。报错信息甚至告诉了你该去看哪个文件。
模块开始使用别人的代码后,go.mod 还会列出这些依赖和它们的版本。讲包和模块的那一篇会再回到这个话题。
导入与导出名称
标准库是一组包,按路径导入,比如 fmt 和 strings。导入之后,用包名、点号加名字来调用里面的东西:
package main
import (
"fmt"
"strings"
)
func main() {
title := "hello, go"
fmt.Println(strings.ToUpper(title))
fmt.Println(strings.Fields(" one two three "))
fmt.Println(strings.Contains(title, "go"))
}
输出:
HELLO, GO
[one two three]
true
两个及以上的导入写在一个括号块里,每行一个。strings.ToUpper 返回转成大写的副本,strings.Fields 按连续空格切分,strings.Contains 判断一个字符串是否出现在另一个字符串里。
上面调用的每个名字都以大写字母开头:Println、ToUpper、Fields、Contains。这不是代码风格的选择。在 Go 里,以大写字母开头的名字是导出的,也就是包外的代码可以使用它。以小写字母开头的名字只在包内可见。
Go 没有 public 或 private 关键字。首字母就是全部规则。写错了,程序就构建不了:
package main
import (
"fmt"
"strings"
)
func main() {
fmt.Println(strings.toUpper("quiet"))
}
构建失败,报错:
./main.go:9:22: undefined: strings.toUpper (but have ToUpper)
编译器没有只说这个名字不存在。它找到了只差大小写的导出名,并给出了建议。这样的报错值得慢慢读,因为里面常常就有解决办法。
这条规则对你自己的代码同样适用。在 main 包里,叫 greeting 的函数和叫 Greeting 的函数都能用,因为没有包外的代码会导入 main。等你把代码拆成自己的多个包时,它才开始起作用。
gofmt:所有人用同一种格式
Go 的源代码有一种官方排版,由 gofmt 工具来执行。下面这个程序完全没管排版:
package main
import "fmt"
func main() {
x:=[]int{1,2,3}
for _,v:=range x {fmt.Println( v )}
}
它能编译,也能运行。现在让 gofmt 来整理:
$ gofmt -l .
main.go
$ gofmt -w main.go
gofmt -l 列出排版不对的文件,gofmt -w 原地重写它们。重写之后,文件是这样的:
package main
import "fmt"
func main() {
x := []int{1, 2, 3}
for _, v := range x {
fmt.Println(v)
}
}
输出:
1
2
3
用制表符缩进,:= 两边和逗号后面加空格,各部分之间空一行,循环体单独成行。这些都没有选项可以改。
这正是目的所在。只有一种格式,团队里就没人争论大括号放哪儿,代码评审的 diff 里也只剩真正的改动。你打开任何一个 Go 项目,看起来都很眼熟。大多数编辑器保存时会自动运行 gofmt,所以你很少需要手动调用它。go fmt ./... 命令对模块里的每个包做同样的事。
go vet:能编译通过的 bug
有些错误在语法上合法,但几乎肯定是写错了,go vet 专门找这类问题。下面是一个常见例子,Printf 的参数顺序写反了:
package main
import "fmt"
func main() {
name := "Ada"
age := 36
fmt.Printf("%s is %d years old\n", age, name)
}
%s 需要字符串,%d 需要整数。这个程序把两者传反了,却照样能构建、能运行:
$ go run .
%!s(int=36) is %!d(string=Ada) years old
$ go vet
main.go:8:14: fmt.Printf format %s has arg age of wrong type int
编译器发现不了这个问题,因为 Printf 在格式字符串之后接受任意值。运行时 fmt 也不会崩溃,而是打印 %!s(int=36),意思是“你要的是字符串,给我的却是 int”。这样的一行可能在日志文件里躺上几个月都没人注意。
go vet 会读格式字符串,把每个格式动词和对应的参数配对,指出出错的行和问题。把参数换过来,程序就对了:
package main
import "fmt"
func main() {
name := "Ada"
age := 36
fmt.Printf("%s is %d years old\n", name, age)
}
输出:
Ada is 36 years old
go test 会自动运行其中一部分 vet 检查,但提交前自己跑一遍 go vet 是值得的。本系列的每个程序都能通过它。
未使用的变量和导入无法编译
在 Go 里,未使用的局部变量和未使用的导入都是编译错误,不是警告。下面这个程序声明了一个从不读取的变量,还导入了一个从不调用的包:
package main
import (
"fmt"
"os"
)
func main() {
count := 3
fmt.Println("hello")
}
构建失败,报错:
./main.go:5:2: "os" imported and not used
./main.go:9:2: declared and not used: count
两条报错都精确指出了行号和列号。删掉 count 和 os 导入,或者用上它们,程序才能构建。
第一次碰到时会觉得太严格,而且往往是改代码改到一半。背后的理由很实际。未使用的变量常常意味着 bug:你算出了某个值,却用错了名字。未使用的导入会拖慢每一次构建,还掩盖了文件真正依赖什么。Go 认为这两种情况都值得在门口就拦下。
如果确实需要忽略某个值,用空白标识符 _ 明确表示这是有意为之。你在 for _, v := range x 里已经见过它,那里不需要索引。
读取命令行参数
Go 程序通过 os.Args 读取程序名后面输入的内容,它是一个字符串切片。os.Args[0] 是程序自身的路径,参数从索引 1 开始。
把逻辑放在单独的函数里,不用输入任何东西就能方便地测试:
package main
import (
"fmt"
"os"
"strings"
)
func greeting(names []string) string {
if len(names) == 0 {
return "Hello, whoever you are"
}
return "Hello, " + strings.Join(names, " and ")
}
func main() {
fmt.Println(len(os.Args))
fmt.Println(greeting(os.Args[1:]))
fmt.Println(greeting([]string{"Ada", "Grace"}))
}
不带参数运行,输出:
1
Hello, whoever you are
Hello, Ada and Grace
len(os.Args) 是 1,因为唯一的元素是程序路径。于是 os.Args[1:] 是空切片,greeting 走第一个分支。最后一行直接用两个名字调用 greeting,测试就是这样调用它的。
构建之后传入真正的参数,main 就有人可以打招呼了:
$ go build
$ ./greet Ada Grace Linus
4
Hello, Ada and Grace and Linus
Hello, Ada and Grace
现在 os.Args 里有四个字符串:程序路径和三个名字。在 Go 看到之前,shell 已经按空格把这些词分开了。
go run 也会传递参数。包名后面的所有内容都交给你的程序:
$ go run . Ada Grace
3
Hello, Ada and Grace
Hello, Ada and Grace
如果不只是几个简单的词,标准库的 flag 包可以帮你解析 -port 8080 这样的选项。
要点
- 一个
go命令就能构建、运行、格式化、做 vet 检查和管理依赖。不需要GOPATH工作区,模块可以放在任何目录。 - 可运行的程序就是带有
func main()的package main。用其他包名,得到的是库。 go run一步完成编译和运行。go build生成一个二进制文件,不装 Go 也能运行,配合GOOS/GOARCH还能为其他系统构建。go.mod保存模块路径和go行,而go行决定编译器允许使用哪些语言特性。- 以大写字母开头的名字是导出的。这是唯一的可见性规则。
gofmt让所有 Go 代码使用同一种排版,go vet能抓出合法但几乎肯定写错的代码,比如不匹配的Printf格式动词。- 未使用的变量和导入是编译错误。想忽略某个值时,用
_。
Go 把构建、格式化和第一轮检查都放进同一个工具,所以每个项目都从同一个起点出发。