디스크립터 가이드

디스크립터 가이드 (Descriptor Guide)

디스크립터는 객체가 속성 조회, 저장, 삭제를 커스터마이즈할 수 있게 해 주는 메커니즘이에요. 프로퍼티, 메서드, 정적 메서드, 클래스 메서드, 그리고 super()까지, Python의 숨은 엔진이라고 할 수 있는데요. 처음부터 차근차근 원리를 이해하면 Python이 '마법처럼' 보이던 동작들이 사실은 하나의 일관된 프로토콜 위에 서 있다는 걸 깨닫게 돼요.

이 가이드는 네 개의 큰 절로 나뉘어요.

  1. '입문'(primer)은 기본 개요를 주며, 간단한 예시에서 시작해 한 번에 하나씩 기능을 더해 가요. 디스크립터가 처음이라면 여기서 시작하세요.
  2. 두 번째 절은 완전하고 실용적인 디스크립터 예시를 보여줘요. 기초를 이미 안다면 여기서 시작하면 돼요.
  3. 세 번째 절은 디스크립터가 동작하는 자세한 메커니즘을 파고드는 더 기술적인 튜토리얼이에요. 대부분의 사람은 이 수준의 세부 사항이 필요 없어요.
  4. 마지막 절은 C로 쓰인 내장 디스크립터의 순수 Python 등가물이에요. 함수가 어떻게 바운드 메서드로 바뀌는지, 또는 @classmethod, @staticmethod, @property, __slots__ 같은 흔한 도구의 구현이 궁금하다면 이걸 읽어 보세요.

출처: Python 공식 문서

입문 (Primer)

이 입문에서는 가장 기본적인 예시부터 시작해서 새로운 능력을 하나씩 더해 볼게요.

간단한 예시: 상수를 반환하는 디스크립터

Ten 클래스는 __get__() 메서드가 항상 상수 10을 반환하는 디스크립터예요.

class Ten:
    def __get__(self, obj, objtype=None):
        return 10

디스크립터를 사용하려면 다른 클래스의 클래스 변수로 저장해야 해요.

class A:
    x = 5                       # Regular class attribute
    y = Ten()                   # Descriptor instance

대화형 세션이 일반 속성 조회와 디스크립터 조회의 차이를 보여줘요.

>>> a = A()                     # Make an instance of class A
>>> a.x                         # Normal attribute lookup
5
>>> a.y                         # Descriptor lookup
10

a.x 속성 조회에서는 점 연산자가 클래스 사전에서 'x': 5를 찾아요. a.y 조회에서는 점 연산자가 __get__ 메서드로 알아볼 수 있는 디스크립터 인스턴스를 찾고, 그 메서드를 호출해서 10을 반환해요.

10은 클래스 사전이나 인스턴스 사전 어디에도 저장되지 않는다는 점을 주목하세요. 대신 값 10은 필요할 때(on demand) 계산돼요.

이 예시는 간단한 디스크립터가 어떻게 동작하는지 보여주지만, 별로 유용하진 않아요. 상수를 얻으려면 일반 속성 조회가 더 낫거든요. 다음 절에서는 더 유용한 것, 동적 조회를 만들어 볼게요.

동적 조회

흥미로운 디스크립터는 보통 상수를 반환하는 대신 계산을 실행해요.

import os

class DirectorySize:

    def __get__(self, obj, objtype=None):
        return len(os.listdir(obj.dirname))

class Directory:

    size = DirectorySize()              # Descriptor instance

    def __init__(self, dirname):
        self.dirname = dirname          # Regular instance attribute

대화형 세션은 조회가 동적임을 보여줘요. 매번 달라지고 갱신된 답을 계산하죠.

>>> s = Directory('songs')
>>> g = Directory('games')
>>> s.size                              # The songs directory has twenty files
20
>>> g.size                              # The games directory has three files
3
>>> os.remove('games/chess')            # Delete a game
>>> g.size                              # File count is automatically updated
2

디스크립터가 계산을 실행할 수 있다는 걸 보여주는 것 외에도, 이 예시는 __get__()에 있는 매개변수의 목적을 드러내요. self 매개변수는 size, 즉 DirectorySize의 인스턴스예요. obj 매개변수는 gs, 즉 Directory의 인스턴스예요. __get__() 메서드가 대상 디렉터리를 알게 해 주는 건 바로 obj 매개변수예요. objtype 매개변수는 Directory 클래스예요.

관리되는 속성 (Managed attributes)

디스크립터의 인기 있는 용도는 인스턴스 데이터에 대한 접근을 관리하는 거예요. 디스크립터는 클래스 사전의 공개(public) 속성에 할당되고, 실제 데이터는 인스턴스 사전의 비공개(private) 속성으로 저장돼요. 공개 속성에 접근하면 디스크립터의 __get__()__set__() 메서드가 촉발돼요.

다음 예시에서 age는 공개 속성이고 _age는 비공개 속성이에요. 공개 속성에 접근하면 디스크립터가 조회나 갱신을 로그로 남겨요.

import logging

logging.basicConfig(level=logging.INFO)

class LoggedAgeAccess:

    def __get__(self, obj, objtype=None):
        value = obj._age
        logging.info('Accessing %r giving %r', 'age', value)
        return value

    def __set__(self, obj, value):
        logging.info('Updating %r to %r', 'age', value)
        obj._age = value

class Person:

    age = LoggedAgeAccess()             # Descriptor instance

    def __init__(self, name, age):
        self.name = name                # Regular instance attribute
        self.age = age                  # Calls __set__()

    def birthday(self):
        self.age += 1                   # Calls both __get__() and __set__()

