改进字节编码处理



在 R 中,可以声明字符串为字节编码。根据 ?Encoding,它必须是非 ASCII 字符串,应作为字节处理,切勿转换为字符编码(例如拉丁语 1、UTF-8)。本文总结了 R 处理字节编码字符串的最新改进,并提供了一些关于它们今天应该和不应该用于什么的想法。

字符向量、字符串和编码

特别是对于不熟悉 R 的读者,了解该语言如何支持字符串可能很有用。字符向量字符串的向量。与任何向量一样,它的长度可以为零或更多,并且可以为 NA。字符串类型在 R 级别不可见,但单个字符串使用长度为一的字符向量表示,该字符串作为元素。

字符串文字(例如 "hello")是长度为一的字符向量

> x <- "hello"
> length(x)
[1] 1
> x[1]
[1] "hello"

类似地,R 中没有类型来保存单个字符。可以使用例如 substring 函数提取单个字符,但该字符将表示为字符串(因此是具有该单字符字符串作为元素的长度为一的字符向量)

> substring(x, 1, 1)
[1] "h"

字符串是不可变的,不能递增地构造,例如通过像在 C 中那样填充各个字节或字符。创建字符串可能是一项昂贵的操作,字符串被缓存/内部化,并且会检查和记录它们的某些属性。目前会检查并记录字符串是否是 ASCII。

编码信息附加到字符串,因此一个字符向量可能包含不同编码的字符串。当前支持的编码为“UTF-8”、“latin1”、“bytes”和“native/unknown”(稍后会详细介绍)。

接受字符向量的函数根据其编码处理字符串。例如,substring 对字节编码字符串按字节计数,但对字符字符串(“UTF-8”、“latin1”和“native/unknown”)按字符计数。并非所有函数都支持字节编码字符串,例如 nchar(,type="chars") 是一个运行时错误,因为字节编码字符串没有字符。

当不同的字符串使用不同的编码时,函数必须处理这种情况。各个函数执行此操作的方式不同,但通常字符字符串会转换为单个字符编码(通常为 UTF-8),并且在发生这种情况时,任何新创建的结果字符串也为 UTF-8。只要字符串有效,用户不必担心,因为它们始终可以用 UTF-8 表示。

对于无法转换为字符编码的字节编码字符串,情况会更复杂。如果任何输入字符串是字节编码,则某些函数(例如 gsubmatch)会切换到不同的模式。在此模式下,它们会忽略字符串的编码,并将它们全部视为字节序列。如后文所述,这仅在某些情况下才有意义。

字节编码字符串不是字节数组

从上文可以清楚地看出,字节编码字符串与 Java 或 Python 中的字节数组或 C 中的字符数组不同,因为无法引用其中的各个字节。此外,无法使用 [] 运算符修改各个字节。

还有其他差异。零字节是保留的,不能包含在任何字符串中。此外,每个字节编码字符串都必须至少包含一个大于 127 的字节值,因为字符串必须是非 ASCII 的。ASCII 字符串始终编码为“本机/未知”(虽然有时可以操作编码标志,但不能违反此规则)。稍后会更清楚地说明,这是由于 ASCII 字符串的身份/比较。

因此,字节编码字符串不能用于表示二进制数据。相反,R 中有 raw 向量可用于此目的。原始向量的元素是任意字节(包括零),并且可以使用 [] 在 R 级别进行索引和变异。它们不像字符串那样工作,不会作为字符串打印,也不受字符串函数支持。

与编码无关的字符串操作

特别是在过去只有单字节编码的时候,考虑与编码无关的字符串操作是有意义的。不仅是因为有时输入编码不可靠,还因为可能重用旧代码,这些代码不了解编码或不了解新编码。此外,还有许多不同的编码正在使用中。

当所有字符串都采用相同的(无状态)单字节编码时,可以在不知道编码的情况下对其进行连接,可以进行搜索/替换。如果它们都是 ASCII 的超集(R 支持的所有编码都是),甚至可以解析全为 ASCII 的语言,包括拆分行和列等琐碎部分。

人们有时会以真正未知的编码输入文件(文件的提供者没有说明)。并且只要大多数字节/字符是 ASCII,就可以在字节级别忽略编码来完成许多事情。

