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

0

pythonic

목록 보기
8/10

타입, 인터페이스, 추상 기본 클래스

클래스의 인스턴스를 만들 때, 해당 인스턴스의 타입은 클래스이다. 클래스의 맴버 자격을 검사하기 위해 내장 함수 isinstance(obj, cls)를 사용한다. 이 함수는 객체 obj가 클래스 cls또는 cls에서 파생된 클래스에 속하면 True를 반환한다.

class A:
    pass

class B(A):
    pass

class C:
    pass

a = A()
b = B()
c = C()

print(type(a)) # <class '__main__.A'>
print(isinstance(a, A)) # True
print(isinstance(b, A)) # True
print(isinstance(b, C)) # False

유사하게 내장 함수 issubclass(A,B)는 클래스 A가 클래스 B의 하위 클래스면 True를 반환한다.

print(isinstance(B, A)) # True
print(isinstance(C, A)) # False

프로그래밍 인터페이스의 상세 설명(specification)을 위해 일반적으로 클래스 타입 관계를 사용한다. 일례로 프로그래밍 인터페이스 요구 사항을 반영하기 위해 최상위 기본 클래스를 구현할 수 있다. 그러면 이 기본 클래스는 타입 힌트 또는 isinstance()를 통한 방어(defensive)타입 적용에 사용할 수 있다.

class Stream:
    def receive(self):
        raise NotImplementedError()

    def send(self):
        raise NotImplementedError()

    def close(self):
        raise NotImplementedError()

def send_request(stream, request):
    if not isinstance(stream, Stream):
        raise TypeError('Expected a Stream')

    stream.send(request)
    return stream.receive()

Stream은 직접 사용할 수 없다. 대신 다른 클래스들이 Stream에서 상속받아 필요한 기능을 구현한다. 사용자는 대신 해당 클래스 중 하나를 인스턴스로 생성한다.

class SocketStream(Stream):
    def receive(self):
        ...
    def send(self):
        ...
    def close(self):
        ...

class PipeStream(Stream):
    def receive(self):
        ...
    def send(self):
        ...
    def close(self):
        ...
        
request = {}
s = SocketStream()
send_request(s, request)

다음과 같이 필요한 기능을 구현하면 된다.

이 예제에서의 문제는 send_request()에서 인터페이스 런타임을 제약하고 있다는 것이다. 대신 타입 힌트를 사용해보자.

def send_request(stream:Stream, request):
    stream.send(request)
    return stream.receive()

이러한 타입 힌트가 강제되지 않는다는 점을 감안하면, 인터페이스에 대한 인수 유효성을 어떻게 확인할지에 대한 결정은 사용자의 요구에 달려있다. 런타임에 코드 검사 단계를 두거나 아예 수행하지 않을 수도 있다.

인터페이스 클래스는 대규모 프레임워크 및 응용 프로그램에서 주로 사용된다. 하지만 이러한 접근 방식을 취할 때는 하위 클래스가 실제로 필요한 인터페이스를 구현하는 지 확인해야한다. 가령, 하위클래스가 필수 메서드의 하나를 구현하지 않거나 단순히 철자를 잘못 입력해도, 보통은 코드가 여전히 동작할 수 있으므로 처음에는 잘못된 것이 눈에 띄지 않을 수 있다. 하지만 나중에 구현되지 않은 메서드가 호출되면 프로그램이 충돌한다.

이 문제를 방지하기 위해 abc모듈을 사용해 인터페이스를 추상 기본 클래스로(Abstract Base Class)로 정의하는 게 일반적이다. 이 모듈은 기본 클래스(ABC)와 데코레이터(@abstractmethod)를 정의하는 데 인터페이스를 설명하기 위해 함께 사용된다.

from abc import ABC, abstractmethod

class Stream(ABC):
    @abstractmethod
    def receive(self):
        pass
    @abstractmethod
    def send(self):
        pass
    @abstractmethod
    def close(self):
        pass

추상 클래스는 인스턴스를 직접 생성하려고 만든 것이 아니라, 만약 Stream인스턴스를 생성하려고 시도하면 다음과 같은 에러가 발생한다.

Traceback (most recent call last):
  File "/p4ws/gy95.park/python_project/study_python/main.py", line 150, in <module>
    s = Stream()
        ^^^^^^^^
TypeError: Can't instantiate abstract class Stream with abstract methods close, receive, send

에러 메시지는 Stream에서 정확히 구현해야 할 메서드가 무엇인지 알려준다. 이는 하위 클래스를 작성하기 위한 가이드 역할을 한다. 하위 클래스를 작성했지만 실수를 했다고 가정해보자.

class SocketStream(Stream):
    def read(self): # 함수명을 잘못 작성
        ...
    def send(self):
        ...
    def close(self):
        ...

