0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Javaの良いコード・悪いコード #4】NullPointerExceptionを防ぐNull処理の3原則

0
Posted at

はじめに

株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。

Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
コーポレートサイト

この記事は、新人〜2年目のJavaエンジニア向けに 「良いコードと悪いコードの違い」 を、現場でよく見る具体例とともに解説していくシリーズの第4回です。

テーマ
#1 命名
#2 コメントの書き方
#3 マジックナンバー・定数化
#4(本記事) Null処理
#5 早期リターン
#6 メソッド分割
#7 ループ処理
#8 例外処理
#9 ログ出力
#10 クラス設計

第4回は Null処理 です。Javaを書いていて最も多く遭遇するエラーの一つが NullPointerException(通称 NPE)です。新人〜2年目のうちに、NPEを構造的に防ぐコードの書き方 を身につけましょう。


この記事のゴール

この記事を読み終わると、以下ができるようになります。

  • NullPointerException がなぜ起きるかを説明できる
  • 早期リターン、Optional、空コレクションを使い分けてnull安全なコードが書ける
  • Optional.get() を使わずに Optional を扱える
  • 演習問題を通じて、null安全なリファクタリングができる

「NullPointerException」の本当のコスト

新人がよく書くコードに、こんなものがあります。

public String formatUserName(User user) {
    return user.getName().toUpperCase() + "さん";
}

このコードは、usernull でも、user.getName()null でも、NullPointerException を投げます。

NPEの厄介なところは、「どこでnullが発生したか」が分かりにくい ことです。

Exception in thread "main" java.lang.NullPointerException
    at UserService.formatUserName(UserService.java:12)
    at OrderService.processOrder(OrderService.java:45)
    at ...

スタックトレースは出ますが、「なぜそこに null が来たのか」 は分かりません。
null が混入した経路を辿るために、コード全体を遡って読む羽目になります。

NPEには、3つの大きなコストがあります。

  1. デバッグコスト:null の混入経路を辿るのに時間がかかる
  2. 保守コスト:あらゆる箇所で != null チェックが入り、ネストが深くなる
  3. 信頼性コスト:本番リリース後に発見されることが多い

特に厄介なのが 信頼性コスト です。
ローカルやテスト環境では null が来ないため通過してしまい、本番環境で初めて NPE が発生する、というケースが非常に多いのです。


押さえるべきは3原則

新人〜2年目がまず身につけるべきNull処理の原則は、以下の3つです。

  1. 早期リターンでnullを最初に処理する
  2. nullを返さない(Optionalまたは空コレクションを返す)
  3. Optional.get() を使わず、orElse / orElseThrow / map を使う

順番に見ていきます。


原則① 早期リターンでnullを最初に処理する

null チェックを メソッドの冒頭で行い、早期にリターン します。これで以降のコードは「null ではない」前提で書けます。

悪い例(ネストが深い)

public String formatUserName(User user) {
    if (user != null) {
        if (user.getName() != null) {
            return user.getName() + "さん";
        } else {
            return "名無し";
        }
    } else {
        return "ゲスト";
    }
}

ネストが深く、何を判定しているのか読み取りづらいです。
処理を追うために頭の中で if-else の対応を取る必要があります。

良い例(早期リターン)

public String formatUserName(User user) {
    if (user == null) {
        return "ゲスト";
    }
    if (user.getName() == null) {
        return "名無し";
    }
    return user.getName() + "さん";
}

「例外的なケース(null)を先に弾いて、本筋の処理を最後に書く」というパターンです。
読み手はメソッドを上から順に読むだけで、各分岐の意味が理解できます。

早期リターンが効くシーン

シーン
引数が null if (user == null) return defaultValue;
必須フィールドが null if (user.getEmail() == null) return errorResponse;
コレクションが空 if (list.isEmpty()) return Collections.emptyList();
業務ルールで処理対象外 if (!user.isActive()) return;

