f

2016-12-27

How to get date time & time zone in POSIX shell script

#posixismadvent この記事はPOSIX原理主義Advent Calendarの22日目だ。

POSIX原理主義によるシェルスクリプトを作成した際に,作成日や更新日などの日時情報をスクリプト内や--helpなどに記述することがある。このときの日時の形式はどうすべきか?という議論がある。この記事ではPOSIX原理主義で採用すべき日時形式とタイムゾーンの取得方法について説明する。

結論としては,ISO 8601の拡張形式(YYYY-MM-DDThh:mm±hh:mm)を採用し,以下のコマンドでタイムゾーンを取得すればよい。

get_tz()(
 set $(date -u '+%j %H %M'); U_D=${1#0}; U_D=${U_D#0}; U_H=${2#0}; U_M=${3#0}
 set $(date    '+%j %H %M'); L_D=${1#0}; L_D=${L_D#0}; L_H=${2#0}; L_M=${3#0}

 # Fix if year is crossed
 IS_CROSSED_YEAR="[ $L_D = 1 -o $U_D = 1 ] && [ $((L_D+U_D)) -gt 3 ]"
 eval "$IS_CROSSED_YEAR" && U_D=$((L_D == 1 ? L_D-1 : L_D+1))

 dm=$(( (L_D*24*60 + L_H*60 + L_M) - (U_D*24*60 + U_H*60 + U_M) ))
 DIGIT_1=$((dm<0 ? -dm%10 : dm%10))

 # Fix if minute is changed during running date command
 [ $DIGIT_1 = 1 -o $DIGIT_1 = 6 ] && dm=$((dm<0 ? dm+1 : dm-1))
 [ $DIGIT_1 = 4 -o $DIGIT_1 = 9 ] && dm=$((dm<0 ? dm-1 : dm+1))

 printf '%+03d:%+03d\n' $((dm / 60)) $((dm % 60)) | sed 's/:[+-]/:/'
)

get_tz # +09:00
echo "$(date +%Y-%m-%dT%H:%M:%S)$(get_tz)" # 2016-12-27T21:57:32+09:00

Introduction

プログラムにおいて,そのバージョンがいつ作られたかという情報が大事になることがある。例えば,依存ソフトウェアのバージョンや,開発の活発さの指標として参考になることがある。

そこで,ソースコードやREADMEなどに,直接そのプログラムの作成日などや更新日時を記述することがある。しかし,日付や時刻の形式は国や地域,人によっていくつかの書き方がある。

以下に比較的よく見かける日時の表記例を示す。

日付の形式
形式
YYYY-MM-DD2016-12-25
YYYYMMDD20161225
YYYY/MM/DD2016/12/25
DD/MM/YYYY25/12/2016
MM/DD/YYYY12/25/2016
YYMMDD161225
YY/MM/DD16/12/26
YYYY/MMM/DD2016/Dec/25
MMM/DD/YYYYDec/25/2016
DD/MMM/YYYY25/Dec/2016
時刻の形式
形式
hh:mm:ss20:30:40
hhmmss203040
TT hh:mm:ssPM 08:30:40
hh:mm:ss TT08:30:40 PM
T.T. hh:mm:ssP.M. 08:30:40
hh:mm:ss T.T.08:30:40 P.M.
t.t. hh:mm:ssp.m. 08:30:40
hh:mm:ss t.t.08:30:40 p.m.
tt hh:mm:sspm 08:30:40
hh:mm:ss tt08:30:40 pm

この他にも年,月,日を空白区切りで並べる方法などもあり,実に多くの日時の表記方法があることがわかる。特に,この中ではDD/MM/YYYYとMM/DD/YYYY表記が紛らわしい。実際にこれらはそれぞれイギリスとアメリカで使われる日付の標識であり,2016-09-07などお互いに1-12の範囲の月日であるときに見分けがつかない

さらに,これとは別にタイムゾーンもある。例えば,13:00という時刻をアメリカと日本とでみると,それぞれの国では時差があるので,実際にはそれぞれ別の時間をみていることになる。例えば,誰が世界で一番最初に公開したかなどのように世界中で時間を競う場合に,この時差を考慮する必要がある。時差を考慮するには,13:00 JSTや13:00+09:00などのようにタイムゾーンも明記しなければ特定できない

こうした日時の表記方法に対して,どのような形式を採用するべきだろうか?

POSIX standard date time

XBD 1.3 Normative ReferencesでPOSIX規格に含んでいる国際規格が掲載されている。この中に,日時形式の国際規格であるISO 8601が存在している。

実際に,POSIX規格内でも以下の場所でISO 8601が参照されている。

また,POSIX以外にもW3CのHTML 5.1のtime要素でもISO 8601の形式しか日時の形式として認められていない。さらに,ECMAScript 2016でも日時として受け付ける文字列にもISO 8601の形式のみが採用されている。

したがって,ISO 8601の日時形式に従うべきだろう。日時形式の国際標準としてはISO 8601しか存在しないので順当な判断だ。

ISO 8601の日時の形式は以下の形式となる。

ISO 8601の書式
項目書式
ISO 8601YYYYMMDDThhmmss±hhmm20161226T230000+0900
ISO 8601拡張形式YYYY-MM-DDThh:mm:ss±hh:mm2016-12-26T23:00:00+09:00

ISO 8601の形式は日付と時刻を文字Tで区切り,タイムゾーンを末尾に付けることで日時を表記する。

1個目の形式は年月日時刻を詰めた形式となっており,可読性は悪いが,Windowsなどでファイル名として利用不可である:がないことから,ファイル名やデータ名などに適した形式である。

2個目の形式は,ISO 8601の拡張形式(extended format)と呼ばれており,各項目の間に-:といった区切り文字を入れることで可読性に優れた形式となっている。そのため,通常の文書ではこちらの形式が適している。

常に日時+タイムゾーンを明記する必要はなく,必要に応じて後ろの部分を省略することが許されている。例えば,2016-12-26のように日付だけ表記したり,23:00といった具合に時刻の部分だけ表記してもよい。また,タイムゾーンを指定しない場合は現地時間を意味する

しかし,ここで一つ疑問が起きる。それは,現在日時を表示するPOSIX準拠コマンドであるdateコマンドの標準の出力形式がISO 8601と異なる点だ。dateコマンドの出力形式は以下となっており,ISO 8601とは異なっている。

When no formatting operand is specified, the output in the POSIX locale shall be equivalent to specifying:
date "+%a %b %e %H:%M:%S %Z %Y"
date - XCU

実際にコマンドを実行すると以下のように出力される。

date
Mon Dec 26 22:39:45 JST 2016

POSIX文書に書かれている以上,これも一つの標準とみなすことができるかもしれない。ただ,この原因は,憶測だが以下のように考えることもできる。

  1. POSIX前のUNIXのデファクトがそうなっていた。
  2. 対話的な用途として視認性がよいフォーマットが優先された。

1点目の理由として,元々の形式がこのようになっていたので,差し障りがないように標準の出力はそのままにしたのではないかと考えることができる。

2点目の理由だが,「今何時かな」と思ってdateの4文字を入力した結果としては,視認性のよいものがよいという思想が働いた可能性がある。例えば,ISO 8601の形式だと曜日は月曜日から始まる1-7の数字で表記されることとなり,現在が何曜日かぱっとわかりにくい。その他,カレンダーを表示するcalコマンドも,標準出力は機械向けというよりは視認性を優先した出力結果となっている。

cal
   December 2016      
Su Mo Tu We Th Fr Sa  
             1  2  3  
 4  5  6  7  8  9 10  
11 12 13 14 15 16 17  
18 19 20 21 22 23 24  
25 26 27 28 29 30 31

dateコマンドの標準出力結果がISO 8601でないことについて考察した。この考察した結果としても,やはり基本はISO 8601に従うべきだろう。ISO 8601の拡張形式(YYYY-MM-DD)を使えば,視認性を損なわずに機械可読な日時にできるからだ。

How to get time zone

ここまでで,日時の形式にはISO 8601を採用すべきだと結論づけた。実際にdateコマンドでISO 8601の形式で出力するには,以下のように変換指定子を組み合わせる。

date +%Y-%m-%dT%H:%M:%S
2016-12-26T23:06:48

ただし,POSIXのdateコマンドのオプションでは現在のタイムゾーンを取得できないという問題がある。変換指定子に%Zというのがあるのだが,これはJSTというようなタイムゾーン名が表示されるだけで,残念ながら協定世界時(UTC)からの時差(オフセット)を数字で表示できない。なお,POSIXで規定されるC言語のstrftime関数には%zという変換指定子があり,これでタイムゾーンが±hhmmの形式で取得できる。dateコマンドにも%zの変換指定子が存在すれば簡単だったのだが,ないならばしかたない。

タイムゾーンを省略する場合は,現地時間と解釈できる。この場合,同じ文書に国や地域名を含める必要があり,これはこれで煩雑になる。より汎用性をあげるには日時にタイムゾーンも明記したほうがよいだろう。そこで,自分でタイムゾーンを取得する方法を検討する。

なお,GNU dateであれば,-Iオプションを使えば簡単にタイムゾーンも含めてISO 8601による現在日時を表示できる。当然ながら,GNUの独自拡張に依存すれば交換可能性がなくなるのでPOSIX原理主義では使ってはいけない。

date -I'seconds'
2016-12-26T23:11:06+09:00

タイムゾーンを取得するにあたって,環境変数TZを使えば簡単にできるかと思ったが,TZ環境変数は定義されていない環境もあり,これに頼ることができない。

dateコマンドはTZ環境変数が存在すれば,このタイムゾーンに基づいて日時を表示する。dateコマンドのTZ環境変数の説明をみればわかる通り,TZ環境変数が存在しなければ,システム標準のタイムゾーンが使われることになっている。

システム標準のタイムゾーンとは,実装依存になるのだが,例えばGNU C Libraryでは/etc/localtime/usr/etc/localtimeが参照され,Ubuntuでは/etc/timezoneが参照される。

しかし,当然ながらこれらのファイルはPOSIXでは未定義であり,これらに依存すれば交換可能性はなくなるので使うことはできない。

POSIX規格を調べたが,現在のタイムゾーンを数値で取得する方法はstrftime以外に存在しない。ではシェルスクリプトではどうするか?

date -uで常にUTC-0での日時が表示されることを利用して,datedate -uの差分をとり,タイムゾーンを取得する。

具体的には,以下のような関数により現在のタイムゾーンを取得できるようになる。

#/bin/sh
## \file get_tz.sh

get_tz()( L_D=$(date +%j); U_D=$(date -u +%j) is_crossed_year="[ $L_D -eq 1 -o $U_D -eq 1 ] && [ $((L_D+U_D)) -ne 3 ]" eval $is_crossed_year && [ $L_D -eq 1 ] && U_D=0 || L_D=0 LOCAL_MIN=$(echo "$L_D*24*60 + $(date +%H)*60 + $(date +%M)" | bc) UTC_0_MIN=$(echo "$U_D*24*60 + $(date -u +%H)*60 + $(date -u +%M)" | bc) DELTA_MIN=$((LOCAL_MIN - UTC_0_MIN)) printf '%+03d:%+03d\n' $((DELTA_MIN/60)) $((DELTA_MIN%60)) | sed 's/:[+-]/:/' )

get_tz

上記のget_tz関数を実行すると,日本であれば+09:00と表示される。

get_tzの仕組みを解説する。

まず,dateコマンドの-uオプションでは,常にUTC-0での日時が表示される。一方,dateコマンドはTZ環境変数が設定されていればそのタイムゾーンに従い,TZ環境変数がなければシステム標準のタイムゾーンに従い日時を表示する。つまり,date -udateコマンドの結果の差分で現在のタイムゾーンを取得できる。

ただし,そのまま差分を取ると問題が起こる。時間は12進数であり,分は60進数である。そのまま単純に減算すれば,10進数での減算となってしまい,値が想定と異なる。例えば,現在が日本時間の01:00である場合,単純に協定世界時との差分を取ると,01:00-14:00=-13となり,期待する9と異なってしまう。

この問題を回避するため,日時の単位を分に統一させる。分に単位を統一して減算を行い,得られた差分を時間と分に戻すことでタイムゾーンを取得している。通常であれば,シェルスクリプトでの日時の演算は煩雑な作業であり,1970-01-01からの経過秒であるエポックタイム(UNIX時間)に変換して行うのが汎用的だ。しかし,この変換自体複雑である。幸い今回は2時刻の差分をとるだけで済むので,素直に日時を分に変換することで対応できた。

最後のsedの処理について説明する。イギリスより西の地域など現地時間とUTC-0との差分がマイナスのとき,時間と分に換算するときにもマイナスの符号が付く。このままだと,出力するときに-09:-30のように分の部分に符号が付くため,これをsedで除去した。

なお,当初はLOCAL_MINUTC_0_MINで日時を分に換算する際に,算術展開(Arithmetic Expansion)($(()))を使っていたのだが,これだと問題が起きたのでbcコマンドに切り替えた。算術展開だと数字が0から始まる場合に8進数とみなされてしまう。現在日時の取得に使用しているdateコマンドの%d%H%M変換指定子では,1桁の数字を常に先頭に0を付けて出力する。そのため,00-07までは10進数と同じであるので問題ないが,08-09に関しては問題が起きる。具体的には以下のようなエラーが出る。

sh
echo "$((08*2))"
sh: 1: arithmetic expression: expecting EOF: "08*2"

bcコマンドであれば数字を常に10進数として扱うのでこの問題を回避できる。同様の問題に対応するのに,exprコマンドも利用できるのだが,こちらは演算子の前後を空白で区切る必要があり,記述量が長くなってしまうので今回は避けた。

2016-12-29追記:

当初掲載していたコードでは,月や年をまたぐ場合に日数が不連続になり(例:12/31と1/1),タイムゾーンを算出できていなかった。ユリウス日(通算日付)をdate +jで取得すれば,月またぎは問題ないが,やはり年をまたぐときにタイムゾーンを算出できない。そこで,年をまたぐかどうかの判定を入れた。判定は以下の手順で行った。

  1. 確実に年をまたぐことを判定。
    1. 現地時間とUTC-0のどちらかが1/1で,もう片方が最終日であるかを判定。
    2. うるう年では最終通算日366がありえるので,残りの日にちが1/1か1/2でないという条件,つまり両方の日数の合計が3以上であるで判定。
  2. 差分を取れるように,日を0か366か367に更新。

これで,年をまたぐ場合であってもタイムゾーンを算出できるようになった。以下のコマンドで現在の端末でだけ日付を1/1に変更してget_tzを実行すれば,きちんと現在のタイムゾーンを取得できることを確認できる。

sudo date 01010000
./get_tz.sh # +09:00

2017-01-07追記:

関数内で1回目のdateを実行してから2回目のdateコマンドを実行するまでの間に,万が一分をまたいでしまうと1分ずれてしまい,タイムゾーンが08:59や09:01となってしまう。幸いなことに,タイムゾーンの分は00,30,45しか存在しないので,差分をとった後の分の1桁目が1,6,4,9なら±1することで誤差を調整する。現在のタイムゾーンがUTC+00:00より西か東かで符号が変わるのでその処理を入れている。

また,当初は日時の先頭に0が登場するために,bcコマンドを使って計算していた。今回の対応で,setコマンドで日時分を個別の変数に代入するように処理を変えたので,ついでに先頭の0を削除するようにして,算術展開$(())で計算できるようにした。

get_tz()(
 set $(date -u '+%j %H %M'); U_D=${1#0}; U_D=${U_D#0}; U_H=${2#0}; U_M=${3#0}
 set $(date    '+%j %H %M'); L_D=${1#0}; L_D=${L_D#0}; L_H=${2#0}; L_M=${3#0}

 # Fix if year is crossed
 IS_CROSSED_YEAR="[ $L_D = 1 -o $U_D = 1 ] && [ $((L_D+U_D)) -gt 3 ]"
 eval "$IS_CROSSED_YEAR" && U_D=$((L_D == 1 ? L_D-1 : L_D+1))

 dm=$(( (L_D*24*60 + L_H*60 + L_M) - (U_D*24*60 + U_H*60 + U_M) ))
 DIGIT_1=$((dm<0 ? -dm%10 : dm%10))

 # Fix if minute is changed during running date command
 [ $DIGIT_1 = 1 -o $DIGIT_1 = 6 ] && dm=$((dm<0 ? dm+1 : dm-1))
 [ $DIGIT_1 = 4 -o $DIGIT_1 = 9 ] && dm=$((dm<0 ? dm-1 : dm+1))

 printf '%+03d:%+03d\n' $((dm / 60)) $((dm % 60)) | sed 's/:[+-]/:/'
)

setコマンドの後に以下の日時を強制的にセットしてちゃんと算出できていることを確認できる。

 # +5:45 Nepal
 # U_D=365; U_H=23; U_M=59
 # L_D=1;   L_H=5;  L_M=45

 # U_D=1;   U_H=5;  U_M=45
 # L_D=365; L_H=23; L_M=59

 # -3:30 Canada
 # U_D=1;   U_H=3;  U_M=28
 # L_D=365; L_H=23; L_M=59

 # L_D=1;   L_H=3;  L_M=28
 # U_D=365; U_H=23; U_M=59

Conclusion

POSIXにおける日時の表示形式とタイムゾーンの取得方法について説明した。

現在日時は一時ファイルを作る時などで重宝するので,以下のように関数にして~/.bashrcなどに書いておけば,現在日時を即座にISO 8601形式で出力できるので便利だ。

get_tz()(
 set $(date -u '+%j %H %M'); U_D=${1#0}; U_D=${U_D#0}; U_H=${2#0}; U_M=${3#0}
 set $(date    '+%j %H %M'); L_D=${1#0}; L_D=${L_D#0}; L_H=${2#0}; L_M=${3#0}

 # Fix if year is crossed
 IS_CROSSED_YEAR="[ $L_D = 1 -o $U_D = 1 ] && [ $((L_D+U_D)) -gt 3 ]"
 eval "$IS_CROSSED_YEAR" && U_D=$((L_D == 1 ? L_D-1 : L_D+1))

 dm=$(( (L_D*24*60 + L_H*60 + L_M) - (U_D*24*60 + U_H*60 + U_M) ))
 DIGIT_1=$((dm<0 ? -dm%10 : dm%10))

 # Fix if minute is changed during running date command
 [ $DIGIT_1 = 1 -o $DIGIT_1 = 6 ] && dm=$((dm<0 ? dm+1 : dm-1))
 [ $DIGIT_1 = 4 -o $DIGIT_1 = 9 ] && dm=$((dm<0 ? dm-1 : dm+1))

 printf '%+03d:%+03d\n' $((dm / 60)) $((dm % 60)) | sed 's/:[+-]/:/'
)


now()( EXE_NAME='now' dt=$(date +%Y%m%dT%H%M%S) OPTSTR=':lst-:' for opt in $(echo $OPTSTR | sed 's/[:-]//g' | fold -w 1); do eval is_opt_$opt='false' done while getopts $OPTSTR opt; do case "$opt${OPTARG-}" in l|-long) dt=$(date +%Y-%m-%dT%H:%M:%S);; s|-short) dt=$(date +%Y%m%dT%H%M%S );; t|-time-zone) is_opt_t='true';; \?*) echo "$EXE_NAME: invalid option -- '$OPTARG'" >&2; exit 1;; *) echo "$EXE_NAME: unrecognized option '-$opt$OPTARG'" >&2; exit 1;; esac done if $is_opt_t; then TIME_ZONE=$(get_tz) [ -n "${dt%%*:*}" ] && TIME_ZONE=$(printf '%s\n' "$TIME_ZONE" | sed 's/://') dt="$dt$TIME_ZONE" fi echo "$dt" )

上記のnow関数は以下のように使う。

now     # 20161227T232101          Same as now -s or now --short
now -t # 20161227T232119+0900 With time zone, same as now -st or now --time-zone
now -l # 2016-12-27T23:22:07 ISO 8601 extended format. same as now --long now -tl # 2016-12-27T23:22:36+09:00 Same as now --long --time-zone

上記のように,関数化までする必要がないと感じるならalias程度にしておくのもよいだろう。こちらだと,タイムゾーンや表示形式の自由度はないが,1行で書けるのですっきりしている。

alias now='date +%Y%m%dT%H%M%S'

このnow関数は,例えば,以下のようにコマンド代入(Command Substitution)$()で現在日時をファイル名として使える。

ls > $(now).log

POSIX規格だけでなく,ISOなどの国際規格に準拠することでも交換可能性を担保できる。日頃からこうしたデジュレ標準を意識して,高品質で高寿命なコードや文書の作成を心がけよう。

2016-12-21

How to check if commands enabled in POSIX shell script

#posixismadvent この記事はPOSIX原理主義Advent Calendarの17日目だ。

POSIX原理主義を実践していくうえで,POSIX規格外のコマンドを使う場合は,交換可能性を担保しなければならない。その際にコマンドが利用可能かどうかの判定が必要となる。そこで,POSIXに準拠したコマンドの利用可能判定方法を解説する。

結論としては,用途に応じて以下の2コマンドを利用すればよい。

command -v hoge >/dev/null  && echo "OK"
hash hoge 2>/dev/null && echo "alias OK"

コマンドが存在するかどうかを判定するコマンドとしてwhichコマンドが存在する。しかし,このwhichコマンドはPOSIXで未定義なので,whichコマンド自体が常に使えると保証できない。

実はコマンドの有無を判定できるコマンドはいくつか存在する。これらを説明していく。

コマンドの存在の判定コマンド

コマンドの有無の判定については以下のコマンドが利用可能である。

  • command -v
  • command -V
  • hash
  • type
  • kshとzshの組み込みコマンドのwhence
  • which

これらのコマンドは判定できるコマンドの種類や,出力形式がまちまちである。これらの対応を以下の表にまとめた。

コマンドの存在判定コマンドの出力内容一覧
コマンド出力形式不在時

存在時




外部コマンド組み込みコマンド関数予約後alias
command -v 定義済み。不在時は無表示。 無表示 絶対パス 名前 名前 名前 定義
command -V unspecified。それぞれの種別がわかるように出力。不在時はエラー表示。 エラー 絶対パス 名前 名前 名前 定義
hash aliasは定義もチェックする。存在時は無表示 エラー 無表示 無表示
(zsh:検出不可)
無表示
(zsh:検出不可)
エラー 無表示
type unspecified。それぞれの種別がわかるように出力。不在時はエラー表示。 エラー 絶対パス 名前 名前 名前 定義
whence POSIX未定義。kshとzsh組み込みコマンド。不在時は無表示。 無表示 絶対パス 名前 名前 名前 定義
which POSIX未定義。aliasは展開してチェックする。不在時は無表示。 無表示
(zsh:表示)
絶対パス
無表示
(検出不可)
(zsh:名前)

無表示
(検出不可)
(zsh:定義)
無表示
(検出不可)
(zsh:名前)
絶対パス
(zsh:定義)

この結果をまとめる。

コマンドの有無の判定コマンドの表示結果の解説
command -v
POSIX準拠。存在時の出力形式が決まっている。全項目の有無の判別可能。ただし,aliasは定義の有無しか判定できない。aliasで定義されたコマンドが実際に存在するかは判定不能。
hash
POSIX準拠。全項目の有無の判別可能。aliasの実際のコマンドも展開して判定できる。zshの組み込みコマンドの場合,組み込みコマンドと関数の判定不能。
command -Vtype
ともにPOSIXで定義されており,挙動もほぼ同じ。コマンドの存在および種類の識別ができるが,出力形式は未定義。
whence
command -vと同等。ただし,POSIX未定義なので,kshとzshでしか使えない。
which
外部コマンドしか判定できない。zshの組み込みwhichに限り,whenceと同等。

command,hash,typeはPOSIXで定義されている。この内,typeとcommand -Vは出力形式が未定義。command -vのみコマンド存在時の出力形式が決まっている。外部コマンドの絶対パスの取得に適している。

hashコマンドはコマンドが存在する場合に,何も表示しない。代わりに,aliasの定義元のコマンドまでチェックできる。ただし,zshの組み込みコマンドのhashは挙動が異なり,組み込みコマンドや関数の存在を判定できない。

zsh組み込みのhashが組み込みコマンドや関数を判別できないのは,おそらくPOSIX規格の以下の一文のためだろう。

Utilities provided as built-ins to the shell shall not be reported by hash.
hash - Shell & Utilities

シェルでは,最近実行されたコマンドをハッシュテーブルに記憶しており,これを利用することでコマンド検索速度をあげているらしい。hashコマンドは,指定したコマンドをハッシュテーブルに追加したり,現在のハッシュテーブルを表示したりできる。

hash
hits command
   1 /bin/grep
   8 /usr/bin/xset
   5 /usr/bin/vim
   1 /usr/bin/xkbcomp

上記一文は,おそらく引数を指定しないハッシュテーブル一覧に組み込みコマンドをを表示させないことを意図していたのだと思うが,zshではこれを誤って解釈して実装したため,hashコマンドで組み込みコマンドや関数が判定できなくなったのだと思われる。

コマンドの存在判定の書き方

実際にこれらのコマンドを使って,if文などでコマンドの有無を判定することを考える。大きく2種類の判定方法がある。

  1. 実行結果の終了ステータスで判定
  2. 出力文字で判定

1.の方法は,例えば以下のように出力結果を捨てて行う。

command -v which >/dev/null && echo "OK"

この方法では,確実に標準出力と標準エラー出力を捨てる必要があるので,>/dev/null 2>&1などといったリダイレクトを記述する必要がある。

2.の方法は,command -vwhencewhichコマンドは対象コマンドが存在しなければ,何も表示しないことを利用している。testコマンドでは,出力文字があればtrue,なければfalseとなるので,コマンド代入$()を使って,コマンドが存在しているかどうかを出力される文字列を使って判定する。

[ "$(command -v which)" ] && echo "OK"

testコマンドを使う場合,入力文字数が少ないという利点があるが,実行コマンドが増えるためパフォーマンスが悪くなる。

この2通りの方法で判定方法を以下の表にまとめた。

2種類のコマンドの存在判定方法
コマンド終了ステータスでの判定方法標準出力の有無で判定
command -v
command -v hoge >/dev/null
[ "$(command -v hoge)" ]
command -V
command -V hoge >/dev/null 2>&1

hash
hash hoge 2>/dev/null
hash hoge 2>&- #(zsh:エラー)
[ ! "$(hash hoge 2>&1)" ] 
type
type hoge >/dev/null 2>&1

whence
whence hoge >/dev/null
[ "$(whence hoge)" ]
which
which hoge >/dev/null
[ "$(which hoge)" ]

POSIXの範囲内で実現する場合,hashコマンドを使う方法が最短記法となる。しかし,hashコマンドはzshで実装が異なっており,汎用性が若干欠けてしまう。hashコマンドで使っている2>&-というリダイレクトは見慣れないと思うので簡単に解説する。これは標準エラー出力を閉じている。これにより,エラーメッセージの表示を強制的に禁止してしている。

同様に>&-という記法で標準出力を閉じることができる。これにより,>/dev/nullよりも手短に標準出力を捨てられてよいと思うかもしれない。しかし,標準出力を閉じた場合,シェルによってはエラーが出てしまうので残寝ながら使えない。

sh
echo "ABC" >&-
sh: echo: I/O error

しかし,標準エラー出力を閉じる>&-であれば,そのエラーすら禁止するので問題なく使える。

hoge 2>&- && echo OK || echo NG
NG

しかし,これをzshの組み込みhashコマンドでやる場合に限り,以下のようなエラーが出る。

hash lv 2>&-
zsh: write error

zshがコマンド実行前にパースしてエラーを出しているようだ。

Re: precmd: write error: interrupted

上記で書かれているようにexec 2>&-を先に掛けば回避できる。しかし,これはみにくく,記述の短さの利点がなくなってしまう。

速度比較

これだけコマンドや判定方法があれば,どれを使えばいいのかわからなくなってしまう。そこで,実行速度を計測して判断材料とする。実際に以下のコードで1万回実行して実行速度を計測してみる。

コマンドの存在判定の実行速度比較コード
#!/bin/sh
## \file time_if_exe.sh

set -eu
umask 0022
export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"

export N=10000
export NULL="/dev/null"

## test vs. >/dev/null
time -p sh -c 'for i in $(yes|head -$N); do command -v which >$NULL;   done'
time -p sh -c 'for i in $(yes|head -$N); do [ "$(command -v which)" ]; done'
echo ""

## Vs. all commmand
time -p  sh -c 'for i in $(yes|head -$N); do command -v which  >$NULL; done'
time -p  sh -c 'for i in $(yes|head -$N); do command -V which  >$NULL; done'
time -p  sh -c 'for i in $(yes|head -$N); do hash       which 2>$NULL; done'
time -p  sh -c 'for i in $(yes|head -$N); do hash       which 2>&-;    done'
time -p  sh -c 'for i in $(yes|head -$N); do type       which  >$NULL; done'
time -p zsh -c 'for i in $(yes|head -$N); do whence     which  >$NULL; done'
time -p  sh -c 'for i in $(yes|head -$N); do which      which  >$NULL; done' 

計測結果を以下の表に示す。

コマンドの存在判定の実行速度比較結果


Time [s]
Commandrealusersys
[ “$(command -v )” ] 1.17 0.04 0.42
command -v 0.03 0.01 0.01
command -V 0.03 0.02 0.00
hash 2>/dev/null 0.05 0.01 0.03
hash 2>&- 0.04 0.01 0.02
type 0.02 0.01 0.00
whence 0.05 0.02 0.01
which 5.69 0.08 0.53
コマンド存在判定の実行速度比較結果
  • testコマンド[ "$()" ]は,リダイレクトの約40倍時間がかかる。
  • whichコマンドは外部コマンドのため,速度が最も遅い。
  • 最速はtypeコマンドの0.02 s,ついでcommandコマンドの0.03 s。0.01 sの差であり誤差の範囲内と思われる。

whichコマンドとtestコマンドを使う方法以外は,どれも十分速いので,速度差は無視していい。

commandコマンド

ここまででコマンドの存在の有無の判定コマンドや,その判定の記述方法について説明してきた。結局のところ,実行速度も記述量もそんなに違いがない。ではどれを選ぶべきか?答えはcommand -vだ。

なぜ,コマンドの有無の判定でcommand -vを使うべきかは,POSIXで記載されている-vVオプションが追加された経緯を読めばわかる。

The command -v and -V options were added to satisfy requirements from users that are currently accomplished by three different historical utilities: type in the System V shell, whence in the KornShell, and which in the C shell. Since there is no historical agreement on how and what to accomplish here, the POSIX command utility was enhanced and the historical utilities were left unmodified. The C shell which merely conducts a path search. The KornShell whence is more elaborate-in addition to the categories required by POSIX, it also reports on tracked aliases, exported aliases, and undefined functions.


RATIONALE - command - Shell & Utilities

上記内容を簡単に解説する。commandコマンドの-vと-Vは,もともと以下の3種類のコマンドで達成していたことを実現するために2001年に追加された。

  • System V shellのtypeコマンド
  • KornShellのwhenceコマンド
  • C shellのwhichコマンド

これらのコマンド間では,何をどのように実現するかの合意がなかったので,これらのコマンドを修正せずにcommandが拡張された。

上記3種類のコマンドには,それぞれ以下のような欠点や特徴がある。

歴史的なコマンドの有無の判定コマンドの違い
System V shellのtypeコマンド
元々実行時のコマンド名を表示するためのコマンド。出力形式は未定義。
KornShellのwhence
aliasや未定義関数などPOSIXで要求する以上に詳しい。
C shellのwhich
単純なパス検索。組み込みコマンドなどを検出不可。

command -Vはtypeコマンドに相当しており,typeコマンドと同様に出力書式は未定義だが,シェルでどのように解釈されるかユーザーにとって役に立つ情報が多く表示される。-VはSystem VのVを意味しているのだと思われる。

command -vはKornShellのwhenceコマンドに相当しており,組み込みコマンドや関数まで判別できている。また,出力形式も規格て定義されている。

Conclusion

ここまでで,コマンドの存在の判定方法について説明してきた。結論として,コマンドの存在の判定は,原則command -vで判定するのがよいだろう。理由は以下となる。

  • commandコマンドの-vVオプションがシェル共通で使うことを念頭に作られた。
  • POSIXで出力書式が唯一規定されている。
  • トータルの記述量はtypeコマンドで判定する場合より1文字多いだけ。
  • 実行速度も十分速い。

command -vは唯一出力書式が決まっているので,外部コマンドの絶対パスを取得したい場合はほぼこれを使うしかない。ただし,commandコマンドはaliasの本体を判定できないという欠点もある。このときのために,hashコマンドを使うのはありだろう。ただし,hashコマンドはzsh組み込みの場合に挙動が変わるので,あまり使わないほうがいいように思う。zshで使わないというのならありかもしれない。

command -v hoge >/dev/null  && echo "OK"
hash hoge 2>/dev/null && echo "alias OK"
hash hoge 2>&- && echo "OK" # except for zsh

リダイレクトの記述を省略したり,より意図がわかるように以下のように関数にしてしまうのもよいだろう。

コマンドの有無の判別関数
is_exe_enabled(){
command -v "$@" >/dev/null
}

POSIX原理主義における交換可能性を担保したシェルスクリプトを作るうえで,極めて重要なコマンドの存在の判定方法について解説した。command -vを使ってコマンドの存在を判定し,POSIX原理主義を実践していこう。

2016-12-18

How to check if script executed as a command in POSIX shell script

#posixismadvent この記事はPOSIX原理主義Advent Calendarの17日目だ。

POSIX原理主義のシェルスクリプトがコマンドとして実行されたか,dot(.)コマンドで読み込まれたかどうかを判定する方法を記す。

結論としては,以下のようなコードでコマンドとして実行されたか判定できる。

#!/bin/sh
## \file script_name.sh

EXE_NAME='script_name.sh'
NOW_EXE=$(ps -p $$ -o args=)
case "$NOW_EXE" in *$EXE_NAME*) echo "Executed as a file." esac

