Mittwoch, 12. September 2012

Groovy: Die Closure - Beispiel findAllFiles

Kernkonzept: Die groovy Closure
Ich mag groovy für kleine Aufgaben, die in Java zumeist unhandlich sind. Nichts gegen Java, aber manches ist gescripted handlicher oder macht zumindest mehr Spass. Einsatzszenarien sind bei mir in der Regel so definiert:

  • kaum Performance Anforderungen. Wenns um die letzten Prozente geht, muss es eben Java sein. Das bedeutet nicht, das in Performance kritischen Anwendungen kein groovy drin sein kann, nur eben nicht an den kritischen Sequenzen.
  • String Manipulation als Kernaufgabe. Ein Script ist ideal, wenn es darum geht automatische Textverarbeitung zu machen. Ob es klassisches Search/Replace oder Textgenerierung/Konvertierung ist, es ist mit groovy leichter. 
  • Klein genug um wartbar zu sein. Jedes einzelne Script sollte nicht mehr als eine A4 Seite lang sein. Da groovy sehr kompakt ist, passt auch viel Logik in wenig Zeilen. Darum ist Modularisierung extrem wichtig, sonst landet man schnell bei "write only" Programmen. 


Damit groovy Spaß macht, braucht man etwas Handwerkszeug um richtig loszulegen. Eines der wichtigsten: Die Closure.

Die Sourcen, an denen ich entlang hangeln werde (am Ende des Postings ist der gleiche Quellcode ohne Zeilennummern als Kopiervorlage):


1: findAllFiles = { File folder, Closure fileMatch ->
2:     def fileNameFilter = {dir, name -> fileMatch(name)} as FilenameFilter
3:     def dirFilter = {File file -> !file.isFile()} as FileFilter
4:     def files = []
5:     def innerFindAll
6:     innerFindAll = {File lFolder ->
7:         if(!lFolder.isFile()){
8:             lFolder.listFiles(dirFilter).each{ innerFindAll(it)}
9:             files.addAll(lFolder.listFiles(fileNameFilter ))
10:         }
11:         return files
12:     }
13:     return innerFindAll(folder)
14: }


Und so wirds benutzt:


println findAllFiles(new File("."), {it.endsWith(".xml")})  

Das Ergebnis ist ein kleines Helferlein, ich verwende es etwa um in allen gelieferten xml Dateien Platzhalter für Autor, Erzeugungsdatum und Version einheitlich zu setzen. Aber das ist ein anderes Thema. Zuerst mal sind die Grundlagen für die Closure zu beschreiben.

Eine erste Closure
Der Code definiert eine Closure findAllFiles. Eine Closure ist ein first Class Bewohner des groovy Ökosystems. Formal ist eine Closure in den groovy Docs definiert, informell ist es ein Methoden-Pointer und das erklärt es für mich auch ausreichend um es benutzen zu können. Etwas ungewohnt für Java Nutzer ist die Definition der Parameter. Dazu hat auch die offizielle Dokumentation eine gute Beschreibung. Von Interesse ist der zweite Parameter: Closure fileMatch. Hiermit wird das Einsatzgebiet flexibler. Die Closure findAllFiles bekommt auf diesen Weg mitgeteilt nach welchen Kriterien die Dateien gefiltert werden sollen. Es ist auch beim Aufruf gut lesbar, was die Closure macht, im Beispielaufruf: finde alle Dateien die mit .xml aufhören. Ich denke, die Aufrufzeile kommt gut ohne Kommentar aus.
Eine weitere Info zu Parametern: Es ist in groovy nicht notwendig, dass man Variablen explizit definiert, ich tue es aber immer, wenn es der Lesbarkeit dient. Es ist z.B. die Variable folder besser dokumentiert, wenn ich den Typ (java.io.File) aufführe. Woher soll der Nutzer sonst wissen, ob ich den Folder als File oder als String erwarte? Alternativ kann man die Variable entsprechen benennen, in der Art fileFolder, nach kurzer Überlegung, ist es aber keine gute Alternative. Ich müsste ja überall den sperrigen Namen tippen und so eindeutig wie eine explizite Deklaration ist es immer noch nicht.

Closure um Interfaces zu implementieren
Die nächsten zwei interessanten Closures sind fileNameFilter und dirFilter. Es ist eine besondere Nutzung, in dem hier jeweils das Interface java.io.FilenameFilter bzw. java.io.FileFilter implementiert wird. Das funktioniert so einfach für Interfaces mit genau einer Methode. Für Interfaces mit vielen Methoden gibt es in groovy auch viele Möglichkeiten, ich verwende dann aber ausschliesslich die original Java Art ein Interface zu implementieren. Die speziellen groovy Spielarten verschlechtern IMHO die Lesbarkeit ohne dafür echten Mehrwert zu bieten. Findet eure bevorzugte Methode am besten selbst raus, nur alle durcheinander zu benutzen erschwert die Lesbarkeit enorm.

