この記事は Qiita Tech Festa 2026「この記事誰得? 私しか得しないニッチな技術で記事投稿!」 の参加記事です。
はじめに
みなさん、文字列の全角・半角判定、どうやってますか?
大半の人はこう答えるでしょう。
「正規表現で /[^\x20-\x7E]/ とか使えば一発じゃん」
はい、大正解です。実務では絶対にそうしてください。
しかし、私はある日、自分専用の超巨大なテキストログ(数GB)をパースする自作ツールを作っている最中に、ふと思ってしまったのです。
「正規表現エンジンのオーバーヘッド……許せねぇ……!」
この記事は、ほんの数ミリ秒の高速化のために、休日の12時間を溶かしてUnicodeの海を泳ぎ、全角半角判定をすべて「ビット演算」だけで再実装した、私しか得しないニッチな狂気の記録です。
なぜ正規表現を捨てたのか?
私が処理したかったのは、「半角英数記号」と「それ以外(全角日本語など)」を秒間数百万回レベルで振り分けるという、完全に自分専用の謎バッチ処理でした。
JavaScript (TypeScript) の正規表現は十分に高速ですが、ループの中で何百万回もコンテキストスイッチが発生すると、どうしてもチリツモで数秒の遅延が発生します。
「正規表現を使わずに、文字のバイト値(文字コード)を直接見て、ビットマスクで判定すれば爆速になるのでは?」
思いついたが吉日。ここから地獄が始まりました。
ニッチ技術:文字コードとビット演算の交差点
Unicode(UTF-16)において、JavaScriptの charCodeAt() は 0 〜 65535 までの数値を返します。
私たちが「半角」と呼んでいる文字は大きく2つの領域に分かれています。
| 種別 | Unicode範囲 | 例 |
|---|---|---|
| ASCII 印字可能文字 |
U+0020 〜 U+007E
|
A, 0, !,
|
| 半角カタカナ |
U+FF61 〜 U+FF9F
|
ア, ン, ゙
|
つまり、「上位ビットが立っているか?」あるいは「特定のビットパターンに合致するか?」をビット演算(Bitwise Operations)で判定すれば、正規表現エンジンを起動するまでもなく即座に判定できるということです。
正規表現の落とし穴:\xA1-\xDF では半角カタカナを判定できない
よく見かける正規表現に /[^\x20-\x7E\xA1-\xDF]/ というものがありますが、これには重大な罠があります。
// ❌ 誤り:半角カタカナを「全角」と誤判定する
const WRONG_REGEX = /[^\x20-\x7E\xA1-\xDF]/;
console.log(WRONG_REGEX.test('ア')); // → true(「全角」と判定されてしまう!)
// ✅ 正しい:Unicode エスケープで正確に指定する
const CORRECT_REGEX = /[^\x20-\x7E\uFF61-\uFF9F]/;
console.log(CORRECT_REGEX.test('ア')); // → false(正しく「半角」と判定)
\xA1-\xDF は Latin-1補助文字(U+00A1〜U+00DF、¡〜ß のあたり)を指しており、半角カタカナ(U+FF61〜U+FF9F)とはまったく別の領域です。
このことに気づいたのも、実はビット演算で文字コードを直接追いかけていたから、というのが今回のオチです。
実際に書いてみたコード(誰得)
正規表現を親の仇のように憎んだ結果、出来上がった判定関数がこちらです。
/**
* 【注意】実務で使ったらコードレビューで燃やされます
* 正規表現を使わず、ビット演算と数値比較だけで全角/半角を判定する関数
*/
function isHalfWidthBitwise(char: string): boolean {
const code = char.charCodeAt(0);
// 1. 基本のASCII領域 (0x0020 - 0x007E) は最上位ビットなどを確認するまでもなく
// 0x80 (10000000) との論理積が 0 になるかで判定可能
if ((code & 0xFF80) === 0) return true;
// 2. 半角カタカナ領域 (0xFF61 - 0xFF9F) の判定
// ここが一番厄介。0xFF60 (11111111 01100000) 付近のビットマスクを取る
// 下位バイトが 61〜9F の間に収まるかを、無理やりビットシフトとマスクで絞り込む
// (実質的には数値比較だが、コンパイラ最適化を狙ってビット演算を意識)
if ((code & 0xFF00) === 0xFF00) {
const lowerByte = code & 0x00FF;
// 0x60(01100000) より大きく、0xA0(10100000) より小さいかをビット的に判定
if (lowerByte > 0x60 && lowerByte < 0xA0) {
return true;
}
}
// それ以外は全角(あるいは半角対象外)とみなす
return false;
}
……はい、可読性は最悪ですね!
同僚がこんなPRを出してきたら、私なら「頼むから正規表現にしてくれ」とコメントしてRejectします。
ちなみに素直に書くとこうです:
// こっちの方が読みやすいし、速度はほぼ同じ(後述)
function isHalfWidthReadable(char: string): boolean {
const code = char.charCodeAt(0);
return (code >= 0x20 && code <= 0x7E) || (code >= 0xFF61 && code <= 0xFF9F);
}
衝撃のベンチマーク結果
しかし、問題は「私が得をするか(私のログ解析が速くなるか)」です。
1000万回のループで、正規表現 test() と、今回の isHalfWidthBitwise() を比較してみました。
実測環境: Node.js v24.16.0 / Windows 11 x64
| 計測対象 | 1000万回実行時間 |
|---|---|
正規表現 /[^\x20-\x7E\uFF61-\uFF9F]/(修正版) |
約 222 ms |
| 今回のビット演算関数 | 約 71 ms |
素直な数値比較(>=/<=) |
約 70 ms |
なんと、約3.1倍の高速化に成功しました!!!!!
実行時間が 222ms から 71ms に縮まりました!
約 0.15秒 の短縮です!
……あれ?
このコードを書くのに調べ物含めて 12時間 かかっているのですが、この 0.15秒 のリターンで投資を回収するには、一体何回このスクリプトを回せばいいのでしょうか。計算するのも恐ろしいです。
入力データの偏りによる速度差の変化
さらに深掘りして、どんな文字列を渡すかによって速度差がどう変わるかも計測しました。
| データセット | 正規表現 (ms) | ビット演算 (ms) | 倍率 |
|---|---|---|---|
| ASCII 100% | 189 ms | 65 ms | 2.90倍 |
| 全角 100% | 235 ms | 69 ms | 3.38倍 |
| 半々(ASCII : 全角) | 231 ms | 65 ms | 3.53倍 |
| 混在 | 223 ms | 70 ms | 3.19倍 |
全角文字が多いほど正規表現側が遅くなる傾向があります(マッチ失敗のコストが高いため)。
一方、ビット演算は入力内容に関わらず安定して速いという特性があります。
誤算:ビット演算と素直な比較は同じ速さだった
実はここに誤算がありました。
ビット演算版: 71.13 ms
数値比較版 (>=): 70.35 ms ← ほぼ同じ!!
現代のJITコンパイラ(V8エンジン)は、ビット演算も >=/<= の数値比較も同等に最適化します。
つまり、キモいビット演算を書いた意味は、可読性の向上にはまったく貢献しませんでした。
私の12時間よ……
おわりに:私しか得しない、でもそれでいい
このコードは、世間一般のWebアプリケーション開発においては全く役に立ちません。
素直に正規表現を使うか、既存のバリデーションライブラリを使うべきです。
今回の実験で得られた本当の知見をまとめるとこうなります:
- ビット演算は正規表現より2〜3倍速い(これは本当)
-
でも素直な
>=/<=比較と速さは変わらない(これが誤算) - そして記事で使っていた正規表現にはバグがあった(これが最大の収穫)
結局のところ、「ビット演算を書こうとしてUnicodeをガン見したおかげで、正規表現のバグに気づけた」という、なんとも不思議な結末です。
しかし、自分専用のCLIツールが、自分の書いたコードによって、ほんの少しだけ高速に動くようになった時の快感。そしてUnicodeの深みをちょっとだけ知れたという満足感。これこそが、プログラミングの原点ではないでしょうか。
「誰の役にも立たないかもしれない。でも、私の知的好奇心と、私の手元のスクリプトだけは確実に救われた」
そんなニッチな技術の探求を、これからも密かに続けていきたいと思います。
少しでも「こいつアホなことやってんな(笑)」と思っていただけたら、ぜひ いいね をお願いします!
新しい記事を書いたので、ぜひ読んでください!
Comments
いいぞもっとやれ、エンジニアはこうでなくては。
約2.9兆回(笑)実行した暁には記事更新を期待しています。
@kaga-yasumitsu
エンジニアは技術探求を極めるべきですからね、、
2.9兆回の実行が終わるのと私のPCが終わるのどっちが早いでしょうか…
面白かったw
エンジニアはこうあるべきと思います。
楽しめました。
12時間かけるところまではエンジニアあるある。
それを記事にまとめたところに賞賛。
charCodeAt よりは codePointAt の方が一般的には推奨されるけど、その違い、というかこの場合は、サロゲートペアやら何やらも含めて、要件をもうちょっと明確にしたいところ?
「画面上で等幅フォント表示を使った時、いわゆる半角表示される文字を洗い出したい」のか「JIS X 0201の範囲の文字だけ洗い出したい」のかで、微妙に変わりそう。
追加多言語面(Supplementary Multilingual Plane)の文字の一部は、フォントの字形によっては半角っぽく見えることもある、あたりがネック。
比較的わかりやすいのだと、古イタリア文字(PDF注意) とか。
@Mina_Mizushima さんコメントありがとうございます!
ご推察の通り今回の要件は「画面上の表示幅(East Asian Width)としての半角」ではなく、JIS X 0201(ASCII + 半角カタカナ)の範囲の文字コードかどうかの洗い出しを目的としています。
おっしゃる通り、汎用的な文字列処理であればサロゲートペアを考慮して codePointAt() を使うのが一般的なアプローチだと思います。
ただ今回は、自分の特定のログデータ(SMP領域の文字が含まれない前提)を、バイト列を高速処理したかったため、あえて16bit単位で取得する charCodeAt() に倒しています。
【 charCodeAt() でサロゲートペアが来た場合の挙動】
ちなみに、ご提示いただいた古イタリア文字 𐌕𐌄𐌔𐌕 のようなSMP文字が入力された場合、JavaScriptの内部表現(UTF-16)ではサロゲートペアになります。
最初の文字 𐌕 (U+10315) に対し charCodeAt(0) を実行すると、上位サロゲートである 0xD800 付近の値(55296)が返ります。
記事内の関数 isHalfWidthBitwise のビット判定では、このサロゲートペアの領域(0xD800〜0xDFFF)はASCIIの網にも半角カナの網にも引っかからないため、結果としてfalse(全角、あるいは対象外)として安全に弾かれる挙動になります。
【 𐌕𐌄𐌔𐌕 は半角か全角か?】
等幅フォントでの表示幅という視点(East Asian Width属性)で見ると、これは N (Neutral) に分類されるため、ターミナルやエディタのレンダリングエンジンによっては1セル(半角相当)で描画される厄介な文字ですね。
もしCUIツール等で表示が崩れないように文字幅を正確に計算したい(string-width のような実装をしたい)という要件であれば、この関数は完全に破綻しますし、ご指摘の通り codePointAt() で East Asian Width を厳密に見る必要があります。
く、狂ってやがる・・・(いい意味で)
@greenyellowblue さんありがとうございます!
エンジニアには最高の褒め言葉すぎます!!
理論的には他のプログラム言語でも使えそうですし、他の言語での結果も追記されると期待しています!
12時間分の回収が少しでも早くできますように…
@HiroyoshiMori さんありがとうございます!
AI時代になんかしらに役立ってくれないかなぁ…とか淡い期待を残しつつこの記事を後世につなぎます、、
他のコメントでは2.9兆回回せば回収できるとか、、
生粋のエンジニアですね。
役に立つ、立たないではなく、知的好奇心を満足させる為にやる。
好きです。こういうの。
逆に、数値比較処理に比べて2~3倍の処理時間しか掛からないV8の正規表現処理こそ狂っている。
こういう手はいかがでしょう?
.charCodeAt()を経由せず(JS UTF-16)のまま判定します。出先につきベンチマークはまだです。16bit程度(!)ならテーブル参照でどうでしょう?
あと、今のコードだと 0x0000-0x001F と 0x007F も半角判定されちゃってますね。
3倍も違うの!とびっくりしたけど、正規表現は文字列全体を調べてて、charCodeAt(0)は先頭文字しか比べてないから、長い文字列を渡すとちょっと意味が変わってくる
これ多分、UnicodeじゃなくてShift-JISなんですよ。文字コード体系がそもそも違うのです。
Let's comment your feelings that are more than good