课程概览 · 第 1 章 · 下一篇:字符串、变量与常量

从 Hello World 开始,了解 Go 的模块系统、包导入与声明规则,以及基本数据类型。

Hello World 与构建命令

最小可运行程序

能编译运行的 Go 程序只需三要素:package 声明、import 语句、main 函数。

1
2
3
4
5
6
7
package main

import "fmt"

func main() {
fmt.Println("Hello, World!")
}

保存为 main.go,在同目录下执行:

1
2
go run main.go
# 输出: Hello, World!

这一行命令背后,Go 工具链完成解析、类型检查、编译、链接、执行。编译速度极快源于语言设计层面考虑了编译性能(包级依赖图是 DAG、无头文件机制)。

go run / go build / go install 的区别

go run:编译到临时目录并立即执行,不保留可执行文件。

1
2
3
go run main.go            # 编译单个文件并运行
go run . # 编译当前目录的包并运行
go run -race main.go # 启用竞态检测器运行

它在系统临时目录($GOTMPDIR 或 /tmp)下生成临时可执行文件,运行后删除(编译结果缓存在 $GOCACHE,默认 ~/.cache/go-build)。适合开发调试,不适合分发。

go build:编译并在当前目录生成可执行文件。

1
2
3
4
go build                  # 编译当前包,生成可执行文件(名为目录名)
go build -o myapp # 指定输出文件名
go build ./cmd/server # 编译子目录的包
go build -ldflags="-s -w" # 去除调试信息,减小二进制体积

main 包生成可执行文件到当前工作目录(非源文件目录);非 main 包(库)只检查能否编译,不产生输出。-ldflags 中 -s 去符号表、-w 去 DWARF 调试信息,通常减小 20%~30% 体积。

go install:编译并安装到 $GOBIN(默认 $GOPATH/bin,即 ~/go/bin)。

1
2
3
go install               # 安装当前模块的主包
go install ./cmd/... # 安装 cmd 下所有主包
go install golang.org/x/tools/cmd/goimports@latest # 安装外部工具

Go 1.16+ 引入 go install pkg@version,可在不修改当前模块 go.mod 的情况下安装任意版本工具:

1
2
3
4
5
# 安装特定版本的工具(不影响当前项目的 go.mod)
go install github.com/cweill/gotests/gotests@v1.6.0

# 安装最新版
go install golang.org/x/tools/cmd/godoc@latest

提示:go install pkg@version 不需在模块目录下执行,也不需 go.mod,在独立环境编译,避免污染项目依赖。这是 Go 1.16 之前 go get 的职责–现在 go get 只管理 go.mod 中的依赖,不再安装二进制。

go run、go build 与 go install 的产物路径

交叉编译

Go 交叉编译只需两个环境变量,无需安装工具链:

1
2
3
4
5
6
7
8
# 在 macOS 上编译 Linux amd64 可执行文件
GOOS=linux GOARCH=amd64 go build -o app-linux

# 编译 Windows 可执行文件
GOOS=windows GOARCH=amd64 go build -o app.exe

# 编译 ARM64(如树莓派、Apple Silicon Linux)
GOOS=linux GOARCH=arm64 go build -o app-arm64
GOOSGOARCH说明
linuxamd6464 位 Linux
linuxarm6464 位 ARM Linux
darwinamd64Intel Mac
darwinarm64Apple Silicon Mac
windowsamd6464 位 Windows

GOOS/GOARCH 决定 int 等平台相关类型的大小,也决定条件编译时哪些文件被纳入构建。涉及 CGO 时需设置 CGO_ENABLED=0 禁用 CGO,或配置 C 交叉编译工具链:

1
2
# 纯 Go 交叉编译(最常见场景)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app

构建标签与文件名约定

1
2
3
4
5
6
7
// 文件: crypto_windows.go
// 仅在 Windows 上编译
package crypto

// 文件: crypto_linux.go
// 仅在 Linux 上编译
package crypto

文件名约定为 name_GOOS.go 或 name_GOOS_GOARCH.go。更灵活的条件编译用构建标签:

1
2
3
4
//go:build go1.22
// +build go1.22

package mypkg

//go:build 是 Go 1.17+ 新语法,替代旧版 // +build,过渡期两者共存,新代码应只用 //go:build。

构建缓存

构建缓存位于 $GOCACHE(默认 ~/.cache/go-build)。所有编译结果都缓存,输入(源码、依赖、编译参数)不变即直接复用,增量编译极快。

1
2
3
go clean -cache        # 清除构建缓存
go clean -modcache # 清除模块下载缓存($GOPATH/pkg/mod)
go clean -testcache # 清除测试结果缓存

⚠️ 注意:go clean -modcache 会删除所有已下载模块,下次构建需重新下载。CI 环境通常应利用缓存加速构建。

Go Modules:依赖管理的核心

从 GOPATH 到 Modules

Go 的依赖管理经历三个阶段:

  1. GOPATH 模式(Go 1.11 之前):所有项目在 $GOPATH/src 下,共享同一份依赖源码,无法项目级版本隔离。
  2. Vendor 模式(Go 1.5+):项目内 vendor/ 目录存放依赖副本,实现项目级隔离,但版本管理仍需第三方工具(Glide、dep 等)。
  3. Go Modules(Go 1.11+,Go 1.16 默认):官方内置依赖管理,彻底取代 GOPATH 模式。

核心目标:可复现构建(同一份 go.mod + go.sum 在任何环境下得到相同依赖版本)、去中心化(不依赖中央仓库,直接从 Git 仓库拉取)、最小惊讶原则(MVS 算法简单可预测)。

go.mod 文件结构详解

go.mod 采用类 TOML 语法,典型示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
module github.com/example/myproject

go 1.22

toolchain go1.22.2

require (
github.com/labstack/echo/v4 v4.11.4
gorm.io/gorm v1.25.7
gorm.io/driver/mysql v1.5.6
)

require github.com/pkg/errors v0.9.1 // indirect

replace github.com/foo/bar => ./local/bar

