読者です 読者をやめる 読者になる 読者になる

無駄と文化

実用的ブログ

【今日のバグ取り】 JavaScript でコールスタックが溢れていたのをどうにかした話

lazex さまのはてブコメントを受けて、animation プロパティを使った改良版を書きました。完全にこっちの方が良いので、参考にするならばどちらかというと新実装の方で。
lazex さま、ご指摘ありがとうございます。
JavaScript のコールスタックが溢れていたのをどうにかしたら JS 要らなくなった話


f:id:todays_mitsui:20160626123718p:plain


先日、とあるサイトを見ていたら JavaScript でエラーが出ているっぽいのを見つけました。

Chromeで見たときのエラーの内容はこんな感じ、

jquery.min.js:2 Uncaught RangeError: Maximum call stack size exceeded

どうやらコールスタックがいっぱいになって溢れているようですね。


スタックトレースを辿っていくと、以下の箇所がエラーの原因のようでした。

$(document).ready(function() {
  setAutoColorChange();
});
 
function setAutoColorChange(index) {
  var colorlist = ["#346caa", "#3386c8", "#4aa45d", "#8fb84f", "#debc1c", "#e09532", "#eb7889"];
  if (!index || index > (colorlist.length - 1)) {
    index = 0;
  }
  var color = colorlist[index];
  $('.top').animate({
    backgroundColor: color
  }, 5000);
  setAutoColorChange(++index);
}

この処理では「とある要素の背景色をゆっくりと変化させ続ける」というようなことをやっています。
5秒に一度、背景色を変化させるアニメーションを呼び出し続けることで、背景色を変化させ続ける演出をしているようです。


このコード、やりたいことはすごくわかるんです。
ロジックとしてもそこまで破綻していないと思います。

一方で、このコードがエラーを引き起こす理由もパッと見えてきます。
おそらく、JavaScript 以外の言語での経験が長い人のコードなのではないかと思います。

というわけで今回はこのコードの解説をしつつ、JavaScript らしく書き直していきたいと思います。


エラーの解説 - コールスタックとは何か

JavaScript のプログラム中で関数が実行されると、関数が実行された場所やそのときの状況などとの情報が コールスタック と呼ばれるメモリ領域に積まれます。

エラーが出ているコード中では setAutoColorChange() 関数の最後の部分で、再び自分自身である setAutoColorChange() を呼び出しています。

function setAutoColorChange(index) {
  /* ... 中略 ... */

  setAutoColorChange(++index); // <- 再び自分自身を呼び出す
}

これによってループを実現しているのですが、そのせいで setAutoColorChange() の処理が1周するたびにコールスタックが1段ずつ積まれてしまいます。

setAutoColorChange() が自分自身を呼ばずに関数内での計算が終わることは無いためコールスタックが無限に積まれ続けて、そのうちに「これ以上スタックを積むことが出来ない!」という点に達してエラーを引き起こしている訳です。


関数の中で自分自身を呼び出すことを再帰呼び出しといいます。

再帰呼び出しそのものがエラーの原因という訳ではありません。
無限に再帰呼び出しし続けてしまい、その結果コールスタックが積まれ続けてしまうことがエラーの原因なのです。


しかし、矛盾するようですが、このコードが正常に動く場合もあるだろうと思ったりします。
JavaScript以外の言語で同様の処理を実装すれば正常に動いてくれる場合もあります。

なぜ、JavaScriptではうまくいかないのか。それを理解するためには JavaScript のいくつかの特徴について知らなければいけません。


JavaScriptの特徴 1 - JavaScriptに「末尾再帰最適化」は無い

今回のコードのように関数の最後の行で自分自身を呼び出すようなパターンを 末尾再帰 といいます。
リスト構造や木構造、数列などのいくらでも長くなっていく可能性のあるデータを順々に辿って処理していくときによく使われる結構ポピュラーなパターンです。

ゆえに末尾再帰に対して 末尾再帰最適化 と呼ばれる最適化が施される言語もあります。
ざっくり言うと「再帰呼び出しが関数の最終行だったときには、コールスタックに積まずに済ませる」という処理です。

この末尾再帰最適化が効いていれば、最終行での再帰呼び出しを無限に繰り返してもコールスタックが溢れることはありません。
なんせ末尾再帰であればスタックに積まれない訳ですからね。


が、しかし、

JavaScript に末尾再帰最適化はありません。


では、どうすればいいかというと「無限に再帰するような処理は避けて、whileループなどを使いましょう」というのが一つの答えです。


JavaScriptの特徴 2 - JavaScript の処理は非同期に進む

元のコードにはjQueryを利用した5秒間のアニメーションが含まれています。

  $('.top').animate({
    backgroundColor: color
  }, 5000);

この部分です。
再帰呼び出しはこの後の行なので、感覚的には「5秒かけてアニメーションしてから、自分自身を呼び出して繰り返す」という処理に見えます。

が、実際に実行してみると、
無限の再帰呼び出しでコールスタックが溢れるまでにかかる時間は一瞬です。
1周するのに少なくとも5秒はかかりそうなループなのに、これはどういうことでしょうか。


これは JavaScript の 非同期 を基本とした処理に起因するものです。

多くのプログラミング言語では途中に時間のかかる処理が現れたら、その処理の完了を待ってから続きを再開します。あえてネガティブに言うと、時間のかかる処理が完了するまでプログラム全体がブロックされるのです。

しかし、JavaScript はそこらの言語とは違い、非同期処理が基本です。 途中に時間のかかる処理が現れても、完了を待つことなどせずどんどん進めます。

結果、光の速さで再帰呼び出しが掛かります。一瞬のうちに大量のスタックが積まれ、最終的に溢れます。


よくある解決策は callback関数 を渡すことです。.animate() メソッドの終了を待つ代わりに、「アニメーションが終わったタイミングでこの関数を実行しておいて」という感じで関数を渡します。

  $('.top').animate({
    backgroundColor: color
  }, 5000);
  setAutoColorChange(++index);

これを、

  $('.top').animate({
    backgroundColor: color
  }, 5000, function() {
    setAutoColorChange(++index);
  });

このように変えます。

これにて再帰呼び出しで setAutoColorChange() が呼び出されるのは少なくとも5秒に一度になりました。
コールスタックは16000段くらいなら積まれても溢れないようなので、この実装であれば80000秒間 = 約22時間はスタックが溢れずに持ちこたえられそうです。


書き直す

これまでの事を踏まえると、①末尾再帰最適化が効いて、②同期的に処理が進む言語であれば、そもそもエラーにならずに済みそうな気がしました。
が、しかし、JavaScriptはそうではないのです。無いものねだりしていても仕方ありません。

書き直しましょう。


というわけで書き直したデモとソースコードがこちらにあります。

github.com


大事な部分だけ抜き出すとこんなコードになっています。

まずは CSS と、

/* 背景色をゆっくりと変化させるために transiton を5sで設定する */
body { transition: background-color 5s linear; }

/* 切り替えの基点になる色を設定 */
body.color0 { background-color: #346caa; }
body.color1 { background-color: #3386c8; }
body.color2 { background-color: #4aa45d; }
body.color3 { background-color: #8fb84f; }
body.color4 { background-color: #debc1c; }
body.color5 { background-color: #e09532; }
body.color6 { background-color: #eb7889; }

JavaScript はこんな感じに、

// 背景色を設定したクラスを切り替えるためのクロージャを生成
// 現在設定されている色は index で保持する、
function initAutoColorChange($el, colorCount) {
  var index = 0;

  return function() {
    index = (index + 1) % colorCount;

    // "color*"にマッチするクラス名を全て削除
    // 今回の組み方では色数が10色以上になった場合に対応していない
    $el.removeClass(function(i, classNames) {
      return classNames.split(' ').map(function(className) {
        return className.match(/color\d+/);
      }).filter(Boolean).join(' ');
    });

    // 次のクラス名を付与
    $el.addClass('color'+index);
  }
}

jQuery(function($) {
  // 切り替え関数を取得
  // 対象は<body>、色数は7色
  var autoColorChange = initAutoColorChange($('body'), 7);

  autoColorChange()
  setInterval(autoColorChange, 5000); // 5000ms 毎に切り替えされるよう設定
});


変更点 1 - 再帰を辞めて setInterval

一定時間毎に関数を実行する方法として、JavaScript では setInterval() というメソッドが用意されています。
月並みですが、ループは setInterval() を使いましょう。

setInterval(autoColorChange, 5000);

このように。


変更点 2 - アニメーションは CSS transition に任せる

確かに jQuery の .animate() メソッドは便利なんですが、今回のように単純に背景色を変えるだけなら CSS アニメーションで充分だと感じます。

今回は <body> の背景色を5秒かけて変化させたいので、その旨を CSS に記述しています。

body { transition: background-color 5s linear; }


変更点 3 - 現在の色番号の保持はクロージャでやる

今回はあらかじめ7つの色を決めておいて、それをアニメーションさせながらぐるぐると変化させています。
そのために、処理の中でも「現在どの色を表示しているか」という情報を index という変数で保持しています。

元のコードは関数型の影響を色濃く受けているようで、再帰呼び出しの際に渡す 引数として 色番号を保持しています。


今回書き直すにあたって、同じようにしても良かったのですが、せっかくなら JavaScript らしいやり方でと思い クロージャ を使うことにしました。 具体的には、クラス名を貼り替える関数を返す関数 initAutoColorChange() を定義しています。

function initAutoColorChange($el, colorCount) {
  var index = 0;

  return function() {
    index = (index + 1) % colorCount;

    // "color*"にマッチするクラス名を全て削除
    // 今回の組み方では色数が10色以上になった場合に対応していない
    $el.removeClass(function(i, classNames) {
      return classNames.split(' ').map(function(className) {
        return className.match(/color\d+/);
      }).filter(Boolean).join(' ');
    });

    // 次のクラス名を付与
    $el.addClass('color'+index);
  }
}

initAutoColorChange() によって返される関数は、自身の外側で定義された変数 index を使用しています。
内側の関数が定義される時点で、 index の値は0。その後、関数が呼び出されるたびに index の値は1ずつインクリメントされますが、関数は index の値を保持し続けます。


まとめ

今回コードを書き直すにあたってやったのは、結局のところ「再帰を辞めて setInterval を使った」というだけです。

それだけ聞くと簡単に聞こえますが、
なぜ再帰ではだめなのか、なぜ setInterval を使う事が JavaScript らしいのか、それについて理解しようとするとバックグラウンドに多くの知識が必要になります。

今回、説明したコールスタックや末尾再帰最適化についてもっと詳しく知りたい方は、以下の記事を根気強く読み解いていくといいかも知れません。

つくづく、複数の言語を学んでこそプログラミングの知識がより深まるなぁ、と感じますね。


私からは以上です。