Как заменить циклы while альтернативой функционального программирования без оптимизации хвостового вызова?

я экспериментирую с более функциональным стилем в своем JavaScript; поэтому я заменил циклы на служебные функции, такие как map и reduce. Однако я не нашел функциональной замены циклам while, поскольку оптимизация хвостового вызова обычно недоступна для JavaScript. (Насколько я понимаю, ES6 предотвращает переполнение стека хвостовыми вызовами, но не оптимизирует их производительность.)

Я объясняю, что я пробовал ниже, но TLDR: если я у вас нет оптимизации хвостового вызова, каков функциональный способ реализации циклов while?

что я пробовал:

создание функции утилиты" while":

function while(func, test, data) {
  const newData = func(data);
  if(test(newData)) {
    return newData;
  } else {
    return while(func, test, newData);
  }
}

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

function while(func, test, data) {
  let newData = *copy the data somehow*
  while(test(newData)) {
    newData = func(newData);
  }
  return newData;
}

однако на данный момент кажется, что я сделал свой код более сложным/запутанным для всех, кто его использует, так как мне приходится таскать пользовательскую функцию утилиты. Единственное практическое преимущество я вижу, что это заставляет меня сделать цикл чистым; но кажется, что было бы проще просто использовать регулярный цикл while и убедиться, что я держу все в чистоте.

Я также попытался выяснить способ создания функции генератора, которая имитирует эффекты рекурсии / цикла, а затем перебирает ее с помощью служебной функции, такой как find или reduce. Тем не менее, я еще не придумал читаемый способ сделать это.

наконец, замена для циклов на функции утилиты делают более очевидным то, что я пытаюсь выполнить (например, сделать что-то с каждым элементом, проверить, проходит ли каждый элемент тест и т. д.). Однако мне кажется, что цикл while уже выражает то, что я пытаюсь выполнить (например, итерация до тех пор, пока мы не найдем простое число, итерация до тех пор, пока ответ не будет достаточно оптимизирован и т. д.).

Итак, после всего этого мой общий вопрос: если мне нужен цикл while, я программирую в функциональном стиле, и у меня нет доступа к tail Call оптимизация, то, что является лучшей стратегией.

3 ответов


пример в JavaScript

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

const repeat = n => f => x =>
  n === 0 ? x : repeat (n - 1) (f) (f(x))
  
console.log(repeat(1e3) (x => x + 1) (0)) // 1000
console.log(repeat(1e5) (x => x + 1) (0)) // Error: Uncaught RangeError: Maximum call stack size exceeded

батуты

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

// trampoline
const Bounce = (f,x) => ({ isBounce: true, f, x })

const Done = x => ({ isBounce: false, x })

const trampoline = ({ isBounce, f, x }) => {
  while (isBounce)
    ({ isBounce, f, x } = f(x))
  return x
}

// our revised repeat function, now stack-safe
const repeat = n => f => x =>
  n === 0 ? Done(x) : Bounce(repeat (n - 1) (f), f(x))


// apply trampoline to the result of an ordinary call repeat
let result = trampoline(repeat(1e6) (x => x + 1) (0))

// no more stack overflow
console.log(result) // 1000000

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

// aux helper hides trampoline implementation detail
// runs about 2x as fast
const repeat = n => f => x => {
  const aux = (n, x) =>
    n === 0 ? Done(x) : Bounce(x => aux (n - 1, x), f (x))
  return trampoline (aux (n, x))
}

Clojure-style loop/recur

батуты хорошие и все, но это раздражает, чтобы иметь, чтобы беспокоиться о вызове trampoline на возвращаемое значение функции. Мы видели, что альтернативой было использовать вспомогательного помощника, но это тоже раздражает. Я уверен, что некоторые из вас не в восторге о Bounce и Done фантики тоже.

Clojure создает специализированный батут интерфейс, который использует пару функций,loop и recur - эта тандемная пара поддается удивительно элегантному выражению программы

О, и это действительно быстро, слишком

