在Perl中构建UTF-8编码器

这周我编写了一个UTF-8编码器/解码器。Perl已经内置了UTF-8编码功能,所以这不是必需的,但有时了解事情是如何工作的也是很不错的。UTF-8方案定义在RFC 3629中。

UTF-8编码器做什么?

UTF-8是一种将Unicode码点序列编码为字节/八位的方案。码点只是一个数字,用于标识Unicode条目(例如,0x24是一个美元符号)。

Unicode定义的码点范围是0x0000..0x10FFFF,因此编码器必须将码点转换为字节,根据UTF-8方案,如下所示

Char. number range  |     UTF-8 bytes/octets sequence
   (hexadecimal)    |              (binary)
--------------------+------------------------------------
0000 0000-0000 007F | 0xxxxxxx
0000 0080-0000 07FF | 110xxxxx 10xxxxxx
0000 0800-0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx
0001 0000-0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

这有一些有趣的特性。首先,范围在0x00..0x7F(0-127)的码点将与ASCII编码具有相同的字节,这很方便。其次,它是一种可变宽度编码,这意味着单个码点可以是1-4个字节长。

解码只是反向过程:将一系列字节转换回码点。

编码UTF-8

为了编码UTF-8,我需要将码点(仅是一个数字)转换为一系列字节。由于UTF-8表中定义了四种不同的字节序列,因此有四种情况需要处理

sub codepoint_to_bytes {
  my $codepoint = shift;

  if ($codepoint < 0x80) {
    return pack 'C', $codepoint;
  }
  elsif ($codepoint < 0x800) {
    return pack 'CC',
           $codepoint >>  6 | 0b11000000,
           $codepoint       & 0b00111111 | 0b10000000;
  }
  elsif ($codepoint < 0x10000) {
    return pack 'CCC',
           $codepoint >> 12 | 0b11100000,
           $codepoint >>  6 & 0b00111111 | 0b10000000,
           $codepoint       & 0b00111111 | 0b10000000;
  }
  else {
    return pack 'CCCC',
           $codepoint >> 18 | 0b11110000,
           $codepoint >> 12 & 0b00111111 | 0b10000000,
           $codepoint >>  6 & 0b00111111 | 0b10000000,
           $codepoint       & 0b00111111 | 0b10000000;
  }
}

第一种情况是最简单的:如果码点在0x00和0x7F之间,不需要转换,所以我只需以原样打包码点。字符的字节值与码点相同(例如,'U' == 56 == 0x38 == 00111000)。

对于第二种情况,我必须将码点填充到掩码110xxxxx 10xxxxxx中,这意味着我需要返回两个字节。这是我的做法

  1. 对于第一个字节,将码点右移6位(因为第二个字节将获取这6位)。
  2. 使用按位或设置两个最高有效位为1(xxxxxxxx | 11000000 == 11xxxxxx)。我使用Perl的行内二进制表示法(0b...),这使得比较二进制数与掩码变得容易。
  3. 对于第二个字节,使用按位与设置两个最高有效位为零(xxxxxxxx & 00111111 == 00xxxxxx)。
  4. 使用按位或设置最高有效位为1(xxxxxxxx | 10000000 == 1xxxxxxx)。
  5. 使用打包将字节组合成标量并返回它。

三字节和四字节编码的过程遵循相同的方法,但根据UTF-8方案更新了规则。

如果我想获取电视码点(U+1F4FA)的UTF-8编码字节,我可以使用如下代码

my $bytes = codepoint_to_bytes(0x1F4FA);

解码UTF-8

为了解码UTF-8字节,我们需要反向编码过程以回到原始的Unicode码点数字。解码器必须检查它接收了多少字节,提取适当的位并将它们加在一起。

正如俗话所说,Perl“试图使简单的事情变得简单,使困难的事情变得可能”,但有时它使简单的事情比在像C这样的简单语言中更困难。二进制数据就是这样:Perl需要在您安全地处理数据之前通知它关闭字符功能。

