Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 635 – 구조적 패턴 매칭: 동기와 근거

Author:
Tobias Kohn <kohnt at tobiaskohn.ch>, Guido van Rossum <guido at python.org>
BDFL-Delegate:

Discussions-To:
Python-Dev list
Status:
Final
Type:
Informational
Created:
12-Sep-2020
Python-Version:
3.10
Post-History:
22-Oct-2020, 08-Feb-2021
Resolution:
Python-Committers message

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

초록

이 PEP는 PEP 634 (“구조적 패턴 매칭: 명세”)의 동기와 근거를 제공합니다. 처음 읽는 독자는 패턴의 개념, 문법, 의미를 보다 쉽게 소개하는 PEP 636부터 읽기를 권장합니다.

동기

(구조적) 패턴 매칭 문법은 Haskell, Erlang, Scala부터 Elixir, Ruby에 이르기까지 많은 언어에서 찾아볼 수 있습니다. (JavaScript에 대한 제안도 검토 중입니다.)

Python은 이미 시퀀스 언패킹 대입을 통해 이것의 제한된 형태를 지원하며, 새 제안은 이를 활용합니다.

그 밖에 흔히 쓰이는 여러 Python 관용구도 관련이 있습니다:

  • if ... elif ... elif ... else 관용구는 isinstance(x, cls), hasattr(x, "attr"), len(x) == n 또는 "key" in x와 같은 검사를 하나 이상 가드로 사용해 적용할 블록을 선택함으로써, 객체의 타입이나 형태를 임시방편으로 알아내는 데 자주 사용됩니다. 그러면 해당 블록은 x가 가드에서 검사한 인터페이스를 지원한다고 가정할 수 있습니다. 예를 들면 다음과 같습니다:
    if isinstance(x, tuple) and len(x) == 2:
        host, port = x
        mode = "http"
    elif isinstance(x, tuple) and len(x) == 3:
        host, port, mode = x
    # Etc.
    

    이와 같은 코드는 match를 사용하면 더 우아하게 표현할 수 있습니다:

    match x:
        case host, port:
            mode = "http"
        case host, port, mode:
            pass
        # Etc.
    
  • AST 순회 코드는 주어진 패턴과 일치하는 노드를 찾는 경우가 많습니다. 예를 들어 “A + B * C” 형태의 노드를 탐지하는 코드는 다음과 같을 수 있습니다:
    if (isinstance(node, BinOp) and node.op == "+"
            and isinstance(node.right, BinOp) and node.right.op == "*"):
        a, b, c = node.left, node.right.left, node.right.right
        # Handle a + b*c
    

    match를 사용하면 이 코드는 더 읽기 쉬워집니다:

    match node:
        case BinOp("+", a, BinOp("*", b, c)):
            # Handle a + b*c
    

저희는 Python에 패턴 매칭을 추가하면 Python 사용자가 위와 같은 예시를 비롯한 여러 경우에 더 깔끔하고 읽기 쉬운 코드를 작성할 수 있으리라 믿습니다.

이 제안에 대한 보다 학술적인 논의는 [1]을 참조하십시오.

패턴 매칭과 객체 지향

패턴 매칭은 객체 지향 패러다임을 보완합니다. 객체 지향과 상속을 사용하면 베이스 클래스에 해당 클래스의 특정 연산에 대한 기본 동작을 정의하는 메서드를 쉽게 정의할 수 있고, 서브클래스에서 이 기본 동작을 오버라이드할 수 있습니다. 또한 Visitor 패턴을 사용하여 동작을 데이터와 분리할 수도 있습니다.

하지만 이것만으로는 모든 상황에 충분하지 않습니다. 예를 들어, 코드 제너레이터는 AST를 입력으로 받을 수 있으며, 앞에서 본 BinOp 예시처럼 노드의 클래스뿐만 아니라 일부 클래스 속성의 값에 따라서도 생성되는 코드가 달라져야 하는 연산을 많이 가질 수 있습니다. Visitor 패턴은 이러한 경우에 충분히 유연하지 않습니다: 클래스를 기준으로만 선택할 수 있기 때문입니다.

전체 예제를 참조하십시오.

Visitor 패턴과 마찬가지로 패턴 매칭은 관심사를 엄격하게 분리할 수 있게 합니다: 특정 동작이나 데이터 처리는 클래스 계층 구조나 조작되는 객체와 독립적입니다. 특히 미리 정의된 클래스나 심지어 내장 클래스를 다룰 때는 개별 클래스에 메서드를 더 추가하는 것이 불가능한 경우가 많습니다. 패턴 매칭은 Visitor 패턴에 필요한 보일러플레이트 코드의 부담을 프로그래머나 클래스 설계자에게서 덜어 줄 뿐만 아니라, 내장 타입과 직접 함께 동작할 만큼 유연합니다. 패턴 매칭은 길이가 서로 다른 시퀀스를 자연스럽게 구별하는데, 이러한 시퀀스는 구조가 분명히 다르더라도 모두 같은 클래스일 수 있습니다. 게다가 패턴 매칭은 상속을 자동으로 고려합니다: C를 상속하는 클래스 D는 기본적으로 C를 대상으로 하는 패턴에 의해 처리됩니다.

객체 지향 프로그래밍은 단일 디스패치를 지향합니다: 어떤 메서드가 호출될지를 결정하는 것은 단일 인스턴스(또는 그 타입)입니다. 이는 이항 연산자의 경우 어느 구현을 사용할지 결정하는 데 두 객체가 동등한 역할을 할 수 있으므로 다소 인위적인 상황을 초래합니다(Python은 반전된 이항 메서드를 사용하여 이를 해결합니다). 패턴 매칭은 수행할 동작이 여러 객체의 타입에 동등하게 의존하는 이러한 다중 디스패치 상황을 처리하는 데 구조적으로 더 적합합니다.

패턴과 함수형 스타일

많은 Python 애플리케이션과 라이브러리는 일관된 OO 스타일로 작성되어 있지 않습니다. Java와 달리 Python은 모듈의 최상위에 함수를 정의하는 것을 권장하며, 단순한 자료 구조에는 튜플(또는 네임드 튜플이나 리스트)과 딕셔너리가 단독으로 쓰이거나 클래스 또는 데이터 클래스와 섞여 쓰이는 경우가 많습니다.

패턴 매칭은 이러한 자료 구조를 분해하는 데 특히 적합합니다. 극단적인 예로, match를 사용하면 JSON 자료 구조를 골라내는 코드를 쉽게 작성할 수 있습니다:

match json_pet:
    case {"type": "cat", "name": name, "pattern": pattern}:
        return Cat(name, pattern)
    case {"type": "dog", "name": name, "breed": breed}:
        return Dog(name, breed)
    case _:
        raise ValueError("Not a suitable pet")

함수형 프로그래밍은 일반적으로 데이터 사이의 관계에 초점을 맞춘 선언적 스타일을 선호합니다. 부작용은 가능한 한 피합니다. 따라서 패턴 매칭은 함수형 프로그래밍 스타일에 자연스럽게 들어맞으며 이를 강력하게 지원합니다.

근거

이 절에서는 개별 설계 결정의 근거를 제시합니다. 이 절은 표준 PEP 형식의 “기각된 아이디어(Rejected ideas)”를 대신합니다. 이 절은 명세(PEP 634)에 대응하는 절들로 구성되어 있습니다.

개요와 용어

패턴 매칭의 강력함은 대부분 하위 패턴의 중첩에서 비롯됩니다. 따라서 패턴 매치의 성공이 하위 패턴의 성공에 직접 좌우된다는 점은 설계의 초석입니다. 그러나 P(Q(), R())와 같은 패턴은 하위 패턴 Q()와 R()가 모두 성공할 때에만 성공하지만(즉, 패턴 P의 성공은 Q와 R에 좌우되지만), 패턴 P가 먼저 검사됩니다. P가 실패하면 Q()와 R() 중 어느 것도 시도되지 않습니다(이는 P가 실패하면 애초에 Q()와 R()에 매치할 대상이 없다는 사실의 직접적인 결과입니다).

또한 패턴은 대입을 수행하는 것이 아니라 이름을 값에 바인딩한다는 점에 유의하십시오. 이는 패턴이 부작용을 갖지 않는 것을 목표로 한다는 사실을 반영하며, 이는 곧 캡처 패턴이나 AS 패턴이 속성이나 첨자에 값을 대입할 수 없다는 뜻이기도 합니다. 따라서 전통적인 대입과 패턴에서의 이름 바인딩 사이의 이 미묘한 차이를 강조하기 위해 ‘대입(assign)’ 대신 ‘바인딩(bind)’이라는 용어를 일관되게 사용합니다.

