2.4 char的真正含义

通过前面小节,我们应该对字符和文本的编码和乱码有了一个清晰的认识,但前面小节基本是与编程语言无关的,我们还是不知道怎么在程序中处理字符和文本。本节讨论在Java中进行字符处理的基础char, Java中还有Character、String、StringBuilder等类用于文本处理,它们的基础都是char,我们在第7章再介绍这些类。

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

1
2
char c = 'A';
char z = '马';

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

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

char本质上是一个固定占用两个字节的无符号正整数,这个正整数对应于Unicode编号,用于表示那个Unicode编号对应的字符。由于固定占用两个字节,char只能表示Unicode编号在65 536以内的字符,而不能表示超出范围的字符。那超出范围的字符怎么表示呢?使用两个char。类Character、String有一些相关的方法,我们到第7章再介绍。

在这个认识的基础上,我们再来看下char的一些行为。

char有多种赋值方式:

1
2
3
4
5
1. char c = 'A'
2. char c = '马'
3. char c = 39532
4. char c = 0x9a6c
5. char c = '\u9a6c'

第1种赋值方式是最常见的,将一个能用ASCII码表示的字符赋给一个字符变量。第2种赋值方式也很常见,但这里是个中文字符,需要注意的是,直接写字符常量的时候应该注意文件的编码,比如,GBK编码的代码文件按UTF-8打开,字符会变成乱码,赋值的时候是按当前的编码解读方式,将这个字符形式对应的Unicode编号值赋给变量,“马”对应的Unicode编号是39 532,所以第2种赋值方式和第3种赋值方式是一样的。第3种赋值方式是直接将十进制的常量赋给字符。第4种赋值方式是将十六进制常量赋给字符,第5种赋值方式是按Unicode字符形式。所以,第2、3、4、5种赋值方式都是一样的,本质都是将Unicode编号39 532赋给了字符。

由于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的方法,如下所示:

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

输出为:

1
1001101001101100

至此,关于整数、小数以及字符的二进制表示就介绍完了,下一章让我们一起来探索类的世界。

2.3 字符的编码与乱码

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

编码和乱码听起来比较复杂,但其实并不复杂,请耐心阅读,让我们逐步来探讨。我们先介绍各种编码,然后介绍编码转换,分析乱码出现的原因,最后介绍如何从乱码中恢复。编码有两大类:一类是非Unicode编码;另一类是Unicode编码。我们先介绍非Unicode编码。

2.3.1 常见非Unicode编码

下面我们看一些主要的非Unicode编码,包括ASCII、ISO 8859-1、Windows-1252、GB2312、GBK、GB18030和Big5。

1. ASCII

世界上虽然有各种各样的字符,但计算机发明之初没有考虑那么多,基本上只考虑了美国的需求。美国大概只需要128个字符,所以就规定了128个字符的二进制表示方法。这个方法是一个标准,称为ASCII编码,全称是American StandardCode for InformationInterchange,即美国信息互换标准代码。

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

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

epub_923038_22

图2-1 ASCII编码:可打印字符

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

表2-4 ASCII编码:常用不可打印字符

epub_923038_23
ASCII码对美国是够用了,但对其他国家而言却是不够的,于是,各个国家的各种计算机厂商就发明了各种各种的编码方式以表示自己国家的字符,为了保持与ASCII码的兼容性,一般都是将最高位设置为1。也就是说,当最高位为0时,表示ASCII码,当为1时就是各个国家自己的字符。在这些扩展的编码中,在西欧国家中流行的是ISO 8859-1和Windows-1252,在中国是GB2312、GBK、GB18030和Big5,我们逐个介绍这些编码。

2. ISO 8859-1

ISO 8859-1又称Latin-1,它也是使用一个字节表示一个字符,其中0~127与ASCII一样,128~255规定了不同的含义。在128~255中,128~159表示一些控制字符,这些字符也不常用,就不介绍了。160~255表示一些西欧字符,如图2-2所示。

epub_923038_24

图2-2 ISO 8859-1

3. Windows-1252

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

图2-3 Windows-1252编码:区别于ISO8859-1的部分

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

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

4. GB2312

美国和西欧字符用一个字节就够了,但中文显然是不够的。中文第一个标准是GB2312。GB2312标准主要针对的是简体中文常见字符,包括约7000个汉字和一些罕用词和繁体字。

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

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

表2-5 GB2312编码示例

epub_923038_26

5. GBK

GBK建立在GB2312的基础上,向下兼容GB2312,也就是说,GB2312编码的字符和二进制表示,在GBK编码里是完全一样的。GBK增加了14 000多个汉字,共计约21 000个汉字,其中包括繁体字。

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

需要注意的是,低位字节是从0x40(也就是64)开始的,也就是说,低位字节的最高位可能为0。那怎么知道它是汉字的一部分,还是一个ASCII字符呢?其实很简单,因为汉字是用固定两个字节表示的,在解析二进制流的时候,如果第一个字节的最高位为1,那么就将下一个字节读进来一起解析为一个汉字,而不用考虑它的最高位,解析完后,跳到第三个字节继续解析。

6. GB18030

GB18030向下兼容GBK,增加了55 000多个字符,共76 000多个字符,包括了很多少数民族字符,以及中日韩统一字符。

用两个字节已经表示不了GB18030中的所有字符,GB18030使用变长编码,有的字符是两个字节,有的是四个字节。在两字节编码中,字节表示范围与GBK一样。在四字节编码中,第一个字节的值为0x81~0xFE,第二个字节的值为0x30~0x39,第三个字节的值为0x81~0xFE,第四个字节的值为0x30~0x39。

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

7. Big5

Big5是针对繁体中文的,广泛用于我国台湾地区和我国香港特别行政区等地。Big5包括13 000多个繁体字,和GB2312类似,一个字符同样固定使用两个字节表示。在这两个字节中,高位字节范围是0x81~0xFE,低位字节范围是0x40~0x7E和0xA1~0xFE。

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之间,即65 536个数字之内。每个字符都有一个Unicode编号,这个编号一般写成十六进制,在前面加U+。大部分中文的编号范围为U+4E00~U+9FFF,例如,“马”的Unicode是U+9A6C。

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

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

1. UTF-32

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

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

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

2. UTF-16

UTF-16使用变长字节表示:

1)对于编号在U+0000~U+FFFF的字符(常用字符集),直接用两个字节表示。需要说明的是,U+D800~U+DBFF的编号其实是没有定义的。

2)字符值在U+10000~U+10FFFF的字符(也叫做增补字符集),需要用4个字节表示。前两个字节叫高代理项,范围是U+D800~U+DBFF;后两个字节叫低代理项,范围是U+DC00~U+DFFF。数字编号和这个二进制表示之间有一个转换算法,本书就不介绍了。

区分是两个字节还是4个字节表示一个字符就看前两个字节的编号范围,如果是U+D800~U+DBFF,就是4个字节,否则就是两个字节。

UTF-16也有和UTF-32一样的字节序问题,如果高位存放在前面就叫大端(BE),编码就叫UTF-16BE,否则就叫小端,编码就叫UTF-16LE。

UTF-16常用于系统内部编码,UTF-16比UTF-32节省了很多空间,但是任何一个字符都至少需要两个字节表示,对于美国和西欧国家而言,还是很浪费的。

3. UTF-8

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

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

表2-6 UTF-8编码的编号范围与对应的二进制格式

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

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

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

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

1
1110xxxx 10xxxxxx 10xxxxxx

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

1
1001 101001 101100

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

1
11101001 10101001 10101100

十六进制表示为0xE9A9AC。

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

4. Unicode编码小结

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

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

2.3.3 编码转换

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

表2-7 字符“马”多种编码方式

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

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

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

编码转换改变了字符的二进制内容,但并没有改变字符看上去的样子。

2.3.4 乱码的原因

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

1.解析错误

看个简单的例子。一个法国人采用Windows-1252编码写了个文件,发送给了一个中国人,中国人使用GB18030来解析这个字符,看到的可能就是乱码。比如,法国人发送的是Pékin, Windows-1252的二进制(采用十六进制)是50 E9 6B 696E,第二个字节E9对应é,其他都是ASCII码,中国人收到的也是这个二进制,但是他把它看成了GB18030编码,GB18030中E9 6B对应的是字符“閗”,于是他看到的就是“P閗in”,这看来就是一个乱码。

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

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

切换查看编码的方式并没有改变数据的二进制本身,而只是改变了解析数据的方式,从而改变了数据看起来的样子,这与前面提到的编码转换正好相反。很多时候,做这样一个编码查看方式的切换就可以解决乱码的问题,但有的时候这样是不够的。

2.错误的解析和编码转换

如果怎么改变查看方式都不对,那很有可能就不仅仅是解析二进制的方式不对,而是文本在错误解析的基础上还进行了编码转换。我们举个例子来说明:

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

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

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

表2-8 用不同编码方式查看错误转换后的二进制

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

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

2.3.5 从乱码中恢复

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

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

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

1.使用UltraEdit

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

新建一个UTF-8编码的文件,复制“ÀÏÂí”到文件中。使用编码转换,转换到Win-dows-1252编码,执行“文件”→“转换到”→“西欧”→WIN-1252命令。

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

epub_923038_30

图2-4 使用UltraEdit查看二进制

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

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

表2-9 尝试不同编码方式进行恢复

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

2.使用Java

下面我们来看如何使用Java恢复乱码。关于使用Java我们还有很多知识没有介绍,为了完整性起见,本节一并列出相关代码,初学者不明白的可以暂时略过。Java中处理字符串的类有String, String中有我们需要的两个重要方法。
1)public byte[] getBytes(String charsetName),这个方法可以获取一个字符串的给定编码格式的二进制形式。
2)public String(byte bytes[], String charsetName),这个构造方法以给定的二进制数组bytes按照编码格式charsetName解读为一个字符串。

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

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

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

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

代码清单2-1 恢复乱码的方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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共12种组合。这4种编码是常见编码,在大部分实际应用中应该够了。如果有其他编码,可以增加一些尝试。

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

2.2 小数的二进制表示

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

1
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-3 2的次方

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

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

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

1
2
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被称为浮点数据类型,小数运算被称为浮点运算。

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

我们还是用十进制类比,十进制有科学记数法,比如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中,可以使用如下代码:

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

3.2 类的组合

程序是用来解决现实问题的,将现实中的概念映射为程序中的概念,是初学编程过程中的一步跨越。本节通过一些例子来演示如何将一些现实概念和问题通过类以及类的组合来表示和处理,涉及的概念包括图形处理、电商、人之间的血缘关系以及计算机中的文件和目录。

我们先介绍两个基础类String和Date,它们都是Java API中的类,分别表示文本字符串和日期。

3.2.1 String和Date

String是Java API中的一个类,表示多个字符,即一段文本或字符串,它内部是一个char的数组,提供了若干方法用于操作字符串。

String可以用一个字符串常量初始化,字符串常量用双引号括起来(注意与字符常量区别,字符常量是用单引号)。例如,如下语句声明了一个String变量name,并赋值为“老马说编程”。

1
String name = "老马说编程";

String类提供了很多方法,用于操作字符串。在Java中,由于String用得非常普遍, Java对它有一些特殊的处理,本节暂不介绍这些内容,只是把它当作一个表示字符串的类型来看待。

Date也是Java API中的一个类,表示日期和时间,它内部是一个long类型的值,也提供了若干方法用于操作日期和时间。

用无参的构造方法新建一个Date对象,这个对象就表示当前时间。

1
Date now = new Date();

日期和时间处理是一个比较大的话题,我们留待第7章详解,本节我们只是把它当作表示日期和时间的类型来看待。

3.2.2 图形类

我们先扩展一下Point类,在其中增加一个方法,计算到另一个点的距离,代码如下:

1
2
3
public double distance(Point p){
return Math.sqrt(Math.pow(x-p.getX(), 2)+Math.pow(y-p.getY(), 2));
}

在类Point中,属性x、y都是基本类型,但类的属性也可以是类。我们考虑一个表示线的类,它由两个点组成,有一个实例方法计算线的长度,如代码清单3-2所示。

代码清单3-2 表示线的类Line
1
2
3
4
5
6
7
8
9
10
11
public class Line {
private Point start;
private Point end;
public Line(Point start, Point end){
this.start= start;
this.end = end;
}
public double length(){
return start.distance(end);
}
}

Line由两个Point组成,在创建Line时这两个Point是必需的,所以只有一个构造方法,且需传递这两个点,length方法计算线的长度,它调用了Point计算距离的方法获取线的长度。可以看出,在设计线时,我们考虑的层次是点,而不考虑点的内部细节。每个类封装其内部细节,对外提供高层次的功能,使其他类在更高层次上考虑和解决问题,是程序设计的一种基本思维方式

使用这个类的代码如下所示:

1
2
3
4
5
6
public static void main(String[] args) {
Point start = new Point(2,3);
Point end = new Point(3,4);
Line line = new Line(start, end);
System.out.println(line.length());
}

这也很简单。我们再说明一下内存布局,line的两个实例成员都是引用类型,引用实际的point,整体内存布局如图3-1所示。

epub_923038_36

图3-1 图形类Point和Line对象的内存布局

start、end、line三个引用型变量分配在栈中,保存的是实际内容的地址,实际内容保存在堆中,line的两个实例变量line.start和line.end还是引用,同样保存的是实际内容的地址。

3.2.3 用类描述电商概念

接下来,我们用类来描述一下电商系统中的一些基本概念,电商系统中最基本的有产品、用户和订单。

1)产品:有产品唯一id、名称、描述、图片、价格等属性。
2)用户:有用户名、密码等属性。
3)订单:有订单号、下单用户、选购产品列表及数量、下单时间、收货人、收货地址、联系电话、订单状态等属性。

当然,实际情况可能非常复杂,这是一个非常简化的描述。

产品类Product如代码清单3-3所示。

代码清单3-3 表示产品的类Product
1
2
3
4
5
6
7
8
9
10
11
12
public class Product {
//唯一id
private String id;
//产品名称
private String name;
//产品图片链接
private String pictureUrl;
//产品描述
private String description;
//产品价格
private double price;
}

我们省略了类的构造方法,以及属性的getter/setter方法,下面大部分示例代码也都会省略。

这是用户类User的代码:

1
2
3
4
public class User {
private String name;
private String password;
}

一个订单可能会有多个产品,每个产品可能有不同的数量,我们用订单条目OrderItem这个类来描述单个产品及选购的数量,如代码清单3-4所示。

代码清单3-4 表示订单条目的类OrderItem
1
2
3
4
5
6
7
8
9
10
11
12
13
public class OrderItem {
//购买产品
private Product product;
//购买数量
private int quantity;
public OrderItem(Product product, int quantity) {
this.product = product;
this.quantity = quantity;
}
public double computePrice(){
return product.getPrice()*quantity;
}
}

OrderItem引用了产品类Product,我们定义了一个构造方法,以及计算该订单条目价格的方法。

订单类Order如代码清单3-5所示。

代码清单3-5 表示订单的类Order
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Order {
//订单号
private String id;
//购买用户
private User user;
//购买产品列表及数量
private OrderItem[] items;
//下单时间
private Date createtime;
//收货人
private String receiver;
//收货地址
private String address;
//联系电话
private String phone;
//订单状态
private String status;
public double computeTotalPrice(){
double totalPrice = 0;
if(items! =null){
for(OrderItem item : items){
totalPrice+=item.computePrice();
}
}
return totalPrice;
}
}

Order类引用了用户类User,以及一个订单条目的数组OrderItem,它定义了一个计算总价的方法。这里用一个String类表示状态status,更合适的应该是枚举类型,枚举我们第5章再介绍。

以上类定义是非常简化的,但是大致演示了将现实概念映射为类以及类组合的过程,这个过程大概就是,想想现实问题有哪些概念,这些概念有哪些属性、哪些行为,概念之间有什么关系,然后定义类、定义属性、定义方法、定义类之间的关系。概念的属性和行为可能是非常多的,但定义的类只需要包括那些与现实问题相关的就行了

3.2.4 用类描述人之间的血缘关系

上面介绍的图形类和电商类只会引用别的类,但一个类定义中还可以引用它自己,比如我们要描述人以及人之间的血缘关系。我们用类Person表示一个人,它的实例成员包括其父亲、母亲、和孩子,这些成员也都是Person类型,如代码清单3-6所示。

代码清单3-6 表示人的类Person
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Person {
//姓名
private String name;
//父亲
private Person father;
//母亲
private Person mother;
//孩子数组
private Person[] children;
public Person(String name) {
this.name = name;
}
}

这里同样省略了setter/getter方法。对初学者,初看起来这是比较难以理解的,有点类似于函数调用中的递归调用,这里面的关键点是,实例变量不需要一开始就有值。我们来看下如何使用:

1
2
3
4
5
6
7
public static void main(String[] args){
Person laoma = new Person("老马");
Person xiaoma = new Person("小马");
xiaoma.setFather(laoma);
laoma.setChildren(new Person[]{xiaoma});
System.out.println(xiaoma.getFather().getName());
}

这段代码先创建了老马(laoma),然后创建了小马(xiaoma),接着调用xiaoma的set-Father方法和laoma的setChildren方法设置了父子关系,Person类对象的内存布局如图3-2所示。

epub_923038_37

图3-2 Person类对象的内存布局

3.2.5 目录和文件

接下来,我们介绍两个类MyFile和MyFolder,分别表示文件管理中的两个概念:文件和文件夹。文件和文件夹都有名称、创建时间、父文件夹,根文件夹没有父文件夹,文件夹还有子文件列表和子文件夹列表。文件类MyFile如代码清单3-7所示。代码清单3-7 文件类MyFile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MyFile {
//文件名称
private String name;
//创建时间
private Date createtime;
//文件大小
private int size;
//上级目录
private MyFolder parent;
//其他方法……
public int getSize() {
return size;
}
}

文件夹类MyFolder如代码清单3-8所示。

代码清单3-8 文件夹类MyFolder
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class MyFolder {
//文件夹名称
private String name;
//创建时间
private Date createtime;
//上级文件夹
private MyFolder parent;
//包含的文件
private MyFile[] files;
//包含的子文件夹
private MyFolder[] subFolders;
public int totalSize(){
int totalSize = 0;
if(files! =null){
for(MyFile file : files){
totalSize+=file.getSize();
}
}
if(subFolders! =null){
for(MyFolder folder : subFolders){
totalSize+=folder.totalSize();
}
}
return totalSize;
}
//其他方法……
}

MyFile和MyFolder都省略了构造方法、settter/getter方法,以及关于父子关系维护的代码,主要演示实例变量间的组合关系。两个类之间可以互相引用,MyFile引用了MyFolder,而MyFolder也引用了MyFile,这是没有问题的。因为正如之前所说,这些属性不需要一开始就设置,也不是必须设置的。另外,演示了一个递归方法totalSize(),返回当前文件夹下所有文件的大小,这是使用递归函数的一个很好的场景。

3.2.6 一些说明

类中应该定义哪些变量和方法,这是与要解决的问题密切相关的,本节中并没有特别强调问题是什么,定义的属性和方法主要用于演示基本概念,实际应用中应该根据具体问题进行调整。

类中实例变量的类型可以是当前定义的类型,两个类之间可以互相引用,这些初听起来可能难以理解,但现实世界就是这样的,创建对象的时候这些值不需要一开始就有,也可以没有,所以是没有问题的。

类之间的组合关系在Java中实现的都是引用,但在逻辑关系上,有两种明显不同的关系,一种是包含,另一种是单纯引用。比如,在订单类Order中,Order与User的关系就是单纯引用,User是独立存在的;而Order与OrderItem的关系就是包含,OrderItem总是从属于某一个Order。

3.2.7 小结

对初学编程的人来说,不清楚如何用程序概念表示现实问题,本节通过一些简化的例子来解释如何将现实中的概念映射为程序中的类。

分解现实问题中涉及的概念以及概念间的关系,将概念表示为多个类,通过类之间的组合来表达更为复杂的概念以及概念间的关系,是计算机程序的一种基本思维方式。

正所谓,道生一,一生二,二生三,三生万物,如果将二进制表示和运算看作一,将基本数据类型看作二,基本数据类型形成的类看作三,那么,类的组合以及下章介绍的继承则使得三生万物。

第2章 理解数据背后的二进制

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

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

要理解这些行为,我们需要理解数值和文本字符在计算机内部的二进制表示,本章就来介绍各种数据背后的二进制,具体分为4节:2.1节介绍整数;2.2节介绍小数;2.3节介绍与语言无关的字符和文本的编码以及乱码;2.4节介绍Java中表示字符的基本类型char。

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

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

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

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

2.1.1 正整数的二进制表示

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

表2-1 二进制与对应的十进制

epub_923038_19

2.1.2 负整数的二进制表示

十进制的负数表示就是在前面加一个负数符号-,例如-123。但二进制如何表示负数呢?其实概念是类似的,二进制使用最高位表示符号位,用1表示负数,用0表示正数。但哪个是最高位呢?整数有4种类型byte、short、int、long,分别占1、2、4、8个字节,即分别占8、16、32、64位,每种类型的符号位都是其最左边的一位。为方便举例,下面假定类型是byte,即从右到左的第8位表示符号位。

但负数表示不是简单地将最高位变为1,比如:
1)byte a=-1,如果只是将最高位变为1,二进制应该是10000001,但实际上,它应该是11111111。
2)byte a=-127,如果只是将最高位变为1,二进制应该是11111111,但实际上,它却应该是10000001。

和我们的直觉正好相反,这是什么表示法?这种表示法称为补码表示法,而符合我们直觉的表示称为原码表示法,补码表示就是在原码表示的基础上取反然后加1。取反就是将0变为1,1变为0。负数的二进制表示就是对应的正数的补码表示,比如:
1)-1:1的原码表示是00000001,取反是11111110,然后再加1,就是11111111。
2)-2:2的原码表示是00000010,取反是11111101,然后再加1,就是11111110。
3)-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
2
3
4
1   -> 00000001
-1 -> 10000001
+ ------------------
-2 -> 10000010

用符合直觉的原码表示,1-1的结果是-2,如果是补码表示:

1
2
3
4
1   -> 00000001
-1 -> 11111111
+ ------------------
0 -> 00000000

结果是正确的。再如,5-3:

1
2
3
4
5   -> 00000101
-3 -> 11111101
+ ------------------
2 -> 00000010

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

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

1
2
3
4
127    -> 01111111
1 -> 00000001
+ ------------------
-128 -> 10000000

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

2.1.3 十六进制

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

表2-2 十六进制

epub_923038_20
可以用十六进制直接写常量数字,在数字前面加0x即可。比如十进制的123,用十六进制表示是0x7B,即123=7×16+11。给整数赋值或者进行运算的时候,都可以直接使用十六进制,比如:

1
int a = 0x7B;

Java 7之前不支持直接写二进制常量。比如,想写二进制形式的11001, Java 7之前不能直接写,可以在前面补0,补足8位,为00011001,然后用十六进制表示,即0x19。Java 7开始支持二进制常量,在前面加0b或0B即可,比如:

1
int a = 0b11001;

在Java中,可以方便地使用Integer和Long的方法查看整数的二进制和十六进制表示,例如:

1
2
3
4
5
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.4 位运算

理解了二进制表示,我们来看二进制级别的操作:位运算。Java 7之前不能单独表示一个位,但可以用byte表示8位,用十六进制写二进制常量。比如,0010表示成十六进制是0x2,110110表示成十六进制是0x36。

位运算有移位运算和逻辑运算。移位有以下几种。
1)左移:操作符为<<,向左移动,右边的低位补0,高位的就舍弃掉了,将二进制看作整数,左移1位就相当于乘以2。
2)无符号右移:操作符为>>>,向右移动,右边的舍弃掉,左边补0。
3)有符号右移:操作符为>>,向右移动,右边的舍弃掉,左边补什么取决于原来最高位是什么,原来是1就补1,原来是0就补0,将二进制看作整数,右移1位相当于除以2。

例如:

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

逻辑运算有以下几种。

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

大部分都比较简单,如下所示,具体就不赘述了。

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

3.3 代码的组织机制

使用任何语言进行编程都有一个类似的问题,那就是如何组织代码。具体来说,如何避免命名冲突?如何合理组织各种源文件?如何使用第三方库?各种代码和依赖库如何编译链接为一个完整的程序?本节就来讨论Java中的解决机制,具体包括包、jar包、程序的编译与链接等。

3.3.1 包的概念

使用任何语言进行编程都有一个相同的问题,就是命名冲突。程序一般不全是一个人写的,会调用系统提供的代码、第三方库中的代码、项目中其他人写的代码等,不同的人就不同的目的可能定义同样的类名/接口名,Java中解决这个问题的主要方法就是

即使代码都是一个人写的,将多个关系不太大的类和接口都放在一起,也不便于理解和维护,Java中组织类和接口的方式也是包。

包是一个比较容易理解的概念,类似于计算机中的文件夹,正如我们在计算机中管理文件,文件放在文件夹中一样,类和接口放在包中,为便于组织,文件夹一般是一个层次结构,包也类似。

包有包名,这个名称以点号(.)分隔表示层次结构。比如,我们之前常用的String类就位于包java.lang下,其中java是上层包名,lang是下层包名。带完整包名的类名称为其完全限定名,比如String类的完全限定名为java.lang.String。Java API中所有的类和接口都位于包Java或javax下,Java是标准包,javax是扩展包。

接下来,我们讨论包的细节,包括包的声明、使用和包范围可见性。

1.声明类所在的包

我们之前定义类的时候没有定义其所在的包,默认情况下,类位于默认包下,使用默认包是不建议的,我们使用默认包只是简单起见。

定义类的时候,应该先使用关键字package声明其包名,如下所示:

1
2
3
4
package shuo.laoma;
public class Hello {
//类的定义
}

以上声明类Hello的包名为shuo.laoma,包声明语句应该位于源代码的最前面,前面不能有注释外的其他语句。

