파이썬의 이항 연산자(Binary Operator)가 구현되지 않았을 때, 파이썬 인터프리터에게 "나는 이 타입에 대한 연산을 어떻게 처리해야 할지 모르겠으니, 상대방 객체에게 물어봐라"라고 알리는 아주 특별한 약속(Signal)입니다.
NotImplemented의 핵심 역할: "상대방에게 넘기기"파이썬에서 a * b를 실행하면 내부적으로는 a.__mul__(b)가 호출됩니다.
만약 a.__mul__(b)를 실행했는데 결과로 NotImplemented가 반환되면, 파이썬은 즉시 에러를 내는 대신 "그럼 반대로 b야, 너 a랑 곱할 수 있니?"라며 b.__rmul__(a)(Reflected mul)를 호출합니다.
a.__mul__(b)가 NotImplemented를 반환함 b.__rmul__(a)를 시도함.b.__rmul__(a)도 NotImplemented를 반환함 그제서야 TypeError: unsupported operand type(s) for *: ... 에러를 발생시킴.즉, 연산의 권한을 상대방 객체에게 넘겨주어, 객체 간의 상호작용(Polymorphism)을 가능하게 만드는 장치입니다.
NotImplemented vs NotImplementedError가장 많이 하는 실수 중 하나가 이 둘을 혼동하는 것입니다. 이름은 비슷하지만 용도가 완전히 다릅니다.
| 구분 | NotImplemented | NotImplementedError |
|---|---|---|
| 정체 | 값 (Constant) | 예외 (Exception) |
| 사용 위치 | __add__, __mul__ 같은 이항 연산 매직 메서드의 return문 | 메서드의 본문 (Implementation) |
| 의미 | "난 이 연산을 못 해. 상대방한테 물어봐!" | "이 메서드는 아직 구현되지 않았어! (개발자 실수)" |
| 결과 | 파이썬이 상대 객체의 __rmul__ 등을 확인한 후 계속 진행함 | 프로그램이 즉시 중단되며 에러 메시지를 띄움 |
예시 상황:
return NotImplemented: "음, 나는 Vector와 String의 곱셈은 모르겠어. String 객체야, 너 이거 할 줄 알아?" (유연한 대응)raise NotImplementedError: "이 메서드는 아직 코드를 안 짰어! 프로그램 멈춰!" (개발 단계의 오류)작성하신 코드의 실제 구현 부분(Implementation)을 보면,
def __mul__(self, other):
if isinstance(other, Vector):
return Vector(self.x * other.x, self.y * other.y)
elif isinstance(other, (int, float)):
return Vector(self.x * other, self.y * other)
# 만약 other가 Vector도 아니고, int/float도 아니라면?
return NotImplemented
v1 * v2 other가 Vector이므로 첫 번째 if문에 걸려 Vector 객체를 반환합니다. (정상)v1 * 3 other가 int이므로 두 번째 elif문에 걸려 Vector 객체를 반환합니다. (정상)v1 * "Hello" 를 실행한다면?other는 str 타입입니다.if와 elif 조건에 모두 해당하지 않습니다.return NotImplemented가 실행됩니다."Hello".__rmul__(v1)를 호출합니다.str) 객체는 Vector를 곱하는 법을 모릅니다. 따라서 str은 NotImplemented를 반환하거나 에러를 냅니다.TypeError를 발생시킵니다.작성하신 코드에서 return NotImplemented는 "내가 지원하지 않는 타입(예: 문자열, 리스트 등)이 들어오면, 일단 내가 해결하려 하지 말고 파이썬 엔진이 상대방 객체의 연산 능력을 한 번 더 확인해볼 수 있도록 양보하겠다"는 의미입니다. 이는 객체 지향 프로그래밍에서 서로 다른 타입 간의 연산을 매끄럽게 연결하기 위한 매너(Protocol)와 같습니다.
# @typing.overload()를 사용하는 방법 : 동일한 이름의 메서드를 타입에 따라 다르게 적용 가능 (260814)
'''
@typing.overload는 실제 런타임 동작을 바꾸는 기능이 아니라 타입 힌트(type hint)용이다.
따라서 여러 형태의 __mul()__() 시그니처를 정의해 IDE와 타입 검사기(mypy)가 이해하도록하고
실제 구현은 하나만 작성해야 한다.
'''
from __future__ import annotations
# 파이썬이 모든 타입 힌트를 문자열처럼 취급한다. (사용하는 이유 설명)
# 클래스가 정의되는 시점에는 Vector 클래스가 아직 완전히 만들어지지 않았기 때문에
from typing import overload
class Vector:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
# 입력, 출력의 타입을 미리 정의함
# Vector * Vector
@overload
def __mul__(self, other: Vector) -> Vector: # 아직 Vector라는 형태가 완성되지 않은 문제가 있으므로
...
# Vector * Scalar
@overload
def __mul__(self, other: int | float) -> Vector:
...
# 실제 구현은 하나만 존재 : 타입 검사를 수행하고 어떤 함수를 사용할 지 결정
def __mul__(self, other):
if isinstance(other, Vector):
return Vector(
self.x * other.x,
self.y * other.y
)
elif isinstance(other, (int, float)):
return Vector(
self.x * other,
self.y * other
)
return NotImplemented
# --- 이 부분을 추가 작성 ---
def __rmul__(self, other):
# 숫자가 Vector를 곱하려 할 때, 결국 Vector가 곱셈의 주도권을 가져와서
# 위에서 정의한 __mul__을 다시 실행하게 합니다.
return self.__mul__(other)
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1*v2)
print(v1*3)
파이썬에서 __init__, __add__, __mul__ 처럼 앞뒤에 언더바가 두 개씩 붙은 메서드들을 통칭하여 Special Methods라고 합니다. 개발자들 사이에서는 "더블 언더스코어(Double Underscore)"의 줄임말인 "Dunder methods"라고도 부릅니다.
연산자(Operator)가 적용되는 방향에 따라 다음과 같이 구분합니다.
a * b에서 a의 메서드인 __mul__을 의미합니다. 객체가 연산의 왼쪽에 위치할 때 호출됩니다.
a * b에서 a가 연산을 처리하지 못해 NotImplemented를 반환했을 때, b에게 "네가 대신 해줄래?"라고 요청하며 호출되는 __rmul__을 의미합니다.
| 구분 | 명칭 (English) | 의미 | 호출되는 상황 |
|---|---|---|---|
__mul__ | LHS Method (Left-hand side) | 왼쪽 객체의 메서드 | Object * Other |
__rmul__ | RHS Method (Right-hand side) / Reflected Method | 오른쪽 객체의 메서드 | Other * Object (LHS가 거절했을 때) |
__add__: "내가 먼저 곱할게!" (LHS)__radd__: "(LHS가 못하겠대서) 그럼 내가 거꾸로(Reverse) 해볼게!" (RHS/Reflected)return self.__mul__(other)라는 코드는 "내가 이미 만들어 놓은 곱셈 규칙(__mul__)을 그대로 가져와서, 지금 이 상황에 다시 적용해라"라는 뜻입니다.
이것은 프로그래밍에서 매우 중요한 위임(Delegation)이라는 기법입니다. 왜 이렇게 하는지, 단계별로 아주 상세하게 쪼개서 보여드릴게요.
3 * v1)v1이 Vector(1, 2)이고, 3이 int라고 가정했을 때의 내부 동작 순서입니다.
3 * v1 실행3에게 물어봅니다. Vector 객체랑 곱할 수 있니? (3.__mul__(v1))"Vector를 곱하는 법을 모르므로 NotImplemented를 반환합니다.Vector 객체에게 물어봅니다.Vector, 숫자가 너랑 곱하기 싫다는데, 네가 대신 해줄래? (v1.__rmul__(3))"v1의 __rmul__ 메서드가 실행됩니다.return self.__mul__(other)를 만납니다.v1.__mul__(3)을 호출하라는 뜻입니다.v1.__mul__(3)이 실행됩니다.elif isinstance(other, (int, float)): 로직이 구현되어 있습니다.Vector(3, 6)이 정상적으로 계산되어 반환됩니다.이미 __mul__ 메서드 안에 Vector * Vector와 Vector * Scalar에 대한 복잡한 계산 로직을 다 적어놓았습니다.
만약 __rmul__에서 self.__mul__(other)를 쓰지 않고 또 계산식을 적는다면, 똑같은 계산 로직을 두 번 써야 합니다.
def __mul__(self, other):
# ... 계산 로직 ...
def __rmul__(self, other):
# 똑같은 계산 로직을 또 적어야 함 (수정할 때 두 번 수정해야 해서 위험함)def __mul__(self, other):
# ... 계산 로직 ...
def __rmul__(self, other):
return self.__mul__(other) # "난 몰라, __mul__한테 시켜!"a * b가 b * a와 수학적으로 결과가 같아야 한다면(교환법칙), __mul__과 __rmul__은 결국 같은 로직을 바라봐야 합니다. __rmul__이 __mul__을 호출하게 만들면, __mul__에 적힌 규칙이 곧 __rmul__의 규칙이 되므로 두 연산이 완벽하게 일치하게 됩니다.
return self.__mul__(other)는 다음과 같은 의미를 담고 있습니다.
"나는 왼쪽(LHS)에 있는 타입이 아니라 오른쪽(RHS)에 있는 타입이야. 하지만 내가 이 연산을 처리할 줄 안다는 건 이미
__mul__에 다 정의해 뒀다는 뜻이지. 그러니까 복잡하게 다시 계산하지 말고, 그냥 내가 원래 하던 방식(__mul__)대로 이 숫자(other)를 처리해버릴게!"