meta class & attributes (2)

About_work·2023년 2월 11일
0

python 기초

목록 보기
15/55

meta class 란

  • meta class

    • 파이썬의 특성으로 자주 언급됨
    • 실제로 어떤 목적으로 쓰이는지 이해하는 프로그래머는 거의 없다.
    • meta class라는 이름은 어렴풋이 이 개념이 클래스를 넘어서는 것임을 암시한다.
    • 메타클래스를 사용하면, 파이썬의 class 문을 가로채서, 클래스가 정의될 때마다 특별한 동작을 제공할 수 있다
  • 동적 attribute 접근

    • metaclass처럼 신비하고 강력한 파이썬 기능으로는, 동적으로 attribute 접근을 custom화 해주는 내장 기능을 들 수 있다.
  • 하지만 이런 2가지 강력함에는 많은 함정이 뒤따른다.(이해하기 어려움 + 부작용)

  • 최소 놀람의 법칙을 따르고, 잘 정해진 관용어로만 이런 기능을 사용하는 것이 중요하다.

  • metaclass가 어떻게 작동하는가?

    • metaclass는 type을 상속해 정의된다.
    • 기본적인 경우 metaclass는 __new__메서드를 통해, 자신과 연관된 클래스의 내용을 받는다.
    • 다음 코드는 어떤 타입이 실제로 구성되기 전에, 클래스 정보를 살펴보고 변경하는 모습을 보여준다.
    • (참고: 모든 클래스는 object를 상속하기 떄문에, metaclass가 받는 부모 클래스의 tuple 안에는 object가 명시적으로 들어 있지 않다.)
class Meta(type):
    def __new__(meta, name, bases, class_dict): # bases는 부모 클래스들
        print(f'* 실행: {name}의 메타 {meta}.__new__')
        print('기반클래스들:', bases)
        print(class_dict)
        return type.__new__(meta, name, bases, class_dict)

class MyClass(metaclass=Meta):
    stuff = 123

    def foo(self):
        pass

class MySubClass(MyClass):
    other = 567

    def bar(self):
        pass

>>>
* 실행: Myclass의 메타 <class '__main__.Meta'>.__new__
* 기반 클래스들: ()
{'__module__': '__main__', '__qualname__': 'MyClass', 
'stuff': 123, 'foo': <function MyClass.foo at ___>}

* 실행: MySubClass의 메타 <class '__main__.Meta'>.__new__
* 기반 클래스들: (<class '__main__.MyClass'>, )
{'__module__': '__main__', '__qualname__': 'MySubClass',
'other': 567, 'bar': <function MySubClass.bar at ___>}

48: __init__subclass__를 사용해 하위 클래스를 검증하라.

요약

  • "메타클래스의 __new__ 메서드"는 class 문의 "모든 본문이 처리(실행)된 직후"에 호출된다.
  • 메타클래스를 사용해 "클래스가 정의된 직후이면서, 클래스가 생성되기 직전인 시점"에 클래스 정의를 변경할 수 있다. (하지만, 메타클래스는 원하는 목적을 달성하기에 너무 복잡해지는 경우가 많다.)
  • __init_subcalss__를 사용해 "하위 클래스가 정의된 직후, 하위 클래스 타입이 만들어지기 직전"에 해당 클래스가 원하는 요건을 잘 갖췄는지 확인하라.
  • __init_subcalss__ 정의 안에서, super().__init_subcalss__를 호출해, 여러 계층에 걸쳐 클래스를 검증하고 다중 상속을 제대로 처리하도록 해라.

본문

  • metaclass의 가장 간단한 활용법 중 하나: 어떤 (하위) 클래스가 제대로 구현되었는지 검증하는 것
    • 복잡한 class 계층을 설계할 때, 아래를 요구할 수 있다.
      • 어떤 style을 강제로 지키도록 만들거나
      • method를 override 하도록 요청하거나
      • 클래스 attribute 사이에 엄격한 관계를 가지도록
    • 검증에 metaclass를 사용하면, 프로그램 시작 시 클래스가 정의된 모듈을 처음 import 할 때와 같은 시점에 검증이 이루어지기 때문에, 예외가 훨씬 더 빨리 발생할 수 있다.
  • class 문에서 변 개수가 3보다 작은 경우에, 해당 class 정의문의 본문이 실행된 직후 예외를 발생시킨다.
    • 이는 변이 2개 이하인 클래스를 정의하면 프로그램이 아예 시작되지도 않는다는 뜻이다.
