「Getter/Setterを作るな」の本当の意味
オブジェクト指向において「Getter/Setterを作るな」というのは様々な場所で言われていることだが、これが一番難しくて苦労する。Getter/Setterを排除するための対処法として「デメテルの法則」や「Tell, don't ask.」などが世の中のあらゆる場所で紹介されているが、どれもこれも対処療法に過ぎない。Getterをできるだけ無くすにはオブジェクトに対する認識そのものを変える必要がある。
オブジェクトはデータの入れ物ではない
Qiitaなどでオブジェクト指向に関する記事を検索するとそのほとんどがオブジェクトを「データとそれを操作するメソッドの集まり」として扱っているが、これがそもそもの間違いだ。オブジェクトは決してデータの集まりなんかではない。どういうことか。例えば下記は「間違ったオブジェクト指向」の解説でよく見かけるようなPerson
オブジェクトだ。
class Person { int id; string name; int age; public Person(int id, string name, int age) { this.id = id; this.name = name; this.age = age; } public int GetId() { return this.id; } public string GetName() { return this.name; } public int GetAge() { return this.age; } }
この時点でGetter以外に既に間違っているところがある。まずPersonからNameを取得するメソッドの名前はGetName
ではなく単にName
だ(理由は後述)。他も同様に修正しよう。
class Person { int id; string name; int age; public Person(int id, string name, int age) { this.id = id; this.name = name; this.age = age; } public int Id() { return this.id; } public string Name() { return this.name; } public int Age() { return this.age; } }
// 使い方 // 10回Personの情報を出力する。(意味不明なメソッド…例えということで勘弁して) void Print10Times(Person person) { for (int i = 0; i < 10; i++) { Console.WriteLine( "ID {1} : Name = {2}, Age = {3}", person.Id(), person.Name(), person.Age() ); } }
この時点ではperson.name
で直接フィールドを参照するのとperson.Name();
でメソッドを通して参照することの結果に全く差はない。しかし意味的に言えば、両者には決定的な差がある。person.Name();
の方はpersonの名前を取得する手続きが隠蔽されているのだ。この説明でしっくりこないのは Person
がこの時点ではまだ単なるデータの入れ物であり、私たちがPerson
の中身を知っている前提で話をしているからだ。このPerson
を本当の意味でオブジェクトにするためにはインターフェースを書く必要がある。
interface Person { string Id(); string Name(); int Age(); }
class ConstPerson : Person { int id; string name; int age; public ConstPerson(int id, string name, int age) { this.id = id; this.name = name; this.age = age; } public int Id() { return this.id; } public string Name() { return this.name; } public int Age() { return this.age; } }
// Personがインターフェースであることに注目 void Print10Times(Person person) { for (int i = 0; i < 10; i++) { Console.WriteLine( "ID {1} : Name = {2}, Age = {3}", person.Id(), person.Name(), person.Age() ); } }
これで手続きが本当に「隠蔽」された。もはやName()
メソッド内で何が起こるかは呼び出し側からは全く分からない。端的に言えばpersonオブジェクトが、自身がフィールドとして持っているデータを返そうがデータベースにアクセスしてデータを取ってきてから返そうが呼び出し側にとっては全く関係がなくなった。Person
の実装は下記のようなものでも良いわけだ。
// データベースのPerson class DbPerson : Person { readonly int id; readonly SqlConnection con; public DbPerson(int id, SqlConnection con) { this.id = id; this.con = con; } public int Id() { return this.id; } public string Name() { try { con.open(); string command = "select name from people where id ='" + id +"'"; SqlDataReader data = new SqlCommand(command).ExecuteReader(); return (string)data["name"]; } finally { con.close(); } } public int Age() { try { con.open(); string command = "select age from people where id ='" + id + "'"; SqlDataReader data = new SqlCommand(command).ExecuteReader(); return (int)data["age"]; } finally { con.close(); } } }
// Personがインターフェースであることに注目 void Print10Times(Person person) { for (int i = 0; i < 10; i++) { Console.WriteLine( "ID {1} : Name = {2}, Age = {3}", person.Id(), person.Name(), person.Age() ); } }
これはYegor氏のブログの「SQLを話すオブジェクト」を参考にしたものだ。ここで気付かれるだろうが、もしDbPersonのインスタンスがPrint10Times
メソッドに渡されていたとすると、Name()
を呼ぶたびにデータベースアクセスが発生することになる。そこで「Name()
メソッドを呼び出したときに、データベースアクセスが起こるかもしれないなんて誰も想像できないじゃないか。これは悪い設計だ!」という人(かつての私)がいるかもしれない。
喝!!
中身を気にしなくてよい(カプセル化)というオブジェクト指向の大原則はどこへ行ったのか。真のオブジェクト思考プログラマーはフィールドを返すName()
とデータベースアクセスを行うName()
を区別しない。冗談ではなく本当に区別しない。私が求めるのは person.Name()
がperson自身の名前を返すこととその名前が正しいことであり、途中で何が起こってようがそれはどうでもいいことだ。もしperson.Name()
の動作が遅くてシステム要件を満たせない…となったらその時に初めて私はpersonの中身を確認し、データベースアクセスが原因であることを突き止める。そしてデータをキャッシュする新しいPerson
の実装クラスを作り、下記のようにするだけだ。
class CachedPerson : Person { int id; string name; int age; public CachedPerson(int id, string name, int age) { this.id = id; this.name = name; this.age = age; } public int Id() { return this.id; } public string Name() { return this.name; } public int Age() { return this.age; } }
void Print10times(Person person) { // 最初にキャッシュする。もし最初からpersonがCachedPersonでも無意味なだけで大丈夫。 Person cached = new CachedPerson(person.Id(), person.Name(), person.Age()); for (int i = 0; i < 10; i++) { Console.WriteLine( "ID {1} : Name = {2}, Age = {3}", cached.Id(), cached.Name(), cached.Age() ); } }
さて、改めて上記のコードを見返してみよう。DbPerson
はデータの居場所(SqlConnection)とIDを知っているだけだが、メソッドの呼び出し側からすれば立派にPersonとして振舞っている。DbPerson
はデータベースのデータをPersonとして振舞わせてるオブジェクト、まさに「データベース内に生きるPerson」と表現できる。もはやName()
はGetterではない。
ここで私が言いたいのは、オブジェクトは別に中にデータを持っている必要はないし、メソッドは中のデータを必ず使わないといけないわけではないということだ。オブジェクト指向においてオブジェクトに求めることはメソッドの戻り値が正しいことで、その手段ではない。だからGetName
とかFindなんとか
とかいうメソッド名は間違っている。GetしてないかもしれないしFindしてないかもしれないから。オブジェクトの属性を得るメソッド名は単に名詞で十分だ。動詞を使って実装を縛るような発想を与えるのはよろしくない。同じ理由でインターフェースを書かずにいきなりクラスを書き始めるのも間違っている。オブジェクトを設計するときはそのオブジェクトが何かではなく何をしてほしいか(インターフェース)を先に考える。そして後から中身(クラス)を考えるのが正しい手順だ。*1
さて、上記のコードに戻る。一時的にキャッシュのために用意したCachedPerson
は「メモリの中に生きるPerson」だ。このクラスのメソッドは…残念ながらまだGetterだ。しかし「オブジェクトはデータの入れ物ではない」という基本ができていれば問題ない。ここからはやっと「デメテルの法則」や「Tell, don't ask.」が役に立つだろう。*2
まとめると、Getterを無くすための合言葉は「オブジェクトはデータの入れ物ではない」だ。
Setterは…?
今回はGetterを無くすための考え方を紹介したが、Setterについては別の機会に記事を書く。ざっくり紹介するとSetterを無くすための合言葉は「オブジェクトは生まれたときに完成していなければならない」だ。