本文翻译自 Parse, don’t validate,原载于 Hacker News。
类型驱动设计的本质
长期以来,我一直苦于找不到一个简洁的方式来解释什么是”类型驱动设计”。当有人问我”你是怎么想到这个方案的?”时,我往往给不出一个令人满意的答案。我知道这不是凭空而来的——我有一个迭代设计过程,不需要从空气中抓取”正确”的方案——但我一直没有成功地与他人沟通这个过程。
大约一个月前,我在 Twitter 上反思了在静态类型语言和动态类型语言中解析 JSON 的差异,终于,我意识到我一直在寻找什么。现在我有一个简洁有力的口号来概括类型驱动设计对我的意义,而且只有三个词:
解析,不要验证。
可能性的领域
静态类型系统的一个美妙之处在于,它们可以让回答”是否可能编写这个函数?”这类问题变得可行,甚至容易。来看一个极端的例子,考虑以下 Haskell 类型签名:
foo :: Integer -> Void
是否可能实现 foo?答案显而易见是否定的,因为 Void 是一个不包含任何值的类型,所以任何函数都不可能产生一个 Void 类型的值。这个例子很无聊,但如果选择一个更现实的例子,问题会变得更有趣:
head :: [a] -> a
这个函数返回列表的第一个元素。是否可能实现?这听起来并不复杂,但如果我们尝试实现它,编译器不会满意:
head :: [a] -> a
head (x:_) = x
warning: [-Wincomplete-patterns]
Pattern match(es) are non-exhaustive
In an equation for 'head': Patterns not matched: []
这个警告指出我们的函数是偏函数(partial),也就是说它并非对所有可能的输入都有定义。具体来说,当输入是 [](空列表)时,它是未定义的。这是合理的,因为如果列表为空,就不可能返回第一个元素——根本没有元素可返回!
将偏函数转化为全函数
对于有动态类型背景的人来说,这可能看起来有些困惑。如果我们有一个列表,我们可能确实想要获取它的第一个元素。实际上,”获取列表的第一个元素”这个操作在 Haskell 中并非不可能,只是需要一些额外的步骤。有两种不同的方法可以修复 head 函数,我们从最简单的一种开始。
管理预期
如前所述,head 是偏函数,因为如果列表为空就没有元素可返回——我们做出了一个无法履行的承诺。幸运的是,这个困境有一个简单的解决方案:我们可以削弱我们的承诺。既然我们不能保证调用者一定能得到一个元素,我们就需要做一些预期管理:我们会尽力返回一个元素,但我们也保留什么都不返回的权利。
在 Haskell 中,我们使用 Maybe 类型来表达这种可能性:
head :: [a] -> Maybe a
这给了我们实现 head 所需的自由——它允许我们在发现最终无法产生类型为 a 的值时返回 Nothing:
head :: [a] -> Maybe a
head (x:_) = Just x
head [] = Nothing
问题解决了吗?暂时是……但这个解决方案有一个隐藏的代价。
在我们实现 head 时,返回 Maybe 无疑是方便的。然而,当我们想要实际使用它时,它变得非常不方便!因为 head 总是有可能返回 Nothing,所以调用者必须处理这种可能性,而这种责任的转嫁有时会让人非常沮丧。
getConfigurationDirectories :: IO [FilePath]
getConfigurationDirectories = do
configDirsString <- getEnv "CONFIG_DIRS"
let configDirsList = split ',' configDirsString
when (null configDirsList) $
throwIO $ userError "CONFIG_DIRS cannot be empty"
pure configDirsList
main :: IO ()
main = do
configDirs <- getConfigurationDirectories
case head configDirs of
Just cacheDir -> initializeCache cacheDir
Nothing -> error "should never happen; already checked configDirs is non-empty"
当 getConfigurationDirectories 从环境变量中获取文件路径列表时,它主动检查了列表非空。然而,当我们在 main 中使用 head 来获取列表的第一个元素时,Maybe FilePath 的结果仍然要求我们处理一个我们知道永远不会发生的 Nothing 情况!这在几个方面都非常糟糕:
- 首先,它很烦人。我们已经检查过列表非空了,为什么还要用多余的检查来污染我们的代码?
- 其次,它有潜在的性能成本。虽然在这个特定例子中多余检查的成本微不足道,但可以想象更复杂的场景,比如在紧密循环中,多余检查可能会累积起来。
- 最后,也是最糟糕的,这段代码是一个等待发生的 bug!如果
getConfigurationDirectories被修改为不再检查列表是否为空(无论是有意还是无意),程序员可能不记得更新main,突然之间这个”不可能”的错误不仅变得可能,而且很可能会发生。
这种多余检查的需求本质上迫使我们在类型系统中打了一个洞。如果我们能够静态地证明 Nothing 情况是不可能的,那么修改 getConfigurationDirectories 以停止检查列表为空将使证明失效,并触发编译时失败。然而,按照现在的写法,我们被迫依赖测试套件或手动检查来捕获 bug。
向前传递
很明显,我们修改后的 head 还有一些不足之处。某种程度上,我们希望它更智能:如果我们已经检查过列表非空,head 应该无条件返回第一个元素,而不强迫我们处理我们知道不可能的情况。我们如何做到这一点?
让我们再看看原始的(偏函数)类型签名:
head :: [a] -> a
前一节说明了我们可以通过削弱返回类型中的承诺来将那个偏函数类型签名转化为全函数。然而,既然我们不想这样做,那么只剩下一件事可以改变:参数类型(这里是 [a])。我们可以加强参数类型,而不是削弱返回类型,从根本上消除 head 在空列表上调用的可能性。
要做到这一点,我们需要一个表示非空列表的类型。幸运的是,Data.List.NonEmpty 中现有的 NonEmpty 类型正是如此。它有以下定义:
data NonEmpty a = a :| [a]
注意 NonEmpty a 实际上只是一个 a 和普通的可能为空的 [a] 的元组。这通过将列表的第一个元素与其尾部分开存储来方便地建模非空列表:即使 [a] 组件是 [],a 组件也必须始终存在。这使得实现 head 变得完全微不足道:
head :: NonEmpty a -> a
head (x:|_) = x
与之前不同,GHC 毫无抱怨地接受了这个定义——这个定义是全函数,不是偏函数。我们可以更新我们的程序以使用新的实现:
getConfigurationDirectories :: IO (NonEmpty FilePath)
getConfigurationDirectories = do
configDirsString <- getEnv "CONFIG_DIRS"
let configDirsList = split ',' configDirsString
case nonEmpty configDirsList of
Just nonEmptyConfigDirsList -> pure nonEmptyConfigDirsList
Nothing -> throwIO $ userError "CONFIG_DIRS cannot be empty"
main :: IO ()
main = do
configDirs <- getConfigurationDirectories
initializeCache (head configDirs)
注意 main 中的多余检查现在完全消失了!相反,我们在 getConfigurationDirectories 中只检查一次。它使用 Data.List.NonEmpty 中的 nonEmpty 函数从 [a] 构造一个 NonEmpty a,该函数有以下类型:
nonEmpty :: [a] -> Maybe (NonEmpty a)
Maybe 仍然存在,但这次我们在程序的很早阶段就处理 Nothing 情况:就在我们已经进行输入验证的同一个地方。一旦检查通过,我们现在有了一个 NonEmpty FilePath 值,它在类型系统中保留了列表确实非空的知识。换句话说,你可以将类型为 NonEmpty a 的值视为类型为 [a] 的值,加上一个列表非空的证明。
通过加强 head 参数的类型而不是削弱其结果的类型,我们完全消除了前一节的所有问题:
- 代码没有多余检查,所以不会有任何性能开销。
- 此外,如果
getConfigurationDirectories改变为停止检查列表非空,它的返回类型也必须改变。因此,main将无法通过类型检查,甚至在我们运行程序之前就提醒我们!
更重要的是,通过组合 head 和 nonEmpty,很容易从新版本恢复 head 的旧行为:
head' :: [a] -> Maybe a
head' = fmap head . nonEmpty
注意反过来不成立:没有办法从旧版本获得新版本的 head。总的来说,第二种方法在所有方面都更优越。
解析的力量
你可能想知道上面的例子与这篇博客文章的标题有什么关系。毕竟,我们只检查了两种不同的方法来验证列表非空——没有看到任何解析。这种解释没有错,但我想提出另一个视角:在我看来,验证和解析之间的区别几乎完全在于信息如何保留。考虑以下一对函数:
validateNonEmpty :: [a] -> IO ()
validateNonEmpty (_:_) = pure ()
validateNonEmpty [] = throwIO $ userError "list cannot be empty"
parseNonEmpty :: [a] -> IO (NonEmpty a)
parseNonEmpty (x:xs) = pure (x:|xs)
parseNonEmpty [] = throwIO $ userError "list cannot be empty"
这两个函数几乎相同:它们检查提供的列表是否为空,如果是,就用错误消息中止程序。区别完全在于返回类型:validateNonEmpty 总是返回 ()(一种不包含任何信息的类型),但 parseNonEmpty 返回 NonEmpty a(输入类型的细化,在类型系统中保留获得的知识)。这两个函数检查相同的东西,但 parseNonEmpty 给调用者访问它所学的信息,而 validateNonEmpty 只是把它扔掉了。
这两个函数优雅地说明了静态类型系统作用的两种不同视角:validateNonEmpty 足够服从类型检查器,但只有 parseNonEmpty 充分利用了它。如果你明白为什么 parseNonEmpty 更可取,你就理解了我所说的格言”解析,不要验证”的意思。不过,你可能对 parseNonEmpty 的名称持怀疑态度。它真的在解析什么吗,还是只是验证其输入并返回结果?虽然解析或验证某物的精确定义是有争议的,但我相信 parseNonEmpty 是一个真正的解析器(虽然是一个特别简单的解析器)。
考虑:什么是解析器?实际上,解析器只是一个消耗较少结构化的输入并产生更多结构化的输出的函数。本质上,解析器是一个偏函数——域中的某些值不对应于范围中的任何值——所以所有解析器都必须有某种失败概念。通常,解析器的输入是文本,但这绝不是必需的,parseNonEmpty 是一个完全合理的解析器:它将列表解析为非空列表,通过用错误消息终止程序来发出失败信号。
在这个灵活的定义下,解析器是一个令人难以置信的强大工具:它们允许在前面预先卸载对输入的检查,就在程序和外部世界之间的边界上,一旦这些检查完成,它们就不需要再次检查!Haskell 开发者很清楚这种力量,他们定期使用许多不同类型的解析器:
- aeson 库提供了一个
Parser类型,可用于将 JSON 数据解析为领域类型。 - 同样,optparse-applicative 提供了一组解析器组合子,用于解析命令行参数。
- 像 persistent 和 postgresql-simple 这样的数据库库有一种机制,用于解析存储在外部数据存储中的值。
- servant 生态系统建立在从路径组件、查询参数、HTTP 标头等解析 Haskell 数据类型之上。
所有这些库的共同主题是它们位于 Haskell 应用程序和外部世界之间的边界上。那个世界不会用乘积类型和和类型说话,而是用字节流说话,所以无法避免进行一些解析。在作用于数据之前预先进行这种解析,可以很大程度上避免许多类别的 bug,其中一些甚至可能是安全漏洞。
这种预先解析所有内容的方法的一个缺点是有时需要在实际使用之前很久就解析值。在动态类型语言中,这使得在没有广泛测试覆盖的情况下保持解析和处理逻辑同步有点棘手,其中许多测试维护起来很费劲。然而,有了静态类型系统,这个问题变得非常简单,正如上面的 NonEmpty 例子所示:如果解析和处理逻辑不同步,程序甚至无法编译。
验证的危险
希望此时你至少对解析优于验证的想法有些信服,但你可能还有挥之不去的疑虑。如果类型系统最终会强迫你做必要的检查,验证真的那么糟糕吗?错误报告可能稍微差一点,但一些多余检查不会有害,对吧?
不幸的是,事情没那么简单。特设验证会导致语言理论安全领域称为霰弹式解析(shotgun parsing)的现象。在 2016 年的论文《The Seven Turrets of Babel: A Taxonomy of LangSec Errors and How to Expunge Them》中,作者提供了以下定义:
霰弹式解析是一种编程反模式,其中解析和输入验证代码与处理代码混合并分散在其中——向输入投掷一系列检查,并在没有任何系统理由的情况下希望其中一个或另一个会捕获所有”坏”情况。
他们继续解释这些验证技术固有的问题:
霰弹式解析必然剥夺程序拒绝无效输入而不是处理它的能力。输入流中后期发现的错误将导致部分无效输入已被处理,结果是程序状态难以准确预测。
换句话说,一个不预先解析所有输入的程序有风险作用于输入的有效部分,发现不同部分无效,然后突然需要回滚它已经执行的任何修改以维护一致性。有时这是可能的——比如在 RDBMS 中回滚事务——但通常可能不会。
霰弹式解析与验证有什么关系可能并不明显——毕竟,如果你预先进行所有验证,你就减轻了霰弹式解析的风险。问题是基于验证的方法使得极难或不可能确定是否真的预先验证了所有内容,或者某些所谓的”不可能”情况是否真的可能发生。整个程序必须假设在任何地方引发异常不仅可能,而且是经常必要的。
解析通过将程序分层为两个阶段——解析和执行——来避免这个问题,其中由于无效输入而导致的失败只能发生在第一个阶段。相比之下,执行期间剩余的失败模式集是最小的,它们可以用所需的细致关怀来处理。
实践中的解析,而非验证
到目前为止,这篇博客文章有点像推销。”亲爱的读者,你应该解析!”它说,如果我做得好,至少你们中有些人被说服了。然而,即使你理解了”什么”和”为什么”,你可能对”如何”不太自信。
我的建议:专注于数据类型。
假设你正在编写一个函数,它接受一个表示键值对的元组列表,你突然不确定如果列表有重复键该怎么办。一个解决方案是编写一个函数来断言列表中没有重复:
checkNoDuplicateKeys :: (MonadError AppError m, Eq k) => [(k, v)] -> m ()
然而,这个检查是脆弱的:它极其容易被遗忘。因为它的返回值未使用,所以它总是可以被省略,而需要它的代码仍然可以通过类型检查。一个更好的解决方案是选择一个在构造上不允许重复键的数据结构,比如 Map。调整你的函数类型签名以接受 Map 而不是元组列表,并像往常一样实现它。
一旦你这样做了,你的新函数的调用站点可能无法通过类型检查,因为它仍然被传递一个元组列表。如果调用者通过其参数之一获得该值,或者它从某个其他函数的结果接收它,你可以继续沿调用链将类型从列表更新到 Map。最终,你要么到达创建值的位置,要么找到一个实际上应该允许重复的地方。在那时,你可以插入对修改后的 checkNoDuplicateKeys 的调用:
checkNoDuplicateKeys :: (MonadError AppError m, Eq k) => [(k, v)] -> m (Map k v)
现在检查不能被省略,因为它的结果实际上是程序继续进行所必需的!
这个假设的场景突出了两个简单的想法:
- 使用使非法状态不可表示的数据结构。 使用你能合理使用的最精确的数据结构来建模你的数据。如果使用当前使用的编码来排除特定可能性太难,请考虑可以更容易表达你关心的属性的替代编码。不要害怕重构。
-
尽可能向上推进证明的负担,但不要更进一步。 尽快将数据转换为你需要的最精确表示。理想情况下,这应该发生在你的系统边界,在任何数据被作用之前。
如果某个特定代码分支最终需要数据更精确的表示,则在选择分支后立即将数据解析为更精确的表示。明智地使用和类型,使你的数据类型能够反映和适应控制流。
换句话说,在你希望拥有的数据表示上编写函数,而不是在你给定的数据表示上编写函数。然后设计过程变成一个弥合差距的练习,通常是从两端工作直到它们在中间相遇。不要害怕在迭代过程中调整设计的某些部分,因为你可能在重构过程中学到新东西!
这里还有一些额外的建议点,没有特别的顺序:
- 让你的数据类型通知你的代码,不要让你的代码控制你的数据类型。 避免仅仅因为你当前正在编写的函数需要就在某处粘贴一个
Bool的诱惑。不要害怕重构代码以使用正确的数据表示——类型系统将确保你覆盖了所有需要更改的地方,并且它可能会为你节省头痛。 - 对返回
m ()的函数持深深的怀疑态度。 有时这些是真正必要的,因为它们可能执行没有有意义结果的命令效果,但如果该效果的主要目的是引发错误,那么可能有更好的方法。 - 不要害怕在多个阶段中解析数据。 避免霰弹式解析只是意味着你不应该在输入数据完全解析之前对其行动,并不意味着你不能使用一些输入数据来决定如何解析其他输入数据。许多有用的解析器是上下文相关的。
-
避免数据的非规范化表示,特别是如果它是可变的。 在多个地方复制相同的数据引入了一个微不足道的可表示非法状态:地方不同步。争取单一事实来源。
- 将非规范化的数据表示保持在抽象边界后面。 如果非规范化是绝对必要的,请使用封装来确保一个小的、可信的模块承担保持表示同步的唯一责任。
- 使用抽象数据类型使验证器”看起来像”解析器。 有时,考虑到 Haskell 提供的工具,使非法状态真正不可表示是完全不切实际的,比如确保整数在特定范围内。在这种情况下,使用带有智能构造函数的抽象
newtype从验证器”伪造”解析器。
一如既往,使用你的最佳判断。为了摆脱某个地方的单一 error "impossible" 调用而打破单例并重构整个应用程序可能不值得——只要确保将这些情况视为它们所具有的放射性物质,并用适当的关怀处理它们。如果一切都失败了,至少留下一个注释来记录不变量,以便下一个需要修改代码的人。
总结、反思和相关阅读
就这样了。希望这篇博客文章证明了充分利用 Haskell 类型系统不需要博士学位,甚至不需要使用 GHC 最新最伟大的新语言扩展——尽管它们当然有时有帮助!有时充分利用 Haskell 的最大障碍只是知道可用的选项是什么,不幸的是,Haskell 小社区的一个缺点是相对缺乏记录已成为部落知识的设计模式和技术的资源。
这篇博客文章中的想法都不是新的。事实上,核心思想——”编写全函数”——在概念上非常简单。尽管如此,我发现以一种可操作的、实用的方式传达我编写 Haskell 代码的方式是非常具有挑战性的。很容易花很多时间谈论抽象概念——其中许多非常有价值——而没有传达关于过程的任何有用内容。我希望这是朝这个方向迈出的一小步。
遗憾的是,我对这个特定主题的其他资源了解不多,但我知道一个:我从不犹豫推荐 Matt Parson 的精彩博客文章《Type Safety Back and Forth》。如果你想要关于这些想法的另一个易于理解的视角,包括另一个工作示例,我强烈鼓励你阅读它。对于这些想法的更高级的观点,我还可以推荐 Matt Noonan 的 2018 年论文《Ghosts of Departed Proofs》,该论文概述了在类型系统中捕获比我在这里描述的更复杂的不变量的几种技术。
作为结束语,我想说进行这篇博客文章中描述的那种重构并不总是容易的。我给出的例子很简单,但现实生活往往不那么直截了当。即使对于有类型驱动设计经验的人来说,在类型系统中捕获某些不变量也可能真正困难,所以如果你不能按照你想要的方式解决问题,不要认为这是个人失败!将这篇博客文章中的原则视为争取的理想,而不是要满足的严格要求。重要的是尝试。
核心要点
“解析,不要验证”这个原则的核心思想是:
-
让类型系统为你工作:通过精心设计数据类型,让非法状态在类型层面就无法表示,从而将运行时错误转化为编译时错误。
-
在边界处解析:尽可能早地(通常在系统边界)将数据解析为精确的类型表示,之后在整个程序中就可以安全地使用,无需重复验证。
-
保留信息而非丢弃:验证通常会丢弃检查后获得的信息(如
validateNonEmpty返回()),而解析则保留这些信息在类型系统中(如parseNonEmpty返回NonEmpty a)。 -
避免霰弹式解析:不要将验证逻辑分散在处理代码各处,这会导致难以维护和潜在的安全漏洞。
-
重构是关键:不要害怕为了更好的类型表示而重构代码。静态类型系统会帮助你确保重构的正确性。
这个原则不仅适用于 Haskell,对于任何具有强大类型系统的语言(如 TypeScript、Rust 等)都有借鉴意义。通过在类型系统中编码更多的约束,我们可以编写更安全、更可维护的代码。