class ValidatePolygon(type):
	# 메타 클래스 (검증용)
    def __new__(meta, name, bases, class_dict): # bases는 부모 클래스들
        # Polygon 클래스의 하위 클래스만 검증한다
        if bases:
            if class_dict['sides'] < 3:
                raise ValueError('다각형 변은 3개 이상이어야 함')
        return type.__new__(meta, name, bases, class_dict)

class Polygon(metaclass=ValidatePolygon):
	# 기반 클래스( 검증 수행 x)
    sides = None # 하위 클래스는 이 애트리뷰트에 값을 지정해야 한다
    @classmethod
    def interior_angles(cls):
        return (cls.sides - 2) * 180

class Triangle(Polygon):
    sides = 3

class Rectangle(Polygon):
    sides = 4

class Nonagon(Polygon):
    sides = 9

assert Triangle.interior_angles() == 180
assert Rectangle.interior_angles() == 360
assert Nonagon.interior_angles() == 1260

print('class 이전')

class Line(Polygon):
    print('sides 이전')
    sides = 2
    print('sides 이후')

print('class 이후')
>>>
class 이전
sides 이전
sides 이후
Traceback ...
ValueError: 다각형 변은 3개 이상이어야 함.
  • 표준 파이썬 metalcass 방식은, 위 코드처럼 너무 복잡하다.
  • 아래와 같이 __init_subclass__ special class method를 정의하는 방식을 활용하라.
class BetterPolygon:
    sides = None  # 하위클래스에서 이 애트리뷰트의 값을 지정해야 함

    def __init_subclass__(cls):
        super().__init_subclass__()
        if cls.sides < 3:
            raise ValueError('다각형 변은 3개 이상이어야 함')

    @classmethod
    def interior_angles(cls):
        return (cls.sides - 2) * 180

class Hexagon(BetterPolygon):
    sides = 6

assert Hexagon.interior_angles() == 720
  • 표준 파이썬 metalcass 방식의 또 다른 문제점
    • 클래스 정의마다 metaclass를 단 하나만 지정할 수 있다는 점
  • 대신, __init_subclass__ special class method를 사용하면 이 문제도 해결할 수 있다.
    • super 내장 함수를 사용해 부모나 형제자매 클래스의 __init_subclass__를 호출해주는 한, 여러 단계로 이뤄진 __init__subclass__를 활용하는 클래스 계층 구조를 쉽게 정의할 수 있다.
    • 이 방식은 심지어 다중 상속과도 잘 어우러진다.
class Filled:
    color = None  # 하위 클래스에서 이 애트리뷰트 값을 지정해야 한다

    def __init_subclass__(cls):
        super().__init_subclass__()
        if cls.color not in ('red', 'green', 'blue'):
            raise ValueError('지원하지 않는 color 값')

class RedTriangle(Filled, BetterPolygon):
    color = 'red'
    sides = 3

ruddy = RedTriangle()
assert isinstance(ruddy, Filled)
assert isinstance(ruddy, Polygon)

49:__init__subclass__를 사용해 클래스 확장을 등록하라.

간단 요약

  • 클래스 등록은, 파이썬 프로그램을 모듈화할 때 유용한 패턴이다.
  • metaclass를 사용하면, 프로그램 안에서 기반 class를 상속한 하위 class가 정의될 때마다 등록 코드를 자동으로 실행할 수 있다.
  • metaclass를 클래스 등록에 사용하면, class 등록 함수를 호출하지 않아서 생기는 오류를 피할 수 있다.
  • 표준적인 metaclass 방식보다는, __init_subclass__가 더 낫다. __init_subclass__ 쪽이 더 깔끔하고, 초보자가 이해하기도 더 쉽다.

본문

  • 어떤 object를 json으로 직렬화하고, 직렬화된 json으로부터 다시 객체로 역직렬화를 하는 기능을 만들고 싶을 때, 아래의 코드를 활용할 수 있다.
registry = {}
register_class(EvenBetterPoint2D)

before = EvenBetterPoint2D(5, 3)
print('이전: ', before)
data = before.serialize()
print('직렬화한 값:', data)
after = deserialize(data)
print('이후: ', after)

