내가 읽어 이 질문에 기능적인 프로그래머가 자신의 프로그램이 제대로 작동하는지 확인 증명을 사용하는 경향이있다. 이것은 단위 테스트보다 훨씬 쉽고 빠르지 만 OOP / 단위 테스트 배경에서 나온 것입니다.
설명해 주시고 예를 들어 주시겠습니까?
null
.
내가 읽어 이 질문에 기능적인 프로그래머가 자신의 프로그램이 제대로 작동하는지 확인 증명을 사용하는 경향이있다. 이것은 단위 테스트보다 훨씬 쉽고 빠르지 만 OOP / 단위 테스트 배경에서 나온 것입니다.
설명해 주시고 예를 들어 주시겠습니까?
null
.
답변:
부작용, 무제한 상속 및 null
모든 유형의 구성원 이기 때문에 OOP 세계에서는 증명이 훨씬 어렵습니다 . 대부분의 증거는 모든 가능성을 다룬다는 것을 증명하기 위해 유도 원리에 의존하며,이 세 가지가 모두 증명하기 어렵게 만듭니다.
정수 값을 포함하는 이진 트리를 구현한다고 가정 해 봅시다 (구문을 더 단순하게 유지하기 위해 일반적인 프로그래밍을하지는 않지만 아무것도 변경하지는 않습니다). 표준 ML에서는 다음과 같이 정의합니다. 이:
datatype tree = Empty | Node of (tree * int * tree)
이것은 tree
값이 정확히 두 가지 종류 (또는 클래스의 OOP 개념과 혼동되지 않는 클래스)- Empty
정보를 전달하지 않는 Node
값과 처음과 마지막을 갖는 3 튜플 을 전달 하는 값으로 불리는 새로운 유형을 도입합니다. 요소는 tree
이며 중간 요소는입니다 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
, value
및 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 단계는 모든 가능성을 소진합니다. 그러나 우리에게는 돌연변이가없고, 널이 없으며, 정확히 두 종류의 나무가 있습니다. 이 세 가지 조건을 제거하면 실용적이지 않더라도 증거가 더 복잡해집니다.
편집 : 이 답변이 맨 위로 올라 갔으므로 증거의 간단한 예를 추가하고 구조적 유도를 좀 더 철저히 다루고 싶습니다. 위의 우리는 것을 증명 하는 경우 height
반환 , 반환 값이 올바른지. 그러나 항상 값을 반환한다는 것을 증명하지는 못했습니다. 구조적 유도를 사용하여이를 증명할 수도 있습니다 (또는 다른 자산). 다시 2 단계에서 재귀 호출이 모두 직계 자식에 대해 작동하는 한 재귀 호출의 속성이 보류되는 것으로 가정 할 수 있습니다. 나무.
함수는 두 가지 상황에서 값을 반환하지 못할 수 있습니다. 예외가 발생하는 경우와 영원히 반복되는 경우입니다. 먼저 예외가 발생하지 않으면 함수가 종료된다는 것을 증명해 봅시다.
예외가 발생하지 않으면 기본 사례 ( Empty
)에 대해 함수가 종료됨을 증명하십시오 . 무조건 0을 반환하기 때문에 종료됩니다.
기본이 아닌 경우 ( Node
) 에서 함수가 종료되는지 증명하십시오 . 여기 +
에는 max
,, 및 세 가지 함수 호출이 있습니다 height
. 우리는 알고 +
와 max
언어의 표준 라이브러리의 그들이 있기 때문에있는 거 부분을 종료하고 그들은 방법 것으로 정의하고있다. 앞에서 언급했듯이 재귀 호출이 즉각적인 하위 트리에서 작동하는 한 재귀 호출에 대해 입증하려는 속성이 true라고 가정 할 수 있으므로 호출도 height
종료됩니다.
이것으로 증명이 끝납니다. 단위 테스트로는 종료를 증명할 수 없습니다. 이제 남은 것은 height
예외를 발생시키지 않는 것을 보여주는 것입니다 .
height
기본 사례 ( Empty
) 에서 예외가 발생하지 않음을 증명하십시오 . 0을 반환하면 예외를 throw 할 수 없으므로 끝났습니다.height
기본이 아닌 경우 ( Node
) 에서 예외가 발생하지 않는다는 것을 증명하십시오 . 우리가 예외를 알고 +
있고 max
던지지 않는다고 다시 가정하십시오 . 그리고 구조적 유도는 재귀 호출이 (나무의 직계 자식에 대한 작업 때문에) 발생하지 않을 것이라고 가정합니다. 이 함수는 재귀이지만 꼬리 재귀는 아닙니다 . 우리는 스택을 날려 버릴 수 있습니다! 시도한 증거로 버그가 발견되었습니다. 꼬리 재귀 로 변경 height
하여 수정할 수 있습니다 .나는 이것이 증거가 무섭거나 복잡 할 필요가 없다는 것을 보여주기를 바란다. 실제로 코드를 작성할 때마다 비공식적으로 머리에 증거를 만들었습니다. 그렇지 않으면 함수를 구현했다고 확신 할 수 없습니다. null, 불필요한 돌연변이 및 무제한 상속을 피함으로써 직관이 상당히 쉽게 수정하십시오. 이러한 제한은 생각만큼 가혹하지 않습니다.
null
언어 결함이며 그것을 없애는 것은 무조건적으로 좋습니다.모든 것이 불변 인 경우 코드에 대해 추론하기가 훨씬 쉽다 . 결과적으로 루프는 재귀로 더 자주 작성됩니다. 일반적으로 재귀 솔루션의 정확성을 확인하는 것이 더 쉽습니다. 종종 이러한 솔루션은 문제의 수학적 정의와 매우 유사하게 읽 힙니다.
그러나 대부분의 경우 실제 공식적인 정확성 증명을 수행하려는 동기는 거의 없습니다. 증명은 어렵고 시간이 많이 걸리며 ROI가 낮습니다.
일부 기능적 언어 (예 : ML 제품군)는 C 표현형 시스템을 훨씬 더 완벽하게 보장 할 수있는 매우 표현적인 유형 시스템을 가지고 있습니다 (그러나 일반 언어와 같은 일부 아이디어는 주류 언어에서도 공통적으로 사용됨). 프로그램이 유형 검사를 통과하면 일종의 자동 증거입니다. 경우에 따라 일부 오류를 감지 할 수 있습니다 (예 : 재귀에서 기본 사례를 잊어 버리거나 패턴 일치에서 특정 사례를 처리하지 못함).
반면에, 이러한 유형의 시스템은 매우 그들을 유지하기 위해 제한 유지되어야한다 decidable . 따라서 어떤 의미에서 우리는 유연성을 포기함으로써 정적 인 보장을 얻습니다. 이러한 제한은“ 하스켈에서 해결 된 문제에 대한 모나드 솔루션 ”라인을 따라 복잡한 학술 논문이 존재하는 이유 입니다.
나는 매우 자유로운 언어와 매우 제한된 언어를 모두 즐기고 있으며 각자 어려움이 있습니다. 그러나 하나가 더 나은 경우는 아니며, 각각 다른 종류의 작업에 더 편리합니다.
그런 다음 증명과 단위 테스트를 서로 바꿔서 사용할 수 없다는 점을 지적해야합니다. 둘 다 프로그램의 정확성에 한계를 둘 수 있습니다.
테스트는 정확성에 상한선을 둔다 : 테스트가 실패하면 프로그램이 부정확하고, 테스트가 실패하지 않으면 프로그램이 테스트 된 사례를 처리 할 것이라고 확신하지만, 아직 발견되지 않은 버그가있을 수있다.
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)
문제는 구현이 법률을 충족하는지 확인하는 것입니다. 어떻게 그렇게합니까?
한 가지 방법은 테스트 사례를 작성하는 것입니다. 이 접근법의 근본적인 한계는 유한 한 수의 경우 (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))
파트 2의 두 가지 애플리케이션"위의 코드에서 버그에주의하십시오; 나는 그것이 시도 된 것이 아니라 단지 올바른 것으로 증명되었습니다." -도널드 크 누스
완벽한 세상에서 프로그래머는 완벽하고 실수를하지 않기 때문에 버그가 없습니다.
완벽한 세상에서 컴퓨터 과학자와 수학자 또한 완벽하며 실수도하지 않습니다.
그러나 우리는 완벽한 세상에 살고 있지 않습니다. 따라서 우리는 프로그래머가 실수하지 않도록 의존 할 수 없습니다. 그러나 우리는 프로그램이 정확하다는 수학적 증거 를 제공하는 컴퓨터 과학자 가 그 증거에 실수를하지 않았다고 가정 할 수 없습니다 . 그래서 그의 코드가 작동한다는 것을 증명 하려는 사람에게는주의를 기울이지 않을 것 입니다. 단위 테스트를 작성하고 코드가 사양에 따라 작동 함을 보여줍니다. 다른 어떤 것도 나에게 아무것도 확신하지 못합니다.