대화형 세션은 관리되는 속성 age에 대한 모든 접근이 로그로 남지만, 일반 속성 name은 로그로 남지 않음을 보여줘요.

>>> mary = Person('Mary M', 30)         # The initial age update is logged
INFO:root:Updating 'age' to 30
>>> dave = Person('David D', 40)
INFO:root:Updating 'age' to 40

>>> vars(mary)                          # The actual data is in a private attribute
{'name': 'Mary M', '_age': 30}
>>> vars(dave)
{'name': 'David D', '_age': 40}

>>> mary.age                            # Access the data and log the lookup
INFO:root:Accessing 'age' giving 30
30
>>> mary.birthday()                     # Updates are logged as well
INFO:root:Accessing 'age' giving 30
INFO:root:Updating 'age' to 31

>>> dave.name                           # Regular attribute lookup isn't logged
'David D'
>>> dave.age                            # Only the managed attribute is logged
INFO:root:Accessing 'age' giving 40
40

이 예시의 큰 문제 중 하나는 비공개 이름 _ageLoggedAgeAccess 클래스에 하드코딩돼 있다는 거예요. 즉 각 인스턴스는 로그가 남는 속성을 하나만 가질 수 있고, 그 이름도 바꿀 수 없어요. 다음 예시에서 그 문제를 고쳐 볼게요.

커스터마이즈된 이름

클래스가 디스크립터를 사용할 때, 각 디스크립터에 어떤 변수 이름이 쓰였는지 알려줄 수 있어요.

이 예시에서 Person 클래스는 nameage라는 두 디스크립터 인스턴스를 가져요. Person 클래스를 정의할 때 LoggedAccess__set_name__()에 콜백을 만들어 필드 이름을 기록해서, 각 디스크립터가 자신만의 public_nameprivate_name을 가지게 해요.

import logging

logging.basicConfig(level=logging.INFO)

class LoggedAccess:

    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name

    def __get__(self, obj, objtype=None):
        value = getattr(obj, self.private_name)
        logging.info('Accessing %r giving %r', self.public_name, value)
        return value

    def __set__(self, obj, value):
        logging.info('Updating %r to %r', self.public_name, value)
        setattr(obj, self.private_name, value)

class Person:

    name = LoggedAccess()                # First descriptor instance
    age = LoggedAccess()                 # Second descriptor instance

    def __init__(self, name, age):
        self.name = name                 # Calls the first descriptor
        self.age = age                   # Calls the second descriptor

    def birthday(self):
        self.age += 1

대화형 세션은 Person 클래스가 __set_name__()을 호출해서 필드 이름이 기록됐음을 보여줘요. 여기서는 vars()를 호출해서 디스크립터를 촉발하지 않고 조회해요.

>>> vars(vars(Person)['name'])
{'public_name': 'name', 'private_name': '_name'}
>>> vars(vars(Person)['age'])
{'public_name': 'age', 'private_name': '_age'}

새 클래스는 이제 nameage 둘 다에 대한 접근을 로그로 남겨요.

>>> pete = Person('Peter P', 10)
INFO:root:Updating 'name' to 'Peter P'
INFO:root:Updating 'age' to 10
>>> kate = Person('Catherine C', 20)
INFO:root:Updating 'name' to 'Catherine C'
INFO:root:Updating 'age' to 20

Person 인스턴스는 비공개 이름만 담아요.

>>> vars(pete)
{'_name': 'Peter P', '_age': 10}
>>> vars(kate)
{'_name': 'Catherine C', '_age': 20}

마무리 생각

디스크립터라 부르는 것은 __get__(), __set__(), 또는 __delete__()를 정의하는 어떤 객체든 돼요.

선택적으로 디스크립터는 __set_name__() 메서드를 가질 수 있어요. 이 메서드는 디스크립터가 자신이 만들어진 클래스나 할당된 클래스 변수의 이름을 알아야 하는 경우에만 쓰여요. (이 메서드는, 있으면, 클래스가 디스크립터가 아니어도 호출돼요.)

디스크립터는 속성 조회 중에 점 연산자로 호출돼요. 디스크립터를 vars(some_class)[descriptor_name]으로 간접 접근하면 호출하지 않고 디스크립터 인스턴스가 반환돼요.

디스크립터는 클래스 변수로 쓰일 때만 동작해요. 인스턴스에 넣으면 아무 효과가 없어요.

디스크립터의 주된 동기는 클래스 변수에 저장된 객체가 속성 조회 중에 무슨 일이 일어나는지 제어할 수 있는 훅을 제공하는 거예요.

전통적으로 호출하는 클래스가 조회 중에 무슨 일이 일어나는지 제어해요. 디스크립터는 그 관계를 뒤집어서, 조회되는 데이터가 그 일에 한마디 할 수 있게 해 주죠.

디스크립터는 언어 전반에 걸쳐 쓰여요. 함수가 바운드 메서드로 바뀌는 것이 바로 디스크립터 덕분이에요. @classmethod, @staticmethod, @property, @functools.cached_property 같은 흔한 도구가 모두 디스크립터로 구현돼요.

완전한 실용 예시

이 예시에서는 찾기로 악명 높은 데이터 손상 버그를 찾는 실용적이고 강력한 도구를 만들어 볼게요.

Validator 클래스

validator는 관리되는 속성 접근을 위한 디스크립터예요. 데이터를 저장하기 전에, 새 값이 다양한 타입·범위 제한을 충족하는지 검증해요. 그 제한을 충족하지 못하면 예외를 일으켜 데이터 손상을 원천에서 막아요.

