Aller au contenu
Tableaux indexés et associatifs

Tableaux indexés et associatifs

200 Compagnon ⏱ 1 h 30 bashlinuxsshs3scaleway

À la fin, vous saurez

  • Créer, compléter, parcourir et vider un tableau indexé, y compris creux, et en extraire une tranche
  • Choisir entre "${t[@]}", "${t[*]}" et ${t[@]} en prévoyant le découpage en mots qui en résulte
  • Construire une ligne de commande dans un tableau, avec des options conditionnelles, sans chaîne ni eval
  • Transmettre une commande à une machine distante par ssh sans qu'elle soit réinterprétée de travers
  • Compter et regrouper des données avec un tableau associatif, puis produire une sortie triée
  • Passer un tableau à une fonction et en recevoir un, par valeurs ou par nameref, en évitant les collisions de noms
  • Valider les données qui servent d'indice pour fermer la porte à l'injection arithmétique

Prérequis

Testé avec bash 5.2.21 (Ubuntu 24.04), 5.2.37 (Debian 13) jq 1.7.1 openssh 9.6p1 (Ubuntu), 10.0p1 (Debian) , vérifié le 8 octobre 2026

Pourquoi

Deux scripts de Camille posent le même problème sous deux formes.

Le premier, deployer, envoie une archive de version sur sig-app-1 et sig-app-2. Ses options SSH sont rangées dans une chaîne :

OPTS="-o BatchMode=yes -i '/etc/signalements/deploiement/cle de deploiement'"
eval "scp $OPTS $ARCHIVE deploiement@$h:/opt/signalements/versions/"

Camille avait d'abord écrit scp $OPTS ..., constaté que la clé n'était pas trouvée (son chemin contient des espaces), puis ajouté eval « pour que les guillemets marchent ». Ça marche. Ça exécute aussi n'importe quelle commande cachée dans le nom de l'archive, et ce nom vient d'une étiquette de version saisie dans la CI.

Le second, rapport-journaux, compte les codes HTTP renvoyés par l'API sur chaque machine. Il le fait avec un grep -c par couple (hôte, code) : deux hôtes, six codes surveillés, douze lectures complètes des journaux, et un code nouveau (un 429 apparu avec la limitation de débit) n'apparaît jamais dans le rapport, puisqu'il n'est pas dans la liste.

Les deux défauts ont la même cause : Bash n'avait, dans ces scripts, qu'une seule sorte de variable, la chaîne. Une liste d'arguments n'est pas une chaîne, et un ensemble de compteurs non plus. Cette leçon présente les deux structures de données de Bash, le tableau indexé et le tableau associatif, ce qu'elles permettent, ce qu'elles coûtent, et le point où il vaut mieux changer d'outil.

Les concepts

Une variable, plusieurs valeurs

