개발일지

[번역] 너무 세세해서 설명하기 어려운 Python의 진짜 함정 10 선

• python

놓치기 쉬운 파이썬의 함정들

이 포스트는 kwatch님의 [python] 細かすぎて伝わりにくい、Pythonの本当の落とし穴10選를 번역한 것입니다. 기본적으로 Python으로 개발을 시작하기 전에 봐야 할 Tips - Qiita 의 반박문이나, 훌륭한 내용이 많다고 생각되어 번역해 보았습니다.


Python으로 개발을 시작하기 전에 봐야 할 Tips - Qiita

Python은 무언가 함정이 많은 언어인지라, 한번 걸리면 ‘이 무슨 쓰래기 같은 언어인가’ 라고 느낄 만한 것이 적지 않다.

과 같은 포스팅(*)이 나 돌았습니다만, 저 포스팅에서 거론하는 요소 중 Python의 ‘함정’이라고 말할 수 있는 것은 예전 스타일의 클래스와 새로운 클래스 정도이고, 다른 요소는 결코 함정도 쓰레기도 아니라고 생각합니다. 자신이 알고 있는 언어와 다르다는 이유 만으로 ‘함정’ 이라거나 ‘쓰레기 언어’ 라고 폄하한다는 것이 바람직한 자세로 보이지는 않는군요.

(*) 잘못된 점을 몰래 수정하고 모른 척하는 점에서 현대 일본 매스컴같은 은폐 체질을 느낌.

그렇다고 해서, Python에 함정이 없는 것은 아닙니다. 이 포스트에서는, 너무 세세해서 설명하기 어려운 Python의 진짜 함정 10개를 소개합니다.

자신이 만든 test.py를 import할 수 없다.

많은 초보자가 빠지는 함정입니다만, 자신이 ‘test.py’라는 파일을 만들어서 실행하려고 해도 잘 되지 않을 수 있습니다.

bash$ vi test.py      # test.py 란 파일을 만듬
bash$ cat test.py
def func(x, y):
    return x, y
bash$ python
>>> import test       # 이를 import하면 이유 없이 종료되어 버림

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK
bash$

이는 Python에 기본적으로 “test”라는 모듈이 포함되어 있으며 (!) 자작 스크립트보다 그 쪽이 우선 실행되어 버리기 때문입니다.

이 기본 모듈은 본인이 사용할 용도로 Python을 컴파일 & 설치하는 경우에는 필요하지만, 그 이외에서는 거의 필요하지 않습니다.그러나 초보자를 빠뜨리는 함정으로는 아주 잘 작동해 주는 겁니다!

이에 대한 대책은 ‘test.py’ 나 ‘test / __ init__.py ‘라는 파일을 만드는 대신 ‘tests.py’ 과 ‘tests / __ ini__.py’ 를 만드는 것입니다.

또한 스크립트를 만들 때, 비단 ‘test.py’뿐만 아니라 표준 모듈과 같은 이름으로 만들지 않는 편이 신상에 이롭습니다.

스크립트 파일을 고쳤는데 실행시켜도 동작이 변하지 않는다

Python에서는 소스 파일 (.py)를 자동으로 컴파일 하여 바이트 코드 파일(.pyc, Java에서 말하는 .class 파일)을 만들어 줍니다.이로 인해, 2번째 이후의 실행속도가 매우 빨라집니다.또한 원래 소스 파일 (.py)이 업데이트 되면 필요에 따라 바이트 코드 파일 (*.pyc)도 다시 컴파일 해 줍니다.매우 편리하지요.

Python의 자동 컴파일 기능은 .py 파일의 타임 스탬프가 업데이트 된 경우에만 동작해서 *.pyc을 다시 컴파일 합니다. 바꾸어 말하자면, * *.py를 업데이트 하더라도 타임 스탬프가 바뀌지 않으면 *.pyc가 다시 컴파일되지 않습니다.

예:

bash$ cat calc_add.py      # 덧셈을 수행하는 함수를 정의함
def fn(x, y):
  return x+y
bash$ cat calc_mul.py      # 곱셈을 수행하는 함수를 정의함
def fn(x, y):
  return x+y

bash$ touch calc_add.py calc_mul.py   # 두 파일의 타임 스탬프를 갱신함

bash$ cp -p calc_add.py calc.py   # 덧셈 버전 프로그램을 사용
bash$ python
>>> from ex1 import fn
>>> fn(3, 4)      # 덧셈이 이루어지는 것을 확인함
7
>>> ^D

bash$ cp -p calc_mul.py calc.py   # 곱셈 버전 프로그램으로 변경함
bash$ python
>>> from ex1 import fn
>>> fn(3, 4)      # 실행되는 것은 곱셈 프로그램이 아니라 덧셈 프로그램 그대로!
7
>>> ^D

이처럼 Python에서는 * .py 파일의 타임 스탬프가 갱신되지 않으면 * pyc에 컴파일이 되지 않습니다. 이 때문에, 예를 들어 백업 파일에서 * .py를 리턴했는데도 프로그램의 동작이 바뀌지 않는다는 함정에 빠지게 됩니다.

또한 Python 프로그램은 *.py 파일이 없더라도 *.pyc 만 있으면 문제없이 작동합니다. 이 때문에, 예를 들어 개발 환경에서는 *.py 파일이 사라 졌는지 눈치 채지 못하고 있다가 프로덕션 환경에 배포하고 나서야 알게 되는 상황에 맞닥뜨릴 수도 있습니다.

이러한 상황의 대책은 *.pyc 파일을 적절하게 지워 주는 것입니다. 보통은 필요 없습니다만, 어떻게 해도 변경사항이 반영되지 않는다고 생각했을 때에는 *.pyc를 일단 지워주는 것이 좋습니다.

