Descriptor 학습

Beginner

This tutorial is from open-source community. Access the source code

소개

이 랩에서는 객체의 속성 접근을 사용자 정의하는 강력한 메커니즘인 Python 의 descriptor 에 대해 배우게 됩니다. Descriptor 를 사용하면 속성 접근, 설정 및 삭제 방법을 정의하여 객체 동작을 제어하고 유효성 검사 로직을 구현할 수 있습니다.

이 랩의 목표는 descriptor 프로토콜 이해, 사용자 정의 descriptor 생성 및 사용, descriptor 를 사용한 데이터 유효성 검사 구현, 그리고 descriptor 구현 최적화를 포함합니다. 랩 과정에서 descrip.py, stock.py, 그리고 validate.py를 포함한 여러 파일을 생성하게 됩니다.

이것은 가이드 실험입니다. 학습과 실습을 돕기 위한 단계별 지침을 제공합니다.각 단계를 완료하고 실무 경험을 쌓기 위해 지침을 주의 깊게 따르세요. 과거 데이터에 따르면, 이것은 초급 레벨의 실험이며 완료율은 91%입니다.학습자들로부터 100%의 긍정적인 리뷰율을 받았습니다.

Descriptor 프로토콜 이해

이 단계에서는 간단한 Stock 클래스를 생성하여 Python 에서 descriptor 가 어떻게 작동하는지 배우겠습니다. Python 의 descriptor 는 속성 접근, 설정 및 삭제 방식을 사용자 정의할 수 있는 강력한 기능입니다. Descriptor 프로토콜은 __get__(), __set__(), 그리고 __delete__()의 세 가지 특수 메서드로 구성됩니다. 이 메서드는 각각 속성에 접근, 값을 할당 또는 삭제할 때 descriptor 가 어떻게 동작하는지 정의합니다.

먼저, 프로젝트 디렉토리에 stock.py라는 새 파일을 생성해야 합니다. 이 파일에는 Stock 클래스가 포함됩니다. 다음은 stock.py 파일에 넣어야 할 코드입니다.

## stock.py

class Stock:
    __slots__ = ['_name', '_shares', '_price']

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

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        if not isinstance(value, str):
            raise TypeError('Expected a string')
        self._name = value

    @property
    def shares(self):
        return self._shares

    @shares.setter
    def shares(self, value):
        if not isinstance(value, int):
            raise TypeError('Expected an integer')
        if value < 0:
            raise ValueError('Expected a positive value')
        self._shares = value

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError('Expected a number')
        if value < 0:
            raise ValueError('Expected a positive value')
        self._price = value

    def cost(self):
        return self.shares * self.price

    def sell(self, amount):
        self.shares -= amount

    def __repr__(self):
        return f'Stock({self.name!r}, {self.shares!r}, {self.price!r})'

Stock 클래스에서는 property 데코레이터를 사용하여 name, shares, 그리고 price 속성에 대한 getter 및 setter 메서드를 정의하고 있습니다. 이러한 getter 및 setter 메서드는 descriptor 역할을 하며, 이는 이러한 속성에 접근하고 설정하는 방식을 제어한다는 의미입니다. 예를 들어, setter 메서드는 입력 값이 올바른 유형이고 허용 가능한 범위 내에 있는지 확인하기 위해 입력 값을 검증합니다.

이제 stock.py 파일이 준비되었으므로, Python 셸을 열어 Stock 클래스를 실험하고 descriptor 가 실제로 어떻게 작동하는지 살펴보겠습니다. 이렇게 하려면 터미널을 열고 다음 명령을 실행합니다.

cd ~/project
python3 -i stock.py

python3 명령의 -i 옵션은 stock.py 파일을 실행한 후 대화형 셸을 시작하도록 Python 에 지시합니다. 이렇게 하면 방금 정의한 Stock 클래스와 직접 상호 작용할 수 있습니다.

