파이써닉한 파이썬을 배워보자 - 9일차 파이썬의 클래스와 객체지향3

0

pythonic

목록 보기
9/10

약한 참조

이전에 사용했던 Date 클래스를 확인해보자.

class Date:
    _cache = {}
    
    @staticmethod
    def __new__(cls, year, month, day):
        self = Date._cache.get((year, month, day))
        if not self:
            self = super().__new__(cls)
            self.year = year
            self.month = month
            self.day = day
            Date._cache[year, month, day] = self
        return self
    
    def __init__(self, year, month, day):
        pass

d = Date(2012, 12, 21)
d = None

d에 있는 Date(2012, 12, 21) 객체를 우리가 더 이상 사용하지 않기때문에 dNone을 주었다. 그러나, Date(2012, 12, 21)는 사라지지 않는다. 즉, 객체의 메모리가 해제되지 않는다.

왜일까? 아주 간단한 이유 때문인데, Date 클래스 안에서 _cache dict안에 저장되어있기 때문이다. 따라서, 참조 횟수가 줄어들 뿐 객체가 사라지지는 않는 것이다.

이 문제를 해결하는 방법 하나는 weakref 모듈을 이용하여 약한 참조(weak reference)를 사용하는 것이다. 약한 참조는 참조 횟수를 늘리지 않으면서 객체를 참조한다. 약한 참조를 사용하려면, 참조하는 객체가 아직 존재하는 지 여부를 검사하는 코드를 먼저 추가해야한다.

import weakref

a = Account("gyu", 1000.0)
a_ref = weakref.ref(a)
print(a_ref) # <weakref at 0x7f179adb8950; to 'Account' at 0x7f179adb3910>

일반 참조와 달리 약한 참조는 참조횟수 카운트에 들어가지 않아, 사용중에 삭제당할 수 있다.

del a
print(a_ref) # <weakref at 0x7f2e771e5900; dead>

약한 참조는 객체에 대한 선택적 참조를 포함한다. 실제 객체를 얻으려면, 약한 참조를 인수가 없는 함수로 호출해야한다. 이렇게 하면 가리키고 있는 개체가 반환되거나 None이 반환된다. 다음의 예를 보자.

acct = a_ref()
if acct is not None:
    acct.balance += 10

print(acct.balance) # 1010.0

약한 참조는 캐싱과 기타 고급 메모리 관리에서 사용된다. 다음은 참조가 더 이상 없을 때 캐시에서 객체를 자동으로 제거하는 기존 Date클래스의 수정 버전이다.

import weakref

class Date:
    _cache = {}
    
    @staticmethod
    def __new__(cls, year, month, day):
        selfref = Date._cache.get((year, month, day))
        if not selfref:
            self = super().__new__(cls)
            self.year = year
            self.month = month
            self.day = day
            Date._cache[year,month,day] = weakref.ref(self)
            
        else:
            self = selfref()
        return self

    def __init__(self, year, month, day):
        pass
    
    def __del__(self):
        del Date._cache[self.year, self.month, self.day]
        
print(Date._cache) # {}
a = Date(2012,12,21)
b = Date(2012, 12,21)
print(a is b) # True
del a
print(Date._cache) # {(2012, 12, 21): <weakref at 0x7fbe6b93e180; to 'Date' at 0x7fbe6b93d070>}
del b
print(Date._cache) # {}

이전에도 언급했듯이 __del__메서드는 객체의 참조 횟수가 0에 도달할 때 호출된다. del a은 참조횟수를 줄일 뿐이다. 한 번 del a을 해도 또 다른 참조가 여전히 남아 있기 때문에 객체는 Date._cache에 유지된다. 두 번째 객체가 삭제되면 __del__()이 호출되고 캐시에서 사라진다.

약한 참조를 지원하려면 인스턴스는 변경 가능한 __weakref__ 속성이 있어야 한다. 기본적으로 사용자 정의 클래스 인스턴스는 __weakref__ 속성이 있다. 따라서 우리의 Date도 약한 참조가 가능했던 것이다.

하지만 내장 타입과 특수한 자료구조(이름이 있는 tuple, 슬롯이 있는 클래스)는 그렇지 않다. 이러한 타입에서도 약한 참조를 구성하려면 __weakref__속성이 추가된 변현 메서드를 정의해야 한다.

class wdict(dict):
    __slots__ = ('__weakref__',)
    
w = wdict()
w_ref = weakref.ref(w)

내부 객체 표현과 속성 바인딩

