Чем можно заменить циклы в с
“Кто ни разу не ошибался в индексировании цикла, пусть первый бросит в деструкторе исключение.”
— Древняя мудрость
Циклы ужасны. Циклы сложно читать — вместо того, чтобы сразу понять намерение автора, приходится сначала вникать в код, чтобы понять, что именно он делает. В цикле легко ошибиться с индексированием и переопределить индекс цикла во вложенном цикле. Циклы сложно поддерживать, исправлять в случае ошибок, сложно вносить текущие изменения, и т.д. и т.п.
В конце концов, это просто некрасиво.
Человечество издревле пытается упростить написание циклов. Вначале программисты подметили часто повторяющиеся циклы и выделили их в отдельные функции. Затем они придумали ленивые итераторы, а потом и диапазоны. И каждая из этих идей была прорывом. Но, несмотря на это, идеал до сих пор не достигнут, и люди продолжают искать способы улучшить свой код.
Данная работа ставит своей целью пролить свет на отнюдь не новую, но пока что не слишком распространённую идею, которая вполне способна произвести очередной прорыв в области написания программ на языке C++.
Так как же писать красивый, понятный, эффективный код, а также иметь возможность параллелить большие вычисления лёгким движением пальцев по клавиатуре?
Содержание
- Существующие модели
- Базовые понятия
- Определение №1: свёртка
- Определение №2: ядро свёртки
- Идеология
- Факт №1: каждый цикл можно представить в виде свёртки
- Факт №2: большинство циклов расладываются на простые составляющие
- Факт №3: каждую свёртку можно представить в виде автомата
- Факт №4: автоматы комбинируются
- Снова к свёртке
- Я птичка, мне такое сложно, можно я сразу код посмотрю?
- Простой пример
- constexpr
- Многопоточность
- Сравнительная таблица
- Ссылки
Существующие модели
Основные на текущий момент способы избавления от циклов — это алгоритмы из стандартной библиотеки и ленивые итераторы и диапазоны из библиотек Boost.Iterator, Boost.Range и range-v3.
range-v3 частично попали в стандартную библиотеку C++20, но, во-первых, попали они туда в достаточно усечённом виде, а во-вторых, соответствующих реализаций на текущий момент пока нет.
Стандартные алгоритмы прекрасны, и помогают избаваться от циклов, но, к сожалению, только в самых простых случаях, посколько несколько таких алгоритмов невозможно скомбинировать в единое вычисление. Для каждого шага придётся хранить промежуточный результат. А это и перерасход по памяти, и сложности с выводом типов для промежуточных результатов, то есть усложнение кода.
Именно из-за этого появились ленивые итераторы и диапазоны в сторонних библиотеках, а в C++17 появились гибриды семейства std::transform_reduce.
Ленивые итераторы и диапазоны решают многие проблемы. Но они сами не лишены своих собственных проблем. В частности, поскольку они отделены от схемы вычислений (они определяют только операции над отдельными элементами последовательности), их сложно параллелить. А стандартные алгоритмы уже с C++17 имеют параллельные версии, способные более эффективно использовать многоядерные архитектуры.
Возникает вопрос: можно ли объединить преимущества обоих подходов одновременно? Оказывается, можно. Об этом и пойдёт речь далее.
Базовые понятия
Для того, чтобы двинуться далее, необходимо разобраться с тем, что такое свёртка.
Определение №1: свёртка
Свёртка — это вычислительный процесс, производимый над некоторой последовательностью значений по правилу, задаваемому ядром свёртки.
Результат свёртки — значение, полученное последовательным применением ядра свёртки к текущему значению и очередному элементу последовательности.
Определение №2: ядро свёртки
Ядро свёртки — это действие, производимое на каждом шаге свёртки. Применяется к текущему значению свёртки и очередному элементу последовательности.
На этом рисунке изображена свёртка последовательности с ядром и начальным значением . — результат свёртки.
В стандартной библиотеке свёртка представлена алгоритмами std::accumulate и std::reduce.
Идеология
Итак, для того, чтобы понять основную идею данного подхода, нужно обратить внимание на несколько известных фактов.
Факт №1: каждый цикл можно представить в виде свёртки
И действительно:
- Контекст программы перед началом цикла — начальное значение;
- Набор индексов, контейнер, диапазон и т.п. — последовательность элементов;
- Итерация цикла — применение двуместной операции (ядра свёртки) к текущему значению и очередному элементу последовательности, в результате чего текущее значение изменяется.
auto v = 0; // Начальное значение: v_0
for (auto i = 0; i < 10; ++i) // Последовательность: [x_0, x_1, …]
{
v = f(v, i); // Двуместная операция, изменяющая
// значение: v_{i + 1} = f(v_i, x_i)
}
Иначе говоря, для того, чтобы выразить любой цикл, достаточно базиса из одной единственной операции — свёртки. А все остальные операции — например, стандартные алгоритмы, — можно выразить через неё.
Пример №1: отображение через свёртку
template <ForwardIterator I, OutputIterator J, UnaryFunction F>
J transform (I begin, I end, J result, F f)
{
// Начальное значение — это выходной итератор.
auto initial_value = result;
// Ядро свёртки.
auto binary_op =
[f] (auto iterator, auto next_element)
{
// Записываем в текущий итератор результат отображения…
*iterator = f(next_element);
// … и возвращаем продвинутый итератор.
return ++iterator;
};
// Свёртка.
return accumulate(begin, end, initial_value, binary_op);
}
Пример №2: фильтрация через свёртку
template <ForwardIterator I, OutputIterator J, UnaryPredicate P>
J copy_if (I begin, I end, J result, P p)
{
// Начальное значение.
auto initial_value = result;
// Ядро свёртки.
auto binary_op =
[p] (auto iterator, auto next_element)
{
if (p(next_element))
{
*iterator = next_element;
++iterator;
}
return iterator;
};
// Свёртка.
return accumulate(begin, end, initial_value, binary_op);
}
Аналогичным образом выражаются и все остальные последовательные алгоритмы. Любознательный читатель может проделать это в качестве упражнения.
Факт №2: большинство циклов расладываются на простые составляющие
Если присмотреться, то станет понятно, что большинство циклов — типовые. Они раскладываются на простые составляющие:
- Преобразование;
- Фильтрация;
- Группировка;
- Подсчёт;
- Суммирование;
- Запись в массив;
- …
- и т.д.
Это значит, что нужно подобрать достаточно выразительный базис операций, чтобы их комбинациями покрыть подавляющее большинство возможных циклов, а также научиться легко и удобно составлять эти комбинации с точки зрения программного кода.
Факт №3: каждую свёртку можно представить в виде автомата
По определению, автомат — это система, которая может пребывать в различных состояниях, а переход между этими состояниями происходит при произведении на систему определённого воздействия.
Так, если рассматривать свёртку как автомат, то состояния этого автомата — это совокупность возможных значений переменной, а воздействие — это применение ядра свёртки к текущему значению переменной и очередному элементу последовательности.
Важно:
В данной модели рассматривается обобщение автоматов, когда есть не только входные символы, под действием которых происходит переход между состояниями, но и выходные символы, сопутствующие этому переходу.
На диаграмме входной символ рисуется над стрелками переходов, а выходной — под стрелкой.Кроме того, наш автомат может обладать памятью.
Пример №1: автомат для отображения
Например, так будет выглядеть автомат для отображения (transform, или map в функциональном программировании).
Здесь — входной символ, — функция преобразования.
Данный автомат имеет одно состояние и один переход. Каждый входной символ он преобразует с помощью функции , и результат этого преобразования подаёт на выход. После этого возвращается в исходное состояние.
Пример №2: автомат для фильтрации
Здесь — входной символ, — предикат, — обозначение пустого символа.
Данный автомат имеет одно состояние и два перехода. Один переход реализуется тогда, когда входной символ удовлетворяет предикату . В этом случае на выход подаётся сам символ . В случае, если символ не удовлетворяет предикату, на выход подаётся пустой символ (то есть ничего не подаётся). В обоих случаях автомат возвращается в исходное состояние.
Факт №4: автоматы комбинируются
Если у автомата есть выход, то, очевидно, этот выход можно подать на вход другому автомату.
Таким образом, имея набор из нескольких автоматов, каждый из которых задаёт одну операцию преобразования, можно составлять достаточно сложные преобразования.
Снова к свёртке
Чтобы получить нужную нам свёртку, в конец цепочки мы поставим автомат, который представляет собой ядро свёртки.
Далее заметим, что все автоматы, кроме последнего, как бы подготавливают данные для него, поэтому можно мысленно схлопнуть все автоматы в последний. Получим ядро свёртки. А это и есть тело цикла, который мы и хотели записать.
Итак, мы разложили цикл на простые составляющие и представили с помощью свёртки. В теории всё прекрасно, но как же это будет выглядеть в коде?
Код
На основе изложенных выше идей разработана библиотека Проксима.
Простой пример
#include <proxima/compose.hpp>
#include <proxima/kernel/sum.hpp>
#include <proxima/reduce.hpp>
#include <proxima/transducer/stride.hpp>
#include <proxima/transducer/take_while.hpp>
#include <proxima/transducer/transform.hpp>
#include <cassert>
int main ()
{
const int items[] = {1, 2, 3, 4, 5};
const auto kernel =
proxima::compose
(
proxima::transform([] (auto x) {return x * x;}), // 1. Каждый элемент возведён в квадрат;
proxima::stride(2), // 2. Берутся только элементы с номерами,
// кратными двойке (нумерация с нуля);
proxima::take_while([] (auto x) {return x < 10;}), // 3. Элементы берутся до тех пор, пока
// они меньше десяти;
proxima::sum // 4. Результат суммируется.
);
const auto x = proxima::reduce(items, kernel);
assert(x == 10); // 1 * 1 + 3 * 3
}
constexpr
Можно отметить, что код из примера может быть выполнен на этапе компиляции:
#include <proxima/compose.hpp>
#include <proxima/kernel/sum.hpp>
#include <proxima/reduce.hpp>
#include <proxima/transducer/stride.hpp>
#include <proxima/transducer/take_while.hpp>
#include <proxima/transducer/transform.hpp>
int main ()
{
constexpr int items[] = {1, 2, 3, 4, 5};
constexpr auto kernel =
proxima::compose
(
proxima::transform([] (auto x) {return x * x;}), // 1. Каждый элемент возведён в квадрат;
proxima::stride(2), // 2. Берутся только элементы с номерами,
// кратными двойке (нумерация с нуля);
proxima::take_while([] (auto x) {return x < 10;}), // 3. Элементы берутся до тех пор, пока
// они меньше десяти;
proxima::sum // 4. Результат суммируется.
);
constexpr auto x = proxima::reduce(items, kernel);
static_assert(x == 10); // 1 * 1 + 3 * 3
}
Большая часть Проксимы может быть выполнена на этапе компиляции.
Многопоточность
Одна из ключевых особенностей описываемой модели состоит в том, что она легко поддаётся параллелизации.
В Проксиме существует механизм, с помощью которого очень легко распараллеливать вычисления. Это делается с помощью фиктивного преобразователя pipe, который выполняет роль “разделителя потоков”:
proxima::reduce(values,
proxima::compose
(
proxima::for_each(hard_work), // | Поток №1
// ———-
proxima::pipe, // Разделитель потоков
// ———-
proxima::for_each(hard_work), // | Поток №2
// ———-
proxima::pipe, // Разделитель потоков
// ———-
proxima::for_each(hard_work), // | Поток №3
proxima::sum // | Поток №3
));
Запись выше означает, что будут созданы три потока, и в каждом из них будет выполняться только часть работы над очередным элементом последовательности.
Чтобы показать эффективность такого разбиения, рассмотрим пример (полный код лежит на Гитлабе).
В нём будем замерять разницу между распараллеленной в три потока свёрткой, обычной свёрткой и простым циклом. Для имитации “тяжёлых” вычислений сделаем функцию, которая просто засыпает на несколько микросекунд. И сгенерируем набор случайных чисел, которые и будут определять время засыпания.
auto hard_work (std::int32_t time_to_sleep)
{
std::this_thread::sleep_for(std::chrono::microseconds(time_to_sleep));
}
const auto proxima_crunch_parallel =
[] (auto b, auto e)
{
return
proxima::reduce(b, e,
proxima::compose
(
proxima::for_each(hard_work),
proxima::pipe,
proxima::for_each(hard_work),
proxima::pipe,
proxima::for_each(hard_work),
proxima::sum
));
};
const auto proxima_crunch =
[] (auto b, auto e)
{
return
proxima::reduce(b, e,
proxima::compose
(
proxima::for_each(hard_work),
proxima::for_each(hard_work),
proxima::for_each(hard_work),
proxima::sum
));
};
const auto loop_crunch =
[] (auto b, auto e)
{
auto sum = typename decltype(b)::value_type{0};
while (b != e)
{
hard_work(*b);
hard_work(*b);
hard_work(*b);
sum += *b;
++b;
}
return sum;
};
Если сгенерировать 1000 случайных засыпаний в диапазоне от 10 до 20 микросекунд, то получим следующую картину (показано время работы соответствующего обработчика — чем меньше, тем лучше):
proxima_crunch_parallel | 0.0403945
proxima_crunch | 0.100419
loop_crunch | 0.103092
И чем “жирнее” будут вычислительные функции, тем больше будет отрыв многопоточной версии. Например, если взять случайные засыпания в диапазоне от 100 до 200 микросекунд, то картина будет следующей:
proxima_crunch_parallel | 0.213352
proxima_crunch | 0.624727
loop_crunch | 0.625393
То есть почти в три раза быстрее, как было бы при идеальном разложении на три потока.
Сравнительная таблица
*) Параллелизация в STL ещё не везде реализована.
**) constexpr диапазонов, видимо, будет лучше, когда они попадут в STL.
***) constexpr Проксимы зависит от STL. Всё, что своё — уже constexpr. Всё, что зависит от STL, будет constexpr как только в STL оно будет таковым.
Ссылки
- Проксима
- Алгоритмы STL
- atria::xform
- Boost.Iterator
- Boost.Range
- range-v3
- Абстрактный автомат
Источник
+VANO+ | как заменить вложенные циклы на с++ 18.12.2010, 16:12 |
есть код: | |
arseniiv | Re: как заменить вложенные циклы на с++ 18.12.2010, 16:51 |
Насколько я понимаю, надо взять массив, который будет изображать все переменные-итераторы, и ходить по нему нехитрым способом, проверяя значения его элементов на конечность, увеличивая их и присваивая начальные значения когда следует. Раз «ходить» — значит, нужен индекс активного (в котором мы изменяем переменную) цикла. А ещё, естественно, надо не забывать выполнять тело цикла. Попробуйте теперь. — Сб дек 18, 2010 22:52:37 — Кстати, зачем вам такое? Вдруг можно упростить. — Сб дек 18, 2010 22:55:08 — // условие1 Это означает, что там какой-то if стоит или что? P. S. В следующий раз используйте [syntax]: topic26708.html (Тссс, если будут спрашивать, я ничего такого не говорил.) | |
+VANO+ | Re: как заменить вложенные циклы на с++ 18.12.2010, 18:31 |
// условие1 Это означает, что там какой-то if стоит или что? но if никакого отношения к следующему фору не имеет. | |
arseniiv | Re: как заменить вложенные циклы на с++ 18.12.2010, 18:39 |
В принципе, в любом случае это бы не помешало. Если бы имел, можно было бы не выполнять много циклов, а в этом случае, если все if ы имеют похожий вид (а точнее, могут быть засунуты в цикл по номеру цикла), с ними всё легко, они впишутся в систему. Будут выполняться при изменении «своей» итерационной переменной. Вы когда пробовать-то совет будете? Если что-то не выйдет, покажу более подробно. Хотя не исключено, что есть лучшее решение, чем моё. Какое-нибудь неочевидное как быстрое перемножение матриц. | |
Circiter | Re: как заменить вложенные циклы на с++ 18.12.2010, 19:41 |
2+VANO+ Цитата: Чем можно всё это заменить? Фактически, каждая итерация такой конструкции эквивалентна прибавлению единице к -значному числу в -чной системе счисления. Цифры этого числа дают нужный вам набор i , i1 , i2 , … Единица прибавляется также как и на бумажке. — Вс дек 19, 2010 01:44:56 — Также можно смотреть на все возможные наборы счетчиков как на элементы декартовой степени . Я тоже когда-то искал что-нибудь стандарное из STL для работы с декартовыми произведениями, но ничего не нашел… В любом случае погуглите еще разок по словам “cartesian product in c++”, может быть уже кто-нибудь придумал решение. | |
arseniiv | Re: как заменить вложенные циклы на с++ 18.12.2010, 19:51 |
(2 Criticer) Фактически, каждая итерация такой конструкции эквивалентна прибавлению единице к -значному числу в -чной системе счисления. Цифры этого числа дают нужный вам набор i, i1, i2, … А так раскладывать по цифрам каждый раз не будет дольше? А ещё это всё может не влезть и в Int64. | |
Circiter | Re: как заменить вложенные циклы на с++ 18.12.2010, 19:58 |
(2asreiniv) Я там добавил про бумажку. Необязательно использовать нативные числа, можно просто вектор (вы уже предложили алгоритм в начале темы). | |
arseniiv | Re: как заменить вложенные циклы на с++ 18.12.2010, 20:20 |
(Оффтоп) | |
Circiter | Re: как заменить вложенные циклы на с++ 18.12.2010, 22:15 |
2+VANO+ Цитата: Если рекурсией, то как? Ну а это вообще элементарно. Просто оставляете один цикл, итерирующий счетчик, соответствующий уровню рекурсивной вложенности, а все остальные штук циклов заменяете единственным вызовом самого себя (если остались свободные счетчики). Т.е. что-то вроде: int i[k]; void f(int Level) f(Level+1) // Recursive call. f(0); Вышеописанное прибавление единицы может выглядеть так (нерекурсивное инкрементное решение): int i[k]; // Initialize to zeroes. bool Next() return false; Эта функция генерирует следующую комбинацию значений счетчиков и возвращает истину, если это новая комбинация. Т.е., использовать её надо примерно так: do /* Innermost logic. */ while(Next()); . Но здесь надо ещё подумать, куда же впендюрить ваши условия… Можно ещё попробовать метапрограммирование от boost, но там используется препроцессор для генерации вложенных циклов. | |
arseniiv | Re: как заменить вложенные циклы на с++ 19.12.2010, 09:08 |
куда же впендюрить ваши условия… Тогда можно сделать функцию с условиями, принимающую номер цикла, и передавать её в Next() (?) | |
Circiter | Re: как заменить вложенные циклы на с++ 19.12.2010, 11:04 |
Все верно, но проблема-то не в том, как передать функцию, а где и когда её вызвать. 🙂 Код-то должен получиться эквивалентным решению с вложенными циклами. Но я думаю, что топикстартер разберется. | |
+VANO+ | Re: как заменить вложенные циклы на с++ 20.12.2010, 18:17 |
Спасибо всем. Во всём разобрался, всё работает | |
Страница 1 из 1 | [ Сообщений: 12 ] |
Источник
пример в 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
Источник