Python 3.10의 구조적 패턴 매칭
원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기
요약: 2021년 10월 초 출시 예정인 Python 3.10에는 구조적 패턴 매칭이라는 대규모 신규 언어 기능이 포함될 예정이다. 이 글은 실제 코드에 기반한 예시와 함께 이 기능을 비판적이지만 (부디) 유익한 시각에서 소개한다.
최근 열린 지역 파이썬 밋업에서 한 친구가 Python 3.8과 3.9의 새로운 기능들을 발표했는데, 발표가 끝난 뒤 Python 3.10에 들어올 패턴 매칭 기능에 대해 이야기를 나누게 됐다. 나는 파이썬이 노선을 이탈했다는 둥 가벼운 투덜거림을 늘어놓았다. 먼저 :=를 이용한 대입 표현식이더니, 이제는 이렇게 방대한 기능까지 나온다는 것이었다.
그 친구는 내 투덜거림을 꽤 너그럽게 받아들이더니 곧 이렇게 말했다. “다음 밋업에서 그 주제로 발표를 하면 좋겠네요.” 그래 … 뭐, 해보지 뭐!
그 사이 이 기능을 좀 더 잘 알아보고자 생각을 정리하고 코드 예제를 곁들인 글로 써보기로 했다. 짐작했겠지만 나는 다소 편향된 시각을 갖고 있지만, 비판뿐 아니라 장점도 함께 전하려고 노력하겠다.
패턴 매칭 기능은 무려 세 개의 PEP(Python Enhancement Proposal)로 설명되어 있다:
특히 튜토리얼은 기능에 대한 좋은 개요를 제공하므로, PEP을 하나만 읽고 싶다면 그 문서를 읽는 것이 좋다. 아래에서도 기능을 시연해 보겠다.
비꼬는 마음으로 보니 근거를 다룬 PEP이 압도적으로 가장 길다(8,500단어에 달한다). 변명이 너무 길지 않은가? 공정하게 말하자면, 원래 PEP의 ‘기각된 아이디어’ 섹션이 길어져 별도 PEP으로 분리된 것으로 보인다.
하지만 PEP들에서 빠진 것은 비용 대비 편익에 대한 평가라고 생각한다. 비용은 개발자가 새로 배워야 할 상당한 규모의 새로운 언어 의미론과, 구현 비용(CPython 및 다른 파이썬 구현체에서의)이다. 편익은 실제 코드, 즉 PEP의 ‘동기’ 섹션에 나오는 다소 작위적인 예시가 아니라 사람들이 일상적으로 파이썬을 사용하는 종류의 코드를 놓고 논의되어야 한다.
여기서 내가 하고 싶은 일 중 하나는 실제 코드를 평가해 패턴 매칭이 얼마나 (혹은 얼마나 별로) 개선하는지 살펴보는 것이다. 하지만 그 전에 파이썬의 구조적 패턴 매칭이 어떤 모습인지 간략히 살펴보자.
무엇인가
패턴 매칭을 스테로이드 맞은 switch 문 정도로 생각하기 쉽다. 하지만 근거 PEP에서 지적하듯, 이를 ‘이터러블 언패킹의 일반화된 개념’으로 생각하는 편이 낫다. 많은 사람들이 수년간 파이썬에 switch를 요청해 왔지만, 왜 추가되지 않았는지는 이해가 간다. if ... elif 나열에 비해 제공하는 가치가 그 비용을 감당할 만큼 크지 않기 때문이다. 새로운 match ... case 기능은 switch의 기본 기능에 ‘구조적’ 매칭 부분 등을 더해 제공한다.
기본 문법은 다음과 같은 switch 형태의 예시에서 볼 수 있다(우리만의 Git CLI를 만든다고 상상해 보자):
parser = argparse.ArgumentParser()
parser.add_argument('command', choices=['push', 'pull', 'commit'])
args = parser.parse_args()
match args.command:
case 'push':
print('pushing')
case 'pull':
print('pulling')
case _:
parser.error(f'{args.command!r} not yet implemented')파이썬은 match 식을 평가한 뒤 위에서부터 각 case를 시도해 가장 먼저 일치하는 것을 실행하고, 일치하는 것이 없으면 _ 기본 케이스를 실행한다.
하지만 구조적인 부분이 작용하는 지점은 바로 여기다. case 패턴은 단순히 리터럴일 필요만 있는 것이 아니다. 패턴은 다음과 같은 것도 할 수 있다:
- case가 일치하면 설정되는 변수 이름을 사용할 수 있다
- 리스트나 튜플 문법을 이용해 시퀀스를 매칭할 수 있다(파이썬 기존의 이터러블 언패킹 기능과 유사)
- dict 문법을 이용해 매핑을 매칭할 수 있다
*를 이용해 리스트의 나머지를 매칭할 수 있다**를 이용해 dict의 나머지 키를 매칭할 수 있다- 클래스 문법을 이용해 객체와 그 속성을 매칭할 수 있다
|로 “or” 패턴을 포함할 수 있다as로 하위 패턴을 캡처할 수 있다if“가드” 절을 포함할 수 있다
와! 기능이 정말 많다. 아주 작위적인 예시로 이 모든 기능을 한 번에 사용해 보며 어떤 모습인지 살펴보자(좀 더 점진적인 소개는 튜토리얼을 참고하라):
class Car:
__match_args__ = ('key', 'name')
def __init__(self, key, name):
self.key = key
self.name = name
expr = eval(input('Expr: '))
match expr:
case (0, x): # seq of 2 elems with first 0
print(f'(0, {x})') # (new variable x set to second elem)
case ['a', x, 'c']: # seq of 3 elems: 'a', anything, 'c'
print(f"'a', {x!r}, 'c'")
case {'foo': bar}: # dict with key 'foo' (may have others)
print(f"{{'foo': {bar}}}")
case [1, 2, *rest]: # seq of: 1, 2, ... other elements
print(f'[1, 2, *{rest}]')
case {'x': x, **kw}: # dict with key 'x' (others go to kw)
print(f"{{'x': {x}, **{kw}}}")
case Car(key=key, name='Tesla'): # Car with name 'Tesla' (any key)
print(f"Car({key!r}, 'TESLA!')")
case Car(key, name): # similar to above, but use __match_args__
print(f"Car({key!r}, {name!r})")
case 1 | 'one' | 'I': # int 1 or str 'one' or 'I'
print('one')
case ['a'|'b' as ab, c]: # seq of 2 elems with first 'a' or 'b'
print(f'{ab!r}, {c!r}')
case (x, y) if x == y: # seq of 2 elems with first equal to second
print(f'({x}, {y}) with x==y')
case _:
print('no match')보다시피 복잡하지만 강력하기도 하다. 매칭이 정확히 어떻게 수행되는지에 대한 세부 사항은 명세에 나와 있다. 다행히 위 내용 중 상당수는 꽤 자명하지만, __match_args__ 속성은 설명이 필요하다. 클래스 패턴에서 위치 인자를 사용하면 해당 클래스의 __match_args__ 튜플에 있는 항목들이 속성의 이름을 제공한다. 이는 클래스 패턴에서 속성 이름을 일일이 지정하지 않아도 되도록 하는 축약 표현이다.
한 가지 짚고 넘어갈 점은 match와 case가 진정한 키워드가 아니라 ‘소프트 키워드’라는 것이다. 즉, match ... case 블록 안에서만 키워드로 동작한다. 이는 의도된 설계인데, 사람들이 match를 변수 이름으로 정말 자주 사용하기 때문이다. 나만 해도 정규식 매칭 결과 변수로 거의 항상 match라는 이름을 쓴다.
빛을 발하는 곳
앞서 언급했듯, 단순히 화려한 switch로만 match를 쓴다면 그만한 가치가 없다고 생각한다. 그렇다면 어디서 빛을 발할까?
튜토리얼 PEP에는 이 기능을 빛나게 만드는 몇 가지 예시가 있다. 간단한 텍스트 기반 게임에서 명령어와 그 인자를 매칭하는 예시들이다. 그중 일부를 합쳐 아래에 옮겨 보았다:
command = input("What are you doing next? ")
match command.split():
case ["quit"]:
print("Goodbye!")
quit_game()
case ["look"]:
current_room.describe()
case ["get", obj]:
character.get(obj, current_room)
case ["drop", *objects]:
for obj in objects:
character.drop(obj, current_room)
case ["go", direction] if direction in current_room.exits:
current_room = current_room.neighbor(direction)
case ["go", _]:
print("Sorry, you can't go that way")
case _:
print(f"Sorry, I couldn't understand {command!r}")비교를 위해 패턴 매칭 없이 예전 방식으로 해당 조각을 빠르게 다시 작성해 보자. 거의 확실히 여러 개의 if ... elif 블록을 쓸 것이다. 조건을 더 단순하게 만들기 위해 미리 분리된 필드를 담은 새 변수 fields와 필드 개수를 담은 n을 설정하겠다:
command = input("What are you doing next? ")
fields = text.split()
n = len(fields)
if fields == ["quit"]:
print("Goodbye!")
quit_game()
elif fields == ["look"]:
current_room.describe()
elif n == 2 and fields[0] == "get":
obj = fields[1]
character.get(obj, current_room)
elif n >= 1 and fields[0] == "drop":
objects = fields[1:]
for obj in objects:
character.drop(obj, current_room)
elif n == 2 and fields[0] == "go":
direction = fields[1]
if direction in current_room.exits:
current_room = current_room.neighbor(direction)
else:
print("Sorry, you can't go that way")
else:
print(f"Sorry, I couldn't understand {command!r}")조금 더 짧다는 점을 제외하고도, 구조적 매칭 버전이 더 읽기 쉽고, fields[1] 같은 수동 인덱싱을 피하도록 변수 바인딩을 활용한다는 점에서 이 예시는 패턴 매칭의 명백한 승리라고 해도 무방하다.
튜토리얼은 아마도 게임의 이벤트 루프 일부로 보이는 클래스 기반 매칭 예시도 제공한다:
match event.get():
case Click((x, y), button=Button.LEFT): # This is a left click
handle_click_at(x, y)
case Click():
pass # ignore other clicks
case KeyPress(key_name="Q") | Quit():
game.quit()
case KeyPress(key_name="up arrow"):
game.go_north()
...
case KeyPress():
pass # Ignore other keystrokes
case other_event:
raise ValueError(f"Unrecognized event: {other_event}")이것도 평범한 if ... elif를 이용해 다시 써보자. 위에서 ‘go’ 명령어를 다시 작성할 때와 비슷하게, 말이 되는 경우에는 같은 클래스의 케이스들을 합치겠다:
e = event.get()
if isinstance(e, Click):
x, y = e.position
if e.button == Button.LEFT:
handle_click_at(x, y)
# ignore other clicks
elif isinstance(e, KeyPress):
key = e.key_name
if key == "Q":
game.quit()
elif key == "up arrow":
game.go_north()
# ignore other keystrokes
elif isinstance(e, Quit):
game.quit()
else:
raise ValueError(f"Unrecognized event: {e}")내게는 이 예시가 좀 더 미묘하게 느껴진다. 패턴 매칭 쪽이 확실히 조금 더 깔끔하긴 하지만, 그 차이가 크지는 않다. match는 모든 case가 일렬로 정렬된다는 장점이 있고, if ... elif는 이벤트 타입이 더 강하게 그룹화되고 타입을 반복하지 않아도 된다는 장점이 있다.
회의적이긴 하지만 공정하게 말하려 노력 중이다. 이 예시들은 보기에도 좋고, 패턴 매칭 명세를 읽지 않았더라도 무엇을 하는지 상당히 명확하다. __match_args__ 마법 정도가 예외라면 말이다.
귀도 반 로섬이 이 기능을 선보이기 위해 작성한 표현식 파서 및 평가기도 있다. 이 코드는 match ... case를 대대적으로 사용한다(비교적 작은 파일에서 11번이나). 그중 한 예시는 다음과 같다:
def eval_expr(expr):
"""Evaluate an expression and return the result."""
match expr:
case BinaryOp('+', left, right):
return eval_expr(left) + eval_expr(right)
case BinaryOp('-', left, right):
return eval_expr(left) - eval_expr(right)
case BinaryOp('*', left, right):
return eval_expr(left) * eval_expr(right)
case BinaryOp('/', left, right):
return eval_expr(left) / eval_expr(right)
case UnaryOp('+', arg):
return eval_expr(arg)
case UnaryOp('-', arg):
return -eval_expr(arg)
case VarExpr(name):
raise ValueError(f"Unknown value of: {name}")
case float() | int():
return expr
case _:
raise ValueError(f"Invalid expression value: {repr(expr)}")이걸 if ... elif로 하면 어떻게 보일까? 다시 말하지만 아마 구조를 조금 다르게 잡고, 모든 BinaryOp 케이스를 한데 모을 것이다. 중첩된 if 블록이 실제로 들여쓰기 수준을 높이지 않는다는 점에 유의하라. match에서는 case 절을 위해 이중 중첩이 필요하기 때문이다:
def eval_expr(expr):
"""Evaluate an expression and return the result."""
if isinstance(expr, BinaryOp):
op, left, right = expr.op, expr.left, expr.right
if op == '+':
return eval_expr(left) + eval_expr(right)
elif op == '-':
return eval_expr(left) - eval_expr(right)
elif op == '*':
return eval_expr(left) * eval_expr(right)
elif op == '/':
return eval_expr(left) / eval_expr(right)
elif isinstance(expr, UnaryOp):
op, arg = expr.op, expr.arg
if op == '+':
return eval_expr(arg)
elif op == '-':
return -eval_expr(arg)
elif isinstance(expr, VarExpr):
raise ValueError(f"Unknown value of: {name}")
elif isinstance(expr, (float, int)):
return expr
raise ValueError(f"Invalid expression value: {repr(expr)}")두 줄이 더 길어진 이유는 BinaryOp와 UnaryOp 필드에 대해 수동으로 속성을 언패킹해야 하기 때문이다. 나만 그런지 모르겠지만, 나는 이 버전이 match 버전만큼 읽기 쉽고 더 명시적이라고 생각한다.
또 다른 match가 유용할 만한 곳은 HTTP 요청에서 들어온 JSON의 구조를 검증할 때다(이건 내가 만든 가상의 예시다):
try:
obj = json.loads(request.body)
except ValueError:
raise HTTPBadRequest(f'invalid JSON: {request.body!r}')
match obj:
case {
'action': 'sign-in',
'username': str(username),
'password': str(password),
'details': {'email': email, **other_details},
} if username and password:
sign_in(username, password, email=email, **other_details)
case {'action': 'sign-out'}:
sign_out()
case _:
raise HTTPBadRequest(f'invalid JSON structure: {obj}')꽤 괜찮다. 다만 한 가지 단점은 제대로 된 검증 오류를 제공하지 못한다는 것이다. 이상적으로 API는 어떤 필드가 빠졌는지, 어떤 타입이 잘못됐는지 호출자에게 알려줘야 한다.
내 코드에 적용해 보기
기존 코드를 새 기능으로 변환하는 것을 살펴보자. 기본적으로 if ... elif 블록을 찾아 변환하는 것이 타당한지 살펴보고 있다. 내가 작성한 코드 몇 가지 예시부터 시작하겠다.
처음 두 예시는 pygit에서 가져온 것으로, 저장소를 만들고 커밋한 뒤 스스로를 GitHub에 푸시할 만큼의 Git 클라이언트 기능만 갖춘 장난감 수준의 git 부분 집합이다(전체 소스 코드).
아래 코드 블록은 기본적으로 접혀 있습니다. 화살표나 요약 문단을 클릭하면 펼칠 수 있습니다.
if answer() == 42:
print('The meaning of life, the universe and everything!')find_object()에서 가져온 예시. 일부 측면은 조금 더 깔끔하지만, 전반적으로 match를 쓰도록 다시 쓰는 것은 기능을 남용하는 것이라고 생각한다.
def find_object(sha1_prefix):
...
objects = [n for n in os.listdir(obj_dir) if n.startswith(rest)]
if not objects:
raise ValueError('object {!r} not found'.format(sha1_prefix))
if len(objects) >= 2:
raise ValueError('multiple objects ({}) with prefix {!r}'.format(
len(objects), sha1_prefix))
return os.path.join(obj_dir, objects[0])지금도 꽤 명확하지만, match를 쓰면 더 단순해지는지 살펴보자:
def find_object(sha1_prefix):
...
objects = [n for n in os.listdir(obj_dir) if n.startswith(rest)]
match objects:
case []:
raise ValueError('object {!r} not found'.format(sha1_prefix))
case [obj]:
return os.path.join(obj_dir, obj)
case _:
raise ValueError('multiple objects ({}) with prefix {!r}'
.format(len(objects), sha1_prefix))케이스 자체는 조금 더 깔끔한데, 특히 objects[0]를 쓰는 대신 obj가 자동으로 바인딩되는 점이 그렇다.
하지만 그다지 좋지 않은 점은 ‘성공 케이스’가 중간에 끼게 되어 정상적인 코드 경로가 다소 묻힌다는 것이다. 다음과 같이 꼼수로 끝에 배치할 수도 있지만(그러면 확실히 원래 코드만큼 명확하지 않다):
match objects:
case []:
raise ValueError('object {!r} not found'.format(sha1_prefix))
case [_, _, *_]:
raise ValueError('multiple objects ({}) with prefix {!r}'
.format(len(objects), sha1_prefix))
case [obj]:
return os.path.join(obj_dir, obj)또는 가장 구체적인 성공 케이스를 맨 앞에 두는 방법도 있는데, 이 편이 조금 더 낫다:
match objects:
case [obj]:
return os.path.join(obj_dir, obj)
case []:
raise ValueError('object {!r} not found'.format(sha1_prefix))
case _:
raise ValueError('multiple objects ({}) with prefix {!r}'
.format(len(objects), sha1_prefix))cat_file()에서 가져온 예시, 두 가지 다른 방식으로 보여준다. 약간의 이득은 있는 듯하다.
def cat_file(mode, sha1_prefix):
obj_type, data = read_object(sha1_prefix)
if mode in ['commit', 'tree', 'blob']:
if obj_type != mode:
raise ValueError('expected type {}, got {}'.format(
mode, obj_type))
sys.stdout.buffer.write(data)
elif mode == 'size':
print(len(data))
elif mode == 'type':
print(obj_type)
elif mode == 'pretty':
if obj_type in ['commit', 'blob']:
sys.stdout.buffer.write(data)
elif obj_type == 'tree':
... # pretty print tree
else:
assert False, 'unhandled type {!r}'.format(obj_type)
else:
raise ValueError('unexpected mode {!r}'.format(mode))직접 번역하면 다음과 같다(“pretty” 케이스에 중첩된 match에 주목하라):
def cat_file(mode, sha1_prefix):
obj_type, data = read_object(sha1_prefix)
match mode:
case 'commit' | 'tree' | 'blob':
if obj_type != mode:
raise ValueError('expected type {}, got {}'.format(
mode, obj_type))
sys.stdout.buffer.write(data)
case 'size':
print(len(data))
case 'type':
print(obj_type)
case 'pretty':
match obj_type:
case 'commit' | 'blob':
sys.stdout.buffer.write(data)
case 'tree':
... # pretty print tree
case _:
assert False, 'unhandled type {!r}'.format(obj_type)
case _:
raise ValueError('unexpected mode {!r}'.format(mode))우리는 match를 단순한 switch로 쓰고 있지만, 아주 약간의 이득은 있다. mode와 obj_type을 튜플로 동시에 매칭하도록 다시 구성하면 어떨까:
def cat_file(mode, sha1_prefix):
obj_type, data = read_object(sha1_prefix)
match (mode, obj_type):
case ('commit' | 'tree' | 'blob', _) if obj_type == mode:
sys.stdout.buffer.write(data)
case ('size', _):
print(len(data))
case ('type', _):
print(obj_type)
case ('pretty', 'commit' | 'blob'):
sys.stdout.buffer.write(data)
case ('pretty', 'tree'):
... # pretty print tree
case _:
raise ValueError('unexpected mode {!r} or type {!r}'.format(
mode, obj_type))이제 원래 코드보다 더 간결해졌지만, 딱히 더 명확하다고 하기는 어렵다!
인자 파싱에서 가져온 예시, CLI 하위 명령을 스위칭할 때. match를 쓰는 것이 말이 되긴 하지만 단순한 switch일 뿐, 구조적 기능을 활용한 것은 아니다.
args = parser.parse_args()
if args.command == 'add':
... # do add
elif args.command == 'cat-file':
... # do cat-file
elif args.command == 'commit':
... # do commit
...match를 사용하면 시각적 잡음을 줄일 수 있다:
args = parser.parse_args()
match args.command:
case 'add':
... # do add
case 'cat-file':
... # do cat-file
case 'commit':
... # do commit
...하지만 그걸 위해 완전히 새로운 기능이 필요한 것은 아니다. 짧은 변수 이름을 할당하는 것만으로도 시각적 잡음 대부분을 줄일 수 있다:
args = parser.parse_args()
cmd = args.command
if cmd == 'add':
... # do add
elif cmd == 'cat-file':
... # do cat-file
elif cmd == 'commit':
... # do commit
...Canonical의 ops 라이브러리 중 내가 업무로 작성한 pebble.py에서 가져온 예시 두 가지가 더 있다.
add_layer()에서 가져온 예시. layer 매개변수에 허용된 여러 타입을 처리한다. 시각적 잡음은 줄었지만 덜 명시적이기도 하다.
def add_layer(self, label, layer, *, combine=False):
...
if isinstance(layer, str):
layer_yaml = layer
elif isinstance(layer, dict):
layer_yaml = Layer(layer).to_yaml()
elif isinstance(layer, Layer):
layer_yaml = layer.to_yaml()
else:
raise TypeError('layer must be str, dict, or pebble.Layer')
# use layer_yamlmatch 버전은 클래스 매칭 문법을 사용한다:
def add_layer(self, label, layer, *, combine=False):
...
match layer:
case str():
layer_yaml = layer
case dict(): # could also be written "case {}:"
layer_yaml = Layer(layer).to_yaml()
case Layer():
layer_yaml = layer.to_yaml()
case _:
raise TypeError('layer must be str, dict, or pebble.Layer')
# use layer_yamlmatch 버전이 더 명확할까? 잡음은 덜하지만 나는 isinstance() 호출의 명시성이 좀 더 마음에 든다. 게다가 여러 케이스에 있는 빈 괄호도 좀 어색하다. 위치 인자나 속성이 없는데 굳이 필요해 보이지 않지만, 괄호가 없으면 match는 str이나 dict라는 이름의 새 변수를 바인딩해 버린다.
처음에는 case 블록 안에서 바인딩(그리고 할당)된 변수들이 전체 match 블록을 넘어서까지 살아남는 것이 이상하다고 생각했다. 하지만 위에서 보듯 이는 말이 된다. match 아래 코드에서 그 변수를 쓰고 싶을 때가 많기 때문이다.
exec()에서 가져온 예시, 지금 작업 중인 코드다. 이 경우에는 딱히 더 명확하지 않다.
def exec(command, stdin=None, encoding='utf-8', ...):
if isinstance(command, (bytes, str)):
raise TypeError('command must be a list of str, not {}'
.format(type(command).__name__))
if len(command) < 1:
raise ValueError('command must contain at least one item')
if stdin is not None:
if isinstance(stdin, str):
if encoding is None:
raise ValueError('encoding must be set if stdin is str')
stdin = io.BytesIO(stdin.encode(encoding))
elif isinstance(stdin, bytes):
if encoding is not None:
raise ValueError('encoding must be None if stdin is bytes')
stdin = io.BytesIO(stdin)
elif not hasattr(stdin, 'read'):
raise TypeError('stdin must be str, bytes, or a readable file-like object')
...match가 그런 검사를 단순화하는 데 도움이 될까? 한번 보자:
def exec(command, stdin=None, encoding='utf-8', ...):
match command:
case bytes() | str():
raise TypeError('command must be a list of str, not {}'
.format(type(command).__name__))
case []:
raise ValueError('command must contain at least one item')
match stdin:
case str():
if encoding is None:
raise ValueError('encoding must be set if stdin is str')
stdin = io.BytesIO(stdin.encode(encoding))
case bytes():
if encoding is not None:
raise ValueError('encoding must be None if stdin is bytes')
stdin = io.BytesIO(stdin)
case None:
pass
case _ if not hasattr(stdin, 'read'):
raise TypeError('stdin must be str, bytes, or a readable file-like object')
...이게 더 명확하다고 하기는 어렵다. case None이 어색하다. 원래 코드처럼 전체를 if stdin is not None:으로 감싸면 피할 수 있지만, 그러면 중첩이 세 단계가 되어 그다지 이상적이지 않다.
가드가 있는 기본 케이스 case _ if not hasattr(stdin, 'read')도 원래의 elif 버전보다 좀 더 난해하다. 물론 그냥 case _를 쓰고 그 안에 if not hasattr을 중첩시킬 수도 있다.
어쩌면 나는 이 기능의 혜택을 받을 만한 종류의 코드를 많이 작성하지 않는 것일지도 모른다. 하지만 이런 부류에 속하는 사람이 꽤 많을 것 같기도 하다. 그렇다면 몇몇 인기 파이썬 프로젝트의 코드를 살펴보며 무엇을 찾을 수 있는지 보자.
다른 프로젝트에 적용해 보기
라이브러리 코드(표준 라이브러리), 프레임워크 코드(Django 웹 프레임워크), 그리고 애플리케이션 코드(Warehouse — Python Package Index를 구동하는 서버 —, Mercurial, Ansible) 이렇게 세 가지 다른 유형의 코드에서 예시를 골라보겠다.
공정성을 기하기 위해 match의 진가를 발휘할 만한, 단순히 화려한 switch를 넘어서는 예시를 찾으려 노력했다(그런 예시는 많았지만, 구조적 패턴 매칭을 활용하지 않으므로 변환해도 큰 이득은 없다). 데이터의 구조를 테스트하는 것처럼 보이는 elif 블록을 찾아다녔다. elif 없이 if만 쓰는 코드에도 match를 잘 활용할 수 있는 경우가 있을 수 있지만, 그런 경우는 드물다고 생각한다.
표준 라이브러리
파이썬 표준 라이브러리는 테스트를 포함해 약 709,000줄의 코드로 이루어져 있다(scc를 이용해 측정). ripgrep 검색 도구(rg --type=py 'elif ' | wc)에 따르면 그중 2529줄이 elif 문이며, 이는 0.4%에 해당한다. 주석 안의 “elif ”까지 잡힐 수 있다는 점은 감안해야 하지만, 그런 경우는 드물 것이다.
ast.literal_eval()에서 가져온 예시, _convert() 헬퍼 함수. 놀랍지 않게도 내가 찾은 첫 번째 정말 좋은 사용 사례는 AST 처리였다. 확실히 이득이다.
def _convert(node):
if isinstance(node, Constant):
return node.value
elif isinstance(node, Tuple):
return tuple(map(_convert, node.elts))
elif isinstance(node, List):
return list(map(_convert, node.elts))
elif isinstance(node, Set):
return set(map(_convert, node.elts))
elif (isinstance(node, Call) and isinstance(node.func, Name) and
node.func.id == 'set' and node.args == node.keywords == []):
return set()
elif isinstance(node, Dict):
if len(node.keys) != len(node.values):
_raise_malformed_node(node)
return dict(zip(map(_convert, node.keys),
map(_convert, node.values)))
elif isinstance(node, BinOp) and isinstance(node.op, (Add, Sub)):
left = _convert_signed_num(node.left)
right = _convert_num(node.right)
if isinstance(left, (int, float)) and isinstance(right, complex):
if isinstance(node.op, Add):
return left + right
else:
return left - right
return _convert_signed_num(node)그걸 match를 쓰도록 변환하면:
def _convert(node):
match node:
case Constant(value):
return value
case Tuple(elts):
return tuple(map(_convert, elts))
case List(elts):
return list(map(_convert, elts))
case Set(elts):
return set(map(_convert, elts))
case Call(Name('set'), args=[], keywords=[]):
return set()
case Dict(keys, values):
if len(keys) != len(values):
_raise_malformed_node(node)
return dict(zip(map(_convert, keys),
map(_convert, values)))
case BinOp(left, (Add() | Sub()) as op, right):
left = _convert_signed_num(left)
right = _convert_num(right)
match (op, left, right):
case (Add(), int() | float(), complex()):
return left + right
case (Sub(), int() | float(), complex()):
return left - right
return _convert_signed_num(node)확실히 이득이다! 구문 트리 처리는 match의 이상적인 사용 사례로 보인다. Python 3.10에서는 ast 모듈의 노드 타입에 이미 __match_args__가 설정되어 있어, Constant(value=value) 같은 반복을 피할 수 있어 더욱 깔끔하다.
그래도 ast 모듈 외의 예시 하나를 더 찾아보자. 여기에는 포함하지 않겠지만, curses/textpad.py의 do_command()에는 꽤 긴 if ... elif 체인이 있다. 대부분 단순한 switch지만, 몇 가지 if 가드와 함께 match ... case의 혜택을 볼 수 있을 것이다.
dataclasses에서 가져온 예시, _asdict_inner() 함수. 시각적 잡음을 줄여 소소하지만 괜찮은 개선을 제공한다.
def _asdict_inner(obj, dict_factory):
if _is_dataclass_instance(obj):
result = []
for f in fields(obj):
value = _asdict_inner(getattr(obj, f.name), dict_factory)
result.append((f.name, value))
return dict_factory(result)
elif isinstance(obj, tuple) and hasattr(obj, '_fields'):
return type(obj)(*[_asdict_inner(v, dict_factory) for v in obj])
elif isinstance(obj, (list, tuple)):
return type(obj)(_asdict_inner(v, dict_factory) for v in obj)
elif isinstance(obj, dict):
return type(obj)((_asdict_inner(k, dict_factory),
_asdict_inner(v, dict_factory))
for k, v in obj.items())
else:
return copy.deepcopy(obj)그걸 match로 변환해 보자:
def _asdict_inner(obj, dict_factory):
match obj:
case _ if _is_dataclass_instance(obj):
result = []
for f in fields(obj):
value = _asdict_inner(getattr(obj, f.name), dict_factory)
result.append((f.name, value))
return dict_factory(result)
case tuple(_fields=_):
return type(obj)(*[_asdict_inner(v, dict_factory) for v in obj])
case list() | tuple():
return type(obj)(_asdict_inner(v, dict_factory) for v in obj)
case {}:
return type(obj)((_asdict_inner(k, dict_factory),
_asdict_inner(v, dict_factory))
for k, v in obj.items())
case _:
return copy.deepcopy(obj)꽤 괜찮은 개선이지만, if 가드가 있는 첫 번째 case _는 좀 어색하다. 최종 case _ 안의 일반 if 문으로 옮길 수도 있겠지만, 그 순서가 여전히 요구사항대로 동작할지는 코드를 잘 몰라서 확신할 수 없다.
email.utils에서 가져온 예시, parsedate_tz() 함수. 튜플 언패킹을 활용한 매칭이 훨씬 깔끔하게 만든다.
def _parsedate_tz(data):
...
tm = tm.split(':')
if len(tm) == 2:
[thh, tmm] = tm
tss = '0'
elif len(tm) == 3:
[thh, tmm, tss] = tm
elif len(tm) == 1 and '.' in tm[0]:
# Some non-compliant MUAs use '.' to separate time elements.
tm = tm[0].split('.')
if len(tm) == 2:
[thh, tmm] = tm
tss = 0
elif len(tm) == 3:
[thh, tmm, tss] = tm
else:
return None
# use thh, tmm, tss이걸 match를 쓰도록 변환해 보자:
def _parsedate_tz(tm):
...
match tm.split(':'):
case [thh, tmm]:
tss = '0'
case [thh, tmm, tss]:
pass
case [s] if '.' in s:
match s.split('.'):
case [thh, tmm]:
tss = 0
case [thh, tmm, tss]:
pass
case _:
return None
case _:
return None
# use thh, tmm, tss확실히 훨씬 깔끔하다. str.split()을 사용할 때 튜플을 언패킹하기 전에 길이를 테스트해야 하는 것은 항상 좀 번거롭다(ValueError 예외를 잡는 방법도 있지만 그다지 명확하지 않고, 중첩 수준도 꽤 깊어진다).
덧붙이자면, str.partition() 메서드는 이런 경우에 종종 유용하지만, 구분자를 사이에 두고 두 항목이 있을 때만 그렇다.
재미있게도 parsedate_tz()를 테스트하다 이 코드에 잘못된 사용자 입력에서 UnboundLocalError를 일으키는 버그가 있음을 발견했다. 12.34.56.78처럼 점으로 구분된 구간이 3개보다 많은 시간을 전달하면, 뒤따르는 코드에서 thh/tmm/tss 변수가 정의되지 않는다. 한번 보자:
$ python3.10 -c 'import email.utils; \
email.utils.parsedate_tz("Wed, 3 Apr 2002 12.34.56.78+0800")'
Traceback (most recent call last):
File "<string>", line 1, in <module>
File "/usr/local/lib/python3.10/email/_parseaddr.py", line 50, in parsedate_tz
res = _parsedate_tz(data)
File "/usr/local/lib/python3.10/email/_parseaddr.py", line 134, in _parsedate_tz
thh = int(thh)
UnboundLocalError: local variable 'thh' referenced before assignment점 케이스에 else: return None 하나만 추가하면 된다. 나는 이 버그에 대해 이슈와 이를 수정하고 테스트 케이스를 추가하는 풀 리퀘스트를 열었다.
Django
Django는 테스트를 포함해 327,000줄의 코드로 이루어져 있다. 그중 elif 사용은 905번으로, 0.3%에 해당한다.
Django admin 검사에서 가져온 예시, _check_fieldsets_item() 함수. 구조적 매칭이 여기서는 훌륭하지만 좋은 오류 메시지를 만드는 데는 도움이 되지 않는다.
def _check_fieldsets_item(self, obj, fieldset, label, seen_fields):
if not isinstance(fieldset, (list, tuple)):
return must_be('a list or tuple', option=label, obj=obj, id='admin.E008')
elif len(fieldset) != 2:
return must_be('of length 2', option=label, obj=obj, id='admin.E009')
elif not isinstance(fieldset[1], dict):
return must_be('a dictionary', option='%s[1]' % label, obj=obj, id='admin.E010')
elif 'fields' not in fieldset[1]:
return [
checks.Error(
"The value of '%s[1]' must contain the key 'fields'." % label,
obj=obj.__class__,
id='admin.E011',
)
]
elif not isinstance(fieldset[1]['fields'], (list, tuple)):
return must_be('a list or tuple', option="%s[1]['fields']" % label, obj=obj, id='admin.E008')
seen_fields.extend(flatten(fieldset[1]['fields']))
...흥미로운 예시다. 꽤 많은 중첩 구조적 매칭을 하고 있어 아주 잘 어울려 보인다. 변환을 한번 시도해 보자. 다음 코드는 어느 정도 역할을 한다:
def _check_fieldsets_item(self, obj, fieldset, label, seen_fields):
match fieldset:
case (_, {'fields': [*fields]}):
pass
case _:
return must_be('a list or tuple of length 2 with a fields dict')
seen_fields.extend(flatten(fields))
...구체적인 오류 메시지가 중요하지 않다면 정말 깔끔하다! 하지만 이 경우에는 아마 중요할 것이다. 그렇지 않다면 굳이 저렇게 세심하게 나눠 놓지 않았을 테니까. 그걸 고치려면 모든 케이스를 명시해야 하는데, 가장 구체적인 것이 먼저 매칭되도록 원래와 반대 순서로 해야 한다:
def _check_fieldsets_item(self, obj, fieldset, label, seen_fields):
match fieldset:
case [_, {'fields': [*fields]}]:
pass # valid, fall through
case [_, {'fields': _}]:
return must_be('a list or tuple', option="%s[1]['fields']" % label, obj=obj, id='admin.E008')
case [_, {}]:
return [
checks.Error(
"The value of '%s[1]' must contain the key 'fields'." % label,
obj=obj.__class__,
id='admin.E011',
)
]
case [_, _]:
return must_be('a dictionary', option='%s[1]' % label, obj=obj, id='admin.E010')
case [*_]:
return must_be('of length 2', option=label, obj=obj, id='admin.E009')
case _:
return must_be('a list or tuple', option=label, obj=obj, id='admin.E008')
seen_fields.extend(flatten(fields))
...더 명확할까? 그렇지는 않다. 점점 덜 구체적으로 반복하는 것이 좀 이상하다. 케이스가 ‘역순’으로 느슨한 매칭으로 떨어지는 것도 내게는 덜 직관적으로 보인다. 그리고 “길이가 2가 아님”을 의미하는 [_, _] 뒤의 [*_]도 정확히 명시적이라고 하기는 어렵다.
Warehouse
Warehouse, PyPI 서버 코드베이스는 테스트를 포함해 59,000줄의 파이썬 코드로 이루어져 있다. elif 사용은 35번으로, 0.06%에 해당한다. 흥미롭게도 이는 표준 라이브러리나 Django보다 한 자릿수 적으며, 이는 match가 ‘일반적인’ 코드에서는 그만큼 효용이 적을 것이라는 내 추측과 부합한다.
BigQuery 동기화에서 가져온 예시, sync_bigquery_release_files() 함수. Warehouse에서 (처음에는!) match의 혜택을 받을 것처럼 보였던 유일한 예시지만, 알고 보니 그렇지 않다.
for sch in table_schema:
if hasattr(file, sch.name):
field_data = getattr(file, sch.name)
elif hasattr(release, sch.name) and sch.name == "description":
field_data = getattr(release, sch.name).raw
elif sch.name == "description_content_type":
field_data = getattr(release, "description").content_type
elif hasattr(release, sch.name):
field_data = getattr(release, sch.name)
elif hasattr(project, sch.name):
field_data = getattr(project, sch.name)
else:
field_data = None하지만 자세히 살펴보면, 이 구조적 테스트들은 세 가지 다른 값(file, release, project)에 대해 수행되고 있고, 테스트하는 구조 자체가 동적이다. 처음에는 object(name=name)이 원하는 대로 동작할 거라 생각했지만, 실제로 이 코드는 sch.name 값 자체를 이름으로 하는 속성에 대해 매칭하고 있다. 까다롭다!
Warehouse는 딱히 match를 절실히 필요로 하는 것 같지 않았다. 그래도 좋은 반례가 된다고 생각해 여기 남겨 둔다. 두 개의 다른 대형 애플리케이션인 Mercurial과 Ansible을 훑어보며 예시를 몇 개 더 찾아보자.
Mercurial
Mercurial, 버전 관리 시스템은 테스트를 포함해 268,000줄의 파이썬 코드로 이루어져 있다. elif 사용은 1941번으로, 0.7%에 해당한다. 지금까지 중 가장 높은 비율이다.
context.py에서 가져온 예시, ancestor() 함수. 튜플 언패킹을 활용한 소소한 개선이다.
def ancestor(self, c2, warn=False):
n2 = c2._node
if n2 is None:
n2 = c2._parents[0]._node
cahs = self._repo.changelog.commonancestorsheads(self._node, n2)
if not cahs:
anc = self._repo.nodeconstants.nullid
elif len(cahs) == 1:
anc = cahs[0]
else:
anc = ...
return self._repo[anc]그걸 match를 쓰도록 바꾸면:
def ancestor(self, c2, warn=False):
n2 = c2._node
if n2 is None:
n2 = c2._parents[0]._node
cahs = self._repo.changelog.commonancestorsheads(self._node, n2)
match cahs:
case []:
anc = self._repo.nodeconstants.nullid
case [anc]:
pass
case _:
anc = ...
return self._repo[anc]이런 식으로 큰 이득은 아니더라도 개발자에게 소소한 ‘삶의 질’ 개선이 되는 경우가 꽤 많다.
Ansible
Ansible은 파이썬으로 작성된 널리 쓰이는 구성 관리 시스템이다. 테스트를 포함해 217,000줄의 파이썬 코드로 이루어져 있다. elif 사용은 1594번으로, 역시 0.7%에 해당한다. 아래는 패턴 매칭의 혜택을 받을 수 있을 것 같아 보였던 몇 가지 사례다.
module_utils/basic.py에서 가져온 예시, _return_formatted() 함수. 가독성이 소소하게 개선된다.
def _return_formatted(self, kwargs):
...
for d in kwargs['deprecations']:
if isinstance(d, SEQUENCETYPE) and len(d) == 2:
self.deprecate(d[0], version=d[1])
elif isinstance(d, Mapping):
self.deprecate(d['msg'], version=d.get('version'), date=d.get('date'),
collection_name=d.get('collection_name'))
else:
self.deprecate(d)
...가벼운 구조적 패턴과 함께 match를 사용하면 가독성이 소소하게 개선된다. 다만 SEQUENCETYPE에 포함된 다른 타입들을 처리하는 가장 좋은 방법은 잘 모르겠다:
def _return_formatted(self, kwargs):
...
for d in kwargs['deprecations']:
match d:
case (msg, version):
self.deprecate(msg, version=version)
case {'msg': msg}:
self.deprecate(msg, version=d.get('version'), date=d.get('date'),
collection_name=d.get('collection_name'))
case _:
self.deprecate(d)
...utils/version.py에서 가져온 예시, _Alpha.__lt__() 함수, 버전 비교 코드다. 타입 검사가 match로 조금 더 깔끔해진다.
class _Alpha:
...
def __lt__(self, other):
if isinstance(other, _Alpha):
return self.specifier < other.specifier
elif isinstance(other, str):
return self.specifier < other
elif isinstance(other, _Numeric):
return False
raise ValueError다시 한번, match를 쓰면 조금 더 깔끔하다:
class _Alpha:
__match_args__ = ('specifier',)
...
def __lt__(self, other):
match other:
case _Alpha(specifier):
return self.specifier < specifier
case str():
return self.specifier < other
case _Numeric():
return False
case _:
raise ValueError이 모든 프로젝트에서 match를 쓰도록 변환할 수 있는 사례는 훨씬 더 많지만, 적어도 시도해 볼 만한 여러 다른 종류의 코드 중 몇 가지를 골라보려 했다.
이 기능의 몇 가지 문제점
보여줬듯 패턴 매칭은 몇몇 경우에 코드를 더 명확하게 만들긴 하지만, 이 기능에 대해 우려되는 점이 여러 가지 있다. 물론 이미 배는 떠났다. Python 3.10은 며칠 뒤면 출시된다! 하지만 향후 설계를 위해서는 문제점을 고려하는 것이 가치 있다고 생각한다. (파이썬이 사람들이 원하는 모든 기능을 출시하는 것은 확실히 아니다. 기각된 PEP들을 훑어보는 것도 흥미롭다.)
사소한 것 중 하나는 match ... case가 들여쓰기를 두 단계 요구한다는 점이다. PEP 저자들은 다양한 대안을 고려했고, 나는 그들이 올바른 방향을 선택했다고 생각한다. 그건 사소한 불편함에 불과하다. 하지만 더 큰 문제는 어떨까?
학습 곡선과 기능 범위. 명세 PEP의 분량에서 알 수 있듯, 이 기능에는 약 10개의 하위 기능이 하나로 묶여 있어 다룰 것이 많다. 파이썬은 항상 배우기 쉬운 언어였는데, 이 기능은 겉보기에는 좋아 보일 수 있지만 의미론적으로 복잡성이 크다.
또 다른 방법. 파이썬의 선(Zen of Python)에는 “한 가지 — 가급적이면 단 하나의 — 명백한 방법이 있어야 한다.”라는 구절이 있다. 현실에서 파이썬은 항상 여러 가지 방법으로 같은 일을 할 수 있었다. 하지만 이제 개발자에게 적지 않은 인지적 부담을 더하는 또 하나의 방법이 생겼다. 많은 예시에서 보듯, 개발자는 종종 match를 쓴 경우와 쓰지 않은 경우를 둘 다 시도해 봐야 하고, 그럼에도 어느 쪽이 더 ‘명백한’지 논쟁한 채 남게 될 수 있다.
드문 영역에서만 유용함. 위에서 보듯 match가 정말 빛나는 경우도 있다. 하지만 그런 경우는 드물고, 대부분 구문 트리를 다루거나 파서를 작성할 때다. 많은 코드에 if ... elif 체인이 있긴 하지만, 이들은 종종 단순히 값에 대한 switch이거나(elif가 거의 같은 역할을 한다), 테스트하는 조건이 case 패턴에 들어맞지 않는 더 복잡한 조합인 경우가 많다(어색한 case _ if cond 절을 쓰지 않는 한 그렇다. 하지만 그건 elif보다 확실히 더 나쁘다).
내 짐작으로는 PEP 저자들(둘 다 파이썬 코어 개발자인 브랜트 부처와 귀도 반 로섬)은 패턴 매칭의 혜택을 받는 종류의 코드를 정기적으로 작성하지만, 대부분의 애플리케이션 개발자나 스크립트 작성자는 match를 훨씬 덜 필요로 할 것이라는 점이다. 특히 귀도 반 로섬은 한동안 Mypy 타입 체커를 작업해 왔고, 지금은 CPython 속도 개선을 작업 중이다. 컴파일러 작업에는 틀림없이 AST가 관여할 것이다.
문법이 다르게 동작함. 이 기능에는 ‘일반 파이썬’에서는 한 가지처럼 보이는 문법이 패턴 안에서는 다르게 동작하는 부분이 적어도 두 가지 있다:
- 변수 이름:
case절의 변수는 일반 코드에서처럼 값을 반환하지 않고 이름으로 바인딩한다. 즉,case RED는 기대한 대로 동작하지 않는다. 색상 상수와 매칭되는 것이 아니라RED라는 새 변수를 설정하게 된다. 상수와 매칭하려면 점이 포함되어야 한다. 그래서case Colors.RED는 동작한다. 위 코드 중 일부를 작성하면서 실제로 이 실수를 저질렀다.case ('commit' | 'tree' | 'blob', mode)라고 썼는데, 튜플의 두 번째 항목이mode와 같으면 매칭되리라 기대했지만, 물론 두 번째 항목을mode에 설정하게 될 뿐이다. - 클래스 패턴: 함수 호출처럼 보이지만 실제로는
isinstance와hasattr테스트다. 보기에는 좋지만 때로는 혼란스럽다. 또한 실제 함수 호출 결과에 대해 매칭할 수 없다는 의미이기도 하다. 그런 경우는if가드에 넣어야 한다.
근거 PEP도 “Patterns” 섹션에서 이러한 문법적 차이를 인정하고 있다:
패턴이 겉보기에는 표현식처럼 보일 수 있지만, 분명한 구분이 있다는 점을 명심하는 것이 중요하다. 사실 어떤 패턴도 표현식이 아니며 표현식을 포함하지도 않는다. 패턴을 함수 정의에서 형식 매개변수와 유사한 선언적 요소로 생각하는 것이 더 생산적이다.
__match_args__ 마법. 내 생각에 __match_args__ 기능은 너무 마법 같고, 개발자가 클래스의 속성 중 어떤 것을 위치로 매칭 가능하게 할지 결정해야 한다. __match_args__ 순서가 해당 클래스의 __init__ 매개변수 순서와 다를 수 있다는 점도 이상하다(실제로는 그렇게 하지 않으려 하겠지만). AST 노드 매칭 같은 것을 정말 깔끔하게 만들기 때문에 이 기능을 포함시킨 이유는 이해하지만, 그다지 명시적이지는 않다.
다른 구현체에 대한 비용. CPython은 단연 가장 널리 쓰이는 파이썬 인터프리터이지만, PyPy나 MicroPython처럼 이 기능을 구현할지 말지 결정해야 하는 다른 구현체들도 있다. 다른 인터프리터들은 어차피 항상 따라잡기에 바쁘지만, 파이썬 역사상 이 시점에 이 정도 규모의 기능은 다른 구현체가 따라잡기를 더욱 어렵게 만들 것이다.
원래는 match의 클래스 패턴이 파이썬의 덕 타이핑과 잘 어울리지 않는다는 점도 우려했다. 덕 타이핑에서는 타입을 먼저 검사하지 않고 그냥 객체의 속성에 접근하고 메서드를 호출한다(예를 들어 파일 유사 객체를 사용할 때). 하지만 클래스 패턴에서는 타입을 명시하고 isinstance 검사를 수행한다. object()를 이용하면 덕 타이핑도 여전히 가능하지만 다소 어색할 것이다.
하지만 이제 이 기능을 직접 써 보니, 이는 대부분 이론적인 우려라고 생각한다. 클래스 패턴을 쓸 만한 곳과 덕 타이핑을 쓸 만한 곳은 실제로 겹치지 않는 경우가 많다.
이 덕 타이핑 우려는 근거 PEP에서 간략히 논의된다:
하지만 파이썬의 ‘덕 타이핑’이라는 동적 특성을 존중하여, 특정 속성의 존재나 제약 조건을 지정하는 더 직접적인 방법도 추가했다.
Node(x, y)대신object(left=x, right=y)라고 쓸 수도 있으며, 이는 사실상isinstance()검사를 없애left와right속성을 가진 모든 객체를 지원하게 된다.
마무리하며
나는 패턴 매칭의 일부 측면이 마음에 들고, 특정 코드는 확실히 if ... elif보다 match ... case로 더 깔끔해진다. 하지만 이 기능이 그 복잡성, 말할 것도 없이 파이썬을 배우거나 파이썬 코드를 읽는 사람들에게 주는 인지적 부담을 정당화할 만큼 충분한 가치를 제공할까?
그렇다고 해도 파이썬은 언제나 실용적인 프로그래밍 언어였지, 순수주의자의 꿈이 아니었다. C++의 창시자인 비야네 스트롭스트룹이 말했듯, “언어에는 두 종류뿐이다. 사람들이 불평하는 언어와 아무도 사용하지 않는 언어.” 나는 파이썬을 항상 좋아했고 수년간 성공적으로 사용해 왔다. 앞으로도 거의 확실히 많은 작업에 계속 사용할 것이다. 완벽하지는 않지만, 완벽했다면 아무도 사용하지 않았을 것이다.
최근에는 Go도 많이 쓰고 있는데, 언어가 (의도적으로) 천천히 변한다는 점에는 분명 좋은 점이 있다. 대부분의 릴리스 노트는 “언어에 대한 변경 사항은 없습니다”로 시작한다. 예를 들어 Go 1.16에서는 모든 변경 사항이 툴링과 표준 라이브러리에 있었다. 그렇다고 해도 Go 역시 몇 달 뒤 Go 1.18에서 제네릭이 도입되며 자체적인 대규모 신기능을 갖게 될 예정이다.
전반적으로 나는 파이썬의 구조적 패턴 매칭에 대해 다소 비관적이다. 게임이 한참 진행된 이 시점에 추가하기에는 그저 너무 큰 기능이다(파이썬은 올해로 30년이 됐다). 언어가 자체 무게에 짓눌려 붕괴하기 시작하는 것일까?
아니면 내 친구의 예측대로, 이 기능이 몇 년간 모든 것에 남용되다가 커뮤니티가 가라앉고 정말 코드를 개선하는 곳에만 사용하게 되는 그런 기능 중 하나일까? 두고 볼 일이다!
Lobsters나 Hacker News에서 댓글을 남겨 주세요.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기