$ find . -type '*.pyc' | xargs rm     # bash
$ rm **/*.pyc                         # zsh

개인적으로는 타임 스탬프의 문제는 어쩔 수 없다 하더라도 *.py없이 *.pyc만으로 움직여 버리는 (파이썬의)사양은 대부분의 경우에서 문제의 원인이 되므로 바꾸어 주었으면 하는 바람이 있습니다. 예를 들어, 기본적으로 *.py가 없으면 에러로 처리하고, 뭔가 옵션을 붙여서 작동시켰을 경우에만 *.py이 없더라도 에러로 처리하지 않는 사양이 좋지 않았나 생각합니다.

exception Err1, Err2: 라고 작성하면 Err2가 무시됨(Python2)

Python에서 튜플을 사용하면 하나의 except 절에 여러 개의 예외 클래스를 지정할 수 있습니다.

from datetime import date
try:
  date(2014, 2, 30)
except (TypeError, ValueError):  # 여러개의 예외 클래스를 한 번에 지정함
  print("error")

그러나 튜플을 사용하는 것을 잊어버렸을 경우, Python2에서는 다른 의미가 되어 버립니다.

from datetime import date
try:
  date(2014, 2, 30)
except TypeError, ValueError:
  # ↑이 코드는 except TypeError as ValueError: 와 같음
  print("error")

이렇게 코드를 작성했다면 TypeError는 잡을 수 있지만 ValueError는 던져져 버립니다. 이는 매우 실수하기 쉬우므로 조심하도록 합시다.

참고로 Python3에서는 이럴 경우 문법 오류가 발생하기 때문에 실수할 수가 없습니다.

’,’ 만 있어도 튜플이 되어 버린다

Python에서 튜플의 리터럴 ‘,’은 있으면 좋은 것이며 괄호는 반드시 필요한 것이 아닙니다.

## Python에서 튜플 작성법
t1 = (1, 2, 3)    # 일반적으로는 이렇게 작성하는 경우가 많지만,
t2 = 1, 2, 3      # 사실 이렇게 작성해도 상관없음

그리고 요소를 하나만 가진 튜플의 경우에도 사정은 마찬가지입니다.

## 요소가 1개뿐인 튜플
t3 = (100,)       # 일반적으로는 이렇게 작성하는 경우가 많지만,
t4 = 100,         # 사실 이렇게 작성해도 상관없음

이를 보면 알 수 있듯이, Python에서는 문장 끝부분에 ‘,’이 붙기만 해도 튜플이 되어버립니다. 이것이 초보자가 알아채기 어려운 함정이 될 수 있습니다.

예를 들어, 여러 개의 긴 문자열을 받는 함수를 호출한다고 했을 때.

func("The Python Tutorial",
     "This tutorial is part of Python's documentation set and ...",
     "https://docs.python.org/3.4/tutorial/")

이것을 일단 변수 desc에 할당하도록 리팩토링 했다고 해 봅시다.

title = "The Python Tutorial"
desc  = "This tutorial is part of Python's documentation set and ...",
url   = "https://docs.python.org/3.4/tutorial/"
func(title, desc, url)

이로써 버그가 만들어졌습니다. desc에 대입한 문자열 끝 부분에 ‘,’ 이 남아 있기 때문에 desc는 문자열이 아니라 튜플이 되어 버렸습니다!

이런 리팩토링은 자주 하는 일이지만, 그 때마다 이렇게 소소한 버그가 들어갈 수 있습니다. 알고 있는데도 빠져 버리는, 매우 짜증나는 함정입니다.

이 함정은 ‘튜플에는 괄호가 필수’ 라는 언어의 사양이 마련되어 있다면 막을 수 있습니다. 그래서 (이론은 있겠지만) 개인적으로 Python의 설계 미스라고 생각합니다.

연속된 문자열 리터럴이 자동적으로 하나가 합쳐진다

Python에서 연속된 문자열 리터럴은 자동으로 합쳐집니다.

## 3개의 문자열 리터럴이 있어도 하나로 합쳐짐
s = "AA" "BB" "CC"
print(repr(s))   #=> 'AABBCC'

이를 이용한 여러 줄의 문자열 쓰기도 잘 사용되는 방법입니다.

## 여러 줄의 문자열을 쓰는 예
if not valid_password(email, password):
    return (
        "사용자 이름 또는 비밀번호가 다릅니다.\n"
        "(CapsLock이 켜져 있지 않은지 확인하십시오.)\n"
        "패스워드를 잊었을 경우에는 지원 센터로 문의하십시오.\n"
    )

그러나 이것은 조금만 잘못 써도 예상치 못한 버그의 원인이 되곤 합니다. 예를 들어 다음 예에서는 ‘,’ 을 빼 버렸기 때문에 버그가 들어가 있습니다. 게다가 얼핏 보는 것만으로는 어디가 잘못 되었는지 알기 어렵습니다. 어디에 버그가 있는지 아시겠습니까?

## 버그가 있는 코드
month_names = [
    "January", "February", "March", "May", "June", "July"
    "August", "September", "October," "Novenber", "December"
]

Python 입문서에 따라서는 ‘연속된 문자열 리터럴은 하나로 합쳐진다’ 라는 사양을 설명하지 않은 것도 있을 것입니다. 그런 입문서를 통해 배운 초보자가 이 버그를 이해할 수 있냐면.. 조금 어려운 일이겠군요. 따라서 Python의 이 사양은 초보자에게는 충분히 함정이라고 할 수 있습니다.

(그런데 위의 코드에 포함된 버그는 2개 입니다. 하나만 찾고서 “뭐 대단한 거라고!” 라고 생각한 사람은 스쿼트 30회 하세요. )

원래 지금의 Python에 이 사양은 필요 없는 것입니다. 왜냐하면 문자열 리터럴을 ‘+’연산자로 연결하면 코드의 최적화를 통해 자동으로 연결되기 때문입니다. 이것은 다음과 같이 바이트 코드를 살펴보면 알 수 있습니다.

>>> def fn():
...   return "X" + "Y" + "Z"  # ← 문자열 리터럴을 "+"로 연결시키면…
...
>>> import dis
>>> dis.dis(fn)
  2      0 LOAD_CONST      5 ('XYZ')   # ← 바이트 코드에서는 이미 연결된 상태
         3 RETURN_VALUE

이와 같이 문자열 리터럴끼리의 연결은 “+”연산자 최적화에 맡기면 “연속적인 문자열 리터럴은 하나로 합쳐진다” 는 함정도 없앨 수 있으니 없어져야 할 것입니다. Python2는 어쩔 수 없다고 하더라도 Python3에도 이 사양이 남아있는 것은 문제가 아닐까요.

문자열 % 연산자의 평가가 튜플에 사용했을때와 아닐 때 각각 다르다

Python에서 str % arg 표현식이 있을 때, arg가 튜플일 경우와 아닐 경우의 평가가 다릅니다

arg가 튜플이 아닌 경우, % 연산자의 인수는 하나 뿐이라고 간주됩니다. arg가 튜플일 경우, % 연산자의 인수는 N개 (N> = 1)로 간주됩니다. 예:

"%r" % "a"          #=> 'a'
"%r" % [1, 2]       #=> '[1, 2]'
"%r" % (1, 2)       #=> TypeError: not all arguments converted during string formatting
"%r, %r" % (1, 2)   #=> '(1, 2)'

따라서, 예를 들어 다음과 같은 함수에 튜플을 넘기면 의도하지 않은 에러가 발생합니다.

def validate(arg):
  if not isinstance(arg, str):
    errmsg = "%r: integer expected" % arg   # 여기가 버그
    raise ValueError(errmsg)

validate(['3','4'])   # 이것은 의도한 에러가 발생
validate(('3','4'))   # 이것은 의도하지 않은 TypeError가 발생

이 문제에 대한 대책은 % 연산자 대신에 format() 메소드를 사용하거나, str % arg를 사용하는 대신에 모두 str % (arg,)로 만드는 것입니다.

def validate(arg):
  if not isinstance(arg, str):
    errmsg = "%r: integer expected" % (arg,)  # 이 편이 바람직함
    raise ValueError(errmsg)

bool 형이 사실은 int의 서브 타입이다

Python에서 “True == 1”또는 “False == 0”이 참이되는 것으로 알려져 있습니다. PHP의 ‘0’== 0 정도로 심하지는 않지만, 그다지 좋은 사양이라고 할 수는 없네요

>>> True==1
True
>>> False==0
True

이 뿐만 아니라 무려 bool형이 int의 서브 타입입니다.

## 왜 이렇게 만들었을까
>>> isinstance(True, int)
True
>>> isinstance(False, int)
True
>>> issubclass(bool, int)
True

이 사양 탓에, ‘int 형이라고 생각하십니까? 사실은 True와 False이었습니다! ‘ 라는 빌어 먹을 쓰레기 버그가 들어갑니다.

## 예1: 값이 정수인지 확인하고 나서 insert 문을 실행하고 있지만, 
## 값이 True 나 False이면 벨리데이션 체크를 통과해 버리므로, SQL 에러가 발생함

if not isinstance(value, int):
    raise TypeError("%r: integer expected." % (value,))
sql = "insert into tbl(intval) values (:intval)"
db.execute(sql, {'intval': value})
## 예2: JSON의 속성 값이 정수인지 확인하고 있지만, 
## 사실 True나 False가 들어 있더라도 테스트가 에러가 되지 않음

def test_expected_int_value(self):
    response = requests.get('http://....')
    jdata = response.json()
    assert isinstance(jdata['value'], int)

사실 예전의 Python은 True와 False가 없고, 그 대신 1과 0을 사용하고 있었다고 합니다. 그 때의 흔적이 남아 이런 사양이 된 것이겠지요. (실제로 Python2에서 True와 False는 예약어가 아닌 그냥 전역 변수입니다. 알고 계셨나요? )

그러나 Python2이라면 이해하겠지만, Python3에서도 이 사양을 유지하고 있는 이유는 무엇일까요? 언어 사양을 정돈하기 위해 Python3을 만든 것 아닌가요? 이런 부분이야말로 고쳐주길 바랬습니다.

int와 long에 공통된 부모 클래스가 없다 (Python2)

Python2에서는 정수를 int 형과 long 형으로 표현합니다. int 형으로 표현할 수 없을 정도의 큰 숫자에 도달했을 경우 자동으로 long 형이 사용됩니다.

## MacOSX의 경우
>>> 2**62
4611686018427387904       # ← 2의 62승은 int형
>>> 2**63
9223372036854775808L      # ← 2의 63승은 long형 (문장 마지막의 'L' 에 주목)

그렇다고 해서 ‘큰 숫자가 아니라면 long형이 되지 않는다’ 는 것은 아닙니다. 마지막에 ‘L’을 붙이면 어떤 정수든 long 형으로 만들 수 있습니다.

>>> type(1)
<type 'int'>
>>> type(1L)
<type 'long'>

이처럼 Python2에서는 정수를 int 형과 long 형으로 표현합니다. 그 탓에 예상치 못한 버그가 발생 할 수 있습니다.

예를 들어, int형 인수만 받는 함수에 long 형 인수를 넘기면 에러가 발생할 수 있습니다.

## int 형만 받는 함수가 있다고 가정했을 때,
def build_json(intval):
    if not isinstance(intval, int):
        raise TypeError("%r: integer expected" % (intval,))
    return {"status": "OK", "value": intval}

## 여기에 long 형을 넘기면 TypeError가 됨
jdict = build_json(123L)           #=> TypeError
response.write(json.dumps(jdict))

“이런 건 일부러 long 형을 사용하기 때문이잖아! 정상적으로 사용하면 안 나는 에러라고!” 하는 사람도 있겠지만, 그것은 Python을 그다지 사용하지 않기 때문입니다. 예를 들어 데이터베이스에서 가져온 값은, 작은 숫자라도 long형 일 수 있습니다.

count = db.execute("select count(*) from tbl").scalar()
print count         #=> 5
print type(count)   #=> <type 'long'>    # 정수값이 int 형이 아니라 모두 long 형으로 되어있음

이 코드에서 만약 long 형이 int 형의 서브 클래스였다면 위와 같은 문제는 발생하지 않습니다. 그러나 Python2에서 long 형은 int 형의 서브 클래스가 아니며, 두 클래스 공통의 부모 클래스 또한 존재하지 않습니다. str 형과 unicode 형식은 basestring 형이라는 공통의 부모 클래스가 있는데, 그런 배려가 int 형과 long 형에는 없습니다.

따라서 값이 정수 값인지의 여부를 확인하려면 Python2에서는 다음처럼 작성할 필요가 있습니다.

if isinstance(value, (int, long)):   # isinstance(value, int) 는 안됨
    print("OK")

bool형을 고려하면 이렇게 됩니다.

if isinstance(value, (int, long)) and not (isinstance(value, bool)):
    print("OK")

아무래도 이건 좀 심했다…

int 형과 long 형이 있는 것 자체는 그렇게 나쁘다고 생각하지 않습니다. 그러나 long 형과 int 형에 아무런 상속 관계도 없다는 것은 아무리 생각해도 사양에서 놓친 부분입니다. 이것에 대한 다른 의견은 인정하지 않습니다. 이것이 Python3에 들어와서는 long형이 없어지고 큰 숫자도 모두 int 형이 되었습니다. 멋지군요.

데코레이터에 복잡한 수식을 쓰면 문법 에러 발생

Python에는 “데코레이터에 복잡한 수식은 사용하지 않았으면 좋겠음’이라는 설계 의도가있는 것 같습니다. 따라서 데코레이터에서 메소드 체이닝을 사용하면 문법 에러가 됩니다.

예:

class cmdopt(object):
  _all = []
  def __init__(self, opt):
    self._opt = opt
    cmdopt._all.append(self)
  def arg(self, name, type=str):
    self._arg_name = name
    self._arg_type = type
    return self
  def __call__(self, func):
    self._callback = func

@cmdopt("-f").arg("file")    # 겨우 이 정도로 문법 에러
def fn(arg):
  filename = arg
  with open(filename) as f:
    print(f.read())

실행결과:

$ python ex1.py
  File "ex1.py", line 13
    @cmdopt("-f").arg("file")
                 ^
SyntaxError: invalid syntax

이와 같이, Python 데코레이터에는 아무 식이나 쓸 수 있는 것이 아니라, 복잡한 식은 쓸 수 없도록 의도적으로 제한이 걸려 있습니다.

분명히 이런 제약은 필요 없다고 말할 수 있습니다만, 이것이 Python way 인 것일까요. 메소드 체인 대신에 순순히 키워드 인수를 사용해 줍니다.

## 키워드 인수가 많아지면 복잡해지기 때문에 메소드 체인을 사용하고 있는데,
## 메소드 체인은 에러가 나는 주제에 키워드 인수는 제한이 없다는게
## 납득이 안감
@cmdopt("-f", arg=("file", str), desc="data file")  # 에러 아님
def fn(arg):
  pass

같은 코드라도 Python 버전에 따라 동작이 바뀜

매우 드문 일이지만, Python에서도 버전에 따라 같은 코드가 동작하거나 동작하지 않을 수 있습니다.

예를 들어 보겠습니다. 먼저 Python에서는 인스턴스 객체별로 별도의 메소드를 정의할 수 있습니다.

class Hello(object):
  def hello_english(self, name):
    return "Hello %s!" % (name,)
  def hello_french(self, name):
    return "Bonjor %s!" % (name,)

  def __init__(self, lang='en'):
    if lang == 'fr':
      self.hello = self.hello_french
    else:
      self.hello = self.hello_english

hello1 = Hello()
hello2 = Hello('fr')
print(hello1.hello("Python"))   #=> Hello Python!
print(hello2.hello("Python"))   #=> Bonjor Python!

이 자체는 편리한 기능이지만, 예를 들어 이것이 __enter__()와 __exit__()일 경우, Python 버전에 따라 동작하거나 동작하지 않을 수 있습니다.

다음 코드를 예로 들어 보겠습니다.

class Foo(object):

  def enter1(self):
    print("enter1")
  def enter2(self):
    print("enter2")
  def exit1(self, *args):
    print("exit1")
  def exit2(self, *args):
    print("exit2")

  def __init__(self, arg=1):
    if arg == 1:
      self.__enter__ = self.enter1
      self.__exit__  = self.exit1
    else:
      self.__enter__ = self.enter2
      self.__exit__  = self.exit2

obj = Foo()
with obj:
  print("with-statement")

이 코드는 Python 2.6이나 3.0이나 3.1에서 문제없이 작동합니다.

bash$ py ex1.py
enter1
with-statement
exit1

그러나 Python 2.7이나 Python 3.2 이상에서 오류가 발생합니다.

bash$ py ex1.py
Traceback (most recent call last):
  File "ex1.py", line 22, in <module>
    with obj:
AttributeError: __exit__

이처럼 같은 코드라도 Python의 버전에 따라 동작하거나 동작하지 않을 수 있습니다. 게다가 이것은 언어 사양을 개선하기 위해서 라기보다는 단순히 Python 구현상의 문제이기도합니다.

이런 경우는 Python에서는 좀처럼 발견하기 어렵습니다만, 절대 제로가 아니라는 것은 명심하는 것이 좋을 것입니다.

Python에는 이 밖에도 함정이 있었다고 생각합니다만, 기억나는것이 이 정도였습니다. 특히 Python 2.3 또는 2.4 정도에서 매우 불합리한 함정에 빠진 기억이 있는데, 어떤 함정이었는지 기억해 내지 못했습니다. 이것은 정말 의미를 알 수 없는 함정이라 “Python 같은 쓰레기 언어!” 라고 말하는데 충분할 만큼 너무 심한 버그였던 만큼 지금 재현 할 수없는 게 너무 억울하네요!

뭐 어떠한 언어에도 함정은 있습니다만, 자신이 알고 있는 언어와 다르다는 이유만으로 ‘함정’ 이라거나 ‘쓰레기 언어’ 라고 폄하하지 말도록 합시다.

comments powered by Disqus