ageとclassでグルーピングし、各グループの最高scoreを持つオブジェクトを見つけ出すStream処理


この処理には大きく分けて2つの優れた方法があります。
 * Collectors.toMap() を使う方法: よりモダンで効率的なアプローチです。
 * Collectors.groupingBy() を使う方法: より直感的で分かりやすいアプローチです。
両方の方法を、DTOの定義から具体的な実装、そして実行結果まで含めて解説します。
1. DTOの準備
まず、処理の対象となるSchoolDtoと、最終的に生成したいMaxScoreDtoを定義します。Java 16以降で利用可能なrecordを使うと簡潔に記述できます。
// 入力データとなるDTO
// recordは、private finalなフィールド、コンストラクタ、getter、equals、hashCode、toStringを自動生成します。
public record SchoolDto(
    String name,
    int age,
    String schoolClass, // "A", "B", "C" など
    int score
) {}

// 出力データとなるDTO
public record MaxScoreDto(
    int age,
    String schoolClass,
    int maxScore,
    String topScorerName // 最高得点者の名前も保持する
) {}

> 補足: recordを使わない場合は、通常のclassとして定義し、コンストラクタ、getter、equals/hashCodeを実装してください。
> 
2. サンプルデータの準備
動作確認のために、サンプルとなるSchoolDtoのリストを作成します。
import java.util.List;

List<SchoolDto> schoolDtoList = List.of(
    new SchoolDto("佐藤", 15, "A", 85),
    new SchoolDto("鈴木", 15, "A", 92), // 15歳A組の最高得点
    new SchoolDto("高橋", 15, "A", 78),
    new SchoolDto("田中", 16, "B", 95), // 16歳B組の最高得点
    new SchoolDto("伊藤", 16, "B", 88),
    new SchoolDto("渡辺", 15, "C", 91), // 15歳C組の最高得点
    new SchoolDto("山本", 15, "A", 89), // 鈴木さん(92)より低い
    new SchoolDto("中村", 16, "B", 95)  // 田中さん(95)と同じ最高得点
);

3. Streamの実装
アプローチ1： Collectors.toMap() を使う方法（推奨）
この方法は、キーの重複が発生した場合にどちらの値を保持するか（マージ関数）を指定できるtoMapの特性を活かします。非常に簡潔かつ効率的です。
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.BinaryOperator;
import java.util.function.Function;
import java.util.stream.Collectors;

// --- グルーピングのキーを定義 ---
// ageとclassをまとめるための複合キー用レコード
record GroupingKey(int age, String schoolClass) {}

// --- Stream処理の実行 ---
// toMapの戻り値は Map<GroupingKey, SchoolDto>
Map<GroupingKey, SchoolDto> maxScoreMap = schoolDtoList.stream()
    .collect(Collectors.toMap(
        // keyMapper: マップのキーを生成
        dto -> new GroupingKey(dto.age(), dto.schoolClass()),
        // valueMapper: マップの値を生成（DTOそのもの）
        Function.identity(),
        // mergeFunction: キーが重複した場合の解決策
        // 2つのDTOのうち、scoreが高い方を常に選択する
        BinaryOperator.maxBy(Comparator.comparing(SchoolDto::score))
    ));

// MapのValue（最高得点のSchoolDto）だけをリストに変換
List<SchoolDto> maxScoreSchoolDtoList = maxScoreMap.values().stream().toList();

// 最終的にMaxScoreDtoのリストに変換
List<MaxScoreDto> resultList = maxScoreSchoolDtoList.stream()
    .map(dto -> new MaxScoreDto(
        dto.age(),
        dto.schoolClass(),
        dto.score(),
        dto.name()
    ))
    .sorted(Comparator.comparing(MaxScoreDto::age).thenComparing(MaxScoreDto::schoolClass)) // 見やすくするためにソート
    .toList();

// --- 結果の出力 ---
resultList.forEach(System.out::println);

解説:
 * collect(Collectors.toMap(...)) を使ってMapを生成します。
 * keyMapper: dto -> new GroupingKey(dto.age(), dto.schoolClass())
   ageとclassをまとめた複合キーを生成します。
 * valueMapper: Function.identity()
   値としてはSchoolDtoオブジェクトそのものを保持します。
 * mergeFunction: BinaryOperator.maxBy(Comparator.comparing(SchoolDto::score))
   これがこのアプローチの核心です。同じキー（同じageとclass）を持つ要素が現れた場合、この関数が呼ばれます。maxByは2つの要素を比較し、scoreが大きい方を常に残します。
 * 結果として得られるMapの各Valueには、そのグループで最高得点を持つSchoolDtoが格納されています。
 * 最後に.values()でSchoolDtoのコレクションを取得し、必要に応じてMaxScoreDtoに変換します。
アプローチ2： Collectors.groupingBy() を使う方法
こちらは「グルーピング」という操作をより明示的に表現する方法です。
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;


// --- グルーピングのキーを定義 ---
record GroupingKey(int age, String schoolClass) {}

// --- Stream処理の実行 ---
Map<GroupingKey, Optional<SchoolDto>> groupedMap = schoolDtoList.stream()
    .collect(Collectors.groupingBy(
        dto -> new GroupingKey(dto.age(), dto.schoolClass()),
        // downstream collector: グループ内の要素をどう処理するか
        Collectors.maxBy(Comparator.comparing(SchoolDto::score))
    ));

// Mapの値（Optional<SchoolDto>）から値を取り出してリストに変換
List<MaxScoreDto> resultList2 = groupedMap.values().stream()
    .filter(Optional::isPresent) // 空のグループはないはずだが念のため
    .map(Optional::get)
    .map(dto -> new MaxScoreDto(
        dto.age(),
        dto.schoolClass(),
        dto.score(),
        dto.name()
    ))
    .sorted(Comparator.comparing(MaxScoreDto::age).thenComparing(MaxScoreDto::schoolClass)) // 見やすくするためにソート
    .toList();

// --- 結果の出力 ---
resultList2.forEach(System.out::println);

解説:
 * collect(Collectors.groupingBy(...)) を使います。
 * 第一引数でtoMapの時と同じように複合キーを指定します。
 * 第二引数（downstream collector）で、グループ化されたリストに対して何を行うかを指定します。ここではCollectors.maxBy(...)を使い、各グループ内のリストからscoreが最大の要素を見つけ出します。
 * maxByの結果はOptional<SchoolDto>となるため、Mapの型はMap<GroupingKey, Optional<SchoolDto>>となります。
 * 最後にMapから値を取り出し、Optionalをget()で展開してリストに変換します。
実行結果
どちらのアプローチでも、以下の同じ結果が得られます。（sortedでソートした場合）
MaxScoreDto[age=15, schoolClass=A, maxScore=92, topScorerName=鈴木]
MaxScoreDto[age=15, schoolClass=C, maxScore=91, topScorerName=渡辺]
MaxScoreDto[age=16, schoolClass=B, maxScore=95, topScorerName=田中]

> 最高得点が同点の場合（サンプルデータの田中さんと中村さん）、Streamの仕様上、どちらのSchoolDtoが残るかは保証されませんが、結果（最高得点95）は正しく得られます。
> 
まとめ
| アプローチ | メリット | デメリット |
|---|---|---|
| toMap | - コードがより簡潔で効率的。<br>- 中間生成されるMapがOptionalを保持しないため、後続処理がシンプル。 | - 「グルーピング」という意図がgroupingByほど直接的ではないかもしれない。 |
| groupingBy | - 「グルーピングしてから集計する」という処理の流れが非常に明確で直感的。 | - 中間生成されるMapがOptionalをラップするため、値を取り出す際に一手間必要になる。 |
基本的には、このような「グルーピングして最大値（または最小値）を持つ要素を見つける」という要件では、Collectors.toMap() を使う方法がより洗練されており、推奨されます。