[Python] overrides 라이브러리 (리스코브 치환 원칙)
Introduction
파이썬에서 클래스 계층 구조를 확장시키다 보면 부모 클래스의 메서드를 오버라이딩 (overriding) 해야 할 일이 분명히 있을 것이다. 오버라이딩이란 부모 클래스를 상속받는 자식 클래스에서, 부모 클래스에서 이미 정의된 메서드를 새로 정의하는 행위를 뜻한다. 아래의 코드를 참고하자.
class FooClass():
def __init__(self):
pass
def foo_function(self):
print("Foo function.")
return
class FooChildClass(FooClass):
def __init__(self):
pass
# Method overrided.
def foo_function(self):
super().foo_function()
print("Foo child function.")
return
>>> foo_class = FooClass()
>>> foo_child_class = FooChildClass()
>>> foo_class.foo_function()
Foo function.
>>> foo_child_class.foo_function()
Foo function.
Foo child function.
오버라이딩은 기존의 메서드 이름을 그대로 유지하고 싶으면서 자식 클래스에서 기능을 덧붙이고 싶을 때 유용하게 쓰이는 기능이다.
그런데 클래스 계층 구조가 복잡해질수록 오버라이딩과 관련해 미묘한 케이스들이 생겨날 수 있다. 예를 들면,
- 부모 클래스에서 새로 추가한 메서드가 이미 자식 클래스에 정의되어 있던 메서드에 의해 본의 아니게 오버라이딩 되는 상황.
- 부모 클래스의 메서드를 자식 클래스에서 오버라이딩 하고 있다가, 부모 클래스 메서드를 rename 하게 되었는데 미처 자식 클래스의 오버라이딩 메서드를 rename 하지 않은 상황.
- 부모 클래스의 메서드를 자식 클래스에서 오버라이딩 하고 있었지만 부모 클래스 메서드가 삭제되어 자식 클래스의 메서드만 남은 상황.
- 부모 클래스의 메서드가 자식 클래스에서 오버라이딩 되었는데 둘의 메서드 인터페이스가 다른 상황.
등이 있다. 물론 이런 상황들은 신경 써서 프로그래밍을 한다면 충분히 방지할 수 있는 상황들이지만, 파이썬 인터프리터 단계에서 오버라이딩과 관련해 별도의 체크를 해 주지 않기 때문에 더욱 실수할 여지가 있는 부분들이다.
즉, 오버라이드 되기를 기대하는 상황에서 실제로 오버라이딩이 일어나는지 혹은 오버라이딩을 전혀 기대하지 않은 메서드에서 오버라이드가 실수로 일어나는지를 체크할 수 있다면 위와 같은 상황들을 쉽게 방지할 수 있을 것이다.
overrides 라이브러리는 오버라이딩과 관련해 편리한 기능을 제공하는 데코레이터를 제공하는 라이브러리이다. 한번 사용법을 알아보자.
Overrides Library
Installation
$ pip install overrides
파이썬 3.6 이상부터 설치할 수 있다.
Decorator: @overrides
@overrides 데코레이터를 붙여 현재 메서드가 부모 클래스의 메서드를 반드시 오버라이드 하고 있어야 한다고 강제할 수 있다. 아래 코드를 참고하자.
from overrides import overrides
class SuperClass:
def __init__(self):
pass
def foo_function(self):
print("Foo function.")
return
class ChildClass(SuperClass):
def __init__(self):
pass
@overrides
def foo_function(self):
print("Foo child function.")
return
실행시켜보면 아무 문제 없이 돌아간다.
그런데 이 상황에서, 자식 클래스의 메서드 이름을 살짝 바꾸어 더이상 자식 클래스가 부모 클래스 메서드를 오버라이드 하지 않도록 상황을 조정해 보자.
from overrides import overrides
class SuperClass:
def __init__(self):
pass
def foo_function(self):
print("Foo function.")
return
class ChildClass(SuperClass):
def __init__(self):
pass
@overrides
def not_foo_function(self):
print("Foo child function.")
return
Traceback (most recent call last):
...
TypeError: ChildClass.not_foo_function: No super class method found
인스턴스를 만들지 않아도 클래스 정의 단계에서부터 TypeError가 레이즈 된다. @overrides 데코레이터가 붙어 있는 상태에서 아무런 부모 클래스 메서드를 오버라이딩 하고 있지 않다면 이와 같은 에러가 발생한다.
결과적으로는 앞서 언급한, 실수로 부모 클래스 메서드만 이름을 바꾼다던가 메서드를 삭제하는 등의 상황에서 자식 클래스 메서드만 독립된 상태로 덩그러니 남는 상황을 클래스 정의 단계에서 캐치해 낼 수 있다.
EnforceOverrides
EnforceOverrides 클래스는 자식 클래스 메소드들이 부모와 같은 이름의 메소드를 정의할 때 무조건 @overrides 데코레이터를 붙이도록 강제하는 베이스 클래스이다.
from overrides import EnforceOverrides
class SuperClass(EnforceOverrides):
def __init__(self):
pass
def foo_function(self):
print("Foo function.")
return
class ChildClass(SuperClass):
def __init__(self):
pass
def not_foo_function(self):
print("Not Foo function.")
return
# No @overrides decorator - which raises error.
def foo_function(self):
print("Foo child function.")
return
Traceback (most recent call last):
...
AssertionError: Method foo_function overrides but does not have @overrides decorator
부모 클래스 메서드와 이름이 겹치지 않는 메서드는 따로 문제가 발생하지 않는다. 베이스 클래스에서 정의한 최소한의 클래스 인터페이스를 지키도록 만들고 싶을 때 사용할 수 있는 강력한 방법이다.
Decorator: @final
@final 데코레이터는 해당 메서드가 더 이상 오버라이딩 되지 않도록 방지해 준다.
from overrides import EnforceOverrides, overrides, final
class SuperClass(EnforceOverrides):
def __init__(self):
pass
@final
def foo_function(self):
print("Foo function.")
return
class ChildClass(SuperClass):
def __init__(self):
pass
@overrides
def foo_function(self):
print("Foo child function.")
return
Traceback (most recent call last):
...
TypeError: foo_function: is finalized
@final 이 붙은 메서드가 @overrides 가 붙은 메서드에 의해 오버라이딩 되고 있다면 위와 같이 에러가 발생한다. 다만 여기서 주의할 점은 @overrides를 붙이지 않고 그냥 메서드 오버라이딩을 하게 되면, @final이 위 상황을 잡아내지 못 하게 된다. 따라서 EnforceOverrides를 통해 메서드 이름이 겹칠 경우 항상 @overrides가 붙도록 강제해야 @final이 유의미하게 작동할 수 있다.
Usage with @classmethod / @staticmethod
class ChildClass(SuperClass):
def __init__(self):
pass
@staticmethod
@overrides
def foo_function(x):
print(f"Foo child function.{x}")
return
@classmethod, @staticmethod 는 @overrides 앞에다 붙여주면 된다. 다른 데코레이터들은 따로 상관이 없는 듯 하다. 참고로 여러 데코레이터가 붙어 있는 경우에는 맨 앞의 데코레이터부터 순차적으로 기능이 적용된다.
Docstring Inheritence
@overrides가 붙은 자식 클래스 메서드에 따로 독스트링이 (참고) 작성되지 않았다면 자동으로 부모 클래스 독스트링을 자식 클래스 독스트링에 대입시킨다. 아래 코드를 보자.
class SuperClass:
"""Super class docstring."""
def __init__(self):
pass
def foo_function(self):
"""Foo function of super class."""
print("Foo function.")
return
class ChildClass(SuperClass):
"""Child class docstring."""
def __init__(self):
pass
# No docstring.
def foo_function(self):
print(f"Foo child function.")
return
>>> super_class = SuperClass()
>>> child_class = ChildClass()
>>> print(super_class.foo_function.__doc__)
Foo function of super class.
>>> print(child_class.foo_function.__doc__)
None
일반적으로 오버라이딩을 한다 해도 자식 클래스에서 따로 독스트링을 작성하지 않았다면 자식 클래스 메서드의 독스트링은 비어있는 상태로 남게 된다.
from overrides import overrides
class SuperClass:
"""Super class docstring."""
def __init__(self):
pass
def foo_function(self):
"""Foo function of super class."""
print("Foo function.")
return
class ChildClass(SuperClass):
"""Child class docstring."""
def __init__(self):
pass
@overrides
def foo_function(self):
print(f"Foo child function.")
return
>>> super_class = SuperClass()
>>> child_class = ChildClass()
>>> print(super_class.foo_function.__doc__)
Foo function of super class.
>>> print(child_class.foo_function.__doc__)
Foo function of super class.
하지만 @overrides 데코레이터가 붙은 상태로 오버라이딩을 하는 메서드는 아무 독스트링이 적혀있지 않으면 자동적으로 부모 클래스의 독스트링을 사용한다. 물론 새로 작성하는 경우 새 독스트링을 사용하게 된다.
Signature Identity, LSP
from overrides import EnforceOverrides, overrides
class SuperClass(EnforceOverrides):
def __init__(self):
pass
def foo_function(self):
print("Foo function.")
return
class ChildClass(SuperClass):
def __init__(self):
pass
@overrides
def foo_function(self, x):
print(f"Foo child function.{x}")
return
Traceback (most recent call last):
...
TypeError: ChildClass.foo_function: `x` is not a valid parameter.
맨 앞에서 언급한 상황 중 하나인, 메서드 시그니쳐 (인자 목록) 이 다른 경우도 @overrides 에 의해 방지된다. 위 코드를 보면 자식 클래스에서 인자 하나를 더 받으려 하니 바로 TypeError가 발생하는 것을 알 수 있다.
사실 여기서 살짝 애매한 부분이 있는데, 일반적으로 메서드 시그니쳐가 같은 상태로 재정의 하는 것을 오버라이딩으로, 이름은 같지만 시그니쳐가 다른 경우를 오버로딩 (Overloading) 이라 칭한다. 하지만 파이썬에서는 클래스 메서드 시그니쳐가 달라도 사실상 새로운 시그니쳐를 가진 메서드가 기존의 메서드를 덮어씌우게 된다.
오버로딩은 따로 지원하지 않지만 대신 오버라이딩 비스무리하게 기능을 덮어씌우면서 메서드 시그니쳐를 은근슬쩍 바꿔놓는 작업은 할 수 있도록 인터프리터 단에서 열려 있다. 이 것을 뭐라 부르는지는 모르겠다.
그럼에도 메서드를 오버라이드 하는 과정에서 메서드 시그니쳐를 변경하지 말아야 하는 이유는 따로 있는데, 바로 객체 지향 프로그래밍 (OOP) 설계 원칙 중 하나인 리스코프 치환 원칙 (Liskov Substitution Principle, LSP) 때문이다.
이 원칙에 따르면, 자식 클래스는 언제나 부모 클래스의 기능을 대신할 수 있어야 한다. 하지만 위와 같은 방식으로 자식 클래스 메서드 단에서 메서드 인터페이스를 마음대로 변경해 버리게 되면, 부모 클래스 메서드를 사용할 때 기대하는 인자 목록을 자식 클래스의 오버라이딩 된 메서드에 입력했을 때 에러가 발생하게 된다. 즉 부모 클래스가 하는 기능을 자식 클래스가 온전히 대체할 수 없는 상황이 만들어지며, LSP 원칙을 위반하게 된다.
따라서 일반적인 상황에서는 오버라이드 시 부모 클래스의 인자 목록을 자식 클래스에서 변경하지 않는 것이 바람직하며, 이를 overrides 데코레이터를 통해 강제할 수 있는 것이다.
만약 정말 메서드 시그니쳐 변경이 불가피한 상황이라면, 베이스 클래스 메서드 인자 목록에 *args, **kwargs를 미리 포함시켜 두는 식으로 문제를 우회할 수 있지만 코드의 명확성을 (참고) 해치게 되므로 반드시 필요한 경우에만 적용하도록 하자.
아래는 참고할 만한 페이지들이다.
References
overrides 6.1.0
https://pypi.org/project/overrides/
Liskov substitution principle
https://en.wikipedia.org/wiki/Liskov_substitution_principle
Python Method overriding, does signature matter?
https://stackoverflow.com/questions/6034662/python-method-overriding-does-signature-matter
Difference between Method Overloading and Method Overriding in Python
https://www.geeksforgeeks.org/difference-between-method-overloading-and-method-overriding-in-python/
Method Overriding in Python
https://www.geeksforgeeks.org/method-overriding-in-python/