前端开发转 Go 全栈(四):代码写在前面,却要最后执行?我终于搞懂了 defer

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

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

上一篇从函数定义、参数与多返回值,一路学习到了 error、错误包装和错误传递。这一篇继续学习函数相关内容,重点弄清楚命名返回值、裸返回,以及 Go 中非常有特点的 defer。

前言:函数执行到 return,真的结束了吗?

上一篇学习函数和错误处理时,我经常写这样的代码:

go 复制代码
func divide(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("除数不能为 0")
	}

	return a / b, nil
}

函数通过:

go 复制代码
return a / b, nil

明确返回计算结果和错误。

按照以前写 JavaScript 的习惯,我一直觉得:

函数执行到 return,这次调用就已经结束了。

但这次学习 defer 后,我发现 Go 中的 return 并不一定意味着函数已经真正退出。

在函数返回之前,可能还有一些提前登记好的操作需要执行:

text 复制代码
计算并设置返回值
→ 执行 defer
→ 函数真正返回

更反直觉的是:

函数已经执行到 return 了,defer 居然还能修改最终返回值。

这一次实际学习和验证的内容主要有两个:

text 复制代码
命名返回值
defer

命名返回值还比较容易理解,无非是提前给函数的返回结果起个名字。

但 defer 就有点反直觉了。

代码明明写在前面,却不会立即执行,而是要等当前函数准备结束时才运行。

作为一名前端开发,我看到 defer 的第一反应是:

arduino 复制代码
text
它是不是有点像 setTimeout?

但真正写完几个实验后,我发现它们完全不是一回事。

这篇文章就来记录一下,我是怎么一步步弄清楚命名返回值、裸返回和 defer 的。


返回值也可以提前起名字

上一篇写过一个除法函数:

go 复制代码
func divide(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("除数不能为 0")
	}

	return a / b, nil
}

这里的返回值只有类型:

go 复制代码
(int, error)

Go 还允许我们直接给返回值起名字:

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

完整代码如下:

go 复制代码
package main

import (
	"errors"
	"fmt"
)

func divide(a, b int) (result int, err error) {
	if b == 0 {
		err = errors.New("除数不能为 0")
		return
	}

	result = a / b
	return
}

