PyCon Korea 2019

리얼월드 메타클래스 👽

김성현

Who am I?

NovemberOscar

https://seonghyeon.dev

{ self@seonghyeon.dev | k.seonghyeon@mymusictaste.com }

Seonghyeon Kim | 김성현

Overview

Part A: 메타클래스란 무엇인가

Part B: 파이썬에서 메타클래스의 역할

Part C: 메타클래스를 활용하기

Part D: 오픈소스에서의 메타클래스

 

text & code here 👨‍💻

PART A:
메타클래스란 무엇인가 👻

파이썬의 데이터 모델부터 먼저 알아봅시다.

파이썬에서 모든 것은 데이터를 추상화한  객체

파이썬은 어떻게 객체로 데이터를 추상화할까?

OBJECT

IDENTITY

TYPE

VALUE

Identity

  • id 함수를 통해 얻어지는 값
  • 객체의 수명주기 동안 유일하고 불변한 정수
>>> v = object()
>>> id(v)
4473343200

Value

  • 말 그대로 객체의 값
  • 불변일 수도 있고 가변일 수도 있다
>>> v = "hello"
>>> x = v
>>> id(x) == id(v)
True
>>> x += "o"
>>> id(x) == id(v)
False

Type

  • 객체가 지원하는 연산, 가질 수 있는 값 등을 정의
  • 객체의 특성 정의
  • 불변
>>> v = "hello"
>>> type(v)
<class 'str'>
>>> v = 42
>>> type(v)
<class 'int'>

OBJECT

IDENTITY

TYPE

VALUE

내장된 타입들

int
str
float
complex
bool
NoneType
ellipsis
...

기본적인 타입을 가지는 객체들은

타입들이 정의

하지만 기본적인 타입들 만으론 부족하다
→ 새로운 타입들을 정의

class

클래스 정의 == 새로운 타입 정의

새로운 타입이 새로운 객체를 정의

파이썬의 모든 것들은 객체로 이루어져 있다

"모든 것" 이라니? 어디까지 객체인걸까?

객체는 파이썬이 데이터를 추상화한 것입니다. 파이썬 프로그램의 모든 데이터는 객체나 객체 간의 관계로 표현됩니다. 폰 노이만의 "프로그램 내장식 컴퓨터" 모델을 따르고, 또 그 관점에서 코드 역시 객체로 표현됩니다.

코드가 객체?

함수, 클래스 등 모든 요소들이 객체

클래스도 객체?

다른 언어에서는 객체를 생성하는

코드 조각, 바이트 코드의 일부분

정말로 클래스도 객체인지 알아보자

CLASS
OBJECT

IDENTITY

TYPE

VALUE

객체의 조건을

만족한다면 객체

class

>>> class C: 
...     pass 

identity

>>> id(C) 
140559438115784 

value

>>> dir(C) 
[ ... ] 

type

>>> type(C) 
<class 'type'>

3가지 요소를 모두 만족 == 클래스는 객체

잠깐, 객체는 타입에 의해 정의되는데?

  1. 객체는 타입에 의해 정의된다 😐

  2. 클래스는 객체 😲

  3. 클래스 객체를 정의하는 타입? 🤯

타입을 정의하는 특별한 타입

클래스를 인스턴스로 가지는 특별한 클래스

그것이 바로 메타클래스

 파이썬의 메타클래스가 정말로
타입을 만들어 내는지 확인해 봅시다

파이썬의 메타클래스

>>> type(C) 
<class 'type'>
type()

3개의 인자와 함께 사용 → 새로운 타입 객체 반환

>>> class X: 
...      a = 1 

>>> X = type('X', (object,), dict(a=1))

>>> X
<class '__main__.X'>

class 문을 사용하여 만드는것과 완전히 동일

PART B:
파이썬에서 메타클래스의 역할 🧞‍♂️

Summary

  • 파이썬에서 모든 것은 객체

  • 객체를 만드는 타입도 객체

  • 타입도 타입에 의해 정의된다

  • 타입을 정의하는 특별한 타입 메타클래스인
    type()이 타입을 정의한다

type()은 어떻게 새로운 타입을 정의할까?

클래스 정의 과정

  • MRO 항목 결정

  • 적절한 메타 클래스를 결정

  • 클래스 네임스페이스를 준비

  • 네임스페이스 안에서 클래스 바디 실행

  • 클래스 객체 생성

class

class MyClass: 
    z = 1
    
    def f(self,):
        return x

MRO 항목 결정

C3 선형화 알고리즘을 사용해 메소드를 가져오는 순서인

Method Resolution Order(MRO)를 구성

메타클래스 결정

 별도로 지정하지 않으면 기본 메타클래스인 type을 사용

클래스 네임스페이스 준비

  • 메타클래스에서 네임스페이스를 준비하는 __prepare__ 어트리뷰트를 호출하여 반환받은 매핑으로 네임스페이스를 정의

  • __prepare__ 어트리뷰트가 없다면 빈 순서있는 매핑(빈 딕셔너리)을 대신 사용

클래스 네임스페이스 준비

from collections import OrederedDict

class MyMeta(type):
    @classmethod
    def __prepare__(mcs, name, bases, **kwargs):
        return OrderedDict()

class MyClass(metaclass=MyMeta):
    z = 1

    def f(self,):
        return x

print({k: v for k, v in MyClass.__dict__.items() if not k.startswith("__")})

# {'z': 1, 'f': <function MyClass.f at 0x10efc8bf8>}

클래스 바디 실행

exec() 함수를 사용하여 바디 속에 들어있는 함수 정의, 변수 할당 등을 수행하여 클래스의 네임스페이스를 채운다

클래스 객체 생성

일반적인 클래스가 인스턴스를 만들 때처럼

메타클래스의 __new__를 사용하여

메타클래스의 인스턴스인 클래스 객체를 생성

클래스 객체 생성

class MyMeta(type):
    def __new__(mcs, *args, **kwargs):
        print(f"new class: {args}")
        r = super().__new__(mcs, *args, **kwargs)

        return r

class MyClass(metaclass=MyMeta):
    z = 1

    def f(self, x):
        return x

# new class: ('MyClass', (), {'__module__': '__main__', '__qualname__': 'MyClass', 'z': 1, 'f': <function MyClass.f at 0x101cc89d8>})
# same as type('MyClass', (), {...})

클래스 객체 생성

파이썬 3.6 이상에선

메타클래스는 객체를 생성할 때 디스크립터를 수집한 후

디스크립터 객체의 __set_name__ 훅을 실행하여 디스크립터와 네임스페이스를 연결

  • 코드를 읽어들여 메타클래스를 결정

  • 메타클래스를 사용하여 네임스페이스 생성

  • 네임스페이스에서 바디 실행

  • 메타클래스의 __new__ 를 사용하여 객체를 생성

요약하자면...

One more thing...

메타클래스의 __call__은 어디에 쓰지?

인스턴스 생성

클래스가 인스턴스를 만들 때

메타클래스의 __call__ 호출

인스턴스 생성

class MyMeta(type):
    def __call__(cls, *args, **kwargs):
        x = super().__call__(*args, **kwargs)
        print(f"A new object created: {x}")
        return x

class MyClass(metaclass=MyMeta):
    z = 1

    def f(self, x):
        return x

m = MyClass()
print(f"created object is {m}")

# A new object created: <__main__.MyClass object at 0x1073d54e0>
# created object is <__main__.MyClass object at 0x1073d54e0>
  • 코드를 읽어들여 메타클래스를 결정

  • 메타클래스를 사용하여 네임스페이스 생성

  • 네임스페이스에서 바디 실행

  • 메타클래스의 __new__ 를 사용하여 클래스 객체를 생성

  • 메타클래스의 __call__을 사용하여 인스턴스 객체를 생성

다시 요약하자면...

PART C:

메타클래스를 활용하기 🛠

메타클래스의 메소드들을 오버라이드해

클래스 생성을 변경할 수 있다

  • __prepare__

  • __new__

  • __init__

  • __call__

클래스 검증

1. 클래스 상속 통제

2. 애트리뷰트 검증

__new__ 의 두번째 인자는

베이스 클래스들을 담은 튜플

상속 통제하기

 

상속 통제하기

다중 상속 시에는 베이스 튜플에

두개 이상의 클래스가 담기게 된다

