🙃

【C#】Linqを書いてて感じていたこと&拡張メソッド紹介

に公開

私の人生経験においてLinqという考え方がどのような立ち位置にあるのか再確認するため、自問自答していきます。

一応頑張って4年ほどC#を勉強していたのにそれに関しての記事を書いたことが無かったので、今一度書いてみようかな~と。
私は独学プログラマーで、ちゃんとした勉強はしておらず「こんな感じかな~?」でやっています。
ゆるブログって感じですね。

最後の方では、仕事で使用していた拡張メソッドの紹介もしています。
もしよろしければ最後の方も読んでみてください。

ちなみにみなさんはLinqに憑りつかれて「すべての処理をLinqで書きたがる期」に陥ったことはありますか?
私はあります!

副作用について

最初に思いついたのが、これ

Linqの本質というより私の思考に合っていたという感じですかね?
というのもC#以前に学んでいたことが、並列プログラミング(Unityのシェーダー)なのでその思考に引っ張られています。
他のスレッドに干渉できないような独立した考え方に、戸惑いながらも惚れ込んでいたのですね。

Linqは副作用がないクリーンな処理だと思います。

元のデータを変更しないというのもありますが、遅延実行ができるという点がここで繋がってきますね。

ほぼ確実に副作用が無いと断言できて、そこに恩恵があります。
例えば、コードを読んでいて余計な心配がないとか(後述)、あとは並列化が容易になるとか

可読性について

その1:アルゴリズムの可視化

Linqは各アルゴリズムを最小化していき、それを再認識するのにとても良いと思っています。

各メソッドはとても単純な役割しかないので!
また各処理が独立していて簡潔ですよね。

var query = users
	.Where(u => u.IsActive) // ①アクティブなユーザーを絞り込み
	.OrderBy(u => u.LastName) // ②姓で並べ替え
	.Select(u => u.EmailAddress); // ③メールアドレスだけを取り出す

「こうして、ああして…」と手順を書くのではなく、「これが欲しい!」と宣言する感じ。
この思考の流れがそのままコードになるのが好きです。

その2:「問い合わせ」と「実行」の分離

次に、「コードを読んでいて余計な心配がない」という点。

私が業務でやっていたスタイルとして、「データを準備する部分」と「データを使って何かする部分」を明確に分けていました。

// 変数定義
var targetUsers = users
	.Where(u => u.Age >= 20 && u.Country == "Japan");
	
// 実行
foreach (var user in targetUsers) {
	user.Status = "Checked";
	Console.WriteLine(user.Name);
}

このように分けることで、
「あ、Linqの部分はデータを集めてるだけだな」
「お、foreachだ。ここで何か変更が加わるな」
と、瞬時にプログラムの役割を分類できます。

これもLinqが持つ副作用の無さから出来ることですね。

Linqはステータスを変えたり、コンソールを表示したりは出来ないので、必ず何らかのデータ群を取り寄せていることになります。
(できなくはないけど、やらないようにしよう!)

このような認知負荷の低減こそが可読性の向上につながると思います。

もし、これがごちゃ混ぜになっていると…

foreach (var user in users)
{
    if (user.Age >= 20 && user.Country == "Japan")
    {
        user.Status = "Checked";
        Console.WriteLine(user.Name);
    }
}

条件を変えたいだけなのに、ループの中の処理まで全部読まないといけなくて、ちょっと大変ですよね。
私がやっていたスタイルなら各役割が分担されているので、メンテナンス性も多少良いと思います。
(どっちも正解だと思うけど、Linqの方が良い理由はその3につながる)

その3:「ネスト」より「チェーン」という考え方

あとネストとチェーンの考え方の違いですよね。
for文で書くとネストを色々気にする必要がありますが、チェーンは上から読んでいくだけです。

例えば、
(あまり良くない例ではありますが、良い例が思いつかなかったので…)

foreach(var user in users){
	if(user.IsActive){
		// hoge1
	}
	// hoge2
}

のような処理では、
 hoge1はアクティブユーザーしか実行されないが、hoge2は全ユーザーが実行される…
みたいに、今自分がどのネスト階層にいるか、常に意識しないといけません。

Linqなら以下の通り。

foreach(var user in users
	.Where(user => user.IsActive)
	){
	// hoge1
}

foreach(var user in users){
	// hoge2
}

上の文でもforeachを2つに分ければ同じですが、こちらの場合は絶対分けなければ書けません。

この 「出来るけどやらない」のではなく、「構造上できない」という制限がコードを読むうえで楽になる と思います。
制限のおかげで前提条件が無くなり、認知負荷の低減につながります。
(switch文や三項演算子にも通じる考えだと思っている)

まあ今回の例では、一連の処理フローを重視するなら関数に抜き出す、もしくはLinqの例が良いかな?
ループ回数がN→2Nに増えているのであまり良くはないのですが…

