내장 자료형 상속
파이썬 내장 자료형을 상속할 때, 종종 특정 메소드를 오버라이드했음에도 불구하고 예상대로 동작하지 않는 경우가 발생할 수 있다. 이는 파이썬이 내부적으로 C로 구현된 메소드들을 직접 호출하기 때문이다. 여기서는 이 문제의 원인과 이를 해결하는 방법에 대해 설명하겠다.
원인
파이썬의 내장 자료형(예: list, dict, str 등)은 대부분 C 언어로 구현되어 있으며, 이 때문에 성능이 매우 뛰어나다. 하지만 이로 인해 특정 메소드들이 내부적으로 직접 C 함수 호출을 통해 구현되어 있다. 따라서, 이러한 메소드를 오버라이드해도 파이썬의 특정 연산이 직접 C 함수를 호출하면, 오버라이드된 메소드가 무시될 수 있다.
예를 들어, dict를 상속하여 setitem메소드를 오버라이드했을 때, update 같은 연산이 내부적으로 C 함수로 구현되어 있다면, 이 연산들은 우리가 오버라이드한 setitem 메소드를 호출하지 않는다.
class MyDict(dict):
def __setitem__(self, key, value):
print(f"Setting item {key} to {value}")
super().__setitem__(key, value)
my_dict = MyDict()
my_dict['a'] = 1 # "Setting item a to 1" 출력
print(my_dict) # {'a': 1}
# 업데이트 시 __setitem__이 호출되지 않음
my_dict.update({'b': 2, 'c': 3}) # 출력 없음
print(my_dict) # {'a': 1, 'b': 2, 'c': 3}
위의 예제에서 my_dict['a'] = 1를 호출하면 setitem 메소드를 오버라이드한 내용이 실행된다. 하지만 my_dict.update 를 호출하면, 내부적으로 C 함수가 호출되기 때문에 오버라이드한 setitem 메소드가 무시된다.
해결 방법
이 문제를 해결하기 위해서는, 내장 자료형을 상속하는 대신 collections 모듈의 UserList, UserDict, UserString 등을 사용하는 것이 좋다. 이 클래스들은 파이썬으로 구현되어 있어 모든 메소드를 쉽게 오버라이드할 수 있다.
from collections import UserDict
class MyUserDict(UserDict):
def __setitem__(self, key, value):
print(f"Setting item {key} to {value}")
super().__setitem__(key, value)
my_user_dict = MyUserDict()
my_user_dict['a'] = 1 # "Setting item a to 1" 출력
print(my_user_dict) # {'a': 1}
# 업데이트 시 __setitem__이 호출됨
my_user_dict.update({'b': 2, 'c': 3}) # "Setting item b to 2", "Setting item c to 3" 출력
print(my_user_dict) # {'a': 1, 'b': 2, 'c': 3}
위의 코드에서는 UserList를 사용함으로써 update 연산에서도 우리가 오버라이드한 setitem 메소드가 호출된다.
파이썬 내장 자료형을 상속할 때 오버라이드한 메소드가 무시되는 이유는 파이썬이 내부적으로 C로 구현된 메소드를 직접 호출하기 때문이다. 이러한 문제를 피하기 위해 collections 모듈의 UserList, UserDict, UserString 등을 사용하는 것이 좋다. 이 클래스를 사용하면 모든 메소드를 일관되게 오버라이드할 수 있다.
다중상속 (Multiple inheritance)
파이썬 다중 상속은 하나의 클래스가 둘 이상의 부모 클래스를 상속받는 것을 의미한다. 다중 상속을 통해 다양한 기능을 조합할 수 있지만, 다이아몬드 문제와 같은 복잡성을 동반할 수 있다. 다중 상속의 개념과 함께 이를 효과적으로 다루는 방법을 설명하겠다.
다중 상속의 기본 개념
다중 상속을 사용하면 자식 클래스가 여러 부모 클래스의 속성과 메소드를 상속받을 수 있다. 이는 재사용 가능한 코드를 작성하고, 다양한 기능을 하나의 클래스에 결합하는 데 유용하다.
class Parent1:
def method1(self):
print("Method from Parent1")
class Parent2:
def method2(self):
print("Method from Parent2")
class Child(Parent1, Parent2):
pass
child = Child()
child.method1() # Parent1의 method1 호출
child.method2() # Parent2의 method2 호출
위 예제에서 Child 클래스는 Parent1과 Parent2를 동시에 상속받아 두 부모 클래스의 메소드를 사용할 수 있다.
다이아몬드 문제(Diamond Problem)
다이아몬드 문제(Diamond Problem)는 다중 상속을 사용할 때 발생할 수 있는 문제로, 두 개 이상의 부모 클래스가 같은 조상을 가질 때 생긴다. 이를 "다이아몬드 문제"라고 부르는 이유는 이 클래스 관계를 다이어그램으로 나타냈을 때 모양이 다이아몬드와 같기 때문이다.
다이아몬드 문제 설명
다이아몬드 문제는 다음과 같은 상황을 가정한다:
- 클래스 A가 있다.
- 클래스 B와 C가 각각 A를 상속받는다.
- 클래스 D가 B와 C를 동시에 상속받는다.
이렇게 되면 D가 A로부터 상속받은 속성과 메소드가 여러 경로를 통해 중복되게 된다.
다이아몬드 문제의 기본 구조는 다음과 같다:
A
/ \\
B C
\\ /
D
문제의 발생
이 구조에서 D가 A의 메소드나 속성을 호출할 때, 파이썬은 어떤 경로로 호출할지를 결정해야 한다. 이는 메소드 결정 순서(Method Resolution Order, MRO)와 관련이 있습니다.
class A:
def method(self):
print("Method from A")
class B(A):
def method(self):
print("Method from B")
class C(A):
def method(self):
print("Method from C")
class D(B, C):
pass
d = D()
d.method()
위 코드에서 D 클래스는 B와 C를 상속받고 있다. d.method()를 호출하면 어떤 메소드가 호출될까?
파이썬의 해결 방법: MRO
파이썬은 다이아몬드 문제를 해결하기 위해 MRO를 사용한다. MRO는 클래스 상속 구조를 순회하는 순서를 정의한다. 파이썬 2.3 이후로 C3 선형화(C3 Linearization)라는 알고리즘을 사용하여 MRO를 결정한다.
위 예제에서 D 클래스의 MRO는 다음과 같다:
print(D.mro())
# [<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>]
이 MRO에 따라 d.method()를 호출하면 B의 method가 호출된다. 이는 D -> B -> C -> A 순서로 클래스가 검색되기 때문이다.
- 다이아몬드 문제는 다중 상속에서 동일한 조상을 갖는 두 부모 클래스를 상속할 때 발생한다.
- 파이썬은 MRO를 사용하여 어떤 메소드를 호출할지 결정한다.
- C3 선형화 알고리즘을 통해 MRO를 계산하고, 이 순서에 따라 메소드를 검색한다.
이러한 방식으로 파이썬은 다이아몬드 문제를 해결하고, 다중 상속이 발생하는 상황에서도 일관된 메소드 호출 순서를 유지한다.
바인딩되지 않은 메소드(Unbound Method)
파이썬에서 클래스의 메소드는 인스턴스와 관련된 바인딩(binding) 상태에 따라 바인딩된 메소드(bound method)와 바인딩되지 않은 메소드(unbound method)로 구분할 수 있다.
- 바인딩된 메소드(Bound Method): 특정 인스턴스에 바인딩된 메소드이다. 호출할 때 인스턴스를 첫 번째 인자로 자동으로 전달한다.
- 바인딩되지 않은 메소드(Unbound Method): 특정 인스턴스와 연결되지 않은 클래스 자체의 메소드이다. 인스턴스를 명시적으로 첫 번째 인자로 전달해야 한다.
class MyClass:
def my_method(self):
print("This is a method")
# 인스턴스 생성
instance = MyClass()
# 바인딩된 메소드 호출
instance.my_method() # "This is a method" 출력
# 바인딩되지 않은 메소드 접근
unbound_method = MyClass.my_method
unbound_method(instance) # "This is a method" 출력
self와 super
self
- self는 인스턴스 메소드 내에서 인스턴스를 참조하기 위해 사용되는 첫 번째 인자이다.
- 클래스 내의 메소드에서 self를 통해 인스턴스 속성과 다른 메소드에 접근할 수 있다.
class MyClass:
def __init__(self, value):
self.value = value
def show_value(self):
print(self.value)
instance = MyClass(10)
instance.show_value() # "10" 출력
super
- super는 부모 클래스를 참조할 때 사용된다. 주로 상속받은 메소드를 호출할 때 사용된다.
- super를 사용하면 다이아몬드 문제나 다중 상속 구조에서 메소드 호출 순서를 관리하는데 유용하다.
기본 사용법
class BaseClass:
def show_message(self):
print("Message from BaseClass")
class SubClass(BaseClass):
def show_message(self):
super().show_message()
print("Message from SubClass")
instance = SubClass()
instance.show_message()
# Message from BaseClass
# Message from SubClass
super와 다중 상속
super는 다중 상속에서도 유용하다. 파이썬은 MRO에 따라 super가 올바른 부모 클래스를 참조하도록 한다.
class A:
def method(self):
print("Method from A")
class B(A):
def method(self):
print("Method from B")
super().method()
class C(A):
def method(self):
print("Method from C")
super().method()
class D(B, C):
def method(self):
print("Method from D")
super().method()
d = D()
d.method()
'''
Method from D
Method from B
Method from C
Method from A
'''
위 예제에서 D 클래스의 method를 호출하면, super()는 MRO에 따라 B, C, A 순으로 부모 클래스의 메소드를 호출한다.
- 바인딩되지 않은 메소드는 클래스 메소드로, 특정 인스턴스에 바인딩되지 않으며 호출할 때 인스턴스를 명시적으로 전달해야 한다.
- self는 인스턴스 메소드 내에서 현재 인스턴스를 참조하기 위해 사용되는 매개변수이다.
- super는 부모 클래스의 메소드나 속성에 접근하기 위해 사용되며, 특히 다중 상속에서 올바른 메소드 호출 순서를 보장하는 데 유용하다.
다중 상속 다루기
다중 상속을 효과적으로 다루기 위한 다양한 전략들이 있다. 이를 통해 코드의 가독성과 유지보수성을 높이고, 다이아몬드 문제와 같은 다중 상속의 복잡성을 최소화할 수 있다. 각 전략을 자세히 설명하겠다.
1. 인터페이스 상속과 구현 상속을 구분한다.
- 인터페이스 상속: 클래스가 특정 메소드나 속성을 구현해야 한다는 계약을 명시한다. 인터페이스는 주로 추상 클래스(ABC, Abstract Base Class)를 통해 구현된다.
- 구현 상속: 클래스가 부모 클래스의 실제 구현을 상속받는다. 이를 통해 코드를 재사용할 수 있다.
from abc import ABC, abstractmethod
# 인터페이스 상속
class MyInterface(ABC):
@abstractmethod
def do_something(self):
pass
# 구현 상속
class MyBaseClass:
def do_something(self):
print("Doing something in MyBaseClass")
class MyClass(MyBaseClass, MyInterface):
pass
# MyClass는 MyBaseClass의 구현을 상속받고, MyInterface의 계약을 준수한다.
2. ABC를 이용해서 인터페이스를 명확히 한다.
- ABC (Abstract Base Class)를 사용하여 인터페이스를 정의한다. 이는 명확하게 어떤 메소드가 하위 클래스에서 구현되어야 하는지 정의할 수 있다.
from abc import ABC, abstractmethod
class Animal(ABC):
@abstractmethod
def speak(self):
pass
class Dog(Animal):
def speak(self):
return "Woof!"
# Dog 클래스는 Animal 인터페이스를 준수한다.
3. 코드를 재사용하기 위해 믹스인을 사용한다.
- 믹스인(Mixin): 작은 단위의 재사용 가능한 코드를 제공하는 클래스이다. 다른 클래스에 기능을 추가할 때 사용된다. 믹스인은 단일 책임 원칙을 지키며, 독립적으로 사용할 수 있는 기능을 제공한다.
class LoggingMixin:
def log(self, message):
print(f"Log: {message}")
class Application(LoggingMixin):
def run(self):
self.log("Application is running")
app = Application()
app.run() # "Log: Application is running" 출력
4. 이름을 통해 믹스인임을 명확히 한다.
- 믹스인 클래스는 Mixin 접미사를 붙여 이름에서 믹스인임을 명확히 한다. 이는 다른 개발자들이 이 클래스가 단일 책임을 지니고, 보조적인 역할을 한다는 것을 쉽게 이해할 수 있도록 돕는다.
class LoggingMixin:
def log(self, message):
print(f"Log: {message}")
class Application(LoggingMixin):
pass
5. ABC가 믹스인이 될 수 있지만, 믹스인이라고 해서 ABC인 것은 아니다.
- ABC는 추상 메소드를 포함하여 하위 클래스에서 구현해야 할 메소드의 인터페이스를 정의할 수 있다. 믹스인은 구체적인 구현을 제공하여 다른 클래스에 기능을 추가한다.
- 따라서 ABC는 믹스인이 될 수 있지만, 모든 믹스인이 ABC인 것은 아니다.
from abc import ABC, abstractmethod
class LoggingMixin:
def log(self, message):
print(f"Log: {message}")
class AbstractWorker(ABC):
@abstractmethod
def work(self):
pass
class Worker(LoggingMixin, AbstractWorker):
def work(self):
self.log("Working")
worker = Worker()
worker.work() # "Log: Working" 출력
6. 두개 이상의 구상 클래스에서 상속받지 않는다.
- 구상 클래스는 구체적인 구현을 가진 클래스이다. 두 개 이상의 구상 클래스를 상속받으면, 다이아몬드 문제와 같은 복잡한 상속 구조가 발생할 수 있다.
- 대신 추상 클래스를 상속받거나 믹스인을 사용하여 기능을 추가한다.
7. 사용자에게 집합 클래스를 제공한다.
- 여러 클래스를 조합하여 사용자가 사용할 수 있는 집합 클래스를 제공한다. 이는 여러 기능을 단일 클래스에 통합하여 제공하는 방식이다.
class FeatureA:
def feature_a(self):
print("Feature A")
class FeatureB:
def feature_b(self):
print("Feature B")
class CombinedClass(FeatureA, FeatureB):
pass
combined = CombinedClass()
combined.feature_a() # "Feature A" 출력
combined.feature_b() # "Feature B" 출력
8. 클래스 상속보다 객체 구성을 사용한다.
- 객체 구성은 클래스가 다른 클래스의 인스턴스를 포함하여 사용하는 방식이다. 이는 상속보다 유연하게 기능을 조합할 수 있게 한다.
class Engine:
def start(self):
print("Engine started")
class Car:
def __init__(self, engine):
self.engine = engine
def start(self):
self.engine.start()
print("Car started")
engine = Engine()
car = Car(engine)
car.start()
'''
Engine started
Car started
'''
- 인터페이스 상속과 구현 상속을 구분하여, 계약과 구현을 명확히 한다.
- ABC를 사용하여 인터페이스를 정의한다.
- 믹스인을 통해 코드를 재사용하고, 단일 책임 원칙을 지킨다.
- 믹스인 클래스 이름에 Mixin 접미사를 붙여 명확히 구분한다.
- ABC가 믹스인이 될 수 있지만, 모든 믹스인이 ABC인 것은 아니다.
- 두 개 이상의 구상 클래스를 상속받지 않는다.
- 사용자에게 집합 클래스를 제공하여 여러 기능을 통합한다.
- 객체 구성을 사용하여 유연하게 기능을 조합한다.
이러한 전략들을 통해 다중 상속의 복잡성을 줄이고, 가독성과 유지보수성을 높일 수 있다.
모든 코드는 github에 저장되어 있습니다.
'Python study' 카테고리의 다른 글
제너레이터 (Generator) (1) | 2024.05.29 |
---|---|
연산자 오버로딩 (0) | 2024.05.29 |
인터페이스 (0) | 2024.05.26 |
시퀀스 해킹, 해시, 슬라이스 (0) | 2024.05.22 |
파이썬스러운 객체(Pythonic object) (0) | 2024.05.20 |