Introduction

一般的に,シェルスクリプトでコマンドを自作する場合,コマンドをファイルとして実行することを前提として作られることが多い。例えば以下のようなコードだ。

cat <<- EOT >init.sh
#!/bin/sh
## \file init.sh

init(){
set -eu
umask 0022
export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"
}

main()(
init
echo 'init: $@'
)

main "$@"
EOT
./init.sh a b c
init: a b c

しかし,上記のinit.shinit関数に見られるように,どのスクリプトでも共通で使うような処理が出てくる。それぞれのスクリプトで同じ内容を記述するのは冗長なので記述を共通化したくなる。つまり,シェルスクリプトをライブラリとして活用できてもいいのではないかと考えた。

シェルスクリプトでは,dot(.)コマンドにより外部ファイルの内容を現在のシェルでそのまま実行することができる。これにより関数を現在のシェルやスクリプトで読み込むことができる。

cat <<- EOT >import.sh
#!/bin/sh
## \file import.sh

. /init.sh
main(){
echo "import: $@"
}
EOT
./import.sh 1 2 3
init: 1 2 3
import: 1 2 3

しかし,上記のimport.shの実行結果でimport.shでは記述していないinit: 1 2 3が出力されていることからわかるように,単にdotコマンドで読み込んだだけだと,当然ながら読み込んだファイルの通常のコードが実行されてしまう。関数定義を読み込みたいだけなので,通常のコマンドを実行したくない

これについて,例えばPythonやRubyではファイルとして実行されたかどうかを判定する記述方法が存在する。

cat <<- EOT >if_main.py
#!/usr/bin/env python3
## \file if_main.py

def foo():
printf(__name__)

if __name__ == '__main__':
foo()
EOT
./if_main.py
__main__
cat <<- EOT >if_main.rb
#!/usr/bin/env ruby
## \file if_main.rb

def foo
puts $0
end

if __FILE__ == $0
foo()
end
EOT
./if_main.rb
./if_main.rb
PythonとRubyでのファイルとして実行されたことの判定方法
Python
__name__変数に文字列'__main__'が入っている。
Ruby
__FILE__変数にファイル名が$0に実行中のスクリプト名が入っている。

PythonとRubyではファイルとして実行されたかどうかの情報を変数で保持している。したがって,この変数を使いファイルとして実行されている場合にだけ,関数を実行することができる。このように記述することで,ライブラリしても,コマンドとしても使える汎用性が高いスクリプトを作成できる。

このようなことをPOSIX準拠のシェルスクリプトでもできないか検討した。

Method

まず,ネット上で提案されている事例を調査した。複数の案があったので,そのうちダメな方法を以下で一覧する。

既に提案されている方法のダメな理由
方法参照元ダメな理由
[ "$_" = "$0" ]
URL$_変数がPOSIX非準拠。過去にはPOSIX規格に存在したが,多重定義時にkshで混乱するため2001年に廃止となった。
[ "$0" = "$BASH_SOURCE" ]
URLBASH_SOURCE変数がbash専用。
$(return >/dev/null 2>&1)

[ "$?" = "0" ]
URL関数以外でのreturnコマンドの挙動がPOSIX未定義。bash,zshとdashでは挙動が違う。
case $(caller) in '0 '*)
echo "main"
esac
URLcallerコマンドがPOSIX未定義。

bashの独自拡張を使ってよいのであれば,上記のようにいくつもやり方があるのだが,POSIX原理主義を通すには他の方法を探るしかない。

調べてわかった重要な前提として,dot(.)コマンドで読み込んだファイル名を保存する変数やアクセスするコマンドなどはPOSIXの範囲では存在しない。そのため,自分でファイルにファイル名を格納する変数をハードコーディング(直打ち)して,このファイル名と一致するかで判定する。

具体的には,以下のコードの$hogeに相当する変数を用意する。

#!/bin/sh
## \file file.sh

EXE_NAME='file.sh'
case "$hoge" in *"$EXE_NAME"
echo "MAIN"
esac

この視点に立って,改めて利用可能な方法を検討する。考えられる方法は以下の2通りだ。

  1. $0に格納される値と比較
  2. psコマンドの実行で得られる現在実行中のプロセス名と比較

使用する変数を以下の表で説明した。

現在実行中ファイルの特定に利用可能な変数
変数説明
$_シェルかシェルスクリプトの起動で使われたフルパス。POSIX非準拠。
$0現在のシェルかシェルスクリプト名。
$$現在のプロセスID
$PPID親プロセスのID

1点目の方法では,スクリプト実行時に$0変数に格納される値と比較する。この方法でうまくいくならこれが一番素直で簡単だ。

2点目の方法では,psコマンドを使ってプロセス名を取得する。

psコマンドを実行すると以下のようにプロセスのIDと実行コマンドが表示される。

ps
  PID TTY      STAT   TIME COMMAND
 2275 pts/2    Ss     0:00 bash
22064 pts/9    R+     0:00 ps w

この出力から[COMMAND]で表示される実行コマンドを取得することでファイル名と比較を行う。

psコマンドのオプションで,-pで表示させるプロセスIDを指定し,-oで出力項目(列)を指定できる。-oオプションで指定できる項目はいくつかあるが,今回は実行コマンド名がほしいので,commargsを試す。commでは実行中のコマンドだけ(C言語のargv[0]相当)を出力する。argsはコマンドだけではなく呼び出しコマンドも表示できる可能性がある。また,-oオプションでは指定対象の末尾に=を付けることで,ヘッダーを省略できる。

psコマンドで対象とするプロセスには現在のプロセス$$と親プロセス$PPIDが考えられる。現在のプロセスはもちろんであるが,親プロセスが何であるかで特定できるかもしれないので検討対象とした。

これらから,以下のpsコマンドで現在実行中のファイル名を取得できる可能性がある。

ps -p $PPID -o comm=
ps -p $$    -o comm=
ps -p $$ -o args=

ここまでで,$0とpsコマンドによるPOSIXに準拠した方法で現在実行中のファイル名の取得方法について検討した。実際にこれらの方法で値を取得できるかを検証していく。

検証にあたって,ファイル名での実行とdot(.)コマンドによる読み込みで考えられる全てのパターンを試す。具体的には以下のrun_if_main.shのコードで示すように,以下の2パターンを考慮した。

  • 現在のシェルでの読み込みと実行
  • 新しいシェルでの読み込みと実行

なお,参考までに$_の値も一緒に確認する。

