查看源代码 在 Erlang 中使用 Unicode

Unicode 实现

实现对 Unicode 字符集的支持是一个持续的过程。《Erlang 增强提案 (EEP) 10》概述了 Unicode 支持的基础知识,并指定了二进制文件中的默认编码,所有支持 Unicode 的模块都将在未来处理该编码。

以下是目前已完成的工作的概述

  • EEP10 中描述的功能已在 Erlang/OTP R13A 中实现。

  • Erlang/OTP R14B01 添加了对 Unicode 文件名的支持,但它并不完整,并且在无法保证文件名编码的平台上默认禁用。

  • Erlang/OTP R16A 引入了对 UTF-8 编码源代码的支持,并增强了许多应用程序以支持 Unicode 编码的文件名以及在许多情况下对 UTF-8 编码文件的支持。最值得注意的是支持 file:consult/1 读取的 UTF-8 文件、发布处理程序对 UTF-8 的支持以及 I/O 系统中对 Unicode 字符集的更多支持。

  • 在 Erlang/OTP 17.0 中,Erlang 源文件的默认编码切换为 UTF-8。

  • 在 Erlang/OTP 20.0 中,原子和函数可以包含 Unicode 字符。模块名称、应用程序名称和节点名称仍然限制在 ISO Latin-1 范围内。

    unicode 中添加了对规范化形式的支持,并且 string 模块现在可以处理 UTF-8 编码的二进制文件。

本节概述了当前的 Unicode 支持,并提供了一些使用 Unicode 数据的技巧。

理解 Unicode

在 Erlang 中使用 Unicode 支持的经验表明,理解 Unicode 字符和编码并不像人们预期的那么容易。该领域的复杂性以及标准的含义需要对以前很少考虑的概念进行透彻的理解。

此外,Erlang 的实现需要理解许多(Erlang)程序员从未遇到的概念。要理解和使用 Unicode 字符,即使您是一位经验丰富的程序员,也需要彻底研究该主题。

举例来说,考虑一下在大小写字母之间转换的问题。阅读标准会让您意识到,并非所有脚本中都存在简单的一对一映射,例如

  • 在德语中,字母 “ß” (sharp s) 是小写,但大写等效项是 “SS”。
  • 在希腊语中,字母 “Σ” 有两种不同的小写形式,“ς” 在单词末尾,而 “σ” 在其他地方。
  • 在土耳其语中,带点和不带点的 “i” 都存在小写和大写形式。
  • 西里尔文 “I” 通常没有小写形式。
  • 没有大写(或小写)概念的语言。

因此,转换函数不仅必须一次知道一个字符,还可能必须知道整个句子、要翻译的自然语言、输入和输出字符串长度的差异等等。Erlang/OTP 目前没有具有特定于语言处理的 Unicode uppercase/lowercase 功能,但公开可用的库解决了这些问题。

另一个例子是重音字符,其中同一个字形有两种不同的表示形式。瑞典字母 “ö” 就是一个例子。Unicode 标准为此定义了一个代码点,但您也可以将其写为 “o”,后跟 “U+0308”(组合变音符,其简化含义是最后一个字母上方带有 “¨”)。它们具有相同的字形,用户感知到的字符。对于大多数用途来说,它们是相同的,但具有不同的表示形式。例如,MacOS X 将所有文件名转换为使用组合变音符,而大多数其他程序(包括 Erlang)在列出目录时会尝试通过执行相反的操作来隐藏这一点。但是,无论如何进行,通常都需要对这些字符进行规范化以避免混淆。

例子列表可以列得很长。当程序只考虑一两种语言时,需要一种不需要的知识。人类语言和文字的复杂性在构建通用标准时无疑带来了挑战。在您的程序中正确支持 Unicode 将需要付出努力。

什么是 Unicode

Unicode 是一种标准,它为所有已知(无论是现存的还是已消亡的)文字定义了代码点(数字)。原则上,任何语言中使用的每个符号都有一个 Unicode 代码点。Unicode 代码点由 Unicode 联盟定义和发布,该联盟是一个非营利组织。

随着一个通用字符集的好处在全球环境中使用程序时变得非常明显,对 Unicode 的支持在整个计算领域都在增加。除了标准的基础,即所有文字的代码点之外,还有一些编码标准可用。

理解编码和 Unicode 字符之间的区别至关重要。Unicode 字符是根据 Unicode 标准的代码点,而编码是表示此类代码点的方式。编码只是表示的标准。例如,UTF-8 可以用来表示 Unicode 字符集的非常有限的部分(例如 ISO-Latin-1)或完整的 Unicode 范围。它只是一种编码格式。

只要所有字符集都限制为 256 个字符,每个字符就可以存储在单个字节中,因此字符的实际编码或多或少只有一种。以一个字节编码每个字符非常普遍,以至于该编码甚至没有被命名。使用 Unicode 系统,有超过 256 个字符,因此需要一种通用的表示方法。表示代码点的常用方法是编码。这意味着对于程序员来说是一个全新的概念,即字符表示的概念,这在以前不是问题。

不同的操作系统和工具支持不同的编码。例如,Linux 和 MacOS X 选择了 UTF-8 编码,它与 7 位 ASCII 向后兼容,因此对以纯英语编写的程序的影响最小。Windows 支持 UTF-16 的有限版本,即所有字符可以存储在单个 16 位实体中的代码平面,其中包括大多数现有语言。

以下是最广泛使用的编码

  • 字节表示 - 这不是适当的 Unicode 表示,而是 Unicode 标准之前用于字符的表示。它仍然可以用于表示 Unicode 标准中数字 < 256 的字符代码点,这与 ISO Latin-1 字符集完全对应。在 Erlang 中,这通常表示为 latin1 编码,这有点误导,因为 ISO Latin-1 是字符代码范围,而不是编码。

  • UTF-8 - 每个字符都存储在一到四个字节中,具体取决于代码点。该编码与 7 位 ASCII 的字节表示向后兼容,因为所有 7 位字符都存储在 UTF-8 中的单个字节中。代码点 127 以外的字符存储在更多字节中,让第一个字符中的最高有效位指示多字节字符。有关编码的详细信息,RFC 是公开可用的。

    请注意,对于 128 到 255 的代码点,UTF-8 与字节表示兼容,因此 ISO Latin-1 字节表示通常与 UTF-8 不兼容。

  • UTF-16 - 此编码与 UTF-8 有许多相似之处,但基本单元是一个 16 位数字。这意味着所有字符至少占用两个字节,一些高位数字占用四个字节。一些声称使用 UTF-16 的程序、库和操作系统只允许可以存储在单个 16 位实体中的字符,这通常足以处理现有语言。由于基本单元不止一个字节,因此会出现字节顺序问题,这就是 UTF-16 同时存在大端和小端变体的原因。

    在 Erlang 中,当适用时,支持完整的 UTF-16 范围,例如在 unicode 模块和位语法中。

  • UTF-32 - 最直接的表示形式。每个字符都存储在单个 32 位数字中。不需要转义符或任何可变数量的实体来表示一个字符。所有 Unicode 代码点都可以存储在单个 32 位实体中。与 UTF-16 一样,存在字节顺序问题。UTF-32 可以是大端和小端。

  • UCS-4 - 基本上与 UTF-32 相同,但没有 IEEE 定义的一些 Unicode 语义,并且作为单独的编码标准几乎没有用处。对于所有正常(也可能是异常)使用,UTF-32 和 UCS-4 是可以互换的。

