47
35

More than 3 years have passed since last update.

シェルスクリプトのための良いデザイン ~ expr と bc から知る設計の違い ~

Last updated at Posted at 2021-05-10

はじめに

POSIX で規定されているコマンドに expr コマンドと bc コマンドがあります。expr は正規表現による比較ができる一方 bc コマンドは高度な算術計算が行えるなど機能は全く同じではありませんが共通する機能として数値計算機能を持っています。ただし同じ計算を行うだけでも、この 2 つのコマンドは設計に大きな違いがあります。この記事ではこの 2 つのコマンドの違いから、シェルスクリプトにとってふさわしいコマンドの設計について解説します。

引数 vs パイプ

exprbc の違いは簡単な計算を行うだけですぐ気づきます。例えば 1 + 2 を行う場合は次のようになります。

$ expr 1 + 2
3

$ echo "1 + 2" | bc
3

なんと bc コマンドは面倒くさいんでしょう?

どうみても expr の方が直感的です。わざわざ計算する数式をパイプで渡すなんて冗長です。echo なんて要らないでしょう?シェルスクリプトを本格的に学ぶ前の私はそう考えていました。

注意 POSIX シェルでは同じことを算術式展開(例 $((1 + 2)))で計算することができます。これは expr を使うよりも推奨される方法です。しかし今はひとまず忘れてください。

パイプで渡すことの本当の意味

多くの bc コマンドの解説の残念な所は次のような使い方でしか説明されていない所です。

# 計算を行います
echo "1 + 2" | bc

# 計算を行い変数に入れます
ret=$(echo "1 + 2" | bc)

重要な使い方が説明されていません。それはこのような使い方です。

$ cat data.txt
1 + 2
2 + 3
3 + 4

$ cat data.txt | bc     # または bc < data.txt
3
5
7

つまり bc コマンドというのは数式のリストを渡して一連の計算を行うことができるフィルタなのです。

bc コマンドのエレガントな使い方

bc コマンドは 多くの場合 awk で代用することができます。また POSIX で規定されているコマンドにも関わらずディストリや構成によってはデフォルトでインストールされません。そのためあまり使う機会が少なく詳しい解説もあまり行われないように思われますが、私は bc コマンドは本来は数式のリストを渡して計算するものであると今では考えています。

例えば CSV 形式の次のようなファイルがあって、それぞれの項目の合計を出したい場合

$ cat data.txt
1,2,3
10,20,30
100,200,300

このようにすると bc コマンドを一回実行するだけで計算を行うことができます。

$ cat data.txt | sed 's/,/+/g' | bc
6
60
600

複雑な計算を行う場合、関数をファイルに定義しておけばこのように計算することもできます。

$ cat func.bc             # vim によると bc コマンド用のプログラムファイルの拡張子は bc らしい
define f(v) {             # POSIX 規定では関数名・変数名は 1 文字だけ・・・
  return (v * v + 100)
}

$ cat data.txt
f(1)
f(2)
f(3)
$ cat data.txt | bc func.bc
101
104
109

expr と bc の設計の違いがシェルスクリプトのパフォーマンスに与える影響

シェルスクリプトから計算を行う時 exprbc の設計の違いがシェルスクリプトのパフォーマンスにも影響を与えてきます。

まず一般論として外部コマンドの起動は遅いということを知っておいてください。(外部コマンドの"起動"であって外部コマンドそのものが遅いわけではない。)つまりシェルスクリプトのパフォーマンスを悪くしないためには外部コマンドの呼び出し回数を減らすことが重要になってきます。

expr.sh
i=0
while [ "$i" -lt 10000 ]; do
  expr "$i" + 10000
  i=$((i+1))
done
bc.sh
i=0
while [ "$i" -lt 10000 ]; do
  echo "$i" + 10000
  i=$((i+1))
done | bc
$ time ./expr.sh > /dev/null

real    0m5.716s
user    0m4.913s
sys     0m1.343s

$ time ./bc.sh > /dev/null

real    0m0.063s
user    0m0.031s
sys     0m0.091s

見ての通り速度は圧倒的に bc コマンドの方が速いです。expr コマンドは引数で計算式を渡すという設計であるため、計算のたびに外部コマンドの呼び出しが必要になります。それに対して bc コマンドは計算式をリストで渡すことができるため 1 回しか呼び出す必要がありません。

