Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 316 – Python을 위한 계약에 의한 프로그래밍

Author:
Terence Way <terry at wayforward.net>
Status:
Deferred
Type:
Standards Track
Created:
02-May-2003
Post-History:


Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

초록

이 제출물은 Python을 위한 계약에 의한 프로그래밍을 설명합니다. Eiffel의 Design By Contract(tm)는 아마도 프로그래밍 계약의 가장 대중적인 활용 사례일 것입니다 [2].

프로그래밍 계약은 클래스와 모듈에 대한 불변식 표현식, 그리고 함수와 메서드에 대한 사전 조건 및 사후 조건 표현식을 포함하도록 언어를 확장합니다.

이러한 표현식(계약)은 단언문(assertion)과 유사합니다: 참이어야 하며, 그렇지 않으면 프로그램이 중단되고, 계약의 런타임 검사는 일반적으로 디버깅 중에만 활성화됩니다. 계약은 단순한 단언문보다 더 높은 수준이며, 일반적으로 문서에 포함됩니다.

동기

Python에는 이미 단언문이 있는데, 계약과 같은 것을 지원하기 위해 굳이 언어에 추가적인 요소를 더할 필요가 있습니까? 가장 좋은 두 가지 이유는 1) 더 나은, 더 정확한 문서화, 그리고 2) 더 쉬운 테스트입니다.

복잡한 모듈과 클래스는 결코 제대로 문서화된 적이 없는 것 같습니다. 제공된 문서는 프로그래머가 다른 모듈이나 클래스 대신 특정 모듈이나 클래스를 사용하도록 설득하기에 충분할 수 있지만, 실제 디버깅이 시작되면 프로그래머는 거의 항상 소스 코드를 읽어야 합니다.

계약은 doctest 모듈이 제공하는 훌륭한 예시를 확장합니다 [4]. 문서는 프로그래머가 읽을 수 있으면서도, 그 안에 실행 가능한 테스트가 내장되어 있습니다.

계약을 사용한 코드 테스트도 더 쉬워집니다. 포괄적인 계약은 단위 테스트와 동등합니다 [8]. 테스트는 사전 조건의 전체 범위를 실행하며, 사후 조건이 위반되면 실패합니다. 이론적으로, 올바르게 명세된 함수는 완전히 무작위로 테스트될 수 있습니다.

그렇다면 왜 이것을 언어에 추가해야 할까요? 왜 여러 가지 다른 구현을 두거나, 프로그래머가 자신만의 어서션을 구현하도록 하지 않을까요? 그 답은 상속 하에서 계약이 보이는 동작에 있습니다.

Alice와 Bob이 서로 다른 어서션 패키지를 사용한다고 가정합시다. Alice가 어서션으로 보호되는 클래스 라이브러리를 만든 경우, Bob은 Alice의 라이브러리에서 클래스를 파생시키면서 사후 조건과 불변식이 제대로 검사되기를 기대할 수 없습니다. 둘 다 같은 어서션 패키지를 사용한다면, Bob은 Alice의 메서드를 오버라이드하면서도 여전히 Alice의 계약 어서션에 대해 테스트할 수 있습니다. 이러한 어서션 시스템을 두기에 자연스러운 곳은 언어의 런타임 라이브러리입니다.

명세

모듈이나 클래스의 독스트링(docstring)에는 콜론(:)이 뒤따르는 inv 키워드로 시작하는 줄로 표시된 불변 계약(invariant contract)을 포함할 수 있습니다. 줄 시작 부분과 콜론 주위의 공백은 무시됩니다. 콜론 뒤에는 같은 줄에 단일 표현식이 바로 이어지거나, inv 키워드보다 더 깊이 들여쓰기된 다음 줄들에 일련의 표현식이 이어질 수 있습니다. 여기서는 암시적 및 명시적 줄 연속에 관한 일반적인 파이썬 규칙을 따릅니다. 독스트링에는 임의 개수의 불변 계약이 포함될 수 있습니다.

예제 몇 가지:

# state enumeration
START, CONNECTING, CONNECTED, CLOSING, CLOSED = range(5)

class conn:

    """A network connection

    inv: self.state in [START, CLOSED,       # closed states
                        CONNECTING, CLOSING, # transition states
                        CONNECTED]

    inv: 0 <= self.seqno < 256
    """

class circbuf:

    """A circular buffer.

    inv:
        # there can be from 0 to max items on the buffer
        0 <= self.len <= len(self.buf)

        # g is a valid index into buf
        0 <= self.g < len(self.buf)

        # p is also a valid index into buf
        0 <= self.p < len(self.buf)

        # there are len items between get and put
        (self.p - self.g) % len(self.buf) == \
              self.len % len(self.buf)
    """

모듈 불변 조건은 모듈이 로드된 후, 그리고 모듈 내 모든 공개 함수의 진입 시점과 종료 시점에 참이어야 합니다.

클래스 불변 조건은 __init__ 함수가 반환된 후, __del__ 함수의 진입 시점, 그리고 클래스의 다른 모든 공개 메서드의 진입 시점과 종료 시점에 참이어야 합니다. 클래스 불변 조건은 인스턴스 변수에 접근하기 위해 self 변수를 사용해야 합니다.