Validator 클래스는 추상 기본 클래스이자 관리되는 속성 디스크립터예요.

from abc import ABC, abstractmethod

class Validator(ABC):

    def __set_name__(self, owner, name):
        self.private_name = '_' + name

    def __get__(self, obj, objtype=None):
        return getattr(obj, self.private_name)

    def __set__(self, obj, value):
        self.validate(value)
        setattr(obj, self.private_name, value)

    @abstractmethod
    def validate(self, value):
        pass

커스텀 validator는 Validator에서 상속받아 필요에 따라 다양한 제한을 테스트하는 validate() 메서드를 제공해야 해요.

커스텀 validator

여기 실용적인 데이터 검증 유틸리티 세 개가 있어요.

  • OneOf는 값이 제한된 옵션 집합 중 하나인지 검증해요.
  • Number는 값이 intfloat인지 검증해요. 선택적으로 값이 주어진 최솟값과 최댓값 사이인지 검증해요.
  • String은 값이 str인지 검증해요. 선택적으로 주어진 최소·최대 길이를 검증해요. 사용자 정의 술어도 검증할 수 있어요.
class OneOf(Validator):

    def __init__(self, *options):
        self.options = set(options)

    def validate(self, value):
        if value not in self.options:
            raise ValueError(
                f'Expected {value!r} to be one of {self.options!r}'
            )