某些数字范围在 Unicode 标准中未使用,甚至某些范围被视为无效。最值得注意的无效范围是 16#D800-16#DFFF,因为 UTF-16 编码不允许编码这些数字。这可能是因为 UTF-16 编码标准从一开始就被期望能够在单个 16 位实体中保存所有 Unicode 字符,但随后进行了扩展,在 Unicode 范围中留下了一个漏洞来处理向后兼容性。

代码点 16#FEFF 用于字节顺序标记 (BOM),不鼓励在其他上下文中使用该字符。但它有效,因为字符“ZWNBS”(零宽度不间断空格)。BOM 用于标识事先不知道此类参数的程序的编码和字节顺序。BOM 的使用比预期的要少,但由于它们为程序提供了对某个文件的 Unicode 格式进行有根据的猜测的方法,因此可能会变得更加广泛。

Unicode 支持领域

为了在 Erlang 中支持 Unicode,已经解决了各个领域的问题。本节将简要介绍每个领域,并在本用户指南的后面部分进行更详细的介绍。

  • 表示形式 - 要在 Erlang 中处理 Unicode 字符,需要在列表和二进制文件中使用通用表示形式。EEP (10) 和随后在 Erlang/OTP R13A 中的初始实现确定了 Erlang 中 Unicode 字符的标准表示形式。

  • 操作 - Unicode 字符需要由 Erlang 程序处理,这就是为什么库函数必须能够处理它们的原因。在某些情况下,已将功能添加到现有接口(例如,string 模块现在可以处理具有任何代码点的字符串)。在某些情况下,已添加新的功能或选项(如在 io 模块、文件处理、unicode 模块和位语法中)。如今,Kernel 和 STDLIB 中的大多数模块以及 VM 都支持 Unicode。

  • 文件 I/O - I/O 是 Unicode 最麻烦的领域。文件是存储字节的实体,而编程的传统是将字符和字节视为可互换的。使用 Unicode 字符,您必须在要将数据存储在文件中时确定编码。在 Erlang 中,您可以打开具有编码选项的文本文件,以便可以从中读取字符而不是字节,但也可以打开一个文件进行按字节 I/O。

    Erlang I/O 系统在设计(或至少使用)的方式中,您期望任何 I/O 服务器都处理任何字符串数据。但是,在使用 Unicode 字符时,情况不再如此。Erlang 程序员现在必须知道数据最终到达的设备的功能。此外,Erlang 中的端口是面向字节的,因此在未先将其转换为所选编码的情况下,不能将任意(Unicode)字符字符串发送到端口。

  • 终端 I/O - 终端 I/O 比文件 I/O 稍微简单一些。输出是为人类阅读而设计的,通常是 Erlang 语法(例如,在 shell 中)。任何 Unicode 字符都有语法表示,而无需显示字形(而是写成 \x{HHH})。因此,即使终端本身不支持整个 Unicode 范围,通常也可以显示 Unicode 数据。

  • 文件名 - 文件名可以以不同的方式存储为 Unicode 字符串,具体取决于底层操作系统和文件系统。程序可以很容易地处理这种情况。当文件系统的编码不一致时,问题就会出现。例如,Linux 允许使用任何字节序列来命名文件,留给每个程序来解释这些字节。在使用这些“透明”文件名的系统上,必须通过启动标志告知 Erlang 文件名编码。默认是按字节解释,这通常是错误的,但允许解释所有文件名。

    如果在一个平台上启用了 Unicode 文件名转换(+fnu),而该平台默认情况下没有启用,则可以使用“原始文件名”的概念来处理错误编码的文件名。

  • 源代码编码 - Erlang 源代码支持 UTF-8 编码和按字节编码。Erlang/OTP R16B 中的默认值是按字节(latin1)编码。在 Erlang/OTP 17.0 中,它更改为 UTF-8。您可以通过文件开头类似以下内容的注释来控制编码

    %% -*- coding: utf-8 -*-

    当然,这需要您的编辑器也支持 UTF-8。相同的注释也会被 file:consult/1、发布处理程序等函数解释,以便您可以在源代码目录中拥有所有 UTF-8 编码的文本文件。

  • 语言 - 以 UTF-8 编码编写源代码还允许您编写包含代码点 > 255 的 Unicode 字符的字符串字面量、函数名称和原子。模块名称、应用程序名称和节点名称仍然限制在 ISO Latin-1 范围内。可以使用 Unicode 字符 > 255 来表示使用 /utf8 类型的二进制字面量。在具有不一致的文件命名方案的操作系统上,使用非 7 位 ASCII 字符的模块名称或应用程序名称可能会导致问题,并会损害可移植性,因此不建议这样做。

    EEP 40 建议该语言还允许在变量名中使用代码点 > 255 的 Unicode 字符。是否实现该 EEP 尚未决定。

标准 Unicode 表示

在 Erlang 中,字符串是整数列表。在 Erlang/OTP R13 之前,字符串被定义为以 ISO Latin-1 (ISO 8859-1) 字符集编码,该字符集是 Unicode 字符集的子范围,按代码点逐一对应。

因此,字符串的标准列表编码很容易扩展以处理整个 Unicode 范围。Erlang 中的 Unicode 字符串是一个包含整数的列表,其中每个整数都是有效的 Unicode 代码点,并表示 Unicode 字符集中的一个字符。

ISO Latin-1 中的 Erlang 字符串是 Unicode 字符串的子集。

只有当字符串包含代码点 < 256 时,才能通过使用例如 erlang:iolist_to_binary/1 直接将其转换为二进制,或者直接发送到端口。如果字符串包含 Unicode 字符 > 255,则必须确定一种编码,并使用 unicode:characters_to_binary/1,2,3 将字符串转换为首选编码的二进制。字符串通常不是字节列表,就像在 Erlang/OTP R13 之前一样,它们是字符列表。字符通常不是字节,它们是 Unicode 代码点。

二进制文件更麻烦。出于性能原因,程序通常将文本数据存储在二进制文件中,而不是列表中,主要是因为它们更紧凑(每个字符一个字节,而不是列表中的每个字符两个字)。使用 erlang:list_to_binary/1,可以将 ISO Latin-1 Erlang 字符串转换为二进制文件,有效地使用按字节编码:每个字符一个字节。这对于那些有限的 Erlang 字符串来说很方便,但是对于任意的 Unicode 列表来说是无法做到的。

