关于 Go 在 `Return Nil or Pointer to Empty Struct on Error` 上的讨论?

2022-08-12 11:47:48 +08:00
 ryan961
type User struct {}


// 1. return nil
func GetUser() (*User, err) {
	....
    
    return nil, err
}

// 2. return pointer to empty struct
func GetUser() (*User, err) {
	....
    
    return &User{}, err
}

个人会担心下游的不注意( nil 判断)导致进程 panic ,所以基本都是用第二种。想听听大家的意见或见解?

5185 次点击
所在节点    Go 编程语言
82 条回复
pigmen
2022-08-13 02:09:37 +08:00
如果返回的不是 pointer struct 而是一个 struct 呢?

btw 楼主啥字体呀
YYYeung
2022-08-13 02:30:06 +08:00
可以先理一下思路:
如果函数执行正常,那就是意味着没有错误,那么 user 就应该有一个结果,而且它是可用的;
如果函数执行不正常,那就意味着有错误,那么 user 即使是有结果,那它也是一个错误的结果,是不可用的。

返回一个 Empty struct 的危险之处之处在于,当调用方忘记了检查 err ,而且 Empty Struct 从业务上也讲得通,后面的逻辑会根据这个错误的结果执行下去了,于是最后的结果也可能是错误了。
FrankHB
2022-08-13 02:34:50 +08:00
@codehz 我不满现有语言,就自己造了。但是理由是别的(光这里 C++ 其实都够用)。

对一般用户,最简单的就是去熟悉带有异常支持的语言,习惯怎么简化默认策略之后,去提议增强没这种机制的语言。
本质上,异常是一种控制作用(control effect)的具体应用。控制作用和修改对象一样同属副作用,是很基本的东西,语言缺乏这种特性多少是残的,对通用的编程语言,只要不是像 C 那么躺平(但就算 C 都有 setjmp/longjmp ),实在是逃不过的。
当然你可以说纯函数式语言就拒绝副作用,但其实真完全拒绝的就没法编程了。典型做法是副作用和控制作用都打包成 monad 隔离。但说实话这要没语法糖就不是给人用的,有也就是强迫用户用糖化 CPS 风格强制代替自由书写直接风格的程序的自由罢了,属实没事找事。
(用 monad 能组装出修改状态是因为假定 monad law 存在下的一些规则起到了能表达 delimited control 的类似的作用,后者能表达可变状态。其实典型的异常严格意义上比 delimited control 还强点,理论上一个不存在对象修改而只有异常作为副作用的语言也可以写出修改对象的实现。)

