Passer au contenu principal
Linux, OS

Boucles Bash : for, while et until sans tourner en rond

Une boucle est l’un des mécanismes les plus utiles de la programmation.

Elle permet de transformer :

faire quelque chose une fois

en :

faire quelque chose 10 fois
faire quelque chose pour chaque fichier
faire quelque chose tant qu'une condition est vraie
faire quelque chose jusqu'à ce qu'un service réponde

En Bash, les boucles sont particulièrement utiles parce que l’administration système consiste précisément à répéter les mêmes opérations sur :

  • des fichiers ;
  • des utilisateurs ;
  • des serveurs ;
  • des services ;
  • des lignes de texte ;
  • des sauvegardes ;
  • des paquets ;
  • des machines distantes.

Une boucle bien écrite peut faire gagner plusieurs heures.

Une boucle mal écrite peut effectuer plusieurs milliers de fois une opération que vous n’auriez déjà pas dû effectuer une seule fois.

Le principal avantage d’une boucle est qu’elle répète exactement ce qu’on lui demande. C’est également son principal défaut.

Qu’est-ce qu’une boucle ?

Une boucle est une structure de contrôle permettant d’exécuter plusieurs fois un bloc de commandes.

On peut répéter :

  • pour chaque élément d’une liste ;
  • tant qu’une condition réussit ;
  • jusqu’à ce qu’une condition réussisse ;
  • un nombre déterminé de fois ;
  • indéfiniment jusqu’à une interruption.

Conceptuellement :

début
  ↓
tester / choisir un élément
  ↓
exécuter des commandes
  ↓
recommencer ?
  ├── oui → nouvelle itération
  └── non → sortie de la boucle

Chaque passage dans la boucle s’appelle une :

itération

Les principales boucles Bash

Bash fournit principalement :

Construction Utilisation
for Parcourir des éléments ou répéter selon un compteur
while Répéter tant qu’une commande ou condition réussit
until Répéter jusqu’à ce qu’une commande ou condition réussisse
select Construire une boucle de sélection interactive

select est moins utilisé, mais il fait bien partie des constructions itératives de Bash.

Comprendre une notion essentielle : Bash teste des statuts

Avant de parler de :

while
until
if

il faut comprendre que Bash raisonne beaucoup en :

codes de retour

Une commande qui réussit retourne généralement :

0

Une commande qui échoue retourne généralement un statut :

non nul

Cela signifie qu’une construction :

while commande; do
    ...
done

signifie essentiellement :

« Tant que cette commande retourne un statut de succès, continue. »

La boucle for

for est idéale lorsqu’on possède une collection connue d’éléments.

Syntaxe générale :

for variable in element1 element2 element3; do
    commandes
done

À chaque itération :

variable

reçoit la valeur de l’élément courant.

For sur une liste fixe

#!/bin/bash

for fruit in pomme banane cerise; do
    printf 'Fruit : %s\n' "$fruit"
done

Résultat :

Fruit : pomme
Fruit : banane
Fruit : cerise

Bash exécute trois itérations :

fruit=pomme
fruit=banane
fruit=cerise

Les éléments peuvent contenir des espaces

Il suffit de les citer correctement :

for ville in "New York" "Los Angeles" "Buenos Aires"; do
    printf 'Ville : %s\n' "$ville"
done

Chaque chaîne citée reste un élément unique.

Pourquoi « $variable » reste important dans la boucle

Même si :

ville

contient :

New York

ceci :

printf '%s\n' "$ville"

transmet une seule chaîne.

En règle générale, les expansions contenant des données doivent rester citées :

"$fruit"
"$fichier"
"$serveur"
"$repertoire"

For avec une séquence numérique

Bash possède la brace expansion :

for i in {1..5}; do
    printf 'Compteur : %d\n' "$i"
done

Elle génère :

1
2
3
4
5

Ordre décroissant

for i in {5..1}; do
    printf '%d\n' "$i"
done

Résultat :

5
4
3
2
1

Avec un pas

for i in {0..10..2}; do
    printf 'Pair : %d\n' "$i"
done

Résultat :

Pair : 0
Pair : 2
Pair : 4
Pair : 6
Pair : 8
Pair : 10

La brace expansion est textuelle

Une construction :

{1..5}

est développée par Bash très tôt, avant notamment l’expansion des variables.

C’est important.

Ce code paraît logique :

max=5

for i in {1..$max}; do
    printf '%s\n' "$i"
done

mais il ne produit pas dynamiquement :

1 2 3 4 5

comme on pourrait l’espérer.

La variable :

$max

n’est pas remplacée avant que Bash décide si l’expression entre accolades forme une séquence valide.

Pour une borne variable, utilisez une boucle arithmétique

max=5

for ((i = 1; i <= max; i++)); do
    printf '%d\n' "$i"
done

Cette forme est beaucoup plus adaptée lorsqu’une valeur est calculée à l’exécution.

La boucle for de style C

Bash possède une seconde syntaxe :

for ((initialisation; condition; modification)); do
    commandes
done

Exemple :

for ((i = 0; i < 5; i++)); do
    printf 'i vaut %d\n' "$i"
done

Décomposition

i = 0

est exécuté une fois au départ.

Puis :

i < 5

est testé avant chaque itération.

Enfin :

i++

est exécuté après chaque passage.

Conceptuellement :

i = 0
↓
i < 5 ?
↓ oui
commandes
↓
i++
↓
recommencer

Compter à l’envers

for ((i = 10; i >= 0; i--)); do
    printf '%d\n' "$i"
done

Changer le pas

for ((i = 0; i <= 100; i += 10)); do
    printf '%d %%\n' "$i"
done

La boucle arithmétique n’a pas besoin de $ devant les variables

Dans :

(( ... ))

on peut écrire :

i < max

plutôt que :

$i < $max

Les noms y sont interprétés comme variables arithmétiques.

For sur les paramètres du script

Imaginons :

./script.sh alpha beta gamma

Vous pouvez parcourir les arguments avec :

for argument in "$@"; do
    printf 'Argument : %s\n' "$argument"
done

Forme raccourcie

En Bash, ceci :

for argument; do
    printf '%s\n' "$argument"
done

équivaut à :

for argument in "$@"; do
    printf '%s\n' "$argument"
done

C’est élégant, mais la version explicite peut être plus pédagogique lorsqu’on apprend le langage.

Pourquoi « $@ » est important

Si le script reçoit :

./script.sh "rapport annuel.txt" "photo vacances.jpg"

avec :

for fichier in "$@"; do
    ...
done

il reçoit correctement deux éléments.

Évitez :

for fichier in $@; do
    ...
done

car les espaces contenus dans les arguments peuvent alors être interprétés comme séparateurs.

For sur les fichiers d’un dossier

Exemple classique :

for fichier in *.txt; do
    printf 'Fichier : %s\n' "$fichier"
done

Le glob :

*.txt

est développé par Bash en liste de noms.

Un fichier appelé :

rapport annuel.txt

reste un seul élément produit par l’expansion du glob.

Le problème apparaît plutôt si vous oubliez ensuite les guillemets :

cat "$fichier"

est correct.

Ne faites pas for fichier in $(ls)

Cette construction :

for fichier in $(ls *.txt); do
    ...
done

est fragile.

La sortie de :

ls

est transformée en texte puis redécoupée par le shell.

Les noms contenant :

  • espaces ;
  • tabulations ;
  • retours à la ligne ;

peuvent donc être détruits logiquement.

Le shell sait déjà faire :

*.txt

directement.

Le piège : aucun fichier ne correspond

Supposons qu’aucun fichier :

*.txt

n’existe.

Par défaut, Bash laisse généralement le motif inchangé.

La boucle peut donc recevoir littéralement :

*.txt

Solution simple : vérifier l’existence

for fichier in *.txt; do
    [[ -e "$fichier" ]] || continue

    printf 'Fichier : %s\n' "$fichier"
done

Solution Bash : nullglob

On peut demander à Bash qu’un glob sans correspondance produise zéro élément :

shopt -s nullglob

for fichier in *.txt; do
    printf 'Fichier : %s\n' "$fichier"
done

S’il n’y a aucun `.txt`, la boucle effectue simplement :

0 itération

failglob

Une autre option Bash est :

shopt -s failglob

Avec elle, un motif sans correspondance provoque une erreur d’expansion.

Selon le script, cela peut être préférable à :

