패턴 매칭: is · switch 식과 and · or · not 연산자
패턴 매칭: is · switch 식과 and · or · not 연산자
is 식, switch 문, 그리고 switch 식을 사용하면 입력 식을 원하는 만큼 많은 특성에 대해 매칭할 수 있어요. C#은 선언, 형식, 상수, 관계, 속성, 목록, var, discard 등 여러 패턴을 지원하고, 불리언 로직 키워드 and, or, not으로 패턴을 결합할 수도 있죠.
패턴은 여러 종류가 있는데, 논리·속성·위치·목록 패턴은 재귀 패턴(recursive pattern) 이에요. 즉, 그 안에 중첩 패턴(nested pattern) 을 담을 수 있습니다.
선언 패턴과 형식 패턴
선언 패턴(declaration pattern) 과 형식 패턴(type pattern) 은 식의 런타임 형식이 주어진 형식과 호환되는지 확인해요. 선언 패턴을 쓰면 새 지역 변수도 선언할 수 있는데, 패턴이 식과 매칭되면 변수에 변환된 식 결과를 할당해 줍니다.
object greeting = "Hello, World!";
if (greeting is string message)
{
Console.WriteLine(message.ToLower()); // output: hello, world!
}
형식 T를 가진 선언 패턴은 식 결과가 null이 아니고 다음 조건 중 하나가 참일 때 그 식과 매칭돼요:
- 식 결과의 런타임 형식이
T로의 정체성 변환(identity conversion)을 가져요. - 형식
T가ref struct형식이고 식에서T로의 정체성 변환이 있어요. - 식 결과의 런타임 형식이 형식
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[]가 Array 형식에서 파생되므로 첫 패턴이 매칭돼요. 두 번째 호출의 인자 런타임 형식 List<T>는 Array에서 파생되진 않지만 ICollection<T> 인터페이스를 구현해서 두 번째 패턴이 매칭되죠.
식의 형식만 확인하려면 변수 이름 자리에 discard _를 쓰는 게 아니라, 형식 패턴을 사용해요.
public abstract class Vehicle {}
public class Car : Vehicle {}
public class Truck : Vehicle {}
public static class TollCalculatorWithTypePattern
{
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 상수 패턴(is not null)을 쓰면 돼요.
상수 패턴
상수 패턴(constant pattern) 은 오른쪽 피연산자가 상수일 때 ==의 대체 구문이에요. 식 결과가 지정된 상수와 같은지 테스트합니다.
// <NonNullCheck>
object input = new();
if (input is not null)
{
// ...
}
상수 패턴에는 정수·부동 소수점 숫자 리터럴, char, string 리터럴, 불리언 값 true/false, enum 값, 선언된 const 필드나 지역의 이름, null 등 어떤 상수 식이든 쓸 수 있어요. 식은 상수 형식으로 변환 가능한 형식이어야 하며, 한 가지 예외로 Span<char> 또는 ReadOnlySpan<char> 형식의 식은 상수 문자열에 대응해 매칭할 수 있어요.
식 x is null을 평가할 때 컴파일러는 사용자가 오버로드한 == 연산자를 호출하지 않는다는 게 보장돼요. 부정된 null 상수 패턴으로 null이 아닌지 확인할 수도 있어요.
관계 패턴
관계 패턴(relational pattern) 은 식 결과를 상수와 비교해요. 관계 패턴에는 관계 연산자 <, >, <=, >=를 쓸 수 있고, 오른쪽은 반드시 상수 식이어야 해요. 상수 식은 정수, 부동 소수점, char, enum 형식일 수 있죠.
식 결과가 어떤 범위 안에 있는지 확인하려면 결합형 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",
};
식 결과가 null이거나 nullable·unboxing 변환으로 상수 형식으로 변환하지 못하면 관계 패턴은 그 식과 매칭되지 않아요.
논리 패턴
not, and, or 패턴 결합자로 논리 패턴(logical pattern) 을 만들 수 있어요.
- 부정(negation)
not패턴 — 부정된 패턴이 식과 매칭되지 않을 때 그 식과 매칭돼요.null상수 패턴을 부정하면is not null로 식이 null이 아닌지 확인하죠. - 결합(conjunctive)
and패턴 — 두 패턴이 모두 식과 매칭될 때 매칭돼요. 관계 패턴을 결합해 값이 어떤 범위 안에 있는지 확인하는 데 쓰여요. - 분리(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}."),
};
우선순위와 확인 순서
패턴 결합자는 식의 바인딩 순서에 따라 이런 순서로 확인해요 — not → and → or. not 패턴이 먼저 피연산자에 바인딩되고, and는 그 다음, or는 마지막이에요. 다음 예는 a부터 z까지의 소문자가 아닌 모든 문자를 매칭하려는 건데, not이 and보다 먼저 바인딩되기 때문에 오류가 나요.
// Incorrect pattern. `not` binds before `and`
static bool IsNotLowerCaseLetter(char c) => c is not >= 'a' and <= 'z';
기본 바인딩은 위 예시를 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');
패턴이 복잡해질수록 괄호가 더 중요해져요. 일반적으로는 다른 개발자를 위해 패턴을 명확히 하려고 괄호를 쓰는 게 좋아요. 같은 바인딩 순서를 가진 패턴을 컴파일러가 확인하는 순서는 정의되어 있지 않아요 — 런타임에 여러 or 패턴과 여러 and 패턴의 오른쪽 중첩 패턴을 먼저 확인할 수 있습니다.
속성 패턴
속성 패턴(property pattern) 은 식의 속성이나 필드를 중첩 패턴에 대응해 매칭해요. 식 결과가 null이 아니고 모든 중첩 패턴이 식 결과의 해당 속성·필드와 매칭될 때 매칭되죠.
if (input is not (float or double))
{
return;
}
속성 패턴에 런타임 형식 검사와 변수 선언을 추가할 수 있어요. 빈 속성 패턴 is { }는 null이 아닌 모든 것과 매칭된다는 특별한 의미가 있어요. 그래서 somethingPossiblyNull is { } somethingDefinitelyNotNull처럼 변수를 만들 때 is not null 대신 쓸 수 있죠.
속성 패턴은 재귀 패턴이라 어떤 패턴이든 중첩 패턴으로 쓸 수 있어요. 데이터의 일부를 중첩 패턴에 대응해 매칭하는 데 사용해요.
// 속성 패턴 안에서 중첩 속성/필드를 참조할 수 있는데
// 이를 확장 속성 패턴(extended property pattern)이라고 해요.
가독성을 높이려면 확장 속성 패턴을 쓸 곳을 제안하는 속성 패턴 단순화(IDE0170) 스타일 규칙도 참고할 수 있어요.
위치 패턴
위치 패턴(positional pattern) 은 식을 분해(deconstruct)하고 그 결과 값을 해당 중첩 패턴과 매칭해요. 식의 형식에 Deconstruct 메서드가 있으면 패턴이 그 메서드로 식 결과를 분해해요.
중요: 위치 패턴의 멤버 순서는
Deconstruct메서드의 매개변수 순서와 반드시 일치해야 해요. 위치 패턴에 대해 생성된 코드가Deconstruct메서드를 호출하기 때문이에요.
튜플 형식의 식도 위치 패턴에 매칭할 수 있어요. 이렇게 하면 여러 입력을 다양한 패턴으로 매칭할 수 있죠. 위치 패턴에 런타임 형식 검사와 변수 선언을 추가하거나, 위치 패턴 안에서 속성 패턴을 쓰거나, 둘을 결합할 수도 있어요. 위치 패턴은 재귀 패턴이라 어떤 패턴이든 중첩 패턴으로 쓸 수 있습니다.
var 패턴
var 패턴은 null을 포함한 모든 식과 매칭되고, 그 결과를 새 지역 변수에 할당해요. 불리언 식 안에서 중간 계산 결과를 담는 임시 변수가 필요할 때 유용해요. 또 switch 식이나 문의 when 케이스 가드에서 추가 검사를 수행할 때도 쓸 수 있죠.
var (x, y) 패턴은 위치 패턴 (var x, var y)와 동등해요. var 패턴에서 선언된 변수의 형식은 그 패턴이 매칭하는 식의 컴파일 타임 형식입니다.
discard 패턴
discard 패턴 _은 null을 포함한 모든 식과 매칭돼요. 위의 TollCalculator 예시처럼 discard 패턴이 null과 열거형의 해당 멤버가 없는 정수 값을 처리해주면 switch 식이 모든 가능한 입력 값을 다룬다는 걸 보장해 줘요. switch 식에서 discard 패턴을 쓰지 않고 어떤 입력도 패턴이 매칭하지 않으면 런타임이 예외를 던집니다.
discard 패턴은 is 식이나 switch 문에서는 패턴이 될 수 없어요. 그런 경우 모든 식을 매칭하려면 discard를 가진 var 패턴(var _)을 쓰면 되고, discard 패턴은 switch 식에서 패턴이 될 수 있어요.
목록 패턴
배열이나 목록을 패턴의 시퀀스에 대응해 매칭할 수 있어요. 목록 패턴은 각 중첩 패턴이 입력 시퀀스의 해당 요소와 매칭될 때 매칭돼요. 목록 패턴 안에는 어떤 패턴이든 쓸 수 있고, 어떤 요소든 매칭하려면 discard 패턴을 쓰거나 요소도 잡으려면 var 패턴을 써요.
입력 시퀀스의 시작이나 끝(또는 둘 다)에 있는 요소만 매칭하려면 슬라이스 패턴(slice pattern) ..을 사용해요. 슬라이스 패턴은 0개 이상의 요소와 매칭되고, 목록 패턴에 최대 하나만 쓸 수 있으며 목록 패턴 안에서만 나타날 수 있어요. 슬라이스 패턴 안에 하위 패턴을 중첩할 수도 있습니다.
C# 15: 닫힌 계층(closed hierarchy) 패턴
C# 15부터 지배 형식(governing type)이 closed 클래스인 switch 식은, 그 클래스의 모든 직접 하위(direct descendant)를 처리하면 완전(exhaustive) 하게 돼요. 완전하기 때문에 컴파일러는 기본(default) arm을 요구하지 않아요.
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",
};
닫힌 클래스로부터의 파생은 전이적(transitive)이지 않아요. 닫힌 클래스의 닫히지 않은 하위는 다른 어셈블리에서 파생될 수 있는데, 컴파일러는 직접 하위만 완전 집합으로 취급해요. 닫힌 Vehicle의 직접 하위가 Car와 Truck이고 다른 어셈블리에 간접 하위 Sedan이 있다고 해도, Car와 Truck을 처리한 Vehicle에 대한 switch는 Sedan이 존재해도 완전해요 — Car arm이 모든 Sedan 값을 커버하니까요. Sedan을 특별히 디스패치하려면 그 arm을 Car arm 앞에 두면 돼요.
지배 형식이 nullable일 때는 null이 switch가 처리해야 할 추가 값이 돼요. PaymentMethod?에 대한 switch가 null arm을 생략하면 모든 직접 하위를 매칭해도 완전하지 않아요.
C# 15: 유니언 패턴
C# 15부터 패턴의 들어오는 값이 유니언 타입이면 패턴은 일반적으로 유니언을 언랩(unwrap) 해요. 패턴은 유니언 값 자체가 아니라 유니언의 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 속성이 아니라 유니언 값 자체에 적용돼요.