Design and Implementation of
Gracious Days

12月にTwitterに流れていたPOSIX何とかという話題を読んでいたらこの記事にたどり着いた。もう3年以上前の記事だけれど、内容がとても偏っていると思う。

シェルスクリプトの書き方について解説しているウェブ上の資料は少なく、当該記事も検索に引っかかりやすい。最近の初学者はまず検索して調べることが多いが、反論が検索に引っかからないとそれが正しいと受け入れてしまう可能性が高いように思う。きちんと反論している日本語の記事が引っかからないので、あえて問題と思う点をまとめてみた。

そんなこと書いているあなたは誰?

わたしはシェルスクリプトの専門家でも職業プログラマでもないけれど、シェルスクリプトを書いた量はそれなりにあると思う。環境はSunOS4, Solaris, HP-UX, IBM AIX, FreeBSD, OpenBSD, Linuxを経験していて、移植性や性能の問題がどこで発生しやすいかは理解しているつもり。FreeBSDの/etc/rc.dにもコードもたくさん書いてcommitした。

tl;dr

同意できるところ

  • POSIXに準拠してシェルスクリプトを書くのは正しい姿勢だと思う。移植性のあるシェルスクリプトの記述はGNU Autoconfという大先輩がいるけれど、21世紀になってUNIX系OSの数が減った今、あそこまで徹底的に互換性を確保しなくても良いと思う。[ foo == bar ] みたいな bashism は勘弁していただきたかったけれど、最近あまりにそう書く人が増えてきてしまったので、FreeBSDのshやtestには追加された。POSIXにもそのうち追加されるかも知れない

同意できないところ

  • 可読性が下がるから関数や制御構造文を使わず命令文をコピーして使え、というのは当然の結果として誰もがたどり着く発想ではない。かなり偏った意見で、欠点がたくさんある。
  • 著者は広く知られている問題の回避方法を敢えて説明していない(もしくはそもそも知らないで書いているのではないかと思える記述がある)。
  • 「可読性」や「複雑さ」を、偏った言語仕様の理解から定義しているように読める。特に「シェルスクリプト」という言葉が指す内容は、かなり恣意的である。sed や awk が「シェルスクリプト」の範疇に入るのであれば、sed や awk スクリプトで書くべきと思われる内容がたくさんあるが、そこには触れていない。
  • バックスラッシュクォートという謎用語。\'なのかと思ったら、コマンド呼び出しのバッククォート(`command`)を指しているようだ。
  • 著者は POSIX.1 == The Open Group Base Specifications Issue 7, IEEE Std 1003.1-2008, ISO/IEC 9945:2009と書いているが、POSIXはIEEEの規格の別名で、シェル環境を定義しているのは厳密には POSIX.1 ではなく古くは POSIX.2、新しいのは POSIX.1-2001 (IEEE Std 1003.1-2001) 以降という、ちょっとややこしい経緯がある。そしてオンラインで無料でアクセスできるのは IEEEの規格書ではなく、そのスーパーセットになっているSingle UNIX Specificationだけなので、用語は正しく使おう。この文章ではPOSIXという言葉をIEEE Std 1003.1-2001を指すものとして、内容そのものはSUSv3やSUSv4を参照する。

可読性が下がるから関数や制御構造を使うなという思想

著者は「シェルスクリプトは関数や制御構造を使うな」と説明しているが、これが可読性に寄与するとはとても思えない。善意で解釈すると、コードを読む能力(というかコードを読む労力)を極端に制限して間違いのないコードを書こう、という思想なのだと思うけれど、できあがったコードがとても奇妙で理解し難いものになっている。この記事のコード例を引用してみよう。

次のテキストファイルが与えられている。第一フィールドが商品番号、第二フィールドが売上数。商品番号ごとに売上数の合計を計算するスクリプトが欲しい。

003 10
001 53
002 13
005 38
003 71
000 27
001 94
008 36
000 24
001 48

