Bash 命令替換:為什麼 $() 優於反引號
了解 Bash $() 為何在 subshell 執行,導致 cd 與變數變更消失;比較反引號的巢狀問題,並掌握保留 shell 狀態的 process substitution 技巧。
Bash 命令替換的作用
Bash 命令替換會將 $(command) 替換為該命令輸出至標準輸出的文字。使用反引號的舊寫法也具有相同作用。所有令人意外的行為,都源自兩個事實:命令會在名為 subshell 的獨立程序中執行,而且輸出結尾的每個換行字元都會被移除。
mkdir -p /tmp/subst-demo
cd /tmp/subst-demo
printf 'alpha\nbeta\ngamma\n' > three.txt
count=$(wc -l < three.txt)
echo "$count"3這就是這項功能的全部內容。wc -l 輸出 3,後面接著一個換行字元;該換行字元會被移除,而 count 會儲存你要的兩個字元。請注意,wc -l < three.txt 只會輸出純數字,因為 GNU wc 從標準輸入讀取時沒有可輸出的檔名。改用 wc -l three.txt,即可擷取 3 three.txt;這是不同的字串,也是之後算術運算失敗的常見原因。
本指南接下來說明的是一般人意料之外的行為,因為 subshell 是獨立程序,而獨立程序無法變更你目前輸入指令的 shell。
使用 $(),停止使用反引號
這兩種寫法都有效。$() 已納入 POSIX,因此 dash、ash 與 busybox sh 都支援這種寫法。現在沒有任何可攜性上的理由必須使用反引號,而且有兩個具體理由不應使用反引號。
反引號無法巢狀
echo "$(echo "$(echo hi)")"
echo "`echo `echo hi``"hi
echo hi第二行輸出了字面上的 echo hi。shell 會向後尋找下一個未逸出的反引號,因此你輸入的第二個反引號關閉了第一個反引號。實際執行的命令是沒有引數的 echo,它輸出一個空行,接著換行字元被移除,結果變成空字串。echo hi 則被留下來,當成一般文字處理;最後一組反引號執行了空命令。
若要巢狀使用反引號,必須逸出每一個內層反引號:
echo "`echo \`echo hi\``"hi每增加一層,逸出字元都會再次加倍。$() 不需要這些處理,因為剖析器會比對括號,而不是掃描分隔字元。
反引號會在命令執行前變更反斜線
echo "$(echo 'a\\b')"
echo "`echo 'a\\b'`"a\\b
a\b相同的內層命令產生了不同的輸出。在反引號內,shell 會先移除一層反斜線逸出,再剖析內層文字,因此單引號沒有發揮保護作用。在 $() 內,括號之間的文字會以一般 script 方式剖析,因此單引號的行為符合預期。這在 sed 與 awk 單行命令中尤其容易出問題,因為遺失一個反斜線,就可能讓原本可用的模式悄悄變成另一種模式。
$() 內的引號環境也會重新開始,因此可以巢狀使用雙引號,不必逸出:
path=/etc/nginx/nginx.conf
echo "$(dirname "$path")"/etc/nginx反引號的等效寫法需要在 $path 周圍加入 \"。每一次逸出都增加一個出錯的機會。
為什麼在 $() 內執行 cd 不會改變目前的 shell
因為 $(...) 會 fork 出新的程序。子 shell 會取得變數的副本,以及目前工作目錄的副本。它會修改自己的副本、輸出內容,然後結束。這份副本也會隨之消失。
cd /tmp/subst-demo
pwd
target=$(cd /etc && pwd)
echo "$target"
pwd/tmp/subst-demo
/etc
/tmp/subst-demo沒有任何操作失敗。cd 正常執行,子 shell 內的 pwd 也確實輸出了 /etc。只是這項變更沒有路徑可以傳回父 shell,因為子 shell 只會將標準輸出與結束狀態傳給父 shell。
變數指派也遵循相同規則:
count=0
msg=$(count=99; echo "inside: $count")
echo "$msg"
echo "outside: $count"inside: 99
outside: 0相同規則也能解釋另一種更常見的問題。這個版本涉及 pipeline,而不是 command substitution:
n=0
cat three.txt | while read -r line; do n=$((n+1)); done
echo "$n"0pipeline 的每個階段都會在自己的子 shell 中執行,因此 while 迴圈只遞增了 n 的副本,接著就結束了。將 pipe 改為 redirect 後,迴圈會在目前的 shell 中執行:
n=0
while read -r line; do n=$((n+1)); done < three.txt
echo "$n"3Bash 可以透過 shopt -s lastpipe 讓 pipeline 的最後一個階段在目前的 shell 中執行,但只有在 job control 關閉時才有效;互動式 shell 永遠不符合此條件。請改用 redirect。
提示字串去哪裡了?$() 內的互動式命令
命令替換會將標準輸出重新導向至管線,但不會處理標準輸入。某個程式將問題列印至標準輸出後等待回答時,消失的是問題,而不是等待動作。終端機看起來就像當機。
ask() { printf 'Username: '; read -r u; printf '%s\n' "$u"; }
askUsername: deploy
deploy第一行的 deploy 是你輸入的內容。現在在替換中執行相同的函式:
v=$(ask)deploy畫面上只會出現你自行輸入的按鍵內容;這些內容是由終端機驅動程式回顯,而不是由程式列印。提示字串被送到其他地方:
echo "$v"Username: deploy現在提示字串已經位於變數中,並與答案接在一起,因為 $() 擷取了函式寫入標準輸出的所有內容。你的輸入仍然傳送給 read,因為標準輸入完全未受影響。這正是錯誤回報中「我的指令碼卡住了,而且什麼都沒有列印」的確切特徵。
有些工具會將提示字串寫入標準錯誤,或直接寫入 /dev/tty,因此能避免這個問題。但許多工具不會這樣做。如果函式必須留在替換中,請自行將提示字串傳送至標準錯誤:
ask() { printf 'Username: ' >&2; read -r u; printf '%s\n' "$u"; }
v=$(ask)
echo "$v"Username: deploy
deploy標準錯誤不會被擷取,因此提示字串會傳送到終端機,而只有答案會進入 v。
為什麼在 $() 內執行 ls 時,輸出看起來不同?
因為 ls 會在檔案描述元 1 上呼叫 isatty,並根據檢查結果變更輸出格式。在提示字元中,該描述元連接到終端機,因此 ls 會以欄位排列名稱。在命令替換中,該描述元是管線,因此 ls 會切換為每行輸出一個名稱。
mkdir -p /tmp/tty-demo
cd /tmp/tty-demo
touch alpha beta delta gamma
echo "$(ls)"alpha
beta
delta
gamma相同的檢查也會在 grep --color=auto 中停用色彩,並在 git 中停用分頁器。這是設計上的功能。指令碼不必另外要求,就能取得穩定且適合機器讀取的輸出。
這也能解釋管線相關的常見問題。在提示字元中執行 ls | sort 與 $(ls | sort) 時,兩者會輸出相同內容,因為 ls 在這兩種情況下,輸出端都是管線。命令替換內會改變的是管線的最後一個階段。sort 從不檢查輸出是否連接到終端機,因此其輸出不會改變。若將會偵測終端機的指令放在管線末端,輸出就會改變。因此,原本在終端機上目視測試正常的管線,包在 $() 中後可能立即出現不同的行為。
由此可得一項注意事項:即使 ls 的輸出看起來便於使用,也不要在指令碼中解析它。檔名可能包含空格與換行字元。請使用 glob,或搭配 read -d '' 使用 find -print0。
會悄悄消失的結尾換行
命令替換會移除輸出結尾的所有換行。不是最後一個,而是全部換行。
cd /tmp/subst-demo
printf 'hello\n\n\n' > blanks.txt
wc -c < blanks.txt
v=$(cat blanks.txt)
printf '%s' "$v" | wc -c8
5檔案包含 hello 加上 3 個換行,因此共有 8 bytes。變數包含 hello,因此共有 5 bytes。3 bytes 毫無警告地消失了。
移除換行是刻意設計的行為,通常也很實用。這能讓 stamp=$(date -u +%Y%m%dT%H%M%SZ) 產生可直接使用的檔名片段,而不是含有換行的名稱。因此,這種模式適合用在例如 排程的 restic 備份腳本 中:
stamp=$(date -u +%Y%m%dT%H%M%SZ)
printf 'backup-%s.tar.gz\n' "$stamp"backup-20260804T031500Z.tar.gz你的時間戳記會不同。重點是檔名只有一行。
代價是,不能使用 $() 搬移檔案的確切 bytes。若需要保留結尾換行,請在替換內容中加入 sentinel 字元,之後再將它移除:
v=$(cat blanks.txt; printf x)
v=${v%x}
printf '%s' "$v" | wc -c8x 位於換行之後,因此要移除的內容中不再有結尾換行。接著,${v%x} 會移除 sentinel,留下原始 bytes。
另外有 2 個相關細節。讀取整個檔案時,v=$(<blanks.txt) 不需執行 cat 就能完成相同工作,因為 bash 會自行開啟檔案。它同樣會移除結尾換行。相反地,here-string 會自行加入你沒有寫入的換行:
wc -c <<< 'abc'4為結果加上引號,否則 bash 會進行分詞與萬用字元展開
未加引號的替換結果會依序經過分詞與路徑名稱展開。加上引號的替換結果則不會經過這兩個步驟。
printf 'a b\tc\nd\n' > words.txt
echo $(cat words.txt)
echo "$(cat words.txt)"a b c d
a b c
d未加引號時,bash 會依 IFS 中的字元分割輸出內容。預設值為空格、定位字元與換行字元,接著 echo 會以單一空格重新連接這四個部分。加上引號時,整段文字會以一個字詞傳遞,定位字元與內部換行字元也會保留。
萬用字元展開更危險:
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
*指派本身是安全的,因為指派不會進行分詞或萬用字元展開。問題發生在 echo $p,其中的 * 會依目前目錄進行展開。若指令碼從設定檔讀取模式,卻忘記加上引號,就會直接處理它能看到的所有檔案。為每個替換結果加上引號,即可消除這整類錯誤。只有在確實需要分詞時才省略引號,而這種情況並不常見。
為什麼 local x=$(cmd) 總是回傳 0?
因為 local 本身就是一個命令,而 $? 回報的是 local 的狀態,不是其中包含的命令替換狀態。
check_bad() { local out=$(false); echo "status: $?"; }
check_badstatus: 0false 結束時回傳 1,local 卻成功宣告了變數,因此 1 被捨棄。declare、export、typeset 和 readonly 的行為都相同。set -e 也無法捕捉這個錯誤,因為從 shell 的角度來看,沒有任何命令失敗。
將宣告與指派分開:
check_good() { local out; out=$(false); echo "status: $?"; }
check_goodstatus: 1在最上層執行的單純指派,會回報最後一個命令替換的狀態:
out=$(exit 3)
echo $?3這在執行於 systemd 服務與計時器 下的健康檢查中特別重要。被遮蔽的結束碼會讓該 unit 每次執行都回報成功,但原本應驗證的工作實際上從未完成。
實際需要的 shell 內替代方式
多數人使用 $(...),是因為想把資料存入變數。但很多時候,真正需要的是輸入,而不是擷取輸出。以下 4 種形式會在目前的 shell 中保留狀態。
在迴圈上使用重新導向
cd /tmp/subst-demo
while read -r line; do printf 'got: %s\n' "$line"; done < three.txtgot: alpha
got: beta
got: gamma輸入不會建立額外的 process,因此迴圈主體設定的內容會在迴圈結束後保留。
Process substitution
while read -r line; do printf 'got: %s\n' "$line"; done < <(sort -r three.txt)got: gamma
got: beta
got: alpha<(command) 會提供一個路徑,例如 /dev/fd/63,讀取該 command 的輸出。該 command 仍會在自己的 process 中執行。但 while 迴圈不會,這正是此方法的重點。< <( 中的空格是必要的:<<( 會被解讀為 here-document 的開頭,因而無法通過語法解析。Process substitution 是 bash 功能,因此在 Ubuntu 或 Debian 上,含有 #!/bin/sh 的 script 會使用 dash 執行,並在該環境中失敗。請使用 #!/bin/bash。
Here-strings
read -r first rest <<< 'alpha beta gamma'
echo "$first"
echo "$rest"alpha
beta gamma<<< 會將一個字串送入 command 的 standard input。read 會在你的 shell 中執行,因此兩個變數都會在可供使用的環境中設定完成。read -r a b <<< "$(some-command)" 是從一行輸出中取出 2 個欄位的標準方式。
使用 mapfile 處理完整檔案
mapfile -t lines < three.txt
echo "${#lines[@]}"
echo "${lines[1]}"3
betamapfile,也寫作 readarray,會在目前的 shell 中將檔案讀入 array。-t 會移除每個元素結尾的 newline。它需要 bash 4 或更新版本;Ubuntu 24.04 隨附 bash 5.2,因此目前的任何 server image 都能使用。
提交腳本前的檢查清單
- 撰寫
$(command);除非確實需要分割,否則將其引用為"$(command)"。 - 假設結尾的換行字元已消失。如需保留,請加入哨兵字元。
- 不要在替換中使用互動式命令,或將其提示訊息傳送至標準錯誤。
- 當
out=$(command)的結束狀態很重要時,請將local out單獨寫在一行。 - 若要根據輸入設定變數,請使用重新導向或程序替換,不要使用管線。
這些寫法常見於人們開始設定新的 VPS時撰寫的第一批小型腳本,而錯誤通常不會顯示出來。備份腳本若將提示訊息擷取到檔名中,或健康檢查若遮蔽了結束碼,就會持續回報成功。當你開始在多台伺服器上執行相同腳本後,問題的代價會增加,因為原本未讀取的輸出,現在變成 20 台機器上都未讀取的輸出。
FAQ
為什麼在 $() 內執行 cd 不會變更目前的目錄?
$(...) 會在 subshell 中執行命令。subshell 是持有工作目錄與變數副本的獨立程序。cd 只會變更該副本,程序結束後副本也會被捨棄。subshell 只能傳回標準輸出與結束狀態,因此沒有機制將目錄變更傳回父 shell。若只需要取得目錄本身,請使用 target=$(cd /etc && pwd) 擷取,再搭配 "$target"。若要讓 shell 移動目錄,請直接執行 cd,不要在外層包覆 substitution。
bash 中的 $() 與反引號有何不同?
對簡單命令而言,兩者會產生相同結果,但有兩項重要差異。$() 可以直接巢狀使用,因為 parser 會比對括號;反引號則必須在每一層巢狀中使用跳脫的反引號。反引號也會在解析內部命令前,先移除一層反斜線跳脫,因此 ` echo 'a\\b' prints a\b while $(echo 'a\\b') prints a\\b. $() is in POSIX and works in dash and busybox sh`,所以沒有理由為了可攜性而使用反引號。
為什麼命令提出問題時,我的 script 會在沒有提示字元的情況下停住?
Command substitution 會將標準輸出重新導向至 pipe,但標準輸入仍連接至終端機。將提示字元輸出至標準輸出的程式,會讓該提示字元被擷取至變數中;而背後的 read 仍會等待輸入。終端機只會顯示由 terminal driver 回顯的輸入字元。請將問題移到 substitution 外部,或使用 printf 'Username: ' >&2 將提示字元寫入標準錯誤,避免被擷取。
為什麼變數結尾的空白行會消失?
Command substitution 會移除所有結尾的換行,不只是最後一個換行。printf 'hello\n\n\n' > f; v=$(cat f) 會讓 v 只包含五個位元組,而檔案包含八個位元組。若要保留這些換行,請在 substitution 內附加 sentinel,之後使用 v=$(cat f; printf x) 搭配 v=${v%x} 將其移除。sentinel 位於換行之後,因此 bash 沒有結尾內容可移除。
為什麼 local out=$(cmd) 總是回報成功?
local 本身就是一個命令,而該行後的 $? 回報的是 local 宣告變數是否成功。substitution 的結束狀態已被取用並捨棄,因此 set -e 也不會停止 script。declare、export、typeset 與 readonly 的行為相同。請在一行撰寫 local out,下一行再撰寫 out=$(cmd),此時 $? 才會回報實際狀態。