《Java 编程的逻辑》笔记——第2章 理解数据背后的二进制

声明:

本博客是本人在学习《Java 编程的逻辑》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。

本博客已标明出处,如有侵权请告知,马上删除。

开头语

在第 1 章,我们遗留了几个问题。

  • 正整数相乘的结果过居然出现了负数。
  • 非常基本的小数运算结果居然不精确。
  • 字符类型也可以进行算术运算和比较。

要理解这些行为,我们需要理解数值和文本字符在计算机内部的二进制表示,本章就来介绍各种数据背后的二进制。

2.1 整数的二进制表示与位运算

2.1.1 十进制

要理解整数的二进制,我们先来看下熟悉的十进制。十进制是如此的熟悉,我们可能已忽略了它的含义。比如 123,我们不假思索就知道它的值是多少。

但其实 123 表示的 1 * (10 ^ 2) + 2 * (10 ^ 1) + 3 * (10 ^ 0),它表示的是各个位置数字含义之和,每个位置的数字含义与位置有关,从右向左,第一位乘以 10 的 0 次方, 即 1,第二位乘以 10 的 1 次方,即 10,第三位乘以 10 的 2 次方,即 100,依次类推。

换句话说,每个位置都有一个位权,从右到左,第一位为 1,然后依次乘以 10,即第二位为 10,第三位为 100,依次类推。

2.1.2 正整数的二进制表示

正整数的二进制表示与此类似, 只是在十进制中,每个位置可以有 10 个数字,从 0 到 9,但在二进制中,每个位置只能是 0 或 1。位权的概念是类似的,从右到左,第一位为 1,然后依次乘以 2,即第二位为 2,第三位为 4,依次类推。

表 2-1 列出了一些数字的二进制与对应的十进制。

在这里插入图片描述

2.1.3 负整数的二进制表示

十进制的负数表示就是在前面加一个负数符号 -,例如 -123。但二进制如何表示负数呢?

其实概念是类似的,二进制使用最高位表示符号位,用 1 表示负数,用 0 表示正数

但哪个是最高位呢?整数有四种类型,byte/short/int/long,分别占 1/2/4/8 个字节,即分别占 8/16/32/64 位,每种类型的符号位都是其最左边的一位。

为方便举例,下面假定类型是 byte,即从右到左的第 8 位表示符号位。

但负数表示不是简单的将最高位变为 1,比如说:

  • byte a = -1,如果只是将最高位变为 1,二进制应该是 10000001,但实际上,它应该是 11111111。
  • byte a= -127,如果只是将最高位变为 1,二进制应该是 11111111,但实际上,它却应该是 10000001。

和我们的直觉正好相反,这是什么表示法?这种表示法称为补码表示法,而符合我们直觉的表示称为原码表示法补码表示就是在原码表示的基础上取反然后加 1。取反就是将 0 变为 1,1 变为 0。

负数的二进制表示就是对应的正数的补码表示,比如说:

  • -1:1 的原码表示是 00000001,取反是 11111110,然后再加 1,就是 11111111。
  • -2:2 的原码表示是 00000010,取反是 11111101,然后再加 1,就是 11111110。
  • -127:127 的原码表示是 01111111,取反是 10000000,然后再加 1,就是 10000001。

给定一个负数二进制表示,要想知道它的十进制值,可以采用相同的补码运算。比如:10010010,首先取反,变为 01101101,然后加 1,结果为 01101110,它的十进制值为 110,所以原值就是 -110。直觉上,应该是先减 1,然后再取反,但计算机只能做加法,而补码的一个良好特性就是,对负数的补码表示做补码运算就可以得到其对应整数的原码,正如十进制运算中负负得正一样。

byte 类型,正数最大表示是 01111111,即 127,负数最小表示(绝对值最大)是 10000000,即 -128,表示范围就是 -128 到 127。其他类型的整数也类似,负数能多表示一个数。

负整数为什么采用补码呢?

原因是:只有这种形式,计算机才能实现正确的加减法

计算机其实只能做加法,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  ->  00000010 

结果也是正确的。就是这样的,看上去可能比较奇怪和难以理解,但这种表示其实是非常严谨和正确的,是不是很奇妙?

理解了二进制加减法,我们就能理解为什么正数的运算结果可能出现负数了。当计算结果超出表示范围的时候,最高位往往是 1,然后就会被看做负数。比如说,127+1:

127   -> 01111111
1     -> 00000001
+ ------------------
-128  -> 10000000 