const recur = (...args) =>
  ({ type: recur, args })
  
const loop = f =>
  {
    let acc = f ()
    while (acc && acc.type === recur)
      acc = f (...acc.args)
    return acc
  }

const repeat = $n => $f => $x =>
  loop ((n = $n, f = $f, x = $x) =>
    n === 0
      ? x
      : recur (n - 1, f, f (x)))
 
console.time ('loop/recur')
console.log (repeat (1e6) (x => x + 1) (0)) // 1000000
console.timeEnd ('loop/recur')              // 24 ms

продолжение монады

это одна из моих любимых тем, поэтому мы посмотрим, как это выглядит с продолжением монады. Мы также сделаем еще один шаг вперед и спрячем детали реализации trampoline внутри наших repeat функция с помощью вспомогательной функции (aux), так что вызывающий абонент не должен беспокоиться о подпрыгивая возвращаемое значение каждый раз

// trampoline
const Bounce = (f,x) => ({ isBounce: true, f, x })

const trampoline = t => {
  while (t && t.isBounce)
    t = t.f(t.x)
  return t
}

// Continuation monad; stack-safe implementation
const Cont = f => ({
  _runCont: f,
  chain: g =>
    Cont(k =>
      Bounce(f, x =>
        Bounce(g(x)._runCont, k)))
})

Cont.of = x => Cont(k => k(x))

const runCont = (m,k) =>
  trampoline(Bounce(m._runCont, k))

// repeat now leaks no implementation detail that a trampoline is used
const repeat = n => f => x => {
  const aux = (n,x) =>
    n === 0 ? Cont.of(x) : Cont.of(f(x)).chain(x => aux(n - 1, x))
  return runCont(aux(n,x), x => x)
}

// looks like any other function, still stack-safe
console.log(repeat(1e5) (x => x + 1) (0))

Y комбинатора

комбинатор Y-мой комбинатор духа; этот ответ был бы неполным, не давая ему места среди других методов. Однако большинство реализаций y-комбинатора не являются stack-safe и переполнит, если пользовательская функция повторяется слишком много раз. Поскольку этот ответ касается сохранения безопасного поведения стека, конечно, мы реализуем Y безопасным способом – опять же, опираясь на наш надежный батут.

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

const bounce = f => (...xs) =>
  ({ isBounce: true, f, xs })

const trampoline = t => {
  while (t && t.isBounce)
    t = t.f(...t.xs)
  return t
}

// stack-safe Y combinator
const Y = f => {
  const safeY = f =>
    bounce((...xs) => f (safeY (f), ...xs))
  return (...xs) =>
    trampoline (safeY (f) (...xs))
}

// recur safely to your heart's content
const repeat = Y ((recur, n, f, x) =>
  n === 0
    ? x
    : recur (n - 1, f, f (x)))
  
console.log(repeat (1e5, x => x + 1, 0)) // 10000

практичность с while цикл

но давайте будем честными: это много церемоний, когда мы упускаем одно из наиболее очевидных потенциальных решений: используйте for или while цикл, но скрыть его за функциональным интерфейсом

для всех намерений и целей, это repeat функция работает идентично тем, которые указаны выше-за исключением этого примерно в один или два раза быстрее (за исключением loop/recur решение). Черт возьми, это, возможно, много и читать легче.

конечно, эта функция, возможно, является надуманным примером – не все рекурсивные функции могут быть преобразованы в for или while цикл так легко, но в таком сценарии, где это возможно, вероятно, лучше всего просто сделать это так. Сохраните батуты и продолжения для тяжелого подъема, когда простая петля не будет делать.

const repeat = n => f => x => {
  let m = n
  while (true) {
    if (m === 0)
      return x
    else
      (m = m - 1, x = f (x))
  }
}

const gadzillionTimes = repeat(1e8)

const add1 = x => x + 1

const result = gadzillionTimes (add1) (0)

console.log(result) // 100000000

setTimeout не является решением проблемы переполнения стека проблема

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

