初めての方は初めまして。以前からご覧頂いている方はお久しぶり。nqdiorことDELLです。
[C# その2 Advent Calendar 2019]-23日目の投稿です。
今回はもはやC#になくてはならない相棒「Linq」を最大限活用するにはどう用いるか、という自分なりの答えのひとつ(令和1stジェネレーション)を書いてみたいと思います。関数型プログラミングの定義は広義ですので、今回は一旦「高階関数(Linq)を使うプログラミング」という前提でお話します。
1. どんな目的のプログラム?
データベーステーブルからデータをインプットし、縦横結合・検索・集計を60ステップほど行って、50近いフィールドを持つテーブルにアウトプットするプログラムを書いていました。
C#上でのINPUTとOUTPUTのイメージは以下の通り。
どちらもデータの持ち方はIEnumerable<T>(T...ClassType)
です。
- INPUT ... オブジェクトにマッピングしたデータ(ORマッパーはDapperを採用)
- OUTPUT ... 出力データが格納されたテーブル書き込み用のオブジェクト
構築にあたり自分が目指したのは、以下の3点です。
- (当然ですが)出力結果が正しいこと
- IOを除くメモリ上での処理をできるだけ0secに近付けること
- 長期運用される恐れを考慮し、メンテナンスしやすいコードを書くこと
2. なにが課題なの?
普通にコードを組むならば、以下の3点に集約されると思います。
- Linqを用いて検索や集計を行う
- 面倒な処理はループさせた中でゴリゴリ記述
- 処理単位で目的ごとにクラス・メソッドを切り分け
ただ、この方法だと以下の課題があると考えていました。
- 各クラスでオブジェクトのプロパティに対して変更を行うため、どこのクラスがどのプロパティを担当しているか不明瞭
- 高階関数(Linq)と通常関数(foreach)が混じってしまうため、foreachの中でLinqを使用した日にはn*nのループが発生
- メンテナンスによってクラス・メソッドが肥大化
3. それでどうしたの?
1. オブジェクト側
複数ステップのまとまりごとに出力結果を保持する中間エンティティを作成しました。
マッピングオブジェクト → [中間エンティティ(複数)] → 結果オブジェクト
中間エンティティに作成するプロパティは以下の2種類です。
1. 入力側プロパティ ... 出力側の値算出に必要な、入力側のプロパティ。readonly属性を付与し、コンストラクタで値設定。
2. 出力用プロパティ ... 入力側になく、出力側にのみある項目のプロパティ。getterのみ保持。
出力用にプロパティ(フィールド)に関しては、ほぼすべてカプセル化を行い、入力側プロパティが変更されたら自動的にgetter側で出力用フィールドの値が書き換わるように実装の隠蔽を行います。
2. コード側
上記オブジェクトに対してLinq側でUNION, JOIN, Where, GroupByを行い、入力側の値を変更します。
出力側の値をLinqの射影(Select)で、次ステップの中間エンティティの入力側にマッピングします。
各プロパティの設定に必要な細かい計算は、全てオブジェクト側に委任します。
3. イメージ図
※図では処理ごとに中間エンティティを持っていますが、複数処理を跨いで中間エンティティを触っても問題ありません。
4. なにが嬉しい?
- オブジェクトそのものがカプセル化された状態となる
- 実装の隠蔽を用いたオブジェクト指向のメリットが使える
- Linqの射影を用いて一括で入力側プロパティを設定し、必要なだけ出力側プロパティが計算される遅延評価と相性がいい
- コードの総量が相当に減り、可読性が向上する
つまり、関数型プログラミングでオブジェクト指向のオブジェクトを用いるという感じでしょうか。
5. 実装例見せてよ
商品購入履歴と各割引率が入力されたとき、顧客ごとの税込請求金額合計を求めるコードを書きました。
オブジェクト側は入力値が設定されたら割引後価格が算出され、割引後価格が算出されたら税込み価格が算出されます。
入力値によって各プロパティが連鎖的に自動計算される形です。オブジェクト指向のカプセル化ですねー。
コード側は、計算クラス見てもらって分かる通り、処理単位であるLinq単位で1メソッドとなっています。「関数型ぽい」ですね!
1. 入出力管理クラス
namespace ConsoleApp2
{
// 入力用クラス
internal class InputClass
{
internal int CustomerID { get; set; }
internal long ProductID { get; set; }
internal double DiscountRate { get; set; }
}
// 出力用クラス
internal class OutputClass
{
internal int CustomerID { get; set; }
internal int TotalPrice { get; set; }
}
}
2. 商品マスタクラス
using System.Collections.Generic;
namespace ConsoleApp2
{
// 商品クラス
internal class Product
{
internal long ProductID { get; set; }
internal int Price { get; set; }
}
// 商品マスタ
internal class Products : List<Product>
{
internal Products()
{
Add(new Product() { ProductID = 10000000001, Price = 100 });
Add(new Product() { ProductID = 10000000002, Price = 200 });
Add(new Product() { ProductID = 10000000003, Price = 300 });
Add(new Product() { ProductID = 20000000004, Price = 1000 });
Add(new Product() { ProductID = 20000000005, Price = 2000 });
Add(new Product() { ProductID = 20000000006, Price = 3000 });
}
}
}
3. スタートアップオブジェクト
using System.Collections.Generic;
using System.Linq;
namespace ConsoleApp2
{
class Program
{
static void Main(string[] args)
{
// 入力
var inputs = getData();
// 出力
var outputs = new Calculator().Calculation(inputs);
}
// DBからデータを取得。今回は手打ち。
static IEnumerable<InputClass> getData()
{
var input = new List<InputClass>();
input.Add(new InputClass { CustomerID = 1, ProductID = 10000000001, DiscountRate = 0.88 });
input.Add(new InputClass { CustomerID = 1, ProductID = 10000000002, DiscountRate = 0.19 });
input.Add(new InputClass { CustomerID = 1, ProductID = 10000000003, DiscountRate = 0.52 });
input.Add(new InputClass { CustomerID = 2, ProductID = 20000000004, DiscountRate = 0.33 });
input.Add(new InputClass { CustomerID = 2, ProductID = 20000000005, DiscountRate = 0.56 });
input.Add(new InputClass { CustomerID = 3, ProductID = 20000000006, DiscountRate = 0.75 });
return input;
}
}
// 計算クラス
internal class Calculator
{
// 計算を実施する。
internal IEnumerable<OutputClass> Calculation(IEnumerable<InputClass> inputs)
{
// 商品マスタの取得
var products = new Products();
// 中間エンティティを用いて1レコードごとの価格を計算する。
var middles = JoinAndCalcProductPrice(inputs, products);
// 顧客ごとの集計と同時に結果オブジェクトに変換する。
var results = SumPriceAndConvertToOutputs(middles);
return results;
}
// 商品マスタから価格を取得する。計算はオブジェクト側で行う。
privete IEnumerable<MiddleClass> JoinAndCalcProductPrice(IEnumerable<InputClass> inputs, IEnumerable<Product> products) => inputs
.Join(
products,
i => i.ProductID,
p => p.ProductID,
(input, product) => new MiddleClass
{
CustomerID = input.CustomerID,
DiscountRate = input.DiscountRate,
Price = product.Price
}
);
// 顧客ごとの金額合計を取得して出力クラスにマッピングする。
private IEnumerable<OutputClass> SumPriceAndConvertToOutputs(IEnumerable<MiddleClass> middles) => middles
.GroupBy(
c => c.CustomerID
)
.Select(c => new OutputClass
{
CustomerID = c.Key,
TotalPrice = c.Sum(s => s.Price)
});
}
}
4. 中間クラス
namespace ConsoleApp2
{
internal class MiddleClass
{
// 税率
internal const double _tax = 0.1;
// 入力側プロパティ ▽
internal int CustomerID;
internal int Price;
internal double DiscountRate;
// 出力用プロパティ ▽
// 入力値が設定されていれば参照時にこの値は決定する。大きいプログラムだとこのプロパティが増えていく。
// 後々使う可能性があるので割引後価格は単独で持つ。
private int DiscountPrice => Price - (int)(Price * DiscountRate);
// 計算結果を持つプロパティ。入力が変われば割引後価格が変わり、計算結果も連動して変わる。
internal int CalculatedPrice => DiscountPrice + (int)(discountPrice * _tax);
}
}
6. おわりに
プログラムを長期メンテナンスしていくことが想定される場合は、いかに短いコード量で把握しやすいようシンプルに書けるか。
また、カプセル化による切り分けで楽にメンテナンスできるかを想定して書くことが必要になるかと思います。
今回のコードが完成形とは思っていませんが、ひとつの着地点として引き続き考えていきたいと思います。
ご意見ご感想等あればお気軽にコメントお願い致します!
7. 参考
平凡なプログラマにとっての関数型プログラミング - anopara
LINQでの外部結合の方法が載っています。ぜひご参照ください。
LINQでの内部結合・外部結合