Functional Calisthenics z Kotlinem na GDCR 2017

pawelwlodarski.blogspot.com 7 lat temu

Code Retreat - a zwłaszcza GLOBAL - to doskonała okazja do nauki programowania, zerwania z nieskutecznymi nawykami i odnalezienie nowych podejść do rozwiązywania problemów. A, iż to jest to spotkanie cykliczne to akurat w tym miejscu wykorzystam starą metodę DRY i a TY będziesz dispatcherem/dispatcherką wywołania. Także wybierz sobie wstęp z jednego z poniższych artykułów.

  1. Motywacja do GDCR 2013
  2. Wnioski po GDCR 2013
  3. Kilka przykładów użycia mechanizmów FP ze scali do Game of Life
  4. Po GDCR 2014
  5. przed GDCR 2015 - tutaj w ogóle jakiś Haskell jest :D
  6. Po GDCR 2016 - przymiarki do funkcyjnego CR
  7. Wrażenia po GDCR 2016, którego nie było i tym razem trochę lepszy Haskell

Skoro już trochę historii za nami to wróćmy na chwilkę do ograniczeń. Jednym z popularniejszych na CR jest ograniczenie zwane object calisthenics, które jest nakierowane na poprawienie umiejętności pisania poprawnego obiektowego kodu. I od razu musimy sobie wyjaśnić jedną rzecz - jeżeli z jakiegoś powodu nie podoba ci się programowanie obiektowe to masz jak najbardziej prawo je krytykować ale najpierw musisz zrozumieć o co w tym wszystkim chodzi. jeżeli dzień za dniem rzeźbisz klasy, które nazywają się ManagerCosTamImpl albo CosTamHelper to nie masz problemu z programowaniem obiektowym ale zwyczajnie piszesz nad wyraz złozony kod proceduralny w języku, który wymusza by ten kod proceduralny był w klasach - stąd te kolosy po 8000 linijek.

A wracając do ograniczeń to object calisthenics mają np. takie ćwiczenie "jedna kropka na linie kodu" i to ma pomóc pisać kod, który lepiej enkapsuluje dane w klasach bo trudniej o tzw. train wreck obj.get()A.get()B.setC(c). No i jako, iż programowanie funkcyjne również zaczyna grać istotną role w twoim ulubionym języku to warto by podobne zasady wprowadzić dla FP.

W 2016 mieliśmy przerwę ale już w tym roku Code Retreat wraca na JUG Łódź. Spotkanie odbędzie się 18 listopada a zapisac można się tutaj -> Global Day of CR łódź Meetup. W tym roku będzie także zapewniona opieka nad dziećmi także można wpaść z rodziną.

No i by do tematu nauki podejść całościowo i rozszerzyć ćwiczenia o FP to tym razem najprawdopodobniej kilka ćwiczeń będzie zainspirowanych nowym zestawem functional calisthenics -> https://codurance.com/2017/10/12/functional-calisthenics/

No to jedziemy z koksem i obczajamy te zasady. Tym razem użyjemy języka, który zdobywa coraz większą popularność na JVM, ma dobre wsparcie w IDE bo robi go firma, która sama robi IDE a do tego jest wciąż bezpiecznie ubogi tak, ze korporacje nie powinny nim sobie zrobić krzywdy - mowa o Kotlinie.


Zasada 1 : Nazywaj wszystko i naucz się rozpoznawać wzorce w kodzie.

"All functions need to be named. Which means that you shouldn't use lambdas or anonymous functions. Also, name all your variables as per standard clean code rules."

Jeśli musisz dać czemuś nazwę to istnieje duża szansa, ze wiesz co ten fragment musi robić. No i teraz jeżeli zamiast używać anonimowych funkcji zaczniesz je definiować i nazywać to może zauważysz szansę na parametryzacje i kompozycje. Ale aby to miało ręce i nogi najpierw musisz w ogóle zastosować podejście funkcyjne a tutaj moga pomóc bardziej fundamentalne ograniczenia jak np. brak zmiennych.

Co do implementacji to tutaj bardzo może się przydać nowy ficzer z kotlina 1.1 - aliasy typów.

//aliases since Kotlin 1.1
typealias Cell = Boolean
typealias Neighbours = Int
val
LIVE_CELL:Cell = true
val
DEAD_CELL:Cell = false


fun cellEvolution(c:Cell,n:Neighbours): Cell =
        when(c){
            LIVE_CELL ->
if(n==2 || n==3) LIVE_CELL
else
DEAD_CELL
DEAD_CELL ->
if(n==3) LIVE_CELL
else
DEAD_CELL
        }