计算结果超出了 byte 的表示范围,会被看做 -128。

2.1.4 十六进制

二进制写起来太长,为了简化写法,可以将四个二进制位简化为一个 0 到 15 的数,10 到 15 用字符 A 到 F 表示,这种表示方法称为十六进制,如表 2-2 所示。

在这里插入图片描述

可以用十六进制直接写常量数字,在数字前面加 0x 即可。比如 10 进制的 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 的方法查看整数的二进制和十六进制表示,例如:

int a = 25;
System.out.println(Integer.toBinaryString(a)); //二进制
System.out.println(Integer.toHexString(a));  //十六进制
System.out.println(Long.toBinaryString(a)); //二进制
System.out.println(Long.toHexString(a));  //十六进制

2.1.5 位运算

位运算是将数据看做二进制,进行位级别的操作。Java7 之前不能单独表示一个位,但是可以用 byte 表示 8 位,可以用 16 进制写二进制常量。比如:0010 表示成 16 进制是 0x2, 110110 表示成 16 进制是 0x36。

位运算有移位运算逻辑运算

移位有:

  • 左移:操作符为 <<,向左移动,右边的低位补 0,高位的就舍弃掉了。将二进制看做整数,左移 1 位就相当于乘以 2。
  • 无符号右移:操作符为 >>>,向右移动,右边的舍弃掉,左边补 0。
  • 有符号右移:操作符为 >>,向右移动,右边的舍弃掉,左边正数高位补 0,负数高位补 1。将二进制看做整数,右移 1 位相当于除以 2。

例如:

int a = 4; // 100
a = a >> 2; // 001,等于1
a = a << 3 // 1000,变为8

逻辑运算有:

  • 按位与 &:两位都为 1 才为 1
  • 按位或 |:只要有一位为 1,就为 1
  • 按位取反 ~: 1 变为 0,0 变为 1
  • 按位异或 ^ :相异为真,相同为假

大部分都比较简单,就不详细说了。具体形式,例如:

int a = ...; 
a = a & 0x1 // 返回0或1,就是a最右边一位的值。
a = a | 0x1 // 不管a原来最右边一位是什么,都将设为1

2.2 小数的二进制表示

计算机之所以叫"计算"机就是因为发明它主要是用来计算的,"计算"当然是它的特长,在大家的印象中,计算一定是非常准确的。但实际上,即使在一些非常基本的小数运算中,计算的结果也是不精确的。

比如:

float f = 0.1f*0.1f;
System.out.println(f);

这个结果看上去,不言而喻,应该是 0.01,但实际上,屏幕输出却是 0.010000001,后面多了个 1。

看上去这么简单的运算,计算机怎么会出错了呢?

2.2.1 小数计算为什么会出错

实际上,不是运算本身会出错,而是计算机根本就不能精确的表示很多数,比如 0.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,0.001,即 10 ^ (-1),10 ^ (-2),10 ^ (-3)。

很多数,十进制也是不能精确表示的,比如 1/3。保留三位小数的话,十进制表示是 0.333,但无论后面保留多少位小数,都是不精确的,用 0.333 进行运算,比如乘以 3,期望结果是 1,但实际上却是 0.999。

二进制是类似的,二进制只能表示哪些可以表述为 2 的多少次方和的数。来看下 2 的次方的一些例子,如表 2-3 所示。

在这里插入图片描述

可以精确表示为 2 的某次方之和的数可以精确表示,其他数则不能精确表示。

为什么一定要用二进制呢?

为什么就不能用我们熟悉的十进制呢?在最最底层,计算机使用的电子元器件只能表示两个状态,通常是低压和高压,对应 0 和 1,使用二进制容易基于这些电子器件构建硬件设备和进行运算。如果非要使用十进制,则这些硬件就会复杂很多,并且效率低下。

为什么有的小数计算是准确的?

如果你编写程序进行试验,你会发现有的计算结果是准确的。比如,我用 Java 写:

System.out.println(0.1f+0.1f); 
System.out.println(0.1f*0.1f);

第一行输出 0.2,第二行输出 0.010000001。按照上面的说法,第一行的结果应该也不对啊?

其实,这只是 Java 语言给我们造成的假象。计算结果其实也是不精确的,但是由于结果和 0.2 足够接近,在输出的时候,Java 选择了输出 0.2 这个看上去非常精简的数字,而不是一个中间有很多 0 的小数。

在误差足够小的时候,结果看上去是精确的,但不精确其实才是常态。

怎么处理计算不精确?

计算不精确,怎么办呢?大部分情况下,我们不需要那么高的精度,可以四舍五入,或者在输出的时候只保留固定个数的小数位。

如果真的需要比较高的精度,一种方法是将小数转化为整数进行运算,运算结束后再转化为小数,另外的方法一般是使用十进制的数据类型,这个没有统一的规范,在 Java 中是 BigDecimal,运算更准确,但效率比较低,本节就不详细说了。

2.2.2 二进制表示

我们之前一直在用"小数"这个词表示 float 和 double 类型,其实,这是不严谨的,“小数"是在数学中用的词,在计算机中,我们一般说的是"浮点数”。float 和 double 被称为浮点数据类型,小数运算被称为浮点运算

为什么要叫浮点数呢?这是由于小数的二进制表示中,表示那个小数点的时候,点不是固定的,而是浮动的。

我们还是用 10 进制类比,10进制有科学表示法,比如 123.45 这个数,直接这么写,就是固定表示法,如果用科学表示法,在小数点前只保留一位数字,可以写为 1.2345E2 即 1.2345 * (10^2),即在科学表示法中,小数点向左浮动了两位。

二进制中为表示小数,也采用类似的科学表示法,形如 m * (2^e)。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 中,可以使用如下代码:

Integer.toBinaryString(Float.floatToIntBits(value))
Long.toBinaryString(Double.doubleToLongBits(value));

2.3 字符的编码与乱码

本节讨论与语言无关的字符和文本的编码以及乱码。我们在处理文件、浏览网页、编写程序时,时不时会碰到乱码的情况。乱码几乎总是令人心烦,让人困惑。希望通过本节和下节文章,你可以自信从容地面对乱码,进而恢复乱码。

编码和乱码听起来比较复杂,文章也比较长,但其实并不复杂,请耐心阅读,让我们逐步来探讨。我们先介绍各种编码,然后介绍编码转换,分析乱码产生的原因,最后介绍如何从乱码种恢复。

编码有两大类:一类是非 Unicode 编码;另一类是 Unicode 编码

2.3.1 常见非 Unicode 编码

2.3.1.1 ASCII

世界上虽然有各种各样的字符,但计算机发明之初没有考虑那么多,基本上只考虑了美国的需求,美国大概只需要 128 个字符,美国就规定了这 128 个字符的二进制表示方法

这个方法是一个标准,称为 ASCII 编码,全称是 American Standard Code for Information Interchange,美国信息互换标准代码。

128 个字符用 7 个位刚好可以表示,计算机存储的最小单位是 byte,即 8 位,ASCII 码中最高位设置为 0,用剩下的 7 位表示字符。这 7 位可以看做数字 0 到 127,ASCII 码规定了从 0 到 127 个,每个数字代表什么含义。

我们先来看数字 32 到 126 的含义,如图 2-1 所示,除了中文之外,我们平常用的字符基本都涵盖了,键盘上的字符大部分也都涵盖了。

在这里插入图片描述

数字 32 到 126 表示的这些字符都是可打印字符,0 到 31 和 127 表示一些不可以打印的字符,这些字符一般用于控制目的,这些字符中大部分都是不常用的,表 2-4 列出了其中相对常用的字符。

在这里插入图片描述

ASCII 码对美国是够用了,但对别的国家而言却是不够的,于是,各个国家的各种计算机厂商就发明了各种各种的编码方式以表示自己国家的字符,为了保持与 ASCII 码的兼容性,一般都是将最高位设置为 1。也就是说,当最高位为 0 时,表示 ASCII 码,当为 1 时就是各个国家自己的字符

在这些扩展的编码中,在西欧国家中流行的是 ISO 8859-1 和 Windows-1252,在中国是 GB2312,GBK,GB18030 和 Big5,我们逐个来看下这些编码。

2.3.1.2 ISO 8859-1

ISO 8859-1 又称 Latin-1,用于西欧国家,它也是使用一个字节表示一个字符,其中 0 到 127 与 ASCII 一样,128 到 255 规定了不同的含义。

在 128 到 255中,128 到 159 表示一些控制字符,这些字符也不常用,就不介绍了。160 到 255 表示一些西欧字符,如图 2-2 所示。

在这里插入图片描述

2.3.1.3 Windows-1252

