poniedziałek, 21 listopada 2016

Bezpieczny numerek

Wyobrażenie

Za każdym razem gdy spróbujemy wyjść poza najbardziej mainstreamowy język programowania na ziemi musi pojawić się pytanie ale po co. W Javie jest wszystko, frameworki, serwery, narzędzia, są obiekty - a wszystko jest obiektem to chyba jest wszystko?

W tym momencie pojawi się teza. Teza może wynikać z teorii lub obserwacji i ta tutaj wynika właśnie z szeregu obserwacji. Obserwacji, że w 2016 - czyli dwa lata po tym jak Java oficjalnie dostała w podarunku lambdy - wielu programistów javy z kilkuletnim doświadczeniem , a których miale okazję obserwować, miało pewien kłopot w operowaniu tymi "strzałkami". I nie jest to dyskusja typu "bo ja umiem a wy nie" ale raczej czy możne pewne zjawiska ubiec aby być lepiej przygotowanym gdy już nastąpią.

I tak na przykład jeśli planowałbym ogarniać strzałki od pierwszego dnia ich wprowadzenia - czyli któryś tam marzec 2014 - wtedy nie mogę ograniczyć się jedynie do javy bo nie nauczę się mechanizmu, który w tym języku jeszcze przed ta datą nie istnieje... (podobnie w 2004 gdy wyszła java5 z generykami świat javy musiał zaznajomić się z teorią, która wcześniej w nim nie istniała czyli nie możesz przygotować się do rozwoju javy ograniczając się jedynie do javy!)

I tak na przykład widziałem jak doświadczeni programiści C# szybciej łapali Scalę aniżeli ci doświadczeni w Javie7lubmniej. Być może nawet w marcu 2014 taki "uśredniony" programista C# lepiej odnalazłby się w Javie8 aniżeli jego javowy odpowiednik. Do tego kolejną zaleta poszerzenia pola widzenia jest to, że znajomość realizacji tego samego mechanizmu w dwóch lub więcej językach ułatwia zrozumienie abstrakcji, która za tym mechanizmem stoi.

No ale co było to było a za chwilę Java8 będzie miała trzecią rocznicę, lambdy - podobnie jak wcześniej generyki - z roku na rok będą stawać się integralną częścią języka, lada chwila ma być wydana książka "Functional Programming in Java" i istnieją już także wygodne biblioteki "lambdowe" jak Javaslang. W Javie 9 i 10 są zapowiadane pewne udogodnienia (Project_Valhalla) ale na horyzoncie nie widać kolejnej wielkiej rewolucji.

Ale czy na pewno...? (tak z akcentem na 'NA PEwno', i do tego przydałby się jakiś podkład muzyczny - może jakiś wczesny scooter?)

Idzie nowe

The Essence of Dependent Object Types - to tytuł pracy - takiej naukowej - na którą można natrafić o tu --> http://dotty.epfl.ch/#why-dotty a "TU" to strona platformy dotty, która (chyba) ma zdefiniować jak będzie wyglądała Scala 3.0. Jeśli wierzyć autorom mamy do czynienia z zupełnie nowym potężnym podejściem do typów w językach prorgamowania. Niestety z dokumentu za wiele nie rozumiem a samo dotty jest work in progress.

Ale gdy tak siedzę nieco zagubiony nagle, tak znienacka wyłania się język, który w kwestii edukacji może być dla "Typów Zależnych" tym samym czym Haskell dla "Programowania Funkcyjnego " - mowa o Idris. Co ciekawe ten język jest w istocie napisany "na" Haskellu i ma bardzo podobną składnię także w tym przypadku dla mających jakieś pojęcie o Haskellu łatwiej jest zacząć. Tyle gadaniny - zerknijmy trochę pod pokrywkę.

Typ Typu

Cały bajer z typami zależnymi polega na tym, że typ może mieć typ, który może mieć typ itd. Nie będę tutaj grał fachury bo sam hoduje sobie neurony by ogarnąć ten temat ale nawet na najprostszych przykładach widać jak obiecujący jest to mechanizm.

Pierwsza rzecz do której musimy się przyzwyczaić to ta rekurencyjna koncepcja pojęcia typu. Jeśli znowu wrócimy do Javy to przed javą 5 lista to była sucha konstrukcja. Od javy 1.5 możemy już np. mówić, że Lista ma typ String czyli :

