PHP7で宇宙船演算子を使いこなすぞ

  • 95
    いいね
  • 0
    コメント

まずはPHP7リリースおめでとうございます!!!
PHP 7.0.0 Released

なんだか記念すべき日にAdvent Calendarを入れてしまって恐縮です。その割にすごい小ネタで書いてしまった…。

ここから本文

PHP7では宇宙船演算子<=>が導入されます。だいたいこういう意味の演算子ですね。

// <=>と同じ意味の関数
function compare($a, $b) {
    if ($a < $b) {
        return -1;
    } elseif ($a > $b) {
        return 1;
    } else {
        return 0;
    }
}

compare(1, 2); // -1;
1 <=> 2; // -1;

ユースケース的にはusort(), uasort(), uksort()との組み合わせで用いられるので、この機会にPHPにおける配列の並び替えについてまとめてみることにします。

usort

usort関数はユーザー定義のcomparatorを使って配列のソートを行います。
comparatorとは二つの要素のどちらが大きいかを判定する関数です。<=>で書けば本体は一行で済みます。

usort($arr, function($a, $b) {
    return $a <=> $b;
});

とはいえ、これだけだとあまり有難みがないですね。恩恵を感じるのはもっと複雑なソートを行う場合だと思います。

例として、こんなクラスの配列を考えてみましょう。

class Person
{
    public $name, $birthday, $height;

    function __construct($name, DateTime $birthday, $height)
    {
        $this->name = $name;
        $this->birthday = $birthday;
        $this->height = $height;
    }

    function __toString()
    {
        return implode(', ', [
            $this->name,
            $this->birthday->format('Y-m-d'),
            $this->height
        ]);
    }
}

ユーザー属性情報っぽいやつです。ちなみに__construct__toStringは生成と出力を楽にするために書いただけなので、あまり深い意味はありません。

初期配置はこんな感じにしましょう。

$people = [
    new Person('Tanaka', date_create('1990-01-01'), 165),
    new Person('Tanaka', date_create('1980-03-12'), 170),
    new Person('Suzuki', date_create('1980-10-25'), 160),
    new Person('Akiyama', date_create('1991-09-11'), 150),
];
echo implode(PHP_EOL, $people);
/*
Tanaka, 1990-01-01, 165
Tanaka, 1980-03-12, 170
Suzuki, 1980-10-25, 160
Akiyama, 1991-09-11, 150
*/

複雑なソート その1

$people配列を、

  1. 名前で昇順
  2. もし名前が同じなら誕生日で昇順

と言う風に二つの条件で並び替えたいとしましょう。

宇宙船を使わずに書くと、if文を入れ子にしたような形になり、あまりカッコいい見た目ではなかったと思います。
早期リターンで頑張ってもこのレベルかな。

usort($people, function($a, $b){
    if ($a->name < $b->name) return -1;
    if ($a->name > $b->name) return 1;
    if ($a->birthday < $b->birthday) return -1;
    if ($a->birthday > $b->birthday) return 1;
    return 0;
});
echo implode(PHP_EOL, $people);
/*
Akiyama, 1991-09-11, 150
Suzuki, 1980-10-25, 160
Tanaka, 1980-03-12, 170
Tanaka, 1990-01-01, 165

↑Tanakaもちゃんとソートされてる
*/

これを、宇宙船演算子と三項演算子省略形(elvis演算子)を組み合わせて書くとこの通りです。

usort($people, function($a, $b){
    return $a->name <=> $b->name
        ?: $a->birthday <=> $b->birthday;
});

「nameで比較する。ダメだったらbirthdayで比較する。」と言う風に読めて、割とわかりやすいんじゃないでしょうか?

?:でつなぐことができるのは、comparatorが同値と判定して次の比較にfallbackしたいとき、ちょうど0を返すことを利用したものです。1と-1はtruthy、0はfalsyなので、0のときだけ?:の後ろが評価されるわけです。

Perlなどではorで繋ぐことができますが、PHPのOR演算では結果がBoolになるので、1と-1が両方trueにキャストされてうまく動きません。PHPではこの場合、or||ではなく?:を使いましょう。

これならば複雑なソート条件でも読みやすいですよね!
降順にしたいなら$a$bをひっくり返せばよいです。

usort($people, function($a, $b){
    return strlen($a->name) <=> strlen($b->name)
        ?: $a->name <=> $b->name
        ?: $b->birthday <=> $a->birthday
        ?: $a->height <=> $b->height;
});

「nameの長さ、長さが同じならnameをアルファベット順で、nameが全く同じなら誕生日降順で、誕生日まで一緒なら身長でソートする」
適当に作った例なので意味はよく分かりませんが、これだけ複雑でもifの入れ子を作ることもなく、?:でつないでいくだけで書けています。後ろにいくらでも繋ぐことができます。

