魂のプログラミング
魂のプログラミング
魂のプログラミングとはシンプルな信念である。
プログラムコードは一行一行すべてに魂がこもっていなければならない
という信念である。
以前プログラム製造の現場で若手プログラマを指導している際に, つい「この行には魂がこもっていない」という言い方を何度かしていたところ, 周囲の数人の SE にそのような物言いが伝染してしまった。後日, その中の一人がソースコードレビューに参加したときに「このソースコードには魂がこもっていない」と指摘し, 指摘されたプログラマが反応に窮したというエピソードを聞いた。「魂のプログラミング」という言葉は, このエピソードを聞いたときにふと私の頭の中に浮かんだ言葉である。
「プログラムコードの一行に魂がこもる」とは, 別に宗教めいた意味ではない。一言で言うならば,
プログラムコードのあらゆる行に目的がある
とか
どの一行についてもその目的が必ず説明できる
というような意味である。空行にさえ説明可能な目的があるべきだと私は考えている。
本稿では魂のプログラミングを実践する上での教訓を, 例を挙げながら解説していくことにする。まだまとまっておらず, 多分に断片的であるが, 魂のプログラミングの一端は感じられるのではないだろうか。
やりたいことを簡潔に表現する
やりたいことを正確に理解する
唐突ではあるが, 「endian (エンディアン)」という言葉を聴いたことがあるだろうか。ガリバー旅行記に由来するこの言葉は, 多桁数値を表現する際, 上位桁から下位桁に向かって記述する (big endian) か, 下位桁から上位桁に向かって記述する (little endian) かの約束事を意味している。ちなみに算用数字による 123 などの表記は big endian で, 百二十三を敢えて little endian で書けば 321 となる。太古の昔から多くのコンピュータはどちらかの宗派に属している。(少数派として変態 endian の VAX などや, endianess 切替機能付きコンピュータもある。)
昔, このような質問を受けたことがある。
プログラムを実行しているコンピュータが big endian であるか調べ, もしそうでなかったら 32 ビット値のバイト順を逆転させたい。C でどう書くのがスマートですか
この質問にもし条件反射的に回答するとすれば, たとえば以下のようになる。
| /* big endian であるか調べ, そうでなかったらバイト順を逆転するコード。*/ ... static short one = 1; unsigned v = ...; ... if (*(char*)&one != 0) // 注意: 少し危険な判定! { v = v >> 16 & 0x0000FFFF | v << 16 & 0xFFFF0000; v = v >> 8 & 0x00FF00FF | v << 8 & 0xFF00FF00; } |
しかし, そう回答する前にこの質問者が本当は何がしたかったのか考えてみよう。3 分ほどじっくり考えると, 実はこの質問者は客先のコンピュータの endianess を研究したいわけでも, バイト順を逆転させるトリックを知りたいわけでもないことに気付く。質問者が本当にやりたかったことは,
4 バイト big endian で格納された 32 ビット値を取出す
という処理である。
| /** 4 バイト big endian で格納された 32 ビット値を取出すコード。*/ unsigned v; unsigned char bytes[4]; ... v = bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3]; |
処理は 1 行で完結する。こちらの方がスマートで分かりやすいコードであるが, これは別にプログラミングスキルの問題ではなく, それ以前の問題であったことが分かる。すなわち, 自分が本当は何をしたいのか理解していなかったのである。
最適な手段で表現する
短いコードは常に長いコードより優れている。より良いコードを書きたいなら, 第一に短く簡潔なコードを目指すべきである。
プログラミング言語にはそれぞれ特有の考え方や機能がある。そのため, 最適なコードは利用するプログラミング言語によって変わってくる。この観点からもプログラミング言語に関する正確な知識は重要である。
例として, まず JAVA で素朴な線形リストクラスを実装してみよう。
| /** JAVA 版線形リストの実装クラス。*/ public class SinglyLinkedList<T> { /** 各ノードを表すクラス。*/ private static class Node<U> { private Node(U data) { this(null, data); } private Node(Node<U> next, U data) { this.next = next; this.data = data; } private Node<U> next; private U data; } ... /** * このリストの末尾に指定された要素を追加する。 * * @param o 追加する要素 */ public void pushBack(T o) { Node<T> node = head; if (node == null) { // ノードが一つもなかった場合, 追加する要素のノードが先頭となる。 head = new Node<T>(o); } else { // 最後のノードを探す。 while (node.next != null) node = node.next; // 最後のノードの次に追加。 node.next = new Node<T>(o); } } ... private Node<T> head; } |
同様な実装を C++ のテンプレートで行う。C/C++ のポインタを効果的に利用すると pushBack メソッド (C++ では push_back メンバ関数) の条件分岐が不要となる。
| /** C++ 版線形リストクラステンプレート。*/ template<class T> class singly_linked_list { /** 各ノードを表す構造体。*/ struct node { explicit node(const T& data = T(), node* next = 0): m_next(next), m_data(data) { } node* m_next; T m_data; }; public: ... /** * このリストの末尾に指定された値の要素を追加する。 * * @param o 追加する要素の値 */ void push_back(const T& o) { node** cptr = &m_head; for (node* curr = *cptr; curr != 0; curr = *cptr) cptr = &curr->m_next; *cptr = new node(o); } ... private: node* m_head; } |
なぜこれでよいのかは, じっくり考えてみて欲しい。
アイディアや疑問は実験してみる
次のコード片はコンパイルできるだろうか。もしできたとして実行すると何が起こるのだろうか。
| // ふと疑問に思った JAVA のコード片。 String[] s = { "Hello", "World" }; Object[] o = s; o[0] = new Integer(0); System.out.println(s[0].getClass().getName()); |
JAVA である程度経験を積んだプログラマなら, 答を知っていると思うが, 分からなくても実際やってみることは 3 分もあればできる。
前節で紹介した例の通り, 少なくともポインタがある分 C++ は JAVA よりも表現力で勝っているといえる。しかし, JAVA でも C++ のポインタを真似すれば同様の表現が可能なのではないか, というアイディアを持ったとしよう。
早速頑張ってみよう。
よく JAVA にはポインタがないと言われたり, JAVA にはポインタしかないと言われたりする。JAVA のオブジェクト参照と C/C++ のポインタ (の値) の考え方は似ているが, 決定的に違うのは C/C++ はポインタ型を含めてあらゆる型のデータオブジェクトへのポインタが & 演算子で簡単に作成できるという点である。そこで & 演算子を適用する可能性のあるポインタ (オブジェクト参照) を予め Pointer オブジェクトとしてラップしておけばうまく真似できるのではないかと予想が立つ。詳細な説明は省略するが, その考え方で前節の例を書き直すと, 次のコードとなった。
| /** C++ 版の真似をした JAVA 版線形リスト。*/ public class SinglyLinkedListCPlusPlus<T> { /** X へのポインタを真似るクラス。*/ private static class Pointer<X> { private Pointer(X pointee) { this.pointee = pointee; } private X pointee; } /** 各ノードを表すクラス。*/ private static class Node<U> { private Node(U data) { this(null, data); } private Node(Node<U> next, U data) { this.next = new Pointer<Node<U>>(next); this.data = data; } private Pointer<Node<U>> next; private U data; } ... /** * このリストの末尾に指定された要素を追加する。 * * @param o 追加する要素 */ public void pushBack(T o) { Pointer<Node<T>> cptr = head; for (Node<T> curr = cptr.pointee; curr != null; curr = cptr.pointee) cptr = curr.next; cptr.pointee = new Node<T>(o); } ... private Pointer<Node<T>> head; } |
ひとまず実験は成功である。しかし実験は実験であって, こんな冗談コードを実際の現場では利用するべきではない。この Pointe クラスは if 文を一つ減らすための JAVA での最適な手段とは決して言えない。
境界に注意する
次の中途半端に魂のこもったコードを見てもらいたい。バグが 3 つほど巧妙に仕込んであるのだが, 気付くだろうか。
| /* 関数名と引数で関数値を計算する C のコード。注意: バグ入り。*/ #include <math.h> #include <string.h> static double func_floor(double a) { return floor(a); } static double func_ceil(double a) { return ceil(a); } static double func_sqrt(double a) { return sqrt(a); } static double func_sin(double a) { return sin(a); } static double func_cos(double a) { return cos(a); } static double func_tan(double a) { return tan(a); } static double func_exp(double a) { return exp(a); } static double func_log(double a) { return log(a); } static const struct { const char* keyword; double (*function)(double x); } function_table[] = { { "ceil", func_ceil }, { "cos", func_cos }, { "exp", func_exp }, { "floor", func_floor }, { "log", func_log }, { "sin", func_sin }, { "sqrt", func_sqrt }, { "tan", func_tan } }; static const unsigned MAX_FUNCTION_INDEX = sizeof function_table / sizeof function_table[0] - 1; double calc_function(const char* keyword, double a) { unsigned lo = 0; unsigned hi = MAX_FUNCTION_INDEX; while (lo < hi) { unsigned k = (lo + hi) / 2; int c = strcmp(function_table[k].keyword, keyword); if (c < 0) lo = k + 1; else if (c > 0) hi = k; else return function_table[k].function(a); } return 0; } |
慎重に考えれば calc_function は次のように修正しなければならないことが分かるだろう。
| double calc_function(const char* keyword, double a) { unsigned lo = 0; unsigned hi = MAX_FUNCTION_INDEX; while (lo <= hi) { unsigned k = lo + (hi - lo) / 2; int c = strcmp(function_table[k].keyword, keyword); if (c < 0) lo = k + 1; else if (c > 0) { if (k == 0) break; hi = k - 1; } else return function_table[k].function(a); } return 0; } |
とかく境界の判断や更新は慎重に検討すべきである。
車輪を再発明する
「車輪を再発明する愚」という人がよくいるが, 再発明自体は大いによいことである。
ただし, そのための工数を確保するには, 説得力のある理由が必要である。
動作したという理由だけで満足しない
プログラムが動作したからといって完成したと思ってはいけない。
より汎用化できないか考える
汎用的なコードは, 再利用できる可能性が大きいため共通化が進み, プログラムの肥大化を防ぐことに繋がる。しかしより重要な利点は, 汎用的なコードからは特殊な要件が排除されるため, コードをシンプルに保ち, 品質を上げやすいというところにある。
具体的には, アルゴリズムやデータ構造を表現するコードから特殊な業務要件を分離し, パラメータや派生クラスとして実現することを考えるべきである。必要なら Facade を用意し, I/F をシンプルに保つ工夫も役に立つ。
前節で登場したバイナリサーチを汎用化してみよう。
| #include <stddef.h> const void* binary_search( const void* key, const void* base, size_t n, size_t size, int (*compare)(const void*, const void*)) { size_t lo = 0; size_t hi = n - 1; while (lo <= hi) { size_t k = lo + (hi - lo) / 2; const void* center = (const char*)base + k * size; int c = compare(center, key); if (c < 0) lo = k + 1; else if (c > 0) { if (k == 0) break; hi = k - 1; } else return center; } return NULL; } |
この仕様は, 標準ライブラリの bsearch という関数ほとんどそのままである。車輪の再発明というわけである。
より簡単な方法がないか考える
動作するコードを書いてみて初めて, 設計フェーズでは気付かなかった抽象化や共通化の方法に気が付くことがある。書いたコードを目視確認していて複数回登場する似たようなパターンに気付いたり, I/F をよりシンプルにできることに気付いたりすることは決して珍しいことではない。
やや作為的だが, ここでは汎用バイナリサーチの仕様を少し変更し, 特定の値以上の最初の位置を返す lower_bound を実装してみる。 こちらの方が応用範囲は広がる。
| #include <stddef.h> const void* lower_bound( const void* key, const void* base, size_t n, size_t size, int (*compare)(const void*, const void*)) { size_t lo = 0; size_t hi = n; while (lo < hi) { size_t k = lo + (hi - lo) / 2; if (compare((const char*)base + k * size, key) < 0) lo = k + 1; else hi = k; } return (const char*)base + lo * size; } |
バイナリサーチのコードの変数 hi の役割を微妙に変更している。バイナリサーチでは hi は対象範囲の最大インデックスを保持していたが, 今回の lower_bound では最大インデックス+1 を保持している。
また, さらに作為的だがバイナリサーチもループ内が極力短くなるように少し変形してみる。ただし変数 hi の役割は元のままである。
| #include <stddef.h> const void* binary_search( const void* key, const void* base, size_t n, size_t size, int (*compare)(const void*, const void*)) { size_t lo = 0; size_t hi = n - 1; while (lo < hi) { size_t k = lo + (hi - lo) / 2; if (compare((const char*)base + k * size, key) < 0) lo = k + 1; else hi = k; } return compare((const char*)base + lo * size, key) == 0 ? (const char*)base + lo * size : NULL; } |
こう変形してみると, 変数 hi の役割が違っているにもかかわらず, ループ部分のコードは lower_bound と全く同じコードとなった。実際にコードを書いてみる前にこのような事実に気付くのは難しい。
より効率化できないか考える
変数 lo, hi で対象範囲を絞込んでいくのではなく, 変数 lo と区間幅 n で対象範囲を絞り込んでいくことを考え, この 2 変数の動きを慎重に検討すると, else 節をなくすことができることが分かる。さらに変数 base と n で制御すればより簡単になるが JAVA ではできない。
| #include <stddef.h> const void* lower_bound( const void* key, const void* base, size_t n, size_t size, int (*compare)(const void*, const void*)) { size_t lo = 0; while (n > 0) { size_t k = n / 2; if (compare((const char*)base + (lo + k) * size, key) < 0) { lo += k + 1; --n; } n /= 2; } return (const char*)base + lo * size; } |
乗算は, 加減算と比べると効率が悪いことが多い。工夫するとコードは長くなるがループ内から乗算を追い出すことができる。ここから先は C 専用の最適化を行っている。
| #include <stddef.h> const void* lower_bound( const void* key, const void* base, size_t n, size_t size, int (*compare)(const void*, const void*)) { size_t d = n * size; while (n > 0) { if (n % 2 == 0) { const char* p = (const char*)base + (d /= 2); if (compare(p, key) < 0) { base = p + size; d -= size; --n; } } else { const char* p = (const char*)base + (d = (d - size) / 2); if (compare(p, key) < 0) base = p + size; } n /= 2; } return base; } |
さらに魂をこめる。(x & -x) という式で x のビットパターン中の最下位の 1 だけを取出すことができる。このことを利用すると, 上のコードの変数 n を省略することができる。
| #include <stddef.h> const void* lower_bound( const void* key, const void* base, size_t n, size_t size, int (*compare)(const void*, const void*)) { size_t odd_bit = size & -size; n *= size; while (n > 0) { if ((n & odd_bit) == 0) { const char* p = (const char*)base + (n /= 2); if (compare(p, key) < 0) { base = p + size; n -= size; } } else { const char* p = (const char*)base + (n = (n - size) / 2); if (compare(p, key) < 0) base = p + size; } } return base; } |
ややマニアックな話題だっただろうか。
主義主張を持つ
プログラミングスタイルを確立する
自分なりのプログラミングスタイルを確立するのは重要である。自分のスタイルで美しいコードには愛着が沸く。何より一定のポリシーに従ったプログラムコードは読みやすい。
プログラミング哲学に触れる
R. C. Martin らによってオブジェクト指向設計の原則がまとめられている。特に目新しい哲学とは言えないが, 設計の様々な段階でチェックリストとして用いると便利である。
| 原則名 | 英語名 | 略記 | 意味 |
|---|---|---|---|
| リスコフの置換原則 | The Liskov Substitution Principle | LSP | 派生型はその基本型と置換可能でなければならない。 |
| 依存の非循環原則 | The Acyclic Dependencies Principle | ADP | パッケージは循環依存してはならない。 |
| 依存関係逆転の原則 | The Dependency Inversion Principle | DIP | 上位レベルモジュールは下位レベルモジュールに依存してはならない。どちらも抽象モジュールに依存する方がよい。抽象は詳細に依存してはならない。 |
| I/F分離の原則 | The Interface Segregation Principle | ISP | インターフェースは最小であるべき。クライアントが利用しないメソッドへの依存を強制してはならない。 |
| 安定依存の原則 | The Stable Dependencies Principle | SDP | パッケージの依存方向は安定性の方向と一致すべきである。 |
| 安定抽象の原則 | The Stable Abstraction Principle | SAP | 安定したパッケージは抽象的であるべきである。 |
| 開放/閉鎖の原則 | The Open-Closed Principle | OCP | ソフトウェアの構成要素 (クラス、モジュール、関数など) は拡張に対して開いていなければならず、修正に対しては閉じていなければならない。 |
| 全再利用の原則 | The Common Reuse Principle | CRP | 1パッケージ内のクラス群は同時に再利用される。 |
| リリース-再利用等価の原則 | The Release-Reuse Equivalency Principle | REP | 再利用の粒度はリリースの粒度と一致する。 |
| 閉鎖性共通の原則 | The Common Closure Principle | CCP | 1パッケージ内のクラス群は同種の変更に対して閉じている。パッケージに影響する変更はパッケージ内のすべてのクラスに影響を及ぼすが、ほかのパッケージには影響しない。 |
| 単一責務の原則 | The Single Responsibility Principle | SRP | 1つのクラスに1つの責務。クラスを変更する理由は1つ以上存在してはならない。(責務=変更理由) |
魂を抜く
「一行入魂」を実践することはそれほど難しいことではないが, これが「万行入魂」とかになってくると, 必ずしもすべての行に同じ集中力で魂を込めるというのは現実的でなくなってくる。
イディオムを使いこなす
たとえば多くのプログラマは C++ や JAVA で n 回だけ繰返したいと思えば
| ... for (int k = 0; k < n; ++k) ... |
というコードが特に考えなくても出てくるだろう。C/C++ でリスト構造を先頭から末尾まで辿りながら処理したいと思えば
| ... for (p = head; p != NULL; p = p->next) ... |
である。この程度のコードならほとんど反射的にタイプする人もいる。
私の場合, その昔 i8080 系の CPU の全盛期に, アセンブリ言語で大量にコードを書いた経験があるが, このテの CPU はアドレッシングが貧弱で, 小さなテーブルルックアップだけでも次のようなコードを必要とした。しかし, この一連の流れはもう指が覚えてしまっていて, 最初のステップをタイプし始めながら, 6 ステップ先以降のコードを考えるというような芸当をこなしていた。高級言語の 1 ステップが数ステップから数十ステップにも膨らむアセンブリ言語で大量のコードを書くには, このような熟練はほとんど必須と言える。
| LHLD base LDA index MOV E, A MVI D, 0 DAD DE MOV A, M |
いや, 今はもうほとんど覚えていない。
もちろんイディオムを構成する一連のコードは, 一度は魂を込めて考える必要がある。いわば一度込めた魂を再利用しているわけである。
メリハリを意識する
矛盾することを言うようだが, プログラム全体の中には密度が薄く, 再利用可能性をほとんど考える余地のないコードが大量に含まれることになる。魂を削って心血注ぐべきコードの割合が大きくなるようでは, あまりバランスのよいプログラム設計とは言えない。
設計書を妄信しない
設計者も間違う
信用しないと言うと語弊があるが, プログラマが渡された設計書は絶対的なものではないし, まして完全なものだと信じてはいけない。自分で納得できないプログラムは書くべきではない。プログラマはキーパンチャではないのだから。
設計へのフィードバック
私は昔物理学を勉強していたのだが, 物理学の根底には, 「正しい理論はシンプルであり, 数学的にも美しい理論である」という漠然とした信念がある。やたら前提条件が複雑であったり, 表現するのに複雑な数式が大量に必要であったりすると, その理論は別の大きな理論の一部を歪に切り取ったものなのではないか, とか, その理論をシンプルに表現できる数学が発明されるべきだと考えるわけである。
プログラムコードについても似たような考え方が成り立たなくもない。ある単純な処理を行うコードがむやみに複雑だったりした場合, 処理仕様が歪んでいるのではないのかとか, プログラミング言語や標準ライブラリに何か問題があるのではないかと疑ってみるのだ。
前者の場合, 処理仕様の改訂が提案できるような状況では大いに提案すべきである。
後者の場合, プログラミング言語の選定をやり直すというのは状況的に受入れられないことが多いが, たとえば非 OOP 言語で OOP の真似をする規約を提案するなど, 工夫によっては何とかなる可能性もある。
推薦図書
| 書名 | 著者 | 内容 |
|---|---|---|
| プログラミング言語C | Brian W. Kernighan Dennis M. Ritchie |
C言語のバイブル。コード例の質が高く, C言語に興味がなくても為になる。著者の頭文字をとって「K&R」とよく省略される。 |
| プログラミング言語C++ | Bjarne Stroustrup | C++言語のバイブル。分量も密度も高く, 示唆に富んでいる。 |
| プログラム書法 | Brian W. Kernighan P. J. Plauger |
古典的な名著。良いプログラムを書くためのべからず集。 |
| 文芸的プログラミング | Donald E. Knuth | プログラムは文芸作品であるという哲学を実践する本。Javadoc にも影響を与えている。 |
| ハッカーの楽しみ ―本物のプログラマはいかにして問題を解くか |
Henry S. Warren Jr. | さまざまなプログラミングテクニック集。内容は非常に濃い。 |
| 達人プログラマー ―システム開発の職人から名匠への道 |
Andrew Hunt David Thomas |
良いソフトウェアを開発/設計/製造するための指針。 |
| Javaによるアルゴリズム事典 | 奥村晴彦 | アルゴリズムの事典。「Javaによる」という部分には期待しない方がよい。解説は薄いため, 本格的な勉強用には向かない。 |