środa, 2 listopada 2016

Wrażenia po Code retreat 2016 którego nie było

W tym roku data Global Day of Code Retreat niestety pokryła się z Mobilizacją dlatego my jako JUG Lodz tegoż wydarzenia nie organizowaliśmy nic mi nie wiadomo by ktoś inny podjął się tego zadania także w Łodzi ćwiczenia tej jesieni chyba się nie odbyły. Cóż - Zobaczymy czy daty za rok dopasują. Ale pomimo braku połączenia video z Koluszkami Górnymi czy innymi miastami wokół globu (oraz brakiem standardowego browara po zajęciach) można samemu przeprowadzić ćwiczenie i wyciągnąć jakąś naukę ze zdarzenia, które się technicznie nie odbyło.

Zasadniczo główną przeszkodą na drodze nauki jesteśmy my sami wyposażeni w bogatą kolekcję błędów poznawczych jak chociażby efekt aureoli, którą sami sobie nakładamy gdy myślimy, że doskonała znajomość jiry i kilkuletnie doświadczenie w odpalaniu tomcata automatycznie oznacza, że ogólnie "umiemy IT bardzo dobrze". "Pisanie kodu, o możliwie niskiej złożoności w wymiarze czasu i przestrzeni" to umiejętność niezależna od znajomości adnotacji w springu, doświadczeniu w konfiguracji mavena czy latach doświadczenia w przekonywaniu, że ten rysunek z 5 kwadratami to kompletny projekt systemu. Także jako umiejętność niezależną należy ją ćwiczyć niezależnie.
Więcej o takich sprawach produkowałem się rok temu : CodeRetreat 2015 i nauka są obrazki z cyklem Kolba (nie mylić z uderzeniem kolbą w skroń, która to może przynieść efekt odwrotny do zamierzonego) jest i teoria atrybucji także można sobie poczytać.

Ograniczenia i przyzwyczajenia

Im dłużej pracujemy w pewien sposób tym bardziej ten sposób dopracowaliśmy do "lokalnej perfekcji" nawet jeśli to jedynie mało wydajne "maksimum lokalne" produktywności. Gdy próbujemy wyjść ze starego sposobu to opuszczamy to maksimum lokalne i nawet jeśli za rogiem czai się potężna "górka efektywności" (ku*wa chyba pójdę w kołczing) to w pierwszym momencie i tak będzie nam szło gorzej.

Stąd też pomysł aby nałożyć sztuczne ograniczenia w trakcie pisania programu, tak abyśmy byli zmuszeni wyjść poza znane nam mechanizmy (ej naprawdę nazwać to "poza cyfrową strefe komfortu", kupić trochę pączków i jeździć po Polsce ze szkoleniami). Oczywiście ograniczenia musza być dobrane w sposób sensowny aby kierować nas w dobra stronę - a skąd wiemy, która jest dobra? CodeRetreat ma pewne gotowe schematy sesji, które czerpią inspirację z doświadczenia ludzie mających ogromne doświadczenie praktyczne. A Skąd wiadomo, że oni mają dobre doświadczenie praktyczne? Tego nie wiadomo nigdy ale taką wskazówką jest podejście tych ludzi do tematu nauki, kiedy to twierdzą iż rozumieją, że w trakcie edukacji trzeba cały czas powracać do etapu początkującego (taka skromność edukacyjna).

Mainstreamowy CodeRetreat jest raczej nakierowany na Javę i dobre praktyki programowania obiektowego - czy można to podejście wykorzystać do nauki programowania funkcyjnego? Może ograniczenie "tylko funkcje", "żadnych przypisań", "nie zmieniaj stanu", "żadnych efektów ubocznych"? No w sumie z myślą o takiej sesji, zostało przygotowane specjalne narzędzie edukacyjne - nazywa się onoHaskell...

Kod

