Skip to content

henrik42/hearts

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

92 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Hearts

Dies ist eine Implementierung des Spiels Hearts [1] in Clojure [2]. Ich habe nicht alle Regeln des Spiels implementiert. Angeregt hat mich ein Artikel im Java Magazin 2019/09 [3], in dem eine Implementation in Haskell vorgestellt wurde.

Um den Code laufen zu lassen, brauchst du erstmal ein Java 8 JDK (eine JRE sollte auch reichen).

Dann braucht man Clojure [4, 5]. Alles was man benötigt, ist die eine JAR Datei. Du kannst sie über deinen Browser runterladen oder auch per wget:

wget https://repo1.maven.org/maven2/org/clojure/clojure/1.8.0/clojure-1.8.0.jar

Und schließlich noch den Quelltext. Du kannst entweder (a) das git Repo clonen oder (b) den master Branch downloaden oder (c) einfach nur die eine Datei runterladen.

(a) git Repo clonen

Falls du git auf deinem Rechner hast, kannst du das Repository clonen. Mit dem folgenden Befehl landet das Repo, inkl. der Historie der Commits etc. im aktuellen Verzeichnis unter ./hearts/:

$ git clone https://github.com/henrik42/hearts.git
Cloning into 'hearts'...
remote: Enumerating objects: 71, done.
remote: Counting objects: 100% (71/71), done.
remote: Compressing objects: 100% (47/47), done.
Receiving objects: remote: Total 261 (delta 43), reused 49 (delta 22), pack-reused 190
Receiving objects: 100% (261/261), 115.45 KiB | 0 bytes/s, done.
Resolving deltas: 100% (152/152), done.
Checking connectivity... done.

Und im aktuellen Verzeichnis ausführen:

$ java -jar clojure-1.8.0.jar -i hearts/src/hearts/core.clj -e '(hearts.core/spiel)'

Falls du Leiningen [13] installiert hast, kannst du auch folgendes machen:

$ cd hearts
$ lein test
...
Ran 9 tests containing 20 assertions.
0 failures, 0 errors.

$ lein run
...

(b) master Branch downloaden

Du kannst auch den aktuellen Stand des master-Branches runterladen. Das ist dann eine Kopie des Quell-Stands --- aber kein git Repository.

Mit dem folgenden Befehl landet der Stand im aktuellen Verzeichnis unter ./hearts-master/:

$ wget -qO- http://github.com/henrik42/hearts/archive/master.tar.gz | tar -vzxf -
hearts-master/
hearts-master/.gitignore
hearts-master/.lein-failures
hearts-master/README.md
hearts-master/project.clj
hearts-master/src/
hearts-master/src/hearts/
hearts-master/src/hearts/core.clj
hearts-master/test/
hearts-master/test/hearts/
hearts-master/test/hearts/core_test.clj

Und ausführen.

$ java -jar clojure-1.8.0.jar -i hearts-master/src/hearts/core.clj -e '(hearts.core/spiel)'

(c) Download core.clj

Um die folgenden Beispiele alle nachzuvollziehen, reicht es, core.clj runterzuladen.

$ wget https://raw.githubusercontent.com/henrik42/hearts/master/src/hearts/core.clj

Und ausführen.

$ java -jar clojure-1.8.0.jar -i core.clj -e '(hearts.core/spiel)'

Du kannst auch eine interaktive REPL [7, 16] starten und dann Dinge ausprobieren, während du die folgende Beschreibung durchliest:

$ java -jar clojure-1.8.0.jar -i core.clj -e "(in-ns 'hearts.core)" -r
#object[clojure.lang.Namespace 0xc430e6c "hearts.core"]
hearts.core=> spieler
[:gabi :peter :paul :sonja]
hearts.core=> ^D

Oder via Leiningen, falls du dir das git Repo geholt hat:

$ lein repl
nREPL server started on port 64069 on host 127.0.0.1 - nrepl://127.0.0.1:64069
REPL-y 0.3.7, nREPL 0.2.12
Clojure 1.8.0
Java HotSpot(TM) 64-Bit Server VM 1.8.0_172-b11
	Docs: (doc function-name-here)
		  (find-doc "part-of-name-here")
  Source: (source function-name-here)
 Javadoc: (javadoc java-object-or-class-here)
	Exit: Control+D or (exit) or (quit)
 Results: Stored in vars *1, *2, *3, an exception in *e

hearts.core=> bilder
(2 3 4 5 6 7 8 9 10 :bube :dame :koenig :ass)

Falls du mal Änderungen am Code machen möchtest, kannst du diese via load-file laden, ohne dass du dafür die REPL neu starten musst:

hearts.core=> (load-file "core.clj")

Über [11] kannst du dich auch über deinen Browser mit einer Clojure REPL verbinden und über ein Web-Interfaces mit dieser interagieren. D.h. in diesem Fall brauchst du auf deinem Rechner kein JDK zu haben.

Du kannst Clojure bzw. eine REPL aber auch in deinem Browser ausführen [9]. Es gibt nämlich einen Clojurescript->JavaScript Transpiler, der aus Clojurescript [10] JavaScript macht und das läuft dann in deinem Browser. D.h. der Clojure(-Script) Code, den du in die GUI eingibst, wird in deinem Browser durch den Transpiler in JavaScript überführt und dann dort ausgeführt. So brauchst du weder JDK/Java noch den Code auf deinem Rechner.

Hinweis: um in [9] den Code aus den folgenden Beispielen via copy & paste einzufügen, musst du in der GUI über das Tastatur-Symbol den input mode auf none oder indent-mode setzen. paren-mode funktioniert nicht, weil er dazu führt, dass am Zeilenende automatisch die schließenden Klammern zugefügt werden und das führt dann wiederum zu Syntaxfehlern. Beim Einfügen von mehreren Formen gleichzeitig hatte ich Probleme. Wenn man die Top-Level-Forms einzeln einfügt, klappt es aber.

Falls du mehr über die Möglichkeiten wissen möchtest, wie man Clojure Programme bauen und ausführen kann, findest du ein paar Hinweise in [8].

Einführungen in Clojure findest du natürlich auch massenhaft im Internet [12, 15].

Falls du beim Nachvollziehen der unten aufgeführten Beispiele auch mal andere Sachen ausprobieren möchtest, hilft ein Blick in [14].

[1] https://de.wikipedia.org/wiki/Hearts
[2] https://clojure.org/
[3] https://kiosk.entwickler.de/java-magazin/java-magazin-9-2019/hearts-ist-trumpf/
[4] https://clojure.org/community/downloads
[5] https://repo1.maven.org/maven2/org/clojure/clojure/1.8.0/clojure-1.8.0.jar
[6] https://raw.githubusercontent.com/henrik42/hearts/master/src/hearts/core.clj
[7] https://clojure.org/guides/repl/introduction
[8] https://github.com/henrik42/solo
[9] https://clojurescript.io/
[10] https://clojurescript.org/
[11] https://repl.it/languages/clojure
[12] http://hitchhikersclojure.com/blog/hitchhikers-guide-to-clojure/
[13] https://leiningen.org
[14] https://clojure.org/api/cheatsheet
[15] http://clojure-doc.org/
[16] https://lambdaisland.com/guides/clojure-repls


Spielregeln

Ich versuche, die Regeln des Spiels möglichst knapp zu formulieren und dabei auch schon Ausdrücke zu benutzen, die sich später im Code wiederfinden. So entwickelt sich eine ubiquitous language ("allgegenwärtige Sprache"), in der wir uns über unsere Domäne unterhalten können.

  • Hearts ist ein Karten-Spiel mit 52 (= 4 x 13) Spielkarten.

  • Es gibt eine Rangfolge von 13 Karten-Bilder (2..10, Bube, Dame, König, Ass)

  • Es gibt 4 Karten-Farben (Kreuz, Pik, Herz, Karo)

  • Jede Karte hat einen Punktwert. Diese werden wir später zählen und addieren, wenn wir den Gewinner eines Spiels ermitteln:

    • Pik-Dame : 13
    • Herz-Karte : 1
    • alle anderen : 0
  • Es gibt vier Spieler, die in einem gedachten Kreis sitzen. Es gibt somit eine Reihenfolge der Spieler (vgl. Runden unten)


Spielablauf

Das Spiel wird in Runden gespielt, in denen jeweils ein Spieler einen Stich macht.

Spielbeginn

Vor der ersten Runde werden die Karten gemischt und je 13 Spielkarten verdeckt auf die Spieler ausgeteilt. Jeder Spieler nimmt anschließend seine Karten auf. Jeder Spieler hält/kennt also nur seine Karten (seine Hand).

Der Spieler mit der Kreuz-2 beginnt (eröffnet) die erste Runde. Alle folgenden Runden werden von demjenigen Spieler begonnen, der in der jeweils vorangegangenen Runde den Stich (vgl. unten) erhalten hat.

Runden

Nun werden die Runden gespielt. Eine Runde beginnt damit, dass der eröffnende Spieler eine Eröffnungskarte offen ausspielt. In der ersten Runde muss die Kreuz-2 ausgespielt werden (vgl. oben).

Nun spielen die drei anderen Spieler der Reihe nach (vgl. oben gedachter Kreis) auch jeweils eine Karte offen aus. Falls ein Spieler eine oder mehrere Karte mit der gleichen Farbe wie die Eröffnungskarte auf der Hand hat, muss er eine dieser Karten ausspielen. Es muss also die Farbe der Eröffnungskarte bedient werden. Andernfalls kann er eine beliebige Karte von seiner Hand spielen (abwerfen).

Nachdem alle Spieler ihre Karte in der Runde gespielt haben, erhält derjenige Spieler die gespielten Karten (den Stich), der die Karte mit der Farbe der Eröffnungskarte mit dem höchsten Rang gespielt hat. Er legt den Stich verdeckt auf seinen Haufen. Er nimmt sie also nicht auf seine Hand.

Es kann natürlich vorkommen, dass der eröffnende Spieler selbst den Stich erhält. Dies ist der Fall, falls alle anderen Spieler abgeworfen haben oder alle anderen Spieler mit einer Karte bedient haben, die unter dem Rang der Eröffnungskarte ist.

Damit ist die Runde beendet.

Durch das Erhalten des Stichs sammeln die Spieler über die Runden also Stiche an, die sie auf ihren Haufen "vor sich auf dem Tisch" legen, der zu Beginn leer ist.

Spielende

Das Spiel endet immer nach der 13. Runde. Die Runden werden immer alle gespielt. Es gibt kein vorzeitiges Spielende, selbst wenn schon während des Spiels feststehen sollte, wer das Spiel gewinnen wird.

Nach der letzten Runde werden für jeden Spieler die Punkte ermittelt. Die Punkte eines Spielers ergeben sich aus der Summe der Karten-Punktwerte (vgl. oben) jener Karten, die er über die Stiche erhalten/gesammelt hat --- also die Karten auf dem Haufen jeden Spielers.

Gewonnen hat derjenige Spieler, der nach Spielende die wenigsten Punkte hat. Es kann auch sein, dass sich mehrere Spieler bei Punktgleichheit den Sieg teilen.


Implementation

Nun folgt eine detailierte Erläuterung des Codes. Ich möchte damit verschiedene Aspekte von Clojure erläutern. Dabei werde ich erst immer jene Dinge erläutern, die für das Verständnis des dann anschließend aufgeführten Code-Abschnitts nötig sind. Ich führe hier wirklich jede Zeile-Code auf. Es wird nichts ausgelassen.

Los geht's.

Clojure ist eine funktionale Programmiersprache [1]. Was das genau bedeutet, kann ich leider nicht aufschreiben. Erwähnt werden sollte aber, dass funktionale Programmiersprachen durchaus auch Aspekte der Objekt-orientierten Programmiersprachen [2] enthalten (z.B. Datenkapselung [3]), nur tut sie das mit anderen Mitteln [4]. Die beiden sind also kein echter Gegensatz .

Somit sind das Gegenteil zu funktionalen Programmiersprachen am ehesten die imperativen Programmiersprachen [5]. In dem Wikiartikel wird zwar gesagt, dass die deklarativen Programmiersprachen (wie z.B. PROLOG) der Gegensatz zu den imperativen Programmiersprachen seien. Für mich entscheidend ist aber, dass die imperativen Programmiersprachen im wesentlichen auf Anweisungen (Statements) und Zustandsänderungen (also die Änderung von Variablen bzw. Speicherstellen; von Neumann Rechner) basieren. Die funktionale Programmierung basiert auf Funktionen und Werten. Das ist ein riesiger Unterschied und wie so ein funktionales Programm sich anfühlt, wird hoffentlich durch den folgenden Text deutlich.

Clojure-Code ist in Namespaces (Namensräumen [6]) organisiert (ähnlich wie Packages in Java). I.d.R. entspricht jeder Namensraum einer Datei. Diese Datei muss in einem Verzeichnis liegen, dessen Name zum Namensraum passt. Der Namesraum hearts.core findet sich in der Datei src/hearts/core.clj.

Die Dateien und Namensräume bilden eine hierarchische Struktur. Diese Hierarchie ist für Clojure jedoch ohne Bedeutung. Sie dient allein der Strukturierung der Code-Basis und hat keine Auswirkung auf Sichtbarkeit oder ähnliches.

I.d.R. schneidet man Namensräume nach fachlichen/inhaltlichen Gesichtspunkten. Namensräume dienen ebenfalls als Mittel um Namenskollisionen zu vermeiden und um die Sichtbarkeit einzuschränken (durch private Namen).

