イントロダクション
このチュートリアルでは、.NET Core と C# 言語の機能について説明します。 次の方法について学習します。
- LINQ を使用してシーケンスを生成します。
- LINQ クエリで簡単に使用できるメソッドを記述します。
- 先行評価と遅延評価を区別する。
これらの手法を学習するには、任意の魔法使いの基本的なスキルの 1 つを示すアプリケーション ( faro shuffle) を構築します。 簡単に言えば、ファロシャッフルは、カードデッキを半分に正確に分割し、シャッフルが各半分から各カードをインターリーブして元のデッキを再構築するテクニックです。
各カードが各シャッフルの後に既知の場所にあり、順序が繰り返しパターンであるため、魔法使いはこの手法を使用します。
ここでは、データ シーケンスの操作として気軽に見ていきましょう。 ビルドするアプリケーションは、カード デッキを構築し、シャッフルのシーケンスを実行し、毎回シーケンスを書き出します。 また、更新された注文を元の注文と比較します。
このチュートリアルには複数の手順があります。 各手順の後、アプリケーションを実行して進行状況を確認できます。 完成したサンプルは、dotnet/samples GitHub リポジトリでも確認できます。 ダウンロード手順については、サンプルとチュートリアルを参照してください。
[前提条件]
- 最新の .NET SDK
- Visual Studio Code エディター
- C# DevKit
アプリケーションを作成する
最初の手順では、新しいアプリケーションを作成します。 コマンド プロンプトを開き、アプリケーションの新しいディレクトリを作成します。 現在のディレクトリにします。 コマンド プロンプトでコマンド dotnet new console
を入力します。 これにより、基本的な "Hello World" アプリケーションのスターター ファイルが作成されます。
C# を使用したことがない場合は、 このチュートリアル で C# プログラムの構造について説明します。 その資料を読んでから、LINQ についてもっと学ぶためにここに戻ってください。
データ セットを作成する
開始する前に、次の行が、dotnet new console
によって生成されたProgram.cs
ファイルの先頭にあることを確認します。
// Program.cs
using System;
using System.Collections.Generic;
using System.Linq;
これら 3 行 (using
ディレクティブ) がファイルの先頭にない場合、プログラムがコンパイルされない可能性があります。
ヒント
このチュートリアルでは、サンプル コードと一致するように LinqFaroShuffle
という名前空間にコードを整理するか、既定のグローバル名前空間を使用できます。 名前空間を使用する場合は、すべてのクラスとメソッドが同じ名前空間内に一貫していることを確認するか、必要に応じて適切な using
ステートメントを追加します。
必要な参照がすべて揃ったので、カードのデッキを構成するものを検討してください。 一般的に、カードのデッキには 4 つのスーツがあり、各スーツには 13 個の値があります。 通常は、最初から Card
クラスを作成し、Card
オブジェクトのコレクションを手動で追加することを検討するかもしれません。 LINQ を使用すると、カードのデッキを作成する通常の方法よりも簡潔にすることができます。 Card
クラスを作成する代わりに、スーツとランクをそれぞれ表す 2 つのシーケンスを作成できます。 あなたは、ランクとスーツを文字列のIEnumerable<T>として生成するイテレーターメソッドの本当に単純なペアを作成します。
// Program.cs
// The Main() method
static IEnumerable<string> Suits()
{
yield return "clubs";
yield return "diamonds";
yield return "hearts";
yield return "spades";
}
static IEnumerable<string> Ranks()
{
yield return "two";
yield return "three";
yield return "four";
yield return "five";
yield return "six";
yield return "seven";
yield return "eight";
yield return "nine";
yield return "ten";
yield return "jack";
yield return "queen";
yield return "king";
yield return "ace";
}
これらは、Program.cs
ファイル内の Main
メソッドの下に配置します。 これら 2 つのメソッドはどちらも、 yield return
構文を使用して、実行時にシーケンスを生成します。 コンパイラは、 IEnumerable<T> を実装し、要求に応じて文字列のシーケンスを生成するオブジェクトをビルドします。
次に、これらの反復子メソッドを使用してカードのデッキを作成します。 LINQ クエリは、 Main
メソッドに配置します。 これを次に示します。
// Program.cs
static void Main(string[] args)
{
var startingDeck = from s in Suits()
from r in Ranks()
select new { Suit = s, Rank = r };
// Display each card that we've generated and placed in startingDeck in the console
foreach (var card in startingDeck)
{
Console.WriteLine(card);
}
}
複数の from
句によって SelectManyが生成され、最初のシーケンス内の各要素と 2 番目のシーケンス内の各要素を組み合わせた 1 つのシーケンスが作成されます。 順序は、私たちの目的のために重要です。 最初のソース シーケンス (Suits) の最初の要素は、2 番目のシーケンス (Ranks) のすべての要素と結合されます。 これは最初のスートのすべての13枚のカードを生成します。 最初のシーケンス (Suits) の各要素でこのプロセスは繰り返されます。 その結果、はじめにスート順に並んで、次に値の順に並んだカード デッキができます。
上記で使用したクエリ構文で LINQ を記述するか、代わりにメソッド構文を使用するかを選択する場合は、常に構文の形式から他の形式に移動できます。 クエリ構文で記述された上記のクエリは、メソッド構文で次のように記述できます。
var startingDeck = Suits().SelectMany(suit => Ranks().Select(rank => new { Suit = suit, Rank = rank }));
コンパイラは、クエリ構文で記述された LINQ ステートメントを同等のメソッド呼び出し構文に変換します。 したがって、構文の選択に関係なく、クエリの 2 つのバージョンで同じ結果が生成されます。 状況に最も適した構文を選択します。たとえば、一部のメンバーがメソッド構文に問題があるチームで作業している場合は、クエリ構文の使用を好みます。
この時点でビルドしたサンプルを実行してください。 デッキに全52枚のカードが表示されます。 デバッガーでこのサンプルを実行して、 Suits()
メソッドと Ranks()
メソッドの実行方法を観察すると、非常に役立つ場合があります。 各シーケンス内の各文字列が必要な場合にのみ生成されることがわかります。
順序を操作する
次に、デッキのカードをシャッフルする方法に焦点を当てます。 良いシャッフルの最初のステップは、デッキを2つに分割することです。 LINQ API の一部である Take メソッドと Skip メソッドによって、その機能が提供されます。 foreach
ループの下に配置します。
// Program.cs
public static void Main(string[] args)
{
var startingDeck = from s in Suits()
from r in Ranks()
select new { Suit = s, Rank = r };
foreach (var c in startingDeck)
{
Console.WriteLine(c);
}
// 52 cards in a deck, so 52 / 2 = 26
var top = startingDeck.Take(26);
var bottom = startingDeck.Skip(26);
}
ただし、標準ライブラリではシャッフルメソッドを利用できないため、独自の方法を記述する必要があります。 作成するシャッフル メソッドは、LINQ ベースのプログラムで使用するいくつかの手法を示しているため、このプロセスの各部分について手順で説明します。
LINQ クエリから戻る IEnumerable<T> を操作する方法にいくつかの機能を追加するには、 拡張メソッドと呼ばれる特別な種類のメソッドを記述する必要があります。 簡単に言うと、拡張メソッドは、機能を追加する元の型を変更することなく、既存の型に新しい機能を追加する特殊な目的の 静的メソッド です。
Extensions.cs
という名前の新しい静的クラス ファイルをプログラムに追加して拡張メソッドに新しいホームを与え、最初の拡張メソッドのビルドを開始します。
// Extensions.cs
using System;
using System.Collections.Generic;
using System.Linq;
namespace LinqFaroShuffle
{
public static class Extensions
{
public static IEnumerable<T> InterleaveSequenceWith<T>(this IEnumerable<T> first, IEnumerable<T> second)
{
// Your implementation will go here soon enough
}
}
}
注
Visual Studio 以外のエディター (Visual Studio Code など) を使用している場合は、拡張メソッドにアクセスできるように、Program.cs ファイルの先頭にusing LinqFaroShuffle;
を追加することが必要になる場合があります。 この using ステートメントは Visual Studio によって自動的に追加されますが、他のエディターでは追加されない場合があります。
メソッドシグネチャを少し見てください。具体的には、次のパラメーターを参照してください。
public static IEnumerable<T> InterleaveSequenceWith<T> (this IEnumerable<T> first, IEnumerable<T> second)
メソッドへの最初の引数に対する this
修飾子の追加を確認できます。 つまり、最初の引数の型のメンバー メソッドであるかのようにメソッドを呼び出します。 このメソッド宣言は、入力と出力の型が IEnumerable<T>
される標準的なイディオムにも従います。 これにより、LINQ メソッドを連結して、より複雑なクエリを実行できます。
当然ながら、デッキを半分に分割するので、それらの半分を結合する必要があります。 コードでは、これは、 Take と Skip を通じて取得したシーケンスの両方を一度に列挙し、要素を interleaving
し、1 つのシーケンス (現在シャッフルされたカードのデッキ) を作成することを意味します。 2 つのシーケンスで動作する LINQ メソッドを記述するには、 IEnumerable<T> のしくみを理解する必要があります。
IEnumerable<T> インターフェイスには、GetEnumeratorという 1 つの方法があります。 GetEnumeratorによって返されるオブジェクトには、次の要素に移動するメソッドと、シーケンス内の現在の要素を取得するプロパティがあります。 これら 2 つのメンバーを使用してコレクションを列挙し、要素を返します。 このインターリーブ メソッドは反復子メソッドであるため、コレクションをビルドしてコレクションを返す代わりに、上記の yield return
構文を使用します。
そのメソッドの実装を次に示します。
public static IEnumerable<T> InterleaveSequenceWith<T>
(this IEnumerable<T> first, IEnumerable<T> second)
{
var firstIter = first.GetEnumerator();
var secondIter = second.GetEnumerator();
while (firstIter.MoveNext() && secondIter.MoveNext())
{
yield return firstIter.Current;
yield return secondIter.Current;
}
}
このメソッドを記述したので、 Main
メソッドに戻り、デッキを 1 回シャッフルします。
// Program.cs
public static void Main(string[] args)
{
var startingDeck = from s in Suits()
from r in Ranks()
select new { Suit = s, Rank = r };
foreach (var c in startingDeck)
{
Console.WriteLine(c);
}
var top = startingDeck.Take(26);
var bottom = startingDeck.Skip(26);
var shuffle = top.InterleaveSequenceWith(bottom);
foreach (var c in shuffle)
{
Console.WriteLine(c);
}
}
比較
デッキを元の順序に戻すには、いくつのシャッフルが必要ですか? 調べるには、2 つのシーケンスが等しいかどうかを判断するメソッドを記述する必要があります。 そのメソッドを作成したら、デッキをシャッフルするコードをループに配置し、デッキが順序に戻るタイミングを確認する必要があります。
2 つのシーケンスが等しいかどうかを判断するメソッドを記述するのは簡単です。 これは、デッキをシャッフルするために記述したメソッドと同様の構造です。 今回のみ、各要素を yield return
するのではなく、各シーケンスの一致する要素を比較します。 シーケンス全体が列挙されている場合、すべての要素が一致する場合、シーケンスは同じになります。
public static bool SequenceEquals<T>
(this IEnumerable<T> first, IEnumerable<T> second)
{
var firstIter = first.GetEnumerator();
var secondIter = second.GetEnumerator();
while ((firstIter?.MoveNext() == true) && secondIter.MoveNext())
{
if ((firstIter.Current is not null) && !firstIter.Current.Equals(secondIter.Current))
{
return false;
}
}
return true;
}
これは、2 つ目の LINQ イディオムであるターミナル メソッドを示しています。 入力としてシーケンスを受け取り (この場合は 2 つのシーケンス)、1 つのスカラー値を返します。 ターミナル メソッドを使用する場合、それらは常に LINQ クエリのメソッドチェーンの最後のメソッドであるため、"terminal" という名前になります。
これを使用して、デッキが元の順序に戻るタイミングを判断するときに、これを実際に確認できます。 シャッフル コードをループ内に配置し、 SequenceEquals()
メソッドを適用してシーケンスが元の順序に戻ったときに停止します。 シーケンスの代わりに 1 つの値が返されるため、クエリでは常に最終的なメソッドになることがわかります。
// Program.cs
static void Main(string[] args)
{
// Query for building the deck
// Shuffling using InterleaveSequenceWith<T>();
var times = 0;
// We can re-use the shuffle variable from earlier, or you can make a new one
shuffle = startingDeck;
do
{
shuffle = shuffle.Take(26).InterleaveSequenceWith(shuffle.Skip(26));
foreach (var card in shuffle)
{
Console.WriteLine(card);
}
Console.WriteLine();
times++;
} while (!startingDeck.SequenceEquals(shuffle));
Console.WriteLine(times);
}
これまでに取得したコードを実行し、各シャッフルでデッキがどのように再配置されるかをメモします。 8 回シャッフルした後 (do-while ループの反復)、開始 LINQ クエリから最初に作成した元の構成にデッキが戻ります。
最適化
これまでに作成したサンプルではアウト シャッフルが実行され、各実行で上と下のカードは同じままです。 1 つの変更を行います。代わりにイン シャッフル を使用します。52 枚のカードすべてが位置を変更します。 インシャッフルでは、デッキをインターリーブして、下半分の最初のカードがデッキの最初のカードになるようにします。 つまり、上半分の最後のカードが下のカードになります。 これは、単一のコード行への単純な変更です。 TakeとSkipの位置を切り替えて、現在のシャッフル クエリを更新します。 これにより、デッキの上半分と下半分の順序が変更されます。
shuffle = shuffle.Skip(26).InterleaveSequenceWith(shuffle.Take(26));
プログラムをもう一度実行すると、デッキ自体の並べ替えに 52 回の反復が必要になることがわかります。 また、プログラムの実行が続くと、パフォーマンスが大幅に低下していることにも気付き始めます。
これにはいくつかの理由があります。 このパフォーマンス低下の主な原因の 1 つである 、レイジー評価の非効率的な使用に取り組むことができます。
簡単に言うと、遅延評価では、ステートメントの評価は、その値が必要になるまで実行されないことを示します。 遅延して評価されているステートメントは LINQ クエリです。 シーケンスは、要素が要求されたときにのみ生成されます。 通常、これは LINQ の主な利点です。 ただし、このプログラムなどの使用では、実行時間が指数関数的に増加します。
LINQ クエリを使用して元のデッキを生成したことを思い出してください。 各シャッフルは、前のデッキで 3 つの LINQ クエリを実行することによって生成されます。 これらはすべて遅延的に実行されます。 これは、シーケンスが要求されるたびに再度実行されることを意味します。 52 回目のイテレーションに到達する頃には、元のデッキを何度も再生成することになります。 この動作を示すログを書き込みましょう。 あなたが修正します。
Extensions.cs
ファイルで、以下のメソッドを入力するか、コピーします。 この拡張メソッドは、プロジェクト ディレクトリ内に debug.log
という名前の新しいファイルを作成し、現在実行されているクエリをログ ファイルに記録します。 この拡張メソッドを任意のクエリに追加して、クエリが実行されたことをマークできます。
public static IEnumerable<T> LogQuery<T>
(this IEnumerable<T> sequence, string tag)
{
// File.AppendText creates a new file if the file doesn't exist.
using (var writer = File.AppendText("debug.log"))
{
writer.WriteLine($"Executing Query {tag}");
}
return sequence;
}
File
の下に赤い波線が表示されますが、それは存在しないことを示しています。 コンパイラは File
が何であるかを知らないので、コンパイルされません。 この問題を解決するには、 Extensions.cs
の最初の行の下に次のコード行を追加してください。
using System.IO;
これにより問題が解決され、赤いエラーが消えます。
次に、ログ メッセージを使用して各クエリの定義をインストルメント化します。
// Program.cs
public static void Main(string[] args)
{
var startingDeck = (from s in Suits().LogQuery("Suit Generation")
from r in Ranks().LogQuery("Rank Generation")
select new { Suit = s, Rank = r }).LogQuery("Starting Deck");
foreach (var c in startingDeck)
{
Console.WriteLine(c);
}
Console.WriteLine();
var times = 0;
var shuffle = startingDeck;
do
{
// Out shuffle
/*
shuffle = shuffle.Take(26)
.LogQuery("Top Half")
.InterleaveSequenceWith(shuffle.Skip(26)
.LogQuery("Bottom Half"))
.LogQuery("Shuffle");
*/
// In shuffle
shuffle = shuffle.Skip(26).LogQuery("Bottom Half")
.InterleaveSequenceWith(shuffle.Take(26).LogQuery("Top Half"))
.LogQuery("Shuffle");
foreach (var c in shuffle)
{
Console.WriteLine(c);
}
times++;
Console.WriteLine(times);
} while (!startingDeck.SequenceEquals(shuffle));
Console.WriteLine(times);
}
クエリにアクセスするたびにログを記録しないことに注意してください。 ログに記録されるのは、元のクエリを作成した場合のみです。 プログラムの実行にはまだ時間がかかりますが、その理由がわかります。 ログを記録しながらインシャッフルを実行するのに我慢できなくなった場合は、アウトシャッフルに戻してください。 それでも遅延評価の影響はわかります。 1 回の実行で、すべての値とスーツの生成を含む 2592 クエリが実行されます。
ここでコードのパフォーマンスを向上させ、実行回数を減らすことができます。 簡単な修正は、カードのデッキを構築する元の LINQ クエリの結果を キャッシュ することです。 現時点では、do-while loop が繰り返されるたびに、何度もクエリが実行され、カード デッキが再構築され、シャッフルが毎回実行されています。 カードのデッキをキャッシュするには、LINQ メソッドの ToArray と ToListを利用できます。クエリに追加すると、クエリに追加したのと同じアクションが実行されますが、呼び出しを選択したメソッドに応じて、結果が配列またはリストに格納されます。 LINQ メソッド ToArray を両方のクエリに追加し、プログラムをもう一度実行します。
public static void Main(string[] args)
{
IEnumerable<Suit>? suits = Suits();
IEnumerable<Rank>? ranks = Ranks();
if ((suits is null) || (ranks is null))
return;
var startingDeck = (from s in suits.LogQuery("Suit Generation")
from r in ranks.LogQuery("Value Generation")
select new { Suit = s, Rank = r })
.LogQuery("Starting Deck")
.ToArray();
foreach (var c in startingDeck)
{
Console.WriteLine(c);
}
Console.WriteLine();
var times = 0;
var shuffle = startingDeck;
do
{
/*
shuffle = shuffle.Take(26)
.LogQuery("Top Half")
.InterleaveSequenceWith(shuffle.Skip(26).LogQuery("Bottom Half"))
.LogQuery("Shuffle")
.ToArray();
*/
shuffle = shuffle.Skip(26)
.LogQuery("Bottom Half")
.InterleaveSequenceWith(shuffle.Take(26).LogQuery("Top Half"))
.LogQuery("Shuffle")
.ToArray();
foreach (var c in shuffle)
{
Console.WriteLine(c);
}
times++;
Console.WriteLine(times);
} while (!startingDeck.SequenceEquals(shuffle));
Console.WriteLine(times);
}
これで、アウト シャッフルは 30 個のクエリにダウンしました。 in シャッフルでもう一度実行すると、同様の改善が見られます。162 個のクエリが実行されるようになりました。
この例は、遅延評価がパフォーマンスの問題を引き起こす可能性があるユース ケースを強調 するように設計されていること に注意してください。 遅延評価がコードのパフォーマンスに影響する可能性がある場所を確認することは重要ですが、すべてのクエリを熱心に実行する必要はないことを理解することも同様に重要です。 ToArrayを使用せずに発生するパフォーマンスのヒットは、カードのデッキの各新しい配置が前の配置から構築されているためです。 レイジー評価を使用すると、新しいデッキ構成が元のデッキからビルドされ、 startingDeck
をビルドしたコードも実行されます。 これにより、大量の余分な作業が発生します。
実際には、先行評価を使用すると効率的に動作するアルゴリズムもあれば、遅延評価を使用したほうがよいアルゴリズムもあります。 日常の使用では、データ ソースがデータベース エンジンのように個別のプロセスである場合は、遅延評価のほうが通常は適しています。 データベースの場合、遅延評価では、より複雑なクエリでデータベース プロセスへのラウンド トリップを 1 回だけ実行し、残りのコードに戻すことができます。 LINQ は、遅延評価と熱心な評価のどちらを利用するかを柔軟に選択できるため、プロセスを測定し、どの種類の評価を選択しても最適なパフォーマンスが得られます。
結論
このプロジェクトでは、次の内容について説明しました。
- LINQ クエリを使用してデータを意味のあるシーケンスに集計する
- 独自のカスタム機能を LINQ クエリに追加するための拡張メソッドの記述
- LINQ クエリで速度低下などのパフォーマンスの問題が発生する可能性があるコード内の領域を検索する
- LINQ クエリにおける遅延評価と即時評価、そしてそれらがクエリのパフォーマンスに与える影響
LINQ とは別に、魔法使いがカードトリックに使用するテクニックについて少し学びました。 魔法使いはFaroシャッフルを使います。デッキ内の各カードの動きを制御できるためです。 知ったのだから、他の人のためにそれを台無しにしないでください!
LINQ の詳細については、次を参照してください。
.NET