continuer silencieusement

Les fichiers cachés

Le glob :

*

ne sélectionne normalement pas les noms commençant par :

.

comme :

.bashrc
.gitignore
.secret

Pour modifier ce comportement dans Bash :

shopt -s dotglob

Mais activez cette option volontairement.

Un script de suppression qui commence soudainement à inclure tous les fichiers cachés vient de changer de personnalité.

Boucler sur les fichiers d’un répertoire précis

repertoire="/srv/data"

for fichier in "$repertoire"/*.txt; do
    [[ -e "$fichier" ]] || continue

    printf 'Traitement de : %s\n' "$fichier"
done

On cite :

"$repertoire"

mais pas :

*.txt

car le motif doit rester actif.

Fichiers commençant par un tiret

Un fichier peut s’appeler :

-important.txt

Lorsqu’on le transmet à une commande, utilisez si possible :

--

Par exemple :

rm -- "$fichier"
cp -- "$fichier" "$destination"
cat -- "$fichier"

Le :

--

indique à de nombreuses commandes :

« Ce qui suit n’est plus une option. »

Combiner for et tests de fichiers

for chemin in ./*; do
    if [[ -d "$chemin" ]]; then
        printf 'Répertoire : %s\n' "$chemin"
    elif [[ -f "$chemin" ]]; then
        printf 'Fichier : %s\n' "$chemin"
    elif [[ -L "$chemin" ]]; then
        printf 'Lien : %s\n' "$chemin"
    else
        printf 'Autre : %s\n' "$chemin"
    fi
done

Attention à l’ordre des tests avec les liens symboliques

Selon ce que vous voulez savoir, un lien symbolique pointant vers un fichier peut satisfaire certains tests appliqués à sa cible.

Si vous voulez identifier spécifiquement les liens :

if [[ -L "$chemin" ]]; then
    ...
elif [[ -d "$chemin" ]]; then
    ...
elif [[ -f "$chemin" ]]; then
    ...
fi

peut être plus approprié.

La boucle while

Syntaxe :

while condition; do
    commandes
done

La boucle continue tant que :

condition

retourne un statut de succès.

Compteur avec while

compteur=0

while (( compteur < 5 )); do
    printf 'Compteur : %d\n' "$compteur"
    compteur=$((compteur + 1))
done

Résultat :

Compteur : 0
Compteur : 1
Compteur : 2
Compteur : 3
Compteur : 4

Pourquoi utiliser (( )) ici ?

L’original utilisait :

while [ "$compteur" -lt 5 ]; do

Cette syntaxe fonctionne.

Mais puisque nous écrivons spécifiquement du Bash et manipulons des nombres :

while (( compteur < 5 )); do

exprime directement une condition arithmétique.

La commande [ n’est pas une parenthèse magique

Dans :

[ "$compteur" -lt 5 ]

le :

[

est essentiellement une commande de test.

C’est pourquoi les espaces sont nécessaires :

[ "$x" -eq 1 ]

et non :

["$x"-eq1]

[[ ]] est préférable pour beaucoup de tests Bash

Pour des chaînes et fichiers :

if [[ -f "$fichier" ]]; then
    ...
fi

Pour l’arithmétique :

if (( compteur < 10 )); then
    ...
fi

Cela rend souvent le script plus lisible qu’un mélange constant de :

[ ]
-eq
-lt
-gt

Attention à ((compteur++)) avec set -e

Cette subtilité est importante.

Une expression arithmétique Bash :

(( expression ))

retourne :

succès si la valeur calculée est non nulle
échec si elle vaut zéro

Supposons :

compteur=0
((compteur++))

L’expression vaut :

0

avant l’incrément.

Son statut peut donc être non nul.

Dans un script avec :

set -e

cela peut provoquer une sortie inattendue selon le contexte.

Forme simple et explicite

compteur=$((compteur + 1))

évite ce piège lorsque la valeur de retour arithmétique ne vous intéresse pas.

While sur une vraie commande

Une boucle n’a pas besoin d’utiliser :

[[ ... ]]

comme condition.

Elle peut tester directement un programme.

Par exemple :

while ping -c 1 -W 1 192.168.1.1 >/dev/null 2>&1; do
    printf 'La cible répond.\n'
    sleep 5
done

La boucle continue tant que :

ping

réussit.

Attendre qu’un service réponde

Exemple HTTP :

while ! curl -fsS --max-time 2 \
    "http://127.0.0.1:8080/health" \
    >/dev/null; do

    printf 'Service indisponible, nouvelle tentative...\n'
    sleep 2
done

printf 'Service disponible.\n'

Ici :

!

inverse le statut.

La boucle continue donc :

tant que curl échoue

Mais attention aux boucles d’attente infinies

Le script précédent peut attendre :

2 secondes
2 minutes
2 jours
l'effondrement thermique de l'univers

si le service ne revient jamais.

Pour une automatisation sérieuse, ajoutez souvent :

  • un nombre maximal de tentatives ;
  • un timeout global ;
  • un message d’erreur ;
  • un code de sortie non nul.

Attendre avec un nombre maximal de tentatives

max=10
tentative=1

while (( tentative <= max )); do
    if curl -fsS --max-time 2 \
        "http://127.0.0.1:8080/health" \
        >/dev/null; then

        printf 'Service disponible.\n'
        break
    fi

    printf 'Tentative %d/%d échouée.\n' \
        "$tentative" "$max"

    tentative=$((tentative + 1))
    sleep 2
done

if (( tentative > max )); then
    printf 'Le service reste indisponible.\n' >&2
    exit 1
fi

La boucle infinie

Forme classique :

while true; do
    printf 'Ctrl+C pour arrêter.\n'
    sleep 1
done

true retourne toujours :

0

La condition réussit donc éternellement.

Forme compacte Bash

On rencontre également :

while :; do
    ...
done

Le :

:

est un builtin ne faisant pratiquement rien et retournant un succès.

Le résultat est également une boucle infinie.

Une boucle infinie n’est pas forcément une erreur

Elle est tout à fait légitime pour :

  • daemon simple ;
  • surveillance ;
  • menu interactif ;
  • traitement continu ;
  • attente d’événements.

Le problème n’est pas :

while true

Le problème est :

while true
+
aucune temporisation
+
aucune sortie
+
une commande coûteuse

Une boucle infinie peut consommer un CPU entier

Comparez :

while true; do
    :
done

avec :

while true; do
    verifier_service
    sleep 5
done

Dans le premier cas, Bash recommence aussi vite qu’il le peut.

Dans le second, il attend cinq secondes entre les vérifications.

Lire un fichier ligne par ligne

La forme classique robuste :

while IFS= read -r ligne; do
    printf 'Ligne : %s\n' "$ligne"
done < monfichier.txt

Pourquoi IFS= ?

IFS signifie :

Internal Field Separator

Avec :

IFS=

on demande à :

read

de ne pas découper ou supprimer involontairement certains caractères de séparation au début et à la fin de la ligne.

Pourquoi read -r ?

Sans :

-r

les antislashs peuvent avoir une signification particulière.

Avec :

read -r

on demande de les conserver comme données ordinaires.

Pour lire du texte arbitraire :

IFS= read -r ligne

est donc un excellent réflexe.

Le problème de la dernière ligne sans saut de ligne

Un fichier texte normalement formé termine ses lignes par un caractère :

newline

Mais un fichier peut parfois finir ainsi :

ligne 1\n
ligne 2\n
dernière ligne sans newline

À EOF, :

read

peut retourner un statut d’échec tout en ayant placé les derniers caractères dans la variable.

Si cette dernière ligne doit absolument être traitée :

while IFS= read -r ligne || [[ -n "$ligne" ]]; do
    printf 'Ligne : %s\n' "$ligne"
done < monfichier.txt

Lire plusieurs champs

On peut utiliser un séparateur spécifique :

while IFS=: read -r utilisateur motdepasse uid gid gecos home shell; do
    printf 'Utilisateur : %s, UID : %s\n' \
        "$utilisateur" "$uid"
done < /etc/passwd

Ici :

:

est le séparateur des champs de :

/etc/passwd

Ne faites pas for ligne in $(cat fichier)

Ce code :

for ligne in $(cat fichier.txt); do
    ...
done

ne parcourt pas correctement les lignes.

Il parcourt les mots produits après découpage par le shell.

Une ligne :

Jean Dupont Paris

devient potentiellement :

Jean
Dupont
Paris

Pour des lignes :

while IFS= read -r ligne

est la construction adaptée.

Le piège des pipelines et des sous-shells

Cette construction semble naturelle :

compteur=0

cat fichier.txt |
while IFS= read -r ligne; do
    compteur=$((compteur + 1))
done

printf 'Total : %d\n' "$compteur"

Mais Bash peut exécuter la boucle de la pipeline dans un :

subshell

Les modifications de :

compteur

restent alors dans ce sous-shell.

Le shell principal peut afficher :

Total : 0

ce qui constitue une excellente occasion de douter de l’arithmétique élémentaire.

Préférez une redirection

compteur=0

while IFS= read -r ligne; do
    compteur=$((compteur + 1))
done < fichier.txt

printf 'Total : %d\n' "$compteur"

Ici, la boucle s’exécute dans le shell courant.

Avec la sortie d’une commande

En Bash, utilisez une substitution de processus :

compteur=0

while IFS= read -r ligne; do
    compteur=$((compteur + 1))
done < <(commande)

printf 'Total : %d\n' "$compteur"

La syntaxe :

<(commande)

est une fonctionnalité Bash appelée :

process substitution

La boucle until

until ressemble à :

while

avec une logique inversée.

Syntaxe :

until condition; do
    commandes
done

La boucle continue tant que la condition :

échoue

et s’arrête lorsqu’elle :

réussit

Exemple avec compteur

i=0

until (( i >= 5 )); do
    printf 'i = %d\n' "$i"
    i=$((i + 1))
done

Résultat :

i = 0
i = 1
i = 2
i = 3
i = 4

While et until peuvent exprimer la même logique

Cette boucle :

while (( i < 5 )); do
    ...
done

peut être formulée :

until (( i >= 5 )); do
    ...
done

Les deux sont correctes.

Choisissez la forme qui exprime le plus naturellement l’intention.

Exemple pratique avec until

Attendre qu’un fichier apparaisse :

until [[ -f "/tmp/application.ready" ]]; do
    printf 'En attente de l’application...\n'
    sleep 2
done

printf 'Application prête.\n'

Attendre qu’un service systemd devienne actif

until systemctl is-active --quiet nginx; do
    printf 'nginx n’est pas encore actif.\n'
    sleep 2
done

printf 'nginx est actif.\n'

Encore une fois : prévoyez un timeout

Une automatisation ne devrait pas toujours attendre éternellement.

Exemple :

tentative=1
maximum=15

until systemctl is-active --quiet nginx; do
    if (( tentative >= maximum )); then
        printf 'nginx n’est pas devenu actif.\n' >&2
        exit 1
    fi

    printf 'Tentative %d/%d...\n' \
        "$tentative" "$maximum"

    tentative=$((tentative + 1))
    sleep 2
done

printf 'nginx est actif.\n'

break : quitter une boucle

break arrête immédiatement la boucle courante.

Exemple :

for i in {1..10}; do
    if (( i == 5 )); then
        printf 'Arrêt à %d.\n' "$i"
        break
    fi

    printf '%d\n' "$i"
done

Résultat :

1
2
3
4
Arrêt à 5.

break est utile lorsqu’on a trouvé ce qu’on cherchait

for fichier in ./*.conf; do
    [[ -e "$fichier" ]] || continue

    if grep -qF "production=true" "$fichier"; then
        printf 'Configuration trouvée : %s\n' "$fichier"
        break
    fi
done

Une fois le bon fichier trouvé, il n’est pas forcément nécessaire de continuer à examiner les 14 000 suivants.

continue : passer à l’itération suivante

continue n’arrête pas la boucle entière.

Il abandonne seulement l’itération actuelle.

for i in {1..5}; do
    if (( i == 3 )); then
        printf 'Je saute %d.\n' "$i"
        continue
    fi

    printf 'i = %d\n' "$i"
done

Résultat :

i = 1
i = 2
Je saute 3.
i = 4
i = 5

Continue est très pratique pour filtrer

for fichier in ./*; do
    [[ -f "$fichier" ]] || continue
    [[ -r "$fichier" ]] || continue

    printf 'Traitement : %s\n' "$fichier"
done

La logique devient :

pas un fichier ?
→ suivant

pas lisible ?
→ suivant

sinon
→ traitement

Réduire l’imbrication avec continue

Comparez :

for fichier in ./*; do
    if [[ -f "$fichier" ]]; then
        if [[ -r "$fichier" ]]; then
            if [[ "$fichier" == *.txt ]]; then
                traiter "$fichier"
            fi
        fi
    fi
done

avec :

for fichier in ./*; do
    [[ -f "$fichier" ]] || continue
    [[ -r "$fichier" ]] || continue
    [[ "$fichier" == *.txt ]] || continue

    traiter "$fichier"
done

La seconde version est souvent beaucoup plus lisible.

Break et continue peuvent recevoir un nombre

Bash permet :

break 2

pour sortir de deux niveaux de boucles imbriquées.

De même :

continue 2

agit sur le deuxième niveau de boucle englobant.

Exemple avec break 2

for serveur in web01 web02 web03; do
    for port in 22 80 443; do
        printf 'Test %s:%s\n' "$serveur" "$port"

        if [[ "$serveur" == "web02" && "$port" == "80" ]]; then
            printf 'Condition trouvée, sortie complète.\n'
            break 2
        fi
    done
done

Sans :

2

break ne quitterait que la boucle :

for port

Les boucles imbriquées

Une boucle peut naturellement contenir une autre boucle.

for serveur in web01 web02; do
    for port in 22 80 443; do
        printf '%s:%s\n' "$serveur" "$port"
    done
done

Résultat :

web01:22
web01:80
web01:443
web02:22
web02:80
web02:443

Le nombre d’itérations se multiplie

Si vous avez :

1000 serveurs
×
1000 fichiers

vous obtenez :

1 000 000 itérations

Une boucle imbriquée parfaitement correcte peut donc devenir très lente simplement parce que son coût algorithmique explose.

Ne faites pas une commande externe inutile à chaque tour

Supposons :

for fichier in ...; do
    commande_externe
done

Si la boucle contient :

500 000 éléments

vous lancez :

500 000 processus

Le coût peut devenir important.

Dans certains cas :

  • find -exec ... + ;
  • xargs correctement utilisé ;
  • un programme traitant plusieurs entrées à la fois ;
  • awk ;
  • un autre langage ;

seront beaucoup plus efficaces.

Exemple : éviter 10 000 appels à rm

Cette boucle :

for fichier in ./*.tmp; do
    [[ -e "$fichier" ]] || continue
    rm -- "$fichier"
done

lance potentiellement :

rm

une fois par fichier.

Selon le besoin, cette commande peut suffire :

rm -- ./*.tmp

avec les précautions nécessaires concernant l’absence de correspondance.

Ou :

find . -maxdepth 1 -type f -name '*.tmp' -delete

si le comportement correspond exactement à l’objectif.

Une boucle n’est pas toujours la meilleure solution

Le shell possède déjà de nombreux outils capables de traiter plusieurs éléments :

cp
mv
rm
find
grep
sed
awk
xargs
rsync

Avant d’écrire :

for ...
do
    ...
done

demandez-vous si la commande peut déjà traiter toute la collection en une fois.

La boucle select

Bash propose également :

select

pour créer rapidement des menus.

Exemple :

PS3="Votre choix : "

select action in "Afficher la date" "Afficher l'utilisateur" "Quitter"; do
    case "$REPLY" in
        1)
            date
            ;;
        2)
            whoami
            ;;
        3)
            break
            ;;
        *)
            printf 'Choix invalide.\n' >&2
            ;;
    esac
done

Ce que fait select

Bash affiche automatiquement :

1) Afficher la date
2) Afficher l'utilisateur
3) Quitter
Votre choix :

La variable :

REPLY

contient la réponse numérique brute.

La variable choisie :

action

contient l’élément correspondant lorsqu’un choix valide est effectué.

Utiliser directement la valeur sélectionnée

PS3="Choisissez : "

select fruit in pomme banane cerise; do
    if [[ -n "$fruit" ]]; then
        printf 'Vous avez choisi : %s\n' "$fruit"
        break
    fi

    printf 'Choix invalide.\n' >&2
done

Select reste une boucle

Si vous n’utilisez pas :

break

le menu est proposé de nouveau après chaque traitement.

C’est pratique pour les petits outils administratifs interactifs.

Pour une vraie interface complexe, Bash finit néanmoins par devenir un framework graphique particulièrement médiocre.

Boucle interactive avec while

L’exemple original :

while true; do
    read -r -p "Entrez un mot (ou stop pour sortir) : " mot

    [[ "$mot" == "stop" ]] && break

    printf 'Vous avez écrit : %s\n' "$mot"
done

fonctionne correctement.

Version avec case

while true; do
    read -r -p "Commande [status/heure/quit] : " commande

    case "$commande" in
        status)
            systemctl --failed
            ;;
        heure)
            date
            ;;
        quit|exit|q)
            break
            ;;
        *)
            printf 'Commande inconnue.\n' >&2
            ;;
    esac
done

Pour plusieurs possibilités, :

case

est souvent plus lisible qu’une longue succession de :

if
elif
elif
elif

Read peut recevoir un timeout

Avec :

read -r -t 10 -p "Réponse : " reponse

Bash attend au maximum environ :

10 secondes

selon le contexte.

Exemple :

if read -r -t 10 -p "Continuer ? [o/N] " reponse; then
    printf '\nRéponse reçue : %s\n' "$reponse"
else
    printf '\nAucune réponse dans le délai imparti.\n'
fi

Ne mélangez pas interaction et automatisation sans réfléchir

Un script exécuté depuis :

cron

n’a généralement aucun humain devant lui pour répondre :

Continuer ? [o/N]

Une boucle interactive doit donc être utilisée dans un programme réellement prévu pour un terminal.

Tester si une entrée vient d’un terminal

if [[ -t 0 ]]; then
    printf 'Mode interactif disponible.\n'
else
    printf 'Pas de terminal sur stdin.\n'
fi

Boucles et fonctions

Une fonction peut naturellement contenir une boucle :

afficher_compteur() {
    local max="$1"
    local i

    for ((i = 1; i <= max; i++)); do
        printf 'Boucle interne : %d\n' "$i"
    done
}

afficher_compteur 3

Une boucle peut appeler une fonction

verifier_serveur() {
    local serveur="$1"

    if ping -c 1 -W 1 "$serveur" >/dev/null 2>&1; then
        printf '[OK] %s\n' "$serveur"
    else
        printf '[ERREUR] %s\n' "$serveur" >&2
        return 1
    fi
}

for serveur in router01 dns01 web01; do
    verifier_serveur "$serveur"
done

Cette organisation sépare :

parcourir les serveurs

de :

vérifier un serveur

et rend le script beaucoup plus lisible.

Parcourir un tableau

Bash possède des tableaux indexés.

serveurs=(
    "web01"
    "web02"
    "db01"
    "backup01"
)

Pour les parcourir :

for serveur in "${serveurs[@]}"; do
    printf '%s\n' "$serveur"
done

Pourquoi « ${tableau[@]} » ?

Cette expansion citée conserve chaque élément comme argument distinct.

Si un élément vaut :

serveur de secours

il reste un seul élément de la boucle.

Parcourir les indices d’un tableau

for index in "${!serveurs[@]}"; do
    printf 'Index %s : %s\n' \
        "$index" "${serveurs[$index]}"
done

Tableaux associatifs

declare -A ports=(
    [ssh]=22
    [http]=80
    [https]=443
)

Parcourir les clés :

for service in "${!ports[@]}"; do
    printf '%s utilise le port %s\n' \
        "$service" "${ports[$service]}"
done

L’ordre des éléments d’un tableau associatif ne doit généralement pas être considéré comme une interface de tri garantie pour votre logique.

Parcourir la sortie d’une commande : attention

Cette forme :

for utilisateur in $(cut -d: -f1 /etc/passwd); do
    ...
done

peut fonctionner ici parce que les noms d’utilisateurs Unix respectent normalement des contraintes évitant les espaces.

Mais généraliser :

for x in $(commande)

est dangereux.

La sortie subit :

  • substitution de commande ;
  • word splitting ;
  • filename expansion.

Pour des lignes arbitraires, utilisez while read

while IFS= read -r ligne; do
    printf '%s\n' "$ligne"
done < <(commande)

Pour des noms de fichiers arbitraires, utilisez NUL

Les noms de fichiers Unix peuvent contenir des retours à la ligne.

Une liste séparée par :

\n

n’est donc pas totalement sûre pour représenter n’importe quel nom.

Le séparateur robuste est :

NUL

Find avec -print0

while IFS= read -r -d '' fichier; do
    printf 'Fichier : %s\n' "$fichier"
done < <(
    find /srv/data \
        -type f \
        -name '*.log' \
        -print0
)

Ici :

find -print0

sépare les noms avec un octet NUL.

Et :

read -d ''

lit jusqu’à ce séparateur.

Pourquoi NUL ?

Un nom de fichier Unix peut contenir presque n’importe quel octet.

Mais il ne peut pas contenir :

NUL

et un composant de chemin ne peut évidemment pas contenir :

/

comme caractère ordinaire de son nom.

NUL constitue donc un séparateur fiable pour transmettre une collection de chemins.

Mapfile pour charger des lignes

Si vous voulez stocker les lignes d’un fichier dans un tableau Bash :

mapfile -t lignes < monfichier.txt

Puis :

for ligne in "${lignes[@]}"; do
    printf '%s\n' "$ligne"
done

L’option :

-t

retire le délimiteur de fin de ligne.

Mapfile et NUL

Pour des chemins provenant de :

find -print0

on peut également faire :

mapfile -d '' -t fichiers < <(
    find /srv/data -type f -print0
)

for fichier in "${fichiers[@]}"; do
    printf '%s\n' "$fichier"
done

Attention à la mémoire

mapfile charge toutes les entrées dans un tableau.

Pour :

200 fichiers

aucun problème.

Pour :

20 millions de lignes

une boucle :

while read

traitant les données progressivement peut être beaucoup plus appropriée.

Boucle sur SSH

Exemple :

serveurs=(
    "web01.example.net"
    "web02.example.net"
    "db01.example.net"
)

for serveur in "${serveurs[@]}"; do
    printf '=== %s ===\n' "$serveur"

    if ssh -- "$serveur" 'uptime'; then
        printf '[OK] %s\n' "$serveur"
    else
        printf '[ERREUR] %s\n' "$serveur" >&2
    fi
done

Une erreur sur un serveur doit-elle arrêter toute la boucle ?

C’est une décision de conception.

Pour un audit :

serveur 1 échoue
→ continuer les autres

est souvent souhaitable.

Pour un déploiement transactionnel :

serveur 1 échoue
→ arrêter immédiatement

peut être préférable.

Une boucle robuste doit définir clairement cette politique.

Collecter le nombre d’échecs

erreurs=0

for serveur in "${serveurs[@]}"; do
    if ssh -o ConnectTimeout=5 \
        -- "$serveur" \
        'uptime' >/dev/null; then

        printf '[OK] %s\n' "$serveur"
    else
        printf '[ERREUR] %s\n' "$serveur" >&2
        erreurs=$((erreurs + 1))
    fi
done

if (( erreurs > 0 )); then
    printf '%d serveur(s) en erreur.\n' "$erreurs" >&2
    exit 1
fi

Boucle de sauvegarde

sources=(
    "/etc"
    "/srv/www"
    "/srv/scripts"
)

destination="/mnt/backup"

for source in "${sources[@]}"; do
    if [[ ! -e "$source" ]]; then
        printf 'Source absente : %s\n' "$source" >&2
        continue
    fi

    printf 'Sauvegarde de %s...\n' "$source"

    if rsync -a -- "$source" "$destination/"; then
        printf '[OK] %s\n' "$source"
    else
        printf '[ERREUR] %s\n' "$source" >&2
    fi
done

Boucle de renommage

Supposons des fichiers :

rapport1.txt
rapport2.txt
rapport3.txt

On peut les préfixer :

for fichier in rapport*.txt; do
    [[ -e "$fichier" ]] || continue

    nouveau="archive_$fichier"

    printf '%s → %s\n' "$fichier" "$nouveau"
    mv -- "$fichier" "$nouveau"
done

Avant un renommage massif : prévisualisez

Commencez par :

for fichier in rapport*.txt; do
    [[ -e "$fichier" ]] || continue

    nouveau="archive_$fichier"

    printf '%s → %s\n' "$fichier" "$nouveau"
done

sans :

mv

Une fois le résultat validé, ajoutez l’opération réelle.

La boucle n’est pas un système de rollback

Si :

itération 1 → réussie
itération 2 → réussie
itération 3 → réussie
itération 4 → échoue

Bash ne revient pas automatiquement en arrière sur les trois premières opérations.

Une boucle n’est pas une transaction.

Pour une opération critique, prévoyez :

  • validation préalable ;
  • sauvegarde ;
  • journalisation ;
  • mécanisme de reprise ;
  • éventuellement opération atomique adaptée.

Boucler sur les PID

Pour certaines opérations d’administration :

for pid in "${pids[@]}"; do
    if kill -0 "$pid" 2>/dev/null; then
        printf 'PID %s existe.\n' "$pid"
    fi
done

kill -0 n’envoie pas réellement un signal destructeur.

Il permet notamment de tester certaines propriétés d’existence ou de permission relatives au processus.

Boucles et sleep

Une boucle de polling doit souvent contenir :

sleep

Exemple :

while true; do
    verifier_etat
    sleep 30
done

Sans temporisation :

while true; do
    verifier_etat
done

peut lancer la vérification plusieurs milliers de fois par seconde.

Le polling n’est pas toujours idéal

Si le système possède un mécanisme événementiel :

  • inotify ;
  • systemd ;
  • webhook ;
  • queue ;
  • signal ;
  • socket ;

il peut être plus efficace d’attendre un événement que de vérifier :

ça a changé ?
ça a changé ?
ça a changé ?
ça a changé ?

toutes les 100 millisecondes.

Une boucle avec timeout global

Une technique simple utilise :

SECONDS

variable spéciale de Bash.

SECONDS=0
timeout=30

while ! systemctl is-active --quiet nginx; do
    if (( SECONDS >= timeout )); then
        printf 'Timeout après %d secondes.\n' "$timeout" >&2
        exit 1
    fi

    sleep 1
done

printf 'nginx est actif après %d seconde(s).\n' "$SECONDS"

SECONDS est pratique pour des délais simples

Bash maintient automatiquement cette variable en fonction du temps écoulé.

Pour des besoins temporels plus sophistiqués, utilisez un mécanisme adapté.

Timeout d’une commande unique

Sous GNU/Linux, la commande :

timeout

peut limiter la durée d’un programme :

timeout 10s commande

Mais :

timeout

et :

while

répondent à des besoins différents.

Contrôler plusieurs conditions

tentative=0
maximum=20

while (( tentative < maximum )); do
    if [[ -f /tmp/service.ready ]] &&
       systemctl is-active --quiet application; then

        printf 'Application prête.\n'
        break
    fi

    tentative=$((tentative + 1))
    sleep 1
done

Les opérateurs logiques dans [[ ]]

On peut écrire :

if [[ -f "$fichier" && -r "$fichier" ]]; then
    ...
fi

ou :

if [[ "$reponse" == "o" || "$reponse" == "oui" ]]; then
    ...
fi

Les commandes peuvent aussi être combinées directement

if commande1 && commande2; then
    ...
fi

Ici, :

commande2

n’est exécutée que si :

commande1

réussit.

Boucles et set -e

Beaucoup de scripts commencent par :

set -e

ou :

set -Eeuo pipefail

Les interactions entre :

errexit
conditions
while
until
&&
||
!

sont subtiles.

Une commande utilisée comme test de :

while

peut légitimement retourner :

non zéro

sans que cela soit considéré comme une erreur fatale à cet endroit.

Exemple naturel

while grep -q "EN_ATTENTE" fichier; do
    sleep 1
done

Lorsque :

grep

ne trouve plus le motif, son statut non nul signifie précisément :

« La boucle doit s’arrêter. »

Ce n’est pas une catastrophe nécessitant l’arrêt anticipé d’errexit.

Ne transformez pas tous les statuts non nuls en anomalies

Selon la commande :

non zéro

peut signifier :

  • erreur ;
  • aucune correspondance ;
  • condition fausse ;
  • différence détectée ;
  • timeout ;
  • autre état documenté.

La boucle doit interpréter le statut selon la commande réellement utilisée.

Boucles et pipefail

Supposons :

generer |
filtrer |
while IFS= read -r ligne; do
    traiter "$ligne"
done

Les pipelines ajoutent plusieurs questions :

  • quelle commande a échoué ?
  • quel est le statut global ?
  • la boucle est-elle dans un sous-shell ?

Avec :

set -o pipefail

les échecs intermédiaires d’une pipeline deviennent plus visibles dans son statut global.

Mais cela ne résout pas automatiquement le problème de portée des variables dans les sous-shells.

Préférez souvent cette forme

while IFS= read -r ligne; do
    traiter "$ligne"
done < <(generer | filtrer)

pour garder la boucle dans le shell courant lorsque c’est important.

Boucler sur une liste générée dynamiquement

Si la liste peut être stockée proprement dans un tableau :

serveurs=(
    "web01"
    "web02"
    "db01"
)

for serveur in "${serveurs[@]}"; do
    ...
done

C’est généralement plus sûr qu’une chaîne contenant :

"web01 web02 db01"

que l’on espère ensuite voir découpée exactement comme prévu.

Les tableaux représentent mieux les listes

Une variable scalaire :

liste="un deux trois"

est :

une chaîne

pas :

un tableau de trois éléments

La distinction devient importante dès qu’un élément contient un espace.

Mauvais exemple

fichiers="rapport.txt rapport annuel.txt"

for fichier in $fichiers; do
    printf '%s\n' "$fichier"
done

Le résultat est découpé en :

rapport.txt
rapport
annuel.txt

Bon exemple

fichiers=(
    "rapport.txt"
    "rapport annuel.txt"
)

for fichier in "${fichiers[@]}"; do
    printf '%s\n' "$fichier"
done

Boucles et chaînes vides

Un tableau peut contenir un élément vide :

valeurs=(
    "alpha"
    ""
    "gamma"
)

Avec :

for valeur in "${valeurs[@]}"; do
    printf '<%s>\n' "$valeur"
done

vous obtenez bien trois éléments :

<alpha>
<>
<gamma>

Le quoting préserve l’élément vide.

Une boucle peut-elle modifier sa propre liste ?

Dans :

for element in "${tableau[@]}"; do
    ...
done

la liste sur laquelle le :

for

itère est issue de l’expansion effectuée au début de la construction correspondante.

Modifier le tableau dans le corps ne constitue donc pas une méthode simple pour injecter de nouvelles itérations dans le parcours déjà établi.

Si votre ensemble de travail doit évoluer dynamiquement, :

while

avec une file explicite peut être plus approprié.

Traiter une file avec while

queue=(
    "tache1"
    "tache2"
    "tache3"
)

while (( ${#queue[@]} > 0 )); do
    tache="${queue[0]}"

    queue=("${queue[@]:1}")

    printf 'Traitement : %s\n' "$tache"
done

Pour des volumes importants, cette manipulation répétée de tableau n’est cependant pas nécessairement la structure la plus performante.

Une boucle peut être parallélisée

La forme :

for fichier in "${fichiers[@]}"; do
    traiter "$fichier"
done

est séquentielle :

fichier 1
puis
fichier 2
puis
fichier 3

On pourrait être tenté de faire :

for fichier in "${fichiers[@]}"; do
    traiter "$fichier" &
done

wait

Chaque traitement part alors en arrière-plan.

Mais attention au parallélisme sans limite

Pour :

10 fichiers

cela peut être acceptable.

Pour :

200 000 fichiers

vous venez potentiellement d’essayer de lancer :

200 000 processus

simultanément.

Le noyau appréciera l’ambition.

Pas nécessairement la méthode.

GNU parallel ou xargs -P

Pour un parallélisme contrôlé, des outils spécialisés peuvent être plus appropriés.

Exemple simple avec des données adaptées :

printf '%s\0' "${fichiers[@]}" |
    xargs -0 -n 1 -P 4 commande

Ici :

-P 4

limite le parallélisme à quatre processus.

Le choix dépend cependant fortement de la commande exécutée et de la manière dont ses arguments doivent être transmis.

Ne parallélisez pas simplement parce que vous pouvez

Une opération peut être limitée par :

  • le disque ;
  • le réseau ;
  • une API ;
  • un serveur distant ;
  • des verrous ;
  • une base de données.

Passer de :

1 tâche

à :

100 tâches concurrentes

peut améliorer les performances.

Ou transformer le service distant en projet d’analyse post-mortem.

Piège : modification d’une variable dans un sous-shell

Autre exemple :

compteur=0

(
    compteur=10
)

printf '%d\n' "$compteur"

Le résultat reste :

0

Le sous-shell possède sa propre copie de l’environnement shell.

Cette notion explique de nombreux comportements apparemment mystérieux dans les boucles placées dans des pipelines.

Boucle et cd

Ce code :

for dir in */; do
    cd "$dir" || continue
    faire_quelque_chose