Clojure-Code (d.h. eine Clojure-Datei) besteht i.d.R. aus Folgen von sog. S-Expressions [7]. Dabei handelt es sich um geschachtelte (hierarchische) Klammerausdrücke (Listen).

Beispiel: (str "Hello," "world")

Die Elemente dieser Listen sind Forms (Formen). Es gibt eine ganze Reihe von Formen (Symbole, Keywords, Zahlen, Listen, Vektoren, Strings, etc.), von denen wir weiter unten einige kennenlernen werden. Das ist im Prinzip alles, was man zur Syntax von Clojure sagen kann [8].

Die folgende Liste (erste Codezeile von core.clj) hat als erstes Element das Symbol ns und als zweites Element das Symbol hearts.core. Das erste Element einer Liste bezeichnet etwas ausführbares (ich verwende dafür den Bezeichner Funktor; z.B. eine Funktion oder ein Makro, aber es gibt in Clojure weitere) und die folgenden Elemente bilden die Argumente des Funktors.

Die Bedeutung (Sematik) der Formen ergibt sich durch Clojures Auswertungs-Regeln [9]. Die meisten Formen "werten zu sich selbst aus". Das heißt, dass die Auswertung der Form 2 (Syntax) die Zahl 2 (Semantik; vom Typ java.lang.Long) ergibt.

REPL:

hearts.core=> 2
2
hearts.core=> (type 2)
java.lang.Long
hearts.core=> "foo"
"foo"
hearts.core=> (type "foo")
java.lang.String
hearts.core=> [1 2 3]
[1 2 3]
hearts.core=> str
#object[clojure.core$str 0x413f69cc "clojure.core$str@413f69cc"]

Symbole werten zu jenem Wert aus, der an den durch das Symbol benannten Namen gebunden ist (vgl. unten).

Die Auswertung von Listen erfolgt durch Auswertung der Listen-Element-Formen (die Listen-Element-Formen können wiederum Listen sein; rekursive Auswertung), wobei das erste Element zu einem Funktor auswerten muss, und der Anwendung des Funktors auf die restlichen (Auswertungs-)Werte (Argumente).

Es gibt einige Sonderfälle, die eine andere Auswertungsregel haben [10]. Und es gibt auch Funktoren, die Seiteneffekte [13] haben, also im funktionalen Sinn nicht rein (engl. pure) sind.

Da Listen sowohl für ausführbaren Code als auch als Datenstruktur verwendet werden (sog. Homoiconicity; "code is data is code" [12, 11]), fällt es Clojure-Neulingen zu Beginn häufig schwer, zu erkennen, ob eine S-Expression/Form nun als ausführbarer Code oder als Daten-Wert gilt.

Mit ns wird das Makro (mehr zu Makros weiter unten) clojure.core/ns benannt. Dieses Makro sorgt dafür, dass der angegebene Namensraum aufgemacht wird. Das Makro kann noch eine Menge mehr, wie z.B. andere Namensräume importieren, aber das brauchen wir hier nicht.

[1] https://de.wikipedia.org/wiki/Funktionale_Programmierung
[2] https://de.wikipedia.org/wiki/Objektorientierte_Programmierung
[3] https://de.wikipedia.org/wiki/Datenkapselung_(Programmierung)
[4] https://de.wikipedia.org/wiki/Closure_(Funktion)
[5] https://de.wikipedia.org/wiki/Imperative_Programmierung
[6] https://clojure.org/reference/namespaces
[7] https://en.wikipedia.org/wiki/S-expression
[8] https://clojure.org/guides/learn/syntax
[9] https://clojure.org/reference/evaluation
[10] https://clojure.org/reference/special_forms
[11] http://blog.muhuk.com/2014/09/28/is_clojure_homoiconic.html#.XYuwxHtCRhE
[12] https://de.wikipedia.org/wiki/Homoikonizit%C3%A4t
[13] https://de.wikipedia.org/wiki/Wirkung_(Informatik)


(ns hearts.core)

def (clojure.core/def [3]; auch ein Makro) bindet einen Wert (hier einen String) an einen Namen (hier durch Symbol hr angegeben) im aktuellen Namensraum. Die Form hr wird also in diesem Fall nicht zu ihrem gebundenen Wert ausgewertet (vgl. oben), sondern sie wird als Name für die Bindung verwendet (so ähnlich wie ein lvalue -- sprich "el-value" -- in Java [1]).

Es handelt sich um einen Java String vom Typ java.lang.String. Clojure übernimmt komplett die java.lang Datentypen, anders als z.B. Jython, das mit Wrapper-Datentypen arbeitet. Dadurch lässt sich Clojure sehr elegant mit anderen Java Klassen/Bibliotheken integrieren; sog. Java interop [2].

Als Java-Entwickler denkt man bei diesen globalen Namen vielleicht an Variablen. Es ist jedoch unüblich (wenn auch möglich), während eines Programmlaufs einen Namen nacheinander bzw. wiederholt an (verschiedene) Werte zu binden.

Dies entspricht in einem gewissen Sinne der Wertzuweisung an eine Variable in Java; tatsächlich ist es aber völlig anders, aber das soll hier nicht im Detail erläutert werden. Diese Bindungen sind also eher wie static final Felder in Java zu verwenden. Aber wie schon gesagt: es ist völlig anders! [4]

Wenn man die def Zeile in der REPL eingegeben hat (bzw. mit -i core.clj die Datei geladen hat), kann man sich anschließend den gebundenen Wert ausgeben lassen.


Einschub: Die REPL

Die REPL liest Formen ein, wertet sie aus und druckt das Ergebnis aus (REPL: Read, Eval, Print Loop). Die Ausgabe erfolgt in einem Format, in dem diese Ausgabe auch wieder als Eingabe genutzt werden kann. Daher werden die Zeilenumbrüche z.B. als \n ausgegeben und der ganze String inkl. der Anführungszeichen ausgegeben. Die Ausgabe erfolgt also im Literal-Format (mit einigen Ausnahmen).

REPL:

hearts.core=> hr
"\n------------------------------------\n"
hearts.core=> (type hr)
java.lang.String
hearts.core=> (.getClass hr)
java.lang.String
hearts.core=> 42
42
hearts.core=> (type 42)
java.lang.Long

Die einzelnen Schritt der REPL sind auch als Funktionen verfügbar. Man kann sich also eine REPL selber bauen.

REPL:

user=> (def foo "x\nfoo\ny")
#'user/foo
user=> foo
"x\nfoo\ny"
user=> (println foo)
x
foo
y
nil
user=> (pr-str foo)
"\"x\\nfoo\\ny\""
user=> (read-string (pr-str foo))
"x\nfoo\ny"

Mit read-string können wir also ein Programm (d.h. eine Form) aus dem String lesen. Das geht auch mit anderen Formen:

REPL:

user=> (read-string "2")
2
user=> (read-string ":foo")
:foo
user=> (read-string "foo")
foo
user=> (type (read-string "foo"))
clojure.lang.Symbol

Man erkennt, dass zwar die Form geparst wird, aber sie wird nicht ausgewertet. Das macht man mit eval:

REPL:

user=> (eval (read-string ":foo"))
:foo
user=> (eval (read-string "2"))
2

So kann man auch gut sehen, was "... werten zu sich selbst aus ..." bedeutet.

Das folgende Beispiel zeigt nun, wie man die REPL als read-string eval println implementiert.

Hinweis: die Eingabe zum Compiler (eval) ist eine Datenstruktur (vgl. oben "code is data is code"), die man auch selber/programmatisch konstruieren kann.

REPL:

