Python 강좌 : 제 45강 - 디스패치
디스패치(Dispatch)
디스패치(Dispatch)란 호출 시점에 어떤 구현(함수)을 실행할지 선택하는 것을 의미합니다.
특히 인수의 형식(type)에 따라 서로 다른 구현을 실행하는 방식을 이용하면, 다른 언어의 오버로딩(Overloading)과 비슷한 기능을 구현할 수 있습니다.
오버로딩이란 같은 이름의 메서드(method)에 매개변수의 개수나 형식을 다르게 지정해 2개 이상의 메서드를 정의하는 것을 의미합니다.
Python은 오버로딩을 지원하지 않으므로, 같은 이름의 함수를 다시 정의하면 나중에 정의한 함수가 이전 함수를 덮어씁니다.
대신 표준 라이브러리인 functools 모듈의 singledispatch와 singledispatchmethod를 사용해 형식에 따라 분기하는 함수를 구현할 수 있습니다.
“같은 일을 하지만 입력의 형식마다 처리 방법이 다른 함수”는 생각보다 자주 필요합니다. 예를 들어 값을 화면에 보기 좋게 출력하는 함수는 정수라면 천 단위 쉼표를, 리스트라면 요소를 나열하고, 날짜라면 날짜 형식으로 바꿔야 합니다.
이런 함수를 가장 쉽게 작성하는 방법은 if isinstance(...) 분기를 늘어놓는 것이지만, 지원하는 형식이 늘어날수록 함수가 길어지고 새 형식을 추가하려면 원래 함수를 고쳐야 하는 문제가 생깁니다.
디스패치는 형식별 구현을 각각의 함수로 분리해 등록하고, 호출할 때 인수의 형식을 보고 알맞은 함수를 자동으로 골라 실행합니다.
이번 강좌에서는 오버로딩이 되지 않는 이유와 if-isinstance 분기의 한계를 먼저 살펴본 다음, singledispatch가 이 문제를 어떻게 해결하는지, 형식 주석과 추상 클래스로 등록하는 방법, 메서드에 적용하는 singledispatchmethod, 그리고 여러 인수로 분기하는 다중 디스패치를 다룹니다.
오버로딩(Overloading)이 되지 않는 이유
다른 언어에서 오버로딩을 사용해 본 적이 있다면, Python에서도 같은 이름의 함수를 형식만 다르게 여러 번 정의하면 될 것이라고 생각하기 쉽습니다.
하지만 Python에서 def는 함수 객체를 만들어 이름에 대입하는 실행문입니다. 같은 이름으로 다시 def를 실행하면 변수에 새 값을 대입하는 것과 같아집니다.
def add(x: int, y: int):
return x + y
def add(x: str, y: int):
return f"{x} = {y}"
print(add("f(x)", 6))
print(add(3, 6))- 결과
- f(x) = 6
3 = 6
두 번째 add 함수가 첫 번째 add 함수를 덮어쓰므로, 정수를 전달해도 두 번째 함수가 실행됩니다.
형식 힌트(Type Hint)는 실행에 영향을 주지 않으므로, 형식 힌트만으로는 함수를 나눌 수 없습니다.
두 번째 줄의 add(3, 6)이 9가 아니라 3 = 6인 것이 그 증거입니다. 첫 번째 add는 이름을 잃어 더 이상 호출할 방법이 없습니다.
제 34강의 함수 주석(형식 힌트)은 코드를 읽는 사람과 검사 도구를 위한 정보일 뿐, Python 인터프리터가 형식을 검사하거나 함수를 고르는 데 사용하지 않습니다.
- Tip :
typing.overload데코레이터는 정적 형식 검사 도구에 여러 호출 형태를 알려주기 위한 기능이며, 실행 시점에 함수를 나눠주지 않습니다.
형식에 따라 분기하는 함수의 문제
오버로딩 대신 가장 먼저 떠올리는 방법은 함수 하나 안에서 isinstance()로 형식을 검사해 분기하는 것입니다.
작은 함수에서는 이 방법으로 충분하지만, 실제 코드에서는 다음과 같은 문제가 생깁니다.
def describe(value):
if isinstance(value, int):
return f"정수 : {value}"
elif isinstance(value, str):
return f"문자열 : {value}"
elif isinstance(value, bool):
return f"논리값 : {value}"
else:
return f"알 수 없는 형식 : {value!r}"
print(describe(3))
print(describe("Python"))
print(describe(True))
print(describe(3.14))- 결과
- 정수 : 3
문자열 : Python
정수 : True
알 수 없는 형식 : 3.14
True를 전달했는데 논리값이 아니라 정수가 출력되었습니다.
bool은 int의 하위 클래스이므로 isinstance(True, int)가 True가 되어 첫 번째 분기에서 걸리고, 아래의 bool 분기에는 영원히 도달하지 못합니다. if-isinstance 방식은 분기의 순서가 결과를 결정하므로, 하위 클래스를 항상 상위 클래스보다 먼저 검사하도록 직접 순서를 관리해야 합니다.
또한 3.14 같은 실수를 지원하려면 describe 함수의 본문을 직접 수정해야 합니다. 이 함수가 다른 사람이 만든 라이브러리 안에 있다면 수정할 수 없고, 형식이 수십 개로 늘어나면 함수 하나가 모든 형식의 처리 코드를 떠안게 됩니다.
| 문제 | 설명 |
|---|---|
| 순서 의존 | 상위 클래스 분기가 먼저 오면 하위 클래스 분기가 실행되지 않음 |
| 확장성 | 새 형식을 지원하려면 원래 함수를 수정해야 함 |
| 응집도 | 서로 관계없는 형식의 처리 코드가 한 함수에 모임 |
singledispatch는 형식별 처리를 등록 방식으로 바꿔 이 세 가지 문제를 해결합니다.
단일 디스패치(singledispatch)
singledispatch는 함수를 형식별 구현을 담은 표(registry) 와 그 표에서 구현을 골라 실행하는 디스패처로 바꿔 주는 데코레이터입니다.
데코레이터의 동작 원리는 제 47강에서 자세히 다루며, 여기서는 사용 방법과 구현이 선택되는 규칙에 집중합니다.
from functools import singledispatch
@singledispatch
def describe(value):
return f"알 수 없는 형식 : {value!r}"
@describe.register(int)
def _(value):
return f"정수 : {value}"
@describe.register(str)
def _(value):
return f"문자열 : {value}"
print(describe(3))
print(describe("Python"))
print(describe(3.14))
print(describe(True))- 결과
- 정수 : 3
문자열 : Python
알 수 없는 형식 : 3.14
정수 : True
@singledispatch 데코레이터를 적용한 함수는 기본 구현이 되며, 등록된 형식이 없을 때 실행됩니다.
@함수명.register(형식) 데코레이터로 형식별 구현을 등록합니다. 등록하는 함수의 이름은 중요하지 않으므로 관례적으로 _를 사용합니다.
singledispatch는 첫 번째 인수의 형식만 보고 실행할 함수를 선택합니다. 이를 단일 디스패치(Single Dispatch)라 합니다.
3.14는 float 형식이 등록되어 있지 않으므로 기본 구현이 실행됩니다.
True는 bool 형식이지만 bool은 int의 하위 클래스이므로, int로 등록된 함수가 실행됩니다.
describe(3)을 호출했을 때 내부에서 일어나는 일은 다음과 같습니다.
- 첫 번째 인수
3의 형식int를 구합니다. - 등록 표에서
int에 대한 구현을 찾습니다. 정확히 일치하는 형식이 있으므로 그 함수를 선택합니다. - 선택된 함수에 원래 인수를 그대로 전달해 호출하고, 반환값을 돌려줍니다.
describe(True)라면 1단계에서 bool을 구하지만 표에 bool이 없으므로, bool의 상위 클래스를 차례로 확인해 가장 가까운 int의 구현을 선택합니다.
- Tip : 정확히 일치하는 형식이 없으면
메서드 결정 순서(MRO)를 따라 가장 가까운 상위 클래스의 구현을 선택합니다.
from functools import singledispatch
@singledispatch
def describe(value):
return f"알 수 없는 형식 : {value!r}"
@describe.register(bool)
def _(value):
return f"논리값 : {value}"
@describe.register(int)
def _(value):
return f"정수 : {value}"
print(describe(True), "/", describe(3))
# 다른 모듈에서 나중에 추가하는 경우
describe.register(float, lambda value: f"실수 : {value:.1f}")
print(describe(3.14))
print(describe.dispatch(bool).__name__, describe.dispatch(float).__name__)- 결과
- 논리값 : True / 정수 : 3
실수 : 3.1
_ <lambda>
앞 절의 if-isinstance 예제가 가진 문제를 singledispatch로 해결한 예제입니다.
첫째, 순서 의존이 사라집니다. bool과 int를 어떤 순서로 등록하더라도, 디스패처는 인수의 형식과 가장 가까운 구현을 고르므로 True는 논리값, 3은 정수로 처리됩니다.
둘째, 확장성이 생깁니다. describe.register(float, 함수)처럼 원래 함수를 수정하지 않고, 나중에 다른 모듈에서도 새 형식의 구현을 추가할 수 있습니다. register(형식, 함수) 형태로 호출하면 데코레이터 없이 lambda 같은 함수도 등록할 수 있습니다.
셋째, 형식별 구현이 분리됩니다. 각 구현은 독립된 함수이므로 따로 읽고, 테스트하고, 수정할 수 있습니다.
마지막 줄의 dispatch(형식)은 그 형식이 전달되었을 때 선택될 함수를 반환합니다. bool은 _라는 이름의 함수가, float는 <lambda>가 선택되는 것을 확인할 수 있습니다.
| 구분 | if-isinstance 분기 | singledispatch |
|---|---|---|
| 형식 추가 | 원래 함수를 수정 | register()로 등록 |
| 하위 클래스 처리 | 분기 순서에 따라 달라짐 | 가장 가까운 형식이 자동 선택 |
| 분기 기준 | 원하는 조건 무엇이든 | 첫 번째 인수의 형식 |
| 적합한 경우 | 형식이 2~3개로 고정된 작은 함수 | 형식이 많거나 외부에서 확장해야 하는 함수 |
from functools import singledispatch
class Animal:
pass
class Dog(Animal):
pass
class Puppy(Dog):
pass
@singledispatch
def sound(obj):
return "..."
@sound.register
def _(obj: Animal):
return "동물 소리"
@sound.register
def _(obj: Dog):
return "멍멍"
print(sound(Animal()), sound(Dog()), sound(Puppy()), sound(1))
print([c.__name__ for c in Puppy.__mro__])- 결과
- 동물 소리 멍멍 멍멍 …
[‘Puppy’, ‘Dog’, ‘Animal’, ‘object’]
사용자 정의 클래스의 상속 관계에서 구현이 선택되는 규칙을 확인하는 예제입니다.
Puppy에 대한 구현은 등록하지 않았지만, Puppy의 MRO는 Puppy → Dog → Animal → object 순서이므로 가장 먼저 만나는 Dog의 구현 멍멍이 선택됩니다.
정수 1은 Animal 계열이 아니므로 object, 즉 기본 구현이 실행되어 ...을 반환합니다.
즉, 상위 클래스에 한 번 등록해 두면 하위 클래스는 자동으로 같은 처리를 받고, 필요한 하위 클래스만 따로 등록해 동작을 바꿀 수 있습니다.
형식 주석으로 등록하기
register(형식)처럼 형식을 데코레이터 인수로 적는 대신, 등록할 함수의 첫 번째 매개변수에 형식 주석을 달아 형식을 지정할 수 있습니다.
형식을 두 번 적지 않아도 되고, 함수 시그니처만 보고도 어떤 형식을 처리하는 구현인지 알 수 있어 더 많이 사용되는 방식입니다.
from functools import singledispatch
@singledispatch
def length(value):
raise NotImplementedError(f"{type(value).__name__} 형식은 지원하지 않습니다.")
@length.register
def _(value: list):
return f"리스트 길이 : {len(value)}"
@length.register
def _(value: dict):
return f"사전 키 개수 : {len(value)}"
@length.register
def _(value: int | float):
return f"숫자 자릿수 : {len(str(value))}"
print(length([1, 2, 3]))
print(length({"a": 1}))
print(length(12345))
print(length(3.14))
try:
length("abc")
except NotImplementedError as e:
print(e)- 결과
- 리스트 길이 : 3
사전 키 개수 : 1
숫자 자릿수 : 5
숫자 자릿수 : 4
str 형식은 지원하지 않습니다.
Python 3.7 이상에서는 register()에 형식을 전달하지 않고, 첫 번째 매개변수의 형식 주석(Type Annotation)으로 형식을 지정할 수 있습니다.
Python 3.11 이상에서는 int | float처럼 유니온(Union) 형식으로 여러 형식을 한 번에 등록할 수 있습니다. typing.Union[int, float]도 같은 의미입니다.
기본 구현에서 NotImplementedError 예외를 발생시키면, 지원하지 않는 형식이 전달되었을 때 명확하게 알릴 수 있습니다.
결과를 보면 12345와 3.14는 모두 유니온으로 등록한 하나의 구현을 사용하며, str(3.14)는 "3.14"로 4글자이므로 4가 됩니다.
"abc"는 등록된 형식이 없어 기본 구현이 실행되고, 오류 메시지에 실제 형식 이름 str이 포함됩니다. 지원하지 않는 형식을 조용히 무시하는 것보다 이렇게 알리는 편이 오류를 찾기 쉽습니다.
from functools import singledispatch
@singledispatch
def length(value):
raise NotImplementedError
@length.register
def _(value: list):
return len(value)
@length.register
def _(value: int | float):
return len(str(value))
print(length.registry.keys())
print(length.dispatch(bool) is length.registry[int])- 결과
- dict_keys([<class ‘object’>, <class ‘list’>, <class ‘int’>, <class ‘float’>])
True
registry 속성으로 등록된 형식과 구현을 확인할 수 있습니다. 기본 구현은 object 형식으로 등록됩니다.
유니온 int | float로 등록한 구현은 int와 float 두 개의 항목으로 나뉘어 등록된 것을 확인할 수 있습니다.
dispatch(형식) 메서드는 해당 형식이 전달되었을 때 실행될 함수를 반환합니다. bool 형식은 int의 구현이 선택되는 것을 확인할 수 있습니다.
registry와 dispatch()는 예상과 다른 구현이 실행될 때 원인을 찾는 데 유용합니다.
from functools import singledispatch
@singledispatch
def total(value):
raise NotImplementedError(type(value).__name__)
@total.register(list)
@total.register(tuple)
@total.register(set)
def _(value):
return sum(value)
print(total([1, 2]), total((3, 4)), total({5, 6}))
try:
total(value=[1, 2])
except TypeError as e:
print(type(e).__name__, ":", e)- 결과
- 3 7 11
TypeError : total requires at least 1 positional argument
register()는 등록한 함수를 그대로 반환하므로, 데코레이터를 여러 개 겹쳐서 하나의 구현을 여러 형식에 등록할 수 있습니다.
주의할 점은 디스패치가 첫 번째 위치 인수의 형식으로 이루어진다는 것입니다. total(value=[1, 2])처럼 키워드 인수로만 전달하면 형식을 확인할 위치 인수가 없으므로 TypeError가 발생합니다.
from functools import singledispatch
@singledispatch
def size(value):
return None
try:
@size.register
def _(value: list[int]):
return len(value)
except TypeError as e:
print(type(e).__name__, ":", e)- 결과
- TypeError : Invalid annotation for ‘value’. list[int] is not a class.
디스패치는 실행 중에 type(인수)로 형식을 확인하므로, list[int]처럼 요소의 형식까지 지정한 주석은 사용할 수 없습니다. 실행 중의 리스트는 요소의 형식과 관계없이 모두 list이기 때문입니다.
이런 주석으로 등록하면 등록하는 시점에 TypeError가 발생합니다. 등록에는 list처럼 클래스를 사용합니다.
추상 클래스로 등록하기
구체적인 클래스 대신 "순서가 있는 자료형 전부", "사전처럼 동작하는 자료형 전부"처럼 성질로 형식을 묶어 등록하고 싶을 때가 있습니다.
이때는 collections.abc 모듈의 추상 클래스를 사용합니다. 제 43강에서 다룬 것처럼 리스트, 튜플, 문자열 등은 Sequence로 인식됩니다.
from collections.abc import Sequence
from functools import singledispatch
@singledispatch
def kind(value):
return "object"
@kind.register
def _(value: Sequence):
return "Sequence"
@kind.register
def _(value: str):
return "str"
print(kind("a"), kind((1,)), kind([1]), kind(1))- 결과
- str Sequence Sequence object
collections.abc 모듈의 추상 클래스(Abstract Base Class)로도 등록할 수 있습니다.
튜플과 리스트는 Sequence로 등록된 구현이 실행되며, str도 Sequence이지만 더 구체적인 str 구현이 우선합니다.
정수는 Sequence가 아니므로 기본 구현인 object가 실행됩니다.
추상 클래스로 등록하면 Sequence 규칙을 따르는 사용자 정의 클래스도 자동으로 같은 구현을 사용하게 되므로, 앞으로 생길 형식까지 미리 처리할 수 있습니다.
메서드 디스패치(singledispatchmethod)
singledispatch를 클래스 안의 메서드에 그대로 사용하면 의도대로 동작하지 않습니다. 메서드의 첫 번째 인수는 항상 인스턴스 자신인 self이기 때문입니다.
from functools import singledispatchmethod
class Formatter:
@singledispatchmethod
def format(self, value):
return f"기타 : {value}"
@format.register
def _(self, value: int):
return f"정수 : {value:,}"
@format.register
def _(self, value: list):
return "목록 : " + ", ".join(map(str, value))
f = Formatter()
print(f.format(1234567))
print(f.format([1, 2, 3]))
print(f.format(None))- 결과
- 정수 : 1,234,567
목록 : 1, 2, 3
기타 : None
singledispatch는 첫 번째 인수의 형식으로 분기하므로, 클래스의 메서드에 사용하면 self의 형식으로 분기하게 됩니다.
Python 3.8 이상에서는 singledispatchmethod를 사용해 self를 제외한 첫 번째 인수의 형식으로 분기하는 메서드를 정의할 수 있습니다.
사용 방법은 singledispatch와 동일하며, @메서드명.register로 형식별 구현을 등록합니다.
f.format(1234567)은 value가 int이므로 천 단위 쉼표를 넣은 구현이, f.format([1, 2, 3])은 list 구현이 실행됩니다. None은 등록된 형식이 없어 기본 구현이 실행됩니다.
등록하는 구현도 일반 메서드처럼 self를 첫 번째 매개변수로 받으므로, 구현 안에서 인스턴스의 속성이나 다른 메서드를 사용할 수 있습니다.
- Tip :
singledispatchmethod는@classmethod,@staticmethod와 함께 사용할 수 있으며, 이때@singledispatchmethod를 가장 바깥쪽(위쪽)에 작성합니다.
다중 디스패치(multipledispatch)
singledispatch는 첫 번째 인수의 형식으로만 분기합니다.
add(int, int)와 add(str, int)처럼 여러 인수의 형식 조합으로 분기하는 다중 디스패치(Multiple Dispatch)가 필요하다면 외부 패키지인 multipledispatch를 사용할 수 있습니다.
외부 패키지는 제 28강에서 다룬 pip로 설치합니다.
pip install multipledispatchfrom multipledispatch import dispatch
@dispatch(int, int)
def add(x, y):
return x + y
@dispatch(str, int)
def add(x, y):
return f"{x} = {y}"
print(add(3, 6))
print(add("f(x)", 6))- 결과
- 9
f(x) = 6
@dispatch(형식1, 형식2, ...)의 구조로 각 인수의 형식을 지정하며, 매개변수의 개수나 형식 조합에 따라 서로 다른 함수가 실행됩니다.
이 장의 처음에 덮어쓰기 문제가 발생했던 add 예제와 같은 코드이지만, 이번에는 두 정의가 모두 유지되어 add(3, 6)은 9, add("f(x)", 6)은 f(x) = 6을 반환합니다.
@dispatch는 같은 이름의 함수를 덮어쓰지 않고, 이름별로 관리하는 등록 표에 새 형식 조합을 추가합니다.
- Tip :
@dispatch(str, int)와@dispatch(int, str)는 서로 다른 함수로 등록됩니다.
from multipledispatch import dispatch
@dispatch(int, int)
def add(x, y):
return x + y
@dispatch(str, int)
def add(x, y):
return f"{x} = {y}"
try:
add(1.5, 2)
except NotImplementedError as e:
print(type(e).__name__, ":", e)- 결과
- NotImplementedError : Could not find signature for add: <float, int>
등록되지 않은 형식 조합 (float, int)로 호출하면 NotImplementedError가 발생하며, 오류 메시지에 실제로 전달된 형식 조합이 표시됩니다.
singledispatch처럼 object로 등록되는 기본 구현이 자동으로 만들어지지 않으므로, 모든 형식을 받아야 한다면 @dispatch(object, object)처럼 직접 등록합니다.
- Tip : 대부분의 경우에는 표준 라이브러리의
singledispatch로 충분하며, 외부 패키지는 여러 인수의 형식 조합이 꼭 필요할 때만 사용하는 것이 좋습니다.
정리
| 이름 | 설명 | 반환값/특징 |
|---|---|---|
| @singledispatch | 첫 번째 인수의 형식으로 구현을 선택하는 함수 생성 | 기본 구현은 object로 등록 |
| 함수.register(형식) | 형식별 구현 등록 | 등록한 함수를 그대로 반환 |
| 함수.register (형식 주석) | 첫 번째 매개변수의 주석으로 형식 지정 | 3.7 이상, 유니온은 3.11 이상 |
| 함수.register(형식, 함수) | 데코레이터 없이 등록 | 다른 모듈에서 확장할 때 사용 |
| 함수.registry | 등록된 형식과 구현 | 읽기 전용 매핑 |
| 함수.dispatch(형식) | 해당 형식에 선택될 구현 | 함수 |
| @singledispatchmethod | self 다음 인수의 형식으로 분기 |
3.8 이상 |
| @dispatch(형식, …) | 여러 인수의 형식 조합으로 분기 | 외부 패키지 multipledispatch |
- Python은 오버로딩을 지원하지 않으며, 같은 이름의
def는 이전 함수를 덮어씁니다. if-isinstance분기는 순서 의존성과 확장성 문제가 있으며, 디스패치는 형식별 구현을 등록 방식으로 분리해 이를 해결합니다.- 정확한 형식이 없으면
MRO를 따라 가장 가까운 상위 클래스의 구현이 선택됩니다. - 디스패치는 실행 중의
type()을 기준으로 하므로,list[int]같은 세부 형식이나 키워드 인수로는 분기할 수 없습니다.
댓글 남기기