некоторые люди нашли интервью Q & A prep сайт, который поощряет эту ужасную стратегию

что наши repeat будет выглядеть через setTimeout – обратите внимание, что он также определен в стиле продолжения передачи - т. е. мы должны вызвать repeat С обратного вызова (k), чтобы получить конечное значение

// do NOT implement recursion using setTimeout
const repeat = n => f => x => k =>
  n === 0
    ? k (x)
    : setTimeout (x => repeat (n - 1) (f) (x) (k), 0, f (x))
    
// be patient, this one takes about 5 seconds, even for just 1000 recursions
repeat (1e3) (x => x + 1) (0) (console.log)

// comment the next line out for absolute madness
// 10,000 recursions will take ~1 MINUTE to complete
// paradoxically, direct recursion can compute this in a few milliseconds
// setTimeout is NOT a fix for the problem
// -----------------------------------------------------------------------------
// repeat (1e4) (x => x + 1) (0) (console.log)

я не могу подчеркнуть, насколько это плохо. Даже 1e5 требуется так много времени, чтобы запустить, что я отказался от попыток измерить его. Я не включаю это в критерии ниже, потому что это слишком медленный, чтобы даже считаться жизнеспособным подходом.


обещания

обещания имеют возможность цепных вычислений и являются безопасными для стека. Однако достижение stack-safe repeat использование обещаний означает, что нам придется отказаться от нашего синхронного возвращаемого значения, так же, как мы использовали setTimeout. Я предоставляю это как "решение", потому что это тут решить проблему, в отличие от setTimeout, но в пути который очень прост сравненный к батут или продолжение монады. Как вы можете себе представить, производительность несколько плоха, но далеко не так плоха, как setTimeout пример выше

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

const repeat = n => f => x => k =>
  n === 0
    ? Promise.resolve(x).then(k)
    : Promise.resolve(f(x)).then(x => repeat (n - 1) (f) (x) (k))
    
// be patient ...
repeat (1e6) (x => x + 1) (0) (x => console.log('done', x))

критерии

если серьезно, то while Loop-это много быстрее-как почти 100x быстрее (при сравнении лучшего с худшим – но не включая асинхронные ответы:setTimeout и Promise)

// sync
// -----------------------------------------------------------------------------
// repeat implemented with basic trampoline
console.time('A')
console.log(tramprepeat(1e6) (x => x + 1) (0))
console.timeEnd('A')
// 1000000
// A 114 ms

// repeat implemented with basic trampoline and aux helper
console.time('B')
console.log(auxrepeat(1e6) (x => x + 1) (0))
console.timeEnd('B')
// 1000000
// B 64 ms

// repeat implemented with cont monad
console.time('C')
console.log(contrepeat(1e6) (x => x + 1) (0))
console.timeEnd('C')
// 1000000
// C 33 ms

// repeat implemented with Y
console.time('Y')
console.log(yrepeat(1e6) (x => x + 1) (0))
console.timeEnd('Y')
// 1000000
// Y 544 ms

// repeat implemented with while loop
console.time('D')
console.log(whilerepeat(1e6) (x => x + 1) (0))
console.timeEnd('D')
// 1000000
// D 4 ms

// async
// -----------------------------------------------------------------------------

// repeat implemented with Promise
console.time('E')
promiserepeat(1e6) (x => x + 1) (0) (console.log)
console.timeEnd('E')
// 1000000
// E 2224 ms

// repeat implemented with setTimeout; FAILED
console.time('F')
timeoutrepeat(1e6) (x => x + 1) (0) (console.log)
console.timeEnd('F')
// ...
// too slow; didn't finish after 3 minutes

Каменный Век JavaScript

вышеуказанные методы демонстрируются с использованием новых синтаксисов ES6, но вы можете реализовать trampoline в самой ранней версии JavaScript – для этого требуется только while и функции первого класса