match 문

match 문은 표현식을 평가하여 대상(subject)을 만들어 내고, 그 대상과 매치되는 첫 번째 패턴을 찾아 연관된 코드 블록을 실행합니다. 따라서 구문상 match 문은 표현식 하나와 case 절의 시퀀스를 받으며, 각 case 절은 패턴과 코드 블록으로 이루어집니다.

case 절은 코드 블록을 포함하므로, 복합문과 유사한 <keyword> ...: <(indented) block>이라는 구문 구조로 기존의 들여쓰기 방식을 따릅니다. 키워드 case는 패턴 매칭 언어에서 널리 쓰인다는 점을 반영한 것이며, | 같은 기호처럼 다른 문법적 수단을 사용하는 언어들은 기존의 Python 구조에 맞지 않으므로 고려하지 않았습니다. 키워드 뒤에 오는 패턴의 문법은 아래에서 논의합니다.

case 절이 복합문의 구조를 따르므로, match 문 자체도 자연스럽게 같은 구문 구조를 따르는 복합문이 됩니다. 이는 자연스럽게 match <expr>: <case_clause>+로 이어집니다. match 문은 평가된 대상이 (지역 변수에 담기지는 않지만) 살아 있도록 유지되는 준(準) 스코프를 결정한다는 점에 유의하십시오. 이는 with 문이 블록을 실행하는 동안 자원을 살아 있도록 유지하는 방식과 유사합니다. 또한 제어 흐름은 match 문에서 case 절로 넘어간 다음 match 문의 블록을 벗어납니다. 따라서 match 문의 블록은 구문적 의미와 의미론적 의미를 모두 가집니다.

case 절의 코드 블록에서 자연스럽게 생기는 “이중 들여쓰기”를 없애거나 피하려는 다양한 제안이 있었습니다. 안타깝게도 이러한 평평한 들여쓰기 방식의 제안은 모두 Python의 확립된 구조적 패러다임을 위반하는 대가를 치르며, 추가적인 문법 규칙으로 이어집니다:

  • 들여쓰지 않는 case 절. case 절을 match와 같은 위치에 정렬하자는 아이디어입니다. 즉,:
    match expression:
    case pattern_1:
        ...
    case pattern_2:
        ...
    

    다른 모든 곳에서는 콜론 뒤에 들여쓰기가 오므로, 이는 Python 프로그래머의 눈에 어색하게 보일 수 있습니다. match는 단순문의 구문 체계도, 복합문의 구문 체계도 따르지 않고 오히려 독자적인 범주를 만들게 됩니다.

  • “match” 다음 줄에 표현식을 따로 두기. 대상을 산출하는 표현식을 문으로 사용하여, 콜론이 있는데도 match에 실제 블록이 없다는 특이점을 피하자는 아이디어입니다:
    match:
        expression
    case pattern_1:
        ...
    case pattern_2:
        ...
    

    첫 번째 블록이 Python 문법에서 또 하나의 새로운 요소, 즉 문장의 시퀀스가 아니라 단일 표현식만을 내용으로 갖는 블록이 되므로 이 방안은 결국 거부되었습니다. match: return expression과 같은 방식으로 또 다른 키워드를 추가하거나 기존 키워드를 다른 용도로 활용하여 이 문제를 보완하려는 시도는 만족스러운 해결책을 내놓지 못했습니다.

평평한 들여쓰기를 사용하면 가로 공간을 어느 정도 절약할 수 있지만, 복잡성 증가나 특이한 규칙이라는 비용이 너무 큽니다. 또한 단순한 코드 편집기의 작업도 복잡하게 만들 것입니다. 마지막으로, 가로 공간 문제는 match 문에 “반 들여쓰기”(즉, 네 칸 대신 두 칸)를 허용하여 완화할 수 있습니다(다만 저희는 이를 권장하지 않습니다).

이 PEP를 개발하는 과정에서 작성한, match를 사용하는 예제 프로그램들에서는 코드 간결성이 눈에 띄게 개선되었으며, 이는 추가된 들여쓰기 수준을 상쇄하고도 남습니다.

문 대 표현식. 일부 제안은 match를 문이 아닌 표현식으로 만들자는 아이디어를 중심으로 했습니다. 그러나 이는 Python의 문장 지향적 특성과 잘 맞지 않으며, 비정상적으로 길고 복잡한 표현식을 낳고, 새로운 문법 구성을 고안하거나 잘 확립된 문법 규칙을 깨뜨려야 하는 필요로 이어질 것입니다. match를 표현식으로 만들 때의 명백한 결과는 case 절에 더 이상 임의의 코드 블록을 붙일 수 없고 단일 표현식만 붙일 수 있게 된다는 것입니다. 전반적으로, 이러한 강한 제약은 일부 특수한 사용 사례에서 얻는 약간의 단순화로는 결코 상쇄될 수 없습니다.

하드 키워드 대 소프트 키워드. match를 하드 키워드로 만들거나 다른 키워드를 선택하는 방안도 있었습니다. 하드 키워드를 사용하면 단순한 문법 강조기의 작업은 쉬워지겠지만, 저희는 여러 이유로 하드 키워드를 사용하지 않기로 결정했습니다:

  • 가장 중요한 이유는 새 파서가 이를 요구하지 않는다는 점입니다. 몇 차례의 릴리스 동안 소프트 키워드로 있으면서 어려움을 일으켰던 async와 달리, 여기서는 match를 영구적인 소프트 키워드로 만들 수 있습니다.
  • match는 기존 코드에서 매우 흔히 사용되므로, 거의 모든 기존 프로그램을 깨뜨리고 새 문법의 혜택조차 받지 못할 수 있는 많은 사람에게 코드를 고쳐야 하는 부담을 지우게 됩니다.
  • 기존 프로그램에서 식별자로 흔히 사용되지 않으면서도 문의 의미를 명확하게 반영하는 대체 키워드를 찾기는 어렵습니다.

case 절에 “case” 대신 “as”나 “|”를 사용하기. 여기서 제안하는 패턴 매칭은 (Algol 계열 언어의 switch나 Lisp의 cond와 같은) 다중 브랜치 제어 흐름과 함수형 언어에서 볼 수 있는 객체 분해를 결합한 것입니다. 제안된 키워드 case는 다중 브랜치 측면을 강조하지만, 분해 측면을 강조하는 as와 같은 다른 키워드도 똑같이 가능할 것입니다. 예를 들어 as나 with는 이미 Python의 키워드라는 장점도 있습니다. 그러나 키워드로서의 case는 match 문 안에서 맨 앞 키워드로만 나타날 수 있으므로, 파서가 이것이 키워드로 쓰였는지 변수로 쓰였는지 쉽게 구별할 수 있습니다.

다른 변형으로는 |나 => 같은 기호를 사용하거나, 특별한 표식을 아예 사용하지 않는 방안이 있습니다.

Python은 Algol의 전통을 잇는 문장 지향 언어이고 각 복합 문장은 식별용 키워드로 시작하므로, case가 Python의 스타일과 전통에 가장 부합하는 것으로 보였습니다.

매치 의미론

서로 다른 case 절의 패턴이 겹쳐서, 주어진 대상에 둘 이상의 case 절이 매치될 수 있습니다. 처음 매치된 것을 선택하는 규칙은 주어진 대상에 대해 case 절이 모호하지 않게 선택되도록 보장합니다. 또한 case 절은 더 넓은 범위의 대상에 매치되는, 점점 더 일반적인 패턴을 가질 수 있습니다. 그러면 처음 매치된 것을 선택하는 규칙이 가장 정밀한 패턴을 선택할 수 있도록 보장합니다(다만 case 절을 올바른 순서로 배치하는 것은 프로그래머의 책임입니다).

정적 타입 언어라면 match 문은 일치하는 패턴을 빠르고 매우 효율적으로 선택하기 위해 결정 트리로 컴파일될 것입니다. 그러나 이렇게 하려면 모든 패턴이 순수하게 선언적이고 정적이어야 하며, 이는 확립된 Python의 동적 의미론에 어긋납니다. 따라서 제안된 의미론은 양쪽의 장점을 모두 취하는 길을 제시합니다. 패턴은 엄격하게 순차적인 순서로 시도되므로 각 case 절은 실제 문을 이룹니다. 동시에 인터프리터가 대상에 관한 어떤 정보든 캐시하거나 하위 패턴을 시도하는 순서를 바꾸는 것을 허용합니다. 다시 말해, 인터프리터가 대상이 클래스 C의 인스턴스가 아님을 알아냈다면, 인스턴스 검사를 반복해서 수행할 필요 없이 이를 다시 검사하는 case 절을 곧바로 건너뛸 수 있습니다. 예를 들어 가드가 변수 x가 양수여야 한다고 규정한다면(즉 if x > 0), 인터프리터는 x를 바인딩한 직후, 다른 하위 패턴을 고려하기 전에 이를 바로 검사할 수도 있습니다.

