シェルスクリプトは環境依存が激しいから……
などとよく言われ、敬遠される。それなら共通しているものだけ使えばいいのだが、それについてまとめているところがなかなかないので作ってみることにした。
「どの環境でも使える=POSIXで定義されている」と定義
「どの環境でも使える」とは、なかなか定義が難しい。あまりこだわりすぎると「古いものも含め、既存のUNIX全てで使えるものでなければダメ」ということになってしまう。しかし、私個人としては 今も現役(=メンテナンスされている)のUNIX系OSで使いまわせること にこだわりたい。
とはいっても全てのOSやディストリビューションについて調べられるわけではないので、この記事では基本的に最新のPOSIXで定義されていることをもって、どの環境でも使えると判断するようにした。(飽くまで「基本的に」ということで)
従って、互換性確保のため、シェルの中で使ってよい機能は Bourneシェルの範囲 ということにする。(bash,ksh,zsh,あるいはcsh等の拡張機能は使わないようにする)
随時バージョンアップ予定
新しいことを発見したり、教わったりしたら、随時この記事をバージョンアップしていこうと思うので、ツッコミ歓迎。
各論
最終行の改行を省略したシェルスクリプトファイルにすべきではない
シェルスクリプトの最後の行だからといって、行末のLF(0x0A)を省略するのは止めるべきだ。それは環境によって異なる動作を引き起こす原因になり得る。
例えば次のようにして、ヒアドキュメントセクションの終了宣言行で終わるシェルスクリプトを作ってみる。
$ printf '#! /bin/sh\n' >> test.sh
$ printf 'cat <<HEREDOC\n' >> test.sh
$ printf ' hoge\n' >> test.sh
$ printf 'HEREDOC' >> test.sh
$ chmod +x test.sh
$
コードを見ればわかるように最後の行にだけ行末にLF(0x0A)を付けていないわけだが、一部の環境でこれを実行すると次のようになってしまう。
$ ./test.sh
hoge
HEREDOC$
ヒアドキュメントセクションの終了文字列と解釈されずに表示されてしまうのだ。他にも予期せぬ動作を招く恐れがあるので、最終行でもちゃんと行末には改行を付けよう。
シェルパターン
シェルパターンとは、DOSで言うならワイルドカードといえば話が早いかもしれない。しかしUNIXのそれはもっと多機能で、ファイル名指定時のみならずcase文の条件指定時にも使えるし、何よりimage[1-9][0-9][0-9].jpg
などと指定すればそのディレクトリーの中に存在する、"image100"から"image999"までのファイルを一括指定できるなど、正規表現ほどではないにしろ表現力が高いことが特長だ。
しかしこのブラケットに要注意。ブラケット記号の中に列挙した文字「以外」を表す文字は、正規表現で馴染み深い^
ではなく、!
である。
^
は一応POSIXでも言及しているが、全ての実装で使えるとは限らない。この事実が厄介の元になっており、逆に言えば^
を「以外」の意味として解釈する環境もあれば、通常文字としてそのまま解釈する環境もあるということだ。
従って、シェルパターンにおけるブラケットの中で^
自身を文字として指定したければブラケット内の2文字目以降に記述すべきである。
シェル変数
まず、配列は使えない。従ってPIPESTATUSも使えない。
変数の中身を部分的に取り出す記述に関して使っても大丈夫なものに関しては、POSIXのページ(2.6.2)を見るとまとまっている。
PIPESTATUS的な変数が必要な場合
例えばPIPESTATUSに依存したシェルスクリプトが既にあって、それをどの環境でも使えるように書き直したいと思った場合、実は可能だ。詳しいやり方については、別記事「PIPESTATUSさようなら」を参照してもらいたい。
スコープ
→local修飾子を参照
正規表現
これはAWK
、grep
、sed
等、コマンドによっても使えるメタキャラは違うし、grep
なら-E
オプションを付けるかどうかでも違うし、さらにGNU版でしか使えないものもあるので注意が必要。(*BSD上でもGNU版が採用されている場合もある。→grep参照)
しかし、 正規表現メモ というスバラシいまとめページがあるのでここを見れば、使っても互換性が維持できるメタキャラがすぐわかる。
え、シェル変数の正規表現?それは一部シェルの独自拡張なので論外。
→ロケールも参照
ただし、文字クラスは使わない方が無難
[[:alnum:]]のように記述して使う「文字クラス」というものがある。これは正式名称をPOSIX文字クラスといい、その名のとおりPOSIX準拠であるのだが、一部の実装ではうまく動いてくれない。(Raspberry PiのAWKなど)
まぁ、それはPOSIXに準拠してないそっちの実装が悪いといってしまえばそれまでなのだが、そもそも設定されているロケールによって全角を受け付けたり受け付けなかったりして環境の影響を受けやすいので使わない方がよいだろう。
乱数
乱数を求めたい時、シェル変数のRANDOMを使うのは論外。それなら、とAWKのrand関数とsrand関数を使えばいいやと思うかもしれないがちょっと待った!
論より証拠。FreeBSDで次の記述を何度も実行してみれば、非実用的であることがすぐわかる。
$ for n in 1 2 3 4 5; do awk 'BEGIN{srand();print rand();}'; sleep 1; done
0.0205896
0.0205974
0.0206052
0.020613
0.0206209
つまり動作環境によっては乱数としての質が非常に悪いのだ。AWKが内部で利用しているOS提供のrand(3)とsrand(3)を、FreeBSDは低品質だったオリジナルのまま残し、新たにrandom(3)という別の高品質乱数源関数を提供することで対応しているのが理由なのだが。(Linuxではrand(3)とsrand(3)を内部的にrandom(3)にしている)
/dev/urandomを使うのが現実的
ではどうすればいいか。POSIXで定義されているものではないが、/dev/urandom
を乱数源に使うのが現実的だと思う。例えば次のようにしてod、sedコマンドを組み合わせれば0~4294967295の範囲の乱数が得られる。
$ od -A n -t u4 -N 4 /dev/urandom | sed 's/[^0-9]//g'
最後のsedは、なぜtr -Cd '0-9'
にしないのか。理由は、→trコマンド参照
/dev/urandomをどうしても使いたくない場合
乱数の品質は/dev/urandomほど高くないものの、代替手段はある。psコマンドの結果は実行するたびに必ず変化するのでこれを種として取り入れる。
具体的には、プロセスID、実行時間、CPU使用率、メモリ使用量の各一覧あたりが刻々と変化するのでこれらを取得するとよいだろう。更に、現在日時も加え、これらに基づいて2^32未満の範囲でAWKのsrand()に渡す乱数の種を生成しているのが下記のコードだ。
LF=$(printf '\\\n_');LF=${LF%_} # sedで改行を扱うための定義
(ps -Ao pid,etime,pcpu,vsz; date) | # 乱数源(プロセス情報一覧+日時)
od -t d4 -A n -v | # 数値化する
sed 's/[^0-9]\{1,\}/'"$LF"'/g' |
grep '[0-9]' |
tail -n 42 | # 100000000未満の数字を
sed 's/.*\(.\{8\}\)$/\1/g' | # 42個まで用意(2^32未満にするため)
awk 'BEGIN{a=-2147483648;} # # 上の値を足してsigned long値を作る
{a+=$1;} #
END{srand(a);print rand();}'
ロケール
ロケール環境変数によって動作が変わる可能性がある
コマンドによっては、ロケール環境変数(LANG
やLC_*
)の内容によって動作が変わるものがある(環境によっては変わらないものもある)。具体的に何が変わるかといえば、主に文字列長の解釈や、出力される日付である。下記にそれらをまとめてみた。
-
ロケール環境変数(
LANG
やLC_*
)の内容によって、全角文字を半角の相当文字と同一扱いしたり、全角文字の文字列長を1とするもの- AWKコマンド、grepコマンド、sedコマンド等の正規表現(
[[:alnum:]]
、[[:blank:]]
等々のキャラクタークラスや、+
、\{n,m\}
などの文字数指定子) - AWKコマンドの文字列操作関数(length、substr等)
- wcコマンドの単語数(
-w
オプション) - などなど
- AWKコマンド、grepコマンド、sedコマンド等の正規表現(
-
ロケール環境変数(
LANG
やLC_*
)の内容によって、デフォルトのフィールド区切り文字のが変わるもの- sort……
LANG
が設定されていると、その文字コードにおける全角スペースもデフォルトでスペース区切りと見なされる実装がある。
- sort……
-
ロケール環境変数(
LANG
やLC_*
)の内容によって、出力される日付の書式が変わるもの- dateコマンド
- lsコマンド
- などなど
-
LC_MONETARY
やLC_NUMERIC
の影響を受けるもの- sort……
-n
を指定した場合に、桁区切りのカンマの影響を受けたり受けなかったりする。
- sort……
ロケール環境変数の設定値は環境によってまちまち
これも認識しておくべきことである。Linuxの多くのディストリビューションではインストール時に日本語を選択すると、日本語のロケールが設定されるが、皆が皆そうしているとは限らない。
対策
全ての環境で動くようにするのであれば、ロケール設定無しの状態、すなわち英語で使うべきであろう。
# 方法1. envコマンドで全てのロケールも含め環境変数を無効にした状態で実行する。
echo 'ほげHOGE' | env -i awk '{print length($0)}'
# 方法2. LC_ALL=C及びLANG=Cを設定し、ロケール環境変数を全てCロケールにして実行する。
echo 'ほげHOGE' | LC_ALL=C LANG=C awk '{print length($0)}'
# 方法3. 予めLC_ALL=CとLANG=Cを設定しておく
export LC_ALL=C
export LANG=C
echo 'ほげHOGE' | awk '{print length($0)}'
ロケール系環境変数には現在、LANGUAGE
とLC_*
とLANG
がある。このうち各種LC_*
についてはLC_ALL
の設定によって全て上書きされるが、LANG
には効かないのでLC_ALL
とLANG
を両方とも"C"にする。最初に列挙したLANGUAGE
は最も強い効力を持つようだが、LANG
やLC_ALL
に"C"が設定されている場合は無視されるということである。→GNU gettextドキュメント2.3.3項
ちなみに、いにしえのexport
は=
を使えないということだが、今どきのPOSIXのmanによれば使えることになっている。
$((式))
よく「expr
を使え」というが、$((式))
はPOSIXでも指定されているので使ってもよいものとする。
ただ、数字の頭に"0"や"0x"を付けると、それぞれ8進数、16進数扱いされるのでexpr
との間で移植をする場合は気を付けなければならない。(exprは数字の先頭に0が付いていても常に10進数と解釈される)
$ echo $((10+10)) # 10進数の10たす10進数の10
20
$ echo $((10+010)) # 10進数の10たす8進数の10
18
$ echo $((10+0x10)) # 10進数の10たす16進数の10
26
この問題は、異なる実装のAWK間にもあるので注意。→AWKコマンド参照
"["コマンド
→testコマンド参照
AWKコマンド
-0(マイナス・ゼロ)
FreeBSD 9.xに標準で入っているAWKでは、-1*0
を計算すると-0
という結果になる。
$ awk 'BEGIN{print -1*0}'
-0
$
ところがこの挙動は同じFreeBSDでも10.xでは確認されていないし、GNU版AWKでも起こらないようだ。
このようにして、0であっても-0
という二文字で返してくる場合のある実装もあるので、0を1文字と決め付けていると不具合を起こす場合がある。
マイナスを取り去るには
結果に0を足せばよいようだ。
$ awk 'BEGIN{print -1*0+0}'
0
$
0始まり即値の解釈の違い
頭に0が付いている数値を即値(プログラムに直接書き入れる値)として与えると、それを8進数と解釈するAWK実装もあれば10進数と解釈するAWK実装もある。
$ awk 'BEGIN{print 010;}'
10
$
$ awk 'BEGIN{print 010;}'
8
$
どこでも同じ動きにしたければ、文字列として渡せばよい。すると10進数扱いになる。
$ awk 'BEGIN{print "010"*1;}'
10
$ echo 010 | awk '{print $1*1;}'
10
$
length関数では配列の要素数を調べられない場合がある
今どきの大抵のAWKは、
awk 'BEGIN{split("a b c",chr); print length(chr);}'
とやると、きちんと要素数(この例では3)を返してくれる。が、AWKの種類によっては、これに対応しておらずエラー終了してしまうものがある。
なので、例えば次のarlen()のように、配列の要素数を数える関数を自作してそちらを使うべきだ。
awk '
BEGIN{split("a b c",chr); print arlen(chr);}
function arlen(ar,i,l){for(i in ar){l++;}return l;}
'
幸いAWKの配列変数は、 参照渡し なので要素の中身が膨大だとしてもそれは影響しない。(要素数が大きい場合はやはり負担がかかると思うのだが……)
「length関数が使えるなら使いたい!」というワガママなアナタは、こうすればいい。
# シェルスクリプトの最初で配列にlength使ってエラーにならないことを確認
case "$(awk 'BEGIN{a[3]=3;a[4]=4;print length(a)}' 2>/dev/null)" in
2) arlen='length';; # ←正しい数(=2)が返ってくれば"length"
*) arlen='arlen' ;; # ←正しい数が返ってこねば独自関数"arlen"
esac
awk '
BEGIN{split("a b c",chr); print '$arlen'(chr);} # ←判定結果に応じて適宜選択される
function arlen(ar,i,l){for(i in ar){l++;}return l;}
'
printf(およびsprintf)関数
→printfコマンド
rand関数,srand関数は使うべきではない
詳しくは「乱数」のセクションを参照。
アクション記述を省略するなら;
を付けるべき
AWKの基本文法は、各行に対するパターンとそれにマッチした時のアクションの記述から成っている。そしてアクションは省略可能で、省略した場合は{print $0;}
を指定されたものと解釈されることになっている。
しかし、アクションを省略するとエラーになってしまう実装がある。RaspbienやSolarisに載っているAWKでは次のようになってしまう。
$ echo HOGE | awk '1 END{exit;}'
awk: line 1: syntax error at or near END
$
回避策は、パターンを単独の行で記述するか、パターンの直後にセミコロンを付ければよいが、ワンライナーでも使えるのは後者だ。
$ echo HOGE | awk '1; END{exit;}'
HOGE
$
アクションは省略すべきではないが、パターンは省略しても大丈夫だ。
主に気を付けるべきは、gensub関数が使えないこと
GNU版の独自拡張がいくつかあるが、中でも注意すべき点はgensub関数がそれであること。互換性を優先するなら多少不便かもしれないが関数とは言い難いインターフェースのsub関数やgsub関数を使う。あとは、GNU AWKの--posix
オプションに関するまとめ@kbkさんのページを見ればだいたいよいと思う。
正規表現では{数}
が使えない
AWKの正規表現は、繰り返し指定が苦手。"?"(0~1個)と"*"(0個以上)と"+"(1個以上)は使えるが、2個以上の任意の数を指定するための{数}
がない。GNU AWKにはあるんだけどね。その他は、正規表現メモさんの記述を見るのが便利。
整数の範囲
例えば、あなたの環境のAWKは次のように表示されるだろうか?
$ awk 'BEGIN{print 2147483648}'
2.14748e+09
$
上記の例は、0x7FFFFFFF(符号付き4バイト整数の最大値)より大きい整数を扱えないAWK実装である。このようなことがあるので桁数の大きな数字を扱わせようとする時は注意が必要だ。
ロケール
→ロケールを参照
bcコマンド
POSIXに明記されているはずのコマンドなのだが、残念ながら一部のOSでは最小構成インストールでは省略されているものがある。Debian系Linuxディストリビューションの一部(Raspbianなど)やCygwinで確認している。
どの環境でも使えるシェルスクリプトにしたければ、bcコマンドを使わないという方法を取らざるを得ない。ただし、さすがにPOSIXのコマンドだけあり、省略されるOSでも(apt等により)大抵パッケージとして用意されているので、コメントやドキュメントでbcコマンドをインストールするよう促すのも手であろう。
dateコマンド
元々の機能が物足りないが故か、各環境で独自拡張されているコマンドの一つだが、互換性を考えるなら使えるのは
-u
オプション(=UTC日時で表示)+フォーマット文字列
で表示形式を指定
の2つだけと考えるが無難だと思う。フォーマット文字列中に指定できるマクロ文字の一覧は、POSIXのmanの"Conversion Specifications"の段落にまとめられている。
いろいろあるのだけど、UNIX時間(エポック秒とも呼ばれるUTC 1970/1/1 00:00:00からの秒数)との相互変換は無いようで、これが非常に残念。これさえできれば何とでもなるのに……。
だがこんなこともあろうかと、相互変換コマンドutconvを作っておいた。もちろんシェルスクリプト製だ。詳しくは、シェルスクリプトで時間計算を一人前にこなすを参照してもらいたい。
duコマンド
-h
オプションをつけた時の1列目表示が環境によって異なる。
$ du -h /etc | head -n 10
112K /etc/bash_completion.d
12K /etc/abrt/plugins
4.0K /etc/statetab.d
4.0K /etc/dracut.conf.d
28K /etc/cron.daily
4.0K /etc/audisp
4.0K /etc/udev/makedev.d
36K /etc/udev/rules.d
48K /etc/udev
8.0K /etc/sasl2
$ du -h /etc | head -n 10
118K /etc/defaults
2.0K /etc/X11
372K /etc/rc.d
4.0K /etc/gnats
6.0K /etc/gss
30K /etc/security
40K /etc/pam.d
4.0K /etc/ppp
2.0K /etc/skel
144K /etc/ssh
違いがわかるだろうか? 1列目(サイズ)が、前者は左揃えなのに後者は右揃えなのだ。なのでどの環境でも動くようにするには、1列目であっても行頭にスペースが入る可能性を考慮しなければならない。
例えば1列目の最後に単位"B"を付加したいとしたら、下記の1行目はダメで、2行目の記述が正しい。
$ du -h /etc | sed 's/^[0-9.]\{1,\}[kA-Z]/&B/' # ←これでは不完全
$ du -h /etc | sed 's/^ *[0-9.]\{1,\}[kA-Z]/&B/' # ←こうするのが正しい
$ du -h /etc | awk '{$1=$1 "B";print}' # ←折角の桁揃えがなくなるがまぁアリ
このようにして1列目にインデントが入るコマンドは結構あるし、インデントの幅も環境によりまちまちなので注意が必要だ。(例、 uniq -c
、wc
などなど)
echoコマンド
結論から言うと、どこでも動くようにしたい場合、下記の項目に1つでも当てはまる時はechoコマンドは使うべきではない。
- 先頭がハイフンで始まる可能性がある文字列
- エスケープシーケンスを含む可能性のある文字列
理由は次のとおりである。
対応しているオプションが異なる
例えば、Linuxのechoコマンドは-e
、-n
オプションに対応しており、第一引数に指定すれば、それを文字列として表示せずに動作を変える。一方、FreeBSDのechoコマンドは-n
のみに対応しており、また一方、AIXのechoコマンドはどちらにも対応していない。
というようにバラバラだからだ。
エスケープシーケンスに反応する実装がある
例えば\n
は改行を意味するエスケープシーケンスであるが、FreeBSDのechoはそのまま“\n
”と表示する。一方、Linuxのechoは-e
オプションが付けられた時のみ改行に置換される。また一方、AIXのechoは常に改行に置換する。
AIXのechoはデフォルトでエスケープシーケンスを解釈するのだ。「それPOSIX的にどうなの?」と困惑するかもしれないが、POSIXのechoのmanにはちゃんとエスケープシーケンスの記述がある。
対策
どんな文字列が入っているかわからない変数を扱う場合(ハイフンで始まらないとかエスケープシーケンスを含まないとわかっているならそのままでよい)、例えば次のようにprintfコマンドを使うなどして回避すること。
#! /bin/sh
for arg in "$@"; do
printf '%s\n' "$arg"
done
envコマンド
今の環境変数の影響を一切受けずにコマンドを呼び出すために、env -i <コマンド名>
のように-iオプションを使って起動したい場合があるが、ここで注意が必要だ。
大抵の実装では呼び出すコマンドパスを見つけるまでは環境変数PATHを覚えていてくれる。しかし一部の実装では-iオプションを付けると、コマンドパスを見つける前に環境変数PATHの内容を消してしまい、指定したコマンド起動に失敗してしまうことがある。
# 多くの実装(外部コマンドのパスを見つけてから消去)
$ env -i awk 'BEGIN{print "OK";}'
OK
$
# 一部の実装(外部コマンドのパスを見つける前に消去するのでエラーになる)
$ env -i awk 'BEGIN{print "OK";}'
env: awk: No such file or directory
$
この問題を防ぐには、既存の環境変数PATHを-iオプションの後ろで改めて指定するとよい。もちろんこの場合、環境変数PATHの値は呼び出し先コマンドに引き継がれることになるので注意すること。
$ env -i PATH="$PATH" awk 'BEGIN{print "OK";}'
OK
$
execコマンド
注意すべきはexecコマンド経由で呼び出すコマンドに環境変数を渡したい時だ。
例えば、execコマンドを経由しない場合はこういう書き方ができる。
name=val awk 'BEGIN{print ENVIRON["name"];}'
これを実行すると、awkはvar
という文字列を表示する。
しかし、次のように書くと何も表示されないシェルがある。
name=val exec awk 'BEGIN{print ENVIRON["name"];}'
理由はというと、一部の環境のexecコマンドは、このようにして設定された環境変数を渡してくれないからだ。もしexecコマンド越しに環境変数を渡したいのであれば、事前にexportで設定しておくこと。
export name=val
exec awk 'BEGIN{print ENVIRON["name"];}'
あるいは、execの後、envコマンドを経由させるのでもよい。
exec env name=val awk 'BEGIN{print ENVIRON["name"];}'
foldコマンド
ファイル指定の-
一般的に、ファイル名として-
を指定すると標準入力の意味と解釈されるが、本コマンドに対しては使わない方がよい。POSIXでもこのコマンドでもこのコマンドについて、そう解釈されると確かに書いてあるのだが、BSDの実装では真面目に"-"というファイルを開こうとしてエラーになってしまう。
grepコマンド
俺は*BSDを使っているから、grepだってBSD版のはず。ここで使えるメタキャラはどこでも使えるでしょ。
と思っているアナタ。果たして本当にそうか確認してみてもらいたい。
$ grep --version
grep (GNU grep) 2.5.1-FreeBSD
Copyright 1988, 1992-1999, 2000, 2001 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
$
なんと、GPL排除に力を入れているFreeBSDのgrepはGNU版だ。関係者によれば、主に速さが理由で、grepだけは当面GNU版を提供するのだという。なので、POSIX標準だと思っていたメタキャラが実はGNU拡張だったということがある。(代表的なものは\+
や\?
や\|
)
POSIX標準grepで使える正規表現は、-E
オプション無しの場合には9.3 BREで規定されているものだけ。-E
オプション付きの場合には9.4 EREで規定されているものだけだ。正規表現メモさんによる日本語解説が分かりやすいかもしれない。
headコマンド
大抵の環境には-c
オプション(ファイルの先頭をバイト単位で切り出す)に対応しているのだが、POSIXでは規定されていないせいか正しく実装されていない環境も存在する。(AIXでは最後に余計な改行コードが付く)
ちなみに、POSIXでもtailコマンドではきちんと規定されているので、headだけ規定されていないのはちょっと不思議だ。
さて、それでは-c
オプションが使えない環境で何とかして同等のことができないものか……。大丈夫、ddコマンドでできる。
dd bs=1 count=$n 2>/dev/null # 標準入力の場合
dd if=/PATH/TO/TARGET_FILE bs=1 count=$n 2>/dev/null # ファイルの場合
ddコマンドは標準エラー出力に動作結果ログを吐くので、head -c相当にするなら 2>/dev/null
などと書いて、ログを捨てること。
iconvコマンド
POSIXに明記されているコマンドなのだが、初版より後に登場した新しいコマンドであり、一部のOSでは後から別途インストールしなければ使えない場合がある。比較的新しい実装としてはFreeBSD 9.0未満などが該当する。
古いUNIX系OSで動かされる可能性も考慮した上でどの環境でも使えるシェルスクリプトにしたければ、iconvコマンドを使わないという方法を取らざるを得ない。ただし、さすがにPOSIXのコマンドだけあり、(port等により)大抵パッケージとして用意されているので、コメントやドキュメントでiconvコマンドをインストールするよう促すのも手であろう。
if [ … ];then ~ else ~ fi構文
たまに、elseの時は何かしたいけどthenの時は何もしたくないということがある。そんな時、then ~ else の間に何も書かないとBash等一部のシェルではエラーを起こしてしまう。
if [ -s /tmp/hoge.txt ]; then
# 1バイトでも中身があれば何もしない
else
# 0バイトだったら消す
rm /tmp/hoge.txt
fi
elifの後もelseの後も同様であるが、Bashでは何かしら有効なコードを置かないといけないのだ。(コメントを書いただけではダメ)
解決策
何らかの無害な処理を書けばいいのだが、一番軽いのはnullコマンドではないだろうか。つまり、こう書けばどの環境でも無難に動くようになるだろう。
if [ -s /tmp/hoge.txt ]; then
# 1バイトでも中身があれば何もしない
:
else
# 0バイトだったら消す
rm /tmp/hoge.txt
fi
別の対策としては、条件を反転してそもそもelse節を使わずに済むようにするのもいいだろう。
ifconfigコマンド
実行中のホストに振られているIPアドレスを調べたい時にこのコマンドを使いたいことがあるが、各環境での互換性を確保するには2つのことに注意しなければならない。
パスが通っているとは限らない。
大抵の場合、ifconfigは/sbinの中にある。しかし 多くのLinuxのディストリビューションでは一般ユーザーにsbin系のパスが通されていない。 だから、このコマンドを互換性を確保しつつ使いたい場合は、環境変数PATHにsbin系ディレクトリー(/sbin
、/usr/sbin
)を追加しておく必要がある。
フォーマットがバラバラ
ifconfigから返される書式が環境によってバラバラである。そこで、IPアドレスを取得するためのシェルスクリプトを別Tipsとして書いてみたので参考にしてもらいたい。(IPv6も対応)
killコマンド
シグナルの種類は名称と番号のどちらでも指定できるわけだが、番号で指定する場合、POSIXのmanによれば、どの環境でも使える番号は下記のもの以外保証されていない。え、たったこれだけ!?
Signal No. | Signal Name |
---|---|
0 | 0 |
1 | SIGHUP |
2 | SIGINT |
3 | SIGQUIT |
6 | SIGABRT |
9 | SIGKILL |
14 | SIGALRM |
15 | SIGTERM |
もちろんシグナルの種類がこれだけしかないわけではない。ただ、 その他のシグナルは名称と番号が環境によってまちまち なのだ。(例えばSIGBUSは、FreeBSDでは10だが、Linuxでは7、といった具合)
なので上記以外のシグナルを指定したい場合は名称("SIG"の接頭辞を略した文字列)で行うこと。使える名称自体は、POSIXの記述でもご覧のとおり、豊富にある。
-l
オプションで番号と名称の関係を調べられるとは限らない
killコマンドで-l
オプションを指定すれば、使えるシグナルの種類の一覧が表示されるのはご存知のとおり。しかし、番号と名称の対応がこれで調べられるわけではない。Linuxだと丁寧に番号まで表示されるが、FreeBSDでは単に名称一覧しか表示されない(一応順番と番号は一致してはいるのだが)。
local修飾子
シェル関数の中で用いる変数を、その関数内だけで有効なローカル変数にする場合に用いる修飾子だが、これはPOSIXでは規定されていない。しかし、関数内ローカルな変数は簡単に用意できる。小括弧で囲ってサブシェルを作ればその中で代入した値は外へは影響しないからだ。
よって、中身を丸ごと小括弧で囲った次のシェル関数で定義したシェル変数$a
、$b
、$c
は、関数終了後に消滅するし、外部に同名の変数があっても中の値を壊すことはない。
localvar_sample() {
(
a=$(whoami)
b='My name is'
c=$(awk -v id=$a -F : '$1==id{print $5}' /etc/passwd)
echo "$b $c."
)
}
mktempコマンド
mktempコマンドはPOSIXで規定されていない! だから、実際に使えない環境がある。
でもシェルスクリプトを本気で使いこなすにはテンポラリーファイルが欠かせず、そんな時に便利なコマンドがmktempなのだが……。どうすればいいだろうか。簡易的な対処と本格的な対処の2種類を用意した。
簡易的なmktemp
簡易的なもの(一意性のみでセキュリティーは保証しない)なら下記のようなコードを追加しておけば作れる。
type mktemp >/dev/null 2>&1 || {
mktemp_fileno=0
mktemp() {
(
filename="/tmp/${0##*/}.$$.$mktemp_fileno"
touch "$filename"
chmod 600 "$filename"
printf '%s\n' "$filename"
)
mktemp_fileno=$((mktemp_fileno+1))
}
}
mktempコマンドの有無を確認し、無ければコマンドと同じ使い方ができるシェル関数を定義するものだ。ただし引数は無視され、必ず/tmpディレクトリーに生成される。
本格的なmktemp
POSIX版mktempコマンドを作ってしまったので、これをダウンロードして使えばよい。
書式はCoreutils版に似せてある。ただし動作パフォーマンス確保のため、/binや/usr/binの中に元々のmktempが存在すればそちらを使う(execする)ようにしてあるので、あまり一般的でないオプションは使わない方がよい。
「どの環境でも使える」という趣旨を理解せず、本投稿に寄せられた1番目のコメントの誘導には乗らない方がよい。
nlコマンド
-w
オプション
POSIXでも規定されている-w
オプションであるが、環境によって挙動が異なるので注意。尚、-w
オプションはPOSIXでデフォルト値が設定されているため、このオプションを記述しなくても同様の問題が起こるので注意!(POSIXの範囲ではないので参考までにということになるが、catコマンドの-n
オプションではこの問題は起こらないようだ。)
-w
とは行番号に割り当てる桁数を指定するものであるが、問題は指定した桁数よりも桁があふれてしまった時である。溢れた場合の規定は定義されていないので、実装によって解釈が異ってしまったようだ。
2つの実装を例にとるが、BSD版のnlコマンドでは、溢れた分の上位桁は消されてしまう。
$ yes | head -n 11 | nl -w 1
1 y
2 y
3 y
4 y
5 y
6 y
7 y
8 y
9 y
0 y
1 y
$
一方、GNU版のnlコマンドでは、溢れたとしても消しはせず、全桁を表示する。
$ yes | head -n 11 | nl -w 1
1 y
2 y
3 y
4 y
5 y
6 y
7 y
8 y
9 y
10 y
11 y
$
行番号数字の直後につくのはデフォルトではタブ("\t
")なので、GNU版では桁数が増えるとやがてズレることになる。BSD版はズレることはない代わりに上位桁が見えないので、何行目なのかが正確にはわからない。
対応方法
AWKコマンドの組み込み変数であるNRを使うとよい。さらに、次のようにしてprintf関数を併用すれば、GNU版nlコマンドと同等の動作をする。
awk '{printf("%6d\t%s\n",NR,$0);}'
ファイル指定の-
一般的に、ファイル名として-
を指定すると標準入力の意味と解釈されるが、本コマンドに対しては使わない方がよい。POSIXでもこのコマンドでもこのコマンドについて、そう解釈されると確かに書いてあるのだが、BSDの実装では真面目に"-"というファイルを開こうとしてエラーになってしまう。
odコマンド
ファイル指定の-
一般的に、ファイル名として-
を指定すると標準入力の意味と解釈されるが、本コマンドに対しては使わない方がよい。POSIXでもこのコマンドでもこのコマンドについて、そう解釈されると確かに書いてあるのだが、BSDの実装では真面目に"-"というファイルを開こうとしてエラーになってしまう。
printf(コマンドおよびAWKの中の関数)
キャラクターコードの即値指定(16進数)
互換性を重視するなら、\xHHという16進表記によるキャラクターコード指定をしてはいけない。これは一部のprintfの独自拡張だからだ。代わりに\OOOという3桁の8進数表記を用いること。
キャラクターコードの即値指定(8進数)
Mac OS X等一部のOS上のprintfでは、\OOO
(OOOは任意の8進数)と同等の表現として\0OOO
(3桁数字の手前に数字の0が付いている)という表現も認められている。しかしこれが厄介な問題を引き起こす。
例えば、\040
に続いて数字の1
を与えたかったら\0401
と記述したいところだが、そうすると一部の環境では8進数で401に相当するコード(実際には0x01のStart Of Heading)を指定されたものと解釈してしまい、環境によって結果が変わってしまうのだ。
# FreeBSDの場合(問題なし)
$ printf '\0401\n'
1
$
# Mac OS Xの場合(数字の1が出てこない!)
$ printf '\0401\n'
$
この問題を回避するには、直後に半角数字が続く場合、その数字自体も\049
のようにエスケープするのが無難だろう。
負数の16進数表現
負の値を16進数に変換すると環境によって結果が異なる。例えば-1を16進数に変換すると次のとおりだ。
# 32ビット実装の場合
$ printf '%X\n' -1
FFFFFFFF
$
# 64ビット実装の場合
$ printf '%X\n' -1
FFFFFFFFFFFFFFFF
$
従って負の値を16進数に変換するのはあまり勧められないが、どうしてもしたいなら下8桁のみを取り出すべきだろう。もちろんその場合、-2147483648より小さい値は扱えない。
psコマンド
現在のpsコマンドは、オプションにハイフンを付けないBSDスタイルなど、いくつかの流派が混ざっているので厄介だ。
-x
オプション
「制御端末を持たないプロセスを含める」という働きであるが、このオプションは使わない方がいい。そもそもPOSIXのmanにはないし、GNU版とBSD版では解釈が異なるっぽい。
例えばCGI(httpd)によって起動されたプロセス上で、-a
も-x
も付けず、自分に関するプロセスのみを表示しようとした場合、前者では表示されるものが後者では-x
を付けた場合に初めて表示されるなどの違いがある。
互換性を重視するなら、大文字である-A
オプションを用いてとにかく全てを表示(-ax
に相当)させる方がよいだろう。
-l
オプションはPOSIXでも明記されているが使うべきではない。
-l
オプションは、lsコマンドの同名オプションのように多くの情報を表示するためのものである。POSIXのmanにも記載されているし、実際主要な環境でサポートされているので使っても問題なさそうだが、使うべきではない。理由は、表示される項目や順序がOSやディストリビューションによってバラバラだからだ。
殆どの場合-o
オプション必須
-l
オプションを付けた場合の表示項目や順序がバラバラだと言ったが、実は付けない場合もバラバラだ。どの環境でも期待できる表示内容といえば、
1列目にPIDが来ること
行のどこかにコマンド名が含まれていること
くらいなものだ。互換性を維持しながらそれ以上の情報を取得しようとするなら、-o
オプションを使って明確に表示させたい項目と順序を指定しなければならない。
-o
オプションで指定できる項目一覧についてはPOSIXのman上の"STDOUT"セクション後半に記されている。(太小文字で列挙されている項目で、現在のところ"ruser"から"args"までが記されている)
補足1.親プロセスID(PPID)の値
Linuxでは、親プロセスIDが0になるのはPID=1のinitだけだ。しかし、FreeBSD等では他の様々なシステムプロセスの親も0になっている。これは、psコマンドの違いというよりカーネルの違いであるが、互換性のあるプログラムを書くときには注意すべきところだ。
補足2.Cygwinのpsコマンド
2016年6月現在、Cygwinやgnupackで用意されているpsコマンドは残念ながらPOSIXのものとは非互換である。Windows配下で使うという事情により特別なものになっているようなのだが、-Aオプションも-oオプションもサポートされていない。manには記述があるのに使えないのは酷いと思うが仕方がない。
そもそもCygwinはPOSIX環境ではないからといって切り捨てるという考え方もあるのだが、対応をするのであればunameコマンドを使ってCygwinで動いていることを検出したら個別対応するコードにするしかない。
sedコマンド
最終行が改行コードで終わっていないテキストの扱い
試しにprintf 'Hello,\nworld!' | sed ''
というコードを実行してみてもらいたい。
$ printf 'Hello,\nworld!' | sed ''
Hello,
world!
$
$ printf 'Hello,\nworld!' | sed ''
Hello,
world!$
と、このように挙動が異なる。最終行が改行コードで終わっていない場合、BSD版は改行を自動的に挿入し、GNU版はしないようだ。
純粋なフィルターとして振る舞ってもらいたい場合にはGNU版の方が理想的ではあるが、すべての環境で動くことを目標にするならBSD版のような実装のsedも無視するわけにはいかない。このようなsedをはじめ、AWKやgrep等、最終行に改行コードが挿入されてしまうコマンドでの対処法を改行無し終端テキストを扱うというTipsにまとめたので見てもらいたい。
使用可能なコマンド・メタキャラ
これも、GNU版は独自拡張されているので注意。
コマンドに関して迷ったらPOSIXのmanを見る。使用可能な正規表現については、正規表現メモさんの記述が
便利。
ファイル指定の-
一般的に、ファイル名として-
を指定すると標準入力の意味と解釈されるが、本コマンドに対しては使わない方がよい。POSIXでもこのコマンドでもこのコマンドについて、そう解釈されると確かに書いてあるのだが、BSDの実装では真面目に"-"というファイルを開こうとしてエラーになってしまう。
ロケール
→ロケールを参照
sortコマンド
→ロケールを参照
tacコマンド or tailコマンドで逆順出力
Tips的な話だが、ファイルの行を最後の行から順番に(逆順に)に並べたい時はtacコマンドやtailコマンドの-r
オプションのお世話になりたいところであろう。しかし、どちらも一部の環境でしか使えないし、もちろんPOSIXにも載っていない。ではどうするか……。そんな時は、次のTipsがお勧めだ。
test("[")コマンド
どんな内容が与えられるかわからない文字列(シェル変数など)の内容を確認する時、最近のtestコマンドなら
# シェル変数$strの内容が"!"ならば"Bikkuri!"を表示する
[ "$str" = '!' ] && echo 'Bikkuri!'
と書いても問題無いものが多い(さすがに$strが"("だった場合ダメなようだが)。しかし、古来の環境では
[: =: unexpected operator
というエラーメッセージが表示され、正しく動作しないものが多い。これは$strに格納されている"!"が、評価すべき文字列ではなく否定のための演算子と解釈され、そうすると後ろに左辺ナシの=
が現れたと見なされてエラーになるというわけである。
testコマンドを用いて、全ての環境で安全に文字列の一致、不一致、大小を評価するには、文字列評価演算子の両辺にある文字列の先頭に無難な一文字を置く必要がある。
# シェル変数$strの内容が"!"ならば"Bikkuri!"を表示する
[ "_$str" = '_!' ] && echo 'Bikkuri!'
もっとも、単に文字列の一致、不一致を評価したいだけなら、testコマンドを使わずに下記のようにcase文を使う方がよい。上記のような配慮は必要ないし、外部コマンド(シェルが内部コマンドとして持ってる場合もあるが)のtestコマンドを呼び出さなくてよいので軽い。
# シェル変数$strの内容が"!"ならば"Bikkuri!"を表示する
case "$str" in '!') echo 'Bikkuri!';; esac
trコマンド
このコマンドは各環境の方言が強く残るコマンドの一種で、無難に作るならなるべく使用を避けたいコマンドだ。
例えばアルファベットの全ての大文字を小文字に変換したい場合、
tr '[A-Z]' '[a-z]'
← System V系での書式(この場合は運よくどこでも動く)
tr 'A-Z' 'a-z'
← BSD系、POSIXでの書式
という2つの書式がある。範囲指定の際にブラケット[~]
が要るかどうかだ。BSD系の場合、ブラケットは通常文字として解釈されるので、これを用いると置換対象文字として扱われてしまう。しかしながら前者のブラケットは置換前も置換後も全く同一の文字なので幸いにしてどこでも動く。従って、このようなケースでは前者の記述をとるべきだろう。
しかし、-dオプションで文字を消したい場合はそうはいかない。
tr -d '[a-z]''
← System V系での書式(これはBSD系、POSIX準拠実装ではNG)
tr 'a-z'
← BSD系、POSIXでの書式
POSIXに準拠してないSystem V実装が悪いと言ってしまえばそれまでなのだが、歴史の上ではPOSIXよりも早いのでそれをいうのもまた理不尽というもの。ではどうすればいいか。
答えは、「sedで代用する」だ。上記のように、全ての小文字アルファベットを消したいという場合はこう書けばよい。
sed 's/[a-z]//g'
しかしながら、改行コードで終わっていないテキストデータを与えると改行を付け足してしまうsed実装があるので、そういう可能性のあるデータを扱いたい場合は更に対策が必要だ。→改行無し終端テキストを扱う
そこまでやるくらいだったら、範囲指定ではなく全部書いてしまえばいいと思うかもしれないが、もちろんそれでもいい。
tr -d 'abcdefghijklmnopqrstuvwxyz'
trapコマンド
→killコマンド参照
whichコマンド
コマンドが存在すれば(パスが通っていれば)そのパスを返してくれるため、コマンドが無ければ無いなりにどの環境でも動くようなシェルスクリプトを書きたい時などに重宝するコマンドだ。しかし、このwhichコマンドがPOSIX標準ではないというオチが待っている。
しかし諦めることはない。POSIXに存在するcommandという名のコマンドに-v
オプションを付けると似た動きをするのでこれを使うとよい。
下記のコードをシェルスクリプトの冒頭に追記しておけば、whichコマンドが存在しない場合のみ、commandコマンドに基づいたシェル関数版whichが登録される。
which which >/dev/null 2>&1 || {
which() {
command -v "$1" 2>/dev/null |
awk 'match($0,/^\//){print; ok=1;}
END {if(ok==0){print "which: not found" > "/dev/stderr"; exit 1}}'
}
}
command -vは組み込みコマンドが指定された場合でもコマンド名自身を返して正常終了するという点がwhichと異なるので、後ろのAWKで挙動を揃えている。
xargsコマンド
改行なし終端データの扱い
次の例を見てもらいたい。
$ printf 'one two threee' | xargs echo
one two
$
単語が3つあるのだから、xargsはechoの後ろに“one”と“two”はもちろん、“three”も付けて実行してくれることを期待するが最後の“three”が無視されてしまっている。じつはこのxargs実装、最後の単語の後にも改行やスペース等の区切り文字を必要とするのである。
こういうxargs実装であっても確実に動作させるようにするには、例えばxargsの直前にgrep ^
などを挿んでデータの終端に確実に改行が付くようにしてやることだ。
$ printf 'one two threee' | grep ^ | xargs echo
one two three
$
空ループの有無
標準入力から入ってきた文字列を引数にしてコマンドに渡すためのコマンドであるが、標準入力から空白以外が含まれた行が1行も渡ってこなかった場合、引数無しでコマンドを実行するxargs実装もあれば、コマンドを実行しないxargs実装もある。
$ printf ' \n\n' | xargs echo 'foo' # FreeBSDの場合
$
$ printf ' \n\n' | xargs echo 'foo' # GNU版(多くのLinux)の場合
foo
$
xargsで呼び出される側のコマンドは引数0個で呼ばれるなど想定していない(Usageを表示したり戻り値0以外にしたりする)ものが多いので、前者の挙動の方が好ましいとは思うのだが、あるものはしょうがない。
一応、前者の動作に揃える-r
オプションというものがある(最近のFreeBSD版もこれを認識する)のだが、そんなオプションはPOSIXでは規定されていないがゆえ、それを付けて互換性を向上させようとすると逆に全ての環境で動く保証がなくなってしまうのが皮肉なところ。
対応方法
さてどうするか……、これは対症療法しかない。すなわち、
- 引数0個で実行されてもエラー扱いしないようなコマンドにする。
- コマンドがエラー動作することを想定するような後続の処理にする。
- 標準入力に必ず有効かつ無害な行が入るようにする。
- 呼び出されるコマンドに無害な引数を付けておく。
などを行う。
1番目の対処は、例えば呼び出すコマンドがrmなら-f
オプションを付けてエラー扱いを抑止するという方法だ。
find . -name '*.tmp' | xargs rm -f
2番目の対処は、例えば戻り値が0以外でも即エラー扱いしないとか、標準エラーに流れてくるエラーメッセージやUsageを/dev/nullに捨てるというものだ。
find . -name '*.tmp' | xargs rm 2>/dev/null
3番目の対処は、例えば呼び出すコマンドがgrepなどのファイルを読み込むだけのものであれば使える方法だ。例えば/dev/nullを読み出しファイルとして、標準入力の最初(最後に付加すると改行なし終端テキストだった場合に不具合が起こる)に付加すればよい。
find . -name '*.txt' | awk 'BEGIN{print "/dev/null"} 1' | xargs grep '検索キーワード'
# grepの場合は後述の4番目の対処方法をお勧めする
4番目の対処は、手段が若干異なるだけで目的は3番目と同じだ。先程のgrepの例ならこう書き直す。短く書けるし、先程紹介した対処方法よりもお勧めだ。
find . -name '*.txt' | xargs grep '検索キーワード' /dev/null
grepコマンドの場合は特にこちらを勧める。理由は、grepコマンドは、検索対象のファイルが1個だけ指定された場合と複数指定された場合で挙動を変えるからだ。具体的には、検索キーワードが見つかった時、1個だけだった場合はファイル名を表示しないのに対し、複数個だった場合には行頭にファイル名を併記する。
上記のように記述しておけば、grepコマンドは常に複数個指定されたと見なすので、findコマンドで見つかったファイルの数が1個であっても2個以上であっても、必ず行頭にファイル名を併記するようになり、動作が統一される。
引数文字列の扱い
xargsに\\\'
という文字列を与えると、例えばFreeBSDのxargsとLinuxのxargsで異なった結果を返す。
# FreeBSD
$ printf '\\\\\\'"' " | xargs printf
'
$
# Linux
$ printf '\\\\\\'"' " | xargs printf
\'
$
実はFreeBSDのxargsコマンドは、引数文字列を$@
(ダブルクォーテーションなし)のように渡してシェルのエスケープ処理を受けるのに対し、Linuxの(GNU版の)xargsコマンドは"$@"
(ダブルクォーテーションあり)のように渡すので、シェルのエスケープ処理を受けない。だから結果として、Linux上ではバックスラッシュが1個残るのだ。
ではどうするか。確実な方法は、エスケープ処理される文字を使わないことだ。バックスラッシュはいたしかたないとして、例えばシングルクォーテーションは\047
などと表現した文字列がprintfに渡るようにすればよい。ただしバックスラッシュも、引数としてシェルに解釈される時やprintfに解釈される時などに変化を受けるので十分注意すること。
zcatコマンド
zcatは、gunzip | cat
相当だと思っている人も多いかもしれないが違う! それはGNU拡張であり、本来のzcatはuncompress | cat
相当である。
従って、次のようにしてgzip圧縮されたデータを与えるとエラーを返すzcatコマンド実装がある。
$ echo hoge | gzip | zcat
stdin: not in compressed format
$
全ての環境のzcatコマンドを想定するなら、compressコマンドで圧縮したデータを与えること。
$ echo hoge | compress | zcat
hoge
$
ただし、compressコマンドは元データがファイルの場合、圧縮してもサイズが小さくならない場合に圧縮をしないので、次のような事故を起こさないように注意すること。(compress -f
とすれば必ず作る)
$ echo 1 > hoge.txt
$ compress hoge.txt
-- file unchanged ← サイズが小さくならないので圧縮ファイルを作らなかった
$ zcat hoge.txt.Z
hoge.txt.Z: No such file or directory
$