配列、連想配列といったデータの集まり - 集合に対する操作は、日々のプログラミングにおいて頻繁に記述するコードの1つです。その一方で、旧来の愚直なループを使った集合操作はコードを複雑にする大きな要因となります。これに対処するために、Microsoftは統合言語クエリ:LINQ(Language-Integrated Query)を開発しました。LINQ to Objectsのページには、LINQを使うメリットとして次のように説明があります。
本質的に、LINQ to Objects は、コレクションを扱うための新しい方法です。 従来の方法では、複雑な foreach ループを記述して、コレクションからどのようにデータを取得するかを指定する必要がありました。 LINQ を使用する場合は、
何を取得するかを表す宣言コードを記述します。
また、LINQ クエリには、従来の foreach ループと比べて次の 3 つの重要な利点があります。
簡潔で読みやすい (特に複数の条件をフィルター処理する場合)。
強力なフィルター処理、並べ替え、およびグループ化機能を最小限のアプリケーション コードで実現できる。
ほとんど変更せずに、他のデータ ソースに移植できる。
一般に、データに対して実行する操作が複雑なほど、従来の反復処理の代わりに LINQ を使用することの意義が高まります。
LINQ to Objects
(強調は引用者による)
PHPにはarray_filter()やarray_map()、array_reduce()といった関数がPHP4の頃から用意されており、集合に対する操作を宣言的にコーディングすること自体は部分的に可能でした。これらの関数にはインターフェイスにバラつきがあり、なかなか使いづらいという難点もありました。
ライブラリGinqを使うと、LINQ to Objectsの提供するメソッド群(標準クエリ演算子)に近いAPIで操作できます。Ginqを使うとどのようなコードになるのか、いくつかの例で紹介します。
コード比較
以下のコードはGitHubで公開しています。
まずはbleis-tiftさんの記事よくあるコーディングパターンと LINQ to Objects の対応付けにある例のいくつかをPHP、PHPのarray_*関数、Ginqを使ったコードで比較してみます。それぞれのコード片で2つまたは3つのメソッドを次のように作成しています。
- normal():素のPHPで書いた例(foreachでループする)
- normal2():(可能であれば)PHPのarray_*系関数を使った例
- ginq():Ginqを使った例
射影: select その1
最初は、(数値)配列のすべての要素を2倍する例です。要素の変換処理にあたり、Ginqではselect()を使います。この内容であれば、array_map()でもGinqと同じように書けます。
select()には「セレクタ(selector)」をクロージャで指定します。
/**
* すべての要素を2倍する
*/
class DoubleAll
{
/**
* @param $data
* @return array
*/
public function normal($data)
{
$result = [];
for ($i = 0; $i < count($data); $i++) {
$result[$i] = $data[$i] * 2;
}
return $result;
}
/**
* @param $data
* @return array
*/
public function normal2($data)
{
return array_map(function ($v) {
return $v * 2;
}, $data);
}
/**
* @param Ginq $data
* @return mixed
*/
public function ginq($data)
{
return $data->select(function ($v) {
return $v * 2;
});
}
}
次のようなテストコードを用意して確認します。Ginqについては例を分かりやすくするために呼び出し側でGinqオブジェクトの準備と、戻り値の配列化を行っています。
class DoubleAllTest extends \PHPUnit_Framework_TestCase
{
/**
* @var DoubleAll
*/
private $SUT;
/**
* @test
*/
public function DoubleAll()
{
$data = [1, 2, 4, 5, 10];
$expected = [2, 4, 8, 10, 20];
$this->assertThat($this->SUT->normal($data), $this->equalTo($expected));
$this->assertThat($this->SUT->normal2($data), $this->equalTo($expected));
$this->assertThat($this->SUT->ginq(Ginq::from($data))->toArray(), $this->equalTo($expected));
}
protected function setUp()
{
$this->SUT = new DoubleAll();
}
}
射影: select その2
array_map()では同時に処理する配列を複数指定できるので、これを使って配列のインデックスをコールバックに渡せます。次の例でこのパターンを使っています。
/**
* インデックスが偶数の要素のみ2倍する
*/
class DoubleIfEvenIndex
{
public function normal($data)
{
$result = [];
for ($i = 0; $i < count($data); $i++) {
if ($i % 2 == 0) {
$result[$i] = $data[$i] * 2;
} else {
$result[$i] = $data[$i];
}
}
return $result;
}
public function normal2($data)
{
return array_map(function ($v, $k) {
return ($k % 2 == 0) ? $v * 2 : $v;
}, $data, array_keys($data));
}
public function ginq($data)
{
return $data->select(function ($v, $k) {
return ($k % 2 == 0) ? $v * 2 : $v;
});
}
}
選択: where その1
次は要素を選択する例です。Ginqではwhere()を使います。PHPの関数としてはarray_filter()が使えます。
where()には「述語(predicate)」をクロージャで指定します。
/**
* 奇数のみを取り出す
*/
class FilterOdd
{
public function normal($data)
{
$result = [];
for ($i = 0; $i < count($data); $i++) {
if ($data[$i] % 2 != 0) {
$result[] = $data[$i];
}
}
return $result;
}
public function normal2($data)
{
return array_filter($data, function($v) {
return $v % 2 == 1;
});
}
public function ginq($data)
{
return $data->where(function ($v) {
return $v % 2 == 1;
});
}
}
選択: where その2
前の例くらいまでならPHPのarray_map()、array_filter()でも記述量はさほど変わらないように見えます。しかし次の例の場合、array_map()とは異なり、array_filter()のコールバックに配列のインデックスを渡せないので、困ったことになります。Ginqであればwhere()のコールバックでインデックスを評価するだけです。
(PHPの関数だけで何とかする方法に興味のある方は参考のリンク先を参照してください。)
/**
* インデックスが奇数の要素のみを取り出す
*/
class FilterOddIndexElem
{
public function normal($data)
{
$result = [];
for ($i = 0; $i < count($data); $i++) {
if ($i % 2 != 0) {
$result[] = $data[$i];
}
}
return $result;
}
/** array_filter() では素直には書けない... ★参考★ */
public function ginq($data)
{
return $data->where(function ($v, $k) {
return $k % 2 == 1;
});
}
}
量指定子: all
all()は、集合のすべての要素が条件を満たすかどうかを調べるのに使います。
use Stringy\StaticStringy as String;
/**
* すべての要素が .txt で終わるかどうか調べる
*/
class All
{
public function normal($data)
{
foreach ($data as $element) {
$endStr = mb_substr($element, mb_strlen($element) - 4);
if ($endStr != '.txt') {
return false;
}
}
return true;
}
public function normal2($data)
{
return array_reduce($data, function ($temp, $v) {
return $temp & String::endsWith($v, '.txt');
}, true);
}
public function ginq($data)
{
return $data->all(function ($v) {
return String::endsWith($v, '.txt');
});
}
}
量指定子: any
any()はall()と似ていますが、集合の要素のうちどれか1つでも条件を満たすかどうかを調べるのに使います。
use Stringy\StaticStringy as String;
/**
* .txt で終わる要素が1つでもあるかどうか調べる
*/
class Any
{
public function normal($data)
{
foreach ($data as $element) {
$endStr = mb_substr($element, mb_strlen($element) - 4);
if ($endStr == '.txt') {
return true;
}
}
return false;
}
public function normal2($data)
{
// 計算量の無駄はある・・・
return array_reduce($data, function ($temp, $v) {
return $temp | String::endsWith($v, '.txt');
}, false);
}
public function ginq($data)
{
return $data->any(function ($v) {
return String::endsWith($v, '.txt');
});
}
}
量指定子: contains
contains()は、集合に指定した要素が含まれるかどうかを調べるのに使います。
/**
* 要素に hoge を含むかどうか調べる
*/
class Contains
{
public function normal($data)
{
return in_array('hoge', $data);
}
public function ginq($data)
{
return $data->contains('hoge');
}
}
集計操作: count
集計も行えます。集合内で特定の条件にマッチする要素の数を調べる場合にcount()を使います。このような集計処理ではarray_reduce()関数も使えます。
/**
* 偶数の要素の個数を調べる
*/
class Count
{
public function normal($data)
{
$result = 0;
foreach ($data as $element) {
if ($element % 2 == 0) {
$result++;
}
}
return $result;
}
public function normal2($data)
{
return array_reduce($data, function ($temp, $v) {
return ($v % 2 == 0) ? $temp + 1: $temp;
}, 0);
}
public function ginq($data)
{
return $data->count(function ($v) {
return $v % 2 == 0;
});
}
}
パーティショニング: skip
集合から部分集合を取り出します。skipは、順に並んだ要素の先頭部分を捨てて残り部分の集合を返します。Ginqではdrop()になっています。
/**
* 先頭10要素を捨てる
*/
class Skip
{
public function normal($data)
{
return array_slice($data, 10);
}
public function ginq($data)
{
return $data->drop(10);
}
}
パーティショニング: skipWhile
skipWhileでは、集合の先頭から、条件を満たさない要素をすべて捨てます。GinqではdropWhile()になっています。
/**
* 先頭から条件を満たす要素以前の要素を捨てる
* (空文字列またはnullなら捨てる)
*/
class SkipWhile
{
public function normal($data)
{
for ($i = 0; $i < count($data); $i++) {
if (!empty($data[$i])) {
break;
}
}
return array_slice($data, $i);
}
public function ginq($data)
{
return $data->dropWhile(function ($v) {
return empty($v);
});
}
}
パーティショニング: take
take()はskipの逆で、先頭から指定個数の要素だけ取り出します。
/**
* 先頭10要素を取得
*/
class Take
{
public function normal($data)
{
return array_slice($data, 0, 10);
}
public function ginq($data)
{
return $data->take(10);
}
}
パーティショニング: takeWhile
takeWhile()では、集合の先頭から条件を満たす要素だけを取り出します。
/**
* 先頭からnullまでの要素を取得
*/
class TakeWhile
{
public function normal($data)
{
for ($i = 0; $i < count($data); $i++) {
if ($data[$i] === null) {
break;
}
}
return array_slice($data, 0, $i);
}
public function ginq($data)
{
return $data->takeWhile(function ($v) {
return $v !== null;
});
}
}
要素操作: first
集合の中で特定の条件を満たす最初の要素を取得するのにfirst()を使います。
/**
* 空でもnullでもない最初の要素
*/
class First
{
public function normal($data)
{
foreach ($data as $value) {
if (!empty($value)) {
return $value;
}
}
}
public function ginq($data)
{
return $data->first(function ($v) {
return !empty($v);
});
}
}
上記以外にも様々な機能があります。何らかのキーでグループ化するのはループ処理ではちょっと大変ですが、GinqにはgroupBy()やtoLookup()といったメソッドが用意されていて手軽にできます。
グループ化: toLookup その1
以降はGinqのコード例のみ示します。複数の従業員情報を部署ごとにグループ化し、部署名をキーとした連想配列を取得しています。
class Emploee
{
public $name;
public $department;
public $salary;
function __construct($name, $department, $salary)
{
$this->name = $name;
$this->department = $department;
$this->salary = $salary;
}
}
class ToLookupTest extends TestCase
{
/**
* 部署でグループ
* @test
*/
public function ToLookup()
{
$employees = [];
$employees[] = new Emploee('あいう', '部署1', 1000);
$employees[] = new Emploee('かきく', '部署2', 1200);
$employees[] = new Emploee('さしす', '部署1', 800);
$employees[] = new Emploee('たちつ', '部署3', 900);
$employees[] = new Emploee('なにぬ', '部署2', 1000);
$employees[] = new Emploee('はひふ', '部署4', 700);
$employees[] = new Emploee('まみむ', '部署2', 900);
$data = Ginq::from($employees);
$result = $data->toLookup(
function ($v) {return $v->department;},
function ($v) {return [$v->name, $v->salary];}
)->toArrayRec();
$this->assertThat($result, $this->equalTo([
'部署1' => [
['あいう', 1000,],
['さしす', 800,],
],
'部署2' => [
['かきく', 1200,],
['なにぬ', 1000,],
['まみむ', 900,],
],
'部署3' => [
['たちつ', 900,],
],
'部署4' => [
['はひふ', 700,],
],
]));
}
}
グループ化: toLookup その2
同じデータで、時給をキーにグループ化しています。取得後の要素を並べ替えるためのorderBy()、thenBy()も指定しています。
class Emploee
{
public $name;
public $department;
public $salary;
function __construct($name, $department, $salary)
{
$this->name = $name;
$this->department = $department;
$this->salary = $salary;
}
}
class ToLookupTest extends TestCase
{
/**
* 時給でグループ
* @test
*/
public function ToLookup2()
{
$employees = [];
$employees[] = new Emploee('あいう', '部署1', 1000);
$employees[] = new Emploee('かきく', '部署2', 1200);
$employees[] = new Emploee('さしす', '部署1', 800);
$employees[] = new Emploee('たちつ', '部署3', 900);
$employees[] = new Emploee('なにぬ', '部署2', 1000);
$employees[] = new Emploee('はひふ', '部署4', 700);
$employees[] = new Emploee('まみむ', '部署2', 900);
$data = Ginq::from($employees);
$result = $data
->orderBy('salary')
->thenBy('name')
->toLookup(
function ($v) {return $v->salary;},
function ($v) {return [$v->name];}
)->toArrayRec();
$this->assertThat($result, $this->equalTo([
700 => [
['はひふ',],
],
800 => [
['さしす',],
],
900 => [
['たちつ',],
['まみむ',],
],
1000 => [
['あいう',],
['なにぬ',],
],
1200 => [
['かきく',],
],
]));
}
}
グループ化: toLookup その3
同じデータで、処理を2段階で行って取得後の連想配列を加工しています(2段階目はmap()なので特別な機能というわけではありませんが)。
class Emploee
{
public $name;
public $department;
public $salary;
function __construct($name, $department, $salary)
{
$this->name = $name;
$this->department = $department;
$this->salary = $salary;
}
}
class ToLookupTest extends TestCase
{
/**
* 時給でグループ
* @test
*/
public function ToLookup2()
{
$employees = [];
$employees[] = new Emploee('あいう', '部署1', 1000);
$employees[] = new Emploee('かきく', '部署2', 1200);
$employees[] = new Emploee('さしす', '部署1', 800);
$employees[] = new Emploee('たちつ', '部署3', 900);
$employees[] = new Emploee('なにぬ', '部署2', 1000);
$employees[] = new Emploee('はひふ', '部署4', 700);
$employees[] = new Emploee('まみむ', '部署2', 900);
$data = Ginq::from($employees);
$result = $data
->orderBy('salary')
->thenBy('name')
->toLookup(
function ($v) {return $v->salary;},
function ($v) {return ['name' => $v->name];}
)
->toArrayRec();
$data2 = Ginq::from($result);
$result2 = $data2->map(
function ($v) {
$temp = [];
$temp['list'] = $v;
$temp['count'] = count($temp['list']);
return $temp;
}
)
->toArrayRec();
$this->assertThat($result2, $this->equalTo([
700 => [
'list' => [
[ 'name' => 'はひふ',],
],
'count' => 1,
],
800 => [
'list' => [
['name' => 'さしす',],
],
'count' => 1,
],
900 => [
'list' =>
[
['name' => 'たちつ',],
['name' => 'まみむ',],
],
'count' => 2,
],
1000 => [
'list' =>
[
['name' => 'あいう',],
['name' => 'なにぬ',],
],
'count' => 2,
],
1200 => [
'list' =>
[
['name' => 'かきく',],
],
'count' => 1,
],
]));
}
}
まとめ
Ginqのようなライブラリを導入することで、集合操作に対する統一されたAPIを、PHPにおいてもインフラとして使えるようになります。
また、宣言的に定義された操作は高階関数なので、汎用的に使ったり、合成することもできます。コレクションに対する条件を部品として切り出すのに使うこともできます。このように作られた部品はドメインの知識を反映した述語となり、ユビキタス言語で集合を操作するよう端的にコードを記述できるようになります。
forやforeachから卒業するために、まずはここで紹介したような簡単な例から練習してみるとよいでしょう。
参考
- Igor Wiedler氏のブログFunctional Libraryシリーズ
- PHPにおける関数型プログラミングを考える Sarabande.jpさんの記事いくつか
LINQ to Objects 標準クエリ演算子のマニュアル