다음을 통해 공유


LINQ를 확장하는 방법

모든 LINQ 기반 메서드는 두 가지 유사한 패턴 중 하나를 따릅니다. 이러한 메서드는 열거 가능한 시퀀스를 수행합니다. 다른 시퀀스 또는 단일 값을 반환합니다. 모양의 일관성을 사용하면 비슷한 모양의 메서드를 작성하여 LINQ를 확장할 수 있습니다. 실제로 .NET 라이브러리는 LINQ가 처음 도입된 이후 많은 .NET 릴리스에서 새로운 메서드를 얻었습니다. 이 문서에서는 동일한 패턴을 따르는 사용자 고유의 메서드를 작성하여 LINQ를 확장하는 예를 확인합니다.

LINQ 쿼리에 대한 사용자 지정 메서드 추가

IEnumerable<T> 인터페이스에 확장 메서드를 추가하여 LINQ 쿼리에 사용하는 메서드 세트를 확장합니다. 예를 들어 표준 평균 또는 최대 작업 외에 사용자 지정 집계 메서드를 만들어 값 시퀀스에서 단일 값을 계산합니다. 값 시퀀스에 대한 특정 데이터 변환 또는 사용자 지정 필터로 작동하고 새 시퀀스를 반환하는 메서드도 만듭니다. 이러한 메서드의 예로는 Distinct, SkipReverse가 있습니다.

IEnumerable<T> 인터페이스를 확장하면 사용자 지정 메서드를 열거 가능한 컬렉션에 적용할 수 있습니다. 자세한 내용은 확장 메서드를 참조하세요.

집계 메서드는 값 집합에서 하나의 값을 계산합니다. LINQ는 Average, MinMax를 포함하여 여러 집계 메서드를 제공합니다. IEnumerable<T> 인터페이스에 확장 메서드를 추가하여 고유한 집계 메서드를 만들 수 있습니다.

다음 코드 예제에서는 double 형식의 숫자 시퀀스에 대한 중앙값을 계산하는 Median이라는 확장 메서드를 만드는 방법을 보여 줍니다.

public static class EnumerableExtension
{
    public static double Median(this IEnumerable<double>? source)
    {
        if (source is null || !source.Any())
        {
            throw new InvalidOperationException("Cannot compute median for a null or empty set.");
        }

        var sortedList =
            source.OrderBy(number => number).ToList();

        int itemIndex = sortedList.Count / 2;

        if (sortedList.Count % 2 == 0)
        {
            // Even number of items.
            return (sortedList[itemIndex] + sortedList[itemIndex - 1]) / 2;
        }
        else
        {
            // Odd number of items.
            return sortedList[itemIndex];
        }
    }
}

IEnumerable<T> 인터페이스에서 다른 집계 메서드를 호출하는 것과 같은 방식으로 열거 가능한 컬렉션에 대해 이 확장 메서드를 호출합니다.

다음 코드 예제에서는 double 형식의 배열에 대해 Median 메서드를 사용하는 방법을 보여 줍니다.

double[] numbers = [1.9, 2, 8, 4, 5.7, 6, 7.2, 0];
var query = numbers.Median();

Console.WriteLine($"double: Median = {query}");
// This code produces the following output:
//     double: Median = 4.85

다양한 형식의 시퀀스를 허용하도록 집계 메서드를 오버로드할 수 있습니다. 표준 접근법은 각 형식에 대한 오버로드를 만드는 것입니다. 또 다른 접근법은 제네릭 형식을 사용하고 대리자를 통해 이를 특정 형식으로 변환할 오버로드를 만드는 것입니다. 두 가지 접근법을 결합할 수도 있습니다.

지원하려는 각 형식에 대한 특정 오버로드를 만들 수 있습니다. 다음 코드 예제에서는 int 형식에 대한 Median 메서드의 오버로드를 보여 줍니다.

// int overload
public static double Median(this IEnumerable<int> source) =>
    (from number in source select (double)number).Median();

이제 다음 코드와 같이 integerdouble 형식에 대한 Median 오버로드를 호출할 수 있습니다.

double[] numbers1 = [1.9, 2, 8, 4, 5.7, 6, 7.2, 0];
var query1 = numbers1.Median();

Console.WriteLine($"double: Median = {query1}");

int[] numbers2 = [1, 2, 3, 4, 5];
var query2 = numbers2.Median();

Console.WriteLine($"int: Median = {query2}");
// This code produces the following output:
//     double: Median = 4.85
//     int: Median = 3

개체의 제네릭 시퀀스를 허용하는 오버로드를 만들 수도 있습니다. 이 오버로드는 대리자를 매개 변수로 사용하여 제네릭 형식의 개체 시퀀스를 특정 형식으로 변환합니다.

