※本記事は、Mohamed Tamanによる”The Java Optional class: 11 more recipes for preventing null pointer exceptions“を翻訳したものです。


アプリケーション開発を効率化しつつ、Optionalクラスのアンチパターンと「設計の臭い」を回避する方法

著者:Mohamed Taman
2020年7月20日

皆さんはnullポインタ例外にうんざりしていませんか。前号のJava Magazineの記事「Optionalクラスを意図されたとおりに使うための12のレシピ」では、Optionalクラスについて説明しました。Optionalクラスは、存在しないかもしれない値を格納するコンテナ型です。前回の記事では、Optionalを使うべきときと、Optionalを使うべきではないときについて確認しました。さらに、いくつかの特殊ケースや誘惑は、わなに相当する場合があることも紹介しました。このわなに引っかかった場合、コードの質が低下することや、さらには予期しない動作の原因となることがあります。12のレシピは、次の3つの分類に対応していました。

  • Optionalを使っているのにnullになるのはなぜですか
  • 値がない場合、何を返せば(または、何を設定すれば)よいですか
  • どうすればOptional値を効率的に使用できますか

本記事では、次の2つの分類に対応する11のレシピについてさらに説明します。

  • どうすればOptionalのアンチパターンを回避できますか
  • Optionalは好きですが、どうすればもっとプロらしくできますか

前回の記事は、Optionalクラスの表面をなぞっただけにすぎません。すなわち、前回説明したのは、null問題を効率よく防ぎ、このクラスのメソッドを最大限に活用して、Optionalを使った場合に起こり得る、いくつかのパフォーマンス面の問題を防ぐベスト・プラクティスです。しかし、一部の開発者にとって、Optionalを正しく使うのは、見た目ほど単純ではありません。

他のプログラミング言語の機能と同じく、Optionalにも正しい使い方と誤った使い方があります。そこで、開発者や筆者自身のコードで気づいたOptionalのいくつかのアンチパターンと「設計の臭い」について、詳しく掘り下げたいと思います。その後、Stream APIや変換などを扱うさらに高度なレシピを紹介し、Optionalクラスに含まれる他のメソッドすべての使い方について解説したいと思います。[編集注:「設計の臭い」という用語は、「基本的な設計原則に違反することが示され、設計の質に悪影響を与える設計上の構造」を指します(Girish Suryanarayana氏、Ganesh Samarthyam氏、Tushar Sharma氏著、『Refactoring for Software Design Smells:Managing Technical Debt』より)。]

まずは、この学習の目的を理解してください。Optionalは、Javaでのnullポインタ例外の数を減らそうとするものです。つまり、値の欠落を示すnull参照を使わずに、「結果がない」ことを示す、メソッドの戻りタイプが可能な、より柔軟なAPIを作成できるようにしています。

筆者の意見では、OptionalクラスがJavaの初期のころから存在したのであれば、ほとんどのライブラリやアプリケーションにおいて、戻り値の欠落はおそらく効果的に処理されているでしょう。それによってnullポインタ例外の可能性が低下し、全体的なバグの件数も減っているでしょう。残念ながら、Javaのはじまりはそうではありませんでした。幸いにも、今のJavaにはOptionalがあります。

ここで本題に入ります。

 

どうすればOptionalのアンチパターンを回避できますか

大ざっぱに言えば、この質問はOptionalクラスの機能の主な目的を正しく理解していないという分類になります。このような不理解は多くのアンチパターンにつながります。それによってコードが冗長になり、本来の効率が失われてしまいます。また、場合によっては、質の悪いAPI設計が公開されることや、メモリの問題を招くこともあります。

レシピ13:Optionalをフィールド型として使わない。筆者がレビューしたコードの45 %で、Optionalがフィールド型として使われていました。次に例を示します。

public class Account {

    enum TYPE {SAVINGS, CREDIT, DEBIT;}

    private String name;
    private Optional<TYPE> type = Optional.of(TYPE.SAVINGS);
    private OptionalDouble balance = OptionalDouble.empty();

    public Account(String name, OptionalDouble balance) {
        this.name = Objects.requireNonNull(name, () -> "Account Name cannot be null.");
        this.balance = balance;
    }