인스턴스와 연결된 상태는 dict에 저장되며, 인스턴스 __dict__속성으로 접근할 수 있다. 이 dict는 각 인스턴스의 고유한 데이터를 담는다. 다음의 예를 보자.

class Account:
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
        
    def withdraw(self, amount):
        self.balance -= amount
        return self.balance

a = Account('Gyu', 1100.0)
print(a.__dict__) # {'owner': 'Gyu', 'balance': 1100.0}

다음과 같이 언제든지 인스턴스에 새로운 속성을 추가할 수 있다.

a.number = 123456
print(a.__dict__['number']) # 123456

인스턴스 수정은 프로퍼티가 관리하는 속성이 아니라면, 언제나 local __dict__ 속성에 반영된다. 마찬가지로 __dict__를 직접 수정하면 변경 사항이 속성에 반영된다.

인스턴스는 특수한 속성 __class__로 자신의 클래스와 다시 연결된다. 클래스 자체도 자신의 __dict__속성으로 method와 클래스 맴버 변수를 저장한다. 다음의 예를 보도록 하자.

print(a.__class__) # <class '__main__.Account'>
print(a.__class__.__dict__.keys()) # dict_keys(['__module__', '__init__', 'withdraw', '__dict__', '__weakref__', '__doc__'])
print(Account.__dict__['withdraw']) # <function Account.withdraw at 0x7f93a2f1cdc0>

a.__class__를 통해 a인스턴스의 클래스인 Account에 접근할 수 있었고, Account 클래스의 속성, 메서드는 __dict__에 저장되어 있다는 것을 확인할 수 있다.

클래스는 기본 클래스를 담은 튜플인 특수한 속성 __base__로 자신의 기본 클래스와 연결된다. __base__ 속성은 정보 제공용이다.

상속의 실제 런타임 구현은 __mro__ 속성을 이용하며, 이 속성은 검색 순서로 나열된 모든 부모 클래스의 튜플이다. 이 기본 구조는 인스턴스의 속성을 가져오거나 설정하거나 삭제하는 모든 작업의 기초가 된다.

obj.name = value를 사용하여 속성을 설정할 때마다 스페셜 메서드인 obj.__setattr__('name', value)가 호출된다. del obj.name으로 속성을 삭제하면 스페셜 메서드인 obj.__delattr__('name')이 호출된다. 이러한 메서드의 기본 동작은 요청된 속성이 프로퍼티나 디스크립터가 아닌 한 obj의 내부 __dict__에 있는 값을 수정하거나 삭제한다. 속성이 프로퍼티나 디스크립터라면 설정과 삭제 작업은 프로퍼티와 연결된 설정과 삭제 함수가 수행한다.

obj.name과 같은 속성 검색에는 스페셜 메서드 obj.__getattritubte__('name')을 사용한다. 이 메서드는 보통 프로퍼티 확인, 내부 __dict__에서 클래스 사전 확인, 마지막으로 메서드 분석 순으로 속성을 검색한다. 이 검색이 실패하면 마지막 시도로 클래스의 obj.__getattr__('name')을 호출하여 속성을 찾는다. 이것마저 실패하면 AttributeError 예외가 발생한다.

class Account:
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
        
    def __setattr__(self, __name: str, __value: Any) -> None:
        if __name not in {'owner', 'balance'}:
            raise AttributeError(f'No attribute {__name}')
        super().__setattr__(__name, __value)

    def withdraw(self, amount):
        self.balance -= amount
        return self.balance
        
a = Account('Gyu', 1100.0)
a.balance = 940.2
# a.amount = 540 # AttributeError: No attribute amount

이 메서드를 다시 구현하는 클래스는 속성을 조작하는 실제적인 작업을 수행하기 위해서 super()가 제공하는 기본 구현에 의존해야한다. 이는 기본 구현이 디스크립터 및 프로퍼티와 같은 클래스의 고급 기능을 지원해주기 때문이다.

프록시(proxy), wrapper, 위임(delegation)

클래스는 일종의 proxy 객체를 생성하기 위해 다른 객체를 감싸는 wrapper를 구현한다. 프록시는 다른 객체와 동일한 인터페이스를 가지지만, 어떤 이유에서인지 상속 관계 원 객체와는 관련이 없는 객체이다. 이는 새 객체가 다른 객체로부터 만들어지지만, 자신의 고유한 메서드와 속성이 있는 컴포지션과 또 다르다.

보통 프록시를 구현하기 위해 __getattr__() 메서드를 사용한다. 다음의 예를 살펴보자.

class A:
    def spam(self):
        print('A.spam')
    
    def grok(self):
        print('A.grok')
    
    def yow(self):
        print('A.yow')
    
