かとじゅんの技術日誌

技術の話をするところ

Getter/Setterを避けて役に立つドメインオブジェクトを作る

Clean Architecture 達人に学ぶソフトウェアの構造と設計を読んでます。モデリングに関しては成分薄めですが、よい本だと思います。はい。

Clean Architecture 達人に学ぶソフトウェアの構造と設計

Clean Architecture 達人に学ぶソフトウェアの構造と設計

本書の大筋から少し逸れるが、「5章 オブジェクト指向プログラミング」の「カプセル化」が面白かったので、これを切り口にモデリングについて考えてみる。

OO言語のカプセル化はすでに弱体化している

オブジェクト指向の三大要素の一つである、カプセル化について、以下のようなことが書いてあります。

「カプセル化」がOOの定義の一部となっているのは、OO言語がデータと関数のカプセル化を簡単かつ効果的なものにしているからだ。それによって、データと関数の周囲に線を引くことができる。その線の外側にはデータが見えないようになっていて、一部の関数だけが見えるようになっている。

本書に登場するある座標を表すオブジェクトをJavaでいつも通り書いてみた。フィールドはプライベートで、直接アクセスできないようにGetterもあるし、距離を計算するメソッドもある。カプセル化の例としてわかりやすいものだが、本章ではカプセル化に問題があると言及している。日常的にこういうコードを書いている人にとって、これの何が悪いのか全くわからない…。僕もそのひとりだった。

public class Point {

  public Point(double x, double y) { this.x = x; this.y = y; }
  // 距離を測る
  public double distance(Point p) {
    double dx = x - p.x;
    double dy = y - p.y;
    return Math.sqrt(dx * dx + dy * dy);
  }

  public double getX() { return x; }
  public double getY() { return y; }

  private double x;
  private double y;
}

早速、本書で紹介されている例を写経してみたので、みてみよう。C言語でPointオブジェクトを実装すると以下のようになると紹介されている。

まずpoint.hだ。久しぶりにC言語で書いた…。

#ifndef OOP_POINT_H
#define OOP_POINT_H

struct Point;
typedef struct Point* PPoint;

PPoint makePoint(double x, double y);
double distance(PPoint p1, PPoint p2);

#endif //OOP_POINT_H

次はpoint.cだ。構造体の宣言が.cファイルに書ける。

#include "point.h"
#include <stdlib.h>
#include <math.h>

struct Point {
    double x,y;
};

PPoint makePoint(double x, double y) {
    PPoint p = malloc(sizeof(struct Point));
    p->x = x;
    p->y = y;
    return p;
}

double distance(PPoint p1, PPoint p2) {
    double dx = p1->x - p2->x;
    double dy = p1->y - p2->y;
    return sqrt(dx*dx + dy*dy);
}

このようにPoint構造体をインスタンスと見立てて、内部状態をカプセル化しそれらを利用する関数群というのはよく見られる設計です。本章の解説は以下のとおり。

point.hのユーザは、struct Pointのメンバーにアクセスできない。makePoint()とdistance()は呼び出せるが、Pointのデータ構造や関数の実装については何も知らない。 これは(非OO言語による)完ぺきなカプセル化である。

ヘッダにはPoint構造体の名前しかないので、ヘッダ利用者からはこの構造体の内部へアクセスができない。というか、ヘッダ利用者は 構造体の内部構造さえ知ることができない 。これが、 完璧なカプセル化 だと言及されている。

そういえば、C言語でライブラリを開発していた頃はこういうコードをよく書いていて、メンバーは存在自体を隠蔽していた記憶がある。

そして、著者はC++では、そういう完璧さが失われてしまったとしている。

だがその後、C++というOO言語が登場し、C言語の完璧なカプセル化が破られてしまった。C++のコンパイラの技術的な理由から、クラスのメンバー変数をヘッダファイルに宣言する必要があった。

C++では以下のようなコードになる。Cの例と比べると、 メンバ変数x, yがヘッダ利用者から見えてる 。もちろん、アクセス修飾子を使えば、コンパイラによる防御は可能だが、 ヘッダ利用者に内部に隠蔽すべき知識が、(ソースコードレベルでは)外部に暴露していることになる 。本章では、これを「カプセル化が壊れている」の根拠としている。

こういう視点からみると、C++だけではなく、Java, C#などヘッダと実装を分離しない言語ではカプセル化は弱体化していることになる*1