缺少 checked exception 不是问题,实际通常反而有才是( https://github.com/FrankHB/pl-docs/blob/master/zh-CN/typing-vs-typechecking.md#%E8%BF%87%E5%BA%A6%E8%AE%BE%E8%AE%A1 ),而且我很怀疑多数 Java 用户就是因为 checked exception 的原因养成过于习惯到处 IDE 加自动生成的 catch 的坏习惯,才失去了自主发现 exception neutrality 的重要性的机会。
用接口的不管怎么样都要看文档搞清楚错误条件。忘记处理异常会有问题,基本说明接口设计有大问题。而实现者不该忘记的是,在实现时确定是不是应该无视原地处理异常的决策过程,即便大部分情况就不应该处理。如果你真需要类似 checked exception 的强制 caller 处理而且不在乎污染签名,那就用 union type ,反而更容易在没异常的语言里找到变通方法(没真正的 union type 基本直接用 sum type 代替,原地 /一层 caller 处理时差别不大)。
至于 bad_alloc 其实基本就不用处理了,因为恢复不了( new_handler 已经救过了),约等于 panic ,比有的 panic 直接 abort 强一点的地方是还能自定义 terminate 最后挣扎一下。
std::ios_base::failure 默认就不该遇到,除非你故意设置 mask 指定对某个状态抛异常,那不接就是你的锅了。这是个用不用异常都不省心的地方(这些异常中一部分通常明显比其它异常更该原地忽略,但另一部分不是),所以直接允许自定义。
wangritian
2022-08-13 07:30:06 +08:00
支持 1 ,另外如果有用户不存在这种情况,返回值我会设计成*User, bool, error
guanhui07
2022-08-13 09:19:21 +08:00
有错误都必须处理
Nugine0
2022-08-13 15:02:22 +08:00
@FrankHB 所谓的 error neutrality 就是指默认向上传播错误?那 Rust 的问号运算符在这些语言中应该是最简单的,毕竟只要打一个字符。
Nugine0
2022-08-13 15:10:32 +08:00
一般有三种可能
1. 查询用户成功,取得有效值
2. 查询用户成功,但用户不存在
3. 查询用户失败

第 2 类是否算作错误,要根据业务决定。
通常情况下在出错时不可使用返回值,所以选 1 更好。
FrankHB
2022-08-13 18:40:58 +08:00
@Nugine0 Rust 的 ? operator 或者 C# 的 ?? (null-coalescing) operator 这种 ad-hoc 的解法是可以避免一些写起来的啰嗦。不幸的是,无法解决更多更根本的问题:
实质上还是 union type 或者 checked exception 的弱化形式,添加错误处理或者修改错误同样会改变接口签名(后者可以用错误擦除类型缓解,但开销嘛……);
就算是解决传播错误废话多的问题,相比原本没错误处理的代码还是得动源代码、引入语法噪音、阻碍关注点分离,比起上游引入新的异常 /在异常中添加和修改错误条件可以直接不用动所有下游不需要处理错误的 caller 实现(包括二进制代码),还是有很大的工程劣势;
因为编码错误和结果共同占用返回值,在较长的调用链(默认情况)中一旦没内连(很常见),至少会浪费寄存器带宽,同时基本不可能被优化(因为侵入签名,优化可能需要 ABI 魔法,不用魔法的地方基本都能直接内联了);
依赖操作数具有一些特定的类型,实际上是弱化的 monad 糖,但又没完全版本的 monad 那么可扩展能自定义。
ryan961
2022-08-13 19:09:01 +08:00
@pigmen #61 Inconsolata-dz for Powerline
Nugine0
2022-08-13 21:13:35 +08:00
@FrankHB
首先改变 API 行为本身就是有风险的,下游不用改源码不代表没有行为会被破坏。你说的“工程劣势”在我看来不算什么问题。
其次异常的实现方式不止 unwind 一种,也不一定能保证二进制兼容。而且 unwind 在非 happy path 的开销非常大,不一定值得冒着性能退化的风险去优化 happy path 。
我只能说异常也不是银弹,用什么错误处理方式要根据具体场景决定。
FrankHB
2022-08-14 03:28:20 +08:00
@Nugine0 有风险是对的,但改 API 本来就要人来改,人来检查正确性,谁改谁承担风险。
(当然其实这不太公平,因为合理设计的 API 允许一定的行为不同作为实现细节而不被 API 依赖,出问题原始设计者也该背锅。这个现在的语言更不好强制,基本得自觉。)
要求改代码对于大多数静态本机语言的感知还是很大的,特别是只会部署二进制的程序,光是无法满足重新编译,很可能就足够枪毙一个调整 API 的方案。

盲目 unwind 当然会有问题(比如不方便控制某些资源生存期,比如掉 frame 不便调试),但和这里的问题不直接相关。更一般的 control effect primitive 原则上允许用户自选实现策略来实现出异常,不过这更复杂(比如考虑 abnormal 但不 exceptional 的 path ),缺乏现成成熟应用,或者其实就是相当不成熟(提供 call/cc 的 Scheme 一般异常也是内建实现的)。但这也就是说,仍有很大改进空间,而不是此路不通。
异常不都能保证实现这里期望的性质,但至少提供了一种既有成熟的方案,而不是像你提到的其它替代一样注定不可能克服问题。
Nugine0
2022-08-14 11:42:58 +08:00
@FrankHB 你说的“注定不可能克服”的“问题”当然是存在的,但在某些场景中就不算问题,不然谷歌为什么禁用 C++异常呢?
从其他错误处理方案的视角看,异常也存在注定不可能克服的问题,比如开销大、不能跨语言兼容、不适合嵌入式系统等。
总之就是一句话,没有银弹。
FrankHB
2022-08-14 23:30:50 +08:00
@Nugine0 这才是最扼腕的地方:这些场景明明客观上就是显然的大多数,但是很多用户就会因为自己使用的语言和习惯为缺陷辩护,而不是直面不足。
在工程上,很多时候经验上的“大多数”足够决定一个决策的正确性。反其道而行之是不明智的。这跟银弹完全是两回事。以银弹为理由而拒绝重新审视现有实践,逃避全面分析问题的成本和收益,是不成熟的优化的一种。

Google C++ 规范不用异常,简而言之就是菜。
Google 相关人士有提到过,他们知道现代 C++ 默认就该放手用户使用异常,但是他们自己现有代码的历史包袱太重,没法承担放开手下人用异常的质量风险。

技术上提出的问题全是比较好笑的:
* 他们认为加异常就需要遍历所有实现的路径。
这直接就不是默认情况——并且说明原来的代码质量普遍就很糟糕,没写清楚接口约束,也习惯用户不看这类“错误条件”就瞎用。
* 他们认为用了异常就难以看清逻辑。
大体同上,只是更偏向于内部接口的实用。(另外类似地,是不是能认为用了赋值就难看清逻辑?其实某些 PFP 教徒还真是那么想的……虽然他们甚至未必都拎清楚这是为了 equational reasoning 这回事。)
* 他们认为写出异常安全的代码是负担。
但事实上异常安全不是线程安全那样需要另外加逻辑才能维持可组合性的属性,不引入异常安全操作,整个程序默认都是异常安全的,就像 Rust 你不去用 unsafe 默认就不用管一些问题——区别无非是 Rust 有明确的关键字和编译器强制检查,这里依赖自觉;但这个自觉工程上很容易做到,甚至人肉检查都没多大开销(特别是按标识符索 poisoned 属于最容易 review 中找茬刷 KPI 的一类,比找拼写错误都容易)。
只有滥用破坏安全性不变量的低级接口才会有这问题。类似地,正常人代码禁绝 malloc/new (不担保资源所有权不变量,恰好同样不担保异常安全)满天飞就能几乎完全静态避免内存泄漏(除了循环引用)。这逻辑就像“写出不泄漏的代码代价太大,学智能指针( RAII )麻烦,所以还不如老老实实用 malloc/new”一样可笑。更荒谬的地方在于,这些代码组合进启用了异常且正确实现了异常安全的正常 C++代码以后,还可能因为这类局部的不安全的传播污染整个程序而使整个程序的安全性失效,基本就快直说“Google 的 C++代码别指望给正常 C++代码复用”了。( Google 还敢在自家开源项目里鼓吹这个,呵、呵。)
实际 Google 倒是有老实用智能指针而不一定同时具有所有的具体问题,但同样缺乏普遍保证,而且不少是自家比 std 劣等残次多了的发明。
* 异常很可能存在额外开销。这是唯一一个真实的工程问题,但是 Google 绝大多数项目实际根本踩不到这里的问题。
即便是这个问题也不是 C++ 自己作死的原因,TR 18015 指出语言上没直接开销(当然这里对空间开销分析不足,但你指望一个提了 stack unwinding 却连什么叫 stack 都说不清楚、多大 satck overrun UB 的语言啥呢)。
实际开销绝大多数问题都是对 ABI 的不切实际的假设。这些也不只是异常,unique_ptr 因为就因为写 SysV ABI 的 C 厨的不走心在 *NIX 实现里走不了寄存器,还得编译器开洞没法默认用。
如果这种问题算回避不了的开销,那么 C++ 早就是性能洼地了。实际呢?不用 C++ ,用 C 用汇编或者其它手段能写出跟 C++ 实现相比像样的代码用户有多少?

退一步讲,就算不深究造成这些历史包袱的责任,这些问题的坑也都是合格的 C++ 用户自动会绕开的,反而要改变习惯还费事(比如 new 得多加 nothrow 免得换到正常配置里直接呵呵了)。你旧代码多,所以新项目代码也跟着喂食,这什么姿势?
就这点原因都敢写到全公司的规范,Google 这里的技术水平整体(先不说个别 team )远不如微软之类,能和鹅比都不错了。
我建议分析 Google 这些规范的不靠谱性列入任何想用 C++ 正经干活的组织的标准面试流程。
Nugine0
2022-08-15 00:37:32 +08:00
@FrankHB 我认为根据特定场景去全面肯定或否定某种技术方案也是不妥的。“大多数”和“正确性”并没有必然联系,事实上很多工程决策后来都变成了历史遗留问题。
既然你这么推崇异常,评价一下另一种异常方案 herbception ?
FrankHB
2022-08-15 01:34:43 +08:00
@Nugine0 “大多数”不直接决定决策结果,但这里足够推理出决策的方向。
无视“大多数”的下场就是这里无谓的工作量会显而易见更多。无谓在于:
作为实现细节,是需求方不关心的;
还会可预见地或多或少劣化代码的可读性(阻碍关注点分离)和可修改性(接口签名传染性),是属于留着就找不出什么好处的类型。
所以,这种能预测的纯粹成本应当能优化就优化掉。没干掉只能说是受到现实条件的限制而不是说不想要,否则就真是三观问题了(比如行数刷 KPI 什么的)。

Herbception 不是真正的传统意义上的异常,而是 union type 的另一种变体,只是和现有 C++ 的语法差异更小,改动少而已——但仍然不是没有。
注意到使用这种机制需要侵入到声明中的 throws 指定变换。在 ABI 上,它同样需要预留改变布局的约定。
所以没什么新鲜的,只是类似 P0323 的语法糖而已(反正 ABI 上 std 里的东西本来就允许飞线)。
除了略没那么啰嗦(改进有限),它仍然具有类似的设计的所有既有问题。特别地,它宣称的异常可见性问题恰恰就是消除传统异常的优势——我说过,如果需要,那么就老实用 union type 的方案,更通用也更明确。用糖来混淆反而削弱目的。
而剩下关于开销的问题,本来就全是实现细节。任何一个能和既有实现划清界限的新设计都有资格来做,但是削弱不修改签名来做就可能得不偿失。反之,例如,如果用 throws 改变 throw (意味提供一个新的、可以无视兼容包袱能在运行时把更多事做对的控制操作符)而不是用 throws 改变 throw 的含义,就不见得有那么明显的问题。
这里的设计思路导致上面的主要缺陷仍然没有改变,结果也只是提供了一种更简单的新写法转移视线罢了,性价比很成问题。(特别是考虑 C++ 已经够复杂的情况下。)
另外,这种侵入式修改在 co_* 中已经表现出很大的争议性。类似的问题是过于暴露实现细节(如果不是为了允许一些固定的静态实现策略,可以直接如 P0534 一样支持非侵入的语法),而完全不足以代表所在问题域的通解。这类提案即便通过,也很难是最佳实践。( C++20 coroutine 大概还没怼赢 Clang++ 的冷屁股。)
Nugine0
2022-08-15 02:42:11 +08:00
@FrankHB 你说开销是实现细节,对不对呢,当然对,但很多时候调用方确实有理由去关注一个 API 在不同 path 的性能表现。和类型与 Monad 能做到以多占一个寄存器的代价(先不管 go 这个用积类型的奇葩),去消除错误路径的额外开销,这时异常(unwind)的开销就成了劣势。给和类型与 Monad 提供对应的语法糖,也能解决啰嗦的问题。
至于函数签名,有人认为一个函数会不会出错应当体现在签名中,有人认为应当透明,这在各自场景里都是对的,但不能说哪边就是绝对正确。硬要无视场景区别去推行所谓“大多数”的决策,我觉得只会收到一大堆黑人问号。
frodez
2022-08-15 15:16:21 +08:00
@pastor 但 golang 的问题在于只能约定必须先处理 err ,而无法强制必须处理 err ,相反 rust 哪怕是 unwrap 也是对 err 的处理,虽然不负责任,但至少保证了强制处理 err 。而且 golang 甚至允许既返回值也返回 err 的情况,相反 rust 的 option 和 result 就能根本上避免这个问题。所以 golang 在错误处理这一点上,比起真正先进的语言还有很大差距。
FrankHB
2022-08-15 18:19:07 +08:00
@Nugine0 性能大多数时候都是次要的,至少会让位于功能正确性。只有后者不成问题,才考虑性能。
虽然 API 设计通常不暴露给最终用户,但对可复用软件的用户(开发者)经常还是重要的,所以提供维持不需要动下游代码的可修改性一定程度上属于功能需求。只有在没得选的情况下,被迫劣化设计,才会让这个前提不存在。而剩下的情况就得考虑性能差异是否足够要紧到劣化 API 的功能设计上。

对支持确定性资源释放的语言,unwind 对维持不变量至关重要——这明确属于功能需求。如果 unwind 会是劣势,那说明没用上这个保证。C++不允许用户以可移植的方式选择这种情形属实无能,但至少行为上能够保证正确(硬要无视,自己包装类类型故意跳过释放,反而逻辑上基本是错的;现有 C++规则让用户实际损失的主要是调整释放的副作用顺序的自由);而没法 unwind 隐含保证顺序依赖的资源释放,相比之下更加无能。
传播错误的实现开销不只是一个寄存器,还有正常路径上的分支,只不过实现不好的异常也可能有另外的正常路径开销,所以之前没展开。但关键是,不管这里的开销有多小,都比实现正常的默认策略的正常路径无额外开销更差。而异常路径本来就相对对性能不敏感,所以只要不是慢到没法用,就能接受,不需要另外“优化”。用默认路径的开销作为代价的方法本来就是可疑的。

(题外话,如果“错误”需要立即处理,不需要考虑 neutrality 的默认策略,那么本来就不适合通过异常提供。不过始终需要立即处理的东西是不是真的应该叫“错误”都是个问题。利用返回值实现的替代方式中,诸如 expected 之类的词汇对这点的混淆,也是个现时问题。)

函数签名的问题是静态类型系统的缩影。根本上,静态类型系统提供的是静态可证明保证。如果程序翻译时不需要这些保证,那么出现在程序中就是语义噪音。
描述 API 的类型签名是静态类型的使用理由中最充分的一个。作为一种约定,它必须在程序运行前确定。对大多数没有多阶段支持的语言,这意味着翻译前确定。这其中毫无疑问地包含了输入的前置条件和输出的后置条件,对应为参数类型和返回类型(用类型构造器→组合就是函数类型)。
而任何超越接口约束的信息,编码到类型中,都是一种滥用,尽管这种滥用可能允许实现生成更高效的代码,它是以暴露实现细节、阻碍接口设计的普适性(依赖更特定应用领域不关心的具体类型系统的元语义)为代价的。
异常本质上编码的是特定条件的备选行为,不属于输入或者输出。所以逻辑上,默认就不该让函数类型依赖异常是否存在,更不应该依赖异常具体是什么。C++ dynamic exception 就是个失败的例子。真要依赖,也就是一个提示(如 C++ noexcept ),就像 __attribute__((pure)) 保证可假定不存在非局域副作用一样,noexcept 保证其中没有传播到外部的非局域控制作用。
C++17 noexcept 影响函数类型已经是特定类型系统的设计侵入普遍接口设计的工作流了:它指明了 API 设计者默认情况下有义务明确函数是否会通过异常退出(即便如此还是鸡肋,实际上组合现有实现时也没法静态确保 noexcept 不会被违反,还是得 terminate )。
只不过,这些多余的工作量在已经考虑到 noexcept 的 API 设计中,问题不大,其它情况也容易通过调用方一次性解决(大不了再包装个;比如 C 函数指针要求你自定义 malloc/free 备胎的情况可能就得多塞进去 lambda ),实用问题相对比较小——但仍然已经体现出危害,比如 std::function 缺的特化。
(实际上,虽然没考虑清楚这里的问题,之前 WG21 已经知道了 noexcept 影响接口验证的问题,因此限制 std 中的 noexcept 的使用;参见 N3279 。)

为了在允许完善地解决这些矛盾,出路有两个方向:
一个就是让开发者更明确地表达约束,使用 union type (或者弱一点的 sum type )等方式把可能出现的异常明确当成输出的一部分,而跟真正意义上的异常划清界限。这个方式也更普遍地符合对静态类型的其它使用实践。但是注意,偏离显式类型的糖就算能减少代码啰嗦程度,也是一种这种实践的弱化。
第二是让类型系统不那么静态而可选:根据用户需要,期望传播带有静态检查就影响函数类型,不需要就默认不影响。很遗憾,这需要让类型依赖阶段(以及和经典的→不同的构造器),普通的静态类型做不到这个。对大多数不能重头重造的语言就是此路不通。
Nugine0
2022-08-15 20:04:07 +08:00
@FrankHB 你这么长篇大论下来,虽然确实说了很多知识,但还是在不断回避我关注的问题……
建议把你的观点整理起来发篇技术文章,比这样在评论区打转更有效率。后面我不再回复了。
FrankHB
2022-08-15 20:11:30 +08:00
@Nugine0 我不清楚你具体在关心什么问题。我说的“默认”“大多数”“计较成本”即便无法明确绝对边界,全部是客观现实,你也没提出明确异议和理由,只是说有不同情况,然后要我评论不同替代方案。那我也说了,其它情况就适合去用 union type ,而不是半吊子。
要再明确一点,就是现实没什么中间路线值得添加新的设计,被迫接受是因为(多数)用户摆脱不掉语言的既有设计只有这类不靠谱的选择这种状况。但吃哑巴亏吃惯了也别斯德哥尔摩综合症了,该干什么是在用语言实现之前就能弄明白的。

我的长篇大论都有专项备份,本来就都是草稿。

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

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

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

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

© 2021 V2EX