1. 今回の学習ゴールと練習課題(仕様)

あなたは塾のアルバイト講師です。塾長から「小テストの結果を、点数の高い順に順位付きで出してほしい。同点の子は同じ順位にしてね」と頼まれました。生徒データは次の連想配列で渡されます。

$students = [
    ['id' => 1, 'name' => '佐藤', 'score' => 85],
    ['id' => 2, 'name' => '鈴木', 'score' => 92],
    ['id' => 3, 'name' => '高橋', 'score' => 78],
    ['id' => 4, 'name' => '田中', 'score' => 85],
    ['id' => 5, 'name' => '伊藤', 'score' => 58],
    ['id' => 6, 'name' => '渡辺', 'score' => 92],
];

要件仕様

期待される実行結果

1位: 鈴木(No.2)92点 [S]
1位: 渡辺(No.6)92点 [S]
3位: 佐藤(No.1)85点 [A]
3位: 田中(No.4)85点 [A]
5位: 高橋(No.3)78点 [B]
6位: 伊藤(No.5)58点 [D]
平均点: 81.7点 / 受験者数: 6人

2. 初心者がハマるNGコード(Before)と解説

まずは、私自身も新人時代に書いてレビューで真っ赤にされた「よくある解答」です。

<?php
// ($students の定義は省略)

// 点数の高い順に並べたい
usort($students, function ($a, $b) {
    return $a['score'] < $b['score'];
});

foreach ($students as $i => $s) {
    echo ($i + 1) . "位: " . $s['name'] . " " . $s['score'] . "点\n";
}

$total = 0;
foreach ($students as $s) {
    $total += $s['score'];
}
echo "平均点: " . $total / count($students) . "点\n";

PHP 8.4 で実行すると、こうなります。

Deprecated: usort(): Returning bool from comparison function is deprecated,
return an integer less than, equal to, or greater than zero in ... on line 5
1位: 鈴木 92点
2位: 渡辺 92点
3位: 佐藤 85点
4位: 田中 85点
5位: 高橋 78点
6位: 伊藤 58点
平均点: 81.666666666667点

NGポイント①:比較関数が bool を返している

usort の比較関数は「負の数・0・正の数」の整数(int)を返す仕様です。true / false を返すと、PHP 8.0 以降は Deprecated 警告が発生し、将来のバージョン(PHP 9.0 等)では例外(TypeError)が発生する予定です。bool を返すと false(0)と true(1)しか返せず、負の数(-1)が存在しないため、ソートアルゴリズム(内部実装)が要素の前後関係を正しく判定できず、予期せぬ順序崩れの原因になります。

NGポイント②:$i + 1 をそのまま順位にしている

配列の添字は「並び位置」であって「順位」ではありません。92点の鈴木さんと渡辺さんが1位・2位に分かれてしまい、仕様違反です。保護者に配るランキングでこれをやると、本当にクレームになります。

NGポイント③:平均点の桁と0人問題(DivisionByZeroError)

81.666666666667 は人間向けの表示ではありません。さらに $students が空配列だと count() が 0 になり、PHP 8.0 以降では警告ではなく DivisionByZeroError 例外がスローされてプログラムが強制終了します。「データが0件のとき」は現場で必ず起きる境界値なので、例外を起こさず安全に処理を逃がす設計(ガード節)が必須です。

NGポイント④(番外編):usort() の戻り値を変数で受け取ってしまう

初心者が非常によくやるもう一つの落とし穴が、$sorted = usort($students, ...); のようにソート結果を変数で受け取ろうとすることです。usort() は配列そのものを直接書き換える(参照渡し)関数であり、戻り値は成否を表す bool (true) です。そのため $sorted を使おうとすると「配列が消えて true になってしまった!」とパニックになります。元の配列 $students が直接ソートされている点に注意しましょう。

3. コピペで即動く模範解答コード(After)

PHP 8.3 / 8.4 でそのまま動く完全版です。ranking.php として保存し、php ranking.php で実行してみてください。

<?php
declare(strict_types=1);

