Skip to content

Go 输入与输出:从变量声明到工程级 I/O

面向有 Java 经验的开发者,基于 Go 1.26。

刚接触 Go 时,输入输出很容易被理解成 fmt.Printlnfmt.Scan 的用法合集。代码一旦写进项目,变量作用域、零值、接口、指针、Unicode、缓冲、错误传播、资源释放和并发安全便会接连出现。先弄清这些函数背后的模型,比背函数名更有用:文件、网络连接、压缩流、HTTP Body 乃至测试桩,最终都可以落到不同的 io.Readerio.Writer 上。

这一章既可以顺序阅读,也可以用作速查手册。后文统一使用“包级变量”,不再沿用一些入门资料中的“全局变量”,因为 Go 的可见性边界以包为核心。

目录

1. 先看一段完整程序

先从一段能运行的程序开始。变量声明、标准输入、格式化和错误处理都在里面,同时还能看到标准输出与标准错误输出应当怎样分工:

go
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 四种常见写法

go
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 的类型推断发生在编译期,它省掉的是类型书写,不会把变量变成动态类型:

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 声明和赋值不是一回事

go
var n int // 声明
n = 10    // 赋值

m := 20   // 声明并初始化
m = 30    // 赋值

同一作用域内,单变量不能重复使用 :=

go
name := "A"
// name := "B" // 编译错误:左侧没有新变量
name = "B"     // 正确

多变量简短声明有一条很重要的例外:左侧至少有一个新变量时,已存在的同作用域变量可以同时参与赋值。

go
value, err := load()

// value 已存在,status 是新变量,因此合法。
// 这里的 value 是赋值,不是重新声明。
value, status := normalize(value)

_, _, _ = err, value, status

处理 (value, error) 返回值时经常会用到这条规则。它也会制造阴影变量,后文会单独说明。

2.3 一次声明多个变量

go
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 支持平行赋值。右侧会先求值,再统一赋给左侧:

go
a, b := 1, 2
a, b = b, a // 交换,不需要临时变量

函数也可以返回多个值:

go
func divide(a, b int) (int, int) {
	return a / b, a % b
}

quotient, remainder := divide(10, 3)

Go 的多返回值不是 Java 的数组、元组对象或 record,而是语言直接提供的返回形式。它只能出现在特定上下文中,不能把 (quotient, remainder) 当成一个普通值继续传递。

2.4 零值

变量只声明、不显式初始化时,会递归获得其类型的零值:

类型零值备注
boolfalse
整数、浮点数、复数0
string""不是 nil
指针nil
slicenillencap 都是 0,可直接 append
mapnil可读取,写入会 panic
channelnil收发会永久阻塞,关闭会 panic
functionnil调用会 panic
interfacenil动态类型和动态值都为空
array每个元素的零值数组本身不是 nil
struct每个字段的零值struct 本身不是 nil

零值不只是初始化规则,也是 Go API 设计中的一项约定。类型的零值如果可以直接使用,调用方就少了一条“必须先调用构造函数”的前置条件:

go
var b strings.Builder // 零值可用
b.WriteString("hello")

var mu sync.Mutex // 零值可用
mu.Lock()
mu.Unlock()

不过,不能由此推断所有标准库类型的零值都可用,具体仍以类型文档为准。例如 bufio.Reader 通常通过 bufio.NewReader 创建。

nil slice、空 slice 和 nil map

go
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 newmake 和取地址

这三个概念经常被 Java 开发者混在一起:

go
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,初始化 channel
  • new(T) 返回 *T,指向一个零值 T
  • make 只用于 slice、map、channel,返回的是 T,不是 *T
  • &value 取得已有变量的地址。
  • Go 没有 Java 构造器语义;new 只负责分配和初始化,不会自动调用某个构造方法。

Go 1.26 允许把表达式直接传给 new

go
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 只有值传递。把变量赋给另一个变量,或传入函数,传递的都是值的副本:

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 和指针的值内部含有对底层数据的引用。复制这些值后,两个副本可能仍共享底层数据:

go
a := []int{1, 2, 3}
b := a
b[0] = 99

fmt.Println(a[0]) // 99,共享底层数组

“Go 是值传递”和“多个值可以共享底层数据”并不冲突。前者描述参数传递方式,后者描述某些值的内部结构。

2.7 包级变量、局部变量与初始化顺序

go
package main

import "fmt"

var appName = "go-study" // 包级变量

func main() {
	version := "1.0" // 局部变量
	fmt.Println(appName, version)
}