class LoggedA:
    def __init__(self):
        self._a = A()
    
    def __getattr__(self, name):
        print("Accessing", name)
        # 내부 A 인스턴스에 위임
        return getattr(self._a, name)

a = LoggedA()
a.spam() # Accessing spam
# A.span
a.yow() # Accessing spam
# A.yow

다음과 같이 ALoggedA는 상속과 같은 관련이 전혀없지만, LoggedAAself._a로 컴포지션을 하고있다. 여기까지만 보면 컴포지션이지만, __getattr__을 사용하여 A클래스에는 없는 logging 메시지를 추가하였다. 단순히 컴포지션만 하고있는 것이 아니라, 원래의 객체에 추가적인 기능을 넣어준 것이 바로 프록시인 것이다.

위임(delegation)은 종종 상의 대안으로 사용된다.

class A:
    def spam(self):
        print('A.spam')
    
    def grok(self):
        print('A.grok')
    
    def yow(self):
        print('A.yow')
    
class B:
    def __init__(self):
        self._a = A()
    
    def grok(self):
        print('B.grok')
    
    def __getattr__(self, name):
        return getattr(self._a, name)

b = B()
b.spam() # A.spam
b.grok() # B.grok
b.yow() # A.yow

이 예제에서 클래스 B는 클래스 A를 상속하거 단일 메서드를 재정의하는 것처럼 보이지만, 아니다. 대신 클래스 B는 내부에서 클래스 A에 대한 내부 참조를 보유한다. 클래스 A의 일부 함수는 재정의될 수 있다. 하지만 다른 메서드는 모두 __getattr__()메서드를 통해 위임된다.

__getattr__()로 속성 조회를 전달하는 방법은 흔히 사용되는 기법이다. 하지만 연산과 연결된 스페셜 메서드에는 적용되지 않는다는 점에 유의하도록 하자. 다음 클래스와 사용 예제를 살펴보도록 하자.

class ListLike:
    def __init__(self):
        self._items = list()
        
    def __getattr__(self, name):
        return getattr(self._items, name)

a = ListLike()
a.append(1)
a.insert(0,2)
a.sort()
len(a) # 실패
a[0] # 실패

위 예에서 클래스는 리스트의 표준 메서드 list.sort(), lit.append() 등을 모두 내부 리스트에 전달한다. 하지만 파이썬의 표준 연산들은 동작하지않는다. 이 작업을 수행하려면 다음과 같이 필요한 스페셜 메서드를 명시적으로 구현해야 한다.

class ListLike:
    def __init__(self):
        self._items = list()
        
    def __getattr__(self, name):
        return getattr(self._items, name)

    def __len__(self):
        return len(self._items)
    
    def __getitem__(self, index):
        return self._items[index]

    def __setitem__(self, index, value):
        self._items[index] = value

__slots__를 사용한 메모리 사용 줄이기

위에서 본 것처럼 인스턴스는 자신의 데이터를 __dict__에 저장한다. 많은 수의 인스턴스를 생성하게 되면 각 인스턴스 마다 __dict__에 저장하는 데이터가 너무 많아 메모리 부하를 초래할 수 있다. 속성 이름이 정해져 있으면 __slots__라는 특수한 변수에 속성 이름을 지정할 수 있다.

class Account(object):
    __slots__ = ('owner', 'balance')
    ...

슬롯은 파이썬이 메모리 사용과 실행 속도의 성능을 최적화할 용도로 허용한 일종의 definition hint이다. __slots__를 사용하는 클래스의 인스턴스는 인스턴스 데이터를 저장할 때 더 이상 dict를 사용하지 않는다. 그 대신 배열에 기초한 훨씬 더 간결한 데이터 구조를 사용한다. 객체를 많이 생성하는 프로그램에서 __slots__를 사용하면 메모리 사용과 실행 시간을 줄일 수 있다.

__slots__의 유일한 항목은 인스턴스 속성뿐이다. 메서드, 프로퍼티, 클래스 변수 또는 기타 클래스 수준 속성은 목록화하지 않는다. 일반적으로 인스턴스의 __dict__에서 사전 키로 나타내는 것과 동일한 이름이다.

__slots__를 사용할 때는 상속과 복잡한 상호작용을 한다는 점을 염두에 두어야 한다. __slots__를 사용하는 기본 클래스로부터 상속받은 클래스는 새로운 속성을 추가하지 않더라도 속성을 저장하는 __slots__를 정의해 __slots__가 제공하는 이점을 얻을 필요가 있다. 이 점을 잊어버리면 파생 클래스는 더 느리게 동작하며 기본 클래스에서 __slots__를 사용핮 ㅣ않는 경우보다 훨씬 더 많은 메모리를 사용하게 된다.

