파이썬은 객체 지향 패러다임을 구현할 수 있는 기능을 제공한다(파이썬에 OOP언어라는 것보단 이게 더 적절하다). 객체의 멤버에 접근할 때 "instance.member" 처럼 점(".")을 이용하는 전형적인 문법을 가지고 있고 이는 getattr함수에 의해 구현된다.
>>> help(getattr)
Help on built-in function getattr in module __builtin__:
getattr(...)
getattr(object, name[, default]) -> value
Get a named attribute from an object; getattr(x, 'y') is equivalent to x.y.
When a default argument is given, it is returned when the attribute doesn't
exist; without it, an exception is raised in that case.
함수처럼 구현되어 객체의 기능을 담당하는 요소 '메서드(method)' 역시 동일한 방식으로 호출할 수 있다. 이러한 멤버를 호출하는 것은 단순해보이지만 프로그램이 복잡할 경우 특별한 처리가 필요하다. 흔히 말하는 다중상속을 할 경우 종종 발생하는 '다이아몬드 문제'를 보자.
다이아몬드 문제 - 메서드의 이름 충돌
어떤 클래스 A에서 메서드 X를 구현했다고 하자. 그리고 클래스 B, C는 둘 다 A를 상속받으면서 서로 다른 메서드 Y를 각각 구현한다. 마지막으로 클래스 D는 B와 C를 동시에 상속받는다. 이를 도형으로 그렸을 때 다이아몬드 모형처럼 된다.
class A:
def X(self):
print('X of A')
class B(A):
def Y(self):
print('Y of B')
class C(A):
def Y(self):
print('Y of C')
class D(B, C):
pass
여기서 질문은 D의 객체가 X와 Y를 호출했을 때 파이썬 인터프리터는 '어떻게 해당 메서드를 찾아낼 것인가?'이다. 좀 더 다르게 표현하면 '어떤 대상을 객체가 호출하고자 하는 멤버라고 결정할 것인가?'이다.
메서드 X의 경우 매우 간단하다. D의 상속 관계를 타고 가다보면 어디선가 클래스 A에 유일하게 존재하는 X를 발견할 것이고, 이를 D의 X라고 결정하면 된다. 문제는 Y의 경우이다. B와 C에는 서로 다른 메서드 Y가 구현되어 있기에 이름 충돌이 발생하고 어느 Y를 D의 Y라고 할 것인가에 대한 문제가 발생한다.
class D(B, C):
def Z(self):
self.X() # A의 X가 호출될 것이다.
self.Y() # B와 C 어느 것의 Y가 호출될 것인가?
여기서 MRO(Method Resolution Order)의 개념이 등장한다. 파이썬의 모든 클래스는 메서드 결정 순서 - mro를 가지도록 구현되어 있다. 이 mro는 __mro__ 속성으로 확인할 수 있다.
>>> object.__mro__
(<class 'object'>,)
>>> A.__mro__
(<class '__main__.A'>, <class 'object'>)
mro는 튜플로 구성되기에 sequential하고 순서가 존재한다. 따라서 mro에 담긴 순서가 곧 메서드 결정의 우선 순위가 된다. 앞선 다이아몬드 문제에 대해 mro를 살펴보자.
>>> D.__mro__
(<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)
>>> d = D()
>>> d.Z()
X of A
Y of B
코드 결과에서 보이듯 D에서 Y를 호출하면 mro에서 우선되는 B의 메서드 Y가 호출되었다. 상속 순서를 바꿔보면 어떨까?
>>> class D(C, B): # (B, C)에서 (C, B)로 바뀌었다.
... def Z(self):
... self.X() # A의 X가 호출될 것이다.
... self.Y() # B와 C 어느 것의 Y가 호출될 것인가?
...
>>> D.__mro__
(<class '__main__.D'>, <class '__main__.C'>, <class '__main__.B'>, <class '__main__.A'>, <class 'object'>)
>>> d = D()
>>> d.Z()
X of A
Y of C
예상대로 C의 Y가 호출되었다. 여기서 중요한건 '먼저 상속받은 클래스가 우선된다'는 것이 아니라 'mro대로 결정된다'는 것이다. 먼저 상속 순서에 의해 mro가 바뀌는 건 세부 알고리즘에 따라 다를 수 있다. 세부 알고리즘이 어떠하든 mro에 의해 이름 충돌이 해결된다는 것이 핵심이다!
여담1
파이썬에서 mro의 순서는 "C3" 알고리즘으로 구현되어 있다. CPython의 Lib/functools.py 파일을 확인하면 _c3_mro 등의 함수를 확인할 수 있다.
여담2
D에서 정의된 Z 메서드에서 self.Y()가 아닌 super().Y()를 호출해보자. 둘은 같은 결과를 보일 것이다. 이는 즉 super() 역시 mro를 따른다는 것이다.
여담3
mro를 따르지 않는 경우가 있는데, list, tuple, dict, set 등의 built-in class등이다. 이는 CPython에서 속도 향상을 위해 내장 자료형을 최적화했기 때문이며, PyPy의 경우 해당 자료형들도 mro대로 작동한다.
MRO의 또 다른 쓰임새
mro는 단순히 메서드를 결정하기 위해서만 쓰이지는 않는다. mro는 특정 클래스의 상속 구조를 튜플에 담은 것이기에 객체의 정체성에 관한 정보를 다루기 용이하다. 그래서 파이썬 내부 기능 곳곳에는 mro가 사용되고 있다. CPython의 라이브러리 코드에서 mro가 사용되는 코드를 확인해보자.
OOP에서 중요한 추상 클래스의 기능과 관련하여, ABCMeta클래스가 정의된 Lib/_py_abc.py 코드의 일부분을 확인해보자.
class ABCMeta(type):
...
def __subclasscheck__(cls, subclass):
"""Override for issubclass(subclass, cls)."""
if not isinstance(subclass, type):
raise TypeError('issubclass() arg 1 must be a class')
...
# Check the subclass hook
ok = cls.__subclasshook__(subclass)
if ok is not NotImplemented:
assert isinstance(ok, bool)
if ok:
cls._abc_cache.add(subclass)
else:
cls._abc_negative_cache.add(subclass)
return ok
# Check if it's a direct subclass
if cls in getattr(subclass, '__mro__', ()):
cls._abc_cache.add(subclass)
return True
...
return False
주석에 쓰여있듯 issubclass 함수를 구현한 코드이다. 입력 받은 subclass의 mro에 cls가 있을 경우 subclass가 맞다고 반환한다. 당연히 cls를 subclass가 상속받았다면 mro에 cls가 있을 것이다!
여담4
위의 subclasshook 메서드 첫 부분을 보면서 이런 의문이 들 수 있다. 클래스는 객체인가? 그렇다! 객체를 정의하는 클래스를 사용자가 다룰 때는 객체로 취급된다. 클래스는 'type' 클래스의 객체이다. 그러면 우리가 타입을 확인하기 위해 사용했던 type()은? 아래 코드를 보면 알겠지만 사실 함수가 아니라 Callable한 클래스였던 것이다! (필자는 이 글을 쓰면서 이 사실을 발견했다. 흥미롭군..)
>>> a = A()
>>> type(a)
<class '__main__.A'>
>>> type(A)
<class 'type'>
>>> type(type)
<class 'type'>
>>> issubclass(type, object)
True
>>> from collections import Callable
>>> issubclass(type, Callable)
True
mro를 통해 직접적으로 subclass인지 확인하는 코드 앞에 subclasshook이라는 것으로 상속 여부를 판단하는 코드가 있다. 여기서도 mro가 쓰인다. subclasshook이 구현된 파이썬 내부 자료형을 몇 가지 확인해보자. 이는 Lib/_collections_abc.py에서 찾아볼 수 있다.
from abc import ABCMeta, abstractmethod
...
GenericAlias = type(list[int])
...
def _check_methods(C, *methods):
mro = C.__mro__
for method in methods:
for B in mro:
if method in B.__dict__:
if B.__dict__[method] is None:
return NotImplemented
break
else:
return NotImplemented
return True
class Hashable(metaclass=ABCMeta):
__slots__ = ()
@abstractmethod
def __hash__(self):
return 0
@classmethod
def __subclasshook__(cls, C):
if cls is Hashable:
return _check_methods(C, "__hash__")
return NotImplemented
class Awaitable(metaclass=ABCMeta):
__slots__ = ()
@abstractmethod
def __await__(self):
yield
@classmethod
def __subclasshook__(cls, C):
if cls is Awaitable:
return _check_methods(C, "__await__")
return NotImplemented
__class_getitem__ = classmethod(GenericAlias)
...
많은 클래스가 구현되어 있으나 가장 먼저 구현된 두 클래스 Hashable과 Awaitable을 가져왔다. 두 클래스의 __subclasshook__ 메서드 구현체를 보자. _check_methods함수를 이용하고 있다.
간단하게 얘기하자면, Hashable.__subclasshook__(C)는 어떤 클래스 C가 __hash__ 메서드를 구현하기만 하면 True를 반환하고, 이는 issubclass의 반환값이 된다. 간단하게 말해서, 아무것도 상속받지 않아도 __hash__ 메서드를 구현하기만 하면 Hashable를 상속받은 서브클래스가 된다는 것이다. Awaitable 역시 __await__ 던더 메서드를 구현하기만 하면 Awaitable의 서브클래스 취급을 받는다.
class A:
def __hash__(self):
return 0
def __await__(self):
pass
>>> from collections import Hashable, Awaitable
>>> issubclass(A, Hashable)
True
>>> issubclass(A, Awaitable)
True
여담5
던더란 무엇일까? 나름 공식 용어로, CPython 구현 코드 내에서도 _is_dunder와 같은 함수가 존재한다. dunder는 double underscore(또는 under-bar)의 줄임말이다. 우리가 OOP에서 대상을 숨기기 위해 사용하기로 '약속'한 naming convention인 "__"를 지칭하는 단어이다.
여담6
mro는 외에도 코드 요소의 docs를 처리하는 Lib/inspect.py와 Lib/pydoc.py 등의 코드에서 유용하게 쓰이고 있다.
파이썬의 프로토콜에 대하여
여기서 등장하는 중요한 개념이 바로 '프로토콜'이다. 네트워크 분야에서 자주 들을 수 있는 단어인 프로토콜의 의미와 정확히 일치한다. 단순히 던더 메서드를 구현하는 것만으로 파이썬 built-in class의 서브클래스가 될 수 있으며, 따라서 자연스럽게 custom class임에도 파이썬의 built-in 기능 안에서 특별한 처리 없이, 일관성 있게 동작할 수 있다(__hash__ 메서드만 구현하면 set의 요소가 될 수 있는 건 파이썬 프로그래머들에게 당연한 것이지만 사실 생각보다 놀라운 것일 수 있다).
이러한 일관성의 개념은 사실 pythonic한 코드를 작성하는 것이나, 파이썬 철학이나, 파이썬의 생산성이 단순히 쉬운 문법이나 동적 타이핑에 있지 않다는 것 등을 이해하는 데 중요하다. 다만 관련해서는 필자 조차도 완벽하게 이해하고 있다 자부하기 어려우므로 서적을 하나 추천하겠다. 루시아누 하말류의 저서 "Fluent Python"에 관련 내용이 나와있다. 사실 필자도 해당 책에서 처음 mro를 알게 되었고, 본 글 역시 책의 내용을 바탕으로 좀 더 공부한 것에 지나지 않는다.
결론이자 요약
- 객체의 상속 구조는 mro에 나타나며, 메서드 결정은 mro를 따른다.
- mro는 파이썬 기능 여러 곳에서 쓰이고 있다.
- 프로토콜을 구현하면 상속받지 않고도 서브클래스가 될 수 있다.
'개발 > 파이썬' 카테고리의 다른 글
파이썬의 루프를 더 빠르게 하는 법? (0) | 2022.11.15 |
---|---|
모션캡쳐 파이썬 구현 (0) | 2022.07.31 |
랜덤이 포함된 로직은 단순히 기능만 테스트해서는 안된다. (0) | 2022.07.10 |
Python 네임스페이스에 대한 이해 (0) | 2022.07.09 |
$D^{-1/2}$는 어떤 방법으로 구해야 할까? np.ndarray와 np.matrix의 차이 (0) | 2022.07.09 |