arthur.bebou

Le site arthur.bebou.netlib.re - retour accueil

git clone git://bebou.netlib.re/arthur.bebou

Log | Files | Refs |

index.sh (14449B)


      1 #! page
      2 title: Quelques subtilités des conditions en shell
      3 author: Arthur Pons
      4 description: "Le shell c'est bizarre et les conditions n'y échappent pas. Voyons quelques excentricités"
      5 publication: 2025-06-04
      6 
      7 section: main
      8 
      9 Cet article a pour but d'être de l'auto-documentation de choses que
     10 j'ai appris en shell au sujet des conditions.
     11 
     12 ## TL;DR
     13 
     14   * Les conditions en shell se reposent sur les valeurs de sortie des commandes
     15   * `test` est une commande comme les autres, elle a simplement des options qui
     16     permettent de tester des trucs pratiques et de faire dépendre sa valeur de
     17     sortie dessus
     18   * `[ blabla ]` est plus ou moins `test` drapé de sucre syntaxique
     19   * `test` et `[` sont presque toujours des built-in. Leurs fonctionnements
     20     peuvent différer de ce que vous voyez dans `man test`.
     21   * `cmd1 && cmd2 || cmd3` n'est pas équivalent à `if cmd1;then cmd2; else cmd3;
     22     fi`. Vous voulez probablement utiliser la seconde syntaxe.
     23   * `cmd1 && cmd2` n'est pas équivalent à `if cmd1;then cmd2; fi`. La syntaxe à
     24     utiliser dépend de votre besoin.
     25   * `[ expr -a expr ]` n'est pas équivalent à `[ expr ] && [ expr ]`. Vous
     26     voulez probablement utiliser la seconde syntaxe.
     27 
     28 ## Les valeurs de sortie
     29 
     30 Le fonctionnement des conditions en shell repose sur les valeurs de sortie (exit
     31 status) des commandes. Chaque commande, et built-in, a une valeur de sortie. Par
     32 convention la valeur `0` indique un succès ou un fonctionnement normal, toute
     33 autre valeur indique une erreur, un échec. On trouve par exemple dans le
     34 standard POSIX pour la commande `chmod` :
     35 
     36     EXIT STATUS
     37            The following exit values shall be returned:
     38 
     39             0    One or more lines were selected.
     40             1    No lines were selected.
     41            >1    An error occurred.
     42 
     43 Les valeurs différentes de zéro peuvent permettre à la commande d'indiquer
     44 quel type d'erreur a été rencontré. Par exemple dans le *très* long manuel de
     45 `rsync` :
     46 
     47     EXIT VALUES
     48         0 - Success
     49         1 - Syntax or usage error
     50         2 - Protocol incompatibility
     51         3 - Errors selecting input/output files, dirs
     52         4 - Requested action not supported. Either: an attempt was made to manipulate
     53             64-bit files on a platform that cannot support them an option was specified
     54             that is supported by the client and not by the server
     55         5 - Error starting client-server protocol
     56         6 - Daemon unable to append to log-file
     57         10 - Error in socket I/O
     58         11 - Error in file I/O
     59         12 - Error in rsync protocol data stream
     60         13 - Errors with program diagnostics
     61         14 - Error in IPC code
     62         20 - Received SIGUSR1 or SIGINT
     63         21 - Some error returned by waitpid()
     64         22 - Error allocating core memory buffers
     65         23 - Partial transfer due to error
     66         24 - Partial transfer due to vanished source files
     67         25 - The --max-delete limit stopped deletions
     68         30 - Timeout in data send/receive
     69         35 - Timeout waiting for daemon connection
     70 
     71 Il est possible de récupérer la valeur de sortie d'une commande en regardant ce
     72 qu'il y a dans la variable spéciale `$?` directement après la fin de son
     73 exécution :
     74 
     75     $ true
     76     $ echo $?
     77     0
     78     $ false
     79     $ echo $?
     80     1
     81 
     82 Si plusieurs commandes sont enchaînées dans un pipe il aura pour valeur de
     83 sortie celle de la dernière commande :
     84 
     85     echo "1+1" | bc -l
     86     echo $?
     87     echo "1/0" | bc -l
     88     echo $?
     89 
     90 Il est possible d'inverser la valeur de sortie d'une commande avec l'opérateur
     91 `!` :
     92 
     93     $ ! true
     94     $ echo $?
     95     1
     96     $ ! false
     97     $ echo $?
     98     0
     99 
    100 Les scripts shell eux même renvoient par défaut la valeur de sortie de la
    101 dernière commande qu'ils ont exécuté. Si l'on veut en envoyer une spécifique on
    102 peut utiliser `exit`. Un exemple est donné par la suite.
    103 
    104 ## Les if normaux
    105 
    106 La syntaxe la plus courante pour créer un embranchement dans son code sur la
    107 base d'une valeur de vérité est le `if`. Du manuel de `dash` :
    108 
    109     if list
    110     then list
    111     [ elif list
    112     then    list ] ...
    113     [ else list ]
    114     fi
    115 
    116 Parfois le `then` est inscrit sur la même ligne que le `if`, la commande et le
    117 `then` séparés par une virgule :
    118 
    119     if list;then
    120     list
    121     [ elif list;then
    122     list ] ...
    123     [ else list ]
    124     fi
    125 
    126 `if` vérifie la valeur de sortie de la ou du groupe de commande `list` et
    127 exécute la ou les commandes appropriées. Ainsi si l'on voulait afficher du texte
    128 selon si l'entrée standard contient ou pas "truc" on pourrait faire :
    129 
    130     if grep -q "truc";then
    131         echo "le texte contient truc"; exit 0;
    132     else
    133         echo "le texte ne contient pas truc"; exit 1;
    134     fi
    135 
    136 Le `-q` de `grep` permet de ne pas afficher la ou les lignes sur lesquelles il
    137 aurait éventuellement trouvé *truc* mais de seulement renvoyer sa valeur de
    138 sortie. Ici les `exit` sont là pour que notre script lui même transfert cette
    139 valeur de sortie si jamais cela est nécessaire. Sinon il aurait toujours renvoyé
    140 `0` puisque `echo` va, à priori, toujours réussir[^1] et que c'est la dernière
    141 commande exécutée. Mieux encore on sait que `grep` renvoie une valeur de sortie
    142 au-dessus de 1 si quelque chose s'est mal passé. Dans notre `else` on voudrait
    143 donc utiliser la valeur contenue dans `$?` pour distinguer les cas ou rien n'a
    144 été trouvé des cas où un bug a été rencontré.
    145 
    146 ## Les complications
    147 
    148 ### La commande `test`
    149 
    150 Malheureusement ce qui suit `if` **doit** être une commande. Il n'est pas
    151 possible en shell d'écrire quelque chose du type :
    152 
    153     if "$?" > 1;then
    154         echo "blabla"
    155     fi
    156 
    157 Ou plutôt il est tout à fait possible de l'écrire mais ça ne fonctionnera pas
    158 comme on le souhaite. L'interpréteur shell va développer la variable `$?` et
    159 tenter d'exécuter son contenu comme si c'était une commande. Ainsi si `$?`
    160 contenait `0` on aura une erreur[^2] :
    161 
    162     zsh: command not found: 0
    163 
    164 C'est pour cela qu'a été inventé la commande `test`. Cette commande n'a rien de
    165 particulier, elle n'est pas comprise différemment par `if` ou le shell. Elle a
    166 simplement pour caractéristique d'avoir des valeurs de sorties sur la base
    167 d'options qui testent pleins de choses pratiques. Par exemple, pour vérifier si
    168 un chiffre est plus grand que 1 :
    169 
    170     $ test 2 -gt 1
    171     $ echo $?
    172     0
    173     $ test 0 -gt 1
    174     $ echo $?
    175     1
    176 
    177 De la même façon qu'avec `grep`, on peut insérer ces commandes dans une syntaxe
    178 `if` :
    179 
    180     if grep -q "truc"; then
    181         echo "le texte contient truc"; exit 0;
    182     else
    183         if test "$?" -gt 1; then
    184             echo "oups problème"; exit 2;
    185         else
    186             echo "le texte ne contient pas truc"; exit 1;
    187         fi
    188     fi
    189 
    190 Avec cet exemple j'espère avoir montré que `test` n'est pas une commande
    191 spéciale. Tout repose sur les valeurs de sorties.
    192 
    193 Alors pourquoi voit-on parfois la syntaxe `[` ?
    194 
    195 ### La syntaxe `[` n'est que la commande test déguisée
    196 
    197 `[` est, je le concède, quelque chose de très confus. C'est une variante
    198 syntaxique de la fonction `test` qui permet d'écrire des conditions de la sorte
    199 :
    200 
    201     if [ 2 -gt 1 ];then echo "2 plus grand que 1"; fi
    202 
    203 Il est *nécessaire* que le dernier argument de la commande `[` soit un `]`.
    204 C'est bel et bien une commande, pour vous en convaincre sur un linux :
    205 
    206     ls -la "/usr/bin/["
    207     -rwxr-xr-x 1 root root 68496 Sep 20  2022 /usr/bin/[
    208 
    209 Je suppose que l'avantage de cette syntaxe est d'être plus proche des syntaxes
    210 d'autres langages. Cela dit je trouve pas ça fou parce qu'elle donne
    211 l'impression que ce qui suit `if` n'est pas une commande. C'est, je crois, une
    212 partie de la raison pour laquelle on galère à comprendre les conditions
    213 en shell[^5]. Elle donne l'impression que `if grep -q "truc";then` n'est pas un
    214 syntaxe correcte. Comme si l'on oubliait les parenthèses dans un `if` en C.
    215 
    216 ### `test` et `[` sont généralement des built-ins
    217 
    218 La plupart du temps `test` et `[` sont implémentés en tant que built-in du
    219 shell. Sur ma machine avec `zsh` :
    220 
    221     $ type test [
    222     test is a shell builtin
    223     [ is a shell builtin
    224 
    225 Il est très probable que cela n'ait aucune incidence mais si vous utilisez l'une
    226 de ces deux commandes dans un script il convient de lire la documentation du
    227 shell utilisé et non pas celle se trouvant derrière `man test`.
    228 
    229 ### La syntaxe `cmd1 && cmd2 || cmd3` n'est pas équivalente à `if cmd1; then cmd2; else cmd3; fi`
    230 
    231 Il existe deux opérateurs, `&&` et `||` que le manuel de dash appelle de
    232 "court-circuit". En lisant la documentation :
    233 
    234 > “&&” and “||” are AND-OR list operators.  “&&” executes the first com‐
    235 > mand, and then executes the second command if and only if the exit status
    236 > of the first command is zero.  “||” is similar, but executes the second
    237 > command if and only if the exit status of the first command is nonzero.
    238 > “&&” and “||” both have the same priority.
    239 
    240 On serait tenté de croire que :
    241 
    242     cmd1 && cmd2 || cmd3
    243 
    244 est équivalent à :
    245 
    246     if cmd1;then
    247         cmd2
    248     else
    249         cmd3
    250     fi
    251 
    252 mais ce n'est pas le cas. Si l'on reprend notre exemple de
    253 `grep`[^3] :
    254 
    255     $ echo "machin" | grep -q "truc" && echo "truc" || echo "pas truc"
    256     pas truc
    257     $ echo "truc" | grep -q "truc" && echo "truc" || echo "pas truc"
    258     truc
    259 
    260 il semble fonctionner correctement. Mais que se passe-t-il si la commande qui
    261 suit directement `&&` a elle même pour valeur de sortie autre chose qu'un 0 ?
    262 Vérifions en inversant la valeur de sortie du `echo` avec un `!` :
    263 
    264     $ echo "truc" | grep -q "truc" && ! echo "truc" || echo "pas truc"
    265     truc
    266     pas truc
    267 
    268 Patatra ! La commande nous dit que notre texte contient truc et ne contient pas
    269 truc simultanément ! Et pour cause, ce qui détermine l'exécution de cmd3 est la
    270 valeur de sortie de cmd2 et non pas de cmd1. Si cette syntaxe fonctionne souvent
    271 comme un `if then else` c'est parce que cmd2 est souvent une commande ayant très
    272 peu de chance de rencontrer une erreur (type `echo`).
    273 
    274 S'il est essentiel d'utiliser la syntaxe `cmd1 && cmd2 || cmd3` comme substitut
    275 à un if - ça ne l'est jamais - et que l'on voulait garantir que cmd3 ne soit
    276 exécuté que si cmd1 est faux il faudrait manuellement garantir que cmd2 renvoie
    277 vrai :
    278 
    279     echo "machin" | grep -q "truc" && { ! echo "truc"; return 0; } || echo "pas truc"
    280 
    281 À ce stade là il vaut mieux écrire :
    282 
    283     if echo "truc" | grep -q "truc"; then
    284         ! echo "truc"
    285     else
    286          echo "pas truc"
    287     fi
    288 
    289 Il ne faut donc utiliser cette syntaxe que lorsque l'on souhaite exécuter :
    290 
    291     if cmd1;then
    292         if ! cmd2;then
    293             cmd3
    294         fi
    295     else
    296         cmd3
    297     fi
    298 
    299 que l'on pourrait se représenter de cette manière :
    300 
    301     { cmd1 && cmd2; } || cmd3
    302 
    303 ### La syntaxe `cmd1 && cmd2`  n'est pas équivalente à `if cmd1; then cmd2; fi`
    304 
    305 ![Anakin dit à Padme ce que le titre précedent raconte, Padme répond rassurée
    306 qu'au moins on peut toujours utiliser cmd1 && cmd2 au lieu d'un if then else.
    307 Anakin la regarde intensément sans lui répondre. Padme reprend en lui demande
    308 inquiète, "c'est pareil" hein ?](meme.jpg)
    309 
    310 Dommage Padme mais non, elles sont ne pas tout à fait équivalentes. Par exemple
    311 :
    312 
    313     $ if false;then
    314          echo "blabla"
    315       fi
    316     $ echo "$?"
    317     0
    318     $ false && echo "blabla"
    319     $ echo "$?"
    320     1
    321 
    322 Si dans les deux cas les commandes exécutées ont bien été les mêmes - `blabla`
    323 ne s'est pas affiché puisque la commande `false` a toujours pour valeur de
    324 sortie 1 - les valeurs de sortie des commandes sont différentes. Dans le premier
    325 cas la valeur de sortie est celle du `if`[^4] alors que dans le second c'est
    326 celle de la commande `false`. Si le script en question utilise par la suite `$?`
    327 alors ces deux constructions risqueraient de donner des résultats très
    328 différents.
    329 
    330 Personnellement j'ai rencontré cette différence lors de l'utilisation de `make`.
    331 J'avais une règle qui permettait d'arrêter un serveur web local de test. Je
    332 voulais exécuter le `kill` que si le serveur état effectivement en
    333 fonctionnement sans quoi le kill n'aurait pas eu d'argument et la compilation
    334 se seraient arrêtée. J'ai écrit quelque chose du style :
    335 
    336     pidof -s busybox && kill $(pidof -s busybox)
    337 
    338 Un peu redondant mais fait le taf. Sauf que lorsqu'il n'y a pas de processus
    339 `busybox` la première commande renvoie une valeur de sortie fausse et la
    340 makefile s'arrête ! Écrire :
    341 
    342     if pidof -s busybox > /dev/null;then kill $$(pidof -s busybox); fi
    343 
    344 résout notre problème. Le kill n'est fait que si un pid existe mais le test ne
    345 force pas le makefile à s'arrêter s'il est en échec.
    346 
    347 ### Les formes `[ expr -a expr ]` et `[ expr -o expr ]` sont ambigües
    348 
    349 Je ne vais pas trop rentrer dans les détails parce que ça a été très bien fait
    350 de [cet article de blog](https://www.oilshell.org/blog/2017/08/31.html).
    351 
    352 Pour reprendre l'une de ses formulations :
    353 
    354 > Qu'est-ce que cette expression veut dire ?
    355 >
    356 > $ [ -a -a -a -a ]
    357 
    358 Eh bien c'est ambiguë, notamment parce que `test` ne sait pas si `-a` est censé
    359 être un opérateur ou un nom de fichier. Quand on veut utiliser des ET et OU
    360 logiques dans des tests il est préférable d'utiliser les opérateurs de
    361 court-circuit `&&`, `||` et, s'il le faut, de grouper les tests avec des
    362 parenthèses. Par exemple :
    363 
    364     [ 2 -gt 1 -a 3 -gt 2 ]
    365 
    366 devient
    367 
    368     [ 2 -gt 1 ] && [ 3 -gt 2 ]
    369 
    370 Il faut cependant bien garder en tête que `-a` prend le pas sur `-o`, comme dans
    371 de nombreux langages, alors que `&&` et `||` ont la même priorité. Ainsi :
    372 
    373     [ 2 -gt 1 -o 3 -gt 1 -a 2 -gt 3 ]
    374 
    375 devra être réécrit
    376 
    377      [ 2 -gt 1 ] || ( [ 3 -gt 1 ] && [ 2 -gt 3 ] )
    378 
    379 Dans le même style il vaut mieux éviter d'utiliser la négation `!` de test et
    380 lui préférer celle du shell pour éviter les cafouillages si jamais une variable
    381 testée à l'intérieur de test a elle même pour valeur `!` :
    382 
    383     [ ! 2 -gt 1 ]
    384 
    385 devient
    386 
    387     ! [ 2 -gt 1 ]
    388 
    389 [^1]: ce qui n'est pas non plus toujours le cas selon si ce sur quoi il essaye
    390     d'écrire est dispo ou pas mais on va pas rentre là-dedans ici
    391 [^2]: à moins que se trouve dans votre `PATH` un exécutable nommé `0` mais ne
    392     faites pas ça
    393 [^3]: la syntaxe avec le pipe fonctionne puisque, souvenez-vous, la valeur de
    394     sortie d'un pipe est la valeur de sortie de sa dernière commande
    395 [^4]: à vérifier, je dis peut-être une grosse bêtise
    396 [^5]: en plus du fait que la plupart des personnes l'utilisant ne prennent pas
    397     le temps de l'apprendre "sérieusement"