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

Python 개선 제안 한국어 번역

PEP 677 – 호출 가능 객체 타입 구문

Author:
Steven Troxler <steven.troxler at gmail.com>, Pradeep Kumar Srinivasan <gohanpra at gmail.com>
Sponsor:
Guido van Rossum <guido at python.org>
Discussions-To:
Python-Dev list
Status:
Rejected
Type:
Standards Track
Topic:
Typing
Created:
13-Dec-2021
Python-Version:
3.11
Post-History:
16-Dec-2021
Resolution:
Python-Dev message

Table of Contents

번역·라이선스 안내

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

초록

이 PEP는 호출 가능 객체 타입을 위한 간결하고 친숙한 구문을 도입하며, 타입이 지정된 함수 시그니처 구문에서 영감을 얻은 화살표 구문을 사용하면서 typing.Callable과 동일한 기능을 지원합니다. 이를 통해 Callable[[int, str], bool]과 같은 타입을 (int, str) -> bool로 작성할 수 있습니다.

제안된 구문은 typing.Callabletyping.Concatenate가 제공하는 모든 기능을 지원하며, 이를 그대로 대체하여 사용할 수 있도록 설계되었습니다.

동기

코드를 더 안전하고 분석하기 쉽게 만드는 한 가지 방법은 함수와 클래스의 타입이 올바르게 지정되었는지 확인하는 것입니다. Python에는 타입 힌트를 제공하는 타입 어노테이션이 있으며, 그 프레임워크는 PEP 484에 정의되어 있습니다. 타입 힌트는 버그를 찾을 수 있게 할 뿐만 아니라, 탭 완성 같은 편집기 도구, 정적 분석 도구 및 코드 검토에도 도움을 줍니다.

다음의 타입이 지정되지 않은 코드를 살펴봅니다.:

def flat_map(func, l):
    out = []
    for element in l:
        out.extend(func(element))
    return out


def wrap(x: int) -> list[int]:
    return [x]

def add(x: int, y: int) -> int:
    return x + y

flat_map(wrap, [1, 2, 3])  # no runtime error, output is [1, 2, 3]
flat_map(add, [1, 2, 3])   # runtime error: `add` expects 2 arguments, got 1

이 예제에 타입을 추가하여 런타임 오류를 감지할 수 있습니다.:

from typing import Callable

def flat_map(
    func: Callable[[int], list[int]],
    l: list[int]
) -> list[int]:
    ....

...


flat_map(wrap, [1, 2, 3])  # type checks okay, output is [1, 2, 3]
flat_map(add, [1, 2, 3])   # type check error

여기에서 Callable의 사용성에 관한 몇 가지 문제를 확인할 수 있습니다.

  • 특히 더 복잡한 함수 시그니처에서는 장황합니다.
  • 다른 어떤 제네릭 타입과도 달리, 중첩된 대괄호를 두 단계로 사용합니다. 일부 타입 매개변수 자체가 제네릭 타입일 때 특히 읽기 어려울 수 있습니다.
  • 대괄호 구조가 함수 시그니처를 작성하는 방식과 시각적으로 유사하지 않습니다.
  • listdict 같은 다른 일반적인 타입 대부분과 달리, 명시적인 임포트가 필요합니다.

그 결과일 수도 있지만, 프로그래머는 완전한 호출 가능 객체 타입을 작성하지 못하는 경우가 많습니다. 이렇게 타입이 지정되지 않았거나 부분적으로만 타입이 지정된 호출 가능 객체 타입은 주어진 호출 가능 객체의 매개변수 타입이나 반환 타입을 검사하지 않으므로 정적 타이핑의 이점을 무효화합니다. 예를 들어 다음과 같이 작성할 수 있습니다.:

from typing import Callable

def flat_map(
    func: Callable[..., Any],
    l: list[int]
) -> list[int]:
    ....

...


flat_map(add, [1, 2, 3])  # oops, no type check error!

여기에는 일부 타입 정보가 있습니다. 적어도 func가 호출 가능 객체라는 것은 알고 있습니다. 그러나 타입 검사기가 버그를 찾는 데 필요한 타입 정보를 너무 많이 버렸습니다.

우리의 제안에 따르면 예제는 다음과 같이 표시됩니다.:

def flat_map(
    func: (int) -> list[int],
    l: list[int]
) -> list[int]:
    out = []
    for element in l:
        out.extend(f(element))
    return out

...

(int) -> list[int] 타입은 더 간결하고, 함수 헤더에서 반환 타입을 나타내는 화살표와 유사한 화살표를 사용하며, 중첩된 대괄호를 피하고, 임포트가 필요하지 않습니다.

근거

Callable 타입은 널리 사용됩니다. 예를 들어, 2021년 10월 기준으로typeshed에서 Optional, Tuple, Union, List에 이어 다섯 번째로 많이 사용되는 복합 타입이었습니다.

다른 타입들은 PEP 604 또는 PEP 585에 의해 구문이 개선되고 임포트가 필요하지 않게 되었습니다.

  • typing.Optional[int]int | None으로 작성합니다.
  • typing.Union[int, str]int | str으로 작성합니다.
  • typing.List[int]list[int]으로 작성합니다.
  • typing.Tuple[int, str]tuple[int, str]으로 작성합니다.

typing.Callable 타입은 이러한 다른 타입만큼 자주 사용되지만 읽고 쓰기가 더 복잡하고, 여전히 임포트와 대괄호 기반 구문이 필요합니다.

이 제안에서는 새로운 기능에 대한 지원을 추가하지 않고 typing.Callable의 기존 의미 체계를 모두 지원하기로 했습니다. 기존의 타입 지정 및 타입 미지정 오픈 소스 코드에서 각 기능이 얼마나 자주 사용될 수 있는지 검토한 후 이러한 결정을 내렸습니다. 대부분의 사용 사례가 다뤄진다고 판단했습니다.

이름 있는 인자, 선택적 인자 및 가변 인자에 대한 지원을 추가하는 방안을 고려했습니다. 그러나 분석 결과 이러한 기능은 자주 사용되지 않는 것으로 나타났으므로 포함하지 않기로 했습니다. 이러한 기능이 실제로 필요한 경우 callback protocols를 사용하여 타입을 지정할 수 있습니다.

호출 가능 객체 타입을 위한 화살표 구문

Python의 함수 헤더와 유사한 typing.Callable용 간결하고 사용하기 쉬운 구문을 제안합니다. 이 제안은 Typescript, KotlinScala와 같은 여러 인기 언어에서 사용하는 구문을 밀접하게 따릅니다.

다음을 목표로 합니다:

  • 이 구문을 사용하는 호출 가능 객체 타입은 특히 다른 언어를 사용해 본 경험이 있는 개발자가 더 쉽게 배우고 사용할 수 있습니다.
  • 라이브러리 작성자는 위의 decorator 예시처럼 타입 검사기가 코드를 더 잘 이해하고 버그를 찾을 수 있도록 하는 표현력 있는 호출 가능 객체 타입을 사용할 가능성이 높아집니다.

기존 typing.Callable을 사용하여 작성한 웹 서버의 단순화된 실제 사례를 고려하십시오:

from typing import Awaitable, Callable
from app_logic import Response, UserSetting


def customize_response(
    response: Response,
    customizer: Callable[[Response, list[UserSetting]], Awaitable[Response]]
) -> Response:
   ...

이 제안을 사용하면 이 코드를 다음과 같이 줄여 쓸 수 있습니다:

from app_logic import Response, UserSetting

def customize_response(
    response: Response,
    customizer: async (Response, list[UserSetting]) -> Response,
) -> Response:
    ...

더 짧고 필요한 임포트도 적습니다. 또한 대괄호의 중첩이 훨씬 적습니다. 원래 코드에서는 세 단계였지만 여기서는 한 단계뿐입니다.

ParamSpec을 위한 간결한 구문

라이브러리 작성자가 호출 가능 객체의 타입 정보를 생략하는 특히 흔한 경우는 데코레이터를 정의할 때입니다. 다음을 고려하십시오:

from typing import Any, Callable

def with_retries(
    f: Callable[..., Any]
) -> Callable[..., Any]:
    def wrapper(retry_once, *args, **kwargs):
        if retry_once:
            try: return f(*args, **kwargs)
            except Exception: pass
        return f(*args, **kwargs)
    return wrapper

@with_retries
def f(x: int) -> int:
    return x


f(y=10)  # oops - no type error!

위 코드에서는 데코레이터가 추가적인 retry_once 인자를 제외하면 인자 f와 같은 시그니처를 가진 함수를 생성해야 한다는 점이 분명합니다. 그러나 ...의 사용으로 인해 타입 검사기는 이를 확인하지 못하며 f(y=10)이 잘못되었다는 사실을 사용자에게 알릴 수 없습니다.

이와 같이 PEP 612를 사용하면 데코레이터에 다음과 같이 올바르게 타입을 지정할 수 있습니다:

from typing import Any, Callable, Concatenate, ParamSpec, TypeVar

R = TypeVar("R")
P = ParamSpec("P")

def with_retries(
    f: Callable[P, R]
) -> Callable[Concatenate[bool, P] R]:
    def wrapper(retry_once: bool, *args: P.args, **kwargs: P.kwargs) -> R:
        ...
    return wrapper

...

제안한 구문을 사용하면 올바르게 타입이 지정된 데코레이터 예시가 간결해지고 타입 표현도 시각적으로 설명력이 높아집니다.:

from typing import Any, ParamSpec, TypeVar

R = TypeVar("R")
P = ParamSpec("P")

def with_retries(
    f: (**P) -> R
) -> (bool, **P) -> R:
    ...

다른 언어와의 비교

많은 인기 프로그래밍 언어에서 여기서 제안하는 것과 유사한 화살표 구문을 사용합니다.

TypeScript

TypeScript에서는 함수 타입을 우리가 제안하는 것과 거의 동일한 구문으로 표현하지만, 화살표 토큰은 =>이고 인자에는 이름이 있습니다.:

(x: int, y: str) => bool

인자의 이름은 실제로 타입과 관련이 없습니다. 따라서 예를 들어 다음은 동일한 호출 가능 객체 타입입니다.:

(a: int, b: str) => bool

Kotlin

Kotlin의 함수 타입에서는 우리가 제안하는 것과 동일한 구문을 사용할 수 있습니다. 예를 들면 다음과 같습니다.:

(Int, String) -> Bool

또한 선택적으로 인자에 이름을 추가할 수 있습니다. 예를 들면 다음과 같습니다.:

(x: Int, y: String) -> Bool

TypeScript에서와 마찬가지로, 인자 이름은 제공되는 경우에도 문서화를 위한 것일 뿐이며 타입 자체의 일부가 아닙니다.

Scala

Scala 는 함수 타입에 => 화살표를 사용합니다. 그 외에는 해당 구문이 우리가 제안하는 구문과 동일합니다. 예를 들어:

(Int, String) => Bool

Scala는 Python과 마찬가지로 함수 인자를 이름으로 제공할 수 있습니다. 함수 타입에는 선택적으로 이름을 포함할 수 있습니다. 예를 들어:

(x: Int, y: String) => Bool

TypeScript 및 Kotlin과 달리, 이 이름들은 제공되는 경우 타입의 일부입니다 - 해당 타입을 구현하는 모든 함수는 동일한 이름을 사용해야 합니다. 이는 Rejected Alternatives 섹션에서 설명하는 확장 구문 제안과 유사합니다.

함수 정의와 호출 가능 객체 타입 어노테이션

위에 나열된 모든 언어에서 함수 정의의 타입 어노테이션에는 :->대신 사용합니다. 예를 들어 TypeScript에서 간단한 add 함수는 다음과 같습니다.:

function higher_order(fn: (a: string) => string): string {
  return fn("Hello, World");
}

Scala와 Kotlin은 반환 어노테이션에 본질적으로 동일한 : 구문을 사용합니다. 이 언어들에서 매개변수와 변수의 타입 어노테이션에 모두 :를 사용하고, 함수 반환 타입에도 비슷하게 사용하므로 :가 적절합니다.

