String in Many language

plus2047 于 2022-07-13 发布

最近使用 Python 2 处理一些网络相关的问题,被 Unicode, String 相关的一系列编码问题搞得一头雾水。在这里整理一下相关的概念。本文后续补充了 Golang & Java 中 Unicode & String 相关的内容。

ASCII / Unicode / UTF8

首先是与 Python 无关的编码问题。在这里理清楚这几个常见的名词:ASCII, Unicode, UTF-8 之间的关系。

我们知道,为了使用计算机处理字符,需要将字符编码为数字。对于英文字符,128 个数字就够了,其中最常见的英文字符编码方案就是 ASCII,该方案规定了使用 7 位二进制数编码英文字符的方案。对于 ASCII 而言,一个字符(使用 7 位编码)占用一个 byte(8 位)就够了,可以简单地用这个字节表示一个无符号整数来代表编码,浪费并不大。

对于非英文字符,特别是汉语这种有超大字符集的语言,需要需要一个很大的编码表将字符编码为数字。竞争之后现在通用的方案是 Unicode,该方案的目标是覆盖所有人类语言符号。也即,Unicode 定义了一个从任意字符到数字的一一映射关系。

Unicode 用于表示一个字符的数字一般称为 codepoint. 一个 codepoint 可以用 int32 表示,但直接使用 int32 表示比较浪费空间。因此,如何用字节表示 Unicode 编码数字就成了一个问题。UTF-8 就是解决这个问题的方案之一。该方案是一种变长方案,使用不定长的字节表示一个 codepoint. 方案对于包含在 ASCII 码表中的字符只使用 1 个字节进行编码,对于非英语拼音语言符号通常使用 2 个字节编码,汉字通常使用 3 个字节进行编码。

如果接触过信息论,可以很容易的理解变长编码是如何做到的。如果读者没有接触过信息论,这里展示一种简陋的变长编码方案,方便读者理解。 第一个字节总是使用无符号整数表示。当数值位于 [0,254] 中时,这个字节的代表的数值就是编码的数值。但当数值是 255 时,不表示这个数字是 255,而是表示这个数字加上之后两位(16 bit) 代表的数之后才是编码的数字,于是这个边长方案能使用 1~3 字节编码 [0, (2 ^ 16 - 1) + (2 ^ 8 - 1)] 之间的数字。

总结而言就是,Unicode 是一个字符到数字 (codepoint) 映射表,而 UTF-8 是 codepoint 到字节的编码方案。 ASCII 由于只使用一个字节,不需要第二个步骤。

Python 与 Unicode

接下来是 Python 中对于字符串的处理。

Python 2 中的 str 和 Unicode

在 Python 2 中,其 str 类型规定了底层的数据结构,是 8 位整数串,也即跟 C 语言中的字符串类似。而 unicode 类型是整数串,unicode.encode() 方法在指定一种编码方式之后返回一个 str 对象,即为这个 unicode 字符串在该编码方式之下的字节表示。

用例子说明这个问题:

# python 2
us = u"你好"
assert(len(us) == 2)
# us 是整数串,共有两个整数,代表两个字符

s = us.encode("utf8")
assert(len(s) == 6)
# 使用 utf8 编码之后,每个汉字用 3 个字节表示,共 6 个字节

仅仅是这样,并不会引起太大的混淆。但问题开始变得复杂起来。Python 2 的字符串是可以被初始化为非英文字符的:

# python 2
s = "你好"
assert(len(s) == 6)

这种情况下解释器会自动将字符串编码为 UTF-8. 注意源代码文件本身也应该被保存为 UTF-8.

更加混乱的是,Python 2 的 print 语句表现有时候会有些神奇:

# python 2
print "你好"
# 得到 你好
print u"你好"
# 得到 你好
print ["你好"]
# 得到 ['\xe4\xbd\xa0\xe5\xa5\xbd']
print [u"你好"]
# 得到 [u'\u4f60\u597d']

也就是,只有当直接 print str 或者 print unicode 的时候能够正常输出中文,其他情况下, print [str] 会输出 'str'print [unicode] 会输出 u'unicode'。这些打印结果看上去会让人怀疑没有正确编解码,实际上是 print 语句的行为而已。

Python 3 中的 byte 和 str

在 python 2 中,str 是一等价于比特串 bytes,但 str 在很多语言中代表字符串,容易引起混淆。如 unicode.encode() 返回值是一个 str,逻辑上并不恰当。这些问题在 python 3 中得到解决。

Python 2 中的 str 类型相当于 Python 3 中的 bytes 类型, bytes 这个名字明确的指出这是字节串,并且不指定字节串代表什么东西。而 Python 3 中的 str 类似于 python 2 中的 unicode 类型,不再指定底层编码规则。str.encode() 返回一个 bytes 类型,更加符合逻辑。

用例子说明:

# python 3
s = "你好"
assert(len(s) == 2)

bs = s.encode('utf8')
assert(len(s) == 6)

Python 3 中的 print 函数一般总是可以正常打印 str,而不是输出码值。

Golang & Unicode

Golang 中的 string 数据类型等价于 bytes, 编码无关。但 Golang 规定其源代码是 UTF-8 编码,

s := "你好"

以上代码以 UTF-8 编码保存该字符串。

Golang 的 for range 循环能够正确支持 UTF-8 编码的 string,

x := "你好"
for i, c in range x {
    fmt.Println(i, c)
}
// 0 20320
// 3 22909

输出的 i 是每个字编码开始位置,c 是 unicode codepoint, 其数据类型是 int32, 而不是 byte.

Golang 中习惯使用 rune 表示一个 unicode codepoint, rune 也就是是 int32 的别名。Golang 可以直接使用数据类型转换风格的语法处理 stringrune,

s := "你好"
x := []rune(s) // also x := []int32(s)
recover := string(x)

另外,unicode package 中提供了很多处理 codepoint 的工具,诸如 IsNumber, IsLetter 等。

Java / Scala & Unicode

Java 以及 Scala 中,问题更加混乱,因为字符串 String 是比较小众的 UTF-16 编码的 unicode 字符串,所以 Java 中至少有 bytes, string, unicode codepoints 三种可能跟字符串相关的数据结构。

Java 以及 Scala 中,其 char 实际上是 uint16 类型。UTF-16 这种编码足以将大部分字符编码到同一个 char 中,所以 Java 的 String 常常不需要解码成 unicode codepoints 就能处理. 安全起见,在处理 unicode string 时,最好仍然解码为 codepoint. String 提供了 .codePoints() 方法将 String 解码成 unicode codepoint.

Java 的 Character 类是 char 的包装类。但这个类还包含了一些用于 codepoint 的函数,如 isLetter, isDigit 等等。

String s = "你好";
System.out.println(s);
IntStream x = s.codePoints();
String r = new String(Character.toChars(x.toArray()[0]));
System.out.println(r);

Scala 的类型系统与 Java 一致,下面补充一个 Scala 的例子,

val s = "你好"
val x = s.codePoints();  // IntStream
val r = x.toArray.flatMap(Character.toChars(_)).mkString