本文用非常便于理解的方式和语言介绍了 UNICODE 编码及其实现,包含 UTF-32, UTF-16 和 UTF-8。这是我以前记得一篇笔记,我将其通俗、细化了,以方便大家理解。此文章中的描述,很多都是我自己想出来的。还有,大家看的时候,不要纠结名词的翻译,名词后边,都是带上英文了的。
目 录
计算机的位只有两种状态,1 和 0,也就是说,在计算机中,只有数字。这些数字,要执行成代码,就得对命令编码;要显示出颜色,就得对颜色编码;要显示成文字,就得对文字编码。
1
0
对命令编码:比如汇编语言;对颜色编码:比如 CSS 用的 24 位色 RGB。对字元编码:通俗的讲,就是规定哪个数字代表哪个字元。比如在 GB 18030 中,规定 B0A1 代表字元「啊」。
B0A1
这些字元都是一个一个给编出来的,工作量是相当庞大的。而 Unicode 就是一个更庞大,面向全球的字符集。
Unicode 使用的数字是从 0 到 0x10ffff,这些数字都对有相对应的字元(当然,有的还没有编好,有的用作私人自定义)。每一个数字,就是一个代码点(Code Point)。
0x10ffff
这些代码点,分为 17 个平面(Plane)。其实就是17 组,只是名字高大上而已:
Plane 3 到 Plane 14 还没有使用,TIP(Plane 3) 准备用来映射甲骨文、金文、小篆等表意文字。PUA-A, PUA-B 为私人使用区,是用来给大家自己玩儿的——存储自定义的一些字元。
Plane 0,习惯上称作基本平面(Basic Plane);剩余的称作扩展平面(Supplementary Plane)。
顺便说下字体文件。通俗的讲,字体文件中存放的就是代码点对应的图形,以便计算机将代码点渲染成该对应的图形,然后人就可以阅读了。有的字体,里边没有存储中文,这些字体就渲染不了中文。
此图表明 U+007A 字元在 Arial 字体中会被渲染成上面选中的图形。
文字通常为点阵图形,就是说把图片分成很多点阵(小正方形),对每一个点,可以上色(1)和不上色(0),从而显示成不同的字元。下图就是一些字元点阵的国家标准。
通过上边的描述,可以知道,在文本中,存储的只是字元的代码点。而 Unicode 标准只规定了代码点对应的字元,而没有规定代码点怎么存储。
Unicode 的不同的实现,用了不同的存储方式。UTF-8, UTF-16, UTF-32 就是 Unicode 不同的实现。当然,还有其他的实现,这儿不作描述(其实那些我也没学习,大多是些抢不过标准的东东)。
计算机的最小存储单位是位元组,也就是 8 位。为了方便描述,我得先来个凡例:
0xf = 0b1111
0x0 = 0b0000
// 引用了 Apache Commons Lang3
/** * 把代码点转换为相应的字元 */ static String codePoint2String(int codePoint) { return new String(Character.toChars(codePoint)); }
/** * 传入字元和相应的编码,返回计算机使用的二进位编码 * 只为演示,未优化方法,未处理 RuntimeException */ static String binStr(String str, String encoding) throws UnsupportedEncodingException { var bytes = ArrayUtils.toObject(str.getBytes(encoding)); if (Byte.toUnsignedInt(bytes[0]) == 0xfe && Byte.toUnsignedInt(bytes[1]) == 0xff) { bytes = Arrays.copyOfRange(bytes, 2, bytes.length); } return Arrays.stream(bytes).collect( StringBuilder::new, (sb, b) -> { if (sb.length() > 0) { sb.append( ); } var s = Integer.toBinaryString(Byte.toUnsignedInt(b)); sb.append(StringUtils.leftPad(s, 8, 0)); }, (sb1, sb2) -> { sb1.append( ).append(sb2); } ).toString(); }
先来说 UTF-32,这个比较简单。
UTF-32 使用四个位元组来表示存储代码点:把代码点转换为 32 位二进位,位数不够的左边充 0。
示例:
var s1 = "A"; // Plane 0 var s2 = codePoint2String(0x10000); // Plane 1 var s3 = codePoint2String(0x10ffff); // Plane 16
binStr(s1, "UTF-32"); // => `00000000 00000000 00000000 01000001` binStr(s2, "UTF-32"); // => `00000000 00000001 00000000 00000000` binStr(s3, "UTF-32"); // => `00000000 00010000 11111111 11111111`
可以发现,空间的浪费极大,在 Plane 0,利用率那是少得可怜,就算是 Plane 16,利用率也不到 3/4。而我们使用的大多数字元,都在 Plane 0。连存储都非常不划算,更不用说网路传输了。所以这种实现用得极少。
通过上表,可以发现,UTF-16 用二个位元组来表示基本平面,用四个位元组来表示扩展平面。
但是,上面的编码可能出现一个问题,比如一个字元编码:xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx,计算机可也不会知道它是二个基本平面的字元,还是一个扩展平面的字元。
xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx
为解决这个问题,Unicode 将基本平面的两段代码点保留,不表示任意字元。110110xx xxxxxxxx(0xd800 - 0xdbff)为高位代理(High Surrogate),110111xx xxxxxxxx(0xdc00 - 0xdfff) 为低位代理(Low Surrogate)。他们的作用,就是告诉计算机,这是代理,是用来构建扩展平面的字元的。计算机只要碰著了代理,就是道扩展平面的字元来了。
110110xx xxxxxxxx
0xd800
0xdbff
110111xx xxxxxxxx
0xdc00
0xdfff
这儿所谓的代理,其实就是一种特殊的字元,不要被名字所迷惑。
一个高位代理和一个低位代理可以组成一个代理对(Surrogate Pair)。如果 y 和 x 全为 0,则为 0x010000 的代码点,全为 1 则为 0x10ffff 的代码点,刚好能把所有扩展平面全部编码。
y
x
0x010000
代码点的编码方法:
0x000000
0x00ffff
0x10000
0x0fffff
yyyy yyyy yyxx xxxx xxxx
yy yyyyyyyy
11011000 00000000
0xD800
xx xxxxxxxx
11011100 00000000
0xDC00
110110yy yyyyyyyy 110111xx xxxxxxxx
解析方法反过来就是。解析时如果代理不成对,计算机通常不显示该代理字元。
同样用上面的 Java 方法来一次
binStr(s1, "UTF-16"); // => `00000000 01000001` binStr(s2, "UTF-16"); // => `11011000 00000000 11011100 00000000` binStr(s3, "UTF-16"); // => `11011011 11111111 11011111 11111111`
可以看出,一、对于 0x0000 - 0xff 字元,空间的浪费也很大。二、扩展平面字元代理对的实现。
0x0000
0xff
编码方法,将代码点转为二进位,依次填入,位数不够的,左边充 0。
可以看出,不同段的代码点会以不同的长度存储,计算机解析时,只用读取前面若干位,就知道该字元占几个位元组,位于哪一段。
对于西文,该编码方式非常节约空间,因为西文的编码通常都小于 0x0007ff,尤其是 ASCII 字元,更是一个字元只占一个位元组的程度。对于中文,常用的汉字通常位于 0x000800 - 0x00ffff 这一段,需要三个位元组的存储,比起 UTF-16 的存储消耗要大一些。
0x0007ff
0x000800
var s1 = codePoint2String(0x7f); var s2_1 = codePoint2String(0x80); var s2_2 = codePoint2String(0x7ff); var s3_1 = codePoint2String(0x800); var s3_2 = codePoint2String(0xffff); var s4_1 = codePoint2String(0x10000); var s4_2 = codePoint2String(0x10ffff);
binStr(s1, "UTF-8"); // => "01111111" binStr(s2_1, "UTF-8"); // => "11000010 10000000" binStr(s2_2, "UTF-8"); // => "11011111 10111111" binStr(s3_1, "UTF-8"); // => "11100000 10100000 10000000" binStr(s3_2, "UTF-8"); // => "11101111 10111111 10111111" binStr(s4_1, "UTF-8"); // => "11110000 10010000 10000000 10000000" binStr(s4_2, "UTF-8"); // => "11110100 10001111 10111111 10111111"
需要特别说明的是,UNICDOE 中的前 0x7f 个字元编码,和 ANSI 编码的前 0x7f 个字元编码是完全相同的。
Byte Order Mark(BOM),即位元组顺序标记,通常叫做大小端。位于文件开始的地方。用于标记高位在前,还是低位在前。
BOM 有两种形式: BE: Big-Endian, 高位在前,低位在后 LE: Little-Endian, 低位在前,高位在后 其中,UTF-8 的 BOM 可有可无,但如果读到 EF BB BF,好了,这就是 UTF-8 的文件。
EF BB BF
LE:
BE:
这个就不详细说了,知道有这么个东西,处理相应编码的文本时时知道要处理这个就行了。
Unicode Block: https://en.wikipedia.org/wiki/Unicode_block
Character Property: https://en.wikipedia.org/wiki/Unicode_character_property
Unicode Script: http://www.unicode.org/Public/UNIDATA/Scripts.txt
/** * Encode character or code point to UTF32, UTF16, UTF8 * @param x {String} Use first character * {Number} Code Point * @author Liulinwj */ function encode(x) {
let cp = typeof x === "string" ? x.codePointAt(0) : Math.floor(x); if (typeof cp !== "number" || cp < 0 || cp > 0x10FFFF) { throw new TypeError("Invalid Code Point!"); }
let UTF32LE, UTF32BE, UTF16LE, UTF16BE, UTF8;
if (cp > 0xFFFF) { UTF32LE = combine(0, cp << 8 >>> 24, cp << 16 >>> 24, cp & 0xFF); } else { UTF32LE = combine(0, 0, cp >>> 8, cp & 0xFF); } UTF32BE = convertBOM(UTF32LE);
if (cp > 0xFFFF) { let c = cp - 0x10000; let sh = (c >>> 10) + 0xD800; let sl = (c & 0xFFF) + 0xDC00; UTF16LE = combine(sh >>> 8, sh & 0xFF, sl >>> 8, sl & 0xFF); } else { UTF16LE = combine(cp >>> 8, cp & 0xFF); } UTF16BE = convertBOM(UTF16LE);
if (cp < 0x80) { UTF8 = combine(cp); } else if (cp < 0x800) { UTF8 = combine((cp >>> 6) | 0xC0, cp & 0x3F | 0x80); } else if (cp < 0x10000) { UTF8 = combine( (cp >>> 12) | 0xE0, ((cp & 0xFC0) >>> 6) | 0x80, cp & 0x3F | 0x80, ); } else { UTF8 = combine( (cp >>> 18) | 0xF0, ((cp & 0x3F000) >>> 12) | 0x80, ((cp & 0xFC0) >>> 6) | 0x80, cp & 0x3F | 0x80, ); }
return { UTF32LE, UTF32BE, UTF16LE, UTF16BE, UTF8 };
function combine() { return [...arguments].map(function(n) { let hex = n.toString(16).toUpperCase(); return n < 0x10 ? ("0" + hex) : hex; }).join(" "); }
function convertBOM(str) { return str.replace(/(ww) (ww)/g, "$2 $1"); }
}
此代码的功能: