1つの関数を長くしすぎない

1. なぜ「長い関数」が問題なのか

1つの関数に処理を詰め込みすぎると、次のような問題が起こります。

  • 何をしているか把握しづらい:関数名は1つでも、中身に複数の役割が混ざっていると、読む側は「結局この関数は何をするものか」を一文で説明できなくなる
  • テストが書きにくい:関数内で複数の処理をしていると、1つのテストで多くのパターンを網羅しなければならず、テストコードも複雑になる
  • 修正の影響範囲が読めない:一部だけ直したつもりが、関数内の別の処理に意図せず影響してしまう
  • 再利用ができない:関数の中に複数の処理が密結合していると、その一部分だけを他の場所で使い回すことができない

これは「単一責任の原則(Single Responsibility Principle)」と呼ばれる考え方に基づいています。1つの関数は1つの役割だけを持つように分割することで、上記の問題を防げます。

以下、実務でよくあるパターンをPHPの例で3つ紹介します。
今回は記事の関係上、行数が少ないもので例文を作っています。
そのためこれくらいだったら普通にそのまま書くという方もいると思います。

実際にはスクロールしないと全体が見えないような(行数が80行ほどある)ものが対象になるケースが多いと思います。
今回の例を参考に同じような考えでシンプルにするといいかと思います。


2. 例1:注文処理のように「複数の工程」が1つの関数に詰め込まれているケース

Before(バリデーション・計算・保存・通知が1つの関数にまとまっている)

//-----------------------------------------------------------------------
// 注文処理
// $orderData   : 注文データ
// 返り値      : なし
//-----------------------------------------------------------------------
function processOrder(array $orderData)
{
    // バリデーション
    if (empty($orderData['items'])) {
        // アイテムがない場合
        throw new Exception('注文商品がありません');
    }
    if (empty($orderData['userId'])) {
        // ユーザIDがない場合
        throw new Exception('ユーザーIDが必要です');
    }

    // 総額の計算
    $total = 0;
    foreach ($orderData['items'] as $item) {
        $total += $item['price'] * $item['quantity'];
    }

    if ($orderData['hasCoupon']) {
        // クーポンを持っている場合
        $total = round($total * 0.9);
    }

    // DBへの保存
    $order = new Order();
    $order->userId = $orderData['userId'];
    $order->total = $total;
    $order->items = $orderData['items'];
    $order->save();

    // メール通知
    $user = User::find($orderData['userId']);
    mail($user->email, '注文確認', "合計金額: {$total}円で注文を受け付けました。");
}

問題点:「バリデーション」「金額計算」「保存」「通知」という4つの異なる責務が1つの関数に混在している。例えば「割引ロジックだけ単体でテストしたい」と思っても、この関数ごと呼び出す以外に方法がない。

After(工程ごとに関数を分割する)

//-----------------------------------------------------------------------
// 注文処理
// $orderData   : 注文データ
// 返り値      : なし
//-----------------------------------------------------------------------
function processOrder($orderData)
{
    // バリデーション
    validateOrder($orderData);

    // 総額の計算
    $total = calculateOrderTotal($orderData['items'], $orderData['hasCoupon']);

    // 注文データ保存
    $order = saveOrder($orderData['userId'], $orderData['items'], $total);

    // 注文完了通知
    notifyOrderConfirmation($orderData['userId'], $total);
}

//-----------------------------------------------------------------------
// バリデーション
// $orderData   : 注文データ
// 返り値      : なし
//-----------------------------------------------------------------------
function validateOrder($orderData)
{
    if (empty($orderData['items'])) {
        // アイテムがない場合
        throw new Exception('注文商品がありません');
    }
    if (empty($orderData['userId'])) {
        // ユーザIDがない場合
        throw new Exception('ユーザーIDが必要です');
    }
}

//-----------------------------------------------------------------------
// 総額の計算
// $items       : 商品データ
// $hasCoupon   : クーポン持っているか?
// 返り値      : 価格
//-----------------------------------------------------------------------
function calculateOrderTotal($items,$hasCoupon)
{
    $total = 0;
    foreach ($items as $item) {
        $total += $item['price'] * $item['quantity'];
    }

    if ($hasCoupon) {
        // クーポンを持っている場合
        $total = round($total * 0.9);
    }

    return ($total);
}

//-----------------------------------------------------------------------
// 注文データ保存
// userId       : ユーザーID
// $items       : 商品データ
// $total       : 総額
// 返り値      : 注文オブジェクト
//-----------------------------------------------------------------------
function saveOrder($userId,$items,$total)
{
    $order = new Order();
    $order->userId = $userId;
    $order->total = $total;
    $order->items = $items;
    $order->save();

    return($order);
}

