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

Python 개선 제안 한국어 번역

PEP 335 – 오버로드 가능한 불리언 연산자

Author:
Gregory Ewing <greg.ewing at canterbury.ac.nz>
Status:
Rejected
Type:
Standards Track
Created:
29-Aug-2004
Python-Version:
3.3
Post-History:
05-Sep-2004, 30-Sep-2011, 25-Oct-2011

Table of Contents

번역·라이선스 안내

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

거부 공지

이 PEP는 거부되었습니다. https://mail.python.org/pipermail/python-dev/2012-March/117510.html 을 참조하십시오.

초록

이 PEP는 객체가 불리언 연산자 ‘and’, ‘or’, ‘not’의 의미를 스스로 정의할 수 있도록 하는 확장을 제안하고, 이를 효율적으로 구현하기 위한 전략을 제시합니다. 이 구현의 프로토타입을 내려받을 수 있습니다.

배경

Python은 현재 ‘and’, ‘or’, ‘not’ 불리언 연산자에 대응하는 ‘__xxx__’ 특수 메서드를 전혀 제공하지 않습니다. ‘and’와 ‘or’의 경우, 가장 유력한 이유는 이 연산자들이 단락 평가(short-circuiting) 의미 체계를 가진다는 점입니다. 즉, 첫 번째 피연산자만으로 결과를 결정할 수 있으면 두 번째 피연산자는 평가되지 않습니다. 따라서 이 연산자들에 특수 메서드를 제공하는 일반적인 기법은 통하지 않습니다.

그러나 ‘not’의 경우에는 그런 어려움이 없으며, 이 연산자에 대한 특수 메서드를 제공하는 것은 간단합니다. 따라서 이 제안의 나머지 부분에서는 주로 ‘and’와 ‘or’를 오버로드하는 방법을 제공하는 데 집중합니다.

동기

Python 연산자에 사용자 정의 의미를 부여하는 것이 자연스러운 응용 분야가 많으며, 그중 일부에서는 불리언 연산자가 사용자 정의 가능한 연산자에서 제외되어 있다는 점이 불편할 수 있습니다. 예는 다음과 같습니다:

  1. NumPy에서는 거의 모든 연산자가 배열에 대해 정의되어 대응하는 요소끼리 적절한 연산을 수행하고 그 결과의 배열을 반환합니다. 일관성을 생각하면 두 배열 사이의 불리언 연산도 불리언 배열을 반환하리라 기대하겠지만, 현재는 이것이 불가능합니다.

    이러한 종류의 확장에는 선례가 있습니다. 비교 연산자는 원래 불리언 결과만 반환하도록 제한되어 있었으나, NumPy 배열의 비교가 불리언 배열을 반환할 수 있도록 풍부한 비교(rich comparison)가 추가되었습니다.

  2. 기호 대수 시스템: 이 시스템에서는 Python 표현식을 특정 환경에서 평가하면, 그 결과로 표현식의 구조에 대응하는 객체 트리가 구성됩니다.
  3. 관계형 데이터베이스 인터페이스: 이 인터페이스에서는 Python 표현식을 사용하여 SQL 쿼리를 구성합니다.

흔히 제안되는 우회 방법은 ‘and’, ‘or’, ‘not’ 대신 비트 연산자 ‘&’, ‘|’, ‘~’를 사용하는 것이지만, 여기에는 몇 가지 단점이 있습니다:

  • 이 연산자들은 다른 연산자에 대한 우선순위가 다르며, 이미 다른 용도로 사용되고 있을 수도 있습니다(예 1에서처럼).
  • 사용자가 표현하려는 바에 대해 가장 명백한 문법이 아닌 다른 것을 사용하도록 강제하는 것은 미적으로 불쾌합니다. 불리언 연산이 SQL 쿼리의 핵심 요소임을 고려하면, 이 문제는 예 3의 경우에 특히 심각합니다.
  • 비트 연산자는 암묵적인 ‘and’ 연산을 포함하는 ‘a < b < c’ 같은 연쇄 비교 문제에 대한 해결책이 되지 못합니다. 현재 이러한 표현식은 비교 결과를 일반적인 불리언 의미 체계를 가진 것으로 취급할 수 없는 NumPy 배열 같은 데이터 타입에서는 전혀 사용할 수 없습니다. 이런 경우 (a < b) & (b < c)와 같은 형태로 풀어 써야 하므로 명료성이 상당히 떨어집니다.