메서드나 함수는 이름이 밑줄(_)로 시작하지 않으면 공개된 것으로 간주되며, 다만 ‘__’(밑줄 두 개)로 시작하고 끝나는 경우는 예외입니다.

함수나 메서드의 독스트링에는 위와 동일한 규칙에 따라 pre 키워드로 문서화된 사전 조건(pre-condition)을 포함할 수 있습니다. 사후 조건(post-condition)은 post 키워드로 문서화하며, 선택적으로 변수 목록이 뒤따를 수 있습니다. 이 변수들은 함수나 메서드의 본문과 같은 스코프에 있습니다. 이 목록은 함수/메서드가 수정할 수 있는 변수를 선언합니다.

예제:

class circbuf:

    def __init__(self, leng):
        """Construct an empty circular buffer.

        pre: leng > 0
        post[self]:
            self.is_empty()
            len(self.buf) == leng
        """

reStructuredText로 작성된 독스트링을 지원하기 위해 단일 콜론(:) 대신 이중 콜론(::)을 사용할 수 있습니다 [7]. 예를 들어, 다음 두 독스트링은 동일한 계약을 설명합니다:

"""pre: leng > 0"""
"""pre:: leng > 0"""

사전 조건과 사후 조건의 표현식은 모듈 네임스페이스에서 정의되며, 클로저 변수를 제외하고 함수가 접근할 수 있는 거의 모든 변수에 접근할 수 있습니다.

사후 조건의 계약 표현식은 두 개의 추가 변수에 접근할 수 있습니다: post 키워드 바로 뒤에 오는 변수 목록에 선언된 값들의 얕은 복사본으로 채워지는 __old__와, 함수 또는 메서드의 반환값에 바인딩되는 __return__입니다.

예시:

class circbuf:

    def get(self):
        """Pull an entry from a non-empty circular buffer.

        pre: not self.is_empty()
        post[self.g, self.len]:
            __return__ == self.buf[__old__.self.g]
            self.len == __old__.self.len - 1
        """

모든 계약 표현식은 몇 가지 추가 편의 함수에 접근할 수 있습니다. 시퀀스의 참 여부를 평가하기 쉽게 하기 위해, forallexists라는 두 함수가 다음과 같이 정의됩니다:

def forall(a, fn = bool):
    """Return True only if all elements in a are true.

    >>> forall([])
    1
    >>> even = lambda x: x % 2 == 0
    >>> forall([2, 4, 6, 8], even)
    1
    >>> forall('this is a test'.split(), lambda x: len(x) == 4)
    0
    """

def exists(a, fn = bool):
    """Returns True if there is at least one true value in a.

    >>> exists([])
    0
    >>> exists('this is a test'.split(), lambda x: len(x) == 4)
    1
    """

예시:

def sort(a):
    """Sort a list.

    pre: isinstance(a, type(list))
    post[a]:
        # array size is unchanged
        len(a) == len(__old__.a)

        # array is ordered
        forall([a[i] >= a[i-1] for i in range(1, len(a))])

        # all the old elements are still in the array
        forall(__old__.a, lambda e: __old__.a.count(e) == a.count(e))
    """

조건 평가를 쉽게 하기 위해 implies 함수가 정의됩니다. 인자가 두 개인 경우, 이는 논리적 implies(=>) 연산자와 유사합니다. 인자가 세 개인 경우, 이는 C의 조건 표현식(x?a:b)과 유사합니다. 이는 다음과 같이 정의됩니다:

implies(False, a) => True
implies(True, a) => a
implies(False, a, b) => b
implies(True, a, b) => a

함수에 진입하면 함수의 사전 조건이 검사됩니다. 사전 조건 중 하나라도 거짓이면 어서션 오류가 발생합니다. 함수가 공개(public) 함수인 경우, 클래스 또는 모듈의 불변식도 검사됩니다. post에 선언된 변수들의 복사본이 저장되고, 함수가 호출되며, 함수가 예외를 발생시키지 않고 종료되면 사후 조건이 검사됩니다.

예외

함수나 메서드가 예외 신호를 발생시키며 종료되더라도 클래스/모듈 불변조건은 검사됩니다(사후조건은 검사되지 않습니다).

실패한 모든 계약은 ContractViolationError예외의 서브클래스인 예외를 발생시키며, 이 예외는 다시 AssertionError예외의 서브클래스입니다. 실패한 사전조건은 PreconditionViolationError예외를 발생시킵니다. 실패한 사후조건은 PostconditionViolationError예외를 발생시키고, 실패한 불변조건은 InvariantViolationError예외를 발생시킵니다.

클래스 계층 구조:

AssertionError
    ContractViolationError
        PreconditionViolationError
        PostconditionViolationError
        InvariantViolationError
        InvalidPreconditionError

InvalidPreconditionError는 사전조건이 부적절하게 강화되었을 때 발생합니다. 다음 상속 절을 참조하십시오.

예제:

try:
    some_func()
except contract.PreconditionViolationError:
    # failed pre-condition, ok
    pass

상속

