はじめに
株式会社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() + "さん";
}
このコードは、user が null でも、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つの大きなコストがあります。
- デバッグコスト:null の混入経路を辿るのに時間がかかる
-
保守コスト:あらゆる箇所で
!= nullチェックが入り、ネストが深くなる - 信頼性コスト:本番リリース後に発見されることが多い
特に厄介なのが 信頼性コスト です。
ローカルやテスト環境では null が来ないため通過してしまい、本番環境で初めて NPE が発生する、というケースが非常に多いのです。
押さえるべきは3原則
新人〜2年目がまず身につけるべきNull処理の原則は、以下の3つです。
- 早期リターンでnullを最初に処理する
- nullを返さない(Optionalまたは空コレクションを返す)
- 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() を直接呼ぶ |
空のときに例外 |
orElse、orElseThrow
|
if (opt.isPresent()) { opt.get() } |
結局null チェックと同じ |
map、ifPresent
|
フィールドの型を 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() |
Optional の map で繋ぐ、または途中で早期リターン |
| 文字列比較の左側が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() を使っています。
map と orElse を使って書き直してください。
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
改善ポイント
| 元のコード | 改善後 | 理由 |
|---|---|---|
findUserById が User を返す(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つです。
- 早期リターンでnullを最初に処理する:例外ケースを先に弾き、本筋を最後に書く
-
nullを返さない:単一値は
Optional<T>、コレクションは空コレクション - Optional.get() を使わず、orElse / orElseThrow / map を使う:型の意図を活かす
NPEは「起きてから直す」のではなく「起きない構造を作る」のが正解です。
特に重要なのは 戻り値の型 です。null を返す可能性があるメソッドは、必ず Optional<T> か空コレクションを返すようにしましょう。
「nullを返さない」だけで、コードベース全体のNPEの大半は消えます。
次回予告
次回(#5)は 「早期リターン」 を扱います。
- ネストが深くなる原因と対処
- ガード節(Guard Clause)パターン
- 「正常系を最後に書く」というルールの徹底
を、Before / After 形式で解説していきます。
参考
- Optional(Oracle公式API)
- Effective Java 第3版 - 項目55「Optionalを賢明に返す」(Joshua Bloch, ピアソン・エデュケーション)
- Tired of Null Pointer Exceptions? Consider Using Java SE 8's Optional!(Oracle)
@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!