Java 编程的逻辑

1. 数据和变量

所谓程序,就是告诉计算机要操作的数据和要执行的指令序列,即对什么数据做什么操作

1.1 数据类型

整数类型:byte(1,2^7)/short(2,2^15)/int(4,2^31)/long(8) 小数类型:float(4)/double(8) 字符类型:char(2) 真假类型:bool(1)

字节(Byte)是计算机信息技术用于计量存储容量的一种计量单位,也表示一些计算机编程语言中的数据类型和语言字符。

1字节(Byte)=8位(bit),二进制中的8位,比如11111110。

1KB( Kilobyte,千字节)=1024B

  1. 整数的二进制表示和位运算

十进制中123=1*(10^2) + 2*(10^1) + 3*(10^0)

二进制转十进制:111=1*(2^2) + 1*(2^1) + 1*(2^0)=7

十进制转二进制:除2取余。

2.1 负数的二进制(补码)

二进制使用最高位表示符号位,用1表示负数,用0表示正数。

补码表示就是在原码表示的基础上取反然后加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。

2.2 位运算

位运算有移位运算和逻辑运算。

移位:

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

  • 无符号右移:操作符为>>>,向右移动,右边的舍弃掉,左边补0。

  • 有符号右移:操作符为>>,向右移动,右边的舍弃掉,左边补什么取决于原来最高位是什么,原来是1就补1,原来是0就补0,将二进制看做整数,右移1位相当于除以2,右移n位相当于除以2^n。

例如:

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

逻辑运算:

  • 按位与 &:两位都为1才为1

  • 按位或 |:只要有一位为1,就为1

  • 按位取反 ~: 1变为0,0变为1

  • 按位异或 ^ :相异为真,相同为假

3. 小数计算为什么会出错

实际上,不是运算本身会出错,而是计算机根本就不能精确的表示很多数,比如0.1这个数。

计算机是用一种二进制格式存储小数的,这个二进制格式不能精确表示0.1,它只能表示一个非常接近0.1但又不等于0.1的一个数。二进制只能表示哪些可以表述为2的多少次方和的数。

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

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

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

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

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

3.3 怎么处理计算不精确

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

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

3.4 为什么叫浮点数

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

我们还是用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位表示指数。

4. 乱码

整数和小数的二进制,字符和文本的二进制。

4.1 ASCII

在计算机发明之初,只考虑了美国用的128个字符,美国就规定了这128个字符的二进制表示方法。这个方法是一个标准,称为ASCII编码,全称是American Standard Code for Information Interchange,美国信息互换标准代码。

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

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

4.2 ISO 8859-1

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

4.3 Windows-1252

ISO 8859-1虽然号称是标准,用于西欧国家,但它连欧元(€) 这个符号都没有,因为欧元比较晚,而标准比较早。

实际使用中更为广泛的是Windows-1252编码,这个编码与ISO8859-1基本是一样的,区别只在于数字128到159,Windows-1252使用其中的一些数字表示可打印字符,这些数字表示的含义,如下图所示:

4.4 GB2312

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

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

GB2312固定使用两个字节表示汉字,在这两个字节中,最高位都是1,如果是0,就认为是Ascii字符。

在这两个字节中,其中高位字节范围是0xA1-0xF7,低位字节范围是0xA1-0xFE。

4.5 GBK

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

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

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

4.6 GB18030

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

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

4.7 编码总结

除了中国(基本使用2个字节,还有4个字节)外,基本使用一个字节就能表示。中国的编码最高位是1,其他都是0,ASCII是所有编码的基础,都会兼容它。

4.8 乱码

编码和查看使用不同的编码就会乱码,除非只有ASCII,因为所有的编码都会兼容它。

4.9 Unicode

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

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

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

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

4.10 UTF-32

这个最简单,就是字符编号的整数二进制形式,四个字节。可以看出,每个字符都用四个字节表示,非常浪费空间,实际采用的也比较少。

4.11 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-16比UTF-32节省了很多空间,但是任何一个字符都至少需要两个字节表示,对于美国和西欧国家而言,还是很浪费的

