前端开发转 Go 全栈(三):从函数到 error,Go 连“失败”都要明确返回

0 阅读22分钟

这是「前端开发转 Go 全栈」系列的第三篇。

上一篇:前端开发转 Go 全栈(二):从 Hello Go 到类型转换,我开始习惯 Go 的“较真”

上一篇,我从第一个 Hello, Go! 开始,学习了变量、常量、零值、作用域和类型转换。这一篇将继续解释上次留下的 value, err := ...,正式进入 Go 的函数与错误处理。

前言:上一篇留下的两个变量,终于要解释清楚了

上一篇学习类型转换时,我多次写过这样的代码:

age, err := strconv.Atoi("30")

当时,我只是先记住了它的使用方式:

age:转换后的整数
err:转换过程中产生的错误

但心里一直留着几个问题:

一个函数为什么能同时返回两个值?
左边为什么要用两个变量接收?
err 到底是什么?
为什么成功时 err 是 nil

所以这一阶段,我没有急着继续学习新的数据类型,而是正式进入了 Go 的函数。

刚开始看,Go 函数和 JavaScript、TypeScript 并没有相差特别远:

func add(a, b int) int {
	return a + b
}

无非是把 function 换成了 func,再把参数类型写在变量名后面。

但继续往下学习后,我发现 Go 函数真正有意思的地方并不只是语法。

它可以直接返回多个值,也经常把“执行结果”和“错误信息”一起返回。

换句话说,Go 不只要求函数告诉调用者:

我成功得到了什么。

还会要求它明确告诉调用者:

如果失败了,失败的原因是什么。

这篇文章就来记录一下,我是如何从第一个自定义函数,一步步走到 error 和错误传递的。


创建函数练习项目

这一次,我创建了一个新的练习目录:

D:\LearningGO\04-functions

由于我使用的是 Git Bash,所以执行:

cd /d/LearningGO
mkdir 04-functions
cd 04-functions
go mod init functions

终端输出:

go: creating new go.mod: module functions

接着创建 main.go,当前目录结构如下:

04-functions/
├── go.mod
└── main.go

接下来,正式开始编写自己的函数。


第一个自定义函数

先从一个没有参数、也没有返回值的函数开始:

package main

import "fmt"

func sayHello() {
	fmt.Println("你好,我正在学习 Go 函数。")
}

func main() {
	sayHello()
}

运行:

go run .

输出:

你好,我正在学习 Go 函数。

函数定义是:

func sayHello() {
}

拆开来看:

func        声明函数
sayHello    函数名称
()          参数列表
{}          函数体

如果换成 JavaScript,写法大概是:

function sayHello() {
  console.log("你好,我正在学习 Go 函数。");
}

Go 使用 func 声明函数,调用时同样在函数名后面加上括号:

sayHello()

这一部分并不难,基本看一眼就能理解。


给函数传递参数

接下来,我给函数增加了两个参数:

package main

import "fmt"

func introduce(name, job string) {
	fmt.Printf("大家好,我是 %s,目前是一名%s。\n", name, job)
}

func main() {
	introduce("Starry", "前端开发")
}

运行结果:

大家好,我是 Starry,目前是一名前端开发。

函数定义:

func introduce(name, job string)

表示两个参数都是 string

name:string
job:string

如果相邻参数的类型相同,可以像上面这样合并书写。

完整写法则是:

func introduce(name string, job string)

两种写法的效果相同。

和 TypeScript 对比:

function introduce(name: string, job: string): void {
  console.log(`大家好,我是 ${name},目前是一名${job}。`);
}

两者的类型位置有所不同:

TypeScript:参数名: 类型
Go:        参数名 类型

Go 对参数数量很较真

正常调用成功后,我开始故意传错参数。

函数定义需要两个字符串:

func introduce(name, job string)

少传一个参数

如果只传一个参数:

introduce("Starry")

编译器会提示:

not enough arguments in call to introduce
	have (string)
	want (string, string)

这里的提示非常直观:

have:实际传入了什么
want:函数需要什么

也就是:

实际传入:1string
函数需要:2string

多传一个参数

接着尝试多传一个参数:

introduce("Starry", "前端开发", "Go")

编译器提示:

too many arguments in call to introduce
	have (string, string, string)
	want (string, string)

这和原生 JavaScript 的体验不太一样。

JavaScript 中,多传的参数通常不会直接导致函数无法执行:

function introduce(name, job) {
  console.log(name, job);
}

introduce("Starry", "前端开发", "Go");

第三个参数虽然传入了,但函数内部没有使用它。

Go 则要求:

实际参数数量必须和函数定义一致,多一个不行,少一个也不行。

如果确实需要接收不固定数量的参数,Go 也提供了可变参数,不过这部分后续再学习。


参数类型也必须对应

我又把第二个参数改成整数:

introduce("Starry", 30)

但是函数要求第二个参数是 string

func introduce(name, job string)

所以编译器继续拒绝:

cannot use 30 (untyped int constant) as string value in argument to introduce

也就是说:

函数需要:string
实际传入:int

如果确实想把数字作为字符串传进去,需要先明确完成转换:

introduce("Starry", strconv.Itoa(30))

这些错误都会在编译阶段被发现,而不是等到函数真正运行以后再处理。


让函数返回一个值

接下来,我写了一个简单的加法函数:

package main

import "fmt"

func add(a, b int) int {
	return a + b
}

func main() {
	result := add(10, 20)

	fmt.Println("计算结果:", result)
}

输出:

计算结果: 30

重点是这部分:

func add(a, b int) int

最后面的 int 表示函数返回值的类型。

可以拆成:

add          函数名称
a、b         函数参数
前面的 int   参数类型
最后的 int   返回值类型

TypeScript 中的写法是:

function add(a: number, b: number): number {
  return a + b;
}

Go 把返回值类型放在参数列表后面:

func add(a, b int) int

调用时:

result := add(10, 20)

函数返回的 30 会被保存到 result 中。


返回几个值,就要用几个变量接收

add 只返回一个值:

func add(a, b int) int {
	return a + b
}

但是我故意使用两个变量接收:

result1, result2 := add(10, 20)

编译器提示:

assignment mismatch: 2 variables but add returns 1 value

也就是:

左边准备了两个变量
右边的函数只返回一个值
返回值数量无法对应

如果修改代码时还保留了原来的输出语句,编译器可能会同时发现其他问题:

declared and not used: result1
declared and not used: result2
undefined: result

这也再次验证了上一篇学过的规则:

局部变量声明后必须使用
不存在的变量不能访问
返回值与接收变量的数量必须匹配

Go 函数可以直接返回多个值

接下来,终于进入了我最想弄清楚的地方。

我写了一个同时计算加法和乘法的函数:

package main

import "fmt"

func calculate(a, b int) (int, int) {
	sum := a + b
	product := a * b

	return sum, product
}

func main() {
	sum, product := calculate(10, 20)

	fmt.Println("相加结果:", sum)
	fmt.Println("相乘结果:", product)
}

运行结果:

相加结果: 30
相乘结果: 200

函数定义中的:

func calculate(a, b int) (int, int)

最后的:

(int, int)

表示函数会返回两个整数。

返回时写:

return sum, product

调用时写:

sum, product := calculate(10, 20)

两个返回值会按照位置依次交给左边的变量:

第一个返回值 sum      → 左边的 sum
第二个返回值 product  → 左边的 product

整个过程可以理解为:

calculate(10, 20)
→ 返回 30200
→ sum 接收 30
→ product 接收 200

到这里,再回头看上一篇的代码:

age, err := strconv.Atoi(ageText)

它就没有那么神秘了。

strconv.Atoi 同样返回了两个值:

第一个返回值:转换后的整数
第二个返回值:转换过程中产生的错误

不需要某个返回值,可以使用 _

假设我只关心乘法结果,可以这样写:

_, product := calculate(10, 20)

fmt.Println("相乘结果:", product)

如果只关心加法结果,则可以写:

sum, _ := calculate(10, 20)

fmt.Println("相加结果:", sum)

这里的 _ 是空白标识符,表示:

这个位置有一个返回值,但我明确选择不使用。

上一篇为了临时忽略变量,也使用过:

_ = name

现在它又可以用来忽略函数返回值。

不过,有些返回值不能为了省事就随便丢掉,尤其是错误。

例如:

age, _ := strconv.Atoi("30岁")

这段代码虽然可以编译,但它把转换错误直接忽略了。

此时 age 会得到 int 类型的零值:

0

但是调用者已经无法判断:

这个 0 是用户真的输入了 0
还是字符串转换失败后得到的零值?

所以 _ 可以使用,但不能养成“看到错误就直接扔掉”的习惯。


第一次自己返回 error

理解多返回值以后,我开始自己编写一个可能失败的函数。

最简单的例子就是除法,因为除数不能为 0

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() {
	result, err := divide(10, 2)

	if err != nil {
		fmt.Println("计算失败:", err)
		return
	}

	fmt.Println("计算结果:", result)
}

正常调用:

divide(10, 2)

输出:

计算结果: 5

函数声明是:

func divide(a, b int) (int, error)

表示函数会返回:

第一个值:int 类型的计算结果
第二个值:error 类型的错误

成功时:

return a / b, nil

其中:

a / b:正常计算结果
nil:没有错误

失败时:

return 0, errors.New("除数不能为 0")

这里的:

errors.New("除数不能为 0")

会创建一个包含错误信息的 error

而前面的 0,只是 int 类型的零值。

真正表示操作失败的,是第二个返回值 error


errornil 可以先怎么理解?

error 是 Go 中用于表示错误的内置接口类型。

现阶段不需要马上深入接口的底层原理,可以先这样理解:

error:用来保存和描述错误
nil:当前没有错误

因此,常见的判断方式是:

if err != nil {
	// 发生了错误
}

反过来:

if err == nil {
	// 没有错误
}

很多可能失败的函数都会采用类似的返回形式:

func doSomething() (结果类型, error)

成功时:

return 正常结果, nil

失败时:

return 结果类型的零值, 具体错误

这是一种常见约定,不是编译器强制要求所有函数都必须这样设计。


故意让除法失败

我把调用改成:

result, err := divide(10, 0)

运行结果:

计算失败: 除数不能为 0

程序进入了下面的分支:

if err != nil {
	fmt.Println("计算失败:", err)
	return
}

这里可以先理解为:

err == nil    没有错误
err != nil    发生了错误

当错误发生时,函数返回的普通结果通常不再值得信任。

因此,应该先处理错误,再继续使用结果:

result, err := divide(10, 0)

if err != nil {
	fmt.Println("计算失败:", err)
	return
}

fmt.Println("计算结果:", result)

这一段结构,之后会在 Go 代码里反复出现:

结果, 错误 := 执行某个可能失败的操作()

if 错误 != nil {
	处理错误
	return
}

继续使用结果

用函数封装真实一点的参数校验

只做加减乘除有点太像语法题。

于是,我把上一篇的字符串转年龄拿了回来,并将它封装成一个函数:

package main

import (
	"errors"
	"fmt"
	"strconv"
)

func parseAge(ageText string) (int, error) {
	age, err := strconv.Atoi(ageText)

	if err != nil {
		return 0, errors.New("年龄必须是整数")
	}

	if age < 0 {
		return 0, errors.New("年龄不能小于 0")
	}

	if age > 150 {
		return 0, errors.New("年龄不能大于 150")
	}

	return age, nil
}

func printAge(ageText string) {
	age, err := parseAge(ageText)

	if err != nil {
		fmt.Printf("输入 %q,处理失败:%v\n", ageText, err)
		return
	}

	fmt.Printf("输入 %q,年龄是:%d\n", ageText, age)
}

func main() {
	printAge("30")
	printAge("30岁")
	printAge("-1")
	printAge("200")
}

运行结果:

输入 "30",年龄是:30
输入 "30岁",处理失败:年龄必须是整数
输入 "-1",处理失败:年龄不能小于 0
输入 "200",处理失败:年龄不能大于 150

这段代码已经有一点后端参数处理的感觉了:

接收字符串
→ 转换成整数
→ 检查转换是否成功
→ 校验业务范围
→ 成功返回年龄
→ 失败返回具体错误

四个测试值分别覆盖了不同情况:

"30"    格式和范围都正确
"30岁"  无法转换成整数
"-1"    可以转换,但小于合理范围
"200"   可以转换,但超过合理范围

这也让我开始意识到:

参数类型正确,不代表业务数据一定有效。

"-1""200" 都可以正常转换成整数,但它们不符合这里定义的年龄规则。

所以后端接收数据后,通常不只是完成类型转换,还需要继续进行业务校验。


类型转换和业务校验不是一回事

通过 parseAge 这个例子,可以进一步区分两个概念。

类型转换

age, err := strconv.Atoi(ageText)

它只负责判断字符串能不能被转换成整数。

例如:

"30"   → 可以转换
"-1"   → 可以转换
"200"  → 可以转换
"30岁" → 无法转换

业务校验

if age < 0 {
	return 0, errors.New("年龄不能小于 0")
}

if age > 150 {
	return 0, errors.New("年龄不能大于 150")
}

它负责判断已经转换成功的数据是否符合当前业务规则。

因此,一个参数最终可以被正常使用,至少要经过两层判断:

格式是否合法?
→ 数据是否符合业务规则?

以后接收后端请求参数时,也会不断遇到类似问题:

页码能不能转换成整数?
页码是否大于 0?

邮箱是不是字符串?
邮箱格式是否合法?

密码是不是字符串?
密码长度是否符合要求?

仅仅“类型正确”,并不代表数据就能直接进入数据库。


return 结束的是当前函数

printAge 中有这样一段代码:

if err != nil {
	fmt.Printf("输入 %q,处理失败:%v\n", ageText, err)
	return
}

这里的 return 只会结束当前这一次 printAge 调用。

例如:

printAge("30岁")
printAge("-1")
printAge("200")

第一个调用失败并执行 return 后,后面的两个调用仍然会继续执行。

因为程序的调用关系是:

main
→ 调用 printAge("30岁")
→ 当前这次 printAge 执行 return
→ 回到 main
→ 继续调用 printAge("-1")
→ 当前这次 printAge 执行 return
→ 回到 main
→ 继续调用 printAge("200")

如果在 main 函数中直接执行 return,结束的才是 main 函数,程序也会随之结束。

所以:

return 结束的是当前正在执行的函数,而不是无条件结束整个程序。


固定错误和动态错误

前面的错误信息都是固定的:

errors.New("年龄不能小于 0")

但是实际排查问题时,只看到:

年龄不能小于 0

可能还不够直观。

我更希望知道:

当前传入的值到底是多少?

于是开始使用 fmt.Errorf

func parseAge(ageText string) (int, error) {
	age, err := strconv.Atoi(ageText)

	if err != nil {
		return 0, fmt.Errorf("年龄 %q 不是合法整数", ageText)
	}

	if age < 0 {
		return 0, fmt.Errorf("年龄不能小于 0,当前值为 %d", age)
	}

	if age > 150 {
		return 0, fmt.Errorf("年龄不能大于 150,当前值为 %d", age)
	}

	return age, nil
}

重新测试:

func main() {
	printAge("30")
	printAge("30岁")
	printAge("-1")
	printAge("200")
}

运行结果:

输入 "30",年龄是:30
输入 "30岁",处理失败:年龄 "30岁" 不是合法整数
输入 "-1",处理失败:年龄不能小于 0,当前值为 -1
输入 "200",处理失败:年龄不能大于 150,当前值为 200

两种创建错误的方式可以先这样理解。

errors.New

errors.New("固定错误信息")

适合不需要插入变量的固定错误。

例如:

errors.New("除数不能为 0")

fmt.Errorf

fmt.Errorf("年龄不能小于 0,当前值为 %d", age)

适合需要把具体数据写进错误信息的场景。

例如:

fmt.Errorf("年龄 %q 不是合法整数", ageText)

当输入为 "30岁" 时,错误信息会变成:

年龄 "30岁" 不是合法整数

相比固定提示,这样更容易知道到底是哪一个输入出了问题。


不要丢掉底层错误

刚才的代码虽然生成了更清楚的错误信息,但它也有一个问题:

if err != nil {
	return 0, fmt.Errorf("年龄 %q 不是合法整数", ageText)
}

这里创建了一个全新的错误,但是没有保留 strconv.Atoi 返回的原始错误。

如果后续需要继续判断底层错误类型,原始信息就已经丢失了。

更合适的写法是使用 %w 包装原始错误:

if err != nil {
	return 0, fmt.Errorf("年龄 %q 不是合法整数:%w", ageText, err)
}

这样既补充了当前业务上下文,也保留了底层错误。

例如,输入 "30岁" 时,完整错误可能是:

年龄 "30岁" 不是合法整数:strconv.Atoi: parsing "30岁": invalid syntax

现阶段可以先记住:

只想创建固定错误        → errors.New
需要插入动态数据        → fmt.Errorf
需要保留底层错误        → fmt.Errorf + %w

错误可以继续交给上一层

接着,我又增加了一个 createUser 函数:

func createUser(name, ageText string) error {
	age, err := parseAge(ageText)

	if err != nil {
		return fmt.Errorf("创建用户 %q 失败:%w", name, err)
	}

	fmt.Printf("用户创建成功:姓名=%s,年龄=%d\n", name, age)

	return nil
}

这个函数只返回一个 error

func createUser(name, ageText string) error

因为这里暂时不需要返回用户对象,只需要告诉调用者:

创建成功了
还是创建失败了

成功时返回:

return nil

失败时返回:

return fmt.Errorf("创建用户 %q 失败:%w", name, err)

然后在 main 中处理最终错误:

func main() {
	err := createUser("Starry", "30岁")

	if err != nil {
		fmt.Println("程序处理失败:", err)
		return
	}

	fmt.Println("程序执行完成")
}

失败时输出:

程序处理失败: 创建用户 "Starry" 失败:年龄 "30岁" 不是合法整数:strconv.Atoi: parsing "30岁": invalid syntax

如果改成正确的年龄:

err := createUser("Starry", "30")

输出:

用户创建成功:姓名=Starry,年龄=30
程序执行完成

这一次,错误经历了多层传递:

strconv.Atoi
→ parseAge
→ createUser
→ main

strconv.Atoi 负责发现最底层的转换问题:

parsing "30岁": invalid syntax

parseAge 补充当前参数的上下文:

年龄 "30岁" 不是合法整数

createUser 再补充当前业务场景:

创建用户 "Starry" 失败

最后由 main 决定如何处理:

打印错误
→ 结束程序

这体现了一种很常见的错误传递方式:

底层函数描述具体失败原因,上层函数补充当前业务场景,最外层决定如何响应。

以后在后端接口中,最外层可能不是简单地打印错误,而是:

记录日志
返回 HTTP 状态码
返回 JSON 错误信息
结束当前请求

%w 目前应该怎么理解?

createUser 中,我使用了:

fmt.Errorf("创建用户 %q 失败:%w", name, err)

这里的 %w 用来包装原来的错误。

最终生成的错误既包含上层信息:

创建用户 "Starry" 失败

也保留了底层错误:

年龄 "30岁" 不是合法整数

目前可以先简单区分:

%v:把错误内容格式化到字符串中
%w:创建新错误时包装原来的错误

例如:

fmt.Errorf("创建用户失败:%v", err)

虽然输出中也能看到原错误的文字,但它没有建立可供 Go 错误工具继续检查的包装关系。

而:

fmt.Errorf("创建用户失败:%w", err)

会保留错误链。

后续可以配合:

errors.Is()
errors.As()

检查错误链中是否存在某个特定错误。

这部分等后面遇到实际场景时再继续学习。

现阶段先记住:

给错误补充上下文时,不要只保留上层描述,也要尽量保留真正的底层原因。


一个完整的参数校验与错误传递示例

把前面的代码整理到一起,可以得到下面这个完整示例:

package main

import (
	"fmt"
	"strconv"
)

func parseAge(ageText string) (int, error) {
	age, err := strconv.Atoi(ageText)

	if err != nil {
		return 0, fmt.Errorf("年龄 %q 不是合法整数:%w", ageText, err)
	}

	if age < 0 {
		return 0, fmt.Errorf("年龄不能小于 0,当前值为 %d", age)
	}

	if age > 150 {
		return 0, fmt.Errorf("年龄不能大于 150,当前值为 %d", age)
	}

	return age, nil
}

func createUser(name, ageText string) error {
	age, err := parseAge(ageText)

	if err != nil {
		return fmt.Errorf("创建用户 %q 失败:%w", name, err)
	}

	fmt.Printf("用户创建成功:姓名=%s,年龄=%d\n", name, age)

	return nil
}

func main() {
	err := createUser("Starry", "30岁")

	if err != nil {
		fmt.Println("程序处理失败:", err)
		return
	}

	fmt.Println("程序执行完成")
}

这个示例中的调用关系是:

main
└── createUser
    └── parseAge
        └── strconv.Atoi

错误则沿着相反的方向向上传递:

strconv.Atoi 发现格式错误
→ parseAge 补充参数上下文
→ createUser 补充业务上下文
→ main 决定最终如何处理

这已经有了一点实际后端项目的代码结构:

底层负责完成具体操作
业务层负责组织业务逻辑
入口层负责返回或展示最终结果

从前端视角看 Go 的错误处理

在 JavaScript 和 TypeScript 中,我更熟悉的是:

try {
  const result = await doSomething();
} catch (error) {
  console.error(error);
}

错误通常通过抛出异常进入 catch

Go 处理可预期错误时,常见方式则是:

result, err := doSomething()

if err != nil {
	return err
}

也就是把错误当成一个普通返回值,明确交给调用者。

刚开始看到大量的:

if err != nil

确实会觉得有点重复。

但自己写完 divideparseAgecreateUser 后,我开始理解这种设计想表达什么。

每次调用一个可能失败的函数时,代码都会提醒我:

这个操作可能失败
→ 你检查错误了吗?
→ 失败以后准备怎么办?

错误不会因为没人注意就自动消失。

它通常有三种去向:

当前函数直接处理
→ 补充上下文后继续返回
→ 明确选择忽略

其中,忽略错误应该是经过思考后的决定,而不是为了少写几行代码就直接使用 _


Go 完全没有异常吗?

这里也需要避免一个误解:

Go 并不是完全没有类似异常的机制。

Go 还提供了:

panic
recover

但是对于参数错误、文件读取失败、数据库查询失败、网络请求失败等可以预期的问题,Go 通常会使用 error 返回值处理。

panic 更适合表示程序遇到了无法继续正常执行的严重问题,而不应该被当成普通业务错误处理方式。