상속 통제하기

이것을 이용하여 다중 상속을 금지하는

메타클래스를 만들 수 있다

다중 상속 막기

class DisallowMultipleInheritance(type):

    def __new__(mcs, *args, **kwargs):
        if len(args[1]) > 1:
            raise Exception("...")

        new_cls = super().__new__(mcs, *args, **kwargs)
        
        return new_cls

다중 상속 막기

class Foo(metaclass=DisallowMultipleInheritance): 
    pass
 
class Bar: 
    pass
 
class Zee(Foo, Bar): 
    pass

다중 상속 막기

Traceback (most recent call last):
  File "/.../disallow_multiple_inheritance.py", line 17, in <module>
    class Zee(Foo, Bar):
  File "/.../disallow_multiple_inheritance.py", line 4, in __new__
    raise Exception(f"Can't be subclassed with multiple inheritance: {args[1]}")

Exception: Can't be subclassed with multiple inheritance: (<Foo>, <Bar>)

상속 막기

또는 상속을 금지하는 메타클래스도 작성 가능

상속 막기

class DisallowInheritance(type):
    def __new__(mcs, *args, **kwargs):
        cls = [c for c in args[1] if isinstance(c, mcs)]

        if cls:
            raise Exception(f"can't subclass {cls}")

        r = super().__new__(mcs, *args, **kwargs)

        return r

애트리뷰트 검증

컴파일 타임에 오류를 찾아낼 수 있다 

애트리뷰트 검증

class UserForm(ModelForm):
    class Meta:
        model = User
        fields = ['name', 'email', 'birth_date']

애트리뷰트 검증

class CheckMeta(type):
    def __new__(mcs, *args, **kwargs):
        name, bases, namespace = args

        if (not namespace.get("Meta", None)) and (bases != ()):
            raise Exception("Can not configure class. Meta is missing")

        r = super().__new__(mcs, *args, **kwargs)

        return r

싱글톤

싱글톤은 항상 같은 인스턴스를 반환

싱글톤

__call__을 오버라이드해 처음 한번만 객체를 생성하고

다음부터는 생성된 객체를 돌려주도록 할 수 있다.

싱글톤

class Singleton(type):
    _instances = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(...)

        return cls._instances[cls]

싱글톤

class C(metaclass=Singleton):
    pass

싱글톤

>>> a = C()

>>> b = C()

>>> print(a is b) 
 True

디스크립터와 네임스페이스 연결

디스크립터란 파이썬에서 getter와 setter를 구현하는 새로운 방식

(Python 3.6부터는 자체 지원)

디스크립터와 네임스페이스 연결

(Python 3.6부터는 자체 지원)

class Product:
    price = Price(name="price", unit="KRW") 

디스크립터와 네임스페이스 연결

(Python 3.6부터는 자체 지원)

>>> pd = Product()

>>> pd.price = "NotANumber"
Please set a valid integer

>>> pd.price = 5000

>>> print(pd.price)
5,000 KRW

디스크립터와 네임스페이스 연결

3.5 까지는 직접 디스크립터의 __init__ 에서

네임스페이스로부터 가져오고자 하는 값의 키를 지정해 줘야 한다.

(Python 3.6부터는 자체 지원)

디스크립터와 네임스페이스 연결

3.6 부터는 PEP-484에서 제시된 __set_name__

이란 훅을 type()에서 자동으로 트리거해

네임스페이스에서 가져올 키의 이름을 지정해 준다

(Python 3.6부터는 자체 지원)

디스크립터와 네임스페이스 연결

PEP-484의 구현을 보고 이것을 똑같이 구현해 봅시다

(Python 3.6부터는 자체 지원)

디스크립터와 네임스페이스 연결

(Python 3.6부터는 자체 지원)

class Meta(type):
    def __new__(mcs, *args, **kwargs):
        name, bases, namespace = args

        self = super().__new__(mcs, name, bases, namespace)

        for k, v in self.__dict__.items():
            func = getattr(v, '__set_name__', None) 
            if func is not None:  # trigger hook if exists
                func(self, k)

        return self

디스크립터와 네임스페이스 연결

(Python 3.6부터는 자체 지원)

