LoginSignup
10
9

More than 5 years have passed since last update.

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

Last updated at Posted at 2016-08-26

概要

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 を使うことができます。それ以前のバージョンに関してはこちらの記事をご参照ください。

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

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

echo "\u{3042}", PHP_EOL;

クロージャで定義する

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

mb_substr

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

$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 のマニュアルをご参照ください。

$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 文で書くと次のとおりです。

$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引数はバイト単位での検索開始位置です。

$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 と遜色ない速度になりました。

array(3) {
  ["mb_substr"]=>
  float(0.25698900222778)
  ["preg_split"]=>
  float(0.27088809013367)
  ["preg_match"]=>
  float(0.46399593353271)
}
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

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

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つずつ取り出します。

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 を使ってバイト単位で文字を切り出します。

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 (コードポイント)

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

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

$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 さんご指摘ありがとうございます)。

10
9
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
9