객체지향 프로그래밍(OOP)에서 다중 상속(Multiple Inheritance)은 하나의 클래스가 여러 부모 클래스로부터 기능을 상속받는 기능이다.
다중 상속은 매우 강력한 기능이지만, 메서드 호출 순서를 잘 이해하지 못하면 예상과 다른 결과가 발생한다. 이러한 문제를 해결하기 위해 파이썬은 MRO(Method Resolution Order)라는 규칙을 사용한다.
이번 글에서는 다중 상속에서 발생하는 다이아몬드 문제(Diamond Problem)와 이를 해결하는 MRO를 초보자도 이해할 수 있도록 살펴본다.
class Root: # 가장 최상위 부모 클래스
def ping(self):
print(f'{self}.ping() in Root')
print("-" * 30)
def pong(self):
print(f'{self}.pong() in Root')
print("-" * 30)
def __repr__(self):
cls_name = type(self).__name__
return f'<instance of {cls_name}>' # 객체를 출력(print)할 때 보기 좋게 표현하는 함수
class A(Root): # Root를 상속받는 첫 번째 자식 클래스
def ping(self):
print(f'{self}.ping() in A')
print("-" * 30)
super().ping()
def pong(self):
print(f'{self}.pong() in A')
print("-" * 30)
super().pong()
class B(Root):
def ping(self):
print(f'{self}.ping() in B')
print("-" * 30)
super().ping()
def pong(self):
print(f'{self}.pong in B')
print("-" * 30)
class Leaf(A, B):
def ping(self):
print(f'{self}.ping() in Leaf')
print("-" * 30)
super().ping()
leaf1 = Leaf()
leaf1.ping()
<instance of Leaf>.ping() in Leaf
------------------------------
<instance of Leaf>.ping() in A
------------------------------
<instance of Leaf>.ping() in B
------------------------------
<instance of Leaf>.ping() in Root
------------------------------
일반적인 단일 상속은 부모가 하나뿐이다.
class Parent:
pass
class Child(Parent):
pass
반면 다중 상속은 부모가 둘 이상이다.
class Parent1:
pass
class Parent2:
pass
class Child(Parent1, Parent2):
pass
이처럼 하나의 클래스가 여러 부모의 기능을 동시에 사용할 수 있다.
다중 상속에서 가장 유명한 문제가 다이아몬드 문제(Diamond Problem)이다.
상속 구조는 다음과 같다.
Root
/ \
A B
\ /
Leaf
Leaf는 A와 B를 모두 상속받고 있으며, A와 B는 모두 Root를 상속받는다.
이 구조를 그림으로 보면 다이아몬드 모양이 되기 때문에 Diamond Problem이라고 부른다.
대부분 처음에는 다음과 같이 생각한다.
Leaf.ping()
│
┌────┴────┐
▼ ▼
A.ping() B.ping()
│ │
▼ ▼
Root Root
즉,
하므로
Root의 메서드가 두 번 실행될 것 같다.
하지만 실제 실행 결과는 그렇지 않다.
class Root:
# 가장 최상위 부모 클래스
def ping(self):
print("ping() in Root")
class A(Root):
def ping(self):
print("ping() in A")
# 부모 클래스를 호출하는 것이 아니라
# MRO에서 다음 클래스를 호출한다.
super().ping()
class B(Root):
def ping(self):
print("ping() in B")
# 역시 MRO의 다음 클래스를 호출한다.
super().ping()
class Leaf(A, B):
def ping(self):
print("ping() in Leaf")
# MRO에 따라 A의 ping()이 호출된다.
super().ping()
leaf = Leaf()
leaf.ping()
ping() in Leaf
ping() in A
ping() in B
ping() in Root
놀랍게도
ping() in Root
는 한 번만 출력된다.
많은 사람들이 상속 구조를 트리(Tree)처럼 생각한다.
Leaf
├── A
│ └── Root
└── B
└── Root
이렇게 생각하면 Root가 두 번 실행되는 것이 맞다.
하지만 파이썬은 이렇게 동작하지 않는다.
파이썬은 메서드를 호출하기 전에 MRO(Method Resolution Order)를 계산한다.
MRO(Method Resolution Order)는
파이썬이 메서드를 어떤 순서로 탐색하고 호출할지를 미리 계산한 목록
이다.
다음 코드를 실행해 보자.
print(Leaf.__mro__)
또는
print(Leaf.mro())
실행 결과는
(
Leaf,
A,
B,
Root,
object
)
이다.
즉,
파이썬은 이미 다음과 같은 호출 순서를 만들어 놓는다.
Leaf
↓
A
↓
B
↓
Root
↓
object
여기에는 Root가 한 번만 존재한다.
많은 책에서는
super() = 부모 클래스 호출
이라고 설명한다.
하지만 이는 단일 상속에서는 맞는 설명이지만,
다중 상속에서는 정확하지 않다.
보다 정확한 설명은
super()는 MRO에서 다음 차례의 클래스를 호출한다.
이다.
예를 들어
Leaf에서
super().ping()
을 호출하면
Leaf
↓
A
가 된다.
A에서
super().ping()
을 호출하면
A
↓
B
가 된다.
여기서 많은 초보자가 놀란다.
"A의 부모는 Root인데 왜 B가 호출되지?"
그 이유는 super()는 부모가 아니라 MRO의 다음 클래스를 호출하기 때문이다.
마지막으로
B에서
super().ping()
을 호출하면
B
↓
Root
가 된다.
결국 호출 순서는
Leaf
↓
A
↓
B
↓
Root
가 된다.
leaf.ping()
│
▼
Leaf.ping()
│
▼
super()
│
▼
A.ping()
│
▼
super()
│
▼
B.ping()
│
▼
super()
│
▼
Root.ping()
한 번도 분기되지 않고,
하나의 직선으로 이동한다.
만약 MRO가 없다면
Leaf
├── A
│ └── Root
└── B
└── Root
처럼 Root가 두 번 실행될 수도 있다.
이는
이를 다이아몬드 문제(Diamond Problem)라고 한다.
파이썬은 C3 Linearization 알고리즘을 이용하여
Leaf
↓
A
↓
B
↓
Root
라는 하나의 선형 순서를 만들고,
각 클래스가 단 한 번만 실행되도록 보장한다.
super()는 부모 클래스를 호출하는 것이 아니라 MRO에서 다음 클래스를 호출한다.처음에는 다중 상속을 보면 상속 구조가 트리처럼 보이기 때문에 메서드 호출도 분기될 것이라고 생각하기 쉽다.
하지만 실제로 파이썬은 상속 트리를 그대로 따라가는 것이 아니라, MRO라는 하나의 선형 호출 순서를 만든 뒤 그 순서를 따라 메서드를 실행한다.
이 원리만 이해하면 super()와 다중 상속이 더 이상 어렵게 느껴지지 않을 것이다.