Le site arthur.bebou.netlib.re - retour accueil
git clone git://bebou.netlib.re/arthur.bebou
Log | Files | Refs |
commit 094564dd729563cf6c69d42e62b319ae45f5f18e parent 98105dca1c97024a74e8499fb782002d28898b75 Auteurice: Arthur Pons <arthur.pons@unistra.fr> Date: Wed, 4 Jun 2025 14:44:40 +0200 Nouvel article sur les conditions du shell Diffstat:
A | contents/conditions-shell/index.sh | | | 397 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
1 file changed, 397 insertions(+), 0 deletions(-)
diff --git a/contents/conditions-shell/index.sh b/contents/conditions-shell/index.sh @@ -0,0 +1,397 @@ +#! page +title: Quelques subtilités des conditions en shell +author: Arthur Pons +description: "Le shell c'est bizarre et les conditions n'y échappent pas. Voyons quelques excentricités" +publication: 2025-06-04 + +section: main + +Cet article a pour but d'être de l'auto-documentation de choses que +j'ai appris en shell au sujet des conditions. + +## TL;DR + + * Les conditions en shell se reposent sur les valeurs de sortie des commandes + * `test` est une commande comme les autres, elle a simplement des options qui + permettent de tester des trucs pratiques et de faire dépendre sa valeur de + sortie dessus + * `[ blabla ]` est plus ou moins `test` drapé de sucre syntaxique + * `test` et `[` sont presque toujours des built-in. Leurs fonctionnements + peuvent différer de ce que vous voyez dans `man test`. + * `cmd1 && cmd2 || cmd3` n'est pas équivalent à `if cmd1;then cmd2; else cmd3; + fi`. Vous voulez probablement utiliser la seconde syntaxe. + * `cmd1 && cmd2` n'est pas équivalent à `if cmd1;then cmd2; fi`. La syntaxe à + utiliser dépend de votre besoin. + * `[ expr -a expr ]` n'est pas équivalent à `[ expr ] && [ expr ]`. Vous + voulez probablement utiliser la seconde syntaxe. + +## Les valeurs de sortie + +Le fonctionnement des conditions en shell repose sur les valeurs de sortie (exit +status) des commandes. Chaque commande, et built-in, a une valeur de sortie. Par +convention la valeur `0` indique un succès ou un fonctionnement normal, toute +autre valeur indique une erreur, un échec. On trouve par exemple dans le +standard POSIX pour la commande `chmod` : + + EXIT STATUS + The following exit values shall be returned: + + 0 One or more lines were selected. + 1 No lines were selected. + >1 An error occurred. + +Les valeurs différentes de zéro peuvent permettre à la commande d'indiquer +quel type d'erreur a été rencontré. Par exemple dans le *très* long manuel de +`rsync` : + + EXIT VALUES + 0 - Success + 1 - Syntax or usage error + 2 - Protocol incompatibility + 3 - Errors selecting input/output files, dirs + 4 - Requested action not supported. Either: an attempt was made to manipulate + 64-bit files on a platform that cannot support them an option was specified + that is supported by the client and not by the server + 5 - Error starting client-server protocol + 6 - Daemon unable to append to log-file + 10 - Error in socket I/O + 11 - Error in file I/O + 12 - Error in rsync protocol data stream + 13 - Errors with program diagnostics + 14 - Error in IPC code + 20 - Received SIGUSR1 or SIGINT + 21 - Some error returned by waitpid() + 22 - Error allocating core memory buffers + 23 - Partial transfer due to error + 24 - Partial transfer due to vanished source files + 25 - The --max-delete limit stopped deletions + 30 - Timeout in data send/receive + 35 - Timeout waiting for daemon connection + +Il est possible de récupérer la valeur de sortie d'une commande en regardant ce +qu'il y a dans la variable spéciale `$?` directement après la fin de son +exécution : + + $ true + $ echo $? + 0 + $ false + $ echo $? + 1 + +Si plusieurs commandes sont enchaînées dans un pipe il aura pour valeur de +sortie celle de la dernière commande : + + echo "1+1" | bc -l + echo $? + echo "1/0" | bc -l + echo $? + +Il est possible d'inverser la valeur de sortie d'une commande avec l'opérateur +`!` : + + $ ! true + $ echo $? + 1 + $ ! false + $ echo $? + 0 + +Les scripts shell eux même renvoient par défaut la valeur de sortie de la +dernière commande qu'ils ont exécuté. Si l'on veut en envoyer une spécifique on +peut utiliser `exit`. Un exemple est donné par la suite. + +## Les if normaux + +La syntaxe la plus courante pour créer un embranchement dans son code sur la +base d'une valeur de vérité est le `if`. Du manuel de `dash` : + + if list + then list + [ elif list + then list ] ... + [ else list ] + fi + +Parfois le `then` est inscrit sur la même ligne que le `if`, la commande et le +`then` séparés par une virgule : + + if list;then + list + [ elif list;then + list ] ... + [ else list ] + fi + +`if` vérifie la valeur de sortie de la ou du groupe de commande `list` et +exécute la ou les commandes appropriées. Ainsi si l'on voulait afficher du texte +selon si l'entrée standard contient ou pas "truc" on pourrait faire : + + if grep -q "truc";then + echo "le texte contient truc"; exit 0; + else + echo "le texte ne contient pas truc"; exit 1; + fi + +Le `-q` de `grep` permet de ne pas afficher la ou les lignes sur lesquelles il +aurait éventuellement trouvé *truc* mais de seulement renvoyer sa valeur de +sortie. Ici les `exit` sont là pour que notre script lui même transfert cette +valeur de sortie si jamais cela est nécessaire. Sinon il aurait toujours renvoyé +`0` puisque `echo` va, à priori, toujours réussir[^1] et que c'est la dernière +commande exécutée. Mieux encore on sait que `grep` renvoie une valeur de sortie +au-dessus de 1 si quelque chose s'est mal passé. Dans notre `else` on voudrait +donc utiliser la valeur contenue dans `$?` pour distinguer les cas ou rien n'a +été trouvé des cas où un bug a été rencontré. + +## Les complications + +### La commande `test` + +Malheureusement ce qui suit `if` **doit** être une commande. Il n'est pas +possible en shell d'écrire quelque chose du type : + + if "$?" > 1;then + echo "blabla" + fi + +Ou plutôt il est tout à fait possible de l'écrire mais ça ne fonctionnera pas +comme on le souhaite. L'interpréteur shell va développer la variable `$?` et +tenter d'exécuter son contenu comme si c'était une commande. Ainsi si `$?` +contenait `0` on aura une erreur[^2] : + + zsh: command not found: 0 + +C'est pour cela qu'a été inventé la commande `test`. Cette commande n'a rien de +particulier, elle n'est pas comprise différemment par `if` ou le shell. Elle a +simplement pour caractéristique d'avoir des valeurs de sorties sur la base +d'options qui testent pleins de choses pratiques. Par exemple, pour vérifier si +un chiffre est plus grand que 1 : + + $ test 2 -gt 1 + $ echo $? + 0 + $ test 0 -gt 1 + $ echo $? + 1 + +De la même façon qu'avec `grep`, on peut insérer ces commandes dans une syntaxe +`if` : + + if grep -q "truc"; then + echo "le texte contient truc"; exit 0; + else + if test "$?" -gt 1; then + echo "oups problème"; exit 2; + else + echo "le texte ne contient pas truc"; exit 1; + fi + fi + +Avec cet exemple j'espère avoir montré que `test` n'est pas une commande +spéciale. Tout repose sur les valeurs de sorties. + +Alors pourquoi voit-on parfois la syntaxe `[` ? + +### La syntaxe `[` n'est que la commande test déguisée + +`[` est, je le concède, quelque chose de très confus. C'est une variante +syntaxique de la fonction `test` qui permet d'écrire des conditions de la sorte +: + + if [ 2 -gt 1 ];then echo "2 plus grand que 1"; fi + +Il est *nécessaire* que le dernier argument de la commande `[` soit un `]`. +C'est bel et bien une commande, pour vous en convaincre sur un linux : + + ls -la "/usr/bin/[" + -rwxr-xr-x 1 root root 68496 Sep 20 2022 /usr/bin/[ + +Je suppose que l'avantage de cette syntaxe est d'être plus proche des syntaxes +d'autres langages. Cela dit je trouve pas ça fou parce qu'elle donne +l'impression que ce qui suit `if` n'est pas une commande. C'est, je crois, une +partie de la raison pour laquelle on galère à comprendre les conditions +en shell[^5]. Elle donne l'impression que `if grep -q "truc";then` n'est pas un +syntaxe correcte. Comme si l'on oubliait les parenthèses dans un `if` en C. + +### `test` et `[` sont généralement des built-ins + +La plupart du temps `test` et `[` sont implémentés en tant que built-in du +shell. Sur ma machine avec `zsh` : + + $ type test [ + test is a shell builtin + [ is a shell builtin + +Il est très probable que cela n'ait aucune incidence mais si vous utilisez l'une +de ces deux commandes dans un script il convient de lire la documentation du +shell utilisé et non pas celle se trouvant derrière `man test`. + +### La syntaxe `cmd1 && cmd2 || cmd3` n'est pas équivalente à `if cmd1; then cmd2; else cmd3; fi` + +Il existe deux opérateurs, `&&` et `||` que le manuel de dash appelle de +"court-circuit". En lisant la documentation : + +> “&&” and “||” are AND-OR list operators. “&&” executes the first com‐ +> mand, and then executes the second command if and only if the exit status +> of the first command is zero. “||” is similar, but executes the second +> command if and only if the exit status of the first command is nonzero. +> “&&” and “||” both have the same priority. + +On serait tenté de croire que : + + cmd1 && cmd2 || cmd3 + +est équivalent à : + + if cmd1;then + cmd2 + else + cmd3 + fi + +mais ce n'est pas le cas. Si l'on reprend notre exemple de +`grep`[^3] : + + $ echo "machin" | grep -q "truc" && echo "truc" || echo "pas truc" + pas truc + $ echo "truc" | grep -q "truc" && echo "truc" || echo "pas truc" + truc + +il semble fonctionner correctement. Mais que se passe-t-il si la commande qui +suit directement `&&` a elle même pour valeur de sortie autre chose qu'un 0 ? +Vérifions en inversant la valeur de sortie du `echo` avec un `!` : + + $ echo "truc" | grep -q "truc" && ! echo "truc" || echo "pas truc" + truc + pas truc + +Patatra ! La commande nous dit que notre texte contient truc et ne contient pas +truc simultanément ! Et pour cause, ce qui détermine l'exécution de cmd3 est la +valeur de sortie de cmd2 et non pas de cmd1. Si cette syntaxe fonctionne souvent +comme un `if then else` c'est parce que cmd2 est souvent une commande ayant très +peu de chance de rencontrer une erreur (type `echo`). + +S'il est essentiel d'utiliser la syntaxe `cmd1 && cmd2 || cmd3` comme substitut +à un if - ça ne l'est jamais - et que l'on voulait garantir que cmd3 ne soit +exécuté que si cmd1 est faux il faudrait manuellement garantir que cmd2 renvoie +vrai : + + echo "machin" | grep -q "truc" && { ! echo "truc"; return 0; } || echo "pas truc" + +À ce stade là il vaut mieux écrire : + + if echo "truc" | grep -q "truc"; then + ! echo "truc" + else + echo "pas truc" + fi + +Il ne faut donc utiliser cette syntaxe que lorsque l'on souhaite exécuter : + + if cmd1;then + if ! cmd2;then + cmd3 + fi + else + cmd3 + fi + +que l'on pourrait se représenter de cette manière : + + ( cmd1 && cmd2 ) || cmd3 + +### La syntaxe `cmd1 && cmd2` n'est pas équivalente à `if cmd1; then cmd2; fi` + + + +Dommage Padme mais non, elles sont ne pas tout à fait équivalentes. Par exemple +: + + $ if false;then + echo "blabla" + fi + $ echo "$?" + 0 + $ false && echo "blabla" + $ echo "$?" + 1 + +Si dans les deux cas les commandes exécutées ont bien été les mêmes - `blabla` +ne s'est pas affiché puisque la commande `false` a toujours pour valeur de +sortie 1 - les valeurs de sortie des commandes sont différentes. Dans le premier +cas la valeur de sortie est celle du `if`[^4] alors que dans le second c'est +celle de la commande `false`. Si le script en question utilise par la suite `$?` +alors ces deux constructions risqueraient de donner des résultats très +différents. + +Personnellement j'ai rencontré cette différence lors de l'utilisation de `make`. +J'avais une règle qui permettait d'arrêter un serveur web local de test. Je +voulais exécuter le `kill` que si le serveur état effectivement en +fonctionnement sans quoi le kill n'aurait pas eu d'argument et la compilation +se seraient arrêtée. J'ai écrit quelque chose du style : + + pidof -s busybox && kill $(pidof -s busybox) + +Un peu redondant mais fait le taf. Sauf que lorsqu'il n'y a pas de processus +`busybox` la première commande renvoie une valeur de sortie fausse et la +makefile s'arrête ! Écrire : + + if pidof -s busybox > /dev/null;then kill $$(pidof -s busybox); fi + +résout notre problème. Le kill n'est fait que si un pid existe mais le test ne +force pas le makefile à s'arrêter s'il est en échec. + +### Les formes `[ expr -a expr ]` et `[ expr -o expr ]` sont ambigües + +Je ne vais pas trop rentrer dans les détails parce que ça a été très bien fait +de [cet article de blog](https://www.oilshell.org/blog/2017/08/31.html). + +Pour reprendre l'une de ses formulations : + +> Qu'est-ce que cette expression veut dire ? +> +> $ [ -a -a -a -a ] + +Eh bien c'est ambiguë, notamment parce que `test` ne sait pas si `-a` est censé +être un opérateur ou un nom de fichier. Quand on veut utiliser des ET et OU +logiques dans des tests il est préférable d'utiliser les opérateurs de +court-circuit `&&`, `||` et, s'il le faut, de grouper les tests avec des +parenthèses. Par exemple : + + [ 2 -gt 1 -a 3 -gt 2 ] + +devient + + [ 2 -gt 1 ] && [ 3 -gt 2 ] + +Il faut cependant bien garder en tête que `-a` prend le pas sur `-o`, comme dans +de nombreux langages, alors que `&&` et `||` ont la même priorité. Ainsi : + + [ 2 -gt 1 -o 3 -gt 1 -a 2 -gt 3 ] + +devra être réécrit + + [ 2 -gt 1 ] || ( [ 3 -gt 1 ] && [ 2 -gt 3 ] ) + +Dans le même style il vaut mieux éviter d'utiliser la négation `!` de test et +lui préférer celle du shell pour éviter les cafouillages si jamais une variable +testée à l'intérieur de test a elle même pour valeur `!` : + + [ ! 2 -gt 1 ] + +devient + + ! [ 2 -gt 1 ] + +[^1]: ce qui n'est pas non plus toujours le cas selon si ce sur quoi il essaye + d'écrire est dispo ou pas mais on va pas rentre là-dedans ici +[^2]: à moins que se trouve dans votre `PATH` un exécutable nommé `0` mais ne + faites pas ça +[^3]: la syntaxe avec le pipe fonctionne puisque, souvenez-vous, la valeur de + sortie d'un pipe est la valeur de sortie de sa dernière commande +[^4]: à vérifier, je dis peut-être une grosse bêtise +[^5]: en plus du fait que la plupart des personnes l'utilisant ne prennent pas + le temps de l'apprendre "sérieusement"