在函数式编程中,如何通过数学定律实现模块化?


11

我在这个问题中读到,功能程序员倾向于使用数学证明来确保其程序正常工作。这听起来比单元测试容易和快捷,但是来自OOP /单元测试的背景,我从未见过如此。

您能给我解释一下并举一个例子吗?


7
“这听起来比单元测试容易和快捷得多”。是的,听起来。实际上,大多数软件实际上是不可能的。为什么标题提到模块化但您在谈论验证呢?
欣快2014年

@Euphoric在OOP中的单元测试中,您编写测试以进行验证...验证软件的一部分是否正常运行,还验证您的关注点是否分开...即模块化和可重用性...如果我理解正确的话。
leeand00 2014年

2
@Euphoric仅当您滥用突变和继承并且使用有缺陷的类型系统(即have null)的语言工作时。
2014年

@ leeand00我认为您在滥用“验证”一词。软件验证不会直接检查模块性和可重用性(当然,缺少模块性会使软件更难以维护和重用,从而引入错误并导致验证过程失败)。
Andres F.

如果以模块化方式编写软件,则验证软件的各个部分要容易得多。因此,您可以真正证明该功能对于某些功能可以正常使用,对于其他功能则可以编写单元测试。
grizwako 2014年

Answers:


22

由于存在副作用,不受限制的继承以及null作为每种类型的成员,因此在OOP世界中,证明要困难得多。大多数证明都依赖归纳原理来证明您已经涵盖了所有可能性,而所有这三种情况都使证明更加困难。

假设我们正在实现包含整数值的二叉树(为了使语法更简单,尽管不会改变任何东西,我也不会引入泛型编程。)在标准ML中,我将定义为这个:

datatype tree = Empty | Node of (tree * int * tree)

这引入了一个新类型,称为tree其类型可以恰好有两个变体(或类,不要与类的OOP概念混淆)-一个Empty不包含任何信息的Node值,以及一个包含第一个和最后一个三元组的值元素是trees,中间元素是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语言中声明这样的局部变量:valuerightChild

Tree leftChild = tuple.getFirst();
int value = tuple.getSecond();
Tree rightChild = tuple.getThird();

我们如何证明我们已height正确实施?我们可以使用结构归纳法,它包括:1. heighttree类型(Empty)的基本情况下证明是正确的。2.假定对的递归调用height是正确的,证明height对非基本情况是正确的)(当树实际上是时Node)。

对于步骤1,我们可以看到当参数为Empty树时,函数始终返回0 。通过定义树的高度,这是正确的。

对于步骤2,函数返回1 + max( height(leftChild), height(rightChild) )。假设递归调用确实确实返回了子代的高度,我们可以看到这也是正确的。

这就完成了证明。步骤1和步骤2相结合,耗尽了所有可能性。但是请注意,我们没有突变,没有空值,并且恰好有两种树木。如果不考虑这三个条件,证明很快就会变得更加复杂,甚至不切实际。


编辑:由于这个答案已经上升到了最高点,我想添加一个不那么平凡的证明示例,并更全面地介绍结构归纳法。上面我们证明,如果height回报,它的返回值是正确的。我们还没有证明它总是返回一个值。我们也可以使用结构归纳来证明这一点(或任何其他属性。)同样,在第2步中,只要递归调用均在递归调用的所有直接子元素上进行操作,就可以假定递归调用的属性成立。树。

函数在两种情况下可能无法返回值:如果它引发异常,并且它永远循环。首先让我们证明,如果没有抛出异常,函数将终止:

  1. 证明(如果没有抛出异常)该函数针对基本情况(Empty)终止。由于我们无条件返回0,因此终止。

  2. 证明该函数在非基本情况下终止(Node)。有三个函数调用这里:+max,和height。我们知道+max终止,因为它们是语言标准库的一部分,并且以这种方式定义。如前所述,只要递归调用在直接子树上运行,我们就可以假定我们试图证明的属性为true,因此调用也可以height终止。

证明到此结束。请注意,您将无法通过单元测试证明终止。现在剩下的就是显示height不会抛出异常。

  1. 证明height不会在基本情况(Empty)上引发异常。返回0不会引发异常,所以我们完成了。
  2. 证明height在非基本案例(Node)上不会引发异常。再次假设我们知道+并且max不会抛出异常。并且结构归纳使我们能够假定递归调用也不会抛出任何异常(因为对树的直接子代进行操作)。但是,请等待!此函数是递归的,但不是尾递归的。我们可以炸掉筹码!我们的尝试证明已发现一个错误。我们可以通过更改height为尾递归来解决它。

