引数でファイルを受け取るシェルスクリプトを書いたが、後からパイプで渡せるようにしたくなった、というのはよくあるケースだ。パイプ対応を追加しようとすると、
if [ -p /dev/stdin ] などを追加してコードが複雑になりがちで、どの書き方が正解なのかわかりにくい。この記事では、RHEL 9系/Ubuntu 24.04 LTSを想定環境として、標準入力とファイル引数の両方を受け付けるシェルスクリプトの設計パターンを4つ解説します。シンプルな1行の書き換えで両対応できる方法から、複数ファイルへの対応、パイプ接続の検知まで、実装例と共に順を追って説明します。なお、記事内のコマンドや出力例は実際には実行していない例示です。
この記事のポイント
・${1:-/dev/stdin} で引数がなければ標準入力へ自動フォールバックできる
・複数ファイルへの対応は [ $# -eq 0 ] + /dev/stdin フォールバックで設計できる
・test -t 0 でパイプ接続か対話入力かを検知してモード切替が実装できる
・- を引数に渡すとstdinを読む Unix慣習は10行以内で自作スクリプトに組み込める
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
なぜ「標準入力とファイル引数の両対応」が必要なのか
Linuxの既存コマンドの多くは、引数でファイルを指定してもパイプから読み込んでも同じように動作する。grep "pattern" file.txt でも cat file.txt | grep "pattern" でも結果は同じだ。この設計が徹底されているため、コマンドを自由に組み合わせられるUnixパイプラインが成立している。自作シェルスクリプトが「引数専用」になっていると、パイプラインに組み込めない。例えば次のような状況で困る。
・他のコマンドの出力を直接スクリプトに渡したいが、一時ファイルを経由しないと使えない
・cronジョブではファイル指定、手動実行ではパイプで使いたいが分岐が書けない
・ファイル名を決定するロジックが上流にあり、スクリプトに直接渡せない場合がある
シェルスクリプトを「部品」として設計するなら、入力源を問わない実装が理想だ。
実際のシェルスクリプト自動化をより体系的に身につけたい方には、シェルスクリプト設計の学習講座でさらに詳しく解説している。
最もシンプルなパターン:${1:-/dev/stdin} によるフォールバック
引数が1つだけ(またはゼロ)を想定するスクリプトには、${1:-/dev/stdin} が最もシンプルな解だ。1. /dev/stdin とは
/dev/stdin はLinuxが提供する仮想デバイスファイルで、プロセスの標準入力(ファイルディスクリプタ 0)をファイルとして扱える。パイプでデータが流れてきている場合も、/dev/stdin を読み込むとそのデータを取得できる。RHEL 9系とUbuntu 24.04 LTSのいずれでも利用できる(POSIX規格には含まれないため、Alpine Linuxのbusybox環境など一部では使えない場合がある。詳細はトラブルシュートの節で説明する)。
2. スクリプトの実装例
次のスクリプトは、引数が指定されればそのファイルを、指定がなければ標準入力を処理する。#!/bin/bash # 引数がなければ /dev/stdin にフォールバックする input_file="${1:-/dev/stdin}" while IFS= read -r line; do echo "処理: ${line}" done < "${input_file}"
${1:-/dev/stdin} は「変数 $1(第1引数)が空または未定義なら /dev/stdin を使う」というbashのパラメータ展開だ。3. 動作確認
ファイル指定の場合(出力例(環境により異なります)):$ ./process.sh /etc/hostname 処理: myserver
$ echo "hello" | ./process.sh 処理: hello
done < "${input_file}" と変数を使ってリダイレクトしている点で、while read ループの入力源を動的に切り替えている。複数ファイル引数への対応:$@ と /dev/stdin のフォールバック設計
引数が複数になる場合は${1:-/dev/stdin} では足りない。$@(全引数の配列展開)と組み合わせた設計が必要だ。1. [ $# -eq 0 ] で引数なしを検出する
$# はスクリプトに渡された引数の数だ。引数が0個のときに /dev/stdin を読むよう分岐する。#!/bin/bash # 引数があれば各ファイルを処理、なければ標準入力を処理する process_input() { local src="$1" while IFS= read -r line; do echo "処理: ${line}" done < "${src}" } if [ $# -eq 0 ]; then process_input /dev/stdin else for file in "$@"; do process_input "${file}" done fi
2. cat への一括渡しパターン
ファイルを連結して処理するだけなら、cat に任せる書き方が最も簡潔だ。#!/bin/bash # 引数があれば各ファイルを連結、なければ標準入力を cat に流す if [ $# -eq 0 ]; then cat /dev/stdin else cat "$@" fi | while IFS= read -r line; do echo "処理: ${line}" done
cat "$@" と cat /dev/stdin の出力を一本のパイプに流し、while read で処理する。引数の数に関係なく後続のパイプが同じになるため、処理ロジックを1か所に集約できる。test -t 0 でパイプ接続を検知する設計
パイプで呼ばれているかどうかを明示的に判定したい場合がある。例えば「パイプのときはプロンプトを出さない、ターミナルから直接実行のときは入力を促す」という分岐だ。1. -t フラグの意味
test -t 0(または [ -t 0 ])は「ファイルディスクリプタ 0(stdin)がターミナルに接続されているか」を判定する。・パイプで接続されているとき:
test -t 0 は偽を返す・ターミナルから直接実行のとき:
test -t 0 は真を返す2. 対話モードと自動化モードを切り替える実装
#!/bin/bash # パイプ接続か対話入力かを検知して分岐する if [ -t 0 ]; then # ターミナルからの対話入力 echo "入力を開始してください(Ctrl+D で終了):" >&2 fi while IFS= read -r line; do echo "処理: ${line}" done
>&2 で標準エラーに出力している。これにより、スクリプトの出力(標準出力)をパイプで次のコマンドに渡す際にプロンプト文字が混入しない。なお
[ -p /dev/stdin ](名前付きパイプかどうか判定)でも似たことはできるが、通常のリダイレクト(< file)では偽になるため網羅性が低い。[ -t 0 ] の方が汎用的だ。「-」を明示的なstdinとして受け付けるUnix慣習の実装
cat -、grep "pattern" -、sort - などのように、Unixのコマンド群は -(ハイフン)をstdinのエイリアスとして扱う慣習がある。自作スクリプトでもこれを実装することで、パイプラインへの組み込みやすさが大きく向上する。1. cat や grep が「-」をstdinとして扱う仕様
cat file1.txt - file3.txt と書くと、file1.txt→stdin→file3.txt の順でデータが連結される。複数ファイルと標準入力を混在させた処理が1つのコマンドで実現できる。2. 自作スクリプトへの組み込み
- を受け取ったとき /dev/stdin として処理するロジックを追加する。#!/bin/bash # 「-」を明示的なstdinエイリアスとして扱う process_input() { local src="$1" # 「-」は /dev/stdin に置き換える [ "${src}" = "-" ] && src=/dev/stdin while IFS= read -r line; do echo "処理: ${line}" done < "${src}" } if [ $# -eq 0 ]; then process_input /dev/stdin else for arg in "$@"; do process_input "${arg}" done fi
./script.sh file1.txt - file3.txt という呼び出しが可能になる。パイプラインの中間にファイルと標準入力を交互に渡す柔軟な設計が実現できる。よくあるトラブルと対処法
1. /dev/stdin が使えない環境への対応
Alpine Linuxのbusybox shや旧POSIX sh環境では/dev/stdin が存在しない場合がある。よりポータブルな代替は、exec でファイルディスクリプタを明示的に操作する方法だ。#!/bin/sh # /dev/stdin を使わずにstdinをFD3に複製する(POSIX互換) if [ $# -eq 0 ]; then exec 3<&0 # stdinをFD3に複製 else exec 3< "$1" # 引数ファイルをFD3で開く fi while IFS= read -r line <&3; do echo "処理: ${line}" done exec 3<&- # FD3を閉じる
exec 3<&0 は標準入力(FD 0)をファイルディスクリプタ 3 に複製する操作だ。/dev/stdin ではなくFDの番号を直接指定するため、busybox環境でも動作する。2. while read ループ内で子プロセスがstdinを消費する問題
while read でstdinを処理しているループの内側で、別のコマンドがstdinを読もうとすると競合が起きる。# NG: ループ内のsshがstdinを消費してしまう while IFS= read -r host; do ssh "${host}" "hostname" # sshがFD0(stdin)を読んでしまう done < /dev/stdin
# OK: ループ入力をFD3に割り当て、FD0は子プロセス用に空ける exec 3< /dev/stdin while IFS= read -r host <&3; do ssh -n "${host}" "hostname" # ssh -n でstdinをオフにする done exec 3<&-
ssh -n の -n オプションは、sshの標準入力を /dev/null にリダイレクトする。sshのような「対話的にstdinを使う可能性があるコマンド」をループ内で呼ぶ場合に必須だ。本記事のまとめ
| やりたいこと | 実装パターン |
|---|---|
| 引数なし時にstdinへフォールバック(単一入力) | input="${1:-/dev/stdin}" |
| 複数ファイル引数とstdinのフォールバック | [ $# -eq 0 ] で分岐 + for ループ |
| パイプ接続の検知 | [ -t 0 ](真=ターミナル、偽=パイプ) |
| 「-」を明示的なstdinエイリアスに | [ "${src}" = "-" ] && src=/dev/stdin |
| busybox・POSIX sh環境での互換対応 | exec 3<&0 でFD操作(/dev/stdin不要) |
| while readループ内での子プロセスとの競合回避 | ループ入力をFD3に割り当て + 子に -n 等でFD0を塞ぐ |
${1:-/dev/stdin} は1つのファイルやstdinを受け取る小さなスクリプトに最適だ。より複雑なパイプラインに組み込む場合は、[ -t 0 ] での検知や exec 3<&0 によるFD操作を組み合わせることで、堅牢な入力インターフェースを設計できる。
>> シェルスクリプト設計の学習講座はこちら
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら

無料メルマガで学習を続ける
Linuxの実践スキルをメールで毎週お届け。
登録は30秒、解除もいつでも可。
登録無料・いつでも解除できます