4.12 UTF-8

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

具体来说,各个Unicode编号范围对应的二进制格式如下图所示:

2^7=128

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

4.13 Uncode编码小结

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

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

4.14 编码转换

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

4.15 如何恢复乱码

切换编码格式,直到正确为止。

将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共十二种组合。这四种编码是常见编码,在大部分实际应用中应该够了,但如果你的情况有其他编码,可以增加一些尝试。

5. char in Java

5.1 char的本质

在 Java内部进行字符处理时,采用的都是Unicode,具体编码格式是UTF-16BE。UTF-16使用两个或四个字节表示一个字符,Unicode编号范围在65536以内的占两个字节,超出范围的占四个字节

char本质上是一个固定占用两个字节的无符号正整数(2^16=65536),这个正整数对应于Unicode编号,用于表示那个Unicode编号对应的字符。

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

这里可以回顾下Java数据类型占用字节大小(char=2byte)

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

5.2 char的赋值

char有多种赋值方式:

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

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

5.3 char的运算

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

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

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

5.4 char的二进制

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

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

5.5 总结

char的本质是一个整数,固定占用两个字节,表示字符的Unicode编号。

6. 条件语句执行的本质

程序最终都是一条条的指令,CPU有一个指令指示器,指向下一条要执行的指令,CPU根据指示器的指示加载指令并且执行。指令大部分是具体的操作和运算,在执行这些操作时,执行完一个操作后,指令指示器会自动指向挨着的下一个指令。

但有一些特殊的指令,称为跳转指令,这些指令会修改指令指示器的值,让CPU跳到一个指定的地方执行。**跳转有两种,一种是条件跳转,另一种是无条件跳转。**条件跳转检查某个条件,满足则进行跳转,无条件跳转则是直接进行跳转。

if, else实际上会转换为这些跳转指令。

switch的转换和具体系统实现有关,如果分支比较少,可能会转换为跳转指令。如果分支比较多,使用条件跳转会进行很多次的比较运算,效率比较低,可能会使用一种更为高效的方式,叫跳转表。跳转表是一个映射表,存储了可能的值以及要跳转到的地址。

跳转表为什么会更为高效呢?因为,其中的值必须为整数,且按大小顺序排序。按大小排序的整数可以使用高效的二分查找,即先与中间的值比,如果小于中间的值则在开始和中间值之间找,否则在中间值和末尾值之间找,每找一次缩小一倍查找范围。如果值是连续的,则跳转表还会进行特殊优化,优化为一个数组,连找都不用找了,值就是数组的下标索引,直接根据值就可以找到跳转的地址。即使值不是连续的,但数字比较密集,差的不多,编译器也可能会优化为一个数组型的跳转表,没有的值指向default分支。

跳转表?跳表?

这里涉及一个知识点,为什么switch值的类型可以是byte, short, int, char, 枚举和String,不能是其他的类型?

其中byte/short/int本来就是整数,char本质上也是整数,而枚举类型也有对应的整数,String用于switch时也会转换为整数(通过hashCode方法),为什么不可以使用long呢?跳转表值的存储空间一般为32位,容纳不下long。

7. 循环语句本质

和if一样,循环内部也是靠条件转移和无条件转移指令实现的。

8. 函数

使用函数减少重复代码和分解复杂功能。

程序执行基本上是顺序执行,条件执行和循环执行。

函数设计到修饰符,方法名,参数,返回值,重载,重写等等内容。

9. 函数调用的基本原理

计算机系统使用这个数据结构来存放函数调用过程中需要的数据,包括参数,返回地址,函数内定义的局部变量。

函数中的参数和函数内定义的变量如果是基本数据类型则都分配到栈上(内存)中,如果类型是数组或对象则不一样:

对于数组和对象,他们都有两块内存,一块存放实际的内容,一块存放实际内容的内存地址。实际的内容存在的内存是在堆上,存放实际内容内存的地址的内存是在栈上。

