導入
ソフトウェア開発を進める中で、クラスのコンストラクタに渡す引数が増えてくることはよくあります。
特に、複雑なオブジェクトを生成する必要がある場合、その引数の数はどんどん増えてしまい、コードの可読性やメンテナンス性に悪影響を及ぼすことがあります。私自身、最近、新しい機能を実装しようとした際、コンストラクタ引数が多くなりすぎて、困っていました。
ちょうどそのときに本棚に眠っていたEffective Javaを読んでみると、Builderパターンを利用して問題を解決する手法が載っていたので、自分の理解を深めるためにも記事を書いてみました。
Builder以外のパターン
まずは自分が行っていたパターンであり、Effctive Javaにもアンチパターンとして紹介されているパターンについて解説していきます。
JavaBeansを利用するパターン
これまでの開発では、このパターンを採用していました。
採用していたというより、私が経験してきた現場の殆どで、JavaBeansを採用していました。
JavaBeansはわかりやすく、学習コストが低いので、初学者でも実装が容易です。
JavaBeansの例
JavaBeansは以下のように実装されます。
public class User {
private String name;
private String role;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
...
}
JavaBeansでは、利用するクラスから、メンバへの直接アクセスを許容しません。
メンバの値を取得する場合はゲッター、上記例ではgetName
メソッドを利用します。
また、メンバへの値の格納には、セッター、上記例ではsetName
メソッドを利用します。
様々な情報を一つのクラス(インスタンス)の中で保持できるオブジェクト指向な実装が可能になります。
しかし、このJavaBeansは大きな問題を持っています。
それは「イミュータブルでない」という点です。
イミュータブルでないと何が困るのか?
イミュータブルでない実装の問題点は次のとおりです。
-
スレッドセーフ性の欠如
- イミュータブルオブジェクトは、複数のスレッドが同時にアクセスしても安全です。変更可能なミュータブルオブジェクトは、スレッド間で状態がコンフリクトすることや予期しない振る舞いを引き起こす可能性があります。
-
予測不可能な状態変化
- ミュータブルオブジェクトは状態を変更できるため、どこでどのように変更されるかが予測しづらく、バグの原因となりやすいです。イミュータブルオブジェクトは、作成時の状態を保持し、変更されないため、状態の一貫性を保証できます。
- 個人的にはこれが1番の問題に感じています。特にドメイン駆動開発においては、ドメインモデルの整合性を保つためにもイミュータブルである方が良いです。
-
デバッグの難しさ:
- イミュータブルオブジェクトは変更されないため、バグの発生源を追跡するのが容易です。ミュータブルオブジェクトは状態が変わるため、どのタイミングで状態が変更されたのかを追うのが難しくなります。
コンストラクタに引数
上述のJavaBeansの問題点から解放される方法として、コンストラクタ引数を利用することを採用しました。
一方で、この方法が今回私がBuilderパターンを採用しようと思うきっかけになった実装でもあります。
まず、コンストラクタ引数についてです。
実装例
public class User {
private final String name;
private final String role;
private final int age;
public User(String name, String role, int age) {
this.name = name;
this.role = role;
this.age = age;
}
...
}
メンバの宣言は、JavaBeansと同じになります。
コンストラクタで引数を渡すことで、メンバに値をセットしていく方法です。
この方法であれば、セッターを実装する必要がなくなるため、一度インスタンス化したUser
オブジェクトのメンバを書き換えることが発生しなくなり、イミュータブルオブジェクトとして扱うことが可能になります。
テレスコーピング・コンストラクタパターン
では、メンバが増えた時やデフォルト値が設定されることを想定するとどうなるでしょうか。
この例は、テレスコーピング・コンストラクタパターンと呼ばれることがあります。
public class User {
private final String name;
private final String role;
private final int age;
private final int salary;
private final String occupation;
private final String phoneNumber;
private final String emailAddress;
...
public User(String phoneNumber, String emailAddress) {
this("エンジニア", phoneNumber, emailAddress);
}
public User(String occupation, String phoneNumber, String emailAddress) {
this(20, 300, occupation, phoneNumber, emailAddress);
}
public User(int age, int salary, String occupation, String phoneNumber, String emailAddress) {
this("テスト 太郎", "役職なし", age, salary, occupation, phoneNumber, emailAddress);
}
public User(String name, String role, int age, int salary, String occupation, String phoneNumber, String emailAddress) {
this.name = name;
this.role = role;
this.age = age;
...
}
...
}
このパターンでは、いくつかのメンバに対して、デフォルト値を設定しており、メンバ数も例ということで、7つとしましたが、いかがでしょうか。
とても読みにくい実装になっていますね。
このパターンのデメリットは、
- デフォルト値がわかりにくい
- メンバが増えると可読性が下がる
- 実装がそもそも大変
というものが挙げられます。
Builderパターン
JavaBeans、コンストラクタ引数の両方のデメリットを解消してくれるのが、Builderパターンになります。
Builderパターンの使い方は以下のようになります。
- まず、必須パラメータのみを引数に持つコンストラクタを呼び出して、Builderオブジェクトを生成します。
- 1.のBuilderオブジェクトに対して、デフォルト値を変更したいパラメータを設定します。これはJavaBeansのセッターのようなものです。
- 最後にbuildメソッドを呼び出して、イミュータブルなオブジェクトを生成します。
さて、早速実装例を見てみましょう。
public class User {
private final String name;
private final String role;
private final int age;
private final int salary;
private final String occupation;
private final String phoneNumber;
private final String emailAddress;
...
public static class Builder {
// 必須パラメータ
private final String phoneNumber;
private final String emailAddress;
// オプションパラメータ(デフォルト値を設定)
private String name = "テスト 太郎";
private String role = "役職なし";
private int age = 20;
private int salary= 300;
private String occupation = "エンジニア";
// 必須パラメータ用コンストラクタ
public Builder(String phoneNumber, String emailAddress) {
this.phoneNumber = phoneNumber;
this.emailAddress = emailAddress;
}
// name(オプションパラメータ)の設定用メソッド
public Builder name(String name) {
this.name = name;
return this;
}
// role(オプションパラメータ)の設定用メソッド
public Builder role(String role) {
this.role = role;
return this;
}
...
// イミュータブルなUserを生成するbuildメソッド
public User build() {
return new User(this);
}
}
// Userのコンストラクタ
private User(Builder builder) {
this.name = builder.name;
this.role = builder.role;
this.age = builder.age;
...
}
...
}
実装は長くなりましたが、中身としてはシンプルです。
使う際は以下のように呼び出します。
User user = new User.Builder("0111111111", "hoge@hoge.com")
.name("検証 花子")
.role("マネージャー")
.salary(600)
.build();
上記のように、必要なオプションパラメータのみを設定し、build
メソッドを呼び出すことで、イミュータブルなオブジェクトを生成します。これにより、柔軟性と安全性が両立した設計が可能となります。
Builderパターンのメリット
-
可読性の向上
- 必須パラメータとオプションパラメータを分けて設定できるため、コンストラクタが増えることはなく、コードの可読性が向上します。
-
イミュータブル性の確保
- Builderを使用してオブジェクトを構築するため、一度生成したオブジェクトの状態を変更することはできません。これにより、スレッドセーフ性や予測可能な動作が保証されます。
-
拡張性
- 将来的にクラスに新しいプロパティが追加された場合でも、既存のコードに影響を与えることなく、Builderクラスを拡張できます。
終わりに
コンストラクタ引数が増えた際、JavaBeansパターンやテレスコーピング・コンストラクタパターンに代わる手段として、Builderパターンが利用できることを紹介しました。
イミュータブル性の確保と可読性の向上という二つの大きなメリットが得られるため、特に保守性の高いコードを求めるプロジェクトにおいては有効な選択肢となります。
是非、次回のプロジェクトで試してみてください!
※この記事は、Effective Javaの内容を参考にしております。