25
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

シェルスクリプト「whileの中の変数が見えない」解決方法5選 〜 パイプ・サブシェル問題

Last updated at Posted at 2021-12-17

はじめに

コマンドの出力をパイプ + while read ループで処理した時にループの中の変数がループの外で使えない(変数の中身が空になっている)という問題の解決方法の紹介です。これも何番煎じかのネタだと思いますが、良くない解決方法が目につき、あまり紹介されてないテクニックもあるので、それらの情報をすべてまとめ、何がどういう理由で駄目なのか?良い方法とはなにか?を解説するのがこの記事の趣旨です。推奨 5 パターン(+非推奨 3 パターン)の解決方法を解説しています。

パイプ・サブシェル問題とは?

以下のような問題です。

#!/bin/sh
total=0
seq 10 | while IFS= read -r line; do
  echo "$line"
  total=$((total + line))
done
echo "$total" # 1 + 2 + ... + 10 = 55 と出力してほしいのに出力されない

「あれ?ちゃんと 55 と出力されたよ?」という方は、おそらく ksh か zsh で実行しています。

なぜ 55 と表示できないのかというと、パイプ(|)でつないだコマンドがそれぞれサブシェル(≒別のプロセス)で実行されているからです。呼び出し元プロセスとは別のプロセスで変数を書き換えてるので、呼び出し元プロセスには反映されません。まあ多分、他の記事を読んでいると思うので詳しい説明は不要かなと思っています。(単に面倒なだけ)

例外シェル

解決方法の前に例外のシェルの話をします。ksh と zsh です。これらのシェルではパイプでつないだいだコマンドのうち、最後だけはサブシェルではなく呼び出し元と同じ環境で実行されます。そのためパイプでつないだ最後の while ループであれば、その中の変数は呼び出し元の環境から参照することができます。

ちなみに POSIX ではパイプでつないだコマンドは最後以外も含めて、サブシェルで実行するかどうかはシェルの実装定義で決めて良いとなっているので、ksh も zsh もその他のシェルも POSIX に準拠した動作です。

補足 入力がファイルなら簡単

入力がコマンドではなくてファイルなら簡単です。ファイルを cat してパイプでつなぐような事をしているのであれば、普通にファイルから読み込みましょう。その方がパフォーマンスも良いです。

#!/bin/sh
total=0
cat data.txt | while IFS= read -r line; do
  echo "$line"
  total=$((total + line))
done
echo "$total"

上記のコードは次のように置き換えられます。

#!/bin/sh
total=0
while IFS= read -r line; do
  echo "$line"
  total=$((total + line))
done < data.txt
echo "$total"

ただし一つだけ注意点、上記の方法は 20 年ぐらい前の古い商用 UNIX で使われていた「Bourne シェル」では(ループ部分がサブシェルなるため)期待通りに動作しません。しかし、もはや一般に使われていない **Bourne シェルを気にする必要はありません。**例えば Solaris 10 の /bin/sh が Bourne シェルですが、POSIX に準拠した UNIX(Solaris 10 も含む)であれば必ず bash や ksh 等の POSIX シェルもインストールされています。詳細は「BourneシェルとBourneシェル系(bash等のPOSIX shell)の違いについて

1. 非推奨の解決方法

まず(よく見かける?)非推奨の解決方法から紹介します。これらは正しく動作するものの非推奨の方法です。非推奨とした理由は**「ストリーミング処理を行わない」**からです。ストリーミング処理とは、簡単に言えば処理すべきデータが発生したらすぐに処理することです。対義語はバッチ処理でデータをまとめて処理します。データ量が少なければ、これらの方法を使っても良いと思いますが、データ量が多い時はパフォーマンスやレスポンスが悪くなってしまいます。

a. for ループを使う(非推奨)

#!/bin/sh
# for でループさせる場合は、パス名展開を無効にし IFS は改行のみにしておいた方がよい
# デフォルトでは*などの文字がファイル名に展開されスペースやタブも区切り文字として扱われる
set -f
IFS="
" 
total=0
for i in $(seq 10); do
  echo "$i"
  total=$((total + i))
done
echo "$total"

seq 10 程度の少ないデータ量であれば問題ないのですが、データ量が多くなるとストリーミング処理されてないことがはっきりと分かります。このコードの処理を細かく見ると、

  1. $(seq 10) でコマンドのすべての出力をメモリに蓄える
  2. そのデータを for で繰り返す

となります。すべての出力をメモリに蓄えるためメモリ消費量が増えてしまい、seq コマンドの実行が完了するまではループが開始されません = ストリーミング処理されません。

とは言え、seq 10 のようにデータが少ない場合は問題ありませんし、楽なので私もちょくちょく使いますが一応非推奨です。

b. ヒアドキュメントを使う (非推奨)

#!/bin/sh
total=0
# read が ホワイトスペース区切りで文字列を解釈しないように IFS に空文字を代入し
# バックスラッシュをメタ文字として解釈しないように -r オプションをつけたほうが良い
while IFS= read -r line; do
  echo "$line"
  total=$((total + line))
done <<HERE
$(seq 10)
HERE