class EvenBetterPoint2D(BetterSerializable):
    def __init__(self, x, y):
        super().__init__(x, y)
        self.x = x
        self.y = y

class BetterSerializable:
    def __init__(self, *args):
        self.args = args

    def serialize(self):
        return json.dumps({
            'class': self.__class__.__name__,
            'args': self.args,
        })

    def __repr__(self):
        name = self.__class__.__name__
        args_str = ', '.join(str(x) for x in self.args)
        return f'{name}({args_str})'


def register_class(target_class):
    registry[target_class.__name__] = target_class

def deserialize(data):
    params = json.loads(data)
    name = params['class']
    target_class = registry[name]
    return target_class(*params['args'])
  • 위 구현의 문제점은, register_class호출을 잊어버릴 수 있다는 점이다.
  • 이럴 때, metaclass를 아래와 같이 이용하라.

meta 를 쓰는 방법

before = Vector3D(10, -7, 3)
print('이전: ', before)
data = before.serialize()
print('직렬화한 값:', data)
print('이후: ', deserialize(data))

class Vector3D(RegisteredSerializable):
    def __init__(self, x, y, z):
        super().__init__(x, y, z)
        self.x, self.y, self.z = x, y, z

class RegisteredSerializable(BetterSerializable,
                             metaclass=Meta):
    pass

class BetterSerializable:
    def __init__(self, *args):
        self.args = args

    def serialize(self):
        return json.dumps({
            'class': self.__class__.__name__,
            'args': self.args,
        })

    def __repr__(self):
        name = self.__class__.__name__
        args_str = ', '.join(str(x) for x in self.args)
        return f'{name}({args_str})'
        
class Meta(type):
    def __new__(meta, name, bases, class_dict):
        cls = type.__new__(meta, name, bases, class_dict)
        register_class(cls)
        return cls

__init_subclass__ magic method 이용하는 방법 (더 추천)

before = Vector1D(6)
print('이전: ', before)
data = before.serialize()
print('직렬화한 값:', data)
print('이후: ', deserialize(data))

class Vector1D(BetterRegisteredSerializable):
    def __init__(self, magnitude):
        super().__init__(magnitude)
        self.magnitude = magnitude


class BetterRegisteredSerializable(BetterSerializable):
    def __init_subclass__(cls):
        super().__init_subclass__()
        register_class(cls)

class BetterSerializable:
    def __init__(self, *args):
        self.args = args

    def serialize(self):
        return json.dumps({
            'class': self.__class__.__name__,
            'args': self.args,
        })

    def __repr__(self):
        name = self.__class__.__name__
        args_str = ', '.join(str(x) for x in self.args)
        return f'{name}({args_str})'

50: __set_name__으로 class attribute 를 표시하라.

요약

  • metaclass 를 사용하면, 어떤 class가 완전히 정의되기 전에, 클래스의 attribute를 변경할 수 있다.
  • descriptor와 metaclass를 조합하면, "강력한 실행 시점 코드 검사 + 선언적인 동작"을 만들 수 있다.
  • __set_name__ special method를 descriptor 클래스에 정의하면, descriptor가 포함된 class의 프로퍼티 이름을 처리할 수 있다.
  • descriptor가 변경한 class의 instance dictionary에 데이터를 저장하게 만들면
    • 메모리 누수를 피할 수 있고,
    • weakref 내장 메서드를 사용하지 않아도 된다.

본문

cust = Customer()
print(f'이전: {cust.first_name!r} {cust.__dict__}')
cust.first_name = '유클리드'
print(f'이후: {cust.first_name!r} {cust.__dict__}')

>>>
이전: '' {}
이후: '유클리드' {'_first_name': '유클리드'}

 class Customer:
    # 클래스 애트리뷰트
    first_name = Field('first_name')
    last_name = Field('last_name')
    prefix = Field('prefix')
    suffix = Field('suffix')

class Field: # descriptor
    def __init__(self, name):
        self.name = name
        self.internal_name = '_' + self.name

    def __get__(self, instance, instance_type):
        if instance is None:
            return self
        return getattr(instance, self.internal_name, '')

    def __set__(self, instance, value):
        setattr(instance, self.internal_name, value)
  • 위 코드보다, 아래처럼 metaclass를 사용해, descriptor의 Field.nameField.internal_name을 자동으로 대입할 수 있다.
    • metaclass를 사용하면 class 문에 직접 훅을 걸어서, class 본문이 끝나자마자 필요한 동작을 수행할 수 있다.
cust = BetterCustomer()
print(f'이전: {cust.first_name!r} {cust.__dict__}')
cust.first_name = '오일러'
print(f'이후: {cust.first_name!r} {cust.__dict__}')
>>>
이전: '' {}
이후: '오일러' {'_first_name': '오일러'}

class BetterCustomer(DatabaseRow):
    first_name = Field()
    last_name = Field()
    prefix = Field()
    suffix = Field()

class DatabaseRow(metaclass=Meta):
    pass
    
class Meta(type): # metaclass
    def __new__(meta, name, bases, class_dict):
        for key, value in class_dict.items():
            if isinstance(value, Field):
                value.name = key
                value.internal_name = '_' + key
        cls = type.__new__(meta, name, bases, class_dict)
        return cls

class Field: # descriptor
    def __init__(self):
        # 이 두 정보를 메타클래스가 채워 준다
        self.name = None
        self.internal_name = None

    def __get__(self, instance, instance_type):
        if instance is None:
            return self
        return getattr(instance, self.internal_name, '')

    def __set__(self, instance, value):
        setattr(instance, self.internal_name, value)
  • 하지만, 위 접근 방법(metaclass를 이용하여 descriptor의 name 과 Internal_name을 자동으로 대입)의 문제점은, 아래의 경우 문제가 된다.
    • DatabaseRow를 상속하는 것을 잊어버리거나,
    • 클래스 계층 구조로 인한 제약 때문에 어쩔 수 없이 DatabaseRow를 상속할 수 없는 경우
  • 이를 해결하는 방법(metaclass를 사용하지 않고도 원래 목적을 달성하는 방법)은 descriptor에 __set_name__ magic method를 사용하는 것이다.
    • __set_name__은 descriptor instance를 소유 중인 class와 descriptor instnace가 대입될 attribute 이름을 argument로 받는다.
cust = FixedCustomer()
print(f'이전: {cust.first_name!r} {cust.__dict__}')
cust.first_name = '메르센'
print(f'이후: {cust.first_name!r} {cust.__dict__}')
>>>
이전: '' {}
이후: '메르센' {'_first_name': '메르센'}

class FixedCustomer:
    first_name = Field()
    last_name = Field()
    prefix = Field()
    suffix = Field()

class Field:
    def __init__(self):
        self.name = None
        self.internal_name = None

    def __set_name__(self, owner, name):
    	"""
        # 클래스가 생성될 때 모든 스크립터에 대해 이 메서드가 호출된다
        
        owner: descriptor instance를 소유 중인 class
        name: descriptor instance가 대입될 attribute 이름
        """
        self.name = name
        self.internal_name = '_' + name

    def __get__(self, instance, instance_type):
        if instance is None:
            return self
        return getattr(instance, self.internal_name, '')

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

51: 합성 가능한 class 확장이 필요하면, meta class 보다는 class decorator을 사용하라.

요약

  • class decorator은, class instance를 parameter로 받아서 -> 이 class를 변경한 class나 새로운 class를 반환해주는 간단한 함수다.
  • 준비 코드를 최소화하면서 class 내부의 모든 method나 attribute를 변경하고 싶을 때, class decorator가 유용하다.
  • metaclass는 서로 쉽게 합성할 수 없지만, 여러 class decorator을 충돌 없이 사용해 똑같은 class를 확장할 수 있다.

본문

  • 어떤 class의 모든 method를 감싸서, method에 전달되는 argument, return, exception을 모두 출력하고 싶다고 하자.
  • 다음 코드는 이런 debugging decorator을 정의한다.
from functools import wraps

trace_dict = TraceDict([('안녕', 1)])
trace_dict['거기'] = 2
trace_dict['안녕']
try:
    trace_dict['존재하지 않음']
except KeyError:
    pass # 키 오류가 발생할 것으로 예상함

class TraceDict(dict):
    @trace_func
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    @trace_func
    def __setitem__(self, *args, **kwargs):
        return super().__setitem__(*args, **kwargs)

    @trace_func
    def __getitem__(self, *args, **kwargs):
        return super().__getitem__(*args, **kwargs)