class Number(Validator):

    def __init__(self, minvalue=None, maxvalue=None):
        self.minvalue = minvalue
        self.maxvalue = maxvalue

    def validate(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError(f'Expected {value!r} to be an int or float')
        if self.minvalue is not None and value < self.minvalue:
            raise ValueError(
                f'Expected {value!r} to be at least {self.minvalue!r}'
            )
        if self.maxvalue is not None and value > self.maxvalue:
            raise ValueError(
                f'Expected {value!r} to be no more than {self.maxvalue!r}'
            )

class String(Validator):

    def __init__(self, minsize=None, maxsize=None, predicate=None):
        self.minsize = minsize
        self.maxsize = maxsize
        self.predicate = predicate

    def validate(self, value):
        if not isinstance(value, str):
            raise TypeError(f'Expected {value!r} to be a str')
        if self.minsize is not None and len(value) < self.minsize:
            raise ValueError(
                f'Expected {value!r} to be no smaller than {self.minsize!r}'
            )
        if self.maxsize is not None and len(value) > self.maxsize:
            raise ValueError(
                f'Expected {value!r} to be no bigger than {self.maxsize!r}'
            )
        if self.predicate is not None and not self.predicate(value):
            raise ValueError(
                f'Expected {self.predicate} to be true for {value!r}'
            )

실용 적용

여기 데이터 validator를 실제 클래스에서 어떻게 쓰는지 보여드릴게요.

class Component:

    name = String(minsize=3, maxsize=10, predicate=str.isupper)
    kind = OneOf('wood', 'metal', 'plastic')
    quantity = Number(minvalue=0)

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

디스크립터는 유효하지 않은 인스턴스가 만들어지는 것을 막아요.

>>> Component('Widget', 'metal', 5)      # Blocked: 'Widget' is not all uppercase
Traceback (most recent call last):
    ...
ValueError: Expected <method 'isupper' of 'str' objects> to be true for 'Widget'

>>> Component('WIDGET', 'metle', 5)      # Blocked: 'metle' is misspelled
Traceback (most recent call last):
    ...
ValueError: Expected 'metle' to be one of {'metal', 'plastic', 'wood'}

>>> Component('WIDGET', 'metal', -5)     # Blocked: -5 is negative
Traceback (most recent call last):
    ...
ValueError: Expected -5 to be at least 0

>>> Component('WIDGET', 'metal', 'V')    # Blocked: 'V' isn't a number
Traceback (most recent call last):
    ...
TypeError: Expected 'V' to be an int or float

>>> c = Component('WIDGET', 'metal', 5)  # Allowed:  The inputs are valid

기술 튜토리얼

다음은 디스크립터가 어떻게 동작하는지 그 메커니즘과 세부 사항에 대한 더 기술적인 튜토리얼이에요.

요약

디스크립터를 정의하고, 프로토콜을 요약하며, 디스크립터가 어떻게 호출되는지 보여줘요. 객체 관계형 매핑이 어떻게 동작하는지 보여주는 예시도 제공해요.

디스크립터를 배우는 것은 더 큰 도구 세트에 접근하게 해 줄 뿐 아니라, Python이 어떻게 동작하는지에 대한 더 깊은 이해를 만들어 줘요.

정의와 소개

일반적으로 디스크립터는 디스크립터 프로토콜의 메서드 중 하나를 가진 속성 값이에요. 그 메서드들은 __get__(), __set__(), __delete__()예요. 이 메서드 중 어느 하나라도 속성에 정의돼 있으면 그 속성을 디스크립터라고 말해요.

속성 접근의 기본 동작은 객체의 사전에서 속성을 얻거나, 설정하거나, 삭제하는 거예요. 예를 들어 a.xa.__dict__['x']로 시작해서 type(a).__dict__['x']로, 그리고 type(a)의 메서드 결정 순서(MRO)를 따라 이어지는 조회 체인을 가져요. 조회된 값이 디스크립터 메서드 중 하나를 정의하는 객체라면, Python은 기본 동작을 재정의하고 대신 디스크립터 메서드를 호출할 수 있어요. 우선순위 체인에서 이게 어디서 발생하는지는 어떤 디스크립터 메서드가 정의됐는지에 달려 있어요.

디스크립터는 강력하고 범용적인 프로토콜이에요. properties, methods, static methods, class methods, super() 뒤에 있는 메커니즘입니다. Python 자체 전반에 걸쳐 사용돼요. 디스크립터는 기저 C 코드를 단순화하고 일상적인 Python 프로그램에 유연한 새 도구 세트를 제공해요.

디스크립터 프로토콜

descr.__get__(self, obj, type=None)

descr.__set__(self, obj, value)

descr.__delete__(self, obj)

이게 전부예요. 이 메서드 중 어느 것이든 정의하면 객체는 디스크립터로 간주되고, 속성으로 조회될 때 기본 동작을 재정의할 수 있어요.

객체가 __set__()이나 __delete__()을 정의하면 데이터 디스크립터(data descriptor)로 간주돼요. __get__()만 정의하는 디스크립터를 논데이터 디스크립터(non-data descriptor)라고 불러요 (메서드에 자주 쓰이지만 다른 용도도 가능해요).

데이터와 논데이터 디스크립터는 인스턴스 사전의 항목과 관련해 재정의가 어떻게 계산되는지가 달라요. 인스턴스 사전에 데이터 디스크립터와 같은 이름의 항목이 있으면 데이터 디스크립터가 우선해요. 인스턴스 사전에 논데이터 디스크립터와 같은 이름의 항목이 있으면 사전 항목이 우선해요.

읽기 전용 데이터 디스크립터를 만들려면 __get__()__set__() 둘 다 정의하는데, __set__()은 호출되면 AttributeError를 일으켜요. 예외를 일으키는 자리 표시자로 __set__() 메서드를 정의하는 것만으로도 그것을 데이터 디스크립터로 만들기에 충분해요.

디스크립터 호출 개요

디스크립터는 desc.__get__(obj)desc.__get__(None, cls)로 직접 호출할 수 있어요.

하지만 더 흔한 건 속성 접근에서 디스크립터가 자동으로 호출되는 것이에요.

obj.x 표현식은 obj의 네임스페이스 체인에서 속성 x를 조회해요. 검색이 인스턴스 __dict__ 밖에서 디스크립터를 찾으면, 아래 나열된 우선순위 규칙에 따라 그 __get__() 메서드가 호출돼요.

호출의 세부 사항은 obj가 객체, 클래스, 또는 super의 인스턴스인지에 따라 달라져요.

인스턴스에서의 호출

인스턴스 조회는 네임스페이스 체인을 훑는데, 데이터 디스크립터에 가장 높은 우선순위를 주고, 그다음 인스턴스 변수, 논데이터 디스크립터, 클래스 변수, 마지막으로 제공된다면 __getattr__() 순으로 우선해요.

a.x에 대해 디스크립터가 발견되면 desc.__get__(a, type(a))로 호출돼요.

점 표기 조회의 논리는 object.__getattribute__()에 있어요. 여기 순수 Python 등가물이 있어요.

def find_name_in_mro(cls, name, default):
    "Emulate _PyType_Lookup() in Objects/typeobject.c"
    for base in cls.__mro__:
        if name in vars(base):
            return vars(base)[name]
    return default

def object_getattribute(obj, name):
    "Emulate PyObject_GenericGetAttr() in Objects/object.c"
    null = object()
    objtype = type(obj)
    cls_var = find_name_in_mro(objtype, name, null)
    descr_get = getattr(type(cls_var), '__get__', null)
    if descr_get is not null:
        if (hasattr(type(cls_var), '__set__')
            or hasattr(type(cls_var), '__delete__')):
            return descr_get(cls_var, obj, objtype)     # data descriptor
    if hasattr(obj, '__dict__') and name in vars(obj):
        return vars(obj)[name]                          # instance variable
    if descr_get is not null:
        return descr_get(cls_var, obj, objtype)         # non-data descriptor
    if cls_var is not null:
        return cls_var                                  # class variable
    raise AttributeError(name)

참고로, __getattribute__() 코드에는 __getattr__() 훅이 없어요. 그래서 __getattribute__()를 직접 호출하거나 super().__getattribute__로 호출하면 __getattr__()을 완전히 우회해요.

대신, __getattribute__()AttributeError를 일으킬 때마다 __getattr__()을 호출하는 책임은 점 연산자와 getattr() 함수에 있어요. 그들의 논리는 헬퍼 함수에 캡슐화돼 있어요.

def getattr_hook(obj, name):
    "Emulate slot_tp_getattr_hook() in Objects/typeobject.c"
    try:
        return obj.__getattribute__(name)
    except AttributeError:
        if not hasattr(type(obj), '__getattr__'):
            raise
    return type(obj).__getattr__(obj, name)             # __getattr__

클래스에서의 호출

A.x 같은 점 표기 조회의 논리는 type.__getattribute__()에 있어요. 단계는 object.__getattribute__()와 비슷하지만, 인스턴스 사전 조회가 클래스의 메서드 결정 순서 검색으로 대체돼요.

디스크립터가 발견되면 desc.__get__(None, A)로 호출돼요.

전체 C 구현은 Objects/typeobject.ctype_getattro()_PyType_Lookup()에서 찾을 수 있어요.

super에서의 호출

super의 점 표기 조회의 논리는 super()가 반환하는 객체의 __getattribute__() 메서드에 있어요.

super(A, obj).m 같은 점 표기 조회는 obj.__class__.__mro__에서 A 바로 다음에 오는 기본 클래스 B를 검색한 다음 B.__dict__['m'].__get__(obj, A)를 반환해요. 디스크립터가 아니면 m이 변경 없이 반환돼요.

전체 C 구현은 Objects/typeobject.csuper_getattro()에서 찾을 수 있어요. 순수 Python 등가물은 Guido의 튜토리얼에서 찾을 수 있어요.

호출 논리 요약

디스크립터의 메커니즘은 object, type, super()__getattribute__() 메서드에 내장돼 있어요.

기억해야 할 중요한 점은:

  • 디스크립터는 __getattribute__() 메서드에 의해 호출돼요.
  • 클래스는 이 기계를 object, type, 또는 super()에서 상속받아요.
  • __getattribute__()를 재정의하면 자동 디스크립터 호출을 막아요. 모든 디스크립터 논리가 그 메서드에 있으니까요.
  • object.__getattribute__()type.__getattribute__()__get__()에 다른 호출을 해요. 첫 번째는 인스턴스를 포함하고 클래스를 포함할 수 있어요. 두 번째는 인스턴스 자리에 None을 넣고 항상 클래스를 포함해요.
  • 데이터 디스크립터는 항상 인스턴스 사전을 재정의해요.
  • 논데이터 디스크립터는 인스턴스 사전에 의해 재정의될 수 있어요.

자동 이름 알림

때로는 디스크립터가 자신이 할당된 클래스 변수 이름을 아는 것이 바람직해요. 새 클래스가 만들어질 때, type 메타클래스가 새 클래스의 사전을 훑어봐요. 항목 중 어느 것이 디스크립터이고 __set_name__()이 정의돼 있으면, 그 메서드가 두 인자로 호출돼요. owner는 디스크립터가 사용된 클래스이고, name은 디스크립터가 할당된 클래스 변수예요.

구현 세부 사항은 Objects/typeobject.ctype_new()set_names()에 있어요.

갱신 논리가 type.__new__()에 있으므로, 알림은 클래스 생성 시점에만 일어나요. 나중에 디스크립터를 클래스에 추가하면 __set_name__()을 수동으로 호출해야 해요.

ORM 예시

다음 코드는 데이터 디스크립터가 객체 관계형 매핑(ORM)을 구현하는 데 어떻게 쓰일 수 있는지 보여주는 단순화된 골격이에요.

핵심 아이디어는 데이터가 외부 데이터베이스에 저장된다는 거예요. Python 인스턴스는 데이터베이스 테이블에 대한 키만 보유해요. 디스크립터가 조회나 갱신을 처리하죠.

class Field:

    def __set_name__(self, owner, name):
        self.fetch = f'SELECT {name} FROM {owner.table} WHERE {owner.key}=?;'
        self.store = f'UPDATE {owner.table} SET {name}=? WHERE {owner.key}=?;'

    def __get__(self, obj, objtype=None):
        return conn.execute(self.fetch, [obj.key]).fetchone()[0]

    def __set__(self, obj, value):
        conn.execute(self.store, [value, obj.key])
        conn.commit()

Field 클래스를 써서 데이터베이스의 각 테이블에 대한 스키마를 설명하는 모델을 정의할 수 있어요.

class Movie:
    table = 'Movies'                    # Table name
    key = 'title'                       # Primary key
    director = Field()
    year = Field()

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

class Song:
    table = 'Music'
    key = 'title'
    artist = Field()
    year = Field()
    genre = Field()

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

모델을 사용하려면 먼저 데이터베이스에 연결해요.

>>> import sqlite3
>>> conn = sqlite3.connect('entertainment.db')

대화형 세션이 데이터를 데이터베이스에서 어떻게 가져오고, 어떻게 갱신할 수 있는지 보여줘요.

>>> Movie('Star Wars').director
'George Lucas'
>>> jaws = Movie('Jaws')
>>> f'Released in {jaws.year} by {jaws.director}'
'Released in 1975 by Steven Spielberg'

>>> Song('Country Roads').artist
'John Denver'

>>> Movie('Star Wars').director = 'J.J. Abrams'
>>> Movie('Star Wars').director
'J.J. Abrams'

순수 Python 등가물

디스크립터 프로토콜은 단순하고 흥미로운 가능성을 제공해요. 몇 가지 사용 사례는 너무 흔해서 내장 도구로 미리 포장돼 있어요. Properties, 바운드 메서드, 정적 메서드, 클래스 메서드, __slots__가 모두 디스크립터 프로토콜에 기반해요.

Properties

property()를 호출하는 것은 속성 접근 시 함수 호출을 촉발하는 데이터 디스크립터를 만드는 간결한 방법이에요. 시그니처는 이러해요.

property(fget=None, fset=None, fdel=None, doc=None) -> property

문서는 관리되는 속성 x를 정의하는 전형적인 사용법을 보여줘요.

class C:
    def getx(self): return self.__x
    def setx(self, value): self.__x = value
    def delx(self): del self.__x
    x = property(getx, setx, delx, "I'm the 'x' property.")

property()가 디스크립터 프로토콜로 어떻게 구현되는지 보려면, 여기 핵심 기능 대부분을 구현하는 순수 Python 등가물이 있어요.

class Property:
    "Emulate PyProperty_Type() in Objects/descrobject.c"

    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        if doc is None and fget is not None:
            doc = fget.__doc__
        self.__doc__ = doc

    def __set_name__(self, owner, name):
        self.__name__ = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError
        self.fdel(obj)

    def getter(self, fget):
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel, self.__doc__)

