124
85

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

C#Advent Calendar 2021

Day 1

.NET 6でLINQに追加されたメソッド

Last updated at Posted at 2021-11-30

.NET 6において、LINQにいくつかのメソッドが追加されました。また、既存のLINQメソッドに新たなオーバーロードが追加されました。非常に便利なメソッド、「一見するとこれは便利なのか?」と疑問が浮かぶけれど実は活躍するメソッドなど、様々なメソッドがあります。

この投稿では、そんな.NET 6において追加されたLINQメソッドを紹介します。

指定した条件での最大・最小を探す「MinBy、MaxBy」

このようなPlayer型と

record Player(int level, string name);

Player型の配列、playersがあります。

var players = new [] {
    new Player(level : 10, name : "taro"),
    new Player(level : 8, name : "jiro"),
    new Player(level : 5, name : "saburo"),
    new Player(level : 10, name : "shiro"),
};

playersの中から、一番levelが高いPlayer要素を求めるにはどうすればいいでしょうか?もともとLINQにはMaxというメソッドがあります。残念ながら、Maxでは「playersの中の最大レベル」を求める事はできても、「最大レベルを持つ要素」を求めることができません。

// maxLevelは10となる
// 「playersの中の最大レベル」は求められた
// けれど、「最大レベルを持つ要素」は求められない
var maxLevel = players.Max(p => p.level);

このような場合、.NET 6において追加されたMaxByメソッドが活躍します。MaxByメソッドは、IEnumerable<T>の要素の中から指定した条件での最大要素を求めることができるメソッドです。このMaxByメソッドを使うことで、次のようにplayers中から最大レベルを持つ要素を簡単に見つけることができます。

// maxLevelPlayerはPlayer型で、
// Player { level = 10, name = taro }
var maxLevelPlayer = players.MaxBy(p => p.level);

このように、IEnumerable<T>の要素の中から指定した条件での最大要素を求めたい場合はMaxByが、最小要素を求めたい場合はMinByが活躍します。

IEnumerable<T>のMaxByとMinByの返値型が、T型で、IReadOnlyList<T>T[]型でないことに注目してください。「指定した条件での最大」を満たす要素が複数存在した場合、複数個の要素は返しません。最大となる最初の要素を返します。

// level 10が二人いる。taroとshiro
var players = new [] {
    new Player(level : 10, name : "taro"),
    new Player(level : 8, name : "jiro"),
    new Player(level : 5, name : "saburo"),
    new Player(level : 10, name : "shiro"),
};

// maxLevelPlayerはPlayer { level = 10, name = taro }
// Player { level = 10, name = shiro }ではないことに注意
var maxLevelPlayer = players.MaxBy(p => p.level);

また、IEnumerable<T>の要素が空だった場合のMaxBy・MinByの挙動にも注意してください。

  • T型が参照型:nullが帰ってくる
  • T型が値型:InvalidOperationExceptionが投げられる

IEnumerable<T>が参照型で、全ての要素がnullな場合もnullが帰ってきます。

3つのIEnumerable<T>もまとめて「Zip」

2つのIEnumerable<T>を同じインデックスごとにまとめて、1つのIEnumerable<T>にしたい場合があります。こんな時に活躍するのがZipメソッドです。

Zipメソッド(のオーバーロードの一つ)は、次の例のように2つのIEnumerable<T>を同じインデックスの要素ごとValueTuple型としてまとめてくれます。

var descriptions = new string[]{
    "すごい",
    "まぁまぁ",
    "がんばれ"
};

var values = new []{
   200,
   100,
   20,
   5,
   2,
};

// 要素はValueTuple<int, string>型
// 要素長は一番短いdescriptionsの長さに合わせて3となる
IEnumerable<ValueTuple<int, string>> zipped = values.Zip(descriptions);

// 次のように表示される
// (200, すごい)
// (100, まぁまぁ)
// (20, がんばれ)
foreach(ValueTuple<int, string> valueTuple in zipped) {
    Console.WriteLine(valueTuple);
}

// resultSelectorを渡すオーバーロードもある
// resultSelectorは、IEnumerable<T>`の要素を、どのように合成するか指定するデリゲート
// IEnumerable<string> valuesAndKeys3 = values.Zip(descriptions, (v, d) => $"value: {v}, description:{d}");

