bashのサブシェルと変数スコープの罠|()と{}の違い・パイプ先で変数が消える問題の対処

宮崎智広 この記事の監修:宮崎智広(Linux実務・教育歴20年以上・受講者3,100名超)
HOMELinux技術 リナックスマスター.JP(Linuxマスター.JP)シェルスクリプト > bashのサブシェルと変数スコープの罠|()と{}の違い・パイプ先で変数が消える問題の対処
「whileループの中でカウンタを増やしているのに、ループを出たら0に戻っている」
「パイプで繋いだコマンドの中で変数に代入したのに、後で参照したら空だった」

シェルスクリプトを書き始めると、必ずといっていいほどこの罠にハマります。原因はbashの「サブシェル」の仕様です。パイプや()でくくったコマンドは、現在のシェルとは別のプロセス(サブシェル)として実行されるため、変数への変更が親シェルには伝わりません。

この記事では、サブシェルの正体(何が起きているのか)から、(){}の違い、パイプ後の変数消失の回避策(process substitution・lastpipe・一時ファイル)まで、実際のコードで解説します。RHEL 9 / bash 5系で動作確認済みです。

この記事のポイント

・パイプ(|)の右辺はサブシェルで動き変数変更が親に戻らない
・()はサブシェル生成、{}はカレントシェル実行で動作が全く異なる
・解決策は < <(...) のprocess substitutionが最もスマート
・bash 4.2以降はshopt -s lastpipeでパイプのまま対処できる


「このままじゃマズい」と感じていませんか?
参考書を開く気力もない、同年代に取り残される不安——
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
図解60P/登録10秒/解除も3秒 / 詳細はこちら

サブシェルとは何か|変数が消える原因の根っこ

bashでコマンドを実行するとき、現在動いているシェルプロセス(親シェル)がそのまま実行する場合と、別の子プロセス(サブシェル)を生成してそこで実行する場合があります。

重要なのは、サブシェルは親シェルのコピーとして起動するという点です。起動時点の変数の値はコピーされますが、サブシェル内での変更は親シェルには一切反映されません。Linuxのプロセスはそれぞれ独立したメモリ空間を持っており、プロセス間で変数を「書き戻す」機能は標準ではないからです。

1. サブシェルが生まれる3つのケース

bashでサブシェルが自動的に生成される主なパターンは次の3つです。

パイプ(|)の各コマンド:デフォルトではパイプの左辺も右辺も別々のサブシェルで実行されます
()でくくったコマンドグループ( cmd1; cmd2 ) 全体がサブシェルで実行されます
バックグラウンド実行(&)cmd & はサブシェルで非同期実行されます

逆にサブシェルにならないケースも知っておきましょう。{}でくくったコマンドグループ、source(または.)で読み込んだスクリプト、通常の関数呼び出しはカレントシェルで実行されます。

2. 変数の「継承」と「スコープ」の仕組み

サブシェルが親シェルから引き継ぐのは、exportした環境変数と普通のシェル変数の両方です。ただし、引き継ぐのは起動時点のスナップショットであり、その後サブシェル内で変更しても親には反映されません。

# 変数の継承と変更の非反映を確認する msg="hello" export ENV_MSG="world" # サブシェル内では両方参照できる ( echo $msg ) # → hello ( echo $ENV_MSG ) # → world # サブシェル内での変更は親に伝わらない ( msg="changed" ) echo $msg # → hello(変わっていない) ( ENV_MSG="changed" ) echo $ENV_MSG # → world(変わっていない)

環境変数(export済み)はサブシェルだけでなく、bashから呼び出した別コマンドやスクリプトにも引き継がれます。なお、cshやtcshではexportの代わりにsetenvを使います。詳しくはcsh 環境変数設定の解説も参考にしてください。

()と{}の違い|カレントシェルで実行するか否か

(){}はどちらも「複数のコマンドをまとめて実行する」ために使いますが、動作の本質が異なります。この違いを理解していないと、変数スコープで予期しないバグを生みます。

3. ()はサブシェル・{}はカレントシェル(実証コード)

#!/bin/bash x=10 # ()はサブシェルで実行 → 変数変更は親に伝わらない ( x=99; echo "()内: $x" ) echo "()外: $x" # 出力: # ()内: 99 # ()外: 10 ← 変わっていない echo "---" # {}はカレントシェルで実行 → 変数変更が維持される # ※ {の直後にスペース、最後のコマンドの後に ; が必要 { x=99; echo "{}内: $x"; } echo "{}外: $x" # 出力: # {}内: 99 # {}外: 99 ← 変わっている!

