Bash: differenza tra $(...) e backtick
Scopri perché $(...) usa una subshell che annulla cd e variabili e perché i backtick complicano il nesting. Risolvi gli errori di output e mantieni lo stato della tua shell.
Cosa fa la sostituzione di comando in bash
La sostituzione di comando in bash sostituisce $(command) con il testo che il comando ha stampato sullo standard output. La sintassi più datata con i backtick esegue la stessa operazione. Tutto ciò che solitamente sorprende deriva da due fatti: il comando viene eseguito in un processo separato chiamato subshell e ogni carattere di nuova riga alla fine dell'output viene eliminato.
mkdir -p /tmp/subst-demo
cd /tmp/subst-demo
printf 'alpha\nbeta\ngamma\n' > three.txt
count=$(wc -l < three.txt)
echo "$count"3Questa è l'intera funzionalità. wc -l ha stampato 3 seguito da una nuova riga, la nuova riga è stata rimossa e count contiene i due caratteri desiderati. Si noti che wc -l < three.txt stampa un numero puro perché GNU wc, leggendo dallo standard input, non ha alcun nome di file da stampare. Scrivere wc -l three.txt permette invece di catturare 3 three.txt, che è una stringa diversa e una causa comune di operazioni aritmetiche che falliscono in seguito.
Il resto di questa guida riguarda il comportamento che nessuno si aspetta, poiché una subshell è un processo separato e un processo separato non può modificare la shell in cui si sta scrivendo.
Usa $() e smetti di usare i backtick
Entrambe le forme sono valide. $() è conforme a POSIX, quindi dash, ash e busybox sh lo supportano pienamente. Non esistono più motivi di portabilità per scrivere i backtick, mentre esistono due ragioni concrete per evitarli.
I backtick non supportano l'annidamento
echo "$(echo "$(echo hi)")"
echo "`echo `echo hi``"hi
echo hiLa seconda riga ha stampato letteralmente le parole echo hi. La shell cerca in avanti il primo backtick non preceduto da escape, quindi il secondo backtick digitato ha chiuso il primo. Il comando effettivamente eseguito è stato echo senza argomenti, che ha stampato una riga vuota il cui carattere di newline è stato rimosso. Le parole echo hi sono rimaste come testo semplice e la coppia finale di backtick ha eseguito un comando vuoto.
Per annidare i backtick è necessario eseguire l'escape di ogni backtick interno:
echo "`echo \`echo hi\``"hiOgni livello aggiuntivo raddoppia la necessità di escape. $() non richiede nulla di tutto ciò, poiché il parser abbina le parentesi invece di cercare un carattere delimitatore.
I backtick modificano i backslash prima dell'esecuzione del comando
echo "$(echo 'a\\b')"
echo "`echo 'a\\b'`"a\\b
a\bLo stesso comando interno ha prodotto un output differente. All'interno dei backtick, la shell rimuove un livello di escape tramite backslash prima che il testo interno venga analizzato, quindi gli apici singoli non hanno protetto nulla. All'interno di $(), il testo tra le parentesi viene analizzato come uno script ordinario, quindi gli apici singoli si comportano come previsto. Questo problema si manifesta soprattutto nelle one-liner di sed e awk, dove un backslash mancante trasforma un pattern funzionante in uno differente senza generare errori.
Anche la gestione delle virgolette ricomincia da capo all'interno di $(), il che significa che è possibile annidare virgolette doppie senza doverle sottoporre a escape:
path=/etc/nginx/nginx.conf
echo "$(dirname "$path")"/etc/nginxL'equivalente con i backtick richiede \" attorno a $path. Ogni escape è un punto in cui è possibile commettere un errore.
Perché cd all'interno di $() non cambia la shell corrente
Perché $(...) genera un nuovo processo. La subshell riceve una copia delle variabili e una copia della directory di lavoro. Esegue il cambio nella sua copia, stampa un output ed esce. La copia termina insieme al processo.
cd /tmp/subst-demo
pwd
target=$(cd /etc && pwd)
echo "$target"
pwd/tmp/subst-demo
/etc
/tmp/subst-demoNon si è verificato alcun errore. Il comando cd è stato eseguito correttamente e pwd all'interno della subshell ha effettivamente stampato /etc. Semplicemente, non esiste un meccanismo per propagare tale modifica al processo padre, poiché l'unica cosa che una subshell restituisce al genitore è il suo standard output e un codice di uscita.
Le assegnazioni di variabili si comportano allo stesso modo:
count=0
msg=$(count=99; echo "inside: $count")
echo "$msg"
echo "outside: $count"inside: 99
outside: 0La stessa regola spiega la variante di questo errore in cui gli utenti incorrono più spesso, che coinvolge una pipeline invece di una sostituzione:
n=0
cat three.txt | while read -r line; do n=$((n+1)); done
echo "$n"0Ogni fase di una pipeline viene eseguita in una propria subshell; pertanto, il ciclo while ha incrementato una copia di n ed è poi terminato. Sostituendo la pipe con una redirezione, il ciclo viene eseguito nella shell corrente:
n=0
while read -r line; do n=$((n+1)); done < three.txt
echo "$n"3Bash può eseguire l'ultima fase di una pipeline nella shell corrente tramite shopt -s lastpipe, ma solo quando il job control è disabilitato, condizione che non si verifica mai in una shell interattiva. Utilizzare la redirezione.
Dove è finito il prompt? Comandi interattivi all'interno di $()
La sostituzione di comando reindirizza lo standard output in una pipe e lascia invariato lo standard input. Un programma che stampa la propria domanda sullo standard output e poi attende una risposta perde la domanda, non l'attesa. Il terminale sembra bloccato.
ask() { printf 'Username: '; read -r u; printf '%s\n' "$u"; }
askUsername: deploy
deployLa parola deploy sulla prima riga è ciò che hai digitato. Ora esegui la stessa funzione all'interno di una sostituzione:
v=$(ask)deployAppaiono solo i tasti premuti, visualizzati dal driver del terminale anziché dal programma. Il prompt è finito altrove:
echo "$v"Username: deployIl prompt si trova ora all'interno della variabile, unito alla risposta, perché $() ha catturato tutto ciò che la funzione ha scritto sullo standard output. La tua digitazione ha comunque raggiunto read, poiché lo standard input non è mai stato toccato. Questa è l'esatta firma del bug report che recita "il mio script si blocca e non stampa nulla".
Alcuni strumenti scrivono i prompt sullo standard error o direttamente su /dev/tty, così da sopravvivere a questo processo. Molti altri non lo fanno. Se una funzione deve rimanere all'interno di una sostituzione, invia tu stesso il prompt allo standard error:
ask() { printf 'Username: ' >&2; read -r u; printf '%s\n' "$u"; }
v=$(ask)
echo "$v"Username: deploy
deployLo standard error non viene catturato, quindi il prompt raggiunge il tuo terminale e solo la risposta finisce in v.
Perché ls ha un aspetto diverso all'interno di $()?
Perché ls invoca isatty sul descrittore di file 1 e modifica il formato di output in base alla risposta. Al prompt, quel descrittore è il tuo terminale, quindi ls distribuisce i nomi su più colonne lungo la riga. All'interno di una sostituzione, si tratta di una pipe, quindi ls passa a un nome per riga.
mkdir -p /tmp/tty-demo
cd /tmp/tty-demo
touch alpha beta delta gamma
echo "$(ls)"alpha
beta
delta
gammaLo stesso controllo disabilita il colore in grep --color=auto e disattiva il pager in git. Questa è una funzionalità. Significa che uno script ottiene un output stabile e leggibile da una macchina senza doverlo richiedere esplicitamente.
Questo risponde anche a una domanda che viene posta spesso riguardo alle pipeline. ls | sort al prompt e $(ls | sort) stampano la stessa cosa, perché ls aveva una pipe sul suo output in entrambi i casi. Ciò che cambia all'interno di una sostituzione è l' ultima fase della pipeline. sort non controlla mai la presenza di un terminale, quindi il suo output non cambia mai. Inserisci un comando che rileva il terminale alla fine della pipeline e vedrai che cambierà; ecco perché una pipeline testata visivamente può comportarsi in modo diverso nel momento in cui la racchiudi in $().
Un avvertimento che ne consegue: non analizzare l'output di ls in uno script, anche se sembra comodo. I nomi dei file possono contenere spazi e caratteri di nuova riga. Usa un glob, oppure find -print0 con read -d ''.
Le nuove righe finali che scompaiono silenziosamente
La sostituzione di comando rimuove ogni carattere di nuova riga alla fine dell'output. Non solo l'ultima. Tutte.
cd /tmp/subst-demo
printf 'hello\n\n\n' > blanks.txt
wc -c < blanks.txt
v=$(cat blanks.txt)
printf '%s' "$v" | wc -c8
5Il file contiene hello più tre nuove righe, quindi 8 byte. La variabile contiene hello, quindi 5 byte. Tre byte sono svaniti senza alcun avviso.
Questa rimozione è intenzionale e solitamente utile. È ciò che permette a stamp=$(date -u +%Y%m%dT%H%M%SZ) di produrre un frammento di nome file utilizzabile invece di un nome contenente un'interruzione di riga, motivo per cui il pattern è sicuro in contesti come uno script di backup restic pianificato:
stamp=$(date -u +%Y%m%dT%H%M%SZ)
printf 'backup-%s.tar.gz\n' "$stamp"backup-20260804T031500Z.tar.gzIl timestamp sarà diverso. Ciò che conta è che il nome sia su una sola riga.
Il costo è che non è possibile utilizzare $() per spostare esattamente i byte di un file. Se sono necessarie le nuove righe finali, occorre aggiungere un carattere sentinella all'interno della sostituzione e rimuoverlo in seguito:
v=$(cat blanks.txt; printf x)
v=${v%x}
printf '%s' "$v" | wc -c8Il carattere x si trova dopo le nuove righe, quindi non rimangono nuove righe finali da rimuovere. ${v%x} elimina poi il sentinella e lascia i byte originali.
Due dettagli correlati. Per leggere un intero file, v=$(<blanks.txt) svolge lo stesso lavoro senza eseguire cat, poiché bash apre il file direttamente. Rimuove le nuove righe finali in modo identico. Inoltre, le here-string si muovono nella direzione opposta, aggiungendo una nuova riga che non è stata scritta:
wc -c <<< 'abc'4Racchiudere il risultato tra virgolette, altrimenti bash eseguirà word splitting e globbing
Una sostituzione non racchiusa tra virgolette subisce word splitting e successivamente pathname expansion. Una sostituzione racchiusa tra virgolette non subisce nessuno dei due processi.
printf 'a b\tc\nd\n' > words.txt
echo $(cat words.txt)
echo "$(cat words.txt)"a b c d
a b c
dSenza virgolette, bash ha suddiviso l'output in base ai caratteri definiti in IFS, che per impostazione predefinita sono spazio, tabulazione e nuova riga, e echo ha poi unito i quattro frammenti con singoli spazi. Con le virgolette, il testo è arrivato come un'unica parola, mantenendo intatte la tabulazione e la nuova riga interna.
Il globbing rappresenta la metà più pericolosa:
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'assegnazione in sé era sicura, poiché le assegnazioni non eseguono word splitting né globbing. Il danno si è verificato in echo $p, dove * è stato espanso rispetto alla directory corrente. Uno script che legge un pattern da un file di configurazione e dimentica le virgolette agirà inavvertitamente su ogni file visibile. Racchiudere ogni espansione tra virgolette elimina l'intera classe di bug. Omettere le virgolette solo quando è effettivamente necessario lo splitting, il che è raro.
Perché local x=$(cmd) restituisce sempre 0?
Perché local è di per sé un comando e $? riporta lo stato di local, non lo stato della sostituzione che conteneva.
check_bad() { local out=$(false); echo "status: $?"; }
check_badstatus: 0false è terminato con 1, local ha completato con successo la dichiarazione della variabile e l'1 è stato scartato. declare, export, typeset e readonly si comportano tutti allo stesso modo. Anche set -e non rileverà l'errore, poiché dal punto di vista della shell non si è verificato alcun fallimento.
Separare la dichiarazione dall'assegnazione:
check_good() { local out; out=$(false); echo "status: $?"; }
check_goodstatus: 1Un'assegnazione semplice al livello principale riporta già lo stato dell'ultima sostituzione di comando:
out=$(exit 3)
echo $?3Questo aspetto è critico in un controllo di integrità eseguito tramite un servizio e un timer systemd, dove un codice di uscita mascherato fa sì che l'unità riporti un successo a ogni esecuzione, anche se il lavoro che avrebbe dovuto verificare non è mai avvenuto.
Le alternative in-shell che ti servono davvero
La maggior parte degli utenti ricorre a $(...) perché desidera memorizzare i dati in una variabile. Spesso, tuttavia, ciò che serve realmente è l'input, non l'acquisizione. Queste quattro forme mantengono lo stato nella shell corrente.
Un redirect sul ciclo
cd /tmp/subst-demo
while read -r line; do printf 'got: %s\n' "$line"; done < three.txtgot: alpha
got: beta
got: gammaNon viene creato alcun processo per l'input, pertanto qualsiasi variabile impostata nel corpo del ciclo persiste al termine dello stesso.
Sostituzione di processo
while read -r line; do printf 'got: %s\n' "$line"; done < <(sort -r three.txt)got: gamma
got: beta
got: alpha<(command) fornisce un percorso, simile a /dev/fd/63, che legge l'output del comando. Il comando viene comunque eseguito nel proprio processo. Il ciclo while invece no, ed è proprio questo il punto. Lo spazio in < <( è obbligatorio: <<( verrebbe interpretato come l'inizio di un here-document e causerebbe un errore di parsing. La sostituzione di processo è una funzionalità di bash; pertanto, uno script con #!/bin/sh su Ubuntu o Debian viene eseguito con dash e fallirà. Usa #!/bin/bash.
Here-strings
read -r first rest <<< 'alpha beta gamma'
echo "$first"
echo "$rest"alpha
beta gamma<<< invia una stringa allo standard input di un comando. read viene eseguito nella tua shell, quindi entrambe le variabili sono impostate e disponibili per l'uso. read -r a b <<< "$(some-command)" è il metodo standard per estrarre due campi da una singola riga di output.
mapfile per file interi
mapfile -t lines < three.txt
echo "${#lines[@]}"
echo "${lines[1]}"3
betamapfile, noto anche come readarray, legge un file all'interno di un array nella shell corrente. -t rimuove il carattere di nuova riga finale da ogni elemento. Richiede bash 4 o versioni successive; dato che Ubuntu 24.04 include bash 5.2, è disponibile su qualsiasi immagine server attuale.
Una checklist prima di eseguire lo script
- Scrivi
$(command)e racchiudilo tra virgolette come"$(command)", a meno che tu non voglia esplicitamente una suddivisione. - Dai per scontato che i caratteri di fine riga finali vengano rimossi. Aggiungi un carattere sentinella se hai bisogno di ripristinarli.
- Escludi i comandi interattivi dalle sostituzioni o invia i loro prompt allo standard error.
- Scrivi
local outsu una riga separata quando lo stato di uscita diout=$(command)è rilevante. - Per impostare variabili dall'input, usa un redirect o una process substitution invece di una pipe.
Queste configurazioni compaiono nei primi piccoli script scritti da chi sta configurando una nuova VPS, e i fallimenti rimangono silenziosi. Uno script di backup che cattura un prompt nel nome di un file, o un controllo di integrità che maschera un codice di uscita, continua a segnalare un successo. Il costo aumenta quando esegui lo stesso script su diversi server, perché l'output che non hai mai letto è ora un output che non leggi su venti macchine diverse.
FAQ
Perché cd all'interno di $() non cambia la mia directory corrente?
$(...) esegue il comando in una subshell, ovvero un processo separato che possiede una copia della directory di lavoro e delle variabili. Il comando cd modifica tale copia, ma al termine del processo la copia viene eliminata. Una subshell può restituire solo lo standard output e un codice di uscita; non esiste alcun meccanismo per propagare il cambio di directory alla shell genitore. Se serve il percorso della directory, acquisiscilo con target=$(cd /etc && pwd) e usa "$target". Se vuoi che la shell si sposti, esegui cd direttamente, senza racchiuderlo in una sostituzione.
Qual è la differenza tra $() e i backtick in bash?
Producono lo stesso risultato per i comandi semplici, ma differiscono in due aspetti rilevanti. $() supporta l'annidamento diretto, poiché il parser riconosce le parentesi, mentre i backtick richiedono un backslash di escape per ogni livello di annidamento. I backtick rimuovono inoltre un livello di escape tramite backslash prima che il comando interno venga analizzato, quindi ` echo 'a\\b' prints a\b while $(echo 'a\\b') prints a\\b. $() is in POSIX and works in dash and busybox sh`, pertanto non esiste alcun motivo di portabilità per preferire i backtick.
Perché il mio script si blocca senza mostrare il prompt quando un comando pone una domanda?
La sostituzione di comando reindirizza lo standard output in una pipe, ma lascia lo standard input collegato al terminale. Un programma che scrive il proprio prompt sullo standard output vede tale prompt catturato nella variabile, mentre il comando read sottostante resta in attesa di un input. Il terminale mostra solo i caratteri digitati, inviati dal driver del terminale. Sposta la domanda al di fuori della sostituzione, oppure scrivi il prompt sullo standard error con printf 'Username: ' >&2 in modo che non venga catturato.
Perché le righe vuote alla fine della mia variabile sono scomparse?
La sostituzione di comando rimuove ogni newline finale, non solo l'ultima. printf 'hello\n\n\n' > f; v=$(cat f) lascia v con cinque byte, mentre il file ne contiene otto. Per conservarle, aggiungi un marcatore (sentinel) all'interno della sostituzione e rimuovilo in seguito con v=$(cat f; printf x) seguito da v=${v%x}. Il marcatore si trova dopo i newline, quindi non c'è nulla alla fine che bash possa rimuovere.
Perché local out=$(cmd) segnala sempre successo?
local è un comando a sé stante e $? dopo quella riga riporta se local ha avuto successo nel dichiarare la variabile. Il codice di uscita della sostituzione viene consumato e scartato, il che significa anche che set -e non interromperà lo script. declare, export, typeset e readonly si comportano allo stesso modo. Scrivi local out su una riga e out=$(cmd) su quella successiva, così $? riporterà il codice di uscita reale.