Из дерева в плоский список: рекурсивный обход строк и сохранение иерархии

· 9 мин

Содержание

Готового метода, который выгрузит дерево значений в таблицу значений одной строкой, в платформе 1С 8.3 нет. Алгоритм такой: создать таблицу с теми же колонками, что у дерева, и обойти все строки рекурсивно, копируя каждую через ЗаполнитьЗначенияСвойств().

Сложности начинаются потом. Теряется иерархия, задваиваются суммы групп, на форме вместо дерева оказывается ДанныеФормыДерево. Ниже рабочий код и разбор каждой из этих ловушек. Код серверный и работает одинаково в любой конфигурации: это механизм платформы, а не БП или УТ.

Чем дерево отличается от таблицы и что теряется при выгрузке

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

Что есть у строки дерева и чего нет у строки таблицы:

  • 🌳 Строка.Родитель: ссылка на строку уровнем выше, у верхнего уровня Неопределено;
  • 📂 Строка.Строки: коллекция дочерних строк;
  • 🔢 Строка.Уровень(): метод, возвращает глубину, верхний уровень равен 0;
  • 📍 положение среди соседей: Строка.Родитель.Строки.Индекс(Строка).

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

Что нужно на выходеКакие колонки добавить
Просто все строки подрядничего, только колонки дерева
Отступы в печатной форме или выгрузке в ExcelУровень
Возможность собрать дерево обратноНомерСтроки и НомерРодителя
Только конечные записи без группничего, фильтр «нет подчинённых строк»
Путь вида 1.3.2 для сортировкиПуть (строка)

Готовим таблицу значений: копируем колонки дерева

Колонки дерева и таблицы описываются одинаково: имя, тип, заголовок, ширина. Поэтому копирование сводится к циклу по Дерево.Колонки.

Функция НоваяТаблицаПоДереву(Дерево)

    Таблица = Новый ТаблицаЗначений;
    Для Каждого Колонка Из Дерево.Колонки Цикл
        Таблица.Колонки.Добавить(Колонка.Имя, Колонка.ТипЗначения,
            Колонка.Заголовок, Колонка.Ширина);
    КонецЦикла;

    Возврат Таблица;

КонецФункции

Тип колонки переносите обязательно. Колонка без типа принимает что угодно, но при выводе в табличный документ, в построитель или в запрос через ПоместитьВоВременнуюТаблицу начнутся проблемы. Запрос не примет колонку без описания типов.

Служебные колонки добавляйте с явными типами:

ТипЧисло = Новый ОписаниеТипов("Число", Новый КвалификаторыЧисла(10, 0));
ТипСтрока = Новый ОписаниеТипов("Строка", , Новый КвалификаторыСтроки(100));

Таблица.Колонки.Добавить("Уровень_", ТипЧисло, "Уровень");
Таблица.Колонки.Добавить("НомерСтроки_", ТипЧисло);
Таблица.Колонки.Добавить("НомерРодителя_", ТипЧисло);

Не называйте служебные колонки Родитель, Строки или Уровень без суффикса. Во-первых, такая колонка может уже быть в дереве, и Колонки.Добавить() упадёт на повторном имени. Во-вторых, у строки дерева есть одноимённые свойства, и при копировании через ЗаполнитьЗначенияСвойств() в колонку попадёт не то, что вы ждёте.

Имена с подчёркиванием в конце (Уровень_) с колонками прикладных деревьев почти не пересекаются. Ещё надёжнее заранее проверить имя через Дерево.Колонки.Найти("Уровень_"): метод вернёт Неопределено, если колонки нет.

Рекурсивная выгрузка дерева значений в таблицу

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

Процедура ВыгрузитьСтрокиДерева(СтрокиДерева, Таблица, СписокКолонок)

    Для Каждого СтрокаДерева Из СтрокиДерева Цикл
        НоваяСтрока = Таблица.Добавить();
        ЗаполнитьЗначенияСвойств(НоваяСтрока, СтрокаДерева, СписокКолонок);
        ВыгрузитьСтрокиДерева(СтрокаДерева.Строки, Таблица, СписокКолонок);
    КонецЦикла;

