好吧,因为您采用的是 Haskell 路线,所以要从几件事开始:
你熟悉库里-霍华德的对应关系吗?有一些用于基于此的机器检查证明的系统,在许多方面,它们只是具有非常强大的类型系统的函数式编程语言。
您是否熟悉为分析 Haskell 代码提供有用概念的抽象数学领域?各种各样的代数和一些范畴论出现了很多。
请记住,Haskell 和所有图灵完备的语言一样,总是有不终止的可能性。一般来说,证明某事永远为真比证明某事为真或取决于非终止值要困难得多。
如果您认真地寻求证明,而不仅仅是测试,那么这些都是要记住的事情。基本规则是:使无效状态导致编译器错误。首先防止无效数据被编码,然后让类型检查器为您完成繁琐的部分。
如果你想更进一步,如果没记错的话,证明助手Coq有一个“提取到 Haskell”功能,可以让你证明关键函数的任意属性,然后将证明转换为 Haskell 代码。
对于直接在 Haskell 中做花哨的类型系统的东西,Oleg Kiselyov 是大师。你可以在他的网站上找到一些巧妙技巧的例子,比如更高等级的多态类型来编码数组边界检查的静态证明。
对于更轻量级的东西,您可以使用类型级证书将一条数据标记为已检查正确性。您仍然需要自己进行正确性检查,但其他代码至少可以依靠知道某些数据实际上已经过检查。
你可以采取的另一个步骤,建立在轻量级验证和花哨的类型系统技巧之上,是使用 Haskell 作为嵌入特定领域语言的宿主语言这一事实;首先构建一个仔细限制的子语言(理想情况下,不是图灵完备的),您可以更轻松地证明有用的属性,然后使用该 DSL 中的程序在整个程序中提供核心功能的关键部分。例如,您可以证明两个参数的函数是关联的,以证明使用该函数并行减少项目集合的合理性(因为函数应用程序的顺序无关紧要,只有参数的顺序)。
哦,最后一件事。关于避免 Haskell 确实包含的陷阱的一些建议,这些陷阱可能会破坏原本安全的代码:您在这里的死敌是一般递归、IO
单子和部分函数:
最后一个相对容易避免:不要编写它们,也不要使用它们。确保每组模式匹配处理所有可能的情况,并且永远不要使用error
or undefined
。唯一棘手的部分是避免可能导致错误的标准库函数。有些显然是不安全的,例如fromJust :: Maybe a -> a
或head :: [a] -> a
但有些可能更微妙。如果您发现自己编写的函数确实无法对某些输入值执行任何操作,那么您将允许输入类型对无效状态进行编码,并且需要首先修复它。
第二种方法很容易在表面上避免,方法是通过各种纯函数分散内容,然后从IO
表达式中使用这些纯函数。更好的是,尽可能将整个程序移到纯代码中,以便可以使用除实际 I/O 之外的所有内容独立评估它。仅当您需要由外部输入驱动的递归时,这才变得很棘手,这使我想到了最后一项:
智者的话:有根据的递归和富有成效的核心递归. 始终确保递归函数要么从某个起点转到已知的基本案例,要么按需生成一系列元素。在纯代码中,最简单的方法是递归地折叠有限的数据结构(例如,在将计数器递增到某个最大值时,不是直接调用自身的函数,而是创建一个包含计数器值范围的列表并将其折叠起来)或递归生成惰性数据结构(例如,某个值的渐进近似列表),同时小心地不要直接将两者混合(例如,不要只是“找到满足某些条件的流中的第一个元素”;它可能不会相反,从流中取值直到某个最大深度,然后搜索有限列表,适当地处理未找到的情况)。
结合最后两项,对于您真正需要IO
使用一般递归的部分,尝试将程序构建为增量组件,然后将所有笨拙的部分压缩为单个“驱动程序”函数。例如,您可以使用纯函数(如mainLoop :: UIState -> Events -> UIState
)、退出测试(如quitMessage :: Events -> Bool
)、获取挂起事件getEvents :: IO Events
的函数和更新函数(update)编写 GUI 事件循环updateUI :: UIState -> IO ()
,然后使用通用函数(如runLoopIO :: (b -> a -> b) -> b -> IO a -> (b -> IO ()) -> IO ()
. 这使复杂的部分真正保持纯净,让您使用事件脚本运行整个程序并检查生成的 UI 状态,同时将笨拙的递归 I/O 部分隔离为一个易于理解且通常不可避免地正确的单一抽象函数通过参数化。