Mit dem heutigen Blogartikel möchte ich eine kleine Artikelserie mit dem Titel „Haskell für Einsteiger“ beginnen. Die Serie richtet sich an Leser mit Programmiererfahrung, die Lust auf Haskell haben, bisher aber den Einstieg in die Sprache nicht richtig geschafft haben.

Ich werde versuchen, Ihnen durch praxisnahe Beispiel und direkt lauffähigem Code den Einstieg zu erleichtern. Um loszulegen benötigen Sie lediglich eine Installation der Haskell Platform sowie ein Checkout des git Repositories zu dieser Artikelserie. Wir werden hin und wieder auch ausgewählte Paket über hackage, dem Haskell-Paket-Repository, installieren.

Ich werde mir Mühe geben, viele Dinge detailliert zu erklären. Allerdings würde es den Rahmen dieses Blogs sprengen, auf jedes Detail einzugehen. Hierzu sei das Studium des einen oder anderen Haskell Tutorials oder Buchs empfohlen. Natürlich können Sie Rückfragen auch als Kommentar zu diesem Artikel stellen.

Im heutigen Artikel implementieren wir eine abgespeckte Version des Unix-Tools tail . Dieses Kommandozeilenprogramm zeigt die letzten Zeilen einer Datei an; per Default werden die letzten 10 Zeilen angezeigt, über die Kommandozeilenoption -n N kann die Zeilenanzahl aber auch auf die Zahl N gesetzt werden.

Um zu schauen, ob Ihre Haskell Installation funktioniert, sollten Sie spätestens jetzt ein Checkout des git Repositories machen. Dann führen Sie im Verzeichnis haskell-for-beginners folgende Befehle aus:

$ cabal install --only-dependencies # installiert zusätzliche Pakete $ cabal build # baut das Programm

Wenn alles gut geht, finden Sie jetzt das kompilierte Programm unter dist/build/tail/tail . Wenn Sie nun dieses Programm mit der Option --help aufrufen, sollten Sie folgende Ausfgabe sehen:

$ dist/build/tail/tail --help USAGE: tail [-n N] [FILE ...]

So, jetzt schauen wir uns aber den Code unserer tail-Implementierung an. Sie finden den Code in src/Tail.hs . Wir starten zunächst mit den Imports für eine Datenstruktur für Sequenzen. Das Besondere an dieser Datenstrukturen ist, dass man ein neues Element links oder rechts an eine Sequenz in Zeit O(1) anhängen kann. (Wir könnten auch normale Listen verwenden. Allerdings hat hier das Anhängen an das rechte Ende einer Liste lineare Laufzeit.)

import qualified Data.Sequence as Seq import qualified Data.Foldable as F -- nötig für toList auf Sequenzen

Das Modul Safe benötigen wir, um beim Parsen der Kommandozeilenargument das Argument der Option -n in eine Zahl zu konvertieren. Haskell hat zwar standardmäßig eine read -Funktion für diesen Zweck, aber hierbei führt das Fehlschlagen der Konvertierung direkt zu einem Programmabsturz. Das Safe -Modul stellt eine bessere Variante der read -Funktion zur Verfügung.

import Safe

Als nächstes folgen Module, die uns Zugriff auf die Kommandozeilenargumente, auf Funktionen zum Beenden des Programms sowie auf Funktionalität zur Ein-/Ausgabe bieten.

Das ByteString -Modul schließlich stellt Operation für den Umgang mit Byte-Arrays zur Verfügung.

import qualified Data.ByteString as BS

Wenn Sie selbst ein Haskell Programm schreiben, aber nicht wissen welche Funktionen in welchen Modulen zu finden sind, gibt es mindestens drei Möglichkeiten, dies herauszufinden:

Sie benutzen hoogle oder hayoo, zwei Suchmaschine für Haskell-API-Dokumentation.

Sie studieren die Übersicht der gängigen Haskell Module.

Sie schauen sich auf hackage um.

Jetzt können wir mit der eigentlichen Implementierung loslegen. Wir starten mit der Funktion printTail , welche die letzten lines Zeile einer Datei file ausgibt.

printTail :: Int -> FilePath -> IO () printTail lines file = if file == "-" then doWork stdin else withFile file ReadMode doWork where doWork :: Handle -> IO () doWork h = do list <- collectLines lines h mapM_ printLine list printLine bs = do BS . hPut stdout bs BS . hPut stdout newline newline = BS . pack [ 10 ]

