サブシェルの落とし穴|変数消失の防止

設計パターン&テンプレ

この記事の狙い

Bash でサブシェル(child shell)が暗黙に生まれるパターンを把握し、意図せぬ変数消失・状態未反映を防ぎます。
代表的な落とし穴(パイプ・() グループ・コマンド置換・バックグラウンド)を安全な置き換えパターン
で解消します。

前提と対象

  • Bash 4+(set -Eeuo pipefail 推奨)
  • 非対話シェル(スクリプト)を想定。lastpipe など Bash 固有の挙動も扱います。

TL;DR(最小実装・コピペ可)

#!/usr/bin/env bash
set -Eeuo pipefail
set -o pipefail

# 1) パイプ右側で更新した変数は親に残らない(サブシェル)
# NG: printf 'a\nb\nc' | while read -r _; do ((n++)); done; echo "$n"  # n は空

# OK: プロセス置換で "親の while" が読む
n=0
while IFS= read -r _; do ((n++)); done < <(printf 'a\nb\nc\n')
echo "$n"  # 3

# 2) ディレクトリ変更を親に残したくないときだけ ( ... )
( cd /tmp && touch x )   # 親の CWD は変わらない
# 親に残したいなら { ...; } で(末尾セミコロン必須)
{ cd /; :; }

サブシェルが生まれる主な場面

  • pipelinecmd1 | cmd2 の各コマンド)
  • 丸括弧グルーピング (...)
  • コマンド置換 $(...)
  • バックグラウンド実行 cmd &(ジョブごとに子プロセス)
  • 一部の組み合わせ((cmd) | while ... など)

サブシェルでは親から環境・変数はコピーされますが、変更は親に戻りません

典型的な落とし穴と回避策

1) パイプでカウント/集計が 0 になる

悪い例

count=0
printf '%s\n' a b c | while IFS= read -r _; do ((count++)); done
echo "$count"   # => 0(while がサブシェル)

良い例(プロセス置換)

count=0
while IFS= read -r _; do ((count++)); done < <(printf '%s\n' a b c)
echo "$count"   # => 3

代替(mapfile)

mapfile -t lines < <(printf '%s\n' a b c)
echo "${#lines[@]}"  # 3

代替(lastpipe
Bash 4.2+:非対話シェルかつジョブ制御 OFF(既定)で、パイプの最後のコマンドだけ親で実行できます。

shopt -s lastpipe
printf '%s\n' a b c | readarray -t lines
echo "${#lines[@]}"  # 3

lastpipe最後のコマンド限定while ループを最後に置く必要があります。

2) () と {} の混同

  • (...)サブシェル:内部の cd・変数更新は親に影響しない
  • { ...; }同一シェル:状態が親に残る(末尾の ; と前後の空白必須)
( cd dir && build )     # 親の CWD は不変(安全な隔離)
{ cd dir; build; }      # 親に CWD 変更が残る(意図時のみ)

3) コマンド置換内の失敗見落とし

$(...) 内はサブシェル。内部失敗は外側の $? で検知。

out="$(awk '{s+=$1} END{print s}' input.txt)" || { echo "calc failed" >&2; exit 3; }

set -o pipefail を有効にしておくと、置換内のパイプ途中の失敗も拾いやすい。

4) バックグラウンドでの状態更新

cmd & は子プロセス。終了コードは wait で拾い、親の変数に書かせない

ok=0
long_job & p=$!
if wait "$p"; then ok=1; fi
echo "$ok"

置き換えパターン早見表

やりたいことNG 例OK 例
ループで数える`echo listwhile read; do ((n++)); done`
行配列に読み込み`cmdmapfile -t lines`
一時的に cd{ cd dir; run; }(親が汚れる)( cd dir && run )
状態を残すグループ( var=1 ){ var=1; }
同時に複数へ流す`logwhile …`

“コピペ可”テストブロック(最小)

#!/usr/bin/env bash
set -Eeuo pipefail
set -o pipefail

# 1) パイプのサブシェル問題の回避(プロセス置換)
n=0
while IFS= read -r _; do ((n++)); done < <(printf 'x\ny\nz\n')
[[ $n -eq 3 ]] || { echo "count fail: $n"; exit 1; }

# 2) () は親に影響しない / {} は影響する
orig="$PWD"
( cd /; : )
[[ "$PWD" == "$orig" ]] || { echo "() leaked"; exit 1; }

{ cd /; :; }
[[ "$PWD" == "/" ]] || { echo "{} no effect"; exit 1; }

echo "PASS"

実務の勘所

  • 親に残したい状態(変数・CWD・FD)を変更するブロックでは**{} を選ぶ**。残したくない処理は ()
  • パイプで状態を書きたいときはプロセス置換、もしくは lastpipe を検討。
  • 関数の出口で値を返す設計(stdout / printf -v / nameref)に寄せ、外側の変数更新依存を減らす
  • set -o pipefail を常時有効にして途中失敗の見落としを回避。

互換性と移植性

  • lastpipe は Bash の shopt 機能(sh/dash では不可)。非対話で有効、ジョブ制御 ON だと無効。
  • プロセス置換 < <(...) / > >(…) は Bash 拡張(POSIX sh では不可)。
  • POSIX 互換が必要なら、一時ファイルで受け渡す設計に。

セキュリティと安全設計

  • サブシェル境界を跨いでロック/一時ファイルパスなどを共有する場合、明示的な受け渡し(引数・環境変数)に。
  • コマンド置換内の未検証入力に注意。失敗時は外側で $? を必ず検査
  • バックグラウンド処理は**wait とタイムアウト**設計を入れ、ゾンビ化を防ぐ。

パフォーマンスの勘所(短く)

  • 不要なサブシェル生成はプロセス起動コストに直結。{} に置き換えられる箇所は置き換える。
  • 大量テキストはストリーム処理プロセス置換で一時ファイルを減らす。
  • パイプの段数を詰め、Bash のパラメータ展開で置き換えられる所は置き換える。
Bash玄

はじめまして!Bash玄です。

エンジニアとしてシステム運用に携わる中で、手作業の多さに限界を感じ、Bashスクリプトを活用して業務を効率化したのがきっかけで、この道に入りました。「手作業は負け」「スクリプトはシンプルに」をモットーに、誰でも実践できるBashスクリプトの書き方を発信しています。

このサイトでは、Bashの基礎から実践的なスクリプト作成まで、初心者でもわかりやすく解説しています。少しでも「Bashって便利だな」と思ってもらえたら嬉しいです!

# 好きなこと
- シンプルなコードを書くこと
- コマンドラインを快適にカスタマイズすること
- 自動化で時間を生み出すこと

# このサイトを読んでほしい人
- Bashに興味があるけど、何から始めればいいかわからない人
- 定型業務を自動化したい人
- 効率よくターミナルを使いこなしたい人

Bashの世界に一歩踏み出して、一緒に「Bash道」を極めていきましょう!

Bash玄をフォローする