ビルダー、ウィザー、レコード - Javaの不変性への道

5 読了時間

Jonathan Vila

Jonathan Vila

Developer Advocate - Java

TL;DR 概要

  • Javaは不変性へのアプローチを3つの主要なパターンで進化させてきました: 複雑なオブジェクトを構築するためのビルダーパターン、修正を加えたコピーを作成するためのウィザー(コピー・ウィズ・モディフィケーションメソッド)、そして不変データキャリアを簡潔に宣言するためのJavaレコードです。
  • Javaレコード(Java 14でプレビューとして導入され、Java 16で正式化)は、不変性への最も現代的で簡潔な道を提供します。コンストラクタ、アクセサ、equals、hashCode、toStringを自動生成し、ボイラープレートを排除します。
  • 不変オブジェクトはコードの安全性、スレッドセーフ性、テストのしやすさを向上させます。SonarQubeのルールは、開発者が不変の代替に置き換えるべき可変状態を特定するのを助けます。
  • データ重視のJavaアプリケーションを構築する開発者は、冗長なビルダーパターンを多くの場合に置き換えるために、Javaレコードを価値オブジェクトの優先アプローチとして評価すべきです。

Javaでオブジェクトを作成する際、特に多くのフィールドを含む複雑なオブジェクトに対しては、流暢なアプローチを使用することで、可読性と適応性を向上させ、既存のコードに対する影響を少なくしてコードを進化させることができます。流暢なコードは読みやすく、書きやすいからです。

また、不変オブジェクトは保守が容易で、エラーが少なく、マルチスレッドに優しいこともわかっています。

この記事では、不変オブジェクトの文脈で通常使用される2つの異なるオブジェクト作成アプローチ、ビルダーとウィザー、およびJavaの新しい不変オブジェクトタイプであるレコードについて説明します。

JavaBeanパターン

Javaでクラスを定義する通常の方法は、JavaBeanパターンに従います。これは、引数のないデフォルトコンストラクタと、プロパティのアクセサとミューテータを使用することを含みます。

public class Person {
  private int age;
  private String name;

  public int getAge() {
    return age;
  }

  public String getName() {
  }

  public void setAge(int age) {
    this.age = age;
  }

  public void setName(String name) {
    this.name = name;
  }
}

Person person = new Person();
person.setAge(15);
person.setName("Antonio");

このアプローチは、オブジェクトの状態が「安全でない」可能性があることを意味します。なぜなら、必須かつ重要な値を指定せずにPersonのインスタンスを作成できるからです。オブジェクトのライフタイム中に変更を加えることも可能であり、特にマルチスレッドアプローチではシステムの安全性を低下させる可能性があります。不変性は多くの利点をもたらします。

不変性と安全な状態への道

この問題を解決するための次のステップは、必須かつ重要なプロパティを持つコンストラクタを作成し、それらのミューテータ(セッター)を公開しないことです。

public class Person {
  private int socialNumber;
  private String name;
  private String address;

  public Person(String name, int socialNumber) {
    if (name == null || name.isBlank()) {
      throw new IllegalArgumentException();
    }
    this.name = name;
    this.socialNumber = socialNumber;
  }

  public int getSocialNumber() {
    return socialNumber;
  }

  public String getName() {
  }

  public void setAddress(String address) {
    this.address = address;
  }

  public String getAddress() {
    return address;
  }
}

Person person = new Person("Antonio", 1566778890);
person.setAddress("Barcelona");

しかし、このアプローチでは、クラスがより複雑な定義に成長するにつれて、可読性と適応性に潜在的な問題が生じます。

public Person(String name, int age, String id, String phoneNumber, String email, Person parent1, Person parent2) { ... }

Person person = new Person("Antonio", 15, 1445678, "+34 666 77 88 99", "antonio@example.com", juan, carla);

上記のように、より多くの必須プロパティを追加する場合、コンストラクタにより多くのパラメータを追加する必要があり、これにより既存のコードに影響を与え、コンストラクタへのすべての呼び出しを修正する必要があります。

必須およびオプションの引数を考慮すると、不変オブジェクトの場合、「テレスコーピングコンストラクタ」の問題に直面する可能性があり、異なるヌラビリティの組み合わせを考慮した複数のコンストラクタを作成する必要があります。

public Person(String name, int age, String id, String phoneNumber, String email, Person parent1, Person parent2) {...}

public Person(String name, int age, String id, String phoneNumber, String email, Person parent1,) {...}

public Person(String name, int age, String id, String phoneNumber, String email) {...}