更精确地说:

  • 函数外声明的是包级变量,同一个包的所有源文件都可访问。
  • 函数、ifforswitchselect 等块内声明的是局部变量。
  • 包级变量允许声明后不在本包使用。
  • 局部变量声明后必须使用,否则编译失败。
  • 未使用的 import 同样会导致编译失败。
  • 函数参数和接收者即使未使用,也允许编译。

包初始化有明确的依赖顺序:先初始化导入的包,再按声明依赖初始化本包的包级变量,随后按编译器接收的文件顺序调用各个 init 函数,最后才执行 main.main。构建系统通常按文件名顺序把文件交给编译器,但业务代码不应依赖这种跨文件的隐含先后关系。网络请求、文件读取等复杂副作用也不适合塞进包级初始化,放到显式构造函数或 main 中更容易控制失败和生命周期。

2.8 作用域与变量遮蔽

Go 使用词法作用域。内层块可以声明一个与外层同名的新变量:

go
count := 10

if true {
	count := 20 // 新变量,遮蔽外层 count
	fmt.Println(count) // 20
}

fmt.Println(count) // 10

最危险的情况通常与 err 有关:

go
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

如果需要给外层变量赋值,用普通赋值:

go
data, err = os.ReadFile("config.json")
if err != nil {
	return
}

另一个常见错误发生在错误处理块中:

go
result, err := step1()
if err != nil {
	return err
}

result, err = step2(result) // 两个变量都已存在,应该使用 =
if err != nil {
	return err
}

go vet 和静态检查工具可以发现一部分遮蔽问题,却无法代替代码结构本身。缩短作用域、控制函数长度,才是减少这类问题的主要手段。

2.9 空白标识符 _

_ 表示“这个位置需要一个值,但我明确不使用它”:

go
value, _ := strconv.Atoi("42") // 示例而已,生产代码不应随便丢弃错误

for _, item := range items {
	fmt.Println(item)
}

_ 不会创建变量,也不能读取。它常用于:

  • 忽略多返回值中的某一项;
  • 忽略 range 的索引或值;
  • 编译期接口实现检查;
  • 只执行某个包的 init 副作用导入。
go
var _ io.Writer = (*bytes.Buffer)(nil)

不要为了让代码“先编译”而随意写 _ = value 或忽略 error。空白标识符应该表达明确意图,而不是掩盖未完成的逻辑。

3. 常量

3.1 基本声明

go
const name string = "枫枫"
const age = 18

const (
	defaultHost = "127.0.0.1"
	defaultPort = 8080
)

常量必须在声明时给出值,之后不能修改。Go 常量只能是布尔值、数字或字符串,不能是 slice、map、struct、函数,也不能是运行期才能得到的结果:

go
const timeout = 3 * time.Second // 合法,time.Second 本身是常量
// const now = time.Now()       // 非法:函数调用不是常量表达式
// const names = []string{"A"}  // 非法:slice 不能是常量

Go 没有 Java 的 final 局部变量或只读对象引用。若需要不可变数据结构,通常依靠不导出字段、只暴露只读方法,以及返回副本来维护约束。

3.2 有类型常量与无类型常量

这是 Go 常量体系中最值得理解的部分:

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)    // 显式转换

无类型常量在编译期拥有比普通变量更高的精度,只有进入需要具体类型的上下文时才检查能否表示:

go
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 开始:

go
type Status int

const (
	StatusUnknown Status = iota // 0
	StatusPending                // 1,重复上一行完整表达式
	StatusRunning                // 2
	StatusDone                   // 3
)

常见用途是枚举和位标志:

go
type Permission uint

const (
	PermissionRead Permission = 1 << iota // 1
	PermissionWrite                      // 2
	PermissionDelete                     // 4
)

const PermissionAll = PermissionRead | PermissionWrite | PermissionDelete

有意跳过值时可以使用 _

go
const (
	_  = iota             // 跳过 0
	KB = 1 << (10 * iota) // 1 << 10
	MB                    // 1 << 20
	GB                    // 1 << 30
)

不要把数据库或网络协议中长期稳定的编号完全交给 iota。在声明组中间插入一行会改变后续值;外部协议更适合显式写出数字并配测试锁定。

4. 命名与可见性

原教程里的“首字母大写可以在包外访问”方向没错,但需要加上边界:

  • 声明在包级的标识符,或 struct 的字段名、方法名,以 Unicode 大写字母开头时,是导出的。
  • 小写开头的标识符只能在当前包内访问。
  • 局部变量即使首字母大写,也不会因为大写而跨函数访问。
  • Go 没有 Java 的 publicprotectedprivate 关键字。
go
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
}

