JavaのOptional: 正しい使い方とアンチパターン

1. 概要

Optional は Java 8 で導入され、null より明確に「存在しない可能性のある値」を表すためのものです。しかし多くの開発者は誤って使用しています:Optional を field や parameter に乱用したり、.get() を呼び出す時に決して空にならないと思い込んだりしています。この記事は、Optional をいつ使うべきか、正しいパターン、そして避けるべき一般的なアンチパターンを理解するのに役立ちます。

この記事を読み終えたら、Optional を返す API を合理的に設計する方法、map/filter/orElseGet を使った明確な空値処理、そして refactor が必要な「偽の Optional」コードを即座に見分けられるようになります。

2. Optional とは何か、いつ使うか

Optional<T> は型 T の値を含む場合も含まない場合もある container です。主な目的は、メソッドシグネチャ内で戻り値が存在しない可能性があることを明確にすることで、呼び出し側に null チェックを忘れさせずに必ず処理させます。

public class CustomerRepository {

    private final Map<Long, Customer> store = new HashMap<>();

    public Optional<Customer> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }
}

findById は Customer(null の可能性がある)の代わりに Optional<Customer> を返します。呼び出し側は signature を見るだけで、見つからない場合を処理する必要があることがわかります。Javadoc を読む必要はありません。

注意: Optional は 戻り値 向けに設計されています。システム内の null の可能性があるあらゆる場所向けではありません。

3. Optional を作成する方法

Optional<String> present = Optional.of("Hello");       // 値が確実に null ではない
Optional<String> empty = Optional.empty();              // 空
Optional<String> nullable = Optional.ofNullable(null);  // null の可能性がある、自動的に empty に変換
  • Optional.of(value) は、value が null の場合、すぐに NullPointerException をスローします。値が確実に null でないことがわかっている場合に使用します。
  • Optional.ofNullable(value) はより安全で、値が制御不可能なソース(クエリ結果、map.get など)からくる場合に適しています。
  • Optional.empty() は空の instance を作成し、前もって値がないことが分かっている場合に使用します。

4. 内部の値を正しく処理する

4.1. map、filter、flatMap

public record Order(Long id, BigDecimal amount, String couponCode) {}

public class DiscountService {

    public Optional<BigDecimal> calculateDiscount(Order order) {
        return Optional.ofNullable(order.couponCode())
                .filter(code -> code.startsWith("SALE"))
                .map(code -> order.amount().multiply(BigDecimal.valueOf(0.1)));
    }
}

filter は条件が真の場合のみ値を保持し、そうでなければ Optional.empty() を返します。map は値が存在する場合はそれを変換し、空の場合は何もしません。このチェーンは null チェックのネストされた if-else を完全に避けられます。

4.2. orElse、orElseGet、orElseThrow

public class PaymentService {

    private final CustomerRepository repository;

    public PaymentService(CustomerRepository repository) {
        this.repository = repository;
    }

    public Customer resolveCustomer(Long id) {
        return repository.findById(id)
                .orElseThrow(() -> new CustomerNotFoundException(id));
    }

    public String resolveCustomerName(Long id) {
        return repository.findById(id)
                .map(Customer::name)
                .orElse("Guest");
    }
}
  • orElseThrow(supplier) は見つからない場合、意味のある exception をスローします。値が必須である場所で使用すべきです。
  • orElse("Guest") は、デフォルト値が 単純な定数 である場合に使用します。Optional に値がある場合でも、常に計算されます。
  • orElseGet(supplier) は、デフォルト値の計算に コストがかかる 場合に使用します。Optional が空の場合にのみ supplier が呼び出されます。
// 間違い:値がある場合でも buildDefaultCustomer() は常に呼び出される
repository.findById(id).orElse(buildDefaultCustomer());

// 正しい:実際に必要な場合のみ呼び出される
repository.findById(id).orElseGet(this::buildDefaultCustomer);

4.3. ifPresent と ifPresentOrElse

public class NotificationService {

    public void notifyCustomer(Optional<Customer> customer) {
        customer.ifPresentOrElse(
                c -> System.out.println("メールを " + c.email() + " に送信"),
                () -> System.out.println("送信する顧客がありません")
        );
    }
}

ifPresentOrElse(Java 9 から)は、値がある場合とない場合の両方の分岐を 1 つの呼び出しで統合します。冗長な if (optional.isPresent()) {...} else {...} に代わるものです。

5. JUnit 5 + AssertJ を使った動作のテスト

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>
class DiscountServiceTest {

    private final DiscountService service = new DiscountService();

    @Test
    void shouldReturnDiscountWhenCouponValid() {
        Order order = new Order(1L, BigDecimal.valueOf(100), "SALE10");

        Optional<BigDecimal> discount = service.calculateDiscount(order);

        assertThat(discount)
                .isPresent()
                .get()
                .isEqualTo(BigDecimal.valueOf(10.0));
    }

