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


11

私はこの質問で、関数型プログラマーは数学的な証明を使用して、プログラムが正しく機能していることを確認する傾向があることを読みました。これは、単体テストよりもずっと簡単で高速に聞こえますが、OOP /単体テストのバックグラウンドから来たので、一度も見たことがないです。

それを私に説明し、例を挙げてもらえますか?


7
「これは、単体テストよりも簡単で高速に聞こえます」。ええ、音。実際には、ほとんどのソフトウェアでは実際に不可能です。そして、なぜタイトルがモジュール性について言及しているのに、検証について話しているのですか?
陶酔14

@Euphoric OOPの単体テストでは、検証のためのテストを作成します...ソフトウェアの一部が正常に動作していることを検証しますが、懸念が分離されていることも検証します...
leeand00 14

2
@Euphoric突然変異と継承を悪用し、型システムに欠陥がある言語で作業している(つまり、持っているnull)場合のみ。
ドーバル14

@ leeand00「検証」という用語を誤用していると思います。モジュール性と再利用性は、ソフトウェア検証によって直接チェックされません(もちろん、モジュール性の欠如はソフトウェアの保守と再利用を難しくし、バグを導入して検証プロセスに失敗します)。
アンドレスF。14年

モジュール方式で記述されている場合、ソフトウェアの一部を検証するのがはるかに簡単です。そのため、一部の関数では関数が正常に機能することを実際に証明できますが、他の関数では単体テストを作成できます。
グリズワコ

回答:


22

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

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

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

これにより、tree値が正確に2種類(またはクラスのOOPコンセプトと混同しないように)になりうる新しいタイプが導入されます- Empty情報を持たないNode値、および最初と最後の3タプルを運ぶ値要素は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;
        }
    }
}

ツリー型の変数は決してありえないという警告がありますnull

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

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

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

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

height正しく実装されたことをどのように証明できますか?我々は使用することができ、構造誘導ことが証明1.で構成されて、heightベースケース(S)で正しいです、私たちのtree(タイプEmpty再帰呼び出しをすると仮定すると2) height、正しいことが証明height秒(非ベースケースのために正しいです)(ツリーが実際にである場合Node)。

ステップ1では、引数がEmptyツリーの場合、関数は常に0を返すことがわかります。これは、ツリーの高さの定義によって正しいです。

ステップ2の場合、関数はを返します1 + max( height(leftChild), height(rightChild) )。再帰呼び出しが本当に子の身長を返すと仮定すると、これも正しいことがわかります。

そして、これで証明が完了しました。ステップ1と2を組み合わせることで、すべての可能性が尽きます。ただし、突然変異やヌルはなく、2種類のツリーがあることに注意してください。これらの3つの条件を取り除けば、非実用的でないとしても、証明はすぐに複雑になります。


編集:この答えが一番上に上がったので、証明のささいな例を追加し、構造誘導をもう少し徹底的にカバーしたいと思います。私たちの上にいることを証明した場合にheight戻り、その戻り値が正しいです。ただし、常に値を返すことを証明していません。構造帰納法を使用してこれを証明することもできます(または他のプロパティ)。繰り返しますが、ステップ2では、再帰呼び出しがすべての直接の子で動作する限り、再帰呼び出しのプロパティ保持を仮定できます。木。

関数は、例外をスローする場合と永久にループする場合の2つの状況で値を返すことができません。まず、例外がスローされない場合、関数が終了することを証明しましょう。

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

  2. 関数が非ベースケースで終了することを証明します(Node)。:3つあり、関数呼び出しはここだ+maxheight。これらは言語の標準ライブラリの一部であり、そのように定義されているため、それを認識し+max終了します。前に述べたように、再帰サブコールが直接のサブツリーで動作する限り、証明しようとしているプロパティがtrueであると仮定することがheightできます。

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

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

証明が怖いものや複雑なものである必要がないことを示してくれることを願っています。実際、コードを書くときはいつでも、頭の中に非公式に証拠を構築しました(そうでなければ、関数を実装しただけだと確信することはありません)。かなり簡単に修正できます。これらの制限は、あなたが考えるほど厳しくない:

  • null 言語の欠陥であり、それを廃止することは無条件に良いことです。
  • 突然変異は避けられないことがあり、必要な場合もありますが、特に永続的なデータ構造を持っている場合は、思っているほど頻繁に必要ではありません。
  • クラスの数(機能的な意味で)/サブクラス(OOPの意味)と数に制限がないということに関しては、1つの答えには大きすぎる主題です。そこには設計上のトレードオフがあると言えば十分です-正確性の証明可能性と拡張の柔軟性。

8
  1. すべてが不変である場合、コードについて推論するのは非常に簡単です。その結果、ループは再帰としてより頻繁に記述されます。一般に、再帰的なソリューションの正確さを確認する方が簡単です。多くの場合、このような解決策は、問題の数学的定義と非常によく似ています。

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

  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未満の数値に依存している場合、それを証明できるはずです。型システムができないことは確かです(少なくとも、有効なプログラムのトンを排除することなく)-できます。
ドーバル14

@Dovalはい。ただし、型システムは証明のためのシステムの一例にすぎません。型システムは非常に視覚的に制限されており、特定のステートメントの真実を評価することはできません。人は非常に複雑な証明を実行できますが、証明できることはまだ限られてます。越えられない限界がまだあります、それはただ遠くです。
アモン14

1
同意したが、この例は少し誤解を招くものだったと思う。
ドーバル14

2
イドリスのような依存型付け言語では、それも可能、それはより低い返す証明するかもしれない10.
インゴ・

2
おそらく、@ Dovalが提起する懸念に対処するためのより良い方法は、いくつかの問題が決定できない(例:問題を停止する)、証明するのに時間がかかりすぎる、または結果を証明するために新しい数学を発見する必要があると述べることです。私の個人的な意見では、何かが真実であることが証明されれば、それを単体テストする必要はないということを明確にすべきだと思います。証明はすでに上限と下限を設定しています。プルーフとテストが互換性がない理由は、プルーフが非常に困難であるか、まっすぐに不可能であるためです。また、テストを自動化できます(コードが変更された場合)。
トーマスエディング14

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削除した結果である木にkfromからt、結果はNothing(見つからないことを示す)になります。

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

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

このように、テストは本当に楽しいです。また、クイックチェックプロパティの読み取りを学習すると、マシンテスト可能な仕様として機能します。


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)

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

1つのアプローチは、テストケースを記述することです。このアプローチの基本的な制限は、有限数のケースで動作を検証していることです(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の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つのアプリケーションによる

-1

「上記のコードのバグに注意してください。私はそれが正しいことを証明しただけで、試したことはありません。」 -ドナルド・クヌース

完璧な世界では、プログラマーは完璧であり、間違いを犯さないため、バグはありません。

完璧な世界では、コンピューター科学者と数学者も完璧であり、間違いもしないでください。

しかし、私たちは完璧な世界に住んでいません。そのため、プログラマーに頼って間違いを犯すことはできません。しかし、プログラムが正しいことを数学的に証明するコンピューター科学者が、その証明に誤りを犯したとは考えられません。したがって、彼のコードが機能することを証明しようとする人には注意を払いません。単体テストを作成し、コードが仕様に従って動作することを示します。他のことは何も私を納得させません。


5
ユニットテストにも間違いがあります。さらに重要なことは、テストではバグの存在のみを示すことができ、バグがないことを示すことはできません。@Ingoが彼の答えで言ったように、彼らは優れた健全性チェックを行い、証明をうまく補完しますが、それらに代わるものではありません。
ドーバル14
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.