本文是《100天精通Go基础入门》专栏第 13 篇,订阅专栏不迷路。
本文目录
-
只回一个值的时候,0 有两种意思
-
加上 error,一个函数就有两种结局
-
返回值可以先起好名字
-
参数有多少个,函数自己数
-
闭包抓到的是变量,不是值
-
不检查 err 就调它的方法,panic 现场
今天不学新语法,学 Go 最有辨识度的那个习惯:函数同时给你"结果"和"哪儿不对"。前 12 天的函数都只回一个值,从今天起你写的每个函数都要开始考虑第二件事。

只回一个值的时候,0 有两种意思
先写一个最普通的除法函数,失败时返回 0:
Plaintext
// 《100天精通Go基础入门》Day 13 · 平头哥 · 技术改变世界,勤奋改变人生
package main
import "fmt"
func divide(a, b int) int {
if b == 0 {
return 0 // 出错了,用 0 表示
}
return a / b
}
func main() {
fmt.Println("10 / 2 =", divide(10, 2))
fmt.Println("10 / 0 =", divide(10, 0))
fmt.Println("0 / 5 =", divide(0, 5))
}



三行输出里两个 0,含义完全不同:一个是"算对了,答案就是 0",一个是"没算成"。用返回值本身当错误码,是把两件事挤进一个盒子。这在 int 上还勉强能猜,换成 float64、string、[]byte 就彻底没法约定------字符串的空串到底是"没有内容"还是"出错了"?
Python 靠抛异常解决这件事,Java 靠 throws 声明。Go 走第三条路:把错误当成第二个普通返回值。
加上 error,一个函数就有两种结局
Plaintext
// 《100天精通Go基础入门》Day 13 · 平头哥 · 技术改变世界,勤奋改变人生
package main
import (
"errors"
"fmt"
)
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("除数不能为 0")
}
return a / b, nil
}
func main() {
q, err := divide(10, 2)
if err != nil {
fmt.Println("失败:", err)
return
}
fmt.Println("成功,商是", q)
q2, err2 := divide(10, 0)
fmt.Println("q2 =", q2, "| err2 =", err2)
}


返回类型写成 (int, error),读作"给我一个 int 和一个 error"。约定是死的:最后一个返回值是 error ,没出错时它是 nil。第二行输出你会看到 err2 = 除数不能为 0------它不是被"抛"出来的,它是被打印出来的普通值,这就是 Go 错误处理的全部真相。
error 是个接口,但今天只需要三件事:能创建(errors.New / fmt.Errorf)、能比较(if err != nil)、能打印(%v 或 Println 直接输出)。

代价也很实在:Go 代码里 if err != nil 一排排看着啰嗦。但换来的是没有隐藏的控制流------函数会不会失败、失败往哪儿去,全写在明面上。我第一次读 Go 源码也被这排比句劝退过,读到第三天就习惯了:它其实是最诚实的写法。
顺带说清一个反直觉的地方:你可以不接 error,但不能接了不用。
Plaintext
// 《100天精通Go基础入门》Day 13 · 平头哥 · 技术改变世界,勤奋改变人生
package main
import "fmt"
func divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("除数不能为 0")
}
return a / b, nil
}
func main() {
q, err := divide(10, 0)
fmt.Println("商:", q)
}

编译直接过不去:declared and not used: err。Go 不允许一个声明了却没用的变量存在,错误值尤其如此------这条规则被很多人骂严格,但它是 Go 世界"错误必须被处理"这句话唯一能被机器执行的形态。
真不想处理就明说不想:
Plaintext
// 《100天精通Go基础入门》Day 13 · 平头哥 · 技术改变世界,勤奋改变人生
package main
import "fmt"
func divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("除数不能为 0")
}
return a / b, nil
}
func main() {
q, _ := divide(10, 0) // 显式丢弃,读代码的人一眼知道你不管错误
fmt.Println("不管错误,拿到:", q)
}


下划线 _ 是多返回值的固定搭档:只要结果、把 error 丢掉。但这个动作等于在源码里签了字------"我知道这里可能失败,我不管"。除了写一次性小脚本,别养成习惯。
四种写法,只有一种是对的
| 写法 | 能编译 | 结果 |
|---|---|---|
q := divide(10, 0) |
否 | 多返回值不能只接一个 |
q, _ := divide(10, 0) |
是 | 错误被显式丢掉,出事没人管 |
接了 err 又不读它 |
否 | declared and not used |
先 if err != nil,再用 q |
是 | 标准姿势 |
返回值可以先起好名字
Go 允许给返回值命名,return 前面那行声明就省了:
Plaintext
// 《100天精通Go基础入门》Day 13 · 平头哥 · 技术改变世界,勤奋改变人生
package main
import (
"errors"
"fmt"
)
// 命名返回值:q 和 err 在函数一进来就已存在
func divide(a, b int) (q int, err error) {
if b == 0 {
return 0, errors.New("除数不能为 0")
}
return a / b, nil
}
// 三个以上返回值时,名字比类型更能救可读性
func split(total, parts int) (per int, rest int, err error) {
if parts <= 0 {
return 0, 0, errors.New("份数必须大于 0")
}
return total / parts, total % parts, nil
}
func main() {
q, err := divide(17, 5)
fmt.Println("17 / 5 =", q, "| err =", err)
per, rest, err := split(100, 3)
fmt.Println("每人", per, "份,余", rest, "份 | err =", err)
_, _, err = split(100, 0)
fmt.Println("份数为 0 →", err)
}


