Python vs. Clojure

Alles, was nicht direkt mit Python-Problemen zu tun hat. Dies ist auch der perfekte Platz für Jobangebote.
Antworten
sma
User
Beiträge: 3018
Registriert: Montag 19. November 2007, 19:57
Wohnort: Kiel

Hier ist ein kurzes Stück Python-Code, welches in einem Spiel, das Einheiten kennt, die Spielern gehören, den Städte besitzenden Spielern dafür Geld zahlt:

Code: Alles auswählen

    def income():
        for u in game.units:
            if u.type == ut_city:
                u.player.money += u.type.income
Das ist offensichtlich imperativ. Clojure bevorzugt einen funktionalen Stil, wo ich nicht einfach die Exemplarvariable `money` ändern kann. Stattdessen muss ich einen neuen Spielstand erzeugen, also neue Spieler als Teil eines neuen Spiel-Objekts.

Hier meine (in Python nicht gezeigten) Objektdefinitionen:

Code: Alles auswählen

    (defstruct game :players :units)
    (defstruct player :no :money :units)
    (defstruct unit :no :type :player)
    (defstruct unittype :income)
    
    (def ut-city (struct unittype 10))
    
    (def *game* (struct game
        [(struct player 1 5 [1])
         (struct player 2 0 [2 3])]
        [(struct unit 1 ut-city 1)
         (struct unit 2 ut-city 2)
         (struct unit 3 ut-city 2)]))
Leider kann ich nicht sowohl Spieler auf Einheiten und Einheiten auf Spieler verweisen lassen und muss daher IDs einführen, die ich stattdessen benutze. Das `def` definiert eine globale (und unveränderliche) Variable `*game*` als Struktur vom Typ `game`.

Dafür baue ich mir einige Zugriffsfunktionen:

