Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 443 – 단일 디스패치 제네릭 함수

Author:
Łukasz Langa <lukasz at python.org>
Discussions-To:
Python-Dev list
Status:
Final
Type:
Standards Track
Created:
22-May-2013
Python-Version:
3.4
Post-History:
22-May-2013, 25-May-2013, 31-May-2013
Replaces:
245, 246, 3124

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

초록

이 PEP는 functools 표준 라이브러리 모듈에 새로운 메커니즘을 제안합니다. 이 메커니즘은 단일 디스패치 제네릭 함수라고 하는 간단한 형태의 제네릭 프로그래밍을 제공합니다.

제네릭 함수는 서로 다른 타입에 대해 같은 연산을 구현하는 여러 함수로 구성됩니다. 호출할 때 어떤 구현을 사용할지는 디스패치 알고리즘이 결정합니다. 인자 하나의 타입을 기준으로 구현을 선택하는 경우, 이를 단일 디스패치라고 합니다.

근거와 목표

Python은 len(), iter(), pprint.pprint(), copy.copy(), 그리고 operator 모듈에 있는 대부분의 함수처럼 다양한 내장 및 표준 라이브러리 제네릭 함수를 언제나 제공해 왔습니다. 그러나 현재 파이썬에는 다음과 같은 문제가 있습니다:

  1. 개발자가 새 제네릭 함수를 만들 수 있는 간단하거나 직관적인 방법이 없습니다.
  2. 기존 제네릭 함수에 메서드를 추가하는 표준 방법이 없습니다(예를 들어, 어떤 함수는 등록 함수를 사용해 메서드를 추가하고, 다른 함수는 __special__ 메서드를 정의해야 하며, 이때 멍키패칭이 필요할 수도 있습니다).

또한 현재 Python 코드에서는 받은 객체로 무엇을 할지 결정하기 위해 전달받은 인자의 타입을 검사하는 경우가 많으며, 이는 흔한 안티패턴입니다.

예를 들어, 코드가 어떤 타입의 객체 하나와 그 타입 객체들의 시퀀스를 모두 받아들이고 싶을 수 있습니다. 현재 이를 위한 “당연한 방법”은 타입 검사이지만, 이 방법은 깨지기 쉽고 확장에 닫혀 있습니다.

추상 베이스 클래스를 사용하면 현재 동작을 파악하기는 쉬워지지만, 새로운 동작을 추가하는 데에는 도움이 되지 않습니다. 이미 작성된 라이브러리를 사용하는 개발자는 그런 코드가 자신의 객체를 다루는 방식을 바꾸지 못할 수 있습니다. 사용하는 객체를 서드파티가 만든 경우라면 더욱 그렇습니다.

따라서 이 PEP는 데코레이터를 사용해 동적 오버로딩을 다루는 통일된 API를 제안합니다.

사용자 API

제네릭 함수를 정의하려면 @singledispatch 데코레이터로 함수를 장식하십시오. 디스패치는 첫 번째 인자의 타입을 기준으로 이루어진다는 점에 유의하십시오. 이에 맞게 함수를 작성하십시오:

>>> from functools import singledispatch
>>> @singledispatch
... def fun(arg, verbose=False):
...     if verbose:
...         print("Let me just say,", end=" ")
...     print(arg)

함수에 오버로드된 구현을 추가하려면 제네릭 함수의 register() 속성을 사용하십시오. 이 어트리뷰트는 데코레이터로, 타입 매개변수를 받아서 해당 타입에 대한 연산을 구현하는 함수를 장식합니다:

>>> @fun.register(int)
... def _(arg, verbose=False):
...     if verbose:
...         print("Strength in numbers, eh?", end=" ")
...     print(arg)
...
>>> @fun.register(list)
... def _(arg, verbose=False):
...     if verbose:
...         print("Enumerate this:")
...     for i, elem in enumerate(arg):
...         print(i, elem)

람다와 이미 존재하는 함수를 등록할 수 있도록 register() 속성을 함수 형태로 사용할 수 있습니다:

>>> def nothing(arg, verbose=False):
...     print("Nothing.")
...
>>> fun.register(type(None), nothing)