user=> (inc 2)
3
user=> (read-string "(inc 2)")
(inc 2)
user=> (type (read-string "(inc 2)"))
clojure.lang.PersistentList
user=> (eval (read-string "(inc 2)"))
3
user=> (eval (list inc 2))
3
user=> (eval '(inc 2))
3
user=> (-> "(inc 2)" read-string eval println)
3
nil

Die Verfügbarkeit von read und eval erlaubt es uns, Daten/Werte (Konfiguration?) aber auch ausführbare Dinge (Programmlogik) als Clojure-Quelltext irgendwoher zu lesen (z.B. aus einer Datei, einer URL oder einer Datenbank) und diesen dynamisch in unser Programm mit einzubinden (WARNUNG: das ist ein Sicherheitsrisiko. Es darf nur aus vertrauenswürdigen Quellen gelesen werden).

Es bedeutet auch, dass wir uns nie wieder selber eine Mikro-Sprache (DSL [5]) ausdenken müssen und einen zugehörigen Interpreter selber schreiben müssen, um die Anforderung nach dieser Form von Dynamik umzusetzen (vgl. Greenspun's tenth rule [6]).

Falls man nun gar nicht mit einer DSL in Clojure-Syntax arbeiten mag, kann man natürlich auch eine eigene Syntax erschaffen und dann trotzdem den Clojure-Compiler verwenden. Das erspart einem zumindest das Schreiben eines Interpreters.

[1] https://docs.oracle.com/cd/E19798-01/821-1841/bnahv/index.html
[2] https://clojure.org/reference/java_interop
[3] https://clojuredocs.org/clojure.core/def
[4] https://clojure.org/reference/vars
[5] https://de.wikipedia.org/wiki/Dom%C3%A4nenspezifische_Sprache
[6] https://en.wikipedia.org/wiki/Greenspun%27s_tenth_rule
[7] https://github.com/henrik42/extended-lisp-reader


(def hr "\n------------------------------------\n")

Der Wert(!!!), der an den Namen first gebunden ist, wird an den Namen farbe gebunden.

first ist ein Name aus dem Namensraum clojure.core. first ist eine Funktion die das erste Element eine Liste liefert.

Genauer: der Name clojure.core/first ist an eine Funktion d.h. ein Funktionsobjekt gebunden (Funktor; vgl. oben). Das ist aber zu länglich zu schreiben, daher verwendet man einfach beim Sprechen über den Code den "Namen für den Wert" (also first; den Benenner) anstatt den Wert explizit zu benennen (Benanntes), falls keine Gefahr von Unklarheit/Missverständnis besteht.

Dieses Funktionsobjekt und die zugehörige Klasse entsteht durch die Kompilierung von S-Expressions. Clojure hat keinen Interpreter sondern einen Compiler [1], der zur Laufzeit läuft; also just in time. Es gibt aber auch ahead of time (AOT), wie bei Java, um die Startup-Zeit von Clojure-Programmen zu verkürzen.

REPL:

hearts.core=> first
#object[clojure.core$first__4339 0x78997c33 "clojure.core$first__4339@78997c33"]
hearts.core=> (type first)
clojure.core$first__4339

Wir führen hier also einfach nur einen Alias für eine Funktion ein, denn wir binden ja einen zweiten Namen an denselben(!) Wert.

farbe ist Teil unser Domänensprache (vgl. oben), anders als first, was eher ein technischer/generischer Name ist. first hat in unserer Domäne keine besondere Bedeutung. Das Gleiche machen wir auch für bild mit der Funktion clojure.core/second.

Mit diesen Namen bzw. den an sie gebundenen Funktionen werden wir später auf unsere Daten, d.h. auf die Farben und die Bilder unserer Spielkarten, zugreifen.

REPL:

hearts.core=> farbe
#object[clojure.core$first__4339 0x78997c33 "clojure.core$first__4339@78997c33"]
hearts.core=> first
#object[clojure.core$first__4339 0x78997c33 "clojure.core$first__4339@78997c33"]
hearts.core=> (identical? farbe first)
true

Mit first referenzieren wir clojure.core/first ohne den Namensraum clojure.core explizit angeben zu müssen. Das liegt daran, dass der Namenraum clojure.core via ns "importiert" [2] wurde und damit zur Verfügung steht (mehr sagen wir hier nicht zu Namensräumen).

[1] https://clojure.org/reference/compilation
[2] https://8thlight.com/blog/colin-jones/2010/12/05/clojure-libs-and-namespaces-require-use-import-and-ns.html


(def farbe first)
(def bild second)

Wir binden einen Vektor [8] mit den Vornamen unserer Spieler an den Namen spieler. Die Reihenfolge der Elemente im Vektor soll beschreiben, in welcher Reihenfolge unsere Spieler an dem gedachten, runden Tisch sitzen. Dabei ist unerheblich, welcher Spieler an welcher Position im Vektor steht. Wie schon gesagt: es ist ein gedachter Kreis.

In Clojure können Vektoren, Listen, Maps, Sets und Reguläre Ausdrücke direkt als Literal [2] aufgeschrieben werden (und man kann weitere eigene Literaltypen definieren -- sog. tagged literals [1]). Die Elemente dieser Collections [4] brauchen nicht vom gleichen Typ zu sein und die ganzen Datenstrukturen sind immutable (in Clojure-Sprech persistent data structures [3]). D.h. wir können ihren Wert(!!) d.h. Inhalt/Zustand nicht ändern. Daher können wir sie auch ohne Gefahr mit anderen teilen, weil sie niemand hinterrücks ändern kann. Man braucht also keine "Clone" oder "defensive Kopien" [5] und keine Synchronisation um Race-Conditions [6] zu unterbinden.

Für die Vornamen unserer Spieler verwenden wir Clojure Keywords [7]. Diese sind natürlich ebenfalls immutable, genau wie die Java Datentypen in java.lang! Keywords verhalten sich so ähnlich wie Java Enums. Sie sind z.B. "identisch" und nicht nur "gleich". Man kann sie jedoch nirgends vorab definieren --- es gibt keine "Klammer" wie die Enum Klassen in Java. Daher kann man sie auch nicht enumerieren/aufzählen. Man schreibt sie einfach hin und kann sich dabei auch verschreiben, ohne dass es der Compiler merkt ....

REPL:

hearts.core=> :foo
:foo
hearts.core=> (type :foo)
clojure.lang.Keyword
hearts.core=> (identical? :foo (keyword "foo"))
true
hearts.core=> spieler
[:gabi :peter :paul :sonja]
hearts.core=> (type spieler)
clojure.lang.PersistentVector

[1] https://clojure.org/reference/reader#tagged_literals
[2] https://clojure.org/reference/reader#_literals
[3] https://en.wikipedia.org/wiki/Persistent_data_structure#Clojure
[4] https://clojure.org/reference/data_structures#Collections
[5] http://www.javapractices.com/topic/TopicAction.do?Id=15
[6] https://www.baeldung.com/java-synchronized
[7] https://clojure.org/reference/data_structures#Keywords
[8] https://clojure.org/reference/data_structures#Vectors


(def spieler [:gabi :peter :paul :sonja])

bilder ist eine Liste [1, 2] mit den Elementen (Zahlen) 2 bis 10 und den Elementen (Keywords) :bube, :dame, :koenig und :ass.

Tatsächlich ist bilder eine Sequenz [3], aber dazu später mehr; man kann sich erstmal eine Liste vorstellen.

REPL:

hearts.core=> bilder
(2 3 4 5 6 7 8 9 10 :bube :dame :koenig :ass)
hearts.core=> (type bilder)
clojure.lang.LazySeq
hearts.core=> (list? bilder)
false
hearts.core=> (seq? bilder)
true

(range x y) liefert eine Liste mit den Zahl-Elementen x bis y-1 und concat verbindet die beiden Listen zu einer Liste.

Die Reihenfolge bzw. die Position der Bilder in der Liste bestimmt später, wenn es darum geht, wer einen Stich bekommt, welche Karte einen "höheren Rang hat" als die andere.

REPL:

hearts.core=> (range 3 5)
(3 4)

[1] https://clojure.org/guides/learn/sequential_colls#_lists
[2] https://clojure.org/reference/data_structures#Lists
[3] https://clojure.org/reference/sequences


(def bilder (concat (range 2 11) [:bube :dame :koenig :ass]))

Anstatt später immer wieder die "Position" einer Karte in bilder zu ermitteln (also ihren Rang), erzeugen wir einmalig eine Abbildung bild->index (eine java.util.Map) von den Bildern auf die jeweilige Position des Bilds in bilder.

REPL:

hearts.core=> (doc instance?)
-------------------------
clojure.core/instance?
([c x])
  Evaluates x and tests if it is an instance of the class
	c. Returns true or false
nil
hearts.core=> (instance? java.util.Map bild->index)
true

Der Name bild->index ist beliebig gewählt, das -> in dem Namen hat hier keine besondere Bedeutung in Clojure.

In dieser S-Expression werden eine Reihe weiterer Funktionen verwendet:

  • ->> (sog. thread last) [1] nimmt das erste Element (hier bilder) und fügt es an die letzte (daher last; es gibt auch thread first ->) Argumentposition des zweiten Elements. Dadurch entsteht in diesem Fall (map-indexed #(-> [%2 %1]) bilder) und dann diesen Ausdruck/Form wieder an die letzte Argumentposition des dritten Elements. Somit ergibt sich schließlich:

    (into {} (map-indexed #(-> [%2 %1]) bilder))

    Der Code wird also umgestellt, bevor er überhaupt compiliert/ausgeführt/ausgewertet wird.

    ->> arbeitet/wirkt auf den Programmcode, nicht aber auf den Programmtext, sondern auf den geparsten Programmtext, der als Datenstruktur/AST [3] vorliegt und formt diese Datenstruktur um (also den AST, der als Listen-Datenstruktur vorliegt). Das ist so ähnlich wie "Codegenerierung zur Laufzeit".

    Das nennt man Meta-Programming. ->>ist auch keine Funktion, sondern ein Makro [2]. Makros sind "fast" normale Clojure Funktionen, die nur eben von Clojure in die Compilephase eingebunden werden und somit das zu kompilierende Programm beliebig umstellen können.

    Das ist auch ein Grund dafür, dass man für Clojure keine "vorgeschalteten Codegeneratoren" braucht. Man programmiert sich seine "Codegenerierung" einfach mit Markos selber [5, 6]. Und zwar in der Programmiersprache, mit der man sowieso schon unterwegs ist. Keine Template-Sprache oder irgendein anderer "Medienbruch". Makros sind also sowas wie eine interne DSL [4] für Codegenerierung.

    ->> erlaubt es, den Code in der Reihenfolge hinzuschreiben, in der er ausgeführt wird: die geschachtelten Funktionsaufrufe werden ja von "innen nach außen" ausgeführt/ausgewertet. Die Verwendung von ->> macht es dem Menschen leichter zu verstehen, was geschieht. Die Semantik wird nicht verändert -- es handelt sich wirklich nur um eine Umstellung des Codes, bevor er an den Compiler gegeben wird.

    Das folgende Beispiel zeigt, wie man sich ausgeben lassen kann, zu welchem "Zielausdruck" ein Makro bzw. eine Makro-Expansion (also die Anwendung des Makros auf den Programmcode) führt. Das kann man auch brauchen, wenn man selber mal ein Makro schreibt.

    REPL:

      hearts.core=> (macroexpand '(->> bilder (map-indexed #(-> [%2 %1])) (into {})))
      (into {} (map-indexed (fn* [p1__1436# p2__1435#] (-> [p2__1435# p1__1436#])) bilder))
      hearts.core=> (clojure.walk/macroexpand-all '(->> bilder (map-indexed #(-> [%2 %1])) (into {})))
      (into {} (map-indexed (fn* [p1__1432# p2__1431#] [p2__1431# p1__1432#]) bilder))
    
  • Mit #(....) wird eine Funktion definiert. Man sagt auch "anonyme Funktion", weil sie an keinen Namen gebunden wird. Die Benennung "anonym" ist aber irreführend, weil Funktionen niemals einen Namen haben und in dem Sinne immer anonym sind. Durch def gibt man eben einer Funktion auch keinen Namen sondern bindet die Funktion an einen Namen und man kann sie eben auch an mehrere Namen binden (vgl. oben). In diesem Sinne besitzt eine Funktion an sich keinen Namen.

    REPL:

      hearts.core=> #(str % "-" %)
      #object[hearts.core$eval1622$fn__1623 0x54ef29c7 "hearts.core$eval1622$fn__1623@54ef29c7"]
      hearts.core=> (#(str %1 "-" %2) "foo" :bar)
      "foo-:bar"
    

    #(-> [%2 %1]) ist eine Funktion mit zwei Parametern, die einen Vektor liefert, dessen erstes Element das zweite Argument der Funktion ist und dessen zweites Element das erste Argument der Funktion ist.

    In Clojure gibt es kein return-Statement. Stattdessen liefert eine Funktion immer den Auswertungswert der "letzten Form". Welche das genau ist, besprechen wir im Code.

    -> ist das thread first Makro, das ich hier verwende, weil der Ausdruck #([%2 %1]) von Clojure so interpretiert wird, als wenn der Vektor eine Funktion (Funktor) wäre (vgl. folgende REPL). -> tut uns den Gefallen, den Code so umzuformen, dass der Vektor als Rückgabewert der anonymen Funktion gilt.

    REPL:

      hearts.core=> (clojure.walk/macroexpand-all '#([%2 %1]))
      (fn* [p1__1444# p2__1443#] ([p2__1443# p1__1444#]))
      hearts.core=> (clojure.walk/macroexpand-all '#(-> [%2 %1]))
      (fn* [p1__1448# p2__1447#] [p2__1447# p1__1448#])
    
  • map-indexed ist eine HOF (higher order function) [7]: sie erwartet als erstes Argument eine Funktion und als zweites eine Sequenz. Sie wendet dann die Funktion der Reihe nach auf die Elemente der Sequenz an.

    Dabei ruft sie die übergebene Funktion mit jeweils zwei Argumenten auf: das erste Argument ist der 0-basierte Index des Elements der Sequenz, das gerade verarbeitet wird und dieser Index ist genau das, was wir als "Position" benötigen (vgl. oben).

    Das zweite Argument ist das Element selbst.

    Das Ergebnis von map-indexed ist wiederum die Sequenz aus den Funktions-Aufruf-Ergebnissen.

    Beispiel: (map-indexed str [1 2 3]) liefert ("01" "12" "23") (str liefert den String/Konkatenation der Argumente). Wir verwenden aber #(-> [%2 %1]) und erzeugen somit für jedes Bild (d.h. Element von bilder) ein 2-Tupel [Bild, Position].

  • (into {} .....) sorgt dafür, dass die Elemente des zweiten Arguments in die Datenstruktur (erstes Argument) zugefügt werden.

    Natürlich wird dabei nichts verändert, sondern es wird ein neuer Wert des gleichen Typen erzeugt und diese neue Struktur ist dann das Ergebnis von into.

    Bei {} handelt es sich um eine Map. Und wenn man einer Map 2-elementige Vektoren (2-Tupel) zufügt, dann wird das jeweils erste Element des 2-Tupels als Schlüssel (Key) und das jeweils zweite Element als Wert (Value) des Map-Entries verwendet.

    Somit fügt into alle [Bild, Position]-Tupel als <Key,Value> in die leere Map und liefert das Ergebnis als Rückgabewert.

    REPL:

      hearts.core=> (into {} [[:a "A"] [:b "B"]])
      {:a "A", :b "B"}
      hearts.core=> bild->index
      {7 5, 4 2, :koenig 11, 6 4, 3 1, :dame 10, 2 0, :ass 12, 9 7, 5 3, 10 8, :bube 9, 8 6}
    

Die ganze Verarbeitung nimmt also bilder, macht eine Folge (in Clojure-Sprech Sequence) von 2-Tupeln [Bild, Bild-Position] daraus und fügt diese als <Key,Value> in eine Map, die wir an den Namen bild->index binden.

Wie man auf diese Map zugreift, sehen wir weiter unten.

[1] https://clojure.org/guides/threading_macros
[2] http://clojure-doc.org/articles/language/macros.html
[3] https://de.wikipedia.org/wiki/Syntaxbaum#Abstrakte_Syntaxb%C3%A4ume
[4] https://de.wikipedia.org/wiki/Dom%C3%A4nenspezifische_Sprache#Interne_bzw._eingebettete_DSLs_(internal_DSL)
[5] https://stackoverflow.com/a/1628255/10546451
[6] https://clojureverse.org/t/why-is-the-macro-systems-in-lisps-considered-so-valuable/2622
[7] https://de.wikipedia.org/wiki/Funktion_h%C3%B6herer_Ordnung


(def bild->index (->> bilder
                      (map-indexed #(-> [%2 %1]))
                      (into {})))

karten->punkte soll eine Abbildung (Map) von den Karten auf die Punkte einer jeden Karte sein. Die Karten ergeben sich als Kartesisches Produkt [6] über die Farben :kreuz, :pik, :herz und :karo, die wiederum als Keywords dargestellt werden, und den Bildern (bilder).

Die Karten sind also einfach nur 2-Tupel/Vektoren <Farbe,Bild>. Wir haben keinen Datentyp definiert. In Java hätte man wohl eine Klasse eingeführt. Wir haben auch nicht irgendwie anders ausgedrückt, dass das erste Element des 2-Tupels die Farbe der Karte ist und dass das zweite Element das Bild der Karte ist. Die Bedeutung ergibt sich also nur "durch den Code" bzw. die Verwendung der 2-Tupel.

Wir haben oben aber die Aliase farbe und bild eingeführt, um diese Namen für den Zugriff auf die entsprechenden Vektor-Elemente zu verwenden. Dadurch erhöhen wir die Lesbarkeit und Verständlichkeit des Codes. Sie sind Teil unserer ubiquitous language. Wir hätten stattdesse ja auch einfach direkt first und second verwenden können.

Zu jeder Karte wird dann noch ihr Punktwert ermittelt und damit die gewünschte Map erzeugt.

  • Das Makro for liefert für jede Kombination (Farben x Bilder) ihrer Argumente das angegebene Ergebnis [f b] (also das 2-Tupel) als Sequenz. Der Mechanismus wird list comprehension (etwa "Listenerzeugung" [1]) genannt.

    In vorliegenden Fall sind dies die Elemente des Farb-Vektors und der Bilder, die der Reihe nach --- also "für jeden Durchlauf" --- an die Namen f und b gebunden werden.

    REPL:

      hearts.core=> (for [f [:kreuz :pik :herz :karo]
           #_=>           b bilder]
           #_=>        [f b])
           ([:kreuz 2] [:kreuz 3] [:kreuz 4] [:kreuz 5] [:kreuz 6] [:kreuz 7] [:kreuz 8] [:kreuz 9] [:kreuz 10] [:kreuz :bube] [:kreuz :dame] [:kreuz :koenig] [:kreuz :ass] [:pik 2] [:pik 3] [:pik 4] [:pik 5] [:pik 6] [:pik 7] [:pik 8] [:pik 9] [:pik 10] [:pik :bube] [:pik :dame] [:pik :koenig] [:pik :ass] [:herz 2] [:herz 3] [:herz 4] [:herz 5] [:herz 6] [:herz 7] [:herz 8] [:herz 9] [:herz 10] [:herz :bube] [:herz :dame] [:herz :koenig] [:herz :ass] [:karo 2] [:karo 3] [:karo 4] [:karo 5] [:karo 6] [:karo 7] [:karo 8] [:karo 9] [:karo 10] [:karo :bube] [:karo :dame] [:karo :koenig] [:karo :ass])
    
  • Diese Sequenz von <Farbe,Bild> "fädeln" wir nun via ->> durch die HOF map, die als erstes Argument eine Arity-1-Funktion erwartet, die sie der Reihe nach auf die Elemente des zweiten Arguments anwendet und als Ergebnis wiederum eine Sequenz mit den Funktionswerten liefert.

REPL:

hearts.core=> (doc inc)
-------------------------
clojure.core/inc
([x])
  Returns a number one greater than num. Does not auto-promote
  longs, will throw on overflow. See also: inc'
nil
hearts.core=> (map inc [1 21 42])
(2 22 43)
  • Wir definieren hier (anonym; also ohne sie an einen Namen zu binden) mit fn die benötigte Arity-1-Funktion. fn ist der kanonische Weg, eine Funktion zu definieren.

    Die Alternative #(....), die oben vorgestellt wurde, ist eine Kurzschreibweise (sog. "reader macro"), in der sich die Arity aus der Nennung/Verwendung der "durchnummerierten Parameter-Formen" %<i> ergibt. Bei fn müssen die (formalen) Parameter hingegen explizit angegeben werden.

    Wir geben als Parameter aber anstatt eines Namen einen Vektor mit zwei Elementen/Namen f und b an. Dieser Vektor wird von Clojure als Muster (pattern) verwendet. Beim Funktionsaufruf wird das Argument (also z.B. ein Vektor oder eine Liste) anhand des Musters zerlegt und die Parameternamen f und b werden in diesem Fall an das erste bzw. zweite Element des Arguments gebunden. Dieser Mechanismus wird destructuring [2] (oder allgemeiner pattern matching) genannt. Man könnte es auch "muster-basierte rekursive Strukturzerlegung mit Namensbindung" nennen.

    Mit einem Vektor-Pattern kann man sequentielle Argumente zerlegen (also Listen, Vektoren, Strings, Maps) und mit einem Map-Pattern (kommt weiter unten noch) kann man assioziative Dinge zerlegen (Maps). Destructuring erspart einem also, den Zugriff auf die Elemente explizit über Funktionsaufrufe (z.B. first und second) hinschreiben zu müssen.

    Unsere Arity-1-Funktion liefert als Ergebnis wieder ein 2-Tupel/Vektor, dessen erstes Element die Karte <Farbe,Bild> ist und deren zweites Element der Punkte-Wert der Karte ist.

  • cond verhält sich fast wie die neue switch-Expression (NICHT switch-Statement!) in Java 12 [3]. Die Argument-Paare aus Prädikat [7] (Form) und Ergebnis (Form) werden der Reihe nach ausgewertet. Dabei wird immer nur die Prädikats-Form ausgewertet! Das ist wichtig, weil die Auswertung der Ergebnis-Form ja Seiteneffekte haben könnte! Sobald die ausgewertete Prädikats-Form wahrhaftig (truthy bzw. logisch wahr; vgl. unten) ist/liefert, wird die zugehörige Ergebnis-Form ausgewertet und als Ergebnis von cond geliefert.

    In Clojure gelten nur nil (das ist dasselbe wie Java null) und false (ist desselbe wie Java java.lang.Boolean/FALSE) als unwahr. Alle anderen Werte (inkl. (new java.lang.Boolean false) gelten als wahr. Um logisch wahr von true zu unterscheiden (denn sie sind nicht dasselbe), sagt man i.d.R. truthy [4], wenn man "logisch wahr" meint und falsy, wenn man "logisch unwahr" meint.

    Mit or und and kann man Wahrheitswerte verknüpfen. Dabei liefern diese nicht true und false sondern konsequenterweise truthy und falsy Werte.

    REPL:

      hearts.core=> (and :foo nil :bar)
      nil
      hearts.core=> (or :foo nil :bar)
      :foo
      hearts.core=> (or false :bar :fred)
      :bar
      hearts.core=> (and 2 3 4)
      4
      hearts.core=> (not (and 2 3 4))
      false
    
  • Schließlich verwenden wir wieder into, um die Sequenz von <<Farbe,Bild>,Punkte> Tupeln in eine Map <Farbe,Bild>--><Punkte> zu überführen.

    REPL:

      hearts.core=> (def xs [[:foo "FOO"] [:bar "BAR"] [:foo "FRED"]])
      #'hearts.core/xs
      hearts.core=> (into [] xs)
      [[:foo "FOO"] [:bar "BAR"] [:foo "FRED"]]
      hearts.core=> (into {} xs)
      {:foo "FRED", :bar "BAR"}
      hearts.core=> (into '() xs)
      ([:foo "FRED"] [:bar "BAR"] [:foo "FOO"])
      hearts.core=> (into #{} xs)
      #{[:foo "FOO"] [:foo "FRED"] [:bar "BAR"]}
    

Einschub: Sequenzen und Listen

Oben haben wir mit for eine Sequenz erzeugt, obwohl der Mechanismus list comprehension heißt ....

Eine Liste ist eine endliche Datenstruktur. D.h. eine Liste hat immer eine bekannte Anzahl von Elementen. Da Listen vom Compiler als Funktionsaufrufe, d.h. als auszuwertende Form verstanden werden, muss man Listen-Formen quoten (quote) und damit ausdrücken, dass man diese Liste als Datenstruktur meint. Das einfache Anführungszeichen (') ist die Kurzform von quote. Mit list kann man Listen auch aus den Elementen konstruieren.

REPL:

hearts.core=> (str :f \r 3 \d)
":fr3d"
hearts.core=> (quote (str :f \r 3 \d))
(str :f \r 3 \d)
hearts.core=> '(str :f \r 3 \d)
(str :f \r 3 \d)
hearts.core=> (str :f \r (inc 2) "d")
":fr3d"
hearts.core=> '(str :f \r (inc 2) "d")
(str :f \r (inc 2) "d")
hearts.core=> (list :f \r (inc 2) \d)
(:f \r 3 \d)

Eine Sequence entspricht eher einer Folge von Berechnungsergebnissen, so wie die Erzeugung des Kartesischen Produkts via for. Diese Folge/Sequenz kann abhängig vom Berechnungsprozess endlich oder "unendlich" sein.

Natürlich ist nichts in einem Computer wirklich unendlich. Aber mit Sequenzen kann man nicht endende Berechnungsprozesse so weit "treiben", wie man möchte. In diesem Sinne sind sie beliebig lang.

So berechnet z.B. (range) alle natürlichen Zahlen. Man sollte diese Form nicht in die REPL eingeben, denn die REPL würde versuchen, den Wert dieses nicht endenden Berechnungsprozesses zu ermitteln, um ihn dann auszugeben und das geht eben nicht.

Sequenzen werden lazy [5] berechnet. D.h. der Berechnungsprozess wird gerade so weit getrieben (und manchmal etwas weiter....), wie es nötig ist, um die geforderte Anzahl von Sequenzelementen zu liefern. D.h. man kann eine unendliche Sequenz erzeugen, man kann jedoch immer nur endlich, aber beliebig viele dieser Elemente "konsumieren".

In dem folgenden Beispiel ist wichtig, dass die REPL den Aufruf von (range) durchführt und dieser Aufruf liefert eine lazy sequence. Die REPL versucht aber nicht, die Elemente dieser Sequenz zu lesen/konsumieren. def bindet den Wert (also die lazy sequence) an den Namen foo. Das Ergebnis von def ist die Variable (var; darauf will ich hier aber nicht weiter eingehen) mit dem Namen hearts.core/foo. Die REPL gibt also wie immer den Wert der ausgewerteten Form aus und die ist in diesem Fall die def Form.

REPL:

hearts.core=> (def foo (range))
#'hearts.core/foo
hearts.core=> (take 4 foo)
(0 1 2 3)
hearts.core=> (take 4 foo)
(0 1 2 3)
hearts.core=> (take 2 foo)
(0 1)
hearts.core=> (take 5 foo)
(0 1 2 3 4)

Listen und Sequenzen werden beide auf die gleiche Weise in der REPL ausgegeben. Das macht auch Sinn: ausgeben kann man ja nur den endlichen Wert, der den ersten n Elementen einer möglicherweise unendlichen Sequenz entspricht. Und dieser Wert ist (d.h. verhält sich wie) eine Liste.

Das folgende Beispiel zeigt, dass man bei der Umwandlung von Sequenzen in Strings sorgsam sein muss.

hearts.core=> (->> (range) (take 3))
(0 1 2)
hearts.core=> (->> (range) (take 3) type)
clojure.lang.LazySeq
hearts.core=> (->> (range) (take 3) str)
"clojure.lang.LazySeq@7480"
hearts.core=> (->> (range) (take 3) seq str)
"(0 1 2)"
hearts.core=> (->> (range) (take 3) pr-str)
"(0 1 2)"

Und wenn du absichtlich einen Seiteneffekt in den Berechnungsprozess einbaust (hier das print), kann du auch erkennen, in welchen Situationen welche Dinge passieren. Wann also der Berechnungsprozess wirklich abläuft.

REPL:

hearts.core=> (def bar (for [x (range)] (do (print (format "[%s]" x)) x)))
#'hearts.core/bar
hearts.core=> (take 2 bar)
(0 1)[0][1]
hearts.core=> (take 2 bar)
(0 1)
hearts.core=> (take 3 bar)
(0 1 2)[2]
hearts.core=> (take 3 bar)
(0 1 2)
hearts.core=> (take 10 bar)
(0 1 2 3 4 5 6 7 8 9)[3][4][5][6][7][8][9]

[1] https://de.wikipedia.org/wiki/List_Comprehension
[2] https://clojure.org/guides/destructuring
[3] https://openjdk.java.net/jeps/325
[4] https://clojure.org/guides/learn/flow#_truth
[5] http://clojure-doc.org/articles/language/laziness.html
[6] https://de.wikipedia.org/wiki/Kartesisches_Produkt
[7] https://de.wikipedia.org/wiki/Pr%C3%A4dikat_(Logik)


(def karten->punkte
  (->> (for [f [:kreuz :pik :herz :karo]
             b bilder]
         [f b])
       (map
        (fn [[f b]]
          [[f b] (cond
                   (= [f b] [:pik :dame]) 13
                   (= f :herz) 1
                   :else 0)]))
       (into {})))

Bis jetzt haben wir mit Ausnahme der beiden Aliase nur "Daten" (d.h. Werte) definiert und sie an Namen gebunden. Der Code, d.h. die S-Expressions, wird beim Laden des Namensraum hearts.core ausgewertet und führt eben dazu, dass die Werte an die Namen gebunden werden. Dieser Vorgang findet nur einmalig statt. Also immer nur das eine Mal, wenn der Namensraum geladen wird. Man kann aber "erzwingen", dass ein Namensraum wiederholt/mehrfach geladen wird. Dann werden auch die S-Expressions wiederholt ausgewertet und die Namen werden erneut an neue Werte gebunden.

Nun wollen wir aber mit defn eine Funktion definieren. (defn <name> [<parameter>...] <body>) ist einfach eine Kurzform von (def <name> (fn [<parameter>...] <body>)).

Die Funktionsdefinition mit fn haben wir schon oben gesehen. Hier binden wir diese Funktion aber an einen Namen, so dass wir später über diesen Namen auf die Funktion zugreifen können.


Die Funktion beginnt ermittelt, welcher Spieler die erste Runde beginnt. Dies ist jener Spieler mit der Kreuz-2. beginnt liefert diesen Spieler als Keyword (also z.B: :gabi).

Das Argument ist eine Map. Diese nenne ich Alle-Spieler-Map. Die Alle-Spieler-Map bildet <spieler> wiederum auf eine Map ab. Diese nenne ich Ein-Spieler-Map. Die Ein-Spieler-Map hat u.a. den Key :hand und der gemappte Wert von :hand ist eine Menge (Set; Hand-Menge). Die Elemente der Hand-Menge sind <Farbe,Bild>-Tupel/Vektoren. Diese könnten wir Karte-Vektor nennen.

Die Alle-Spieler-Map soll als Datenstruktur(-Wert) den Zustand aller Spieler während des Spiels repräsentieren. Es fehlen noch einige Dinge , wie z.B. die Stiche, die die Spieler gemacht haben. Diese Dinge werden aber noch eingeführt (vgl. unten).

Zu Beginn des Spiels werden alle Karten gemischt und auf die Spieler verteilt. beginnt wird also einmalig nach dem Verteilen der Karten mit diesem Start-Zustand der Alle-Spieler-Map aufgerufen, um zu ermitteln, wer die erste Runde beginnt.

In Java würde man wohl für Karte-Vektor, Ein-Spieler-Map und Alle-Spieler-Map separate Klassen einführen, mit Konstruktor, lesenden und vielleicht schreibenden/mutierenden Methoden (Getter/Setter) und dann z.B. auch die Methode beginnt in die Alle-Spieler-Klasse tun.

In Clojure kann man auch "Klassen" definieren, d.h. man definiert "Kontrakte" (sog. Protokolle) so wie man Interfaces in Java definiert. Und man definiert dann Implementierungen zu diesen Protokollen. Aber man verwendet keine Ableitung. Man kann aber Java-Klassen ableiten und auch Java Interfaces implementieren; sog. "Java interop".

Es gibt einen endlosen Streit darüber, ob nun OOP oder Funktionale Sprachen der bessere Weg sind. Hier ein paar Sachen zum Lesen [1, 2, 3]. Und mehr will ich hier auch nicht dazu sagen.

In der Implementation, die ich hier vorstelle, habe ich bewusst auf diese Konstrukte verzichtet, weil ich zeigen wollte, wie man in Clojure mit den "eingebauten Datentypen" arbeiten kann. In Clojure ist dies in vielen Situationen "idiomatisch", also ein akzeptiertes und erwartetes Vorgehen. Das heißt aber nicht, dass es falsch wäre, Protokolle einzuführen und diese zu implementieren. Ganz im Gegenteil: durch eine explizite "Typisierung" kann man viel "Information transportieren" und damit trägt sie zur Verständlichkeit, Wartbarkeit und Fehlerminimierung/Qualität bei.

Nun aber zur Umsetzung von beginnt:

  • ->> fädelt den Argument-Wert (Alle-Spieler-Map) durch die HOF keep, die aus der Map eine Sequenz von Map-Entries macht. Wenn eine Map auf diese Weise "sequenziallisiert" wird, dann besteht die entstehende Sequenz aus <Key,Value> der Map-Entries. Hier also <Spieler,Ein-Spieler-Map>.

    Diese Sequenzialisierung wird von allen Funktionen vorgenommen, die auf Sequenzen arbeiten. Man braucht ihnen also selber gar keine Sequenz direkt zu übergeben, sondern falls nötig machen sie sich diese aus dem Wert, den man als Argument übergibt.

    Das folgende Beispiel demonstriert dies anhand von map, Listen und Maps. Die Sequenzialisierung erfolgt immer über seq.

    REPL:

      hearts.core=> (map inc '(1 2 3))
      (2 3 4)
      hearts.core=> (map inc [1 2 3])
      (2 3 4)
      hearts.core=> (seq [1 2 3])
      (1 2 3)
      hearts.core=> (seq {:foo "FOO" :bar "BAR"})
      ([:foo "FOO"] [:bar "BAR"])
      hearts.core=> (map second {:foo "FOO" :bar "BAR"})
      ("FOO" "BAR")
    
  • keep wendet eine Funktion (erstes Argument) auf die Elemente des zweiten Arguments (also der <Spieler,Ein-Spieler-Map>-Sequenz) an und liefert all jene Funktions-Rückgabe-Werte/Ergebnisse, die non-nil sind. keep ist sowas ähnliches wie ein Filter, nur dass es nicht die Eingabelemente liefert, sondern deren non-nil "abgebildeten" Wert.

    REPL:

      hearts.core=> (filter #(and % (format "[%s]" %)) [:foo true false nil])
      (:foo true)
      hearts.core=> (keep #(and % (format "[%s]" %)) [:foo true false nil])
      ("[:foo]" "[true]" false)
    
  • Für die Definition der Parameter unserer "Abbildungsfunktion" (fn) verwenden wir diesmal ein komplexeres (geschachteltes) "Muster": wir benutzen ein Vektor-Destructuring, um jeweils den <Spieler> des 2-Tupels an s zu binden. Für das zweite Element des 2-Tupels (die <Ein-Spieler-Map>) benutzen wir ein Map-Destructuring und binden h an den Wert des Ein-Spieler-Map-Eintrags mit dem Schlüssel :hand (also die Hand-Menge).

  • when funktioniert wie ein if ohne else-Zweig. Der else-Zweig ist dann automatisch immer der Wert nil. Das muss man erstmal verdauen: in Java ist if ein (imperatives) Statement, in Clojure ist es eine (funktionale) Expression, die einen (Rückgabe-)Wert hat.

    when wertet das erste Argument aus und falls es truthy ist, wird das zweite Argument ausgewertet und dieser Wert als Ergebnis geliefert.

    Das when-else-Fall-nil bewirkt zusammen mit keep, dass nur jene Funktionsergebniswerte durch keep geliefert werden, für die der when-Ausdruck non-nil ist.

    In bestimmten Situationen kann man anstatt eines when auch and verwenden. Zu and kommen wir noch. Hier nur ein Beispiel:

    REPL:

      hearts.core=> (when (#{[:kreuz 2]} [:kreuz 2]) "jupp")
      "jupp"
      hearts.core=> (when (#{[:kreuz 2]} [:kreuz 3]) "jupp")
      nil
      hearts.core=> (and (#{[:kreuz 2]} [:kreuz 3]) "jupp")
      nil
      hearts.core=> (and (#{[:kreuz 2]} [:kreuz 2]) "jupp")
      "jupp"
    
  • h wird an die Hand-Menge gebunden (vgl. oben). In der Form (h [:kreuz 2]) ist diese Menge der Funktor (weil erstes Element der Liste), also muss sie etwas "ausführbares" sein --- eine Funktion!

    Diese zu Mengen zugehörige Arity-1-Lookup-Funktion bildet die Elemente der Menge wiederum auf das jeweilige Element ab (also auf "sich selbst"). Alle anderen Argumente werden auf nil abgebildet. D.h. eine Menge ist "ihre eigene Lookup-Funktion". Somit wertet (h [:kreuz 2]) zu [:kreuz 2] aus, falls [:kreuz 2] Element von h ist oder zu nil andernfalls.

    REPL:

      hearts.core=> (#{42 :foo "bar" [:kreuz 2]} 42)
      42
      hearts.core=> (#{42 :foo "bar" [:kreuz 2]} :foo)
      :foo
      hearts.core=> (#{42 :foo "bar" [:kreuz 2]} [:kreuz 2])
      [:kreuz 2]
    

    Da [:kreuz 2] truthy ist, liefert die fn die bzw. den Spieler, der die Kreuz-2 auf der Hand hat. Und da immer genau ein Spieler diese Karte auf der Hand hat, können wir aus der Sequenz via first einfach das erste (und einzige) Element als Ergebnis liefern.

Fertig.

[1] http://www.smashcompany.com/uncategorized/object-oriented-programming-is-an-expensive-disaster-which-must-end
[2] http://blog.cleancoder.com/uncle-bob/2019/08/22/WhyClojure.html
[3] https://8thlight.com/blog/myles-megyesi/2012/04/26/polymorphism-in-clojure.html


(defn beginnt [s-map]
  (->> s-map
       (keep 
        (fn [[s {h :hand}]]
          (when (h [:kreuz 2])
            s)))
       first))

Anmerkung: es fällt auf, dass Clojure-Programme wenig Verzweigungen haben. Bisher haben wir cond und when als Fallunterscheidung/Verzweigung kennengelernt. Viele Dinge, die man in Java mit if-then-else-if-else machen würde, macht man in Clojure mit Sequenzen und den Funktionen auf diesen und HOFs. Ob das "einfacher" oder "besser" ist, ist noch eine andere Frage.

Ich finde, dass Clojure-Programme sehr "kompakt" bzw. "dicht" wirken. Als Clojure-Anfänger empfand ich das schon fast als "Schmerz beim Lesen", weil die Semanik sich auf so wenig Code "verteilt", dass man extrem aufmerksam lesen muss. Es gibt wenig "Raum zwischen den wichtigen Teilen" zum Ausruhen --- es ist einfach jede Zeile wichtig.

Das führt meiner Meinung nach auch dazu, dass Clojure-Programm einfach "kurz sind". Sie haben i.d.R. weniger Zeilen als Java-Code und weniger Verzweigungen. Beides sind Faktoren, die effektiv zu einer höheren Testabdeckung (branch coverage) führen.

Und Clojure Programme bestehen meiner Meinung nach nur aus den Teilen, die ich wirklich brauche, um mein Problem zu lösen. Nicht weniger, aber eben auch nicht mehr. Das führt zu weniger Code und weniger Bugs, obwohl Clojure keine statische Typprüfung besitzt [3, 4].

Natürlich müssen auch Clojure Programme automatisch getestet werden [2]. Da man aber schon beim Entwickeln ständig in der REPL interaktive Ad-Hoc-Tests macht, sind Clojure Programm i.d.R. schon sehr gut getestet, wenn man den Code fertig hat.


sticht liefert den Spieler (Spieler-Keyword), der die Karten, die auf dem Tisch liegen bzw. ausgespielt wurden, am Ende einer Runde erhält. Der also den Stich macht.

Das Argument ist eine Sequenz. Die Elemente sind Maps mit den Keys :spieler und :karte. Diese geben an (also mappen zu Werten), wer die Karte gespielt hat (Spieler-Keyword) und welche Karte gespielt wurde (Karten-Vektor).

Die Sequenz wird per Vektor-Destrukturierung zerlegt wird. Das erste Element (man spricht häufig vom head) wird an den Namen eroeffnung gebunden. Durch die &-Notation werden die restlichen Elemente (also auch wieder eine Sequenz; man spricht häufig vom tail) an den Namen xs gebunden.

Die Reihenfolge der Element in der Sequenz gibt an, in welcher Reihenfolge die Karten ausgespielt wurden. Daher gibt das erste Element an, mit welcher Karte die betreffende Runde eröffnet wurde.

Die Implementation muss also beginnend von der Eröffnungskarte aus alle anderen Karten betrachten und entscheiden, welches die "Rang-höchste" der Karten ist, die die Farbe der Eröffnungskarte "bedient" hat und denjenigen Spieler liefern, der eben diese Karte gelegt hat.

  • das Keyword :spieler steht hier an der Funktor-Position in der S-Expression. So wie Mengen Funktionen auf ihren Elementen sind, so sind Keywords Lookup-Funktionen auf assoziativen Datenstrukturen. D.h. :spieler verhält sich wie eine Funktion, die mit dem Key :spieler auf eine Map oder eine Menge/Set zugreift und den Ergebniswert (also entweder den Map-Value oder das Set-Element) liefert.

    Hinweis: Der Vollständigkeit wegen sei erwähnt, dass Vektoren Funktionen auf ihren 0-basierten Indexen sind (vgl. unten).

    REPL:

      hearts.core=> (get {:bar "BAR" :foo "FOO"} :foo)
      "FOO"
      hearts.core=> (get {:bar "BAR" :foo "FOO"} 42 "oops")
      "oops"
      hearts.core=> (:foo {:bar "BAR" :foo "FOO"})
      "FOO"
      hearts.core=> (:fred {:bar "BAR" :foo "FOO"})
      nil
      hearts.core=> (:fred {:bar "BAR" :foo "FOO"} "not-found")
      "not-found"
      hearts.core=> ({:bar "BAR" :foo "FOO"} :bar)
      "BAR"
      hearts.core=> (#{:bar :foo} :foo)
      :foo
      hearts.core=> (:bar #{:bar :foo})
      :bar
      hearts.core=> (:foo [:foo :bar])
      nil
      hearts.core=> ([:foo :bar :fred] 2)
      :fred
      hearts.core=> ([:foo :bar :fred] 3)
      IndexOutOfBoundsException   clojure.lang.PersistentVector.arrayFor ...
    

    Mit :spieler wird also auf den gemappten Wert der folgenden reduce-Form zugegriffen und dieser als Ergebnis geliefert. Damit soll am Ende eben der gesuchte Spieler geliefert werden.

    Anmerkung: beim Lesen dieser Beschreibung merkt man, dass hier erst der Zugrff via :spieler beschrieben wird, ob wohl dieser nach der Auswertung von reduce erfolgt. Das ist ja genau der Grund, warum es das threading macro gibt. Denn das erlaubt uns ja, den Code in der Reihenfolge hinzuschreiben, in der er auch ausgeführt wird. Wie müsste man den Code umstellen, damit er etwa so aussieht?

      (... xs .... :spieler)
    
  • reduce ist eine HOF, mit der man Werte (z.B. die Element einer Sequenz) aggregieren kann. Die Aggregierungslogik steckt wiederum in einer Funktion, die man an reduce übergibt.

    reduce ruft diese Funktion (erstes Argument) für jedes Element des "sequenzialisierten" dritten Arguments (xs) einmal auf.

    Dabei ruft reduce die Funktion beim ersten Mal mit eroeffnung (dem "initialen" Aggregat) und dem ersten Element von xs auf. Anschließend ruft reduce die Funktion mit dem Rückgabe-Wert des ersten Funktionsaufrufs (also dem Ergebnis der ersten Aggregation) und dem zweiten Element von xs auf.

    Mit der Funktion können wir also einen Aggregatswert an reduce liefern, den reduce beim anschließenden Funktionsaufruf wieder an die Funktion gibt. Das ist wie Ping-Pong.

    Nach dem letzten Element von xs liefert reduce den Rückgabe-Wert des letzen Funktionsaufrufs --- also das "finale Aggregat".

    In Java gibt es mittlerweile ja auch ein reduce [1]. Früher hätte man in Java einfach eine for Schleife mit einer lokalen Variable benutzt, in der man das Aggregate hält.

  • Die Parameter der Aggregats-Funktion werden hier via Map-Destrukturierung an lokale Namen gebunden. Das erste Argument (das Aggregat) zerlegen wir und binden den Namen f1 an die Farbe des :karte Wertes und b1 an das Bild.

    Hier verwende ich noch ein weiteres Destrukturierungs-Feature: mit :as bindet man das ganze, unzerlegte Argument an einen Namen -- hier also t.

    Die Namen f2, b2 und s binden wir an die Wertes des zweiten Arguments (das jeweilige Element von xs; vgl. oben).

    Die Funktion prüft nun, ob mit s bzw. f2 die Farbe f1 bedient wurden und ob der Rang von s höher ist als der von t (für den ersten Aufruf wird t ja eroeffnung sein).

    Falls dem so ist, wird s unserer weiterer Aggregationszustand sein. Also jene Karte, die "sticht".

    Falls dem aber nicht so ist, "sticht" weiterhin die bisherige "stechende Karte" t.

Am Ende liefert reduce also "die stechende Karte" und mit :spieler liefert sticht den Spieler, der diese Karte gespielt hat.

Fertig.

Hinweis: man hätte auch ((reduce (fn....) eroeffnung) :spieler) schreiben können, also die Map als Funktor und nicht das Keyword, aber es ist idiomatisch, für diesen Use-Case das Keyword als Funktor zu verwenden. Und außerdem sehen zwei öffnende Klammern direkt hintereinander auch merkwürdig aus ;-)

[1] https://www.baeldung.com/java-stream-reduce
[2] https://dev.solita.fi/2017/04/10/making-software-testing-easier-with-clojure.html
[3] https://dev.to/danlebrero/the-broken-promise-of-static-typing
[4] https://developers.slashdot.org/story/18/01/01/0242218/which-programming-languages-are-most-prone-to-bugs


(defn sticht [[eroeffnung & xs]]
  (:spieler
   (reduce
    (fn [{[f1 b1] :karte :as t}
         {[f2 b2] :karte :as s}]
      (if (and (= f1 f2) (> (bild->index b2) (bild->index b1)))
        s
        t))
    eroeffnung
    xs)))

punkte liefert die Summe der Karten-Punkte zu einer Sequenz von Stichen. Ein Stich ist wiederum eine Sequenz von "gespielten" Karten (also eine Sequenz von Maps jeweils mit :spieler und :karte; vgl. sticht). Wir haben also zwei ineinander geschachtelte Sequenzen!

  • flatten macht aus der Sequenz von Sequenzen (von Elementen) eine Sequenz von Elementen (die äußere Sequenz wird also "ausgepackt" oder auch "flachgeklopft"). Damit erhalten wir also eine Sequenz von gespielten Karten (eine Sequenz von Maps).

  • comp ist eine Funktion, die eine Funktion erzeugt! D.h. das Ergebnis von comp ist eine Funktion oder ein Funktionsobjekt. comp ("compose") liefert eine Arity-1-Funktion, die die angegebenen Funktionen der Reihe nach auf das Argument anwenden und als Ergebnis das Funktionsergebnis der letzten Funktion liefert [2, 3, 4]. Wichtig: die Funktionen werden "von rechts nach links" angewendet.

    Beispiel: ((comp h g f) x) entspricht (h (g (f x))) oder auch (-> (f x) g h) bzw. (-> x f g h).

    Hier erzeugen wir eine Funktion, die erst via :karte die Karte aus der gespielten Karte holt und darauf dann karten->punkte anwendet.

    REPL:

      hearts.core=> (comp inc second)
      #object[clojure.core$comp$fn__4727 0x7a6fe31f "clojure.core$comp$fn__4727@7a6fe31f"]
      hearts.core=> ((comp inc second) [:foo 41])
      42
      hearts.core=> ((comp pos? inc second) [:foo 41])
      true
      hearts.core=> ((comp str pos? inc second) [:foo 41])
      "true"
    
  • Mit map bilden wir die Sequenz von gespielten Karten auf die Sequenz der zugehörigen Punkte ab.

  • apply wendet eine Funktion (erstes Argument) auf die Elemente(!) des zweiten Arguments (eine Sequence) an. Dabei stellt apply die Elemente aber an die Argumentpositionen der aufgerufenen Funktion. D.h. wir übergeben apply eine Sequenz mit n Werten/Elementen und apply ruft die Funktion mit n Argumenten auf. Das ist was komplett anderes, als die Funktion mit einem Sequenz-Argument mit n Elementen aufzurufen!

    REPL: die erste Form binden eine Liste mit Rest-Argumenten an xs. In diesem Fall haben wir nur ein Argument (den Vektor). Daher hat die Liste auch nur ein Element. Die zweite Form ruft die Funktion mit drei Argumenten auf (den drei Elementen des Vektors). Daher enthält die Rest-Argumenten-Liste in diesem Fall drei Elemente.

      hearts.core=> ((fn [& xs] xs) [:foo :bar :fred])
      ([:foo :bar :fred])
      hearts.core=> (apply (fn [& xs] xs) [:foo :bar :fred])
      (:foo :bar :fred)
    
  • + ist eine Funktion, die beliebig viele Zahl-Argumente addiert. (+) ergibt 0, (+ 4) ergibt 4 und (+ 3 2 1) ergibt 6.

Mit apply können wir also auf einen Schlag (ohne aggregierende Schleife; vgl. oben) alle gemappten Punktwerte addieren und als Ergebnis liefern. (apply + [1 2 3]) ist das gleiche wie (+ 1 2 3) ist das gleiche wie (reduce + [1 2 3]).

Fertig.

Anmerkung: Clojure-Programme haben schon dadurch weniger Fehler, dass es fast unmöglich ist, in Schleifen Off-by-One-Fehler [1] zu programmieren. Einfach weil man kaum Schleifen zu programmieren braucht und wenn, dann haben sie keine Schleifen-Varianten, gegen die man ein Abbruchkriterium prüft, sondern man arbeitet fast immer auf der Sequenz-Abstraktion und mit den API-Funktionen darauf.

[1] https://de.wikipedia.org/wiki/Off-by-one-Error
[2] https://de.wikipedia.org/wiki/Komposition_(Mathematik)
[3] https://rosettacode.org/wiki/Function_composition#Clojure
[4] https://rosettacode.org/wiki/Function_composition#Java_8


(defn punkte [stiche]
  (->> (flatten stiche)
       (map (comp karten->punkte :karte))
       (apply +)))

rang-liste soll eine nach Punkten aufsteigend geordnete Liste/Sequenz von Spielern liefern. Da es auch einen Punktegleichstand geben kann, sind es Mengen von Spielern.

rang-liste wird nur einmal pro Spiel aufgerufen, nämlich wenn nach der letzten Runde die Stiche der Spieler ausgewertet werden, um festzustellen, wer gewonnen hat.

  • s-map ist wieder eine Alle-Spieler-Map.

  • via map bilden wir die sequenzialisierte Alle-Spieler-Map (also eine Sequenz von <Spieler,Ein-Spieler-Map>-Tupel) auf eine Sequenz von Maps mit den Schlüsseln :spieler und :punkte.

    An dieser Stelle hätten wir auch einfach ein <Spieler,Punkte>-Tupel anstatt der Map nehmen können, genau so, wie wir unsere Karten ja als <Farbe,Bild>-Tupel repräsentieren, anstatt als Map mit :farbe und :bild. Das ist einfach eine Designentscheidung.

  • group-by ist eine HOF, die die Elemente der Sequenz (zweites Argument) nach den Funktionswerten "gruppiert", die sich durch Anwendung der Funktion (erstes Argument) auf die Elemente ergibt. Das Ergebnis von group-by ist eine Map <Gruppierungs-Wert-->Gruppen-Menge>.

    Hier ist :punkte die Funktion, durch die die Gruppierung nach den Punkten erfolgt.

    REPL:

      hearts.core=> (group-by even? (range 10))
      {true [0 2 4 6 8], false [1 3 5 7 9]}
    
  • via map erzeugen wir eine Sequenz von <Punkte,Spieler-Menge>-Tupel.

  • und diese sortieren wir nach dem ersten (first) Element dieser Tupel: also den Punkten.

rang-liste liefert also eine nach Punkten aufsteigend sortierte Sequenz von <Punkte,Spieler-Menge>-Tupel.

Fertig.


(defn rang-liste [s-map]
  (->> s-map
       (map
        (fn [[s {stiche :stiche}]]
          {:spieler s :punkte (punkte stiche)}))
       (group-by :punkte)
       (map (fn [[p xs]]
              [p (->> xs (map :spieler) (into #{}))]))
       (sort-by first)))

gewinnt liefert zu der Alle-Spieler-Map den bzw. die Gewinner. Hier wird die rang-liste Funktion aufgerufen und dann auf das erste Element der Sequenz (also jene Gruppe mit den niedrigsten Punkten) und dann auf das zweite Element (also die Spieler-Menge) zugegriffen.

Das Ergebnis ist also eine Menge mit den Gewinnern (Menge von Keywords).


(defn gewinnt [s-map]
  (->> s-map
       rang-liste
       first
       second))

Bis jetzt haben wir nur "einfache Berechnungsregeln" implementiert. Es wurde noch gar nicht "gespielt". Diese Funktionen kommen jetzt.

Als erstes wird gegeben.

geben! ist eine "impure function". Sie liefert nämlich nicht immer bei gleicher Eingabe das gleiche Ergebnis (tatsächlich hat die Funktion gar keinen Parameter und somit auch keine "Eingabe").

Ein weiterer Umstand, der eine Funktion "impure" macht ist, wenn eine Funktion "Seiteneffekte" hat --- also z.B. eine globale Variable ändert (das kann man in Clojure machen, somit ist Clojure auch keine "reine funktionale Programmiersprache").

In Clojure ist es üblich, für solche Funktionen einen Namen mit einem ! am Ende zu verwenden (ist also ein "hallo wach!" für den Leser). Aber dabei handelt es sich nur um eine Konvention. Ansonsten hat das ! im Namen keine besondere Bedeutung.

"Impure Functions" zu testen ist schwieriger als "reine Funktionen" zu testen. Da liegt eben daran, dass man entweder einen "Funktions-und-Argument-externen Zustand" kontrollieren muss und/oder "Zufälle kontrollieren muss" (wie z.B. beim Geben). Daher sollte man immer versuchen, einen möglichst großen Anteil seines Programms "pure functional" zu machen. Das kann man durchaus auch in Java so machen.

  • keys liefert die Sequenz mit den Schlüsslen der karten->punkte Map. Also die Karten.

  • shuffle ist eine Zufalls-Misch-Funktion.

  • partition ist das Gegenstück zu flatten: es macht aus einer Sequenz von Elementen, eine Sequenz von Sequenzen von Elementen. Die "inneren" Sequenzen haben hier alle die Länge 13. Wir haben damit also 4x jeweils 13 Karten.

  • die HOF map kann nicht nur auf eine Sequenz sondern auch auf mehrere Sequenzen angewendet werden. In dem Fall wendet sie die Funktion (erstes Argument) auf die jeweils ersten Elemente mehrere Sequenzen an (als n Argumente) und dann die jeweils zweiten usw.

    Hier wird also die Funktion auf spieler und die aufgeteilten Karten-Partition angewendet.

    Übung: Schreibe die Funktion karten->punkte so um, dass sie nur aus einem map Aufruf und into besteht. D.h. eliminiere das for.

  • die Arity-2-Funktion liefert ein Tupel mit %1 (dem Spieler; erstes Argument) und einer Map mit dem Schlüssel :hand und (via into) dem Wert "Karten-Menge" (%2 ist ja das zweite Argument, das von map mit den Elementen aus der Karten-Partition bestückt wird).

    Diese Map ist die anfängliche Ein-Spieler-Map. Sie hat nur den Schlüssel :hand. In rang-liste wird auf den Schlüssel :stiche der Ein-Spieler-Map zugegriffen. Ich habe mich dazu entschlossen, die :stiche hier an dieser Stelle nicht zu "initialisieren". Später werden die :stiche zu der Ein-Spieler-Map zugefügt werden, ohne dass wir dafür jetzt schon einen "Leereintrag" bräuchten. Das kann man so machen, muss man aber nicht. Es ist eine Designentscheidung.

  • durch into in eine Map wird aus der <Spieler,Ein-Spieler-Map>-Sequenz die Alle-Spieler-Map.

geben! liefert also die Alle-Spieler-Map, die "den Zustand der Spieler zu Spielbeginn" repräsentiert.


(defn geben! []
  (->> (keys karten->punkte)
       shuffle
       (partition 13)
       (map #(-> [%1 {:hand (into #{} %2)}]) spieler)
       (into {})))

legt liefert jene Karte, die ein Spieler auf den Tisch legt, wenn er an der Reihe ist. legt ist eine Arity-1-Funktion, die als einziges Argument eine Map mit den Schlüsseln :hand und :tisch erwartet. Die inhaltliche Zuordnung der Argument-Teil-Werte (also die Werte der Map) zu den Namen in der Funktion (also hand und tisch) erfolgt (via Destructuring) über die (benannten) Schlüssel.

Wir hätten die Funktion auch als Arity-2-Funktion mit [hand tisch]-Parametern implementieren können. In diesem Fall würde man von positional parameters sprechen. D.h. die Zuordnung der Argumente zu den formalen Parametern der Funktion erfolgt aufgrund der Reihenfolge, in der die Argumente angegeben sind. Also z.B. (legt <hand> <tisch>). So macht man es z.B. auch in Java.

Positional Parameter haben aber den Nachteil, dass man an der Aufrufstelle nicht direkt sieht, dass das erste Argument "die Hand" ist (bzw. sein muss) und das zweite Argument "der Tisch". Und da Clojure aufgrund der fehlenden Typinformation das auch nicht weiß bzw. prüfen kann, ist es durchaus möglich, dass jemand die Argumente in der Reihenfolge versehentlich vertauscht.

Wenn wir aber eine Map als Argument haben, dann sieht die Aufrufstelle eher so aus: (legt {:tisch <tisch> :hand <hand>}). In diesem Fall sind die Argumente an der Aufrufstelle benannt, daher spricht man auch von named parameters.

Anmerkung: von named parameter spricht man eigentlich, wenn die Funktion so aufgerufen würde: (legt :tisch <tisch> :hand <hand>). Also nicht mit einer Map angegeben sondern mit einer Folge von Name-Wert-Paaren.

Quizfrage: wie müssen die Parameter in der Funktionsdefinition definiert werden, damit die Angabe an der Aufrufstelle wie gezeigt mit mehreren Argumenten erfolgt? Hinweis: die Lösung ist eine Kombination auf Vektor- und Map-Destructuring.

Das Problem, dass positional parameters aufgrund von Verwechslungen mit falschen Werten bestückt werden, kennt man auch in Java, wenn man z.B. mehrere null Werte als Argument angibt. Denn in diesem Fall kann auch der Comiler nicht mehr helfen, weil null ja ein untypisierter Wert ist.

Frage: was macht der folgende Aufruf? AdressenService.verlegeBeginn(1, 911, null, true, null). Und hätte es nicht AdressenService.verlegeBeginn(911, 1, null, null, true) heißen müssen? Wer weiß. Der Compiler hilft eben nur bedingt. Natürlich kann man auch benannte Parameter mit falschen Werten belegen, aber man bekommt zumindest beim Lesen eine Ahnung davon, was der Code wohl tuen wird bzw. tuen soll.

Aber positional parameters haben auch ihre Stärken. In der Funktionalen Programmierung und auch in Clojure benutzt man "partielle Funktionsauswertung" (sowas ähnliches wie currying [1, 2]; z.B. (partial str "LOG:")), um aus Funktionen neue Funktionen zu erzeugen, deren "ersten n" Parameter schon mit Argumenten besetzt sind. Aus diesem Grund verwenden die Funktionen, die in clojure.core geliefert werden, wohl alle positional parameter, damit man sie eben zusammen mit partial verwenden kann. Außerdem ist die Gefahr der Verwechslung bei 1 oder 2 Parametern noch nicht so groß wie bei 4 oder 5.

Aber jetzt zum Code:

  • hand ist eine Menge von Karten-Vektoren, also die Karten, die der legende Spieler auf der Hand hat, und tisch ist eine Liste/Sequenz von "bisher in diese Reihenfolge auf den Tisch gelegten Karten": eine Map mit :spieler und :karte.

  • if wertet das erste Argument aus und falls dieses truthy ist, liefert if als Ergebnis das Ergebnis der Auswertung des zweiten Arguments ("then-Fall"). Ansonsten liefert if das Ergebnis der Auswertung des dritten Elements ("else"-Fall). Das dritte Argument ist optional. Falls es nicht angegeben ist, entspricht das dem Wert nil. Es werden also immer nur jene zwei Argumente von if ausgewertet, die nötig sind, um das Ergebnis liefern/berechnen zu können.

    if hat also eine besondere Auswertungsregel. Wenn man das mit einer Funktion machen würde, würde es nicht das gewünschte Ergebnis bringen.

    REPL:

      hearts.core=> (if 42 "jupp" "nope")
      "jupp"
      hearts.core=> (if 42 (do (print "JUPP\n") :jupp) (do (print "NOPE!\n") :nope))
      JUPP
      :jupp
      hearts.core=> (if nil (do (print "JUPP\n") :jupp) (do (print "NOPE!\n") :nope))
      NOPE!
      :nope
      hearts.core=> (defn my-if [p a b] (if p a b))
      #'hearts.core/my-if
      hearts.core=> (my-if nil (do (print "JUPP\n") :jupp) (do (print "NOPE!\n") :nope))
      JUPP
      NOPE!
      :nope
    
  • empty? liefert truthy, falls das Argument "leer ist". nil gilt auch als leer.

    Wenn der Tisch noch leer ist, muss ja entweder mit der Kreuz-2 eröffnet werden oder es kann eine beliebige Karte gelegt werden. Insbesondere braucht zu Beginn, wenn der Tisch noch leer ist, keine Farbe bedient zu werden.

  • or (ein Makro) liefert das erste seiner n (ausgewerteten) Argumente(!!!), das truthy ist. Die Argumente werden der Reihe nach ausgewertet, bis ein truthy Wert vorliegt und dieser wird dann geliefert.

    Zur Erinnerung: die "normale" Auswertung läuft so an, dass erst alle Argumente/Elemente einer Liste ausgewertet werden und dann wird der Funktor mit eben diesen Werten aufgerufen.

    Bei or soll aber die Auswertung nur erfolgen, falls alle "links daneben stehenden Argument" falsy sind. Mit dieser Auswertungssemantik kann man z.B. auch null-Checks implementieren.

  • (hand [:kreuz 2]) liefert [:kreuz 2], falls diese Karte in der Menge hand ist (der Spieler also mit dieser Karte eröffnen muss) und (-> hand shuffle first) liefert die erste Karte der "gemischten Hand" --- also eine beliebige, zufällig aus der Hand gewählten Karte.

    An dieser Stelle merkt man, dass unsere Spieler nicht sonderlich "intelligent" handeln. Denn diese "zufällige Legestrategie" ist suboptimal. Falls es dir Spaß macht, kannst du an dieser Stelle ja eine "schlauere" Strategie implementieren.

    Ein "echter" (d.h. menschlicher) Spieler würde auf alle Fälle auch die bisherigen Stiche aller Spieler (nicht nur der eigenen!) in seine Entscheidung einbeziehen, so dass die Funktion wohl eher als (defn legt [{:keys [hand tisch stiche-aller-spieler]}] ...) definiert sein müsste.

    Der "then"-Zweig für den leeren Tisch liefert also entweder die Kreuz-2 oder eine beliebige Karte des eröffnenden Spielers.

  • if-let ist eine Kombination aus let und if: mit let wird ein lokaler (geschachtelter; nested) Namensraum aufgemacht, in dem Werte an Namen gebunden werden. Diese Name-Wert-Paare stehen in einem Vektor (binding vector). Ein if-let hat immer genau ein Name-Wert-Paar.

    Der Wert-Ausdruck/Form hinter dem lokalen Namen wird ausgewertet und falls er truthy ist, wird er an den Namen gebunden und das if-let-Ergebnis ist der Wert des folgenden "then"-Ausdrucks/Zweig. In diesem Zweig kann man über den lokalen Namen auch auf den zuvor gebundenen Wert zugreifen.

    Andernfalls wird der Namen nicht gebunden und das if-let-Ergebnis ergibt sich aus dem Wert des "else"-Zweiges.

  • mit der HOF filter erzeugen wir eine Sequenz von Karten (hand), deren (farbe %) der Farbe der ersten Karte auf dem Tisch ((-> tisch first :karte farbe)) entspricht (=). Das sind also alle Karten, mit denen man die Eröffnungskarte bedienen könnte (und dann ja auch müsste). Von denen wählen wir uns wieder eine beliebige aus (shuffle first).

    Anmerkung: in #(...) Formen kann man %<i> benutzen, um die Argumente über ihre 1-basierte Position zu "adressieren" (vgl. oben). Wenn man nur ein Argument hat, kann man auch einfach nur % schreiben.

    REPL:

      hearts.core=> (filter even? (range 10))
      (0 2 4 6 8)
    
  • Falls es nun eine solche Karte gibt (also k truthy ist), liefern wir diese Karte.

  • Ansonsten liefern wir wie in dem Leerer-Tisch-Fall eine beliebige Karte, die der Spieler auf der Hand hat.

Fertig.

Aufgabe: Könnte man das if-let durch ein or ersetzen?

[1] https://de.wikipedia.org/wiki/Currying
[2] https://practicalli.github.io/clojure/thinking-functionally/partial-functions.html


(defn legt [{:keys [hand tisch]}]
  (if (empty? tisch)                 
    (or (hand [:kreuz 2])            
        (-> hand shuffle first))     
    (if-let [k (->> hand
                    (filter #(= (farbe %)
                                (-> tisch first :karte farbe)))
                    shuffle
                    first)]           
      k                              
      (-> hand shuffle first))))   

runde bekommt als benannte Argumente die Alle-Spieler-Map :spieler, den Spieler :beginnt, der diese Runde beginnt und die Zahl, welche Runde gerade gespielt wird. Diese Zahl brauchen wir aber nur für eine Ausgabe, die wir während des Spiels erzeugen möchten. Für die Spiellogik ist :runde völlig irrelevent.

runde liefert als Ergebnis eine Map mit der (aktualisierten --- jemand hat ja den Stich bekommen) Alle-Spieler-Map (:spieler) und der Angabe, welcher Spieler (Keyword) diese Runde sticht (:sticht). Falls aber gar keine Runde mehr gespielt wird (weil die Spieler keine Karte mehr auf der Hand haben; vgl. unten), liefert runde den Wert nil.

  • mit (-> s first second :hand empty?) wird geprüft, ob der erste Spieler überhaupt noch eine Karte auf der Hand hat. Damit prüfen wir einfach, ob die letzte Runde bereits in der Runde zuvor gespielt wurde, denn dann wird diese Runde nicht mehr gespielt. Wir können hier einfach nur den ersten Spieler betrachten, weil alle Spieler gleich viele Karten auf der Hand haben.

Anmerkung: Die Verwendung von Funktionen und Funktionskomposition führt dazu, dass man in Clojure häufig gar keine Variablen (also lokale Namen) benötigt, um Zwischenergebnisse daran zu binden. In Java macht man das häufiger.

Wenn man dabei ist, Code zu schreiben oder Fehler im Code zu suchen, nutzt man solche Namen gerne, um die Zwischenwerte auszugeben, um so zu verstehen, was das Programm eigentlich (falsch) macht.

I.d.R. gibt man die Werte dann via System/out aus oder man benutzt einen Debugger, um den Wert einer Variablen zu inspizieren.

In Clojure kann man natürlich auch Variablen für solche Zwecke einführen, aber dazu muss man dann immer den Code umbauen, nur damit man in der Lage ist, die Zwischenwerte auszugeben.

Es gibt natürlich Bibliotheken, die einem beim Debuggen unterstützen (z.B. [4]). Eine Alternative, die häufig ausreicht, ist das Auskommentieren von Teilen einer Funktion.

Beispiel: Ich möchte die Spieler in der Reihenfolge ermitteln, in der sie rauskommen müssen. Die folgende REPL-Sitzung zeigt, wie man zum gewünschten Ergebnis kommt. Derzeit habe ich den Code innerhalb von runde stehen und es wäre wohl besser, ihn in eine separate Funktion zu extrahieren, auch um ihn besser testen zu können. Aber man kann ihn auch einfach so in die REPL pasten. Ich benutze hier den #_ Kommentar, mit dem man eine Form auskommentiert. Das ist super-praktisch, weil es mir erlaubt, einzelne Verarbeitungsschritte temporär auszuschalten und so den Code in verschiedenen Verarbeitungstiefen ausprobieren zu können. Gerade zusammen mit ->> macht das Spaß. Auf diese Weise kann man ohne viel Aufwand auch auf Zwischenergebnisse in der Verarbeitungs-Pipe zugreifen und erkennen, was der Code macht. Die #_=> Teile kommen von der REPL (Zeilenfortführung). Es ist kein Zufall, dass auch diese mit #_ beginnen.

REPL:

hearts.core=> spieler
[:gabi :peter :paul :sonja]
hearts.core=> (->> (concat spieler spieler)
		 #_=>      #_ (drop-while #(not= b %))
		 #_=>      #_ (take 4))
(:gabi :peter :paul :sonja :gabi :peter :paul :sonja)
hearts.core=> (->> (concat spieler spieler)
		 #_=>      (drop-while #(not= b %))
		 #_=>      #_ (take 4))
CompilerException java.lang.RuntimeException: Unable to resolve symbol: b in this context
hearts.core=> (def b :paul)
#'hearts.core/b
hearts.core=> (->> (concat spieler spieler)
		 #_=>      (drop-while #(not= b %))
		 #_=>      #_ (take 4))
(:paul :sonja :gabi :peter :paul :sonja)
hearts.core=> (->> (concat spieler spieler)
		 #_=>      (drop-while #(not= b %))
		 #_=>      (take 4))
(:paul :sonja :gabi :peter)

  • falls dem nicht so ist (when-not), ermitteln wir die Reihenfolge, in der die Spieler in dieser Runde ihre Karten legen müssen. Das machen wir, indem wir die Liste der spieler zweimal hintereinander zusammenfügen ((concat spieler spieler)) und diese Liste dann von Beginn an durchlaufen und alle Elemente löschen (bzw. vernachlässigen), die nicht der :beginnt Spieler sind ((drop-while #(not= b %))). Damit haben wir dann eine Liste/Sequenz, die mit dem :beginnt Spieler beginnt (denn an genau der Stelle haben wir mit dem Vernachlässigen ja aufgehört) und die folgenden drei Elemente (also insgesamt (take 4)) sind die Spieler in jener Reihenfolge, in der sie ausspielen/legen müssen.

  • falls when-not also truthy ist (nämlich die Liste der Spieler), wird diese Liste via when-let an den lokalen Namen xs (gesprochen "ix-es") gebunden und es geht mit dem "then"-Zweig von when-let weiter. Andernfalls (wenn also keine Runde mehr gespielt wird) liefert die Funktion runde den Wert nil.

    Anmerkung: when und when-let unterscheiden sich von if auch dadurch, dass sie nicht nur eine "then"-Form haben, sondern sie können beliebig viele solcher Formen/Argumente haben. Der Wert des when Ausdrucks ist im truthy-Fall dann der Wert der letzen Form.

    In einer rein funktionalen Programmiersprache macht sowas keinen Sinn, denn was sollten schon die Formen/Ausdrücke tun, die vor der letzten Form stehen? Sie können ja nichts zum Rückgabewergebnis beitragen. In Clojure gibt es aber Ausdrücke, die Seiteneffekte haben, wie z.B. die Ausgabe nach STDOUT. Dadurch machen solche "Multi-Form-When-Ausdrücke" doch Sinn. Wir nutzen hier die Möglichkeit, (when-let [...] <form-1> <form-2>)
    schreiben zu können, um so via print Ausgaben auf STDOUT zu erzeugen.

    REPL:

      hearts.core=> (when true (println "x") :foo)
      x
      :foo
      hearts.core=> (println "x")
      x
      nil
      hearts.core=> (if true (println "x") :foo)
      x
      nil
    
  • die zweite Form von when-let ist loop. Bisher haben wir reduce verwendet, um iterative Berechnungen durchzuführen. In Java hätte man dafür for oder while Schleifen verwenden.

    loop bietet auch die Möglichkeit, eine Aggregation durchzuführen, jedoch kann man auf einfach Weise am "Ende der Schleife" noch einen Verarbeitungsschritt einbauen.

    Mit reduce ist das häufig nicht so einfach möglich, weil man nicht leicht erkennen kann, wann das letzte Element verarbeitet wird. In Java und bei der Verwendung von reduce muss man solche "finalen Schritte" daher i.d.R. im Anschluss an die Schleife machen. Mit loop kann man das eleganter und kompakter formulieren.

    loop eröffnet auch einen lokalen Namensraum (wie let und when-let) und bindet Werte an lokale Namen. Der binding vector besteht aus Paaren von <lokaler-name> <init-wert>. Diese initiale Bindung (inkl. Destructuring) wird einmalig zu Beginn der Schleife hergestellt.

    Hier wird x an das erste Element von xs gebunden und xr an den tail (Rest) von xs. Der lokale Name s wird an den Wert von s gebunden.

    Achtung: es handelt sich hierbei um zwei verschiedene Namen. Das linke s ist in der loop gebunden, das rechte s ist in runde gebunden und hier wird der Wert von diesem Namen an das s in loop gebunden. Innerhalb von loop "sieht" man mit s immer nur das "innere" s. Andere Namen, die in runde gebunden sind (wie z.B. r) können auch in loop gesehen werden. Das "innere" s verschattet also das äußere s. Solche Mehrfachverwendungen mit Namenverschattung sollte man vermeiden.

    tisch wird mit einem leeren Vektor gebunden.

    Die Bindung im binding vector ist die Bindung der Namen für den ersten Schleifendurchlauf. Die Schleife wird jedoch mehrfach durchlaufen. Wie das passiert, wird gleich gezeigt. Bei diesen folgenden Schleifendurchläufen sind die Namen dann an andere Werte gebunden (vgl. recur unten).

    Der Name x wird in jedem Schleifendurchlauf an den Namen desjenigen Spielers gebunden, der gerade legen muss. Wenn x falsy ist (also alle Spieler gelegt haben und der aktuelle Spieler nil ist), ist die Runde "fertig". Diesen Fall erkennen wir via (if-not x ...).

    REPL:

      hearts.core=> (loop [[h & t] (range 5) r nil]
                       (if-not h (format ">%s<" r)
                                 (recur t (str h r h))))
      ">4321001234<"
    

    Am Ende der Runde müssen wir via (sticht tisch) ermitteln, wer diese Runde sticht und wir "geben" die Karten auf dem tisch eben diesem Spieler b in seine :stiche (sein Haufen).

    Das machen wir dadurch, dass wir die Alle-Spieler-Map "updaten" (natürlich wird eine neue Map erzeugt!), und zwar in dem Eintrag/Wert, der über den Pfad b und :stiche referenziert wird.

    Der Wert dieses Pfades ist zu Beginn der Wert nil! Wir haben in geben! nur den :hand-Eintrag der Ein-Spieler-Map gesetzt, nicht aber den :stiche-Eintrag.

    REPL:

      hearts.core=> (doc get-in)
      -------------------------
      clojure.core/get-in
      ([m ks] [m ks not-found])
        Returns the value in a nested associative structure,
        where ks is a sequence of keys. Returns nil if the key
        is not present, or the not-found value if supplied.
      nil
      hearts.core=> (def a {:foo {:bar "BAR" :fred "FRED"}})
      #'hearts.core/a
      hearts.core=> (get-in a [:foo :bar])
      "BAR"
      hearts.core=> (get-in a [:foo :oops] "oops")
      "oops"
      hearts.core=> (get-in a [:foo :oops])
      nil
    

    Mit (fnil conj []) erzeugen wir eine Funktion, die das erste Argument durch [] ersetzt, falls es nil ist und sich ansonsten wie conj verhält. Dadurch schafft man eine Funktion, die die Initialisierungslogik mit einschließt: nämlich als Start-Wert einen leeren Vektor zu verwenden. conj (conjoin) fügt ein Element zu einer Collection.

    REPL:

      hearts.core=> (doc conj)
      -------------------------
      clojure.core/conj
      ([coll x] [coll x & xs])
        conj[oin]. Returns a new collection with the xs
      	'added'. (conj nil item) returns (item).  The 'addition' may
      	happen at different 'places' depending on the concrete type.
      nil
      hearts.core=> (conj [] :foo :bar)
      [:foo :bar]
      hearts.core=> (conj '() :foo :bar)
      (:bar :foo)
      hearts.core=> ((fnil conj []) nil :foo :bar)
      [:foo :bar]
      hearts.core=> ((fnil conj []) [:fred :quox] :foo :bar)
      [:fred :quox :foo :bar]
    

    Das Zusammenwirken von update und fnil führt dazu, dass der zu Beginn nicht vorhandene :stiche Wert effektiv zu [] wird und diesem Wert dann der tisch hinzugefügt wird.

    REPL:

      hearts.core=> (doc update-in)
      -------------------------
      clojure.core/update-in
      ([m [k & ks] f & args])
        'Updates' a value in a nested associative structure, where ks is a
        sequence of keys and f is a function that will take the old value
        and any supplied args and return the new value, and returns a new
        nested structure.  If any levels do not exist, hash-maps will be
        created.
      nil
      hearts.core=> (update-in {:foo {}} [:foo :fred] (fnil conj []) :uh-oh :duh!)
      {:foo {:fred [:uh-oh :duh!]}}
      hearts.core=> (update-in {:foo {:fred [:bar]}} [:foo :fred] (fnil conj []) :uh-oh :duh!)
      {:foo {:fred [:bar :uh-oh :duh!]}}
    

    runde liefert also eine Map mit dem Spieler b, der :sticht und der Alle-Spieler-Map, in der b den tisch in :stiche (ein Vektor) eingesammelt hat. let kann genau wie when-let mehrere Formen auswerten und liefert den Wert der letzten Form.

    REPL:

      hearts.core=> (let [x 1] (println x) (inc x))
      1
      2
    
  • falls wir in x aber noch einen Spieler haben (if-not "else"-Zweig) , der nun legen muss, ermitteln wir erst was er auf der hand hat und dann welche Karte k er legen will.

    Der tisch ist entweder noch leer (leerer Vektor []; erster Schleifendurchlauf) oder hat schon Karten. Nun fügen wir dem tisch eine neue "gelegte Karte" mit :karte k und :spieler b zu und binden diesen Wert an einen neuen lokalen Namen tisch.

    Schließlich "updaten" wir noch die :hand des Spielers x und "entziehen" (disj) ihm die Karte k.

  • damit haben wir das "Delta" an unserem Spielzustand berechnet und in Alle-Spieler-Map s und den tisch eingebaut und können nun mit dem nächsten Spieler in dieser Runde fortfahren.

    Wichtig: wir haben nichts geändert, so wie man ein Objekt in Java ändern würde. Wir haben ausschließlich neue unveränderliche Objekte aus bestehenden unveränderlichen Objekten konstruiert. Die neuen s und tisch sind unser neuer Aggregatszustand.

    Nun rufen wir die loop Schleife via recur mit den neuen Argumentwerten für die loop-Bindungen auf: also den verbleibenden Spielern xs, der neuen Alle-Spieler-Map s und dem aktualisierten tisch.

    Der Aufruf sieht so aus, als wenn hier eine "Rekursion" [1] erfolgen würde. Und Rekursion "kostet Stack-Space" und führt irgendwann zum Stackoverflow.

    In Funktionalen Programmiersprachen wird aber häufig eine Optimierung durchgeführt, die sich Endrekursion [2] nennt. Endrekursion führt zu einem iterativen Berechnungsprozess [3] (nicht zu einem rekurisven) und dieser benötigt keinen Stackspace.

    recur ist so ein endrekursiver Rekursionsaufruf. Er kann nur erfolgen, wenn die recur-S-Expression die letzte ist, die ausgewertet wird. Andernfalls kommmt es zu einem Compile-Fehler.

Fertig.

Anmerkung: in imperativen Programmiersprachen kann man versehentlich Endlosschleifen programmieren, weil man das Abbruchkriterium nicht richtig formuliert. Das kann in funktionalen Programmiersprachen auch passieren. Die loop Schleife unten terminiert garantiert, weil wir von xs immer ein Element entfernen (und xs zu Beginn vier Elemente hat) und damit schließlich in einen Zustand kommen, in dem x falsy (hier nil) ist. Und damit laufen wir nicht mehr in das recur.

[1] https://de.wikipedia.org/wiki/Rekursion
[2] https://de.wikipedia.org/wiki/Endrekursion
[3] https://de.wikipedia.org/wiki/Iterative_Programmierung
[4] https://github.com/alexanderjamesking/spy


(defn runde [{s :spieler b :beginnt r :runde}]
  (when-let [xs (when-not (-> s first second :hand empty?)
                  (->> (concat spieler spieler)           
                       (drop-while #(not= b %))           
                       (take 4)))]                        
    (print hr "Runde" r "beginnt: Spieler" b "eröffnet.\nSpieler:" s \newline)
    (loop [[x & xr] xs               
           s s                       
           tisch []]                 
      (if-not x                      
        (let [b (sticht tisch)      
              s {:spieler (update-in s [b :stiche] (fnil conj []) tisch)
                 :sticht b}]
          (println \newline "Runde" r "endet: Spieler" b "sticht:" tisch "\nSpieler:" s hr)
          s)
        (let [{hand :hand} (s x)     
              k     (legt {:hand hand :tisch tisch})   
              tisch (conj tisch {:karte k :spieler x}) 
              s     (update-in s [x :hand] disj k)]    
          (println "     Runde" r ": Spieler" x "legt" k "Tisch:" tisch)
          (recur xr s tisch))))))                      

defn unterstützt auch die Definition von mehreren Arities. Die Funktion spiel hat die Arity-0 und die Arity-1.

Die Arity-0-Variante ruft die Arity-1-Variante mit (spiel (geben!)) auf. D.h. die Arity-1-Variante hat als Argument gegeben die Alle-Spieler-Map zu Spielbeginn nach dem Geben. Diese Variante habe ich eingeführt, um die Funktion besser testen zu können.

Auch das spiel läuft in loop-recur-Schleifen, nämlich über die Runden. s wird zu Beginn an die Alle-Spieler-Map gegeben gebunden, b an den Spieler, der beginnt und der Rundenzähler r bekommt den Wert 1.

  • if-let wird benutzt, um die runde mit dem aktuellen Spielzustand aufzurufen. Falls runde truthy ist, werden in if-let die lokalen Namen s und b an die neuen Werte aus der runde gebunden und es wird sofort mit diesen Werten via recur der nächste Schleifendurchlauf gemacht.

    Wichtig: recur ist kein "imperatives goto", sonder es verhält sich wie ein "Funktionsaufruf ohne Stackverbrauch", der Argumente und einen Rückgabe-Wert hat und genau dieser Rückgabe-Wert wird durch die recur-Form "geliefert".

    Falls runde falsy ist, werden die Namen ja nicht neu gebunden, so dass in let die Alle-Spieler-Map s nach der letzten Runde (in loop gebunden!) verwendet werden kann, um zu ermitteln, wer das Spiel gewinnt.

Der Gewinner wird im Ergebnis erg als :gewinnt geliefert und der finale Zustand der Spieler mit ihren Stichen wird durch :spieler als Alle-Spieler-Map im Ergebnis geliefert.

Fertig.


(defn spiel
  ([] (spiel (geben!)))
  ([gegeben] 
     (println "Gegeben:" gegeben hr)
     (loop [s gegeben
            b (beginnt s)
            r 1]
       (if-let [{s :spieler b :sticht} (runde {:spieler s :beginnt b :runde r})]
         (recur s b (inc r))
         (let [erg {:spieler s
                    :gewinnt (gewinnt s)}]
           (println "Gewinner:" (:gewinnt erg) "\nSpieler:" s hr)
           erg)))))

About

Simple implementation of game of hearts

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published