2026, Jan 03 12:01

Глубокий map в Python: как сохранить форму без higher‑kinded types

Разбираем deep_apply — глубокий map по вложенным структурам в Python: рантайм-поведение, ограничения типизации, NamedTuple, tuple[T, ...], отсутствие HKT.

Применить функцию к каждому листу в произвольно вложенной структуре кажется простым — пока не подключается типизация. Списки внутри кортежей внутри списков, где-то между ними несколько dict и однородные значения NamedTuple — и при этом результат обязан точно повторять форму исходных данных. Во время выполнения это несложно. А вот научить статический проверщик типов увидеть и гарантировать такую структуру — это упирается в пределы того, что система типов Python сегодня способна выразить.

Постановка задачи

Цель — утилита наподобие map, которая проходит по вложенным итерируемым структурам любой глубины, применяет вызываемый объект к каждому листовому значению и возвращает новую структуру с тем же контуром. Ключи в объектах dict должны сохраняться, а однородные значения NamedTuple следует преобразовывать по полям.

>>> g = lambda x: x * 2
>>> payload = [([1, 2], [3, 4]), {"foo": 5, "bar": 6}]
>>> deep_apply(g, payload)
[([2, 4], [6, 8]), {"foo": 10, "bar": 12}]

Реализация для рантайма с подсказками типов (боль типизации)

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

import inspect
from collections.abc import Iterable
from typing import Any, Callable, Generator, NamedTuple, cast

type Layer[T] = list[T] | tuple[T] | dict[Any, T] | Generator[T]

type DeepNest[T] = T | Layer["DeepNest[T]"]

def deep_apply[TIn, TOut](
        fn: Callable[[TIn], TOut], tree: DeepNest[TIn]
    ) -> DeepNest[TOut]:
    """
    Takes a nested iterable structure and applies fn to all of its nodes,
    returning them in the same structure.
    """

    kind = type(tree)
    match tree:
        case dict():
            return {
                k: deep_apply(fn, v)
                for k, v in tree.items()
            }
        # Проверка на NamedTuple-подобный тип
        case _ if hasattr(kind, "_fields") and isinstance(
                getattr(kind, "_fields"), tuple
            ):
            tmp = {
                k: deep_apply(fn, v)
                for k, v in cast(NamedTuple, tree)._asdict().items()
            }
            return cast(DeepNest[TOut], kind(**tmp))
        case _ if inspect.isgenerator(tree):
            return (deep_apply(fn, s) for s in tree)
        # str нужно обрабатывать до проверки на Iterable
        case str():
            return fn(tree)
        case Iterable():
            kind = cast(type[list], kind)
            return kind(map(lambda s: deep_apply(fn, s), tree))
        case _:
            return fn(tree)

Почему система типов не может выразить точную форму

Жесткое требование — «объект на выходе имеет ровно ту же структуру, что и на входе» — нельзя выразить текущими подсказками типов Python. Даже для плоских списков нет сохраняющих структуру аннотаций, которые отслеживали бы преобразование отдельных элементов при сохранении формы контейнера; более того, такие аннотации противоречили бы задумке списка.

В лучшем случае можно надеяться сохранять объединения типов по уровням. Это уже компромисс, потому что уровень, где смешаны list и tuple, будет иметь тип list | tuple, и конкретная форма потеряется. Концептуально сопоставление выглядело бы так:

list[A | list[A | list[A]]] -> list[B | list[B | list[B]]]
list[A | tuple[A, A | list[A]] -> list[B | tuple[B, B | list[B]]

Даже такая ослабленная сохранность недостижима. Самый очевидный путь — параметризовать «форму» типом листьев, а затем переотобразить ту же форму с новым типом листьев. Для этого нужны higher-kinded types — вложенные дженерики вида C[A], которых в Python нет. Желаемая сигнатура могла бы выглядеть так:

def deep_apply[A, B, C: NestedStructure](fn: Callable[[A], B], tree: C[A]) -> C[B]: ...

Такие вложенные дженерики в стиле C[...] не поддерживаются. Есть открытый запрос на добавление higher-kinded types, и он даже не оформлен в виде PEP.

Ограничения и приводят к этой проблеме. Тип листьев до преобразования абстрактен, тип листьев после — тоже, а внешняя структура абстрактна сама по себе, но должна совпадать между входом и выходом. Ни один из этих абстрактных компонентов не выводится из других, а чтобы сузить внешний тип по внутреннему, системе типов нужна выразительность higher-kinded types. У Python её пока нет.

Что можно сделать сейчас

Коротко: с текущей поддержкой типизации в Python это, скорее всего, просто невозможно. Простите.

Можно сохранить поведение на рантайме и базовые аннотации, понимая, что проверщики типов не будут верифицировать сохранность формы. Если идёте этим путём, стоит внести одну небольшую правку в аннотации: для кортежей обычно нужна вариативная форма. Однопараметрическая запись означает кортеж из одного элемента, а не произвольной длины однородный кортеж.

Тот же рантайм с небольшой правкой аннотаций

Эта версия сохраняет точное поведение и на уровне типов явно показывает, что кортежи однородные и вариативные.

import inspect
from collections.abc import Iterable
from typing import Any, Callable, Generator, NamedTuple, cast

type Layer[T] = list[T] | tuple[T, ...] | dict[Any, T] | Generator[T]

type DeepNest[T] = T | Layer["DeepNest[T]"]

def deep_apply[TIn, TOut](
        fn: Callable[[TIn], TOut], tree: DeepNest[TIn]
    ) -> DeepNest[TOut]:
    """
    Takes a nested iterable structure and applies fn to all of its nodes,
    returning them in the same structure.
    """

    kind = type(tree)
    match tree:
        case dict():
            return {
                k: deep_apply(fn, v)
                for k, v in tree.items()
            }
        # Проверка на NamedTuple-подобный тип
        case _ if hasattr(kind, "_fields") and isinstance(
                getattr(kind, "_fields"), tuple
            ):
            tmp = {
                k: deep_apply(fn, v)
                for k, v in cast(NamedTuple, tree)._asdict().items()
            }
            return cast(DeepNest[TOut], kind(**tmp))
        case _ if inspect.isgenerator(tree):
            return (deep_apply(fn, s) for s in tree)
        # str нужно обрабатывать до проверки на Iterable
        case str():
            return fn(tree)
        case Iterable():
            kind = cast(type[list], kind)
            return kind(map(lambda s: deep_apply(fn, s), tree))
        case _:
            return fn(tree)

Почему важно знать про эти ограничения

Это практичный пример того, где статическая типизация Python проводит черту. Хочется выразить в типах «та же форма, другие листья», но без higher-kinded types это недостижимо. Осознавая эту границу, вы не будете усложнять аннотации там, где проверщик всё равно не сможет рассуждать о них, и будете трезво оценивать ожидания при работе с вложенными контейнерами, неизменными ключами словарей и однородными NamedTuple.

Выводы

Если вам нужен глубокий map, который сохраняет форму на рантайме, его можно написать и оставить простые аннотации — важно помнить, что они не зафиксируют нужный инвариант. Если подходит частичная точность, мысленно моделируйте объединения типов по уровням, но не рассчитывайте, что статические инструменты отследят такое отображение. Если используете кортежи как однородные последовательности, аннотируйте их как tuple[T, ...]. А если вы рассчитываете на гарантию сохранения формы, знайте: для этого понадобятся higher-kinded types, которых в Python пока нет.