結論は、ネストの変化が多いと頭が疲れるということですね!

全てLinqにすべきか?

長~いLinq

Linqに慣れ始めるとよくやりますよね、とても長いチェーンを書いて「これはすごいぞ!」と自己満足すること。

Linqで表現できたことはすごいけど、今一度立ち止まってみるべき。
大抵チェーンが3行か4行以上になると頭が追い付かなくなってしまうだろう。

プログラミングという行為は、データ群に対して名前を付ける行為と思っているので…
なので適宜変数名を定義していくと良いです。

// 悪い例:何をやっているか一読では理解しにくい
var topStudents = allCitizens
    .Where(c => c.Prefecture == "Tokyo" && c.Age >= 15 && c.Age <= 18)
    .GroupBy(c => c.School)
    .Select(g => new { SchoolName = g.Key, TopStudent = g.OrderByDescending(s => s.Score).First() })
    .OrderByDescending(x => x.TopStudent.Score)
    .Take(10);

// 良い例:変数名がドキュメントの役割を果たす(名前が良いかは置いといて…;;)
var tokyoHighSchoolStudents = allCitizens
	.Where(c => c.Prefecture == "Tokyo" && c.Age >= 15 && c.Age <= 18);
var studentsBySchool = tokyoHighSchoolStudents
	.GroupBy(c => c.School);
var topStudentOfEachSchool = studentsBySchool
	.Select(g => new {
	    SchoolName = g.Key,
	    TopStudent = g.OrderByDescending(s => s.Score).First()
	});
var top10StudentsOverall = topStudentOfEachSchool
	.OrderByDescending(x => x.TopStudent.Score)
	.Take(10);

まあ無駄な変数は作るべきではないという考えもあるのですが…
そういう意味でもLinqならどちらにも対応できますね!(投げやり)

インデックス操作はfor文で

じゃあ全部Linqで書けばええんか!バリバリ←そんなことはない

明確にLinqが苦手なケースというのは存在すると思います。
その1つがループカウンタライクな操作。

for(int i=2; i<n; i++){
	a[i] = b[i] + a[i-1] - a[i-2];
}

というような、配列のインデックスを操作した処理は、圧倒的にfor文の方が可読性が高いです。

余裕があるならfor文とLinqを比較するのもいいかもしれませんね。

デバッグ性

デバッグ性は多少困った記憶があります。
全ての処理が一行で行われるので途中経過が分かりませんね。

ウォッチウィンドウとにらめっこしたり、途中でコメントアウトしたり、ToArray()を挟んでみたり…色々やった記憶があります。

あれ?ウォッチウィンドウをつけたらその時点でLinqが実行された記憶も…?
なら変数をちゃんと分けておけば解決できますね!

まとめ:プログラミングの楽しみ方

「Linqはアルゴリズムを最小化して再認識するのによい」と言ったのですが、まさにそのロジックを明確にしていくことがプログラミングの楽しい所だと思っています。

またデータ構造を理解してそれに名前を付ける行為も醍醐味だと思っています。
それに対してデータ加工をすれば、もうそれがプログラムの意義でしょう!

Linqは、ループカウンタライクの処理ではなく、プログラマが「データそのもの」に集中することを可能にします。
これにより私たちはコードをよりアルゴリズムの思考の流れに沿って記述できるようになるでしょう。

私にとってLinqは、

  • 可読性を追求するものでもあり、
  • データ加工アルゴリズムを可視化するものであり、
  • プログラミングの根源的な楽しさを提供するもの

なんだとおもいます!

便利な拡張メソッドTips

ここからは私が仕事でも使用していた拡張メソッドを紹介していきます。

このように新しい機能を簡単に開発できるところもいいですよね~。
まあ単純なメソッドで構成するから良いのだろうという意見もありますが…

(無駄に機能を追加したメソッドがいくら開発されて、散っていったことか)

そういう意味では一応必要最低限の仕事をしてくれるメソッドたちです!

WhereIndex

「この条件に合うのは、何番目の要素?」 を知りたい時に。

ソースコード
public static IEnumerable<int> WhereIndex<T>(this IEnumerable<T> source, Func<T, bool> func)
{
	return source
		.Select((item, index) => new { item, index })
		.Where(group => func(group.item))
		.Select(group => group.index);
}
使用例
IEnumerable<int> result = new int[] { 0, 1, 2, 3, 2, 1, 0}
    .WhereIndex(num => num < 2);
// → { 0, 1, 5, 6 }

Whereメソッドの戻り値を、シーケンスの要素ではなくインデックス番号にしたものです。
https://learn.microsoft.com/ja-jp/dotnet/api/system.linq.enumerable.where?view=net-8.0

例えば、ボタン群が並んでいるときに、何番目が選択されたかを判定するのに使用します。
つまり、インデックス番号ベースで変数群を操作する時に使用します。