바인딩과 스코프. 많은 패턴 매칭 구현에서는 각 case 절이 저마다 별도의 스코프를 만듭니다. 그러면 패턴이 바인딩한 변수는 해당 case 블록 안에서만 보이게 됩니다. 그러나 Python에서는 이것이 합리적이지 않습니다. 별도의 스코프를 만든다는 것은 본질적으로 각 case 절이 둘러싼 스코프의 변수에 직접 접근할 수 없는(즉 nonlocal에 의존하지 않고서는 접근할 수 없는) 별개의 함수라는 뜻이 됩니다. 게다가 case 절은 더 이상 return이나 break 같은 표준 문을 통해 둘러싼 제어 흐름에 영향을 줄 수 없게 됩니다. 따라서 그처럼 엄격한 스코프는 직관적이지 않고 예상을 벗어나는 동작으로 이어질 것입니다.

이에 따른 직접적인 결과는 모든 변수 바인딩이 해당 case 문이나 match 문보다 오래 살아남는다는 것입니다. 대상과 부분적으로만 매칭되는 패턴조차도 지역 변수를 바인딩할 수 있습니다(사실 이는 가드가 함수로서 제대로 동작하는 데 필요합니다). 하지만 이러한 변수 바인딩 의미론은 for 루프나 with 문 같은 기존 Python 구조와 일치합니다.

가드

어떤 제약 조건은 패턴만으로는 적절히 표현할 수 없습니다. 예를 들어 ‘보다 작음’이나 ‘보다 큼’ 관계는 패턴의 일반적인 ‘같음’ 의미론에 들어맞지 않습니다. 게다가 서로 다른 하위 패턴은 독립적이어서 서로를 참조할 수 없습니다. 가드를 추가하면 이러한 제약이 해결됩니다. 가드는 패턴에 붙는 임의의 표현식으로, 패턴이 성공하려면 “참으로 간주되는(truthy)” 값으로 평가되어야 합니다.

예를 들어 case [x, y] if x < y:는 가드(if x < y)를 사용하여, 가드가 없다면 서로 무관한 두 캡처 패턴 x와 y 사이의 ‘보다 작음’ 관계를 표현합니다.

개념적 관점에서 패턴은 대상에 대한 구조적 제약 조건을 선언적인 방식으로, 이상적으로는 아무런 부작용 없이 기술합니다. 특히 패턴은 표현식과 분명히 구별되며, 서로 다른 목적과 의미론을 따른다는 점을 상기하십시오. 그리고 가드는 (부작용이 있을 수도 있는) 임의의 표현식으로 case 블록을 매우 통제된 방식으로 보강합니다. 전체 기능을 정적인 구조적 부분과 동적으로 평가되는 부분으로 나누면 가독성에 도움이 될 뿐 아니라 컴파일러 최적화의 가능성도 극적으로 열어 줄 수 있습니다. 이러한 명확한 분리를 유지하기 위해 가드는 case 절 수준에서만 지원되며 개별 패턴에는 지원되지 않습니다.

가드를 사용하는 예제:

def sort(seq):
    match seq:
        case [] | [_]:
            return seq
        case [x, y] if x <= y:
            return seq
        case [x, y]:
            return [y, x]
        case [x, y, z] if x <= y <= z:
            return seq
        case [x, y, z] if x >= y >= z:
            return [z, y, x]
        case [p, *rest]:
            a = sort([x for x in rest if x <= p])
            b = sort([x for x in rest if p < x])
            return a + [p] + b

패턴

패턴은 두 가지 목적을 수행합니다. 대상에 (구조적) 제약 조건을 부과하고, 대상에서 어떤 데이터 값을 추출하여 변수에 바인딩할지 지정합니다. Python에서 패턴 매칭의 원형이라고 볼 수 있는 이터러블 언패킹에는 시퀀스를 표현하는 구조적 패턴이 하나뿐인 반면, 특정 변수나 필드에 값을 할당하는 바인딩 패턴은 풍부하게 있습니다. 완전한 패턴 매칭은 구조적 패턴이 더 다양한 대신 바인딩 패턴은 최소한만 있다는 점에서 이와 다릅니다.

패턴은 (이터러블 언패킹에서와 같은) 대입 대상과 두 가지 점에서 다릅니다. 패턴은 대상의 구조에 추가적인 제약을 부과하며, 대상은 어느 시점에서든 특정 패턴과의 매칭에 안전하게 실패할 수 있습니다(이터러블 언패킹에서는 이것이 오류가 됩니다). 후자는 패턴이 가능한 한 부작용을 피해야 한다는 것을 의미합니다.

부작용을 피하려는 이러한 바람은 캡처 패턴이 속성이나 서브스크립트에 값을 바인딩하는 것을 허용하지 않는 이유 중 하나입니다. 이를 포함하는 패턴이 이후 단계에서 실패한다면 그러한 바인딩을 되돌리기 어렵기 때문입니다.

패턴 매칭의 초석은 임의의 패턴 중첩이 가능하다는 점입니다. 중첩을 통해 대안뿐만 아니라 깊은 트리 구조도 표현할 수 있습니다(중첩된 클래스 패턴의 예는 위의 동기 섹션을 참고하십시오).

패턴은 겉보기에 표현식처럼 보일 수 있지만, 둘 사이에는 분명한 차이가 있다는 점을 명심하는 것이 중요합니다. 사실 어떤 패턴도 표현식이 아니며 표현식을 포함하지도 않습니다. 패턴을 함수 정의의 형식 매개변수와 유사한 선언적 요소로 생각하는 편이 더 생산적입니다.

AS 패턴

패턴은 두 가지 범주로 나뉩니다. 대부분의 패턴은 대상이 충족해야 하는 (구조적) 제약을 부과하는 반면, 캡처 패턴은 대상의 구조나 실제 값에 관계없이 대상을 이름에 바인딩합니다. 따라서 패턴은 제약을 표현하거나 값을 바인딩할 수는 있지만, 둘 다 할 수는 없습니다. AS 패턴은 사용자가 일반적인 패턴을 지정하면서 동시에 대상을 변수에 캡처할 수 있게 해 준다는 점에서 이 공백을 메웁니다.

AS 패턴의 전형적인 사용 사례로는 예를 들어 case BinOp('+'|'-' as op, ...):나 case [int() as first, int() as second]:처럼 OR 패턴 및 클래스 패턴을 바인딩 이름과 함께 사용하는 경우가 있습니다. 후자는 대상이 [first, second]와 [int(), int()]라는 서로 다른 두 패턴을 모두 충족해야 한다는 의미로 이해할 수 있습니다. 따라서 AS 패턴은 ‘and’ 패턴의 특수한 경우로 볼 수 있습니다(‘and’ 패턴에 대한 추가 논의는 아래의 OR 패턴을 참고하십시오).

이전 버전에서 AS 패턴은 case [first:=int(), second:=int()]로 작성되는 ‘월러스 패턴’으로 고안되었습니다. 그러나 as를 사용하는 것은 :=에 비해 몇 가지 장점이 있습니다:

  • 월러스 연산자 :=는 우변에 있는 표현식의 결과를 캡처하는 데 사용되는 반면, as는 일반적으로 import foo as bar나 except E as err:처럼 어떤 형태의 ‘처리’를 나타냅니다. 실제로 패턴 P as x는 패턴 P를 x에 할당하는 것이 아니라, P에 성공적으로 매칭된 대상을 할당합니다.
  • as는 왼쪽에서 오른쪽으로 더 일관된 데이터 흐름을 가능하게 합니다(클래스 패턴의 속성도 왼쪽에서 오른쪽으로의 데이터 흐름을 따릅니다).
  • 바다코끼리 연산자는 클래스 패턴에서 속성을 매칭하는 문법과 매우 비슷해 보이므로 혼란을 일으킬 수 있습니다.

AS 패턴을 사용하는 예제:

def simplify_expr(tokens):
    match tokens:
        case [('('|'[') as l, *expr, (')'|']') as r] if (l+r) in ('()', '[]'):
            return simplify_expr(expr)
        case [0, ('+'|'-') as op, right]:
            return UnaryOp(op, right)
        case [(int() | float() as left) | Num(left), '+', (int() | float() as right) | Num(right)]:
            return Num(left + right)
        case [(int() | float()) as value]:
            return Num(value)

OR 패턴

