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.