包名和文件目录结构必须匹配,如果源文件的根目录为E:\src\,则上面的Hello类对应的文件Hello.java,其全路径就应该是E:\src\shuo\laoma\Hello.java。如果不匹配,Java会提示编译错误。

为避免命名冲突,Java中命名包名的一个惯例是使用域名作为前缀,因为域名是唯一的,一般按照域名的反序来定义包名,比如,域名是apache.org,包名就以org.apache开头。

没有域名的也没关系,使用一个其他代码不太会用的包名即可,比如本节使用的shuo. laoma。如果代码需要公开给其他人用,最好有一个域名以确保唯一性,如果只是内部使用,则确保内部没有其他代码使用该包名即可。

除了避免命名冲突,包也是一种方便组织代码的机制。一般而言,同一个项目下的所有代码都有一个相同的包前缀,这个前缀是唯一的,不会与其他代码重名,在项目内部,根据不同目的再细分为子包,子包可能又会分为下一级子包,形成层次结构,内部实现一般位于比较底层的包。

包可以方便模块化开发,不同功能可以位于不同包内,不同开发人员负责不同的包。包也可以方便封装,供外部使用的类可以放在包的上层,而内部的实现细节则可以放在比较底层的子包内。

2.通过包使用类

同一个包下的类之间互相引用是不需要包名的,可以直接使用。但如果类不在同一个包内,则必须要知道其所在的包。使用有两种方式:一种是通过类的完全限定名;另外一种是将用到的类引入当前类。只有一个例外,java.lang包下的类可以直接使用,不需要引入,也不需要使用完全限定名,比如String类、System类,其他包内的类则不行。

看个例子,使用Arrays类中的sort方法,通过完全限定名可以这样使用:

1
2
3
int[] arr = new int[]{1,4,2,3};
java.util.Arrays.sort(arr);
System.out.println(java.util.Arrays.toString(arr));

显然,这样比较烦琐,另外一种就是将该类引入当前类。引入的关键字是import,import需要放在package定义之后,类定义之前,如下所示:

1
2
3
4
5
6
7
8
9
package shuo.laoma;
import java.util.Arrays;
public class Hello {
public static void main(String[] args) {
int[] arr = new int[]{1,4,2,3};
Arrays.sort(arr);
System.out.println(Arrays.toString(arr));
}
}

做import操作时,可以一次将某个包下的所有类引入,语法是使用.*,比如,将java.util包下的所有类引入,语法是:import java.util.*。需要注意的是,这个引入不能递归,它只会引入java.util包下的直接类,而不会引入java.util下嵌套包内的类,比如,不会引入包java.util.zip下面的类。试图嵌套引入的形式也是无效的,如import java.util.*.*

在一个类内,对其他类的引用必须是唯一确定的,不能有重名的类,如果有,则通过import只能引入其中的一个类,其他同名的类则必须要使用完全限定名。

引入类是一个比较烦琐的工作,不过,大多数Java开发环境都提供工具自动做这件事。比如,在Eclipse中,通过执行Source→Organize Imports命令或按对应的快捷键Ctrl+Shift+O就可以自动管理引用的类。

有一种特殊类型的导入,称为静态导入,它有一个static关键字,可以直接导入类的公开静态方法和成员。看个例子:

1
2
3
4
5
6
7
8
9
10
import java.util.Arrays;
import static java.util.Arrays.*; //静态导入Arrays中的所有静态方法
import static java.lang.System.out; //导入静态变量out
public class Hello {
public static void main(String[] args) {
int[] arr = new int[]{1,4,2,3};
sort(arr); //可以直接使用Arrays中的sort方法
out.println(Arrays.toString(arr)); //可以直接使用out变量
}
}

静态导入不应过度使用,否则难以区分访问的是哪个类的代码。

3.包范围可见性

前面章节我们介绍过,对于类、变量和方法,都可以有一个可见性修饰符public/private,我们还提到,可以不写修饰符。如果什么修饰符都不写,它的可见性范围就是同一个包内,同一个包内的其他类可以访问,而其他包内的类则不可以访问。

需要说明的是,同一个包指的是同一个直接包,子包下的类并不能访问。比如,类shuo.laoma.Hello和shuo.laoma.inner.Test,其所在的包shuo.laoma和shuo.laoma.inner是两个完全独立的包,并没有逻辑上的联系,Hello类和Test类不能互相访问对方的包可见性方法和属性。

除了public和private修饰符,还有一个与继承有关的修饰符protected。关于protected的细节我们下章介绍,这里需要说明的是,protected可见性包括包可见性,也就是说,声明为protected不仅表明子类可以访问,还表明同一个包内的其他类可以访问,即使这些类不是子类也可以。

总结来说,可见性范围从小到大是:private < 默认(包) < protected < public。

3.3.2 jar包

为方便使用第三方代码,也为了方便我们写的代码给其他人使用,各种程序语言大多有打包的概念,打包的一般不是源代码,而是编译后的代码。打包将多个编译后的文件打包为一个文件,方便其他程序调用。

在Java中,编译后的一个或多个包的Java class文件可以打包为一个文件,Java中打包命令为jar,打包后的文件扩展名为.jar,一般称之为jar包。

可以使用如下方式打包,首先到编译后的java class文件根目录,然后运行如下命令:

1
jar -cvf <包名>.jar <最上层包名>

比如,对前面介绍的类打包,如果Hello.class位于E:\bin\shuo\laoma\Hello.class,则可以到目录 E:\bin下,然后运行:

1
jar -cvf hello.jar shuo

hello.jar就是jar包,jar包其实就是一个压缩文件,可以使用解压缩工具打开。

Java类库、第三方类库都是以jar包形式提供的。如何使用jar包呢?将其加入类路径(classpath)中即可。类路径是什么呢?我们下面来看。

3.3.3 程序的编译与链接

从Java源代码到运行的程序,有编译和链接两个步骤。编译是将源代码文件变成扩展名是.class的一种字节码,这个工作一般是由javac命令完成的。链接是在运行时动态执行的,.class文件不能直接运行,运行的是Java虚拟机,虚拟机听起来比较抽象,执行的就是Java命令,这个命令解析.class文件,转换为机器能识别的二进制代码,然后运行。所谓链接就是根据引用到的类加载相应的字节码并执行。

Java编译和运行时,都需要以参数指定一个classpath,即类路径。类路径可以有多个,对于直接的class文件,路径是class文件的根目录;对于jar包,路径是jar包的完整名称(包括路径和jar包名)。在Windows系统中,多个路径用分号“; ”分隔;在其他系统中,以冒号“:”分隔。

在Java源代码编译时,Java编译器会确定引用的每个类的完全限定名,确定的方式是根据import语句和classpath。如果导入的是完全限定类名,则可以直接比较并确定。如果是模糊导入(import带.*),则根据classpath找对应父包,再在父包下寻找是否有对应的类。如果多个模糊导入的包下都有同样的类名,则Java会提示编译错误,此时应该明确指定导入哪个类。

Java运行时,会根据类的完全限定名寻找并加载类,寻找的方式就是在类路径中寻找,如果是class文件的根目录,则直接查看是否有对应的子目录及文件,如果是jar文件,则首先在内存中解压文件,然后再查看是否有对应的类。

总结来说,import是编译时概念,用于确定完全限定名,在运行时,只根据完全限定名寻找并加载类,编译和运行时都依赖类路径,类路径中的jar文件会被解压缩用于寻找和加载类。

3.3.4 小结

本节介绍了Java中代码组织的机制、包和jar包,以及程序的编译和链接。将类和接口放在合适的具有层次结构的包内,避免命名冲突,代码可以更为清晰,便于实现封装和模块化开发;通过jar包使用第三方代码,将自身代码打包为jar包供其他程序使用。这些都是解决复杂问题所必需的。

在Java 9中,清晰地引入了模块的概念,JDK和JRE都按模块化进行了重构,传统的组织机制依然是支持的,但新的应用可以使用模块。一个应用可由多个模块组成,一个模块可由多个包组成。模块之间可以有一定的依赖关系,一个模块可以导出包给其他模块用,可以提供服务给其他模块用,也可以使用其他模块提供的包,调用其他模块提供的服务。对于复杂的应用,模块化有很多好处,比如更强的封装、更为可靠的配置、更为松散的耦合、更动态灵活等。模块是一个很大的主题,限于篇幅,我们就不详细介绍了。

至此,关于类的基础知识就介绍完了。类之间除了组合关系,还有一种非常重要的关系,那就是继承,我们下章来探讨。

第4章 类的继承

上一章,我们谈到了如何将现实中的概念映射为程序中的概念,我们谈了类以及类之间的组合,现实中的概念间还有一种非常重要的关系,就是分类。分类有个根,然后向下不断细化,形成一个层次分类体系,这种例子是非常多的。