추상 기본 클래스는 인스턴스를 생성할 때 실수를 잡는다. 오류가 조기에 발견되므로 유용하다.

Traceback (most recent call last):
  File "/p4ws/gy95.park/python_project/study_python/main.py", line 159, in <module>
    s = SocketStream()
        ^^^^^^^^^^^^^^
TypeError: Can't instantiate abstract class SocketStream with abstract method receive

추상 기본 클래스(abstract base class)는 자신은 인스턴스를 생성할 수 없지만, 하위 클래스에서 사용할 메서드와 속성을 정의할 수 있다.

from abc import ABC, abstractmethod

class Stream(ABC):
    def __init__(self, a, b):
        self.a = a
        self.b = b
    @abstractmethod
    def receive(self):
        pass
    @abstractmethod
    def send(self):
        pass
    @abstractmethod
    def close(self):
        pass

class SocketStream(Stream):
    def receive(self):
        ...
    def send(self):
        ...
    def close(self):
        ...

s = SocketStream(1,2)

다음과 같이 Stream 추상 클래스에서 __init__으로 만든 속성들은 SocketStream에서 인스턴스로 생성할 때 생성자를 통해 초기화할 수 있다.

추가적으로 기본 클래스의 추상 메서드는 하위 클래스에서 호출 할 수 있다. 가령, 하위 클래스에서 super().receive()로 호출하는 것이 허용된다.

다중 상속, 인터페이스, 혼합

파이썬은 다중 상속을 지원한다. 자식 클래스가 둘 이상의 부모를 나열한 경우 자식은 각 부모의 기능을 모두 상속받는다.

class Duck:
    def walk(self):
        print('Waddle')
    
class Trombonist:
    def noise(self):
        print('Blat!')

class DuckBonist(Duck, Trombonist):
    pass

d = DuckBonist()
d.walk() # Waddle
d.noise() # Blat!

만약 DuckTrombonist가 각각 __init__()메서드를 정의하면 어떻게 될까?? 또는 두 클래스 모두 noise()를 정의하면 어떻게 될까? 다중 상속은 굉장히 위험하다.

다중 상속의 실제 사용법을 더 잘 이해하려면 코드 재사용 및 조직화를 위한 전문화된 도구로 바라보아야한다. 다중 상속은 타입과 인터페이스 관계를 조직하기 위해 사용한다. 가령, 이전에 소개한 추상 기본 클래스(absctract base class)를 생각해보자 추상 기본 클래스의 목적은 프로그래밍 인터페이스를 구체적으로 명시하려는 것이다. 다얌과 같이 다양한 추상 기본 클래스가 있다고 하자.

from abc import ABC, abstractmethod

class Stream(ABC):
    @abstractmethod
    def receive(self):
        pass
    @abstractmethod
    def send(self):
        pass
    @abstractmethod
    def close(self):
        pass

class Iterable(ABC):
    @abstractmethod
    def __iter__(self):
        pass

이 클래스들을 이용하면, 다중 상속을 사용해 자식 클래스에 구현된 인터페이스를 명시할 수 있다.

class MessageStream(Stream, Iterable):
    def receive(self):
        ...
    def send(self):
        ...
    def close(self):
        ...
    def __iter__(self):
        ...

다중 상속은 구현에 관한 것이 아닌 타입 관계에 사용한다. 가령, 위 예제에서 상속 메서드가 그 어떤 것도 수행하지 않으며 코드의 재사용도 없다. 주로 상속 관계는 다음과 같은 타입 검사를 수행할 수 있도록 한다.

m = MessageStream()

print(isinstance(m, Stream))  # -> True
print(isinstance(m, Iterable)) # -> True

다중 상속의 다른 용도는 혼합 클래스(mixin class)를 정의하는 것이다. 혼합 클래스는 다른 클래스의 기능을 수정하거나 확장하는 클래스이다. 다음 클래스 정의를 살펴보자.

class Duck:
    def noise(self):
        return 'Quack'
    
class Trombonist:
    def noise(self):
        return 'Blat!'
    def march(self):
        return 'Clomp'
    
class Cyclist:
    def noise(self):
        return 'On your left!'
    def pedal(self):
        return 'Pedaling'

위 클래스들은 서로 관련이 없다. 상속 관계가 없으며 서로 다른 메서드를 구현하고 있다. 하지만 이들 각각은 noise() 메서드를 정의한다는 공통점이 있다. 이를 참조하여 다음 클래스들을 정의할 수 있다.

class LoudMixin:
    def noise(self):
        return super().noise().upper()
    
class AnnoyingMixin:
    def noise(self):
        return 3*super().noise()

위 클래스는 언뜻 보기에는 잘못 작성된 것처럼 보인다. 단 하나의 독립 메서드가 있고, 이 메서드가 super()를 사용하지만 부모 클래스의 존재가 있지가 않다.