다음 코드에서는 Func<T,TResult> 대리자를 매개 변수로 사용하는 Median 메서드의 오버로드를 보여 줍니다. 이 대리자는 제네릭 형식 T의 개체를 사용하고 double 형식의 개체를 반환합니다.

// generic overload
public static double Median<T>(
    this IEnumerable<T> numbers, Func<T, double> selector) =>
    (from num in numbers select selector(num)).Median();

이제 임의 형식의 개체 시퀀스에 대해 Median 메서드를 호출할 수 있습니다. 형식에 자체 메서드 오버로드가 없으면 대리자 매개 변수를 전달해야 합니다. C#에서는 이 목적으로 람다 식을 사용할 수 있습니다. 또한 Visual Basic에서만, 메서드 호출 대신 Aggregate 또는 Group By 절을 사용할 경우 범위 내에 있는 값 또는 식을 이 절에 전달할 수 있습니다.

다음 예제 코드에서는 정수 배열 및 문자열 배열에 대해 Median 메서드를 호출하는 방법을 보여 줍니다. 문자열의 경우 배열에서 문자열 길이의 중앙값이 계산됩니다. 예제에서는 각 경우에 Func<T,TResult> 대리자 매개 변수를 Median 메서드에 전달하는 방법을 보여 줍니다.

int[] numbers3 = [1, 2, 3, 4, 5];

/*
    You can use the num => num lambda expression as a parameter for the Median method
    so that the compiler will implicitly convert its value to double.
    If there is no implicit conversion, the compiler will display an error message.
*/
var query3 = numbers3.Median(num => num);

Console.WriteLine($"int: Median = {query3}");

string[] numbers4 = ["one", "two", "three", "four", "five"];

// With the generic overload, you can also use numeric properties of objects.
var query4 = numbers4.Median(str => str.Length);

Console.WriteLine($"string: Median = {query4}");
// This code produces the following output:
//     int: Median = 3
//     string: Median = 4

값 시퀀스를 반환하는 사용자 지정 쿼리 메서드를 사용하여 IEnumerable<T> 인터페이스를 확장할 수 있습니다. 이 경우 메서드는 IEnumerable<T> 형식의 컬렉션을 반환해야 합니다. 이러한 메서드는 필터 또는 데이터 변환을 값 시퀀스에 적용하는 데 사용될 수 있습니다.

다음 예제에서는 모든 기타 요소를 첫 번째 인수부터 반환하는 AlternateElements 확장 메서드를 만드는 방법을 보여 줍니다.

// Extension method for the IEnumerable<T> interface.
// The method returns every other element of a sequence.
public static IEnumerable<T> AlternateElements<T>(this IEnumerable<T> source)
{
    int index = 0;
    foreach (T element in source)
    {
        if (index % 2 == 0)
        {
            yield return element;
        }

        index++;
    }
}

다음 코드와 같이 IEnumerable<T> 인터페이스에서 다른 메서드를 호출할 때와 같은 방법으로 열거 가능한 모든 컬렉션에 대해 이 확장 메서드를 호출할 수 있습니다.

string[] strings = ["a", "b", "c", "d", "e"];

var query5 = strings.AlternateElements();

foreach (var element in query5)
{
    Console.WriteLine(element);
}
// This code produces the following output:
//     a
//     c
//     e

연속 키를 기준으로 결과 그룹화

다음 예제에서는 연속 키의 하위 시퀀스를 나타내는 청크로 요소를 그룹화하는 방법을 보여 줍니다. 예를 들어 다음과 같은 키-값 쌍의 다음 시퀀스가 제공된다고 가정합니다.

A 3x3 이미지 등과 같은
A think
A that
B Linq
C is
A really
B cool
B !

다음과 같은 그룹이 이 순서로 만들어집니다.

  1. We, think, that
  2. Linq
  3. is
  4. really
  5. cool, !

솔루션은 스트리밍 방식으로 결과를 반환하는 스레드로부터 안전한 확장 메서드로 구현됩니다. 솔루션은 소스 시퀀스를 거치면서 그룹을 생성합니다. group 또는 orderby 연산자와 달리 전체 시퀀스를 읽기 전에 호출자에 그룹을 반환하기 시작할 수 있습니다. 다음 예제에서는 확장 메서드 및 이를 사용하는 클라이언트 코드를 보여 줍니다.

public static class ChunkExtensions
{
    public static IEnumerable<IGrouping<TKey, TSource>> ChunkBy<TSource, TKey>(
            this IEnumerable<TSource> source,
            Func<TSource, TKey> keySelector) =>
                source.ChunkBy(keySelector, EqualityComparer<TKey>.Default);

