Перегрузить метод по сигнатуре как в методе `next`

Я хочу получить метод, который может принимать (в том числе None) или не принимать параметр. Например так, как это работает во встроенном методе next, который может принимать параметр default, может принимать None в качестве параметра default, а может не принимать его вовсе, вызывая StopIterationError в случае, если последовательность пуста. В соответствии с этим ответом на StackOverflow я пытаюсь перегрузить метод touch_first следующим образом:

from itertools import chain, repeat

class EnumerateUtils:
    def touch_first(self, iterable):
        # Raise StopIterationError if sequence is empty
        
        iterable_iterator = iter(iterable)
        first_element = next(iterable_iterator)
        return (first_element, chain(repeat(first_element, 1), iterable_iterator))

class EnumerateUtilsOverloaded(EnumerateUtils):
    def touch_first(self, iterable, default, *args, **kwargs):
        # No raise errors
        try:
            return super().touch_first(iterable, *args, **kwargs)
        except StopIteration:
            return default

sequence = chain(range(0, 5), repeat(None, 1), range(6, 10))
empty_seq = []

def check_without_default(seq):
    util = EnumerateUtilsOverloaded()
    item = util.touch_first(seq)
    if item is not None:
        first_element, new_sequence = item
        print(first_element)
        print(list(new_sequence))

check_without_default(sequence) # -> I WANT: 0\n[0, 1, 2, 3, 4, None, 6, 7, 8, 9]
check_without_default(empty_seq) # -> I WANT: no print, but StopIterationError raised as in 'next' method without default

Однако, я получаю, лишь TypeError "EnumerateUtilsOverloaded.touch_first() missing 1 required positional argument: 'default'", Потому что я не указал дефолтное значение для переменной default. Однако, если я сделаю это, то код отработает штатно, но check_without_default(empty_seq) ничего не выведет и не поднимет StopIterationError, как это делает метод next, а мне очень хотелось бы этого.

Как я могу получить желаемую логику выполнения как в методе next? Я не могу отличить отсутствие default от default=None.


В итоге, в соответствии c ответом @andreymal, я использовал следующий код (без классов):

from itertools import chain, repeat

_sentinel = object()

def touch_first(iterable, default=_sentinel, *args, **kwargs):
    iterable_iterator = iter(iterable)
    first_element = None
    if default is _sentinel:
        first_element = next(iterable_iterator)
    else:
        first_element = next(iterable_iterator, default)
    
    return (first_element, chain(repeat(first_element, 1), iterable_iterator))

Ответы (1 шт):

Автор решения: andreymal

В подобных ситуациях часто используется такой подход — создать где-нибудь уникальный объект, который точно не будет передаваться извне как аргумент, и использовать его как значение аргумента по умолчанию.

_sentinel = object()

Поскольку невозможно создать ещё один такой объект, для которого проверка объект is _sentinel вернёт True (и мы предполагаем, что сторонний код не станет лезть во внутренности модуля, чтобы вытянуть конкретно этот _sentinel) — мы можем использовать это как проверку на наличие или отсутствие переданного в функцию аргумента.

Стоит иметь в виду, что такой подход пока что плохо дружит с аннотациями типов, но если вы не используете аннотации, то сойдёт.

_sentinel = object()

class EnumerateUtilsOverloaded(EnumerateUtils):
    def touch_first(self, iterable, default=_sentinel, *args, **kwargs):
        try:
            return super().touch_first(iterable, *args, **kwargs)
        except StopIteration:
            if default is _sentinel:
                # Значение по умолчанию не указано — бросаем ошибку
                raise
            # Попали сюда — значит значение по умолчанию указано
            return default

Поскольку в функции есть *args, можно использовать альтернативный похдод — убрать аргумент default, тогда он попадёт в первый элемент списка args. Какой способ лучше или хуже — зависит от ситуации и желаемого интерфейса функции (может быть, default вообще должен быть keyword-only аргументом, например?).

class EnumerateUtilsOverloaded(EnumerateUtils):
    def touch_first(self, iterable, *args, **kwargs):
        try:
            # [1:] выкинет аргумент default, если он есть
            return super().touch_first(iterable, *args[1:], **kwargs)
        except StopIteration:
            if not args:
                # Значение по умолчанию не указано — бросаем ошибку
                raise
            # Попали сюда — значит значение по умолчанию указано
            return args[0]
→ Ссылка