List<String>
A teraz... jeśli to pójdzie dalej to ta lista stringów też może mieć typ - dokładnie tak - typ typu - np. List typu String o typie długość cztery
List<String><4>
Oczywiście w Javie nie ma czegoś takiego ale czas na Idris gdzie taka sytuacja to stan naturalny :
fourElements : Vect 4 String
fourElements = ["pierwszy","drugi","trzeci","czwarty"]
Gdybym się pomylił i dał inną ilość elementów to to się nawet nie skompiluje!
fourElements = ["pierwszy","drugi","trzeci"]

No i teraz pierwsze miejsce gdzie ta konstrukcja może sie przydać. Zerknijmy na poznaną już Java8 metoda Stream.map , zmieniająca każdy element według instrukcji podanej w postaci funkcji a->b. W takim javowym pseudokodzie to będzie coś takiego :

List<B> result= List<A>.stream.map(fab).collect(toList);
Problem polega na tym, że mając zabezpieczenie tylko tymi typami a->b bardzo łatwo popełnić błąd. Co gdy zawsze będę zwracać pustą listę? I tak się skompiluje. Podobnie jak analogiczny przykład z Idris z użyciem typu List, która nie ma zależnego typu długości
customStandardMap : (a->b) -> List a -> List b
customStandardMap f [] = []
customStandardMap f (x :: xs) = []
Natomiast gdy przerzucimy się na typ Vect, którego typ ma swój typ dostaniemy taką oto sygnaturę :
customMap : (a -> b ) -> Vect n a -> Vect n b
n to długość kolekcji i przy tak zapisanej funkcji nie ma wyjścia, kolekcja zwracana musi mieć taką samą długość jak ta podana!
-- this will not compile!!!
customMap f [] = []
customMap f (x :: xs) = []

Czas na numerek

Teraz coś z zupełnie innej beczki. Czy można wartość dowolnej liczby naturalnej (jedne, milion,pierdzyliard) zaprezentować przy pomocy jedynie dwóch typów?

Jeśli ktoś spróbował w życiu Scali wie, że tam Lista to nie tyle "kolekcja N elementów" ale po prostu : "element z przodu i (być może) reszta listy". Lista tam jest tzw. typem algebraicznym czyli ściśle ograniczoną rodziną możliwych typów z jasno zdefiniowanymi nań operacjami. A w praktyce Lista to albo ... "Lista" albo "Koniec listy" co pozwala nam wygodnie używać tej konstrukcji rekurencyjnie

safeListIteration: List Integer -> String
safeListIteration [] = ""
safeListIteration (x :: xs) = show x ++":"++ safeListIteration xs

No i teraz jeśli zadziałało dla listy to możemy sobie wyobrazić Kolejne liczby naturalne jako taką listę z końcem ogonka na zerze. I w ten oto sposób dostaniemy bardzo bliźniaczy kod

safeNumberIteration : Nat -> String
safeNumberIteration Z = ""
safeNumberIteration (S k) = show k ++":"++ safeNumberIteration k

Jest to trochę taki mindfuck - dowolna liczba naturalna to : albo zero albo następca innej liczby naturalnej. No i teraz jak tę definicję scalimy z typami zależnymi dostajemy kolejne możliwości zabezpieczenia poprawności programu na etapie kompilacji.

append : String -> Vect n String -> Vect (S n) String
append x [] = [x]
append x xs = x :: xs

Czyli jeśli dodamy element do kolekcji to nie ma ku**a innego wyjścia jak tylko musimy otrzymać kolekcję gdzie długość to kolejna liczba naturalna względem długości kolekcji pierwotnej (być może trzeba to zdanie przeczytać sobie raz jeszcze)

Program zabezpieczony

A teraz taka sytuacja. Jak w Javie byśmy się zabezpieczyli przed np. podaniem indexu przekraczającego długość kolekcji? Albo jak się zabezpieczyć przed podaniem 369 w miejsce gdzie jest spodziewany dzień miesiąca? A jak już się zastanawiasz i widzisz te ify sprawdzające poprawność wartości w rantajmie - to kolejne pytanie - jak to zrobić w trakcie kompilacji?

Weźmy na to ten kalendarz i tylko trzy pierwsze miesiące by było mniej pisania

nToMonth : Fin 3 ->  Month
nToMonth i = case finToInteger i of
                  0 => January
                  1 => February
                  2 => March

