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.

print-argument
#!/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.

lines.txt
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.

read-lines
#!/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.

ask-confirmation
#!/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.

read-lines
#!/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!

read-lines
#!/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.

build-project
#!/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.

build-project
#!/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.

print-args
#!/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

print-args
#!/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.