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 연산자에 사용자 정의 의미를 부여하는 것이 자연스러운 응용 분야가 많으며, 그중 일부에서는 불리언 연산자가 사용자 정의 가능한 연산자에서 제외되어 있다는 점이 불편할 수 있습니다. 예는 다음과 같습니다:
- NumPy에서는 거의 모든 연산자가 배열에 대해 정의되어 대응하는 요소끼리 적절한 연산을 수행하고 그 결과의 배열을 반환합니다. 일관성을 생각하면 두 배열 사이의 불리언 연산도 불리언 배열을 반환하리라 기대하겠지만, 현재는 이것이 불가능합니다.
이러한 종류의 확장에는 선례가 있습니다. 비교 연산자는 원래 불리언 결과만 반환하도록 제한되어 있었으나, NumPy 배열의 비교가 불리언 배열을 반환할 수 있도록 풍부한 비교(rich comparison)가 추가되었습니다.
- 기호 대수 시스템: 이 시스템에서는 Python 표현식을 특정 환경에서 평가하면, 그 결과로 표현식의 구조에 대응하는 객체 트리가 구성됩니다.
- 관계형 데이터베이스 인터페이스: 이 인터페이스에서는 Python 표현식을 사용하여 SQL 쿼리를 구성합니다.
흔히 제안되는 우회 방법은 ‘and’, ‘or’, ‘not’ 대신 비트 연산자 ‘&’, ‘|’, ‘~’를 사용하는 것이지만, 여기에는 몇 가지 단점이 있습니다:
- 이 연산자들은 다른 연산자에 대한 우선순위가 다르며, 이미 다른 용도로 사용되고 있을 수도 있습니다(예 1에서처럼).
- 사용자가 표현하려는 바에 대해 가장 명백한 문법이 아닌 다른 것을 사용하도록 강제하는 것은 미적으로 불쾌합니다. 불리언 연산이 SQL 쿼리의 핵심 요소임을 고려하면, 이 문제는 예 3의 경우에 특히 심각합니다.
- 비트 연산자는 암묵적인 ‘and’ 연산을 포함하는 ‘a < b < c’ 같은 연쇄 비교 문제에 대한 해결책이 되지 못합니다. 현재 이러한 표현식은 비교 결과를 일반적인 불리언 의미 체계를 가진 것으로 취급할 수 없는 NumPy 배열 같은 데이터 타입에서는 전혀 사용할 수 없습니다. 이런 경우 (a < b) & (b < c)와 같은 형태로 풀어 써야 하므로 명료성이 상당히 떨어집니다.
근거
불리언 연산자를 사용자 정의할 수 있게 하는 문제를 성공적으로 해결하기 위한 요구 사항은 다음과 같습니다:
- 기본 경우(사용자 정의가 없는 경우)에는 기존의 단락 평가 의미 체계가 유지되어야 합니다.
- 기본 경우에 눈에 띄는 속도 저하가 없어야 합니다.
- 이상적으로는, 사용자 정의 메커니즘이 객체의 재량에 따라 단락 평가 또는 비단락 평가 의미 체계 중 하나를 제공할 수 있도록 해야 합니다.
이전에 제안된 적이 있는 한 가지 분명한 전략은 특수 메서드에 첫 번째 인자와, 두 번째 인자를 평가하는 함수를 전달하는 것입니다. 이 방식은 요구 사항 1과 3은 충족하지만 요구 사항 2는 충족하지 못합니다. 모든 불리언 연산마다 함수 객체를 구성하는 오버헤드와, 경우에 따라서는 Python 함수 호출의 오버헤드까지 발생하기 때문입니다. 따라서 여기서는 이 방법을 더 이상 고려하지 않습니다.
다음 절에서는 세 가지 요구 사항을 모두 충족하는 전략을 제안합니다. 이 전략의 prototype implementation을 내려받을 수 있습니다.
명세
특수 메서드
Python 수준에서 객체는 다음과 같은 특수 메서드를 정의할 수 있습니다.
| 단항 | 이항, 1단계 | 이항, 2단계 |
|---|---|---|
|
|
|
__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'))
Copyright
This document has been placed in the public domain.