본문 바로가기
프로그래밍/Python

[Python] Class Metaprogramming

by 별준 2022. 4. 1.

References

  • Fluent Python

Contents

  • Class Factory Function, Class Builder, Class Decorator
  • Import Time vs. Runtime
  • Basic of Metaclasses

클래스 메타프로그래밍(Class Metaprogramming)은 실행 도중에 클래스를 생성하거나 커스터마이징하는 기술을 말합니다. 클래스는 파이썬의 일급 객체이므로, class라는 키워드를 사용하지 않고도 언제든 함수를 사용하여 생성할 수 있습니다. 클래스 데코레이터도 함수지만, 데코레이트된 클래스를 조사하고, 변경하고, 심지어 다른 클래스로 대체할 수 있습니다. 메타클래스(metaclasses)는 클래스 메타프로그래밍을 하기 위한 도구로서, 추상 베이스 클래스(ABC)처럼 특별한 특성이 있는 완전히 새로운 카테고리의 클래스를 만들 수 있게 해줍니다.

 

메타클래스는 강력하지만, 제대로 사용하기는 어렵습니다. 클래스 데코레이터는 이와 같은 문제의 상당 부분을 간단하게 해결해줍니다. 게다가 파이썬 3.6은 PEP-487을 구현하여 이전에 메타클래스 또는 클래스 데코레이터가 요구했던 태스크들을 지원하는 스페셜 메소드들을 제공합니다.

 


Classes as Objects

파이썬의 대부분의 프로그램 개체들과 마찬가지로, 클래스 또한 객체입니다. 모든 클래스에는 파이썬 데이터 모델에서 정의된 여러 속성들을 가지고 있는데, 이는 공식 문서(link)에서 확인하실 수 있습니다. 이전 포스팅에게 __class__, __name__, __mro__와 같은 속성들을 몇 번 소개하기는 했습니다. 여기서 살펴볼 다른 클래스 표준 속성은 다음과 같습니다.

  • cls.__bases__ : 클래스의 베이스 클래스들의 튜플

  • cls.__qualname__ : 모듈의 global 스코프에서 클래스 정의까지의 점으로 구분된 경로이며, 클래스 또는 함수의 qualified name 입니다. 예를 들어, Ox와 같은 장고 모델 클래스에는 Meta라는 내부 클래스가 있습니다. 이때, Meta의 __qualname__은 Ox.Meta이지만, __name__은 Meta 입니다. 이 속성에 대한 스펙은 PEP-3155에 있습니다.

  • cls.__subclasses__() : 이 메소드는 클래스에 직접적인 서브클래스 목록을 리턴합니다. 이 메소드의 구현은 약한 참조(weak references)를 사용하여 수퍼클래스와 서브클래스 간의 순환 참조를 방지합니다. 이 메소드는 현재 메모리에 있는 서브클래스들을 나열합니다.

  • cls.mro() : 인터프리터는 클래스를 빌드할 때, 클래스의 __mro__ 속성에 저장된 수퍼클래스 튜플을 얻기 위해서 이 메소드를 호출합니다. 메타클래스는 이 메소드를 재정의하여 생성 중인 클래스의 method resolution order를 커스터마이즈할 수 있습니다.

 


type: The Built-in Class Factory

보통 type을 어느 객체의 클래스를 리턴하는 함수로 생각합니다. 이는 type(my_object)가 my_object.__class__를 리턴하기 때문입니다.

그러나, type은 3개의 인수를 가지고 invoke될 때 새로운 클래스를 생성하는 클래스입니다.

 

다음의 간단한 클래스를 살펴보겠습니다.

class MyClass(MySuperClass, MyMixin):
    x = 42

    def x2(self):
        return self.x * 2

 

type 생성자를 사용하여, 아래의 코드를 통해 MyClass라는 클래스를 런타임에 생성할 수 있습니다.

MyClass = type('MyClass', (MySuperClass, MyMixin),
           {'x': 42, 'x2': lambda self: self.x * 2})

여기서 type 호출은 기능적으로 위의 class MyClass 블록 구문과 동일합니다.

 

파이썬이 class 구문을 읽을 때, 클래스 객체를 생성하기 위해서 아래의 파라미터들과 함께 type을 호출합니다.

  • name: class 키워드 이후에 나타나는 식별자(identifier) (ex, MyClass)
  • bases: 클래스 식별자 뒤에 괄호 안에 주어지는 수퍼클래스의 튜플. 만약 class 구문에서 수퍼클래스가 없다면 (object, )가 됩니다.
  • dict: 속성 이름과 그 값의 매핑. Callable은 메소드가 되며, 다른 값들은 클래스 속성이 됩니다.
type 생성자는 optional keyword 인수를 받습니다.

 

type 클래스가 바로 메타클래스이며, type 클래스는 다른 클래스를 빌드합니다. 다시 말하자면, type 클래스의 인스턴스는 클래스입니다. 표준 라이브러리는 몇 가지 다른 메타클래스를 제공하는데, type이 default입니다.

 

아래에서는 커스텀 메타클래스를 만드는 방법에 대해서 살펴보겠습니다.

먼저, type을 사용하여 클래스를 빌드하는 함수를 만들어보겠습니다.

 


A Class Factory Function

이미 잘 알고 계시겠지만, 표준 라이브러리에는 collections.namedTuple()이라는 클래스 팩토리가 있습니다. 또한 collections.NamedTuple과 @dataclass도 있습니다.

 

먼저 아주 간단한 mutable 객체의 클래스르 위한 팩토리로 시작해보겠습니다. 이는 @dataclass를 가장 간단하게 대체할 수 있는 방법입니다.

 

예를 들어, 펫샵 어플리케이션을 만들고 있고, 개에 대한 데이터를 간단한 레코드로 처리하고 싶다고 가정해보겠습니다. 하지만, 다음과 같이 식상한 코드는 원하지 않습니다.

class Dog:
    def __init__(self, name, weight, owner):
        self.name = name
        self.weight = weight
        self.owner = owner

똑같은 필드명이 세 번씩 나오고, 이러한 코드에서 repr()로 출력한 내용도 마음에 들지 않습니다.

 

collections.namedtuple()에서 힌트를 얻어서 Dog와 같은 간단한 클래스를 즉석에서 생성하는 record_factory()를 만들어보겠습니다. 다음의 코드는 이 팩토리를 사용하는 예를 보여줍니다.

 

아래 코드는 record_factory() 함수를 구현한 코드입니다.

from typing import Union, Any
from collections.abc import Iterable, Iterator

# 사용자는 필드 이름으로 문자열 하나 또는 문자열의 반복형으로 제공할 수 있음
FieldNames = Union[str, Iterable[str]]

# 이 팩토리 함수는 collections.namedtuple()의 처음 두 인수를 받으며, type을 리턴함
# 반환된 type은 클래스이며 tuple처럼 동작함
def record_factory(cls_name: str, field_names: FieldNames) -> type[tuple]:
    # 속성 이름의 튜플을 구성하며, 이는 새로운 클래스의 __slots__ 속성이 됨
    slots = parse_identifiers(field_names)

    # 이 함수는 새로운 클래스의 __init__() 메소드가 되며, positional이나 keyword 인수를 받음
    # 실제 타입이 Any이므로, 타입 힌트는 추가하지 않았다
    def __init__(self, *args, **kwargs) -> None:
        attrs = dict(zip(self.__slots__, args))
        attrs.update(kwargs)
        for name, value in attrs.items():
            setattr(self, name, value)
    
    # __slots__에서 주어진 순서로 필드값을 생성(yield)한다
    def __iter__(self) -> Iterator[Any]:
        for name in self.__slots__:
            yield getattr(self, name)
    
    # __slots__와 self를 반복해서 예쁘게 출력하는 __repr__() 메소드를 정의함
    def __repr__(self):
        values = ', '.join(
            '{}={!r}'.format(*i) for i in zip(self.__slots__, self)
        )
        cls_name = self.__class__.__name__
        return f'{cls_name}({values})'
    
    # 클래스 속성의 딕셔너리를 조합
    cls_attrs = dict(
        __slots__=slots,
        __init__=__init__,
        __iter__=__iter__,
        __repr__=__repr__,
    )

    # type() 생성자를 호출해서 새로운 클래스를 생성하고 반환한다.
    return type(cls_name, (object,), cls_attrs)

