【问题标题】:How to iterate UTF-8 string in PHP?如何在 PHP 中迭代 UTF-8 字符串?
【发布时间】:2025-12-16 13:35:01
【问题描述】:

如何使用索引逐个字符地迭代 UTF-8 字符串?

当您使用括号运算符 $str[0] 访问 UTF-8 字符串时,utf 编码的字符由 2 个或更多元素组成。

例如:

$str = "Kąt";
$str[0] = "K";
$str[1] = "�";
$str[2] = "�";
$str[3] = "t";

但我想要:

$str[0] = "K";
$str[1] = "ą";
$str[2] = "t";

mb_substr 是可能的,但这非常慢,即。

mb_substr($str, 0, 1) = "K"
mb_substr($str, 1, 1) = "ą"
mb_substr($str, 2, 1) = "t"

是否有另一种方法可以在不使用mb_substr 的情况下逐个字符地对字符串进行交互?

【问题讨论】:

  • 定义“极慢”。您是否分析过您的应用程序并发现这些 mb_substr 调用是某个瓶颈?
  • 第二次阅读您的问题后,我意识到您想要一种无需 mb_substr 的方法。我已经删除了我的答案。
  • @Col。弹片:是的,50% 的处理时间是由mb_substr 完成的。
  • 50% 什么处理?整个用户对网络服务器的请求,从连接到断开?我不敢相信。您的整个脚本在每个请求上都以相同的方式解析。没有人注意到这一点。您的 mb 解析占用了整个请求时间的哪一部分?
  • 我很惊讶没有其他人建议这样做,但是如果您想要最快的解决方案,并且可以忍受高达 4 倍的字符串内存开销,converting to UTF-32 将为您提供固定宽度的字符每个 4 个字节 - 如果您需要随机访问字符串中的任何字符,这可能是最有效的解决方案,除非您正在处理非常大的文件,否则内存开销可能是可以接受的。

标签: php utf-8


【解决方案1】:

使用preg_split"u" modifier 支持 UTF-8 unicode。

$chrArray = preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY);

【讨论】:

  • 这非常优雅,但我很难想象它比mb_substr()
  • @Pekka 可能是。使用mb_substr 是字符串长度的二次方;即使存在构建数组的开销,这也是线性的。当然,它比你的方法需要更多的内存。
  • 我刚刚测试过了。对于长度为 100 个字符的字符串,preg_split 快 50%
  • 更重要的是,我已经对 1000 多个“长”文档进行了测试,而且速度快了 40 倍 :-)(请参阅我的答案)。
  • @Andrew:是什么让您认为上面的代码需要递归? PCRE 在looking for longer matches 时使用递归。除非空的正则表达式不再匹配。
【解决方案2】:

Preg split 将通过内存异常故障转移非常大的字符串,并且 mb_substr 确实很慢,所以这里有一个简单而有效的代码,我敢肯定,你可以使用它:

function nextchar($string, &$pointer){
    if(!isset($string[$pointer])) return false;
    $char = ord($string[$pointer]);
    if($char < 128){
        return $string[$pointer++];
    }else{
        if($char < 224){
            $bytes = 2;
        }elseif($char < 240){
            $bytes = 3;
        }else{
            $bytes = 4;
        }
        $str =  substr($string, $pointer, $bytes);
        $pointer += $bytes;
        return $str;
    }
}

这是我用来逐个字符循环遍历多字节字符串的,如果我将其更改为下面的代码,性能差异是巨大的:

function nextchar($string, &$pointer){
    if(!isset($string[$pointer])) return false;
    return mb_substr($string, $pointer++, 1, 'UTF-8');
}

使用下面的代码将字符串循环 10000 次,第一个代码的运行时间为 3 秒,第二个代码的运行时间为 13 秒:

function microtime_float(){
    list($usec, $sec) = explode(' ', microtime());
    return ((float)$usec + (float)$sec);
}

$source = 'árvíztűrő tükörfúrógépárvíztűrő tükörfúrógépárvíztűrő tükörfúrógépárvíztűrő tükörfúrógépárvíztűrő tükörfúrógép';

$t = Array(
    0 => microtime_float()
);

for($i = 0; $i < 10000; $i++){
    $pointer = 0;
    while(($chr = nextchar($source, $pointer)) !== false){
        //echo $chr;
    }
}

$t[] = microtime_float();

echo $t[1] - $t[0].PHP_EOL.PHP_EOL;

【讨论】:

  • 由于在我的环境中将mbstring.func_overload 设置为 7,因此已投票并添加了我需要进行的更改。
  • elseif($char = 252){ 应该是 elseif($char == 252){
  • 缺少“=”正是我改用 Yoda 表示法来比较变量的原因。
  • 在阅读了 Yoda 符号是什么之后,我想说这听起来真的很有用。感谢您提及。
  • 关于代码可读性,这也是使用switch(true)“技巧”的好例子。例如:switch (true) { case ($char &lt; 224): $bytes=2; break; case ($char &lt; 240): $bytes=3; break; case ($char &lt; 248): $bytes=4; break; case ($char == 252): $bytes=5; break; default: $bytes = 6; break; }
【解决方案3】:

回复 @Pekla 和 @Col 发布的 cmets。弹片我将preg_splitmb_substr 进行了比较。

图片显示,preg_split 耗时 1.2 秒,而mb_substr 几乎耗时 25 秒。

这里是函数的代码:

function split_preg($str){
    return preg_split('//u', $str, -1);     
}

function split_mb($str){
    $length = mb_strlen($str);
    $chars = array();
    for ($i=0; $i<$length; $i++){
        $chars[] = mb_substr($str, $i, 1);
    }
    $chars[] = "";
    return $chars;
}

【讨论】:

    【解决方案4】:

    Lajos Meszaros'奇妙的函数为灵感,我创建了一个多字节字符串迭代器类。

    // Multi-Byte String iterator class
    class MbStrIterator implements Iterator
    {
        private $iPos   = 0;
        private $iSize  = 0;
        private $sStr   = null;
    
        // Constructor
        public function __construct(/*string*/ $str)
        {
            // Save the string
            $this->sStr     = $str;
    
            // Calculate the size of the current character
            $this->calculateSize();
        }
    
        // Calculate size
        private function calculateSize() {
    
            // If we're done already
            if(!isset($this->sStr[$this->iPos])) {
                return;
            }
    
            // Get the character at the current position
            $iChar  = ord($this->sStr[$this->iPos]);
    
            // If it's a single byte, set it to one
            if($iChar < 128) {
                $this->iSize    = 1;
            }
    
            // Else, it's multi-byte
            else {
    
                // Figure out how long it is
                if($iChar < 224) {
                    $this->iSize = 2;
                } else if($iChar < 240){
                    $this->iSize = 3;
                } else if($iChar < 248){
                    $this->iSize = 4;
                } else if($iChar == 252){
                    $this->iSize = 5;
                } else {
                    $this->iSize = 6;
                }
            }
        }
    
        // Current
        public function current() {
    
            // If we're done
            if(!isset($this->sStr[$this->iPos])) {
                return false;
            }
    
            // Else if we have one byte
            else if($this->iSize == 1) {
                return $this->sStr[$this->iPos];
            }
    
            // Else, it's multi-byte
            else {
                return substr($this->sStr, $this->iPos, $this->iSize);
            }
        }
    
        // Key
        public function key()
        {
            // Return the current position
            return $this->iPos;
        }
    
        // Next
        public function next()
        {
            // Increment the position by the current size and then recalculate
            $this->iPos += $this->iSize;
            $this->calculateSize();
        }
    
        // Rewind
        public function rewind()
        {
            // Reset the position and size
            $this->iPos     = 0;
            $this->calculateSize();
        }
    
        // Valid
        public function valid()
        {
            // Return if the current position is valid
            return isset($this->sStr[$this->iPos]);
        }
    }
    

    可以这样使用

    foreach(new MbStrIterator("Kąt") as $c) {
        echo "{$c}\n";
    }
    

    哪个会输出

    K
    ą
    t
    

    或者如果你真的想知道起始字节的位置

    foreach(new MbStrIterator("Kąt") as $i => $c) {
        echo "{$i}: {$c}\n";
    }
    

    哪个会输出

    0: K
    1: ą
    3: t
    

    【讨论】:

    【解决方案5】:

    您可以解析字符串的每个字节并确定它是单个(ASCII)字符还是start of a multi-byte character

    UTF-8 编码是可变宽度的,每个字符由 1 到 4 个字节表示。每个字节有 0-4 个前导连续的“1”位,后跟一个“0”位以指示其类型。 2 个或更多的“1”位表示这么多字节序列中的第一个字节。

    您将遍历字符串,而不是将位置增加 1,而是完整读取当前字符,然后将位置增加该字符的长度。

    *文章有每个字符的解释表[retrieved 2010-10-01]

       0-127 Single-byte encoding (compatible with US-ASCII)
     128-191 Second, third, or fourth byte of a multi-byte sequence
     192-193 Overlong encoding: start of 2-byte sequence, 
             but would encode a code point ≤ 127
      ........
    

    【讨论】:

      【解决方案6】:

      我遇到了与 OP 相同的问题,我尽量避免在 PHP 中使用正则表达式,因为它会失败,甚至会因长字符串而崩溃。我使用 Mészáros Lajos' answer 进行了一些更改,因为我将 mbstring.func_overload 设置为 7。

      function nextchar($string, &$pointer, &$asciiPointer){
         if(!isset($string[$asciiPointer])) return false;
          $char = ord($string[$asciiPointer]);
          if($char < 128){
              $pointer++;
              return $string[$asciiPointer++];
          }else{
              if($char < 224){
                  $bytes = 2;
              }elseif($char < 240){
                  $bytes = 3;
              }elseif($char < 248){
                  $bytes = 4;
              }elseif($char = 252){
                  $bytes = 5;
              }else{
                  $bytes = 6;
              }
              $str =  substr($string, $pointer++, 1);
              $asciiPointer+= $bytes;
              return $str;
          }
      }
      

      mbstring.func_overload 设置为 7,substr 实际上调用了mb_substr。所以substr 在这种情况下得到了正确的值。我不得不添加第二个指针。一个跟踪字符串中的多字节字符,另一个跟踪单字节字符。多字节值用于substr(因为它实际上是mb_substr),而单字节值用于以这种方式检索字节:$string[$index]

      很明显,如果 PHP 决定修复 [] 访问以正常处理多字节值,这将失败。但是,一开始就不需要此修复程序。

      【讨论】:

        【解决方案7】:

        我认为最有效的解决方案是使用 mb_substr 处理字符串。在循环的每次迭代中,mb_substr 将被调用两次(以查找下一个字符和剩余的字符串)。它只会将剩余的字符串传递给下一次迭代。这样,每次迭代的主要开销将是查找下一个字符(完成两次),这只需一到五次左右的操作,具体取决于字符的字节长度。

        如果此描述不清楚,请告诉我,我将提供一个有效的 PHP 函数。

        【讨论】:

        • 在我的测试中,使用 preg_split 比这个要快
        • 我认为这是正确的。有人会使用“[.]”作为代表一个字符的正则表达式,我希望这意味着一个 Unicode 字符(尚未测试)。但当然,在 PHP 中迭代字符串的最快方法是将字符串视为字节数组。很少需要隔离字符串中的每个 Unicode 字符。请注意,换行符等控制字符在 UTF-8 中始终为一个字节。此外,标记分隔符通常与英语中的相同,因此也是一个字节。 UTF-8 对于这种单字节识别是安全的。