08 listopada 2009

Programowanie w Clojure - część 2 - pierwszy przykład z wyliczaniem surowców

Nasze ćwiczenia z programowania w Clojure zaczniemy od uruchomienia Clojure REPL (Read-Eval-Print-Loop). Clojure REPL jest niczym innym jak interaktywnym interpreterem dla Clojure.
 jlaskowski@work /cygdrive/c/oss/clojure
$ java -jar clojure-1.1.0-alpha-SNAPSHOT.jar
Clojure 1.1.0-alpha-SNAPSHOT
user=>
Od tej pory zakładam, że wiemy, w jaki sposób dostać znak zachęty jak wyżej.

Powiedzmy, że chcemy wyliczyć liczbę surowców do wybudowania pewnej jednostki (w szczególności podniesienia poziomu samego surowca, aby dawał znacznie większą produkcję niż obecnie). Mamy 4 rodzaje surowców, które nazwiemy: drewno, glina, żelazo i zboże. Całkiem typowe w wielu grach strategicznych. Na celownik weźmiemy Traviana. Trochę czasu mi z nim zeszło i zawsze zastanawiałem się, czy jestem w stanie stworzyć system-bota, który podpowiadałby mi najlepszą strategię. Ktoś kiedyś wspominał coś o programowaniu nieliniowym odnośnie tego rodzaju kalkulacji, ale pojawiała się również wzmianka o programowaniu funkcyjnym. Ile w tym prawdy nigdy nie doszedłem, jednakże tym razem mam możliwość choć po części sprawdzić się w Clojure jako języku funkcyjnym do rozwiązania, albo chociaż próby rozwiązania, tego problemu. To byłby taki system ekspertowy, który podpowiadałby mi różne strategie (pewnie za nim stoi cała masa teorii matematycznej z różnymi modelami, ale ja wybieram podejście naiwne bez zebrania wcześniej wystarczającej wiedzy, tj. "na żywioł" :)). Do zbudowania gliny na 1. poziomie (tak, z każdą jednostką mamy związaną cechę - poziom) potrzeba 80 drewna, 40 gliny, 80 żelaza i 50 zboża (patrz 1.1.11 Kopalnia gliny). Każda wioska ma swoją produkcję i zakładam, że początkowo produkcja jest na 0. Najpierw spróbuję odpowiedzieć na pytanie, ile czasu potrzeba, abym podniósł produkcję gliny przy aktualnym stanie magazynu i produkcji surowców.

Mamy więc założenia do funkcji, którą w Javie moglibyśmy nazwać obliczCzasDoZwiekszeniaProdukcji(surowca, naPoziom, przyObecnymStanieMagazynowym, przyObecnejProdukcji). Nazwy funkcji w Clojure są zazwyczaj ciągiem słów oddzielonych myślnikami, co daje w naszym przypadku czas-zwiekszenia-produkcji (zakładając, że celem funkcji jest obliczenie czegoś nie widzę potrzeby powtarzania tego w nazwie funkcji).

Zaczynam od definicji magazynu. Będzie to mapa, która w Clojure deklarowana jest przez {} (nawiasy klamrowe), między którymi ciąg elementów tworzy pary klucz-wartość. Przecinki są opcjonalne i traktowane dokładnie jak spacja, czyli taki zapis
 user=> (def magazyn {:drewno 0 :glina 0 :zelazo 0 :zboze 0})
#'user/magazyn
jest równoznaczny takiemu:
 user=> (def magazyn {:drewno 0, :glina 0, :zelazo 0, :zboze 0})
#'user/magazyn
W definiowaniu mapy skorzystałem z możliwości definiowania słów kluczowych w Clojure z użyciem : (dwukropek). Wartością słów kluczowych są one same, więc idealnie nadają się na klucze w mapie.
 user=> :cokolwiek
:cokolwiek
Jak większość (wszystko?) w Clojure, tak i mapa jest funkcją, której pojedynczym argumentem jest klucz.
 user=> (magazyn :drewno)