Le manuel de Bash le dit en une phrase : Bash fournit des tableaux à une dimension, indexés ou associatifs. Un tableau indexé (indexed array) associe des valeurs à des entiers positifs ou nuls, à partir de 0. Un tableau associatif (associative array, appelé dictionnaire ou table de hachage dans d'autres langages) associe des valeurs à des chaînes quelconques, les clés.

Chaque élément est une chaîne, rien d'autre. Un élément peut contenir des espaces, des sauts de ligne, des astérisques, une chaîne vide : il reste un élément tant qu'on l'utilise correctement. C'est tout l'intérêt par rapport à une chaîne qu'on découpe à l'usage : le découpage en mots de la leçon 2 ne s'applique plus, parce que les frontières entre valeurs sont mémorisées par le shell au lieu d'être déduites des espaces.

Les tableaux n'existent pas dans le langage de commande POSIX : dash, donc /bin/sh sur Debian et Ubuntu, n'en a pas. La seule liste dont dispose un script sh est celle des paramètres positionnels, "$@". Tout ce qui suit suppose un script lancé par Bash (leçon 1).

Les tableaux indexés

SyntaxeEffet
t=(a "b c" d)crée (ou remplace) le tableau, éléments aux indices 0, 1, 2
t=()tableau vide
t+=(e f)ajoute à la suite du plus grand indice
t[5]=xaffecte l'indice 5, sans toucher aux autres
"${t[1]}"l'élément d'indice 1 ; les accolades sont obligatoires
"${t[-1]}"le dernier élément (Bash 4.3 et suivants)
"${t[@]}"tous les éléments, un mot par élément
"${t[*]}"tous les éléments en un seul mot, séparés par le premier caractère de IFS
${#t[@]}le nombre d'éléments
${#t[1]}la longueur de l'élément d'indice 1
"${!t[@]}"la liste des indices utilisés
"${t[@]:2:3}"une tranche : trois éléments à partir de l'indice 2
unset 't[1]'supprime l'élément d'indice 1 (les autres gardent leur indice)
unset tsupprime le tableau entier
declare -a tdéclare explicitement un tableau indexé

L'indice d'un tableau indexé est une expression arithmétique : t[i+1], t[n-1] sont valides sans $. Ce détail a des conséquences, bonnes (la concision) et mauvaises (on y revient dans « Sécurité » et dans « Pièges courants »).

Les accolades sont obligatoires : $t[1] est compris comme $t suivi du texte [1], et $t seul désigne l'élément d'indice 0. Le manuel le précise : référencer un tableau sans indice équivaut à le référencer avec l'indice 0.

Des indices qui ne se suivent pas

Un tableau Bash est creux (sparse) : rien n'oblige ses indices à être consécutifs. Le manuel l'écrit explicitement : il n'y a ni taille maximale, ni obligation que les éléments soient indexés ou affectés de façon contiguë. t[5]=x sur un tableau de trois éléments ne crée pas d'éléments vides aux indices 3 et 4 ; il crée un quatrième élément, d'indice 5. Et unset 't[1]' laisse un trou.

Conséquence : ${#t[@]} compte des éléments, pas « le plus grand indice plus un ». Une boucle for (( i = 0; i < ${#t[@]}; i++ )) saute des éléments et en lit d'inexistants dès que le tableau a un trou. Pour parcourir, on itère sur les valeurs (for v in "${t[@]}") ou sur les indices réels (for i in "${!t[@]}"), jamais sur un compteur.

"${t[@]}" contre "${t[*]}"

C'est la même distinction qu'entre "$@" et "$*" (leçon 2), appliquée à n'importe quel tableau :

  • "${t[@]}", entre guillemets doubles, produit exactement un mot par élément, sans découpage ni expansion des motifs. Un tableau vide ne produit aucun mot, pas même un mot vide. C'est la forme à utiliser dans 95 % des cas : pour passer des arguments, pour boucler, pour copier.
  • "${t[*]}" produit un seul mot : les éléments joints par le premier caractère de IFS (une espace par défaut). C'est la forme pour afficher ou pour joindre (IFS=, pour une ligne CSV).
  • ${t[@]} sans guillemets soumet chaque élément au découpage en mots et à l'expansion des chemins : c'est revenir à la chaîne qu'on voulait quitter. ShellCheck le signale par SC2068 : « Double quote array expansions to avoid re-splitting elements. »

Les tableaux associatifs

Un tableau associatif se déclare obligatoirement avec declare -A (ou local -A dans une fonction). Sans cette déclaration, h[sig-app-1]=3 crée un tableau indexé, et Bash calcule l'indice comme une expression arithmétique : sig - app - 1. On y revient dans les pièges, c'est l'erreur la plus fréquente avec ces tableaux.

SyntaxeEffet
declare -A h=()crée un tableau associatif vide
declare -A h=([cle]=valeur ["autre clé"]=v2)crée avec des paires
declare -A h=(cle valeur "autre clé" v2)même chose, clés et valeurs alternées (Bash 5.1 et suivants)
h[$cle]=valeuraffecte une clé
"${h[$cle]}"lit une clé ; une clé absente donne la chaîne vide
"${!h[@]}"toutes les clés
"${h[@]}"toutes les valeurs
${#h[@]}le nombre de clés
[[ -v h[$cle] ]]vrai si la clé existe, même avec une valeur vide
unset -v 'h[$cle]'supprime une clé (les apostrophes comptent, voir plus loin)
"${h[@]@k}"clés et valeurs alternées, un mot chacune (Bash 5.2)

Les clés sont des chaînes, sans calcul arithmétique. En revanche, l'ordre dans lequel "${!h[@]}" les rend n'est ni l'ordre d'insertion, ni l'ordre alphabétique : c'est l'ordre interne de la table de hachage (voir « Sous le capot »). Un rapport lisible trie donc toujours les clés explicitement.

Ce que Bash ne sait pas faire

Trois limites sont à connaître avant d'écrire la première ligne :

  • Une seule dimension. Un élément est une chaîne ; il ne peut pas contenir un autre tableau. On simule une table à deux entrées par une clé composée ("$hote $code"), et l'on s'arrête là.
  • Pas d'export. L'environnement d'un processus est une liste de chaînes NOM=valeur passée à execve (leçon 7 de Premiers pas) ; un tableau n'y a pas de représentation. export t ne transmet rien d'utilisable à un programme enfant.
  • Pas de tableau de retour. Une fonction renvoie un code de 0 à 255 et écrit sur sa sortie ; pour « rendre » un tableau, il faut passer par une référence de nom (nameref), comme on le verra.

En pratique

Les sorties ont été produites avec Bash 5.2.21 (celui d'Ubuntu 24.04) et LC_ALL=C.UTF-8, dans un répertoire d'essai. Les messages d'erreur de Bash sont ceux d'un shell interactif (bash: ...) ; dans un script, ils commencent par le nom du script et le numéro de ligne (rapport-journaux: line 12: ...).

Manipuler un tableau indexé

$ hotes=(sig-app-1 sig-app-2)
$ echo "${hotes[0]}"
sig-app-1
$ echo "${hotes[-1]}"
sig-app-2
$ echo "${#hotes[@]}"
2
$ hotes+=(sig-app-3)
$ declare -p hotes
declare -a hotes=([0]="sig-app-1" [1]="sig-app-2" [2]="sig-app-3")

declare -p affiche une variable sous une forme réutilisable comme commande : c'est l'outil de débogage des tableaux, bien plus fiable qu'un echo "${hotes[@]}" qui ne montre pas où commencent et finissent les éléments.

Affectons un indice éloigné, puis supprimons un élément :

$ hotes[5]=sig-app-6
$ declare -p hotes
declare -a hotes=([0]="sig-app-1" [1]="sig-app-2" [2]="sig-app-3" [5]="sig-app-6")
$ echo "${!hotes[@]}"
0 1 2 5
$ unset 'hotes[1]'
$ declare -p hotes
declare -a hotes=([0]="sig-app-1" [2]="sig-app-3" [5]="sig-app-6")
$ echo "${#hotes[@]}"
3

Trois éléments, mais un plus grand indice à 5 : le tableau est creux. Les apostrophes autour de hotes[1] ne sont pas décoratives : sans elles, hotes[1] est un motif pour l'expansion des chemins (les crochets désignent une classe de caractères), et un fichier nommé hotes1 dans le répertoire courant le remplacerait. On le démontre dans les pièges.

Ce que donnent les trois formes d'expansion

On reprend l'outil montrer-args de la leçon 2, qui affiche chaque argument reçu entre chevrons. Le répertoire courant contient deux fichiers, plan-voirie.pdf et plan-eclairage.pdf :

$ fichiers=("photo 1.jpg" "plan*.pdf" "")
$ montrer-args "${fichiers[@]}"
<photo 1.jpg>
<plan*.pdf>
<>
$ montrer-args "${fichiers[*]}"
<photo 1.jpg plan*.pdf >
$ montrer-args ${fichiers[@]}
<photo>
<1.jpg>
<plan-eclairage.pdf>
<plan-voirie.pdf>
  • Avec "${fichiers[@]}", trois arguments, intacts, y compris le vide.
  • Avec "${fichiers[*]}", un seul, où l'on devine l'espace finale laissée par l'élément vide.
  • Sans guillemets, le premier élément est coupé en deux, le second est développé en deux noms de fichiers, et le troisième, vide, a disparu. Quatre arguments, dont aucun n'est celui qu'on avait rangé.

Pour joindre avec un autre séparateur, on change IFS le temps de l'expansion, de préférence dans une fonction avec local IFS ou dans un sous-shell, pour ne pas modifier tout le script :

$ (IFS=,; echo "${fichiers[*]}")
photo 1.jpg,plan*.pdf,

Les tranches, et leur piège sur un tableau creux

$ t=(a b c d e)
$ echo "${t[@]:1:2}"
b c
$ echo "${t[@]: -2}"
d e
$ echo "${t[@]:3}"
d e

${t[@]:début:longueur} rend longueur éléments à partir de l'indice début ; sans longueur, jusqu'à la fin. Un début négatif compte depuis la fin, et l'espace avant le signe moins est obligatoire : ${t[@]:-2} serait l'opérateur « valeur par défaut » de la leçon 3.

Le manuel est précis sur un point qui surprend : le début est un indice, pas une position, et un indice négatif est compté à partir du plus grand indice plus un. Sur un tableau creux :

$ creux=([0]=a [2]=c [10]=k)
$ echo "${creux[@]:1:2}"
c k
$ echo "${creux[@]: -2}"
k

:1:2 commence au premier élément d'indice supérieur ou égal à 1 (c'est c, à l'indice 2), puis en prend deux. : -2 part de l'indice 11 - 2 = 9 et ne trouve que k. Si vous voulez raisonner en positions, renumérotez d'abord :

$ copie=("${creux[@]}")
$ declare -p copie
declare -a copie=([0]="a" [1]="c" [2]="k")

Copier par ("${t[@]}") renumérote toujours à partir de 0. C'est souvent ce que l'on veut, mais pas si les indices portaient une information (un numéro de ligne, par exemple).

Remplir un tableau depuis une commande

La forme qui vient naturellement est la mauvaise :

fichiers=($(find /srv/donnees/exports -name '*.csv'))   # à proscrire

La substitution de commande non protégée subit le découpage en mots et l'expansion des chemins. Une ligne qui contient une espace donne deux éléments, une ligne qui contient * est remplacée par les fichiers du répertoire courant. ShellCheck le signale par SC2207 (« Prefer mapfile or read -a to split command output (or quote to avoid splitting). »), et BashPitfalls en fait son piège n° 50.

Les deux formes correctes, déjà vues en leçon 5 :

# une ligne de sortie = un élément
mapfile -t lignes < <(commande)

# des noms de fichiers quelconques (y compris avec saut de ligne) : séparateur nul
mapfile -t -d '' fichiers < <(find /srv/donnees/exports -name '*.csv' -print0)

# une seule ligne découpée en champs
IFS=, read -r -a champs <<< '42,nid-de-poule,Exempleville,2026-10-07'
$ IFS=, read -r -a champs <<< '42,nid-de-poule,Exempleville,2026-10-07'
$ declare -p champs
declare -a champs=([0]="42" [1]="nid-de-poule" [2]="Exempleville" [3]="2026-10-07")

read -a ne connaît pas les règles du CSV : un champ entre guillemets qui contient une virgule ("lampadaire, éteint") donne deux éléments. Pour un vrai CSV, la leçon 5 recommande un vrai outil.

Construire une ligne de commande : deployer

Revenons au script de déploiement. Ce que reçoit ssh avec la chaîne de Camille, sans eval :

$ OPTS="-o BatchMode=yes -i '/etc/signalements/deploiement/cle de deploiement'"
$ montrer-args ssh $OPTS sig-app-1
<ssh>
<-o>
<BatchMode=yes>
<-i>
<'/etc/signalements/deploiement/cle>
<de>
<deploiement'>
<sig-app-1>

Les apostrophes rangées dans la variable ne sont plus des guillemets : la suppression des guillemets n'a lieu qu'une fois, sur le texte du script, pas sur le résultat d'une expansion (leçon 2). Ce sont des caractères ordinaires, et le découpage en mots coupe le chemin en trois. ssh reçoit -i "'/etc/signalements/deploiement/cle", un fichier qui n'existe pas.

eval « répare » cela en faisant relire toute la ligne par le shell, guillemets compris. Mais il relit tout, y compris le nom de l'archive. Avec une étiquette de version malveillante ou simplement mal saisie :

$ ARCHIVE='signalements-1.4.0$(echo "commande injectée" >&2).tgz'
$ eval "montrer-args scp $OPTS $ARCHIVE sig-app-1:"
commande injectée
<scp>
<-o>
<BatchMode=yes>
<-i>
</etc/signalements/deploiement/cle de deploiement>
<signalements-1.4.0.tgz>
<sig-app-1:>

La substitution de commande cachée dans le nom a été exécutée, avec les droits du compte de déploiement, qui peut se connecter aux deux serveurs de production. Remplacez echo par n'importe quoi d'autre.

La solution de Greg's Wiki (BashFAQ/050) tient en une règle : les variables contiennent des données, les fonctions contiennent du code ; et une liste d'arguments se range dans un tableau.

$ options_ssh=(-o BatchMode=yes -i '/etc/signalements/deploiement/cle de deploiement')
$ montrer-args ssh "${options_ssh[@]}" sig-app-1
<ssh>
<-o>
<BatchMode=yes>
<-i>
</etc/signalements/deploiement/cle de deploiement>
<sig-app-1>

Les guillemets sont interprétés une fois, à l'affectation du tableau, au moment où le shell lit le script. Chaque élément garde ensuite ses frontières, et aucun texte n'est relu comme du code : ni eval, ni injection.

On peut écrire un tableau sur plusieurs lignes, avec un commentaire par option, ce qu'aucune chaîne ne permet proprement :

options_ssh=(
  -o BatchMode=yes                  # jamais de question interactive : échouer plutôt
  -o ConnectTimeout=5               # une machine injoignable ne bloque pas le déploiement
  -o StrictHostKeyChecking=yes      # refuser une clé d'hôte inconnue ou changée
  -o UserKnownHostsFile=/etc/signalements/deploiement/known_hosts
  -i /etc/signalements/deploiement/cle
)

Des options conditionnelles

Un tableau se complète au fil du script. Dans publier-export, une option de simulation pourrait se traduire par un simple ajout :

aws_s3=(aws s3 cp --only-show-errors --endpoint-url "$POINT_ACCES")
if [[ $simulation == 1 ]]; then
  aws_s3+=(--dryrun)
fi
"${aws_s3[@]}" "$archive" "$destination/$annee/$mois/"

--dryrun est l'option documentée de aws s3 cp qui affiche les opérations sans les effectuer ; --endpoint-url est l'option globale qui dirige l'outil vers l'Object Storage de Scaleway au lieu d'AWS. La chaîne de Camille aurait demandé OPTS="$OPTS --dryrun" et une prière. La leçon 8 choisira une voie plus générale, valable pour toutes les commandes et pas seulement pour aws : une fonction executer qui affiche la commande au lieu de la lancer, en s'appuyant sur le même tableau aws_s3.

Un tableau vide disparaît complètement dans "${t[@]}". On en tire un mode simulation générique, qui préfixe chaque commande par echo :

$ prefixe=()
$ "${prefixe[@]}" echo "sans préfixe"
sans préfixe
$ prefixe=(echo "[simulation]")
$ "${prefixe[@]}" scp -- "signalements-1.4.0.tgz" "deploiement@sig-app-1:/opt/signalements/versions/"
[simulation] scp -- signalements-1.4.0.tgz deploiement@sig-app-1:/opt/signalements/versions/

Quand prefixe est vide, le premier mot de la commande est scp ; sinon, c'est echo. Limite à connaître : les redirections et les | ne font pas partie des arguments, ils restent exécutés pour de vrai. Le préfixe convient aux commandes simples, pas aux tubes.

Journaliser la commande qu'on va lancer

Pour tracer la commande exacte avant de l'exécuter, la transformation @Q (Bash 4.4 et suivants) rend chaque élément sous une forme réutilisable comme entrée du shell :

$ distant=(tar -xzf "/opt/signalements/versions/v1.4 finale.tgz" -C /opt)
$ echo "${distant[*]@Q}"
'tar' '-xzf' '/opt/signalements/versions/v1.4 finale.tgz' '-C' '/opt'
$ printf '%q ' "${distant[@]}"; echo
tar -xzf /opt/signalements/versions/v1.4\ finale.tgz -C /opt

Les deux formes se valent pour un journal : on y voit sans ambiguïté où commence et finit chaque argument. Attention à ne pas y faire figurer un secret (voir « Sécurité »).

La commande distante : ssh joint, le serveur relit

Il reste un piège, propre à ssh, et que le tableau seul ne résout pas. La page ssh(1) d'OpenSSH l'écrit : les arguments qui suivent le nom d'hôte sont ajoutés à la commande, séparés par des espaces, avant d'être envoyés au serveur. Côté serveur, sshd confie cette chaîne au shell de connexion du compte, qui l'analyse à nouveau : guillemets, ;, $( ), tout est réinterprété.

On peut reproduire ce que vivra la machine distante avec bash -c, qui fait la même chose localement. Une commande distante dont un argument contient ; :

$ commentaire="version 1.4 ; correctif"
$ distant=(printf '<%s>\n' activer "$commentaire")
$ bash -c "${distant[*]}"
bash: line 1: %s: No such file or directory
bash: line 1: correctif: command not found
$ bash -c "${distant[*]@Q}"
<activer>
<version 1.4 ; correctif>

Sans protection, le shell distant a vu <%s> comme deux redirections, et ; comme la fin d'une commande : correctif a été lancé comme une commande à part entière. Avec @Q, chaque élément arrive protégé par des apostrophes, et le shell distant reconstitue exactement les arguments d'origine.

La règle pour deployer est donc : un tableau localement, et "${distant[*]@Q}" comme commande distante. Deux précisions :

  • Pour une chaîne qui contient un caractère de contrôle (un saut de ligne, une tabulation), @Q et printf %q produisent la forme $'...'. Bash la comprend ; elle n'est entrée dans la norme POSIX qu'en 2024, et tous les shells sh ne la comprennent pas encore. Les comptes de déploiement de sig-app-1 et sig-app-2 ont Bash pour shell, ce qui règle la question ici.
  • Le meilleur moyen de ne pas se tromper sur une commande distante est qu'elle soit courte et fixe : un script installé une fois pour toutes sur les serveurs (/opt/signalements/bin/activer-version), qui reçoit le numéro de version comme seul argument et fait le reste. Le script local n'envoie alors que des données, jamais du code.

Les tableaux associatifs : rapport-journaux

Sur sig-outils, rsyslog range les journaux reçus dans /srv/donnees/journaux/<hôte>/syslog.log (leçon 12 du cours d'administration). L'API, un serveur HTTP Python, y écrit une ligne par requête :

2026-10-07T08:41:07+02:00 sig-app-1 python3[812]: 172.16.8.20 - - [07/Oct/2026 08:41:07] "GET /signalements HTTP/1.1" 200 -

Le code HTTP est le nombre de trois chiffres qui suit le guillemet fermant de la requête. On le capture avec [[ =~ ]] et BASH_REMATCH (leçon 4), puis on compte dans un tableau associatif dont la clé combine l'hôte et le code :

declare -A compte=()       # "hôte code" -> nombre de réponses
declare -A codes_vus=()    # ensemble des codes rencontrés : seules les clés comptent

motif='" ([1-5][0-9][0-9]) '
while IFS= read -r ligne; do
  [[ $ligne =~ $motif ]] || continue
  code=${BASH_REMATCH[1]}
  compte["$hote $code"]=$(( ${compte["$hote $code"]:-0} + 1 ))
  codes_vus[$code]=1
done < "$fichier"

Trois idées à retenir :

  • Une seule lecture de chaque fichier, quel que soit le nombre de codes. Le 429 inconnu de Camille apparaît tout seul, puisque la liste des codes est découverte au lieu d'être fixée.
  • La clé composée "$hote $code" remplace la table à deux dimensions que Bash n'a pas. L'espace est un séparateur sûr ici, parce qu'aucun nom d'hôte ni aucun code n'en contient. Sinon, on choisirait un caractère qui ne peut pas apparaître, comme une tabulation.
  • Le tableau associatif comme ensemble. codes_vus[$code]=1 ne sert qu'à mémoriser la clé : écrire deux fois la même ne crée pas de doublon. C'est la façon idiomatique de dédoublonner en Bash.

L'incrément s'écrit compte[$k]=$(( ${compte[$k]:-0} + 1 )) plutôt que (( compte[$k]++ )), pour une raison qui a sa place dans les pièges : la seconde forme renvoie un code d'échec au premier passage.

Trier les clés pour l'affichage

L'ordre de "${!h[@]}" n'est pas celui d'insertion. Avec sept clés insérées dans l'ordre sig-app-1 sig-app-2 sig-outils "nom avec espace" alpha beta gamma :

$ echo "${!n[*]}"
nom avec espace alpha gamma sig-outils sig-app-1 sig-app-2 beta

Pour un affichage stable, on passe les clés à sort, un par ligne, et on récupère le résultat dans un tableau indexé :

local -a codes
mapfile -t codes < <(printf '%s\n' "${!codes_vus[@]}" | sort -n)

printf '%s\n' réutilise son format pour chaque argument : une clé par ligne. Si les clés pouvaient contenir des sauts de ligne, on passerait à printf '%s\0', sort -z et mapfile -d ''. Et quand l'ordre d'insertion compte (ici, l'ordre des hôtes), on le conserve à côté, dans un tableau indexé : c'est le rôle de hotes dans le script complet plus bas.

Tester la présence d'une clé

Une clé absente et une clé de valeur vide donnent toutes deux "" à la lecture. Pour les distinguer :

if [[ -v compte["$hote 500"] ]]; then ...     # la clé existe (Bash 4.3 et suivants)
if [[ ${compte["$hote 500"]+present} ]]; then ...   # forme équivalente, plus ancienne

${var+mot} (leçon 3) vaut mot si var est définie, même vide, et rien sinon. Les deux formes fonctionnent avec des clés contenant des espaces ou des crochets, comme nous l'avons vérifié avec Bash 5.2.

Pour supprimer une clé, une forme seulement est sûre avec des clés arbitraires :

$ declare -A n=([a]=1 ["a]b"]=2 [x]=4)
$ k='a]b'
$ unset "n[$k]"
$ declare -p n
declare -A n=([x]="4" [a]="1" ["a]b"]="2" )
$ unset -v 'n[$k]'
$ declare -p n
declare -A n=([x]="4" [a]="1" )

Entre guillemets doubles, unset reçoit le texte n[a]b] et ne sait pas où finit la clé : il ne supprime rien, sans message. Entre apostrophes, il reçoit n[$k] et développe lui-même l'indice, une seule fois. Le fichier NEWS de Bash 5.2 décrit ce changement : unset essaie désormais de traiter ses arguments comme des indices de tableau sans les analyser ni les développer une seconde fois.

Passer un tableau à une fonction

Une fonction ne reçoit que des arguments, donc des chaînes. Deux façons de lui transmettre un tableau.

Par valeurs, quand la fonction n'a besoin que des éléments et qu'il n'y a qu'un tableau :

afficher_hotes() {
  local hote
  for hote in "$@"; do printf ' - %s\n' "$hote"; done
}
afficher_hotes "${hotes[@]}"

C'est la forme la plus simple et la plus sûre, mais elle perd les indices et ne permet pas de passer deux tableaux (où finit le premier ?) ni un tableau associatif.

Par nom, avec une référence de nom, ou nameref (declare -n ou local -n, présentée en leçon 6) : la fonction reçoit le nom du tableau, et la variable locale devient un alias de ce tableau.

# somme NOM_DU_TABLEAU : affiche la somme des éléments
somme() {
  local -n _valeurs=$1
  local total=0 v
  for v in "${_valeurs[@]}"; do (( total += v )); done
  echo "$total"
}
durees=(3 4 5)
somme durees        # affiche 12

La même technique permet de rendre un tableau, ce qu'aucun return ne sait faire :

# lister_exports NOM_DU_RÉSULTAT : remplit le tableau nommé avec les CSV présents
lister_exports() {
  local -n _resultat=$1
  _resultat=()
  local f
  for f in /srv/donnees/exports/signalements-*.csv; do
    [[ -e $f ]] && _resultat+=("$f")
  done
}
lister_exports exports
echo "${#exports[@]} exports trouvés"

Le nameref a un défaut, que la leçon 6 a montré pour les scalaires et qui est plus traître avec les tableaux : la collision de noms. Si l'appelant passe un tableau qui porte le même nom qu'une variable locale de la fonction, la référence pointe sur la variable locale :

$ g() { local -n ref=$1; local liste=(interne); echo "g voit : ${ref[*]}"; }
$ liste=(externe)
$ g liste
g voit : interne

Aucun message, un résultat faux. Si c'est le nameref lui-même qui porte le nom passé, Bash avertit (warning: t: circular name reference) mais continue. La parade est une convention : préfixer toutes les variables locales des fonctions qui prennent un nameref par un souligné et le nom de la fonction ou un préfixe peu probable (_valeurs, _resultat), et ne jamais utiliser ce préfixe dans le reste du script.

set -u et les tableaux vides

set -u (nounset), que la leçon 9 placera en tête de tous les scripts, fait échouer toute lecture d'une variable non définie. Les tableaux vides avaient avec lui une relation compliquée : avant Bash 4.4, "${t[@]}" sur un tableau vide déclenchait l'erreur, et l'on trouve encore dans des scripts anciens le contournement ${t[@]+"${t[@]}"}. Avec Bash 5.2 :

$ set -u
$ vide=()
$ echo "n=${#vide[@]} <${vide[@]}>"
n=0 <>
$ declare -a seulement_declare
$ echo "<${seulement_declare[@]}>"
<>
$ echo "n=${#seulement_declare[@]}"
bash: seulement_declare: unbound variable
$ echo "${vide[0]}"
bash: vide[0]: unbound variable

La règle, d'après le manuel : set -u épargne "$@", "$*" et les tableaux indicés par @ ou *, mais un tableau n'est considéré comme défini que si au moins un indice a reçu une valeur. declare -a t seul ne définit rien : ${#t[@]} échoue, alors que t=() passe. D'où la convention de ce cours : toujours initialiser un tableau à sa déclaration, declare -a t=() ou local -a t=(), et ne jamais lire un indice précis sans être sûr qu'il existe (${t[0]-} donne une valeur par défaut vide si besoin).

Sous le capot

Un tableau indexé est une liste chaînée

Le fichier array.h du code source de Bash 5.2 définit un tableau indexé comme une structure qui garde le plus grand indice, le nombre d'éléments, une tête de liste et un pointeur lastref vers le dernier élément consulté ; chaque élément contient son indice, sa valeur, et deux pointeurs next et prev. Le commentaire d'en-tête de array.c le résume : des « listes doublement chaînées creuses », où l'indice d'un élément est rangé avec lui. La liste est circulaire, refermée sur un élément factice d'indice -1, et triée par indice, ce qui explique sans effort la nature creuse des tableaux : un élément absent n'occupe tout simplement pas de maillon. Une implémentation alternative par vecteur existe dans le code (ALT_ARRAY_IMPLEMENTATION), mais l'option de compilation qui l'active, --enable-alt-array-implementation, est désactivée par défaut dans configure.ac.

Le pointeur lastref est, selon le même fichier, un « pointeur baladeur » destiné à optimiser l'accès séquentiel. Pour trouver t[i], la fonction array_reference part de ce dernier élément consulté et avance vers la fin si i est plus grand, recule si i est un peu plus petit, et repart du début si i est inférieur à la moitié de l'indice mémorisé. Le parcours dans l'ordre, croissant ou décroissant, ne coûte donc qu'un maillon par accès ; un accès dans le désordre oblige au contraire à cheminer dans la liste. On le mesure facilement sur un tableau de 50 000 entiers (même poste, même Bash, ordre de grandeur seulement) :

Accès à t[i] pour 50 000 valeurs de iDurée
dans l'ordre croissant0,05 s
dans l'ordre décroissant0,05 s
dans un ordre dispersé ((i*7919) % n)0,61 s
même chose dans un tableau associatif0,21 s

Dix fois plus lent en accès dispersé. Pour un script de quelques centaines d'éléments, c'est sans importance ; pour 50 000 lignes de journaux, c'est le signe qu'il faut confier le traitement à awk ou à Python (voir « En production »).

Un tableau associatif est une table de hachage

Le fichier assoc.h définit un tableau associatif comme une table de hachage (HASH_TABLE, implémentée dans hashlib.c), créée avec ASSOC_HASH_BUCKETS, soit 1 024 alvéoles. La fonction de hachage, d'après le commentaire du fichier, est de la famille FNV-1 (Fowler-Noll-Vo). Quand le nombre d'entrées atteint deux fois le nombre d'alvéoles, la table est quadruplée et chaque entrée est replacée (HASH_REHASH_FACTOR 2, HASH_REHASH_MULTIPLIER 4). Une nouvelle entrée est insérée en tête de la liste de son alvéole.

Ces trois faits expliquent l'ordre « aléatoire » de "${!h[@]}" : Bash parcourt les alvéoles dans l'ordre de leur numéro, qui dépend de la valeur de hachage de chaque clé, et dans chaque alvéole de la plus récente à la plus ancienne. L'ordre est donc déterministe pour une même suite d'insertions et une même version de Bash, mais il change avec les clés, avec l'ordre d'insertion, avec un agrandissement de la table et potentiellement d'une version à l'autre. Ne jamais en dépendre.

Pourquoi un tableau ne s'exporte pas

declare -p montre un attribut x sur un tableau exporté, mais rien n'arrive au processus enfant :

$ export T; T=(a b); declare -p T
declare -ax T=([0]="a" [1]="b")
$ env | grep '^T=' || echo "T absent de env"
T absent de env

L'environnement transmis par execve est un tableau de chaînes NOM=valeur ; Bash n'a pas de codage standard d'un tableau dans une telle chaîne, et n'en transmet donc pas. Pour passer une liste à un autre programme, on passe des arguments ("${t[@]}"), un fichier, ou l'entrée standard.

Ce que voit l'analyseur

L'affectation t=(a "b c" d) est une affectation composée (compound assignment) : le shell applique à chaque mot entre parenthèses toutes les expansions habituelles, guillemets compris, au moment de l'affectation, puis range chaque mot obtenu dans un élément. L'expansion "${t[@]}" est traitée, comme "$@", par une règle spéciale : la paire de guillemets englobante produit autant de mots que d'éléments, sans découpage. C'est ce couple de règles, appliquées aux deux extrémités, qui garantit qu'une valeur ressort exactement comme elle est entrée.

Pièges courants

Oublier declare -A. C'est le plus fréquent, et il est souvent silencieux :

$ codes[sig-app-1]=3
bash: codes[sig-app-1]: bad array subscript
$ sig=5; app=1; h[sig-app]=x; declare -p h
declare -a h=([4]="x")

Sans déclaration, le tableau est indexé, et l'indice est calculé : sig-app-1 vaut 0 - 0 - 1 = -1, refusé ; sig-app, avec deux variables qui existent, vaut 5 - 1 = 4, accepté sans broncher. Déclarez toujours un tableau associatif, et déclarez-le avant la première affectation.

declare -A dans une fonction crée un tableau local. declare utilisé dans une fonction se comporte comme local. Le tableau disparaît au retour de la fonction. Pour créer un tableau global depuis une fonction : declare -gA.

Affecter une chaîne à un tableau. t="Z" n'efface pas le tableau : cela remplace l'élément 0 et garde les autres. Sur un tableau associatif, cela crée une clé "0". Pour vider : t=().

$t au lieu de "${t[@]}". $t est le premier élément. Un for h in $hotes ne parcourt qu'un seul hôte, sans erreur.

s="${t[@]}". Une affectation scalaire joint les éléments, et les frontières sont perdues. ShellCheck : SC2124, « Assigning an array to a string! Assign as array, or use * instead of @ to concatenate. » Écrivez copie=("${t[@]}") pour copier, s="${t[*]}" si vous voulez vraiment joindre.

Remplir par t=($(commande)) ou t=($variable). Découpage et expansion des chemins : SC2207 et SC2206. mapfile -t ou read -r -a.

Parcourir par un compteur. for (( i = 0; i < ${#t[@]}; i++ )) rate des éléments sur un tableau creux. Itérez sur "${t[@]}" ou sur "${!t[@]}".

Les tranches sur un tableau creux. Le début d'une tranche est un indice, pas une position (démonstration plus haut). Renumérotez avec t=("${t[@]}") avant de découper si le tableau a pu être troué par des unset.

(( compte[$k]++ )) qui renvoie un échec. Une expression arithmétique entre (( )) renvoie le code 1 quand sa valeur est 0. Or x++ vaut l'ancienne valeur de x :

$ declare -A n; h=sig-app-1
$ (( n[$h]++ )); echo "statut : $?"
statut : 1

Sans conséquence aujourd'hui, mais fatal dès que la leçon 9 activera set -e : le script s'arrête au premier comptage. Écrivez (( ++n[$h] )) (qui vaut 1, donc vrai) ou, plus lisible, n[$h]=$(( ${n[$h]:-0} + 1 )).

unset t[1] sans apostrophes. Le manuel prévient : un nom avec indice passé en argument à une commande comme unset subit l'expansion des chemins. Avec un fichier nx dans le répertoire courant, unset n[x] devient unset nx : la variable nx (inexistante) est supprimée, l'élément reste. BashPitfalls n° 57. Écrivez unset -v 'n[x]'.

Supposer l'ordre des clés. Triez avec sort, ou gardez l'ordre dans un tableau indexé à côté.

Les vieux contournements. BashPitfalls décrit (n° 61 et 62) des injections de code par les clés de tableaux associatifs dans [[ -v h[$k] ]] et (( h[$k]++ )) : la clé était développée une première fois par le shell, puis une seconde fois par l'évaluation, et une clé comme x$(commande) exécutait la commande. Le remède proposé était d'échapper le $ ((( h[\$k]++ ))). Bash 5.2 a changé la règle : son fichier NEWS annonce qu'il « tente de ne développer les indices de tableaux qu'une seule fois » dans les constructions du shell et les expansions (point 5.2 k), et le fichier COMPAT, à la section compat51, précise que Bash 5.2 « se comporte comme si l'option assoc_expand_once était activée » pour les opérateurs de [[, et ne développe plus deux fois les commandes (( )) ni les expansions $(( )). Nos essais le confirment, et montrent la conséquence pour les scripts anciens : l'échappement est devenu nuisible, (( ++p[\$h] )) incrémente désormais une clé littéralement nommée $h.

$ declare -A p=(); h=sig-app-1
$ (( ++p[\$h] )); declare -p p
declare -A p=(["\$h"]="1" )
$ k='x$(echo INJ >&2)'; [[ -v p[$k] ]] || echo absent
absent

Avec BASH_COMPAT=51, qui demande le comportement de Bash 5.1, la même suite affiche INJ (la commande est exécutée) et la clé sig-app-1 est bien incrémentée. Si vous héritez d'un script qui échappe ainsi ses indices, ou qui positionne BASH_COMPAT, vérifiez avec declare -p. Notez enfin que cette protection ne concerne que la double expansion des clés : l'injection par la valeur d'une variable évaluée comme expression arithmétique fonctionne toujours (voir « Sécurité »).

Écrire un script à tableaux pour sh ou pour macOS. dash refuse la syntaxe (Syntax error: "(" unexpected) ; le Bash 3.2 de macOS n'a ni tableaux associatifs, ni mapfile, ni indices négatifs.

Sécurité

L'injection arithmétique par un indice. L'indice d'un tableau indexé est une expression arithmétique, et une variable qui apparaît dans une expression arithmétique est elle-même évaluée comme une expression. Si elle contient une référence à un tableau avec un indice, cet indice est développé, substitutions de commande comprises. Nos essais avec Bash 5.2, en plaçant dans x la valeur array[$(echo INJ >&2)0] (une donnée lue dans un fichier, par exemple), montrent que la commande cachée est exécutée par chacune de ces constructions :

y=$(( array[x] ))
echo "${array[$x]}"
(( x > 0 ))
[[ $x -gt 0 ]]

C'est le piège n° 45 et 46 de BashPitfalls, et il est toujours d'actualité. Le cas réel : un script qui lit un numéro de ligne ou un identifiant dans un CSV, une requête HTTP ou un argument, et s'en sert comme indice ou comme nombre. La parade est de valider avant tout usage arithmétique :

[[ $id =~ ^[0-9]+$ ]] || mourir "identifiant invalide : $id"

Une valeur réduite à des chiffres ne peut rien contenir d'autre. Attention aux zéros en tête (010 est lu en octal, leçon 3) : $(( 10#$id )) après validation.

eval sur une chaîne construite. Tout ce qui entre dans la chaîne passe par l'analyseur du shell, données comprises. Démonstration faite plus haut avec un nom d'archive. Le tableau rend eval inutile pour construire une commande ; s'il en reste un dans un script, il mérite une justification écrite et une revue.

La commande distante de ssh est relue par le shell du serveur. Protégez chaque argument ("${t[*]@Q}"), et préférez un script fixe installé sur le serveur, qui ne reçoit que des données validées. Le compte deploiement ne devrait d'ailleurs pouvoir exécuter, par sudo, que ce script-là.

Les secrets dans les journaux et dans ps. Journaliser "${commande[*]@Q}" est précieux, jusqu'au jour où la commande contient un mot de passe ou une clé d'API. Et un secret passé en argument est lisible par tous les comptes de la machine dans /proc/<pid>/cmdline (leçon 10 de Premiers pas). Les identifiants de aws passent par son fichier de configuration ou ses variables d'environnement, jamais par la ligne de commande ; et si un argument sensible est inévitable, masquez-le avant de journaliser.

Sérialiser un tableau avec declare -p, puis le recharger avec source. C'est une façon commode de garder un état entre deux exécutions (declare -p compte > etat.sh), mais recharger ce fichier exécute son contenu. Si le fichier est modifiable par un autre compte que celui du script, c'est une porte d'entrée. Rangez-le dans un répertoire qui appartient au compte de service, en 0600, ou préférez un format de données (une ligne clé<TAB>valeur relue par read).

En production

  • Les tableaux tiennent en mémoire. mapfile sur un journal de 2 Go charge 2 Go de chaînes, plus la structure. Les tableaux conviennent aux listes de quelques milliers d'éléments : hôtes, fichiers d'un jour, options. Un traitement en flux (while read, awk) ne garde qu'une ligne à la fois.
  • Quand passer à awk. Compter par clé est exactement ce que fait un tableau associatif d'awk, en C et sans fork : awk '{ n[$2]++ } END { for (k in n) print k, n[k] }'. Au-delà de quelques dizaines de milliers de lignes, rapport-journaux gagnerait à déléguer le comptage à awk et à garder Bash pour l'orchestration. La leçon 5 en donne les critères.
  • Quand passer à jq ou à Python. Dès que les données ont une structure (un objet qui contient une liste, une liste d'objets), les tableaux à une dimension de Bash obligent à inventer des encodages fragiles. Une réponse JSON d'API se traite avec jq, qui renvoie au besoin une valeur par ligne à mapfile. Et quand le script manipule plusieurs structures imbriquées, c'est le signal de la leçon 1 : Python, avec ses dictionnaires et ses listes, sera plus court et plus sûr.
  • Lisibilité. Nommez les tableaux au pluriel (hotes, options_ssh), déclarez-les en tête de fonction avec leur type (local -a, local -A) et un commentaire qui décrit la forme des clés (# "hôte code" -> nombre). En revue, declare -p dans un message de débogage vaut toutes les explications.
  • Portabilité. Un script à tableaux exige Bash 4 (tableaux associatifs, mapfile), 4.3 (indices négatifs, -v sur un élément), 4.4 (@Q, set -u serein), 5.1 (paires alternées) ou 5.2 (@k). Ubuntu 24.04 et Debian 13 ont 5.2 ; un serveur RHEL 8 a 4.4. Si le script doit tourner ailleurs, notez la version minimale en commentaire, voire testez-la : (( BASH_VERSINFO[0] >= 5 )) || mourir "Bash 5 requis".

État des scripts après cette leçon

rapport-journaux, complet (il utilise mourir de lib/commun.sh, leçon 6) :

#!/usr/bin/env bash
# rapport-journaux : nombre de réponses HTTP par hôte et par code,
# d'après les journaux reçus sur sig-outils.

REP_OUTILS=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")/..
# shellcheck source=../lib/commun.sh
source "$REP_OUTILS/lib/commun.sh"

repertoire=${REPERTOIRE_JOURNAUX:-/srv/donnees/journaux}

declare -a hotes=()        # hôtes, dans l'ordre des répertoires
declare -A compte=()       # "hôte code" -> nombre de réponses
declare -A codes_vus=()    # ensemble des codes rencontrés (seules les clés comptent)

# compter_fichier HÔTE FICHIER : ajoute à compte les codes trouvés dans FICHIER.
compter_fichier() {
  local hote=$1 fichier=$2 ligne code
  local motif='" ([1-5][0-9][0-9]) '
  while IFS= read -r ligne || [[ -n $ligne ]]; do
    [[ $ligne =~ $motif ]] || continue
    code=${BASH_REMATCH[1]}
    compte["$hote $code"]=$(( ${compte["$hote $code"]:-0} + 1 ))
    codes_vus[$code]=1
  done < "$fichier"
}

main() {
  local chemin hote code
  shopt -s nullglob
  for chemin in "$repertoire"/*/syslog.log; do
    hote=${chemin%/syslog.log}     # /srv/donnees/journaux/sig-app-1.pn-signalements.internal
    hote=${hote##*/}               # sig-app-1.pn-signalements.internal
    hote=${hote%%.*}               # sig-app-1
    hotes+=("$hote")
    compter_fichier "$hote" "$chemin"
  done
  (( ${#hotes[@]} > 0 )) || mourir "aucun journal sous $repertoire"

  local -a codes=()
  mapfile -t codes < <(printf '%s\n' "${!codes_vus[@]}" | sort -n)

  printf '%-10s' "machine"
  printf '%6s' "${codes[@]}"
  printf '\n'
  for hote in "${hotes[@]}"; do
    printf '%-10s' "$hote"
    for code in "${codes[@]}"; do
      printf '%6d' "${compte["$hote $code"]:-0}"
    done
    printf '\n'
  done
}

if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
  main "$@"
  exit
fi

Sur deux petits journaux d'essai, rangés comme sur sig-outils dans journaux/sig-app-1.pn-signalements.internal/ et journaux/sig-app-2.pn-signalements.internal/, avec HORODATER=0 exporté comme dans toute la suite du cours (leçon 6) :

$ REPERTOIRE_JOURNAUX=./journaux bin/rapport-journaux
machine      200   201   404   500   503
sig-app-1      4     1     1     1     0
sig-app-2      2     0     2     0     1
$ REPERTOIRE_JOURNAUX=/nulle/part bin/rapport-journaux; echo "code=$?"
rapport-journaux : erreur : aucun journal sous /nulle/part
code=1

printf '%6s' "${codes[@]}" réutilise son format pour chaque code : l'en-tête s'adapte tout seul aux codes rencontrés.

La partie de deployer qui construit les commandes (le parallélisme et le retour arrière viendront en leçon 11, les options en leçon 8) :

hotes=(sig-app-1 sig-app-2)
compte_distant=deploiement
options_ssh=(
  -o BatchMode=yes
  -o ConnectTimeout=5
  -o StrictHostKeyChecking=yes
  -o UserKnownHostsFile=/etc/signalements/deploiement/known_hosts
  -i /etc/signalements/deploiement/cle
)

deployer_sur() {
  local hote=$1 archive=$2 version=$3
  local -a prefixe=()
  if [[ ${SIMULATION:-0} == 1 ]]; then
    prefixe=(echo "[simulation]")
  fi
  local -a distant=(sudo /opt/signalements/bin/activer-version "$version")

  journaliser "$hote : envoi de la version $version"
  "${prefixe[@]}" scp "${options_ssh[@]}" -- "$archive" \
    "$compte_distant@$hote:/opt/signalements/versions/" \
    || { avertir "$hote : échec de l'envoi"; return 1; }
  "${prefixe[@]}" ssh "${options_ssh[@]}" "$compte_distant@$hote" "${distant[*]@Q}" \
    || { avertir "$hote : échec de l'activation"; return 1; }
}

main() {
  local archive=${1:-} nom version hote
  [[ -f $archive ]] || mourir "archive introuvable : $archive"
  nom=${archive##*/}
  [[ $nom =~ ^signalements-([0-9]+\.[0-9]+\.[0-9]+)\.tgz$ ]] \
    || mourir "nom d'archive inattendu : $nom"
  version=${BASH_REMATCH[1]}
  for hote in "${hotes[@]}"; do
    deployer_sur "$hote" "$archive" "$version" \
      || mourir "déploiement interrompu sur $hote"
  done
}

La validation du nom par une expression régulière stricte complète le tableau : même protégé, un numéro de version n'a aucune raison de contenir autre chose que des chiffres et des points. Comme le veut la règle de la leçon 6, deployer_sur signale l'échec par un statut et c'est main seule qui décide d'arrêter le script. La commande distante est fixe : activer-version bascule le lien /opt/signalements/courant vers la version reçue et redémarre le service, et le fichier sudoers du compte deploiement n'autorise que ce script.

Exercices

1. Prévoir les expansions (niveau 100). Sans exécuter, donnez le nombre d'arguments reçus par montrer-args et leur contenu, puis vérifiez avec declare -p et montrer-args.

t=("a b" "" "c*")
t[4]=e
montrer-args "${t[@]}"
montrer-args "${t[*]}"
echo "${#t[@]} ${!t[*]}"
montrer-args "${t[@]:1:2}"
Solution

"${t[@]}" : quatre arguments, <a b>, <>, <c*>, <e> ; l'élément vide est conservé, l'astérisque n'est pas développé. "${t[*]}" : un seul argument, <a b c* e> (deux espaces consécutives à cause de l'élément vide). echo affiche 4 0 1 2 4 : quatre éléments, indices 0, 1, 2 et 4 (le 3 n'existe pas). La tranche :1:2 part de l'indice 1 et prend deux éléments : <> et <c*>. Si l'on avait demandé :3:1, on aurait obtenu <e>, premier élément d'indice supérieur ou égal à 3.

2. Réparer la chaîne d'options (niveau 200). Un autre script hérité contient :

OPTIONS="--endpoint-url https://s3.fr-par.scw.cloud --only-show-errors"
if [ "$SIMULATION" = 1 ]; then OPTIONS="$OPTIONS --dryrun"; fi
if [ -n "$TYPE" ]; then OPTIONS="$OPTIONS --content-type '$TYPE'"; fi
aws s3 cp $OPTIONS "$fichier" "s3://sig-exports-mairie/$annee/$mois/"

Avec TYPE='text/csv; charset=utf-8', la commande échoue. Expliquez pourquoi, puis réécrivez ces lignes avec un tableau, sans eval.

Solution

Le découpage en mots coupe 'text/csv; et charset=utf-8' en deux arguments, apostrophes comprises (elles sont des caractères ordinaires dans la variable) : aws reçoit un type de contenu invalide suivi d'un argument inattendu. Réécriture :

options=(--endpoint-url https://s3.fr-par.scw.cloud --only-show-errors)
if [[ ${SIMULATION:-0} == 1 ]]; then options+=(--dryrun); fi
if [[ -n ${TYPE:-} ]]; then options+=(--content-type "$TYPE"); fi
aws s3 cp "${options[@]}" "$fichier" "s3://sig-exports-mairie/$annee/$mois/"

--content-type et sa valeur sont deux éléments distincts, et la valeur reste un seul argument, point-virgule et espace compris. Vérification sans rien envoyer : remplacer aws par montrer-args.

3. Les codes par hôte, en pourcentage (niveau 200). Ajoutez à rapport-journaux une colonne finale erreurs qui donne, pour chaque hôte, le pourcentage de réponses de la classe 5xx, arrondi à l'entier. Le total par hôte doit être calculé pendant la lecture, sans relire les fichiers.

Solution

Un troisième tableau associatif, total, indexé par hôte, incrémenté dans compter_fichier à chaque code reconnu : total[$hote]=$(( ${total[$hote]:-0} + 1 )). Et un quatrième, erreurs, incrémenté quand [[ $code == 5* ]]. À l'affichage, en arithmétique entière (leçon 3), un arrondi au plus proche :

local t=${total[$hote]:-0} e=${erreurs[$hote]:-0} pct=0
(( t > 0 )) && pct=$(( (e * 100 + t / 2) / t ))
printf '%8d%%' "$pct"

Le t / 2 ajouté avant la division donne l'arrondi au plus proche au lieu de la troncature ; le test t > 0 évite la division par zéro, qui est une erreur fatale en Bash (division by 0). Avec les données d'essai : sig-app-1, 1 sur 7, soit 14 % ; sig-app-2, 1 sur 5, soit 20 %. On aurait aussi pu calculer ces pourcentages à partir de compte en sommant les codes 5*, mais compter pendant la lecture évite une seconde boucle.

4. Dédoublonner en gardant l'ordre (niveau 200). Écrivez une fonction unique ENTREE SORTIE qui reçoit deux noms de tableaux indexés et remplit le second avec les éléments du premier, sans doublons, dans l'ordre de leur première apparition. Elle doit fonctionner avec des éléments qui contiennent des espaces et avec un tableau d'entrée vide, sous set -u. Testez-la avec hotes=(sig-app-2 sig-app-1 "sig app" sig-app-2).

Solution
# unique ENTREE SORTIE : copie ENTREE dans SORTIE sans doublons, ordre conservé.
unique() {
  local -n _unique_entree=$1 _unique_sortie=$2
  local -A _unique_vus=()
  local _unique_e
  _unique_sortie=()
  for _unique_e in "${_unique_entree[@]}"; do
    [[ -v _unique_vus[$_unique_e] ]] && continue
    _unique_vus[$_unique_e]=1
    _unique_sortie+=("$_unique_e")
  done
}

set -u
hotes=(sig-app-2 sig-app-1 "sig app" sig-app-2)
unique hotes distincts
declare -p distincts
# declare -a distincts=([0]="sig-app-2" [1]="sig-app-1" [2]="sig app")
vide=()
unique vide rien
declare -p rien
# declare -a rien=()

Le tableau associatif sert d'ensemble (« déjà vu ? »), le tableau indexé de sortie conserve l'ordre, ce que le premier ne sait pas faire. Les noms préfixés par _unique_ évitent les collisions avec les tableaux de l'appelant : essayez de nommer l'entrée _unique_vus pour voir ce qui se passerait sans cette précaution, puis lisez l'avertissement circular name reference en appelant unique _unique_entree x. Un tri sort -u aurait aussi dédoublonné, mais en perdant l'ordre.

Récapitulatif

  • Bash a deux structures de données à une dimension : le tableau indexé (t=(...), indices entiers, creux) et le tableau associatif (declare -A, clés chaînes, déclaration obligatoire).
  • "${t[@]}" : un mot par élément, rien pour un tableau vide ; "${t[*]}" : un seul mot joint par IFS ; sans guillemets : découpage et expansion des chemins (SC2068).
  • ${#t[@]} compte des éléments, "${!t[@]}" donne les indices ou les clés ; on itère sur l'un ou l'autre, jamais sur un compteur.
  • Une ligne de commande se construit dans un tableau, complété par += ; plus de chaîne d'options, plus d'eval. "${t[*]@Q}" la journalise et la protège pour une commande ssh, que le serveur relit.
  • Un tableau associatif compte (h[$k]=$(( ${h[$k]:-0} + 1 ))) et sert d'ensemble ; son ordre n'est pas garanti : sort, ou un tableau indexé à côté.
  • Un tableau se passe à une fonction par valeurs ("${t[@]}") ou par nom (local -n), avec des noms locaux préfixés contre les collisions.
  • Initialisez toujours (local -a t=()) : sous set -u, un tableau seulement déclaré n'est pas défini. unset -v 't[i]' entre apostrophes.
  • Validez toute donnée externe utilisée comme indice ou comme nombre : l'injection arithmétique fonctionne encore en Bash 5.2.
  • Sous le capot : liste doublement chaînée pour les indexés (rapide en séquence, lente en désordre), table de hachage FNV-1 pour les associatifs. Au-delà de quelques milliers d'éléments ou de données imbriquées : awk, jq, Python.

Pour aller plus loin

  • La section Arrays du manuel de Bash, courte et dense, et la section Shell Parameter Expansion pour les transformations @Q, @A, @K et @k.
  • Greg's Wiki : BashFAQ/005 pour les tableaux en général, BashFAQ/050 pour la règle « les variables contiennent des données, les fonctions du code », et les pièges 45 à 62 de BashPitfalls, à lire en gardant en tête que certains visent des versions antérieures à 5.2.
  • Le fichier NEWS de Bash, qui date chaque nouveauté sur les tableaux : utile pour savoir ce qu'un serveur ancien acceptera.
  • Le manuel de jq, pour le jour où vos données auront une structure.
  • La leçon suivante, Arguments, options et interface, qui donne à publier-export et à deployer une vraie interface en ligne de commande, en s'appuyant sur les tableaux pour accumuler les options.
+20 XP Carte du ciel →Mon cosmonaute →

Sources