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

## はじめに

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

[expr]: https://pubs.opengroup.org/onlinepubs/9699919799/utilities/expr.html
[bc]: https://pubs.opengroup.org/onlinepubs/9699919799/utilities/bc.html

## 引数 vs パイプ

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

```console
$ expr 1 + 2
3

$ echo "1 + 2" | bc
3
```

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

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

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

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

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

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

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

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

```console
$ 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 形式の次のようなファイルがあって、それぞれの項目の合計を出したい場合

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

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

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

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

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

$ cat data.txt
f(1)
f(2)
f(3)
```

```console
$ cat data.txt | bc func.bc
101
104
109
```

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

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

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

```sh:expr.sh
i=0
while [ "$i" -lt 10000 ]; do
  expr "$i" + 10000
  i=$((i+1))
done
```

```sh:bc.sh
i=0
while [ "$i" -lt 10000 ]; do
  echo "$i" + 10000
  i=$((i+1))
done | bc
```

```console
$ 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` コマンドの呼び出しに加えサブシェルとパイプが生成されているからです。詳しくは「[シェルスクリプトはパイプを使うと並列処理されて速い ・・・ は神話！？](https://qiita.com/ko1nksm/items/936f15702eb962a83a95)」を参照してください。

```sh:bc2.sh
i=0
while [ "$i" -lt 10000 ]; do
  echo "$i + 10000" | bc
  i=$((i+1))
done
```

```console
$ 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 シェルへ進化する過程で追加された機能は、このような遅い外部コマンド起動が必要な処理をシェル自身で行えるような機能が追加されているように思えます。他には `sed` や `tr` コマンドへの依存を減らすことができるパラメータ展開が追加されています。

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

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

```sh:arith.sh
i=0
while [ "$i" -lt 10000 ]; do
  echo "$((i + 10000))"
  i=$((i+1))
done
```

```console
$ time ./arith.sh > /dev/null

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

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

```sh:seq.sh
seq -f '%.0f + 10000' 0 9999 | bc
```

```sh:awk.sh
awk 'BEGIN { for(i=0; i<10000; i++) print i "+10000" }' | bc
# 余談 この awk コードってどう見ても手続き型プログラミングですよね？
```

```console
$ time ./seq.sh > /dev/null

real    0m0.016s
user    0m0.020s
sys     0m0.002s
```

```console
$ time ./awk.sh > /dev/null

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

## UNIX 哲学

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

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

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

「9. すべてのプログラムをフィルタとして設計する (Make every program a filter)」は [UNIX 哲学](https://ja.wikipedia.org/wiki/UNIX%E5%93%B2%E5%AD%A6) で言われている言葉の一つですが、どの言語で作ったとしても、すべてのプログラム（事実上 ほぼ 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)」）

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

**補足１** 「梃子（てこ）」とは再利用可能なモジュールのことを意味しているようです。「可能なときには常に、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 ）

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

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

## おわりに

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

**関連記事** [パイプを使って高速化したシェルスクリプトを並列実行すると逆に遅くなる謎現象について](https://qiita.com/ko1nksm/items/4b2103e9fa4aa9c4be07)