3 points par GN⁺ 2023-11-24 | 1 commentaires | Partager sur WhatsApp
  • Dans les systèmes de type Unix, il peut exister un exécutable /bin/[ dont le nom est un symbole d’un seul caractère, et une syntaxe qui ressemble à une expression conditionnelle shell repose en réalité sur l’exécution d’une commande et son code de sortie
  • test évalue une expression et renvoie 0 si elle est vraie, 1 si elle est fausse ; lorsqu’il est invoqué comme [ il vérifie aussi que le dernier argument est bien ]
  • Comme beaucoup de shells fournissent aussi test et [ comme commandes intégrées, les messages d’erreur ou le comportement peuvent différer entre /bin/test externe et l’implémentation intégrée du shell
  • L’extension Bash [[ n’est pas une commande externe mais une syntaxe intégrée ; elle applique donc des règles différentes de [ — par exemple, comme dans l’exemple, long* n’est pas développé comme un glob et est comparé comme une chaîne littérale
  • Pour les scripts portables, [ est le bon choix ; si le script est réservé à Bash, mieux vaut utiliser [[ de manière cohérente, à condition de bien connaître les différences de règles d’expansion entre les deux

Ce que sont vraiment /bin/[ et /bin/test

  • Sur un système Unix, il peut exister un exécutable nommé /bin/[
    • La commande d’exemple ls /bin/? affiche /bin/[
  • /bin/[ et /bin/test peuvent désigner le même binaire
    • Dans l’exemple, les deux chemins correspondent à des fichiers ayant le même inode et la même taille
    • Cela ne signifie pas pour autant qu’il s’agit forcément de liens physiques sur tous les systèmes
  • test est un programme shell qui évalue des expressions
    • comparaison de chaînes
    • comparaison numérique
    • vérification de conditions sur des fichiers
  • Si l’évaluation est vraie, il renvoie le code de sortie 0, sinon 1

Pourquoi [ se comporte comme une commande

  • test a = b ressemble peu à une condition, mais la même logique écrite sous la forme [ a = b ] paraît plus familière
  • [ donne l’impression d’une syntaxe spéciale, mais il s’agit en réalité d’un appel de commande
    • if [ a = b ]; then ... fi exécute la commande [ puis vérifie son code de sortie
    • Lorsqu’il est invoqué comme [, le programme vérifie que le dernier argument est bien le crochet fermant ]
  • La structure if n’interprète pas directement une condition ; elle décide du branchement en fonction du code de sortie de la commande reçue
    • test a = a; echo $? donne 0
    • test a = b; echo $? donne 1
    • [ a = a ]; echo $? donne 0
    • [ a = b ]; echo $? donne 1
  • Dans le même esprit, true et false peuvent aussi être vus comme des binaires auxiliaires qui renvoient un code de sortie

Différences entre binaire externe et commande intégrée du shell

  • test et [ sont si fréquents dans les scripts shell que la plupart des shells les implémentent aussi comme commandes intégrées
  • À entrée identique, le binaire externe et la commande intégrée du shell peuvent produire des sorties différentes
    • /bin/test a b produit test: a: unexpected operator
    • test a b produit dash: 2: test: a: unexpected operator
  • Ce type de différence n’existe pas seulement pour test et [ ; on peut aussi l’observer avec des commandes apparemment simples comme echo
  • Comme chaque shell peut avoir sa propre implémentation intégrée, le comportement d’un script peut varier selon le shell qui l’exécute

Les règles particulières de l’extension Bash [[

  • [[ est une extension de Bash qui peut remplacer l’usage de [
  • La plus grande différence est que [[ est toujours une syntaxe intégrée
    • Contrairement à [, qui peut être exécuté comme binaire externe, [[ permet à Bash de modifier les règles du langage à l’intérieur de l’expression
  • Dans l’exemple avec les globs, [ et [[ ne se comportent pas de la même manière
    • Après touch long-name, [ long* = long-name ] && echo match affiche match
    • Les arguments de la commande [ suivent les règles normales d’expansion du shell, donc long* est développé en long-name dans le répertoire
    • [[ long* = long-name ]] && echo match n’affiche rien
    • [[ traite long* comme une chaîne littérale et le compare tel quel à long-name, donc la comparaison échoue
  • Dans un script réservé à Bash, [[ permet aussi d’utiliser des fonctions comme la correspondance par expression régulière =~

Que choisir dans un script

  • Pour les scripts shell portables, il est préférable d’utiliser [
  • test peut aussi être utilisé, mais ce n’est pas le choix le plus courant
  • Si le script est spécifique à Bash, mieux vaut utiliser [[ de manière cohérente
  • Le shell lui-même possède aussi des opérateurs d’expression comme !, && et ||
    • Ces opérateurs fonctionnent à partir de l’état de sortie des commandes
    • grep ^hello$ ... && grep ^bye$ ... donne un code de sortie global de 0 si les deux commandes réussissent
    • Si le premier grep échoue, la commande après && ne peut pas valider l’ensemble, et le code de sortie global devient 1
  • On peut donc combiner les expressions test/[ avec les opérateurs logiques du shell dans une même condition
    • Exemple : [ a = b ] || grep -q ^hello$ /usr/share/dict/words
  • POSIX n’exige pas que /bin/[ et /bin/test soient des liens physiques
    • Sur NetBSD, ils l’étaient
    • macOS Catalina fournit des copies distinctes du même binaire
    • Debian testing fournit deux binaires différents
    • La spécification POSIX n’impose pas que ces deux fichiers soient liés

1 commentaires

 
GN⁺ 2023-11-24
Commentaires sur Hacker News
  • Je suis l’auteur du billet original. Merci de l’avoir partagé, et je suis ravi qu’il soit même monté en page d’accueil. Le titre devrait probablement inclure (2020), et il vaudrait mieux ne pas mettre de majuscule à "test", puisque cela désigne la commande elle-même
    J’ai aussi écrit un billet connexe en 2021, qui traite même de l’opérateur [[ de bash, donc il pourrait être intéressant dans ce contexte : https://jmmv.dev/2021/08/useless-use-of-gnu.html

    • [[ n’est pas, à strictement parler, une commande interne, mais plutôt fondamentalement un élément de syntaxe. Il utilise probablement en interne une commande interne presque inaccessible, mais ce qui est amusant, c’est que ]] est aussi un mot réservé alors qu’il ne peut pas apparaître à une position où il aurait du sens
      Dans certains shells non bash, le mot-clé function est nécessaire pour déclarer certains types de fonctions. Le $(shell) de make peut produire une différence de performance mesurable lorsqu’on construit beaucoup de cibles. Malgré cela, quand il ne fait rien, c’est une perte, donc en général il vaut mieux utiliser include pour déclencher une régénération. Le fait que GNU ignore POSIX est tout à fait justifié, car POSIX n’est pas très utile pour résoudre la plupart des vrais problèmes
    • Une bonne partie des extensions GNU évoquées dans https://jmmv.dev/2021/08/useless-use-of-gnu.html sont très utiles en usage interactif. Chercher dans le répertoire courant sans . explicite est utile, et pouvoir ajouter des options à la fin de la commande qu’on vient de taper est vraiment pratique. Ça m’agace toujours quand une commande ne permet pas ce genre de chose
      Pour les scripts, viser la compatibilité POSIX sh est généralement raisonnable. Au minimum, il faut savoir quand on utilise une syntaxe spécifique à Bash
    • Ce billet se plaint que l’usage d’extensions GNU comme --ignore-case ou set -o pipefail réduit la portabilité des scripts. C’est vrai en soi
      Mais il n’explique pas pourquoi les utilisateurs de Linux devraient autant se soucier de la portabilité. OpenBSD et FreeBSD existent bel et bien, mais leur base d’utilisateurs est trop faible pour que cela semble particulièrement préoccupant. On peut dire, par souci d’équité, qu’il faut aussi tenir compte de ces systèmes, mais où s’arrête ce critère ? Faut-il aussi considérer des systèmes obscurs comme vxWorks ? BusyBox et Alpine sont plus intéressants, mais les changements sont si importants qu’un portage séparé est de toute façon presque toujours nécessaire. Y a-t-il une autre raison convaincante de se soucier des écosystèmes non GNU ?
    • Un truc comme if [ a = b ] || grep -q ^hello$ /usr/share/dict/words; then echo "test failed and grep succeeded"; fi, ce n’est pas tout simplement un usage du shell banal et quotidien ?
    • Petit aparté : le logiciel de blog semble avoir cassé le titre. Il est par exemple affiché comme make $(shell …) expansion, alors que cela devrait être make $(shell ...) expansion
      Comme c’est bien écrit dans le corps du texte, ce sont trois points et non une ellipse typographique, donc mldr non plus n’est pas correct. Deux bugs sans rapport semblent probablement s’être combinés ici
  • En poussant le dernier point un cran plus loin, on peut même supprimer le bloc if lui-même. if [ a = b ]; then echo "Oops!"; else echo "Expected; phew!"; fi devient [ a = b ] && echo "Oops!" || echo "Expected; phew!"
    Je ne sais pas à quelle fréquence il faut vraiment faire ça, mais c’est parfois utile pour envoyer conditionnellement une sortie de debug vers la sortie d’erreur standard, par exemple [ "$debug" ] && echo "what's going on" >&2. Le fait que les blocs if testent des commandes ordinaires permet aussi des choses comme if grep -q 'debug' /var/log/nginx/access.log; then echo "Debug request found!"; fi. Ce que je n’ai pas encore examiné, c’est s’il vaut mieux écrire [ $(expr 1 + 1) -eq 2 ] && [ $(expr 2 + 2) -eq 3 ], ou utiliser le ET logique intégré à test : [ $(expr 1 + 1) -eq 2 -a $(expr 2 + 2) -eq 4 ]. Si la performance n’est pas un problème, les deux semblent défendables pour des raisons similaires

    • Il ne faut pas prendre [ a = b ] && echo "Oops!" || echo "Expected; phew!" comme règle générale. Bash interprétera probablement cette ligne comme ([ a = b ] && echo "Oops!") || echo "Expected; phew!"
      Donc si la suite de commandes après && échoue, le code après || s’exécutera quoi qu’il arrive. Par exemple, si >/dev/full echo "strings match" échoue à cause d’une erreur d’écriture, alors "strings don't match" sera affiché alors que les chaînes sont égales. Ce n’est pas la même sémantique qu’un bloc if
    • Mieux vaut éviter ce genre d’abréviation. Si vous utilisez set -e, comme il faudrait le faire, alors if [ a = b ]; then echo "Oops!"; fi se comportera comme prévu, mais [ a = b ] && echo "Oops!" provoquera une sortie sur erreur quand l’expression a n’est pas égale à b
    • Selon POSIX, les expressions binaires -a, -o et les opérateurs (, ) sont marqués comme obsolescents. Voir la section "Application Usage" de https://pubs.opengroup.org/onlinepubs/9699919799/utilities/t... pour plus de détails
    • Que l’on utilise -a ou deux tests avec &&, si l’on peut utiliser l’évaluation arithmétique de bash, il n’est pas nécessaire d’invoquer expr : [ $((1+1)) -eq 2 ]
  • Depuis quelques années, j’ai arrêté d’utiliser [. test renforce l’idée qu’il ne s’agit pas de syntaxe, mais bien d’une commande comme une autre. Et consulter man test est bien plus agréable que fouiller dans man bash

    • Ça ne colle pas très bien. GNU Coreutils fournit non seulement la page man test, mais aussi la page man [
      Bash propose aussi help test comme antisèche rapide. La commande [ est très ancienne : elle était déjà présente dans Version 7 Unix en 1979
  • D’accord. [ et [[, spécifique à bash, créent en pratique beaucoup de confusion sur ce qui se passe réellement, au point qu’il était difficile d’avoir confiance en leur comportement
    Cela dit, le fait que [[ soit garanti comme commande intégrée avait clairement un intérêt à l’époque où les performances des scripts shell comptaient vraiment, et ce n’était pas il y a si longtemps

    • Après avoir lu cet article, je pense que je vais simplement utiliser test. Je n’écris pas souvent des scripts bash, donc je me fais toujours piéger par les instructions if, surtout par les règles sur les espaces. Une fois la raison comprise, ça devient tout à fait évident, et test montre aussi plus clairement qu’on ne fait que passer des arguments
  • Le plus gros piège de [ et de test, c’est le comportement avec un seul argument. Par exemple, on peut écrire [ -n $FOO ] pour vérifier qu’une variable n’est pas vide
    Mais si FOO n’est pas définie, elle ne s’étend pas en chaîne vide mais en rien du tout, ce qui revient à [ -n ]. POSIX exige que, dans la forme à un seul argument de [, cet argument — ici "-n" — provoque un succès s’il n’est pas vide. Le test indique donc à tort que $FOO n’est pas vide. Il faut toujours mettre les variables entre guillemets

    • La dernière phrase devrait venir en premier. Mettez les variables entre guillemets. Le piège n’est pas dans la spécification de la commande intégrée test, mais dans le shell lui-même
      Le comportement mentionné est logique. [ "$FOO" ] est en effet une forme qui vérifie toujours si le contenu n’est pas vide, quel qu’il soit, même si c’est "-n"
    • Il suffit de passer ShellCheck sur les scripts
    • Si $FOO contient des espaces, il sera étendu en plusieurs arguments. Donc, mettez toujours les variables entre guillemets
    • [ x"$FOO" != x"" ]
    • Dans ce cas, j’utiliserais plutôt [ -n "${FOO?}" ] pour que le script s’arrête immédiatement si $FOO est nul ou non défini
  • chubot a écrit un document intéressant qui explore les aspects plus subtils de test/[/[[. Les autres articles de ce blog expliquent aussi de façon assez intéressante les bizarreries du shell
    ¹ https://www.oilshell.org/blog/2017/08/31.html
    ² https://www.oilshell.org/blog/2016/11/18.html

  • Je ne savais pas du tout que [ était un programme, et je trouve assez drôle qu’il vérifie si le dernier argument est bien un crochet fermant
    En tout cas, ça explique pourquoi il faut des espaces des deux côtés des crochets

  • [[ est spécifique à bash. Si vous savez que vous n’utiliserez que bash, vous pouvez l’utiliser. L’article couvre bien les détails

    • Il y a aussi zsh :)
      https://zsh.sourceforge.io/Doc/Release/Conditional-Expressio...
    • On peut toujours utiliser [[
      C’est aussi présent dans zsh et ksh, et je suis presque certain que ça vient de ksh en 1988, voire avant
    • Autrement dit, test et [ sont spécifiés par POSIX et existent généralement comme vrais binaires, même s’ils peuvent être masqués par une commande intégrée du shell
      En revanche, [[ n’est pas spécifié par POSIX et n’existe généralement que comme commande intégrée au shell
    • À moins d’utiliser explicitement un shell complètement différent comme Fish, je ne vois pas pourquoi on n’utiliserait pas Bash
      Se limiter au plus petit dénominateur commun des shells me paraît être une préoccupation totalement archaïque
  • Je n’ai pas bien compris pourquoi la dernière instruction if était censée être déroutante. Si c’est parce qu’en apprenant les scripts shell on suppose généralement que [ fait partie du langage bash plutôt que d’être simplement un autre programme, alors je comprends. Sinon, j’aimerais bien qu’on m’explique ce qu’il y a de surprenant

    • Même sans savoir que [ est un binaire, je ne vois pas bien pourquoi ce serait déroutant. Ça ressemble à du bash tout ce qu’il y a de plus normal
  • J’ai des préférences très arrêtées sur le shell, qui collent mal avec celles du reste du monde
    Pour moi, il ne faut jamais utiliser [, seulement test. [ donne l’illusion que son mécanisme fait partie de la grammaire du langage, alors qu’en réalité ce n’est qu’un autre « programme ». Par « programme », j’inclus aussi les commandes intégrées et les fonctions. if/||/&& regardent l’état de sortie, et un programme ne peut pas voir l’état de sortie d’autre chose, sauf à consulter la variable magique $?, qui n’est plus qu’une chaîne après expansion. case regarde des chaînes, mais ne fonctionne pas sur les états de sortie et ne définit pas non plus d’état de sortie dans le cadre du fonctionnement de case ... esac. C’est le « programme » qui définit l’état de sortie. De plus, [/test ne devraient servir qu’à évaluer la structure du système de fichiers, comme dans test -f /dev/null. Pour l’évaluation de chaînes, il faudrait utiliser case. Évidemment, la plupart des scripts me démangent, et les scripts que j’écris paraissent bizarres aux autres

    • La blague s’est retournée contre moi. C’était justement le propos de l’article
      Quand j’utilise le shell, je préfère mettre le programme après if sur une ligne séparée avant de passer à then, afin de bien souligner qu’on regarde « l’état de sortie de la dernière commande avant then ». Par exemple, dans une structure comme 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, if ne regarde finalement que l’état de sortie de echo, donc la clause then s’exécute toujours
    • Enfin quelqu’un qui suit le même chemin. Huit ans de scripts avec test prouvent que je suis d’accord. C’est une route solitaire... j’accuse le guide de style shell de Google
      En écrivant des scripts qui doivent fonctionner à la fois en sh et en bash, j’ai pris l’habitude de préférer test, mais je continue surtout parce que ça me paraît plus cohérent sémantiquement que de traiter le caractère [ comme une commande. Le fait que ] ne soit pas un binaire séparé mais un argument de [ est aussi étrange. Je comprends la raison technique, mais ça donne l’impression d’un hack
    • En lisant les commentaires ici, on dirait qu’il y a quand même des dizaines de gens qui n’utilisent que test. Des dizaines !
  • On m’a appris à n’utiliser [[ que lorsqu’on veut faire du filtrage par expression régulière. Par exemple : if [[ "${foo}" =~ ^bar$ ]]; then echo Yes; fi
    Pour le reste, j’utilise simplement "test" ou "[". J’en suis à 85 000 lignes de bash. Je ne dis pas que bash est formidable, mais il répond encore à mes besoins pour beaucoup de choses

    • Si vous voulez faire du filtrage par motif relativement simple d’une manière compatible POSIX, expr peut faire du matching avec des expressions régulières de base et peut aussi renvoyer des groupes capturés

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

  • Je me suis fait avoir pendant un moment à cause du fait qu’on utilise la regex telle quelle sans la mettre entre guillemets. Comme je suis du genre à tout mettre religieusement entre guillemets, il m’a fallu du temps pour comprendre pourquoi une regex stupidement simple ne matchait pas.
  • Cet exemple n’a pas vraiment d’intérêt. Il suffit de faire [ "$foo" = bar ] && echo Yes.
    Pour faire du matching de sous-chaîne, [ et le glob * suffisent généralement. Par exemple : [ "$bar" = extra* ] && echo '$bar began with extra'. Le dialecte regex de Bash est primitif, donc ça vaut rarement la peine de se fatiguer à l’utiliser. Pour les choses complexes, mieux vaut utiliser d’autres outils comme grep, awk ou perl. Si on s’obstine à tout faire en bash, y compris des tâches complexes qui demandent plus de réutilisabilité, de modularité et des types intégrés, le rendement diminue très vite.