Как оптимизировать поиск позиции группы номенклатуры в СпискеЗначений, не используя медленный метод Принадлежит()?

Программист 1С v8.3 (Обычные формы) Управленческий учет Торговля и дистрибуция
← На главную

При разработке конфигураций на платформе 1С мы часто сталкиваемся с задачами, требующими определения принадлежности одного элемента справочника к группе или иерархии другого элемента. В таких ситуациях, на первый взгляд, кажется очевидным использование встроенного метода Принадлежит() или ПринадлежитГруппе(). Однако, как показывает практика, эти методы могут стать узким местом в производительности, особенно при больших объемах данных или глубоких уровнях иерархии. В этой статье мы подробно разберем, почему так происходит, и предложим оптимизированные решения, которые позволят значительно ускорить выполнение подобных операций.

Представим типовую ситуацию: у нас есть документ, содержащий табличную часть с номенклатурными позициями. Для каждой номенклатурной позиции нам необходимо определить, принадлежит ли она какой-либо группе из заранее определенного СпискаЗначений, и, если принадлежит, то какую именно позицию в этом списке она занимает. Более того, в нашем СпискеЗначений могут содержаться не только группы, но и отдельные элементы, а также элементы, расположенные на разных уровнях иерархии.

Анализ исходной проблемы: почему метод Принадлежит() медленный?

Изначально, разработчики часто прибегают к следующему подходу, используя метод Принадлежит():


// Имеется список значений с группами справочника Номенклатура - СпГруппТоваров.
// Товар - текущий элемент номенклатуры из строки документа.
ВыбратьСтроки; // Итерация по строкам табличной части документа
Пока ПолучитьСтроку = 1 Цикл
    Если СпГруппТоваров.Принадлежит(Товар) = 1 Тогда
        // Здесь нам нужно узнать номер позиции в СпГруппТоваров, где сработало Принадлежит()
        // ...
    КонецЕсли;
КонецЦикла;

В этом коде метод СпГруппТоваров.Принадлежит(Товар) проверяет, является ли элемент Товар элементом или подчиненным элементом какой-либо из групп, присутствующих в СпГруппТоваров. Если Товар или один из его родителей (вплоть до корневого) совпадает с элементом из СпГруппТоваров, метод возвращает Истину (1).

Основная причина низкой производительности заключается в том, что для каждого вызова Принадлежит() платформа вынуждена выполнять внутренний обход иерархии справочника Номенклатура для проверяемого Товара. Если иерархия глубока (например, 10 уровней вложенности, как упоминалось в обсуждении), то для каждой строки документа и для каждой группы в СпГруппТоваров такой обход будет повторяться. Представьте, что у вас 100 товаров в документе и 50 групп в СпГруппТоваров. В худшем случае это может быть 100 * 50 * (глубина иерархии) операций чтения из базы данных, что приводит к значительным задержкам. Время выполнения может составлять от 5 до 20 секунд даже для небольшого количества номенклатурных позиций (3-8 позиций в документе), как подтверждают замеры.

Кроме того, метод Принадлежит() возвращает лишь булево значение (Истина/Ложь), но не предоставляет информацию о том, какая именно позиция в СпискеЗначений вызвала срабатывание. Это вынуждает нас искать обходные пути.

Разбираемся с альтернативами и их ограничениями

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


НомерПозиции = СпГруппТоваров.НайтиЗначение(Товар); // Не сработает, т.к. в списке группы, а ищем элемент

Метод НайтиЗначение() ищет точное совпадение элемента в списке, а не его принадлежность к группе. Если в СпГруппТоваров у нас есть "Группа А", а мы ищем "Элемент 1", который входит в "Группу А", то НайтиЗначение() вернет 0, так как "Элемент 1" и "Группа А" - это разные объекты, хоть и связанные иерархически.

Еще одно предложение заключалось в переборе самого СпГруппТоваров и использовании метода Товар.ПринадлежитГруппе(ГруппаИзСписка):


Для Каждого ГруппаТоваров Из СпГруппТоваров Цикл
    Сч = Сч + 1; // Номер позиции в СпГруппТоваров
    Если Товар.ПринадлежитГруппе(ГруппаТоваров) = 1 Тогда
        // Нашли! Сч - это номер позиции.
        Сообщить("Сработало в позиции: " + Сч);
        Прервать;
    КонецЕсли;
КонецЦикла;

Хотя этот подход решает проблему получения номера позиции, он не решает проблему производительности. Метод Товар.ПринадлежитГруппе(ГруппаТоваров) по своей сути выполняет те же ресурсоемкие операции по обходу иерархии, что и СпГруппТоваров.Принадлежит(Товар), только в обратном направлении. В итоге, мы все равно получаем те же 5-20 секунд выполнения для небольшого числа товаров.

Оптимизированное решение: Итеративный поиск по родителям элемента

Ключ к оптимизации лежит в изменении подхода. Вместо того чтобы для каждого элемента из СпГруппТоваров проверять, принадлежит ли ему наш Товар, мы будем идти "снизу вверх" – от самого Товара по его родителям, проверяя, присутствует ли какой-либо из этих родителей (или сам товар) в нашем целевом списке/справочнике. Этот подход значительно эффективнее, потому что мы останавливаемся на первом найденном совпадении и не повторяем обход иерархии для каждого элемента из СпГруппТоваров.

Шаг 1: Понимание логики обхода иерархии

Представьте, что у нас есть Товар "Гвозди 2.5х50". Его иерархия может выглядеть так:

  1. Гвозди 2.5х50 (элемент)
  2. Гвозди строительные (группа, родитель Гвозди 2.5х50)
  3. Метизы (группа, родитель Гвозди строительные)
  4. Стройматериалы (группа, родитель Метизы)
  5. (Корневой уровень, пустой родитель)

Если наш СпГруппТоваров содержит "Стройматериалы", мы хотим найти эту позицию. Мы начнем с "Гвозди 2.5х50", затем перейдем к "Гвозди строительные", потом к "Метизы", и, наконец, дойдем до "Стройматериалы", где и найдем совпадение.

Шаг 2: Реализация через СписокЗначений.НайтиЗначение()

Если у нас есть СписокЗначений (например, СпГруппТоваров), и мы хотим найти номер позиции, мы можем использовать метод СписокЗначений.НайтиЗначение() внутри цикла по родителям. Этот метод вернет номер позиции (индекс) элемента, если он найден, или 0, если не найден. Принцип работы следующий:

  1. Инициализируем переменную _ТекущийЭлемент текущим Товаром из документа.
  2. Запускаем цикл, который будет продолжаться до тех пор, пока _ТекущийЭлемент не станет пустым (то есть, мы не дойдем до корневого уровня).
  3. Внутри цикла пытаемся найти _ТекущийЭлемент в нашем СпГруппТоваров с помощью СпГруппТоваров.НайтиЗначение(_ТекущийЭлемент).
  4. Если НайтиЗначение() возвращает число больше 0, это означает, что мы нашли совпадение. Это число и есть искомый номер позиции. Сохраняем его и прерываем цикл.
  5. Если совпадение не найдено, мы присваиваем _ТекущийЭлемент его родителю (_ТекущийЭлемент.Родитель) и повторяем проверку.

Давайте посмотрим на пример кода:


// Исходные данные:
// СпГруппТоваров - СписокЗначений, содержащий группы или элементы справочника Номенклатура.
// ТоварИзДокумента - текущий элемент номенклатуры из строки табличной части документа.

Перем НомерНайденнойПозиции; // Эта переменная будет хранить номер позиции в СпГруппТоваров
НомерНайденнойПозиции = 0; // Инициализируем 0, если не найдено

_ТекущийЭлементИерархии = ТоварИзДокумента;