__slots__는 다중 상속과 호환되지 않는다. 비어 있지 않은 슬롯을 가진 기본 클래스를 여러 개 지정하면 TypeError가 발생한다.

__slots__를 사용할 대 인스턴스 내부에 __dict__ 속성이 있을 것이라 예상하고 작성한 코드가 제대로 동작하지 않을 수 있다. 사용자가 만든 코드에서는 이런 경우는 거의 없지만, 유틸리티 라이브러리와 다른 객체를 지원하기 위한 기타 도구는 디버깅 또는 객체 직렬화나 기타 연산을 위해 __dict__를 살펴보도록 프로그래밍 되어 있을 수 있다.

__slots__가 있다고 해서, 클래스에서 재정의해야 하는 __getattribute__(), __getattr__(), __setattr__() 같은 메서드를 호출하는 데는 어떠한 영향도 주지 않는다. 그러나 이 메서드를 구현한다면 더 이상 인스턴스 __dict__속성을 사용할 수 없다는 점을 유념해야한다. 구현할 때 이점을 고려해야한다.

디스크립터(descriptor)

일반적으로 속성에 접근하는 것은 dict 작업에 해당한다. 더 많은 제어가 필요하다면 사용자가 정의한 get, set, delete 함수를 통해 속성에 접근할 수 있다. 하지만, 프로퍼티는 디스크립터(descriptor)라고 알려진 저수준(lower-level) 객체를 사용하여 구현된다. 디스크립터는 속성 접근을 관리하는 클래스 수준 객체다. 디스크립터에서 스페셜 메서드인 __get__(), __set__(), __delete__() 중 하나 이상의 메서드를 구현하여 속성 접근 메커니즘을 가로채 연산을 사용자 정의할 수 있다. 다음 예를 살펴보자.

