golang 的 defer 真是个好设计

212 天前
 afxcn

我们开发的时候使用了 sync.Pool ,所以需要考虑资源释放问题。

例如下面这样一段代码:

// bearerAuth is a function that performs bearer token authentication and authorization based on the provided access token and values.
func bearerAuth(c *web.Ctx, vals ...int64) error {

	accessToken := c.BearerToken()

	if accessToken == "" {
		return web.ErrUnauthorized
	}

	cat, err := proxy.GetAuthByAccessToken(accessToken)

	if err != nil {
		return err
	}

	c.Init(cat.UserID, cat.UserRight)

	if !utils.CheckVals(cat.UserRight, vals...) {
		cat.Release()
		return web.ErrForbidden
	}

	cat.Release()

	return nil
}

我们得在所有退出路径上调用 cat.Release(),有了 defer ,我们只需要这样就解决问题了

func bearerAuth(c *web.Ctx, vals ...int64) error {

	accessToken := c.BearerToken()

	if accessToken == "" {
		return web.ErrUnauthorized
	}

	cat, err := proxy.GetAuthByAccessToken(accessToken)

	if err != nil {
		return err
	}

	defer cat.Release()

	c.Init(cat.UserID, cat.UserRight)

	if !utils.CheckVals(cat.UserRight, vals...) {
		return web.ErrForbidden
	}

	return nil
}

如果是自己建对象,就更方便了:

user := model.CreateUser() defer user.Release()

10533 次点击
所在节点    Go 编程语言
81 条回复
Campanula
211 天前
只能说实用,但感觉谈不上设计得好。
我觉得 python 的 with 在形式上更容易理解。
Huelse
211 天前
感觉就是 try-catch-finally 的语法糖
DOLLOR
211 天前
@Ghrhrrv146
其实就是 try finally 的语法糖。
不过我感觉这种写法相比 try finally ,除了不用套一层作用域外,还有一些好处。
比如资源的申请和释放可以挨着写在一起,不容易遗忘;
还有就是如果有多条 defer 回调,这些回调的实际执行顺序跟书写顺序是相反的,类似后进先出的栈模型,也符合多数情况下的资源释放的逻辑。
aladdinding
211 天前
python 的 with 跟灵活一些
tsanie
211 天前
既然 using{}有 using;的糖,那么是不是也可以有个 finally 的糖,隐藏的 try 就从当前 scope 起始位置就好。/doge

```c#
{
var encoder = GetVideoEncoder();
finally encoder.Release();
var surface = encoder.InputSurface;
finally surface.Release();
...
...
}
```

编译后实际展开成

```c#
{
try {
var encoder = GetVideoEncoder();
try {
var surface = encoder.InputSurface;
...
...
} finally {
surface.Release();
}
} finally {
encoder.Release();
}
}
```
thevita
211 天前
defer 可用,但要说好用嘛。。明显就是山猪没吃过细糠
lvlongxiang199
211 天前
@w568w 1 这点没得洗. 但 2 这点, 带 gc 的语言一般都没有用 raii 这套的 (对象销毁时机不确定), try 这一套不太好表达把 ownership move 到其他函数这一点. defer 这玩意只能说有优点也有缺点
thevita
211 天前
因为 defer 对于你这种场景其实不是一个完整的解决方案(相较于上面大家说的其他语言里提供的方案而言),他其实核心思想就一个:让初始化,和 释放 这种 需要成对的操作 放在一起,更好维护,减少人为错误的发生,仅就 “释放” 这种来说,并没有特别好用,但好处就是灵活
pkoukk
211 天前
@hez2010 #17 你非要拿筷子喝汤,还嫌筷子不好用,这实在很难评
fgwmlhdkkkw
211 天前
@aababc 支持!
asuraa
211 天前
@tsanie 没错 其实就相当于 finally
Ghrhrrv146
211 天前
@DOLLOR 有道理
xz410236056
211 天前
@kxct 析构函数是对应类的。defer 是函数内部的
xz410236056
211 天前
@CodingIran 结果你的团队全都用 OC ,你气不气
kuanat
211 天前
回复比较长,简单分了几节方便阅读。

A.
虽然我很对于 Go 设计层面的评价是比较高的,但讨论这种话题还是先表明立场比较好……

1. 语言的单一特性不适合拿出来单独对比,每种语言都是在各种特性中做取舍,单一特性有可能是被偏爱的那个,也有可能是被牺牲的那个,但都是服务于语言整体的。

2. 语言提供的功能特性有适用范围的,有不代表一定要用,好不好用看场景。千万不要手里拿着锤子,看什么都是钉子。

3. 不要用一种语言的思维去套另一种语言的设计。也就是常说的 XXX 味道的 YYY 代码。


B.
回到 defer 设计的问题上,要知道 Go 在关键词上是异常吝啬的,那为什么要专门设计 defer 这样一个看似作用不大的语句?假如没有 defer 语句,对现在的 Go 影响大吗?