現在実行中かの表示コマンド(if_main.sh
#!/bin/sh
# \file if_main.sh

echo "\$_: $_"
echo "\$0: $0"
echo 'ps -p $PPID -o comm=: '"$(ps -p $PPID -o comm=)"
echo 'ps -p $$    -o comm=: '"$(ps -p $$    -o comm=)"
echo 'ps -p $$ -o args=: '"$(ps -p $$ -o args=)"
echo ''
現在の状態を正しく表示できるかのテストコマンド(run_if_main.sh
#!/bin/sh
## \file run_if_main.sh

./if_main.sh
. ./if_main.sh
sh ./if_main.sh
sh -c '  ./if_main.sh'
sh -c '. ./if_main.sh'

run_if_main.shif_main.shを同じディレクトリ(/home/senooken/tmp)に配置して,run_if_main.shを実行した。実行はUbuntu 16.04のbashから以下のコマンドで行った。

cd ~tmp
./run_if_main.sh

実行結果を以下の表にまとめた。

bashからの./run_if_main.shの実行結果一覧
表示項目run_if_main.sh内での実行コマンド

./if_main.sh. ./if_main.shsh ./if_main.sh sh -c './if_main.sh'sh -c '. ./if_main.sh'
期待される値if_main.shif_main.sh以外shif_main.shsh
$_ ./run_if_main.sh ./run_if_main.sh ./run_if_main.sh ./run_if_main.sh ./run_if_main.sh
$0 ./if_main.sh ./run_if_main.sh ./if_main.sh ./if_main.sh sh
ps -p $PPID -o comm= run_if_main.sh bash run_if_main.sh sh run_if_main.sh
ps -p $$ -o comm= if_main.sh run_if_main.sh sh if_main.sh sh
ps -p $$ -o args=/bin/sh ./if_main.sh/bin/sh ./run_if_main.shsh ./if_main.sh/bin/sh ./if_main.shsh -c . ./if_main.sh

この表の結果からいえることを以下にまとめた。

$0とpsコマンドによる現在実行中コマンド名の特定方法の考察
  1. $_ではスクリプトを間に介した場合,スクリプト名の値が入っているので,ネストさせる場合などで判定に使えない
  2. $PPIDを使った場合は,確かに親のプロセス名が取得できている。しかし,この情報だけでは現在のプロセス名を特定できない
  3. psコマンドの-oオプションでは,commだと直接のプロセス名だけが表示されるが,argsだと実行コマンド全体が表示される。argsの方が情報は多いが,条件判定に不要な情報が多いので,commの方が有利
  4. $0ps -p $$ -o comm=はほとんど同じ期待される結果を返している。しかし,sh ./if_main.shのときだけ結果が異なっている

この4番目のsh ./if_main.sh実行時の結果の違いは重要な論点となる。具体的には,sh ./if_main.shはファイルを読み込んでいるのか,それともコマンドとしてファイルを実行しているのか?だ。

この議論を検証するために,実行コマンドであるPOSIXのshのマニュアルを確認する。

command_file
The pathname of a file containing commands. If the pathname contains one or more <slash> characters, the implementation attempts to read that file; the file need not be executable. If the pathname does not contain a <slash> character:
The implementation shall attempt to read that file from the current working directory; the file need not be executable.

If the file is not in the current working directory, the implementation may perform a search for an executable file using the value of PATH, as described in Command Search and Execution.

Special parameter 0 (see Special Parameters) shall be set to the value of command_file. If sh is called using a synopsis form that omits command_file, special parameter 0 shall be set to the value of the first argument passed to sh from its parent (for example, argv[0] for a C program), which is normally a pathname used to execute the sh utility.
sh - Shell & Utilities

上記で書かれているように,sh ファイルで実行された場合はファイルを実行ではなく読み込んでいる。したがって,$0ではなくps -p $$ -o comm=がベストな判定方法だろう。

なお,仮に$0ps -p $$ -o comm=が同じ正しい結果を返していた場合でも,psコマンドを採用することになる。理由は$0のzshの標準の挙動がPOSIX shellと異なるからだ。

zshではfunction_argzeroというオプションがデフォルトで有効になっている。このオプションを有効にすると,dotコマンドでファイルを読み込んだ場合,自動的にそのファイル名を$0に設定してしまう。

参考:Bash/Zshで'source'するファイルの中でで自分のパスを取得する

この機能が便利になる面もあるのだろうが,この機能のためにzshだけが他のシェルとデフォルトの挙動が異なる。つまり,$0を採用した場合zshだけ特別設定が必要となってしまう。そのため,共通で有効なpsコマンドを使うべきと判断した。

Coding

ここまでの調査で分かったことから,POSIX原理主義のシェルスクリプトでファイルとして実行中かどうかの判定は以下のようにして行える。

#!/bin/sh
## \file script_name.sh

EXE_NAME='script_name.sh'
NOW_EXE=$(ps -p $$ -o comm=)
if [ "$EXE_NAME" = "$NOW_EXE" ]; then echo "Executed as a file." fi

実際に,上記ファイルを実行したら,コマンドファイルとして実行したことを判定できている。

. ./script_name.sh
./script_name.sh # Executed as a file

この調査結果を利用して,現在実行中かどうかの判定関数を以下のように作れる。

## コマンドファイルの名前をグローバル変数EXE_NAMEに代入しておき参照
is_main()(
# EXE_NAME='script_name.sh' # またはis_main関数で定義
NOW_EXE=$(ps -p $$ -o comm=)
[ "$EXE_NAME" = "$NOW_EXE" ]
)

この関数を活用すれば,以下のようなライブラリとしても,コマンドファイルとしても利用可能なシェルスクリプトを作成できる。

#!/bin/sh
## \file is_main.sh

EXE_NAME='is_main.sh'

init(){
 set -eu
 umask 0022
 export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"
}

is_main()(
 NOW_EXE=$(ps -p $$ -o comm=)
 [ "$EXE_NAME" = "$NOW_EXE" ]
) main()( init echo "MAIN. $@." ) if is_main; then
main "$@"
fi

## 現在実行中かの判定を関数にしない場合は,上記を以下のように記述する。
# NOW_EXE=$(ps -p $$ -o comm=) # if [ "$EXE_NAME" = "$CUURENT_EXE" ]; then
# main "$@"
# fi
## もちろん1行で書いてもいい
# is_main && main "$@"
# [ "$EXE_NAME" = "$NOW_EXE" ] && main "$@"

2017-01-15追記:

ps -o comm=では,コマンド名が15文字までしか表示されないことがわかった。

linux - What is the maximum allowed limit on the length of a process name? - Stack Overflow

そこで,15文字以上のコマンド名にも対応するために,ps -p $$ -o args=を採用する。この場合,表示される内容は以下の通りにインタープリターのフルパスとコマンド引数まで表示される。

/bin/sh ./script_name.sh 0

コード中に埋め込んだコマンド名とプロセス上でのコマンド名のマッチさせるため,case文により判定を行う。

## Function
is_main()( NOW_EXE=$(ps -p $$ -o args=) case "$NOW_EXE" in *$EXE_NAME*);; *) return 1;; esac )
## Without function
NOW_EXE=$(ps -p $$ -o args=)
case "$NOW_EXE" in *$EXE_NAME*)
main "$@" esac

Conclusion

POSIX原理主義によるシェルスクリプトをライブラリとしても活用するための第一歩として,スクリプトが現在実行中かどうかの判定方法を検討し,確立できた。この方法を使えば,自作のシェルスクリプトのメイン部分を関数にまとめて,4-5行追加するだけで,コマンドだけでなくライブラリとしても活用できる

実のところ,今のままだとシェルスクリプトをライブラリ化しようが,コマンドのままとたいした違いはない。ただ,変数の命名規則(メソッドやプロパティの区切りを__に見立てたりなど)や関数の構造を工夫することで,シェルスクリプトでもオブジェクト指向的なことができるのではないかと考えている。もしこれがうまくいけば,より高度で効率的,汎用性の高いPOSIX原理主義による開発ができるかもしれない。

今回の調査はこのための第一歩だった。将来の応用に向けた基礎研究的なものだろう。POSIX原理主義なら時空を超えることができる。蓄積が重要になる。だから,今回のような細かい話で,すぐには役に立たなさそうな内容であっても,無駄にはならないだろう。

参考:

2016-12-15

How to initialize POSIX shell script

#posixismadvent この記事はPOSIX原理主義Advent Calendarの15日目だ。

POSIX原理主義によるシェルスクリプトの実行環境の最善と思われる初期化方法を解説する。結論としては,シェルスクリプトの冒頭に常に以下のコードを記述すれば,安全でより互換性が高くなる。

set -eu
umask 0022
export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"

Introduction

POSIX原理主義の解説書である「Windows/Mac/UNIX すべてで20年動くプログラムはどう書くべきか」(以下「すべてで20年動く」と略記)のp. 37「1-9 環境変数などの初期化」において,シェルスクリプトの環境変数などの初期化方法として以下のコードが記述されている。

set -u
umask 0022
PATH='/usr/bin:/bin'
IFS=$(printf ' \t\n_'); IFS=${IFS%_}
export IFS LC_ALL=C LANG=C PATH

初めて見たときはとても参考になった。しかし,POSIX規格について勉強しているとこの方法に疑問を持つようになった。

  • PATH変数がPOSIXに規定のない/usr/bin:/binで決め打ち。
  • IFSの初期化方法が汚い。
  • ロケールであるLC_ALLLANGが重複。

シェルスクリプトの初期化は重要なテーマだ。というのも,環境変数を変えられてしまうとプログラムの挙動が変わってしまうというのは脆弱性の元だからだ。特に,サーバーサイドのシェルスクリプトを書く場合にクリティカルとなる。

POSIX規格を徹底的に見直すことで,より望ましい初期化方法を発見した。

set -eu
umask 0022
unset IFS
export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"

それぞれの意味の確認もこめて説明してく。

set -eu

このコマンドでは,2個のオプションを有効にしている。

setコマンドのオプションの説明
-e
while, until, if, &&, ||など条件判定以外でコマンドが失敗したら強制終了
-u
未定義の変数にアクセスしたら強制終了

開発中のデバッグのしやすさと,予想外の事態での安全性を考えて,基本的にはこれらのオプションを有効にしておいたほうがいいだろう。

ただし,set -eを付けるかどうかは議論がわかれる。というのも,正常な処理だがexitステータスに非0を返す場合がありうるからだ。例えば,grepコマンド。検索フォームから入力されたデータを受け付けてgrepコマンドで検索を書ける場合,何もヒットしなくてもそれは正常だ。

filer1 | filter2 | ... | grep '(HOGEHOGE)'

しかし,grepコマンドでは何もヒットしなければ,失敗扱いにしてしまい,set -eを有効にしていればそこで強制終了してしまう。grepコマンドの最後に| catなどの無害なコマンドをおけば,set -eの強制終了は免れることはできる。しかし,これを理由に余計なコードを書くのは本末転倒となる。おそらく,「すべてで20年動く」の著者の松浦さんはこの点を懸念してset -eを記載しなかったのだろう。

そのため,set -eについては以下の方針をとるのがよいと考えた。

  • 安全・デバッグのため,常にset -eを付ける。
  • 必要に応じて,set -eは外す。

2017-01-22追記開始

やはり,set -eは常に付けるべきだと考えなおした。理由は以下2点だ。

  1. grepコマンドの挙動として,ヒットしなければエラーにするのが自然。
  2. ()でサブシェルとしてグループ化したときにexit 1で実行元も終了させるため。

まず,1点目はgrepコマンドの挙動に関する理由だ。grepコマンドはパターンをファイルから検索するコマンド。検索はパターンの存在/不在を期待して行うので,あれば成功,なければ失敗とするのが自然な挙動だ。検索フォームで検索をかけて見つからなかったときにエラーにしてほしくないというのは,検索フォームの一部の実装者の都合の話であって,「検索」自体にまで拡大して適用すべき考えではない。

実際に,以下のように文字列がヒットするかどうかの条件判定にも使われるし,それを想定したような-qオプションまである。

echo "$STR" | grep -q target && echo ok

grepの検索時の終了ステータスが,みつからなくてもエラーにならない場合,常に標準出力が空かどうかの判定が必要になる。これは都合が悪い。

2点目はより実務的な理由だ。シェルではコマンドをグループ化させるときに波括弧と{}と丸括弧()が使える。丸括弧を使えば,そのグループはサブシェルとして実行させる。サブシェルとなるため,変数のスコープを限定でき,親への影響を減らせるので役に立つ。しかし,サブシェルであるがゆえに,exitの挙動が波括弧と異なる。

例えば,エラーが発生したときにメッセージを表示するような関数・コマンドを書くとき,以下のように最後にexit 1でエラーにして終了させる。

exit_try_help()(
MESSAGE="Try command --help"
echo "$MESSAGE" >&2
exit 1
)

波括弧で囲っている場合,親が実行元となるのでそのままプログラム全体が終了となる。しかし,丸括弧で囲っている場合,そのサブシェルだけが終了する。エラー処理の意図として,そこでプログラム全体を終了させなくてはならない。しかし,丸括弧で囲っている場合は,さらに自分で関数の終了ステータスをみてexitするコードが必要になる。

始めから波括弧で囲っておけばよいという話かもしれないが,丸括弧を使えば変数のスコープを限定できるので,できれば活用したい。このときに,set -eを適用させておけば,例え丸括弧でサブシェルとして起動したとしても,関数の終了ステータスを見て親も終了させることができる。

上記2点の理由から,setコマンドのオプションには-uだけでなく-e常につけるべきと判断した。

2017-01-22追記終了

2017-01-29追記開始

では,grepの検索結果として0件があり得る場合はどうするばいいか?その場合は,上記で記載した通り,エラー処理を書けばいい。検索結果が0件の場合に何もしたくなければ,文字通り何もしないコマンド:を実行すればいい。

filter1 | filter2 | grep || :

| catを最後に付ける場合は,常に無関係な内容が標準出力に流れてしまい,やり方としてよくない。これに対して,|| :を付ける場合は,失敗したときにだけしか影響を与えず,文字通り何も処理を行わないので,grepに限らずあらゆるコマンドに適用できる。

このset -eについては1点注意すべきことがある。それは,bash 3系では機能しない場面があることだ。

例えば,以下のようにパイプで繋いだ上でのwhileなどの制御構造内でexit 1した場合や,(){}でグループ化した中でexit 1場合に,set -eの機能により親シェルが終了しない。

#!/bin/bash

set -e

echo 1 | while read line; do
  exit 1 # not exit
  echo 'echo 1 | while'
done

while [ $((i+=1)) -lt 3 ]; do
  exit 1 # exit
  echo 'while'
done

exit 1 ## exit
echo 'echo 1 | exit 1'

(exit 1) ## not exit
echo 'echo 1 | (exit 1)'

echo 1 | { exit 1; } ## not exit
echo 'echo 1 | { exit 1; }'

{ exit 1; } ## exit
echo '{ exit 1; }'

これはbash 3系固有の挙動であり,3.0と3系最新の3.2.57で確認できた。この他のdashやzsh,kshでは問題なかった。

この現象は以下で最初に報告された。

この原因については,以下のツイートで解説されている。

bash 3ができたのは2000年代前半であり,現在のPOSIXのベースとなっている2008年の改訂内容(POSIX:2008,SUSv4相当)とは異なっている。当時のPOSIX規格(POSIX:2004,SUSv3相当)では,set-eオプションはsimple commandが失敗したときに作用することとされている。