Python 셸에서 주식 객체를 생성하고 해당 속성에 접근해 보겠습니다. 다음은 그 방법입니다.

s = Stock('GOOG', 100, 490.10)
s.name      ## Should return 'GOOG'
s.shares    ## Should return 100

s 객체의 nameshares 속성에 접근하면 Python 은 실제로 descriptor 의 __get__ 메서드를 내부적으로 사용합니다. 클래스의 property 데코레이터는 descriptor 를 사용하여 구현되므로, 속성의 접근 및 할당을 제어된 방식으로 처리합니다.

클래스 사전을 자세히 살펴보고 descriptor 객체를 살펴보겠습니다. 클래스 사전에는 클래스에 정의된 모든 속성과 메서드가 포함되어 있습니다. 다음 코드를 사용하여 클래스 사전의 키를 볼 수 있습니다.

Stock.__dict__.keys()

다음과 유사한 출력을 볼 수 있습니다.

dict_keys(['__module__', '__slots__', '__init__', 'name', '_name', 'shares', '_shares', 'price', '_price', 'cost', 'sell', '__repr__', '__doc__'])

name, shares, 그리고 price 키는 property 데코레이터에 의해 생성된 descriptor 객체를 나타냅니다.

이제 descriptor 가 어떻게 작동하는지 수동으로 메서드를 호출하여 살펴보겠습니다. shares descriptor 를 예로 사용하겠습니다. 다음은 그 방법입니다.

## Get the descriptor object for 'shares'
q = Stock.__dict__['shares']

## Use the __get__ method to retrieve the value
q.__get__(s, Stock)  ## Should return 100

## Use the __set__ method to set a new value
q.__set__(s, 75)
s.shares  ## Should now be 75

## Try to set an invalid value
try:
    q.__set__(s, '75')  ## Should raise TypeError
except TypeError as e:
    print(f"Error: {e}")

s.shares와 같은 속성에 접근하면 Python 은 descriptor 의 __get__ 메서드를 호출하여 값을 검색합니다. s.shares = 75와 같이 값을 할당하면 Python 은 descriptor 의 __set__ 메서드를 호출합니다. 그러면 descriptor 는 데이터를 검증하고 입력 값이 유효하지 않은 경우 오류를 발생시킬 수 있습니다.

Stock 클래스와 descriptor 를 실험하는 것을 마치면 다음 명령을 실행하여 Python 셸을 종료할 수 있습니다.

exit()

사용자 정의 Descriptor 생성

이 단계에서는 자체 descriptor 클래스를 생성합니다. 하지만 먼저 descriptor 가 무엇인지 이해해 보겠습니다. Descriptor 는 __get__, __set__, 그리고 __delete__ 메서드로 구성된 descriptor 프로토콜을 구현하는 Python 객체입니다. 이러한 메서드를 통해 descriptor 는 속성 접근, 설정 및 삭제 방식을 관리할 수 있습니다. 자체 descriptor 클래스를 생성함으로써 이 프로토콜이 어떻게 작동하는지 더 잘 이해할 수 있습니다.

프로젝트 디렉토리에 descrip.py라는 새 파일을 생성합니다. 이 파일에는 사용자 정의 descriptor 클래스가 포함됩니다. 다음은 코드입니다.

## descrip.py

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

    def __get__(self, instance, cls):
        print(f'{self.name}:__get__')
        ## In a real descriptor, you would return a value here

    def __set__(self, instance, value):
        print(f'{self.name}:__set__ {value}')
        ## In a real descriptor, you would store the value here

    def __delete__(self, instance):
        print(f'{self.name}:__delete__')
        ## In a real descriptor, you would delete the value here

Descriptor 클래스에서 __init__ 메서드는 descriptor 를 이름으로 초기화합니다. __get__ 메서드는 속성에 접근할 때 호출되고, __set__ 메서드는 속성이 설정될 때 호출되며, __delete__ 메서드는 속성이 삭제될 때 호출됩니다.

