0

In Python we often have a situation where the default argument for some input parameter is None and, if that is the case, we immediately initialize that variable at the top of the function body for use in the rest of the function. One common use case is for mutable default arguments:

def foo(input_list = None):
    if input_list is None:
        input_list = []
    input_list.append('bar')
    print(input_list)

What would be the appropriate way to type hint this?

from typing import List

def foo(input_list: List = None):
    if input_list is None:
        input_list = []
    input_list.append('bar')
    print(input_list)

or

from typing import List, Optional

def foo(input_list: Optional[List] = None):
    if input_list is None:
        input_list = []
    input_list.append('bar')
    print(input_list)

The core of the question is this: From the caller's perspective it is ok to pass in either a list or nothing. So from the callers perspective the input_list parameter is Optional. But within the body of the function, it is necessary that input_list is indeed a List and not None. So is the Optional flag meant to be an indicator to the caller that it is optional to pass that parameter in (in which case the second code block would be correct and the type hint in the first block would be too restrictive), or is it actually a signal to the body of the function that input_list may be either a List or None (in which case the second code block would be wrong since None is not acceptable)?

I lean towards not including Optional because users aren't really supposed to pass anything other than a list into input_list. It's true that they can pass in None, and nothing bad will happen. But that's not really the intent.

Note that I am aware that in at least some cases param: type = None will be parsed as if it was equal to param: Optional[type] = None. That doesn't actually give me clarity on how I should use the Optional annotation.

Jagerber48
  • 488
  • 4
  • 13
  • The appropriate way to type hint this would be to use the Optional type from the typing module. This indicates that the argument can either be of the specified type or None. from typing import List, Optional def foo(input_list: Optional[List[str]] = None): if input_list is None: input_list = [] input_list.append('bar') print(input_list) – Rudi van Hemert Apr 12 '23 at 22:59
  • `mypy` has previously inferred `Optional[List]` from `List` when the default value is `None`, but I believe that is deprecated. – chepner Apr 13 '23 at 01:53
  • If your function is going to mutate the argument, it doesn't really make sense for that argument to be optional, unless you are also *returning* the argument. (And the Python convention in that case is to *not* return a mutated argument, but to return `None` instead.) – chepner Apr 13 '23 at 01:55

3 Answers3

1

Depending on the type checker you're using and how it's configured, assigning a parameter a default value of None implicitly makes it Optional without you having to say so.

Within the function body the type should automatically be narrowed by the if and the resulting assignment.

Both of these behaviors can be demonstrated by adding reveal_type statements and running mypy:

def foo(input_list: list[str] = None):
    reveal_type(input_list)  # Revealed type is "Union[builtins.list[builtins.str], None]"
    if input_list is None:
        input_list = []
    reveal_type(input_list)  # Revealed type is "builtins.list[builtins.str]"
Samwise
  • 68,105
  • 3
  • 30
  • 44
  • This is useful information that shows the type checker behavior should work with either `list` or `Optional[list]`, but still, which approach is suggested? – Jagerber48 Apr 12 '23 at 23:11
  • 1
    I would do `list`, because brevity is the soul of wit (although I never ever do just a bare `list` -- always specify the type, even if it's `list[object]`). If I were working in a codebase where mypy were configured more strictly, I would necessarily do the explicit `Optional` (or `| None`) typing. Personally I don't think forcing the `| None` adds anything when the `= None` is *right there* next to the type annotation -- why repeat yourself? :) And the type checker does the right thing within the body of the function either way. – Samwise Apr 12 '23 at 23:14
0

Here is what the documentation (as of Python 3.11) for typing.Optional says:

Optional type.
Optional[X] is equivalent to X | None (or Union[X, None]).
Note that this is not the same concept as an optional argument, which is one that has a default. An optional argument with a default does not require the Optional qualifier on its type annotation just because it is optional. For example:

def foo(arg: int = 0) -> None:
   ...

On the other hand, if an explicit value of None is allowed, the use of Optional is appropriate, whether the argument is optional or not. For example:

def foo(arg: Optional[int] = None) -> None:
   ...

Changed in version 3.10: Optional can now be written as X | None. See union type expressions.

Here your function expects a list and if it isn't set, it initializes it to an empty list.

In that case, we could think about typing the argument as list and setting a default value of an empty list as such:

def foo(input_list: list[str] = []) -> None:
    ...

However, in Python, for some reason, the default parameter is evaluated at function definition instead of at function execution, that means that we have the following behaviour:

>>> def foo(bar=[]):
...     bar.append(None)
...     return bar
...
>>> foo()
[None]
>>> foo()
[None, None]
>>> foo()
[None, None, None]

This can be quite confusing and that is why you should probably use the following form instead:

def foo(input_list: Optional[list[str]] = None) -> None:
    input_list = input_list if input_list else []
    ...

Or alternatively if you're using Python >= 3.10:

def foo(input_list: list[str] | None = None) -> None:
    input_list = input_list if input_list else []
    ...
  • 2
    `def foo(input_list: list[str]] = []) -> None:` is not acceptable because `[]` is a mutable default argument which is usually a major bad idea in Python: https://stackoverflow.com/questions/1132941/least-astonishment-and-the-mutable-default-argument – Jagerber48 Apr 12 '23 at 23:08
  • To be fair, it's only a bad idea if your function *mutates* `input_list`. If it's a read-only argument, `[]` is an OK default. (And you can emphasize that it's read-only by using `Sequence` or `Iterable` as the type hint, with `[]` being *a* valid `Sequence`/`Iterable`). – chepner Apr 13 '23 at 01:51
0

I don't think type annotations have the idea of a variable's type changing during its lifetime.

If you want to distinguish the type of the parameter from the type of the variable throughout the rest of the function, use different variables.

def foo(input_list: Optional[List] = None):
    real_input_list: list = input_list or []
    # rest of code uses `real_input_list

I fully admit that this is ugly.

Barmar
  • 741,623
  • 53
  • 500
  • 612