실수하기 쉬운 Python 코드

이 글은 Buggy Python Code: The 10 Most Common Mistakes That Python Developers Make 을 참고하여 작성되었습니다.

머신러닝 분야에서 선호되는 언어로는 Python 과 R 이 자주 비교대상이 됩니다. 두 언어의 태생의 근원을 생각해 보면 아마도 Python 은 전통적인 소프트웨어 엔지니어 그룹에서 R 은 통계나 수학자 그룹에서 많이 사용될 것 같습니다. 이 글은 Python 에서 버그를 만들기 쉬운 코드에 대해 설명을 하고 있습니다. Python 에 꽤 익숙한 저도 눈으로만은 잡아내기 힘들더군요 ; )

(특별한 언급이 없는 경우 아래의 코드는 Python 2.7.x 인터프리터에서 실행한 결과를 나타냅니다)

  • 함수의 파라메타 기본값을 함수내에서 변경할 때
    >>> def foo(bar=[]):
    ...    bar.append("baz")
    ...    return bar

    이 코드를 실행하면 항상 “baz” 란 문자열 하나를 가지고 있는 리스트가 리턴되어야 할 것 같은데요. 하지만 실행해 보면 결과가 좀 다릅니다.

    >>> foo()
    ['baz']
    >>> foo()
    ['baz', 'baz']
    >>> foo()
    ['baz', 'baz', 'baz']

    이렇게 되는 이유는 뭘까요. Python 함수에서 파라메타의 기본값으로 지정된 것은 맨 처음 실행될 때 딱 한번 초기화되고 그 다음부터는 재 사용되기 때문입니다. 파라메타 bar 에 매핑된 리스트 오브젝트는  foo 함수에 인자를 넣어서 호출한 후에도 계속 지속됩니다.

    >>> foo([1])
    [1, 'baz']
    >>> foo([2])
    [2, 'baz']
    >>> foo()
    ['baz', 'baz', 'baz', 'baz']

    이러한 예상하지 못한 결과는 특히 리스트 오브젝트를 기본 파라메타로 사용할 때 경험하기 쉽습니다. 리스트 오브젝트를 사용할 때 위와 같은 경우를 피하려면 아래와 같이 함수내로 초기화 부분을 옮겨야 합니다.

    >>> def foo(bar=None):
    ...    if bar is None:
    ...        bar = []
    ...    bar.append("baz")
    ...    return bar
  • 상속된 클래스 변수를 사용할 때
    >>> class A(object):
    ... x = 1
    ...
    >>> class B(A):
    ... pass
    ...
    >>> class C(A):
    ... pass
    ...
    >>> print A.x, B.x, C.x
    1 1 1

    이건 당연해 보입니다.
    그 다음에는

    >>> B.x = 2
    >>> print A.x, B.x, C.x
    1 2 1

    이것도 예상대로입니다.

    >>> A.x = 3
    >>> print A.x, B.x, C.x
    3 2 3

    이건 조금 예상치 못한 결과일 수 있습니다. B.x 는 A.x 에서 상속되어 독립적인 값을 가지게 됩니다. 그러나 C.x 는 아직 독립적으로 생성되지 않은 상태라 여전히 A.x 를 가리키고 있기 때문입니다.

  • 익셉션 처리시 파라메타 지정
    >>> try:
    ...     l = ["a", "b"]
    ...     int(l[2])
    ... except ValueError, IndexError:
    ...     pass
    ...
    Traceback (most recent call last):
      File "<stdin>", line 3, in <module>
    IndexError: list index out of range

    위 코드에서 IndexError 익셉션을 잡지 못하는 것은 except 문에서 여러개의 익셉션 리스트를 파라메타로 받지 못하기 때문입니다. Python 2 에서의 except 문은 except ValueError, e 와 같이 옵셔널(optional)로 두번째 파라메타 e 를 지정하여 사용할 수 있습니다. 그래서 이 신택스로 인해 혼돈을 일으킬 수 있는 것 같습니다. 여러개의 익셉션을 사용하려면 괄호를 묶어서 사용해야 합니다.

    >>> try:
    ...     l = ["a", "b"]
    ...     int(l[2])
    ... except (ValueError, IndexError), e:
    ...     pass

    하지만 Python 2, 3 에서 모두 사용될 수 있는 아래와 같은 방식이 권장됩니다.

    >>> try:
    ...     l = ["a", "b"]
    ...     int(l[2])
    ... except (ValueError, IndexError) as e:
    ...     pass
  • 할당문에 의한 변수 범위 변화
    >>> x = 10
    >>> def foo():
    ...     x += 1
    ...     print x
    ...
    >>> foo()
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
      File "<stdin>", line 2, in foo
    UnboundLocalError: local variable 'x' referenced before assignment

    어찌된 일일까요? 분명히 전역 범위에서 x = 10 을 대입했으니 함수내에서 이를 알아차려야 하는 것 아닐까요? 이번에는 print 문의 위치를 바꾸어 보겠습니다.

    >>> def foo():
    ...     print x
    ...     x += 1
    ...
    >>> foo()
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
      File "<stdin>", line 2, in foo
    UnboundLocalError: local variable 'x' referenced before assignment

    그래도 결과는 마찬가지로 print 문에서 에러가 발생합니다. 이젠 더 이상하게 생각되지요?
    사실 Python 의 변수 범위에서 한가지 주의해야 할 점은 함수 내에서 할당문이 있을 경우 그 변수의 범위가 로컬(local)로 한정된다는 점 입니다. 함수 foo 에 x 에 대한 할당문이 있기 때문에 x 의 범위를 함수내로 한정시키고 변수 x 에 값이 대입되기 전에 먼저 사용되었다고 익셉션이 발생하게 됩니다. 전역 변수 x 를 사용하려는 의도를 코드에 적용하려면 global 키워드를 사용해야 합니다.

    >>> def foo():
    ...     global x
    ...     print x
    ...     x += 1
    ...
    >>> foo()
    10

    이와 같은 에러는 리스트를 사용할 때에도 자주 발생할 수 있습니다.

    >>> lst = [1, 2, 3]
    >>> def foo1():
    ...     lst.append(5)
    ...
    >>> foo1()
    >>> lst
    [1, 2, 3, 5]

    위 문장은 예상대로 잘 동작합니다만 아래는 그렇지 않습니다.

    >>> lst = [1, 2, 3]
    >>> def foo2():
    ...     lst += [5]
    ...
    >>> foo2()
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
      File "<stdin>", line 2, in foo
    UnboundLocalError: local variable 'lst' referenced before assignment

    위 코드에서 lst 변수를 찾지 못하는 이유도 lst += [5] 가 할당문이어서 변수의 범위가 로컬로 변경되었기 때문입니다. 즉 lst = lst + [5] 에서 우변의 lst 를 찾지 못하는 셈이 된것입니다.

  • 반복문 안에서 리스트를 수정할 때
    사실 이 에러는 Python 에서 뿐만이 아니라 다른 언어에서도 자주 겪을 수 있는 것으로 숙련된 프로그래머는 대부분 잘 방어하고 있을 것 같습니다. 반복문안에서 리스트의 갯수를 조절하게 되면 특히 길이가 줄어들 때 반복문이 리스트의 범위를 초과하게 되는 경우입니다.

    >>> numbers = [n for n in range(10)]
    >>> for i in range(len(numbers)):
    ...     if x % 2:
    ...         del numbers[i]
    ...
    Traceback (most recent call last):
              File "<stdin>", line 2, in <module>
    IndexError: list index out of range

    numbers 는 0~9 까지 10개의 요소를 가지고 있습니다. 그런데 for 문 안에서 number 의 갯수를 줄이게 되면 for 루프가 number 의 길이보다 더 많이 실행되게 되어 에러가 발생하게 됩니다.
    이런 경우에는 number 의 갯수를 줄이는 것보다 새로운 리스트를 만드는 방법이 좋은 습관입니다.

    >>> numbers1 = [n for n in numbers if not n % 2]
    >>> numbers1
    [0, 2, 4, 6, 8]
  • 클로저(closure)에서 변수의 동적 바인딩(Dynamic Binding or Late Binding)
    클로저는 파라메타로 전달될 수 있는 함수 오브젝트로 쓰이는 곳의 위치에 상관없이 클로저가 생성됐을 때의 외부 함수의 변수에 접근할 수 있는 특징이 있습니다. Python 에서는 람다 함수와 함께 자주 사용됩니다. 아래 코드를 살펴보겠습니다.

    >>> def create_multipliers():
    ...     return [lambda x : i * x for i in range(5)]
    >>> for multiplier in create_multipliers():
    ...     print multiplier(2)
    ...

    이 코드는 0~4 까지의 수에 2를 곱한 결과

    0
    2
    4
    6
    8

    이 될 것 같지만

    8
    8
    8
    8
    8

    이 프린트 됩니다. 무엇이 잘못된 걸까요? create_mulipliers 함수는 다섯개의 클로져를 리스트로 구성하여 리턴하고 있습니다. 각 클로져에는 i 값이 0 에서 4까지 변화될 것 같지만 실제로는 모두 4 로 결과가 나왔습니다. 이는 Python 의 동적 바인딩 혹은 게으른 바인딩(late binding) 특성때문입니다. 즉 클로져가 생성될 때 i 값이 결정되는 것이 아니라 i 변수가 참조될 때 값이 결정되기 때문에 모두 4의 값을 가지고 있습니다.
    원하는 결과를 얻기 위해서는 람다함수로 넘겨지는 파라메타에 i 를 억지로 참조하는 핵(hack)을 넣으면 됩니다.

    >>> def create_multipliers():
    ...     return [lambda x, i = i : i * x for i in range(5)]
  • 모듈 순환 참조
    두개의 모듈이 서로를 참조하고 있을 경우에 발생할 수 있는 문제입니다. 아래 두개의 모듈에서 a.py 는 b 를 임포트한 후 함수 f 에서 b.x 를 참조하고 있습니다. 그리고 b.py 는 a 를 임포트한 후에 a.f() 함수를 참조하고 있습니다.
    a.py

    import b
    def f():
        return b.x
    print f()

    b.py

    import a
    x = 1
    def g():
        print a.f()

    Python 인터프리터에서 a.py 를 임포트해 보겠습니다.

    >>> import a
    1

    a 를 임포트하면 b 를 임포트하게 되고 다시 b 에서 a  를 임포트하는 순환 구조 때문에 에러가 발생할 것 같지만 Python 은 한번 임포트된 것을 다시 임포트하지 않기 때문에 문제 없이 a.py 의 f() 함수가 실행되고 b.x 가 프린트 되었습니다. 그럼 반대로 먼저 b 를 임포트하면 어떻게 될까요?

    >>> import b
    Traceback (most recent call last):
              File "<stdin>", line 1, in <module>
              File "b.py", line 1, in <module>
        import a
              File "a.py", line 6, in <module>
            print f()
              File "a.py", line 4, in f
            return b.x
    AttributeError: 'module' object has no attribute 'x'

    b 에서 a 를 임포트하게 되고 함수 f() 가 실행됩니다. 그런데 x = 1 문장은 아직 실행전이라서 b.x 가 존재하지 않기 때문에 에러가 발생하게 됩니다. 이런 경우 import a 문장을 실제 사용하려는 곳 바로 위로 옮기는 것이 좋습니다.
    b.py

    x = 1
    def g():
        import a
        print a.f()
    >>> import b
    >>> b.g()
    1
    1
  • 익셉션 오브젝트 e 의 참조 범위
    Python 2 에서는 익셉션 오브젝트 e 를 except 밖에서도 참조할 수 있지만 Python 3 에서는 불가능합니다.

    try:
        ...
    except ValueError as e:
        ...
    print(e)

    이와 같은 코드는 Python 2 에서는 문제가 없지만 Python 3 에서는 변수 e 를 찾을 수 없다고 에러가 납니다. 따라서 아래와 같이 익셉션 오브젝트를 별도의 변수에 저장하여 사용하는 것이 좋습니다.

    exception = None
    try:
        ...
    except ValueError as e:
        ...
        exception = e
    print(exception)

실수하기 쉬운 Python 코드”에 대한 1개의 생각

  1. moonko

    파이썬이 배우기 쉬운 언어라고 하는데 반만 맞는 말 같네요, 말 그대로 배우기만 쉽고 오히려 쓰다보면 알 수 없는 버그라고(?) 생각되는 부분도 많아ㅡ 자기가 직접 겪으면서 배워야하는 부분이 많은 것 같아요.

    Liked by 1명

    응답

moonko 에 답글 남기기 응답 취소

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

%s에 연결하는 중

This site uses Akismet to reduce spam. Learn how your comment data is processed.