無駄と文化

実用的ブログ

【今日のバグ取り】 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 らしいのか、それについて理解しようとするとバックグラウンドに多くの知識が必要になります。

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

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


私からは以上です。

とあるCSSハックの弔い

今日、私の職場でデザイナーに向けて共有していた CSS の雛形から、とある2行を削除した。
削除されたその部分はこのようになっていた。

body {
  /* text-align: center; */
}

.container {
  width: 980px;
  margin: 0 auto;
  /* text-align: left; */
}

コメントアウトされた2行が、本日、私の手で削除され git commit された部分だ。

コレが適用されている HTML の雛形がおよそ次のようになっていた。

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html lang="ja">
<head>
  ...
</head>

<body>
  <div class="container">
    <div class="header"> ... </div>

    <div class="wrapper"> ... </div>

    <div class="footer"> ... </div>
  </div><!-- /.container -->
</body>
</html>

CSS と HTML とを交互に見てもらえば分かるとおり、削除された2行の CSS は全く意味を成さない。だから削除した。


が、意味を成さないこの2行にはちゃんと意味がある。
実はこれ、IE5 用の CSS ハックなのだ。

今回の削除について、この2行が削除された理由や、そもそも記述された理由について気にする者は居ないだろう。
ひっそりと消されていくのを惜しんで、弔いの意味でこの記事を書く。


{margin: auto;} と中央配置

<body>{text-align: center;} して、直後の <div class="conatiner">{text-align: left;} に戻すこの記述は、<div class="conatiner">中央配置するためにある。


ブロック要素の中央配置といえば、{margin-left: auto; margin-right: auto;} がお馴染みだろうが、実は IE5 にはこれが効かない。
どうやら IE5 では margin に指定する値として auto が許容されていないらしい。

代わりに、という訳ではないがIE5ではブロック要素を中央配置するのに {text-align: center;} を使う。
本来、インライン要素やテキストノードの揃えを指定するための text-align がブロック要素にも効いてしまうのは明らかにバグなのだが。なにはともあれこれ以外に方法がないのでIE5向けにコーディングする上では必要な知識だったようだ。


実は弊社では数ヶ月前までIE7を、一昨年までIE6をサポートしていた。

今回のIE5用のCSSハックは、IE6をサポートしてた頃に私が使っていた雛形から持ってきたんだか、そのときに社内にあったコピペ用CSSを整理するときに残すかしたものだ。
約2年前の時点では確信を持ってこのハックの生き残りを決定したことを覚えている。

そのままなんとな~く据え置かれて、まさかいまだに残っていたとは。


CSSハックは歴史を学び、過去を想うためにある

私が HTML と CSS を学び始めたのは今から5年ほど前で、その当時でさえこの CSS ハックが現役で活躍する時代は過ぎ去っていた。
ただ、私が初めて HTML を学ぶために手に取った書籍がなかなかの良書で、古いブラウザに丁寧に対応するための CSS ハックをコラム的に紹介していたのが このCSS ハックを知るきっかけになった。

5年前は、いわゆるテーブルコーディングがはっきりと悪しき慣習だと認知され、これからはセマンティックなマークアップとCSSによる柔軟なレイアウトだ!という空気感だったように記憶している。

世の中にはスマホ向けサイトというものもあり。でも、レスポンシブで作るなんてのは現実的じゃなかった。そんな頃だ。


私が最初に手に取った書籍でも

  • テーブルコーディングは絶対にやめろ
  • <font>タグなんていまどきじゃない
  • CSSの表現力はすごいぞ

といった事が初心者向けなりにしっかりと書かれていた。
私もそれを気に入って選んでいたし、読むほどにセマンティックなコーディングに憧れたものだ。


一方で、この記事でつらつらと書いてきたような CSS ハックについて解説されていたのも、私のWeb系人生にいい影響を与えたと思う。

インターネットエクスプローラー と ネットスケープ の CSS 実装合戦で Web 制作の現場がひどく混乱したこと。それでもCSSハックを駆使して古いブラウザをサポートして、多くの人に同じ情報を届けるのが Web 制作者の役目である事。そういった事を、初心者ながらに肌で感じられたのを覚えている。


CSS ハック自体が Web から消えていく