    public Account(String name, OptionalDouble balance, Optional<TYPE> type) {
        this(name, balance);
        this.type = type;
    }

    public void setType(Optional<TYPE> type) {
        this.type = type;
    }

    public Optional<TYPE> getType() {
        return type;
    }    
    ...
}

まず、この種のコードのようにしてnull参照を避けるのは、アンチパターンです。さらに、これによってAccountクラスが使いづらくなっています。Accountオブジェクトのメソッド、コンストラクタ、setter引数を満足するため、コール元にOptionalを使うことを求めているからです。このようなことをした場合、コードが無駄に複雑で冗長なものになるでしょう。

Account acc = new Account("Euro Account", OptionalDouble.of(1453.70), Optional.of(Account.TYPE.CREDIT));

acc.getBalance().ifPresent(System.out::println);
acc.getType().ifPresent(System.out::println);

Optionalを使うたびに、メモリ使用量のフットプリントが増加します。Optionalによって、元の値をラップする、別のオブジェクトの層ができるからです。Optionalクラスはもともと、データタイプとしてではなく、主にメソッドの戻りタイプとして使うことが想定されていました。そのため、できる限りシンプルな状態を保ってください。

public class Account {
    [access_modifier] [static] [final] String name;
    [access_modifier] [static] [final] Type type = TYPE.SAVINGS;
    [access_modifier] [static] [final] double balance = 0.0;
    ...
}

APIメモ:OptionalクラスではSerializableインタフェースを実装していません。そのため、JavaBeanプロパティで使うことはまったく意図されていません。

レシピ14:コンストラクタ引数でOptionalを使わない。Optionalはフィールド型として使うように設計されたものではないため、以下のルールが成立します。

ルール:Optionalは、フィールド型や、コンストラクタ、メソッド、setterの引数として使わない。

Optionalの使用に関する別のアンチパターンは、すべてのコンストラクタ引数に、元の値をラップしたOptionalを受け取ることを強制する傾向が開発者にあることです。開発者は、一部の値が存在しない可能性があり、Optionalであることを示すために、このようなことをします。たとえば、次の例のbalanceがそれに当たります。

public class Account {
    ...
    private String name;
    private OptionalDouble balance = OptionalDouble.empty();

    public Account(String name, OptionalDouble balance) {
        this.name = Objects.requireNonNull(name, () -> "Account Name cannot be null.");
        this.balance = balance;
    }
    
    public OptionalDouble getBalance() {
        return balance;
    }    
    ...
}

ここでの問題は何でしょうか。コードが定型挿入文のように見えることに加え、次のようにしてAccountオブジェクトを作成しようとした場合、失敗します。

Account acc = new Account("Euro Account", null);

acc.getBalance().ifPresent(System.out::println);
acc.getType().ifPresent(System.out::println);
System.out.println(acc.getName());

開発者がOptionalを使って防ごうとしたNullPointerExceptionに注目してください。これを、balanceのgetterメソッドで修正しましょう(コンストラクタが変わっている点を確認してください)。

private Double balance = 0.0;

public Account(String name, Double balance) {
    this.name = Objects.requireNonNull(name, () -> "Account Name cannot be null.");
    this.balance = balance;
}

public Optional<Double> getBalance() {
    return Optional.ofNullable(balance);
}

APIメモ:すべてのgetterメソッドがOptionalを返すべきだという主張を経験則として受け取らないでください。これでは、Optionalクラスが過剰使用になります。このテクニックは、必要な場合にのみ使用してください。

レシピ15:メソッドの引数でOptionalを使わない。メソッドの引数としてOptionalを使うことに関しては、大きな議論があります。筆者は、メソッドの引数としてOptionalを使うべきだという意見には納得しておらず、これは「設計の臭い」の発生源になる一般的なアンチパターンだと考えています。ところで、コード・インスペクタの中には、SonarQubeなどのように、パラメータにOptionalを使うことを正式に「設計の臭い」と見なしているものもあります。

レシピ14のルールには挙げましたが、メソッドの引数としてOptionalを使うことが適切かどうかを確認してみます。