class Typed:
    expected_type = object
    
    def __set_name__(self, cls, name):
        self.key = name
    
    def __get__(self, instance, cls):
        if instance:
            return instance.__dict__[self.key]
        else:
            return self
    
    def __set__(self, instance, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(f'Expected {self.expected_type}')
        instance.__dict__[self.key] = value
    
    def __delete__(self, instance):
        raise AttributeError("Can't delete attribute")
    
class Integer(Typed):
    expected_type = int
    
class Float(Typed):
    expected_type = float

class String(Typed):
    expected_type = str
    
class Account:
    owner = String()
    balance = Float()
    
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance

이 예에서 Typed 클래스는 속성에 값이 할당될 때 타입 검사를 수행하고, 속성을 삭제하려고 시도하면 에러를 일으키는 디스크립터를 정의한다. Integer, Float, String 하위 클래스는 특정 타입과 일치하도록 Typed을 특화한다. Account와 같은 다른 클래스에서 이 하위 클래스를 사용해 해당 속성에 접근할 때 적절한 __get__(), __set__(), __delete__() 메서드를 자동으로 호출한다. 다음의 예를 살펴보자.

a = Account('gyu', 1000.9)
b = a.owner # Account.owner.__get__(a, Account) 호출
a.owner = 'Eva' # Account.owner.__set__(a, 'Eva') 호출
del a.owner # AttributeError: Can't delete attribute

디스크립터는 클래스 수준에서만 디스크립터의 인스턴스를 생성할 수 있다. __init__() 및 기타 메서드 내부에 디스크립터 객체를 만들어 인스턴스마다 디스크립터를 생성하는 일은 올바른 방법이 아니다. 디스크립터의 __set_name__()메서드는 클래스를 정의하고 나서 인스턴스가 생성되기 전에 호출되는데, 클래스에서 사용된 이름을 디스크립터에 알린다. 가령 balance = Float()Float.__set_name__(Account, 'balance')를 호출하여 사용 중인 클래스와 이름을 디스크립터에 알린다.

__set__()메서드를 사용하는 디스크립터는 인스턴스 사전에 있는 항목들보다 우선순위가 높다. 가령, 특정 디스크립터가 인스턴스 dict의 키와 이름이 같으면, 디스크립터에 우선권이 있다. 다음은 Account 예에서 인스턴스 dict와 일치하는 항목이 있더라도 타입 검사를 수행하는 디스크립터가 우선한다는 것을 확인할 수 있다.

class Account:
    owner = String()
    balance = Float()
    
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance


a = Account('gyu', 1000.9)
print(a.__dict__) # {'owner': 'gyu', 'balance': 1000.9}
a.balance = 'a lot' # TypeError: Expected <class 'float'>

디스크립터의 __get__(instance, cls) 메서드는 인스턴스와 클래스 모두 인수로 사용한다. __get__()은 클래스 수준에서 호출될 수 있는데, 이 경우 인스턴스 인수는 None이다. 대부분의 경우 __get__()은 인스턴스가 제공되지 않으며 디스크립터를 다시 반환한다.

a = Account('gyu', 1000.9)
print(Account.balance) # <__main__.Float object at 0x7fe34c9117f0>

__get__()만 구현하는 디스크립터를 매서드 디스크립터(method descriptor)라고 한다. 이는 get/set 기능을 모두 가진 디스크립터보다 결합력(binding)이 약하다. 특히 메서드 기술자 __get__() 메서드는 인스턴스 dict에 일치하는 항목이 없는 경우에만 호출된다. 즉, __set__()는 인스턴스의 __dict__를 우선하는게 아니라 클래스의 __dict__를 우선하지만, __get__()은 인스턴스의 __dict__를 우선하고, 클래스의 __dict__를 나중에 찾는다.

메서드 디스크립터라 불리는 이유는 이 디스크립터가 인스턴스 메서드, 클래스 메서드, 정적 메서드를 포함한 파이썬의 다양한 메서드를 구현할 때 자주 사용되기 때문이다. 가령, 다음의 코드는 @classmethod@staticmethod를 처음부터 어떻게 구현하는지 그 뼈대를 보여주고 있다.

import types
class classmethod:
    def __init__(self, func):
        self.__func__ = func
    
    # cls를 첫번째 인수로 사용하는 바운드 메서드를 반환
    def __get__(self, instance, cls):
        return types.MethodType(self.__func__, cls)

class staticmethod:
    def __init__(self, func):
        self.__func__ = func
        
    def __get__(self, instance, cls):
        return self.__func__

메서드 디스크립터는 인스턴스 __dict__에 일치하는 항목이 없는 경우에만 동작하므로, 다양한 형태의 속성 지연 평가(lazy evaluation)를 구현할 때 사용할 수 있다. 다음의 예를 살펴보자.

class Lazy:
    def __init__(self, func):
        self.func = func
    
    def __set_name__(self, cls, name):
        self.key = name
    
    def __get__(self, instance, cls):
        if instance:
            value = self.func(instance)
            instance.__dict__[self.key] = value
            return value
        else:
            return self

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    area = Lazy(lambda self: self.width * self.height)
    perimeter = Lazy(lambda self: 2*self.width + 2*self.height)

이 예에서 areaperimeter는 요청에 따라 계산되고 인스턴스 dict에 저장되는 속성이다. 계산될 때마다 값은 인스턴스 사전에서 직접 반환된다.

r = Rectangle(3, 4)
print(r.__dict__) # {'width': 3, 'height': 4}
print(r.area) # 12
print(r.perimeter) # 14

클래스 정의 과정

클래스 정의는 동적 프로세스이다. class문을 사용하여 클래스를 정의하면 내부 클래스 네임스페이스 역할을 하는 새로운 dict가 생성된다. 그러면 클래스의 본문은 이 네임스페이스 내에서 스크립트로 실행된다. 결국 네임스페이스는 결과 클래스 객체의 __dict__ 속성이 된다.

문법이 적절하다면 어떤 형식의 파이썬 문장도 클래스 본문에서 허용된다. 보통 함수와 변수는 정의하면 되는데, 제어 흐름, import문, 중첩 클래스와 기타 모든 것들도 허용된다. 다음 코드는 조건부로 메서드를 정의하는 클래스를 보여준다.

debug = True

class Account:
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
        
    if debug:
        import logging
        log = logging.getLogger(f'{__module__}.{__qualname__}')
    
        def deposit(self, amount):
            Account.log.debug('Depositing %f', amount)
            self.balance += amount
            
        def withdraw(self, amount):
            Account.log.debug('Withdrawing %f', amount)
            self.balance -= amount
    else:
        def deposit(self, amount):
            self.balance += amount
            
        def withdraw(self, amount):
            self.balance -= amount

위 예에서 전역 변수 debug는 조건부로 메서드를 정의하는 데 사용된다. __qualname____module__ 변수는 각각 클래스 이름과 이를 감싸고 있는 모듈에 대한 정보를 가진 미리 정의된 문자열이다. 이들은 클래스 본문에서 문장으로 사용될 수 있다. 이 예에서는 로깅 시스템을 구성하는 데 사용되고 있다. 이 코드를 더욱 깔끔하게 정리할 수 있지만, 핵심은 클래스에 원하는 모든 것을 넣을 수 있다는 점이다.

클래스 정의에서 한 가지 중요한 점은 클래스 본문의 내용을 담는 데 이용하는 네임스페이스가 변수의 유효 범위가 아니라는 것이다. 즉, 로컬 변수처럼 해제되지 않는다. 메서드에서 사용되는 모든 이름(위 예에서는 Account.log)는 완전히 한정적으로 쓰여야 한다.

locals()와 같은 함수를 클래스 본문에서 사용하면(메서드 내부X), 클래스 네임스페이스에서 사용되는 dict를 반환한다.

동적 클래스 생성

클래스는 보통 class을 사용해 생성하지만, 반드시 class문으로만 생성하는 것은 아니다. 클래스는 클래스 본문을 실행해 네임스페이스를 채우는 방식을 정의할 수 있다. 네임스페이스 dict에 자신이 정의한 것을 채울 수 있다면, class문을 사용하지 않고도 클래스를 생성할 수 있다. 클래스 생성을 위해 types.new_class()를 사용한다.

import types

# 메서드(클래스에 속하지 않음)
def __init__(self, owner, balance):
    self.owner = owner
    self.balance = balance
    
def deposit(self, amount):
    self.balance -= amount

def withdraw(self, amount):
    self.balance += amount

methods = {
    '__init__': __init__,
    'deposit': deposit,
    'withdraw': withdraw
}

Account = types.new_class('Account', (), exec_body=lambda ns: ns.update(methods))

a = Account('Guido', 1000.0)
a.deposit(50)
a.withdraw(25)
print(a.balance) # 975.0

new_class()함수에서 클래스 네임스페이스를 채우기 위해서는 클래스 이름, base class tuple 그리고 callback함수가 필요하다. 콜백 함수는 클래스 네임스페이스 dict를 인수로 받으므로 이 dict를 업데이트해야한다.

메타 클래스

파이썬에서 클래스를 정의하면 클래스 정의 자체도 객체가 된다.

class Account:
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
    
    def deposit(self, amount):
        self.balance += amount
    
    def withdraw(self, amount):
        self.balance -= amount
        
print(isinstance(Account, object)) # True

Account가 객체라면 무언가가 이 객체를 생성해야 한다는 것을 알게 된다. 클래스 객체를 생성하는 일은 메타 클래스(metaclass)라 불리는 특수한 종류의 객체가 제어한다. 간단히 말해, 메타 클래스는 클래스의 인스턴스를 생성하는 클래스이다.

Account를 생성한 메타 클래스는 type이라 불리는 내장 클래스이다. 사실 Account 타입을 확인해보면 Account의 타입이 type의 인스턴스라는 것을 알 수 있다.

print(Account.__class__) # <class 'type'>

class문으로 새로운 클래스를 정의하면 몇 가지 일이 일어난다. 먼저 클래스를 위한 새로운 네임스페이스(__dict__)가 생성된다. 다음으로 클래스의 본문이 네임스페이스 안에서 실행된다. 마지막으로 클래스 이름, 기본 클래스 그리고 생성된 네임스페이스가 해당 클래스 인스턴스를 생성하기 위해서 사용된다. 다음 코드는 이 과정이 이루어지는 저수준 단계를 보여준다.

# 클래스 네임스페이스 생성
namespace = type.__prepare__('Account', ())

# 클래스 본문 실행
exec('''
def __init__(self, owner, balance):
    self.owner = owner
    self.balance = balance

def deposit(self, amount):
    self.balance += amount

def withdraw(self, amount):
    self.balance -= amount
''', globals(), namespace)

# 최종 클래스 객체 생성
Account = type('Account', (), namespace)
print(isinstance(Account, object)) # True

print(Account.__class__) # <class 'type'>

클래스 정의 단계에서는 type 클래스와 상호작용하여 클래스 네임스페이스를 생성하고 최종 클래스 객체를 생성한다. type을 사용하여 사용자 정의 타입을 만들 수 있다(즉, 클래스를 만들 수 있다). 어떤 클래스는 다른 메타 클래스를 지정해 다른 타입 클래스가 처리하도록 선택할 수 있다. 이 작업을 위해 상속에서 metaclass 키워드 인수를 사용한다.

class Account(metaclass=type):
    ...

metaclass를 지정하지 않으면 class문은 기본 클래스 튜플의 첫번째 항목 타입을 살펴본다음, 있으면 이를 메타클래스로 사용한다. 따라서, class Account(object)를 작성하면 그 결과 Account 클래스는 object(이는 type)와 동일한 타입을 갖게 된다. 부모를 지정하지 않은 클래스는 언제나 object에서 상속되므로 이 방법과 동일하게 적용된다는 점을 주목하자.

새 메타 클래스를 생성하려면 type을 상속한 클래스를 정의하자. 이 클래스에서 클래스 생성 과정 동안 사용할 하나 또는 그 이상의 메서드를 다시 정의할 수 있다. 일반적으로 여기에는 클래스 네임스페이스를 만드는 __prepare__()메서드, 클래스 그 자체의 인스턴스를 만드는 __new__() 메서드, 클래스가 만들어진 다음 호출되는 __init__메서드, 새로운 인스턴스를 생성하는 __call__() 메서드 등이 있다. 다음 예는 메타 클래스를 구현한 코드로 각각의 메서드에서 입력 인수만 출력해 실험해볼 수 있게 하였다.

class mytype(type):
    # 클래스 네임스페이스 생성
    @classmethod
    def __prepare__(meta, clsname, bases):
        print("Preparing:", clsname, bases)
        return super().__prepare__(clsname, bases)
    
    # 본문이 실행된 후 클래스 그 자체의 인스턴스 생성
    @staticmethod
    def __new__(meta, clsname, bases, ns):
        print("Creating:", clsname, bases, ns)
        return super().__new__(meta, clsname, bases, ns)
    
    # 클래스 그 자체의 인스턴스 초기화
    def __init__(cls, clsname, bases, ns):
        print("Initializing:", clsname, bases, ns)
        super().__init__(clsname, bases, ns)
    
    # 클래스의 새로운 인스턴스 생성
    def __call__(cls, *args, **kwargs):
        print("Creating instance:", args, kwargs)
        return super().__call__(*args, **kwargs)

class Base(metaclass=mytype):
    def __init__(self, name):
        print("Base hello:", name)

#Preparing: Base ()
#Creating: Base () {'__module__': '__main__', '__qualname__': 'Base'}
#Initializing: Base () {'__module__': '__main__', '__qualname__': 'Base'}

b = Base("gyu") # Creating instance: () {}
# Base hello: gyu

메타 클래스로 작업할 때 한 가지 까다로운 것은 변수 이름을 지정하고 관련된 다양한 엔티티(entities)를 추적하는 일이다. 이 코드에서 meta라는 이름은 메타 클래스 자체를 참조한다. cls라는 이름은 메타 클래스로 생성된 클래스 인스턴스를 참조한다. self는 클래스로 생성된 일반 인스턴스를 참조한다.

메타 클래스는 상속을 통해 전파된다. 따라서 다른 메타 클래스를 사용하도록 기본 클래스를 정의한다면, 자식 클래스 또한 모두 메타 클래스를 사용하게 된다. 다음 예제에서 사용자 정의한 메타 클래스를 확인해보도록 하자.

class Account(Base):
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
    
    def deposit(self, amount):
        self.balance += amount
    
    def withdraw(self, amount):
        self.balance -= amount

print(type(Account)) # <class '__main__.mytype'>

type(Account)<class '__main__.mytype'>가 나오는 것을 확인할 수 있다. 이는 Base의 메타 타입이 mytype이기 때문이고, 이 타입이 subclass에 모두 영향을 미치기 때문이다.

메타 클래스는 클래스 정의 환경과 생성 과정을 극도로 저수준에서 제어할 필요가 있을 때 사용한다. 이렇게 하기 전에 파이썬에는 __init_subclass__() 메서드, 클래스, 데코레이터, 디스크립터, 등과 같이 클래스 정의를 모니터링하고 변경할 수 있는 기능이 이미 많다. 대체로 메타 클래스는 필요하진 않지만, 다음과 같은 경우는 메타 클래스가 매우 합리적인 해결책이라는 사실을 보여준다.

메타 클래스는 클래스 객체를 생성하기 전, 클래스 네임스페이스의 내용을 다시 작성할 때 사용된다. 클래스의 특정 기능은 클래스를 정의할 때 설정되며 이 후에는 수정할 수 없다. 이 기능 중 하나가 __slots__이다. __slots__는 인스턴스의 메모리 레이아웃(layout)과 관련해 성능을 최적화하려는 용도로 사용된다. 다음은 __init__() 메서드의 함수 시그니처에서 __slots__속성을 자동으로 설정하는 메타 클래스이다.

import inspect

class SlotMeta(type):
    @staticmethod
    def __new__(meta, clsname, bases, methods):
        if '__init__' in methods:
            sig = inspect.signature(methods['__init__'])
            __slots__ = tuple(sig.parameters)[1:]
        else:
            __slots__ = ()
        
        methods['__slots__'] = __slots__
        return super().__new__(meta, clsname, bases, methods)

class Base(metaclass=SlotMeta):
    pass

class Point(Base):
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1,2)

