패턴 매칭 — `is`·`switch` 표현식과 `and`, `or`, `not` 패턴 연산자
패턴 매칭 — is·switch 표현식과 and, or, not 패턴 연산자
입력값이 어떤 특징을 갖는지 여러 가지 기준으로 확인하고 싶을 때가 있어요. 그럴 때 is 표현식, switch 문, 그리고 switch 표현식을 쓰면 입력 표현식을 원하는 만큼의 특성과 대조해 볼 수 있어요. C#이 지원하는 패턴은 선언, 형식, 상수, 관계, 속성, 목록, var, discard까지 여럿인데요. 이 패턴들은 불 논리 키워드인 and, or, not을 조합해서 묶을 수도 있어요.
본문
패턴 매칭을 지원하는 C# 표현식과 문은 다음 세 가지입니다.
이 구성들 안에서 입력 표현식을 아래 패턴들 중 어떤 것과도 대조할 수 있어요.
- 선언 패턴: 표현식의 런타임 형식을 확인하고, 매칭에 성공하면 그 결과를 선언한 변수에 담아요.
- 형식 패턴: 표현식의 런타임 형식을 확인해요.
- 상수 패턴: 표현식의 결과가 지정한 상수와 같은지 검사해요.
- 관계 패턴: 표현식의 결과를 지정한 상수와 비교해요.
- 논리 패턴: 표현식이 패턴들의 논리적 조합과 일치하는지 검사해요.
- 속성 패턴: 표현식의 속성이나 필드가 중첩 패턴과 일치하는지 검사해요.
- 위치 패턴: 표현식의 결과를 분해하고, 그 값들이 중첩 패턴과 일치하는지 검사해요.
var패턴: 어떤 표현식이든 매칭하고, 그 결과를 선언한 변수에 담아요.- Discard 패턴: 어떤 표현식이든 매칭해요.
- 목록 패턴: 요소들의 시퀀스가 대응하는 중첩 패턴과 일치하는지 검사해요.
논리, 속성, 위치, 목록 패턴은 재귀적 패턴이에요. 곧, 중첩 패턴을 담을 수 있다는 뜻이죠.
이 패턴들로 데이터 기반 알고리즘을 만드는 실제 예시가 궁금하다면 튜토리얼: 형식 기반 및 데이터 기반 알고리즘을 만드는 데 패턴 매칭 사용을 살펴보세요.
선언 패턴과 형식 패턴
선언 패턴과 형식 패턴으로 표현식의 런타임 형식이 주어진 형식과 호환되는지 확인할 수 있어요. 선언 패턴을 쓰면 새 지역 변수도 선언할 수 있고요. 선언 패턴이 표현식과 매칭되면, 그 변수에 변환된 표현식 결과를 넣어줘요. 아래 예시를 볼게요.
object greeting = "Hello, World!";
if (greeting is string message)
{
Console.WriteLine(message.ToLower()); // output: hello, world!
}
형식 T를 가진 선언 패턴은 표현식 결과가 null이 아니고 아래 조건 중 하나가 참일 때 표현식과 매칭돼요.
-
표현식 결과의 런타임 형식이
T로의 ID 변환(identity conversion)을 갖는 경우 -
형식
T가ref struct형식이고, 표현식에서T로의 ID 변환이 존재하는 경우 -
표현식 결과의 런타임 형식이 형식
T에서 파생했거나, 인터페이스T를 구현했거나, 혹은 그로부터T로의 또 다른 암시적 참조 변환이 존재하는 경우. 이 조건이 상속 관계와 인터페이스 구현을 모두 아우르는 건데요. 아래 예시는 이 조건이 성립하는 두 가지 경우를 보여줘요.var numbers = new int[] { 10, 20, 30 }; Console.WriteLine(GetSourceLabel(numbers)); // output: 1 var letters = new List<char> { 'a', 'b', 'c', 'd' }; Console.WriteLine(GetSourceLabel(letters)); // output: 2 static int GetSourceLabel<T>(IEnumerable<T> source) => source switch { Array array => 1, ICollection<T> collection => 2, _ => 3, };바로 위 예시에서,
GetSourceLabel메서드를 처음 호출할 때는 첫 번째 패턴이 인자 값과 매칭돼요. 인자의 런타임 형식인int[]가 xref:System.Array 형식에서 파생했기 때문이에요. 두 번째 호출에서는 인자의 런타임 형식 xref:System.Collections.Generic.List`1이 xref:System.Array에서 파생하지 않지만 xref:System.Collections.Generic.ICollection`1 인터페이스를 구현하고 있어요. -
표현식 결과의 런타임 형식이 기본 형식
T를 가진 nullable 값 형식이고, xref:System.Nullable`1.HasValue가true인 경우 -
표현식이
ref struct의 인스턴스가 아닐 때, 표현식 결과의 런타임 형식에서 형식T로의 boxing 또는 unboxing 변환이 존재하는 경우
선언 패턴은 사용자 정의 변환이나 암시적 span 변환은 고려하지 않아요.
아래 예시는 마지막 두 조건을 보여줘요.
int? xNullable = 7;
int y = 23;
object yBoxed = y;
if (xNullable is int a && yBoxed is int b)
{
Console.WriteLine(a + b); // output: 30
}
표현식의 형식만 확인하고 싶다면 변수 이름 자리에 discard _를 쓰면 돼요. 아래 예시처럼요.
public abstract class Vehicle {}
public class Car : Vehicle {}
public class Truck : Vehicle {}
public static class TollCalculator
{
public static decimal CalculateToll(this Vehicle vehicle) => vehicle switch
{
Car _ => 2.00m,
Truck _ => 7.50m,
null => throw new ArgumentNullException(nameof(vehicle)),
_ => throw new ArgumentException("Unknown type of a vehicle", nameof(vehicle)),
};
}
이런 용도에는 형식 패턴을 쓰는 게 더 자연스러워요. 아래 예시를 볼게요.
public static decimal CalculateToll(this Vehicle vehicle) => vehicle switch
{
Car => 2.00m,
Truck => 7.50m,
null => throw new ArgumentNullException(nameof(vehicle)),
_ => throw new ArgumentException("Unknown type of a vehicle", nameof(vehicle)),
};
형식 패턴도 선언 패턴과 마찬가지로, 표현식 결과가 null이 아니고 그 런타임 형식이 앞서 본 조건 중 하나를 만족할 때 매칭돼요.
null이 아닌 걸 확인하려면 부정된 null 상수 패턴을 쓰면 돼요. 아래처럼요.
if (input is not null)
{
// ...
}
더 자세한 내용은 C# 언어 사양의 선언 패턴과 형식 패턴 섹션을 참고하세요.
상수 패턴
상수 패턴은 오른쪽 피연산자가 상수일 때 ==에 대한 대체 문법이에요. 표현식의 결과가 지정한 상수와 같은지 검사하는 데 쓰는데요, 아래 예시를 볼게요.
public static decimal GetGroupTicketPrice(int visitorCount) => visitorCount switch
{
1 => 12.0m,
2 => 20.0m,
3 => 27.0m,
4 => 32.0m,
0 => 0.0m,
_ => throw new ArgumentException($"Not supported number of visitors: {visitorCount}", nameof(visitorCount)),
};
상수 패턴에서는 어떤 상수 표현식이든 쓸 수 있어요. 예를 들면요.
표현식은 상수 형식으로 변환 가능한 형식이어야 하는데, 예외가 하나 있어요. 형식이 Span<char>나 ReadOnlySpan<char>인 표현식은 상수 문자열과 대조될 수 있어요.
상수 패턴으로 null인지도 확인할 수 있어요. 아래 예시를 볼게요.
if (input is null)
{
return;
}
컴파일러는 표현식 x is null을 평가할 때 사용자가 오버로드한 같음 연산자 ==를 호출하지 않는다는 걸 보장해요.
null이 아닌지 확인하려면 부정된 null 상수 패턴을 쓰면 돼요. 아래처럼요.
if (input is not null)
{
// ...
}
더 자세한 내용은 기능 제안 노트의 상수 패턴 섹션을 참고하세요.
관계 패턴
관계 패턴은 표현식의 결과를 상수와 비교할 때 써요. 아래 예시를 볼게요.
Console.WriteLine(Classify(13)); // output: Too high
Console.WriteLine(Classify(double.NaN)); // output: Unknown
Console.WriteLine(Classify(2.4)); // output: Acceptable
static string Classify(double measurement) => measurement switch
{
< -4.0 => "Too low",
> 10.0 => "Too high",
double.NaN => "Unknown",
_ => "Acceptable",
};
관계 패턴에서는 관계 연산자 <, >, <=, >= 중 어떤 것이든 쓸 수 있어요. 관계 패턴의 오른쪽 부분은 반드시 상수 표현식이어야 하고요. 그 상수 표현식은 정수, 부동 소수점, char, enum 형식 중 하나여야 해요.
표현식 결과가 어떤 범위 안에 있는지 확인하고 싶다면 접속(conjunctive) and 패턴과 같이 대조해 볼게요.
Console.WriteLine(GetCalendarSeason(new DateTime(2021, 3, 14))); // output: spring
Console.WriteLine(GetCalendarSeason(new DateTime(2021, 7, 19))); // output: summer
Console.WriteLine(GetCalendarSeason(new DateTime(2021, 2, 17))); // output: winter
static string GetCalendarSeason(DateTime date) => date.Month switch
{
>= 3 and < 6 => "spring",
>= 6 and < 9 => "summer",
>= 9 and < 12 => "autumn",
12 or (>= 1 and < 3) => "winter",
_ => throw new ArgumentOutOfRangeException(nameof(date), $"Date with unexpected month: {date.Month}."),
};
표현식 결과가 null이거나, nullable이나 unboxing 변환으로 상수 형식으로 변환하는 데 실패하면 관계 패턴은 그 표현식과 매칭되지 않아요.
더 자세한 내용은 C# 언어 사양의 관계 패턴 섹션을 참고하세요.
논리 패턴
not, and, or 패턴 결합자를 쓰면 아래와 같은 논리 패턴을 만들 수 있어요.
-
부정(negation)
not패턴 — 부정된 패턴이 표현식과 매칭되지 않을 때 표현식을 매칭해요. 아래 예시는 상수null패턴을 부정해서 표현식이 null이 아닌지 확인하는 방법을 보여줘요.if (input is not null) { // ... } -
접속(conjunctive)
and패턴 — 두 패턴이 모두 표현식과 매칭될 때 매칭해요. 아래 예시는 관계 패턴을 조합해 값이 어떤 범위 안에 있는지 확인하는 방법을 보여줘요.Console.WriteLine(Classify(13)); // output: High Console.WriteLine(Classify(-100)); // output: Too low Console.WriteLine(Classify(5.7)); // output: Acceptable static string Classify(double measurement) => measurement switch { < -40.0 => "Too low", >= -40.0 and < 0 => "Low", >= 0 and < 10.0 => "Acceptable", >= 10.0 and < 20.0 => "High", >= 20.0 => "Too high", double.NaN => "Unknown", }; -
선택(disjunctive)
or패턴 — 두 패턴 중 어느 하나라도 표현식과 매칭되면 매칭해요. 아래 예시를 볼게요.Console.WriteLine(GetCalendarSeason(new DateTime(2021, 1, 19))); // output: winter Console.WriteLine(GetCalendarSeason(new DateTime(2021, 10, 9))); // output: autumn Console.WriteLine(GetCalendarSeason(new DateTime(2021, 5, 11))); // output: spring static string GetCalendarSeason(DateTime date) => date.Month switch { 3 or 4 or 5 => "spring", 6 or 7 or 8 => "summer", 9 or 10 or 11 => "autumn", 12 or 1 or 2 => "winter", _ => throw new ArgumentOutOfRangeException(nameof(date), $"Date with unexpected month: {date.Month}."), };
앞선 예시에서 보듯이, 패턴 안에서 패턴 결합자를 반복해서 쓸 수 있어요.
우선순위와 검사 순서
패턴 결합자는 표현식의 바인딩 순서에 따라 아래 순서로 표현식을 검사해요.
notandor
not 패턴이 자신의 피연산자에 가장 먼저 바인딩돼요. and 패턴은 모든 not 패턴 표현식 바인딩 뒤에 바인딩되고, or 패턴은 모든 not과 and 패턴이 피연산자에 바인딩된 뒤에 바인딩돼요. 아래 예시는 소문자 a부터 z가 아닌 모든 문자를 매칭하려고 하는 건데, 오류가 나요. not 패턴이 and 패턴보다 먼저 바인딩되기 때문이에요.
// Incorrect pattern. `not` binds before `and`
static bool IsNotLowerCaseLetter(char c) => c is not >= 'a' and <= 'z';
기본 바인딩 때문에 위 예시는 아래 예시처럼 해석돼요.
// The default binding without parentheses is shows in this method. `not` binds before `and`
static bool IsNotLowerCaseLetterDefaultBinding(char c) => c is ((not >= 'a') and <= 'z');
오류를 고치려면 not 패턴이 >= 'a' and <= 'z' 표현식에 바인딩되도록 지정하면 돼요.
// Correct pattern. Force `and` before `not`
static bool IsNotLowerCaseLetterParentheses(char c) => c is not (>= 'a' and <= 'z');
패턴이 복잡해질수록 괄호가 더 중요해져요. 일반적으로는 다른 개발자들이 이해하기 쉽도록 패턴을 괄호로 묶는 게 좋아요. 아래 예시처럼요.
static bool IsLetter(char c) => c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z');
[!NOTE] 바인딩 순서가 같은 패턴들을 컴파일러가 어떤 순서로 검사하는지는 정의되지 않았어요. 런타임에는 컴파일러가 여러
or패턴과 여러and패턴의 오른쪽 중첩 패턴을 먼저 검사할 수도 있어요.
더 자세한 내용은 C# 언어 사양의 패턴 결합자 섹션을 참고하세요.
속성 패턴
속성 패턴은 표현식의 속성이나 필드를 중첩 패턴과 대조할 때 써요. 아래 예시를 볼게요.
static bool IsConferenceDay(DateTime date) => date is { Year: 2020, Month: 5, Day: 19 or 20 or 21 };
속성 패턴은 표현식 결과가 null이 아니고, 모든 중첩 패턴이 표현식 결과의 대응하는 속성이나 필드와 매칭될 때 표현식을 매칭해요.
속성 패턴에 런타임 형식 검사와 변수 선언을 추가할 수도 있어요. 아래처럼요.
Console.WriteLine(TakeFive("Hello, world!")); // output: Hello
Console.WriteLine(TakeFive("Hi!")); // output: Hi!
Console.WriteLine(TakeFive(new[] { '1', '2', '3', '4', '5', '6', '7' })); // output: 12345
Console.WriteLine(TakeFive(new[] { 'a', 'b', 'c' })); // output: abc
static string TakeFive(object input) => input switch
{
string { Length: >= 5 } s => s.Substring(0, 5),
string s => s,
ICollection<char> { Count: >= 5 } symbols => new string(symbols.Take(5).ToArray()),
ICollection<char> symbols => new string(symbols.ToArray()),
null => throw new ArgumentNullException(nameof(input)),
_ => throw new ArgumentException("Not supported input type."),
};
이 구성은 특히 빈 속성 패턴 is { }가 null이 아닌 모든 것을 매칭한다는 뜻이에요. 그래서 is not null 대신 변수를 만들 때 쓸 수 있어요. somethingPossiblyNull is { } somethingDefinitelyNotNull처럼요.
if (GetSomeNullableStringValue() is { } nonNullValue) // Empty property pattern with variable creation
{
Console.WriteLine("NotNull:" + nonNullValue);
}
else
{
nonNullValue = "NullFallback"; // we can access the variable here.
Console.WriteLine("it was null, here's the fallback: " + nonNullValue);
}
속성 패턴은 재귀적 패턴이에요. 어떤 패턴이든 중첩 패턴으로 쓸 수 있고요. 데이터의 일부분을 중첩 패턴과 대조할 때 쓸 수 있어요. 아래 예시를 볼게요.
public record Point(int X, int Y);
public record Segment(Point Start, Point End);
static bool IsAnyEndOnXAxis(Segment segment) =>
segment is { Start: { Y: 0 } } or { End: { Y: 0 } };
앞선 예시는 or 패턴 결합자와 레코드 형식을 쓰고 있어요.
속성 패턴 안에서 중첩된 속성이나 필드를 참조할 수도 있는데, 이 능력을 *확장 속성 패턴(extended property pattern)*이라고 불러요. 예를 들어 앞선 예시의 메서드를 아래와 같은 동등한 코드로 리팩터링할 수 있어요.
static bool IsAnyEndOnXAxis(Segment segment) =>
segment is { Start.Y: 0 } or { End.Y: 0 };
더 자세한 내용은 C# 표준의 속성 패턴 섹션을 참고하세요.
[!TIP] 코드 가독성을 높이려면 속성 패턴 단순화(IDE0170) 스타일 규칙을 쓰는 걸 추천해요. 확장 속성 패턴을 쓸 자리를 제안해 줘요.
위치 패턴
위치 패턴은 표현식을 분해하고 그 결과 값들을 대응하는 중첩 패턴과 매칭할 때 써요. 아래 예시를 볼게요.
public readonly struct Point
{
public int X { get; }
public int Y { get; }
public Point(int x, int y) => (X, Y) = (x, y);
public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);
}
static string Classify(Point point) => point switch
{
(0, 0) => "Origin",
(1, 0) => "positive X basis end",
(0, 1) => "positive Y basis end",
_ => "Just a point",
};
앞선 예시에서 표현식의 형식은 Deconstruct 메서드를 담고 있는데, 패턴이 그 메서드로 표현식 결과를 분해해요.
[!IMPORTANT] 위치 패턴에서 멤버의 순서는
Deconstruct메서드의 매개변수 순서와 일치해야 해요. 위치 패턴에 대해 생성된 코드가Deconstruct메서드를 호출하기 때문이에요.
튜플 형식의 표현식도 위치 패턴과 매칭할 수 있어요. 이렇게 하면 여러 입력을 다양한 패턴과 매칭할 수 있는데, 아래 예시를 볼게요.
static decimal GetGroupTicketPriceDiscount(int groupSize, DateTime visitDate)
=> (groupSize, visitDate.DayOfWeek) switch
{
(<= 0, _) => throw new ArgumentException("Group size must be positive."),
(_, DayOfWeek.Saturday or DayOfWeek.Sunday) => 0.0m,
(>= 5 and < 10, DayOfWeek.Monday) => 20.0m,
(>= 10, DayOfWeek.Monday) => 30.0m,
(>= 5 and < 10, _) => 12.0m,
(>= 10, _) => 15.0m,
_ => 0.0m,
};
위치 패턴 안에서 튜플 요소 이름과 Deconstruct 매개변수 이름을 쓸 수도 있어요. 아래 예시를 볼게요.
var numbers = new List<int> { 1, 2, 3 };
if (SumAndCount(numbers) is (Sum: var sum, Count: > 0))
{
Console.WriteLine($"Sum of [{string.Join(" ", numbers)}] is {sum}"); // output: Sum of [1 2 3] is 6
}
static (double Sum, int Count) SumAndCount(IEnumerable<int> numbers)
{
int sum = 0;
int count = 0;
foreach (int number in numbers)
{
sum += number;
count++;
}
return (sum, count);
}
위치 패턴을 아래와 같은 방법으로 확장할 수도 있어요.
-
런타임 형식 검사와 변수 선언을 추가하는 방법. 아래 예시를 볼게요.
public record Point2D(int X, int Y); public record Point3D(int X, int Y, int Z); static string PrintIfAllCoordinatesArePositive(object point) => point switch { Point2D (> 0, > 0) p => p.ToString(), Point3D (> 0, > 0, > 0) p => p.ToString(), _ => string.Empty, };앞선 예시는
Deconstruct메서드를 암시적으로 제공하는 위치 레코드를 쓰고 있어요. -
위치 패턴 안에서 속성 패턴을 쓰는 방법. 아래 예시를 볼게요.
public record WeightedPoint(int X, int Y) { public double Weight { get; set; } } static bool IsInDomain(WeightedPoint point) => point is (>= 0, >= 0) { Weight: >= 0.0 }; -
앞의 두 용법을 결합하는 방법. 아래 예시를 볼게요.
if (input is WeightedPoint (> 0, > 0) { Weight: > 0.0 } p) { // .. }
위치 패턴은 재귀적 패턴이에요. 어떤 패턴이든 중첩 패턴으로 쓸 수 있다는 뜻이죠.
더 자세한 내용은 기능 제안 노트의 위치 패턴 섹션을 참고하세요.
var 패턴
var 패턴은 null을 포함한 어떤 표현식이든 매칭하고, 그 결과를 새 지역 변수에 담을 때 써요. 아래 예시를 볼게요.
static bool IsAcceptable(int id, int absLimit) =>
SimulateDataFetch(id) is var results
&& results.Min() >= -absLimit
&& results.Max() <= absLimit;
static int[] SimulateDataFetch(int id)
{
var rand = new Random();
return Enumerable
.Range(start: 0, count: 5)
.Select(s => rand.Next(minValue: -10, maxValue: 11))
.ToArray();
}
var 패턴은 불 표현식 안에서 중간 계산 결과를 담을 임시 변수가 필요할 때 유용해요. switch 표현식이나 switch 문의 when case 가드에서 추가 검사를 더 해야 할 때도 var 패턴을 쓸 수 있는데, 아래 예시를 볼게요.
public record Point(int X, int Y);
static Point Transform(Point point) => point switch
{
var (x, y) when x < y => new Point(-x, y),
var (x, y) when x > y => new Point(x, -y),
var (x, y) => new Point(x, y),
};
static void TestTransform()
{
Console.WriteLine(Transform(new Point(1, 2))); // output: Point { X = -1, Y = 2 }
Console.WriteLine(Transform(new Point(5, 2))); // output: Point { X = 5, Y = -2 }
}
앞선 예시에서 패턴 var (x, y)는 위치 패턴 (var x, var y)와 동등해요.
var 패턴에서 선언된 변수의 형식은 패턴이 매칭하는 표현식의 컴파일 타임 형식이에요.
더 자세한 내용은 기능 제안 노트의 Var 패턴 섹션을 참고하세요.
Discard 패턴
discard 패턴 _은 null을 포함한 어떤 표현식이든 매칭할 때 써요. 아래 예시를 볼게요.
Console.WriteLine(GetDiscountInPercent(DayOfWeek.Friday)); // output: 5.0
Console.WriteLine(GetDiscountInPercent(null)); // output: 0.0
Console.WriteLine(GetDiscountInPercent((DayOfWeek)10)); // output: 0.0
static decimal GetDiscountInPercent(DayOfWeek? dayOfWeek) => dayOfWeek switch
{
DayOfWeek.Monday => 0.5m,
DayOfWeek.Tuesday => 12.5m,
DayOfWeek.Wednesday => 7.5m,
DayOfWeek.Thursday => 12.5m,
DayOfWeek.Friday => 5.0m,
DayOfWeek.Saturday => 2.5m,
DayOfWeek.Sunday => 2.0m,
_ => 0.0m,
};
앞선 예시에서 discard 패턴은 null과 xref:System.DayOfWeek 열거형에 대응하는 멤버가 없는 모든 정수 값을 처리해요. 그래서 그 예시의 switch 표현식이 가능한 모든 입력 값을 처리한다는 걸 보장하죠. switch 표현식에서 discard 패턴을 쓰지 않고 어떤 패턴도 입력과 매칭되지 않으면, 런타임이 예외를 던져요. 또 switch 표현식이 가능한 모든 입력 값을 처리하지 않으면 컴파일러가 경고를 생성해요.
discard 패턴은 is 표현식이나 switch 문에서는 패턴으로 쓸 수 없어요. 그런 경우 어떤 표현식이든 매칭하려면 discard와 함께 var 패턴을 쓰면 돼요. var _처럼요. discard 패턴은 switch 표현식에서는 패턴으로 쓸 수 있어요.
더 자세한 내용은 기능 제안 노트의 Discard 패턴 섹션을 참고하세요.
괄호 패턴
어떤 패턴이든 괄호로 감쌀 수 있어요. 보통 논리 패턴에서 우선순위를 강조하거나 바꾸려고 괄호를 쓰는데, 아래 예시를 볼게요.
if (input is not (float or double))
{
return;
}
목록 패턴
배열이나 목록을 시퀀스 패턴과 매칭할 수 있어요. 아래 예시를 볼게요.
int[] numbers = { 1, 2, 3 };
Console.WriteLine(numbers is [1, 2, 3]); // True
Console.WriteLine(numbers is [1, 2, 4]); // False
Console.WriteLine(numbers is [1, 2, 3, 4]); // False
Console.WriteLine(numbers is [0 or 1, <= 2, >= 3]); // True
앞선 예시에서 보듯이, 각 중첩 패턴이 입력 시퀀스의 대응하는 요소와 매칭될 때 목록 패턴이 매칭돼요. 목록 패턴 안에서는 어떤 패턴이든 쓸 수 있는데, 어떤 요소든 매칭하려면 discard 패턴을 쓰거나, 요소도 함께 담고 싶으면 var 패턴을 쓰면 돼요. 아래 예시를 볼게요.
List<int> numbers = new() { 1, 2, 3 };
if (numbers is [var first, _, _])
{
Console.WriteLine($"The first element of a three-item list is {first}.");
}
// Output:
// The first element of a three-item list is 1.
앞선 예시들은 입력 시퀀스 전체를 목록 패턴과 매칭해요. 입력 시퀀스의 시작이나 끝 — 또는 양쪽 — 에 있는 요소만 매칭하려면 슬라이스 패턴 ..을 쓰면 돼요. 아래 예시를 볼게요.
Console.WriteLine(new[] { 1, 2, 3, 4, 5 } is [> 0, > 0, ..]); // True
Console.WriteLine(new[] { 1, 1 } is [_, _, ..]); // True
Console.WriteLine(new[] { 0, 1, 2, 3, 4 } is [> 0, > 0, ..]); // False
Console.WriteLine(new[] { 1 } is [1, 2, ..]); // False
Console.WriteLine(new[] { 1, 2, 3, 4 } is [.., > 0, > 0]); // True
Console.WriteLine(new[] { 2, 4 } is [.., > 0, 2, 4]); // False
Console.WriteLine(new[] { 2, 4 } is [.., 2, 4]); // True
Console.WriteLine(new[] { 1, 2, 3, 4 } is [>= 0, .., 2 or 4]); // True
Console.WriteLine(new[] { 1, 0, 0, 1 } is [1, 0, .., 0, 1]); // True
Console.WriteLine(new[] { 1, 0, 1 } is [1, 0, .., 0, 1]); // False
슬라이스 패턴은 0개 이상의 요소와 매칭돼요. 목록 패턴 안에서는 슬라이스 패턴을 최대 하나만 쓸 수 있고, 슬라이스 패턴은 목록 패턴 안에서만 나타날 수 있어요.
슬라이스 패턴 안에 하위 패턴을 중첩할 수도 있어요. 아래 예시를 볼게요.
void MatchMessage(string message)
{
var result = message is ['a' or 'A', .. var s, 'a' or 'A']
? $"Message {message} matches; inner part is {s}."
: $"Message {message} doesn't match.";
Console.WriteLine(result);
}
MatchMessage("aBBA"); // output: Message aBBA matches; inner part is BB.
MatchMessage("apron"); // output: Message apron doesn't match.
void Validate(int[] numbers)
{
var result = numbers is [< 0, .. { Length: 2 or 4 }, > 0] ? "valid" : "not valid";
Console.WriteLine(result);
}
Validate(new[] { -1, 0, 1 }); // output: not valid
Validate(new[] { -1, 0, 0, 1 }); // output: valid
더 자세한 내용은 C# 언어 사양의 목록 패턴을 참고하세요.
폐쇄(closed) 계층 패턴
C# 15부터, 제어 형식이 closed 클래스인 switch 표현식은 그 분기(arm)들이 해당 클래스의 모든 직접 하위 형식을 처리할 때 *전체(exhaustive)*가 돼요. switch가 전체이므로 컴파일러는 기본 분기를 요구하지 않아요.
public closed record class PaymentMethod;
public record class Cash : PaymentMethod;
public record class Card(string Last4) : PaymentMethod;
public record class BankTransfer(string Iban) : PaymentMethod;
public static string Describe(PaymentMethod method) => method switch
{
Cash => "cash",
Card(var last4) => $"card ending {last4}",
BankTransfer(var iban) => $"bank transfer to {iban}",
// No warning: every direct descendant of 'PaymentMethod' is handled.
};
폐쇄 계층 switch는 모든 직접 하위 형식이 switch의 위치에서 도달 가능할 때만 전체가 돼요. 어떤 직접 하위 형식이 폐쇄 기본 형식보다 접근성이 낮아서 switch 위치에서 보이지 않는다면, 컴파일러는 그것을 처리되지 않은 것으로 보고 switch가 전체가 아니라고 경고해요.
예를 들어 폐쇄 public 기본 클래스가 internal 직접 하위 형식을 가질 수 있어요. 같은 어셈블리의 코드는 하위 형식 전체 집합을 보지만, 다른 어셈블리의 코드는 보지 못해요.
// Assembly 1
public closed record class Shape;
public record class Circle(double Radius) : Shape;
internal record class Triangle(double Base, double Height) : Shape;
// Same assembly: 'Triangle' is visible, so the switch is exhaustive.
internal static double Area(Shape shape) => shape switch
{
Circle(var r) => Math.PI * r * r,
Triangle(var b, var h) => 0.5 * b * h,
};
// Assembly 2
public static string Name(Shape shape) => shape switch
{
Circle => "circle",
// Warning CS8509: the switch expression doesn't handle all possible values.
// 'Triangle' is a direct descendant of 'Shape' but isn't visible here.
};
어셈블리 2에서 전제성을 되찾으려면, discard 분기(_ => ...)를 추가하거나, 기본 클래스 분기(앞선 예시의 Shape)를 추가하거나, 모든 직접 하위 형식을 폐쇄 기본 형식만큼 접근 가능하게 만들면 돼요.
제어 형식이 nullable이면 null은 switch가 처리해야 할 추가 값이에요. null 분기를 생략한 PaymentMethod?에 대한 switch는 모든 직접 하위 형식이 매칭되더라도 전체가 아니에요.
폐쇄 클래스로부터의 파생은 전이적(transitive)이지 않아요. 폐쇄 클래스의 비폐쇄 하위 형식은 다른 어셈블리에서 파생될 수 있거든요. 컴파일러는 직접 하위 형식만 전체 집합으로 취급해요. 하위 형식에 대한 switch도 전제성 검사의 혜택을 받게 하려면, 그 하위 형식을 closed(또는 sealed)로 선언하면 돼요.
간접 하위 형식은 폐쇄 기본 형식의 전체 집합을 늘리지 않으므로, 전제성을 만족시키기 위해 모든 전이 하위 형식마다 분기를 추가할 필요는 없어요. 분기 간의 포섭(subsumption)은 어떤 클래스 계층에서든 똑같이 동작해요. 기본 형식을 매칭하는 분기가 모든 하위 형식을 덮고, 그 하위 형식 중 하나를 매칭하는 뒤의 분기는 도달 불가능해지죠. 직접 하위 형식이 Car와 Truck이고, 다른 어셈블리에 선언된 간접 하위 형식 Sedan이 있는 폐쇄 Vehicle을 생각해 볼게요.
// Assembly 1
public closed record class Vehicle;
public record class Car(int Doors) : Vehicle;
public record class Truck(double PayloadTons) : Vehicle;
// Assembly 2
public record class Sedan(int Doors) : Car(Doors);
Car와 Truck을 처리하면 Sedan이 존재하더라도 Vehicle에 대한 switch는 전체가 돼요. Car 분기가 모든 Sedan 값을 덮기 때문이에요.
public static string Category(Vehicle v) => v switch
{
Car => "car",
Truck => "truck",
// No warning. The 'Car' arm covers 'Sedan' through ordinary subtype matching.
};
Sedan을 구체적으로 분기하고 싶다면 그 분기를 Car 분기 앞에 두면 돼요. Car 분기는 Sedan이 아닌 모든 Car를 여전히 매칭하므로 도달 가능하게 남아 있어요.
public static string CategorySedanFirst(Vehicle v) => v switch
{
Sedan => "sedan",
Car => "car", // Reachable: 'Car' values that aren't 'Sedan'.
Truck => "truck",
};
이 두 분기의 순서를 뒤집으면 다른 어떤 클래스 계층에서와 마찬가지로 포섭 오류가 발생해요. 컴파일러가 Car 분기가 이미 Sedan을 덮는다는 걸 감지하거든요.
string Category(Vehicle v) => v switch
{
Car => "car",
Sedan => "sedan", // Error CS8510: the pattern is unreachable. It has already been handled by 'Car => ...'.
Truck => "truck",
};
전체성이 계층을 더 아래까지 따라가길 원한다면 Car 자체를 closed로 선언하면 돼요. 그러면 컴파일러는 Car의 모든 직접 하위 형식(예: Sedan)을 Car에 뿌리를 둔 전체 집합으로 취급해요. 제어 형식이 Car인 switch는 아래 중 어떤 것을 하든 전체가 돼요.
Car의 모든 직접 하위 형식을 각자의 분기로 처리- 모든
Car값(모든 하위 형식 포함)을 덮는Car분기 포함 - discard 분기(
_ => ...)나Vehicle처럼Car의 기본 형식에 대한 분기 포함
제어 형식이 Vehicle인 switch에는 선택지가 있어요. Car와 Truck을 처리하는 코드는 여전히 전체인데, Car 분기가 Car의 모든 하위 형식을 덮기 때문이에요. Car를 closed로 표시하면 그 switch에 대한 두 번째 선택지가 생길 뿐이에요. 단일 Car 분기를 유지하거나, Car의 직접 하위 형식마다 분기 하나씩으로 바꿔도(Truck 분기와 함께) 여전히 전체예요.
Car를 closed로 표시하면 그것이 암시적으로 abstract가 되기도 하는데, 이는 더 이상 Car의 인스턴스를 직접 만들 수 없다는 뜻이에요. 그 조건이 설계에 맞지 않을 수도 있지요. Car가 인스턴스화 가능해야 한다면 열어 두고, 앞서 보여준 대로 분기 순서를 정해서 신경 쓰는 특정 하위 형식으로 분기하면 돼요.
형식 매개변수 제어 형식
폐쇄 계층 기능 사양은 제어 형식이 폐쇄 클래스로 제약된 형식 매개변수인 switch 표현식을, 폐쇄 클래스 자체에 대한 switch와 동일한 조건에서 전체라고 정의해요. 그래서 제네릭 코드는 형식 매개변수를 폐쇄 기본 형식으로 제약하고 모든 직접 하위 형식을 처리하는 방식으로 폐쇄 계층에 분기할 수 있어요.
public static string Describe<X>(X method) where X : PaymentMethod => method switch
{
Cash => "cash",
Card(var last4) => $"card ending {last4}",
BankTransfer(var iban) => $"bank transfer to {iban}",
// No warning: 'X' is constrained to the closed type 'PaymentMethod', so the
// compiler treats this switch as exhaustive because every direct descendant is handled.
};
더 자세한 내용은 closed 한정자를 참고하세요. 사양은 Closed hierarchies를 보세요.
유니언(union) 패턴
C# 15부터, 패턴의 들어오는 값이 유니언 형식일 때 패턴은 일반적으로 유니언을 언랩해요. 패턴은 유니언 값 자체가 아니라 유니언의 Value 속성에 적용되죠. 이 동작 덕분에 유니언이 패턴 매칭에 투명해져요.
public record class Cat(string Name);
public record class Dog(string Name);
public union Pet(Cat, Dog);
string Describe(Pet pet) => pet switch
{
Dog d => d.Name,
Cat c => c.Name,
};
세 가지 패턴은 예외예요. discard _ 패턴, var 패턴, not 패턴은 유니언의 Value 속성이 아니라 유니언 값 자체에 적용돼요.
null 패턴은 유니언의 Value가 null인지 검사해요. 클래스 기반 유니언에서는 유니언 참조 자체가 null일 때도 null이 성공해요.
유니언 형식이 *비박싱 접근 패턴(non-boxing access pattern)*을 제공하면, 컴파일러는 형식 패턴 검사에 TryGetValue를, null 패턴 검사에 HasValue를 호출해서 값 형식 경우의 박싱을 피해요. 각 멤버는 자신의 패턴 종류에 적용되며, 어느 것도 다른 쪽의 대체물(fallback)이 아니에요. 멤버가 없으면 컴파일러는 그 패턴에 대해 Value 속성을 검사해요.
더 자세한 내용은 유니언 매칭을 참고하세요. 사양은 Unions를 보세요.
C# 언어 사양
더 자세한 내용은 C# 언어 사양의 패턴과 패턴 매칭 섹션을 참고하세요. 다음을 포함해요.