본문으로 건너뛰기

NotImplemented vs. NotImplementedError

오승윤

Python에서 인터페이스 역할을 하는 추상 클래스를 만드는 예제들을 보면, 메서드에 NotImplementedError를 raise 하는 것을 볼 수 있습니다. 다음 예와 같이 말이죠.

class SomeAbstractClass:
def some_method_must_be_implemented(self, *args, **kwargs):
raise NotImplementedError("It should be implemented!")

그런데, Pycharm 같은 IDE를 사용하면 자동완성에 NotImplemented가 등장하는 것을 볼 수 있습니다. 저는 NotImplementedError == NotImplemented로 이해를 하고 있었는데요.

이번에 찾아보니 완전히 다른 것이어서 여기에 정리해두려고 합니다.

NotImplementedError는 Exception의 한 종류

공식문서를 보면, NotImplementedError는 Python의 빌트인 예외 객체중 하나입니다. RuntimeError로부터 만들어졌다고 하네요.

In user defined base classes, abstract methods should raise this exception when they require derived classes to override the method, or while the class is being developed to indicate that the real implementation still needs to be added.

위의 공식문서의 설명을 보면 추상 메서드들은 반드시 이 예외를 뱉어야한다고 합니다. 이 공식문서에서는 저처럼 헷갈려하는 사람들을 위해 다음과 같은 내용도 추가해놓았습니다.

NotImplementedError and NotImplemented are not interchangeable, even though they have similar names and purposes. See NotImplemented for details on when to use it.

다른 거니까 혼동해서 쓰지 말라구요.

NotImplemented는 값!

여기서도 공식문서를 봅시다.

A special value which should be returned by the binary special methods (e.g. eq(), lt(), add(), rsub(), etc.) to indicate that the operation is not implemented with respect to the other type

다른 타입과 해당 연산이 이뤄지는 것이 구현되어 있지 않은 경우 특수 메서드들이 반환해야하는 특수 값이라고 합니다. 이 특수 함수들의 이름과 이에 대응되는 연산 기호는 다음과 같은 것들을 말합니다.

  • __eq__(): ==
  • __lt__: <
  • __add__: +
  • ...

근데 이런 특수 메서드들에서 NotImplemented라는 값을 왜 반환해야하고, 반환하면 어떤 일이 일어나는 걸까요?

알아보기 위해 예시를 만들고 시작해봅시다.

예시: 나이가 무척이나 중요한 한국인 클래스

한국인에게는 나이가 가장 중요하죠? 한국인 사이에서 존댓말을 해야하는지 아닌지 알려주는 코드를 짜야한다고 합시다. 객체를 직접 비교 연산자(>)로 비교할 수 있다면 편하겠죠. 특수함수 __gt__()를 구현해서 비교하도록 해봅시다.

from datetime import datetime

class Korean:
def __init__(self, name: str, birth_year: int):
self.name = name
self.birth_year = birth_year

def get_korean_age(self):
current_year = datetime.now().year
return current_year - self.birth_year + 1

def __str__(self):
return f"{self.birth_year}년생 {self.name}"

def __gt__(self, other): # 비교 연산자로
return self.get_korean_age() > other.get_korean_age()

kimchulsoo = Korean("김철수", 1989)
honggildong = Korean("홍길동", 1990)

print(kimchulsoo > honggildong) # True

나이만 중요하다고 하면 한국인 객체를 숫자랑 직접 비교할 수도 있을 겁니다. 다음 내용처럼 비교를 해볼까요.

print(kimchulsoo > 34)

예상했듯이 에러가 날겁니다.

AttributeError: 'int' object has no attribute 'get_korean_age'

에러 메시지가 이해하기 쉽지 않네요.

NotImplemented를 사용하는 이유 1: 에러를 이해하기 쉽게 만들기

이 에러를 이해하기 쉽게 쓰면 "Korean과 int 사이에는 > 연산이 지원되지 않는다"가 될 것입니다. NotImplemented를 반환하도록 __gt__() 함수를 좀 바꿔보겠습니다.

...
def __gt__(self, other):
if isinstance(other, Korean):
return self.get_korean_age() > other.get_korean_age()
return NotImplemented
...

이제 kimchulsoo > 34를 실행하면 좀 더 명확한 에러 메시지가 출력됩니다.

TypeError: '>' not supported between instances of 'Korean' and 'int'

자, 이제 원인을 알았으니 int와 연산할 때 어떻게 해야하는지 정의해줍시다.

