2025, Oct 18 16:16

Почему дерево по отступам в Python падает с RecursionError и как это исправить

Разбираем коварный баг при построении дерева по отступам в Python: общий список children на уровне класса вызывает RecursionError. Причина и решение.

Построить вложенное дерево из плана счетов кажется простой задачей — пока структура не начинает самовоспроизводиться, а стек не переполняется. Классическая ловушка в Python достаточно незаметна, чтобы пройти код‑ревью, но достаточно коварна, чтобы в продакшене привести к RecursionError. Ниже — короткое объяснение, почему это происходит при построении дерева по уровню отступов и как аккуратно это исправить.

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

Исходные данные — это план счетов, где иерархия определяется отступами. Каждый уровень смещён на три пробела. Нужно разобрать каждую строку, определить её глубину и вложить узел под соответствующего родителя.

Ниже усечённый класс и драйвер, иллюстрирующие подход:

class ChartItem:
    ancestor = None
    acct_no: int | None
    label: str
    raw_line: str
    kind: str | None
    subtype: str | None
    is_header_flag: bool = False
    children: list = list()  # список потомков, предполагавшийся для каждого отдельного экземпляра
    def __init__(self, line_in: str, ancestor=None, kind: str = None, subtype: str = None):
        self.raw_line = line_in
        self.ancestor = ancestor
        self.kind = kind
        self.subtype = subtype
        cleaned = line_in.strip()
        ...  # код, который вычисляет self.acct_no и self.label из cleaned
    def is_header(self) -> bool:
        return self.is_header_flag
    def nesting_level(self) -> int:
        return (len(self.raw_line) - len(self.raw_line.strip())) / 3
    def add_child(self, line_in, type_hint: str = None, detail_hint: str = None):
        if len(self.children) > 0:
            if calc_level(line_in) > self.children[-1].nesting_level():
                self.children[-1].add_child(line_in, type_hint=type_hint, detail_hint=detail_hint)
            else:
                self.children.append(ChartItem(
                    line_in,
                    ancestor=self,
                    kind=self.kind if type_hint is None else type_hint,
                    subtype=self.subtype if detail_hint is None else detail_hint
                ))
        else:
            self.children.append(ChartItem(
                line_in,
                ancestor=self,
                kind=self.kind if type_hint is None else type_hint,
                subtype=self.subtype if detail_hint is None else detail_hint
            ))
        return
if __name__ == "__main__":
    root_nodes: list[ChartItem] = list()
    for (row_text, type_hint, subtype_hint) in supply_rows():
        lvl = (len(row_text) - len(row_text.lstrip())) / 3
        if lvl == 0:
            root_nodes.append(ChartItem(row_text, ancestor=None, kind=type_hint, subtype=subtype_hint))
        else:
            if type_hint is not None:
                root_nodes[-1].add_child(row_text, type_hint=type_hint, detail_hint=subtype_hint)
            else:
                root_nodes[-1].add_child(row_text)

Почему всё рушится

Рекурсия появляется в ветке, которая делегирует обработку последнему потомку, если входящая строка глубже уровня вложенности этого потомка. На первый взгляд логика соответствует задумке построения дерева. Проблема в том, что список, предназначенный для хранения детей узла, объявлен на уровне класса, а не экземпляра. В Python изменяемые атрибуты, заданные у класса, общие для всех экземпляров. То есть каждый ChartItem ссылается на один и тот же список children.

Из-за общего списка условие «углубиться к последнему потомку» продолжает передавать управление узлу, который с точки зрения алгоритма не становится соседним элементом, а остаётся тем же логическим местом для всех экземпляров. Метод зовёт сам себя, стек вызовов растёт, и интерпретатор в итоге выбрасывает RecursionError. Наблюдаемое «самовложение» одного и того же узла снова и снова — закономерный итог единственного общего контейнера children для всех экземпляров.

Исправление

Сделайте контейнер children атрибутом экземпляра. Инициализируйте его в конструкторе, чтобы у каждого узла был собственный список. Остальную логику можно оставить прежней.

class ChartItem:
    ancestor = None
    acct_no: int | None
    label: str
    raw_line: str
    kind: str | None
    subtype: str | None
    is_header_flag: bool = False
    def __init__(self, line_in: str, ancestor=None, kind: str = None, subtype: str = None):
        self.raw_line = line_in
        self.ancestor = ancestor
        self.kind = kind
        self.subtype = subtype
        self.children: list[ChartItem] = []  # контейнер для потомков на уровне экземпляра
        cleaned = line_in.strip()
        ...  # код, который вычисляет self.acct_no и self.label из cleaned
    def is_header(self) -> bool:
        return self.is_header_flag
    def nesting_level(self) -> int:
        return (len(self.raw_line) - len(self.raw_line.strip())) / 3
    def add_child(self, line_in, type_hint: str = None, detail_hint: str = None):
        if len(self.children) > 0:
            if calc_level(line_in) > self.children[-1].nesting_level():
                self.children[-1].add_child(line_in, type_hint=type_hint, detail_hint=detail_hint)
            else:
                self.children.append(ChartItem(
                    line_in,
                    ancestor=self,
                    kind=self.kind if type_hint is None else type_hint,
                    subtype=self.subtype if detail_hint is None else detail_hint
                ))
        else:
            self.children.append(ChartItem(
                line_in,
                ancestor=self,
                kind=self.kind if type_hint is None else type_hint,
                subtype=self.subtype if detail_hint is None else detail_hint
            ))
        return

Когда дети больше не разделяются между экземплярами, делегирование к «последнему потомку» начинает работать как задумано: более глубокие строки уходят вниз по текущей ветке, а соседние элементы добавляются на текущем узле.

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

Изменяемые поля на уровне класса — скрытый источник связности между экземплярами. В деревьях это может незаметно испортить структуру или запустить бесконечную рекурсию при вставке. Даже опытные Python‑разработчики временами забывают, что списки и словари, объявленные у класса, являются общими для всех экземпляров, если их явно не переопределить в конструкторе.

Этот паттерн не привязан к разбору плана счетов. Любой алгоритм, который постепенно строит вложенные структуры по порядку или по отступам, опирается на контейнеры на уровне узлов. Правильно проведённая граница — это разница между аккуратной иерархией и падением со стектрейсом.

Итоги

Если рекурсивная вставка неожиданно зацикливается, проверьте два момента в самом начале. Во‑первых, действительно ли ветвление меняется между вызовами. Во‑вторых, убедитесь, что все контейнеры, которые должны быть у каждого экземпляра, инициализируются в конструкторе, а не на уровне класса. В Python списки и словари, объявленные в области класса, общие для всех экземпляров и ломают логику, зависящую от состояния, вроде построения деревьев. Переместите их в __init__, упростите правила вставки — и иерархия сложится ровно так, как подсказывают данные.

Статья основана на вопросе на StackOverflow от In Hoc Signo и ответе от John Gordon.