def parse_identifiers(names: FieldNames) -> tuple[str, ...]:
    if isinstance(names, str):
        # names를 공백이나 콤바로 구분하여 문자열의 리스트로 변환
        names = names.replace(',', ' ').split()
    if not all(s.isidentifier() for s in names):
        raise ValueError('names must all be valid identifiers')
    return tuple(names)

요약하면, record_factory 함수의 마지막 return은 cls_name의 값으로 명명된 클래스를 빌드합니다. 이 클래스는 object를 베이스 클래스로 지정하고, __slots__, __init__, __iter__, __repr__로 로드된 네임스페이스를 사용하며 마지막 3개는 인스턴스 메소드입니다.

 

__slots__ 클래스 속성의 이름은 다른 이름으로 할 수도 있지만, 그러면 할당한 속성명을 검증하기 위해 __setattr__() 메소드를 구현해야 합니다. record 같은 클래스의 경우 속성들의 이름이 언제나 동일하고 같은 순서로 되어 있어야 하기 때문입니다. __slots__을 사용하면 수백만 객체를 사용할 때 메모리를 절약할 수 있다는 특징이 있지만, 단점도 존재합니다. __slots__에 대한 내용은 아래 포스팅을 참조해주세요.

[Python] Special Methods for Sequences

 

[Python] Special Methods for Sequences

References Fluent Python Contents Vector: User-Defined Sequence Type Protocols and Duck Typing Special Methods for Sequences [Python] A Pythonic Object 이번 포스팅에서는 위의 포스팅에서 구현한 2차원..

junstar92.tistory.com

 

 

이제 사용자 정의 클래스를 클래스 구문으로 작성하고 더 많은 기능을 가지도록 하는 typing.NamedTuple과 같은 모던 클래스 빌더를 생성하는 방법을 살펴보겠습니다.

 


Introducing __init_subclass__

__init_subclass__()와 __set_name__()는 PEP-487에서 제안되었습니다. 여기서 __set_name__은 아래의 포스팅에서 다루었으니 필요하시다면 참조바랍니다 !

[Python] Attribute Descriptor

 

typing.NamedTuple과 @dataclass는 프로그래머가 클래스 구문을 사용하여 새로운 클래스의 속성을 지정하고 __init__, __repr__, __eq__와 같은 중요한 메소드들을 자동으로 추가하는 클래스 빌더입니다. 두 클래스 빌더는 사용자의 클래스 구분에서 타입 힌트를 읽어서 클래스를 향상시킵니다. 이러한 타입 힌트는 또한 정적 타입 체커(static type checker)가 이 속성을 set/get하는 코드의 유효성을 검증하도록 합니다. 그러나 NamedTuple과 @dataclass는 런타임에 속성 유효성 검사를 위해 타입 힌트를 활용하지 않습니다. 아래의 Checked 클래스가 바로 이렇게 동작합니다.

속성 타입 힌트로 사용되는 생성자들은 zero 또는 하나의 인수를 받는 어떠한 callable이라도 될 수 있고, 의도한 필드 타입에 적절한 값을 리턴하거나 TypeError나 ValueError를 일으켜서 전달된 인수를 거절할 수도 있습니다.

 

위의 Movie 클래스 정의에서 어노테이션된 내장 타입을 사용한 것은 그 값들이 반드시 그 타입의 생성자에 acceptable 해야한다는 것을 의미합니다. 예를 들어 int의 경우, int(x)가 int를 반환하는 모든 x를 의미합니다. str의 경우에는 str(x)는 Python의 모든 x와 동작하기 때문에 런타임에 모든 것이 에러없이 동작됩니다.

 

아무런 인수도 없이 호출될 때, 생성자는 해당 타입의 기본값을 반환합니다.

다음은 파이썬의 내장 생성자들의 표준 동작을 보여줍니다.

 

Movie와 같은 Checked의 서브클래스는 누락된 매개변수에 필드 생성자에서 반환된 기본값으로 설정하여 인스턴스를 생성합니다. 예를 들면 다음과 같습니다. (int 타입인 year는 0, float 타입인 box_office는 0.0으로 설정됨)

생성자들은 인스턴스화하는 동안, 그리고 인스턴스의 속성을 직접 변경할 때 검증을 위해 사용됩니다.

 

이제 Checked 클래스의 구현을 살펴보기 전에, Checked 클래스에서 사용되는 Field 디스크립터 클래스의 구현을 살펴보겠습니다.

# 파이썬3.9부터 어노테이션을 위한 Callable은 ABC이며, collections.abc에 있음(typing.Callable은 삭제예정)
from collections.abc import Callable
from typing import Any, NoReturn, get_type_hints

class Field:
    # constructor에는 최소한의 Callable 타입 힌트 사용. constructor의 파라미터와 리턴 타입은 Any이기 때문에 생략
    def __init__(self, name: str, constructor: Callable) -> None:
        if not callable(constructor) or constructor is type(None):
            # 런타임 체크를 위해 내장된 callable을 사용
            raise TypeError(f'{name!r} type hint must be callable')
        self.name = name
        self.constructor = constructor
    
    def __set__(self, instance: Any, value: Any) -> None:
        if value is ...:
            # 만약 Checked.__init__이 value를 ...(Ellipsis)으로 설정한다면, 
            # 인수없이 constructor 호출
            value = self.constructor()
        else:
            # 그렇지 않다면 주어진 value로 constuctor 호출
            try:
                value = self.constructor(value)
            except (TypeError, ValueError) as e:
                # 만약 constructor가 이 예외들을 발생시키면, TypeError를 에러메시지와 함께 발생시킴
                # e.g. 'MMIX' is not compatible with year: int
                type_name = self.constructor.__name__
                msg = f'{value!r} is not compatible with {self.name}:{type_name}'
                raise TypeError(msg) from e
        # 예외가 발생하지 않았다면, value는 instance.__dict__에 저장
        instance.__dict__[self.name] = value

__set__ 메소드에서 우리는 TypeError와 ValueError를 캐치해야 하는데, 이는 내장 생성자들이 이 예외들을 발생시키기 때문입니다. 예를 들어, float(None)은 TypeError를 발생시키지만, float('A')는 ValueError를 발생시킵니다. 반면에, float('8')은 예외를 발생시키지 않고, 8.0을 리턴합니다. 

 

이제 Checked 클래스의 구현을 살펴보겠습니다. 

