卷 I · 减法CH 03深度 03/24

包、模块与 go 工具链

本章卸下Gradle 构建脚本地狱「留着以后用」的死代码

写 Android 时,你有 build.gradle、settings.gradle、一堆插件、几分钟的构建、Gradle 版本和 AGP 版本的相性问题。Go 把这一整摊子几乎全删了:一个 go.mod、一条命令、几秒钟。这一章讲 Go 怎么组织代码,以及它一条会让你先火大、后感激的硬规矩——未使用的东西,一律编译不过。

packagego module未用即错误go 命令

包:Go 的基本组织单位

Go 代码以包(package)为单位组织。每个 .go 文件开头都要声明自己属于哪个包:

package user   // 这个文件是 user 包的一部分

规则很简单:同一个目录下的所有文件,必须属于同一个包。一个包就是一个目录。包内的东西互相可见(不管大小写),包外只能看见首字母大写的(上一章讲的可见性)。程序的入口是特殊的 package main 里的 func main()

和 Java / Kotlin 最大的不同:Go 没有「一个文件一个 public 类」的规矩,也没有嵌套的包层级关系。包和包之间是平的,靠导入路径引用,不存在「子包能访问父包私有成员」这种事 —— a/ba 是两个毫无特殊关系的独立包。这又是一次减法:删掉了「包的层级可见性」这个你可能都没意识到自己在依赖的复杂规则。

模块:go.mod 就是全部

一堆包组成一个模块(module),模块是版本化、可被别人依赖的单位。一个模块的根目录有一个 go.mod 文件,它小得可怜:

module github.com/you/myapp

go 1.23

require (
    github.com/google/uuid v1.6.0
)

就这些。模块名(通常是它的代码仓库地址)、Go 版本、依赖列表。你 import 一个外部包时,写的是它的完整路径:

import (
    "fmt"                        // 标准库
    "github.com/google/uuid"     // 第三方,路径即身份
)

没有中央仓库、没有 groupId:artifactId:version 那套坐标 —— 包的身份就是它的 URL。你 go get github.com/google/uuid,Go 直接去那个地址拉代码。依赖的精确版本和校验和记在 go.modgo.sum 里,就这两个文件,没有 Gradle 那种层层叠叠的构建脚本。

✎ GOPATH 已经死了,别信旧教程

如果你搜到的老教程让你配 GOPATH、把代码放进 $GOPATH/src/...、纠结目录结构 —— 忘了它。那是 2018 年 Go Modules 出现之前的旧世界。现在你在任何地方 go mod init 就能开一个项目,和它待在硬盘哪个角落毫无关系。看到教 GOPATH 的教程,直接关掉,它至少过时了五年。

那条会让你火大的规矩:未使用即错误

现在讲 Go 最有性格的一条规矩。看这段代码:

package main

import (
    "fmt"
    "os"      // ← 导入了却没用
)

func main() {
    x := 10   // ← 声明了却没用
    fmt.Println("hi")
}

这段代码编译不过。不是警告,是错误,程序根本跑不起来:

./main.go:5:5: "os" imported and not used
./main.go:9:2: declared and not used: x

第一次撞上这个,几乎每个从别的语言来的人都会火大:「我就临时注释掉一行、留个变量待会儿用,凭什么不让我编译?」在 Java / Kotlin 里,未使用的 import 和变量顶多是个灰色的 IDE 警告,你可以视而不见。Go 把它升级成了硬错误。

但这恰恰是 Go 性格的又一次体现,而且方向很有意思。前两章我们说 Go「替你留白」(删特性);这里它反过来 —— 它不许你留白。一段死代码、一个没用上的 import,就是你留在代码里的一片模糊地带:读的人会想「这个 import 是干嘛的?这个变量是不是漏用了?」Go 直接不给这片模糊地带存在的机会。

◆ 两个方向的「留白」

Go 在语言层面对你慷慨地留白(删掉一大堆特性,把语言缩小),但在代码层面对你锱铢必较地不许留白(未用的变量、import 一律报错,后面还有 gofmt 强制格式、error 必须处理)。