Powyżej pierwsza próba, w trakcie której próbowałem zadeklarować dwie stałe okreslające stan komórki DEAD oraz LIVE jakkolwiek kompilator (a przynajmniej plugin do Intellij) nie był w stanie wykryć iż wszystkie warunki w wywołaniu when zostały ogarnięte. Dlatego tez powstało drugie podejście - sealed class

sealed
class
Cell
object
LiveCell :Cell()
object
DeadCell :Cell()


fun cellEvolution(c:Cell,n:Neighbours): Cell =
        when(c){
            LiveCell ->
if(n==2 || n==3) LiveCell
else
DeadCell
DeadCell ->
if(n==3) LiveCell
else
DeadCell
        }


Zasada 2 : Brak zmiennego stanu.

Dosłownie : "You shouldn't use any variable of any type that can mutate."

Najważniejsze w tym ćwiczeniu jest "zablokowanie" uczestnikom drogi w kierunku (nawet i poprawnego) programowania obiektowego gdyż w tym wypadku nie można owinąć zmiennego stanu instancją obiektu.

//THIS IS FORBIDDEN
class
MutableCell(private
var
state:Boolean){
    fun evolve(n:Neighbours){
        //set new state here
    }
}

Pewne trudności moga sie pojawić kiedy będziemy chcieli zmienić stan planszy - no ale przecież zmiany nie są dozwolone!. Kotlin dosyć ładnie oddziela api służące do deklarowania i używania niemutowalnych kolekcji od mutowalnych odpowiedników. Aby transformować niemutowalne listy tworząc nowe niezależne wersje używamy funkcyjny wyższego rzędu jak map czy filter obecnych teraz choćby w Javie.

typealias Coordinates=Pair<Int,Int>
typealias CellPosition=Pair<Coordinates,Cell>
typealias Board = kotlin.collections.List<CellPosition>
val
nextRound : (Board) ->
Board = {board ->
    board.map { ??? }
}

No i zobacz czytelniku/czytelniczko jakim wygodnym pomysłem są aliasy w przykładzie powyżej.

Zasada 3 : Każdy argument na wejściu zwraca wynik

"There can not be an if without an else. Switches and pattern matching should always have all paths considered"

To jest dosyć interesująca zasada bo dobrze oddaje naturę code retreat. Tak więc mamy proste założenie, którego uczestnicy mają się trzymać i obserwować wyniki. Jest to na początku znacznie prostsze aniżeli wchodzenie w rozważania teoretyczne o funkcjach częściowych. Każdy argument musi generować konkretny wynik - bez wyjątków - dosłownie!

Zasada 4 : Nie używaj zmiennych tymczasowych

Zasada 5 : Expressions, not statements

Te dwie zasady postanowiłem połączyć bo w moim odczuciu dotyczą podobnego tematu. Dodatkowo druga zostawiam po engliszu by nie popełnić błędów w tłumaczeniu.

"There shouldn't be any variable declared in the body of a function." oraz "All lines should be expressions. That's it, all lines should return a value."

Tutaj języki takie jak Kotlin czy Scala mogą pomóc bardzo, bardzo, bardzo bo tam wszystko zdaje się coś zwracać! A szczególnie if jest wygodny jak coś zwraca. W Javie zwykle trzeba coś tam manipulować po bokach.

fun badEvolve(cell:Cell,neighbours:Int):Cell{
    var
newCell:Cell?=null
//val newCell:Cell would also work here but var shows problem better
if(cell==LiveCell && neighbours in (2..3))  {
        newCell = LiveCell
    }
    else
if(cell==LiveCell)  {
        newCell = DeadCell
    }
    else
if(cell==DeadCell && neighbours==3) {
        newCell=LiveCell
    }
    else {
        newCell=DeadCell
    }

    return newCell
}

W Javie często deklarowane są zmienne tymczasowe (pamiętacie te czasy gdy 90% zmiennych w waszym godzinie nazywało się temp?) tylko po to by coś w nich na chwilę przypisać i od razu dać return. Wyjście? Niech if coś zwraca i niech to będzie wynik metody. No i w Kotlinie to dzieje sie naturalnie. W Javie niby jest tzw. "ternary operator" ale on chyba dlatego nazywa się ternary bo nie ma "if-else". Trochę peszek.

