Scribbling

Python: Class Metaprogramming (클래스 메타프로그래밍) 본문

Computer Science/Python

Python: Class Metaprogramming (클래스 메타프로그래밍)

focalpoint 2022. 6. 17. 16:51

 

클래스 메타프로그래밍은 실행 도중에 클래스를 생성하거나 커스터마이징 하는 기술이다. 클래스 데코레이터와 메타클래스는 이를 위한 방법이다. 메타클래스는 강력하지만, 어렵다. 클래스 데커레이터는 사용하기 쉽지만, 상속 계층 구조가 있는 경우에는 사용하기 어렵다. 

 

1. 런타임에 클래스 생성하기

파이썬 표준 라이브러리에는 collections.namedtuple 이라는 클래스 팩토리가 있다. 클래스명과 속성명을 전달하면 이름 및 점 표기법으로 항목을 가져올 수 있다. 유사한 클래스 팩토리를 만들면서 런타임에 클래스 생성하는 방법을 확인해보자.

def record(cls_name, field_names):
    try:
        field_names = field_names.replace(',', '').split()
    except AttributeError:
        pass
    field_names = tuple(field_names)

    def __init__(self, *args, **kwagrs):
        attrs = dict(zip(self.__slots__, args))
        attrs.update(kwagrs)
        for name, value in attrs.items():
            setattr(self, name, value)

    def __iter__(self):
        for name in self.__slots__:
            yield getattr(self, name)

    def __repr__(self):
        values = ', '.join('{}={!r}'.format(*i) for i in zip(self.__slots__, self))
        return '{}({})'.format(self.__class__.__name__, values)

    cls_attrs = dict(__slots__=field_names,
                     __init__=__init__,
                     __iter__=__iter__,
                     __repr__=__repr__,
                     )
    return type(cls_name, (object,), cls_attrs)



Person = record('Person', 'name age occupation')
p1 = Person('morgan', 29, 'Student')
print(p1)

코드가 다소 복잡하다. 먼저 눈여겨 봐야 할 것은, type 함수로 클래스를 동적으로 생성한다. 전달되는 매개변수는 차례로 클래스명, 상속클래스, 속성 딕셔너리다. 속성 딕셔너리에서 __slots__에 field_names를 부여하고 있다. 이를 통해 생성되는 클래스는 filed_names 외의 변수를 가질 수 없다.

 

2. 디스크립터를 커스터마이즈하기 위한 클래스 데커레이터

이전 글에서 프로퍼티 팩토리를 이용해서 LineItem 클래스의 변수를 담을 객체를 생성했었다. 다만 weight 등의 속성 값이 _Quantity#0라는 디버깅하기 어려운 객체명으로 지정되어 있었다. 클래스 데커레이터를 이용하면 이를 보다 쉽게 개선할 수 있다.

https://focalpoint.tistory.com/315

 

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

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

focalpoint.tistory.com

 

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


def entity(cls):
    for key, attr in cls.__dict__.items():
        if isinstance(attr, Validated):
            type_name = type(attr).__name__
            attr.name = '_{}#{}'.format(type_name, key)
    return cls


@entity
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())
print(dir(product1)[:3])

 

3. 임포트 타임과 런타임

파이썬에서는 임포트 타임과 런타임 구분이 모호한 감이 없지않아 있다. 임포트 타임에 인터프리ㅣ터가 .py 모듈에 들어 있는 소스 코드를 파싱하고, 실행할 바이트코드를 생성한다. 만약 __pycache__ 디렉터리에 최신 .pyc 파일이 있으면 바이트코드가 이미 있는 것이므로 이 과정을 생략한다.

애매함은 import 문에서 발생한다. 임포트되는 모듈의 모든 '최상위 수준 코드'를 실제로 실행하기 때문이다. 즉, import 문이 각종 '런타임' 동작을 유발한다. 복잡한 내용을 간략하게 정리하자면, 인터프리터는 임포트 타임동안 함수를 정의하지만 런타임에 호출될 때만 실제로 함수를 실행한다. 그러나 클래스는 다르다. 클래스의 경우, 임포트 타임에 클래스가 정의되고,  클래스 객체가 만들어진다.

클래스가 정의만 되어있어도, import 시에 실행되는 것이다.

 

추가적으로, 클래스 데커레이터가 변경한 내용이 서브클래스에 영향을 주지 않을 수 있다. 때문에 클래스 데커레이터로는 계층구조 전체를 커스터마이징하는 것이 어렵다. 이에 대한 대안이 바로 메타클래스이다.

 

4. 메타클래스

메타클래스는 클래스 팩토리이다. 메타클래스의 __init__() 메서드는 클래스 데커레이터가 하는 모든 일이 가능하고, 더욱 강력하다. 메타클래스는 주로 생성되는 클래스를 제어하기 위해 사용한다. 이번에는 메타클래스로 클래스 데커레이터를 대체해보자.

class EntityMeta(type):
    ''' 검증된 필드를 가진 개체에 대한 메타클래스 '''

    def __init__(cls, name, bases, attr_dict):
        super().__init__(name, bases, attr_dict)
        for key, attr in attr_dict.items():
            if isinstance(attr, Validated):
                type_name = type(attr).__name__
                attr.name = '_{}#{}'.format(type_name, key)

class Entity(metaclass=EntityMeta):
    ''' 검증된 필드를 가진 개체 '''


class LineItem(Entity):
    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())
print(dir(product1)[:3])

 

*참고: LineItem에서 메타클래스를 직접 설정할 수도 있다.

class LineItem(metaclass=EntityMeta):

 

 

결론은 이렇게 내고자 한다.

메타 클래스는 직접 쓸 일이 거의 없다. 메타 클래스는 대부분의 경우 닭 잡는데 소 칼 드는 격이다. 우리 모두 닭칼좌(aka 화웅) 가 되지 맙시다.