今までZipメソッドには、2つのIEnumerable<T>をまとめるオーバーロードしかありませんでした。.NET 6から、3個のIEnumerable<T>をまとめることができるオーバーロードが加わりました。

var descriptions = new string[]{
    "すごい",
    "まぁまぁ",
    "がんばれ"
};

var values = new []{
   200,
   100,
   20,
   5,
   2,
};

var keys = new []{
   "RankA",
   "RankB",
   "RankC",
   "RankD",
};

// 要素はValueTuple<int, string, string>型
// 要素長は一番短いdescriptionsの長さに合わせて3となる
IEnumerable<ValueTuple<int, string, string>> zipped = values.Zip(descriptions, descriptions);

// 次のように表示される
// (200, すごい, RankA)
// (100, まぁまぁ, RankB)
// (20, がんばれ, RankC)
foreach(ValueTuple<int, string, string> valueTuple in zipped) {
    Console.WriteLine(valueTuple);
}

なお、こちらのGitHub issueのコメントによると

  • 3個までIEnumerable<T>をまとめるオーバーロードまでをサポート、4個以上のIEnumerable<T>をまとめるはオーバーロードはサポートしない
  • resultSelectorを渡すオーバーロードに、3個のIEnumerable<T>まとめるオーバーロードを追加しない

とのことです。

デフォルトを指定可能な「FirstOrDefault、LastOrDefault、SingleOrDefault」

このようなPosition型があります。

record Position(int X, int Y) { }

次のコードは、Position型とFirstOrDefultメソッドの利用例です。

// 現在のPositionを読み込んで・・・
Position currentPosition = LoadCurrentPosition();

// Positionの配列を読み込んで・・・
Position[] positions = LoadPositions();

// デフォルトのポジションを読み込んで・・・
Position defaultTargetPosition = LoadDefualtPosition()

// positionsの中から、一番最初の近い(IsNearがtrueを返す)要素を返す
// もしpositionsの中に近い(IsNearがtrueを返す)要素がなかったら、デフォルトのポジションとする
Position targetPosition = positions
    .FirstOrDefault(p => IsNear(p, currentPosition)) ?? defaultTargetPosition; 

FirstOrDefaultメソッドは、条件を満たす要素が存在しなかった場合、型の規定値を返します。Positionは参照型なので、上のコードではFirstOrDefaultメソッドはnullを返します。上のコードでは??演算子を使い、nullであればLoadDefualtPositionの結果を、targetPositionに代入します。

.NET 6から

  • FirstOrDefault
  • LastOrDefault
  • SingleOrDefault

メソッドに、要素が空だった・満たす要素がなかった場合の規定値を指定するオーバーロードが、それぞれ加わりました。このオーバーロードを使って、先のコードを書き換えると次のようになります。

// 現在のPositionを読み込んで・・・
Position currentPosition = LoadCurrentPosition();

// Positionの配列を読み込んで・・・
Position[] positions = LoadPositions();

// デフォルトのポジションを読み込んで・・・
Position defaultTargetPosition = LoadDefualtPosition()

// positionsの中から、一番最初の近い(IsNearがtrueを返す)要素を返す
// もしpositionsの中に近い(IsNearがtrueを返す)要素がなかったら、引数に指定したdefaultTargetPositionを返す
// .NET 6から加わったオーバーロード
Position targetPosition = positions
    .FirstOrDefault(p => IsNear(p, currentPosition), defaultTargetPosition);

集合演算も指定した条件で「DistinctBy, ExceptBy, IntersectBy, UnionBy」

LINQにはIEnumerable<T>の重複要素を排除するDistinctというメソッドがあります。

IEnumerable<string> ids = new [] {
    "aaa",
    "bbb",
    "aaa", 
    "ccc",
    "ddd",
    "aaa", 
    "ccc",
    "aaa",
    "aaa"
};

// 次のように表示される
// aaa
// ccc
// bbb
// ddd
foreach(var id in ids.Distinct()) {
    Console.WriteLine(id);
}

.NET 6から、このDistinctに似たDistinctByというメソッドが追加されました。DistinctByを使うことで、重複比較する方法を指定できます。次のようなStatus型とLog型を使って、DistinctByの例を示します。

enum Status {
    Pending,
    Failure,
    Success,
}

record Log(String id, Status status) { }

