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

Python 개선 제안 한국어 번역

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

Table of Contents

번역·라이선스 안내

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

Important

This PEP is a historical document: see ParamSpec and typing.ParamSpec for up-to-date specs and documentation. Canonical typing specs are maintained at the typing specs site; runtime typing behaviour is described in the CPython documentation.

×

See the typing specification update process for how to propose changes to the typing spec.

초록

현재 호출 가능 객체의 타입을 지정하는 방법에는 PEP 484에 정의된 Callable[[int, str], bool] 구문과 PEP 544의 콜백 프로토콜이라는 두 가지 방법이 있습니다. 이들 어느 것도 한 호출 가능 객체의 매개변수 타입을 다른 호출 가능 객체로 전달하는 기능을 지원하지 않으므로, 함수 데코레이터에 어노테이션을 지정하기가 어렵습니다. 이 PEP에서는 이러한 종류의 관계를 표현할 수 있도록 typing.ParamSpectyping.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 기능을 함께 사용하여 수행합니다. (*args, **kwargs)를 받는 함수(예: inner)를 정의하고 이어서 (*args, **kwargs)를 사용하여 다른 함수를 호출하면, 래핑 함수는 래핑된 함수를 안전하게 호출할 수 있는 모든 방식으로만 안전하게 호출할 수 있습니다. 이 데코레이터에 타입을 지정하려면 호출 가능 객체 f의 매개변수와 반환된 함수의 매개변수 사이에 의존성을 설정할 수 있어야 합니다. PEP 484def append(l: typing.List[T], e: T) -> typing.List[T]: ...와 같이 단일 타입 간의 의존성을 지원하지만, 함수의 매개변수와 같은 복잡한 엔터티에 대해서는 그렇게 할 기존 방법이 없습니다.

현재 방식의 한계로 인해 add_logging 예제는 타입 검사를 통과하지만 런타임에 실패합니다. inner는 문자열 “B”를 takes_int_str, 에 전달하며, 이 함수는 여기에 7을 더하려 하여 타입 오류를 발생시킵니다. 데코레이트된 takes_int_strCallable[..., 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 변수이고, concatenatetyping.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

사용자 정의 제네릭 클래스

TTypeVar일 때 클래스를 Generic[T]를 상속하도록 정의하면 클래스가 하나의 매개변수에 대해 제네릭이 되는 것과 마찬가지로, PParamSpec일 때 클래스를 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변수가 포함된 함수 호출의 반환 타입에 대한 추론 규칙은 TypeVars를 사용한 항을 평가할 때의 규칙과 유사합니다.

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

ParamSpecs를 사용하는 사용자 정의 제너릭 클래스의 생성자는 동일한 방식으로 평가해야 합니다.

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, **kwargs)로 호출할 수 있으며, 이는 args의 타입이 P.args이고 kwargs의 타입이 P.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]유형의 함수는 argskwargs가 각각 P의 해당 구성 요소이고, aA유형이며 bB유형일 때만 (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

이는 구현상의 편의를 위한 것이 아니라 건전성을 위한 요구 사항입니다. 두 번째 호출 방식을 허용한다면 다음 코드 조각에 문제가 발생합니다.

@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의 기존 기능에 필요한 유일한 변경 사항은 이러한 ParamSpecConcatenate 객체를 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 타입의 영역에서 작동하는 ParametersOfReturnType 연산자를 정의하는 방안도 고려했습니다. FunctionParametersOf[F] 만을 사용하여 호출할 수 있는 호출 가능 객체가 됩니다. ParametersOfReturnType 은 정확히 이 바운드를 갖는 타입 변수에 대해서만 작동합니다. 이 세 가지 기능을 조합하면 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 타이핑은 지금까지 사용자 정의 연산자이든 내장 연산자이든 관계없이 연산자 지원을 피하고 구조 분해를 선호해 왔습니다. 따라서 ParamSpec 기반 시그니처는 기존 Python의 형태와 훨씬 더 비슷해 보입니다.
  • 사용자 정의 연산자가 없으면 일반적인 패턴을 표현하기가 어렵습니다. unwrappingF가 실제로 어떤 호출 가능 객체도 가리키지 않기 때문에 읽기에 어색합니다. 이는 단지 전파하려는 매개변수의 컨테이너로 사용될 뿐입니다. RemoveList[List[X]] = X와 같은 연산자를 정의할 수 있다면 더 자연스럽게 읽힐 것이며, 그러면 unwrappingF를 받아 Callable[ParametersOf[F], RemoveList[ReturnType[F]]]를 반환할 수 있습니다. 그렇게 하지 않으면 실제로 반환 타입을 바인딩하지 않으면서 Function 변수​를 임시 ParamSpec으로 사용해야 하는 상황에 안타깝게도 처하게 됩니다.

요약하면, 동등한 표현력을 가진 이 두 문법 중에서 ParamSpec이 현재 방식에 훨씬 더 자연스럽게 들어맞습니다.

키워드 매개변수 연결

원칙적으로 유한한 수의 위치 매개변수를 수정하는 수단으로서 연결이라는 개념은 키워드 매개변수까지 포함하도록 확장할 수 있습니다.

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 타이핑 밋업(meetup)에서 이 아이디어에 대해 초기 피드백을 주어 훨씬 더 간결한 .args/.kwargs 구문으로 직접 이어지게 해준 전체 Python 타이핑 커뮤니티에도 감사를 드립니다.