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

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

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

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

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

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

css 复制代码
age, err := strconv.Atoi("30")

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

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

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

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

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

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

go 复制代码
func add(a, b int) int {
	return a + b
}

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

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

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

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

我成功得到了什么。

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

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

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


创建函数练习项目

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

bash 复制代码
D:\LearningGO\04-functions

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

bash 复制代码
cd /d/LearningGO
mkdir 04-functions
cd 04-functions
go mod init functions

终端输出:

vbnet 复制代码
go: creating new go.mod: module functions

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

go 复制代码
04-functions/
├── go.mod
└── main.go

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


第一个自定义函数

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

go 复制代码
package main

import "fmt"

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

func main() {
	sayHello()
}

运行:

arduino 复制代码
go run .

输出:

复制代码
你好,我正在学习 Go 函数。

函数定义是:

csharp 复制代码
func sayHello() {
}

拆开来看:

go 复制代码
func        声明函数
sayHello    函数名称
()          参数列表
{}          函数体

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

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

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

scss 复制代码
sayHello()

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


给函数传递参数

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

go 复制代码
package main

import "fmt"

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

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

运行结果:

复制代码
大家好,我是 Starry,目前是一名前端开发。

函数定义:

go 复制代码
func introduce(name, job string)

表示两个参数都是 string

c 复制代码
name:string
job:string

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

完整写法则是:

go 复制代码
func introduce(name string, job string)

两种写法的效果相同。

和 TypeScript 对比:

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

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

makefile 复制代码
TypeScript:参数名: 类型
Go:        参数名 类型

Go 对参数数量很较真

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

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

go 复制代码
func introduce(name, job string)

少传一个参数

如果只传一个参数:

scss 复制代码
introduce("Starry")

编译器会提示:

vbnet 复制代码
not enough arguments in call to introduce
	have (string)
	want (string, string)

这里的提示非常直观:

复制代码
have:实际传入了什么
want:函数需要什么

也就是:

c 复制代码
实际传入:1 个 string
函数需要:2 个 string

多传一个参数

接着尝试多传一个参数:

erlang 复制代码
introduce("Starry", "前端开发", "Go")

编译器提示:

vbnet 复制代码
too many arguments in call to introduce
	have (string, string, string)
	want (string, string)

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

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

javascript 复制代码
function introduce(name, job) {
  console.log(name, job);
}

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

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

Go 则要求:

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

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


参数类型也必须对应

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

scss 复制代码
introduce("Starry", 30)

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

go 复制代码
func introduce(name, job string)

所以编译器继续拒绝:

csharp 复制代码
cannot use 30 (untyped int constant) as string value in argument to introduce

也就是说:

c 复制代码
函数需要:string
实际传入:int

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

less 复制代码
introduce("Starry", strconv.Itoa(30))

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


让函数返回一个值

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

go 复制代码
package main

import "fmt"

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

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

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

输出:

复制代码
计算结果: 30

重点是这部分:

go 复制代码
func add(a, b int) int

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

可以拆成:

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

TypeScript 中的写法是:

typescript 复制代码
function add(a: number, b: number): number {
  return a + b;
}

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

go 复制代码
func add(a, b int) int

调用时:

sql 复制代码
result := add(10, 20)

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


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

add 只返回一个值:

go 复制代码
func add(a, b int) int {
	return a + b
}

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

csharp 复制代码
result1, result2 := add(10, 20)

编译器提示:

sql 复制代码
assignment mismatch: 2 variables but add returns 1 value

也就是:

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

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

yaml 复制代码
declared and not used: result1
declared and not used: result2
undefined: result

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

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

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

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

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

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

函数定义中的:

go 复制代码
func calculate(a, b int) (int, int)

最后的:

arduino 复制代码
(int, int)

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

返回时写:

bash 复制代码
return sum, product

调用时写:

bash 复制代码
sum, product := calculate(10, 20)

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

bash 复制代码
第一个返回值 sum      → 左边的 sum
第二个返回值 product  → 左边的 product

整个过程可以理解为:

scss 复制代码
calculate(10, 20)
→ 返回 30 和 200
→ sum 接收 30
→ product 接收 200

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

css 复制代码
age, err := strconv.Atoi(ageText)

它就没有那么神秘了。

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

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

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

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

css 复制代码
_, product := calculate(10, 20)

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

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

bash 复制代码
sum, _ := calculate(10, 20)

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

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

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

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

ini 复制代码
_ = name

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

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

例如:

css 复制代码
age, _ := strconv.Atoi("30岁")

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

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

复制代码
0

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

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

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


第一次自己返回 error

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

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

go 复制代码
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)
}

正常调用:

scss 复制代码
divide(10, 2)

输出:

复制代码
计算结果: 5

函数声明是:

go 复制代码
func divide(a, b int) (int, error)

表示函数会返回:

go 复制代码
第一个值:int 类型的计算结果
第二个值:error 类型的错误

成功时:

css 复制代码
return a / b, nil

其中:

css 复制代码
a / b:正常计算结果
nil:没有错误

失败时:

kotlin 复制代码
return 0, errors.New("除数不能为 0")

这里的:

vbnet 复制代码
errors.New("除数不能为 0")

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

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

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


errornil 可以先怎么理解?

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

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

go 复制代码
error:用来保存和描述错误
nil:当前没有错误

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

go 复制代码
if err != nil {
	// 发生了错误
}

反过来:

go 复制代码
if err == nil {
	// 没有错误
}

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

go 复制代码
func doSomething() (结果类型, error)

成功时:

go 复制代码
return 正常结果, nil

失败时:

kotlin 复制代码
return 结果类型的零值, 具体错误

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


故意让除法失败

我把调用改成:

css 复制代码
result, err := divide(10, 0)

运行结果:

复制代码
计算失败: 除数不能为 0

程序进入了下面的分支:

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

这里可以先理解为:

go 复制代码
err == nil    没有错误
err != nil    发生了错误

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

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

go 复制代码
result, err := divide(10, 0)

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

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

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

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

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

继续使用结果

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

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

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

go 复制代码
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")
}

运行结果:

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

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

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

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

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

这也让我开始意识到:

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

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

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


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

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

类型转换

css 复制代码
age, err := strconv.Atoi(ageText)

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

例如:

arduino 复制代码
"30"   → 可以转换
"-1"   → 可以转换
"200"  → 可以转换
"30岁" → 无法转换

业务校验

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

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

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

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

复制代码
格式是否合法?
→ 数据是否符合业务规则?

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

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

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

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

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


return 结束的是当前函数

printAge 中有这样一段代码:

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

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

例如:

scss 复制代码
printAge("30岁")
printAge("-1")
printAge("200")

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

因为程序的调用关系是:

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

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

所以:

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


固定错误和动态错误

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

vbnet 复制代码
errors.New("年龄不能小于 0")

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

复制代码
年龄不能小于 0

可能还不够直观。

我更希望知道:

复制代码
当前传入的值到底是多少?

于是开始使用 fmt.Errorf

go 复制代码
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
}

重新测试:

scss 复制代码
func main() {
	printAge("30")
	printAge("30岁")
	printAge("-1")
	printAge("200")
}

运行结果:

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

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

errors.New

vbnet 复制代码
errors.New("固定错误信息")

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

例如:

vbnet 复制代码
errors.New("除数不能为 0")

fmt.Errorf

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

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

例如:

perl 复制代码
fmt.Errorf("年龄 %q 不是合法整数", ageText)

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

arduino 复制代码
年龄 "30岁" 不是合法整数

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


不要丢掉底层错误

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

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

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

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

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

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

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

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

json 复制代码
年龄 "30岁" 不是合法整数:strconv.Atoi: parsing "30岁": invalid syntax

现阶段可以先记住:

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

错误可以继续交给上一层

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

go 复制代码
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

go 复制代码
func createUser(name, ageText string) error

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

复制代码
创建成功了
还是创建失败了

成功时返回:

go 复制代码
return nil

失败时返回:

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

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

go 复制代码
func main() {
	err := createUser("Starry", "30岁")

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

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

失败时输出:

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

如果改成正确的年龄:

css 复制代码
err := createUser("Starry", "30")

输出:

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

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

css 复制代码
strconv.Atoi
→ parseAge
→ createUser
→ main

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

json 复制代码
parsing "30岁": invalid syntax

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

arduino 复制代码
年龄 "30岁" 不是合法整数

createUser 再补充当前业务场景:

arduino 复制代码
创建用户 "Starry" 失败

最后由 main 决定如何处理:

复制代码
打印错误
→ 结束程序

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

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

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

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

%w 目前应该怎么理解?

createUser 中,我使用了:

perl 复制代码
fmt.Errorf("创建用户 %q 失败:%w", name, err)

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

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

arduino 复制代码
创建用户 "Starry" 失败

也保留了底层错误:

arduino 复制代码
年龄 "30岁" 不是合法整数

目前可以先简单区分:

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

例如:

perl 复制代码
fmt.Errorf("创建用户失败:%v", err)

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

而:

perl 复制代码
fmt.Errorf("创建用户失败:%w", err)

会保留错误链。

后续可以配合:

scss 复制代码
errors.Is()
errors.As()

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

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

现阶段先记住:

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


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

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

go 复制代码
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("程序执行完成")
}

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

css 复制代码
main
└── createUser
    └── parseAge
        └── strconv.Atoi

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

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

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

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

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

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

vbnet 复制代码
try {
  const result = await doSomething();
} catch (error) {
  console.error(error);
}

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

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

go 复制代码
result, err := doSomething()

if err != nil {
	return err
}

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

刚开始看到大量的:

go 复制代码
if err != nil

确实会觉得有点重复。

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

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

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

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

它通常有三种去向:

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

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


Go 完全没有异常吗?

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

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

Go 还提供了:

go 复制代码
panic
recover

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

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

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

go 复制代码
result, err := doSomething()

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

这一阶段真实踩过的坑

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

参数传少了

vbnet 复制代码
not enough arguments in call to introduce
	have (string)
	want (string, string)

参数传多了

vbnet 复制代码
too many arguments in call to introduce
	have (string, string, string)
	want (string, string)

参数类型不对

csharp 复制代码
cannot use 30 (untyped int constant) as string value in argument to introduce

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

sql 复制代码
assignment mismatch: 2 variables but add returns 1 value

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

css 复制代码
age, _ := strconv.Atoi("30岁")

此时得到:

ini 复制代码
age = 0

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

arduino 复制代码
用户输入的是 "0"
还是转换失败后得到了零值 0?

错误不能只看普通返回值

正确写法应该是:

go 复制代码
age, err := strconv.Atoi("30岁")

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

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

真正应该判断的是:

go 复制代码
err != nil

而不是:

ini 复制代码
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 中常见的错误处理结构则是:

go 复制代码
result, err := doSomething()

if err != nil {
	return err
}

fmt.Println(result)

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

go 复制代码
result, err := doSomething()

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

写在最后

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

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

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

css 复制代码
age, err := strconv.Atoi(ageText)

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

它表达的是:

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

成功时:

go 复制代码
age 是有效结果
err 是 nil

失败时:

arduino 复制代码
age 得到 int 类型的零值
err 保存具体错误

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

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

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

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

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

复制代码
正常情况下能不能运行?

扩展到:

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

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

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

go 复制代码
命名返回值
→ defer
→ defer 的执行顺序
→ 文件和资源为什么需要关闭
→ panic 与 recover 的基础认识

还是按照现在的方式:

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

下一篇见。

相关推荐
我命由我1234544 分钟前
Kotlin 面向对象 - Kotlin 类变量与类方法
java·服务器·后端·java-ee·kotlin·android jetpack·android runtime
SimonKing1 小时前
开源免费+AI运维,Netcatty快速上手教程
java·后端·程序员
Canace1 小时前
GPT-5.6 到底怎么选?一文搞懂 Sol、Terra、Luna 和 Ultra
前端·人工智能·chatgpt
用户059540174461 小时前
LangChain Memory 踩坑实录:10种异常场景差点搞崩生产,我们写了一套自动化测试保命
前端·css
名字还没想好☜1 小时前
React useImperativeHandle 实战:让父组件安全地调用子组件的方法
前端·javascript·react.js·react·forwardref
CodeSheep1 小时前
FFmpeg 9.0正式发布:代号“Lei”,以纪念中国开发者雷霄骅
前端·后端·程序员
IT_陈寒1 小时前
小心!Java里的这个空指针问题绝对坑过你
前端·人工智能·后端
Canace1 小时前
AI 都能操作浏览器了,却读不了微信公众号文章
前端·人工智能·产品
铁皮饭盒1 小时前
阿里ASR语音转文字,用JS模拟微信
前端·javascript·后端