근거

불리언 연산자를 사용자 정의할 수 있게 하는 문제를 성공적으로 해결하기 위한 요구 사항은 다음과 같습니다:

  1. 기본 경우(사용자 정의가 없는 경우)에는 기존의 단락 평가 의미 체계가 유지되어야 합니다.
  2. 기본 경우에 눈에 띄는 속도 저하가 없어야 합니다.
  3. 이상적으로는, 사용자 정의 메커니즘이 객체의 재량에 따라 단락 평가 또는 비단락 평가 의미 체계 중 하나를 제공할 수 있도록 해야 합니다.

이전에 제안된 적이 있는 한 가지 분명한 전략은 특수 메서드에 첫 번째 인자와, 두 번째 인자를 평가하는 함수를 전달하는 것입니다. 이 방식은 요구 사항 1과 3은 충족하지만 요구 사항 2는 충족하지 못합니다. 모든 불리언 연산마다 함수 객체를 구성하는 오버헤드와, 경우에 따라서는 Python 함수 호출의 오버헤드까지 발생하기 때문입니다. 따라서 여기서는 이 방법을 더 이상 고려하지 않습니다.

다음 절에서는 세 가지 요구 사항을 모두 충족하는 전략을 제안합니다. 이 전략의 prototype implementation을 내려받을 수 있습니다.

명세

특수 메서드

Python 수준에서 객체는 다음과 같은 특수 메서드를 정의할 수 있습니다.

단항 이항, 1단계 이항, 2단계
  • __not__(self)
  • __and1__(self)
  • __or1__(self)
  • __and2__(self, other)
  • __or2__(self, other)
  • __rand2__(self, other)
  • __ror2__(self, other)

__not__ 메서드가 정의되어 있으면 이 메서드가 ‘not’ 연산자를 구현합니다. 이 메서드가 정의되어 있지 않거나 NotImplemented를 반환하면 기존 의미 체계가 사용됩니다.

단락 평가를 허용하기 위해 ‘and’ 및 ‘or’ 연산자의 처리는 두 단계로 나뉩니다. 1단계는 첫 번째 피연산자를 평가한 뒤, 두 번째 피연산자를 평가하기 전에 일어납니다. 첫 번째 피연산자가 해당하는 1단계 메서드를 정의하고 있으면, 첫 번째 피연산자를 인자로 하여 그 메서드가 호출됩니다. 그 메서드가 두 번째 피연산자 없이 결과를 결정할 수 있으면 결과를 반환하고, 이후의 처리는 생략됩니다.

1단계 메서드가 두 번째 피연산자가 필요하다고 판단하면 특수 값 NeedOtherOperand를 반환합니다. 그러면 두 번째 피연산자가 평가되고, 해당하는 2단계 메서드가 호출됩니다. 2단계에서 __and2__/__rand2__ 및 __or2__/__ror2__ 메서드 쌍은 다른 이항 연산자에서와 똑같이 동작합니다.

어느 단계에서든 해당하는 특수 메서드를 찾을 수 없거나 그 메서드가 NotImplemented를 반환하면, 처리는 기존 의미 체계로 대체됩니다.

특수한 경우로, 첫 번째 피연산자가 2단계 메서드는 정의하지만 대응하는 1단계 메서드는 정의하지 않으면, 두 번째 피연산자가 항상 평가되고 2단계 메서드가 호출됩니다. 따라서 단락 평가 의미 체계를 원하지 않는 객체는 2단계 메서드만 구현하고 1단계는 무시하면 됩니다.