いまやブラウザは毎月のように新しいバージョンがリリースされ、自動で更新されてるようになっている。
W3C といった標準化団体がコンセンサスを取りながら機能追加を図ってくれていて、「他のブラウザでは使えない高機能な CSS が使えちゃう俺カッコイイ(ドヤァ」という空気も完全に消え失せている。

これから先にはCSSハックといった『バグでバグをカバーする黒魔術』は生まれないだろう。


今回削除された2行の記述はコミットログに残り続けるけれど、あらゆる人の記憶からは薄れていくと思う。
ま、これは、ただの思い出話だけど、そんな感じで。


私からは以上です。

Python で文字列の類似度を比較する

日本語の処理をしているときに厄介なのが表記揺れですよね。
「コンピューター」と「コンピュータ」、「問い合わせ」と「問い合せ」など。人間が見れば同じ単語だと分かっても、プログラムで処理する際に単純に等号で比較してしまうと別の単語扱いになってしまいます。

今回は類似度を用いて二つの単語を評価することで、表記揺れの問題に対処してみます。


単語間の類似度を算出する

単純に文字列が 等しいか/異なるか 二者択一で評価するのではなく、類似度 を用いて評価してみましょう。

類似度は 0~1 の float で表される値で、二つの単語が全く異なれば 0 、全く一致すれば 1 に評価されます。
そして、全て一致しないにしても似ている単語同士であれば 1に近い少数 に評価されます。

「一致はしないけど、まぁまぁ似てるから同じ単語なんちゃう?」というファジーな評価をするわけですね。


今回は Python を使います。はっきり言って、ものすごく簡単です。

差分計算のための difflib というライブラリがあり、その中の difflib.SequenceMatcher クラスで類似度が出せそうです。

# -*- coding: utf-8 -*-

import difflib


str1 = u"スパゲッティー"
str2 = u"スパゲティ"

s = difflib.SequenceMatcher(None, str1, str2).ratio()

print str1, "<~>", str2
print "match ratio:", s, "\n"

# >> スパゲッティー <~> スパゲティ
# >> match ratio: 0.833333333333

という訳で、「スパゲッティー」と「スパゲティ」の類似度は 83.3% です。
いい感じですね。


いろいろな単語同士を比較する

似ていない単語同士を比べた場合も見てみたいので、もうちょっといろいろなパターンを試しましょう。

# -*- coding: utf-8 -*-

import difflib


# 互いに類似度を比較する文字列のリスト
strs = [
    u"スパゲッティー",
    u"スパゲッティ",
    u"スパゲティ",
    u"カペッリーニ",
]

# リスト内包表記で strs の中の文字列から重複なしの組み合わせを作る
for (str1, str2) in [
        (str1, str2)
        for str1 in strs
        for str2 in strs
        if str1 < str2
    ]:
    print str1, "<~>", str2

    # 類似度を計算、0.0~1.0 で結果が返る
    s = difflib.SequenceMatcher(None, str1, str2).ratio()
    print "match ratio:", s, "\n"

# >> スパゲッティー <~> スパゲッティ
# >> match ratio: 0.0
# >>
# >> スパゲッティー <~> スパゲティ
# >> match ratio: 0.833333333333
# >>
# >> スパゲティ <~> スパゲッティ
# >> match ratio: 0.0
# >>
# >> カペッリーニ <~> スパゲッティー
# >> match ratio: 0.307692307692
# >>
# >> カペッリーニ <~> スパゲッティ
# >> match ratio: 0.0
# >>
# >> カペッリーニ <~> スパゲティ
# >> match ratio: 0.0

「カッペリーニ」と「スパゲッティー」の類似度 30.8% を高いと見るか低いと見るか微妙なところですが、75% あたりで線引きするのがよさそうです。


ところで、「スパゲティ」と「スパゲッティ」の類似度 0% に注目してください。

日本語には 半角文字/全角文字 というもう一つ厄介なものがありましたね。
おそらく同じ物を指しているであろう単語でも半角カタカナでタイピングするだけで類似度が 0% に。これはまずい。


半角文字/全角文字 を正規化してから比較する

類似度の算出に掛ける前処理として、

  • 半角カタカナ → 全角カタカナ
  • 全角英数字 → 半角英数字

と変換を掛けてあげるのがいいでしょう。


unicodedata.normalize() を使います。

# -*- coding: utf-8 -*-

import unicodedata


str1 = u"スパゲッティ"
print str1

# >> スパゲッティ

normalized_str1 = unicodedata.normalize('NFKC', str1)
print normalized_str1

# >> スパゲッティ


先ほどのコードを書き換えましょう。

# -*- coding: utf-8 -*-

import unicodedata
import difflib


# 互いに類似度を比較する文字列のリスト
strs = [
    u"スパゲッティー",
    u"スパゲッティ",
    u"スパゲティ",
    u"カペッリーニ",
]

# リスト内包表記で strs の中の文字列から重複なしの組み合わせを作る
for (str1, str2) in [
        (str1, str2)
        for str1 in strs
        for str2 in strs
        if str1 < str2
    ]:
    # unicodedata.normalize() で全角英数字や半角カタカナなどを正規化する
    normalized_str1 = unicodedata.normalize('NFKC', str1)
    normalized_str2 = unicodedata.normalize('NFKC', str2)

    print str1, "<~>", str2

    # 類似度を計算、0.0~1.0 で結果が返る
    s = difflib.SequenceMatcher(None, normalized_str1, normalized_str2).ratio()
    print "match ratio:", s, "\n"

# >> スパゲッティー <~> スパゲッティ
# >> match ratio: 0.923076923077
# >>
# >> スパゲッティー <~> スパゲティ
# >> match ratio: 0.833333333333
# >>
# >> スパゲティ <~> スパゲッティ
# >> match ratio: 0.909090909091
# >>
# >> カペッリーニ <~> スパゲッティー
# >> match ratio: 0.307692307692
# >>
# >> カペッリーニ <~> スパゲッティ
# >> match ratio: 0.166666666667
# >>
# >> カペッリーニ <~> スパゲティ
# >> match ratio: 0.0

「スパゲティ」と「スパゲッティ」の類似度は 90.9% !今度こそいい感じです。


difflib.SequenceMatcher について補足

ドキュメントを読むと、difflib.SequenceMatcher クラスは4つの引数を受け取れることになっています。

  • isjunk - 類似度を比較するときに無視する文字を評価関数で指定する。デフォルトは None
  • a - 比較される文字列の一つめ
  • b - 比較される文字列の二つめ
  • autojunk - 自動 junk ヒューリスティック の 有効化/無効化 フラグ。デフォルトは True(有効)


ab は比較される二つの文字列です。

s = difflib.SequenceMatcher(a=str1, b=str2)

というように名前付きで指定することも可能です。


isjunk に関数を渡すと、類似度を算出するときに無視させる文字を指定することができます。
c を文字列の中の一文字として、isjunk(c)True を返せばその文字は無視される。isjunk(c)False を返せばそのまま使われる。といった具合ですね。

単語のつなぎとしてのハイフン - や括弧 (, ) などを無視させたければ、

isjunk = lambda c: c in ["-", "(", ")"]
s = difflib.SequenceMatcher(isjunk=isjunk, a=str1, b=str2)

というように適当な無名関数を渡してあげればよさそうです。


autojunk については日本語ドキュメントの説明をそのまま引用させてもらいます。

自動 junk ヒューリスティック: SequenceMatcher は、シーケンスの特定の要素を自動的に junk として扱うヒューリスティックをサポートしています。 このヒューリスティックは、各個要素がシーケンス内に何回現れるかを数えます。ある要素の重複数が (最初のものは除いて) 合計でシーケンスの 1% 以上になり、そのシーケンスが 200 要素以上なら、その要素は “popular” であるものとしてマークされ、シーケンスのマッチングの目的からは junk として扱われます。このヒューリスティックは、 SequenceMatcher の作成時に autojunk パラメタを False に設定することで無効化できます。

英語圏では isthe などの一般的な単語が多出するので、それを無視するためのものなのでしょう。


まとめ

Python で類似度の算出をやってみました。

驚きだったのは、類似度算出の difflib.SequenceMatcher にしても、半角/全角 正規化の unicodedata.normalize() にしても、標準ライブラリだけでサクッと処理できてしまうことですね。いやー、Python すごい。


さて、表記揺れに対処するという視点で言えば、日本語にはまだまだ厄介な問題が多くあります。

  • 「Excel」と「エクセル」のような、アルファベット/カタカナ の表記揺れ
  • 「スターバックス」と「スタバ」のような、略語の表記揺れ
  • 「後程」と「のちほど」のような、漢字表記を ひらく/ひらかない 表記揺れ

これらについては今回とは違ったアプローチで対処していく必要があります。 それぞれ場合へのアプローチについては、また別のエントリーで解説していきたいと思います。


私からは以上です。