Login
Newsletter
Werbung

So, 4. November 2001, 00:00

Schleifen in der Shell vermeiden

Einleitung

Es gibt unter UNIX/Linux zu bestimmten Problemstellungen Standardlösungen, die so gut wie jeder während seiner ersten UNIX/Linux-Schritte kennenlernt. Beispielsweise

  • "Wieviele Dateien und Verzeichnisse stehen in einem Verzeichnis?"

ls(1) kennt man sowieso, und wc(1) kann man sich wegen des Namens meist gut merken... Die Antwort lautet also

ls | wc -l

Man könnte dies schon fast das "Hello, World!" der Shell nennen. Niemand käme auf die Idee, statt dessen folgendes einzutippen:

COUNTER=0
ls | while read LINE ; do
 let COUNTER=COUNTER+1
done
echo $COUNTER

Viel Tippaufwand und fehleranfällig. Ganz klar, dass man lieber die schon existierenden Tools verwendet. Das Beispiel soll letzten Endes auch nur aufzeigen, dass wc(1) intern eine Schleife haben muss, um seine Arbeit zu tun. Und diese nutzt man meist, ohne darüber nachzudenken.

Fast alle UNIX-Kommandos besitzen intern Schleifen. ls(1) und wc(1) bearbeiten in einer Schleife alle übergebenen Dateien bzw. Zeilen, die gelesen werden. grep(1) durchsucht alle Zeilen einer Datei nach einem Muster, ohne dass man dies ausprogrammieren müsste. sed(1) und awk(1) lassen pro Eingabezeile eine bestimmte, programmgesteuerte Verarbeitung zu, aber auch hier muss man die Schleife über das Lesen aller Zeilen nicht ausprogrammieren. Sie gilt implizit.

Das Problem

Wenn man sich aber Foren ansieht, in denen es sich um Shellskripte und deren Programmierung dreht, findet man immer wieder Aufgabenstellungen der folgenden Art:

  • "Ändere bei allen Dateien die Endung .zip zu .mp3!"

Und als Ergebnis ungefähr diesen Code:

ls *.zip | while read OLD_FILENAME ; do
 NEW_FILENAME=$(echo "$OLD_FILENAME" | sed 's/\(.*\)\.zip/\1.mp3/')
 mv "$OLD_FILENAME" "$NEW_FILENAME"
done

Diese Lösung ähnelt der von Hand programmierten Schleife, die wir vorhin mit "Würde nie einer machen!" abgetan haben. Stellt sich doch die Frage, ob es nicht auch hier eine bessere Lösung gibt, die die explizit ausprogrammierte Schleife umgeht!

Wenn man sich den Code näher betrachtet, wird man bemerken, dass sich diese Lösung explizit Mühe gibt, um dem sed(1)-Kommando jeweils pro Aufruf nur einen Dateinamen einzufüttern. Dieses generiert aus dem alten Dateinamen destruktiv den neuen, den die Datei bekommen soll. Dabei ist sed(1) ohne weiteres in der Lage, viele Dateinamen auf einmal zu verarbeiten. Man denke an die implizite Schleife des sed(1), über alle Eingabezeilen zu arbeiten. Weiterhin sollte man den Performanceverlust im Auge behalten, wenn man pro Datei einen eigenen sed(1) laufen lässt, statt mit einem Aufruf alle Dateinamen zu bearbeiten.

Es sieht also tatsächlich so aus, als ob diese Schleife unnötig ausprogrammiert ist.

Lösung

Wie sähe nun eine bessere Lösung aus? An einem mv(1) pro Datei wird man nicht herumkommen. Aber aus den vielen sed(1)-Aufrufen wollen wir jetzt nur noch einen machen. Damit einem dabei nicht die Zuordnung zwischen altem und neuen Namen verloren geht, muss sed(1) beide in eine Zeile packen. Das Kommando dazu könnte so lauten:

ls *.zip | sed 's/\(.*\)\.zip/& \1.mp3/'

Aus dem Dateinamen datei.zip würde so datei.zip datei.mp3 gemacht werden. Fehlt eigentlich nur noch das mv(1)-Kommando! Und genau das lassen wir uns ebenfalls durch den sed(1) mit einfügen:

ls *.zip | sed 's/\(.*\)\.zip/mv & \1.mp3/'

Gut. Aus datei.zip wird nun mv datei.zip datei.mp3 erstellt. Dies ist aber leider reiner Text, der so als Kommando nicht ausgeführt wird! Stehen wir vor einem Scherbenhaufen?

Nein, natürlich nicht. Auch normale Shellskripte sind nichts weiter als "reiner Text"! Und wenn man dies erst einmal verinnerlicht hat, ist die weitere Vorgehensweise klar: Man muss nur die on-the-fly erstellte Liste von mv(1)-Kommandos einer Shell zur Ausführung übergeben. Die Shell ist wie jedes gute UNIX-Programm natürlich auch in der Lage, von der Standard-Eingabe zu lesen. Auch hier wieder die implizite Schleife: "Lies Kommandos bis zum Ende der Datei!" Und so ist der letzte Schritt jetzt komplett:

ls *.zip | sed 's/\(.*\)\.zip/mv & \1.mp3/' | sh

Keine handprogrammierte Schleife mehr!

Ergebnis

Kurze Rechnung: Gesetzt den Fall, man hätte 1000 Dateien, so würden durch die alte Lösung 2001 Prozesse verwendet - je 1000 sed(1), 1000 mv(1) und 1 ls(1). Bei der neuen Lösung werden lediglich 1 ls(1), 1 sed(1), eine sh(1) und 1000 mv(1) verwendet: 1003 Prozesse.

Gegenüber der ersten Lösung erspart man sich sowohl Laufzeit durch die geringere Anzahl zu startender Prozesse, als auch Komplexität - man nutzt die internen Schleifen der UNIX-Kommandos, statt explixit Schleifen in der Shell auszuprogrammieren. Als Nachteil könnte man die erhöhte Komplexität im Regulären Ausdruck zum sed(1) sehen, allerdings kommt man in Sachen Textbearbeitung unter UNIX sowieso nicht um die Regulären Ausdrücke herum - ob man nun perl, awk, vi, emacs, expr oder eben sed benutzt.

Erweiterung

Während der Entwicklung, beim Debuggen oder in anderen speziellen Situationen möchte man vielleicht sehen, welche Kommandos alle ausgeführt werden. Hier kann man wieder auf die Shell zurückgreifen: Man übergibt der Shell zusätzlich die Option -v (verbose). Dadurch gibt die Shell jedes Kommando, dass sie ausführt, vorher aus. Die gesteigerte Version davon wäre die Option -x (executed command), wodurch die Shell auch noch den genauen Aufruf des jeweiligen Kommandos ausgibt. Der Unterschied zwischen -v und -x liegt darin, dass -v die Zeilen so ausgeben lässt, wie sie im Skript stehen, und -x so, wie die Shell nach diversen Ersetzungen das Kommando tatsächlich aufruft.

(Wenn man sich die Auswirkungen einmal interaktiv ansehen möchte, schaltet set -xv beide Optionen ein, ein set - beide wieder aus.)

Aber manchmal ist selbst das noch zu gefährlich, und man möchte lediglich sehen, was die Shell an Anweisungen bekäme, ohne Gefahr, dass diese auch ausgeführt werden könnten. An dieser Stelle verwendet man statt der Shell einfach ein anderes Kommando: cat(1). Das obige Beispiel könnte man also einfach um einen Debugschalter erweitern:

if [ "$1" = "-d" ] ; then
 # Debuggen, keine Ausführung
 DO_IT_COMMAND=cat
else
 # Normalfall, Programm ist scharf
 DO_IT_COMMAND=sh
fi
ls *.zip | sed 's/\(.*\)\.zip/mv & \1.mp3/' | $DO_IT_COMMAND
exit $?

Je nach Debug-Schalter wird entweder die Shell zur Ausführung der Kommandos herangezogen oder aber durch das cat(1) lediglich eine Ausgabe auf die Standardausgabe erreicht. Zum Schluss reicht unser Skript noch den Exitcode des letzten Kommandos (cat(1) oder sh(1)) als seinen eigenen Exitcode weiter.

Nachwort

Das Prinzip der Schleifenvermeidung ist bei vielen Shellskript-Aufgaben verwendbar und hoffentlich in diesem Text auch klar hervorgekommen. Andere Gesichtspunkte wurden dem untergeordnet. Das herausgearbeitete Beispiel ist daher natürlich noch entwicklungsfähig. Verbesserungen wären beispielsweise ein vernünftiges Parsen von Optionen mittels getopts(1) oder an die Parametrisierung der gewünschten Endungen. Dies sei dem geneigten Leser zur Übung überlassen. Wer sich nicht an Shellskript-Basteleien ergötzen kann, sei noch darauf hingewiesen, dass mittlerweile auch auf vielen UNIX-Plattformen Kommandos existieren, die genau die o.a. Aufgabenstellung mit einem Aufruf lösen. Jedoch sind die leider nicht auf allen UNIX-Systemen unter dem gleichen Namen oder mit der gleichen Syntax zu erreichen.

Viel Spass beim Basteln!

Kommentare (Insgesamt: 0 )
Pro-Linux
Pro-Linux @Facebook
Neue Nachrichten
Werbung