Нам всё еще нужны циклы?


Несмотря на то, что раньше в университетах было принято обучать людей 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).