この記事は 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の深みをちょっとだけ知れたという満足感。これこそが、プログラミングの原点ではないでしょうか。
「誰の役にも立たないかもしれない。でも、私の知的好奇心と、私の手元のスクリプトだけは確実に救われた」
そんなニッチな技術の探求を、これからも密かに続けていきたいと思います。
少しでも「こいつアホなことやってんな(笑)」と思っていただけたら、ぜひ いいね をお願いします!