【JavaScript】文字数のカウントになぜ String.length を使ってはいけないのか

JavaScript で文字列の文字数をカウントするには String.length を使うことが多いのではないでしょうか。しかし、扱う文字によっては適切な文字数を得られない場合があります。今回は、そもそも String.length は何をしているのかを紐解き、そして適切な文字数を得るための方法について探っていきます。

文字数のカウントには Intl.Segmenter を使いましょう

先に結論を書いておきます。文字数のカウントには Intl.Segmenter を使いましょう。

const str = '𩸽が好き😀';
const segmenter = new Intl.Segmenter('ja-JP', { granularity: 'grapheme' });
console.log([...segmenter.segment(str)].length); // 5

ご覧の通り、文字列は 5 つの文字から構成されています。期待通りに上記コードは 5 を出力します。しかし、String.length では 7 が返ってきます。詳細に迫ってみましょう。

JavaScript の String 型とは

よく文字列の文字数をカウントするために使われる String.length は、私たちが認知する文字の数をカウントするプロパティではありません。多くのシーンで文字数と一致するため、その手段として代用していたに過ぎません。

String.length が何をしているのかを探る前に、JavaScript の文字列とは何なのかを知らないといけません。これに関しては、ECMAScript の仕様を見る必要があります。

まず、ECMAScript 仕様の String 型の規定を見てみましょう。そこでは、String 型とは 16 ビット符号なし整数値の列と規定しています。そして、その 16 ビットの値を「要素 (element)」と呼びます。そして、それぞれの要素は、1 つの UTF-16 コードユニットとして扱うと規定しています。

これは、ウェブブラウザーは JavaScript で扱う文字列を UTF-16 で扱うことを意味します。たとえ、HTML ファイルや JavaScript ファイルが UTF-8 で書かれていたとしても、JavaScript の処理において文字列は UTF-16 に変換されたうえで扱われるということになります。

UTF-16 コードユニットとサロゲートペア

では、UTF-16 コードユニットを深堀りしましょう。UTF-16 コードユニット 1 つで世の中のすべての文字を表現できたなら話はシンプルです。もともと UTF-16 は 2 バイトで世界中の文字を扱うつもりで作られました。そのため、ユニットコードとなる 2 バイトが 1 文字に相当するはずでした。

しかし、その後に更に多くの文字を扱う必要に迫られ、 2 バイトでは表現しきれないことが分かります。結局、足りない分は、複数のコードユニットを束ねて一文字を表現する仕組みになりました。これによって定義された文字をサロゲート文字と呼び、それを構成する複数のコードユニットをサロゲートペアと呼びます。そのため、日本語でも一部の漢字は 2 つのコードユニットをつなげて構成されています。その場合は 1 文字が 4 バイトということになります。近年では顔文字や絵文字が世界中で普及しましたが、中には 3 つのコードユニットで構成される場合もあります。

String.length は何をしているのか

では、String.length は何をしているのでしょうか。ECMAScript 仕様の String.length の規定では、該当の String オブジェクトによって表されている文字列の中の要素の数と規定されています。文字数ではなく要素の数を表しています。

前述の通り、ECMAScript 仕様の String 型における「要素」とは 16 ビットの値のことであり、1 つの UTF-16 コードユニットを意味します。そのため、String.length は多くの場合で文字数と同じ値を返すかもしれませんが、それはたまたまです。もし該当の String オブジェクトにサロゲート文字が 1 つでも含まれていれば、String.length は文字数と一致しません。

まとめると、そもそも String.length は文字数を返すプロパティではない、ということです。

サロゲート文字の例

とはいえ、サロゲート文字を扱うことがないのであれば影響はないと思われるでしょう。確かにそれは利用シーンに大きく依存します。では、サロゲート文字にどんな文字が含まれるのかを簡単に紹介しましょう。以下はほんの一例です。

𠀋、𠮷、𠮟、𡈽、𥔎、𥝱、𩸽、😀

異体字や旧字が多く見られますが、時々見かける文字ですね。特に「𠮟」が含まれているのが不思議に思うのではないでしょうか。実はこの漢字は2つ存在しています。「叱」と「𠮟」です。前者は通常の文字ですが、後者はサロゲート文字です。横に並べれば少しの違いを感じることはできますが、それぞれを単独で見た場合、違いに気づくことはないでしょう。

サロゲート文字には顔文字や絵文字も含まれます。近年はスマートフォン経由で顔文字や絵文字を多用する人も増えているため、システム的なトラブルを避けるためにも、正しくサロゲート文字を認識できることが望ましいと言えます。

String.length の結果を確かめる

では、実際に通常の文字とサロゲート文字とで、String.length の結果を見てみましょう。まずは「叱」と「𠮟」です。

console.log('叱'.length); // 1
console.log('𠮟'.length); // 2

ご覧の通り、よく似た漢字ですが、コードユニットを表す要素数が異なります。前述のサロゲート文字の他の文字も見てみましょう。

console.log('𠀋'.length); // 2
console.log('𠮷'.length); // 2
console.log('𡈽'.length); // 2
console.log('𥔎'.length); // 2
console.log('𥝱'.length); // 2
console.log('𩸽'.length); // 2
console.log('😀'.length); // 2

