컬렉션 식

컬렉션 식 (Collection expressions)

컬렉션 식을 쓰면 흔히 쓰는 컬렉션 값을 간결하게 만들 수 있어요. 컬렉션 식(collection expression)은 [] 괄호 사이에 요소들의 나열을 담는 짧은 문법인데, 같은 식을 다양한 컬렉션 타입에 할당할 수 있다는 게 핵심이에요.

출처: Collection expressions - C# language reference

본문

먼저 string 요소를 담는 xref:System.Span`1?displayProperty=nameWithType을 선언하고 요일로 초기화하는 예시를 볼게요.

Span<string> weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
foreach (var day in weekDays)
{
    Console.WriteLine(day);
}

컬렉션 식은 아주 많은 컬렉션 타입으로 변환될 수 있어요. 첫 번째 예시는 변수를 초기화하는 용도였죠. 아래 코드는 컬렉션 식을 쓸 수 있는 다른 여러 위치를 보여줘요.

// Initialize private field:
private static readonly ImmutableArray<string> _months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

// property with expression body:
public IEnumerable<int> MaxDays =>
    [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

public int Sum(IEnumerable<int> values) =>
    values.Sum();

public void Example()
{
    // As a parameter:
    int sum = Sum([1, 2, 3, 4, 5]);
}

다만 컴파일 타임 상수가 필요한 자리에서는 컬렉션 식을 쓸 수 없어요. 상수를 초기화하거나 메서드 인자의 기본값으로 쓸 때가 그런 경우죠.

지금까지 예시들은 컬렉션 식의 요소로 상수를 썼는데요, 요소에는 변수도 들어갈 수 있어요. 다음 예시를 보면 금방 와닿을 거예요.

string hydrogen = "H";
string helium = "He";
string lithium = "Li";
string beryllium = "Be";
string boron = "B";
string carbon = "C";
string nitrogen = "N";
string oxygen = "O";
string fluorine = "F";
string neon = "Ne";
string[] elements = [hydrogen, helium, lithium, beryllium, boron, carbon, nitrogen, oxygen, fluorine, neon];
foreach (var element in elements)
{
    Console.WriteLine(element);
}

전개 요소 (Spread element)

컬렉션 식 안에 다른 컬렉션의 값을 그대로 펼쳐 넣고 싶을 때는 전개 요소(spread element) ..를 써요. 아래 예시는 모음 컬렉션, 자음 컬렉션, 그리고 어느 쪽에도 속할 수 있는 글자 "y"를 합쳐서 전체 알파벳 컬렉션을 만들어요.

string[] vowels = ["a", "e", "i", "o", "u"];
string[] consonants = ["b", "c", "d", "f", "g", "h", "j", "k", "l", "m",
                       "n", "p", "q", "r", "s", "t", "v", "w", "x", "z"];
string[] alphabet = [.. vowels, .. consonants, "y"];

전개 요소 ..vowels는 평가되면 "a", "e", "i", "o", "u" 다섯 개 요소를 만들어 내고, ..consonantsconsonants 배열의 개수인 20개 요소를 만들어 내요. 전개 요소 안의 식은 반드시 foreach 문으로 열거할 수 있어야 해요. 그리고 위 예시처럼 전개 요소는 컬렉션 식의 단일 요소들과 자유롭게 섞어 쓸 수 있답니다.

변환 (Conversions)

컬렉션 식은 다음과 같은 다양한 컬렉션 타입으로 변환할 수 있어요.

[!NOTE] 컬렉션 식으로는 인라인 배열을 초기화할 수 없어요. 인라인 배열은 다른 초기화 문법이 필요해요.

[!IMPORTANT] 컬렉션 식은 변환 대상 타입과 무관하게, 식에 포함된 모든 요소를 담는 컬렉션을 항상 만들어 내요. 예를 들어 변환 대상이 xref:System.Collections.Generic.IEnumerable`1?displayProperty=nameWithType이면, 생성된 코드는 컬렉션 식을 평가해서 그 결과를 메모리 내 컬렉션에 저장해요.

이 동작은 열거되기 전까지 시퀀스가 인스턴스화되지 않을 수도 있는 LINQ와는 달라요. 컬렉션 식으로는 열거되지 않을 무한 시퀀스를 만들 수 없어요.

컴파일러는 정적 분석을 통해 컬렉션 식으로 선언된 컬렉션을 만들 때 가장 성능이 좋은 방법을 골라요. 예를 들어 빈 컬렉션 식 []은 초기화 후 대상이 수정되지 않는다면 xref:System.Array.Empty``1?displayProperty=nameWithType으로 구현될 수 있어요. 대상이 xref:System.Span`1?displayProperty=nameWithType이나 xref:System.ReadOnlySpan`1?displayProperty=nameWithType이면 저장소가 스택에 할당될 수도 있어요. 컴파일러가 따라야 하는 규칙은 C# 표준의 §12.8.25 Collection expressions 절에 명시돼 있어요.