著者が「手続き型プログラミング言語的な発想ならこう書くはずだ」と主張しているコードは次のようなものだ。日付を入れるコードが追加されている(%M となっているのは原文ママ)。

#!/bin/sh
m0=0; m1=0; m2=0; m3=0; m4=0;
m5=0; m6=0; m7=0; m8=0; m9=0;
while read p i
do
    case "${p}" in
000)
    m0=$((${m0} + ${i}))
;;
001)
    m1=$((${m1} + ${i}))
;;
002)
    m2=$((${m2} + ${i}))
;;
003)
    m3=$((${m3} + ${i}))
;;
004)
    m4=$((${m4} + ${i}))
;;
005)
    m5=$((${m5} + ${i}))
;;
006)
    m6=$((${m6} + ${i}))
;;
007)
    m7=$((${m7} + ${i}))
;;
008)
    m8=$((${m8} + ${i}))
;;
009)
    m9=$((${m9} + ${i}))
;;
esac
done < data
for i in 0 1 2 3 4 5 6 7 8 9
do
    eval echo $(printf "%03.0f" ${i}) $(date +%Y-%M-%d) \${m$i}
done

こんなコードを書くだろうか? わたしが手続き型言語で書こうと考えるなら、まず一番最初に

  • 第一フィールドを要素番号にした配列を用意する
  • 行単位でループさせて、その配列に個数を積算する

という処理が頭に浮かぶ。シェルスクリプトには配列がない(bash にはあるけれど POSIX シェルにはない)ので、

  • N_001 というように第一フィールドの頭に N_ を付けた変数をつくる
  • その変数に数値を代入して、加算する
  • 最後にシンボルテーブルから変数一覧を取り出す

というコードを書くのがひとつの方法だ。具体的な例は次のようになる。

#!/bin/sh
D=$(date +%Y-%m-%d)
while read num pcs; do
    eval N=\$N_$num
    eval N_$num=$((${N:-0} + $pcs))
done < data
set | sed "s/=/ $D /;/^N_/s/^N_//;/^[0-9]/p;d"

evalは1行でも書けるが、エスケープ処理が繁雑になるので2行に分けている。

おそらく、著者の考え方だと「これは複雑すぎて理解に時間がかかる」と批判の対象になるのだと思う。配列や連想配列の機能をevalsetで代用しているのは技巧的だが、シェルスクリプトの書き方としては特別なものではない。sed スクリプトはs///, //s///, //p, d の 4 個のsedコマンドで書いてある。最初が単純な置き換え、次がパターンマッチを限定して置き換え、次もパターンマッチを限定して行出力、最後はマッチしなかった行を表示しないようにする、という処理になる。

$()$(()) もサポートしていない、もっと古いシェルでも動くようにしたければ次のように書き換えれば良い。相当古い環境でも動くはずだ。

#!/bin/sh
D=`date +%Y-%m-%d`
while read num pcs; do
    eval N=\$N_$num
    eval N_$num=`expr ${N:-0} + $pcs`
done < data
set | sed "s/=/ $D /;/^N_/s/^N_//;/^[0-9]/p;d"

こんなのちょっと普通じゃないな、と感じるひとも多いかも知れない。POSIXコマンドだけで数を数える場合によく使う別の方法は、数えたい数を改行の数に変換してしまう方法だ。たとえば次のように書ける。

#!/bin/sh
D=$(date +%Y-%m-%d)
while read num pcs; do
    echo "for(i=0; i<$pcs; i++){$num}" | bc
done | sort | uniq -c | while read c n d; do
    printf "%03d %s %d\n" $n $D $c
done

シェルスクリプトでbc(1)を使う例はあまり見ないかも知れないが、一般的にawk(1)より軽く制御構文を持っているので使う価値がある。bc(1)では「商品番号と個数」の行から、商品番号だけが現れる行を個数分つくるという処理をしている。行の数を数えれば、個数が分かるわけだ。それを入力データに対して繰り返すと、すべて商品番号だけが並んでいるデータになるので、sort(1)とuniq(1)の組み合わせでソートして重複行を数えれば良い。この、行ないたい処理(ここでは数の積算)を行の処理に置き換えるというテクニックは、シェルスクリプトの技法として良く知られている。