ここまで理解すると最初は単に面倒なだけだと思っていた bc コマンドの方が実は優れているということに気づきます。

追記

次のコードは bc コマンドを expr コマンドと同じようにループ毎に呼び出して計算した場合の結果です。上記のコードでは bc コマンドの方が速いという結果が出ましたが、だからと言って「bc コマンドの方が速い」や「パイプを使った方が速い」という特定の場合に当てはまる結論を元に以下のようなコードを書くと逆に遅くなります。expr よりも遅くなっているのは bc コマンドの呼び出しに加えサブシェルとパイプが生成されているからです。詳しくは「シェルスクリプトはパイプを使うと並列処理されて速い ・・・ は神話!?」を参照してください。

bc2.sh
i=0
while [ "$i" -lt 10000 ]; do
  echo "$i + 10000" | bc
  i=$((i+1))
done
$ time ./bc2.sh > /dev/null

real    0m7.945s
user    0m8.018s
sys     0m2.646s

算術式展開の登場

ここで少しシェル(スクリプト)の話をします。 expr コマンドや bc コマンドは正確にはシェルスクリプトの一部ではありません。たまたまシェルスクリプトから呼び出しているだけで、他の言語からも呼び出すことができる外部コマンドです。使ってる機能がシェルスクリプトの機能なのか、シェルスクリプトから呼び出してる外部機能(外部コマンド)なのかは正しく認識する必要があります。

多くの"シェルスクリプト"の解説ではこれらがはっきりと区別されておらず、シェルスクリプトの文法の話をしていたと思いきやいつの間にか外部コマンドの使い方の話に変わってしまっている場合が多々あります。(シェルスクリプトを使った"作業"の解説と考えれば、そうなるのも仕方ないとは思いますが)

POSIX シェル(bash など)登場以前の Bourne シェルでは数値計算には一般的に expr コマンドが使われていました。(あ、正確には「使われていたっぽい」です。時代が違うので・・・。)もちろん bc コマンドも使えますがループ変数に 1 加えるような処理は、計算式のリストを bc にわたすことができないので意味がありません。リストを作ってコマンドにパイプで渡すのが良いパターンだとしても、そう都合よくリストが作れるような処理になるとは限りません。

expr コマンドの起動(つまり外部コマンドの起動)が遅いのはすでに説明したとおりです。Bourne シェル から POSIX シェルへ進化する過程で追加された機能は、このような遅い外部コマンド起動が必要な処理をシェル自身で行えるような機能が追加されているように思えます。他には sedtr コマンドへの依存を減らすことができるパラメータ展開が追加されています。

POSIX シェルでは expr コマンドによる計算に変わるものとして算術式展開が登場しました。expr コマンドは他にも正規表現による文字列マッチングの機能があるため、算術式展開に完全に置き換えられるわけではありませんが、少なくとも整数の四則演算に限っては遅い expr を使う必要はありません。POSIX シェル以前に対応する必要がない限り、四則演算には算術式展開を使用しましょう。

expr コマンドの代わりに算術式展開を使用した場合(dash の場合)の実行速度です。外部コマンドを使わずにシェル自身で処理しているために圧倒的に速くなっています。

arith.sh
i=0
while [ "$i" -lt 10000 ]; do
  echo "$((i + 10000))"
  i=$((i+1))
done
$ time ./arith.sh > /dev/null

real    0m0.031s
user    0m0.030s
sys     0m0.001s

おまけで seq (POSIX で規定されたコマンドではない) と awk を使った場合の実行速度も提示します。今回の例では生成するデータ量が多いので、たとえ外部コマンドの起動が遅かったとしても遅いシェルスクリプトよりも外部コマンドを使った方が速くなっています。どちらが速いかは処理内容やデータ量次第で変わります。

seq.sh
seq -f '%.0f + 10000' 0 9999 | bc
awk.sh
awk 'BEGIN { for(i=0; i<10000; i++) print i "+10000" }' | bc
# 余談 この awk コードってどう見ても手続き型プログラミングですよね?
$ time ./seq.sh > /dev/null