ISO 8859-1 虽然号称是标准,用于西欧国家,但它连欧元(€) 这个符号都没有,因为欧元比较晚,而标准比较早。实际西欧国家使用更为广泛的是 Windows-1252 编码,这个编码与 ISO8859-1 基本是一样的,区别只在于数字 128 到 159。Windows-1252 使用其中的一些数字表示可打印字符,这些数字表示的含义,如图 2-3 所示。

在这里插入图片描述

这个编码中加入了欧元符号以及一些其他常用的字符。基本上可以认为,ISO 8859-1 已被 Windows-1252 取代,在很多应用程序中,即使文件声明它采用的是 ISO 8859-1 编码,解析的时候依然被当做 Windows-1252 编码。

HTML5 甚至明确规定,如果文件声明的是 ISO 8859-1 编码,它应该被看做 Windows-1252 编码。为什么要这样呢?因为大部分人搞不清楚 ISO 8859-1 和 Windows-1252 的区别,当他说 ISO 8859-1 的时候,其实他实际指的是 Windows-1252,所以标准干脆就这么强制了。

2.3.1.4 GB2312

美国和西欧字符用一个字节就够了,但中文显然是不够的。中文第一个标准是 GB2312。

GB2312 标准主要针对的是简体中文常见字符,包括约 7000 个汉字,不包括一些罕用词,不包括繁体字。

GB2312 固定使用两个字节表示汉字,在这两个字节中,最高位都是 1,如果是 0,就认为是 ASCII 字符。在这两个字节中,其中高位字节范围是 0xA1-0xF7,低位字节范围是 0xA1-0xFE。

比如,"老马"的 GB2312 编码是(十六进制表示)如表 2-5 所示。

在这里插入图片描述

2.3.1.5 GBK

GBK 建立在 GB2312 的基础上,向下兼容 GB2312,也就是说,GB2312 编码的字符和二进制表示,在 GBK 编码里是完全一样的。

GBK 增加了一万四千多个汉字,共计约 21000 汉字,其中包括繁体字。

GBK 同样使用固定的两个字节表示,其中高位字节范围是 0x81-0xFE,低位字节范围是 0x40-0x7E 和 0x80-0xFE。

需要注意的是,低位字节是从 0x40 也就是 64 开始的,也就是说,低位字节最高位可能为 0。那怎么知道它是汉字的一部分,还是一个 ASCII 字符呢?

其实很简单,因为汉字是用固定两个字节表示的,在解析二进制流的时候,如果第一个字节的最高位为 1,那么就将下一个字节读进来一起解析为一个汉字,而不用考虑它的最高位,解析完后,跳到第三个字节继续解析。

2.3.1.6 GB18030

GB18030 向下兼容 GBK,增加了五万五千多个字符,共七万六千多个字符。包括了很多少数民族字符,以及中日韩统一字符。

用两个字节已经表示不了 GB18030 中的所有字符,GB18030 使用变长编码,有的字符是两个字节,有的是四个字节

在两字节编码中,字节表示范围与 GBK 一样。在四字节编码中,第一个字节的值从 0x81 到 0xFE,第二个字节的值从 0x30 到 0x39,第三个字节的值从 0x81 到 0xFE,第四个字节的值从 0x30 到 0x39。

解析二进制时,如何知道是两个字节还是四个字节表示一个字符呢?看第二个字节的范围,如果是 0x30 到 0x39 就是四个字节表示,因为两个字节编码中第二字节都比这个大。

2.3.1.7 Big5

Big5 是针对繁体中文的,广泛用于台湾香港等地

Big5 包括 13000 多个繁体字,和 GB2312 类似,一个字符同样固定使用两个字节表示。在这两个字节中,高位字节范围是 0x81-0xFE,低位字节范围是 0x40-0x7E 和 0xA1-0xFE。

2.3.1.8 编码汇总

我们简单汇总一下上面的内容。

ASCII 码是基础,一个字节表示,最高位设为 0,其他 7 位表示 128 个字符。其他编码都是兼容 ASCII 的,最高位使用 1 来进行区分。

西欧主要使用 Windows-1252,使用一个字节,增加了额外 128 个字符。

中文大陆地区的三个主要编码 GB2312,GBK,GB18030,有时间先后关系,表示的字符数越来越多,且后面的兼容前面的,GB2312 和 GBK都是用两个字节表示,而 GB18030 则使用两个或四个字节表示。

香港台湾地区的主要编码是 Big5。

如果文本里的字符都是 ASCII 码字符,那么采用以上所说的任一编码方式都是一样的。

