はじめに
前回の記事「POSIX準拠シェルスクリプトでマルチコアの能力を活用する並列処理の実装(最大並列数あり、GNU Parallel, xargsなし)」の続編です。「POSIX 版の xargs
が NULL 文字を扱えない問題は od
コマンドを使って 8 進数(または 16 進数)文字列に変換して処理することで解決できます。」という伏線を回収しています。
シェルスクリプトでスペースや改行が含まれたファイルを処理する場合、一般的には find -print0
や xargs -0
を使うのではないかと思います。しかしこれらは POSIX で規定されていません。ただ macOS や FreeBSD や Solaris 11 などでも普通に使えるようなので事実上問題にならない気がします。よってこの記事は必要ありません。お疲れさまでした。
まあ、検索すると UNIX あたりでは実装されてないようですので無視して進めます。
(話長すぎ。解説はいらんから実装を見せろという方はこちら「find.sh」「xargs.sh」)
find -print0 と xargs -0 のおさらい
前提知識として find -print0
と xargs -0
がどのように機能するかを見てみます。まずは find
です。
$ touch "1 2 3"
$ touch "a b c"
-print0
をつけないと以下のように各ファイル名の区切り文字が 0a
(改行 \n
)となります。
$ find . | hexdump -C
00000000 2e 0a 2e 2f 31 20 32 20 33 0a 2e 2f 61 20 62 20 |.../1 2 3../a b |
00000010 63 0a |c.|
-print0
をつけた場合は各ファイル名の区切り文字が 00
(NULL文字 \0
)です。
$ find . -print0 | hexdump -C
00000000 2e 00 2e 2f 31 20 32 20 33 00 2e 2f 61 20 62 20 |.../1 2 3../a b |
00000010 63 00 |c.|
xargs -0
はこの \0
区切りを扱うためのオプションということになります。改行だけでなく引数の扱いも異なっていることに注意してください。
$ printf '1 2 3\na b c\n' | xargs printf '%s\n'
1
2
3
a
b
c
$ printf '%s\n' 1 2 3 a b c # と同等であることがわかる
$ printf '1 2 3\0a b c\0' | xargs -0 printf '%s\n'
1 2 3
a b c
$ printf '%s\n' "1 2 3" "a b c" # と同等であることがわかる
ファイル名にスペースが含まれてるだけであれば引数を改行区切りにして 1 行 1 引数とするだけで良かったのでしょうが、ファイル名には改行が含まれてることすらあります。そのため改行を区切り記号として使うことが出来ません。
$ touch "foo
bar"
$ find . | hexdump -C
00000000 2e 0a 2e 2f 31 20 32 20 33 0a 2e 2f 61 20 62 20 |.../1 2 3../a b |
00000010 63 0a 2e 2f 66 6f 6f 0a 62 61 72 0a |c../foo.bar.|
雑学
ところで改行(や非印刷可能文字)が含まれたファイルを GNU 版の find
や ls
で見ると以下のように表示されるのではないかと思います。
$ find .
.
./1 2 3
./a b c
./foo?bar
$ ls -l
total 0
-rw-r--r-- 1 koichi koichi 0 Mar 2 23:31 '1 2 3'
-rw-r--r-- 1 koichi koichi 0 Mar 2 23:31 'a b c'
-rw-r--r-- 1 koichi koichi 0 Mar 2 23:51 'foo'$'\n''bar'
スペースが含まれたファイル名はダブルクォートでくくられ find
では ?
に文字化けしており ls
の改行はシェルエスケープ($'string'
)形式で表示されます。(シェルエスケープという名前は ls
コマンドのオプション --quote-style=shell-escape
から名前を借りています。)これは実装依存なので Linux でない場合は異なる形式で表示されることがあります。そしてこの出力形式は端末に出力した場合のデフォルトの形式です。端末以外に出力した時はそのまま(オプションで指定する場合は ls -l -N --show-control-chars
)のファイル名で出力されます。つまりシェルスクリプトでパイプやコマンド置換で ls
の出力を処理する場合は画面への表示とは異なりそのままのファイル名であるということです。
$ find . | cat
.
./1 2 3
./a b c
./foo
bar
$ ls -l | cat
total 0
-rw-r--r-- 1 koichi koichi 0 Mar 3 06:31 1 2 3
-rw-r--r-- 1 koichi koichi 0 Mar 3 06:31 a b c
-rw-r--r-- 1 koichi koichi 0 Mar 3 06:51 foo
bar
この ls の出力形式は 実はPOSIX で規定されています。つまり移植性があるのでパースして使うことが一応可能ということです。なぜ一応なのかというとファイル名に改行が含まれている場合に適切に扱うことが難しいからです。そのため BashPitfalls ではこれをパースすべきでない「Why you shouldn't parse the output of ls(1) 」と主張しています。とは言えリンク先にも書いてありますが単一のファイルだけを出力し、そのメタデータ(パーミッションやファイルサイズなど)を取得するのに使うことが出来ます。これは POSIX 準拠でポータブルなメタデータの取得方法でもあります。(stat
コマンドは POSIX で規定されておらずオプションが大きく異なります。)
ちなみにシェルエスケープの $'string'
形式は多くのシェルで文字列として使うことができますが POSIX では規定されておらず dash では対応していません。ただし次期の POSIX 仕様である Issue 8 で標準化される予定です。(いつになるのか不明ですが)
標準入出力でデータを渡さないなら簡単
まず根本的な話として、改行が含まれたファイル名を扱えないのは、標準入出力で 1 行 1 データとしてファイル名を渡すから発生している問題であるということです。外部コマンドを使う場合は一般的に標準入出力でデータを渡すことになりますが、シェルスクリプトで実装する場合はそのような制約はありません。パイプラインやコマンド置換を使わず、シェル関数に変数や引数でデータを渡しているならば何も難しいことはないのです。例えば次のようなコードによりスペースや改行が含まれたファイル名を普通に処理することが出来ます。
#!/bin/sh
func() {
printf "[%s]\n" "$1"
}
for file in *; do
func "$file"
done
$ ./find.sh
[bar
]
[find.sh]
[foo ]
この記事で作成するコードは途中の処理でパイプラインを使ってはいますが、最後の処理では位置パラメータを配列変数とみなして使い、引数でシェル関数にファイル名を渡しています。これはデータのやり取りを標準入出力で行いパイプで処理をつなげるというシェルスクリプトらしいやり方が必ずしもベストプラクティスではないということを示す例でもあります。
もし find
コマンドのようにディレクトリ以下も再帰的に探索したい場合は再帰を使って処理を書くと良いでしょう。find
コマンドを使うより長くはなりますが、ファイル探索コードと考えれば特に長くもない普通のコードです。
find -print0
相当の実装
シェルは(zsh を除き) \0
を変数に入れることはできませんが printf
を使うことで \0
を出力することはできます。
$ printf '1 2 3\0' | hexdump -C
00000000 31 20 32 20 33 00 |1 2 3.|
そのため find -print0
相当は printf
を使うことで簡単に実現することが出来ます。(念の為ですが、あくまで出力データの話であって find
の機能すべてを実装するのが簡単という話ではないです。)
find.sh 実装例
#!/bin/sh
for file in *; do
[ -e "$file" ] || continue
printf "%s\0" "$file"
done
$ ./find.sh | hexdump -C
00000000 62 61 72 0a 00 66 69 6e 64 2e 73 68 00 66 6f 6f |bar..find.sh.foo|
00000010 20 00 | .|
$ find * -print0 | hexdump -C
00000000 62 61 72 0a 00 66 69 6e 64 2e 73 68 00 66 6f 6f |bar..find.sh.foo|
00000010 20 00 | .|
解説
[ -e "$file" ] || continue
わざわざ存在チェックをしているのは、空ディレクトリの場合にパターンそのものが出力されてしまうためです。例えば abc
というファイルがある時に echo a*
を実行すると abc
と出力されますが、マッチするファイルがない場合は a*
という文字列が出力されてしまいます。しかしながら本当にa*
というファイルが存在する可能性もあるので、存在チェックを行ってマッチした結果のファイル名なのかマッチしなかった結果の文字列なのかを区別しています。シェルスクリプトの初見殺しの一つです。なおマッチしない場合の挙動を変更して空にしたりエラーにできるシェルもあります。
xargs -0
相当の実装
シェルスクリプトは \0
を扱えないので xargs -0
相当を実現するには od
コマンドを使って文字列として処理する必要があります。もちろん od
は POSIX で規定されているコマンドです。
xargs.sh 実装例
#!/bin/sh
set -eu
MAX=100
[ "${ZSH_VERSION:-}" ] && setopt shwordsplit
encode_arg() {
od -A n -t o1 -v
}
each_arg() {
buf=''
while IFS= read -r line; do
set -- $line
while [ $# -gt 0 ]; do
case $1 in
000) printf '%s\n' "$buf"; buf='' ;;
*) buf="$buf\\$1" ;;
esac
shift
done
done
}
decode_arg() {
set -- "$1"
while IFS= read -r arg; do
arg=$(printf "${arg}_")
set -- "$@" "${arg%_}"
[ "$#" -gt "$MAX" ] || continue
"$@"
set -- "$1"
done
[ $# -le 1 ] || "$@"
}
func() {
i=0
echo "func()"
while [ $# -gt 0 ] && i=$((i+1)); do
printf ' %d: [%s]\n' "$i" "$1"
shift
done
}
encode_arg | each_arg | decode_arg func
実行例
$ ./find.sh | ./xargs.sh
func()
1: [bar
]
2: [find.sh]
3: [foo ]
4: [xargs.sh]
解説
MAX=100
xargs
は可能な限り複数の引数をまとめて呼び出します。その最大の引数サイズは getconf ARG_MAX
で得られる・・・とよく書いてある割になにか違うようで実際には以下の「Size of command buffer we are actually using」の値のようです。(すみません、ちゃんと調べてないです。)
$ xargs --show-limits -r < /dev/null
Your environment variables take up 3945 bytes
POSIX upper limit on argument length (this system): 2091159
POSIX smallest allowable upper limit on argument length (all systems): 4096
Maximum length of command we could actually use: 2087214
Size of command buffer we are actually using: 131072
Maximum parallelism (--max-procs must be no greater): 2147483647
最大の引数サイズを検出する処理はこの記事で作成する xargs.sh
にとって本質的ではないので単純に最大 100 個の引数をまとめて呼び出すようにしています。なお最大引数のサイズはシェルの実装によるとは思いますがシェル関数には当てはまらないようです。100 MBの引数でも問題なく実行できました。
[ "${ZSH_VERSION:-}" ] && setopt shwordsplit
zsh ではデフォルトで単語分割を行わないので 他のシェル(POSIX の仕様)に合わせています。
encode_arg()
./find.sh
の出力(\0
区切りのファイル名)を 8 進数文字列に変換する関数です。
$ ./find.sh
bar
find.shfoo xargs.sh
$ ./find.sh | od -A n -t o1 -v
142 141 162 012 000 146 151 156 144 056 163 150 000 146 157 157
040 000 170 141 162 147 163 056 163 150 000
-v
オプションがないと同じ文字が多数連続した場合に *
に置き換えられてしまうので注意してください。気が付きにくいバグとなります。
$ printf '%40s' | od -A n -t o1
040 040 040 040 040 040 040 040 040 040 040 040 040 040 040 040
*
040 040 040 040 040 040 040 040
一部の環境(BusyBox を採用している OpenWrt 等)では od
コマンドがない場合があります。その場合は代わりに hexdump
を使用することも出来ます。
each_arg()
encode_arg
関数で出力した 8 進数文字列を 000
で改行し値の頭に \
を入れる処理です。以下のような出力が得られます。これは printf
で解釈することができるエスケープシーケンスです。ちなみに 16 進数を使ってないのは \x
が POSIX で規定されておらず、実際に dash 等が対応していないからです。
\142\141\162\012
\146\151\156\144\056\163\150
\146\157\157\040
\170\141\162\147\163\056\163\150
set -- $line
変数をクォートでくくっていないため単語分割が行われます。単語分割と set
を利用して 142 141 162 012
のような文字列を位置パラメータ($@
)にセット、つまり空白文字(IFS
変数にセットされている文字)で分割しています。
補足ですが単語分割のルールは複雑です。例えば IFS=","
とすることで 1,2,3
のようなカンマ区切りの CSV を各フィールドに分割することが出来ます。フィールドに値が入っていなくても問題ありません。例えば ,,3
の場合は、$1: ""
, $2: ""
, $3: "3"
となります。しかし <TAB><TAB>3<TAB><TAB>5
のようなタブ区切りの TSV は $1: "3"
, $2: "5"
となり期待通りには解釈されません。
単語分割の詳細な仕様は以下を参照してください。日本語でも何を言っているのかさっぱりです(笑)
- https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_06_05
- https://www.ibm.com/support/knowledgecenter/ja/ssw_aix_72/osmanagement/korn_shell_field_split.html (日本語)
- https://linuxjm.osdn.jp/html/GNU_bash/man1/bash.1.html#lbBF (以下の文章、これが一番マシかも)
シェルは IFS のそれぞれの文字を区切り文字として扱い、 ほかの展開の結果をこれらの文字によって単語に分割します。 IFS が設定されていないか、その値がデフォルト値の <スペース><タブ><改行> と全く同じならば、前段の展開の結果の先頭や末尾の <スペース>, <タブ>, <改行> の並びは無視され、 先頭と末尾以外の IFS 文字の並びで単語が区切られます。 IFS の値がデフォルト以外のときに、 スペース や タブ という空白文字の並びが単語の先頭と末尾で無視されるのは、 その空白文字が IFS の値に含まれるとき ( IFS の空白文字の一つであるとき) だけです。 IFS に含まれ、 IFS 空白文字ではない文字は全て、隣接する任意の IFS 空白文字と一緒になってフィールドの区切りとなります。 IFS 空白文字の列も区切り文字として扱われます。 IFS の値が空文字列であれば、単語分割は全く行われません。
変数をクォートでくくらない場合はこの後にパス名展開(*
や ?
等の展開)が行われます。これらの機能を意図的に使用したい場合もありますが多くの場合は不要なはずです。予期せぬ結果を引き起こすので必要がない限り変数はクォートでくくるようにしましょう。ShellCheck を使えば簡単にクォート漏れをチェックすることが出来ます。
printf '%s\n' "$buf"
printf
の代わりに echo
を使用しないように注意してください。$buf
の中にバックスラッシュが入ってる場合にエスケープシーケンスとして解釈される場合があります。echo
挙動は実装(シェルや OS)によって異なります。なお printf
は一部のシェル(mksh
, posh
)でシェルビルトインではないため非常に遅くなります。mksh (及び ksh, zsh)ではシェルビルトインとして似たような機能を持つ print
というコマンドがあるためそちらを使うと高速化出来ます。
decode_arg()
上記のエスケープシーケンスをデコードしつつ複数の引数の塊を位置パラメータ($@
)にセットしてからコールバック関数(func
)を呼び出します。
arg=$(printf "${arg}_")
デコード処理です。末尾に _
をつけているのは、コマンド置換で末尾の連続する改行が削除されないようにするためです。これがないと arg=$(printf '\012\012\012')
が 空文字になってしまいます。シェルスクリプトの初見殺しの一つです。
set -- "$@" "${arg%_}"
意外と知られてない(気づきにくい)気がするので説明すると set
を使うことで現在の位置パラメータ($@
)に値をセットすることが出来ます。位置パラメータは POSIX 準拠シェルで唯一利用可能な配列として使うことが出来ます。位置パラメータは関数毎に存在するのでローカル変数の代わりとしても使うことができます。POSIX 準拠シェルで再帰処理を行う際にも重要になるテクニックです。
応用例 /proc/PID/cmdline
の読み取り
Linux などで採用されいている procfs はプロセスの情報をファイルの形で提供しているので限られたファイル操作しか行えないシェルスクリプトでもさまざまな情報の取得が可能です。/proc/PID/cmdline
は指定したプロセスのコマンドとその引数を取得することが出来るファイルですが、その引数は \0
区切りとなっています。ps
コマンドを使えばわかるのでは?と思うかもしれませんが、以下のように改行が取り除かれ引数も正しく解釈できません。以下の -x
ははたして何に対する引数でしょうか?
$ sh -c 'echo "foo
bar"; ps $$' -x
PID TTY STAT TIME COMMAND
5925 tty1 S 0:00 sh -c echo "foo bar"; ps $$ -x
実装コードは MAX
に関する処理をなくしただけで基本的に同じです。気まぐれで一つの関数に結合してみました。
#!/bin/sh
set -eu
[ "${ZSH_VERSION:-}" ] && setopt shwordsplit
read_cmdline() {
od -A n -t o1 -v | {
buf=''
while IFS= read -r line; do
set -- $line
while [ $# -gt 0 ]; do
case $1 in
000) printf '%s\n' "$buf"; buf='' ;;
*) buf="$buf\\$1" ;;
esac
shift
done
done
} | {
set -- "$1"
while IFS= read -r line; do
arg=$(printf "${line}_") && arg=${arg%_}
set -- "$@" "$arg"
done
"$@"
}
}
func() {
i=0
echo "func()"
while [ $# -gt 0 ] && i=$((i+1)); do
printf ' %d: [%s]\n' "$i" "$1"
shift
done
}
read_cmdline func
使用例
$ ps 4698
PID TTY STAT TIME COMMAND
4698 tty1 S 0:00 socat UNIX-LISTEN:/var/run/docker.sock,fork,group=docker,umask=007 EXEC:npiperelay.exe -ep -s //./pipe/docker_engine,nofork
$ ./cmdline.sh < /proc/4698/cmdline
func()
1: [socat]
2: [UNIX-LISTEN:/var/run/docker.sock,fork,group=docker,umask=007]
3: [EXEC:npiperelay.exe -ep -s //./pipe/docker_engine,nofork]
余談
ということで前回の記事が予想外にウケてしまったので調子に乗った続編でした。POSIX で規定されているシェルとコマンドの機能は貧弱ですがやってみると意外となんとかなるということがわかっていただけたでしょうか?シェルスクリプトによるプログラミングをやればやるほど、不可能に思えることが POSIX 準拠のコマンドだけで実現できるので、本当にプリミティブなものだけを標準化しているということがわかります。もちろん出来るからと言ってやったほうが良いと言うつもりはありません。見ての通り POSIX シェルとコマンドの範囲だけでやろうとするとこのようなコードが必要なりますし、実行速度も遅く開発コストも上がるので万人に進められるものではありません。本当にそうすべき理由がある場合にやるべきことです。
私にとってのやるべき理由は ShellSpecの開発です。これは POSIX 準拠のシェルスクリプト用のユニットテストフレームワークです。(前回の記事の最後の、シェルスクリプトでもテストをしましょうというのはこの伏線です ^^;)そして更に元をたどるとどの環境でも動作するシェルスクリプトによるアプリケーションとライブラリを開発したかったからです。POSIX シェルは殆どの環境でインストールされているため、シェルスクリプトで開発すると事前準備をすること無くビルドも不要でどこでもすぐに動くアプリケーションを作ることが出来ます。
ShellSpec は移植性を重視しており POSIX よりもさらにきつい制限で開発しています。高い移植性を実現するためには依存コンポーネント(外部コマンド)は可能な限り最小にする必要があります。そのためシェルスクリプトで実装可能な処理は POSIX コマンドであっても使用していません。例えば awk
や sed
すら使っていません。それらは複雑な機能を持っており複数の実装が存在します。複数の実装が存在するということは互換性問題が発生する可能性があるということです。さすがに長い年月使われ続けているので POSIX に反する仕様やバグはそうそうないと思いますが、それでも BusyBox のように POSIX コマンドの仕様に完全に準拠していないものが存在します。(元々 BusyBox は組み込み向けに最小限の機能を実装するプロジェクトです。)シェルだけでも複数の実装に対応するのが大変なので外部コマンドは極力使わないようにした結果、シェルスクリプトだけで実現するという技術が生まれました。この記事や前回の記事等は ShellSpec で使われてる技術を抜き出したものなのです。
将来的にはこの技術を応用したシェル関数ライブラリを作ってオープンソースで公開したいと思っています。移植性が高いからシェルスクリプトを POSIX 準拠にしましょうと言ったところで、それが大変であれば誰もやらないと思いますが、シェル関数ライブラリがあれば誰でもより簡単に作ることが可能になります。シェルスクリプトを世の中から無くすのが不可能な以上(誰が POSIX と Linux ディストリから sh を消し去ることができるというんです?)シェルスクリプトをより使いやすくするしかありません。それが私の目標の一つです。シェル関数ライブラリのいくつかはすでに私の個人リポジトリで公開しています。その中でもお気に入りなのが getoptions でシェルスクリプトで面倒なオプション解析を簡単に実装することができるライブラリです。使い捨てシェルスクリプトをささっと実装したものはいいけれど、少しコマンドライン引数にこだわりたくなったら面倒になった。(それだけのために)他の言語で実装すればよかった。というよくある問題を解決することが出来ます。
前回の記事は Qiita の LGTM やはてブしていただいてとてもありがたかったですが、GitHub のスターだともっとありがたいです。気に入ったらで良いのでスターをつけていただけると嬉しいです。特に ShellSpec は シェルスクリプト用のテストフーレームワークとして bats (bats-core)、shUnit2と争えるレベルにまで普及するのを狙っています。
ということで余談という名の本編を書いて満足したので終わります。