[Python] functools.total_ordering

DoonDoon·2020년 1월 25일
0
post-thumbnail

Photo by Debby Hudson on Unsplash

TL;DR;


  1. @total_ordering 데코레이터를 사용하면, 사용자 정의 객체끼리 비교하거나 정렬할 수 있다
  2. 파이썬의 사용자 정의 객체는, Dunder Method 로 정의된 비교 관련 함수가, 논리적으로 동작하기를 기대한다
  3. 이제 @total_ordering 데코레이팅 된 클래스가 무슨 의도인지 알 수 있다

Terminology


사용자 정의 객체?

말이 어려워서 그렇지 그냥 제가 작성한 class 입니다
물론 Python 은 거의 모든것이 값 객체이기 때문에 정확히는 class 만 지칭하는 것은 아니겠지만
편의상 그렇게 이해해 주시면 좋을 것 같습니다

Intro


파이썬 표현식 관련 문서를 읽던 중에, 값 비교 부분에서 아래와 같은 항목을 읽게 되었습니다

User-defined classes that customize their comparison behavior should follow some consistency rules, if possible:
Inverse comparison should result in the boolean negation. In other words, the following expressions should have the same result:

x == y and not x != y
x < y and not x >= y (for total ordering)
x > y and not x <= y (for total ordering)

The last two expressions apply to totally ordered collections (e.g. to sequences, but not to sets or mappings). See also the total_ordering() decorator.

읭? @total_ordering 어디서 본 것 같은데 기억을 더듬어 봅니다...
https://github.com/django/django/blob/master/django/contrib/gis/measure.py#L51

Django 에서 사용자 정의 필드를 만들고 싶어서 찾아보다가 이 소스코드를 봤었던 기억이 납니다
하지만.. total...? ordering...? 엥 뭐지... 하고 그냥 넘어갔져

그래서 이게 뭔데요

@total_ordering 을 알아보기 전에 파이썬 공식 문서에 나온 비교에 관한 진리표(?) 같은 것을 잠깐 살펴보아요

Equality comparison should be reflexive. In other words, identical objects should compare equal:

    x is y implies x == y

Comparison should be symmetric. In other words, the following expressions should have the same result:

    x == y and y == x
    x != y and y != x
    x < y and y > x
    x <= y and y >= x

Comparison should be transitive. The following (non-exhaustive) examples illustrate that:

    x > y and y > z implies x > z
    x < y and y <= z implies x < z

Inverse comparison should result in the boolean negation. In other words, the following expressions should have the same result:

    x == y and not x != y
    x < y and not x >= y (for total ordering)
    x > y and not x <= y (for total ordering)
    
User-defined classes that customize their comparison behavior should follow some consistency rules, if possible:
Inverse comparison should result in the boolean negation. In other words, the following expressions should have the same result:

위의 내용을 읽어보시면 비교에 관한 명제에 대해 역/이/대우가 성립해야 한다는 내용으로 구성됩니다. 따라서, 사용자 정의 객체(class) 가 비교 가능한 상태일 경우는 위의 내용을 만족하도록 작성해야 한다는 의미입니다.

사용자 정의 객체를 비교 가능하게 만들기

비교 가능한 사용자 정의 객체라는게 무엇인지는 코드로 설명드릴게요
(이 글을 읽고 계시는 분들이라면 당연히 알고 계신다고 생각하지만요 ㅇㅅㅇ)

간단하게, Account 라는 계좌 객체가 있고 각 계좌간의 잔액을 비교할 수 있는 사용자 정의 객체 입니다

class Account:
    
    def __init__(self, initial_deposit: int):
        self.balance = initial_deposit
        
    def __lt__(self, other: 'Account'):
        # NOTE: 'lt' is a abbreviation of 'Less than' which is equals to operator symbol '<'
        if isinstance(other, self.__class__):
            return self.balance < other.balance
        raise NotImplemented  # Account 가 아닌 객체와는 비교하지 않습니다 (구현 안됨)
    
    def __gt__(self, other: 'Account'):
        # NOTE: 'gt' is a abbreviation of 'Greater than' which is equals to operator symbol '>'
        if isinstance(other, self.__class__):
            return self.balance > other.balance
        raise NotImplemented  # Account 가 아닌 객체와는 비교하지 않습니다 (구현 안됨)
        
    def __eq__(self, other: 'Account'):
        if isinstance(other, self.__class__):
            return self.balance == other.balance
        raise NotImplemented  # Account 가 아닌 객체와는 비교하지 않습니다 (구현 안됨)
        
    def __repr__(self):
        return f'Account balance: {self.balance}'

