シェルスクリプトの関数をパイプで繋げられるようにする(注意点あり)


関数を引数渡しでもパイプ渡しでも同様に処理できるようにするのと、実装時にハマったことのメモ。


忙しい人向けの結論

以下のように実装すれば良い。

to_upper.sh
#!/bin/bash
to_upper() {
    # パラメータの受け取り
    if [ -p /dev/stdin ]; then
        if [ "`echo $@`" == "" ]; then 
            __str=`cat -`
        else
            __str=$@
        fi
    else
        __str=$@
    fi

    # 処理
    echo "${__str}" | tr '[a-z]' '[A-Z]'
}

# 通常の呼び出し方(引数渡し)はもちろんOK
to_upper hoge

# パイプ渡しでもOK
echo hoge | to_upper

パイプで渡せるようにする実装

ググった結果、パイプで渡されたときは標準入力 cat - を読み出せば良いとわかった。

sample.sh
#!/bin/bash
to_upper() {
    # パラメータの受け取り
    if [ -p /dev/stdin ]; then
        __str=`cat -`
    else
        __str=$@
    fi

    # 処理
    echo "${__str}" | tr '[a-z]' '[A-Z]'
}

ハマったこと

テキストファイルを while read line で読み、この関数を通したところ、引数渡しをすると最初の1行が処理されない問題が発覚。

sample.sh
# テキストファイルを読み出し関数に通す
echo -e "txt1\ntxt2\ntxt3" | while read line
do
    to_upper $line
done
# 結果
# TXT2
# TXT3

なぜ?

原因は未だに分からないのですが、関数を呼び出す側でパイプ処理が通されている状態で引数渡しをすると、 /dev/stdin が名前付きパイプとして認識され、それが関数側にも伝搬してしまっている模様。

sample.sh
#!/bin/bash
to_upper() {
    if [ -p /dev/stdin ]; then
        echo stdin
    else
        echo args
    fi
}

##### 引数渡しで実装すると、呼び出し元側の影響を受ける
### case1
echo -e "txt1" | while read line
do
    to_upper $line
done
# 結果
# stdin (本当はargsと出て欲しい)

### case2
while read line
do
    to_upper $line
done < file.txt
# 結果
# args (想定通りargsと出る)


##### 元々パイプ渡し処理の場合は問題ない
### case3
echo -e "txt1" | while read line
do
    echo $line | to_upper
done
# 結果
# stdin (想定通りstdinと出る)

### case4
while read line
do
    echo $line | to_upper
done < file.txt
# 結果
# stdin (想定通りstdinと出る)

よって、前述Case1のような実装をすると、 read line で1行目を while 文に読み出され、関数内で /dev/stdin を名前付きパイプとして評価され、 cat - で残りの標準入力をすべて奪い取られた結果、2周目の read line のときには標準入力が空になっているので while ループを終了される、という挙動となっていた。

どう実装すればよいか

前述Case1のとき、引数渡し to_upper $line で関数を呼び出すので、関数内の引数 $@ に入ってるべき値(この場合 txt1 という文字列)が入っている。
ということで、冒頭結論の実装例のように、 /dev/stdin を評価したあとに $@ に値が入っているかどうかを評価するロジックにすればよい。

参考

標準入力を受け取れるシェルスクリプト、関数の作成(パイプで繋げられるようにする) - Qiita
シェルスクリプトでパイプを判断する - Qiita