【PHP】 空白文字で文字列を分割して検索用キーワードの配列を作る

# これは何？

データベースの LIKE 検索用途とかでキーワードを抽出するやつ。
例えば

`PHP Qiita Laravel`

と入力したら，

`PHP` `Qiita` `Laravel`

として取り出す関数を作ります。

# 実装

実装したい関数の形を示し， 2 つの異なるアプローチでの実装例を記載します。

## 関数シグネチャ

`$input` に入力文字列を渡し， `$limit` にキーワード数制限を指定します。 `-1` を指定した場合は無制限になり，これをデフォルトとします。

```php
function extractKeywords(string $input, int $limit = -1): array
```

## 手法の比較

※ 「手法」の名称は一般的に提唱されているものではありません

| 手法 | 説明 | よく使われる関数 |
|:----|:----|:---|
| 空白分割法 | 空白文字で分割し，残った部分をキーワードとする。**キーワード数制限を超過する場合，最後の1つの中に空白文字を含む残りのすべてが含まれる。**| [`preg_split()`]<br>[`explode()`]
| 非空白抽出法 | 空白文字ではない部分だけを直接キーワードとして抽出する。**キーワード数制限を超過する場合，単に超過分は無視される。** | [`preg_match_all()`]<br>[`preg_replace_callback()`]

実装が簡単なのは空白分割法のほうですが，キーワード数制限超過時の挙動がそれぞれ異なるので注意してください。例えば `a b c d e` において `$limit = 3` で抽出する場合，

- 空白分割法の場合， `a` `b` `c d e` となる
- 非空白抽出法の場合， `a` `b` `c` となる

という違いがあります。

## 空白分割法

### 半角スペースで分割

一番シンプルな実装。 [`explode()`] を使う手法もありますが， `$limit` の適用や空文字列の除外を考えると，こちらのほうが基本的に上位互換です。

```php
function extractKeywords(string $input, int $limit = -1): array
{
    return preg_split('/ ++/', $input, $limit, PREG_SPLIT_NO_EMPTY);
}
```

### あらゆる空白文字で分割

半角スペースの他に全角スペース，改行，タブ，ノーブレークスペースなどあらゆる空白系の制御文字を対象とする場合はこちら。 `\p{Z}` は ASCII 範囲にある制御文字の集合， `\p{Cc}` は Unicode 範囲にある制御文字の集合を表しています。またこれらを適用するためには `u` フラグが必須になります。

```php
function extractKeywords(string $input, int $limit = -1): array
{
    return preg_split('/[\p{Z}\p{Cc}]++/u', $input, $limit, PREG_SPLIT_NO_EMPTY);
}
```

## 非空白抽出法

### 半角スペース以外を抽出

```php
function extractKeywords(string $input, int $limit = -1): array
{
    $matches = [];
    preg_replace_callback(
        '/[^ ]++/',
        function (array $match) use (&$matches) {
            $matches[] = $match[0];
        },
        $input,
        $limit,
        $_,
        PREG_SET_ORDER
    );
    return $matches;
}
```

*「なんで [`preg_match_all()`] じゃないんだ！？」*

が素直な感想だと思います。 [`preg_replace_callback()`] で参照代入を使うみたいな変なことをやっているのは， `$limit` 適用に対応するためです。  **[`preg_match_all()`] にはマッチング回数を制限する仕組みがありません。**

### あらゆる空白文字以外を抽出

```php
function extractKeywords(string $input, int $limit = -1): array
{
    $matches = [];
    preg_replace_callback(
        '/[^\p{Z}\p{Cc}]++/u',
        function (array $match) use (&$matches) {
            $matches[] = $match[0];
        },
        $input,
        $limit,
        $_,
        PREG_SET_ORDER
    );
    return $matches;
}
```

パターンの部分を変えるだけで全体的な体裁は同じですね。

### ダブルクオーテーションで括った部分は保持したまま，あらゆる空白文字以外の部分を抽出

こちらは，非空白抽出法でしか実装できません。

`Laravel "Taylor Otwell" PHP` と入力したら `Laravel` `Taylor Otwell` `PHP` として欲しい場合はこちら。 厳密にイコールではないですが，ある程度 Google 検索っぽい動きになります。

```php
function extractKeywords(string $input, int $limit = -1): array
{
    $matches = [];
    preg_replace_callback(
        '/""(*SKIP)(*FAIL)|"([^"]++)"|([^"\p{Z}\p{Cc}]++)/u',
        function (array $match) use (&$matches) {
            $matches[] = $match[2] ?? $match[1];
        },
        $input,
        $limit,
        $_,
        PREG_SET_ORDER
    );
    return $matches;
}
```

ダブルクオーテーションで括られた空文字列は， PCRE 特有の機能である `(*SKIP)(*FAIL)` でマッチングを強制終了して `$limit` 適用の際に 1 キーワードとしてカウントしない，など少し気を利かせています。

# 補足

## 重複を削除する

もし重複したキーワードを削除する場合は， [`array_unique()`] + [`array_values()`] の処理を組み合わせてください。以下に使用率の高そうな，適用版の一部を記載しておきます。

```php:あらゆる空白文字で分割し，重複を除外する
function extractKeywords(string $input, int $limit = -1): array
{
    return array_values(array_unique(preg_split('/[\p{Z}\p{Cc}]++/u', $input, $limit, PREG_SPLIT_NO_EMPTY)));
}
```

```php:ダブルクオーテーションで括った部分は保持したまま，あらゆる空白文字以外の部分を抽出し，重複を除外する
function extractKeywords(string $input, int $limit = -1): array
{
    $matches = [];
    preg_replace_callback(
        '/""(*SKIP)(*FAIL)|"([^"]++)"|([^"\p{Z}\p{Cc}]++)/u',
        function (array $match) use (&$matches) {
            $matches[] = $match[2] ?? $match[1];
        },
        $input,
        $limit,
        $_,
        PREG_SET_ORDER
    );
    return array_values(array_unique($matches));
}
```

[`preg_split()`]: https://www.php.net/manual/ja/function.preg-split.php
[`explode()`]: https://www.php.net/manual/ja/function.explode.php
[`preg_replace_callback()`]: https://www.php.net/manual/ja/function.preg-replace-callback.php
[`preg_match_all()`]: https://www.php.net/manual/ja/function.preg-match-all.php
[`array_unique()`]: https://www.php.net/manual/ja/function.array-unique.php
[`array_values()`]: https://www.php.net/manual/ja/function.array-values.php