balance_10000 = Account(10000)
balance_20000 = Account(20000)
print(balance_10000 < balance_20000)  # True
print(balance_10000 > balance_20000)  # False
print(balance_10000 == balance_20000)  # False
print(balance_10000 != balance_20000)  # True
print(balance_10000 >= balance_20000)  # TypeError: '>=' not supported between instances of 'Account' and 'Account'

위 예제 코드에서 저는 __lt__(), __gt__() 만을 정의했기 때문에,
>= 연산자에 해당하는 메서드인 __ge__() 가 없어서 4번째 비교는 실패했습니다

만약 <=, >= 연산도 가능하게 하고 싶으시다면 추가로 __le__(), __ge__() 를 정의하시면 됩니다. 지금은 사용자 정의 객체가 비교 가능하게 만드는 법을 배우고 있으니 구냥 넘어갈게요

추가로, __ne__() 를 오버라이딩 하지 않았음에도 4번째의 != 연산이 가능한건
Account 클래스의 조상인 object 에서 __ne__() 를 다음과 같이 정의하고 있기 때문입니다
https://github.com/python/cpython/blob/master/Objects/typeobject.c#L3887
(미래에 이 라인번호가 아니라면... object_richcompare() 로 찾아가세요!)

내용은 __eq__() 의 결과를 뒤집어서 표현한다는 내용이에요. 만약 __eq__() 가 NotImplemented 라면 똑같이 에러를 뱉습니다
(By default, __ne__() delegates to __eq__() and inverts the result, unless the latter returns NotImplemented.)

근데 저걸 언제 다 맨두러요...


그르게요... 바로 위에 적은것처럼 __ne__()__eq__() 뒤집어서 쓰듯이 하나만 맨들면
알아서 진리표처럼 지가 챡챡 해주면 참 좋겠는데요이...!?

그럴때 쓰는게 @total_ordering 입니다 어-예

@total_ordering 으로 데코레이팅 된 클래스는 __lt__(), __gt__(), __le__(), __ge__() 중 하나만 정의하고, __eq__() 만 정의하면 나머지 비교 연산자도 모두 사용 가능합니다.

그럼 위에서 만든 클래스를 바꿔볼까요?

from functools import total_ordering


@total_ordering
class Account:
    
    def __init__(self, initial_deposit: int):
        self.balance = initial_deposit
        
    def __lt__(self, other: 'Account'):
        # NOTE: 'lt' is a abbreviation of 'Less than' which is equals to operator symbol '<'
        if isinstance(other, self.__class__):
            return self.balance < other.balance
        raise NotImplemented  # Account 가 아닌 객체와는 비교하지 않습니다 (구현 안됨)

    def __eq__(self, other: 'Account'):
        if isinstance(other, self.__class__):
            return self.balance == other.balance
        raise NotImplemented  # Account 가 아닌 객체와는 비교하지 않습니다 (구현 안됨)
        
    def __repr__(self):
        return f'Account balance: {self.balance}'

balance_10000 = Account(10000)
balance_20000 = Account(20000)
print(balance_10000 < balance_20000)  # True
print(balance_10000 <= balance_20000)  # True
print(balance_10000 != balance_20000)  # True

print(balance_10000 > balance_20000)  # False
print(balance_10000 >= balance_20000)  # False
print(balance_10000 == balance_20000)  # False

실행결과를 보시면, TypeError 없이 정상적으로 모든 비교가 가능한것을 확인할 수 있습니다
더불어 비교가 가능하니까 정렬도 가능하겠군요?

from random import shuffle

balance_1000 = Account(1000)
balance_1500 = Account(1500)
balance_2000 = Account(2000)
balance_2500 = Account(2500)
balance_3000 = Account(3000)

# 정렬되지 않은 채로 배열 생성
balances = [balance_2000, balance_1500, balance_2500, balance_3000, balance_1000]
print(balances)  # [Account balance: 2000, Account balance: 1500, Account balance: 2500, Account balance: 3000, Account balance: 1000]

# 잔액 내림차순 정렬 (잔액 많은 순)
balances.sort(reverse=True) 
print(balances)  # [Account balance: 3000, Account balance: 2500, Account balance: 2000, Account balance: 1500, Account balance: 1000]

정렬도 잘 됩니다이 오예 👻

사족


처음 functools 문서를 읽을때는 배경지식 없이 (total_ordering 이 어떻게 쓰이는지) 그냥 찾아보니까 문서가 안읽혔는데, 비교연산자 → total_ordering → 문서 이렇게 되니까 갑자기 잘 읽히고 사용하는 방법도 눈에 들어오더라구요. 역시 개발자는 일이 안되면 집에가고 내일 해야 합니다...

아무튼 이 데코레이터에 관심있으셨던 분께 도움이 되었으면 좋겠네요. 감사합니다 🙇‍♂️

profile
Espresso Cream Cat DD

0개의 댓글