여러 API는 매개변수로 컬렉션 타입 여러 개를 오버로드로 갖는 경우가 많아요. 컬렉션 식은 다양한 식 타입으로 변환될 수 있기 때문에, 올바른 변환을 지정하려면 이런 API에서 컬렉션 식에 캐스팅이 필요할 수 있어요. 다음 변환 규칙이 그 모호함의 일부를 해소해 줘요.

  • 더 나은 컬렉션 타입 변환보다 더 나은 요소 변환이 우선돼요. 다시 말해, 컬렉션 타입보다 컬렉션 식 요소의 타입이 더 중요해요. 이 규칙은 C# 언어 명세의 better conversion from expression 절에 설명돼 있어요.
  • xref:System.Span`1, xref:System.ReadOnlySpan`1 또는 다른 ref struct 타입으로의 변환이 non-ref struct 타입으로의 변환보다 더 좋아요.
  • 인터페이스 타입으로의 변환보다 비인터페이스 타입으로의 변환이 더 좋아요.

컬렉션 식을 Span이나 ReadOnlySpan으로 변환하면, span 객체의 safe context는 span에 포함된 모든 요소의 safe context에서 나와요. 자세한 규칙은 C# 표준의 §9.7.2 Ref safe contexts를 참고해요.

컬렉션 빌더 (Collection builder)

컬렉션 식은 well-behaved(올바르게 동작하는)한 컬렉션 타입이라면 어떤 것이든 함께 동작해요. well-behaved한 컬렉션은 다음 특징을 가져요.

  • countable 컬렉션의 CountLength 값이 열거 시 요소 개수와 같은 값을 만들어 내요.
  • xref:System.Collections.Generic?displayProperty=fullName 네임스페이스의 타입들은 부수 효과(side-effect)가 없어요. 컴파일러는 이런 타입이 중간 값으로 쓰이는 시나리오를 최적화할 수 있지만, 그 외에는 노출하지 않아요.
  • 컬렉션에서 적용 가능한 .AddRange(x) 멤버를 호출한 결과가 x를 반복하면서 각 열거 값을 .Add로 하나씩 추가한 최종 값과 같아요.

.NET 런타임에 있는 모든 컬렉션 타입은 well-behaved해요.

[!WARNING] 사용자 정의 컬렉션 타입이 well-behaved하지 않으면, 그 타입을 컬렉션 식과 함께 쓸 때의 동작은 정의되지 않아요(undefined).

여러분의 타입이 컬렉션 식을 지원하도록 하려면 Create() 메서드를 작성하고, 컬렉션 타입에 xref:System.Runtime.CompilerServices.CollectionBuilderAttribute?displayProperty=fullName 특성을 적용해서 빌더 메서드를 가리키면 돼요. 예를 들어 80자 길이의 고정 길이 버퍼를 쓰는 애플리케이션을 생각해 볼게요. 그 클래스는 대략 다음 코드처럼 생겼어요.

public class LineBuffer : IEnumerable<char>
{
    private readonly char[] _buffer;
    private readonly int _count;

    public LineBuffer(ReadOnlySpan<char> buffer)
    {
        _buffer = new char[buffer.Length];
        _count = buffer.Length;
        for (int i = 0; i < _count; i++)
        {
            _buffer[i] = buffer[i];
        }
    }

    public int Count => _count;
    
    public char this[int index]
    {
        get
        {
            if (index >= _count)
                throw new IndexOutOfRangeException();
            return _buffer[index];
        }
    }

    public IEnumerator<char> GetEnumerator()
    {
        for (int i = 0; i < _count; i++)
        {
            yield return _buffer[i];
        }
    }
    
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    // etc
}

이 클래스를 다음 샘플처럼 컬렉션 식으로 쓰고 싶다고 해볼게요.

LineBuffer line = ['H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd', '!'];