一旦你意识到这个问题,越往下深挖就越能理解“妥协”在语言设计里的意义。以下的部分都是我根据记忆总结的,大多数来源是官方文档和一些开发者的个人 blog ,然后形成了我自己的理解,所以不一定正确,大家谨慎参考。


我不止一次在站里各种 Golang 相关的帖子里提到过,Go 语言设计核心的要素是合作,换句话就是妥协。有些事情程序员做很麻烦,那就让语言或者编译器多工作一点,有些事情编译器很难优化,那就让程序员多担待一点。

合作体现最明显的地方是 goroutine 。过去 Linux 在非常长的时间 IPC 信号机制尽管有类似的尝试,但缺乏合作意识导致真正其作用的只有接收方无感知的 SIGKILL/SIGSTOP ,而 SIGTERM 之类的 graceful 操作应用很少。而 Golang channal 机制是内存模型层面唯一保证线程安全的手段,无形中迫使开发者尽可能写出合作式的多线程调度代码。

同时 Golang 设计目标中优先级很高的一点是概念和语法层面的简洁。但是一般来说,写起来越简单,编译器就越复杂。核心设计者在早期就一边写编译器,然后他们发现,try...catch 会使得编译器产生大量难以优化的 goto ,而 RAII 的方式又会使得 runtime 变得复杂。

所以最终的妥协是:支持精简的 runtime ,放弃 RAII 的析构;使用基于错误返回的异常处理,放弃 try...catch 语法。同时还包括使用 GC 降低开发者心智负担,而不是 Rust 那样为了安全增加大量程序员的工作。这里还是需要强调一遍,这些设计抉择是为语言整体服务的,不能单独拿出来比较好坏。


这样做的结果是:

- Golang 中对于 OO 的支持是基于接口的 duck typing 模式,并没有 RAII 支持。
- Golang 没有复杂的控制流结构,只能通过序列式返回错误。程序员被迫要写大量 if err!= nil {}。

相应的好处也显而易见,Go 在很多时候有着媲美无 GC 语言的运行效率,以及非常高的开发效率。

其实在 Go 设计者眼里,语言表达能力弱是可以接受的妥协,而且对于程序员来说付出的代价能够接受。反过来为了开发者习惯做妥协,牺牲编译器效率和 runtime 简洁性就是无法承受之痛了。


C.
在聊 defer 之前,还有点铺垫要做。不清楚开发者们有没有意识到,官方文档对于 defer 讲解的文章标题是 Defer, Panic, and Recover 。

Go Proverbs 里专门有一条 Don't Panic 。意思是不要用 try..catch 的思路来同时处理异常和错误,因为 Go 没有 RAII 。具有 C++/Java 背景的开发者会非常习惯用相同的控制流去一并理异常和错误,而且处理的位置是调用栈的最外层,但 Golang 要求开发者转变思路了。

于是官方文档里 Golang 要求开发者重新审视:什么是错误( error ),什么是 Panic ?一般意义上,可以恢复(即能够继续运行)的异常叫错误,而无法恢复的异常叫 Panic 。

为了弥补缺少了 try...catch 结构导致的表达能力弱的问题,即区分错误类型的问题,Golang 顺利成章地借鉴了多返回值的设计思路。但是依旧不那么便利,毕竟 try...catch 结构里的错误类型是可以在其他地方定义然后使用的。于是 Go 更进一步,将 error 的类型定义为接口。尽管不是那么完美,至少能用了不是。

这里我想再重复一遍,当了解清楚 Golang 设计思路之后,你认为还能单独拿 if err != nil {} 来评价 Golang 吗?


D.
回到上个问题 Defer/Panic/Recover 放在一起是因为这三者的作用域都是当前 function ,这是和传统 RAII 控制流不同的。

官方怕被误用,给出了相关的实现逻辑:Panic/Recover 都是内置函数,defer 是基于栈的 LIFO 队列。所以需要使用者注意循环嵌套的问题。

本质上,defer 作为语法糖的用途只是个副作用,它的设计用途其实是为 Recover 服务的。Recover 只有在 defer 调用时才有意义。我这里引用一下原文:Recover is a built-in function that regains control of a panicking goroutine. Recover is only useful inside deferred functions. During normal execution, a call to recover will return nil and have no other effect. If the current goroutine is panicking, a call to recover will capture the value given to panic and resume normal execution.

说得再简洁一些,Panic 是个隐式 goto ,通过 defer recover 可以利用 defer 的栈结构简单回到之前的执行位置。

这也是官方说 Don't Panic 的原因,没有必要就不需要用。从哲学层面区分错误和 Panic 可以简化逻辑模型。


E.
最后回到更加原始的问题上:编程语言设计大 runtime 和 GC 的目的是什么?还不是因为手动内存、对象管理对开发者造成的心智负担太重了。那让开发者养成“谁污染谁治理”的习惯不好吗?这可太难了,随着控制结构变得复杂,创建和释放资源的位置可能隔着十万八千里呢……

所以回头看 defer 作为语法糖的副作用,栈实现和作用域限制了它的应用范围,了解清楚这一点就更容易明白,defer 不是银弹,该手动的时候还是要手动。

就以楼上的例子:循环里加锁、defer 解锁就是非常不合适的用例。不仅仅是因为 defer 的作用域在锁上会扩大 critical section ,更重要的是违反了创建者负责释放的原则。

我这里有个非常简单的原则:在 function 作用域内即可完成的资源创建和释放,不用 defer ;创建资源之后需要给其他 function 调用,在创建之后 defer 释放。


希望我这一大长篇能够说清楚这个问题。考虑到以上都是我个人的理解,难免会有误解或者错误,欢迎各位斧正。
kuanat
211 天前
最后补充一下我理解的 Go 为什么不选择 RAII 路线的理由,runtime 原因之外的理由。

Go Proverbs 里面有一句 Clearer is better than clever ,说得更直白一些就是 explicit over implicit 即显式优于隐式。

Go 的设计目标“合作”还有工程上的意义,鼓励程序员之间以“合作”的思维来写代码。这一点我在某个讨论包管理的话题里简单提到过一次。

不夸张地说,Go 是我接触过的所有编程语言里,读代码最容易的,没有之一。不管这个 Go 代码是 Java 味道或是什么别的。

原因我猜测是在于 Go 没有隐式的调用。但是基于 RAII 的实现就会存在问题,构造和解析会触发预期之外的代码执行,作为代码或者项目的使用者就要主动去注意相关的问题。

于是 Defer/Panic/Recover 这一类影响控制流的功能都在语言层面上做了限制,这类隐式 goto 的行为也是可预期的,代码也通常不会间隔太远。基于同样的理由,Go 不推荐在无必要的时候在包层面上使用 Init 方法。

与之相关的是,Go 对于零值的执着。一方面它是作为弥补没有构造机制导致表达力缺失的手段,另一方面它是让开发者重新思考构造、默认值和 sane defaults 这些问题。

尽管有人会吐槽 Go 的 pkg.dev.go 被低质量的包污染(也许说得是好名字被提前占了?),但可能没意识到 Go 的简洁带来的好处,一个包的质量好不好,看下代码几分钟就能弄清楚。

PS

说到底,机器是没有办法完美 GC 的,如果人人都能写出生命周期管理完美的代码,也用不到机器了。在这个意义上,defer 提供了非常简单的机制辅助开发者,我认为是语言设计层面上非常大的思路进步,这个机制只有足够简单才能让人有欲望去用。
DefoliationM
211 天前
不如 rust 和 kotlin 的作用域结束之后自动执行。
kuanat
211 天前
我看到楼上有人提多返回值的类型问题,这里也多解释一下。当然我觉得有必要再重复一次:

- 不要拿 Java/C++ 的思路去套 Go 的实现
- 不要单独讨论一个特性点的好坏

异常处理并不是只有 try...catch 这一种范式。Go 之所以设计成这个样子就是为了干掉 try...catch 。Go 这样做单独和其他语言放到一起比显得很笨,但却是非常符合 Go 需求的方式。


现实世界里“逐级汇报”和“越级处理”都是普遍存在的。习惯了随意抛出,集中处理,要是异常的类型不确定,肯定会让人抓狂,而且还会觉得,有事说事,明明没问题还汇报个屁。这样想下来,肯定觉得 Go 哪哪都是毛病。

现在换一种眼光,如果用逐级汇报的想法。Go 就明说了,我的机制比较死板,只能逐级汇报,而且模式都是没问题就过,有问题就汇报问题。这样能处理的原地就处理了,不能处理的考虑继续交给上级,直到有人能处理为止。

对 Go 来说还有个问题,要是不止一级出问题怎么办?总不能无限追加返回值的数量吧。为了解决这个问题,Go 把 error 定义成了接口:type error interface { Error() string }。

这里有两层用意,一是中间层级可以追加或修改错误内容,二是指明了 Errors are values 。第一点映射到现实就是,如果中间层级也出错,那就追加进去,甚至可以做请示备注。(这里不抬杠,C++/Java 也可以逐级处理)


Go 真正创新的地方在与第二点:错误是个值,而不是个类型。一旦明白错误是个值,你就会发现异常处理不再强依赖 goto 类控制流程。同时错误状态和错误本身也完成了解耦,控制逻辑只需要判断有没有错误,至于错误,是否处理、由谁处理甚至什么时候处理都有完全的自由。

所有纠结 Go 返回值错误类型的,有没有思考过,凭什么错误一定要是一个类型,这种先入为主的概念是从哪里来的?这就是我一直反复强调的,千万不要手里拿把锤子,就看什么都是钉子。
RedisMasterNode
211 天前
@imherer @vimiix defer 并不是所有情况都能保证执行的
arloor
211 天前
还是 rust 好,drop trait 让我从不担心资源没有释放。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1036972

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX