Это продолжение перевода книги Грегори Декер, Рик де Гроот, Мелисса де Корте. Полное руководство по языку М Power Query. Итерация и рекурсия являются фундаментальными концепциями в программировании, которые позволяют выполнять код в повторяющемся режиме. В контексте М Power Query эти методы значительно расширяют возможности преобразования и обработки данных. Хотите ли вы применить функцию к списку значений, накопить результаты или обратиться к предыдущим шагам, понимание итерации и рекурсии имеет большое значение.
Мои комментарии набраны с отступом.
Предыдущая глава Содержание Следующая глава
Скачать заметку в формате Word или pdf
В этой главе подробно рассматриваются функции и операторы, которые обеспечивают итерацию и рекурсию. Вы узнаете, как циклически перебирать списки с помощью List.Transform, использовать функцию List.Accumulate, создавать списки с помощью List.Generate и реализовывать рекурсию с помощью оператора @.
Цель состоит в том, чтобы предоставить вам навыки, необходимые для использования этих методов. Основные темы, которые будут затронуты:
- итерация: Transform, List.Accumulate, List.Generate;
- рекурсия: оператор области видимости @ (scope).
Чтобы извлечь максимальную пользу мы рекомендуем выполнять примеры в Power Query. Скачайте PBIX-файл с репозитория GitHub.
Введение в итерацию
Итерация – концепция программирования, важная для выполнения повторяющихся действий с данными. В Power Query итерация часто выполняется по строкам в таблице или элементам списка.
Пусть вы хотите возвести в квадрат список из 1000 чисел. Чтобы сделать это без итераций, потребовалось бы написать 1000 отдельных строк кода. С помощью итерации задача может быть выполнена с помощью всего нескольких строк кода. Традиционные языки, такие как Python или Java, используют для таких задач циклы for и while. В языке M используется иной подход к итерации. По умолчанию преобразования выполняются в базовых структурах данных.
Например, когда вы используете Table.AddColumn для создания нового столбца, Power Query автоматически применяет указанную операцию к каждой строке в таблице, аналогично тому, как цикл for выполняет итерацию по каждому элементу в списке. Язык M автоматически выполняет итерацию за вас, используя свои стандартные библиотечные функции.
Однако в M существуют некоторые функции, специально разработанные для итераций: List.Transform, List.Accumulate и List.Generate. Они тесно связаны с концепцией итерации, понимаемой в более широком контексте программирования.
List.Transform и List.Accumulate позволяют выполнять операцию над каждым элементом в списке (аналог цикла for), а List.Generate похожа на цикл while, поскольку выполняет итерацию на основе условия остановки. List.Generate создает список путем многократного применения функции, продолжая до тех пор, пока условие не перестанет выполняться. Освоив эти функции, вы сможете выбирать подходящую подобно тому, как выбираете между циклом for или while в других языках программирования.
List.Transform
Когда речь идет об итерации, List.Transform является одной из основных функций. List.Transform применяет операцию к каждому элементу в списке. Она принимает список и функцию в качестве входных данных, и создает новый список, где каждый выходной элемент является результатом применения этой функции к соответствующему элементу в исходном списке.
Представьте, что у вас есть список чисел { 2, 4, 6 }, и вам нужно удвоить каждое число. Это типичный пример итерации, в которой операция многократно применяется к каждому элементу. В большинстве языков программирования вы примените цикл for:
1 2 3 4 5 |
for x in [2, 4, 6] { array[x] = array[x] * 2; x++; } |
Этот код перебирает каждый элемент в массиве, удваивая его значение. Однако в Power Query вы достигаете того же результата с помощью более лаконичного подхода:
1 |
List.Transform( { 2, 4 6 }, each _ * 2) |
List.Transform принимает список {2, 4, 6} и применяет функцию к каждому элементу. Функция определена следующим образом: each _ * 2. Символ подчеркивания (_) представляет каждый элемент в списке во время итерации.
Эти примеры показывают отличия традиционного цикла for и функционального стиля Power Query. В то время как цикл for явно проходит по индексам массива, List.Transform абстрагирует процесс итерации, сосредотачиваясь на операции, применяемой к каждому элементу списка.
Извлечение элементов из списка по позициям
Перейдем к более сложному сценарию с итерацией. Допустим вы изучаете настройки приложения, чтобы проанализировать предпочтения пользователя. Настройки спрятаны в списке, где каждая настройка либо активна (true), либо неактивна (false). В списке 8 логических значений, каждое из которых привязано к виду настройки: уведомления, темный режим, обмен местоположением, автоматические обновления и др. Список хранится на шаге с именем myList:
1 |
{ true, false, true, false, true, true, false, true } |
Цель – извлечь из списка значения в позициях 2, 3, 5, 6 и 8. В языке М нет функции для извлечения нескольких элементов из списка на основе их индекса. Вы можете подумать о List.Select, которая принимает список в качестве входных данных и позволяет выбирать элементы на основе условия:
1 |
List.Select( { true, false, true, false, true, true, false, true }, each _ = true ) |
Это выражение возвращает список, содержащий только значения true. Но с нашей задачей функция не справится. Причина в том, что она выделяет элементы по их значению, но не по позиции в списке. Рассмотрим решение этой проблемы с помощью List.Transform.
Извлечь один элемент из списка по его позиции просто. Например, чтобы получить второй элемент из списка, используйте:
1 |
myList{1} |
В этом фрагменте кода используется отсчитываемый от нуля индекс в фигурных скобках для указания позиции элемента. Этот процесс известен как выбор поля. Для нашего примера ручной отбор…
1 |
{ myList{1}, myList{2}, myList{4}, myList{5}, myList{7} } |
… верный, но явно многословный, и ему не хватает гибкости. Это типичный случай для использования List.Transform. Нам потребуются два аргумента:
- список индексных позиций, которые нужно извлечь;
- функция для извлечения позиций из списка.
Мы можем имитировать ручной подход, но в более сжатой форме:
1 2 3 4 5 |
let myList = { true, false, true, false, true, true, false, true }, RetrieveItems = List.Transform({1, 2, 4, 5, 7}, each myList{_}) in RetrieveItems |
List.Transform выполнит итерацию по заданным позициям индекса. Для каждого значения она применит функцию из второго аргумента, извлекая соответствующие элементы из myList. Этот подход не только короче, но и адаптируется для последующих настроек. Если позже вам потребуется извлечь другие позиции, просто измените список в первом аргументе.
Распределение годового бюджета по месяцам
В следующем сценарии вы работаете с бюджет продаж на 2024 год. Данные хранятся на шаге с именем Source:
Рис. 13.1. Таблица со значениями бюджета на 2024 год
Чтобы следовать примеру, откройте запрос Start of month dates в PBIX-файле главы. Обратите внимание, что для этой задачи данные предоставляются на ежегодной основе. Ваша цель – равномерно распределить бюджетные средства по месяцам. Для этого:
- составьте список дат начала каждого месяца 2024 года;
- разверните этот список на строки;
- разделите годовой бюджет на 12, чтобы получить ежемесячные показатели.
Создадим список дат для каждого месяца 2024 года. Так как количество значений фиксировано, с этим справится List.Transform. Начнем со списка чисел от 1 до 12: { 1 .. 12 }. Далее превратим числа в даты. Набор данных уже включает год (2024). Чтобы создать список ежемесячных дат, начнем с 1 января 2024 года. Для каждого последующего месяца мы сможем использовать функцию Date.AddMonths, чтобы увеличить дату на один месяц на каждом шаге. Нам все равно не нужен дополнительный столбец. Начнем с простого примера и сосредоточимся на цифре 6 в нашем сгенерированном списке. Мы можем превратить 6 в 1 июня 2024 года, написав:
1 |
Дата.AddMonths( #date( 2024, 1, 1 ), 6 - 1 ) |
Здесь число 6 уменьшается на 1, а затем используется для увеличения начальной даты на столько месяцев. Когда вы поймете эту концепцию, вам будет легче манипулировать набором данных. Цель – добавить столбец со списком в каждую строку. В этом списке будут даты первого числа месяца для выбранного года. Мы добьемся этого, обрабатывая последовательность из 12 значений с помощью функции List.Transform.
Добавим столбец в таблицу (рис. 13.1), который принимает год и список из 12 чисел:
1 2 3 4 5 6 |
Table.AddColumn( Source, "Date", (x) => List.Transform({1 .. 12}, each Date.AddMonths(#date(x[Year], 1, 1), _ - 1)), type {date} ) |
Код создаст новый столбец Date в таблице Source. Используется информация о годе из каждой строки таблицы. Здесь, правда, есть одна проблема. Обычно функция Table.AddColumn использует выражение each в третьем аргументе для определения функции. Это сокращенный способ создания функции с символом подчеркивания в качестве имени переменной. List.Transform также по умолчанию использует выражение each для определения выражения функции во втором аргументе. Но использование двух функций с одним и тем же именем переменной (подчеркивание) создает неоднозначность.
Если в функциях List.Transform и Table.AddColumn используется ключевое слово each, ссылка на символ подчеркивания (имя параметра) может получить доступ к переменной только в ее собственном контексте. Однако нам требуется доступ к значению Year во внешней области.
Чтобы решить эту проблему, код определяет пользовательскую функцию для внешней области видимости (таблицу Source) с помощью переменной x. Это позволяет нам ссылаться на столбец Year из таблицы Source, когда мы находимся в контексте функции List.Transform.
В четвертом аргументе Table.AddColumn определен тип данных нового столбца – список, содержащий даты. В результате добавления шага получаем:
Рис. 13.2. Строки, содержащие списки с 12 датами начала месяца
Кликните стрелки справа от заголовка столбца Date и выберите Развернуть в новые строки. Это действие создаст таблицу с датой начала для каждого месяца в 2024 году. Осталось разделить бюджета на 12. Самый простой способ сделать это – выбрать столбец Budget и пройти Преобразование –> Стандартный –> Разделить и ввести 12. После подтверждения у вас будет набор данных с месячными бюджетами:
Рис. 13.4. Распределение годового бюджета по месяцам
До сих пор функция List.Transform справлялась с перебором списков и выполнением действий с каждым элементом. Но что делать, если вам нужно больше контроля, особенно когда результат одной итерации влияет на следующую? В игру вступает List.Accumulate, предоставляющая продвинутый набор функций.
List.Accumulate
List.Accumulate – еще один мощный итератор. Как и List.Transform, функция перебирает элементы в списке один за другим, выполняя указанное действие над каждым элементом. Тем не менее, в отличие от большинства итераторов, у List.Accumulate есть память. По мере того, как функция генерирует новые значения, она может получить доступ как к новому элементу в списке, так и к результату предыдущей итерации. Это позволяет использовать результат шага в качестве входных данных для следующего шага.
Вы можете заметить некоторое сходство с рекурсией, о которой мы расскажем далее в этой главе. Однако суть List.Accumulate заключается не совсем в рекурсии. Это больше похоже на накопительную операцию, где каждый шаг строится на основе предыдущего, отсюда и название accumulate. Это различие позволяет List.Accumulate выполнять задачи, в которых результаты более ранних шагов влияют на следующие операции. Так как же это работает?
Анатомия функции
Синтаксис:
1 2 3 4 5 |
List.Accumulate( list as list, seed as any, accumulator as function ) as any |
Термины кажутся абстрактными, поэтому рассмотрим каждый из них:
- list – исходный список для работы. Применяя логику функция перебирает каждое значение в этом списке.
- seed – начальное значение, с которого начинается перебор элементов в списке.
- accumulator – набор инструкций о том, что Accumulate должен делать с каждым элементом в списке по мере их прохождения по очереди. Аккумулятор – это функция с двумя параметрами: накопленным значением (которое начинается как начальное значение) и текущим элементом из списка. Аккумулятор выполняет действие, создавая новое накопленное значение, а затем переходит к следующему элементу списка.
Пусть есть список чисел от 1 до 5, и вы хотите их перемножить. В обычной среде программирования вы используете структуру цикла для накопления результата произведения:
1 2 3 4 5 |
y = 1 for x = 1 to 5 { y = y * array[x]; } |
Этот цикл итеративно умножает переменную-аккумулятор y на каждый элемент массива. В Power Query функция List.Accumulate достигает того же результата в более функциональном стиле:
1 2 3 4 5 |
List.Accumulate( { 1, 2, 3, 4, 5 }, 1, ( state, current ) => state * current ) // возвращает 120 |
Вот как это работает:
- Функции предоставлен список значений от 1 до 5.
- Начальное значение равно 1. На первом шаге оно называется значением состояния.
- Аккумулятор принимает значение состояния и умножает его на текущий элемент в списке.
Процесс можно визуализировать:
Рис. 13.5. Этапы итерации функции List.Accumulate
Каждый шаг функции отражает итерацию цикла, умножая накапливающееся состояние на текущий элемент. Так как функция перебирает список, количество шагов равно количеству элементов в списке. Обратите внимание, что результат является результатом последнего шага. Предшествующие шаги не возвращаются.
Давайте подробнее рассмотрим, как работает функция аккумулятора. Она использует два входных параметра. Хотя у вас есть свобода называть эти параметры по своему усмотрению, здесь для ясности, они называются state и current:
- state – накопленное значение. В первой итерации параметр равен начальному значению. По мере итераций значение состояния изменяется на результат предыдущего шага.
- current – значения из обрабатываемого списка. В первой итерации current равен первому значению в списке. На следующем шаге он переходит ко второму значению, продолжая таким образом до тех пор, пока не пройдет по всем значениям в списке.
Чтобы лучше визуализировать это, мы можем настроить функцию аккумулятора таким образом, чтобы сохранять значения промежуточного итога:
1 2 3 4 5 |
List.Accumulate( { 1, 2, 3, 4, 5 }, {1}, ( state, current ) => state & { List.Last( state ) * current } ) // возвращает {1,1,2,6,24,120} |
Логика выполнения этих шагов:
Рис. 13.6. Логика List.Accumulate при сохранении промежуточных результатов
Вместо того, чтобы возвращать только результат умножения, приведенная выше логика хранит результат аккумулятора каждого шага внутри списка. При каждой итерации аккумулятор захватывает последний элемент из списка и умножает его на текущее значение. Затем он прибавляет результат к предыдущему результату, объединяя два списка. Выражение, подобное {1} & {1}, объединяет два списка в один, в результате чего получается {1,1}.
Перейдем теперь к более полезным примерам работы List.Accumulate.
Замена нескольких значений
Для каких сценариев вы бы использовали List.Accumulate, а не иные итераторы? Это те сценарии, в которых List.Accumulate обладает возможностями, которых нет у других функций. Чтобы следить за примером, откройте запрос ListAccumulate Manual Replacement в PBIX-файле.
Рис. 13.7. Данные для очистки
Вы хотите вернуть столбец с пробелами между частями имени и без специальных символов. Вам потребуются четыре операции замены: символы _, - и . следует заменить пробелом, а восклицательные знаки убрать. Эти операции очень похожи, и мы хотим реализовать их в функции List.Accumulate. Для начала посмотрим, как выглядит код обычной операции замены значений. Щелкните правой кнопкой мыши столбец Names и выберите Замена значений. Замените каждый символ подчеркивания пробелом. Интерфейс создаст код:
1 2 3 4 5 6 7 |
Table.ReplaceValue( Source, "_", " ", Replacer.ReplaceText, {"Names"} ) |
Выполнение операции четыре раза добавит четыре шага к вашему запросу. Как использовать List.Accumulate для хранения этой логики в одном шаге? Сначала определим значения для итерации и начальное значение.
- Значения для итерации. Цель – заменить старое значение новым. Поэтому в каждой итерации нам нужен доступ как к старому, так и к новому значению. Это означает, что нам нужно хранить элементы либо в списке, либо в записи.
- Начальное значение. Поскольку мы будем заменять значения в таблице, мы предоставим здесь исходную таблицу.
1 2 3 4 5 6 7 8 9 10 11 |
List.Accumulate( { {"_"," "}, {"-", " "}, {".", " "}, {"!",""} }, // списки замен Source, // таблица, в которой мы заменяем значения ( state, current ) => Table.ReplaceValue( state, // таблица, в которой мы заменяем значения current{0}, // старое значение – первый элемент из текущего списка current{1}, // новое значение – второй элемент из текущего списка Replacer.ReplaceText, {"Names"} // имя столбца, в котором заменяются значения ) ) |
Список для замены включает внутренние списки с парой значений: старым и новым. При выполнении функции, с каждой итерацией (и, следовательно, каждой заменой), значение состояния изменяется на новую таблицу с замененными значениями:
Рис. 13.9. Пошаговая логика замены
Мы начинаем работу с таблицей Source. Затем, с каждой заменой, содержимое таблицы изменяется, что иллюстрируется версиями от Table1 до Table3. Результаты от Table1 до Table3 не являются отдельными таблицами, к которым можно получить доступ, но вычисляются в памяти для создания результата Table4.
Выполнив шаг ReplaceValuesManually, вы получите очищенный набор данных:
Рис. 13.10. Очищенный набор данных
Возможно, вы заметили, что в коде указаны точные замены, которые могут не обеспечить вам желаемой гибкости. Удобнее сохранить старые и соответствующие им новые значения в таблице и использовать ее в качестве входных данных. Наш первоначальный список замен сохраните в таблице Replacements:
Рис. 13.11. Таблица замен
Table.ToRows преобразует приведенную выше таблицу в список списков, по одному для каждой строки:
Рис. 13.12. Table.ToRows преобразует таблицу в список списков
Именно такой формат нам нужен в качестве входных данных для функции List.Accumulate. Теперь мы можем заменить исходный список замены выражением Table.ToRows:
1 2 3 4 5 6 7 8 9 10 11 |
List.Accumulate( Table.ToRows( Replacements ), // динамический список замены Source, ( state, current ) => Table.ReplaceValue( state, current{0}, current{1}, Replacer.ReplaceText, {"Names"} ) ) |
Теперь формула ссылается на таблицу замен. Такой дизайн кода окупится, когда вам потребуется внести изменения в список замен. Если вы захотите добавить или изменить замены, просто обновите таблицу Replacements.
Далее мы познакомимся с List.Generate. Эта функция выполняет аналогичную задачу, но с изюминкой: вместо того, чтобы перебирать заданные элементы, она продолжает работать до тех пор, пока выполняется определенное условие.
List.Generate
List.Generate – одна из самых мощных функций в языке M. Она многократно выполняет итерации на основе заданных условий и преобразований. Она также одна из самых сложных функций.
List.Generate отличается от статичных методов, таких как List.Numbers и List.Accumulate. Статичные методы полагаются на предопределенные входные данные. List.Generate использует функции в качестве аргументов. Эти функции задают, как формируется список, от его начала до конца, на основе уникальной логики. Концепция особенно полезна, когда вы достигли пределов возможности статичных методов.
Функция List.Generate имеет как итерационные, так и рекурсивные аспекты:
- Итерации. Generate выполняет итерации, создавая список на основе условий. Она требует начального значения, имеет функцию шаг (step), которая определяет, как перейти к следующему значению, и продолжается до тех пор, пока выполняется условие. Итерационный процесс похож на цикл while, в котором выполняется шаг, проверяется условие и цикл продолжается или завершается. Структура List.Generate воплощает в себе суть итерации.
- Рекурсия. Хотя Generate не является рекурсивной в обычном смысле, способ, которым List.Generate может создавать последовательность значений на основе предыдущих вычислений, имеет сходство с рекурсией. Функция шаг может ссылаться на ранее вычисленное значение, как это делает List.Accumulate. Это похоже на то, как рекурсия повторно использует вывод операции, хотя и без вызова самой функции, что было бы настоящей рекурсией.
Почему же вы можете предпочесть List.Generate другим методам?
Преимущества List.Generate
List.Generate имеет преимущества по сравнению с другими методами, особенно с традиционной рекурсией. Наиболее важные преимущества:
Производительность. List.Generate обычно работает быстрее, чем обычная рекурсия. Рекурсия вызывает функцию несколько раз и добавляет промежуточные результаты в стек. Этот медленный процесс. List.Generate работает в одном контексте. Как правило, она использует меньше памяти и работает быстрее.
Отслеживаемая итерация. При использовании List.Generate легко отслеживать результат каждой итерации, так как результат сохраняется как значение в сгенерированном списке. Это дает четкое представление о том, какой вклад каждая итерация вносит в финальный список. В рекурсии виден только результат всех итераций. Это затрудняет понимание логики и отладку каждого шага.
Простота понимания. List.Generate имеет четкую структуру, что делает код легче для чтения и понимания. Рекурсия часто включает сложные, взаимосвязанные вызовы, которые могут потребовать больше времени для понимания.
Анатомия функции
Чтобы разобраться, как работает List.Generate, необходимо понять ее синтаксис и последовательный поток четырех аргументов: Initial, Condition, Next и Selector. Эти аргументы составляют инструкции для динамического списка, который будет генерировать функция. Синтаксис:
1 2 3 4 5 6 |
List.Generate( Initial as function, Condition as function, Next as function, optional Selector as nullable function ) as list |
Каждый аргумент в List.Generate принимает функцию в качестве своего значения. Это отличает List.Generate от других функций языка M. Мы можем описать эти аргументы следующим образом:
Initial – отправная точка. Это может быть простое значение, например число, или более сложная структура, например запись. Список начинается с этого аргумента.
Condition – тест, который должно пройти значение, прежде чем List.Generate добавит его в список. Сначала функция оценивает начальное значение. Если оно не удовлетворяет условию, функция возвращает пустой список. Если начальное значение проходит проверку, функция приступает к генерации следующего элемента в списке с использованием аргумента Next. Однако это новое значение добавляется в список только в том случае, если оно также пройдет тест. Функция продолжает генерировать и оценивать новые элементы, пока не встречает значение, которое не удовлетворяет условию, после чего создание списка останавливается.
Next формирует каждое новое значение в списке. Функция в этом аргументе определяет логику генерации значений в списке.
Selector – необязательный аргумент, позволяющий изменить финальный список. Например, если у вас список записей, с помощью Selector можно выбрать поля. Или можете изменить все значения списка, добавив префикс или преобразовав значения в другой тип.
Примеры, использованные в этом разделе представлены в PBIX-файле. Создадим список чисел от 1 до 9:
1 2 3 4 5 6 7 8 9 |
Код 13.1 let source = List.Generate( () => 1, // начальное значение 1 each _ < 10, // до тех пор, пока значение меньше 10 each _ + 1 // увеличивать каждое значение на 1 ) in source |
Что здесь произошло?
() => 1: функция без параметров задает начальное значение 1.
each _ < 10: функция вычисляет условие для каждого значения, включая начальное. Как только условие не выполняется, List.Generate прекращает генерацию новых значений списка. В нашем примере условие гарантирует, что список перестанет расти, как только значение достигнет 9.
each _ + 1: это правило для создания каждого следующего значения. Мы указали функции увеличивать каждое значение на 1.
List.Generate можно сравнить с циклом while. Обе структуры управляют итерациями и проверкой условий. Вот как может выглядеть цикл while, отражающий логику кода 13.1:
1 2 3 4 5 |
y = 1 while (y < 10) { y = y + 1; } |
Здесь цикл начинается с y = 1. Цикл продолжается до тех пор, пока y < 10. Значение y увеличивается на 1 в каждой итерации.
List.Generate и цикл while стартуют с начального значения, вычисляют следующее значение, проверяют его на соответствие условию и решают, следует ли продолжить процесс или остановиться. Фактически List.Generate работает рекурсивно, отслеживая последнее созданное значение, используя его в качестве входных данных для следующего вычисления и определяя, следует ли включить его в список или остановиться на основе условия.
В коде 13.1 использовались три обязательных аргумента. Они предоставляют логику для создания результирующего списка. Необязательный четвертый аргумент (селектор) может преобразовать этот список. Предположим, вы хотите создать список из 12 предложений, по одному на каждый месяц года. В списке вы хотите вернуть предложение, которое гласит, что первый день месяца приходится на такой-то день недели. Используем List.Generate для создания списка дат первых чисел месяца, а селектором преобразуем эти даты в нужный формат.
Чтобы создать список, содержащий даты начала месяца в 2024 году, используйте код:
1 2 3 4 5 6 7 8 |
let source = List.Generate( () => #date( 2024,1,1 ), // начальное значение each _ < #date( 2025,1,1 ), // только для 2024 года each Date.AddMonths( _, 1) // увеличение на месяц ) in source |
Рис. 13.13. Создание последовательности дат с помощью List.Generate
Чтобы преобразовать список в нужный формат, можно использовать List.Transform. Но можно добавить селектор в List.Generate. Селектор применит функцию к списку, созданному первыми тремя аргументами List.Generate. Такой подход ограничит логику рамками одной функции.
1 2 3 4 5 6 7 8 9 |
let source = List.Generate( () => #date( 2024,1,1 ), each _ < #date( 2025,1,1 ), each Date.AddMonths( _, 1) each Date.ToText( _, "d MMMM yyyy", "ru-RU") & " приходится на " & Date.ToText( _, "dddd", "ru-RU") ) ) in source |
Я изменил код Селектора, приспособив его к региональным настройкам.
В этом примере Селектор выполняет три функции:
- Использует ToText для текстового представления дат, начиная с 1 января 2024.
- Дополняет текстовую строку фрагментом приходится на.
- Использует ToText для добавления названия дня недели.
Рис. 13.14. Результат после применения Селектора
Чтобы получить эту таблицу, мы написали относительно простой код. Однако бывают ситуации, когда для достижения результата необходимо отслеживать несколько значений внутри List.Generate. Для этого понадобятся переменные, о которых мы расскажем далее.
Работа с переменными внутри записей
В сложных сценариях может потребоваться отслеживать несколько переменных при создании списка. List.Generate использует записи для управления этой сложностью. Запись – это пара имя-значение, что делает ее удобной для одновременного отслеживания нескольких переменных.
Рассмотрим сценарий, в котором требуется создать список чисел от 1 до 10 и указать, является ли число четным. Для этого необходимо поддерживать две переменные при создании списка: текущий номер в последовательности, четное это число или нет. Поскольку переменные будут меняться с каждой итерацией, мы можем использовать записи для хранения информации об изменении значений.
Вернемся к коду 13.1. Он отлично справился с созданием базового списка, но не годится для управления более чем одной переменной. Изменим пример, используя запись с одним полем:
1 2 3 4 5 |
List.Generate( () => [num = 1], each [num] <= 10, each [num = [num] + 1] ) |
Initial: мы начинаем с записи, содержащей одно поле с именем num и значением 1.
Condition: условие применяет выбор поля [num] для получения его значения, и проверяет, меньше или равно ли это значение 10.
Next: при каждой итерации поле num записи увеличивается на 1.
Эта операция возвращает список записей:
Рис. 13.15. List.Generate возвращает список записей
Как видно из приведенного выше рисунка, изучить детали отдельных записей не всегда просто. Есть два распространенных способа сделать это:
- Кликните пустое место рядом с каждой записью, чтобы увидеть, что находится внутри.
- Используйте аргумент Селектор для преобразования записи в иное легко проверяемое значение.
Мы предлагаем третий подход. Чтобы упростить проверку содержимого записи во время работы над кодом, можно преобразовать список записей в таблицу. Оберните List.Generate функцией Table.FromRecords:
Рис. 13.16. Использование Table.FromRecords для визуализации содержимого записей
Включим в запись еще одну переменную, чтобы определить, является ли значение num четным. Для этого добавим новое поле в запись, определенную в аргументе Initial, и укажем, как это поле будет изменяться на каждой итерации:
Рис. 13.17. Использование записей для определения нескольких переменных в List.Generate
Теперь запись имеет дополнительное поле IsEven. Обратите внимание, что наше условие остается тем же: num ≤ 10. Дополнительное поле в нашей записи на это не влияет.
Table.FromRecords по-прежнему позволяет легко проверить значения. Убедившись, что результаты соответствуют ожиданиям, вы можете удалить функцию Table.FromRecords из кода.
После проверки кода вам часто понадобится только одно поле результирующей записи. Предположим, вас интересует только значения поля IsEven. Примените Селектор – each [IsEven ]
1 2 3 4 5 6 7 8 9 |
let Source = List.Generate( () => [num=1, IsEven = Number.IsEven( 1 ) ], each [num] <= 10, each [ num = [num] + 1, IsEven = Number.IsEven( [num] + 1 ) ], each [IsEven ] ) in Source |
Это иллюстративный пример. Подобный сценарий проще реализовать с помощью List.Transform:
1 2 3 4 |
List.Transform( {1..10}, each Number.IsEven( _ ) ) |
Функция List.Transform принимает список в качестве входных данных и перебирает каждый элемент в списке, применяя функцию из второго аргумента. Такой подход не только упрощает код, но и, вероятно, выполняется быстрее.
Следует тщательно взвесить, использовать ли List.Generate. Функция сложна для написания и понимания. List.Transform проще для понимания и поддержки. При выборе функции и если вы заранее знаете количество итераций, рассмотрите следующие варианты:
- Transform лучше подходит, когда каждый элемент в списке работает независимо, без рекурсивной логики.
- Accumulate полезна для рекурсивных операций, так как она передает результат от одной итерации к следующей, пока все итерации не будут завершены.
Полезные сценарии использования List.Generate
Существует множество случаев, которые можно решить с помощью List.Generate, однако вы часто обнаружите, что можете достичь тех же результатов, используя, либо List.Accumulate, либо List.Generate. Вот некоторые сценарии, в которых полезен List.Generate:
Пагинация. При работе с API, которые возвращают результаты с разбивкой по страницам, List.Generate может проходить через каждую страницу, чтобы собрать полный набор данных. Функция прекратит итерации, когда страниц не останется.
Диапазоны дат. Когда вы хотите создать список дат, которые находятся между двумя конкретными датами.
Пользовательские последовательности. Когда вы не ограничены числами или датами, List.Generate может создавать списки, которые следуют сложной пользовательской логике, такой как нарастающий итог, последовательность Фибоначчи или простые числа.
Манипуляции с текстом. В конце главы с помощью рекурсии мы решим проблему с множественными пробелами между словами. Вы можете использовать List.Generate для создания списка каждой измененной текстовой строки до тех пор, пока не выполнится ваше условие.
Давайте рассмотрим несколько примеров использования List.Generate.
Циклический перебор данных API
Функция List.Generate отличается от List.Accumulate тем, что использует условие для продолжения генерации значений. Это важно при работе с API. API (Application Programming Interface) – интерфейс прикладного программирования. По сути, это набор правил и протоколов для получения данных из приложения. Предположим, мы используем API из библиотечной базы данных для сбора всех книг, написанных автором. Мы не знаем, какой объем данных будет возвращен. Нам пригодится условие List.Generate. Можно настроить аргумент так, чтобы функция работала, пока не закончатся данные для выборки.
Общие сведения об API открытой библиотеки
Мы используем Open Library API – каталог почти всех когда-либо опубликованных книг. На веб-сайте есть не только веб-страницы, которые вы можете просматривать, но и API для прямого извлечения данных. Прежде чем мы начнем, важно понять, как работает этот API. Посетите страницу документации, чтобы узнать больше. В этом онлайн-руководстве содержатся инструкции по взаимодействию с API.
Мы извлечем список всех работ Стивена Кинга (Stephen King) с помощью Authors API. На этой странице представлен формат, который необходимо использовать при отправке запроса к базе данных Open Library. Если вы прокрутите вниз до раздела Works by an Author, то найдете запросы, которые можно сделать к API:
Рис. 13.18. Инструкции по вызову API
Вот что вам нужно знать о вызовах API:
- URL для вызова API для получения работ автора будет иметь вид: https://openlibrary.org/authors/OL23919A/works.json.
- Чтобы получить данные об авторе, вам потребуется его идентификатор. Для Стивена Кинга ID – OL2162284A.
- Каждый вызов API извлекает до 50 работ автора.
- Можно указать смещение в URL-адресе, чтобы указать, с какой записи начинать отбор.
Получение ключа идентификатора автора
Чтобы найти URL-адрес для работ Стивена Кинга, перейдите по ссылке https://openlibrary.org/ и введите имя в поле поиска. В результатах поиска нажмите на имя, и перейдите на страницу автора:
Рис. 13.19. Страница Стивена Кинга в openlibrary.org
URL авторской страницы содержит идентификатор OL2162284A. Вооружившись этой информацией, можно приступить к написанию вызова API.
Проверка вызова API
Прежде чем углубляться в сложную логику, убедимся, что вызов API возвращает данные. Возьмите шаблон URL-адреса, приведенный в документации, и включите в него идентификатор Стивена Кинга. Получится https://openlibrary.org/authors/OL2162284A/works.json. Откройте Power Query. Пройдите Главная –> Создать источник –> Интернет. В окне вставьте URL-адрес и нажмите OK:
Рис. 13.20. Окно вызова API с использованием интерфейса Power Query
Это действие импортирует данные, возвращенные API. Если Power Query автоматически создаст дополнительные шаги, удалите их, оставив только шаг Source. Возвращается запись с тремя полями, содержащими вложенные структуры:
Рис. 13.21. Результаты вызова API для Authors API Стивена Кинга
Проверка результатов вызова API
На шаге Source API возвращает запись с тремя полями:
- links – cодержит запись с URL-адресами автора, текущего и следующего вызова API;
- size – дает количество записей книг для выбранного автора;
- entries – включает список записей с информацией по книгам (одна запись – одна книга); в том числе поле title с заголовком книги.
Понимание этой структуры помогает решить, как подойти к разбиению на страницы.
Обработка ограничений данных
Важно отметить, как API сообщает о завершении доступных данных. Когда больше нет записей для получения, следующий URL-адрес будет отсутствовать в поле ссылок. Если вы вручную выполните вызов API со смещением, которое не подразумевает данных, список записей вернется пустым.
Мы можем проверить это, либо ища последний вызов API с данными, либо указывая заведомо большое число в качестве смещения. Например, если вы попытаетесь выполнить вызов API, который пропускает 999 записей и извлекает следующие 50, то, поскольку таких записей не существует, список вернется пустым:
Рис. 13.22. Вызов API, возвращающий пустой список и отсутствие поля next для URL-адреса
Стратегии извлечения данных
На основе изученной структуры API можно предложить несколько стратегий получения всех названий книг:
- Используйте Generate для создания функции, которая будет брать следующий URL-адрес из поля next записи links для последующего вызова API. Остановитесь, когда запись links не содержит поле next.
- Используйте Generate для проверки записей в списке entries. Продолжайте выполнять вызовы API до тех пор, пока в этом списке есть данные.
- Зная общее количество записей в базе данных и количество элементов, извлекаемое одним запросом (50), вычислите число итераций, необходимое для извлечения всех записей. Создайте функцию, которая будет выполнять указанное число итераций по страницам с использованием смещения.
Мы сосредоточимся на двух первых стратегиях, и используем List.Generate.
Компоненты для функции List.Generate
Чтобы использовать функцию List.Generate для пагинации:
- создайте функцию для взаимодействия с API;
- организуйте компоненты URL;
- определите логику обработки ответов API для одной итерации.
Создание функции для взаимодействия с API
Для более ясного кода полезно определить отдельную функцию, которая будет вызывать API. Когда вы прошли Главная –> Создать источник –> Интернет, ввели URL-адрес и нажали OK, Power Query сгенерировал код (см. строку формул на рис. 13.21):
1 2 3 4 5 |
Json.Document( Web.Contents( "https://openlibrary.org/authors/OL2162284A/works.json" ) ) |
Чтобы упростить управление функцией List.Generate, превратим этот код в функцию fxGetData. Когда понадобится, мы будем передавать функции URL-адрес API в качестве аргумента:
1 |
( myURL as text ) => Json.Document(Web.Contents( myURL ) ) |
Организация компонентов URL
Для эффективного выполнения вызовов API, особенно для пагинации, полезно аккуратно разделить URL-адрес на логические элементы. Так мы подготовим запрос для будущих вызовов API. Первый вызов API использует полный URL:
1 |
"https://openlibrary.org/authors/OL2162284A/works.json" |
Сравните это с адресом из поля next (см. рис. 13.21) который вы получаете для пагинации:
1 |
"/authors/OL2162284A/works.json" |
Он неполный, пропускает переднюю часть (https://openlibrary.org), которая необходима для формирования работающего URL-адреса.
Разделение URL на сегменты
Чтобы упростить пагинацию, мы разделим полный URL-адрес на два сегмента:
- BaseURL – постоянное начало, https://openlibrary.org, которое остается неизменным от вызова к вызову.
- OffsetSuffix – переменный хвост, который изменяется от итерации к итерации. Например, /authors/OL2162284A/works.json для первого вызова.
Преимущества модульного подхода
Разбивка URL-адреса на части обеспечивает гибкость. Используйте BaseURL для первоначального вызова API и соедините его с OffsetSuffix, специфичным для этого вызова. Для последующих вызовов API объедините BaseURL со следующим фрагментом, полученным для пагинации:
1 2 |
BaseURL = "https://openlibrary.org/", OffsetSuffix = "authors/OL2162284A/works.json" |
Такой подход обеспечивает простой и организованный способ управления вызовами API, упрощая создание URL-адресов как для первоначального, так и для последующих вызовов. Разбив и упорядочив компоненты URL, мы переходим к подготовке оператора пагинации.
Инструкция List.Generate с использованием смещенного URL-адреса
Теперь, когда мы организовали наши компоненты URL в BaseURL и OffsetSuffix, мы можем использовать в параметрах List.Generate. Эти параметры будут обрабатывать вызовы API в цикле, выполняя несколько запросов до тех пор, пока не закончатся данные для автора:
Рис. 13.23. Оператор для циклического перебора API с использованием смещенного URL
Функция состоит из четырех аргументов: начальное значение, условие, следующий шаг, селектор.
Начальное значение
Начальное значение – это запись, которая состоит из двух полей:
- Поле Request выполняет первый вызов API, объединяя BaseURL и AuthorSuffix для формирования начального URL API. В нашем случае URL-адрес: https://openlibrary.org/authors/OL2162284A/works.json
Рис. 13.24. URL API
- Поле HasNext задает начальное условие для разбиения на страницы равное true. Это связано с тем, что действительный URL API всегда будет возвращать некоторые записи. Если записи не возвращаются, это означает, что автор не существует в базе данных.
Состояние
Здесь мы проверяем поле HasNext, чтобы решить, нужно ли нам сделать еще один вызов API. Если HasNext равно true, функция выполнит еще один запрос.
Следующий шаг
Аргумент next подготавливает функцию к следующему вызову API. Он состоит из двух полей:
- Обновленный Request формирует URL-адрес для следующего вызова API. Он принимает BaseURL и добавляет значение поля [Request][links][next], т.е. ссылку, хранящуюся в поле next записи links, которая в свою очередь является полем записи
- Обновленный HasNext проверяет, было ли поле next в записи links при последнем вызове API. Если было, возвращает true. Если нет, возвращает false, завершая цикл.
Код [Request][links][next] представляет из себя детализацию записи с помощью трех последовательных операций выбора поля.
Селектор
Селектор указывает, что должна возвращать функция. В нашем случае она вернет поле Request, потому что именно в нем хранятся данные API. Если мы опустим этот аргумент, то в результате получим список записей.
Тестирование List.Generate для пустого API
В описанном выше подходе мы получали доступ к компоненту URL, который указывал смещение для следующего вызова. В качестве альтернативы мы можем проверить, содержит ли вызов API данные:
Рис. 13.25. Оператор для циклического перебора API с использованием смещения вручную
Начальное значение
Функция запускается с начальной записи, которая имеет два поля:
- счетчик, отсчитываемый от нуля;
- запрос – поле с данными, возвращаемыми вызовом API; использует функцию fxGetData для получения данных, начиная с первой записи о книгах (смещение равно нулю).
Состояние
Аргумент condition определяет, когда следует остановить дальнейшие вызовы API. Он смотрит на поле entries записи Request. Если поле не пустое, вызовы продолжатся. Если пустое, цикл останавливается, указывая, что все данные собраны.
Следующий шаг
Эта часть обновляет запись для следующей итерации:
- счетчик увеличивается на 50, и служит смещением для следующего вызова API.
- запрос извлекает следующий набор данных на основе нового смещения.
Селектор
Селектор указывает, что будет возвращать функция. Нас интересует только поле Request, содержащее данные API.
Второй подход проще первого. Нет необходимости отслеживать, существует ли следующая ссылка или нет. С помощью List.IsEmpty мы лишь проверяем, есть ли еще записи для извлечения.
Превращение записей в желаемый результат
Выходными данными обеих функций List.Generate является список записей:
Рис. 13.26. Список записей в качестве выходных данных List.Generate
Если мы хотим вернуть список названий книг и дату их выхода, мы должны извлечь эти значения из записей. Детализируя записи и списки из вызова API можно найти следующие сведения:
Рис. 13.27. Объекты, возвращаемые вызовом API
Наличие списка записей – это не тот формат, который нам интересен. Трансформируем его:
- Пройдите Преобразование –> В таблицу.
- Разверните столбец Column1, оставив только поле entries. Снимите галку Использовать исходное поле столбца, как префикс.
- Разверните столбец entries, содержащий списки. Выберите опцию Развернуть до новых строк.
- Разверните столбец entries, содержащий записи. Оставьте поля title и first_publish_date.
Рис. 13.28. Таблица из API со всеми работами Стивена Кинга
На этом мы завершаем первый пример использования List.Generate. Как вы увидели, настройка API-соединения может показаться сложной. Она требует понимания, как получить доступ к различным объектам и научиться перебирать их. У нас есть еще один типичный вариант использования List.Generate – создание нарастающих итогов.
Создание столбца нарастающего итога
Создать нарастающий итог в модели данных с помощью DAX проще. Но существуют сценарии, в которых имеет смысл использовать Power Query. Нарастающий итог – это сумма ряда значений. Например, скользящий итог в финансовой отчетности. Фактические продажи часто сравнивают с плановыми с использованием расчетов с начала года.
В Power Query отсутствует функция для нарастающих итогов. Можно использовать List.Range или List.FirstN. Они вычисляют общую сумму путем сложения постоянно расширяющегося диапазона. При этом одно и то же число включается в операцию суммирования снова и снова. Это может замедлить расчеты.
Более эффективно использовать функции с рекурсивными возможностями: List.Generate или List.Accumulate. Они выполняют расчет, сохраняют результат в списке, а затем переходят к следующему числу. Каждое новое число добавляется к ранее сохраненной сумме. Таким образом, функции не нужно каждый раз пересчитывать весь диапазон, что приводит к повышению производительности.
Предположим, у вас есть таблица продаж:
Рис. 13.29. Данные о продажах
Следите за изложением с помощью запроса RunningTotal в PBIX-файле.
Чтобы добавить столбец с продажами, отсчитываемыми с начала года, нужно:
- Получить список значений для вычисления нарастающих итогов.
- Вычислить нарастающий итоги с помощью Generate.
- Разделить существующую таблицу на списки, каждый из которых представляет столбец.
- Собрать таблицу, объединив все списки.
Получение списка значений
Итак, мы начинаем с таблицы (рис. 13.29), сохраненной на шаге Source. Чтобы получить значения для передачи в List.Generate, нажмите на символ fХ рядом со строкой формул. Создастся новый шаг, как ссылка на Source. Переименуйте этот шаг в RTValues.
Обратимся к столбцу Sales с помощью выбора поля: Source [Sales]. Для расчета нарастающего итога мы будем обращаться к этому списку несколько раз. Чтобы повысить производительность, буферизируем список значений: List.Buffer(Source [Sales]):
Рис. 13.30. Список значений для нарастающих итогов
Вычисление нарастающих итогов с помощью List.Generate
Ранее в этой главе вы узнали, что List.Generate использует условие для оценки того, когда остановить вычисления.
Создайте новый шаг, который ссылается на RTValues. Переименуйте его в RunningTotal. Мы напишем формулу List.Generate, которая использует список RTValues для подсчета элементов в нарастающем итоге. Мы включим счетчик в List.Generate, который увеличивается на каждом шаге. Подсчет значений нарастающего итога прекращается, когда счетчик достигает общего количества значений в списке. Чтобы настроить счетчик, напишите:
1 2 3 4 5 |
List.Generate( () => 0, each _ < List.Count(RTValues), each _ + 1 ) |
В результате получается список с числами от 0 до 9. Поскольку нам нужен, и счетчик, и значения нарастающего итога, мы используем запись. Пока для хранения одной переменной. Чтобы визуализировать, что у нас получилось мы обернем конструкцию в Table.FromRecords:
Рис. 13.32. Запись с одной переменной для List.Generate
Чтобы рассчитать нарастающий итог, введем вторую переменную. Она появится, как в начальной записи (аргумент initial), так и в функции, отвечающей за обновление значений (аргумент next):
1 2 3 4 5 6 7 |
Table.FromRecords( List.Generate( () => [ counter = 0, RT = RTValues{0} ], // initial each [counter] < List.Count ( RTValues ), // condition each [ counter = [counter] + 1, RT = [RT] + RTValues{[counter] + 1} ] // next ) ) |
В запись добавлено новое поле с именем RT. Значение RTValues{0} задает начальное значение нарастающего итога на основе первого элемента из списка RTValues. В третьем аргументе (next) также добавлено поле RT. Еще раз: counter – переменная, которая увеличивается на единицу при каждой итерации функции; RT – нарастающий итог на каждом шаге.
С помощью счетчика мы получаем соответствующий элемент из списка RTValues. Когда counter равен 0, получаем RTValues{0}; когда counter равен 1, получаем RTValues{1} и так далее:
Рис. 13.33. Нарастающий итог с использованием List.Generate
Осталось внести два изменения:
- Удалить FromRecords – функцию, которую мы использовали для визуализации.
- Вернуть не записи целиком, а только поле RT.
1 2 3 4 5 6 |
LisList.Generate( () => [counter = 0, RT = RTValues{0}], each [counter] < List.Count(RTValues), each [counter = [counter] + 1, RT = [RT] + RTValues{[counter] + 1}], each [RT] ) |
Разделение исходной таблицы на отдельные списки
Нам нужно преобразовать исходную таблицу (рис. 13.29) в отдельные списки: один столбец – один список. Создайте новый шаг с именем TableColumns с кодом Table.ToColumns(Source):
Рис. 13.34. Таблица продаж преобразована в два списка
Объединение всех списков в таблицу
Теперь нужно собрать таблицу, объединив три списка: два с шага TableColumns и один с шага RunningTotal. Для этого используем Table.FromColumns с двумя аргументами:
- lists – список, включающий другие списки; каждый внутренний список – значения для одного столбца;
- columns – необязательный список названий столбцов.
Чтобы объединить столбцы в один список, используем выражение:
1 |
TableColumns & { RunningTotal } |
Так отдельные списки значений столбцов объединятся в один список:
Рис. 13.35. Списки значений столбцов
Создадим таблицу на основе этого списка: Table.FromColumns( TableColumns & { RunningTotal } ). Выражение вернет таблицу, содержащую все исходные столбцы, и новый столбец RunningTotal:
Рис. 13.36. Таблица дополнена нарастающими итогами
Таблица почти готова, но в ней отсутствуют два элемента: тип данных для столбца RunningTotal, и красивые имена столбцов. Есть два варианта установки типа данных для столбца RunningTotal. Первый – добавить отдельный шаг, в котором указать тип столбца. Второй – назначить тип данных столбцу RunningTotal с помощью функции Value.ReplaceType:
1 |
Value.ReplaceType( RunningTotal, тип {Int64.Type} ) |
Чтобы указать имена столбцов в Table.FromColumns, вы можете включить их в виде списка во второй аргумент. Чтобы получить имена столбцов из исходной таблицы, можно использовать Table.ColumnNames. Плюс добавить RunningTotal в качестве имени нового столбца. Следующий код решит проблему типа данных и имен столбцов:
1 2 3 4 5 |
Table.FromColumns( TableColumns & {Value.ReplaceType( RunningTotal, type { Int64.Type } )}, Table.ColumnNames( Source ) & {"Running Total"} ) |
Этот единственный оператор создаст таблицу со столбцом Running Total, правильно типизирует его и даст имена всем трем столбцам:
Рис. 13.37. Результирующая таблица
Пример с нарастающими итогами отлично иллюстрирует, как можно итеративно применять логику до тех пор, пока условие не завершится сбоем. Полезно помнить, что тот же сценарий можно реализовать и с помощью List.Accumulate. Оба подхода работают эффективно. Вы сами решаете, какую функцию предпочесть.
Мы рассмотрели функции List.Transform и List.Accumulate, которые работают в течение фиксированного числа итераций. Мы изучили List.Generate, выполняющую итерации, пока выполняется условие. В оставшейся части главы вы узнаете, что такое рекурсия, и как лучше всего ее использовать.
Рекурсия
Рекурсия возникает, когда функция вызывает себя в пределах собственного определения. Думайте об этом как о цикле, но вместо использования типичного цикла for или while функция использует себя для выполнения операции несколько раз. Рекурсия полезна, когда необходимо повторить задачу, но количество повторений заранее неизвестно. Для этой операции требуется условие окончания, чтобы итерации останавливались, предотвращая бесконечные повторения.
Почему рекурсия важна?
Хотя язык M предлагает богатый набор встроенных функций, некоторые операции требуют дополнительной гибкости и рекурсивного элемента. Например, у вас могут быть иерархические данные, такие как организационная диаграмма, которые необходимо распаковать. Или у вас могут быть вложенные объекты, которые вы хотите выпрямить.
Для большинства ситуаций, требующих рекурсии, мы рекомендуем использовать List.Generate. Функция работает лучше, чем традиционная рекурсия, и с ее помощью легче устранить неполадки. Тем не менее, книга была бы неполной без рассмотрения традиционной рекурсии.
Итак, когда мы считаем рекурсию приемлемой? Вы можете столкнуться с ситуацией, когда рекурсивная функция проста в написании, а количество итераций относительно невелико. Поскольку производительность здесь не будет проблемой, использование рекурсии приемлемо. Для иных ситуаций мы рекомендуем использовать List.Generate.
Сравнение рекурсии и итераций
И рекурсия, и итерация выполняют повторяющиеся задачи, но делают это по-разному. В итерации набор инструкций (цикл) выполняется многократно, пока не достигнет конца входных данных. Предположим, у вас есть список значений для преобразования. Функция List.Transform может применять операцию к каждому элементу в списке, что считается итерацией. Длина списка известна заранее, а количество итераций предсказуемо.
Рекурсия, с другой стороны, фиксирует повторяющуюся задачу внутри самой функции. Она многократно выполняет выражение, содержащее условие, до тех пор, пока это условие больше не будет выполняться. Это часто приводит к более чистому и элегантному коду, но за счет большего использования памяти и, как правило, более низкой производительности.
Так как же рекурсия работает на практике?
Рекурсивные функции
При написании рекурсивных функций важно учитывать понятие области видимости (scope). Область видимости относится к среде, в которой доступна переменная или функция. Обычно область видимости помогает вам ссылаться на переменную, определенную на другом этапе или в другом запросе. Однако для рекурсивной функции нам нужно создать ссылку на саму функцию.
С учетом этого, давайте познакомимся с символом @, известным как оператор области видимости или инклюзивная ссылка на идентификатор.
Что такое оператор области видимости @?
В языке M символ @ предназначен для решения проблем при доступе к переменным. @ позволяет функции ссылаться на себя или на другую переменную в своем собственном определении.
Переменные в M обычно не являются частью среды выражения, в котором они определены. Это означает, что обращение к самому себе, как правило, невозможно. Именно здесь на помощь приходит символ @. Он позволяет переменной быть частью своего окружения, тем самым поддерживая рекурсивные вызовы функций.
Инклюзивная ссылка на идентификатор
Символ @ формально известен как инклюзивная ссылка на идентификатор, термин, который подходит для концепции области в программировании. Термин состоит из трех слов:
- Инклюзивно: символ @ включает идентификатор (т.е. переменную или функцию) в пределах своей собственной области.
- Идентификатор: это переменная или функция, к которой вы пытаетесь получить доступ.
- Ссылка: вы указываете на определенный идентификатор в коде.
Символ @ является противоположностью тому, что известно как ссылка на эксклюзивный идентификатор. Эксклюзивные идентификаторы не могут быть частью собственной среды, что приводит к ошибкам при попытке ссылаться на себя.
Использование рекурсивных функций
Некоторые задачи требуют многократной проверки состояния и выполнения операции. В этих ситуациях рекурсивные функции особенно полезны. Типичный пример – вычисление факториала. Факториал – произведение всех положительных целых чисел, меньших или равных заданному положительному целому числу. Например, чтобы вычислить 5!, вы умножаете 5*4*3*2*1 = 120:
1 2 3 4 5 6 7 8 9 |
let Factorial = ( n ) => if n <= 1 then 1 else n * @Factorial ( n - 1 ), // используем @ Result = Factorial( 5 ) in Result |
Символ @ позволяет функции Factorial вызывать саму себя, делая функцию рекурсивной. Когда if имеет значение false, код умножает текущее число n на число n минус 1. Затем функция вызывается заново, на этот раз с аргументом n — 1. Процесс продолжается, пока оператор if не будет равен true. В этот момент функция прекращает работу. Если вы пропустите символ @, Power Query создаст ошибку, поскольку функция не может распознать себя без символа @.
Рассмотрим, как включить в код оператор @.
Как использовать оператор @
Увидев в одном из примеров оператора @, вы, вероятно, задаетесь вопросом, как включить его в код M. Используйте четыре шага для включения оператора scope в пользовательскую функцию.
Шаг 1: Напишите первоначальный код
Начните с написания функции обычным способом, без символа @. Например, вы создаете функцию Factorial, и ваш первоначальный код может выглядеть следующим образом:
1 |
Factorial = (n) => if n <= 1 then 1 else n * (n – 1) |
На этом шаге функция создается путем принятия входного значения n и выполнения операции один раз. Функция проверяет условие n ≤ 1. Если оно не выполняется, то умножает n на n — 1. Прежде чем думать о рекурсии, важно начать с условия if. Условие – мера безопасности для предотвращения попадания функции в бесконечный цикл. Не забудьте дать функции имя. Оно понадобится позже для рекурсивного вызова.
Шаг 2: Определите рекурсивный вызов
Затем определите, где функция должна вызывать саму себя. В нашем примере рекурсивный вызов включает n * (n — 1). На каждой итерации функция умножает текущее значение на себя -1.
Шаг 3: Добавьте оператор @
Пришло время включить в код символ @. Используем имя, присвоенное функции, включив символ @ и имя функции в ту часть, где она вызывает себя:
1 |
Factorial = (n) => if n <= 1 then 1 else n * @Factorial(n - 1) |
Функция будет продолжать вызывать себя до тех пор, пока условие if равно false. Когда это происходит, код доходит до части @Factorial, а затем начинает сначала, используя n-1 в качестве входных данных.
Шаг 4: Проверьте свою функцию
Выполните функцию, чтобы убедиться, что она работает должным образом. Проверьте на разных сценариях, чтобы убедиться, что оператор @ выполняет рекурсию должным образом.
Выполнив эти четыре шага, вы будете готовы к использованию оператора @ в своих функциях. Вы будете не только знать, как реализовать рекурсию, но и защитить ее от бесконечных циклов. Готовы к еще одному примеру?
Удаление последовательных пробелов
Предположим, у вас есть столбец текстов, в которых между словами произвольное число пробелов. Ваша цель, чтобы между словами всегда был один пробел. Например, у вас предложение «Hard work beats luck», (тяжелая работа побеждает удачу). Вы можете решить эту проблему, используя…
1 |
Text.Replace( MyString, " ", " " ) |
При этом коде два пробела подряд заменяются на один. Сделав это один раз, вы улучшите ситуацию, но в некоторых местах по-прежнему останутся лишние пробелы. Вам нужно выполнить операцию еще дважды, чтобы полностью убрать лишние пробелы.
Поскольку в каждом предложении в наборе данных может быть разное количество пробелов между словами, вы захотите повторять это действие по мере необходимости. Чтобы избежать чрезмерных вычислений, важно включить условие, которое проверяет наличие двух и более последовательных пробелов в строке:
1 2 3 4 5 6 7 |
let MyString = "Hard work beats luck", Replace = if Text.Contains(MyString, " ") then Text.Replace(MyString, " ", " ") else MyString in Replace |
Функция Text.Replace будет использована только в том случае, если условие выполняется, что экономит вычислительные ресурсы.
Теперь у нас есть операция замены, и условие, определяющее наличие последовательных пробелов. Включим повторение замены с помощью рекурсии. Чтобы реализовать рекурсию, нам нужно поместить нашу логику в функции. Это важно, потому что символ @ работает путем многократного вызова функции. Превратим код в функцию:
1 2 3 4 5 6 7 8 |
( MyString as text ) as text => let Replace = if Text.Contains ( MyString, " " ) then Text.Replace ( MyString, " ", " " ) else MyString in Replace |
Мы определяем параметр MyString, который принимает текстовую строку в качестве входных данных. Затем мы используем этот параметр в логике нашей функции.
Текущая версия нашей функции может заменить только один двойной пробел за один раз. Чтобы обеспечить непрерывные проверки и замены, мы можем включить рекурсию с помощью оператора @:
1 2 3 4 5 6 7 8 |
fxDeSpace = ( MyString as text ) as text => let Replace = if Text.Contains ( MyString, " " ) then @fxDeSpace ( Text.Replace ( MyString, " ", " " ) ) else MyString in Replace |
Мы присвоил функции имя fxDeSpace, внутри функции добавили ссылку на собственное имя, предваряемую оператором @. Важной деталью здесь является перенос Text.Replace внутрь @fxDeSpace. Если функция Text.Contains подтвердит наличие последовательных пробелов, функция заменит двойной пробел на одинарный, а затем снова вызовет себя. Этот цикл продолжится до тех пор, пока будут находиться двойные пробелы. Если таковых более не будет, функция перестает вызывать саму себя.
Рекомендации по производительности при использовании рекурсии
Рекурсия может быть элегантным способом решения проблем, имеющих рекурсивные структуры. Например, вычисление факториалов или обращение к иерархическим данным. Тем не менее, рекурсия не лишена недостатков, особенно когда речь идет о производительности. При использовании рекурсии каждый рекурсивный вызов создает новый слой в стеке вызовов – временное хранилище памяти, где программа отслеживает свои задачи. Если функция вызывает себя слишком много раз, стек может переполниться, что приведет к сбою запроса.
Когда происходит переполнение стека, вы увидите сообщение об ошибке, указывающее на то, что размер стека превысил свое ограничение. Это происходит, когда рекурсивная функция не имеет надлежащего условия выхода или глубина рекурсии слишком высока. Чтобы предотвратить это, обязательно включите условие выхода в рекурсивную функцию. А также следите за глубиной рекурсии.
Если вы получаете ошибку ограничения стека, это признак того, что вам следует рассмотреть альтернативные подходы.
Рекурсия редко является первым выбором для вычислений. Существуют более эффективные методы с использованием List.Generate и List.Accumulate.
Резюме
В этой главе мы рассмотрели два важных понятия: итерацию и рекурсию. Начав с итерации, мы изучили функции List.Transform и List.Accumulate. Они работают для списков с фиксированным числом элементов, подобно циклу for. List.Transform предназначен для простых операций, List.Accumulate полезен для сценариев, требующих доступа к результатам более ранних итераций.
Затем мы углубились в функцию List.Generate, которая позволяет динамически создавать списки на основе пользовательских условий и логики, аналогично циклу while. Функция обладает хорошей производительностью, и предоставляет простой способ отладки результатов. В большинстве случаев предпочтите ее традиционной рекурсии.
Наконец, мы рассмотрели рекурсию, реализованную в M с помощью оператора @ (scope). Несмотря на то, что рекурсия имеет свои издержки с точки зрения памяти и скорости, вы можете рассмотреть возможность ее использования для задач, включающих ограниченное количество итераций, где написание рекурсивной функции упрощает код.
На протяжении всей главы мы подчеркивали важность выбора подходящего метода – итерации или рекурсии – исходя из конкретных требований задач преобразования данных. Будьте проще, но при необходимости не бойтесь обращаться к сложным техникам, описанным в этой главе. Они могут значительно расширить возможности работы с данными в М Power Query.