클래스의 불변조건에는 모든 상위 클래스의 불변조건이 포함됩니다(클래스 불변조건은 상위 클래스 불변조건과 AND 연산됩니다). 이러한 불변조건은 메서드 해석 순서(method-resolution order)로 검사됩니다.

메서드의 사후조건에는 오버라이드된 모든 사후조건도 포함됩니다(메서드 사후조건은 오버라이드된 모든 메서드 사후조건과 AND 연산됩니다).

오버라이드된 메서드의 사전조건은 오버라이딩 메서드의 사전조건이 충족되면 무시될 수 있습니다. 그러나 오버라이딩 메서드의 사전조건이 실패하는 경우, 오버라이드된 메서드의 사전조건도 모두 실패해야 합니다. 그렇지 않으면 InvalidPreconditionError라는 별도의 예외가 발생합니다. 이는 사전조건을 완화하는 것을 지원합니다.

다소 인위적인 예제:

class SimpleMailClient:

    def send(self, msg, dest):
        """Sends a message to a destination:

        pre: self.is_open() # we must have an open connection
        """

    def recv(self):
        """Gets the next unread mail message.

        Returns None if no message is available.

        pre: self.is_open() # we must have an open connection
        post: __return__ is None or isinstance(__return__, Message)
        """

 class ComplexMailClient(SimpleMailClient):
    def send(self, msg, dest):
        """Sends a message to a destination.

        The message is sent immediately if currently connected.
        Otherwise, the message is queued locally until a
        connection is made.

        pre: True # weakens the pre-condition from SimpleMailClient
        """

    def recv(self):
        """Gets the next unread mail message.

        Waits until a message is available.

        pre: True # can always be called
        post: isinstance(__return__, Message)
        """

사전 조건은 약화만 가능하므로, ComplexMailClient는 기존 코드를 손상시킬 걱정 없이 SimpleMailClient를 대체할 수 있습니다.

근거

다음과 같은 차이점을 제외하면, Python의 계약에 의한 프로그래밍은 Eiffel DBC 명세 [3]를 그대로 따릅니다.

독스트링에 계약을 내장하는 방식은 doctest 모듈을 본떠 만들어졌습니다. 이는 추가 문법의 필요성을 없애고, 계약이 포함된 프로그램이 하위 호환성을 유지하도록 보장하며, 계약을 문서에 포함시키기 위해 별도의 작업이 필요하지 않게 합니다.

키워드 pre, post, inv는 Eiffel 방식의 REQUIRE, ENSURE, INVARIANT 대신 선택되었는데, 이는 더 짧고 수학적 표기법에 더 부합하기 때문이며, 더 미묘한 이유도 있습니다: ‘require’라는 단어는 호출자의 책임을 암시하는 반면, ‘ensure’는 제공자의 보증을 암시하기 때문입니다. 그러나 다중 상속을 사용할 때는 호출자의 잘못이 아니어도 사전 조건이 실패할 수 있고, 다중 스레드를 사용할 때는 함수의 잘못이 아니어도 사후 조건이 실패할 수 있습니다.

Eiffel에서 사용되는 루프 불변식은 지원되지 않습니다. 이를 구현하는 것은 번거로운 데다, 어차피 문서의 일부도 아닙니다.

변수 이름 __old____return__return 키워드와의 충돌을 피하고 Python 명명 규칙과 일관성을 유지하기 위해 선택되었습니다: 이들은 공개(public)되어 있으며 Python 구현체가 제공합니다.

post 키워드 뒤에 변수 선언을 두는 것은 함수나 메서드가 수정할 수 있는 대상을 정확히 명시합니다. 이는 Eiffel의 NoChange 문법에 대한 필요성을 없애며, __old__의 구현을 훨씬 쉽게 만듭니다. 또한 이는 Z 스키마 [9]와 더 부합하는데, Z 스키마는 변경 대상을 선언하고 그 변경을 제한하는 두 부분으로 나뉩니다.

__old__ 값을 위한 변수의 얕은 복사는 계약 프로그래밍 구현이 시스템 속도를 지나치게 저하시키지 않도록 방지합니다. 함수가 얕은 복사로는 포착되지 않는 값을 변경하는 경우, 다음과 같이 변경 사항을 선언할 수 있습니다:

post[self, self.obj, self.obj.p]

forall, exists, implies 함수는 계약과 함께 기존 함수를 문서화하는 데 시간을 들인 후 추가되었습니다. 이들은 일반적인 명세 관용구의 대부분을 담아냅니다. implies를 함수로 정의하는 것이 동작하지 않을 것처럼 보일 수 있지만(다른 불리언 연산자와 달리, 인자는 필요 여부와 상관없이 평가됩니다), 계약 내의 어떤 표현식에도 부작용이 없어야 하므로 계약에 대해서는 잘 동작합니다.

참조 구현

참조 구현을 이용할 수 있습니다 [1]. 이는 클래스나 모듈의 네임스페이스를 직접 변경하여, 기존 함수를 계약 검사를 수행하는 새 함수로 대체합니다.

__getattr__을 편법으로 사용하거나 [5] __metaclass__를 사용하는 [6] 다른 구현들도 존재합니다.

참고 문헌