Generator と IteratorAggregate を組み合わせて文字列の Iterator を実装する


概要
---

HTML の特殊文字のエスケープのように文字列から1文字ずつ取り出して変換する処理をする場合、あらかじめ Iterator を実装するクラスを用意しておけば、`foreach` 文で展開することができます。`for` 文や `while` 文でインデックスを数えたり、文字列の長さを求めたりする必要がなくなります。

マルチバイト処理の選択肢
--------------------

PCRE が標準モジュールであることから、UTF-8 だけの対応が必要であれば `preg_match` 関数を使うことができます。同じく UTF-8 限定の intl モジュールには `IntlBreakIterator` が用意されていますが、ICU4C のライブラリが大きく、レンタルサーバーで採用しづらいことやこのクラスが導入されたのは PHP 5.5 の時代で比較的新しいことからあまりユーザーはいないようです。導入者のサラさんは当時 Facebook で勤務していました。複数のエンコーディングのサポートが必要な場合、mbstring モジュールの `mb_substr` 関数を使うことを考えます。

単位はコードポイントか拡張書記素クラスターか
------------------------------------

部分文字列を取り出す場合、漢字の形を変えたり、絵文字の色を変えたりする装飾記号を考慮する必要があります。複数のコードポイントであらわされる文字を拡張書記素クラスターと呼んでいます。PCRE の正規表現では拡張書記素クラスターは `\X` であらわされます。拡張書記素クラスターを扱う場合の注意事項はクラスターの大きさの上限がないので、文字数のバリデーションの実装には使えないことです。

文字をコードポイントに変換する
-------------------------