Code: Alles auswählen

    (defn all-players [] (*game* :players))
    
    (defn all-units [] (*game* :units))
    
    ;; ich finde kein "detect" oder "find" in der echt miesen Dokumentation...
    (defn find-by-no [no coll] (first (filter #(= (% :no) no coll)))
        
    (defn player [no] (find-by-no no (all-players)))
    
    (defn unit [no] (find-by-no no (all-units)))
    
    (defn is-city [u] (= (:type u) ut-city))
    
    (defn all-cities [] (filter is-city (all-units)))
    
Ich habe mit `all-cities` jetzt eine Liste alle Städte aller Spieler, kann aber jetzt nicht einfach Spieler-Objekte verändern, sondern sammle die Einkommen in einer Map:

Code: Alles auswählen

    (defn income-per-players []
        (reduce (fn [m c] 
                    (let [p (player (:player c)) income (:income (:type c))]
                        (assoc m p (+ (or (m p) 0) income))))
                {}
                (all-cities)))
Mittels `assoc` kann ich einzelne Werte einer Map aktualisieren und erhalte dann eine neue Map zurück. Die obige Funktion sammelt daher unter dem Schlüssel eines Spielerobjekts die aufsummierten Einkommen.

Nun muss ich ein neues Spiel bauen, welches eine neue Liste mit neuen Spielerobjekten hat, deren Geld das neue Einkommen enthält.

Code: Alles auswählen

    (defn add-income [new-income]
        (map (fn [p] (assoc p :money (+ (:money p) (or (new-income p) 0))))
             (all-players)))
Ganz am Schluss leiste ich mir den Luxus, eine globale Variable zu ändern, was nur innerhalb eines `binding` funktioniert, wodurch man dann eine globale Variable mit einem anderen Wert überschatten kann:

Code: Alles auswählen

    (defn income []
        (set! *game* (assoc *game* :players (add-income (income-per-players)))))
Irgendwie ist der Python-Code einfacher :)

Hier sind noch zweite Varianten:

Code: Alles auswählen

    def income():
        for p in game.players:
            p.money = p.money + sum(u.type.income for u in p.units if u.type == ut_city)

    def income(game):
        return update(game, {
            'players',
            map(lambda p: update(p, {
                'money': 
                p.money + sum(u.type.income for u in p.units if u.type == ut_city)
            }), game.players)
        })
        
    def update(obj, dct):
        import copy
        nobj = copy.copy(obj)
        for k, v in dct.items():
            setattr(nobj, k, v)
        return nobj
Die erste Form ist in Python umständlicher, lässt sich aber besser in Clojure überführen:

Code: Alles auswählen

    (defn money-of-player [p]
        (reduce + 
                (:money p) 
                (for [u (map unit (:units p)) :when (is-city u)] (:income (:type u)))))
    
    (defn players-with-updated-income []
        (map (fn [p] (merge p {:money (money-of-player p)})) (all-players)))

    (def update-players! [players] (set! *game* (assoc *game* :players players)))

    (defn income [] (update-players! (players-with-updated-income)))
Die zweite Form ist funktional und entspricht ziemlich genau der Clojure-Version. Sie kommt ohne globale Variable aus, da das Spiel übergeben und ein neues Spiel mit einer Spielerliste zurückgegeben wird.

Clojure kennt übrigens eine Funktion `update-in`, mit der man (ineffizient) folgendes machen kann:

Code: Alles auswählen

    (def game (ref (struct ...)))

    (defn update-game-in [ks f v]
        (dosync (ref-set game (update-in @game ks #(f % v)))))
    
    (defn income []
      (for [u (:units @game) :when (is-city u)]
        (update-game-in [:players (:player u) :money] + (:income (:type u)))))
Die Funktion wendet die in `ks` übergebenen Funktionen der Reihe nach auf `@game` an, holt sich also zuerst die Liste der Spieler, dann den durch die Einheit definierten Spieler, dort das Geld und wendet jetzt auf den erhaltenen Wert die übergebene Funktion an, die zu dem existierenden Wert (der durch `%` in dieser Kurzform einer Funktion markiert ist) das durch den Einheitentyp definierte Einkommen addiert. Danach werden alle beteiltigen Objekte kopiert und der veränderte Wert aktualisiert. Ich nutze eine `ref`, eine in einer mit `dosync` definierten Transaktion änderbare globale Variable, auf deren Wert ich mit `@` zugreifen kann.

Ich speichere jetzt in `:player` nicht mehr die Nummer, sondern einen Index, aber ansonsten ist das jetzt fast genauso kompakt die der ursprüngliche Python-Code.

Stefan

PS: Leider ist die Entwicklung mit Clojure zur Zeit PITA^2, da einembei Fehlern nichtssagende Java-Exceptions um die Ohren fliegen und die API-Doku in Form einer alphabetischen Liste aller Funktionen und Macros absolut unzureichend ist.
Leonidas
Python-Forum Veteran
Beiträge: 16025
Registriert: Freitag 20. Juni 2003, 16:30
Kontaktdaten:

Ich kann dazu recht wenig beisteuern, da ich mit Clojure seitdem ich das erste mal was damit gemacht habe, nicht viel weitergemacht habe (werde momentan nicht mal mit dem Zeug fertig was ich eigentlich machen sollte, von Freizeit-Coding ganz abgesehen), aber ich habe gestern den Channel #clojure entdeckt, wo auch Rich Hickey rumhockt. Zum Channel selbst kann ich wenig sagen, aber generell habe ich mit Lisp-Fragen bisher gute Erfahrungen im IRC gemacht, weil ja doch einiges an Wissen verstreut im Netz ist und man nicht genau weiß wie man was am besten löst. In #clojure wird man wohl erst recht brauchbare Antworten bekommen, da das dort wohl alles early-adopters sind, nicht Leute die vor Ewigkeiten mal was in Lisp gemacht haben sondern Leute die heute die Sprache mitgestalten.

Und naja, Rollenspiele gehören auch schon in Python nicht so besonders zu meinen besonderen Interessensgebieten ;) Trotzdem habe ich dein Posting interessiert gelesen, sowas finde ich immer recht aufschlussreich.
My god, it's full of CARs! | Leonidasvoice vs (former) Modvoice
sma
User
Beiträge: 3018
Registriert: Montag 19. November 2007, 19:57
Wohnort: Kiel

Ich wollte ein bisschen mit Clojure warm werden und nahm dazu einige Python-Beispiele als Vorbild. Das es dabei um Spieler und Einheiten geht, ist nicht weiter relevant. Ich tat mich erstaunlich schwer damit, das kompakte und IMHO lesbare Python-Programm in einen funktionalen Stil zu überführen. Insbesondere das völlige Fehlen von veränderbaren Variablen macht ein anderes (ungewohntes) Vorgehen notwendig.

Bei diesem Beispiel, welches vom Geld der Spieler Unterhaltskosten für Einheiten bezahlt oder diese aus dem Spiel entfernt, wenn kein Geld mehr vorhanden ist, braucht man noch mehr Seiteneffekte und bei einer funktionalen Variante kann ich kaum noch den Algorithmus erkennen.

Code: Alles auswählen

def upkeep():
    for p in game.players:
        for u in shuffled(p.units):
            cost = u.type.upkeepcost
            if p.money >= cost:
                p.money -= cost
            else:
                u.remove()
Vielleicht habe ich den falschen Anwendungsfall gewählt, weil ich eigentlich Clojure mögen wollte. Vielleicht ist aber Python einfach pragmatischer, indem man's funktional machen kann, aber nie muss. Nervig bei Python ist, dass einige Funktionen zwangsweise destruktiv sind, wie etwa `random.shuffle()` oder `list.sort()`. Daher benutze ich eine eigene (echte) Funktion `shuffled()`.

Funktional würde ich vielleicht so vorgehen:

Ich laufe über die Liste und erzeuge dabei drei Ergebnisse: Die Liste der bezahlten Einheiten, die der unbezahlten und das restliche Geld des Spielers:

Code: Alles auswählen

def maintain_units_per_player(p):
    def f((to_keep, to_remove, money), u):
        cost = u.type.upkeepcost
        if  money >= cost:
            return to_keep + [u], to_remove, money - cost
        else:
            return to_keep, to_remove + [u], money

    to_keep, to_remove, money = reduce(f, p.units, ([], [], p.money))
    
    return Player(to_keep, money), to_remove
Jetzt mache ich das für alle Spieler und baue dabei noch eine Liste aller zu entfernenden Einheiten. Diese muss ich danach noch aus der globalen Liste des Spiels entfernen:

Code: Alles auswählen

def maintain_units(game):
    def f((players, to_remove), p):
        p2, units = maintain_units_per_player(p)
        return players + [p2], to_remove + units

    players, to_remove = reduce(f, game.players, ([], []))

    units = filter(lambda u: u not in to_remove, game.units)

    return Game(players, units)
Möglicherweise ist aber die Optimierung, pro Spieler Einheiten zu verwalten, unnötig und ich habe einfach nur zwei Listen und filtere die Einheiten jeweils entsprechend. Dann kann ich zusammen mit einer Map von Spielern auf deren Geld direkt die Liste der Einheiten durchlaufen:

Code: Alles auswählen

def upkeep(game):
    def f((to_keep, to_remove, moneymap), u):
        cost = u.type.upkeepcost
        if moneymap[u.player] >= cost:
            return to_keep + [u], to_remove, assoc(moneymap, u.player, moneymap[u.player] - cost)
        else:
            return to_keep, to_remove + [u], moneymap

    moneymap = dict((p, p.money) for p in game.players)
    units, to_remove, moneymap = reduce(f, game.units, ([], [], moneymap))
    
    players = [Player(moneymap[p]) for p in game.players]
    
    return Game(players, units)
    
def assoc(dct, k, v):
    dct = dct.copy()
    dct[k] = v
    return dct
Was war noch gleich so großartig an funktionaler Programmierung? Das man's nicht mehr so einfach nachvollziehen kann? Das es weniger verständlich ist und nur viel cooler wirkt? ;)

Nochmal zurück zu Clojure: Da ich jederzeit auf Java-Klassen zurückgreifen kann, könnte ich mir mein Beispiel natürlich mit Java-Klassen oder auch nur einer `java.util.ArrayList` viel einfacher gestalten:

Code: Alles auswählen

    (import '(java.util.ArrayList))
    
    (def units (java.util.ArrayList.))
    
    (defn add-unit [u] (.add units u))

    (defn unit [no] (first (filter #(= (% :no) no) units)))
    
    ;; Array-Zugriffe und Slicing a la Python...
    (let [idx #(if (> 0 %2) (+ (.size %1) %2) %2)]
        (defn geta [arraylist index] (.get arraylist (idx arraylist index)))
    
        (defn seta [arraylist index value] (.set arraylist (idx arraylist index) value))
    
        (defn slicea [arraylist left right]
            (.subList arraylist (if left (idx arraylist left) 0)
                                (if right (idx arraylist right) (.size arraylist))))
            
        (defn setslicea [arraylist left right value]
            (let [l (if left (idx arraylist left) 0)
                  r (if right (idx arraylist right) (.size arraylist))]
                (dotimes _ (- r l) (.remove arraylist l))
                (.addAll arraylist l value))))
                
    (defn reject! [f arraylist]
        "Removes all elements from the given list for which the given function returns true"
        (let [i (.iterator arraylist)]
            (loop []
                (if (.hasNext i)
                    (do
                        (if (f (.next i)) (.remove i))
                        (recur))))))
Das versöhnt mich dann doch wieder mit dieser nicht uninteressanten Sprache.

Wenn das "j" in Clojure für Java steht... wäre dann auch Clopure interessant? Kann eigentlich gar nicht so schwer zu schreiben sein...

Stefan
Antworten