class Checked:
    @classmethod
    def _fields(cls) -> dict[str, type]: # get_type_hints를 래핑하는 클래스 메소드
        return get_type_hints(cls)
    
    # __init_subclass__()는 현재 서브클래스의 서브클래스가 정의될 때 호출되며,
    # 새로운 서브클래스를 첫 번째 인수로 가져온다.
    def __init_subclass__(subclass) -> None:
        super().__init_subclass__() # super().__init_subclass__()를 invoke해야한다.
        # 각 필드의 이름과 생성자를 반복하면서,
        # 서브클래스에 각 이름에 name과 constructor로 파라미터화되는 Field 디스크립터로 바인딩된
        # 속성을 생성한다
        for name, constructor in subclass._fields().items():
            setattr(subclass, name, Field(name, constructor))
        
    def __init__(self, **kwargs: Any) -> None:
        for name in self._fields(): # 클래스 필드의 각 이름을 반복
            # kwargs로부터 대응되는 값을 얻고, kwargs에서 name을 제거
            # ...을 default로 사용하면 None값이 주어진 인수와 값이 주어지지 않은 인수를 구분할 수 있음
            value = kwargs.pop(name, ...)
            # settattr은 Checked.__setattr__()을 트리거함
            setattr(self, name, value)
        if kwargs:
            # kwargs에 남아있는 아이템이 있다면, 이들은 선언된 필드에 매칭되지 않고 __init__은 실패함
            # 에러는 __flag_unknown_attrs에 의해 리포트됨. 이 메소드는 *names 인수를 받음
            # 따라서, kwargs의 key들을 시퀀스로 전달함
            self.__flag_unknown_attrs(*kwargs)
    
    # 인스턴스 속성을 설정하는 모든 시도를 가로챈다. 이는 unknown 속성을 설정하는 것을 방지하는데 필요
    def __setattr__(self, name: str, value: Any) -> None:
        if name in self._fields():
            # name 속성이 필드에 있다면 대응되는 디스크립터를 fetch함
            cls = self.__class__
            descriptor = getattr(cls, name)
            # 보통 디스크립터의 __set__()을 명시적으로 호출할 필요는 없다. 하지만, 여기서는 
            # __setattr__()이 Field와 같은 오버라이딩 디스크립터가 있는 경우를 포함하여
            # 인스턴스에 속성을 설정하는 모든 시도를 가로채기 때문에 필요함
            descriptor.__set__(self, value)
        else:
            # name 속성이 필드에 없다면, __flag_unknown_attrs에 의해 예외가 발생됨
            self.__flag_unknown_attrs(name)
    
    # 기대하지 않은 인수를 나열하는 메세지와 함께 AttributeError를 발생시킴
    # 잘 사용되지 않는 NoReturn 스페셜 타입의 예
    def __flag_unknown_attrs(self, *names: str) -> NoReturn:
        plural = 's' if len(names) > 1 else ''
        extra = ', '.join(f'{name!r}' for name in names)
        cls_name = repr(self.__class__.__name__)
        raise AttributeError(f'{cls_name} object has no attribute{plural} {extra}')
    
    # 인스턴스의 속성에서 딕셔너리를 생성. collections.namedtuple의 _asdict() 메소드 이름을 따랐음
    def _asdict(self) -> dict[str, Any]:
        return {
            name: getattr(self, name)
            for name, attr in self.__class__.__dict__.items() if isinstance(attr, Field)
        }
    
    # nice한 __repr__() 메소드를 구현. 이 메소드를 위해 _asdict()를 구현함
    def __repr__(self) -> str:
        kwargs = ', '.join(
            f'{key}={value!r}' for key, value in self._asdict().items()
        )
        return f'{self.__class__.__name__}({kwargs})'
- __INIT_SUBCLASS__ is Not A Typical Class Method
@classmethod 데코레이터는 절대 __init_subclass__()와 함께 사용되지 않지만, __new__ 스페셜 메소드는 @classmethod 없이도 클래스 메소드로 동작하기 때문에 큰 의미는 없습니다. Python이 __init__subclass__에 전달하는 첫 번째 인수는 클래스입니다(self가 아님). 그러나 __init_subclass__()가 구현된 클래스는 아니며, 해당 클래스의 새로 정의된 서브클래스입니다. 이는 __new__를 포함하여 알고 있는 다른 클래스 메소드와 다릅니다. 따라서 __init_subclass__()는 일반적인 의미의 클래스 메소드가 아니며, 첫 번째 인수의 이름을 cls로 지정하는 것은 잘못된 것이라고 저자는 생각합니다. 파이썬 공식 문서에서 __init_subclass__()의 인수 이름을 cls로 지정하지만 다음과 같이 설명합니다 : "...called whenever the containing class is subclassed. cls is then the new subclass"

위에서 구현한 Checked 클래스의 메소드 중 _fields와 _asdict 메소드의 이름은 collections.namedtuple의 API를 따라 명명되었습니다.

 

Checked 예제는 인스턴스화 된 후, 임의의 속성이 설정되는 것을 막히위해 __setattr__()을 구현할 때 오버라이딩 디스크립터를 처리하는 방법을 보여줍니다. 이 예제에서 __setattr__()을 구현하는 것이 의미가 있는지에 대한 논쟁의 여지는 있습니다. 만약 __setattr__()이 없다면 movie.director = 'Greta Gerwig' 문장이 성공하지만, director 속성이 체크인되지 않고 __repr__()에도 나타나지 않으며, _asdict()에서 반환한 딕셔너리에도 포함되지 않습니다.

 

record_factory() 클래스 팩토리 함수에서는 __slots__ 클래스 속성을 사용해서 인스턴스 속성이 설정되는 것을 막습니다. 하지만, __slots__을 사용하는 것은 Checked 클래스에서는 불가능합니다.

 

Why __init_subclass__ cannot configure __slots__

__slots__ 속성은 오직 type.__new__에 전달된 클래스 네임스페이스 안에 있는 엔티티 중의 하나일 때만 효과가 있습니다. 존재하는 클래스에 __slots__을 추가하는 것은 효과가 없습니다. 파이썬은 __init_subclass__()를 오직 클래스가 빌드된 이후에 호출합니다. 따라서 __slots__을 설정하기에는 너무 늦습니다. 클래스 데코레이터 또한 __slots__을 설정할 수 없으며, 이는 __init_subclass__() 보다 더 늦기 때문입니다. 이와 관련한 타이밍 이슈는 아래에서 Import Time과 Runtime과 비교하면서 살펴보도록 할 예정입니다.

 

런타임에 __slots__()을 설정하기 위해서는 사용자 코드에서 type.__new__()의 마지막 인수로 전달된 클래스 네임스페이스를 빌드해야 합니다. 그렇게 하려면 record_factory()와 같은 클래스 팩토리 함수를 작성하거나 nuclear 옵션을 취하고 메타클래스를 구현해야 합니다. __slots__을 동적으로 설정하는 방법 또한 아래에서 메타클래스에 대해 알아볼 때 살펴보겠습니다.

 


Enhancing Classes with a Class Decorator

PEP-487가 파이썬 3.7에서 __init_subclass__()를 사용하여 클래스 생성자의 커스터마이징을 단순화하기 전에, 클래스 데코레이터를 사용하여 유사한 기능을 구현해야 했습니다. 이에 대해서 알아보도록 하겠습니다.

 

클래스 데코레이터는 함수 데코레이터와 유사하게 동작하는 Callable입니다. 인수로 데코레이트되는 클래스를 받으며, 반드시 데코레이트되는 클래스를 대체하는 클래스를 반환합니다. 클래스 데코레이터는 종종 데코레이트되는 클래스 자체를 반환하기도 하는데, 이런 경우에는 주로 속성 할당을 통해 더 많은 메소드가 추가되어서 반환됩니다.

 

아마 더 간단한 __init_subclass__() 대신 클래스 데코레이터를 선택하는 가장 일반적인 이유는 상속 및 메타클래스와 같은 다른 클래스 기능을 방해하지 않기 위함일 것입니다.

 

그럼 이번에는 위에서 구현한 Checked 클래스와 동일한 동작을 수행하는 클래스 데코레이터 checked를 구현해보도록 하겠습니다. 구현된 checked는 위에서 본 예제 코드와 똑같이 아래처럼 동일한 동작을 수행하게 됩니다.

위에서 구현한 Checked 클래스와의 차이점은 오직 Movie 클래스를 선언하는 방법입니다. 여기서는 Checked 클래스를 상속하는 것이 아닌 @checked로 데코레이트합니다. 그 이외의 외부 동작(타입 검증, 기본값 할당)은 동일합니다.

 

이제 @checked 데코레이터의 구현을 살펴보겠습니다. 여기서 이전에 구현한 Field 클래스를 그대로 사용하며, import하는 패키지도 동일합니다.

이전에 __init_subclass__()에서 구현한 로직은 이제 checked 함수의 일부가 되며, 클래스 데코레이터 checked 함수는 다음과 같이 구현됩니다.

def checked(cls: type) -> type: # type의 인스턴스는 클래스이다!
    # _fields()는 module-level 함수이다
    for name, constructor in _fields(cls).items():
        # _fields에서 반환된 각 속성을 Field 디스크립터 인스턴스로 대체한다
        # __init_subclass__()의 동작과 동일
        setattr(cls, name, Field(name, constructor))
    
    # _fields를 클래스 메소드로 빌드하고, 이를 데코레이트되는 클래스에 추가한다
    # Mypy가 type에 _fields 속성이 없다고 출력하기 때문에 ignore 코멘트가 필요하다
    cls._fields = classmethod(_fields) # type: ignore

    # Module-level의 함수들이며, 이는 데코레이트되는 클래스의 인스턴스 메소드가 된다
    instance_methods = (
        __init__,
        __repr__,
        __setattr__,
        _asdict,
        __flag_unknown_attrs,
    )
    for method in instance_methods: # instance_methods들을 cls에 추가한다
        setattr(cls, method.__name__, method)
    
    return cls # 데코레이트되는 cls를 반환. 필요한 기능들이 추가되었다

 