在当今的 R 中仍然存在的具体示例是 package DESCRIPTION 文件。该文件可能采用不同的编码,但编码在该文件中的名为 Encoding: 的字段中定义。该文件甚至可以包含不同编码的记录,每个记录都有自己的 Encoding: 字段。在 R 中解析此类文件需要一些与编码无关的操作:在读取文件之前不知道编码是什么。

使用多字节编码,事情变得更加复杂,与编码无关的操作不再真正有意义。不过,UTF-8 允许其中一些操作,以至于 DESCRIPTION 文件中支持它。UTF-8 是 ASCII 安全的:多字节字符仅使用非 ASCII 字节编码,因此所有 ASCII 字节都表示 ASCII 字符。此外,在 UTF-8 中,搜索可以基于字节:多字节字符的字节表示不包括另一个字符的字节表示。不过,目前,DESCRIPTION 文件所基于的 Debian Control Files (DCF) 不允许在其中定义编码,今天它们必须采用 UTF-8。最终将 DESCRIPTION 文件也迁移到 UTF-8 也是有意义的。

即使使用 UTF-8,实际上也无法进行一些基本的与编码无关的操作,因为字符可能由多个字节表示。其他多字节,特别是状态编码,使得无法对字节流进行与编码无关的操作。

当前的趋势似乎是文件必须采用已定义的已知编码(无需解析文件文本即可得知),并且通常此编码是已知隐式编码,因为它需要采用 UTF-8。

不过,为了支持旧式文件(例如当前的 DESCRIPTION(或例如旧 LaTeX)),需要基于字节的与编码无关的操作,而字节编码字符串是 R 中的合适工具。

“未知”编码不适用于与编码无关的操作

R 有一种称为“未知”的编码(例如,请参见?Encoding)。

在 R 的大部分地方,此编码中的字符串应为 R 会话的本机编码中的有效字符串,这就是我在本文中其他地方使用“未知/本机”的原因。任何编码转换(通常转换为 UTF-8)都依赖于此。如果它不成立,则会出现错误、警告、替换无效字节等,具体取决于操作。

此类字符串转换几乎可以在任何时间在内部发生,而无需用户直接控制,因此使用“未知/本机”字符串执行与编码无关的操作是脆弱且容易出错的。由于当前在创建时不会检查字符串的有效性,因此有时仍然有可能,但并非不可能,将来会将其变成错误,因为通常无效字符串只是由用户错误创建的。

相反,字节编码字符串对于意外转换是安全的,因为根据设计/定义,它们不能转换为字符字符串。

为了完整性,应该说 R 的某些部分为具有“未知/本机”编码的字符串实现了一定的不确定性。它们旨在成为本机编码中的有效字符串,但我们的想法是,除非用户明确声明(某些函数允许将此类字符串标记为 UTF-8 或 Latin 1)或通过成功转换为不同的编码,否则我们不确定是否相信这一点。不过,每当实际需要字符串编码时,都应采用本机编码,否则会出现错误、警告、替换、音译等。

在过去和最近的 Windows 中,本机编码通常是单字节(和 Latin 1),因此转换不像现在这样经常检测到无效字节,并且结果通常可以接受,原因如上所述。现在,当本机编码主要是 UTF-8 时,其中许多字节值不能作为前导字节,转换更经常在旧的单字节编码文件中检测到无效字节。

尤其是在过去,另一个不确定因素是本机编码实际上是什么,即使在今天,找出它也是特定于平台的。因此,字符串被假定为采用本机编码,但有时不知道该编码实际上是什么。

最后,虽然不鼓励这样做,但可以在运行时更改 R 会话编码。这会使“本机/未知”编码中的现有字符串无效,或者换句话说,不知道哪些字符串采用什么编码。

我认为所有这些不确定性来源今天变得不太令人担忧,“未知”编码应该理解为“本机”,并且所有用该编码标记的字符串都应该在其中有效。R 会话编码绝不应在运行时更改。在最近的 Windows 上,它根本不应该被更改(它应该是 UTF-8,因为这是 Windows 中 R 的系统编码的构建时选择)。当然,“未知”编码不应用于与编码无关的操作:我们有字节编码。

