这个问题说来话长,我先表达一下我的观点,Go语言从语法层面提供区分错误和异常的机制是很好的做法,比自己用单个返回值做值判断要方便很多.
上面看到很多知乎大牛把异常和错误混在一起说,有认为Go没有异常机制的,有认为Go纯粹只有异常机制的,我觉得这些观点都太片面了.
具体对于错误和异常的讨论,我转发一下前阵子写的一篇日志抛砖引玉吧.
============================
最近连续遇到朋友问我项目里错误和异常管理的事情,之前也多次跟团队强调过错误和异常管理的一些概念,所以趁今天有动力就赶紧写一篇Go语言项目错误和异常管理的经验分享.
首先我们要理清:什么是错误、什么是异常、为什么需要管理.然后才是怎样管理.
错误和异常从语言机制上面讲,就是error和panic的区别,放到别的语言也一样,别的语言没有error类型,但是有错误码之类的,没有panic,但是有throw之类的.
在语言层面它们是两种概念,导致的是两种不同的结果.如果程序遇到错误不处理,那么可能进一步的产生业务上的错误,比如给用户多扣钱了,或者进一步产生了异常;如果程序遇到异常不处理,那么结果就是进程异常退出.
在项目里面是不是应该处理所有的错误情况和捕捉所有的异常呢?我只能说,你可以这么做,但是估计效果不会太好.我的理由是:
如果所有东西都处理和记录,那么重要信息可能被淹没在信息的海洋里.
不应该处理的错误被处理了,很容易导出BUG暴露不出来,直到出现更严重错误的时候才暴露出问题,到时候排查就很困难了,因为已经不是错误的第一现场.
所以错误和异常最好能按一定的规则进行分类和管理,在第一时间能暴露错误和还原现场.
对于错误处理,Erlang有一个很好的概念叫速错,就是有错误第一时间暴露它.我们的项目从Erlang到Go一直是沿用这一设计原则.但是应用这个原则的前提是先得区分错误和异常这两个概念.
错误和异常上面已经提到了,从语言机制层面比较容易区分它们,但是语言取决于人为,什么情况下用错误表达,什么情况下用异常表达,就得有一套规则,否则很容易出现全部靠异常来做错误处理的情况,似乎Java项目特别容易出现这样的设计.
这里我先假想有这样一个业务:游戏粉丝通过购买按钮,用铜钱购买宝石.
在这种结构下,铜钱不足就变成了业务逻辑范围内的一种失败情况,但不能提升为异常,否则铜钱不足的用户一点购买按钮都会出错掉线.
所以,异常和错误在不同程序结构下是互相转换的,我们没办法一句话的给所有类型所有结构的程序一个统一的异常和错误分类规则.
所以我们可以这样来归类异常和错误:不会终止程序逻辑运行的归类为错误,会终止程序逻辑运行的归类为异常.
错误和异常的分类需要通过一定的思维训练来强化分类能力,就类似于面向对象的设计方式一样的,技术实现就摆在那边,但是要用好需要不断的思维训练不断的归类和总结,以上提到的归类方式希望可以作为一个参考,期待大家能发现更多更有效的归类方式.
此时此刻呢我们讲一下速错和Go语言里面怎么做到速错.
速错我最早接触是在做的时候就体验到的,当然跟Erlang的速错不完全一致,那时候也没有那么高大上的一个名字,但是对待异常的理念是一样的.
在.NET项目开发的时候,有经验的程序员都应该知道,不能随便re-throw,就是catch错误再抛出,原因是异常的第一现场会被破坏,堆栈跟踪信息会丢失,因为外部最后拿到异常的堆栈跟踪信息,是最后那次throw的异常的堆栈跟踪信息;其次,不能随便try catch,随便catch很容易导出异常暴露不出来,升级为更严重的业务漏洞.
到了Erlang时期,大家学到了速错概念,简单来讲就是:让它挂.只有挂了你才会第一时间知道错误,但是Erlang的挂,只是Erlang进程的异常退出,不会导致整个Erlang节点退出,所以它挂的影响层面比较低.
在Go语言项目中,虽然有类似Erlang进程的Goroutine,但是Goroutine如果panic了,并且没有recover,那么整个Go进程就会异常退出.所以我们在Go语言项目中要应用速错的设计理念,就要对Goroutine做一定的管理.
第一类Goroutine典型的有:处理各个粉丝请求的Goroutine,因为每个粉丝连接各自有一个Goroutine,所以挂掉了只会影响单个粉丝,不会影响整体业务进行.
第二类Goroutine典型的有:数据库同步用的Goroutine,如果它挂了,数据就无法同步到数据库,游戏如果继续运行下去只会导致数据回档,还不如让整个游戏都异常退出.
这样一分类,就可以比较清楚哪些Goroutine该做recover处理,哪些不该做recover处理了.
为此,我们还特地设计了一个库,用来格式化输出堆栈跟踪信息和对象信息,项目地址:funny/debug - GitHub
通篇写下来发现比我预期的长很多,所以这里我做一下归纳总结,帮组大家理解这篇文章所要表达的:
错误和异常需要分类和管理,不能一概而论
错误和异常的分类可以以是否终止业务过程作为标准
错误是业务过程的一部分,异常不是
不要随便捕获异常,更不要随便捕获再重新抛出异常
Go语言项目需要把Goroutine分为两类,区别处理异常
在捕获到异常时,需要尽可能的保留第一现场的关键数据
以上仅为一家之言,抛砖引玉,希望大家如果觉得本站发布的文章不错,请转发分享给您身边的朋友,您的支持是我们最大的动力.
有好几次,当我想起来的时候,总是会问自己:我为什么要放弃Go语言?这个决定是正确的吗?是明智和理性的吗?其实我一直在认真思考这个问题.
开门见山地说,我当初放弃Go语言(golang),就是因为两个"不爽":第一,对Go语言本身不爽;第二,对Go语言社区里的某些人不爽.毫无疑问,这是非常主观的结论.转载
①1 不允许左花括号另起一行
①10 许多语言内置设施不支持用户定义的类型
①11 没有泛型支持,常见数据类型接口丑陋
此篇文章流传甚广, 其实里面没啥干货, 而且里面很多观点是有问题的. 这个文章在 golang-china 很早就讨论过了.
最近因为 Rust 1.0 和 1.1 的发布, 导致这个文章又出来毒害读者.
所以写了这篇反驳文章, 指出其中的问题.
开门见山地说,我当初放弃Go语言(golang),就是因为两个"不爽":第一,对Go语言本身不爽;第二,对Go语言社区里的某些人不爽.毫无疑问,这是非常主观的结论.但是我有足够详实的客观的论据,用以支撑这个看似主观的结论.
文末附有本文更新日志.
确实是非常主观的结论, 因为里面有不少有问题的观点(用来忽悠Go小白还行).
第0节:我的Go语言经历
先说说我的经历吧,以避免被无缘无故地当作Go语言的低级黑.
目前, 真正的Go用户早就将Go用于实际的生产了.
说到 其语法中的分号和花括号不满, 我想说这只是你的 个人主观感受, 还有很多人对Go的分号和花括号很满意,
包括水果公司的的 Swift 的语言设计者也很满意这种风格(Swift中的分号和花括号和Go基本相同).
如果只谈 个人主观感受, 我也可以说 Rust 的 fn 缩写也很蛋疼!
真的不清楚楼主说的可以在 Go1.0 之前短时间内能实现的 重大改进和诸多明显缺陷 是什么.
如果是楼主说前面的 其语法中的分号和花括号不满 之类的重大改进, 我只能说这只是你的 个人主观感受 而已,
Go 1.1的Release Note,发现语言层面没有太大改变. 语言层没有改变是是因为 Go1 作出的向后兼容的承诺. 对于工业级的语言来说, Go1 这个只能是优点. 如果连语言层在每个版本都会出现诸多大幅改进, 那谁还敢用Go语言来做生产开发呢(我承认Rust的改动很大胆, 但也说明了Rust还处于比较幼稚和任性的阶段)?
说 Go语言社区里的某些人固执 的观点我是同意的. 但是这些 固执 的人是可以讲道理的, 但是他们对很多东西的要求很高(特别是关于Go的设计哲学部分).
只要你给的建议有依据(语言的设计哲学是另外一回事情), 他们绝对不会盲目的拒绝(只是讨论的周期会比较长).
关于楼主提交的给Go文件添加BOM的文章, 需要补充说明下.
我觉得对于写Go程序来说, 这个限制是没有任何问题的, 到目前为止, 我还从来没有使用过带BOM的.go文件.
不仅是因为带BOM的.go文件没有太多的意义, 而且有很多的缺陷.
但是, 在现实中, 因为MS的txt记事本, 对于中文环境会将txt(甚至是C/C++源文件)当作GBK编码(GBK是个烂编码),
所以, 我觉得Go用户不用纠结BOM这个无关紧要的东西.
在上一个10年,我(Liigo)在我所属的公司里,深度参与了两个编程语言项目的开发.我想,对于如何判断某个编程语言的优劣,或者说至少对于如何判断某个编程语言是否适合于我自己,我应该还是有一点发言权的.
第1节:我为什么对Go语言不爽?
Go语言有很多让我不爽之处,这里列出我现在还能记起的其中一部分,排名基本上不分先后.读者们耐心地看完之后,还能淡定地说一句"我不在乎"吗?
关于对花括号的摆放,在C语言、C++、Java、C#等社区中,十余年来存在持续争议,从未形成一致意见.在我看来,这本来就是主观倾向很重的抉择,不违反原则不涉及是非的情况下,不应该搞一刀切,让程序员或团队自己选择就足够了.编程语言本身强行限制,把自己的喜好强加给别人,得不偿失.无论倾向于其中任意一种,必然得罪与其对立的一群人.虽然我现在已经习惯了把左花括号放在行尾,但一想到被禁止其他选择,就感到十分不爽.Go语言这这个问题上,没有做到"团结一切可以团结的力量"不说,还有意给自己树敌,太失败了.
我觉得Go最伟大的发明是 go fmt, 从此Go用户不会再有花括弧的位置这种无聊争论了(当然也少了不少灌水和上tiobe排名的机会).
对Go语言本身而言,行尾的分号是可以省略的.但是在其编译器(gc)的实现中,为了方便编译器开发者,却在词法分析阶段强行添加了行尾的分号,反过来又影响到语言规范,对"怎样添加分号"做出特殊规定.这种变态做法前无古人.在左花括号被意外放到下一行行首的情况下,它自动在上一行行尾添加的分号,会导致莫名其妙的编译错误(Go 1.0之前),连它自己都解释不明白.如果实在处理不好分号,干脆不要省略分号得了;或者,Scala和JavaScript的编译器是开源的,跟它们学学怎么处理省略行尾分号可以吗?
又是楼主的 个人主观感受, 不过我很喜欢这个特性. Swift 语言也是类似.
编译速度是很重要的, 如果编译速度够慢, 语言再好也不会有人使用的.
比如C/C++的增量编译/预编译头文件/并发编译都是为了提高编译速度.
当然, Go刚面世的时候, 编译速度是其中的一个设计目标.
不过我想楼主, 可能想说的是因为编译器自己添加分号而导致的编译错误的问题.
我觉得Go中 { 不能另起一行是语言特性, 如果修复这个就是引入了新的错误.
其他的我真想不起来还有哪些 调编译速度,不惜放弃本应提供的功能 (不要提泛型, 那是因为还没有好的设计).
在Go语言中处理错误的基本模式是:函数通常返回多个值,其中最后一个值是error类型,用于表示错误类型极其描述;调用者每次调用完一个函数,都需要检查这个error并进行相应的错误处理:if err != nil { /*这种代码写多了不想吐么*/ }.此模式跟C语言那种很原始的错误处理相比如出一辙,并无实质性改进.实际应用中很容易形成多层嵌套的if else语句,可以想一想这个编码场景:先判断文件是否存在,如果存在则打开文件,如果打开成功则读取文件,如果读取成功再写入一段数据,最后关闭文件,别忘了还要处理每一步骤中出现错误的情况,这代码写出来得有多变态、多丑陋?实践中普遍的做法是,判断操作出错后提前return,以避免多层花括号嵌套,但这么做的后果是,许多错误处理代码被放在前面突出的位置,常规的处理逻辑反而被掩埋到后面去了,代码可读性极差.而且,error对象的标准接口只能返回一个错误文本,有时候调用者为了区分不同的错误类型,甚至需要解析该文本.除此之外,你只能手工强制转换error类型到特定子类型(静态类型的优势没了).至于panic - recover机制,致命的缺陷是不能跨越库的边界使用,注定是一个半成品,最多只能在自己的pkg里面玩一玩.Java的异常处理虽然也有自身的问题(比如Checked Exceptions),但总体上还是比Go的错误处理高明很多.
话说, 软件开发都发展了半个世纪, 还是无实质性改进. 不要以为弄一个异常的语法糖就是革命了.
正因为有异常这个所谓的银弹, 导致很多等着别人帮忙擦屁股的行为(注意 shit 函数抛出的绝对不会是一种类型的 shit, 而被其间接调用的各种 xxx_shit 也可能抛出各种类型的异常, 这就导致 catch 失控了):
int main() {
try {
shit();
} catch( /* 到底有几千种 shit ? */) {
...
}
Go的建议是 panic - recover 不跨越边界, 也就是要求正常的错误要由pkg的处理掉.
这是负责任的行为.
再说Go是面向并发的编程语言, 在海量的 goroutine 中使用 try/catch 是不是有一种不伦不类的感觉呢?
而且这个问题早就不存在了(大家可以去看Go的发布日志).
关于GC的被人诟病的地方是会导致卡顿, 但是我以为这个主要是因为GC的实现还不够完美而导致的.
如果是完美的并发和增量的GC, 那应该不会出现大的卡顿问题的.
当然, 如果非要实时性, 那用C好了(实时并不表示性能高, 只是响应时间可控).
对于Rust之类没有GC的语言来说, 想很方便的开发并发的后台程序那几乎是不可能的.
不要总是吹Rust能代替底层/中层/上层的开发, 我们要看有谁用Rust真的做了什么.
Go编译器不允许存在被未被使用的变量和多余的import,如果存在,必然导致编译错误.但是现实情况是,在代码编写、重构、调试过程中,例如,临时性的注释掉一行代码,很容易就会导致同时出现未使用的变量和多余的import,直接编译错误了,你必须相应的把变量定义注释掉,再翻页回到文件首部把多余的import也注释掉,......等事情办完了,想把刚才注释的代码找回来,又要好几个麻烦的步骤.还有一个让人蛋疼的问题,编写数据库相关的代码时,如果你import某数据库驱动的pkg,它编译给你报错,说不需要import这个未被使用的pkg;但如果你听信编译器的话删掉该import,编译是通过了,运行时必然报错,说找不到数据库驱动;你看看程序员被折腾的两边不是人,最后不得不请出大神:import _.对待这种问题,一个比较好的解决方案是,视其为编译警告而非编译错误.但是Go语言开发者很固执,不容许这种折中方案.
这个问题我只能说楼主的吐槽真的是没水平.
为何不使用的是错误而不是警告? 这是为了将低级的bug消灭在编译阶段(大家可以想下C/C++的那么多警告有什么卵用).
而且, import 即使没有使用的话, 也是用副作用的, 因为 import 会导致 init 和全局变量的初始化.
如果某些代码没有使用, 为何要执行 init 这些初始化呢?
如果是因为调试而添加的变量, 那么调试完删除不是很正常的要求吗?
如果是因为调试而要导入fmt或log之类的包, 删除调试代码后又导致 import 错误的花,
楼主难道不知道在一个独立的文件包装下类似的辅助调试的函数吗?
import (
"fmt"
"log"
)
func logf(format string, a ...interface{}) {
file, line := callerFileLine()
fmt.Fprintf(os.Stderr, "%s:%d: ", file, line)
fmt.Fprintf(os.Stderr, format, a...)
func fatalf(format string, a ...interface{}) {
os.Exit(1)
import _ 是有明确行为的用法, 就是为了执行包中的 init 等函数(可以做某些注册操作).
将警告当作错误是Go的一个哲学, 当然在楼主看来这是白痴做法.
创建对象的方式,调用new函数、调用make函数、调用New方法、使用花括号语法直接初始化结构体,你选哪一种?不好选择,因为没有一个固定的模式.从实践中看,如果要创建一个语言内置类型(如channel、map)的对象,通常用make函数创建;如果要创建标准库或第三方库定义的类型的对象,首先要去文档里找一下有没有New方法,如果有就最好调用New方法创建对象,如果没有New方法,则退而求其次,用初始化结构体的方式创建其对象.这个过程颇为周折,不像C++、Java、C#那样直接new就行了.
C++的new是狗屎. new导致的问题是构造函数和普通函数的行为不一致, 这个补丁特性真的没啥优越的.
我还是喜欢C语言的 fopen 和 malloc 之类构造函数, 构造函数就是普通函数, Go语言中也是这样.
C++中, 除了构造不兼容普通函数, 析构函数也是不兼容普通函数. 这个而引入的坑有很多吧.
没有构造函数还好说,毕竟还有自定义的New方法,大致也算是构造函数了.没有析构函数就比较难受了,没法实现RAII.额外的人工处理资源清理工作,无疑加重了程序员的心智负担.没人性啊,还嫌我们程序员加班还少吗?C++里有析构函数,Java里虽然没有析构函数但是有人家finally语句啊,Go呢,什么都没有.没错,你有个defer,可是那个defer问题更大,详见下文吧.
Go语言设计defer语句的出发点是好的,把释放资源的"代码"放在靠近创建资源的地方,但把释放资源的"动作"推迟(defer)到函数返回前执行.遗憾的是其执行时机的设置似乎有些不甚合理.设想有一个需要长期运行的函数,其中有无限循环语句,在循环体内不断的创建资源(或分配内存),并用defer语句确保释放.由于函数一直运行没有返回,所有defer语句都得不到执行,循环过程中创建的大量短暂性资源一直积累着,得不到回收.而且,系统为了存储defer列表还要额外占用资源,也是持续增加的.这样下去,过不了多久,整个系统就要因为资源耗尽而崩溃.像这类长期运行的函数,http.ListenAndServe()就是典型的例子.在Go语言重点应用领域,可以说几乎每一个后台服务程序都必然有这么一类函数,往往还都是程序的核心部分.如果程序员不小心在这些函数中使用了defer语句,可以说后患无穷.如果语言设计者把defer的语义设定为在所属代码块结束时(而非函数返回时)执行,是不是更好一点呢?可是Go 1.0早已发布定型,为了保持向后兼容性,已经不可能改变了.小心使用defer语句!一不小心就中招.
前面说到 defer 还有其他的任务, 也就是 defer 中执行的 recover 可以捕获 panic 抛出的异常.
还有 defer 可以在 return 之后修改命名的返回值.
Go中的defer是以函数作用域作为触发的条件的, 是会导致楼主说的在 for 中执行的错误用法(哪个语言没有坑呢?).
不过 for 中 局部 defer 也是有办法的 (Go中的defer是以函数作用域):
for {
func(){
f, err := os.Open(...)
defer f.Close()
}()
在 for 中做一个闭包函数就可以了. 自己不会用不要怪别人没告诉你.
说到底, 这个是因为对泛型支持的不完备导致的.
Go语言是没啥NB的特性, 但是Go的特性和工具组合在一起就是好用.
这就是Go语言NB的地方.
没有泛型的话,List、Set、Tree这些常见的基础性数据类型的接口就只能很丑陋:放进去的对象是一个具体的类型,取出来之后成了无类型的interface{}(可以视为所有类型的基础类型),还得强制类型转换之后才能继续使用,令人无语.Go语言缺少min、max这类函数,求数值绝对值的函数abs只接收/返回双精度小数类型,排序接口只能借助sort.Interface无奈的回避了被比较对象的类型,等等等等,都是没有泛型导致的结果.没有泛型,接口很难优雅起来.Go开发者没有明确拒绝泛型,只是说还没有找到很好的方法实现泛型(能不能学学已经开源的语言呀).现实是,Go 1.0已经定型,泛型还没有,那些丑陋的接口为了保持向后兼容必须长期存在着.
Go有自己的哲学, 如果能有和目前哲学不冲突的泛型实现, 他们是不会反对的.
如果只是简单学学(或者叫抄袭)已经开源的语言的语法, 那是C++的设计风格(或者说C++从来都是这样设计的, 有什么特性就抄什么), 导致了各种脑裂的编程风格.
编译时泛型和运行时泛型可能是无法完全兼容的, 看这个例子:
type AdderT interface {
Add(a, b T) T