Closures und Rekursion
Mit Closures können Rekursionen, fast so wie in Methoden programmiert werden. Aber nur fast: Es muss zuerst die Closure definiert werden, dann kann sie in der Implementierung selbst referenziert und damit rekursiv benutzt werden. Darum erstreckt sich die Definition der Closure innerFindAll über zwei Zeilen (Zeile 5+6).

Implizites Return
Die Zeile 13 return innerFindAll(folder) kann auf das Schlüsselwort return verzichten. Der letzte Wert in einem Codeblock wird in groovy immer zurückgeliefert. Ich finde aber, dass ein explizites return das lesen erleichtert.

Unbenannte Closure
Die letzte Closure im Codebeispiel ist eine unnamed Closure. Es ist die Sequenz: {it.endsWith(".xml")} Die geschweiften Klammern dienen dazu, eine Closure zu definieren. Da die Closure hier als Aufrufparameter genutzt wird und danach obsolet ist, kann sie unnamed definiert werden.

Unbenannte Variable it
Wer kennt nicht Cousin It? Es gibt auch ein interessantes "Es" in groovy. Jede Closure hat implizit it als Variable und kann ohne zutun benutzt werden. Es spart einiges an Codierarbeit, aber ich nutze es nur in Closures die nicht an verschiedenen Stellen aufgerufen werden, da mit der Automatik auch das Mittel der Dokumentation flöten geht. Als Faustregel: In unbenannten Closures ist it ok, ansonsten ist ein Namen vorzuziehen, sorry Cousin It.

Lesekurs für Java Programmierer
Es ist für Java Programmierer, die wohl das Gros der groovy Nutzer ausmachen, der schwierige Teil, aufmerksam die Klammern zu lesen. Die {} haben  in groovy wie zuvor beschrieben, eine neue Wertigkeit. Die Zeile 8 definiert z.B. die Closure each, es wird schnell mal () verwendet, was einem Methodenaufruf entspricht. 
Achtung auch bei []. Es ist keine Array Definition, sondern die Definition einer Liste. So auch im Beispiel auf Zeile 4.
Die resultierenden Fehlermeldungen deuten nicht immer direkt auf die Ursache.
Es ist eine systematische Schwäche von Scriptsprachen, da es nicht zu so deutlichen Fehlermeldungen kommt wie etwa in Java. Aber durchhalten, nach kurzer Zeit hat man die Sematiken der Fehlermeldungen verstanden und ist so schnell mit der Korrektur wie in Java.


Keine Semikolons wo es geht
Es ist dort wo ein Zeilenumbruch vorkommt, nur sehr selten nötig Semikolons zu setzen. Wenn man beim Umstieg auf groovy Konsequent die Semi's weglässt, schränkt man sich kaum ein, zwingt sich aber das man ein Mindestmaß an guter Formatierung beibehält.

Sourcecode
Der Quellcode als Kopiervorlage, d.h. ohne die Zeilennummern.

findAllFiles = { File folder, Closure fileMatch ->
     def fileNameFilter = {dir, name -> fileMatch(name)} as FilenameFilter
     def dirFilter = {File file -> !file.isFile()} as FileFilter
     def files = []
     def innerFindAll
     innerFindAll = {File lFolder ->
         if(!lFolder.isFile()){
             lFolder.listFiles(dirFilter).each{ innerFindAll(it)}
             files.addAll(lFolder.listFiles(fileNameFilter ))
         }
         return files
     }
     return innerFindAll(folder)
}

Kommentare:

  1. Netter Artikel, Bernd.

    Hier eine leicht kürzere Variante (noch lesbar?), die hoffentlich das gleiche tut:

    def files = []
    new File(folder).traverse(type: groovy.io.FileType.FILES, nameFilter: ~/.*\.xml$/) { it ->
    files << it
    }
    return files

    Ich vermisse noch eine Art findRecursive() Methode in der File-Klasse vom GDK, aber Danke Metaprogrammierung kann man die ja nachrüsten. :-)

    Viele Grüße
    Thomas

    AntwortenLöschen
  2. Danke für die Ergänzung Tom. Deine Lösung ist wirklich was für groovy Profis.

    AntwortenLöschen