但如果有高位为 1 的字符,除了 GB2312/GBK/GB18030 外,其他编码都是不兼容的,比如,Windows-1252 和中文的各种编码是不兼容的,即使 Big5 和 GB18030 都能表示繁体字,其表示方式也是不一样的,而这就会出现所谓的乱码。

2.3.2 Unicode 编码

以上我们介绍了中文和西欧的字符与编码,但世界上还有很多别的国家的字符,每个国家的各种计算机厂商都对自己常用的字符进行编码,在编码的时候基本忽略了别的国家的字符和编码,甚至忽略了同一国家的其他计算机厂商,这样造成的结果就是,出现了太多的编码,且互相不兼容。

世界上所有的字符能不能统一编码呢?可以,这就是 Unicode。

Unicode 做了一件事,就是给世界上所有字符都分配了一个唯一的数字编号,这个编号范围从 0x000000 到 0x10FFFF,包括 110 多万。但大部分常用字符都在 0x0000 到 0xFFFF 之间,即 65536 个数字之内。每个字符都有一个 Unicode 编号,这个编号一般写成十六进制,在前面加 U+。大部分中文的编号范围在 U+4E00 到 U+9FA5,例如,"马"的 Unicode 是 U+9A6C。

Unicode 就做了这么一件事,就是给所有字符分配了唯一数字编号。它并没有规定这个编号怎么对应到二进制表示,这是与上面介绍的其他编码不同的,其他编码都既规定了能表示哪些字符,又规定了每个字符对应的二进制是什么,而 Unicode 本身只规定了每个字符的数字编号是多少。

那编号怎么对应到二进制表示呢?有多种方案,主要有 UTF-32, UTF-16 和 UTF-8

2.3.2.1 UTF-32

这个最简单,就是字符编号的整数二进制形式,四个字节

但有个细节,就是字节的排列顺序,如果第一个字节是整数二进制中的最高位,最后一个字节是整数二进制中的最低位,那这种字节序就叫“大端”(Big Endian, BE),否则,正好相反的情况,就叫“小端”(Little Endian, LE)。对应的编码方式分别是 UTF-32BE 和 UTF-32LE。

可以看出,每个字符都用四个字节表示,非常浪费空间,实际采用的也比较少

2.3.2.2 UTF-16

UTF-16 使用变长字节表示

  • 对于编号在 U+0000 到 U+FFFF 的字符 (常用字符集),直接用两个字节表示。需要说明的是,U+D800 到 U+DBFF 之间的编号其实是没有定义的。
  • 字符值在 U+10000 到 U+10FFFF 之间的字符(也叫做增补字符集),需要用四个字节表示。前两个字节叫高代理项,范围是 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 节省了很多空间,但是任何一个字符都至少需要两个字节表示,对于美国和西欧国家而言,还是很浪费的

2.3.2.3 UTF-8

UTF-8 就是使用变长字节表示,每个字符使用的字节个数与其 Unicode 编号的大小有关,编号小的使用的字节就少,编号大的使用的字节就多,使用的字节个数从 1 到 4 个不等

具体来说,各个 Unicode 编号范围对应的二进制格式如表 2-6 所示。

在这里插入图片描述

表 2-6 中的 x 表示可以用的二进制位,而每个字节开头的 1 或 0 是固定的。

小于 128 的,编码与 ASCII 码一样,最高位为 0。其他编号的第一个字节有特殊含义,最高位有几个连续的 1 表示一共用几个字节表示,而其他字节都以 10 开头。

对于一个 Unicode 编号,具体怎么编码呢?首先将其看做整数,转化为二进制形式(去掉高位的 0),然后将二进制位从右向左依次填入到对应的二进制格式 x 中,填完后,如果对应的二进制格式还有没填的 x,则设为 0

我们来看个例子,'马’的 Unicode 编号是:0x9A6C,整数编号是 39532,其对应的 UTF-8 二进制格式是:

1110xxxx 10xxxxxx 10xxxxxx

整数编号 39532 的二进制格式是:

1001 101001 101100

将这个二进制位从右到左依次填入二进制格式中,结果就是其 UTF-8 编码:

11101001 10101001 10101100

十六进制表示为 0xE9A9AC

和 UTF-32/UTF-16 不同,UTF-8 是兼容 ASCII 的,对大部分中文而言,一个中文字符需要用三个字节表示

2.3.2.4 Unicode 编码小结