/**
 * 点数の高い順(同点なら出席番号の若い順)に並べ、同点同順位の順位を付ける
 *
 * @param list<array{id: int, name: string, score: int}> $students
 * @return list<array{rank: int, id: int, name: string, score: int}>
 */
function rankStudents(array $students): array
{
    usort(
        $students,
        fn(array $a, array $b): int =>
            ($b['score'] <=> $a['score'])   // 第1キー:点数の降順
            ?: ($a['id'] <=> $b['id'])      // 第2キー:出席番号の昇順
    );

    $ranked        = [];
    $currentRank   = 0;
    $previousScore = null;

    foreach ($students as $index => $student) {
        // 前の人と点数が違うときだけ「並び位置 + 1」に順位を更新する
        if ($student['score'] !== $previousScore) {
            $currentRank   = $index + 1;
            $previousScore = $student['score'];
        }
        $ranked[] = ['rank' => $currentRank, ...$student];
    }

    return $ranked;
}

/** 点数から評価(S〜D)を返す */
function gradeOf(int $score): string
{
    return match (true) {
        $score >= 90 => 'S',
        $score >= 80 => 'A',
        $score >= 70 => 'B',
        $score >= 60 => 'C',
        default      => 'D',
    };
}

/**
 * 平均点(小数第1位で四捨五入)を返す。受験者0人または点数データなしなら null
 *
 * @param list<array{id: int, name: string, score: int}> $students
 */
function averageScore(array $students): ?float
{
    $scores = array_column($students, 'score');
    if ($scores === []) {
        return null;
    }

    return round(array_sum($scores) / count($scores), 1);
}

// ---- ここから実行部分 ----
$students = [
    ['id' => 1, 'name' => '佐藤', 'score' => 85],
    ['id' => 2, 'name' => '鈴木', 'score' => 92],
    ['id' => 3, 'name' => '高橋', 'score' => 78],
    ['id' => 4, 'name' => '田中', 'score' => 85],
    ['id' => 5, 'name' => '伊藤', 'score' => 58],
    ['id' => 6, 'name' => '渡辺', 'score' => 92],
];

foreach (rankStudents($students) as $row) {
    printf(
        "%d位: %s(No.%d)%d点 [%s]\n",
        $row['rank'],
        $row['name'],
        $row['id'],
        $row['score'],
        gradeOf($row['score'])
    );
}

$average = averageScore($students);

echo $average === null
    ? "受験者がいません\n"
    : sprintf("平均点: %.1f点 / 受験者数: %d人\n", $average, count($students));

4. 現場のプロ(kixking)による徹底コード解説

Step 1:宇宙船演算子 <=> で比較関数を書く

$x <=> $y は「x が小さければ -1、等しければ 0、大きければ 1」を返す演算子(PHP 7.0+)で、usort の比較関数の仕様に完璧に合致しています。降順にしたいときは $b と $a を入れ替えるだけ。アロー関数 fn(array $a, array $b): int => ... に戻り値の型宣言 : int を明記することで、型安全性を高め、万が一のバグを早期発見できます。

Step 2:?: で「第2キー」をつなぎ、決定論的ソートにする

点数が同じなら <=> の結果は 0(falsy)なので、エルビス演算子 ?: の右側=出席番号の比較式が評価されます。これで「同点なら出席番号順」という並び順の仕様が1行で明文化されます。

実は PHP 8.0 以降、usort などの標準ソート関数は内部アルゴリズムが「安定ソート(等しい値の要素の相対順序が保持される)」に変更されました。しかし、元の配列の順序自体が「DBクエリの取得順」や「APIレスポンスの順」で変化する可能性があるため、元の順序に甘えず第2キーを明示して、誰がいつ実行しても一意に並ぶ「決定論的ソート(Deterministic Sort)」にするのが現場の鉄則です。

なお、名前(漢字)で第2キーを組むのはおすすめしません。strcmp は文字コード(UTF-8バイナリ)順の比較なので、「あいうえお順(五十音順)」にはならず漢字コード順になってしまうからです。並び順には読み仮名(ひらがな/カタカナ)や ID のような機械的に一意比較できる値を使いましょう。