property() 내장 함수는 사용자 인터페이스가 속성 접근을 허용했는데, 그 후의 변경이 메서드의 개입을 요구할 때마다 도움이 돼요.

예를 들어 스프레드시트 클래스가 Cell('b10').value를 통해 셀 값에 접근을 허용할 수 있어요. 이후의 프로그램 개선으로 셀을 매 접근마다 다시 계산해야 하게 됐는데, 프로그래머는 속성을 직접 접근하는 기존 클라이언트 코드에 영향을 주고 싶지 않아요. 해법은 value 속성에 대한 접근을 property 데이터 디스크립터로 감싸는 거예요.

class Cell:
    ...

    @property
    def value(self):
        "Recalculate the cell before returning value"
        self.recalc()
        return self._value

이 예시에서는 내장 property()나 우리의 Property() 등가물 어느 쪽도 동작해요.

함수와 메서드

Python의 객체 지향 기능은 함수 기반 환경 위에 세워져 있어요. 논데이터 디스크립터를 사용하면 둘이 매끄럽게 합쳐져요.

클래스 사전에 저장된 함수는 호출될 때 메서드로 바뀌어요. 메서드는 객체 인스턴스가 다른 인자 앞에 붙는다는 점만 일반 함수와 달라요. 관례상 인스턴스를 self라 부르지만 this나 다른 어떤 변수 이름으로도 부를 수 있어요.

