Day 13 | 一个函数回两个值:Go 的 error 是从哪冒出来的

本文是《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 上还勉强能猜,换成 float64string[]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)、能打印(%vPrintln 直接输出)。

代价也很实在: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.PrintlnPrintln(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 现场

最后说一个真会踩的坑:errnil 的时候,它不能"点"任何东西。

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) 走的是成功路径,errnil。对 nil.Error() 就是解引用空指针,运行时直接 panic,程序当场结束。注意编译器不会拦你------它只看类型 error,不知道运行时的值。

所以 Go 的固定顺序是先判错、再用结果 ,永远不要写完调用顺手就 if err != nil 放到最后。写反顺序的那次,我半夜被一个只回 signal: panic 的 CI 任务叫起来,查了二十分钟才发现是 err.Error() 在错误为 nil 时被执行了。这一条从今天起变成肌肉记忆。

小结:把 error 当一个普通值看待

今天三件事是一条线:一个返回值说不清哪儿不对 → 加一个 error → 编译器逼你接住它。

命名返回值和可变参数是语法层面的便利,闭包抓变量则是内存模型的问题,它和 go.mod

里的版本号绑定,属于必须知道自己在用哪种语义的那类知识。

把错误当第二个返回值这个设计,从 Go 1 沿用至今,属于长期适用的机制,不依赖具体版本;今天真正和版本绑定的只有上面那条循环变量语义。

参考资料


相关推荐
骇客野人1 小时前
SpringBoot电商购物车、结算、下单、库存方案设计与落地实施步骤
java·spring boot·后端
asdzx671 小时前
Python 实现 PDF 文本查找与高亮标注
开发语言·python·pdf
zander2581 小时前
LeetCode 1143. 最长公共子序列
开发语言·python·算法
Sumerking1 小时前
llc_control.c 专项评审(v3 更新版)
c语言·开发语言·算法·obc
Cache技术分享1 小时前
517. Java 方法句柄 - 方法句柄 vs 反射 API
前端·后端
棣廷1 小时前
初识Python面向对象
java·开发语言
研☆香2 小时前
js中 onload 事件的用法
开发语言·javascript·ecmascript
xiancai_xianyu2 小时前
想把数据模型一次建完美再上AI?会一直卡在建模里
开发语言·人工智能·php·数据模型·语义层·本体建模·企业知识网络
BS30813_vx2 小时前
基于 Express + uni-app 的养老院管理系统设计与实现
后端·uni-app·express