1)在自然世界中,生物有动物和植物,动物有不同的科目,食肉动物、食草动物、杂食动物等,食肉动物有狼、豹、虎等,这些又细分为不同的种类。
2)打开电商网站,在显著位置一般都有分类列表,比如家用电器、服装,服装有女装、男装,男装有衬衫、牛仔裤等。

计算机程序经常使用类之间的继承关系来表示对象之间的分类关系。在继承关系中,有父类子类,比如动物类Animal和狗类Dog, Animal是父类,Dog是子类。父类也叫基类,子类也叫派生类。父类、子类是相对的,一个类B可能是类A的子类,但又是类C的父类。

之所以叫继承,是因为子类继承了父类的属性和行为,父类有的属性和行为子类都有。但子类可以增加子类特有的属性和行为,某些父类有的行为,子类的实现方式可能与父类也不完全一样。

使用继承一方面可以复用代码,公共的属性和行为可以放到父类中,而子类只需要关注子类特有的就可以了;另一方面,不同子类的对象可以更为方便地被统一处理。

本章详细介绍继承。我们先介绍继承的基本概念,然后详述继承的一些细节,理解了继承的用法之后,我们探讨继承实现的基本原理,最后讨论继承的注意事项,解释为什么说继承是把双刃剑,以及如何正确地使用继承。

4.1 基本概念

本节介绍Java中继承的基本概念,在Java中,所有类都有一个父类Object,我们先来看这个类,然后主要通过图形处理中的一些简单例子来介绍继承的基本概念。

4.1.1 根父类Object

在Java中,即使没有声明父类,也有一个隐含的父类,这个父类叫Object。Object没有定义属性,但定义了一些方法,如图4-1所示。

epub_923038_39

图4-1 类Object中的方法

本节我们会介绍toString()方法,其他方法我们会在后续章节中逐步介绍。toString()方法的目的是返回一个对象的文本描述,这个方法可以直接被所有类使用。

比如,对于我们上一章介绍的Point类,可以这样使用toString方法:

1
2
Point p = new Point(2,3);
System.out.println(p.toString());

输出类似这样:

1
Point@76f9aa66

这是什么意思呢?@之前是类名,@之后的内容是什么呢?我们来看下toString()方法的代码:

1
2
3
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}

getClass().getName() 返回当前对象的类名,hashCode()返回一个对象的哈希值,哈希我们会在后续章节进一步介绍,这里可以理解为是一个整数,这个整数默认情况下,通常是对象的内存地址值,Integer.toHexString(hashCode())返回这个哈希值的十六进制表示。

为什么要这么写呢?写类名是可以理解的,表示对象的类型,而写哈希值则是不得已的,因为Object类并不知道具体对象的属性,不知道怎么用文本描述,但又需要区分不同对象,只能是写一个哈希值。

但子类是知道自己的属性的,子类可以重写父类的方法,以反映自己的不同实现。所谓重写,就是定义和父类一样的方法,并重新实现。

4.1.2 方法重写

上一章,我们介绍了一些图形处理类,其中有Point类,这次我们重写其toString()方法,如代码清单4-1所示。

代码清单4-1 Point类:重写toString()方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Point {
private int x;
private int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public double distance(Point point){
return Math.sqrt(Math.pow(this.x-point.getX(),2)
+Math.pow(this.y-point.getY(), 2));
}
public int getX() {
return x;
}
public int getY() {
return y;
}
@Override
public String toString() {
return "("+x+", "+y+")";
}
}

toString()方法前面有一个@Override,这表示toString()这个方法是重写的父类的方法,重写后的方法返回Point的x和y坐标的值。重写后,将调用子类的实现。比如,如下代码的输出就变成了(2,3)。

1
2
Point p = new Point(2,3);
System.out.println(p.toString());

4.1.3 图形类继承体系

接下来,我们以一些图形处理中的例子来进一步解释。先来看一些图形的例子,如图4-2所示。

这都是一些基本的图形,图形有线、正方形、三角形、圆形等,图形有不同的颜色。接下来,我们定义以下类来说明关于继承的一些概念:

  • 父类Shape,表示图形。
  • 类Circle,表示圆。
  • 类Line,表示直线。
  • 类ArrowLine,表示带箭头的直线。

1.图形

所有图形(Shape)都有一个表示颜色的属性,有一个表示绘制的方法,如代码清单4-2所示。

epub_923038_40

图4-2 一些图形的例子
代码清单4-2 类Shape
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Shape {
private static final String DEFAULT_COLOR = "black";
private String color;
public Shape() {
this(DEFAULT_COLOR);
}
public Shape(String color) {
this.color = color;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public void draw(){
System.out.println("draw shape");
}
}

以上代码非常简单,实例变量color表示颜色,draw方法表示绘制,我们没有写实际的绘制代码,主要是演示继承关系。

2.圆

圆(Circle)继承自Shape,但包括了额外的属性:中心点和半径,以及额外的方法area,用于计算面积,另外,重写了draw方法,如代码清单4-3所示。

圆(Circle)继承自Shape,但包括了额外的属性:中心点和半径,以及额外的方法area,用于计算面积,另外,重写了draw方法,如代码清单4-3所示。

代码清单4-3 类Circle
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Circle extends Shape {
//中心点
private Point center;
//半径
private double r;
public Circle(Point center, double r) {
this.center = center;
this.r = r;
}
@Override
public void draw() {
System.out.println("draw circle at " +center.toString()+" with r "+r
+", using color : "+getColor());
}
public double area(){
return Math.PI*r*r;
}
}

说明:

1)Java使用extends关键字表示继承关系,一个类最多只能有一个父类;
2)子类不能直接访问父类的私有属性和方法。比如,在Circle中,不能直接访问Shape的私有实例变量color;
3)除了私有的外,子类继承了父类的其他属性和方法。比如,在Circle的draw方法中,可以直接调用getColor()方法。

使用它的代码如下:

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
Point center = new Point(2,3);
//创建圆,赋值给circle
Circle circle = new Circle(center,2);
//调用draw方法,会执行Circle的draw方法
circle.draw();
//输出圆面积
System.out.println(circle.area());
}

程序的输出为:

1
2
draw circle at (2,3) with r 2.0, using color : black
12.566370614359172

这里比较奇怪的是,color是什么时候赋值的?在new的过程中,父类的构造方法也会执行,且会优先于子类执行。在这个例子中,父类Shape的默认构造方法会在子类Circle的构造方法之前执行。关于new过程的细节,我们会在4.3节进一步介绍。

3.直线

线(Line)继承自Shape,但有两个点,以及一个获取长度的方法,并重写了draw方法,如代码清单4-4所示。

代码清单4-4 类Line
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Line extends Shape {
private Point start;
private Point end;
public Line(Point start, Point end, String color) {
super(color);
this.start = start;
this.end = end;
}
public double length(){
return start.distance(end);
}
public Point getStart() {
return start;
}
public Point getEnd() {
return end;
}
@Override
public void draw() {
System.out.println("draw line from "
+ start.toString()+" to "+end.toString()
+ ", using color "+super.getColor());
}
}

这里我们要说明的是super这个关键字,super用于指代父类,可用于调用父类构造方法,访问父类方法和变量。

1)在Line构造方法中,super(color)表示调用父类的带color参数的构造方法。调用父类构造方法时,super必须放在第一行。
2)在draw方法中,super.getColor()表示调用父类的getColor方法,当然不写super.也是可以的,因为这个方法子类没有同名的,没有歧义,当有歧义的时候,通过super.可以明确表示调用父类的方法。
3)super同样可以引用父类非私有的变量。

4.带箭头直线

带箭头直线(ArrowLine)继承自Line,但多了两个属性,分别表示两端是否有箭头,也重写了draw方法,如代码清单4-5所示。

代码清单4-5 类ArrowLine
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ArrowLine extends Line {
private boolean startArrow;
private boolean endArrow;
public ArrowLine(Point start, Point end, String color,
boolean startArrow, boolean endArrow) {
super(start, end, color);
this.startArrow = startArrow;
this.endArrow = endArrow;
}
@Override
public void draw() {
super.draw();
if(startArrow){
System.out.println("draw start arrow");
}
if(endArrow){
System.out.println("draw end arrow");
}
}
}

ArrowLine继承自Line,而Line继承自Shape, ArrowLine的对象也有Shape的属性和方法。

注意draw()方法的第一行,super.draw()表示调用父类的draw()方法,这时候不带super.是不行的,因为当前的方法也叫draw()。

需要说明的是,这里ArrowLine继承了Line,也可以直接在类Line里加上属性,而不需要单独设计一个类ArrowLine,这里主要是演示继承的层级性。

5.图形管理器

使用继承的一个好处是可以统一处理不同子类型的对象。比如,我们来看一个图形管理者类,它负责管理画板上的所有图形对象并负责绘制,在绘制代码中,只需要将每个对象当作Shape并调用draw方法就可以了,系统会自动执行子类的draw方法。如代码清单4-6所示。

代码清单4-6 图形管理器类ShapeManager
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ShapeManager {
private static final int MAX_NUM = 100;
private Shape[] shapes = new Shape[MAX_NUM];
private int shapeNum = 0;
public void addShape(Shape shape){
if(shapeNum<MAX_NUM){
shapes[shapeNum++] = shape;
}
}
public void draw(){
for(int i=0; i<shapeNum; i++){
shapes[i].draw();
}
}
}

ShapeManager使用一个数组保存所有的shape,在draw方法中调用每个shape的draw方法。ShapeManager并不知道每个shape具体的类型,也不关心,但可以调用到子类的draw方法。

我们来看下使用ShapeManager的一个例子:

1
2
3
4
5
6
7
8
public static void main(String[] args) {
ShapeManager manager = new ShapeManager();
manager.addShape(new Circle(new Point(4,4),3));
manager.addShape(new Line(new Point(2,3), new Point(3,4), "green"));
manager.addShape(new ArrowLine(new Point(1,2),
new Point(5,5), "black", false, true));
manager.draw();
}

新建了三个shape,分别是一个圆、直线和带箭头的线,然后加到了shapemanager中,然后调用manager的draw方法。

需要说明的是,在addShape方法中,参数Shape shape,声明的类型是Shape,而实际的类型则分别是Circle、Line和ArrowLine。子类对象赋值给父类引用变量,这叫向上转型,转型就是转换类型,向上转型就是转换为父类类型。

变量shape可以引用任何Shape子类类型的对象,这叫多态,即一种类型的变量,可引用多种实际类型对象。这样,对于变量shape,它就有两个类型:类型Shape,我们称之为shape的静态类型;类型Circle/Line/ArrowLine,我们称之为shape的动态类型。在ShapeManager的draw方法中,shapes[i].draw()调用的是其对应动态类型的draw方法,这称之为方法的动态绑定

为什么要有多态和动态绑定呢?创建对象的代码(ShapeManager以外的代码)和操作对象的代码(ShapeManager本身的代码),经常不在一起,操作对象的代码往往只知道对象是某种父类型,也往往只需要知道它是某种父类型就可以了。

可以说,多态和动态绑定是计算机程序的一种重要思维方式,使得操作对象的程序不需要关注对象的实际类型,从而可以统一处理不同对象,但又能实现每个对象的特有行为。在4.3节,我们会进一步介绍动态绑定的实现原理。

4.1.4 小结

本节介绍了继承和多态的基本概念。

1)每个类有且只有一个父类,没有声明父类的,其父类为Object,子类继承了父类非private的属性和方法,可以增加自己的属性和方法,以及重写父类的方法实现。
2)new过程中,父类先进行初始化,可通过super调用父类相应的构造方法,没有使用super的情况下,调用父类的默认构造方法。
3)子类变量和方法与父类重名的情况下,可通过super强制访问父类的变量和方法。
4)子类对象可以赋值给父类引用变量,这叫多态;实际执行调用的是子类实现,这叫动态绑定。

继承和多态的基本概念是比较简单的,子类继承父类,自动拥有父类的属性和行为,并可扩展属性和行为,同时,可重写父类的方法以修改行为。但关于继承,还有很多细节,我们下一节继续讨论。

4.3 继承实现的基本原理

本节通过一个例子来介绍继承实现的基本原理。需要说明的是,本节主要从概念上来介绍原理,实际实现细节可能与此不同。

4.3.1 示例

基类Base如代码清单4-7所示。

代码清单4-7 演示继承原理:Base类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Base {
public static int s;
private int a;
static {
System.out.println("基类静态代码块, s: "+s);
s = 1;
}
{
System.out.println("基类实例代码块, a: "+a);
a = 1;
}
public Base(){
System.out.println("基类构造方法, a: "+a);
a = 2;
}
protected void step(){
System.out.println("base s: " + s +", a: "+a);
}
public void action(){
System.out.println("start");
step();
System.out.println("end");
}
}

Base包括一个静态变量s,一个实例变量a,一段静态初始化代码块,一段实例初始化代码块,一个构造方法,两个方法step和action。子类Child如代码清单4-8所示。

代码清单4-8 演示继承原理:Child类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Child extends Base {
public static int s;
private int a;
static {
System.out.println("子类静态代码块, s: "+s);
s = 10;
}
{
System.out.println("子类实例代码块, a: "+a);
a = 10;
}
public Child(){
System.out.println("子类构造方法, a: "+a);
a = 20;
}
protected void step(){
System.out.println("child s: " + s +", a: "+a);
}
}

Child继承了Base,也定义了和基类同名的静态变量s和实例变量a,静态初始化代码块,实例初始化代码块,构造方法,重写了方法step。使用的例子如代码清单4-9所示。

代码清单4-9 演示继承原理:main方法
1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
System.out.println("---- new Child()");
Child c = new Child();
System.out.println("\n---- c.action()");
c.action();
Base b = c;
System.out.println("\n---- b.action()");
b.action();
System.out.println("\n---- b.s: " + b.s);
System.out.println("\n---- c.s: " + c.s);
}

上面的代码创建了Child类型的对象,赋值给了Child类型的引用变量c,通过c调用action方法,又赋值给了Base类型的引用变量b,通过b也调用了action,最后通过b和c访问静态变量s并输出。这是屏幕的输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
---- new Child()
基类静态代码块, s: 0
子类静态代码块, s: 0
基类实例代码块, a: 0
基类构造方法, a: 1
子类实例代码块, a: 0
子类构造方法, a: 10
---- c.action()
start
child s: 10, a: 20
end
---- b.action()
start
child s: 10, a: 20
end
---- b.s: 1
---- c.s: 10

下面我们来解释一下背后都发生了一些什么事情,从类的加载开始。

4.3.2 类加载过程

在Java中,所谓类的加载是指将类的相关信息加载到内存。在Java中,类是动态加载的,当第一次使用这个类的时候才会加载,加载一个类时,会查看其父类是否已加载,如果没有,则会加载其父类。
1)一个类的信息主要包括以下部分:

  • 类变量(静态变量);
  • 类初始化代码;
  • 类方法(静态方法);
  • 实例变量;
  • 实例初始化代码;
  • 实例方法;
  • 父类信息引用。
    2)类初始化代码包括:
  • 定义静态变量时的赋值语句;
  • 静态初始化代码块。
    3)实例初始化代码包括:
  • 定义实例变量时的赋值语句;
  • 实例初始化代码块;
  • 构造方法。
    4)类加载过程包括:
  • 分配内存保存类的信息;
  • 给类变量赋默认值;
  • 加载父类;
  • 设置父子关系;
  • 执行类初始化代码。

注意,类初始化代码,是先执行父类的,再执行子类的。不过,父类执行时,子类静态变量的值也是有的,是默认值。对于默认值,我们之前说过,数字型变量都是0, boolean是false, char是’\u0000’,引用型变量是null。

之前我们说过,内存分为栈和堆,栈存放函数的局部变量,而堆存放动态分配的对象,还有一个内存区,存放类的信息,这个区在Java中称为方法区

加载后,Java方法区就有了一份这个类的信息。以我们的例子来说,有3份类信息,分别是Child、Base、Object,内存布局如图4-3所示。

epub_923038_41

图4-3 继承原理:类信息内存布局

我们用class_init()来表示类初始化代码,用instance_init()表示实例初始化代码,实例初始化代码包括了实例初始化代码块和构造方法。例子中只有一个构造方法,实际情况则可能有多个实例初始化方法。

本例中,类的加载大致就是在内存中形成了类似上面的布局,然后分别执行了Base和Child的类初始化代码。接下来,我们看对象创建的过程。

4.3.3 对象创建的过程

在类加载之后,new Child()就是创建Child对象,创建对象过程包括:
1)分配内存;
2)对所有实例变量赋默认值;
3)执行实例初始化代码。

分配的内存包括本类和所有父类的实例变量,但不包括任何静态变量。实例初始化代码的执行从父类开始,再执行子类的。但在任何类执行初始化代码之前,所有实例变量都已设置完默认值。

每个对象除了保存类的实例变量之外,还保存着实际类信息的引用。

Child c = new Child();会将新创建的Child对象引用赋给变量c,而Base b = c;会让b也引用这个Child对象。创建和赋值后,内存布局如图4-4所示。

epub_923038_42

图4-4 继承原理:对象内存布局

引用型变量c和b分配在栈中,它们指向相同的堆中的Child对象。Child对象存储着方法区中Child类型的地址,还有Base中的实例变量a和Child中的实例变量a。创建了对象,接下来,来看方法调用的过程。

