Computer Engineering/Fluent Python 정리

Fluent Python Chapter 20. 속성 디스크립터

jordan.bae 2022. 4. 17. 00:38

Introduction

20장은 속성 디스크립터 입니다. 디스크립터는 객체의 속성의 접근 및 관리를 위한 __get__(), __set__(), __delete__() 메서드로 구성된 프로토콜을 구현한 클래스입니다. 19장에서는 디스크립터 프로토콜을 구현한 property 클래스로 객체 속성을 생성하고 접근했었습니다. 20장에서는 직접 descriptor클래스를 만들어 객체의 속성을 만들 때 사용합니다. 이런 부분은 Django, SQLAlchemy의 모델 부분의 column 속성에서도 사용된 부분으로 관련 프레임워크를 만들거나 공부하고 있는 분들께도 공부하시면 도움이 될 것 같습니다. 책 시작말에 있는 파이썬 핵심 개발자인 레이몬드 헤팅거가 말한 것 처럼 디스크립터에 대해 배우면 더욱 다양한 도구에 접근할 수 있고, 파이썬의 작동 방식을 이해하는데 큰 도움을 주는 것 같습니다.

책 정리를 시작한 이유

책 정리를 시작한 이유

Chapter1의 Introduction 부분에서 이야기 한 것처럼 지난 5년간 다양한 언어나 프레임워크 및 프로그램을 공부하고 이용하여 소프트웨어를 개발했는데 이것저것 하다 보니 자주 사용하는 언어임에도 불구하고 파이썬을 잘 활용하고 있느냐에 대한 답변을 자신 있게 하기 어렵다고 느껴서 Fluent Python이라는 책을 공부하며 정리하고 있습니다. 올해에는 새로운 기술들도 좋지만 기존에 활용하던 언어나 프레임워크 그리고 소프트웨어를 더 잘 사용할 수 있도록 깊게 공부할 수 있는 한 해를 만들고 싶은 소망을 가지고 있습니다. 21장까지 정리를 성공하고 맛있는 걸 먹으면서 스스로 축하할 날이 어서 왔으면 좋겠네요! 혹시, 제가 정리한 글이 괜찮으시면 구독 하시면 다음편이 나오면 바로 알림을 받으실 수 있습니다. 어느 덧 20장을 정리하는 날이 왔네요. 마지막 2장!! 예상한 것 보다는 많이 늦었지만 조금 만 더 힘내봐야겠습니다.

 

지난 chapter 정리한 포스팅

Fluent Python Chapter 1. 파이썬 데이터 모델 (Feat. 일관성 있는 언어)

Fluent Python Chapter 2-1. Sequence (Sequence의 분류, listcomp, generator, tuple, slicing

Fluent Python Chapter 2-2. Sequence (bisect, deque, memoryview)

Fluent Python Chapter 3. Dictionary (feat. hashtable)

Fluent Python Chapter 4. 텍스트와 바이트 (feat. 깨지지마라..)

Fluent Python Chapter 5. 일급 함수

Fluent Python Chapter 6. 일급 함수 디자인 패턴

Fluent Python Chapter 7. 함수 데커레이터와 클로저 (feat. 메타프로그래밍)

Fluent Python Chapter 8. 객체 잠조, 가변성, 재활용

Fluent Python Chapter 9. 파이썬스러운 객체

Fluent Python Chapter 10. 시퀀스 해킹, 해시, 슬라이스

Fluent Python Chapter 11. 인터페이스: 프로토콜에서 ABC까지

Fluent Python Chapter 12. 내장 자료형 상속과 다중 상속

Fluent Python Chapter 13. 연산자 오버로딩(feat. 제대로 하기)

Fluent Python Chapter 14. 반복형, 반복자, 제너레이터

Fluent Python Chapter 15. Context manager와 else 블록

Fluent Python Chapter 16. 코루틴 (Coroutine)

Fluent Python Chapter 17. Future를 이용한 동시성

Fluent Python Chapter 18. asyncio를 이용한 동시성

Fluent Python Chapter 19. 동적 속성과 프로퍼티

 

 

 

속성 검증 디스크립터

19장에서는 속성을 검증하기 위해서 property setter를 정의했다. 여러 속성에 적용하기 위해서 팩토리 함수를 만들었었다. 기억을 되살리기 위해서 코드를 다시 가지고 왔다.

 

# BEGIN LINEITEM_V2_PROP_FACTORY_FUNCTION
def quantity(storage_name):  # <1>

    def qty_getter(instance):  # <2>
        return instance.__dict__[storage_name]  # <3>

    def qty_setter(instance, value):  # <4>
        if value > 0:
            instance.__dict__[storage_name] = value  # <5>
        else:
            raise ValueError('value must be > 0')

    return property(qty_getter, qty_setter)  # <6>
# END LINEITEM_V2_PROP_FACTORY_FUNCTION


# BEGIN LINEITEM_V2_PROP_CLASS
class LineItem:
    weight = quantity('weight')  # <1>
    price = quantity('price')  # <2>

    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight  # <3>
        self.price = price

    def subtotal(self):
        return self.weight * self.price  # <4>

위에 처럼 property를 생성하는 팩토리 함수를 만들 수 있지만 이번에는 디스크립터 클래스를 만든다. 두 가지 방법의 비교는 나중에 이야기해본다. 디스크립터 클래스는 위에서 얘기한 것 처럼 __get__(), __set__(), __delete__() 메서드를 구현하는 클래스가 디스크립터 클래스고 여러 프로토콜 처럼 일부만 구현해서 사용해도 된다. 디스크립터 클래스의 객체는 다른 클래스의 클래스 속성으로 정의해서 사용한다.

 

예제 코드는 Quantity라는 디스크립터 클래스 그리고 관리 대상 클래스인 LineItem 그리고 LineItem의 속성인 관리 대상 속성인 description, weight, price가 있다. 디스크립터의 클래스의 객체는 관리 대상 클래스의 속성으로 관리 대상 클래스의 객체의 속성을 접근하고, 생성하고, 삭제할 때 사용된다.(디스크립터 클래스의 객체가 관리 대상 클래스의 속성인 부분이 중요하다.)   코드는 아래와 같다.

 

class Quantity:
    def __init__(self, storage_name):
        self.storage_name = storage_name
    
    def __set__(self, instance, value):
        if value > 0:
            instance.__dict__[self.storage_name] = value
        else:
            raise ValueError('value must be > 0')
            

class LineItem:
    weight = Quantity('weight')  # <1>
    price = Quantity('price')  # <2>

    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight  # <3>
        self.price = price

    def subtotal(self):
        return self.weight * self.price  # <4>
        

item = LineItem('test', 1, 1)

print(item.__dict__)
{'description': 'test', 'weight': 1, 'price': 1}

item = LineItem('test', 1, -1)

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Input In [8], in <module>
----> 1 item = LineItem('test', 1, -1)

Input In [5], in LineItem.__init__(self, description, weight, price)
     17 self.description = description
     18 self.weight = weight  # <3>
---> 19 self.price = price

Input In [5], in Quantity.__set__(self, instance, value)
      7     instance.__dict__[self.storage_name] = value
      8 else:
----> 9     raise ValueError('value must be > 0')

ValueError: value must be > 0

위의 동작을 통해서 파이썬이 객체의 속성을 초기화 할 때 클래스 속성을 통해서 디스크립터의 __set__() 메서드가 실행된다는 것을 알 수 있다. 클래스의 속성이 없으면 그냥 주입한 값이 바로 들어갈 것이다. 

 

이 부분을 보면 Django의 model class가 생각날 수 있다. 이제 모델 클래스의 각 객체가 각 디스크립터 클래스에 정의 된대로 동작한다는 것을 이해할 수 있다.

 

class AnalyticsEvent(models.Model):
    user_id = models.IntegerField()
    event_name = models.CharField(max_length=64)
    created_at = models.DateTimeField()

 

디스크립터 클래스와 VS property 팩토리 함수

책에서 재미있게 읽었던 부분 중 하나다.

첫 번째 포인트는 쉽게 생각해볼 수 있는 부분인데 디스크립터 클래스는 상속을 이용해서 확장할 수 있다는 부분이다.

두 번째 포인트는 함수 속성과 클로저에 상태를 저장하는 것보다 클래스의 객체 속성에 저장하는 것이 더 간단하다.  사실 두 번째 부분은 클로저에 대한 개념만 이해하면 크게 상관이 없을 것 같다. 

이 내용과 별개로 이런 생각이나 시각을 가지고 있으면 코드 리뷰할 때도 도움이 많이 될 수 있겠다는 생각을 했다.

 

디스크립터 클래스 확장

책에서 저자는 위에서 이야기한 부분을 바로 클래스를 상속해서 확장하는 예제로 의견에 대한 증거를 보여줬다.

description 속성도 빈 값을 체크해야 하는 requirements가 생겼을 때 우리는 추상화를 통해서 코드를 재사용할 수 있다.

 

import abc


class AutoStorage:  # <1>
    __counter = 0

    def __init__(self):
        cls = self.__class__
        prefix = cls.__name__
        index = cls.__counter
        self.storage_name = '_{}#{}'.format(prefix, index)
        cls.__counter += 1

    def __get__(self, instance, owner):
        if instance is None:
            return self
        else:
            return getattr(instance, self.storage_name)

    def __set__(self, instance, value):
        setattr(instance, self.storage_name, value)  # <2>


class Validated(abc.ABC, AutoStorage):  # <3>

    def __set__(self, instance, value):
        value = self.validate(instance, value)  # <4>
        super().__set__(instance, value)  # <5>

    @abc.abstractmethod
    def validate(self, instance, value):  # <6>
        """return validated value or raise ValueError"""


class Quantity(Validated):  # <7>
    """a number greater than zero"""

    def validate(self, instance, value):
        if value <= 0:
            raise ValueError('value must be > 0')
        return value


class NonBlank(Validated):
    """a string with at least one non-space character"""

    def validate(self, instance, value):
        value = value.strip()
        if len(value) == 0:
            raise ValueError('value cannot be empty or blank')
        return value  # <8>

 

오버라이딩, 논오버라이딩 디스크립터

파이썬이 속성을 처리하는 방식에는 비대칭성이 있다.

객체의 속성을 읽을 때는 객체의 속성을 먼저 체크하고 존재하지 않으면 class의 속성을 체크한다. 하지만, 할당할 때는 객체에 값을 할당하고 클래스에는 전혀 영향을 주지 않는다. 이 부분의 영향으로 디스크립터 또한 비대칭성을 가지고 있다. 오버라이딩 디스크립터는 __set__()메서드를 가지고 있는 디스크립터 클래스를 의미하고 디스크립터는 객체 속성에 바로 값을 할당하려는 것을 가로채서 자신의__set__()메서드를 사용하여 할당한다. 이 때는 객체를 접근할 때 descriptor의 __get__()메서드가 있으면 먼저 체크하고, 없으면 객체의 속성(obj.__dict__[key])를 체크한다. 하지만, 논오버라이딩(__set__()메서드가 없을 때)는 객체 속성을 먼저 체크하게 된다...(이럴 땐 일관성이 있는 언어가 맞나 싶다...)

 

결론적으로 overriding 디스크립터일 때는 디스크립터의 __get__()메서드 다음 객체의 속성에 접근하고, non-overriding 디스크립터인 경우에는 객체의 속성에 접근 후 없으면 디스크립터의 __get__()메서드에 접근한다.

 

class Overriding:
    def __set__(self, instance, value):
        print('Overriding set')

    def __get__(self, instance, value):
        print('Overriding get')
        
class NonOverriding:
    def __get__(self, instance, value):
        print('NonOverriding get')
        
class Managed:
    over = Overriding()
    non_over = NonOverriding()
    
    def spam(self):
        print('spam')
        
obj = Managed()

# overriding -> check descriptor -> instance
obj.over = 10
obj.over

Overriding set
Overriding get

# non overriding -> check instance -> descriptor
obj.non_over = 10
obj.non_over
10

del obj.non_over
obj.non_over
NonOverriding get

 

 

메서드도 디스크립터

아래 코드를 보면 함수는 논오버라이딩 디스크립터라는 것을 알 수 있다.

obj.spam
<bound method Managed.spam of <__main__.Managed object at 0x114b7f460>>

obj.__class__.spam
<function __main__.Managed.spam(self)>

obj.spam = 7
obj.spam
7

 

 

 

디스크립터 팁

책에서 나온 내용과 개인적인 의견을 조금 섞어서 몇가지만 정리해보면 아래와 같다.

 

- 특별히 여러 객체의 속성을 커스터마이징 하는게 아니면 @property를 사용한다.

- non-overriding __get__() 메서드를 캐시로 사용할 수 있다. 객체 속성이 없을 때만 __get__()메서드가 수행되서 객체의 속성을 채우면 된다.

- 읽기 전용 디스크립터는 __set__()을 구현해야 한다.구현 안하면 non-overriding이 되어서 객체 속성에 가려진다.

- 검증 디스크립터는 __set__()만 사용할 수 있다. __get__() 을 구현하지 않고 self.__dict__[key]에 바로 저장해서 __get__()메서드를 거치지 않을 수 있다.

 

 

정리

20장에서는 디스크립터 클래스를 사용해서 객체의 속성을 다루는 법을 살펴봤다. 디스크립터 클래스가 property 팩토리 함수보다 나은 장점도 살펴봤다. 개인적으로는 장고 ORM 클래스 코드를 읽을 때 도움이 될 것 같아서 기대된다.

 

 

Reference

- Fluent Python Chapter 20

- https://github.com/fluentpython/example-code/tree/master/20-descriptor/bulkfood

반응형