done

est problématique.

Après la première itération, le répertoire courant a changé.

Les chemins des itérations suivantes ne correspondent peut-être plus à l’endroit initial.

Utiliser un sous-shell

for dir in */; do
    (
        cd -- "$dir" || exit
        faire_quelque_chose
    )
done

Le :

cd

n’affecte que le sous-shell de cette itération.

La boucle principale reste dans son répertoire original.

Ou éviter cd

Encore mieux lorsque possible :

for dir in */; do
    faire_quelque_chose "$dir"
done

Les chemins explicites réduisent souvent les surprises.

Boucle de nettoyage avec find

Imaginons que nous voulions supprimer des :

*.tmp

de plus de 30 jours.

Avant toute suppression :

find /srv/cache \
    -type f \
    -name '*.tmp' \
    -mtime +30 \
    -print

Lorsque le résultat est validé :

find /srv/cache \
    -type f \
    -name '*.tmp' \
    -mtime +30 \
    -delete

Une boucle Bash n’est donc pas nécessaire dans ce cas.

Si une logique complexe est nécessaire

while IFS= read -r -d '' fichier; do
    taille="$(stat -c '%s' "$fichier")"

    if (( taille > 100000000 )); then
        printf 'Gros fichier ancien : %s\n' "$fichier"
    fi