実行結果を見ると重複と判定された要素のうち、先の要素は残り、後の要素が排除されていることに注目してください。

var logs = new [] {
   new Log("aaa", Status.Pending),
   new Log("bbb", Status.Pending),
   new Log("ccc", Status.Pending),
   new Log("ddd", Status.Pending),
   new Log("aaa", Status.Success),
   new Log("aaa", Status.Pending),
   new Log("aaa", Status.Success),
   new Log("ccc", Status.Success),
   new Log("aaa", Status.Failure),
};

// Logのidが同じ要素を重複排除
IEnumerable<Log> distinctBy = logs.DistinctBy<Log, string>(log => log.id);

// 次のように表示される
// Log { id = aaa, status = Pending }
// Log { id = bbb, status = Pending }
// Log { id = ccc, status = Pending }
// Log { id = ddd, status = Pending }
foreach(var log in distinctBy) {
    Console.WriteLine(log);
}

またLINQにはIEnumerable<T>の差集合をもとめる「Except」、積集合をもとめる「Intersect」、和集合をもとめる「Union」というメソッドがあります。.NET 6でこれらとよく似た「ExceptBy」、「IntersectBy」、「UnionBy」というメソッドが追加されました。それぞれの利用例を示します。

var logs = new [] {
   new Log("aaa", Status.Pending),
   new Log("bbb", Status.Pending),
   new Log("ccc", Status.Pending),
   new Log("ddd", Status.Pending),
   new Log("aaa", Status.Success),
   new Log("aaa", Status.Pending),
   new Log("aaa", Status.Success),
   new Log("ccc", Status.Success),
   new Log("aaa", Status.Failure),
};

var anotherLogs = new [] {
   new Log("ddd", Status.Success),
   new Log("eee", Status.Success),
   new Log("fff", Status.Failure),
};

Console.WriteLine("--ExceptBy--");
// logsの要素から、idが"aaa"と"bbb"を除いた差集合を求める
IEnumerable<Log> exceptBy = logs.ExceptBy<Log, string>(new []{"aaa", "bbb"}, a => a.id);
// 次のように表示される
// Log { id = ccc, status = Pending }
// Log { id = ddd, status = Pending }
foreach(var log in exceptBy) {
    Console.WriteLine(log);
}

Console.WriteLine("--IntersectBy--");
// logsの要素から、idが"aaa"と"bbb"と交差する積集合を求める
IEnumerable<Log> intersectBy = logs.IntersectBy<Log, string>(new []{"aaa", "bbb"}, a => a.id);
// 次のように表示される
// Log { id = aaa, status = Pending }
// Log { id = bbb, status = Pending }
foreach(var log in intersectBy) {
    Console.WriteLine(log);
}


Console.WriteLine("--UnionBy--");
// logsとanotherLogsのid比較による和集合を求める
IEnumerable<Log> unionBy = logs.UnionBy<Log, string>(anotherLogs, a => a.id);
// 次のように表示される
// Log { id = aaa, status = Pending }
// Log { id = bbb, status = Pending }
// Log { id = ccc, status = Pending }
// Log { id = ddd, status = Pending }
// Log { id = eee, status = Success }
// Log { id = fff, status = Failure }
foreach(var log in unionBy) {
    Console.WriteLine(log);
}

指定した数ごとにまとめる 「Chunk」

IEnumerable<T>を指定した要素ごとにまとめる「Chunk」メソッドが加わりました。結構需要が高いやつ!

指定できるのは「まとめる数」だけがです。
Rxやコレクションライブラリなどでよくある「3個ずつまとめて、2個ずつずらしてまとめる」みたいなことは、今のところChunkではできません。
また余った最後の方の要素は、破棄されずまとめる数より少ない数でもまとめられます。

using System;
using System.Linq;

var strings = new []{
    "aaa",
    "bbb",
    "ccc",
    "ddd",
    "eee",
    "fff",
    "ggg",
};

// 2個ずつまとめる、次のように表示される
// aaa,bbb
// ccc,ddd
// eee,fff
// ggg
foreach(var item in strings.Chunk(2)) {
    Console.WriteLine(string.Join(",", item));
}

// 3個ずつまとめる、次のように表示される
// aaa,bbb,ccc
// ddd,eee,fff
// ggg
foreach(var item in strings.Chunk(3)) {
    Console.WriteLine(string.Join(",", item));
}

