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