PHP 5.6と7の新機能を使った画期的バリデータの実装

  • 75
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

以前、private/protectedなプロパティを外部から読み込み可能にするを書いたとき、記事のコメントにて「コレクション(配列)内の要素の型を検証したいよー」「でもそうすると全要素を検証しなくちゃいけないから実行時のパフォーマンスの問題があるよね」って話をしました。

気付いてしまった

PHP: 関数の引数 - Manualを読んでた私は気付いてしまったのです。PHP 5.6で実装された機能を巧みに利用すればバリデータが実装可能なことに。

……そうです。可変長引数です。

PHPの可変長引数とは

PHP 5.6で追加された可変長引数は、以前の文法に...を追加することで0個以上の値をまとめて受け取れるようにしたものです。

function f($a, ...$b)
{
    var_dump($a, $b);
}

呼び出した結果は以下のようになる。

f("a")
// =>
// string(1) "a"
// array(0) {
// }

f("a", "b")
// =>
// string(1) "a"
// array(1) {
//   [0]=>
//   string(1) "b"
// }

f("a", "b", "c", "d")
// =>
// string(1) "a"
// array(3) {
//   [0]=>
//   string(1) "b"
//   [1]=>
//   string(1) "c"
//   [2]=>
//   string(1) "d"
// }

ご覧の通り、「一個以上」ではなく「0個以上」です。

可変個引数の受け渡し

何かの事情により、引数に受け渡すべき値を配列で受け取ったとき、愚直には以下のように書きますね。


$a = array_values($args);
$count = count($args);

if ($count === 1) {
    f($a[0]);
} elseif ($count === 2) {
    f($a[0], $a[1]);
} elseif ($count === 3) {
    f($a[0], $a[1], $a[2]);
} elseif ($count === 4) {
    f($a[0], $a[1], $a[2], $a[3]);
} elseif ($count === 5) {
    f($a[0], $a[1], $a[2], $a[3], $a[4]);
} elseif ($count === 6) {
    f($a[0], $a[1], $a[2], $a[3], $a[4], $a[5]);
}

いや、これはあんまりにも不便すぎる…

なのでふつうはcall_user_func_array()を使って、こう書きます。

call_user_func_array('f', $args);

さて、PHP 5.6以降では、さらに短く書けます。

f(...$args);

短い! べんり!

関数を呼び出すときに ... を使うと、 配列変数やTraversableクラスを引数リストに含めることができます。

しれっと、Traversableの名前が出てます。これが何を意味するかといふと、$argsの実装はArrayObjectとかでも良いのです。すごいじゃんPHP 5.6。

タイプヒント、改め型宣言

引数には、クラス名を指定してUserModel $userとかarray $usersとか書くことができます。では、可変長引数と組み合せると?

マニュアルには以下のようにあります。

...トークンの前に、 タイプヒント を付加することもできます。 タイプヒントがある場合、...が取り込むすべての引数はそのヒントに従わなければいけません。

なるほど! べんり! 超べんり!

つまり…?

これだけで「配列要素の型チェックができる」ってことです。

class User
{
}

$v = function (User ...$users) {};

$users = [new User("taro"), new User("jiro"), new User("saburo")];
$v(...$users);

検証に失敗する、つまり不正な要素が含まれてたらTypeErrorが投げられます。

$null_users = [new User("taro"), null, new User("saburo")];
$v(...$null_users);
// PHP Fatal error:  Uncaught TypeError: Argument 2 passed to {closure}() must be an instance of User, null given ...

任意の型を検証する

PHPで動的な函数生成といったら、上のようにfunction式を利用する方法がポピュラーですね。しかし今回のようにライブラリとしてタイプヒントに任意の型を指定したいと思ったとき、function式だけではタイプヒントを動的に変更することは不可能です。ただし、evalを除く。

……evalを除く? みなさん、ちょっとセキュアなモダンPHPに毒されすぎなんじゃないですか???

つまりcreate_function()の存在を思ひ出すのです。

警告 この関数は、内部的に eval() を実行しているので、 eval() と同様にセキュリティ上のリスクがあります。 さらに、パフォーマンスやメモリ使用効率の面でも問題があります。
PHP 5.3.0 以降を使っている場合は、この関数ではなく、ネイティブの 無名関数 を使うべきです。

コワイ! 正常なセキュリティ意識があれば絶対に近付きたくないやつです。

しかし、これなら…?

$f = create_function($type . ' ...$args', 'return true;');

