파이썬 코딩의 기술 - 41

JinWooHyun·2021년 8월 4일

파이썬 코딩의 기술

목록 보기
11/14

기능을 합성할 때는 믹스인 클래스를 사용하라

파이썬은 다중 상속을 처리할 수 있게 지원하는 객체지향 언어이다. (하지만 다중 상속은 피하는 편이 좋다.)

다중 상속이 제공하는 편의와 캡슐화가 필요하지만 다중 상속으로 인해 발생할 수 있는 문제들을 피하고 싶다면, 믹스인(mix-in) 을 사용할 지 고려야 보아야 한다.

믹스인은 자식 클래스가 사용할 메서드 몇 개만 정의하는 클래스이다. 믹스인 클래스에는 자체 애트리뷰트 정의가 없으므로 믹스인 클래스의 __init__ 메서드를 호출할 필요도 없다.

믹스인(mix-in)
믹스인(mix-in)은 다른 클래스에서 사용할 수 있도록 공통적인 메서드를 모아 놓은 클래스를 말한다. 파이썬에서 믹스인은 자체 인스턴스 속성을 가지고 있지 않으며 __init__ 메서드를 구현하지 않는다. 예를 들어 인사하는 메서드 greeting를 사람 종류의 클래스에서 공통적으로 사용하는 메서드로 HelloMixIn을 구현했을 때, Student는 HelloMixIn과 Person을 상속받고, Teacher도 HelloMixIn과 Person을 상속받으면 Student와 Teacher는 모두 공통 메서드인 greeting을 사용할 수 있다.

class HelloMixIn:
    def greeting(self):               # 인사하는 메서드는 공통적인 메서드
        print('안녕하세요.')
class Person():
    def __init__(self, name):
        self.name = name
class Student(HelloMixIn, Person):    # HelloMixIn과 Person을 상속받아 학생 클래스를 만듦
    def study(self):
        print('공부하기')
class Teacher(HelloMixIn, Person):    # HelloMixIn과 Person을 상속받아 선생님 클래스를 만듦
    def teach(self):
        print('가르치기')

예를 들어 메모리 내에 들어 있는 파이썬 객체를 직렬화에 사용할 수 있도록 딕셔너리로 바꾸고 싶다고 하자.

class ToDictMixin:
    def to_dict(self):
        return self._traverse_dict(self.__dict__)

이 _traverse_dict 메서드를 hasattr을 통한 동적인 애트리뷰트 접근과 isinstance를 사용한 타입 검사, __dict__를 통한 인스턴스 딕셔너리 접근을 활용해 간단하게 구현할 수 있다.

    def _traverse_dict(self, instance_dict):
        output = {}
        for key, value in instance_dict.items():
            output[key] = self._traverse(key, value)

    def _traverse(self, key, value):
        if isinstance(value, ToDictMixin):
            return value.to_dict()
        elif isinstance(value, dict):
            return self._traverse_dict(value)
        elif isinstance(value, list):
            return [self._traverse(key, i) for i in value]
        elif hasattr(value, '__dict__'):
            return self._traverse_dict(value.__dict__)
        else:
            return value

다음은 이 믹스인을 사용해 이진 트리를 딕셔너리로 변경하는 코드이다.

class BinaryTree(ToDictMixin):
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

연관된 여러 파이썬 객체들을 한 딕셔너리로 변환하는 것도 쉽게 할 수 있다.

tree = BinaryTree(10, 
    left=BinaryTree(7, right=BinaryTree(9)),
    right=BinaryTree(13, left=BinaryTree(11))
)
print(tree.to_dict())

>>>
{'value': 10,
 'left': {'value': 7,
          'left': None,
          'right': {'value': 9, 'left': None, 'right': None}},
 'right': {'value': 13,
          'left': {'value': 11, 'left': None, 'right': None},
          'right': None}
}

믹스인의 가장 큰 장점은 제네릭 기능을 쉽게 연결할 수 있고 필요할 때 기존 기능을 다른 기능으로 오버라이드(override)해 변경할 수 있다는 것이다. 예를 들어 다음 코드는 BinaryTree에 대한 참조를 저장하는 BinaryTree의 하위 클래스를 정의한다. 이런 순환 참조가 있으면 원래의 ToDictMixin.to_dict 구현은 무한 루프를 돈다.

class BinaryTreeWithParent(BinaryTree):
    def __init__(self, value, left=None, right=None, parent=None):
        super().__init__(value, left=left, right=right)
        self.parent = parent