КонецПроцедуры

Функция ДеревоВТаблицу(Дерево) Экспорт

    Таблица = НоваяТаблицаПоДереву(Дерево);

    ИменаКолонок = Новый Массив;
    Для Каждого Колонка Из Дерево.Колонки Цикл
        ИменаКолонок.Добавить(Колонка.Имя);
    КонецЦикла;
    СписокКолонок = СтрСоединить(ИменаКолонок, ",");

    ВыгрузитьСтрокиДерева(Дерево.Строки, Таблица, СписокКолонок);

    Возврат Таблица;

КонецФункции

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

Третий параметр ЗаполнитьЗначенияСвойств() здесь не для красоты. Он ограничивает копирование колонками дерева и исключает служебные свойства строки. Список собирается один раз до обхода, а не на каждой строке.

💡 Если нужны только конечные строки (листья), добавьте условие перед Таблица.Добавить(): Если СтрокаДерева.Строки.Количество() = 0 Тогда. Рекурсивный вызов оставьте вне условия, иначе обход не дойдёт до нижних уровней.

Как сохранить уровни иерархии, родителя и отступы

Расширим процедуру: передаём номер строки-родителя и счётчик строк. Номер строки берём из Таблица.Количество() сразу после добавления: он совпадает с порядковым номером новой строки.

Процедура ВыгрузитьСИерархией(СтрокиДерева, Таблица, СписокКолонок,
        НомерРодителя, Уровень)

    Для Каждого СтрокаДерева Из СтрокиДерева Цикл
        НоваяСтрока = Таблица.Добавить();
        ЗаполнитьЗначенияСвойств(НоваяСтрока, СтрокаДерева, СписокКолонок);

        НоваяСтрока.НомерСтроки_ = Таблица.Количество();
        НоваяСтрока.НомерРодителя_ = НомерРодителя;
        НоваяСтрока.Уровень_ = Уровень;

        ВыгрузитьСИерархией(СтрокаДерева.Строки, Таблица, СписокКолонок,
            НоваяСтрока.НомерСтроки_, Уровень + 1);
    КонецЦикла;

КонецПроцедуры

// Первый вызов: родителя нет, уровень 0
ВыгрузитьСИерархией(Дерево.Строки, Таблица, СписокКолонок, 0, 0);

У строк верхнего уровня НомерРодителя_ равен 0. По паре «номер — номер родителя» дерево собирается обратно, а в запросе по такой таблице можно отбирать содержимое конкретной группы.

Уровень можно не передавать параметром, а брать через СтрокаДерева.Уровень(). Результат тот же, но параметр дешевле: метод при каждом вызове поднимается по цепочке родителей.

Проверка: собираем дерево обратно

Лучший тест выгрузки — восстановить из таблицы исходное дерево и сравнить количество строк. Родитель в таблице всегда стоит выше потомка, поэтому хватает одного прохода:

Функция ТаблицаВДерево(Таблица, Дерево)

    Созданные = Новый Соответствие;
    Для Каждого СтрокаТаблицы Из Таблица Цикл
        Если СтрокаТаблицы.НомерРодителя_ = 0 Тогда
            Коллекция = Дерево.Строки;
        Иначе
            Коллекция = Созданные[СтрокаТаблицы.НомерРодителя_].Строки;
        КонецЕсли;
        НоваяСтрока = Коллекция.Добавить();
        ЗаполнитьЗначенияСвойств(НоваяСтрока, СтрокаТаблицы);
        Созданные.Вставить(СтрокаТаблицы.НомерСтроки_, НоваяСтрока);
    КонецЦикла;

    Возврат Дерево;

КонецФункции

Дерево-приёмник создайте заранее с теми же колонками, но без служебных. Тогда ЗаполнитьЗначенияСвойств() скопирует только совпадающие по имени колонки, а номера останутся в таблице.

