Blog

Go 入门:工具链、模块和第一个程序

写第一个 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/arm64windows/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 rungo 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 大,也正因为如此,你可以把它复制到任何 GOOSGOARCH 相符的地方。

Python 或 JavaScript 程序正好相反。你分发的是源代码,机器上得装好对应的解释器才能运行。

这个比喻的局限: 盒装蛋糕在哪儿都能吃,但 Go 二进制文件只能在构建时指定的系统上运行。Linux 二进制文件在 Windows 上启动不了。每种厨房都得单独烤一个蛋糕,而有了 GOOSGOARCH,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 还会列出这些依赖和它们的版本。讲包和模块的那一篇会再回到这个话题。

导入与导出名称

标准库是一组包,按路径导入,比如 fmtstrings。导入之后,用包名、点号加名字来调用里面的东西:

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 判断一个字符串是否出现在另一个字符串里。

上面调用的每个名字都以大写字母开头:PrintlnToUpperFieldsContains。这不是代码风格的选择。在 Go 里,以大写字母开头的名字是导出的,也就是包外的代码可以使用它。以小写字母开头的名字只在包内可见。

Go 没有 publicprivate 关键字。首字母就是全部规则。写错了,程序就构建不了:

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

两条报错都精确指出了行号和列号。删掉 countos 导入,或者用上它们,程序才能构建。

第一次碰到时会觉得太严格,而且往往是改代码改到一半。背后的理由很实际。未使用的变量常常意味着 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 把构建、格式化和第一轮检查都放进同一个工具,所以每个项目都从同一个起点出发。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。