Index型で指定「ElementAt」

ElementAtにIndex型を引数に取るオーバーロードが加わりました。

using System;
using System.Linq;

var strings = new []{
    "aaa",
    "bbb",
    "ccc",
    "ddd",
    "eee",
    "fff",
    "ggg",
}.AsEnumerable();


// 最初の要素を指すIndex型のオブジェクト
Index indexFirst = new Index(value: 0, fromEnd: false);
// Index型を引数に取るオーバーロードが加わった
Console.WriteLine(strings.ElementAt(indexFirst)); // 「aaa」と表示

↑の使い方では特にメリットは感じませんが、「後ろから何番目」という指定で要素を指定したい場合、Index型を引数に取るオーバーロードが活躍します。

今まではIEnumerable<T>のオブジェクトによっては、最後からn番目の要素をElementAtするには

  • Count(長さを求める)ために全要素列挙を一回
  • ElementAtでも一回(この列挙は全要素列挙ではない)

計2回の列挙が必要になる場合が次のコードのようにありました。

// 今まで最後からn番目の要素を取得するためにはこうやって書いていた
// こうやって書くとCount(要素長を求める)ために一回、ElementAtでも一回(この列挙は全要素列挙ではない)、計2回の要素列挙が必要
// 最後から2番目の要素
int targetIndexInt = strings.Count() - 2 - 1;
Console.WriteLine(strings.ElementAt(targetIndexInt)); // 「eee」と表示

ところが、「後ろから何番目」を指定するIndex型のオブジェクトを渡すElementAtのオーバーロードでは、全要素の列挙一回で済みます。

// 末尾の要素を指すIndex型のオブジェクト
Index indexLast = new Index(value: 1, fromEnd: true);

// 末尾の要素や末尾から何番目という要素を取得するのにIndex型を引数に取るオーバーロードが便利
// Indexを取得するオーバーロードならば、列挙は1回でいい(ただし全要素の列挙)
Console.WriteLine(strings.ElementAt(indexLast)); // 「ggg」と表示
Console.WriteLine(strings.ElementAt(new Index(value: 3, fromEnd: true))); // 「eee」と表示

// もちろん、Index型を生成する単項^演算子を使っても便利
Console.WriteLine(strings.ElementAt(^1)); // 「ggg」と表示

Index型の活躍の場面が増えましたね。

Range型で指定「Take」

Index型だけでなく、Range型の活躍の場も増えました。TakeメソッドにRange型の引数を取るオーバーロードが加わりました。

既存のTakeメソッドは、IEnumerable<T>の最初のn個の要素を列挙するメソッドです。

var strings = new []{
    "000",
    "111",
    "222",
    "333",
    "444",
    "555",
    "666",
}.AsEnumerable();


// 最初から0番目から2番目の3要素を取得する
var targets = strings.Take(3);
Console.WriteLine(string.Join(",", targets.ToArray())); // 「000,111,222」と表示

さてこのTakeメソッドを使って、「2番目から4番目の要素を取得する」ことを考えます。Skipメソッドと合わせてこんなふうに書けば実現できます。

// 最初から数えて2番目から4番目を取得する
var targets2to4 = strings
    .Skip(2)  // Skipメソッドで最初の0番目要素と1番目要素、計2個をスキップ
    .Take(3); // Takeメソッドで3個の要素を取得する(2番目要素、3番目要素、4番目要素)
Console.WriteLine(string.Join(",", targets2to4.ToArray())); // 「222,333,444」と表示

SkipメソッドとTakeメソッドを使えば、「最初から数えて、n番目からm番目の要素を取得する」というコードは、このように記述できます。

このような範囲指定でIEnumerable<T>から要素を取得する処理は、新たに加わったRange型の引数を取るTakeメソッドのオーバーロードを使うことで、次のように記述できます。

// 0番目、1番目、2番目を取得する。3番目は含まないことに注意
var targets0to3 = strings.Take(0..3);
Console.WriteLine(string.Join(",", targets0to3.ToArray())); // 「000,111,222」と表示

// 2番目、3番目、4番目、5番目を取得する。6番目は含まないことに注意
var targets2to5 = strings.Take(2..6);
Console.WriteLine(string.Join(",", targets2to5.ToArray())); // 「222,333,444,555」と表示