public Person(String name, int age, String id, String phoneNumber) {...}

ビルダーアプローチ

これを解決するために、ビルダーを使用することができます。これにより、可読性が向上し、将来の変更に対応しやすくなります。

まず、すべてのミューテータを削除し、アクセサを残し、コンストラクタで新しいインスタンスを作成することを「不可能」にします。

public class Person {
  private String name;
  private int socialNumber;

  // Invisible constructor 
  private Person() {
  }

  public String getName() {
    return this.name;
  }

  public int getSocialNumber() {
    return this.socialNumber;
  }

  @Override
  public String toString() {
    return "Person [name=" + name + ", socialNumber=" + socialNumber + "]";
  }
}

次に、新しいインスタンスを構築する役割を持つ内部クラスと、ビルダーを呼び出す新しいメソッドを追加します。

  // inside Person class

  // Fluent Builder API
  public static PersonBuilder builder() {
    return new PersonBuilder();
  }

  public static class PersonBuilder {
    private String name;
    private int socialNumber;

    PersonBuilder() {
    }

    public PersonBuilder name(String name) {
      this.name = name;
      return this;
    }

    public PersonBuilder socialNumber(int socialNumber) {
      this.socialNumber = socialNumber;
      return this;
    }

    public Person build() {
      // Validations
      if (name == null || name.isBlank()) {
        throw new IllegalArgumentException();
      }

      // Build
      Person person = new Person();
      person.name = name;
      person.socialNumber = socialNumber;
      return person;
    }
  }

このアプローチにより、検証された状態で新しい不変インスタンスを作成できるようになりました。

Person person = Person.builder()
            			.name("Antonio")
            			.socialNumber(15546464564)
                                .build();

上記のアプローチには多くのボイラープレートコードが含まれており、使用をためらわせることがあります。これを簡単にするために、アノテーションを使用してコードを生成するライブラリを使用できます: Immutables, Lombok, Auto, FreeBuilderなど。

@lombok.Builder
public class Person {
  private String name;
  private int socialNumber;
}

Person person = Person.builder().name("Antonio").socialNumber(2023452).build();

ウィザーアプローチ

流暢なAPIと不変性を持つ別のアプローチは、「ウィザー」またはwith*メソッドを使用することです。これにより、プロパティが変更されるたびに新しいインスタンスが作成されます。

その背後にある考え方は、すべてのミューテータが新しいオブジェクトインスタンスを作成し、それらの呼び出しを連鎖させて完全なインスタンスを生成することです。

// inside Person class

// remove setters

public Person(String name, int age) {
  if (name == null) throw new NullPointerException("name");
  this.name = name;
  this.age = age;
}

public Person withName(String name) {
  if (name == null) throw new NullPointerException("name");

  return (this.name == name) ? this : new Person(name, age);
}

public Person withAge(int age) {
  if (age < 0) throw new IllegalArgumentException("age");

  return (this.age == age) ? this : new Person(name, age);
}

このアプローチを次のように消費することができ、既存のオブジェクトに小さな変更を加えるのが非常に簡単になります。オブジェクトを「クローン」し、一度に1つのプロパティを変更しています。

Person person = new Person("Luis", 45);
Person person2 = person.withName("Jose");
// here we have person2 = Jose, 45

再び、ボイラープレートコードを減らし、エラーを減らすために、既存のライブラリとアノテーションプロセッサを活用してプロセスをスムーズかつクリーンにすることができます。

public class Person {

  @lombok.With @NonNull private final String name;
  @lombok.With private final int age;

  public Person(@NonNull String name, int age) {
    this.name = name;
    this.age = age;
  }
}

ウィザーアプローチの主な欠点は、特にウィザーを連鎖させた場合に、中間オブジェクトを削除するためにガベージコレクタに大きく依存することです。person.withName(“John”).withAge(50)

これらのオブジェクトは最終的に使用されず、ガベージコレクタがそれらを削除するのを待つ必要があります。これは、高いオブジェクト作成率を持つシステムのパフォーマンスに影響を与える可能性があります。

レコード

最後に、Java 16以降、言語自体がレコードと呼ばれる構造定義を提供しています。これは、不変性に焦点を当て、主にデータ値を保存し、ボイラープレートコードを削減し、可読性を向上させることを目的としています。

レコードを使用すると、ミューテータを提供せず、アクセサのみを提供し、フィールドが最終的であるため、オブジェクトが不変であることを確信できます。

したがって、私たちの場合、Personクラスは次のように定義できます。

record Person(String name, int age) {}

...
Person person = new Person("Pedro", 66);