前端开发转 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 会返回当前的 resulterr

例如:

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,
  };
}

这里的 resulterror 是返回对象的属性。

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);

defersetTimeout 完全不是一回事。

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 而故意设计复杂的返回逻辑。


returndefer 的执行过程

经过前面的实验,我现在会把包含 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)
命名返回值零值 int0string""boolfalse
裸返回 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 小时前
只学一点点:我的技术学习策略
前端·javascript·react.js
大鹏说大话1 小时前
HTML5 地理定位 Geolocation:获取用户位置的“红线”与最佳实践
前端·html·html5
Csvn2 小时前
📊 SQL 入门 Day 14:聚合窗口函数 — 让 SUM / AVG 也能"滑动"起来
后端·sql
Oneslide2 小时前
K8s NodePort 端口为什么 netstat 查不到?
后端
Csvn2 小时前
content-visibility: auto —— 让浏览器跳过离屏渲染的性能黑科技
前端
小鹰信息技术服务部2 小时前
Edge安装包MicrosoftEdgeSetup.exe无法运行,点击没反应
前端·edge
北冥you鱼2 小时前
Go语言大数(big.Int)比较大小:原理、方法与最佳实践
开发语言·后端·golang
kkkAloha2 小时前
非对称加解密理解
后端
xcLeigh2 小时前
Go入门:零值与变量默认初始化机制
开发语言·后端·golang