register() 속성은 데코레이트되지 않은 함수를 반환합니다. 덕분에 데코레이터를 겹쳐 쓸 수 있고, 피클링할 수 있으며, 각 변형에 대한 단위 테스트를 따로 작성할 수도 있습니다:

>>> @fun.register(float)
... @fun.register(Decimal)
... def fun_num(arg, verbose=False):
...     if verbose:
...         print("Half of your number:", end=" ")
...     print(arg / 2)
...
>>> fun_num is fun
False

제네릭 함수를 호출하면 첫 번째 인자의 타입을 기준으로 디스패치합니다:

>>> fun("Hello, world.")
Hello, world.
>>> fun("test.", verbose=True)
Let me just say, test.
>>> fun(42, verbose=True)
Strength in numbers, eh? 42
>>> fun(['spam', 'spam', 'eggs', 'spam'], verbose=True)
Enumerate this:
0 spam
1 spam
2 eggs
3 spam
>>> fun(None)
Nothing.
>>> fun(1.23)
0.615

특정 타입에 대해 등록된 구현이 없으면, 그 타입의 메서드 결정 순서에 따라 더 일반적인 구현을 찾습니다. @singledispatch로 장식된 원래 함수는 베이스 object 타입에 대해 등록됩니다. 따라서 더 적합한 구현을 찾지 못하면 이 함수가 사용됩니다.

주어진 타입에 대해 제네릭 함수가 어떤 구현을 선택할지 확인하려면 dispatch() 속성을 사용하십시오:

>>> fun.dispatch(float)
<function fun_num at 0x104319058>
>>> fun.dispatch(dict)    # note: default implementation
<function fun at 0x103fe0000>

등록된 모든 구현에 접근하려면 읽기 전용 registry 속성을 사용하십시오:

>>> fun.registry.keys()
dict_keys([<class 'NoneType'>, <class 'int'>, <class 'object'>,
          <class 'decimal.Decimal'>, <class 'list'>,
          <class 'float'>])
>>> fun.registry[float]
<function fun_num at 0x1035a2840>
>>> fun.registry[object]
<function fun at 0x103fe0000>

제안된 API는 설명하고 사용하기 쉬우면서도 functools 모듈의 기존 멤버들과 일관성을 유지하도록, 의도적으로 기능 범위를 좁히고 설계 방향을 분명하게 정했습니다.

구현 참고 사항

이 PEP에서 설명하는 기능은 이미 pkgutil 표준 라이브러리 모듈에 simplegeneric으로 구현되어 있습니다. 이 구현은 충분히 성숙했으므로 거의 그대로 옮기는 것이 목표입니다. 참조 구현은 hg.python.org에서 볼 수 있습니다 [1].

디스패치 타입은 데코레이터 인자로 지정합니다. 함수 어노테이션을 사용하는 대안 형식도 검토했지만, 이를 포함하는 방안은 기각되었습니다. 2013년 5월 현재 이러한 사용 패턴은 표준 라이브러리의 범위를 벗어나며 [2], 어노테이션 사용에 관한 모범 사례는 아직 논의 중입니다.

현재의 pkgutil.simplegeneric 구현에 따르고 추상 베이스 클래스에 가상 서브클래스를 등록하는 관례도 따라서, 디스패치 레지스트리는 스레드 안전하지 않습니다.

추상 베이스 클래스

pkgutil.simplegeneric 구현은 여러 형태의 메서드 결정 순서(MRO)에 의존했습니다. @singledispatch는 구식 클래스와 Zope의 ExtensionClasses에 대한 특수 처리를 제거합니다. 더 중요한 점은 추상 베이스 클래스(ABC) 지원을 도입한다는 것입니다.

ABC에 대해 제네릭 함수 구현이 등록되면 디스패치 알고리즘은 확장된 형태의 C3 선형화로 전환하며, 이 선형화는 주어진 인자의 MRO에 관련 ABC를 포함합니다. 이 알고리즘은 ABC의 기능이 도입되는 위치에 ABC를 삽입합니다. 즉, issubclass(cls, abc)가 클래스 자체에 대해서는 True를 반환하지만 그 클래스의 모든 직접 베이스 클래스에 대해서는 False를 반환하는 위치입니다. 주어진 클래스의 암시적 ABC(등록되었거나 __len__() 같은 특수 메서드의 존재로부터 추론된 ABC)는 해당 클래스의 MRO에 명시적으로 나열된 마지막 ABC 바로 뒤에 삽입됩니다.

