Quantcast
Channel: Bashタグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 2722

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

$
0
0
はじめに POSIX で規定されているコマンドに expr コマンドと bc コマンドがあります。expr は正規表現による比較ができる一方 bc コマンドは高度な算術計算が行えるなど機能は全く同じではありませんが共通する機能として数値計算機能を持っています。ただし同じ計算を行うだけでも、この 2 つのコマンドは設計に大きな違いがあります。この記事ではこの 2 つのコマンドの違いから、シェルスクリプトにとってふさわしいコマンドの設計について解説します。 引数 vs パイプ expr と bc の違いは簡単な計算を行うだけですぐ気づきます。例えば 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 の設計の違いがシェルスクリプトのパフォーマンスに与える影響 シェルスクリプトから計算を行う時 expr と bc の設計の違いがシェルスクリプトのパフォーマンスにも影響を与えてきます。 まず一般論として外部コマンドの起動は遅いということを知っておいてください。(外部コマンドの"起動"であって外部コマンドそのものが遅いわけではない。)つまりシェルスクリプトのパフォーマンスを悪くしないためには外部コマンドの呼び出し回数を減らすことが重要になってきます。 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 コマンドの方が実は優れているということに気づきます。 算術式展開の登場 ここで少しシェル(スクリプト)の話をします。 expr コマンドや bc コマンドは正確にはシェルスクリプトの一部ではありません。たまたまシェルスクリプトから呼び出しているだけで、他の言語からも呼び出すことができる外部コマンドです。使ってる機能がシェルスクリプトの機能なのか、シェルスクリプトから呼び出してる外部機能(外部コマンド)なのかは正しく認識する必要があります。 多くの"シェルスクリプト"の解説ではこれらがはっきりと区別されておらず、シェルスクリプトの文法の話をしていたと思いきやいつの間にか外部コマンドの使い方の話に変わってしまっている場合が多々あります。(シェルスクリプトを使った"作業"の解説と考えれば、そうなるのも仕方ないとは思いますが) POSIX シェル(bash など)登場以前の Bourne シェルでは数値計算には一般的に expr コマンドが使われていました。(あ、正確には「使われていたっぽい」です。時代が違うので・・・。)もちろん bc コマンドも使えますがループ変数に 1 加えるような処理は、計算式のリストを bc にわたすことができないのであまり意味がありません。リストを作ってコマンドにパイプで渡すのが良いパターンだとしても、そう都合よくリストが作れるような処理になるとは限りません。 expr コマンドの起動(つまり外部コマンドの起動)が遅いのはすでに説明したとおりです。Bourne シェル から POSIX シェルへ進化する過程で追加された機能は、このような遅い外部コマンド起動が必要な処理をシェルスクリプト自身で行えるような機能が追加されているように思えます。他には sed や tr コマンドへの依存を減らすことができるパラメータ展開が追加されています。 POSIX シェルでは expr コマンドによる計算に変わるものとして算術式展開($(( 1 + 2 )) のようなもの)が登場しました。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 を使った場合の実行速度も提示します。(外部コマンドの起動が遅くともデータの量が多いので、遅いシェルスクリプトよりも C 言語で作られたコマンドや 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 コマンドのような設計でプログラムを作らなければいけません。 さて expr や bc コマンドは何の言語で書かれているでしょうか? そうです、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 シェルスクリプトは一行一データがもっとも扱いやすいデータですが、フィルタは必ずしも一行一データで入出力する必要はありません。xargs -0 で NULL 文字区切りにした所で役目は変わりませんし、圧縮を行う gzip も アーカイブを出力する tar コマンドも動画から音声を抜き出すようなコマンドもバイナリ形式で出力するフィルタです。JSON 形式を入出力する jq コマンドも当然フィルタでありパイプでつなげることだってできます。JSON 形式は awk や sed などの一行一データ形式を前提とするコマンドにつなげることが難しいというだけで、他の JSON 形式に対応しているコマンドにつなげることはできます。よってこれらも UNIX 哲学に反していません。 おわりに この記事は「シェルスクリプト リファクタリング ~遅いシェルスクリプトが供養されてたので蘇生して256倍に高速化させました~」の別の形の第二章です。パフォーマンス向上に全振りするのではなく第一章の考え方をより深く解説したものです。外部コマンドとパイプを排除した一方、こちらの記事ではそれをうまく使うべきと、言っていることが矛盾しているように感じるかもしれませんが、これは目的が異なっているからです。私はシェルスクリプトの高い移植性に着目しシェルスクリプト(移植性重視なので当然 bash ではなく POSIX シェル)でプログラミングをしていますが、これもまた目的が違います。パフォーマンス向上を目的とするなら「256倍に~」の記事のような内容になるし、高い移植性を目的とするならまた別の内容(現在記事執筆中)になるというだけのことです。「256倍に~」の記事は私が予想していた以上にウケてしまいましたが、シェルスクリプト(というより CLI コマンド全般)の設計についてはこちらの記事のほうがより重要な考え方です。適切なやり方は目的と状況によって変わります。誰かに(もちろん私にも)こうすべきと言われても鵜呑みにせずに自分で考えて適切な答えを見つけてください。それはポジショントークかもしれないし間違っていても誰も責任はとってくれません。

Viewing all articles
Browse latest Browse all 2722

Trending Articles