Znam (niestety) wiele opinii i podejść do Haskella - "tego się nie używa to po co się uczyć". A może języka warto się nauczyć dla ... samej nauki? (chociaż tutaj pewnie fani Haskella powiedzą, że w praktyce tu czy tam się go używa). W Haskellu programowaniu funkcyjne jest "natywne" i przez to nauka tego podejścia idzie tam naturalnie i duzo łatwiej niż w Scali czy Javie. No jak na przykład w książce "Functional Programming in Java" jest cały rozdział o tym jak w Javie zasymulować tail recursion, które w Haskellu (a nawet w Scali) po prostu "jest" - to istnieje duże prawdopodobieństwo, że standardowy programista Javy zawróci z tej ścieżki traktując funkcje w Java8 jako taki kosmetyczny dodatek do kolekcji. (to trochę tka jakby naukę programowania zaczynać od lutowania rezystorów do płytki - "Lekcja 3 - nakładamy kalafonię")

Zasady gry Game Of Life, którą to implementuje się na code retreat - opisane są tutaj : https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life. Jak jest tyle a tyle komórek a nasza jest żywa to przezywa albo umiera. I podobnie gdy jest martwa. Czyli dosyć ciekawy zestaw wymagań gdzie występuje stan zarówno wewnątrz komórki jak i poza nią.

No to spróbujmy Haskella i zobaczmy najciekawsze fragmenty, które udało się wygenerować. Na początek w naszym kodzie - komórka. Gra życie polega na ewolucji komórek w kolejnych pokoleniach. Często ludzie słysząc, że komórka ma dwa stany myślą boolean. A Komórka to Komórka. Więc prezentujmy Komórkę jako komórkę.

data Cell = Live | Dead deriving (Show,Eq)
Kolejna rzecz, która jest jasno zdefiniowana w wymaganiach gry to zasady ewolucji komórki w zależności od sąsiadów. Widzimy więc, że komórka jest mocno zależna od stanu zewnętrznego. Trzeba jakoś przekazać tę informację i zazwyczaj tutaj pojawia się jakiś if no bo jest kilka warunków ewolucji komórki. Okazuje się, że mając mechanizmy niespotykane w Javie można te zasady w sposób bardzo czytelny napisać bez użycia ifa - oto mamy pattern matching
next :: Cell -> Int -> Cell
next Dead 3 = Live
next Dead _ = Dead
next Live 2 = Live
next Live 3 = Live
next Live _ = Dead
Następnie przechodzimy do definicji planszy. Generalnie pisząc raz za razem game of life dochodzimy do momentu gdzie w zasadzie liczą się tylko miejsca z żywymi komórkami i możemy łatwo zasymulować nieskończoną przestrzeń przy pomocy mapy. Nie musimy także tworzyć osobnego typu na współrzędne gdyż haskella dostarcza nam wygodne aliasy. W tym przypadku zastosowanie aliasu pomaga łatwo dołożyć na przykład trzeci wymiar gdyż współrzędne działają jedynie jako "klucz" i kawałki kodu świadome ilości wymiarów są ograniczone i łatwe do ogarnięcia.
type Coordinates = (Int,Int)
type Board = M.Map Coordinates Cell

initial:: [Coordinates]
initial = [(0,0),(0,1),(1,1),(2,1)]

initialBoard ::M.Map Coordinates Cell
initialBoard = M.fromList $ map (\c->(c,Live)) initial

Tu macz comprehenszyn

W jaki sposób znaleźć współrzędne wszystkich sąsiadów? Szukamy iloczynu kartezjańskiego sasiadów w osi x z sąsiadami w osi y. W Javie można to zrobić dwoma zagnieżdżonymi forami - no chyba, że akurat jest ograniczenie "bez zagnieżdżeń" - wtedy robi się na przykład klasy Range, Surroundings i jakiś Carthesian (Function,Service co tam chceta). A w Haskellu ?
neighbours:: Coordinates -> [Coordinates]
neighbours (x,y) = [(xn,yn) |xn <- [x-1..x+1],yn<-[y-1..y+1], (xn,yn)/=(x,y) ]
No proszę tylko jedna linijka! Ale to oszukiwanie bo tam są poukrywane monady... W każdym razie podobnie możemy policzyć żywych sąsiadów
liveNeighbours :: Coordinates -> Board -> Int
liveNeighbours c board = sum [1 | coord <- neighbours c,M.member coord board]
Ponieważ zyskujemy tutaj dodatkowy wymiar kompozycji pod nazwą currying czyli innymi słowy możemy niezależnie zaaplikować Board i Coordinate, więc powstaje pytanie czy obecna kolejność jest właściwa : Coordinates -> Board -> Int czy może powinno być na odwrót czyli Board-> Coordinates -> Int? O tym, że problem nie jest banalny starłem się napisać tutaj : http://pawelwlodarski.blogspot.com/2016/03/o-rany-odwrotna-kolejnosc-danych.html natomiast w tymże ćwiczeniu odpowiedzi nie znam. W tej formie w jakiej jest kod kolejność wydaje się nie mieć znaczenia bo funkcja i tak ani nie weźmie udziału w kompozycji ani nie będzie niezależnie aplikować argumentów.