-e
When this option is on, if a simple command fails for any of the reasons listed in Consequences of Shell Errors or returns an exit status value >0, and is not part of the compound list following a while, until, or if keyword, and is not a part of an AND or OR list, and is not a pipeline preceded by the ! reserved word, then the shell shall immediately exit.
set - XCU - POSIX:2004

なお,POSIX:2008ではこのsimple commandsの縛りがなくなっており,単にcommandsとされている。

パイプを使って,制御構造やグループ化コマンドを使うと,simple commandsの条件から外れるため,-eの効果を受けなくなったのだと思われる。

このbash 3のset -eの挙動をカバーするなら,以下3点のアプローチが考えられる。

  1. set -eを使わない
  2. (exit 1)を諦める
  3. 制御構造や複合文((){})でのexit 1に注意する

1と2は確実だが,それだとbash 3のためだけに利便性が損なわれる。3の方法に従い注意するのがよいだろう。また,set -eだけに頼るのではなく,クリティカルな部分ではきちんと専用のエラー処理・終了処理を書くべきだろう。

2017-01-29追記終了

また,setコマンドはシェルのオプションとしても指定できるので,shebangに以下のように記述してもよい。

#!/bin/sh -eu

umask 0022

umaskコマンドの実行自体に異論はない。このコマンドで新しく生成するファイルのアクセス権を変更できる。事前に作成するファイルのアクセス権を設定しておかないと,アクセス権を変更するまでの間にわずかに第三者にファイルの改ざんを許すことになってしまう。

Ubuntu 16.04では0002となっていた。

umask
0002
echo a >a.dat
umask 0022
echo b >b.dat
ls -l
-rw-rw-r-- 1 senooken 2 2016-12-14 21:42 a.dat
-rw-r--r-- 1 senooken 2 2016-12-14 21:42 b.dat

Ubuntu 16.04のデフォルト状態(0002)では,自分の作成したファイルであっても同じグループのユーザーが勝手に書き込むことができてしまう。umask 0022で書き込みできるのは自分だけにしたほうが無難だろう。

また,個人情報のように機密性の高い情報を扱う場合は,umask 0077のようによりアクセス制限を厳しくすることを検討してもよいだろう。

unset IFS

IFS変数は,forやread,$@$*などの区切り文字を指定する。何かと重宝する。例えば,IFS=,として,カンマ区切りのデータをforで1個ずつ処理することができる。IFS変数の期待される初期値は <space> <tab> <newline>だ。

「すべてで20年動く」の本では以下のようにしてIFSを初期化していた。

IFS=$(printf ' \t\n_'); IFS=${IFS%_}

また,POSIX規格で例示される初期化方法は,以下の通り素直に文字を記入している。

IFS='
'
#    The preceding value should be <space><tab><newline>.
#    Set IFS to its default value.

「すべてで20年動く」のやり方はごちゃごちゃしすぎている。また,POSIX規格のサンプルは改行をそのまま入れるため見た目が悪い。

両者のアプローチの通り,直接IFS変数に値を入れてももちろんいい。しかし,POSIX規格でIFS変数の値は未設定なら標準の値(スペース,タブ,改行)が設定されることが保証されている。

If IFS is not set, it shall behave as normal for an unset variable, except that field splitting by the shell and line splitting by the read utility shall be performed as if the value of IFS is <space> <tab> <newline>; see Field Splitting.
2.5.3 Shell Variables - Shell Command Language
1. If the value of IFS is a <space>, <tab>, and <newline>, or if it is unset, any sequence of <space>, <tab>, or <newline> characters at the beginning or end of the input shall be ignored and any sequence of those characters within the input shall delimit a field.
2.6.5 Field Splitting - Shell Command Language

よって,単純に以下のようにIFSを解除するだけで,POSIX規格に準拠して安全にIFS変数を初期化できる。

unset IFS

同じ結果が得られるのなら,よりシンプルなこの方法が優れているだろう。

2016-12-16追記:

他の指摘を受けて改めてIFS変数についてPOSIX規格を確認していたら,見落としを発見した。

IFS
A string treated as a list of characters that is used for field splitting, expansion of the '*' special parameter, and to split lines into fields with the read utility. If the value of IFS includes any bytes that do not form part of a valid character, the results of field splitting, expansion of '*', and use of the read utility are unspecified.
If IFS is not set, it shall behave as normal for an unset variable, except that field splitting by the shell and line splitting by the read utility shall be performed as if the value of IFS is <space> <tab> <newline>; see Field Splitting.

The shell shall set IFS to <space> <tab> <newline> when it is invoked.
2.5.3 Shell Variables - Shell Command Language

つまり,IFS変数はシェルの起動時にスペース,タブ,改行で自動で初期化されることがPOSIX規格で保証されるので,そもそもスクリプトでの初期化は不要だった。

export LC_ALL='C'

「すべてで20年動く」のp. 41-43「1-16 ロケール」で解説されているように,一部のコマンドはロケール系環境変数(Internationalization Variables)の値によって挙動が変化してしまう。例えば,LANG=ja_JP.UTF-8であれば,dateコマンドの出力結果が日本語となったり,joinやsortコマンドの列区切りに全角空白が加わってしまう。これを防ぐために,ロケールを固定する。

LC_から始まるロケール変数は時間や金額などいくつも存在するが,一括で設定できる3種類の変数が存在する。

  • LANG
  • LC_ALL
  • LANGUAGE

この内,LANGとLC_ALLはPOSIXで定義されており,LANGUAGEはGNU gettextで定義されている。LANGUAGEコマンドは主にコマンドのヘルプの言語などで使われる。LANGとLC_ALLよりも優先順位が高い。

これらの変数は以下の関係がある。

一括設定ロケール変数
LANG
LC_ALLを含め,値の設定されていないLC_変数についてはLANGの値を利用
LC_ALL
全LC_変数とLANGの値を上書き
LANGUAGE
LANGまたはLC_ALLの値が'C'でなければ,LANGUAGEの値を利用

つまり,基本的に以下の順番の優先順位となる。

  1. LANGUAGE
  2. LC_ALL
  3. LANG

LC_ALLやLANGの値がCの場合のときだけ,例外的にLANGUAGEよりもLC_ALLやLANGの値が優先される。

それぞれの根拠は以下の通り。

LANG
This variable shall determine the locale category for native language, local customs, and coded character set in the absence of the LC_ALL and other LC_* (LC_COLLATE, LC_CTYPE, LC_MESSAGES, LC_MONETARY, LC_NUMERIC, LC_TIME) environment variables. This can be used by applications to determine the language to use for error messages and instructions, collating sequences, date formats, and so on.
8.2 Internatinalization Variables - XBD
LC_ALL
The value of this variable overrides the LC_* variables and LANG, as described in XBD Environment Variables.
2.5.3 Shell Variables - Shell Command Language

GNU gettext gives preference to LANGUAGE over LC_ALL and LANG for the purpose of message handling, but you still need to have LANG (or LC_ALL) set to the primary language; this is required by other parts of the system libraries.


Note: The variable LANGUAGE is ignored if the locale is set to ‘C’. In other words, you have to first enable localization, by setting LANG (or LC_ALL) to a value other than ‘C’, before you can use a language priority list through the LANGUAGE variable.

2.3.3 Specifying a Priority List of Languages - GNU gettext utilities

ロケールの値には以下で記載されている通り,CまたはPOSIXを指定することで,POSIXロケールとなりPOSIX規格で規定される標準的なコマンドの振る舞いにできる。

If the locale value is "C" or "POSIX", the POSIX locale shall be used and the standard utilities behave in accordance with the rules in POSIX Locale for the associated category.

8.2 Internationalization Variables - XBD

「すべてで20年動く」のp. 43ではGNU gettextのLANGUAGE変数の設定を上書きするために,LANGとLC_ALLの値にCを設定しないといけないと勘違いしている。実際のところ,マニュアルや規格を確認すればわかる通り,単にLC_ALL='C'と記述するだけで全てのロケールをPOSIXロケールに設定できる。

また,このp. 43とp. 37ではもう一つ誤りがある。それはexportコマンドにより変数を環境変数として再設定している点だ。POSIX規格に書かれている通り,IFS,LC_ALL,PATH変数は環境変数の値を初期値としてもつただのシェル変数だ。

Variables shall be initialized from the environment (as defined by XBD Environment Variables and the exec function in the System Interfaces volume of POSIX.1-2008) and can be given new values with variable assignment commands.
2.5.3 Shell Variables - Shell Command Language

したがって,設定を有効にするためにexportコマンドを実行する必要はなく,単に値を変更すれば即座に適用される。

2016-12-16追記:

LC_ALLとPATH変数についてはこれは間違いだった。exportコマンドにより,これらは環境変数として設定しなければならない。

理由はシェルスクリプトの起動元(#!/bin/sh)である,shが環境変数としてこれらの変数を参照しているからだ。

特に,LC_ALL変数はほとんどのPOSIX準拠コマンドが環境変数として参照している。

The entire manner in which environment variables described in this volume of POSIX.1-2008 affect the behavior of each utility is described in the ENVIRONMENT VARIABLES section for that utility, in conjunction with the global effects of the LANG, LC_ALL, and [XSI] [Option Start] NLSPATH [Option End] environment variables described in XBD Environment Variables.

1.4 Utility Desciription Defaults - Shell & Command Utilities

例えば,dateコマンドのENVIRONMENT VARIABLESのセクションに環境変数としてLC_ALLやLANGを参照している。実際に以下のようなコマンドを実行すると環境変数が優先されていることがわかる。

#!/bin/sh
export LANG=ja_JP.UTF-8 date LC_ALL=C date (date) sh -c "date"
2016年 12月 16日 金曜日 21:41:26 JST
2016年 12月 16日 金曜日 21:41:26 JST
2016年 12月 16日 金曜日 21:41:26 JST
2016年 12月 16日 金曜日 21:41:26 JST

ここで,LC_ALL=Cexport LC_ALL=Cに変えるだけで,ロケールがサブシェルなどにも反映される。

2016年 12月 16日 金曜日 21:41:49 JST
Fri Dec 16 21:41:49 JST 2016
Fri Dec 16 21:41:49 JST 2016
Fri Dec 16 21:41:49 JST 2016

したがって,LC_ALLはシェル変数ではなく環境変数として設定しなければならない。

また,PATH変数もshだけでなく,typeコマンドのように一部のコマンドはPOSIX規格で環境変数として参照している。多くの環境ではシェル変数としてPATH変数に値を代入した場合にサブシェル(シェルスクリプト内でのsh -cなど)にも引き継がれるのだが,FreeBSDのshでは引き継がれない(FreeBSD 11.0で確認)。exportによりPATHを環境変数としてやればきちんと反映される。

export PATH="$(command -p getconf PATH):$PATH"

PATH変数の初期化は極めて重要だ。例えば,.bashrcの最後にunset PATHを追記されたり,lsという名前でrmコマンドが実行されるなどのコマンド名は見慣れているが中身が全く違う悪意あるプログラムのPATHを先頭に持ってこられたりしたら致命的な問題となる。

「すべてで20年動く」p. 37「1-9 環境変数などの初期化」においては以下のように決め打ちで指定されている。

PATH='/usr/bin:/bin'

これには以下2点の問題がある。

  1. /usr/bin:/binはPOSIXで未保証
  2. /usr/bin:/bin以外にインストールされているPOSIX準拠コマンドが利用不可能

/usr/bin/binにPOSIX準拠コマンドが全て存在しているとは限らない。POSIXではこれらのディレクトリは規定されていないからだ。また,/usr/bin:/binで固定化してしまうと,システム管理者やユーザーが追加でインストールしたPOSIX準拠コマンドを利用できない。例えば,bcコマンドは標準でインストールされてないOSがあり,システム管理者に/usr/local/binなどにインストールされていることも考えられる。

したがって,「すべてで20年動く」のPATH変数の初期化方法はよろしくない

それではどうするのが最善か?実はこのPATH変数を安全に完全に初期化する方法がある。それはcommandコマンドを使う方法だ。初期化するコードは以下となる。

PATH="$(command -p getconf PATH):$PATH"

上記コードは以下の手順でPATH変数を設定している。

commandコマンドによるPATH変数の初期化手順
  1. command -pによりPOSIXで保証される標準PATHからgetconfコマンドを検索
  2. getconfコマンドにより標準PATHの値を取得
  3. コマンド代入$()により,getconfコマンドの出力結果を取得
  4. 既存のPATH変数の直前にgetconfで取得した安全な標準PATHを配置

この方法が優れている理由は以下3点だ。

commandコマンドによるPATH変数の初期化方法の利点
  1. commandコマンドは組み込みであり,PATH変数が未定義でも,同名コマンドがあっても利用可能
  2. commandコマンドの-pオプションで標準のPATHを保証
  3. POSIX規格で例示される公認コード

決め打ちでPATH変数を初期化しない場合,なんらかのコマンドにより標準的なPATH変数の値を取得する必要がある。その目的として使えるコマンドとしてgetconfが存在する。このことは,bashクックブックの「レシピ14.3 安全な $PATHの設定」にも書かれている。

getconfコマンドは設定値を取得するコマンドであり,引数の最大値ARG_MAXなどシステム設定値を取得できる。ただし,getconfコマンドは外部コマンドなので,まずgetconfコマンドを安全に起動する必要がある。そのために,command -pを使う。commandコマンドは-vか-Vオプションをつけなければ,引数に渡されたコマンドを実行するコマンドだ。

1個目の利点だが,commandコマンドはPOSIXで規定されるBuilt-In Utilities(組み込みコマンド)だ。そのため,仮にPATH変数が空でも実行できる。また,POSIXの「2.9 Command Search and Execution - Shell Command Language」を見ればわかる通り,組み込みコマンドは外部コマンドよりも検索優先順位が高い。そのため,同名コマンドがPATHに存在していても必ず本物が優先される。

なお,初期化で既に利用したumaskもbuilt-In utilitiyであり,setとunsetはspecial built-in utility(特殊組み込みコマンド)であり,安全に実行できることが保証されている。

2点目の利点だが,commandコマンドのオプション-pは,標準コマンドの存在が保証されるPATHの標準値からコマンド検索を実行する。したがって,command -pにより実行されるコマンドは安全であることが保証される。これにより,getconfコマンドを安全に起動することができる。

3点目の利点だが,この方法はcommandコマンドのEXAMPLESで例示されておりPOSIX規格公認のPATH変数の初期化方法とみなせる。実際のところ,getconfコマンドはXSHで規定されるようなシステム値(何かの最大値であったり最小値)の取得はできるが,こうした環境変数の取得については「The implementation may add other local values.」とあるように実装依存のところがあった。しかし,POSIX規格で例示されているので安心して利用できる。

commandコマンドのEXAMPLESで例示では,より安全にPATHを設定するためにunaliasunset -f commandなども書かれている。しかし,サブシェルで起動した時点でaliasや関数は初期化されるので,シェルスクリプトの冒頭で書くのならこれらは不要だ。

なお,冒頭でset -uを有効にしたため,万が一PATH変数が設定されていない環境でエラーが出ないように以下のように未定義の場合は空文字を返すようにした。

export PATH="$(command -p getconf PATH):${PATH:-}"

これは,set -uより前にPATHを初期化すれば不要になる。個人的に,umaskやsetの方がシェルそのものの挙動に影響を与え,影響範囲が大きいと判断してこの順番にした。

2016-12-19追記

当初はset -uで万が一PATHが未定義の場合でもエラーで落ちないように${PATH:-}としていたが,やはり通常通りの$PATHとした。

export PATH="$(command -p getconf PATH):$PATH"

理由は以下4点。

  1. 見栄えが悪い。
  2. set -uで落ちないように,ガードするのは本末転倒。
  3. PATH変数が未定義の場合は異常事態なので,そこで止まってほしい。
  4. PATH変数が未定義の場合,sh起動時に実装依存で標準値が設定されていることが多く,未定義なことが考えにくい。

シェルスクリプトの初期化は今後何度も見ることになるため,きれいなコードを維持したい。set -uで落ちることを防ぐという,本末転倒な理由のために汚いコードを残すのは忍びない。だから,POSIX規格で書かれている通りのコードにした。

Conclusion

以上のことから,POSIX原理主義によるシェルスクリプトでは,常に以下のコードを初期化のために冒頭で記述することが望まれる。

set -eu
umask 0022
export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"

例えば,以下のように関数にまとめてしまってもよいだろう。

#!/bin/sh

init(){
set -eu
umask 0022 export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"
}

main()(
init
# command
)

main

「徹底的に調べ尽くす」という自分のPOSIX原理主義を実践できた。自分一人ではここまでのことを思いつけなかった。最初にサンプルがあり,そこに疑問をもってPOSIX規格を徹底的に読み込むことで今回の結論が得られた。今回の結論は「巨人の肩の上に立つ」ことで得られたものであり,叩き台として最初に例を挙げてくださった「すべてで20年動く」の著者である松浦さんに感謝したい。

2016-12-08

How to time a program in POSIX shell script

この記事はPOSIX原理主義Advent Calendarの8日目だ。

POSIXに準拠した,プログラムの実行時間の計測方法を紹介する。

結論としては,timeコマンドを使って以下のようにサブシェルで実行するのが最善と判断した。

\time -p sh -c 'sleep 1'

Introduction

シェルスクリプトは複数のコマンドや構文の組み合わせにより構成される。そのため,ある処理を行う場合に複数の実現方法が存在することがよくある。例えば,変数VARと文字列TARGETが一致するかの判定には,以下の3通りの方法がある。

[ "$VAR" = "TARGET" ]                   && echo MATCH
printf '%s' "$VAR" | grep -q '^TARGET$' && echo MATCH
case "$VAR" in 'TARGET') echo MATCH;; esac

複数の実現方法が存在する場合は,以下の要素を考慮してどれが最善の方法かを検討して選ぶだろう。

コード選択時の判断材料
可読性
人が見て理解しやすい
記述量
記述量が少ない
実行速度
実行速度が速いか

これらの内,[可読性]と[記述量]はコードとして書かれた時点で人が判断できる。しかし,実行速度はコードを実行して実際に時間を計測しなければわからない。場合によっては,実行速度が圧倒的に速いために可読性や記述量が多少悪くても最善の方法になることも考えられる。

POSIX原理主義を実践していくうえで,シェルスクリプトは実行速度が遅いという風評被害を受けることがある。これは,複数の実現方法の中から実行速度が遅いものを選択した場合や,実行速度の遅い方法しか知らない場合になされる批判だ。

そこで,POSIXに準拠したプログラムの実行速度の計測方法を検討し,実行速度が遅いという批判を避けられるようにする。

時間の計測方法

まず,実行速度の計測にあたって,計測する時間の種類を把握しておく。例えば,プログラムの開始時から終了時までなのか,プログラムの実際の処理時間だけをみるのかといったように,用途によって注目すべき項目が異なってくる。

POSIXにおける実行時間はtimeコマンドの節で説明されている3種類となる。

POSIXにおける実行時間の種類
種類説明
Real timeプログラムの呼び出しから終了までの時間。I/Oの待ち時間を含む。
User CPU timeプログラム自体の実行時間。
System CPU timeプログラムを実行するためのOSの処理時間。

Real timeはコマンド呼び出しから終了までの合計時間である。単に実行速度について議論するときはReal timeを指すことが多い。User CPU timeとSystem CPU timeに加え,これら2種類以外の全ての要素(I/Oやネットワークの待ち時間など)の+αの要素を含む。式で記述すると以下となる。

Real time = User CPU time + System CPU time + α

User CPU timeはコマンド自体の処理時間である。プログラム自体のパフォーマンスに注目する場合はこの時間を参照する。

System CPU timeはプログラムを実行するために必要なリソースの割当などのOS自体が処理する時間である。System CPU timeを参照することはあまりない印象だ。

計測時間の保証精度

計測方法の説明の前にもう一つ知っておく必要がある。それは,POSIXでの計測時間の保証精度だ。計測した時間の秒の内,小数点以下何桁までPOSIXで使えるとみていいかだ。

POSIXでのプロセスの時間計測にはsys/times.hで定義されるtimes関数を使われている。times関数のページで以下の記述がある。

All times are measured in terms of the number of clock ticks used.

...

Applications should use sysconf(_SC_CLK_TCK) to determine the number of clock ticks per second as it may vary from system to system.

times - XSH

つまり,時間の計測にはclock tick(クロック単位)を使っていることになる。そして,clock tickはシステムで異なるのでsysconf(_SC_CLK_TCK)で取得すべきだとのこと。clock tickは以下で定義される。

3.97 Clock Tick

An interval of time; an implementation-defined number of these occur each second. Clock ticks are one of the units that may be used to express a value found in type clock_t.
3.97 Clock Tick - Definitions - XBD

1秒間に発生する実装で定義される回数での時間の間隔。この回数で時間の間隔が処理されている模様。clock_tの値でclock tickの値を確認できるらしい。

実際にclock tickの値を確認してみる。以下のコードをgccでコンパイルして実行してみたところ,以下の結果になった。

  • MSYS2だと1000
  • Ubuntu 16.04だと100
clock tickの表示
////////////////////////////////////////////////////////////////////////////////
/// \file      clock_tick.c
/// \author    SENOO, Ken
/// \copyright CC0
////////////////////////////////////////////////////////////////////////////////

#include <stdio.h>
#include <unistd.h>

int main(void){
  printf("%ld\n", sysconf(_SC_CLK_TCK));
  return 0;
}

clock tickが確認できたところで,このclock tickの値と計測時間の精度をどう見るかを考える。timeコマンドに以下の説明があった。

The precision used may be less than the default six digits of %f, but shall be sufficiently precise to accommodate the size of the clock tick on the system (for example, if there were 60 clock ticks per second, at least two digits shall follow the radix character).

time - XCU

timeコマンドやtimesコマンドなど計測時間を表示するコマンドの表示結果の書式は%fという表記を使っており,ここの精度は基本的に処理系依存となる。しかし,上記説明で書かれている通り,この精度はclock tickのサイズに対応できなければならない。例にあるとおり60 clock ticksであれば,1/60≒0.0167であり0.01 sの単位までは保証されなければならないとある。

実際にclock tickを確認したところUbuntu 16.04で100だった。0.1 sの単位までの精度の保証で済むのはclock tick=10(1/10=0.1 s)までで,clock tick>11となれば1/11≒0.091 sとなり,0.01 sの精度の保証が必要となる。現実的にclock tick<=10は小さすぎるので0.1 sの単位の保証で済むとは考えにくい。

したがって,時間の計測時には小数点以下第2位である0.01 s(10 ms)の単位までは保証されると考えてよいだろう。

計測方法

ここからは,実際にPOSIXに準拠した実行時間の計測方法について説明する。以下の3通りの方法が存在する。

  1. date
  2. times
  3. time
date

dateコマンドには2種類の機能がある。

  • 現在日時の出力
  • システム日時の更新

この内,時間の計測には現在日時の出力を利用する。dateコマンドでの実行時間の計測手順は以下となる。

  1. 計測対象プログラムの実行前後でdateコマンドを実行
  2. dateコマンドの実行結果を変数に保存
  3. 実行前後の時間の差分により時間を計測
dateコマンドによる時間計測例
#!/bin/sh -u
## \file time-date.sh
start=$(date +%s)
sleep 1
stop=$(date +%s)
echo "$((stop - start))"
dateコマンドによる時間計測結果例
1

dateコマンドによる時間計測は,プログラムの実行前後の時間の差分をとるということで,直感的でわかりやすい。しかし,以下の欠点がある。

dateコマンドによる時間計測の欠点
  1. 最小計測単位が1 sであり精度が低すぎる。
  2. Real timeしか計測できない。

GNU coreutilsに含まれるdateコマンドではns(ナノ秒)を表現する%Nという記法がサポートされている。しかし,POSIXでは定義されていない。POSIXでの最小単位は秒(s)%Sである。たいていのコマンドは1 ms以内で動作するので,dateコマンドで測定するには秒の単位になるまで自分でループさせる必要がある。そして,時間計測のためにそれだけユーザーが待たなければならない。また,最小計測単位が1 sであり,有効数字が1桁であるので精度が悪い。例えば,計測して8 sだったとしても,この値はその一つ下の位である0.1 sの誤差を含んでおり信頼性が低い

実際に時間を計測する際には,マシンの状態も考慮して複数回試行する。1回の測定単位が秒だと1コマンドの測定にあたって少なくとも10秒程度かかる。これでは効率が悪すぎる。

また,Real timeしか計測できないので,本当にパフォーマンスがシビアなプログラムの計測では余計な待ち時間まで含んでしまうため,正確な速度がわからない。

times

timesコマンドはPOSIXのspecial built-in utilityとして定義されている。

このコマンドは引数を取らず,現在のプロセスと,全ての子プロセスの合計のUser CPU timeとSystem CPU timeを出力する。Real timeを出力しないところに注意する。

例えば,現在のシェルでtimesコマンドを入力すると,以下のように1行目に現在のプロセスのUser CPU timeとSystem CPU timeを表示し,2行目には全子プロセスの時間を表示する。

times
0m0.088s 0m0.052s
0m17.144s 0m1.552s

timesコマンドによる時間の計測はスクリプトの最後にtimesコマンドを実行して行う。

timesコマンドによる時間計測
#!/bin/sh -u
## \file time-times.sh yes | head -n 100000
times
timesコマンドによる時間計測結果例
0m0.000000s 0m0.000000s
0m0.000000s 0m0.020000s

また,コマンドライン上で行う場合は以下のようにshコマンドを起動してそこで処理するようにすればよい。

sh -c 'for i in $(yes | head -n 10000); do echo $i; done; times'

なお,shでの秒の出力結果の精度が6桁となっているが,実際のところ数字が表示されるのは小数第2位までだった。これは,計測時間の保証精度で議論したとおりの精度だ。

timesコマンドの欠点は以下となる。

timesコマンドによる時間計測の欠点

  1. Real timeが計測できない。
  2. 計測対象が親と子プロセスで分かれる。

一番必要と思われるReal timeを計測できないが残念だ。また,計測対象が親と子で分かれているのは利点でもあり欠点でもある。実行時間計測したいときは,通常全体の時間が問題になる。子プロセスの実行時間が必要であれば,そこだけ別で計測すればいいからだ。

timesコマンドは,dateコマンドのように自分で計算する必要がなくスクリプトの一番最後で実行するだけでよいので簡単だ。

参考:bash - How to get execution time of a script effectively? - Unix & Linux Stack Exchange
time

timeコマンドはコマンドの実行時間を計測するコマンドだ。引数で指定したコマンドを実行し,Real timeとUser CPU time,System CPU timeの全てを表示してくれる。

timeコマンドの表示結果の違い

timeコマンドをそのまま実行すると,環境によって表示結果が異なってしまう。これはbashやzshの構文としてtimeが存在するからだ。試しにいろんなシェルでのtimeの実行結果を以下の表に示す。

timeコマンドの表示結果例
シェルtime sleep 1の実行結果
dash
0.00user 0.00system 0:01.00elapsed 0%CPU (0avgtext+0avgdata 2116maxresident)k
0inputs+0outputs (0major+81minor)pagefaults 0swaps
bash
real    0m1.001s
user    0m0.000s
sys     0m0.000s
zsh
sleep 1  0.00s user 0.00s system 0% cpu 1.003 total
csh
0.0u 0.0s 0:01.00 0.0% 0+0k 0+0io 0pf+0w
tcsh
0.000u 0.000s 0:01.00 0.0%      0+0k 0+0io 0pf+0w
ksh
real 0m1.00s
user 0m0.00s
sys 0m0.00s

したがって,shebangが#!/bin/bash#!/bin/cshなどと書かれたシェルスクリプトではシェル構文のtimeが呼ばれてしまうので,表示結果がばらばらになってしまう。timeコマンドの表示結果を統一するには以下の手順をとる。

timeコマンドの表示結果の統一方法
  1. コマンドライン上などで使う場合は,timeコマンドをエスケープして(\time)明示的にコマンドを指定。
  2. -pオプションを指定して表示結果をPOSIXに準拠させる。

