読者です 読者をやめる 読者になる 読者になる

猫型プログラミング言語史観(1) 〜あるいはオブジェクト指向における設計指針のひとつ〜

programming

TL;DR

手続き型プログラミングでは手続きを抽象化することで保守性を挙げることに成功したが、データを守ることには失敗してしまった。そこでオブジェクト指向はデータと手続きをひとかたまりにすることでデータを外から守るというコンセプトを打ち出した。

ここから、「手続き型プログラミングで書いてるのに手続きが十分に抽象化されていないのはヤバいね」とか「オブジェクト指向で書いてるのにひとかたまりじゃない雑多なデータに関心をもっちゃってるのはヤバいね」などの設計指針を導くことができるのである。そして純粋関数型言語の場合は……という話です。

はじめに

プログラミング言語にはいろいろなパラダイムがあるが、その中で手続き型プログラミング、オブジェクト指向、純粋関数型言語について、わたしなりのひとつの史観を示すのがこの稿の目的である。となんかかっこつけて言ってみたんだけど、要するに、それぞれのパラダイムがどんなことに対して問題意識を持っていて、どのようなやりかたでその問題を解決しようとしているのかということを、わたしなりの視点で述べてみたいな〜という試みです。

なぜそんな試みをするのか

なぜならそれがプログラミングの役に立つからです。これは言い切っていいと思う。そのパラダイムがどのような問題意識を持っていてどのようにそれを解決しようとしているのかを知ることは、そのパラダイムにおいてどのようにコードを設計すべきなのかに直結する。あるパラダイムが以前存在したどのような問題をどのように解決しようとしたのか、というものをひとつの歴史としてとらえ、ひとつの史観を持つことによってなんらかの設計指針を導くことができるだろうというもくろみである。もちろん、これはわたしの史観(猫型史観)であり、これが唯一の正解だと言うつもりはないし、史実と違うことを言ってるかもしれない(その場合は指摘してもらえるとうれしいな〜)。しかし、設計におけるヒントにはなりうると信じている。では始まり始まり〜

手続き型プログラミング

ではまずは、手続き型プログラミングについて見てみましょう。