바이트코드

이 패치는 LOGICAL_AND_1, LOGICAL_AND_2, LOGICAL_OR_1, LOGICAL_OR_2라는 네 가지 새 바이트코드를 추가합니다. 사용 예로, ‘and’ 표현식에 대해 생성되는 바이트코드는 다음과 같습니다:

     .
     .
     .
     evaluate first operand
     LOGICAL_AND_1  L
     evaluate second operand
     LOGICAL_AND_2
L:   .
     .
     .

LOGICAL_AND_1 바이트코드는 1단계 처리를 수행합니다. 두 번째 피연산자가 필요하다고 판단하면 첫 번째 피연산자를 스택에 남겨 두고 다음 코드를 이어서 실행합니다. 그렇지 않으면 첫 번째 피연산자를 팝하고, 결과를 푸시한 뒤 L로 브랜치합니다.

LOGICAL_AND_2 바이트코드는 2단계 처리를 수행하여 두 피연산자를 모두 꺼내고 결과를 넣습니다.

타입 슬롯

C 수준에서 새 특수 메서드는 타입 객체의 새 슬롯 다섯 개로 나타납니다. 패치에서는 이 슬롯들을 tp_as_number 하위 구조체에 추가합니다. 이렇게 하면 단항 연산자와 이항 연산자를 처리하는 기존 코드 일부를 활용할 수 있기 때문입니다. 이 슬롯들이 있다는 것은 새 타입 플래그인 Py_TPFLAGS_HAVE_BOOLEAN_OVERLOAD로 표시됩니다.

새 타입 슬롯은 다음과 같습니다:

unaryfunc nb_logical_not;
unaryfunc nb_logical_and_1;
unaryfunc nb_logical_or_1;
binaryfunc nb_logical_and_2;
binaryfunc nb_logical_or_2;

Python/C API 함수

새 연산에 대응하는 Python/C API 함수도 다섯 개 새로 추가됩니다:

PyObject *PyObject_LogicalNot(PyObject *);
PyObject *PyObject_LogicalAnd1(PyObject *);
PyObject *PyObject_LogicalOr1(PyObject *);
PyObject *PyObject_LogicalAnd2(PyObject *, PyObject *);
PyObject *PyObject_LogicalOr2(PyObject *, PyObject *);

대안과 최적화

이 절에서는 이 제안의 몇 가지 가능한 변형과, 불리언 표현식에 대해 생성되는 바이트코드 시퀀스를 최적화할 수 있는 방법을 논의합니다.

축소된 특수 메서드 집합

이 제안의 전체 버전에는 완결성을 위해 타입이 단락 평가 동작을 직접 정의할 수 있는 메커니즘이 포함되어 있습니다. 그러나 여기에서 제시한 주요 사용 사례를 해결하는 데 전체 메커니즘이 필요하지는 않으며, 2단계 메서드만 포함하는 간소화된 버전을 정의할 수도 있습니다. 그러면 새 특수 메서드는 5개(__and2__, __rand2__, __or2__, __ror2__, __not__)뿐이며, 관련 타입 슬롯은 3개, API 함수는 3개가 됩니다.

이 간소화된 버전은 필요하다면 나중에 전체 버전으로 확장할 수 있습니다.

추가 바이트코드

여기에서 정의한 대로라면, 불리언 표현식의 결과에 따라 브랜치하는 코드의 바이트코드 시퀀스는 현재보다 약간 길어집니다. 예를 들어 Python 2.7에서는

if a and b:
    statement1
else:
    statement2

다음을 생성합니다

    LOAD_GLOBAL         a
    POP_JUMP_IF_FALSE   false_branch
    LOAD_GLOBAL         b
    POP_JUMP_IF_FALSE   false_branch
    <code for statement1>
    JUMP_FORWARD        end_branch
