Java Optionalの使い方|戻り値・map・orElseGetと避けたい使い方

みかん猫が値ありと値なしの容器を示しJava Optionalを説明するアイキャッチ

利用者をIDで検索するとき、条件に合う利用者が必ず見つかるとは限りません。この「値があるかもしれないし、ないかもしれない」という戻り値の契約を表せるのが、JavaのOptionalです。

Optionalを使うと、不在を呼び出し側へ伝えたうえで、表示名への変換、既定値の選択、例外への変換などを組み立てられます。ただし、すべてのnullをOptionalへ置き換える必要はありません。この記事ではJava SE 21のAPIを基準に、利用者検索の例を通して基本操作と採用判断を整理します。

目次

Java Optionalとは|値がない可能性を戻り値で表す

検索結果が見つからないケースをOptionalで表す

Optional<T>は、nullではないT型の値を一つ保持するか、値を保持しない状態を表すコンテナです。たとえばOptional<User>を返す検索メソッドなら、戻り値の型から「利用者が見つからない場合がある」と読み取れます。

次の例では、Mapに登録された利用者を検索します。IDが正でない場合は入力不正として拒否し、正のIDで利用者が見つからなければ空のOptionalを返します。

import java.util.Map;
import java.util.Objects;
import java.util.Optional;

public class OptionalExample {
    record User(long id, String displayName, boolean active) {
        User {
            Objects.requireNonNull(displayName, "displayName");
        }
    }

    private static final Map<Long, User> USERS = Map.of(
            1001L, new User(1001L, "みかん", true),
            1002L, new User(1002L, "ゆず", false)
    );

    static Optional<User> findUser(long id) {
        if (id <= 0) {
            throw new IllegalArgumentException("id must be positive");
        }
        return Optional.ofNullable(USERS.get(id));
    }

    public static void main(String[] args) {
        String name = findUser(1001L)
                .map(User::displayName)
                .orElse("利用者なし");

        System.out.println(name);
    }
}

このコードはOptionalExample.javaへ置く構成です。以降の例で使うUserとfindUser()も、このクラス内の宣言を指します。変数宣言やメソッド呼び出しだけの例はmain内へ、staticメソッドの例はクラス内へ置いて読む形です。

Optionalは主に、結果が存在しない可能性を明示するメソッドの戻り値として使います。呼び出し側は、不在なら既定値を使うのか、何もしないのか、例外にするのかを用途に応じて決められます。

値の不在と入力不正・処理失敗を区別する

検索対象が存在しないこと、IDの形式や範囲が不正なこと、検索先へ接続できないことは、それぞれ異なる状態です。すべてをOptional.empty()にすると、呼び出し側は見つからなかった理由を区別できなくなります。

上の例の契約は「正のIDを検索し、登録がなければempty」です。入力不正は例外で伝えています。DBなどへ検索を広げる場合も、接続失敗を単なる検索結果の不在へ置き換えず、失敗として扱う経路を用意します。

また、Optionalは入力値の妥当性を自動で検証する仕組みではありません。Web入力の必須条件や文字数などを確認する実装については、次の記事で整理しています。

of・ofNullable・emptyでOptionalを生成する

ofとofNullableをnullの可能性で使い分ける

Optional.of(value)は、nullではない値からOptionalを作ります。nullを渡すとNullPointerExceptionになります。一方、Optional.ofNullable(value)は、値がnullなら空のOptional、nullでなければ値を持つOptionalを返します。

生成方法渡す値結果
Optional.of(value)nullではない値値を持つOptional。nullを渡すと例外
Optional.ofNullable(value)nullの可能性がある値nullなら空、それ以外なら値を持つOptional
Optional.empty()引数なし空のOptional
Optional<String> present = Optional.of("みかん");

String nullableName = null;
Optional<String> absent = Optional.ofNullable(nullableName);

Optional<String> empty = Optional.empty();

たとえば、存在しないキーに対してnullを返すMapの検索結果は、ofNullableでOptionalへ変換できます。ただし、本来nullを許さない値にまでofNullableを使うと、契約違反を不在として受け流すことになります。nullを不在として受け入れる境界なのかを考えて選びます。

値がない戻り値はemptyで返し、Optional自体をnullにしない

Optionalを返すメソッドでは、不在をOptional.empty()で表します。Optional自体をnullで返すと、呼び出し側はOptionalの有無と、その中の値の有無を二重に確認しなければなりません。

static Optional<User> findActiveUser(long id) {
    Optional<User> user = findUser(id);
    if (user.isEmpty()) {
        return Optional.empty();
    }
    return user.filter(User::active);
}

この例は空を返す形を示したものです。実際にはfilterが空のOptionalもそのまま扱えるため、return findUser(id).filter(User::active);とまとめられます。どちらでも戻り値のOptional自体はnullにしません。

map・flatMap・filterで値を変換・絞り込みする

mapで検索結果から表示名へ変換する

mapは、値がある場合に変換関数を適用し、その結果をOptionalで返します。元が空なら変換関数は呼ばれず、結果も空になります。

Optional<User> user = findUser(1001L);
Optional<String> displayName = user.map(User::displayName);
String label = displayName.orElse("利用者なし");

この変換では、User::displayNameがUserからStringを返すため、型はOptional<User>からOptional<String>へ変わります。値を一度getで取り出してから加工する必要はありません。

flatMapでOptionalを返す処理をつなぐ

変換先の処理がすでにOptionalを返す場合は、flatMapでつなぎます。たとえば、利用者が存在しても表示名が空白だけなら、表示に使える名前はないと判断するメソッドを考えます。

static Optional<String> usableDisplayName(User user) {
    String name = user.displayName().strip();
    return name.isEmpty() ? Optional.empty() : Optional.of(name);
}

このメソッドをmapへ渡すと、戻り値のOptionalがさらにOptionalで包まれます。flatMapなら、その入れ子を作らずにつなげられます。

Optional<Optional<String>> nested = findUser(1001L)
        .map(OptionalExample::usableDisplayName);

Optional<String> name = findUser(1001L)
        .flatMap(OptionalExample::usableDisplayName);

使い分けは、渡す関数の戻り値を見ると整理できます。Stringなどの値を返す関数ならmap、Optional<String>などを返す関数ならflatMapが基本です。

mapの変換結果がnullの場合とflatMapの契約を区別する

mapの変換関数がnullを返すと、結果は空のOptionalになります。変換結果をofNullableで包むような扱いです。一方、flatMapへ渡す関数は、nullではないOptionalを返す必要があります。値がある状態でその関数がnullを返すと、NullPointerExceptionになります。

Optional<String> source = Optional.of("みかん");

// mapの変換結果がnullなら、結果は空になる
Optional<String> mapped = source.map(value -> (String) null);

// flatMapで不在を返すときは、nullではなくemptyを使う
Optional<String> flattened = source.flatMap(
        value -> Optional.<String>empty());

ここで区別しているのは、関数が返す値です。mapもflatMapも、変換関数そのものにnullを渡してよいわけではありません。また、変換関数の実行中に発生した例外を、自動でemptyへ変換する機能もありません。

filterで条件に合う値だけを残す

filterは、値があり、条件がtrueならその値を残します。条件がfalseなら空になり、元から空の場合は条件の判定を行いません。

String label = findUser(1002L)
        .filter(User::active)
        .map(User::displayName)
        .orElse("表示対象なし");

この処理では、利用者が見つからない場合も、見つかった利用者が非アクティブの場合も「表示対象なし」になります。両者を同じ結果へまとめてよい用途に向いています。理由ごとに応答を変える必要があるなら、filterで情報を落とす前に分岐するなど、区別できる構造にします。

orElseとorElseGetの違い|代替値の評価タイミングで選ぶ

orElseは代替値を渡し、orElseGetは必要なときに生成する

orElseとorElseGetは、値があればその値を返し、空なら代替値を返すメソッドです。違いは、代替値をどのタイミングで求めるかにあります。

orElse(createDefaultName())では、Javaのメソッド引数の評価としてcreateDefaultNameが先に呼ばれます。Optionalに値があっても、この呼び出しは行われます。orElseGet(OptionalExample::createDefaultName)では生成処理を渡すため、Optionalが空の場合だけ、その処理が呼ばれます。

static String createDefaultName() {
    System.out.println("既定名を生成");
    return "利用者なし";
}
Optional<String> name = Optional.of("みかん");

// 値があってもcreateDefaultName()が呼ばれる
String first = name.orElse(createDefaultName());

// 値があるのでcreateDefaultName()は呼ばれない
String second = name.orElseGet(OptionalExample::createDefaultName);

どちらの変数にも保持されるのは「みかん」ですが、既定値を生成する処理の実行有無が異なります。orElseに渡した生成処理が例外を投げれば、Optionalに値があっても、その例外によって処理が中断します。

固定の既定値と生成処理・外部アクセスを使い分ける

代替値の用意方法選択例判断する点
文字列などの固定値orElse("利用者なし")渡す値がすでに決まっている
必要な場合だけ行う生成処理orElseGet(() -> createDefaultName())空の場合だけ生成したい
外部アクセスを伴う代替値の取得orElseGet(() -> ...)実行条件に加え、失敗や副作用の扱いも決める

固定の既定値ならorElseで簡潔に書けます。代替値を求めるために別の検索、ファイル読み込み、オブジェクト生成などが必要なら、空の場合だけ実行すべきかを確認してorElseGetを選びます。単純に「どちらが速いか」で決めるのではなく、不要な処理や副作用を実行してよいかが判断の中心です。

orElseGetが遅らせるのは、渡したSupplierの処理です。あらかじめString fallback = createDefaultName();と生成してからorElseGet(() -> fallback)へ渡しても、その生成処理はすでに実行されています。

値がない場合の例外化と、値がある場合の処理

orElseThrowで呼び出し側の要件に合う例外へ変える

検索メソッドでは不在を許していても、その結果を使う処理では利用者の存在を必須にしたい場合があります。その境界でorElseThrowを使うと、空のOptionalを例外へ変換できます。

long userId = 9999L;

User requiredUser = findUser(userId)
        .orElseThrow(() -> new IllegalStateException(
                "処理に必要な利用者が存在しません: " + userId));

例外を生成する関数は、Optionalが空の場合だけ呼ばれます。この例では処理の前提が満たされないものとしてIllegalStateExceptionを選んでいますが、実際の例外型は呼び出し側の契約に合わせます。利用者に見せる応答やHTTPステータスは、さらに外側の層で決める内容です。

引数なしのorElseThrow()もあり、空ならNoSuchElementExceptionを投げます。不在の意味を呼び出し側へ伝えたい場面では、例外型とメッセージを指定する形が役立ちます。

ifPresent・ifPresentOrElseで処理を分岐する

値を変換して返すのではなく、値があるときだけ処理を行うならifPresentを使えます。不在の場合の処理も指定するならifPresentOrElseを使います。

findUser(1001L).ifPresent(user ->
        System.out.println(user.displayName()));

findUser(9999L).ifPresentOrElse(
        user -> System.out.println(user.displayName()),
        () -> System.out.println("利用者なし")
);

どちらも戻り値はvoidです。表示名をStringとして後続へ渡したいなら、外側の変数を書き換えようとするより、mapとorElseなどで値として組み立てるほうが流れを追いやすくなります。

isPresentとgetの繰り返しを見直す

isPresent()で確認してからget()で取り出す書き方も可能です。ただし、単純な変換や既定値の選択まで毎回この形にすると、Optionalの操作で表せる処理を手作業で繰り返すことになります。

Optional<User> user = findUser(1001L);

String label;
if (user.isPresent()) {
    label = user.get().displayName();
} else {
    label = "利用者なし";
}

// 同じ用途を変換と既定値の選択で表す
String anotherLabel = user
        .map(User::displayName)
        .orElse("利用者なし");

複数の処理を行う分岐では、通常のif文のほうが読みやすいこともあります。if文をなくすことを目的にせず、変換はmap、処理の実行はifPresent、必須値の取得はorElseThrowというように、目的に対応する操作を選びます。

OptionalをStreamへ変換して複数の検索結果を扱う

streamで値ありを1要素、値なしを0要素として扱う

Optional.stream()は、値があればその値だけを含むStream、空なら要素のないStreamを返します。複数のIDを検索し、見つかった利用者の表示名だけを集めたい場合に組み合わせられます。

次の例を最初のクラスへ追加する場合は、java.util.Listとjava.util.stream.Streamをimportします。

List<Long> ids = List.of(1001L, 9999L, 1002L);

Stream<Optional<User>> results = ids.stream()
        .map(OptionalExample::findUser);

List<String> names = results
        .flatMap(Optional::stream)
        .map(User::displayName)
        .toList();

IDを検索した直後はStream<Optional<User>>です。各Optionalを0要素または1要素のStreamへ変換して平坦化すると、Stream<User>になります。その後に表示名へ変換してListへまとめています。

StreamのflatMapとOptionalのflatMapを区別する

同じflatMapという名前でも、受け取る関数と結果の型が異なります。Optional.flatMapは、値からOptionalを返す関数を受け取り、結果をOptionalとして返します。Stream.flatMapは、各要素からStreamを返す関数を受け取り、その要素を一つのStreamへつなぎます。

先ほどのflatMap(Optional::stream)はStream側の操作です。関数に渡るのは一つのOptional<User>で、そこからStream<User>を返しています。利用者からOptionalの表示名へつなぐflatMap(OptionalExample::usableDisplayName)とは、扱う型の流れを分けて読みます。

なお、この例はIDごとにfindUserを呼ぶ構造です。検索先がDBや外部サービスの場合、Streamへ書き換えても一括検索になるわけではありません。検索回数や一括取得の必要性は、検索APIの設計として別途検討します。

フィールド・引数・永続化DTOでOptionalを使うか判断する

戻り値への利用を基本に、フィールドと引数は必要性を検討する

Optionalは、主に値がない可能性をメソッドの戻り値で伝えるための型です。フィールドや引数への利用がJava言語として禁止されているわけではありませんが、採用する理由と、呼び出し側や連携先への影響を確認します。

引数をOptional<String>にすると、呼び出し側にはOptionalを作って渡す作業が増えます。また、その引数自体へnullを渡せないようにするには、別途契約や検証が必要です。必須値だけのメソッドと任意指定を受けるメソッドを分ける、オーバーロードを用意するなど、不在の意味が伝わるAPIを検討します。

フィールドへ採用する場合も、保持するOptional自体をnullにしない条件を守る必要があります。すでにOptionalを中心に組み立てる内部モデルなど、意図が明確な用途では選択肢になりますが、「任意項目だから」という理由だけで一律に採用しないようにします。

DTO・永続化エンティティ・検索結果の戻り値を分けて考える

DTOは処理の境界でデータを受け渡す役割、永続化エンティティは保存対象を表す役割を持ちます。一方、Optional<User>という検索メソッドの戻り値は、検索結果そのものが存在しない可能性を表しています。

そのため、検索メソッドがOptionalを返すことから、UserやDTOの全任意項目もOptionalにすべきだとは決まりません。「利用者が存在しない」と「存在する利用者の任意項目に値がない」は、異なる場所で表す情報です。

DTOを通常クラスとrecordのどちらで表すかも、Optionalの採用とは別の判断です。DTOの役割やrecordを使う際の設計判断は、次の記事を参考にしてください。

JSON変換・永続化・シリアライズの対応要件を確認する

Optionalをフィールドやrecordのcomponentに使うときは、採用するライブラリのバージョンと設定に合わせて、読み取り・書き込みの両方向を確認します。Javaで型を宣言できることだけでは、JSONやDBとの受け渡し方法までは決まりません。

  • JSON変換では、値ありとemptyがどの形式になるか、項目の欠落と明示的なnullをどう読み込むかを確認する。
  • 永続化では、エンティティの属性として扱う場合と、検索結果をDTOへマッピングする場合を分け、対象の型や変換処理の要件を確認する。
  • Java標準のオブジェクトシリアライズでは、Optional自体がSerializableを実装していないことを考慮する。

たとえば更新用の入力では、「項目を送っていないので変更しない」と「明示的に値を消したい」を分ける必要があります。単純なOptionalの値あり・値なしだけでは、この二つと新しい値の指定をそのまま区別できません。更新操作の契約を先に決め、それを保持できる入力モデルを選びます。

