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

【Effective Java】項目2:数多くのコンストラクタパラメータに直面した時にはビルダーを検討する

Java Effective Java

多くのオプションパラメータを持つオブジェクトを生成する場合はビルダーパターンを利用することを検討する。

テレスコーピングパターン

多くのオプションパラメータが存在するとき、伝統的にはテレスコーピングコンストラクタと呼ばれるパターンが利用されてきた。 例えば、加工食品で示される栄養成分(Nutrition Facts)ラベルを表すクラスのコンストラクタを考えてみる。

//
// テレスコーピングパターンによる NutritionFacts
//
public class NutritionFacts {
    private final int servingSize; // (ml) 必須
    private final int servings; // (容器あたり)必須
    private final int categroes; // オプション
    private final int fat; // オプション
    private final int sodium; // オプション
    private final int carbohydrate // オプション
 
    public NutritionFacts(int servingSize, int servings) {
        this(servingSize, servings, 0, 0, 0, 0);
    }
    public NutritionFacts(int servingSize, int servings, int calories) {
        this(servingSize, servings, calories, 0, 0, 0);
    }
    public NutritionFacts(int servingSize, int servings, int calories, int fat) {
        this(servingSize, servings, calories, fat, 0, 0);
    }
    public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium) {
        this(servingSize, servings, calories, fat, sodium, 0);
    }
    public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium, int carbohydrate) {
        this.servigsSize = servingsSize;
        this.servigs = servings;
        this.calories = calories;
        this.fat = fat;
        this.sodium = sodium;
        this.carbohydrate = carbohydrate;
    }
}

このように順序に依存したパラメータを受けるコンストラクタをテレスコーピングコンストラクタと呼ぶ。この方法はもちろん機能するが、さらに多くのパラメータがある場合にクライアントコードを書くのが困難になり、また、コードの可読性が下がる。

特に同じ型のパラメータが長く続いた場合には分かりにくいバグを引き起こす。例えば、パラメータの値を calories と fat を逆にしてしまっていても、コンパイラでは検知できない。

JavaBeans パターン

テレスコーピングパターンの欠点を克服するために JavaBeans パターンと呼ばれるパターンがある。

//
// JavaBeans パターン
//
public class NutritionFacts {
    private int servingSize = -1; // 必須
    private int servings = -1; // 必須
    private int calories = 0;
    private int fat = 0;
    private int sodium = 0;
    private int carbohydrate = 0;

    public NutritionFacts() { }

    public void setServingSize(int val) { servingSize = val; }
    public void setServings(int val) { servings = val; }
    public void setCalories(int val) { calories = val; }
    public void setFat(int val) { fat = val; }
    public void setSodium(int val) { sodium = val; }
    public void setCarbohydrate(int val) { carbohydrate = val; }
}

クライアント側は以下のようにして使う。

NutritionFacts cola = new NutritionFacts();
cola.setServingSize(240);
cola.setServings(8);
cola.setCalories(100);
cola.setSodium(35);
cola.setCarbohydrate(27);

このパターンはテレスコーピングパターンの欠点を克服しているが、オブジェクト生成が複数の呼び出しに分割されているので、その生成過程で不整合な状態に陥る。

加えて、そのような不整合な状態が存在するためクラスを不変(項目15)にする可能性を排除してしまう。これによってスレッドセーフを保証するため、プログラマに余計な負荷がかかる。

ビルダーパターン

テレスコーピングコンストラクタパターンの安全性と JavaBeans パターンの可読性を組み合わせた 3 番目の方法がビルダーパターン。

まず、対象のオブジェクトを直接生成する代わりに、必須パラメータをすべて保持するビルダーオブジェクトを生成する。次に、このビルダーオブジェクトが持っている build メソッド内で、対象のオブジェクトのコンストラクタにこのビルダーオブジェクトを与えて、クライアントに対象のオブジェクトを返す。

//
// ビルダーパターン
//
public class NutritionFacts {
    private final int servingSize; // (ml) 必須
    private final int servings; // (容器あたり)必須
    private final int calories; // オプション
    private final int fat; // オプション
    private final int sodium; // オプション
    private final int carbohydrate; // オプション

    public static class Builder {
        // 必須
        private final int servingSize;
        private final int servings;

        // オプション
        private int calories = 0;
        private int fat = 0;
        private int carbohydrate = 0;
        private int sodium = 0;

        public Builder(int servingSize, int servings) {
            this.servingSize = servingSize;
            this.servings = servings;
        }

        public Builder setCalories(int val) {
            calories = val;
            return this;
        }
        public Builder setFat(int val) {
            fat = val;
            return this;
        }
        public Builder setSodium(int val) {
            sodium = val;
            return this;
        }
        public Builder setCarbohydrate(int val) {
            carbohydrate = val;
            return this;
        }

