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の良いコード・悪いコード #5】ネストを浅くする「早期リターン」の3原則

0
Posted at

はじめに

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

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

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

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

第5回は 早期リターン(Early Return) です。前回(#4 Null処理)でも触れた手法ですが、null以外にも 業務ルールでの除外バリデーション など、早期リターンが効くシーンは多くあります。ネストの深いコードを構造的に浅くする ための原則を整理します。


この記事のゴール

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

  • 「ネストが深いコード」がなぜ読みにくいかを説明できる
  • ガード節を使ってネストを平坦化できる
  • else を消して、正常系を最後に書く構造を作れる
  • ループ内で continue / break を使い分けられる

「ネストが深いコード」の本当のコスト

新人のコードによく見られるのが、こんな構造です。

public String process(Order order) {
    if (order != null) {
        if (order.isLoggedIn()) {
            if (order.isInStock()) {
                if (!order.isCanceled()) {
                    // 本当にやりたい処理(ようやくここ)
                    int finalPrice = (int) (order.getPrice() * 0.9);
                    return "発送処理開始(最終価格: " + finalPrice + "円)";
                } else {
                    return "キャンセル済みです";
                }
            } else {
                return "在庫切れです";
            }
        } else {
            return "ログインしてください";
        }
    } else {
        return "注文がありません";
    }
}

書いた本人は順番に if を重ねただけのつもりです。
ですが、読み手は 「いま自分がどの分岐の中にいるのか」を頭の中でスタックに積みながら 読まなければなりません。

ネストが深いコードには、3つのコストがあります。

  1. 読解コスト:「今どの条件下にいるか」を頭の中で常に追う必要がある
  2. 保守コスト:分岐を1つ追加するだけで、ネストがさらに深くなる
  3. バグコストelse の対応関係を読み間違えて、想定外のパスを通る

特に厄介なのが バグコスト です。
ネストが4段、5段と深くなると、「この else はどの if に対応しているのか」を読み違える事故が起こります。


押さえるべきは3原則

新人〜2年目がまず身につけるべき早期リターンの原則は、以下の3つです。

  1. ガード節で前提条件を最初に弾く
  2. else を消す(早期リターンで分岐を平坦化)
  3. ループ内では continue / break で異常系を弾く

順番に見ていきます。


原則① ガード節で前提条件を最初に弾く

ガード節(Guard Clause) とは、メソッドの冒頭で 「処理を続行できないケース」を先に return する パターンです。

悪い例(ネストが4段)

public String process(Order order) {
    if (order != null) {
        if (order.isLoggedIn()) {
            if (order.isInStock()) {
                if (!order.isCanceled()) {
                    int finalPrice = (int) (order.getPrice() * 0.9);
                    return "発送処理開始(最終価格: " + finalPrice + "円)";
                } else {
                    return "キャンセル済みです";
                }
            } else {
                return "在庫切れです";
            }
        } else {
            return "ログインしてください";
        }
    } else {
        return "注文がありません";
    }
}

良い例(ガード節)

public String process(Order order) {
    if (order == null) {
        return "注文がありません";
    }
    if (!order.isLoggedIn()) {
        return "ログインしてください";
    }
    if (!order.isInStock()) {
        return "在庫切れです";
    }
    if (order.isCanceled()) {
        return "キャンセル済みです";
    }
    int finalPrice = (int) (order.getPrice() * 0.9);
    return "発送処理開始(最終価格: " + finalPrice + "円)";
}

メソッドの長さはほぼ同じですが、読みやすさは劇的に違います

ガード節の特徴:

  • ネストが0段 に保たれる
  • 「メソッドの上から順に 「失格条件」を並べていく」という単純な構造
  • 最後に 「正常系の処理」 だけが残る

ガード節の典型パターン

弾く対象 ガード節の例
null引数 if (user == null) return defaultValue;
不正な引数 if (price < 0) throw new IllegalArgumentException(...);
業務ルールでの除外 if (!user.isActive()) return "停止中のユーザーです";
必要な権限の不足 if (!user.hasPermission(...)) return "権限がありません";
必須フィールドの不足 if (order.getCoupon() == null) return order.getPrice();

ガード節で「正常系以外」をすべて弾ききると、メソッドの最後には 本当にやりたい処理だけが残ります


原則② else を消す(早期リターンで分岐を平坦化)

ifelse の片方で return している場合、else書く必要がありません

悪い例(不要なelse)

public String classifyAge(int age) {
    if (age < 0) {
        return "不正な年齢";
    } else {
        if (age < 20) {
            return "未成年";
        } else {
            if (age < 65) {
                return "成人";
            } else {
                return "高齢者";
            }
        }
    }
}

if ブロックの中で return しているので、その後のコードは else を書かなくても実行されません
それなのに else を書くと、ネストが無駄に深くなります。

良い例(elseを削除)

public String classifyAge(int age) {
    if (age < 0) {
        return "不正な年齢";
    }
    if (age < 20) {
        return "未成年";
    }
    if (age < 65) {
        return "成人";
    }
    return "高齢者";
}

ネストが0段 になり、上から順に読むだけで分類ルールが理解できます。
特に 最後の return "高齢者"else の中ではなく、メソッド直下に書く のがポイントです。

三項演算子も有効

シンプルな2択なら、三項演算子で1行にしても良いです。

// if-else を使った冗長な書き方
String label;
if (user.isAdmin()) {
    label = "管理者";
} else {
    label = "一般";
}

// 三項演算子で1行に
String label = user.isAdmin() ? "管理者" : "一般";

ただし、条件が複雑な場合は無理に三項演算子にしない ようにしましょう。三項演算子のネストは、if-else のネストよりも読みにくくなります。


原則③ ループ内では continue / break で異常系を弾く

ループ内でも、ガード節と同じ考え方が使えます。continuebreak で異常系を弾けば、ネストを浅く保てます。

悪い例(ループ内のネスト)

public int sumPositive(List<Integer> nums) {
    int total = 0;
    for (Integer n : nums) {
        if (n != null) {
            if (n > 0) {
                total += n;
            }
        }
    }
    return total;
}

ループ内で if が2段ネストしています。
処理対象が増えると、さらにネストが深くなります。

良い例(continueで弾く)

public int sumPositive(List<Integer> nums) {
    int total = 0;
    for (Integer n : nums) {
        if (n == null) {
            continue;
        }
        if (n <= 0) {
            continue;
        }
        total += n;
    }
    return total;
}

continue で異常系を弾くと、ループ本体には「本当にやりたい処理」だけ が残ります。
これは「ガード節のループ版」と言える書き方です。

continue / break / return の使い分け

使う場面 キーワード 効果
「この要素はスキップして次の要素へ」 continue ループの次のイテレーションに進む
「ループ自体を抜ける」 break ループから抜けて次の処理へ
「メソッド自体を抜ける」 return メソッド全体を終了する

ループ内で 「条件を満たす要素を1つでも見つけたら処理を抜けたい」 ようなケースでは、breakreturn も活用します。

public boolean hasAdmin(List<User> users) {
    for (User user : users) {
        if (user.isAdmin()) {
            return true;  // 見つかった瞬間にメソッドを抜ける
        }
    }
    return false;
}

ここで「全員チェックしてから return」ではなく、最初に1人見つけた時点で return するのが効率的かつ読みやすいパターンです。


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

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

import java.util.List;
import java.util.ArrayList;

public class EarlyReturnDemo {
    public static void main(String[] args) {
        // 原則①:ガード節で前提条件を弾く
        System.out.println(applyDiscount(new Order(1000, true, false)));
        System.out.println(applyDiscount(new Order(1000, false, false)));
        System.out.println(applyDiscount(new Order(1000, true, true)));
        System.out.println(applyDiscount(null));

        System.out.println("---");

        // 原則②:elseを消して早期リターン
        System.out.println(classifyAge(25));
        System.out.println(classifyAge(15));
        System.out.println(classifyAge(70));

        System.out.println("---");

        // 原則③:ループ内でcontinue
        List<Integer> nums = new ArrayList<>();
        nums.add(1);
        nums.add(2);
        nums.add(-3);
        nums.add(4);
        nums.add(0);
        System.out.println("正の合計: " + sumPositive(nums));
    }

    static int applyDiscount(Order order) {
        if (order == null) {
            return 0;
        }
        if (!order.isLoggedIn()) {
            return order.getPrice();
        }
        if (order.isCanceled()) {
            return 0;
        }
        return (int) (order.getPrice() * 0.9);
    }

    static String classifyAge(int age) {
        if (age < 0) {
            return "不正な年齢";
        }
        if (age < 20) {
            return "未成年";
        }
        if (age < 65) {
            return "成人";
        }
        return "高齢者";
    }

    static int sumPositive(List<Integer> nums) {
        int total = 0;
        for (Integer n : nums) {
            if (n == null) {
                continue;
            }
            if (n <= 0) {
                continue;
            }
            total += n;
        }
        return total;
    }
}

class Order {
    private final int price;
    private final boolean isLoggedIn;
    private final boolean isCanceled;

    Order(int price, boolean isLoggedIn, boolean isCanceled) {
        this.price = price;
        this.isLoggedIn = isLoggedIn;
        this.isCanceled = isCanceled;
    }

    int getPrice() { return price; }
    boolean isLoggedIn() { return isLoggedIn; }
    boolean isCanceled() { return isCanceled; }
}

期待する出力

900
1000
0
0
---
成人
未成年
高齢者
---
正の合計: 7

よくある反論:「メソッドの出口は1つにすべき」のは本当?

新人向けの書籍では「メソッドの出口は1つにすべき(return文は1個だけ書くべき)」という古典的なルールを見かけます。これは C言語時代の「リソース解放のため」のルール で、現代のJavaにはほぼ当てはまりません。

Javaでは以下の理由から、早期リターン(複数return)が推奨されます

観点 単一出口の問題 早期リターンの利点
リソース解放 C言語では goto cleanup が必要だった Javaは try-with-resources が解決済み
読みやすさ フラグ変数 + 巨大なif-elseで複雑化 上から順に「失格条件」を読める
ネスト どんどん深くなる 0段に保てる
認知負荷 「今どの条件下にいるか」を追う必要 フラットなので不要

リーダブルコードや Effective Java でも、早期リターンは推奨される書き方 です。新人向けの教材で「return は1つ」と書かれていても、現代のJavaでは積極的に複数returnを使いましょう。


半日溶かした実話:ネスト7段のメソッド

過去最大に絡まったコードに出会った時の話です。

public Result process(Request request) {
    if (request != null) {
        if (request.getUser() != null) {
            if (request.getUser().isActive()) {
                if (request.getOrder() != null) {
                    if (request.getOrder().getItems() != null) {
                        if (!request.getOrder().getItems().isEmpty()) {
                            if (request.getOrder().getTotalAmount() > 0) {
                                // ようやく本処理
                                return doProcess(request);
                            }
                            // 各else節がここから下に延々と並ぶ
                        }
                    }
                }
            }
        }
    }
    return null;
}

ネストが7段 ありました。
このメソッドのあるバグを直す依頼が来たのですが、調査だけで 半日以上 かかりました。

問題だったのは:

  • どの if がどの else に対応しているか、目で追うのが困難
  • 修正箇所を見つけても、影響範囲(他の else ブロック)が想像できない
  • テストを書こうにも、何パターンあるのか分からない

最終的に ガード節への書き換え を提案して、ネストを0段に直しました。
書き換え後のコードは行数が増えるどころか 減りましたelse を書いていた分のインデントとブロック括弧が消えたからです。

修正のリスクも下がりました。ガード節なら、「ここを通る条件」を1つずつ消していくだけ で全パスを網羅できます。

ネストはコストです。書いた瞬間の楽さと、保守する地獄を引き換えにします。


演習問題

難易度の見方

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

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


問題1:ネストをガード節に書き換える ⭐

次のメソッドを、ガード節を使ってネストを浅く書き直してください。

public class Sample {
    public static void main(String[] args) {
        System.out.println(getDiscountRate(new Customer(true, 5)));
        System.out.println(getDiscountRate(new Customer(true, 0)));
        System.out.println(getDiscountRate(new Customer(false, 5)));
        System.out.println(getDiscountRate(null));
    }

    static double getDiscountRate(Customer customer) {
        if (customer != null) {
            if (customer.isMember()) {
                if (customer.getPurchaseCount() > 0) {
                    return 0.10;
                } else {
                    return 0.0;
                }
            } else {
                return 0.0;
            }
        } else {
            return 0.0;
        }
    }
}

class Customer {
    private final boolean isMember;
    private final int purchaseCount;
    Customer(boolean isMember, int purchaseCount) { this.isMember = isMember; this.purchaseCount = purchaseCount; }
    boolean isMember() { return isMember; }
    int getPurchaseCount() { return purchaseCount; }
}
模範解答
public class Exercise01 {
    public static void main(String[] args) {
        System.out.println(getDiscountRate(new Customer(true, 5)));
        System.out.println(getDiscountRate(new Customer(true, 0)));
        System.out.println(getDiscountRate(new Customer(false, 5)));
        System.out.println(getDiscountRate(null));
    }

    static double getDiscountRate(Customer customer) {
        if (customer == null) {
            return 0.0;
        }
        if (!customer.isMember()) {
            return 0.0;
        }
        if (customer.getPurchaseCount() == 0) {
            return 0.0;
        }
        return 0.10;
    }
}

class Customer {
    private final boolean isMember;
    private final int purchaseCount;
    Customer(boolean isMember, int purchaseCount) { this.isMember = isMember; this.purchaseCount = purchaseCount; }
    boolean isMember() { return isMember; }
    int getPurchaseCount() { return purchaseCount; }
}

期待する出力

0.1
0.0
0.0
0.0

ポイント

  • 「失格条件」を上から順に並べる
  • ネスト3段が0段になり、上から順に読むだけで条件が分かる
  • 正常系の return 0.10 はメソッドの最後に1行残るだけ

問題2:バリデーションをガード節で書く ⭐

次のメソッドは、パスワードのバリデーションを行います。
ガード節を使って、エラーメッセージを返すバリデーション関数を書いてください。

仕様

  • nullなら「パスワードがnullです」
  • 8文字未満なら「パスワードは8文字以上にしてください」
  • 大文字を含まなければ「大文字を1文字以上含めてください」
  • 数字を含まなければ「数字を1文字以上含めてください」
  • 上記すべてOKなら「OK」
public class Sample {
    public static void main(String[] args) {
        System.out.println(validatePassword("abc"));
        System.out.println(validatePassword("abcdefgh"));
        System.out.println(validatePassword("ABCDEFGH"));
        System.out.println(validatePassword("Abcdefgh1"));
    }

    static String validatePassword(String password) {
        // ここに実装する
        return null;
    }
}
模範解答
public class Exercise02 {
    public static void main(String[] args) {
        System.out.println(validatePassword("abc"));
        System.out.println(validatePassword("abcdefgh"));
        System.out.println(validatePassword("ABCDEFGH"));
        System.out.println(validatePassword("Abcdefgh1"));
    }

    static String validatePassword(String password) {
        if (password == null) {
            return "パスワードがnullです";
        }
        if (password.length() < 8) {
            return "パスワードは8文字以上にしてください";
        }
        if (!password.matches(".*[A-Z].*")) {
            return "大文字を1文字以上含めてください";
        }
        if (!password.matches(".*[0-9].*")) {
            return "数字を1文字以上含めてください";
        }
        return "OK";
    }
}

期待する出力

パスワードは8文字以上にしてください
大文字を1文字以上含めてください
数字を1文字以上含めてください
OK

ポイント

  • バリデーション関数は ガード節の典型例
  • 条件を1つずつ順に弾いていく
  • if-else でネストを作らないので、検査項目を追加するときも1ブロック足すだけで済む

問題3:ループ内でcontinueを使う ⭐

次のメソッドは、商品リストから「在庫があり、価格が0以上で、商品名がnullでない」商品の価格を合計します。
continue を使ってネストを浅く書き直してください。

import java.util.List;
import java.util.ArrayList;

public class Sample {
    public static void main(String[] args) {
        List<Product> products = new ArrayList<>();
        products.add(new Product("apple", 100, true));
        products.add(new Product(null, 200, true));
        products.add(new Product("banana", -50, true));
        products.add(new Product("cherry", 300, false));
        products.add(new Product("date", 400, true));

        int total = sumValidProductPrices(products);
        System.out.println("有効商品の合計: " + total);
    }

    static int sumValidProductPrices(List<Product> products) {
        int total = 0;
        for (Product product : products) {
            if (product != null) {
                if (product.getName() != null) {
                    if (product.getPrice() >= 0) {
                        if (product.isInStock()) {
                            total += product.getPrice();
                        }
                    }
                }
            }
        }
        return total;
    }
}

class Product {
    private final String name;
    private final int price;
    private final boolean isInStock;
    Product(String name, int price, boolean isInStock) { this.name = name; this.price = price; this.isInStock = isInStock; }
    String getName() { return name; }
    int getPrice() { return price; }
    boolean isInStock() { return isInStock; }
}
模範解答
import java.util.List;
import java.util.ArrayList;

public class Exercise03 {
    public static void main(String[] args) {
        List<Product> products = new ArrayList<>();
        products.add(new Product("apple", 100, true));
        products.add(new Product(null, 200, true));
        products.add(new Product("banana", -50, true));
        products.add(new Product("cherry", 300, false));
        products.add(new Product("date", 400, true));

        int total = sumValidProductPrices(products);
        System.out.println("有効商品の合計: " + total);
    }

    static int sumValidProductPrices(List<Product> products) {
        int total = 0;
        for (Product product : products) {
            if (product == null) {
                continue;
            }
            if (product.getName() == null) {
                continue;
            }
            if (product.getPrice() < 0) {
                continue;
            }
            if (!product.isInStock()) {
                continue;
            }
            total += product.getPrice();
        }
        return total;
    }
}

class Product {
    private final String name;
    private final int price;
    private final boolean isInStock;
    Product(String name, int price, boolean isInStock) { this.name = name; this.price = price; this.isInStock = isInStock; }
    String getName() { return name; }
    int getPrice() { return price; }
    boolean isInStock() { return isInStock; }
}

期待する出力

有効商品の合計: 500

ポイント

  • ループ内のネストが4段から0段に
  • continue で「対象外の要素」を弾く
  • 加算処理がループ本体に1行だけ残る
  • 検査条件を追加するときも、新しい if-continue を1ブロック足すだけ

問題4:複合リファクタリング ⭐⭐

次のメソッドは「ネストの深さ」「不要なelse」「単一出口の強制」がすべて含まれています。
3原則を踏まえて、リファクタリングしてください。

public class Sample {
    public static void main(String[] args) {
        System.out.println(processOrder(new Order(true, true, 1000, false)));
        System.out.println(processOrder(new Order(false, true, 1000, false)));
        System.out.println(processOrder(new Order(true, false, 1000, false)));
        System.out.println(processOrder(new Order(true, true, 1000, true)));
        System.out.println(processOrder(null));
    }

    static String processOrder(Order order) {
        String result;
        if (order == null) {
            result = "注文がありません";
        } else {
            if (order.isLoggedIn()) {
                if (order.isInStock()) {
                    if (!order.isCanceled()) {
                        int finalPrice = (int) (order.getPrice() * 0.9);
                        result = "発送処理開始(最終価格: " + finalPrice + "円)";
                    } else {
                        result = "キャンセル済みです";
                    }
                } else {
                    result = "在庫切れです";
                }
            } else {
                result = "ログインしてください";
            }
        }
        return result;
    }
}

class Order {
    private final boolean isLoggedIn;
    private final boolean isInStock;
    private final int price;
    private final boolean isCanceled;
    Order(boolean isLoggedIn, boolean isInStock, int price, boolean isCanceled) {
        this.isLoggedIn = isLoggedIn; this.isInStock = isInStock; this.price = price; this.isCanceled = isCanceled;
    }
    boolean isLoggedIn() { return isLoggedIn; }
    boolean isInStock() { return isInStock; }
    int getPrice() { return price; }
    boolean isCanceled() { return isCanceled; }
}
模範解答
public class Exercise04 {
    public static void main(String[] args) {
        System.out.println(processOrder(new Order(true, true, 1000, false)));
        System.out.println(processOrder(new Order(false, true, 1000, false)));
        System.out.println(processOrder(new Order(true, false, 1000, false)));
        System.out.println(processOrder(new Order(true, true, 1000, true)));
        System.out.println(processOrder(null));
    }

    static String processOrder(Order order) {
        if (order == null) {
            return "注文がありません";
        }
        if (!order.isLoggedIn()) {
            return "ログインしてください";
        }
        if (!order.isInStock()) {
            return "在庫切れです";
        }
        if (order.isCanceled()) {
            return "キャンセル済みです";
        }
        int finalPrice = (int) (order.getPrice() * 0.9);
        return "発送処理開始(最終価格: " + finalPrice + "円)";
    }
}

class Order {
    private final boolean isLoggedIn;
    private final boolean isInStock;
    private final int price;
    private final boolean isCanceled;
    Order(boolean isLoggedIn, boolean isInStock, int price, boolean isCanceled) {
        this.isLoggedIn = isLoggedIn; this.isInStock = isInStock; this.price = price; this.isCanceled = isCanceled;
    }
    boolean isLoggedIn() { return isLoggedIn; }
    boolean isInStock() { return isInStock; }
    int getPrice() { return price; }
    boolean isCanceled() { return isCanceled; }
}

期待する出力

発送処理開始(最終価格: 900円)
ログインしてください
在庫切れです
キャンセル済みです
注文がありません

改善ポイント

元のコード 改善後 理由
String result の宣言 削除 早期リターンで不要に
ネスト3段の if-else ガード節4本 ネスト0段に
単一出口の return result; 各分岐で直接return フラグ変数が消える
else { if (...) } の連鎖 平坦な if-return の連鎖 読みやすさが格段に向上

ポイント

  • 「単一出口(return 1つ)」というルールを捨てる
  • フラグ変数(String result)を持たずに、各条件で直接return
  • 結果として 元のコードより行数が減り、読みやすさが大幅に向上

まとめ

新人〜2年目が押さえるべき早期リターンの3原則は、以下の3つです。

  1. ガード節で前提条件を最初に弾く:処理続行できないケースを上から順に return
  2. else を消すif の中で return しているなら else は不要
  3. ループ内では continue / break で異常系を弾く:ガード節のループ版

すべてに共通するのは、「異常系を先に処理して、正常系を最後に書く」 という設計です。
この順序を守るだけで、ネストの深いコードはほぼ消えます。

「単一出口」「return は1つだけ」という古典ルールは、現代のJavaでは当てはまりません。
ネストはコストです。早期リターンを使い倒しましょう。


次回予告

次回(#6)は 「メソッド分割」 を扱います。

  • 「100行を超えるメソッド」がなぜダメか
  • メソッドの責務を1つに保つ方法
  • リファクタリング「メソッド抽出」の判断軸

を、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?