引言 #
之所以有这个疑问,是上次阅读Java基础书时碰到讲解char
类型没有看明白,并且在代码验证过程中错误的理解了代码的意思,导致我对这么个简单问题产生疑惑并且“恶意揣测”Java内部的黑魔法,这里就把我如何走上歪路,并且最终找到“正确”的道路的故事讲出来
问题的产生 #
我们知道Java
是采用Unicode
进行内部编码,但是使用UTF-16
作为外部编码。
怎么来理解这个东西呢。首先你要知道Unicode
是在我们熟悉的GB 18030
、BIG-5
、ISO8859-1
之后出现的,它的出现就是为了统一全世界的编码,因为前面这些编码都太片面了,只包含自己国家或者少数几个国家的字符。
Unicode
的目的就是包括全世界的编码,并且给未来可能出现的编码留下位置,你可以理解为它是一张大“表”,一般我们使用16进制来表达它,并且在前面加上U+
。例如U+0041
代表字母A
,但是这里有个历史问题
一开始我们知道Unicode
为了包含全世界的字符从ASCII
的一个字节扩展到两个字节,就能包含65536个字符了,但是随着字符包含越来越多,我们逐渐需要更多字符了,最后扩展到U+0000 -> U+10FFFF
去了,为了表示这些我们必须使用三个字符,假设我们不考虑内存成本,每个字符都使用四个字符来表示(不使用三个是为了内存对齐),那么问题就解决了,大家都用Unicode
来表示,这样我传给你一串字符你就能秒懂了。
但是学过信息论就知道,单字符越长信息熵也就是信息量就少,其实在日常通信中我们并不是每个字符都会用到,为了提高效率,我们可以使用霍夫曼、香农编码技术对信息重新编码,这个就是UTF-8
、UTF-16
等现代编码的理论基础。
这就好比特种部队手势,我们把作战命令(Unicode)需要的指令放到手势(如UTF-8)里面,这样几个手势就能表达复杂的作战计划(假如用嘴巴说的话)。
接下来我们就从JAVA和Python来看,编码与其关系
表面兄弟:JAVA #
Unicode
对于JAVA来说,只能算是表面兄弟,虽然内部支持Unicode
编码,但是其本质还是基于UTF-16
编码,为什么要这么说呢。
我们来回顾一下,我们知道Unicode
的范围是U+0000-U+10ffff
,这意味着我们没法用两个字节来表示,但是在Java
里面char
类型字节为2字节,而对于字符串类String
来说,其组成就是一个char
字组,对于小于U+10000
的Unicode
码来说,String
对象最小组成单位就是char
,但是对于大于U+10000
的Unicode
码来说却是char
数组,我们用代码来展示一下两者之间的关系。
char[] chars = Character.toChars(0x1f121);
String s = new String(chars);
而且我们将s
输出的话,会发现它是一个字符,但是它的length
却为二,而且我们将s
每个字符转换成二进制你会发现他们的值依次为0xd83c
和0xdd21
,他们存贮的值全部以UTF-16
的格式存贮,具体编码详细我就不细说了,下面资料介绍的很详细(需要翻墙)。在Unicode
里面占一个字符的值,却以两个基本类型存贮,当然为了维持这种“表面兄弟”的关系,Java
也使用了“码点”来支持一下兄弟,只要使用codePointAt
代替charAt
,用codePointCount
代替length
,我们也能处理超过U+10000
的Unicode
编码(对于不超过U+10000
的字符那就是“真兄弟”)
当我不知道一个char
只能放两个字节的时候,我强行使用char c = (char)0x1f121
来“存”一个超过U+10000
的Unicode
码,结果被Java
无情的溢出掉,只取到了部分值,但是我却误以为Java
有黑魔法能用两个字节存贮了三个字节才能存下的值,乃至我闹了个笑话。
总结一下Java
是一个非常严谨的语言,规定死的东西就不会变,表面上看Java
能够支持Unicode
编码,但是实际上他只是编译器支持,比如你写一个🄡
(0x1f122)的值来赋给String
如下面:
String ns = "🄡"
表面上看,Java
完全支持Unicode
码,但在实际的上面他内部还是用UTF-16
进行编码,只是在编译的时候帮我们将0x1f122
转换成为两个
0xd83c
和0xdd21
存贮在char
字符组里面。
其实这个表面兄弟是相对的,从Python3``Unicode
支持来比较一下就能发现不同。
亲兄弟:Python #
Python3
对Unicode
是非常友好的,它在明面上完全按照Unicode
的编码表使用来存贮Unicode
码,对应它的Unicode
字符串,最小单元都是Unicode
码,多说无意,上代码。
c = chr(0x1f122)
print(len(c)) # = 1
print(type(c)) # str
我们可以看到我们得到的最小的码元是字符串str
类型,无论这个Unicode
码是否大于U+10000
,Python
都把它视为一个基本单位,这样避免了你对其进行一些误操作,插句话来讲讲怎么得到这个大小呢,我们使用sys.getsizeof
方法就能计算出来
sys.getsizeof(chr(0x1f122)) # 80
sys.getsizeof(chr(0x1f122) * 2) # 84
由于Python
使用一些字段来标注类型,所以直接使用sys.getsizeof
得不得一个Unicode
码需要的字节,所以我们计算两个的差,很清楚的就能得到一个Unicode
码使用四个字节,你可以依次乘下去,而且你发现一个有趣的现象,对于小于U+007F
的Unicode
码,其大小为一字节,而对于U+0080-U+07FF
其大小为两字节。具体可以看参考资料,Python
内部是使用UTF-8
来存贮Unicode
码的,但是Python
将这一切都隐藏起来,你从表面上看好像一个Unicode
就是一个最小单元,对于其底层我们不得而知,我们可以从侧面来验证一下
timeit.timeit("'中国人'.encode('gbk')")
>> 0.6366317819999949
timeit.timeit("'中国人'.encode('utf-8')")
>> 0.2109854949999317
我们可以看到将Unicode
编译成其他编码方式,其中utf-8
速度是最快的,因为基本上是复制一下就行了,而其他的差距到了三倍
总结 #
通过前面我们知道,Python
之所以 Unicode
如此“亲兄弟”是因为做了一层封装得来的,相比Java
将Unicode
码(使用UTF-16
作为底层编码)暴露给出来,Java
在底层上却是非常“坦诚”,你想直接使用Unicode
码值也可以,Java
编译器会帮你把Unicode
码值转换成UTF-16
,你也可以从UTF-16
码生成String
字符串,这样底层在实现查找的时候也是使用统一的编码进行。但是也正是由于这么“底层”,代码看起来总不是那么“亲”,相比于Python
的“一视同仁”,我们也可以理解这就是这两种语言的各自特点所在。
总的来说如果你想直接接触代码底层,推荐使用Java
,假如你只想研究其本质,推荐使用Python
来进行自然语言处理,他的封装能让你不需要了解其内部组成。