OR 패턴을 사용하면 ‘구조적으로 동등한’ 대안들을 새로운 패턴으로 결합할 수 있습니다. 즉, 여러 패턴이 공통 핸들러를 공유할 수 있습니다. OR 패턴의 하위 패턴 중 하나라도 대상과 매칭되면 OR 패턴 전체가 성공합니다.

정적 타입 언어는 변수의 타입과 관련된 잠재적 충돌 때문에 OR 패턴 안에서 이름을 바인딩하는 것(캡처 패턴)을 금지합니다. 동적 타입 언어인 Python은 이 점에서 덜 제한적일 수 있으며, OR 패턴 안에서 캡처 패턴을 허용할 수 있습니다. 그러나 정의되지 않을 수 있는 이름을 남기지 않도록 각 하위 패턴은 같은 변수 집합을 바인딩해야 합니다. 두 대안 P | Q의 경우, 이는 P가 변수 u와 v를 바인딩한다면 Q도 정확히 같은 변수 u와 v를 바인딩해야 한다는 뜻입니다.

대안을 구분하는 데 막대 기호 |를 사용할지, or 키워드를 사용할지를 두고 논의가 있었습니다. OR 패턴은 이 두 기호 중 어느 쪽의 기존 의미론이나 용법에도 완전히 들어맞지는 않습니다. 그러나 |는 OR 패턴을 지원하는 모든 프로그래밍 언어에서 선택된 기호이며, Python의 정규 표현식에서도 그러한 용도로 사용됩니다. 또한 이는 형식 문법(Python의 문법 포함)에서 대안들을 구분하는 전통적인 구분자이기도 합니다. 게다가 |는 비트 단위 OR뿐만 아니라 집합의 합집합과 딕셔너리 병합(PEP 584)에도 사용됩니다.

다른 대안들도 검토되었지만, 그중 어느 것도 OR 패턴을 다른 패턴 안에 중첩할 수 있게 하지는 못합니다:

  • 쉼표 사용:
    case 401, 403, 404:
        print("Some HTTP error")
    

    이 방식은 튜플과 너무 비슷해 보입니다 – 튜플을 표기하는 다른 방법을 찾아야 하고, 클래스 패턴의 인자 목록 안에서는 이 구문을 괄호로 감싸야 합니다. 일반적으로 쉼표는 Python에서 이미 매우 다양한 의미를 지니고 있으므로, 의미를 더 추가해서는 안 됩니다.

  • case 여러 개 겹쳐 쓰기:
    case 401:
    case 403:
    case 404:
        print("Some HTTP error")
    

    C에서는 case의 폴스루(fall-through) 의미론을 사용하여 이렇게 합니다. 그러나 match/case가 폴스루 의미론(C에서 흔한 버그의 원인입니다)을 사용한다고 사람들이 오해하게 만들고 싶지는 않습니다. 또한 이 방식은 새로운 들여쓰기 패턴이 되어 IDE 등에서 지원하기가 더 어려워질 수 있습니다(“콜론으로 끝나는 줄 다음에는 들여쓰기 수준을 하나 추가한다”는 단순한 규칙이 깨지게 됩니다). 마지막으로, 이 방식 역시 다른 패턴 안에 중첩된 OR 패턴을 지원하지 못합니다.

  • “case in” 뒤에 쉼표로 구분된 목록 사용:
    case in 401, 403, 404:
        print("Some HTTP error")
    

    이 방식은 다음과 같이 다른 패턴 안에 중첩된 OR 패턴에는 작동하지 않습니다::

    case Point(0|1, 0|1):
        print("A corner of the unit square")
    

AND 및 NOT 패턴

이 제안이 여러 대안 중 하나와 매치하는 OR 패턴(|)을 정의하므로, AND 패턴(&)이나 더 나아가 NOT 패턴(!)도 정의하면 어떻겠습니까? 특히 일부 다른 언어(예를 들어 F#)가 AND 패턴을 지원한다는 점을 고려하면 더욱 그렇습니다.

그러나 이것이 얼마나 유용할지는 분명하지 않습니다. 딕셔너리, 객체, 시퀀스를 매칭하는 의미는 이미 암묵적인 ‘and’를 포함하고 있습니다. 매치가 성공하려면 언급된 모든 속성과 요소가 존재해야 합니다. 가드 조건 또한 가상의 ‘and’ 연산자가 쓰일 만한 사용 사례의 상당수를 지원할 수 있습니다.

연산자 !를 접두사로 사용하는 매치 패턴의 부정은 패턴 자체가 매치되지 않을 때에만 정확히 매치됩니다. 예를 들어 !(3 | 4)는 3이나 4를 제외한 모든 것과 매치됩니다. 그러나 이는 거의 유용하지 않으며, 주로 변수 스코프를 제어하고 변수 바인딩을 막기 위해(이는 Python에는 해당하지 않습니다) 이중 부정 !!로 사용된다는 다른 언어에서 나온 증거가 있습니다. 다른 사용 사례는 가드를 사용하여 더 잘 표현할 수 있습니다.

결국 이것은 큰 이점 없이 문법만 더 복잡하게 만든다고 결정되었습니다. 이는 언제든 나중에 추가할 수 있습니다.

OR 패턴을 사용하는 예제:

def simplify(expr):
    match expr:
        case ('/', 0, 0):
            return expr
        case ('*'|'/', 0, _):
            return 0
        case ('+'|'-', x, 0) | ('+', 0, x) | ('*', 1, x) | ('*'|'/', x, 1):
            return x
    return expr

리터럴 패턴

리터럴 패턴은 대상의 타입이나 구조가 아니라 값에 제약을 부과하는 편리한 방법입니다. 또한 리터럴 패턴을 사용하면 패턴 매칭으로 switch 문을 흉내 낼 수 있습니다.

일반적으로 대상은 표준 동등 비교(Python 문법으로는 x == y)를 통해 리터럴 패턴과 비교됩니다. 따라서 리터럴 패턴 1.0과 1은 정확히 같은 객체 집합에 매치됩니다. 즉, case 1.0:과 case 1:은 완전히 서로 바꿔 쓸 수 있습니다. 원칙적으로는 True == 1이 성립하므로 True도 같은 객체 집합에 매치될 것입니다. 그러나 우리는 case True:가 대상 1.0에 매치된다는 사실에 많은 사용자가 놀랄 것이며, 이로 인해 미묘한 버그와 복잡한 우회 방법이 생겨날 것이라고 생각합니다. 따라서 저희는 세 개의 싱글턴 패턴 None, False, True가 동등이 아니라 동일성(Python 문법으로는 x is y)으로 매치된다는 규칙을 채택했습니다. 따라서 case True:는 True에만 매치되며 그 밖의 어떤 것에도 매치되지 않습니다. 다만 리터럴 패턴 1은 동일성이 아닌 동등성으로 동작하므로, case 1:은 여전히 True에 매치된다는 점에 유의하십시오.

case 1.0은 정수 1과 부동소수점 수 1.0 모두에 매치되고 case 1:은 정수 1에만 매치되도록 수에 계층을 도입하자는 초기 아이디어는, 결국 동등성에 기반한 더 단순하고 일관된 규칙을 위해 폐기되었습니다. 게다가 대상이 numbers.Integral의 인스턴스인지 추가로 검사하면, 본질적으로 Python에 새로운 개념을 도입하기 위해 높은 런타임 비용을 치르게 됩니다. 필요한 경우 명시적 문법 case int(1):을 사용할 수 있습니다.

리터럴 패턴은 표현식이 아니며, 특정 값을 직접 나타낸다는 점을 기억하십시오. 실용적인 관점에서 우리는 음수, 나아가 복소수 값까지 리터럴 패턴으로 사용할 수 있게 하고 싶지만, 이들은 원자적 리터럴이 아닙니다(부호 없는 실수와 허수만 원자적 리터럴입니다). 예를 들어 -3+4j는 문법적으로 BinOp(UnaryOp('-', 3), '+', 4j) 형태의 표현식입니다. 표현식은 패턴의 일부가 아니므로, 완전한 표현식에 의존하지 않고 이러한 값을 다룰 수 있도록 명시적인 문법 지원을 추가해야 했습니다.

반면 보간된 f-문자열은 겉모습과 달리 리터럴 값이 아니므로 리터럴 패턴으로 사용할 수 없습니다(단, 문자열 연결은 지원됩니다).

리터럴 패턴은 그 자체로 패턴으로 쓰일 뿐만 아니라 매핑 패턴의 키로도 쓰입니다.

범위 매칭 패턴. 이를 도입하면 1...6과 같은 패턴을 쓸 수 있을 것입니다. 그러나 여러 가지 모호함이 있습니다:

  • 범위는 열린 범위입니까, 반열린 범위입니까, 닫힌 범위입니까? (즉, 위 예에서 6이 포함됩니까, 포함되지 않습니까?)
  • 범위가 단일 숫자에 매치됩니까, 아니면 range 객체에 매치됩니까?
  • 범위 매칭은 흔히 문자 범위(‘a’…’z’)에 사용되지만, Python에는 문자 데이터 타입이 없고 문자열만 있으므로 이 방식은 동작하지 않습니다.
  • 범위 매칭은 점프 테이블을 미리 구축할 수 있다면 상당한 성능 최적화가 될 수 있지만, Python에서는 이름이 동적으로 다시 바인딩될 수 있기 때문에 일반적으로 이것이 불가능합니다.

범위를 위한 특수한 문법을 만드는 대신 사용자 정의 패턴 객체(InRange(0, 6))를 허용하는 편이 더 유연하고 덜 모호하다고 결정했습니다. 다만 이러한 아이디어는 당분간 보류되었습니다.

리터럴 패턴을 사용하는 예제:

def simplify(expr):
    match expr:
        case ('+', 0, x):
            return x
        case ('+' | '-', x, 0):
            return x
        case ('and', True, x):
            return x
        case ('and', False, x):
            return False
        case ('or', False, x):
            return x
        case ('or', True, x):
            return True
        case ('not', ('not', x)):
            return x
    return expr

캡처 패턴

캡처 패턴은 어떤 값이든 받아들여 이를 (지역) 변수에 바인딩하는 이름의 형태를 띱니다(이름이 nonlocal 또는 global로 선언된 경우는 제외합니다). 그런 의미에서 캡처 패턴은 함수 정의의 매개변수와 비슷합니다(함수가 호출되면 각 매개변수는 해당 인자를 함수 스코프의 지역 변수에 바인딩합니다).

캡처 패턴에 사용된 이름은 같은 패턴 안의 다른 캡처 패턴과 겹쳐서는 안 됩니다. 이 역시 매개변수와 비슷합니다. 매개변수도 마찬가지로 각 매개변수 이름이 매개변수 목록 안에서 고유해야 합니다. 그러나 이는 이터러블 언패킹 대입과는 다릅니다. 이터러블 언패킹 대입에서는 같은 변수 이름을 대상으로 반복해서 사용할 수 있습니다(예: x, x = 1, 2). 패턴에서 (x, x)를 지원하지 않는 근거는 그 해석이 모호하다는 점입니다. 이는 x에 대한 두 번째 바인딩만 남는 이터러블 언패킹처럼 볼 수도 있습니다. 하지만 같은 요소 두 개로 이루어진 튜플을 표현하는 것으로 볼 수도 있습니다(이 해석에도 나름의 문제가 있습니다). 필요하다면 이름의 반복 사용에 대한 지원을 나중에 도입할 수도 있습니다.

캡처 패턴을 명시적으로 표시하여 바인딩 대상임을 드러내자는 요구가 있었습니다. 그 아이디어에 따르면 캡처 패턴은 예컨대 ?x, $x 또는 =x처럼 작성됩니다. 이러한 명시적 캡처 표시의 목적은 표시되지 않은 이름을 값 패턴으로 만드는 것입니다(아래 참조). 그러나 이는 패턴 매칭이 switch 문의 확장이며 (순서형) 값에 기반한 빠른 분기에 중점을 둔다는 오해에 바탕을 두고 있습니다. 이러한 switch 문은 실제로 이전에 Python에 제안된 적이 있습니다(PEP 275 및 PEP 3103 참조). 반면 패턴 매칭은 이터러블 언패킹을 일반화한 개념을 구축합니다. 자료 구조에서 추출한 값을 바인딩하는 것은 이 개념의 가장 핵심이며, 따라서 가장 흔한 사용 사례입니다. 따라서 캡처 패턴에 명시적 표시를 도입하면 제안된 패턴 매칭 문법의 목적을 저버리게 되며, 부차적인 사용 사례를 단순화하는 대가로 핵심 사례에 문법적 군더더기를 더하게 됩니다.

AS 패턴과 와일드카드 패턴을 결합하면 같은 효과를 얻을 수 있으므로(예: case _ as x는 case x와 동등합니다) 캡처 패턴은 전혀 필요하지 않다는 제안도 있었습니다. 그러나 이는 불편할 정도로 장황하며, 캡처 패턴이 매우 흔하게 쓰일 것으로 예상된다는 점을 고려하면 더욱 그렇습니다.

캡처 패턴을 사용하는 예제:

def average(*args):
    match args:
        case [x, y]:           # captures the two elements of a sequence
            return (x + y) / 2
        case [x]:              # captures the only element of a sequence
            return x
        case []:
            return 0
        case a:                # captures the entire sequence
            return sum(a) / len(a)

와일드카드 패턴

와일드카드 패턴은 ‘캡처’ 패턴의 특수한 경우입니다. 어떤 값이든 받아들이지만, 그 값을 변수에 바인딩하지는 않습니다. 이 규칙의 취지는 패턴에서 와일드카드를 반복해서 사용할 수 있도록 하는 것입니다. (x, x)는 오류이지만 (_, _)는 유효합니다.

특히 더 큰 (시퀀스) 패턴에서는 패턴이 나머지는 모두 무시하고 실제로 의미 있는 값에만 집중할 수 있도록 하는 것이 중요합니다. 와일드카드가 없다면 여러 개의 지역 변수를 ‘만들어 내야’ 하는데, 이 변수들은 바인딩되기만 하고 사용되지는 않습니다. 명명 규칙을 지켜 무관한 값의 이름으로 예컨대 _1, _2, _3을 사용하더라도, 이는 여전히 시각적 군더더기를 만들고 성능을 저하시킬 수 있습니다(시퀀스 패턴 (x, y, *z)와 (_, y, *_)를 비교해 보십시오. *z는 인터프리터가 매우 길 수도 있는 시퀀스를 복사하도록 강제하는 반면, 두 번째 버전은 단순히 y = seq[1]과 비슷한 코드로 컴파일됩니다).

밑줄 _를 와일드카드 패턴으로 선택하는 것, 즉 이 한 이름을 바인딩하지 않는 이름으로 만드는 것에 대해 많은 논의가 있었습니다. 그러나 밑줄은 이미 이터러블 언패킹에서 ‘값 무시’ 표시로 널리 사용되고 있습니다. 와일드카드 패턴 _는 절대 바인딩하지 않으므로, 이러한 밑줄 사용은 REPL 안에서의 사용이나 gettext 모듈에서의 사용 같은 다른 용도와 충돌하지 않습니다.

... (즉, 줄임표 토큰)이나 * (별표)를 와일드카드로 사용하자는 제안이 있었습니다. 그러나 둘 다 임의 개수의 항목이 생략된 것처럼 보입니다:

case [a, ..., z]: ...
case [a, *, z]: ...

두 예 모두 두 개 이상의 항목으로 이루어진 시퀀스와 매치되어 첫 번째 값과 마지막 값을 캡처하는 것처럼 보입니다. 그것이 궁극의 “와일드카드”일 수는 있겠지만, 원하는 의미를 전달하지는 못합니다.

임의 개수의 항목을 암시하지 않는 대안으로는 ?가 있습니다. 이는 PEP 640에서 패턴 매칭과는 별개로 제안되고 있기도 합니다. 그러나 저희는 ?를 특수한 “대입” 대상으로 사용하는 것이 _를 사용하는 것보다 Python 사용자에게 더 혼란스러울 가능성이 높다고 생각합니다. 이는 구두점 문자를 일반적인 영어 용법이나 고등학교 수학에서 쓰이는 방식과 비슷하게만 사용한다는 Python의 (분명 모호하기는 한) 원칙에 어긋납니다. 단, 그 용법이 다른 프로그래밍 언어에서 매우 잘 확립된 경우(예를 들어 멤버 접근에 점을 사용하는 것처럼)는 예외입니다.

물음표는 두 기준 모두를 충족하지 못합니다. 다른 프로그래밍 언어에서 물음표는 “질문”이라는 개념에서 막연하게만 연상되는 온갖 용법이 뒤섞인 채로 쓰입니다. 예를 들어, 셸 글로빙에서는 “임의의 문자”, 정규 표현식에서는 “있을 수도 있음”, C와 C에서 파생된 많은 언어에서는 “조건 표현식”, Scheme에서는 “술어 함수”, Rust에서는 “오류 처리 변경”, TypeScript에서는 “선택적 인자”와 “선택적 체이닝”을 의미합니다(후자의 의미는 PEP 505에서 Python에도 제안된 바 있습니다). 아직 번호가 매겨지지 않은 한 PEP는 이를 선택적 타입을 표시하는 데 사용하자고 제안합니다(예: int?).

프로그래밍 시스템에서 ?의 또 다른 흔한 용도는 “도움말”로, 예를 들어 IPython과 Jupyter Notebooks, 그리고 많은 대화형 명령줄 유틸리티에서 그렇게 쓰입니다.

게다가 이는 Python을 상당히 독특한 위치에 놓이게 할 것입니다. 저희가 찾을 수 있었던 패턴 매칭을 지원하는 모든 프로그래밍 언어(C#, Elixir, Erlang, F#, Grace, Haskell, Mathematica, OCaml, Ruby, Rust, Scala, Swift, Thorn 포함)에서 밑줄은 와일드카드 패턴으로 쓰입니다. Python 사용자 중 상당수가 다른 프로그래밍 언어도 함께 사용하고, Python을 배울 때 이미 사전 경험이 있으며, Python을 배운 뒤 다른 언어로 옮겨갈 수도 있다는 점을 염두에 두면, 저희는 이처럼 잘 확립된 표준이 가독성과 학습 용이성 측면에서 중요하고 의미가 있다고 판단합니다. 저희가 보기에, 이 와일드카드로 인해 일반 이름이 특별한 취급을 받게 된다는 우려는 Python만 특별하게 만드는 문법을 도입할 만큼 강력한 근거가 되지 못합니다.

Else 블록. 가드가 없고 패턴이 단일 와일드카드(즉, case _:)인 case 블록은 어떤 대상이든 변수에 바인딩하거나 다른 어떤 연산도 수행하지 않고 받아들입니다. 따라서 이는 else:가 지원된다면 그것과 의미상 동등합니다. 그러나 match 문 문법에 이러한 else 블록을 추가하더라도 다른 맥락에서 와일드카드 패턴이 필요하다는 사실은 없어지지 않습니다. 이에 반대하는 또 다른 인자는 else 블록의 들여쓰기 수준으로 그럴듯한 것이 두 가지라는 점입니다. case에 맞추는 것과 match에 맞추는 것입니다. 저자들은 어느 들여쓰기 수준을 선호할지에 대해 의견이 상당히 엇갈린다는 것을 확인했습니다.

와일드카드 패턴을 사용하는 예제:

def is_closed(sequence):
    match sequence:
        case [_]:               # any sequence with a single element
            return True
        case [start, *_, end]:  # a sequence with at least two elements
            return start == end
        case _:                 # anything
            return False

값 패턴

매개변수적 값에 이름 있는 상수를 사용하거나 특정 값의 의미를 명확히 하는 것은 좋은 프로그래밍 스타일입니다. 예를 들어, case (200, body):보다 case (HttpStatus.OK, body):라고 쓰는 편이 분명히 더 바람직할 것입니다. 여기서 생기는 주요 문제는 캡처 패턴(변수 바인딩)과 값 패턴을 어떻게 구별하느냐입니다. 이 문제를 둘러싼 전반적인 논의에서 매우 많은 선택지가 제시되었으며, 여기서 그 모두를 빠짐없이 나열할 수는 없습니다.

엄밀히 말하면 값 패턴이 꼭 필요한 것은 아니며, 가드를 사용하여, 즉 case (status, body) if status == HttpStatus.OK:처럼 구현할 수도 있습니다. 그럼에도 불구하고 값 패턴의 편리함은 의심의 여지가 없으며 자명합니다.

상수는 대문자로 쓰이거나 열거형과 비슷한 네임스페이스에 모여 있는 경향이 있다는 관찰로부터, 상수를 구문적으로 식별하는 규칙을 생각해 볼 수 있습니다. 그러나 대문자와 소문자의 구분을 표시로 사용하자는 아이디어는 핵심 Python에 이와 비슷한 선례가 없다는 이유로(다른 언어에서는 흔하지만) 회의적인 반응을 얻었습니다. 따라서 저희는 점으로 구분된 이름(즉, 속성 접근)은 모두 값 패턴으로 해석한다는 규칙만 채택했습니다. 예를 들어 위의 HttpStatus.OK가 그렇습니다. 이로 인해 특히 지역 변수와 현재 모듈에서 정의된 전역 변수는 상수로 동작할 수 없습니다.

이 목적으로 앞에 점을 붙이자는(예: .CONSTANT) 규칙이 제안되었으나, 점이 그러한 목적으로 쓰기에는 충분히 눈에 띄는 표시가 아니라는 이유로 비판을 받았습니다. 부분적으로 다른 프로그래밍 언어에서 볼 수 있는 형태에서 영감을 받아 여러 가지 표시/기호가 제안되었지만(^CONSTANT, $CONSTANT, ==CONSTANT, CONSTANT?, 또는 백틱으로 감싼 단어 등), 명백하거나 자연스러운 선택지는 없었습니다. 따라서 현재 제안은 이러한 ‘상수’ 표시에 대한 논의와 그 도입 여부를 향후 PEP로 미룹니다.

이름이 전역 변수인지 여부에 따라 이름의 의미를 구분하면(즉, 컴파일러가 전역 변수를 캡처 패턴이 아닌 상수로 취급하면) 여러 문제가 생깁니다. 모듈에서 전역 변수를 추가하거나 변경하면 패턴에 의도하지 않은 부작용이 생길 수 있습니다. 게다가 모듈 스코프에서는 모든 변수가 전역 변수이므로 캡처 패턴을 쓸 수 없게 되어, 모듈 스코프 안에서는 패턴 매칭을 직접 사용할 수 없게 됩니다.

값 패턴을 사용한 예시:

def handle_reply(reply):
    match reply:
        case (HttpStatus.OK, MimeType.TEXT, body):
            process_text(body)
        case (HttpStatus.OK, MimeType.APPL_ZIP, body):
            text = deflate(body)
            process_text(text)
        case (HttpStatus.MOVED_PERMANENTLY, new_URI):
            resend_request(new_URI)
        case (HttpStatus.NOT_FOUND):
            raise ResourceNotFound()

그룹 패턴

사용자가 그룹을 명시적으로 지정할 수 있으면 특히 OR 패턴에서 유용합니다.

시퀀스 패턴

시퀀스 패턴은 이미 확립된 이터러블 언패킹의 문법과 의미를 최대한 가깝게 따릅니다. 물론 하위 패턴이 대입 대상(변수, 속성, 서브스크립트)의 자리를 대신합니다. 또한 이터러블 언패킹은 모든 이터러블에 적용할 수 있지만, 시퀀스 패턴은 신중하게 선별한 일부 대상에만 매칭됩니다.

  • 이터러블 언패킹에서와 마찬가지로, ‘튜플’ 표기법과 ‘리스트’ 표기법을 구분하지 않습니다. [a, b, c], (a, b, c), a, b, c는 모두 동등합니다. 이는 중복된 표기법이 생기고 리스트나 튜플을 구체적으로 검사하려면 더 많은 노력이 필요하다는 것(예: case list([a, b, c]))을 의미하지만, 저희는 가능한 한 이터러블 언패킹을 모방합니다.
  • 별표 패턴은 임의 길이의 하위 시퀀스를 캡처하며, 이 역시 이터러블 언패킹을 그대로 따른 것입니다. 하나의 시퀀스 패턴에는 별표 항목이 하나만 올 수 있습니다. 이론적으로는 (*_, 3, *_) 같은 패턴을 값 3을 포함하는 임의의 시퀀스를 표현하는 것으로 이해할 수도 있습니다. 그러나 실제로는 이러한 방식이 매우 좁은 범위의 사용 사례에서만 동작하며, 그 밖의 경우에는 비효율적인 백트래킹이나 심지어 모호성까지 일으킵니다.
  • 시퀀스 패턴은 이터러블 대상을 순회하지 않습니다. 모든 요소는 서브스크립션과 슬라이싱으로 접근하며, 대상은 collections.abc.Sequence의 인스턴스여야 합니다. 여기에는 물론 리스트와 튜플이 포함되지만, 예를 들어 집합과 딕셔너리는 제외됩니다. 문자열과 바이트열도 여기에 포함되겠지만, 이들은 예외로 둡니다(아래 참조).

시퀀스 패턴은 아무 이터러블 객체나 순회할 수는 없습니다. 전체 패턴이 실패하면 순회하면서 소비한 요소를 되돌려야 하는데, 이는 실현할 수 없습니다.

시퀀스는 이 점에서 이러한 프로토콜을 매핑(예: dict)과 공유하므로, 시퀀스를 식별할 때 len()과 서브스크립션, 슬라이싱에만 의존할 수는 없습니다. 시퀀스 패턴이 딕셔너리나 매핑 프로토콜(즉, __getitem__)을 구현하는 다른 객체에도 매칭된다면 뜻밖일 것입니다. 따라서 인터프리터는 인스턴스 검사를 수행하여 해당 대상이 실제로 (알려진 타입의) 시퀀스인지 확인합니다. (가장 흔한 경우에 대한 최적화로, 대상이 정확히 리스트나 튜플이라면 인스턴스 검사를 생략할 수 있습니다.)

문자열과 바이트열 객체는 이중적인 성격을 지닙니다. 이들은 그 자체로 ‘원자적’ 객체이면서 동시에 시퀀스이기도 합니다(문자열은 문자열의 시퀀스라는 점에서 강한 재귀적 성격을 띱니다). 문자열과 바이트열의 일반적인 동작과 사용 사례는 튜플이나 리스트와 충분히 달라서 명확히 구분할 필요가 있습니다. 실제로 문자열이 시퀀스로 취급되는 것은 직관에 어긋나고 의도하지 않은 경우가 많으며, 이는 꾸준히 제기되는 질문과 불만으로도 알 수 있습니다. 따라서 문자열과 바이트열은 시퀀스 패턴에 매칭되지 않으며, 이로써 시퀀스 패턴은 ‘시퀀스’를 매우 한정된 의미로만 이해합니다. bytes의 가변 버전인 내장 bytearray 타입도 예외로 둘 만합니다. 하지만 바이트를 표현하는 데 사용될 수 있는 다른 모든 타입(예: memoryview와 array.array의 인스턴스 중 전부는 아니지만 일부)을 열거할 생각은 없습니다.

매핑 패턴

딕셔너리, 더 일반적으로는 매핑은 Python에서 가장 중요하고 가장 널리 사용되는 자료 구조 중 하나입니다. 시퀀스와 달리 매핑은 키로 식별되는 임의의 요소에 빠르게 직접 접근하도록 만들어졌습니다. 대부분의 경우 딕셔너리에서 요소를 가져올 때는 순서나 같은 딕셔너리에 저장된 다른 키-값 쌍을 고려하지 않고 이미 알고 있는 키를 사용합니다. 특히 문자열 키가 흔히 쓰입니다.

매핑 패턴은 딕셔너리 조회의 일반적인 사용 방식을 반영합니다. 사용자는 상수 키나 이미 알고 있는 키로 매핑에서 일부 값을 추출하고, 그 값이 주어진 하위 패턴과 매치되도록 할 수 있습니다. **rest가 없더라도 대상에 있는 추가 키는 무시됩니다. 이 점은 추가 항목이 있으면 매치가 실패하는 시퀀스 패턴과 다릅니다. 하지만 매핑은 실제로 시퀀스와 다릅니다. 매핑에는 자연스러운 구조적 서브타이핑 동작이 있습니다. 즉, 추가 키가 있는 딕셔너리를 어딘가에 전달해도 대개는 그대로 동작합니다. 매핑에 상한을 두어 추가 키가 없도록 보장해야 한다면 일반적인 이중 별표 패턴 **rest를 사용할 수 있습니다. 그러나 와일드카드를 사용하는 특수한 경우인 **_는 지원하지 않습니다. 아무런 효과가 없으면서 매핑 패턴의 의미를 잘못 이해하게 만들 수 있기 때문입니다.

지나치게 비용이 큰 매칭 알고리즘을 피하기 위해 키는 리터럴이나 값 패턴이어야 합니다.

__getitem__(key)를 호출한 뒤 AttributeError를 검사하는 대신 get(key, default)를 사용하는 데에는 미묘한 이유가 있습니다. 대상이 마침 defaultdict라면 존재하지 않는 키로 __getitem__을 호출할 때 그 키가 추가되기 때문입니다. get()을 사용하면 이런 예기치 않은 부작용을 피할 수 있습니다.

매핑 패턴을 사용한 예시:

def change_red_to_blue(json_obj):
    match json_obj:
        case { 'color': ('red' | '#FF0000') }:
            json_obj['color'] = 'blue'
        case { 'children': children }:
            for child in children:
                change_red_to_blue(child)

클래스 패턴

클래스 패턴에는 두 가지 목적이 있습니다. 주어진 대상이 실제로 특정 클래스의 인스턴스인지 확인하는 것과 대상의 특정 속성에서 데이터를 추출하는 것입니다. 일화적인 근거에 따르면, 프로그램에 정적으로 등장하는 횟수를 기준으로 할 때 isinstance()는 Python에서 가장 자주 사용되는 함수 중 하나입니다. 이러한 인스턴스 검사 뒤에는 보통 객체에 저장된 정보에 접근하거나 그 정보를 조작하는 코드가 이어집니다. 전형적인 패턴은 대략 다음과 같은 형태입니다:

def traverse_tree(node):
    if isinstance(node, Node):
        traverse_tree(node.left)
        traverse_tree(node.right)
    elif isinstance(node, Leaf):
        print(node.value)

동기 부분에서 제시한 예시처럼, 클래스 패턴은 많은 경우 중첩되어 나타납니다:

if (isinstance(node, BinOp) and node.op == "+"
        and isinstance(node.right, BinOp) and node.right.op == "*"):
    a, b, c = node.left, node.right.left, node.right.right
    # Handle a + b*c

클래스 패턴을 사용하면 인스턴스 검사와 관련 속성(추가 제약 조건을 붙일 수도 있음)을 모두 간결하게 지정할 수 있습니다. 그래서 예를 들어 위의 첫 번째 경우에는 case Node(left, right):라고, 두 번째 경우에는 case Leaf(value):라고 쓰고 싶은 유혹이 매우 큽니다. 이 방식은 엄격한 대수적 데이터 타입이 있는 언어에서는 실제로 잘 동작하지만, Python 객체의 구조에서는 문제가 됩니다.

일반적인 Python 객체를 다룰 때는 순서가 없는 속성이 잠재적으로 매우 많다는 문제에 부딪힙니다. Node의 인스턴스에는 많은 속성이 있으며, 그 대부분은 __repr__과 같은 ‘특수 메서드’입니다. 게다가 인터프리터는 속성의 순서를 확실하게 추론할 수 없습니다. 예를 들어 원을 나타내는 객체라면 속성 x, y, radius 사이에 본질적으로 자명한 순서가 없습니다.

이 문제를 다루는 방법으로 두 가지를 구상합니다. 관심 있는 속성의 이름을 명시적으로 지정하거나, 어떤 속성을 어떤 순서로 추출할지 인터프리터에게 알려 주는 추가 매핑을 제공하는 것입니다. 두 방식을 모두 지원합니다. 게다가 관심 있는 속성의 이름을 명시적으로 지정하면 객체에 요구되는 구조를 더 구체적으로 지정할 수 있습니다. 패턴에 지정된 속성이 객체에 없으면 매치는 실패합니다.

  • 이름을 명시적으로 지정한 속성은 이름 있는 인자의 문법을 따릅니다. 위와 같이 Node 클래스의 객체에 left와 right라는 두 속성이 있다면, 패턴 Node(left=x, right=y)는 두 속성의 값을 추출하여 각각 x와 y에 할당합니다. 데이터가 왼쪽에서 오른쪽으로 흐르는 것이 낯설어 보일 수 있지만, 이는 매핑 패턴과 일관되며 with 문이나 import 문에서 as를 통해 할당하는 것(그리고 AS 패턴)과 같은 선례도 있습니다.

    해당 속성의 이름을 명시적으로 지정하는 방식은 주로 (아래에서 설명할) 위치 형식으로는 충분하지 않은 더 복잡한 경우에 사용될 것입니다.

  • 클래스 필드 __match_args__는 여러 속성과 그 순서를 지정하므로, 클래스 패턴은 해당 속성의 이름을 명시적으로 지정하지 않고도 위치 하위 패턴을 사용할 수 있습니다. 이는 관심 있는 속성이 꽤 명확하고 대개 순서가 잘 정해져 있는 작은 객체나 데이터 클래스의 인스턴스에서 특히 유용합니다. 어떤 의미에서 __match_args__는 형식 매개변수 선언과 비슷합니다. 형식 매개변수 선언 덕분에 모든 매개변수의 이름을 지정하지 않고 위치 인자로 함수를 호출할 수 있습니다.

    이것은 클래스 속성입니다. 대상 인스턴스가 아니라 클래스 패턴에 지정된 클래스에서 조회해야 하기 때문입니다.

클래스 패턴의 문법은 분해가 생성 문법을 그대로 반영한다는 아이디어에 기반합니다. 이는 대입 대상이든, 함수 정의든, 이터러블 언패킹이든 사실상 모든 Python 구문에서 이미 그러합니다. 이 모든 경우에서 ‘데이터’를 보내는 문법과 받는 문법이 사실상 동일하다는 것을 알 수 있습니다.

  • 변수, 속성, 서브스크립트와 같은 대입 대상: foo.bar[2] = foo.bar[3];
  • 함수 정의: def foo(x, y, z=6)로 정의된 함수는 예를 들어 foo(123, y=45)와 같이 호출되며, 이때 호출 위치에서 제공된 실제 인자가 정의 위치의 형식 매개변수와 매칭됩니다;
  • 이터러블 언패킹: a, b = b, a 또는 [a, b] = [b, a] 또는 (a, b) = (b, a). 이는 동등한 여러 형태 중 몇 가지에 불과합니다.

읽기와 쓰기, l-값과 r-값, 또는 생성과 분해에 같은 문법을 사용하는 방식은 데이터와 그 흐름, 조작을 생각하는 데 도움이 된다는 점에서 널리 받아들여지고 있습니다. 이는 인스턴스의 명시적 생성에도 똑같이 적용되며, 클래스 패턴 C(p, q)는 인스턴스를 생성하는 문법을 의도적으로 반영합니다.

내장 클래스 bool, bytearray 등에 대한 특수한 경우(예를 들어 str(x)가 대상 값을 x에 캡처하는 경우)는 다음과 같이 사용자 정의 클래스로 흉내 낼 수 있습니다:

class MyClass:
    __match_args__ = ["__myself__"]
    __myself__ = property(lambda self: self)

패턴 변수에 대한 타입 어노테이션. 패턴과 타입 어노테이션을 결합하자는 제안이었습니다:

match x:
    case [a: int, b: str]: print(f"An int {a} and a string {b}:")
    case [a: int, b: int, c: int]: print("Three ints", a, b, c)
    ...

이 아이디어에는 문제가 많습니다. 우선, 콜론은 대괄호나 괄호 안에서만 사용할 수 있으며, 그렇지 않으면 문법이 모호해집니다. 그리고 Python은 제네릭 타입에 대한 isinstance() 검사를 허용하지 않으므로, 제네릭을 포함하는 타입 어노테이션은 예상대로 동작하지 않습니다.

역사와 배경

패턴 매칭은 1970년대 후반에 튜플 언패킹의 형태로, 그리고 연결 리스트나 트리 같은 재귀적 자료 구조를 처리하는 수단으로 등장했습니다(객체 지향 언어는 보통 재귀적 자료 구조를 처리하기 위해 방문자 패턴을 사용합니다). 패턴 매칭의 초기 지지자들은 구조화된 데이터를 C의 struct나 이후에 도입된 객체가 아니라 ‘태그가 붙은 튜플’로 구성했습니다. 예를 들어 이진 트리의 노드는 각각 왼쪽과 오른쪽 브랜치에 해당하는 두 요소와 Node 태그로 이루어진 튜플이며, Node(left, right)로 작성됩니다. Python에서는 아마도 태그를 튜플 안에 넣어 ('Node', left, right)처럼 작성하거나, 같은 효과를 얻기 위해 데이터 클래스 Node를 정의할 것입니다.

현대적인 문법을 사용하면 깊이 우선 트리 순회는 다음과 같이 작성됩니다:

def traverse(node):
    match node:
        case Node(left, right):
            traverse(left)
            traverse(right)
        case Leaf(value):
            handle(value)

패턴 매칭으로 재귀적 자료 구조를 처리한다는 개념은 곧바로 더 일반적인 재귀적 ‘패턴’(즉, 재귀적 자료 구조를 넘어서는 재귀)을 패턴 매칭으로 처리한다는 아이디어로 이어졌습니다. 이에 따라 패턴 매칭은 다음과 같은 재귀 함수를 정의하는 데에도 사용되었습니다:

def fib(arg):
    match arg:
        case 0:
            return 1
        case 1:
            return 1
        case n:
            return fib(n-1) + fib(n-2)

패턴 매칭이 새로 등장하는 프로그래밍 언어에 거듭 통합되면서 그 문법도 조금씩 발전하고 확장되었습니다. 위 fib 예제의 처음 두 경우는 대안 패턴을 나타내는 |를 사용하여 case 0 | 1:로 더 간결하게 작성할 수 있습니다. 또한 밑줄 _은 와일드카드로 널리 채택되었습니다. 와일드카드는 패턴 일부의 구조도 값도 중요하지 않은 자리를 채우는 역할을 합니다. 밑줄은 이미 Python의 이터러블 언패킹에서 동등한 역할로 자주 사용되므로(예: _, _, third, _* = something), 저희는 이러한 보편적인 표준을 유지했습니다.

패턴 매칭이라는 개념이 늘 함수라는 개념과 밀접하게 연결되어 왔다는 점은 주목할 만합니다. 서로 다른 case 절은 늘 일종의 반독립적인 함수처럼 여겨져 왔으며, 여기서 패턴 변수는 매개변수의 역할을 맡습니다. 이는 패턴 매칭을 다음과 같이(Standard ML) 오버로드된 함수로 작성할 때 가장 분명하게 드러납니다.:

fun fib 0 = 1
  | fib 1 = 1
  | fib n = fib (n-1) + fib (n-2)

Python에서는 case 절을 독립적인 함수로 엄격하게 분리하는 방식이 적용되지 않지만, 패턴은 매개변수와 많은 구문 규칙을 공유합니다. 예를 들어 인자는 한정되지 않은 이름에만 바인딩되며, 특정 패턴/함수 안에서 변수/매개변수 이름이 중복되어서는 안 됩니다.

추상화와 캡슐화를 강조하는 객체 지향 프로그래밍은 패턴 매칭에 심각한 과제를 안겨 주었습니다. 요컨대 객체 지향 프로그래밍에서는 더 이상 객체를 태그가 붙은 튜플로 볼 수 없습니다. 생성자에 전달되는 인자가 반드시 객체의 속성이나 필드를 지정하는 것은 아닙니다. 게다가 객체의 필드에는 더 이상 엄격한 순서가 없으며, 일부 필드는 비공개여서 접근할 수 없을 수도 있습니다. 더 나아가 주어진 객체가 실제로는 구조가 약간 다른 서브클래스의 인스턴스일 수도 있습니다.

이 과제를 해결하기 위해 패턴은 원래의 튜플 생성자로부터 점점 더 독립적인 형태가 되었습니다. Node(left, right)와 같은 패턴에서 Node는 더 이상 수동적인 태그가 아니라, 주어진 임의의 객체가 올바른 구조를 갖추었는지 능동적으로 검사하고 left와 right 필드를 추출할 수 있는 함수입니다. 다시 말해 Node 태그는 객체를 튜플로 변환하거나, 변환할 수 없으면 어떤 실패 표시를 반환하는 함수가 됩니다.

Python에서는 단순히 isinstance()를 클래스의 __match_args__ 필드와 함께 사용하여 객체가 올바른 구조를 갖추었는지 검사한 다음, 그 속성 중 일부를 튜플로 변환합니다. 예를 들어 위의 Node 예제에서는 이 두 속성을 추출하여 튜플을 구성해야 함을 나타내기 위해 __match_args__ = ('left', 'right')를 지정합니다. 즉, case Node(x, y)는 먼저 주어진 객체가 Node의 인스턴스인지 검사한 다음, left를 x에, right를 y에 각각 할당합니다.

하지만 ‘덕 타이핑’이라는 Python의 동적인 특성을 존중하여, 특정 속성의 존재 여부나 특정 속성에 대한 제약 조건을 더 직접적으로 지정하는 방법도 추가했습니다. Node(x, y) 대신 object(left=x, right=y)라고 작성할 수도 있으며, 이렇게 하면 사실상 isinstance() 검사가 제거되어 left와 right 속성을 가진 모든 객체를 지원하게 됩니다. 또는 이러한 아이디어를 결합하여 Node(right=y)라고 작성함으로써 Node의 인스턴스를 요구하되 right 속성의 값만 추출할 수도 있습니다.

하위 호환성

이 제안은 “소프트 키워드”와 새로운 PEG 파서(PEP 617)를 사용하므로 완전한 하위 호환성을 유지합니다. 하지만 Python 소스 코드를 파싱하는 데 LL(1) 파서를 사용하는 서드파티 도구는 같은 기능을 지원하기 위해 파서 기술을 바꿔야 할 수도 있습니다.

보안 영향

이 언어 기능으로 인한 보안 영향은 없을 것으로 예상합니다.

참조 구현

기능이 모두 구현된 CPython 구현을 GitHub에서 이용할 수 있습니다.

위 구현을 기반으로 한 대화형 플레이그라운드가 Binder [2]와 Jupyter [3]를 사용하여 만들어졌습니다.

참고 문헌