はじめに
本記事では、Robert C. Martinの名著『Clean Code』の第6章「オブジェクトとデータ構造」に関して、自分が大切だと感じた箇所を、具体的なコードを追加しながらまとめました。
この章では、オブジェクト指向と手続き型プログラミングの違い、データ抽象化、デメテルの法則などについて議論されています。本書にはさらに詳細なベストプラクティスが含まれていますので、興味がある方はぜひお読みください。
本記事では、Robert C. Martinの名著『Clean Code』の第6章「オブジェクトとデータ構造」に関して、自分が大切だと感じた箇所を、具体的なコードを追加しながらまとめました。
この章では、オブジェクト指向と手続き型プログラミングの違い、データ抽象化、デメテルの法則などについて議論されています。本書にはさらに詳細なベストプラクティスが含まれていますので、興味がある方はぜひお読みください。
変数をprivate
にすることで、他のクラスからの依存を避け、実装を自由に変更できるようになります。しかし、多くのプログラマは反射的にゲッタとセッタを用意し、実質的に変数をpublic
のように扱ってしまいます。これは実装の隠蔽ではありません。実装の隠蔽とは、抽象化のことです。オブジェクトは単に変数をゲッタ、セッタを通してクラスの外に伝えるものではありません。抽象インターフェースを公開することでデータの実装を隠しつつデータの本質を操作させることが本来の目的です。
詳細な実装を隠して本質的な操作のみを公開するのは以下のような利点があります。
データ抽象化の例として、デカルト座標平面上の点を表すクラスを考えます。
具象的な座標
public class Point {
public double x;
public double y;
}
抽象的な座標
public interface Point {
double getX();
double getY();
void setCartesian(double x, double y);
double getR();
double getTheta();
void setPolar(double r, double theta);
}
具象的な座標の例では、直交座標を用いた実装をそのまま公開しています。一方、抽象的な座標の例では、内部の実装を隠しつつ、データの構造を明確に表現しています。
具象的な温度計
public interface Thermometer {
double temperatureInKelvin;
}
抽象的な温度計
public interface Thermometer {
double getTemperatureInCelsius();
double getTemperatureInFahrenheit();
double convertFahrenheitToCelsius(double fahrenheit);
double convertCelsiusToFahrenheit(double celsius);
}
具象的な温度計では、内部で管理するデータをそのまま公開しています。一方、抽象的な温度計は摂氏と華氏で温度を取得できるように抽象化しており、内部データの管理方法を隠しています。
オブジェクトは内部のデータを隠し、データを操作する機能を公開します。対して、データ構造はデータを公開し、操作する機能は持ちません。これらは相補的であり、実質的に反対の性質を持っています。
データ構造と手続き型の形状クラス
class Circle {
public double radius;
}
class Rectangle {
public double length;
public double width;
}
class ShapeFunctions {
public static double calculateArea(Object shape) {
if (shape instanceof Circle) {
Circle circle = (Circle) shape;
return Math.PI * circle.radius * circle.radius;
} else if (shape instanceof Rectangle) {
Rectangle rectangle = (Rectangle) shape;
return rectangle.length * rectangle.width;
} else {
throw new IllegalArgumentException("Unknown shape type");
}
}
}
データ構造と手続き型では、データと操作を分離して考えます。新しい関数を追加する際にはShapeFunctions
に関数を追加するだけで済むため、既存のデータ構造には影響を与えません。ただし、新しいデータ構造を追加する場合、既存のすべての関数を変更する必要があります。例えば、 Triangle
クラスを追加すると、 SearchFunctions
の関数全てに Triangle
クラスに対する処理を追加する必要があります。
オブジェクト指向の形状クラス
interface Shape {
double calculateArea();
}
class Circle implements Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
class Rectangle implements Shape {
private double length;
private double width;
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
@Override
public double calculateArea() {
return length * width;
}
}
// 新たなクラスの追加
class Triangle implements Shape {
private double base;
private double height;
private double sideA;
private double sideB;
private double sideC;
public Triangle(double base, double height, double sideA, double sideB, double sideC) {
this.base = base;
this.height = height;
this.sideA = sideA;
this.sideB = sideB;
this.sideC = sideC;
}
@Override
public double calculateArea() {
return 0.5 * base * height;
}
}
オブジェクト指向では、新しい形状のクラスを追加しても、既存の関数は影響を受けません。 Triangle
クラスを追加しても、既存のコードは影響を受けません。ただし、新しい関数を追加する場合、すべての形状クラスを変更する必要があります。例えば、形状クラスの外周を求める関数を追加する場合、 calculatePerimeter
メソッドを既存の全ての形状クラスに追加する必要があります。
まとめると、以下になります。
新しいデータ型を追加することが多ければオブジェクト指向が適しており、関数を追加することが多ければ手続き型とデータ構造が適しています。オブジェクト指向が常に優れているわけではなく、システムによってどちらが適切かを見極める必要があります。
今まで見てきたように、オブジェクトを使用する際、そのオブジェクトの内部について知るべきではありません。オブジェクトはデータを隠蔽し、操作を公開します。アクセサを通して内部のデータ構造をそのまま公開するべきではありません。
デメテルの法則は、「オブジェクトは直接の友達だけと通信するべきであり、友達の友達に話しかけてはいけない」という原則で、オブジェクトが他のオブジェクトの内部構造に依存しないようにするものです。
正確には、クラス C
のメソッド f
は、以下のオブジェクトのメソッドのみを呼び出すことができます。
C
自身C
のインスタンス変数に保持されたオブジェクトf
で生成されたオブジェクトf
の引数で渡されたオブジェクトデメテルの法則に違反する例
public class Order {
private Customer customer;
public Customer getCustomer() {
return customer;
}
public void printCustomerZipCode() {
String zipCode = customer.getAddress().getCity().getZipCode();
System.out.println("Customer ZIP Code: " + zipCode);
}
}
上記の printCustomerZipcode
メソッドにおいて、友達は customer
ですが、getAddress()
の戻りオブジェクト(友達の友達)のgetCity()
を呼び出し、さらにgetCity()
の戻りオブジェクト(友達の友達の友達)のgetZipCode()
を呼び出しているため、デメテルの法則に違反しています。
デメテルの法則に従う例
public class Order {
private Customer customer;
public Customer getCustomer() {
return customer;
}
public void printCustomerZipCode() {
String zipCode = customer.getZipCode();
System.out.println("Customer ZIP Code: " + zipCode);
}
}
String absolutePath = ctxt.getOptions().getTempDir().getAbsolutePath();
上記のような呼び出しチェインは「電車の衝突」と呼ばれ、一般には避けるべきとされています。以下のように分けるのが良いでしょう。
Options opts = ctxt.getOptions();
File tempDir = opts.getTempDir();
String absolutePath = tempDir.getAbsolutePath();
これがデメテルの法則に違反しているかどうかは、opts
やtempDir
がオブジェクトかデータ構造かによります。もしこれらがデータ構造であれば、デメテルの法則は適用されません。
String absolutePath = ctxt.options.scratchDir.absolutePath;
上記のような書き方であれば、関数を持たないデータ構造のpublic
変数であると分かるので、デメテルの法則に違反していないとすぐに判断できます。もしデータ構造でprivate
変数とアクセサ関数を用いると、オブジェクトかデータ構造かすぐに判断ができなくなるため、事態がややこしくなります。しかし、実際に単純なデータ構造に対してアクセサとミューテータを用意するフレームワークや標準(ビーン)があることも事実です。
オブジェクトとデータ構造の混血児は、新たな関数を追加することを困難にするだけでなく、データ構造の追加も困難にするので最悪です。避けないといけません。
String absolutePath = ctxt.getOptions().getTempDir().getAbsolutePath();
先ほどの例で、ctxt
、options
、tempDir
がデータ構造ではなくオブジェクトだった場合はどう修正すればいいでしょうか?
まず思いつくのは、 ctxt
に以下のメソッドを用意することです。
ctxt.getAbsolutePathOfTempDir();
しかし、このように情報を取得するメソッドを追加していくと、ctxt
に大量のメソッドを用意しなければならなくなりそうです。そもそも、なぜ一時ディレクトリの絶対パスを取得したいのかを考える必要があります。たとえば、一時ファイル生成のために一時ディレクトリの絶対パスを取得するのであれば、ctxt
にその責任自体を持たせるのが良さそうです。
BufferedOutputStream bos = ctxt.createTempFileStream(fileName);
この方法だと、ctxt
が一時ファイルの生成を担当し、内部の構造や詳細を隠蔽します。これにより、外部のコードは一時ディレクトリの絶対パスを知る必要がなくなり、ctxt
の内部構造に依存することもありません。
典型的なデータ構造は、クラス関数を持たずpublic
変数のみを持つもので、DTO(Data Transfer Object)と呼ばれることがあります。データベースやソケットから取得したデータをパースする際に便利です。アプリケーションでは、データベースから読み込んだ生データを変換していく過程の最初の段階として使われます。一般的なビーンでは以下のようにprivate
変数とゲッタ、セッタで操作されますが、何の利点もありません。
public class UserDTO {
private String id;
private String name;
private String email;
public UserDTO() {}
public UserDTO(String id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
}
アクティブレコードはDTOの特殊形態です。public
変数(か、ビーン形式のアクセス手段)を持ったデータ構造ですが、save
やfind
などの典型的なメソッドも持っています。一般にアクティブレコードはデータテーブルやデータソースの直接の写像ですが、オブジェクトのように扱おうとしてビジネスルールを持ったメソッドを追加するのは勧められません。データ構造とオブジェクトの混血児を作ることになるからです。
Ruby on Railsにおけるアクティブレコードの例
class User < ApplicationRecord
# データベースのフィールド
attr_accessor :name, :email, :age
# ビジネスルールを含むメソッド
def can_drink_alcohol?
age >= 21
end
# ビジネスルールを含むメソッド
def send_welcome_email
Mailer.welcome_email(self).deliver_now
end
end
解決策は、アクティブレコードはデータ構造として扱い、ビジネスルールを持ったオブジェクトを別に作成することです。そして、その内部データ(アクティブレコードのインスタンス)はオブジェクトの中に隠蔽します。
アクティブレコード(データ構造)
class User < ApplicationRecord
# データベースのフィールド
attr_accessor :name, :email, :age
end
ビジネスロジックを含むオブジェクト
class UserService
def initialize(user)
@user = user
end
def can_drink_alcohol?
@user.age >= 21
end
def send_welcome_email
Mailer.welcome_email(@user).deliver_now
end
end
オブジェクトとデータ構造にはそれぞれ特性があり、適切に使い分けることが重要です。
オブジェクト
データ構造
オブジェクト指向と手続き型プログラミングの選択は、システムのニーズに応じて行うことが重要です。新たなデータ型の追加が多い場合はオブジェクト指向が適しており、新たな関数の追加が多い場合は手続き型とデータ構造が適しています。
カテゴリー: