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에 구문 매크로 지원을 추가합니다. 매크로는 프로그램의 일부를 변환하는 컴파일 타임 함수로, 일반적인 라이브러리 코드로는 깔끔하게 표현할 수 없는 기능을 가능하게 합니다.
“syntactic(문법적)”이라는 용어는 이러한 종류의 매크로가 프로그램의 문법 트리에서 동작한다는 뜻입니다. 이는 텍스트 기반 치환 매크로에서 일어날 수 있는 잘못된 변환의 가능성을 줄이고, 위생적 매크로(hygienic macros)를 구현할 수 있게 합니다.
문법적 매크로를 사용하면 라이브러리가 컴파일 중에 추상 문법 트리를 수정할 수 있습니다. 따라서 언어 전체의 복잡성을 늘리지 않고도 특정 도메인에 맞게 언어를 확장할 수 있습니다.
동기
새로운 언어 기능은 논란을 일으키고 혼란을 주며 때로는 분열을 초래할 수 있습니다. Python은 이제 충분히 강력하고 복잡해져서, 제안되는 많은 추가 기능이 늘어나는 복잡성 때문에 언어에 순손실이 됩니다.
언어 변경은 특정 패턴을 쉽게 표현하게 해 줄 수 있지만, 그에 따른 비용이 있습니다. 새로운 기능이 추가될 때마다 언어는 더 커지고, 배우기 더 어려워지며, 이해하기 더 어려워집니다. Python은 한때 Python Fits Your Brain이라고 묘사되었지만, 기능이 점점 더 많이 추가될수록 그 말은 점점 더 사실과 멀어집니다.
새로운 기능을 추가하는 비용이 높기 때문에, 일부 사용자에게만 도움이 되는 기능은 그 사용자가 얼마나 많든, 또 그 기능이 그들에게 얼마나 유익하든 추가하기가 매우 어렵거나 불가능합니다.
지난 몇 년 동안 데이터 과학과 머신러닝 분야에서 Python의 사용이 매우 빠르게 늘어났습니다. 그러나 Python 코어 개발자 대부분은 데이터 과학이나 머신 러닝 분야의 배경 지식이 없습니다. 그래서 코어 개발자가 머신 러닝을 위한 언어 확장이 가치 있는지 판단하기가 매우 어렵습니다.
언어 확장을 라이브러리처럼 모듈화하고 배포할 수 있게 하면, 해당 도메인 밖의 사용자에게 부정적인 영향을 주지 않고 도메인 특화 확장을 구현할 수 있습니다. 웹 개발자는 데이터 과학자와는 매우 다른 확장 기능들을 원할 가능성이 높습니다. 커뮤니티가 자신들의 확장을 직접 개발할 수 있도록 해야 합니다.
사용자 정의 언어 확장이 어떤 형태로든 없다면, 언어를 간결하게 유지하여 머릿속에 담기 쉽게 하려는 사람들과 자신의 도메인이나 프로그래밍 스타일에 맞는 새 기능을 원하는 사람들 사이의 싸움이 상수처럼 끊임없이 이어질 것입니다.
특정 도메인을 위한 라이브러리의 표현력 향상
많은 도메인에는 라이브러리로 표현하기 어렵거나 불가능한 반복 패턴이 있습니다. 매크로를 사용하면 이러한 패턴을 더 간결하고 오류가 덜 생기는 방식으로 표현할 수 있습니다.
새로운 언어 기능의 시험
매크로를 사용하여 잠재적인 언어 확장을 시연할 수 있습니다. 예를 들어, 매크로가 있었다면 with 문과 yield from 표현식을 미리 시험해 볼 수 있었을 것입니다. 그렇게 했다면 해당 기능들이 언어에 포함되기 전에 더 많은 테스트를 할 수 있었으므로, 첫 릴리스 때부터 더 높은 품질의 구현으로 이어졌을 수도 있습니다.
새로운 기능이 릴리스되기 전에 완전히 신뢰할 수 있는지 확인하는 것은 거의 불가능합니다. with와 yield from 기능과 관련된 버그는 릴리스되고 나서 여러 해가 지난 뒤에도 여전히 수정되고 있었습니다.
바이트코드 인터프리터의 장기적 안정성
역사적으로 새로운 언어 기능은 AST를 새롭고 복잡한 바이트코드 명령어로 단순하게 컴파일하는 방식으로 구현되어 왔습니다. 그러한 바이트코드는 흔히 자체적인 내부 흐름 제어를 가지고 있었으며, 컴파일러에서 수행할 수 있었고 또 수행했어야 하는 연산을 수행했습니다.
예를 들어, 최근까지 try-finally 문과 with 문 내부의 흐름 제어는 문맥에 따라 의미가 달라지는 복잡한 바이트코드로 관리되었습니다. 이제 그러한 문 내부의 제어 흐름은 컴파일러에서 구현되므로, 인터프리터가 더 단순하고 빨라졌습니다.
새로운 기능을 AST 변환으로 구현하면, 기존 컴파일러가 인터프리터를 수정하지 않고도 해당 기능의 바이트코드를 생성할 수 있습니다.
CPython VM의 성능과 이식성을 향상하려면 안정적인 인터프리터가 필요합니다.
근거
Python은 표현력이 풍부하면서도 배우기 쉽습니다. 널리 쓰이는 프로그래밍 언어 가운데 가장 배우기 쉬운 언어로 널리 인정받고 있습니다. 그러나 파이썬이 가장 유연한 언어는 아닙니다. 그 칭호는 lisp의 것입니다.
lisp는 동형성(homoiconic)을 지닌 언어입니다. 즉, 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)로 구성된 4-튜플로 정의됩니다:
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(...):
...
다음과 같이 비용이 전혀 들지 않는 매크로로 대체할 수 있습니다:
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는 하위 호환성을 완전히 유지합니다.
성능에 미치는 영향
매크로를 사용하지 않는 코드의 성능에는 아무런 영향이 없습니다.
매크로를 사용하고 이미 바이트코드로 컴파일된 코드의 경우, 코드를 컴파일하는 데 사용된 매크로의 버전이 임포트된 매크로 처리기와 일치하는지 확인하기 위한 약간의 오버헤드가 발생합니다.
컴파일되지 않았거나 다른 버전의 매크로 처리기로 컴파일된 코드의 경우, 일반적인 바이트코드 컴파일 오버헤드에 더해 매크로 처리로 인한 추가 오버헤드가 발생합니다.
소스에서 바이트코드로 컴파일하는 속도는 Python 성능과 거의 관련이 없다는 점에 주목할 필요가 있습니다.
구현
Python 코드가 컴파일 시점에 AST를 변환할 수 있도록 하려면, 컴파일러의 모든 AST 노드가 Python 객체여야 합니다.
이를 효율적으로 수행하려면, 성능이 크게 저하되지 않도록 _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.