ある開発者が、名前をマッチングして従業員のリストを検索するメソッドを書いたとします。このメソッドは、部署でフィルタリングすることもできます。開発者はIDEを開き、深く考えず、次のように打ち込み始めます。

public class Employee {

    int id;
    String name;
    String department;
    ...
}

public class EmployeeService {
    
    public List<Employee> searchEmployee(
            List<Employee> 
            employees, String name, 
            Optional<String> department){
        
        // employeesとnameのnullチェック
        return employees.stream()
                .filter(employee -> employee.getName().matches(name))
                .filter(employee -> employee.getDepartment().matches(department.orElse(".*")))
                .collect(toList());
    }
}

このコードは、部署の存在を扱うエレガントなソリューションとして有望に見えます。そのため、開発者はこのコードをコード・リポジトリにリリースします。

その後、別の開発者がこのコードをプルして使い始め、「部署にかかわらず、すべての従業員を検索する必要がある」と考えます。

service.searchEmployee(employees, ".*", null)
                .stream()
                .forEach(System.out::println);

すると、コードからNullPointerExceptionがスローされます。これは明らかに、最初のメソッド設計で想定されている、部署パラメータの扱いではありません。さらに、この例外によって、このメソッドを正しく使うことが難しくなります。メソッドがnullチェックを怠っているため、次のようにメソッドのコール元がOptionalに依存せざるを得なくなるからです。

EmployeeService service = new EmployeeService();
        
var employees = List.of(
    new Employee(1, "Mohamed Taman", "Business Development"),
    new Employee(1, "Malik Taman", "HR"));

service.searchEmployee(employees, "Mo.*", Optional.empty())
        .stream()
        .forEach(System.out::println);

service.searchEmployee(employees, ".*", Optional.<String>of("HR.*"))
        .stream()
        .forEach(System.out::println);

この設計上の問題を修正してわかりやすくするために、オーバーロードしたメソッドを2つ追加します。この2つのメソッドは同じ実装を共有していますが、異なる2つのことを行います。

private List<Employee> searchEmployee0(
            List<Employee> employees, String name,
            String department) {

        Objects.requireNonNull(employees, "Employees can't be null.");
        Objects.requireNonNull(name, "Name can't be null.");
        final var departmentFilter = Objects.requireNonNullElse(department, ".*");

        return employees.stream()
                .filter(employee -> employee.getName().matches(name))
                .filter(employee -> employee.getDepartment().matches(departmentFilter))
                .collect(toList());
}

public List<Employee> searchEmployee(
        List<Employee> employees, String name) {
    return searchEmployee0(employees, name, ".*");
}

public List<Employee> searchEmployee(
        List<Employee> employees, String name,
        String department) {
    return searchEmployee0(employees, name, department);
}

これで安全にメソッドを使えるようになり、実行時に驚くようなことは起こらなくなります。

service.searchEmployee(employees, "Mo.*")
        .stream()
        .forEach(System.out::println);

service.searchEmployee(employees, ".*", "HR.*")
        .stream()
        .forEach(System.out::println);

service.searchEmployee(employees, ".*", null)
        .stream()
        .forEach(System.out::println);

参考:このトピックに関して、Grzegorz Ziemoński氏がDZoneで「Optionalメソッドのパラメータ」(英語)というすばらしい記事を書いています。

レシピ16:Optionalはsetterの引数として使わない。コードによっては、Java Persistence API(JPA)エンティティのプロパティが存在しないことを示すためにOptionalが使われることがあります。筆者はこの設計を好みません。setterメソッドに、元の値をラップするOptionalを引数として受け取ることを強制するからです。このアプローチでは、コードが複雑になり、setterメソッドのコール元に余計な依存性が追加されるからです。

@Entity
public class Employee implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    int id;
    ...
    @Column(name = "employee_address")
    private Optional<String> address; // 省略可能なフィールドであるため、nullになる可能性がある

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

    public Optional<String> getAddress() {
        return address;
    }
    ...
}

そのため、これはアンチパターンであり、「設計の臭い」であると筆者は考えます。Optionalはシリアライズできないことも覚えておいてください。この問題を回避するためには、次のようにします。

