python-peps/peps/pep-0677.rst

1255 lines
43 KiB
ReStructuredText
Raw Permalink Normal View History

PEP: 677
Title: Callable Type Syntax
Author: Steven Troxler <steven.troxler@gmail.com>,
Pradeep Kumar Srinivasan <gohanpra@gmail.com>
Sponsor: Guido van Rossum <guido at python.org>
Discussions-To: python-dev@python.org
2022-02-11 20:42:11 -05:00
Status: Rejected
Type: Standards Track
Topic: Typing
Content-Type: text/x-rst
Created: 13-Dec-2021
Python-Version: 3.11
Post-History: 16-Dec-2021
2022-02-11 20:42:11 -05:00
Resolution: https://mail.python.org/archives/list/python-dev@python.org/message/NHCLHCU2XCWTBGF732WESMN42YYVKOXB/
Abstract
========
This PEP introduces a concise and friendly syntax for callable types,
supporting the same functionality as ``typing.Callable`` but with an
arrow syntax inspired by the syntax for typed function
signatures. This allows types like ``Callable[[int, str], bool]`` to
be written as ``(int, str) -> bool``.
The proposed syntax supports all the functionality provided by
``typing.Callable`` and ``typing.Concatenate``, and is intended to
work as a drop-in replacement.
Motivation
==========
One way to make code safer and easier to analyze is by making sure
that functions and classes are well-typed. In Python we have type
annotations, the framework for which is defined in :pep:`484`, to provide
type hints that can find bugs as well as helping with editor tooling
like tab completion, static analysis tooling, and code review.
Consider the following untyped code::
def flat_map(func, l):
out = []
for element in l:
2022-01-13 10:22:30 -05:00
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
We can add types to this example to detect the runtime error::
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
There are a few usability challenges with ``Callable`` we can see here:
- It is verbose, particularly for more complex function signatures.
- It relies on two levels of nested brackets, unlike any other generic
type. This can be especially hard to read when some of the type
parameters are themselves generic types.
- The bracket structure is not visually similar to how function signatures
are written.
- It requires an explicit import, unlike many of the other most common
types like ``list`` and ``dict``.
Possibly as a result, `programmers often fail to write complete
Callable types
<https://github.com/pradeep90/annotation_collector#typed-projects---callable-type>`_.
Such untyped or partially-typed callable types do not check the
parameter types or return types of the given callable and thus negate
the benefits of static typing. For example, they might write this::
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!
There's some partial type information here - we at least know that ``func``
needs to be callable. But we've dropped too much type information for
type checkers to find the bug.
With our proposal, the example looks like this::
def flat_map(
func: (int) -> list[int],
l: list[int]
) -> list[int]:
out = []
for element in l:
out.extend(f(element))
return out
...
The type ``(int) -> list[int]`` is more concise, uses an arrow similar
to the one indicating a return type in a function header, avoids
nested brackets, and does not require an import.
Rationale
=========
The ``Callable`` type is widely used. For example, `as of October 2021
it was
<https://github.com/pradeep90/annotation_collector#overall-stats-in-typeshed>`_
the fifth most common complex type in typeshed, after ``Optional``,
``Tuple``, ``Union``, and ``List``.
The others have had their syntax improved and the need for imports
eliminated by either :pep:`604` or :pep:`585`:
- ``typing.Optional[int]`` is written ``int | None``
- ``typing.Union[int, str]`` is written ``int | str``
- ``typing.List[int]`` is written ``list[int]``
- ``typing.Tuple[int, str]`` is written ``tuple[int, str]``
The ``typing.Callable`` type is used almost as often as these other
types, is more complicated to read and write, and still requires an
import and bracket-based syntax.
In this proposal, we chose to support all the existing semantics of
``typing.Callable``, without adding support for new features. We made
this decision after examining how frequently each feature might be
used in existing typed and untyped open-source code. We determined
that the vast majority of use cases are covered.
We considered adding support for named, optional, and variadic
arguments. However, we decided against including these features, as
our analysis showed they are infrequently used. When they are really
needed, it is possible to type these using `callback protocols
<https://mypy.readthedocs.io/en/stable/protocols.html#callback-protocols>`_.
An Arrow Syntax for Callable Types
----------------------------------
We are proposing a succinct, easy-to-use syntax for
``typing.Callable`` that looks similar to function headers in Python.
Our proposal closely follows syntax used by several popular languages
such as `Typescript
<https://basarat.gitbook.io/typescript/type-system/callable#arrow-syntax>`_,
`Kotlin <https://kotlinlang.org/docs/lambdas.html>`_, and `Scala
<https://docs.scala-lang.org/tour/higher-order-functions.html>`_.
Our goals are that:
- Callable types using this syntax will be easier to learn and use,
particularly for developers with experience in other languages.
- Library authors will be more likely to use expressive types for
callables that enable type checkers to better understand code and
find bugs, as in the ``decorator`` example above.
Consider this simplified real-world example from a web server, written
using the existing ``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:
...
With our proposal, this code can be abbreviated to::
from app_logic import Response, UserSetting
def customize_response(
response: Response,
customizer: async (Response, list[UserSetting]) -> Response,
) -> Response:
...
This is shorter and requires fewer imports. It also has far less
nesting of square brackets - only one level, as opposed to three in
the original code.
Compact Syntax for ``ParamSpec``
--------------------------------
A particularly common case where library authors leave off type information
for callables is when defining decorators. Consider the following::
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!
In the code above, it is clear that the decorator should produce a
function whose signature is like that of the argument ``f`` other
than an additional ``retry_once`` argument. But the use of ``...``
prevents a type checker from seeing this and alerting a user that
``f(y=10)`` is invalid.
With :pep:`612` it is possible to type decorators like this correctly
as follows::
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
...
With our proposed syntax, the properly-typed decorator example becomes
concise and the type representations are visually descriptive::
from typing import Any, ParamSpec, TypeVar
R = TypeVar("R")
P = ParamSpec("P")
def with_retries(
f: (**P) -> R
) -> (bool, **P) -> R:
...
Comparing to Other Languages
----------------------------
Many popular programming languages use an arrow syntax similar
to the one we are proposing here.
TypeScript
~~~~~~~~~~
In `TypeScript
<https://basarat.gitbook.io/typescript/type-system/callable#arrow-syntax>`_,
function types are expressed in a syntax almost the same as the one we
are proposing, but the arrow token is ``=>`` and arguments have names::
(x: int, y: str) => bool
The names of the arguments are not actually relevant to the type. So,
for example, this is the same callable type::
(a: int, b: str) => bool
Kotlin
~~~~~~
Function types in `Kotlin <https://kotlinlang.org/docs/lambdas.html>`_ permit
an identical syntax to the one we are proposing, for example::
(Int, String) -> Bool
It also optionally allows adding names to the arguments, for example::
(x: Int, y: String) -> Bool
As in TypeScript, the argument names (if provided) are just there for
documentation and are not part of the type itself.
Scala
~~~~~
`Scala <https://docs.scala-lang.org/tour/higher-order-functions.html>`_
uses the ``=>`` arrow for function types. Other than that, their syntax is
the same as the one we are proposing, for example::
(Int, String) => Bool
Scala, like Python, has the ability to provide function arguments by name.
Function types can optionally include names, for example::
(x: Int, y: String) => Bool
Unlike in TypeScript and Kotlin, these names are part of the type if
provided - any function implementing the type must use the same names.
This is similar to the extended syntax proposal we describe in our
`Rejected Alternatives`_ section.
Function Definitions vs Callable Type Annotations
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In all of the languages listed above, type annotations for function
definitions use a ``:`` rather than a ``->``. For example, in TypeScript
a simple add function looks like this::
function higher_order(fn: (a: string) => string): string {
return fn("Hello, World");
}
Scala and Kotlin use essentially the same ``:`` syntax for return
annotations. The ``:`` makes sense in these languages because they
all use ``:`` for type annotations of
parameters and variables, and the use for function return types is
similar.
In Python we use ``:`` to denote the start of a function body and
``->`` for return annotations. As a result, even though our proposal
is superficially the same as these other languages the context is
different. There is potential for more confusion in Python when
reading function definitions that include callable types.
This is a key concern for which we are seeking feedback with our draft
PEP; one idea we have floated is to use ``=>`` instead to make it easier
to differentiate.
The ML Language Family
~~~~~~~~~~~~~~~~~~~~~~
Languages in the ML family, including `F#
<https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/fsharp-types#syntax-for-types>`_,
`OCaml
<https://www2.ocaml.org/learn/tutorials/basics.html#Defining-a-function>`_,
and `Haskell <https://wiki.haskell.org/Type_signature>`_, all use
``->`` to represent function types. All of them use a parentheses-free
syntax with multiple arrows, for example in Haskell::
Integer -> String -> Bool
The use of multiple arrows, which differs from our proposal, makes
sense for languages in this family because they use automatic
`currying <https://en.wikipedia.org/wiki/Currying>`_ of function arguments,
which means that a multi-argument function behaves like a single-argument
function returning a function.
Specification
=============
Typing Behavior
---------------
Type checkers should treat the new syntax with exactly the same
semantics as ``typing.Callable``.
As such, a type checker should treat the following pairs exactly the
same::
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]
Grammar and AST
---------------
The proposed new syntax can be described by these AST changes to `Parser/Python.asdl
<https://github.com/python/cpython/blob/main/Parser/Python.asdl>`_::
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)
Here are our proposed changes to the `Python Grammar
<https://docs.python.org/3/reference/grammar.htm>`::
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 &')'
If :pep:`646` is accepted, we intend to include support for unpacked
types in two ways. To support the "star-for-unpack" syntax proposed in
:pep:`646`, we will modify the grammar for
``callable_type_positional_argument`` as follows::
callable_type_positional_argument:
| !'...' expression ','
| !'...' expression &')'
| '*' expression ','
| '*' expression &')'
With this change, a type of the form ``(int, *Ts) -> bool`` should
evaluate the AST form::
CallableType(
ArgumentsList(Name("int"), Starred(Name("Ts")),
Name("bool")
)
and be treated by type checkers as equivalent to or ``Callable[[int,
*Ts], bool]`` or ``Callable[[int, Unpack[Ts]], bool]``.
Implications of the Grammar
---------------------------
Precedence of ->
~~~~~~~~~~~~~~~~
``->`` binds less tightly than other operators, both inside types and
in function signatures, so the following two callable types are
equivalent::
(int) -> str | bool
(int) -> (str | bool)
``->`` associates to the right, both inside types and in function
signatures. So the following pairs are equivalent::
(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
Because operators bind more tightly than ``->``, parentheses are
required whenever an arrow type is intended to be inside an argument
to an operator like ``|``::
(int) -> () -> int | () -> bool # syntax error!
(int) -> (() -> int) | (() -> bool) # okay
We discussed each of these behaviors and believe they are desirable:
- Union types (represented by ``A | B`` according to :pep:`604`) are
valid in function signature returns, so we need to allow operators
in the return position for consistency.
- Given that operators bind more tightly than ``->`` it is correct
that a type like ``bool | () -> bool`` must be a syntax error. We
should be sure the error message is clear because this may be a
common mistake.
- Associating ``->`` to the right, rather than requiring explicit
parentheses, is consistent with other languages like TypeScript and
respects the principle that valid expressions should normally be
substitutable when possible.
``async`` Keyword
~~~~~~~~~~~~~~~~~
All of the binding rules still work for async callable types::
(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
Trailing Commas
~~~~~~~~~~~~~~~
- Following the precedent of function signatures, putting a comma in
an empty arguments list is illegal: ``(,) -> bool`` is a syntax
error.
- Again following precedent, trailing commas are otherwise always
permitted::
((int,) -> bool == (int) -> bool
((int, **P,) -> bool == (int, **P) -> bool
((...,) -> bool) == ((...) -> bool)
Allowing trailing commas also gives autoformatters more flexibility
when splitting callable types across lines, which is always legal
following standard python whitespace rules.
Disallowing ``...`` as an Argument Type
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Under normal circumstances, any valid expression is permitted where we
want a type annotation and ``...`` is a valid expression. This is
never semantically valid and all type checkers would reject it, but
the grammar would allow it if we did not explicitly prevent this.
Since ``...`` is meaningless as a type and there are usability
concerns, our grammar rules it out and the following is a syntax
error::
(int, ...) -> bool
We decided that there were compelling reasons to do this:
- The semantics of ``(...) -> bool`` are different from ``(T) -> bool``
for any valid type T: ``(...)`` is a special form indicating
``AnyArguments`` whereas ``T`` is a type parameter in the arguments
list.
- ``...`` is used as a placeholder default value to indicate an
optional argument in stubs and callback protocols. Allowing it in
the position of a type could easily lead to confusion and possibly
bugs due to typos.
- In the ``tuple`` generic type, we special-case ``...`` to mean
"more of the same", e.g. a ``tuple[int, ...]`` means a tuple with
one or more integers. We do not use ``...`` in a a similar way
in callable types, so to prevent misunderstandings it makes sense
to prevent this.
Incompatibility with other possible uses of ``*`` and ``**``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The use of ``**P`` for supporting :pep:`612` ``ParamSpec`` rules out any
future proposal using a bare ``**<some_type>`` to type
``kwargs``. This seems acceptable because:
- If we ever do want such a syntax, it would be clearer to require an
argument name anyway. This would also make the type look more
similar to a function signature. In other words, if we ever support
typing ``kwargs`` in callable types, we would prefer ``(int,
**kwargs: str)`` rather than ``(int, **str)``.
- :pep:`646` unpacking syntax would rule out using ``*<some_type>`` for
``args``. The ``kwargs`` case is similar enough that this rules out
a bare ``**<some_type>`` anyway.
Compatibility with Arrow-Based Lambda Syntax
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
To the best of our knowledge there is no active discussion of
arrow-style lambda syntax that we are aware of, but it is nonetheless
worth considering what possibilities would be ruled out by adopting
this proposal.
It would be incompatible with this proposal to adopt the same a
parenthesized ``->``-based arrow syntax for lambdas, e.g. ``(x, y) ->
x + y`` for ``lambda x, y: x + y``.
Our view is that if we want arrow syntax for lambdas in the future, it
would be a better choice to use ``=>``, e.g. ``(x, y) => x + y``.
Many languages use the same arrow token for both lambdas and callable
types, but Python is unique in that types are expressions and have to
evaluate to runtime values. Our view is that this merits using
separate tokens, and given the existing use of ``->`` for return types
in function signatures it would be more coherent to use ``->`` for
callable types and ``=>`` for lambdas.
Runtime Behavior
----------------
The new AST nodes need to evaluate to runtime types, and we have two goals for the
behavior of these runtime types:
- They should expose a structured API that is descriptive and powerful
enough to be compatible with extending the type to include new features
like named and variadic arguments.
- They should also expose an API that is backward-compatible with
``typing.Callable``.
Evaluation and Structured API
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
We intend to create new builtin types to which the new AST nodes will
evaluate, exposing them in the ``types`` module.
Our plan is to expose a structured API as if they were defined as follows::
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 = ...
The evaluation rules are expressed in terms of the following
pseudocode::
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)
)
Backward-Compatible API
~~~~~~~~~~~~~~~~~~~~~~~
To get backward compatibility with the existing ``types.Callable`` API,
which relies on fields ``__args__`` and ``__parameters__``, we can define
them as if they were written in terms of the following::
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)
Additional Behaviors of ``types.CallableType``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
As with the ``A | B`` syntax for unions introduced in :pep:`604`:
- The ``__eq__`` method should treat equivalent ``typing.Callable``
values as equal to values constructed using the builtin syntax, and
otherwise should behave like the ``__eq__`` of ``typing.Callable``.
- The ``__repr__`` method should produce an arrow syntax representation that,
when evaluated, gives us back an equal ``types.CallableType`` instance.
Rejected Alternatives
=====================
Many of the alternatives we considered would have been more expressive
than ``typing.Callable``, for example adding support for describing
signatures that include named, optional, and variadic arguments.
To determine which features we most needed to support with a callable
type syntax, we did an extensive analysis of existing projects:
- `stats on the use of the Callable type <https://github.com/pradeep90/annotation_collector#typed-projects---callable-type>`_;
- `stats on how untyped and partially-typed callbacks are actually used <https://github.com/pradeep90/annotation_collector#typed-projects---callback-usage>`_.
We decided on a simple proposal with improved syntax for the existing
``Callable`` type because the vast majority of callbacks can be correctly
described by the existing ``typing.Callable`` semantics:
- Positional parameters: By far the most important case to handle well
is simple callable types with positional parameters, such as
``(int, str) -> bool``
- ParamSpec and Concatenate: The next most important feature is good
support for :pep:`612` ``ParamSpec`` and ``Concatenate`` types like
``(**P) -> bool`` and ``(int, **P) -> bool``. These are common
primarily because of the heavy use of decorator patterns in python
code.
- TypeVarTuples: The next most important feature, assuming :pep:`646` is
accepted, is for unpacked types which are common because of cases
where a wrapper passes along ``*args`` to some other function.
Features that other, more complicated proposals would support account
for fewer than 2% of the use cases we found. These are already
expressible using callback protocols, and since they are uncommon we
decided that it made more sense to move forward with a simpler syntax.
Extended Syntax Supporting Named and Optional Arguments
-------------------------------------------------------
Another alternative was for a compatible but more complex syntax that
could express everything in this PEP but also named, optional, and
variadic arguments. In this “extended” syntax proposal the following
types would have been equivalent::
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
Advantages of this syntax include: - Most of the advantages of the
proposal in this PEP (conciseness, :pep:`612` support, etc) -
Furthermore, the ability to handle named, optional, and variadic
arguments
We decided against proposing it for the following reasons:
- The implementation would have been more difficult, and usage stats
demonstrate that fewer than 3% of use cases would benefit from any
of the added features.
- The group that debated these proposals was split down the middle
about whether these changes are desirable:
- On the one hand, they make callable types more expressive. On the
other hand, they could easily confuse users who have not read the
full specification of callable type syntax.
- We believe the simpler syntax proposed in this PEP, which
introduces no new semantics and closely mimics syntax in other
popular languages like Kotlin, Scala, and TypesScript, is much
less likely to confuse users.
- We intend to implement the current proposal in a way that is
forward-compatible with the more complicated extended syntax. If the
community decides after more experience and discussion that we want
the additional features, it should be straightforward to propose
them in the future.
- Even a full extended syntax cannot replace the use of callback
protocols for overloads. For example, no closed form of callable type
could express a function that maps bools to bools and ints to floats,
like this callback protocol.::
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
We confirmed that the current proposal is forward-compatible with
extended syntax by
`implementing <https://github.com/stroxler/cpython/tree/callable-type-syntax--extended>`_
a grammar and AST for this extended syntax on top of our reference
implementation of this PEP's grammar.
Syntax Closer to Function Signatures
------------------------------------
One alternative we had floated was a syntax much more similar to
function signatures.
In this proposal, the following types would have been equivalent::
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
The benefits of this proposal would have included:
- Perfect syntactic consistency between signatures and callable types.
- Support for more features of function signatures (named, optional,
variadic args) that this PEP does not support.
Key downsides that led us to reject the idea include the following:
- A large majority of use cases only use positional-only arguments. This
syntax would be more verbose for that use case, both because of requiring
argument names and an explicit ``/``, for example ``(int, /) -> bool`` where
our proposal allows ``(int) -> bool``
- The requirement for explicit ``/`` for positional-only arguments has
a high risk of causing frequent bugs - which often would not be
detected by unit tests - where library authors would accidentally
use types with named arguments.
- Our analysis suggests that support for ``ParamSpec`` is key, but the
scoping rules laid out in :pep:`612` would have made this difficult.
Other Proposals Considered
--------------------------
Functions-as-Types
~~~~~~~~~~~~~~~~~~
An idea we looked at very early on was to `allow using functions as types
<https://docs.google.com/document/d/1rv6CCDnmLIeDrYlXe-QcyT0xNPSYAuO1EBYjU3imU5s/edit?usp=sharing>`_.
The idea is allowing a function to stand in for its own call
signature, with roughly the same semantics as the ``__call__`` method
of callback protocols::
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
This may be a good idea, but we do not consider it a viable
replacement for callable types:
- It would be difficult to handle ``ParamSpec``, which we consider a
critical feature to support.
- When using functions as types, the callable types are not first-class
values. Instead, they require a separate, out-of-line function
definition to define a type alias
- It would not support more features than callback protocols, and seems
more like a shorter way to write them than a replacement for
``Callable``.
Hybrid keyword-arrow Syntax
~~~~~~~~~~~~~~~~~~~~~~~~~~~
In the Rust language, a keyword ``fn`` is used to indicate functions
in much the same way as Python's ``def``, and callable types are
indicated using a hybrid arrow syntax ``Fn(i64, String) -> bool``.
We could use the ``def`` keyword in callable types for Python, for
example our two-parameter boolean function could be written as
``def(int, str) -> bool``. But we think this might confuse readers
into thinking ``def(A, B) -> C`` is a lambda, particularly because
Javascript's ``function`` keyword is used in both named and anonymous
functions.
Parenthesis-Free Syntax
~~~~~~~~~~~~~~~~~~~~~~~
We considered a parentheses-free syntax that would have been even more
concise::
int, str -> bool
We decided against it because this is not visually as similar to
existing function header syntax. Moreover, it is visually similar to
lambdas, which bind names with no parentheses: ``lambda x, y: x ==
y``.
Requiring Outer Parentheses
~~~~~~~~~~~~~~~~~~~~~~~~~~~
A concern with the current proposal is readability, particularly
when callable types are used in return type position which leads to
multiple top-level ``->`` tokens, for example::
def make_adder() -> (int) -> int:
return lambda x: x + 1
We considered a few ideas to prevent this by changing rules about
parentheses. One was to move the parentheses to the outside, so
that a two-argument boolean function is written ``(int, str -> bool)``.
With this change, the example above becomes::
def make_adder() -> (int -> int):
return lambda x: x + 1
This makes the nesting of many examples that are difficult to
follow clear, but we rejected it because
- Currently in Python commas bind very loosely, which means it might be common
to misread ``(int, str -> bool)`` as a tuple whose first element is an int,
rather than a two-parameter callable type.
- It is not very similar to function header syntax, and one of our goals was
familiar syntax inspired by function headers.
- This syntax may be more readable for deaply nested callables like the one
above, but deep nesting is not very common. Encouraging extra parentheses
around callable types in return position via a style guide would have most of
the readability benefit without the downsides.
We also considered requiring parentheses on both the parameter list and the
outside, e.g. ``((int, str) -> bool)``. With this change, the example above
becomes::
def make_adder() -> ((int) -> int):
return lambda x: x + 1
We rejected this change because:
- The outer parentheses only help readability in some cases, mostly when a
callable type is used in return position. In many other cases they hurt
readability rather than helping.
- We agree that it might make sense to encourage outer parentheses in several
cases, particularly callable types in function return annotations. But
- We believe it is more appropriate to encourage this in style guides,
linters, and autoformatters than to bake it into the parser and throw
syntax errors.
- Moreover, if a type is complicated enough that readability is a concern
we can always use type aliases, for example::
IntToIntFunction: (int) -> int
def make_adder() -> IntToIntFunction:
return lambda x: x + 1
Making ``->`` bind tighter than ``|``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In order to allow both ``->`` and ``|`` tokens in type expressions we
had to choose precedence. In the current proposal, this is a function
returning an optional boolean::
(int, str) -> bool | None # equivalent to (int, str) -> (bool | None)
We considered having ``->`` bind tighter so that instead the expression
would parse as ``((int, str) -> bool) | None``. There are two advantages
to this:
- It means we no would longer have to treat ``None | (int, str) ->
bool`` as a syntax error.
- Looking at typeshed today, optional callable arguments are very common
because using ``None`` as a default value is a standard Python idiom.
Having ``->`` bind tighter would make these easier to write.
We decided against this for a few reasons:
- The function header ``def f() -> int | None: ...`` is legal
and indicates a function returning an optional int. To be consistent
with function headers, callable types should do the same.
- TypeScript is the other popular language we know of that uses both
``->`` and ``|`` tokens in type expressions, and they have ``|`` bind
tighter. While we do not have to follow their lead, we prefer to do
so.
- We do acknowledge that optional callable types are common and
having ``|`` bind tighter forces extra parentheses, which makes these
types harder to write. But code is read more often than written, and
we believe that requiring the outer parentheses for an optional callable
type like ``((int, str) -> bool) | None`` is preferable for readability.
Introducing type-strings
~~~~~~~~~~~~~~~~~~~~~~~~
Another idea was adding a new “special string” syntax and putting the type
inside of it, for example ``t”(int, str) -> bool”``. We rejected this
because it is not as readable, and seems out of step with `guidance
<https://mail.python.org/archives/list/python-dev@python.org/message/SZLWVYV2HPLU6AH7DOUD7DWFUGBJGQAY/>`_
from the Steering Council on ensuring that type expressions do not
diverge from the rest of Python's syntax.
Improving Usability of the Indexed Callable Type
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If we do not want to add new syntax for callable types, we could
look at how to make the existing type easier to read. One proposal
would be to make the builtin ``callable`` function indexable so
that it could be used as a type::
callable[[int, str], bool]
This change would be analogous to :pep:`585` that made built in collections
like ``list`` and ``dict`` usable as types, and would make imports
more convenient, but it wouldn't help readability of the types themselves
much.
In order to reduce the number of brackets needed in complex callable
types, it would be possible to allow tuples for the argument list::
callable[(int, str), bool]
This actually is a significant readability improvement for
multi-argument functions, but the problem is that it makes callables
with one arguments, which are the most common arity, hard to
write: because ``(x)`` evaluates to ``x``, they would have to be
written like ``callable[(int,), bool]``. We find this awkward.
Moreover, none of these ideas help as much with reducing verbosity
as the current proposal, nor do they introduce as strong a visual cue
as the ``->`` between the parameter types and the return type.
Alternative Runtime Behaviors
-----------------------------
The hard requirements on our runtime API are that:
- It must preserve backward compatibility with ``typing.Callable`` via
``__args__`` and ``__params__``.
- It must provide a structured API, which should be extensible if
in the future we try to support named and variadic arguments.
Alternative APIs
~~~~~~~~~~~~~~~~
We considered having the runtime data ``types.CallableType`` use a
more structured API where there would be separate fields for
``posonlyargs`` and ``param_spec``. The current proposal was
was inspired by the ``inspect.Signature`` type.
We use "argument" in our field and type names, unlike "parameter"
as in ``inspect.Signature``, in order to avoid confusion with
the ``callable_type.__parameters__`` field from the legacy API
that refers to type parameters rather than callable parameters.
Using the plain return type in ``__args__`` for async types
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
It is debatable whether we are required to preserve backward compatibility
of ``__args__`` for async callable types like ``async (int) -> str``. The
reason is that one could argue they are not expressible directly
using ``typing.Callable``, and therefore it would be fine to set
``__args__`` as ``(int, int)`` rather than ``(int, typing.Awaitable[int])``.
But we believe this would be problematic. By preserving the appearance
of a backward-compatible API while actually breaking its semantics on
async types, we would cause runtime type libraries that attempt to
interpret ``Callable`` using ``__args__`` to fail silently.
It is for this reason that we automatically wrap the return type in
``Awaitable``.
Backward Compatibility
======================
This PEP proposes a major syntax improvement over ``typing.Callable``,
but the static semantics are the same.
As such, the only thing we need for backward compatibility is to
ensure that types specified via the new syntax behave the same as
equivalent ``typing.Callable`` and ``typing.Concatenate`` values they
intend to replace.
There is no particular interaction between this proposal and ``from
__future__ import annotations`` - just like any other type annotation
it will be unparsed to a string at module import, and
``typing.get_type_hints`` should correctly evaluate the resulting
strings in cases where that is possible.
This is discussed in more detail in the Runtime Behavior section.
Reference Implementation
========================
We have a working `implementation
<https://github.com/stroxler/cpython/tree/callable-type-syntax--shorthand>`_
of the AST and Grammar with tests verifying that the grammar proposed
here has the desired behaviors.
The runtime behavior is not yet implemented. As discussed in the
`Runtime Behavior`_ portion of the spec we have a detailed plan for
both a backward-compatible API and a more structured API in
`a separate doc
<https://docs.google.com/document/d/15nmTDA_39Lo-EULQQwdwYx_Q1IYX4dD5WPnHbFG71Lk/edit>`_
where we are also open to discussion and alternative ideas.
Open Issues
===========
Details of the Runtime API
--------------------------
We have attempted to provide a complete behavior specification in
the `Runtime Behavior`_ section of this PEP.
But there are probably more details that we will not realize we
need to define until we build a full reference implementation.
Optimizing ``SyntaxError`` messages
-----------------------------------
The current reference implementation has a fully-functional parser and
all edge cases presented here have been tested.
But there are some known cases where the errors are not as informative
as we would like. For example, because ``(int, ...) -> bool`` is
illegal but ``(int, ...)`` is a valid tuple, we currently produce a
syntax error flagging the ``->`` as the problem even though the real
cause of the error is using ``...`` as an argument type.
This is not part of the specification *per se* but is an important
detail to address in our implementation. The solution will likely
involve adding ``invalid_.*`` rules to ``python.gram`` and customizing
error messages.
Resources
=========
Background and History
----------------------
:pep:`PEP 484 specifies
<484#suggested-syntax-for-python-2-7-and-straddling-code>`
a very similar syntax for function type hint *comments* for use in
code that needs to work on Python 2.7. For example::
def f(x, y):
# type: (int, str) -> bool
...
At that time we used indexing operations to specify generic types like
``typing.Callable`` because we decided not to add syntax for
types. However, we have since begun to do so, e.g. with :pep:`604`.
**Maggie** proposed better callable type syntax as part of a larger
`presentation on typing simplifications
<https://drive.google.com/file/d/1XhqTKoO6RHtz7zXqW5Wgq9nzaEz9TXjI/view>`_
at the PyCon Typing Summit 2021.
**Steven** `brought up this proposal on typing-sig
<https://mail.python.org/archives/list/typing-sig@python.org/thread/3JNXLYH5VFPBNIVKT6FFBVVFCZO4GFR2>`_.
We had several meetings to discuss alternatives, and `this presentation
<https://www.dropbox.com/s/sshgtr4p30cs0vc/Python%20Callable%20Syntax%20Proposals.pdf?dl=0>`_
led us to the current proposal.
**Pradeep** `brought this proposal to python-dev
<https://mail.python.org/archives/list/python-dev@python.org/thread/VBHJOS3LOXGVU6I4FABM6DKHH65GGCUB>`_
for feedback.
Acknowledgments
---------------
Thanks to the following people for their feedback on the PEP and help
planning the reference implementation:
Alex Waygood, Eric Traut, Guido van Rossum, James Hilton-Balfe,
Jelle Zijlstra, Maggie Moss, Tuomas Suutari, Shannon Zhu.
Copyright
=========
This document is placed in the public domain or under the
CC0-1.0-Universal license, whichever is more permissive.
..
Local Variables:
mode: indented-text
indent-tabs-mode: nil
sentence-end-double-space: t
fill-column: 70
coding: utf-8
End: