挙動不審!?
IE の split() メソッドは何かと挙動不審である。セパレータに文字列を用いた場合は問題ないが、ひとたび正規表現で指定しようものなら、トンでもない結果が返されることがしばしば。あまりに一貫性のない挙動のため、デバッグでハマることも多かろう。
(1) 分割後、空文字列となる要素は配列に含まれない
(2) ただし空文字列を分割すると空要素が一つの配列が返される
(1) に関しては以下の二つのコードで確認して欲しい。普通の頭で考えるなら両者とも「3」が返されると思うはずだ。
X = 'ABC\n\nDEF'.split('\n').length
Y = 'ABC\n\nDEF'.split(/\n/).length
ところが Y では二番目の空行が無視され「2」が返される。Firefox や Opera では無論「3」である。取り合えず ECMAScript における split() メソッドの仕様は下記。
では続けよう。百歩譲ってこう考えてみた。「改行を無視してくれたほうがありがたいこともあるかもしれない」。改行のみで構成された文字列で試してみる。
X = '\n\n\n'.split(/\n/).length
予想通り、X は「0」である。しかしこれには重大な落とし穴があった。それが (2) である。
X = ''.split(/\n/).length
空文字列の要素は配列に含まれないのだから、これも「0」になるだろうと思ったら大間違い。こいつは何故か「1」になるのだ。だから
「JScript の split() メソッドはセパレータに正規表現を指定した場合、空要素を無視した配列を返す」
などとは口が裂けても言ってはいけない。首尾一貫性のない箇所がとんでもないバグを引き起こすかもしれないからだ。本来なら ECMAScript の仕様に準拠して欲しいところだが、せめて「空要素を抜く」というなら完全にそうして欲しかった。JScript の split() メソッドはあまりに中途半端すぎる仕様である。
回避策(a.k.a. ごまかし)
空文字列の分割においても空配列を返したいという場合は、終端マッチを利用して次のように書けばよい。
X = '\n\n\n'.split(/\n|$/).length
Y = ''.split(/\n|$/).length
先頭マッチではダメなのかという向きもあろうが
X = '\n\n\n'.split(/^|\n/).length
Y = ''.split(/^|\n/).length
はダメである。
X = '\n\n\n'.split(/\n|^/).length
Y = ''.split(/\n|^/).length
なら問題ない。ただし ^ を後ろに記述するのはなんとも気持ちがワルイ。
X = '\n\n\n'.split(/$|\n/).length
Y = ''.split(/$|\n/).length
これも問題なかったが、やはり気持ちワルイ。
ECMAScript に準拠させる
何だかんだ遊んでみたものの、やっぱりこの仕様はいただけない。さらに上記に加えて「捕捉括弧にマッチした文字列が配列に追加されない」というオチもある。
A = 'ABC+=DEF=GHI-=JKL+=MN'.split(/([+-])?=/)
Firefox や Opera では [ABC,+,DEF,,GHI,-,JKL,+,MN] が返されるが、IE では [ABC,DEF,GHI,JKL,MN] である。まあ、これ以上嘆いたところでどうなるわけでもなし、結局は split() メソッドを再実装するのが一番安全かもしれない。というわけで書いたのが以下。
String.prototype.split = (function () {
var _split = String.prototype.split;
var MAX_COUNT = Math.pow(2, 32) - 1;
return 'foo'.split(/foo/).length ? _split : function (separator, limit) {
if (separator instanceof RegExp) {
var str = this.toString();
var arr = [];
limit = limit ? parseInt(limit) : MAX_COUNT;
if (limit > 0) {
var len, match;
var i = 0;
var j = -1;
separator = eval(separator + 'g');
while ((len = limit - arr.length) && (match = separator.exec(str))) {
if (match.index < str.length) {
if (match[0].length || match.index > i) {
arr = arr.concat(
str.substr(i, match.index - i),
match.slice(1, len)
);
}
i = match.index + match[0].length;
j = match.lastIndex;
}
}
if (arr.length < limit && (i < str.length || j <= str.length)) {
arr.push(str.substr(i));
}
}
return arr;
}
else {
return _split.call(this, separator, limit);
}
};
})();
ポイントは以下の通り。
- セパレータが RegExp オブジェクト以外のときは元の split() メソッドへ投げることにした。クロージャにしているのはメソッドの退避によるグローバル空間の汚染を避けるため。
- 元の split() メソッドが挙動不審であるかのチェックをする(「2」が返れば正常、「0」が返れば挙動不審ということ)。正常であれば元のメソッドを、挙動不審であれば書き直したメソッドを渡す。
- String オブジェクトを toString() メソッドで明示的に String オブジェクトに変換しているのはメソッドが他のオブジェクトへ流用された時のため。
- セパレータは指定の有無に関わらずグローバルマッチさせる必要があるので RegExp オブジェクトを再生成。eval はあまり使いたくなかったが他に策が浮かばなかった。ちなみに global プロパティは読み取り専用のため使用できない。
- while 文は「配列数が上限以下」かつ「マッチするセパレータがある」限り、セパレータまでの文字列と補足括弧によるセパレータ内のサブマッチ文字列を追加し続ける。ただし空文字列にマッチする場合、先頭と末尾は無視する。
- match.slice(1, len) の部分は見た目はシンプルだが、実際はもうちょっと複雑。
slice(start, start + size)
start: 抽出開始インデックス
size : 抽出サイズ
サブマッチ文字列は match[1] 以降に格納されるため、start は「1」である。また size は追加可能な要素数の上限であり、len - 1 となる(len は limit - arr.length で「-1」は区切られた文字列の追加分を考慮したもの)。
slice(start, start + size)
start = 1
size = len - 1
> slice(1, 1 + len - 1)
> slice(1, len)
第二引数は「1」が相殺されてたまたまシンプルになっただけ。
上記の再実装は他のブラウザの挙動に限りなく近い(と思う・・・)が完全に等しいわけではない。唯一の相違は JScript の正規表現にある。ECMAScript の仕様では "." は "\r" と "\n" の改行コードを除くすべての一文字にマッチするが、JScript では "\n" 以外の一文字となる。つまり "\r" にはマッチしてしまうのである。これに関してはまた章をあらためて記すことにする。
おまけ
RegExp オブジェクトの exec() メソッドを呼び出すとマッチした文字列を格納した配列が返されるが(マッチしなかった場合は null)、この配列は input, index, lastIndex というプロパティを持つ。input は検索対象の文字列、index はマッチした文字列の先頭インデックス、lastIndex は次回検索時の開始インデックスが格納される(マッチした文字列が空文字列であっても開始インデックスが 1 シフトされることに注意)。lastIndex プロパティの値は RegExp オブジェクトにグローバルフラグが立てられている場合、オブジェクト自身の lastIndex プロパティにもコピーされる。これが exec() メソッドによる続き検索が可能となる仕組みである。lastIndex プロパティの値は exec()メソッドに失敗するか、明示的に 0 を代入することでリセットされる。もちろん任意の開始インデックスを指定してもよい。
最終更新:2009年04月24日 20:50