//-----------------------------------------------------------------------
// 注文完了通知
// userId       : ユーザーID
// $total       : 総額
// 返り値      : なし
//-----------------------------------------------------------------------
function notifyOrderConfirmation($userId,$total)
{
    $user = User::find($userId);
    mail($user->email, '注文確認', "合計金額: {$total}円で注文を受け付けました。");
}

改善点processOrderを読むだけで「バリデーション→計算→保存→通知」という処理の流れが一目で分かるようになった。calculateOrderTotalのように、割引ロジックだけを単体でテストすることもできる。


2. 例2:CSVインポート処理のように「読み込み・変換・保存」が混在しているケース

Before(ファイル読み込みからDB保存まで1つの関数に詰め込まれている)

//-----------------------------------------------------------------------
// ユーザ情報CSVインポート
// $filePath    : ファイルパス
// 返り値      : なし
//-----------------------------------------------------------------------
function importUsersFromCsv($filePath)
{
    // ファイル読み込み
    $handle = fopen($filePath, 'r');
    $rows = [];
    while (($line = fgetcsv($handle)) !== false) {
        $rows[] = $line;
    }
    fclose($handle);

    // ヘッダー行を除去
    array_shift($rows);

    foreach ($rows as $row) {
        $name = trim($row[0]);
        $email = strtolower(trim($row[1]));

        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            // メールアドレスの形式ではない場合
            continue;
        }

        $existing = User::where('email', $email)->first();
        if ($existing) {
            // 既に登録されているメールアドレスの場合
            continue;
        }

        // 保存
        $user = new User();
        $user->name = $name;
        $user->email = $email;
        $user->save();
    }
}

問題点:「CSVを読み込む処理」「1行を整形・検証する処理」「DBに保存する処理」が全て混在している。CSVの形式が変わった場合も、メールの検証ロジックを変えたい場合も、同じ長い関数を修正することになる。

After(読み込み・変換・保存を別関数に分ける)

//-----------------------------------------------------------------------
// ユーザ情報CSVインポート
// $filePath    : ファイルパス
// 返り値      : なし
//-----------------------------------------------------------------------
function importUsersFromCsv($filePath)
{
    // ファイル読み込み
    $rows = readCsvRows($filePath);

    foreach ($rows as $row) {
        $userData = parseUserRow($row);

        if ($userData === null || userAlreadyExists($userData['email'])) {
            // データがないまたは既に登録されている場合
            continue;
        }

        // ユーザー登録
        createUser($userData['name'], $userData['email']);
    }
}

//-----------------------------------------------------------------------
// ファイル読み込み
// $filePath    : ファイルパス
// 返り値      : CSVデータ
//-----------------------------------------------------------------------
function readCsvRows($filePath)
{
    $handle = fopen($filePath, 'r');
    $rows = [];
    while (($line = fgetcsv($handle)) !== false) {
        $rows[] = $line;
    }
    fclose($handle);

    // ヘッダー行を除去
    array_shift($rows); 

    return($rows);
}

//-----------------------------------------------------------------------
// ファイル読み込み
// $row         : CSV1行分のデータ
// 返り値      : ユーザー情報
//-----------------------------------------------------------------------
function parseUserRow($row)
{
    $name = trim($row[0]);
    $email = strtolower(trim($row[1]));

    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        // メールアドレスの形式ではない場合
        return null;
    }

    return ['name' => $name, 'email' => $email];
}

//-----------------------------------------------------------------------
// 登録済みか?
// $email       : メールアドレス
// 返り値      : true 登録済み false 未登録
//-----------------------------------------------------------------------
function userAlreadyExists(string $email): bool
{
    $existing = User::where('email', $email)->first();
    return ($existing);
}

//-----------------------------------------------------------------------
// ユーザ登録
// $name        : 名前
// $email       : メールアドレス
// 返り値      : ユーザーオブジェクト
//-----------------------------------------------------------------------
function createUser($name,$email)
{
    $user = new User();
    $user->name = $name;
    $user->email = $email;
    $user->save();

    return ($user);
}

改善点parseUserRowだけを単体でテストして「不正なメールアドレスの行が正しく除外されるか」を検証できるようになった。CSVの読み込み方法(区切り文字やエンコーディング)を変更したくなった場合も、readCsvRowsだけを直せばよい。


2. 例3:画面表示用のデータ整形処理が肥大化しているケース

Before(取得・加工・整形が1つの関数に混在)

// 最大表示名文字数
define('MAX_DISPLAY_NAME',20);

// 投稿数閾値
define('THRESHOLD_POST_COUNT',100);

