本文用非常便于理解的方式和语言介绍了 UNICODE 编码及其实现,包含 UTF-32, UTF-16 和 UTF-8。这是我以前记得一篇笔记,我将其通俗、细化了,以方便大家理解。此文章中的描述,很多都是我自己想出来的。还有,大家看的时候,不要纠结名词的翻译,名词后边,都是带上英文了的。

目 录

  • 字符集编码(Character Encoding)
  • 平面(Plane)
  • Unicode 的实现
    • UTF-32
    • UTF-16
    • UTF-8
  • 位元组顺序标记(BOM)
  • Unicode 的其他属性
  • 查看三种实现的 JavaScript 代码

字符集编码(Character Encoding)

计算机的位只有两种状态,10,也就是说,在计算机中,只有数字。这些数字,要执行成代码,就得对命令编码;要显示出颜色,就得对颜色编码;要显示成文字,就得对文字编码。

对命令编码:比如汇编语言;对颜色编码:比如 CSS 用的 24 位色 RGB。对字元编码:通俗的讲,就是规定哪个数字代表哪个字元。比如在 GB 18030 中,规定 B0A1 代表字元「啊」。

这些字元都是一个一个给编出来的,工作量是相当庞大的。而 Unicode 就是一个更庞大,面向全球的字符集。

平面(Plane)

Unicode 使用的数字是从 00x10ffff,这些数字都对有相对应的字元(当然,有的还没有编好,有的用作私人自定义)。每一个数字,就是一个代码点(Code Point)。

这些代码点,分为 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 标准只规定了代码点对应的字元,而没有规定代码点怎么存储。

Unicode 的不同的实现,用了不同的存储方式。UTF-8, UTF-16, UTF-32 就是 Unicode 不同的实现。当然,还有其他的实现,这儿不作描述(其实那些我也没学习,大多是些抢不过标准的东东)。

计算机的最小存储单位是位元组,也就是 8 位。为了方便描述,我得先来个凡例:

  1. 由于二进位太长,通常记作十六进位,可以方便阅读。四位二进位可以记作一位十六进位位,0xf = 0b1111, 0x0 = 0b0000,一个位元组记作两位十六进位数。
  2. 写二进位数时,如无特殊情况我统一采用 8 个一组,也就是一位元组。
  3. 我写了两个 JAVA 方法,用于转换代码点和二进位编码,看得懂就看,看不懂也不影响阅读,只要知道方法的作用就行了。

// 引用了 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,这个比较简单。

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

通过上表,可以发现,UTF-16 用二个位元组来表示基本平面,用四个位元组来表示扩展平面。

但是,上面的编码可能出现一个问题,比如一个字元编码:xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx,计算机可也不会知道它是二个基本平面的字元,还是一个扩展平面的字元。

为解决这个问题,Unicode 将基本平面的两段代码点保留,不表示任意字元。110110xx xxxxxxxx0xd800 - 0xdbff)为高位代理(High Surrogate),110111xx xxxxxxxx0xdc00 - 0xdfff) 为低位代理(Low Surrogate)。他们的作用,就是告诉计算机,这是代理,是用来构建扩展平面的字元的。计算机只要碰著了代理,就是道扩展平面的字元来了。

这儿所谓的代理,其实就是一种特殊的字元,不要被名字所迷惑。

一个高位代理和一个低位代理可以组成一个代理对(Surrogate Pair)。如果 yx 全为 0,则为 0x010000 的代码点,全为 1 则为 0x10ffff 的代码点,刚好能把所有扩展平面全部编码。

代码点的编码方法:

  1. 如果代码点位于 0x000000 - 0x00ffff,直接进行二进位编码,位数不够的左边充 0。
  2. 如果代码点位于 0x010000 - 0x10ffff,则:
    1. 代码点减去 0x10000,会得到一个位于 0x0000000x0fffff 之间的数字。
    2. 这个数字转换为 12 位二进位数,位数不够的,左边充 0,记作:yyyy yyyy yyxx xxxx xxxx
    3. 取出 yy yyyyyyyy,并加上 11011000 00000000(0xD800),得到高位代理。
    4. 取出 xx xxxxxxxx,并加上 11011100 00000000(0xDC00),得到低位代理。
    5. 高位代理和低位代理相连,得到 110110yy yyyyyyyy 110111xx xxxxxxxx

解析方法反过来就是。解析时如果代理不成对,计算机通常不显示该代理字元。

同样用上面的 Java 方法来一次

var s1 = "A"; // Plane 0
var s2 = codePoint2String(0x10000); // Plane 1
var s3 = codePoint2String(0x10ffff); // Plane 16

binStr(s1, "UTF-16"); // => `00000000 01000001`
binStr(s2, "UTF-16"); // => `11011000 00000000 11011100 00000000`
binStr(s3, "UTF-16"); // => `11011011 11111111 11011111 11111111`

可以看出,一、对于 0x0000 - 0xff 字元,空间的浪费也很大。二、扩展平面字元代理对的实现。

UTF-8

编码方法,将代码点转为二进位,依次填入,位数不够的,左边充 0。

可以看出,不同段的代码点会以不同的长度存储,计算机解析时,只用读取前面若干位,就知道该字元占几个位元组,位于哪一段。

对于西文,该编码方式非常节约空间,因为西文的编码通常都小于 0x0007ff,尤其是 ASCII 字元,更是一个字元只占一个位元组的程度。对于中文,常用的汉字通常位于 0x000800 - 0x00ffff 这一段,需要三个位元组的存储,比起 UTF-16 的存储消耗要大一些。

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 个字元编码是完全相同的。

位元组顺序标记(BOM)

Byte Order Mark(BOM),即位元组顺序标记,通常叫做大小端。位于文件开始的地方。用于标记高位在前,还是低位在前。

BOM 有两种形式: BE: Big-Endian, 高位在前,低位在后 LE: Little-Endian, 低位在前,高位在后 其中,UTF-8 的 BOM 可有可无,但如果读到 EF BB BF,好了,这就是 UTF-8 的文件。

LE:

BE:

这个就不详细说了,知道有这么个东西,处理相应编码的文本时时知道要处理这个就行了。

Unicode 的其他属性

Unicode Block: en.wikipedia.org/wiki/U

Character Property: en.wikipedia.org/wiki/U

Unicode Script: unicode.org/Public/UNID

查看三种实现的 JavaScript 代码

/**
* 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");
}

}

此代码的功能:


推荐阅读:
相关文章