はじめに
POSIXシェルにおいて複数のコマンドをパイプで繋いだとき、途中のコマンドでエラーが起きたとしても全体の終了ステータスは最後のものになります。
#!/bin/sh
foo(){exit 11;}
bar(){exit 12;}
baz(){exit 13;}
foo | bar | baz
echo$?# => 13
foo | bar | baz | cat
echo$?# => 0これでは途中で何かしらのエラーが発生したとしても検出できず、場合によっては処理がそのまま進んでしまいます。これを防ぐオプションが pipefailでパイプラインの各コマンドの終了ステータスを取得するための変数が PIPESTATUSです。
#!/bin/bash省略
foo | bar | baz
echo"${PIPESTATUS[@]}"# => 11 12 13set-o pipefail
foo | bar | baz | cat
echo$?# => 13pipefailは bash, zsh, ksh, mksh, yash で利用可能で PIPESTATUSは bash と zsh (では pipestatus変数)で利用可能です。
便利な機能ですが POSIX 準拠シェルの機能ではありません。この記事はこれらを POSIX 準拠の機能だけを使って実装しようというどこかで聞いたような話です。この話は「ここ」とか「ここ」で紹介されている有名な実装コードがあります。
# https://www.unix.com/302268337-post4.html より
run(){j=1
while eval"\${pipestatus_$j+:} false";do
unset pipestatus_$jj=$(($j+1))done
j=1 com=k=1 l=for a;do
if["x$a"='x|'];then
com="$com { $l "'3>&-
echo "pipestatus_'$j'=$?" >&3
} 4>&- |'j=$(($j+1))l=else
l="$l\"\$$k\""fi
k=$(($k+1))done
com="$com$l"' 3>&- >&4 4>&-
echo "pipestatus_'$j'=$?"'exec 4>&1
eval"$(exec 3>&1;eval"$com")"exec 4>&-
j=1
while eval"\${pipestatus_$j+:} false";do
eval"[ \$pipestatus_$j -eq 0 ]"||return 1
j=$(($j+1))done
return 0
}しかしこの関数の使い方を見るとすぐわかる問題点があります。
use it as:
run cmd1 \| cmd2 \| cmd3 exit codes are in $pipestatus_1, $pipestatus_2, $pipestatus_3
上記の通り各コマンドの区切り文字として \|を使用しています。(パイプとして認識されないようにエスケープしており、これは |という文字です。)つまりコマンドの引数として |という文字が渡されてしまうと誤動作を起こしてしまうということです。またエラーが発生した場合の終了ステータスは 1固定であるため pipefailとも挙動が異なります。(もっとも元記事は pipefailを実装したとは書いてないので間違いというわけではありません。)
また set -eの状態だと期待通りに動作しないことがあり、dash ではエラーが発生したコマンド以降の戻り値が取れません。(その他のシェルでは取れるようなので dash のバグかも知れないです。)
最初、参照元のコードを読んだときは何をやってるかさっぱりだったのですが、今ならシェルスクリプト力もついたので自力で実装できるんじゃないかと思い改良版を実装しました。この記事では参照元のコードを「オリジナルのコード」と呼ぶことにし、いくつかの問題を解決した新しい実装を提示します。
実装
# License: Creative Commons Zero v1.0 Universal
pipeline(){r=$1i=0 com=''ret=''while[$# -gt 1 ]&&i=$(($i+1))&&shift;do
com="$com( ($1) &&:; echo xs${i}=\$? >&4; exec 4>&- )${2:+ | }"ret="$ret\$xs${i}"done
echo"{ $r=\$( ( exec >&3; $com; exec 3>&- ) 4>&1 ); } 3>&1"echo"$r=\$(eval \$$r; echo $ret; exit \$xs${i})"}
pipefail(){
pipeline "$@"remove_trailing_zeros='until [ "$i" = "${i% 0}" ]; do i=${i% 0}; done'echo"(i=\$$1; $remove_trailing_zeros; exit \${i##* })"}(ずいぶん短くすることができました。😁)
# 使い方eval"$(pipeline PSTATUS \"printf 1"\"awk '{print \$1+1}END{exit 2}'"\"cat"\"awk '{print \$1+1}END{exit 4}'"\"cat"\)"echo"$PSTATUS"
3 # 標準出力
0 2 0 4 0 # PSTATUS# PSTATUS の各値の参照の仕方(例)echo"${PSTATUS%% *}"# 最初の値echo"${PSTATUS##* }"# 最後の値
check(){eval"set -- $1"# スペース区切りの各値を位置パラメータに展開するecho$1# 1つ目の値echo$2# 2つ目の値}
check "$PSTATUS"使い方はオリジナルのコードから随分と変わります。pipelineは evalで実行できるコードを出力する関数です。PIPESTATUSはグローバル変数 $pipestatus_Nではなく pipeline関数の第一引数で指定した名前の変数に代入されます。名前は決め打ちでも良かったのですが指定できた方が便利だと思います。また複数の変数に別々にいれるのではなく単一の変数にスペース区切りで入れています。この値を処理するのは難しくないでしょう。(上記に参照方法の例を書いています。)
残りの引数はパイプラインの各コマンドです。一引数がそれぞれ一つのコマンド(+引数)になります。この引数には変数も使用可能です。その場合'cmd "$1"'のように文字列で指定します。分かりづらいかもしれませんが evalと同じと考えれば良いです。一引数が一コマンドであるため \|のような区切り文字は不要です。(あった方が見やすいかもしれませんが必要ないので不要にしています。区切り文字を使いたければ単に無視する処理を入れるだけです。)
pipeline関数の戻り値は最後のコマンドの値ですが、代わりに pipefailを使用すると最後にエラーになった値になります。
注意点
OpenBSD ksh には ifなどの条件文と組み合わせてevalを使用した場合に、 set -eをしていると処理が中断されてしまうバグがあります。(参考 「Behaviour of “eval” under “set -e” in conditional expression」「ksh/sh: eval misbehaving under "set -e" in AND-OR list/conditional statement」)
#!/bin/shset-e# 本来ならfalseが出力されるべきだが eval で中断されるため何も出力されないif eval"false";then
echo"true"else
echo"false"fi# 回避策if eval"false &&:";then
echo"true"else
echo"false"fiそのため pipefail、pipefail関数でも同様の対策が必要になります。
if eval"$(pipeline PSTATUS 'foo''bar''baz')&&:";thenなおこのバグはオリジナルのコードにも影響します。(以下の箇所です)
while eval"\${pipestatus_$j+:} false";do一応、関数側で &&:を追加することで回避することもできます。ただし常につけてしまうと今度は少し古い zsh (5.0系とそれ以前)のバグで動作しなくなるため zsh では追加しないようにする必要があります。
pipeline(){r=$1i=0 com=''ret=""["${ZSH_VERSION:-}"]&&wa=''||wa='&&:'# この行を追加while[$# -gt 1 ]&&i=$(($i+1))&&shift;do
com="$com( ($1) &&:; echo xs${i}=\$? >&4; exec 4>&- )${2:+ | }"ret="$ret\$xs${i}"done
echo"{ $r=\$( ( exec >&3; $com; exec 3>&- ) 4>&1 ); } 3>&1"echo"$r=\$(eval \$$r; echo $ret; exit \$xs${i}) $wa"# 最後に &&: を追加}
pipefail(){
pipeline "$@"remove_trailing_zeros='until [ "$i" = "${i% 0}" ]; do i=${i% 0}; done'echo"(i=\$$1; $remove_trailing_zeros; exit \${i##* }) $wa"# 最後に &&: を追加}コード解説
コードは大幅に変わっていますが、パイプラインのそれぞれのコマンドの戻り値を取得する仕組みは元記事のコードとそう変わりません。pipeline関数および pipefail関数は evalするためのコードを出力します。例えば以下のコードを実行する場合
eval"$(pipeline PSTATUS 'foo "$@"''bar "$@"''baz "$@"')"pipeline関数は次のようなコードを出力します。(見やすさのため適当な場所で改行やインデントを入れています。)
{PSTATUS=$((exec>&3;((foo "$@")&&:;echo xs1=$?>&4;exec 4>&- )
|
((bar "$@")&&:;echo xs2=$?>&4;exec 4>&- )
|
((baz "$@")&&:;echo xs3=$?>&4;exec 4>&- );exec 3>&-
) 4>&1
);} 3>&1
PSTATUS=$(eval$PSTATUS;echo$xs1$xs2$xs3;exit$xs3)コマンドの戻り値を取得する方法のキモは、戻り値をファイルディスクリプタ 4 経由で渡すことです。各コマンドをパイプでつなぐと本来の戻り値($?)は消えてしまいます。そのため戻り値が消える前に echo xs1=$? >&4のようにしてファイルディスクリプタ 4 に出力します。そして外側の 4>&1で標準出力に出力し、コマンド置換 PSTATUS=$(...)でこの値をキャプチャします。この時、本来の標準出力の内容と混じらないように、本来の標準出力はファイルディスクリプタ 3に退避(exec >&3)させ、再度外側で元に戻して(3>&1)います。
その他、オリジナルのコードからの改良点を羅列しておきます。
pipeline,pipefail関数の戻り値を、set -o pipefailと互換にしたset -eの状態でも正しく動作するようにした- グローバル変数を使わないようにした
オリジナルのコードからのマイナス点は POSIX 準拠かつ実際のシェルで正しく動くようにサブシェルをいくつか使用するのでパフォーマンスがわずかに低下します。
余談
このコードを見て「exec 4>&-は必要ないのでは?」とか「exec >&3ではなくて ( ) >&3を使えばいいのでは?」とか「 ここは遅い ( )でなく { }でいいのでは?」と思った人へ。それらは特定の(古い)シェルのバグのワークアラウンドです。もしかしたら改善の余地はあるかもしれませんが疲れました。一応テストもしているので全てのPOSIXシェルに対応できているはずです。