bashなどでtimeとそのまま入力すると組み込みコマンドが呼ばれてしまう。それを避けるために,以下のようにエスケープして明示的に外部コマンドのtimeを実行してやる。

\time sleep 1

また,timeコマンドの既定の表示結果はPOSIXで定義されていない。-pオプションを指定した場合のみ以下の形式で表示されることが規定されている。

real 1.00
user 0.00
sys 0.00

したがって,timeコマンドをシェルスクリプトで使う場合は-pオプションを常に付けるべきだろう。ただし,-pオプションをつけると表示される結果が小数点第2位までとなるので注意する。コマンドライン上でぱっと確認したいだけならば,bashやzshの組み込みのtimeコマンドの方が便利なのでそちらを使うのもありだろう。

timeコマンドの可搬性

2017-01-07追記:

IBM AIX 7.1ではtime -pとして実行した場合に,-pオプションを認識できず以下のようなエラーが出てしまう。

#!/bin/sh
time -p sleep 1
-p: Command not found.

これは,IBM AIXの/bin/shの実体がksh88となっているためだ。kshの組み込みのtimeには元々-pオプションは存在しない(cshとzshのtimeにも不在,bashのtimeには-pが存在)。しかし,ksh93であればtime -pとした場合,-pオプションを認識してシェル構文ではなく外部コマンドのtimeを実行してくれていたので大丈夫だった。本来の動作だと思うが,ksh88では常にtimeをシェル構文として扱うようで,-pをコマンドとみなしてしまうためにこのようなエラーが出てしまった。

シェル組み込みのtimeは,実は組み込みコマンドではなくcaseforのような予約語として存在するシェル構文となっている。このことは,typeコマンドの出力結果で確認できる。

bash
type time
type command
time is a shell keyword
command is a shell builtin

シェル構文はプロセスが新しく生成されないため,コマンドよりも実行速度が速く,本来であれば利用が推奨される。しかし,timeに関しては外部コマンドのtimeと名前が被ってしまい,シェル構文が優先されてしまうと,今回のように可搬性を高めるための-pオプションが使えなくなってしまう

これを回避するためには,クォートにより予約語を無効化する。この機能はPOSIXで明記されている。

Quoting is used to remove the special meaning of certain characters or words to the shell. Quoting can be used to preserve the literal meaning of the special characters in the next paragraph, prevent reserved words from being recognized as such, and prevent parameter expansion and command substitution within here-document processing (see Here-Document).

2.2 Quoting - XCU