由于 UTF-8 编码被广泛使用,并在 7 位 ASCII 范围内提供了一些向后兼容性,因此它被选为 Erlang 中二进制文件中 Unicode 字符的标准编码。

当 Erlang 中的库函数要处理二进制文件中的 Unicode 数据时,会使用标准二进制编码,当然,在外部通信时不会强制执行。存在用于在二进制文件中编码和解码 UTF-8、UTF-16 和 UTF-32 的函数和位语法。但是,通常处理二进制文件和 Unicode 的库函数只处理默认编码。

字符数据可以来自多个来源,有时以字符串和二进制文件的混合形式提供。长期以来,Erlang 具有 iodataiolist 的概念,其中可以将二进制文件和列表组合起来以表示字节序列。以相同的方式,支持 Unicode 的模块通常允许组合二进制文件和列表,其中二进制文件中的字符以 UTF-8 编码,而列表包含此类二进制文件或表示 Unicode 代码点的数字。

unicode_binary() = binary() with characters encoded in UTF-8 coding standard

chardata() = charlist() | unicode_binary()

charlist() = maybe_improper_list(char() | unicode_binary() | charlist(),
  unicode_binary() | nil())

模块 unicode 甚至支持类似的混合,其中二进制文件包含 UTF-8 之外的其他编码,但这是一种特殊情况,允许与外部数据进行转换。

external_unicode_binary() = binary() with characters coded in a user-specified
  Unicode encoding other than UTF-8 (UTF-16 or UTF-32)

external_chardata() = external_charlist() | external_unicode_binary()

external_charlist() = maybe_improper_list(char() | external_unicode_binary() |
  external_charlist(), external_unicode_binary() | nil())

基本语言支持

从 Erlang/OTP R16 开始,Erlang 源文件可以用 UTF-8 或按字节 (latin1) 编码编写。有关如何声明 Erlang 源文件的编码的信息,请参阅 epp 模块。从 Erlang/OTP R16 开始,可以使用 Unicode 编写字符串和注释。从 Erlang/OTP 20 开始,还可以使用 Unicode 编写原子和函数。模块、应用程序和节点仍然必须使用 ISO Latin-1 字符集中的字符来命名。(语言中的这些限制与源文件的编码无关。)

位语法

位语法包含用于处理三种主要编码的二进制数据的类型。这些类型名为 utf8utf16utf32utf16utf32 类型可以是 big-endian 或 little-endian 变体。

<<Ch/utf8,_/binary>> = Bin1,
<<Ch/utf16-little,_/binary>> = Bin2,
Bin3 = <<$H/utf32-little, $e/utf32-little, $l/utf32-little, $l/utf32-little,
$o/utf32-little>>,

为了方便起见,可以使用以下(或类似的)语法将字面字符串以 Unicode 编码在二进制文件中编码

Bin4 = <<"Hello"/utf16>>,

字符串和字符字面量

对于源代码,语法 \OOO(反斜杠后跟三个八进制数字)和 \xHH(反斜杠后跟 x,后跟两个十六进制字符)有一个扩展,即 \x{H...}(反斜杠后跟 x,后跟左花括号,任意数量的十六进制数字,以及一个终止右花括号)。即使源文件的编码是按字节(latin1)编码,这也允许在字符串中输入任何代码点的字符。

在 shell 中,如果使用 Unicode 输入设备,或者在以 UTF-8 存储的源代码中,$ 可以直接后跟一个 Unicode 字符,从而生成一个整数。在以下示例中,输出西里尔字母 с 的代码点

7> $с.
1089

启发式字符串检测

在某些输出函数中,以及在 shell 中返回值输出时,Erlang 会尝试启发式地检测列表和二进制文件中的字符串数据。通常,您会在如下情况下看到启发式检测

1> [97,98,99].
"abc"
2> <<97,98,99>>.
<<"abc">>
3> <<195,165,195,164,195,182>>.
<<"åäö"/utf8>>

这里,shell 检测包含可打印字符的列表或包含按字节或 UTF-8 编码的可打印字符的二进制文件。但是什么是可打印字符?一种观点是,Unicode 标准认为可打印的任何内容,根据启发式检测也是可打印的。结果是,几乎任何整数列表都被视为字符串,并且打印各种字符,也可能打印您的终端字体集中缺少的字符(导致一些不受欢迎的通用输出)。另一种方法是保持向后兼容性,以便仅使用 ISO Latin-1 字符集来检测字符串。第三种方法是让用户决定将哪些 Unicode 范围视为字符。

从 Erlang/OTP R16B 开始,您可以通过提供启动标志 +pc latin1+pc unicode 来分别选择 ISO Latin-1 范围或整个 Unicode 范围。为了向后兼容,latin1 是默认值。这仅控制如何进行启发式字符串检测。预计将来会添加更多范围,以便根据与用户相关的语言和地区定制启发式方法。

以下示例显示了两个启动选项

$ erl +pc latin1
Erlang R16B (erts-5.10.1) [source] [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.10.1  (abort with ^G)
1> [1024].
[1024]
2> [1070,1085,1080,1082,1086,1076].
[1070,1085,1080,1082,1086,1076]
3> [229,228,246].
"åäö"
4> <<208,174,208,189,208,184,208,186,208,190,208,180>>.
<<208,174,208,189,208,184,208,186,208,190,208,180>>
5> <<229/utf8,228/utf8,246/utf8>>.
<<"åäö"/utf8>>
$ erl +pc unicode
Erlang R16B (erts-5.10.1) [source] [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.10.1  (abort with ^G)
1> [1024].
"Ѐ"
2> [1070,1085,1080,1082,1086,1076].
"Юникод"
3> [229,228,246].
"åäö"
4> <<208,174,208,189,208,184,208,186,208,190,208,180>>.
<<"Юникод"/utf8>>
5> <<229/utf8,228/utf8,246/utf8>>.
<<"åäö"/utf8>>

在这些示例中,您可以看到默认的 Erlang shell 仅将 ISO Latin1 范围内的字符解释为可打印的,并且仅检测包含那些“可打印”字符的列表或二进制文件,并将它们视为包含字符串数据。包含俄语单词“Юникод”的有效 UTF-8 二进制文件未打印为字符串。当以所有 Unicode 字符可打印(+pc unicode)启动时,shell 会将任何包含可打印 Unicode 数据(在二进制文件中,无论是 UTF-8 还是按字节编码)的内容输出为字符串数据。

当修饰符 t~p~P 一起使用时,io:format/2io_lib:format/2 和类似函数也会使用这些启发式方法。

$ erl +pc latin1
Erlang R16B (erts-5.10.1) [source] [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.10.1  (abort with ^G)
1> io:format("~tp~n",[{<<"åäö">>, <<"åäö"/utf8>>, <<208,174,208,189,208,184,208,186,208,190,208,180>>}]).
{<<"åäö">>,<<"åäö"/utf8>>,<<208,174,208,189,208,184,208,186,208,190,208,180>>}
ok
$ erl +pc unicode
Erlang R16B (erts-5.10.1) [source] [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.10.1  (abort with ^G)
1> io:format("~tp~n",[{<<"åäö">>, <<"åäö"/utf8>>, <<208,174,208,189,208,184,208,186,208,190,208,180>>}]).
{<<"åäö">>,<<"åäö"/utf8>>,<<"Юникод"/utf8>>}
ok

请注意,这只会影响对输出上的列表和二进制文件的启发式解释。例如,无论 +pc 设置如何,~ts 格式序列始终输出有效的字符列表,因为程序员已明确请求字符串输出。

交互式 Shell

交互式 Erlang shell 可以支持 Unicode 输入和输出。

在 Windows 上,正确操作需要为 Erlang 应用程序安装并选择合适的字体。如果您的系统上没有合适的字体,请尝试安装DejaVu 字体,这些字体是免费提供的,然后在 Erlang shell 应用程序中选择该字体。

在类 Unix 操作系统上,终端必须能够在输入和输出上处理 UTF-8(例如,通过现代版本的 XTerm、KDE Konsole 和 Gnome 终端完成),并且您的区域设置必须正确。例如,可以如下设置 LANG 环境变量

$ echo $LANG
en_US.UTF-8

大多数系统在 LANG 之前处理变量 LC_CTYPE,因此如果设置了该变量,则必须将其设置为 UTF-8

$ echo $LC_CTYPE
en_US.UTF-8

LANGLC_CTYPE 设置应与终端的功能一致。Erlang 没有可移植的方式来询问终端的 UTF-8 功能,我们必须依赖于语言和字符类型设置。

要调查 Erlang 对终端的看法,当 shell 启动时可以使用调用 io:getopts()

$ LC_CTYPE=en_US.ISO-8859-1 erl
Erlang R16B (erts-5.10.1) [source] [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.10.1  (abort with ^G)
1> lists:keyfind(encoding, 1, io:getopts()).
{encoding,latin1}
2> q().
ok
$ LC_CTYPE=en_US.UTF-8 erl
Erlang R16B (erts-5.10.1) [source] [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.10.1  (abort with ^G)
1> lists:keyfind(encoding, 1, io:getopts()).
{encoding,unicode}
2>

当(最终?)一切都符合区域设置、字体和终端模拟器的要求时,您可能已经找到一种以您想要的脚本输入字符的方法。对于测试,最简单的方法是为其他语言添加一些键盘映射,通常使用桌面环境中的某些小程序完成。

在 KDE 环境中,选择KDE 控制中心(个人设置) > 区域和辅助功能 > 键盘布局

在 Windows XP 中,选择控制面板 > 区域和语言选项,选择语言选项卡,然后在名为文本服务和输入语言的方框中单击详细信息...按钮。

您的环境可能提供类似的更改键盘布局的方法。如果您不习惯这样做,请确保您有一种可以轻松地在键盘之间来回切换的方法。例如,在 Erlang shell 中使用西里尔字符集输入命令并不容易。

现在您已经设置好进行一些 Unicode 输入和输出了。最简单的事情就是在 shell 中输入一个字符串

$ erl
Erlang R16B (erts-5.10.1) [source] [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.10.1  (abort with ^G)
1> lists:keyfind(encoding, 1, io:getopts()).
{encoding,unicode}
2> "Юникод".
"Юникод"
3> io:format("~ts~n", [v(2)]).
Юникод
ok
4>

虽然字符串可以作为 Unicode 字符输入,但语言元素仍然限于 ISO Latin-1 字符集。只有字符常量和字符串允许超出该范围

$ erl
Erlang R16B (erts-5.10.1) [source] [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.10.1  (abort with ^G)
1> $ξ.
958
2> Юникод.
* 1: illegal character
2>

Escripts 和非交互式 I/O

当 Erlang 在没有交互式 shell 的情况下启动时(-noshell-noinput 或作为 escript),Unicode 支持是使用环境变量来识别的,就像 交互式 shell 一样。在非交互式会话中使用 Unicode 的方式与在交互式会话中完全相同。

在某些情况下,您可能需要能够从 standard_io 读取和写入原始字节。如果是这种情况,则需要将 standard_io_encoding 配置参数设置为 latin1,并使用 file API 来读取和写入数据(如 文件中的 Unicode 数据中所述)。

在下面的示例中,我们首先从 standard_io 读取字符 ξ,然后打印由它表示的 charlist()

#!/usr/bin/env escript
%%! -kernel standard_io_encoding latin1

main(_) ->
  {ok, Char} = file:read_line(standard_io),
  ok = file:write(standard_io, string:trim(Char)),
  ok = file:write(standard_io, io_lib:format(": ~w~n",[string:trim(Char)])),
  ok.
$ escript test.es
ξ
ξ: [206,190]

ξ 通常表示为整数 958,但由于我们使用的是按字节编码 (latin1),它表示为 206 和 190,这是表示 ξ 的 utf-8 字节。当我们把这些字节回显到 standard_io 时,终端会将这些字节视为 utf-8 并显示正确的值,即使在 Erlang 中我们根本不知道它实际上是一个 Unicode 字符串。

Unicode 文件名

大多数现代操作系统都以某种方式支持 Unicode 文件名。有很多不同的方法可以做到这一点,Erlang 默认情况下以不同的方式处理不同的方法

  • 强制 Unicode 文件命名 - Windows、Android 以及大多数情况下,MacOS X 强制执行对文件名的 Unicode 支持。在文件系统中创建的所有文件都具有可以一致解释的名称。在 MacOS X 和 Android 中,所有文件名都以 UTF-8 编码检索。在 Windows 中,每个处理文件名的系统调用都有一个特殊的 Unicode 感知变体,效果大致相同。在这些系统上,没有不是 Unicode 文件名的文件名。因此,Erlang VM 的默认行为是在“Unicode 文件名转换模式”下工作。这意味着可以将文件名指定为 Unicode 列表,该列表会自动转换为底层操作系统和文件系统的正确名称编码。

    例如,在这些系统之一上执行 file:list_dir/1 可以返回带有代码点 > 255 的 Unicode 列表,具体取决于文件系统的内容。

  • 透明文件命名 - 大多数 Unix 操作系统都采用了一种更简单的方法,即不强制执行 Unicode 文件命名,而是按照惯例。这些系统通常对 Unicode 文件名使用 UTF-8 编码,但不强制执行。在这样的系统上,包含代码点从 128 到 255 的字符的文件名可以命名为纯 ISO Latin-1 或使用 UTF-8 编码。由于没有强制执行一致性,Erlang VM 无法对所有文件名进行一致的转换。

    默认情况下,在此类系统上,如果终端支持 UTF-8,则 Erlang 以 utf8 文件名模式启动,否则以 latin1 模式启动。

    latin1 模式下,文件名按字节编码。这允许系统中的所有文件名以列表形式表示。但是,名为“Östersund.txt”的文件在 file:list_dir/1 中显示为“Östersund.txt”(如果文件名是由创建该文件的程序以字节 ISO Latin-1 编码的),或者更有可能显示为 [195,150,115,116,101,114,115,117,110,100],这是一个包含 UTF-8 字节的列表(不是您想要的)。如果您在此类系统上使用 Unicode 文件名转换,则 file:list_dir/1 等函数会忽略非 UTF-8 文件名。可以使用函数 file:list_dir_all/1 检索它们,但错误编码的文件名会显示为“原始文件名”。

Unicode 文件命名支持是在 Erlang/OTP R14B01 中引入的。在 Unicode 文件名转换模式下运行的 VM 可以处理具有任何语言或字符集名称的文件(只要底层操作系统和文件系统支持)。Unicode 字符列表用于表示文件名或目录名。如果列出了文件系统内容,您还会得到 Unicode 列表作为返回值。该支持位于 Kernel 和 STDLIB 模块中,这就是为什么大多数应用程序(不明确要求文件名在 ISO Latin-1 范围内)在无需更改的情况下从 Unicode 支持中获益的原因。

在具有强制 Unicode 文件名的操作系统上,这意味着您可以更轻松地符合其他(非 Erlang)应用程序的文件名。您还可以处理至少在 Windows 上无法访问的文件名(因为它们的名称无法用 ISO Latin-1 表示)。此外,您还可以避免在 MacOS X 上创建难以理解的文件名,因为操作系统的 vfs 层会将您的所有文件名都接受为 UTF-8,而不会重写它们。

对于大多数系统,即使它使用透明文件命名,启用 Unicode 文件名转换也没有问题。很少有系统具有混合的文件名编码。一致的 UTF-8 命名系统在 Unicode 文件名模式下工作良好。但是,它在 Erlang/OTP R14B01 中仍然被认为是实验性的,并且仍然不是此类系统上的默认设置。

Unicode 文件名转换通过开关 +fnu 启用。在 Linux 上,未明确声明文件名转换模式而启动的 VM 默认使用 latin1 作为本机文件名编码。在 Windows、MacOS X 和 Android 上,默认行为是 Unicode 文件名转换。因此,file:native_name_encoding/0 默认在这些系统上返回 utf8 (Windows 不在文件系统级别上使用 UTF-8,但 Erlang 程序员可以安全地忽略这一点)。正如前面所述,可以使用 VM 的选项 +fnu+fnl 更改默认行为,请参阅 erl 程序。如果 VM 在 Unicode 文件名转换模式下启动,则 file:native_name_encoding/0 返回原子 utf8。开关 +fnu 后面可以跟 wie 来控制如何报告错误编码的文件名。

  • w 表示每当在目录列表中“跳过”错误编码的文件名时,都会向 error_logger 发送警告。w 是默认设置。
  • i 表示错误编码的文件名会被静默忽略。
  • e 表示每当遇到错误编码的文件名(或目录名)时,API 函数都会返回错误。

请注意,如果链接指向无效的文件名,则 file:read_link/1 始终返回错误。

在 Unicode 文件名模式下,使用选项 {spawn_executable,...} 提供给 BIF open_port/2 的文件名也被解释为 Unicode。在使用 spawn_executable 时可用的选项 args 中指定的参数列表也是如此。可以使用二进制文件避免参数的 UTF-8 转换,请参阅 关于原始文件名的注意事项部分。

请注意,打开文件时指定的文件编码选项与文件名编码约定无关。您可以很好地打开包含以 UTF-8 编码的数据的文件,但文件名采用按字节 (latin1) 编码,反之亦然。

注意

Erlang 驱动程序和 NIF 共享对象仍然不能使用包含代码点 > 127 的名称命名。此限制将在未来的版本中删除。但是,Erlang 模块可以,但这绝对不是一个好主意,并且仍然被认为是实验性的。

关于原始文件名的注意事项

注意

请注意,原始文件名不一定与操作系统级别的编码方式相同。

原始文件名与 ERTS 5.8.2 (Erlang/OTP R14B01) 中的 Unicode 文件名支持一起引入。在系统中引入“原始文件名”的原因是为了能够一致地表示在同一系统上以不同编码指定的文件名。让 VM 自动将非 UTF-8 的文件名转换为 Unicode 字符列表似乎很实用,但这会打开重复文件名和其他不一致行为的大门。

考虑一个目录,其中包含一个名为“björn”的文件,该文件使用 ISO Latin-1 编码,而 Erlang 虚拟机在 Unicode 文件名模式下运行(因此期望使用 UTF-8 文件名)。 ISO Latin-1 名称不是有效的 UTF-8 编码,人们可能会认为,例如在 file:list_dir/1 中进行自动转换是一个好主意。但是,如果我们稍后尝试打开该文件,并使用 Unicode 列表(从 ISO Latin-1 文件名神奇转换而来)作为文件名,会发生什么?虚拟机将文件名转换为 UTF-8,因为这是预期的编码。实际上,这意味着尝试打开名为 <<"björn"/utf8>> 的文件。此文件不存在,即使它存在,它也与列出的文件不是同一个文件。我们甚至可以创建两个名为“björn”的文件,一个使用 UTF-8 编码,另一个不使用。如果 file:list_dir/1 自动将 ISO Latin-1 文件名转换为列表,我们将得到两个相同的文件名作为结果。为避免这种情况,我们必须区分根据 Unicode 文件命名约定(即 UTF-8)正确编码的文件名和在该编码下无效的文件名。通过通用函数 file:list_dir/1,在 Unicode 文件名转换模式下,错误编码的文件名将被忽略,但通过函数 file:list_dir_all/1,具有无效编码的文件名将作为“原始”文件名返回,即作为二进制数据返回。

file 模块接受原始文件名作为输入。open_port({spawn_executable, ...} ...) 也接受它们。如前所述,在 open_port({spawn_executable, ...} ...) 的选项列表中指定的参数与文件名一样进行相同的转换,这意味着可执行文件也会收到 UTF-8 编码的参数。通过将参数作为二进制数据给出,可以避免这种转换,这与处理文件名的方式一致。

在 Erlang/OTP R14B01 中,强制在默认情况下不启用 Unicode 文件名转换模式的系统上启用该模式被认为是实验性的。这是因为初始实现没有忽略错误编码的文件名,因此原始文件名可能会意外地在整个系统中传播。从 Erlang/OTP R16B 开始,错误编码的文件名仅通过特殊函数(例如 file:list_dir_all/1)检索。由于对现有代码的影响因此大大降低,现在已支持该模式。预计 Unicode 文件名转换将在未来的版本中成为默认设置。

即使您在没有虚拟机自动执行 Unicode 文件命名转换的情况下运行,您也可以通过使用编码为 UTF-8 的原始文件名来访问和创建具有 UTF-8 编码名称的文件。在某些情况下,无论 Erlang 虚拟机以何种模式启动,都强制使用 UTF-8 编码可能是一个好主意,因为使用 UTF-8 文件名的约定正在普及。

关于 MacOS X 的说明

MacOS X 的 vfs 层以一种激进的方式强制使用 UTF-8 文件名。旧版本通过拒绝创建不符合 UTF-8 编码的文件名来实现这一点,而新版本则将违规字节替换为序列“%HH”,其中 HH 是原始字符的十六进制表示。由于 Unicode 转换在 MacOS X 上默认启用,因此遇到这种情况的唯一方法是使用标志 +fnl 启动虚拟机,或者使用字节方式 (latin1) 编码的原始文件名。如果使用包含 127 到 255 字符的字节方式编码的原始文件名来创建文件,则无法使用与创建文件时使用的相同名称打开该文件。除了保持文件名使用正确的编码外,没有其他方法可以解决此问题。

MacOS X 会重新组织文件名,使重音符号等的表示使用“组合字符”。例如,字符 ö 表示为代码点 [111,776],其中 111 是字符 o776 是特殊的重音符号字符“组合变音符号”。这种规范化 Unicode 的方式在其他情况下很少使用。Erlang 在检索时以相反的方式规范化这些文件名,以便不将使用组合重音符号的文件名传递给 Erlang 应用程序。在 Erlang 中,文件名“björn”被检索为 [98,106,246,114,110],而不是 [98,106,117,776,114,110],尽管文件系统可能以不同的方式考虑。访问文件时会重新进行规范化为组合重音符号的操作,因此 Erlang 程序员通常可以忽略这一点。

环境和参数中的 Unicode

环境变量及其解释的处理方式与文件名非常相似。如果启用了 Unicode 文件名,则环境变量以及 Erlang 虚拟机的参数都应使用 Unicode 编码。

如果启用了 Unicode 文件名,则对 os:getenv/0,1os:putenv/2os:unsetenv/1 的调用将处理 Unicode 字符串。在类似 Unix 的平台上,内置函数将 UTF-8 编码的环境变量转换为 Unicode 字符串,反之亦然,可能包含代码点 > 255。在 Windows 上,使用环境系统 API 的 Unicode 版本,并允许代码点 > 255。

在类似 Unix 的操作系统上,如果启用了 Unicode 文件名,则参数应为 UTF-8 编码,而无需转换。

支持 Unicode 的模块

Erlang/OTP 中的大多数模块在某种意义上是不支持 Unicode 的,因为它们没有 Unicode 的概念,并且不应该有。通常,它们处理非文本或面向字节的数据(例如 gen_tcp)。

处理文本数据的模块(例如 io_libstring)有时需要进行转换或扩展,以便能够处理 Unicode 字符。

幸运的是,大多数文本数据都存储在列表中,并且范围检查很少,因此像 string 这样的模块在处理 Unicode 字符串时效果很好,几乎不需要转换或扩展。

但是,有些模块已更改为显式支持 Unicode。这些模块包括

  • unicode - unicode 模块显然支持 Unicode。它包含用于在不同 Unicode 格式之间进行转换的函数,以及一些用于识别字节顺序标记的实用程序。很少有处理 Unicode 数据的程序可以不用此模块。

  • io - io 模块已与实际的 I/O 协议一起扩展,以处理 Unicode 数据。这意味着许多函数要求二进制数据使用 UTF-8 编码,并且还有用于格式化控制序列的修饰符,以允许输出 Unicode 字符串。

  • file, group, user - 整个系统的 I/O 服务器可以处理 Unicode 数据,并具有用于在输出或输入到/从设备时转换数据的选项。如前所示,shell 模块支持 Unicode 终端,并且 file 模块允许在磁盘上与各种 Unicode 格式进行转换。

    但是,使用 file 模块读取和写入包含 Unicode 数据的文件不是最佳选择,因为它的接口是面向字节的。使用 Unicode 编码(如 UTF-8)打开的文件最好使用 io 模块进行读取或写入。

  • re - re 模块允许将 Unicode 字符串作为特殊选项进行匹配。由于该库的核心是在二进制数据中进行匹配,因此 Unicode 支持以 UTF-8 为中心。

  • wx - 图形库 wx 对 Unicode 文本提供广泛的支持。

string 模块可以完美地处理 Unicode 字符串和 ISO Latin-1 字符串,但语言相关的函数 string:uppercase/1string:lowercase/1 除外。这两个函数在当前形式下永远无法正确处理 Unicode 字符,因为在文本大小写之间转换时需要考虑语言和区域设置问题。在国际环境中转换大小写是一个尚未在 OTP 中解决的重大问题。

文件中的 Unicode 数据

尽管 Erlang 可以以多种形式处理 Unicode 数据,但这并不自动意味着任何文件的内容都可以是 Unicode 文本。外部实体,例如端口和 I/O 服务器,通常不具备 Unicode 能力。

端口始终是面向字节的,因此在将不确定是否为字节方式编码的数据发送到端口之前,请确保将其编码为正确的 Unicode 编码。有时这意味着只有部分数据必须编码为 UTF-8,例如。某些部分可以是二进制数据(例如长度指示符)或其他不需要进行字符编码的内容,因此不存在自动转换。

I/O 服务器的行为略有不同。连接到终端(或 stdout)的 I/O 服务器通常可以处理 Unicode 数据,而不管编码选项如何。当人们期望现代环境但又不想在写入陈旧的终端或管道时崩溃时,这很方便。

文件可以具有使其通常可用于 io 模块的编码选项(例如 {encoding,utf8}),但默认情况下会作为面向字节的文件打开。file 模块是面向字节的,因此只能使用该模块写入 ISO Latin-1 字符。如果要将 Unicode 数据输出到具有其他 encoding 而不是 latin1 (字节方式编码)的文件,请使用 io 模块。一个使用 file:open(Name,[read,{encoding,utf8}]) 打开的文件无法使用 file:read(File,N) 正确读取,但可以使用 io 模块从中检索 Unicode 数据,这有点令人困惑。原因是 file:readfile:write(以及相关函数)纯粹是面向字节的,并且应该如此,因为这是逐字节访问文本文件以外的其他文件的方式。与端口一样,您可以通过将数据“手动”转换为所选编码(使用 unicode 模块或位语法),然后在字节方式 (latin1) 编码的文件上输出它,将编码数据写入文件。

建议

  • 对于为字节方式访问打开的文件,请使用 file 模块({encoding,latin1})。
  • 当访问具有任何其他编码的文件时,请使用 io 模块(例如 {encoding,utf8})。

从文件中读取 Erlang 语法的功能会识别 coding: 注释,因此可以在输入时处理 Unicode 数据。当将 Erlang 术语写入文件时,建议在适用时插入此类注释

$ erl +fna +pc unicode
Erlang R16B (erts-5.10.1) [source]  [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.10.1  (abort with ^G)
1> file:write_file("test.term",<<"%% coding: utf-8\n[{\"Юникод\",4711}].\n"/utf8>>).
ok
2> file:consult("test.term").
{ok,[[{"Юникод",4711}]]}

选项总结

Unicode 支持由命令行开关、一些标准环境变量以及您使用的 OTP 版本共同控制。大多数选项主要影响 Unicode 数据的显示方式,而不是标准库中 API 的功能。这意味着 Erlang 程序通常不需要关心这些选项,它们更多地是为开发环境而设的。Erlang 程序可以编写成无论系统类型或生效的 Unicode 选项如何,都能良好地工作。

以下是影响 Unicode 的设置的总结:

  • LANGLC_CTYPE 环境变量 - 操作系统中的语言设置主要影响 shell。仅当环境告知允许 UTF-8 时,终端(即组长)才以 {encoding, unicode} 运行。此设置应与您正在使用的终端相对应。

    如果 Erlang 以标志 +fna 启动(这是 Erlang/OTP 17.0 的默认设置),则环境还会影响文件名解释。

    您可以通过调用 io:getopts() 来检查此设置,该调用会返回一个包含 {encoding,unicode}{encoding,latin1} 的选项列表。

  • erl(1)+pc {unicode|latin1} 标志 - 此标志影响在 shell 和 io/ io_lib:format 中使用 "~tp"~tP 格式化指令进行启发式字符串检测时,将哪些内容解释为字符串数据,如前所述。

    您可以通过调用 io:printable_range/0 来检查此选项,该调用会返回 unicodelatin1。为了与未来(预期)的设置扩展兼容,最好使用 io_lib:printable_list/1 来检查列表是否根据设置可打印。该函数会考虑从 io:printable_range/0 返回的新可能设置。

  • erl(1)+fn{l|u|a} [{w|i|e}] 标志 - 此标志影响如何解释文件名。在具有透明文件命名的操作系统上,必须指定此标志以允许使用 Unicode 字符进行文件命名(以及正确解释包含字符 > 255 的文件名)。

    • +fnl 表示按字节解释文件名,这是在 UTF-8 文件命名普及之前,表示 ISO Latin-1 文件名的常用方式。
    • +fnu 表示文件名以 UTF-8 编码,这是现在常见的方案(尽管不是强制性的)。
    • +fna 表示您根据环境变量 LANGLC_CTYPE+fnl+fnu 之间自动选择。这确实是一种乐观的启发式方法,没有任何规定强制用户使用与文件系统编码相同的终端,但这通常是这种情况。这是所有类 Unix 操作系统的默认设置,除了 MacOS X。

    可以使用函数 file:native_name_encoding/0 读取文件名转换模式,该函数返回 latin1(按字节编码)或 utf8

  • epp:default_encoding/0 - 此函数返回当前运行版本中 Erlang 源文件的默认编码(如果不存在编码注释)。在 Erlang/OTP R16B 中,返回 latin1(按字节编码)。从 Erlang/OTP 17.0 开始,返回 utf8

    可以使用 epp 模块中描述的注释来指定每个文件的编码。

  • io:setopts/1,2standard_io_encoding - Erlang 启动时,standard_io 的编码默认设置为 区域设置指示的编码。您可以通过将内核配置参数 standard_io_encoding 设置为所需的编码来覆盖默认设置。

    您可以使用函数 io:setopts/2 设置文件或其他 I/O 服务器的编码。这也可以在打开文件时设置。将终端(或其他 standard_io 服务器)无条件设置为选项 {encoding,utf8} 意味着 UTF-8 编码的字符被写入设备,无论 Erlang 如何启动或用户的环境如何。

    注意

    如果您使用 io:setopts/2 更改 standard_io 的编码,则 I/O 服务器可能已使用默认编码读取了一些数据。为了避免这种情况,您应该使用 standard_io_encoding 设置编码。

    当以已知编码写入或读取文本文件时,使用选项 encoding 打开文件很方便。

    您可以使用函数 io:getopts() 检索 I/O 服务器的 encoding 设置。

示例

当开始使用 Unicode 时,人们经常会遇到一些常见问题。本节介绍一些处理 Unicode 数据的方法。

字节顺序标记

在文本文件中识别编码的常用方法是在文件开头放置一个字节顺序标记 (BOM)。BOM 是代码点 16#FEFF,其编码方式与其余文件相同。如果要读取此类文件,则前几个字节(取决于编码)不是文本的一部分。此代码概述了如何打开一个被认为具有 BOM 的文件,并设置文件的编码和位置以进行进一步的顺序读取(最好使用 io 模块)。

请注意,代码中省略了错误处理。

open_bom_file_for_reading(File) ->
    {ok,F} = file:open(File,[read,binary]),
    {ok,Bin} = file:read(F,4),
    {Type,Bytes} = unicode:bom_to_encoding(Bin),
    file:position(F,Bytes),
    io:setopts(F,[{encoding,Type}]),
    {ok,F}.

函数 unicode:bom_to_encoding/1 从至少四个字节的二进制数据中识别编码。它与适合设置文件编码的项一起返回 BOM 的字节长度,以便可以相应地设置文件位置。请注意,函数 file:position/2 始终对字节偏移起作用,因此需要 BOM 的字节长度。

打开文件进行写入并将 BOM 放在开头更简单:

open_bom_file_for_writing(File,Encoding) ->
    {ok,F} = file:open(File,[write,binary]),
    ok = file:write(File,unicode:encoding_to_bom(Encoding)),
    io:setopts(F,[{encoding,Encoding}]),
    {ok,F}.

在这两种情况下,最好都使用 io 模块处理文件,因为该模块中的函数可以处理超出 ISO Latin-1 范围的代码点。

格式化 I/O

当读取和写入到 Unicode 感知的实体(例如为 Unicode 转换打开的文件)时,您可能希望使用 io 模块或 io_lib 模块中的函数来格式化文本字符串。出于向后兼容性的原因,这些函数不接受任何列表作为字符串,但在处理 Unicode 文本时需要特殊的转换修饰符。修饰符是 t。当应用于格式化字符串中的控制字符 s 时,它接受所有 Unicode 代码点,并期望二进制数据为 UTF-8。

1> io:format("~ts~n",[<<"åäö"/utf8>>]).
åäö
ok
2> io:format("~s~n",[<<"åäö"/utf8>>]).
åäÃ
ok

显然,第二个 io:format/2 给出的是不需要的输出,因为 UTF-8 二进制数据不在 latin1 中。出于向后兼容性的原因,未加前缀的控制字符 s 期望二进制数据中按字节编码的 ISO Latin-1 字符以及仅包含代码点 < 256 的列表。

只要数据始终是列表,修饰符 t 就可以用于任何字符串,但是当涉及二进制数据时,必须小心选择正确的格式化字符。按字节编码的二进制数据也被解释为字符串,即使在使用 ~ts 时也会打印出来,但它可能会被误认为有效的 UTF-8 字符串。因此,如果二进制数据包含按字节编码的字符而不是 UTF-8,请避免使用 ~ts 控制符。

函数 io_lib:format/2 的行为类似。它被定义为返回深层字符列表,并且可以通过简单的 erlang:list_to_binary/1 轻松地将输出转换为二进制数据以在任何设备上输出。但是,当使用转换修饰符时,列表可能包含无法存储在一个字节中的字符。然后,对 erlang:list_to_binary/1 的调用会失败。但是,如果您要与之通信的 I/O 服务器是 Unicode 感知的,则返回的列表仍然可以直接使用。

$ erl +pc unicode
Erlang R16B (erts-5.10.1) [source] [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.10.1 (abort with ^G)
1> io_lib:format("~ts~n", ["Γιούνικοντ"]).
["Γιούνικοντ","\n"]
2> io:put_chars(io_lib:format("~ts~n", ["Γιούνικοντ"])).
Γιούνικοντ
ok

Unicode 字符串作为 Unicode 列表返回,该列表被识别为 Unicode 列表,因为 Erlang shell 使用 Unicode 编码(并且启动时将所有 Unicode 字符都视为可打印)。Unicode 列表是函数 io:put_chars/2 的有效输入,因此可以在任何支持 Unicode 的设备上输出数据。如果设备是终端,则如果编码为 latin1,则字符以格式 \x{H...} 输出。否则,以 UTF-8(对于非交互式终端:“oldshell”或“noshell”)或适合正确显示字符的任何方式(对于交互式终端:常规 shell)输出。

因此,您可以始终将 Unicode 数据发送到 standard_io 设备。但是,如果 encoding 设置为除 latin1 之外的其他值,则文件只接受 ISO Latin-1 之外的 Unicode 代码点。

UTF-8 的启发式识别

虽然强烈建议在处理二进制数据之前了解其中字符的编码,但这并非总是可行。在典型的 Linux 系统上,存在 UTF-8 和 ISO Latin-1 文本文件的混合,并且文件中很少有 BOM 来标识它们。

UTF-8 的设计使得当解码为 UTF-8 时,超出 7 位 ASCII 范围的 ISO Latin-1 字符很少被认为是有效的。因此,通常可以使用启发式方法来确定文件是 UTF-8 编码还是 ISO Latin-1 编码(每个字符一个字节)。可以使用 unicode 模块来确定数据是否可以解释为 UTF-8。

heuristic_encoding_bin(Bin) when is_binary(Bin) ->
    case unicode:characters_to_binary(Bin,utf8,utf8) of
	Bin ->
	    utf8;
	_ ->
	    latin1
    end.

如果您没有文件的完整二进制内容,则可以分块读取文件并逐部分检查。函数 unicode:characters_to_binary/1,2,3 返回的元组 {incomplete,Decoded,Rest} 会派上用场。从文件中读取的数据块中不完整的部分会添加到下一个数据块之前,因此我们避免了在 UTF-8 编码中读取字节块时出现的字符边界问题。

heuristic_encoding_file(FileName) ->
    {ok,F} = file:open(FileName,[read,binary]),
    loop_through_file(F,<<>>,file:read(F,1024)).

loop_through_file(_,<<>>,eof) ->
    utf8;
loop_through_file(_,_,eof) ->
    latin1;
loop_through_file(F,Acc,{ok,Bin}) when is_binary(Bin) ->
    case unicode:characters_to_binary([Acc,Bin]) of
	{error,_,_} ->
	    latin1;
	{incomplete,_,Rest} ->
	    loop_through_file(F,Rest,file:read(F,1024));
	Res when is_binary(Res) ->
	    loop_through_file(F,<<>>,file:read(F,1024))
    end.

另一种选择是尝试以 UTF-8 编码读取整个文件,看看是否失败。在这里,我们需要使用函数 io:get_chars/3 来读取文件,因为我们必须读取代码点 > 255 的字符。

heuristic_encoding_file2(FileName) ->
    {ok,F} = file:open(FileName,[read,binary,{encoding,utf8}]),
    loop_through_file2(F,io:get_chars(F,'',1024)).

loop_through_file2(_,eof) ->
    utf8;
loop_through_file2(_,{error,_Err}) ->
    latin1;
loop_through_file2(F,Bin) when is_binary(Bin) ->
    loop_through_file2(F,io:get_chars(F,'',1024)).

UTF-8 字节列表

出于各种原因,您有时可能会有一个 UTF-8 字节列表。这不是一个常规的 Unicode 字符串,因为每个列表元素不包含一个字符。相反,您会得到二进制文件中存在的“原始”UTF-8 编码。通过首先将每个字节转换为二进制,然后将 UTF-8 编码的字符的二进制转换回 Unicode 字符串,可以很容易地将其转换为正确的 Unicode 字符串。

utf8_list_to_string(StrangeList) ->
  unicode:characters_to_list(list_to_binary(StrangeList)).

双重 UTF-8 编码

在处理二进制文件时,您可能会遇到可怕的“双重 UTF-8 编码”,其中奇怪的字符被编码到您的二进制文件或文件中。换句话说,您可能会得到一个 UTF-8 编码的二进制文件,该文件第二次被编码为 UTF-8。常见的情况是,您逐字节读取一个文件,但内容已经是 UTF-8。如果然后使用例如 unicode 模块,或者通过写入以选项 {encoding,utf8} 打开的文件,将字节转换为 UTF-8,那么您会将输入文件中的每个*字节*都编码为 UTF-8,而不是原始文本的每个字符(一个字符可能已被编码为多个字节)。除了确保哪些数据以哪种格式编码,并且永远不要将 UTF-8 数据(可能从文件中逐字节读取)再次转换为 UTF-8 之外,没有真正的补救方法。

到目前为止,发生这种情况最常见的情况是,当您获得 UTF-8 列表而不是正确的 Unicode 字符串时,然后将它们转换为二进制文件或文件中的 UTF-8。

wrong_thing_to_do() ->
  {ok,Bin} = file:read_file("an_utf8_encoded_file.txt"),
  MyList = binary_to_list(Bin), %% Wrong! It is an utf8 binary!
  {ok,C} = file:open("catastrophe.txt",[write,{encoding,utf8}]),
  io:put_chars(C,MyList), %% Expects a Unicode string, but get UTF-8
                          %% bytes in a list!
  file:close(C). %% The file catastrophe.txt contains more or less unreadable
                 %% garbage!

在将二进制文件转换为字符串之前,请确保您知道其中包含的内容。如果没有其他选择,请尝试启发式方法。

if_you_can_not_know() ->
  {ok,Bin} = file:read_file("maybe_utf8_encoded_file.txt"),
  MyList = case unicode:characters_to_list(Bin) of
    L when is_list(L) ->
      L;
    _ ->
      binary_to_list(Bin) %% The file was bytewise encoded
  end,
  %% Now we know that the list is a Unicode string, not a list of UTF-8 bytes
  {ok,G} = file:open("greatness.txt",[write,{encoding,utf8}]),
  io:put_chars(G,MyList), %% Expects a Unicode string, which is what it gets!
  file:close(G). %% The file contains valid UTF-8 encoded Unicode characters!