메서드는 types.MethodType으로 수동으로 만들 수 있는데, 대략 다음과 같아요.

class MethodType:
    "Emulate PyMethod_Type in Objects/classobject.c"

    def __init__(self, func, obj):
        self.__func__ = func
        self.__self__ = obj

    def __call__(self, *args, **kwargs):
        func = self.__func__
        obj = self.__self__
        return func(obj, *args, **kwargs)

    def __getattribute__(self, name):
        "Emulate method_getset() in Objects/classobject.c"
        if name == '__doc__':
            return self.__func__.__doc__
        return object.__getattribute__(self, name)

    def __getattr__(self, name):
        "Emulate method_getattro() in Objects/classobject.c"
        return getattr(self.__func__, name)

    def __get__(self, obj, objtype=None):
        "Emulate method_descr_get() in Objects/classobject.c"
        return self

메서드의 자동 생성을 지원하려고, 함수는 속성 접근 중 메서드를 바인딩하는 __get__() 메서드를 포함해요. 이는 함수가 인스턴스에서 점 표기 조회 중 바운드 메서드를 반환하는 논데이터 디스크립터라는 뜻이에요. 이렇게 동작해요.

class Function:
    ...

    def __get__(self, obj, objtype=None):
        "Simulate func_descr_get() in Objects/funcobject.c"
        if obj is None:
            return self
        return MethodType(self, obj)

다음 클래스를 인터프리터에서 실행하면 함수 디스크립터가 실제로 어떻게 동작하는지 보여줘요.

class D:
    def f(self):
         return self

class D2:
    pass

함수는 인트로스펙션을 지원하는 qualified name 속성을 가져요.

>>> D.f.__qualname__
'D.f'

클래스 사전을 통해 함수에 접근하면 __get__()을 호출하지 않아요. 대신 기저 함수 객체만 반환해요.

>>> D.__dict__['f']
<function D.f at 0x00C45070>

클래스에서 점 표기 접근은 __get__()을 호출해서 기저 함수를 변경 없이 반환해요.

>>> D.f
<function D.f at 0x00C45070>

흥미로운 동작은 인스턴스에서 점 표기 접근 중에 발생해요. 점 표기 조회가 __get__()을 호출해서 바운드 메서드 객체를 반환해요.

>>> d = D()
>>> d.f
<bound method D.f of <__main__.D object at 0x00B18C90>>

내부적으로 바운드 메서드는 기저 함수와 바운드 인스턴스를 저장해요.

>>> d.f.__func__
<function D.f at 0x00C45070>

>>> d.f.__self__
<__main__.D object at 0x00B18C90>

일반 메서드에서 self가 어디서 오는지, 클래스 메서드에서 cls가 어디서 오는지 궁금해 본 적이 있다면, 바로 여기예요!

메서드의 종류

논데이터 디스크립터는 함수를 메서드로 바인딩하는 보통 패턴의 변형을 위한 간단한 메커니즘을 제공해요.

정리하면, 함수에는 __get__() 메서드가 있어서 속성으로 접근될 때 메서드로 변환돼요. 논데이터 디스크립터는 obj.f(*args) 호출을 f(obj, *args)로 변환해요. cls.f(*args) 호출은 f(*args)가 돼요.

이 차트가 바인딩과 그 두 가지 가장 유용한 변형을 요약해요.

변형 객체에서 호출 클래스에서 호출
function f(obj, *args) f(*args)
staticmethod f(*args) f(*args)
classmethod f(type(obj), *args) f(cls, *args)

정적 메서드

정적 메서드는 기저 함수를 변경 없이 반환해요. c.fC.f든 호출하는 것은 object.__getattribute__(c, "f") 또는 object.__getattribute__(C, "f")로 직접 조회하는 것과 같아요. 그 결과, 함수는 객체나 클래스 어디서든 동일하게 접근할 수 있게 돼요.

정적 메서드의 좋은 후보는 self 변수를 참조하지 않는 메서드예요.

예를 들어 통계 패키지가 실험 데이터를 위한 컨테이너 클래스를 포함할 수 있어요. 그 클래스는 데이터에 의존하는 평균, 중간값, 중앙값 등 기술 통계를 계산하는 일반 메서드를 제공해요. 하지만 개념적으로는 관련되지만 데이터에 의존하지 않는 유용한 함수가 있을 수 있어요. 예를 들어 erf(x)는 통계 작업에서 나오는 편리한 변환 루틴이지만 특정 데이터셋에 직접 의존하지 않아요. 이것은 객체나 클래스 어느 쪽에서든 호출할 수 있어요: s.erf(1.5) --> 0.9332 또는 Sample.erf(1.5) --> 0.9332.

