Scribbling

Python: Attribute Descriptors (속성 디스크립터) 본문

Computer Science/Python

Python: Attribute Descriptors (속성 디스크립터)

focalpoint 2022. 6. 17. 16:07

 

디스크립터를 이요하면 여러 속성에 대한 동일한 접근 논리를 재사용 가능하다. 디스크립터는 __get__(), __set__(), __delete__() 메서드로 구성된 프로토콜을 구현하는 클래스다. property 클래스는 디스크립터 프로토콜을 완벽히 구현한다.

아래 글에서 동적 속성을 구현함에 있어 프로퍼티 팩토리를 이용했었다.

https://focalpoint.tistory.com/314

 

Python: Dynamic attributes and properties (동적 속성과 프로퍼티)

파이썬에서는 데이터 속성과 메서드를 통틀어 속성이라고 한다. 메서드는 단지 호출가능한 속성이다. 프로퍼티를 사용하면 클래스인터페이스를 변경하지 않고도 공개 데이터 속성을 접근자 메

focalpoint.tistory.com

이러한 논리를 객체지향적 방식으로 구현하는 것이 바로 디스크립터이다. 

class Quantity:

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

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


class LineItem:

    weight = Quantity('weight')
    price = Quantity('price')

    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight
        self.price = price

    def subtotal(self):
        return self.weight * self.price

product1 = LineItem('cereal', 10, 0.95)
print(product1.subtotal())
product1.weight = -1
print(product1.subtotal())

위의 코드에서는 Quantity() 및 LineItem이 같은 문자열의 weight 속성을 가지므로, __get__() method가 필요없다.

다만 아직까지도 아래 부분이 비효율적이다. 이번엔 이를 개선해보자.

    weight = Quantity('weight')
    price = Quantity('price')

 

class Quantity:
    __counter = 0

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

    def __get__(self, instance, owner):
        return getattr(instance, self.name)

    def __set__(self, instance, value):
        if value > 0:
            setattr(instance, self.name, value)
        else:
            raise ValueError('value must be > 0')


class LineItem:

    weight = Quantity()
    price = Quantity()

    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight
        self.price = price

    def subtotal(self):
        return self.weight * self.price

product1 = LineItem('cereal', 10, 0.95)
print(product1.subtotal())
product1.weight = 1
print(product1.subtotal())

위의 코드에서 getattr() 및 setattr() 고수준 내장 함수를 사용가능한 것은, 관리 대상 속성과 저장소 속성의 이름이 다르기 때문이다. (만약 같다면 무한 재귀가 발생할 것이다)

* 참고로 __get__ method의 owner는 관리 대상 클라스 (LineItem)에 대한 참조이다. 

 

현재는 클래스를 통해 속성에 접근하면 아래와 같은 에러가 발생한다. 이렇게 두기 보다는 __get__() method가 이 경우 디스크립터 객체를 반환하도록 하는것이 더 좋다.

print(LineItem.weight)

 

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

 

지금까지 구현한 Quantity 디스크립터는 아주 잘 작동하지만, _Quantity#0 등의 자동 생성된 저장소명을 사용한다는 것이 단점이다. 이를 해결하려면 클래스 데커레이터나 메타클래스가 필요하다.

 

다양한 속성 검증을 위한 디스크립터 확장하기

지금까지 구현한 Quantity 디스크립터 외에 description 속성을 처리하기 위한 NonBlank 디스크립터를 추가 구현하자. Nonblank 디스크립터는 description이 공백이 아닌지 체크한다.

import abc

class AutoStorage:
    __counter = 0

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

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

    def __set__(self, instance, value):
        setattr(instance, self.name, value)

class Validated(abc.ABC, AutoStorage):

    def __set__(self, instance, value):
        value = self.validate(instance, value)
        super().__set__(instance, value)

    @abc.abstractmethod
    def validate(self, instance, value):
        ''' 검증 값을 반환하거나 raise ValuError'''

class Quantity(Validated):

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

class NonBlank(Validated):

    def validate(self, instance, value):
        value = value.strip()
        if not value:
            raise ValueError('value cannot be empty')
        return value

class LineItem:
    description = NonBlank()
    weight = Quantity()
    price = Quantity()

    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight
        self.price = price

    def subtotal(self):
        return self.weight * self.price

product1 = LineItem('cereal', 10, 0.95)
print(product1.subtotal())
product1.weight = 1
print(product1.subtotal())

 

- 프로퍼티 팩토리는 간단하게 구현가능한 반면, 디스크립터는 융통성이 높다.

- 읽기 전용 디스크립터는 __set__()을 구현해야 한다. 읽기 전용 속성의 __set__() 메서드에서 AttributeError를 발생시켜야 한다.

- 검증 디스크립터는 __set__()만 사용할 수 있다. 값을 검증한 후 __dict__에 직접 설정해야 한다.