PEP 612 – 매개변수 명세 변수
- Author:
- Mark Mendoza <mendoza.mark.a at gmail.com>
- Sponsor:
- Guido van Rossum <guido at python.org>
- BDFL-Delegate:
- Guido van Rossum <guido at python.org>
- Discussions-To:
- Typing-SIG list
- Status:
- Final
- Type:
- Standards Track
- Topic:
- Typing
- Created:
- 18-Dec-2019
- Python-Version:
- 3.10
- Post-History:
- 18-Dec-2019, 13-Jul-2020
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
현재 호출 가능 객체의 타입을 지정하는 방법은 두 가지가 있습니다. PEP 484에서 정의한 Callable[[int, str], bool] 문법과 PEP 544의 콜백 프로토콜입니다. 두 방법 모두 한 호출 가능 객체의 매개변수 타입을 다른 호출 가능 객체로 넘기는 기능을 지원하지 않습니다. 그래서 함수 데코레이터에 어노테이션을 달기가 어렵습니다. 이 PEP는 이러한 관계를 표현할 수 있도록 typing.ParamSpec과 typing.Concatenate를 제안합니다.
동기
고차 함수에 어노테이션을 다는 기존 표준으로는 다음과 같이 흔히 쓰는 데코레이터 패턴에 제대로 어노테이션을 달 수 없습니다.
from typing import Awaitable, Callable, TypeVar
R = TypeVar("R")
def add_logging(f: Callable[..., R]) -> Callable[..., Awaitable[R]]:
async def inner(*args: object, **kwargs: object) -> R:
await log_to_database()
return f(*args, **kwargs)
return inner
@add_logging
def takes_int_str(x: int, y: str) -> int:
return x + 7
await takes_int_str(1, "A")
await takes_int_str("B", 2) # fails at runtime
add_logging은 데코레이터가 적용된 함수에 진입할 때마다 그 전에 로그를 남기는 데코레이터로, 한 함수가 자신에게 주어진 모든 인자를 다른 함수에 그대로 넘기는 Python 관용구의 한 예입니다. 이 관용구는 매개변수와 인자 양쪽에서 *args와 **kwargs 기능을 함께 사용해 구현합니다. (inner처럼) (*args, **kwargs)를 받아 다른 함수를 (*args, **kwargs)로 호출하는 함수를 정의하면, 감싸는 함수를 안전하게 호출하는 방법은 감싸진 함수를 안전하게 호출하는 방법과 정확히 같습니다. 이 데코레이터에 타입을 지정하려면 호출 가능 객체 f의 매개변수와 반환되는 함수의 매개변수 사이에 의존성을 둘 수 있어야 합니다. PEP 484는 def append(l: typing.List[T], e: T) -> typing.List[T]: ...처럼 단일 타입 사이의 의존성은 지원하지만, 함수의 매개변수처럼 복잡한 대상에 대해서는 그렇게 할 수 있는 방법이 아직 없습니다.
현재 방식에는 이러한 한계가 있어서 add_logging 예제는 타입 검사는 통과하지만 런타임에 실패합니다. inner는 문자열 “B”를 takes_int_str에 넘기고, takes_int_str는 여기에 7을 더하려다가 타입 오류를 일으킵니다. 데코레이트된 takes_int_str에 Callable[..., Awaitable[int]] 타입이 부여되었기 때문에 타입 검사기가 이 문제를 잡아내지 못했습니다(매개변수 타입 자리에 생략 부호를 쓰면 인자를 전혀 검증하지 않는다는 뜻으로 규정되어 있습니다).
서로 다른 호출 가능 객체 타입의 매개변수 사이에 의존성을 정의할 수 없으면, 현재로서는 데코레이트된 함수의 매개변수 강제를 그대로 유지하면서 add_logging을 모든 함수와 호환되게 만들 방법이 없습니다.
이 PEP에서 제안하는 ParamSpec 변수를 사용하면 데코레이터의 유연성과 데코레이트된 함수의 매개변수 검사를 모두 유지하면서 앞의 예제를 다시 작성할 수 있습니다.
from typing import Awaitable, Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def add_logging(f: Callable[P, R]) -> Callable[P, Awaitable[R]]:
async def inner(*args: P.args, **kwargs: P.kwargs) -> R:
await log_to_database()
return f(*args, **kwargs)
return inner
@add_logging
def takes_int_str(x: int, y: str) -> int:
return x + 7
await takes_int_str(1, "A") # Accepted
await takes_int_str("B", 2) # Correctly rejected by the type checker
지금까지 타입을 지정할 수 없었던 또 다른 흔한 데코레이터 패턴은 데코레이트된 함수에 인자를 추가하거나 제거하는 방식입니다. 예를 들면 다음과 같습니다.
class Request:
...
def with_request(f: Callable[..., R]) -> Callable[..., R]:
def inner(*args: object, **kwargs: object) -> R:
return f(Request(), *args, **kwargs)
return inner
@with_request
def takes_int_str(request: Request, x: int, y: str) -> int:
# use request
return x + 7
takes_int_str(1, "A")
takes_int_str("B", 2) # fails at runtime
이 PEP의 Concatenate 연산자를 사용하면 이처럼 더 복잡한 데코레이터에도 타입을 지정할 수 있습니다.
from typing import Concatenate
def with_request(f: Callable[Concatenate[Request, P], R]) -> Callable[P, R]:
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
return f(Request(), *args, **kwargs)
return inner
@with_request
def takes_int_str(request: Request, x: int, y: str) -> int:
# use request
return x + 7
takes_int_str(1, "A") # Accepted
takes_int_str("B", 2) # Correctly rejected by the type checker
명세
ParamSpec 변수
선언
매개변수 명세 변수는 일반 타입 변수를 typing.TypeVar로 정의할 때와 비슷한 방식으로 정의합니다.
from typing import ParamSpec
P = ParamSpec("P") # Accepted
P = ParamSpec("WrongName") # Rejected because P =/= WrongName
런타임은 typing.TypeVar와 마찬가지로 선언에서 bound와 covariant, contravariant 인자를 받아들여야 합니다. 다만 이 옵션들의 의미를 표준화하는 일은 당분간 이후의 PEP로 미룹니다.
유효한 사용 위치
이전에는 매개변수 인자의 리스트([A, B, C])나 생략 부호(“정의되지 않은 매개변수”라는 뜻)만 typing.Callable의 첫 번째 “인자”로 허용되었습니다. 이제 여기에 두 가지 선택지를 새로 추가합니다. 하나는 매개변수 명세 변수(Callable[P, int])이고, 다른 하나는 매개변수 명세 변수에 대한 연결(Callable[Concatenate[int, P], int])입니다.
callable ::= Callable "[" parameters_expression, type_expression "]"
parameters_expression ::=
| "..."
| "[" [ type_expression ("," type_expression)* ] "]"
| parameter_specification_variable
| concatenate "["
type_expression ("," type_expression)* ","
parameter_specification_variable
"]"
여기서 parameter_specification_variable은 위에서 정의한 방식으로 선언한 typing.ParamSpec 변수이고, concatenate는 typing.Concatenate입니다.
이전과 마찬가지로 parameters_expression 자체만으로는 타입이 와야 하는 위치에 쓸 수 없습니다.
def foo(x: P) -> P: ... # Rejected
def foo(x: Concatenate[int, P]) -> int: ... # Rejected
def foo(x: typing.List[P]) -> None: ... # Rejected
def foo(x: Callable[[int, str], P]) -> None: ... # Rejected
사용자 정의 제네릭 클래스
T가 TypeVar일 때 Generic[T]를 상속하도록 클래스를 정의하면 그 클래스는 단일 매개변수에 대해 제네릭이 됩니다. 마찬가지로 P가 ParamSpec일 때 Generic[P]를 상속하도록 클래스를 정의하면 그 클래스는 parameters_expression에 대해 제네릭이 됩니다.
T = TypeVar("T")
P_2 = ParamSpec("P_2")
class X(Generic[T, P]):
f: Callable[P, int]
x: T
def f(x: X[int, P_2]) -> str: ... # Accepted
def f(x: X[int, Concatenate[int, P_2]]) -> str: ... # Accepted
def f(x: X[int, [int, bool]]) -> str: ... # Accepted
def f(x: X[int, ...]) -> str: ... # Accepted
def f(x: X[int, int]) -> str: ... # Rejected
위에서 정의한 규칙에 따르면, 단 하나의 ParamSpec에 대해서만 제네릭인 클래스의 구체적인 인스턴스를 표기하려면 보기 좋지 않은 이중 대괄호가 필요합니다. 미관상의 이유로 이 대괄호는 생략할 수 있도록 허용합니다.
class Z(Generic[P]):
f: Callable[P, int]
def f(x: Z[[int, str, bool]]) -> str: ... # Accepted
def f(x: Z[int, str, bool]) -> str: ... # Equivalent
# Both Z[[int, str, bool]] and Z[int, str, bool] express this:
class Z_instantiated:
f: Callable[[int, str, bool], int]
의미론
시그니처에 ParamSpec 변수가 포함된 함수 호출의 반환 타입 추론 규칙은 TypeVar가 포함된 호출을 평가할 때의 규칙과 유사합니다.
def changes_return_type_to_str(x: Callable[P, int]) -> Callable[P, str]: ...
def returns_int(a: str, b: bool) -> int: ...
f = changes_return_type_to_str(returns_int) # f should have the type:
# (a: str, b: bool) -> str
f("A", True) # Accepted
f(a="A", b=True) # Accepted
f("A", "A") # Rejected
expects_str(f("A", True)) # Accepted
expects_int(f("A", True)) # Rejected
기존의 TypeVars와 마찬가지로, 사용자는 여러 인자 사이의 의존성을 나타내기 위해 같은 함수의 인자에 동일한 ParamSpec을 여러 번 포함할 수 있습니다. 이러한 경우 타입 검사기는 공통된 행동적 상위 타입(즉, 유효한 모든 호출이 두 서브타입 모두에서 유효한 매개변수 집합)으로 해를 구할 수 있지만, 반드시 그래야 하는 것은 아닙니다.
P = ParamSpec("P")
def foo(x: Callable[P, int], y: Callable[P, int]) -> Callable[P, bool]: ...
def x_y(x: int, y: str) -> int: ...
def y_x(y: int, x: str) -> int: ...
foo(x_y, x_y) # Should return (x: int, y: str) -> bool
foo(x_y, y_x) # Could return (__a: int, __b: str) -> bool
# This works because both callables have types that are
# behavioral subtypes of Callable[[int, str], int]
def keyword_only_x(*, x: int) -> int: ...
def keyword_only_y(*, y: int) -> int: ...
foo(keyword_only_x, keyword_only_y) # Rejected
ParamSpec에 대해 제네릭인 사용자 정의 클래스의 생성자도 같은 방식으로 평가해야 합니다.
U = TypeVar("U")
class Y(Generic[U, P]):
f: Callable[P, str]
prop: U
def __init__(self, f: Callable[P, str], prop: U) -> None:
self.f = f
self.prop = prop
def a(q: int) -> str: ...
Y(a, 1) # Should resolve to Y[(q: int), int]
Y(a, 1).f # Should resolve to (q: int) -> str
Concatenate[X, Y, P]의 의미는 P가 나타내는 매개변수 앞에 위치 전용 매개변수 두 개를 덧붙인 매개변수를 나타낸다는 것입니다. 따라서 이를 사용하여 호출 가능 객체의 매개변수를 유한한 개수만큼 추가, 제거 또는 변환하는 고차 함수를 표현할 수 있습니다.
def bar(x: int, *args: bool) -> int: ...
def add(x: Callable[P, int]) -> Callable[Concatenate[str, P], bool]: ...
add(bar) # Should return (__a: str, x: int, *args: bool) -> bool
def remove(x: Callable[Concatenate[int, P], int]) -> Callable[P, bool]: ...
remove(bar) # Should return (*args: bool) -> bool
def transform(
x: Callable[Concatenate[int, P], int]
) -> Callable[Concatenate[str, P], bool]: ...
transform(bar) # Should return (__a: str, *args: bool) -> bool
이는 또한 R을 반환하는 모든 함수가 typing.Callable[P, R]을 만족할 수 있는 반면, 첫 번째 위치에 X를 위치 인자로 전달하여 호출할 수 있는 함수만이 typing.Callable[Concatenate[X, P], R]을 만족할 수 있다는 뜻이기도 합니다.
def expects_int_first(x: Callable[Concatenate[int, P], int]) -> None: ...
@expects_int_first # Rejected
def one(x: str) -> int: ...
@expects_int_first # Rejected
def two(*, x: int) -> int: ...
@expects_int_first # Rejected
def three(**kwargs: int) -> int: ...
@expects_int_first # Accepted
def four(*args: int) -> int: ...
이러한 기능으로도 여전히 지원되지 않는 데코레이터 유형이 몇 가지 있습니다:
- 가변 개수의 매개변수를 추가/제거/변경하는 데코레이터(예를 들어,
functools.partial은 이 PEP 이후에도 여전히 타입을 지정할 수 없습니다) - 키워드 전용 매개변수를 추가/제거/변경하는 데코레이터(자세한 내용은 Concatenating Keyword Parameters를 참조하십시오).
ParamSpec의 구성 요소
ParamSpec은 위치로 접근 가능한 매개변수와 키워드로 접근 가능한 매개변수를 모두 포착하지만, 안타깝게도 런타임에는 이 둘을 함께 포착하는 객체가 없습니다. 그래서 이들을 각각 *args와 **kwargs로 분리할 수밖에 없습니다. 따라서 하나의 ParamSpec을 이 두 구성 요소로 분리한 다음, 호출 시 다시 합칠 수 있어야 합니다. 이를 위해, 주어진 호출의 위치 인자 튜플을 나타내는 P.args와 키워드에서 값으로의 대응하는 Mapping을 나타내는 P.kwargs를 도입합니다.
유효한 사용 위치
이러한 “속성”은 이미 스코프 안에 있는 ParamSpec에서 접근하여 *args와 **kwargs의 어노테이션 타입으로만 사용할 수 있습니다.
def puts_p_into_scope(f: Callable[P, int]) -> None:
def inner(*args: P.args, **kwargs: P.kwargs) -> None: # Accepted
pass
def mixed_up(*args: P.kwargs, **kwargs: P.args) -> None: # Rejected
pass
def misplaced(x: P.args) -> None: # Rejected
pass
def out_of_scope(*args: P.args, **kwargs: P.kwargs) -> None: # Rejected
pass
또한 Python의 기본 매개변수 종류((x: int))는 위치로도, 이름으로도 지정할 수 있기 때문에, (*args: P.args, **kwargs: P.kwargs) 함수에 대한 두 가지 유효한 호출이 동일한 매개변수 집합을 서로 다르게 분할할 수 있습니다. 따라서 가능한 모든 분할에 대해 사용이 유효하도록, 이 특수 타입들이 반드시 함께 도입되고 함께 사용되도록 해야 합니다.
def puts_p_into_scope(f: Callable[P, int]) -> None:
stored_args: P.args # Rejected
stored_kwargs: P.kwargs # Rejected
def just_args(*args: P.args) -> None: # Rejected
pass
def just_kwargs(**kwargs: P.kwargs) -> None: # Rejected
pass
의미론
이러한 요구 사항이 충족되면, 이 구성이 제공하는 고유한 속성을 활용할 수 있습니다:
- 함수 내부에서
args의 타입은 일반 어노테이션에서처럼Tuple[P.args, ...]가 아니라P.args입니다(**kwargs도 마찬가지입니다)- 이 특수한 경우는 주어진 호출의
args/kwargs가 지닌 이질적인 내용을 캡슐화하기 위해 필요하며, 이러한 내용은 길이가 정해지지 않은 튜플/딕셔너리 타입으로는 표현할 수 없습니다.
- 이 특수한 경우는 주어진 호출의
Callable[P, R]타입의 함수는args가P.args타입이고kwargs가P.kwargs타입이며, 두 타입이 모두 같은 함수 선언에서 비롯된 경우에만(*args, **kwargs)로 호출할 수 있습니다.def inner(*args: P.args, **kwargs: P.kwargs) -> X로 선언된 함수는Callable[P, X]타입을 가집니다.
이 세 가지 속성을 통해, 이제 매개변수를 보존하는 데코레이터를 완전히 타입 검사할 수 있습니다.
def decorator(f: Callable[P, int]) -> Callable[P, None]:
def foo(*args: P.args, **kwargs: P.kwargs) -> None:
f(*args, **kwargs) # Accepted, should resolve to int
f(*kwargs, **args) # Rejected
f(1, *args, **kwargs) # Rejected
return foo # Accepted
이를 Concatenate까지 포함하도록 확장하기 위해, 다음 속성을 선언합니다:
Callable[Concatenate[A, B, P], R]타입의 함수는args와kwargs가 각각P의 구성 요소이고,a가A타입이며b가B타입인 경우에만(a, b, *args, **kwargs)로 호출할 수 있습니다.def inner(a: A, b: B, *args: P.args, **kwargs: P.kwargs) -> R로 선언된 함수의 타입은Callable[Concatenate[A, B, P], R]입니다.*args와**kwargs사이에 키워드 전용 매개변수를 두는 것은 금지됩니다.
def add(f: Callable[P, int]) -> Callable[Concatenate[str, P], None]:
def foo(s: str, *args: P.args, **kwargs: P.kwargs) -> None: # Accepted
pass
def bar(*args: P.args, s: str, **kwargs: P.kwargs) -> None: # Rejected
pass
return foo # Accepted
def remove(f: Callable[Concatenate[int, P], int]) -> Callable[P, None]:
def foo(*args: P.args, **kwargs: P.kwargs) -> None:
f(1, *args, **kwargs) # Accepted
f(*args, 1, **kwargs) # Rejected
f(*args, **kwargs) # Rejected
return foo
ParamSpec 구성 요소 앞에 오는 매개변수의 이름은 결과로 나오는 Concatenate에 나타나지 않는다는 점에 유의하십시오. 즉, 이러한 매개변수는 이름을 지정한 인자로 전달할 수 없습니다:
def outer(f: Callable[P, None]) -> Callable[P, None]:
def foo(x: int, *args: P.args, **kwargs: P.kwargs) -> None:
f(*args, **kwargs)
def bar(*args: P.args, **kwargs: P.kwargs) -> None:
foo(1, *args, **kwargs) # Accepted
foo(x=1, *args, **kwargs) # Rejected
return bar
이는 구현상의 편의를 위한 것이 아니라 건전성(soundness)을 위한 요구 사항입니다. 두 번째 호출 방식을 허용한다면 다음 코드 조각에서 문제가 생깁니다.
@outer
def problem(*, x: object) -> None:
pass
problem(x="uh-oh")
bar 내부에서 TypeError: foo() got multiple values for argument 'x'가 발생합니다. 이렇게 연결된 인자를 위치로만 지정하도록 요구하면 이러한 종류의 문제를 피할 수 있고, 이 타입들을 표기하는 문법도 단순해집니다. 이것이 (*args: P.args, s: str, **kwargs: P.kwargs) 형태의 시그니처를 거부해야 하는 이유이기도 하다는 점에 유의하십시오(자세한 내용은 Concatenating Keyword Parameters를 참조하십시오).
앞에 추가된 위치 매개변수 중 하나에 자유 ParamSpec이 포함되어 있으면, 해당 ParamSpec의 구성 요소를 추출하는 목적에 한해 그 변수를 스코프 안에 있는 것으로 간주합니다. 이를 통해 다음과 같은 것을 표기할 수 있습니다:
def twice(f: Callable[P, int], *args: P.args, **kwargs: P.kwargs) -> int:
return f(*args, **kwargs) + f(*args, **kwargs)
위 예제에서 twice의 타입은 Callable[Concatenate[Callable[P, int], P], int]이며, 여기서 P는 바깥쪽 Callable에 의해 바인딩됩니다. 이는 다음과 같은 의미를 갖습니다:
def a_int_b_str(a: int, b: str) -> int:
pass
twice(a_int_b_str, 1, "A") # Accepted
twice(a_int_b_str, b="A", a=1) # Accepted
twice(a_int_b_str, "A", 1) # Rejected
하위 호환성
typing의 기존 기능에 필요한 변경 사항은 이러한 ParamSpec 및 Concatenate 객체를 Callable의 첫 번째 매개변수와 Generic의 매개변수로 쓸 수 있게 허용하는 것뿐입니다. 현재 Callable은 그 자리에 타입 목록을 받고 Generic은 단일 타입을 받으므로, 현재로서는 이 둘이 서로 배타적입니다. 그 밖에 새 인터페이스를 참조하지 않는 기존 코드는 영향을 받지 않습니다.
참조 구현
Pyre 타입 검사기는 위에서 설명한 모든 동작을 지원합니다. 이러한 용도에 필요한 런타임 구성 요소의 참조 구현은 pyre_extensions 모듈에서 제공됩니다. CPython용 참조 구현은 여기에서 찾을 수 있습니다.
기각된 대안
리스트 가변 인자와 맵 가변 인자 사용
다음과 같이 리스트 형태의 가변 인자와 맵 형태의 가변 인자로 매개변수화된 콜백 프로토콜을 사용해 이와 같은 것을 만드는 방안을 고려했습니다:
R = typing.TypeVar(“R”)
Tpositionals = ...
Tkeywords = ...
class BetterCallable(typing.Protocol[Tpositionals, Tkeywords, R]):
def __call__(*args: Tpositionals, **kwargs: Tkeywords) -> R: ...
그러나 주어진 호출 가능 객체에 대해 이러한 타입 변수의 일관된 해를 찾으려 하면 몇 가지 문제가 있습니다. 이 문제는 가장 단순한 호출 가능 객체에서도 발생합니다:
def simple(x: int) -> None: ...
simple <: BetterCallable[[int], [], None]
simple <: BetterCallable[[], {“x”: int}, None]
BetterCallable[[int], [], None] </: BetterCallable[[], {“x”: int}, None]
어떤 타입이 서로 호환되지 않는 둘 이상의 방식으로 프로토콜을 구현할 수 있는 경우에는 언제든 정보를 잃는 상황이 생길 수 있습니다. 이 프로토콜을 사용해 데코레이터를 만든다면 우선할 호출 규약 하나를 골라야 합니다.
def decorator(
f: BetterCallable[[Ts], [Tmap], int],
) -> BetterCallable[[Ts], [Tmap], str]:
def decorated(*args: Ts, **kwargs: Tmap) -> str:
x = f(*args, **kwargs)
return int_to_str(x)
return decorated
@decorator
def foo(x: int) -> int:
return x
reveal_type(foo) # Option A: BetterCallable[[int], {}, str]
# Option B: BetterCallable[[], {x: int}, str]
foo(7) # fails under option B
foo(x=7) # fails under option A
여기서 핵심 문제는 Python의 매개변수가 기본적으로 위치로도, 키워드 인자로도 호출될 수 있다는 점입니다. 즉, 실제로는 세 가지 범주(위치 전용, 위치 또는 키워드, 키워드 전용)를 두 범주에 억지로 끼워 넣으려는 셈입니다. 이는 .args와 .kwargs를 논의할 때 간략히 언급했던 것과 같은 문제입니다. 근본적으로, 두 범주 중 어느 쪽에도 속할 수 있는 것이 있을 때 두 범주를 포착하려면, 세 범주를 모두 포착한 뒤 나중에 나눌 수 있는 더 높은 수준의 기본 요소(ParamSpec)가 필요합니다.
ParametersOf 정의하기
저희가 고려한 또 다른 제안은 새로 정의되는 Function 타입의 정의역에서 동작하는 ParametersOf와 ReturnType 연산자를 정의하는 것이었습니다. Function은 ParametersOf[F]로, 그리고 오직 그것으로만 호출할 수 있는 호출 가능 객체가 됩니다. ParametersOf와 ReturnType은 정확히 이 바운드를 가진 타입 변수에 대해서만 동작합니다. 이 세 가지 기능을 조합하면 ParamSpecs로 표현할 수 있는 모든 것을 표현할 수 있습니다.
F = TypeVar("F", bound=Function)
def no_change(f: F) -> F:
def inner(
*args: ParametersOf[F].args,
**kwargs: ParametersOf[F].kwargs
) -> ReturnType[F]:
return f(*args, **kwargs)
return inner
def wrapping(f: F) -> Callable[ParametersOf[F], List[ReturnType[F]]]:
def inner(
*args: ParametersOf[F].args,
**kwargs: ParametersOf[F].kwargs
) -> List[ReturnType[F]]:
return [f(*args, **kwargs)]
return inner
def unwrapping(
f: Callable[ParametersOf[F], List[R]]
) -> Callable[ParametersOf[F], R]:
def inner(
*args: ParametersOf[F].args,
**kwargs: ParametersOf[F].kwargs
) -> R:
return f(*args, **kwargs)[0]
return inner
저희는 여러 가지 이유로 이 방식 대신 ParamSpec을 택하기로 결정했습니다.
ParamSpec은 새 변수 하나만 도입하지만, 이 방식은 새 연산자 두 개와 새 타입 하나가 필요하므로 변경의 영향 범위가 더 커집니다.- 지금까지 Python 타이핑은 사용자 정의 연산자든 내장 연산자든 연산자를 지원하지 않고 구조 분해(destructuring)를 택해 왔습니다. 따라서
ParamSpec기반 시그니처가 기존 Python과 훨씬 더 비슷해 보입니다. - 사용자 정의 연산자가 없으면 흔한 패턴을 표현하기 어렵습니다.
unwrapping은F가 실제로 어떤 호출 가능 객체도 가리키지 않기 때문에 읽기에 어색합니다. 이는 단지 전파하려는 매개변수들을 담는 컨테이너로 쓰이고 있을 뿐입니다.RemoveList[List[X]] = X라는 연산자를 정의할 수 있고, 그러면unwrapping이F를 받아Callable[ParametersOf[F], RemoveList[ReturnType[F]]]를 반환할 수 있다면 더 읽기 좋을 것입니다. 그렇게 할 수 없으면, 반환 타입을 실제로 바인딩하는 일이 전혀 없다는 점에서Function변수를 임시변통의ParamSpec으로 써야 하는 상황에 안타깝게도 처하게 됩니다.
요약하자면, 표현력이 동등한 이 두 문법 중에서 ParamSpec이 현 상황에 훨씬 더 자연스럽게 들어맞습니다.
키워드 매개변수 연결하기
원칙적으로는 유한한 개수의 위치 매개변수를 수정하는 수단인 연결(concatenation)이라는 아이디어를 키워드 매개변수까지 포함하도록 확장할 수 있습니다.
def add_n(f: Callable[P, R]) -> Callable[Concatenate[("n", int), P], R]:
def inner(*args: P.args, n: int, **kwargs: P.kwargs) -> R:
# use n
return f(*args, **kwargs)
return inner
그러나 핵심적인 차이는, 유효한 호출 가능 객체 타입 앞에 위치 전용 매개변수를 덧붙이면 항상 또 다른 유효한 호출 가능 객체 타입이 되지만, 키워드 전용 매개변수를 추가하는 경우에는 그렇다고 할 수 없다는 점입니다. above에서 암시했듯이, 문제는 이름 충돌입니다. Concatenate[("n", int), P] 매개변수는 P 자체에 n이라는 이름의 매개변수가 아직 없을 때만 유효합니다.
def innocent_wrapper(f: Callable[P, R]) -> Callable[P, R]:
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
added = add_n(f)
return added(*args, n=1, **kwargs)
return inner
@innocent_wrapper
def problem(n: int) -> None:
pass
problem(2)를 호출하면 잘 동작하지만, problem(n=2)를 호출하면 innocent_wrapper 안에서 added를 호출할 때 TypeError: problem() got multiple values for argument 'n'이 발생합니다.
다음과 같은 방식으로 매개변수 집합이 특정 이름을 포함하지 않는다는 제약을 구체화할 수 있다면, 이런 상황을 피할 수 있고 이런 종류의 데코레이터에도 타입을 지정할 수 있습니다.
P_without_n = ParamSpec("P_without_n", banned_names=["n"])
def add_n(
f: Callable[P_without_n, R]
) -> Callable[Concatenate[("n", int), P_without_n], R]: ...
그러면 해당 호출 가능 객체에 n이라는 이름의 매개변수가 아직 없다는 것이 보장되지 않으므로, innocent_wrapper 안의 add_n 호출을 거부할 수 있습니다.
하지만 이러한 제약을 강제하려면 상당한 추가 구현 작업이 필요하므로, 저희는 이 확장이 이 PEP의 범위를 벗어난다고 판단했습니다. 다행히 ParamSpec은 충분한 수요가 있으면 나중에 이 아이디어로 다시 돌아올 수 있도록 설계되어 있습니다.
이것을 ParameterSpecification이라고 이름 붙이기
저희는 ParameterSpecification이 여기서 쓰기에는 다소 장황하며, 이런 식의 축약된 이름이 TypeVar와 더 비슷해 보이게 한다고 판단했습니다.
이것을 ArgSpec이라고 이름 붙이기
호출 가능 객체에는 매개변수가 있고, 매개변수는 특정 호출 지점에서 전달되는 인자와 구별되므로, 저희는 이것을 ArgSpec이라고 부르는 것보다 ParamSpec이라고 부르는 것이 더 정확하다고 생각합니다. ParamSpec에 대한 특정 바인딩은 함수 매개변수의 집합이지, 호출 지점의 인자가 아닙니다.
감사의 글
이 PEP의 초기 초안에 의견을 주시고 참조 구현을 도와주신 Pyre 팀의 모든 구성원께 감사드립니다.
Python 타이핑 밋업에서 이 아이디어에 대해 초기 피드백을 주어 훨씬 더 간결한 .args/.kwargs 문법으로 곧바로 이어지게 해 준 Python 타이핑 커뮤니티 전체에도 감사를 전합니다.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.