Python에서는 :로 함수 본문의 시작을 나타내고 ->를 반환 어노테이션에 사용합니다. 따라서 우리의 제안은 표면적으로는 이러한 다른 언어들과 동일해 보이지만 맥락은 다릅니다. 호출 가능 객체 타입을 포함하는 함수 정의를 읽을 때 Python에서는 혼동이 더 커질 가능성이 있습니다.

이는 우리가 초안 PEP에 대한 피드백을 구하고 있는 핵심 우려 사항입니다. 더 쉽게 구별할 수 있도록 대신 =>를 사용하자는 아이디어를 제안한 바 있습니다.

ML 언어 계열

F#, OCaml, Haskell 을 비롯한 ML 계열의 언어는 모두 함수 타입을 나타내는 데 ->를 사용합니다. 이 언어들은 모두 여러 화살표를 사용하는 괄호 없는 구문을 사용합니다. 예를 들어 Haskell에서는:

Integer -> String -> Bool

우리의 제안과 다른 여러 화살표의 사용 방식은 이 계열의 언어에 적합합니다. 이러한 언어는 함수 인자를 자동으로 currying 을 수행하므로, 여러 인자를 받는 함수가 함수를 반환하는 단일 인자 함수처럼 동작하기 때문입니다.

사양

타입 지정 동작

타입 검사기는 새로운 구문을 typing.Callable과 정확히 동일한 의미 체계로 처리해야 합니다.

따라서 타입 검사기는 다음 쌍을 정확히 동일하게 처리해야 합니다.:

from typing import Awaitable, Callable, Concatenate, ParamSpec, TypeVarTuple

P = ParamSpec("P")
Ts = TypeVarTuple('Ts')

f0: () -> bool
f0: Callable[[], bool]

f1: (int, str) -> bool
f1: Callable[[int, str], bool]

f2: (...) -> bool
f2: Callable[..., bool]

f3: async (str) -> str
f3: Callable[[str], Awaitable[str]]

f4: (**P) -> bool
f4: Callable[P, bool]

f5: (int, **P) -> bool
f5: Callable[Concatenate[int, P], bool]

f6: (*Ts) -> bool
f6: Callable[[*Ts], bool]

f7: (int, *Ts, str) -> bool
f7: Callable[[int, *Ts, str], bool]

문법 및 AST

제안하는 새로운 구문은 Parser/Python.asdl에 대한 다음 AST 변경 사항으로 설명할 수 있습니다.:

expr = <prexisting_expr_kinds>
     | AsyncCallableType(callable_type_arguments args, expr returns)
     | CallableType(callable_type_arguments args, expr returns)

callable_type_arguments = AnyArguments
                        | ArgumentsList(expr* posonlyargs)
                        | Concatenation(expr* posonlyargs, expr param_spec)

다음은 Python Grammar에 대해 제안하는 변경 사항입니다.:

expression:
    | disjunction disjunction 'else' expression
    | callable_type_expression
    | disjunction
    | lambdef

callable_type_expression:
    | callable_type_arguments '->' expression
    | ASYNC callable_type_arguments '->' expression

callable_type_arguments:
    | '(' '...' [','] ')'
    | '(' callable_type_positional_argument*  ')'
    | '(' callable_type_positional_argument* callable_type_param_spec ')'

callable_type_positional_argument:
    | !'...' expression ','
    | !'...' expression &')'

callable_type_param_spec:
    | '**' expression ','
    | '**' expression &')'

관련 PEP 646이 승인되면 언패킹된 타입을 두 가지 방식으로 지원할 예정입니다. 관련 PEP 646에서 제안한 “언패킹용 별표” 구문을 지원하기 위해 callable_type_positional_argument의 문법을 다음과 같이 수정할 예정입니다.:

callable_type_positional_argument:
    | !'...' expression ','
    | !'...' expression &')'
    | '*' expression ','
    | '*' expression &')'

이 변경으로 (int, *Ts) -> bool형식의 타입은 다음 AST 형식으로 평가되어야 합니다.:

CallableType(
    ArgumentsList(Name("int"), Starred(Name("Ts")),
    Name("bool")
)

그리고 타입 검사기는 이를 Callable[[int, *Ts], bool] 또는 Callable[[int, Unpack[Ts]], bool]와 동등한 것으로 처리해야 합니다.

문법의 함의

->의 우선순위

->는 타입 내부와 함수 시그니처 모두에서 다른 연산자보다 결합 우선순위가 낮으므로 다음 두 호출 가능 객체 타입은 동등합니다.:

(int) -> str | bool
(int) -> (str | bool)

->는 타입 내부와 함수 시그니처 모두에서 오른쪽으로 결합합니다. 따라서 다음 쌍은 동등합니다.:

(int) -> (str) -> bool
(int) -> ((str) -> bool)

def f() -> (int, str) -> bool: pass
def f() -> ((int, str) -> bool): pass

def f() -> (int) -> (str) -> bool: pass
def f() -> ((int) -> ((str) -> bool)): pass

연산자는 ->보다 결합 우선순위가 높으므로, |와 같은 연산자의 인자 내부에 화살표 타입을 사용하려는 경우에는 괄호가 필요합니다.:

(int) -> () -> int | () -> bool      # syntax error!
(int) -> (() -> int) | (() -> bool)  # okay

이러한 동작을 각각 논의했으며 바람직하다고 생각합니다.

  • 유니언 타입(A | BPEP 604에 따른 표현입니다)은 함수 시그니처의 반환값에서 유효하므로, 일관성을 위해 반환 위치에서 연산자를 허용해야 합니다.
  • 연산자가 ->보다 결합 우선순위가 높다는 점을 고려하면 bool | () -> bool과 같은 타입이 구문 오류여야 하는 것은 올바릅니다. 이는 흔히 발생할 수 있는 실수이므로 오류 메시지가 명확한지 확인해야 합니다.
  • 명시적인 괄호를 요구하는 대신 ->를 오른쪽으로 결합하는 방식은 TypeScript와 같은 다른 언어와 일관되며, 가능한 경우 유효한 표현식을 일반적으로 서로 치환할 수 있어야 한다는 원칙을 따릅니다.

async 키워드

모든 결합 규칙은 비동기 호출 가능 객체 타입에서도 그대로 적용됩니다.:

(int) -> async (float) -> str | bool
(int) -> (async (float) -> (str | bool))

def f() -> async (int, str) -> bool: pass
def f() -> (async (int, str) -> bool): pass

def f() -> async (int) -> async (str) -> bool: pass
def f() -> (async (int) -> (async (str) -> bool)): pass

후행 쉼표

  • 함수 시그니처의 선례에 따라 빈 인자 목록에 쉼표를 넣는 것은 허용되지 않습니다. (,) -> bool은 구문 오류입니다.
  • 다시 선례에 따라 후행 쉼표는 그 외의 경우 항상 허용됩니다.:
    ((int,) -> bool == (int) -> bool
    ((int, **P,) -> bool == (int, **P) -> bool
    ((...,) -> bool) == ((...) -> bool)
    

후행 쉼표를 허용하면 호출 가능 객체 타입을 여러 줄로 나눌 때 자동 포매터에 더 많은 유연성을 제공하며, 이는 표준 Python 공백 규칙에 따라 항상 허용됩니다.

인자 타입으로 ...를 허용하지 않음

일반적인 상황에서는 타입 어노테이션이 필요한 위치에 유효한 표현식을 사용할 수 있으며 ...도 유효한 표현식입니다. 이는 의미론적으로는 절대 유효하지 않아 모든 타입 검사기가 거부하지만, 명시적으로 방지하지 않는다면 문법상 허용됩니다.

...는 타입으로서 의미가 없고 사용성에 대한 우려도 있으므로, 문법에서 이를 배제하며 다음은 구문 오류입니다.:

(int, ...) -> bool

여기에는 그렇게 해야 할 설득력 있는 이유가 있다고 판단했습니다.

  • (...) -> bool의 의미는 유효한 모든 타입 T에 대한 (T) -> bool의 의미와 다릅니다. (...)AnyArguments를 나타내는 특수 형식인 반면, T는 인자 목록의 타입 매개변수입니다.
  • ...는 스텁과 콜백 프로토콜에서 선택적 인자를 나타내는 자리 표시자 기본값으로 사용됩니다. 이를 타입 위치에서 허용하면 쉽게 혼란을 일으키고 오타로 인해 버그가 발생할 수도 있습니다.
  • tuple 제네릭 타입에서는 ...를 “같은 항목이 더 있음”이라는 의미로 특수 처리합니다. 예를 들어 tuple[int, ...]은 정수가 하나 이상 포함된 튜플을 의미합니다. 호출 가능 객체 타입에서는 ...를 이와 비슷한 방식으로 사용하지 않으므로, 오해를 방지하기 위해 이를 막는 것이 타당합니다.

***의 다른 가능한 사용과의 비호환성

**P를 사용하여 PEP 612ParamSpec을 지원하면, 일반적인 **<some_type>을 사용하여 kwargs의 타입을 지정하는 향후 제안은 배제됩니다. 다음과 같은 이유로 이는 허용할 만합니다.

  • 그러한 구문을 정말 원하게 되더라도, 어쨌든 인자 이름을 요구하는 편이 더 명확합니다. 이렇게 하면 타입이 함수 시그니처와 더 비슷하게 보이기도 합니다. 다시 말해, 호출 가능 객체 타입에서 kwargs의 타입 지정을 지원한다면 (int, **kwargs: str)(int, **str)보다 선호합니다.
  • PEP 646 언패킹 구문 때문에 args*<some_type>을 사용하는 것은 배제됩니다. kwargs의 경우도 충분히 유사하므로, 이 때문에 아무것도 지정하지 않은 **<some_type> 역시 배제됩니다.

화살표 기반 람다 구문과의 호환성

제가 아는 한 화살표 스타일 람다 구문에 관한 활발한 논의는 없지만, 그럼에도 이 제안을 채택함으로써 어떤 가능성이 배제될지 고려해 볼 가치는 있습니다.

람다에 동일한 괄호로 묶인 -> 기반 화살표 구문을 채택하는 것은 이 제안과 호환되지 않습니다. 예를 들어 lambda x, y: x + y(x, y) -> x + y를 사용하는 경우입니다.

앞으로 람다에 화살표 구문을 사용하고자 한다면 =>를 사용하는 편이 더 나은 선택이라고 생각합니다. 예를 들어 (x, y) => x + y와 같습니다. 많은 언어가 람다와 호출 가능 객체 타입 모두에 동일한 화살표 토큰을 사용하지만, Python은 타입이 표현식이며 런타임 값으로 평가되어야 한다는 점에서 독특합니다. 따라서 별도의 토큰을 사용하는 것이 타당하다고 생각하며, 함수 시그니처의 반환 타입에 이미 ->를 사용하고 있다는 점을 고려하면 호출 가능 객체 타입에는 ->를, 람다에는 =>를 사용하는 편이 더 일관됩니다.

런타임 동작

새로운 AST 노드는 런타임 타입으로 평가되어야 하며, 이러한 런타임 타입의 동작에 대해서는 두 가지 목표가 있습니다.

  • 이름 있는 인자와 가변 인자 같은 새로운 기능을 포함하도록 타입을 확장하는 것과 호환될 만큼 설명적이고 강력한 구조화된 API를 제공해야 합니다.
  • 또한 typing.Callable과 하위 호환성을 갖는 API를 제공해야 합니다.

평가 및 구조화된 API

새로운 AST 노드가 평가될 새로운 내장 타입을 만들고, 이를 types 모듈에 노출할 예정입니다.

다음과 같이 정의된 것처럼 구조화된 API를 노출할 계획입니다.:

class CallableType:
    is_async: bool
    arguments: Ellipsis | tuple[CallableTypeArgument]
    return_type: object

class CallableTypeArgument:
    kind: CallableTypeArgumentKind
    annotation: object

@enum.global_enum
class CallableTypeArgumentKind(enum.IntEnum):
    POSITIONAL_ONLY: int = ...
    PARAM_SPEC: int = ...

평가 규칙은 다음 의사 코드로 표현됩니다.:

def evaluate_callable_type(
    callable_type: ast.CallableType | ast.AsyncCallableType:
) -> CallableType:
    return CallableType(
       is_async=isinstance(callable_type, ast.AsyncCallableType),
       arguments=_evaluate_arguments(callable_type.arguments),
       return_type=evaluate_expression(callable_type.returns),
    )

def _evaluate_arguments(arguments):
    match arguments:
        case ast.AnyArguments():
            return Ellipsis
        case ast.ArgumentsList(posonlyargs):
            return tuple(
                _evaluate_arg(arg) for arg in args
            )
        case ast.ArgumentsListConcatenation(posonlyargs, param_spec):
            return tuple(
                *(evaluate_arg(arg) for arg in args),
                _evaluate_arg(arg=param_spec, kind=PARAM_SPEC)
            )
        if isinstance(arguments, Any
    return Ellipsis

def _evaluate_arg(arg, kind=POSITIONAL_ONLY):
    return CallableTypeArgument(
        kind=POSITIONAL_ONLY,
        annotation=evaluate_expression(value)
    )

하위 호환 API

__args____parameters__필드에 의존하는 기존 types.CallableAPI와 하위 호환성을 확보하기 위해, 다음과 같은 방식으로 작성된 것처럼 이를 정의할 수 있습니다.:

import itertools
import typing

def get_args(t: CallableType) -> tuple[object]:
    return_type_arg = (
        typing.Awaitable[t.return_type]
        if t.is_async
        else t.return_type
    )
    arguments = t.arguments
    if isinstance(arguments, Ellipsis):
        argument_args = (Ellipsis,)
    else:
        argument_args = (arg.annotation for arg in arguments)
    return (
        *arguments_args,
        return_type_arg
    )

def get_parameters(t: CallableType) -> tuple[object]:
    out = []
    for arg in get_args(t):
        if isinstance(arg, typing.ParamSpec):
            out.append(t)
        else:
            out.extend(arg.__parameters__)
    return tuple(out)

types.CallableType의 추가 동작

관련 PEP 604에서 도입된 유니온용 A | B 구문과 마찬가지로:

  • __eq__메서드는 동등한 typing.Callable값을 내장 구문을 사용해 구성한 값과 같은 것으로 처리해야 하며, 그 외에는 typing.Callable__eq__와 같이 동작해야 합니다.
  • __repr__메서드는 평가했을 때 동일한 types.CallableType인스턴스를 돌려주는 화살표 구문 표현을 생성해야 합니다.

거부된 대안

검토한 많은 대안은 typing.Callable보다 표현력이 뛰어났습니다. 예를 들어 이름 있는 인자, 선택적 인자 및 가변 인자를 포함하는 시그니처를 설명하는 기능을 추가하는 방안이 있습니다.

호출 가능 객체 타입 구문에서 어떤 기능을 가장 우선적으로 지원해야 하는지 결정하기 위해 기존 프로젝트를 광범위하게 분석했습니다.

대다수의 콜백은 기존 typing.Callable의미 체계로 올바르게 설명할 수 있으므로, 기존 Callable타입에 개선된 구문을 추가하는 간단한 제안을 채택했습니다.

  • 위치 매개변수: 가장 잘 처리해야 하는 경우는 (int, str) -> bool과 같은 위치 인자를 사용하는 단순한 호출 가능 객체 타입입니다.
  • ParamSpec 및 Concatenate: 다음으로 중요한 기능은 (**P) -> bool(int, **P) -> bool과 같은 PEP 612 ParamSpecConcatenate 타입을 잘 지원하는 것입니다. 이는 Python 코드에서 데코레이터 패턴이 많이 사용되기 때문에 주로 흔하게 사용됩니다.
  • TypeVarTuples: PEP 646이 승인된다고 가정할 때, 다음으로 중요한 기능은 언패킹된 타입을 지원하는 것입니다. 이러한 타입은 래퍼가 *args를 다른 함수에 전달하는 경우 때문에 흔하게 사용됩니다.

더 복잡한 다른 제안들이 지원할 기능은 우리가 발견한 사용 사례의 2% 미만을 차지합니다. 이러한 기능은 이미 콜백 프로토콜을 사용하여 표현할 수 있으며, 흔하지도 않으므로 더 단순한 구문으로 진행하는 것이 합리적이라고 결정했습니다.

명명된 인자와 선택적 인자를 지원하는 확장 구문

또 다른 대안은 이 PEP의 모든 내용을 표현하면서 명명된 인자, 선택적 인자 및 가변 인자도 표현할 수 있는 호환 가능하지만 더 복잡한 구문이었습니다. 이 “확장” 구문 제안에서는 다음 타입들이 동등했을 것입니다.:

class Function(typing.Protocol):
    def f(self, x: int, /, y: float, *, z: bool = ..., **kwargs: str) -> bool:
        ...

Function = (int, y: float, *, z: bool = ..., **kwargs: str) -> bool

이 구문의 장점은 다음과 같습니다: - 이 PEP의 제안이 가진 장점 대부분(간결성, PEP 612 지원 등) - 또한 명명된 인자, 선택적 인자 및 가변 인자를 처리할 수 있습니다.

다음과 같은 이유로 이 구문을 제안하지 않기로 결정했습니다.

  • 구현이 더 어려웠을 것이며, 사용 통계에 따르면 추가된 기능 중 어느 것이라도 혜택을 받을 사용 사례는 3% 미만입니다.
  • 이러한 제안들을 논의한 그룹은 이러한 변경 사항이 바람직한지에 대해 정확히 양분되었습니다.
    • 한편으로는 호출 가능 객체 타입을 더욱 표현력 있게 만듭니다. 다른 한편으로는 호출 가능 객체 타입 구문의 전체 명세를 읽지 않은 사용자를 쉽게 혼란스럽게 할 수 있습니다.
    • 새로운 의미론을 도입하지 않고 Kotlin, Scala 및 TypesScript와 같은 다른 인기 언어의 구문을 밀접하게 모방하는 이 PEP의 더 단순한 구문 제안이 사용자를 혼란스럽게 할 가능성이 훨씬 낮다고 생각합니다.
  • 현재 제안을 더 복잡한 확장 구문과 하위 호환되도록 구현할 계획입니다. 커뮤니티가 더 많은 경험과 논의 후에 추가 기능을 원한다고 결정한다면, 향후 이를 제안하는 일은 간단할 것입니다.
  • 완전한 확장 구문이라도 오버로드에 콜백 프로토콜을 사용하는 것을 대체할 수는 없습니다. 예를 들어, bool을 bool로 매핑하고 int를 float로 매핑하는 함수를 이 콜백 프로토콜과 같이 표현할 수 있는 호출 가능 객체 타입의 폐쇄형 형식은 없습니다.:
    from typing import overload, Protocol
    
    class OverloadedCallback(Protocol)
    
      @overload
      def __call__(self, x: int) -> float: ...
    
      @overload
      def __call__(self, x: bool) -> bool: ...
    
      def __call__(self, x: int | bool) -> float | bool: ...
    
    
    f: OverloadedCallback = ...
    f(True)  # bool
    f(3)     # float
    

이 PEP의 문법에 대한 참조 구현 위에 이 확장 구문의 문법과 AST를 implementing하여 현재 제안이 확장 구문과 하위 호환된다는 것을 확인했습니다.

함수 시그니처에 더 가까운 구문

우리가 제시했던 한 가지 대안은 함수 시그니처와 훨씬 더 유사한 구문이었습니다.

이 제안에서는 다음 타입들이 동등했을 것입니다.:

class Function(typing.Protocol):
    def f(self, x: int, /, y: float, *, z: bool = ..., **kwargs: str) -> bool:
        ...

Function = (x: int, /, y: float, *, z: bool = ..., **kwargs: str) -> bool

이 제안의 장점은 다음과 같습니다.

  • 시그니처와 호출 가능 객체 타입 간의 완벽한 구문적 일관성입니다.
  • 이 PEP가 지원하지 않는 함수 시그니처의 더 많은 기능(명명된 인자, 선택적 인자, 가변 인자)을 지원합니다.

이 아이디어를 거부하게 만든 주요 단점은 다음과 같습니다.

  • 사용 사례의 대다수는 위치 전용 인자만 사용합니다. 이 구문은 인자 이름과 명시적인 /를 요구하기 때문에 해당 사용 사례에서 더 장황합니다. 예를 들어 우리 제안에서는 (int) -> bool을 허용하는 반면 (int, /) -> bool과 같이 작성해야 합니다.
  • 위치 전용 인자에 명시적인 /를 요구하면 라이브러리 작성자가 실수로 명명된 인자를 사용하는 빈번한 버그가 발생할 위험이 높으며, 이러한 버그는 단위 테스트에서 발견되지 않는 경우가 많습니다.
  • 분석 결과 ParamSpec지원이 핵심이지만, PEP 612에 제시된 스코핑 규칙으로 인해 이를 구현하기가 어려웠을 것입니다.

검토한 다른 제안들

함수-타입

아주 초기에 검토했던 아이디어 중 하나는 함수를 타입으로 사용할 수 있도록 허용하는 것이었습니다. 이 아이디어는 함수가 자신의 호출 시그니처를 대신하도록 허용하는 것으로, 콜백 프로토콜의 __call__ 메서드와 대략 동일한 의미론을 가집니다.:

def CallableType(
    positional_only: int,
    /,
    named: str,
    *args: float,
    keyword_only: int = ...,
    **kwargs: str
) -> bool: ...

f: CallableType = ...
f(5, 6.6, 6.7, named=6, x="hello", y="world")  # typechecks as bool

좋은 아이디어일 수 있지만, 이를 호출 가능 객체 타입의 실현 가능한 대체 수단으로는 여기지 않습니다.

  • 지원해야 할 중요한 기능이라고 생각하는 ParamSpec을 처리하기가 어렵습니다.
  • 함수를 타입으로 사용하면 호출 가능 객체 타입은 일급 값이 아닙니다. 대신 타입 별칭을 정의하려면 별도의 함수 정의가 필요합니다.
  • 콜백 프로토콜보다 더 많은 기능을 지원하지 않으며, Callable의 대체 수단이라기보다는 콜백 프로토콜을 더 짧게 작성하는 방법에 가까워 보입니다.

하이브리드 키워드-화살표 구문

Rust 언어에서는 Python의 def와 거의 같은 방식으로 함수임을 나타내는 키워드 fn을 사용하며, 호출 가능 객체 타입은 하이브리드 화살표 구문 Fn(i64, String) -> bool으로 나타냅니다.

Python의 호출 가능 객체 타입에서 def 키워드를 사용할 수도 있습니다. 예를 들어 두 매개변수를 받는 불리언 함수는 def(int, str) -> bool로 작성할 수 있습니다. 그러나 특히 Javascript의 function 키워드가 이름 있는 함수와 익명 함수 모두에 사용되므로, 독자가 def(A, B) -> C를 람다로 오해할 수 있다고 생각합니다.

괄호 없는 구문

더 간결한 괄호 없는 구문도 고려했습니다.:

int, str -> bool

그러나 기존 함수 헤더 구문과 시각적으로 비슷하지 않기 때문에 채택하지 않기로 했습니다. 더욱이 이름을 괄호 없이 바인딩하는 람다와 시각적으로 비슷합니다: lambda x, y: x == y.

바깥 괄호 요구

현재 제안의 우려 사항 중 하나는 가독성입니다. 특히 호출 가능 객체 타입을 반환 타입 위치에서 사용하면 최상위 -> 토큰이 여러 개 생기기 때문입니다. 예를 들면 다음과 같습니다.:

def make_adder() -> (int) -> int:
    return lambda x: x + 1

괄호에 관한 규칙을 변경하여 이를 방지하는 몇 가지 아이디어를 고려했습니다. 그중 하나는 괄호를 바깥쪽으로 옮겨 두 매개변수를 받는 불리언 함수를 (int, str -> bool)로 작성하는 것이었습니다. 이 변경을 적용하면 위의 예는 다음과 같이 됩니다.:

def make_adder() -> (int -> int):
    return lambda x: x + 1

이렇게 하면 따라가기 어려운 많은 예에서 중첩 구조가 명확해지지만, 다음과 같은 이유로 채택하지 않았습니다.

  • 현재 Python에서는 쉼표의 결합력이 매우 약하므로, (int, str -> bool)을 두 매개변수를 받는 호출 가능 객체 타입이 아니라 첫 번째 요소가 int인 튜플로 잘못 읽기 쉽습니다.
  • 함수 헤더 구문과 그다지 비슷하지 않으며, 우리의 목표 중 하나는 함수 헤더에서 영감을 받은 익숙한 구문이었습니다.
  • 이 구문은 위와 같이 깊게 중첩된 호출 가능 객체에 더 읽기 쉬울 수 있지만, 깊은 중첩은 흔하지 않습니다. 스타일 가이드로 반환 위치의 호출 가능 객체 타입 주위에 추가 괄호를 사용하도록 권장하면 단점 없이 대부분의 가독성 이점을 얻을 수 있습니다.

매개변수 목록과 바깥쪽 모두에 괄호를 요구하는 방안도 고려했습니다. 예를 들면 ((int, str) -> bool)입니다. 이 변경을 적용하면 위의 예는 다음과 같이 됩니다.:

def make_adder() -> ((int) -> int):
    return lambda x: x + 1

다음과 같은 이유로 이 변경을 채택하지 않았습니다.

  • 바깥 괄호는 일부 경우에만, 주로 호출 가능 객체 타입을 반환 위치에서 사용할 때만 가독성을 높입니다. 그 밖의 많은 경우에는 가독성을 높이기보다 오히려 해칩니다.
  • 여러 경우, 특히 함수 반환 어노테이션의 호출 가능 객체 타입에서 외부 괄호를 권장하는 것이 타당할 수 있다는 데 동의합니다. 하지만
    • 이것을 파서에 내장하여 구문 오류를 발생시키기보다는 스타일 가이드, 린터, 자동 포매터에서 권장하는 것이 더 적절하다고 믿습니다.
    • 또한 타입이 가독성을 걱정해야 할 정도로 복잡하다면 언제든지 타입 별칭을 사용할 수 있습니다. 예를 들면 다음과 같습니다.:
      IntToIntFunction: (int) -> int
      
      def make_adder() -> IntToIntFunction:
          return lambda x: x + 1
      

->|보다 더 강하게 결합하도록 하기

타입 표현식에서 ->|토큰을 모두 허용하려면 우선순위를 선택해야 했습니다. 현재 제안에서는 이것이 선택적 불리언을 반환하는 함수입니다.:

(int, str) -> bool | None  # equivalent to (int, str) -> (bool | None)

->를 더 강하게 결합하여 표현식이 대신 ((int, str) -> bool) | None으로 파싱되도록 하는 방안을 고려했습니다. 여기에는 두 가지 이점이 있습니다.

  • 그러면 더 이상 None | (int, str) -> bool을 구문 오류로 처리할 필요가 없습니다.
  • 오늘날 typeshed를 살펴보면 선택적 호출 가능 객체 인자는 매우 흔합니다. None을 기본값으로 사용하는 것이 표준 Python 관용구이기 때문입니다. ->를 더 강하게 결합하면 이러한 표현을 더 쉽게 작성할 수 있습니다.

몇 가지 이유로 이 방안을 채택하지 않기로 결정했습니다.

  • 함수 헤더 def f() -> int | None: ...는 유효하며 선택적 정수를 반환하는 함수를 나타냅니다. 함수 헤더와 일관성을 유지하려면 호출 가능 객체 타입도 동일하게 동작해야 합니다.
  • 타입 표현식에서 ->|토큰을 모두 사용하는 대중적인 언어로는 TypeScript가 유일하게 더 알려져 있으며, 이 언어에서는 |를 더 강하게 결합합니다. 반드시 그 방식을 따라야 하는 것은 아니지만, 그렇게 하는 것을 선호합니다.
  • 선택적 호출 가능 객체 타입이 흔하고 |를 더 강하게 결합하면 추가 괄호가 필요해 이러한 타입을 작성하기 어려워진다는 점은 인정합니다. 하지만 코드는 작성되는 것보다 읽히는 경우가 더 많으며, ((int, str) -> bool) | None과 같은 선택적 호출 가능 객체 타입에 외부 괄호를 요구하는 것이 가독성에 더 좋다고 믿습니다.

타입 문자열 도입

또 다른 아이디어는 새로운 “특수 문자열” 구문을 추가하고 그 안에 타입을 넣는 것이었습니다. 예를 들면 t”(int, str) -> bool”과 같습니다. 이는 가독성이 더 낮고, 타입 표현식이 Python의 나머지 구문과 달라지지 않도록 하라는 Steering Council의 지침과도 어긋나는 것으로 보여 이 방안을 거부했습니다.

인덱싱된 호출 가능 객체 타입의 사용성 개선

호출 가능 객체 타입에 새로운 구문을 추가하지 않으려면 기존 타입을 더 쉽게 읽을 수 있도록 만드는 방법을 살펴볼 수 있습니다. 한 가지 제안은 내장 callable함수를 인덱싱 가능하게 만들어 타입으로 사용할 수 있도록 하는 것입니다.:

callable[[int, str], bool]

이 변경은 listdict같은 내장 컬렉션을 타입으로 사용할 수 있게 만든 PEP 585과 유사하며, 임포트를 더 편리하게 만들지만 타입 자체의 가독성에는 큰 도움이 되지 않습니다.

복잡한 호출 가능 객체 타입에 필요한 괄호 수를 줄이기 위해 인자 목록에 튜플을 허용할 수도 있습니다.:

callable[(int, str), bool]

이는 실제로 다중 인자 함수의 가독성을 크게 향상하지만, 가장 흔한 인자 개수인 인자 하나의 호출 가능 객체를 작성하기 어렵게 만든다는 문제가 있습니다. (x)x로 평가되므로 callable[(int,), bool]처럼 작성해야 하기 때문입니다. 이는 어색하다고 생각합니다.

또한 이러한 아이디어 중 어느 것도 현재 제안만큼 장황함을 줄이는 데 도움이 되지 않으며, 매개변수 타입과 반환 타입 사이의 ->만큼 강한 시각적 단서도 제공하지 않습니다.

대안적 런타임 동작

런타임 API에 대한 엄격한 요구 사항은 다음과 같습니다.

  • typing.Callable과의 하위 호환성을 __args____params__를 통해 유지해야 합니다.
  • 구조화된 API를 제공해야 하며, 향후 이름 지정 인자와 가변 인자를 지원하려는 경우 확장 가능해야 합니다.

대체 API

런타임 데이터 types.CallableType이 더욱 구조화된 API를 사용하도록 하여 posonlyargsparam_spec에 대한 별도의 필드를 두는 방안을 고려했습니다. 현재 제안은 inspect.Signature타입에서 영감을 받았습니다.

호출 가능 객체 매개변수가 아니라 타입 매개변수를 가리키는 레거시 API의 callable_type.__parameters__필드와 혼동하지 않도록, inspect.Signature에서 사용하는 “parameter”와 달리 필드 및 타입 이름에는 “argument”를 사용합니다.

비동기 타입에서 __args__에 일반 반환 타입 사용

async (int) -> str와 같은 비동기 호출 가능 객체 타입에서 __args__의 하위 호환성을 유지해야 하는지는 논쟁의 여지가 있습니다. 그 이유는 이러한 타입이 typing.Callable을 사용하여 직접 표현될 수 없다고 주장할 수 있으므로, __args__(int, typing.Awaitable[int])대신 (int, int)로 설정해도 괜찮다고 볼 수 있기 때문입니다.

그러나 이는 문제가 될 것이라고 생각합니다. 하위 호환 가능한 API처럼 보이게 유지하면서 실제로는 비동기 타입에서 그 의미를 깨뜨리면, __args__를 사용하여 Callable을 해석하려는 런타임 타입 라이브러리가 조용히 실패하게 됩니다.

이러한 이유로 반환 타입을 Awaitable로 자동 래핑합니다.

하위 호환성

이 PEP는 typing.Callable에 비해 주요한 구문 개선을 제안하지만, 정적 의미론은 동일합니다.

따라서 하위 호환성을 위해 필요한 유일한 작업은 새 구문으로 지정된 타입이 이를 대체하려는 동등한 typing.Callabletyping.Concatenate 값과 동일하게 동작하도록 보장하는 것입니다.

이 제안과 from __future__ import annotations사이에는 특별한 상호 작용이 없습니다. 다른 타입 어노테이션과 마찬가지로 모듈을 가져올 때 문자열로 역구문 분석되며, 가능한 경우 typing.get_type_hints가 결과 문자열을 올바르게 평가해야 합니다.

이에 대해서는 런타임 동작 절에서 더 자세히 설명합니다.

참조 구현

여기에는 AST 및 Grammar의 작동하는 implementation이 있으며, 제안된 문법이 여기서 의도한 동작을 보이는지 검증하는 테스트도 포함되어 있습니다.

런타임 동작은 아직 구현되지 않았습니다. 사양의 Runtime Behavior부분에서 설명한 것처럼, 하위 호환 가능한 API와 더욱 구조화된 API 모두에 대한 상세한 계획을 a separate doc에 마련했으며, 논의와 대안 아이디어도 환영합니다.

미해결 문제

런타임 API의 세부 사항

이 PEP의 Runtime Behavior절에서 완전한 동작 사양을 제공하려고 했습니다.

그러나 완전한 참조 구현을 구축하기 전까지는 정의해야 한다는 사실조차 깨닫지 못할 세부 사항이 더 많이 있을 것입니다.

SyntaxError 메시지 최적화

현재 참조 구현에는 완전히 기능하는 파서가 있으며, 여기 제시된 모든 엣지 케이스를 테스트했습니다.

그러나 오류 메시지가 원하는 만큼 유용하지 않은 몇 가지 알려진 경우가 있습니다. 예를 들어 (int, ...) -> bool은 올바르지 않지만 (int, ...)은 유효한 튜플이므로, 오류의 실제 원인이 ...를 인자 타입으로 사용한 것임에도 현재는 ->를 문제로 표시하는 구문 오류를 생성합니다.

이는 per se 사양의 일부는 아니지만, 구현에서 해결해야 할 중요한 세부 사항입니다. 해결책에는 python.graminvalid_.* 규칙을 추가하고 오류 메시지를 사용자 지정하는 작업이 포함될 가능성이 높습니다.

리소스

배경 및 역사

PEP 484 specifies는 Python 2.7에서 작동해야 하는 코드에서 사용할 함수 타입 힌트 주석을 위한 매우 유사한 구문을 지정합니다. 예를 들면 다음과 같습니다.:

def f(x, y):
    # type: (int, str) -> bool
    ...

당시에는 타입에 대한 구문을 추가하지 않기로 결정했기 때문에 typing.Callable과 같은 제네릭 타입을 지정하기 위해 인덱싱 연산을 사용했습니다. 그러나 그 이후로 타입에 대한 구문을 추가하기 시작했으며, 예를 들어 PEP 604가 있습니다.

Maggie는 PyCon Typing Summit 2021에서 더 큰 타이핑 단순화에 관한 발표의 일부로 더 나은 호출 가능 객체 타입 구문을 제안했습니다.

Steventyping-sig에서 이 제안을 제기했습니다. 대안을 논의하기 위해 여러 차례 회의를 열었으며, 이 발표를 통해 현재 제안에 이르렀습니다.

Pradeep은 피드백을 받기 위해 python-dev에 이 제안을 전달했습니다.

감사의 글

PEP에 의견을 주시고 참조 구현 계획 수립을 도와주신 다음 분들께 감사드립니다.

Alex Waygood, Eric Traut, Guido van Rossum, James Hilton-Balfe, Jelle Zijlstra, Maggie Moss, Tuomas Suutari, Shannon Zhu.