golang 的 defer 真是个好设计

2024-04-30 10:24:24 +08:00
 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()

12419 次点击
所在节点    Go 编程语言
81 条回复
hez2010
2024-04-30 12:48:20 +08:00
@CEBBCAT spec 里这么写了那也就是说设计如此,尽管这样也一样会导致开发者对于资源释放场景不得不写出多余的代码来让代码正确工作。

假设现在设计个锁,用 defer 来释放锁:

for ...
AcquireLock()
defer ExitLock()
// do something
result = ...

本应简简单单就搞定的东西,你也不得不写成:

for ...
result := func() {
AcquireLock()
defer ExitLock()
// do something
return ...
}()
// use result

于是但凡要用 defer 的时候都要想一下 scope 对不对,做 go 的代码生成的时候还得防御性的把但凡需要 defer 的地方都塞到一个匿名函数里来用,然后通过匿名函数的返回值来向外传递那一小块儿代码的执行结果(参考 https://ex.noerr.eu.org/t/1036033 的 tryErr 部分的 codegen )。

上面这还只是个简单的例子,如果你在 AcquireLock 和 ExitLock 之间用到了大量来自当前 block 之外的变量的话,在匿名函数里使用那些变量还会导致编译器需要捕获变量到闭包从而发生大量的拷贝。“只用两行”只是写代码时最简单情况的假象,而编译器要为了这两行做大量的工作包括不限于插入更多的控制流、捕获变量到闭包等等,这又是一个隐藏的性能陷阱。

这种情况只能说,要么是 defer 被我滥用了所以 defer 不适合用来做通用的资源释放;要么这是语言设计有问题留给用户来做 workaround 。
CodingIran
2024-04-30 12:53:17 +08:00
iOS 开发赞同:defer 是真的很好用
w568w
2024-04-30 12:54:17 +08:00
Go 的很多设计总是给我一种「能用就行」「就差一点」的感觉(不是攻击 Go 开发者,我知道人都是很牛的大佬)。以下是我最不满意的几个点:

1. 创新性地使用了「错误亦返回值」的概念,很先进吧?但错误和返回值的关系应该表示成一种和类型( Sum Type ),即「 A 或 B 」,Go 偏偏设计成了积类型( Prod Type ),即「 A 和 B 」。导致很多时候不得不写出 return "", err 这样的愚蠢代码。Rust 、Haskell 等都没有这个问题;
2. 引入了做 cleanup 的 defer ,相比 RAII 其实不优不劣。但 Go 偏偏限制 defer 只能作用于「函数作用域」,导致经常要写「 for 循环里套匿名函数」的丑陋操作;
3. 直接干掉了 Enum ,把命名空间展平。后果是所有 Enum 的名称都变得非常长;
4. 仓库即包。想法很美好,但偏偏没有一个人工审查的包仓库:每次要找某种功能的包,都要在 pkg.go.dev 无数个 0 star 的、作者本意根本不是通用库的业务逻辑和模块包里翻;
5. 结构体允许不完全初始化,又不允许赋默认值。标准库里有些之前没写 New 方法的结构体,为了保证兼容,都只能在每个结构体的函数前面插一句 ensureInited() 之类的调用来补全默认字段。
minami
2024-04-30 13:22:26 +08:00
@hez2010 #20 从这个例子看,defer 完全不如 C++的 RAII 啊
ipwx
2024-04-30 13:25:46 +08:00
没用过 Go 。本来看了楼主的描述,觉得 defer 不差,似乎是一个 C++ 的析构函数、Python with 、其他语言的 try ... finally 的东西。

然后看到只能绑定当前函数作用域。

md Go 果然智障。。
dacapoday
2024-04-30 13:39:04 +08:00
@w568w
这不是创新,C 就是这么做,go 只是将 C 的 return code 升级为可携带任意信息的 error 接口,本质是不要把错误特殊化,不要游离于函数调用栈之外 (相应的反例设计方案是 try catch exception 异常,就是要特殊化,就是要穿透调用栈,不认为错误是正常逻辑的一部分)

go 的设计很古典,文法是贴合 计算机数据结构 而非 自然语言和数学表达式,强调结构简洁,功能正交。对使用者的抽象能力要求较高。
lvlongxiang199
2024-04-30 13:41:58 +08:00
@kxct 似乎只有不带 gc 的语言才有析构函数, 带 gc 的语言里头, gc 的时机不确定, 导致析构函数调用时机不确定. 没 gc 的话, 又一堆操心事
AV1
2024-04-30 13:48:33 +08:00
JS 的 Explicit Resource Management 有类似的实现,应该就是学 C# 来的,已经进入 Stage 3 了。

{
using stack = new DisposableStack();
console.log('start');
stack.defer(() => console.log('defer'));
console.log('end');
}

以上运行结果 start 、end 、defer 。

如果发生异常,defer 也会执行
{
using stack = new DisposableStack();
console.log('start');
stack.defer(() => console.log('defer'));
console.log('next');
throw new Error('error'); // 异常
console.log('end');
}
以上运行结果 start 、next 、defer 。
hez2010
2024-04-30 13:50:42 +08:00
@lvlongxiang199 C# 和 Java 两个带 GC 的语言都有析构函数。
尽管析构函数的调用时机不确定,但也是对于释放不属于 GC 的资源的一种保底机制。如果开发者忘记调用了例如 socket class 的 close 函数,那也可以由析构函数代替开发者调用来做到保底防止资源泄露。而 close 函数里可以顺便调用 GC 的 finalizer suppressing API 表示当前 class 不需要再执行析构函数,于是如果开发者自己已经调用了 close 了,则析构函数就不会被执行。
xjpicism
2024-04-30 13:51:56 +08:00
@w568w
1. 错误和返回值不是互斥的关系,有错误也有可能有返回值,比如处理数组到一半报错了,会有一半的结果
举个具体的例子 exec.LookPath 在返回当前目录下结果的时候会同时返回匹配结果和 exec.ErrDot ,不在乎这个错误的调用者可以忽略这个错误直接用结果
2. 设计偏好问题,可能官方觉得需要不在循环结束而是函数结束时清理的场景足够多
3. go 哪来的 enum
4. 不是通用库按规范应该放在 internal 文件夹下 这样别人就导入不了,而且哪个语言的包有人工审查 不都是自己发布吗
5. 设计偏好问题,延后初始化更省资源
zhyl
2024-04-30 13:59:11 +08:00
Zig 中的 defer 更好用,还有 errdefer
w568w
2024-04-30 14:03:04 +08:00
@xjpicism

1. 这个是我听过 Go 开发者很多次的辩解。第一,这样的用例实在少,你在标准库以外恐怕都找不出几个;第二,即便有这样的情况,从类型论角度来说,也应该是 string 和 (string, error) 的和类型,而非 (string, error) 这单独一个积类型,从抽象上来说依然是缺陷的;
2. 既然 Go 的开发者会关注「同时有错误和返回值」的小众需求,怎么「作用域结束清理」这种非小众需求、甚至 GCC Extension 和 Clang BlockExtension 同时都实现的需求又不管了呢?
3. 我的意思正是 Go 错误地删掉了 Enum ;
4. Go 没有官方的、强制性的包结构规范,因此很多开发者会这么做,甚至你去 CSDN 之类的国内网站全是教你这么做,导致产生了大量低质量、不规范的包结构,pkg.go.dev 已经被污染得不能看了。至于人工审查,你能告诉我为什么 Pypi 、Crates 、Dart Pub 、Conan 、vcpkg 上都没有我提到的这种情况吗?
5. 省的那点资源已经被每次函数调用都检查一遍是否初始化给抵消了吧。
nuk
2024-04-30 14:03:24 +08:00
defer 好用,有 scope 更好,但是没有也影响不是很大,毕用到的场景真的不算多。
fanhed
2024-04-30 14:10:24 +08:00
@nuk scope 就是 function, 你需要多层 scope 的生活, 就套上 function 就行了
Zzhiter
2024-04-30 14:20:28 +08:00
感觉还是没有学到最佳实践
xjpicism
2024-04-30 14:33:18 +08:00
@w568w
1. 我用的很多,后台需要定期获取从多个外部来源的数据数据,返回能正常获取的数据,然后把所有错误合并返回
2.作用域结束清理可以用匿名函数 为什么会是不管
3. 这个是缺,是用自己实现的 enum 库
4. internal 就是[官方规范]( https://go.dev/doc/go1.4#internalpackages)啊 你引用外部叫 internal 的包编译器会直接报错的
pkg.do.dev 本来就更侧重于文档平台而不是包索引 只要带可访问网址访问就会自动生成一份文档 登录都不用 找包怎么不直接到 github 找
5. 100%需要初始化且不需要兼容老版本的场景就用 New* 呗
lvlongxiang199
2024-04-30 14:35:33 +08:00
@hez2010 这玩意真没法兜底. 可能会出现没触发 gc 资源就因为没有及时释放就耗尽的情况
cpp/rust 里头的 RAII 能保证这个 obj 离开作用域就销毁
Ghrhrrv146
2024-04-30 14:37:11 +08:00
@DOLLOR js 中,try...finally 应该就能做到吧,还是考虑到 try...finally 有自己的作用域的问题?
lvlongxiang199
2024-04-30 14:40:40 +08:00
@hez2010 另外 java9 中已经废弃 finalize 了
xiangxiangxiang
2024-04-30 14:48:14 +08:00
@w568w 枚举这个确实不知道为啥要这么设计,可能我还没从 java boy 的写代码方式习惯过来。。。

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

https://ex.noerr.eu.org/t/1036972

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

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

© 2021 V2EX