RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

PHP で日本語の文字列配列をイイ感じにソートする 3 つの方法

はじめに

こんにちは、開発エンジニアの amdaba_sk(ペンネーム未定)です。

今回は PHP のお話です。例えば以下のような配列があったとしましょう。

<?php
$target = [
    'ほげ',
    'ふが',
    'ぴよ',
];

これをソートしたいとします。ただそれだけなら、

<?php
sort($target)

でおしまい、Q.E.D.! でもいいのですが、PHP には他にもいろいろな配列のソート方法が用意されていますよね。

この記事は、それらいろいろなソート方法を紹介し、特に日本語文字列を値に持つ配列の場合もっと「イイ感じ」にする方法がありますよというお話です。

なお、ソートに限らず PHP の配列機能について知りたいという方は、弊社ブログのこちらの記事も併せてチェックしてみてくださいね。

tech-blog.rakus.co.jp

もくじ

PHP の配列ソート関数いろいろ

お決まりですが、こういうときまずは公式の「PHP マニュアル」を見に行くようにしましょう。PHP は公式マニュアルがとても充実していますよね。配列のソートについても、それについてまとめた専用のページがあります。

www.php.net

関数名 ソートの基準 キーと値の相関関係 ソート順 関連する関数
array_multisort() 連想配列の場合は維持し、数値添字配列の場合は維持しない 最初の配列、あるいはソートオプション array_walk()
asort() 維持する 昇順 arsort()
arsort() 維持する 降順 asort()
krsort() キー 維持する 降順 ksort()
ksort() キー 維持する 昇順 asort()
natcasesort() 維持する 大文字小文字を区別しない自然順 natsort()
natsort() 維持する 自然順 natcasesort()
rsort() 維持しない 降順 sort()
shuffle() 維持しない ランダム array_rand()
sort() 維持しない 昇順 rsort()
uasort() 維持する ユーザー定義 uksort()
uksort() キー 維持する ユーザー定義 uasort()
usort() 維持しない ユーザー定義 uasort()

配列をソートする関数が表でまとめられ、それぞれの関数のソートの基準(キー or 値)、キーと値の相関、ソート順、関連する関数が一覧できます。各関数ごとの詳細ページへのリンクもあって、勉強にも役立ちます。

余談ですが、ソートの基準とキーと値の相関が情報として入ってくるのは、すべての配列が連想配列PHP の特徴的なところですね。

さて、マニュアルは PHP の一般の配列に関するものですが、本記事では文字列を値として持つ、一次元の連番整数値添字の配列について考えます。例えばいくつかの静的型付け言語だと、対象配列が次のように型付けされる場合です1

Java: List<String>, C#: List<string>, TypeScript: string[], Scala: List[String], F#: string list, Haskell: [Text]

また、すでに暗黙的に前提としていますが、特に日本語の文字を含む文字列を想定しています。

このような問題設定の下で、ここでは sort 関数について詳しく見ていくことにしましょう。

標準の配列ソート関数 - sort

www.php.net

sort ( array &$array , int $flags = SORT_REGULAR ) : bool

sort は配列を昇順にソートする関数です。

第一引数 $array にソートしたい配列への参照を受け取って、ソートに成功したかどうかを真偽値で返します。引数で渡した配列は、この関数の実行によって変更されます。破壊的な作用を持つ関数ということです。

またオプションの第二引数 $flags に整数で表されたフラグを取り、要素の大小関係の判定方法を切り替えることが出来ます。使える値は以下の通りです。

  • SORT_REGULAR - 通常通りに項目を比較します。 詳細は 比較演算子 で説明されています。
  • SORT_NUMERIC - 数値として項目を比較します。
  • SORT_STRING - 文字列として項目を比較します。
  • SORT_LOCALE_STRING - 現在のロケールに基づいて、文字列として項目を比較します。 比較に使うロケールは、setlocale() 関数で変更できます。
  • SORT_NATURAL - 要素の比較を文字列として行い、 natsort() と同様の「自然順」で比較します。
  • SORT_FLAG_CASE - SORT_STRING や SORT_NATURAL と (ビットORで) 組み合わせて使い、 文字列のソートで大文字小文字を区別しないようにします。

これらはすべて、 PHP のグローバルな定数として定義されています。いくつかピックアップしてみましょう。

SORT_REGULAR

SORT_REGULARsort の第二引数 $flags のデフォルト値です。この値が指定された場合、要素の大小関係の判定には宇宙船演算子 <=>(と同等の処理)が使われます。

宇宙船演算子の詳細については公式マニュアルの該当するページを参照してほしいのですが、SORT_REGULAR が指定された場合の sort は、すべての要素が文字列である前提であれば、次に取り上げる SORT_STRING と変わりません。

ただしマニュアル上でも以下のような警告があるように、要素に複数の型が混在する場合は注意が必要です。

警告 flagsSORT_REGULAR の場合に 複数の型が混在する配列をソートする場合には、注意してください。 sort() が期待しない結果を出力することがあります。

この件に関する詳細は、かなり古い記事ではありますが、以下が参考になります。

hnw.hatenablog.com

SORT_STRING

SORT_STRING が指定された場合 sort 関数は各要素を文字列であるとして大小関係を比較します。

文字列であるとして比較するとはつまり、文字列を文字の配列であるとして、文字コードの数値的大小関係に基づく辞書式順序で比較する2ということです。

例えば'abc''abd'を比較する場合、以下のように先頭から一文字ずつ文字の大小を比較していきます。

文字位置 'abc' 'abd' 比較
1 a a a = a
2 b b b = b
3 c d c < d
全体 'abc' 'abd' 'abc' < 'abd'

初めて違いの出た文字位置の文字の大小で、文字列全体の大小が決まります。ちなみに文字列の終端は、どんな文字よりも小さいと判定されます。

PHP においては、最終的に C 言語の memcmp 関数を用いる実装がされています3

日本語文字列をイイ感じに並べる

ここで以下のような配列をソートすることを考えてみましょう。

順位 文字列 読み UTF-8 文字コード
1 あぶりだし アブリダシ E38182 E381B6 E3828A E381A0 E38197
2 あまガミ アマガミ E38182 E381BE E382AC E3839F
3 ういーん ウイーン E38186 E38184 E383BC E38293
4 アフロディテ アフロディテ E382A2 E38395 E383AD E38387 E382A3 E38386
5 アフロディーテ アフロディーテ E382A2 E38395 E383AD E38387 E382A3 E383BC E38386
6 アプロディテ アプロディテ E382A2 E38397 E383AD E38387 E382A3 E38386
7 アマガミ アマガミ E382A2 E3839E E382AC E3839F
8 ウィーン ウィーン E382A6 E382A3 E383BC E383B3
9 一遍上人 イッペンショウニン E4B880 E9818D E4B88A E4BABA
10 悪人正機 アクニンショウキ E682AA E4BABA E6ADA3 E6A99F
11 阿闍梨 アジャリ E998BF E9978D E6A2A8

上表は sort 関数に SORT_STRING を指定して並べ替えた順に記載しています。各文字列の UTF-8 文字コード値を 16 進数表示で付けました。これを見てもらうと、前節で説明した通りの順序に並んでいることが分かります。

一方で、ぱっと見てこの順序は「イイ感じ」でしょうか? 少し考えれば納得できそうですが、おそらくぱっと見だと「なんでこんな順序になっているの?」と思ってしまうのではないでしょうか。

世界にはいろいろな言語があり、使用している文字も違います。それぞれの言語での最適な文字の並び順が、文字コードの大小とは一致しないこともあります。日本語だと、文字列は基本「読み」の順に並べるのが普通ですよね。

さて、ようやく本題にたどり着きました。日本語文字列を PHP で「イイ感じ」にソートするにはどのようにすればいいでしょうか?

先に結論をいうと、以下の 3 つの方法があります。

  • sort 関数に SORT_LOCALE_STRING フラグを指定する
  • intl 拡張を使う - Collator::sort または collator_sort
  • 独自ルールを実装する - usort

一つずつ見ていきましょう。

sort 関数に SORT_LOCALE_STRING フラグを指定する

標準のソート関数である sort は、先にも説明した通りオプションの第二引数 $flags によって要素の大小関係の判定方法を切り替えることが出来ます。 $flagsSORT_LOCALE_STRING が指定された場合、SORT_STRING と同様各要素を文字列であるとして大小関係を比較しますが、その際、現在のロケールに基づいて地域化された比較ルールを用います。

文字列を文字の配列であるとして先頭から一文字ずつ文字の大小を比較するという点は SORT_STRING の時と同様なのですが、文字同士の大小比較ルールが単純な文字コードの大小比較ではありません。

SORT_LOCALE_STRING を使った場合、PHP は内部的には C 言語の strcoll 関数を使って文字列の比較します4。これによってロケールが示す地域の言語に適した方法で文字の大小比較をしてくれるというわけです。