class Desc:
    def __get__(self, instance, owner):
        return instance.__dict__[self.name]

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

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

디스크립터와 네임스페이스 연결

(Python 3.6부터는 자체 지원)

class C(metaclass=Meta):
    v = Desc()

디스크립터와 네임스페이스 연결

(Python 3.6부터는 자체 지원)

>>> c = C()

>>> c.v = 3

>>> print(c.v == 3) 
 True

PART D:

오픈소스에서의 메타클래스 🚀

Celery (v2.6)

Celery (v2.6)

바로 처리하기 어려운 작업을 비동기로 처리하기 위한 작업 큐

Celery (v2.6)

태스크를 만들어 브로커에 전달하면

워커가 작업을 처리한 후 지정한 곳으로 결과 반환

Celery (v2.6)

Task 클래스 를 상속받아 태스크를 작성하면

Task의 메타클래스 TaskType이 자동으로 태스크를 등록

Celery (v2.6)

class TaskType(type):
    """Meta class for tasks.
    Automatically registers the task in the task registry, except
    if the `abstract` attribute is set.
    If no `name` attribute is provided, then no name is automatically
    set to the name of the module it was defined in, and the class name.
    """

    def __new__(cls, name, bases, attrs):
        new = super(TaskType, cls).__new__
        task_module = attrs.get("__module__") or "__main__"

        # - Abstract class: abstract attribute should not be inherited.
        if attrs.pop("abstract", None) or not attrs.get("autoregister", True):
            return new(cls, name, bases, attrs)

        # The 'app' attribute is now a property, with the real app located
        # in the '_app' attribute.  Previously this was a regular attribute,
        # so we should support classes defining it.
        _app1, _app2 = attrs.pop("_app", None), attrs.pop("app", None)
        app = attrs["_app"] = _app1 or _app2 or current_app

        # - Automatically generate missing/empty name.
        autoname = False
        if not attrs.get("name"):
            try:
                module_name = sys.modules[task_module].__name__
            except KeyError:  # pragma: no cover
                # Fix for manage.py shell_plus (Issue #366).
                module_name = task_module
            attrs["name"] = '.'.join(filter(None, [module_name, name]))
            autoname = True

        # - Create and register class.
        # Because of the way import happens (recursively)
        # we may or may not be the first time the task tries to register
        # with the framework.  There should only be one class for each task
        # name, so we always return the registered version.
        tasks = app._tasks

        # - If the task module is used as the __main__ script
        # - we need to rewrite the module part of the task name
        # - to match App.main.
        if MP_MAIN_FILE and sys.modules[task_module].__file__ == MP_MAIN_FILE:
            # - see comment about :envvar:`MP_MAIN_FILE` above.
            task_module = "__main__"
        if autoname and task_module == "__main__" and app.main:
            attrs["name"] = '.'.join([app.main, name])

        task_name = attrs["name"]
        if task_name not in tasks:
            tasks.register(new(cls, name, bases, attrs))
        instance = tasks[task_name]
        instance.bind(app)
        return instance.__class__

Celery (v2.6)

This is simplified form. not original
class TaskType(type):
    def __new__(cls, name, bases, attrs):
        # get __new__ form super
        # get app from attrs. if not exists, get from current_app
        # automatically generate missing/empty name.

        ...

        tasks = app._tasks

        ...

        task_name = attrs["name"]

        if task_name not in tasks:
            tasks.register(new(cls, name, bases, attrs))

        instance = tasks[task_name]
        instance.bind(app)

        return instance.__class__

Celery (v2.6)

Celery 4부터는 클래스 기반 태스크 자동 등록을 지원하지 않음

Django

Django

Forms

Django

class Form(BaseForm, metaclass=DeclarativeFieldsMetaclass):
    "A collection of Fields, plus their associated data."
    # This is a separate class from BaseForm in order to abstract the way
    # self.fields is specified. This class (Form) is the one that does the
    # fancy metaclass stuff purely for the semantic sugar -- it allows one
    # to define a form using declarative syntax.
    # BaseForm itself has no way of designating self.fields.

Django

