为编程语言设计怎样的错误处理方式才是“好的”_-invalid s
简单说:错误处理是程序员的事,是系统/架构/模块设计师的事,不是编程语言的事 。
举例来说,C大概是错误处理领域“最垃圾”的语言了——它压根就没有异常,而是用含混不清的返回值、用全局共享随时扯淡的errno来表示错误。
那么,这种语言是不是不能要了?是不是应该淘汰掉、换新的了?
恰恰相反,最复杂、最困难的操作系统内核、驱动、数据库……这些全都得用C。
原因很简单,这种语言清晰、明确,只要你水平到了,计算机的一切都在你的掌控之下。
只要你头脑不糊涂,一切都是清晰明朗的——好的程序员“写明显没有错误的程序,不要写没有明显错误的程序”:这事,C++、Java其实是做不到的。太多因为自动化而隐晦不明的东西了,比如C++的自动类型转换、Java的层层委托……这时候,不仔细看上下文,是做不到C一样“瞟一眼就知道是想做什么”的。
想要搞明白“什么是好的错误处理方式”,首先要做的并不是“考察各种语言的异常方案”,那就太工具太细节了;正确做法是,让我们离远一点,看看究竟什么是“异常” 。


如上是我从网上找到的一份合同范本。
我们可以看到,这份合同规定了双方的权利和义务、明确了双方责任;也规定了诸如经营损失、法律阻止等“不可抗力”如何应对等条款。
我们可以把这些条款分为两类。
第一类是权利-义务关系。比如甲方必须尽哪些义务、享有哪些权利;乙方必须尽哪些义务、享有哪些权利。
第二类是异常处理情况。比如市场风险、法律风险等等如何发现、如何报告、如何应对,等等。
换句话说,第一类关系出现问题,这就是“错误”,是要立即抓到责任人、责成他修正、赔偿的。
而第二类关系出现问题,这是“不可抗力”,也就是“异常”,是要尽快报告、提交到更高层讨论处理的。
这个东西,其实正是“结构化异常”的立足之本。
这个东西的萌芽——或者说,激进的愿景——就是Eiffel倡导的“编程契约论Design by Contract”。
这个想法是如此的激进,以至于十几年前已经提出了、但到了今天,能理解的人仍然寥寥无几:
浅谈契约式编程 – 简明现代魔法 (nowamagic.net)
当我们尝试站在契约的层面——而不是函数的层面——整体看待程序设计问题时,就会发现问题是那么的理所当然。
我们会和甲方签合同。合同会规定我们在什么情况下必须完成任务、保证多高的可用概率;而什么样的东西是“不可抗力”:比如火山、地震甚至同时被核打击了多少个核心机房——只有遭遇了不可抗力,软件撂挑子才是合乎合同规定的。
然后,我们会划分模块,为不同模块设定接口。这实质上也是在“签署合同”——对于主控模块,它应该保证自己安排任务的逻辑性、保证参数的有效性;对被控模块,只要给的任务符合逻辑、参数有效,它就必须完成任务,不得有丝毫含糊。
当然,除了网络中断、网卡损坏、机房受到恐怖袭击等等“不可抗力”。
继续的,对于每个函数,我们可以把它分为调用者caller和被调用者callee;那么调用者就是甲方、被调用者就是乙方。调用者必须保证自己传递给callee的参数的有效性、确保它符合设定逻辑;而被调用者必须100%的成功完成任务,绝不容半丝含糊——除非,遭遇了“不可抗力”。
换句话说,但凡是“应该放进bug列表、需要尽快修复或者确定时间修复的”,这些都是错误,是bug,是程序员或者架构设计者的责任;而那些可以理直气壮的说“打死都不改”“打死也改不了”的,那就是异常了。
当然,也有介于两者之间的。比如本版不处理、下个版本修复的:这种在本版是异常,下个版本就是错误了。
你看,清晰,精确,权责分明,对吧。
好了,当我们有了这样的正确认识之后,回头再看各门编程语言……
看出问题了吗?
没错。从契约的角度看,最优秀的编程语言反而是C……
原因很简单,C里面,“错误”和“异常(不可抗力)”是分明的。
在C里面,我们用assert来检查错误——我是被调用者,我断言(assert)调用者传给我的参数必须如此如此、这般这般;如果不符合,那么我立即撂挑子不干:你都没有尽到你的责任,凭什么要我正常工作!
同时,我们用“返回值(错误码)”来提示异常——我自己肯定不会返还非0/负数的,只要你给我的参数是正确的;但由于不可抗力,比如网络故障、内存耗尽,我不得不用非0或者错误码来向你报告:不是我不努力,这事……神仙都没辙!
你看,简洁,清晰,对吧。
相比之下,Java的异常机制就是不折不扣的垃圾。
这样说其实有点不公平。其实Java的初心是极好的:错误必须全部消除,所以函数返回就没有错误,必然是有效值,不要用错误码污染它的返回;只有异常可以通过try-catch-throw机制传递。
你看,清晰,分明,对吧。
但,坏就坏在,它把这个做成强制性的了。
举例来说,我要做一个字符串cache;用户用到的字符串优先放在内存里;如果内存不够用了,我就把它们按“最近最少使用原则”存储到磁盘,腾出空间继续提供cache服务……
因此,我们在里面使用了一个“内存池”;当内存池空间不足时,我们会把部分字符串存入磁盘、然后把它们占用的空间归还给内存池、然后再从内存池拿出空间来迎接新的字符串……
那么,很显然的,我们这个cache是不允许报告OOM(out of memory)的。因为从逻辑上就不存在OOM的可能——只可能disk full,不可能OOM。
但是,内存池的实现却决定了,它必须把OOM这个异常声明在自己的throw列表里。因为它真的可能遭遇OOM——这和我们的使用方式相悖。
那么,我们这个cache类,应该是“仅在初始化或者resize时抛OOM异常,其他时候绝不会抛这个异常”,对吧?这是不是就限定了我们的接口实现方式?
更进一步的,另一个组合了cache类的类,它应该在什么时候抛OOM异常呢?是否还要继续绑定接口实现方式、从而使得initial和resize接口和内部cache对应?
不绑定?不绑定,那么OOM就是乱来的!有些接口、有些使用方式根本就不可能抛这个异常、语言却强迫调用者必须处理它——但他们根本就不可能知道这个异常是真的异常、还是压根不可能出现的!
这个问题的本质就在于,在一些合同中的“不可抗力”,在另一些合同中就是错误!
没错。
供货商的确可能因为原料、运输、工人罢工、环境变化而违约;但作为采购科负责人的你,我不是告诉你了吗?鸡蛋不要放一个篮子!在你这里,除非全国某种原料同时短缺,不允许出现什么缺乏原料、原料质量不稳定问题!我养你干什么吃的!
甚至,我让你做的就是异地容灾,单个地区的地震火山对这个方案来说什么都不是!合同写的清清楚楚,1分钟内你必须给我自动切换、系统功能不得受到影响——你解决不了这个问题我另请高明,凭什么把你的错误混进“不可抗力”条款试图免责!
换句话说,我们需要清晰的区分错误和异常——在合同中规定什么叫错误什么叫违约以及什么叫不可抗力;而Java呢,却致力于混淆两者!
没错。C语言把“异常”混进返回值、污染返回值值域,这是非常糟糕的实践;但这只是“小节”;在更大的层面,它的的确确严格区分了错误和异常——遇到错误就一个assert崩掉,绝无半点妥协!
可Java呢?
它的确捡了“返回值和异常区分开”这个芝麻,却丢了“区分错误和异常”这个西瓜!
至于C++……那破玩意儿还不如Java。
复杂系统,诸多精英一通折腾,搞了个精巧无比、看起来棒极了的机制,结果却还不如不搞——还不如返璞归真——这事司空见惯。
可见,自题主提出“错误处理”这个问题起,错误已经铸就。
呼应开头:错误处理是程序员的事,是系统/架构/模块设计师的事,不是编程语言的事 。
事实上,函数/模块返回的东西/抛出的异常,是不能笼统的叫什么“错误处理的”——错误是错误,异常是异常。
这两个都分不清,那就别往下谈了。不会有结果的。
我们程序员的责任,就是把一份份合同拟好、区分清楚错误和异常;然后对“违反合同”的“错误”重拳出击,绝不姑息——这就是我在很多帖子里反复强调,遇到了错误的、不合理的参数,就写个assert让程序尽快崩掉的原因。只有这样才能写出稳定的软件。
同时,我们的UI/接口设计师的责任,则是“雇佣一个好的客服(写一个友好的界面)”,耐心的给用户(甲方)解释“xxx是不可抗力,(所以)我们实在无能为力”。
异常系统就对应于客服,错误系统对应于assert以及语法检查等语言特性。
C的缺点,就是它本身的语法检查不够严格、智能,甚至它本身都算不上强类型。这就使得它的assert功能薄弱、使得很多错误难以检测;但只要你别作死、别用“容错性代码/防御性代码”把错误混进异常,这些错误还是比较容易通过测试发现的——因此它可以胜任几乎所有支柱性项目。
而Java/C的长处是,它们本身是强类型的,可以发现更多错误;但它们的缺陷却是“把异常系统设计成了屎、帮助很多错误混进了异常、逃避了合同约束”。这些缺陷是极难避免甚至无法规避的;甚至有时候,这种错误是以某些流行库为源头开始污染的,可谓防不胜防——这是Linus当年痛骂C的根本原因、也是至今Linux kernel拒不接受C++、却向Rust抛出橄榄枝的原因之一。
一门语言的设计者、使用者越是能清晰的区分错误和异常,这门语言就越是健康和稳固;否则,就只会白白的凭空制造很多垃圾,白白增加使用者的心智负担。
换句话说:我们更应该关注的,是如何清晰的区分和标注“错误”和“异常”,而不是把错误和异常混为一谈、反而跑过去和值域辩经。
或者说,我们真正需要关注的问题有两个,一是区分错误和异常;二是“如何把异常弄的不可忽略”——把异常报告混进函数返回值,最大的问题其实就是“傻X程序员不检查返回值、导致遗漏了异常报告”。
C的问题是无法应对第二个问题,也就是“无法强制调用者检查返回值”;而Java、C++引入的异常这个概念解决了第二个问题——然而与之同时,异常机制的引入,却使得问题一难以解决甚至无法解决了。
对于问题二,其实只要引入code review甚至自动的代码风格检查,就可以自动报告“未接收和处理返回值”问题;但问题一,一旦你从源头就搞乱了,那就完全无解。
评论区
傻乎乎: 现实的场景是:当问题(需求)都没明晰,就要求实现出来,然后现实告诉你想太少了;接下来则是开发太菜。确实菜啊,有模糊的地方为啥不“抗议“?搞得不清不楚不就适合背锅[doge] 👍🏽20 💭新加坡 🕐2023-03-25 15:01:15
│ └── invalid s: 哈哈,事先不说清楚,出事了背锅,这就是所谓的“冤大头”。 👍🏽11 💭广东 🕐2023-03-25 15:04:59
知乎用户JyIPuK: [爱]作为一个法律人转码,我终于知道我为什么不喜欢java那种错误处理方式了,哈哈哈哈哈 👍🏽6 💭广东 🕐2023-03-25 19:54:34
遗迹: C同样没有好好的解决第一个问题,Java如果你愿意书写详尽的文档,正确利用 Exception/Error,第一个问题是可以解决的(C中你所说的解决不外如是),当然Java的异常层次和受检非受检异常乱得一塌糊涂这是事实 👍🏽5 💭北京 🕐2023-03-25 22:27:03
│ └── 遗迹: 这并不是语言该去解决的问题 👍🏽0 💭北京 🕐2023-03-25 22:28:19
soluty: 合约和不可抗力既然是双方约定的,但是在函数签名中确没法体现,甲方只有看源码才知道有多少assert 👍🏽5 💭上海 🕐2023-03-25 16:56:01
│ └── invalid s: 是的。这就是为什么要有接口文档了。另外,哪怕看到了assert,很多约束也不是一下子就能搞明白的。所以也有一些人专注于“如何用编译器语法检查识别约束”。举例来说,Qt的signal-slot-bind机制其实就是为了实现编译期合法性检查,这才不得不魔改c++…… 👍🏽3 💭广东 🕐2023-03-25 18:40:21
│ └── soluty: 接口文档几乎都是返回错误码,比如http的4xx代表参数错误,也就是你文章中的合同错误,没见过直接崩的,5xx是你说的异常,错误和异常又统一起来了 👍🏽1 💭上海 🕐2023-03-27 22:21:22
│ └── invalid s: 命名混乱是客观现实,但不要让它带乱你的脑子。 👍🏽1 💭广东 🕐2023-03-28 12:43:54
蒋甬杭: go 的类型检查不错,只要永远别 recover 就没事。我怀疑 recover 是为了应付某些随便 panic 的第三方包而设计的。 👍🏽1 💭浙江 🕐2023-03-25 16:03:45
还差一百二: 用assert检查错误,是强制非debug版本不关闭assert吗? 👍🏽1 💭浙江 🕐2023-03-26 03:23:55
│ └── invalid s: 那要看你对自己的测试流程有多大信心了。 👍🏽1 💭广东 🕐2023-03-26 17:13:43
│ └── AllenChen: 碰到性能问题时,我才会改release,个人习惯 👍🏽1 💭江苏 🕐2023-03-28 16:07:34
Yunxg15643: 作者的意思是“不要把错误当作异常抛出”,尤其是java;因为java有这种先天缺陷(允许你这么干),致使很多偷懒的程序员不管是业务上的逻辑错误、访问NULL、还是例如真正的异常:例如除零,都将视为异常,然后再统一AOP拦截;这是一切混乱的根源,我实际在几个大型项目中观察到了,这是为什么人们老加班。你说你为这个事加班值当吗?所以自从用了java开发项目以后,我从不允许加班,这个事情本身一开始就是烂的,那就一直烂下去好了。 👍🏽1 💭河北 🕐2025-01-09 15:52:07
│ └── Yunxg15643: 再鄙视一下所谓的“java开发规范”,这是一个极其荒谬的事,“因为这个工具长满了刺,所以大家最好避开,但你不避开也能干活”,还有在其上衍生的代码检查工具,还不觉得丢人吗? 👍🏽1 💭河北 🕐2025-01-09 15:55:43
Gavin: 说的不对,编程语言是一种工具。工具当然是越好用越好。好的工具完全可能改变一个行业的现状。优秀的人当然能解决好问题,但是这个世界最缺的就是优秀的人。这也是工业化的优势。作者的说法可以说是完全反潮流的,和计算机软件工业的发展可以说是背道而驰。[捂脸] 👍🏽1 💭陕西 🕐2023-03-30 22:54:18
渺若星辰: rust也是像c一样区分错误和异常的吗? 👍🏽2 💭广东 🕐2023-03-25 17:01:34
天才在于积累: 在C里面,我们用assert来检查错误——我是被调用者,我断言(assert)调用者传给我的参数必须如此如此、这般这般;如果不符合,那么我立即撂挑子不干:你都没有尽到你的责任,凭什么要我正常工作!同时,我们用“返回值(错误码)”来提示异常——我自己肯定不会返还非0/负数的,只要你给我的参数是正确的;但由于不可抗力,比如网络故障、内存耗尽,我不得不用非0或者错误码来向你报告:不是我不努力,这事……神仙都没辙! 请问,参数错误不应该是异常么?有些疑惑 👍🏽2 💭上海 🕐2023-11-22 01:27:54
│ └── yuantj: 假设我接收一个文件标识符作为参数,如果因为权限不足、硬件问题无法读取,甚至是文件不存在,这都可以是可以处理的异常。但你给我传过来个null算怎么回事?这就是错误了。 👍🏽1 💭上海 🕐2024-09-14 23:17:17
超构造体: [赞同]错误处理是设计的问题,不是语言语法能随意解决的 👍🏽1 💭湖南 🕐2023-04-04 22:37:38
朱元: 问题是,,NDEBUG编译通常会让assert失效啊,其实只要禁止catch(…)这类使用,C++/JAVA的异常也可以实现(真正的)assert的效果呢, 例如这个std::vector<T,Allocator>::at - cppreference.com确实做到了检查的同时也给你提供了你不维护异常安全或catch异常的情况下终止进程的选项:想必没什么人会去catch std::out_of_range 异常吧。 👍🏽0 💭新加坡 🕐2024-06-04 21:02:38
│ └── yuantj: 我理解作者的意思是,“错误”是不应该出现的,是可以在编写期间避免的,但是程序难免有bug,所以在debug版本assert,尽快让程序崩掉,把这类不该出现的,可以修复的bug揪出来。但是到了release的时候,为了性能就会停用assert,此时再出现错误就是“未定义行为”了。但实际上嘛,谁知道顾客会不会点炒饭,点完炒饭之后酒吧会炸掉还是真的会莫名其妙端出来一盘炒饭过来。 👍🏽1 💭上海 🕐2024-09-14 23:22:55
│ └── 朱元: 这种东西就是一个工程上的选择,和语言扯不上关系。其次并不是所有程序都是无状态的、快速重启没代价的。 👍🏽0 💭新加坡 🕐2024-09-14 23:59:57
暮无井见铃: C++ 在类型强弱上应该算是比 C 强点但不多。[捂脸] 👍🏽0 💭广东 🕐2024-05-29 16:32:51
憶記: 您好,nowamagic那个网站已经不能访问了啊[大哭]还有哪些渠道可以比较好了解契约式编程呢? 👍🏽0 💭广东 🕐2023-11-06 22:42:29
顾木头: 哇,好厉害 👍🏽0 💭湖南 🕐2023-03-26 16:34:31
brightlamp: 您好,如果C++项目规定错误用assert,异常用try可以吗? 👍🏽0 💭广东 🕐2023-03-25 19:31:18
│ └── invalid s: 很难。因为异常是会扩散的;一旦用了异常,项目组往往就会习惯于“只解决自己拿的准的”甚至“需求/领导清楚要求catch的”,然后放任异常流窜、直到高层模块忍无可忍、怎么都找不完所有的异常抛出点、搞明白所有异常的来龙去脉,只好catch all。相反,错误码想要继续传递就很麻烦,就必须考虑“怎么把它和我自己的其他错误码结合起来继续传递”,这时候就会倾向于尽量就地解决,解决不了再继续传递——事实上,很多异常,本模块就是最佳解决位置。比如说,网络不通,你在底层连接模块处理,简单,清晰;但你不管,往上一直扔一直扔,从网络不通变成了“发送失败”再变成“缓冲区耗尽”,那基本就无法解决了。换句话说,异常的本意是“解决自己能解决的,不能解决的尽快上报、不要隐瞒错误”;但在实践中,每个人都想偷懒,都想敷衍塞责,异常就成了“嘿嘿我才不管炸谁手里谁倒霉”的玩意儿。 👍🏽12 💭广东 🕐2023-03-25 19:39:06
│ └── brightlamp: 非常感谢答疑!也就是说其实C其实没问题,有问题是异常机制是毒品,不能让人类使用。 👍🏽1 💭广东 🕐2023-03-25 19:52:41
│ │ └── invalid s: 主要是“理想”和“现实”总有差距。“想的很美”的东西往往一落实到现实就变得丑陋…… 👍🏽2 💭广东 🕐2023-03-25 22:44:13
│ │ └── brightlamp: 我平时主写Python,中小型短平快微服务项目居多,C主要用在给设备写客户端。Python中习惯性随意抛异常,最后通过日志打印的堆栈找到抛异常的位置。我这样用Python异常,以后会有什么无法挽回的技术债务吗? 👍🏽0 💭广东 🕐2023-03-25 23:15:31
│ │ └── invalid s: python项目一般较小、较简单;随意抛异常反而是一种好习惯——只要别随意的catch all然后忽略掉…… 👍🏽0 💭广东 🕐2023-03-25 23:23:42
│ │ └── brightlamp: 非常感谢答疑!写代码遇到参数不对就抛异常,网络框架主入口有一个catch all然后打日志,其他地方不允许catch all。我们用C++收集设备数据,Python写数据整理分析和报告。 👍🏽0 💭广东 🕐2023-03-25 23:55:40
│ └── Eox42: “只解决拿得准的”听起来不是优点吗?该解决的地方不解决,改成错误码一样可以偷懒不解决嘛。感觉这件事问题不是出在异常扩散,而是遇到了异常不知道发生在哪的问题。 👍🏽0 💭美国 🕐2023-03-30 01:09:33