惯用命名:

  • 使用 camelCasePascalCase,不使用 Java 常见的全大写常量风格。
  • 缩写保持一致:userIDHTTPServerparseURL,而不是 userIdHttpServer
  • 包名短、小写、单数,调用时读起来不重复:http.Server,而不是 httputil.HTTPServer
  • 小作用域允许短名字:循环里的 i、reader 的 r、writer 的 w、错误的 err
  • 大作用域和领域对象使用能表达含义的名字。
  • getter 通常叫 Name(),而不是 GetName();setter 可以叫 SetName()

避免遮蔽预声明标识符:

go
// 不推荐
len := 10
error := "failed"
new := func() {}

这在语法上合法,但会让同一作用域内无法正常使用内建的 lenerrornew

5. 基础输出:fmt 家族

5.1 PrintPrintlnPrintf

go
fmt.Print("hello")               // 不自动换行
fmt.Println("hello", 42, true)   // 参数之间按规则加空格,末尾换行
fmt.Printf("name=%s age=%d\n", "枫枫", 18)

三者默认写到 os.Stdout。对应的函数族可以按“输出到哪里”来记:

目标默认格式格式字符串结果
标准输出fmt.Print / Printlnfmt.Printf写入 os.Stdout
io.Writerfmt.Fprint / Fprintlnfmt.Fprintf返回写入字节数和错误
字符串fmt.Sprint / Sprintlnfmt.Sprintf返回字符串
[]byte 追加fmt.Append / Appendlnfmt.Appendf返回追加后的字节切片
go
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)
%+vstruct 增加字段名{Name:枫枫 Age:18}
%#v尽量按 Go 语法表示main.User{Name:"枫枫", Age:18}
%T值的动态类型stringmain.User
%p指针地址,十六进制0xc000...
%%字面量 %100%

%#v 很适合调试空字符串、nil 和复合值,但它不是稳定的序列化协议。不要解析 %#v 的输出,也不要把它当 JSON。

布尔与整数

动词含义
%ttrue / false
%d十进制整数
%b二进制
%o / %O八进制,%O0o 前缀
%x / %X十六进制
%c对应 Unicode 码点
%q带引号的字符字面量
%UUnicode 格式,如 U+4F60
go
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十六进制浮点表示
go
pi := 3.1415926
fmt.Printf("%.2f %.3e %g\n", pi, pi, pi)

%.2f 表示小数点后两位,不代表金融计算的精确小数语义。金额不要直接依赖二进制浮点和输出舍入;常见做法是用最小货币单位的整数,或选择明确的十进制定点方案。

字符串、字节与 Unicode

动词含义
%s字符串或字节序列
%q双引号包围并转义
%x / %X每个字节以十六进制输出
% x十六进制字节之间加空格
go
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 bd

5.3 宽度、精度和标志

格式的一般结构可以理解为:

text
%[参数索引][标志][宽度][.精度]动词

常用标志:

标志作用
+数字总是显示符号;%+v 为 struct 显示字段名
-左对齐
0用零填充数字
#使用替代格式,如 0x 前缀或 Go 语法
空格为正数预留符号位;与 %x 配合时分隔字节
go
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

宽度和精度也可以来自参数:

go
width, precision := 8, 2
fmt.Printf("%*.*f\n", width, precision, 3.14159)

重复使用参数时可以显式索引,索引从 1 开始:

go
fmt.Printf("%[1]d %[1]x %[1]b\n", 255)

5.4 格式错误不会返回 error

Printf 的格式和参数不匹配时,通常不会像 Java 格式化那样抛异常,而是把诊断文本写进结果:

go
fmt.Printf("%d\n", "hello")
// %!d(string=hello)

常见诊断包括:

  • %!d(string=hello):动词和参数类型不匹配;
  • %!s(MISSING):缺少参数;
  • %!(EXTRA int=1):参数过多;
  • %!(BADWIDTH)%!(BADPREC):宽度或精度参数不合法。

go vet 能静态检查许多 Printf 风格的调用:

bash
go vet ./...

如果自己封装了日志函数,可以通过合适的命名、注释和分析器配置让工具继续检查;不要等线上日志出现 %! 才发现问题。

5.5 自定义格式化:StringerGoStringerFormatter

fmt.Stringer

实现 String() string 后,fmt 会在适当的格式下调用它:

go
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 等敏感字段。

避免递归调用自己:

go
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

需要控制动词、宽度、精度和标志时,实现:

go
type Formatter interface {
	Format(state fmt.State, verb rune)
}

Formatter 是最强的扩展点,也最容易写错。实现时要处理未知动词、写入错误以及递归格式化。普通业务类型优先实现 Stringer;只有库类型或确实存在多种格式表示时,再实现 Formatter