real    0m0.016s
user    0m0.020s
sys     0m0.002s
$ time ./awk.sh > /dev/null

real    0m0.016s
user    0m0.020s
sys     0m0.001s

UNIX 哲学

話を戻します。この記事は「シェルスクリプトの"ための"良いデザイン」です。シェルスクリプトのデザインではなくシェルスクリプトの"ための"デザインという所が重要なポイントです。

シェルスクリプト側も bc コマンドにパイプでデータを渡すというコードを書かなければいけませんが、それ以前に expr コマンドのような引数でデータを渡すようなコマンドではいくらシェルスクリプトで頑張ろうとしても無理な話です。前提として bc コマンドのような設計でプログラムを作らなければいけません。

さて exprbc コマンドは何の言語で書かれているでしょうか? そうです、C 言語です。(その他の実装があるかもしれないですが。)この話はシェルスクリプトの話のようでシェルスクリプトだけの話ではありません。CLI コマンド全般の話なので C 言語だけでなく Ruby、Python、Go、Rust、CLI コマンドとして実装するものであればなんにでも当てはまります。

「9. すべてのプログラムをフィルタとして設計する (Make every program a filter)」は UNIX 哲学 で言われている言葉の一つですが、どの言語で作ったとしても、すべてのプログラム(事実上 ほぼ CLI 限定の話だと思います。当時は GUI はほぼなかったでしょうし)はフィルタとして振る舞うように、つまり expr コマンドではなく bc コマンドのように振る舞うようにせよという言葉です。

(私の解釈ではこの言葉は「すべてのプログラム」の"実装"を外部コマンドとフィルタの組み合わせにすべきという意味ではありません。そのようにしてもいいですが、言葉の意味としてはプログラムがフィルタとして振る舞えば十分です。だって C 言語で作ったプログラムの中身が外部コマンドとパイプの組み合わせで作られてるってことはまず無いですし。フィルタとして作るなら関数型に近いスタイルになると思いますがほぼ手続き型プログラミングで作られていますよね。だからシェルスクリプトでプログラムを作った場合だって、プログラム自身がフィルタとして振る舞っていればよく、その中身を外部コマンドとパイプの組み合わせにすることは必須条件ではありません。)

今の時代は GUI アプリだったり、サーバーアプリだったり、1 バイナリで全てを実行してしまってフィルタになってないプログラムもたくさんありますが、それでも例えば機械学習のデータを前処理したりする時に、フィルタとしてプログラムを作ったほうが良い場合もあります。そのプログラム(機械学習だと Python が多いと思いますが)の中でファイルを開いてファイルに保存するなんてしていませんか? そういうプログラムはフィルタではありません。フィルタというのは標準入力からデータを受け取り標準出力にデータを出力するプログラムのことです。

UNIX 哲学には「2. 一つのプログラムには一つのことをうまくやらせる (Make each program do one thing well)」という言葉もあります。機械学習の前処理ではいくつもの種類の前処理を行うことが多いですが、これら全てを一つの Python スクリプトの中でやってしまうのは「一つのことをうまくやる」に反します。UNIX 哲学に従えば前処理の種類それぞれを一つのプログラムにします。そしてそれをシェルスクリプトでつなげるわけです。(UNIX 哲学「7. シェルスクリプトによって梃子(てこ)の効果と移植性を高める (Use shell scripts to increase leverage and portability)」)

シェルスクリプトが得意な事というのはこのフィルタをつなげる処理です。シェルスクリプトがグルー(糊付け)言語と言われる所以です。なんでもシェルスクリプトでやってしまうのは得策ではありません。しかしそれと同じようにシェルスクリプトを全く使わないというのもよくありません。フィルタ部分をそれが得意な言語で実装し、シェルスクリプトでそれをつなげると、フィルタの順番を入れ替えたり、フィルタを並列で実行したりといった張り替えや効率化が簡単に行えるようになります。シェルスクリプトでうまく繋げられるようにプログラムをデザイン(設計)し、シェルスクリプトらしいやり方で柔軟につなげる。適切な言語を使うというのはこういうことです。

