Постигаем Y-комбинатор с помощью Elixir

В данной статье в общем смысле рассматривается само понятие Y-комбинатора и его получение на Elixir с помощью анонимных функций. Статья определённо не из лёгких, но, надеюсь, это никак не отразится на её интересности.

Зачем вам всё это?

Почему так важно знать о Y-комбинаторе? Честно говоря, это имеет значение лишь для продвинутых разработчиков. Преимущества от умения работать с Y-комбинатором в обычной веб-разработке практически незаметны.

Как бы то ни было, Y-комбинатор — интереснейшее явление IT-мира.

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

Для меня Y-комбинатор — нечто сродни множеству Мандельброта или игре «Жизнь». Нечто, чем можно любоваться и восхищаться.

Y-комбинатор: начало

Начнём знакомство с Y-комбинатором с создания рекурсивной функции без использования рекурсии.

Воспользуемся классическим подходом:

def length([]),    do: 0
def length([h|t]), do: 1 + length(t)

Функция length, объявленная как анонимная, вычисляет длину списка:

length([])     # 0
length([1])    # 1
length([1, 1]) # 2 и т. д. ...

Если функции передать пустой список, то она возвратит нулевое значение. В противном случае возвращается 1 плюс длина хвоста списка. Такой тип рекурсивного определения возможен благодаря использованию def и defp при задании функций, вследствие чего Elixir «разрешает» функциям осуществлять прямые вызовы самих себя.

Но что если попробовать задать рекурсивную анонимную функцию?

length = fn
  []    -> 0
  [h|t] -> 1 + length.(t)
end

Привязываем length к анонимной функции, в которой вызываем ту же length, создавая рекурсию. Поскольку функция length не находится в скоупе, возникает следующая ошибка компиляции:

** (CompileError) iex:9: undefined function length/0
       (stdlib) lists.erl:1354: :lists.mapfoldl/3
       (stdlib) lists.erl:1355: :lists.mapfoldl/3

Возможно ли вообще создать рекурсивную анонимную функцию? Создать рекурсивную функцию, при том, что ссылаться прямо на неё нельзя?

Да! Вот она, мощь Y-комбинатора!

Навстречу неизведанному

Прежде чем перейти к самому интересному, сделаем небольшое отступление и кое-что проясним. Несмотря на то, что рекурсивный вызов анонимной функции length не работает, часть функции всё же написана корректно.

Для наглядности вышесказанного уберём рекурсию из тела функции:

length = fn
  []    -> 0
  [h|t] -> 1 + (fn _ -> raise "oops" end).(t)
end

Очевидно, можно вычислить длину пустого списка и без использования рекурсии:

length.([])   # 0
length.([1])  # упс...

Можно обработать и список единичной длины, заменив функцию обработки исключений на точную копию функции length:

length = fn
  []    -> 0
  [h|t] -> 1 + (fn
                  []    -> 0
                  [h|t] -> 1 + (fn _ -> raise "oops" end).(t)
                end).(t)
end

Теперь мы можем вычислить длину пустого списка и списка с одним элементом, но при большем количестве элементов возникает коллапс:

length.([])     # 0
length.([1])    # 1
length.([1, 2]) # упс...

Можно до бесконечности продолжать «обучать» функцию находить длину более объемных списков, прописывая одни и те же строки для каждого отдельного случая:

length = fn
  []    -> 0
  [h|t] -> 1 + (fn
                  []    -> 0
                  [h|t] -> 1 + (fn
                                  []    -> 0
                                  [h|t] -> 1 + (
                                                  ...
                                               ).(t)
                                end).(t)
                end).(t)
end

Схема рабочая, но совершенно безграмотная. А что если список будет состоять из 1000 элементов? Строить цепочку из огромного количества одинаковых функций?

Кроме того, при таком способе реализации функция всегда будет иметь верхнюю границу, и при её нарушении неизбежно возникнет ошибка.

Нужно другое решение.

Don’t Repeat Yourself

Если присмотреться, то можно заметить, что предыдущий пример пестрит повторами. На каждом уровне такой рекурсии происходит задание анонимной функции, очень похожей на изначальную функцию length.

Каждый уровень идентичен предыдущему, за исключением вызываемой функции: ещё одна функция типа length, либо функция обработки исключений в самом конце.