exclude github.com/baz/qux v1.2.0

retract [v1.5.0, v1.5.2]
  • module:模块路径,全局唯一标识,通常用仓库地址,作为导入路径前缀。
  • go:使用的 Go 语言版本,影响启用的语言特性(如 go 1.18 才能用泛型)、循环变量语义(go 1.22 改变 for 变量作用域)、类型检查规则与构建行为。
  • toolchain(Go 1.21+):实际使用的 Go 工具链版本,系统 Go 低于此值时自动下载指定版本(GOTOOLCHAIN 机制)。
  • require:声明依赖,格式 路径 版本。// indirect 表示间接依赖,工具链自动维护。
  • replace:将依赖替换为另一版本或本地路径,常用于本地联调、修复上游 bug 的临时 fork。
1
2
3
4
5
# 替换为本地路径(用于开发联调)
replace github.com/foo/bar => ../my-fork-of-bar

# 替换为特定版本
replace github.com/foo/bar v1.2.0 => github.com/myorg/bar v1.2.1-fix
  • exclude:排除某个版本,通常用于排除有已知 bug 的版本。
  • retract(Go 1.16+):撤回自己发布的版本,go get 会跳过。
1
2
retract v1.5.0                   // 撤回单个版本
retract [v1.5.0, v1.5.2] // 撤回版本范围

go.sum 与完整性校验

go.sum 记录每个依赖版本的加密哈希,确保依赖内容不被篡改:

1
2
github.com/pkg/errors v0.9.1 h1:abcdef1234567890...
github.com/pkg/errors v0.9.1/go.mod h1:fedcba0987654321...

每个依赖版本通常两行:第一行(h1:)是模块 zip 的 SHA-256 哈希;第二行(/go.mod h1:)是该版本 go.mod 的哈希。下载依赖时验证哈希,不匹配则拒绝–供应链安全的重要保障。两行的原因:Go 解析依赖图时只需读取每个依赖的 go.mod(不需完整源码),单独校验 go.mod 可在不下载完整模块的情况下验证元数据完整性。

1
go mod verify    # 校验缓存中的模块与 go.sum 是否一致

语义化版本(SemVer)

Go Modules 采用语义化版本:vMajor.Minor.Patch

  • Major(主版本号):不兼容的 API 变更。
  • Minor(次版本号):向后兼容的功能新增。
  • Patch(修订号):向后兼容的 bug 修复。

主版本号与模块路径

Go 的关键规则:主版本号 >= 2 时,必须在模块路径中包含主版本号后缀。

1
2
3
github.com/foo/bar           // v0 或 v1(路径不含版本号)
github.com/foo/bar/v2 // v2
github.com/foo/bar/v3 // v3

v1 和 v2 被视为不同模块(路径不同),可在同一项目中共存:

1
2
3
4
import (
"github.com/foo/bar" // v1
barv2 "github.com/foo/bar/v2" // v2
)

这是 Go 解决「依赖地狱」的核心设计。npm 靠 node_modules 嵌套允许不同主版本共存,Go 则更显式–不同主版本即不同导入路径。

v0 和 v1 的特殊处理:v0 表示「初始开发阶段,不保证 API 稳定」,v1 是第一个稳定版本。两者都不在路径中含版本号,因此 v0 和 v1 互斥(不能共存)。v0 到 v1 是「走向稳定」,v1 到 v2 是「破坏性变更」。

伪版本(pseudo-version)

依赖某个尚未发布 tag 的 commit 时,Go 生成伪版本号:

1
github.com/foo/bar v0.0.0-20231215120000-abcdef123456

格式为 vX.Y.Z-YYYYMMDDHHMMSS-commitHash12:基准版本(commit 之前的最近 tag)、commit 的 UTC 时间戳、commit hash 前 12 位。

1
2
3
# 获取特定 commit 的版本
go get github.com/foo/bar@abcdef123456
# go.mod 中会记录为伪版本

⚠️ 注意:伪版本可读性差。推荐为依赖打 tag,而非依赖特定 commit。

MVS:最小版本选择算法

MVS(Minimum Version Selection)是 Go Modules 的版本选择算法,也是与 npm/pip 等的核心差异。

核心思想:对每个依赖,选择所有模块(直接和间接)中声明的最高最低要求版本(所有要求中的最大值)。

1
2
3
4
你的模块 A
├── 直接依赖 B v1.2.0(B 要求 D >= v1.3.0)
├── 直接依赖 C v1.1.0(C 要求 D >= v1.2.0)
└── 直接依赖 D v1.4.0(显式声明)

B 要求 D >= v1.3.0、C 要求 D >= v1.2.0、A 显式要求 D v1.4.0,最终选择 D v1.4.0(所有要求中的最大值)。

Go Modules 的 MVS 选版与 go.sum 校验

关键特征:

  1. 不做版本协调:不像 npm 那样找「满足所有约束」的版本,直接选最大值。
  2. 可预测:结果完全由 go.mod 声明决定,不需运行求解器。
  3. 无版本漂移:只要 go.mod 不变,选出的版本一定相同。
  4. 不会因约束冲突失败:只要主版本号兼容(路径相同),任何版本都可被选中。

与 npm 的 SAT 求解器对比:

特性Go MVSnpm (SAT solver)
算法取最大值约束满足问题求解
复杂度O(n)NP-complete(实际用启发式)
结果可预测是,完全由 go.mod 决定否,可能因安装顺序不同而变化
版本冲突不存在(取最大即可)可能无解,需手动解决
lockfilego.sum 只校验哈希package-lock.json 锁定版本
依赖共存主版本不同可共存嵌套 node_modules

npm 中不同包可能对同一依赖声明互斥版本范围(如 ^1.2.0 和 ^2.0.0),SAT 求解器理论上可能无解,靠嵌套 node_modules 缓解却带来磁盘占用和「幽灵依赖」。Go 的 MVS 通过「主版本号 = 模块路径」从根源消除版本冲突–不同主版本即不同模块。

