-
[Python] Tip - 믹스인 유틸리티 클래스에만 다중 상속을 사용언어/파이썬 & 장고 2016. 10. 26. 21:00
파이썬은 다중 상속을 다루기 쉽게 하는 기능을 내장한 객체지향 언어입니다. 다중 상속으로 얻는 편리함과 캡슐화가 필요하다면 대신 믹스인(mix-in)을 작성하는 방안을 고려해야 합니다. 믹스인이란 클래스에서 제공해야 하는 추가적인 메서드만 정의하는 작은 클래스를 말합니다. 믹스인 클래스는 자체의 인스턴스 속성(attribute)를 정의하지 않으며 __init__ 생성자를 호출하도록 요구하지 않습니다.
Mix-In 상세 설명
파이썬에서의 믹스인은믹스 인은 특별한 종류의 다중 상속입니다. 믹스인이 사용되는 두 가지 주요 상황이 있습니다.한 클래스에 대해 많은 선택 기능을 제공할 때
# 많은 다른 클래스에서 하나의 특정 기능을 사용하려고합니다. class HasMethod1(object): def method(self): return 1 class HasMethod2(object): def method(self): return 2 class UsesMethod10(object): def usesMethod(self): return self.method() + 10 class UsesMethod20(object): def usesMethod(self): return self.method() + 20 class C1_10(HasMethod1, UsesMethod10): pass class C1_20(HasMethod1, UsesMethod20): pass class C2_10(HasMethod2, UsesMethod10): pass class C2_20(HasMethod2, UsesMethod20): pass print(C1_10().usesMethod() == 11) # UsesMethod10클래스의 usesMethod()가 호출되고 HasMethod1클래스의 method()가 호출되어 1 + 10이 됨 print(C1_20().usesMethod() == 21) # UsesMethod20클래스의 usesMethod()가 호출되고 HasMethod1클래스의 method()가 호출되어 1 + 20이 됨 print(C2_10().usesMethod() == 12) # UsesMethod10클래스의 usesMethod()가 호출되고 HasMethod1클래스의 method()가 호출되어 2 + 10이 됨 print(C2_20().usesMethod() == 22) # UsesMethod20클래스의 usesMethod()가 호출되고 HasMethod2클래스의 method()가 호출되어 2 + 20이 됨 # 결과 # True # True # True # True
많은 다른 클래스에서 하나의 특정 기능을 사용하려고 할 때
# 한 클래스에 대해 많은 선택 기능을 제공하려고합니다. class HasMethod1(object): def hasmethod1(self): return 1 class HasMethod2(object): def hasmethod2(self): return 2 class UsesMethod10(object): def usesmethod10(self): return self.method() + 10 class UsesMethod20(object): def usesmethod20(self): return self.method() + 20 class C1(HasMethod1, HasMethod2, UsesMethod10, UsesMethod20): def method(self): return 10 print(C1().hasmethod1(), C1().hasmethod2(), C1().usesmethod10()) # 결과 # 1 2 20
ruby의 경우
module M1 def m1_m p "m1_m" end end module M2 def m2_m p "m2_m" end end class C include M1, M2 end c = C.new() c.m1_m() c.m2_m()
class C 의 include 부분이 믹스인.
먼저 모듈 M1과 M2가 선언되며, 클래스 C가 이 모듈들을 include한 다음, C 클래스의 인스턴스를 만들어 주고 include한 두 모듈들의 메소드를 호출하면 에러없이 정상적으로 동작하게 됨
따라서, C가 M1과 M2의 메소드를 포함하고 있는 것처럼 행동하는 것이 믹스인의 핵심
파이썬에서는 타입과 상관없이 객체의 현재 상태를 간단하게 조사할 수 있어서 믹스인을 쉽게 작성할 수 있습니다. 동적 조사를 이용하면 많은 클래스에 적용할 수 있는 범용 기능을 믹스인에 한 번만 작성하면 됩니다. 믹스인들을 조합하고 계층으로 구성하면 반복 코드를 최소화하고 재사용성을 극대화할 수 있습니다.
예를 들어, 파이썬 객체를 메모리 내부 표현에서 직렬화(serialization)용 딕셔너리로 변환하는 기능이 필요하다고 가정합니다. 이 기능을 모든 클래스에서 사용할 수 있게 범용으로 작성하도록 만들어 보겠습니다.
다음은 상속받는 모든 클래스에 추가될 새 공개 메서드로 이 기능을 구현하는 믹스인입니다.
class ToDictMixin: def to_dict(self): # __dict__은 상속받은 값들에 대해 dict타입으로 보여줌 print('Todict', self.__dict__) return self._traverse_dict(self.__dict__) def _traverse_dict(self, instance_dict): output = {} for key, value in instance_dict.items(): output[key] = self._traverse(key, value) return output def _traverse(self, key, value): """ 세부 구현은 직관적이며 hasattr을 사용한 동적 속성 접근, isinstance를 사용한 동적 타입 검사, 인스턴스 딕셔너리 __dict__를 이용한 클래스 """ 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))) print(tree.to_dict()) # 결과 # {'value': 10, 'right': {'value': 13, 'right': None, 'left': {'value': 11, 'right': None, 'left': None}}, 'left': {'value': 7, 'right': {'value': 9, 'right': None, 'left': None}, 'left': None}}
이와 같이 수많은 파이썬 객체를 딕셔너리로 손쉽게 변환할 수 있습니다.
믹스인의 가장 큰 장점은 범용 기능을 교체할 수 있게 만들어서 필요할 때 동작을 오버라이드할 수 있다는 점입니다. 예를 들어 다음은 부모 노드에 대한 참조를 저장하는 BinaryTree의 서브클래스입니다. 이 순환 참조는 ToDictMixin.to_dict의 기본 구현이 무한 루프에 빠지게 만듭니다. ToDictMixin 클래스 내의 _traverse()함수에서 if isinstance(value, ToDictMixin): 이 부분에서 parent는 ToDictMixin 클래스의 자식클래스이기 때문에 to_dict()함수를 재 호출하고 다시 _traverse()함수의 조건에 걸리게 됩니다.
class BinaryTreeWithParent(BinaryTree): def __init__(self, value, left=None, right=None, parent=None): super().__init__(value, left=left, right=right) self.parent = parent
해결책은 BinaryTreeWithParent 클래스에서 ToDictMixin._traverse 메서드를 오버라이드해서 믹스인이 순환에 빠지지 않도록 필요한 값만 처리하게 하는 것입니다. 다음은 _traverse메서드를 오바라이드해서 부모를 탐색하지 않고 부모의 숫자 값만 꺼내오게 만든 예제입니다.
class BinaryTreeWithParent(BinaryTree): def __init__(self, value, left=None, right=None, parent=None): super().__init__(value, left=left, right=right) self.parent = parent def _traverse(self, key, value): if (isinstance(value, BinaryTreeWithParent) and key == 'parent'): return value.value # 순환방지 else: return super()._traverse(key. value)
순환 참조 속성을 따라가지 않으므로 BinaryTreeWithParent.to_dict를 호출하는 코드는 문제 없이 동작합니다.
class ToDictMixin: def to_dict(self): # __dict__은 상속받은 값들에 대해 dict타입으로 보여줌 return self._traverse_dict(self.__dict__) def _traverse_dict(self, instance_dict): output = {} for key, value in instance_dict.items(): output[key] = self._traverse(key, value) return output def _traverse(self, key, value): """ 세부 구현은 직관적이며 hasattr을 사용한 동적 속성 접근, isinstance를 사용한 동적 타입 검사, 인스턴스 딕셔너리 __dict__를 이용한 클래스 """ 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 class BinaryTreeWithParent(BinaryTree): def __init__(self, value, left=None, right=None, parent=None): super().__init__(value, left=left, right=right) self.parent = parent 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()) # 결과 # {'parent': None, 'left': {'parent': 10, 'left': None, 'right': {'parent': 7, 'left': None, 'right': None, 'value': 9}, 'value': 7}, 'right': None, 'value': 10}
BinaryTreeWithParent._traverse를 정의한 덕분에 BinaryTreeWithParent 타입의 속성이 있는 클래스라면 무엇이든 자동으로 ToDictMixin을 동작할 수 있게 되었습니다.
class ToDictMixin: def to_dict(self): # __dict__은 상속받은 값들에 대해 dict타입으로 보여줌 return self._traverse_dict(self.__dict__) def _traverse_dict(self, instance_dict): output = {} for key, value in instance_dict.items(): output[key] = self._traverse(key, value) return output def _traverse(self, key, value): """ 세부 구현은 직관적이며 hasattr을 사용한 동적 속성 접근, isinstance를 사용한 동적 타입 검사, 인스턴스 딕셔너리 __dict__를 이용한 클래스 """ 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 class BinaryTreeWithParent(BinaryTree): def __init__(self, value, left=None, right=None, parent=None): super().__init__(value, left=left, right=right) self.parent = parent def _traverse(self, key, value): if (isinstance(value, BinaryTreeWithParent) and key == 'parent'): return value.value # 순환방지 else: return super()._traverse(key, value) class NamedSubTree(ToDictMixin): def __init__(self, name, tree_with_parent): self.name = name self.tree_with_parent = tree_with_parent root = BinaryTreeWithParent(10) root.left = BinaryTreeWithParent(7, parent=root) root.left.right = BinaryTreeWithParent(9, parent=root.left) # print(root.to_dict()) my_tree = NamedSubTree('foobar', root.left.right) print(my_tree.to_dict()) # 무한 루프를 돌지 않음 # 결과 # {'name': 'foobar', 'tree_with_parent': {'parent': 7, 'value': 9, 'left': None, 'right': None}}
믹스인을 조합할 수도 있습니다. 예를 들어 어떤 클래스에도 동작하는 범용 JSON 직렬화를 제공하는 믹스인이 필요하다고 가정합니다. 이 믹스인은 클래스에 to_dict 메서드(ToDictMixin 클래스에서 제공할 수도 있고 그렇지 않을 수도 있음)가 있다고 가정하고 만들면 됩니다.
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으로 직렬화하고 JSON에서 역직렬화하는 유틸리티 클래스의 계층 구조를 간단하게 생성할 수 있습니다. 예를 들어 다음은 데이터센터 토폴로지를 구성하는 부분들을 표현하는 데이터 클래스의 계층입니다.
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): pass class Machine(ToDictMixin, JsonMixin): pass
이 클래스들을 JSON으로 직렬화하고 JSON에서 역직렬화하는 방법은 간단합니다. 여기서는 데이터가 직렬화와 역직렬화를 통해 원래 상태가 되는지 검증합니다.
serialized = """{"switch": {"ports": 5, "speed": 1e9}, "machines": [{"cores": 8, "ram": 32e9, "disk": 5e12}, {"cores": 4, "ram": 16e9, "disk": 1e12}, {"cores": 2, "ram": 4e9, "disk": 500e9}]}""" deserialized = DatacenterRack.from_json(serialized) # deserialized의 리턴은 인스턴스속성이므로 self값에 DatacenterRack()의 딕셔너리 값이 포함되어 있음 # 호출 시 JsonMixin이 호출되고 cls(**kwargs)로 json이 DatacenterRack()의 생성자인 __init__을 호출하게 됨. 다음 json의 key에 맞게 switch 키인 값이 switch 변수에, machines 키의 값이 machines 변수에 담김 roundtrip = deserialized.to_json() assert json.loads(serialized) == json.load(roundtrip)
이런 믹스인을 사용할 때는 클래스가 객체 상속 계층의 상위에서 이미 JsonMixin을 상속받고 있어도 괜찮습니다. 결과로 만들어지는 클래스는 같은 방식으로 동작할 것입니다.
요약
믹스인 클래스로 같은 결과를 얻을 수 있다면 다중 상속 사용 금지
인스턴스 수준에서 동작을 교체할 수 있게 만들어서 믹스인 클래스가 요구할 때 클래스 별로 원하는 동작을 하게 만드는 것이 좋음
간단한 동작들로 복잡한 기능을 생성하려면 믹스인을 조합