PHP 7.0 で `IntlChar::ord`、PHP 7.2dev で `mb_ord` を使うことができます。それ以前のバージョンに関しては[こちら](http://qiita.com/masakielastic/items/5696cf90738c1438f10d)の記事をご参照ください。


Unicode エスケープシーケンスを使う
-----------------------------

漢字の異体字をあらわすための異体字セレクターや肌の色を変える修飾子などを使う場合、PHP 7.0 で導入された Unicode エスケープシーケンスが便利です。

```php
echo "\u{3042}", PHP_EOL;
```


クロージャで定義する
-----------------

練習として、クロージャと Generator を組み合わせて文字列から1文字ずつ表示させてみましょう。PHP 7.0 でクロージャの即時実行が利用できるようになりました。

### mb_substr


文字数をあらかじめ計算した上で一文字ずつ取り出す方法は次のようになります。

```php
$str = 'あいうえお';
$gen = (function($str) {
    for ($i = 0, $len = mb_strlen($str); $i < $len; ++$i) {
        yield mb_substr($str, $i, 1);
    }
})($str);

foreach ($gen as $char) {
    echo $char, PHP_EOL;
}
```

ほかの方法として、mb_substr で文字数を超えるインデックスで文字を取り出すと空白文字が返されることを利用することを挙げます。

`while` 文を使って書くと次のようになります。空白文字ではないことをチェックしないと、「あいう0えお」のように文字の 0 が含まれる場合にループが停止してしまいます。どのような文字に気をつけなければならないのかは `empty` のマニュアルをご参照ください。

```php
$str = 'あいう0えお';
$gen = (function($str) {
    $i = 0;
    while ('' !== ($char = mb_substr($str, $i, 1))) {
        yield $char;
        ++$i;
    }
})($str);

foreach ($gen as $char) {
    echo $char, PHP_EOL;
}
```

`for` 文で書くと次のとおりです。

```php
$str = 'あいうえお';
$gen = (function($str) {
    for ($i = 0; '' !== ($char = mb_substr($str, $i, 1)); ++$i) {
        yield $char;
    }
})($str);

foreach ($gen as $char) {
    echo $char, PHP_EOL;
}
```

### preg_match

`preg_match` の第3引数は検索結果、第4引数はフラグのデフォルト値、第5引数はバイト単位での検索開始位置です。

```php
$str = 'あいうえお';

$gen = (function($str) {
    $offset = 0;
    while (preg_match('/./su', $str, $matches, 0, $offset)) {
        yield $matches[0];
        $offset += strlen($matches[0]);
    }
})($str);

foreach ($gen as $char) {
    echo $char, PHP_EOL;
}
```


### preg_split を含めたベンチマーク

`preg_match` 以外に `preg_split` を使って文字列を1文字ずつ分解して配列に変換する方法もあります。ベンチマークをとると `preg_match` よりも速く `mb_substr` と遜色ない速度になりました。

```php
array(3) {
  ["mb_substr"]=>
  float(0.25698900222778)
  ["preg_split"]=>
  float(0.27088809013367)
  ["preg_match"]=>
  float(0.46399593353271)
}
```

```php
function timer(callable $block, $repeat = 100000)
{
    $start = microtime(true);
    for ($i = 0; $i < $repeat; ++$i) {
        $block();
    }
    $end = microtime(true);
    return $end - $start;
}

function str_gen($str)
{
    for ($i = 0, $len = mb_strlen($str); $i < $len; ++$i) {
        yield mb_substr($str, $i, 1);
    }
}

function str_gen2($str)
{
    $offset = 0;

    while (preg_match('/./su', $str, $matches, 0, $offset)) {
        yield $matches[0];
        $offset += strlen($matches[0]);
    }
}

function str_gen3($str)
{
     foreach(preg_split('//su', $str, -1, PREG_SPLIT_NO_EMPTY) as $char) {
         yield $char;
     }
}

$str = 'あいうえお';
$ret = [
    'mb_substr' => timer(function() use($str) {
        $gen = str_gen($str);
        foreach ($gen as $char) {}
    }),
    'preg_match' => timer(function() use($str) {
        $gen = str_gen2($str);
        foreach ($gen as $char) {}
    }),
    'preg_split' => timer(function() use($str) {
        $gen = str_gen3($str);
        foreach ($gen as $char) {}
    })
];

asort($ret);
var_dump($ret);
```

IteratorAggregate を実装する
--------------------------

### preg_match

```php
class StringIterator implements IteratorAggregate {
    private $value = '';

    public function __construct(string $value) {
        $this->value = $value;
    }

    public function getIterator() {
        $offset = 0;
        while (preg_match('/./su', $this->value, $matches, 0, $offset)) {
            yield $matches[0];
            $offset += strlen($matches[0]);
        }
    }
}

$it = new StringIterator('あいうえお');

foreach ($it as $char) {
    echo $char, PHP_EOL;
}
```

### mb_substr

```php
class StringIterator implements IteratorAggregate
{
    private $value = '';

    public function __construct(string $value)
    {
        $this->value = $value;
    }

    public function getIterator()
    {
        for ($i = 0, $len = mb_strlen($this->value); $i < $len; ++$i) {
            yield mb_substr($this->value, $i, 1);
        }
    }
}


$it = new StringIterator('あいうえお');

foreach ($it as $char) {
    echo $char, PHP_EOL;
}
```

### IntlBreakIterator::createCharacterInstance

文字列から拡張書記素クラスターを1つずつ取り出します。

```php
class GraphemeIterator implements IteratorAggregate
{
    private $it;

    public function __construct(string $str, string $locale = 'en_US')
    {
        $this->it = IntlBreakIterator::createCharacterInstance($locale);
        $this->it->setText($str);
    }

    public function getIterator()
    {
        $prev = 0;

        foreach ($this->it as $next) {
            $char = substr($this->it->getText(), $prev, $next - $prev);
            $prev = $next;

            if (empty($char)) {
                continue;
            }

            yield $char;
        }
    }
}

$str = "\u{1F466}\u{1F3FB}"."\u{1F466}\u{1F3FC}"
      ."\u{1F466}\u{1F3FD}"."\u{1F466}\u{1F3FE}"."\u{1F466}\u{1F3FF}";
$locale = 'ja_JP';

$it = new GraphemeIterator($str, $locale);

foreach ($it as $char) {
    echo $char, PHP_EOL;
}
```

### IntlBreakIterator::createCodePointInstance

コードポイント単位で文字を取り出します。得られるのはバイト単位の開始位置なので、`substr` を使ってバイト単位で文字を切り出します。

```php
class StringIterator implements IteratorAggregate
{
    private $it;

    public function __construct(string $str)
    {
        $this->it = IntlBreakIterator::createCodePointInstance();
        $this->it->setText($str);
    }

    public function getIterator()
    {
        $prev = 0;

        foreach ($this->it as $next) {
            $char = substr($this->it->getText(), $prev, $next - $prev);
            $prev = $next;

            if (empty($char)) {
                continue;
            }

            yield $char;
        }
    }
}

$it = new StringIterator('あいうえお');

foreach ($it as $char) {
    echo $char, PHP_EOL;
}
```

### IntlBreakIterator::createCodePointInstance (コードポイント)

文字の代わりにコードポイントを取り出します。

```php
class CodePointIterator implements IteratorAggregate
{
    private $it;

    public function __construct(string $str)
    {
        $this->it = IntlBreakIterator::createCodePointInstance();
        $this->it->setText($str);
    }

    public function getIterator()
    {
        while (IntlCodePointBreakIterator::DONE !== $this->it->next()) {
            yield $this->it->getLastCodePoint();
        }
    }
}

$it = new CodePointIterator('あいうえお');

foreach ($it as $cp) {
    echo 'U+'.dechex($cp), PHP_EOL;
}
```

IteratorAggregate を無名クラスで実装する
------------------------------------

PHP 7.0 で無名クラスが導入されました。クロージャを使う場合と比べてロジックを複数のメソッドに分割することができます。

#### mb_substr

```php
$str = 'あいうえお';

$it = new class($str) implements IteratorAggregate {
    public function __construct(string $value)
    {
        $this->value = $value;
    }
    public function getIterator()
    {
        for ($i = 0, $len = mb_strlen($this->value); $i < $len; ++$i) {
            yield mb_substr($this->value, $i, 1);
        }
    }
};

foreach ($it as $char) {
    echo $char, PHP_EOL;
}
```

ストリームや SplFileObject に対応させる
----------------------------------

こちらの記事をご参照ください　(@mpyw さんご指摘ありがとうございます)。