PEP 638 – 구문 매크로
- Author:
- Mark Shannon <mark at hotpy.org>
- Discussions-To:
- Python-Dev thread
- Status:
- Draft
- Type:
- Standards Track
- Created:
- 24-Sep-2020
- Post-History:
- 26-Sep-2020
Table of Contents
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
이 PEP는 Python에 구문 매크로 지원을 추가합니다. 매크로는 일반 라이브러리 코드로 깔끔하게 표현할 수 없는 기능을 가능하게 하도록 프로그램의 일부를 변환하는 컴파일 시간 함수입니다.
“구문”이라는 용어는 이러한 종류의 매크로가 프로그램의 구문 트리에서 작동한다는 의미입니다. 이를 통해 텍스트 기반 치환 매크로에서 발생할 수 있는 오역 가능성이 줄어들며, 위생적 매크로를 구현할 수 있습니다.
구문 매크로를 사용하면 라이브러리가 컴파일 중에 추상 구문 트리를 수정할 수 있으므로, 언어 전체의 복잡성을 높이지 않고 특정 도메인을 위해 언어를 확장할 수 있습니다.
동기
새로운 언어 기능은 논란을 일으키거나, 파괴적이거나, 때로는 분열을 초래할 수 있습니다. Python은 이제 충분히 강력하고 복잡하므로, 제안되는 많은 추가 기능은 복잡성 증가로 인해 언어에 순손실을 초래합니다.
언어를 변경하면 특정 패턴을 쉽게 표현할 수 있게 되지만, 그에 따른 비용이 발생합니다. 새로운 기능이 추가될 때마다 언어는 더 커지고, 배우기 더 어려워지며, 이해하기 더 어려워집니다. Python은 한때 Python Fits Your Brain이라고 묘사되었지만, 기능이 점점 더 많이 추가되면서 이는 점점 사실이 아니게 됩니다.
새로운 기능을 추가하는 비용이 크기 때문에, 사용자 수가 얼마나 많든 또는 해당 기능이 사용자에게 얼마나 유익하든 관계없이 일부 사용자에게만 도움이 되는 기능을 추가하는 것은 매우 어렵거나 불가능합니다.
지난 몇 년 동안 데이터 과학과 머신 러닝에서 Python의 사용은 매우 빠르게 증가했습니다. 그러나 Python의 핵심 개발자 대부분은 데이터 과학이나 머신 러닝을 배경으로 하지 않습니다. 이 때문에 핵심 개발자가 머신 러닝을 위한 언어 확장이 가치가 있는지 판단하기는 매우 어렵습니다.
언어 확장을 라이브러리처럼 모듈화하고 배포할 수 있게 하면 해당 도메인 밖의 사용자에게 부정적인 영향을 주지 않고 도메인별 확장을 구현할 수 있습니다. 웹 개발자는 데이터 과학자와는 매우 다른 확장 기능 집합을 원할 가능성이 높습니다. 커뮤니티가 자체 확장 기능을 개발할 수 있도록 해야 합니다.
사용자 정의 언어 확장의 형태가 없다면, 언어를 작고 자신의 사고방식에 잘 맞게 유지하려는 사람들과 자신의 도메인이나 프로그래밍 방식에 맞는 새로운 기능을 원하는 사람들 사이에 끊임없는 갈등이 발생할 것입니다.
특정 도메인을 위한 라이브러리의 표현력 향상
많은 도메인에는 라이브러리로 표현하기 어렵거나 불가능한 반복 패턴이 존재합니다. 매크로를 사용하면 이러한 패턴을 더 간결하고 오류 발생 가능성이 낮은 방식으로 표현할 수 있습니다.
새로운 언어 기능 시험
매크로를 사용하여 잠재적인 언어 확장을 시연할 수 있습니다. 예를 들어 매크로를 사용했다면 with 문과 yield from 표현식을 시험해 볼 수 있었을 것입니다. 이를 통해 해당 기능을 언어에 포함하기 전에 더 많은 테스트를 수행할 수 있었으므로, 최초 릴리스에서 더 높은 품질의 구현이 제공되었을 가능성이 높습니다.
새로운 기능이 출시되기 전에 완전히 안정적인지 확인하는 것은 거의 불가능합니다. 실제로 with 및 yield from 기능과 관련된 버그는 출시된 지 수년이 지난 후에도 계속 수정되었습니다.
바이트코드 인터프리터의 장기적 안정성
역사적으로 새로운 언어 기능은 AST를 새롭고 복잡한 바이트코드 명령으로 단순하게 컴파일하는 방식으로 구현되어 왔습니다. 이러한 바이트코드는 자체적인 내부 제어 흐름을 갖는 경우가 많았으며, 컴파일러에서 수행할 수 있고 수행했어야 하는 연산을 수행했습니다.
예를 들어 최근까지 try-finally 및 with 문 내부의 제어 흐름은 컨텍스트에 따라 의미가 달라지는 복잡한 바이트코드로 관리되었습니다. 이제 이러한 문 내부의 제어 흐름은 컴파일러에서 구현되므로, 인터프리터가 더 단순하고 빨라졌습니다.
새로운 기능을 AST 변환으로 구현하면 기존 컴파일러가 인터프리터를 수정하지 않고도 해당 기능의 바이트코드를 생성할 수 있습니다.
CPython VM의 성능과 이식성을 향상하려면 안정적인 인터프리터가 필요합니다.
근거
Python은 표현력이 풍부하고 배우기 쉽습니다. 또한 가장 배우기 쉽고 널리 사용되는 프로그래밍 언어로 널리 인정받고 있습니다. 그러나 가장 유연한 언어는 아닙니다. 그 지위는 lisp에 속합니다.
lisp는 호모아이코닉합니다. 즉, lisp 프로그램이 lisp 데이터 구조이므로 lisp 프로그램은 lisp 프로그램으로 조작할 수 있습니다. 따라서 언어의 상당 부분을 언어 자체로 정의할 수 있습니다.
lisp를 특징짓는 많은 괄호 없이 Python에서도 그러한 기능을 사용하고 싶습니다. 다행히 언어가 스스로를 조작할 수 있으려면 호모아이코니시티가 필요하지 않습니다. 필요한 것은 프로그램을 파싱한 후 실행 가능한 형식으로 변환하기 전에 조작할 수 있는 능력뿐입니다.
Python에는 이미 필요한 구성 요소가 있습니다. Python의 구문 트리는 ast 모듈을 통해 사용할 수 있습니다. 필요한 것은 매크로가 존재한다는 사실을 컴파일러에 알리는 표식과, 컴파일러가 AST를 조작하기 위해 사용자 코드를 콜백할 수 있는 능력뿐입니다.
명세
구문
어휘 분석
식별자 문자로 이루어진 임의의 시퀀스 뒤에 느낌표(영국 영어에서는 exclamation mark)가 오면 MACRO_NAME으로 토큰화됩니다.
문 형식
macro_stmt = MACRO_NAME testlist [ "import" NAME ] [ "as" NAME ] [ ":" NEWLINE suite ]
표현식 형식
macro_expr = MACRO_NAME "(" testlist ")"
모호성 해결
매크로의 문 형식이 우선하므로, macro_name!(x)는 매크로 표현식을 포함하는 표현식 문이 아니라 매크로 문으로 파싱됩니다.
의미론
컴파일
바이트코드로 번역하는 동안 macro를 만나면 코드 제너레이터는 해당 매크로에 등록된 매크로 프로세서를 조회하고, 매크로를 루트로 하는 AST를 프로세서 함수에 전달합니다. 그런 다음 반환된 AST가 원래 트리를 대신합니다.
이름이 여러 개인 매크로의 경우 여러 트리가 매크로 프로세서에 전달되지만, 하나만 반환되어 대체되며, 이를 통해 둘러싼 문 블록이 짧아집니다.
이 과정을 반복할 수 있으므로, 매크로가 다른 매크로를 포함하는 AST 노드를 반환할 수 있습니다.
컴파일러는 해당 매크로에 도달할 때까지 매크로 프로세서를 조회하지 않으므로, 내부 매크로에 대해서는 프로세서를 등록할 필요가 없습니다. 예를 들어, switch 매크로에서는 case 및 default 매크로가 switch 프로세서에 의해 제거되므로 프로세서를 등록할 필요가 없습니다.
가져올 매크로를 정의할 수 있도록 import! 및 from! 매크로가 미리 정의되어 있습니다. 다음 구문을 지원합니다:
"import!" dotted_name "as" name
"from!" dotted_name "import" name [ "as" name ]
import! 매크로는 매크로 프로세서를 찾기 위해 컴파일 시점에 dotted_name을 임포트한 다음, 현재 컴파일 중인 범위에서 이를 name으로 등록합니다.
from! 매크로는 매크로 프로세서를 찾기 위해 컴파일 시점에 dotted_name.name을 임포트한 다음, 현재 컴파일 중인 범위에서 이를 name으로 등록합니다(있는 경우 “as” 뒤에 오는 name을 사용합니다).
import! 및 from!은 임포트가 존재하는 범위에서만 매크로를 정의하므로, 명확성을 높이려면 매크로를 사용할 때마다 명시적인 import! 또는 from!이 앞에 있어야 합니다.
예를 들어, “my.compiler”에서 “compile” 매크로를 임포트하려면 다음과 같이 합니다:
from! my.compiler import compile
매크로 프로세서 정의
매크로 프로세서는 (func, kind, version, additional_names)로 구성된 네 튜플로 정의됩니다:
func는len(additional_names)+1개의 인수를 받는 호출 가능 객체여야 하며, 인수는 모두 추상 구문 트리이고 단일 추상 구문 트리를 반환해야 합니다.kind는 다음 중 하나여야 합니다:macros.STMT_MACRO: 매크로 본문이 들여쓰기된 문 매크로입니다. 추가 이름을 가질 수 있는 유일한 형식입니다.macros.SIBLING_MACRO: 매크로 본문이 동일한 블록의 다음 문장인 문 매크로입니다. 다음 문장이 본문으로 매크로 안으로 이동됩니다.macros.EXPR_MACRO: 식 매크로입니다.
version은 생성된 바이트코드를 올바르게 캐시할 수 있도록 매크로 버전을 추적하는 데 사용됩니다. 정수여야 합니다.additional_names은 매크로의 추가 부분에 대한 이름이며, 문자열로 이루어진 튜플이어야 합니다.
# (func, _ast.STMT_MACRO, VERSION, ())
stmt_macro!:
multi_statement_body
# (func, _ast.SIBLING_MACRO, VERSION, ())
sibling_macro!
single_statement_body
# (func, _ast.EXPR_MACRO, VERSION, ())
x = expr_macro!(...)
# (func, _ast.STMT_MACRO, VERSION, ("subsequent_macro_part",))
multi_part_macro!:
multi_statement_body
subsequent_macro_part!:
multi_statement_body
컴파일러는 사용된 구문이 선언된 종류와 일치하는지 확인합니다.
편의를 위해, macros 모듈에는 함수를 매크로 프로세서로 표시하는 macro_processor데코레이터가 제공됩니다:
def macro_processor(kind, version, *additional_names):
def deco(func):
return func, kind, version, additional_names
return deco
이는 예를 들어 매크로 프로세서를 선언하는 데 사용할 수 있습니다:
@macros.macro_processor(macros.STMT_MACRO, 1_08)
def switch(astnode):
...
AST 확장
매크로를 표현하려면 macro_stmt 및 macro_expr라는 두 개의 새로운 AST 노드가 필요합니다.
class macro_stmt(_ast.stmt):
_fields = "name", "args", "importname", "asname", "body"
class macro_expr(_ast.expr):
_fields = "name", "args"
또한 매크로 프로세서는 값을 생성하는 제어 흐름 또는 부작용을 일으키는 코드를 표현할 수단이 필요합니다. 문과 식을 결합하는 stmt_expr라는 새로운 AST 노드가 추가됩니다. 이 새 AST 노드는 expr의 서브타입이지만, 부작용을 허용하도록 문을 포함합니다. 문을 컴파일한 다음 값을 컴파일하는 방식으로 바이트코드로 컴파일됩니다.
class stmt_expr(_ast.expr):
_fields = "stmt", "value"
위생성 및 디버깅
매크로 처리기는 새 변수를 생성해야 하는 경우가 많습니다. 이러한 변수는 원본 코드와 다른 매크로를 오염시키지 않도록 이름을 지정해야 합니다. 이름 지정 규칙을 강제하지는 않지만, 위생성을 보장하고 디버깅을 돕기 위해 다음과 같은 이름 지정 방식이 권장됩니다.
- 생성되는 모든 변수 이름은
$로 시작해야 합니다. - 순수하게 인공적으로 생성된 변수 이름은
$$mname으로 시작해야 하며, 여기서mname은 매크로의 이름입니다. - 실제 변수에서 파생된 변수는
$vname으로 시작해야 하며, 여기서vname은 변수의 이름입니다. - 모든 변수 이름에는 밑줄로 구분된 줄 번호와 열 오프셋이 포함되어야 합니다.
예:
- 순수하게 생성된 이름:
$$macro_17_0 - 표현식 매크로의 변수에서 파생된 이름:
$var_12_5
예
컴파일 시점에 검사되는 데이터 구조
Python에서 대규모 딕셔너리로 데이터 테이블을 인코딩하는 방식은 일반적입니다. 그러나 이러한 방식은 유지 관리가 어렵고 오류가 발생하기 쉽습니다. 매크로를 사용하면 이러한 데이터를 더 읽기 쉬운 형식으로 작성할 수 있습니다. 그런 다음 컴파일 시점에 데이터를 검증하고 효율적인 형식으로 변환할 수 있습니다.
예를 들어, 코드에서 이름으로, 그리고 그 반대로 매핑하는 두 개의 딕셔너리 리터럴이 있다고 가정해 보겠습니다. 딕셔너리에 중복 키가 있을 수 있거나 한 테이블이 다른 테이블의 역매핑이 아닐 수 있으므로 이는 오류가 발생하기 쉽습니다. 매크로는 하나의 테이블에서 두 매핑을 생성하는 동시에 중복 항목이 없는지 검증할 수 있습니다.
color_to_code = {
"red": 1,
"blue": 2,
"green": 3,
}
code_to_color = {
1: "red",
2: "blue",
3: "yellow", # error
}
다음과 같이 됩니다:
bijection! color_to_code, code_to_color:
"red" = 1
"blue" = 2
"green" = 3
도메인별 확장
매크로가 실질적인 가치를 발휘한다고 생각하는 영역은 범용 언어 기능이 아니라 특정 도메인입니다.
예를 들어 파서가 있습니다. 다음은 매크로를 사용한 Python 파서 정의의 일부입니다.
choice! single_input:
NEWLINE
simple_stmt
sequence!:
compound_stmt
NEWLINE
컴파일러
numba와 같은 런타임 컴파일러는 Python 소스를 재구성하거나 바이트코드를 분석하려고 해야 합니다. 이들이 AST를 직접 가져오는 편이 더 간단하고 안정적일 것입니다.
from! my.jit.library import jit
jit!
def func():
...
기호 표현식 매칭
Python ast 노드나 sympy 표현식처럼 구문을 나타내는 대상을 매칭할 때는 해당 데이터 구조가 아니라 실제 구문과 매칭하는 편이 편리합니다. 예를 들어, 구문 매칭을 위한 도메인별 매크로를 사용하여 계산기를 구현할 수 있습니다.
from! ast_matcher import match
def calculate(node):
if isinstance(node, Num):
return node.n
match! node:
case! a + b:
return calculate(a) + calculate(b)
case! a - b:
return calculate(a) - calculate(b)
case! a * b:
return calculate(a) * calculate(b)
case! a / b:
return calculate(a) / calculate(b)
이는 다음과 같이 변환할 수 있습니다.
def calculate(node):
if isinstance(node, Num):
return node.n
$$match_4_0 = node
if isinstance($$match_4_0, _ast.Add):
a, b = $$match_4_0.left, $$match_4_0.right
return calculate(a) + calculate(b)
elif isinstance($$match_4_0, _ast.Sub):
a, b = $$match_4_0.left, $$match_4_0.right
return calculate(a) - calculate(b)
elif isinstance($$match_4_0, _ast.Mul):
a, b = $$match_4_0.left, $$match_4_0.right
return calculate(a) * calculate(b)
elif isinstance($$match_4_0, _ast.Div):
a, b = $$match_4_0.left, $$match_4_0.right
return calculate(a) / calculate(b)
비용이 없는 마커와 어노테이션
데코레이터든 PEP 3107 함수 어노테이션이든, 어노테이션은 검사기를 위한 표시나 문서화 역할만 하더라도 런타임 비용이 발생합니다.
@do_nothing_marker
def foo(...):
...
비용이 들지 않는(zero-cost) 매크로로 대체할 수 있습니다:
do_nothing_marker!:
def foo(...):
...
언어 확장 프로토타이핑
매크로는 도메인 특화 확장에 가장 유용하겠지만, 매크로를 사용하여 가능한 언어 확장을 보여줄 수도 있습니다.
f-문자열:
f-문자열 f"..."은 f!("...")와 같은 매크로로 구현할 수 있습니다. 읽기에 그다지 좋지는 않지만, 실험해보기에는 여전히 유용할 것입니다.
try finally 문:
try_!:
body
finally!:
closing
대략 다음과 같이 변환될 것입니다:
try:
body
except:
closing
else:
closing
- 참고:
- return, break, continue를 올바르게 처리하도록 주의해야 합니다. 위 코드는 단지 예시일 뿐입니다.
with 문:
with! open(filename) as fd:
return fd.read()
위 코드는 open을 특별히 처리해야 할 것입니다. 더 명시적인 대안은 다음과 같을 것입니다:
with! open!(filename) as fd:
return fd.read()
매크로 정의 매크로
구문적 매크로를 가진 언어는 대개 매크로를 정의하기 위한 매크로를 제공합니다. 이 PEP는 의도적으로 그렇게 하지 않는데, 어떤 설계가 좋을지 아직 명확하지 않으며, 커뮤니티가 자신만의 매크로를 정의할 수 있도록 하고자 하기 때문입니다.
가능한 형태 중 하나는 다음과 같을 수 있습니다:
macro_def! name:
input:
... # input pattern, defining meta-variables
output:
... # output pattern, using meta-variables
하위 호환성
이 PEP는 완전히 하위 호환됩니다.
성능에 미치는 영향
매크로를 사용하지 않는 코드의 경우, 성능에 아무런 영향이 없습니다.
매크로를 사용하고 이미 바이트코드로 컴파일된 코드의 경우, 코드를 컴파일하는 데 사용된 매크로 버전이 임포트된 매크로 프로세서와 일치하는지 확인하는 약간의 오버헤드가 발생합니다.
아직 컴파일되지 않았거나 다른 버전의 매크로 프로세서로 컴파일된 코드의 경우, 일반적인 바이트코드 컴파일 오버헤드에 매크로 처리에 따른 추가 오버헤드가 더해집니다.
소스에서 바이트코드로의 컴파일 속도는 파이썬 성능과 대체로 무관하다는 점에 주목할 만합니다.
구현
컴파일 시점에 파이썬 코드로 AST를 변환할 수 있도록 하려면, 컴파일러의 모든 AST 노드가 파이썬 객체여야 합니다.
이를 효율적으로 수행하려면, 성능을 크게 저하시키지 않도록 _ast 모듈의 모든 노드를 불변으로 만들어야 함을 의미합니다. 순환 GC를 지원할 필요가 없도록 AST가 트리 형태를 유지함을 보장하려면 노드가 불변이어야 합니다. 노드를 불변으로 만든다는 것은 __dict__ 속성을 갖지 않게 되어 더 간결해짐을 의미합니다.
ast 모듈의 AST 노드는 계속 가변으로 유지됩니다.
현재 모든 AST 노드는 아레나 할당자를 사용하여 할당됩니다. 표준 할당자를 사용하도록 변경하면 컴파일 속도가 다소 느려질 수 있지만, 많은 코드를 삭제할 수 있으므로 유지 보수 측면에서 이점이 있습니다.
참조 구현
아직 없습니다.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.