SSD Nodes Learn 🎉 VPS dès $4.99/mois
Guides Matt ConnorPar Matt Connor · Mis à jour le 2026-08-07

Substitution de commande Bash : $() ou accents graves ?

Comprenez pourquoi cd et les variables disparaissent dans un subshell, pourquoi les accents graves s’imbriquent mal et quelles astuces gardent l’état du shell.

Verified Every command ran end-to-end on a fresh Ubuntu 24.04 server, August 4, 2026.

Fonctionnement de la substitution de commande de bash

La substitution de commande de Bash remplace $(command) par le texte que cette commande a écrit sur la sortie standard. L’ancienne syntaxe avec des accents graves produit le même résultat. Tout ce qui surprend repose sur deux faits : la commande s’exécute dans un processus distinct appelé subshell, et chaque saut de ligne à la fin de sa sortie est supprimé.

mkdir -p /tmp/subst-demo
cd /tmp/subst-demo
printf 'alpha\nbeta\ngamma\n' > three.txt
count=$(wc -l < three.txt)
echo "$count"
3

C’est toute la fonctionnalité. wc -l a affiché 3 suivi d’un saut de ligne ; le saut de ligne a été supprimé, et count contient les deux caractères attendus. Notez que wc -l < three.txt affiche un nombre seul, car GNU wc, qui lit l’entrée standard, n’a aucun nom de fichier à afficher. Écrivez plutôt wc -l three.txt : vous capturez alors 3 three.txt, qui est une chaîne différente et une cause fréquente d’échec ultérieur des opérations arithmétiques.

La suite de ce guide décrit les comportements que personne n’attend, car un subshell est un processus distinct et un processus distinct ne peut pas modifier le shell dans lequel vous saisissez vos commandes.

Utilisez $() et n’utilisez plus les accents graves

Les deux formes sont valides. $() fait partie de POSIX, donc dash, ash et busybox sh la prennent toutes en charge. Il n’existe plus de raison de portabilité d’écrire des accents graves, et deux raisons concrètes justifient de ne pas le faire.

Les accents graves ne permettent pas l’imbrication

echo "$(echo "$(echo hi)")"
echo "`echo `echo hi``"
hi
echo hi

La deuxième ligne a affiché littéralement les mots echo hi. Le shell parcourt la suite jusqu’au prochain accent grave non échappé, et le deuxième accent grave que vous avez saisi a donc fermé le premier. La commande réellement exécutée était echo sans argument. Elle a affiché une ligne vide, dont le retour à la ligne a ensuite été supprimé, ce qui a produit une chaîne vide. Les mots echo hi sont restés comme texte brut, et la dernière paire d’accents graves a exécuté une commande vide.

Pour imbriquer des accents graves, vous devez échapper chaque accent grave interne :

echo "`echo \`echo hi\``"
hi

Chaque niveau supplémentaire double encore le nombre d’échappements nécessaires. $() évite entièrement ce problème, car l’analyseur associe les parenthèses au lieu de rechercher un caractère délimiteur.

Les accents graves modifient vos antislashs avant l’exécution de la commande

echo "$(echo 'a\\b')"
echo "`echo 'a\\b'`"
a\\b
a\b

La même commande interne a produit une sortie différente. Avec les accents graves, le shell supprime un niveau d’échappement des antislashs avant d’analyser le texte interne. Les guillemets simples ne protégeaient donc rien. Avec $(), le texte situé entre les parenthèses est analysé comme un script ordinaire, et les guillemets simples se comportent comme prévu. Le problème est particulièrement gênant dans les commandes en une ligne sed et awk, où la perte d’un antislash transforme un pattern fonctionnel en un autre pattern, sans message d’erreur.

Les règles de citation repartent également de zéro à l’intérieur de $(). Vous pouvez donc imbriquer des guillemets doubles sans les échapper :

path=/etc/nginx/nginx.conf
echo "$(dirname "$path")"
/etc/nginx

