web-dev-qa-db-ja.com

whileループを、末尾呼び出しの最適化を行わない関数型プログラミングの代替手段に置き換えるにはどうすればよいですか?

JavaScriptでより機能的なスタイルを試しています。したがって、mapやreduceなどのユーティリティ関数でforループを置き換えました。ただし、末尾呼び出しの最適化は一般的にJavaScriptでは利用できないため、whileループの機能的な代替物は見つかりませんでした。 (私が理解していることから、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などのユーティリティ関数を使用して反復する方法を見つけようとしました。しかし、私はまだそれを行うための読みやすい方法を見つけていません。

最後に、forループをユーティリティ関数に置き換えると、私が何を達成しようとしているかがより明確になります(たとえば、各要素に何かをする、各要素がテストに合格するかどうかを確認するなど)。ただし、whileループは、私が達成しようとしていることを既に表現しているようです(たとえば、素数が見つかるまで繰り返す、答えが十分に最適化されるまで繰り返すなど)。

結局のところ、私の全体的な質問は次のとおりです。whileループが必要な場合、機能的なスタイルでプログラミングしているので、テールコールの最適化にアクセスできません。

36

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

トランポリン

繰り返しの書き方を変更することで、この制限を回避できますが、ほんの少しだけです。値を直接またはすぐに繰り返すのではなく、2つのトランポリンタイプ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

Currying は物事を少し遅くしますが、再帰に補助関数を使用することで改善できます。これは、トランポリンの実装の詳細を隠し、呼び出し元が戻り値をバウンスすることを期待しないため、これも素晴らしいことです。これは上記のrepeatの約2倍の速度で実行されます

// 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を呼び出すことを心配する必要があるのはちょっと面倒です。代替ヘルパーは補助ヘルパーを使用することでしたが、それはちょっと面倒なことでもあります。ラッパーのBounceDoneについても熱心ではない人もいると思います。

Clojureはloopおよびrecurの関数のペアを使用する特殊なトランポリンインターフェイスを作成します。このタンデムペアは、プログラムの非常にエレガントな表現に役立ちます。

ああ、それも本当に速いです

const recur = (...values) =>
  ({ recur, values })
  
const loop = run =>
{ let r = run ()
  while (r && r.recur === recur)
    r = run (...r.values)
  return r
}

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

最初はこのスタイルは異質に感じますが、時間が経つにつれて、耐久性のあるプログラムを作成している間、それが最も一貫していると感じています。以下のコメントを使用すると、式が豊富な構文を簡単に理解できます-

const repeat = n => f => x =>
  loop  // begin a loop with
    ( ( m = n   // local loop var m: counter, init with n
      , r = x   // local loop var r: result, init with x
      ) =>
        m === 0 // terminating condition
          ? r   // return result
          : recur    // otherwise recur with 
             ( m - 1 // next m value
             , f (r) // next r value
             )
    )

継続モナド

これは私のお気に入りのトピックの1つなので、継続モナドでこれがどのようになるかを見ていきます。 looprecurを再利用して、contを使用して操作をシーケンスし、chainを使用して操作シーケンスを実行できるスタックセーフrunContを実装します。 repeatの場合、これは無意味(および低速)ですが、この単純な例でcontの仕組みが動作しているのを見るのはクールです-

const identity = x =>
  x

const recur = (...values) =>
  ({ recur, values })
  
const loop = run =>
{ let r = run ()
  while (r && r.recur === recur)
    r = run (...r.values)
  return r
}

// cont : 'a -> 'a cont
const cont = x =>
  k => recur (k, x)

// chain : ('a -> 'b cont) -> 'a cont -> 'b cont
const chain = f => mx =>
  k => recur (mx, x => recur (f (x), k))

// runCont : ('a -> 'b) -> a cont -> 'b
const runCont = f => mx =>
  loop ((r = mx, k = f) => r (k))

const repeat = n => f => x =>
{ const aux = n => x =>
    n === 0 // terminating condition
      ? cont (x) // base case, continue with x
      : chain             // otherise
          (aux (n - 1))   // sequence next operation on
          (cont (f (x)))  // continuation of f(x)

  return runCont  // run continuation
    (identity)    // identity; pass-thru
    (aux (n) (x)) // the continuation returned by aux
}

console.time ('cont monad')
console.log (repeat (1e6) (x => x + 1) (0)) // 1000000
console.timeEnd ('cont monad')              // 451 ms

Yコンビネータ

Y Combinatorは私のスピリットコンビネーターです。この答えは、他の手法の中で何らかの場所を与えることなく不完全です。ただし、Y Combinatorのほとんどの実装はスタックセーフではなく、ユーザーが指定した関数が何度も繰り返されるとオーバーフローします。この答えはスタックセーフ動作を維持することに関するものなので、もちろん、信頼できるトランポリンに依存して、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ループの実用性

しかし、正直に言ってください。それは、明らかな潜在的なソリューションの1つを見落とすときの多くのセレモニーです。forまたはwhileループを使用しますが、機能的なインターフェースの背後に隠します

すべての意図と目的のために、このrepeat関数は上記のものと同じように機能します。ただし、この関数は約1から2兆倍高速です(例外は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はスタックオーバーフロー問題の解決策ではありません

OK、それでdoesは機能するが、逆説的にしか機能しない。データセットが小さい場合、スタックオーバーフローが発生しないため、setTimeoutは不要です。データセットが大きく、setTimeoutを安全な再帰メカニズムとして使用している場合、関数から値を同期的に返すことができなくなるだけでなく、Fが遅くなるため、関数

一部の人々は、インタビューのQ&A準備サイトを見つけました この恐ろしい戦略を奨励します

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は実行に非常に時間がかかるため、測定しようとしてあきらめました。これは実行可能なアプローチと見なすには遅すぎるので、以下のベンチマークにはこれを含めません。


約束

Promiseには計算を連鎖する機能があり、スタックセーフです。ただし、Promisesを使用してスタックセーフrepeatを実現するには、setTimeoutを使用した場合と同じように、同期戻り値を放棄する必要があります。 setTimeoutとは異なり、問題をdoesで解決するため、これを「ソリューション」として提供していますが、トランポリンまたは継続モナドに比べて非常に簡単です。ご想像のとおり、パフォーマンスはやや悪いですが、上記のsetTimeoutの例ほど悪くはありません

このソリューションで注目に値するのは、Promise実装の詳細が呼び出し元から完全に隠されていることです。単一の継続が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ループはlotより高速です-ほぼ100倍高速です(最高と最低を比較する場合-非同期応答を含まない場合: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

Stone Age JavaScript

上記の手法は新しいES6構文を使用して実証されていますが、トランポリンはJavaScriptの可能な限り早いバージョンで実装できます。必要なのはwhileおよびファーストクラス関数のみです

以下では、石器時代のjavascriptを使用して、無限の再帰が可能であり、necessarily同期戻り値を犠牲にすることなくパフォーマンスを発揮することを示します– 100,000,0での再帰6 =秒-これは、同じ時間内で1,0再帰しかできないsetTimeoutと比較して劇的な違いです。

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を使用した非ブロッキング無限再帰

If、何らかの理由で、非ブロッキング(非同期)無限再帰が必要な場合、setTimeoutに依存してsingle frameを延期できます計算の開始。また、このプログラムは石器時代のjavascriptを使用し、8秒未満で100,000,000回の再帰を計算しますが、今回はノンブロッキング方式です。

これは、非ブロッキング要件を持つことについて特別なものはないことを示しています。 whileループとファーストクラス関数は、パフォーマンスを犠牲にすることなくスタックセーフな再帰を達成するための唯一の基本的な要件です。

Promiseを指定した最新のプログラムでは、単一のPromiseを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
68
user633183

関数型パラダイムという意味でのプログラミングとは、アルゴリズムを表現するために型に導かれることを意味します。

末尾再帰関数をスタックセーフバージョンに変換するには、2つのケースを考慮する必要があります。

  • 規範事例
  • 再帰的な場合

私たちは選択をしなければなりませんが、これはタグ付けされたユニオンでもうまくいきます。ただし、Javascriptにはそのようなデータ型がないため、1つ作成するか、Objectエンコーディングにフォールバックする必要があります。

エンコードされたオブジェクト

// simulate a tagged union with two Object types

const Loop = x =>
  ({value: x, done: false});
  
const Done = x =>
  ({value: x, done: true});

// trampoline

const tailRec = f => (...args) => {
  let step = Loop(args);

  do {
    step = f(Loop, Done, step.value);
  } while (!step.done);

  return step.value;
};

// stack-safe function

const repeat = n => f => x =>
  tailRec((Loop, Done, [m, y]) => m === 0
    ? Done(y)
    : Loop([m - 1, f(y)])) (n, x);

// run...

const inc = n =>
  n + 1;

console.time();
console.log(repeat(1e6) (inc) (0));
console.timeEnd();

エンコードされた関数

または、関数エンコーディングを使用して実際のタグ付きユニオンを作成できます。今、私たちのスタイルは成熟した関数型言語にはるかに近いものになっています。

// type/data constructor

const Type = Tcons => (tag, Dcons) => {
  const t = new Tcons();
  t.run = cases => Dcons(cases);
  t.tag = tag;
  return t;
};

// tagged union specific for the case

const Step = Type(function Step() {});

const Done = x =>
  Step("Done", cases => cases.Done(x));
  
const Loop = args =>
  Step("Loop", cases => cases.Loop(args));

// trampoline

const tailRec = f => (...args) => {
  let step = Loop(args);

  do {
    step = f(step);
  } while (step.tag === "Loop");

  return step.run({Done: id});
};

// stack-safe function

const repeat = n => f => x => 
  tailRec(step => step.run({
    Loop: ([m, y]) => m === 0 ? Done(y) : Loop([m - 1, f(y)]),
    Done: y => Done(y)
  })) (n, x);

// run...

const inc = n => n + 1;
const id = x => x;

console.log(repeat(1e6) (inc) (0));
2
user6445533

nfold which(Ramda docsから)も参照してください

シード値からリストを作成します。イテレータ関数を受け入れます。イテレータ関数は、反復を停止するfalseまたは結果のリストに追加する値と、イテレータ関数の次の呼び出しで使用されるシードを含む長さ2の配列を返します。

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>
0
gpilotino

私はこの質問についてよく考えてきました。最近、機能的なwhileループの必要性に出会いました。

この質問が本当に望んでいるのは、whileループをインライン化する方法だけであるように思えます。 ISクロージャーを使用してそれを行う方法。

"some string "+(a=>{
   while(comparison){
      // run code
   }
   return result;
})(somearray)+" some more"

あるいは、必要なものが配列から連鎖している場合は、reduceメソッドを使用できます。

somearray.reduce((r,o,i,a)=>{
   while(comparison){
      // run code
   }
   a.splice(1); // This would ensure only one call.
   return result;
},[])+" some more"

これらのどれも、実際にはコアでのwhileループを関数に変えません。ただし、インラインループを使用できます。そして、これを助けてくれる人と共有したいだけです。

0
bronkula