@Column(name = "employee_address")
private String address;  // 省略可能なフィールドであるため、nullになる可能性がある

public Employee() {
}

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

public Optional<String> getAddress() {
    return Optional.ofNullable(address);
}

レシピ17:Optionalを使って配列やコレクションが空であることをアサートしない。確かに先ほど、Optionalは主にメソッドの戻りタイプとして使うべきだと言いました。しかし、すべての戻り値をOptionalインスタンスでラップし、空やnull値を返すことを避けようとしているコードを見たことがあります。そのような実装は一般的なアンチパターンであり、「設計の臭い」と見なされます。次の例について考えてみます。

public class EmployeeService {

    public Optional<List<Employee>> getEmployeeByDepartment(String department) {
        // nullまたは空のリストを返す可能性がある
        List<Employee> employees = searchEmployee(".*", department);
        // そのため、Optionalでラップする
        return Optional.ofNullable(employees);
    }
}

このコードによって、余分な層が1つ増えることになります。この場合は次のようにします。CollectionsクラスのemptySet()、emptyList()、emptyMap()の各メソッドを使い、単純に空のコレクションや配列を返します。

public List<Employee> getEmployeeByDepartment(String department) {
    var employees = searchEmployee(".*", department);
    return employees != null? employees : Collections.emptyList();
}

APIメモ:CollectionsクラスのemptySet()、emptyList()、emptyMap()の各メソッドは、Java 1.5以降に存在します。

 

Optionalは好きですが、どうすればもっとプロらしくできますか

ここまでで紹介した一連のレシピは、Optionalを使った場合に起こり得るさまざまなアンチパターンや「設計の臭い」の問題の多くに関するものでした。次は、Stream APIでOptionalを使うさまざまな方法や、Optionalのその他の重要な側面について確認します。

レシピ18:OptionalのofNullable()とof()の機能を混同しない。ofNullable()メソッドとof()メソッドは、いずれもOptionalを作成する静的メソッドですが、さまざまな方法で値をラップしたOptionalインスタンスを作成します。もちろん、値がないことを示す空のOptionalインスタンスは、empty()メソッドで作成します。しかし、中にはofNullable()とof()を混同している開発者もおり、それが問題につながることもあります。

null以外の値でOptionalを作成する場合は、of()メソッドを使います。このメソッドにnullを渡した場合、即座にnullポインタ例外がスローされるからです。その場合、何も作成されません。

Employee emp = new Employee(1, "Mohamed Taman", "Business Development");       
Optional<Employee> employee = Optional.of(emp);

nullであるかもしれない値でOptionalを作成する場合は、ofNullable()メソッドを使います。ここで値がnullの場合は、空のOptionalが返されます。それ以外の場合は、渡された元の値をラップしたOptionalが返されます。

Employee emp = service.getEmployee(int id);  // empはnullである可能性がある
Optional<Employee> employee = Optional.ofNullable(emp);

レシピ19:数値には、汎用のOptionalを使うよりも数値のOptionalを使う方がよい。Optionalにプリミティブ値をボクシングして格納しなければならないこともあります。たとえば、次のような場合です。

Optional<Integer> price = Optional.of(156);
Optional<Long> peopleCount = Optional.of(156_978_934_24L);
Optional<Double> finalPrice = Optional.of(230.17d);

オートボクシングは、パフォーマンスに影響する場合もあることに注意してください。つまり、int、long、doubleなどのプリミティブ値のラッパーであり、 getAsInt()、getAsLong()、getAsDouble()で必要に応じてアンラップできる数値版のOptionalを使う方がよいということです。

OptionalInt price = OptionalInt.of(156);
OptionalLong peopleCount = OptionalLong.of(156_978_934_24L);
OptionalDouble finalPrice = OptionalDouble.of(230.17d);

APIメモ:OptionalInt、OptionalLong、OptionalDoubleの各クラスは、Java 8以降に存在します。

レシピ20:ラップした値をfilter()メソッドでフィルタリングする。Optionalクラスには、Stream APIの同名のメソッドと同じようなfilter()メソッドがあります。超過引き出しが可能なために負の値もとることができる、銀行の引き落とし口座について考えてみます。