L’équivalent avec des accents graves nécessite \" autour de $path. Chaque échappement est une occasion de faire une erreur.

Pourquoi cd dans $() ne change pas votre shell

Parce que $(...) crée un nouveau processus. Le subshell reçoit une copie de vos variables et une copie de votre répertoire de travail. Il modifie sa copie, affiche quelque chose, puis se termine. La copie disparaît avec lui.

cd /tmp/subst-demo
pwd
target=$(cd /etc && pwd)
echo "$target"
pwd
/tmp/subst-demo
/etc
/tmp/subst-demo

Rien n’a échoué. Le cd a fonctionné et pwd dans le subshell a bien affiché /etc. Ce changement ne peut tout simplement pas remonter, car les seuls éléments qu’un subshell transmet à son processus parent sont sa sortie standard et un code de retour.

Les affectations de variables suivent la même règle :

count=0
msg=$(count=99; echo "inside: $count")
echo "$msg"
echo "outside: $count"
inside: 99
outside: 0

La même règle explique la version de ce bug que l’on rencontre beaucoup plus souvent, avec un pipeline plutôt qu’une substitution :

n=0
cat three.txt | while read -r line; do n=$((n+1)); done
echo "$n"
0

Chaque étape d’un pipeline s’exécute dans son propre subshell. La boucle while a donc incrémenté une copie de n, puis s’est terminée. Remplacez le pipe par une redirection pour que la boucle s’exécute dans votre shell :

n=0
while read -r line; do n=$((n+1)); done < three.txt
echo "$n"
3

Bash peut exécuter la dernière étape d’un pipeline dans le shell courant avec shopt -s lastpipe, mais uniquement lorsque le job control est désactivé, ce qui n’est jamais le cas dans un shell interactif. Utilisez la redirection.

Où est passée l’invite ? Commandes interactives dans $()

Une substitution de commande redirige la sortie standard vers un pipe et laisse l’entrée standard intacte. Un programme qui affiche sa question sur la sortie standard, puis attend une réponse, perd la question, mais pas l’attente. Le terminal semble figé.

ask() { printf 'Username: '; read -r u; printf '%s\n' "$u"; }
ask
Username: deploy
deploy

Le mot deploy sur la première ligne correspond à ce que vous avez saisi. Exécutez maintenant la même fonction dans une substitution :

v=$(ask)
deploy

Seules vos propres frappes apparaissent, répercutées par le pilote du terminal et non par le programme. L’invite est passée ailleurs :

echo "$v"
Username: deploy

L’invite se trouve maintenant dans la variable, accolée à la réponse, car $() a capturé tout ce que la fonction a écrit sur la sortie standard. Votre saisie est tout de même parvenue à read, car l’entrée standard n’a pas été modifiée. C’est la signature exacte du bug signalé par « mon script se bloque et n’affiche rien ».

Certains outils écrivent les invites sur la sortie d’erreur standard ou directement sur /dev/tty pour qu’elles restent visibles dans ce cas. Beaucoup ne le font pas. Si une fonction doit rester dans une substitution, envoyez vous-même son invite vers la sortie d’erreur standard :

ask() { printf 'Username: ' >&2; read -r u; printf '%s\n' "$u"; }
v=$(ask)
echo "$v"
Username: deploy
deploy

La sortie d’erreur standard n’est pas capturée. L’invite atteint donc votre terminal, et seule la réponse est placée dans v.

Pourquoi ls s’affiche-t-il différemment dans $() ?

Parce que ls appelle isatty sur le descripteur de fichier 1 et modifie son format de sortie selon la réponse. À l’invite, ce descripteur correspond à votre terminal. ls répartit donc les noms en colonnes sur la ligne. Dans une substitution, il correspond à un pipe. ls passe alors à un nom par ligne.

mkdir -p /tmp/tty-demo
cd /tmp/tty-demo
touch alpha beta delta gamma
echo "$(ls)"
alpha
beta
delta
gamma

