数据背后的二进制
我们可能会经常遇见以下问题:
- 正整数相乘结果居然出现了负数
- 基本的小数运算的结果居然不准确
- 字符也可以进行算术运算和比较
整数的进制表示与位运算
逢x进一,范围为0-x(不包括x),每个位置都有对应的位权。
要理解二进制,我们先熟悉一下十进制。对于数值123我们不假思索就知道它的值是多少。但其实123表示的是1*10``2
+2*10``1
+3*10``0
即123,它表示的是各个位置的数值乘以进制的次方。
次方由位权位权决定,从右到左,第一位为0(0次方为1),第二位为2,依次类推。
正整数的二进制表示
二进制只有0
和1
,可以表示电路的开关、逻辑的真假、数字的正负……以下是十进制与二进制的一些转换
- 3 = 2+2
- 11= 2+2+2
n>1的情况下,2=2+2……+2+1
负整数的二进制表示
十进制的负数表示是在前面加上-
号。二进制使用最高位表示符号位,1表示负数
,0表示正数
,一字节表示8
字节,即左边的为符号位
。
- byte 1字节 8位
- short 2字节 16位
- int 4字节 32位
- long 8字节 64位
但负数表示不是简单地将最高位变为1,比如:
byte a = -1
,如果将最高位变为1,二进制应该是1000 0001
,但实际上,它应该是1111 1111
。byte a = -127
,如果将最高位变为1,二进制应该是1111 1111
,但实际上,它应该是1000 0001
。
这种表示法称为补码表示法
,符合我们直觉的称为原码表示法
。
补码表示就是在原码(除符号位)的基础上取反加1,取反就是0变1,1变0。
负数的二进制表示就是对应的正数的补码表示,比如:
- -1:1的原码表示是
0000 0001
,取反是1111 1110
,然后再加1,就是1111 1111
- -2:2的原码表示是
0000 0010
,取反是1111 1101
,然后再加1,就是1111 1110
- -127:127的原码表示是
0111 1111
,取反是1000 0000
,然后再加1,就是1000 0001
给定一个负数的进制表示,要想知道它的十进制表示,可以采用相同的补码运算。比如:10010010
,首先取反是01101101
然后加1
是01101110
,它的十进制是2+4+8+32+64=110
,因此它的值是-110
。直觉上应该先减1然后再取反,但计算机只能做加法,而补码的一个良好特性是对于负数的补码表示做补码运算就能得到对应其整数的原码。
对于byte
类型,正数能表示的最大范围是01111111
,即127
,负数最小表示(绝对值最大)是10000000
,即-128
,表示范围是-127-128
。其他整数类型也一样,负数能多表示一个数。
计算机只能做加法,1-1
其实是1+(-1)
。如果用原码表示,计算结果是不对的,比如:
1 -> 00000001
-1 -> 10000001
-2 -> 10000010
用符合直觉的原码表示,1-1的结果是-2。而用补码表示:
1 -> 00000001
-1 -> 11111111
0 -> 00000000
再比如,5-3
5 -> 00000101
-3 -> 11111101
2 -> 10000010
当计算结果超出了对应的数据类型表示范围之后,就会发生溢出。
127 -> 01111111
1 -> 00000001
-128 -> 10000000
十六进制
二进制写起来太麻烦,可以使用4位二进制数表示一位16进制数,其中10-15使用A-F
表示。
二进制 | 十进制 | 十六进制 |
---|---|---|
1010 | 10 | A |
1011 | 11 | B |
1100 | 12 | C |
1101 | 13 | D |
1110 | 14 | E |
1111 | 15 | F |
可以用十六进制表示整型常量,在数字前面加上前缀0x
,比如十进制的123,用十六进制表示就是0x7b
,即123=7*16+11。给整数赋值或者运算的时候,都可以直接使用十六进制,比如:
int a = 0x7B;
Java7
之前不支持直接写二进制常量。比如,想写二进制形式的11001
,Java7
之前不能直接写,可以在前面补0
,补足8位,为00011001
,然后用十六进制表示,即0x19
。Java7
开始支持二进制常量,在前面加0b
或者0B
即可,比如:
int a = 0b11001;
在Java
中,可以方便地使用Integer
和Long
的方法查看整数的二进制和十六进制表示,例如:
// 二进制表示
print(Integer.toBinaryString(a));
// 十六进制表示
print(Integer.toHexString(a));
// 二进制表示
print(Long.toBinaryString(a));
// 十六进制表示
print(Long.toHexString(a));
位运算
Java7
之前不能单独表示一个位,但可以用byte
表示8位,用十六进制写二进制常量。比如,0010
表示十六进制是0x2
,110110
表示成十六进制是0x36
。
位运算有移位运算和逻辑运算。移位有以下几种。
- 左移:操作符为
<<
,向左移动,右边低位补0,高位的就舍弃掉了,将二进制看作整数,左移一位就相当于乘以2。 - 无符号右移:操作符为
>>>
,向右移动,右边的舍弃掉,左边补0。 - 有符号右移:操作符为
>>
,向右移动,右边的舍弃掉,左边补什么取决于最高位是什么,原来是1就补1,原来是0就补0,将二进制看作整数,右移一位相当于除以2。
例如:
int a = 4; // 0100
a = a >> 2; // 0001
a = a << 3; // 1000
逻辑运算有以下几种:
按位与&
:两位为1才为1
按位或|
:两位有一位为1,就为1
按位取反~
:1变0,0变1
按位异或^
:相异为真,相同为假
小数的二进制表示
在一些最基本的计算中,计算的结果也是不精确的,比如:
float f = 0.1f * 0.1f;
print(f);
这个结果看上去是0.01
,但实际上,输出结果确实0.010000001
,后面多了个1。
小数运算为什么会出错
实际上,不是运算本身会出错,而是计算机根本就不能精确地表示很多数,比如0.1
这个数。计算机是使用二进制格式存储小数的,这个二进制不能精确表示0.1
,它只能表示一个非常接近但又不等于0.1
的一个数。数字都不能精确表示,在不精确数字运算上的结果不精确也就不足为奇了。
0.1
为什么不能精确表示呢?在十进制世界是可以的,但是在二进制世界是不行的。实际上,十进制也只能表示那么可以表述为10的多少次方的数,比如12.345
,实际表示的是1*10+2*1+3*0.1+4*0.01+5*0.001
,与整数表示的类似,小数点后面的每个位置也都有一个位权,从左至右,一次为0.1 0.01…….即10负一次方,10的负二次方。
很多数十进制也是不能精确表示的,比如1/3
,保留三位小数的话,十进制是0.333
,但无论保留多少位小数,都是不精确的,用0.333
进行运算,比如乘以3,期望的结果是1,但实际上是0.999。
二进制是类似的,但二进制可以表示那些可以表述为2的多少次方的数。
二进制 | 十进制 |
---|---|
2^(-1) | 0.5 |
2^(-2) | 0.25 |
2^(-3) | 0.125 |
2^(-4) | 0.0625 |
可以精确表示为2的某次方之和的数可以精确表示,其他数则不能精确表示。为什么计算机不使用十进制呢?在最底层,计算机使用的电子元器件只能表示两个状态,通常是低压和高压,对应0和1,使用二进制容易基于这些电子元器件构建硬件设备和进行运算。如果使用十进制,则这些硬件会复杂很多,并且效率低下。如果进行代码实践,会发现有些计算结果是准确的。比如,用Java
写:
print(0.1f + 0.1f); // 0.2
print(0.1f * 0.1f); // 0.010000001
按照上面的说法,第一行的结果应该也不对。其实,这是Java
语言给我们造成的假象,计算结果其实也是不精确的,但是由于结果和0.2
足够接近,在输出的时候,Java
选择了输出0.2
这个看上去非常精简的数字,而不是一个中间有很多0的小数。在误差足够小的情况下,结果看上去是精确的,但不精确其实才是常态。
大部分情况下,我们不需要这么高的精度,可以四舍五入,或者在输出的时候值保留固定两位小数。如果真的需要比较高的精度,一种方法是将小数转换成整数进行运算,运算结束后再转换为小数;另一种方法是使用十进制的数据类型,这个并没有统一的规范。再Java
中BigDecimal
,运算更准确,但效率比较低。
二进制表示
之前一直再用小数
这个词表示float
和double
类型,其实,这是不严谨的,”小数”是数学中的词,再计算机中,我们一般说浮点数。flaot
和double
被称为浮点类型,小数运算被称为浮点运算
。这是因为小数再二进制中,表示那个小数点的时候,点不是固定的,而是浮动的。
我们还是用十进制类比,十进制有科学记数法,比如123.45
这个数,直接这么写,就是固定表示法,如果用科学记数法,再小数点前只保留一位数字,可以写为1.2345E2即1.234510,即在科学记数法中,小数向做浮动了两位。二进制中表示为小数,也采用类似的科学记数法,形如m(2)。m称为尾数,e称为指数。指数可以为正,也可以为负,负的指数表示那些接近0的比较小的数。在二进制中,单独表示尾数部分和指数部分,另外还有一个符号表示正负。
几乎所有的硬件和编程语言表示小数的二进制格式都是一样的。这种格式是一个标准,叫做IEEE 754
标准,它定义了两种格式:一种是32位的,对应Java
的float
;另一种是64位的,对应Java
的double
。
32位格式中,1位表示符号,23位表示尾数,8位表示指数。64位格式中,1位表示符号,52位表尾数,11位表示指数。在两种格式中,除了表示正常的数,标准还规定了一些特殊的二进制形式表示一些特殊的值,比如正无穷,负无穷,0,NAN(非数值,比如0乘无穷大)。IEEE 754
标准中还有一些复杂的细节,具体可百度。如果想查看浮点数具体二进制形式,在Java
中,可以使用如下代码:
// 32位
Integer.toBinaryString(Float.flaotToIntBits(value));
// 64位
Long.toBinaryString(Double.doubleToLongBits(value));
字符与乱码
编码有两大类:一种是Unicode
编码,一种是非Unicode编码。
常见的非Unicode编码
包括ASCII
、ISO 8859-1
、Windows-152
、GB2312
、GBK
、GB18030
、Big5
。
ASCII编码
世界上虽然有各种各样的字符,但计算机发明之初并没有考虑这么多,基本只考虑了美国的需求。美国大概需要128
个字符,所以就规定了128个字符的二进制表示方法,这个方法是个标准,称为ASCII
编码,即美国信息互换标准码。
128个字符刚好用7
位可以表示,计算机最小存储单位是byte
,即8位,ASCII码中最高位为0,用剩下7位表示字符,这7位可以看作数字0-127,ASCII规定了0-127的每个数字代表什么含义,数字32-126表示的字符都是可打印字符,0-31和127表示的字符都是不可打印字符。这些字符一般用于控制目的,大部分都是不常用的。
ASCII码对美国来说是够用了,但对其他国家而言却是不够用的,于是,各个国家的各种计算机厂商就发明了各种各样的编码方式表示自己国家的字符,为了保持与ASCII的兼容,一般都是将最高位设置位1
,也就是说当最高位为0
的时候,表示ASCII码,当为1
的时候就是各个国家自己的字符。在这些扩展编码中,在西欧国家中流行的是ISO 8859-1
和Windows-1252
,在中国是GB2312
,GBK
,GB18030
和Big5
。
ISO 8859-1
ISO 8859-1
又称Latin-1
,它也是使用一个字节表示一个字符,其中0-127与ASCII
一样,128-256规定了不同的含义在128-255中,128-159表示一些控制字符,这些字符不常用,160-255表示西欧字符。
ISO 8859-1虽然号称是标准,用于西欧国家,但它连欧元这个符号都没有,因为欧元比较晚,而标准比较早。实际运用更广泛的是Windows-1252
编码,这个编码和ISO 8859-1基本是一样的,区别只在于数字128-159。Windows-1252使用其中的一些数字表示可打印字符,这个编码加入了欧元符号以及一些其他常用的字符。基本上Windows-1252已经取代了ISO 8859-1,在很多应用程序中,即使文件声明为ISO 8859-1解析的时候依然会被当作Windows-1252编码
GB2312
美国和西欧字符用一个字节就已经够了,但中文明显不够,中文的第一个标准是GB2312,GB2312标准主要针对的是常见的中文字符,GB2312固定使用两个字节表示汉字,在这两个字节中,最高位都是1,如果是0,就认为是ASCII码,在这两个字节中,高位字节范围是0xA1-0xF7
,低位字节范围是0xA1-0xFE
。
GBK
GBK建立在GB2312基础上,向下兼容GB2312,也就是说,GB2312编码的字符和二进制表示,在GBK编码完全是一样的。GBK增加了许多汉字,其中包括繁体。
GBK同样使用固定的两个字节表示,其中高位字节范围0x81-0xFE
,低位字节范围是0x40-0x7E
和0x80-0xFE
。
需要注意的是,低位字节是从0x40
(也就是64)开始的,也就是说低位字节的最高位可能为0
。那怎么知道它是汉字的一部分,还是一个ASCII码呢?,其实很简单,因为汉字是用固定两个字节表示的,在解析二进制流的时候,如果第一个字节的最高位为1,那么就将下一个字节读进来一起解析为一个汉字,而不用考虑它的最高位,解析完后,跳到第三个字节继续解析。
GB18030
GB18030向下兼容GBK,增加了很多字符,包括了少数民族字符,以及一些中日韩统一字符。
用两个字节已经表示不了GB18030中的所有字符,GB18030使用变长编码,有的字符是两个字节,有的是四个字节。在两个字节编码中,字节表示范围与GBK一样。在四个字节编码中,第一个字节的值为0x81-0xFE
,第二个字节的值为0x30-0x39
,第三个字节的值为0x81-0xFE
,第四个字节的值为0x30-0x39
。
解析二进制的时候,如何知道是两个字节还是四个字节呢?看第二个字节的范围,如果是0x30-0x39
就是四个字节表示,因为两个字节编码中第二个字节都比这个大。
Big5
Big5是针对繁体中文的,广泛用于我国台湾地区和我国香港特别行政区等地。Big5包括很多繁体,和GB2312类似,一个字符固定使用两个字节表示。在这两个字节中,高位字节范围是0x81-0xFE
,低位字节范围是0x40-0x7E
和0xA1-0xFE
。
编码汇总
- ASCII码是基础,使用一个字节表示,最高位设位0,其他7位表示128个字符。其他编码都兼容ASCII,最高位使用1来区分。
- 西欧主要使用Windows-1252,使用一个字节,增加了额外128个字符。
- 我国内地的三个主要编码GB2312、GBK、GB18030有时间先后关系,表示的字符越来越多,且后面的都兼容前面的,GB2312和GBK都是用2个字节表示,而GB18030则使用两个或四个字节表示。
- 我国香港特别行政区和我国台湾地区的主要编码是Big5。
- 如果文本里的字符都是ASCII字符,那么采用上面任意一种编码方式都是一样的
- 但
Unicode编码
每个国家的计算机厂商都对自己常用的字符进行编码,在编码的时候基本忽略了其他国家的字符和编码,甚至忽略了同一国家的其他计算机厂商,这样造成的结果是,出现太多的编码,且互不兼容。
Unicode
做了一件事,就是给世界上所有字符都分配了一个唯一的字符编号,这个编号范围从0x000000-0x10FFFF
,包括110多万。但大部分常用字符都在0x000000-0xFFFF
之间,即65536个数字之内。每个字符都有一个Unicode编号,这个编号一般写成十六进制,在前面加U+。大部分中文的编号范围为U+4E00-U+9FFF
,例如,”马”的Unicode 是”U+9A6C”。
简单理解,Unicode主要做了一件事,就是给所有字符分配了一个唯一数字编号。它并没有规定这个编号怎么对应到二进制表示,这是与上面介绍的其他编码不同的,其他编码都即规定了能表示那些字符,有规定了每个字符对应的二进制表示,而Unicode本身只规定了每个数字的数字编号是多少。
编号对应到二进制表示主要有三种方案:UTF-8
、UTF-16
、UTF-32
。
UTF-32
这个最简单,就是字符编号的整数二进制表示,4个字节。
但有一个细节,就是字符的排列顺序,如果第一个字节是整数二进制的最高位,最后一个字节是整数二进制中的最低位,那这种字节序就叫”大端(Big Ending BE)”,否者就叫”小端”(Little Ending LE)。对应的编码方式分别是UTF-32
和UTF-32LE
。
每个字符都用4个字节表示,非常浪费空间,实际采用的也比较少。
UTF-16
UTF-16
使用边长字节表示:
- 对于编号在
U+0000-U+FFFF
的字符(常用字符集),直接使用两个字节表示。需要说明的是,U+D800-U+DBFF
的编号其实是没有定义的。 - 字符集在
U+10000-U+10FFFF
的字符(也叫增补字符集),需要4个字节表示。前面两个字节叫高代理项,范围是U+D800-U+DBFF
;后面两个字节叫低代理项,范围是U+DC00-U+DFFF
。数字编号和这个二进制之间有一个转换算法,区分是两个字节还是四个字节表示一个字符就看前面两个字节的编号范围,如果是U+D800-U+DBFF
,就是四个字节,否者就是两个字节。UTF-16
和UTF-32一样也有字节序问题,如果高位存放在前面就叫大端(BE),编码就叫UTF-16BE,否者就叫小端,编码就叫UTF-16LE。UTF-16常用于系统内部编码,UTF-16比UTF-32节省了很多空间,但是任何一个字符都至少需要两个字节表示,就美国和西欧而言,还是比较浪费。
UTF-8
UTF-8使用边长字节表示,每个字节使用的字节个数与其Unicode编号大小有关,编号小的使用的字节就少,编号大的使用的字节就多,使用的字节个数为1-4不等。
编号范围 | 二进制格式 |
---|---|
0x00-0x7F(0-127) | 0xxxxxxx 一个字节 |
0x80-0x7FF(128-2047) | 110xxxxx 10xxxxxx |
0x800-0xFFFF(2048-655535) | 1110xxxxx 10xxxxxx 10xxxxxx |
0x10000-0x10FFFF(655535以上) | 11110xxxxx 10xxxxxx 10xxxxxx 10xxxxxx |
x的表示可以用的二进制位,而每个字节开头的1或者0是固定的。
小于128的,编码与ASCII码一样,最高位为0。其他编码的第一个字节都有特殊含义,最高位有几个连续的1就表示用几个字节表示,而其他字节都已10开头。
对于一个Unicode编号,具体怎么编码呢?首先将其看作整数,转化为二进制形式(去掉最高位的0),然后将二进制位从右至左依次填入对应的二进制格式x中,填完后,如果对应的二进制格式还没有填的x,则设为0。
我们来看个例子,”马”的Unicode编号是”0x9A6C”,整数编号是39532,其对应的UTF-8的二进制格式是:1110xxxx 10xxxxxx 10xxxxxx
。整数39532的二进制格式是1001 101001 10100
将这个二进制从右至左依次填入二进制格式中,结果就是UTF-8
编码11101001 10101001 1010100
十六进制表示为0xE9A9AC
。和UTF-16/UTF-32
不同,UTF-8是兼容ASCII
码的,对大部分中文而言,一个中文字符需要用三个字节表示。
Unicode小结
Unicode给世界上所有字符都规定了一个统一的编号,编号范围达到110万,但大部分字符都在65536范围以内。Unicode本身没有规定怎么把这个编号对应到二进制形式。
UTF-32/UTF-16/UTF-8都在做一件是,就是把Unicode编号对应到二进制形式,其对应方法不同而已。UTF-32使用4个字节,UTF-16大部分都是两个字节,少部分是四个字节,它们不兼容ASCII编码,都有字节顺序问题。UTF-8使用1-4个字节表示,兼容ASCII码,英文字符使用一个字节,中文字符大多使用三个字节。
编码转换
有了Unicode之后,每一个字符就有了多种不兼容的编码方式,比如说”马”这个字,它的各种编码方式对应的十六进制。
编码方式 | 十六进制编码 | 编码方式 | 十六进制编码 |
---|---|---|---|
GB18030 | C2 ED | UTF-8 | E9 A9 AC |
Unicode编码 | 9A 6C | UTF-16LE | 6C9A |
这几种格式之间可以借助Unicode编号进行编码转换。可以认为:每种编码都有一个映射表,存储其特有的字符编码和Unicode编号之间的对应关系,这种映射表是一个简化的说法,实际上可能是一种映射或转换方式。
编码具体转换过程可以是:一个字符从A编码转到B编码,先找到字符A的编码格式,通过A的映射表找到其Unicode编号,让后通过Unicode编号再查B的映射表,找到字符B的编码格式。
举例来说:”马”从GB18030转到UTF-8:先查GB18030->Unicode编号表,得到其编号是9A6C,然后查Unicode编号->UTF-8表,得到其UTF-8编码:E9A9AC。
乱码的原因
理解了编码,我们来看乱码。乱码通常有两种原因:一种比较简单,就是简单的解析错误;另一种比较复杂,再解析错误的基础上进行了编码转换。
解析错误
看个简单的例子。一个法国人采用Windows-1252编码写了个文件,发给一个中国人,中国人使用GB18030来解析这个字符,看到的可能就是乱码。比如,法国人发送的是pekin,windows-1252的二进制(采用十六进制)是50E96B 696E,第二个字节E9对应e,其他都是ASCII码,中国人收到的也是这个二进制,但是他把它看成了GB18030编码,GB18030编码中E96B对应的字符是”閗”,于是他看到的就是”p閗in”,这看来就是一个乱码。
反之也是一样,一个GB18030编码的文件如果被看作Windows-1252也是乱码。
这种情况下,之所以看起来是乱码,是因为看待或者说解析数据的方式错了。只要使用正确的编码方式进行解读就可以纠正了。很多文件编辑器,如Editplus、Notepad++、UltraEdit都有切换查看编码方式的功能,浏览器也都有切换查看编码方式的功能,如fire-fox、chrome。
切换查看编码的方式并没有改变数据的二进制本身,而是改变了解析数据的方式,从而改变了数据看起来的样子,这与前面提到的编码转换正好相反。很多时候,做这样一个编码查看方式的切换就可以解决问题,但有的时候是不够的。
错误的解析和编码转换
如果怎么改变查看方式都不对,那很有可能就不仅仅是解析二进制方式的不对,而是文本在错误解析的基础上还进行了编码转换。
两个字”老马”,本来的格式是GB18030,编码(十六进制)是COCF C2ED。
这个二进制形式被错误当成了了Windows-1252编码,解读成了”a]ai”。
随后这个字符进行了编码转换,转换成了UTF-8编码,形式还是a]ai,但二进制变成了C380C38F C382C3AD,每个字符两个字节。
这个时候再按照GB18030解析,字符就变成了乱码形式”鎎屒”,而这个时候无论怎么切换查看编码的方式,这个二进制看起来都是乱码。
这种情况是乱码产生的主要原因,其实这种情况很常见,计算机程序为了便于统一处理,经常会将所有编码转换为一种方式UTF-8
,在转换的时候,需要知道原来的编码是什么,但可能会搞错,而一旦搞错并进行了转换,就会出现乱码。这种情况下,无论怎么切换查看编码的方式都是不行的,如表所示。
编码方式 | 结果 | 编码方式 | 结果 |
---|---|---|---|
十六进制 | C3 80 C3 8F C3 82 C3 AD | GB18030 | xxx |
UTF-8 | ÀÏÂí | Big5 | ??? |
Windows-1252 | xxx |
虽然这么多形式,我们看到的乱码形式很有可能是”a]ai”,因为例子中UTF-8是编码转换的目标编码格式,既然转换了UTF-8,一般也是按UTF-8查看。
从乱码中恢复
乱码主要是因为发生了一次错误的编码转换,所谓恢复,是指要恢复两个关键信息:一个是原来的二进制编码方式A;另一个是错误解读的编码方式B。
恢复的基本思路是尝试进行逆向操作,假定一种编码转换方式B获取乱码的二进制格式,然后再假定一种编码解读方式A解读这个二进制文件,查看其看上去的形式,这要尝试多种编码,如果能找到看着正常的字符形式,应该就可以恢复。
这听上去可能比较抽象,举个例子,假定乱码的形式是”ÀÏÂí”,尝试多种B和A来看字符形式。我们先使用编辑器,然后使用Java编程来看。
使用UltraEdit
UltraEdit支持编码转换和切换查看编码的方式,也支持文件的二进制显和编辑,其他编辑其也有类似功能。
新建一个UTF-8编码的文件,复制”a]ai”到文件中。使用编码转换,转换到Windows-1252,执行文件->转换到->西欧->Win-1252命令。
转换完后,打开十六进制编辑,查看其二进制形式。
C0 CF C2 ED
可以看出,其形式还是”a]ai”,但二进制格式变成了”C0 CF C2 ED”。这个过程相当于假设B是Windows-1252。这个时候,再按照多种编码格式查看这个二进制,在UltraEdit中,关闭十六进制编辑,切换查看编码方式为GB18030,执行”视图”->”查看方式(文件编码)”->”东亚语言”->”GB18030”,切换完后,同样的二进制神奇地变为了正确的字符形式”老马”,打开十六进制编辑器,可以看出还是”C0CF C2ED”,这个”GB18030”相当于假设A是GB18030。
这个例子我们碰巧第一次就猜对了。实际中,可能要做多次尝试,过程是类似的,先进行编码转换(使用B编码),然后使用不同的编码方式查看(使用A编码),如果能找到看上去对的形式,就恢复了。表列出了主要的B编码格式、对应的二进制、以及按A编码解读的各种形式。
可以看出,第一行是正确的,也就是说原来的编码其实是A即GB18030,但被错误解读成了B即Windows-1252了。
使用Java
// 获取一个字符串给定的编码格式的二进制
public byte[] getBytes(String charsetName);
// 这个构造方法已给定的二进制数字bytes按照编码格式charsetName解读为一个字符串
public String(Byte[] bytes, String charsetName);
将A看做GB18030,将B看做Windows-1252,进行恢复的Java代码如下:
String str = "ÀÏÂí";
String newStr = new Stirng(str.getBytes("windows-1252","GB18030"));
print(newStr);
先按照B编码(Windows-1252)获取字符从的二进制,然后按A编码(GB18030)解读这个二进制,得到一个新字符串,输出为”老马”。
同样,一次碰巧就对了,实际中,我们可以写一个循环,测试不同A/B编码中的结果形式。
public static void recover(String text) throws UnsupportedEncodingException {
String[] charsets = new String[]{"Windows-1252", "GB18030", "Big5", "UTF-8"};
for (int i = 0; i < charsets.length; i++) {
for (int j = 0; j < charsets.length; j++) {
if (i == j) {
continue;
}
String newText = new String(text.getBytes(charsets[i]), charsets[j]);
System.out.println("已" + charsets[i] + "解析成二进制" + "已" + charsets[j] + "解读成一个新字符串");
System.out.println("newText = " + newText);
}
}
}
以上使用不同的编码进行测试,如果输出有正确的,那么就可以恢复。
可以看出,恢复的尝试需要进行很多次,上面例子尝试了常见编码GB18030、Windows-1252、Big5、UTF-8。这4中编码是常见的编码,在大部分实际应用中应该够用了。如果有其他编码,可以增加进行尝试。
不是所有乱码形式都是可以恢复的,如果形式中有很多不能识别字符(?),则很难恢复。另外,如果乱码是由进行了很多次解析和转换错误造成的,也很难恢复。
Char的本质
char
用于表示一个字符,可以是英文字符,也可以是中文字符,赋值时使用'
括起来即可。
char c = 'A';
char c = '马';
但为什么char
可以进行算术运算和比较呢?它的本质到底是什么呢?
再Java内部进行字符处理时,采用的都是Unicode,具体编码格式是Unicode-16BE。简单回顾一下,UTF-16BE使用两个或者四个字节表示一个字符,Unicode编号范围再65536以内的占用两个字节,超出范围使用四个字节,BE就是先输出高字节位,再输出低字节位,这与整数在内存表示是一样的。
char
本质上是一个固定占用两字节的无符号整数,这与正整数对应于Unicode编号,用于表示那个Unicode编号对应的字符。由于固定占用两个字节,char只能表示Unicode编号在65536以内的字符,而不能表示超出范围的字符。那超出范围的字符怎么表示呢?使用两个char。类Character、String有一些相关方法。
char
有多种赋值方式
char c = 'A'; // ASCII码能直接表示的字符
char c = '马'; // 转换成对应的Unicode编号 39532
char c = 39532; // 十进制宝石
char c = 0x9a6c; // 十六进制
char c = '\u9a6c'; // Unicode编码
第一种赋值方式是最常见的,将一个能用的ASCII码表示的字符赋值给一个字符变量。第二种赋值方式也很常见,但这里是中文字符,需要注意的是,直接写字符常量的时候应该注意文件的编码,比如,GBK编码的代码按UTF-8打开,字符会直接乱码,赋值的时候是按当前的编码解读方式,将这个字符形式对应的Unicode编号赋值给变量,”马”对应的Unicode编号是39523,所以第二种赋值方式和第三种赋值方式是一样的。第三种赋值方式是直接将十进制常量赋给字符,第五种方式是按Unicode字符形式。所以,第二、三、四、五中赋值方式都是一样的,本质都是将Unicode编号59523赋给了字符。
由于char本质上是一个整数,所以可以进行整数能做的运算,在进行运算时会被看作int
,但由于char
占用两个字节,运算结果不能直接赋值给char
类型,需要进行强制类型转换,这和byte、short参与运算是类似的。char
类型的比较就是其Unicode编号的比较。
Char
的加减运算就是按其Unicode编号进行运算,一般对字符做加减运算没什么意义,但ASCII字符是有意义的。比如大小写转换,大写A-Z的编号是65-90,小写a-z的编号是97-122,正好相差32,所以大写转小写只需要加32,二小写转大写只需减32。加减运算的另一个引用就是加密和解密,将字符进行某种可逆的数学运算可以做加解密。
char
的位运算可以看作是对应整数的位运算,只是它是无符号数,也就是说,有符号右移>>
和无符号右移>>>
的结果是一样的。既然char
本质上是整数,查看char
的二进制表示,同样也可以用Integer
的方法,如下所示:
char c = '马';
print(Integer.toBinaryString(c));
输出为:1001101001101100
。