해결 방법은 BinaryTreeWithParent._traverse 메서드를 오버라이드해 문제가 되는 값만 처리하게 만들어서 믹스인이 무한 루프를 돌지 못하게 하는 것이다.

    def _traverse(self, key, value):
        if (isinstance(value, BinaryTreeWithParent) and key == 'parent'):
            return value.value # 순환 참조 방지
        else:
            return super()._traverse(key, value)
root = BinaryTreeWithParent(10)
root.left = BinaryTreeWithParent(7, parent=root)
root.left.right = BinaryTreeWithParent(9, parent=root.left)
print(root.to_dict())

>>>
{'value': 10,
 'left': {'value': 7,
          'left': None,
          'right': {'value': 9, 'left': None, 'right': None, 'parent': 7},
          'parent': 10},
 'right': None,
 'parent': None}

BinaryTreeWithParent._traverse를 오버라이드함에 따라 BinaryTreeWithParent를 애트리뷰트로 저장하는 모든 클래스도 자동으로 ToDictMixin을 문제없이 사용할 수 있게 된다.

class NamedSubTree(ToDictMixin):
    def __init__(self, name, tree_with_parent):
        self.name = name
        self.tree_with_parent = tree_with_parent

my_tree = NamedSubTree('foobar', root.left.right)
print(my_tree.to_dict()) # 무한 루프 없음

>>>
{'name': 'foobar',
 'tree_with_parent': {'value': 9,
                      'left': None,
                      'right': None,
                      'parent': 7}}

믹스인을 서로 합성할 수도 있다. 예를 들어 임의의 클래스를 JSON으로 직렬화하는 제네릭 믹스인을 만들고 싶다고 하자. 모든 클래스가 to_dict 메서드를 제공한다고 가정하면 다음과 같은 제네릭 믹스인을 만들 수 있다.

import json

class JsonMixin:
    @classmethod
    def from_json(cls, data):
        kwargs = json.loads(data)
        return cls(**kwargs)

    def to_json(self):
        return json.dumps(self.to_dict())

여기서 JsonMixin 클래스 안에 인스턴스 메서드와 클래스 메서드가 함계 정의됐다는 점에 유의해야 한다. 믹스인을 사용하면 인스턴스의 동작이나 클래스의 동작 중 어느 것이든 하위 클래스에 추가할 수 있다. 이 예제에서 JsonMixin 하위 클래스의 요구 사항은 to_dict 메서드를 제공해야 한다는 점과 __init__ 메서드가 키워드 인자로 받아야 한다는 점뿐이다.

이런 믹스인이 있으면 JSON과 직렬화를 하거나 역직렬화를 할 유틸리티 클래스의 클래스 계층 구조를 쉽게 만들 수 있다. 예를 들어 데이터 센터의 각 요소 간 연결(topology)을 표현하는 클래스 계층이 있다고 하자.

class DatacenterRack(ToDictMixin, JsonMixin):
    def __init__(self, switch=None, machines=None):
        self.switch = Switch(**switch)
        self.machines = [Machine(**kwargs) for kwargs in machines]

class Switch(ToDictMixin, JsonMixin):
    def __init__(self, ports=None, speed=None):
        self.ports = ports
        self.speed = speed

class Machine(ToDictMixin, JsonMixin):
    def __init__(self, cores=None, ram=None, disk=None):
        self.cores = cores,
        self.ram = ram
        self.disk = disk

이런 클래스들을 JSON으로 직렬화하거나 역직렬화하는 것은 간단하다.

deserialized = DatacenterRack.from_json(serialized)
roundtrip = deserialized.to_json()
assert json.loads(serialized) == json.loads(roundtrip)

이렇게 믹스인을 사용할 때 JsonMixin을 적용하려고 하는 클래스 상속 계층의 상위 클래스에 이미 JsonMixin을 적용한 클래스가 있어도 아무런 문제가 없다. 이런 경우에도 super가 동작하는 방식으로 인해 믹스인을 적용한 클래스가 제대로 작동한다.

기억해야 할 내용

  • 믹스인을 사용해 구현할 수 있는 기능을 인스턴스 애트리뷰트와 __init__을 사용하는 다중 상속을 통해 구현하지 말라.
  • 믹스인 클래스가 클래스별로 특화된 기능을 필요로 한다면 인스턴스 수준에서 끼워 넣을 수 있는 기능(정해진 메서드를 통해 해당 기능을 인스턴스가 제공하게 만듦, 오버라이딩)을 활용하라.
  • 믹스인에는 필요에 따라 인스턴스 메서드는 물론 클래스 메서드도 포함할 수 있다.
  • 믹스인을 합성하면 단순한 동작으로부터 더 복잡한 기능을 만들어 낼 수 있다.
profile
Unicorn Developer

0개의 댓글