Końcówka

Końcówkę umieszczam w zasadzie tylko aby za rok zrobić porównanie. Generalnie metody wydają się krótkie ale jest to złudne gdyż te kilka linijek w Javie7 zajęłoby linijek kilkadziesiąt. Logika jest dosyć "skompaktowana" i wymaga od umysłu programisty odpowiedniej mocy obliczeniowej na rozpakowanie.

potentialCells :: [Coordinates] -> [Coordinates]
potentialCells cs = [(xn,yn) | c<-cs,(xn,yn)<-neighbours c, xn>=0 , yn>=0]

nextStep :: Board -> Board
nextStep board =M.filter ( == Live) $  M.fromList $ map mapCell newCells
  where
    newCells = potentialCells $ M.keys board
    mapCell cord =(cord, next (M.findWithDefault Dead cord board) (liveNeighbours cord board))

Odpalenie też w sumie wykorzysta kilka ciekawych mechanizmów :
map (map fst  . Data.Map.toList) $ take 5 $  iterate nextStep initialBoard
    -- [[(0,0),(0,1),(1,1),(2,1)],[(0,0),(0,1),(1,1),(1,2)],[(0,0),(0,1),(0,2),(1,0),(1,1),(1,2)],
[(0,0),(0,2),(1,0),(1,2),(2,1)],[(1,0),(1,2),(2,1)]]
W wyniku dostajemy kolejne fazy gry i chyba są nawet poprawne - niestety jeszcze nie ogarnąłem tematu testów jednostkowych w Haskellu także dowód jest empiryczny. Podejście było dosyć restrykcyjne z punktu widzenia Obiektówki bo nie mamy żadnego zmiennego stanu. Natomiast widać cały czas pewne oznaki takiego trochę proceduralnego programowania gdzie kawałki kodu niejako nie komponują się a jedynie następują jeden po drugim.
Także by zabawy było więcej, czas dorzucić jakieś ograniczenia w ramach już istniejących ograniczeń.

Ograniczenia

Pojawiają się pewne rzeczy, które potencjalnie nadają się na ograniczenia
  • maks jeden $ w linii - generalnie każdy kolejny '$' można traktować jako kolejną linie kodu tyle że... w poziomie.
  • brak zagnieżdzonych nawiasów "))" - kolejny sposób na wywołanie instrukcji w instrukcji
  • brak where,let i list comprehension - tak by nie zamykać logiki w lokalnych aliasach
