关于 Go 在 `Return Nil or Pointer to Empty Struct on Error` 上的讨论?
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 ,所以基本都是用第二种。想听听大家的意见或见解?
首先,谢谢大家热心的讨论与指导,本人收获良多。
原先是我的表达能力有所欠缺,我想说的侧重点不是在 err 上,而是在于 if err 校验成功后,是否需要对 User 这样一个返回值去做 if user == nil 校验。个人选择 2 的想法是在下游可以放心大胆的直接 User, 而不是 err 校验为空后,再继续对 if user == nil 做一次校验,所以在代码习惯上就偏向了 2 这种代码风格(才疏学浅,大家见笑了)。
通过大家的讨论,个人也推荐对于 err != nil 的时候应该直接抛出 nil 。对于 err == nil 但 user 不存在的这种情况是
- nil, nil
- &User{} ,nil
- nil ,errors.New("user not exists")
大家可以在 1 和 3 种约定好一种方式,2 就不推荐了。
有 err 的情况还这样用?
那只能掩盖漏洞了
2
2 宁愿出现了 Panic 都别用初始值。
万一上游调用漏掉了 error 。你这个 error 会一直漏掉
#1 因为有些接口逻辑中可以容忍部分功能模块出错
imgur.com/YKWjZ2r
#3 一般是在 err 开始的地方直接打的日志,下游只做判断
可以容忍不代表你就得惯着(忽略错误然后假装无事发生肯定不是正确的行为)要么你就加个接口不返回 err 的,来明确表达这个目的
万一出现一个真的需要区分的时候,漏判断 err 还没测试出来,生产环境把错误内容写入数据库(或者别的危险操作,如权限判断默认值啥的地方),不就留了漏洞了?
- fail-fast 原则
“有些情况” 应该根据特殊情况才使用,其他情况应该判断 error ,调用方自行处理默认值
能不能容忍错误不是你来决定的,是 caller 决定。当然你这样搞,出问题也只能怪 caller 不检查错误。但是一般还是 fail-fast 。
因为你不知道这样的错误会被拖到什么时候才爆发。程序如果不能处理错误,那么就应该尽早死掉重启
都返回 error 了,应该根据 error 是否为 null 来判断。
go 不就是要判断 err 吗,缺了 err 就不是 go 了
我都是用 1
肯定选 1 啊,选 1 如果调用方因为漏了判断导致空指针崩了看下异常好歹明确知道崩在哪,2 返回一个空结构到时候如果勉强能运行下去的话出了业务错误光是调查都查到半死。
肯定 1 阿,你是在掩饰错误
1
不注意是下游的问题,2 存在二义性
1
1
都返回 err 了,不处理怪谁
1
没有 if err != nil{} 的 go 是没有灵魂的
肯定是 1 啊 , 外面以判断 err
不应该返回一个初始化的对象,如果中途有错误,需要返回,要么你把原来的对象抛出去,要么给个 nil ,你这里给一个新的初始化的对象,下游如果不知道你的逻辑,他会怎么想,为什么莫名其妙多出来一个空结构体?
不止 golang ,外面这几天吵的不可开交的语言都有 nil/null 这个问题
要回答这个问题,可以从 rust 的思想入手
首先在 php/java 这类常规语言开发中,要么返回 null ,要么返回空对象 User{},返回 null 就有个有名的 java 常见的运行时异常 NullPointException ,php 就是 call xxx on null
作为强类型语言,java 定义方法是需要有确定返回类型,如果返回 User 却返回 null ,但是 null 明显不是 User 类型,这个很矛盾,所以调用者即使知道方法返回 User ,却还要处理 null 的情况,而 php 可以不定义返回类型,所以锅就强行推给了方法的调用者
现在就有了楼主说的 2 种解决办法:
- 返回 null ,让调用者处理 null 问题(目前常规解决方案)
- 返回空 User ,调用者放心使用返回值,而不用担心问题,但是在业务中却需要注意空 User 问题,例如在 php/laravel 中调用 guard 的方法,$user->isAdmin()就会产生歧义,到底是没有这个 user ,还是 user 的权限不是管理员,只是普通用户
为了解决这个问题,rust 给出了以下解决方案:
首先 rust 中有 Option 这个东西,枚举为:
enum Option
None,
Some(T),
}
对于你的问题,返回就应该是 Some(User)或者 None ,在方法定义中,由于返回是 enum 枚举 Option
但是到了方法调用时,调用者必须处理 Option
编译器会检查这个问题,所以就不会有运行时异常了,也不会有返回 User 却返回 null 还合规这种矛盾的事情了
golang 的解决方案稍微变换了一下,按照常规解决方案是返回(nil, err),这样在调用时:
if user, err := x.GetUser(); err != nil {return err}
golang 在调用后 if 中处理,如果 err 不为 nil 则 user 值不合规,而不必管是 nil 还是空 User{}
说起来这个和 rust 的 Option 有一点异曲同工之妙
区别就是 rust 是为了编译时合规而给出了 Option 方案,golang 是运行时使用判断 err 给出了 if 方案
而关于担心下游不注意 nil 判断就很多余了,随便给了 api 文档说明:
func (b *Builder) Write(p []byte) (int, error)
方法定义既然给出了(int, error),如果有 error!=nil ,不管 int 真的是 int 还是 nil ,此时都不合规,调用者不能使用,这就是 golang 的规范
当然在 php/java 常用另一种做法,就是在 GetUser()中没有找到方法就 throw 个 Exception 而不是返回 null ,让外面 try/catch 解决,没有触发异常则返回的 User 是合规的对象
对于复杂的代码来说,经常有 try 包 try 的代码,看上去反而有些奇怪,于是有了 php/java 的 try 加 if 判断的混合用法,反倒有点混乱的感觉,具体喜好还是看个人
当然 1 了、优先处理 error ,这种写法肯定是错误的:
r, err := do()
r.Xxxx()
if err != nil {
xxxx
}
第二种写法,虽然调用 r.Xxxx()不会 panic ,但是
执行预期是什么喃?完全就会把不确定性传递下去。
#20 受教了 🌹
rust 的方案相对更好,golong 容易 repeat your self
并不觉得哪个比另一个更好。如果只是 if err 就嫌麻烦,那其他哪个语言的额外处理是能省略掉的呢?都省不掉,偏偏觉得 if err 就麻烦了,这有点自欺欺人了。
可以多看看官方库或是一些著名开源项目的源代码,看他们是怎么做的。
至少在这个问题上,一般的选择都是 1 ,不同理由楼上都给出来了。
估计 OP 还没 get 到本质,这个问题的本质很简单:golang 提倡的正确做法是 if err ,OP 没有按照正确的做法去做、然后想出了方案 2 去弥补自己错误做法,然后又引来了默认空结构体可能造成的下游错误需要去弥补。
本来就是使用 if err 判断就完事了,入乡随俗、用这个语言就按这个语言的规矩来,根本不需要 #20 对比的其他语言的那么多原因,但是解释 go 自己这样做的原因是合理的。但是话说回来,对比一下多个语言,也让大伙多长长见识,挺好,#20 我先赞为敬
最后我们又一次重新发明了 Monad
2 吧,不过我只是用的方便,带名字的返回值直接 return 方便,有 err 的话返回的东西就不应该访问,所以都不清理的,不过有时候返回值里的垃圾数据反而会让 debug 会更容易。
如果上下层都是我写,我会用 1 ,强制自己检查 error 。
如果是别人用,我听他的意愿,但是要保证所有地方一样。不能既有 1 又有 2.
如果是我用别人的,我说了不算,只能尽量都检查 error 。
1 是更合理的, 无论从语义还是处理上.
2 会更容易产生被忽略的 bug ,有个类似的问题我们之前遇到过的是我们的 err wrapper 的接受 err 为空 nil ,返回为 nil 。但是一般这种场景很多时候会需要返回错误,导致了很多隐性 bug ,我们扫了一遍找到了几百个错误使用.。。
不能害怕 panic ,panic 发现的越晚,造成的影响越大
// 3. return nil, nil
func GetUser() (*User, err) {
....
return nil, nil
}
会不会这样?查询也没有报错,确实也没有这个用户,后续走创建用户的逻辑(假设有的话)。还是说 err 返回一个比如说 error('用户不存在')
这样的,caller 通过比对 err 进行下一步逻辑?🤔
在其它语言里,我一般的做法是抛异常. 所以在 go 里应该是返回 err 判断 err 选 1.
1 ,如果下游可以容忍错误,也应该是下游自己创建一个&User{}
选 1
nil 才是不存在,空值和不存在是两回事
1, 有错误都必须处理
学 haskell 少走 30 年弯路
学 lisp 少走 60 年弯路
#26 我的错,可能我的表达能力有所欠缺。我想说的侧重点不是在 err 上,而是在于 if err 校验成功后,是否需要对 User 这样一个返回值去做 if user == nil 校验。个人选择 2 的想法是在下游可以放心大胆的直接 User, 而不是 err 校验为空后,再继续对 if user == nil 做一次校验
都出现 err 还用返回值不是作死吗
都返回 error 了,不处理 error, 还去用 这谁也救不了吧, 正常规范应该是, 有 error 都要处理
你都 err 了,为什么还要用*User 。Java 里面 catch exception 你还用返回值?
如果都像 2 那样用,那么 Go 为什么要搞个 nil ?
zig 中可以这样 var x: anyerror!?T
这个 error 按你的函数名语义不应该为 nil ,没有获取到那就是有错误
user 都是空的了还不报错吗?没有获取到就是没获取到,不报错给个空的忽悠人啊
这个就更离谱了,err 为空同时返回的 user 为 nil ?如果用户不存在请返回 user not found 的 error ,不要去解决本不存在的问题
#39 你应该把这楼写到最前面。。。看到大家都在讨论返回 nil 还是&User{},我也感觉是歪楼了。
理论上来说,按 1 返回,caller 不需要检查返回的 user , 只要 err 为 nil ,就可以放心使用返回的 user 。但实际开发时,被调方写的啥样都有,而且倘若出了 bug ,必然会怪到 caller 身上。反正我是被坑过,所以我检查完 err 后还是会检查下返回值是否为 nil 。
最近正好在书中看到过这个,书中给的原则是:如果方法返回了 error ,那么该方法返回的其它值就不能够再被信任了。
我感觉调用者是否忽略错误是调用者的事,但大家只要遵守上面的原则,应该就没有问题。为了促进大家遵守这个原则,使用方法一更好。
肯定 1 啊。
第 2 种,调用方不管 error 也不管 nil ,那么返回一个空对象,更不能指望 TA 在用这个空对象时,会去判断 User.Id User.Name ,而这种属性在甚至可能会有不少人根据惯性思维认为 Id 或者 Name 是一定存在的
你的想法本身就是有问题的,无论是调用方还是被调用方,都应该遵守一个原则:err != nil 的时候,默认 err 之外的所有一切返回值都是毫无意义的应该直接丢弃(连检查都不应该去检查)。如果你需要添加额外的错误信息,那应该将信息放入 err 而不是从其他返回值上获取。所以你担心的 err 不等于 nil 需要去判断 user 指针,这种事情本身就不应该发生
err != nil 的时候,不应该信任 err 以外的返回值
😅看了 39 楼,还是应该用 1 , 用了 2 等于回到 C++了,弱类型各种判断,你烦不烦啊。
#48 抱歉,不会操作这个😅
为啥不学 controller-runtime 那套呢
user := &User{}
if err := client.Get(user); err != nil {
...
}
这些说法是有问题的。
是否能够忽略错误,根本上就是应该是接口具有的含义确定。有的接口的用途决定一些错误就一定是实现细节,而应该及时被处理掉;此时 caller 被设计为无权依赖这些实现细节,更不应该被这些泄漏的细节干扰。
只是这里 2 的接口设计怎么都跟允许忽略错误的接口设计需求不搭。
反而一般的接口实现作为程序的一部分,不应该随便决定整个程序是不是能处理错误(况且判断是不是该死其实也是一般意义上的一种“处理”),因为 caller 通常总是比 callee 更配代表整个程序来决定是不是该死掉。所以不能就地处理的错误默认就该甩锅给 caller ,出问题就是 caller 的问题。
如果整个调用链都是默认策略,在这里就是最终 panic ,也就是选 1 。而在某个位置因为接口的设计,可以能出现类似 2 的情况(但不应设计成这样容易误用的接口,下同),只是一般较少见。
这种“默认”的性质是可组合的,叫做 error neutrality 。但是因为语言设计的原因导致一些语言这种“默认”策略编码起来无畏地啰嗦,具体如 C 、Go 、Rust 、Java 用户通常在这方面的意识很弱,或者基本没有。一些 C++用户倒是容易注意这些问题——没事别乱 catch ,要及时 rethrow——于是实现了 exception neutrality ;不过另一部分死脑筋嚷嚷排除 exception 的用户(基本同 C 用户,除了那些会 sjlj 的)这里一样没救。
因为大量初级用户容易默认语言既有设计真实反映了实用需求,这种啰嗦的一个直接问题是让许多用户无法意识到 1 和 2 的比例在实际需求中的差异,以及造成这种差异的理由,而放弃深究问题的来源,停留在流于基于既有经验的瞎蒙,以期望得到 best practice ,却不知道已经踏入了具体语言设计局限性的陷阱之中。
#56 然后呢,你是要设计一个新语言还是在“现有语言的约束下”寻找比较好的方案呢?
现有 go 的约束,就是没有办法去充分表达错误分支和正常分支的互斥性,而想自动传播异常用 panic+recover 也不是推荐的方案,因此这个情况只能靠开发者自己的规范,那这里的选择,就是用最不容易让其他开发者犯错的方法,使用上述策略,然后在要搞特殊处理的时候再单独写文档说明为什么要这样做。
这帖子是在讨论“已经在用 go”的情况下如何尽可能避免踩坑,而不是来讨论 go 语言设计的哪里不好,也不是来搞语言鄙视链的。(而且 c++异常的喷点也不少,缺少 checked exception ( throws 被移除,noexcept 只能用来标记永远不抛出异常的函数,这个特性好不好另说)或者 effect 机制等类似机制,导致实际上很多时候是开发者忘记处理异常(和标记在文档里),而不是注意到不要 catch ,特别是 call graph 复杂了之后,很难发现某个调用路径下有可能会抛出哪些异常(例如人均忘记的 bad_alloc ,和流处理会遇到的 std::ios_base::failure ),这导致异常本身经常会由于上述疏忽变成实现细节——只有崩了才知道啊原来这个函数会抛出这个异常)
返回 copy 好了。
var ErrUserNotExist = errors.New("user not exist")
func () {
return nil, ErrUserNotExist
}
然后调用者根据 err!=nil 和 errors.Is(err, ErrUserNotExist)来处理。
4 把返回类型写成结构体,不过有没有 err 都得返回个结构体,下游永远不可能 panic
如果返回的不是 pointer struct 而是一个 struct 呢?
btw 楼主啥字体呀
可以先理一下思路:
如果函数执行正常,那就是意味着没有错误,那么 user 就应该有一个结果,而且它是可用的;
如果函数执行不正常,那就意味着有错误,那么 user 即使是有结果,那它也是一个错误的结果,是不可用的。
返回一个 Empty struct 的危险之处之处在于,当调用方忘记了检查 err ,而且 Empty Struct 从业务上也讲得通,后面的逻辑会根据这个错误的结果执行下去了,于是最后的结果也可能是错误了。
我不满现有语言,就自己造了。但是理由是别的(光这里 C++ 其实都够用)。
对一般用户,最简单的就是去熟悉带有异常支持的语言,习惯怎么简化默认策略之后,去提议增强没这种机制的语言。
本质上,异常是一种控制作用(control effect)的具体应用。控制作用和修改对象一样同属副作用,是很基本的东西,语言缺乏这种特性多少是残的,对通用的编程语言,只要不是像 C 那么躺平(但就算 C 都有 setjmp/longjmp ),实在是逃不过的。
当然你可以说纯函数式语言就拒绝副作用,但其实真完全拒绝的就没法编程了。典型做法是副作用和控制作用都打包成 monad 隔离。但说实话这要没语法糖就不是给人用的,有也就是强迫用户用糖化 CPS 风格强制代替自由书写直接风格的程序的自由罢了,属实没事找事。
(用 monad 能组装出修改状态是因为假定 monad law 存在下的一些规则起到了能表达 delimited control 的类似的作用,后者能表达可变状态。其实典型的异常严格意义上比 delimited control 还强点,理论上一个不存在对象修改而只有异常作为副作用的语言也可以写出修改对象的实现。)
缺少 checked exception 不是问题,实际通常反而有才是( 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 指定对某个状态抛异常,那不接就是你的锅了。这是个用不用异常都不省心的地方(这些异常中一部分通常明显比其它异常更该原地忽略,但另一部分不是),所以直接允许自定义。
支持 1 ,另外如果有用户不存在这种情况,返回值我会设计成*User, bool, error
有错误都必须处理
所谓的 error neutrality 就是指默认向上传播错误?那 Rust 的问号运算符在这些语言中应该是最简单的,毕竟只要打一个字符。
一般有三种可能
- 查询用户成功,取得有效值
- 查询用户成功,但用户不存在
- 查询用户失败
第 2 类是否算作错误,要根据业务决定。
通常情况下在出错时不可使用返回值,所以选 1 更好。
Rust 的 ? operator 或者 C# 的 ?? (null-coalescing) operator 这种 ad-hoc 的解法是可以避免一些写起来的啰嗦。不幸的是,无法解决更多更根本的问题:
实质上还是 union type 或者 checked exception 的弱化形式,添加错误处理或者修改错误同样会改变接口签名(后者可以用错误擦除类型缓解,但开销嘛……);
就算是解决传播错误废话多的问题,相比原本没错误处理的代码还是得动源代码、引入语法噪音、阻碍关注点分离,比起上游引入新的异常 /在异常中添加和修改错误条件可以直接不用动所有下游不需要处理错误的 caller 实现(包括二进制代码),还是有很大的工程劣势;
因为编码错误和结果共同占用返回值,在较长的调用链(默认情况)中一旦没内连(很常见),至少会浪费寄存器带宽,同时基本不可能被优化(因为侵入签名,优化可能需要 ABI 魔法,不用魔法的地方基本都能直接内联了);
依赖操作数具有一些特定的类型,实际上是弱化的 monad 糖,但又没完全版本的 monad 那么可扩展能自定义。
#61 Inconsolata-dz for Powerline
首先改变 API 行为本身就是有风险的,下游不用改源码不代表没有行为会被破坏。你说的“工程劣势”在我看来不算什么问题。
其次异常的实现方式不止 unwind 一种,也不一定能保证二进制兼容。而且 unwind 在非 happy path 的开销非常大,不一定值得冒着性能退化的风险去优化 happy path 。
我只能说异常也不是银弹,用什么错误处理方式要根据具体场景决定。
有风险是对的,但改 API 本来就要人来改,人来检查正确性,谁改谁承担风险。
(当然其实这不太公平,因为合理设计的 API 允许一定的行为不同作为实现细节而不被 API 依赖,出问题原始设计者也该背锅。这个现在的语言更不好强制,基本得自觉。)
要求改代码对于大多数静态本机语言的感知还是很大的,特别是只会部署二进制的程序,光是无法满足重新编译,很可能就足够枪毙一个调整 API 的方案。
盲目 unwind 当然会有问题(比如不方便控制某些资源生存期,比如掉 frame 不便调试),但和这里的问题不直接相关。更一般的 control effect primitive 原则上允许用户自选实现策略来实现出异常,不过这更复杂(比如考虑 abnormal 但不 exceptional 的 path ),缺乏现成成熟应用,或者其实就是相当不成熟(提供 call/cc 的 Scheme 一般异常也是内建实现的)。但这也就是说,仍有很大改进空间,而不是此路不通。
异常不都能保证实现这里期望的性质,但至少提供了一种既有成熟的方案,而不是像你提到的其它替代一样注定不可能克服问题。
你说的“注定不可能克服”的“问题”当然是存在的,但在某些场景中就不算问题,不然谷歌为什么禁用 C++异常呢?
从其他错误处理方案的视角看,异常也存在注定不可能克服的问题,比如开销大、不能跨语言兼容、不适合嵌入式系统等。
总之就是一句话,没有银弹。
这才是最扼腕的地方:这些场景明明客观上就是显然的大多数,但是很多用户就会因为自己使用的语言和习惯为缺陷辩护,而不是直面不足。
在工程上,很多时候经验上的“大多数”足够决定一个决策的正确性。反其道而行之是不明智的。这跟银弹完全是两回事。以银弹为理由而拒绝重新审视现有实践,逃避全面分析问题的成本和收益,是不成熟的优化的一种。
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++ 正经干活的组织的标准面试流程。
我认为根据特定场景去全面肯定或否定某种技术方案也是不妥的。“大多数”和“正确性”并没有必然联系,事实上很多工程决策后来都变成了历史遗留问题。
既然你这么推崇异常,评价一下另一种异常方案 herbception ?
“大多数”不直接决定决策结果,但这里足够推理出决策的方向。
无视“大多数”的下场就是这里无谓的工作量会显而易见更多。无谓在于:
作为实现细节,是需求方不关心的;
还会可预见地或多或少劣化代码的可读性(阻碍关注点分离)和可修改性(接口签名传染性),是属于留着就找不出什么好处的类型。
所以,这种能预测的纯粹成本应当能优化就优化掉。没干掉只能说是受到现实条件的限制而不是说不想要,否则就真是三观问题了(比如行数刷 KPI 什么的)。
Herbception 不是真正的传统意义上的异常,而是 union type 的另一种变体,只是和现有 C++ 的语法差异更小,改动少而已——但仍然不是没有。
注意到使用这种机制需要侵入到声明中的 throws 指定变换。在 ABI 上,它同样需要预留改变布局的约定。
所以没什么新鲜的,只是类似 P0323 的语法糖而已(反正 ABI 上 std 里的东西本来就允许飞线)。
除了略没那么啰嗦(改进有限),它仍然具有类似的设计的所有既有问题。特别地,它宣称的异常可见性问题恰恰就是消除传统异常的优势——我说过,如果需要,那么就老实用 union type 的方案,更通用也更明确。用糖来混淆反而削弱目的。
而剩下关于开销的问题,本来就全是实现细节。任何一个能和既有实现划清界限的新设计都有资格来做,但是削弱不修改签名来做就可能得不偿失。反之,例如,如果用 throws 改变 throw (意味提供一个新的、可以无视兼容包袱能在运行时把更多事做对的控制操作符)而不是用 throws 改变 throw 的含义,就不见得有那么明显的问题。
这里的设计思路导致上面的主要缺陷仍然没有改变,结果也只是提供了一种更简单的新写法转移视线罢了,性价比很成问题。(特别是考虑 C++ 已经够复杂的情况下。)
另外,这种侵入式修改在 co_* 中已经表现出很大的争议性。类似的问题是过于暴露实现细节(如果不是为了允许一些固定的静态实现策略,可以直接如 P0534 一样支持非侵入的语法),而完全不足以代表所在问题域的通解。这类提案即便通过,也很难是最佳实践。( C++20 coroutine 大概还没怼赢 Clang++ 的冷屁股。)
你说开销是实现细节,对不对呢,当然对,但很多时候调用方确实有理由去关注一个 API 在不同 path 的性能表现。和类型与 Monad 能做到以多占一个寄存器的代价(先不管 go 这个用积类型的奇葩),去消除错误路径的额外开销,这时异常(unwind)的开销就成了劣势。给和类型与 Monad 提供对应的语法糖,也能解决啰嗦的问题。
至于函数签名,有人认为一个函数会不会出错应当体现在签名中,有人认为应当透明,这在各自场景里都是对的,但不能说哪边就是绝对正确。硬要无视场景区别去推行所谓“大多数”的决策,我觉得只会收到一大堆黑人问号。
但 golang 的问题在于只能约定必须先处理 err ,而无法强制必须处理 err ,相反 rust 哪怕是 unwrap 也是对 err 的处理,虽然不负责任,但至少保证了强制处理 err 。而且 golang 甚至允许既返回值也返回 err 的情况,相反 rust 的 option 和 result 就能根本上避免这个问题。所以 golang 在错误处理这一点上,比起真正先进的语言还有很大差距。
性能大多数时候都是次要的,至少会让位于功能正确性。只有后者不成问题,才考虑性能。
虽然 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 )等方式把可能出现的异常明确当成输出的一部分,而跟真正意义上的异常划清界限。这个方式也更普遍地符合对静态类型的其它使用实践。但是注意,偏离显式类型的糖就算能减少代码啰嗦程度,也是一种这种实践的弱化。
第二是让类型系统不那么静态而可选:根据用户需要,期望传播带有静态检查就影响函数类型,不需要就默认不影响。很遗憾,这需要让类型依赖阶段(以及和经典的→不同的构造器),普通的静态类型做不到这个。对大多数不能重头重造的语言就是此路不通。
假如告诉 100 年前的人如今人人都有能远隔万里就能面对面聊天的手机电脑,他们恐怕只会想到例如驿站被替代,邮差被替代之类的吧,就好像很多人觉得 AI 来了程序员就要失业了,但是…
我说这个事,只是觉得闲鱼的处理有失偏颇,原因不详,看看大家的看法,还请大家手下留情 我尽量准确客观的描述,但毕竟一面之词难免会有我没有意识到的不准确的地方,但大概的时间线就是这…
我们知道计算机的计算数据需要从磁盘调度到内存,然后再调度到L2 Cache,再到L1 Cache,最后进CPU寄存器进行计算。 给老婆在电脑城买本本的时候向电脑推销人员问到这些…