Пока _ТекущийЭлементИерархии.ПустоеЗначение() = 0 Цикл
    // Попробуем найти текущий элемент (или его родителя) в СпГруппТоваров
    _ПозицияВСписке = СпГруппТоваров.НайтиЗначение(_ТекущийЭлементИерархии);

    Если _ПозицияВСписке > 0 Тогда
        // Мы нашли совпадение! _ПозицияВСписке содержит номер позиции в СпГруппТоваров.
        НомерНайденнойПозиции = _ПозицияВСписке;
        Сообщить("Товар """ + Строка(ТоварИзДокумента) + """ найден в позиции " + НомерНайденнойПозиции + " СпГруппТоваров по элементу """ + Строка(_ТекущийЭлементИерархии) + """.");
        Прервать; // Нашли, что искали, выходим из цикла по иерархии
    КонецЕсли;

    // Если текущий элемент не найден, переходим к его непосредственному родителю
    _ТекущийЭлементИерархии = _ТекущийЭлементИерархии.Родитель;
КонецЦикла;

Если НомерНайденнойПозиции = 0 Тогда
    Сообщить("Для товара """ + Строка(ТоварИзДокумента) + """ соответствующая позиция в СпГруппТоваров не найдена.");
КонецЕсли;

// После этого блока НомерНайденнойПозиции будет содержать искомый индекс
// или 0, если ни сам товар, ни его родители не нашлись в СпГруппТоваров.
```
<p>Этот подход значительно быстрее, потому что каждый вызов <code>НайтиЗначение()</code> для <code>СписокЗначений</code> происходит в памяти (если список небольшой) или эффективно, а цикл по родителям выполняется не чаще, чем глубина иерархии (максимум 10 раз в нашем случае). Кроме того, начальное условие <code>Если СпГруппТоваров.Принадлежит(Товар) = 1 Тогда</code>, которое само по себе было медленным, здесь не требуется, так как логика поиска по родителям полностью покрывает проверку принадлежности.</p>

<h4>Шаг 3: Реализация через НайтиПоРеквизиту() в отсортированном справочнике (для связанных данных)</h4>

<p>Во многих реальных задачах, помимо простого поиска позиции, нам также необходимо получить некие дополнительные данные, связанные с этой группой. Например, в контексте задачи со скидками, мы имеем справочник <code>СкидкиПоГруппамТоваров</code>, который подчинен <code>Контрагентам</code> и содержит реквизиты "Товар" (ссылка на элемент/группу номенклатуры) и "ПроцОпт" (процент скидки).</p>

<p>В этом случае мы можем адаптировать описанный выше итеративный поиск по родителям, используя метод <code>Справочник.НайтиПоРеквизиту()</code>.</p>

<ol>
    <li><strong>Важный подготовительный шаг: Сортировка справочника.</strong> Для максимально эффективной работы метода <code>НайтиПоРеквизиту()</code> критически важно, чтобы справочник <code>СкидкиПоГруппамТоваров</code> был отсортирован по реквизиту "Товар". Эту сортировку можно установить в конфигураторе (на вкладке "Данные" справочника, в свойстве "Порядок"). Если сортировка установлена, платформа использует индексированный поиск, который работает очень быстро.</li>
    <li>Инициализируем переменную <code>_ТекущийЭлементИерархии</code> текущим <code>ТоваромДокумента</code>.</li>
    <li>Запускаем цикл, который будет продолжаться до тех пор, пока <code>_ТекущийЭлементИерархии</code> не станет пустым.</li>
    <li>Внутри цикла используем <code>СкидкиПоГруппамТоваров.НайтиПоРеквизиту("Товар", _ТекущийЭлементИерархии)</code>.</li>
    <li>Если метод возвращает непустую ссылку на элемент справочника <code>СкидкиПоГруппамТоваров</code>, это означает, что мы нашли скидку для текущего элемента иерархии. Получаем процент скидки из найденного элемента и прерываем цикл.</li>
    <li>Если совпадение не найдено, переходим к родителю текущего элемента иерархии.</li>
</ol>

<p>Рассмотрим пример кода для этого сценария:</p>
<codebig>
// Исходные данные:
// ТоварДокумента - текущий элемент номенклатуры из строки табличной части документа.
// Справочник.СкидкиПоГруппамТоваров - справочник, в котором хранятся условия скидок.
//                                      Важно: этот справочник должен быть отсортирован по реквизиту "Товар".
//                                      (Устанавливается в конфигураторе: Справочники -> СкидкиПоГруппамТоваров -> Свойства -> Порядок)

Перем ПроцентСкидкиДляТовара;
ПроцентСкидкиДляТовара = 0; // Инициализируем скидку нулем

_ТекущийЭлементИерархии = ТоварДокумента;

Пока _ТекущийЭлементИерархии.ПустоеЗначение() = 0 Цикл
    // Пытаемся найти запись о скидке, где реквизит "Товар" совпадает с текущим элементом иерархии.
    // Если справочник отсортирован по реквизиту "Товар", поиск будет очень быстрым.
    НайденнаяСтрокаСкидки = Справочники.СкидкиПоГруппамТоваров.НайтиПоРеквизиту("Товар", _ТекущийЭлементИерархии);

    Если НайденнаяСтрокаСкидки.ПустоеЗначение() = 0 Тогда
        // Мы нашли запись о скидке!
        ПроцентСкидкиДляТовара = НайденнаяСтрокаСкидки.ПроцОпт;
        Сообщить("Для товара """ + Строка(ТоварДокумента) + """ найдена скидка " + ПроцентСкидкиДляТовара + "% по группе/элементу """ + Строка(_ТекущийЭлементИерархии) + """.");
        Прервать; // Нашли нужную скидку, выходим из цикла по иерархии
    КонецЕсли;

    // Если для текущего элемента иерархии скидка не найдена, переходим к его родителю
    _ТекущийЭлементИерархии = _ТекущийЭлементИерархии.Родитель;
КонецЦикла;

Если ПроцентСкидкиДляТовара = 0 Тогда
    Сообщить("Для товара """ + Строка(ТоварДокумента) + """ скидка не найдена.");
КонецЕсли;

// Переменная ПроцентСкидкиДляТовара теперь содержит процент скидки,
// который может быть применен к текущей строке документа.
```
<p>Как показал замер времени, такое решение приводит к <strong>ускорению в 4 раза</strong> по сравнению с исходным вариантом, использующим <code>ПринадлежитГруппе()</code>. Это подтверждает эффективность подхода "снизу вверх" при работе с иерархическими структурами.</p>

<h3>Дополнительные аспекты и дальнейшая оптимизация</h3>

<p>Мы рассмотрели основной метод оптимизации, который доказал свою эффективность. Однако, в зависимости от конкретной задачи и объемов данных, могут быть и другие подходы, которые стоит рассмотреть.</p>

<ol>
    <li>
        <strong>Предварительная загрузка данных:</strong>
        <p>Если <code>СпГруппТоваров</code> (или <code>Справочник.СкидкиПоГруппамТоваров</code>) не слишком велик, и мы обрабатываем большое количество строк документа, имеет смысл предварительно загрузить все необходимые данные в структуру, оптимизированную для быстрого поиска в оперативной памяти, например, в <code>Соответствие</code> или <code>ТаблицуЗначений</code> (если нам нужны дополнительные реквизиты, как процент скидки). Однако, стоит помнить, что для иерархического поиска (т.е. поиска не только по самому элементу, но и по его родителям) все равно потребуется либо обход родителей, либо более сложная предобработка данных для включения всех возможных родительских связей.</p>
        <p>Например, можно заранее сформировать <code>Соответствие</code>, где ключ – это элемент или группа номенклатуры, а значение – процент скидки. А затем использовать в цикле по родителям <code>Соответствие.Получить()</code>. Однако, для формирования такого соответствия, если оно должно включать все иерархические связи, нам все равно придется пройтись по иерархии, либо воспользоваться запросами.</p>
    </li>
    <li>
        <strong>Использование запросов с конструкцией "В ИЕРАРХИИ":</strong>
        <p>В некоторых случаях, если логику задачи можно переформулировать, может быть очень эффективным использование запросов с конструкцией <code>В ИЕРАРХИИ</code>. Например, если нам нужно найти все товары из табличной части, которые принадлежат любой из заданных групп. Это позволяет делегировать работу по обходу иерархии системе управления базами данных (СУБД), которая обычно делает это очень быстро. Однако, этот метод больше подходит для массовых операций выборки, а не для определения *конкретной позиции* в <code>СпискеЗначений</code> для *каждого* товара отдельно.</p>
        <p>Пример гипотетического запроса (не для получения позиции в <code>СпискеЗначений</code>, а для фильтрации):</p>
        <codebig>
        Запрос = Новый Запрос;
        Запрос.Текст = "ВЫБРАТЬ
                           |    Номенклатура.Ссылка КАК Ссылка,
                           |    Номенклатура.Родитель КАК Родитель
                           |ИЗ
                           |    Справочник.Номенклатура КАК Номенклатура
                           |ГДЕ
                           |    Номенклатура.Ссылка В ИЕРАРХИИ(&СписокГруппДляПоиска)";
        Запрос.УстановитьПараметр("СписокГруппДляПоиска", СпГруппТоваров.ВыгрузитьЗначения()); // Если СпГруппТоваров содержит ссылки
        Результат = Запрос.Выполнить().Выбрать();

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

Заключение

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

Оптимальное решение заключается в использовании итеративного подхода "снизу вверх": мы последовательно обходим элемент и всех его родителей, пытаясь найти совпадение в целевом СпискеЗначений с помощью НайтиЗначение() или в отсортированном справочнике с помощью НайтиПоРеквизиту(). Этот подход позволяет значительно сократить время выполнения за счет минимизации обращений к СУБД и использования эффективных методов поиска, что подтверждается практическими замерами производительности.

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

← На главную