이제 사용자 정의 descriptor 를 실험하기 위한 테스트 파일을 생성해 보겠습니다. 이를 통해 descriptor 가 다양한 시나리오에서 어떻게 동작하는지 확인할 수 있습니다. 다음 코드를 사용하여 test_descrip.py라는 파일을 생성합니다.

## test_descrip.py

from descrip import Descriptor

class Foo:
    a = Descriptor('a')
    b = Descriptor('b')
    c = Descriptor('c')

## Create an instance and try accessing the attributes
if __name__ == '__main__':
    f = Foo()
    print("Accessing attribute f.a:")
    f.a

    print("\nAccessing attribute f.b:")
    f.b

    print("\nSetting attribute f.a = 23:")
    f.a = 23

    print("\nDeleting attribute f.a:")
    del f.a

test_descrip.py 파일에서 descrip.py에서 Descriptor 클래스를 가져옵니다. 그런 다음 세 개의 속성 a, b, 그리고 c를 가진 Foo 클래스를 생성하며, 각 속성은 descriptor 에 의해 관리됩니다. Foo의 인스턴스를 생성하고 속성 접근, 설정 및 삭제와 같은 작업을 수행하여 descriptor 메서드가 어떻게 호출되는지 확인합니다.

이제 이 테스트 파일을 실행하여 descriptor 가 작동하는 것을 살펴보겠습니다. 터미널을 열고 프로젝트 디렉토리로 이동한 다음 다음 명령을 사용하여 테스트 파일을 실행합니다.

cd ~/project
python3 test_descrip.py

다음과 같은 출력을 볼 수 있습니다.

Accessing attribute f.a:
a:__get__

Accessing attribute f.b:
b:__get__

Setting attribute f.a = 23:
a:__set__ 23

Deleting attribute f.a:
a:__delete__

보시다시피, descriptor 에 의해 관리되는 속성에 접근, 설정 또는 삭제할 때마다 해당 매직 메서드 (__get__, __set__, 또는 __delete__) 가 호출됩니다.

descriptor 를 대화형으로 살펴보겠습니다. 이를 통해 descriptor 를 실시간으로 테스트하고 결과를 즉시 확인할 수 있습니다. 터미널을 열고 프로젝트 디렉토리로 이동한 다음 descrip.py 파일을 사용하여 대화형 Python 세션을 시작합니다.

cd ~/project
python3 -i descrip.py

이제 대화형 Python 세션에서 다음 명령을 입력하여 descriptor 프로토콜이 어떻게 작동하는지 확인합니다.

class Foo:
    a = Descriptor('a')
    b = Descriptor('b')
    c = Descriptor('c')

f = Foo()
f.a         ## Should call __get__
f.b         ## Should call __get__
f.a = 23    ## Should call __set__
del f.a     ## Should call __delete__
exit()

여기서 핵심적인 통찰력은 descriptor 가 속성 접근을 가로채고 사용자 정의할 수 있는 방법을 제공한다는 것입니다. 이는 데이터 유효성 검사, 계산된 속성 및 기타 고급 동작을 구현하는 데 강력하게 해줍니다. Descriptor 를 사용하면 클래스 속성에 접근, 설정 및 삭제하는 방식을 더 잘 제어할 수 있습니다.

Descriptor 를 사용하여 유효성 검사 구현

이 단계에서는 descriptor 를 사용하여 유효성 검사 시스템을 만들 것입니다. 하지만 먼저 descriptor 가 무엇인지, 그리고 왜 사용하는지 이해해 보겠습니다. Descriptor 는 __get__, __set__, 또는 __delete__ 메서드를 포함하는 descriptor 프로토콜을 구현하는 Python 객체입니다. 이를 통해 객체에서 속성에 접근, 설정 또는 삭제하는 방식을 사용자 정의할 수 있습니다. 이 경우, descriptor 를 사용하여 데이터 무결성을 보장하는 유효성 검사 시스템을 만들 것입니다. 이는 객체에 저장된 데이터가 특정 유형이거나 양수 값과 같은 특정 기준을 항상 충족함을 의미합니다.

