让我们来谈谈例外情况。程序一直在成功地做一件事,除了有时事情没有成功。所以我们有 "异常",就像编程语言中任何有趣的东西一样,它是60年代在Lisp中发明的。
它们在Haskell中并不完美。它们在任何语言中都不完美,真的。我们一直在对我们认为是处理特殊情况的最佳方式进行调整。作为一个领域,我们正在蒙混过关。让我们来讨论一下Haskell中的选项。
已经有其他的博文讨论了如何在Haskell中引发异常--从使用IO异常(throw),到ExceptT-类似单体的变换器,到直接返回一个Either Error a-但我们要看的是我们如何定义异常类型本身。
使用字符串
在Haskell中定义异常的一个非常常见的方法是使用String 类型(又称[Char] )--是的,就是那个我们滥用于一切的Char 列表。这其实有很多优点:
- 第一个优点是,它是内置在Haskell语言中的;它可以从任何地方的包中获得。
- 根据定义,这已经是一个人类可读的错误信息了!
- 你已经可以使用
Prelude模块中的error函数来抛出这个信息。 - 此外,Haskell中的许多包,如Parsec和attoparsec等,已经使用
String类型来提供错误信息。 Exception的任何实例都可以使用show或displayException转换为String,这意味着你可以很容易地在你的表述中包含其他的异常类型。
不过,这并不全是茶和面包。
在Haskell的库生态系统中,许多现有的错误信息未能在其字符串错误信息中包含关键信息。如果错误是以数据类型的形式提供的,那么它就可以包括数据类型中出错的地方,这样就可以让你自己访问这些信息。
考虑一下,例如
if waterTemp < boilingPoint
then error "The tea isn't coming!"
else ...
这条信息没有告诉我为什么茶没有来。水壶坏了吗?是不是有人忘了打开它?理想情况下,这个错误字符串应该说:
if waterTemp < idealTemp
then error ("The tea isn't coming, water temperature (" ++
waterTemp ++ ") is below ideal temp (" ++
idealTemp ++ ")".
else ...
这条信息是一个改进,因为它给了我一些关于为什么失败的细节。但这仍然不理想。
另一个问题是,你必须提前构建新的消息,这意味着你的异常的用户不能选择以他们喜欢的方式显示消息,比如用不同的语言(再见,再见,i18n),或用不同的布局,或不同的文件格式(如生成JSON消息)。如果你的函数的用户想用普通话显示你的异常怎么办?或者用HTML格式很好地显示它?
此外,有时异常包含敏感信息,比如密码或API密钥:这对开发者来说可能很有用,但你并不总是想把它显示给整个世界,无论是在日志文件中还是在终端用户面前。通过String ,剥离这些信息充其量是一个移动的目标。
情况变得更糟。因为它是一个字符串类型,所以不可能依靠这种表现形式作为检查手段。例如,如果我收到一封来自Elizabeth II 的电子邮件,我可以对该域名的 DNS 记录进行 SPF 记录查询,看看给我发送电子邮件的 IP 是否是该域名的有效发件人。
但如果我用一个库来做,我就会得到这样的错误:
> queryTXT (Network.DNS.Name "palace.gov.uk")
*** Exception: user error (res_query(3) failed)
嗯,这是真的!查询确实失败了。但我需要比这更多的信息。可能是你的连接有问题,可能是这个域名没有SPF记录,也可能是这个域名无效;如果这个域名没有SPF记录,那就是一个问题。如果我的连接有问题,那也没关系--我可以稍后再试。如果这是一个字符串类型,你就不能做出这个决定。
最后,在所有这些悲哀之后,你没有像在和类型中那样对String 进行穷举性检查,所以你不知道你是否已经处理了所有的可用情况。
点击下面了解更多关于一个独特的提议
巨型异常类型
另一种非常常见的表达错误的方式是使用一个大型的和类型,其中每个构造函数代表一个不同的错误情况。这在例如http -conduit、stack等中被广泛使用。
data HttpExceptionContent
= StatusCodeException (Response ()) S.ByteString
| TooManyRedirects [Response L.ByteString]
| OverlongHeaders
| ResponseTimeout
| ...
这种方法的好处是,所有类型的异常都集中在一个地方,这包括在你进行模式匹配时能够检查每一种可能的错误情况(用GHC的穷举检查),比如:
catch (makeRequest)
(\case
StatusCodeException r s -> ...
OverlongHeaders -> ...
...)
此外,当你在haddock中查看异常类型时,你能够提前分辨出哪些事情可能出错。你也可以把任何相关的数据放到构造函数中。
换句话说,这种方法是自我记录的。它是透明的。
缺点是你必须把所有的工作集中在一个地方,这比简单地把错误写成一个字符串需要额外的维护负担。而且每当你添加一个新的构造函数时,都会对你的库的下游用户造成破坏性的改变。这可能被认为是一个优势或劣势。
为构造函数添加上下文也很困难,尤其是当你不想在每个构造函数中都重复该上下文时。例如,在http -conduit包中,它有大约30个构造函数。你不会想把相同的上下文复制到每个构造函数中。相反,最好是有一个单独的异常类型,其中包含上下文,然后有一个字段用于你的和类型。事实上,这就是http -conduit包在 HttpException类型。
data HttpException
= HttpExceptionRequest Request HttpExceptionContent
| InvalidUrlException String String
Request 是背景,而HttpExceptionContent 是实际发生的问题。
最后一点:巨型的异常类型意味着某种完整性;如果你在使用一个库的时候捕捉到这种异常类型,你就已经处理了所有可能的异常。但事实并非如此,一个库仍然可以抛出一个不同的异常类型。所以这种方法可能会给人们一种错误的安全感。
单独的异常类型
这种方法与巨型异常类型类似,只是每个错误条件都有一个单独的数据类型。这样做的好处是在Either :你清楚地知道什么会出错,因为这种类型的异常只有一种可能的错误情况,而在巨型异常类型中,你可以捕捉到这个类型,但这样你可能有30种不同的错误情况可能发生。这30种错误情况不可能全部出错,但你不得不假设它们可能出错,因为这个类型。
例如,如果一个文件不存在,这就是一种类型的异常。如果一个目录不存在,那是另一种类型的异常。所以像openFile这样的函数可以声称抛出这两种异常类型的产物,文件不存在,或者目录不存在,甚至是你没有对目录的访问权,等等。
Throws (FileNotFound,DirectoryNotFound,AccessDenied)
缺点是很难将它们结合起来。产品类型,如图元,并不能真正扩展。当几十个不同的类型出现时,类型签名也会变得非常不方便,比如在base 的IO APIs中。这很难真正管理。只需考虑一下base 中可抛出的大量异常。
另一个问题是,用户必须了解所有可以抛出的不同类型的异常,以便在不纯的throw ,从你的库中捕获所有的东西,而在超大的异常情况下,很容易捕获所有的东西,因为所有的东西都已经为你放在一个地方了。
抽象的异常类型
这种方法是指你有一个完全不透明的异常类型,除非使用访问器,否则无法检查。这方面的一个例子是Haskell中的IOError类型,它是标准的,在整个IO库中使用。例如,错误访问器isDoesNotExistError 。
这样做的好处是,你可以在不破坏API的情况下改变内部结构。另一个好处是,你可以很容易地捕捉到错误的上下文,因为你只需把它放在一个访问器中。
另一个优点是谓词可以应用于多个构造函数,比如isFileOpenError ;这可以是一个访问器,表示你不能打开一个文件,但原因可以更详细,比如 "无法访问目录","没有这样的文件",或者其他。
这样做的缺点是:它不像透明异常类型那样在haddock上有自我记录,所以你基本上对你的用户隐藏了不同的选项是什么。
另外,当你改变错误的抛出方式时,也许你应该破坏你用户的代码;也许隐藏细节只会让事情变得更糟。
为什么不同时提供呢?
另一个选择是同时提供这两样东西。因此,你提供了一组构造函数,但你也提供了一组谓词,可以在这些类型和其他访问器上使用。甚至可以使用模式同义词来提供一个记录的访问器集,而不暴露内部的数据类型。这将给你一些灵活性,但我不知道在野外有什么例子实现了这种方法。
Matt Parsons探讨了一种使用棱镜和generic-lens的错误处理方法,值得一看。
终止程序
最后,我们可以采用C语言的方法,终止程序,将返回代码设为-1。这样做的缺点是,你的Haskell卡会被撕掉,你会被永远驱逐到node.js项目上工作。
结论
看起来基础的Haskell包更倾向于不透明的方法,而许多标准的任务库则使用巨型的异常方法。我们已经讨论了其中的利弊得失。在这一点上,如何为你的异常建模在很大程度上是一种判断,而不是一个明确的决定。
对我来说,唯一明确的一点是,String (或Text)的错误信息总是错误的决定,原因如上所述。例如,在分析器库中,旧的方法是这个。但是在更现代的库中,比如megaparsec,错误类型是由库的调用者提供的。所以没有必要提前决定一个具体的类型。