fun betterEvolve(cell:Cell,neighbours:Int):Cell{
    return
if(cell==LiveCell && neighbours in (2..3)) LiveCell
else
if(cell==LiveCell) DeadCell
else
if(cell==DeadCell && neighbours==3) LiveCell
else
DeadCell
}

No i teraz najlepsze. Ponieważ jest to jedyna instrukcja funkcji to możemy wyrażenie uprościć jeszcze bardziej.

fun bestEvolve(cell:Cell,neighbours:Int):Cell =
        if(cell==LiveCell && neighbours in (2..3)) LiveCell
else
if(cell==LiveCell) DeadCell
else
if(cell==DeadCell && neighbours==3) LiveCell
else
DeadCell

Zasada 6 : Brak jawnej rekurencji

Przyznam, iż tego punktu do nie rozumiem a to z faktu, iż autorzy odnoszą się do jakichś konstrukcji z Clojure. Coś tam kiedyś czytałem o "loop/recur" ale nie będę udawał, iż pamiętam. Tak czy inaczej w tym punkcie znajdziemy poradę by tę "jawną rekurencję" zastąpić map/reduce . No to tak będziemy starali się zrobić.

Zasada 7 : Generyczne bloki

"Try to use a much as possible generic types for your functions, outside of the boundaries of your application"

To jets dosyć interesujący i abstrakcyjny sam w sobie punk. Z jednej strony ile razy deklarowałeś listę kiedy kiedy Collecion lub Iterable w zupełności wystarczało? Z tym ograniczeniem będziesz musiał dokładnie uzasadnić czy typ, którego używasz nie jest zbyt konkretny co może związać ci ręce przy dalszych zmianach. Dodatkowo w Kotlinie ze względu na wykrywanie typów musisz się zastanowić czy pozostawić prace kompilatorowi, który pewnie znajdzie najbardziej konkretny typ czy też może zadecydować świadomie samemu.

Możesz zastosować podobne podejście do funkcji ale tutaj idzie to trochę w inną stronę. Czy obecna implementacja dla danego typu nie jest zbyt "wąska"? Klasycznym przykładem jest tutaj reduce które jest generalizacją dodawania, mnożenia i czego tam sobie zażyczymy.

fun  add(list:List<Int>) : Int =if(list.isEmpty()) 0
else list[0] + add(list.drop(1))
fun  multiply(list:List<Int>) : Int =if(list.isEmpty()) 1
else list[0] * multiply(list.drop(1))

fun <A> genericReduce(l:List<A>,neutral:A,reduction:(A,A)->A):A =
        if(l.isEmpty()) neutral else reduction(l[0], genericReduce(l.drop(1),neutral,reduction))

I zgodnie z oczekiwaniami

println(add(listOf(1,2,3,4,5)))
println(multiply(listOf(1,2,3,4,5)))

println(genericReduce(listOf(1,2,3,4,5),0,{a,b->a+b}))
println(genericReduce(listOf(1,2,3,4,5),1,{a,b->a*b}))

Zasada 8 : Efekty uboczne tylko na granicy systemu

"Side effects should only appear on the boundaries of your application. The guts of your application should have no side effects."

Tutaj po pierwsze musisz używać funkcji, które nie zmieniają żadnego stanu poza nimi żadnego println czy też modyfikowania mutowalnej tablicy zdefiniowanej poza funkcją. To ograniczenie może być jeszcze bardziej interesujące gdy założysz, iż efekty na granicy systemu musza być reprezentowane przy pomocy odpowiednich typów.

W takich wypadkach możesz skorzystać z typu Optional dodanego do Javy8

typealias Coordinates=Pair<Int,Int>
typealias CellPosition=Pair<Coordinates,Cell>
typealias Board = kotlin.collections.List<CellPosition>
val
initializeBoard:(Coordinates) ->
Board = ...
val
nextTurn: (Board) ->
Board = ...


fun readInput():Optional<Coordinates> = ...

fun display(b:Board):Unit = ...

readInput().map(initializeBoard).map(nextTurn).ifPresent(::display)

W przykładzie powyżej możesz zobaczyć, iż Kotlin całkiem wygodnie integruje Jawowe klasy. Optional służy tam do zasygnalizowania efektu ubocznego związanego z wprowadzaniem danych ze świata zewnętrznego - który jakby nie patrzyć jest trochę uboczny.

Bardziej zaawansowani uczestnicy mogą zechcieć skorzystać z również bardziej zaawansowanej biblioteki http://kategory.io/ i użyć IO Monad do wypisywania tekstu na konsolę http://kategory.io/docs/effects/io/ .

