字符编码

在早期计算机系统中,为了给字符编码,美国国家标准学会(American National Standard Institute,ANSI)制定了一套英文编码规范,包含英文字母,数字和一些常用符号,编码范围从0127,称为ascii编码,每个字素(grapheme,a single unit of a human writing system)只占用一个字节,比如A的编码为0x41(65)

但是随着计算机的发展的全球化,计算机需要能支持更多的语言,也就是说每一种语言的文字都需要一套与之对应的编码,对于拉丁母来说,一个字节的大小就能基本包含常用的字母和符号,但是对于东亚的表意文字来说,一个字节的大小显然是不够用的,需要更多的字节数,比如一个占用两个字节

在早期的时候并没有一套统一的规范,于是不同的国家和地区都制定了一套适用于本区域文字的编码,比如中文有GB2312,日文有Shift_JIS,韩文有EUC-KR,不同的编码之间会冲突,这也导致了乱码的问题出现。

Unicode

为了统一全球所有语言的编码,全球统一码联盟发布了Unicode编码,它把世界上的主要语言都纳入同一套编码中,这样,中文,日文,韩文和其他语言也就不会冲突了。它的长度为 2~4 个字节,比如Aascii编码为0x41(65),而Unicode编码为U+0041GB2312编码为0xd6d0Unicode编码为U+4e2d,除此之外,Unicode编码还包含了 emoji 表情,比如🐂的编码为U+1f402🍺的编码为U+1f37a

UTF-8

而我们常说的UTF-8编码是一种编码方式,它将固定长度的Unicode编码转换成长度为 1~4 个字节的二进制码,比如AUTF-8编码为0x41,只有一个字节的长度,所以对于大量的英文文本,采用UTF-8编码可以节省大量的存储空间,UTF-8编码是通过高字节位来判断一个字素到底是几个字节的。

Java 中的 Unicode 码点

Java中,char类型是采用UTF-16编码的,也就是两个字节来表示一个字素,但是对于一些长度超过两个字节的Unicode点(用来表示一个字素的 Unicode 编码)就不够用了, 所以就需要用两个 char 来表示一个码点,因此在用char类型遍历字符串的时候就会产生错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Main {
public static void main(String[] args) {
String s1 = "🐂🍺";
String s2 = "牛啤";
System.out.println(s1.length()); // 4
System.out.println(s2.length()); // 2
for (int i = 0; i < s1.length(); i++) {
System.out.println(s1.charAt(i));
}
// ?
// ?
// ?
// ?
for (int i = 0; i < s2.length(); i++) {
System.out.println(s2.charAt(i));
}
// 牛
// 啤
}
}

所以为了解决这种问题,Java提供了以码点的长度方式来遍历字符串的对应方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class Main {
public static void main(String[] args) {
String s1 = "🐂🍺";
String s2 = "牛啤";
System.out.println(s1.codePointCount(0, s1.length())); // 2
System.out.println(s2.codePointCount(0, s2.length())); // 2

int index1 = s1.offsetByCodePoints(0, 0); // 得到从0索引开始偏移0个码点的索引
System.out.println(index1); // 0
System.out.println(Integer.toHexString(s1.codePointAt(index1))); // 1f402
int index2 = s1.offsetByCodePoints(0, 1); // 得到从0索引开始偏移1个码点的索引
System.out.println(index2); // 2
System.out.println(Integer.toHexString(s1.codePointAt(index2))); // 1f37a

// 正向
int cp;
for (int i = 0; i < s1.length(); i += Character.charCount(cp)) {
cp = s1.codePointAt(i);
System.out.println(Integer.toHexString(cp));
System.out.println(Character.toString(cp));
}
// 1f402
// 🐂
// 1f37a
// 🍺

// 反向
for (int i = s1.length() - 1; i > 0; i--) {
if (Character.isSurrogate(s1.charAt(i))) i--;
cp = s1.codePointAt(i);
System.out.println(Integer.toHexString(cp));
System.out.println(Character.toString(cp));
}
// 1f37a
// 🍺
// 1f402
// 🐂
}
}

但是,以上遍历的方式显然不够优雅,其实Java还提供了将字符串变为一个码点数组的方法,那我们就可以以数组的方式去遍历这个字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
String s1 = "🐂🍺";
String s2 = "牛啤";

// codePoints方法可以得到一个int流
int[] codePoints = s1.codePoints().toArray();
System.out.println(Arrays.toString(codePoints));

// 将码点数组转回字符串
String str = new String(codePoints, 0, codePoints.length);
System.out.println(str);
}
}

将单个码点转为字符串可以用Character.toString(int codePoint)方法。

1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
int codePoint = 0x1f37a;
System.out.println(Character.toString(codePoint));
}
}