Die printTail -Funktion öffnet erst die zu lesende Datei, dabei wird - als Standard-Input interpretiert (wie üblich). Für echte Dateien benutzten wir die Funktion withFile . Diese Funktion öffnet eine Datei, führt mit der geöffneten Datei die übergebenen Aktion aus (hier: doWork ) und stellt dann sicher, dass die Datei nach Abschluss der Aktion oder im Fehlerfall geschlossen wird.

Die Funktion doWork nimmt dann eine Handle , welches in Haskell eine geöffnete Datei repräsentiert. Mittels der noch zu implementierenden Funktion collectLines werden die letzten lines Zeilen aus der Datei extrahiert und anschließend mit der Hilfsfunktion printLine ausgegeben. Der Aufruf mapM_ printLine list ruft dabei die printLine -Funktion für jedes Element der Liste list auf.

Noch ein Wort zum Typ von printTail : in der Typsignatur zu Beginn der Funktion legen wir diesen auf Int -> FilePath -> IO () zurück, die Funktion nimmt also zwei Argumente vom Typ Int und FilePath und hat als Rückgabetyp IO () . Der Rückgabetyp bedeutet, dass die Funktion Seiteneffekte hat (der IO Teil des Typs) und kein Ergebnis liefert (der () Teil des Typs, gesprochen „Unit“).

In der collectLines -Funktion sammeln wir nun die richtigen Zeilen aus der Datei auf. Dazu benutzen wir die Datenstruktur aus Data.Sequence . Zu Anfang ist die Sequenz leer ( Seq.empty ). Dann hängen wir jede Zeile der Datei rechts an die Sequenz mittels Seq.|> an, und sorgen anschließend dafür, dass überschüssige Zeilen am linken Ende entfernt werden. Dazu benutzen wir Seq.viewl , um einen „View“ auf das linke Ende zu bekommen. Wenn das linke Ende leer ist ( Seq.EmptyL ) ist das ein Bug, anderenfalls matchen wir mittels _ Seq.:< rest auf das erste Element _ und den rest .

collectLines :: Int -> Handle -> IO [ BS . ByteString ] collectLines lines handle = loop Seq . empty where loop acc = do isEof <- hIsEOF handle if isEof then return ( F . toList acc ) else do l <- BS . hGetLine handle loop ( trim ( acc Seq .|> l )) trim seq = if Seq . length seq <= lines then seq else case Seq . viewl seq of Seq . EmptyL -> error ( "Bug in tail: argument to -n should not be negative" ) _ Seq .:< rest -> rest

Da wir keine Annahme über das Encoding der zu lesenden Daten machen wollen, lesen wir rohe Byte-Arrays (Type BS.ByteString ) aus der Datei und verzichten auf eine Konvertierung nach String .

Die hier vorgestellte Implementierung von collectLines ist sicher nicht die effizienteste, da wir immer die komplette Datei lesen. Die C-Implementierung von tail unter BSD ist hier z.B. wesentlich schlauer, denn hier wird mit Memory-Mapped IO vom Ende der Datei her immer mehr Zeilen in den Speicher geladen, bis die gewünschte Anzahl Zeilen erreicht ist. So etwas ist natürlich auch mit Haskell möglich, es gibt z.B. auf Hackage einen Wrapper für den mmap -Systemcall. Falls ein Leser es wünscht, liefere ich gerne eine effizientere Implementierung von collectLines mittels mmap nach, in diesem Falle bitte einen entsprechenden Kommentar schreiben.

Als nächstes widmen wir uns dem Parsen der Kommandozeilenargumente. Dazu definieren wir zunächste einen Datentyp für die Optionen, die wir erwarten:

data TailOpts = TailOpts { to_files :: [ FilePath ] , to_lines :: Int }

Das Parsen der Kommandzeilenargument funktioniert über Pattern-Matching auf der Liste der Argumente:

parseArgs :: [ String ] -> IO TailOpts parseArgs args = if "-h" ` elem ` args || "--help" ` elem ` args then usage else case args of ( "-n" : nStr : rest ) -> case readMay nStr of Just n | n >= 0 -> return ( TailOpts { to_files = rest , to_lines = n }) _ -> abort "Argument to -n must be a non-negative int." [] -> return ( TailOpts { to_files = [ "-" ], to_lines = defaultLines }) _ -> return ( TailOpts { to_files = args , to_lines = defaultLines }) where usage = abort "USAGE: tail [-n N] [FILE ...]" defaultLines = 10 abort msg = do hPutStrLn stderr msg exitWith ( ExitFailure 1 )

In der main -Funktion bauen wir schließlich alles zusammen:

main = do args <- getArgs opts <- parseArgs args mapM_ ( printTail ( to_lines opts )) ( to_files opts )