递归函数调用的过程虽然执行方法是一样的,但是每进入一次方法,每入栈一次,都会定义一份方法内的局部变量,返回地址。这样占用的栈的空间就会非常可观,如果超过了设定的栈的大学,则会抛出java.lang.StackOverflowError。

10. 类

函数中的static表示类方法,也叫静态方法,与类方法相对的是实例方法。实例方法没有static修饰符,必须通过实例调用,而类方法可以直接通过类调用,不需要创建实例。

public和private修饰符,通过private封装和隐藏内部实现细节,避免误操作,这也是计算机程序的一种基本思维方式。

类的属性可以分为类型本身具有的属性或者具体数据具有的属性,同样操作可以分为类型本身进行的操作还是具体数据进行的操作。

如果将微信订阅号看做一个类型,那"老马说编程"订阅号就是一个实例,订阅号的头像、功能介绍、发布的文章可以看做实例变量,而修改头像、修改功能介绍、发布新文章可以看做实例方法。

  • 类方法只能访问类变量,但不能访问实例变量,可以调用其他的类方法,但不能调用实例方法。

  • 实例方法既能访问实例变量,也可以访问类变量,既可以调用实例方法,也可以调用类方法。

与方法内定义的局部变量不同,在创建对象的时候,所有的实例变量都会分配一个默认值,另外会调用代码块,构造方法,这与在创建数组的时候是类似的,数值类型变量的默认值是 0,boolean是false, char是'\u0000',引用类型变量都是null,null是一个特殊的值,表示不指向任何对象。

如何修改创建对象时为实例变量分配的默认值?

int x = 1;
int y;
{
    y = 2;
}

代码块,在对象被创建时就会被调用。

静态变量也可以这样初始化:

static int STATIC_ONE = 1;
static int STATIC_TWO;
static
{
    STATIC_TWO = 2;    
}

静态代码块,在类加载的时候就会调用。

在程序运行的时候,当第一次通过new创建一个类的对象的时候,或者直接通过类名访问类变量和类方法的时候,Java会将类加载进内存,为这个类型分配一块空间,这个空间会包括类的定义,它有哪些变量,哪些方法等,同时还有类的静态变量,并对静态变量赋初始值。后续文章会进一步介绍有关细节。一般放在方法区中的。

类加载进内存后,一般不会释放,直到程序结束。一般情况下,类只会加载一次,所以静态变量在内存中只有一份。

当通过new创建一个对象的时候,对象产生,在内存中,会存储这个对象的实例变量值,每new一次,对象就会产生一个,就会有一份独立的实例变量。

每个对象除了保存实例变量的值外,可以理解还保存着对应类型即类的地址,这样,通过对象能知道它的类,访问到类的变量和方法代码。

11. 继承和多态

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

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

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();
        }
    }
}
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();
}

11.1 父子类型转换

在addShape方法中,参数Shape shape,声明的类型是Shape,而实际的类型则分别是Circle,Line和ArrowLine。子类对象赋值给父类引用变量,这叫向上转型,转型就是转换类型,向上转型就是转换为父类类型。那么能不能向下转型呢,语法上可以进行强制类型转换,但不一定能转换成功一个父类的变量,能不能转换为一个子类的变量,取决于这个父类变量的动态类型(即引用的对象类型)是不是这个子类或这个子类的子类。

11.2 多态,静态类型,动态类型

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

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

如果父类没有默认的构造方法,它的任何子类都必须在构造方法中通过super(...)调用base的带参数的构造方法。

在父类构造方法中调用可被子类重写的方法,是一种不好的实践,容易引起混淆,应该只调用private的方法。

11.3 父类与子类变量,方法重名

如果子类和父类有相同的实例变量,静态方法,静态变量,重名是可以的,重名后实际是两个变量或方法,对于private修饰的变量或方法,它们只能在类内被访问,访问的也永远是当前类的。但对于public变量和方法,则要看如何访问它,在类内访问的是当前类的,但子类可以通过super.明确指定访问父类的。在类外,则要看访问变量的静态类型,静态类型是父类,则访问父类的变量和方法,静态类型是子类,则访问的是子类的变量和方法。

public static void main(String[] args) {
    Child c = new Child();
    Base b = c;
    
    System.out.println(b.s);
    System.out.println(b.m);
    b.staticTest();
    
    System.out.println(c.s);
    System.out.println(c.m);
    c.staticTest();
}

以上代码创建了一个子类对象,然后将对象分别赋值给了子类引用变量c和父类引用变量b,然后通过b和c分别引用变量和方法。这里需要说明的是,静态变量和静态方法一般通过类名直接访问,但也可以通过类的对象访问。程序输出为:

static_base
base
base static: static_base
child_base
child
child static: child_base

当通过b (静态类型Base) 访问时,访问的是Base的变量和方法,当通过c (静态类型Child)访问时,访问的是Child的变量和方法,这称之为静态绑定,即访问绑定到变量的静态类型,静态绑定在程序编译阶段即可决定,而动态绑定则要等到程序运行时。实例变量、静态变量、静态方法、private方法,都是静态绑定的。

当父类和子类有多个重名函数的时候,在决定要调用哪个函数的过程中,首先是按照参数类型进行匹配的,换句话说,寻找在所有重载版本中最匹配的,然后才看变量的动态类型,进行动态绑定。

11.4 重写

重写方法时,子类不能降低方法的可见性,但是可以升级。因为继承反应的是is-a的关系,子类也是父类,子类必须支持父类的所有对外行为。将可见性减少将会破坏is-a的关系。

12. 继承实现的基本原理

例子

这是基类代码:

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

这是子类代码:

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);
    }
}

这是使用的代码:

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); 
}

这是输出结果:

---- 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

这里c.action和b.action执行结果是一样的,因为都是根据实际类型去调用,实际类型是Child,那么就会调用Child的action()方法,但是子类并没有这个方法,就通过虚方法表(描述有问题,应该是在虚方法表中的映射关系直接找到父类的对应方法)找到父类的action()方法,action()方法会调用step方法,又会重新在子类中找这个方法。 对于实例变量和类变量是直接通过静态绑定找到对应的类型,而不是动态绑定找到实际类型。

12.1 类的加载

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

一个类的信息主要包括以下部分:

  • 类变量(静态变量)

  • 类初始化代码

  • 类方法(静态方法)

  • 实例变量

  • 实例初始化代码(代码块)

  • 实例方法

  • 父类信息引用

类初始化代码包括:

  • 定义静态变量时的赋值语句

  • 静态初始化代码块

实例初始化代码包括:

  • 定义实例变量时的赋值语句

  • 实例初始化代码块

  • 构造方法

类加载过程包括:

  • 分配内存保存类的信息

  • 给类变量赋默认值

  • 加载父类

  • 设置父子关系

  • 执行类初始化代码(先执行父类的,再执行子类的):静态变量默认值,静态代码块

创建对象

在类加载之后,new Child()就是创建Child对象,创建对象过程包括:

  1. 分配内存

  2. 对所有实例变量赋默认值

  3. 执行实例初始化代码

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

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

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

12.2 方法调用

我们先来看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的虚方法表如下所示:

对Child类型来说,action方法指向Base中的代码,toString方法指向Object中的代码,而step()指向本类中的代码。

这个表在类加载的时候生成,当通过对象动态绑定方法的时候,只需要查找这个表就可以了,而不需要挨个查找每个父类。

12.3 变量访问

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

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

13. 为什么继承是一把双刃剑

为什么这么说呢?一方面是因为继承是非常强大的,另一方面是因为继承的破坏力也是很强的。

继承的强大是比较容易理解的,具体体现在:

  • 子类可以复用父类代码,不写任何代码即可具备父类的属性和功能,而只需要增加特有的属性和行为。

  • 子类可以重写父类行为,还可以通过多态实现统一处理。

  • 给父类增加属性和行为,就可以自动给所有子类增加属性和行为。

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

13.1 继承破坏封装

什么是封装呢?封装就是隐藏实现细节。

继承可能破坏封装是因为子类和父类之间可能存在着实现细节的依赖。子类在继承父类的时候,往往不得不关注父类的实现细节,而父类在修改其内部实现的时候,如果不考虑子类,也往往会影响到子类。

如果子类不知道基类方法的实现细节,它就不能正确的进行扩展。

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

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

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

13.2 继承没有反映"is-a"关系

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

13.3 如何应对继承的双面性?

继承既强大又有破坏性,那怎么办呢?

  • 避免使用继承

  • 正确使用继承

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

  • 使用final关键字

  • 优先使用组合而非继承

  • 使用接口

13.3.1 使用final避免继承

final方法不能被重写,final类不能被继承。

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

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

13.3.2 优先使用组合而非继承

使用组合可以抵挡父类变化对子类的影响,从而保护子类,应该被优先使用。

13.3.3 使用接口

14. 接口的本质

很多时候,我们实际上关心的,并不是对象的类型,而是对象的能力。

要拍个照片,很多时候,只要能拍出符合需求的照片就行,至于是用手机拍,还是用Pad拍,或者是用单反相机拍,并不重要,关心的是对象是否有拍出照片的能力,而并不关心对象到底是什么类型,手机、Pad或单反相机都可以。

要计算一组数字,只要能计算出正确结果即可,至于是由人心算,用算盘算,用计算器算,用电脑软件算,并不重要,关心的是对象是否有计算的能力,而并不关心对象到底是算盘还是计算器。

在这些情况中,类型并不重要,重要的是能力。那如何表示能力呢?那就是接口

14.1 接口的概念

Java使用接口这个概念来表示能力。

接口声明了一组能力,但它自己并没有实现这个能力,它只是一个约定,它涉及交互两方对象,一方需要实现这个接口,另一方使用这个接口,但双方对象并不直接互相依赖,它们只是通过接口间接交互。图示如下:

拿上面的USB接口来说,USB协议约定了USB设备需要实现的能力,每个USB设备都需要实现这些能力,电脑使用USB协议与USB设备交互,电脑和USB设备互不依赖,但可以通过USB接口相互交互。

14.2 定义接口

public interface MyComparable {
    int compareTo(Object other);
}
  1. Java使用interface这个关键字来声明接口,修饰符一般都是public。

  2. interface后面就是接口的名字MyComparable。

  3. 接口定义里面,声明了一个方法compareTo,但没有定义方法体,接口都不实现方法。接口方法不需要加修饰符,加与不加都是public的,不能是别的修饰符。

14.3 实现接口

类可以实现接口,表示类的对象具有接口所表示的能力。

一个类可以实现多个接口,表明类的对象具备多种能力,各个接口之间以逗号分隔,语法如下所示:

public class Test implements Interface1, Interface2 {

....

}

14.4 使用接口

与类不同,接口不能new,不能直接创建一个接口对象,对象只能通过类来创建。但可以声明接口类型的变量,引用实现了接口的类对象。比如说,可以这样:

MyComparable p1 = new Point(2,3);
MyComparable p2 = new Point(1,2);
System.out.println(p1.compareTo(p2));

p1和p2可以调用MyComparable接口的方法,也只能调用MyComparable接口的方法,实际执行时,执行的是具体实现类的代码。

public class CompUtil {
    public static Object max(MyComparable[] objs){
        if(objs==null||objs.length==0){
            return null;
        }
        MyComparable max = objs[0];
        for(int i=1;i<objs.length;i++){
            if(max.compareTo(objs[i])<0){
                max = objs[i];
            }
        }
        return max;
    }
    
    public static void sort(MyComparable[] objs){
        for(int i=0;i<objs.length;i++){
            int min = i;
            for(int j=i+1;j<objs.length;j++){
                if(objs[j].compareTo(objs[min])<0){
                    min = j;
                }
            }
            if(min!=i){
                 MyComparable temp = objs[i];
                 objs[i] = objs[min];
                 objs[min] = temp;
            }
        }
    }
}