#ifndef OOP_POINT_HPP
#define OOP_POINT_HPP

class Point {
public:
    Point(double x, double y);
    double distance(const Point& p) const;
private:
    double x;
    double y;
};

#endif //OOP_POINT_HPP
#include "point.hpp"
#include <math.h>

Point::Point(double x, double y) : x(x), y(y) {
}

double Point::distance(const Point &p) const {
    double dx = x - p.x;
    double dy = y - p.y;
    return sqrt(dx * dx + dy * dy)
}

追記:C++でもPimplイディオムを使えば完ぺきなカプセル化ができるようです。コメントいただき、ありがとうございました。

flat-leon.hatenablog.com

カプセル化を破るGetter/Setter

いまや完ぺきなカプセル化ができなくなってしまったが、今の問題は残念ながらそこじゃない。C++の例のようにprivate フィールドのままならよいが、安易にGetter, Setterを追加すると、知識のカプセル化が破られる。この問題はあまりに日常的に起こっていて気づきにくい*2。そして、そのGetter, Setterによって、ドメインオブジェクトが骨抜きになることはよくある。いわゆる貧血症オブジェクトを生み出してしまう問題だ。

たとえば、先ほど示したJavaのPointクラスのgetX(), getY()のGetterはカプセル化を弱めてしまっている。

public class Point {

  public Point(double x, double y) { this.x = x; this.y = y; }

  public double distance(Point p) {
    double dx = x - p.x;
    double dy = y - p.y;
    return Math.sqrt(dx * dx + dy * dy);
  }

  public double getX() { return x; }
  public double getY() { return y; }

  private double x;
  private double y;

}

そして、以下のようにPointクラスが担うべき距離計算処理は、getX(), getY()などのGetterによってカプセル化が壊れているので、Pointクラスの責務に逆らって他のクラスに実装できてしまう。

public class Point {
  public Point(double x, double y) { this.x = x; this.y = y; }
// カプセル化が弱いと、このメソッドがあってなくてもPointの責務を無視できる
//  public double distance(Point p) 
  public double getX() { return x; }
  public double getY() { return y; }
  private double x;
  private double y;
}

public class PointLogic {

   public double distance(Point p1, Point p2) {
    double dx = p1.getX() - p2.getX();
    double dy = p1.getY() - p2.getY();
    return Math.sqrt(dx * dx + dy * dy);
  }

}

Getter, Setter禁止とTell, Don't Ask原則

前述のような状況を改善させるには、「ThoughtWorksアンソロジー ―アジャイルとオブジェクト指向によるソフトウェアイノベーション」の「オブジェクト指向エクササイズ」には、「ルール9:Getter,Setter、プロパティを使用しないこと」という一見過激なルールが効果的だ。だいたい、初見で学ぶとやりすぎじゃねーの?と思うやつです。でも、やってみると意外と効果がある。

ThoughtWorksアンソロジー ―アジャイルとオブジェクト指向によるソフトウェアイノベーション

ThoughtWorksアンソロジー ―アジャイルとオブジェクト指向によるソフトウェアイノベーション

  • 作者: ThoughtWorks Inc.,株式会社オージス総研オブジェクトの広場編集部
  • 出版社/メーカー: オライリージャパン
  • 発売日: 2008/12/27
  • メディア: 単行本(ソフトカバー)
  • 購入: 14人 クリック: 323回
  • この商品を含むブログ (81件) を見る

このルール9にはこんなことが書いてある。

インスタンス変数の適切な集合をカプセル化してもまだ設計がぎこちないときは、もっと直接的なカプセル化の違反がないかチェックしましょう。振る舞いがその場で簡単に値を求められるようになっていると、その振る舞いはインスタンス変数の後を付いてきません。(中略) これは後に、重複の大幅な削減や、新機能を実現するための修正の局所化、といった大きな効果をもたらします。このルールは「求めるな、命じよ」として一般的に言われています。