def trace_func(func):
    if hasattr(func, 'tracing'):  # 단 한번만 데코레이터를 적용한다
        return func

    @wraps(func)
    def wrapper(*args, **kwargs):
        result = None
        try:
            result = func(*args, **kwargs)
            return result
        except Exception as e:
            result = e
            raise
        finally:
            print(f'{func.__name__}({args!r}, {kwargs!r}) -> '
                  f'{result!r}')

    wrapper.tracing = True
    return wrapper
  • 이 코드의 문제점은,
    • 꾸미려는 모든 method를 @trace_func 데코레이터를 써서 재정의해야 한다는 것이다.
    • 더 나아가 나중에 dict 상위 클래스에 method를 추가하면, TraceDict에서 그 method를 재정의하기 전까지는 decorator 적용이 되지 않는다.
  • 다음 코드는 metaclass를 사용해 dict 하위 클래스를 정의하고, 해당 클래스가 잘 작동하는지 확인한다.
import types

trace_dict = TraceDict([('안녕', 1)])
trace_dict['거기'] = 2
trace_dict['안녕']
try:
    trace_dict['존재하지 않음']
except KeyError:
    pass # 키 오류가 발생할 것으로 예상함

class TraceDict(dict, metaclass=TraceMeta):
    pass

class TraceMeta(type):
    def __new__(meta, name, bases, class_dict):
        klass = super().__new__(meta, name, bases, class_dict)

        for key in dir(klass):
            value = getattr(klass, key)
            if isinstance(value, trace_types):
                wrapped = trace_func(value)
                setattr(klass, key, wrapped)
        return klass
        
def trace_func(func):
    if hasattr(func, 'tracing'):  # 단 한번만 데코레이터를 적용한다
        return func
        
trace_types = (
    types.MethodType,
    types.FunctionType,
    types.BuiltinFunctionType,
    types.BuiltinMethodType,
    types.MethodDescriptorType,
    types.ClassMethodDescriptorType)
  • 하지만, metaclass를 사용하는 접근 방식은, 적용 대상 class에 대한 제약이 너무 많다.
    • 위의 코드는, 상위 클래스(dict)가 이미 metaclass를 정의한 경우 문제가 된다.
    • 또한, TraceMeta 같은 utility metaclass를 여럿 사용하고 싶은 경우에도 사용할 수 없다.
  • 위 문제를 해결하고자, 파이썬은 class decorator을 지원한다.
    • class decorator은, 함수 decorator 처럼 사용할 수 있다. 즉, class 선언 앞에 @ 기호와 decorator 함수를 적으면 된다.
    • 이 때, decorator 함수는, 인자로 받은 class를 적절히 변경해서 재생성해야 한다.
    • 클래스를 확장하면서, 합성이 가능한 방법을 찾고 있다면 -> class decorator가 가장 적합한 도구이다.
def my_class_decorator(klass):
    klass.extra_param = '안녕'
    return klass

@my_class_decorator
class MyClass:
    pass

print(MyClass)
>>>
<class '__main__.MyClass'>

print(MyClass.extra_param)
>>>
안녕
trace_dict = TraceDict([('안녕', 1)])
trace_dict['거기'] = 2
trace_dict['안녕']
try:
    trace_dict['존재하지 않음']
except KeyError:
    pass # 키 오류가 발생할 것으로 예상함

>>>
__new__((<class '__main__.TraceDict'>, [('안녕', 1)]), {}) -> {}
__getitem__(({'안녕': 1, '거기': 2}, '안녕'), {}) -> 1
__getitem__(({'안녕': 1, '거기': 2}, '존재하지 않음'), {}) -> KeyError('존재하지 않음')

@trace
class TraceDict(dict):
    pass

def trace(klass):
    for key in dir(klass):
        value = getattr(klass, key)
        if isinstance(value, trace_types):
            wrapped = trace_func(value)
            setattr(klass, key, wrapped)
    return klass

def trace_func(func):
    if hasattr(func, 'tracing'):  # 단 한번만 데코레이터를 적용한다
        return func

trace_types = (
    types.MethodType,
    types.FunctionType,
    types.BuiltinFunctionType,
    types.BuiltinMethodType,
    types.MethodDescriptorType,
    types.ClassMethodDescriptorType)
profile
새로운 것이 들어오면 이미 있는 것과 충돌을 시도하라.

0개의 댓글