가장 기본적인 형태에서 이 선형화는 주어진 타입의 MRO를 반환합니다:

>>> _compose_mro(dict, [])
[<class 'dict'>, <class 'object'>]

지정된 타입을 서브클래스로 두는 ABC가 두 번째 인자에 포함되어 있으면, 그 ABC들은 예측 가능한 순서로 삽입됩니다:

>>> _compose_mro(dict, [Sized, MutableMapping, str,
...                     Sequence, Iterable])
[<class 'dict'>, <class 'collections.abc.MutableMapping'>,
 <class 'collections.abc.Mapping'>, <class 'collections.abc.Sized'>,
 <class 'collections.abc.Iterable'>, <class 'collections.abc.Container'>,
 <class 'object'>]

이 동작 방식은 상당히 느리지만, 모든 디스패치 결정은 캐시됩니다. 캐시는 제네릭 함수에 새 구현을 등록할 때나, 사용자 코드가 ABC에 register()를 호출하여 암시적으로 서브클래스로 만들 때 무효화됩니다. 후자의 경우 디스패치가 모호한 상황이 생길 수 있습니다. 예를 들면 다음과 같습니다:

>>> from collections.abc import Iterable, Container
>>> class P:
...     pass
>>> Iterable.register(P)
<class '__main__.P'>
>>> Container.register(P)
<class '__main__.P'>

모호함에 직면하면 @singledispatch는 추측하려는 유혹을 거부합니다:

>>> @singledispatch
... def g(arg):
...     return "base"
...
>>> g.register(Iterable, lambda arg: "iterable")
<function <lambda> at 0x108b49110>
>>> g.register(Container, lambda arg: "container")
<function <lambda> at 0x108b491c8>
>>> g(P())
Traceback (most recent call last):
...
RuntimeError: Ambiguous dispatch: <class 'collections.abc.Container'>
or <class 'collections.abc.Iterable'>

클래스 정의 시 하나 이상의 ABC를 베이스 클래스로 명시적으로 제공했다면 이 예외는 발생하지 않는다는 점에 유의하십시오. 이 경우 디스패치는 MRO 순서대로 이루어집니다:

>>> class Ten(Iterable, Container):
...     def __iter__(self):
...         for i in range(10):
...             yield i
...     def __contains__(self, value):
...         return value in range(10)
...
>>> g(Ten())
'iterable'

__len__()이나 __contains__() 같은 특수 메서드가 있다는 사실로부터 ABC의 서브클래싱이 추론되는 경우에도 비슷한 충돌이 발생합니다:

>>> class Q:
...   def __contains__(self, value):
...     return False
...
>>> issubclass(Q, Container)
True
>>> Iterable.register(Q)
>>> g(Q())
Traceback (most recent call last):
...
RuntimeError: Ambiguous dispatch: <class 'collections.abc.Container'>
or <class 'collections.abc.Iterable'>

이 PEP의 초기 버전에는 더 단순하지만 놀라운 결과를 낳는 여러 경계 사례를 만들어 내는 자체 방식이 담겨 있었습니다 [3].

사용 패턴

이 PEP는 제네릭으로 명시적으로 표시된 함수의 동작만 확장할 것을 제안합니다. 베이스 클래스 메서드를 서브클래스가 오버라이드할 수 있는 것처럼, 함수도 주어진 타입에 맞는 사용자 정의 기능을 제공하도록 오버로드할 수 있습니다.

범용 오버로딩이 임의의 오버로딩과 같은 것은 아닙니다. 즉, 사람들이 기존 함수의 동작을 예측할 수 없는 방식으로 마구 재정의하리라고 예상할 필요는 없습니다. 오히려 실제 프로그램에서 제네릭 함수를 사용하는 방식은 매우 예측 가능한 패턴을 따르는 경향이 있으며, 일반적인 경우 등록된 구현을 쉽게 찾을 수 있습니다.

