2025, Oct 17 07:17

sum() в Python, _typeshed и SupportsAdd: почему строки запрещены и что с этим делать

Почему sum() в Python не принимает строки: разбираем typeshed, протокол SupportsAdd и Addable, чем это отличается от рантайма и чем заменить — join().

Когда начинаешь разбираться в sum() Python в IDE, легко наткнуться на загадочные типы вроде _Addable. Функция явно складывает числа и умеет объединять списки при заданном стартовом значении, но со строками работать отказывается, хотя у строк есть оператор +. Что на самом деле означает здесь «addable» и почему sum(['a', 'b'], '') падает во время выполнения?

Воспроизвести ситуацию

Сначала — ожидаемое поведение: суммирование чисел и конкатенация списков через начальное значение.

a_numbers = list(range(1, 4))
b_numbers = list(range(4, 7))

sum(a_numbers)  # 6

nested_lists = [a_numbers, b_numbers]
sum(nested_lists, [])  # [1, 2, 3, 4, 5, 6]

А вот неожиданность: у строк есть сложение, но sum отвергает их даже если передан start.

sum(["x", "y"], "")

TypeError: sum() can't sum strings [use ''.join(seq) instead]

Откуда берётся _Addable

Подсказка — не в рантайме Python, а в информации о типах. Тип-подсказка, которую вы видели, привязана к протоколу SupportsAdd, определённому во вспомогательном модуле только со стабами под названием _typeshed. Этот модуль — часть typeshed, общего источника сведений о типах для стандартной библиотеки и популярных пакетов, которым пользуются типизаторы и языковые серверы. Он поставляется как .pyi-стабы и не импортируется во время выполнения. Иными словами, import _typeshed не сработает при запуске вашей программы; он существует лишь для статического анализа.

SupportsAdd очень прост: это протокол, который говорит «у типа есть __add__».

class AddProto(Protocol[_U_contra, _V_co]):
    def __add__(self, other: _U_contra, /) -> _V_co: ...

В таком виде SupportsAdd[Any, Any] означает «любой класс, в котором определён __add__». Переменные типа _Addable, которые вы видели, связаны с этим протоколом.

AddableA = TypeVar("AddableA", bound=AddProto[Any, Any])
AddableB = TypeVar("AddableB", bound=AddProto[Any, Any])

Как типизируют sum()

Заглушка typeshed для sum перегружена, чтобы описать несколько распространённых сценариев. Среди них — особый случай для булевых значений и литеральных целых, а также общий случай для «складываемых» типов.

AddableA = TypeVar("AddableA", bound=AddProto[Any, Any])
AddableB = TypeVar("AddableB", bound=AddProto[Any, Any])

@type_check_only
class _SumNoDefaultProto(AddProto[Any, Any], SupportsRAdd[int, Any], Protocol): ...

_SumNoDefaultT = TypeVar("_SumNoDefaultT", bound=_SumNoDefaultProto)

@overload
def sum(items: Iterable[bool | _LiteralInteger], /, start: int = 0) -> int: ...

@overload
def sum(items: Iterable[_SumNoDefaultT], /) -> _SumNoDefaultT | Literal[0]: ...

@overload
def sum(items: Iterable[AddableA], /, start: AddableB) -> AddableA | AddableB: ...

Это фиксирует, что sum работает по итерируемым коллекциям «складываемых» объектов и при желании принимает стартовое значение. Также указано, что если start не задан, sum ведёт себя так, будто начинается с 0. Поэтому использование типа, который нельзя сложить с int, приведёт к ошибке, если только ваш тип не поддерживает такую операцию. Статические анализаторы типов отражают это через перегрузки.

Так почему sum() отвергает строки?

Суть в следующем: статическая типизация Python не стремится описать каждую деталь семантики рантайма. Запрет на суммирование строк — это правило времени выполнения, заложенное в реализации. В типах sum в typeshed оно не смоделировано, и статические анализаторы не обязаны о нём предупреждать. Поэтому sum(["x", "y"], "") во многих инструментах проходит проверку типов, но падает при запуске.

Короче говоря, «addable» в этом контексте для типизации означает «есть __add__», а не «sum всегда это примет». Строки — заметное исключение, которое sum намеренно запрещает в рантайме.

Что делать со строками

Сообщение об ошибке уже подсказывает правильный приём для строк: используйте join.

"".join(["x", "y"])  # "xy"

Пользовательские типы и sum()

Любой пользовательский класс, реализующий __add__, удовлетворяет протоколу SupportsAdd и потому считается допустимым элементом для sum с точки зрения статической типизации. Если вы используете форму без стартового значения, помните: sum действует как будто начинает с 0, значит, вашему типу нужно поддерживать выражение 0 + экземпляр. Если это не так, передайте start своего типа, чтобы первое сложение было корректно определено.

Почему это важно

Это хороший пример границы между семантикой рантайма Python и его статической системой типов. Заглушки typeshed для стандартной библиотеки дают мощные ориентиры редакторам и проверяющим типы, но они не отражают каждое частное правило, реализованное в CPython. Понимание того, что _typeshed — ресурс только для проверки типов, что SupportsAdd говорит лишь о наличии __add__, а особые рантайм-ограничения вроде запрета на строки в sum не обязательно представлены на уровне типов, помогает избежать сюрпризов и делает подсказки типов полезнее в повседневной работе.

Выводы

Увидев _Addable в подсказках типов sum, читайте это как «любой тип с __add__», как определено протоколом SupportsAdd в _typeshed. Ожидайте, что статические анализаторы примут sum для любого такого типа, но помните: рантайм может накладывать дополнительные правила, например запрет на строки. Для конкатенации строк используйте ''.join(...), а применяя sum к своим классам, передавайте стартовое значение того же типа, если сложение с целыми числами для ваших объектов не имеет смысла.

Статья основана на вопросе с StackOverflow от tarheeljks и ответе автора STerliakov.