【PHP】mb_substr()はなぜ遅いのか


簡単なまとめ

  • PHPの mb_substr() はあんまり速くない
  • 長い文字列を与えて末尾の方を取り出そうとするとすげー遅い
    • 先頭部分を取り出すなら別に問題ない
  • どうしても長い文字列をカットしたければ別の方法を使おう
  • 無理やり分割するときは文字の境界に気をつけてね

検証環境: PHP 8.0.1

検証

65万文字(ASCII文字のみ)の文字列を用意して、 mb_substr($文字列, 開始位置, 1) が要する処理時間を計測しました。(使ったコードはGistに置いてあります)
ところどころに怪しいぴょこぴょこが見えますが、見事に線形になっています。終わりの方ではたかが 文字列から1文字取り出すだけ の処理1回が1msを超えてしまいました。

つまり「任意の文字列を先頭から末尾まで1文字ずつ切り出して文字種をチェックする」という処理に mb_substr() を使うと、かかる時間は 文字数の2乗 に比例します。10,000文字の文字列を処理するには1,000文字のときの100倍の時間がかかるということです。

なんで?

PHPは文字列をただのバイト配列として保持します。そしてUTF-8では1文字のバイト長が可変なのでした。そこで次の例題を考えます。

【Q】 ABCあいうαβγかきく という文字列から「10 文字目以降(かきく)」が欲しいとします。何バイト目から切り出せばいいでしょうか? 文字列のエンコーディングはUTF-8だとします。
【A】 先頭から順に数えていくしかありません。ABCは1文字1バイト、あいうは1文字3バイト、αβγは1文字2バイトですから、先頭18バイトを除いて19バイト目から取り出します。これは面倒くさい!

したがって、 mb_substr() に「50万文字目」という無茶な値を突っ込むと、 mb_substr() は内部1mbfl_string() という関数23を呼び出して愚直に先頭から文字数とバイト数のカウントを始めます。 mb_substr() を1,000回呼ぶと同じ処理が1,000回走ります。そりゃー遅いよね、という話でした。

どうすればいいのか

文字数を制限する、文字列を分割する

長さ制限を加えてしまうというのが一つの手です。
改行もデリミタもない、ながーーーーーい文字列をPHPで処理しなくてはならないという状況は割とレアでしょうから(Webアプリの場合それはDoSアタックの可能性があります)、適当に制限をかけるなり、 explode() するなりでだいたいの問題は解決するはずです。

改行やデリミタのような手がかりがないなら一定サイズごとに無理やり分割することになりますが、たとえば「あ」(UTF-8では 0xE3 0x81 0x82)の真ん中で切ってしまったりすると困ります。切り方には気をつけないといけません。

特定の文字種・エンコーディングを前提に処理を書く

処理対象の文字列がASCII文字だけで構成されていることが確実なのであれば(正規表現を使ってチェックしましょう) substr() で同じ結果を得られます。

preg_splitで分解する

「任意の文字列を先頭から末尾まで1文字ずつ切り出す」をやりたいなら、

$arr = preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY);

で文字列を1文字ずつにバラして配列にできます。こちらの方が圧倒的に高速です。

長さ固定のエンコーディングを使う

ここまでで分かった通り、1文字の長さが可変であるUTF-8を使うから時間がかかるわけです。1文字の長さが固定4のエンコーディング、たとえばUTF-32を使えば爆速になります(文字種をチェックしなくても 文字数×定数 でバイト長が直接出せるため)。

$longString_utf8 = "...."; // なんか長い文字列
$longString_utf32 = mb_convert_encoding($longString_utf8, "UTF-32");
$len = mb_strlen($longString_utf8); // mb_strlen($longString_utf32, "UTF-32") でも同じ
for($i = 0; $i < $len; $i++) {
    $char_utf32 = mb_substr($longString_utf32, $i, 1, "UTF-32"); // エンコーディングを明示的に指定する
    $char_utf8 = mb_convert_encoding($char_utf32, "UTF-8", "UTF-32"); // これで1文字ずつが取れる
    // あとはchar_utf8 を使って好きなことをやる
}

なお、こうするとお手軽に高速になりますがおそらく地獄のような状況を招きます(やめましょう)

mb_internal_encoding("UTF-32");

補足

何か間違いなどあればコメントで教えてください。よろしくお願いします。


  1. https://github.com/php/php-src/blob/PHP-8.0.1/ext/mbstring/mbstring.c#L2123 

  2. https://github.com/php/php-src/blob/PHP-8.0.1/ext/mbstring/libmbfl/mbfl/mbfilter.c#L919 

  3. この関数は mb_strlen() も使っています 

  4. 結合文字や異体字セレクタを考えると全然固定ではないのですが、ここでは説明を省きます……