V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
The Go Programming Language
http://golang.org/
Go Playground
Go Projects
Revel Web Framework
afxcn
V2EX  ›  Go 编程语言

golang 的 defer 真是个好设计

  •  
  •   afxcn ·
    afxcn · 15 天前 · 7977 次点击

    我们开发的时候使用了 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()

    第 1 条附言  ·  13 天前

    感谢大家的讨论,让我学到了不少新知识。

    喜欢defer,主要是它让问题变简单,例如下面这段代码:

    // Create create user
    func (r *UserController) Create(c *web.Ctx) (any, error) {
    
    	user := model.CreateUser()
    	defer user.Release()
    
    	if err := c.TryParseBody(user); err != nil {
    		return nil, err
    	}
    
    	if err := validator.CreateUser(user); err != nil {
    		return nil, err
    	}
    
    	if _, err := proxy.CreateUser(user); err != nil {
    		return nil, err
    	}
    
    	return user.ID, nil
    }
    

    一行 defer user.Release() 解决了问题。

    如果我们在一个控制类方法里要操作多个对象的时候,defer带来的帮助更明显。例如用户登入,我们需要需要操作的有用户,令牌,邮箱等,处理好每个资源的释放,会是个比较费脑的事情,可能得画个流程图。

    我用的语言主要有C#, PHP, GO, C等, 没有接触过更多先进的语言,受限于认知,让大家见笑了。

    81 条回复    2024-05-07 10:05:06 +08:00
    kxct
        1
    kxct  
       15 天前
    其他语言的析构函数了解下
    guoziyan
        2
    guoziyan  
       15 天前   ❤️ 2
    RAII 了解下
    aababc
        3
    aababc  
       15 天前   ❤️ 1
    感觉 python 的 with 更方便,也更容易理解
    idealhs
        4
    idealhs  
       15 天前
    using
    ResidualSoils
        5
    ResidualSoils  
       15 天前
    C#什么时候能有这东西,using 太怪异了
    fenfire
        6
    fenfire  
       15 天前
    想问下大佬,如果我用 defer 去关闭 redis 的连接,但是中间出异常了会不会导致到不了 defer 那一步,连接没关闭?
    Chad0000
        7
    Chad0000  
       15 天前
    @ResidualSoils #5
    其实不加花括号的 Using 也算得上跟它差不多的写法
    imherer
        8
    imherer  
       15 天前   ❤️ 1
    @fenfire 直接在初始化链接成功后面紧接 defer 关闭就好了
    masterclock
        9
    masterclock  
       15 天前   ❤️ 1
    defer 简直就是傻子吧
    忽略过去所有的成果,硬是要来点 exotic 玩意儿,有提升没问题,问题是没提升啊
    tsanie
        10
    tsanie  
       15 天前
    @ResidualSoils #5
    defer 其实就相当于 try{}finally{},using 其实也是 try{}finally{}的糖
    vimiix
        11
    vimiix  
       15 天前   ❤️ 2
    @fenfire 不会,即使后续逻辑 panic ,defer 也会被调用到
    inhzus
        12
    inhzus  
       15 天前
    有些时候还是好用的。于是 C++ 有了 scope_exit ,C 23 也引入了 defer 关键字
    weeei
        13
    weeei  
       15 天前
    @inhzus C++ 的 scope_exit 还在 <experimental/scope>,进展太慢了。
    seth19960929
        14
    seth19960929  
       15 天前
    @kxct #1 两个概念不一样
    dnfQzjPBXtWmML
        15
    dnfQzjPBXtWmML  
       15 天前 via Android
    当初写 c 的时候总想着怎么搞个 c 版的 defer
    Ericcccccccc
        16
    Ericcccccccc  
       15 天前
    看了下第二段代码,藏一个 defer 不是好设计

    java 那种 try release 的写法我感觉挺好
    hez2010
        17
    hez2010  
       15 天前   ❤️ 1
    那你是没有遇到 go 的 defer 在作用域上的坑,比如这段代码:
    package main

    import "fmt"

    func main() {
    for i := 0; i < 5; i++ {
    defer foo()
    fmt.Println(i)
    }
    }

    func foo() {
    fmt.Println("foo")
    }
    会输出
    0 1 2 3 4 foo foo foo foo foo ,而不是 0 foo 1 foo 2 foo 3 foo 4 foo

    而别的语言的无论是 RAII 还是 try-finally 还是 using ,作用域都是显然的,会在当前 scope 结束的时候被调用。
    这也就导致你没法直接用 go 的 defer 来做退出锁之类的操作,因为全都被 go 给你放到函数最后去了。
    Guaderxx
        18
    Guaderxx  
       15 天前   ❤️ 10
    @hez2010 想说点啥也不知道从哪说起,祝您今天开心
    CEBBCAT
        19
    CEBBCAT  
       15 天前
    @hez2010 #16

    https://go.dev/ref/spec#Defer_statements 提到:
    > A "defer" statement invokes a function whose execution is deferred to the moment the surrounding function returns, either...

    defer 是针对当前函数的,for 中的 defer 会在 for 所在的函数结束后逆序逐个执行,如果需要,可以使用匿名函数将目标 scope 明确包裹,只用两行:
    https://go.dev/play/p/7YwquKzmFDd

    我觉得这个不能完全算是“坑”吧
    iOCZS
        20
    iOCZS  
       15 天前   ❤️ 1
    Swift 、Python:嘿黑,我们也有
    hez2010
        21
    hez2010  
       15 天前   ❤️ 2
    @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://v2ex.com/t/1036033 的 tryErr 部分的 codegen )。

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

    这种情况只能说,要么是 defer 被我滥用了所以 defer 不适合用来做通用的资源释放;要么这是语言设计有问题留给用户来做 workaround 。
    CodingIran
        22
    CodingIran  
       15 天前
    iOS 开发赞同:defer 是真的很好用
    w568w
        23
    w568w  
       15 天前   ❤️ 9
    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
        24
    minami  
       15 天前
    @hez2010 #20 从这个例子看,defer 完全不如 C++的 RAII 啊
    ipwx
        25
    ipwx  
       15 天前   ❤️ 1
    没用过 Go 。本来看了楼主的描述,觉得 defer 不差,似乎是一个 C++ 的析构函数、Python with 、其他语言的 try ... finally 的东西。

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

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

    go 的设计很古典,文法是贴合 计算机数据结构 而非 自然语言和数学表达式,强调结构简洁,功能正交。对使用者的抽象能力要求较高。
    lvlongxiang199
        27
    lvlongxiang199  
       15 天前
    @kxct 似乎只有不带 gc 的语言才有析构函数, 带 gc 的语言里头, gc 的时机不确定, 导致析构函数调用时机不确定. 没 gc 的话, 又一堆操心事
    DOLLOR
        28
    DOLLOR  
       15 天前   ❤️ 1
    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
        29
    hez2010  
       15 天前
    @lvlongxiang199 C# 和 Java 两个带 GC 的语言都有析构函数。
    尽管析构函数的调用时机不确定,但也是对于释放不属于 GC 的资源的一种保底机制。如果开发者忘记调用了例如 socket class 的 close 函数,那也可以由析构函数代替开发者调用来做到保底防止资源泄露。而 close 函数里可以顺便调用 GC 的 finalizer suppressing API 表示当前 class 不需要再执行析构函数,于是如果开发者自己已经调用了 close 了,则析构函数就不会被执行。
    xjpicism
        30
    xjpicism  
       15 天前
    @w568w
    1. 错误和返回值不是互斥的关系,有错误也有可能有返回值,比如处理数组到一半报错了,会有一半的结果
    举个具体的例子 exec.LookPath 在返回当前目录下结果的时候会同时返回匹配结果和 exec.ErrDot ,不在乎这个错误的调用者可以忽略这个错误直接用结果
    2. 设计偏好问题,可能官方觉得需要不在循环结束而是函数结束时清理的场景足够多
    3. go 哪来的 enum
    4. 不是通用库按规范应该放在 internal 文件夹下 这样别人就导入不了,而且哪个语言的包有人工审查 不都是自己发布吗
    5. 设计偏好问题,延后初始化更省资源
    zhyl
        31
    zhyl  
       15 天前   ❤️ 2
    Zig 中的 defer 更好用,还有 errdefer
    w568w
        32
    w568w  
       15 天前   ❤️ 6
    @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
        33
    nuk  
       15 天前
    defer 好用,有 scope 更好,但是没有也影响不是很大,毕用到的场景真的不算多。
    fanhed
        34
    fanhed  
       15 天前
    @nuk scope 就是 function, 你需要多层 scope 的生活, 就套上 function 就行了
    Zzhiter
        35
    Zzhiter  
       15 天前
    感觉还是没有学到最佳实践
    xjpicism
        36
    xjpicism  
       15 天前
    @w568w
    1. 我用的很多,后台需要定期获取从多个外部来源的数据数据,返回能正常获取的数据,然后把所有错误合并返回
    2.作用域结束清理可以用匿名函数 为什么会是不管
    3. 这个是缺,是用自己实现的 enum 库
    4. internal 就是[官方规范]( https://go.dev/doc/go1.4#internalpackages)啊 你引用外部叫 internal 的包编译器会直接报错的
    pkg.do.dev 本来就更侧重于文档平台而不是包索引 只要带可访问网址访问就会自动生成一份文档 登录都不用 找包怎么不直接到 github 找
    5. 100%需要初始化且不需要兼容老版本的场景就用 New* 呗
    lvlongxiang199
        37
    lvlongxiang199  
       15 天前
    @hez2010 这玩意真没法兜底. 可能会出现没触发 gc 资源就因为没有及时释放就耗尽的情况
    cpp/rust 里头的 RAII 能保证这个 obj 离开作用域就销毁
    Ghrhrrv146
        38
    Ghrhrrv146  
       15 天前
    @DOLLOR js 中,try...finally 应该就能做到吧,还是考虑到 try...finally 有自己的作用域的问题?
    lvlongxiang199
        39
    lvlongxiang199  
       15 天前
    @hez2010 另外 java9 中已经废弃 finalize 了
    xiangxiangxiang
        40
    xiangxiangxiang  
       15 天前
    @w568w 枚举这个确实不知道为啥要这么设计,可能我还没从 java boy 的写代码方式习惯过来。。。
    Campanula
        41
    Campanula  
       15 天前
    只能说实用,但感觉谈不上设计得好。
    我觉得 python 的 with 在形式上更容易理解。
    Huelse
        42
    Huelse  
       15 天前
    感觉就是 try-catch-finally 的语法糖
    DOLLOR
        43
    DOLLOR  
       15 天前
    @Ghrhrrv146
    其实就是 try finally 的语法糖。
    不过我感觉这种写法相比 try finally ,除了不用套一层作用域外,还有一些好处。
    比如资源的申请和释放可以挨着写在一起,不容易遗忘;
    还有就是如果有多条 defer 回调,这些回调的实际执行顺序跟书写顺序是相反的,类似后进先出的栈模型,也符合多数情况下的资源释放的逻辑。
    aladdinding
        44
    aladdinding  
       15 天前
    python 的 with 跟灵活一些
    tsanie
        45
    tsanie  
       15 天前
    既然 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
        46
    thevita  
       15 天前   ❤️ 2
    defer 可用,但要说好用嘛。。明显就是山猪没吃过细糠
    lvlongxiang199
        47
    lvlongxiang199  
       15 天前
    @w568w 1 这点没得洗. 但 2 这点, 带 gc 的语言一般都没有用 raii 这套的 (对象销毁时机不确定), try 这一套不太好表达把 ownership move 到其他函数这一点. defer 这玩意只能说有优点也有缺点
    thevita
        48
    thevita  
       15 天前
    因为 defer 对于你这种场景其实不是一个完整的解决方案(相较于上面大家说的其他语言里提供的方案而言),他其实核心思想就一个:让初始化,和 释放 这种 需要成对的操作 放在一起,更好维护,减少人为错误的发生,仅就 “释放” 这种来说,并没有特别好用,但好处就是灵活
    pkoukk
        49
    pkoukk  
       15 天前
    @hez2010 #17 你非要拿筷子喝汤,还嫌筷子不好用,这实在很难评
    fgwmlhdkkkw
        50
    fgwmlhdkkkw  
       15 天前
    @aababc 支持!
    asuraa
        51
    asuraa  
       15 天前
    @tsanie 没错 其实就相当于 finally
    Ghrhrrv146
        52
    Ghrhrrv146  
       15 天前
    @DOLLOR 有道理
    xz410236056
        53
    xz410236056  
       15 天前
    @kxct 析构函数是对应类的。defer 是函数内部的
    xz410236056
        54
    xz410236056  
       15 天前
    @CodingIran 结果你的团队全都用 OC ,你气不气
    kuanat
        55
    kuanat  
       15 天前   ❤️ 25
    回复比较长,简单分了几节方便阅读。

    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
        56
    kuanat  
       15 天前   ❤️ 1
    最后补充一下我理解的 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
        57
    DefoliationM  
       15 天前 via Android
    不如 rust 和 kotlin 的作用域结束之后自动执行。
    kuanat
        58
    kuanat  
       15 天前   ❤️ 5
    我看到楼上有人提多返回值的类型问题,这里也多解释一下。当然我觉得有必要再重复一次:

    - 不要拿 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
        59
    RedisMasterNode  
       15 天前
    @imherer @vimiix defer 并不是所有情况都能保证执行的
    arloor
        60
    arloor  
       15 天前 via Android
    还是 rust 好,drop trait 让我从不担心资源没有释放。
    hez2010
        61
    hez2010  
       15 天前 via Android
    @pkoukk 这不是用筷子喝汤,这是面前给你摆一碗咖喱拌饭但餐具只有吸管。
    Jirajine
        62
    Jirajine  
       15 天前
    @kuanat 其实只有一个原因,就是 simplicity 。go 是一个 dsl ,它只是为特定场景设计的,不适合其他场景很正常。你非要把 go 在这些场景的缺陷(如缺乏 sum type/null safety/checked initialization )解释称 “the go way”更好,其实是一种皈依者狂热。
    cmdOptionKana
        63
    cmdOptionKana  
       15 天前
    @Jirajine 为什么要扣帽子?你说别人是皈依者狂热,我说你是键盘侠杠精,这种互扣帽子毫无意义。
    kuanat
        64
    kuanat  
       15 天前
    @Jirajine #62

    我的观点一直都是,在合适的场景使用恰当的工具。在说 Go 好的帖子里这样说,在说 Go 不好的帖子里也这样说。就连这个帖子里,我的观点也是一致的,defer 不能滥用,而 error 处理要换思路。

    关于 sum type 我从来没谈过,因为我想不出这样做的原因,我也没有从 Go 的官方文章找到头绪。Null safety/checked initialization 都是用来佐证,语言特性是取舍这一观点的。既然选择了简洁,就要付出代价,Go 没有特别多的选择。

    既然你能准确使用这些术语,说明对这些话题有足够的认知,那完全可以阐述你对于语言设计的认识和观点,没有必要归结于个人偏好。
    ensonmj
        65
    ensonmj  
       14 天前 via iPhone
    @arloor 但是 rust 特别强调不能依赖 drop 来保证资源释放,很烦
    bukekangli
        66
    bukekangli  
       14 天前
    屎真香?
    mayli
        67
    mayli  
       14 天前
    @aababc with 多个嵌套就有点费劲
    arloor
        68
    arloor  
       14 天前 via Android
    @ensonmj 哪里说的
    Jirajine
        69
    Jirajine  
       14 天前
    @cmdOptionKana #63 为什么要扣“扣帽子”的帽子?
    我们都知道任何语言都有优缺点,但如果它的缺陷在你感觉而言也成了优势,这种现象将之描述为皈依者狂热。
    ragnaroks
        70
    ragnaroks  
       14 天前
    标题改成“defer 对于 golang 真是个好设计”就没这么多争论了
    mark2025
        72
    mark2025  
       13 天前
    zzhaolei
        73
    zzhaolei  
       12 天前
    该说不说的,评论区太多 Javaer 了,Javaer 就是改不了自己的 Java 思维,套用之前看到的一个 boss 上的评价,Javaer 的大脑已经被污染了,接受不了新的东西。
    zzhaolei
        74
    zzhaolei  
       12 天前
    https://go.dev/talks/2012/splash.article

    没事儿读一读这篇文章,看看 Go 是怎么诞生的,是为了解决什么问题。吵来吵去的也只是用自己的固有思维去思考,没有意义。
    lolizeppelin
        75
    lolizeppelin  
       12 天前
    @kuanat
    defer 你解释那么多都没啥意义....

    defer 怎么实现大家并不关心,纯粹语法上难看而已
    python 的 with 就是漂亮,defer 就是丑
    kuanat
        76
    kuanat  
       12 天前 via Android
    @lolizeppelin #75

    确实我说 Go 好不好对 Go 和其他编程语言没有什么意义,但不代表我在这里的回帖没有意义。

    很多人觉得我是狂热的吹捧者,我想还是有所误解。这里面有我表述方式的原因,也有读者立场的原因。我在之前的某个帖子里表达过,我大多数回复的目的是“创造价值”,因为我从 V2EX 学到了很多,我也想把我学到的反馈给社区。Go 的主题比较多更多是因为它有一定流行度却又不那么流行,在我日常工作里,主流的语言应用比例里 Go 反倒没有那么高。

    关于 python 的 with 语法,我有个问题,你怎么看自定义对象需要手写 __enter__ __exit__ 的?

    单独拿 with 出来,配合 python 的缩进,起到了看上去很美的效果。但是没有 RAII ,为了实现 scope ,又要回到手写析构的路上。

    因此我一直都强调,单独拿一个关键词一个特性出来比较是不合适的。
    lolizeppelin
        77
    lolizeppelin  
       12 天前
    @kuanat

    你不怎么写 python 才会有这个问题, python 有 contextlib.contextmanager

    golang 的很多问题就是 TM 的太丑了....还在那里犟,一开始还吹逼不要泛型呢,后来呢?
    有些丑的好改有些改不了了而已...大家凑活着用罢了。
    kuanat
        78
    kuanat  
       12 天前   ❤️ 2
    @lolizeppelin #77

    如果只是单纯辩论美丑,我原本没打算回复,但既然你提到了 python 用装饰器来减少代码,那我觉得还是可以讨论一下的。

    生命周期管理这个事情的困难在于:资源的分配和释放可能时机不确定,又或者在代码中的位置不同,还可能是多线程的主体不确定。原地释放几乎没有人会写错,而 scope 变得很大的时候,错写或者漏写的几率就会变大。

    考虑 with...as 加入到 python 中已经有二十年了,但是装饰器版本的 contextmanager 也就十年,异步安全的版本可能最近才实现。这里有个很现实的问题,作为使用者是更愿意手写 enter/exit 还是愿意手动管理?当 contextmanager 加入的时候,项目允许升级吗?当有异步、多线程需求的时候,开发者能写出正确的版本吗?

    所以抛开美丑的主观看法不谈,defer 这种万金油几乎是个 Go 开发者就会写,而 with...as 使用率就要大打折扣。先让人用起来显然是重要地多的事情。这里不抬杠开发者水平高低的问题,defer 是一种兜底机制,当开发者不确定我应该在什么时候释放资源的时候就可以用,而 with...as 即便是装饰器的版本显得繁琐,写好几行肯定比不上写一个单词,这是人性。



    之前的回复里我总是反复提,不要单独拿某个关键词来讨论特性,那就以 with...as 来彻底完整做个说明,证明一下为什么这样做不合适。

    C/Python/Java 由于诞生较早,都是先有的实现,然后反过来总结出来的标准。比较新的语言,都是设计先于实现,终归会从之前语言中总结经验教训,所以不仅仅是 Go ,你会发现 C#/Rust/Kotlin 这些“现代”语言往往有些共性,共享的是新的设计理念。



    这里 defer/with...as 反映的其实是对异常处理这件事的哲学思考不同。

    传统语言中,一方面异常是不区分错误和 Panic 的,另一方面异常又与控制流强耦合,try...catch 实质是隐式的 goto 指令。

    现代语言几乎都放弃了这样的思路,Panic 要立即终止,而错误是可以处理的。代码层面也尽量避免隐式跳转。这样的改变是大趋势,原因是大家都从 C 语言中汲取了足够的教训,隐式控制流是有害的( hidden control flow considered harmful ),特别是与 C 的宏机制一结合,人脑 debug 几乎变得不可能。

    反过来,显式控制流的优点是显而易见的,一方面极大提高了代码的可读性以及可靠性,所见即所得,不存在那些写在其他地方的代码被意外调用执行的情况;另一方面对于并行编程的支持变得更加容易。

    如果拿 Go 来举例可能没有说服力,我简单说下 Rust 和 Kotlin 。

    - Rust 没有 exception ,只有 recoverable/unrecoverable 错误,类型分别为 Result<T, E> 和 panic! 只有后者是宏实现的跳转

    - Kotlin 保留了 try 关键词,但 try 是表达式而非(控制流)语句,也就是说 try 可以返回(错误)值

    在减轻开发者心智负担方面,新的设计哲学可谓是殊途同归。这个进步主要体现在工程化层面,当语言不区分错误和 Panic 的时候,开发者会主动选择最无脑的方式,也就是随意抛出集中处理。现代语言中砍掉了这样的机制,反过来倒逼开发者思考异常处理的本质,从而间接提高代码的可复用性,以及程序的可靠性。



    回到 with...as 的问题上,Python 的错误处理还是传统的控制流模式。换句话说,就算语法糖写出花来,它的核心仍旧是基于 enter/exit 的对于 RAII 的模仿。由于 Python 没有 RAII ,这种模仿的结果就是一定要手动处理 scope 的问题。

    先说一个比较接近最终方案的提案版本:

    with EXPR:
    ____BLOCK

    这个版本看上去相当美好,可是没多久就被枪毙了。原因有两个,一是 with 只能是 statement 而不能是表达式(需要同时支持 with VAR = EXPR: 这样的语法,VAR = EXPR 赋值本身也是合法的 EXPR );二是 EXPR 自身有可能再次抛出异常,或者包含 break/continue 等其他控制流指令,结果会导致 scope 失效。

    第二条麻烦一下解释器也不是不可以,多维护一个基于栈的 trackback 机制。但第一条的影响是致命的:

    with VAR = EXPR:
    ____BLOCK

    想要对以上形式写装饰器,__exit__ 一定要在 VAR 上有定义,这就失去了装饰器省代码的意义。

    为了不让 VAR 获得 EXPR 的赋值,最终实现的版本用 with EXPR [as VAR]: 这样的形式。相关实现细节 PEP 都能搜到,这里就不继续展开了。

    即便是用上装饰器,代码大概是这样的:

    @contextmanager
    def open(file):
    ____f = open(file)
    ____try:
    ________yield f
    ____finally:
    ____f.close()

    写这么多只是为了实现:可以在 with scope 里,不用单独写 f.close()。

    装饰器的实现晚于 with...as 很久才进标准库,核心原因就是前面提到的隐式控制流。装饰器本身

    客观地说,Python 本来就是试错型快速迭代偏好的语言,with...as 加上迭代器的语法糖的作用非常有限。它起到的主要作用是,当你知道什么时候该写 f.close() 的时候( scope 边界清晰),不用你再写了(自定义类型还是要写装饰器)。但是解决不了辅助开发者判断,什么时候该写的问题,你无法依赖解释器帮你兜底。



    尽管在站队好或者不好的事情上,网友可能会支持你也可能会反对你,但不代表大家都是出于相同的推理逻辑。在语言设计这么关键的问题上,一半是妥协一半是取舍,单拿出来一个点做比较最终只会变成屁股之争。
    securityCoding
        79
    securityCoding  
       11 天前 via Android
    go 在鹅厂的语言份额极速上升中。。。当你维护过 c++
    Hantong
        80
    Hantong  
       11 天前
    @securityCoding 毕竟写 c++ 的心智耗费蛮大的, 特别是所谓 modern c++.

    贴个偶然间看到的 issuecomment 片段, 阐述了为什么 SRS 坚持使用 c++98, ref: https://github.com/ossrs/srs/issues/1191#issuecomment-917324341

    ```
    No, I still firmly believe that if we can use C++98, we should definitely not use C++11. The more complex language features we use, the smaller the target audience of developers becomes. Even the senior brother finds C++98 too complicated, let alone C++11. I hope all C programmers can easily modify the code of SRS.
    ```
    flyv2x
        81
    flyv2x  
       8 天前
    defer 的作用就是用来做 close release 等操作的,上面提到的作用域这些,纯粹是对这个特性的滥用。代码应该是简洁易读的,defer 放在不同作用域这种写法实在是非常的不专业!!

    保持代码的逻辑简单,易读,不要用 defer 来做业务逻辑相关的任何实现,这才是最佳实战!
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1081 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 28ms · UTC 19:20 · PVG 03:20 · LAX 12:20 · JFK 15:20
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.