詳しくは次回(#5 早期リターン)で扱いますが、null処理は早期リターンの典型的な使い所です。


原則② nullを返さない(Optionalまたは空コレクション)

メソッドの 戻り値で null を返さない のは、現代のJava開発における重要なルールです。
null を返すと、呼び出し側は 必ず != null チェックを書かなければなりません。チェックを忘れた瞬間にNPEになります。

悪い例(nullを返す)

public User findUserById(long userId) {
    return userRepository.get(userId);  // 存在しない場合は null を返す
}

このメソッドを呼び出す側は、null チェックを毎回書く必要があります。

User user = findUserById(1L);
if (user != null) {  // ← 忘れたらNPE
    System.out.println(user.getName());
}

しかも、「このメソッドは null を返すことがある」 という事実が、メソッドのシグネチャからは読み取れません。

良い例(Optionalを返す)

public Optional<User> findUserById(long userId) {
    return Optional.ofNullable(userRepository.get(userId));
}

戻り値の型が Optional<User> になることで、「存在しない可能性がある」ことが型として明示 されます。
呼び出し側はnullチェックを忘れることがありません。

Optional<User> user = findUserById(1L);
user.ifPresent(u -> System.out.println(u.getName()));

コレクションを返す場合は null ではなく「空コレクション」

リストやセットなどのコレクションを返すメソッドは、null ではなく空のコレクション を返します。

悪い例

public List<String> findTagsByUserId(long userId) {
    User user = userRepository.get(userId);
    if (user == null) {
        return null;  // ← NG
    }
    return tagRepository.findByUserId(userId);
}

良い例

public List<String> findTagsByUserId(long userId) {
    User user = userRepository.get(userId);
    if (user == null) {
        return Collections.emptyList();  // 空リストを返す
    }
    return tagRepository.findByUserId(userId);
}

呼び出し側は、コレクションが空かどうかだけ 気にすればよくなります。
.size() == 0.isEmpty() で判定でき、!= null のチェックが不要になります。

戻り値の型ごとの推奨

戻り値の意味 推奨の型
「単一の値が存在しないかもしれない」 Optional<T>
「複数の値(0件もありうる)」 List<T>Set<T>Map<K, V>(空でもnullを返さない)
「必ず値がある」 T(プリミティブまたは値オブジェクト)

原則③ Optional.get() を使わず、orElse / orElseThrow / map を使う

Optional を使い始めた新人がやりがちなのが、Optional.get() の濫用です。

悪い例(Optional.get() の濫用)

Optional<User> userOpt = findUserById(userId);
if (userOpt.isPresent()) {
    User user = userOpt.get();
    System.out.println(user.getName());
}

これは null チェックを if (user != null) から isPresent() に書き換えただけ で、本質は何も変わっていません。
さらに、Optional.get()空のOptionalに対して呼び出すと NoSuchElementException を投げます。NPEと変わりません。

良い例(orElse / orElseThrow / map / ifPresent を使う)

パターンA:値がない場合のデフォルト値

String userName = findUserById(userId)
        .map(User::getName)
        .orElse("名無し");

パターンB:値がない場合に例外を投げる

User user = findUserById(userId)
        .orElseThrow(() -> new UserNotFoundException("userId=" + userId));

パターンC:値がある場合だけ処理する

findUserById(userId)
        .ifPresent(user -> System.out.println(user.getName()));

パターンD:値の変換

Optional<String> emailOpt = findUserById(userId)
        .map(User::getEmail);

これらを使い分ければ、Optional.get() を呼び出す機会はほぼゼロ になります。

Optional使用時のNG行為

NG行為 なぜダメか 推奨
Optional.get() を直接呼ぶ 空のときに例外 orElseorElseThrow
if (opt.isPresent()) { opt.get() } 結局null チェックと同じ mapifPresent
フィールドの型を Optional にする シリアライズ不可、設計ミスのサイン フィールドは通常型、getterだけ Optional を返す
Optional をメソッドの引数にする 呼び出し側で Optional.of(...) を書く手間 引数はオーバーロードか通常型
Optional<List<T>> を返す 空リストで意図を表現できる List<T> を返し、空ならemptyList

動作確認:3原則を全部適用したサンプル

3つの原則をすべて適用したコード例です。コピペでそのまま動かせます。

import java.util.Optional;
import java.util.List;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

public class NullDemo {
    private static final Map<Long, User> USER_DB = new HashMap<>();

    public static void main(String[] args) {
        USER_DB.put(1L, new User(1L, "田中", "tanaka@example.com"));

        // 原則①:早期リターンでnullチェック
        String greeting = greet(new User(2L, "佐藤", null));
        System.out.println(greeting);

        // 原則②:nullを返さない(Optionalを使う)
        Optional<User> user = findUserById(1L);
        user.ifPresent(u -> System.out.println("見つかったユーザー: " + u.getName()));

        Optional<User> notFound = findUserById(99L);
        System.out.println("見つからない場合: " + notFound.isPresent());

        // 空コレクションを返す
        List<String> tags = findTagsByUserId(99L);
        System.out.println("タグ数: " + tags.size());

        // 原則③:Optional.get()を使わない
        String userName = findUserById(1L)
                .map(User::getName)
                .orElse("名無し");
        System.out.println("ユーザー名: " + userName);
    }

    static String greet(User user) {
        if (user == null) {
            return "ゲストさん、こんにちは";
        }
        if (user.getEmail() == null) {
            return user.getName() + "さん、メールアドレスを登録してください";
        }
        return user.getName() + "さん(" + user.getEmail() + ")、こんにちは";
    }

    static Optional<User> findUserById(long userId) {
        return Optional.ofNullable(USER_DB.get(userId));
    }

    static List<String> findTagsByUserId(long userId) {
        User user = USER_DB.get(userId);
        if (user == null) {
            return Collections.emptyList();
        }
        return new ArrayList<>(List.of("Java", "SQL"));
    }
}

class User {
    private final long id;
    private final String name;
    private final String email;

    User(long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    long getId() { return id; }
    String getName() { return name; }
    String getEmail() { return email; }
}

期待する出力

佐藤さん、メールアドレスを登録してください
見つかったユーザー: 田中
見つからない場合: false
タグ数: 0
ユーザー名: 田中

NPEを生む典型パターン集

NPEを生みやすいパターンと、その対処をまとめました。

パターン 対処
メソッドチェーン中のnull user.getAddress().getCity() Optionalmap で繋ぐ、または途中で早期リターン
文字列比較の左側がnull userInput.equals("admin") 定数を左に置く:"admin".equals(userInput)
Map.get() の戻り値 map.get(key).doSomething() Optional.ofNullable(map.get(key))
配列・リストの要素 list.get(0).getName() if (!list.isEmpty()) で早期リターン
外部APIの戻り値 apiClient.fetch().getResult() レスポンスのフィールドを Optional でラップ
未初期化フィールド private List<String> tags; のまま使う 宣言時に初期化:= new ArrayList<>();
戻り値で null を返す自作メソッド return null; Optional か空コレクションを返す

特に 「文字列比較の左側がnull」 は、新人がよく踏むNPEです。

// 悪い例
if (userInput.equals("admin")) { ... }
// → userInputがnullだとNPE

// 良い例
if ("admin".equals(userInput)) { ... }
// → userInputがnullでもfalseが返るだけ

文字列リテラルは絶対にnullにならないので、比較対象を左側に置く のが安全です。


半日溶かした実話:本番でだけ起きるNPE

ある日の午後、本番でNPEが多発し始めました。スタックトレースは以下のような感じ。

NullPointerException
    at OrderService.calculate(OrderService.java:38)
    at OrderController.submit(OrderController.java:75)

OrderService.java:38 を見ると、こうなっていました。

public BigDecimal calculate(Order order) {
    return order.getCoupon().getDiscountAmount();
}

order.getCoupon() の戻り値が null の場合、.getDiscountAmount() でNPEになります。
コードを書いた人は 「クーポンは必ずある前提」 で書いていたのですが、その日 クーポン未適用の注文を許容する仕様変更がリリース されていました。

このバグの調査・修正に 半日以上 かかりました。原因は単純です。

  • Order.getCoupon() の戻り値が Coupon 型(nullを返す可能性がある)になっていた
  • 呼び出し側は null チェックを書いていなかった
  • テストデータでは常にクーポンがあったため、テスト環境では検出されなかった

もし Order.getCoupon()Optional<Coupon> を返す設計 だったら、コンパイラが呼び出し側に「null かもしれないよ」と教えてくれるので、このバグは生まれませんでした。

Optional は実行時の安全装置ではなく、設計時の意図表明です。
「nullを返すかもしれない」と明示しておけば、呼び出し側は対処を強制されます。


演習問題

難易度の見方

マーク 難易度 目安
基本 原則を覚えれば解ける
⭐⭐ 応用 複数の原則を組み合わせる

まずは自分で考えてから、模範解答を見てください!


問題1:早期リターンでネストを浅くする ⭐

次のコードを、早期リターンを使ってネストの浅いコードに書き直してください。

public class Sample {
    public static void main(String[] args) {
        System.out.println(formatUserName(new User("田中")));
        System.out.println(formatUserName(new User(null)));
        System.out.println(formatUserName(null));
    }

    static String formatUserName(User user) {
        if (user != null) {
            if (user.getName() != null) {
                return user.getName() + "さん";
            } else {
                return "名無し";
            }
        } else {
            return "ゲスト";
        }
    }
}

class User {
    private final String name;
    User(String name) { this.name = name; }
    String getName() { return name; }
}
模範解答
public class Exercise01 {
    public static void main(String[] args) {
        System.out.println(formatUserName(new User("田中")));
        System.out.println(formatUserName(new User(null)));
        System.out.println(formatUserName(null));
    }

    static String formatUserName(User user) {
        if (user == null) {
            return "ゲスト";
        }
        if (user.getName() == null) {
            return "名無し";
        }
        return user.getName() + "さん";
    }
}

class User {
    private final String name;
    User(String name) { this.name = name; }
    String getName() { return name; }
}

期待する出力

田中さん
名無し
ゲスト

ポイント

  • 例外的なケース(null)を先に処理してreturnする
  • 本筋の処理(正常系)を最後に書く
  • ネストが消え、上から読むだけで処理の流れが分かる

問題2:nullを返すメソッドをOptionalで書き直す ⭐

次のメソッドは、ユーザー名からメールアドレスを取得し、見つからない場合に null を返しています。
このメソッドの戻り値を Optional<String> に変更してください。

import java.util.HashMap;
import java.util.Map;

public class Sample {
    private static final Map<String, String> EMAIL_DB = new HashMap<>();

    public static void main(String[] args) {
        EMAIL_DB.put("tanaka", "tanaka@example.com");

        String email = findEmailByUserName("tanaka");
        if (email != null) {
            System.out.println("田中のメール: " + email);
        }
    }

    static String findEmailByUserName(String userName) {
        return EMAIL_DB.get(userName);
    }
}
模範解答
import java.util.Optional;
import java.util.HashMap;
import java.util.Map;

public class Exercise02 {
    private static final Map<String, String> EMAIL_DB = new HashMap<>();

    public static void main(String[] args) {
        EMAIL_DB.put("tanaka", "tanaka@example.com");

        Optional<String> found = findEmailByUserName("tanaka");
        System.out.println("田中のメール: " + found.orElse("未登録"));

        Optional<String> notFound = findEmailByUserName("yamada");
        System.out.println("山田のメール: " + notFound.orElse("未登録"));
    }

    /**
     * ユーザー名からメールアドレスを取得する。
     *
     * @param userName 検索対象のユーザー名
     * @return メールアドレス。存在しない場合は空のOptional
     */
    static Optional<String> findEmailByUserName(String userName) {
        return Optional.ofNullable(EMAIL_DB.get(userName));
    }
}

期待する出力

田中のメール: tanaka@example.com
山田のメール: 未登録

ポイント

  • Map.get() は値がなければ null を返すので、Optional.ofNullable() で包む
  • 呼び出し側は orElse("未登録") でデフォルト値を簡潔に指定できる
  • Javadocに「存在しない場合は空のOptional」と書いておくと、契約が明確になる(#2参照)

問題3:Optional.get() を使わずに書く ⭐

次のコードは Optional.get() を使っています。
maporElse を使って書き直してください。

import java.util.Optional;

public class Sample {
    public static void main(String[] args) {
        Optional<User> user1 = Optional.of(new User("tanaka"));
        Optional<User> user2 = Optional.empty();

        System.out.println(getUserNameUpperCase(user1));
        System.out.println(getUserNameUpperCase(user2));
    }

    static String getUserNameUpperCase(Optional<User> userOpt) {
        if (userOpt.isPresent()) {
            return userOpt.get().getName().toUpperCase();
        } else {
            return "GUEST";
        }
    }
}

class User {
    private final String name;
    User(String name) { this.name = name; }
    String getName() { return name; }
}
模範解答
import java.util.Optional;

public class Exercise03 {
    public static void main(String[] args) {
        Optional<User> user1 = Optional.of(new User("tanaka"));
        Optional<User> user2 = Optional.empty();

        System.out.println(getUserNameUpperCase(user1));
        System.out.println(getUserNameUpperCase(user2));
    }

    static String getUserNameUpperCase(Optional<User> userOpt) {
        return userOpt
                .map(User::getName)
                .map(String::toUpperCase)
                .orElse("GUEST");
    }
}

class User {
    private final String name;
    User(String name) { this.name = name; }
    String getName() { return name; }
}

期待する出力

TANAKA
GUEST

ポイント

  • map(User::getName)Optional<User>Optional<String> に変換
  • さらに map(String::toUpperCase) で大文字化
  • 最後に orElse("GUEST") で値がない場合のデフォルト値を指定
  • Optional.get() を一度も呼ばずに済む

問題4:null安全なリファクタリング(複合)⭐⭐

次のコードには「nullを返すメソッド」「Optional.get() の濫用」「ネストの深いnullチェック」がすべて含まれています。
3原則を踏まえてリファクタリングしてください。

import java.util.List;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;

public class Sample {
    private static final Map<Long, User> USER_DB = new HashMap<>();
    private static final Map<Long, List<String>> TAG_DB = new HashMap<>();

    public static void main(String[] args) {
        USER_DB.put(1L, new User(1L, "田中"));
        TAG_DB.put(1L, new ArrayList<>(List.of("Java", "SQL")));

        System.out.println(buildUserSummary(1L));
        System.out.println(buildUserSummary(99L));
    }

    static String buildUserSummary(long userId) {
        User user = findUserById(userId);
        if (user != null) {
            List<String> tags = findTagsByUserId(userId);
            if (tags != null) {
                return user.getName() + "(タグ: " + String.join(", ", tags) + ")";
            } else {
                return user.getName() + "(タグ: なし)";
            }
        } else {
            return "ユーザーが見つかりません: id=" + userId;
        }
    }

    static User findUserById(long userId) {
        return USER_DB.get(userId);
    }

    static List<String> findTagsByUserId(long userId) {
        return TAG_DB.get(userId);
    }
}

class User {
    private final long id;
    private final String name;
    User(long id, String name) { this.id = id; this.name = name; }
    long getId() { return id; }
    String getName() { return name; }
}
模範解答
import java.util.Optional;
import java.util.List;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

public class Exercise04 {
    private static final Map<Long, User> USER_DB = new HashMap<>();
    private static final Map<Long, List<String>> TAG_DB = new HashMap<>();

    public static void main(String[] args) {
        USER_DB.put(1L, new User(1L, "田中"));
        TAG_DB.put(1L, new ArrayList<>(List.of("Java", "SQL")));

        System.out.println(buildUserSummary(1L));
        System.out.println(buildUserSummary(99L));
    }

    static String buildUserSummary(long userId) {
        Optional<User> userOpt = findUserById(userId);
        if (userOpt.isEmpty()) {
            return "ユーザーが見つかりません: id=" + userId;
        }
        User user = userOpt.get();
        List<String> tags = findTagsByUserId(userId);
        return user.getName() + "(タグ: " + String.join(", ", tags) + ")";
    }

    static Optional<User> findUserById(long userId) {
        return Optional.ofNullable(USER_DB.get(userId));
    }

    static List<String> findTagsByUserId(long userId) {
        List<String> tags = TAG_DB.get(userId);
        if (tags == null) {
            return Collections.emptyList();
        }
        return tags;
    }
}

class User {
    private final long id;
    private final String name;
    User(long id, String name) { this.id = id; this.name = name; }
    long getId() { return id; }
    String getName() { return name; }
}

期待する出力

田中(タグ: Java, SQL)
ユーザーが見つかりません: id=99

改善ポイント

元のコード 改善後 理由
findUserByIdUser を返す(nullあり) Optional<User> を返す 「存在しない可能性」が型で明示される
findTagsByUserId がnullを返す可能性 空リストを返す 呼び出し側がnullチェック不要に
深いネスト(if (user != null) { if (tags != null) } 早期リターンで平坦化 上から読むだけで意図が分かる
tags != null チェック 不要になった 戻り値が空コレクションになったため

ポイント

  • 戻り値の型を見直すことで、呼び出し側のコードが シンプルかつ安全 になる
  • Optional と空コレクションを併用すると、!= null チェックがほぼ消える
  • 補足:実務では userOpt.get() の代わりに userOpt.orElseThrow(...) を使うと、より安全(ただし上の例ではすでに isEmpty() でチェック済みなので get() でも問題ない)

まとめ

新人〜2年目が押さえるべきNull処理の3原則は、以下の3つです。

  1. 早期リターンでnullを最初に処理する:例外ケースを先に弾き、本筋を最後に書く
  2. nullを返さない:単一値は Optional<T>、コレクションは空コレクション
  3. Optional.get() を使わず、orElse / orElseThrow / map を使う:型の意図を活かす

NPEは「起きてから直す」のではなく「起きない構造を作る」のが正解です。
特に重要なのは 戻り値の型 です。null を返す可能性があるメソッドは、必ず Optional<T> か空コレクションを返すようにしましょう。

「nullを返さない」だけで、コードベース全体のNPEの大半は消えます。


次回予告

次回(#5)は 「早期リターン」 を扱います。

  • ネストが深くなる原因と対処
  • ガード節(Guard Clause)パターン
  • 「正常系を最後に書く」というルールの徹底

を、Before / After 形式で解説していきます。


参考


@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?