        public NutritionFacts build() {
            return new NutritionFacts(this);
        }
    }

    private NutritionFacts(Builder builder) {
        this.servingSize = builder.servingSize;
        this.servings = builder.servings;
        this.calories = builder.calories;
        this.fat = builder.fat;
        this.sodium = builder.sodium;
        this.carbohydrate = builder.carbohydrate;
    }
}

クライアント側のコードは以下の通り。

NutritionFacts cola = new NutritionFacts.Builder(240, 8)
                        .calories(100)
                        .sodium(35)
                        .calories(27)
                        .build();

ビルダーパターンの利点は以下の通り。

  • クライアントのコードを書くのが容易になる。さらに重要なのは読むのが容易になるという点。
  • すべての組み合わせの可変長パラメータを設定できる
  • パラメータを管理下における
    • シリアル番号の管理
    • 不変式の検査

一方、欠点は以下の通り。

  • ビルダーオブジェクトを生成しなければならないのでコストがかかる。
  • テレスコーピングパターンよりもコード量が増える
    • そのため、パラメータが4つ以上になってきたら導入を考える。
    • ただし、あとからパラメータが増えた場合に、新規にビルダーパターンを導入するのは難しいので、パラメータが増える可能性がある場合には最初から導入したほうがよい。

不変式の検査

ビルダーパターンを利用すると、build メソッド内でオブジェクトが満たすべき不変式を検査することができる。ただし、不変式の検査には注意するべき項目がある。

  • ビルダーのフィールドではなくオブジェクトのフィールドに対して検査する(項目39)
  • 不変式が守られていない場合は IllegalStateException をスローする(項目60)
  • 守られていない不変式の詳細を示す(項目63)

抽象ファクトリー

型パラメータが設定されたビルダーは、優れた抽象ファクトリーを作り出す。

型パラメータを利用して、すべてのビルダーに対して1つのパラメータ化された型 Builder を作ることができる。

// 型 T のオブジェクト用ビルダー
public interface Builder<T> {
    public T build();
}

// Builder インスタンスを使用して木を構築する
Tree buildTree(Builder <? extends Node> nodeBuilder) {
...
}

Java で伝統的な抽象ファクトリーの実装として Class オブジェクトの newInstance メソッドがある。この手法は問題があり、newInstance メソッドはパラメータなしコンストラクタを呼び出すが、もしかしたらパラメータなしコンストラクタは存在しないかもしれないし、アクセス不可能かもしれない。

クラスがアクセスできるコンストラクタを持っていなくてもコンパイルは通るため、実行時に InstantiationException と IllegalAccessException が発生する可能性があり、それに対処しなければならない。

すなわち、Class.newInstance はコンパイル時の型検査を破ってしまうが、上記の Builder インタフェースはこれらの欠点をすべて克服してる。実際に Builder インタフェースの builder の実装がアクセスできないコンストラクタを利用していた場合はコンパイルすることができないためである。

感想

ビルダーパターン、現実のコード上でも目にする機会は多いが、コードを理解使用としているときは、「抽象化されてて、わかりづらい…」ってなることが多い。

抽象的にコードを理解する必要も感じつつ、やっぱりシステム全体を把握するには細部、または奥深くの実装まで理解しないといけないんじゃないかなぁ、、、と思って、そういうときに static ファクトリーメソッドとか Builder パターンはけっこう大敵。

あと、this を返してメソッドをチェーンするやつ、途中で違うオブジェクトが返ってたりすると、個人的にはちょっとつらいんだけど、世間的には普通なのかなぁ?

//
// わかりやすい例
// setLeftNode も setRightNode も Tree を返す
// 
public static class Node {
}

pubic class Tree {
    private Node left;
    private Node Right;
    Tree setLeftNode(Node node) { 
        left = node;
        return this;
    }
    Tree setRigthNode(Node node) { 
        right = node;
        return this;
    }
}

Tree tree = new tree();
tree.setLeftNode(new Node()).setRightNode(new Node());

//
// わかりにくい例
//  setLeftNode は設定した Node を返している。
//
public static class Node {
    public void setText(String text) {
        this.text = text;        
    }
}

pubic class Tree {
    private Node left;
    private Node Right;
    public Tree setLeftNode(Node node) { 
        left = node;
        return left;
    }
    public Tree setRigthNode(Node node) { 
        right = node;
        return right;
    }
}

Tree tree = new tree();
tree.setLeftNode(new Node()).setText("left node");
tree.setRightNode(new Node()).setText("right node");