ниже мы используем javascript каменного века, чтобы продемонстрировать, что бесконечная рекурсия возможна и эффективна без обязательно жертвуя Синхронное возвращаемое значение -100,000,000 рекурсии в разделе 6 секунд - это огромная разница по сравнению с setTimeout который может только 1,000 рекурсии в столько же времени.

function trampoline (t) {
  while (t && t.isBounce)
    t = t.f (t.x);
  return t.x;
}

function bounce (f, x) {
  return { isBounce: true, f: f, x: x };
}

function done (x) {
  return { isBounce: false, x: x };
}

function repeat (n, f, x) {
  function aux (n, x) {
    if (n === 0)
      return done (x);
    else 
      return bounce (function (x) { return aux (n - 1, x); }, f (x));
  }
  return trampoline (aux (n, x));
}

console.time('JS1 100K');
console.log (repeat (1e5, function (x) { return x + 1 }, 0));
console.timeEnd('JS1 100K');
// 100000
// JS1 100K: 15ms

console.time('JS1 100M');
console.log (repeat (1e8, function (x) { return x + 1 }, 0));
console.timeEnd('JS1 100M');
// 100000000
// JS1 100K: 5999ms

неблокирующая бесконечная рекурсия с использованием JavaScript каменного века

если по какой-то причине, вы хотите неблокирующий (асинхронный) бесконечной рекурсии, мы можем положиться на setTimeout отложить один кадр в начале расчета. Эта программа также использует javascript каменного века и вычисляет 100,000,000 рекурсии менее чем за 8 секунд, но на этот раз в не-преграждая путь.

это демонстрирует, что нет ничего особенного в том, чтобы иметь неблокирующее требование. А while цикл и первоклассные функции по-прежнему являются единственным фундаментальным требованием для достижения безопасной рекурсии без ущерба для производительности

в современной программе, учитывая обещания, мы бы заменили setTimeout призыв к единому обещанию.

function donek (k, x) {
  return { isBounce: false, k: k, x: x };
}

function bouncek (f, x) {
  return { isBounce: true, f: f, x: x };
}

function trampolinek (t) {
  // setTimeout is called ONCE at the start of the computation
  // NOT once per recursion
  return setTimeout(function () {
    while (t && t.isBounce) {
      t = t.f (t.x);
    }
    return t.k (t.x);
  }, 0);
}

// stack-safe infinite recursion, non-blocking, 100,000,000 recursions in under 8 seconds
// now repeatk expects a 4th-argument callback which is called with the asynchronously computed result
function repeatk (n, f, x, k) {
  function aux (n, x) {
    if (n === 0)
      return donek (k, x);
    else
      return bouncek (function (x) { return aux (n - 1, x); }, f (x));
  }
  return trampolinek (aux (n, x));
}

console.log('non-blocking line 1')
console.time('non-blocking JS1')
repeatk (1e8, function (x) { return x + 1; }, 0, function (result) {
  console.log('non-blocking line 3', result)
  console.timeEnd('non-blocking JS1')
})
console.log('non-blocking line 2')

// non-blocking line 1
// non-blocking line 2
// [ synchronous program stops here ]
// [ below this line, asynchronous program continues ]
// non-blocking line 3 100000000
// non-blocking JS1: 7762ms

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

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

  • базовый
  • рекурсивный случай

мы должны сделать выбор, и это хорошо сочетается с тегами союзов. Однако Javascript не имеет такого типа данных, поэтому мы должны либо создать его, либо вернуться к Object кодировок.


см. также разогнуть который (из Ramda docs)

создает список из начального значения. Принимает функцию итератора, которая возвращает false для остановки итерации или массив длины 2 содержит значение для добавления в результирующий список и семя, которое должно быть используется в следующем вызове функции iterator.

var r = n => f => x => x > n ? false : [x, f(x)];
var repeatUntilGreaterThan = n => f => R.unfold(r(n)(f), 1);
console.log(repeatUntilGreaterThan(10)(x => x + 1));
<script src="https://cdnjs.cloudflare.com/ajax/libs/ramda/0.22.1/ramda.min.js"></script>