(q int, err error) 读作"返回一个 int 叫 q、一个 error 叫 err"。它到底有什么用?两个地方值得用:一是 godoc 里函数签名会带上名字,调用方不用读实现就知道第二个返回值是错误;二是像 split 这种返回三四个同类型值的,没有名字根本分不清哪个是哪个。
至于 return 什么都不写的"裸返回"(return 一行自动返回 q 和 err),我建议你别用:函数一长,光看 return 不知道返回了什么,读的人得往上翻。命名返回值的好处留在签名上就够了。
顺带解释一下最后那段为什么 per, rest, err := 不冲突:同一作用域里用 := 时,只要左边至少有一个新变量 就合法,其余的按普通赋值处理,所以 err 在这里是被重新写入,不是重新声明。
参数有多少个,函数自己数
Plaintext
// 《100天精通Go基础入门》Day 13 · 平头哥 · 技术改变世界,勤奋改变人生
package main
import "fmt"
func sum(nums ...int) int {
total := 0
for _, n := range nums { // nums 在这里就是一个 []int
total += n
}
return total
}
func label(name string, tags ...string) string {
s := name
for _, t := range tags {
s += " #" + t
}
return s
}
func main() {
fmt.Println("sum() =", sum())
fmt.Println("sum(3) =", sum(3))
fmt.Println("sum(1,2,3,4) =", sum(1, 2, 3, 4))
xs := []int{10, 20, 30}
fmt.Println("把切片打散传进去: sum(xs...) =", sum(xs...))
fmt.Println(label("平头哥", "前端", "Go", "日更"))
}



三条硬规矩记牢:可变参数必须写在参数表最后 ;一个函数只能有一个可变参数;... 前面是类型不是切片,sum([]int) 和 sum(...int) 是两回事。
顺带把 label(name string, tags ...string) 说明白:可变参数前面完全可以有固定参数,这种"一个主语 + 一堆补充"的形状是它最常见的用法。fmt.Sprintf 自己不收可变参数,但 fmt.Println 是 Println(a ...any)------这就是为什么它能连着打印好几个不相干的东西。
闭包抓到的是变量,不是值
多返回值讲完,顺便把 Go 另一个"看起来像语法糖其实是内存模型"的点讲掉:func 可以当值传来传去。
Plaintext
// 《100天精通Go基础入门》Day 13 · 平头哥 · 技术改变世界,勤奋改变人生
package main
import "fmt"
// 返回值是一个函数------因为函数本身就是普通类型
func counter() func() int {
n := 0
return func() int {
n++ // 抓到的是 n 这个变量本身,不是它的拷贝
return n
}
}
func main() {
c := counter()
fmt.Println("第一次到第三次:", c(), c(), c())
d := counter() // 全新的 n,和 c 互不干扰
fmt.Println("另一条线:", d(), c())
fs := make([]func(), 0, 3)
for i := 0; i < 3; i++ {
fs = append(fs, func() { fmt.Print(i, " ") })
}
fmt.Print("三个闭包各自记的 i: ")
for _, f := range fs {
f()
}
fmt.Println()
}



输出是 0 1 2,不是老帖子里的 3 3 3。这是 Go 近年最重要的一次语义改动 :Go 1.22 起,for 每轮循环的循环变量都是新变量,所以上面那三个闭包各自记一个 i。网上大量 Go 面试题、StackOverflow 高票回答还停在旧语义,你如果照着它们写 i := i 那行"修复",现在纯属多余------不报错,但白绕一个变量。
要判断你手上的代码走的是哪种语义,看 go.mod 里那行 go:1.22 及以上走新语义。这也是前面反复强调"别删 go.mod 里那行版本号"的原因之一。
不检查 err 就调它的方法,panic 现场
最后说一个真会踩的坑:err 是 nil 的时候,它不能"点"任何东西。
Plaintext
// 《100天精通Go基础入门》Day 13 · 平头哥 · 技术改变世界,勤奋改变人生
package main
import (
"errors"
"fmt"
)
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("除数不能为 0")
}
return a / b, nil
}
func main() {
q, err := divide(10, 2)
fmt.Println("商:", q)
fmt.Println("出错信息:", err.Error()) // 这次没出错,err 是 nil
}

divide(10, 2) 走的是成功路径,err 是 nil。对 nil 调 .Error() 就是解引用空指针,运行时直接 panic,程序当场结束。注意编译器不会拦你------它只看类型 error,不知道运行时的值。
所以 Go 的固定顺序是先判错、再用结果 ,永远不要写完调用顺手就 if err != nil 放到最后。写反顺序的那次,我半夜被一个只回 signal: panic 的 CI 任务叫起来,查了二十分钟才发现是 err.Error() 在错误为 nil 时被执行了。这一条从今天起变成肌肉记忆。
小结:把 error 当一个普通值看待
今天三件事是一条线:一个返回值说不清哪儿不对 → 加一个 error → 编译器逼你接住它。
命名返回值和可变参数是语法层面的便利,闭包抓变量则是内存模型的问题,它和 go.mod
里的版本号绑定,属于必须知道自己在用哪种语义的那类知识。
把错误当第二个返回值这个设计,从 Go 1 沿用至今,属于长期适用的机制,不依赖具体版本;今天真正和版本绑定的只有上面那条循环变量语义。
参考资料