Списковые включения, lists и охранные выражения

Выразительные инструменты для обработки данных и уточнения условий.

Списковое включение (list comprehension) — компактная запись построения нового списка из элементов другого с фильтрацией и преобразованием.

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

Освоив их, вы заметите, что огромная доля повседневного кода складывается из этих трёх кирпичиков. «Возьми список, преобразуй элементы, отбрось ненужные, собери итог» — это скелет бесчисленного множества задач, и Erlang позволяет выразить его в одну-две строки, не теряя при этом ясности.

Списковые включения

Синтаксис похож на математическую запись множеств. Двойная вертикальная черта || отделяет выражение-результат от генераторов и фильтров. Если вы когда-нибудь видели запись множества вида «все x·x, такие что x принадлежит набору и x чётно», то списковое включение — почти дословный перенос этой математической идеи в код. Слева от черты стоит то, что мы строим, справа — откуда берём элементы и какие из них оставляем.

% Квадраты чисел
Squares = [X * X || X <- [1, 2, 3, 4]].
% [1, 4, 9, 16]

% Только чётные, удвоенные
Evens = [X * 2 || X <- [1,2,3,4,5,6], X rem 2 =:= 0].
% [4, 8, 12]

Читается так: «для каждого X из списка, где X чётное, возьми X*2». Стрелка-генератор берёт элементы по очереди, а условие после запятой работает как фильтр, отсеивая неподходящие. Можно ставить несколько генераторов и несколько фильтров подряд: тогда включение переберёт все сочетания значений и оставит только те, что прошли все условия. Так в одну строку выражаются вещи, которые на циклах заняли бы вложенные блоки с проверками внутри.

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

Модуль lists

Стандартный модуль lists содержит десятки готовых функций. Самые ходовые — map, filter, foldl (свёртка слева). Это проверенные временем кирпичики, и почти всегда лучше взять готовую функцию из lists, чем писать свою рекурсию: библиотечный код отлажен, понятен любому читателю и зачастую эффективнее наивной самоделки. Здесь как раз и пригождаются анонимные функции из второго урока — именно их вы передаёте в map и filter, описывая, что делать с каждым элементом.

lists:map(fun(X) -> X + 1 end, [1, 2, 3]).      % [2,3,4]
lists:filter(fun(X) -> X > 2 end, [1,2,3,4]).   % [3,4]
lists:foldl(fun(X, Acc) -> X + Acc end, 0, [1,2,3,4]). % 10

Свёртка foldl особенно мощна: она «схлопывает» список в одно значение, прогоняя через функцию каждый элемент вместе с аккумулятором. По сути это обобщение той самой хвостовой рекурсии с аккумулятором, что мы писали руками: foldl уже содержит готовый обход списка, а вам остаётся лишь сказать, как объединять очередной элемент с накопленным результатом. Через свёртку выражается удивительно много: сумма и произведение, подсчёт количества, сборка строки, построение словаря из списка пар — всё это частные случаи одной операции.

Стоит привыкнуть смотреть на задачу и спрашивать себя: «я преобразую каждый элемент по отдельности (тогда map), отбираю подмножество (тогда filter) или свожу всё к одному итогу (тогда foldl)?». Этот простой вопрос чаще всего сразу подсказывает нужный инструмент и избавляет от соблазна писать очередной рекурсивный обход вручную.

Охранные выражения (guards)

Иногда сопоставления по форме недостаточно — нужно условие на значение. Для этого служат guards: дополнительная проверка после ключевого слова when. Шаблон отвечает на вопрос «какой формы данные», а guard — на вопрос «удовлетворяют ли они дополнительному условию», например «число положительное» или «длина строки больше десяти». Вместе они дают очень выразительный отбор клауз: можно различать случаи и по структуре, и по содержательным свойствам значений одновременно.

classify(N) when N < 0 -> negative;
classify(0) -> zero;
classify(N) when N > 0 -> positive.

Клауза выбирается, только если и шаблон совпал, и охранное выражение истинно. В guards допускаются сравнения, арифметика и специальные проверки типов: is_integer/1, is_list/1, is_atom/1 и другие. Эти проверки типов особенно ценны: язык динамически типизирован, и guard — основной способ убедиться, что аргумент действительно того сорта, который вы ожидаете, прежде чем что-то с ним делать. Сочетание шаблона и проверки типа в guard нередко заменяет целую россыпь защитных условий, которые в других языках пришлось бы писать в начале функции.

describe(X) when is_integer(X), X > 100 -> big_number;
describe(X) when is_list(X) -> some_list;
describe(_) -> other.

Как работает под капотом

В guards разрешён лишь ограниченный набор «безопасных» выражений без побочных эффектов — нельзя вызывать произвольные функции. Это сделано намеренно: проверка клаузы обязана быть быстрой и предсказуемой, без риска зависнуть или упасть. Запятая в guard означает «и», точка с запятой — «или». Списковые включения компилятор разворачивает в обычную рекурсию по списку. Ещё одна важная гарантия: если само вычисление guard вдруг приведёт к ошибке (скажем, арифметика над неподходящим типом), guard просто считается ложным, и язык переходит к следующей клаузе вместо того, чтобы рушить всю функцию. Благодаря этому охранные выражения безопасно отсеивают «не те» значения, даже не зная заранее их типа.

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

Частые ошибки

  • Вызывать в guard свою функцию. Разрешены только встроенные безопасные проверки.
  • Путать =:= и ==. =:= — строгое равенство (1 не равно 1.0), == — с приведением.
  • Забыть фильтр в comprehension. Тогда обработаются все элементы без отбора.

Итоги

  • List comprehension строит новый список с преобразованием и фильтрацией через ||.
  • Модуль lists даёт map, filter, foldl и десятки других функций.
  • Guards (when) добавляют условие на значение поверх шаблона.
  • В guards допустимы только безопасные встроенные проверки без побочных эффектов.
Проверьте себя
1. Что делает выражение [X * X || X <- [1,2,3]]?
AСкладывает числа
BСтроит список квадратов: [1, 4, 9]
CВозвращает [1,2,3]
DВызывает ошибку
2. Зачем нужны охранные выражения (guards)?
AЧтобы экспортировать функции
BЧтобы добавить условие на значение поверх сопоставления шаблона
CЧтобы создать процесс
DЧтобы завершить модуль
3. Почему в guards нельзя вызывать произвольные функции?
AЭто слишком медленно компилируется
BПроверка клаузы должна быть быстрой и без побочных эффектов
CФункции запрещены в Erlang
DЭто ограничение синтаксиса списков