私はこの質問で、関数型プログラマーは数学的な証明を使用して、プログラムが正しく機能していることを確認する傾向があることを読みました。これは、単体テストよりもずっと簡単で高速に聞こえますが、OOP /単体テストのバックグラウンドから来たので、一度も見たことがないです。
それを私に説明し、例を挙げてもらえますか?
null
)場合のみ。
私はこの質問で、関数型プログラマーは数学的な証明を使用して、プログラムが正しく機能していることを確認する傾向があることを読みました。これは、単体テストよりもずっと簡単で高速に聞こえますが、OOP /単体テストのバックグラウンドから来たので、一度も見たことがないです。
それを私に説明し、例を挙げてもらえますか?
null
)場合のみ。
回答:
副作用、無制限の継承、およびnull
あらゆるタイプのメンバーであるため、OOPの世界では証明がはるかに困難です。ほとんどの証明は、すべての可能性を網羅していることを示すために帰納法の原理に依存しており、これらの3つすべてが証明を難しくしています。
整数値を含むバイナリツリーを実装しているとしましょう(構文を簡単にするために、一般的なプログラミングはこれに入れませんが、何も変更しません)。標準MLでは、次のように定義します。この:
datatype tree = Empty | Node of (tree * int * tree)
これにより、tree
値が正確に2種類(またはクラスのOOPコンセプトと混同しないように)になりうる新しいタイプが導入されます- Empty
情報を持たないNode
値、および最初と最後の3タプルを運ぶ値要素は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;
}
}
}
ツリー型の変数は決してありえないという警告がありますnull
。
次に、ツリーの高さ(または深さ)を計算する関数を作成し、max
2つの数値のうち大きい方を返す関数にアクセスできると仮定します。
fun height(Empty) =
0
| height(Node (leftChild, value, rightChild)) =
1 + max( height(leftChild), height(rightChild) )
height
ケースごとに関数を定義しました- Empty
ツリーとツリーにそれぞれ1つの定義がNode
あります。コンパイラーはツリーのクラスがいくつあるかを認識しており、両方のケースを定義しなかった場合は警告を発行します。表現Node (leftChild, value, rightChild)
関数のシグネチャでは、変数に3組の値を結合しleftChild
、value
および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つの状況で値を返すことができません。まず、例外がスローされない場合、関数が終了することを証明しましょう。
(例外がスローされない場合)関数が基本ケース(Empty
)で終了することを証明します。無条件に0を返すため、終了します。
関数が非ベースケースで終了することを証明します(Node
)。:3つあり、関数呼び出しはここだ+
、max
とheight
。これらは言語の標準ライブラリの一部であり、そのように定義されているため、それを認識し+
てmax
終了します。前に述べたように、再帰サブコールが直接のサブツリーで動作する限り、証明しようとしているプロパティがtrueであると仮定することがheight
できます。
これで証明は終わりです。単体テストでは終了を証明できないことに注意してください。残っているのは、height
例外をスローしないことを示すことだけです。
height
基本ケース(Empty
)で例外をスローしないことを証明します。0を返すことで例外をスローすることはできませんので、完了です。height
非ベースケースで例外をスローしないことを証明します(Node
)。例外が発生していることを知って+
おり、max
スローしないことをもう一度想定します。また、構造の帰納法により、再帰呼び出しもスローされないと想定することができます(ツリーの直接の子を操作するためです)。この関数は再帰的ですが、末尾再帰的ではありません。スタックを爆破できました!私たちの試みた証拠はバグを発見しました。末尾再帰に変更height
することで修正できます。証明が怖いものや複雑なものである必要がないことを示してくれることを願っています。実際、コードを書くときはいつでも、頭の中に非公式に証拠を構築しました(そうでなければ、関数を実装しただけだと確信することはありません)。かなり簡単に修正できます。これらの制限は、あなたが考えるほど厳しくない:
null
言語の欠陥であり、それを廃止することは無条件に良いことです。すべてが不変である場合、コードについて推論するのは非常に簡単です。その結果、ループは再帰としてより頻繁に記述されます。一般に、再帰的なソリューションの正確さを確認する方が簡単です。多くの場合、このような解決策は、問題の数学的定義と非常によく似ています。
しかし、ほとんどの場合、正確性の実際の正式な証明を実行する動機はほとんどありません。証明は難しく、多くの(人間の)時間を要し、ROIは低くなります。
一部の関数型言語(特に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
fromからt
、結果はNothing
(見つからないことを示す)になります。
これにより、既存のキーを削除するための適切な機能がチェックされます。存在しないキーの削除を管理する法律は何ですか?確かに、結果のツリーは削除したツリーと同じになります。これは簡単に表現できます。
p_delete_nonexistant = forAll aTree (\t ->
forAll arbitrary (\k ->
k `notElem` keys t ==> delete t k == t))
このように、テストは本当に楽しいです。また、クイックチェックプロパティの読み取りを学習すると、マシンテスト可能な仕様として機能します。
「数学の法則を通じてモジュール性を達成する」ことでリンクされた答えが何を意味するのかを正確に理解していませんが、私は何を意味するのか考えていると思います。
Functorをご覧ください。
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)
問題は、実装が法律を満たしていることを確認することです。それをどうやってやるの?
1つのアプローチは、テストケースを記述することです。このアプローチの基本的な制限は、有限数のケースで動作を検証していることです(8つのパラメーターを使用して関数を徹底的にテストすることをお祈りします!)。
別のアプローチは、(限られた数の場合の行動ではなく)実際の定義に基づいた数学的推論、すなわち証明を使用することです。ここでの考え方は、数学的証明がより効果的かもしれないということです。ただし、これは、プログラムが数学的に証明できるかどうかに依存します。
上記のFunctor
例が法律を満たしているという実際の正式な証明を案内することはできませんが、証明がどのように見えるかについての概要を説明します。
fmap id = id
Nothing
fmap id Nothing
= Nothing
実装のパート1id 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の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つのアプリケーションによる「上記のコードのバグに注意してください。私はそれが正しいことを証明しただけで、試したことはありません。」 -ドナルド・クヌース
完璧な世界では、プログラマーは完璧であり、間違いを犯さないため、バグはありません。
完璧な世界では、コンピューター科学者と数学者も完璧であり、間違いもしないでください。
しかし、私たちは完璧な世界に住んでいません。そのため、プログラマーに頼って間違いを犯すことはできません。しかし、プログラムが正しいことを数学的に証明するコンピューター科学者が、その証明に誤りを犯したとは考えられません。したがって、彼のコードが機能することを証明しようとする人には注意を払いません。単体テストを作成し、コードが仕様に従って動作することを示します。他のことは何も私を納得させません。