fmt 的格式化优先级大致是:Formatter%#v 下的 GoStringererrorStringer → 默认反射格式。知道这个顺序,才能解释为什么某个类型没有按预期输出。

5.6 内建 print / println 不是业务输出 API

Go 还有预声明的 printprintln,但规范只把它们保留给引导和调试,实现相关的格式并不稳定。项目代码应使用 fmtlog/slog 或明确的 io.Writer

6. 输出目标:os.Stdoutos.Stderrio.Writer

6.1 三个标准文件

go
os.Stdin  // *os.File,标准输入
os.Stdout // *os.File,标准输出
os.Stderr // *os.File,标准错误

命令行程序中,建议把可供下游消费的数据写到 stdout,把提示、进度和诊断写到 stderr:

go
fmt.Fprintln(os.Stderr, "正在读取配置……")
fmt.Fprintln(os.Stdout, `{"status":"ok"}`)

这样用户可以安全地执行:

bash
go run . > result.json

6.2 io.Writer 是输出的核心抽象

接口定义非常小:

go
type Writer interface {
	Write(p []byte) (n int, err error)
}

文件、网络连接、压缩器、哈希器、HTTP ResponseWriter、bytes.Bufferstrings.Builder 都可以作为 writer。业务函数接受 io.Writer 后,不需要知道目标究竟是什么:

go
func WriteGreeting(w io.Writer, name string) error {
	_, err := fmt.Fprintf(w, "你好,%s\n", name)
	return err
}

生产环境写法:

go
if err := WriteGreeting(os.Stdout, "枫枫"); err != nil {
	fmt.Fprintln(os.Stderr, "输出失败:", err)
	os.Exit(1)
}

测试时:

go
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 把 OutputStreamWriter 注入业务类的思想相近,但 Go 通常直接依赖最小接口,不必建立庞大的抽象层。

6.3 缓冲输出

大量小写操作可以用 bufio.Writer 合并:

go
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 的错误也必须处理。

在需要汇总多个清理错误的函数中,可以使用命名返回值谨慎合并错误:

go
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.Builderbytes.Buffer

要在内存中构造字符串:

go
var b strings.Builder
b.Grow(64) // 可选:已知大致大小时减少扩容

b.WriteString("hello")
b.WriteByte(',')
b.WriteRune('')

result := b.String()

要同时处理字节和字符串,或需要把缓冲区当 io.Reader

go
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 没问题。服务端程序需要时间、级别、字段、上下文和稳定格式时,应使用结构化日志:

go
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 的真正含义

go
var name string
n, err := fmt.Scan(&name)

fmt.Scan 需要修改调用方变量,因此接收的是变量地址。&name 的类型是 *string。这不是 Scan 的特殊语法,而是 Go 指针的普通用法。

永远检查返回值:

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 ScanScanlnScanf

函数行为
fmt.Scan从 stdin 按空白分隔读取;换行也视为空白
fmt.Scanln从 stdin 读取,到换行停止
fmt.Scanf按格式字符串解析 stdin
fmt.Fscan从指定 io.Reader 读取
fmt.Sscan从字符串读取
go
var name string
var age int

if _, err := fmt.Scan(&name, &age); err != nil {
	fmt.Fprintln(os.Stderr, "输入格式错误:", err)
	return
}

输入:

text
枫枫 18

Scan 适合空白分隔的竞赛题、简单脚本和演示,不适合读取带空格的姓名、地址或自由文本:

text
张 三

上面会被看作两个 token。

Scanf 的空白和换行匹配规则与 C 的 scanf 并不完全相同,格式稍复杂就容易出错。业务程序通常更稳妥的做法是:先读取完整一行,再用 stringsstrconv 做明确解析。

7.3 从字符串解析,便于测试

Sscan 不依赖标准输入:

go
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.Scannerbufio.Reader

8.1 bufio.Scanner:最方便的逐行读取

go
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 或字节拆分:

go
scanner.Split(bufio.ScanWords)

内建 split 函数:

  • bufio.ScanLines
  • bufio.ScanWords
  • bufio.ScanRunes
  • bufio.ScanBytes

还可以实现自定义 bufio.SplitFunc,用于逗号分隔或自定义帧协议。不过真正的 CSV 应使用 encoding/csv,不要只按逗号切字符串,因为引号、转义和换行都有专门规则。

8.2 Scanner 的 64 KiB 限制

Scanner 默认 token 上限为 64 KiB 左右。日志行、压缩后的长行、Base64、SQL 或用户粘贴文本都可能超过它:

go
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 64*1024), 1024*1024) // 最大 1 MiB