    public static IEnumerable<IGrouping<TKey, TSource>> ChunkBy<TSource, TKey>(
            this IEnumerable<TSource> source,
            Func<TSource, TKey> keySelector,
            IEqualityComparer<TKey> comparer)
    {
        // Flag to signal end of source sequence.
        const bool noMoreSourceElements = true;

        // Auto-generated iterator for the source array.
        IEnumerator<TSource>? enumerator = source.GetEnumerator();

        // Move to the first element in the source sequence.
        if (!enumerator.MoveNext())
        {
            yield break;        // source collection is empty
        }

        while (true)
        {
            var key = keySelector(enumerator.Current);

            Chunk<TKey, TSource> current = new(key, enumerator, value => comparer.Equals(key, keySelector(value)));

            yield return current;

            if (current.CopyAllChunkElements() == noMoreSourceElements)
            {
                yield break;
            }
        }
    }
}
public static class GroupByContiguousKeys
{
    // The source sequence.
    static readonly KeyValuePair<string, string>[] list = [
        new("A", "We"),
        new("A", "think"),
        new("A", "that"),
        new("B", "LINQ"),
        new("C", "is"),
        new("A", "really"),
        new("B", "cool"),
        new("B", "!")
    ];

    // Query variable declared as class member to be available
    // on different threads.
    static readonly IEnumerable<IGrouping<string, KeyValuePair<string, string>>> query =
        list.ChunkBy(p => p.Key);

    public static void GroupByContiguousKeys1()
    {
        // ChunkBy returns IGrouping objects, therefore a nested
        // foreach loop is required to access the elements in each "chunk".
        foreach (var item in query)
        {
            Console.WriteLine($"Group key = {item.Key}");
            foreach (var inner in item)
            {
                Console.WriteLine($"\t{inner.Value}");
            }
        }
    }
}

ChunkExtensions 클래스

제공된 ChunkExtensions 클래스 구현 코드에서 ChunkBy 메서드의 while(true) 루프가 소스 시퀀스에서 반복되고 각 청크의 복사본을 만듭니다. 각 패스에서 반복기는 소스 시퀀스에서 Chunk 개체로 표시되는 다음 "청크"의 첫 번째 요소로 이동합니다. 이 루프는 쿼리를 실행하는 외부 foreach 루프에 해당합니다. 이 루프에서 코드는 다음 작업을 수행합니다.

  1. 현재 청크의 키를 가져와서 key 변수에 할당합니다. 소스 반복기는 일치하지 않는 키가 있는 요소를 찾을 때까지 소스 시퀀스를 사용합니다.
  2. 새 청크(그룹) 개체를 만들고 current 변수에 저장합니다. 여기에는 현재 소스 요소의 복사본인 GroupItem이 하나 있습니다.
  3. 해당 청크를 반환합니다. 청크는 ChunkBy 메서드의 반환 값인 IGrouping<TKey,TSource>입니다. 청크에는 소스 시퀀스의 첫 번째 요소만 있습니다. 나머지 요소는 클라이언트 코드 foreach가 이 청크 위에 있는 경우에만 반환됩니다. 자세한 내용은 Chunk.GetEnumerator를 참조하세요.
  4. 다음 여부를 확인합니다.
    • 청크가 모든 소스 요소의 복사본을 만들었는지 여부, 또는
    • 반복기가 소스 시퀀스의 끝에 도달했는지 여부.
  5. 호출자가 모든 청크 항목을 열거한 경우 Chunk.GetEnumerator 메서드는 모든 청크 항목을 복사합니다. Chunk.GetEnumerator 루프가 청크의 모든 요소를 열거하지 않은 경우 별도의 스레드에서 호출할 수 있는 클라이언트의 반복기가 손상되지 않도록 지금 수행해야 합니다.

Chunk 클래스

Chunk 클래스는 키가 같은 하나 이상의 원본 요소로 구성된 연속 그룹입니다. 청크에는 키와 원본 시퀀스의 요소 복사본인 ChunkItem 개체 목록이 있습니다.

class Chunk<TKey, TSource> : IGrouping<TKey, TSource>
{
    // INVARIANT: DoneCopyingChunk == true ||
    //   (predicate != null && predicate(enumerator.Current) && current.Value == enumerator.Current)

    // A Chunk has a linked list of ChunkItems, which represent the elements in the current chunk. Each ChunkItem
    // has a reference to the next ChunkItem in the list.
    class ChunkItem
    {
        public ChunkItem(TSource value) => Value = value;
        public readonly TSource Value;
        public ChunkItem? Next;
    }

    public TKey Key { get; }

