Why not login to Qiita and try out its useful features?

We'll deliver articles that match you.

You can read useful information later.

398
116

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「正規表現が0.002ミリ秒遅かった」という理由だけで、全角/半角判定をすべて『ビット演算』で実装した話

398
Last updated at Posted at 2026-06-14

この記事は Qiita Tech Festa 2026「この記事誰得? 私しか得しないニッチな技術で記事投稿!」 の参加記事です。

はじめに

みなさん、文字列の全角・半角判定、どうやってますか?
大半の人はこう答えるでしょう。

「正規表現で /[^\x20-\x7E]/ とか使えば一発じゃん」

はい、大正解です。実務では絶対にそうしてください。
しかし、私はある日、自分専用の超巨大なテキストログ(数GB)をパースする自作ツールを作っている最中に、ふと思ってしまったのです。

「正規表現エンジンのオーバーヘッド……許せねぇ……!」

この記事は、ほんの数ミリ秒の高速化のために、休日の12時間を溶かしてUnicodeの海を泳ぎ、全角半角判定をすべて「ビット演算」だけで再実装した、私しか得しないニッチな狂気の記録です。

なぜ正規表現を捨てたのか?

私が処理したかったのは、「半角英数記号」と「それ以外(全角日本語など)」を秒間数百万回レベルで振り分けるという、完全に自分専用の謎バッチ処理でした。

JavaScript (TypeScript) の正規表現は十分に高速ですが、ループの中で何百万回もコンテキストスイッチが発生すると、どうしてもチリツモで数秒の遅延が発生します。

「正規表現を使わずに、文字のバイト値(文字コード)を直接見て、ビットマスクで判定すれば爆速になるのでは?」

思いついたが吉日。ここから地獄が始まりました。

ニッチ技術:文字コードとビット演算の交差点

Unicode(UTF-16)において、JavaScriptの charCodeAt() は 0 〜 65535 までの数値を返します。
私たちが「半角」と呼んでいる文字は大きく2つの領域に分かれています。