이 예에서 Point 클래스는 ('x', 'y')__slots__을 사용하여 자동으로 생성된다. Point의 인스턴스는 슬롯을 사용하는 지도 모르고 메모리를 절약하게 된다. 슬롯은 직접 지정하지 않아도 된다. 이러한 종류의 트릭은 클래스 데코레이터나 __init_subclass에서는 불가능하다. 왜냐하면 이러한 기능은 클래스가 생성된 다음 동작하기 때문이다. 그 시점에서 __slots__최적화를 사용하기에는 너무 늦다.

메타 클래스의 또 다른 용도는 클래스의 정의 환경을 변경하는 것이다. 가령, 클래스를 정의하는 과정에서 메소드의 이름을 중복해 정의하면 가동되지 않는 오류가 발생한다. 즉, 두번째 정의가 첫번째 정의를 덮어쓴다. 이 오류를 처리한고 생각해보자.

가령, 다음과 같은 경우가 있다.

class SomeClass:
    def yow(self):
        print('Yow!')
    
    def yow(self, x):
        print('Different Yow!')
    
s = SomeClass()
s.yow()
s.yow(1) # TypeError: yow() missing 1 required positional argument: 'x'

yow(self)yow(self, x)로 덮여쓰여진다. 이는, python에서 method overloading을 지원하지 않기 때문이다. (이상하게 연산자 오버로딩은 지원한다.)