Le même contrôle désactive les couleurs dans grep --color=auto et le pager dans git. C’est un comportement prévu. Un script obtient ainsi une sortie stable et lisible par une machine sans avoir à le demander explicitement.

Cela répond également à une question fréquente sur les pipelines. ls | sort à l’invite et $(ls | sort) affichent la même chose, car ls avait un pipe en sortie dans les deux cas. Dans une substitution, c’est la dernière étape du pipeline qui change. sort ne vérifie jamais la présence d’un terminal. Sa sortie ne change donc jamais. Si vous placez une commande qui tient compte du terminal à la fin du pipeline, son comportement change. C’est pourquoi un pipeline vérifié visuellement peut se comporter différemment dès que vous l’englober dans $().

Une mise en garde en découle : n’analysez pas la sortie de ls dans un script, même si cette méthode semble pratique. Les noms de fichiers peuvent contenir des espaces et des retours à la ligne. Utilisez un glob, ou find -print0 avec read -d ''.

Les retours à la ligne finaux qui disparaissent silencieusement

La substitution de commande supprime tous les retours à la ligne à la fin de la sortie. Pas seulement le dernier. Tous.

cd /tmp/subst-demo
printf 'hello\n\n\n' > blanks.txt
wc -c < blanks.txt
v=$(cat blanks.txt)
printf '%s' "$v" | wc -c
8
5

Le fichier contient hello suivi de trois retours à la ligne, soit 8 octets. La variable contient hello, soit 5 octets. Trois octets ont disparu sans avertissement.

Cette suppression est volontaire et généralement utile. Elle permet à stamp=$(date -u +%Y%m%dT%H%M%SZ) de produire un fragment de nom de fichier exploitable, au lieu d’un nom contenant un retour à la ligne. C’est pourquoi ce modèle est sûr dans un script tel que un script de sauvegarde restic planifié :

stamp=$(date -u +%Y%m%dT%H%M%SZ)
printf 'backup-%s.tar.gz\n' "$stamp"
backup-20260804T031500Z.tar.gz

Votre horodatage sera différent. L’important est que le nom tienne sur une seule ligne.

En contrepartie, vous ne pouvez pas utiliser $() pour déplacer les octets exacts d’un fichier. Si vous devez conserver les retours à la ligne finaux, ajoutez un caractère sentinelle dans la substitution, puis supprimez-le ensuite :

v=$(cat blanks.txt; printf x)
v=${v%x}
printf '%s' "$v" | wc -c
8

Le x se trouve après les retours à la ligne. Il ne reste donc aucun retour à la ligne final à supprimer. ${v%x} supprime ensuite la sentinelle et conserve les octets d’origine.

Deux détails connexes. Pour lire un fichier entier, v=$(<blanks.txt) effectue le même travail sans exécuter cat, car bash ouvre lui-même le fichier. Il supprime les retours à la ligne finaux de la même manière. À l’inverse, les here-strings ajoutent un retour à la ligne que vous n’avez pas écrit :

wc -c <<< 'abc'
4

Entourez le résultat de guillemets, sinon bash effectuera le découpage et l’expansion par motif

Une substitution sans guillemets est soumise au découpage en mots, puis à l’expansion des chemins. Une substitution entre guillemets n’est soumise à aucun de ces traitements.

printf 'a b\tc\nd\n' > words.txt
echo $(cat words.txt)
echo "$(cat words.txt)"
a b c d
a b	c
d

Sans guillemets, bash a découpé la sortie selon les caractères de IFS, qui sont par défaut l’espace, la tabulation et le saut de ligne, puis echo a réuni les quatre éléments avec des espaces simples. Entre guillemets, le texte est arrivé comme un seul mot, en conservant la tabulation et le saut de ligne interne.

L’expansion par motif est la partie la plus dangereuse :

mkdir -p /tmp/glob-demo
cd /tmp/glob-demo
touch one.txt two.txt
printf '*\n' > pattern.txt
p=$(cat pattern.txt)
echo $p
echo "$p"
one.txt pattern.txt two.txt
*