// accountIdについて、nullチェックと空チェックを行い、口座番号体系を確認する
Optional<Account> accountFiltered = getAccount(accountId);

if (accountFiltered.isPresent()) {
    Account account = accountFiltered.get();
    if (!account.isOverdraftAllowed()) {
        throw new IllegalStateException("Overdraft is not allowed for this account.");
    }
    
    account.addToBalance(value);
    updateAccount(account);
}

ラップされている値が見つかった場合に、事前定義されたルールでチェックを行うために、filter()メソッドを使います。値が存在し、条件に一致した場合、filter()はその値を表すOptionalを返します。そうでない場合は、空のOptionalを返します。これを使って、先ほどの実装をよりエレガントでわかりやすくなるように書き換えます。

getAccount(accountId)
        .filter(account -> account.isOverdraftAllowed())
        .ifPresentOrElse(account -> {
            account.addToBalance(amount);
            updateAccount(account);
        }, () -> {
            throw new IllegalStateException("Overdraft is not allowed for this account.");
        });

APIメモ:filter()メソッドは、Java 8以降に存在します。

レシピ21:値の抽出や変換を行うmap()とflatMap()を使うべきタイミングを知る。アプリケーションでは、ラップされているOptional値を変換しなければならない場合もあります。その場合、ラップされている値に対して処理を行い、新しい結果を返す何らかの変換マッピング関数を適用します。これを行うために使うのが、map()メソッドとflatMap()メソッドです。この2つのメソッドは、いずれも変換を行うものですが、その仕組みは異なります。

String getAddress()メソッドがあるEmployeeクラスについて考えてみます。このアプリケーションでは、IDを使って従業員を検索します。従業員が存在する場合は、その従業員の住所を取得します(住所がnullでない場合)。住所が空でない場合は、トリミングして大文字に変換し、その結果を返します。コードは次のようになります。

var finalAddress = "No address found.";

Optional<Employee> optEmployee = employeeRepository.getEmployeeBy("1284");

if (optEmployee.isPresent()) {
    Employee employee = optEmployee.get();
    if (employee.getAddress() != null && !employee.getAddress().isBlank()) {
        finalAddress = employee.getAddress().trim().toUpperCase();
    }
}
System.out.println(finalAddress);

または、getEmployeeBy(String id)メソッドによる検索で返されたOptional<Employee>とmap()関数を併せて使うことにより、もう少し読みやすくわかりやすいコードで同じ機能が実現します。

1 var finalAddress = employeeRepository.getEmployeeBy(1284)
2        .map(Employee::getAddress)
3        .filter(Predicate.not(String::isBlank))
4        .map(String::trim)
5        .map(String::toUpperCase)
6        .orElse("No Address found.");

map関数(2行目)では、2つのことを行っています。従業員IDが存在して住所がnullでない場合にのみ、ラップされたString値がOptional<String>に変換されます。それ以外の場合は、6行目のコードが適用され、No Address foundが結果として返されます。

ただし、従業員と住所が存在する場合は、filterメソッド(3行目)を使って、住所が空でないかどうかがチェックされます。その後、map関数(4行目)で住所がトリミングされ、それをOptionalでラップしたものが結果となります。そして最後のmap関数が適用されて、住所がOptionalでラップした大文字に変換されます。大変きちょうめんな処理です。

次に、Employeeクラスについて、getAddress()メソッドはあるものの、住所をStringとして返すのではなく、Optional<String>を返す場合について再度考えてみます。この場合、最初からnullを避けようとすることが必要です。そして、先ほどと同じ要件を適用してみます。同じコードを使った場合、問題が起きます。2行目のmap関数がネストされたOptional<Optional<String>>を返すからです。これはとても臭います。この問題は、2行目のmap()をflatMap()にするだけで修正されます。そしてすべては魔法のようにうまくいくはずです。

1 var finalAddress = employeeRepository.getEmployeeBy(1284)
2        .flatMap(Employee::getAddress)
3 ...

一般的な経験則:マッピング関数がOptionalを返す場合、map()メソッドではなくflatMap()メソッドを使います。これにより、Optionalからフラットな結果を取得します。

