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  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"