BSDならjot(1)が使えるので、POSIX環境に拘らなければ次のようにbc(1)の処理を置き換えることも可能だ。

#!/bin/sh
D=$(date +%Y-%m-%d)
while read num pcs; do
    jot -b $num $pcs
done | sort | uniq -c | while read c n d; do
    printf "%03d %s %d\n" $n $D $c
done

一方、著者の代替コードは次のようなものになる。

#!/bin/sh
grep 000 data | awk '{print $2}' | total > sum00
grep 001 data | awk '{print $2}' | total > sum01
grep 002 data | awk '{print $2}' | total > sum02
grep 003 data | awk '{print $2}' | total > sum03
grep 004 data | awk '{print $2}' | total > sum04
grep 005 data | awk '{print $2}' | total > sum05
grep 006 data | awk '{print $2}' | total > sum06
grep 007 data | awk '{print $2}' | total > sum07
grep 008 data | awk '{print $2}' | total > sum08
grep 009 data | awk '{print $2}' | total > sum09
echo 000 $(date +%Y-%M-%d) $(cat sum00)
echo 001 $(date +%Y-%M-%d) $(cat sum01)
echo 002 $(date +%Y-%M-%d) $(cat sum02)
echo 003 $(date +%Y-%M-%d) $(cat sum03)
echo 004 $(date +%Y-%M-%d) $(cat sum04)
echo 005 $(date +%Y-%M-%d) $(cat sum05)
echo 006 $(date +%Y-%M-%d) $(cat sum06)
echo 007 $(date +%Y-%M-%d) $(cat sum07)
echo 008 $(date +%Y-%M-%d) $(cat sum08)
echo 009 $(date +%Y-%M-%d) $(cat sum09)
rm sum0[0-9]

totalというコマンドが登場しているが、内容は次のようなものらしい。

#!/bin/sh
sum=0
while read i
do
    sum=$(($sum + $i))
done
echo $sum

こう書き換えると読みやすいと著者は主張している。

比較元のコードがあまりに不自然なのでこっちのほうが良さそうに見えてしまうけれど、わたしは似たような行がならんでいるコードを読みづらく感じる。どのデータがどう処理されているのかを、目で注意深く追わないといけないからだ。ループなら、ループの単位だけに意識を集中でき、読む量が少ないので理解しやすく感じる。この点は、著者と考え方が大きく違うのだろう。

それに加えて、このコードには問題点はたくさんある。

  • 同一の入力ファイルに対する複数回の読み出し。処理途中で入力ファイルが変更されたら一貫性はどうなる?
  • 大量の一時ファイル。ファイル書き込みでエラーが発生した場合の例外処理を入れるのは難しそう。
  • 毎回実行されるdateコマンド。実行途中で日が変わることは想定していなさそう。

同じようなコードをコピーすることの有害性

ループ等の制御構造を使わずに全部書き下せ、というのが著者の主張のようだ。エディタを使ってコピーしているのだと思うが、こういうプログラミングスタイルは仕様変更に対してとても弱く、間違いのチェックが難しい。

「読みやすさ」は個人の感性の問題でもあるが、同じようなコードが大量にコピーされたスクリプトを常に優れているとする姿勢は理解に苦しむ。たとえば 007 を一行書き忘れてしまうことは発生しないだろうか? 仕様変更で 007 は計算しないようにしたのかも知れないし、書き忘れたのかも知れない。ループを使って007だけ除外するコードのほうが、個人的にははるかに読みやすいと感じる。

大量の同一入力ファイル読み出し

このコードでもうひとつ理解に苦しむところは、grep XXX filenameというファイル入力を大量に行なう点だ。この部分は書き換え前のコードと同じ処理ではなく、もはや完全に違う動作のプログラムだ。それぞれの入力時点で同じファイルを読んでいるかどうかの一貫性が保証できないだけでなく、実質的に入力ファイルが 10 倍の大きさになっているようなものだ。可読性のためにそれらを犠牲にするというのは理解できない。