先ほどのサンプル配列をロケールja_JP.UTF-8 の環境下で、 SORT_STRINGSORT_LOCALE_STRING のそれぞれでソートした結果を比較すると、下のようになります。

--- SORT_STRING
+++ SORT_LOCALE_STRING
@@ @@
 Array (
     0 => 'あぶりだし'
     1 => 'あまガミ'
     2 => 'ういーん'
-    3 => 'アフロディテ'
-    4 => 'アフロディーテ'
+    3 => 'アフロディーテ'
+    4 => 'アフロディテ'
     5 => 'アプロディテ'
     6 => 'アマガミ'
     7 => 'ウィーン'
-    8 => '一遍上人'
+    8 => '阿闍梨'
     9 => '悪人正機'
-    10 => '阿闍梨'
+    10 => '一遍上人'
 )

比較結果を見ると、「ひらがな → カタカナ → 漢字」という文字種ごとの並び順は SORT_STRING の時と同様です。しかし漢字文字列だけを見ると、「ア → アク → イチ」と読みの順に並んでいて、より日本語の文字列として自然と思える並び順になっていると言えます。

より詳細にどのような比較ルールになっているのかというと、申し訳ないのですがにわか仕込みの C 言語ぢからが足りず、分かりませんでした。おそらく PHPコンパイルした際の処理系に依存するものと考えているのですが、実際のところどうなのでしょう。教えてつよつよな人……!

intl 拡張を使う - Collator::sort または collator_sort

www.php.net

オブジェクト指向

public Collator::sort ( array &$arr , int $sort_flag = ? ) : bool

手続き型

collator_sort ( Collator $coll , array &$arr , int $sort_flag = ? ) : bool

PHP をビルドする際に、国際化用拡張モジュール (intl 拡張) を有効にすることで、 ICU ライブラリのラッパーを使うことが出来るようになります。ライブラリで提供される機能はいくつもありますが、ここで注目するのは Collator です。

Collator は、ロケールに応じた適切な並び順を考慮した文字列比較機能を提供するクラスです。並び順のルールは UCA に準拠しています。

Collator を使って配列を並べ替えるには、インスタンスメソッドの Collator::sort か、あるいは collator_sort 関数を使います。

使い方は標準の sort 関数とほぼ同じで、引数 $arr にソートしたい配列への参照を受け取って、ソートに成功したかどうかを真偽値で返します。引数で渡した配列は、この関数の実行によって変更されます。破壊的な作用を持つ関数というところも同じです。

またオプションの引数 $sort_flag に整数で表されたフラグを取り、要素の大小関係の判定方法を切り替えることが出来ます。使える値は以下の通りです。

  • Collator::SORT_REGULAR - 通常の比較 (型を変更しない)
  • Collator::SORT_NUMERIC - 数値としての比較
  • Collator::SORT_STRING  - 文字列としての比較

省略した場合のデフォルトは Collator::SORT_REGULAR ですが、要素がすべて文字列である場合は Collator::SORT_STRING を指定した場合と結局のところ同じになります5。そして Collator::SORT_STRING が指定された場合、Collator::sort は内部的に ICU ライブラリで提供される C 言語の関数 ucol_strcoll 6 を使用して文字列の比較をします7

ロケール ja_JP.UTF-8 が有効な環境下で、サンプル配列を標準の sort + SORT_LOCALE_STRINGCollator::sort + Collator::SORT_STRING のそれぞれでソートして比較してみましょう。

<?php

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use Collator;

class CollatorSortTest extends TestCase
{
    /**
     * ロケールの設定(SORT_LOCALE_STRING 用)
     */
    public static function setUpBeforeClass(): void
    {
        parent::setUpBeforeClass();
        setlocale(LC_COLLATE, 'ja_JP.UTF-8');
    }

    /**
     * sort + SORT_LOCALE_STRING と Collator::sort + Collator::SORT_STRING の
     * ソート結果を比較するテストケース
     * @return void
     */
    public function testCollatorSort()
    {
        $sample = [
            'あぶりだし',
            'あまガミ',
            'ういーん',
            'アフロディテ',
            'アフロディーテ',
            'アプロディテ',
            'アマガミ',
            'ウィーン',
            '一遍上人',
            '悪人正機',
            '阿闍梨',
        ];

        $sortLocalString = $this->sortAndReturn(
            fn (&$arr) => sort($arr, SORT_LOCALE_STRING),
            $sample
        );

        $sortCollator = $this->sortAndReturn(
            fn (&$arr) => Collator::create('ja_JP.utf8')->sort($arr),
            $sample
        );

        $this->assertEquals($sortLocalString, $sortCollator);
    }