a = AnnoyingMixin()
a.noise()

위 클래스를 작동시키면 동작도 하지 않는다.

Traceback (most recent call last):
  File "/p4ws/python/pythonic/main.py", line 26, in <module>
    a.noise()
  File "/p4ws/python/pythonic/main.py", line 23, in noise
    return 3*super().noise()
AttributeError: 'super' object has no attribute 'noise'

이들은 혼합 클래스이다. 이들을 동작하게 하는 방법은 누락된 기능을 구현한 다른 클래스와 함꼐 사용하는 것이다. 다음 예를 보자.

class LoudDuck(LoudMixin, Duck):
    pass

class AnnoyingTrombonist(AnnoyingMixin, Trombonist):
    pass

class AnnoyingLoudCyclist(AnnoyingMixin, LoudMixin, Cyclist):
    pass

d = LoudDuck()
print(d.noise()) # QUACK

t = AnnoyingTrombonist()
print(t.noise()) # Blat!Blat!Blat!

c = AnnoyingLoudCyclist()
print(c.noise()) # ON YOUR LEFT!ON YOUR LEFT!ON YOUR LEFT!

혼합 클래스는 일반 클래스와 같은 방식으로 정의하므로 Mixin이라는 단어를 클래스 이름의 일부로 포함하는 것이 좋다.

혼합 클래스를 완전히 이해하려면 상속과 super()함수가 어떻게 동작하는 지 좀 더 알아야 한다.

첫째로 상속을 사용할 때마다 파이선은 메서드 분석 순서(Method Resolution Order, MRO)로 알려진 선형 클래스 연결을 구축한다. 이는 클래스의 __mro__속성으로 살펴볼 수 있다. 다음은 단일 상속에 대한 몇 가지 예를 보여준다.

class Base:
    pass

class A(Base):
    pass

class B(A):
    pass

print(Base.__mro__) # (<class '__main__.Base'>, <class 'object'>)
print(A.__mro__) # (<class '__main__.A'>, <class '__main__.Base'>, <class 'object'>)
print(B.__mro__) # (<class '__main__.B'>, <class '__main__.A'>, <class '__main__.Base'>, <class 'object'>)

메서드 분석 순서(MRO)는 속성(메서드, 지역변수)를 위한 검색 순서를 지정한다. 특히 인스턴스나 클래스에서 속성을 검색할 때마다 메서드 분석 순서에 포함된 각 클래스들은 나열된 순서대로 확인된다. 첫 번째 매치 항목이 만들어지면 검색은 중단된다. 위 예에서는 object 클래스가 존재하는 것을 볼 수 있는데, 이는 어떤 부모에서 온 것인지 여부와 상관없이 클래스는 모두 object에서 상속되기 때문이다.

다중 상속을 지원하기 위해 파이썬은 협력 다중 상속(cooperative multiple inheritance)을 구현한다. 협력 상속을 통해 클래스는 모두 두 가지 기본 순서 규칙에 따라 메서드 분석 순서 목록에 배치된다.

첫 번째는 자식 클래스는 항상 부모보다 먼저 확인되어야 한다는 것이다.

두 번째는 한 클래스에 여러 부모가 있을 때, 해당 부모들은 자식의 상속 목록에 작성된 것과 동일한 순서로 확인해야 한다는 것이다.

대부분의 경우 이러한 규칙으로 합리적인 메서드 분석 순서를 생성한다. 하지만 클래스를 정렬하는 알고리즘은 실제로 복잡하며 dfs, bfs와 같은 간단한 알고리즘으로 결정되지 않는다. 이 순서는 c3 선형화 알고리즘을 채택하였는데, 이 알고리즘에 따르면 파이썬에서는 특정 종류의 클래스 계층을 생성할 수 없다. 계층을 생성하려고 하면 TypeError 예외가 발생한다.

class X: pass
class Y(X): pass
class Z(X,Y): pass # TypeError: Cannot create a consistent method resolution order (MRO) for bases X, Y

이 예에서 메서드 분석 알고리즘은 의미 있는 기본 클래스의 순서를 제대로 정할 수 없기 때문에 클래스 Z의 생성을 거부한다.

여기서 클래스 X는 상속 목록에서 클래스 Y보다 앞에 등장하므로 먼저 검사해야한다. 하지만 클래스 Y는 X에서 상속받았기 때문에 X가 먼저 검색된다면 자식부터 검사해야하는 규칙을 위반하게 된다. 실전에서 이러한 문제는 프로그램 설계에 큰 문제가 있다는 것이다.

실제 메서드 분석 순서의 예씨로 앞에서 보여준 AnnoyingLoudCyclist 클래스에 대한 메서드 분석 순서를 살펴보자.