这两件事其实是同一个信念的两面:代码里的每一个符号都应该有明确的、当下的意义,没有「以后可能会用」的模糊地带。语言小,是为了让每段代码里出现的花样少;不许死代码,是为了让每段代码里出现的东西都算数。目标都是同一个 —— 让读的人不用猜。

用一阵子你会发现,这条规矩其实在帮你:它逼你随手清理,代码库里几乎不会积攒「不知道还有没有用」的僵尸代码和依赖。你想临时留个变量调试?Go 给了个专门的出口 —— 空白标识符 _

_ = x            // 显式告诉编译器:我知道没用它,别报错
result, _ := f() // 只要第一个返回值,第二个明确丢弃

_ 是「我故意不要这个」的正式声明。它把「忘了用」和「故意不用」区分开 —— 又一次,把意图摊到明面上。

一条命令,干所有事

Go 的工具链是内建的、统一的、零配置的。你不需要装 Gradle、配插件、选构建系统。装好 Go,你就有了一个 go 命令,它一个人干所有活:

$ go run main.go      # 直接编译并运行(开发时最常用)
$ go build            # 编译成一个可执行二进制文件
$ go test ./...       # 跑当前模块所有测试
$ go fmt ./...        # 按官方格式格式化所有代码
$ go vet ./...        # 静态检查,揪出可疑写法
$ go mod tidy         # 整理依赖,删掉没用到的
$ go get pkg@version  # 添加 / 升级一个依赖

没有配置文件(go.mod 不算,它只列依赖不写构建逻辑)、没有构建脚本、没有插件生态。go build 怎么编译,是写死在工具链里的,你不能也不需要定制。又一次减法:删掉了「如何构建」这个可配置维度 —— 所有 Go 项目的构建方式一模一样。

而且它。Go 从设计之初就把「编译快」当核心目标(还记得第 1 章那 40 分钟的 C++ 编译吗),几万行代码通常几秒钟编完。go run 快到你会把它当解释型语言用 —— 改一行、跑一下,几乎无等待。

▶ 动手:三条命令开一个项目

在任意空目录里:

go mod init example.com/hello —— 生成 go.mod,项目就成立了。
新建 main.go,写上第 2 章那个五行 hello。
go run . —— 屏幕上出现 hello。

没有 SDK 配置、没有构建文件、没有等待。对比你上一次新建 Android 项目、等 Gradle sync 转圈的体验 —— 这个落差,就是 Go 把多少复杂度删进了语言本身的直接证据。

Kotlin 对照

⇄ 从 Gradle 星球回到平地

Android / JVM 的构建是一个需要专门学习的领域。Gradle 的 DSL、AGP 版本、Kotlin 版本、插件、buildTypes、flavors、多模块依赖图、几分钟的构建、时不时的 sync 失败 —— 你可能都习惯到不觉得它复杂了,但它确实是你项目里一大块需要维护的东西。JVM 的可见性也更繁:public / private / protected / internal 四档,还有包和模块的层级。

Go 把这一整片删平了。构建逻辑不可配置(也就没得维护)、依赖就是一个 go.mod、可见性就是大小写一档、工具链一条命令全包。你会失去 Gradle 那种「什么都能定制」的能力 —— 但你也会发现,你其实很少真的需要那种能力,而它的复杂度是你天天在付的税。

还有那条「未用即错误」的规矩:它是 Kotlin 的 @Suppress("unused") 灰色警告的严格版。一开始你会嫌它管得宽,一个月后你会发现你的 Go 代码库里干干净净,没有一行「不知道还用不用得上」的东西 —— 这种整洁是它逼出来的。

这一章的一句话

Go 的代码组织简单到极致:一个目录一个包、一个 go.mod 一个模块、包身份就是 URL;工具链内建、零配置、编译飞快;而「未使用的变量和 import 一律编译错误」这条硬规矩,是 Go 在代码层面「不许你留白」的性格宣言。

卷 I 到此结束 —— 你已经能读懂、能跑起 Go 代码了。接下来卷 II 才是真正开始重装你的直觉:从一个你从没认真想过的问题开始 —— 一个变量,到底是「值」还是「引用」?