[C#]GroupByメソッドで帳票出力処理をスッキリ書いてみる


この記事の目的

帳票出力で良くあるグループ毎に小計を出力する処理をGroupByメソッドなどを使って簡潔に書く方法を提示します。

特別な帳票出力ツールを使うのではなく、一般的な出力の流れを例にして可読性の高いコードの書き方を解説します。

課題

using System;
using System.Collections.Generic;
using System.Linq;

public class 売上 {
    public DateTime 売上日 {get; set;}
    public int 商品番号 {get; set;}
    public decimal 販売額 {get; set;}
}

List<売上> list = 最新の売上取得();

上記のような、売上の一覧データがあったとします。listは商品番号でソートされています。

これを、以下のように商品番号ごとの小計行を持つ帳票として出力するのが目的です。

商品番号:000001

商品番号 売上日 販売額
000001 2022/01/02 2,000
000001 2022/01/02 2,000
000001 2022/01/02 2,000
000001 2022/01/03 2,000
000001 2022/01/03 2,000
000001 2022/01/04 3,000
000001 2022/01/04 3,000
000001 2022/01/05 3,000
000001 2022/01/05 3,000
000001 2022/01/05 3,000
000001 2022/01/06 3,000
> 小計 28,000

商品番号:000002

商品番号 売上日 販売額
000002 2022/01/02 1,000
000002 2022/01/02 1,000
000002 2022/01/02 1,000
000002 2022/01/03 1,000
000002 2022/01/03 1,000
000002 2022/01/04 1,500
000002 2022/01/05 1,500
000002 2022/01/05 1,500
000002 2022/01/06 1,500
> 小計 11,000

 :

帳票出力用に以下の処理を定義してあります。

  • タイトル行出力(商品番号) : 商品番号だけの行と、タイトル行を出力する
  • 明細行出力(売上) : 1行分の明細行を出力する
  • 小計行出力(小計) : 小計行を出力する

手続き的(命令的)な書き方

経験豊富なプログラマーならば、各所でこのようなループ処理を見たことがあるでしょう。

売上一覧をループで探索し、商品番号が変わったタイミングでグループを切り替える方法です。
グループ内で小計を計算し、グループが変わった時にリセットします。そのタイミングで、小計行やタイトル行を出力します。

int groupKey = 0;	// 現在のグループキー(商品番号)
decimal groupTotal = 0m;	// 現在の小計

foreach ( 売上 uri in list )
{
	if ( groupKey != uri.商品番号 )
	{
		// 小計行の出力
		if ( groupKey != 0 )
		{
			小計行出力(groupTotal);
			groupTotal = 0;
		}
		
		// タイトル行の出力
		タイトル行出力(uri.商品番号);
		
		groupKey = uri.商品番号;
		
	}
	
	明細行出力(uri);
	
	groupTotal += uri.販売額;

}

小計行出力(groupTotal);

このパターンは一度覚えれば「ああなるほど」と理解できるものの、初めて見た場合、いったい何がどのような順序で出力されているのか非常に分かりにくいです。

また、小計行出力が途中と最後の2か所で呼ばれているのも気になります。

頭の中で「最終的にどのような出力になるのか」を想像するのも、慣れていないと一苦労でしょう。

GroupByを使った宣言的な書き方

LINQを利用して、次のように宣言的に書くことができます。

考え方としては以下のようになります。

  • 帳票をグループ単位で繰り返し出力する
  • 売上一覧を商品番号でグループ化する
  • グループをタイトル行+グループの明細+小計行で出力する
// グループ毎に帳票を出力する
foreach( var grp in list.GroupBy( uri => uri.商品番号 ))
{
	// タイトル行の出力
	タイトル行出力(grp.Key);
	
	// 明細行の出力
	foreach( 売上 uri in grp )
	{
		明細行出力(uri);
	}
	
	// 小計行の出力
	小計行出力(grp.Sum( uri => uri.販売額 ));
}

GroupByは、リストの内容について、引数に指定したラムダ式の結果が同じ値になる要素をグループ単位にまとめ、そのグループのリストを返してくれるメソッドです。

各グループ(grp)は、グループに含まれるリストをイテレータを持っているので、そのままforeachでループ処理できます。しかも、グループキーとなった値をgrp.Keyというプロパティに入れて返してくれます。超絶便利ですね。

このコードを見て、「どこが宣言的なんだ、foreachを使って副作用のある命令文が列挙してあるだけじゃないか」と思われる方もいるかもしれません。

しかし、よく見るとこれは帳票の出力イメージをそのままコードに落とし込んでいます。

手続き的に書かれたコードとの違いを見ればそれは明らかです。

「どうやるか」ではなく「何をするのか」をコードに落とし込むのが、宣言的なプログラミングです。

まとめ

手続き的な書き方と宣言的な書き方で、何が違うのでしょうか?

手続き的な書き方では、次のように先頭から順に考えていきます。

  • まず1件目を読み込む
  • 初めての商品番号だから、タイトル行を出力する
  • その後、明細行を出力する
  • 次の行に進む
  • 次の行はこれまでと同じ商品番号か?
  • 同じなら、また明細行を出力する
  • 違う商品番号になったなら、小計行を出力する
  • 小計行を出力する為には、商品番号が変わるまで販売額を合計していかなければならない
  • 商品番号が変わったら、改めてタイトル行を出力する
  • 上記をループの中で行う
  • 最後の商品番号は「変わったこと」を検知できないから、ループが終わったら改めて小計行を出力する

しかし、これを見て「何がしたいのか」を理解するのは難しいでしょう。

これに対して宣言的な書き方では、次のように「構造」を考えます。

  • 帳票は、売上一覧を「商品番号」でグループ化したものである
  • 帳票は、グループ単位で、タイトル行、明細一覧、小計行で構成される
  • 小計はグループの販売額の合計である

この記述レベルは「機能仕様書」に近くなります。やりたいことが明確にわかります。

「グループ化する」「グループの合計を取得する」は、LINQに用意されているので、いちいち詳細を書く必要がありません。

もう一度「宣言的な」コードを見直すと、コードが機能仕様のレベルで記述されていることが分かるでしょう。

// グループ毎に帳票を出力する
foreach( var grp in list.GroupBy( uri => uri.商品番号 ))
{
	// タイトル行の出力
	タイトル行出力(grp.Key);
	
	// 明細行の出力
	foreach( 売上 uri in grp )
	{
		明細行出力(uri);
	}
	
	// 小計行の出力
	小計行出力(grp.Sum( uri => uri.販売額 ));
}

宣言的な書き方をする為のコツは、「入力されるデータの構造」と「最終的に必要なデータの構造」を想像することです。

そして入力から出力へ、LINQのようなライブラリを用いて変換(写像の作成)を行います。

LINQはそのための様々な機能を持っています。

  • 絞り込み(Where)
  • 写像(Select, SelectMany)
  • グループ化(GroupBy)
  • 畳み込み・集計(Aggregate, Sum, Max, Min)
  • ソート(OrderBy)

最終的に必要なデータの構造に変換されたデータは、シンプルに意図を表現しやすいものになります。

これらを意識しつつ、読みやすく保守しやすいコードを書けるようにしたいですね。

サンプルコード

出力処理は適当ですが、大体の動きを確認できるよう、実際に動作するサンプルを以下に用意しました。
当記事の理解のお役に立てれば幸いです。

この帳票に「合計行」つまり、全ての商品番号の販売額の合計を出力したければどのように改造するか、動かしながら考えてみるのも面白いかもしれません。

それが出来たら、最初に提示した「命令的なプログラム」で同じことをした場合と比較してみましょう。

using System;
using System.Collections.Generic;
using System.Linq;

public class 売上 {
    public DateTime 売上日 {get; set;}
    public int 商品番号 {get; set;}
    public decimal 販売額 {get; set;}
}

public class Program
{
	public static void Main()
	{
		
		List<売上> list = new() {
			new() { 売上日 = DateTime.Parse("2022/01/01"), 商品番号 = 1, 販売額 = 1000 },
			new() { 売上日 = DateTime.Parse("2022/01/02"), 商品番号 = 1, 販売額 = 1000 },
			new() { 売上日 = DateTime.Parse("2022/01/03"), 商品番号 = 1, 販売額 = 1000 },
			new() { 売上日 = DateTime.Parse("2022/01/04"), 商品番号 = 2, 販売額 = 2000 },
			new() { 売上日 = DateTime.Parse("2022/01/05"), 商品番号 = 2, 販売額 = 2000 },
		};
		
		foreach( var grp in list.GroupBy( uri => uri.商品番号 ))
		{
			// タイトル行の出力
			タイトル行出力(grp.Key);

			// 明細行の出力
			foreach( 売上 uri in grp )
			{
				明細行出力(uri);
			}

			// 小計行の出力
			小計行出力(grp.Sum( uri => uri.販売額 ));
		}
		
	}
	
	public static void タイトル行出力(int a){
		Console.WriteLine( $"----------------------------------" );
		Console.WriteLine( $"商品番号:{a}" );
	}
	
	public static void 明細行出力(売上 uri){
		Console.WriteLine( $"商品番号:{uri.商品番号}, 売上日:{uri.売上日}, 販売額:{uri.販売額}" );
	}
	
	public static void 小計行出力(decimal total){
		Console.WriteLine( $"小計 = {total}" );
	}
	
}