補足1 「梃子(てこ)」とは再利用可能なモジュールのことを意味しているようです。「可能なときには常に、C 言語ではなくシェルスクリプトを使うべきだ」という言葉が書いてあるため、なんでもシェルスクリプトで作れと言ってるようにも見えなくもないですが、私の解釈ではそういう意味ではなく、C 言語ばかり使うな、シェルスクリプトも使えという意味でしょう。UNIX 開発当時はさほど多くのプログラミング言語の選択肢はありません。また UNIX 哲学「6. ソフトウェアを梃子(てこ)として使う (Use software leverage to your advantage)」という言葉もあります。当時は POSIX なんてものはありませんから POSIX コマンドだけを使えという意味でないのは明白です。単に他の人が作った再利用可能なモジュール(ライブラリを含む)を使って素早く開発しましょうということでしょう。最初からあらゆる場合を想定するのではなく素早く開発することも重要なことです。UNIX 哲学「3. できるだけ早く試作する (Build a prototype as soon as possible)」

もちろん適切な再利用可能なモジュール(コマンド・ライブラリ)がなければ自分で作れるようになることも重要です。技術的に不可能なことならまだしもコマンドや特定の機能を(誰かが)作ってないから(私は)作れません(作りません)とかありえません。またシェルスクリプトや POSIX コマンドだけで作る必要もないです。当時の UNIX 開発者はカーネルの開発に C 言語を選びましたがプログラムを(POSIX で規定されていない)他の言語で作ることは誰も禁止していません。最近では Linux カーネルの開発に Rust をサポートする要求が高まっていますね。まだ認められたわけではないようですがリーナスも Rust サポートに強く反対しているわけではないとのことです。(参考 https://gihyo.jp/admin/clip/01/linux_dt/202104/15

補足2 Python 等もまたグルー言語と言われているようです。しかしこの意味は少し違うように思えます。歴史的な登場順から正当なグルー言語はシェルスクリプトであると思いますが、Python 等の場合は異なる言語等のモジュールを統合できるという意味で使われているようです。もっともグルー言語に正式な定義はないのであれこれ言った所で意味はありません。

補足3 シェルスクリプトは一行一データのテキスト形式がもっとも扱いやすいデータ形式ですが、フィルタコマンドが扱うデータ形式は必ずしも一行一データのテキスト形式である必要はありません。iconvfold が扱うデータはテキスト形式ですが一行一データではありません。gziptar はバイナリ形式を扱うフィルタコマンドです。POSIX で規定されているコマンドの中にも一行一データのテキスト形式ではない形式を扱うフィルタコマンドはいくつもあります。JSON 形式を入出力する jq コマンドも当然フィルタコマンドです。JSON 形式だと awksed などの一行一データのテキスト形式を前提とするコマンドにパイプでつなげることが難しいというだけで、他の JSON 形式に対応しているコマンドにつなげることができます。よってこれらも UNIX 哲学に反していません。

おわりに

この記事は「シェルスクリプト リファクタリング ~遅いシェルスクリプトが供養されてたので蘇生して256倍に高速化させました~」の別の形の第二章です。パフォーマンス向上に全振りするのではなく第一章の考え方をより深く解説したものです。外部コマンドとパイプを排除した一方、こちらの記事ではそれをうまく使うべきと、言っていることが矛盾しているように感じるかもしれませんが、これは目的が異なっているからです。私はシェルスクリプトの高い移植性に着目しシェルスクリプト(移植性重視なので当然 bash ではなく POSIX シェル)でプログラミングをしていますが、これもまた目的が違います。パフォーマンス向上を目的とするなら「256倍に~」の記事のような内容になるし、コマンドを柔軟に組み替えられるようにして生産性を上げたいならこの記事のような内容になるし、高い移植性を目的とするならまた別の内容(現在記事執筆中)になるというだけのことです。「256倍に~」の記事は私が予想していた以上にウケてしまいましたが、シェルスクリプト(というより CLI コマンド全般)の設計についてはこちらの記事のほうがより重要な考え方です。適切なやり方は目的と状況によって変わります。誰かに(もちろん私にも)こうすべきと言われても鵜呑みにせずに自分で考えて適切な答えを見つけてください。それはポジショントークかもしれないし間違っていても誰も責任はとってくれません。

関連記事 パイプを使って高速化したシェルスクリプトを並列実行すると逆に遅くなる謎現象について

47
35
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
47
35