// 最初から最後までを取得する
var targets0ToLast = strings.Take(0..^0);
Console.WriteLine(string.Join(",", targets0ToLast.ToArray())); // 「000,111,222,333,444,555,666」と表示

// 1番目から最後の1つ前までを取得する
var targets1ToLastPre1 = strings.Take(1..^1);
Console.WriteLine(string.Join(",", targets1ToLastPre1.ToArray())); // 「111,222,333,444,555」と表示

// 2番目から最後の2つ前までを取得する
var targets2ToLastPre2 = strings.Take(2..^2);
Console.WriteLine(string.Join(",", targets2ToLastPre2.ToArray())); // 「222,333,444」と表示

// 10番目から最後の10つ前までを取得する(そんな要素はないから列挙できない)
var targets6ToLastPre6 = strings.Take(10..^10);
Console.WriteLine(string.Join(",", targets6ToLastPre6.ToArray())); // 「」と表示

Rangeの定義として、「0..3」は、3番目を含まないことに注意してください。

列挙しないで要素数を求められる場合だけ要素数を求める「TryGetNonEnumeratedCount」

一見「これいつ使うんだ?」と思う人もいるかもしれないけれど、実は活躍する「TryGetNonEnumeratedCount」。

このメソッドは、列挙せずとも要素数を求めることができるならば、要素数を求めるメソッドです。返り値としてboolを返して、out引数としてint型のcountを取ります。

public static bool TryGetNonEnumeratedCount<TSource> (this IEnumerable<TSource> source, out int count);

TryGetNonEnumeratedCountの呼び出し元(this引数source)が、「列挙しないと要素数を数えることができない」場合は、返り値としてfalseを返します。

var targets = Repeat();

// この場合、TryGetNonEnumeratedCountはfalseを返して「count失敗」と表示されます。
if(targets.TryGetNonEnumeratedCount(out var countRepeat)) {
    Console.WriteLine(countRepeat);
} else {
    Console.WriteLine("count失敗");
}

IEnumerable<String> Repeat() {
    while(true) {
        yield return "hello";
    }
}

TryGetNonEnumeratedCountの呼び出し元(this引数source)が、「列挙しないでも要素数を数えることができる」場合は、返り値としてtrueを返し、out引数countに要素数を返します。

var targets = new [] {
    "aaa",
    "bbb",
    "ccc",
    "ddd",        
};

// この場合、TryGetNonEnumeratedCountはtrueを返して、countに4が代入されます。
// 「4」と表示されます。
if(targets.TryGetNonEnumeratedCount(out var count)) {
    Console.WriteLine(count);
} else {
    Console.WriteLine("count失敗");
}

配列やリストなどIColectionインターフェースを実装している型のオブジェクトは、列挙しないでも要素数を数えることができます。
またLINQの結果のオブジェクトで、「これは列挙しないと要素数がわからないのでは?」という場合も、internalな「IIListProvider」というインターフェースを実装している場合、列挙せずとも要素数を取得できる場合があります。

// LINQの結果オブジェクト
// 列挙しないと、これは列挙しないと要素数がわからなさそう
var targets = new [] {
    "aaa",
    "bbb",
    "ccc",
    "ddd",        
}.Select(it => it.Length);

// でも内部的にSelectの結果はIIListProviderを実装しているので、列挙せずとも長さがが分かる
// この場合、TryGetNonEnumeratedCountはtrueを返して、countに4が代入されます。
// 「4」と表示されます。
if(targets.TryGetNonEnumeratedCount(out var count)) {
    Console.WriteLine(count);
} else {
    Console.WriteLine("count失敗");
}

一見すると「どこで使うんだ」という人もいるかもしれません。実は先に紹介した「Indexを引数に取るElementAtメソッド」や「Rangeを引数に取るTakeメソッド」の内部で利用され、活躍しています。

まとめ

この投稿では、.NET 6で追加されたLINQメソッド・既存のLINQメソッドに追加された新たなオーバーロードを紹介しました。非常に使いどころの多い便利なメソッド、「一見するとこれは便利なのか?」と疑問が浮かぶけれど実は活躍するメソッドなど、様々なメソッドが追加されました。ぜひあなたの.NET 6プロジェクトでも活用してください。

関連リンク

124
85
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
124
85

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?