📚 Golang 教程系列

  1. 入门与基础类型(本文)
  2. 字符串、变量与常量
  3. 流程控制
  4. 集合类型
  5. 函数、指针与类型
  6. 结构体
  7. 接口
  8. 错误处理、并发与泛型
  9. 测试与工程实践
  10. 杂项:make 与 select
  11. 杂项:embed 与资源嵌入

从 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 交叉编译只需两个环境变量,无需安装工具链:

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.goname_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

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

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.0B 要求 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(所有要求中的最大值)。

关键特征

  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.txtgo.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"
)

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

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.Argsflag 包获取;退出码通过 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)语义化别名

整数类型

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

int 的平台相关性intuint 大小不固定,取决于编译目标 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_addwrapping_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

byterune 不是新类型,而是已有类型的别名:

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 位),值为 truefalse。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.Mutexbytes.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.Itoafmt.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 实现「语义化类型」的手段–CelsiusFahrenheit 底层都是 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.modgo.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 的预声明标识符还包括 truefalsenilappendmakelencap 等,完整列表见 官方文档

与其他语言对比

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

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

本篇小结

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

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

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