4.3.4 方法调用的过程

我们先来看c.action(); ,这句代码的执行过程:
1)查看c的对象类型,找到Child类型,在Child类型中找action方法,发现没有,到父类中寻找;
2)在父类Base中找到了方法action,开始执行action方法;
3)action先输出了start,然后发现需要调用step()方法,就从Child类型开始寻找step()方法;
4)在Child类型中找到了step()方法,执行Child中的step()方法,执行完后返回action方法;
5)继续执行action方法,输出end。

寻找要执行的实例方法的时候,是从对象的实际类型信息开始查找的,找不到的时候,再查找父类类型信息。

我们来看b.action(),这句代码的输出和c.action()是一样的,这称为动态绑定,而动态绑定实现的机制就是根据对象的实际类型查找要执行的方法,子类型中找不到的时候再查找父类。这里,因为b和c指向相同的对象,所以执行结果是一样的。

如果继承的层次比较深,要调用的方法位于比较上层的父类,则调用的效率是比较低的,因为每次调用都要进行很多次查找。大多数系统使用一种称为虚方法表的方法来优化调用的效率。

所谓虚方法表,就是在类加载的时候为每个类创建一个表,记录该类的对象所有动态绑定的方法(包括父类的方法)及其地址,但一个方法只有一条记录,子类重写了父类方法后只会保留子类的。对于本例来说,Child和Base的虚方法表如图4-5所示。

epub_923038_43

图4-5 继承原理:虚方法表

对Child类型来说,action方法指向Base中的代码,toString方法指向Object中的代码,而step()指向本类中的代码。当通过对象动态绑定方法的时候,只需要查找这个表就可以了,而不需要挨个查找每个父类。接下来,我们介绍变量访问的过程。

4.3.5 变量访问的过程

对变量的访问是静态绑定的,无论是类变量还是实例变量。代码中演示的是类变量:b.s和c.s,通过对象访问类变量,系统会转换为直接访问类变量Base.s和Child.s。

例子中的实例变量都是private的,不能直接访问;如果是public的,则b.a访问的是对象中Base类定义的实例变量a,而c.a访问的是对象中Child类定义的实例变量a。

本节通过一个例子来介绍类的加载、对象创建、方法调用以及变量访问的内部过程。现在,我们应该对继承的实现有了比较清楚的理解。之前我们提到,继承是把双刃剑,为什么这么说呢?让我们下节来探讨。

4.4 为什么说继承是把双刃剑

继承其实是把双刃剑:一方面继承是非常强大的;另一方面继承的破坏力也是很强的。

继承广泛应用于各种Java API、框架和类库之中,一方面它们内部大量使用继承,另一方面它们设计了良好的框架结构,提供了大量基类和基础公共代码。使用者可以使用继承,重写适当方法进行定制,就可以简单方便地实现强大的功能。

但,继承为什么会有破坏力呢?主要是因为继承可能破坏封装,而封装可以说是程序设计的第一原则;另外,继承可能没有反映出is-a关系。下面我们详细来说明。

4.4.1 继承破坏封装

什么是封装呢?封装就是隐藏实现细节,提供简化接口。使用者只需要关注怎么用,而不需要关注内部是怎么实现的。实现细节可以随时修改,而不影响使用者。函数是封装,类也是封装。通过封装,才能在更高的层次上考虑和解决问题。可以说,封装是程序设计的第一原则,没有封装,代码之间会到处存在着实现细节的依赖,则构建和维护复杂的程序是难以想象的。

继承可能破坏封装是因为子类和父类之间可能存在着实现细节的依赖。子类在继承父类的时候,往往不得不关注父类的实现细节,而父类在修改其内部实现的时候,如果不考虑子类,也往往会影响到子类。我们通过一些例子来说明。这些例子主要用于演示,可以基本忽略其实际意义。

4.4.2 封装是如何被破坏的

我们来看一个简单的例子,基类Base如代码清单4-10所示。

代码清单4-10 继承破坏封装:基类Base
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Base {
private static final int MAX_NUM = 1000;
private int[] arr = new int[MAX_NUM];
private int count;
public void add(int number){
if(count<MAX_NUM){
arr[count++] = number;
}
}
public void addAll(int[] numbers){
for(int num : numbers){
add(num);
}
}
}

Base提供了两个方法add和addAll,将输入数字添加到内部数组中。对使用者来说, add和addAll就是能够添加数字,具体是怎么添加的,不用关心。

子类代码Child如代码清单4-11所示。

代码清单4-11 继承破坏封装:子类Child
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Child extends Base {
private long sum;
@Override
public void add(int number) {
super.add(number);
sum+=number;
}
@Override
public void addAll(int[] numbers) {
super.addAll(numbers);
for(int i=0; i<numbers.length; i++){
sum+=numbers[i];
}
}
public long getSum() {
return sum;
}
}

子类重写了基类的add和addAll方法,在添加数字的同时汇总数字,存储数字的和到实例变量sum中,并提供了方法getSum获取sum的值。使用Child的代码如下所示:

1
2
3
4
5
public static void main(String[] args) {
Child c = new Child();
c.addAll(new int[]{1,2,3});
System.out.println(c.getSum());
}

使用addAll添加1、2、3,期望的输出是1+2+3=6,实际输出为12!为什么是12呢?查看代码不难看出,同一个数字被汇总了两次。子类的addAll方法首先调用了父类的add-All方法,而父类的addAll方法通过add方法添加,由于动态绑定,子类的add方法会执行,子类的add也会做汇总操作。

可以看出,如果子类不知道基类方法的实现细节,它就不能正确地进行扩展。知道了错误,现在我们修改子类实现,修改addAll方法为:

1
2
3
4
@Override
public void addAll(int[] numbers) {
super.addAll(numbers);
}

也就是说,addAll方法不再进行重复汇总。这次,程序就可以输出正确结果6了。

但是,基类Base决定修改addAll方法的实现,改为下面代码:

1
2
3
4
5
6
7
public void addAll(int[] numbers){
for(int num : numbers){
if(count<MAX_NUM){
arr[count++] = num;
}
}
}

也就是说,它不再通过调用add方法添加,这是Base类的实现细节。但是,修改了基类的内部细节后,上面使用子类的程序却错了,输出由正确值6变为了0。

从这个例子,可以看出,子类和父类之间是细节依赖,子类扩展父类,仅仅知道父类能做什么是不够的,还需要知道父类是怎么做的,而父类的实现细节也不能随意修改,否则可能影响子类

更具体地说,子类需要知道父类的可重写方法之间的依赖关系,具体到上例中,就是add和addAll方法之间的关系,而且这个依赖关系,父类不能随意改变

即使这个依赖关系不变,封装还是可能被破坏。还是上面的例子,我们先将addAll方法改回去,这次,我们在基类Base中添加一个方法clear,这个方法的作用是将所有添加的数字清空,代码如下:

1
2
3
4
5
6
public void clear(){
for(int i=0; i<count; i++){
arr[i]=0;
}
count = 0;
}

基类添加一个方法不需要告诉子类,Child类不知道Base类添加了这么一个方法,但因为继承关系,Child类却自动拥有了这么一个方法。因此,Child类的使用者可能会这么使用Child类:

1
2
3
4
5
6
7
public static void main(String[] args) {
Child c = new Child();
c.addAll(new int[]{1,2,3});
c.clear();
c.addAll(new int[]{1,2,3});
System.out.println(c.getSum());
}

先添加一次,之后调用clear清空,又添加一次,最后输出sum,期望结果是6,但实际输出是12。因为Child没有重写clear方法,它需要增加如下代码,重置其内部的sum值:

1
2
3
4
5
@Override
public void clear() {
super.clear();
this.sum = 0;
}

可以看出,父类不能随意增加公开方法,因为给父类增加就是给所有子类增加,而子类可能必须要重写该方法才能确保方法的正确性

总结一下:对于子类而言,通过继承实现是没有安全保障的,因为父类修改内部实现细节,它的功能就可能会被破坏;而对于基类而言,让子类继承和重写方法,就可能丧失随意修改内部实现的自由

4.4.3 继承没有反映is-a关系

继承关系是设计用来反映is-a关系的,子类是父类的一种,子类对象也属于父类,父类的属性和行为也适用于子类。就像橙子是水果一样,水果有的属性和行为,橙子也必然都有。

但现实中,设计完全符合is-a关系的继承关系是困难的。比如,绝大部分鸟都会飞,可能就想给鸟类增加一个方法fly()表示飞,但有一些鸟就不会飞,比如企鹅。

在is-a关系中,重写方法时,子类不应该改变父类预期的行为,但是这是没有办法约束的。还是以鸟为例,你可能给父类增加了fly()方法,对企鹅,你可能想,企鹅不会飞,但可以走和游泳,就在企鹅的fly()方法中,实现了有关走或游泳的逻辑。