OptionalがSerializableではないことは、JSONへ一切変換できないという意味ではありません。Java標準のシリアライズと、ライブラリによるJSON変換は別の仕組みです。製品ごとの対応は、その環境の公式資料と設定に沿って判断します。

空のコレクションで足りる場合はOptionalで包む必要を見直す

複数件の検索結果なら、0件を空のListで表せることがあります。その契約で十分なら、Optional<List<User>>にせず、nullではないList<User>を返す設計で扱えます。

Optionalで包むと、「Optionalが空」と「値はあるがListが空」という二つの状態ができます。たとえば「検索自体を行っていない」と「検索した結果が0件」を区別するなら意味を持たせられますが、区別が不要なら呼び出し側の分岐が増えるだけです。表すべき状態が何かを決めてから型を選びます。

Optionalで避けたい使い方

Optional自体のnullと、根拠のないget呼び出しを避ける

Optionalを返しながらnullも返す設計は、不在の表現を二つに増やします。値がなければemptyを返し、呼び出し側がOptionalの操作へそのまま進める契約にします。

また、空のOptionalに対するget()はNoSuchElementExceptionになります。検索直後に無条件でgetを呼ぶのではなく、不在を許すなら既定値や分岐を選び、存在が必須ならorElseThrowでその要件を明示します。

一度取り出した値を何度も使う場合は、必要な境界でローカル変数へ受け取れます。すべての処理を長いOptionalのチェーンにする必要はありません。

==で比較せず、同期ロックにも使わない

Optionalはvalue-basedなクラスです。同じ値を表すインスタンスは交換可能なものとして扱い、インスタンスの同一性に依存する操作を避けます。Optional同士を値として比較するならequals()、空かどうかを判定するならisEmpty()またはisPresent()を使います。

Optional<String> first = Optional.of("みかん");
Optional<String> second = Optional.of("みかん");

boolean sameValue = first.equals(second);
boolean absent = first.isEmpty();

equalsは、両方が空の場合、または保持する値がequalsで等しい場合にtrueになります。一方、==は参照の同一性の比較です。Optional.empty()が常に同一インスタンスを返すという保証もないため、emptyとの参照比較で不在を判定してはいけません。

Optionalをsynchronizedのロック対象にしないでください。value-basedな型の同一性へ同期を依存させず、同期が必要な場合は、共有範囲と寿命を明確にした専用のロックなどを使います。

例外を一律にemptyへ変換して失敗の原因を隠さない

検索処理全体を広いcatchで囲み、どんな例外でもemptyを返すと、「該当する利用者がいない」と「検索が失敗した」が同じ結果になります。呼び出し側が代替表示だけで処理を終えると、接続失敗や実装上の不具合まで見えなくなるおそれがあります。

emptyへ変換するのは、契約上「値がない」と解釈してよい結果に限ります。外部APIなどが特定の不在を例外で伝える場合も、その条件だけを境界で変換し、通信失敗や予期しない例外までまとめないようにします。

mapやflatMapも例外を自動で吸収しません。Optionalは不在を表す道具として使い、処理失敗の伝達や回復方針は別に設計します。

仕様の確認には、Java SE 21のOptional APIと、メソッド引数の評価順序を定めるJava Language Specification 21の15.7.4節を参照してください。

まとめ

JavaのOptionalは、主にメソッドの戻り値で「値がない可能性」を明示するために使います。nullではない値はof、nullを不在として受け入れる値はofNullable、値のない結果はemptyで表し、Optional自体をnullにしない契約を守ります。

値の変換にはmap、Optionalを返す処理との接続にはflatMap、条件で残す値を選ぶならfilterを使います。固定の代替値はorElse、空の場合だけ求めたい代替値はorElseGet、存在が必須となる境界ではorElseThrowを選べます。複数の検索結果は、Optional.streamとStream.flatMapを組み合わせて扱えます。

フィールドや引数、DTO、永続化への採用は、不在の意味と連携要件に合わせて判断してください。根拠のないget、参照の同一性による比較、同期ロックへの利用、失敗を一律にemptyへ変える処理を避けることで、呼び出し側が結果を判断しやすいAPIになります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次