Но ведь можно избежать повторений в коде, определив функцию, которая сама бы создавала каждый новый уровень стека length. Новая функция make_length принимает в качестве аргумента изменяющийся на каждом уровне фрагмент кода и следующую по очереди функцию:

make_length = fn
  length ->
    fn
      []    -> 0
      [h|t] -> 1 + length.(t)
    end
end

Теперь можем воспользоваться функцией make_length, чтобы вычислить длину пустого списка:

make_length.(fn _ -> raise "oops" end)

Аналогично для списков нулевой и единичной длины:

make_length.(make_length.(fn _ -> raise "oops" end))

Это работает, ведь мы создаём функцию для пустого списка и передаём функции make_length, которая оборачивает всё это в ещё одну length-подобную функцию make_length.

Можно сделать код чуть приятнее с помощью пайп-операторов:

(fn _ -> raise "oops" end)
|> (make_length).()
|> (make_length).()

Получается, теперь можно без привязок задать функцию length анонимно и вычислить длину списков, содержащих до трех элементов:

(fn
  make_length ->
    (fn _ -> raise "oops" end)
    |> (make_length).() # support for length 1
    |> (make_length).() # support for length 2
    |> (make_length).() # support for length 3
end).(fn
  length ->
    fn
      []    -> 0
      [h|t] -> 1 + length.(t)
    end 
end)

Вот это уже интересно!

По сравнению с первой реализацией мы смогли избавиться от большей части повторений, но проблема ограниченного числа рекурсий осталась нерешённой.

По кирпичику за раз

На данный момент существует необходимость заранее определять максимально возможную длину списка.

Каждый новый вызов функции make_length добавляет новый слой в растущий стек. Но если список окажется слишком длинным, произойдет вызов функции обработки исключений, и подсчёт длины списка застопорится.

Чтобы довести дело до ума, нужно хорошенько поразмыслить о том, что вообще происходит в коде…

При каждом вызове нашей length-подобной функции принимается решение: нужна ли рекурсия. Если да, то вызывается функция length, привязанная к её замыканию, если нет, то возвращается значение, которое затем помещается в стек вызовов.

Проблема в том, что функция length может указывать на функцию обработки исключений.

Вместо возникновения ошибки при достижении последней функции в стеке хорошо бы добавить ещё один слой функции make_length и вызвать её. На деле, в стеке нам нужны лишь две функции: текущая и следующая.

Каждый слой рекурсии создаётся передачей функции make_length самой себе. Упростим создание стека: убрерём функцию обработки ошибок и просто добавим следующий слой:

(fn
  make_length ->
    make_length.(make_length) # Add another layer
end).(fn
  length ->
    fn
      []    -> 0
      [h|t] -> 1 + length.(t)
    end 
end)

Теперь можно даже переименовать аргумент length во второй анонимной функции, чтобы make_length сообщала нам, что именно в неё передаётся:

(fn
  make_length ->
    make_length.(make_length)
end).(fn
  make_length -> # We're being passed make_length
    fn
      []    -> 0
      [h|t] -> 1 + make_length.(t)
    end 
end)

Но всё не так просто.

Попытки передать хвост списка t функции make_length не имеют никакого смысла. Функция make_length в качестве аргумента ожидает получить функцию, а возвращает функцию, которая на входе ожидает получить список.

Что же можно передать make_length, чтобы на выходе получить ещё один слой? Подождите, помнится, make_length полностью дублирует тело нашей второй анонимной функции:

(fn
  make_length ->
    fn
      []    -> 0
      [h|t] -> 1 + make_length.(t)
    end 
end)

Так может, передать make_length саму себя? Точно!

Передавая make_length самой make_length, мы получим новый слой length-подобной функции, которая в качестве аргумента принимает список:

(fn
  make_length ->
    make_length.(make_length)
end).(fn
  make_length ->
    fn
      []    -> 0
      [h|t] -> 1 + (make_length.(make_length)).(t)
    end 
end)

Полученный слой имеет доступ к make_length, и, если понадобится рекурсия, ничего больше не препятствует создать и привести в действие новый слой length. Теперь не нужно заранее продумывать, какой высоты будет наша башня из функций length. Мы можем строить её постепенно, по одному кирпичику!

Как это прекрасно… Вы ещё не устали?

«Каждое поколение будет и сосудом, и его содержимым — эхом самовоспроизводящейся реверберации». — Тед Чан, «История твоей жизни»

Возвращаемся к функции length

Теперь наша реализация полностью функциональна. Мы можем вычислить длину списка любого размера:

(fn
  make_length ->
    make_length.(make_length)
end).(fn
  make_length ->
    fn
      []    -> 0
      [h|t] -> 1 + (make_length.(make_length)).(t)
    end 
end).([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...])

Единственная проблема — это то, что мы отошли от изначального представления функции:

fn
  []    -> 0
  [h|t] -> 1 + length.(t)
end

Мы модифицировали её таким образом, чтобы она содержала некие специфические для реализации вызовы make_length:

fn
  []    -> 0
  [h|t] -> 1 + (make_length.(make_length)).(t)
end

Если бы можно было вернуть функции length прежний вид, теоретически, мы могли бы «вынести её за скобки» и создать общую формулу, которая позволяла бы превращать любую функцию в рекурсивную без лишних движений.

Начнём с того фрагмента кода, где length((make_length.(make_length))) превращается в отдельную функцию:

fn
  t ->
    (make_length.(make_length)).(t)
end

Данная функция получает t в качестве аргумента и передаёт его следующему слою length, как только он создаётся.

Результат этой функции — сама length! Можно прописать ещё одно замыкание, которое принимало бы length в качестве аргумента и передавало бы в неё нашу новую функцию. Новая реализация получилась немного длиннее, но зато мы вернулись к изначальной функции length:

(fn
  make_length ->
    make_length.(make_length)
end).(fn
  make_length ->
    (fn
      length ->
        fn
          []    -> 0
          [h|t] -> 1 + length.(t)
        end
    end).(fn
      t ->
        (make_length.(make_length)).(t)
    end)
end)

Возрождённая функция length нигде не ссылается на make_length, а значит, ничего не мешает выпутать её из этого клубка анонимных функций.

Обернём весь блок в ещё одно замыкание, принимающее функцию length как аргумент:

(fn
  length ->
    (fn
      make_length ->
        make_length.(make_length)
    end).(fn
      make_length ->
        length.(fn
          t ->
            (make_length.(make_length)).(t)
        end)
    end)
end).(fn
  length ->
    fn
      []    -> 0
      [h|t] -> 1 + length.(t)
    end 
end)

Вынесем «общий множитель» length и приведём код в более традиционный, хотя и гораздо менее читабельный, вид:

y = fn
  f ->
    (fn
      x ->
        x.(x)
    end).(fn
      x ->
        f.(fn
          t ->
            (x.(x)).(t)
        end)
    end)
end

Самое интересное здесь то, что полученная функция y сможет сделать абсолютно любую функцию рекурсивной! Вот, к примеру, рекурсивная функция для нахождения n-ого члена последовательности Фибоначчи:

y.(fn
  fib ->
    fn
      0 -> 0
      1 -> 1
      n -> fib.(n-1) + fib.(n - 2)
    end 
end)

Как вы, наверное, уже догадались, то, что у нас получилось, имеет название. И это тот самый Y-комбинатор! С моей точки зрения, Y-комбинатор — одна из самых красивых структур во всей IT-вселенной.

Выводы и дополнительные материалы

Надеюсь, данная статья помогла вам разобраться с Y-комбинатором и показала всю его рекурсивную мощь.

Честно говоря, мне самому до сих пор не удаётся до конца в нём разобраться. Мне понятен порядок действий для его создания, но как только я сталкиваюсь лицом к лицу c присущей ему системой лямбда-исчисления, я не способен коротко и ясно объяснить, что он из себя представляет:

λf. (λx. f (x x))(λx. f (x x))

По-моему, это греческий.

Если у вас есть желание копнуть глубже по этой теме, я настоятельно рекомендую обратить внимание на книгу «The Little Schemer» Даниэля Фридмана и Мэттиаса Феллейсена. Это то, что позволило мне увидеть всю красоту Y-комбинатора и поразмыслить над другими не менее занимательными идеями. Рекомендую. Настоятельно.

Если книги не по вашей части, то можете обогатить свои знания при помощи вот этого краткого видео-введения в мир Y-комбинатора от Computerphile и этой довольно-таки исчерпывающей статьи.

© 2020 / Россия Любые мысли и вопросы пишите на elixir@wunsh.ru.