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.