    /**
     * 配列にソート関数を適用して返す
     * @param callable $sortFunc ソート関数
     * @param array $arr 対象配列
     * @return array
     */
    private function sortAndReturn(callable $sortFunc, array $arr): array
    {
        $sortFunc($arr);
        return $arr;
    }
}

実行するとこのテストケースは失敗して、以下の差分出力が出て来ます。

--- Expected (sort + SORT_LOCALE_STRING)
+++ Actual (Collator::sort + Collator::SORT_STRING)
@@ @@
 Array (
     0 => 'あぶりだし'
-    1 => 'あまガミ'
-    2 => 'ういーん'
-    3 => 'アフロディーテ'
-    4 => 'アフロディテ'
-    5 => 'アプロディテ'
-    6 => 'アマガミ'
-    7 => 'ウィーン'
+    1 => 'アフロディーテ'
+    2 => 'アフロディテ'
+    3 => 'アプロディテ'
+    4 => 'アマガミ'
+    5 => 'あまガミ'
+    6 => 'ウィーン'
+    7 => 'ういーん'
     8 => '阿闍梨'
     9 => '悪人正機'
     10 => '一遍上人'
 )

比較結果を見ると、「かな文字 → 漢字」という並び順は相変わらずですが、Collator::sort では sort + SORT_LOCALE_STRING とは違って、ひらがなとカタカナの区別がなくなり、音の順で並べられるようになりました。また漢字文字列同士であればやはり読みの順に並んでいて、sort + SORT_LOCALE_STRING よりもさらに日本語の文字列として自然と思える並び順になっていると言えます。

独自ルールを実装する - usort

www.php.net

usort (array &$array , callable $callback ): bool

sort + SORT_LOCAL_STRINGCollator::sort でも満足できないこだわり派の方には、独自に比較関数を実装する方法をご紹介しましょう。

usort は第一引数 $array にソートしたい配列への参照を受け取って、ソートに成功したかどうかを真偽値で返します。引数で渡した配列は、この関数の実行によって変更されます。破壊的な作用を持つ関数ということです。

sort と異なるのは第二引数がフラグではなく、要素を比較するための関数であり、かつ必須であるという点です。無理やりシグネチャを上と同じように書くならば、第二引数のコールバック関数は以下のようになるでしょうか。

$callback (mixed $element1 , mixed $element2 ): int

配列の要素を引数として二つ取り、整数値を返す関数です。返り値の int は、最初の引数 $element1 のほうが二番目の引数 $element2 より大きい場合は正の数を、等しい場合はゼロを、そして小さい場合は負の数を返すように実装します。

このコールバック関数によって、好きなルールでソートを実現することが出来るという寸法です。要件に合わせて適切なルールを実装しましょう。ちなみに昇順降順の切替も、返り値の正負を反転させることで実現することが出来ます(ursort が無いのはそのためでしょう)。

実装例:いろは順

せっかくなので ursort を使った独自ソート関数の実装をやってみましょう。

ursort を使うのであれば、配列の各要素文字列からソートキー(よみがな等)を別途作成して付け加え、それを使ってソート、なんてこともできるでしょうが、ここでは以下の簡単な条件で実装してみます。

  • ソート対象配列の要素文字列はすべて「ひらがな」で構成される
  • ただし濁音、半濁音、長音、拗音、促音に相当する文字は含まれないものとする
  • 各文字列は辞書式順序で順序比較する
  • 各文字の順序は「いろは順」に従う

できあがりはこちらです。

<?php

class IrohaOrder
{
    /** @var array $iroha いろは順マスタ */
    private $iroha = [
        'い', 'ろ', 'は', 'に', 'ほ', 'へ', 'と',
        'ち', 'り', 'ぬ', 'る', 'を',
        'わ', 'か', 'よ', 'た', 'れ', 'そ',
        'つ', 'ね', 'な', 'ら', 'む',
        'う', 'ゐ', 'の', 'お', 'く', 'や', 'ま',
        'け', 'ふ', 'こ', 'え', 'て',
        'あ', 'さ', 'き', 'ゆ', 'め', 'み', 'し',
        'ゑ', 'ひ', 'も', 'せ', 'す',
        'ん',
    ];