こちらもストリーミング処理されないのでお勧めしません。データが少ないうちは for を使った場合とそんなに変わらないと思います。データが多い場合はシェルによってはメモリ消費を抑えるためにヒアドキュメントの内容を一時ファイルに書き出す(ちゃんと検証していません)ようなので、データ量が多い場合は for よりもこちらを使った方が良いかもしれません。とはいえやっぱり非推奨です。

c. 一時ファイルを使う(非推奨)

一時ファイルに書き出してから参照する方法です。ストリーミング処理が行われないだけでなく、常にファイル書き込みが行われるためパフォーマンスが悪い方法です。ヒアドキュメントを使えば良いのでこの方法を使う理由はありません。(というよりもたかがメモリ上のデータのやり取りでファイルなんか経由したくないってのが普通の感覚ですよね?)

#!/bin/sh
total=0
seq 10 > data.txt
while IFS= read -r line; do
  echo "$line"
  total=$((total + line))
done < data.txt
rm data.txt
echo "$total"

同時に複数起動したときのために一時ファイル名は mktemp コマンドで生成するほうが良いでしょう。また CTRL-C やエラーが起きて途中で中断したときのための終了処理が必要になります。

もし一時時ファイルを使うのであれば入力データをファイルに書き出すのではなく(データが少ないであろう)変数の内容をファイルに書き出した方が良いでしょう。その場合はストリーミング処理を維持することができます。

#!/bin/sh
total=0
seq 10 | while IFS= read -r line; do
  echo "$line"
  total=$((total + line))
  echo "$total" > total.txt # 毎回書き込みはパフォーマンスが悪い
done
total=$(cat total.txt)
rm total.txt
echo "$total"

ただしこのままではループのたびに無駄にファイル書き込みをしているのでよくありません。ファイルに書き込むのであれば本当に必要な最後の合計だけにすべきです。その方法については「A. グループを使う」を応用することで可能ですが、その方法を使えばファイルに書き出す必要が無くなる可能性が高く、結局の所この方法を使う必要性はほとんどありません。

2. 推奨する解決方法

これらの方法はストリーミング処理を妨げないので推奨する方法です。

A. グループを使う(推奨)

{} もしくは () でグループを作り、その中に while 等を入れます。

#!/bin/sh
seq 10 | {
  total=0
  while IFS= read -r line; do
    echo "$line"
    total=$((total + line))
  done
  echo "$total"
}

この方法は、個人的に目からウロコな方法じゃないかな?と思っています。パイプ・サブシェル問題は正確には「while の中の変数が見えない」のではなく「サブシェルの中の変数が見えない」です。| で直接 while をつなぐから、while = サブシェルとなって、while の中が見えなくなっているので、{} をサブシェルとしてつなげば、その中の while の中と外は同一の環境となるため while の外から中の変数を参照することができます。ただし {} の外から中を参照することはできません。

グループを作るのに個人的には軽い感じがする {} を使っていますが、どちらにしろ | によってサブシェルが作られるはずなので(シェルがちゃんと最適化していれば){} でも () でも変わらない気がします。むしろ () の方が必ずサブシェルとなるので一貫性があって良いかもしれません。

この形のアレンジが関数を使う方法です。グループ部分をそのまま関数にします。

#!/bin/sh

count() {
  total=0
  while IFS= read -r line; do
    echo "$line"
    total=$((total + line))
  done
  echo "$total"
}

seq 10 | count

やっていることは同じですが、変数のスコープがより明確になったと感じるのではないでしょうか? {} の外からは参照することはできないので完璧な解決方法ではないかもしれませんが、おそらく半数はこの方法で十分なのではないかと思います。

B. プロセス置換を利用する(推奨)

POSIX で標準化されておらず dash、mksh、yash などでは使えませんが、対応シェルも多くもっとも簡単な方法です。使用可能なシェルは bash、ksh、zsh、busybox ash 1.34 以上などです。

#!/usr/bin/env bash
total=0
while IFS= read -r line; do
  echo "$line"
  total=$((total + line))
done < <(seq 10)
echo "$total"

文法がややこしく見えるかもしれませんが <(seq 10) の部分がファイルとして解釈されると考えればよいです。なので cat <(seq 10)diff <(seq 10) <(seq 11) のような書き方ができます。

C. bash で lastpipe を使う(推奨)

bash 4.2 以降であれば shopt -s extglob lastpipe を使って ksh や zsh と同じようにパイプラインの最後のコマンドを呼び出し元と同じ環境で実行することができます。そのためループの外から while の中の変数を参照することができます。

#!/usr/bin/env bash
shopt -s extglob lastpipe
total=0
seq 10 | while IFS= read -r line; do
  echo "$line"
  total=$((total + line))
done
echo "$total"

D. 名前付きパイプを使う(推奨)

非推奨とした「c. 一時ファイルを使う」のファイル書き込みが必要となってストリーミング処理できないという問題を解決した方法で「B. プロセス置換を利用する」を POSIX 準拠の機能で実装したものです。

#!/bin/sh
total=0
mkfifo data.txt
seq 10 > data.txt &
while IFS= read -r line; do
  echo "$line"
  total=$((total + line))