정적 메서드는 기저 함수를 변경 없이 반환하므로, 예시 호출들은 별로 흥미롭지 않아요.

class E:
    @staticmethod
    def f(x):
        return x * 10
>>> E.f(3)
30
>>> E().f(3)
30

논데이터 디스크립터 프로토콜을 쓰면 @staticmethod의 순수 Python 버전은 이렇게 보일 거예요.

import functools

class StaticMethod:
    "Emulate PyStaticMethod_Type() in Objects/funcobject.c"

    def __init__(self, f):
        self.f = f
        functools.update_wrapper(self, f)

    def __get__(self, obj, objtype=None):
        return self.f

    def __call__(self, *args, **kwds):
        return self.f(*args, **kwds)

    @property
    def __annotations__(self):
        return self.f.__annotations__

functools.update_wrapper() 호출은 기저 함수를 가리키는 __wrapped__ 속성을 추가해요. 또한 wrapper가 감싼 함수처럼 보이게 만드는 데 필요한 속성, 즉 __name__, __qualname__, __doc__을 전달해요.

클래스 메서드

정적 메서드와 달리, 클래스 메서드는 함수를 호출하기 전에 클래스 참조를 인자 목록 앞에 붙여요. 이 형식은 호출자가 객체든 클래스든 동일해요.

class F:
    @classmethod
    def f(cls, x):
        return cls.__name__, x
>>> F.f(3)
('F', 3)
>>> F().f(3)
('F', 3)

이 동작은 메서드가 클래스 참조만 필요하고 특정 인스턴스에 저장된 데이터에 의존하지 않을 때 유용해요. 클래스 메서드의 한 용도는 대체 클래스 생성자를 만드는 거예요. 예를 들어 classmethod dict.fromkeys()가 키 리스트에서 새 dict를 만들어요. 순수 Python 등가물은 이러해요.

class Dict(dict):
    @classmethod
    def fromkeys(cls, iterable, value=None):
        "Emulate dict_fromkeys() in Objects/dictobject.c"
        d = cls()
        for key in iterable:
            d[key] = value
        return d

이제 고유 키의 새 dict를 이렇게 만들 수 있어요.

>>> d = Dict.fromkeys('abracadabra')
>>> type(d) is Dict
True
>>> d
{'a': None, 'b': None, 'r': None, 'c': None, 'd': None}

논데이터 디스크립터 프로토콜을 쓰면 @classmethod의 순수 Python 버전은 이렇게 보일 거예요.

import functools

class ClassMethod:
    "Emulate PyClassMethod_Type() in Objects/funcobject.c"

    def __init__(self, f):
        self.f = f
        functools.update_wrapper(self, f)

    def __get__(self, obj, cls=None):
        if cls is None:
            cls = type(obj)
        return MethodType(self.f, cls)

ClassMethodfunctools.update_wrapper() 호출은 기저 함수를 가리키는 __wrapped__ 속성을 추가해요. 또한 wrapper가 감싼 함수처럼 보이게 만드는 속성인 __name__, __qualname__, __doc__, __annotations__을 전달해요.

멤버 객체와 __slots__

클래스가 __slots__을 정의하면, 인스턴스 사전을 슬롯 값의 고정 길이 배열로 대체해요. 사용자 관점에서 이것은 몇 가지 효과가 있어요.

  1. 오타가 난 속성 할당으로 인한 버그를 즉시 감지해 줘요. __slots__에 지정된 속성 이름만 허용돼요:
class Vehicle:
    __slots__ = ('id_number', 'make', 'model')
>>> auto = Vehicle()
>>> auto.id_nubmer = 'VYE483814LQEX'
Traceback (most recent call last):
    ...
AttributeError: 'Vehicle' object has no attribute 'id_nubmer'
  1. 디스크립터가 __slots__에 저장된 비공개 속성에 대한 접근을 관리하는 불변 객체를 만드는 데 도움을 줘요.
class Immutable:

    __slots__ = ('_dept', '_name')          # Replace the instance dictionary

    def __init__(self, dept, name):
        self._dept = dept                   # Store to private attribute
        self._name = name                   # Store to private attribute

    @property                               # Read-only descriptor
    def dept(self):
        return self._dept

    @property
    def name(self):                         # Read-only descriptor
        return self._name
>>> mark = Immutable('Botany', 'Mark Watney')
>>> mark.dept
'Botany'
>>> mark.dept = 'Space Pirate'
Traceback (most recent call last):
    ...
AttributeError: property 'dept' of 'Immutable' object has no setter
>>> mark.location = 'Mars'
Traceback (most recent call last):
    ...
AttributeError: 'Immutable' object has no attribute 'location'
  1. 메모리를 절약해요. 64비트 Linux 빌드에서 두 개의 속성을 가진 인스턴스는 __slots__을 쓰면 48바이트, 안 쓰면 152바이트를 차지해요. 이 플라이웨이트(flyweight) 디자인 패턴은 아마 많은 수의 인스턴스를 만들 때만 중요할 거예요.

  2. 속도를 향상시켜요. 인스턴스 변수를 읽는 것이 __slots__을 쓰면 35% 더 빨라요(Python 3.10에서 Apple M1 프로세서로 측정).

  3. 인스턴스 사전을 필요로 하는 @functools.cached_property 같은 도구를 막아요.

