Windows 上的 UTF-8 支持



R 内部允许以当前本机编码、UTF-8 和 Latin 1 来表示字符串。与操作系统或外部库交互时,所有这些表示都必须转换为本机编码。在当今的 Linux 和 macOS 上,这不是问题,因为本机编码是 UTF-8,因此支持所有 Unicode 字符。在 Windows 上,本机编码不能是 UTF-8,也不能是任何其他可以表示所有 Unicode 字符的编码。

Windows 有时会用类似的可表示字符替换字符(“最合适”),这通常效果很好,但有时会产生令人惊讶的结果,例如,alpha 字符变成字母 a。在其他情况下,Windows 可能会用问号或其他字符替换不可表示的字符,而 R 可能会用 \uxxx\UXXXXXXXX 或其他转义字符替换。因此,许多访问操作系统的函数在 Windows 上具有复杂的语义和实现。例如,对于有效路径,normalizePath 会尝试返回一个有效的路径,即指向同一文件的路径。在朴素的实现中,归一化路径可能不存在或由于最合适而指向不同的文件,即使原始路径完全可表示且有效也是如此。

Windows 上的 R 的这一限制对于需要使用其本机编码中不可表示的字符的用户来说,是一个痛苦的根源。R 提供了有时会绕过转换的“快捷方式”,例如,通过 readLines 读取 UTF-8 文本文件时,但这些快捷方式仅适用于某些情况,即不涉及外部软件且使用它们很困难时。

最后,最新的 Windows 10 允许将 UTF-8 设置为本机编码。R 已被修改为允许此设置,这并不难,因为 Unix/macOS 已支持此设置多年。

坏消息是 Windows 上的 UTF-8 支持需要通用 C 运行时 (UCRT),这是一种新的 C 运行时。我们需要一个新的编译器工具链,并且必须为 R 和软件包重新构建所有外部库:使用较旧工具链(Rtools 4 及更早版本)构建的对象文件无法重新使用。

UCRT 可以安装在较早版本的 Windows 上,但 UTF-8 支持仅适用于 Windows 10(2019 年 11 月更新)及更高版本。

本文的其余部分将更详细地解释本机 UTF-8 支持将为 Windows R 用户带来的好处。为了让非软件包开发人员的 R 用户能够理解,本文简化了许多细节。为软件包开发人员和 Windows 上构建 R 的基础设施维护人员提供了额外的文本,此处提供了有关如何使用不同基础设施构建 R 以及如何使用 UCRT 构建 R 的详细信息。

提供 R 和推荐软件包的二进制安装程序演示(稍后本文中会提供链接),以及一个演示工具链,其中包含许多(但并非全部)CRAN/BIOC 软件包的库和头文件。

对 RGui 的影响

RGui(RStudio 类似,因为它使用相同的 R 接口)是一个仅适用于 Windows 的应用程序,使用 Windows API 和 UTF-16LE 实现。在 R 4.0 及更早版本中,RGui 已经可以处理所有 Unicode 字符。

RGui 可以打印 UTF-8 R 字符串。在使用 RGui 运行时,R 会转义 UTF-8 字符串,并将它们嵌入到字符串中,否则在输出时会使用本机编码。RGui 理解这种专有编码,并在打印前转换为 UTF-16LE。这旨在用于 R 为 RGui 生成的所有输出,但这种方法有其局限性:在设置输出格式时会变得复杂,并且 R 还不清楚它将在哪里打印。许多极端情况已经得到修复,其中一些是最近修复的,但可能还有一些遗留问题。

RGui 已经可以将 Unicode 字符串传递给 R。这是通过另一个半专有嵌入实现的,RGui 将 UTF-16LE 字符串转换为本机编码,但用解析器可以理解的 \u\U 转义符替换不可表示的字符。然后,解析器会将它们转换为 R UTF-8 字符串。这意味着不可表示的字符只能在 R 允许 \u\U 转义符的地方使用,其中包括 R 字符串文字(它是最重要的),但此类字符甚至不能出现在注释中。

这里顺便提一下,我认为为了保持软件开发的国际协作,所有代码都应该使用 ASCII,当然所有符号都应该使用 ASCII,我甚至会说包括注释在内也应该使用英语。但 R 仍然可以交互使用,这是一个技术限制,而不是故意强制执行的要求。

例如,可以将这些捷克语字符粘贴到 Rgui 中:ěščřžýáíé。在使用英语区域设置运行的 Windows 上