ご覧の通り、要素数は 2 となります。このように、String.length が文字数と一致しないことがお分かりいただけたかと思います。

であれば、String.split() で文字を分割してから数えれば良いのではないかと思うかもしれませんが、結果は String.length と同じで、結局、コードユニットを表す要素で分割してしまいます。

console.log('𩸽'.split('').length); // 2

このように、String.split() は 1 つのサロゲート文字を 2 つに分割してしまうため、分割した要素を別々に取り出して出力すると文字化けしてしまうので、注意が必要です。

スプレッド構文で文字を分解

では、コードユニットの要素数としてではなく、人が認識する文字数としてカウントするにはどうすればよいのでしょうか。文字列を分解する方法の 1 つにスプレッド構文があります。文字列を分解するという意味では前述の String.split() に近いのですが、実はスプレッド構文は文字列の分解の結果が異なります。

console.log('𠀋𠮷𠮟𡈽𥔎𥝱𩸽😀'.length); // 16
console.log([...'𠀋𠮷𠮟𡈽𥔎𥝱𩸽😀'].length); // 8

上記コードでは、8 つのサロゲート文字を連結した文字列の文字数を調べています。ご覧の通り、String.length では 16 という結果が出たのに対し、スプレッド構文では期待通りに 8 という結果が得られました。

これで解決したかのように見えますが、少し意地悪な文字列を入れてみましょう。

// 合成済み文字の「ダ」
console.log('ダ'.length); // 1
console.log([...'ダ'].length); // 1

// 基底文字+結合文字の「ダ」
console.log('ダ'.length); // 2
console.log([...'ダ'].length); // 2

同じ文字のように見えるにも関わらず、なぜか後半は期待通りの結果が得られません。

結合文字の闇

実はこの後半の「ダ」は通常のダではないのです。Unicode では、日本語の仮名の濁点や半濁点はそれ単独で一つの結合文字として定義されています。つまり、Unicode における「ダ」は、「タ」(基底文字)と「゙ 」(結合文字)を連結した文字として作ることもできますし、「ダ」(合成済み文字)という単独の文字として作ることもできます。

基底文字+結合文字で作られた文字は、String.length で文字数を把握できないことはもちろんのこと、スプレッド構文でも期待通りに文字列を分割できません。いずれも「タ」と「゙ 」に分解されてしまい、2 つとカウントされてしまいます。

ここでふと「基底文字+結合文字で作られた文字なんて使うことはないんじゃないか」なんて思われた方もいらっしゃるでしょう。少なくとも Windows でそのような文字を作るのは難しいでしょう。しかし、macOS の場合は簡単に作れてしまいます。

macOS で新規にフォルダやファイルを作ってみてください。フォルダ名とファイル名に濁音または半濁音を伴う仮名文字を入れてください。実は、そのフォルダ名とファイル名の濁音または半濁音を伴う仮名文字は基底文字+結合文字で作られた文字となります。

macOS で作られたフォルダやファイルを zip で圧縮し、それを Windows で解凍すると、文字化けが発生したことはないでしょうか。この文字化けは、基底文字+結合文字で作られた文字が原因です。

もし macOS ユーザーがフォルダ名やファイル名をコピー&ペーストすれば、いとも簡単に基底文字+結合文字で作られた文字を入力することができてしまいます。

そう考えると、スプレッド構文を使って文字列の文字数をカウントするのは不十分と言えます。

書記素で分解できる Intl.Segmenter

これまで「私たちが認知する文字」という表現を使ってきましたが、そういった文字のことを「書記素 (grapheme)」と言います。この書記素で文字列を分割することがゴールなのですが、それを実現するのが冒頭で紹介した Intl.Segmenter です。改めて冒頭のコードを掲載します。そして、String.length の結果も加えてあります。

const str = '𩸽が好き😀';
const segmenter = new Intl.Segmenter('ja-JP', { granularity: 'grapheme' });
console.log([...segmenter.segment(str)].length); // 5
console.log(str.length); // 7

もちろん、基底文字+結合文字で作られた文字も 1 文字としてカウントしてくれます。

const str = 'ダ'; // 基底文字+結合文字の「ダ」
const segmenter = new Intl.Segmenter('ja-JP', { granularity: 'grapheme' });
console.log([...segmenter.segment(str)].length); // 1
console.log(str.length); // 2

Intl.Segmenter は文字列を書記素で分解するだけでなく、単語で分割、文で分割することもできます。詳細は MDN などのドキュメントをご覧ください。

最後に

Intl.Segmenter は Chrome、Edge、Safari にはかなり以前から実装されていましたが、Firefox がなかなか実装してくれませんでした。そのため以前は Intl.Segmenter の利用がためらわれました。しかし、2024 年に Firefox が Intl.Segmenter を実装したことで、すべてのメジャーブラウザーで Intl.Segmenter が利用できるようになりました。いまは迷うことなく Intl.Segmenter が使えます。

今回は以上で終わりです。最後まで読んでくださりありがとうございました。それでは次回の記事までごきげんよう。

Share