func main() {
	result, err := divide(10, 2)

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

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

运行结果:

text 复制代码
计算结果: 5

再把除数改成 0:

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

输出:

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

命名返回值进入函数时就已经存在

重点看函数声明:

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

这里提前声明了两个返回值:

text 复制代码
result int
err    error

可以暂时理解为,函数刚开始执行时,Go 已经准备好了两个变量:

go 复制代码
var result int
var err error

而且它们已经获得了对应类型的零值:

text 复制代码
result = 0
err = nil

所以成功时,只需要给 result 赋值:

go 复制代码
result = a / b
return

失败时,只需要给 err 赋值:

go 复制代码
err = errors.New("除数不能为 0")
return

函数中的其他代码也可以直接读取和修改这些命名返回值。


return 后面什么都不写,也能返回?

前面的代码里出现了一个看起来有点奇怪的写法:

go 复制代码
return

之前的 return 都会明确写出返回内容:

go 复制代码
return a / b, nil

但是使用命名返回值后,只写一个 return 也可以。

因为函数已经提前声明了:

go 复制代码
(result int, err error)

Go 会返回当前的 result 和 err。

例如:

go 复制代码
result = a / b
return

可以暂时理解成:

go 复制代码
return result, err

这种没有在 return 后明确写出返回表达式的方式,通常叫作裸返回。


命名返回值不代表必须裸返回

即使返回值已经有名字,仍然可以显式返回。

例如:

go 复制代码
func divide(a, b int) (result int, err error) {
	if b == 0 {
		return 0, errors.New("除数不能为 0")
	}

	return a / b, nil
}

也就是说,下面两种写法都可以。

使用裸返回

go 复制代码
func divide(a, b int) (result int, err error) {
	if b == 0 {
		err = errors.New("除数不能为 0")
		return
	}

	result = a / b
	return
}

使用显式返回

go 复制代码
func divide(a, b int) (result int, err error) {
	if b == 0 {
		return 0, errors.New("除数不能为 0")
	}

	return a / b, nil
}

裸返回确实可以少写几个字符。

但函数一旦变长,问题也会随之出现。

看到:

go 复制代码
return

很容易产生疑问:

text 复制代码
当前的 result 是什么?
err 有没有在某个分支中被修改?
这个 return 最终返回了哪些值?

所以我目前的理解是:

命名返回值需要认识,裸返回也需要看得懂,但不能为了少写几个字就在所有函数中使用。

在很短的函数中,裸返回可能还比较清晰。

函数稍微复杂一些,明确写出返回内容通常更容易阅读:

go 复制代码
return result, err

什么都不赋值,会返回什么?

为了验证命名返回值的初始值,我又写了一个函数:

go 复制代码
package main

import "fmt"

func getDefaultValues() (count int, message string, success bool) {
	return
}

func main() {
	count, message, success := getDefaultValues()

	fmt.Printf("count:%d\n", count)
	fmt.Printf("message:%q\n", message)
	fmt.Printf("success:%t\n", success)
}

运行结果:

text 复制代码
count:0
message:""
success:false

虽然函数体中什么都没有做,但三个返回值依然存在:

text 复制代码
int    → 0
string → ""
bool   → false

这再次说明:

命名返回值在函数开始执行时就已经存在,并自动获得对应类型的零值。

因此,下面的函数会直接返回 0:

go 复制代码
func getCount() (count int) {
	return
}

但这种写法虽然合法,表达的意图并不一定清楚。

如果函数本来就是要返回 0,直接写出来通常更直观:

go 复制代码
func getCount() int {
	return 0
}

从前端视角理解命名返回值

JavaScript 和 TypeScript 没有与 Go 命名返回值完全对应的语法。

TypeScript 通常只能声明整个返回结果的类型:

typescript 复制代码
function divide(
  a: number,
  b: number
): { result: number; error: Error | null } {
  if (b === 0) {
    return {
      result: 0,
      error: new Error("除数不能为 0"),
    };
  }

  return {
    result: a / b,
    error: null,
  };
}

这里的 result 和 error 是返回对象的属性。

Go 的命名返回值则直接属于函数声明:

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

它们在进入函数时,就已经是可以访问和修改的变量:

go 复制代码
result = a / b
err = nil

这也是后面 defer 能够修改最终返回值的重要前提。


defer:代码先登记,函数结束前再执行

接下来进入这篇真正反直觉的部分。

先看第一段代码:

go 复制代码
package main

import "fmt"

func study() {
	fmt.Println("1. 开始学习")

	defer fmt.Println("3. 学习结束")

	fmt.Println("2. 正在学习")
}

func main() {
	study()
}

运行结果:

text 复制代码
1. 开始学习
2. 正在学习
3. 学习结束

代码中的顺序明明是:

text 复制代码
1
3
2

输出却是:

text 复制代码
1
2
3

原因是:

go 复制代码
defer fmt.Println("3. 学习结束")

不会立即调用 fmt.Println。

它会先登记这次延迟调用,等当前函数准备退出时再执行。defer 后面的调用会在包含它的函数即将返回前运行。

实际过程可以理解为:

text 复制代码
输出"开始学习"
→ 登记 defer
→ 输出"正在学习"
→ study 准备结束
→ 执行 defer
→ study 真正结束

这里需要特别注意:

defer 关注的是当前函数,不是整个程序。

上面的 defer 写在 study 中,所以它会在 study 退出前执行,而不是等到 main 结束时再执行。


defer 不是 setTimeout

作为前端开发,看到"延迟执行",很容易想到:

javascript 复制代码
setTimeout(() => {
  console.log("稍后执行");
}, 0);

但 defer 和 setTimeout 完全不是一回事。

text 复制代码
setTimeout
→ 把回调交给定时器和事件循环机制
→ 等满足条件后再进入任务队列
→ 属于 JavaScript 异步调度的一部分

而:

text 复制代码
defer
→ 仍然属于当前函数的执行流程
→ 在当前函数退出前同步执行
→ defer 执行完成后,当前函数才会真正返回

所以:

text 复制代码
defer ≠ setTimeout
defer ≠ 异步任务
defer ≠ 开启新的 goroutine

defer 只是把一次函数或方法调用安排到当前函数退出前执行。


JavaScript 中更接近 defer 的是 finally

看下面这个 Go 函数:

go 复制代码
package main

import "fmt"

func study(success bool) {
	fmt.Println("开始执行 study")

	defer fmt.Println("study 即将结束")

	if !success {
		fmt.Println("学习失败,提前返回")
		return
	}

	fmt.Println("学习成功")
}

func main() {
	study(false)
}

输出:

text 复制代码
开始执行 study
学习失败,提前返回
study 即将结束

即使函数提前执行了:

go 复制代码
return

之前已经登记的 defer 仍然会执行。

JavaScript 中比较接近的写法是:

javascript 复制代码
function study(success) {
  console.log("开始执行 study");

  try {
    if (!success) {
      console.log("学习失败,提前返回");
      return;
    }

    console.log("学习成功");
  } finally {
    console.log("study 即将结束");
  }
}

study(false);

输出同样是:

text 复制代码
开始执行 study
学习失败,提前返回
study 即将结束

两者表达的意图比较接近:

text 复制代码
Go defer
→ 当前函数退出前执行收尾操作

JavaScript finally
→ try 代码块退出时执行收尾操作

不过两者并不完全等价。

Go 可以在函数的不同位置登记多个 defer,不需要把主要逻辑全部放进一个 try 代码块。

所以可以使用 finally 帮助建立初步理解,但不能直接把它们当成完全相同的语法。


为什么 defer 适合做资源清理?

假设以后需要打开一个文件:

go 复制代码
file, err := os.Open("test.txt")

if err != nil {
	return err
}

defer file.Close()

这里可以理解为:

text 复制代码
文件成功打开
→ 立即登记"函数退出前关闭文件"
→ 中间继续处理文件
→ 即使某个分支提前 return
→ 最终仍然会调用 file.Close()

以后还会看到类似代码:

go 复制代码
defer rows.Close()
go 复制代码
defer response.Body.Close()
go 复制代码
defer mutex.Unlock()

它们都有一个共同点:

text 复制代码
前面获得了某种资源
→ 当前函数结束前需要释放

把清理操作紧跟在成功获得资源的代码后面,可以减少后续增加多个返回分支时遗漏清理逻辑的可能。defer 也经常被用于文件关闭、资源释放和锁的解锁。

现在我还没有正式学习文件、数据库和锁,但已经可以先理解为什么 defer 会频繁出现在这些场景中。

需要注意的是:

必须先确认资源获取成功,再登记对应的清理操作。

也就是先判断 err:

go 复制代码
file, err := os.Open("test.txt")

if err != nil {
	return err
}

defer file.Close()

而不是不管文件有没有打开成功,就直接尝试关闭。


多个 defer,谁先执行?

接下来,我又登记了三个 defer:

go 复制代码
package main

import "fmt"

func main() {
	defer fmt.Println("第一个 defer")
	defer fmt.Println("第二个 defer")
	defer fmt.Println("第三个 defer")

	fmt.Println("main 正在执行")
}

运行结果:

text 复制代码
main 正在执行
第三个 defer
第二个 defer
第一个 defer

多个 defer 不是按照登记顺序执行,而是完全反过来。

它们遵循:

text 复制代码
后进先出

也就是:

text 复制代码
先登记第一个
→ 再登记第二个
→ 最后登记第三个

执行时:
第三个
→ 第二个
→ 第一个

这种顺序也叫:

text 复制代码
LIFO
Last In, First Out

多个 defer 会按照后进先出的顺序运行,最后登记的延迟调用最先执行。

可以把它想象成叠盘子:

text 复制代码
第一个盘子放在最下面
第二个盘子放在中间
第三个盘子放在最上面

取盘子时:
先取第三个
再取第二个
最后取第一个

这种执行顺序对于多个资源的清理也很有意义。

假设资源是按照下面的顺序获得的:

text 复制代码
获得资源 A
→ 获得资源 B
→ 获得资源 C

清理时通常会反过来:

text 复制代码
释放资源 C
→ 释放资源 B
→ 释放资源 A

变量修改后,defer 输出旧值还是新值?

接着,我测试了一个很容易猜错的问题:

go 复制代码
package main

import "fmt"

func main() {
	name := "JavaScript"

	defer fmt.Println(name)

	name = "Go"
}

最后输出的是:

text 复制代码
JavaScript

而不是:

text 复制代码
Go

原因是:

defer 延迟的是函数调用,但函数参数会在执行到 defer 这一行时立即求值并保存。

执行到:

go 复制代码
defer fmt.Println(name)

时,name 的值还是:

text 复制代码
JavaScript

为了帮助自己理解,可以近似想象成 Go 先做了:

go 复制代码
savedName := name

然后登记:

go 复制代码
defer fmt.Println(savedName)

后面虽然执行:

go 复制代码
name = "Go"

但是已经确定的参数值不会跟着变化。

所以需要区分两个时间点:

text 复制代码
defer 后面的函数调用
→ 当前函数退出前执行

传给这次调用的参数
→ 执行到 defer 语句时确定

可以总结为:

调用延迟,参数不延迟。


直接传参和匿名函数读取变量并不一样

前面的写法是直接把 name 作为参数传给 fmt.Println:

go 复制代码
defer fmt.Println(name)

如果改成匿名函数:

go 复制代码
package main

import "fmt"

func main() {
	name := "JavaScript"

	defer func() {
		fmt.Println(name)
	}()

	name = "Go"
}

这次会输出:

text 复制代码
Go

原因是,匿名函数本身没有接收 name 参数:

go 复制代码
func() {
	fmt.Println(name)
}()

fmt.Println(name) 位于匿名函数体内,要等这个匿名函数真正执行时才会读取 name。直接作为延迟调用参数传入的表达式会在 defer 语句执行时求值,而匿名函数体内的表达式会在匿名函数真正执行时求值。

二者可以先这样区分:

go 复制代码
defer fmt.Println(name)
text 复制代码
name 作为调用参数
→ 执行到 defer 时确定
→ 输出登记时的值

而:

go 复制代码
defer func() {
	fmt.Println(name)
}()
text 复制代码
name 在匿名函数体内读取
→ 匿名函数实际执行时再读取
→ 可能输出修改后的值

这篇暂时不会单独展开匿名函数和闭包。

这里只需要先意识到:

defer 语句执行时会立即计算它的调用参数,但这不等于为整个匿名函数体创建了一份变量快照。


从前端视角理解 defer 参数

JavaScript 可以用下面的代码模拟这种"先保存旧值,最后再使用"的效果:

javascript 复制代码
function main() {
  let name = "JavaScript";

  const savedName = name;

  try {
    name = "Go";
    console.log("main 中:", name);
  } finally {
    console.log("最后输出:", savedName);
  }
}

main();

输出:

text 复制代码
main 中: Go
最后输出: JavaScript

这里真正关键的不是 finally,而是:

javascript 复制代码
const savedName = name;

在变量修改前,先保存了一份旧值。

Go 的:

go 复制代码
defer fmt.Println(name)

也会在登记时确定参数值。

所以这个 JavaScript 示例只是帮助理解下面这件事:

text 复制代码
变量之后还可以修改
但 defer 已经保存了调用时需要的参数值

defer 可以修改命名返回值吗?

接下来进入这次学习中最有意思的实验:

go 复制代码
package main

import "fmt"

func getNumber() (result int) {
	result = 10

	defer func() {
		result++
		fmt.Println("defer 中的 result:", result)
	}()

	fmt.Println("return 前的 result:", result)

	return
}

func main() {
	number := getNumber()

	fmt.Println("最终返回值:", number)
}

运行结果:

text 复制代码
return 前的 result: 10
defer 中的 result: 11
最终返回值: 11

函数原本准备返回:

text 复制代码
10

但是在函数真正退出之前,defer 又把命名返回值增加了 1:

go 复制代码
result++

最后调用者拿到的结果变成了:

text 复制代码
11

命名返回值属于当前函数作用域中的变量,而延迟函数会在函数真正返回前执行,因此延迟函数可以访问并修改命名返回值。


return 10 也会被 defer 修改

接着把函数改成:

go 复制代码
func getNumber() (result int) {
	defer func() {
		result++
		fmt.Println("defer 中的 result:", result)
	}()

	return 10
}

完整调用:

go 复制代码
package main

import "fmt"

func getNumber() (result int) {
	defer func() {
		result++
		fmt.Println("defer 中的 result:", result)
	}()

	return 10
}

func main() {
	number := getNumber()

	fmt.Println("最终返回值:", number)
}

运行结果仍然是:

text 复制代码
defer 中的 result: 11
最终返回值: 11

虽然代码明确写了:

go 复制代码
return 10

但对于命名返回值,可以暂时把这个过程理解为:

go 复制代码
result = 10

然后执行已经登记的 defer:

go 复制代码
result++

最后才真正返回。

所以 Go 的 return 并不是看到以后立即退出函数。

它大致会经历三个步骤:

text 复制代码
1. 计算返回表达式,并设置返回值
2. 执行已经登记的 defer
3. 函数真正退出,把结果交给调用者

因为这里的 result 是命名返回值,所以 defer 修改的正是函数最终要返回的那个变量。


普通返回值为什么没有被修改?

再把函数改成普通返回值:

go 复制代码
package main

import "fmt"

func getNumber() int {
	result := 10

	defer func() {
		result++
		fmt.Println("defer 中的 result:", result)
	}()

	return result
}

func main() {
	number := getNumber()

	fmt.Println("最终返回值:", number)
}

运行结果:

text 复制代码
defer 中的 result: 11
最终返回值: 10

defer 明明把 result 改成了 11,最终返回值为什么还是 10?

因为执行:

go 复制代码
return result

时,要返回的值已经根据 result 当时的内容计算并保存:

text 复制代码
准备返回的值 = 10

后面的 defer 修改的是函数中的局部变量:

text 复制代码
局部变量 result = 11

但是已经准备好的返回结果不会跟着这个局部变量一起变化。

这里的 result 只是一个普通局部变量:

go 复制代码
result := 10

它并不是命名返回值。

因此:

text 复制代码
局部变量 result

和:

text 复制代码
函数准备返回的值

已经不是同一个可以继续共同变化的变量。


三种情况放在一起看

为了更清楚地理解,我把三种情况放在一起比较。

第一种:命名返回值加裸返回

go 复制代码
func getNumber() (result int) {
	result = 10

	defer func() {
		result++
	}()

	return
}

最终结果:

text 复制代码
11

执行过程:

text 复制代码
result = 10
→ 遇到 return
→ 执行 defer
→ result 变成 11
→ 返回 11

第二种:命名返回值加 return 10

go 复制代码
func getNumber() (result int) {
	defer func() {
		result++
	}()

	return 10
}

最终结果:

text 复制代码
11

执行过程:

text 复制代码
把 10 设置给命名返回值 result
→ 执行 defer
→ result 变成 11
→ 返回 11

第三种:普通返回值

go 复制代码
func getNumber() int {
	result := 10

	defer func() {
		result++
	}()

	return result
}

最终结果:

text 复制代码
10

执行过程:

text 复制代码
计算 return result
→ 准备返回 10
→ 执行 defer
→ 局部变量 result 变成 11
→ 已经准备好的返回值仍然是 10
→ 返回 10

核心差异是:

text 复制代码
命名返回值
→ defer 修改的是函数最终返回的变量

而:

text 复制代码
普通局部变量
→ return 表达式已经先计算完成
→ defer 只能修改局部变量
→ 不影响已经准备好的返回结果

可以整理成表格:

表格

函数形式 defer 修改的内容 最终结果
命名返回值加裸返回 命名返回变量 会影响最终返回值
命名返回值加显式返回 命名返回变量 会影响最终返回值
普通返回值 普通局部变量 不影响已经准备好的返回值

JavaScript 的 try/finally 也有类似表现

JavaScript 代码:

javascript 复制代码
function getNumber() {
  let result = 10;

  try {
    return result;
  } finally {
    result++;
    console.log("finally 中的 result:", result);
  }
}

console.log("最终返回值:", getNumber());

输出:

text 复制代码
finally 中的 result: 11
最终返回值: 10

这和 Go 的普通返回值比较接近:

go 复制代码
func getNumber() int {
	result := 10

	defer func() {
		result++
	}()

	return result
}

两者都可以这样理解:

text 复制代码
先计算 return 表达式
→ 再执行 finally 或 defer
→ 修改局部变量
→ 不影响已经准备好的返回结果

不过 JavaScript 还可以在 finally 中再次执行 return:

javascript 复制代码
function getNumber() {
  try {
    return 10;
  } finally {
    return 11;
  }
}

最后会返回:

text 复制代码
11

因为 finally 中的新 return 覆盖了前面的返回结果。

这种写法非常容易让代码难以理解,实际开发中通常不建议使用。

Go 中的情况稍有不同。

defer 并不是重新执行一次 return,而是在函数真正退出之前,修改了函数的命名返回变量。


defer 修改返回值,真的应该这样写吗?

下面这段代码完全合法:

go 复制代码
func calculate() (result int) {
	defer func() {
		result++
	}()

	return 10
}

但是读代码的人看到:

go 复制代码
return 10

第一反应大概率是:

text 复制代码
这个函数返回 10

实际上却返回:

text 复制代码
11

这会增加阅读和排查成本。

所以我的理解是:

defer 可以修改命名返回值,但不代表普通业务代码都应该这样写。

需要理解这种执行机制,是因为以后阅读第三方代码时可能遇到。

但自己编写业务逻辑时,还是应该优先保证返回结果直观。

例如:

go 复制代码
return result, nil

通常比在 defer 中悄悄修改结果更容易理解和维护。

当然,defer 修改命名返回值也不是完全没有合理场景。

例如,以后可能会看到某些函数在延迟关闭资源时,需要把关闭失败补充到命名错误返回值中。

但在还没有真正遇到这种需求之前,没有必要为了使用 defer 而故意设计复杂的返回逻辑。


return 与 defer 的执行过程

经过前面的实验,我现在会把包含 defer 的返回过程理解成下面这样:

text 复制代码
函数执行普通代码
→ 遇到 return
→ 计算返回表达式
→ 设置返回值
→ 按后进先出顺序执行所有 defer
→ 函数真正返回

例如:

go 复制代码
func getNumber() (result int) {
	defer func() {
		result++
	}()

	return 10
}

可以近似拆成:

text 复制代码
result = 10
→ 执行 defer
→ result++
→ result 变成 11
→ 返回 11

而普通返回值:

go 复制代码
func getNumber() int {
	result := 10

	defer func() {
		result++
	}()

	return result
}

可以近似拆成:

text 复制代码
计算 return result
→ 准备返回值 10
→ 执行 defer
→ 局部变量 result 变成 11
→ 返回之前准备好的 10

这些步骤是为了帮助自己理解执行顺序,并不是说编译器一定会把代码逐字改写成上述形式。

真正需要记住的是:

延迟调用会在返回值设置之后、函数真正返回之前执行,并且可以修改仍然可访问的命名返回值。


这一阶段最容易混淆的地方

1. 裸 return 返回什么?

go 复制代码
func getValue() (result int) {
	return
}

返回的是命名返回值当前的内容。

如果没有进行任何赋值,就是对应类型的零值。

上面的函数会返回:

text 复制代码
0

2. 使用命名返回值后,必须裸返回吗?

不需要。

下面两种写法都合法:

go 复制代码
func getValue() (result int) {
	result = 10
	return
}
go 复制代码
func getValue() (result int) {
	return 10
}

命名返回值和裸返回是两个相关但不同的概念:

text 复制代码
命名返回值
→ 给函数返回结果起名字

裸返回
→ return 后面不写返回表达式

3. defer 是异步的吗?

不是。

text 复制代码
defer ≠ setTimeout
defer ≠ Promise
defer ≠ goroutine

它仍然属于当前函数的同步执行流程,只是被安排在函数退出前执行。

当前函数需要等所有已登记的 defer 执行完成,才会真正把控制权交还给调用者。


4. return 后,defer 还会执行吗?

会。

执行顺序大致是:

text 复制代码
计算并设置返回值
→ 执行 defer
→ 真正退出函数

前提是程序已经执行到了对应的 defer 语句,那次延迟调用已经成功登记。

如果某个 defer 写在提前返回的代码之后:

go 复制代码
func study() {
	return

	// 无法执行到这里,也就不会登记
	defer fmt.Println("不会执行")
}

那么它当然不会运行。


5. 多个 defer 的顺序是什么?

后进先出:

text 复制代码
最后登记的最先执行

例如:

go 复制代码
defer fmt.Println("A")
defer fmt.Println("B")
defer fmt.Println("C")

执行顺序为:

text 复制代码
C
B
A

6. defer 的参数什么时候确定?

执行到 defer 这一行时就确定。

go 复制代码
name := "JavaScript"

defer fmt.Println(name)

name = "Go"

最后输出:

text 复制代码
JavaScript

因为 name 作为 fmt.Println 的参数,在执行 defer 语句时就已经求值。


7. 匿名函数中的变量也是立即确定吗?

不一定。

例如:

go 复制代码
name := "JavaScript"

defer func() {
	fmt.Println(name)
}()

name = "Go"

输出:

text 复制代码
Go

这里 name 不是延迟调用的参数,而是在匿名函数真正执行时从函数体中读取。

如果希望明确保存登记时的值,可以把它作为参数传入:

go 复制代码
name := "JavaScript"

defer func(savedName string) {
	fmt.Println(savedName)
}(name)

name = "Go"

这次会输出:

text 复制代码
JavaScript

因为调用参数 name 会在执行到 defer 语句时求值。


8. defer 能修改返回值吗?

如果修改的是命名返回值,可以影响最终结果:

go 复制代码
func getNumber() (result int) {
	defer func() {
		result++
	}()

	return 10
}

最终返回:

text 复制代码
11

如果修改的只是普通局部变量,通常不会改变已经计算并准备好的返回结果:

go 复制代码
func getNumber() int {
	result := 10

	defer func() {
		result++
	}()

	return result
}

最终返回:

text 复制代码
10

这一阶段的知识点总结

这一阶段的实际学习范围可以整理成下面这张表:

表格

内容 示例或规则
命名返回值 func getValue() (result int)
命名返回值零值 int 为 0,string 为 "",bool 为 false
裸返回 return
显式返回 return result
登记延迟调用 defer cleanup()
defer 执行时机 当前函数真正退出前
提前返回 已登记的 defer 仍会执行
多个 defer 后进先出
参数求值时机 执行到 defer 语句时
匿名函数体求值 匿名函数真正执行时
修改命名返回值 可以影响最终结果
修改普通局部变量 不影响已经准备好的返回结果

最重要的几条规则是:

text 复制代码
defer 不是异步任务
text 复制代码
调用延迟执行,调用参数立即求值
text 复制代码
多个 defer 后进先出
text 复制代码
return 设置返回值后,还要执行 defer
text 复制代码
defer 可以修改命名返回值

写在最后

这一阶段,我从命名返回值开始,一路学到了:

text 复制代码
命名返回值
→ 返回值零值
→ 裸 return
→ defer
→ 提前 return 时执行 defer
→ 多个 defer 后进先出
→ defer 参数的求值时机
→ defer 修改命名返回值

defer 给我最深的印象是:

代码出现的位置,不一定等于代码真正执行的时间。

它写在函数中间,却可能在最后执行。

函数遇到 return 后,它仍然会运行。

它甚至可以在函数准备返回后,继续修改命名返回值。

这些能力确实很灵活,但灵活也意味着需要更加谨慎。

特别是命名返回值、裸返回和 defer 放在一起时,如果使用得太复杂,很容易让一个函数的最终结果变得不直观。

所以目前我的结论是:

text 复制代码
语法需要学会
→ 执行顺序需要理解
→ 阅读代码时需要注意
→ 实际使用要优先考虑可读性

对于前端开发者来说,可以暂时这样建立对应关系:

text 复制代码
Go defer
→ 更接近 JavaScript 的 finally
→ 不是 setTimeout
→ 也不是异步任务

但这个类比只能帮助理解"退出前执行收尾操作"。

真正使用时,还是要记住 Go 自己的规则:

text 复制代码
多个 defer 后进先出
参数在登记时求值
匿名函数体在真正执行时求值
defer 可以修改命名返回值

下一阶段,我会继续学习 Go 中的匿名函数和闭包。

这部分听起来应该会比 defer 熟悉一些。

毕竟 JavaScript 开发每天都在和函数表达式、箭头函数以及闭包打交道。

但 Go 的闭包和 JavaScript 闭包是否完全一样,还需要继续通过代码验证。

还是按照之前的方式:

text 复制代码
先写代码
→ 主动修改变量
→ 观察输出结果
→ 分析变量到底从哪里读取
→ 再整理成文章

下一篇见。


相关推荐
码事漫谈1 小时前
骂AI,会让它做得更好吗?
后端
陈随易2 小时前
bm2,Node.js 与 Bun 项目部署新选择
前端·后端·程序员
架构技术专栏3 小时前
AI Agent 框架怎么选:从一次制度查询拆出技术边界
后端·面试
福兮说3 小时前
canvas 旋转图片的六个坑:四角被裁、Math.ceil 多出 1px 黑边、JPG 角发黑、翻转方向反了
前端·javascript·图像处理·canvas
传人once5 小时前
多图片上传预览功能如何写
前端·css·html·jquery·html5
Eric.465 小时前
Wan‑Animate+MiniMax-H3 本地部署 AI 漫剧流水线:8G 显存动作迁移与动态分镜工程实战
前端·人工智能·comfyui·ai漫剧
明月_清风5 小时前
SaaS 的三层挣钱逻辑,正在被 AI 从第一层击穿
人工智能·后端
库拉镜像AI牛牛6 小时前
短剧内容自动化生产:知漫剧工作室落地教程
大数据·服务器·前端·人工智能·语音识别
谁在黄金彼岸6 小时前
nginx服务化五套方案原理解剖与选型
后端
律宏阔7 小时前
Flutter 列表图片内存优化:CachedNetworkImage 与 ClipRRect 的取舍
前端·flutter