我在这个问题中读到,功能程序员倾向于使用数学证明来确保其程序正常工作。这听起来比单元测试容易和快捷,但是来自OOP /单元测试的背景,我从未见过如此。
您能给我解释一下并举一个例子吗?
null
)的语言工作时。
我在这个问题中读到,功能程序员倾向于使用数学证明来确保其程序正常工作。这听起来比单元测试容易和快捷,但是来自OOP /单元测试的背景,我从未见过如此。
您能给我解释一下并举一个例子吗?
null
)的语言工作时。
Answers:
由于存在副作用,不受限制的继承以及null
作为每种类型的成员,因此在OOP世界中,证明要困难得多。大多数证明都依赖归纳原理来证明您已经涵盖了所有可能性,而所有这三种情况都使证明更加困难。
假设我们正在实现包含整数值的二叉树(为了使语法更简单,尽管不会改变任何东西,我也不会引入泛型编程。)在标准ML中,我将定义为这个:
datatype tree = Empty | Node of (tree * int * tree)
这引入了一个新类型,称为tree
其类型可以恰好有两个变体(或类,不要与类的OOP概念混淆)-一个Empty
不包含任何信息的Node
值,以及一个包含第一个和最后一个三元组的值元素是tree
s,中间元素是int
。在OOP中与该声明最接近的近似如下所示:
public class Tree {
private Tree() {} // Prevent external subclassing
public static final class Empty extends Tree {}
public static final class Node extends Tree {
public final Tree leftChild;
public final int value;
public final Tree rightChild;
public Node(Tree leftChild, int value, Tree rightChild) {
this.leftChild = leftChild;
this.value = value;
this.rightChild = rightChild;
}
}
}
需要注意的是,Tree类型的变量永远都不会是null
。
现在,让我们编写一个函数来计算树的高度(或深度),并假定我们可以访问max
返回两个数字中较大的一个的函数:
fun height(Empty) =
0
| height(Node (leftChild, value, rightChild)) =
1 + max( height(leftChild), height(rightChild) )
我们已经height
根据案例定义了函数-有一个Empty
树定义和一个Node
树定义。编译器知道存在几类树,如果您没有定义两种情况,则会发出警告。Node (leftChild, value, rightChild)
函数签名中的表达式将3元组的值分别绑定到,和变量leftChild
,因此我们可以在函数定义中引用它们。类似于在OOP语言中声明这样的局部变量:value
rightChild
Tree leftChild = tuple.getFirst();
int value = tuple.getSecond();
Tree rightChild = tuple.getThird();
我们如何证明我们已height
正确实施?我们可以使用结构归纳法,它包括:1. height
在tree
类型(Empty
)的基本情况下证明是正确的。2.假定对的递归调用height
是正确的,证明height
对非基本情况是正确的)(当树实际上是时Node
)。
对于步骤1,我们可以看到当参数为Empty
树时,函数始终返回0 。通过定义树的高度,这是正确的。
对于步骤2,函数返回1 + max( height(leftChild), height(rightChild) )
。假设递归调用确实确实返回了子代的高度,我们可以看到这也是正确的。
这就完成了证明。步骤1和步骤2相结合,耗尽了所有可能性。但是请注意,我们没有突变,没有空值,并且恰好有两种树木。如果不考虑这三个条件,证明很快就会变得更加复杂,甚至不切实际。
编辑:由于这个答案已经上升到了最高点,我想添加一个不那么平凡的证明示例,并更全面地介绍结构归纳法。上面我们证明,如果height
回报,它的返回值是正确的。我们还没有证明它总是返回一个值。我们也可以使用结构归纳来证明这一点(或任何其他属性。)同样,在第2步中,只要递归调用均在递归调用的所有直接子元素上进行操作,就可以假定递归调用的属性成立。树。
函数在两种情况下可能无法返回值:如果它引发异常,并且它永远循环。首先让我们证明,如果没有抛出异常,函数将终止:
证明(如果没有抛出异常)该函数针对基本情况(Empty
)终止。由于我们无条件返回0,因此终止。
证明该函数在非基本情况下终止(Node
)。有三个函数调用这里:+
,max
,和height
。我们知道+
并max
终止,因为它们是语言标准库的一部分,并且以这种方式定义。如前所述,只要递归调用在直接子树上运行,我们就可以假定我们试图证明的属性为true,因此调用也可以height
终止。
证明到此结束。请注意,您将无法通过单元测试证明终止。现在剩下的就是显示height
不会抛出异常。
height
不会在基本情况(Empty
)上引发异常。返回0不会引发异常,所以我们完成了。height
在非基本案例(Node
)上不会引发异常。再次假设我们知道+
并且max
不会抛出异常。并且结构归纳使我们能够假定递归调用也不会抛出任何异常(因为对树的直接子代进行操作)。但是,请等待!此函数是递归的,但不是尾递归的。我们可以炸掉筹码!我们的尝试证明已发现一个错误。我们可以通过更改height
为尾递归来解决它。我希望这表明证明不必太吓人或复杂。实际上,每当编写代码时,您都在脑海中非正式地构造了一个证明(否则,您不会相信您只是实现了该函数。)通过避免null,不必要的变异和不受限制的继承,您可以证明自己的直觉是更容易纠正。这些限制并不像您想象的那么严厉:
当一切都是不可变的时,对代码进行推理要容易得多。结果,循环通常被写为递归。通常,更容易验证递归解决方案的正确性。通常,这种解决方案也将非常类似于问题的数学定义。
但是,在大多数情况下,几乎没有动力进行正确的正式形式证明。证明很困难,需要很多(人工)时间,并且投资回报率很低。
某些功能语言(尤其是ML系列)具有极富表现力的类型系统,可以更加完整地保证使用C风格的类型系统(但在通用语言中,泛型等概念也很常见)。当程序通过类型检查时,这是一种自动证明。在某些情况下,这将能够检测到一些错误(例如,在递归中忘记基本情况,或者在模式匹配中忘记处理某些情况)。
另一方面,这些类型的系统必须保持非常有限,以使其可判定。因此,从某种意义上讲,我们通过放弃灵活性而获得了静态保证–这些限制是为什么存在复杂的学术论文的原因,该论文遵循“ 在Haskell中解决问题的单解 ”。
我既喜欢非常宽松的语言,又喜欢非常有限的语言,并且都有各自的困难。但是并不是每个人都会“更好”,每个人对于另一种任务来说都更加方便。
然后必须指出,证明和单元测试不可互换。它们都使我们能够限制程序的正确性:
测试为正确性设定了上限:如果测试失败,则程序是错误的;如果没有测试失败,则我们可以确定该程序将处理已测试的案例,但是仍然可能存在未发现的错误。
int factorial(int n) {
if (n <= 1) return 1;
if (n == 2) return 2;
if (n == 3) return 6;
return -1;
}
assert(factorial(0) == 1);
assert(factorial(1) == 1);
assert(factorial(3) == 6);
// oops, we forgot to test that it handles n > 3…
证明为正确性设定了下限:可能无法证明某些属性。例如,很容易证明一个函数总是返回一个数字(这就是类型系统所做的事情)。但是,可能无法证明这个数字将一直存在< 10
。
int factorial(int n) {
return n; // FIXME this is just a placeholder to make it compile
}
// type system says this will be OK…
在这里可能会发出警告的提示:
虽然其他人通常在这里写的东西(简而言之,高级类型系统,不变性和引用透明性对正确性有很大贡献),但在功能世界中不进行测试并非并非如此。相反!
这是因为我们拥有Quickcheck之类的工具,可以自动且随机地生成测试用例。您仅声明函数必须遵守的法律,然后快速检查将检查数百个随机测试用例的这些法律。
您会看到,这比在一些测试用例上进行简单的相等性检查要高一些。
这是AVL树的实现示例:
--- A generator for arbitrary Trees with integer keys and string values
aTree = arbitrary :: Gen (Tree Int String)
--- After insertion, a lookup with the same key yields the inserted value
p_insert = forAll aTree (\t ->
forAll arbitrary (\k ->
forAll arbitrary (\v ->
lookup (insert t k v) k == Just v)))
--- After deletion of a key, lookup results in Nothing
p_delete = forAll aTree (\t ->
not (null t) ==> forAll (elements (keys t)) (\k ->
lookup (delete t k) k == Nothing))
第二条定律(或属性)可以理解如下:对于所有任意树t
,以下内容成立:如果 t
不为空,则对于k
该树的所有键,它将保留k
在树中查找的结果,这是删除的结果k
从t
,结果将是Nothing
(指示:未找到)。
这将检查删除现有密钥的正确功能。删除不存在的密钥应遵循哪些法律?我们当然希望结果树与我们从中删除的树相同。我们可以很容易地表达这一点:
p_delete_nonexistant = forAll aTree (\t ->
forAll arbitrary (\k ->
k `notElem` keys t ==> delete t k == t))
这样,测试真的很有趣。此外,一旦您学习了Quickcheck属性,它们就可以作为机器可测试的规范。
我不完全理解链接的答案通过“通过数学定律实现模块化”的含义,但是我认为我有一个含义。
Functor类的定义如下:
class Functor f where fmap :: (a -> b) -> f a -> f b
它与测试用例无关,而是与一些必须满足的法律相关。
Functor的所有实例都应遵守:
fmap id = id fmap (p . q) = (fmap p) . (fmap q)
现在假设您实现了Functor
(source):
instance Functor Maybe where
fmap _ Nothing = Nothing
fmap f (Just a) = Just (f a)
问题是要验证您的实施是否符合法律规定。您如何去做?
一种方法是编写测试用例。这种方法的基本局限性在于您要在有限的情况下验证行为(祝您好运,包括8个参数的功能详尽的测试!),因此通过测试不能保证测试通过。
另一种方法是基于实际定义(而不是有限数量的情况下的行为)使用数学推理,即证明。这里的想法是数学证明可能更有效。但是,这取决于您的程序对数学证明的接受程度。
我无法指导您通过实际的形式证明上述Functor
实例满足法律要求,但我将尝试概述该证明的外观:
fmap id = id
Nothing
fmap id Nothing
= Nothing
由实施的第1部分id Nothing
= Nothing
通过定义id
Just x
fmap id (Just x)
= Just (id x)
= Just x
通过实现的第2部分,然后通过定义id
fmap (p . q) = (fmap p) . (fmap q)
Nothing
fmap (p . q) Nothing
= Nothing
第1部分(fmap p) . (fmap q) $ Nothing
= (fmap p) $ Nothing
= Nothing
通过第1部分的两次应用Just x
fmap (p . q) (Just x)
= Just ((p . q) x)
= Just (p (q x))
由第2部分定义,然后由.
(fmap p) . (fmap q) $ (Just x)
= (fmap p) $ (Just (q x))
= Just (p (q x))
通过第二部分的两次应用“当心上面代码中的错误;我只是证明了它是正确的,没有尝试过。” -唐纳德·努斯
在一个完美的世界中,程序员是完美的,不会犯错误,因此没有错误。
在一个理想的世界中,计算机科学家和数学家也很完美,也不会犯错误。
但是我们没有生活在一个完美的世界中。因此,我们不能依靠程序员没有犯错误。但是我们不能假设任何提供数学证明程序正确的计算机科学家都不会在该证明中犯任何错误。因此,我不会对试图证明其代码有效的任何人给予任何关注。编写单元测试,并向我展示代码是否符合规范。其他任何事情都不会使我信服。