模块代理与私有仓库

Go 默认通过模块代理(默认 https://proxy.golang.org)下载依赖,而非直接访问 Git 仓库:

1
2
3
4
5
go env GOPROXY
# 默认: https://proxy.golang.org,direct

# 国内常用配置
go env -w GOPROXY=https://goproxy.cn,direct

,direct 表示代理无法获取时(如私有仓库),回退到直接从源(Git 等)拉取。

私有仓库配置:

1
2
3
4
5
6
# 设置不走代理和校验数据库的路径(推荐)
go env -w GOPRIVATE=git.company.com,github.com/myorg/*

# GOPRIVATE 等价于同时设置:
# GONOPROXY = 不走模块代理
# GONOSUMDB = 不走校验和数据库(sum.golang.org)

GOPRIVATE(Go 1.15+)适用于私有仓库–私有代码不会出现在公共校验和数据库中,必须跳过校验。

环境变量作用
GOPROXY模块代理地址列表
GOPRIVATE私有路径通配符(同时设置 GONOPROXY 和 GONOSUMDB)
GONOPROXY不走代理的路径
GONOSUMDB不走校验和数据库的路径
GOSUMDB校验和数据库地址(默认 sum.golang.org)

vendor 机制

1
2
3
go mod vendor     # 创建/更新 vendor 目录
go build -mod=vendor # 强制使用 vendor
go build -mod=mod # 强制使用 go.mod(忽略 vendor)

go mod vendor 将所有依赖复制到项目内 vendor/ 目录。Go 1.14+ 行为:项目根目录存在 vendor/ 且 vendor/modules.txt 与 go.mod 一致时,默认使用 vendor 构建。适用场景:完全离线构建(气隙环境)、对依赖做局部补丁(不推荐,应优先用 replace)、企业审计要求依赖源码可见。

提示:现代 Go 项目通常不需要 vendor–构建缓存已解决重复下载,go.mod + go.sum 已保证可复现构建。仅在特殊场景考虑,使用时建议提交到版本控制。

常用 go mod 命令详解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 初始化新模块
go mod init github.com/user/project

# 整理依赖:添加缺失的、移除未使用的
go mod tidy

# 下载依赖到本地缓存(不修改 go.mod)
go mod download

# 查看依赖图
go mod graph

# 查看某个依赖为什么被需要
go mod why github.com/pkg/errors

# 校验依赖完整性
go mod verify

# 将依赖复制到 vendor 目录
go mod vendor

# 编辑 go.mod(脚本友好)
go mod edit -require=github.com/foo/bar@v1.2.0
go mod edit -replace=github.com/foo/bar@v1.2.0=./local/bar

go mod tidy 做两件事:添加源码中 import 但不在 go.mod 中的依赖;移除 go.mod 中存在但源码未 import 的依赖(同时更新 go.sum)。

⚠️ 注意:go mod tidy 可能修改 go.mod 中的 go 版本指令。需兼容旧版 Go 的项目,在 CI 中应检查 go 版本未被意外升级。

go get 与依赖添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 添加最新版本
go get github.com/pkg/errors

# 添加指定版本
go get github.com/pkg/errors@v0.9.1

# 添加某个分支的最新 commit
go get github.com/foo/bar@main

# 升级到最新版本(含次版本)
go get -u github.com/foo/bar

# 只升级 patch
go get -u=patch github.com/foo/bar

# 升级所有依赖
go get -u ./...

# 降级
go get github.com/foo/bar@v1.0.0

Go 1.17+ 中 go get 不再安装二进制(改由 go install 负责),只负责修改 go.mod。

包导入机制详解

import 语法

1
2
3
4
5
6
7
8
9
10
// 单行导入
import "fmt"
import "os"

// 分组导入(推荐)
import (
"fmt"
"os"
"strings"
)

gofmt、goimports 会自动分组排序:标准库在前,第三方在后,用空行分隔。

1
2
3
4
5
6
7
8
9
10
11
12
import (
// 标准库
"fmt"
"os"

// 第三方
"github.com/labstack/echo/v4"
"gorm.io/gorm"

// 项目内部
"github.com/myorg/project/internal/config"
)

标准库导入

标准库无需下载,导入路径就是包名本身(不含域名前缀):

1
2
3
4
5
6
7
import (
"fmt" // 格式化 I/O
"os" // 操作系统功能
"strings" // 字符串操作
"net/http" // HTTP 客户端/服务端
"encoding/json" // JSON 编解码
)

使用时以包名为前缀(fmt.Println()、os.Exit(1))。标准库超过 100 个包,覆盖网络、加密、压缩、编码、测试等。

第三方包导入

1
2
3
4
5
6
import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"gorm.io/gorm"
"gorm.io/driver/mysql"
)

导入路径就是模块路径加子路径。导入前需确保 go.mod 中已声明该依赖(通过 go get 添加或 go mod tidy 自动添加)。

内部包导入

假设模块路径为 github.com/myorg/project,内部包通过模块路径前缀导入:

1
2
3
4
5
6
7
8
9
10
project/
├── go.mod # module github.com/myorg/project
├── main.go
├── config/
│ └── config.go # package config
├── handler/
│ └── user.go # package handler
└── internal/
└── auth/
└── auth.go # package auth
1
2
3
4
5
6
7
8
// main.go
package main

import (
"github.com/myorg/project/config"
"github.com/myorg/project/handler"
"github.com/myorg/project/internal/auth"
)

导入路径即「模块路径 + 目录相对路径」。Go 不支持相对路径导入(如 ./config),Modules 模式下已废弃。

别名导入

1
2
3
4
5
import (
"fmt"
str "strings" // 别名: str.HasPrefix(...)
"github.com/myorg/very-long-package-name" // 假设包名很长
)

包名冲突、过长或不符合惯例时使用别名。常见场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 场景1: 包名冲突
import (
"path/filepath"
path "path" // 别名避免与 filepath 冲突
)

// 场景2: 同名包的不同主版本
import (
"github.com/foo/bar" // v1, 包名 bar
barv2 "github.com/foo/bar/v2" // v2, 包名也是 bar, 需要别名
)

// 场景3: 约定俗成的别名
import (
"context"
"github.com/gin-gonic/gin" // 包名 gin, 无需别名
"github.com/labstack/echo/v4" // 包名 echo, 无需别名
)

点导入(.)

点导入将导入包的导出标识符直接引入当前文件命名空间,使用时无需包名前缀:

1
2
3
4
5
6
7
import (
. "fmt"
)

func main() {
Println("Hello") // 直接使用 Println,不需要 fmt 前缀
}

⚠️ 注意:点导入不推荐–污染命名空间、降低可读性、易冲突,标准库几乎不用。唯一合理场景是测试文件简化断言:

1
2
3
4
5
6
7
8
9
10
11
// test_helper_test.go
package mypackage_test

import (
. "github.com/stretchr/testify/assert"
"testing"
)

func TestSomething(t *testing.T) {
Equal(t, 1+1, 2) // 直接使用 assert.Equal
}

空白导入(_)

空白导入(blank import)只执行包的 init() 函数,不使用其导出内容。最典型用途是注册驱动:

1
2
3
4
5
6
7
8
9
import (
"database/sql"
_ "github.com/go-sql-driver/mysql" // 注册 MySQL 驱动
)

func main() {
// mysql 驱动已在 init() 中注册到 database/sql
db, err := sql.Open("mysql", "user:pass@/dbname")
}

驱动包的 init() 调用 sql.Register() 将自己注册到 database/sql 的全局注册表。主代码只需 database/sql 的接口,不需直接访问驱动包。另一常见场景是 protobuf:

1
2
3
import (
_ "github.com/myorg/proto/gen/go/user/v1" // 注册 protobuf 类型
)

这种「自注册」(self-registration)模式利用 init() 副作用,方便但引入隐式依赖。

循环依赖

Go 严格禁止循环导入。包 A 导入包 B,包 B 又(直接或间接)导入包 A,编译器报错:

1
2
3
4
package main
imports github.com/foo/a
imports github.com/foo/b
imports github.com/foo/a: import cycle not allowed

这是语言层面的硬性约束,非工具链限制。

为什么禁止循环依赖?

  1. 编译效率:DAG 可并行编译,循环依赖需整体分析。
  2. 架构清晰性:循环依赖通常意味着职责未正确分离。
  3. 初始化确定性:包的 init() 执行顺序依赖导入图,有环则无法确定顺序。

如何解决循环依赖?

核心思路是「依赖倒置」–将公共依赖提取到更底层的包,或通过接口解耦。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 问题: A ↔ B 循环依赖
// a.go (package a)
import "github.com/foo/b"
func UseB() { b.DoSomething() }

// b.go (package b)
import "github.com/foo/a" // 循环!
func DoSomething() { a.UseA() }

// 方案1: 提取公共部分到第三个包
// common/types.go
package common
type Data struct { ... }

// a.go 只导入 common
// b.go 只导入 common

// 方案2: 通过接口解耦(依赖倒置)
// b.go 定义接口
package b
type Doer interface { DoSomething() }
func Handle(d Doer) { d.DoSomething() }

// a.go 实现 b.Doer 接口,导入 b
package a
import "github.com/foo/b"
type ADoer struct{}
func (ADoer) DoSomething() { ... }
func UseB() { b.Handle(ADoer{}) }

方案 2 是最常用的解耦手法:定义接口的包不依赖实现接口的包,实现接口的包依赖定义接口的包,方向单一,无环。

导入路径解析

Go 工具链解析导入路径的顺序:

  1. 标准库:先在 Go 安装目录的 src/ 下查找。
  2. vendor 目录:从当前文件向上逐级查找 vendor/ 目录。
  3. 模块缓存:在 $GOPATH/pkg/mod/ 下按模块路径查找。

导入路径以域名开头(如 github.com/...)时,Go 视其为外部模块,从模块缓存或代理下载。

包声明与导出规则

package 声明

每个 Go 源文件必须在第一行(注释之后)声明所属包:

1
2
3
4
5
6
7
8
// helper.go
package utils

import "fmt"

func Help() {
fmt.Println("helping")
}

同一目录下所有 .go 文件必须声明同一包名(_test.go 文件可声明 xxx_test 包用于外部测试)。违反则编译报错:

1
found packages pkg1 (pkg1.go) and pkg2 (pkg2.go) in /path/to/dir

main 包与可执行入口

main 是特殊包名。只有声明为 package main 且包含 func main() 的包才能编译为可执行文件:

1
2
3
4
5
6
7
package main

import "os"

func main() {
os.Exit(0)
}

main 函数签名固定:func main(),不接受参数,不返回值。命令行参数通过 os.Args 或 flag 包获取;退出码通过 os.Exit() 设置。Go 运行时在所有包初始化完成后调用 main。

包名与目录名的关系

Go 惯例是包名与目录名相同,但非强制:

1
2
3
4
5
6
7
project/
├── go.mod
├── main.go # package main
├── config/
│ └── config.go # package config(与目录名一致)
├── httphandler/
│ └── user.go # package handler(可以 != 目录名,但不推荐)

技术上允许包名与目录名不同,但会带来困惑:导入路径是 github.com/foo/httphandler,使用时却是 handler.DoSomething()。强烈建议保持一致。唯一常见例外是版本化模块路径:

1
github.com/foo/bar/v2/       # 目录: v2, 包名: bar

导入路径含 /v2,但包名仍是 bar,这是 Go 的约定。

导出规则:首字母大小写

Go 的可见性控制极为简洁:标识符首字母大写 = 导出(公开),首字母小写 = 未导出(私有)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package user

type User struct { // 导出类型
Name string // 导出字段
email string // 未导出字段(仅包内可见)
phone PhoneNumber // 导出字段,但类型未导出
}

func (u *User) Save() {} // 导出方法
func (u *User) validate() {} // 未导出方法

func NewUser() *User {} // 导出函数(构造函数惯用名 NewXxx)
func hashEmail() string {} // 未导出函数

type PhoneNumber string // 导出类型别名
type internalState int // 未导出类型

此规则适用于所有标识符:变量、常量、函数、类型、方法、结构体字段。

设计考量–与 Java/C# 的 public/private/protected 截然不同:

语言可见性修饰符粒度
Go首字母大小写包级(无 protected)
Javapublic/protected/private/包级类级 + 包级
TypeScriptpublic/private/protected类级
Rustpub(默认私有)模块级

Go 只有两种可见性:包内可见和全局可见,无 protected–包是封装的基本单位而非类。因此同一包内不同文件可自由访问彼此私有成员;Go 的继承机制(嵌入)不引入可见性层级;同包测试文件可直接测试私有函数。

init 函数

每个包可包含一个或多个 init() 函数,在 main() 之前自动执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package config

var settings map[string]string

func init() {
settings = make(map[string]string)
settings["env"] = os.Getenv("APP_ENV")
// 初始化全局配置
}

func init() {
// 可以有多个 init 函数
log.Println("config package initialized")
}

参见:上例 init 中用 make(map[string]string) 初始化全局 map。make 的完整语义与 nil map 陷阱见 杂项:make 与 select。

执行顺序:

  1. 所有导入的包先初始化(深度优先,被依赖的先初始化)。
  2. 包级变量按声明顺序初始化(如有依赖,按依赖顺序)。
  3. 包内 init() 函数执行(同文件内多个 init 按声明顺序,不同文件间由文件名字母序决定,不应依赖)。
  4. main() 执行。
1
2
main 包导入 A,A 导入 B,B 导入 C
执行顺序: C.init -> B.init -> A.init -> main

init() 的典型用途:注册驱动/插件(配合空白导入)、初始化全局变量(配置、连接池)、校验不变量。

1
2
3
4
5
6
7
// 注册 HTTP handler 的惯用法
package handler

func init() {
http.HandleFunc("/users", handleUsers)
http.HandleFunc("/health", handleHealth)
}

⚠️ 注意:过度使用 init() 会导致代码难以测试维护–初始化的全局变量难以 mock,执行顺序隐式。现代实践倾向在 main() 中显式初始化,通过依赖注入传递。init() 最适合「自注册」模式。

internal/ 目录限制

Go 对名为 internal 的目录有特殊限制:internal 目录下的包只能被其父目录树状路径内的包导入。

1
2
3
4
5
6
7
8
9
10
project/
├── go.mod
├── main.go
├── internal/
│ └── auth/
│ └── auth.go # package auth
├── handler/
│ └── user.go # 可以导入 project/internal/auth ✓
└── public/
└── api.go # 可以导入 project/internal/auth ✓
1
2
another-module/
└── main.go # 不能导入 project/internal/auth ✗

更精确的表述:a/b/c/internal/d/e/f 这个 internal 包只能被 a/b/c/ 路径下的包导入。internal/ 实现了「模块内私有包」–实现细节不应被外部模块使用,通过目录约定而非关键字实现。

1
2
3
4
5
// project/internal/config/config.go
package config

// 这个包只能在 project 模块内部使用
// 外部模块无法导入 github.com/myorg/project/internal/config

基本数据类型全解

类型清单

类别类型说明
布尔booltrue / false
字符串string不可变的 UTF-8 字节序列
整数int, int8, int16, int32, int64有符号整数
无符号uint, uint8, uint16, uint32, uint64, uintptr无符号整数
浮点float32, float64IEEE 754 浮点数
复数complex64, complex128复数
别名byte (= uint8), rune (= int32)语义化别名

Go 基础类型、UTF-8 与显式转换

整数类型

类型字节大小值域
int81-128 ~ 127
int162-32768 ~ 32767
int324-2147483648 ~ 2147483647
int648-9223372036854775808 ~ 9223372036854775807
uint810 ~ 255
uint1620 ~ 65535
uint3240 ~ 4294967295
uint6480 ~ 18446744073709551615

int 的平台相关性:int 和 uint 大小不固定,取决于编译目标 CPU 架构(GOARCH):

  • 64 位架构(amd64, arm64):int = int64,占 8 字节
  • 32 位架构(386, arm):int = int32,占 4 字节
1
2
var a int = 42
fmt.Println(unsafe.Sizeof(a)) // 64位机器: 8, 32位机器: 4

提示:大多数场景用 int 即可。以下情况需明确指定大小:

  • 精确控制内存布局(二进制协议、序列化)
  • 确保跨平台一致性(如文件偏移量用 int64)
  • 与 C 语言交互(CGO)

uintptr:无符号整数,大小足以存放指针值,主要用于 unsafe 包的指针运算,日常极少使用。

整数溢出

Go 整数运算不会 panic,溢出会静默回绕:

1
2
3
4
5
var a int8 = 127
a++ // 变成 -128(回绕)

var b uint8 = 0
b-- // 变成 255(回绕)

标准库 math 包提供常量边界值:

1
2
3
4
5
6
7
import "math"

math.MaxInt8 // 127
math.MinInt8 // -128
math.MaxInt64 // 9223372036854775807
math.MaxUint64 // 18446744073709551615
math.MaxInt // Go 1.17+, 平台相关的最大 int 值

Go 没有内置溢出检查运算符(Rust 有 checked_add、wrapping_add 等),需手动检查或使用第三方库:

1
2
3
4
5
6
7
8
9
func safeAdd(a, b int64) (int64, bool) {
if b > 0 && a > math.MaxInt64-b {
return 0, false // 溢出
}
if b < 0 && a < math.MinInt64-b {
return 0, false // 下溢
}
return a + b, true
}

math/bits 包提供高效位操作和前导零计数等函数,可用于手动溢出检测。

byte 与 rune

byte 和 rune 不是新类型,而是已有类型的别名:

1
2
3
4
5
// byte is an alias for uint8
type byte = uint8

// rune is an alias for int32
type rune = int32

byte 表示单个字节,常用于二进制数据。单个 byte 可用字符字面量(单引号)或整数值直接声明:

1
2
3
4
5
6
var b byte = 'a'    // 字符字面量,值为 ASCII 码 97
var c byte = 0x61 // 十六进制写法,与上行等价
var d byte = 97 // 十进制写法,与上行等价
ch := byte('x') // 显式类型转换写法,短变量声明时常用

var data []byte = []byte{0x48, 0x65, 0x6c, 0x6c, 0x6f} // "Hello"

字符字面量本质是无类型的 rune 常量,赋给 byte 时隐式转换,但值必须在 0~255 范围内。var b byte = '中' 会编译报错(constant 20013 overflows byte),多字节字符必须用 rune。

rune 表示一个 Unicode 码点(code point),用于处理文本字符:

1
var ch rune = '中'  // 单引号表示 rune 字面量,值为 20013

需要 rune 是因为 Go 字符串是 UTF-8 编码的字节序列,一个中文字符可能占 3 个字节,而 rune 表示一个逻辑字符(码点):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
s := "Hello, 世界"
fmt.Println(len(s)) // 13(字节数)
fmt.Println(utf8.RuneCountInString(s)) // 9(rune 数)

// 按字节遍历
for i := 0; i < len(s); i++ {
fmt.Printf("%d: %x\n", i, s[i])
}
// 0: 48, 1: 65, ..., 7: e4, 8: b8, 9: 96, 10: e7, 11: 95, 12: 8c

// 按 rune 遍历
for i, r := range s {
fmt.Printf("%d: %c (U+%04X)\n", i, r, r)
}
// 0: H, 1: e, ..., 7: 世, 10: 界

浮点类型

类型字节精度值域(绝对值)
float324~7 位十进制1.4e-45 ~ 3.4e38
float648~15 位十进制4.9e-324 ~ 1.8e308
1
2
3
4
5
var f1 float32 = 3.14
var f2 float64 = 3.141592653589793

// 浮点字面量默认是 float64
f := 3.14 // f 的类型是 float64

浮点数遵循 IEEE 754 标准,存在精度问题:

1
2
fmt.Println(0.1 + 0.2)  // 0.30000000000000004
fmt.Println(0.1+0.2 == 0.3) // false

金融计算等需精确十进制运算的场景,应使用 math/big 包或第三方十进制库(如 shopspring/decimal)。特殊浮点值:

1
2
3
4
5
math.NaN()           // 非数字
math.Inf(1) // 正无穷
math.Inf(-1) // 负无穷
math.IsNaN(x) // 判断是否 NaN
math.IsInf(x, 1) // 判断是否正无穷

⚠️ 注意:NaN != NaN 是 IEEE 754 规定,不能用 == 判断 NaN,必须用 math.IsNaN()。

复数类型

类型说明
complex64实部和虚部均为 float32
complex128实部和虚部均为 float64
1
2
3
4
5
var c1 complex64 = 1 + 2i
var c2 complex128 = complex(3.0, 4.0) // 用 complex() 构造

fmt.Println(real(c1)) // 1
fmt.Println(imag(c2)) // 4

复数类型日常很少使用,主要用于科学计算和信号处理。

布尔类型

1
2
var b bool = true
var ok bool = 5 > 3

bool 占 1 字节(不是 1 位),值为 true 或 false。Go 不允许布尔值与整数隐式转换(不像 C/C++):

1
2
3
var b bool = true
// var i int = b // 编译错误: cannot use b (type bool) as type int
// if 1 { ... } // 编译错误: non-bool 1 used as condition

零值为 false。

字符串类型

Go 的 string 是不可变的字节序列,底层结构是指向只读字节数组的指针和长度:

1
2
3
4
5
// reflect.StringHeader(概念模型,Go 1.20+ 不推荐直接使用)
type StringHeader struct {
Data uintptr // 指向底层字节数组
Len int // 字节长度
}
1
2
3
4
s := "Hello"
fmt.Println(len(s)) // 5(字节数)
fmt.Println(s[0]) // 72(字节值 ascii 码,类型是 uint8/byte)
fmt.Println(s[0:3]) // "Hel"(切片操作)

不可变性:字符串创建后不能修改内容:

1
2
s := "Hello"
// s[0] = 'h' // 编译错误: cannot assign to s[0]

要修改字符串,需先转换为 []byte 或 []rune:

1
2
3
4
s := "Hello"
b := []byte(s)
b[0] = 'h'
s2 := string(b) // "hello"

字符串的底层结构将在第 2 篇深入讲解,包括拼接优化、字符串与切片的关系等。

零值机制

每个变量声明时都会被初始化为对应类型的零值(zero value)。Go 没有未初始化变量–这是内存安全的重要保障。

类型零值
boolfalse
所有整数类型0
所有浮点类型0.0
复数类型0 + 0i
string""(空字符串)
指针nil
函数nil
接口nil
切片nil(len=0, cap=0)
通道nil
映射nil
数组每个元素对应类型的零值
1
2
3
4
5
6
7
var i int       // 0
var s string // ""
var b bool // false
var p *int // nil
var f func() // nil
var m map[string]int // nil
var s2 []int // nil

设计考量:零值机制消除了 C/C++ 中「使用未初始化变量」这一大类 bug。C 中 int x; printf("%d", x); 是未定义行为;Go 中 var x int; fmt.Println(x) 一定输出 0。零值还使结构体可直接使用而无需构造函数:

1
2
3
4
5
6
7
8
9
type Server struct {
Addr string
Port int
TLS bool
}

// 零值可用的结构体
s := &Server{}
// s.Addr == "", s.Port == 0, s.TLS == false

标准库很多类型被设计为「零值可用」(zero-value is useful),如 sync.Mutex、bytes.Buffer:

1
2
var buf bytes.Buffer  // 零值即可用,无需初始化
buf.WriteString("hello")

类型应尽量做到零值可用,减少构造函数依赖–这是 Go 的重要设计惯例。

类型转换与零值机制

Go 无隐式类型转换

Go 严格要求所有类型转换显式进行,这是类型系统最显著的特征。

1
2
3
4
5
6
7
8
var i int = 42
var f float64 = float64(i) // 显式转换
var u uint = uint(f) // 显式转换

// 以下都是编译错误:
// var f float64 = i // cannot use i (type int) as type float64
// var s string = i // cannot convert
// var b bool = 1 // type mismatch

唯一例外:无类型常量(untyped constant)可隐式转换为需要的类型:

1
2
3
4
const x = 42          // 无类型常量
var i int = x // x 隐式转为 int
var f float64 = x // x 隐式转为 float64
var b byte = x // x 隐式转为 byte

这为常量运算提供灵活性,不影响变量类型的严格性(常量在第 2 篇深入)。

显式转换语法

Go 的类型转换语法是 T(v),即 类型(值):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 数值类型间转换
i := 42
f := float64(i) // int -> float64
u := uint(f) // float64 -> uint
b := byte(i) // int -> byte(截断高位)

// string 与 []byte
s := "hello"
bytes := []byte(s) // string -> []byte(分配拷贝)
s2 := string(bytes) // []byte -> string(分配拷贝)

// string 与 []rune
runes := []rune(s) // string -> []rune(按 UTF-8 解码)
s3 := string(runes) // []rune -> string(按 UTF-8 编码)

数值类型转换的陷阱

1
2
3
4
var i int64 = 300
var b byte = byte(i) // 300 超出 byte 范围, 截断为 44 (300 % 256)

fmt.Println(b) // 44

整数截断不会 panic,而是静默丢弃高位。浮点转整数向零截断(不是四舍五入):

1
2
3
4
5
6
7
8
f := 3.99
i := int(f) // 3(向零截断)

f2 := -3.99
i2 := int(f2) // -3(向零截断)

// 四舍五入需手动处理
i3 := int(math.Round(f)) // 4

字符串与数值的转换

Go 1.20 之前,整数转字符串常用 strconv.Itoa 和 fmt.Sprintf:

1
2
3
4
5
6
7
8
9
import "strconv"

s := strconv.Itoa(42) // "42"
i, err := strconv.Atoi("42") // 42, nil
i, err = strconv.Atoi("abc") // 0, strconv.ErrSyntax

// 浮点转字符串
s := strconv.FormatFloat(3.14, 'f', 2, 64) // "3.14"
f, err := strconv.ParseFloat("3.14", 64)

Go 1.20+ 提供更便捷的 fmt 风格整数转字符串(避免分配):

1
2
3
4
// Go 1.20+
var buf [20]byte
n := strconv.AppendInt(buf[:0], 42, 10)
s := string(n) // "42"

与 Java/TypeScript 的类型转换对比

场景GoJavaTypeScript
int -> longint64(i) 显式long l = i; 隐式拓宽i as number(已是 number)
int -> floatfloat64(i) 显式double d = i; 隐式隐式
float -> intint(f) 显式截断(int) f 显式截断Math.trunc(f)
string -> intstrconv.Atoi()Integer.parseInt()parseInt()
int -> stringstrconv.Itoa()String.valueOf(i)String(i) 或模板字符串
bool -> int不支持,需手写不支持Number(true)
null/nil 转换无隐式转换自动装箱/拆箱strictNullChecks 控制

关键差异:Java 的隐式拓宽转换(widening conversion)允许 int 自动转为 long/double(不丢失精度),Go 不做此区分–所有转换都必须显式。TypeScript 运行时是 JavaScript,类型转换动态,类型系统编译时检查但运行时无类型信息(类型擦除);Go 类型检查在编译时,运行时保留类型信息(通过 reflect 包查询)。

类型转换 vs 类型断言 vs 类型别名

这三个概念容易混淆:

类型转换(conversion):在兼容的底层类型间转换。

1
2
var i int = 42
var f float64 = float64(i) // 转换

类型断言(assertion):从接口类型提取具体类型(第 7 篇详述)。

1
2
var i interface{} = "hello"
s, ok := i.(string) // 断言

类型别名(alias):为已有类型创建新名字,两者完全等价。

1
2
3
type MyInt = int  // 别名,MyInt 就是 int,可直接赋值
var i int = 42
var m MyInt = i // 无需转换

类型定义(definition):创建新类型,底层类型相同但不可以直接赋值。

1
2
3
4
5
6
type Celsius float64    // 新类型
type Fahrenheit float64 // 新类型

var c Celsius = 100
// var f Fahrenheit = c // 编译错误: 不同类型
var f Fahrenheit = Fahrenheit(c) // 需要显式转换

类型定义是 Go 实现「语义化类型」的手段–Celsius 和 Fahrenheit 底层都是 float64 但是不同类型,不能混淆赋值。

横向对比与工程实践

包管理对比:Go Modules vs npm vs pip vs Maven

特性Go ModulesnpmpipMaven
配置文件go.modpackage.jsonrequirements.txt / pyproject.tomlpom.xml
锁文件go.sum(哈希)package-lock.json(版本+哈希)无(或 pip-tools)无(Maven 解析固定)
版本选择MVS(最大值)SAT 求解器最新匹配最近优先
依赖存储全局缓存 $GOPATH/pkg/mod项目内 node_modules全局 site-packages全局 ~/.m2/repository
多版本共存主版本不同可共存嵌套 node_modules不支持不支持
离线构建vendor 目录离线 npm难本地仓库
私有仓库GOPRIVATE.npmrc额外配置settings.xml
中心仓库无(去中心化)npm registryPyPIMaven Central

Go Modules 独特优势:全局共享依赖缓存,不同项目复用同一份源码;无 node_modules 黑洞问题;MVS 可预测、无版本冲突;去中心化–任何 Git 仓库都可作包源。劣势:依赖必须遵循 SemVer 和模块路径约定;不使用 Git 的内部仓库配置较繁琐;代理机制在国内可能有网络问题(需配置镜像)。

类型系统对比

特性GoJavaTypeScript
隐式转换无有(拓宽)有(运行时)
泛型Go 1.18+有有
联合类型无(用接口)无有
可空类型无(用指针/接口 nil)OptionalT | null
枚举iota 常量enumenum / union
运算符重载无无无
类型推断有(:=)有(var)有

Go 类型系统刻意保持简单–无继承、无运算符重载、无隐式转换,代价是某些场景的冗长,换来代码的透明性和可维护性。

工程化建议

  1. 模块路径要规范:使用 GitHub 等代码托管地址作为模块路径,保证未来可发布共享。
  2. go mod tidy 应作为 CI 步骤:提交前运行,确保 go.mod 和 go.sum 干净。
  3. 谨慎使用 replace:指向本地路径的 replace 提交前要移除或改为正式版本,否则他人无法构建。
  4. 内部包放 internal/:实现细节、不愿暴露的 API 放入 internal/,利用编译器强制访问控制。
  5. 避免点导入:除测试文件断言库外,不要使用 . 导入。
  6. 合理使用空白导入:仅用于自注册场景(驱动注册、protobuf 注册),并添加注释说明原因。
  7. 零值可用设计:自定义类型尽量做到零值可用,减少构造函数依赖。
  8. 版本管理:发布公开模块时遵循 SemVer,v2+ 必须更新模块路径。

常见陷阱

陷阱1:go.mod 中 go 版本意外升级

go mod tidy 在较新 Go 上运行时,可能将 go 指令升级到当前 Go 版本。需兼容旧版 Go 时应固定:

1
go mod edit -go=1.21

陷阱2:主版本号路径遗漏

使用 v2+ 的库时,忘记在导入路径中加 /v2:

1
2
3
4
// 错误: 找不到模块或导入 v1
import "github.com/foo/bar"
// 正确: v2 的导入路径
import "github.com/foo/bar/v2"

陷阱3:replace 提交到主分支

开发时用 replace 指向本地路径联调,忘记移除就提交:

1
replace github.com/foo/bar => /home/user/local/bar  // 提交后其他人无法构建

应在代码审查中检查 replace 指令,或用脚本在 CI 中验证。

陷阱4:循环依赖编译失败

大型项目中两个包互相引用时编译失败。解决方案:提取公共接口到独立包,或通过依赖注入解耦。

陷阱5:int 跨平台不一致

32 位平台上 int 是 4 字节,可能导致溢出或与 64 位平台行为不一致。涉及大数值计算时用 int64。

陷阱6:byte 和 rune 混淆

1
2
3
4
5
6
s := "世界"
fmt.Println(len(s)) // 6(字节)
fmt.Println(len([]rune(s))) // 2(rune)
for i, c := range s {
fmt.Printf("%d: %c\n", i, c) // i 是字节索引,不是 rune 索引
}

陷阱7:字符串修改

1
2
3
4
5
s := "hello"
// s[0] = 'H' // 编译错误: 字符串不可变
b := []byte(s)
b[0] = 'H'
s = string(b) // 正确: 通过 []byte 中转

string ↔ []byte 转换会分配内存拷贝,热路径上应避免频繁转换。

附录:Go 全部保留字

Go 的保留字(keywords)共 25 个,不能用作标识符。按功能分组:

类别保留字说明
声明const, var, type, func常量、变量、类型、函数声明
包与导入package, import包声明与导入
流程控制if, else, for, range, switch, case, default, break, continue, goto, fallthrough条件、循环、分支、跳转
复合类型struct, interface, map, chan结构体、接口、映射、通道
并发go, defer启动 goroutine、延迟执行
其他return, select, iota返回值、多路复用、常量计数器

按字母顺序排列

1
2
3
4
5
6
break     case      chan      const     continue
default defer else fallthrough for
func go goto if import
interface map package range return
select struct switch type var
iota

注意:iota 在技术上不是保留字,而是预声明标识符(predeclared identifier),但同样不能用作自定义标识符。Go 的预声明标识符还包括 true、false、nil、append、make、len、cap 等,完整列表见 官方文档。

与其他语言对比

语言保留字数量特点
Go25极简设计,无 class/extends/public/private 等面向对象关键字
Java50+面向对象、异常、泛型等关键字较多
C++90+关键字最多,含大量修饰符和模板相关关键字
Python35包含 async/await、with、yield 等现代特性关键字

Go 的保留字数量是主流语言中最少的之一,体现了「少即是多」的设计哲学。

本篇小结

本篇从 Hello World 出发,系统讲解了 Go 的工程基础设施和基础类型系统:

  • 构建工具:go run 用于快速运行,go build 生成可执行文件,go install 安装到 $GOBIN。Go 的交叉编译只需设置 GOOS/GOARCH,开箱即用。
  • Go Modules:基于 go.mod 和 go.sum 实现可复现构建。MVS 算法选择「最大最低要求版本」,简单可预测,不存在 npm 式的版本冲突。主版本号 >= 2 时路径需加版本后缀,使不同主版本可共存。
  • 包导入:标准库直接导入,第三方包用完整路径,内部包用模块路径前缀。别名导入解决冲突,点导入简化测试断言,空白导入实现自注册。Go 严格禁止循环依赖,需通过接口和依赖倒置解耦。
  • 包声明与导出:package main 是可执行入口。首字母大小写控制可见性–大写导出、小写私有,包是封装边界而非类。init() 用于自注册和初始化,internal/ 目录提供模块级私有包约束。
  • 基本类型:完整的整数/浮点/复数类型体系,int 平台相关,byte/rune 是语义化别名。零值机制保证无未初始化变量,类型设计应追求零值可用。Go 无隐式类型转换,所有转换必须显式 T(v)。
  • 保留字:Go 仅 25 个保留字,极简设计,无 class/extends/public/private 等面向对象关键字。

这些是 Go 工程化的地基。下一篇将深入字符串的底层结构、变量声明方式与常量机制,继续构建 Go 语言的核心知识体系。