继承是应该被当作is-a关系使用的,但是,Java并没有办法约束,父类有的属性和行为,子类并不一定都适用,子类还可以重写方法,实现与父类预期完全不一样的行为。

但对于通过父类引用操作子类对象的程序而言,它是把对象当作父类对象来看待的,期望对象符合父类中声明的属性和行为。如果不符合,结果是什么呢?混乱。

4.4.4 如何应对继承的双面性

继承既强大又有破坏性,那怎么办呢?
1)避免使用继承;
2)正确使用继承。

我们先来看怎么避免继承,有三种方法:

  • 使用final关键字;
  • 优先使用组合而非继承;
  • 使用接口。

1.使用final避免继承

在4.2节,我们提到过final类和final方法,final方法不能被重写,final类不能被继承,我们没有解释为什么需要它们。通过上面的介绍,我们就应该能够理解其中的一些原因了。

给方法加final修饰符,父类就保留了随意修改这个方法内部实现的自由,使用这个方法的程序也可以确保其行为是符合父类声明的。

给类加final修饰符,父类就保留了随意修改这个类实现的自由,使用者也可以放心地使用它,而不用担心一个父类引用的变量,实际指向的却是一个完全不符合预期行为的子类对象。

2.优先使用组合而非继承

使用组合可以抵挡父类变化对子类的影响,从而保护子类,应该优先使用组合。还是上面的例子,我们使用组合来重写一下子类,如代码清单4-12所示。

代码清单4-12 使用组合实现子类Child
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Child {
private Base base;
private long sum;
public Child(){
base = new Base();
}
public void add(int number) {
base.add(number);
sum+=number;
}
public void addAll(int[] numbers) {
base.addAll(numbers);
for(int i=0; i<numbers.length; i++){
sum+=numbers[i];
}
}
public long getSum() {
return sum;
}
}

这样,子类就不需要关注基类是如何实现的了,基类修改实现细节,增加公开方法,也不会影响到子类了。但组合的问题是,子类对象不能当作基类对象来统一处理了。解决方法是使用接口。接口是什么呢?我们留待下章介绍。

3.正确使用继承

如果要使用继承,怎么正确使用呢?使用继承大概主要有三种场景:
1)基类是别人写的,我们写子类;
2)我们写基类,别人可能写子类;
3)基类、子类都是我们写的。

第1种场景中,基类主要是Java API、其他框架或类库中的类,在这种情况下,我们主要通过扩展基类,实现自定义行为,这种情况下需要注意的是:

  • 重写方法不要改变预期的行为;
  • 阅读文档说明,理解可重写方法的实现机制,尤其是方法之间的依赖关系;
  • 在基类修改的情况下,阅读其修改说明,相应修改子类。

第2种场景中,我们写基类给别人用,在这种情况下,需要注意的是:

  • 使用继承反映真正的is-a关系,只将真正公共的部分放到基类;
  • 对不希望被重写的公开方法添加final修饰符;
  • 写文档,说明可重写方法的实现机制,为子类提供指导,告诉子类应该如何重写;
  • 在基类修改可能影响子类时,写修改说明。

第3种场景,我们既写基类也写子类,关于基类,注意事项和第2种场景类似,关于子类,注意事项和第1种场景类似,不过程序都由我们控制,要求可以适当放松一些。

至此,关于继承就介绍完了,本章最后,我们提到了一个概念:接口,接口到底是什么呢?让我们下章探讨。

5.2 抽象类

顾名思义,抽象类就是抽象的类。抽象是相对于具体而言的,一般而言,具体类有直接对应的对象,而抽象类没有,它表达的是抽象概念,一般是具体类的比较上层的父类。比如,狗是具体对象,而动物则是抽象概念;樱桃是具体对象,而水果则是抽象概念;正方形是具体对象,而图形则是抽象概念。下面我们通过图形处理中的一些概念来说明Java中的抽象类。

5.2.1 抽象方法和抽象类

之前我们介绍过图形类Shape,它有一个方法draw()。Shape其实是一个抽象概念,它的draw()方法其实并不知道如何实现,只有子类才知道。这种只有子类才知道如何实现的方法,一般被定义为抽象方法

抽象方法是相对于具体方法而言的,具体方法有实现代码,而抽象方法只有声明,没有实现。上节介绍的接口中的方法(非Java 8引入的静态和默认方法)就都是抽象方法。

抽象方法和抽象类都使用abstract这个关键字来声明,语法如下所示:

1
2
3
4
public abstract class Shape {
//其他代码
public abstract void draw();
}

定义了抽象方法的类必须被声明为抽象类,不过,抽象类可以没有抽象方法。抽象类和具体类一样,可以定义具体方法、实例变量等,它和具体类的核心区别是,抽象类不能创建对象(比如,不能使用new Shape()),而具体类可以

抽象类不能创建对象,要创建对象,必须使用它的具体子类。一个类在继承抽象类后,必须实现抽象类中定义的所有抽象方法,除非它自己也声明为抽象类。圆类的实现代码,如下所示:

1
2
3
4
5
6
7
public class Circle extends Shape {
//其他代码
@Override
public void draw() {
//主体代码
}
}

圆实现了draw()方法。与接口类似,抽象类虽然不能使用new,但可以声明抽象类的变量,引用抽象类具体子类的对象,如下所示:

1
2
Shape shape = new Circle();
shape.draw();

shape是抽象类Shape类型的变量,引用了具体子类Circle的对象,调用draw()方法将调用Circle的draw代码。

5.2.2 为什么需要抽象类

抽象方法和抽象类看上去是多余的,对于抽象方法,不知道如何实现,定义一个空方法体不就行了吗?而抽象类不让创建对象,看上去只是增加了一个不必要的限制。

引入抽象方法和抽象类,是Java提供的一种语法工具,对于一些类和方法,引导使用者正确使用它们,减少误用。使用抽象方法而非空方法体,子类就知道它必须要实现该方法,而不可能忽略,若忽略Java编译器会提示错误。使用抽象类,类的使用者创建对象的时候,就知道必须要使用某个具体子类,而不可能误用不完整的父类。

无论是编写程序,还是平时做其他事情,每个人都可能会犯错,减少错误不能只依赖人的优秀素质,还需要一些机制,使得一个普通人都容易把事情做对,而难以把事情做错。抽象类就是Java提供的这样一种机制

5.2.3 抽象类和接口

抽象类和接口有类似之处:都不能用于创建对象,接口中的方法其实都是抽象方法。如果抽象类中只定义了抽象方法,那抽象类和接口就更像了。但抽象类和接口根本上是不同的,接口中不能定义实例变量,而抽象类可以,一个类可以实现多个接口,但只能继承一个类。

抽象类和接口是配合而非替代关系,它们经常一起使用,接口声明能力,抽象类提供默认实现,实现全部或部分方法,一个接口经常有一个对应的抽象类。比如,在Java类库中,有:

  • Collection接口和对应的AbstractCollection抽象类。
  • List接口和对应的AbstractList抽象类。
  • Map接口和对应的AbstractMap抽象类。

对于需要实现接口的具体类而言,有两个选择:一个是实现接口,自己实现全部方法;另一个则是继承抽象类,然后根据需要重写方法。

继承的好处是复用代码,只重写需要的部分即可,需要编写的代码比较少,容易实现。不过,如果这个具体类已经有父类了,那就只能选择实现接口了。

我们以一个例子来进一步说明这种配合关系。前面引入了IAdd接口,我们实现一个抽象类AbstractAdder,代码如下:

1
2
3
4
5
6
7
8
public abstract class AbstractAdder implements IAdd {
@Override
public void addAll(int[] numbers) {
for(int num : numbers){
add(num);
}
}
}

这个抽象类提供了addAll方法的实现,它通过调用add方法来实现,而add方法是一个抽象方法。这样,对于需要实现IAdd接口的类来说,它可以选择直接实现IAdd接口,或者从AbstractAdder类继承,如果继承,只需要实现add方法就可以了。这里,我们让原有的Base类继承AbstractAdder,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
public class Base extends AbstractAdder {
private static final int MAX_NUM = 1000;
private int[] arr = new int[MAX_NUM];
private int count;
@Override
public void add(int number){
if(count<MAX_NUM){
arr[count++] = number;
}
}
}

5.2.4 小结

本节介绍了抽象类,相对于具体类,它用于表达抽象概念,虽然从语法上抽象类不是必需的,但它能使程序更为清晰,可以减少误用。抽象类和接口经常相互配合,接口定义能力,而抽象类提供默认实现,方便子类实现接口。

在目前关于类的描述中,每个类都是独立的,都对应一个Java源代码文件,但在Java中,一个类还可以放在另一个类的内部,称之为内部类。为什么要将一个类放到别的类内部呢?让我们下节探讨。