Unicode 给世界上所有字符都规定了一个统一的编号,编号范围达到 110 多万,但大部分字符都在 65536 以内。Unicode 本身没有规定怎么把这个编号对应到二进制形式。

UTF-32/UTF-16/UTF-8 都在做一件事,就是把 Unicode 编号对应到二进制形式,其对应方法不同而已。UTF-32 使用 4 个字节,UTF-16 大部分是两个字节,少部分是四个字节,它们都不兼容 ASCII 编码,都有字节顺序的问题。UTF-8 使用 1 到 4 个字节表示,兼容 ASCII 编码,英文字符使用 1 个字节,中文字符大多用 3 个字节。

2.3.3 编码转换

有了 Unicode 之后,每一个字符就有了多种不兼容的编码方式,比如说"马"这个字符,它的各种编码方式对应的十六进制如表 2-7 所示。

在这里插入图片描述

这几种格式之间可以借助 Unicode 编号进行编码转换。可以简化认为,每种编码都有一个映射表,存储其特有的字符编码和 Unicode 编号之间的对应关系,这个映射表是一个简化的说法,实际上可能是一个映射或转换方法。

编码转换的具体过程可以是,比如说,一个字符从 A 编码转到 B 编码,先找到字符的 A 编码格式,通过 A 的映射表找到其 Unicode 编号,然后通过 Unicode 编号再查 B 的映射表,找到字符的 B 编码格式。

举例来说,"马"从 GB18030 转到 UTF-8,先查 GB18030->Unicode 编号表,得到其编号是 9A 6C,然后查 Unicode 编号->UTF-8 表,得到其 UTF-8 编码:E9 A9 AC。

编码转换改变了数据的二进制格式,但并没有改变字符看上去的样子

2.3.4 乱码的原因

理解了编码,我们来看乱码。乱码有两种常见原因:一种比较简单,就是简单的解析错误;另外一种比较复杂,在解析错误的基础上进行了编码转换

2.3.4.1 解析错误

看个简单的例子。

一个法国人,采用 Windows-1252 编码写了个文件,发送给了一个中国人,中国人使用 GB18030 来解析这个字符,看到的可能就是乱码。

比如,法国人发送的是 “Pékin”,Windows-1252 的二进制是(采用16进制):50 E9 6B 69 6E,第二个字节 E9 对应 é,其他都是 ASCII 码,中国人收到的也是这个二进制,但是他把它看做成了 GB18030 编码,GB18030 中 E9 6B 对应的是字符 “閗i”,于是他看到的就是:“P閗kin”,这看来就是一个乱码。

反之也是一样的,一个 GB18030 编码的文件如果被看做 Windows-1252 也是乱码。

这种情况下,之所以看起来是乱码,是因为看待或者说解析数据的方式错了。纠正的方式,只要使用正确的编码方式进行解读就可以了。很多文件编辑器,如 EditPlus, NotePad++, UltraEdit 都有切换查看编码方式的功能,浏览器也都有切换查看编码方式的功能,如 Firefox,在菜单 “查看”->“文字编码” 中。

切换查看编码的方式,并没有改变数据的二进制本身,而只是改变了解析数据的方式,从而改变了数据看起来的样子,这与前面提到的编码转换正好相反

很多时候,做这样一个编码查看方式的切换,就可以解决乱码的问题,但有的时候,这样是不够的。

2.3.4.2 错误的解析和编码转换

但如果怎么改变查看方式都不对的话,那很有可能就不仅仅是解析二进制的方式不对,而是文本在错误解析的基础上还进行了编码转换

我们举个例子来说明:

  1. 两个字 “老马”,本来的编码格式是 GB18030,编码是(16进制): C0 CF C2 ED。
  2. 这个二进制形式被错误当成了 Windows-1252 编码, 解读成了字符 “ÀÏÂí”
  3. 随后这个字符进行了编码转换,转换成了 UTF-8 编码,形式还是 “ÀÏÂí”,但二进制变成了:C3 80 C3 8F C3 82 C3 AD,每个字符两个字节。
  4. 这个时候,再按照 GB18030 解析,字符就变成了乱码形式 “脌脧脗铆”, 而且这时无论怎么切换查看编码的方式,这个二进制看起来都是乱码。

这种情况是乱码产生的主要原因

这种情况其实很常见,计算机程序为了便于统一处理,经常会将所有编码转换为一种方式,比如 UTF-8, 在转换的时候,需要知道原来的编码是什么,但可能会搞错,而一旦搞错,并进行了转换,就会出现这种乱码。这种情况下,无论怎么切换查看编码方式,都是不行的,如表 2-8 所示。