class DeclarativeFieldsMetaclass(MediaDefiningClass):
    """Collect Fields declared on the base classes."""
    def __new__(mcs, name, bases, attrs):
        # Collect fields from current class.
        current_fields = []
        for key, value in list(attrs.items()):
            if isinstance(value, Field):
                current_fields.append((key, value))
                attrs.pop(key)
        attrs['declared_fields'] = dict(current_fields)

        new_class = super(DeclarativeFieldsMetaclass, mcs).__new__(mcs, name, bases, attrs)

        # Walk through the MRO.
        declared_fields = {}
        for base in reversed(new_class.__mro__):
            # Collect fields from base class.
            if hasattr(base, 'declared_fields'):
                declared_fields.update(base.declared_fields)

            # Field shadowing.
            for attr, value in base.__dict__.items():
                if value is None and attr in declared_fields:
                    declared_fields.pop(attr)

        new_class.base_fields = declared_fields
        new_class.declared_fields = declared_fields

        return new_class

Django

Form을 정의할때 사용한 필드들을 메타클래스에서 자동으로 수집

Django

class DeclarativeFieldsMetaclass(MediaDefiningClass):
    """Collect Fields declared on the base classes."""
    def __new__(mcs, name, bases, attrs):
        # Collect fields from current class.
        current_fields = []
        for key, value in list(attrs.items()):
            if isinstance(value, Field):
                current_fields.append((key, value))
                attrs.pop(key)
        attrs['declared_fields'] = dict(current_fields)

        new_class = super(DeclarativeFieldsMetaclass, mcs).__new__(mcs, name, bases, attrs)

        # Walk through the MRO.
        declared_fields = {}
        
        ...

        new_class.base_fields = declared_fields
        new_class.declared_fields = declared_fields

        return new_class

Django

ModelForm

Django

ModelForm을 사용할때 몇가지 설정을

Meta라는 내부 클래스로 정의

Django

메타클래스가 이 Meta를 이용해 폼을 생성

Django

class ModelFormMetaclass(DeclarativeFieldsMetaclass):
    def __new__(mcs, name, bases, attrs):
        base_formfield_callback = None
        for b in bases:
            if hasattr(b, 'Meta') and hasattr(b.Meta, 'formfield_callback'):
                base_formfield_callback = b.Meta.formfield_callback
                break

        formfield_callback = attrs.pop('formfield_callback', base_formfield_callback)

        new_class = super(ModelFormMetaclass, mcs).__new__(mcs, name, bases, attrs)

        if bases == (BaseModelForm,):
            return new_class

        opts = new_class._meta = ModelFormOptions(getattr(new_class, 'Meta', None))
        
        ...

Django

먼저 Meta가 올바른 형식으로 만들어 졌는지 확인

Django

예를 들면 fields에 ("foo", ) 대신 ("foo") 를 넣었을 경우 🤦‍♂️

Django

class ModelFormMetaclass(DeclarativeFieldsMetaclass):
    def __new__(mcs, name, bases, attrs):
        ...
        
        # We check if a string was passed to `fields` or `exclude`,
        # which is likely to be a mistake where the user typed ('foo') instead
        # of ('foo',)
        for opt in ['fields', 'exclude', 'localized_fields']:
            value = getattr(opts, opt)
            if isinstance(value, str) and value != ALL_FIELDS:
                msg = ("%(model)s.Meta.%(opt)s cannot be a string. "
                       "Did you mean to type: ('%(value)s',)?" % {
                           'model': new_class.__name__,
                           'opt': opt,
                           'value': value,
                       })
                raise TypeError(msg)

        ...

Django

Meta의 model에 정의된 모델로부터 필드를 가져오기

Django

class ModelFormMetaclass(DeclarativeFieldsMetaclass):
    def __new__(mcs, name, bases, attrs):
        ...

        if opts.model:
            # If a model is defined, extract form fields from it.

            # make sure opts.fields doesn't specify an invalid field

            # Override default model fields with any custom declared ones
            # (plus, include all the other declared fields).
            ...
            fields.update(new_class.declared_fields)
        else:
            fields = new_class.declared_fields

        new_class.base_fields = fields

        return new_class

WE ARE NOW HIRING! 🙌

team.mymusictaste.com/recruit

async def qna_time🙏(questions):
    answer = await asyncio.gather(*questions)

    return answer

보충역 포함!