awk を使うの?

また、ここで急に awk が登場している。awk は連想配列を備えているので、awk を使って良いのであれば、全体の処理は次のように書けば十分だ。このコードにはdateの処理がないが、入れるのは難しくない。

#!/usr/bin/awk
/^[0-9]/ { v[$1] += $2 }
END { for (i in v) { print i, v[i] } }

この awk コードは読みづらいだろうか?「シェルスクリプトなのだから awk に頼るな」「awk の文法は複雑だ」という考え方もあるが、そうであれば著者の代替コードで awk を使っている理由がよく分からない。第二フィールドを切り出すならcut -f 2 -d " "で良いはずだ(区切り文字が空白である必要があるが)。

また、totalコマンドの存在理由が理解できない。この部分は awkではダメなのだろうか? 一行目は次のように書けば total は必要ない。

grep 000 | awk '{print $2}' | awk '{v += $1} END { print v }' > sum00

もちろんさらに次のように書けば grep も total も要らず、awk だけで完結する。

awk '/^000/ {v += $2} END { print v }' > sum00

わたしは、著者が grep + awk + total(外部コマンド)という組み合わせを選択した理由がまったく理解できない。可読性が高いとは思えないし、処理効率が良いわけでもない。そもそも定型処理をtotalという外部コマンドとして切り出している書き方は、実質的に関数を使うのと変わらないので、「関数を使うな」という主張と矛盾しているようにも思う。

一時ファイルと while 有害論

次の段落で、「cat data | whileを使うとwhileループの内部で計算した変数の結果が取り出せないということが欠点であり、著者のコードの有用性はそれが避けられる点だ」という主張がある。別の記事でもwhileは問題だと書いている。サブシェルの存在が直観的でないという点は同意するが、一時ファイルをその唯一の解決方法とするのは疑問だ。記事の最後には「awkで書いたほうが良いかも」という、本末転倒な文章もある。

この問題の解決方法は、次の 2 つが広く知られている。ただし、こういった処理が要求された時点でシェルスクリプトで書くのを諦めましょうというのも、広くアドバイスされていることだと思う。

setevalを使った変数代入によるデータの受け渡し

サブシェルで代入した変数を取り出したい場合は、その量が少ないなら変数全体をsetで取り出し、evalしてしまうのが最も単純な方法だ。先ほどwhileで書いたコード例を、無理矢理cat dataからのパイプで接続した場合、次のようになる。

#!/bin/sh
eval $(cat data | (while read num pcs; do
    eval N=N_$num
    eval N_$num=$(($N + $pcs))
done; set))
echo $N_003

setで出力される変数の一覧は、そのままevalして変数代入できることがPOSIXで保証されている。

名前付きパイプ (FIFO) を使ったプロセス間通信

サブシェルの実行環境と、元のシェル実行環境との間でファイル記述子経由でデータを受け渡す。具体的には FIFO を使って次のように書ける。

#!/bin/sh
rm -f /tmp/$$.fifo; mkfifo /tmp/$$.fifo
cat data > /tmp/$$.fifo &
while read num pcs; do
    eval N=N_$num
    eval N_$num=$(($N + $pcs))
done < /tmp/$$.fifo
rm -f /tmp/$$.fifo
echo $N_003

複数のFIFOとtee(1)を使って、同じ入力に対して異なる処理を適用するという方法も、よく知られている。

#!/bin/sh
for i in 1 2 3; do
    rm -f /tmp/$$.fifo$i; mkfifo /tmp/$$.fifo$i
done
while read num pcs; do echo $num;      done < /tmp/$$.fifo1 > /tmp/output.1 &
while read num pcs; do echo $pcs;      done < /tmp/$$.fifo2 > /tmp/output.2 &
while read num pcs; do echo $pcs $num; done < /tmp/$$.fifo3 > /tmp/output.3 &
cat data | tee /tmp/$$.fifo1 /tmp/$$.fifo2 /tmp/$$.fifo3 > /dev/null
rm -f /tmp/$$.fifo[123]

この場合FIFOが作成できなった時の例外処理に注意が必要だが、入力データは一度しか読まないので、一貫性の問題は低減される。

一時ファイルは有害か?

著者が主張するような一時ファイルは特別に有害なものではないが、著者のコード例にあるように大量の一時ファイルを出力すると性能的に不利であることと、ファイル名の衝突や、何らかの理由で出力できなかった時のエラー処理に悩まされることになる。学習用のサンプルコードでは例外処理を省いて紹介することがあるものの、ここで出てきている書き換えの是非は、例外処理も含めて考えるべきだと思う。著者の記事には一時ファイルの例外処理についてまったく触れていないが、とても不思議に感じる。

また、「サブシェルが独立環境を持っていて変数への代入が常に捨てられてしまう」という動作は保証されているが、「パイプラインの途中にあるコマンドが常にサブシェルで実行されるかどうか」は、実はPOSIXで保証されていない。SUSv4 の Section 2.12 には、次のように書かれている。サブシェルで実行されない実装は少ないと思うけれど。

A subshell environment shall be created as a duplicate of the shell environment, except that signal traps that are not being ignored shall be set to the default action. Changes made to the subshell environment shall not affect the shell environment. Command substitution, commands that are grouped with parentheses, and asynchronous lists shall be executed in a subshell environment. Additionally, each command of a multi-command pipeline is in a subshell environment; as an extension, however, any or all commands in a pipeline may be executed in the current environment. All other commands shall be executed in the current shell environment.

記事の著者が主張する「可読性」や「書き換えやすさ」

