この記事は
PHPで高速オシャレな配列操作を求めて
にインスパイアされたものです。
こんにちは。
皆さん今日も生array使ってますか。私も使ってます。今回はarray的なデータ構造をよりよく扱いたいというテーマで、解決したかった問題についてつらつらと書いていきたいと思います。
結果
https://packagist.org/packages/oubakiou/phpp
https://github.com/oubakiou/phpp
課題1.arrayがArrayオブジェクトじゃない
PHPでも標準で、あるいはPECL等で、array的なデータ構造に対応したクラスが提供されています。
http://php.net/manual/ja/book.ds.php
http://php.net/manual/ja/class.arrayobject.php
http://php.net/manual/ja/spl.datastructures.php
また、ただオブジェクトでありさえすればいいのであれば
(object)[1, 2, 3]
でstdClassへ変換されます。
限界
後述する何れかの問題が解決されないので、クラスを自作したくなるかもしれません。しかしその場合はarrayリテラルによる記述やarrayなら出来る演算子による操作は出来ません。また性能面でもプリミティブなarrayに比べると不利になる事が多いでしょう。
課題2.arrayがミュータブル一択
実行パフォーマンスの話は棚上げにして、基本的にはミュータブルなものを読むよりもイミュータブルなものを読む方が楽です。そして自作のCollectionクラスをイミュータブルな実装にする事は可能です。
限界
PHPにはまだ再代入不可変数が無いので片手落ち感はあります。導入されると良いですね。
課題3.ジェネリクスが欲しい
本題。というわけで自作の道を歩むわけですが
array内要素の型を縛れるジェネリクス的なやつ欲しい、というのを思う人は多いと思います。私もそう思うので実装しました。assertでバリデーションが実行されるので、PHP7以降であれば本番環境では無効化しバリデーションの実行コストをスキップする事が出来ます。
$collection = Arr::ofInts([1, 2, 3]);
$collection = Arr::ofUsers([new User(['name' => 'hoge'])]);
$collection = Arr::ofInts([1, 'hoge', 3]);
// throw TypeError
$collection = Arr::ofUsers([new User(['name' => 'hoge']), 2, 3]);
// throw TypeError
限界
あくまで実行時の動的バリデーションとして実装されています。なので開発時に想定していない実行パスによって実行時バリデーションが失敗する可能性があります。またmapのような操作をするとバリデーション情報が失われるので人力で注入し直す必要があります。(mapへ渡すクロージャの返り値の型によって自動的に解決されたりはしない)
ここらへんはPHPとPhanでPythonとmypyのようなジェネリクスがサポートされれば生arrayのままでも綺麗に解決するのかもしれません。また、ちょうどアドベントカレンダーの
で知った話ですがPHPStormであればそういう感じの事が既に出来るみたいです。エディタ選択の自由がある大人数組織だと少し心許ないかもしれませんが、十分な気はします。
なおofIntsのようなstatic factoryメソッドを設けているのはIDEによる入力補完のためです。JavaがIDEとの組み合わせである種のAlt Javaを実現してるように、PHPであってもIDE(やVim、Emacsプラグイン)の力を借りられる所は借り、半自動入力してもらう事で人の手による温かみのある入力ミスを防いでいきたい。
のですが、例えばAnimalのようなユーザ定義型に対しては動的にバリデーターを生成しているので入力補完が効きません。そこを本気で気にするなら
class AnimalArr extends Arr
{
public static function of(array $animals)
{
$validator = function ($elem) {return instanceof Animal;};
return new self($animals, '', $validator);
}
}
new AnimalArr::of($animals);
のようなサブクラスが必要になります。
課題4.array_mapで引数の順番を毎回間違えてIDEに怒られる、一部の配列関数に副作用があって使いにくい、とか
return array_sum(
array_filter(
array_map(
function ($v) {
return $v ** 2;
},
array_filter($range, function ($v) {
return $v % 2 === 0;
})
),
function ($v) {
return $v > 20;
}
)
);
より
return Arr::ofInts($range)
->filter(function ($v) {
return $v % 2 === 0;
})
->map(function ($v) {
return $v ** 2;
})
->filter(function ($v) {
return $v > 20;
})
->reduceLeft(function ($a, $b) {
return $a += $b;
});
の方がいくらか読みやすいですね。前者は中間変数を挟む事でもう少し読みやすくなるので、公平な比較ではないかもしれません。
限界
functionキーワードが書くにも読むにも鬱陶しいというのも、また多くの人が思う所だと思いますが、これは妥協しています。あとreturnキーワードも鬱陶しい。PHP二大書き忘れるキーワードはfunctionとreturnだと思います。
functionキーワードについて妥協していない例
array_mapにありがとう、さよなら
課題5.よりパワフルな関数(メソッド)が欲しい
Underbar.phpのようなUnderscore.jsライクな先駆者やLINQライクな先駆者もいますが、Scalaを勉強中なので
http://seratch.hatenablog.jp/entry/20110429/1304072372
https://github.com/oubakiou/phpp/blob/master/src/Arr.php#L213
をとりあえず実装しました。
- append(self $that): self
- head()
- tail(): self
- init(): self
- last()
- foldLeft(callable $op, $z)
- foldRight(callable $op, $z)
- reduceLeft(callable $op)
- reduceRight(callable $op)
- foreach(callable $op): void
- filter(callable $p = null): self
- filterNot(callable $p): self
- drop(int $n): self
- dropWhile(callable $p): self
- take(int $n): self
- takeWhile(callable $p): self
- map(callable $f, string $builtinValidatorName = '', callable $validator = null): self
- flatMap(callable $f, string $builtinValidatorName = '', callable $validator = null): self
- flatten(): self
- collect(callable $p, callable $f): self
- splitAt(int $n): self
- slice(int $from, int $untile): self
- partition(callable $p): self
- span(callable $p): self
- groupBy(callable $f) :self
- unzip(): self
- find(callable $p)
- exists(callable $p): bool
- forall(callable $p): bool
- count(callable $p = null): int
- size(): int
- length(): int
- min(): int
- minFloat(): float
- max(): int
- maxFloat(): float
- mkString(string $string1, string $string2 = '', string $string3 = ''): string
- dropRight($n): self
- sameElements(self $that): bool
- zip(self $that): self
- zipWithIndex(): self
- apply($key)
- contains($elem): bool
- diff(self $that): self
- startsWith(self $that): bool
- endsWith(self $that): bool
- indexOf($elem): int
- isDefinedAt($key): bool
- indices(): self
- distinct(): self
- reverse(): self
- reverseMap(callable $f, string $builtinValidatorName = '', callable $validator = null)
- sorted(): self
- sortWith(callable $lt): self
- patch(int $from, self $that, int $replaced)
- updated(int $n, $elem): self
- cons($elem): self
- getOrElse($key, $default = null)
- keys(): self
- values(): self
限界
それっぽいというだけで、あんまり挙動を模倣していません。
実用性
とりあえずマイクロベンチマークを取ってみましょう。
ouba-no-MacBook-Air:phpp ouba$ vendor/bin/phpunit tests/ArrBenchTest.php
PHPUnit 5.7.3 by Sebastian Bergmann and contributors.
Runtime: PHP 7.1.0
Configuration: /Users/ouba/Desktop/phpp/phpunit.xml
. 1 / 1 (100%)
bench start
plain 0001 : 75 ms (usage 4MB)
array_function 0002 : 249 ms (usage 4MB)
Arr 0003 : 851 ms (usage 4MB)
bench end
Time: 2.43 seconds, Memory: 6.00MB
OK (1 test, 2 assertions)
plainの10%ぐらいの性能しか出てないですね。大きく息を吸って落ち着いて、本番環境を想定したassert無効のベンチをとってみます。
ouba-no-MacBook-Air:phpp ouba$ php -d zend.assertions=0 `which vendor/bin/phpunit` tests/ArrBenchTest.php
PHPUnit 5.7.3 by Sebastian Bergmann and contributors.
Runtime: PHP 7.1.0
Configuration: /Users/ouba/Desktop/phpp/phpunit.xml
. 1 / 1 (100%)
bench start
plain 0001 : 84 ms (usage 4MB)
array_function 0002 : 239 ms (usage 4MB)
Arr 0003 : 281 ms (usage 4MB)
bench end
Time: 1.22 seconds, Memory: 4.00MB
OK (1 test, 2 assertions)
意外と悪くないのでは?
※アンカリング
実用性の話にもどりますが、あまり性能面でシビアな要求がない箇所で、かつ、それによって本当にコードが読みやすくなるのであれば有りな気はします。ただしコード全体での統一性によるリーダビリティ向上と引き換えになるかもしれません。
また動的型付言語であってもリーダビリティが重要な場面において、関数の型宣言はかなり費用対効果が高いと思っていますが、このジェネリクスもどきに関してはちょっと微妙なラインかなと思ってます。
まとめ
多くの職業プログラマの日々における、主な相手であろうドメインの問題と向き合ったコードと、こういったライブラリコードとでは、書いていてまた違った面白さがあります。新しめのPHP機能について勉強になったりと色々と得るものも多いので、皆もさいきょうのはいれつを書いてみるといいんじゃないかなと思いました。(実戦投入は慎重に)