...
def __gt__(self, other):
if isinstance(other, Korean):
return self.get_korean_age() > other.get_korean_age()
elif isinstance(other, int):
return self.get_korean_age() > other
return NotImplemented
...

이러면 print(kimchulsoo > 34) 는 정상적으로 True로 출력될 겁니다.

NotImplemented를 사용하는 이유 2: 가능한 다른 연산 찾도록 만들기

NotImplemented에는 에러를 명확하게 하는 것 이외에 다른 용도가 있습니다. 바로 Python 인터프리터가 다른 가능한 연산을 시도하도록 유도하는 것인데요.

한국인 존댓말 코드를 우리 코드를 가지고 신나게 짜다가 다음과 같이 intKorean을 순서를 바꿔 썼다고 해봅시다.

34 > kimchulsoo  # 에러가 발생!

잘 따라오신 분들은 저렇게 쓰면 당연히 에러가 난다는 것을 알 것입니다. 왜냐하면 int__gt__() 함수에서는 당연히 Korean과의 연산은 정의되지 않았기 때문에 NotImplemented가 반환될 것이기 때문이죠.

그럼 Korean 클래스와 int 사이의 연산은 항상 Korean, int 순으로 이뤄져야 하는 걸까요? 이거 너무 불편할 것 같습니다.

그런데 저 예시를 조금만 바꿔보면 신기한 일이 일어납니다.

34 < kimchulsoo  # 에러가 안난다!

어라 에러가 안나네요?

이건 NotImplemented가 반환 됐을 때 인터프리터가 처리하는 방식 때문에 생긴 일입니다.

Reflected Operation

공식문서를 보면 다음과 같은 내용을 볼 수 있습니다.

"the interpreter will try the reflected operation on the other type (or some other fallback, depending on the operator)."

인터프리터는 NotImplemented가 반환되면 "reflected operation"이라는 것을 찾습니다. 직역을 하자면 반사된 연산이라는 얘긴데요. 순서를 바꿔서 연산시켜 보는 겁니다. 그러니까 이렇게요:

34 < kimchulsoo  # 에러가 안난다!

# 윗줄을 뒤집어보자!
kimchulsoo > 34 # 이건 정의돼 있는 연산이다.

이렇게 순서가 뒤집힌 연산은 우리가 정의해놨기 때문에 에러가 안나고 제대로 처리가 된 것입니다. 각 특수 함수의 Python의 공식문서를 살펴보면, reflected operation이 어떤 것인지 알 수 있습니다.

__gt__() 메서드의 공식문서를 보면 __lt__()가 "뒤집힌" 연산에 해당하네요. 그럼 아까 에러가 났던 부분을 고쳐보죠!

from datetime import datetime

class Korean:
def __init__(self, name: str, birth_year: int):
self.name = name
self.birth_year = birth_year

def get_korean_age(self):
current_year = datetime.now().year
return current_year - self.birth_year + 1

def __str__(self):
return f"{self.birth_year}년생 {self.name}"

def __gt__(self, other):
if isinstance(other, Korean):
return self.get_korean_age() > other.get_korean_age()
elif isinstance(other, int):
return self.get_korean_age() > other
return NotImplemented

def __lt__(self, other):
if isinstance(other, Korean):
return self.get_korean_age() < other.get_korean_age()
elif isinstance(other, int):
return self.get_korean_age() < other
return NotImplemented

print(34 > kimchulsoo) # False

이제 이 에러가 안납니다.

결론

NotImplementedErrorNotImplemented는 이름이 비슷해서 혼동했었는데 완전히 다른 것이었습니다. NotImplemented을 공부해보니 Python을 설계하신 분들의 유연한 설계를 엿볼 수 있었습니다. 앞으로도 그냥 갖다가 쓰는게 아니라 문서를 좀 읽어보면서 왜 이렇게 구현했을까 살펴보면 재밌을 것 같습니다.

요약

  • 추상 메서드는 반드시 NotImplementedError를 raise 하도록 코드를 짜야한다.
  • NotImplemented는 연산을 위한 특별한 함수들이 반환하는, 다른 타입끼리의 연산이 정의되지 않았음을 나타내는 특수한 값이다.
    • 에러를 이해하기 쉽게 만들 수 있다. ("XXX와 YYY 사이에 ZZZ 연산이 정의되지 않았습니다")
    • 반전된 연산(reflected operation)을 활용해서 에러를 해결하는 유연한 방법을 제공한다.