必须在第一次 Scan 之前调用 Buffer。上限不是越大越好;它同时是内存和安全边界。输入完全不可信时,应根据协议设置合理上限。

scanner.Bytes() 返回的数据可能在下一次 Scan 时被覆盖,需要长期保存就复制:

go
token := append([]byte(nil), scanner.Bytes()...)

scanner.Text() 返回字符串,使用更方便,但涉及字符串分配。性能敏感路径应通过 benchmark 决定,而不是凭感觉优化。

8.3 bufio.Reader:需要更多控制时使用

读取一整行:

go
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

ReadBytesReadString 类似,只是返回 []byteReadSlice 可能直接引用内部缓冲区,下一次读取后内容失效,适合对分配敏感且清楚生命周期的代码。

选择:

  • 简单逐行、逐词扫描:bufio.Scanner
  • token 可能很大、需要保留分隔符、需要 Peek / UnreadByte / ReadString 等控制:bufio.Reader
  • 固定长度或二进制协议:直接使用 io.Readerio.ReadFullencoding/binary

8.4 一种更可靠的“读取并解析整数”

go
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) 多几行,却把“读取失败”“空输入”“格式错误”区分开了,测试也容易:

go
reader := bufio.NewReader(strings.NewReader("42\n"))
value, err := ReadInt(reader)

9. Unicode、string、byte 与 rune

Go 源代码采用 UTF-8,字符串是只读字节序列,不保证内容一定是合法 UTF-8。

go
s := "你好"

fmt.Println(len(s))                    // 6:字节数
fmt.Println(utf8.RuneCountInString(s)) // 2:Unicode 码点数

索引字符串得到 byte:

go
fmt.Printf("%x\n", s[0]) // e4

range 字符串得到 byte 偏移和 rune:

go
for byteIndex, r := range s {
	fmt.Printf("byte=%d rune=%c code=%U\n", byteIndex, r, r)
}

runeint32 的别名,表示 Unicode 码点;byteuint8 的别名。两者是别名,不是独立的新类型。

还要注意:“码点数”不等于用户看到的字符数。组合音标、emoji 肤色修饰、家庭 emoji 等可能由多个码点组成。涉及光标移动、文本截断和界面宽度时,需要按 grapheme cluster 处理,不能只用 len 或 rune 数量。

输入边界建议:

  • 文本协议先判断 utf8.Validutf8.ValidString
  • 不要按任意字节位置截断 UTF-8 文本;
  • strings.TrimSpace 按 Unicode 空白处理,TrimRight(s, "\r\n") 只去除指定行尾;
  • 大小写无关比较可用 strings.EqualFold,不要简单 ToLower(a) == ToLower(b)
  • 用户名、路径和安全标识符是否需要 Unicode 归一化,应由业务协议明确规定。

10. io.Reader:Go I/O 的另一半

接口同样只有一个方法:

go
type Reader interface {
	Read(p []byte) (n int, err error)
}

10.1 正确理解 (n, err)

一次 Read 可以同时返回 n > 0 和非 nil 的 err。调用方必须先处理读到的字节,再处理错误:

go
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
	}
}

不过手写复制循环通常没有必要,直接用:

go
_, err := io.Copy(w, r)

io.Copy 会处理读取循环、EOF 和部分写入,并可能利用 WriterToReaderFrom 走更高效的实现。

10.2 定长读取

一次 Read(p) 不保证填满 p。网络流和文件读取都可能只返回一部分:

go
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 不是默认答案

go
data, err := io.ReadAll(r)

它简单,但会持续读取到 EOF。对文件、HTTP Body、压缩流或用户输入直接 ReadAll,可能让一个巨大输入耗尽内存。

设置上限的一种方式:

go
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:

go
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)

同时写文件和标准输出:

go
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 的消费者直接连接起来,不需要先把全部数据存入内存:

go
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 装饰器式管道

压缩文件可以自然组合:

go
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 小文件快捷函数

go
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 流式读写和资源释放

go
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 标志

go
file, err := os.OpenFile(
	"app.log",
	os.O_CREATE|os.O_WRONLY|os.O_APPEND,
	0o644,
)

常见标志:

  • os.O_RDONLYos.O_WRONLYos.O_RDWR
  • os.O_CREATE
  • os.O_APPEND
  • os.O_TRUNC
  • os.O_EXCL

危险组合要特别小心:O_TRUNC 会在打开成功时截断文件。创建临时文件优先使用 os.CreateTemp,不要自己拼一个“看起来不会重复”的文件名。

12.4 文件更新与原子性

