Quelques subtilités des conditions en shell


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 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 grep4 :

$ 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

Anakin dit à Padme ce que le titre précedent raconte, Padme répond rassurée
qu'au moins on peut toujours utiliser cmd1 && cmd2 au lieu d'un if then else.
Anakin la regarde intensément sans lui répondre. Padme reprend en lui demande
inquiète, "c'est pareil" hein ?

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 if5 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 ]

  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. en plus du fait que la plupart des personnes l’utilisant ne prennent pas le temps de l’apprendre “sérieusement” 

  4. 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 

  5. à vérifier, je dis peut-être une grosse bêtise