在这里插入图片描述

虽然有这么多形式,但我们看到的乱码形式很可能是 “ÀÏÂí”,因为在例子中 UTF-8 是编码转换的目标编码格式,既然转换为了 UTF-8,一般也是要按 UTF-8 查看。

那有没有办法恢复呢?如果有,怎么恢复呢?

2.3.5 从乱码中恢复

"乱"主要是因为发生了一次错误的编码转换,恢复是要恢复两个关键信息,一个是原来的二进制编码方式 A,另一个是错误解读的编码方式 B。

恢复的基本思路是尝试进行逆向操作,假定按一种编码转换方式 B 获取乱码的二进制格式,然后再假定一种编码解读方式 A 解读这个二进制,查看其看上去的形式,这个要尝试多种编码,如果能找到看着正常的字符形式,那应该就可以恢复

这个听上去可能比较模糊,我们举个例子来说明,假定乱码形式是 “ÀÏÂí”,尝试多种 B 和 A 来看字符形式。我们先使用编辑器,以 UltraEdit 为例,然后使用 Java 编程来看。

2.3.5.1 使用 UltraEdit

UltraEdit 支持编码转换和切换查看编码方式,也支持文件的二进制显示和编辑,所以我们以 UltraEdit 为例,其他一些编辑器可能也有类似功能。

新建一个 UTF-8 编码的文件,拷贝 “ÀÏÂí” 到文件中。使用编码转换,转换到 windows-1252 编码,功能在 “文件”->“转换到”->“西欧”->WIN-1252。

转换完后,打开十六进制编辑,查看其二进制形式,如图 2-4 所示。

在这里插入图片描述

可以看出,其形式还是 “ÀÏÂí”,但二进制格式变成了 C0 CF C2 ED。这个过程,相当于假设 B 是 windows-1252。这个时候,再按照多种编码格式查看这个二进制,在 UltraEdit中,关闭十六进制编辑,切换查看编码方式为 GB18030,功能在 “视图”->“查看方式(文件编码)”->“东亚语言”->GB18030,切换完后,同样的二进制神奇的变为了正确的字符形式 “老马”,打开十六进制编辑器,可以看出,二进制还是 C0 CF C2 ED,这个 GB18030 相当于假设 A 是 GB18030。

这个例子我们碰巧第一次就猜对了。实际中,我们可能要做多次尝试,过程是类似的,先进行编码转换(使用 B 编码),然后使用不同编码方式查看(使用 A 编码),如果能找到看上去对的形式,就恢复了。表 2-9 列出了主要的 B 编码格式,对应的二进制,按 A 编码解读的各种形式。

在这里插入图片描述

可以看出,第一行是正确的,也就是说原来的编码其实是 A 即 GB18030,但被错误解读成了 B 即 Windows-1252 了。

2.3.5.2 使用 Java

下面我们来看如何使用 Java 恢复乱码。关于使用 Java 我们还有很多知识没有介绍,但一些读者已经有很好的 Java 知识,所以本文一并列出相关代码,初学者不明白的我们随后会进一步讲解。

Java 中处理字符串的类有 String,String 中有我们需要的两个重要方法

  • public byte[] getBytes(String charsetName),这个方法可以获取一个字符串的给定编码格式的二进制形式
  • public String(byte bytes[], String charsetName),这个构造方法以给定的二进制数组 bytes 按照编码格式 charsetName 解读为一个字符串。

将 A 看做 GB18030,B 看做 Windows-1252,进行恢复的 Java 代码如下所示:

String str = "ÀÏÂí";
String newStr = new String(str.getBytes("windows-1252"),"GB18030");
System.out.println(newStr);

先按照 B 编码(windows-1252)获取字符串的二进制,然后按 A 编码(GB18030)解读这个二进制,得到一个新的字符串,然后输出这个字符串的形式,输出为 “老马”。

同样,这个一次碰巧就对了,实际中,我们可以写一个循环,测试不同的 A/B 编码中的结果形式,代码如下所示:

public static void recover(String str) 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) {
    
    
                String s = new String(str.getBytes(charsets[i]), charsets[j]);
                System.out.println("---- 原来编码(A)假设是: " + charsets[j] + ", 被错误解读为了(B): " + charsets[i]);
                System.out.println(s);
                System.out.println();
            }
        }
    }
}

