ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 파이썬 클래스 내부구조 (Python Class Internals)
    Python 2021. 1. 25. 22:22
    반응형

    클래스(class)는 데이터와 기능을 함께 번들링하는 수단을 제공합니다. 새로운 클래스는 새로운 객체의 type을 생성하며, 해당 type의 새로운 인스턴스(instances) 생성을 가능하게 합니다. 각 클래스의 인스턴스는 그 상태를 유지하기 위해 속성들을 가질 수 있습니다. 또한, 클래스 인스턴스들은 그것의 상태를 변경하기 위해 methods들을 가질 수 있습니다 [1].

     

    class A:
        pass
    
    print(type(A))
    # 출력
    <class 'type'>

     

    이 글에서는 클래스(User-Defined class)에 대해 다음과 같은 사항들을 기술합니다:

     

    • class 구조 - type과 metaclass 
    • MRO (Method Resolution Order)
    • class를 구성하는 것들
    • class / instance 생성

     

     

    관련글:

     

     


     

    class 구조 - type과 metaclass

    위 코드의 print(type(A))를 실행하면 <class 'type'>이라는 결과가 발생합니다. 왜 'A'가 아닌 'type'이라는 것이 출력되는 걸까요?

     

    cpython 살펴보기

     

    파이썬에서 실행에 사용되는 값(interpreter loop가 evaluation stack에 있는 values 사용 시), PyObject로 이루어져 있습니다.

    typedef struct _object {
        _PyObject_HEAD_EXTRA
        Py_ssize_t ob_refcnt;
        PyTypeObject *ob_type;
    } PyObject;

    그러한 PyObject에는 PyTypeObject라는 필드가 존재하는데, 그 대략적인 코드는 아래와 같습니다:

    struct _typeobject {
        PyObject_VAR_HEAD
        const char *tp_name; /* For printing, in format "<module>.<name>" */
        Py_ssize_t tp_basicsize, tp_itemsize; /* For allocation */
    
        /* Methods to implement standard operations */
    
        destructor tp_dealloc;
        Py_ssize_t tp_vectorcall_offset;
        getattrfunc tp_getattr;
        setattrfunc tp_setattr;
        PyAsyncMethods *tp_as_async; /* formerly known as tp_compare (Python 2)
                                        or tp_reserved (Python 3) */
        reprfunc tp_repr;
    
        ...
    };

     

    각 객체의 *ob_type에는 PyTypeObject를 각 객체의 특성에 따라 필드를 구성한 PyType_Type, PyBaseObject_Type, PyTupe_Type 등으로 채워지며, 객체의 special methods / attrs(파이썬 코드 상의 __name__, __hash__ 등)을 매핑합니다 (slots).

     

    Part of Python 'type's tp slots - Image from [7]

     

    그러한 Type들은 이름(아래 NAME)을 가지고 있습니다:

     #define INIT_TYPE(TYPE, NAME) \
        do { \
            if (PyType_Ready(TYPE) < 0) { \
                return _PyStatus_ERR("Can't initialize " NAME " type"); \
            } \
        } while (0)
        
        INIT_TYPE(&PyBaseObject_Type, "object");
        INIT_TYPE(&PyType_Type, "type");
        
        ...
        
        INIT_TYPE(&PyTuple_Type, "tuple")
        
        ...
        
        return _PyStatus_OK();
        
    #undef INIT_TYPE
    }

     

    print(type(A)) 실행 시, 'type'이 출력되는 이유도 PyType_Type과 class A가 모종의 관계를 가지고 있기 때문입니다.

     

    아래 이미지와 같이, 파이썬 builtin type은 type의 직접적인 instance이고, User defined type은 type의 (c 언어)instance인 object의 (파이썬)instance인 것을 알 수 있습니다.

     

    Image from Author

    그렇기에 (하나의 예시인) builtin type인 tuple의 type을 살펴보면  'type'이 출력되는 것을 알 수 있습니다:

    print(type(tuple))
    # 출력
    <class 'type'>

    위 이미지에도 나와있듯이 user defined type은 PyBaseObject_Type를 root로 두는데, 이것은 몇 가지 디폴트 값을 넣어주기 위함입니다. 이 부분은 A 클래스의 MRO를 확인하여 알 수 있습니다.

    for e in A.mro():
        print(e.__name__)
        
    # 
    A
    object

     

    Python 코드로 살펴보기

    클래스는 object입니다. 그렇기에 다른 모든 object와 같이 어떤 것의 다른 것의 instance입니다. 모든 클래스는 디폴트로 위의 type에 해당되는 metaclass의 instance입니다 [8].

     

    class A:
        pass
    
    a = A()
    
    print(type(A))
    # <class 'type'>
    print(type(a))
    # <class '__main__.A'>
    print(isinstance(a, A))
    # True
    print(isinstance(A, type))
    # True

     

    간단하게 아래 이미지와 같이 metaclass, class, instance의 관계를 나타낼 수 있습니다:

     

    Image from Author inspired by [8]

    그렇기에  type을 직접 상속해서 metaclass를 만들어 사용할 수 있습니다.

     

    class Meta(type):
        pass
        
    class Complex(metaclass=Meta):
        pass
        
    print(type(Complex))
    # <class '__main__.Meta'>

     

    MRO (Method Resolution Order)

    파이썬 클래스는 다중상속이 가능합니다. 그렇기 때문에 만약 여러 super 클래스에 동일한 이름을 가진 함수가 있을 때, 해당 함수를 호출했을 때 어떤 super 클래스에 속한 함수를 사용할지 선택하는 알고리즘이 필요합니다. 

     

    파이썬(3.9)은 버젼 2.3에서 적용된 C3 superclasss linearization을 사용하고 있습니다.

     

    MRO는 super 클래스를 가지는 클래스에서 함수를 사용할 때 주로 고려하게 됩니다. 파이썬 사용 시에는, 자주 이런 MRO가 적용되는데 그 이유는 다음과 같습니다:

     

    • 클래스 구조에서 살펴보았듯이, instance, class, metaclass는 상속 관계를 가집니다.
    • 클래스 또는 인스턴스에서 속성에 대한 접근, 클래스 생성 등은 모두 위에서 살펴본 slots과 관련이 깊은 Magic methods를 통해 이뤄집니다.

     

    예로, 아래와 같이 클래스 C의 MRO는 C -> A -> B인 것을 알 수 있습니다:

    Image from Author inspired by [9]

     

    아래의 클래스 D에서는 MRO가 D -> C -> A -> B이기에 process()를 실행하면, C의 process()가 실행되는 것을 확인할 수 있습니다:

    Image from Author inspired by [9]

     

    마지막 fail 케이스인 아래 이미지에서 C의 MRO 계산을 생각해보면,

     

    C -> A -> B -> A였다가, 2번째 A는 후행하는 리스트에 존재하여 good candidate이 아니게 됩니다.

    2번째 A를 제외한, C -> B -> A를 시도하면 C -> A -> B 인지 C -> B -> A인지 우선순위를 알 수 없게 됩니다. 그렇기에 아래와 같은 TypeError를 일으킵니다. 이렇게 Monotonic이 아닌 케이스는 이 영상에서 잘 설명해주고 있습니다.

    Image from Author inspired by [9]

     

    class를 구성하는 것들

    디폴트 metaclass인 type, 그리고 user-defined class인 A는 아래와 같이 다양한 special method/attribute(s)를 가지고 있습니다 (user-defined class는 user-defined method/attribute(s)도 가짐).

     

    type에 dir을 실행하면 type__dir__impl이 실행되며, type 생성 시 설정한 bases와 dict를 merge하여 key만 리턴해줍니다. 

    for attr in dir(type):
        if attr == '__abstractmethods__':
            continue
        print(f"{attr:25}", type(getattr(type, attr)))
        
     # 출력
    __base__                  <class 'type'>
    __bases__                 <class 'tuple'>
    __basicsize__             <class 'int'>
    __call__                  <class 'wrapper_descriptor'>
    __class__                 <class 'type'>
    __delattr__               <class 'wrapper_descriptor'>
    __dict__                  <class 'mappingproxy'>
    __dictoffset__            <class 'int'>
    __dir__                   <class 'method_descriptor'>
    __doc__                   <class 'str'>
    __eq__                    <class 'wrapper_descriptor'>
    __flags__                 <class 'int'>
    __format__                <class 'method_descriptor'>
    __ge__                    <class 'wrapper_descriptor'>
    __getattribute__          <class 'wrapper_descriptor'>
    __gt__                    <class 'wrapper_descriptor'>
    __hash__                  <class 'wrapper_descriptor'>
    __init__                  <class 'wrapper_descriptor'>
    __init_subclass__         <class 'builtin_function_or_method'>
    __instancecheck__         <class 'method_descriptor'>
    __itemsize__              <class 'int'>
    __le__                    <class 'wrapper_descriptor'>
    __lt__                    <class 'wrapper_descriptor'>
    __module__                <class 'str'>
    __mro__                   <class 'tuple'>
    __name__                  <class 'str'>
    __ne__                    <class 'wrapper_descriptor'>
    __new__                   <class 'builtin_function_or_method'>
    __prepare__               <class 'builtin_function_or_method'>
    __qualname__              <class 'str'>
    __reduce__                <class 'method_descriptor'>
    __reduce_ex__             <class 'method_descriptor'>
    __repr__                  <class 'wrapper_descriptor'>
    __setattr__               <class 'wrapper_descriptor'>
    __sizeof__                <class 'method_descriptor'>
    __str__                   <class 'wrapper_descriptor'>
    __subclasscheck__         <class 'method_descriptor'>
    __subclasses__            <class 'method_descriptor'>
    __subclasshook__          <class 'builtin_function_or_method'>
    __text_signature__        <class 'NoneType'>
    __weakrefoffset__         <class 'int'>
    mro                       <class 'method_descriptor'>

     

    User defined class A를 살펴보면,

    class A:
        class_a = 'a'
    
        def name(self):
            print('A')
    
        def __init__(self):
            self.instance_a = 'a'
            
    for attr in dir(A):
        print(f"{attr:25}", type(getattr(A, attr)))
        
    #
    __class__                 <class 'type'>
    __delattr__               <class 'wrapper_descriptor'>
    __dict__                  <class 'mappingproxy'>
    __dir__                   <class 'method_descriptor'>
    __doc__                   <class 'NoneType'>
    __eq__                    <class 'wrapper_descriptor'>
    __format__                <class 'method_descriptor'>
    __ge__                    <class 'wrapper_descriptor'>
    __getattribute__          <class 'wrapper_descriptor'>
    __gt__                    <class 'wrapper_descriptor'>
    __hash__                  <class 'wrapper_descriptor'>
    __init__                  <class 'function'>
    __init_subclass__         <class 'builtin_function_or_method'>
    __le__                    <class 'wrapper_descriptor'>
    __lt__                    <class 'wrapper_descriptor'>
    __module__                <class 'str'>
    __ne__                    <class 'wrapper_descriptor'>
    __new__                   <class 'builtin_function_or_method'>
    __reduce__                <class 'method_descriptor'>
    __reduce_ex__             <class 'method_descriptor'>
    __repr__                  <class 'wrapper_descriptor'>
    __setattr__               <class 'wrapper_descriptor'>
    __sizeof__                <class 'method_descriptor'>
    __str__                   <class 'wrapper_descriptor'>
    __subclasshook__          <class 'builtin_function_or_method'>
    __weakref__               <class 'getset_descriptor'>
    class_a                   <class 'str'>
    name                      <class 'function'>

    A의 instance를 살펴보면,

    a = A()
    for attr in dir(a):
        print(f"{attr:25}", type(getattr(a, attr)))
        
    #
    __class__                 <class 'type'>
    __delattr__               <class 'method-wrapper'>
    __dict__                  <class 'dict'>
    __dir__                   <class 'builtin_function_or_method'>
    __doc__                   <class 'NoneType'>
    __eq__                    <class 'method-wrapper'>
    __format__                <class 'builtin_function_or_method'>
    __ge__                    <class 'method-wrapper'>
    __getattribute__          <class 'method-wrapper'>
    __gt__                    <class 'method-wrapper'>
    __hash__                  <class 'method-wrapper'>
    __init__                  <class 'method'>
    __init_subclass__         <class 'builtin_function_or_method'>
    __le__                    <class 'method-wrapper'>
    __lt__                    <class 'method-wrapper'>
    __module__                <class 'str'>
    __ne__                    <class 'method-wrapper'>
    __new__                   <class 'builtin_function_or_method'>
    __reduce__                <class 'builtin_function_or_method'>
    __reduce_ex__             <class 'builtin_function_or_method'>
    __repr__                  <class 'method-wrapper'>
    __setattr__               <class 'method-wrapper'>
    __sizeof__                <class 'builtin_function_or_method'>
    __str__                   <class 'method-wrapper'>
    __subclasshook__          <class 'builtin_function_or_method'>
    __weakref__               <class 'NoneType'>
    class_a                   <class 'str'>
    instance_a                <class 'str'>
    name                      <class 'method'>

     

    위에서 보듯이, type / object / instance에는 다양한 __로 시작하는 special methods들이 존재합니다. 그리고 그것들은 대략적으로  각 (cpython의) object -> ob_type -> tp_<이름>과 연결되어 실행됩니다 (wrapper_descriptor, builtin_function_or_method 등등)

     

    이러한 special methods들은 파이썬의 특정 operation이나 내장함수 실행 시 사용됩니다.

     

    예시로, 아래와 같이 수정한 metaclass를 사용하여 instance를 생성했을 때, 관여하는 special methods를 확인할 수 있습니다.

     

    class Meta(type):
        def __prepare__(mcs, name):
            print('prepare')
            return {}
    
        def __new__(mcs, name, bases, attrs, **kwargs):
            print('new')
            return super().__new__(mcs, name, bases, attrs)
    
        def __call__(cls, *args, **kwargs):
            print('call')
            return super().__call__(*args, **kwargs)
    
    class A(metaclass=Meta):
        def __init__(self):
            print('init')
            
    # 실행 시 출력
    prepare
    new
    
    a = A()
    # 
    call
    init

     

    function과 method (classmethod, staticmethod)

    클래스 내부의 함수들의 종류를 좀 더 자세히 살펴보면 다음과 같습니다:

    class C(object):
        def f1(self, val):
            return val
    
        @staticmethod
        def fs():
            pass
    
        @classmethod
        def fc(cls):
            return cls
            
    print(C.f1)
    print(C.fs)
    print(C.fc)
    #
    <function C.f1 at 0x7f26c1442e18>
    <function C.fs at 0x7f26c1442bf8>
    <bound method C.fc of <class '__main__.C'>>
    
    c = C()
    print(c.f1)
    print(c.fs)
    print(c.fc)
    #
    <bound method C.f1 of <__main__.C object at 0x7f26c1401e48>>
    <function C.fs at 0x7f26c1442bf8>
    <bound method C.fc of <class '__main__.C'>>

    먼저 클래스 C에 대해 살펴보면, f1과 fs는 function 그리고 @classmethod가 씌워진 fc는 bound method라는 것을 볼 수 있습니다. 클래스도 metaclass의 instance이기에, 그러한 클래스에 의존한 method를 생성하였습니다.

     

    C 클래스 -  c 인스턴스를 비교해보면 f1이 function에서 bound method로 변경된 것을 볼 수 있습니다. 그렇기에 아무 annotation이 없는 함수는 인스턴스 namespace에서 bound된다는 것을 알 수 있습니다.

     

    cpython에서 functions과 methods는 아래와 같은 구조를 가지고 있습니다:

    Image from Author inspired by [10]

    위에서 나온, function과 bound method를 중점적으로 살펴보면, function의 경우 closure, global, module 등등 context를 직접 가지고 있는 반면 bound method는 func, self 등으로 의존하고 있는 것을 볼 수 있습니다:

    set(dir(C.fs)) - set(dir(C.fc))
    # function만 가지고 있는 것
    {'__annotations__',
     '__closure__',
     '__code__',
     '__defaults__',
     '__dict__',
     '__globals__',
     '__kwdefaults__',
     '__module__',
     '__name__',
     '__qualname__'}
     
     set(dir(C.fc)) - set(dir(C.fs))
     # bound method만 가지고 있는 것
     {'__func__', '__self__'}

     

     

    class / instance 생성

    파이썬의 클래스와 인스턴스 생성은 동일하게 ()인 call(CALL_FUNCTION)을 실행하며 진행됩니다.

     

    아래와 같이 dis 모듈을 통해 클래스, 인스턴스 생성 시의 op_code를 확인할 수 있습니다:

    from dis import dis
    
    dis('class A:\n\tpass')
      1           0 LOAD_BUILD_CLASS
                  2 LOAD_CONST               0 (<code object A at 0x7efe48eecdb0, file "<dis>", line 1>)
                  4 LOAD_CONST               1 ('A')
                  6 MAKE_FUNCTION            0
                  8 LOAD_CONST               1 ('A')
                 10 CALL_FUNCTION            2
                 12 STORE_NAME               0 (A)
                 14 LOAD_CONST               2 (None)
                 16 RETURN_VALUE
                 
    dis('A()')
      1           0 LOAD_NAME                0 (A)
                  2 CALL_FUNCTION            0
                  4 RETURN_VALUE

    2가지의 생성은 전반적으로 비슷한 flow를 공유하면서 부분부분 다른 값과 로직을 통해 진행됩니다:

     

    • 클래스의 생성은 __build_class__ 부분의 값 설정이 선행합니다.
    • __call__을 실행해 얻은 변수 type은 type_call 함수의 인자로 전달됩니다.
    • type -> tp_new를 실행하여 obj를 얻고 obj가 type의 subtype인지 여부에 따라 리턴값이 결정됩니다.

    Image from Author inspired by [3, 8]

     

     

     

     

    Reference

    [1] Python Docs Classes

    [2] CPython Internals - Class

    [3] CPython Internals - type

    [4] Fluent Python

    [5] Inside the Python Virtual Machine

    [6] Python class inherit object

    [7] Python type tp slots

    [8] blog.ionelmc.ro/2015/02/09/understanding-python-metaclasses/

    [9] Method Resolution Order in Python

    [10] PEP-575

    반응형
Kaden Sungbin Cho