done < <(
    find /srv/cache \
        -type f \
        -name '*.tmp' \
        -mtime +30 \
        -print0
)

La boucle devient intéressante lorsque chaque élément exige une vraie logique.

Boucles et erreurs destructives

Cette boucle :

for fichier in "$destination"/*; do
    rm -rf -- "$fichier"
done

peut parfaitement supprimer tout le contenu de :

$destination

Si :

destination

vaut le mauvais chemin, la boucle ne remettra pas votre jugement en question.

Valider avant une boucle destructive

destination="/srv/app/cache"

if [[ "$destination" != "/srv/app/cache" ]]; then
    printf 'Destination refusée.\n' >&2
    exit 1
fi

if [[ ! -d "$destination" ]]; then
    printf 'Destination absente.\n' >&2
    exit 1
fi

Le niveau de validation réel doit naturellement correspondre aux risques du script.

Ajoutez un mode dry-run

dry_run=true

for fichier in "$destination"/*.tmp; do
    [[ -e "$fichier" ]] || continue

    if "$dry_run"; then
        printf '[DRY-RUN] suppression : %s\n' "$fichier"
    else
        rm -- "$fichier"
    fi
done

Une boucle destructive avec :

--dry-run

est souvent beaucoup plus agréable à relire en production.

Boucle avec confirmation

for fichier in ./*.tmp; do
    [[ -e "$fichier" ]] || continue

    read -r -p "Supprimer '$fichier' ? [o/N] " reponse

    case "$reponse" in
        o|O|oui|OUI)
            rm -- "$fichier"
            ;;
        *)
            printf 'Conservé : %s\n' "$fichier"
            ;;
    esac
done

Pour mille fichiers, cette interface deviendra toutefois une expérience méditative assez longue.

Compter les succès et échecs

succes=0
erreurs=0

for fichier in "${fichiers[@]}"; do
    if traiter "$fichier"; then
        succes=$((succes + 1))
    else
        erreurs=$((erreurs + 1))
    fi
done

printf 'Succès : %d\n' "$succes"
printf 'Erreurs : %d\n' "$erreurs"

if (( erreurs > 0 )); then
    exit 1
fi

Cette structure est excellente pour les traitements par lot.

Conserver les éléments en erreur

erreurs=()

for fichier in "${fichiers[@]}"; do
    if ! traiter "$fichier"; then
        erreurs+=("$fichier")
    fi
done

if (( ${#erreurs[@]} > 0 )); then
    printf 'Éléments en erreur :\n' >&2
    printf ' - %s\n' "${erreurs[@]}" >&2
    exit 1
fi

Une boucle peut produire un statut global utile

Pour une tâche automatisée :

0

devrait généralement signifier :

tout ce qui devait réussir a réussi

et un statut non nul :

quelque chose nécessite une attention

Le scheduler ou système de supervision peut alors détecter l’échec.

Boucle de retry avec délai croissant

Pour certains services réseau, on peut augmenter progressivement l’attente :

delai=1
maximum=5

for ((tentative = 1; tentative <= maximum; tentative++)); do
    if curl -fsS \
        --max-time 5 \
        "https://example.com/health" \
        >/dev/null; then

        printf 'Succès à la tentative %d.\n' "$tentative"
        exit 0
    fi

    printf 'Tentative %d/%d échouée.\n' \
        "$tentative" "$maximum"

    if (( tentative < maximum )); then
        sleep "$delai"
        delai=$((delai * 2))
    fi
done

printf 'Échec après %d tentatives.\n' "$maximum" >&2
exit 1

Backoff exponentiel

Les délais deviennent ici :

1
2
4
8
...

Ce principe est courant pour éviter de bombarder un service indisponible avec des requêtes en boucle serrée.

Ajoutez parfois du jitter

Si cent machines effectuent exactement :

1 s
2 s
4 s
8 s

elles risquent de retenter toutes en même temps.

Les systèmes distribués ajoutent souvent une petite composante aléatoire :

jitter

afin d’éviter cette synchronisation.

Pour un petit script local, ce niveau de sophistication n’est évidemment pas toujours nécessaire.

Boucle de traitement CSV : attention

On voit parfois :

while IFS=, read -r nom email ville; do
    ...
done < fichier.csv

Cela fonctionne seulement pour un format extrêmement simple.

Un vrai CSV peut contenir :

"Dupont, Jean",jean@example.com,"Paris, France"

Le parsing CSV comporte :

  • guillemets ;
  • virgules échappées ;
  • retours à la ligne dans les champs selon format ;
  • autres règles.

Pour du CSV réel :

utilisez un parseur CSV.

Toutes les données séparées par une virgule ne deviennent pas magiquement simples parce que Bash possède :

IFS=,

Erreur classique : oublier do

Incorrect :

for i in {1..5}
    printf '%s\n' "$i"
done

Correct :

for i in {1..5}; do
    printf '%s\n' "$i"
done

Erreur classique : oublier done

Une boucle commencée avec :

for
while
until

se termine par :

done

et non :

fi

fi termine :

if

esac termine :

case

Bash n’a pas voulu rendre les mots-clés uniformes.

Il avait manifestement d’autres priorités.

Erreur classique : for i in 1..5

Ceci :

for i in 1..5; do
    ...
done

contient un seul élément :

1..5

Il ne représente pas une séquence.

Utilisez :

{1..5}

ou :

for ((i = 1; i <= 5; i++))

Erreur classique : utiliser = pour comparer des nombres

Avec :

[ ... ]

une comparaison numérique utilise notamment :

-eq
-ne
-lt
-le
-gt
-ge

Exemple :

if [ "$i" -eq 3 ]; then
    ...
fi

Mais en Bash, pour l’arithmétique, préférez souvent :

if (( i == 3 )); then
    ...
fi

Erreur classique : oublier de citer une variable

Fragile :

for fichier in ...; do
    cp $fichier /backup
done

Préférable :

for fichier in ...; do
    cp -- "$fichier" /backup/
done

Erreur classique : parser ls

Évitez :

for fichier in $(ls)

Utilisez :

for fichier in ./*

ou :

find ... -print0

selon les besoins.

Erreur classique : le glob littéral

Ce code :

for fichier in *.log; do
    printf '%s\n' "$fichier"
done

peut afficher :

*.log

si aucun fichier ne correspond.

Utilisez :

[[ -e "$fichier" ]] || continue

ou :

shopt -s nullglob

Erreur classique : oublier les fichiers cachés

Le :

*

ne signifie pas automatiquement :

absolument tout

Les fichiers commençant par un point sont normalement exclus.

Erreur classique : boucle infinie accidentelle

Exemple :

compteur=0

while (( compteur < 10 )); do
    printf '%d\n' "$compteur"
done

Il manque :

compteur=$((compteur + 1))

La condition :

compteur < 10

restera vraie éternellement.

Erreur classique : modifier la mauvaise variable

i=0

while (( i < 10 )); do
    printf '%d\n' "$i"
    j=$((j + 1))
done

i ne change jamais.

La boucle non plus.

Erreur classique : pas de sleep dans un polling

while ! condition; do
    :
done

peut saturer inutilement un cœur CPU.

Préférez souvent :

while ! condition; do
    sleep 1
done

Erreur classique : aucune limite de retry

Ce code :

until curl ...; do
    sleep 5
done

peut être parfaitement correct pour un daemon.

Pour un déploiement automatisé, il peut également bloquer le pipeline pendant trois jours.

Définissez un timeout lorsque l’attente infinie n’est pas explicitement souhaitée.

Erreur classique : variables perdues après une pipeline

Fragile :

compteur=0

cat fichier |
while read -r ligne; do
    compteur=$((compteur + 1))
done

printf '%d\n' "$compteur"

Préférez :

compteur=0

while IFS= read -r ligne; do
    compteur=$((compteur + 1))
done < fichier

printf '%d\n' "$compteur"

Erreur classique : traiter des fichiers avec une liste textuelle

Les chemins doivent être manipulés comme des éléments distincts, pas comme une longue chaîne que Bash devra redécouper.

Préférez :

  • globs ;
  • tableaux ;
  • find -print0 ;
  • read -d ''.

Erreur classique : lancer tout en arrière-plan

for fichier in ...; do
    traitement "$fichier" &
done

peut créer des milliers de processus.

Contrôlez le parallélisme.

Erreur classique : utiliser une boucle quand une commande suffit

Avant :

for fichier in *.txt; do
    grep "ERREUR" "$fichier"
done

demandez-vous si :

grep "ERREUR" -- ./*.txt

répond déjà au besoin.

Chaque boucle supplémentaire est également une logique supplémentaire à maintenir.

Testez la syntaxe sans exécuter

bash -n script.sh

Cette commande peut détecter :

  • done oublié ;
  • do manquant ;
  • guillemet non fermé ;
  • parenthèse mal formée ;
  • autres erreurs syntaxiques.

Utilisez ShellCheck

Sur Debian :

sudo apt install shellcheck

Puis :

shellcheck script.sh

ShellCheck peut notamment signaler de nombreux problèmes liés à :

  • quoting ;
  • boucles sur la sortie de commandes ;
  • tableaux ;
  • variables ;
  • portabilité ;
  • subshells ;
  • globbing.

Exemple complet : analyser plusieurs fichiers

#!/bin/bash
set -u

shopt -s nullglob

fichiers=(./*.log)

if (( ${#fichiers[@]} == 0 )); then
    printf 'Aucun fichier .log trouvé.\n'
    exit 0
fi

erreurs=0

for fichier in "${fichiers[@]}"; do
    printf 'Analyse : %s\n' "$fichier"

    if grep -qF "ERROR" "$fichier"; then
        printf '[ALERTE] ERROR détecté dans %s\n' \
            "$fichier" >&2
        erreurs=$((erreurs + 1))
    else
        printf '[OK] %s\n' "$fichier"
    fi
done

printf 'Fichiers analysés : %d\n' "${#fichiers[@]}"
printf 'Fichiers en alerte : %d\n' "$erreurs"

if (( erreurs > 0 )); then
    exit 1
fi

Exemple complet : attendre un service proprement

#!/bin/bash
set -u

readonly URL="http://127.0.0.1:8080/health"
readonly MAX_ATTEMPTS=20
readonly DELAY=3

for ((tentative = 1; tentative <= MAX_ATTEMPTS; tentative++)); do
    printf 'Test %d/%d...\n' \
        "$tentative" "$MAX_ATTEMPTS"

    if curl -fsS \
        --max-time 2 \
        "$URL" \
        >/dev/null; then

        printf 'Service disponible.\n'
        exit 0
    fi

    if (( tentative < MAX_ATTEMPTS )); then
        sleep "$DELAY"
    fi
done

printf 'Service indisponible après %d tentatives.\n' \
    "$MAX_ATTEMPTS" >&2

exit 1

Exemple complet : lire un inventaire de serveurs

Fichier :

web01.example.net
web02.example.net
db01.example.net

Script :

#!/bin/bash
set -u

inventaire="serveurs.txt"
erreurs=0

if [[ ! -r "$inventaire" ]]; then
    printf 'Inventaire illisible : %s\n' "$inventaire" >&2
    exit 1
fi

while IFS= read -r serveur || [[ -n "$serveur" ]]; do
    [[ -n "$serveur" ]] || continue
    [[ "$serveur" == \#* ]] && continue

    printf 'Test : %s\n' "$serveur"

    if ping -c 1 -W 1 "$serveur" >/dev/null 2>&1; then
        printf '[OK] %s\n' "$serveur"
    else
        printf '[ERREUR] %s\n' "$serveur" >&2
        erreurs=$((erreurs + 1))
    fi
done < "$inventaire"

if (( erreurs > 0 )); then
    printf '%d cible(s) sans réponse ICMP.\n' "$erreurs" >&2
    exit 1
fi

Ping ne signifie pas « service disponible »

Le script précédent teste :

ICMP

Il ne prouve pas que :

SSH
HTTP
HTTPS
SMB
base de données

fonctionnent.

Une vraie supervision doit tester le service correspondant.

Exemple complet : menu Bash

#!/bin/bash

PS3="Votre choix : "

select action in \
    "Afficher la date" \
    "Afficher l’espace disque" \
    "Afficher les services en échec" \
    "Quitter"
do
    case "$REPLY" in
        1)
            date
            ;;
        2)
            df -h
            ;;
        3)
            systemctl --failed
            ;;
        4)
            printf 'Fin.\n'
            break
            ;;
        *)
            printf 'Choix invalide.\n' >&2
            ;;
    esac
done

Exemple complet : traitement sécurisé de fichiers

#!/bin/bash
set -u

readonly REPERTOIRE="/srv/import"

if [[ ! -d "$REPERTOIRE" ]]; then
    printf 'Répertoire absent : %s\n' "$REPERTOIRE" >&2
    exit 1
fi

traites=0
erreurs=0

while IFS= read -r -d '' fichier; do
    printf 'Traitement de : %s\n' "$fichier"

    if traiter_fichier "$fichier"; then
        traites=$((traites + 1))
    else
        printf 'Échec : %s\n' "$fichier" >&2
        erreurs=$((erreurs + 1))
    fi
done < <(
    find "$REPERTOIRE" \
        -type f \
        -name '*.csv' \
        -print0
)

printf 'Traités : %d\n' "$traites"
printf 'Erreurs : %d\n' "$erreurs"

(( erreurs == 0 ))

La dernière expression peut devenir le statut du script

Dans :

(( erreurs == 0 ))

Bash retourne :

0

si :

erreurs == 0

et non zéro sinon.

Pour davantage de lisibilité dans un script destiné à des débutants, on peut néanmoins écrire :

if (( erreurs > 0 )); then
    exit 1
fi

exit 0

Comment choisir entre for, while et until ?

Situation Boucle généralement adaptée
Parcourir une liste for
Parcourir un tableau for
Compter de 1 à N for (( ... ))
Lire un fichier ligne par ligne while read
Continuer tant qu’un état est vrai while
Attendre qu’un état devienne vrai until
Créer un petit menu interactif select
Boucle permanente while true ou équivalent

For ou while pour un compteur ?

Les deux fonctionnent.

For

for ((i = 0; i < 10; i++)); do
    printf '%d\n' "$i"
done

While

i=0

while (( i < 10 )); do
    printf '%d\n' "$i"
    i=$((i + 1))
done

Pour un simple compteur déterministe, :

for (( ... ))

est souvent plus compact.

Pour une condition évoluant de manière plus complexe, :

while

peut mieux exprimer l’intention.

While ou until ?

Comparez :

while [[ ! -f "$fichier" ]]; do
    sleep 1
done

et :

until [[ -f "$fichier" ]]; do
    sleep 1
done

La seconde formulation peut être plus naturelle :

« Attends jusqu’à ce que le fichier existe. »

La lisibilité compte davantage que l’élégance apparente

Cette construction :

while ! condition_complexe_inversee; do
    ...
done

peut être techniquement correcte.

Mais si :

until condition_positive; do
    ...
done

exprime plus clairement l’objectif, utilisez-la.

L’administrateur qui relira le script à 3 h 17 appréciera.

Checklist avant d’écrire une boucle

  1. Qu’est-ce qui doit être parcouru ?
  2. Quel est exactement un élément ?
  3. Les éléments peuvent-ils contenir des espaces ?
  4. S’agit-il de noms de fichiers arbitraires ?
  5. La liste peut-elle être vide ?
  6. La boucle doit-elle continuer après une erreur ?
  7. Existe-t-il une condition de sortie garantie ?
  8. Faut-il un timeout ?
  9. Faut-il une temporisation ?
  10. La commande exécutée possède-t-elle déjà un mode de traitement multiple ?

Checklist pour une boucle sur des fichiers

  • éviter de parser ls ;
  • utiliser les globs directement lorsqu’ils suffisent ;
  • citer les expansions de chemin ;
  • prévoir le cas où aucun glob ne correspond ;
  • penser aux fichiers cachés ;
  • penser aux noms commençant par - ;
  • utiliser NUL pour des collections de noms totalement arbitraires ;
  • prévisualiser avant une opération destructive.

Checklist pour while read

  • utiliser IFS= ;
  • utiliser read -r ;
  • citer "$ligne" ;
  • gérer la dernière ligne sans newline si nécessaire ;
  • éviter une pipeline si les variables de la boucle doivent survivre ;
  • ne pas confondre ligne de texte et nom de fichier arbitraire.

Checklist pour une boucle d’attente

  • la condition teste-t-elle réellement le service recherché ?
  • un sleep est-il présent ?
  • combien de temps maximum doit-on attendre ?
  • combien de tentatives ?
  • quel statut retourner après échec ?
  • faut-il journaliser les tentatives ?
  • un backoff serait-il préférable ?

Checklist pour une boucle destructive

  1. Afficher les cibles avant de modifier.
  2. Vérifier les variables de chemin.
  3. Vérifier le répertoire ou filesystem réellement concerné.
  4. Utiliser -- lorsque la commande le permet.
  5. Prévoir un dry-run.
  6. Vérifier les sauvegardes.
  7. Tester sur un environnement fictif.
  8. Vérifier l’utilisateur effectif.

Les constructions essentielles à retenir

Construction Rôle
for x in ... Parcourir une liste
for (( ... )) Boucle arithmétique
for x in "$@" Parcourir les arguments du script
while condition Continuer tant que la condition réussit
until condition Continuer jusqu’à ce que la condition réussisse
while IFS= read -r ligne Lire un flux ligne par ligne
break Quitter la boucle
continue Passer à l’itération suivante
break 2 Quitter plusieurs niveaux imbriqués
select Créer un menu interactif
shopt -s nullglob Faire disparaître un glob sans correspondance
find ... -print0 Produire des noms de fichiers séparés par NUL

Les anti-patterns à retenir

À éviter Préférer
for f in $(ls) for f in ./*
for ligne in $(cat fichier) while IFS= read -r ligne
for f in $liste Tableau + "${tableau[@]}"
{1..$max} for (( i=1; i<=max; i++ ))
Polling sans sleep Temporisation ou mécanisme événementiel
Retry éternel involontaire Nombre maximal / timeout
Pipeline + compteur à conserver Redirection ou substitution de processus
Suppression immédiatement Prévisualisation / dry-run puis suppression

Méthode recommandée pour construire une boucle

Commencez toujours par identifier les données.

Quels éléments vais-je parcourir ?

Puis écrivez une boucle qui ne fait qu’afficher :

for element in ...; do
    printf '%s\n' "$element"
done

Vérifiez :

  • le nombre d’éléments ;
  • leur valeur ;
  • les espaces ;
  • les cas vides ;
  • les éléments inattendus.

Ensuite seulement, remplacez :

printf

par :

la véritable opération

Exemple : méthode prudente

Objectif :

supprimer les .tmp de /srv/cache

Première version :

for fichier in /srv/cache/*.tmp; do
    [[ -e "$fichier" ]] || continue
    printf 'Cible : %s\n' "$fichier"
done

Deuxième étape :

for fichier in /srv/cache/*.tmp; do
    [[ -e "$fichier" ]] || continue
    printf '[DRY-RUN] rm -- %q\n' "$fichier"
done

Puis seulement après validation :

for fichier in /srv/cache/*.tmp; do
    [[ -e "$fichier" ]] || continue
    rm -- "$fichier"
done

Pourquoi %q est utile en Bash

Le format Bash :

printf '%q\n' "$variable"

produit une représentation permettant de mieux visualiser certains caractères spéciaux.

Par exemple, un fichier :

rapport annuel.txt

peut être présenté sous une forme révélant clairement l’espace.

C’est utile lors du debug de chemins inhabituels.

Une boucle doit être observable

Pour un script automatique, vous devez idéalement pouvoir savoir :

  • combien d’éléments ont été trouvés ;
  • combien ont été traités ;
  • combien ont échoué ;
  • quels éléments ont échoué ;
  • combien de temps l’opération a pris.

Un :

for ...

qui tourne silencieusement pendant 45 minutes n’est pas nécessairement cassé.

Mais il réussit admirablement à donner cette impression.

Une boucle doit avoir une politique d’erreur

Pour chaque commande susceptible d’échouer, choisissez volontairement :

échec
├── arrêter tout
├── ignorer
├── continuer mais compter l'erreur
├── retenter
└── restaurer / nettoyer

Ne laissez pas la réponse dépendre accidentellement de :

set -e

ou du statut d’une commande intermédiaire que personne n’avait pensé à vérifier.

Une boucle doit avoir une condition de fin compréhensible

Avant de lancer :

while
until

posez-vous :

« Quel événement rendra cette condition différente ? »

Si la réponse est :

« Normalement quelque chose devrait finir par changer. »

ajoutez probablement :

timeout
limite
journalisation

Conclusion : répéter intelligemment

Les boucles Bash sont simples à apprendre :

for
while
until
done

Mais leur utilisation robuste demande de comprendre :

  • les expansions du shell ;
  • les guillemets ;
  • les tableaux ;
  • les globs ;
  • les statuts de commandes ;
  • les sous-shells ;
  • les entrées ligne par ligne ;
  • les conditions de sortie.

Retenez surtout :

  • for est idéal pour parcourir une liste connue ;
  • for (( ... )) est préférable pour une boucle numérique dynamique ;
  • la brace expansion {1..5} est textuelle et ne doit pas être confondue avec une vraie boucle numérique ;
  • while continue tant que sa condition retourne un succès ;
  • until continue tant que sa condition échoue ;
  • break quitte une boucle et continue saute une itération ;
  • break n et continue n peuvent agir sur plusieurs niveaux imbriqués ;
  • un glob sans correspondance peut rester littéral si nullglob n’est pas activé ;
  • les globs ne sélectionnent normalement pas les fichiers cachés ;
  • il ne faut pas parser ls pour construire une boucle de fichiers ;
  • pour lire un fichier ligne par ligne, utilisez while IFS= read -r ;
  • pour des noms de fichiers totalement arbitraires, préférez un séparateur NUL ;
  • une boucle placée dans une pipeline peut s’exécuter dans un sous-shell ;
  • une boucle d’attente doit généralement avoir un sleep et souvent un timeout ;
  • une boucle destructive mérite une prévisualisation ou un mode dry-run ;
  • et une boucle n’est pas nécessaire lorsqu’une commande sait déjà traiter efficacement toute la collection.

Une boucle Bash correcte ressemble finalement à ceci :

données bien définies
↓
itération sûre
↓
variables correctement citées
↓
erreurs contrôlées
↓
condition de sortie claire
↓
résultat vérifiable

Une boucle incorrecte ressemble plutôt à :

while true; do
    rm -rf "$quelque_chose"
done

Dans les deux cas, Bash fera exactement ce qui est écrit.

La seule différence est que dans le second scénario, il le fera avec une remarquable constance jusqu’à ce qu’un humain, un signal ou l’absence définitive de fichiers mette fin à l’expérience.