针对接口而非具体类型进行编程,是计算机程序的一种重要思维方式。更重要的是降低了耦合,提高了灵活性,使用接口的代码依赖的是接口本身,而非实现接口的具体类型,程序可以根据情况替换接口的实现,而不影响接口使用者。解决复杂问题的关键是分而治之,分解为小问题,但小问题之间不可能一点关系没有,分解的核心就是要降低耦合,提高灵活性,接口为恰当分解,提供了有力的工具。

14.5 接口的细节

14.5.1 接口中的变量

接口中可以定义变量,语法如下所示:

public interface Interface1 {
    public static final int a = 0;
}

这里定义了一个变量int a,修饰符是public static final,但这个修饰符是可选的,即使不写,也是public static final。这个变量可以通过"接口名.变量名"的方式使用,如Interface1.a。

14.5.2 接口的继承

接口也可以继承,一个接口可以继承别的接口,继承的基本概念与类一样,但与类不同,接口可以有多个父接口,代码如下所示:

public interface IBase1 {
    void method1();
}

public interface IBase2 {
    void method2();
}

public interface IChild extends IBase1, IBase2 {
}

接口的继承同样使用extends关键字,多个父接口之间以逗号分隔。注意这里可以继承多个接口。

14.5.3 类的继承与接口

类的继承与接口可以共存,换句话说,类可以在继承基类的情况下,同时实现一个或多个接口,语法如下所示:

public class Child extends Base implements IChild {

 //...

}

extends要放在implements之前。

14.5.4 instanceof

与类一样,接口也可以使用instanceof关键字,用来判断一个对象是否实现了某接口,例如:

Point p = new Point(2,3);
if(p instanceof MyComparable){
    System.out.println("comparable");
}

14.5.4 default方法

14.6 使用接口替代继承

继承至少有两个好处,一个是复用代码,另一个是利用多态和动态绑定统一处理多种不同子类的对象。

使用组合替代继承,可以复用代码,但不能统一处理。使用接口,针对接口编程,可以实现统一处理不同类型的对象,但接口没有代码实现,无法复用代码。将组合和接口结合起来,就既可以统一处理,也可以复用代码了。我们还是以上节的例子来说明。

泛型

其实很多时候,我们关心的不是类型,而是能力。针对接口和能力编程,不仅可以复用代码,还可以降低耦合,提高灵活性。

"泛型"字面意思就是广泛的类型,类、接口和方法代码可以应用于非常广泛的类型,代码与它们能够操作的数据类型不再绑定在一起,同一套代码,可以用于多种数据类型,这样,不仅可以复用代码,降低耦合,同时,还可以提高代码的可读性和安全性。

public class Pair<T> {

    T first;
    T second;
    
    public Pair(T first, T second){
        this.first = first;
        this.second = second;
    }
    
    public T getFirst() {
        return first;
    }
    
    public T getSecond() {
        return second;
    }
}

T是什么呢?T表示类型参数,泛型就是类型参数化,处理的数据类型不是固定的,而是可以作为参数传入

Pair<Integer> minmax = new Pair<Integer>(1,100);
Integer min = minmax.getFirst();
Integer max = minmax.getSecond();

类型参数可以有多个,Pair类中的first和second可以是不同的类型,多个类型之间以逗号分隔,来看改进后的Pair类定义:

public class Pair<U, V> {

    U first;
    V second;
    
    public Pair(U first, V second){
        this.first = first;
        this.second = second;
    }
    
    public U getFirst() {
        return first;
    }

    public V getSecond() {
        return second;
    }
}

可以这样使用:

Pair<String,Integer> pair = new Pair<>("老马",100);

泛型类型参数到底是什么呢?为什么一定要定义类型参数呢?定义普通类,直接使用Object不就行了吗?比如,Pair类可以写为:

public class Pair {

    Object first;
    Object second;
    
    public Pair(Object first, Object second){
        this.first = first;
        this.second = second;
    }
    
    public Object getFirst() {
        return first;
    }
    
    public Object getSecond() {
        return second;
    }
}

使用Pair的代码可以为:

Pair minmax = new Pair(1,100);
Integer min = (Integer)minmax.getFirst();
Integer max = (Integer)minmax.getSecond();

Pair kv = new Pair("name","老马");
String key = (String)kv.getFirst();
String value = (String)kv.getSecond();

实际上,Java泛型的内部原理就是这样的

我们知道,Java有Java编译器和Java虚拟机,编译器将Java源代码转换为.class文件,虚拟机加载并运行.class文件。对于泛型类,Java编译器会将泛型代码转换为普通的非泛型代码,就像上面的普通Pair类代码及其使用代码一样,将类型参数T擦除(类型擦除),替换为Object,插入必要的强制类型转换。Java虚拟机实际执行的时候,它是不知道泛型这回事的,它只知道普通的类及代码。

再强调一下,Java泛型是通过擦除实现的,类定义中的类型参数如T会被替换为Object,在程序运行过程中,不知道泛型的实际类型参数,比如Pair,运行中只知道Pair,而不知道Integer,认识到这一点是非常重要的,它有助于我们理解Java泛型的很多限制。

既然只使用普通类和Object就是可以的,而且泛型最后也转换为了普通类,那为什么还要用泛型呢?或者说,泛型到底有什么好处呢

主要有两个好处:

  1. 更好的安全性

  2. 更好的可读性

只使用Object,代码写错的时候,开发环境和编译器不能帮我们发现问题,看代码:

Pair pair = new Pair("老马",1);
Integer id = (Integer)pair.getFirst();
String name = (String)pair.getSecond();

写代码时,不小心类型弄错了,代码编译时是没有任何问题的,但运行时,程序抛出了类型转换异常ClassCastException

如果使用泛型,则不可能犯这个错误,如果这么写代码:

Pair<String,Integer> pair = new Pair<>("老马",1);
Integer id = pair.getFirst();
String name = pair.getSecond();

开发环境如Eclipse会提示你类型错误,即使没有好的开发环境,编译时Java编译器也会提示你。这称之为类型安全,也就是说,通过使用泛型,开发环境和编译器能确保你不会用错类型,为你的程序多设置一道安全防护网。

使用泛型,还可以省去繁琐的强制类型转换,再加上明确的类型信息,代码可读性也会更好。

容器类

泛型方法

除了泛型类,方法也可以是泛型的,而且,一个方法是不是泛型的,与它所在的类是不是泛型没有什么关系。

public static <T> int indexOf(T[] arr, T elm){
    for(int i=0; i<arr.length; i++){
        if(arr[i].equals(elm)){
            return i;
        }
    }
    return -1;
}

这个方法就是一个泛型方法,类型参数为T,放在返回值前面,它可以这么调用:

indexOf(new Integer[]{1,3,5}, 10)

也可以这么调用:

indexOf(new String[]{"hello","老马","编程"}, "老马")

与泛型类一样,类型参数可以有多个,多个以逗号分隔,比如:

public static <U,V> Pair<U,V> makePair(U first, V second){
    Pair<U,V> pair = new Pair<>(first, second);
    return pair;
}

与泛型类不同,调用方法时一般并不需要特意指定类型参数的实际类型是什么,比如调用makePair:

makePair(1,"老马");

泛型接口

接口也可以是泛型的,我们之前介绍过的Comparable和Comparator接口都是泛型的,它们的代码如下:

public interface Comparable<T> {
    public int compareTo(T o);
}
public interface Comparator<T> {
    int compare(T o1, T o2);
    boolean equals(Object obj);
}

与前面一样,T是类型参数。实现接口时,应该指定具体的类型,比如,对Integer类,实现代码是:

public final class Integer extends Number implements Comparable<Integer>{
    public int compareTo(Integer anotherInteger) {
        return compare(this.value, anotherInteger.value);
    }
    //...
}

再看Comparator的一个例子,String类内部一个Comparator的接口实现为:

private static class CaseInsensitiveComparator
        implements Comparator<String> {
    public int compare(String s1, String s2) {
        //....
    }
}

类型参数的限定

在之前的介绍中,无论是泛型类、泛型方法还是泛型接口,关于类型参数,我们都知之甚少,只能把它当做Object,但Java支持限定这个参数的一个上界,也就是说,参数必须为给定的上界类型或其子类型,这个限定是通过extends这个关键字来表示的。

这个上界可以是某个具体的类,或者某个具体的接口,也可以是其他的类型参数,我们逐个来看下其应用。

上界为某个具体类

比如说,上面的Pair类,可以定义一个子类NumberPair,限定两个类型参数必须为Number,代码如下:

public class NumberPair<U extends Number, V extends Number> extends Pair<U, V> {

    public NumberPair(U first, V second) {
        super(first, second);
    }
}

限定类型后,就可以使用该类型的方法了,比如说,对于NumberPair类,first和second变量就可以当做Number进行处理了,比如可以定义一个求和方法,如下所示:

public double sum(){
    return getFirst().doubleValue()
            +getSecond().doubleValue();
}

可以这么用:

NumberPair<Integer, Double> pair = new NumberPair<>(10, 12.34);
double sum = pair.sum();

限定类型后,如果类型使用错误,编译器会提示。

指定边界后,类型擦除时就不会转换为Object了,而是会转换为它的边界类型,这也是容易理解的。

上界为某个接口

在泛型方法中,一种常见的场景是限定类型必须实现Comparable接口,我们来看代码:

public static <T extends Comparable> T max(T[] arr){
    T max = arr[0];
    for(int i=1; i<arr.length; i++){
        if(arr[i].compareTo(max)>0){
            max = arr[i];
        }
    }
    return max;
}

不过,直接这么写代码,Java中会给一个警告信息,因为Comparable是一个泛型接口,它也需要一个类型参数,所以完整的方法声明应该是:

public static <T extends Comparable<T>> T max(T[] arr){

//...

}

<T extends Comparable<T>>是一种令人费解的语法形式,这种形式称之为递归类型限制,可以这么解读,T表示一种数据类型,必须实现Comparable接口,且必须可以与相同类型的元素进行比较。

上界为其他类型

我们看个例子,给上面的DynamicArray类增加一个实例方法addAll,这个方法将参数容器中的所有元素都添加到当前容器里来,直觉上,代码可以这么写:

public void addAll(DynamicArray<E> c) {
    for(int i=0; i<c.size; i++){
        add(c.get(i));
    }
}

但这么写有一些局限性,我们看使用它的代码:

DynamicArray<Number> numbers = new DynamicArray<>();
DynamicArray<Integer> ints = new DynamicArray<>();
ints.add(100);
ints.add(34);
numbers.addAll(ints);

Java会在number.addAll(ints)这行代码上提示编译错误,提示,addAll需要的参数类型为DynamicArray<Number>,而传递过来的参数类型为DynamicArray<Integer>,不适用,Integer是Number的子类,怎么会不适用呢?

虽然Integer是Number的子类,但DynamicArray<Integer>并不是DynamicArray<Number>的子类,DynamicArray<Integer>的对象也不能赋值给DynamicArray<Number>的变量,这一点初看上去是违反直觉的,但这是事实,必须要理解这一点。

public <T extends E> void addAll(DynamicArray<T> c) {
    for(int i=0; i<c.size; i++){
        add(c.get(i));
    }
}

E是DynamicArray的类型参数,T是addAll的类型参数,T的上界限定为E,这样,下面的代码就没有问题了:

DynamicArray<Number> numbers = new DynamicArray<>();
DynamicArray<Integer> ints = new DynamicArray<>();
ints.add(100);
ints.add(34);
numbers.addAll(ints);

泛型是计算机程序中一种重要的思维方式,它将数据结构和算法与数据类型相分离,使得同一套数据结构和算法,能够应用于各种数据类型,而且还可以保证类型安全,提高可读性

最后更新于