checked 데코레이터와 함께 구현되는 모든 top-level 함수(_field, ...)들은 밑줄(_)로 prefix됩니다. 이 naming conventions은 다음과 같이 이유 때문입니다.

  • checked는 모듈의 public 인터페이스이지만, 다른 함수들은 아닙니다.
  • 아래에서 살펴볼 나머지 함수들은 데코레이트되는 클래스에 주입되며, '_'를 붙이는 것은 사용자가 정의한 속성이나 메소드와 이름 충돌의 가능성을 줄여줍니다.

다음은 checked 데코레이터와 함께 구현되는 module-level의 함수들입니다. 이 함수들은 Checked 클래스에서 구현한 각 메소드들의 구현과 동일합니다. 코드가 동일하므로 설명은 생략하도록 하겠습니다.

def _fields(cls: type) -> dict[str, type]:
    return get_type_hints(cls)

def __init__(self: Any, **kwargs: Any) -> None:
    for name in self._fields():
        value = kwargs.pop(name, ...)
        setattr(self, name, value)
    if kwargs:
        self.__flag_unknown_attrs(*kwargs)

def __setattr__(self: Any, name: str, value: Any) -> None:
    if name in self._fields():
        cls = self.__class__
        descriptor = getattr(cls, name)
        descriptor.__set__(self, value)
    else:
        self.__flag_unknown_attrs(name)

def __flag_unknown_attrs(self: Any, *names: str) -> NoReturn:
    plural = 's' if len(names) > 1 else ''
    extra = ', '.join(f'{name!r}' for name in names)
    cls_name = repr(self.__class__.__name__)
    raise AttributeError(f'{cls_name} has no attribute{plural} {extra}')

def _asdict(self: Any) -> dict[str, Any]:
    return {
        name: getattr(self, name) for name, attr in self.__class__.__dict__.items() if isinstance(attr, Field)
    }

def __repr__(self: Any) -> str:
    kwargs = ', '.join(
        f'{key}={value!r}' for key, value in self._asdict().items()
    )
    return f'{self.__class__.__name__}({kwargs})'

여기서 _fields() 함수는 checked 데코레이터의 첫 번째 line에서 일반 함수로도 사용되며, 데코레이트되는 클래스의 클래스 메소드로도 추가됩니다.

 

간단하지만 사용할 수 있는 클래스 데코레이터를 구현했습니다. Python의 @dataclass는 이보다 훨씬 더 많은 작업을 수행합니다. 더 많은 구성 옵션을 지원하고, 데코레이트되는 클래스에 더 많은 케소드를 추가하며, 데코레이트되는 클래스의 사용자 정의 메소드와의 충돌을 처리하거나 경고하기도 하며, 심지어 __mro__를 살펴보고 데코레이트되는 클래스의 수퍼클래스에 선언된 사용자 정의 속성을 수집합니다. 이러한 이유로 파이썬 3.9에서 dataclass 패키지 소스 코드는 1200줄 이상입니다.

 


What Happends When: Import Time vs. Runtime

성공적으로 메타프로그래밍 클래스 구현하려면, 파이썬 인터프리터가 클래스를 구성하는 동안 각 코드 블록을 언제 평가하는지 알고 있어야 합니다. 이번에는 이에 대해서 살펴보도록 하겠습니다.

 

파이썬 프로그래머들은 'import time'과 'runtime'을 구분하지만, 이 용어들은 엄격히 정의되어 있지 않으며 구분히 모호한 경우도 있습니다.

Import time에서 인터프리터는

  • top->bottom 방향으로 .py 모듈의 소스 코드를 파싱하며, 이때 SyntaxError가 발생할 수 있습니다.
  • 실행할 바이트코드를 컴파일합니다.
  • 컴파일된 모듈의 top-level code를 실행합니다.

만약 local __pycache__ 디렉토리에 최신 .pyc 파일이 있으면 파이트코드를 실행할 준비가 된 것이므로 이 과정을 생략합니다.

 

비록 파싱과 컴파일이 import time의 동작이긴 하지만, 이때 다른 일들도 발생합니다. 이는 대부분의 파이썬 문장이 사용자 코드를 실행하고 사용자 프로그램의 상태를 변경한다는 의미에서 실행될 수 있기 때문입니다. 특히, import문은 단순히 선언이 아니라 프로세스에서 처음으로 가져올 때 모듈의 모든 top-level 코드를 실제로 실행합니다. 이후에 다시 import되는 경우에는 동일 모듈의 캐시를 사용하고 이름들만 바인딩합니다. top-level 수준의 코드에는 데이터베이스를 연결하는 등 일반적으로 'runtime'에 수행하는 작업들이 포함될 수 있습니다. 이처럼 import문이 각종 'runtime'의 동작을을 수행하기 때문에, 'import time'과 'runtime'의 구분히 모호해집니다.

 

다소 추상적으로 설명했는데, 예제 코드를 통해 언제 어떤 일이 발생하는지 구체적으로 살펴보도록 하겠습니다.

 

Evaluation Time Experiments

먼저 실험에 사용할 클래스 데코레이터와 디스크립터, 클래스 빌드가 구현되는 builderlib.py 모듈을 작성합니다. 이 모듈은 여러 print() 호출이 있는데 어떤 일들이 발생하는지 출력해줍니다. 사실 이 모듈에서 구현되는 것들이 유용한 동작을 수행하는 것은 아니며, 단지 print()를 호출하면서 어떠한 일이 발생하는지만 보여주는 역할만 수행합니다.

""" builderlib.py """
print('@ builderlib module start')

# 클래스 빌더
class Builder:
    print('@ Builder body')

    def __init_subclass__(cls):
        print(f'@ Builder.__init_subclass__({cls!r})')
        # 메소드 내부에서 함수를 정의하고, 아래에서 서브클래스에 할당문을 통해 추가
        def inner_0(self):
            print(f'@ SuperA.__init_subclass__:inner_0({self!r})')
        
        cls.method_a = inner_0
    
    def __init__(self):
        super().__init__()
        print(f'@ Builder.__init__({self!r})')

# 클래스 데코레이터
def deco(cls):
    print(f'@ deco({cls!r})')

    # 데코레이트되는 클래스에 추가되는 함수
    def inner_1(self):
        print(f'@ deco:inner_1({self!r})')

    cls.method_b = inner_1
    return cls # 인수로 전달받은 클래스를 반환

# 디스크립터 클래스
class Descriptor:
    print('@ Descriptor body')

    def __init__(self): # 디스크립터 인스턴스가 생성될 때 호출
        print(f'@ Descriptor.__init__({self!r})')
    
    def __set_name__(self, owner, name): # owner 클래스 생성동안 호출됨
        args = (self, owner, name)
        print(f'@ Descriptor.__set_name__{args!r}')
    
    def __set__(self, instance, value): 
        args = (self, instance, value)
        print(f'@ Descriptor.__set__{args!r}')
    
    def __repr__(self):
        return '<Descriptor instance>'

print('@ builderlib module end')

이렇게 작성한 모듈을 콘솔에서 import하면, 다음의 출력을 확인할 수 있습니다.

 

이제 evaldemo.py 모듈을 작성해보도록 하겠습니다. 이 모듈에서는 위에서 정의한 것들을 import해서 사용합니다.

""" evaldemo.py """
from builderlib import Builder, deco, Descriptor

print('# evaldemo module start')

@deco
class Klass(Builder): # Builder를 서브클래싱하면 __init_subclass__()를 호출함
    print('# Klass body')

    attr = Descriptor() # 디스크립터 인스턴스화

    def __init__(self):
        super().__init__()
        print(f'# Klass.__init__({self!r})')
    
    def __repr__(self):
        return '<Klass instance>'

def main(): # 이 함수는 모듈이 메인프로그램에서 동작할 때만 호출됨
    obj = Klass()
    obj.method_a()
    obj.method_b()
    obj.attr = 999

if __name__ == '__main__':
    main()

print('# evaldemo module end')

evaldemo.py는 '#'을 붙여서 출력합니다.

 

다시 콘솔을 열어서 evaldemo.py를 import하면 다음의 출력을 확인하실 수 있습니다. 이때 __pycahce__에 생성된 builderlib의 pyc 파일을 삭제해주고 import 합니다.