이제 유효성 검사 시스템을 만들기 시작해 보겠습니다. 프로젝트 디렉토리에 validate.py라는 새 파일을 생성합니다. 이 파일에는 유효성 검사기를 구현하는 클래스가 포함됩니다.

## validate.py

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

    @classmethod
    def check(cls, value):
        return value

    def __set__(self, instance, value):
        instance.__dict__[self.name] = self.check(value)


class String(Validator):
    expected_type = str

    @classmethod
    def check(cls, value):
        if not isinstance(value, cls.expected_type):
            raise TypeError(f'Expected {cls.expected_type}')
        return value


class PositiveInteger(Validator):
    expected_type = int

    @classmethod
    def check(cls, value):
        if not isinstance(value, cls.expected_type):
            raise TypeError(f'Expected {cls.expected_type}')
        if value < 0:
            raise ValueError('Expected a positive value')
        return value


class PositiveFloat(Validator):
    expected_type = float

    @classmethod
    def check(cls, value):
        if not isinstance(value, (int, float)):
            raise TypeError('Expected a number')
        if value < 0:
            raise ValueError('Expected a positive value')
        return float(value)

validate.py 파일에서 먼저 Validator라는 기본 클래스를 정의합니다. 이 클래스에는 유효성 검사 중인 속성을 식별하는 데 사용될 name 매개변수를 사용하는 __init__ 메서드가 있습니다. check 메서드는 단순히 전달된 값을 반환하는 클래스 메서드입니다. __set__ 메서드는 속성이 객체에 설정될 때 호출되는 descriptor 메서드입니다. check 메서드를 호출하여 값을 검증한 다음 검증된 값을 객체의 사전에 저장합니다.

그런 다음 Validator의 세 가지 하위 클래스인 String, PositiveInteger, 그리고 PositiveFloat를 정의합니다. 이러한 각 하위 클래스는 특정 유효성 검사를 수행하기 위해 check 메서드를 재정의합니다. String 클래스는 값이 문자열인지 확인하고, PositiveInteger 클래스는 값이 양의 정수인지 확인하며, PositiveFloat 클래스는 값이 양의 숫자 (정수 또는 부동 소수점) 인지 확인합니다.

이제 유효성 검사기가 정의되었으므로, 이러한 유효성 검사기를 사용하도록 Stock 클래스를 수정해 보겠습니다. stock_with_validators.py라는 새 파일을 생성하고 validate.py 파일에서 유효성 검사기를 가져옵니다.

## stock_with_validators.py

from validate import String, PositiveInteger, PositiveFloat

class Stock:
    name = String('name')
    shares = PositiveInteger('shares')
    price = PositiveFloat('price')

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

    def cost(self):
        return self.shares * self.price

    def sell(self, amount):
        self.shares -= amount

    def __repr__(self):
        return f'Stock({self.name!r}, {self.shares!r}, {self.price!r})'

stock_with_validators.py 파일에서 Stock 클래스를 정의하고 유효성 검사기를 클래스 속성으로 사용합니다. 즉, 속성이 Stock 객체에 설정될 때마다 해당 유효성 검사기의 __set__ 메서드가 호출되어 값을 검증합니다. __init__ 메서드는 Stock 객체의 속성을 초기화하고, cost, sell, 그리고 __repr__ 메서드는 추가 기능을 제공합니다.

이제 유효성 검사기 기반의 Stock 클래스를 테스트해 보겠습니다. 터미널을 열고 프로젝트 디렉토리로 이동하여 stock_with_validators.py 파일을 대화형 모드로 실행합니다.

cd ~/project
python3 -i stock_with_validators.py