有两种方法可以实现。旧的不推荐的方法是使用bytes祈使句。新的方法是使用Encode模块将标量编码为字节并移除其UTF-8标志。之后,Perl的函数将把标量当作字节序列来处理,而不是字符。

use Encode 'encode';

sub bytes_to_codepoint {
  # treat the scalar as bytes/octets
  my $input    = encode('UTF-8', shift);

  # length returns number of bytes
  my $len      = length $input;
  my $template = 'C' x $len;
  my @bytes    = unpack $template, $input;

  ...
}

在子例程bytes_to_codepoint中,我使用encode()将传入的字节填充到$input中。接下来,我使用length函数来返回$input中的字节数——这与它通常的行为不同,它返回的是字符数;这是使用encode()将标量转换为字节的效果。最后,我使用unpack$input中提取字节。

现在我知道了传递给bytes_to_codepoint的字节数,只需反转编码过程中的二进制操作即可。

if ($len == 1) {
  return $bytes[0];
}
elsif ($len == 2) {
  return (($bytes[0] & 0b00011111) <<  6) +
          ($bytes[1] & 0b00111111);
}
elsif ($len == 3) {
  return (($bytes[0] & 0b00001111) << 12) +
         (($bytes[1] & 0b00111111) <<  6) +
         ( $bytes[2] & 0b00111111);
}
else {
  return (($bytes[0] & 0b00000111) << 18) +
         (($bytes[1] & 0b00111111) << 12) +
         (($bytes[2] & 0b00111111) <<  6) +
          ($bytes[3] & 0b00111111);
}

如果只有一个字节,就原样返回它,因为代码点数字与字节值相同。与编码一样,当涉及到两个字节时,事情变得有趣起来。

  1. 使用位与操作移除第一个字节的掩码。
  2. 将得到的数字左移6位以获取原始值。所以00000010会变成10000000
  3. 使用位与操作移除第二个字节的掩码。
  4. 将数字相加。

同样的逻辑适用于三字节和四字节序列,我只是更新了位操作以匹配UTF-8方案。最终的代码如下所示

use Encode 'encode';

sub bytes_to_codepoint {
  # treat the scalar as bytes/octets
  my $input    = encode('UTF-8', shift);

  # length returns number of bytes
  my $len      = length $input;
  my $template = 'C' x $len;
  my @bytes    = unpack $template, $input;

  # reverse encoding
  if ($len == 1) {
    return $bytes[0];
  }
  elsif ($len == 2) {
    return (($bytes[0] & 0b00011111) <<  6) +
            ($bytes[1] & 0b00111111);
  }
  elsif ($len == 3) {
    return (($bytes[0] & 0b00001111) << 12) +
           (($bytes[1] & 0b00111111) <<  6) +
           ( $bytes[2] & 0b00111111);
  }
  else {
    return (($bytes[0] & 0b00000111) << 18) +
           (($bytes[1] & 0b00111111) << 12) +
           (($bytes[2] & 0b00111111) <<  6) +
            ($bytes[3] & 0b00111111);
  }
}

假设我想获取东京塔的代码点,可以这样调用代码

use utf8;
my $codepoint = bytes_to_codepoint('🗼');

注意

  1. 这是一个简单的实现——它不处理UTF-16保留字符(U+D800..U+DFFF)、非字符,并且一次只编码/解码一个代码点。
  2. 如果你需要一个快速的UTF-8编码器且不想使用Perl的内置工具,请查看Unicode::UTF8
  3. UTF-8是最受欢迎的Unicode编码。它由Ken Thompson和Rob Pike在短短几天内创建。
  4. 如果你正在构建自己的UTF-8编码器,请查看Markus Kuhn的解码器测试文件,它包含几个困难的或边缘情况的UTF-8解码测试。Markus还编写了一份全面的UTF-8和Unicode FAQ for Unix/Linux


这篇文章最初发布在PerlTricks.com上。

标签

David Farrell

David是一位专业的程序员,他经常推文博客关于代码和编程的艺术。

浏览他们的文章

反馈

这篇文章有什么问题吗?请通过在GitHub上打开问题或拉取请求来帮助我们。