처음 4줄은 'from builderlib import ...' 의 결과입니다. 만약 __pycache__에 생성된 builderlib의 pyc 파일을 삭제하지 않고 import했다면 이 4줄의 출력은 나타나지 않습니다.

여섯 번째 줄(# Klass body)은 파이썬이 Klass의 body를 읽기 시작했다는 것을 보여줍니다. 이 시점에서 아직 클래스 객체는 존재하지 않습니다. 

일곱 번째 줄의 출력에서는 디스크립터 인스턴스가 생성되고 파이썬이 디폴트 클래스 객체 생성자 type.__new__에 전달할 네임스페이스의 attr에 바인딩 됩니다.

여덟 번째 줄 시점에서는 파이썬의 내장 type.__new__가 Klass 객체를 생성하고 각 디스크립터 인스턴스의 __set_name__()을 호출하여 Klass를 owner 인수로 전달합니다.

아홉 번째 줄에서는 type.__new__가 Klass의 수퍼클래스에 대해 __init_subclass__()를 호출하고 Klass를 단일 인수로 전달합니다.

열 번째 줄의 출력에서 type.__new__가 클래스 객체를 리턴할 때, 파이썬은 데코레이터를 적용한다는 것을 볼 수 있습니다. 이 예제에서 deco에 의해 반환되는 클래스는 모듈 네임스페이스에서 Klass에 바인딩됩니다.

 

type.__new__의 구현은 C로 작성되었습니다. 이 동작은 파이썬의 Data Model 레퍼런스의 Creating the class object 섹션(link)에서 설명합니다.

 

evaldemo.py의 main() 함수는 위의 콘솔 세션에서는 실행되지 않습니다. 따라서 Klass의 인스턴스는 생성되지 않습니다. 지금까지 살펴본 모든 동작은 'import time' 동작(builderlib 임포트와 Klass 정의)에 의해 트리거된 것입니다.

 

만약 evaldemo.py를 스크립트로 실행하면, 다음과 같은 출력을 확인할 수 있습니다. 위에서 살펴본 출력이 동일하게 나타나고, 그 이후 나머지 출력은 main()이 실행된 결과입니다.

10번째 줄의 "deco(<class '__main__.Klass'>)"까지의 출력은 evaldemo를 import했을 때와 동일한 출력입니다.

11번째 줄의 출력은 Klass.__init__()에서 super().__init__()에 의해 출력됩니다.

13번째 줄의 출력은 main()에서 obj.method_a()에 의해 출력되며, method_a는 SuperA.__init_subclass__()에서 추가되었습니다.

14번째 줄의 출력은 main()에서 obj.method_b()에 의해 출력됩니다. method_b는 deco에 의해 추가되었습니다.

15번째 줄의 출력은 main()에서 obj.attr = 999에 의해 출력됩니다.

 

 

__init_subclass__()를 가진 베이스 클래스와 클래스 데코레이터는 강력한 도구이지만, 이미 type.__new__에 의해 빌드된 클래스로 작업하는 것으로 제한됩니다. 드물지만, type.__new__에 전달된 인수를 조정해야 하는 경우에는 메타클래스가 필요합니다. 아래에서는 메타클래스의 기본에 대해서 살펴보겠습니다.

 


Basic of Metaclasses

메타클래스는 일종의 클래스 팩토리입니다. 포스팅 처음에 구현했던 record_factory()와 같은 함수 대신 클래스로 만들어진다는 점이 다릅니다. 아래 그림은 메타클래스를 표현하고 있는데, 공장이 또 다른 공장을 만드는 것으로 표현하고 있습니다.

파이썬 객체 모델을 생각해보겠습니다. 클래스도 객체이므로, 각 클래스는 다른 어떤 클래스의 객체이어야 합니다. 기본적으로 파이썬 클래스는 type의 객체입니다. 즉, type은 대부분의 내장된 클래스와 사용자 정의 클래스에 대한 메타클래스입니다.

무한 회귀를 방지하기 위해, type의 클래스는 type입니다.

 

여기서 str이나 temp가 type을 상속한다는 것이 아니라, str과 temp 클래스가 모두 type의 객체라는 점을 강조합니다. 이들 클래스는 object의 서브클래스입니다.

즉, 그림으로 표현하면 다음과 같습니다. (LineItem은 temp라고 이해하시면 됩니다!)

object와 type 클래스는 독특한 관계를 맺고 있습니다. object는 type의 객체이며, type은 object의 서브클래스입니다. 이 관계는 "magic"과 같으며 파이썬으로는 표현할 수 없는데, 두 클래스 모두 다른 클래스를 정의하기 전에 존재해야 하기 때문입니다. type 클래스가 type 자신의 객체라는 사실도 신기합니다.

 

다음의 예제 코드는 collections.Iterable의 클래스가 abc.ABCMeta라는 것을 보여줍니다. Iterable은 추상 클래스이지만, ABCMeta는 구체화된 클래스입니다. 결국, Iterable은 ABCMeta의 인스턴스입니다.

궁극적으로 ABCMeta의 클래스도 type입니다. 모든 클래스는 직간접적으로 type의 객체이지만, 메타클래스만 type의 서브클래스입니다. 메타클래스를 이해하려면 이점에 유의해야 합니다. ABCMeta 등의 메타클래스는 type으로부터 클래스 생성 능력을 상속합니다. 아래 그림은 이러한 중요한 관계를 잘 보여줍니다.

정리하면, 모든 클래스는 type의 객체이지만, 메타클래스는 type의 서브클래스이기도 하므로, 클래스 팩토리로서 동작합니다. 메타클래스는 스페셜 메소드를 구현하여 인스턴스를 커스타이즈할 수 있는데 이는 아래에서 살펴보도록 하겠습니다.

 

How a Metaclass Customizes a Class

메타클래스를 사용하기 위해서, __new__()가 동작하는 방법을 이해하는 것이 중요합니다. __new__에 대해서는 아래 포스팅의 'Flexible Object Creation with __new__'([Python] 동적 속성과 프로퍼티)에서 다룬 적이 있으니 필요하시다면 참고바랍니다 !

 

메타클래스가 클래스인 새 인스턴스를 생성하려고 할 때 동일한 메커니즘이 'meta' 수준에서 발생합니다. 다음의 선언을 살펴보겠습니다.

class Klass(SuperKlass, metaclass=MetaKlass):
    x = 42
    def __init__(self, y):
        self.y = y

이 class 구문을 처리하기 위해서 파이썬은 MetaKlass.__new__()를 다음의 인수들과 함께 호출합니다.

  • meta_cls: 메타클래스 그 자체(MetaKlass). __new__()는 클래스 메소드로 동작하기 때문
  • cls_name: "Klass" 문자열
  • bases: 하나의 항목을 가진 튜플 (SuperKlass, ). 다중 상속의 경우에는 여러 항목들의 튜플이 됨
  • cls_dict: {x: 42, `__init__:<function init at 0x1009c4040>}과 같은 매핑형

MetaKlass.__new__()를 구현할 때, 인수들을 super().__new__()로 전달하기 전에 이 인수들을 조사하고 변경할 수 있습니다. 그리고 super.__new__()은 type.__new__()를 호출하여 새로운 클래스 객체를 생성합니다.

 

super().__new__()가 리턴한 이후, 새롭게 생성되는 클래스를 파이썬에 반환하기 전에 추가 처리를 적용할 수도 있습니다. 그런 다음 Python은 SuperKlass.__init_subclass__()를 호출하여 생성한 클래스를 전달한 다음 클래스 데코레이터가 있는 경우 여기에 적용합니다. 마지막으로 파이썬은 클래스 객체를 주변 네임스페이스의 이름에 바인딩합니다. class 구문이 top-level 구문인 경우 일반적으로 모듈의 global 네임스페이스입니다.

 

메타클래스 __new__()에서 수행되는 가장 일반적인 처리는 cls_dict의 항목을 추가하거나 교체하는 것입니다. 이는 생성 중인 클래스의 네임스페이스를 나타내는 매핑입니다. 예를 들어 super().__new__()를 호출하기 전에 cls_dict에 함수를 추가하여 생성 중인 클래스에 메소드를 추가할 수 있습니다. 하지만 메소드 추가는 클래스가 빌드된 후에도 수행할 수 있으므로 __init_subclass__() 또는 클래스 데코레이터를 사용하여 수행할 수도 있었습니다.

 

 

type.__new__()가 실행되기 전에 cls_dict에 추가해야 하는 속성 중 하나는 __slots__ 입니다. 위에서 __slots__은 __init_subclass__()에서 추가할 수 없다고 언급했었습니다. 메타클래스의 __new__() 메소드는 __slots__을 구성하기에 이상적인 장소입니다. 아래에서 그 방법에 대해서 살펴보겠습니다.

 

A Nice Metaclass Example

MetaBunch라는 메타클래스를 구현할텐데, 먼저 Bunch 베이스 클래스가 제공하는 동작을 먼저 살펴보겠습니다.

이전에 Checked 서브클래스에서는 필드명을 명명하기 위해 타입 힌트를 사용했지만, Bunch 서브클래스에서는 클래스 속성에 값을 할당하며, 이는 인스턴스 속성의 기본값이 됩니다. 생성된 __repr__()는 기본값과 동일한 속성의 인수는 생략하는 것을 볼 수 있습니다.

 

MetaBunch(Bunch의 메타클래스)는 사용자의 클래스에 선언된 클래스 속성에서 새 클래스에 대한 __slots__을 생성합니다. 이렇게 하면 선언되지 않은 속성이 인스턴스화된 이후에 할당되는 것을 차단합니다.

 

아래 코드는 MetaBunch의 구현입니다.

class MetaBunch(type): # type을 상속받는 새로운 메타클래스 생성
    # __new__()는 클래스 메소드로 동작하지만, 이 클래스는 메타클래스이므로,
    # 첫 번째 인수의 이름을 meta_cls로 명명
    # 나머지 인수는 type()을 호출하여 클래스를 생성할 때의 시그니처와 동일
    def __new__(meta_cls, cls_name, bases, cls_dict):
        # defaults는 속성 이름과 그들의 기본값의 매핑
        defaults = {}

        # 생성되는 클래스에 추가 될 메소드
        def __init__(self, **kwargs):
            # defaults를 읽고 대응되는 인스턴스 속성에 kwargs로부터 pop한 값이나 default 값을 설정
            for name, default in defaults.items():
                setattr(self, name, kwargs.pop(name, default))
            # 만약 아직 kwargs에 항목이 남아있다면, 이는 예기치 않은 동작
            # failing fast가 best practice이므로, kwargs에서 하나의 항목을 선택하고
            # 인스턴스의 속성에 설정하여 의도적으로 AttributeError를 트리거함
            if kwargs:
                setattr(self, *kwargs.popitem())
        
        # __repr__()은 생성자 호출과 동일한 문자열을 반환(e.g. Point(x=3))
        # 기본값과 동일한 키워드 인수는 생략함
        def __repr__(self):
            rep = ', '.join(f'{name}={value!r}'
                            for name, default in defaults.items()
                            if (value := getattr(self, name)) != default)
            return f'{cls_name}({rep})'
        
        # 새로운 클래스의 네임스페이스를 초기화
        new_dict = dict(__slots__=[], __init__=__init__, __repr__=__repr__)

        # 사용자 클래스의 네임스페이스를 반복
        for name, value in cls_dict.items():
            if name.startswith('__') and name.endswith('__'):
                # double underscore인 name이 발견되면,
                if name in new_dict:
                    raise AttributeError(f"Can't set {name!r} in {cls_name}")
                # 이미 존재하지 않는 경우에만 이 항목을 새로운 클래스 네임스페이스로 복사
                # 이렇게 함으로써 Python에서 설정한 기타 속성을 덮어쓰는 것을 방지함
                new_dict[name] = value
            else:
                # double underscore가 아닌 name을 __slots__에 추가하고, 그 값을 defaults에 저장
                new_dict['__slots__'].append(name)
                defaults[name] = value
        # 새로운 클래스를 빌드하고 리턴함
        return super().__new__(meta_cls, cls_name, bases, new_dict)

# 베이스 클래스를 제공하여, 유저가 MetaBunch를 볼 필요가 없도록 함
class Bunch(metaclass=MetaBunch):
    pass

MetaBunch는 super().__new__()를 호출하여 최종 클래스를 빌드하기 전에 __slots__을 구성하기 때문에 잘 동작합니다. 

 

 

다음으로 메타프로그램의 동작 순서를 살펴보도록 하겠습니다.

Metaclass Evaluation Time Experiment

포스팅 중반부에서 builderlib.py 모듈을 구현해서 'import time'과 'runtime'에서의 출력을 살펴보는 실험을 했었습니다. 이번에도 유사하게 실험을 할 텐데, 이전 실험에서 메타클래스가 추가된 것입니다. builderlib 모듈은 이전과 동일한 코드를 그대로 사용하고, 메타클래스를 정의하는 metalib 모듈만 새로 구현하고 실험해보도록 하겠습니다.

print('% metalib module start')

import collections

# __setitem__()을 오버라이딩하여 key와 value가 set될 때 display하도록 커스터마이징
class NosyDict(collections.UserDict):
    def __setitem__(self, key, value):
        args = (self, key, value)
        print(f'% NosyDict.__setitem__{args!r}')
        super().__setitem__(key, value)
    
    def __repr__(self):
        return '<NosyDict instance>'

class MetaKlass(type):
    print('% MetaKlass body')

    # __prepare__()은 클래스 메소드로 선언되어야 함
    # 파이썬이 __prepare__()를 호출할 때 생성 중인 클래스가 아직 존재하지 않기 때문
    # 파이썬은 __prepare__()을 메타클래스에서 호출하여 생성 중에 클래스의 네임스페이스를 가지고 있을 매핑을 얻음
    @classmethod
    def __prepare__(meta_cls, cls_name, bases):
        args = (meta_cls, cls_name, bases)
        print(f'% MetaKlass.__prepare__{args!r}')
        # 네임스페이스로 사용할 NosyDict 인스턴스를 반환
        return NosyDict()
    
    # cls_dict는 __prepare__()에 의해 반환된 NosyDict 인스턴스이다
    def __new__(meta_cls, cls_name, bases, cls_dict):
        args = (meta_cls, cls_name, bases, cls_dict)
        print(f'% MetaKlass.__new__{args!r}')
        
        def inner_2(self):
            print(f'% MetaKlass.__new__:inner_2({self!r})')
        
        # type.__new__()는 마지막 인수로 실제 dict를 요구하므로,
        # UserDict을 상속하는 NosyDict의 data 속성을 전달
        cls = super().__new__(meta_cls, cls_name, bases, cls_dict.data)
        # 새로 생성되는 클래스에 메소드를 추가
        cls.method_c = inner_2

        # 일반적으로 __new__()는 방금 생성된 객체(여기서는 새로운 클래스)를 반환해야 함
        return cls
    
    # 메타클래스에서 __repr__()을 정의하면 클래스 객체의 repr()을 커스터마이즈할 수 있음
    def __repr__(cls):
        cls_name = cls.__name__
        return f"<class {cls_name!r} built by MetaKlass>"

print('% metalib module end')

여기서 주목해야할 부분은 __prepare__() 스페셜 메소드의 구현이며, 이 메소드는 메타클래스에서만 호출되며 반드시 클래스 메소드이어야 합니다(@classmethod로 데코레이트). __prepare__() 메소드는 새로운 클래스를 만드는 프로세스에 영향을 줄 수 있는 가장 빠른 기회를 제공합니다.

 

파이썬 3.6 이전의 __prepare__()가 주로 사용되는 케이스는 생성 중인 클래스의 속성을 유지하기 위해 OrderedDict를 제공하여 메타클래스의 __new__()가 사용자 클래스 정의의 소스 코드에 나타나는 순서대로 해당 속성을 처리할 수 있도록 하는 것이었습니다. 파이썬 3.6 이후에서 dict는 삽입 순서를 유지하므로 __prepare__()는 거의 필요하지 않습니다. 아래에서 __prepare__()를 사용하는 다른 방법에 대해서 살펴볼 예정입니다.

 

metalib.py를 파이썬 콘솔에서 import하여 출력되는 것은 딱히 흥미로운 것은 아닙니다. 여기서 %는 이 모듈에서 출력되었다는 것을 의미합니다.

 

이제 테스트를 수행할 evaldemo_meta.py 스크립트를 작성하겠습니다. 위에서 작성한 evaldemo.py에서 메타클래스만 추가된 것입니다.

from builderlib import Builder, deco, Descriptor
from metalib import MetaKlass # 구현한 MetaKlass import

print('# evaldemo_meta module start')

@deco
class Klass(Builder, metaclass=MetaKlass): # Klass를 Builder의 서브스크립트, MetaKlass의 인스턴스로 선언
    print('# Klass body')

    attr = Descriptor()

    def __init__(self):
        super().__init__()
        print(f'# Klass.__init__({self!r})')
    
    def __repr__(self):
        return '<Klass instance>'

def main():
    obj = Klass()
    obj.method_a()
    obj.method_b()
    obj.method_c() # MetaKlass에 의해 추가된 메소드
    obj.attr = 999

if __name__ == '__main__':
    main()

print('# evaldemo_meta module end')

 

이번에는 evaldemo_meta 모듈을 import 해보도록 하겠습니다.

출력 결과는 다음과 같습니다.

8번째 줄의 '# evaldemo_meta module start' 이전의 출력은 builderlib.py와 metalib.py를 import한 결과입니다.

그리고 9번째 줄의 '# MetaKlass.__prepare__(...)' 출력을 보면 파이썬은 class 구문 처리를 시작하기 위해서 __prepare__()를 호출한다는 것을 알 수 있습니다. 그리고 다음 줄(10~11 line)에서 클래스의 body를 파싱하기 전에 파이썬은 __module__과 __qualname__을 생성 중인 클래스의 네임스페이스에 추가합니다.

Klass body가 시작된 이후에 13번째 줄에서 Descriptor 인스턴스가 생성되는 것을 볼 수 있고, 14번째 줄에서 생성된 Descriptor 인스턴스가 attr에 바인딩되는 것을 볼 수 있습니다.

그리고 그 다음 줄(15~16 line)에서 __init__()과 __repr__() 메소드가 정의되고 네임스페이스에 추가됩니다.

18번째 줄의 출력 'MetaKlass.__new__(...)'에서 파이썬이 클래스 body의 처리를 끝낸 직후, MetaKlass.__new__()를 호출한다는 것을 알 수 있습니다.

그리고 메타클래스의 __new__() 메소드가 새롭게 생성된 클래스를 반환한 이후에 __set_name__(), __init_subclass__(), 그리고 데코레이터가 순서대로 호출된다는 것을 볼 수 있습니다.

 

evaldemo_meta.py를 스크립트로 실행하면 main() 함수가 호출되는데, 다음과 같이 몇 가지 출력이 더 나타납니다.

위 이미지의 첫 번째 줄을 포함한 이전 출력들은 import할 때와 동일합니다. 그리고 6번째 줄에서 main() 함수 내의 obj.method_c()에 의해서 트리거되는 출력을 확인할 수 있습니다. 이 메소드는 MetaKlass.__new__()에서 추가된 메소드입니다.

 


A Metaclass solution for Checked Class

다시 위에서 구현했던 Checked 클래스로 돌아가서, 동일한 역할을 수행하는 메타클래스를 구현하는 방법에 대해 알아보겠습니다. 그리고 여기서는 __slots__을 추가합니다.

 

위에서 구현했던 Checked의 Complexity는 사용자로부터 추상화됩니다. 다음 코드는 Checked가 정의된 패키지를 사용하는 스크립트의 소스 코드입니다. 위에서 예제 코드로 살펴봤던 것과 동일합니다.

from checkedlib import Checked

class Movie(Checked):
    title: str
    year: int
    box_office: float

if __name__ == '__main__':
    movie = Movie(title='The Godfather', year=1972, box_office=137)
    print(movie)
    print(movie.title)

이렇게 간결하게 작성된 Movide 클래스 정의는 검증을 위한 디스크립터인 3개의 Field 인스턴스, __slots__, Checked로부터 상속받는 5개의 메소드, 그리고 이 모든 것들을 함께 묶어주는 메타클래스를 활용합니다. 하지만 여기서 유일하게 볼 수 있는 것은 checkedlib의 Checked 베이스 클래스입니다.

 

아래의 그림은 클래스와 인스턴스들을 조금 더 시각화해서 보여줍니다.

예를 들어, Movie 클래스는 CheckedMeta의 인스턴스이고, Checked의 서브클래스입니다. 또한, title, year, box_office 클래스 속성은 각각 Field의 인스턴스이고, 각 Movie 인스턴스는 자신만의 _title, _year, _box_office 속성을 가지고 있으며 대응되는 필드의 값을 저장합니다.

 

이제 소스 코드를 살펴보도록 할텐데, Field 클래스부터 살펴보겠습니다.

Field 디스크립터 클래스는 이전과 조금 달라집니다. 이전에 살펴봤던 예제에서 Field 디스크립터 인스턴스는 관리대상 인스턴스(Movie 인스턴스) 내에서 동일한 이름의 속성을 사용해 이들의 값을 저장합니다. 이러한 이유로 Field가 __get__() 메소드를 제공할 필요가 없습니다.

그러나, Movie와 같은 클래스가 __slots__을 사용할 때, 동일한 이름의 클래스 속성과 인스턴스 속성을 가질 수 없습니다. 각 디스크립터 인스턴스는 클래스 속성이며, 이제 우리는 각각의 분리된 인스턴스 저장 속성이 필요합니다. 코드에서는 디스크립터 이름에 하나의 '_'를 앞에 붙여서 사용합니다. 따라서 Field 인스턴스는 분리된 name과 storage_name 속성을 가지며, Field.__get__()을 구현합니다.

class Field:
    def __init__(self, name: str, constructor: Callable) -> None:
        if not callable(constructor) or constructor is type(None):
            raise TypeError(f'{name!r} type hint must be callable')
        self.name = name
        self.storage_name = '_' + name # name 인수로부터 storage_name 결정
        self.constructor = constructor
    
    # __get__() 메소드 구현
    def __get__(self, instance, owner=None):
        # getattr()과 storage_namew을 사용한다
        return getattr(instance, self.storage_name)

    def __set__(self, instance: Any, value: Any) -> None:
        if value is ...:
            value = self.constructor()
        else:
            try:
                value = self.constructor(value)
            except (TypeError, ValueError) as e:
                type_name = self.constructor.__name__
                msg = f'{value!r} is not compatible with {self.name}:{type_name}'
                raise TypeError(msg) from e
        # __set__()은 이제 관리대상 속성을 업데이트하기 위해서 setattr을 사용한다
        setattr(instance, self.storage_name, value)

 

다음은 위의 Field가 있는 모듈에 구현되는 CheckedMeta 메타클래스입니다.

class CheckedMeta(type):
    def __new__(meta_cls, cls_name, bases, cls_dict):
        # cls_dict에 __slots__이 없는 경우에만 클래스를 향상시킨다
        # 만약 __slots__이 존재한다면 사용자 정의 서브클래스가 아닌 Checked 베이스클래스라고 가정한다
        if '__slots__' not in cls_dict:
            slots = []
            # 이전 예제외 마찬가지로 typing.get_type_hints를 사용하여 타입 힌트를 가져온다
            # 이 시점에서 생성되는 클래스가 아직 존재하지 않으므로 cls_dict로부터 __annotations__를 직접 탐색
            type_hints = cls_dict.get('__annotations__', {})
            for name, constructor in type_hints.items():
                field = Field(name, constructor) # 각 어노테이트된 속성을 빌드한다
                cls_dict[name] = field # cls_dict에 대응되는 엔트리에 Field 인스턴스를 덮어쓴다
                slots.append(field.storage_name) # field의 storage_name을 추가
            
            # 생성 중인 클래스의 네임스페이스인 cls_dict의 __slots__ 엔트리에 slots을 할당
            cls_dict['__slots__'] = slots
        
        # 마지막으로 super().__new__()를 호출한다
        return super().__new__(meta_cls, cls_name, bases, cls_dict)

 

그리고 마지막으로 이 라이브러리의 유저가 서브클래싱할 Checked 베이스 클래스의 구현입니다. 이 코드는 이전에 __init_subclass__로 구현한 Checked와 비교해서 3가지 변경점이 있습니다.

  1. 빈 __slots__을 추가해서 CheckedMeta.__new__() 메소드에 이 클래스는 특별한 처리가 필요없다는 걸을 알려줍니다.
  2. __init_subclass__()가 삭제됩니다. 이 역할은 이제 CheckedMeta.__new__()에서 수행됩니다.
  3. __setattr__()이 삭제됩니다. 사용자 정의 클래스에 __slots__을 추가하면, 선언되지 않은 속성을 설정할 수 없으므로 이 메소드는 더 이상 필요없습니다(중복 구현).

Checked 클래스의 구현은 다음과 같습니다.

class Checked(metaclass=CheckedMeta):
    __slots__ = ()  # skip CheckedMeta.__new__ processing

    @classmethod
    def _fields(cls) -> dict[str, type]:
        return get_type_hints(cls)

    def __init__(self, **kwargs: Any) -> None:
        for name in self._fields():
            value = kwargs.pop(name, ...)
            setattr(self, name, value)
        if kwargs:
            self.__flag_unknown_attrs(*kwargs)

    def __flag_unknown_attrs(self, *names: str) -> NoReturn:
        plural = 's' if len(names) > 1 else ''
        extra = ', '.join(f'{name!r}' for name in names)
        cls_name = repr(self.__class__.__name__)
        raise AttributeError(f'{cls_name} object has no attribute{plural} {extra}')

    def _asdict(self) -> dict[str, Any]:
        return {
            name: getattr(self, name)
            for name, attr in self.__class__.__dict__.items()
            if isinstance(attr, Field)
        }

    def __repr__(self) -> str:
        kwargs = ', '.join(
            f'{key}={value!r}' for key, value in self._asdict().items()
        )
        return f'{self.__class__.__name__}({kwargs})'

 

이렇게 구현한 checkedlib 모듈로 아래의 스크립트를 실행하면 정상적으로 동작하는 것을 볼 수 있습니다.

from checkedlib import Checked

class Movie(Checked):
    title: str
    year: int
    box_office: float

if __name__ == '__main__':
    movie = Movie(title='The Godfather', year=1972, box_office=137)
    print(movie)
    print(movie.title)

 


Metaclasses in the Real world

메타클래스는 강력하지만 까다롭습니다. 따라서 메타클래스를 구현하기로 결정하기 전에는 다음 사항들을 고려하는 것이 좋습니다.

 

Modern Features Simplify or Replace Metaclasses

시간이 지나면서, 새로운 언어 기능으로 인해 메타클래스의 몇 가지 일반적인 사용 사례가 중복되었습니다.

  • Class Decorators :
    메타클래스보다 이해하기 쉽고 기본 클래스 및 메타클래스와 충돌을 일으킬 가능성이 적습니다.

  • __set_name__ :
    디스크립터의 이름을 자동으로 설정하기 위해 커스텀 메타클래스 로직이 필요하지 않습니다.

  • __init_subclass__ :
    end-user에게 투명하고 데코레이터보다 더 심플한 클래스 생성을 커스터마이즈하는 방법을 제공합니다. 하지만 복잡한 클래스 계층에서 충돌이 발생할 수 있습니다.

  • Built-in dict preserving key insertion order :
    __prepare__()는 생성 중인 클래스의 네임스페이스를 저장하기 위한 OrderedDict를 제공하기 위해서 주로 사용되었습니다. 파이썬은 __prepare__() 메소드를 오직 메타클래스에서만 호출하며, 그래서 만약 소스 코드에 나타나는 순서대로 클래스 네임스페이스를 처리하려면 이를 사용해야 합니다. 파이썬 3.6 이후부터는 일반 dict도 삽입 순서를 유지하므로 필요하지 않습니다.

2021년부터는 CPython에서 활성 중인 모든 버전에서 위의 기능들을 제공합니다.

 

Metaclasses are Stable Language Features

메타클래스는 2002년 파이썬 2.2에서 'new-style classes'로 discriptors, properties와 함께 도입되었습니다.

 

2002년 7월 Alex Martelli가 처음 게시한 MetaBunch 예제는 여전히 파이썬 3.9에서 동작한다는 점은 주목할 만합니다. 유일한 변경 사항은 사용할 메타클래스를 지정하는 것이며, 파이썬 3에서는 class Bunch(metaclass=MetaBunch):으로 작성됩니다.

 

바로 위에서 언급한 추가 기능 중 어느 것도 메타클래스를 사용하는 기존 코드를 손상시키지 않습니다. 하지만 만약 파이썬 3.6이전 버전에 대한 지원이 중단되는 경우, 메타클래스를 사용하는 레거시 코드는 추가된 기능들을 활용하여 단순화할 수 있습니다.

 

A Class Can Only Have One Metaclass

만약 클래스 선언에 2개 이상의 메타클래스가 포함된 경우 다음과 같은 에러 메세지가 출력됩니다.

이는 다중 상속 없이도 발생할 수 있습니다. 예를 들어, 다음과 같은 선언은 위의 TypeError를 발생시킬 수 있습니다.

 

abc.ABC는 abc.ABCMeta 메타클래스의 인스턴스입니다. 만약 MetaBunch 메타클래스가 그 자체가 abc.ABCMeta의 서브클래스가 아니라면, 메타클래스 충돌이 발생합니다.

 

이 에러는 다음의 두 가지 방법으로 해결할 수 있습니다.

  • 관련된 메타클래스 중의 하나를 포함시키지 않으면서 필요한 작업을 수행하는 다른 방법을 찾는다
  • 다중 상속을 사용하여 BunchABCMeta 메타클래스를 abc.ABCMeta 및 MetaBunch의 서브클래스로 작성하고 이를 Record의 유일한 메타클래스로 사용한다

 

Metaclasses Should be Implementation Details

type외에 파이썬 3.9 표준 라이브러리 전체에는 6개의 메타클래스만 있습니다. 아마 잘 알려진 것으로는 abc.ABCMeta, typing.NamedTupleMeta, 그리고 enum.EnumMeta 입니다. 이들 중 어느 것도 사용자 코드에 명시적으로 사용하도록 의도되지 않았습니다. 

 

메타클래스를 사용하여 정말 별난 메타프로그래밍을 할 수 있지만 대부분의 사용자가 실제로 메타클래스의 구현 세부사항을 고려할 수 있도록 Principle of least astonishment(link)에 유의하는 것이 좋습니다.

 

최근 몇 년 동안 파이썬 표준 라이브러리의 일부 메타클래스를 패키지의 public API를 손상시키지 않으면서 다른 메커니즘으로 대체되었습니다. 이러한 API가 미래에 대비할 수 있는 가장 간단한 방법은 예제에서 수행한 것처럼 사용자가 메타클래스에서 제공하는 기능에 액세스하기 위해 서브클래싱하는 regular 클래스를 제공하는 것입니다.

 

 


Wrapping up

메타클래스뿐만 아니라 클래스 데코레이터, 그리고 __init_subclass__는 다음과 같은 작업에 유용합니다.

  • Subclass registration
  • Subclass structural validation
  • Applying decorators to many methods at once
  • Object serialization
  • Object-relational mapping
  • Object-based persistence
  • Implementing special methods at the class level
  • Implementing class features found in other languages, such as traits and aspect-oriented programming

 

클래스 메타프로그래밍은 또한 몇몇 케이스에서 런타임에 반복적으로 실행되는 작업을 import time에 수행하여 성능 이슈를 해결하는데 도움이 될 수 있습니다.

 

이러한 강력한 도구는 주로 라이브러리 및 프레임워크 개발을 지원하기 위해서 존재합니다. 어플리케이션은 당연히 파이썬 표준 라이브러리 또는 외부 패키지에서 제공하는 도구를 사용해야 합니다. 

'프로그래밍 > Python' 카테고리의 다른 글

[Python] Attribute Descriptor  (0) 2022.03.31
[Python] 동적 속성과 프로퍼티  (0) 2022.03.30
[Python] Futures  (0) 2022.03.30
[Python] Concurrency Models  (0) 2022.03.29
[Python] 코루틴(Coroutines), yield from  (0) 2022.03.27

댓글