- TL;DR
- Les valeurs de sortie
- Les if normaux
- Les complications
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 moinstest
drapé de sucre syntaxiquetest
et[
sont presque toujours des built-in. Leurs fonctionnements peuvent différer de ce que vous voyez dansman 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éussir1 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 erreur2 :
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 shell3. 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
4 :
$ 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
5 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.
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 ]
-
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 ↩
-
à moins que se trouve dans votre
PATH
un exécutable nommé0
mais ne faites pas ça ↩ -
en plus du fait que la plupart des personnes l’utilisant ne prennent pas le temps de l’apprendre “sérieusement” ↩
-
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 ↩
-
à vérifier, je dis peut-être une grosse bêtise ↩