以下のように,予約語の全体または一部分に対して,バックスラッシュでエスケープしたり,一重や二重の引用符('")で囲むと単語全体がクォートされ,予約語が無効化される。

\time
t\ime
t"im"e
ti'me'

この他に,以下のようにcommandコマンドを使って明示的に外部コマンドとしてtimeを実行することでも対応できる。しかし,記述の簡潔さからバックスラッシュでクォートするのがよいだろう。

command time -p sleep 1

IBM AIXのように/bin/shの実体としてksh88が使われている場合があるので,シェルスクリプトでtimeを使う場合は,出力形式を統一するために常に\time -pにより外部コマンドのtimeを使うべきだろう。

timeコマンドでの時間計測

timeコマンドで時間を計測する場合は,基本的に計測対象プログラムを引数に指定するだけでよい。

\time -p sleep 1

しかし,この方法だとパイプを含む場合そこで計測対象が終わってしまう。パイプを含む全体を計測対象とするには,timeコマンドの引数にシェルを指定し,そのシェルの中で実行させる。

\time -p sh -c 'for i in $(yes | head -n 10000); do echo "A" | grep -q "A"; done'

このシェルの中で実行させる方法だと,forやcaseなどのシェルの組み込み構文での速度計測にも使える。サブシェルで実行させなければ,いちいちスクリプトファイルに書き出してtimeコマンドを実行しないといけないのでこれは便利だ。

timeコマンドを使う場合,Real time,User CPU time,System CPU timeの全てを計測できる。また,引数に計測対象を指定するだけでよいので簡単だ。

その他の計測方法

POSIX非準拠だったり,時間計測としてはあまり適さないと判断した方法を参考までに掲載する。

/proc/uptime

まず,POSIXの範囲を超えるのならば,/proc/uptimeを使う方法がある。

この方法では,システム起動からの経過時間を最小単位10ミリ秒で取得可能とのこと。

この方法だと,POSIX非準拠になるうえ,測定可能な時間はReal timeだけとなる。多くのLinuxでは存在するので百歩譲って許容するとしても,自分で読み込みのためのスクリプトを書く必要があり手間である。

ps

psコマンドでも時間を測ることができる。

以下のコマンドのようにプロセスと表示させる項目を選択すれば,プロセスの処理時間を取得できる。

ps -o stime,time $$
STIME     TIME
20:03 00:00:00

この方法だと,最小単位が秒であり精度が低い。その割に,終了時間を得るのが煩雑になってしまう。

How to check how long a process has been running? - Unix & Linux Stack Exchange

C言語で自作

最後はPOSIXのC言語の範囲でプログラムを書くことだ。

実行時間の計測には,clock()getrusage()関数が使えるようなので,これらを使ったC言語プログラムを作成しても実現できるだろう。

しかし,C言語でやる場合は自分で書く必要があり,またコンパイルも必要となる。最後の手段にすべきだろう。

CPU time - Wikipedia

Discussion

ここまでで,実行時間の計測方法を紹介した。これらの中でどれがベストであるかを検討する。検討にあたって,時間計測結果に必要と考える以下2項目の対応状況を確認する。

  • ミリ秒の精度での計測
  • Real timeとUser CPU timeの計測

時間計測結果の対応状況を以下の表にまとめた。

実行時間の計測方法の比較
コマンド・方法計測最小単位Real timeUser CPU timeSystem CPU time
date +%s1 s××
times10 ms×
\time -p10 ms
/proc/uptime10 ms××
ps1 s××

この結果から,全ての条件を満たしている\time -pがプログラムの実行時間の計測方法として最善と判断した。実際,計測手順も簡単で応用しやすいので順当な結果だろう。

Conclusion

プログラムの実行時間のPOSIXに準拠した計測方法を紹介した。プログラムを書く上で,実行速度はとても重要な要素である。「シェルスクリプトは速度が遅い」などといわれないように,複数の実現方法が思いついたときtimeコマンドで時間を計測し,最善の方法を検討しよう。

最後に,冒頭で掲示した変数VARと文字列TARGETが一致するかの3通りの方法(testgrepcase)の実行速度の測定コードと測定結果を示す。

#!/bin/sh -u
## \file time-match.sh

N=20000 VAR=TARGET \time -p sh -c 'for i in $(yes | head -n $N); do [ "$VAR" = "TARGET" ] && echo MATCH; done' >/dev/null
N=20000 VAR=TARGET \time -p sh -c 'for i in $(yes | head -n $N); do printf %s "$VAR" | grep -q ^TARGET$ && echo MATCH; done' >/dev/null
N=20000 VAR=TARGET \time -p sh -c 'for i in $(yes | head -n $N); do case "$VAR" in TARGET) echo MATCH;; esac; done' >/dev/null
real 0.03
user 0.03
sys 0.00
real 15.38
user 0.19
sys 2.18
real 0.01
user 0.01
sys 0.00

組み込みコマンドのcaseが最も速く,grepによるものが最も遅い結果となった。このような結果になった理由を簡単に解説する。

caseはシェルの複合コマンド(Compound Commands,制御構造)であり,通常のコマンドと異なり新しいプロセスが生成されない。そのため,基本的に速度が速い。grepによるマッチでは文字列の出力のために他と比べて余分にコマンドを実行していることと,正規表現のマッチ自体の処理があるため遅くなったのだと思われる。

2016-12-05

Introduction of references about POSIXism (POSIX fundamentalism)

#posixismadvent この記事はPOSIX原理主義Advent Calendarの5日目だ。

POSIX原理主義を実践していくうえで参考になる文献・書籍・ツールを紹介する。

POSIX原理主義とは何であるかについて知りたい場合や,POSIX原理主義を実践していくうえでのシェルスクリプトの知識を身に付けたい場合,頼りになる文献が必要となる。押さえておけばよいと思われる文献をまとめたので,これらを参考にして勉強していこう。

POSIX原理主義の文献

POSIX原理主義について書かれた文献をまとめる。

論文

POSIX原理主義について発表された論文や発表スライドを紹介する。

ソフトウェアの高い互換性と長い持続性を目差すPOSIX中心主義プログラミング
項目内容
タイトル ソフトウェアの高い互換性と長い持続性を目差すPOSIX中心主義プログラミング
発表日 2016-07-08
学会マルチメディア,分散,協調とモバイル(DICOMO2016)シンポジウム
表紙画像
書誌情報 松浦, 智之; 大野 浩之 & 當仲 寛哲 (2016) : ソフトウェアの高い互換性と長い持続性を目差すPOSIX中心主義プログラミング, DICOMO, pp. 1328 - 1334. http://tsys.jp/dicomo/program/7A.html.
解説 POSIX原理主義の論文発表。6ページ。論文本体は非公開。Windows 10においてBashが使えるようになったことにも触れられている。現在発表されている全文献の中で最もPOSIX原理主義について詳しく書かれている。POSIX原理主義について参照したり言及するときはこの論文を引用しよう。
POSIX中心主義と情報科学教育
項目内容
タイトル IoT時代に資するユニケージ開発手法の普及啓発に関する研究(2)POSIX中心主義と情報科学教育
発表日 2016-03-11
学会情報処理学会第78回全国大会
表紙画像
書誌情報 [論文]松浦, 智之; 中村, 和敬; 大野, 浩之 & 當仲, 寛哲 (2016) : IoT時代に資するユニケージ開発手法の普及啓発に関する研究(2)POSIX中心主義と情報科学教育 , Vol. 2016 , No. 1 , pp. 421 - 422. https://ipsj.ixsq.nii.ac.jp/ej/?action=pages_view_main&active_action=repository_view_main_item_detail&item_id=163079&item_no=1&page_id=13&block_id=8.
[スライド]松浦, 智之; 大野, 浩之 & 當仲, 寛哲 (2016) : IoT時代に資するユニケージ開発手法の普及啓発に関する研究(2)POSIX中心主義と情報科学教育 , 情報処理学会第78回全国大会. http://www.slideshare.net/tomoyukimatsura/posix-59447685.
解説 POSIX原理主義の初めての学会での露出。論文・スライドとも公開。要旨は2ページと少ない。教育の分野でのPOSIX原理主義の適用を軸に理論を紹介。POSIX原理主義の理論はこの時点で確立していた。この時点ではPOSIX原理主義による開発ソフトのライセンスは現在のCC0と異なりPublic Domainだった。
書籍

POSIX原理主義やPOSIX原理主義を実践するテクニックについて解説された一般販売書籍を紹介する。

Windows/Mac/UNIX すべてで20年動くプログラムはどう書くべきか
項目内容
タイトル Windows/Mac/UNIX すべてで20年動くプログラムはどう書くべきか
発表日 2016-11-10
表紙画像
書誌情報 松浦, 智之 (2016) : Windows/Mac/UNIX すべてで20年動くプログラムはどう書くべきか. http://richlab.org/coterie/pfb2.html.
解説 「すべてのUNIXで20年動くプログラムはどう書くべきか」の実質改訂第2版。論文で発表されてきたPOSIX原理主義の理論がきっちりと解説されている。また,POSIX原理主義を実践していくうえでのTipsが大量に掲載されている。特に,POSIX規格の正規表現の解説が参考になった。
Shell Script ライトクックブック 2014-2016
項目内容
タイトル Shell Script ライトクックブック 2014-2016
発表日 2016-08-14
表紙画像
書誌情報 リッチ・ミカン (2016) : Shell Script ライトクックブック 2014-2016. http://richlab.org/coterie/ssr2016.html.
解説 「20年動くプログラムはどう書くべきか」の下地となっている同人誌。POSIX原理主義の思想についても解説。書籍版と異なり,同人誌版は購入するとPDFの電子書籍版も入手できる。
すべてのUNIXで20年動くプログラムはどう書くべきか
項目内容
タイトル すべてのUNIXで20年動くプログラムはどう書くべきか
発表日 2015-08-04
表紙画像
書誌情報 松浦, 智之 (2015) : すべてのUNIXで20年動くプログラムはどう書くべきか. http://richlab.org/coterie/pfb.html.
解説 POSIX原理主義について初めて書かれた書籍。この頃は交換可能性というPOSIX原理主義の本質となる概念が考案されておらず,理論に未熟な部分があった。書籍の内容としては「Windows/Mac/UNIX すべてで20年動くプログラムはどう書くべきか」の下位互換であるが,値段が安いというメリットがある。
スライド

シェルショッカーが公開しているスライドを掲載する。

恐怖!シェルショッカーのPOSIX原理主義シェルスクリプト
項目内容
タイトル 恐怖!シェルショッカーのPOSIX原理主義シェルスクリプト
発表日 2016-03-20
イベント歌舞伎座.tech#9「異種プログラミング言語格闘勉強会」
表紙画像
書誌情報 シェルショッカー日本支部 (2016) : 恐怖!シェルショッカーのPOSIX原理主義シェルスクリプト. http://www.slideshare.net/ShellShoccarJpn/posix-59780910.
解説 ニコニコ動画のドワンゴ主催のIT技術勉強会である「歌舞伎座.tech」での発表。この発表によりシェルショッカーの存在が世間に知れ渡るようになった。当時の発表の様子はニコニコ動画の有料会員になれば今でも閲覧可能歌舞伎座.tech#9「異種プログラミング言語格闘勉強会」 - 2016/03/20 13:30開始 - ニコニコ生放送)。POSIX原理主義の実績がふんだんに紹介されており,説得力のある素晴らしい発表だった。POSIX原理主義に興味があれば,まずこのスライドを見ることを強く勧める。
恐怖シェルショッカー一号男!
項目内容
タイトル 恐怖シェルショッカー一号男!
発表日 2014-10-11
イベントPHP Conference Japan 2014
表紙画像
書誌情報 松浦, 智之 (2014) : 恐怖シェルショッカー一号男!. http://www.slideshare.net/tomoyukimatsura/1-php-con2014-40134119.
解説 POSIX原理主義集団である秘密結社シェルショッカーの設立発表と怪人シェルショッカー第一号(ショッピングカート)の誕生。シェルショッカーの活動はここから始まったといえる。

POSIXとシェルスクリプトの文献

狭義のPOSIX原理主義であるPOSIXに極力準拠したプログラミングや,POSIX原理主義で主に使われるシェルスクリプトについての文献を紹介する。

POSIX規格

狭義のPOSIX原理主義を実践していくうえで最重要文献がPOSIX規格だ。1988年に公布されて以来,IEEE Standard 1003やISOなどの承認を受けながら,内容に問題がないことの確認とともに改訂が重ねられている。POSIX規格は以下のページで参照できる。

The Open Group Base Specifications Issue 7, 2016 Edition

POSIX規格はOSが最低限守るべきとされる共通規格であり,C言語のシステムコールも含んでいる。そのため,単にPOSIX規格を参照すればいいといっても量が多すぎる。実際のところ,重要度が高いと思われる箇所があるのでそのポイントを解説する。

まず,POSIX規格は以下の4種類から構成される。

POSIX規格の構成
種類略称略称の由来説明
Base DefinitionXBDX Base Definition規格全体で共通の用語や概念,C言語ヘッダーファイルの説明。
System InterfacesXSHX System Interfaces and HeadersシステムコールなどC言語の関数を説明。
Shell and UtilitiesXCUX Command and Utilitiesシェル・コマンドの説明。
XRATXRATX RATional上記3文書の解説。

参考:Preface - The Base Specifications Issue 7

これらの4文書における重要なポイントを以下の表でまとめた。

POSIX規格における重要なセクションの紹介
文書セクション解説
XBD1. IntroductionPOSIXから参照している標準規格を掲載。例えば,C99やASCII文字コード,FORTRAN77など。その他に規格の専門用語(undefined,unspecifiedなど)を定義。

3. DefinitionsPOSIX規格で使われる用語を定義している。ファイル名に使える文字や,規格で登場する用語utilityの意味などが定義されている。POSIX規格で意味のわからない用語が出てきたら,このセクションを検索して該当項目を読もう。

8. Environment VariablesPOSIXで定義されている環境変数が定義されている。ロケールに関する環境変数も定義されている。ただし,POSIXで使える環境変数はこれが全てではないことに注意する。実際には,2.5.3 Shell Variables - XCUとXCUの各種コマンドのページでも環境変数が定義されている。XCUのコマンドで特に重要になる環境変数はshコマンドの環境変数だ。shはシェルスクリプトの起動元(#!/bin/sh)となるので,影響範囲が大きい。

9. Regular Expressions正規表現について定義。BRE(Basic Regular Expression)とERE(Extended Regular Expression)が存在する。これらは他のプログラミング言語で使われる正規表現のサブセットである。BREとEREさえ理解できれば,どの言語でも使える正規表現を書けるので極めて重要度が高い

10. Directory Structure and DevicesPOSIXで存在が保証されているディレクトリや,デバイスファイル/dev/nullなどが定義されている。

12. Utility Conventionsコマンドの引数やオプション仕様について定義。一部ロングオプションなどこの規定の範囲外の挙動もするが,世間で出回っているコマンドは基本的に暗黙の内にこの規約に準拠されている。コマンドのオプションを指定する場合や,コマンドを自作する場合に重要になる
XSH
ひたすらC言語の関数について記載されている。細かい挙動について理解するには重要だが,ひとまず後回しでもよい。
XCU
シェルスクリプトの構文とコマンドが定義されており,ほぼ全ての内容が重要3. Batch Environment Servicesのセクションのみ重要度が低い。また,175個のコマンドが定義されているが,全てが重要というわけではない。grepやsedなど主要なものを押さえていけばよいと思う。まずはこの規格をよく読んでシェルスクリプトの構文やコマンドの使い方を理解しよう。
XRAT
XBD,XSH,XCUの個別の項目について,歴史的経緯などより詳細な解説がなされている。補足説明として読むとよい。理解が不十分の場合,こちらの該当箇所の解説も読むことを勧める。例えば,2.5.2 Special Parameters - XCU$@$*の違いなど,具体例が掲載され詳しく説明されている。

POSIXで定義されているコマンドは全部で175個となる。文量が多く一度に全て把握することは困難なので,何度も見返しながら少しずつ理解を深めていくのがよいだろう。

シェルスクリプトの文献

POSIX原理主義の主なプログラミング言語はシェルスクリプトである。そこで,シェルスクリプトを記述するうえで参考になる文献についても紹介する。

入門UNIXシェルプログラミング
項目内容
タイトル 入門UNIXシェルプログラミング
発表日 2003-02-05
表紙画像
書誌情報 ブルース ブリン (2003) : 入門UNIXシェルプログラミング. http://www.sbcr.jp/products/4797321946.html.
解説 発売日が2003年と古い書籍だが,内容がとてもしっかりしている。未だに有用。これはPOSIXに準拠すれば,時間に関係ない知識を得られるということを示している。紙書籍版しかないのがとても残念。シェルスクリプトをやるなら一冊手元にほしい。
入門bash 第3版
項目内容
タイトル 入門bash 第3版
発表日 2005-10-26
表紙画像
書誌情報 Newham, Cameron & Rosenblatt, Bill (2005) : 入門bash 第3版. https://www.oreilly.co.jp/books/4873112540/.
解説 bashについて書かれた書籍。内容は「入門UNIXシェルプログラミング」とよく似ている。こちらはPDFの電子書籍があるのがありがたい。「入門…」が入手できなければこちらでも代用がきくと思われる。ただし,bashの独自拡張がいたるところで使われているので,POSIX規格と合わせて読むことを勧める。「入門…」ではhistoryファイルなどのbashの設定については一切記載がない。bashはLinuxの標準シェルとして広く普及している。bashの設定についても書かれており,POSIX原理主義とは離れるが役に立つ部分がある。個人的にはLinuxでシェルを初めて使う人にはこの本を勧めたい。
bashクックブック
項目内容
タイトル bashクックブック
発表日 2008-09-23
表紙画像
書誌情報 Albing, Carl; Vossen, J. P. & Newham, Cameron (2008) : bashクックブック. https://www.oreilly.co.jp/books/9784873113760/.
解説 シェルスクリプトにおいてある特定の作業を行いたいというときの対処方法がレシピの形で掲載されている。全19章645ページにわたりひたすらレシピが書かれている。情報量としては相当なもので,実作業で疑問に思ったことがあればこの本を読めば参考になる情報が掲載されている可能性が高い。
基本的にはbashを前提とした解説になっているが,シェルスクリプトの互換性を高めるためのテクニックも掲載されており,POSIX原理主義を実践するうえでも参考になる部分がある。また,bashでなされる処理をPOSIX原理主義でやる場合ならどうやればいいか?という視点で見ることもでき勉強になる。
Effective AWK Programming
項目内容
タイトル Effective AWK Programming 4th Edition
発表日 2015-0313
表紙画像
書誌情報 Robbins, Arnold (2015) : Effective Awk Programming. http://it-ebooks.directory/book-1491904615.html.
解説 AWKについて書かれた書籍。AWKが使えればできることが一気に広がる。英語版は無料で入手可能。日本語版も古いが公開されている。
その他の書籍

その他に有用と思える書籍を紹介する。

UNIXという考え方
項目内容
タイトル UNIXという考え方
発表日 2001-02
表紙画像
書誌情報 Gancarz, Mike (2001) : UNIXという考え方 - その設計思想と哲学. http://amazon.co.jp/o/ASIN/4274064069/.
解説 シェルスクリプトやPOSIX規格とは関係ない話が多いが,UNIXの設計思想について書かれており,シェルスクリプトでのプログラミングで参考になる部分がある。本自体も分厚くないので手元に一冊もっておいてもよいと思う。
詳解UNIXプログラミング第3版
項目内容
タイトル 詳解UNIXプログラミング第3版
発表日 2014-04-21
表紙画像
書誌情報 Stevens, W. Richard & Rago, Stephen A. (2014) : 詳解UNIXプログラミング 第3版. http://amazon.co.jp/o/ASIN/B00KRB9U8K/.
解説 UNIXにおける標準Cライブラリが提供するシステムコールなどC言語の膨大な関数について詳細に解説している。POSIX原理主義においてはC言語の部分の知識の補強にとても役に立つ。その他,POSIXや関連規格の歴史について第2章で詳しくまとまっている。POSIXは1988年に公布されてから何回か改訂がなされている。しかし,この経緯や関連規格との関係などはあまり文書にまとまっていない。POSIX規格とは何であるかを知るうえでこの書籍はとても役に立つ。値段が高いので,第2章だけ見たければGoogle Booksでみてもよいかもしれない。

ツール

最後にPOSIXに準拠したシェルスクリプトを書く上で役に立つツールを紹介する。

参考:シェルスクリプトのlint - Qiita

POSIX man

POSIX原理主義を実践していくうえで,以下の2項目を確認したいことが頻繁にある。

  • 使用したいコマンドがPOSIXに準拠しているか
  • どのオプションがPOSIXに準拠しているか

きちんと確認するにはPOSIX規格を確認するべきだが,毎回Webブラウザーを開いて確認するのは煩雑だ。これを解消する手段として,POSIXのmanがある。

Debian系OSであれば,manpages-posixパッケージをインストールすれば,manコマンドでPOSIXのmanを閲覧できるようになる。Ubuntu 16.04で確認した。

sudo apt install manpages-posix manpages-posix-dev

manコマンドでPOSIXのmanを見たいコマンドのセクション番号のあとにpposixの先頭から数語を付ける。

man 1p psman 1po grep
man 3posix clock

これでPOSIX Programmer's Manualがmanコマンドで閲覧できる。なお,このPOSIX Programmer's Manualはオンラインのmanページでも閲覧できる。

参考:Linux man pages online

インストールしたパッケージに何が含まれているかは以下のコマンドで確認できる。

apt show manpages-posix-dev
apt show manpages-posix
POSIXのmanに含まれるもの
パッケージ内容
manpages-posix1p: command
manpages-posix-dev3p: library call
7p: header files

なお,RedHat系OSでは最初からPOSIX manはインストールされているようだった。パッケージをネットで探したが見つからず,少なくともCentOS 7ではデフォルトでインストールされているようだった。

参考:linux - How to install man pages for C standard library functions in Ubuntu? - Super User

checkbashisms

POSIXに準拠しているかどうかをチェックするcheckbashismsというPerlで作られたプログラムがある。

checkbashisms download | SourceForge.net

ただ,bashやkshの独自拡張を使っているかどうかのチェックだけのようで,構文の誤りまではチェックしない模様。

なお,2.0.0.2をダウンロードして試したところ,以下のような警告が出た。

cat <<- EOT >> sh.sh
#!/bin/sh -u A=b [[ "$A" = "a" ]] && echo OK
EOT
./checkbashisms sh.sh
Unescaped left brace in regex is deprecated, passed through in regex; marked by <-- HERE in m/
            # the exec should either be "eval"ed or a new statement
            (^\s*|\beval\s*[\'\"]|(;|&&|\b(then|else))\s*)

            # eat anything between the exec and $0
            exec\s*.+\s*

            # optionally quoted executable name (via $0)
            .?\$0.?\s*

            # optional "end of options" indicator
            (--\s*)?

            # Match expressions of the form '${1+$@}', '${1:+"$@"',
            # '"${1+$@', "$@", etc where the quotes (before the dollar
            # sign(s)) are optional and the second (or only if the $1
            # clause is omitted) parameter may be $@ or $*.
            #
            # Finally the whole subexpression may be omitted for scripts
            # which do not pass on their parameters (i.e. after re-execing
            # they take their parameters (and potentially data) from stdin
            .?(\${ <-- HERE 1:?\+.?)?(\$(\@|\*))?/ at ./checkbashisms line 422, <IN> line 3.
Unescaped left brace in regex is deprecated, passed through in regex; marked by <-- HERE in m/
            # Match scripts which use "foo $0 $@ &\nexec true\n"
            # Program name
            \S+\s+

            # As above
            .?\$0.?\s*
            (--\s*)?
            .?(\${ <-- HERE 1:?\+.?)?(\$(\@|\*))?.?\s*\&/ at ./checkbashisms line 448, <IN> line 3.
possible bashism in sh.sh line 3 (alternative test command ([[ foo ]] should be [ foo ])): [[ "$A" = "a" ]] && echo OK

使用しているPerlはv5.22.1,OSはUbuntu 16.04。原因がよくわからないが,このようなエラーが出るようではソフトウェア自体も当てにならない。

ShellCheck

checkbashismsと同じようにPOSIXに準拠しているかどうかのチェックツールとして,ShellCheckがある。こちらは,Haskellで作られたもので,Webアプリ版とコマンドライン版の2種類が用意されている。

ShellCheck – shell script analysis tool

Webアプリ版のShellCheck

例えば,先ほどのコードを貼り付けると以下のように警告を出してくれる。

ShellCheckの結果例

よくできている。checkbashismsを使うくらいならこちらを使ったほうがよいだろう。ただ,残念ながらこのShellCheck自体がPOSIXに準拠して作られていないという問題がある。したがって,こうしたツールはあくまで一時的な確認として使う程度にし,自分の知識を磨きツールに依存しないようにしよう。

まとめ

POSIX原理主義を実践していくうえで役に立つ文献について紹介した。学ぶべきことは多いが,やる価値は十分にある。

POSIX原理主義にしたがえば,一度書くだけでいつでもどこでも実行できる(Write once, Run anytime anywhere),時空を超える力が手に入る。ちょっとした知識であっても,一生使えるのならばそれは大きな知識につながる。早く始めれば始めるほど得られる便益は大きくなる。

僕もまだまだ勉強を始めた身であるので,これらの文献を読んでしっかり勉強していく。