LineBuffer 타입은 IEnumerable<char>를 구현하므로 컴파일러는 이를 char 항목들의 컬렉션으로 인식해요. 구현된 xref:System.Collections.Generic.IEnumerable`1?displayProperty=nameWithType 인터페이스의 타입 매개변수가 요소 타입을 나타내는 거예요. LineBuffer 객체에 컬렉션 식을 할당하려면 애플리케이션에 두 가지를 추가해야 해요. 우선 Create 메서드를 담은 클래스를 만들어야 해요.

internal static class LineBufferBuilder
{
    internal static LineBuffer Create(ReadOnlySpan<char> values) => new LineBuffer(values);
}

Create 메서드는 LineBuffer 객체를 반환해야 하고, 마지막 매개변수 타입은 ReadOnlySpan<char>여야 해요. ReadOnlySpan의 타입 매개변수는 컬렉션의 요소 타입과 일치해야 해요. 제네릭 컬렉션을 반환하는 빌더 메서드는 제네릭 ReadOnlySpan<T>를 매개변수로 가져요. 이 메서드는 접근 가능하고 static이어야 해요.

C# 15부터 Create 메서드는 ReadOnlySpan<T> 매개변수 앞에 추가 매개변수를 가질 수 있어요. 이 매개변수들에는 컬렉션 식 안의 with(...) 요소로 값을 전달할 수 있어요. 자세한 내용은 컬렉션 빌더 인자를 참고해요.

마지막으로 LineBuffer 클래스 선언에 xref:System.Runtime.CompilerServices.CollectionBuilderAttribute를 추가해야 해요.

[CollectionBuilder(typeof(LineBufferBuilder), "Create")]

첫 번째 매개변수는 Builder 클래스의 이름을, 두 번째 매개변수는 빌더 메서드의 이름을 제공해요.

컬렉션 식 인자 (Collection expression arguments)

C# 15부터 컬렉션 식의 첫 번째 요소에 with(...) 요소를 써서 기본 컬렉션의 생성자나 팩토리 메서드에 인자를 전달할 수 있어요. 이 기능 덕분에 용량(capacity), 비교자(comparer), 기타 생성자 매개변수를 컬렉션 식 문법 안에서 직접 지정할 수 있어요. 자세한 내용은 collection expression arguments 기능 사양을 참고해요.

with(...) 요소는 컬렉션 식의 첫 번째 요소여야 해요. with(...) 요소에 선언된 인자는 대상 타입에 따라 적절한 생성자나 create 메서드로 들어가요. with 요소의 인자에는 유효한 어떤 식이든 쓸 수 있어요.

생성자 인자

대상 타입이 xref:System.Collections.IEnumerable?displayProperty=nameWithType을 구현하는 클래스나 구조체라면, with(...)의 인자가 평가되어 그 결과가 생성자로 전달돼요. 컴파일러는 오버로드 해석으로 가장 잘 맞는 생성자를 선택해요.

public void CollectionArgumentsExamples()
{
    string[] values = ["one", "two", "three"];

    // Pass capacity argument to List<T> constructor
    List<string> names = [with(capacity: values.Length * 2), .. values];

    // Pass comparer argument to HashSet<T> constructor
    HashSet<string> set = [with(StringComparer.OrdinalIgnoreCase), "Hello", "HELLO", "hello"];
    // set contains only one element because all strings are equal with OrdinalIgnoreCase

    // Pass capacity to IList<T> (uses List<T> constructor)
    IList<int> numbers = [with(capacity: 100), 1, 2, 3];
}

위 예시를 정리하면 이렇게 돼요.

컬렉션 빌더 인자

xref:System.Runtime.CompilerServices.CollectionBuilderAttribute?displayProperty=nameWithType가 있는 타입의 경우, with(...) 요소에 선언된 인자가 평가되어 ReadOnlySpan<T> 매개변수 앞에 create 메서드로 전달돼요. 이 기능 덕분에 create 메서드가 구성 매개변수를 받을 수 있어요.

internal static class MySetBuilder
{
    internal static MySet<T> Create<T>(ReadOnlySpan<T> items) => new MySet<T>(items);
    internal static MySet<T> Create<T>(IEqualityComparer<T> comparer, ReadOnlySpan<T> items) => 
        new MySet<T>(items, comparer);
}

그리고 with(...) 요소로 비교자를 전달할 수 있어요.

public void CollectionBuilderArgumentsExample()
{
    // Pass comparer to a type with CollectionBuilder attribute
    // The comparer argument is passed before the ReadOnlySpan<T> parameter
    MySet<string> mySet = [with(StringComparer.OrdinalIgnoreCase), "A", "a", "B"];
    // mySet contains only two elements: "A" and "B"
}

create 메서드는 여러분이 제공하는 인자를 기준으로 오버로드 해석으로 선택돼요. 컬렉션 요소를 담는 ReadOnlySpan<T>은 항상 마지막 매개변수예요.

인터페이스 대상 타입

여러 인터페이스 대상 타입이 컬렉션 식 인자를 지원해요. 다음 표는 지원되는 인터페이스와 그에 해당하는 생성자 시그니처를 보여줘요.

Interface Supported with elements
xref:System.Collections.Generic.IEnumerable`1, xref:System.Collections.Generic.IReadOnlyCollection`1, xref:System.Collections.Generic.IReadOnlyList`1 () (empty only)
xref:System.Collections.Generic.ICollection`1, xref:System.Collections.Generic.IList`1 (), (int capacity)

xref:System.Collections.Generic.IList`1xref:System.Collections.Generic.ICollection`1의 경우 컴파일러는 지정된 생성자를 가진 xref:System.Collections.Generic.List`1?displayProperty=nameWithType을 사용해요.

제한 사항

with(...) 요소에는 다음 제한이 있어요.

  • 컬렉션 식에서 첫 번째 요소여야 해요.
  • 인자 타입이 dynamic일 수 없어요.
  • 배열이나 span 타입(Span<T>, ReadOnlySpan<T>)에서는 지원되지 않아요.
  • with(...)의 인자는 컬렉션 식에서 후보 대상 타입으로의 변환이 존재하는지에 영향을 주지 않아요. 컴파일러는 오버로드 해석과 타입 유추 중에 이를 무시해요. 변환이 존재하는지 여부에 영향을 주는 것은 그 인자가 아니라 with(...) 요소의 존재 자체예요.

더 알아보기