Python 인터프리터가 실행되면 몇 가지 명령을 사용하여 유효성 검사 시스템을 테스트할 수 있습니다.

## Create a stock with valid values
s = Stock('GOOG', 100, 490.10)
print(s.name)    ## Should return 'GOOG'
print(s.shares)  ## Should return 100
print(s.price)   ## Should return 490.1

## Try changing to valid values
s.shares = 75
print(s.shares)  ## Should return 75

## Try setting invalid values
try:
    s.shares = '75'  ## Should raise TypeError
except TypeError as e:
    print(f"Error setting shares to string: {e}")

try:
    s.shares = -50  ## Should raise ValueError
except ValueError as e:
    print(f"Error setting negative shares: {e}")

exit()

테스트 코드에서 먼저 유효한 값으로 Stock 객체를 생성하고 해당 속성을 인쇄하여 올바르게 설정되었는지 확인합니다. 그런 다음 shares 속성을 유효한 값으로 변경하고 다시 인쇄하여 변경 사항을 확인합니다. 마지막으로, shares 속성을 유효하지 않은 값 (문자열 및 음수) 으로 설정하고 유효성 검사기에 의해 발생한 예외를 catch 합니다.

이제 코드가 훨씬 더 깔끔해졌음을 알 수 있습니다. Stock 클래스는 더 이상 모든 property 메서드를 구현할 필요가 없으며, 유효성 검사기가 모든 유형 검사 및 제약을 처리합니다.

Descriptor 를 사용하면 모든 클래스 속성에 적용할 수 있는 재사용 가능한 유효성 검사 시스템을 만들 수 있었습니다. 이는 애플리케이션 전체에서 데이터 무결성을 유지하기 위한 강력한 패턴입니다.

Descriptor 구현 개선

이 단계에서는 descriptor 구현을 개선할 것입니다. 경우에 따라 이름을 중복해서 지정해 왔다는 것을 눈치챘을 수 있습니다. 이는 코드를 약간 지저분하게 만들고 유지 관리를 더 어렵게 만들 수 있습니다. 이 문제를 해결하기 위해 Python 3.6 에서 도입된 유용한 기능인 __set_name__ 메서드를 사용합니다.

__set_name__ 메서드는 클래스가 정의될 때 자동으로 호출됩니다. 주요 역할은 descriptor 의 이름을 설정하는 것이므로 매번 수동으로 수행할 필요가 없습니다. 이렇게 하면 코드가 더 깔끔하고 효율적으로 됩니다.

이제 __set_name__ 메서드를 포함하도록 validate.py 파일을 업데이트해 보겠습니다. 업데이트된 코드는 다음과 같습니다.

## validate.py

class Validator:
    def __init__(self, name=None):
        self.name = name

    def __set_name__(self, cls, name):
        ## This gets called when the class is defined
        ## It automatically sets the name of the descriptor
        if self.name is None:
            self.name = name

    @classmethod
    def check(cls, value):
        return value

    def __set__(self, instance, value):
        instance.__dict__[self.name] = self.check(value)


class String(Validator):
    expected_type = str

    @classmethod
    def check(cls, value):
        if not isinstance(value, cls.expected_type):
            raise TypeError(f'Expected {cls.expected_type}')
        return value


class PositiveInteger(Validator):
    expected_type = int

    @classmethod
    def check(cls, value):
        if not isinstance(value, cls.expected_type):
            raise TypeError(f'Expected {cls.expected_type}')
        if value < 0:
            raise ValueError('Expected a positive value')
        return value


class PositiveFloat(Validator):
    expected_type = float

    @classmethod
    def check(cls, value):
        if not isinstance(value, (int, float)):
            raise TypeError('Expected a number')
        if value < 0:
            raise ValueError('Expected a positive value')
        return float(value)