//-----------------------------------------------------------------------
// 表示用ユーザープロフィール取得
// $userId      : ユーザーID
// 返り値      : 表示データ
//-----------------------------------------------------------------------
function getUserProfileForDisplay($userId)
{
    // ユーザ情報取得
    $user = User::find($userId);

    // 年齢
    $age = null;
    if ($user->birthDate) {
        // 生年月日がある場合
        $birth = new DateTime($user->birthDate);
        $now = new DateTime();
        $age = $now->diff($birth)->y;
    }

    // 表示名
    if (!empty($user->nickname) {
        // ニックネームがある場合
        $displayName = $user->nickname;
    }
    else {
        // ない場合
        $displayName = $user->name;
    }

    if (mb_strlen($displayName) > MAX_DISPLAY_NAME) {
        // 表示名が20文字より大きい場合
        $displayName = mb_substr($displayName, 0, MAX_DISPLAY_NAME) . '...';
    }

    // バッジ
    $badges = [];
    if ($user->postCount > THRESHOLD_POST_COUNT) {
        // 投稿数が閾値を超えている場合
        $badges[] = 'ベテラン投稿者';
    }

    if ($user->createdAt < new DateTime('-1 year')) {
        // 1年以上利用している場合
        $badges[] = '1年以上の利用者';
    }

    return [
        'displayName' => $displayName,
        'age' => $age,
        'badges' => $badges,
    ];
}

問題点:「年齢計算」「表示名の省略処理」「バッジ判定」という独立した3つのロジックが1つの関数の中に埋め込まれている。バッジの種類が増えるたびに、この関数全体が長くなり続ける。

After(それぞれの整形ロジックを独立した関数に切り出す)

// 最大表示名文字数
define('MAX_DISPLAY_NAME',20);

// 投稿数閾値
define('THRESHOLD_POST_COUNT',100);

//-----------------------------------------------------------------------
// 表示用ユーザープロフィール取得
// $userId      : ユーザーID
// 返り値      : 表示データ
//-----------------------------------------------------------------------
function getUserProfileForDisplay($userId)
{
    // ユーザ情報取得
   $user = User::find($userId);

    $data= array(
               'displayName' => formatDisplayName($user),
               'age' => calculateAge($user->birthDate),
               'badges' => getUserBadges($user),
            );
    return($data);
}

//-----------------------------------------------------------------------
// 表示名整形
// $userId      : ユーザーID
// 返り値      : 表示名
//-----------------------------------------------------------------------
function formatDisplayName($user)
{
    if (!empty($user->nickname) {
        // ニックネームがある場合
        $displayName = $user->nickname;
    }
    else {
        // ない場合
        $displayName = $user->name;
    }

    if (mb_strlen($displayName) > MAX_DISPLAY_NAME) {
        // 表示名が20文字より大きい場合
        $displayName = mb_substr($displayName, 0, MAX_DISPLAY_NAME) . '...';
    }

    return($displayName);
}

//-----------------------------------------------------------------------
// 年齢計算
// $birthDate   : 生年月日
// 返り値      : 年齢
//-----------------------------------------------------------------------
function calculateAge($birthDate)
{
    if ($birthDate === null) {
        // 生年月日がない場合
        return(null);
    }

    $birth = new DateTime($birthDate);
    $now = new DateTime();

    $age = $now->diff($birth)->y;
    return($age);
}

//-----------------------------------------------------------------------
// バッジ取得
// $user        : ユーザ情報
// 返り値      : バッジ情報
//-----------------------------------------------------------------------
function getUserBadges($user)
{
    $badges = [];
    if ($user->postCount > THRESHOLD_POST_COUNT) {
        // 投稿数が閾値を超えている場合
        $badges[] = 'ベテラン投稿者';
    }

    if ($user->createdAt < new DateTime('-1 year')) {
        // 1年以上利用している場合
        $badges[] = '1年以上の利用者';
    }

    return($badges);
}

改善点:バッジの条件を追加したい場合はresolveUserBadgesだけを見ればよく、年齢計算のロジックにミスがないかを確認したい場合はcalculateAge単体でテストできる。それぞれの関数が「1つのことだけ」をしているため、getUserProfileForDisplay自体は「何のデータを組み立てているか」の一覧のように読める。


3. まとめ:長い関数を避けるための視点

目安 内容
一文で説明できるか 関数の役割を「〜と〜と〜をする」のように「と」でつなげないと説明できない場合、分割のサインです
処理の工程ごとに分ける バリデーション/計算/保存/通知のように、性質が異なる処理は別の関数にする
単体でテストしたい部分があるか 「この部分だけテストしたい」と思ったら、そこは独立した関数にできる可能性が高い
関数の行数 明確な絶対値はないが、スクロールしないと全体を見渡せない関数は分割を検討する目安になる

ポイント:関数を分割する目的は「行数を減らすこと」自体ではなく、1つの関数が1つの役割だけを持つようにして、読む人が処理の流れをすぐに理解できるようにすることです。分割後に関数の数が増えても、それぞれが単純で名前から役割が分かる状態であれば、それは良い設計といえます。