Отступы для печати и Excel

В табличном документе отступ лучше делать свойством ячейки Отступ, а не пробелами. Если же результат уходит в CSV или в текст, отступ собирают в строку:

Функция СтрокаОтступа(Уровень)
    Результат = "";
    Для Сч = 1 По Уровень Цикл
        Результат = Результат + "    ";
    КонецЦикла;
    Возврат Результат;
КонецФункции

// при выгрузке
НоваяСтрока.Наименование = СтрокаОтступа(Уровень) + СтрокаДерева.Наименование;

Так значение колонки меняется. Если наименование потом будет участвовать в поиске или сравнении, храните отступ в отдельной колонке, а исходный текст не трогайте.

Путь строки для сортировки

Колонка Путь_ со значениями вида 1.3.2 позволяет восстановить порядок после любой сортировки таблицы. Номер строки среди соседей берут через Индекс() коллекции, а число переводят в строку только через Формат(), иначе разряды разделятся пробелом:

Номер = Формат(СтрокиДерева.Индекс(СтрокаДерева) + 1, "ЧГ=0");
НоваяСтрока.Путь_ = ?(ПутьРодителя = "", Номер, ПутьРодителя + "." + Номер);

Сортировать по такой строке напрямую нельзя: 1.10 встанет раньше 1.2. Для сортировки дополняйте номера нулями слева до одинаковой длины, например формат "ЧЦ=4; ЧВН=; ЧГ=0" даст 0002.

Выгрузка без рекурсии и скорость на больших деревьях

Рекурсия наглядна, но на очень глубоком дереве упирается в стек вызовов. Деревья из запросов редко глубже 5–10 уровней, а вот деревья, построенные по иерархическим справочникам или спецификациям, бывают глубже. Тогда обход переводят на явный стек из массива.

Функция ДеревоВТаблицуБезРекурсии(Дерево, СписокКолонок, Таблица)

    Стек = Новый Массив;
    // кладём строки верхнего уровня в обратном порядке
    Количество = Дерево.Строки.Количество();
    Для Сч = 1 По Количество Цикл
        Стек.Добавить(Дерево.Строки[Количество - Сч]);
    КонецЦикла;

    Пока Стек.Количество() > 0 Цикл
        Последний = Стек.ВГраница();
        СтрокаДерева = Стек[Последний];
        Стек.Удалить(Последний);

        НоваяСтрока = Таблица.Добавить();
        ЗаполнитьЗначенияСвойств(НоваяСтрока, СтрокаДерева, СписокКолонок);

        Подчиненные = СтрокаДерева.Строки;
        Количество = Подчиненные.Количество();
        Для Сч = 1 По Количество Цикл
            Стек.Добавить(Подчиненные[Количество - Сч]);
        КонецЦикла;
    КонецЦикла;

    Возврат Таблица;

КонецФункции

Дочерние строки кладутся в стек с конца, поэтому снимаются в исходном порядке. Результат совпадает с рекурсивным вариантом строка в строку. Удаление с конца массива дешёвое: элементы не сдвигаются.

Что ещё ускоряет выгрузку на десятках тысяч строк:

  1. Копируйте строку одним ЗаполнитьЗначенияСвойств() с готовым списком колонок, а не присваиванием по каждой колонке в цикле.
  2. Не вызывайте внутри обхода Дерево.Колонки.Найти(), Строка.Уровень() и прочее, что можно посчитать один раз.
  3. Индекс по служебной колонке добавляйте после выгрузки: Таблица.Индексы.Добавить("НомерРодителя_"). Индекс, созданный до заполнения, перестраивается на каждой новой строке.
  4. Выполняйте код на сервере. Гонять дерево между сервером и клиентом ради выгрузки незачем.