{}を使う際の構文上の注意点が2つあります。開き中括弧 { の直後にスペースが必要なこと、最後のコマンドの末尾に ; が必要なことです。この2つを忘れると構文エラーになります。

4. ()が便利なユースケース|cdを使う場合

サブシェルの「変更が親に伝わらない」特性は、うまく活用できます。cdコマンドを()の中で実行すれば、ディレクトリ移動後に元の場所に戻る処理が不要になります。

# ()の中でcdしても親シェルのディレクトリは変わらない echo "作業前: $(pwd)" (cd /var/log && tar czf /tmp/logs_backup.tar.gz .) echo "作業後: $(pwd)" # 元のディレクトリのまま # 実行結果: # 作業前: /home/user # 作業後: /home/user ← 戻る処理が不要

一方、{}で同じことをすると、cd後のディレクトリが親シェルにも反映されます。pushd/popdで元に戻すか、()を使うかを目的に応じて選んでください。

パイプ後で変数が消える問題|原因と仕組み

bashスクリプトで最も多くのエンジニアがハマるのが、パイプを使ったループでの変数問題です。特に「ファイルを読んでカウントしたい」「条件に合う行を集計したい」といった処理で発生します。

5. ハマりやすいパターン(失敗例)

#!/bin/bash # /etc/passwdの行数を数えようとしている(失敗例) count=0 cat /etc/passwd | while read line; do count=$((count + 1)) done echo "行数: $count" # 実行結果(RHEL 9 / bash 5.2): # 行数: 0

echo "行数: $count" で期待した行数ではなく 0 が出力されます。ループの中で確かに count を増やしているのに、なぜでしょうか。

6. なぜ消えるのか|$BASHPIDで確認する

パイプ | で繋いだとき、bashのデフォルト動作では右辺のコマンドがサブシェルで実行されます。つまり上の例では while read line; do ... done ブロック全体がサブシェル内で動いています。

#!/bin/bash # $BASHPID は現在のプロセスの実際のPIDを返す(bash 4.0以降) echo "親シェル BASHPID: $BASHPID" # パイプ経由の場合(whileがサブシェルになる) echo "=== パイプ経由 ===" echo "" | while read line; do echo "while内 BASHPID: $BASHPID" done # リダイレクト経由の場合(whileはカレントシェルのまま) echo "=== リダイレクト経由 ===" while read line; do echo "while内 BASHPID: $BASHPID" done < /dev/null # 実行結果例: # 親シェル BASHPID: 12345 # === パイプ経由 === # while内 BASHPID: 12346 ← 別プロセスになっている! # === リダイレクト経由 === # while内 BASHPID: 12345 ← 同じプロセス

パイプ経由では while がサブシェル(別プロセス)として動き、count への変更はサブシェル内だけで完結します。サブシェルが終了するとその変数はすべて消え、親シェルの count=0 だけが残るのです。
現場で通用する安全なLinuxサーバー構築の「型」を体系的に身につけたい方へ、20年以上の運用経験を持つ現役エンジニアが基礎から教えます。
シェルスクリプト講座を見る >>

3つの解決策|変数をパイプ後も保持する

7. process substitution(< <(...))を使う【推奨】

最もスマートで可搬性が高い解決策が、プロセス置換(process substitution)を使う方法です。< <(コマンド) という構文で、コマンドの出力を「ファイルへの入力リダイレクト」として while に渡します。この形では while はリダイレクト入力を受け取るだけなので、サブシェルになりません。

#!/bin/bash # process substitution を使った正解版 count=0 while read line; do count=$((count + 1)) done < <(cat /etc/passwd) echo "行数: $count" # 実行結果(RHEL 9 / bash 5.2): # 行数: 33

< <(cat /etc/passwd) の読み方は「cat /etc/passwd の出力をプロセス置換でファイル相当として while の標準入力に渡す」です。<<( の間にスペースが必要な点に注意してください(<< と連続で書くとヒアドキュメント扱いになってしまいます)。

なお、process substitution は #!/bin/bash が必須です。#!/bin/sh でdashなどが起動する環境では使えません。

8. shopt -s lastpipeを使う(bash 4.2以降)

bash 4.2以降では、shopt -s lastpipe を設定するとパイプの最後のコマンドをサブシェルではなくカレントシェルで実行するようになります。パイプの書き方を変えたくない場合に有効です。

#!/bin/bash # シェルスクリプト実行時(ジョブコントロールが無効の環境)でのみ有効 shopt -s lastpipe count=0 cat /etc/passwd | while read line; do count=$((count + 1)) done echo "行数: $count" # 実行結果(RHEL 9 / bash 5.2): # 行数: 33

注意点lastpipe はジョブコントロールが無効のとき(シェルスクリプト実行時)にのみ有効です。ターミナルでの対話操作(インタラクティブシェル)ではジョブコントロールが有効になっているため、この設定は効きません。シェルスクリプト専用の対処法として覚えておいてください。

9. 一時ファイルを使う古典的方法

bash 3.x系への対応が必要な場合や、/dev/fdが存在しない環境でprocess substitutionが使えない場合は、一時ファイルを使う方法が最も確実です。

#!/bin/bash # mktemp で安全な一時ファイルを作成する TMPFILE=$(mktemp) cat /etc/passwd > "$TMPFILE" count=0 while read line; do count=$((count + 1)) done < "$TMPFILE" rm -f "$TMPFILE" echo "行数: $count" # 実行結果: # 行数: 33

mktemp コマンドで安全な一時ファイルパスを生成します。/tmp/work_$$ のように PID で命名する方法より、mktemp の方がファイル名衝突のリスクが低いため推奨します。スクリプト終了時に必ず rm -f "$TMPFILE" で削除してください。

トラブルシュート|よくある疑問と対処

Q. process substitutionで構文エラーになる
bash: syntax error near unexpected token が出る場合、スクリプト冒頭のシェバンが #!/bin/sh になっていないか確認してください。/bin/sh が dash 等の場合、process substitution は使えません。#!/bin/bash に変更してください。

Q. ()の中でcdしたのに元のディレクトリに戻ってこない
() の中の cd は親シェルに影響しません。元ディレクトリに戻れていない場合は、() ではなく {}pushd/popd を使っていないか確認してください。

Q. 関数内で変数を変更したが外に出ない場合と出る場合がある
bashの関数はデフォルトでカレントシェルで実行されます。関数内での変数変更は親スクリプトに反映されます。ただし、関数内で local 変数名 を宣言した場合はローカルスコープになり、関数を出ると消えます。local を使っているかどうかを確認してください。

Q. パイプを複数段繋げた後の変数を取り出したい
cmd1 | cmd2 | while read のように複数段のパイプでも、process substitution は有効です。while read ... done < <(cmd1 | cmd2) の形に書き換えてください。Linuxの基本的なコマンド連携についてはLinux 基本コマンドの解説も合わせて参照してください。

本記事のまとめ

状況 原因 解決策
パイプ後のwhileで変数が0のまま whileがサブシェルで実行される while ... done < <(コマンド) に変える
()内の変数変更が外に伝わらない ()はサブシェルを生成する カレントシェルで実行したい場合は{}を使う
パイプの書き方を変えたくない デフォルトではパイプ右辺がサブシェル shopt -s lastpipe(bash 4.2以降・スクリプト専用)
古い環境・高い可搬性が必要 process substitution非対応の環境 一時ファイル(mktemp)を使う
bashのサブシェルは「変数が消える」というよりも「別プロセスの変数変更が元のプロセスに届かない」という、Linuxプロセスの基本動作です。|() がサブシェルを生み出すことを理解していれば、パイプ後の変数問題も落ち着いて対処できます。

まずは process substitution(< <(...))を一つ覚えるだけで、現場でよくあるケースの大半に対応できます。
現場で通用する安全なLinuxサーバー構築の「型」を体系的に身につけたい方へ、20年以上の運用経験を持つ現役エンジニアが基礎から教えます。
シェルスクリプト講座を見る >>

無料メルマガで学習を続ける

Linuxの実践スキルをメールで毎週お届け。
登録は1分、解除もいつでも可。

登録無料・いつでも解除できます

暗記不要・1時間後にはサーバーが動く

3,100名以上が実践した「型」を無料で公開中

プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。

登録10秒/合わなければ解除3秒 / 詳細はこちら

Linux無料マニュアル(図解60P) 名前とメールで30秒登録
宮崎 智広

この記事を書いた人

宮崎 智広(みやざき ともひろ)

株式会社イーネットマーキュリー代表。現役のLinuxサーバー管理者として20年以上の実務経験を持ち、これまでに累計3,100名以上のエンジニアを指導してきたLinux教育のプロフェッショナル。「現場で本当に使える技術」を体系的に伝えることをモットーに、実践型のLinuxセミナーの開催や無料マニュアルの配布を通じてLinux人材の育成に取り組んでいる。

趣味は、キャンプにカメラ、トラウト釣り。好きな食べ物は、ラーメンにお酒。休肝日が作れない、酒量を減らせないのが悩み。最近、ドラマ「フライトエンジェル」を観て涙腺が崩壊しました。