サブシェルは「別の端末」ではなく、現在のシェル実行環境を複製してコマンド群を評価する仕組みです。Bash では丸括弧 ( list )、コマンド置換 $(command)、多くのパイプライン要素でサブシェル環境が使われます。波括弧 { list; } は見た目が似ていますが、現在のシェルで実行します。どちらを選ぶかは、ディレクトリ、変数、trap、シェルオプションの変更を後へ残すか隔離するかで決めます。 確認ポイント:subshellは変更を隔離しますが、brace groupは現在shellで実行されるため状態が残ります。
丸括弧とbrace groupのscope差を再現する
丸括弧は構文演算子なので内側の空白は必須ではありませんが、読みやすく ( command ) と書きます。波括弧は予約語なので前後の空白が必要で、閉じる } の前にセミコロンまたは改行が必要です。{ cd /tmp; } は正しく、{ cd /tmp } と {cd /tmp;} は構文エラーです。
start=$PWD
color=blue
( cd /; color=red; printf 'sub pwd=%s color=%s level=%s\n' "$PWD" "$color" "$BASH_SUBSHELL" )
printf 'after-sub pwd=%s color=%s level=%s\n' "$PWD" "$color" "$BASH_SUBSHELL"
{ color=green; printf 'group pwd=%s color=%s level=%s\n' "$PWD" "$color" "$BASH_SUBSHELL"; }
printf 'after-group pwd=%s color=%s unchanged-pwd=%s\n' "$PWD" "$color" "$([[ $PWD == $start ]] && echo yes || echo no)"
開始場所が /home/user/work なら出力は sub pwd=/ color=red level=1、after-sub pwd=/home/user/work color=blue level=0、group pwd=/home/user/work color=green level=0、after-group pwd=/home/user/work color=green unchanged-pwd=yes です。丸括弧内の cd と代入は終了と同時に捨てられ、波括弧内の代入は親へ残ります。ディレクトリ移動を一時化する典型形は ( cd -- "$dir" && command ) です。cd に失敗したまま別の場所で command を走らせないよう && で接続します。
終了状態は最後のコマンドから返る
グループ全体の終了状態は、原則として最後に実行したコマンドの状態です。丸括弧内の exit はそのサブシェルだけを終わらせますが、波括弧内の exit は現在のシェルを終了させます。対話中に後者を直接試さず、別の Bash の中で確認します。
( printf 'before\n'; exit 7; printf 'never\n' ); printf 'paren rc=%d still-here\n' "$?"
{ printf 'brace-ok\n'; false; }; printf 'brace rc=%d\n' "$?"
bash -c '{ printf "brace-exit\n"; exit 9; }; printf "never\n"'; printf 'child rc=%d parent-alive\n' "$?"
正確な出力は before、paren rc=7 still-here、brace-ok、brace rc=1、brace-exit、child rc=9 parent-alive です。never はどちらも出ません。複数コマンドを一つのリダイレクト対象にするだけなら { command1; command2; } >out.log が余分な隔離を生まず、途中の状態を残したくないなら ( command1; command2 ) >out.log が適します。
パイプラインで変数が戻らない理由
Bash はパイプラインの各コマンドを通常は別のサブシェルで実行します。そのため、while read の中で更新した shell 変数が、パイプの後で元に戻る例が頻出します。外部コマンドは当然別プロセスですが、Bash 組み込みやループであってもパイプの要素なら隔離され得ます。
count=0
printf 'alpha\nbeta\n' | while IFS= read -r line; do
count=$((count + 1))
printf 'inside=%d:%s level=%d\n' "$count" "$line" "$BASH_SUBSHELL"
done
printf 'after=%d level=%d\n' "$count" "$BASH_SUBSHELL"
標準の Bash 設定では inside=1:alpha level=1、inside=2:beta level=1、after=0 level=0 になります。解決策はパイプを避け、プロセス置換でループを現在のシェル側に置くことです。
count=0
while IFS= read -r line; do
count=$((count + 1))
done < <(printf 'alpha\nbeta\n')
printf 'after=%d\n' "$count"
Bash での出力は after=2 です。プロセス置換 < <(...) は Bash 機能で POSIX sh にはありません。Bash の shopt -s lastpipe はジョブ制御が無効なとき最終パイプ要素を現在のシェルで動かせますが、対話設定や他シェルで結果が変わるため、変数保持をそれに暗黙依存させません。POSIX はパイプラインの各コマンドをサブシェル環境で実行しつつ、一部または全部を現在環境で実行する拡張も許すため、移植用スクリプトではパイプ内の代入を後段で使わない設計にします。
パイプ全体の終了状態は Bash の既定では最後のコマンドの状態です。false | true は 0、set -o pipefail; false | true は 1 になります。失敗検知が必要なら、実行前に pipefail の有無を明示し、直後の $? または Bash 配列 PIPESTATUS を別コマンドで上書きする前に保存します。
コマンド置換も隔離され、末尾改行が消える
result=$(command) は command をサブシェル環境で実行し、その標準出力から末尾の連続する改行を削除して result へ代入します。内部の cd や通常の変数代入は外側へ残りません。出力中の改行は末尾以外なら保持されますが、shell 変数は NUL バイトを保存できないため、任意のバイナリデータをコマンド置換へ通してはいけません。
place=outer
value=$(place=inner; cd /; printf 'A\nB\n\n')
printf 'value=<%s> place=%s pwd=%s level=%d\n' "$value" "$place" "$PWD" "$BASH_SUBSHELL"
printf 'lines=%d chars=%d\n' "$(printf '%s\n' "$value" | wc -l)" "${#value}"
value の内部改行のため最初は二行に分かれ、表示は value=<A、次行が B> place=outer pwd=元の場所 level=0 です。末尾の空行は除去されています。ASCII の A、改行、B の3文字なので次は lines=2 chars=3 です。コマンド置換内で BASH_SUBSHELL を表示すれば通常 1 ですが、代入完了後の外側は 0 です。
BASH_SUBSHELL、$$、BASHPID の見分け方
BASH_SUBSHELL は Bash がサブシェルまたはサブシェル環境の実行を始めるたび 1 ずつ増える Bash 専用変数です。$$ は丸括弧内でも親 Bash の PID を示す場合があり、実際の現在 Bash プロセスを区別するには BASHPID を使います。新しく bash -c を起動した場合は新しい Bash の基準へリセットされるので、「OS の親子深度」と同義ではありません。
printf 'outer level=%d dollar=%s bashpid=%s\n' "$BASH_SUBSHELL" "$$" "$BASHPID"
( printf 'one level=%d dollar=%s bashpid=%s\n' "$BASH_SUBSHELL" "$$" "$BASHPID"; ( printf 'two level=%d\n' "$BASH_SUBSHELL" ) )
level は順に 0、1、2 です。dollar は通常3行で同じ値、one の bashpid は outer と異なります。PID の数値そのものは毎回変わります。BASH_SUBSHELL と BASHPID は Bash 固有なので、#!/bin/sh の移植用コードでは使わず、必要ならサブシェルから明示的な標準出力と終了状態を返します。
選択と検証の要点
- 一時的な
cd、trap、変数変更を隔離するなら( list )。 - 現在シェルへ代入を残し、コマンド群へ一括リダイレクトするなら
{ list; }。 - パイプ後に必要な変数をパイプ内で更新しない。Bash 限定ならプロセス置換を使う。
- コマンド置換では末尾改行が消える前提で、空行を意味のあるデータとして返さない。
- グループ直後に
rc=$?を保存し、期待する終了状態と標準出力を双方確認する。

コメント