Go 输入与输出:从变量声明到工程级 I/O
面向有 Java 经验的开发者,基于 Go 1.26。
刚接触 Go 时,输入输出很容易被理解成 fmt.Println 和 fmt.Scan 的用法合集。代码一旦写进项目,变量作用域、零值、接口、指针、Unicode、缓冲、错误传播、资源释放和并发安全便会接连出现。先弄清这些函数背后的模型,比背函数名更有用:文件、网络连接、压缩流、HTTP Body 乃至测试桩,最终都可以落到不同的 io.Reader 或 io.Writer 上。
这一章既可以顺序阅读,也可以用作速查手册。后文统一使用“包级变量”,不再沿用一些入门资料中的“全局变量”,因为 Go 的可见性边界以包为核心。
目录
1. 先看一段完整程序
先从一段能运行的程序开始。变量声明、标准输入、格式化和错误处理都在里面,同时还能看到标准输出与标准错误输出应当怎样分工:
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func main() {
in := bufio.NewReader(os.Stdin)
fmt.Fprint(os.Stderr, "请输入名字:")
name, err := in.ReadString('\n')
if err != nil {
fmt.Fprintln(os.Stderr, "读取名字失败:", err)
os.Exit(1)
}
name = strings.TrimSpace(name)
if name == "" {
fmt.Fprintln(os.Stderr, "名字不能为空")
os.Exit(1)
}
fmt.Printf("你好,%s!\n", name)
}这段程序不长,不过有四个习惯最好从第一天就保留下来:
- 提示信息写到
os.Stderr,业务结果写到os.Stdout。以后程序接入管道时,不会把提示语混进数据。 - 读取函数返回的
error必须处理。 - 需要整行文本时,不能用只按空白分词的
fmt.Scan。 - 输入数据进入业务逻辑前,先做清理和校验。
2. 变量声明
2.1 四种常见写法
package main
import "fmt"
func main() {
// 1. 只声明,得到该类型的零值
var name string
name = "枫枫"
// 2. 显式类型和初始值
var userName string = "枫枫知道"
// 3. 由右侧表达式推断类型
var city = "上海"
// 4. 简短变量声明,只能写在函数内部
age := 18
fmt.Println(name, userName, city, age)
}Go 的类型推断发生在编译期,它省掉的是类型书写,不会把变量变成动态类型:
count := 1 // count 的静态类型是 int
count = 2 // 正确
// count = "2" // 编译错误:不能把 string 赋给 int与 Java 对照:
var name string接近String name = null的声明形式,但值并不是null,而是空字符串""。name := "枫枫"的作用接近 Java 的var name = "枫枫",都是局部变量类型推断。- Go 的
var既能显式写类型,也能推断;:=是“声明并初始化”,不是普通赋值运算符。
2.2 声明和赋值不是一回事
var n int // 声明
n = 10 // 赋值
m := 20 // 声明并初始化
m = 30 // 赋值同一作用域内,单变量不能重复使用 :=:
name := "A"
// name := "B" // 编译错误:左侧没有新变量
name = "B" // 正确多变量简短声明有一条很重要的例外:左侧至少有一个新变量时,已存在的同作用域变量可以同时参与赋值。
value, err := load()
// value 已存在,status 是新变量,因此合法。
// 这里的 value 是赋值,不是重新声明。
value, status := normalize(value)
_, _, _ = err, value, status处理 (value, error) 返回值时经常会用到这条规则。它也会制造阴影变量,后文会单独说明。
2.3 一次声明多个变量
var x, y, z int
var first, last = "San", "Zhang"
width, height := 1920, 1080
var (
host = "127.0.0.1"
port = 8080
timeout = 30
)同一组变量不要求类型相同,只要每个右值都能赋给对应左值即可。
Go 支持平行赋值。右侧会先求值,再统一赋给左侧:
a, b := 1, 2
a, b = b, a // 交换,不需要临时变量函数也可以返回多个值:
func divide(a, b int) (int, int) {
return a / b, a % b
}
quotient, remainder := divide(10, 3)Go 的多返回值不是 Java 的数组、元组对象或 record,而是语言直接提供的返回形式。它只能出现在特定上下文中,不能把 (quotient, remainder) 当成一个普通值继续传递。
2.4 零值
变量只声明、不显式初始化时,会递归获得其类型的零值:
| 类型 | 零值 | 备注 |
|---|---|---|
bool | false | |
| 整数、浮点数、复数 | 0 | |
string | "" | 不是 nil |
| 指针 | nil | |
| slice | nil | len 和 cap 都是 0,可直接 append |
| map | nil | 可读取,写入会 panic |
| channel | nil | 收发会永久阻塞,关闭会 panic |
| function | nil | 调用会 panic |
| interface | nil | 动态类型和动态值都为空 |
| array | 每个元素的零值 | 数组本身不是 nil |
| struct | 每个字段的零值 | struct 本身不是 nil |
零值不只是初始化规则,也是 Go API 设计中的一项约定。类型的零值如果可以直接使用,调用方就少了一条“必须先调用构造函数”的前置条件:
var b strings.Builder // 零值可用
b.WriteString("hello")
var mu sync.Mutex // 零值可用
mu.Lock()
mu.Unlock()不过,不能由此推断所有标准库类型的零值都可用,具体仍以类型文档为准。例如 bufio.Reader 通常通过 bufio.NewReader 创建。
nil slice、空 slice 和 nil map
var a []int // nil slice
b := []int{} // 非 nil 的空 slice
c := make([]int, 0) // 非 nil 的空 slice
fmt.Println(len(a), len(b), len(c)) // 都是 0
a = append(a, 1) // 合法
var m map[string]int
fmt.Println(m["missing"]) // 读取合法,得到 0
// m["x"] = 1 // panic: assignment to entry in nil map
m = make(map[string]int)
m["x"] = 1三种空 slice 在多数业务逻辑中等价,但序列化结果可能不同。例如某些 JSON 协议会把 nil slice 编码成 null,把非 nil 空 slice 编码成 []。这不是“零值细节”,而是接口契约的一部分。
2.5 new、make 和取地址
这三个概念经常被 Java 开发者混在一起:
p1 := new(int) // *int,指向值为 0 的 int
value := 42
p2 := &value // *int,指向 value
s := make([]int, 0, 8) // []int,初始化 slice 描述符及底层数组
m := make(map[string]int) // map[string]int,初始化 map
ch := make(chan string, 1) // chan string,初始化 channelnew(T)返回*T,指向一个零值T。make只用于 slice、map、channel,返回的是T,不是*T。&value取得已有变量的地址。- Go 没有 Java 构造器语义;
new只负责分配和初始化,不会自动调用某个构造方法。
Go 1.26 允许把表达式直接传给 new:
age := new(18) // *int,指向值为 18 的 int
type Person struct {
Name string
Age *int // nil 表示未知,非 nil 表示已知
}
p := Person{
Name: "枫枫",
Age: new(18),
}new(18) 与过去常见的辅助函数 ptr(18) 用途相近,特别适合 JSON、Protobuf 等使用指针表达“字段是否存在”的模型。若模块声明的语言版本低于 Go 1.26,这种写法不能使用。
2.6 Go 是值传递
Go 只有值传递。把变量赋给另一个变量,或传入函数,传递的都是值的副本:
type Point struct {
X int
Y int
}
p1 := Point{X: 1, Y: 2}
p2 := p1
p2.X = 99
fmt.Println(p1.X) // 1容易产生误解的是,slice、map、channel、function 和指针的值内部含有对底层数据的引用。复制这些值后,两个副本可能仍共享底层数据:
a := []int{1, 2, 3}
b := a
b[0] = 99
fmt.Println(a[0]) // 99,共享底层数组“Go 是值传递”和“多个值可以共享底层数据”并不冲突。前者描述参数传递方式,后者描述某些值的内部结构。
2.7 包级变量、局部变量与初始化顺序
package main
import "fmt"
var appName = "go-study" // 包级变量
func main() {
version := "1.0" // 局部变量
fmt.Println(appName, version)
}更精确地说:
- 函数外声明的是包级变量,同一个包的所有源文件都可访问。
- 函数、
if、for、switch、select等块内声明的是局部变量。 - 包级变量允许声明后不在本包使用。
- 局部变量声明后必须使用,否则编译失败。
- 未使用的 import 同样会导致编译失败。
- 函数参数和接收者即使未使用,也允许编译。
包初始化有明确的依赖顺序:先初始化导入的包,再按声明依赖初始化本包的包级变量,随后按编译器接收的文件顺序调用各个 init 函数,最后才执行 main.main。构建系统通常按文件名顺序把文件交给编译器,但业务代码不应依赖这种跨文件的隐含先后关系。网络请求、文件读取等复杂副作用也不适合塞进包级初始化,放到显式构造函数或 main 中更容易控制失败和生命周期。
2.8 作用域与变量遮蔽
Go 使用词法作用域。内层块可以声明一个与外层同名的新变量:
count := 10
if true {
count := 20 // 新变量,遮蔽外层 count
fmt.Println(count) // 20
}
fmt.Println(count) // 10最危险的情况通常与 err 有关:
var data []byte
var err error
if data, err := os.ReadFile("config.json"); err != nil {
fmt.Println(err)
} else {
fmt.Println(len(data))
}
// 上面的 data 和 err 只属于 if/else 的隐式作用域。
// 这里看到的仍是外层零值。
_ = data
_ = err如果需要给外层变量赋值,用普通赋值:
data, err = os.ReadFile("config.json")
if err != nil {
return
}另一个常见错误发生在错误处理块中:
result, err := step1()
if err != nil {
return err
}
result, err = step2(result) // 两个变量都已存在,应该使用 =
if err != nil {
return err
}go vet 和静态检查工具可以发现一部分遮蔽问题,却无法代替代码结构本身。缩短作用域、控制函数长度,才是减少这类问题的主要手段。
2.9 空白标识符 _
_ 表示“这个位置需要一个值,但我明确不使用它”:
value, _ := strconv.Atoi("42") // 示例而已,生产代码不应随便丢弃错误
for _, item := range items {
fmt.Println(item)
}_ 不会创建变量,也不能读取。它常用于:
- 忽略多返回值中的某一项;
- 忽略
range的索引或值; - 编译期接口实现检查;
- 只执行某个包的
init副作用导入。
var _ io.Writer = (*bytes.Buffer)(nil)不要为了让代码“先编译”而随意写 _ = value 或忽略 error。空白标识符应该表达明确意图,而不是掩盖未完成的逻辑。
3. 常量
3.1 基本声明
const name string = "枫枫"
const age = 18
const (
defaultHost = "127.0.0.1"
defaultPort = 8080
)常量必须在声明时给出值,之后不能修改。Go 常量只能是布尔值、数字或字符串,不能是 slice、map、struct、函数,也不能是运行期才能得到的结果:
const timeout = 3 * time.Second // 合法,time.Second 本身是常量
// const now = time.Now() // 非法:函数调用不是常量表达式
// const names = []string{"A"} // 非法:slice 不能是常量Go 没有 Java 的 final 局部变量或只读对象引用。若需要不可变数据结构,通常依靠不导出字段、只暴露只读方法,以及返回副本来维护约束。
3.2 有类型常量与无类型常量
这是 Go 常量体系中最值得理解的部分:
const untyped = 100 // 无类型整数常量
const typed int64 = 100 // int64 类型常量
var a int = untyped // 合法
var b int64 = untyped // 合法
var c float64 = untyped // 合法
// var d int = typed // 非法:int64 不能隐式赋给 int
var d int = int(typed) // 显式转换无类型常量在编译期拥有比普通变量更高的精度,只有进入需要具体类型的上下文时才检查能否表示:
const Big = 1 << 100
// fmt.Println(Big) // 通常无法作为默认 int 传入接口,编译期溢出
var x = Big >> 90 // 1024,可表示为默认 int
_ = x这与 Java 的字面量提升和强制类型转换规则不同。阅读编译错误时,重点看“constant overflows”或“not representable”,而不是寻找 Java 式的自动数值转换。
3.3 iota
iota 是当前 const 声明组中的行号计数器,从 0 开始:
type Status int
const (
StatusUnknown Status = iota // 0
StatusPending // 1,重复上一行完整表达式
StatusRunning // 2
StatusDone // 3
)常见用途是枚举和位标志:
type Permission uint
const (
PermissionRead Permission = 1 << iota // 1
PermissionWrite // 2
PermissionDelete // 4
)
const PermissionAll = PermissionRead | PermissionWrite | PermissionDelete有意跳过值时可以使用 _:
const (
_ = iota // 跳过 0
KB = 1 << (10 * iota) // 1 << 10
MB // 1 << 20
GB // 1 << 30
)不要把数据库或网络协议中长期稳定的编号完全交给 iota。在声明组中间插入一行会改变后续值;外部协议更适合显式写出数字并配测试锁定。
4. 命名与可见性
原教程里的“首字母大写可以在包外访问”方向没错,但需要加上边界:
- 声明在包级的标识符,或 struct 的字段名、方法名,以 Unicode 大写字母开头时,是导出的。
- 小写开头的标识符只能在当前包内访问。
- 局部变量即使首字母大写,也不会因为大写而跨函数访问。
- Go 没有 Java 的
public、protected、private关键字。
package user
type User struct {
ID int // 导出,其他包可访问
Name string // 导出
password string // 不导出,只能在 user 包内访问
}
func (u User) DisplayName() string { // 导出方法
return u.Name
}
func (u User) validate() error { // 包内方法
return nil
}惯用命名:
- 使用
camelCase或PascalCase,不使用 Java 常见的全大写常量风格。 - 缩写保持一致:
userID、HTTPServer、parseURL,而不是userId、HttpServer。 - 包名短、小写、单数,调用时读起来不重复:
http.Server,而不是httputil.HTTPServer。 - 小作用域允许短名字:循环里的
i、reader 的r、writer 的w、错误的err。 - 大作用域和领域对象使用能表达含义的名字。
- getter 通常叫
Name(),而不是GetName();setter 可以叫SetName()。
避免遮蔽预声明标识符:
// 不推荐
len := 10
error := "failed"
new := func() {}这在语法上合法,但会让同一作用域内无法正常使用内建的 len、error、new。
5. 基础输出:fmt 家族
5.1 Print、Println、Printf
fmt.Print("hello") // 不自动换行
fmt.Println("hello", 42, true) // 参数之间按规则加空格,末尾换行
fmt.Printf("name=%s age=%d\n", "枫枫", 18)三者默认写到 os.Stdout。对应的函数族可以按“输出到哪里”来记:
| 目标 | 默认格式 | 格式字符串 | 结果 |
|---|---|---|---|
| 标准输出 | fmt.Print / Println | fmt.Printf | 写入 os.Stdout |
io.Writer | fmt.Fprint / Fprintln | fmt.Fprintf | 返回写入字节数和错误 |
| 字符串 | fmt.Sprint / Sprintln | fmt.Sprintf | 返回字符串 |
[]byte 追加 | fmt.Append / Appendln | fmt.Appendf | 返回追加后的字节切片 |
message := fmt.Sprintf("user=%s score=%d", "枫枫", 95)
var b strings.Builder
_, err := fmt.Fprintf(&b, "user=%s score=%d", "枫枫", 95)
if err != nil {
// strings.Builder 的 Write 不会返回实际错误,
// 但换成文件或网络连接后就可能失败。
}
buf := make([]byte, 0, 64)
buf = fmt.Appendf(buf, "user=%s score=%d", "枫枫", 95)5.2 最常用的格式化动词
通用
| 动词 | 含义 | 示例 |
|---|---|---|
%v | 默认格式 | fmt.Printf("%v", value) |
%+v | struct 增加字段名 | {Name:枫枫 Age:18} |
%#v | 尽量按 Go 语法表示 | main.User{Name:"枫枫", Age:18} |
%T | 值的动态类型 | string、main.User |
%p | 指针地址,十六进制 | 0xc000... |
%% | 字面量 % | 100% |
%#v 很适合调试空字符串、nil 和复合值,但它不是稳定的序列化协议。不要解析 %#v 的输出,也不要把它当 JSON。
布尔与整数
| 动词 | 含义 |
|---|---|
%t | true / false |
%d | 十进制整数 |
%b | 二进制 |
%o / %O | 八进制,%O 带 0o 前缀 |
%x / %X | 十六进制 |
%c | 对应 Unicode 码点 |
%q | 带引号的字符字面量 |
%U | Unicode 格式,如 U+4F60 |
n := 65
fmt.Printf("%d %b %o %#x %c %q %U\n", n, n, n, n, n, n, n)
// 65 1000001 101 0x41 A 'A' U+0041浮点数与复数
| 动词 | 含义 |
|---|---|
%f | 普通小数 |
%e / %E | 科学计数法 |
%g / %G | 根据数值选择紧凑形式 |
%x / %X | 十六进制浮点表示 |
pi := 3.1415926
fmt.Printf("%.2f %.3e %g\n", pi, pi, pi)%.2f 表示小数点后两位,不代表金融计算的精确小数语义。金额不要直接依赖二进制浮点和输出舍入;常见做法是用最小货币单位的整数,或选择明确的十进制定点方案。
字符串、字节与 Unicode
| 动词 | 含义 |
|---|---|
%s | 字符串或字节序列 |
%q | 双引号包围并转义 |
%x / %X | 每个字节以十六进制输出 |
% x | 十六进制字节之间加空格 |
s := "你好"
fmt.Printf("%s\n", s) // 你好
fmt.Printf("%q\n", s) // "你好"
fmt.Printf("%x\n", s) // e4bda0e5a5bd
fmt.Printf("% x\n", s) // e4 bd a0 e5 a5 bd5.3 宽度、精度和标志
格式的一般结构可以理解为:
%[参数索引][标志][宽度][.精度]动词常用标志:
| 标志 | 作用 |
|---|---|
+ | 数字总是显示符号;%+v 为 struct 显示字段名 |
- | 左对齐 |
0 | 用零填充数字 |
# | 使用替代格式,如 0x 前缀或 Go 语法 |
| 空格 | 为正数预留符号位;与 %x 配合时分隔字节 |
fmt.Printf("|%10s|\n", "go") // 右对齐
fmt.Printf("|%-10s|\n", "go") // 左对齐
fmt.Printf("%08d\n", 42) // 00000042
fmt.Printf("%#x\n", 255) // 0xff
fmt.Printf("%+.2f\n", 3.5) // +3.50宽度和精度也可以来自参数:
width, precision := 8, 2
fmt.Printf("%*.*f\n", width, precision, 3.14159)重复使用参数时可以显式索引,索引从 1 开始:
fmt.Printf("%[1]d %[1]x %[1]b\n", 255)5.4 格式错误不会返回 error
Printf 的格式和参数不匹配时,通常不会像 Java 格式化那样抛异常,而是把诊断文本写进结果:
fmt.Printf("%d\n", "hello")
// %!d(string=hello)常见诊断包括:
%!d(string=hello):动词和参数类型不匹配;%!s(MISSING):缺少参数;%!(EXTRA int=1):参数过多;%!(BADWIDTH)、%!(BADPREC):宽度或精度参数不合法。
go vet 能静态检查许多 Printf 风格的调用:
go vet ./...如果自己封装了日志函数,可以通过合适的命名、注释和分析器配置让工具继续检查;不要等线上日志出现 %! 才发现问题。
5.5 自定义格式化:Stringer、GoStringer、Formatter
fmt.Stringer
实现 String() string 后,fmt 会在适当的格式下调用它:
type User struct {
ID int
Name string
}
func (u User) String() string {
return fmt.Sprintf("%s(%d)", u.Name, u.ID)
}
func main() {
u := User{ID: 7, Name: "枫枫"}
fmt.Println(u) // 枫枫(7)
}String 不应修改接收者,不应产生不可控副作用,也不要泄露密码、token 等敏感字段。
避免递归调用自己:
type Label string
func (l Label) String() string {
// 错误写法:fmt.Sprintf("<%s>", l) 可能再次调用 String
return fmt.Sprintf("<%s>", string(l))
}fmt.GoStringer
实现 GoString() string 后,%#v 会使用它,适合给调试输出提供 Go 风格表示。
fmt.Formatter
需要控制动词、宽度、精度和标志时,实现:
type Formatter interface {
Format(state fmt.State, verb rune)
}Formatter 是最强的扩展点,也最容易写错。实现时要处理未知动词、写入错误以及递归格式化。普通业务类型优先实现 Stringer;只有库类型或确实存在多种格式表示时,再实现 Formatter。
fmt 的格式化优先级大致是:Formatter → %#v 下的 GoStringer → error → Stringer → 默认反射格式。知道这个顺序,才能解释为什么某个类型没有按预期输出。
5.6 内建 print / println 不是业务输出 API
Go 还有预声明的 print 和 println,但规范只把它们保留给引导和调试,实现相关的格式并不稳定。项目代码应使用 fmt、log/slog 或明确的 io.Writer。
6. 输出目标:os.Stdout、os.Stderr 与 io.Writer
6.1 三个标准文件
os.Stdin // *os.File,标准输入
os.Stdout // *os.File,标准输出
os.Stderr // *os.File,标准错误命令行程序中,建议把可供下游消费的数据写到 stdout,把提示、进度和诊断写到 stderr:
fmt.Fprintln(os.Stderr, "正在读取配置……")
fmt.Fprintln(os.Stdout, `{"status":"ok"}`)这样用户可以安全地执行:
go run . > result.json6.2 io.Writer 是输出的核心抽象
接口定义非常小:
type Writer interface {
Write(p []byte) (n int, err error)
}文件、网络连接、压缩器、哈希器、HTTP ResponseWriter、bytes.Buffer、strings.Builder 都可以作为 writer。业务函数接受 io.Writer 后,不需要知道目标究竟是什么:
func WriteGreeting(w io.Writer, name string) error {
_, err := fmt.Fprintf(w, "你好,%s!\n", name)
return err
}生产环境写法:
if err := WriteGreeting(os.Stdout, "枫枫"); err != nil {
fmt.Fprintln(os.Stderr, "输出失败:", err)
os.Exit(1)
}测试时:
var buf bytes.Buffer
if err := WriteGreeting(&buf, "枫枫"); err != nil {
t.Fatal(err)
}
if got, want := buf.String(), "你好,枫枫!\n"; got != want {
t.Fatalf("got %q, want %q", got, want)
}这与 Java 把 OutputStream 或 Writer 注入业务类的思想相近,但 Go 通常直接依赖最小接口,不必建立庞大的抽象层。
6.3 缓冲输出
大量小写操作可以用 bufio.Writer 合并:
writer := bufio.NewWriter(os.Stdout)
if _, err := writer.WriteString("第一行\n"); err != nil {
return err
}
if _, err := fmt.Fprintln(writer, "第二行"); err != nil {
return err
}
if err := writer.Flush(); err != nil {
return err
}忘记 Flush 是最常见的缓冲输出 bug。Write 成功只表示数据进入缓冲区,最终写入底层目标时仍可能失败,因此 Flush 的错误也必须处理。
在需要汇总多个清理错误的函数中,可以使用命名返回值谨慎合并错误:
func writeReport(w io.Writer) (err error) {
bw := bufio.NewWriter(w)
defer func() {
err = errors.Join(err, bw.Flush())
}()
_, err = fmt.Fprintln(bw, "report")
return err
}6.4 strings.Builder 与 bytes.Buffer
要在内存中构造字符串:
var b strings.Builder
b.Grow(64) // 可选:已知大致大小时减少扩容
b.WriteString("hello")
b.WriteByte(',')
b.WriteRune('世')
result := b.String()要同时处理字节和字符串,或需要把缓冲区当 io.Reader:
var b bytes.Buffer
b.WriteString("hello")
b.Write([]byte{'\n'})
data := b.Bytes()
text := b.String()选择原则:
- 最终目标明确是字符串:优先
strings.Builder。 - 需要
[]byte、读取、重用或二进制操作:使用bytes.Buffer。 - 只是拼接少量固定片段:直接
a + b往往最清楚,编译器也可能优化。 - 循环中反复
s += part可能产生大量复制,应使用 builder 或 buffer,并以基准测试确认。
非零的 strings.Builder 不应被复制;复制后继续写可能 panic。通常用局部变量并传指针即可。
6.5 日志不是 fmt.Println
练习程序用 fmt.Println 没问题。服务端程序需要时间、级别、字段、上下文和稳定格式时,应使用结构化日志:
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
logger.Info(
"user login",
slog.Int("user_id", 42),
slog.String("remote_ip", "127.0.0.1"),
)几个工程习惯:
- 日志通常写 stderr,不污染 stdout 上的数据协议。
- 使用稳定字段名,不把所有信息拼进 message。
- 不记录密码、访问令牌、身份证号等敏感数据。
- 错误对象作为字段保留,而不是只保存模糊文本。
- 高频路径在构造昂贵日志参数前,可先检查相应级别是否启用。
Go 1.26 的 slog.NewMultiHandler 可以把同一记录发送给多个 handler,但输出链越复杂,越要明确错误策略、性能成本和字段脱敏规则。
7. 基础输入:fmt.Scan 家族
7.1 &name 的真正含义
var name string
n, err := fmt.Scan(&name)fmt.Scan 需要修改调用方变量,因此接收的是变量地址。&name 的类型是 *string。这不是 Scan 的特殊语法,而是 Go 指针的普通用法。
永远检查返回值:
var name string
n, err := fmt.Scan(&name)
if err != nil {
fmt.Fprintln(os.Stderr, "读取失败:", err)
return
}
if n != 1 {
fmt.Fprintln(os.Stderr, "没有读到名字")
return
}7.2 Scan、Scanln、Scanf
| 函数 | 行为 |
|---|---|
fmt.Scan | 从 stdin 按空白分隔读取;换行也视为空白 |
fmt.Scanln | 从 stdin 读取,到换行停止 |
fmt.Scanf | 按格式字符串解析 stdin |
fmt.Fscan 等 | 从指定 io.Reader 读取 |
fmt.Sscan 等 | 从字符串读取 |
var name string
var age int
if _, err := fmt.Scan(&name, &age); err != nil {
fmt.Fprintln(os.Stderr, "输入格式错误:", err)
return
}输入:
枫枫 18Scan 适合空白分隔的竞赛题、简单脚本和演示,不适合读取带空格的姓名、地址或自由文本:
张 三上面会被看作两个 token。
Scanf 的空白和换行匹配规则与 C 的 scanf 并不完全相同,格式稍复杂就容易出错。业务程序通常更稳妥的做法是:先读取完整一行,再用 strings 和 strconv 做明确解析。
7.3 从字符串解析,便于测试
Sscan 不依赖标准输入:
var id int
var name string
n, err := fmt.Sscan("42 fengfeng", &id, &name)
if err != nil {
return err
}
if n != 2 {
return fmt.Errorf("需要 2 个字段,实际得到 %d 个", n)
}但如果格式是机器协议,优先考虑 CSV、JSON、XML 或二进制编码库,不要发明依赖空格的脆弱协议。
8. 行输入:bufio.Scanner 与 bufio.Reader
8.1 bufio.Scanner:最方便的逐行读取
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := scanner.Text()
fmt.Printf("读到:%q\n", line)
}
if err := scanner.Err(); err != nil {
fmt.Fprintln(os.Stderr, "读取失败:", err)
}默认的 split 函数是 bufio.ScanLines,返回的文本不包含行尾。也可以按单词、rune 或字节拆分:
scanner.Split(bufio.ScanWords)内建 split 函数:
bufio.ScanLinesbufio.ScanWordsbufio.ScanRunesbufio.ScanBytes
还可以实现自定义 bufio.SplitFunc,用于逗号分隔或自定义帧协议。不过真正的 CSV 应使用 encoding/csv,不要只按逗号切字符串,因为引号、转义和换行都有专门规则。
8.2 Scanner 的 64 KiB 限制
Scanner 默认 token 上限为 64 KiB 左右。日志行、压缩后的长行、Base64、SQL 或用户粘贴文本都可能超过它:
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 64*1024), 1024*1024) // 最大 1 MiB必须在第一次 Scan 之前调用 Buffer。上限不是越大越好;它同时是内存和安全边界。输入完全不可信时,应根据协议设置合理上限。
scanner.Bytes() 返回的数据可能在下一次 Scan 时被覆盖,需要长期保存就复制:
token := append([]byte(nil), scanner.Bytes()...)scanner.Text() 返回字符串,使用更方便,但涉及字符串分配。性能敏感路径应通过 benchmark 决定,而不是凭感觉优化。
8.3 bufio.Reader:需要更多控制时使用
读取一整行:
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return err
}
// 即使 err == io.EOF,line 也可能含有最后一段有效数据。
line = strings.TrimRight(line, "\r\n")ReadString 返回的数据包含分隔符。如果遇到错误前已经读到数据,会同时返回数据和错误。处理最后一行没有换行的文件时,不能一看到 io.EOF 就丢弃 line。
ReadBytes 与 ReadString 类似,只是返回 []byte。ReadSlice 可能直接引用内部缓冲区,下一次读取后内容失效,适合对分配敏感且清楚生命周期的代码。
选择:
- 简单逐行、逐词扫描:
bufio.Scanner。 - token 可能很大、需要保留分隔符、需要
Peek/UnreadByte/ReadString等控制:bufio.Reader。 - 固定长度或二进制协议:直接使用
io.Reader、io.ReadFull或encoding/binary。
8.4 一种更可靠的“读取并解析整数”
func ReadInt(r *bufio.Reader) (int, error) {
line, err := r.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return 0, fmt.Errorf("读取整数: %w", err)
}
line = strings.TrimSpace(line)
if line == "" {
return 0, errors.New("输入为空")
}
value, parseErr := strconv.Atoi(line)
if parseErr != nil {
return 0, fmt.Errorf("解析 %q 为整数: %w", line, parseErr)
}
return value, nil
}这种写法比 fmt.Scan(&n) 多几行,却把“读取失败”“空输入”“格式错误”区分开了,测试也容易:
reader := bufio.NewReader(strings.NewReader("42\n"))
value, err := ReadInt(reader)9. Unicode、string、byte 与 rune
Go 源代码采用 UTF-8,字符串是只读字节序列,不保证内容一定是合法 UTF-8。
s := "你好"
fmt.Println(len(s)) // 6:字节数
fmt.Println(utf8.RuneCountInString(s)) // 2:Unicode 码点数索引字符串得到 byte:
fmt.Printf("%x\n", s[0]) // e4range 字符串得到 byte 偏移和 rune:
for byteIndex, r := range s {
fmt.Printf("byte=%d rune=%c code=%U\n", byteIndex, r, r)
}rune 是 int32 的别名,表示 Unicode 码点;byte 是 uint8 的别名。两者是别名,不是独立的新类型。
还要注意:“码点数”不等于用户看到的字符数。组合音标、emoji 肤色修饰、家庭 emoji 等可能由多个码点组成。涉及光标移动、文本截断和界面宽度时,需要按 grapheme cluster 处理,不能只用 len 或 rune 数量。
输入边界建议:
- 文本协议先判断
utf8.Valid或utf8.ValidString; - 不要按任意字节位置截断 UTF-8 文本;
strings.TrimSpace按 Unicode 空白处理,TrimRight(s, "\r\n")只去除指定行尾;- 大小写无关比较可用
strings.EqualFold,不要简单ToLower(a) == ToLower(b); - 用户名、路径和安全标识符是否需要 Unicode 归一化,应由业务协议明确规定。
10. io.Reader:Go I/O 的另一半
接口同样只有一个方法:
type Reader interface {
Read(p []byte) (n int, err error)
}10.1 正确理解 (n, err)
一次 Read 可以同时返回 n > 0 和非 nil 的 err。调用方必须先处理读到的字节,再处理错误:
buf := make([]byte, 32*1024)
for {
n, err := r.Read(buf)
if n > 0 {
if _, writeErr := w.Write(buf[:n]); writeErr != nil {
return writeErr
}
}
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
}不过手写复制循环通常没有必要,直接用:
_, err := io.Copy(w, r)io.Copy 会处理读取循环、EOF 和部分写入,并可能利用 WriterTo 或 ReaderFrom 走更高效的实现。
10.2 定长读取
一次 Read(p) 不保证填满 p。网络流和文件读取都可能只返回一部分:
header := make([]byte, 16)
n, err := io.ReadFull(r, header)
if err != nil {
return fmt.Errorf("读取 16 字节头部,只得到 %d 字节: %w", n, err)
}io.ReadFull:必须填满整个缓冲区。io.ReadAtLeast:至少读取指定字节数。io.CopyN:复制恰好 N 字节,否则返回错误。encoding/binary.Read:按字节序读取固定布局的二进制数据。
10.3 io.ReadAll 不是默认答案
data, err := io.ReadAll(r)它简单,但会持续读取到 EOF。对文件、HTTP Body、压缩流或用户输入直接 ReadAll,可能让一个巨大输入耗尽内存。
设置上限的一种方式:
const maxSize = 1 << 20 // 1 MiB
limited := io.LimitReader(r, maxSize+1)
data, err := io.ReadAll(limited)
if err != nil {
return err
}
if len(data) > maxSize {
return fmt.Errorf("输入超过 %d 字节", maxSize)
}多读 1 字节是为了区分“恰好达到上限”和“已经超限”。
11. I/O 组合器:真正值得掌握的高阶能力
Go 的 I/O 设计强调组合,而不是继承。很多复杂流程只需要把 reader 和 writer 像积木一样拼起来。
11.1 常用组合器
| 工具 | 用途 |
|---|---|
io.LimitReader | 最多读取 N 字节 |
io.MultiReader | 顺序拼接多个 reader |
io.TeeReader | 读取时把同一份数据复制到 writer |
io.MultiWriter | 一次写入多个 writer |
io.SectionReader | 只读取某个区间,支持 seek |
io.Copy | 流式复制直到 EOF |
io.CopyN | 流式复制固定字节数 |
io.Pipe | 用同步内存管道连接 reader 和 writer |
io.NopCloser | 给 reader 包一层无操作的 Close |
边读边计算 SHA-256:
hash := sha256.New()
reader := io.TeeReader(src, hash)
if _, err := io.Copy(dst, reader); err != nil {
return err
}
sum := hash.Sum(nil)
fmt.Printf("%x\n", sum)同时写文件和标准输出:
out := io.MultiWriter(file, os.Stdout)
if _, err := fmt.Fprintln(out, "hello"); err != nil {
return err
}注意 MultiWriter 不是事务:前一个 writer 已经成功、后一个失败时,不会自动回滚。
11.2 io.Pipe 与流式并发
io.Pipe 把只接受 io.Writer 的生产者和只接受 io.Reader 的消费者直接连接起来,不需要先把全部数据存入内存:
pr, pw := io.Pipe()
go func() {
err := writeArchive(pw)
_ = pw.CloseWithError(err)
}()
if err := upload(pr); err != nil {
_ = pr.CloseWithError(err)
return err
}它是同步管道,没有内部大缓冲。写入会阻塞,直到读取端消费数据。工程上必须考虑:
- 生产者和消费者谁负责关闭哪一端;
- 错误如何通过
CloseWithError传播; - 消费者提前退出时如何让生产者解除阻塞;
- goroutine 是否可能泄漏;
- 上层取消信号能否关闭底层资源。
不要为了“异步”随手把每次 I/O 放进 goroutine。没有明确的关闭和取消协议,得到的通常是泄漏而不是并发收益。
11.3 装饰器式管道
压缩文件可以自然组合:
file, err := os.Create("data.txt.gz")
if err != nil {
return err
}
defer file.Close()
gz := gzip.NewWriter(file)
defer gz.Close()
bw := bufio.NewWriter(gz)
defer bw.Flush()
_, err = fmt.Fprintln(bw, "hello")
return err关闭顺序必须与包装顺序相反:先 flush 最外层缓冲,再关闭压缩 writer 写入尾部,最后关闭文件。真实项目要检查每一步错误;多个关闭错误可以用 errors.Join 合并。
12. 文件 I/O
12.1 小文件快捷函数
data, err := os.ReadFile("config.json")
if err != nil {
return err
}
if err := os.WriteFile("result.txt", data, 0o644); err != nil {
return err
}0o644 是文件权限:所有者读写,组和其他用户只读。Windows 对 Unix 权限位的处理不同,不要把它当作跨平台访问控制系统。
ReadFile 会把整个文件读入内存,只适合大小已知且合理的文件。
12.2 流式读写和资源释放
src, err := os.Open("source.bin")
if err != nil {
return fmt.Errorf("打开源文件: %w", err)
}
defer src.Close()
dst, err := os.Create("target.bin")
if err != nil {
return fmt.Errorf("创建目标文件: %w", err)
}
_, copyErr := io.Copy(dst, src)
closeErr := dst.Close()
if copyErr != nil {
return fmt.Errorf("复制文件: %w", copyErr)
}
if closeErr != nil {
return fmt.Errorf("关闭目标文件: %w", closeErr)
}目标文件的 Close 可能报告延迟写入错误,不能永远忽略。只读文件的 Close 错误通常不关键,但数据库、网络连接、压缩 writer 和写文件的 Close 需要认真处理。
12.3 OpenFile 标志
file, err := os.OpenFile(
"app.log",
os.O_CREATE|os.O_WRONLY|os.O_APPEND,
0o644,
)常见标志:
os.O_RDONLY、os.O_WRONLY、os.O_RDWRos.O_CREATEos.O_APPENDos.O_TRUNCos.O_EXCL
危险组合要特别小心:O_TRUNC 会在打开成功时截断文件。创建临时文件优先使用 os.CreateTemp,不要自己拼一个“看起来不会重复”的文件名。
12.4 文件更新与原子性
os.WriteFile 不是“业务事务”。进程在写到一半时崩溃,目标文件可能只剩部分内容。需要原子替换配置或状态文件时,常见流程是:
- 在同一文件系统的目标目录创建临时文件;
- 写入内容;
- 检查
Sync和Close错误; - 设置需要的权限;
- 用
Rename替换目标; - 根据持久性要求同步目录。
不同操作系统和文件系统的 rename、覆盖、持久性语义存在差异。真正关键的数据应使用成熟存储系统或经过验证的库,不能只靠几行文件操作假定事务成立。
13. 错误处理
13.1 给错误增加上下文
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取配置 %q: %w", path, err)
}%w 保留错误链,调用方可以使用 errors.Is 和 errors.As:
if errors.Is(err, fs.ErrNotExist) {
// 文件不存在
}
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println(pathErr.Op, pathErr.Path)
}%v 只把错误格式化成文本,不参与 unwrap:
fmt.Errorf("读取失败: %v", err) // 丢失错误链,不推荐13.2 io.EOF 不是普通失败
io.EOF 表示输入正常结束。不同 API 对 EOF 的约定不同:
Reader.Read用io.EOF表示没有更多数据;io.Copy和io.ReadAll成功读到结尾时返回nil,不会把 EOF 当错误交给调用方;bufio.Scanner.Err()会忽略正常 EOF;ReadString可能同时返回最后一段数据和io.EOF;- 固定长度结构在中途结束,通常是
io.ErrUnexpectedEOF。
不要形成“所有 err != nil 都立即 return”的机械习惯。先读清楚当前 API 的返回契约。
13.3 部分成功
I/O 经常出现“完成一部分后失败”:
n, err := writer.Write(data)如果 n < len(data),即使 err == nil,实现也违反了 Writer 约定,调用方通常应视为 io.ErrShortWrite。标准库的 io.Copy 等函数会处理这些边界。
设计自己的 I/O API 时也应保留部分结果:
func Import(r io.Reader) (count int, err error)失败时 count 可以告诉调用方已经导入多少条,但必须清楚说明这些条目是否已经提交、能否重试,以及重试会不会重复。
13.4 panic 不是 I/O 错误处理
文件不存在、网络超时、输入格式错误都属于可预期错误,应返回 error。panic 适合不可恢复的程序不变量破坏或程序员错误,不应替代常规错误传播。
log.Fatal 会记录后直接调用 os.Exit(1),不会执行当前 goroutine 中挂起的 defer。它通常只适合 main 的最外层;库函数不应决定整个进程退出。
14. 并发、取消与超时
14.1 不要假设 Reader/Writer 并发安全
io.Reader 和 io.Writer 接口本身不承诺可被多个 goroutine 同时调用。具体类型是否安全,要看该类型文档。
多个 goroutine 同时写一个 writer,可能造成:
- 数据竞争;
- 多条消息交错;
- 缓冲区状态损坏;
- 协议帧边界被破坏。
常见解决方案:
- 由单个 goroutine 负责写,其他 goroutine 通过 channel 发送消息;
- 用 mutex 保护一整个逻辑记录的写入;
- 每个请求使用独立 writer;
- 使用明确声明并发安全的组件。
不要只给单次 Write 加锁,却让一条记录由多次 Write 组成;锁的粒度应覆盖不可分割的逻辑消息。
14.2 context.Context 不会自动取消普通 I/O
给函数传入 context,并不会神奇地打断一个阻塞的 Read:
func Read(ctx context.Context, r io.Reader) error {
// 如果 r.Read 永久阻塞,仅检查 ctx 不能将它唤醒。
return nil
}可靠取消依赖底层资源能力:
net.Conn可以设置 deadline,关闭连接也能唤醒阻塞 I/O;- HTTP 请求使用
http.NewRequestWithContext; os.File的可取消性取决于文件类型和平台;- 自己创建的
io.Pipe可以关闭管道端; - 完全未知的
io.Reader没有通用、无泄漏的强制取消办法。
把阻塞 Read 放进 goroutine 后 select context,只能让等待方返回,不能保证那个 goroutine 结束。除非你还能关闭底层 reader,否则可能造成 goroutine 泄漏。
14.3 backpressure
流式 I/O 会自然形成背压:消费者变慢,写入者随之阻塞,数据不会无上限地堆在内存里。如果在中间加入无界队列,这个性质也就被破坏了。
设计并发管道时要明确:
- 缓冲区最多占用多少内存;
- 上游在下游变慢时是阻塞、丢弃还是降级;
- 错误和取消由谁向哪一端传播;
- 谁拥有关闭权。
15. 性能与安全
15.1 先选择流式模型,再谈微优化
性能优化可以先按影响范围排个顺序:
- 避免把无限或巨大输入全部读入内存;
- 使用
io.Copy等流式 API; - 用
bufio.Reader/Writer合并大量小 I/O; - 减少不必要的
string与[]byte转换; - 预估容量,使用
Grow; - 最后才考虑对象池、手写解析器和零拷贝。
是否更快要用 benchmark 和 profile 证明:
go test -bench=. -benchmem ./...
go test -run=^$ -bench=BenchmarkName -cpuprofile=cpu.out
go tool pprof cpu.out15.2 fmt 方便但不是最快
fmt 为通用格式化做了类型处理和反射,热路径中可能比专用转换慢:
s1 := fmt.Sprintf("%d", n)
s2 := strconv.Itoa(n)日志、高频协议编码和批量数据生成可以考虑 strconv.AppendInt、encoding/json.Encoder、bytes.Buffer 等专用 API。若性能数据没有把瓶颈指向 fmt,为了省下无法观察的开销而牺牲可读性没有意义。
15.3 不可信输入必须有边界
至少考虑:
- 最大总字节数;
- 最大行长度或 token 长度;
- 最大字段数和嵌套深度;
- 读取超时;
- 解压后的最大体积,防止压缩炸弹;
- Unicode 合法性与规范化要求;
- 临时文件数量和磁盘配额;
- 错误日志是否会回显敏感输入。
“使用流式 API”不等于天然安全。一个永不结束的 reader 仍然能长期占用 goroutine、连接和 CPU。
15.4 缓冲区复用要谨慎
sync.Pool 可以减少高频临时缓冲分配,但会增加生命周期和所有权复杂度:
- 归还后不能继续引用;
- 写出前不能归还;
- 不应假定池会永久保存对象;
- 巨大缓冲区放回池中可能抬高常驻内存;
- 包含敏感数据的缓冲区需要考虑清理。
先用 profile 证明分配是瓶颈,再引入池化。
16. 工程化写法
16.1 把输入输出作为依赖传入
不要让核心逻辑到处直接访问 os.Stdin 和 os.Stdout:
type App struct {
In io.Reader
Out io.Writer
Err io.Writer
}
func (a App) Run() error {
scanner := bufio.NewScanner(a.In)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return fmt.Errorf("读取输入: %w", err)
}
return io.EOF
}
_, err := fmt.Fprintf(a.Out, "hello, %s\n", scanner.Text())
return err
}main 负责接线:
app := App{
In: os.Stdin,
Out: os.Stdout,
Err: os.Stderr,
}
if err := app.Run(); err != nil {
fmt.Fprintln(app.Err, err)
os.Exit(1)
}测试不需要替换全局变量:
var out bytes.Buffer
app := App{
In: strings.NewReader("枫枫\n"),
Out: &out,
Err: io.Discard,
}
if err := app.Run(); err != nil {
t.Fatal(err)
}16.2 接口放在使用方
如果函数只需要写入能力,就接收 io.Writer,不要接收 *os.File:
func Export(w io.Writer, rows []Row) error如果只需要一个更小的自定义能力:
type Flusher interface {
Flush() error
}Go 接口是隐式实现的。类型不需要写 implements,只要方法集满足接口即可。这让 I/O 组件很容易组合和测试,也是 Go 相比 Java 最有代表性的设计差异之一。
16.3 机器输出应有稳定协议
供人阅读:
fmt.Fprintf(w, "%-20s %8d\n", name, count)供程序读取:
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
if err := enc.Encode(result); err != nil {
return err
}不要让下游解析人类日志、fmt.Printf("%v") 或会随版本改变的表格。CLI 如果同时支持两者,通常提供 --json 等显式模式,并把日志写到 stderr。
16.4 资源所有权必须清楚
资源所有权可以遵循一条简单约定:谁创建,谁负责关闭。函数如果只是接收外部 io.Reader / io.Writer,通常不应替调用方关闭它。
func Parse(r io.Reader) error {
// 只读,不关闭 r
return nil
}如果函数接收并拥有 io.ReadCloser,应在 API 文档和命名中明确。否则调用方很难判断资源何时失效。
17. Java 开发者对照表
| Java | Go | 关键差异 |
|---|---|---|
var x = ... | x := ... | Go 的 := 只能在函数内,并且是声明 |
| 字段默认值 | 零值 | Go 局部变量也可只声明后获得零值 |
null | nil | 只适用于特定类型;string、struct、array 不能为 nil |
final / 常量 | const | Go const 只能是布尔、数字、字符串常量 |
| package-private / public | 小写 / 大写开头 | Go 的导出规则由标识符首字符决定 |
Object.toString() | fmt.Stringer | Go 接口隐式实现 |
System.out | os.Stdout | 都可视作输出目标 |
System.err | os.Stderr | CLI 中应与业务输出分离 |
InputStream | io.Reader | Go 接口更小,读操作返回 (n, error) |
OutputStream | io.Writer | 写操作返回 (n, error) |
BufferedReader | bufio.Reader / Scanner | Scanner 有 token 上限 |
StringBuilder | strings.Builder | 非零 Builder 不应复制 |
| checked exception | error 返回值 | Go 没有异常签名,调用点显式处理 |
| try-with-resources | defer Close() | 写入类资源仍要检查 Close 错误 |
| SLF4J + 实现 | log/slog 或第三方日志库 | 标准库直接提供结构化日志 |
implements | 无关键字 | 方法集满足接口即实现 |
构造器 new T() | 复合字面量 / NewT 函数 | Go 的 new 不是构造器调用 |
18. 常见错误清单
变量与常量
- 把
:=当普通赋值,意外创建阴影变量。 - 认为类型推断等于动态类型。
- 往 nil map 写数据。
- 认为 nil slice 与空 slice 在所有序列化协议中完全等价。
- 使用
iota后随意插行,破坏外部协议编号。 - 用
_丢弃真正应该处理的 error。 - 遮蔽
len、error、new等预声明标识符。
格式化输出
- 把
%#v当稳定序列化格式。 - 忘记给
Printf加换行。 - 格式动词与参数不匹配,输出中出现
%!。 String()递归格式化自身。- 把 token、密码等敏感字段写入日志。
- 缓冲 writer 忘记 Flush,或忽略 Flush 错误。
输入
- 用
fmt.Scan读取包含空格的一整行。 - 不检查 Scan 返回的数量和错误。
- 忘记
Scanner默认 token 上限。 - 保存
scanner.Bytes()后继续扫描,导致内容被覆盖。 ReadString返回 EOF 时把已经读到的最后一段数据丢掉。- 误把
len(string)当字符数。 - 对无限输入直接
io.ReadAll。
文件与并发
- 忽略目标文件的 Close 错误。
- 在 defer 执行前调用
os.Exit或log.Fatal。 - 假定所有 Reader/Writer 都并发安全。
- 用 goroutine 包住阻塞 Read,却没有关闭底层资源的办法。
- 认为
MultiWriter或WriteFile自带事务回滚。 - 没有给不可信输入设置大小和超时边界。
19. 速查表
声明
var name string // 零值 ""
var age int = 18 // 显式类型
var city = "上海" // 推断 string
score := 95 // 局部简短声明
a, b := 1, 2 // 多变量声明
a, b = b, a // 交换
const timeout = 3 * time.Second // 常量输出
fmt.Print("no newline")
fmt.Println("with newline")
fmt.Printf("name=%s age=%d\n", name, age)
s := fmt.Sprintf("%#v", value)
_, err := fmt.Fprintf(writer, "%v\n", value)
buf = fmt.Appendf(buf, "%d", n)输入
_, err := fmt.Scan(&name)
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
_ = line
}
err := scanner.Err()
br := bufio.NewReader(reader)
line, err := br.ReadString('\n')流与文件
n, err := io.Copy(dst, src)
data, err := io.ReadAll(io.LimitReader(src, max+1))
n, err = io.ReadFull(src, buf)
data, err = os.ReadFile(path)
err = os.WriteFile(path, data, 0o644)错误
return fmt.Errorf("读取 %q: %w", path, err)
if errors.Is(err, fs.ErrNotExist) {
// ...
}
var pathErr *os.PathError
if errors.As(err, &pathErr) {
// ...
}20. 与本章相关的高阶特性索引
输入输出这条主线已经牵涉到 Go 的不少核心能力。后续学习时,可以沿着下面的关系继续展开:
| 特性 | 与本章的连接点 |
|---|---|
| 指针 | Scan(&value)、可选字段、资源状态 |
| 方法与方法集 | 类型如何实现 Stringer、Reader、Writer |
| 接口与隐式实现 | I/O 解耦、测试替身、装饰器组合 |
| 泛型 | 通用解析、容器和算法;不要用泛型替代本来就合适的 I/O 接口 |
| error 链 | %w、errors.Is/As/Join、部分成功 |
| defer / panic / recover | 资源释放、异常边界;panic 不替代普通 error |
| goroutine / channel / select | 流式生产消费、取消、背压与泄漏 |
| context | 网络请求和跨调用链取消;不能自动打断任意 Reader |
| Go 内存模型 | 多 goroutine 共享 writer、缓冲区所有权与数据竞争 |
| 反射 | fmt、encoding/json 的通用类型处理及性能成本 |
| struct tag | JSON/XML/数据库字段映射 |
unsafe | 零拷贝转换等极端优化;破坏类型安全和可移植性,默认不用 |
| build constraints | 按操作系统提供不同文件 I/O 实现 |
| cgo | 与 C 文件描述符和缓冲区交互;引入调度、内存和部署成本 |
| testing / fuzzing | 验证解析器边界、格式协议和恶意输入 |
| benchmark / pprof / trace | 判断格式化、分配、锁和系统调用是否真是瓶颈 |
这些主题值得单独成章。学习顺序建议是:类型与复合数据 → 函数、方法和接口 → error 与资源管理 → goroutine/channel/context → 泛型与反射 → 性能分析和内存模型。unsafe 与 cgo 放到确有业务需要时再学,收益更高。
21. 可运行示例
下面三个例子不是只能阅读的代码块。网页展示的源码直接来自仓库的 examples/ch01,因此你在本地运行的就是眼前这一份代码。命令都从仓库根目录执行。
21.1 从零值开始构造可用对象
先从零值的实际行为看起。 刚声明的变量究竟能做什么?为什么 nil slice 可以 append,nil map 却不能写?
package main
import "fmt"
// Options 故意不提供构造函数。
// 这个类型的零值就能表达“不开启调试、不限制重试、没有标签”,
// 因而调用方声明变量后可以立刻安全使用。
type Options struct {
Debug bool
MaxRetries int
Label string
Tags []string
Metadata map[string]string
}
func main() {
var count int
var enabled bool
var name string
var options Options
// %q 能把空字符串显示为 "",比直接打印更容易看出零值。
fmt.Printf("基础零值: count=%d enabled=%t name=%q\n", count, enabled, name)
fmt.Printf("结构体零值: debug=%t retries=%d label=%q\n",
options.Debug, options.MaxRetries, options.Label)
// nil slice 可以读取、取长度和追加;append 会在需要时分配底层数组。
options.Tags = append(options.Tags, "go")
fmt.Printf("追加后的标签: %v\n", options.Tags)
// nil map 可以读取,但不能直接写入。写入前必须用 make 初始化。
_, exists := options.Metadata["owner"]
fmt.Printf("nil map 读取: exists=%t\n", exists)
options.Metadata = make(map[string]string)
options.Metadata["owner"] = "team"
fmt.Printf("初始化后的元数据: owner=%s\n", options.Metadata["owner"])
}运行:
go run ./examples/ch01/zero-values预期输出:
基础零值: count=0 enabled=false name=""
结构体零值: debug=false retries=0 label=""
追加后的标签: [go]
nil map 读取: exists=false
初始化后的元数据: owner=team程序依次验证了五件事。
var options Options会递归地把每个字段初始化为各自类型的零值,不会留下未初始化内存。- nil slice 的长度和容量都是 0,
append可以返回一个已经分配存储空间的新切片,必须接住它的返回值。 - nil map 的读取结果是 value 类型的零值,但写入会 panic,所以第一次写之前要
make。 - 一个类型若能让零值直接表示合理状态,调用方就不必记住必须先调用哪个构造函数。
沿着边界继续试。 删掉 make(map[string]string) 后运行,观察 panic;再给 Options 增加一个 *int 字段,用 %v 打印它,比较“没有值”的 nil 指针和数值零。
21.2 把输入和输出放在内存中
把终端替换成内存。 怎样练习扫描和格式化,又不让程序依赖人工输入?怎样让输出可以被测试捕获?
package main
import (
"bytes"
"fmt"
"strings"
)
func main() {
// strings.Reader 把内存中的字符串变成 io.Reader。
// 测试和示例不必依赖 os.Stdin,输入因此完全可复现。
input := strings.NewReader("gopher 3")
var name string
var lessons int
if _, err := fmt.Fscan(input, &name, &lessons); err != nil {
// 真正的命令行程序通常把错误返回给上层或写到 stderr。
// 这里的输入是程序内置常量,扫描失败表示示例本身写错了。
panic(err)
}
// bytes.Buffer 实现 io.Writer,用来接住格式化结果。
// 业务函数若接收 io.Writer,就不必绑定到终端或文件。
var output bytes.Buffer
if _, err := fmt.Fprintf(&output, "%s 完成了 %d 个练习\n", name, lessons); err != nil {
panic(err)
}
fmt.Print(output.String())
}运行:
go run ./examples/ch01/scan-and-format预期输出:
gopher 完成了 3 个练习这里看输入与输出怎样解耦。
fmt.Fscan的第一个参数是io.Reader,所以strings.Reader可以替代os.Stdin。- 扫描目标必须传地址,
Fscan才能把解析结果写回变量。 fmt.Fprintf面向io.Writer写数据;bytes.Buffer同时保存写入结果,最后可通过String读取。- 业务逻辑依赖 Reader/Writer,而不是全局的标准输入输出,后续写单元测试会简单很多。
换一组输入验证错误路径。 把输入改为 "gopher unknown",观察扫描整数时的错误;再把格式从 %d 改成 %03d,看看宽度和补零怎样影响结果。
21.3 用 Reader/Writer 组合一条转换管道
把转换逻辑放到接口之间。 函数怎样在不知道输入、输出具体来自哪里的情况下完成转换?这个版本为什么不适合大输入?
package main
import (
"bytes"
"fmt"
"io"
"strings"
)
// copyUpper 只依赖 io.Reader 和 io.Writer。
// 输入来自字符串、文件还是网络,对它都没有区别;输出也可以替换为任意 Writer。
func copyUpper(dst io.Writer, src io.Reader) (int64, error) {
// 为了把重点放在接口组合上,这里先完整读入内存再转换,
// 因而不是流式转换,也不适合不受控的大输入。
// io.Copy 负责随后从 upper 循环读取、写入 dst,并检查异常短写。
data, err := io.ReadAll(src)
if err != nil {
return 0, fmt.Errorf("读取输入: %w", err)
}
upper := strings.NewReader(strings.ToUpper(string(data)))
return io.Copy(dst, upper)
}
func main() {
source := strings.NewReader("go interfaces compose")
var destination bytes.Buffer
written, err := copyUpper(&destination, source)
if err != nil {
panic(err)
}
fmt.Printf("写入字节数: %d\n", written)
fmt.Printf("结果: %s\n", destination.String())
}运行:
go run ./examples/ch01/reader-writer预期输出:
写入字节数: 21
结果: GO INTERFACES COMPOSE这条管道由四步组成。
copyUpper的边界只有io.Reader和io.Writer,没有文件名、终端或网络连接等额外假设。io.Copy会反复读取直到 EOF,并校验每次写入结果;Writer 若在没有返回错误时发生短写,io.Copy会返回io.ErrShortWrite。返回值是实际写入的字节数。- 当前示例为讲清接口组合,先用
io.ReadAll收集全部数据再转换,因此不是流式转换,大文件会占用与输入大小相当的内存。 bytes.Buffer由调用方持有,数据所有权很明确;函数只借用 Writer,不负责关闭它。
替换管道两端再运行。 把 destination 换成 os.Stdout,观察函数不需要修改;再尝试用 io.LimitReader(source, 2) 限制输入,确认只会复制前两个字节。
22. 官方资料
本文尽量只依赖稳定的语言规则和标准库契约。需要确认边界时,优先查官方资料:
- Go 语言规范
- Go 1.26 Release Notes
fmt包io包bufio包os包strings包strconv包unicode/utf8包log/slog包- Effective Go
- Go Code Review Comments
原教程提供的博客可以帮助入门,但当博客示例与规范或标准库文档冲突时,以当前模块声明的 Go 版本和官方文档为准。