이러한 문제가 생기면 알 수 있도록 해주자.

class NoDupleDict(dict):
    def __setitem__(self, key, value):
        if key in self:
            raise AttributeError(f'{key} already defined')
        super().__setitem__(key, value)
    
class NoDupleMeta(type):
    @classmethod
    def __prepare__(meta, clsname, bases):
        return NoDupleDict()
    
class Base(metaclass=NoDupleMeta):
    pass

class SomeClass(Base):
    def yow(self):
        print('Yow!')
    
    def yow(self, x): # 실패 이미 정의됨
        print('Different Yow!')

위 코드를 실행하면 두번째 yow를 정의한 곳에서 에러가 발생한다. 이는 NoDupleMeta를 metaclass로 받도록 하고, NoDupleMeta에서는 __prepare__로 namespace를 전달해주는데, namespace가 NoDupleDict인 것이다. NoDupleDict는 이미 설정된 인자 key가 전달되면 AttributeError를 발생시킨다.

메타클래스를 사용하여 일종의 도메인 특화된 언어로 역할을 하도록 도울 수 있다.

메타 클래스는 다양한 작업을 수행하는데 이용되어 왔지만, 지금은 다른 수단으로도 가능하다. 특히 __init_subsclass__()메서드는 한때 메타 클래스를 적용했던 다양한 사용 사례(test case) 처리에 이용될 수 있다.

인스턴스와 클래스를 위한 내장 객체

다음은 타입을 직접 조작해야하는 저수준 메타 프로그래밍과 코드에 유용하다.

속성설명
cls.name클래스 이름
cls.module클래스가 정의된 모듈 이름
cls.qualname완전히 한정된 클래스 이름
cls.bases기본 클래스 튜플
cls.mro메서드 분석 순서(method resolution order) 튜플
cls.dict클래스 메서드와 변수를 담은 dict
cls.doc문서화 문자열
cls.annotations클래스 타입 힌트 dict
cls.abstractmethods추상 메서드 이름 집합(없는 경우 정의되지 않을 수 있음)

cls.__name__속성에는 클래스 이름이 포함된다. cls.__qualname__속성은 주변 컨텍스트에 대한 추가 정보가 있는 완전히 한정적인 이름을 포함한다. 이는 클래스가 함수 내부에 정의되어 있거나 중첩된 클래스를 정의할 때 유용할 수 있다. cls.__annotations__ dict은 클래스 수준 타입 힌트(있는 경우)를 포함한다.

다음은 인스턴스 속성이다.

속성설명
i.class인스턴스가 속한 클래스
i.dict인스턴스가 데이터를 보유한 사전(정의된 경우)

__dict__속성에는 인스턴스와 관련된 데이터가 모두 저장된다. 하지만 사용자 정의 클래스가 __slots__를 사용하면 더 효율적인 내부 표현을 사용할 수 있어 인스턴스는 __dict__속성을 갖지 않는다.

0개의 댓글