目前我还没有正式学习 panicrecover,所以这一阶段先专注于最常见的模式:

result, err := doSomething()

if err != nil {
	// 处理或返回错误
}

这一阶段真实踩过的坑

回头看这一轮,最值得记录的还是编译器给出的这些提示。

参数传少了

not enough arguments in call to introduce
	have (string)
	want (string, string)

参数传多了

too many arguments in call to introduce
	have (string, string, string)
	want (string, string)

参数类型不对

cannot use 30 (untyped int constant) as string value in argument to introduce

返回值数量和接收变量不一致

assignment mismatch: 2 variables but add returns 1 value

忽略错误后,普通返回值不一定可信

age, _ := strconv.Atoi("30岁")

此时得到:

age = 0

但是已经无法通过代码判断:

用户输入的是 "0"
还是转换失败后得到了零值 0

错误不能只看普通返回值

正确写法应该是:

age, err := strconv.Atoi("30岁")

if err != nil {
	fmt.Println("转换失败:", err)
	return
}

fmt.Println("年龄:", age)

真正应该判断的是:

err != nil

而不是:

age == 0

这一阶段的知识点总结

这一阶段实际学习到的内容可以整理成下面这张表:

内容示例
声明函数func sayHello() {}
接收参数func introduce(name string) {}
同类型参数简写func add(a, b int) {}
返回一个值func add(a, b int) int
返回多个值func calculate(a, b int) (int, int)
忽略返回值result, _ := calculate()
返回错误func divide(a, b int) (int, error)
创建固定错误errors.New("除数不能为 0")
创建动态错误fmt.Errorf("当前值为 %d", value)
包装底层错误fmt.Errorf("操作失败:%w", err)
判断是否失败if err != nil {}
成功时返回空错误return result, nil
将错误向上传递return fmt.Errorf("业务失败:%w", err)

Go 中常见的错误处理结构则是:

result, err := doSomething()

if err != nil {
	return err
}

fmt.Println(result)

如果当前函数还需要补充业务上下文,可以写成:

result, err := doSomething()

if err != nil {
	return fmt.Errorf("执行某项业务失败:%w", err)
}

写在最后

这一阶段,我从最简单的函数开始,一路学习到了:

定义函数
→ 传递参数
→ 返回一个值
→ 返回多个值
→ 使用空白标识符
→ 返回 error
→ 判断 err 是否为 nil
→ 创建固定错误
→ 创建带上下文的错误
→ 包装底层错误
→ 将错误继续返回给上一层

现在再看上一篇留下的代码:

age, err := strconv.Atoi(ageText)

已经不再只是一个需要死记硬背的固定写法。

它表达的是:

尝试把字符串转换成整数
→ 返回转换结果
→ 同时返回是否发生错误

成功时:

age 是有效结果
err 是 nil

失败时:

age 得到 int 类型的零值
err 保存具体错误

这一阶段让我对 Go 又多了一层感受:

Go 不只是要求成功路径写清楚,也要求失败路径明确存在。

函数不能只告诉调用者“我做完了”。

如果某件事可能失败,它还要把失败原因交出来,让调用者决定下一步怎么做。

对于以前主要关注页面展示和接口结果的前端开发者来说,这种思维正在慢慢把我的关注点从:

正常情况下能不能运行?

扩展到:

输入不合法怎么办?
操作失败怎么办?
错误应该在哪里处理?
是否需要补充业务上下文?
要不要继续返回给上一层?
最外层应该向用户返回什么?

这应该也是从前端走向全栈时,必须逐步建立起来的能力。

下一阶段,我会继续学习函数相关内容:

命名返回值
→ deferdefer 的执行顺序
→ 文件和资源为什么需要关闭
→ panicrecover 的基础认识

还是按照现在的方式:

先写代码
→ 主动制造问题
→ 观察编译器和运行结果
→ 理解规则
→ 再整理成文章

下一篇见。