    /**
     * 文字列配列をいろは順に並べる
     * @param array $arr
     * @return bool ソートできたかどうか
     */
    public function sort(array &$arr): bool
    {
        try {
            usort($arr, fn ($a, $b) => $this->compare($a, $b));
            return true;
        } catch (RangeException $e) {
            return false;
        }
    }

    /**
     * 文字列を二つ受け取っていろは順に従って順序を決める
     * @param string $a
     * @param string $b
     * @return int <=> の仕様に沿った整数値
     * @throws RangeException 引数の文字列にいろは順が付けられない場合
     */
    public function compare(string $a, string $b): int
    {
        $lenA = mb_strlen($a);
        $lenB = mb_strlen($b);
        $ret = 0;
        for ($i = 0; $i < min([$lenA, $lenB]); $i++) {
            $c = $this->getCharIndex(mb_substr($a, $i, 1));
            $d = $this->getCharIndex(mb_substr($b, $i, 1));
            $ret = $c <=> $d;
            if ($ret !== 0) {
                break;
            }
        }
        return $ret ?: $lenA <=> $lenB;
    }

    /**
     * ひらがな一文字を取っていろは順での順位を返す
     * @param string $c ひらがな一文字
     * @return int いろは順で何番目か
     * @throws RangeException 引数の文字列にいろは順が付けられない場合
     */
    private function getCharIndex(string $c): int
    {
        $ret = array_search($c, $this->iroha, true);
        if ($ret === false) {
            throw new RangeException("Character ${$c} is not supported!");
        }
        return $ret;
    }
}

使い方のサンプルも兼ねて、テストも簡単に作ってみました。

<?php

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use IrohaOrder;

class IrohaOrderTest extends TestCase
{
    /** @var IrohaOrder $irohaOrder */
    private $irohaOrder;

    public function setUp(): void
    {
        $this->irohaOrder = new IrohaOrder();
    }

    /**
     * ひらがな文字列配列がいろは順に並ぶことの確認
     * @return void
     */
    public function testIrohaOrderSort()
    {
        $expected = [
            'いしき', 'いんかん',
            'ろうか',
            'はにわ',
            'におう', 'におうもん',
            'ほとけ', 'ほんき',
            'へいし', 'へんろ',
            'とうき',
        ];
        $sorted = $this->shuffleAndSort(
            fn (&$arr) => $this->irohaOrder->sort($arr),
            $expected
        );
        $this->assertEquals($expected, $sorted);
    }

    /**
     * 配列をシャッフルしてソート関数を適用して返す
     * @param callable $sortFunc ソート関数
     * @param array $arr 対象配列
     * @return array
     */
    private function shuffleAndSort(callable $sortFunc, array $arr): array
    {
        shuffle($arr);
        $sortFunc($arr);
        return $arr;
    }
}

実行してみましょう。

PHPUnit 9.5.2 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 00:00.004, Memory: 4.00 MB

OK (1 test, 1 assertion)

うまく動いているようです。

おわりに

いかがでしたでしょうか?

本記事では、日本語文字列を値として持つ一次元の連番整数値添字の配列を対象として、PHP で利用可能なソート方法をいくつか取り上げて具体例とともに紹介しました。ポイントをまとめると以下のようになります。

  • PHP で配列をソートする方法はいろいろある
  • シンプルな sort 関数の結果は時に「イイ感じ」にならない
  • PHP で日本語文字列を「イイ感じ」の順序で並べる方法は 3 通り

その 3 通りの方法は以下の通りです。

  1. sort 関数で SORT_LOCAL_STRING フラグを指定する
  2. intl 拡張の Collator::sort または collator_sort を使う
    • 拡張の有効化が必要で少し面倒
    • UCA に準拠した順序でのソートが可能
  3. 独自ルールを実装する
    • 最も高コスト
    • 並び順ルールを自由に決められる

それでどの方法が一番「イイ感じ」なのかというと、結局要件次第にはなってしまうものの、個人的には 2 番の intl 拡張の Collator::sort または collator_sort を使う方法が以下の点で「イイ感じ」だと思います。

  • Unicode の技術標準として定められた仕様に準拠できる
  • 外部のよくテストされたライブラリを使用できる
  • 仕様の標準さと実装コストのバランスがよい

以上、PHP で日本語の文字列配列をイイ感じにソートする話でした。少しでもみなさんの PHP ライフの参考にしていただけたら幸いです。

※ 本記事に記載したサンプルコードは php 7.4.15 (cli) で動作を確認しています。


◆TECH PLAY
techplay.jp

◆connpass
rakus.connpass.com

Copyright © RAKUS Co., Ltd. All rights reserved.