Je n'aime pas faire des scripts shell
Je fais beaucoup de scripts shell pour Linux, et je n'ai jamais aimé faire ça.
Par faire des scripts shell
, je veux dire écrire des petits programmes en langage shell script qui est interprété par dash
(POSIX
), bash
ou zsh
.
Vu que les termes sont un peu mélangé, à partir de maintenant je vais appelé script shell
le langage commun à dash
, bash
or zsh
.
Laissez-moi vous expliquer pour je fais scripts shell, vous montrer les problèmes qui font que je n'aime pas en faire, et vous montrer quelques pièges qui valide mon dégoût pour ce langage.
Pourquoi est-ce que je fais des scripts shell?
J'aime que les choses soient automatisées le plus possible sur mon ordinateur, et je suis également payé pour faire ça en tant que développeur. Vu que j'utilise Linux (ou GNU/Linux si vous préférez), le meilleur moyen pour faire cette automatisation est d'écrire des scripts shell plutôt qu'utiliser un autre outil.
La première raison pour laquelle j'utilise le script shell est pour sa disponibilité.
Quand je commence mes scripts par un #!/bin/sh
, je sais qu'ils vont certainement fonctionner sur n'importe quel système Linux.
Ce n'est pas le cas avec Python ou Tcl.
La seconde raison est pour que ce soit valide dans la durée.
Si j'écris un script une fois, surtout en POSIX
, je sais que ce sera encore valide plus tard.
Ce n'est pas le cas avec Python.
Le troisième raison pour la performance. Souvent les scripts shell tournent plus vite que les scripts Python.
Toutes ces raisons sont d'excellentes raisons pour utiliser le script shell. Maintenant parlons de des problèmes que me font détester ce langage.
Quelques problèmes
Je vais montrer ici les problèmes les plus évident du script shell. Si vous connaissez déjà bien ce langage, vous serez certainement plus intéressez par les pièges que je décris dans la section suivante.
La syntaxe
Une fois que vous écrivez des scripts shell en ayant en tête que tout est commande
, alors en écrire peut être une expérience agréable.
Mais avant d'avoir ça en tête, vous allez certainement être étonné de découvrir que les scripts suivants ne sont pas bons.
Cette erreur est celle que je fais le plus quand j'ai commencé à écrire des scripts shell puisque que j'ai commencé à programmer avec le langage C.
#!/bin/sh
my_var="foo"
if["$my_var" = "foo"]; then
echo "my_var is foo"
fi
Si vous utilisez ce script, vous aurez un message d'erreur absolument pas explicite.
3: Syntax error: "then" unexpected
Contrairement au C où les parenthèses ne sont que des ponctuations et qu'elles n'ont pas besoin d'être séparées du reste par des espaces, en script shell le caractère [
est une commande et non une ponctuation.
D'ailleurs vous pouvez voir son fonctionnement en tapant man [
dans un terminal.
Quand vous avez en tête que tout est commande
, vous arrêtez de faire ce genre d'erreur car vous êtes plus prudent à séparer toutes les commandes.
En parlant d'erreurs à cause des espaces, voici une autre erreur classique de débutant.
#!/bin/sh
if [ $1 = 'the argument' ]; then
echo 'Your first argument is "the argument".'
fi
Faîtes tourner ce script comme ça.
./print-argument "the argument"
Et vous aurez un message d'erreur bizarre et inattendu.
./print-argument: 2: [: the: unexpected operator
Pourquoi?
Parce que vous avez oublié de mettre des guillemets autour de $1
donc cette variable s'est étendue en the argument
.
L'interpréteur ne s'attendait pas à trouver là la commande the
après la commande ]
.
Je n'imagine pas le nombre d'heures qui ont été perdues à cause de cette erreur en particulier. Cette erreur est tellement fréquente que le conseil qu'on donne aux utilisateurs est de ne jamais mettre d'espace dans les noms de fichier. C'est ridicule: cela ne devrait pas être à l'utilisateur de s'adapter à langage informatique, mais plutôt l'inverse!
Une structure de données à la ramasse
En gros en script shell la seule structure de données qui existe est la chaîne de caractères. Il n'y a pas de structure, pas de table associatif (ou dictionnaire ou table de hachage), pas de set, et les tableaux en Bash ressemblent plus à du bricolage qu'autre chose.
Ce manque de structure de données est tellement ridicule que vous devez souvent bidouiller des trucs comme echo "$my_var" | cut -f2
pour grouper et accéder à de plusieurs données stockées dans une variable!
Si vous avez besoin dans vos scripts de faire quelque chose d'un peu plus élaboré qu'une liste d'instructions avec des chaînes de caractères, alors vous devriez utiliser un autre langage. Et si vous utilisez un autre langage, ne choisissez pas Awk si vous avez besoin d'une liste associative dans une liste associative!
Inconsistance
Quand vous cherchez de l'aide en ligne, la plupart du temps vous trouverez des choses pour Bash mais non pas pour POSIX.
Mais POSIX, Bash et Zsh partagent tous les trois beaucoup de similarités mais ont des différences subtiles.
Ne vous attendez à ce que read
, set
, trap
ou même les instructions de test fonctionnent de la même manière partout.
Même si vous choisissez un seul type de script shell vous pouvez avoir des soucis.
Par exemple avec Zsh, si vous mettez dans un script Zsh echo "Hello World!"
cela affichera Hello World!
correctement, mais si vous écrivez cette commande dans un terminal alors il y aura une erreur comme quoi il manque un guillemet!
Et parce que le langage script shell est tellement limité, vous êtes obligé de vous baser énormément sur les programmes installés sur le système.
Vous devez vous demander si votre script sera exécuté sur un système UNIX
ou sur un système GNU/Linux
.
Quand vous utilisez awk
, est-ce POSIX awk ou GNU awk?
Quand vous utilisez grep
, est-ce POSIX grep ou GNU grep?
Il est très facile de faire un script qui fonctionne sur ma machine
mais qui n'est pas exploitable en production.
Quelques pièges
Maintenant laissez moi vous montrer quelques pièges que j'ai découvert.
Cela prouve qu'on ne peut pas écrire le mot shell
sans écrire le mot hell
.
Utiliser trap
dans une fonction
Le premier piège que je veux vous montrer inclus trap
!
(Je sais, cette remarque est moins drôle en français qu'en anglais.)
J'utilise ici uniquement Bash parce que ce code n'est pas compatible avec dash
.
Vous savez que quand une fonction ou une commande retourne 0
, alors cela signifie true
.
Et cela retourne une autre valeur, cela signifie false
.
Pour avoir la valeur de retour de la dernière fonction ou commande exécutée, vous pouvez vérifier la variable $?
.
Si dans un if
vous voulez si cela correspond à true
ou false
, vous pouvez exécuter la fonction ou la commande directement dans le if
.
Ici un exemple pris de la page wiki du code d'erreur SC2181 de ShellCheck.
(Vous devriez utiliser ShellCheck pour vérifier vos scripts shell!)
make mytarget
if [ $? -ne 0 ]; then
echo "Build failed"
fi
# Le même comportement (spoiler alert: c'est faux!):
if ! make mytarget; then
echo "Build failed"
fi
Pour vous montrer le piège ici, je vais créer une fonction return_false
qui retourne 1
.
#/bin/bash
return_false() {
true
return 1
}
return_false
echo "$?"
if return_false; then
echo "This is true."
else
echo "This is false."
fi
Le code a le comportement attendu quand on l'exécute.
1
This is false.
Maintenant je veux une fonction plus élaborée.
Je veux que cette fonction renvoie 1
seulement si une commande qu'elle exécute renvoie quelque chose autre que 0
.
Si aucune commande n'échoue, alors la fonction n'échoue pas donc renvoie 0
.
Pour faire ça, j'utilise trap
.
#!/bin/bash
return_false() {
local old_ERR_trap=$(trap -p ERR)
if [[ -z $old_ERR_trap ]]; then old_ERR_trap="trap - ERR"; fi
trap 'local ret=$?; eval "$old_ERR_trap"; [[ $ret -ne 0 ]] && to_return=1' ERR
to_return=0
false
true
return $to_return
}
return_false
echo "$?"
if return_false; then
echo "This is true."
else
echo "This is false."
fi
Et ici on a un comportement étrange.
1
This is true.
Comme vous le voyez, même si la fonction retourne 1
, cela est interprété comme 0
quand c'est vérifié directement dans un if
.
Heureusement ce problème ne se produit pas quand trap
est utilisé dans un autre script, il survient uniquement quand c'est utilisé dans une fonction.
Comme toujours, ne suivez pas aveuglément les conseils d'un linter.
Utiliser read
dans read
Disons que j'ai un fichier lines.txt
qui contient ces lignes suivantes.
Line 1
Line 2
Line 3
Line 4
Je veux lire ce fichier ligne par ligne dans un script nommé read-lines
.
La première façon de faire que je trouve sur internet, et la façon la plus courante, est celle-ci.
#!/bin/sh
while IFS= read -r line; do
printf '%s\n' "$line"
done < lines.txt
Le script affiche le contenu de lines.txt
.
Maintenant je veux demander à l'utilisateur si une ligne doit être affichée.
Je crée un script nommé ask-confirmation
qui retourne 0
si l'utilisateur insère y
pour confirmer, sinon 1
.
#!/bin/sh
printf '%s (y/N) ' "$1"
read -r result
[ "$result" = 'y' ]
Maintenant j'implémente ce petit script dans read-lines
.
Je le teste avant de réellement l'implémenter.
D'abord je mets du code faux.
#!/bin/sh
line_number=1
line="This is a fake line just to test."
if ./ask-confirmation "Do you want to print line #$line_number?"; then
printf '\t%s\n' "$line"
fi
# while IFS= read -r line; do
# printf '%s\n' "$line"
# line_number=$((line_number+1))
# done < lines.txt
Puis je teste.
(Les lignes qui commencent par $
sont des commandes que j'ai exécutées dans mon terminal.
$ ./read-lines
Do you want to print line #1? (y/N) y
This is a fake line just to test.
$ ./read-lines
Do you want to print line #1? (y/N) N
$ ./read-lines
Do you want to print line #1? (y/N)
$
Génial, cela fonctionne comme prévu donc je peux implémenter ça pour de vrai!
#!/bin/sh
line_number=1
while IFS= read -r line; do
line="This is a fake line just to test."
if ./ask-confirmation "Do you want to print line #$line_number?"; then
printf '\t%s\n' "$line"
fi
line_number=$((line_number+1))
done < lines.txt
Et maintenant voici le résultat pour ma plus grande déception.
(Le caractère %
à la fin signifie le script a affiché ça sans caractère de fin de ligne à la fin.
$ ./read-lines
Do you want to print line #1? (y/N) Do you want to print line #2? (y/N) %
$
Comme vous pouvez le constater, je n'ai pas pu insérer quoi que ce soit.
C'est le comportement typique quand vous utilisez un read
and un read
.
Et c'est très facile de tomber dans ce piège quand vous utilisez la méthode recommandée pour lire un fichier ligne par ligne.
Cela arrive aussi quand dans votre boucle vous exécutez un programme qui demande une action fossil sync
, git push
...
Utiliser cd
dans un script
Disons que j'ai un projet, et dans ce projet j'ai un dossier contenant plusieurs scripts. Un de ces scripts permet de compiler (build) ce projet. J'ai cette hiérarchie.
Racine du projet (project root): /tmp/My-Project/
Scripts du projet: /tmp/My-Project/scripts/
Script pour compiler (build script): /tmp/My-Project/scripts/build-project
Je veux pouvoir exécuté build-project
de n'importe dans mon système.
Voici à quoi ressemble le début de ce script.
#!/bin/sh
echo "dirname is: $(dirname "$0")"
echo "Project root path is: $(cd "$(dirname "$0")"/.. && pwd -P)"
Quand je suis dans /tmp/My-Project
, exécuté le script en faisant ./scripts/build-project
retourne ceci.
dirname is: ./scripts
Project root path is: /tmp/My-Project
Maintenant je sais que la plupart des scripts utilisés dans build-project
se trouvent dans scripts
, et je n'ai pas envie de préfixer partout leurs appels avec le chemin racine du projet.
Je décide donc d'utiliser cd
dans le script pour que mon script soit exécuté dans /tmp/My-Project/scripts
.
#!/bin/sh
cd "$(dirname "$0")"
echo "dirname is: $(dirname "$0")"
echo "Project root path is: $(cd "$(dirname "$0")"/.. && pwd -P)"
Mais maintenant quand je suis dans /tmp/My-Project
comme précédemment, exécuter le script en faisant ./scripts/build-project
fait planter le résultat pour le chemin racine du projet alors même que dirname
est le même!
dirname is: ./scripts
Project root path is: /tmp/My-Project/scripts
Je ne sais pas pourquoi cela arrive.
Tout ce que je sais c'est que je devrais toujours récupérer le chemin racine du projet avant d'utiliser le moindre cd
dans le script.
Argument expansion avec echo
et printf
Dans cet exemple je crée un script print-args
qui affiche les arguments.
J'exécute toujours ce script de cette manière.
./print-args "a b c" "x y z"
Pour accéder à la liste d'arguments, vous pouvez utiliser $*
ou $@
.
J'utilise echo
pour afficher ces variables.
#!/bin/sh
echo "\$@: \"$@\""
echo "\$*: \"$*\""
Cela retourne le résultat attendu.
$@: "a b c x y z"
$*: "a b c x y z"
Utiliser echo
n'est pas idéal: je dois échapper les caractères spéciaux et ce n'est pas très portable (soi-disant).
Je change mon code pour utiliser printf
#!/bin/sh
printf '$@: %s\n' "$@"
printf '$*: %s\n' "$*"
Et maintenant j'ai un résultat complètement inattendu!
$@: a b c
$@: x y z
$*: a b c x y z
Pour le comportement a changé pour $@
?
La réponse ce trouve dans le manuel de dash
.
$* Expands to the positional parameters, starting from one.
When the expansion occurs within a double-quoted string it
expands to a single field with the value of each parameter
separated by the first character of the IFS variable, or by
a ⟨space⟩ if IFS is unset.
$@ Expands to the positional parameters, starting from one.
When the expansion occurs within double-quotes, each posi‐
tional parameter expands as a separate argument. If there
are no positional parameters, the expansion of @ generates
zero arguments, even when @ is double-quoted. What this ba‐
sically means, for example, is if $1 is “abc” and $2 is “def
ghi”, then "$@" expands to the two arguments:
"abc" "def ghi"
Cela signifie que cette ligne.
printf '$@: %s\n' "$@"
Équivaut dans cet exemple à cette ligne.
printf '$@: %s\n' "a b c" "x y z"
C'est évident
quand c'est explicitement décrit comme ici.
Mais imaginez que vous n'êtes pas un développeur expérimenté de script shell, vous ne connaissez pas la différence entre $*
et $@
.
Peut-être que vous écririez ce code suivant.
if [ $# -ge 2 ]; then
printf 'The second argument in "%s" is "%s".\n' "$@" "$2"
fi
Et donc le résultat ne sera certainement pas à quoi vous vous attendiez.
The second argument in "a b c" is "x y z".
The second argument in "x y z" is "".
Conclusion
Après des années d'expérience, je dois encore régulièrement faire de petits scripts juste pour tester le comportement du langage avant d'implémenter du code dans mes scripts. C'est ridicule que je doive encore faire ça.
Quand j'écris un script, je veux écrire ça aussi vite et correct que possible. Tous ces pièges et toute cette étrangeté du langage script shell réduit énormément ma productivité.
Et malheureusement pour le moment, script shell est le meilleur langage que je peux utiliser pour faire des scripts portables, valides et rapides. Donc je vais continuer de l'utiliser, même si je n'aime pas faire des scripts shell.