위 코드에서 Validator 클래스의 __set_name__ 메서드는 name 속성이 None인지 확인합니다. 그렇다면 name을 클래스 정의에 사용된 실제 속성 이름으로 설정합니다. 이렇게 하면 descriptor 클래스의 인스턴스를 생성할 때 이름을 명시적으로 지정할 필요가 없습니다.

이제 validate.py 파일을 업데이트했으므로 개선된 버전의 Stock 클래스를 만들 수 있습니다. 이 새 버전에서는 이름을 중복해서 지정할 필요가 없습니다. 개선된 Stock 클래스에 대한 코드는 다음과 같습니다.

## improved_stock.py

from validate import String, PositiveInteger, PositiveFloat

class Stock:
    name = String()  ## No need to specify 'name' anymore
    shares = PositiveInteger()
    price = PositiveFloat()

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

    def cost(self):
        return self.shares * self.price

    def sell(self, amount):
        self.shares -= amount

    def __repr__(self):
        return f'Stock({self.name!r}, {self.shares!r}, {self.price!r})'

Stock 클래스에서는 이름을 지정하지 않고 String, PositiveInteger, 그리고 PositiveFloat descriptor 클래스의 인스턴스를 생성합니다. Validator 클래스의 __set_name__ 메서드가 자동으로 이름을 설정합니다.

개선된 Stock 클래스를 테스트해 보겠습니다. 먼저 터미널을 열고 프로젝트 디렉토리로 이동합니다. 그런 다음 improved_stock.py 파일을 대화형 모드로 실행합니다. 다음은 그렇게 하기 위한 명령입니다.

cd ~/project
python3 -i improved_stock.py

대화형 Python 세션에 들어가면 다음 명령을 시도하여 Stock 클래스의 기능을 테스트할 수 있습니다.

s = Stock('GOOG', 100, 490.10)
print(s.name)    ## Should return 'GOOG'
print(s.shares)  ## Should return 100
print(s.price)   ## Should return 490.1

## Try changing values
s.shares = 75
print(s.shares)  ## Should return 75

## Try invalid values
try:
    s.shares = '75'  ## Should raise TypeError
except TypeError as e:
    print(f"Error: {e}")

try:
    s.price = -10.5  ## Should raise ValueError
except ValueError as e:
    print(f"Error: {e}")

exit()

이러한 명령은 Stock 클래스의 인스턴스를 생성하고, 해당 속성을 인쇄하고, 속성의 값을 변경한 다음, 적절한 오류가 발생하는지 확인하기 위해 유효하지 않은 값을 설정하려고 시도합니다.

__set_name__ 메서드는 클래스가 정의될 때 descriptor 의 이름을 자동으로 설정합니다. 이렇게 하면 속성 이름을 두 번 지정할 필요가 없으므로 코드가 더 깔끔하고 중복이 줄어듭니다.

이 개선 사항은 Python 의 descriptor 프로토콜이 어떻게 계속 발전하여 깔끔하고 유지 관리 가능한 코드를 더 쉽게 작성할 수 있는지 보여줍니다.

요약

이 랩에서는 클래스에서 속성 접근을 사용자 정의할 수 있게 해주는 강력한 기능인 Python descriptor 에 대해 배웠습니다. __get__, __set__, 그리고 __delete__ 메서드를 포함하는 descriptor 프로토콜을 탐구했습니다. 또한 속성 접근을 가로채는 기본 descriptor 클래스를 만들고 데이터 무결성을 위한 유효성 검사 시스템을 구현하기 위해 descriptor 를 사용했습니다.

더욱이, 중복을 줄이기 위해 __set_name__ 메서드로 descriptor 를 개선했습니다. Descriptor 는 Django 및 SQLAlchemy 와 같은 Python 라이브러리 및 프레임워크에서 널리 사용됩니다. 이를 이해하면 Python 에 대한 더 깊은 통찰력을 얻고 더 우아하고 유지 관리 가능한 코드를 작성하는 데 도움이 됩니다.