    @Test
    void shouldReturnEmptyWhenNoCoupon() {
        Order order = new Order(2L, BigDecimal.valueOf(100), null);

        Optional<BigDecimal> discount = service.calculateDiscount(order);

        assertThat(discount).isEmpty();
    }
}

AssertJ には Optional 専用の assertion があります:isPresent()、isEmpty()、hasValue(...)。.get() を呼び出して比較する代わりにこれらを使用すると、テストコードがより自然に読めます。

6. よくあるアンチパターン

6.1. チェックなしで .get() を呼び出す

// 間違い:Optional が空の場合、NoSuchElementException がスローされ、
// Optional を使う意味が完全になくなります
Customer customer = repository.findById(id).get();

.get() を直接呼び出すと、Optional は単に null をもう一層でラップしたのと変わりません。runtime でクラッシュしてしまう可能性があります。代わりに orElseThrow()(意味のある exception)を使用するか、map/ifPresent で処理してください。

6.2. entity/DTO の field として Optional を使う

// 間違い:Optional は Serializable を実装していないため、JPA entity に現れるべきではありません
@Entity
public class Customer {
    private Optional<String> middleName; // 避ける
}

Optional は Serializable を実装していないため、field や constructor parameter として設計されていません。middleName が null の可能性がある場合、単に String で保ったままにします。Optional は getter から 戻すとき にのみ、必要に応じてラップしてください:

// 正しい:field は String のままで、getter で必要に応じて Optional をラップ
public Optional<String> getMiddleName() {
    return Optional.ofNullable(middleName);
}

6.3. メソッドの parameter として Optional を使う

// 間違い:呼び出し側は呼び出す前に Optional.of/ofNullable で自分でラップするよう強制されます
public void updateAddress(Optional<String> newAddress) { ... }

このアプローチは複雑さを呼び出し側に押し付け、overload や @Nullable と比べて利点がありません。overload を使うか、通常の null チェック付きで String を渡すべきです:

// 正しい
public void updateAddress(String newAddress) {
    this.address = newAddress; // 呼び出し側が null を渡すかどうかを自分で決める
}

6.4. isPresent() をチェックしてから get() を呼び出す(昔の if-null スタイル)

// 間違い:昔の null-check スタイルを書き直しただけで、Optional の何の利点も得ていません
if (repository.findById(id).isPresent()) {
    Customer c = repository.findById(id).get(); // クエリを 2 回呼び出しています!
    System.out.println(c.name());
}

このコードは冗長で、findById が実際のクエリの場合、2 回呼び出してしまいます。代わりに以下を使用してください:

// 正しい
repository.findById(id).ifPresent(c -> System.out.println(c.name()));

6.5. collection に対して Optional を使う

// 間違い:collection には既に「空」という概念(empty list)があり、Optional は不要です
public Optional<List<Order>> findOrdersByCustomer(Long customerId) { ... }

Optional<List<Order>> を返すと、呼び出し側は 2 つのレイヤーの空を処理する必要があります:Optional が空、list が空。これは冗長です。データがない場合は常に空の List を返してください:

// 正しい
public List<Order> findOrdersByCustomer(Long customerId) {
    return orders.getOrDefault(customerId, List.of());
}

6.6. 高パフォーマンスが必要な hot path で Optional を使う

Optional は追加の object wrapper を作成しますが、ほとんどのアプリケーションではそれほど大きなコストではありません。ただし、1 秒間に何百万回も実行されるコード(例:大量データを処理するループ)では、継続的な boxing が GC に影響を与える可能性があります。このような場合、null の直接チェックまたは sentinel value の使用を検討してください。

注意: premature-optimize をしないでください。profile を取って、Optional が実際のボトルネックであることが確認できた場合のみ検討してください。

7. いつ使うべき/使うべきではないかの一覧表

状況 Optional を使うべき?
メソッドの戻り値で、存在しない可能性がある ✅ 使うべき
entity/DTO の field ❌ 使うべきではない
constructor/method の parameter ❌ 使うべきではない
collection(List、Set、Map)を返す ❌ 使うべきではない。空の collection を返す
null チェックの繰り返しを避けるための local variable ⚠️ 場合による。時には if/else の方が明確

8. 結論

Optional は API のコントラクトを明確にし、呼び出し側での NullPointerException を避けるための優れたツールです。ただし、正しい場所、つまりメソッドの戻り値にのみ価値があります。field、parameter、または collection のラッパーに変えてしまうのは避けてください。これらの場所では、Optional はコードをより複雑にするだけで、何も解決しません。新しいコードを書くときは、自問してください:「この場所の Optional は、呼び出し側がより明確に処理するのを助けるのか、それとも単に null を包装しているだけなのか?」その答えが、それを保つべきか削除すべきかを教えてくれます。

javaoptionalベストプラクティスクリーンコード