모듈이 새로운 제네릭 연산을 정의한다면, 보통 기존 타입에 필요한 구현도 같은 곳에서 정의합니다. 마찬가지로 모듈이 새로운 타입을 정의한다면, 보통 그 모듈이 알고 있거나 관심을 두는 제네릭 함수에 대한 구현을 그곳에서 정의합니다. 결과적으로 등록된 구현의 대다수는 오버로드되는 함수 근처나, 해당 구현이 지원을 추가하는 새로 정의된 타입 근처에서 찾을 수 있습니다.

구현이 추가되는 대상인 함수도, 그 타입도 포함하지 않는 모듈에 구현이 등록되는 경우는 상당히 드뭅니다. 무능하거나 일부러 알기 어렵게 만들려는 의도가 없는 한, 관련 타입이나 함수 가까이에 등록되지 않은 소수의 구현은 대개 그 구현이 정의된 범위 밖에서 이해하거나 알고 있을 필요가 없습니다. (단, “지원 모듈”의 경우는 예외이며, 이때는 모듈 이름을 그에 맞게 짓는 것이 모범 사례입니다.)

앞서 언급했듯이, 단일 디스패치 제네릭은 이미 표준 라이브러리 전반에서 널리 쓰이고 있습니다. 이를 깔끔하고 표준적인 방식으로 처리할 수 있게 되면, 그런 개별 구현들이 공통 구현을 사용하도록 리팩터링할 길이 열리고, 동시에 사용자가 이를 확장할 수도 있게 됩니다.

대안적 접근 방식

PEP 3124에서 Phillip J. Eby는 임의의 규칙 집합에 기반한 오버로딩(기본 구현은 인자 타입에 따라 디스패치합니다)뿐만 아니라 인터페이스, 어댑테이션, 메서드 결합까지 갖춘 완전한 해결책을 제안합니다. PEAK-Rules [4]는 PJE의 PEP에서 설명한 개념의 참조 구현입니다.

이처럼 광범위한 접근 방식은 본질적으로 복잡하므로 합의에 이르기가 어렵습니다. 이와 달리, 이 PEP는 이해하기 쉬운 단일 기능에 집중합니다. 이것이 현재나 미래에 다른 접근 방식을 사용하는 것을 배제하지 않는다는 점에 유의해야 합니다.

Guido van Rossum은 2005년 Artima에 게재한 글 [5]에서 함수의 모든 인자의 타입에 따라 디스패치하는 제네릭 함수 구현을 제시합니다. PyPI에서 구할 수 있는 Andrey Popp의 generic 패키지 [6]와 David Mertz의 gnosis.magic.multimethods [7]에서도 같은 접근 방식을 택했습니다.

처음에는 이 방식이 바람직해 보이지만, 저는 “함수가 어떤 코드를 실행해야 할지 가려내는 데만 몇 페이지에 이르는 로직이 필요한 API를 설계한다면, 아마 API 설계를 다른 사람에게 넘겨야 할 것”이라는 Fredrik Lundh의 의견에 동의합니다. 다시 말해, 이 PEP에서 제안하는 단일 인자 방식은 구현하기가 더 쉬울 뿐 아니라, 더 복잡한 상태에 따라 디스패치하는 것이 안티패턴이라는 점을 분명하게 전달합니다. 또한 객체 지향 프로그래밍에서 익숙한 메서드 디스패치 메커니즘과 직접 대응한다는 장점도 있습니다. 유일한 차이는 개별 구현이 데이터(객체 지향 메서드)와 더 밀접하게 연관되는지, 아니면 알고리즘(단일 디스패치 오버로딩)과 더 밀접하게 연관되는지뿐입니다.

PyPy의 RPython은 클래스를 외부에서 확장할 수 있게 해 주는 메타클래스인 extendabletype [8]을 제공합니다. 이것을 pairtype() 및 pair() 팩토리와 함께 사용하면 일종의 단일 디스패치 제네릭이 됩니다.

감사의 글

PEP 3124와 PEAK-Rules에 대한 Phillip J. Eby의 작업 외에도, functools API의 일부로 pkgutil.simplegeneric을 노출하자고 제안한 Paul Moore의 최초 이슈 [9], 멀티메서드에 관한 Guido van Rossum의 글 [5], 그리고 pprint 전반의 재작성에 관한 Raymond Hettinger와의 논의가 영향을 주었습니다. 이 PEP를 작성하도록 격려하고 초기 피드백을 준 Alyssa Coghlan에게 깊이 감사드립니다.

참고 문헌