コードをまとめる技術としてのイテレータとジェネレータ

  • 358
    Like
  • 3
    Comment
More than 1 year has passed since last update.

ジェネレータが5.5から入ったことで完全に空気と化した(?)PHPのイテレータを、ちょっと違う面からまとめたいと思います。

コードをまとめるということ

Don't Repeat Yourself(DRY)という言葉があります。達人プログラマーという本に出てくる言葉です。

信頼性の高いソフトウェアを開発して、開発そのものを簡単に理解したりメンテナンスできるようにする唯一の方法は、DRY原則に従うことです。
「すべての知識はシステム内において、単一、かつ明確な、そして信頼できる表現になっていなければならない。」

(p. 27)

端的に言えば「同じことを二度書いてはいけない」ということですね。この原則を当てはめなくてもいい例外のパターンもいくつかあるのですが。。

コードにおいて「同じことを二度書いてはいけない」を忠実に守ろうとすると、同じコードを何度も書きたくなったら、何らかの方法でそのコードをまとめる必要があります。

サブルーチン / 関数

サブルーチン化/関数化はコードをまとめる技術として最も基本的なものです。
例えば、libcurlで3つのURLに存在するHTMLを取得するコードを書いてみます。

<?php

$ch1 = curl_init('http://www.yahoo.co.jp/');
curl_setopt($ch1, CURLOPT_RETURNTRANSFER, true);
$responseHtml1 = curl_exec($ch1);
curl_close($ch1);

$ch2 = curl_init('http://www.google.co.jp/');
curl_setopt($ch1, CURLOPT_RETURNTRANSFER, true);
$responseHtml2 = curl_exec($ch2);
curl_close($ch2);

$ch3 = curl_init('http://www.facebook.com/');
curl_setopt($ch3, CURLOPT_RETURNTRANSFER, true);
$responseHtml3 = curl_exec($ch3);
curl_close($ch3);

はい、不毛なコードですね。関数が一つあればすっきりします。

<?php
function getHtml($url) {
  $ch = curl_init($url);
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  return curl_exec($ch);
}

$responseHtml1 = getHtml('http://www.yahoo.co.jp/');
$responseHtml2 = getHtml('http://www.google.co.jp/');
$responseHtml3 = getHtml('http://www.facebook.com/');

似た部分が関数としてまとまったので、DRYになりましたね。
…当たり前すぎるって? いえいえ、関数はDRYなコードを書くための最も基本的な道具ですので、おろそかにすることはできません。 

高階関数

しかし、関数ではまとめられないコードというのも存在します。以下の例を見てください。

<?php

try {
  doSomething1();
} catch (Exception $e) {
  handleException($e);
}

//...
try {
  doSomething2();
  doSomething3();
} catch (Exception $e) {
  handleException($e);
}

//...
try {
  doSomething3();
  doSomething4();
} catch (Exception $e) {
  handleException($e);
}

よく似たtry~catchブロックが何度も登場しています。しかしブロック内部の処理が異なるので、先ほどのように単純に関数化することはできません。

単純な関数化というのは、連続した複数の文を抜き出し、まとめることを意味します。
iterator1.png

今回のケースだと「ブロックの外側」だけが共通で、中の部分だけ再利用性がない、という図式です。
コードを書いていればよく遭遇します。
iterator2.png

ではどう対処するかというと、可変箇所自体を引数にして、外から突っ込めるようにすればいいのです。
こういう「関数を引数に取る関数」のことを高階関数と言います。高階関数にすると、真ん中だけ抜け落ちた関数として「ブロックの外側」をまとめることができます。

プログラミング言語に無名関数を作る文法があると、高階関数は非常に利用しやすくなります。

function handleError(callable $func) {
  try {
    $func(); //関数を外から突っ込んで実行
  } catch (Exception $e) {
    handleException($e);
  }
}

handleError('doSomething');
handleError(function(){
  doSomething2();
  doSomething3();
});
handleError(function(){
  doSomething3();
  doSomething4();
});

ブロック内部の可変箇所だけ、外から関数として突っ込むことで、うまく共通化することができました。
こんな風に高階関数を使いこなすことで、複雑なコードでもまとめやすくなります。

イテレータ

正直なところ、高階関数まで使いこなせれば、だいたい何でもまとめることができます。JavaScriptなんかは、関数と高階関数でほとんど何でも書いてしまいますよね。オブジェクト指向で書く場合はコードの単位がオブジェクトになるので、もう少し違う形になりますが、発想は同じです。

