👨‍🎓

クリーンコード【オブジェクトとデータ構造編】

2024/07/13に公開

はじめに

本記事では、Robert C. Martinの名著『Clean Code』の第6章「オブジェクトとデータ構造」に関して、自分用にまとめました。具体的なコード例は差し替えたり追加しています。この章では、オブジェクト指向と手続き型プログラミングの違い、データ抽象化、デメテルの法則などについて議論されています。興味がある方はぜひ本書をお読みください。

変数をprivateにする理由

変数をprivateにすることで、実装を隠蔽し、他のクラスからの依存を避け、実装を自由に変更できるようになります。しかし、多くのプログラマは反射的にゲッタとセッタを用意し、実質的に変数をpublicのように扱ってしまいます。これは実装の隠蔽にはなりません。

実装の隠蔽とは抽象化のことであり、オブジェクトは単に変数をゲッタとセッタを通してクラスの外に伝えるものではありません。抽象インターフェースを公開することでデータの実装を隠し、データの本質を操作させることが本来のprivateの目的です。

データ抽象化の利点

詳細な実装を隠して本質的な操作のみを公開するのは以下のような利点があります。

  • カプセル化
    外部からの直接アクセスを制限することで、データの整合性を保ち、不正な操作を防ぐことができる。
  • 抽象化
    内部の詳細を隠し、必要な部分だけ公開することでユーザーが使いやすくなる。
  • 変更容易性
    詳細に対する依存性を無くし、外部のコードに影響を与えずに内部の詳細を変更できる。
  • 再利用性
    汎用的なオブジェクトを作成することで、他のプロジェクトからも利用しやすくなる。
  • セキュリティ
    詳細を隠蔽することで、外部からの不正アクセスやデータの改ざんを防ぐことができる。

データ抽象化の例

データ抽象化の例として、デカルト座標平面上の点を表すクラスを考えます。

具象的な座標

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の内部構造に依存することもありません。

データ転送オブジェクト(DTO)

典型的なデータ構造は、クラス関数を持たずpublic変数のみを持つもので、DTO(Data Transfer Object)と呼ばれることがあります。データベースやソケットから取得したデータをパースする際に便利です。アプリケーションでは、データベースから読み込んだ生データを変換していく過程の最初の段階として使われます。一般的なビーンではpublic変数ではなく、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

結論

オブジェクトとデータ構造にはそれぞれ特性があり、適切に使い分けることが重要です。

オブジェクト

  • 内部構造を隠蔽し、振る舞いを公開します
  • 新しいクラスを追加するのが容易です
  • 既存のオブジェクトに新たな振る舞いを追加するのは難しくなります

データ構造

  • データを公開し、振る舞いを持ちません
  • 新しい振る舞いを追加するのが容易です
  • 既存の機能に新しいデータ構造を追加するのは難しくなります

オブジェクト指向と手続き型プログラミングの選択は、システムのニーズに応じて行うことが重要です。新たなデータ型の追加が多い場合はオブジェクト指向が適しており、新たな関数の追加が多い場合は手続き型とデータ構造が適しています。

Discussion