소개
이 자습서에서는 .NET Core 및 C# 언어의 기능을 설명합니다. 배울 내용은 다음과 같습니다:
- LINQ를 사용하여 시퀀스를 생성합니다.
- LINQ 쿼리에서 쉽게 사용할 수 있는 메서드를 작성합니다.
- 즉시 평가와 지연 평가를 구분합니다.
마술사인 파로 셔플의 기본 기술 중 하나를 보여 주는 애플리케이션을 빌드하여 이러한 기술을 알아봅니다. 간단히 말해, 파로 셔플은 카드 덱을 정확히 반으로 나눈 다음, 각 절반에서 카드를 하나씩 교차 섞어 원래의 덱을 복원하는 기술입니다.
마술사는 모든 카드가 각 순서 섞기 후 알려진 위치에 있고 순서가 반복 패턴이기 때문에 이 기술을 사용합니다.
사용자의 목적을 위해 데이터 시퀀스를 조작하는 것에 대해 가볍게 살펴봅니다. 빌드할 애플리케이션은 카드 덱을 구성한 다음, 여러 번 섞는 과정을 수행하고, 각 과정을 기록합니다. 또한 업데이트된 순서를 원래 주문과 비교합니다.
이 자습서에는 여러 단계가 있습니다. 각 단계 후에 애플리케이션을 실행하고 진행률을 확인할 수 있습니다. dotnet/samples GitHub 리포지토리에서 완성된 샘플을 볼 수도 있습니다. 다운로드 지침은 샘플 및 자습서참조하세요.
필수 조건
- 최신 .NET SDK
- Visual Studio Code 편집기
- C# 개발 키트
애플리케이션 만들기
첫 번째 단계는 새 애플리케이션을 만드는 것입니다. 명령 프롬프트를 열고 애플리케이션에 대한 새 디렉터리를 만듭니다. 현재 디렉터리로 설정하십시오. 명령 프롬프트에 명령을 dotnet new console
입력합니다. 그러면 기본 "Hello World" 애플리케이션에 대한 시작 파일이 만들어집니다.
이전에 C#을 사용한 적이 없는 경우 이 자습서 에서는 C# 프로그램의 구조를 설명합니다. LINQ에 대해 자세히 알아보려면 이 내용을 읽은 다음 여기로 돌아갈 수 있습니다.
데이터 집합 만들기
시작하기 전에 다음 줄이 Program.cs
에서 생성된 dotnet new console
파일의 맨 위에 있는지 확인하십시오.
// Program.cs
using System;
using System.Collections.Generic;
using System.Linq;
이러한 세 줄(using
지시문)이 파일 맨 위에 없으면 프로그램이 컴파일되지 않을 수 있습니다.
팁 (조언)
이 자습서에서는 샘플 코드와 일치하도록 LinqFaroShuffle
라는 네임스페이스에 코드를 구성하거나 전역 네임스페이스를 사용할 수 있습니다. 네임스페이스를 사용하도록 선택한 경우 모든 클래스와 메서드가 동일한 네임스페이스 내에 일관되게 있는지 확인하거나 필요에 따라 적절한 using
문을 추가합니다.
이제 필요한 모든 참조가 있으므로 카드 덱을 구성하는 항목을 고려합니다. 일반적으로 카드 덱에는 4개의 정장이 있으며 각 슈트에는 13개의 값이 있습니다. 일반적으로 곧바로 Card
클래스를 만들고 Card
개체 컬렉션을 손수 채우는 것을 고려할 수 있습니다. LINQ를 사용하면 카드 덱을 만드는 일반적인 방법보다 더 간결할 수 있습니다. 클래스를 Card
만드는 대신 각각 정장과 순위를 나타내는 두 개의 시퀀스를 만들 수 있습니다. 문자열로 순위와 문양을 생성하는 매우 간단한 반복기 메서드 쌍을 만듭니다.
// 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";
}
파일의 Main
메서드 아래에 Program.cs
를 배치합니다. 이 두 메서드는 모두 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을(를) 만듭니다. 주문은 우리의 목적을 위해 중요합니다. 첫 번째 소스 시퀀스의 첫 번째 요소(Suits)는 두 번째 시퀀스(Ranks)의 모든 요소와 결합됩니다. 이것은 첫 번째 정장의 모든 13 카드를 생산하고 있습니다. 해당 프로세스는 첫 번째 시퀀스(Suits)의 각 요소와 함께 반복됩니다. 최종 결과는 카드가 수트를 기준으로 먼저 정렬되고, 그 다음에 값에 따라 정렬됩니다.
위에서 사용한 쿼리 구문에서 LINQ를 작성하거나 대신 메서드 구문을 사용하든 관계없이 한 형식의 구문에서 다른 구문으로 이동하는 것이 항상 가능하다는 점을 명심해야 합니다. 쿼리 구문으로 작성된 위의 쿼리는 다음과 같이 메서드 구문으로 작성할 수 있습니다.
var startingDeck = Suits().SelectMany(suit => Ranks().Select(rank => new { Suit = suit, Rank = rank }));
컴파일러는 쿼리 구문으로 작성된 LINQ 문을 동등한 메서드 호출 구문으로 변환합니다. 따라서 구문 선택에 관계없이 두 버전의 쿼리는 동일한 결과를 생성합니다. 상황에 가장 적합한 구문을 선택합니다. 예를 들어 일부 멤버가 메서드 구문에 어려움을 겪고 있는 팀에서 작업하는 경우 쿼리 구문을 사용하는 것을 선호합니다.
계속 진행하여 이 시점에서 빌드한 샘플을 실행합니다. 데크에 52장의 카드가 모두 표시됩니다. 디버거에서 이 샘플을 실행하여 Suits()
및 Ranks()
메서드가 실행되는 방식을 관찰하는 것이 매우 유용할 수 있습니다. 각 시퀀스의 각 문자열은 필요에 따라 생성된다는 것을 분명히 알 수 있습니다.
순서 조작
다음으로, 데크에서 카드를 셔플하는 방법에 집중하세요. 좋은 셔플의 첫 번째 단계는 덱을 두 개로 나누는 것입니다.
Take LINQ API의 일부인 메서드 및 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;
해야 할 수 있습니다. Visual Studio는 이 using 문을 자동으로 추가하지만 다른 편집기에서는 추가하지 않을 수 있습니다.
메서드 시그니처, 특히 매개 변수를 잠시 살펴보세요.
public static IEnumerable<T> InterleaveSequenceWith<T> (this IEnumerable<T> first, IEnumerable<T> second)
메서드에 첫 번째 인수에 this
한정자가 추가된 것을 볼 수 있습니다. 즉, 첫 번째 인수 형식의 멤버 메서드인 것처럼 메서드를 호출합니다. 또한 이 메서드 선언은 입력 및 출력 형식 IEnumerable<T>
이 있는 표준 관용구를 따릅니다. 이러한 방법을 사용하면 LINQ 메서드를 함께 연결하여 더 복잡한 쿼리를 수행할 수 있습니다.
당연히, 데크를 반으로 나눈 다음 그 두 부분을 합쳐야 합니다. 코드에서 이는 Take과 Skip을 통해 획득한 두 시퀀스를 동시에 나열하여 interleaving
요소를 사용하고, 하나의 시퀀스를 생성하는 것을 의미합니다. 이를 통해 카드를 지금 막 섞은 덱을 만듭니다. 두 시퀀스로 작동하는 LINQ 메서드를 작성하려면 작동 방식을 IEnumerable<T> 이해해야 합니다.
IEnumerable<T> 인터페이스에는 다음 한 가지 메서드가 있습니다GetEnumerator. 반환된 GetEnumerator 개체에는 다음 요소로 이동할 메서드와 시퀀스의 현재 요소를 검색하는 속성이 있습니다. 이러한 두 멤버를 사용하여 컬렉션을 열거하고 요소를 반환합니다. 이 Interleave 메서드는 반복기 메서드이므로 컬렉션을 빌드하고 컬렉션을 반환하는 대신 위에 표시된 구문을 사용합니다 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
메서드로 돌아가서 덱을 한 번 섞습니다.
// 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);
}
}
비교
데크를 원래 순서로 다시 설정하는 데 걸리는 순서는 몇 개입니까? 알아보려면 두 시퀀스가 같은지 여부를 결정하는 메서드를 작성해야 합니다. 해당 메서드가 있으면 데크를 순서대로 섞는 코드를 루프에 배치하고 데크가 다시 순서대로 돌아가는 시기를 확인해야 합니다.
두 시퀀스가 같은지 확인하는 메서드를 작성하는 것은 간단해야 합니다. 그것은 당신이 카드를 섞으려고 쓴 방법과 유사한 구조입니다. 이번에는 각 요소를 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;
}
두 번째 LINQ 관용구인 터미널 메서드를 보여 줍니다. 시퀀스를 입력(또는 이 경우 두 시퀀스)으로 사용하고 단일 스칼라 값을 반환합니다. 터미널 메서드를 사용하는 경우 항상 LINQ 쿼리에 대한 메서드 체인의 최종 메서드이므로 이름 "터미널"입니다.
덱이 원래 순서로 돌아가는 시점을 결정할 때 사용하는 방법을 통해 이를 확인할 수 있습니다. 순서 섞기 코드를 루프 내부에 배치하고 메서드를 적용하여 SequenceEquals()
시퀀스가 원래 순서로 되돌아가면 중지합니다. 시퀀스 대신 단일 값을 반환하기 때문에 항상 모든 쿼리에서 최종 메서드가 되는 것을 볼 수 있습니다.
// 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 쿼리에서 처음 만들었을 때의 원래 구성으로 돌아갑니다.
최적화
지금까지 빌드한 샘플은 각 실행에서 맨 윗장과 맨 아랫장의 카드가 동일하게 유지되는 아웃 셔플을 실행합니다. 이제 한 가지 변경을 제안합니다. 대신 52장의 카드가 모두 위치를 바꾸는 인 셔플을 사용해 봅시다. 인 셔플에서는 아래쪽 절반의 첫 번째 카드가 전체 카드 더미의 첫 번째 카드가 되도록 카드를 섞습니다. 즉, 상위 절반의 마지막 카드가 하단 카드가 됩니다. 이는 단수 코드 줄에 대한 간단한 변경입니다. 의 위치를 전환하여 현재 순서 섞기 쿼리를 업데이트합니다 TakeSkip. 그러면 데크의 위쪽과 아래쪽 절반의 순서가 변경됩니다.
shuffle = shuffle.Skip(26).InterleaveSequenceWith(shuffle.Take(26));
프로그램을 다시 실행하면 데크 자체의 순서를 다시 지정하는 데 52회 반복이 필요합니다. 또한 프로그램이 계속 실행됨에 따라 몇 가지 심각한 성능 저하를 알아차리기 시작합니다.
여기에는 여러 가지 이유가 있습니다. 지연 평가의 비효율적인 사용이라는 이 성능 저하의 주요 원인 중 하나를 해결할 수 있습니다.
간단히 말해, 지연 평가는 문장의 평가가 해당 값이 필요할 때까지 수행되지 않는다고 설명합니다. LINQ 쿼리는 지연 평가되는 문장입니다. 시퀀스는 요소가 요청될 때만 생성됩니다. 일반적으로 LINQ의 주요 이점입니다. 그러나 이 프로그램과 같은 사용에서는 실행 시간이 기하급수적으로 증가합니다.
LINQ 쿼리를 사용하여 원래 데크를 생성했습니다. 각 순서 섞기는 이전 데크에서 세 개의 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);
}
유의하십시오, 쿼리에 액세스할 때마다 로그하지 않습니다. 원래 쿼리를 만들 때만 로그합니다. 프로그램을 실행하는 데는 여전히 시간이 오래 걸리지만 이제 그 이유를 확인할 수 있습니다. 로깅이 켜진 상태에서 입력 섞기를 실행하다가 인내심이 바닥나면, 다시 출력 섞기로 전환하세요. 지연 평가의 효과를 계속 볼 수 있을 것입니다. 한 번의 실행에서 모든 값과 적합 생성을 포함하여 2592개의 쿼리를 실행합니다.
여기서 코드의 성능을 향상시켜 실행 횟수를 줄일 수 있습니다. 간단한 수정은 카드 데크를 생성하는 원래 LINQ 쿼리의 결과를 캐시 하는 것입니다. 현재 do-while 루프가 반복될 때마다 쿼리를 반복해서 실행하고, 카드 덱을 재구성하여 매번 다시 셔플합니다. 카드의 데크를 캐시하려면 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개의 쿼리로 줄어들었습니다. 순서 섞기를 사용하여 다시 실행하면 비슷한 개선 사항이 표시됩니다. 이제 162개의 쿼리를 실행합니다.
이 예제는 지연 평가로 인해 성능 문제가 발생할 수 있는 사용 사례를 강조 표시하도록 설계되었습니다 . 지연 평가가 코드 성능에 영향을 미칠 수 있는 위치를 확인하는 것이 중요하지만, 모든 쿼리를 열심히 실행해야 하는 것은 아니라는 점을 이해하는 것이 중요합니다.
ToArray을 사용하지 않으면 카드 덱의 새로운 배열이 이전 배열에서 구성되기 때문에 성능 저하가 발생합니다. 지연 평가를 사용하면 각 새 덱 구성이 원래 덱에서부터 빌드되며, 심지어 startingDeck
을(를) 구성했던 코드를 실행하기도 합니다. 이로 인해 많은 양의 추가 작업이 발생합니다.
실제로 일부 알고리즘은 즉시 평가를 사용하여 잘 실행되고 다른 알고리즘은 지연 평가를 사용하여 잘 실행됩니다. 일상적인 사용의 경우 데이터 원본이 데이터베이스 엔진과 같은 별도의 프로세스인 경우 지연 평가가 일반적으로 더 나은 선택입니다. 데이터베이스의 경우, 지연 평가를 사용하면 더 복잡한 쿼리가 데이터베이스 프로세스에 대해 단 한 번의 왕복 요청만으로 실행되어 나머지 코드로 돌아올 수 있습니다. LINQ는 지연 또는 즉시 평가를 활용하도록 선택하든 유연하므로 프로세스를 측정하고 최상의 성능을 제공하는 평가 유형을 선택합니다.
결론
이 프로젝트에서는 다음을 다루었습니다.
- LINQ 쿼리를 사용하여 의미 있는 시퀀스로 데이터 집계
- LINQ 쿼리에 고유한 사용자 지정 기능을 추가하는 확장 메서드 작성
- LINQ 쿼리가 성능 저하와 같은 성능 문제가 발생할 수 있는 코드의 영역 찾기
- LINQ 쿼리에 있어 지연 평가와 즉시 평가 및 이러한 평가가 쿼리 성능에 미칠 수 있는 영향
LINQ 외에도 마술사가 카드 트릭에 사용하는 기술에 대해 조금 배웠습니다. 마술사는 데크에서 모든 카드가 이동하는 위치를 제어할 수 있기 때문에 파로 셔플을 사용합니다. 이제 알다시피, 다른 사람을 위해 그것을 망치지 마십시오!
LINQ에 대한 자세한 내용은 다음을 참조하세요.
.NET