Если дерево получено из запроса через Выгрузить(ОбходРезультатаЗапроса.ПоГруппировкам), самый быстрый путь — не обходить его вообще. Вызовите РезультатЗапроса.Выгрузить() без параметра: получится обычная таблица значений. Результат запроса можно выгружать несколько раз, перевыполнять запрос не нужно.

Сравнение способов:

СпособКогда подходитОграничения
Рекурсивная процедураЛюбое дерево обычной глубиныСтек вызовов на очень глубоких деревьях
Обход через массив-стекГлубокие деревья, спецификации, иерархии справочниковКод длиннее, легче ошибиться с порядком
Повторная выгрузка результата запросаДерево построено запросом и результат ещё доступенНе подходит для дерева, изменённого в коде

Дерево на форме, итоги групп и другие особые случаи

Дерево — реквизит управляемой формы

На форме реквизит с типом «Дерево значений» живёт как ДанныеФормыДерево. Это другой тип: у него нет Строки, подчинённые строки получают методом ПолучитьЭлементы(). Код из разделов выше на нём не сработает.

Проще всего перейти на сервер и превратить реквизит в обычное дерево:

&НаСервере
Функция ТаблицаИзДереваФормы()
    Дерево = РеквизитФормыВЗначение("ДеревоДанных");
    Возврат ДеревоВТаблицу(Дерево);
КонецФункции

Если результат должен оказаться в реквизите формы типа «Таблица значений», после выгрузки вызовите ЗначениеВРеквизитФормы(Таблица, "ТаблицаДанных"). Колонки реквизита должны быть созданы заранее в конфигураторе или программно.

Суммы групп задваиваются

Дерево из запроса с ИТОГИ содержит в строках группировок суммы по подчинённым строкам. При выгрузке всех строк итог по колонке Сумма в таблице окажется кратно больше реального: каждая сумма посчитана один раз в детальной строке и ещё раз в каждой группе над ней.

Перед тем как считать итоги по выгруженной таблице, оставьте только детальные строки или обнулите суммы в группах. Отличить группу от записи помогает условие «есть подчинённые строки» или служебная колонка Уровень_, если глубина фиксирована.

Строки удаляют или меняют прямо при обходе

Не удаляйте строки дерева внутри Для Каждого по той же коллекции: часть строк будет пропущена. Сначала выгрузите таблицу, потом правьте дерево обратным циклом по индексу:

Количество = СтрокиДерева.Количество();
Для Сч = 1 По Количество Цикл
    СтрокаДерева = СтрокиДерева[Количество - Сч];
    Если СтрокаДерева.Сумма = 0 Тогда
        СтрокиДерева.Удалить(СтрокаДерева);
    КонецЕсли;
КонецЦикла;

Типичные сообщения при выгрузке

Диагностика: выберите симптом

Частые вопросы

Можно ли выгрузить только один уровень дерева, например второй?
Да. В рекурсивной процедуре передавайте текущий уровень и добавляйте строку в таблицу только при нужном значении. Обход ниже этого уровня можно не продолжать, это экономит время.
Как выгрузить содержимое одной группы, а не всё дерево?
Найдите строку группы через Дерево.Строки.Найти(Значение, "Колонка", Истина): третий параметр включает поиск по подчинённым строкам. Затем передайте в процедуру выгрузки НайденнаяСтрока.Строки.
Подойдёт ли выгруженная таблица для запроса?
Подойдёт, если у всех колонок задан тип. Передайте таблицу параметром и выберите из неё ПОМЕСТИТЬ во временную таблицу. Колонки без типа запрос не примет.
Можно ли собрать дерево обратно из такой таблицы?
Можно, если при выгрузке сохранены номер строки и номер родителя. Проходите таблицу по порядку и запоминайте созданные строки дерева в соответствии «номер → строка». Родитель всегда стоит выше потомка, поэтому он уже будет создан.
Будет ли код работать на клиенте?
Нет. Деревья и таблицы значений на клиенте управляемого приложения недоступны. Выгрузку выполняйте в серверной процедуре формы или в общем модуле с флагом «Сервер».