컬렉션 식
컬렉션 식 (Collection expressions)
컬렉션 식을 쓰면 흔히 쓰는 컬렉션 값을 간결하게 만들 수 있어요. 컬렉션 식(collection expression)은 [와 ] 괄호 사이에 요소들의 나열을 담는 짧은 문법인데, 같은 식을 다양한 컬렉션 타입에 할당할 수 있다는 게 핵심이에요.
본문
먼저 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" 다섯 개 요소를 만들어 내고, ..consonants는 consonants 배열의 개수인 20개 요소를 만들어 내요. 전개 요소 안의 식은 반드시 foreach 문으로 열거할 수 있어야 해요. 그리고 위 예시처럼 전개 요소는 컬렉션 식의 단일 요소들과 자유롭게 섞어 쓸 수 있답니다.
변환 (Conversions)
컬렉션 식은 다음과 같은 다양한 컬렉션 타입으로 변환할 수 있어요.
- xref:System.Span`1?displayProperty=nameWithType과 xref:System.ReadOnlySpan`1?displayProperty=nameWithType.
int[],string[]같은 배열.- 매개변수 타입이
ReadOnlySpan<T>인 create 메서드를 갖는 타입. 단, 컬렉션 식 타입에서T로의 암시적 변환이 있어야 해요. - 컬렉션 이니셜라이저를 지원하는 모든 타입(예: xref:System.Collections.Generic.List`1?displayProperty=nameWithType). 보통 이 요구사항은 타입이 xref:System.Collections.Generic.IEnumerable`1?displayProperty=nameWithType을 지원하고, 항목을 추가할 수 있는 접근 가능한
Add메서드가 있다는 뜻이에요. 그리고 컬렉션 식 요소 타입에서 컬렉션 요소 타입으로, 전개 요소의 경우 전개 요소 타입에서 컬렉션 요소 타입으로의 암시적 변환이 있어야 해요. - 다음 인터페이스 중 하나:
- xref:System.Collections.Generic.IEnumerable`1?displayProperty=fullName.
- xref:System.Collections.Generic.IReadOnlyCollection`1?displayProperty=fullName.
- xref:System.Collections.Generic.IReadOnlyList`1?displayProperty=fullName.
- xref:System.Collections.Generic.ICollection`1?displayProperty=fullName.
- xref:System.Collections.Generic.IList`1?displayProperty=fullName.
[!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 컬렉션의
Count나Length값이 열거 시 요소 개수와 같은 값을 만들어 내요. - 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];
}
위 예시를 정리하면 이렇게 돼요.
capacity매개변수를 받는List<string>생성자에values.Length * 2가 전달돼요.- xref:System.Collections.Generic.IEqualityComparer`1?displayProperty=nameWithType 매개변수를 받는
HashSet<string>생성자에StringComparer.OrdinalIgnoreCase가 전달돼요. - xref:System.Collections.Generic.IList`1?displayProperty=nameWithType 같은 인터페이스 대상 타입이라면 컴파일러가 지정된 용량으로
List<T>를 만들어요.
컬렉션 빌더 인자
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`1와 xref:System.Collections.Generic.ICollection`1의 경우 컴파일러는 지정된 생성자를 가진 xref:System.Collections.Generic.List`1?displayProperty=nameWithType을 사용해요.
제한 사항
with(...) 요소에는 다음 제한이 있어요.
- 컬렉션 식에서 첫 번째 요소여야 해요.
- 인자 타입이
dynamic일 수 없어요. - 배열이나 span 타입(
Span<T>,ReadOnlySpan<T>)에서는 지원되지 않아요. with(...)의 인자는 컬렉션 식에서 후보 대상 타입으로의 변환이 존재하는지에 영향을 주지 않아요. 컴파일러는 오버로드 해석과 타입 유추 중에 이를 무시해요. 변환이 존재하는지 여부에 영향을 주는 것은 그 인자가 아니라with(...)요소의 존재 자체예요.