> x <- "ěščřžýáíé"
> Encoding(x)
[1] "UTF-8"
> x
[1] "ěščřžýáíé"

这工作得很好。但是,注释已经出现问题

> f <- function() {
+ x # ěščřžýáíé
+ }
> f
function() {
x # \u11bš\u10d\u159žýáíé
}

有些字符没问题,有些字符有问题。

在 R 的实验版本中,UTF-8 是本机编码,因此 RGui 在向 R 发送文本时不会使用任何 \u\U 转义符,并且 R 不会嵌入任何 UTF-8 字符串,因为本机编码已经是 UTF-8。上面的示例就可以正常工作

> f <- function() {
+ x # ěščřžýáíé
+ }
> f
function() {
x # ěščřžýáíé
}

在实验版本中,UTF-8 会自动选择为当前区域设置的编码

> Sys.getlocale()
[1] "LC_COLLATE=English_United States.utf8;LC_CTYPE=English_United States.utf8;LC_MONETARY=English_United States.utf8;LC_NUMERIC=C;LC_TIME=English_United States.utf8"
> 

请注意,RGui 仍然需要使用可以正确表示字符的字体。同样,并非所有字体都应该正确显示本文中的示例。

对 RTerm 的影响

RTerm 是一款 Windows 应用程序,不使用 Unicode,与 R 的大多数部分一样,它使用标准 C 库进行实现,假设编码特定的操作将根据 C 区域设置正常工作。在 R 4.0 及更早版本中,RTerm 无法处理不可表示的字符。

我们甚至无法将不可表示的字符粘贴到 R 中。它们将自动转换为本机编码。粘贴“ěščřžýáíé”会产生

> escrzyáíé

对于在英语区域设置中运行的 Windows 上的捷克语文本,情况并没有那么糟糕(仅删除了一些变音符号),但仍然不是准确的表示。对于在英语区域设置中运行的 Windows 上的亚洲语言,结果不可用。

原则上,我们可以手动使用 \u\U 序列来输入字符串,但它们仍然无法正确打印

> x <- "\u11b\u161\u10d\u159\u17e\u0fd\u0e1\u0ed\u0e9"
> Encoding(x)
[1] "UTF-8"
> x
[1] "escrzyáíé"
> as.hexmode(utf8ToInt(x))
[1] "11b" "161" "10d" "159" "17e" "0fd" "0e1" "0ed" "0e9"

输出显示字符串在 R 内部是正确的,只是无法在 RTerm 上正确打印。

在 R 的实验版本中,如果我们运行 cmd.exe,然后在运行 RTerm 之前通过“chcp 65001”将代码页更改为 UTF-8,则它将按预期工作

> x <- "ěščřžýáíé"
> x
[1] "ěščřžýáíé"
> x <- "ěščřžýáíé"
> Encoding(x)
[1] "UTF-8"
> x
[1] "ěščřžýáíé"

本文没有详细说明字符在何处确切转换/最佳拟合,但关键在于,使用 UTF-8 版本并在 UTF-8 代码页 (65001) 中运行 cmd.exe 时,无需修改 RTerm 代码,RTerm 即可使用 Unicode 字符。

与 RGui 一样,终端还需要适当的字体。使用日语文本的示例:こんにちは, 今日は

> x <- "こんにちは, 今日は"
> Encoding(x)
[1] "UTF-8"
> x
[1] "こんにちは, 今日は"

此示例在我的系统上使用实验版本运行良好,但使用默认字体(Consolas)时,字符会被方框中的问号替换。不过,只需在 cmd.exe 菜单中切换到另一种字体,例如 FangSong,字符就会在已打印的文本中正确显示。当将字符粘贴到使用正确字体的应用程序时,字符也会正确显示。

与操作系统交互的影响

Windows 上的 R 已在许多情况下使用 Windows API,而不是标准 C 库,以避免转换或访问 Windows 特定的功能。更具体地说,R 尝试在将字符串传递给操作系统时始终执行此操作,例如,创建具有不可表示名称的文件已经可以工作。R 将 UTF-8 字符串转换为 UTF16-LE,Windows 可以理解。但是,R 包或外部库通常不会有这样的 Windows 特定代码,并且无法做到这一点。使用实验版本,这些问题消失了,因为标准 C 函数(通常又会调用非 Unicode Windows API)将使用 UTF-8。