os.WriteFile 不是“业务事务”。进程在写到一半时崩溃,目标文件可能只剩部分内容。需要原子替换配置或状态文件时,常见流程是:

  1. 在同一文件系统的目标目录创建临时文件;
  2. 写入内容;
  3. 检查 SyncClose 错误;
  4. 设置需要的权限;
  5. Rename 替换目标;
  6. 根据持久性要求同步目录。

不同操作系统和文件系统的 rename、覆盖、持久性语义存在差异。真正关键的数据应使用成熟存储系统或经过验证的库,不能只靠几行文件操作假定事务成立。

13. 错误处理

13.1 给错误增加上下文

go
data, err := os.ReadFile(path)
if err != nil {
	return nil, fmt.Errorf("读取配置 %q: %w", path, err)
}

%w 保留错误链,调用方可以使用 errors.Iserrors.As

go
if errors.Is(err, fs.ErrNotExist) {
	// 文件不存在
}

var pathErr *os.PathError
if errors.As(err, &pathErr) {
	fmt.Println(pathErr.Op, pathErr.Path)
}

%v 只把错误格式化成文本,不参与 unwrap:

go
fmt.Errorf("读取失败: %v", err) // 丢失错误链,不推荐

13.2 io.EOF 不是普通失败

io.EOF 表示输入正常结束。不同 API 对 EOF 的约定不同:

  • Reader.Readio.EOF 表示没有更多数据;
  • io.Copyio.ReadAll 成功读到结尾时返回 nil,不会把 EOF 当错误交给调用方;
  • bufio.Scanner.Err() 会忽略正常 EOF;
  • ReadString 可能同时返回最后一段数据和 io.EOF
  • 固定长度结构在中途结束,通常是 io.ErrUnexpectedEOF

不要形成“所有 err != nil 都立即 return”的机械习惯。先读清楚当前 API 的返回契约。

13.3 部分成功

I/O 经常出现“完成一部分后失败”:

go
n, err := writer.Write(data)

如果 n < len(data),即使 err == nil,实现也违反了 Writer 约定,调用方通常应视为 io.ErrShortWrite。标准库的 io.Copy 等函数会处理这些边界。

设计自己的 I/O API 时也应保留部分结果:

go
func Import(r io.Reader) (count int, err error)

失败时 count 可以告诉调用方已经导入多少条,但必须清楚说明这些条目是否已经提交、能否重试,以及重试会不会重复。

13.4 panic 不是 I/O 错误处理

文件不存在、网络超时、输入格式错误都属于可预期错误,应返回 errorpanic 适合不可恢复的程序不变量破坏或程序员错误,不应替代常规错误传播。

log.Fatal 会记录后直接调用 os.Exit(1),不会执行当前 goroutine 中挂起的 defer。它通常只适合 main 的最外层;库函数不应决定整个进程退出。

14. 并发、取消与超时

14.1 不要假设 Reader/Writer 并发安全

io.Readerio.Writer 接口本身不承诺可被多个 goroutine 同时调用。具体类型是否安全,要看该类型文档。

多个 goroutine 同时写一个 writer,可能造成:

  • 数据竞争;
  • 多条消息交错;
  • 缓冲区状态损坏;
  • 协议帧边界被破坏。

常见解决方案:

  • 由单个 goroutine 负责写,其他 goroutine 通过 channel 发送消息;
  • 用 mutex 保护一整个逻辑记录的写入;
  • 每个请求使用独立 writer;
  • 使用明确声明并发安全的组件。

不要只给单次 Write 加锁,却让一条记录由多次 Write 组成;锁的粒度应覆盖不可分割的逻辑消息。

14.2 context.Context 不会自动取消普通 I/O

给函数传入 context,并不会神奇地打断一个阻塞的 Read

go
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 先选择流式模型,再谈微优化

性能优化可以先按影响范围排个顺序:

  1. 避免把无限或巨大输入全部读入内存;
  2. 使用 io.Copy 等流式 API;
  3. bufio.Reader/Writer 合并大量小 I/O;
  4. 减少不必要的 string[]byte 转换;
  5. 预估容量,使用 Grow
  6. 最后才考虑对象池、手写解析器和零拷贝。

是否更快要用 benchmark 和 profile 证明:

bash
go test -bench=. -benchmem ./...
go test -run=^$ -bench=BenchmarkName -cpuprofile=cpu.out
go tool pprof cpu.out

15.2 fmt 方便但不是最快

fmt 为通用格式化做了类型处理和反射,热路径中可能比专用转换慢:

go
s1 := fmt.Sprintf("%d", n)
s2 := strconv.Itoa(n)