done < data.txt
rm data.txt
echo "$total"

名前付きパイプとは名前(ファイル名)がついたパイプで、FIFO ファイルとも呼ばれ mkfifo コマンドで作成することがでる特殊なファイルです。名前付きパイプはファイルとして作成しますが、実際のデータのやり取りはファイルに書き込まれずメモリ上で行われます。

名前付きパイプの特徴は書き込みと読み込みの両方が揃わない限り、もう片方はブロックされてしまうことです。そのため seq の名前付きパイプへの書き込みはブロックされてしまうので、バックグラウンドプロセスとして実行する必要があります。その後はループで名前付きパイプを読み込み始めるとブロックが解除され seq の書き込みが並列・並行で行われながら一行ずつ読み込むため、ストリーミングで処理が実行されます。

この方法のデメリットは「c. 一時ファイルを使う」を使った方法と同じく、名前付きパイプの後片付けが必要となることです。そのため実際には以下のようなコードを書く必要があります。

#!/bin/sh

fifo=""
cleanup() {
  if [ "$fifo" ]; then
    rm "$fifo"
  fi
}
trap cleanup EXIT
fifo=$(mktemp)

total=0
seq 10 > "$fifo" &
while IFS= read -r line; do
  echo "$line"
  total=$((total + line))
done < "$fifo"
echo "$total"

E. ファイルディスクリプタを使う(推奨)

少し技巧的になりますがファイルディスクリプタ(以下 FD と略します)を使うことで、POSIX に準拠しつつ、後片付けが必要な名前付きパイプも使わずに実装することができます。これが冒頭で書いた**「あまり紹介されていないテクニック」**です。

#!/bin/sh
{
  total=$(seq 10 | {
    total=0
    while IFS= read -r line; do
      echo "$line"
      total=$((total + line))
    done >&3
    echo "$total"
  })
} 3>&1
echo "$total"

一般的な定義とは異なると思いますが、私はファイルディスクリプタを**「最終的にはファイルや画面に出力しなければいけないものの、途中の経路を追加してパイプの流れを変更(迂回)するための道具」**と捉えています。上記のコードでは標準出力(FD1)に出力していた echo "$line" を FD3 に迂回することで total 変数にキャプチャされることを回避し、その後に FD3 を FD1 に戻しています。

注意点としてファイルディスクリプタは最終的にファイルまたは画面(標準出力 or 標準エラー出力)に出力しなければならないものなので、FD3 に出力する前に FD3 を作っておかなければいけません。FD を作るとは最終的に出力先をどこにするかを決めることを意味します。それを { ... } 3>&1 で行っています。

exec を使ってファイルディスクリプタを作成することもできますが、自動でクローズしてくれるので { ... } 3>&1 のような書き方のほうがおすすめです。exec を使った場合は以下のようになります。

#!/bin/sh
exec 3>&1
total=$(seq 10 | {
  total=0
  while IFS= read -r line; do
    echo "$line"
    total=$((total + line))
  done >&3
  echo "$total"
})
exec 3>&-
echo "$total"

もし複数の値を返したい場合は eval できる形式で返すのが便利です。

#!/bin/sh
{
  ret=$(seq 10 | {
    total=0
    while IFS= read -r line; do
      echo "$line"
      total=$((total + line))
    done >&3
    echo "total=$total"
    echo "other=\"foo bar baz\""
  })
} 3>&1
eval "$ret"
echo "$total"
echo "$other"

eval を使いますので特別な意味を持つ文字の扱いには気をつけてください。そのような文字が入る可能性があるのであればエスケープ処理をする必要があります。でなければ最悪脆弱性の元となります。そのため eval を避けて一つの変数に : 区切りなどで詰め込んで返すのも良いでしょう。

まとめ

この記事では「whileの中の変数が見えない」という問題の推奨の解決方法を紹介しました。また避けるべき非推奨の方法も紹介しました。

  • 非推奨(ストリーミング処理されない)
    • a. for ループを使う (POSIX 準拠、少量データ向け)
    • b. ヒアドキュメントを使う (POSIX 準拠、大量データ向け)
    • c. 一時ファイルを使う (POSIX 準拠、面倒・遅い)
  • 推奨(ストリーミング処理される)
    • A. グループを使う (POSIX 準拠、簡単)
    • B. プロセス置換を利用する (bash、ksh、zsh、busybox ash 1.34 以降、簡単)
    • C. bash で lastpipe を使う(bash 4.2 以降、ksh、zsh はデフォルトの動作、簡単)
    • D. 名前付きパイプを使う (POSIX 準拠、面倒)
    • E. ファイルディスクリプタを使う (POSIX 準拠、難しい)

非推奨の方法はメモリまたはファイルに蓄えてから処理するためストリーミング処理が行われず、いずれもパフォーマンスやレスポンスが悪くなります。ただし非推奨の方法でも使ってはいけないというわけではなく、データ量が十分小さければ使用しても構わないと思います。

推奨する方法はストリーミング処理が行われメモリ消費量も抑えることができパフォーマンスやレスポンスも良くなるのでおすすめです。状況に応じて適切な方法を使用してください。


関連リンク

25
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?