class AnnoyingLoudCyclist(AnnoyingMixin, LoudMixin, Cyclist):
    pass

print(AnnoyingLoudCyclist.__mro__) # (<class '__main__.AnnoyingLoudCyclist'>, <class '__main__.AnnoyingMixin'>, <class '__main__.LoudMixin'>, <class '__main__.Cyclist'>, <class 'object'>)

이 메서드 분석 순서에서 어떻게 두 규칙이 모두 충족되는지 볼 수 있다. 특히 모든 자식 클래스는 언제나 부모보다 먼저 나열된다. object 클래스는 모두 클래스의 부모이므로 가장 마지막에 나열된다. 부모들은 코드에서 나타난 순서대로 나열된다.

super()의 동작 방식은 메서드 분석 순서와 연결되어 있다. super()의 역할은 메서드 분석 순서에서 다음에 위치하는 클래스에 속성을 위임하는 것이다. 이는 super()가 사용되는 클래스를 기반으로 한다.

가령, AnnoyingMixin 클래스가 super()를 사용할 때, 인스턴스의 메서드 분석 순서를 확인하여 자신의 위치를 찾는다. 여기서 속성 조회를 다음 클래스로 위임한다. 이 예에서 AnnoyingMixin 클래스의 super().noise()LoudMixing.noise()를 호출하는데, 이는 LoudMixinAnnoyingLoudCyclist의 메서드 분석 순서에 등재된 다음 클래스이기 때문이다.

그런 다음 LoudMixin에서의 super().noise()작업은 Cyclist클래스로 위임된다. super()를 사용하는 경우 다음 클래스 선택은 인스턴스 타입에 따라 다르다. 가령, AnnoyingTrombonist의 인스턴스를 생성하면 super.noise()가 대신 Trombonist.noise()를 호출한다.

협력적 다중 상속과 혼합 클래스를 설꼐하는 것은 어려운 일이다. 다음 몇 가지 설계 지침이 있다.

첫번째로 자식 클래스는 항상 메서드 분석 순서의 기본 클래스(Base class)보다 먼저 확인된다.

따라서 혼합 클래스는 공통 부모를 공유하고, 해당 부모는 메서드의 빈 구현을 제공하는 것이 일반적이다. 다중 혼합 클래스를 동시에 사용하는 경우, 서로 나란히 정렬된다. 공통 부모는 기본 구현 또는 에러 검사를 제공할 수 있도록 마지막에 나타난다. 다음은 한 예이다.

class NoiseMixin:
    def noise(self):
        raise NotImplementedError('noise() not implemented')
    
class LoudMixin(NoiseMixin):
    def noise(self):
        return super().noise().upper()
    
class AnnoyingMixin(NoiseMixin):
    def noise(self):
        return 3 * super().noise()

혼합 클래스에서 noise()가 구현되었는 지 검사하는 NoiseMixin을 넣는 것이다. 그리고 구현부를 맞은 클래스는 LoudMixinAnnoyingMixinNoiseMixin클래스보다 먼저 상속을 받으면 된다.

두번째로 혼합 메서드의 구현은 모두 동일한 함수 서명(function signature)를 가져야 한다. 혼합 클래스에서의 한 가지 문제점은 이들은 선택적이며 예측할 수 없는 순서로 함께 혼합된다는 것이다. 이 작업이 동작하려면 다음에 오는 클래스와 관계없이 super()와 관련된 작업이 성공할 수 있도록 해야한다는 것이다. 이를 위해서는 호출 연쇄(call chain)에서 메서드는 모두 호환되는 호출서명이 있어야 한다는 것이다.

마지막으로 어디에서나 super()를 사용해야 한다. 때로는 부모를 직접 호출하는 클래스를 볼 수 있다.

class Base:
    def yow(self):
        print("Base.yow")

class A(Base):
    def yow(self):
        print('A.yow')
        Base.yow(self)

class B(Base):
    def yow(self):
        print('B.yow')
        super().yow()

class C(A, B):
    pass

c = C()
c.yow() 
# A.yow
# Base.yow

위의 A 클래스에서와 같이 직접 부모를 호출하는 방식도 있지만, super()를 사용해야 연쇄적으로 chain call에 따라 결과가 나온다. 즉, 현재는 B클래스의 결과가 안나왔는데, 원래의 chain call은 다음과 같다.

class Base:
    def yow(self):
        print("Base.yow")

class A(Base):
    def yow(self):
        print('A.yow')
        super().yow()

class B(Base):
    def yow(self):
        print('B.yow')
        super().yow()

class C(A, B):
    pass

c = C()
print(C.__mro__) # (<class '__main__.C'>, <class '__main__.A'>, <class '__main__.B'>, <class '__main__.Base'>, <class 'object'>)
c.yow() 
# A.yow
# B.yow
# Base.yow

다음과 같이 Base 클래스가 B보다 뒤에 있는 이유는 AB모두 Base클래스를 상속해서 그렇다. 만약, A 클래스만 Base를 상속했다면 A다음 Base가 나왔을 것이다. 즉, 다중 상속에 있어서 부모 클래스끼리 같은 클래스를 상속받았다면 상속받은 클래스는 이들 뒤에 나온다.

따라서 실행결과는 위와 같이 나오는 것이 맞다. 이는 super()를 통해서 호출 시에 AsuperB로 바뀌고 BsuperBase가 호출되기 때문이다.

타입 기반 디스패치

때로는 특정 타입에 기반하여 코드가 다르게 동작하도록 작성하는 경우가 있다.

if isinstance(obj, Duck):
    handle_duck(obj)
elif isinstance(obj, Trombonist):
    handler_cyclist(obj)
else:
    raise RuntimeError('Unknown object'

위 코드는 if-elif-else로 처리되어 우아하지도 않고, 깨지기도 쉽다. 자주 사용하는 해결책은 dict를 사용해서 dispatch하는 것이다.

handlers = {
    Duck: handle_duck,
    Trombonist: handle_tromonist,
    Cyclist: handl_cyclist
}

def dispatch(obj):
    func = handlers.get(type(obj))
    if func:
        return func(obj)
    else:
        raise RuntimeError(f'No handler for {obj}')

위 해결책은 타입이 정확히 일치한다고 가정한다. 디스패치에서 상속을 지원하는 경우에는 메서드 분석 순서를 살펴봐야 한다.

def dispatch(obj):
    for ty in type(obj).__mro__:
        func = handlers.get(ty)
        if func:
            return func(obj)
    raise RuntimeError(f'No handler for {obj}')

다음과 같이 getattr()을 사용하는 클래스 기반 인터페이스를 통해 디스패치를 구현하기도 한다.

class Dispatcher:
    def handle(self, obj):
        for ty in type(obj).__mro__:
            meth = getattr(self, f'handle_{ty.__name__}', None)
            if meth:
                return meth(obj)
        raise RuntimeError(f'No handler for {obj}')
    
    def handle_Duck(self, obj):
        ...
    def handle_Trombonist(self, obj):
        ...
    def handle_Cyclist(self, obj):
        ...
        
dispatcher = Dispatcher()
dispatcher.handle(Duck()) # -> handle_Duck()
dispatcher.handle(Cyclist()) # -> handle_Cyclist()

getattr()을 사용하여 클래스의 메서드를 디스패치하는 위의 예쩨는 자주 쓰이는 프로그래밍 패턴이다.

클래스 데코레이터

클래스를 정의하고 난 후, 클래스를 registry에 등록하거나 추가 지원 코드를 생성하는 것과 같은 몇 가지 추가 작업을 수행하고 싶을 때가 있다. 한 가지 방법은 class decorator를 사용하는 것이다. class decorator는 입력으로 클래스를 받고 클래스를 반환하는 함수이다.

_registry = {}
def register_decoder(cls):
    for mt in cls.mimetypes:
        _registry[mt] = cls
    return cls

def create_decoder(mimetype):
    return _registry[mimetype]()

register_decoder() 함수는 클래스 내에 mimetypes 속성이 있는 지 살펴보고 이 속성을 반경하면 MIME 타입을 클래스 객체에 매핑하는 사전에 이 클래스를 추가한다. 이 함수를 사용하려면 다음과 같이 클래스 정의 바로 앞에 데코레이터를 써주면 된다.

@register_decoder
class TextDecoder:
    mimetypes = ['text/plain']
    def decode(self, data):
        ...

@register_decoder
class HTMLDecoder:
    mimetypes = ['text/html']
    def decode(self, data):
        ..

@register_decoder
class ImageDecoder:
    mimetypes = ['image/png', 'image/jpg', 'image/gif']
    def decode(self, data):
        ...
        
decoder = create_decoder('image/jpg')

클래스 데코레이터는 주어진 클래스의 내용을 자유롭게 수정할 수 있다. 가령, 기존 메서드를 다시 작성할 수 있다. 이는 혼합 클래스 또는 다중 상속을 대신할 방법이기도 하다. 가령, 다음 데코레이터들을 살펴보자.

def loud(cls):
    orig_noise = cls.noise
    def noise(self):
        return orig_noise(self).upper()
    cls.noise = noise
    return cls

def annoying(cls):
    orig_noise = cls.noise
    def noise(self):
        return 3 * orig_noise(self)
    cls.noise = noise
    return cls

@annoying
@loud
class Cyclist(object):
    def noise(self):
        return 'On your left'
    
    def pedal(self):
        return 'Pedaling'

c = Cyclist()    
print(c.noise()) # ON YOUR LEFTON YOUR LEFTON YOUR LEFT

다중상속이 없고 super를 사용하지 않아도 각각의 데코레이터를 통해서 이전과 동일한 결과를 만들어낼 수 있다.

위와 같이 클래스 데코레이터로 기존 메서드를 변형할 수도 있고, 완전히 새로운 코드를 작성할 수도 있다. 다음은 클래스를 작성할 때 디버깅을 도와주는 유용한 __repr__() 메서드를 작성하는 코드이다.

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        
    def __repr__(self):
        return f'{type(self).__name__}({self.x!r}, {self.y!r})

매번 __repr__메서드를 작성하기 성가시다. 클래스 데코레이터가 사용자를 위해 해당 메서드를 추가해주도록 하는 것이 어떨까?

import inspect
def with_repr(cls):
    args = list(inspect.signature(cls).parameters)
    argvals = ', '.join('{self.%s!r}' % arg for arg in args)
    code = 'def __repr__(self):\n'
    code += f'    return f"{cls.__name__}({argvals})"\n'
    locs = {}
    exec(code, locs)
    cls.__repr__ = locs['__repr__']
    return cls

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

위 예에서 __repr__메서드는 __init__메서드의 호출 signature로부터 생성된다. 이 메서드는 텍스트 문자열로 생성되고, exec()에 전달되어 함수를 생성한다. 생성된 함수는 클래스와 연결된다.

이와 유사한 코드 생성 기법이 표준 라이브러리의 일부에서 사용되고 있다. 다음 코드는 dataclass를 사용하여 자료구조를 편리하게 정의하는 방법을 보여준다.

from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

p = Point(2,3)
print(p) # Point(x=2, y=3)

dataclassPoint에 있는 클래스 변수을 사용하여 __init____repr__을 만들어준다. 참고로 dataclass를 사용하면 내부에 있는 클래스 변수들은 클래스의 맴버 변수로 바뀐다. 즉,

class Point:
    def __init__(self, x:int, y:int) -> None:
        self.x = x
        self.y = y

로 바뀐다. 여기에 맴버 변수 x, y를 사용하여 __repr__을 정의해주는 것이다.

이 방법의 단점은 시작 성능이 좋지 않다. exec()을 사용하여 동적으로 코드를 생성하면 파이썬은 모듈에 적용하는 최적화 과정을 건너뛴다. 따라서 이 방식으로 많은 수의 클래스를 정의하면 코드를 불러오는 속도가 크게 느려질 수 있다.

상속 감독

클래스를 정의하고 나서 추가적인 작업을 수행하는 방법 중 하나가 데코레이터였다. 그런 부모 클래스가 하위클래스를 대신하여 추가 작업을 수행해야 할 경우도 있다. 이는 __init_succlass__(cls) 클래스 메서드를 구현하여 수행 할 수 있다. 다음 예를 살펴보자.

class Base:
    @classmethod
    def __init_subclass__(cls):
        print('Initializing', cls)
    
class A(Base):
    pass

class B(A):
    pass

a = A() # Initializing <class '__main__.A'>
b = B() # Initializing <class '__main__.B'>

__init_subclass__() 메서드가 있으면 자식 클래스를 정의할 때 자동으로 트리거된다. 이는 자식 클래스가 상속 계층 깊이 숨겨져 있어도 수행된다.

클래스 데코레이터로 수행되는 많은 작업은 __init_subclass__()를 사용하면 대신할 수 있다. 다음은 클래스 등록과 관련된 코드이다.

class DecoderBase:
    _registry = {}
    @classmethod
    def __init_subclass__(cls):
        for mt in cls.mimetypes:
            DecoderBase._registry[mt] = cls

def create_decoder(mimetype):
    return DecoderBase._registry[mimetype]()

class TextDecoder(DecoderBase):
    mimetypes = ['text/plain']
    def decode(self, data):
        ...
        
class HTMLDecoder(DecoderBase):
    mimetypes = ['text/html']
    def decode(self, data):
        ...

class ImageDecoder(DecoderBase):
    mimetypes = ['image/png', 'image/jpg', 'image/gif']
    def decode(self, data):
        ...

decoder = create_decoder('image/jpg')

다음은 클래스 __init__() 메서드의 signature에서 __repr__() 메서드를 자동으로 생성하는 클래스 예제이다.

import inspect

class Base:
    @classmethod
    def __init_subclass__(cls):
        # __repr__ 메서드 생성
        args = list(inspect.signature(cls).parameters)
        argvals = ', '.join('{self.%s!r}' % arg for arg in args)
        code = 'def __repr__(self):\n'
        code += f'    return f"{cls.__name__}({argvals})"\n'
        locs = {}
        exec(code, locs)
        cls.__repr__ = locs['__repr__']
    
class Point(Base):
    def __init__(self, x ,y):
        self.x = x
        self.y = y
        
p = Point(10,20)
print(p) # Point(10, 20)

이전에 @dataclass 데코레이터를 사용해 만들었던 것과 같은 기능을 한다.

다중 상속을 사용할 때는 __init_subclass__()를 구현하는 클래스가 모두 호출되도록 super()를 사용해야 한다. 다음 예를 보자.

class A:
    @classmethod
    def __init_subclass__(cls):
        print('A.init_subclass')
        super().__init_subclass__()
    
class B:
    @classmethod
    def __init_subclass__(cls):
        print('B.init_subclass')
        super().__init_subclass__()
        
class C(A,B):
    pass

__init_subclass__()로 상속을 감독하는 것은 파이썬의 강력한 사용자 정의 기능 가운데 하나이다. 대부분 이는 암묵적으로 수행된다. 최상위 기본 클래스는 이를 사용하여 자식 클래스의 전체 계층 구조를 조용히 감독할 수 있다. 이러한 감독 활동을 통해 클래스를 등록하고 메서드를 다시 작성하고 유효성을 검증하는 등의 작업을 수행할 수 있다.

객체 생애주기와 메모리 관리

클래스가 정의되면, 결과로 발생하는 클래스는 새로운 인스턴스를 만들기 위한 factory 역할을 수행한다. 다음 예를 살펴보자.

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

# Account 인스턴스를 몇 개 생성        
a = Account('Guido', 1000.0)
b = Account('Eva', 25.0)

인스턴스 생성은 새로운 인스턴스를 생성하는 스페셜 메서드인 __new__()와 생성된 인스턴스를 초기화하는 __init__()메서드를 사용하는 두 단계로 수행된다.

가령, a = Account('Guido', 1000.0)은 다음 단계를 수행하는 것과 같다.

a = Account.__new__(Account, 'Guido', 1000.0)
if isinstance(a, Account):
    Account.__init__('Guido', 1000.0)

인스턴스에서 클래스를 대신하는 첫 번째 인수를 제외하고 __new__()__init__()에 전달하는 인수와 동일한 인수를 받는다. 하지만 __new__()은 기본적으로 이러한 사항을 무시한다. 때로는 단 하나의 인수로 __new__()를 호출하는 경우도 있다. 가령 이 코드는 다음과 같이 동작한다.

a = Account.__new__(Account)
Account.__init__('Guido', 1000.0)

__new__()메서드를 직접사용하는 경우는 드물지만, 때로는 __init__()메서드 호출을 우회하여 인스턴스를 생성하기도 한다. 이런 용도로 사용하는 사례가 클래스 메서드에 있다. 다음 예를 살펴보자.

import time

class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day
        
    @classmethod
    def today(cls):
        t = time.localtime()
        self = cls.__new__(cls) # 인스턴스를 생성
        self.year = t.tm._year
        self.month = t.tm_mon
        self.day = t.tm_mday
        return self

pickling과 같은 객체 직렬화(object serialization)을 수행하는 모듈에서 객체를 역직렬화할 때 인스턴스를 생성하기 위해 __new__()를 사용한다. 이 작업은 __init__()을 호출하지 않고 수행된다.

클래스는 인스턴스 생성의 일부를 변경하기 위해 __new__()를 정의할 때가 있다. 이는 일반적으로 인스턴스 캐싱, 싱글톤, 불변성 등을 위해 사용한다. 가령 Date클래스에서 날짜를 미리 만들고자 한다. 즉, 같은 연도, 월 ,일을 가진 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)
e = Date(2012, 12, 21)
assert d is e # 같은 객체임을 의미

이 예에서 클래스는 이미 생성된 Data인스턴스의 내부 dict를 유지하여 새로운 Date를 생성할 때 캐시가 먼저 참조한다. 일치하는 항목을 발견하면 해당 인스턴스를 반환해준다. 만약 그렇지 않으면 새로운 인스턴스가 생성되고 초기화된다.

이 해결책의 미묘한 세부 사항은 빈 __init__() 메서드이다. 인스턴스가 캐시되더라도 Date()를 호출하면 여전히 __init__()를 호출한다. 중복해 호출하지 않도록 __init__() 메서드는 아무 작업도 하지 않는다. 인스턴스 생성은 실제로 인스턴스가 처음 생성될 때 __new__()에서 일어난다.

__init__()에 대한 추가 호출을 막는 방법도 있지만 어느정도 트릭이 필요하다. 한 가지 방법은 __new__()가 완전히 다른 타입의 인스턴스(ex, 다른 클래스에 속하는 인스턴스)를 반환하는 것이다. 또 다른 방법은 다음에 설명할 메타클래스를 사용하는 것이다.

일단 생성된 인스턴스는 참조 횟수 세기로 관리된다. 참조 횟수가 0에 도달하면 인스턴스는 즉시 폐기된다. 인스턴스가 소멸되려고 할 때 인터프리터는 먼저 인스턴스에 __del__() 메서드가 있는 지 살펴보고 이를 호출한다. 다음 예를 살펴보자.

class Account(object):
    def __init__(self, owner, balance):
        self.owner = owner
        self.balance = balance
        
    def __del__(self):
        print('Deleting Account')
    
a = Account('Guido', 1000.0)
del a # Deleting Account

위 예처럼 때때로 프로그램은 객체에 대한 참조를 삭제하기 위해 del문을 사용한다. 이에 따라 객체 참조 횟수가 0에 도달하면 __del__()메서드가 호출된다.

일반적으로 del문은 __del__()을 직접 호출하지 않는데, 이는 어딘가에 다른 객체 참조가 남아 있을 수 있기 때문이다. 객체를 삭제하는 방법에는 변수 이름을 재할당하거나 함수의 유효 범위를 벗어나도록 변수를 사용하는 것 등이 있다.

a = Account('Guido', 1000.0)
a = 32 # Deleting Account
def func():
    a = Account('Guido', 1000.0)
    ...
    
func() # Deleting Account

실제로 클래스에서 __del__()메서드를 정의하는 경우는 굉장히 드물다. 한 가지 예외는 객체 폐기 과정에서 파일을 닫거나 네트워크 연결을 끓거나 기타 시스템 자원을 해제하는 등의 청소 작업이 필요할 때이다. 이 경우에도 적절한 종료를 위해 __del__()에 의존하는 것은 위험하다. 왜냐하면 인터프리터가 종료 될 때 __del__()메서드를 호출하리라는 보장이 없기 때문이다.