日志、高频协议编码和批量数据生成可以考虑 strconv.AppendIntencoding/json.Encoderbytes.Buffer 等专用 API。若性能数据没有把瓶颈指向 fmt,为了省下无法观察的开销而牺牲可读性没有意义。

15.3 不可信输入必须有边界

至少考虑:

  • 最大总字节数;
  • 最大行长度或 token 长度;
  • 最大字段数和嵌套深度;
  • 读取超时;
  • 解压后的最大体积,防止压缩炸弹;
  • Unicode 合法性与规范化要求;
  • 临时文件数量和磁盘配额;
  • 错误日志是否会回显敏感输入。

“使用流式 API”不等于天然安全。一个永不结束的 reader 仍然能长期占用 goroutine、连接和 CPU。

15.4 缓冲区复用要谨慎

sync.Pool 可以减少高频临时缓冲分配,但会增加生命周期和所有权复杂度:

  • 归还后不能继续引用;
  • 写出前不能归还;
  • 不应假定池会永久保存对象;
  • 巨大缓冲区放回池中可能抬高常驻内存;
  • 包含敏感数据的缓冲区需要考虑清理。

先用 profile 证明分配是瓶颈,再引入池化。

16. 工程化写法

16.1 把输入输出作为依赖传入

不要让核心逻辑到处直接访问 os.Stdinos.Stdout

go
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 负责接线:

go
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)
}

测试不需要替换全局变量:

go
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

go
func Export(w io.Writer, rows []Row) error

如果只需要一个更小的自定义能力:

go
type Flusher interface {
	Flush() error
}

Go 接口是隐式实现的。类型不需要写 implements,只要方法集满足接口即可。这让 I/O 组件很容易组合和测试,也是 Go 相比 Java 最有代表性的设计差异之一。

16.3 机器输出应有稳定协议

供人阅读:

go
fmt.Fprintf(w, "%-20s %8d\n", name, count)

供程序读取:

go
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,通常不应替调用方关闭它。

go
func Parse(r io.Reader) error {
	// 只读,不关闭 r
	return nil
}

如果函数接收并拥有 io.ReadCloser,应在 API 文档和命名中明确。否则调用方很难判断资源何时失效。

17. Java 开发者对照表

JavaGo关键差异
var x = ...x := ...Go 的 := 只能在函数内,并且是声明
字段默认值零值Go 局部变量也可只声明后获得零值
nullnil只适用于特定类型;string、struct、array 不能为 nil
final / 常量constGo const 只能是布尔、数字、字符串常量
package-private / public小写 / 大写开头Go 的导出规则由标识符首字符决定
Object.toString()fmt.StringerGo 接口隐式实现
System.outos.Stdout都可视作输出目标
System.erros.StderrCLI 中应与业务输出分离
InputStreamio.ReaderGo 接口更小,读操作返回 (n, error)
OutputStreamio.Writer写操作返回 (n, error)
BufferedReaderbufio.Reader / ScannerScanner 有 token 上限
StringBuilderstrings.Builder非零 Builder 不应复制
checked exceptionerror 返回值Go 没有异常签名,调用点显式处理
try-with-resourcesdefer Close()写入类资源仍要检查 Close 错误
SLF4J + 实现log/slog 或第三方日志库标准库直接提供结构化日志
implements无关键字方法集满足接口即实现
构造器 new T()复合字面量 / NewT 函数Go 的 new 不是构造器调用

18. 常见错误清单

变量与常量

  • := 当普通赋值,意外创建阴影变量。
  • 认为类型推断等于动态类型。
  • 往 nil map 写数据。
  • 认为 nil slice 与空 slice 在所有序列化协议中完全等价。
  • 使用 iota 后随意插行,破坏外部协议编号。
  • _ 丢弃真正应该处理的 error。
  • 遮蔽 lenerrornew 等预声明标识符。

格式化输出

  • %#v 当稳定序列化格式。
  • 忘记给 Printf 加换行。
  • 格式动词与参数不匹配,输出中出现 %!
  • String() 递归格式化自身。
  • 把 token、密码等敏感字段写入日志。
  • 缓冲 writer 忘记 Flush,或忽略 Flush 错误。

输入

  • fmt.Scan 读取包含空格的一整行。
  • 不检查 Scan 返回的数量和错误。
  • 忘记 Scanner 默认 token 上限。
  • 保存 scanner.Bytes() 后继续扫描,导致内容被覆盖。
  • ReadString 返回 EOF 时把已经读到的最后一段数据丢掉。
  • 误把 len(string) 当字符数。
  • 对无限输入直接 io.ReadAll