Zasada 9 : Nieskończone strumienie danych

W skrócie - nie możesz używać tablic ani kolekcji. Tam gdzie użyłbyś standardowej kolekcji musisz użyć strumieni/ nieskończonej sekwencji

Programowanie Funkcyjne kładzie ogromny nacisk na operowanie na wartościach bez mutowania stanu. jeżeli nie zmieniamy stanu i wartości są stałe to czy ważne jest kiedy operacje będą miały miejsce (stąd FP ułatwia operacje wielowątkowe)? No nie ma w związku z czym możemy pracować na tzw. leniwych kolekcjach które są niejako "opisane" ale nie istnieją puki nie trzeba.

Przykładowymi kandydatami do "strumieniowania" w grze życie są komórki - na z definicji nieskończonej planszy. Również gra sama w sobie jest nieskończoną kolekcją tur.

//here we are playing 10 turns
val
init:Board = initializeBoard()
generateSequence(init,nextTurn).take(10).forEach(::display)

Ale zamiast 10 tur możemy chcieć grać do momentu kiedy na planszy mamy jakiekolwiek żywe komórki. Aby było bardziej interesująco przeskoczmy na chwile do Javy. Pierwszą rzeczą jaką chcemy sprawdzić to czy da się wykorzystać kod, ktory napisaliśmy w Kotlinie.

List<Pair<Pair<Integer, Integer>, Cell>> board = Life1Kt.initializeBoard();

Technicznie jest to możliwe ale praktycznie raczej nikomu nie będzie się chciało babrać z tak rozbudowanymi typami generycznymi. No dobrze to spróbujmy od początku :

Board init= ...;
Predicate<Board> hasLiveCells = ...;
UnaryOperator<Board> nextTurn = ...;
Consumer<Board> display=...;

//below iterate version possible from Java9
Stream.iterate(init,hasLiveCells,nextTurn).forEach(display);

//take while possible from Java9
Stream.iterate(init,nextTurn).takeWhile(hasLiveCells).forEach(display);

Zasada 10 : Tylko funkcje jedno parometrowe

"Each function should have a single parameter."

Według mnie to najważniejsze ograniczenie by dobrze zrozumieć siłę kompozycji funkcji. Gdy przekazujesz do metody całą paczkę parametrów możesz ją postrzegać jako wielki monolit. ale kiedy przesłanie kolejnych parametrów określa tak naprawdę innego rodzaju funkcje - wtedy musisz się zastanowić co z czego tworzysz. Dla przykładu funkcję findNeighbours możesz zaimplementować na dwa sposoby.

findNeighbours : (Coordinates) -> (Board) ->
Neighbours = ...

Czy widzisz różnicę? Czy lepiej mieć funkcje, która znajdzie sąsiadów dla przekazanych koordynat na z góry określonej planszy czy może wygodniej jest przekazywać planszę dla wcześniej orkeślonych koordynat?

Co do samego Kotlina to nie ma on tak wygodnego wsparcia dla curryingu jak scala (currying to własnie to, iż przekazujesz parametr po parametrze) ale jak się trochę ogarnie składnie to też daje radę

val
evolve : (Neighbours) -> (Cell) ->
Cell = {ns->{c->c}}

Ewentualnie produkuj funkcję z metody

fun evolve2(ns:Neighbours) : (Cell) ->
Cell = {c -> c}

Zasada X : Żadnych nulli

Ta zasada co prawda nie pojawia się w oryginalnym zestawie ale w programowaniu funkcyjnym null jest ... bez sensu. Kotlin ma interesujące patenty na poziomie języka do radzenia sobie z nullami . jeżeli nie chcesz w nie wchodzić wtedy cóż, zawsze zostaje prawilne podejście z opakowywaniem każdego nullowego api jak map.get w Optional.

Podsumowanie

Podobnie jak chodząc na siłownię powtarzasz pewne ćwiczenia tak samo tutaj powtarzając cwiczenia nabierasz informatycznej siły. I analogicznie samo czytanie o ćwiczeniach kilka ci da. jeżeli chcesz zwiększyć siłę programowania funkcyjnego ćwicz,ćwicz,ćwicz,ćwicz,ćwicz,ćwicz,ćwicz,ćwicz,ćwicz,ćwicz,ćwicz,ćwicz. Poszukaj spotkania code retreat w twojej okolicy lub podobnych warsztatów i nie opierdalaj się!

Idź do oryginalnego materiału