L’affectation elle-même était sûre, car les affectations ne sont soumises ni au découpage en mots ni à l’expansion par motif. Le problème est survenu à echo $p, lorsque le * a été développé dans le répertoire courant. Un script qui lit un motif dans un fichier de configuration et oublie les guillemets peut alors agir sur chaque fichier auquel il a accès. Entourez chaque expansion de guillemets pour éliminer toute cette catégorie de bogues. Omettez les guillemets uniquement lorsque vous voulez réellement effectuer le découpage, ce qui est rare.

Pourquoi local x=$(cmd) renvoie-t-il toujours 0 ?

Parce que local est elle-même une commande, et que $? renvoie le code de sortie de local, pas celui de la substitution qu’elle contenait.

check_bad() { local out=$(false); echo "status: $?"; }
check_bad
status: 0

false s’est terminée avec le code 1, local a réussi à déclarer la variable, et le code 1 a été perdu. declare, export, typeset et readonly se comportent de la même manière. set -e ne le détectera pas non plus, car, du point de vue du shell, aucune commande n’a échoué.

Séparez la déclaration de l’affectation :

check_good() { local out; out=$(false); echo "status: $?"; }
check_good
status: 1

Une affectation seule au niveau supérieur renvoie déjà le code de sortie de sa dernière substitution de commande :

out=$(exit 3)
echo $?
3

C’est particulièrement important dans un health check exécuté par un service et un timer systemd, car un code de sortie masqué fait que l’unité signale une réussite à chaque exécution alors que le traitement qu’elle devait vérifier n’a jamais été effectué.

Les alternatives dans le shell à utiliser réellement

La plupart des utilisateurs choisissent $(...) parce qu’ils veulent stocker des données dans une variable. Souvent, ils veulent en réalité une entrée, pas une capture. Ces quatre formes conservent l’état dans votre shell courant.

Redirection sur la boucle

cd /tmp/subst-demo
while read -r line; do printf 'got: %s\n' "$line"; done < three.txt
got: alpha
got: beta
got: gamma

Aucun processus n’est créé pour l’entrée. Les valeurs définies dans le corps de la boucle restent donc disponibles après la boucle.

Substitution de processus

while read -r line; do printf 'got: %s\n' "$line"; done < <(sort -r three.txt)
got: gamma
got: beta
got: alpha