false_branch:
    <code for statement2>
end_branch:

지금까지 설명한 제안에 따르면 이는 대략 다음과 같이 바뀝니다

    LOAD_GLOBAL         a
    LOGICAL_AND_1       test
    LOAD_GLOBAL         b
    LOGICAL_AND_2
test:
    POP_JUMP_IF_FALSE   false_branch
    <code for statement1>
    JUMP_FORWARD        end_branch
false_branch:
    <code for statement2>
end_branch:

이렇게 하면 단락 평가가 일어나는 경우에는 바이트코드를 하나 더, 단락 평가가 일어나지 않는 경우에는 바이트코드를 두 개 더 실행하게 됩니다.

그러나 논리 연산과 결과에 대한 검사 및 분기를 결합한 추가 바이트코드를 도입하면 원래와 같은 수의 바이트코드로 줄일 수 있습니다:

    LOAD_GLOBAL         a
    AND1_JUMP           true_branch, false_branch
    LOAD_GLOBAL         b
    AND2_JUMP_IF_FALSE  false_branch
true_branch:
    <code for statement1>
    JUMP_FORWARD        end_branch
false_branch:
    <code for statement2>
end_branch:

여기에서 AND1_JUMP는 위와 같이 1단계 처리를 수행한 다음 결과를 확인합니다. 결과가 있으면 그 결과를 스택에서 팝하고, 진릿값을 검사한 뒤 두 위치 중 한 곳으로 브랜치합니다.

그렇지 않으면 첫 번째 피연산자를 스택에 남겨 두고 다음 바이트코드로 실행을 계속합니다. AND2_JUMP_IF_FALSE 바이트코드는 2단계 처리를 수행하고, 결과를 팝한 뒤 그 결과가 거짓으로 판정되면 브랜치합니다.

‘or’ 연산자에 대해서는 이에 대응하는 OR1_JUMP와 OR2_JUMP_IF_TRUE 바이트코드가 있게 됩니다.

1단계 메서드가 없는 간소화된 버전을 사용하면, 조기 종료는 ‘and’의 경우 첫 번째 피연산자가 거짓일 때, ‘or’의 경우 참일 때만 일어날 수 있습니다. 따라서 대상이 두 개인 AND1_JUMP와 OR1_JUMP 바이트코드는 AND1_JUMP_IF_FALSE와 OR1_JUMP_IF_TRUE로 대체할 수 있으며, 이들은 대상이 하나뿐인 일반적인 브랜치 명령어입니다.

‘not’의 최적화

최근 버전의 Python은 부정된 불리언 표현식에 따른 브랜치를 브랜치의 방향을 반전시키는 방식으로 구현하여 UNARY_NOT 옵코드를 절약하는 간단한 최적화를 구현하고 있습니다.

엄격하게 보면 이 최적화는 더 이상 수행해서는 안 됩니다. ‘not’ 연산자를 재정의하여 평소와 전혀 다른 결과를 만들어 낼 수 있기 때문입니다. 그러나 일반적인 사용 사례에서는 사용자 정의 불리언 연산을 포함하는 표현식이 분기에 사용되리라고 예상하지 않습니다 – 그 결과는 다른 방식으로 사용될 가능성이 훨씬 높습니다.

따라서 컴파일러가 불리언 문맥에 직접 나타나는 모든 표현식을 단순화하는 데 불 대수의 법칙을 사용할 수 있다고 명시하더라도 해가 될 일은 거의 없을 것입니다. 이것이 불편하다면 언제든지 결과를 먼저 임시 이름에 대입하면 됩니다.

이렇게 하면 기존의 ‘not’ 최적화를 유지할 수 있고, 드 모르간의 법칙을 사용하여 이 최적화를 표현식의 더 깊은 곳까지 확장하는 것과 같은 향후 확장도 가능해집니다.

사용 예

예제 1: NumPy 배열

