web-dev-qa-db-ja.com

関数型プログラミングでは、数学の法則を通じてモジュール性をどのように実現しますか?

私は この質問 を読んでいますが、関数型プログラマーは数学的な証明を使用してプログラムが正しく機能していることを確認する傾向があります。これは単体テストよりもはるかに簡単で高速に聞こえますが、OOP /単体テストの背景から来たので、私はそれが実行されたのを見たことはありません。

それを私に説明して、例を挙げていただけますか?

11
leeand00

OOPの世界では、副作用、無制限の継承、およびすべての型のメンバーであることによるnullのため、証明ははるかに困難です。ほとんどの証明は、すべてをカバーしたことを示すために帰納法の原則に依存しています可能性、そしてこれらすべての3つのことは、それを証明することを困難にします。

整数値を含むバイナリツリーを実装しているとしましょう(構文を簡単にするために、何も変更しませんが、一般的なプログラミングをこれに持ち込みません)。標準MLでは、次のように定義します。この:

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

これにより、treeと呼ばれる新しい型が導入されます。その値は、正確に2つの種類(またはクラス、OOPクラスの概念と混同しないでください))-情報を持たないEmpty値であり、最初の要素と最後の要素がNodesで、中央の要素がtreeである3​​タプルを含む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;
        }
    }
}
_

ツリー型の変数はnullにはならないことに注意してください。

次に、ツリーの高さ(または深さ)を計算する関数を記述して、2つの数値のうち大きい方を返すmax関数にアクセスできると仮定します。

_fun height(Empty) =
        0
 |  height(Node (leftChild, value, rightChild)) =
        1 + max( height(leftChild), height(rightChild) )
_

height関数をケースごとに定義しました。Emptyツリーの定義とNodeツリーの定義が1つずつあります。コンパイラーはツリーのクラスがいくつ存在するかを認識しており、両方のケースを定義しなかった場合は警告を出します。関数シグネチャの式Node (leftChild, value, rightChild)は、3-Tupleの値を変数leftChildvalue、およびrightChildにそれぞれバインドし、関数定義でそれらを参照できるようにします。 OOP言語でこのようにローカル変数を宣言したのと同じです:

_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を組み合わせると、すべての可能性がなくなります。ただし、変異やnullはなく、2種類のツリーがあることに注意してください。これら3つの条件を取り除くと、実用的ではないにしても、証明はすぐに複雑になります。


EDIT:この答えはトップに上がったので、簡単な証明の例を追加して、構造的誘導をもう少し完全にカバーしたいと思います。上記で、heightが返す場合、その戻り値が正しいことを証明しました。ただし、常に値が返されることは証明されていません。構造誘導を使用してこれを証明することもできます(またはその他のプロパティ)。ここでも、手順2で、再帰呼び出しがすべての直接の子で動作する限り、再帰呼び出しのプロパティが保持されていると想定できます。木。

関数は、例外をスローする場合と、ループが永久に続く場合の2つの状況で値を返せない可能性があります。最初に、例外がスローされない場合、関数が終了することを証明しましょう。

  1. (例外がスローされない場合)関数が基本ケース(Empty)で終了することを証明します。無条件に0を返すので終了します。

  2. 関数が基本以外の場合に終了することを証明します(Node)。ここには3つの関数呼び出しがあります:_+_、max、およびheight。 _+_およびmaxは、言語の標準ライブラリの一部であり、そのように定義されているため、終了することがわかっています。前述のように、再帰呼び出しが即時サブツリーで動作する限り、証明しようとしているプロパティがtrueであると想定できるため、heightの呼び出しも終了します。

これで証明は終わりです。単体テストでは終了を証明できないことに注意してください。あとは、heightが例外をスローしないことを示すだけです。

  1. heightが基本ケース(Empty)で例外をスローしないことを証明します。 0を返しても例外はスローされないので、これで完了です。
  2. heightが基本以外のケース(Node)で例外をスローしないことを証明します。 _+_とmaxが例外をスローしないことがわかっているともう一度仮定します。また、構造的誘導により、再帰呼び出しもスローしないと想定できます(ツリーの直接の子を操作するため)。この関数は再帰的ですが、 tail recursive ではありません。スタックを爆破する可能性があります! 証明を試みてバグを発見しました。heightを末尾再帰 に変更することで修正できます。

これが証明が怖いものや複雑なものである必要がないことを示しているといいのですが。実際、コードを書くときはいつでも、頭に非公式に証明を構築します(そうしないと、関数を実装しただけでは確信が持てません)。null、不必要な変更、および無制限の継承を回避することで、直感を証明できます。かなり簡単に修正します。これらの制限は、あなたが考えるほど厳しいものではありません。

  • nullは言語の欠陥であり、それを取り除くことは無条件に良いことです。
  • 突然変異は避けられない場合や必要な場合もありますが、特に永続的なデータ構造がある場合は、思ったよりもはるかに少ない頻度で必要です。
  • 有限数のクラス(機能的な意味で)/サブクラス(OOP意味で))と無制限の数を持つことについては、 それは対象としては大きすぎます単一の答え 。そこには設計のトレードオフがあると言えば十分です-正確性の証明vs拡張の柔軟性。
22
Doval
  1. すべてが不変 の場合、コードについて推論する方がはるかに簡単です。その結果、ループはより頻繁に再帰として記述されます。一般に、再帰的ソリューションの正当性を検証する方が簡単です。多くの場合、このような解決策は、問題の数学的定義と非常によく似ています。

    ただし、ほとんどの場合、正当性の実際の正式な証明を実行する動機はほとんどありません。証明は困難であり、(人の)時間がかかり、ROIが低くなります。

  2. 一部の関数型言語(特にMLファミリー)には、Cスタイルの型システムをより完全に保証できる非常に表現力豊かな型システムがあります(ただし、ジェネリックのようないくつかのアイデアが主流の言語でも一般的になっています)。プログラムが型チェックに合格した場合、これは一種の自動化された証明です。場合によっては、これによりいくつかのエラーを検出できます(たとえば、再帰で基本ケースを忘れたり、パターンマッチで特定のケースを処理し忘れたりする)。

    一方で、これらの型システムは decidable を維持するために非常に制限された状態に維持する必要があります。したがって、ある意味で、柔軟性を放棄することで静的な保証を得ることができます。これらの制限は、「Hasell」が存在します。

    私は非常にリベラルな言語と非常に制限された言語の両方を楽しんでいますが、どちらもそれぞれに困難があります。しかし、どちらかが「より良い」というわけではなく、それぞれが異なる種類のタスクに便利です。

次に、証明と単体テストは互換性がないことを指摘する必要があります。どちらを使用しても、プログラムの正確性に限界を設けることができます。

  • テストは正確さに上限を設けます。テストが失敗した場合、プログラムは正しくありません。テストが失敗しなかった場合、プログラムはテストされたケースを処理しますが、まだ発見されていないバグがある可能性があります。

    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…
    
8
amon

警告の言葉がここにあるかもしれません:

一般的に、他の人がここで書いていることは真実ですが、つまり、高度な型システム、不変性、参照の透明性が正確さに大きく貢献しています。テストが機能的な世界で行われていないというわけではありません。 逆に

これは、テストケースを自動的かつランダムに生成する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そのツリーのkkから削除した結果であるツリーでtを検索すると、結果はNothing(これはを示します:見つかりません)。

これにより、既存のキーの削除について適切な機能がチェックされます。存在しないキーの削除を管理する法律は何ですか?結果のツリーは、削除したツリーと同じにしたいはずです。これは簡単に表現できます。

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

このように、テストはとても楽しいです。さらに、クイックチェックプロパティの読み方を学ぶと、それらはマシンテスト可能な仕様として機能します。

7
Ingo

リンクされた答えが「数学の法則を通じてモジュール性を達成すること」が何を意味するのか正確に理解していませんが、私は何が意味されているのかを理解していると思います。

チェックアウト 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)
_

問題は、実装が法律を満たしていることを確認することです。どうやってそれをやりますか?

1つのアプローチは、テストケースを作成することです。このアプローチの基本的な制限は、有限数のケースで動作を検証することです(8つのパラメーターを使用して関数を徹底的にテストしてください!)、テストに合格しても、テストに合格することしか保証できません。

別のアプローチは、数学的推論、つまり証明を使用することです実際の定義に基づいて(限られた数のケースでの動作ではなく)。ここでの考え方は、数学的証明がより効果的であるかもしれないということです。ただし、これは、プログラムが数学的な証明にどの程度順応できるかに依存します。

上記のFunctorインスタンスが法律を満たしているという実際の正式な証明についてはご案内できませんが、証明がどのように見えるかについて概要を説明します。

  1. _fmap id = id_
    • Nothing がある場合
      • _fmap id Nothing_ = Nothing実装のパート1
      • _id Nothing_ = Nothingidの定義による
    • _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の2つのアプリケーションによる
    • _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))パート2の2つのアプリケーションによる
4
user39685