주의: 이 글은 Python 3 입장에서 작성되었습니다. 2에서의 동작을 보장하지 않습니다.
예전에 IRC Bot을 개발하던 시절의 이야기 입니다.
당시 저는 간편하게 사용 가능한 계산 기능이 필요했는데, 마땅한 방법을 알지 못해서 exec
를 할 때 local 영역에 들어가는 __builtins__
를 뜯어고쳐서 썼었습니다.
하지만 그건 너무나도 비정상적인 방법이란 생각이 들어서 방법을 바꾸기로 했습니다.
처음 썼던 방식은 Google 검색에 수식을 넣으면 결과로 계산된 값이 나오는 것을 이용했었습니다.
당연히 구글이 제공하는거니 매우 잘 동작했지만 구글의 마크업이 변할때마다 대응해줘야해서 귀찮았습니다.
Python sandbox를 만들라는 제안을 받았었지만 당시로썬 그런건 할 줄 몰랐습니다.
그래서 취한 방법은 직접 계산하는 것이었습니다.
한번 읽어봤던 자료구조 책에서 Stack으로 수식 계산을 하는 방법이 있었어서1 그 방법을 차용해서 제가 원하는 수준의 계산기를 구현해버렸습니다.2
문제는 1.사용자 입력이 깔끔하다는 보장이 없다는 것(띄어쓰기 유무, 올바르지 않은 수식) 2.제가 만든 구린 처리기가 인식할 수 없는 경우들이었습니다.
저는 그 문제를 무식하게 re.sub
을 이용해서 적당히 고쳐서 동작하게 만들었는데, 지금 생각해보면 __builtins__
를 고치는것 만큼이나 이상한 방법인 것 같습니다.
그러던 와중에 다른 분이 만든 봇에도 계산 기능이 있어서 보니까 작동 중 실패하면 ASTError라는 것이 나왔습니다. 당시에 나는 Python에 ast라는 모듈이 있다는 건 docs를 읽다가 우연히 보긴 했었지만 정작 AST가 뭔지 몰랐었습니다. 나는 AST라는걸 쓰면 되나보다 하고 넘어갔었습니다. 그리고 시간이 흘러 PHP 7에 AST가 생긴다는 소식을 듣는다던가 하는 식으로 AST라는 단어를 몇번 더 마주하였지만 “문법을 저장하는 트리” 정도로만 이해하고 넘어갔습니다.
그리고 지금 보고 있는 책, 실전 파이썬 프로그래밍3에서 AST를 또 마주하게 되었습니다. 저는 AST에 대해서 관심이 많은 상태에서 읽게 됬는데4 불행히도 이 책은 AST라는것이 있다는 정도만 소개해주고 있었습니다. 결국 목 마른 자가 우물을 판다고, 직접 몸통박치기를 시작했습니다.
일단 내가 목표로 잡은 것은 이것입니다.
0.1
을 10번 더했을 때 1.0
이 나오게 해보자.
당연히 1.0
이 나오는 것 아니냐고 생각하시는 분들은 지금 빨리 가서 직접 더해보고 오시길 권해드립니다.
실험해보면 알 수 있지만 1.0
이 나오지 않습니다.
왜냐하면 Python도 부동소숫점을 쓰기 때문입니다.
이 문제를 해결하는 Pythonic한 방법은 decimal
모듈을 사용하는 것입니다.
>>> 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1
0.9999999999999999
>>> import decimal
>>> decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1')
Decimal('1.0')
당연히 계산기를 쓰는 사람들에게 *“숫자를 쓸때 decimal.Decimal이라고 쓰세요”*라고 할 순 없으므로 숫자를 자동으로 치환해주는 작업이 필요합니다.
문제를 좀 더 구체화해보기로 했습니다. 다음과 같은 변수를 가지고 있다고 가정합니다.
expr = 'print(0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1)'
그러면 이제 실행을 시키면 내부적으론 이렇게 실행되어야 합니다.
print(decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1'))
그러려면 ast모듈을 활용해야 합니다.
AST(Abstract Syntax Tree)는 이름 그대로 Tree형의 자료 구조입니다.
각각의 노드를 갖게 되는데 실제로 1.2 + 3.4
의 AST를 뜯어보면 다음과 같습니다.
>>> import ast
>>> ast.dump(ast.parse('1.2 + 3.4'))
'Module(body=[Expr(value=BinOp(left=Num(n=1.2), op=Add(), right=Num(n=3.4)))])'
ast.parse
로 AST 구조를 뽑아낼 수 있고, ast.dump
로 결과물을 볼 수 있습니다.
결과물에 보이는 Module
이라던가 Expr
등등은 모두 ast 모듈에서 제공하는 클래스들입니다.
우리가 주목할 것은 Num
입니다.
이것은 숫자 표현을 나타내는데, 내 목표는 이걸 (어떻게 하는지는 몰라도) decimal.Decimal
로 바꿔버리는 것입니다.
ast 모듈은 이러한 작업을 간편하게 할 수 있도록 ast.NodeTransformer
라는 클래스를 제공합니다.
이것을 상속받아서 원하는 node 종류를 고칠 수 있습니다.
내가 바꿀 종류는 Num
이므로 visit_Num(self, node)
와 같은 형태의 메소드를 만들어두고, Num이 아닌 다른 값을 return해주면 변경이 될 것입니다.
그러면 decimal.Decimal
은 어떻게 사용할까요?
역시 아까와 같은 방법으로 decimal.Decimal('12.34') + decimal.Decimal('56.78')
을 대상으로 ast.dump
를 사용해보기로 했습니다.
>>> ast.dump(ast.parse("decimal.Decimal('12.34') + decimal.Decimal('56.78')"))
"Module(body=[Expr(value=BinOp(left=Call(func=Attribute(value=Name(id='decimal', ctx=Load()), attr='Decimal', ctx=Load()), args=[Str(s='12.34')], keywords=[], starargs=None, kwargs=None), op=Add(), right=Call(func=Attribute(value=Name(id='decimal', ctx=Load()), attr='Decimal', ctx=Load()), args=[Str(s='56.78')], keywords=[], starargs=None, kwargs=None)))])"
아까랑 다른 점은 Call
, Attribute
, Str
같은 새로운 것들이 출현한 점 입니다.
추측해보건데 Call
은 __call__
을, Attribute
는 __getattr__
을 의미할 것입니다.
Str
은 str
형이라고 생각했습니다.
일단 아주 단순무식하게 바꿔치기해보기로 했습니다.
>>> import ast
>>> expr = '1.2 + 3.4'
>>> node = ast.parse(expr)
>>> class NumTransformer(ast.NodeTransformer): ... def visit_Num(self, node): ... return ast.Call(func=ast.Attribute(value=ast.Name(id='decimal', ctx=ast.Load()), attr='Decimal', ctx=ast.Load()), args=[ast.Str(s=str(node.n))], keywords=[])
...
>>> NumTransformer().visit(node)
<_ast.Module object at 0x10077b898>
>>> ast.dump(node)
"Module(body=[Expr(value=BinOp(left=Call(func=Attribute(value=Name(id='decimal', ctx=Load()), attr='Decimal', ctx=Load()), args=[Str(s='1.2')], keywords=[]), op=Add(), right=Call(func=Attribute(value=Name(id='decimal', ctx=Load()), attr='Decimal', ctx=Load()), args=[Str(s='3.4')], keywords=[])))])"
일단 AST 자체는 의도한대로는 나온 것 같습니다. 하지만 이 소스는 동작하지 않습니다.
>>> compile(node, '', 'exec')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: required field "lineno" missing from expr
왜냐하면 ast.AST
는 모두 lineno
라는 속성과 col_offset
이란 속성을 필요로 하기 때문입니다.
이 속성은 강제로 지정해줘도 되지만, 혹시라도 만져야할 AST의 원본 소스 코드가 여러 줄일 경우에는 곤란할 수 있습니다.
이럴 경우 ast.copy_location
을 사용하면 됩니다.
아래는 실행이 가능하도록 고친 소스입니다.
import ast
class NumTransformer(ast.NodeTransformer):
def visit_Num(self, node):
decimal = ast.copy_location(
ast.Name(id='decimal', ctx=ast.Load()), node
)
Decimal = ast.copy_location(
ast.Attribute(value=decimal, attr='Decimal', ctx=ast.Load()),
node
)
num = ast.copy_location(ast.Str(s=str(node.n)), node)
return ast.copy_location(
ast.Call(
func=Decimal,
args=[num],
keywords=[]
),
node
)
이제 이 클래스를 불러다가 쓰면 됩니다.
>>> from test import *
>>> expr = '1.2 + 3.4'
>>> node = ast.parse(expr)
>>> NumTransformer().visit(node)
<_ast.Module object at 0x10077b8d0>
>>> exec(compile(node, '', 'exec'), {})
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "", line 1, in <module>
NameError: name 'decimal' is not defined
>>> exec(compile(node, '', 'exec'), {'decimal': __import__('decimal')})
하지만 아무것도 나오지 않는데, 원인이 세가지 있습니다.
첫째로 decimal
이 내부적으로 정의가 안되있을 것입니다.
하지만 마지막 줄에서 정의해줘도 변화가 없습니다.
또 하나는 실행 모드가 exec
라는 점이고, 마지막은 node의 상태입니다.
다시 node를 열어보았습니다.
>>> ast.dump(node)
"Module(body=[Expr(value=BinOp(left=Call(func=Attribute(value=Name(id='decimal', ctx=Load()), attr='Decimal', ctx=Load()), args=[Str(s='1.2')], keywords=[]), op=Add(), right=Call(func=Attribute(value=Name(id='decimal', ctx=Load()), attr='Decimal', ctx=Load()), args=[Str(s='3.4')], keywords=[])))])"
이 상태는 말 그대로 node가 품고 있는 것이 Module로 처리되고 있다는 뜻인데, Module은 exec로밖에 감당이 안됩니다.
이것은 ast.parse
를 실행할때 인자를 달리 주면 해결됩니다.
>>> import ast
>>> import decimal
>>> from test import *
>>> expr = '1.2 + 3.4'
>>> node = ast.parse(expr, '', 'eval')
>>> NumTransformer().visit(node)
<_ast.Expression object at 0x10186ac88>
>>> eval((compile(node, '', 'eval')))
Decimal('4.6')
eval
을 사용하므로 decimal
을 import 해준 부분에 유의해주세요.
바꾸고 나니 잘 되는 것 같습니다.
근데 정말로 의도한 것 처럼 되는것인지 0.1
을 10번 더해보았습니다.
>>> expr = '0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1'
>>> node = ast.parse(expr, '', 'eval')
>>> NumTransformer().visit(node)
<_ast.Expression object at 0x10186acc0>
>>> eval((compile(node, '', 'eval')))
Decimal('1.0')
매우 잘 되고 있음이 확인 가능합니다.
계산기 만들기는 여기서 살짝만 응용하면 되는 영역이므로 어렵지 않을것으로 예상됩니다.
그보다, math.sin
같은 함수를 지원한다거나 할 것이 아니라면 ast.literal_eval
로도 이미 충분한걸지도 모르겠습니다.
물론 그 경우엔 ast.literal_eval('{1, 2, 3}')
같은 것도 유효한 식으로 처리되긴 합니다.
저는 그게 싫어서라도 따로 만들것입니다.
어떻게 할 것인지는 다음 기회에 글로 쓸 생각입니다.
각주.
-
정확히는 in-order 표기법으로 된 것을 post-order방식으로 바꾸는 것과, 별도로 있는 post-order방식으로 표기된 것을 해석하는 아주 간략한 예제였습니다. 원래 위치로
-
당시에 난 내가 뭘 만들었는지 몰랐었는데 깨닫고보니 아주 간단한 lexer였습니다. 지금 보면 소스도 무지 지저분하다고 생각하는데 당시로썬 아주 열심히 만든 것이었습니다. 리팩토링을 하게 된다면 더 깔끔하게 만들 수도 있을 것 같지만, 일단 저 DogBot이란 프로젝트 자체는 더 이상 개선할 생각이 없습니다. 당장에 저 소스는 PEP-8 준수만 시켜도 diff가 심각하게 나올 것인지라… 원래 위치로
-
책 베타 리더로 소마 멘토님이 참여하셨었는데, “집에 꼽아놓고 먼지만 쌓이게 할 것 아니면 읽어볼만 하다.”라고 하셔서 지른 책입니다. 절반쯤 읽었는데 정말 추천할만 합니다. 원래 위치로
-
GraphQL의 Python구현에 관심이 있는데, 저걸 알려면 Lexer를 다룰 줄 알아야하고, Lexer는 AST를 반환해주므로 관심이 가고 있었습니다. 원래 위치로