<(command) vous fournit un chemin, par exemple /dev/fd/63, qui lit la sortie de la commande. La commande s’exécute toujours dans son propre processus. Ce n’est pas le cas de la boucle while, ce qui est précisément l’objectif. L’espace dans < <( est obligatoire : <<( est interprété comme le début d’un here-document et ne peut pas être analysé. La substitution de processus est une fonctionnalité de bash. Un script contenant #!/bin/sh sur Ubuntu ou Debian s’exécute donc avec dash et échoue. Utilisez #!/bin/bash.

Here-strings

read -r first rest <<< 'alpha beta gamma'
echo "$first"
echo "$rest"
alpha
beta gamma

<<< transmet une chaîne à l’entrée standard d’une commande. read s’exécute dans votre shell. Les deux variables sont donc définies à l’endroit où vous pouvez les utiliser. read -r a b <<< "$(some-command)" est la méthode standard pour extraire deux champs d’une ligne de sortie.

mapfile pour les fichiers entiers

mapfile -t lines < three.txt
echo "${#lines[@]}"
echo "${lines[1]}"
3
beta

mapfile, également appelé readarray, lit un fichier dans un tableau du shell courant. -t supprime le retour à la ligne final de chaque élément. Cette commande nécessite bash 4 ou une version ultérieure. Ubuntu 24.04 fournit bash 5.2. Elle est donc disponible sur toute image serveur actuelle.

Une liste de contrôle avant de valider le script

  • Écrivez $(command) et protégez-le entre guillemets sous la forme "$(command)", sauf si vous souhaitez explicitement effectuer un découpage.
  • Partez du principe que les retours à la ligne finaux ont disparu. Ajoutez un caractère sentinelle si vous devez les conserver.
  • N’utilisez pas de commandes interactives dans les substitutions, ou envoyez leurs invites vers la sortie d’erreur standard.
  • Écrivez local out sur sa propre ligne lorsque le code de sortie de out=$(command) est important.
  • Pour définir des variables à partir de l’entrée, utilisez une redirection ou une substitution de processus plutôt qu’un pipe.

Ces constructions apparaissent dans les premiers petits scripts que l’on écrit lorsqu’on configure un nouveau VPS, et les échecs restent silencieux. Un script de sauvegarde qui a capturé une invite dans un nom de fichier, ou un contrôle d’état qui a masqué un code de sortie, continue de signaler une réussite. Le coût augmente lorsque vous exécutez le même script sur plusieurs serveurs, car la sortie que vous ne lisiez déjà pas est désormais produite par vingt machines.

FAQ

Pourquoi cd dans $() ne modifie-t-il pas mon répertoire courant ?

$(...) exécute sa commande dans un sous-shell. Il s’agit d’un processus distinct qui contient une copie de votre répertoire de travail et de vos variables. cd modifie cette copie, puis le processus se termine et la copie est supprimée. Un sous-shell peut uniquement renvoyer sa sortie standard et un code de retour. Il n’existe donc aucun mécanisme permettant à la modification du répertoire d’atteindre le shell parent. Si vous voulez récupérer le répertoire, capturez-le avec target=$(cd /etc && pwd) et utilisez "$target". Si vous voulez déplacer votre shell, exécutez directement cd, sans l’entourer d’une substitution.

Quelle est la différence entre $() et les backticks dans bash ?

Pour les commandes simples, ils produisent le même résultat. Deux différences sont importantes. $() peut être imbriqué directement, car l’analyseur associe les parenthèses. Avec les backticks, il faut échapper les backticks à chaque niveau d’imbrication. Les backticks suppriment également un niveau d’échappement par antislash avant l’analyse de la commande interne. Ainsi, ` echo 'a\\b' prints a\b while $(echo 'a\\b') prints a\\b. $() is in POSIX and works in dash and busybox sh`, et il n’existe donc aucun argument de portabilité en faveur des backticks.

Pourquoi mon script reste-t-il bloqué sans afficher d’invite lorsqu’une commande pose une question ?

La substitution de commande redirige la sortie standard vers un pipe, mais laisse l’entrée standard connectée à votre terminal. Un programme qui affiche son invite sur la sortie standard voit cette invite capturée dans la variable, tandis que le read situé derrière attend toujours votre saisie. Le terminal affiche uniquement les caractères que vous tapez, répercutés par le pilote du terminal. Déplacez la question en dehors de la substitution, ou faites écrire l’invite sur la sortie d’erreur standard avec printf 'Username: ' >&2 afin qu’elle ne soit pas capturée.

Pourquoi les lignes vides à la fin de ma variable ont-elles disparu ?

La substitution de commande supprime tous les sauts de ligne finaux, et pas uniquement le dernier. printf 'hello\n\n\n' > f; v=$(cat f) laisse v contenir cinq octets, alors que le fichier en contient huit. Pour les conserver, ajoutez un marqueur à la fin de la substitution, puis supprimez-le avec v=$(cat f; printf x) suivi de v=${v%x}. Le marqueur se trouve après les sauts de ligne. Il ne reste donc rien à la fin que bash puisse supprimer.

Pourquoi local out=$(cmd) indique-t-il toujours une réussite ?

local est une commande à part entière. $?, exécuté après cette ligne, indique si local a réussi à déclarer la variable. Le code de retour de la substitution est consommé puis ignoré. Cela signifie également que set -e n’arrêtera pas le script. declare, export, typeset et readonly se comportent de la même manière. Écrivez local out sur une ligne, puis out=$(cmd) sur la suivante. $? indique alors le véritable code de retour.

#bash#shell-scripting#linux#subshell#coreutils