スクリプトを読んですぐに理解できるように書こうという姿勢は間違っているとは思わないけれど、その姿勢の行き着く先が、あのループが展開されたシェルスクリプトだとするならば、そこには同意できない。「{ は読みにくいから #define BEGIN { しましょう」という、昔あった話と同質の奇妙さを感じる。本人がそう書きたいのは構わないけれど、それを他人に勧めないで欲しい。

何年もの間の保守が予想される何らかのシステムをシェルスクリプトで組む時に重要なのは、わたしは次の 3 点を強く意識することだと思う。

  1. プログラムのロジックはすぐに読めるように短く単純に書く努力をする。
  2. 想定しないデータや異常が発生した時のエラー処理を必ず書く。スクリプト言語自体のエラー処理機能が乏しく型チェックもないので、長いスクリプトを書くのは避けたほうが良い。
  3. 中心となるロジックの中で、繰り返し適用する処理は無駄がないようにきちんと書き、関数なりコマンドなりで部品化する。コードが複雑になっても良いが、複雑になるならコードが短くなるように書く。ユニットテストを必ず書き、コードが正しく動くことをいつでも確認できるようにしておく。

記事のコード例にある売上の加算処理は 3. に相当する部分だと思う。これを部品化せずに記事のようにループ展開して書くと、本来のロジックがぼやけるだけでなく保守性が大幅に低下する。大量にコピペされた同一処理が、ところどころ後から仕様変更で書き換えられているプログラムは、少なくともわたしにとっては悪夢のようなコードに見える。

また、sedやawkスクリプトの記述力は高いので、行や列で書かれたテキストファイルの単純な処理ならば、シェルスクリプトで書くのではなくてsedやawkスクリプトで書くのが正しい。そもそもsedやawkはカスタマイズしたフィルタコマンドを作成するために作られたプログラムだ。複雑な処理は全部sedやawkで書いて実行ビットを立てて外部コマンドにして、シェルスクリプトはデータの流れを記述するというスタイルが、スマートで保守性が高い方法だと思う。シェルスクリプトで字句解析して計算、というのは悪いことではないけれど、どうしても冗長だったり暗号めいたコードになってしまい、保守性の高いコードは書けない。

まとめ

sedやawkは学習が大変、制御構造を使わないシェルスクリプトは初学者でもすぐに理解できるから意味がある、という反論があるのかも知れない。それは否定しないし、上に書いた内容も感想の域を出ていないので絶対的に正しい考え方だと主張するつもりもない。ただ、記事のような思想で開発することは自由だけれど、一般的にそれがエレガントだとか、優れていると手放しで推奨できる内容ではないと思う。

これはまた別の方向で偏った意見だけれど、個人的には「シェルスクリプト」と呼ぶのであれば極力外部コマンドを使わないのが筋なのではないかと思う。90年代にシェルスクリプトを書いた経験があるひとなら、外部コマンドを1個呼ぶコストがとても大きく、どうにかして外部コマンドを使わずに実現できないだろうか、と頭を悩ませて cat(1) や basename(1) をシェルスクリプトの関数に置き換えたりするテクニックを学んだのではないかと思う。なので「シェルスクリプトだけで○○しよう」と書いてありながら awk を使っていて、「ああこれならシェルの命令文だけで書けるのに」と思ってしまう記事を読むと、何とも残念な気持ちになる。

冒頭に書いたとおり、最近はわからないことを検索して調べる学習スタイルが定着しているし、シェルスクリプトの教本はほとんど存在しない。当該記事は3年以上残っているし、これからも長い間残るのだと思うので、内容に対して疑問を感じるひとがいるということが伝わって欲しいというのが、この文章を書いた理由だ。

この記事で紹介したシェルスクリプトの古典的技法をまとめておく。ただし、これらは積極的に使うことを推奨するものではない。

配列を使いたい場合

名前に要素番号を含む変数を定義して使う。変数名の制限に気をつければ、連想配列として使うこともできる。動的に定義したければevalを使う。変数をまとめて参照したい時には、setを使う。

#!/bin/sh
N_001=10
N_002=30
N_004=50
set | grep ^N_

grep(1)を使うのが無駄だと思うなら次のようにも書ける。

#!/bin/sh
N_001=10
N_002=30
N_004=50
set | while read IN; do case $IN in N_[0-9]*=*) echo "$IN";; esac; done

サブシェル内の変数変更を、親シェル環境に反映させたい場合

サブシェルでsetを使い、その文字列を親シェル環境でevalする。また、親シェルにまとまった量のデータを送りたければ、FIFOを使うのが簡単だ。

数を数えたい場合

数えたい対象を行数に持つデータをつくる。数値を行数に変換するには、jot(1), bc(1), awk(1)が使える(一般的にはこの順番に重くなる)。次のコードは、数値だけを含む複数行のデータの合計値を出力する。

36
1
56
82

#!/bin/sh
while read NUM; do
    echo "for(i=0; i<$NUM; i++){1}" | bc
done | uniq -c | while read N D; do echo $N; done

一般的に、計算処理は複雑になった時点でawk(1)を使うのが正しい。どうしてもシェルスクリプトのみで完結させたい場合は、expr(1)を使うか、あるいは、計算式を構築して bc(1) に流し込めば計算できる。

同一のファイルからの入力データを複数のパイプに入れて処理したい場合

FIFOとtee(1)を使う。ただしFIFOが作成できなかった場合のエラー処理をきちんと行なうこと。

#!/bin/sh
rm -f /tmp/fifo1.$$ /tmp/fifo2.$$ /tmp/fifo3.$$
mkfifo /tmp/fifo1.$$ /tmp/fifo2.$$ /tmp/fifo3.$$
grep test /tmp/fifo1.$$ > /tmp/output.test &
grep google /tmp/fifo2.$$ > /tmp/output.google &
grep best /tmp/fifo3.$$ > /tmp/output.best &
cat data | tee /tmp/fifo1.$$ /tmp/fifo2.$$ /tmp/fifo3.$$ > /dev/null

Next post: read(2) vs mmap(2) の迷信