Ok co ja pacze? Co to jets ten Fin 3? Otóż jest to zabezpieczenie na poziomie typów - czyli i kompilacji, takie że tam jako argument tejże metody otrzymamy wartość skończoną z zakresu 0-2. Jeszcze raz - TYPÓW - T-Y-P-Ó-W - Typów - types - типы. Jeśli to się skompiluje to znaczy, że działa! Jeszcze raz - jak to się skompiluje to znaczy, ze działa!

No ok ale skąd ten Fin się ma wziąć?. Trzeba pomyśleć o tym w szerszym kontekście. Ta funkcja jest taka czysto biznesowa a funkcja "sito" odpowiedzialna za komunikację z nieprzewidywalnym światem zewnętrznym będzie gdzieś indziej.

firstQuarterMonth : Integer -> Maybe Month
firstQuarterMonth i = case integerToFin i 3 of
                           Nothing => Nothing
                           Just n => Just (nToMonth n)

I teraz ładnie ogarniamy każdą możliwą kombinację wejścia czyli nasz program jako program działa zawsze poprawnie, pomimo iż w trakcie kompilacji nie wiemy co przyjdzie w runtime.

*poligon> firstQuarterMonth 0
Just January : Maybe Month
*poligon> firstQuarterMonth 1
Just February : Maybe Month
*poligon> firstQuarterMonth 2
Just March : Maybe Month
*poligon> firstQuarterMonth 3
Nothing : Maybe Month

Czaisz o co chodzi? Poprawność programu wynika z tego, że zwraca zdefiniowaną odpowiedź na każda możliwa kombinacje wejściową!

Ale czy na pewno?

Oczywiście, że nie bo użyłem tam hamskiego brute forca, żeby na siłę zamienić Fin z powrotem w integer, którego użyłem później w pattern matchingu. Dlaczego tak zrobiłem? Wynika to z prostej przyczyny : inaczej nie umiem. Nauka jest w trakcie trwania i na pewno za kilka miesięcy spojrzę na swój kod w Idris z dzisiaj i być może się porzygam ale to co fajnego wyszło w tym przykładzie, to że tak naprawdę to język zauważył, że odpierdalam a nie ja sam. Idris jest w stanie przeanalizować typy od początku do końca i sprawdzić czy funkcja jest całkowita czyli nei wystąpi jakaś nieprzewidziana kombinacja parametrów wejściowych, która zrobi nam kuku.
*poligon> :total firstQuarterMonth 
Main.firstQuarterMonth is possibly not total due to:
Main.nToMonth, which is possibly not total due to:
Main.case block in nToMonth at poligon.idr:59:19, which is not total as there are missing cases  

Linia 59 to jest dokładnie ta linia z tym brute forcem. Żeby być w stanie zrobić taką analizę programu to jest siła!!! Jeśli to nie wywróci świata IT do góry nogami w przeciągu następnej dekady...



3 komentarze:

  1. Gdyby ktoś jednak uważał, że artykuł jest jednak niedorobiony bo nie ma przedstawionego w pełni zabezpieczonego typami rozwiązania to być może nie najsprytniejszym - ale zawsze - rozwiązaniem będzie stworzenie wektora miesięcy o określonej długości

    months : Vect 3 Month
    months = [January,February,March]

    i wtedy mamy gotowa funkcję Fin n -> Month :
    Data.Vect.index : Fin n -> Vect n a -> a

    Jednakże ten przykład z "dziurą" w typach, która wykrył sam język jest moim zdaniem tak silnym przykładem, że zostawiłem to w końcowej wersji.

    OdpowiedzUsuń
  2. Za każdym razem, kiedy widzę tablicę o z góry określonej długości, to zastanawiam się, czy nie powinna tam być struktura z jawnie nazwanymi polami, bo inaczej to jest takie Scheme-style (zobacz dowolną strukturę danych w SICP).

    Interesowałaby mnie inna możliwość - rozwinięcie przykładu ze zwróceniem tablicy n+1 - czy można zadeklarować np. "ta funkcja zawsze zwróci niepustą tablicę" albo "ta funkcja zwróci tablicę nie dłuższą niż x elementów"?

    OdpowiedzUsuń
    Odpowiedzi
    1. Jeszcze nie weim czy możesz sobie okreslić "nie większe" ale na pewno możesz dynamicznie określić typ
      wektora.

      fill : a -> (x: Nat) -> Vect x a
      fill a Z = []
      fill a (S k) = a :: fill a k

      Ale pamiętaj, że to był tylko przykład większej idei. List w Idrisie w ogóle nie ma drugiego typu, Vect ma, i technicznie jest stworzenie nawet i samemu czegoś takiego "CustomArray a range"

      Usuń