Step 3:順位は「1つ前の点数」と比べて決める(配列アンパック)

ソート後の配列を先頭から見て、前の人と点数が違うときだけ順位を「並び位置 + 1(添字 $index + 1)」に更新します。同点なら順位を据え置くので、92・92・85 は 1位・1位・3位になります。$previousScore を null で初期化しているため、先頭の人は必ず更新処理に入ります。また、型の取り違えによる誤判定を防ぐため、比較には必ず !==(厳密比較)を使います。

行データへの順位追加には、PHP 8.1 で導入された連想配列のアンパック構文 ['rank' => $currentRank, ...$student] を採用しています。従来の + 演算子(配列結合)よりも上書き関係が視覚的にわかりやすく、モダンPHPの現場で推奨されるスタイルです。

Step 4:match(true) で評価の基準表を表現する

match (true) は上から順に条件を評価し、最初に true になった腕の値を返します。従来の switch (true) との決定的な違いは次の3点です。

Step 5:0件ガードと表示用の整形を分ける(責務の分離)

averageScore() は計算のみを担当し、データが0件(または点数データが存在しない場合)は null を返します。一方、表示側では sprintf('%.1f') で桁数を統一するため、平均がちょうど 82 点でも「82.0点」と揃えて出力されます。計算ロジックと表示フォーマットを分離しておくことで、後からWeb画面(HTML)やJSON API、CSV出力に流用する際も関数に一切手を加えずに再利用できます。

【セキュリティの重要Tips:Web画面出力時のXSS対策】
このランキングをブラウザ(HTML)に出力する場合は、名前などの外部入力値を必ず htmlspecialchars($row['name'], ENT_QUOTES, 'UTF-8') でエスケープしてください。受講生名に <script> や HTML 特殊文字が含まれていた場合、クロスサイトスクリプティング(XSS)脆弱性につながります。現場では <td><?= htmlspecialchars($row['name'], ENT_QUOTES, 'UTF-8') ?></td> のように出力境界で確実に無害化するのが鉄則です。

5. もう一歩進める!発展チャレンジ課題

模範解答が動いたら、現場でよく求められる次のミニ要件に自力で挑戦してみましょう。

  1. 密順位(Dense Rank)モード:同点の次を飛ばさない「1位・1位・2位」方式に切り替えられるよう、rankStudents() に bool $isDense = false 引数を追加してください。
  2. 入力値バリデーション:score が 0〜100 の整数でないデータ(例:105 や '八十')が混ざっていたら、除外して警告を出す処理を書いてください。filter_var() の FILTER_VALIDATE_INT を使う際、0 点のときに緩やかな比較(== false)をすると誤判定されるため、必ず === false で厳密比較するのがポイントです。
  3. 上位3名だけ表彰:順位が3位以内の生徒だけを array_filter() で抽出してください。同点で4人以上になった場合、全員表彰されるかも確認しましょう。
  4. 科目別ランキング:score を ['math' => 80, 'english' => 72] のような配列に変え、合計点ランキングと科目別ランキングを出し分けてみてください。
  5. 【PHP 8.4新機能】全員が合格(60点以上)かを判定:PHP 8.4 で追加された標準配列関数 array_all() を使って、全員が60点以上(合格)かどうかを1行で判定するコードを書いてみてください(ヒント:array_all($students, fn(array $s): bool => $s['score'] >= 60);)。

まとめ

今回のドリルのポイントは、usort の比較関数は整数を返す(<=> を活用する)、安定ソートに甘えず第2キーで決定論的ソートにする、並び位置と順位を明確に分離する、0件ガードと計算・表示の責務を分離する、の4点です。ソートと順位付けは、売上ランキング・人気記事一覧・ゲームのスコアボードなどWeb開発の実務でも頻出のパターン。ぜひ発展課題まで手を動かして、一生モノの基礎力を身につけてください。