我';我已经在脑后胡思乱想了很长时间,现在是我完美的应用程序语言。有一些语言我或多或少都喜欢,但我仍然相信另一种语言可能更适合我想写的软件1,我有很多想法,关于它将包括哪些功能,它将省略哪些功能,如何编写,等等。
我对现有语言的一个不满是,我认为它们都不能很好地处理错误。格雷登·霍尔';What’很好的博客文章下一步是什么?谈到编程语言未来可能会去的地方,他指出的一件事是我们';我有各种各样的方法,但没有一种感觉明显是对的。我非常同意,我不';我不想假装我';我们在这里“解决”了错误处理。
然而,我';我有一个假设,关于在我完美的应用程序语言中我可能需要什么样的错误处理系统,我';我从来没有完整地写过。我';我没有想象一个全新的错误处理系统:相反,我';我想象着用一些已经尝试过的想法,并以一种新颖的方式将它们结合起来,希望能将它们的优点和缺点结合起来。为了解释这一假设特征,我';我将把它分解为两种语言设计假设:
第一个假设:静态声明函数可以抛出的可能异常列表,比如Java';S检查异常或C++异常规范,只要它们可以被推断,不需要由程序员明确列出,就可以是一个很好的特性。
第二个假设:我们可以通过实现一个受Common Lisp#39启发的静态类型推断条件系统,创建一个极其灵活和强大的错误处理机制;s条件系统,将错误条件的静态推断与重新启动的静态推断耦合起来。
这篇文章的其余部分将以片段的形式解释它们。我还应该指出,这一切都是假设性的:我没有';我没有实现这一点,我也没有';I don’我不相信任何编程语言功能都是正当的,除非它在其预期领域的至少中等规模的程序中得到证明。(事实上,我将要讨论的一个特性在小片段中似乎很好,而在大型程序中却非常糟糕。)也就是说,它';这是一个假设I';我想测试一下,因为我怀疑有一个功能,比如我';我将要描述的将是我通常编写的各种程序和库的一大福音!
我觉得是';如果说检查过的异常在理论上听起来可能很有吸引力,但在实践中却非常糟糕,这可能是相当无可争议的。想法很简单:如果每个方法都列举了可能引发的异常,那么可以静态地验证是否检查了所有错误路径。如果我调用一个没有声明任何异常的方法foo(),那么我肯定会赢';我不需要抓住任何一个!如果我查看源代码,发现foo()抛出了该异常,那么我肯定需要处理该异常!如果我不';我不能对那个异常做些什么:我要么需要显式地捕捉它,要么需要通过向我的方法中添加抛出那个异常来显式地传递它。知道所有的错误路径都得到了处理,我可以轻松入睡!
…当然,除了现在你需要在你的整个该死的代码库中一次又一次地复制和粘贴这个异常,这个代码库非常嘈杂,没有人愿意这么做。(更不用说我可能想要抛出的六个或更多例外了!)在实践中,Java程序员普遍认为检查异常绝对不值得:要么他们随意地在所有方法周围抛出泛型和无帮助的抛出异常,要么他们捕获任何异常而不费心辨别以消除警告,要么他们使用运行时错误,而这些错误不';不需要包含在类型中。大型代码库的研究结果坚定地表明,异常规范会增加工作强度,但不会';不会显著影响安全性。
但我怀疑问题不在于';他们不能';t在某些方面提高安全性:问题在于,它们所造成的工作量与您所获得的安全性不成比例,而且它们往往会促使程序员以最小化工作量的方式使用它们,从而也会最小化安全性。那么,如果我们摆脱了辛劳呢?
让';让我们设想一种假设的编程语言,但有例外:语法不是';不重要,所以我';我将借用handwavey Rust的语法,但我必须清楚,下面所有的例子都是虚构的,都是虚构的非锈语言。我在这里想象的是,方法的默认方式可能如下所示:
//虚构语言fn find(haystack:String,needle:String)中的一个函数示例>;Nat{//implementation elided}
啊,但是有';这段话里没有提到例外,对吧?在我们假设的语言中,这并不意味着';不是说它赢了';t抛出异常:相反,这意味着它';允许抛出任何异常!然而,编译器可以推断它可能抛出哪些异常,并且它不应该';不难计算:只需累积方法体中抛出的任何未捕获异常或方法体中调用的方法抛出的任何未捕获异常。如果我向编译器询问find的类型,它可能会告诉我
这意味着编译器很清楚find可能会抛出异常,并且实际上知道它可能抛出什么异常。使用find now的任何东西都可以知道它可能会抛出SubstringNotFound。这意味着,在这个假设的语言中,我们可以写
现在我们';we';我声称这个例子没有例外,但因为我们';重新使用find,编译器可以正确地指出我们没有';未找到处理过的子字符串。就像在爪哇一样,我们';我们有两个基本选项,要么捕获示例主体中的SubstringNotFound,要么将SubstringNotFound添加到抛出的异常列表中,但我们';我们还有第三个更简洁的选择:我们可以完全删除throws()并允许它为我们推断出它想要的任何东西。如果我们愿意的话,我们可以添加规范,但其他的则由编译器来处理。
我认为有';这里还有更多关于表现力的内容。例如,程序员可能会选择在列表中显式地包含一个异常:一个类型,比如fn example()->;Nat抛出(某个异常,…)这意味着,“无论编译器在这里为方法体推断出什么,但它也可以抛出一些异常,即使编译器没有推断出该异常。”有一种情况你可能希望这样做,那就是在原型化一个API时:也许我知道我的API最终可能会抛出CacheMissException,但我没有';我还没把缓存连接好,所以我';我要确保在我的类型签名中,在适当的地方包括它,以防万一,在其他地方使用throws()来确保我在需要的地方处理它。
不过,更有用的是,我可以想象一种语法来确保特定的异常不会出现';t包含在推断类型中。在本例中,像fn example()这样的类型->;Nat抛出(!OtherException,…)这意味着,“这会抛出编译器为主体推断的任何内容,但如果推断集包含OtherException,则会引发编译错误。”这意味着你没有';不需要为一个复杂的API定期重新编写异常集,它可能会抛出十几个不同的特定错误,但你仍然可以说,“我不希望这个特定的异常逃逸,所以请告诉我实话:如果example()试图抛出其他异常,那么就对我大喊大叫。”
事实上,我可以想象,想要以一种方式实现这一点,异常列表,即使是像()这样的空列表,实际上也会隐式地包含普遍存在的“存在”异常:例如,表示SIGKILL之类信号的异常,或者在进程耗尽内存时引发的异常。在这种情况下,fn foo()抛出()将是一个方便的小说,因为它赢得了';不要强迫程序员处理内存不足错误,但是程序员可以编写fn foo()throws(!OutOfMemory)来表示foo不仅没有';它不会抛出任何用户编写的或典型的stdlib异常,它还承诺处理从内部冒出的内存不足异常。一个典型的程序可能仍然会定义fn main()throws(),但服务器';可能会定义fn main()抛出(!OutOfMemory,!Sigkill),这样编译器就可以在';我们无法处理这些错误情况。
我没有';我没有用任何语言实现这一点,所以它';很可能是';我仍然有问题。而我';我已经解决了一些我没有解决的问题';我没有试图解决这个问题。例如,我没有';t试图充分阐明高阶函数的类型规则,或者这将如何与类型类交互,或者编译模型将如何工作:there';还有很多工作要做,在实践中可能需要其他限制、妥协或启发。但我的假设是,这样的功能可以让人们以一种包含最小开销的方式使用检查过的异常,允许程序员选择使用有用的检查,但也可以在检查时避开它们';这没用。
我说我的假设还有另一部分,并解释我';我得谈谈常见的Lisp';s条件系统。让';让我们从例外开始,详细描述它们是如何工作的,因为';我们将帮助我们了解情况的不同。
在一种有异常的语言中,你通过构造一个叫做异常的“东西”来表示错误的存在,异常通常是一个特定类型的值。在具有继承性的语言中,这些值通常是从特定基类继承的(尽管Ruby并不总是允许抛出任何值),而在没有继承性的语言中,它们通常具有某种特定于错误的标记(例如在Haskell或各种ML语言中)这些值可以“提升”或“抛出”。当一个异常被“引发”时,语言运行库将开始沿着堆栈向上移动,在移动过程中释放堆栈帧,直到它找到一个“处理程序”,一个与适当的异常类型或标记匹配的代码位,并附加到一个错误处理代码块上。如果找不到处理程序,运行时通常会终止程序并打印相关消息。如果它确实找到了一个处理程序,它将从该点恢复,为该代码位提供异常。
这是一种被广泛使用的错误机制:事实上,这一机制如此之多,以至于很难想象替代方案。你还能做什么?你还想从中得到什么?
我';我要从彼得·塞贝尔那里偷一个例子';这是一个实用的通用Lisp作为动机,但是对于那些没有';我不喜欢口齿不清。想象一下我';我正在写一个库,它正在解析特定格式的日志文件。我有一个名为parse_log_entry的函数,它获取一段文本并生成一个日志条目。说我';我是这样写的:
fn解析日志条目(文本:字符串)->;LogEntry{if(格式良好(文本)){return LogEntry::from_text(文本);}else{raise morformedlogentry(text);}
现在这个库是关于解析整个日志文件的,所以我还公开了一个函数来解析整个文件,如下所示:
fn解析日志文件(f:file)->;列表<;登录>;{let mut list=list::new();for ln in f.read_lines(){list.append(parse_log_entry(ln));}列表}
这很好也很简单!不幸的是,如果一个条目无法解析,我们';我将抛出一个格式错误的Genetry异常,并失去对迄今为止分析的整个日志的访问权限!在某些应用中,可能是';很好,但我';我说过我们';我们正在编写一个库,我们希望它对最终用户来说尽可能灵活。也许库的用户希望我们使用一种特殊的LogEntry值来表示格式错误的条目?我们可以这样写:
fn解析日志文件(f:file)->;列表<;登录>;{let mut list=list::new();for ln in f.read_line(){try{list.append(parse_log_entry(ln))}catch(exn:MalformedLogEntry){list.append(bad_log_entry())}list}
现在我们优雅地处理这个错误。但现在我们';我们假设';这就是用户希望我们处理错误的方式。也许用户希望我们悄悄地跳过这个条目!我们也可以这样写:
fn解析日志文件(f:file)->;列表<;登录>;{let mut list=list::new();for ln in f.read_line(){try{list.append(parse_log_entry(ln))}catch(_exn:MalformedLogEntry){//do nothing}list}
但如果他们想让我们跳过它们,但将错误写入stdout呢?还是特定的记录器?或者应用一个修正启发式,然后再次尝试解析该行?或者…
好吧,库的设计很难,而设计能够以各种可能的方式处理错误的库真的很难。这里的一种方法可能是提供所有这些选项,供图书馆用户选择。也许我们向用户公开了不同的方法,每个方法都实现了这些策略的不同版本,比如使用默认值解析日志文件与跳过坏文件解析日志文件。也许我们提供了一个带有许多可选参数的函数,比如parse_log_file(默认值为:…,跳过无效值为:…)。也许我们只是把手举在空中,然后选择一个我们认为合理的:常规而非配置,对吗?
另一方面,如果我们有一个条件系统,我们将有一个非常强大的方式,允许用户选择其中任何一个或更多,而不必大幅改变我们的界面。条件系统所做的是分离出两个因异常处理而混淆的不同关注点:错误恢复代码的作用和应该调用的错误恢复代码。首先,我们不安装异常处理程序,而是安装什么';它被称为重启,并给它起了一个名字:这就是我们如何定义在引发错误后可能运行的恢复代码,但它不能保证与重启相关的错误处理代码实际运行。在这种情况下,让';让我们从跳过条目的逻辑开始:
fn解析日志文件(f:file)->;列表<;登录>;{let mut list=list::new();for ln in f.read_lines(){try{list.append(parse_log_entry(ln))}重新启动SkipEntry{//do nothing}}list}
此重启块代表出现错误时可能的恢复策略,Skiperor是we';我已经给了。然而,我们';我们仍然缺少一些东西:我们没有';我没有告诉我们的程序使用它。我们的图书馆应该';I don’我不是做出这个选择的人,所以让';让我们想象一下';从一些应用程序代码中重新调用parse_log_file library函数。现在,我们告诉应用程序代码通过调用restart We';我定义了:
fn analyze_log()->;列表<;登录>;{试试{parse_log_file(file::open(";my_log.txt";)}句柄{MalformedLogEntry()=>;restart skippentry,}
这就是我';我告诉程序使用哪段恢复代码。我';我的意思是,“如果我们遇到代码产生了错误的生成,那么通过找到标记为SkipEntry的恢复路径并从那里重新启动来恢复。”
到目前为止,我们';我只定义了一条恢复路径。让';我们将重新访问parse_log_条目,并添加一些可能用于从该函数中的错误中恢复的策略。与上面的SkipEntry不同,它们还接受参数,这些参数是处理程序可以在句柄块中提供的信息片段:
fn解析日志条目(文本:字符串)->;LogEntry{if(格式良好(文本)){return LogEntry::from_text(文本);}否则{try{raise morformedlogentry(text);}重新启动UseValue(v:LogEntry){return v;}重新启动RetryParse(new_text:String){return parse_log_entry(new_text);}}
现在我们总共有三种可能的方法来从错误的生成中恢复:我们可以调用SkipEntry重新启动,它只会跳过错误的行,我们可以使用带有LogEntry类型的值的UseValue重新启动来用提供的不同日志替换坏日志,或者,我们可以使用RetryParse重新启动来提供一个新的已更正字符串,然后再次尝试解析。
现在重要的是,库允许所有这些重启同时存在,但没有指定要执行哪些重启:即';这取决于呼叫代码。让';让我们更改应用程序代码,将bad_log_entry()作为默认值提供;这意味着一个应用程序仍将包含与我们的行一样多的日志条目值,但有些特定地表示为坏值:
fn analyze_log()->;列表<;登录>;{试试{parse_log_file(file::open(";my_log.txt";)}句柄{MalformedLogEntry()=>;重新启动UseEntry(bad_log_entry()),}
如果我们想跳过那些不好的,但仍然通过向日志记录程序打印消息来记录我们看到的,该怎么办?我们可以使用SkipEntry和一些额外的处理程序代码,然后:
fn analyze_log()->;列表<;登录>;{试试{parse_log_file(file::open(";my_log.txt";)}句柄{MalformedLogEntry(text)=>;{logger.log(";发现错误的日志条目:`{}`";,text);重新启动SkipEntry;}}}
如果我们想尝试对我们看到的前几个错误应用修正启发,但如果我们看到的错误超过预先确定的“允许”数量,就退出程序,该怎么办?我们可以在处理程序和RetryParse重新启动中使用共享状态:
fn analyze_log()->;列表<;登录>;{let mut errors=0;尝试{parse_log_file(file::open(";my_log.txt";)}处理{MalformedLogEntry(文本)=>;{if(errors<;ALLOWED_LOG_errors){errors+=1;重新启动RetryParse(try_correction(文本));}else{logger.log(";遇到太多错误的日志条目;正在退出";);系统退出(1);}}}
诚然,这个系统肯定比异常处理更精细:你';我们得到了更多的移动部件,包括错误条件(在本例中,我们只有一个)加上命名的错误重启加上处理程序逻辑。(我怀疑这就是为什么语言设计师没有费心将其移植到新语言中的原因之一,而是更喜欢基于异常的简单错误或显式结果类型。)但这种分离可能非常强大:我们不再需要通过API手动执行线程状态;相反,我们的API是为“快乐之路”而设计的,但错误仍然可以以细粒度的方式处理,还有什么';此外,应用程序可以完全控制如何处理这些错误。将错误恢复策略(称为重新启动)从如何实际处理错误(称为处理程序策略)中分离出来,可以实现一些非常简单但功能强大的API设计。
好吧,让';让我们把这些放在一起。最后一节讨论了类型,理由很充分:在Common Lisp中,没有';t类型用于重新启动。事实上,它';处理程序可能会指定一个不';不存在,在这种情况下它';我会产生另一种错误(用Lisp的说法是控制错误),因为它不是';无法找到代码应该恢复的位置。
但我们可以构建一种实现条件系统的静态类型语言,这样编译器就可以拒绝捕获不存在的错误的程序,或者尝试从不存在的重新启动中恢复(或者为这些重新启动提供错误类型的值)再一次,我';d建议这样做是通过允许错误或条件,使用常见的Lisp术语,但也可以重新启动
......