我希望这表明证明不必太吓人或复杂。实际上,每当编写代码时,您都在脑海中非正式地构造了一个证明(否则,您不会相信您只是实现了该函数。)通过避免null,不必要的变异和不受限制的继承,您可以证明自己的直觉是更容易纠正。这些限制并不像您想象的那么严厉:

  • null 是一种语言缺陷,因此无条件地消除它是不错的选择。
  • 突变有时是不可避免的,而且是必要的,但是它的使用频率比您想象的要少得多-特别是当您具有持久性数据结构时。
  • 至于具有有限数量的类(在功能上)/子类(从OOP上)与无限数量的类,对于单个答案而言这是一个太大的主题。可以说有一个设计折衷方案-正确性的可证明性与扩展的灵活性。

8
  1. 一切都是不可变的时,对代码进行推理要容易得多。结果,循环通常被写为递归。通常,更容易验证递归解决方案的正确性。通常,这种解决方案也将非常类似于问题的数学定义。

    但是,在大多数情况下,几乎没有动力进行正确的正式形式证明。证明很困难,需要很多(人工)时间,并且投资回报率很低。

  2. 某些功能语言(尤其是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…
    

1
“可能无法证明某些性质...但是可能无法证明数字始终小于10。” 如果程序的正确性取决于小于10的数字,则应该可以证明它。的确,类型系统不能(至少在不排除大量有效程序的情况下)-但是可以。
2014年

@Doval是的。但是,类型系统只是证明系统的一个示例。类型系统非常明显地受限制,无法评估某些语句的真实性。一个人可以开展极大地更复杂的证明,但仍然会在被限制什么,他可以证明。仍然有一个不可逾越的极限,它距离更远。
阿蒙2014年

1
同意,我只是认为该示例有些误导。
Doval

2
在依赖性类型的语言,如伊德里斯,它甚至可能可以证明它返回低于10
英戈

2
解决@Doval引起的担忧的一种更好的方法可能是声明某些问题是无法确定的(例如,暂停问题),需要太多时间来证明,或者需要发现新的数学来证明结果。我个人的观点是,您应该澄清一下,如果某事被证明是正确的,则无需进行单元测试。证明已经设定了上限和下限。证明和测试不可互换的原因是,证明可能太难做,或者根本不可能做。测试也可以自动化(用于代码更改时)。
Thomas Eding

7

在这里可能会发出警告的提示:

虽然其他人通常在这里写的东西(简而言之,高级类型系统,不变性和引用透明性对正确性有很大贡献),但在功能世界中不进行测试并非并非如此。相反

这是因为我们拥有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在树中查找的结果,这是删除的结果kt,结果将是Nothing(指示:未找到)。

这将检查删除现有密钥的正确功能。删除不存在的密钥应遵循哪些法律?我们当然希望结果树与我们从中删除的树相同。我们可以很容易地表达这一点:

p_delete_nonexistant = forAll aTree (\t ->
                          forAll arbitrary (\k -> 
                              k `notElem` keys t ==> delete t k == t))

这样,测试真的很有趣。此外,一旦您学习了Quickcheck属性,它们就可以作为机器可测试的规范


4

我不完全理解链接的答案通过“通过数学定律实现模块化”的含义,但是我认为我有一个含义。

Functor

Functor类的定义如下:

 class Functor f where
   fmap :: (a -> b) -> f a -> f b

它与测试用例无关,而是与一些必须满足的法律相关。

Functor的所有实例都应遵守:

 fmap id = id
 fmap (p . q) = (fmap p) . (fmap q)

现在假设您实现了Functorsource):

instance  Functor Maybe  where
    fmap _ Nothing       = Nothing
    fmap f (Just a)      = Just (f a)

问题是要验证您的实施是否符合法律规定。您如何去做?

一种方法是编写测试用例。这种方法的基本局限性在于您要在有限的情况下验证行为(祝您好运,包括8个参数的功能详尽的测试!),因此通过测试不能保证测试通过。

另一种方法是基于实际定义(而不是有限数量的情况下的行为)使用数学推理,即证明。这里的想法是数学证明可能更有效。但是,这取决于您的程序对数学证明的接受程度。

我无法指导您通过实际的形式证明上述Functor实例满足法律要求,但我将尝试概述该证明的外观:

  1. 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
  2. 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))通过第二部分的两次应用

-1

“当心上面代码中的错误;我只是证明了它是正确的,没有尝试过。” -唐纳德​​·努斯

在一个完美的世界中,程序员是完美的,不会犯错误,因此没有错误。

在一个理想的世界中,计算机科学家和数学家也很完美,也不会犯错误。

但是我们没有生活在一个完美的世界中。因此,我们不能依靠程序员没有犯错误。但是我们不能假设任何提供数学证明程序正确的计算机科学家都不会在该证明中犯任何错误。因此,我不会对试图证明其代码有效的任何人给予任何关注。编写单元测试,并向我展示代码是否符合规范。其他任何事情都不会使我信服。


5
单元测试也可能有错误。更重要的是,测试只能显示错误的存在-从来没有错误。正如@Ingo在他的回答中所说,它们确实进行了完善的检查,并很好地补充了证据,但它们并不能替代它们。
2014年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.