ただ、PHPにはもう少し用途に特化した文法がいくつか存在し、そのうちの一つにイテレータがあるのです。

イテレータは手続き型言語特有の、ループを抽象化する機構です。ループのロジックのみをまとめる機構、とも言えます。
ちょっと例を見てみましょう。

<?php
// 0~100のうちの奇数のみを足した数を出力する。
$sum = 0;
foreach (range(0,100) as $i) {
  if ($i % 2) {
    $sum += $i;
  }
}
echo $sum, PHP_EOL;
<?php
//data.txtの奇数行だけを出力する。
foreach (new SplFileObject('data.txt') as $i => $line) {
  if ($i % 2) {
    echo $line;
  }
}

まずこの二つのコードですが、配列とファイルオブジェクトという全く異なる性質の値を扱っていますが、両方ともforeach構文でぐるぐる回して処理することができます。これはPHPのforeach文がIteratorに対応しているおかげなんですが、今更ピンとこないですし、もはや当たり前すぎる気がするので解説は省略します。

これらのコードですが、「ループを回して奇数の時だけ特定の動作をする」というようなロジックになっています。よく似ていますよね。まとめられそうです。

これも「ブロックの外側」が共通になっているタイプなので、高階関数を使えばまとめられるんですが、今回はイテレータで処理をまとめてみたいと思います。

イテレータの加工

イテレータで処理をまとめる時の発想は、「ループを回す部分のロジックを全部イテレータの中にぶち込む」というところです。

上記の例では、「ループの奇数番目だけ処理を行う」というロジックが共通です。例えばCallbackFilterIteratorを使うと、ループの奇数番目を取り出す作業を共通化できます。

<?php

function filterOdd($ite) {
  if (is_array($ite)) {
    $ite = new ArrayIterator($ite);
  } elseif (! $ite instanceof Traversable) {
    throw new InvalidArgumentException;
  }

  return new CallbackFilterIterator($ite, function($v, $key, $ite){
    return $key % 2;
  });
}

// 0~100のうちの奇数のみを足した数を出力する。
$sum = 0;
foreach (filterOdd(range(0,100)) as $i) {
    $sum += $i;
}
echo $sum, PHP_EOL;


//data.txtの奇数行だけを出力する。
foreach (filterOdd(new SplFileObject('data.txt')) as $line) {
    echo $line;
}

ループ中のif文が消えたことがわかるでしょうか? こんな風に「イテレータを加工する関数」を用意してやると、if文なども含めてループが再利用できるようになります。

IteratorIteratorはイテレータを引数にとって新たなイテレータを返します。CallbackFilterIteratorもIteratorIteratorの仲間です。PHPに標準で付属するSPLのIteratorは妙に充実していて、多彩な加工が可能です。

これらのIteratorIteratorを使うと、結構複雑なループロジックでもイテレータだけで実装できます。

FilterIteratorとRecursiveTreeIteratorを使う例を昔書いたので、以下も参考にしてください。

参考:PHP - RecursiveTreeIteratorでtreeコマンドを実装 - Qiita [キータ]

それジェネレータでもできるよ

この記事の冒頭に「ジェネレータが追加されたのでイテレータが空気になった」と書きました。基本的にイテレータで実現することはジェネレータでも実現でき、ジェネレータの方がパフォーマンスもよく、しかも読みやすいです。

<?php

function filterOdd($ite) {
  $i = 0;
  foreach ($ite as $v) {
    if ($i++ % 2) yield $v;
  }
}

// 0~100のうちの奇数のみを足した数を出力する。
$sum = 0;
foreach (filterOdd(range(0,100)) as $i) {
    $sum += $i;
}
echo $sum, PHP_EOL;


//data.txtの奇数行だけを出力する。
foreach (filterOdd(new SplFileObject('data.txt')) as $line) {
    echo $line;
}

5.5以降のバージョンを使っているなら、積極的にジェネレータを使うといいのではないでしょうか。
SPLにあらかじめ用意されているクラスをnewするだけで使える場合は、IteratorIteratorを使っても簡易に書けるでしょうけど。。

まとめ

  • コードをまとめる技術として、一般には関数や高階関数などがあります
  • イテレータはそのうちの「ループに関する部分」に特化してコードをまとめる能力があります
  • ジェネレータ使ったほうが楽なので、移行していくといいと思います