文件与并发

  • 忽略目标文件的 Close 错误。
  • 在 defer 执行前调用 os.Exitlog.Fatal
  • 假定所有 Reader/Writer 都并发安全。
  • 用 goroutine 包住阻塞 Read,却没有关闭底层资源的办法。
  • 认为 MultiWriterWriteFile 自带事务回滚。
  • 没有给不可信输入设置大小和超时边界。

19. 速查表

声明

go
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 // 常量

输出

go
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)

输入

go
_, 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')

流与文件

go
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)

错误

go
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)、可选字段、资源状态
方法与方法集类型如何实现 StringerReaderWriter
接口与隐式实现I/O 解耦、测试替身、装饰器组合
泛型通用解析、容器和算法;不要用泛型替代本来就合适的 I/O 接口
error 链%werrors.Is/As/Join、部分成功
defer / panic / recover资源释放、异常边界;panic 不替代普通 error
goroutine / channel / select流式生产消费、取消、背压与泄漏
context网络请求和跨调用链取消;不能自动打断任意 Reader
Go 内存模型多 goroutine 共享 writer、缓冲区所有权与数据竞争
反射fmtencoding/json 的通用类型处理及性能成本
struct tagJSON/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 却不能写?

go
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"])
}

运行:

bash
go run ./examples/ch01/zero-values

预期输出:

text
基础零值: count=0 enabled=false name=""
结构体零值: debug=false retries=0 label=""
追加后的标签: [go]
nil map 读取: exists=false
初始化后的元数据: owner=team

程序依次验证了五件事。

  1. var options Options 会递归地把每个字段初始化为各自类型的零值,不会留下未初始化内存。
  2. nil slice 的长度和容量都是 0,append 可以返回一个已经分配存储空间的新切片,必须接住它的返回值。
  3. nil map 的读取结果是 value 类型的零值,但写入会 panic,所以第一次写之前要 make
  4. 一个类型若能让零值直接表示合理状态,调用方就不必记住必须先调用哪个构造函数。

沿着边界继续试。 删掉 make(map[string]string) 后运行,观察 panic;再给 Options 增加一个 *int 字段,用 %v 打印它,比较“没有值”的 nil 指针和数值零。

21.2 把输入和输出放在内存中

把终端替换成内存。 怎样练习扫描和格式化,又不让程序依赖人工输入?怎样让输出可以被测试捕获?

go
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())
}

运行:

bash
go run ./examples/ch01/scan-and-format

预期输出:

text
gopher 完成了 3 个练习

这里看输入与输出怎样解耦。

  1. fmt.Fscan 的第一个参数是 io.Reader,所以 strings.Reader 可以替代 os.Stdin
  2. 扫描目标必须传地址,Fscan 才能把解析结果写回变量。
  3. fmt.Fprintf 面向 io.Writer 写数据;bytes.Buffer 同时保存写入结果,最后可通过 String 读取。
  4. 业务逻辑依赖 Reader/Writer,而不是全局的标准输入输出,后续写单元测试会简单很多。

换一组输入验证错误路径。 把输入改为 "gopher unknown",观察扫描整数时的错误;再把格式从 %d 改成 %03d,看看宽度和补零怎样影响结果。

21.3 用 Reader/Writer 组合一条转换管道

把转换逻辑放到接口之间。 函数怎样在不知道输入、输出具体来自哪里的情况下完成转换?这个版本为什么不适合大输入?

go
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())
}

运行:

bash
go run ./examples/ch01/reader-writer

预期输出:

text
写入字节数: 21
结果: GO INTERFACES COMPOSE

这条管道由四步组成。

  1. copyUpper 的边界只有 io.Readerio.Writer,没有文件名、终端或网络连接等额外假设。
  2. io.Copy 会反复读取直到 EOF,并校验每次写入结果;Writer 若在没有返回错误时发生短写,io.Copy 会返回 io.ErrShortWrite。返回值是实际写入的字节数。
  3. 当前示例为讲清接口组合,先用 io.ReadAll 收集全部数据再转换,因此不是流式转换,大文件会占用与输入大小相当的内存。
  4. bytes.Buffer 由调用方持有,数据所有权很明确;函数只借用 Writer,不负责关闭它。

替换管道两端再运行。destination 换成 os.Stdout,观察函数不需要修改;再尝试用 io.LimitReader(source, 2) 限制输入,确认只会复制前两个字节。

22. 官方资料

本文尽量只依赖稳定的语言规则和标准库契约。需要确认边界时,优先查官方资料:

原教程提供的博客可以帮助入门,但当博客示例与规范或标准库文档冲突时,以当前模块声明的 Go 版本和官方文档为准。

以 Go 官方规范与标准库文档为准,示例面向 Go 1.26。