このルールを知らなくても、「求めるな、命じよ」(Tell, Don't Ask)という言葉を知ってる人は多いでしょう。

このGetter, Setterの不要・必要論はかなり昔からあって、以下のQiita記事がわかりやすいので読むとよいです。

qiita.com

記事中で、「求めるな、命じよ」(Tell, Don't Ask)というオブジェクト指向の原則がわかりやすく解説されている。

ある処理をする際、その処理に必要な情報をオブジェクトから引き出さないで、情報を持ったオブジェクトにその処理をさせろということ

Point#getX(), #getY()は、まさに情報を引き出す行為だ。Point#getX(), #getY()を使って構成されるロジックは、Pointが持つべきロジックかもしれない。

あるクラスで他クラスのgetterを呼び出すような処理を実装している場合、その処理は本来呼び出されるクラス側で実装されるべきだということ

せっかくPoint#distanceメソッドが実装されていても、getX(), getY()が使えるのでdistanceを迂回することができる。これらのGetterの使い方によっては、Pointは台無しになる可能性がある。

もともとこれは知ってはいたけど、実践的なノウハウは増田さんの本 現場で役立つシステム設計の原則 ~変更を楽で安全にするオブジェクト指向の実践技法 から学んだ。判断/加工/計算を持たない、Getter/Setterのみのデータクラスは諸悪の根源であり、「第10章 オブジェクト指向設計の学び方と教え方」の「古い習慣から抜け出すちょっと過激なコーディング規則」では、そのGetterとSetterに以下の弊害があると述べられている。なぜデータクラスを使うとダメなのかは、「第3章 業務ロジックをわかりやすく整理する」を読むのがよいでしょう。

データクラスは諸悪の根源です。(中略)メソッドは、何らかの判断/加工/計算をしなければなりません。インスタンス変数をそのまま返すだけのgetterを書いてはいけません。インスタンス変数を書き換えるsetterはプログラムの挙動を不安定にし、バグの原因になります。

現場で役立つシステム設計の原則 ~変更を楽で安全にするオブジェクト指向の実践技法

現場で役立つシステム設計の原則 ~変更を楽で安全にするオブジェクト指向の実践技法

確かに、Getter, Setterは、ほとんどの場合で判断/加工/計算をしない。Pointで例でもそうだ。逆に、Point#distanceメソッドは計算をしている。ドメインオブジェクトはこういう分析の視点が反映されているべきだ。

この記事では副作用の制御が難しいSetterについて特に触れていませんが、こちらを読んでください。(Scalaのようなイミュータブルが基本の言語では、Setterはほとんど書かないので…厄介なのはGetterなのだ…)

blog.j5ik2o.me

Getter, Setterを書かないのは現実的じゃないと、まだ思っているならばActorプログラミングのコードをみてみるといい。

object Point {

  def props(x: Double, y: Double): Props = Props(new Point(x, y))

  case class Distance(x: Double, y: Double)
  case class DistanceResult(value: Double)

}

class Point(val x: Double, val y: Double) extends Actor { // 本来 val は不要

  // val state: State = ...

  override def receive: Receive = {
    case Distance(x, y) =>
     val dx = this.x - x
     val dy = this.y - y
     sender() ! DistanceResult(Math.sqrt(dx * dx + dy * dy))
  }

}

そもそもアクタープログラミングでアクターの内部状態にアクセスできない。つまり、Askしにくい。プロトコルとしてのメッセージを定義してアクターに文字通りTellすることになる。*3

object Main {

  def props(pointRef: ActorRef): Props = Props(new Main(pointRef))

  case object Start

}

class Main(pointRef: ActorRef) extends Actor {
  
  override def receive: Receive = {
     case Start =>
       pointRef ! Distance(2, 1) // !メソッドは、tellメソッドの別名!
     case DistanceResult(value) =>
       println("result = $value")
  }

}

val system: ActorSystem = ActorSystem()
val pointRef: ActorRef = system.actorOf(Point.props(1, 2))
val mainRef: ActorRef = system.actorOf(Main.props(pointRef))

// pointRef.x pointRef.yはコンパイルエラー
// pointRef.state にもアクセスできない
// そもそもActorRefからはActorの内部状態にアクセスできない

mainRef ! Start

sys.addShutdownHook{
  system.terminate()
  Await.result(system.whenTerminated, 60 seconds)
}

追記: 「point1.distance(point2) で距離を取得できていたのだから、pointRef1 ! Distance(PointRef2)ではないと不公平ではないです?」という意見をもらいました。確かにそのとおり。少し書き直してみた。Getterは必要なのですが、Publicにする必要がなかったりします。

https://gist.github.com/j5ik2o/b4512b2e2e6b5343ee8ac06cdb16aa90

データクラスはAskの温床となりやすい

Tell, Don't Askのもう少しわかりやすい例を考えてみた。いつもの銀行口座の例はやめてチャットルームの例で説明してみよう。

内部にメンバ情報を持つチャットルームをモデルとして考えるとき以下のような構造を考えるだろう。これはScalaの例だけど、case classはgetterをコンパイラが自動生成なので、この例は見えないところにgetterが書かれていると思って読めばいい。そう、これは諸悪の根源 データクラス。少なくともドメインオブジェクトとしては表現力が乏しく、何が出てきて何ができないかがわからない。

case class Room(id: Long, members: Set[Member])

では重要なメンバーを追加するシナリオを考えてみよう。利用者はroomのmembersに自由にアクセスできるので、以下のように状態を変えた新しいインスタンスを生成できる(copyメソッドは一部の属性を置き換えるために使います)。これはAskの例です。

val newRoom: Room = room.copy(members = room.members ++ members)

上記のようなコードが責務に含まれるという前提で考えると、ドメインオブジェクトの外に知識が分散するリスクがある。このような重要なコードは、Roomクラスのすぐ近くではなく、遠く離れたインターフェイス層やユースケース層などに現れる。つまり、Roomの全貌を理解するには、これらの散らばったコードを全て探して回り、頭の中ですべてを繋ぎ合わせる必要がある。これでは、非効率でわかりにくいコードになってしまう。

ということで、Room#addMembersメソッドを追加することになるはずだ。

case class Room(id: Long, private val members: Set[Member]) {
  def addMembers(values: Member*): Room = 
    copy(members = this.members ++ values)
}

先ほどのコードと打って変わって、呼び出し側はaddMembersと命じる(Tell)だけだ。Whatを公開してHowは隠されていてAskの例よりわかりやすい。

val newRoom = room.addMembers(newMembers)

これは頭でわかっていても、Getter,Setterを安易に記述してしまうと、それに頼ってしまい、カプセル化を破ってドメインモデルを貧血症にしてしまう。だから、ドメインモデルを設計するときは、publicなGetter/Setterを書かない方が無難でしょう。

追記:メンバーの一覧表示のユースケースがある場合は、もちろんmembersはprivateにできず、publicなGetterは必要です。そういうユースケースもない状況で、無条件にGetter/Setterを定義すると、上記のような状況に陥りやすいという主張です。

Getter/Setterを禁止するのが難しい場合

Getter/Setter禁止が現実的ではない場合、どうするとよいか。それは、このブログ記事に書きましたが、プロパティに長い名前を付けることです。例えば、breachEncapsulationOfなどのプレフィックスを付ける例です。

creators-note.chatwork.com

実はこの方法は、Ericさんが過去に関わっていたTime and Moneyのコードから借用したアイデアです。

Time & Money Code Library

例えば、CalendarDateには、カプセル化を破るが内部状態にアクセスできるメソッドがあります。

  • breachEncapsulationOf_day
  • breachEncapsulationOf_month
  • breachEncapsulationOf_year

Roomの例で説明すると、以下のようになる。

case class Room(id: Long, breachEncapsulationOfMembers: Set[Member])

以下のようなAskするコードは書けるけど、明らかに可読性が落ちるので、Roomの責務に応じてTellするためのメソッドを定義しよう。

val newRoom: Room = room.copy(breachEncapsulationOfMembers = room.breachEncapsulationOfMembers ++ members) // Tell形式に書き換える → val newRoom = room.addMembers(members)

とはいえ、ドメインオブジェクトをI/Oする際は、Getterがあった方がよいかもしれません。フレームワークやライブラリの都合でこのような妥協も必要な場合があります。

val json = room.breachEncapsulationOfMembers.asJson

まとめ

ということで、今日から ドメインオブジェクトに安易にGetter/Setterを書くのをやめてみよう。

*1:コメントもらったけど、確かに Javaなら.class, Javadocのみの提供は完ぺきなカプセル化ですね。誤って*-source.jarを参照しないように…

*2:いや、この問題もヘッダと実装の統合がカプセル化を破る温床となった可能性があるのではないか。わざわざ、外部に存在を知らせなくてもよい知識を暴露しやすくなったのではないか、そういう個人的な疑念を持っている

*3:まぁ、アクタープログラミングでも、内部状態を取得するプロトコルを実装してしまうと同様の問題を抱えることになる…。要注意。しかし、GetするにはGet, GetResultみたいなプロトコルが必要で実装が面倒。そんなことやるぐらいなら、判断/加工/計算するプロトコルを実装した方がコスパがよい、というのはある