자원을 완전히 해제하기 위해서는 객체에 명시적으로 close()메서드를 제공하는 것이 좋다. 또한 클래스가 with문과 함께 사용하는 컨텍스트 관리자 프로토콜을 지원하게 해야한다.

다시 한 번 강조하지만 클래스에서 __del__()메서드를 작성할 필요는 거의 없다. 파이썬에는 이미 가비지 컬렉션(garbage collection)기능이 있으며 객체가 소멸 될 때 수행해야 할 추가 작업이 없다면 __del__()메서드를 굳이 작성할 필요가 없다. 추가 작업을 수행하더라도 __del__()이 필요하지 않을 수 있는데, 아무 것도 하지 않아도 객체 스스로 정리되도록 이미 프로그래밍되어 있을 수 있기 때문이다.

참조 횟수 세기와 객체 파괴에 대해 위험하지 않으면서 객체들이 순환 참조(reference cycle)를 생성할 수 있는 특정 종류의 프로그램 패턴이 존재한다. 특히 부모 자식 관계나 그래프 또는 캐싱과 관련된 패턴이 그렇다. 다음의 예를 살펴보자

class SomeClass:
    def __del__(self):
        print('Deleting')
    
parent = SomeClass()
child = SomeClass()

# 부모 자식 순환 참조(reference cycle) 생성
parent.child = child
child.parent = parent

# 삭제하려고 시도 __del__의 출력이 나오지 않는다.
del parent
del child

위 예제에서 변수 이름은 파괴되었지만 __del__ 메서드의 실행 결과는 볼 수 없다. 두 객체가 각각 서로에 대한 참조를 가지고 있으므로 참조 횟수가 0이 되지 않기 때문이다. 이를 처리하기 위해 특별한 순환 참조 감지(cycle-detecting) 가비지 컬렉터가 자주 실행된다. 결국 객체는 회수가 되지만, 언제 회수될지는 예측하기 어렵다. 가비지 컬렉션을 강제로 실행하려면 gc.collect()를 호출하면 된다. gc 모듈에는 순환 가비지 컬렉터 및 메모리 모니터링과 관련된 함수가 많다.

가비지 컬렉션이 언제 동작할 지 정확히 예측할 수 없으므로 __del__()메서드에 대해서는 몇 가지 제한 사항이 있다.

  1. __del__()로 전파되는 예외는 모두 sys.stderr에 출력되며 그렇지 않으면 무시된다.
  2. __del__()메서드는 lock이나 기타 자원의 획득과 같은 작업을 피해야 한다. 만약 자원을 획득하게 되면 신호 처리나 스레드의 콜백 과정에서 관련없는 함수를 실행하다가 __del__이 예기치 않게 실행되어 교착상태에 빠질 수 있다.

따라서, __del__()은 최대한 사용하지 않는게 좋고 사용한다면 단순하게 만들어야 한다.

0개의 댓글