UTF-8 相对于 ULEB128 或 LLVM 位码等最高位编码的优势是什么?

What's the advantage of UTF-8 over highest-bit encoding such as ULEB128 or LLVM bitcode?

最高位编码方式

if a byte starts with 0 it's the final byte, if a byte starts with 1 it's not the final byte

注意,这也是 ASCII 兼容的,事实上,这就是 LLVM 位码所做的。

如果您在流中删除一个字节,或者跳到流的中间,UTF-8 可以毫无歧义地同步自身。如果你只有一个“last/not-last”标志,那么你无法检测到一个字节已经丢失,并且会默默地错误地解码序列。在 UTF-8 中,这可以转换为 REPLACEMENT CHARACTER (U+FFFD) 以指示不可解码的部分,否则整个字符将丢失。但是由于丢失的字节而得到 错误的 字符的情况要少得多。

它还提供了明确的解码内存限制。从第一个字节开始,您立即知道需要读取多少字节才能完成解码。这对于安全性很重要,因为除非您对序列的长度包含任意限制,否则您描述的方案可能会耗尽内存。 (这个问题 确实 存在于 Unicode 组合字符,并且有一个特殊的 Stream-Safe Text Format 强加了这样的任意限制,但这只影响需要规范化的解析器。)这也可以帮助性能,因为您立即知道要加载多少字节来解码整个代码点。

您还知道在搜索操作中可以安全地跳过多少字节。如果第一个字节不匹配,您可以一步跳转到下一个字符,而无需读取中间字节。

由于它是稀疏的,因此它与其他编码也相当明确。一个随机的字节序列,尤其是一个扩展的 ASCII 编码,几乎肯定不是有效的 UTF-8。有一些值 永远不会 出现,因此如果找到,您可以完全排除 UTF-8,并且有针对可能出现的值的规则。您描述的方案很密集。每个序列都是合法的,就像扩展的 ASCII 一样。这使得准确检测编码变得更加困难。

大多数编码都有取舍。

注意:你提出的是介于编码和转义之间的东西,这使得一些东西很难比较。

UTF-8 之所以流行,是因为:它解码速度快,与 ASCII 完全兼容,并且是自同步的,并且您可以用 3 个字节(所以不多)解码 BMP(所以代码点直到 0xFFFF)开销)。

您的编码也是自同步的:高位为 0 表示代码点结束。快速:是的,它只是移位操作(并比较 + 屏蔽高位),并且高效:您只用两个字节对字符进行编码直到 0x7FFF(UTF-8 直到 0x07FF)。在开发 UTF-8 时,这种编码并不为人所知。

但问题在于您使用的是“转义”模型。字节 0x41(ASCII 字母 A)可以读作字母 A,或序列的最后一个字符(因此不是字母 A)。当只比较字符串时,这可能是一个安全问题。更正代码很容易(您应该只向后检查一个字符)。注意:UTF-8 也有一些安全问题:过长的序列(这是非法的)可能会跳过简单的字符串比较。

注意:我不认为序列过长是个问题。 UTF-8 也有一个限制(与第一个版本相比有所降低),在实际使用中,我们转换为整数,因此它的大小有限(因此它不应该溢出其他字节)。 OTOH 像 UTF-8,应该检查大小并拒绝无效的 sequences/codepoints(例如 0x10FFFF 以上)。

所以,我不知道确切的原因,而且 UTF-8“很糟糕”也是因为它与 ISO-10646 不兼容(所以 C1 控制代码只有一个字节)。 OTOH UTF-1 设计也很糟糕(所以现在它是一种过时的编码)。

我怀疑这种编码因“转义”问题而被排除:易于修复,但不能(以安全的方式)与旧程序互操作。不支持 UTF-8 的程序可以正常运行(但会出现错误,不知道高位字符的含义)。 [注意:您的提案和 UTF-8 均未通过 ISO-10646(但它允许 UTF-8,但需要特别考虑:忽略字节 0x80-0x9F(C1 字节),因此终端可能需要了解 UTF-8。]。

我认为堆栈中的效率不是很高:使用 ISO-10646 的 CJK 字符使用比 UTF-8 更长的序列编码,并且摆脱大多数 ISO-10646 编码处理将简化大多数程序。 [ISO-10646 还具有其他(非编码)功能]。

UTF-8 具有而 ULEB128 所缺乏的关键 属性 是,没有任何 UTF-8 字符编码是任何其他 UTF-8 字符的子字符串。为什么这很重要?它允许 UTF-8 满足绝对关键的设计标准:您可以将适用于 single-byte 编码(如 ASCII 和 Latin-1)的 C 字符串函数应用到 UTF-8,它们将正常工作。正如我们将看到的,ULEB128 并非如此。这反过来意味着大多数为使用 ASCII 或 Latin-1 编写的程序在传递 UTF-8 数据时也将“正常工作”,这对于在旧系统上采用 UTF-8 是一个巨大的好处。

首先,这里简要介绍一下 ULEB128 编码的工作原理:每个代码点都被编码为一个字节序列,以一个没有设置高位的字节结尾——我们称之为“低位字节”(值为 0 -127);每个前导字节都设置了高位——我们称之为“高字节”(值 128-255)。由于 ASCII 字符是 0-127,它们都是低字节,因此在 ULEB128(以及 Latin-1 和 UTF-8)中的编码方式与在 ASCII 中相同:即任何 ASCII 字符串也是有效的 ULEB128 字符串(和 Latin- 1 和 UTF-8), 编码相同的代码点序列。

注意子字符 属性:任何 ULEB128 字符编码都可以使用 127 个高字节值中的任何一个作为前缀,以编码更长、更高的代码点。这不会发生在 UTF-8 中,因为前导字节对后面的字节数进行编码,因此 UTF-8 字符不能包含在任何其他字符中。

这 属性 为什么重要?一个特别糟糕的情况是,代码点可被 128 整除的任何字符的 ULEB128 编码将包含尾随 NUL 字节 (0x00)。例如,字符 Ā(大写字母“A”上面有横线)的代码点 U+0100 将在 ULEB128 中编码为字节 0x820x00。由于许多 C 字符串函数将 NUL 字节解释为字符串终止符,因此它们会错误地将第二个字节解释为字符串的结尾并将 0x82 字节解释为无效的悬挂高字节。另一方面,在 UTF-8 中,NUL 字节只会出现在 NUL 字节代码点 U+00 的编码中,因此不会出现此问题。

同样,如果您使用 strchr(str, c) 在 ULEB128 编码的字符串中搜索 ASCII 字符,您可能会得到误报。这是一个例子。假设您有以下非常 Unicode 的文件路径:/☯。这大概是您的 collection 卷饼食谱中的一个文件,用于您称之为“阴阳”的卷饼。此路径名具有以下代码点序列:

  • </code> — Unicode U+1F32F</li> <li><code>/ — ASCII/Unicode U+2F
  • — Unicode U+262F

在 ULEB128 中,这将使用以下字节序列进行编码:

  • 10000111</code> (U+1F32F)</li> 的前导字节 <li><code>11100110</code> (U+1F32F)</li> 的前导字节 <li><code>00101111</code> (U+1F32F)</li> 的最后一个字节 <li><code>00101111/ (U+2F)
  • 的唯一字节
  • 11001100 (U+262F)
  • 的前导字节
  • 00101111 (U+262F)
  • 的最后一个字节

如果您使用 strchr(path, '/') 搜索斜杠将此路径拆分为路径组件,您将得到 </code> 中最后一个字节和最后一个字节的虚假匹配在 <code>☯ 中,因此对于 C 代码来说,这条路径看起来像是一个无效的目录名 \x87\xE6 后跟两个斜杠,然后是另一个无效的目录名 \xCC 和最后一个尾部斜杠.将此与此路径在 UTF-8 中的编码方式进行比较:

  • 11110000</code> (U+1F32F)</li> 的前导字节 <li><code>10011111</code> (U+1F32F)</li> 的连续字节 <li><code>10001100</code> (U+1F32F)</li> 的连续字节 <li><code>10101111</code> (U+1F32F)</li> 的连续字节 <li><code>00101111/ (U+2F)
  • 的唯一字节
  • 11110000 (U+262F)
  • 的前导字节
  • 10011111 (U+262F)
  • 的连续字节
  • 10010000 (U+262F)
  • 的连续字节
  • 10101111 (U+262F)
  • 的连续字节

/ 的编码是 0x2F,它不会——也不能——出现在字符串的其他任何地方,因为该字符不会出现在其他任何地方——[=101= 的最后一个字节] 和 0xAF 而不是 0x2F。所以使用strchr在路径的UTF-8编码中搜索/是保证能找到斜杠的,而且只能找到斜杠

情况类似于使用 strstr(haystack, needle) 搜索子字符串:使用 ULEB128 这会找到实际出现的 needle,但它也会找到 needle 的出现,其中第一个字符替换为编码扩展实际第一个字符的任何字符。另一方面,使用 UTF-8 不会有误报——needle 编码出现在 haystack 中的每个地方仅编码 needle 并且不能是其他字符串编码的片段。

UTF-8 还具有其他令人愉悦的特性——许多是子字符 属性 的必然结果——但我认为这是真正关键的特性。其中一些其他属性:

  • 您可以通过查看第一个字节来预测要解码 UTF-8 字符需要读取多少字节。使用 ULEB128,在读取最后一个字节之前,您不知道自己已经读取完一个字符。

  • 如果您在任何数据中嵌入有效的 UTF-8 字符串,无论有效还是无效,都不会影响有效子字符串的解释。这是 属性 的概括,即没有字符是任何其他字符的子序列。

  • 如果您从中间开始读取 UTF-8 流并且无法向后看,则可以判断您是在字符的开头还是在字符的中间。使用 ULEB128,您可能会将字符的尾端解码为看起来有效但不正确的不同字符,而使用 UTF-8,您知道要向前跳到整个字符的开头。

  • 如果您从中间开始读取一个 UTF-8 流,并且您想要向后跳到当前字符的开头,您可以这样做而无需读取超过该字符的开头。使用 ULEB128,您会在字符开始前读取一个字节(如果可能的话)以了解字符的开始位置。

  • 要解决前两点,您可以使用 ULEB128 的 little-endian 变体,您可以在其中将每个代码点的位从最低到最高有效位编码为 7 组。然后每个字符的编码的第一个字节是低字节,尾随字节是高字节。但是,这种编码牺牲了 UTF-8 的另一个令人愉快的 属性,即如果您按字节按字典顺序对编码的字符串进行排序,则它们的排序顺序与按字符按字典顺序排序时的顺序相同。

一个有趣的问题是,鉴于这组令人愉快的特性,UTF-8 是否不可避免?我怀疑可能会有一些变化,但变化不大。

  • 您想用一些不能出现在其他任何地方的标记来锚定字符的开头或结尾。在 UTF-8 中,这是一个带有 0、2、3 或 4 个前导的字节,表示一个字符的开始。

  • 您想以某种方式对字符的长度进行编码,而不仅仅是指示字符何时完成,因为这可以防止将一个有效字符扩展到另一个有效字符。在 UTF-8 中,前导字节中的前导位数表示字符中有多少字节。

  • 你想要 start/end 标记和开头的长度编码,这样你就可以在流的中间知道你是否在字符的开头,并且这样你就知道你需要多少字节来完成这个字符。

可能有一些变体可以满足所有这些和字典顺序 属性,但由于所有这些限制,UTF-8 开始似乎是不可避免的。