蛇蝎のごとく嫌われる「手続き型」という言葉ですが、手続きに注目する、というのは決して悪いことではありません。手続きに注目してプログラミングを考えたときに、「似たような手続きには名前をつけて再利用できるようにしよう」「ひとかたまりの手続きに名前をつけてわかりやすくしよう」となるのは自然なことです。そこで手続き型言語では「関数(関数というよりはプロシジャとかサブルーチンって言ったほうが適切では?というもっともな突っ込みを @masaru_b_cl さんからもらいました。see http://twitter.com/masaru_b_cl/status/424029964984283136 )」を使って、似たような手続きやひとかたまりの手続きに名前をつけて抽象化します。言い方を変えれば、手続き型プログラミング言語は「似たようなひとかたまりの手続きがいろんなところに散らばってる」ということを問題視、あるいは、「ひとかたまりの処理として切り出せるものがベタ書きされていること」を問題視し、「ひとまとまりの手続き」に名前をつけて抽象化することで手続きを抽象化したのです。

ここから、手続き型プログラミングに置ける「良くない匂い」というものが見えてきます。たとえば、「似たような処理がいろんなところに書いてある」っていうパターンですね。似たような処理がいろんなところにちらばって書いてある場合、その処理に修正が必要になった場合に修正箇所が膨大になるし、似てるけど微妙に違ったりするから grep で見つけられないみたいな話になって、「あっ修正漏れ!」となってユーザーに価値を届けるどころかバグを届けることになる。

もうひとつの「良くない匂い」は、「一連の手続きとして名前をつけれるのにな〜」という処理がべた書きされているというパターンです。これは一連の手続きを関数としてくくりだし、「その手続きに必要なデータ」は引数に渡すことによって

  • 関数の呼び出し元は手続きの詳細を気にしなくてよくなる(これがまさに「抽象化である」)
  • 変数のスコープが短くなって、「意図せずこの値が書き換えられちゃった!」って事故が減る

わけです。このあたりの「手続きに名前をつけて抽象化すること」については id:shim0mura 氏の名エントリがあるのでそちらも参照すべきだと思う。

http://d.hatena.ne.jp/shim0mura/20130117/1358436466

手続き型の限界

さて、手続きが 抽象化 されたことでわたしたちは少し幸せになりました。適切に手続きが抽象化されたソースコードは、いままでバラバラと散らばっていた「まとめられるはずのロジック」をあるべき場所にまとめることに成功しました。万歳!最高!

とは、残念ながら行かなかったのです。何が問題になったのか。それは、「値がどこで書き換わるかわからん」という、データ側の問題でした。

手続きを抽象化することで「ベタ書きされていた(あるいは具体的な実装が露出していた)手続き」を整理することはできました。でも、それだけでは、その手続きで扱う「値」に関してはまだまだ不十分だったのです。

ここからはちょっと具体的なコードを見ながら考えてみましょう。そうですね、たとえば、「誕生日になったひとの年齢をひとつ増やすプログラム」を考えてみましょう。

ではまず手続き型から。

#include <stdio.h>

typedef struct {
  char* name;
  int age;
} person_t;

void happy_birthday(person_t* p){
  p->age++;
}

int main(int argc, char** argv){
  person_t shinpei = {"shinpei", 29};
  happy_birthday(&shinpei);
  printf("shinpei's age is %d", shinpei.age);

  return 0;
}

人間データを表す構造体 person_t が存在して、人間の年齢を1増やす手続きとしてhappy_birthdayが定義されています。

さて、happy_birthday という手続きは、「人間を引数にとってその人間の年齢を1増やす」という手続きです。手続きをひとかたまりにして抽出したことによって、ここに任意の人間(shinpei でも jewelve でも dankogai でもだれでも)を入れることができます。さらに、単に値を1増やすんだよ、ってことだけではなく、そこに「誕生日を迎えたんだよ」という意味を持たせることにも成功しています。うん、すばらしい!!!!

しかし、実はこれだけだとちょっと困ったことになるのです。構造体は「自分がどのようなデータを持っているのか」を知っていますが、「自分が持ってるデータにどにような手続きが適用されて、どのように値が変化しうるのか」ということを知りません。もしクソプログラマー氏が、以下のような関数をどこかに書いてしまったら????

void kuso_function(person_t* p){
  p->age = -1;
}

Oooooops!!!!!!年齢が負数になってしまいました!person_t の構造体の定義だけを見ていたら、ageに負数を入れるなんてことは普通の人間ならやらないでしょう。しかし、クソプログラマーは平気でこういうことをします。あるいは、クソプログラマーでなくても、「ひとかたまりの手続き」と「ひとかたまりのデータ」が別の場所で定義されうる限り、ある構造体の値がどういうふうに操作されうるのかはプログラム全体を調べないとわからないし、ちょっとした不注意で、データを壊してしまうことになりかねません。

つまり、手続きだけに注目するだけでは、「ある一塊のデータがどういうふうに変化しうるのか、どういう手続きに適用されうるのか」がわからないし、「どこからでもデータを書き換えることができてしまう」のです。こうなってくると、「どこでどんな手続きによって値が変更されるかわからない、怖い」という問題が生まれてきます。つまり、手続きを抽象化しただけでは、外からの操作から「データを守る」ことはできないのです。

この問題を解決しようとしたのがオブジェクト指向です。ではオブジェクト指向について見てみましょう。

オブジェクト指向

さて、手続き型の限界は、「手続きは一カ所にまとめることができたけど、データがいろんなところで書き換えられることを防ぐことができない(そして事故が起こる)」でした。そこでオブジェクト指向は、「じゃあさ、データと手続きを紐づけて、データと紐づいた手続きしかデータを書き換えられなくすればいいんじゃない?」と考えたのです。これが「データのカプセル化」です。

コードを見ましょう。

class Person
  def initialize(name, age)
    @name = name
    @age = age
  end

  def happy_birthday
    @age += 1
  end
end

Personクラスが、nameという属性とageという属性を持っています。そして、happy_birthdayというメソッド(手続き)は、Person の @age を 1 増加させる手続きです。「Personというひとかたまりのデータがある」「データを操作するひとまとまりの手続きが定義されている」までは手続き型のコードととくに変わりはありません。しかし、オブジェクト指向を取り入れたことによって、private な 値 @age は「Person クラスで定義されているメソッドが呼ばれない限り書き変わらない」とういうことが保証できました(いやまあ Ruby の場合 instance_eval とかオープンクラスとかいろいろ抜け道はあるけどそれは例外としようよここは)。つまり、Personクラスに「kuso_function」みたいなものが定義されていなければ、Person の @age はひとつずつあがっていくだけで、減ったりいきなり10増えたりしないことが保証されたわけです!最高っ!

これで、「データを private で守ればそのデータが定義されたのと同じところで定義された手続きでしか変更できない(これで事故が減るぜ!)」という安心をわたしたちは得ることができました。いままでは構造体の定義だけを見ていても「で、このデータはどういう操作をされうるの?」ってのがわかんなくて「うっどんなことが起こるかわからなくて怖い」ってなってたことが、「ほうほう、このデータはこういう操作をされうるのか」というのがクラス定義を見るだけでわかるようになりました。安心ですね!

大事なことなのでさらに言い方を変えて念押ししましょう。今までは「似た操作はひとつにまとめよう」だけだったところに、「一塊のデータと、それを変更する操作をひとつにまとめて整理しよう」という視点がオブジェクト指向によってもたらされたのです。

ここから、オブジェクト指向における「ヤバい匂い」が見えてきますね。それは「他のオブジェクトのデータを直接いじっちゃうようなメソッド」(データとそれに紐づく操作が一カ所にまとめられてない)だとか、「どこからでもいじれちゃうデータ」(データが守れてない)だとか、「手続きが同じだからといって、複数の種類のデータに関心を持っちゃったクラス」(「ひとまとまりのデータ」になっていない)などです。

さて、最後の「複数の種類のデータに関心を持っちゃったクラス」というのは意外と(特に手続き型に慣れ親しんでいるけどオブジェクト指向に慣れ親しんでいないプログラマーが)やってしまいがちなので、そこについて具体的に見てみましょう。

たとえば、「誕生日に年齢をひとつ増やす」ってことと、「誕生日プレゼントの在庫をひとつふやす」っていう機能があるプログラムについて考えてみましょう。

手続き "だけ" に注目した場合、「数字を増やす」という手続きが抽象化できそうです。手続き型の思考だけだと、ここで「ヨッシャこの「数字を増やす」って機能をまとめてひとつの(基底)クラスにしよう!」 となりがちです。とくに「DRY原則」こそが一番の正義だ!みたいな状態にあるプログラマーはそうなりがちです。

しかし、思い出してください。オブジェクト指向では、「手続きを抽象化すること」だけではまだ不十分で、「ひとかたまりのデータと、それを操作する手続きをまとめること」で問題を解決しようとしていたのでした。そういう視点で見たときに、「人間の誕生日」というのと「倉庫の在庫」というのは全く別のデータです。まったく別のデータをいっしょくたのクラスにまとめてしまったら、せっかくオブジェクト指向を導入して得られた「データとそのデータに対する手続きをまとめて見通しをよくしよう!」というコンセプトから退行してしまいます!

このような場合、例えばどんな問題が起こりうるか、例をあげて見てみましょう。たとえば、倉庫の在庫は実はダース単位でしか増えない、という仕様があとから明らかになったとしましょう。このとき、倉庫の在庫を増やすという操作の単位は12です。一方、年齢を増やすとなったときの単位は1です。これはこまった!!!!もしも手続きだけに注目して、このふたつの操作がひとつのメソッドにまとめられていたら、片方の仕様変更がもう片方に対して致命的な影響をあたえてしまうことになります。困ったプログラマーは よけいな if 文をやフラグをメソッド内や引数に追記して…………OOOOOOOOOOOOOOOPS!!!!! どんどんコードの複雑性はあがっていきます。

いままで私が見てきた、「誤ったオブジェクト指向における構造化」でよくあるのがこのパターンでした。全然異なる関心を持ったデータなのだけど、たとえば「表として表示する」だとか「平均を出す」だとか、そういう「データがどんなものなのか」ではなくて「データをどう処理するのか」ということを軸として抽象化/構造化してしまい、結果異なる関心を持ったデータなのに同じクラスで処理してしまい、片方の変更がもう片方に影響を与えて困ったね、みたいな。

ここで、オブジェクト指向における問題意識のまとめをしましょう。オブジェクト指向は、「手続き」だけではなくて、「そのデータがどういう関心を持ち、どういう操作をされうるのか」ということまで考えることで、「一塊のデータとそれに対する手続き」をまとめることをその目的としていました。なので、いままで「手続き」だけでものを考えていたのにプラスして「そのクラスが関心を持つべきデータはなんなのか」も十分に意識する必要があるでしょう。別種のデータに対する操作を一手に担うようなクラスを作っているぞ、と気づいたときには、一度立ち止まって「おっと、このクラスはどんなデータに関心をもち、そのデータをどう操作したいんだっけ?」と考えてみるといいでしょう。

オブジェクト指向落ち穂拾い

さて、このように「データ」にも注目をしてクラスを作っていくと、「んんーーーーーーーたしかにデータとしてはまったく別の関心なんだけど手続き自体は共通なんだよ!!!!そういうのも別のクラスの別メソッドとして定義しないとダメなの!?」という場合が出てきます。つまり、関心は別なんだけど実装が同じなんだよ!それはどうするの!という話です。そういうときには、伝統的なオブジェクト指向ならば「手続きだけに関心を持ち、データには関心を持たないクラス」ってのを作って、そのクラス(やインスタンス)をデータに責任を持つクラスが保持し、実際の処理は「手続きだけに関心を持つクラスのメソッドを呼ぶ」というやりかたをすると良いでしょう。この「手続きだけに関心を持つ」ってのが最初は理解しづらいのだけれど、そのあたりはストラテジーパターンとかブリッジパターンとかコマンドパターンみたいなのをを学んでみると良いでしょう。このあたりの話をシンプルに表現すると、「実装の再利用に継承を使うな、委譲を使え」というフレーズになるわけですね。

あるいは、traitを持つような言語や Ruby の module みたいな機能がある言語ならば、それらを使うことを検討しても良いでしょう。

純粋関数型言語

あまりにエントリが長くなったので、次回へ続く。

このエントリに対する返歌

http://www.tatapa.org/~takuo/structured_programming/structured_programming.html

@cocoa_ruto 氏がめちゃめちゃ有用で興味深い記事をレスポンスとして書いてくださいました。必読です。