0
Również odwrotne spojrzenie jest poprawne, tj. słowa kluczowe są również funkcją, której argumentem jest mapa, w której się odszukują (!)
 user=> (:drewno magazyn)
0
Takie spojrzenie na mapę i jej kluczy było dla mnie niezwykłym doświadczeniem mentalnym :) W sumie to logiczne, aby potraktować w ten sposób struktury danych, a zmienia całkowicie moje dotychczasowe spojrzenie obiektowe. Skoro wszystko jest funkcją, jedynym problemem pozostaje poznanie ich zasady działania (semantyki). Niby proste i pamiętam, że kiedy uczyłem się żonglować, co wydawało mi się początkowo niezwykle trudne i w zasadzie poza zasięgiem, kiedy oduczyłem się złego sposobu podawania piłeczki z ręki do ręki, nauczenie się poprawnie przez przerzucanie piłeczek było dziecinnie proste. Mówi się, że jedynym problemem w nauce nowego są nasze własne przyzwyczajenia i obawy. Chyba dlatego tak trudno jest się uczyć z wiekiem - zbyt wiele doświadczenia, które nas ogranicza?!

Do definicji funkcji w Clojure używamy defn, w której w cudzysłowach dodajemy opis funkcji, a w nawiasach kwadratowych parametry wejściowe.
 (defn czas-zwiekszenia-produkcji
"Wylicza potrzebny czas do zwiekszenia produkcji surowca na danym poziom przy danym stanie magazynowym i produkcji"
[surowiec, poziom, stanMagazynowy, produkcja]
...
)
Chwilowo zamiast parametru wejściowego poziom będącym liczbą całkowitą przekażemy mapę z potrzebnymi surowcami do przejścia na dany poziom.
 user=> (def poziom1 {:drewno 80 :glina 40 :zelazo 80 :zboze 50})
#'user/poziom1
W takim przypadku sam surowiec nie ma znaczenia, bo w końcu na wejściu mamy stan początkowy (stan magazynowy), stan docelowy i produkcję (przyrost jednostek w czasie 1h). Definicja funkcji mogłaby wyglądać tak:
  (defn czas-zwiekszenia-produkcji
"Wylicza potrzebny czas do osiagniecia stanu magazynowego na uruchomienie rozbudowy do poziomu przy danej produkcji"
[stanDocelowy, stanMagazynowy, produkcja]
...
)
Wszystkie parametry wejściowe są mapami.

Wywołanie funkcji w Clojure jest listą, której pierwszym elementem jest nazwa funkcji, a po nim następują parametry wejściowe.
 user=> (czas-zwiekszenia-produkcji docelowo magazyn produkcja)
Dodając do tego możliwość importowania typów javowych przez import oraz ich wywoływania przez . (kropka) czy / (ukośnik) otrzymujemy ostatecznie następujący program w Clojure do obliczania czas potrzebnego do zwiększenia produkcji surowca przy danym magazynie i bieżącej produkcji.
 (def magazyn {:drewno 10 :glina 4 :zelazo 15 :zboze 0})

(def produkcja {:drewno 5 :glina 2 :zelazo 5 :zboze 4})

(def glina1 {:drewno 80 :glina 40 :zelazo 80 :zboze 5})

(import '(java.util Calendar))

(def teraz (Calendar/getInstance))

(defn czas-zwiekszenia-produkcji
"Wylicza potrzebny czas (w minutach) do osiagniecia stanu magazynowego na uruchomienie rozbudowy do poziomu przy danej produkcji"
[docelowo, magazyn, produkcja]
(max
(/ (- (docelowo :drewno)(magazyn :drewno)) (produkcja :drewno))
(/ (- (docelowo :glina)(magazyn :glina)) (produkcja :glina))
(/ (- (docelowo :zelazo)(magazyn :zelazo)) (produkcja :zelazo))
(/ (- (docelowo :zboze)(magazyn :zboze)) (produkcja :zboze)))
)

(. teraz add (Calendar/MINUTE) (* 60 (czas-zwiekszenia-produkcji glina1 magazyn produkcja)))

(. teraz getTime)
Kopiując każdą z linii do REPL mamy:
 $ java -jar clojure-1.1.0-alpha-SNAPSHOT.jar
Clojure 1.1.0-alpha-SNAPSHOT
user=> (def magazyn {:drewno 10 :glina 4 :zelazo 15 :zboze 0})
#'user/magazyn
user=> (def produkcja {:drewno 5 :glina 2 :zelazo 5 :zboze 4})
#'user/produkcja
user=> (def glina1 {:drewno 80 :glina 40 :zelazo 80 :zboze 5})
#'user/glina1
user=> (import '(java.util Calendar))
java.util.Calendar
user=> (def teraz (Calendar/getInstance))
#'user/teraz
user=> (defn czas-zwiekszenia-produkcji
"Wylicza potrzebny czas (w minutach) do osiagniecia stanu magazynowego na uruchomienie rozbudowy do poziomu przy danej produkcji"
[docelowo, magazyn, produkcja]
(max
(/ (- (docelowo :drewno)(magazyn :drewno)) (produkcja :drewno))
(/ (- (docelowo :glina)(magazyn :glina)) (produkcja :glina))
(/ (- (docelowo :zelazo)(magazyn :zelazo)) (produkcja :zelazo))
(/ (- (docelowo :zboze)(magazyn :zboze)) (produkcja :zboze)))
)
#'user/czas-zwiekszenia-produkcji
user=> (. teraz add (Calendar/MINUTE) (* 60 (czas-zwiekszenia-produkcji glina1 magazyn produkcja)))
nil
user=> (. teraz getTime)
#<Date Mon Nov 09 07:16:59 CET 2009>
Wersja bardziej funkcyjna z map i reduce w kolejnym wpisie. Zdecydowanie zbyt wiele w tej wersji argumentów do max. Można się zapisać na śmierć z tymi kluczami w mapach i samym wprowadzaniem programu do wykonania. Wasze propozycje mile widziane, bo moja przygoda z programowaniem funkcyjnym z Clojure dopiero się zaczęła i wpadki są wręcz oczekiwane.

6 komentarzy:

  1. Zakładam, że odnosisz się do programowania funkcyjnego w Clojure? Jeśli tak, to może kontrpytanie: "A po co nam programowanie funkcyjne (PF), jeśli mamy obiektowe, czy imperatywne, czy ich mieszankę?".

    Ja sam jeszcze nie potrafię powiedzieć, jaką wartość niesie ze sobą PF, poza takimi ogólnikami jak "PF mówi co, a imperatywne jak". Próbuję i jak w końcu zrozumiem sens PF na pewno będę o tym trąbił wszem i wobec :)

    OdpowiedzUsuń
  2. Programowanie funkcyjne jest bardzo uzależniające. Najprostsze porównanie, które przychodzi mi na myśl w mojej krótkiej przygodzie z Clojure (zapoczątkowanej z resztą przez samego Jacka) to tak jak przerzucić się ze sterowania myszką na klawisze skrótu.

    Duża rolę odgrywa tutaj biblioteka użytkownika, którą to programista będzie sobie tworzył aby utrwalać swoje doświadczenie (zapisane algorytmy będą przystawać do wszystkich typów danych). W programowaniu imperatywnym najpierw mamy wymyślenie algorytmu, potem przejście do implementacji, w funkcyjnym tylko to pierwsze, więc oszczędzamy trochę czasu.

    Moja sugestia do Jacka to przerzucić się na Emacsa, zaoszczędzi Ci to mnóstwo czasu..

    Jeśli już chcesz pozostać przy oryginalnym REPL to polecam zintegrować się z JLine (wedle sugestii autora języka).

    Polecam również uczyć się przez analizę core.clj, na dobry początek polecam marco "->" oraz funkcję "line-seq".

    Szczerze to nauczanie przez podany przez Jacka przykład nie ma sensu. Jaka jest składnia i zasady stosowania tego wszystkiego można nauczyć się czytając stronę autora języka. Natomiast wartością jest przetłumaczenie technik algorytmicznych znanych z Java do Clojure. Np. jak uzyskać efekt zczytywania danych w sekwencji, i np. przerwaniu procesu w danym momencie.

    Motywacją do nauki PF jest zmęczenie materiału. Jeśli już się napiszemy kodu w Javie i już mamy dosyć klepania, jesteśmy zmęczeni tworzeniem "commonsów", upraszczaniem API, do tego zauważamy ograniczenia języka w wyrażaniu swoich potrzeb oraz zauważamy, że stosujemy skróty myślowe przy projektowaniu przepływu danych, bo każde nowe działanie jest podobne do poprzedniego, wtedy na pomoc przychodzi PF. Ktoś gdzieś napisał, że PF jest taką czerwoną pigułą przejścia na drugą stronę.

    Przeglądając fora można zauważyć, że wielu programistów Java po kilkunastu latach naturalnie przerzuca się na PF (po uzyskaniu efektu zmęczenia materiału). Clojure pozwala nam czerpać z bogatego dorobku Javy oraz cieszyć się lekkością wyrażania algorymtów.

    OdpowiedzUsuń
  3. Moja przygoda z Javą jest zbyt krótka, bym zechciał uczyć się programowania funkcyjnego.

    Słyszałem jednak, że w niektórych krajach na uczelniach wyższych studenci rozpoczynają swoją przygodę z programowaniem właśnie od języków funkcyjnych.

    OdpowiedzUsuń
  4. Nie tylko na zagranicznych, bo na MIMUWie jest i na UMK też było. Coś mi mówi, że na PJWSTK też jest. Ostatnio jednak zaskoczyła mnie informacja, że na PW (Elka) już nie.

    OdpowiedzUsuń
  5. Ciekawa dyskusja. Jacek - my chyba studiowaliśmy na UMK mniej więcej w tym samym czasie ;). Ale rzeczywiście pamiętam, że wówczas panowała pewnego rodzaju moda (ponownie! bo przecież FP to nie lata 90) na FP. Cieszę się, że mogłem uczyć się Haskella, Lispa czy Scheme i wiem, że programowanie imperatywne ma swoje wady. Myślę jednak, że to cecha uczelni, które mają podejście bardziej matematyczne, niż, powiedzmy techniczne do informatyki - stąd właśnie na uniwersytetach zwykle (?) sie uczy PF. Może nie zaczyna się od tego. Niemniej jest to naturalny język dla kogoś kto uczy się mocno algebry, czy logiki matematycznej czy choćby matematyki dyskretnej. Funkcje wyższego rzędu, lambda rachunek, rachunek predykatów to jest coś czego uczy się na logice czy algebrze - a potem to jest naturalne podejście w językach programowania. Po prostu często języki funkcyjne 'mówią' językiem matematyki. Zresztą - większość z tych języków ma dokładnie takie podłoże.

    Ale tyle o tym. Swoją drogą, wkładając kij w mrowisko. Powiedziałbym, że jeśli chce się zacząć przygodę z prawdziwie ciekawym językiem funkcyjnym to warto zaglądnąć na CAL :). Jest to bliski kuzyn Haskella, moim zdaniem najciekawszego języka funkcyjego. Co ciekawe CAL ma *doskonałą* dokumentację, jest *bardzo* dojrzały, wspierany przez SAP, BusinessObject. Jest używany w produkcyjnych, komercyjnych systemach (BusinessObjects). Posiada bardzo dobre środowisko (plugin do Eclipse - jest świetny, IMHO - warto zobaczyć przykład łączenia CAL z Java w jednym edytorze!). Ma nawet własne środowisko - Gem Cutter. No i, jak już mówiłem sam język, a właściwie dialekt jest, moim zdaniem, czymś bardzo odświeżającym. Mówię to ja, wieloletni użytkownik emaca, którego wszystkie makra są pisane w LISPie, zatem i wieloletni użytkownik Lispa ;). Aha strona CALa:

    www.openquark.org

    OdpowiedzUsuń