PHPのsubstr_countは重なる出現を数えない “aaa”の中の”aa”が1回になる理由

substr_count()は、文字列の中に部分文字列が何回出てくるかを数える関数です。ただし数えるのは重ならない出現だけで、大文字小文字も区別します。

「数えるだけの関数でしょ」と油断していると、ログの集計やDNA配列っぽい文字列の処理で、期待より少ない数字が返ってきて首をひねることになります。私もこの関数の戻り値を何も疑わず使って、あとから数が合わない原因を探した側です。

“aaa”の中に”aa”は何回ある?

人間の感覚だと、”aaa”の中に”aa”は2回あります(1文字目から、2文字目から)。ところがsubstr_count()は1を返します。一度マッチした部分は読み終わったものとして、その次の位置から探し直すからです。

<?php
var_dump(substr_count('aaa', 'aa')); // int(1)
var_dump(substr_count('abababa', 'aba')); // int(2) ← 重なりを数えれば3

この挙動自体は仕様どおりで、バグではありません。ただ「重なりも数えたい」場面では別の道具が必要になります。

重なりも数えたいときはどう書く?

正規表現の先読み(ゼロ幅のマッチ)を使うと、読み進める位置が1文字ずつしか動かないので、重なりも拾えます。preg_match_all()の戻り値はマッチ数なので、そのまま件数として使えます。

<?php
$needle = 'aa';
$pattern = '/(?=' . preg_quote($needle, '/') . ')/';
var_dump(preg_match_all($pattern, 'aaa')); // int(2)

検索語を埋め込むので、preg_quote()は必須です。これで、ユーザー入力が検索語でも正規表現として暴れません。ただし、ふつうの重ならない件数で足りる場面なら、速くて読みやすいsubstr_count()のままで十分だと思います。

大文字小文字を区別しない件数はどう数える?

substr_count()には大文字小文字を無視するオプションがありません。両方を小文字に寄せてから数えるのが定番です。日本語を含むなら、strtolower()ではなくmb_strtolower()を使うほうが安全です。

<?php
var_dump(substr_count('Apple apple', 'apple')); // int(1)
var_dump(substr_count(strtolower('Apple apple'), 'apple')); // int(2)

これで「Apple」と「apple」を同じ単語として集計できます。

空文字を探すとエラーになる

PHP 8では、検索語が空文字だとValueErrorが投げられます。範囲指定の引数も同じで、offsetやlengthが文字列の範囲に収まっていないと、静かに0を返すのではなく例外になります。

<?php
try {
    substr_count('abc', '');
} catch (ValueError $e) {
    echo $e->getMessage(), "\n";
    // substr_count(): Argument #2 ($needle) cannot be empty
}

try {
    substr_count('abc', 'a', 0, 10);
} catch (ValueError $e) {
    echo $e->getMessage(), "\n";
    // substr_count(): Argument #4 ($length) must be contained in argument #1 ($haystack)
}

外部入力をそのまま検索語や範囲に渡している場合は、事前に空チェックと長さの確認をしておくと、本番での予期せぬ例外を避けられます。

offsetとlengthは何文字目から何文字分を見る?

第3引数のoffsetは数え始める位置、第4引数のlengthはそこから見る長さです。offsetには負の値も使えて、その場合は末尾から数えます。

<?php
$s = 'hello hello hello';
var_dump(substr_count($s, 'hello', 3));     // int(2) ← 先頭の"hello"は範囲外
var_dump(substr_count($s, 'hello', 0, 11)); // int(2)
var_dump(substr_count($s, 'hello', -5));    // int(1)

「先頭の1件だけ除いて数えたい」といったときに、文字列を切り出さず済むのが嬉しいところです。

日本語はバイト単位で数えられている

substr_count()はバイト単位で動きます。UTF-8の文字列どうしを正しく指定していれば、日本語でも件数は合います。ただ、文字の途中のバイトを検索語にしてしまうと、文字としては存在しないものにマッチします。

<?php
var_dump(substr_count('あいあい', 'あい'));   // int(2)
var_dump(substr_count('あい', "\xE3"));       // int(2) ← 「あ」と「い」の先頭バイト
var_dump(mb_substr_count('あい', "\xE3"));    // int(0)

通常の運用でここを踏むことは少ないですが、バイナリ混じりのデータや文字コード変換の途中で出てくる値を数えるときは、mb_substr_count()のほうが文字として意味のある結果を返します。

まとめ

substr_count()は、重ならない出現を、大文字小文字を区別して、バイト単位で数える関数です。この三つを頭に置いておくだけで、数が合わない原因のほとんどは説明がつくと思います。重なりが要るならpreg_match_all()の先読み、大文字小文字を無視したいなら小文字化してから、という使い分けですね。

よくある質問

Q. substr_count()で重なる出現も数えられますか?
A. 数えられません。一度マッチした部分の次から探し直すため、”aaa”の中の”aa”は1回になります。重なりが必要なら、preg_match_all()と先読み(?=…)の組み合わせを使います。

Q. 大文字小文字を区別せずに数えるには?
A. 対象の文字列と検索語の両方を小文字に揃えてから数えます。日本語を含む可能性があるならmb_strtolower()を使うと安全です。

Q. 検索語が空文字だとどうなりますか?
A. PHP 8以降はValueErrorが投げられます。外部入力を渡す場合は、事前に空文字でないことを確認しておきましょう。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です