Button[] Buttons;
int[] Counts;

private void button_Click(object sender, EventArgs e)
{
    int index = Buttons
        .WhereIndex(bt => bt == (Button) sender)
        .First();

    Counts[index]++;
}

Convert

「intの配列をlongの配列にしたい」 みたいに、柔軟に型変換したい時に。

ソースコード
public static T Convert<T>(this object source)
{
	return (T) System.Convert.ChangeType(source, typeof(T));
}

public static IEnumerable<T> Converts<T>(this IEnumerable source)
{
	foreach (var item in source)
		yield return Convert<T>(item);
}
使用例
IEnumerable<long> result = new int[] { 0, 1, 2, 3 }
    .Converts<long>();
// → System.Int64[]

各要素を、指定した型に変換します。

CastメソッドやOfTypeメソッドとも違う動きをします。
型変換と聞いて一番しっくりくる動作だと思います。
https://learn.microsoft.com/ja-jp/dotnet/api/system.linq.enumerable.cast?view=net-8.0
https://learn.microsoft.com/ja-jp/dotnet/api/system.linq.enumerable.oftype?view=net-8.0

例えば、テキストボックス群に入っている文字列を数値に変換するのに使用します。

TextBox[] TextBoxs;
int[] Params;

private void Convert()
{
    Params = TextBoxs
        .Select(tb => tb.Text)
        .Converts<int>()
        .ToArray();
}

このような場合、文字列が空だったり数値に変換できない場合があるため、

  • try-chatch句を追加する
  • 例外時に初期値を返すConvertsメソッドの作成

などを行うと良いです。

Clamp

「数値を必ず0~100の間に収めたい」 みたいに、値を範囲内に丸めたい時に。

ソースコード
public static T Clamp<T>(this T num, T min, T max)
	where T : IComparable
{
	if (0 < min.CompareTo(max))
		throw new ArgumentOutOfRangeException("max", "max must be greater than min");

	if (num.CompareTo(min) < 0)
		return min;
  
	if (0 < num.CompareTo(max))
		return max;

	return num;
}

public static IEnumerable<T> Clamp<T>(this IEnumerable<T> source, T min, T max)
	where T : IComparable
{
	foreach (var num in source)
		yield return num.Clamp(min, max);
}
使用例
var result = new int[] { -1, 0, 1, 2, 3 }
    .Clamp(0, 2);
// → { 0, 0, 1, 2, 2 }

各要素を、指定した最小値と最大値の範囲に収めます。
最小値や最大値だけならありそうですが、案外このようなメソッドは存在しません。

仕様で変数値の範囲が決まっている場合に使用します。

Chunk

「配列を3つずつの塊に分けたい」 時に。
(.NET 6から標準搭載され、古い環境向けのコードらしいです!)

ソースコード
public static IEnumerable<IEnumerable<T>> Chunk<T>(this IEnumerable<T> source, int chunkSize)
{
	if (chunkSize < 1)
		throw new ArgumentException("chunkSize must be a positive integer");

	var chunk = new List<T>(chunkSize);

	foreach (var item in source)
	{
		chunk.Add(item);

		if (chunk.Count == chunkSize)
		{
			yield return chunk;
			chunk = new List<T>(chunkSize);
		}
	}

	if (0 < chunk.Count)
		yield return chunk;
}
使用例
IEnumerable<IEnumerable<long>> result = new long[] { 1, 2, 3, 4, 5, 6 }
    .Chunk(2);
// → { 1, 2 } { 3, 4 } { 5, 6 }

1次配列をN個区切りに分け、2次配列で返します。

使用例としては、

  • 配列数が通信容量を超えている
  • byte配列をint配列にバイナリ変換(Chunk(4))

などで使用しました。

ArrangeCount

「配列の要素数を、常に10個に揃えたい」 時に。
足りなければ埋め、多ければ切り捨てます。

ソースコード
public static IEnumerable<T> ArrangeCount<T>(this IEnumerable<T> source, int count, T empty)
{
	var list = source
		.Take(count)
		.ToList();

	for (var i = list.Count; i < count; i++)
		list.Add(empty);

	return list;
}
使用例
IEnumerable<int> result = new int[] { 1, 2, 3 }
    .ArrangeCount(5, 0);
// → { 1, 2, 3, 0, 0 }

IEnumerable<int> result = new int[] { 1, 2, 3, 4, 5 }
    .ArrangeCount(3, 0);
// → { 1, 2, 3 }

要素数を指定した個数にします。

このメソッドに頼るより仕様を詰めた方が良いと言われそうですが、案外あると便利です。
(仕様が自己で完結するのみとは限らないため...)


以上、ポエムと拡張メソッド紹介でした!
最後まで読んでいただきありがとうございました~

Discussion