Bashのコマンド置換は$()とバッククォートのどちら?
Bashの$()はなぜサブシェルで実行され、cdや変数が元に戻るのでしょうか。バッククォートの入れ子が壊れる理由と、状態を保つプロセス置換の使い方を解説します。
Bash のコマンド置換の動作
Bash のコマンド置換は、$(command) をコマンドが標準出力に出力したテキストに置き換えます。バッククォートを使う古い記法でも同じ処理が行われます。予想外の動作は、次の 2 つの事実から生じます。コマンドはサブシェルと呼ばれる別プロセスで実行されること、そして出力末尾の改行がすべて削除されることです。
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 には必要な 2 文字が格納されます。GNU wc は標準入力を読み取る際に出力するファイル名がないため、wc -l < three.txt は数値だけを出力する点に注意してください。代わりに wc -l three.txt と記述すると、3 three.txt を取得します。これは異なる文字列であり、後続の算術処理が失敗する一般的な原因になります。
以降では、多くの人が予想しない動作を説明します。サブシェルは別プロセスであり、別プロセスから入力中のシェルを変更することはできないためです。
$() を使用し、バッククォートの使用をやめる
どちらの形式も有効です。$() は POSIX に含まれているため、dash、ash、busybox sh はすべて対応しています。バッククォートを使用する移植性上の理由はもうありません。使用しない具体的な理由も2つあります。
バッククォートは入れ子にできません
echo "$(echo "$(echo hi)")"
echo "`echo `echo hi``"hi
echo hi2 行目では、echo hi という文字列がそのまま出力されました。シェルは、エスケープされていない次のバッククォートまで読み進めます。そのため、入力した2つ目のバッククォートが最初のコマンド置換を閉じました。実際に実行されたコマンドは、引数のない echo です。このコマンドは空行を出力し、その改行が削除されたため、結果は空文字列になりました。echo hi という文字列は通常のテキストとして残り、最後のバッククォートの組は空のコマンドを実行しました。
バッククォートを入れ子にするには、内側のバッククォートをすべてエスケープする必要があります。
echo "`echo \`echo hi\``"hi入れ子のレベルが1つ増えるたびに、エスケープもさらに倍増します。$() ではこの必要はありません。区切り文字を検索するのではなく、括弧の対応を解析するためです。
コマンドの実行前に、バッククォートはバックスラッシュを変更します
echo "$(echo 'a\\b')"
echo "`echo 'a\\b'`"a\\b
a\b同じ内側のコマンドでも、出力が異なります。バッククォート内では、シェルが内側のテキストを解析する前に、バックスラッシュによるエスケープを1段階削除します。そのため、シングルクォートでは何も保護できません。$() 内では、括弧の間のテキストが通常のスクリプトとして解析されるため、シングルクォートは期待どおりに機能します。これは sed と awk のワンライナーで特に問題になります。バックスラッシュが1つ失われるだけで、動作していたパターンが気付かないうちに別のパターンへ変わるためです。
$() の内部では引用も新しく始まります。そのため、ダブルクォートをエスケープせずに入れ子にできます。
path=/etc/nginx/nginx.conf
echo "$(dirname "$path")"/etc/nginxバッククォートで同じことをするには、$path の周囲に \" が必要です。エスケープする箇所が増えるほど、ミスの可能性も増えます。
$() 内の cd がシェルを変更しない理由
$(...) は新しいプロセスを fork するためです。サブシェルは、変数のコピーと作業ディレクトリのコピーを取得します。サブシェルはそのコピーを変更し、何かを出力して終了します。コピーはサブシェルとともに破棄されます。
cd /tmp/subst-demo
pwd
target=$(cd /etc && pwd)
echo "$target"
pwd/tmp/subst-demo
/etc
/tmp/subst-demo何も失敗していません。cd は正常に動作し、サブシェル内の pwd も実際に /etc を出力しました。ただし、その変更が親プロセスへ戻る経路はありません。サブシェルが親プロセスへ渡せるのは、標準出力と終了ステータスだけです。
変数への代入も同じ動作になります。
count=0
msg=$(count=99; echo "inside: $count")
echo "$msg"
echo "outside: $count"inside: 99
outside: 0同じ規則は、置換よりもはるかに頻繁に発生する、この問題の別の形にも当てはまります。パイプラインを使う場合です。
n=0
cat three.txt | while read -r line; do n=$((n+1)); done
echo "$n"0パイプラインの各段階は、それぞれ独自のサブシェルで実行されます。そのため、while ループは n のコピーをインクリメントしてから終了しました。パイプをリダイレクトに置き換えると、ループは現在のシェルで実行されます。
n=0
while read -r line; do n=$((n+1)); done < three.txt
echo "$n"3Bash は shopt -s lastpipe を使うと、パイプラインの最後の段階を現在のシェルで実行できます。ただし、ジョブ制御が無効な場合に限られます。対話型シェルでジョブ制御が無効になることはありません。リダイレクトを使用してください。
プロンプトはどこに消えたのですか?$() 内の対話型コマンド
コマンド置換は標準出力をパイプへリダイレクトしますが、標準入力には何も変更を加えません。質問を標準出力へ表示してから回答を待つプログラムでは、待機状態は続いたまま、質問だけが表示されなくなります。端末はフリーズしたように見えます。
ask() { printf 'Username: '; read -r u; printf '%s\n' "$u"; }
askUsername: deploy
deploy1 行目の 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 は 1 行に 1 つの名前を出力します。
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、または find -print0 と read -d '' を使用してください。
コマンド置換で末尾の改行が気付かないうちに消える
コマンド置換は、出力末尾の改行をすべて削除します。最後の1つだけではありません。すべて削除されます。
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バイトです。変数には hello が格納されるため、5バイトです。3バイトが警告なしに消えています。
この削除は意図された動作であり、通常は便利です。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タイムスタンプは環境によって異なります。重要なのは、名前が1行になることです。
一方、$() を使ってファイルの正確なバイト列を移動することはできません。末尾の改行が必要な場合は、置換処理の中でセンチネル文字を追加し、後から削除します。
v=$(cat blanks.txt; printf x)
v=${v%x}
printf '%s' "$v" | wc -c8x は改行の後ろに置かれるため、削除対象の末尾の改行は残りません。続いて ${v%x} がセンチネルを削除し、元のバイト列を残します。
関連する点が2つあります。ファイル全体を読み取る場合、v=$(<blanks.txt) は cat を実行せずに同じ処理を行います。bash 自体がファイルを開くためです。末尾の改行も同じように削除されます。また、here-string は逆方向に動作し、記述していない改行を追加します。
wc -c <<< 'abc'4結果は引用してください。そうしないと、bash によって分割と glob 展開が行われます
引用符で囲んでいない置換は、単語分割の後にパス名展開を受けます。引用符で囲んだ置換は、どちらも受けません。
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 は 4 つの断片を単一の空白で結合しました。引用符で囲むと、テキストは 1 つの単語として渡され、タブと内部の改行もそのまま保持されます。
glob 展開は、より危険な部分です。
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
*代入自体は安全でした。代入では単語分割も glob 展開も行われないためです。問題が発生したのは echo $p で、* が現在のディレクトリを対象に展開されました。設定ファイルからパターンを読み取り、引用符を付け忘れたスクリプトは、参照可能なすべてのファイルに対して処理を実行してしまいます。すべての展開を引用符で囲めば、この種類のバグは解消できます。分割が本当に必要な場合に限って引用符を外してください。そのような場合はまれです。
ローカル変数の x=$(cmd) が常に 0 を返すのはなぜですか?
local 自体がコマンドであり、$? は local のステータスではなく、その中に含まれるコマンド置換のステータスを報告するためです。
check_bad() { local out=$(false); echo "status: $?"; }
check_badstatus: 0false は 1 で終了しましたが、local は変数の宣言に成功したため、1 は破棄されました。declare、export、typeset、readonly も同じように動作します。set -e でも捕捉できません。シェルから見ると、失敗した処理がないためです。
宣言と代入を分けてください。
check_good() { local out; out=$(false); echo "status: $?"; }
check_goodstatus: 1トップレベルで単純な代入を行うと、最後のコマンド置換のステータスがそのまま報告されます。
out=$(exit 3)
echo $?3これは、systemd のサービスとタイマーで実行するヘルスチェックで特に重要です。終了コードが隠れると、確認対象の処理が実行されていなくても、unit は毎回成功を報告するためです。
シェル内で実際に使いたい代替手段
多くの場合、$(...) を使うのは、データを変数に格納したいからです。しかし、本当に必要なのはキャプチャではなく入力であることがよくあります。次の4つの形式を使うと、状態を現在のシェルに保持できます。
ループへのリダイレクト
cd /tmp/subst-demo
while read -r line; do printf 'got: %s\n' "$line"; done < three.txtgot: alpha
got: beta
got: gamma入力に対してプロセスは作成されないため、ループ本体で設定した内容はループの終了後も保持されます。
プロセス置換
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 のようなパスを渡します。このパスからコマンドの出力を読み取れます。コマンド自体は独自のプロセスで実行されます。一方、while ループは独自のプロセスで実行されません。これがこの方法の目的です。< <( にはスペースが必要です。<<( と書くと here-document の開始として解釈され、構文エラーになります。プロセス置換は bash の機能です。そのため、Ubuntu または Debian で #!/bin/sh を使うスクリプトは dash で実行され、失敗します。#!/bin/bash を使用してください。
Here-string
read -r first rest <<< 'alpha beta gamma'
echo "$first"
echo "$rest"alpha
beta gamma<<< は、1つの文字列をコマンドの標準入力に渡します。read は現在のシェルで実行されるため、2つの変数はどちらも、その後利用できる場所で設定されます。read -r a b <<< "$(some-command)" は、1行の出力から2つのフィールドを取り出す標準的な方法です。
ファイル全体には mapfile
mapfile -t lines < three.txt
echo "${#lines[@]}"
echo "${lines[1]}"3
betamapfile は readarray とも表記され、現在のシェルでファイルを配列に読み込みます。-t は各要素末尾の改行を削除します。bash 4 以降が必要です。Ubuntu 24.04 には bash 5.2 が付属するため、現在のサーバーイメージでは利用できます。
スクリトをコミットする前のチェックリスト
$(command)と記述し、分割が明確に必要な場合を除き、"$(command)"として引用します。- 末尾の改行は失われるものと考えます。改行を復元する必要がある場合は、センチネル文字を追加します。
- 対話型コマンドを置換の中で実行しないでください。実行する場合は、プロンプトを標準エラーに送ります。
out=$(command)の終了ステータスが重要な場合は、local outを単独の行に記述します。- 入力から変数を設定するには、パイプではなくリダイレクトまたはプロセス置換を使用します。
このような記述は、新しい VPS をセットアップするときに、初めて作成する小さなスクリプトでよく登場します。そして、障害が表面化しないまま残ります。プロンプトをファイル名に取り込んだバックアップスクリプトや、終了コードを隠してしまったヘルスチェックは、成功したと報告し続けます。同じスクリプトを複数のサーバーで実行するようになると、コストはさらに増大します。読まなかった出力が、今度は 20 台のマシンで読まれない出力になるためです。
FAQ
$() 内の cd で現在のディレクトリが変わらないのはなぜですか?
$(...) はコマンドをサブシェルで実行します。サブシェルは、作業ディレクトリと変数のコピーを保持する別のプロセスです。cd はそのコピーを変更しますが、プロセスが終了するとコピーも破棄されます。サブシェルから返せるのは標準出力と終了ステータスだけです。そのため、ディレクトリの変更を親シェルへ伝える仕組みはありません。ディレクトリ自体が必要な場合は、target=$(cd /etc && pwd) で取得して "$target" を使用します。シェル自体を移動させたい場合は、置換で囲まずに cd を直接実行します。
bash における $() とバッククォートの違いは何ですか?
単純なコマンドでは同じ結果になりますが、重要な違いが2つあります。$() は括弧をパーサーが対応付けるため、そのまま入れ子にできます。一方、バッククォートでは、入れ子のレベルごとにエスケープしたバッククォートが必要です。バッククォートは、内部コマンドを解析する前にバックスラッシュによるエスケープを1段階取り除きます。そのため、` echo 'a\\b' prints a\b while $(echo 'a\\b') prints a\\b. $() is in POSIX and works in dash and busybox sh` となり、バッククォートを使う移植性上の理由はありません。
コマンドが質問を表示すると、プロンプトなしでスクリプトが停止するのはなぜですか?
コマンド置換は標準出力をパイプへリダイレクトしますが、標準入力は端末に接続されたままにします。プロンプトを標準出力へ表示するプログラムでは、そのプロンプトが変数に取り込まれます。一方、その背後で実行されている read は入力を待ち続けます。端末に表示されるのは、端末ドライバーによってエコーされた入力文字だけです。質問処理を置換の外側へ移すか、printf 'Username: ' >&2 でプロンプトを標準エラー出力へ書き込み、取り込まれないようにします。
変数末尾の空行が消えたのはなぜですか?
コマンド置換は、末尾の最後の1個だけでなく、末尾に連続するすべての改行を削除します。printf 'hello\n\n\n' > f; v=$(cat f) では、v が5バイトを保持する一方、ファイルのサイズは8バイトです。改行を保持するには、置換の内部にセンチネルを追加し、その後 v=$(cat f; printf x) と v=${v%x} を使ってセンチネルを削除します。センチネルは改行の後ろに置かれるため、bash が削除する末尾の改行はなくなります。
local out=$(cmd) が常に成功を報告するのはなぜですか?
local はそれ自体がコマンドです。この行の後の $? は、変数の宣言に local が成功したかどうかを報告します。置換の終了ステータスは消費されて破棄されるため、set -e もスクリプトを停止させません。declare、export、typeset、readonly も同じ動作をします。local out を1行に記述し、次の行に out=$(cmd) を記述してください。これにより、$? が実際の終了ステータスを報告します。