    // Stores a reference to the enumerator for the source sequence
    private IEnumerator<TSource> enumerator;

    // A reference to the predicate that is used to compare keys.
    private Func<TSource, bool> predicate;

    // Stores the contents of the first source element that
    // belongs with this chunk.
    private readonly ChunkItem head;

    // End of the list. It is repositioned each time a new
    // ChunkItem is added.
    private ChunkItem? tail;

    // Flag to indicate the source iterator has reached the end of the source sequence.
    internal bool isLastSourceElement;

    // Private object for thread synchronization
    private readonly object m_Lock;

    // REQUIRES: enumerator != null && predicate != null
    public Chunk(TKey key, [DisallowNull] IEnumerator<TSource> enumerator, [DisallowNull] Func<TSource, bool> predicate)
    {
        Key = key;
        this.enumerator = enumerator;
        this.predicate = predicate;

        // A Chunk always contains at least one element.
        head = new ChunkItem(enumerator.Current);

        // The end and beginning are the same until the list contains > 1 elements.
        tail = head;

        m_Lock = new object();
    }

    // Indicates that all chunk elements have been copied to the list of ChunkItems.
    private bool DoneCopyingChunk => tail == null;

    // Adds one ChunkItem to the current group
    // REQUIRES: !DoneCopyingChunk && lock(this)
    private void CopyNextChunkElement()
    {
        // Try to advance the iterator on the source sequence.
        isLastSourceElement = !enumerator.MoveNext();

        // If we are (a) at the end of the source, or (b) at the end of the current chunk
        // then null out the enumerator and predicate for reuse with the next chunk.
        if (isLastSourceElement || !predicate(enumerator.Current))
        {
            enumerator = default!;
            predicate = default!;
        }
        else
        {
            tail!.Next = new ChunkItem(enumerator.Current);
        }

        // tail will be null if we are at the end of the chunk elements
        // This check is made in DoneCopyingChunk.
        tail = tail!.Next;
    }

    // Called after the end of the last chunk was reached.
    internal bool CopyAllChunkElements()
    {
        while (true)
        {
            lock (m_Lock)
            {
                if (DoneCopyingChunk)
                {
                    return isLastSourceElement;
                }
                else
                {
                    CopyNextChunkElement();
                }
            }
        }
    }

    // Stays just one step ahead of the client requests.
    public IEnumerator<TSource> GetEnumerator()
    {
        // Specify the initial element to enumerate.
        ChunkItem? current = head;

        // There should always be at least one ChunkItem in a Chunk.
        while (current != null)
        {
            // Yield the current item in the list.
            yield return current.Value;

            // Copy the next item from the source sequence,
            // if we are at the end of our local list.
            lock (m_Lock)
            {
                if (current == tail)
                {
                    CopyNextChunkElement();
                }
            }

            // Move to the next ChunkItem in the list.
            current = current.Next;
        }
    }

    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}

ChunkItem(ChunkItem 클래스로 표시됨)에는 목록에 다음 ChunkItem에 대한 참조가 있습니다. 목록은 이 청크에 속하는 첫 번째 소스 요소의 내용을 저장하는 head와 목록의 끝인 tail로 구성됩니다. 새 ChunkItem이 추가될 때마다 꼬리의 위치가 변경됩니다. 다음 요소의 키가 현재 청크의 키와 일치하지 않거나 소스에 더 이상 요소가 없는 경우, 연결된 목록의 꼬리가 CopyNextChunkElement 메서드에서 null로 설정됩니다.

Chunk 클래스의 CopyNextChunkElement 메서드는 현재 항목 그룹에 하나의 ChunkItem을 추가합니다. 소스 시퀀스에서 반복기를 진행하려고 합니다. MoveNext() 메서드가 false를 반환하면 반복은 끝에 있고 isLastSourceElementtrue로 설정됩니다.

CopyAllChunkElements 메서드는 마지막 청크의 끝에 도달한 후 호출됩니다. 먼저 소스 시퀀스에 더 많은 요소가 있는지 확인합니다. 더 많은 요소가 있는 경우 이 청크에 대한 열거자가 소진되면 true를 반환합니다. 이 메서드에서 프라이빗 DoneCopyingChunk 필드가 true로 확인될 때 isLastSourceElement가 false이면 외부 반복기에 반복을 계속하도록 신호를 보냅니다.

내부 foreach 루프가 Chunk 클래스의 GetEnumerator 메서드를 호출합니다. 이 메서드는 클라이언트 요청보다 한 요소 앞서 있습니다. 클라이언트가 목록의 이전 마지막 요소를 요청한 후에만 청크의 다음 요소를 추가합니다.