種別 Unicode範囲
ASCII 印字可能文字 U+0020U+007E A, 0, !,
半角カタカナ U+FF61U+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-\xDFLatin-1補助文字U+00A1U+00DF¡ß のあたり)を指しており、半角カタカナ(U+FF61U+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アプリケーション開発においては全く役に立ちません
素直に正規表現を使うか、既存のバリデーションライブラリを使うべきです。

今回の実験で得られた本当の知見をまとめるとこうなります:

  1. ビット演算は正規表現より2〜3倍速い(これは本当)
  2. でも素直な >=/<= 比較と速さは変わらない(これが誤算)
  3. そして記事で使っていた正規表現にはバグがあった(これが最大の収穫)

結局のところ、「ビット演算を書こうとしてUnicodeをガン見したおかげで、正規表現のバグに気づけた」という、なんとも不思議な結末です。

しかし、自分専用のCLIツールが、自分の書いたコードによって、ほんの少しだけ高速に動くようになった時の快感。そしてUnicodeの深みをちょっとだけ知れたという満足感。これこそが、プログラミングの原点ではないでしょうか。

「誰の役にも立たないかもしれない。でも、私の知的好奇心と、私の手元のスクリプトだけは確実に救われた」

そんなニッチな技術の探求を、これからも密かに続けていきたいと思います。

少しでも「こいつアホなことやってんな(笑)」と思っていただけたら、ぜひ いいね をお願いします!


新しい記事を書いたので、ぜひ読んでください!

398
116
16

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up

@Shiro_Shihi's pickup articles

Shiro_Shihi

@Shiro_Shihi(しひろ)

散歩が好きなフルスタック?エンジニアしてます。 主にバックエンドとフロントエンドをやってます。 ネットワークもちょっとできます
unipro-tech
趣味でデジタル創作活動を広範に行うデジタル創作サークルです。 サークル内でK8sやProxmoxVEなどを運用しています。 UTAUやデジタル絵の制作なども行なっています。

Linked from these articles

Comments

いいぞもっとやれ、エンジニアはこうでなくては。
約2.9兆回(笑)実行した暁には記事更新を期待しています。

13
Shiro_Shihi
@Shiro_Shihi(しひろ)

@kaga-yasumitsu
エンジニアは技術探求を極めるべきですからね、、
2.9兆回の実行が終わるのと私のPCが終わるのどっちが早いでしょうか…

5
jarno222
@jarno222

面白かったw
エンジニアはこうあるべきと思います。

8
YewShmz
@YewShmz(悠 清水)

楽しめました。
12時間かけるところまではエンジニアあるある。
それを記事にまとめたところに賞賛。

11

charCodeAt よりは codePointAt の方が一般的には推奨されるけど、その違い、というかこの場合は、サロゲートペアやら何やらも含めて、要件をもうちょっと明確にしたいところ?
「画面上で等幅フォント表示を使った時、いわゆる半角表示される文字を洗い出したい」のか「JIS X 0201の範囲の文字だけ洗い出したい」のかで、微妙に変わりそう。

追加多言語面(Supplementary Multilingual Plane)の文字の一部は、フォントの字形によっては半角っぽく見えることもある、あたりがネック。
比較的わかりやすいのだと、古イタリア文字(PDF注意) とか。

𐌕𐌄𐌔𐌕
↑これは半角?全角?
1
Shiro_Shihi
@Shiro_Shihi(しひろ)

@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 を厳密に見る必要があります。

3
greenyellowblue
@greenyellowblue

く、狂ってやがる・・・(いい意味で)

5
Shiro_Shihi
@Shiro_Shihi(しひろ)

@greenyellowblue さんありがとうございます!
エンジニアには最高の褒め言葉すぎます!!

1

理論的には他のプログラム言語でも使えそうですし、他の言語での結果も追記されると期待しています!
12時間分の回収が少しでも早くできますように…

1
Shiro_Shihi
@Shiro_Shihi(しひろ)

@HiroyoshiMori さんありがとうございます!
AI時代になんかしらに役立ってくれないかなぁ…とか淡い期待を残しつつこの記事を後世につなぎます、、

他のコメントでは2.9兆回回せば回収できるとか、、

1

生粋のエンジニアですね。
役に立つ、立たないではなく、知的好奇心を満足させる為にやる。
好きです。こういうの。

4
gooya5utaka
@gooya5utaka

逆に、数値比較処理に比べて2~3倍の処理時間しか掛からないV8の正規表現処理こそ狂っている。

2
dankogai
@dankogai(Dan Kogai)
(Edited)

こういう手はいかがでしょう?.charCodeAt()を経由せず(JS UTF-16)のまま判定します。出先につきベンチマークはまだです。

const hankakuLUT = (() => {
    let result = {};
    for (let i = 0x20; i <= 0x7e; ++i)
        result[String.fromCharCode(i)] = true;
    for (let i = 0xFF61; i <= 0xFF9F; ++i)
        result[String.fromCharCode(i)] = true;
    return result;
})();
export const isHankaku = (str) => hankakuLUT[str[0]] || false;
0
fuzzball
@fuzzball
(Edited)

16bit程度(!)ならテーブル参照でどうでしょう?

// テーブルがbooleanの場合
const isHalfWidthReadable = (char: string): boolean =>
    HALF_WIDTH_READABLE_MAP[char.charCodeAt(0)];

あと、今のコードだと 0x0000-0x001F と 0x007F も半角判定されちゃってますね。

0
@nomoto-neo

3倍も違うの!とびっくりしたけど、正規表現は文字列全体を調べてて、charCodeAt(0)は先頭文字しか比べてないから、長い文字列を渡すとちょっと意味が変わってくる

0

よく見かける正規表現に /[^\x20-\x7E\xA1-\xDF]/ というものがありますが、これには重大な罠があります。

これ多分、UnicodeじゃなくてShift-JISなんですよ。文字コード体系がそもそも違うのです。

1

Let's comment your feelings that are more than good

Being held Article posting campaign

398
116

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Login to continue?

Login or Sign up with social account

Login or Sign up with your email address