3 Punkte von GN⁺ 2023-11-24 | 1 Kommentare | Auf WhatsApp teilen
  • Auf Unix-ähnlichen Systemen kann es eine ausführbare Datei /bin/[ geben, deren Name aus einem einzigen Symbol besteht; auch Syntax, die wie ein Shell-Bedingungsausdruck aussieht, basiert tatsächlich auf dem Ausführen von Befehlen und deren Exit-Codes
  • test wertet Ausdrücke aus und gibt 0 zurück, wenn sie wahr sind, und 1, wenn sie falsch sind; wird es als [ aufgerufen, prüft es zusätzlich, ob das letzte Argument ] ist
  • Viele Shells stellen test und [ auch als Built-in-Befehle bereit, daher können sich Fehlermeldungen oder Verhalten der externen Programme /bin/test und der Shell-internen Implementierung unterscheiden
  • Die Bash-Erweiterung [[ ist kein externer Befehl, sondern eingebaute Syntax; sie wendet daher andere Regeln als [ an, etwa indem sie long* wie im Beispiel nicht per Glob erweitert, sondern als literalen String vergleicht
  • Für portable Skripte ist [ die richtige Wahl; wenn es Bash-only ist, ist es besser, konsequent [[ zu verwenden — man sollte die Unterschiede bei den Erweiterungsregeln beider Varianten aber kennen

Was hinter /bin/[ und /bin/test steckt

  • Auf Unix-Systemen kann es eine ausführbare Datei /bin/[ geben, deren Name ein einzelnes Symbol ist
    • Der Beispielbefehl ls /bin/? zeigt /bin/[ an
  • /bin/[ und /bin/test können auf dasselbe Binary verweisen
    • Im Beispiel erscheinen die beiden Pfade als Dateien mit derselben inode und Größe
    • Allerdings müssen sie nicht auf jedem System Hardlinks sein
  • test ist ein Programm, das in der Shell Ausdrücke auswertet
    • String-Vergleiche
    • Zahlenvergleiche
    • Prüfung von Dateibedingungen
  • Ist das Ergebnis der Auswertung wahr, gibt es den Exit-Code 0 zurück, ist es falsch, 1

Warum [ wie ein Befehl funktioniert

  • test a = b sieht kaum wie ein Bedingungsausdruck aus, aber schreibt man dieselbe Logik als [ a = b ], wirkt sie vertrauter
  • [ sieht wie separate Syntax aus, ist tatsächlich aber ein Befehlsaufruf
    • if [ a = b ]; then ... fi führt den Befehl [ aus und prüft den Exit-Code
    • Wird das Programm als [ aufgerufen, prüft es, ob das letzte Argument die schließende eckige Klammer ] ist
  • Eine if-Anweisung interpretiert den Bedingungsausdruck nicht direkt, sondern verzweigt anhand des Exit-Codes des übergebenen Befehls
    • test a = a; echo $? ergibt 0
    • test a = b; echo $? ergibt 1
    • [ a = a ]; echo $? ergibt 0
    • [ a = b ]; echo $? ergibt 1
  • Im selben Sinne lassen sich auch true und false als Hilfs-Binaries betrachten, die Exit-Codes zurückgeben

Unterschiede zwischen externem Binary und Shell-Built-in

  • Weil test und [ in Shell-Skripten häufig verwendet werden, implementieren die meisten Shells sie auch als Built-in-Befehle
  • Selbst bei gleicher Eingabe kann sich die Ausgabe eines externen Binarys von der eines Shell-Built-ins unterscheiden
    • /bin/test a b ergibt test: a: unexpected operator
    • test a b ergibt dash: 2: test: a: unexpected operator
  • Solche Unterschiede können nicht nur bei test und [, sondern auch bei scheinbar einfachen Befehlen wie echo auftreten
  • Da sich die Built-in-Implementierungen je nach Shell unterscheiden, kann sich das Verhalten eines Skripts je nach ausführender Shell ändern

Die separaten Regeln der Bash-Erweiterung [[

  • [[ ist eine Bash-Erweiterung und kann die Verwendung von [ ersetzen
  • Der größte Unterschied ist, dass [[ immer eingebaute Syntax ist
    • Anders als [, das als externes Binary ausgeführt werden kann, erlaubt [[ Bash, die Sprachregeln innerhalb des Ausdrucks zu ändern
  • Im Glob-Beispiel verhalten sich [ und [[ unterschiedlich
    • Nach touch long-name gibt [ long* = long-name ] && echo match match aus
    • Auf die Argumente des Befehls [ werden die normalen Shell-Erweiterungsregeln angewendet, sodass long* zu long-name im Verzeichnis erweitert wird
    • [[ long* = long-name ]] && echo match gibt nichts aus
    • [[ behandelt long* als literalen String und vergleicht ihn unverändert mit long-name, was fehlschlägt
  • In Bash-only-Skripten lassen sich mit [[ auch Funktionen wie Regex-Matching =~ verwenden

Was man in Skripten wählen sollte

  • Für portable Shell-Skripte sollte man [ verwenden
  • test kann ebenfalls verwendet werden, ist aber nicht die übliche Wahl
  • Wenn ein Skript ausschließlich für Bash gedacht ist, ist es besser, konsequent [[ zu verwenden
  • Die Shell selbst hat ebenfalls Ausdrucksoperatoren wie !, && und ||
    • Diese Operatoren arbeiten anhand des Exit-Status von Befehlen
    • grep ^hello$ ... && grep ^bye$ ... führt dazu, dass der gesamte Exit-Code 0 ist, wenn beide Befehle erfolgreich sind
    • Scheitert das erste grep, wird auch der Befehl hinter && nicht erfolgreich ausgeführt, und der gesamte Exit-Code wird 1
  • Daher lassen sich test-/[-Ausdrücke und logische Operatoren der Shell in einer Bedingung kombinieren
    • Beispiel: [ a = b ] || grep -q ^hello$ /usr/share/dict/words
  • POSIX verlangt nicht, dass /bin/[ und /bin/test Hardlinks sind
    • Unter NetBSD waren sie Hardlinks
    • macOS Catalina stellt separate Kopien desselben Binarys bereit
    • Debian testing stellt unterschiedliche Binaries bereit
    • Die POSIX-Spezifikation verlangt nicht, dass die beiden Dateien Links sind

1 Kommentare

 
GN⁺ 2023-11-24
Hacker-News-Kommentare
  • Ich bin der Autor des Originals. Danke fürs Teilen, und ich freue mich, dass es es bis auf die Frontpage geschafft hat. Im Titel sollte wahrscheinlich (2020) stehen, und „test“ bezeichnet tatsächlich einen Befehl, daher wäre es besser, es nicht großzuschreiben.
    Es gibt auch einen verwandten Beitrag, den ich 2021 geschrieben habe; er behandelt sogar den [[-Operator von Bash und dürfte in diesem Kontext interessant sein: https://jmmv.dev/2021/08/useless-use-of-gnu.html

    • [[ ist streng genommen kein eingebauter Befehl, sondern im Kern eher ein Syntaxelement. Intern wird vermutlich ein kaum direkt zugänglicher Builtin verwendet, aber interessant ist, dass auch ]] ein reserviertes Wort ist, obwohl es gar nicht an einer Stelle stehen kann, an der ein reserviertes Wort sinnvoll wäre.
      In einigen Nicht-Bash-Shells braucht man das Schlüsselwort function, um bestimmte Arten von Funktionen zu deklarieren. Bei $(shell) in make kann der Performance-Unterschied messbar sein, wenn viele Targets gebaut werden. Wenn allerdings nichts passiert, ist es dennoch ein Nachteil; normalerweise ist es daher richtig, include zu verwenden, wenn man eine Regenerierung auslösen will. Dass GNU POSIX ignoriert, ist völlig vertretbar, denn POSIX ist für die Lösung der meisten realen Probleme nicht besonders nützlich.
    • Viele der in https://jmmv.dev/2021/08/useless-use-of-gnu.html behandelten GNU-Erweiterungen sind bei interaktiver Nutzung sehr nützlich. Auch das Suchen im aktuellen Verzeichnis ohne explizites . ist hilfreich, und Optionen nachträglich an den gerade eingegebenen Befehl anzuhängen, ist wirklich bequem. Bei Befehlen, die so etwas nicht unterstützen, ärgere ich mich immer.
      In Skripten ist es meist sinnvoll, sich an POSIX sh zu halten. Zumindest sollte man wissen, ob man Bash-spezifische Syntax verwendet.
    • Der Beitrag beklagt, dass GNU-Erweiterungen wie --ignore-case und set -o pipefail die Portabilität von Skripten verringern. Das stimmt an sich.
      Er erklärt aber nicht, warum Linux-Nutzer sich groß um Portabilität kümmern sollten. OpenBSD und FreeBSD sind zwar quicklebendig, haben aber so wenige Nutzer, dass sie mir kein besonders großer Grund zur Sorge zu sein scheinen. Man kann der Fairness halber sagen, dass man solche Betriebssysteme berücksichtigen sollte, aber wo hört dieser Maßstab auf? Muss man auch Obskures wie vxWorks berücksichtigen? BusyBox und Alpine sind interessanter, aber die Änderungen sind so groß, dass ohnehin fast immer ein separates Porting nötig ist. Gibt es einen anderen überzeugenden Grund, sich um Nicht-GNU-Ökosysteme zu kümmern?
    • Ist etwas wie if [ a = b ] || grep -q ^hello$ /usr/share/dict/words; then echo "test failed and grep succeeded"; fi nicht einfach ganz normale, alltägliche Shell-Nutzung?
    • Nebenbei: Die Blog-Software scheint den Titel kaputtzumachen. Zum Beispiel wird er als make $(shell …) expansion ausgegeben, sollte aber eigentlich make $(shell ...) expansion sein.
      Im Text steht es korrekt mit drei Punkten, nicht mit einem Auslassungszeichen, also stimmt auch mldr selbst nicht. Vermutlich wirken hier zwei voneinander unabhängige Bugs gleichzeitig.
  • Wenn man den letzten Punkt noch einen Schritt weiter treibt, kann man den if-Block selbst ebenfalls entfernen. Aus if [ a = b ]; then echo "Oops!"; else echo "Expected; phew!"; fi wird [ a = b ] && echo "Oops!" || echo "Expected; phew!".
    Ich weiß nicht, wie oft man das so machen sollte, aber es ist gelegentlich nützlich, wenn man bedingt Debug-Ausgaben auf Standardfehler schreiben will, etwa [ "$debug" ] && echo "what's going on" >&2. Weil if-Blöcke normale Befehle prüfen, ist auch so etwas möglich wie if grep -q 'debug' /var/log/nginx/access.log; then echo "Debug request found!"; fi. Was ich noch nicht untersucht habe, ist, ob man [ $(expr 1 + 1) -eq 2 ] && [ $(expr 2 + 2) -eq 3 ] schreiben sollte oder den eingebauten logischen UND-Operator von test verwenden sollte, also [ $(expr 1 + 1) -eq 2 -a $(expr 2 + 2) -eq 4 ]. Wenn Performance keine Rolle spielt, scheinen beide aus ähnlichen Gründen vertretbar.

    • [ a = b ] && echo "Oops!" || echo "Expected; phew!" sollte man nicht als allgemeine Regel übernehmen. Bash wird diese Zeile vermutlich als ([ a = b ] && echo "Oops!") || echo "Expected; phew!" interpretieren.
      Wenn also die Befehlsfolge nach && fehlschlägt, wird der Code nach || trotzdem ausgeführt. Wenn zum Beispiel >/dev/full echo "strings match" wegen eines Schreibfehlers fehlschlägt, wird "strings don't match" ausgegeben, obwohl die Strings gleich waren. Das ist nicht dieselbe Semantik wie bei einem if-Block.
    • Solche Verkürzungen sollte man besser vermeiden. Wenn man das set -e verwendet, das man verwenden sollte, dann funktioniert if [ a = b ]; then echo "Oops!"; fi wie erwartet, aber [ a = b ] && echo "Oops!" beendet sich mit einem Fehler, wenn der Ausdruck a nicht gleich b ist.
    • Laut POSIX sind die binären Primärausdrücke -a, -o sowie die Operatoren ( und ) als obsoleszent markiert. Details stehen unter „Application Usage“ auf https://pubs.opengroup.org/onlinepubs/9699919799/utilities/t....
    • Ob man -a oder zwei Tests mit && verwendet: Wenn man die arithmetische Auswertung von Bash nutzen kann, muss man nicht extra expr ausführen: [ $((1+1)) -eq 2 ]
  • Vor ein paar Jahren habe ich angefangen, [ nicht mehr zu verwenden. test verstärkt den Punkt, dass das keine Syntax ist, sondern einfach ein Befehl wie jeder andere. Und man test ist deutlich angenehmer, als sich durch man bash zu wühlen.

    • Das passt nicht ganz. GNU Coreutils hat nicht nur eine Manpage für man test, sondern auch eine für man [.
      In Bash gibt es als schnellen Spickzettel help test. Der Befehl [ ist sehr alt und war bereits 1979 in Version 7 Unix enthalten.
  • Einverstanden. [ und das Bash-spezifische [[ sorgen tatsächlich für viel Verwirrung darüber, was eigentlich passiert, sodass man sich nur schwer sicher sein kann
    Allerdings hatte die Tatsache, dass [[ garantiert ein Builtin ist, sicher ihren Zweck in Zeiten, in denen die Performance von Shell-Skripten relevant war – und das ist gar nicht so lange her

    • Nach der Lektüre dieses Artikels werde ich wohl einfach test verwenden. Ich schreibe nicht oft Bash-Skripte und stolpere deshalb ständig über if-Anweisungen, besonders über die Leerzeichenregeln. Nachdem ich den Grund gesehen habe, ist es völlig naheliegend geworden, und mit test wird klarer, dass man einfach nur Argumente übergibt
  • Die größte Falle bei [ und test ist das Verhalten mit genau einem Argument. Zum Beispiel könnte man [ -n $FOO ] schreiben, um zu prüfen, ob eine Variable nicht leer ist
    Wenn FOO aber nicht gesetzt ist, wird es nicht zu einem leeren String erweitert, sondern zu gar nichts, sodass daraus [ -n ] wird. POSIX verlangt, dass die Ein-Argument-Form von [ erfolgreich ist, wenn dieses Argument – hier "-n" – nicht leer ist. Daher meldet es fälschlich, dass $FOO nicht leer sei. Variablen müssen unbedingt in Anführungszeichen gesetzt werden

    • Der letzte Satz sollte ganz nach vorn. Setze Variablen in Anführungszeichen. Die Falle steckt nicht in der Spezifikation des Builtins test selbst, sondern in der Shell an sich
      Das erwähnte Verhalten ergibt Sinn. [ "$FOO" ] ist nämlich die Form, die prüft, ob der Inhalt – egal welcher, sogar "-n" – nicht leer ist
    • Für Skripte sollte man ShellCheck laufen lassen
    • Wenn $FOO Leerzeichen enthält, wird es zu mehreren Argumenten erweitert. Einfach immer Variablen in Anführungszeichen setzen
    • [ x"$FOO" != x"" ]
    • In diesem Fall würde ich [ -n "${FOO?}" ] verwenden, damit das Skript sofort abbricht, wenn $FOO null oder nicht gesetzt ist
  • chubot hat ein interessantes Dokument geschrieben, das sich mit den subtileren Aspekten von test/[/[[ befasst. Auch die anderen Beiträge in diesem Blog erklären ziemlich interessant die seltsamen Seiten der Shell
    ¹ https://www.oilshell.org/blog/2017/08/31.html
    ² https://www.oilshell.org/blog/2016/11/18.html

  • Ich wusste überhaupt nicht, dass [ ein Programm ist, und finde es etwas lustig, dass es prüft, ob das letzte Argument eine schließende eckige Klammer ist
    Immerhin erklärt das, warum auf beiden Seiten der eckigen Klammern Leerzeichen nötig sind

  • [[ ist Bash-spezifisch. Wenn man weiß, dass man nur Bash verwenden wird, kann man es benutzen. Der Artikel behandelt die Details gut

    • zsh gibt es auch :)
      https://zsh.sourceforge.io/Doc/Release/Conditional-Expressio...
    • Man kann einfach immer [[ verwenden
      Es gibt es auch in zsh und ksh, und ich bin mir ziemlich sicher, dass es eigentlich in ksh von 1988 oder früher begonnen hat
    • Also: test und [ sind in POSIX spezifiziert und existieren üblicherweise als echte Binaries. Sie können allerdings von Shell-Builtins überdeckt werden
      [[ dagegen ist nicht in POSIX spezifiziert und existiert normalerweise nur als Shell-Builtin
    • Wenn man nicht ausdrücklich eine völlig andere Shell wie Fish verwendet, weiß ich nicht, warum man nicht Bash nutzen sollte
      Sich nur am kleinsten gemeinsamen Shell-Nenner zu orientieren, wirkt wie eine völlig archaische Sorge
  • Ich habe nicht ganz verstanden, warum die letzte if-Anweisung verwirrend sein soll. Wenn es daran liegt, dass man beim ersten Lernen von Shell-Skripten normalerweise annimmt, [ sei Teil der Bash-Skriptsprache und nicht einfach ein anderes Programm, dann verstehe ich es jetzt. Falls nicht, wäre es gut zu erklären, warum das überraschend ist

    • Selbst wenn man nicht weiß, dass [ ein Binary ist, verstehe ich nicht so recht, warum das verwirrend sein soll. Es sieht nach ganz normalem Bash aus
  • Ich habe starke Vorlieben bei Shells, die mit dem Großteil der Welt nicht besonders gut zusammenpassen
    Meiner Meinung nach sollte man [ niemals verwenden, sondern nur test. [ verleitet zu dem Irrglauben, der Mechanismus sei Teil der Sprachsyntax, ist in Wirklichkeit aber nur ein weiteres „Programm“. Zu „Programm“ zähle ich hier auch Builtins und Funktionen. if/||/&& betrachten den Exit-Status, und ein Programm kann nach der Expansion den Exit-Status von nichts anderem sehen, außer der magischen Variable $?, die dann einfach ein String ist. case betrachtet Strings, arbeitet aber nicht anhand des Exit-Status und setzt auch nicht als Teil der Ausführung von case ... esac einen Exit-Status. Ein „Programm“ setzt den Exit-Status. Außerdem sollten [/test nur verwendet werden, um Dateisystemstrukturen auszuwerten, etwa bei test -f /dev/null. Für String-Auswertungen sollte man meiner Ansicht nach case verwenden. Natürlich jucken mich daher die meisten Skripte beim Lesen, und andere finden meine Skripte seltsam

    • Der Witz geht wohl auf meine Kosten. Genau das war die Pointe des Artikels
      Wenn ich Shell schreibe, bevorzuge ich die Form, bei der nach if ein Programm in einer eigenen Zeile steht und es dann mit then weitergeht. Das soll betonen, dass nach if „der Exit-Status des letzten Befehls vor then“ betrachtet wird. In einer Struktur wie if; ls /tmp/goober; test -d /tmp/goober; echo "I'll always execute the then clause because echo will always return a 0 return code"; then ... fi etwa betrachtet if am Ende nur den Exit-Status von echo, daher wird der then-Zweig immer ausgeführt
    • Da habe ich einen Gleichgesinnten gefunden. Acht Jahre Skripte mit test sind mein Beweis, dass ich zustimme. Es ist ein einsamer Weg ... ich gebe dem Google Shell Style Guide die Schuld
      Ich habe mir die Vorliebe für test angewöhnt, als ich Skripte schrieb, die sowohl unter sh als auch unter bash laufen mussten, aber ich bin dabei geblieben, weil es semantisch sinnvoller ist, als das Zeichen [ wie einen Befehl zu behandeln. Dass ] kein eigenes Binary ist, sondern ein Argument von [, ist ebenfalls seltsam. Ich verstehe den technischen Grund, aber es fühlt sich wie ein Hack an
    • Wenn ich die Kommentare hier lese, scheint es irgendwie Dutzende Leute zu geben, die ausschließlich test verwenden. Dutzende!
  • Ich habe es so gelernt, dass man [[ nur verwendet, wenn man Regex-Matching machen will. Beispiel: if [[ "${foo}" =~ ^bar$ ]]; then echo Yes; fi
    Ansonsten nutze ich einfach "test" oder "[". Ich bin inzwischen bei 85.000 Zeilen Bash. Das heißt nicht, dass Bash großartig wäre, aber es erfüllt noch immer bei vielen Dingen meine Anforderungen

    • Wenn man relativ einfaches Pattern-Matching POSIX-kompatibel machen möchte, kann expr Basic Regular Expressions matchen und auch Capture Groups zurückgeben

1: https://pubs.opengroup.org/onlinepubs/9699919799/utilities/e...

  • Ich bin eine ganze Weile darauf hereingefallen, weil man reguläre Ausdrücke unverändert schreibt und nicht in Anführungszeichen setzt. Da ich zu den Leuten gehöre, die aus Prinzip alles in Anführungszeichen setzen, hat es eine Weile gedauert herauszufinden, warum ein idiotisch einfacher regulärer Ausdruck nicht matchte.
  • Dieses Beispiel ist nicht besonders aussagekräftig. [ "$foo" = bar ] && echo Yes reicht aus.
    Für Substring-Matching reichen [ und *-Globs meistens völlig aus. Etwa so: [ "$bar" = extra* ] && echo '$bar began with extra'. Bashs Regex-Dialekt ist primitiv, daher lohnt es sich kaum, ihn mit Mühe zu verwenden. Für komplexe Dinge sollte man andere Tools wie grep, awk oder perl verwenden. Wenn man sich darauf versteift, alles in Bash zu erledigen, selbst komplexe Aufgaben, die mehr Wiederverwendbarkeit, Modularität und eingebaute Typen erfordern, nimmt der Nutzen rapide ab.