以上代码使用不同的编码格式进行测试,如果输出有正确的,那么就可以恢复。

恢复的讨论

可以看出,这种尝试需要进行很多次,上面例子尝试了常见编码 GB18030/Windows 1252/Big5/UTF-8 共十二种组合。这四种编码是常见编码,在大部分实际应用中应该够了,但如果你的情况有其他编码,可以增加一些尝试。

不是所有的乱码形式都是可以恢复的,如果形式中有很多不能识别的字符如 �?,则很难恢复,另外,如果乱码是由于进行了多次解析和转换错误造成的,也很难恢复

2.4 char 的真正含义

通过前面小节,我们应该对字符和文本的编码和乱码有了一个清晰的认识,但前两节都是与编程语言无关的,我们还是不知道怎么在程序中处理字符和文本。

本节讨论在 Java 中进行字符处理的基础 char,Java 中还有 Character, String, StringBuffer, StringBuilder 等类进行文本处理,他们的基础都是 char,我们在后续文章中介绍这些类。

char 看上去是很简单的,正如我们在 1.2 节所说,char 用于表示一个字符,这个字符可以是中文字符,也可以是英文字符。赋值时把常量字符用单引号括起来,例如:

char c = 'A';
char z = '中';

但为什么字符类型也可以进行算术运算和比较它的本质到底是什么呢

2.4.1 char 的本质

在 Java 内部进行字符处理时,采用的都是 Unicode,具体编码格式是 UTF-16BE。简单回顾一下,UTF-16 使用两个或四个字节表示一个字符,Unicode 编号范围在 65536 以内的占两个字节,超出范围的占四个字节,BE (Big Endian)就是先输出高位字节,再输出低位字节,这与整数的内存表示是一致的。

char 本质上是一个固定占用两个字节的无符号正整数,这个正整数对应于 Unicode 编号,用于表示那个 Unicode 编号对应的字符

由于固定占用两个字节,char 只能表示 Unicode 编号在 65536 以内的字符,而不能表示超出范围的字符。

那超出范围的字符怎么表示呢?使用两个 char。类 String 有一些相关的方法,后续文章介绍。

在这个认识的基础上,我们再来看下 char 的一些行为,就比较容易理解了。

2.4.2 char 的赋值

char 有多种赋值方式:

char c = 'A'
char c = '马'
char c = 39532
char c = 0x9a6c
char c = '\u9a6c'

第 1 种赋值方式是最常见的,将一个能用 ASCII 码表示的字符赋给一个字符变量。

第 2 种也很常见,但这里是个中文字符,需要注意的是,直接写字符常量的时候应该注意文件的编码,比如说,GBK 编码的代码文件按 UTF-8 打开,字符会变成乱码,赋值的时候是按当前的编码解读方式,将这个字符形式对应的 Unicode 编号值赋给变量,'马’对应的 Unicode 编号是 39532,所以第 2 种赋值和第 3 种是一样的。

第 3 种是直接将十进制的常量赋给字符,第 4 种是将十六进制常量赋给字符,第 5 种是按 Unicode 字符形式。

以上,2,3,4,5 都是一样的,本质都是将 Unicode 编号 39532 赋给了字符。

2.4.3 char 的运算

由于 char 本质上是一个整数,所以可以进行整数可以进行的一些运算,在进行运算时会被看做 int。但由于 char 占两个字节,运算结果不能直接赋值给 char 类型,需要进行强制类型转换,这和 byte, short 参与整数运算是类似的。

char 类型的比较就是其 Unicode 编号的比较。

char 的加减运算就是按其 Unicode 编号进行运算,一般对字符做加减运算没什么意义,但 ASCII 码字符是有意义的。比如大小写转换,大写 A-Z 的编号是 65-90,小写 a-z 的编号是 97-122,正好相差 32,所以大写转小写只需加 32,而小写转大写只需减 32。加减运算的另一个应用是加密和解密,将字符进行某种可逆的数学运算可以做加解密。

char 的位运算可以看做就是对应整数的位运算,只是它是无符号数,也就是说,有符号右移 >> 和无符号右移 >>> 的结果是一样的。

2.4.4 char 的二进制

既然 char 本质上是整数,查看 char 的二进制表示,同样可以用 Integer 的方法,如下所示:

char c = '马';
System.out.println(Integer.toBinaryString(c));

输出为

1001101001101100

猜你喜欢

转载自blog.csdn.net/bm1998/article/details/107703247