深入理解字符编码

以前对 UTF-8, UTF-16, unicode, GBK, ASCII 之类的都是一知半解, 其实深入之后了解这些并不困难.

unicode

其实这是最简单的, unicode 就是一套标准, 把世界上每个存在的字符 (甚至包括 emoji) 都编一个号, 可以在 https://unicode-table.com/cn/ 看到全部的编码,
把这个编的号, 一般叫做 code point, 也就是码点. 同时也设定了几个编码的标准, 也就是 UTF-8, UTF-16 等等, 毕竟直接把码点转二进制太浪费空间了.

而这些编码, 就是把这些码点保存为计算机可以识别的二进制形式.

UTF-8

UTF-8 就是这些编码的一种, 详细可以参考维基百科, 简单来说, 就是把码点转为二进制后,
然后为了节省空间, 把不同大小的码点转为不同长度的字节序列, 这就是他天才的地方, 用变长编码来节省长度.

这里以 为例, 它的码点是 0x554a, 在 U+0800~U+FFFF 的范围内, 二进制是 0b101010101001010,
那么根据定义, 他的保存方式是 1110xxxx 10xxxxxx 10xxxxxx, 我们将这个码点的二进制以大端的方式填入其中,
得到 1110|0101 10|010101 10|001010, 也就是 b'\xe5\x95\x8a', 可以尝试在 python 解码, 得到的就是 .

而在 0x00 ~ 0xFF 范围内, UTF-8 其实就被退化为 ASCII 了, 一是为了兼容性, 二是确实 ASCII 所涵盖的符号, 确实用的非常多.
这里可以手动写一些 UTF-8 的函数来加深学习, 因为是变长的, 所以来算长度, 以及截取相对比较麻烦.
这里用我用 c 写, 感觉非常的爽, 就像在高速公路上飙车, 控制不好就会翻车 (Segment fault) 233, 但是确实是最接近这些字符编码本来的样子.
没有了 python 上各种的包装.

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
#include <stdio.h>
#include <stdlib.h>
#include <memory.h>

char* read_file(char *filename) {
FILE *fd = fopen(filename, "r");

fseek(fd, 0, SEEK_END);
long sz = ftell(fd);
fseek(fd, 0, SEEK_SET);

char *content = malloc(sizeof(char) * sz);
fread(content, sizeof(char), sz, fd);

fclose(fd);
return content;
}

int utf8_strlen(char *s) {
int length = 0;

while (*s) {
if ((*s & 0b11000000) != 0b10000000) {
length += 1;
}
s++;
}
return length;
}

char* codepoint_to_utf8(unsigned int codepoint, int *sz) {
char *s;
if (codepoint < 0x80) {
*sz = 1;
s = malloc(sizeof(char));
*s = codepoint;
} else if (codepoint < 0x800) {
*sz = 2;
s = malloc(sizeof(char) * 2);
*(s + 1) = 0b10000000 | (codepoint & 0b00111111);
*s = 0b11000000 | ((codepoint >> 6) & 0b00011111);
} else if (codepoint < 0x10000) {
*sz = 3;
s = malloc(sizeof(char) * 3);
*(s + 2) = 0b10000000 | (codepoint & 0b00111111);
*(s + 1) = 0b10000000 | ((codepoint >> 6) & 0b00111111);
*s = 0b11100000 | ((codepoint >> 12) & 0b00001111);
} else {
*sz = 4;
s = malloc(sizeof(char) * 4);
*(s + 3) = 0b10000000 | (codepoint & 0b00111111);
*(s + 2) = 0b10000000 | ((codepoint >> 6) & 0b00111111);
*(s + 1) = 0b10000000 | ((codepoint >> 12) & 0b00111111);
*s = 0b11110000 | ((codepoint >> 18) & 0b00000111);
}
return s;
}

int get_tail_length(char c) {
int length = 0;
if ((c & 0b11111000) == 0b11110000) {
length = 3;
} else if ((c & 0b11110000) == 0b11100000) {
length = 2;
} else if ((c & 0b11100000) == 0b11000000) {
length = 1;
}
return length;
}

char* utf8_substr(char* s, int start, int length) {
int curr_pos = 0;
int byte_count = 0;
int offset = 0;
char *new_s;

while (curr_pos < start && *s) {
if ((*s & 0b11000000) != 0b10000000) {
curr_pos += 1;
}
s += (get_tail_length(*s) + 1);
}

curr_pos = 0;
while (curr_pos < length && *s) {
if ((*s & 0b11000000) != 0b10000000) {
curr_pos += 1;
}
s++;
byte_count++;
}

if (curr_pos > 0) {
offset = get_tail_length(*(s - 1));

new_s = malloc(sizeof(char) * (byte_count + offset + 1));
memcpy(new_s, s - byte_count, byte_count + offset);
new_s[byte_count + offset] = '\0';
} else {
new_s = NULL;
}
return new_s;
}

int utf8_to_codepoint(char *s) {
int length = 0;
int codepoint = 0;

if ((*s & 0b11111000) == 0b11110000) {
length = 4;
codepoint = *s & 0b111;
} else if ((*s & 0b11110000) == 0b11100000) {
length = 3;
codepoint = *s & 0b1111;
} else if ((*s & 0b11100000) == 0b11000000) {
length = 2;
codepoint = *s & 0b11111;
} else {
length = 1;
codepoint = *s;
}
for (int i = 1; i < length; i++) {
s++;
codepoint <<= 6;
codepoint = codepoint | (*s & 0b00111111);
}
return codepoint;
}

int main() {
/*
char *content = read_file("/home/rmb122/temp/example.txt");
printf("%d", utf8_strlen(content));

free(content);
*/

printf("%d\n", utf8_strlen("一二三四五六一二三四五六1234"));

char *s;
int sz;

s = codepoint_to_utf8(21834, &sz);
printf("%.*s\n", sz, s);
free(s);

s = utf8_substr("一2三四5六7八9", 1, 1);
if (s) {
printf("%s\n", s);
} else {
printf("%s", "null");
}
free(s);

printf("%d\n", utf8_to_codepoint("五"));
return 0;
}

UTF-16

说实话, 我觉得 UTF-16 已经可以被扔进历史的垃圾桶, 然而 Windows 还依然使用 233.
他本身是为了保证固定长度而设计的, 因为两个字节 256*256, 可以表示 65536 种字符,
在以前 unicode 收录字符比较少的情况下是完全够的, 这样每个字符都保存为两个字节长,
可以节省运算, 虽然浪费了挺多的空间, 但是确实很方便.

然而现在上面的网站上看到, unicode 收录的字符早已超过了 65536 个, 也就意味着码点超过了 0xFFFF.
这里以 𐎠 这个字符为例

1
2
3
4
>>> "啊".encode('utf-16be')
b'UJ'
>>> "𐎠".encode('utf-16be')
b'\xd8\x00\xdf\xa0'

可以看到, 实际上保存这个字符用了 4 个字节. 历史的发展使得 UTF-16 唯一的好处已经荡然无存.
而且为了保存大端小端的额外信息, 还用了 BOM 这种丑陋的设计, 希望 Windows 也能早点淘汰掉 UTF-16 把 233

GBK

这个是我国自创的编码标准, 优点非常明显, 节省空间, 相当于缩小版的 UTF-8 (当然用的码点表和具体实现跟 unicode 不同),
因为它只需要表达 ASCII + 汉字的字符空间就够了. 当然缺点也非常明显, 不能表达 ASCII + 汉字之外的字符, 也就注定只能在中国使用,
无法国际化, 而且实际上很多国家都有一套自创字符集, 这更促进了 unicode 的诞生.