Map как замена циклу
Я нашел, что отказ от циклов в пользу map
улучшает код сразу по
нескольким критериям, а заодно вправляет мозги. Код с мапами короче,
меньше подвержен ошибкам, его легче поддерживать.
Функция map
родом из мира функционального
программирования (далее ФП). Почти любой язык имеет ее аналог в
стандартной поставке. Map может быть как функцией, так и методом
коллекции.
Map принимает функцию и коллекцию. Функция обрабатывает один
элемент. Результат map
– коллекция результатов функции на множестве
входных данных.
Map призван заменить циклы и ручную итерацию с накоплением результатов. ФП в российских вузах либо не преподают, либо касаются факультативно. Студенты мыслят циклами и переносят привычки во взрослую жизнь.
Мышление циклами – плохая штука. Цикл – примитивная, не
регламентированная конструкция, которая быстро выходит из-под контроля
и превращает код в лапшу. Map
лучше цикла по следующим пунктам.
Меньше кода
Пусть определена функция do_stuff(item)
, ее тело не важно. Это
штучный обработчик объекта. Сравним два фрагмента кода:
results = map(do_stuff, my_data_list)
и
results = []
for x in my_data_list:
res = do_stuff(x)
results.append(res)
Очевидно, первый вариант короче и не засоряет пространство лишними
переменными. Строку с промежуточным результатом res
можно опустить,
но тогда следующая строка results.append(do_stuff(x))
станет
сложнее. Две операции на одну строку – не желательно.
Сдвиг кода и логические прыжки
Цикл – это дополнительный сдвиг кода. ООП уже дает сдвиги для классов и методов, а тут еще циклы. При этом нужно бегать глазами к переменным-спискам, объявленным до цикла. Map не делает сдвига и создает переменные по требованию.
Декларативный код безопасней
Map
– это декларативная операция. Это значит, я говорю что именно
хочу получить, а не как. За счет автоматизации ручных действий,
декларативный код очень легко поддерживать и в нем меньше
багов. Бывает, баг кроется именно в неверной итерации или ручной
сборке списка. Но из-за кажущейся тривиальности проверяешь это место в
последнюю очередь.
Дополнительный уровень абстракции
Map несет большой потенциал для построения абстракций. Скажем, в
обычном языке map
– это банальный прогон функции по элементам в
цикле. Но в других языках map
обретает преимущества, недоступные
обычным циклам.
Если у нас неизменяемые коллекции и транзакционная память,
интерпретатор раскидает задачу по ядрам. Имея 1000 элементов и 4 ядра,
получим по 250 итераций на одно ядро. Описать это циклом –
нетривиальная задача. Насколько я знаю, Clojure и некоторые реализации
Haskell параллелят map
при заданных флагах компиляции.
Map
очень круто параллелит запросы в сеть. Например, нужно дернуть
100 урлов, при этом сервера находятся в разных полушариях, и время
отклика ожидаемо велико. Отклик в 10 секунд на запрос из Европы в
Сингапур – нормальное дело. Важно понять, что хоть у вас
супер-кластер, он будет висеть 10 секунд в цикле, ожидая блокирующий
запрос. По меркам машинного времени это целая вечность.
Map
легко перестоит одну схему исполнения в другую. Вот обычный
подход, аналогичный циклу:
url_list = ["http://foo1.com", "http://foo2.com", ...]
def get_data(url):
return requests.get(url, timeout=5).json()
data_list = map(get_data, url_list)
Теперь напишем свой map с использованием тредов:
from multiprocessing.dummy import Pool
def map_threaded(func, *seq):
pool = Pool(processes=16)
return pool.map(func, *seq)
data_list = map_threaded(get_data, url_list)
Всего лишь поменяли map
на map_threaded
, но какая большая разница
в реализации! Был цикл, а стали треды, но сигнатуры одинаковые. Теперь
на всю операцию понадобится время, равное самому долгому запросу.
(Да, это пул тредов не смотря на название модуля.)
Мощь абстракции map
очень велика. Мы можем использовать любой другой
метод распределения задач. Например, процессы вместо тредов. Или
подключить фреймворк вроде Celery
, который шлет задания воркерам,
собирает ответы и отдает список.
Мощь Гугла – это комбинация функций
Map и Reduce. Инженеры Гугла дробят задачи на
подзадачи и эффективно исполняют их на тысячах серверов. Технология
работает в масштабах планеты. Под капотом может быть скучный ООП-код
на Java, но Map
и Reduce
– абстракция высочайшего
порядка. Благодаря им Гугл стал тем, что есть сейчас.
Контроль за разрастанием цикла
Проблема цикла в том, что код внутри никак не регламентирован. Тело цикла бесконтрольно разрастается. Сперва кажется, что в цикле все ясно. Но меняются бизнес-требования, нужны фичи, и ясность быстро уходит.
Добавили if
, затем еще один, затем continue
по условию, а потом
вообще залепили вложенный цикл. В этой ветке пишем в результирующий
список, а в этой нет. Логику накопления результатов отследить трудно.
Бывает, мы добавляем костыли в цикл, потому что на носу релиз, и возиться некогда. Часто в ревью мы видим пару строчек:
for item in items:
+ if not item.check_foo():
+ continue
...
которые ни у кого не вызывают подозрений. Ну, пропускаешь шаг, все понятно.
Это плохо.
Ближайшая аналогия – растение морской желудь. Он разрастается по дну судна. Если планово не счищать, скорость судна и потребление горючего падают и возрастают соответственно на 40%.
Отрефакторить цикл, в котором полно if
, continue
, try/catch
очень трудно. Все завязано на глобальные списки, объявленные до входа
в цикл. Оператор continue
я считаю дурным тоном. Мы должны отделять
котлеты от мух до входа в итерацию.
Map
исключает условия и пропуск элементов. Целевая функция не может
сказать “этот обработать не могу, дай-ка лучше следующий”. Список
следует предварительно очистить функцией filter
.
Неверное прохождение слоев бизнес-логики
Мы часто работаем со списками объектов. Типичная задача – извлечь
данные из базы и выполнить для каждого ряд действий. Например,
действие1
, действие2
и действие3
.
Итерируясь вручную, программист выполняет все три для первого объекта,
затем для второго, и так далее. И забывает, что противоположный обход
– сначала для всех выполнить действие1
, затем для всех действие2
и т.д. – выгодней по следующим причинам:
- для очередного действия может потребоваться какой-то ресурс: файл, сеть, авторизация. Логично открыть его, прогнать объекты, закрыть ресурс и переходить к следующей стадии. В цикле придется держать ресурсы открытыми все время.
- если мы лажанулись уже в начале, открывать некоторые ресурсы не понадобиться вообще.
- описанный подход – это проход бизнес-логики по слоям, что ближе к реальному положению дел. Например, вы прошли два действия, но третье не вышло – нет сети или файла. Можно сдампить результаты в файл и несложным хаком вернуться в состояние, когда результаты двух первых стадий есть, а третьей – нет. В случае ошибки на середине цикла контекст будет намного сложней, а вернуться на конкретный шаг итерации – невозможно.
Повышенные требования к коду
Map
заставляет писать код качественней. Map
не имеет встроенной
защиты от не пойманных исключений. Если упадет первый элемент, не
выполнятся остальные 999. Поэтому передавая функцию в мап, программист
должен подписаться, что функция не подведет.
Не каждый готов дать эту гарантию. Часто мы видим такое:
result = []
for item in items:
try:
data = process_item(item)
res = process_data(data)
result.append(res)
except Exception as e:
logger.except(e)
continue
Этот код ужасен. Он беззуб и неуклюж. Функции написаны криво и бросают ошибки. Смысловая часть уехала на два отступа. На выходе из цикла не ясно, с какими элементами случилась ошибка. Смешались в кучу итерация, отлов исключений, логирование.
А ведь достаточно завернуть целевую функцию в декоратор или другую
функцию, которая отдаст пару (err, result)
и прогнать через
map
. После прогона отделить хорошие результаты от плохих. Ошибки
залогировать отдельным шагом. Хорошие результаты отправить в следующий
слой бизнес-логики.
Хорошая новость в том, что мы уже в двух шагах от монад, но пока не будем о них.
Вместо заключения
Отказ от циклов сокращает код, делает его яснее и легче в поддержке. Map не позволит воткнуть в середине условие или переход к следующей итерации. Ручное накопление списка чревато багами, поэтому лучше доверить это надежной функции.
Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter