Czytam w tym pytaniu, że programiści funkcjonalni używają matematycznych dowodów, aby upewnić się, że ich program działa poprawnie. Brzmi to o wiele łatwiej i szybciej niż testy jednostkowe, ale pochodzące z tła OOP / testów jednostkowych Nigdy tego nie widziałem.
Czy możesz mi to wyjaśnić i podać przykład?
testing
functional-programming
leeand00
źródło
źródło
null
).Odpowiedzi:
Dowód jest znacznie trudniejszy w świecie OOP ze względu na skutki uboczne, nieograniczone dziedziczenie i
null
bycie członkiem każdego rodzaju. Większość dowodów opiera się na zasadzie indukcji, aby pokazać, że wykorzystałeś wszystkie możliwości, a wszystkie 3 z tych rzeczy utrudniają udowodnienie.Powiedzmy, że wdrażamy drzewa binarne, które zawierają wartości całkowite (dla uproszczenia składni nie będę wprowadzał do tego programowania ogólnego, chociaż nic by to nie zmieniło). W Standardowym ML zdefiniowałbym to jako to:
datatype tree = Empty | Node of (tree * int * tree)
Wprowadza to nowy typ o nazwie,
tree
którego wartości mogą występować w dokładnie dwóch odmianach (lub klasach, których nie należy mylić z koncepcją OOP klasy) -Empty
wartość, która nie przenosi żadnych informacji, orazNode
wartości, które niosą 3-krotkę, której pierwsza i ostatnia elementy totree
s, a środkowym elementem jestint
. Najbliższe zbliżenie do tej deklaracji w OOP wyglądałoby tak:Z zastrzeżeniem, że zmienne typu Drzewo nigdy nie mogą być
null
.Napiszmy teraz funkcję do obliczenia wysokości (lub głębokości) drzewa i załóżmy, że mamy dostęp do
max
funkcji, która zwraca większą z dwóch liczb:Zdefiniowaliśmy
height
funkcję według przypadków - istnieje jedna definicja dlaEmpty
drzew i jedna definicja dlaNode
drzew. Kompilator wie, ile istnieje klas drzew i wyda ostrzeżenie, jeśli nie zdefiniujesz obu przypadków. WyrażenieNode (leftChild, value, rightChild)
w podpisie funkcji wiąże wartości 3-krotki do zmiennychleftChild
,value
orazrightChild
odpowiednio więc możemy odnieść się do nich w definicji funkcji. Jest to podobne do zadeklarowania takich zmiennych lokalnych w języku OOP:Jak możemy udowodnić, że
height
poprawnie wdrożyliśmy ? Możemy użyć indukcji strukturalnej , która składa się z: 1. Udowodnij, żeheight
jest poprawna w przypadku (przypadkach) bazowych naszegotree
typu (Empty
) 2. Zakładając, że wywołania rekurencyjneheight
są poprawne, udowodnij, żeheight
jest to poprawne w przypadku przypadku nie będącego podstawą ) (gdy drzewo to tak naprawdęNode
).W kroku 1 widzimy, że funkcja zawsze zwraca 0, gdy argumentem jest
Empty
drzewo. Jest to prawidłowe z definicji wysokości drzewa.W kroku 2 funkcja powraca
1 + max( height(leftChild), height(rightChild) )
. Zakładając, że wywołania rekurencyjne naprawdę zwracają wzrost dzieci, możemy zauważyć, że jest to również poprawne.I to stanowi dowód. Łączne kroki 1 i 2 wyczerpują wszystkie możliwości. Zauważ jednak, że nie mamy mutacji, żadnych zer i istnieją dokładnie dwie odmiany drzew. Po usunięciu tych trzech warunków dowód szybko staje się bardziej skomplikowany, jeśli nie niepraktyczny.
EDYCJA: Ponieważ ta odpowiedź osiągnęła szczyt, chciałbym dodać mniej trywialny przykład dowodu i nieco dokładniej objąć indukcję strukturalną. Powyżej udowodniliśmy, że jeśli
height
zwraca , to zwracana wartość jest poprawna. Jednak nie udowodniliśmy, że zawsze zwraca wartość. Możemy również użyć indukcji strukturalnej, aby to udowodnić (lub dowolną inną właściwość). Ponownie, podczas kroku 2, możemy przyjąć, że wstrzymania własności wywołań rekurencyjnych są zachowane, o ile wszystkie wywołania rekurencyjne działają na bezpośrednim potomku drzewo.Funkcja może nie zwrócić wartości w dwóch sytuacjach: jeśli zgłosi wyjątek i zapętli się na zawsze. Najpierw udowodnijmy, że jeśli nie zostaną zgłoszone żadne wyjątki, funkcja kończy się:
Wykazać, że (jeśli nie zostaną zgłoszone wyjątki) funkcja kończy się dla przypadków bazowych (
Empty
). Ponieważ bezwarunkowo zwracamy 0, to kończy się.Wykazać, że funkcja kończy się w przypadkach innych niż bazowe (
Node
). Są trzy wywołania funkcji tutaj:+
,max
, iheight
. Wiemy o tym+
imax
rozwiązujemy, ponieważ są one częścią standardowej biblioteki języka i są zdefiniowane w ten sposób. Jak wspomniano wcześniej, możemy założyć, że właściwość, którą próbujemy udowodnić, jest prawdziwa w przypadku wywołań rekurencyjnych, o ile działają one w bezpośrednich poddrzewach, więc również wezwania doheight
zakończenia.To kończy dowód. Pamiętaj, że nie można udowodnić zakończenia przy pomocy testu jednostkowego. Teraz pozostaje tylko pokazać, że
height
nie rzuca wyjątków.height
nie wyrzuca wyjątków na przypadek podstawowy (Empty
). Zwrócenie 0 nie może zgłosić wyjątku, więc skończyliśmy.height
nie zgłasza wyjątku w przypadku innym niż bazowy (Node
). Załóżmy jeszcze raz, że wiemy+
imax
nie rzucamy wyjątków. A indukcja strukturalna pozwala nam założyć, że wywołania rekurencyjne też nie rzucą (ponieważ działają na bezpośrednie dzieci drzewa). Ale czekaj! Ta funkcja jest rekurencyjna, ale nie rekurencyjna . Możemy zdmuchnąć stos! Nasz próbowany dowód odkrył błąd. Możemy to naprawić, zmieniającheight
się w rekurencyjny .Mam nadzieję, że to pokazuje, że dowody nie muszą być przerażające ani skomplikowane. W rzeczywistości za każdym razem, gdy piszesz kod, nieformalnie konstruujesz dowód w swojej głowie (w przeciwnym razie nie byłbyś przekonany, że właśnie zaimplementowałeś tę funkcję). Unikając zerowej, niepotrzebnej mutacji i nieograniczonego dziedziczenia, możesz udowodnić, że twoja intuicja jest poprawić dość łatwo. Te ograniczenia nie są tak surowe, jak mogłoby się wydawać:
null
jest wadą językową i zlikwidowanie go jest bezwarunkowo dobre.źródło
O wiele łatwiej jest wnioskować o kodzie, gdy wszystko jest niezmienne . W rezultacie pętle są częściej zapisywane jako rekurencja. Zasadniczo łatwiej jest zweryfikować poprawność rozwiązania rekurencyjnego. Często takie rozwiązanie będzie również czytać bardzo podobnie do matematycznej definicji problemu.
Jednak w większości przypadków motywacja do przeprowadzenia rzeczywistego formalnego dowodu poprawności jest niewielka. Dowody są trudne, zajmują dużo czasu (ludzie) i mają niski zwrot z inwestycji.
Niektóre języki funkcjonalne (zwłaszcza z rodziny ML) mają wyjątkowo wyraziste systemy typów, które mogą zapewnić o wiele bardziej kompletne gwarancje, że system typu C (ale niektóre pomysły, takie jak generyczne, stały się również powszechne w językach głównego nurtu). Gdy program przejdzie kontrolę typu, jest to rodzaj zautomatyzowanego dowodu. W niektórych przypadkach będzie w stanie wykryć niektóre błędy (np. Zapomnienie przypadku podstawowego w rekurencji lub zapomnienie obsługi niektórych przypadków w dopasowaniu wzorca).
Z drugiej strony, systemy tego typu muszą być bardzo ograniczone, aby można było je rozstrzygać . W pewnym sensie uzyskujemy więc gwarancje statyczne, rezygnując z elastyczności - a te ograniczenia są powodem, dla którego istnieją skomplikowane prace akademickie w stylu „ monadycznego rozwiązania rozwiązanego problemu w Haskell ”.
Lubię zarówno bardzo liberalne, jak i bardzo ograniczone języki i oba mają swoje trudności. Ale nie jest tak, że ktoś byłby „lepszy”, każdy jest po prostu wygodniejszy do innego rodzaju zadania.
Następnie należy zauważyć, że dowody i testy jednostkowe nie są zamienne. Oba pozwalają nam ustalić granice poprawności programu:
Testowanie nakłada górną granicę na poprawność: jeśli test się nie powiedzie, program jest niepoprawny, jeśli żaden test się nie powiedzie, jesteśmy pewni, że program obsłuży testowane przypadki, ale nadal mogą występować nieodkryte błędy.
Dowody określają dolną granicę poprawności: udowodnienie niektórych właściwości może być niemożliwe. Na przykład może być łatwo udowodnić, że funkcja zawsze zwraca liczbę (to właśnie robią systemy typów). Ale może nie być możliwe udowodnienie, że liczba będzie zawsze
< 10
.źródło
Słowo ostrzeżenia może być tutaj:
Chociaż ogólnie jest prawdą, co piszą tutaj inni - krótko mówiąc, że zaawansowane systemy typu, niezmienność i przejrzystość referencyjna mają duży wpływ na poprawność - nie jest tak, że testowanie nie odbywa się w świecie funkcjonalnym. Wręcz przeciwnie !
Dzieje się tak, ponieważ mamy narzędzia takie jak Quickcheck, które generują przypadki testowe automatycznie i losowo. Podajesz jedynie prawa, których musi przestrzegać funkcja, a następnie szybkie sprawdzenie sprawdzi te prawa pod kątem setek przypadkowych przypadków testowych.
Widzisz, jest to nieco wyższy poziom niż trywialne kontrole równości w kilku przypadkach testowych.
Oto przykład z implementacji drzewa AVL:
Drugie prawo (lub właściwość) możemy odczytać w następujący sposób: W przypadku wszystkich dowolnych drzew
t
następujące są następujące: jeślit
nie jest puste, to dla wszystkich kluczyk
tego drzewa będzie trzymać to spojrzeniek
w drzewie, które jest wynikiem usunięciak
zt
, wynikiem będzieNothing
(co oznacza: nie znaleziono).Sprawdza to poprawność działania w celu usunięcia istniejącego klucza. Jakie przepisy powinny regulować usuwanie nieistniejącego klucza? Z pewnością chcemy, aby powstałe drzewo było takie samo jak drzewo, z którego usunęliśmy. Możemy to łatwo wyrazić:
W ten sposób testowanie jest naprawdę zabawne. Poza tym, gdy nauczysz się czytać właściwości szybkiego sprawdzania, służą one jako specyfikacja do przetestowania na maszynie .
źródło
Nie do końca rozumiem, co oznacza połączona odpowiedź poprzez „osiągnięcie modułowości poprzez prawa matematyczne”, ale myślę, że mam pojęcie o tym, co to znaczy.
Sprawdź Functor :
Nie zawiera przypadków testowych, a raczej kilka przepisów, które należy spełnić.
Powiedzmy teraz, że implementujesz
Functor
( źródło ):Problem polega na sprawdzeniu, czy Twoje wdrożenie jest zgodne z prawem. Jak ty to robisz?
Jednym z podejść jest pisanie przypadków testowych. Podstawowym ograniczeniem tego podejścia jest to, że weryfikujesz zachowanie w skończonej liczbie przypadków (powodzenia, wyczerpujące testowanie funkcji z 8 parametrami!), A zatem zaliczenie testów nie może zagwarantować niczego poza pozytywnym wynikiem testów.
Innym podejściem jest zastosowanie rozumowania matematycznego, tj. Dowodu, opartego na faktycznej definicji (zamiast na zachowaniu w ograniczonej liczbie przypadków). Chodzi o to, że dowód matematyczny może być bardziej skuteczny; zależy to jednak od tego, jak podatny jest twój program na dowód matematyczny.
Nie mogę przeprowadzić cię przez faktyczny dowód formalny, że powyższa
Functor
instancja jest zgodna z prawem, ale postaram się przedstawić zarys tego, jak mógłby wyglądać ten dowód:fmap id = id
Nothing
fmap id Nothing
=Nothing
w części 1 wdrożeniaid Nothing
=Nothing
z definicjiid
Just x
fmap id (Just x)
=Just (id x)
=Just x
w części 2 wdrożenia, a następnie według definicjiid
fmap (p . q) = (fmap p) . (fmap q)
Nothing
fmap (p . q) Nothing
=Nothing
przez część 1(fmap p) . (fmap q) $ Nothing
=(fmap p) $ Nothing
=Nothing
przez dwa zastosowania części 1Just x
fmap (p . q) (Just x)
=Just ((p . q) x)
=Just (p (q x))
według części 2, a następnie według definicji.
(fmap p) . (fmap q) $ (Just x)
=(fmap p) $ (Just (q x))
=Just (p (q x))
przez dwa zastosowania części drugiejźródło
„Strzeż się błędów w powyższym kodzie; udowodniłem tylko, że jest poprawny, a nie wypróbowałem”. - Donald Knuth
W idealnym świecie programiści są doskonali i nie popełniają błędów, więc nie ma błędów.
W idealnym świecie informatycy i matematycy są również doskonali i nie popełniają błędów.
Ale nie żyjemy w idealnym świecie. Dlatego nie możemy polegać na programistach, którzy nie popełniają błędów. Ale nie możemy zakładać, że żaden informatyk, który dostarczy matematyczny dowód, że program jest poprawny, nie popełnił żadnych błędów w tym dowodzie. Dlatego nie zwracałbym uwagi na nikogo, kto próbowałby udowodnić, że jego kod działa. Napisz testy jednostkowe i pokaż mi, że kod zachowuje się zgodnie ze specyfikacjami. Nic innego mnie nie przekona.
źródło