“字节”编码实现的限制

最近发现,“字节”编码的现有支持存在一些限制,这些限制最近已得到修复。

首先,无法将文本文件(例如 DESCRIPTION)中的行作为“字节”编码中的字符串读取。通常会使用 readLines 读取而不指定编码,然后使用 Encoding(x) <- "bytes" 标记为“字节”,但这种方法使用无效字符串,因为字符串在短时间内被标记为“本机/未知”。这已得到改进,现在可以使用 readLines(,encoding="bytes") 将文件中的行作为“字节”读取。事实上,这假定行分隔符具有该含义(对于与编码无关的操作必须如此)。

然后,regexp 操作 gsubsubstrsplit 出现了一个问题。这些操作有时会通过替换或拆分创建新字符串,而问题在于这些字符串应该具有什么编码。当任何输入都编码为字节时,这些操作“使用字节”(在字节级别工作)。但是,出于历史原因,它们过去常常将这些新字符串返回为“未知/本机”。

因此,通过处理表示为字节编码字符串的 DESCRIPTION 文件中的输入行,可以得到一个无效的“本机/未知”字符串,然后该字符串可能会因意外转换为其他编码而损坏。必须始终将每个 regexp 操作结果的编码更改为“字节”,但这不方便,有时用户无法轻松完成,例如当调用不执行此操作的函数时(例如 trimws,它可能会按顺序应用两个 regexp 操作)。

当至少一个输入标记为字节时,这些函数被更改为将新创建的字符串标记为字节。应该说,虽然 regexp 函数允许混合编码使用,但只有很小一部分有意义。要么所有输入都采用字符编码(因此可以转换为 UTF-8),然后结果也将采用字符编码。或者,所有输入都是字节编码或 ASCII,然后结果也将是字节编码或 ASCII。混合字节编码和其他非 ASCII 字符串没有意义。

regexp 操作中的 useBytes=TRUE 和类型不稳定性

现在,一个自然的问题是,无论何时 useBytes=TRUE,我们是否也应该这样做,无论新创建的字符串还是可能返回的所有字符串是否都不应标记为字节。

这已经在 R-devel 中尝试过,但由于破坏了太多现有代码而被还原以进行进一步分析。我首先只想将新创建的字符串标记为字节(因为我们没有更改旧字符串,所以为什么要忘记它们的编码)。从概念上讲这是有意义的,但破坏了用户代码中的这种模式

xx <- gsub(<something_strange>, "", x, useBytes = TRUE)
stopifnot(identical(xx, yy))

该模式从字符编码的输入文本中删除了“奇怪的东西”。当发生替换时,结果元素在更改后是字节编码的(但在更改之前是“未知/本机”)。当不发生替换时,它将以 x 的原始字符编码进行编码。但是,字节编码字符串永远不会与字符编码中的字符串相同。因此,更改引入了类型不稳定性(字符与字节编码),而之前没有,并且测试开始失败。我尝试通过使函数返回的所有字符串字节编码来解决此问题,但虽然“稳定”,但它破坏了更多代码,因为它最终将字节编码字符串传递给不支持它们的字符串函数(有些函数无法支持)。

在前面,我写道在输入中使用字节和字符编码的非 ASCII 字符串的混合是没有意义的。useBytes = TRUE 与使用多种不同字符编码的输入也没有意义,原因相同(不同输入中的字节仅仅表示不同的内容)。但是,useBytes = TRUE 历史上一直被用于此模式中,以实现对无效输入 UTF-8 字符串的某种程度的健壮性。这适用于 UTF-8 输入中包含一些无效字节的正则表达式的子集。

能够处理包含无效字节的 UTF-8 是一个有用的特性,例如在处理来自多个并行进程的文本日志时,这些进程没有适当的同步:多字节字符可能无法以原子方式写入。虽然 PCRE2 现在对 UTF-8 字符串中的无效字节有更好的支持,但 R 尚未提供对它的访问。事实上,对于某些应用程序,可以使用 iconv 简单地替换无效字节,并获得一个有效的 UTF-8 字符串进行处理。

这里应该注意,“字节”编码(以及字符编码)已经对 ASCII 具有另一种类型的不稳定性。如果对字节编码字符串的操作(例如提取字符串的某些部分或以其他方式处理它们)的结果可能是字节编码(当它至少有一个非 ASCII 字节时)或“本机/未知”(当它是 ASCII 时)。substring 是一个简单的示例。因此,字符串操作的结果应该已经考虑到某种类型的不稳定性。

似乎可以处理上面的模式(警告:这不起作用,见下文)

xx <- gsub(<something_strange>, "", x, useBytes = TRUE)
xx <- iconv(xx)
stopifnot(identical(xx, yy))

这将重新标记 xx 的字节编码元素为“未知/本机”,并将字符编码中的元素也转换为“未知/本机”。但是,这有两个问题。第一个问题是某些输入字符可能无法用“未知/本机”编码表示(在 UTF-8 不是本机编码的旧系统上)。可以使用 xx <- iconv(xx, to="UTF-8") 来解决此问题。

但是,还有另一个问题:iconv(,from="") 历史上忽略了输入字符串的编码标志,但总是从“未知/本机”编码转换,因此它错误地解释了其他编码中的字符串。

iconv 的此行为已更改。现在,如果输入字符串的编码标志是 UTF-8 或 Latin 1,则它将优先。这是对已记录行为的更改,但原则上它只会破坏过去依赖于使用无效字符串的代码。检查所有 CRAN 和 Bioconductor 包显示,只有在更改后一个包开始失败,而这实际上是一件好事,因为该包有一个错误;它在旧行为下偶然起作用。

我相信在考虑使用 useBytes = TRUE 时,首先应该决定是否需要支持无效输入,在许多应用程序中可能不需要,但在某些应用程序中需要。然后,我认为应该首先考虑使用 iconv(,sub=) 替换为有效的 UTF-8 输入是否可以接受。如果是这样,这是接受无效字符串最简单、最具防御性和最具未来兼容性的选项。

只有在不可接受并且要使用正则表达式的 useBytes = TRUE 时,代码才应该处理在字节或“本机/未知”编码中获取结果时类型不稳定的问题,如上所述。正则表达式操作的文档已更新,以明确说明在某些情况下,结果是“字节”还是“未知/本机”编码是未指定的(以前是间接未指定的)。代码应该针对此范围内的可能更改(这可能不仅是清理的结果,还可能是性能优化或重构以支持新特性)变得健壮。一旦 R 获得更安全的正则表达式支持来处理无效的 UTF-8 输入,此类代码可能无论如何都必须更新。

除了类型不稳定之外,我还不会考虑在正则表达式操作中使用 useBytes = TRUE,还因为可能使用的正则表达式的限制。过去,这样做是为了提高性能,但出于这个原因,最近改进了正则表达式操作的性能(请参阅此 博客文章)。过去还这样做,当时 R 中处理 UTF-8 字符串的支持有限,但现在这不再是一个有效的理由。

摘要

字节编码是 R 中一个有点不寻常的特性,它适用于字节级的编码无关操作。

它允许安全地执行此类操作。过去使用的非安全替代方法包括在“未知/本机”编码中使用无效字符串,有时还会更改 R 会话区域设置,但这些会导致错误结果(由于意外音译和替换)或警告和错误。非安全替代方法也只可能存在,因为 R 允许创建无效字符串,这反过来又隐藏了用户代码和包中的错误,否则可以通过在字符串创建时检查字符串有效性来检测这些错误。

R 中的最新改进使得在需要时更容易使用字节编码进行字节级的编码无关操作。然而,本文还认为,当编码得到适当支持、已知(并且理想情况下/主要是 UTF-8)时,将来对编码无关操作的需求不会很大。

现在以字节编码改进、对无效 UTF-8 输入的正则表达式提供更好的支持或正则表达式加速的形式提供“本机/未知”编码的非安全操作的安全替代方法,应该可以更好地检测现在导致不正确结果或错误的编码错误,而且还可以简化 R 中的编码支持在未来。由于现在 UTF-8 也是最新 Windows 上的本机编码,因此应该有可能只将 UTF-8 作为 R 中支持的字符编码。