データ型のメンバーが増える度にあちこち修正しないためのLINQ
データ型のコンストラクタを呼ぶとき・データ型のメンバーにアクセスするとき、繰り返し同じコードが現れるとメンバー名の打ち間違えを招きやすく、そこにコードの臭いを感じ取るときがあります。
LINQを使った方法で問題を抑制する方法について考察したので、ここに共有したいと思います。
🥮動機づけ
架空のRPGで、キャラクターは 火・水・土・風 の攻撃に対して属性耐性の個性を持っており、ダメージの計算にその値が使われます。これをジェネリックな Tolerance<T> 型で表します。
ここでは、 int, float, (UI要素の型) が型引数としてあり得る以下のコードについて考えます(ぼくは普段RPGのプログラムを書いているので、こういう場面によく遭遇します)。
Tolerance<int> _tolerance;
Tolerance<UiLabel> _uiLabels;
void Apply(Tolerance<float> rate)
{
// float -> int
_tolerance = new Tolerance<int>(
(int)(_tolerance.Fire * rate.Fire),
(int)(_tolerance.Water * rate.Water),
(int)(_tolerance.Earth * rate.Earth),
(int)(_tolerance.Wind * rate.Wind)
);
// int -> 代入
_uiLabels.Fire.Text = _tolerance.Fire.ToString();
_uiLabels.Water.Text = _tolerance.Water.ToString();
_uiLabels.Earth.Text = _tolerance.Earth.ToString();
_uiLabels.Wind.Text = _tolerance.Wind.ToString();
}
record Tolerance<T>(T Fire, T Water, T Earth, T Wind);
問題点
主な問題点は変更の分散にあります:
- データが増えるときは、データを使用するコードを修正しなければいけない。
-
float -> intとint -> 代入すべて -
DarkとかLightが増えたら、修正箇所は8箇所。データの数が2増加 × 2種類の用途 × 各使用で2回参照
-
- 使用方法を変更するときは、全てのプロパティについて修正しなければいけない。
-
Fire,Water,Earth,Windすべて -
ToStringにフォーマットを指定するよう修正するときには、修正箇所は4(データの数)箇所。
-
- 修正するときに、プロパティの順番を誤って書く危険性がある。
- この例では順番を誤っても型が同じであるためコンパイル時にエラーが起きないのが危険です。
データ数 × 使用数
フィールド
しかし各フィールドについて一様なアクセスがしたいのだから、このスケールは
メソッドの抽出?
アクセス処理をメソッド抽出することで Apply メソッドだけは読みやすくできますが、この調子では
🥮回答例
データ型のメンバーが追加・削除されても、その利用側は同じ種類の処理なら一様に適用したいとき、私はそのデータ型を 💠形状(Shape) と呼びます。
Zip
💠形状 を見出したら、LINQ 的なメソッドを作成して処理を共通にします。ここでは Zip メソッドを実装して、2つの異なる Tolerance<T> どうしを組み合わせ、新たな Tolerance<TResult> を作成できるようにします。
Tolerance<int> _tolerance;
Tolerance<UiLabel> _uiLabels;
void Apply(Tolerance<float> rate)
{
// Zip で、 Tolerance<int> と Tolerance<float> を合成できる
_tolerance = _tolerance.Zip(
rate,
(x, r) => (int)(x * r));
// Zip で、 Tolerance<int> と Tolerance<UiLabel> を対応付けて処理できる
// 戻り値は使わないスタイルでOK
var _ = _tolerance.Zip(
_uiLabels,
(x, label) => label.Text = x.ToString());
}
record Tolerance<T>(T Fire, T Water, T Earth, T Wind)
{
// IEnumerable に対する Zip の Tolerance 版
public Tolerance<TResult> Zip<TOther, TResult>(
Tolerance<TOther> other,
Func<T, TOther, TResult> selector)
{
// 責任を持ってここで順序を保証する
return new Tolerance<TResult>(
selector(Fire, other.Fire),
selector(Water, other.Water),
selector(Earth, other.Earth),
selector(Wind, other.Wind));
}
}
Zip が担うため、
Select
順序を保証してほしいのは 💠形状 を合成したいときだけではありません。💠形状 を射影して新しいオブジェクトを作成したい場合は、 Select を実装します。
public Tolerance<TResult> Select<TResult>(Func<T, TResult> selector)
{
// 責任を持って順序を保証する
return new Tolerance<TResult>(
selector(Fire),
selector(Water),
selector(Earth),
selector(Wind));
}
ForEach
データごとに一様に副作用を起こす処理をしたい場合もあります。 foreach 文を使う代わりに、メソッドチェーンで使えるメソッドを提供する手もあります。
public void ForEach(Action<T> action)
{
// 責任を持って順序を保証する
action(Fire);
action(Water);
action(Earth);
action(Wind);
}
順序を保証する箇所をまとめる
Select, Zip, ForEach それぞれのメソッドが順序の保証を担っているため、データの順序が変わるとその影響はこれらのLINQメソッド全てに及びます。再度現れた変更の分散を抑えるために、順序を保証する役割を持つメンバーを定義できないものでしょうか。
今回は2つのメンバーを追加して、LINQメソッドの実装で順序の保証を負担せずに済ませるために使います。
-
Values: データが毎回同じ順序で並ぶコレクションを返すプロパティ -
FromEnumerable:Valuesから派生した値を基にデータ型を復元する
class Tolerance<T>(T Fire, T Water, T Earth, T Wind)
{
private IEnumerable<T> Values => [Fire, Water, Earth, Wind];
// コレクションから型を復元するための静的メソッド
private static Tolerance<T> FromEnumerable(IReadOnlyList<T> values)
{
return values is not [var fire, var water, var earth, var wind]
? throw new ArgumentException()
: new Tolerance<T>(fire, water, earth, wind);
}
// 上記の機能を使った Select, Zip, ForEach の実装
public Tolerance<TResult> Select<TResult>(Func<T, TResult> selector)
{
return Tolerance<TResult>.FromEnumerable(
Values.Select(selector).ToList());
}
public Tolerance<TResult> Zip<TOther, TResult>(
Tolerance<TOther> other,
Func<T, TOther, TResult> selector)
{
return Tolerance<TResult>.FromEnumerable(
Values.Zip(other.Values, selector).ToList());
}
public void ForEach(Action<T> action)
{
foreach (var value in Values)
{
action(value);
}
}
}
Values と FromEnumerable は public にすべきではないでしょう。Values で得られたコレクションは要素ごとにかつてあった名前を失っており、将来データの順番が入れ替わった場合にプログラムの挙動が変わってしまいます。そして FromEnumerable に配列でデータを入力する場合も、将来データの順番が入れ替わると利用側の思ったようには初期化できません。
アイデアとして紹介はしましたが、個人的には Zip, Select, ForEach が各々で順序の保証を担っても良いかなと思います。メソッドが増えていく場合はこの方法が役立ちますが、これから実際に増えることがあるのかは個人的に疑問に思っています。
形状の性質を使うために
私はデータが増減する度にその使い方を修正する必要に迫られるのを嫌うので、様々な手を使って型を 💠形状 に近づけるのを好みます。例えば:
- 使い方が一様でないデータを別の型に分割する。
- ジェネリック型を使って、使い方が一様だけれどメンバーの型だけが異なるデータ型の群れをまとめる。
- このとき特定の型引数のときだけ有効なメソッドは、拡張メソッドとして追い出す(これの手順についても紹介する価値がありますが、今回は省略)。
こうして 💠形状 を見出せば、 Zip の他にもこれから紹介する Select, ForEach のようなメソッドを活用できます。
🥮複雑な例
ここで設計にストレスを与えるため、新たな属性である「毒属性耐性」への特別な計算をAPIに要求してみようと思います。
新登場の毒属性攻撃はダメージを与えるだけでなく、キャラクターの免疫力を活性化したり、逆に削ったりします。つまり、毒属性でダメージを受ける度に耐性の値が変化するということで、これは配列で表現できそうです。
record Tolerance<T>(T Fire, T Water, T Earth, T Wind, T[] Poison)
{
// times 回目に毒属性攻撃を受けたときの、ダメージ計算用の耐性値を得る
public T GetPoisonTolerance(int times)
=> Poison[Math.Min(times, Poison.Length - 1)];
}
Tolerance<T> は今まで Vector4 のようなデータ構造と大差がありませんでしたが、配列が混ざったことでベクトルの視点では長さが可変長になったように見えます。それでもなお Select, Zip, ForEach のようなメソッドを設計することはできるのでしょうか?
私の見解では必ずしも定義はできません。仕様から何が要求されているかをチェックし、実装すべき操作は何か見極めて実装しましょう。
仕様を決めて、必要なものだけ実装
今回はこんな仕様に決めます: Tolerance<T> を使用するときは、 Poison 配列内の全要素も他のデータと一様に使われることが期待されており、配列の構造は要素の順番さえ変わらなければ構いません。
例えば、 Poison 配列のサイズが3のときは、 Tolerance<T> は7次元ベクトルのようなイメージで操作すべきです。操作が一様なので、恐らく 💠形状 と呼べるでしょう。
この仮定のもとで、 Select の実装は以下のようにできそうです。
record Tolerance<T>(T Fire, T Water, T Earth, T Wind, T[] Poison)
{
public Tolerance<TResult> Select<TResult>(Func<T, TResult> selector)
=> new Tolerance<TResult>(
selector(Fire),
selector(Water),
selector(Earth),
selector(Wind),
Poison.Select(selector).ToArray());
}
Zip, ForEach は以下のような実装になります。
record Tolerance<T>(T Fire, T Water, T Earth, T Wind, T[] Poison)
{
// 2つの Tolerance の Poison 配列の長さが一致していない場合は、
// Zip オペレータの慣習に従って長さが短いほうに揃えられるので注意
public Tolerance<TResult> Zip<TOther, TResult>(
Tolerance<TOther> other,
Func<T, TOther, TResult> selector)
=> new Tolerance<TResult>(
selector(Fire, other.Fire),
selector(Water, other.Water),
selector(Earth, other.Earth),
selector(Wind, other.Wind),
Poison.Zip(other.Poison, selector).ToArray());
public void ForEach(Action<T> action)
{
action(Fire);
action(Water);
action(Earth);
action(Wind);
foreach (var value in Poison)
{
action(value);
}
}
}
🥮おわりに
💠形状 に対するLINQは、操作対象の内容1つごとに意味のある名前や順番があるところが IEnumerable<T> に対する標準LINQと異なっています。
💠形状(Shape) という考え方自体は、C#言語仕様で議論中の Shapes and Extensions に着想を得ているので、 Functor とか Applicative とかの文脈では既に議論されつくした話題のように思うのですが、あまり詳しくないので文献を見つけられず… 詳しい人がいたら教えてください。
🥮参考文献
- Exploration: Shapes and Extensions, dotnet
- Functorに触れてみよう, Izawa_
- FunctorとApplicative、Monadを理解する, 日本インサイトテクノロジー株式会社
-
リファクタリング 第2版, Martin Fowler
- リファクタリングに言及するときは必ず参照しています。Code Smells に載っているコードの臭いの日本語訳に、この本の訳を採用しています。
-
Code Smells, Luzkan
- コードの臭いについて同僚と認識を合わせるためにいつも役立っています。
-
Span<T>を使うべき5つの理由, aka-nse
- 話題には関係ないが、
FromEnumerableを実装するときにSpanを使うかどうか悩んだときに助けになった。
- 話題には関係ないが、
Discussion