宇宙船演算子によく似た関数

ところで、宇宙船演算子とよく似た関数が既に標準関数にいくつか用意されていました。これらの関数も正の値、0、負の値の三種類を結果として返します。

strcmp, strcasecmp
文字列を単純にアルファベット順で比較する。数値っぽい文字列でも特別な対応をせず、純粋に比較。strcmpは大文字小文字を考慮し、strcasecmpは大文字小文字を無視する。

strnatcmp, strnatcasecmp
文字列を自然順で比較する。数字っぽいところがあれば数字の大小を加味する。caseつきは大文字小文字無視。例えばa9a10は、strcmpでは「a10の方が小さい」と判定されるが、strnatcmpなら「a9の方が小さい」と判定される。

version_compare
1.2.123のようなソフトウェアバージョンの文字列を比較する。細かい挙動(alpha, RCなどの大小)はPHP自体のバージョン番号命名規則に従って大小が判定される。

これらの関数も宇宙船演算子と組み合わせて使えます。つまり、sort関数にあるような文字列順や自然順のオプションはusort関数でも再現することができます。

// nameはSORT_NATURAL、降順に比較する。birthdayは普通に比較
usort($people, function($a, $b){
    return strnatcmp($b->name, $a->name)
        ?: $a->birthday <=> $b->birthday;
});

「どちらが大きいのか」について

cmp系に比べて、宇宙船演算子は普通の比較演算を行うので高機能です。一旦確認しておきましょう。
intやfloatの比較は直感的ですね。型が混ざってもよしなに比較してくれます。

var_dump(1 < 5); // true
var_dump(1 < 5.1); // true

stringの場合は基本的にアルファベット順で比較されます。

var_dump('aaa' < 'aab'); // true
var_dump('Aaa' < 'aaa'); // true

ただ、intやfloatとの比較では数字として解釈して比較しようとします。また、文字列同士であっても数値のような形式の場合だけ挙動が違うので注意が必要です。アルファベットとして比較するならば、strcmp()やstrcasecmp()を使った方がわかりやすいでしょう。
この辺の挙動は @hnw さんの記事がわかりやすいですね。

PHPのsort関数は相当おかしい - hnwの日記

さて、配列やオブジェクトについても、PHPの比較演算子は結構がんばって比較をしてくれます。

配列の場合、まず要素数を比較し、同じであれば前から順番に要素を比較します。

$a = [1, 'aaa'];
$b = [2, 'bbb'];
var_dump($a < $b); // true

$b = [0, 'bbb'];
var_dump($a < $b); // false

オブジェクトの場合、まずクラスが違うと比較不能として常にfalseが返ります。
クラスが同じであれば、プロパティ同士を一つずつ比較して大小関係を判定してくれます。プロパティの比較順は宣言順です。直感的な挙動をしていると思います。結構賢いじゃん。

PHPは(extensionでいじらない限りは)演算子オーバーロードがないので、この挙動が違うオブジェクトは基本的にありません。
オーバーロードされている例としては組み込みのDateTimeクラスがあり、タイムゾーンを加味した上で日時の大小を比較してくれたりします。

複雑なソート その2

この配列同士の比較の性質を活用すると、配列をその場で作って宇宙船演算子に食わせるような書き方もできます。

usort($people, function($a, $b){
    return [$a->name, $a->birthday] <=> [$b->name, $b->birthday];
});

?:で繋げた場合よりも短いですね。aとbを直接比較するのに比べると、比較順や比較要素を選べるので、カスタマイズできています。ただ、自然順を混ぜたりはできないので、個別に比較して?:でつなぐ方が汎用性が高くなります。

複雑なソートその3 array_multisort

ところで、昔ながらのPHPで複数条件を使った複雑なソートを行う際にお世話になっていたのがarray_multisort()という可変引数を取る関数です。

array_multisort($arr1, $arr2, $arr3, ...);

PHP: array_multisort - Manual

この挙動は若干説明しづらいです。

与える配列は、すべて同じ要素数である必要があります。

まず$arr1を使ってソートします。このとき、$arr2, $arr3, ...などの後続の配列も合わせて順序を入れ替えていきます。
ソートの最中、$arr1に同じ要素があって順序に困った場合は$arr2の同じ位置にある要素を使って順番を決めます。
同じように$arr1, $arr2がともに同じ要素で順序に困った場合は$arr3を使って順番を決めます。

最終的に、array_multisortの最後に指定した配列が、いい感じでソートされて終わります。

今までやってきたusortでのソート(name, birthdayの基準でソート)を、array_multisortでやるとしたらこんな感じになるでしょう。

array_multisort(
    array_column($people, 'name'),
    array_column($people, 'birthday'),
    $people
);

//ソート基準抽出に複雑な処理が必要であれば前もってforeachやarray_mapで作る

ソートの基準になる要素だけ抜き出した配列を作成しておいて、それらをmultisortにソートさせ、引きずる形で元の$peopleも一緒にソートしてもらいます。

ただですね…、このインターフェース、分かりにくくないでしょうか? 事前準備として色々と配列を用意する必要もあり、特段効率がいいわけでもありません。インターフェース的にusortと組み合わせることもできません。シュワルツ変換相当のことをやってくれるのは便利ですが…。

複数条件によるソートであれば、usortを使ってしまう方が読みやすいと思います。

安定ソート化

PHPの組み込みソート関数はすべて安定性が保証されていません。内部的にもquicksortで実装されているので、comparatorが0を返した場合、順番が入れ替わってしまうことがあります。
ちなみにPHP7では要素数が少ない場合に挿入ソートにアルゴリズムが切り替わるようになっているみたいなので、安定ソートになっていることもあります。

試すためにわざとらしい配列を用意します。

$people = [
    new Person('Tanaka', date_create('1990-01-01'), 165),
    new Person('Tanaka', date_create('1990-01-02'), 165),
    new Person('Tanaka', date_create('1990-01-03'), 165),
    new Person('Tanaka', date_create('1990-01-04'), 165),
    new Person('Tanaka', date_create('1990-01-05'), 165),
    new Person('Tanaka', date_create('1990-01-06'), 165),
    new Person('Tanaka', date_create('1990-01-07'), 165),
    new Person('Tanaka', date_create('1990-01-08'), 165), 
    new Person('Tanaka', date_create('1990-01-09'), 165), 
    new Person('Tanaka', date_create('1990-01-10'), 165), 
    new Person('Tanaka', date_create('1990-01-11'), 165), 
    new Person('Tanaka', date_create('1990-01-12'), 165), 
    new Person('Tanaka', date_create('1990-01-13'), 165), 
    new Person('Tanaka', date_create('1990-01-14'), 165),  
    new Person('Tanaka', date_create('1990-01-15'), 165),   
    new Person('Tanaka', date_create('1990-01-16'), 165),
    new Person('Tanaka', date_create('1990-01-17'), 165), 
];

これをnameだけでソートしてみましょう。全部Tanakaさんなのでそのままでもいいはずですが、、

usort($people, function($a, $b){
    return $a->name <=> $b->name;
});

echo implode(PHP_EOL, $people);
/*
Tanaka, 1990-01-01, 165
Tanaka, 1990-01-10, 165
Tanaka, 1990-01-16, 165
Tanaka, 1990-01-15, 165
Tanaka, 1990-01-14, 165
Tanaka, 1990-01-13, 165
Tanaka, 1990-01-12, 165
Tanaka, 1990-01-11, 165
Tanaka, 1990-01-09, 165
Tanaka, 1990-01-02, 165
Tanaka, 1990-01-08, 165
Tanaka, 1990-01-07, 165
Tanaka, 1990-01-06, 165
Tanaka, 1990-01-05, 165
Tanaka, 1990-01-04, 165
Tanaka, 1990-01-03, 165
Tanaka, 1990-01-17, 165
*/

何だか順番が入れ替わってしまっていますね。

連番を振るなどして各要素を一意にし、comparatorが決して0を返すことがないようにすれば、安定ソートにできます。
こちらは逆にmultisortを使った方が簡単です。ソート基準の最後に連番の配列をつけてあげればOK。連番配列はrange関数で簡単に作れますね。

array_multisort(
    array_column($people, 'name'),
    range(1, count($people)),
    $people
);

usortでやるのは若干面倒になります。各要素を拡張して、連番を組み込むような操作が必要になります。
要素自体を改変するという手抜きをするとこんな感じ。

//準備
foreach ($people as $i => $p) {
    $p->no = $i;
}

usort($people, function($a, $b){
    return $a->name <=> $b->name
        ?: $a->no <=> $b->no;
});

//掃除
foreach ($people as $p) {
    unset($p->no);
}

要素を改変できない場合は、[[1, 元の要素], [2, 元の要素], ...]みたいな配列の配列を作ればよいでしょう。

まとめ

  • PHPでも複雑なソートが書きやすくなってる。
    • (ひと昔前は無名関数もつらかったし、?:も無かったから、usortでこんな簡潔さは出せなかった)
  • シュワルツ変換や安定ソート化が必要な場合はarray_multisortの方が今でも得意。適宜使い分けを。

まあ、PHPのユースケースだとSQL側でソートしてしまう方が多いと思うので、sort関数を使うことは少ないかもしれませんが、できるに越したことはないですね。

ちなみに、<=>は冒頭の例のように関数としてなら実装できるので、PHP7以前でもcompare関数を一個作っておくと似たような書き方が可能になりますよ。

この投稿は PHP Advent Calendar 20154日目の記事です。