APIメモ:Optionalクラスのmap()メソッドとflatMap()メソッドは、Java 8以降に存在します。Predicate.not()メソッドとString.isBlank()メソッドは、Java 11以降に存在します。

レシピ22:コードでOptionalとStream APIをチェーンできるかアプリケーションで次のようなことをしなければならない場合があります。

public Optional<Employee> getEmployeeBy(String id) {
    return Optional.ofNullable(...);
}

public List<Employee> getEmployeesBy(List<String> ids) {
    return ids.stream()
            .map(this::getEmployeeBy)
            .filter(Optional::isPresent)
            .map(Optional::get)
            .collect(Collectors.toList());
}

与えられた従業員IDのリストに対応する従業員のリストを取得し、在籍している従業員のみを返しています。続いてmap()を使い、Stream<Employee>を生成して、最終的なリストにまとめています。この処理では、Stream APIを使って多くのやり取りが行われています。Optional.stream()を使ってOptional APIをStream APIに接続することで、このやり取りが効率化されます。これにより、filter()とmap()をflatMap()メソッドに置き換えることができます。

public List<Employee> getEmployeesBy1(List<String> ids) {
    return ids.stream()
            .map(this::getEmployeeBy)
            .flatMap(Optional::stream)
            .collect(Collectors.toList());
}

APIメモ:Optional.stream()メソッドは、Java 9以降に存在します。

レシピ23:Optionalクラスについての結論。次のように、==を使って2つのOptionalエンティティの等価性をテストしたいことや、複数のOptionalオブジェクトを同期させたいことや、識別ハッシュ・コードのチェックを実行したいことがあるかもしれません。

Optional<String> first = Optional.of("Java is turning 25");
Optional<String> second = Optional.of("Java is turning 25");

System.out.println(first == second); // falseが返される
// または
synchronized(first){
    ...
}

これを行ってはいけません。同一性の影響を受ける何らかの操作をOptionalに対して実行することは避ける必要があります。その理由は、Optionalが値ベースのクラスであるからです。同一性の影響を受けるOptionalインスタンスを実行することは、予測できない結果を引き起こす可能性があるため、避けるべきです。

また、等価性をチェックするために、次のようにしてOptional値をアンラップする必要はありません。

if (first.get().equals(second.get())) … ; // trueを返す

次のようにします。

if (first.equals(second)); /// trueを返す

このコードがうまく動作するのは、Optional.equals()において、Optionalオブジェクトではなく、ラップされた値が比較されるからです。その理由は、Optionalが値ベースのクラスだからです。

 

まとめ

Optionalを正しく使うのは、見た目ほど単純ではありません。

本記事では、Optionalクラスの特に一般的なアンチパターンと「設計の臭い」のいくつかを詳しく取り上げ、それらを避ける方法について説明しました。このような問題を避けるために役立ついくつかの例を、代替ソリューションとともに提示しました。さらに、Optional.of()とOptional.ofNullable()を混同してしまうというありがちな間違いも取り上げました。このようなエラーは、問題につながることがあるからです。

最後に、高度なレシピとして、Stream APIとOptional APIのチェーン、Optionalでラップされた値の変換、map()とflatMap()を使うべき条件も説明しました。そして、値ベースのOptionalクラスに対し、同一性の影響を受けるすべての操作を避けることに関する結論も共有しました。

詳しく学びたい方は、Java SE 15のOptionalに関するドキュメントや、Raoul-Gabriel Urma氏、Mario Fusco氏、Alan Mycroft氏による書籍『Java 8 in Action』(Manning、2014年)、プレゼンテーション「あなたはOptionalクラスを適切に使っているか」(英語)をご覧ください。
 


Mohamed Taman

Mohamed Taman(@_tamanm):SiriusXI InnovationsのCEOであり、Effortel Telecommunicationsの最高ソリューション・アーキテクト。セルビアのベオグラードを拠点とするJava Champion、Oracle Groundbreaker、JCPメンバーであり、Jakarta EEのAdopt-a-SpecプログラムとOpenJDKのAdopt-a-JSRのメンバーを務める。