#-----------------------------------------------------------------
#
#   This example creates a subclass of numpy array to which
#   'and', 'or' and 'not' can be applied, producing an array
#   of booleans.
#
#-----------------------------------------------------------------

from numpy import array, ndarray

class BArray(ndarray):

    def __str__(self):
        return "barray(%s)" % ndarray.__str__(self)

    def __and2__(self, other):
        return (self & other)

    def __or2__(self, other):
        return (self & other)

    def __not__(self):
        return (self == 0)

def barray(*args, **kwds):
    return array(*args, **kwds).view(type = BArray)

a0 = barray([0, 1, 2, 4])
a1 = barray([1, 2, 3, 4])
a2 = barray([5, 6, 3, 4])
a3 = barray([5, 1, 2, 4])

print "a0:", a0
print "a1:", a1
print "a2:", a2
print "a3:", a3
print "not a0:", not a0
print "a0 == a1 and a2 == a3:", a0 == a1 and a2 == a3
print "a0 == a1 or a2 == a3:", a0 == a1 or a2 == a3

예제 1 출력

a0: barray([0 1 2 4])
a1: barray([1 2 3 4])
a2: barray([5 6 3 4])
a3: barray([5 1 2 4])
not a0: barray([ True False False False])
a0 == a1 and a2 == a3: barray([False False False  True])
a0 == a1 or a2 == a3: barray([False False False  True])

예제 2: 데이터베이스 쿼리

#-----------------------------------------------------------------
#
#   This example demonstrates the creation of a DSL for database
#   queries allowing 'and' and 'or' operators to be used to
#   formulate the query.
#
#-----------------------------------------------------------------

class SQLNode(object):

    def __and2__(self, other):
        return SQLBinop("and", self, other)

    def __rand2__(self, other):
        return SQLBinop("and", other, self)

    def __eq__(self, other):
        return SQLBinop("=", self, other)


class Table(SQLNode):

    def __init__(self, name):
        self.__tablename__ = name

    def __getattr__(self, name):
        return SQLAttr(self, name)

    def __sql__(self):
        return self.__tablename__


class SQLBinop(SQLNode):

    def __init__(self, op, opnd1, opnd2):
        self.op = op.upper()
        self.opnd1 = opnd1
        self.opnd2 = opnd2

    def __sql__(self):
        return "(%s %s %s)" % (sql(self.opnd1), self.op, sql(self.opnd2))


class SQLAttr(SQLNode):

    def __init__(self, table, name):
        self.table = table
        self.name = name

    def __sql__(self):
        return "%s.%s" % (sql(self.table), self.name)


class SQLSelect(SQLNode):

    def __init__(self, targets):
        self.targets = targets
        self.where_clause = None

    def where(self, expr):
        self.where_clause = expr
        return self

    def __sql__(self):
        result = "SELECT %s" % ", ".join([sql(target) for target in self.targets])
        if self.where_clause:
            result = "%s WHERE %s" % (result, sql(self.where_clause))
        return result


def sql(expr):
    if isinstance(expr, SQLNode):
        return expr.__sql__()
    elif isinstance(expr, str):
        return "'%s'" % expr.replace("'", "''")
    else:
        return str(expr)


def select(*targets):
    return SQLSelect(targets)

#-----------------------------------------------------------------

dishes = Table("dishes")
customers = Table("customers")
orders = Table("orders")

query = select(customers.name, dishes.price, orders.amount).where(
    customers.cust_id == orders.cust_id and orders.dish_id == dishes.dish_id
    and dishes.name == "Spam, Eggs, Sausages and Spam")

print repr(query)
print sql(query)

예제 2 출력

<__main__.SQLSelect object at 0x1cc830>
SELECT customers.name, dishes.price, orders.amount WHERE
(((customers.cust_id = orders.cust_id) AND (orders.dish_id =
dishes.dish_id)) AND (dishes.name = 'Spam, Eggs, Sausages and Spam'))