from functools import cached_property

class CP:
    __slots__ = ()                          # Eliminates the instance dict

    @cached_property                        # Requires an instance dict
    def pi(self):
        return 4 * sum((-1.0)**n / (2.0*n + 1.0)
                       for n in reversed(range(100_000)))
>>> CP().pi
Traceback (most recent call last):
  ...
TypeError: No '__dict__' attribute on 'CP' instance to cache 'pi' property.

__slots__은 C 구조체에 직접 접근하고 객체 메모리 할당을 제어해야 하므로, 정확히 동등한(various-drop-in) 순수 Python 버전을 만드는 건 불가능해요. 하지만 슬롯을 위한 실제 C 구조체를 비공개 _slotvalues 리스트로 흉내 내는, 대부분 충실한 시뮬레이션을 만들 수 있어요. 그 비공개 구조체에 대한 읽기·쓰기는 멤버 디스크립터가 관리해요.

null = object()

class Member:

    def __init__(self, name, clsname, offset):
        'Emulate PyMemberDef in Include/descrobject.h'
        # Also see descr_new() in Objects/descrobject.c
        self.name = name
        self.clsname = clsname
        self.offset = offset

    def __get__(self, obj, objtype=None):
        'Emulate member_get() in Objects/descrobject.c'
        # Also see PyMember_GetOne() in Python/structmember.c
        if obj is None:
            return self
        value = obj._slotvalues[self.offset]
        if value is null:
            raise AttributeError(self.name)
        return value

    def __set__(self, obj, value):
        'Emulate member_set() in Objects/descrobject.c'
        obj._slotvalues[self.offset] = value

    def __delete__(self, obj):
        'Emulate member_delete() in Objects/descrobject.c'
        value = obj._slotvalues[self.offset]
        if value is null:
            raise AttributeError(self.name)
        obj._slotvalues[self.offset] = null

    def __repr__(self):
        'Emulate member_repr() in Objects/descrobject.c'
        return f'<Member {self.name!r} of {self.clsname!r}>'

type.__new__() 메서드가 클래스 변수에 멤버 객체를 추가하는 일을 처리해요.

class Type(type):
    'Simulate how the type metaclass adds member objects for slots'

    def __new__(mcls, clsname, bases, mapping, **kwargs):
        'Emulate type_new() in Objects/typeobject.c'
        # type_new() calls PyTypeReady() which calls add_methods()
        slot_names = mapping.get('slot_names', [])
        for offset, name in enumerate(slot_names):
            mapping[name] = Member(name, clsname, offset)
        return type.__new__(mcls, clsname, bases, mapping, **kwargs)

object.__new__() 메서드가 인스턴스 사전 대신 슬롯을 가진 인스턴스를 만드는 일을 처리해요. 여기 순수 Python의 대략적인 시뮬레이션이 있어요.

class Object:
    'Simulate how object.__new__() allocates memory for __slots__'

    def __new__(cls, *args, **kwargs):
        'Emulate object_new() in Objects/typeobject.c'
        inst = super().__new__(cls)
        if hasattr(cls, 'slot_names'):
            empty_slots = [null] * len(cls.slot_names)
            object.__setattr__(inst, '_slotvalues', empty_slots)
        return inst

    def __setattr__(self, name, value):
        'Emulate _PyObject_GenericSetAttrWithDict() Objects/object.c'
        cls = type(self)
        if hasattr(cls, 'slot_names') and name not in cls.slot_names:
            raise AttributeError(
                f'{cls.__name__!r} object has no attribute {name!r}'
            )
        super().__setattr__(name, value)

    def __delattr__(self, name):
        'Emulate _PyObject_GenericSetAttrWithDict() Objects/object.c'
        cls = type(self)
        if hasattr(cls, 'slot_names') and name not in cls.slot_names:
            raise AttributeError(
                f'{cls.__name__!r} object has no attribute {name!r}'
            )
        super().__delattr__(name)

시뮬레이션을 실제 클래스에서 쓰려면 Object에서 상속받고 메타클래스를 Type으로 설정하면 돼요.

class H(Object, metaclass=Type):
    'Instance variables stored in slots'

    slot_names = ['x', 'y']

    def __init__(self, x, y):
        self.x = x
        self.y = y

이 시점에 메타클래스가 xy에 대한 멤버 객체를 로드했어요.

>>> from pprint import pp
>>> pp(dict(vars(H)))
{'__module__': '__main__',
 '__doc__': 'Instance variables stored in slots',
 'slot_names': ['x', 'y'],
 '__init__': <function H.__init__ at 0x7fb5d302f9d0>,
 'x': <Member 'x' of 'H'>,
 'y': <Member 'y' of 'H'>}

인스턴스가 만들어지면 속성이 저장되는 slot_values 리스트를 가져요.

>>> h = H(10, 20)
>>> vars(h)
{'_slotvalues': [10, 20]}
>>> h.x = 55
>>> vars(h)
{'_slotvalues': [55, 20]}

오타가 나거나 할당되지 않은 속성은 예외를 일으켜요.

>>> h.xz
Traceback (most recent call last):
    ...
AttributeError: 'H' object has no attribute 'xz'

더 알아보기 (Learn more)

  • 디스크립터의 프로토콜 정의는 라이브러리 문서의 디스크립터 프로토콜 절을 보세요.
  • property, staticmethod, classmethod, 그리고 __slots__의 레퍼런스 문서를 함께 읽으면 실제 동작과 대조할 수 있어요.