另一种情况是从操作系统获取字符串,例如列出目录中的文件。在这种情况下,Windows 上的 R 使用 C(非 Unicode)API 或转换为本机编码,除非这是对已经是 UTF-8 的输入的直接转换。有关详细信息,请参阅 R 文档;此文本简化了技术细节。

原则上,R 还可以使用 Windows 特定的 UTF-16LE 调用并将字符串转换为 UTF-8,R 可以表示 UTF-8。鉴于在将字符串传递给 Windows 的函数上花费了大量精力,这不会带来更多工作。

但是,R 一直小心不要为用户尚未有意设置为 UTF-8 的内容引入 UTF-8 字符串,因为这会导致无法正确处理编码的包出现问题。此类包在错误地使用 UTF-8 中的字符串但认为它们处于本机编码时,会神秘地开始失败。自动化测试不会发现此类问题,因为测试不使用此类不寻常的输入,并且通常在英语或类似区域设置中运行。

这种预防措施以增加复杂性为代价。例如,如果我们允许引入 UTF-8 字符串,则 normalizePath 实现可以减少一半的代码大小甚至更少。相反,R 规范“更少”,例如,如果不提供帮助,则不遵循符号链接,但会为本机编码中的符号链接生成可表示的路径名。

以 UTF-8 作为本机编码,不再需要这些考虑因素。在目录中列出不可表示的文件不再是一个问题(当是有效的 Unicode 时),并且它在实验性版本中无需任何代码更改即可工作。

另一个问题是与早在 Windows 10 之前就开始以自己的方式解决此问题的外部库。一些库绕过任何外部代码和 C 字符串库,并使用 UTF-8 或 UTF-16LE 执行字符串操作,有时借助外部库(通常是 ICU)。

当 R 与此类库交互时,它需要知道这些库期望哪种编码,并且随着库的演变,有时会从本机编码更改为 UTF-8。例如,Cairo 切换到 UTF-8,因此 R 必须注意,并且必须将较新 Cairo 版本的字符串转换为 UTF-8,但将较旧版本的字符串转换为本机编码。

有时很难注意到这种变化,因为类型保持不变,char *。处理这些情况也会增加代码复杂性。人们必须仔细阅读外部库的更改日志,否则会遇到难以调试且几乎不可能通过测试检测到的错误,因为它们不使用不寻常的字符。随着 UTF-8 成为本机编码,外部库的此类转换将不再成为问题。

对内部功能的影响

R 允许在 R 字符对象中对字符串进行多种编码,并带有标志,表明它是 UTF-8、Latin 1 还是本机编码。但是,最终在与 C 库、操作系统和其他外部库或与合并到 R 中的外部代码交互时,必须将字符串转换为 char *

从历史上看,假设一旦键入 char *,字符串始终采用一种编码,然后需要采用本机编码。这很有道理,因为否则维护代码会变得困难,但 R 做了一些例外,例如 readLines 中的快捷方式,有时将字符串保留为 R 字符对象会有所帮助。不过,有时仅为了获得字符串的 char * 表示形式而进行本机编码转换,即使尚未与 C/外部代码交互。当 UTF-8 成为本机编码时,所有这些转换都会消失。

一个相关的示例是 R 符号。它们需要有一个唯一表示形式,该表示形式由存储在 R 符号表中的指针定义。对于任何有效的实现,它们需要采用相同的编码,现在是本机编码。一个合乎逻辑的改进是改为转换为 UTF-8,但这可能会产生非同小可的性能开销。当 UTF-8 成为本机编码时,这些问题就不再存在。

在 R 4.0 中,此限制对哈希映射产生了不良影响

e <- new.env(hash=TRUE)
assign("a", "letter a", envir=e)
assign("\u3b1", "letter alpha", envir=e)
ls(e)

在 Windows 上,这会生成一个仅包含一个名为“a”的元素的哈希映射,因为 \u3b1 (α) 最适合 Windows 的字母“a”。使用实验版本时,它可以正常工作,就像在 Unix/macOS 上一样,向哈希映射添加两个元素。即使使用非 ASCII 变量名可能不是正确的方法,哈希映射也确实应该能够支持任意 Unicode 密钥。

演示

可以从 此处 下载 R 的实验版本。它具有基本包和推荐包,但无法与使用本机代码的其他包一起使用。实验工具链允许测试更多包(但不是所有 CRAN/BIOC),更多信息请参见 此处,可能会在不通知的情况下进行更新(始终有其 SVN 历史记录)。不适用于生产用途。