5

As we know, optional arguments must be at the end of the arguments list, like below:

def func(arg1, arg2, ..., argN=default)

I saw some exceptions in the PyTorch package. For example, we can find this issue in torch.randint. As it is shown, it has a leading optional argument in its positional arguments! How could be possible?

Docstring:
randint(low=0, high, size, \*, generator=None, out=None, dtype=None, layout=torch.strided, device=None, requires_grad=False) -> Tensor

How can we define a function in a similar way as above?

javadr
  • 310
  • 2
  • 12
  • Since you appear to desire an actual working implementation: What calling behaviour do you expect for this? ``\*`` is not a valid signature specifier. Did you mean either of just ``\`` or ``*``? – MisterMiyagi Sep 09 '20 at 11:41
  • Actually, I have no idea what exactly `\*` is! I've just copied from the `torch.randint`'s documentation. As you mentioned, I am so curious about its implementation in Python. – javadr Sep 09 '20 at 11:59

3 Answers3

4

My other answer was about reverse-engineering the torch library, however I want to dedicate this answer on how a similar mechanism can be achieved in a non-hacky, straight forward way.

We have the multipledispatch library:

A relatively sane approach to multiple dispatch in Python. This implementation of multiple dispatch is efficient, mostly complete, performs static analysis to avoid conflicts, and provides optional namespace support. It looks good too.

So let's utilize it:

from multipledispatch import dispatch

@dispatch(int, int)
def randint(low, high):
    my_randint(low, high)

@dispatch(int)
def randint(high):
    my_randint(0, high)

def my_randint(low, high):
    print(low, high)

# 0 5
randint(5)

# 2 3
randint(2, 3)
Aviv Yaniv
  • 6,188
  • 3
  • 7
  • 22
2

Your discovery fascinated me, as it's indeed illegal in Python (and all other languages I know) to have leading optional arguments, that would surely raise in our case:

SyntaxError: non-default argument follows default argument

I got suspicious, yet I've searched on the source code:

I found, at lines 566-596 of TensorFactories.cpp that there are actually several (!) implementations of randint:

// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ randint ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Tensor randint(int64_t high, IntArrayRef size, const TensorOptions& options) {
  return native::randint(high, size, c10::nullopt, options);
}

Tensor randint(
    int64_t high,
    IntArrayRef size,
    c10::optional<Generator> generator,
    const TensorOptions& options) {
  return native::randint(0, high, size, generator, options);
}

Tensor randint(
    int64_t low,
    int64_t high,
    IntArrayRef size,
    const TensorOptions& options) {
  return native::randint(low, high, size, c10::nullopt, options);
}

Tensor randint(
    int64_t low,
    int64_t high,
    IntArrayRef size,
    c10::optional<Generator> generator,
    const TensorOptions& options) {
  auto result = at::empty(size, options);
  return result.random_(low, high, generator);
}

This pattern reoccurred at lines 466-471 of gen_pyi.py where it generates type signatures for top-level functions:

        'randint': ['def randint(low: _int, high: _int, size: _size, *,'
                    ' generator: Optional[Generator]=None, {}) -> Tensor: ...'
                    .format(FACTORY_PARAMS),
                    'def randint(high: _int, size: _size, *,'
                    ' generator: Optional[Generator]=None, {}) -> Tensor: ...'
                    .format(FACTORY_PARAMS)],

So, what basically happens is that there is no "real" optional parameter rather than several functions, in which one is present and in the other, it's not.

That means, when randint is called without the low parameter it is set as 0:

Tensor randint(
    int64_t high,
    IntArrayRef size,
    c10::optional<Generator> generator,
    const TensorOptions& options) {
  return native::randint(0, high, size, generator, options);
}

Further research, as for OP request on how that possible that there are multiple functions with the same name and different arguments:

Returning once again to gen_pyi.py we see that these functions are collected to unsorted_function_hints defined at line 436, then it's used to create function_hints at lines 509-513, and finally function_hints is set to env at line 670.

The env dictionary is used to write pyi stub files.

These stub files make use of Function/method overloading as described in PEP-484.

Function/method overloading, make use of @overload decorator:

The @overload decorator allows describing functions and methods that support multiple different combinations of argument types. This pattern is used frequently in builtin modules and types.

Here is an example:

from typing import overload

class bytes:
    ...
    @overload
    def __getitem__(self, i: int) -> int: ...
    @overload
    def __getitem__(self, s: slice) -> bytes: ...

So we basically have a definition of the same function __getitem__ with different arguments.

And another example:

from typing import Callable, Iterable, Iterator, Tuple, TypeVar, overload

T1 = TypeVar('T1')
T2 = TypeVar('T2')
S = TypeVar('S')

@overload
def map(func: Callable[[T1], S], iter1: Iterable[T1]) -> Iterator[S]: ...
@overload
def map(func: Callable[[T1, T2], S],
        iter1: Iterable[T1], iter2: Iterable[T2]) -> Iterator[S]: ...
# ... and we could add more items to support more than two iterables

Here we have a definition of the same function map with a different number of arguments.

Aviv Yaniv
  • 6,188
  • 3
  • 7
  • 22
  • Thanks. I'd seen these definitions but I didn't get how to implement it in python! As you know, It is not allowed to have a unique name for several functions with different types of arguments in Python as it is possible in C/C++. – javadr Sep 09 '20 at 11:26
  • @javadr Thank you for your respone, elaborated regarding the function/method overloading of PEP-484 :) – Aviv Yaniv Sep 09 '20 at 11:44
  • 1
    PEP-484 was what I wanted to find out! :D +1 – javadr Sep 09 '20 at 12:09
  • Please note that ``typing.overload`` is a no-op at runtime. It merely exists as an *annotation* for use by static type checkers. – MisterMiyagi Sep 09 '20 at 12:12
2

A single function is not allowed to have only leading optional parameters:

8.6. Function definitions

[...] If a parameter has a default value, all following parameters up until the “*” must also have a default value — this is a syntactic restriction that is not expressed by the grammar.

Note this excludes keyword-only parameters, which never receive arguments by position.


If desired, one can emulate such behaviour by manually implementing the argument to parameter matching. For example, one can dispatch based on arity, or explicitly match variadic arguments.

def leading_default(*args):
    # match arguments to "parameters"
    *_, low, high, size = 0, *args
    print(low, high, size)

leading_default(1, 2)     # 0, 1, 2
leading_default(1, 2, 3)  # 1, 2, 3

A simple form of dispatch achieves function overloading by iterating signatures and calling the first matching one.

import inspect


class MatchOverload:
    """Overload a function via explicitly matching arguments to parameters on call"""
    def __init__(self, base_case=None):
        self.cases = [base_case] if base_case is not None else []

    def overload(self, call):
        self.cases.append(call)
        return self

    def __call__(self, *args, **kwargs):
        failures = []
        for call in self.cases:
            try:
                inspect.signature(call).bind(*args, **kwargs)
            except TypeError as err:
                failures.append(str(err))
            else:
                return call(*args, **kwargs)
        raise TypeError(', '.join(failures))


@MatchOverload
def func(high, size):
    print('two', 0, high, size)


@func.overload
def func(low, high, size):
    print('three', low, high, size)


func(1, 2, size=3)    # three 1 2 3
func(1, 2)            # two 0 1 2
func(1, 2, 3, low=4)  # TypeError: too many positional arguments, multiple values for argument 'low'
MisterMiyagi
  • 44,374
  • 10
  • 104
  • 119
  • @MinsterMiyagi Thanks. But now we can't call this function with keyword arguments. Please take a look at AvivYaniv's response. As he mentioned some parts of its implementation it seems it has several definitions. But as far as I know, it is impossible in python. – javadr Sep 09 '20 at 11:33
  • @javadr As mentioned, one can do arity dispatch. *Technically* this is the same mechanism as [singledispatch](https://docs.python.org/3/library/functools.html#functools.singledispatch) – registering several functions and calling the matching one. However, this requires to be tailored to exactly the dispatch behaviour desired – which the question does not detail. Note that as per the signature in the question, none of ``low``, ``high``, or ``size`` seem to allow calling as keyword arguments. They are positional-only, due to preceding ``\``. – MisterMiyagi Sep 09 '20 at 11:38
  • @javadr I've added an example of dispatch/overloading based on signature. Is that what you are looking for? – MisterMiyagi Sep 09 '20 at 12:01
  • 1
    @MinsterMiyagi Although your solution is a very nice implementation, I prefer the way that PEP-484 does. +1 – javadr Sep 09 '20 at 12:06
  • @javadr Please clarify. PEP 484 is on type *hints* and *annotations* – ``typing.overload`` has no runtime behaviour whatsoever It does not actually overload anything, merely hint an alternate signature to static type checkers. – MisterMiyagi Sep 09 '20 at 12:10
  • https://www.python.org/dev/peps/pep-0484/#function-method-overloading – javadr Sep 09 '20 at 12:12
  • 1
    @javadr I'm well aware of what it is, and especially that *it does not actually overload anything*. As the linked PEP says: "The ``@overload``-decorated definitions are for the benefit of the type checker only, since they will be overwritten by the non-``@overload``-decorated definition, while the latter is used at runtime but should be ignored by a type checker. " – MisterMiyagi Sep 09 '20 at 12:14