Bash command substitution: $() versus backticks
Ontdek waarom $(...) in een subshell draait en variabelen verliest. Leer waarom backticks slecht nesten en hoe u met proces-substitutie de status in uw shell behoudt.
Wat bash command substitution doet
Bash command substitution vervangt $(command) door de tekst die het commando naar de standaarduitvoer schreef. De oudere schrijfwijze met backticks doet hetzelfde. Alles wat gebruikers verrast, vloeit voort uit twee feiten: het commando draait in een apart proces genaamd een subshell, en elke newline aan het einde van de uitvoer wordt verwijderd.
mkdir -p /tmp/subst-demo
cd /tmp/subst-demo
printf 'alpha\nbeta\ngamma\n' > three.txt
count=$(wc -l < three.txt)
echo "$count"3Dat is de volledige functionaliteit. wc -l printte 3 gevolgd door een newline, de newline werd verwijderd en count bevat de twee tekens die u wilde hebben. Let op: wc -l < three.txt print een kaal getal omdat GNU wc bij het lezen van standaardinvoer geen bestandsnaam heeft om te printen. Schrijf in plaats daarvan wc -l three.txt en u vangt 3 three.txt op; dit is een andere string en een veelvoorkomende oorzaak van rekenfouten die later optreden.
De rest van deze handleiding behandelt het gedrag dat niemand verwacht, omdat een subshell een apart proces is en een apart proces de shell waarin u typt niet kan wijzigen.
Gebruik $() in plaats van backticks
Beide vormen zijn geldig. $() is onderdeel van POSIX, dus dash, ash en busybox sh ondersteunen dit allemaal. Er is geen reden meer op basis van portabiliteit om backticks te schrijven, en er zijn twee concrete redenen om dit niet te doen.
Backticks kunnen niet worden genest
echo "$(echo "$(echo hi)")"
echo "`echo `echo hi``"hi
echo hiDe tweede regel printte de letterlijke woorden echo hi. De shell zoekt vooruit naar de volgende niet-geëscapete backtick, waardoor de tweede backtick die u typte de eerste afsloot. Het commando dat daadwerkelijk werd uitgevoerd was echo zonder argumenten, wat een lege regel printte waarvan de newline werd verwijderd. De woorden echo hi bleven achter als platte tekst, en het laatste paar backticks voerde een leeg commando uit.
Om backticks te nesten, moet u elke interne backtick escapen:
echo "`echo \`echo hi\``"hiElk extra niveau verdubbelt het aantal escapes. $() heeft dit niet nodig, omdat de parser haakjes matcht in plaats van te zoeken naar een scheidingsteken.
Backticks wijzigen uw backslashes voordat het commando wordt uitgevoerd
echo "$(echo 'a\\b')"
echo "`echo 'a\\b'`"a\\b
a\bHetzelfde interne commando produceerde verschillende uitvoer. Binnen backticks verwijdert de shell één laag backslash-escaping voordat de interne tekst wordt geparseerd, waardoor de enkele aanhalingstekens niets beschermden. Binnen $() wordt de tekst tussen de haakjes geparseerd als een gewoon script, waardoor enkele aanhalingstekens zich gedragen zoals u verwacht. Dit is vooral problematisch in sed en awk one-liners, waarbij een verloren backslash een werkend patroon stilletjes verandert in een ander patroon.
Quoting begint ook opnieuw binnen $(), wat betekent dat u dubbele aanhalingstekens kunt nesten zonder ze te escapen:
path=/etc/nginx/nginx.conf
echo "$(dirname "$path")"/etc/nginxHet equivalent met backticks vereist \" rond $path. Elke escape is een plek waar een fout kan ontstaan.
Waarom cd binnen $() uw shell niet wijzigt
Omdat $(...) een nieuw proces start (fork). De subshell ontvangt een kopie van uw variabelen en een kopie van uw huidige werkmap. Deze wijzigt de eigen kopie, voert een printopdracht uit en sluit af. De kopie verdwijnt zodra het proces eindigt.
cd /tmp/subst-demo
pwd
target=$(cd /etc && pwd)
echo "$target"
pwd/tmp/subst-demo
/etc
/tmp/subst-demoEr is niets mislukt. Het cd-commando is uitgevoerd en pwd binnen de subshell heeft daadwerkelijk /etc geprint. Er is echter geen manier waarop deze wijziging kan terugkeren naar de parent-shell, omdat een subshell alleen de standaarduitvoer en een exit-status aan de parent doorgeeft.
Variabeletoewijzingen werken op dezelfde manier:
count=0
msg=$(count=99; echo "inside: $count")
echo "$msg"
echo "outside: $count"inside: 99
outside: 0Dezelfde regel verklaart de variant van dit probleem die gebruikers veel vaker tegenkomen, waarbij een pipeline wordt gebruikt in plaats van een substitutie:
n=0
cat three.txt | while read -r line; do n=$((n+1)); done
echo "$n"0Elke fase van een pipeline draait in een eigen subshell, dus de while-loop heeft een kopie van n opgehoogd en is daarna afgesloten. Vervang de pipe door een redirect en de loop wordt in uw eigen shell uitgevoerd:
n=0
while read -r line; do n=$((n+1)); done < three.txt
echo "$n"3Bash kan de laatste fase van een pipeline in de huidige shell uitvoeren met shopt -s lastpipe, maar alleen wanneer job control is uitgeschakeld. Dit is nooit het geval in een interactieve shell. Gebruik de redirect.
Waar is de prompt gebleven? Interactieve commando's binnen $()
Een command substitution leidt de standaarduitvoer om naar een pipe en laat de standaardinvoer ongemoeid. Een programma dat zijn vraag naar de standaarduitvoer schrijft en vervolgens wacht op een antwoord, verliest de vraag, maar niet het wachten. De terminal lijkt bevroren.
ask() { printf 'Username: '; read -r u; printf '%s\n' "$u"; }
askUsername: deploy
deployHet woord deploy op de eerste regel is wat u heeft getypt. Voer nu dezelfde functie uit binnen een substitutie:
v=$(ask)deployAlleen uw eigen toetsaanslagen verschijnen, weergegeven door de terminaldriver in plaats van door het programma. De prompt is ergens anders heen gegaan:
echo "$v"Username: deployDe prompt bevindt zich nu in de variabele, vastgeplakt aan het antwoord, omdat $() alles heeft opgevangen wat de functie naar de standaarduitvoer schreef. Uw invoer bereikte nog steeds read, omdat de standaardinvoer nooit is aangeraakt. Dat is precies het kenmerk van het bugrapport dat stelt: "mijn script hangt en print niets".
Sommige tools schrijven prompts naar de standaardfout of rechtstreeks naar /dev/tty, zodat ze dit overleven. Vele doen dat niet. Als een functie binnen een substitutie moet blijven, stuur de prompt dan zelf naar de standaardfout:
ask() { printf 'Username: ' >&2; read -r u; printf '%s\n' "$u"; }
v=$(ask)
echo "$v"Username: deploy
deployDe standaardfout wordt niet opgevangen, dus de prompt bereikt uw terminal en alleen het antwoord komt terecht in v.
Waarom ziet ls er anders uit binnen $()?
Omdat ls de functie isatty aanroept op file descriptor 1 en het uitvoerformaat aanpast op basis van het resultaat. Bij de prompt is die descriptor uw terminal, dus verspreidt ls de namen over de regel in kolommen. Binnen een substitutie is het een pipe, dus schakelt ls over naar één naam per regel.
mkdir -p /tmp/tty-demo
cd /tmp/tty-demo
touch alpha beta delta gamma
echo "$(ls)"alpha
beta
delta
gammaDezelfde controle schakelt kleur uit in grep --color=auto en de pager in git. Dit is een functionaliteit. Het betekent dat een script stabiele, machineleesbare uitvoer krijgt zonder dat dit expliciet gevraagd hoeft te worden.
Dit beantwoordt ook een vraag die vaak gesteld wordt over pipelines. ls | sort bij de prompt en $(ls | sort) tonen dezelfde uitvoer, omdat ls in beide gevallen een pipe op de uitvoer had staan. Wat er binnen een substitutie verandert, is de laatste fase van de pipeline. sort controleert nooit op een terminal, dus de uitvoer verandert nooit. Plaats een terminal-bewust commando aan het einde van de pipeline en de uitvoer verandert wel; dit is de reden waarom een pipeline die u visueel heeft getest, zich anders kan gedragen zodra u deze verpakt in $().
Een waarschuwing die hieruit voortvloeit: parse de uitvoer van ls niet in een script, ook al lijkt dit handig. Bestandsnamen kunnen spaties en regeleinden bevatten. Gebruik een glob, of find -print0 in combinatie met read -d ''.
De afsluitende regeleinden die stilletjes verdwijnen
Command substitution verwijdert elk regeleinde aan het einde van de output. Niet alleen de laatste. Allemaal.
cd /tmp/subst-demo
printf 'hello\n\n\n' > blanks.txt
wc -c < blanks.txt
v=$(cat blanks.txt)
printf '%s' "$v" | wc -c8
5Het bestand bevat hello plus drie regeleinden, dus 8 bytes. De variabele bevat hello, dus 5 bytes. Drie bytes zijn zonder waarschuwing verdwenen.
Dit strippen is een bewuste keuze en meestal nuttig. Het is de reden dat stamp=$(date -u +%Y%m%dT%H%M%SZ) een bruikbaar bestandsnaamfragment oplevert in plaats van een naam met een regeleinde, wat de reden is dat dit patroon veilig is in bijvoorbeeld een gepland restic back-upscript:
stamp=$(date -u +%Y%m%dT%H%M%SZ)
printf 'backup-%s.tar.gz\n' "$stamp"backup-20260804T031500Z.tar.gzUw tijdstempel zal afwijken. Het gaat erom dat de naam één regel beslaat.
Het nadeel is dat u $() niet kunt gebruiken om de exacte bytes van een bestand te verplaatsen. Als u de afsluitende regeleinden nodig heeft, voeg dan een sentinel-karakter toe binnen de substitutie en strip dit achteraf:
v=$(cat blanks.txt; printf x)
v=${v%x}
printf '%s' "$v" | wc -c8De x staat na de regeleinden, dus er zijn geen afsluitende regeleinden meer over om te strippen. ${v%x} verwijdert vervolgens het sentinel-karakter en laat de oorspronkelijke bytes intact.
Twee gerelateerde details. Voor het lezen van een volledig bestand doet v=$(<blanks.txt) hetzelfde werk zonder cat uit te voeren, omdat bash het bestand zelf opent. Het stript afsluitende regeleinden op identieke wijze. En here-strings werken in de tegenovergestelde richting; ze voegen een regeleinde toe dat u niet zelf heeft geschreven:
wc -c <<< 'abc'4Plaats het resultaat tussen aanhalingstekens, anders zal bash het splitsen en globben
Een niet-gequoteerde substitutie ondergaat woord-splitsing en daarna padnaam-expansie. Een gequoteerde substitutie ondergaat geen van beide.
printf 'a b\tc\nd\n' > words.txt
echo $(cat words.txt)
echo "$(cat words.txt)"a b c d
a b c
dZonder aanhalingstekens splitste bash de uitvoer op de tekens in IFS, wat standaard spatie, tab en regeleinde zijn, en echo voegde de vier delen vervolgens samen met enkele spaties. Met aanhalingstekens kwam de tekst aan als één woord, waarbij de tab en het interne regeleinde behouden bleven.
Globbing is het gevaarlijkere deel:
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
*De toewijzing zelf was veilig, omdat toewijzingen geen woord-splitsing of globbing uitvoeren. De schade ontstond bij echo $p, waar de * werd geëxpandeerd tegen de huidige map. Een script dat een patroon uit een configuratiebestand leest en de aanhalingstekens vergeet, zal vrolijk actie ondernemen op elk bestand dat het kan zien. Plaats elke expansie tussen aanhalingstekens en deze hele klasse van bugs verdwijnt. Laat de aanhalingstekens alleen weg wanneer u daadwerkelijk de splitsing wilt, wat zelden voorkomt.
Waarom geeft local x=$(cmd) altijd 0 terug?
Omdat local zelf een commando is, en $? de status rapporteert van local, niet de status van de substitutie die het bevatte.
check_bad() { local out=$(false); echo "status: $?"; }
check_badstatus: 0false eindigde met 1, local slaagde in het declareren van de variabele, en de 1 werd genegeerd. declare, export, typeset en readonly gedragen zich allemaal op dezelfde manier. set -e zal het ook niet opvangen, omdat er vanuit het perspectief van de shell niets is mislukt.
Scheid de declaratie van de toewijzing:
check_good() { local out; out=$(false); echo "status: $?"; }
check_goodstatus: 1Een eenvoudige toewijzing op het hoogste niveau rapporteert al de status van de laatste commando-substitutie:
out=$(exit 3)
echo $?3Dit is vooral van belang bij een health check die draait onder een systemd service en timer, waarbij een gemaskeerde exit-code ertoe leidt dat de unit bij elke uitvoering succes rapporteert, terwijl het werk dat het had moeten verifiëren nooit heeft plaatsgevonden.
De in-shell alternatieven die u daadwerkelijk wilt gebruiken
De meeste mensen grijpen naar $(...) omdat ze gegevens in een variabele willen opslaan. Vaak is wat ze werkelijk nodig hebben input, geen capture. Deze vier vormen behouden de status in uw huidige shell.
Een redirect op de loop
cd /tmp/subst-demo
while read -r line; do printf 'got: %s\n' "$line"; done < three.txtgot: alpha
got: beta
got: gammaEr wordt geen proces aangemaakt voor de input, waardoor alles wat de body van de loop instelt, behouden blijft na de loop.
Process substitution
while read -r line; do printf 'got: %s\n' "$line"; done < <(sort -r three.txt)got: gamma
got: beta
got: alpha<(command) geeft u een pad, iets als /dev/fd/63, dat de output van het commando leest. Het commando draait nog steeds in zijn eigen proces. De while loop doet dat niet, en dat is precies het doel. De spatie in < <( is vereist: <<( wordt gelezen als het begin van een here-document en zal niet parsen. Process substitution is een bash-functie, dus een script met #!/bin/sh op Ubuntu of Debian draait onder dash en zal daarop falen. Gebruik #!/bin/bash.
Here-strings
read -r first rest <<< 'alpha beta gamma'
echo "$first"
echo "$rest"alpha
beta gamma<<< voert één string in naar de standaard input van een commando. read draait in uw shell, dus beide variabelen worden ingesteld op een plek waar u ze kunt gebruiken. read -r a b <<< "$(some-command)" is de normale manier om twee velden uit één regel output te halen.
mapfile voor volledige bestanden
mapfile -t lines < three.txt
echo "${#lines[@]}"
echo "${lines[1]}"3
betamapfile, ook wel geschreven als readarray, leest een bestand in een array in de huidige shell. -t verwijdert de afsluitende newline van elk element. Het vereist bash 4 of nieuwer, en Ubuntu 24.04 levert bash 5.2, dus het is beschikbaar op elke huidige server-image.
Een checklist voordat u het script commit
- Schrijf
$(command)en quote dit als"$(command)", tenzij u specifiek splitsing wenst. - Ga ervan uit dat afsluitende nieuwe regels zijn verwijderd. Voeg een sentinel-karakter toe als u deze terug wilt hebben.
- Houd interactieve commando's buiten substituties, of stuur hun prompts naar standard error.
- Schrijf
local outop een eigen regel wanneer de exit status vanout=$(command)van belang is. - Gebruik voor het instellen van variabelen op basis van input een redirect of process substitution in plaats van een pipe.
Deze patronen duiken op in de eerste kleine scripts die mensen schrijven wanneer zij een nieuwe VPS inrichten, en de fouten blijven onopgemerkt. Een back-upscript dat een prompt opving in een bestandsnaam, of een health check die een exit code maskeerde, blijft succes rapporteren. De kosten nemen toe zodra u hetzelfde script op meerdere servers uitvoert, omdat de output die u nooit las, nu output is die u op twintig machines niet leest.
FAQ
Waarom wijzigt cd binnen $() mijn huidige directory niet?
$(...) voert het commando uit in een subshell, een afzonderlijk proces dat een kopie van uw werkdirectory en variabelen beheert. De cd wijzigt die kopie, waarna het proces wordt beëindigd en de kopie verloren gaat. Een subshell kan alleen de standaarduitvoer en een exit-status retourneren; er is dus geen mechanisme waarmee de directorywijziging de parent-shell kan bereiken. Als u de directory zelf nodig heeft, vang deze dan op met target=$(cd /etc && pwd) en gebruik "$target". Als u wilt dat uw shell van directory wisselt, voer cd dan direct uit, zonder substitutie eromheen.
Wat is het verschil tussen $() en backticks in bash?
Ze produceren hetzelfde resultaat voor eenvoudige commando's, maar verschillen op twee belangrijke punten. $() ondersteunt directe nesting, omdat de parser de haakjes herkent, terwijl backticks voor elk niveau van nesting een escaped backtick vereisen. Backticks verwijderen bovendien één laag backslash-escaping voordat het interne commando wordt geparseerd, dus ` echo 'a\\b' prints a\b while $(echo 'a\\b') prints a\\b. $() is in POSIX and works in dash and busybox sh`, waardoor er geen argument voor portabiliteit is om backticks te gebruiken.
Waarom blijft mijn script hangen zonder prompt wanneer een commando een vraag stelt?
Command-substitutie leidt de standaarduitvoer om naar een pipe, maar laat de standaardinvoer verbonden met uw terminal. Een programma dat zijn prompt naar de standaarduitvoer schrijft, ziet die prompt opgevangen in de variabele, terwijl de read erachter nog steeds op uw invoer wacht. De terminal toont alleen de tekens die u typt, weergegeven door de terminal-driver. Verplaats de vraag buiten de substitutie, of laat de prompt naar de standaardfout schrijven met printf 'Username: ' >&2 zodat deze niet wordt opgevangen.
Waarom verdwenen de lege regels aan het einde van mijn variabele?
Command-substitutie verwijdert elke afsluitende newline, niet alleen de laatste. printf 'hello\n\n\n' > f; v=$(cat f) laat v achter met vijf bytes, terwijl het bestand er acht bevat. Om ze te behouden, voegt u een sentinel toe binnen de substitutie en verwijdert u deze achteraf met v=$(cat f; printf x) gevolgd door v=${v%x}. De sentinel staat na de newlines, waardoor er aan het einde niets meer is voor bash om te verwijderen.
Waarom rapporteert local out=$(cmd) altijd succes?
local is op zichzelf een commando, en $? na die regel rapporteert of local erin slaagde de variabele te declareren. De exit-status van de substitutie wordt verbruikt en genegeerd, wat ook betekent dat set -e het script niet zal stoppen. declare, export, typeset en readonly gedragen zich op dezelfde manier. Schrijf local out op één regel en out=$(cmd) op de volgende, waarna $? de werkelijke status rapporteert.