Kod zaczyna się podobnie, w sensie operacje na komórkach sa jasno określone przy pomocy pattern matchingu. Ciekawie zaczyna robić się przy określaniu współrzędnych. Na początek prosty jednowymiarowy generator sąsiadów z uwzględnieniem krawędzi planszy co być może jest niepotrzebne bo przecież poruszamy się po planszy nieskończonej.
surrounding :: Int -> [Int]
surrounding i = filter (>=0) [i-1,i,i+1]
I teraz zaczyna się robić naprawdę ciekawie bo aby zdefiniować współrzędne sąsiadów potrzebne jest połączenie "każdy-z-każdym". Tyle, że bez comprehension i zagnieżdżonych funkcji. Jak to zrobić?
wchodzimy na hoogle (https://www.haskell.org/hoogle/) i szukamy czegoś co połączy nam dwie listy w listę tupli, które są u nas współrzędnymi. [a]->[a]->[(a,a)]. Znajdujemy funkcję zip i jakieś inne funkcje specjalizowane. Ponieważ mamy REPLa (od wersji 9 w końcu ma być repl w Javie!) możemy sobie szybko zrobić test
zip [1,2] [3,4]
 >> [(1,3),(2,4)]
To nie to o co nam chodziło. Spróbujmy poszerzyć poszukiwania. Może znajdziemy coś co łączy dwie listy dając nam do dyspozycji mechanizm połączenia ich w wygodny dla nas sposób (a->b->c)->[a]->[b]->[c] . I tu pojawi się coś ciekawego na miejscu drugim :
  1. zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
  2. liftA2 :: Applicative f => (a -> b -> c) -> f a -> f b -> f c
Applicative w tej sygnaturze to w wolnym tłumaczeniu "Klasa Typu", która znowu w wolnym wytłumaczenia trochę po javowemu mówi, że przekazany typ musi spełniać określony interfejs. Czy lista go spełnia?
:info []
data [] a = [] | a : [a]  -- Defined in ‘GHC.Types’
instance Eq a => Eq [a] -- Defined in ‘GHC.Classes’
instance Monad [] -- Defined in ‘GHC.Base’
instance Functor [] -- Defined in ‘GHC.Base’
instance Ord a => Ord [a] -- Defined in ‘GHC.Classes’
instance Read a => Read [a] -- Defined in ‘GHC.Read’
instance Show a => Show [a] -- Defined in ‘GHC.Show’
instance Applicative [] -- Defined in ‘GHC.Base’      `` <- spełnia!spełnia! ojjjj spełnia!
...
Ale skąd weźmiemy funkcję? To jest dosyć ciekawa perspektywa poznawcza. Struktura z która pracujemy w zasadzie... jest funkcją
:t (,)
>> (,) :: a -> b -> (a, b)
i eksperyment :
Control.Applicative.liftA2 (,) [1,2] [3,4]
>> [(1,3),(1,4),(2,3),(2,4)]
Control.Applicative.liftA2 (,) [0,1,2] [1,2]
>> [(0,1),(0,2),(1,1),(1,2),(2,1),(2,2)]

Ćwiczenie można kontynuować bez wnikania w to co to jest faktycznie to Applicative. Temat jest gruby i w dużym skrócie można napisać, że Applicative umożliwia zastosowanie prostej funkcji a->b->c czyli u nas a->b->(a,b) na poziomie złozonychefektów. Tylko jakich efektów i w jakim sensie złożonych? Czego efektem jest lista? Jeśli założymy, że funkcja może mieć tylko jeden wynik to zwrócenie listy, która jest "wieloma wynikami", oznacza brak determinizmu. No bo jeśli mamy funkcję MONETA->STRONA i wyniku dostajemy [ORZEL,RESZKA] to po dwóch wywołaniach mamy [ORZEL,RESZKA] i [ORZEL,RESZKA] i łącząc ze sobą dwa niedeterministyczne wyniki otrzymujemy wszystkie możliwe kombinacje - co też zobaczyliśmy na przykładzie szukanie sąsiadów.

I to w zasadzie tyle.
Tyle?!? Ale przecież nieskończone!!!
Nie musi być skończone a nawet nie powinno dlatego sesje na code retreat trwają 45 minut i nie zakładają, że ktoś skończy. I aby nie przywiązywać się do jednego rozwiązania kasujemy kod po sesji i to też własnie robię.

Podsumowanie

W ferworze implementacji wyszło mi coś takiego map (\(coord,cell)->(coord,next cell)) na co haskell-ide zareagowała "po co się meczysz z czymś takim zamiast użyć Control.Arrow.second"

:t Control.Arrow.second 
Control.Arrow.second :: Arrow a => a b c -> a (d, b) (d, c)
Jak już myślałem, że zaczynam ogarniać te monady to teraz zerkłem se na to i : "Arrows, like monads, express computations that happen within a context. However, they are a more general abstraction than monads, and thus allow for contexts beyond what the Monad class makes possible". Także ten tego... nauka nie kończy się nigdy...

Brak komentarzy:

Prześlij komentarz