Несмотря на то, что раньше в университетах было принято обучать людей CS начиная с какого-нибудь ФП и Лиспа, большинство людей сегодня начинают знакомство с программированием с императивного стиля. Представлять программу, как установленный набор последовательно выполняемых инструкций, это наиболее базовый и понятный новичку подход к тому, чтобы заставить компьютер что-то делать.
И одно из первых понятий, с которыми знакомятся начинающие – это циклы. Цикл, если задуматься, неразрывно связан с императивностью. Он группирует множество схожих последовательных инструкций над каким-то набором данных. Это очень легко соотнести с реальной жизнью: “курьеру нужно посетить дома 1, 2 и 3 и т.п”.
Потому что мы узнаём при обучении о циклах так рано, то поэтому при решении любых проблем прибегаем к использованию циклов очень рано. Но что если их использование – это не всегда лучшее или даже хорошее решение? Некоторые функциональные языки, например, вообще не включают в себя понятие цикла, и при этом вполне успешно решают задачи.
Зачем заменять чем-то циклы?
Циклы приводят к запутанному коду
Нам хочется проитерироваться по двумерному массиву (по n-мерному). Для этого мы создаём вложенные массивы. Затем, по необходимости, включаем туда условия. Или нам нужно пройтись по полученной JSON`ке, где наши данные “спрятаны” за несколькими уровнями ключей. Очень быстро наш код становиться сложным для восприятия
Циклы небезопасны (иногда):
Самый радикальный пример здесь, это циклы в C
, где задача следить, чтобы индекс не вышел за пределы массива
целиком и полностью на плечах программиста, а ошибка вызовет в лучшем случае краш программы, в худшем она продолжит работать с Undefined Behavior.
Многие современные языки так или иначе абстрагируют работу циклов, так например в Python
цикл for
по умолчанию вызывает итератор,
который удобно защищает нас от IndexError
. Но даже в таком случае программисты часто всё равно выбирают (вынуждено или нет) “небезопасный” вариант, например с while
.
Циклы невыразительны
Цикл в конце концов обозначает в первую очередь именню какую-то итерацию по данным, но к какому результату она приводит нужно восстанавливать уже по операциям, вложенным внутрь этого цикла. Это становится только сложнее.
Альтернативные подходы
Если кратко, список того, что может нам помочь:
- Рекурсия, итеративный процесс.
- Встроенные в язык итераторы.
- Функциональные подходы: map, reduce, fold, filter и т.д.
- Стандартная библиотека языка: для
Python
это, например, инструментыitertools
,functools
.
Рекурсия и итеративный процесс (когда мы передаём результат итерации в аргумент функции, который обычно называют аккyмулятором) это то что обычно заменяет циклы в чисто функциональных языках. В обычных ЯП рекурсию нужно использовать с большой осторожностью, как многие языки не поддерживают tail call optimization, поэтому рекурсия может легко привести к переполнению стека. Однако рекурсия всё ещё очень мощный инструмент, который позволяет решить определенный ряд задач значительно проще и выразительнее, чем при помощи циклов. Самым очевидным примером здесь будет обход узлов деревьев при помощи алгоритмов BFS и DFS.
Пример решения проблемы с вложенными циклами:
Допустим, нам нужно создать партиции для нескольких таблиц по датам для нескольких менеджеров. Вместо того, чтобы перебирать все варианты через 3 цикла for, мы можем сократить их до одного (при этом мы итерируемся по объекту, который возвращает нам product, что обеспечивает дополнительную безопасность).
TABLE_LIST = ['sales', 'stocks', 'prices']
DATES = ['2024-01-01', '2024-01-02', '2024-01-03']
MANAGERS = ['Tanya', 'Oleg']
def create_partition(date, table, manager) -> None:
...
for date, table, manager in itertools.product(DATES, TABLES_LIST, MANAGERS):
create_partition(date, table, manager)
Или если у нас есть JSON со списком сотрудников, мы можем легко отобрать их email
по определенным условиям, используя map
и filter
.
filtered_emails = list(
map(lambda employee: employee["email"],
filter(lambda employee: employee["department"] == "Engineering"
and employee["salary"] > 70000, employees))
)
Один из красивых способов представления “Игры жизни”, который я видел, не использует никаких классов для представления доски или клеток и также практически не применяет циклов для расчета каждого этапа изменения “жизни” клеток на доске, вместо этого:
- используется генератор для представления всех соседних клеток текущей.
- расчет распространения клеток каждый ход рассчитывается сочетанием функций chain из itertools и map для применения указанного выше генератора к текущим клеткам.
- используем списковые включения для итоговой фильтрации (аналогично применению filter()).
def neighbors(point):
x, y = point
yield x + 1, y
yield x - 1, y
yield x , y + 1
yield x , y - 1
yield x + 1, y + 1
yield x + 1, y - 1
yield x - 1, y + 1
yield x - 1, y - 1
def filter_cond(point, board):
count = sum((neigh in board) for neigh in neighbors(point))
return count == 3 or (count == 2 and point in board)
def advance(board):
newstate = set()
recalc = board | set(itertools.chain(*map(neighbors, board)))
return [point for point in recalc if filter_cond(point, board)]
# Пример работы
glider = set([(0, 0), (1, 0), (2, 0), (0, 1), (1, 2)])
for i in range(1000):
glider = advance(glider)
print(glider)
Заключение
Циклы – неотъемлемая часть большинства современных языков программирования. И мы так или иначе будем постоянно сталкиваться с ними в работе и использовать их. Однако при виде каскада вложенных циклов и условий, впору задуматься, можно ли “сказать это лучше” на вашем ЯП. Посмотрите, например, как возможно решить проблему в более функциональном стиле на C++ (Youtube).