これなら、ちょっとは大丈夫な気がする…? $typeにユーザー入力が入らないように注意すれば、まあ大丈夫そう。 (保障はできない!)

実装

BaguettePHP/collection-validatorを用意しました。

比較のために作ったクラスはみっつ。Eachってついてるクラスは配列の中身を一個一個チェックするやつ。

  • TypeHint.php: 可変長引数とタイプヒントを使った実装。PHP7必須。
  • EachIf.php: ループ内で自明な分岐を何回か繰り返すやつ
  • EachCallable.php: 自明な分岐を繰り返さないようにcallableオブジェクトをキャッシュするやつ

今回の記事の本題のTypeHintクラスの実装です。ほかのクラスはGitHubで読んでね。

TypeHint.php
<?php
declare(strict_types=1);
namespace Teto\CollectionValidator;

/**
 * @license WTFPL
 */
class TypeHint
{
    private static $functions = [];
    private $type;

    /**
     * @param  string   $type
     * @return TypeHint
     */
    public function __construct($type)
    {
        $this->type = ltrim($type, '\\');
    }

    /**
     * @return bool
     */
    public function validate($collection): bool
    {
        try {
            $is_valid = self::getValidateFunction($this->type)(...$collection);
        } catch (\TypeError $e) {
            $is_valid = false;
        }
        return $is_valid;
    }

    /**
     * @return \Closure
     */
    public static function getValidateFunction($type)
    {
        if (!isset(self::$functions[$type])) {
            self::$functions[$type] = create_function($type . ' ...$args', 'return true;');
        }
        return self::$functions[$type];
    }
}

さて、三種類のクラスのベンチマークをとってみよう。あと、テスト対象は「10要素の配列」と「1000要素の配列」にしておく。

% php --version
PHP 7.0.3 (cli) (built: Feb  6 2016 03:16:24) ( NTS )
Copyright (c) 1997-2016 The PHP Group
Zend Engine v3.0.0, Copyright (c) 1998-2016 Zend Technologies

% php ./vendor/bin/athletic -p ./tests/

Teto\CollectionValidator\StringBench
    Method Name             Iterations    Average Time      Ops/second
    ---------------------  ------------  --------------    -------------
    foreach_callable_10  : [     1,000] [0.0000009810925] [1,019,271.93196]
    foreach_if_10        : [     1,000] [0.0000008912086] [1,122,071.69609]
    typehint_10          : [     1,000] [0.0000005047321] [1,981,248.93718]
    foreach_callable_1000: [       100] [0.0000671696663] [   14,887.67259]
    foreach_if_1000      : [       100] [0.0000663876534] [   15,063.04184]
    typehint_1000        : [       100] [0.0000161123276] [   62,064.27937]

いいね! typehint速いよ! これなら勝てる! Average Timeは低いほど、Ops/secondは高いほど性能が良いことになる。

……しかし、これは型チェックの対象が全て妥当な場合だ。では、不正な要素が入った配列では?

% php --version
PHP 7.0.3 (cli) (built: Feb  6 2016 03:16:24) ( NTS )
Copyright (c) 1997-2016 The PHP Group
Zend Engine v3.0.0, Copyright (c) 1998-2016 Zend Technologies

% php ./vendor/bin/athletic -p ./tests/

Teto\CollectionValidator\StringFailBench
    Method Name             Iterations    Average Time      Ops/second
    ---------------------  ------------  --------------    -------------
    foreach_callable_10  : [     1,000] [0.0000004541874] [2,201,734.38320]
    foreach_if_10        : [     1,000] [0.0000001759529] [5,683,338.75339]
    typehint_10          : [     1,000] [0.0000481393337] [   20,773.03366]
    foreach_callable_1000: [       100] [0.0000003933907] [2,542,002.42424]
    foreach_if_1000      : [       100] [0.0000002026558] [4,934,475.29412]
    typehint_1000        : [       100] [0.0426092553139] [       23.46908]

なんだこの結果は… たまげたなぁ。

アルゴリズム上、早期に失敗した方が良いのでforeachが超高速化するのはわかる。しかしてtypehint_10は100倍、typehint_1000に至っては3000倍近くにもなる差がついてしまってる。

宿題

  • typehintが不正な値を検出したときの性能劣化は何に起因するか考へてみよう
  • このライブラリを、あなたの業務プロジェクトで導入することは可能か、懸念事項を挙げてみよう

追記

PHP 5.6と7の新機能って言った割に、PHP 7の説明をしてないや。まあ、いっか。