Java 查漏补缺

时间:2022-06-28 13:37:23

摘自《老马说编程

计算机程序的思维逻辑 (4) - 整数的二进制表示与位运算

Java中不支持直接写二进制常量,比如,想写二进制形式的11001,Java中不能直接写,可以在前面补0,补足8位,为00011001,然后用16进制表示,即 0x19。

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

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

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

int a = 25;

Integer.toBinaryString(a); //二进制
Integer.toHexString(a); //十六进制
Long.toBinaryString(a); //二进制
Long.toHexString(a); //十六进制

计算机程序的思维逻辑 (5) - 小数计算为什么会出错?

如果你想查看浮点数的具体二进制形式,在Java中,可以使用如下代码:

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

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

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

计算机程序的思维逻辑 (6) - 如何从乱码中恢复 (上)?

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

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

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

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

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

初识乱码

切换查看编码的方式,并没有改变数据的二进制本身,而只是改变了解析数据的方式,从而改变了数据看起来的样子。(稍后我们会提到编码转换,它正好相反)。

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

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

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

编码转换

有了Unicode之后,每一个字符就有了多种不兼容的编码方式。

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

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

与前文提到的切换查看编码方式正好相反,编码转换改变了数据的二进制格式,但并没有改变字符看上去的样子。

再看乱码

在前文中,我们提到乱码出现的一个重要原因是解析二进制的方式不对,通过切换查看编码的方式就可以解决乱码。但如果怎么改变查看方式都不对的话,那很有可能就不仅仅是解析二进制的方式不对,而是文本在错误解析的基础上还进行了编码转换。

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

这种情况下,无论怎么切换查看编码方式,都是不行的。

计算机程序的思维逻辑 (7) - 如何从乱码中恢复 (下)?

乱码出现的主要原因,即在进行编码转换的时候,如果将原来的编码识别错了,并进行了转换,就会发生乱码,而且这时候无论怎么切换查看编码的方式,都是不行的。

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

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

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

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

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

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

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

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

计算机程序的思维逻辑 (9) - 条件执行的本质

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

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

程序源代码中的case值排列不要求是排序的,编译器会自动排序。之前说switch值的类型可以是byte, short, int, char, 枚举和String。其中byte/short/int本来就是整数,在上节我们也说过,char本质上也是整数,而枚举类型也有对应的整 数,String用于switch时也会转换为整数(通过hashCode方法,后文介绍),为什么不可以使用long呢?跳转表值的存储空间一般为32位,容纳不下long。

计算机程序的思维逻辑 (11) - 初识函数

可变长度参数的语法是在数据类型后面加三个点...,在函数内,可变长度参数可以看做就是数组,可变长度参数必须是参数列表中的最后一个参数,一个函数也只能有一个可变长度的参数。

可变长度参数实际上会转换为数组参数,也就是说,函数声明max(int min, int... a)实际上会转换为 max(int min, int[] a),在main函数调用 max(0,2,4,5)的时候,实际上会转换为调用 max(0, new int[]{2,4,5}),使用可变长度参数主要是简化了代码书写。

计算机程序的思维逻辑 (12) - 函数调用的基本原理

从函数调用的过程我们可以看出,调用是有成本的,每一次调用都需要分配额外的栈空间用于存储参数、局部变量以及返回地址,需要进行额外的入栈和出栈操作。

在递归调用的情况下,如果递归的次数比较多,这个成本是比较可观的,所以,如果程序可以比较容易的改为别的方式,应该考虑别的方式。

计算机程序的思维逻辑 (13) - 类

与方法内定义的局部变量不同,在创建对象的时候,所有的实例变量都会分配一个默认值,这与在创建数组的时候是类似的,数值类型变量的默认值是 0,boolean是false, char是'\u0000',引用类型变量都是null

在新建一个对象的时候,会先调用这个初始化,然后才会执行构造方法中的代码。

静态初始化代码块在类加载的时候执行,这是在任何对象创建之前,且只执行一次。

new操作,一个是分配内存,另一个是给实例变量设置默认值,这里我们需要加上一件事,就是调用构造方法。

你在没有定义任何构造方法的时候,Java认为你不需要,所以就生成一个空的以被new过程调用,你定义了构造方法的时候,Java认为你知道自己在干什么,认为你是有意不想要不带参数的构造方法的,所以不会帮你生成。

构造方法可以是私有方法,即修饰符可以为private, 为什么需要私有构造方法呢?大概可能有这么几种场景:

  • 不能创建类的实例,类只能被静态访问,如Math和Arrays类,它们的构造方法就是私有的。
  • 能创建类的实例,但只能被类的的静态方法调用。有一种常用的场景,即类的对象有但是只能有一个,即单例模式(后续文章介绍),在这个场景中,对象是通过静态方法获取的,而静态方法调用私有构造方法创建一个对象,如果对象已经创建过了,就重用这个对象。
  • 只是用来被其他多个构造方法调用,用于减少重复代码。

在程序运行的时候,当第一次通过new创建一个类的对象的时候,或者直接通过类名访问类变量和类方法的时候,Java会将类加载进内存,为这个类型分配一块空间

对象

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

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

对象的释放是被Java用垃圾回收机制管理的,大部分情况下,我们不用太操心,当对象不再被使用的时候会被自动释放。

具体来说,对象和数组一样,有两块内存,保存地址的部分分配在栈中,而保存实际内容的部分分配在堆中。栈中的内存是自动管理的,函数调用入栈就会分配,而出栈就会释放。

堆中的内存是被垃圾回收机制管理的,当没有活跃变量指向对象的时候,对应的堆空间就可能被释放,具体释放时间是Java虚拟机自己决定的。活跃变量,具体的说,就是已加载的类的类变量,和栈中所有的变量。

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

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

通过类实现自定义数据类型,封装该类型的数据所具有的属性和操作,隐藏实现细节,从而在更高的层次上(类和对象的层次,而非基本数据类型和函数的层次)考虑和操作数据,是计算机程序解决复杂问题的一种重要的思维方式。

java中类方法和实例方法区别

当类的字节码文件被加载到内存时,类的实例方法不会被分配入口地址,当该类创建对象后,类中的实例方法才分配入口地址,从而实例方法可以被类创建的任何对象调用执行。需要注意的是,当我们创建第一个对象时,类中的实例方法就分配了入口地址,当再创建对象时,不再分配入口地址,也就是说,方法的入口地址被所有的对象共享,当所有的对象都不存在时,方法的入口地址才被取消。

对于类中的类方法,在该类被加载到内存时,就分配了相应的入口地址。从而类方法不仅可以被类创建的任何对象调用执行,也可以直接通过类名调用。类方法的入口地址直到程序退出才被取消。

类方法在类的字节码加载到内存时就分配了入口地址,因此,Java语言允许通过类名直接调用类方法,而实例方法不能通过类名调用。在讲述类的时候我们强调过,在Java语言中,类中的类方法不可以操作实例变量,也不可以调用实例方法,这是因为在类创建对象之前,实例成员变量还没有分配内存,而且实例方法也没有入口地址。

计算机程序的思维逻辑 (14) - 类的组合

每个类封装其内部细节,对外提供高层次的功能,使其他类在更高层次上考虑和解决问题,是程序设计的一种基本思维方式。

类中实例变量的类型可以是当前定义的类型,两个类之间可以互相引用

计算机程序的思维逻辑 (15) - 初识继承和多态

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

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

可以看出,super的使用与this有点像,但super和this是不同的,this引用一个对象,是实实在在存在的,可以作为函数参数,可以作为返回值,但super只是一个关键字,不能作为参数和返回值,它只是用于告诉编译器访问父类的相关变量和方法。

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

计算机程序的思维逻辑 (16) - 继承的细节

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

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

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

一个父类的变量,能不能转换为一个子类的变量,取决于这个父类变量的动态类型(即引用的对象类型)是不是这个子类或这个子类的子类。给定一个父类的变量,能不能知道它到底是不是某个子类的对象,从而安全的进行类型转换呢?答案是可以,通过instanceof关键字。instanceof前面是变量,后面是类,返回值是boolean值,表示变量引用的对象是不是该类或其子类的对象。

基类定义了表示对外行为的方法action,并定义了可以被子类重写的两个步骤step1和step2,以及被子类查看的变量currentStep,子类通过重写protected方法step1和step2来修改对外的行为。

这种思路和设计在设计模式中被称之为模板方法,action方法就是一个模板方法,它定义了实现的模板,而具体实现则由子类提供。模板方法在很多框架中有广泛的应用,这是使用protected的一个常用场景。

重写时,子类方法不能降低父类方法的可见性。为什么要这样规定呢?继承反映的是"is-a"的关系,即子类对象也属于父类,子类必须支持父类所有对外的行为,将可见性降低就会减少子类对外的行为,从而破坏"is-a"的关系,但子类可以增加父类的行为,所以提升可见性是没有问题的。 

计算机程序的思维逻辑 (17) - 继承实现的基本原理

类的加载

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

类加载过程包括:

  1. 分配内存保存类的信息
  2. 给类变量赋默认值
  3. 加载父类
  4. 设置父子关系
  5. 执行类初始化代码

需要说明的是,关于类初始化代码,是先执行父类的,再执行子类的,不过,父类执行时,子类静态变量的值也是有的,是默认值。

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

创建对象

在类加载之后,创建对象过程包括:

  1. 分配内存
  2. 对所有实例变量赋默认值
  3. 执行实例初始化代码

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

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

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

动态绑定实现的机制,就是根据对象的实际类型查找要执行的方法,子类型中找不到的时候再查找父类。

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

虚方法表

所谓虚方法表,就是在类加载的时候,为每个类创建一个表,这个表包括该类的对象所有动态绑定的方法及其地址,包括父类的方法,但一个方法只有一条记录,子类重写了父类方法后只会保留子类的。

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

变量访问

对变量的访问是静态绑定的,无论是类变量还是实例变量。

计算机程序的思维逻辑 (18) - 为什么说继承是把双刃剑

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

  • 子类可以复用父类代码,不写任何代码即可具备父类的属性和功能,而只需要增加特有的属性和行为。
  • 子类可以重写父类行为,还可以通过多态实现统一处理。
  • 给父类增加属性和行为,就可以自动给所有子类增加属性和行为。

继承破坏封装

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

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

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

如何应对继承的双面性?

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

1.避免使用继承

  • 使用final关键字
    •   给方法加final修饰符,父类就保留了随意修改这个方法内部实现的*,使用这个方法的程序也可以确保其行为是符合父类声明的。
    •   给类加final修饰符,父类就保留了随意修改这个类实现的*,使用者也可以放心的使用它,而不用担心一个父类引用的变量,实际指向的却是一个完全不符合预期行为的子类对象。
  • 优先使用组合而非继承
    •   使用组合可以抵挡父类变化对子类的影响,从而保护子类,应该被优先使用。
    •   子类就不需要关注基类是如何实现的了,基类修改实现细节,增加公开方法,也不会影响到子类了。但,组合的问题是,子类对象不能被当做基类对象,被统一处理了。解决方法是,使用接口
  • 使用接口
    •   (保留)

2.正确使用继承

  • 基类是别人写的,我们写子类。
    •   基类主要是Java API,其他框架或类库中的类,在这种情况下,我们主要通过扩展基类,实现自定义行为,这种情况下需要注意的是:
      •     重写方法不要改变预期的行为。
      •     阅读文档说明,理解可重写方法的实现机制,尤其是方法之间的调用关系。
      •     在基类修改的情况下,阅读其修改说明,相应修改子类。
  • 我们写基类,别人可能写子类。
    • 使用继承反映真正的"is-a"关系,只将真正公共的部分放到基类。
    • 对不希望被重写的公开方法添加final修饰符。
    • 写文档,说明可重写方法的实现机制,为子类提供指导,告诉子类应该如何重写。
    • 在基类修改可能影响子类时,写修改说明。
  • 基类、子类都是我们写的。

计算机程序的思维逻辑 (19) - 接口的本质

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

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

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

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

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

接口也可以继承,一个接口可以继承别的接口,继承的基本概念与类一样,但与类不同,接口可以有多个父接口。接口的继承同样使用extends关键字,多个父接口之间以逗号分隔。

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

我们说继承至少有两个好处,一个是复用代码,另一个是利用多态和动态绑定统一处理多种不同子类的对象。使用组合替代继承,可以复用代码,但不能统一处理。使用接口,针对接口编程,可以实现统一处理不同类型的对象,但接口没有代码实现,无法复用代码。将组合和接口结合起来,就既可以统一处理,也可以复用代码了,而且不用担心破坏封装。

针对接口编程是一种重要的程序思维方式,这种方式不仅可以复用代码,还可以降低耦合,提高灵活性,是分解复杂问题的一种重要工具。

计算机程序的思维逻辑 (20) - 为什么要有抽象类?

抽象类可以没有抽象方法。抽象类和具体类一样,可以定义具体方法、实例变量等,它和具体类的核心区别是,抽象类不能创建对象,要创建对象,必须使用它的具体子类。与接口类似,抽象类虽然不能使用new,但可以声明抽象类的变量,引用抽象类具体子类的对象。

引入抽象方法和抽象类,是Java提供的一种语法工具,对于一些类和方法,引导使用者正确使用它们,减少被误用。

使用抽象方法,而非空方法体,子类就知道他必须要实现该方法,而不可能忽略。

使用抽象类,类的使用者创建对象的时候,就知道他必须要使用某个具体子类,而不可能误用不完整的父类。

虽然从语法上,抽象类不是必须的,但它能使程序更为清晰,减少误用,抽象类和接口经常相互配合,接口定义能力,而抽象类提供默认实现,方便子类实现接口。

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

比如说,在Java类库中,有:

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

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

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

计算机程序的思维逻辑 (21) - 内部类的本质

一般而言,内部类与包含它的外部类有比较密切的关系,而与其他类关系不大,定义在类内部,可以实现对外部完全隐藏,可以有更好的封装性,代码实现上也往往更为简洁。

不过,内部类只是Java编译器的概念,对于Java虚拟机而言,它是不知道内部类这回事的, 每个内部类最后都会被编译为一个独立的类,生成一个独立的字节码文件。

也就是说,每个内部类其实都可以被替换为一个独立的类。当然,这是单纯就技术实现而言,内部类可以方便的访问外部类的私有变量,可以声明为private从而实现对外完全隐藏,相关代码写在一起,写法也更为简洁,这些都是内部类的好处。

在Java中,根据定义的位置和方式不同,主要有四种内部类:

  • 静态内部类
  • 成员内部类
  • 方法内部类
  • 匿名内部类

方法内部类是在一个方法内定义和使用的,匿名内部类使用范围更小,它们都不能在外部使用,成员内部类和静态内部类可以被外部使用,不过它们都可以被声明为private,这样,外部就使用不了了。

静态内部类

静态内部类与静态变量和静态方法定义的位置一样,也带有static关键字,只是它定义的是类。语法上,静态内部类除了位置放在别的类内部外,它与一个独立的类差别不大,可以有静态变量、静态方法、成员方法、成员变量、构造方法等。

静态内部类与外部类的联系也不大(与后面其他内部类相比)。它可以访问外部类的静态变量和方法,如innerMethod直接访问shared变量,但不可以访问实例变量和方法。在类内部,可以直接使用内部静态类。public静态内部类可以被外部使用,只是需要通过"外部类.静态内部类"的方式使用。

内部类访问了外部类的一个私有静态变量shared,而我们知道私有变量是不能被类外部访问的,Java的解决方法是,自动为Outer生成了一个非私有访问方法access$0或access$000 会根据不同编译器版本生成

JAVA基础 :request与response转向的区别

这种方式是在服务器端作 的重定向。服务器往client 发送数据的过程是这样的:服务器在向客户端发送数据之前,是先将数据输出到缓冲区,然后将缓冲区中数据发送给client端。什么时候将缓冲区里的数据发 送给client端呢?(1)当对来自client的request处理完,并把所有数据输出到缓冲区,(2)当缓冲区满,(3)在程序中调用缓冲区的输 出方法out.flush()或response.flushbuffer(),web container才将缓冲区中的数据发送给client。
这种重定向方式是利用服务器端的缓冲区机制,在把缓冲区的数据发送到客户端之前,原来的数据不发送,将执行转向重定向页面,发送重定向页面的数据,重定向调用页的数据将被清除。特别提示: 在<JSP:FORWORD>之前有很多输出,前面的输出已使缓冲区满,将自动输出到客户端,那么这种重定向方式将不起作用。

response.sendRedirect是向客户浏览器发送页面重定向指令,浏览器接收后将向web服务器重新发送页面请求,所以执行完后浏览器的url显示的是跳转后的页面。跳转页面可以是一个任意的url(本服务器的和其他服务器的均可)。
RequestDispatcher.forward 则是直接在服务器中进行处理,将处理完后的信息发送给浏览器进行显示,所以完成后在url中显示的是跳转前的页面。在forward的时候将上一页面中传 送的request和response信息一同发送给下一页面(而response.sendRedirect不能将上一页面的request和 response信息发送到下一页面)。由于forward是直接在服务器中进行处理,所以forward的页面只能是本服务器的。

计算机程序的思维逻辑 (22) - 代码的组织机制

声明类所在的包

语法

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

命名冲突

为避免命名冲突,Java中命名包名的一个惯例是使用域名作为前缀。没有域名的,也没关系,使用一个其他代码不太会用的包名即可。如果代码需要公开给其他人用,最好有一个域名以确保唯一性,如果只是内部使用,则确保内部没有其他代码使用该包名即可。

组织代码

包可以方便模块化开发,不同功能可以位于不同包内,不同开发人员负责不同的包。

包也可以方便封装,供外部使用的类可以放在包的上层,而内部的实现细节则可以放在比较底层的子包内。

通过包使用类

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

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就可以自动管理引入类。

包范围可见性

如果什么修饰符都不写,它的可见性范围就是同一个包内,同一个包内的其他类可以访问,而其他包内的类则不可以访问。需要说明的是,同一个包指的是同一个直接包,子包下的类并不能访问。

另外,需要说明的是protected修饰符,protected可见性包括包可见性,也就是说,声明为protected,不仅表明子类可以访问,还表明同一个包内的其他类可以访问,即使这些类不是子类也可以。

总结来说,可见性范围从小到大是:

private < 默认(包) < protected < public

jar包

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

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

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

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

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

程序的编译与连接

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

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

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

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

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

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

计算机程序的思维逻辑 (23) - 枚举的本质

所谓枚举,是一种特殊的数据,它的取值是有限的,可以枚举出来的,比如说一年就是有四季、一周有七天,虽然使用类也可以处理这种数据,但枚举类型更为简洁、安全和方便。

基本用法

枚举使用enum这个关键字来定义,值一般是大写的字母,多个值之间以逗号分隔。枚举类型可以定义为一个单独的文件,也可以定义在其他类内部。

枚举变量的toString方法返回其字面值,所有枚举类型也都有一个name()方法,返回值与toString()一样。

枚举变量可以使用equals和==进行比较,结果是一样的。

枚举值是有顺序的,可以比较大小。枚举类型都有一个方法int ordinal(),表示枚举值在声明时的顺序,从0开始。

枚举类型都实现了Java API中的Comparable接口,都可以通过方法compareTo与其他枚举值进行比较,比较其实就是比较ordinal的大小。

枚举变量可以用于和其他类型变量一样的地方,如方法参数、类变量、实例变量等,枚举还可以用于switch语句。在switch语句内部,枚举值不能带枚举类型前缀,例如,直接使用SMALL,不能使用Size.SMALL。

枚举类型都有一个静态的valueOf(String)方法,可以返回字符串对应的枚举值。

枚举类型也都有一个静态的values方法,返回一个包括所有枚举值的数组,顺序与声明时的顺序一致。

枚举的好处

Java是从JDK 5才开始支持枚举的,在此之前,一般是在类中定义静态整形变量来实现类似功能。

枚举的好处是比较明显的:

  • 定义枚举的语法更为简洁。
  • 枚举更为安全,一个枚举类型的变量,它的值要么为null,要么为枚举值之一,不可能为其他值,但使用整形变量,它的值就没有办法强制,值可能就是无效的。
  • 枚举类型自带很多便利方法(如values, valueOf, toString等),易于使用。

基本实现原理

枚举类型实际上会被Java编译器转换为一个对应的类,这个类继承了Java API中的java.lang.Enum类。

Enum类有两个实例变量name和ordinal,在构造方法中需要传递,name(), toString(), ordinal(), compareTo(), equals()方法都是由Enum类根据其实例变量name和ordinal实现的。

values和valueOf方法是编译器给每个枚举类型自动添加的。

解释几点:

  • Size是final的,不能被继承,Enum<Size>表示父类,<Size>是泛型写法。
  • Size有一个私有的构造方法,接受name和ordinal,传递给父类,私有表示不能在外部创建新的实例。
  • 三个枚举值实际上是三个静态变量,也是final的,不能被修改。
  • values方法是编译器添加的,内部有一个values数组保持所有枚举值。
  • valueOf方法调用的是父类的方法,额外传递了参数Size.class,表示类的类型信息,类型信息我们后续文章介绍,父类实际上是回过头来调用values方法,根据name对比得到对应的枚举值的。

一般枚举变量会被转换为对应的类变量,在switch语句中,枚举值会被转换为其对应的ordinal值,上面的枚举类型Size转换后的普通类的代码大概如下所示:

 
public final class Size extends Enum<Size> {
public static final Size SMALL = new Size("SMALL",0);
public static final Size MEDIUM = new Size("MEDIUM",1);
public static final Size LARGE = new Size("LARGE",2); private static Size[] VALUES =
new Size[]{SMALL,MEDIUM,LARGE}; private Size(String name, int ordinal){
super(name, ordinal);
} public static Size[] values(){
Size[] values = new Size[VALUES.length];
System.arraycopy(VALUES, 0,
values, 0, VALUES.length);
return values;
} public static Size valueOf(String name){
return Enum.valueOf(Size.class, name);
}
}

解释几点:

  • Size是final的,不能被继承,Enum<Size>表示父类,<Size>是泛型写法,我们后续文章介绍,此处可以忽略。
  • Size有一个私有的构造方法,接受name和ordinal,传递给父类,私有表示不能在外部创建新的实例。
  • 三个枚举值实际上是三个静态变量,也是final的,不能被修改。
  • values方法是编译器添加的,内部有一个values数组保持所有枚举值。
  • valueOf方法调用的是父类的方法,额外传递了参数Size.class,表示类的类型信息,类型信息我们后续文章介绍,父类实际上是回过头来调用values方法,根据name对比得到对应的枚举值的。

一般枚举变量会被转换为对应的类变量,在switch语句中,枚举值会被转换为其对应的ordinal值。

可以看出,枚举类型本质上也是类,但由于编译器自动做了很多事情,它的使用也就更为简洁、安全和方便。

典型场景

用法

以上枚举用法是最简单的,实际中枚举经常会有关联的实例变量和方法,比如说,上面的Size例子,每个枚举值可能有关联的缩写和中文名称,可能需要静态方法根据缩写返回对应的枚举值。

需要说明的是,枚举值的定义需要放在最上面,枚举值写完之后,要以分号(;)结尾,然后才能写其他代码。

每个枚举值经常有一个关联的标示(id),通常用int整数表示,使用整数可以节约存储空间,减少网络传输。一个自然的想法是使用枚举中自带的ordinal值,但ordinal并不是一个好的选择。

为什么呢?因为ordinal的值会随着枚举值在定义中的位置变化而变化,但一般来说,我们希望id值和枚举值的关系保持不变,尤其是表示枚举值的id已经保存在了很多地方的时候。所以,一般是增加一个实例变量表示id,使用实例变量的另一个好处是,id可以自己定义。

高级用法

枚举还有一些高级用法,比如说,每个枚举值可以有关联的类定义体,枚举类型可以声明抽象方法,每个枚举值中可以实现该方法,也可以重写枚举类型的其他方法。

这种写法有什么好处呢?如果每个或部分枚举值有一些特定的行为,使用这种写法比较简洁。对于这个例子,上面我们介绍了其对应的switch语句,在switch语句中根据size的值执行不同的代码。

switch的缺陷是,定义swich的代码和定义枚举类型的代码可能不在一起,如果新增了枚举值,应该需要同样修改switch代码,但可能会忘记,而如果使用抽象方法,则不可能忘记,在定义枚举值的同时,编译器会强迫同时定义相关行为代码。所以,如果行为代码和枚举值是密切相关的,使用以上写法可以更为简洁、安全、容易维护。

这种写法内部是怎么实现的呢?每个枚举值都会生成一个类,这个类继承了枚举类型对应的类,然后再加上值特定的类定义体代码,枚举值会变成这个子类的对象,具体代码我们就不赘述了。

枚举还有一些其他高级用法,比如说,枚举可以实现接口,也可以在接口中定义枚举,使用相对较少,本文就不介绍了。

计算机程序的思维逻辑 (24) - 异常 (上)

异常是相对于return的一种退出机制,可以由系统触发,也可以由程序通过throw语句触发,异常可以通过try/catch语句进行捕获并处理,如果没有捕获,则会导致程序退出并输出异常栈信息。

异常类

Throwable

所有异常类都有一个共同的父类Throwable,它有4个public构造方法:

  1. public Throwable()
  2. public Throwable(String message)
  3. public Throwable(String message, Throwable cause)
  4. public Throwable(Throwable cause)

有两个主要参数,一个是message,表示异常消息,另一个是cause,表示触发该异常的其他异常。异常可以形成一个异常链,上层的异常由底层异常触发,cause表示底层异常。

Throwable还有一个public方法用于设置cause:

Throwable initCause(Throwable cause)

Throwable的某些子类没有带cause参数的构造方法,就可以通过这个方法来设置,这个方法最多只能被调用一次。

所有构造方法中都有一句重要的函数调用:

fillInStackTrace();

它会将异常栈信息保存下来,这是我们能看到异常栈的关键。

Throwable有一些常用方法用于获取异常信息:

void printStackTrace()

打印异常栈信息到标准错误输出流,它还有两个重载的方法:

void printStackTrace(PrintStream s)
void printStackTrace(PrintWriter s)

打印栈信息到指定的流,关于PrintStream和PrintWriter我们后续文章介绍。

String getMessage()
Throwable getCause()

获取设置的异常message和cause

StackTraceElement[] getStackTrace()

获取异常栈每一层的信息,每个StackTraceElement包括文件名、类名、函数名、行号等信息。

异常类体系

Throwable是所有异常的基类,它有两个子类Error和Exception。

Error表示系统错误或资源耗尽,由Java系统自己使用,应用程序不应抛出和处理。

Exception表示应用程序错误,它有很多子类,应用程序也可以通过继承Exception或其子类创建自定义异常

RuntimeException(运行时异常)比较特殊,它的名字有点误导,因为其他异常也是运行时产生的,它表示的实际含义是unchecked exception (未受检异常),相对而言,Exception的其他子类和Exception自身则是checked exception (受检异常),Error及其子类也是unchecked exception。

checked还是unchecked,区别在于Java如何处理这两种异常,对于checked异常,Java会强制要求程序员进行处理,否则会有编译错误,而对于unchecked异常则没有这个要求。

这么多不同的异常类其实并没有比Throwable这个基类多多少属性和方法,大部分类在继承父类后只是定义了几个构造方法,这些构造方法也只是调用了父类的构造方法,并没有额外的操作。

那为什么定义这么多不同的类呢?主要是为了名字不同,异常类的名字本身就代表了异常的关键信息,无论是抛出还是捕获异常时,使用合适的名字都有助于代码的可读性和可维护性。

自定义异常

除了Java API中定义的异常类,我们也可以自己定义异常类,一般通过继承Exception或者它的某个子类,如果父类是RuntimeException或它的某个子类,则自定义异常也是unchecked exception,如果是Exception或Exception的其他子类,则自定义异常是checked exception。

计算机程序的思维逻辑 (25) - 异常 (下)

异常处理

catch匹配

异常处理机制将根据抛出的异常类型找第一个匹配的catch块,找到后,执行catch块内的代码,其他catch块就不执行了,如果没有找到,会继续到上层方法中查找。需要注意的是,抛出的异常类型是catch中声明异常的子类也算匹配,所以需要将最具体的子类放在前面,如果基类Exception放在前面,则其他更具体的catch代码将得不到执行。

重新throw

为什么要重新抛出呢?因为当前代码不能够完全处理该异常,需要调用者进一步处理。

为什么要抛出一个新的异常呢?当然是当前异常不太合适,不合适可能是信息不够,需要补充一些新信息,还可能是过于细节,不便于调用者理解和使用,如果调用者对细节感兴趣,还可以继续通过getCause()获取到原始异常。

finally

finally内的代码不管有无异常发生,都会执行。具体来说:

  • 如果没有异常发生,在try内的代码执行结束后执行。
  • 如果有异常发生且被catch捕获,在catch内的代码执行结束后执行
  • 如果有异常发生但没被捕获,则在异常被抛给上层之前执行。

由于finally的这个特点,它一般用于释放资源,如数据库连接、文件流等。

try/catch/finally语法中,catch不是必需的,也就是可以只有try/finally,表示不捕获异常,异常自动向上传递,但finally中的代码在异常发生后也执行。

finally语句有一个执行细节,如果在try或者catch语句内有return语句,则return语句在finally语句执行结束后才执行,但finally并不能改变返回值。

如果在finally中也有return语句呢?try和catch内的return会丢失,实际会返回finally中的返回值。finally中有return不仅会覆盖try和catch内的返回值,还会掩盖try和catch内的异常,就像异常没有发生一样。

finally中不仅return语句会掩盖异常,如果finally中抛出了异常,则原异常就会被掩盖。

所以,一般而言,为避免混淆,应该避免在finally中使用return语句或者抛出异常,如果调用的其他代码可能抛出异常,则应该捕获异常并进行处理。

throws

throws跟在方法的括号后面,可以声明多个异常,以逗号分隔。这个声明的含义是说,我这个方法内可能抛出这些异常,我没有进行处理,至少没有处理完,调用者必须进行处理。这个声明没有说明,具体什么情况会抛出什么异常,作为一个良好的实践,应该将这些信息用注释的方式进行说明,这样调用者才能更好的处理异常。

对于RuntimeException(unchecked exception),是不要求使用throws进行声明的,但对于checked exception,则必须进行声明,换句话说,如果没有声明,则不能抛出。

对于checked exception,不可以抛出而不声明,但可以声明抛出但实际不抛出,不抛出声明它干嘛?主要用于在父类方法中声明,父类方法内可能没有抛出,但子类重写方法后可能就抛出了,子类不能抛出父类方法中没有声明的checked exception,所以就将所有可能抛出的异常都写到父类上了。

※重写的方法能够抛出任何非强制异常,无论被重写的方法是否抛出异常。但是,重写的方法不能抛出新的强制性异常,或者比被重写方法声明的更广泛的强制性异常,反之则可以。

Checked对比Unchecked Exception

checked exception必须出现在throws语句中,调用者必须处理,Java编译器会强制这一点,而RuntimeException则没有这个要求。

为什么要有这个区分呢?我们自己定义异常的时候应该使用checked还是unchecked exception啊?对于这个问题,业界有各种各样的观点和争论,没有特别一致的结论。

一种普遍的说法是,RuntimeException(unchecked)表示编程的逻辑错误,编程时应该检查以避免这些错误,比如说像空指针异常,如果真的出现了这些异常,程序退出也是正常的,程序员应该检查程序代码的bug而不是想办法处理这种异常。Checked exception表示程序本身没问题,但由于I/O、网络、数据库等其他不可预测的错误导致的异常,调用者应该进行适当处理。

但其实编程错误也是应该进行处理的,尤其是,Java被广泛应用于服务器程序中,不能因为一个逻辑错误就使程序退出。所以,目前一种更被认同的观点是,Java中的这个区分是没有太大意义的,可以统一使用RuntimeException即unchcked exception来代替。

这个观点的基本理由是,无论是checked还是unchecked异常,无论是否出现在throws声明中,我们都应该在合适的地方以适当的方式进行处理,而不是只为了满足编译器的要求,盲目处理异常,既然都要进行处理异常,checked exception的强制声明和处理就显得啰嗦,尤其是在调用层次比较深的情况下。

其实观点本身并不太重要,更重要的是一致性,一个项目中,应该对如何使用异常达成一致,按照约定使用即可。Java中已有的异常和类库也已经在哪里,我们还是要按照他们的要求进行使用。

如何使用异常

异常不能代替正常的条件判断

真正出现异常的时候,应该抛出异常,而不是返回特殊值。异常不能假装当正常处理。

异常处理的目标

异常大概可以分为三个来源:用户、程序员、第三方。用户是指用户的输入有问题,程序员是指编程错误,第三方泛指其他情况如I/O错误、网络、数据库、第三方服务等。每种异常都应该进行适当的处理。

处理的目标可以分为报告和恢复。恢复是指通过程序自动解决问题。报告的最终对象可能是用户,即程序使用者,也可能是系统运维人员或程序员。报告的目的也是为了恢复,但这个恢复经常需要人的参与。

对用户,如果用户输入不对,可能提示用户具体哪里输入不对,如果是编程错误,可能提示用户系统错误、建议联系客服,如果是第三方连接问题,可能提示用户稍后重试。

对系统运维人员或程序员,他们一般不关心用户输入错误,而关注编程错误或第三方错误,对于这些错误,需要报告尽量完整的细节,包括异常链、异常栈等,以便尽快定位和解决问题。

对于用户输入或编程错误,一般都是难以通过程序自动解决的,第三方错误则可能可以,甚至很多时候,程序都不应该假定第三方是可靠的,应该有容错机制。比如说,某个第三方服务连接不上(比如发短信),可能的容错机制是,换另一个提供同样功能的第三方试试,还可能是,间隔一段时间进行重试,在多次失败之后再报告错误。

异常处理的一般逻辑

如果自己知道怎么处理异常,就进行处理,如果可以通过程序自动解决,就自动解决,如果异常可以被自己解决,就不需要再向上报告。

如果自己不能完全解决,就应该向上报告。如果自己有额外信息可以提供,有助于分析和解决问题,就应该提供,可以以原异常为cause重新抛出一个异常。

总有一层代码需要为异常负责,可能是知道如何处理该异常的代码,可能是面对用户的代码,也可能是主程序。如果异常不能自动解决,对于用户,应该根据异常信息提供用户能理解和对用户有帮助的信息,对运维和程序员,则应该输出详细的异常链和异常栈到日志。

这个逻辑与在公司中处理问题的逻辑是类似的,每个级别都有自己应该解决的问题,自己能处理的自己处理,不能处理的就应该报告上级,把下级告诉他的,和他自己知道的,一并告诉上级,最终,公司老板必须要为所有问题负责。每个级别既不应该掩盖问题,也不应该逃避责任。

在有了异常机制后,程序的正常逻辑与异常逻辑可以相分离,异常情况可以集中进行处理,异常还可以自动向上传递,不再需要每层方法都进行处理,异常也不再可能被自动忽略,从而,处理异常情况的代码可以大大减少,代码的可读性、可靠性、可维护性也都可以得到提高。

计算机程序的思维逻辑 (26) - 剖析包装类 (上)

包装类

Java有八种基本类型,每种基本类型都有一个对应的包装类。

包装类是什么呢?它是一个类,内部有一个实例变量,保存对应的基本类型的值,这个类一般还有一些静态方法、静态变量和实例方法,以方便对数据进行操作。

基本类型和包装类

每种包装类都有一个静态方法valueOf(),接受基本类型,返回引用类型,也都有一个实例方法xxxValue()返回对应的基本类型。

将基本类型转换为包装类的过程,一般称为"装箱",而将包装类型转换为基本类型的过程,则称为"拆箱"。装箱/拆箱写起来比较啰嗦,Java 1.5以后引入了自动装箱和拆箱技术,可以直接将基本类型赋值给引用类型,反之亦可。

自动装箱/拆箱是Java编译器提供的能力,背后,它会替换为调用对应的valueOf()/xxxValue()。

每种包装类也都有构造方法,可以通过new创建。

那到底应该用静态的valueOf方法,还是使用new呢?一般建议使用valueOf。new每次都会创建一个新对象,而valueOf除了Float和Double外的其他包装类,都会缓存包装类对象,减少需要创建对象的次数,节省空间,提升性能。

重写Object方法

equals用于判断当前对象和参数传入的对象是否相同,Object类的默认实现是比较地址,对于两个变量,只有这两个变量指向同一个对象时,equals才返回true,它和比较运算符(==)的结果是一样的。

但,equals应该反映的是对象间的逻辑相等关系,所以这个默认实现一般是不合适的,子类需要重写该实现。所有包装类都重写了该实现,实际比较用的是其包装的基本类型值

,比如说,对于Long类,其equals方法代码是:

public boolean equals(Object obj) {
if (obj instanceof Long) {
return value == ((Long)obj).longValue();
}
return false;
}

对于Float,其实现代码为:

public boolean equals(Object obj) {
return (obj instanceof Float)
&& (floatToIntBits(((Float)obj).value) == floatToIntBits(value));
}

Float有一个静态方法floatToIntBits(),将float的二进制表示看做int。需要注意的是,只有两个float的二进制表示完全一样的时候,equals才会返回true。在第5节的时候,我们提到小数计算是不精确的,数学概念上运算结果一样,但计算机运算结果可能不同。两个浮点数不一样,将二进制看做整数也不一样。

Double的equals方法与Float类似,它有一个静态方法doubleToLongBits,将double的二进制表示看做long,然后再按long比较。

hashCode

hashCode返回一个对象的哈希值,哈希值是一个int类型的数,由对象中一般不变的属性映射得来,用于快速对对象进行区分、分组等。一个对象的哈希值不能变,相同对象的哈希值必须一样。不同对象的哈希值一般应不同,但这不是必须的,可以有不同对象但哈希值相同的情况。

hashCode和equals方法联系密切,对两个对象,如果equals方法返回true,则hashCode也必须一样。反之不要求,equal返回false时,hashCode可以一样,也可以不一样,但应该尽量不一样。hashCode的默认实现一般是将对象的内存地址转换为整数,子类重写equals时,也必须重写hashCode。之所以有这个规定,是因为Java API中很多类依赖于这个行为,尤其是集合中的一些类。

包装类都重写了hashCode,根据包装的基本类型值计算hashCode,对于Byte, Short, Integer, Character,hashCode就是其内部值,代码为:

public int hashCode() {
return (int)value;
}

对于Boolean,hashCode代码为:

public int hashCode() {
return value ? 1231 : 1237;
}

根据基类类型值返回了两个不同的数,为什么选这两个值呢?它们是质数,即只能被1和自己整除的数,后续我们会讲到,质数比较好,但质数很多,为什么选这两个呢,这个就不得而知了,大概是因为程序员对它们有特殊的偏好吧。

对于Long,hashCode代码为:

public int hashCode() {
return (int)(value ^ (value >>> 32));
}

是高32位与低32位进行位异或操作。

对于Float,hashCode代码为:

public int hashCode() {
return floatToIntBits(value);
}

与equals方法类似,将float的二进制表示看做了int。

对于Double,hashCode代码为:

public int hashCode() {
long bits = doubleToLongBits(value);
return (int)(bits ^ (bits >>> 32));
}

与equals类似,将double的二进制表示看做long,然后再按long计算hashCode。

Comparable

每个包装类也都实现了Java API中的Comparable接口,Comparable接口代码如下:

public interface Comparable<T> {
public int compareTo(T o);
}

<T>是泛型语法,我们后续文章介绍,T表示比较的类型,由实现接口的类传入。接口只有一个方法compareTo,当前对象与参数对象进行比较,在小于、等于、大于参数时,应分别返回-1,0,1。

各个包装类的实现基本都是根据基本类型值进行比较,不再赘述。对于Boolean,false小于true。对于Float和Double,存在和equals一样的问题,0.01和0.1*0.1相比的结果并不为0。

包装类和String

除了Character外,每个包装类都有一个静态的valueOf(String)方法,根据字符串表示返回包装类对象。

也都有一个静态的parseXXX(String)方法,根据字符串表示返回基本类型值。

都有一个静态的toString()方法,根据基本类型值返回字符串表示。

对于整数类型,字符串表示除了默认的十进制外,还可以表示为其他进制,如二进制、八进制和十六进制,包装类有静态方法进行相互转换,比如:

System.out.println(Integer.toBinaryString(12345)); //输出2进制
System.out.println(Integer.toHexString(12345)); //输出16进制
System.out.println(Integer.parseInt("3039", 16)); //按16进制解析

常用常量

包装类中除了定义静态方法和实例方法外,还定义了一些静态变量。

Boolean类型:

public static final Boolean TRUE = new Boolean(true);
public static final Boolean FALSE = new Boolean(false);

所有数值类型都定义了MAX_VALUE和MIN_VALUE,表示能表示的最大/最小值,比如,对Integer:

public static final int   MIN_VALUE = 0x80000000;
public static final int MAX_VALUE = 0x7fffffff;

Float和Double还定义了一些特殊数值,比如正无穷、负无穷、非数值,如Double类:

public static final double POSITIVE_INFINITY = 1.0 / 0.0;
public static final double NEGATIVE_INFINITY = -1.0 / 0.0;
public static final double NaN = 0.0d / 0.0;

Number

六种数值类型包装类有一个共同的父类Number,Number是一个抽象类,它定义了如下方法:

byte byteValue()
short shortValue()
int intValue()
long longValue()
float floatValue()
double doubleValue()

通过这些方法,包装类实例可以返回任意的基本数值类型。

不可变性

包装类都是不可变类,所谓不可变就是,实例对象一旦创建,就没有办法修改了。这是通过如下方式强制实现的:

  • 所有包装类都声明为了final,不能被继承
  • 内部基本类型值是私有的,且声明为了final
  • 没有定义setter方法

为什么要定义为不可变类呢?不可变使得程序可以更为简单安全,因为不用操心数据被意外改写的可能了,可以安全的共享数据,尤其是在多线程的环境下。

计算机程序的思维逻辑 (27) - 剖析包装类 (中)

为什么要关心实现代码呢?大部分情况下,确实不用关心,我们会用它就可以了,我们主要是为了学习,尤其是其中的二进制操作,二进制是计算机的基础,但代码往往晦涩难懂,我们希望对其有一个更为清晰深刻的理解。

位翻转

用法

Integer有两个静态方法,可以按位进行翻转:

public static int reverse(int i)
public static int reverseBytes(int i)

位翻转就是将int当做二进制,左边的位与右边的位进行互换,reverse是按位进行互换,reverseBytes是按byte进行互换。

HD表示的是一本书,书名为Hacker's Delight,HD是它的缩写

高效实现位翻转的基本思路,首先交换相邻的单一位,然后以两位为一组,再交换相邻的位,接着是四位一组交换、然后是八位、十六位,十六位之后就完成了。对十进制而言,这个效率并不高,但对于二进制,却是高效的,因为二进制可以在一条指令中交换多个相邻位。

reverse代码为什么要写的这么晦涩呢?或者说不能用更容易理解的方式写吗?比如说,实现翻转,一种常见的思路是,第一个和最后一个交换,第二个和倒数第二个交换,直到中间两个交换完成。如果数据不是二进制位,这个思路是好的,但对于二进制位,这个效率比较低。

CPU指令并不能高效的操作单个位,它操作的最小数据单位一般是32位(32位机器),另外,CPU可以高效的实现移位和逻辑运算,但加减乘除则比较慢。

reverse是在充分利用CPU的这些特性,并行高效的进行相邻位的交换,也可以通过其他更容易理解的方式实现相同功能,但很难比这个代码更高效。

循环移位

用法

Integer有两个静态方法可以进行循环移位:

public static int rotateLeft(int i, int distance)
public static int rotateRight(int i, int distance)

rotateLeft是循环左移,rotateRight是循环右移,distance是移动的位数,所谓循环移位,是相对于普通的移位而言的,普通移位,比如左移2位,原来的最高两位就没有了,右边会补0,而如果是循环左移两位,则原来的最高两位会移到最右边,就像一个左右相接的环一样。

实现代码

实际的移位个数不是后面的直接数字,而是直接数字的最低5位的值,或者说是直接数字 & 0x1f的结果。之所以这样,是因为5位最大表示31,移位超过31位对int整数是无效的。

上面代码中,i>>>-distance就是 i>>>(32-distance),i<<-distance就是i<<(32-distance)。

按位查找、计数

Integer中还有其他一些位操作,包括:

public static int signum(int i)

查看符号位,正数返回1,负数返回-1,0返回0

public static int lowestOneBit(int i)

找从右边数第一个1的位置,该位保持不变,其他位设为0,返回这个整数。

public static int highestOneBit(int i) 

找从左边数第一个1的位置,该位保持不变,其他位设为0,返回这个整数。

public static int bitCount(int i)  

找二进制表示中1的个数。

public static int numberOfLeadingZeros(int i)

左边开头连续为0的个数。

public static int numberOfTrailingZeros(int i)

右边结尾连续为0的个数。

valueOf的实现

创建包装类对象时,可以使用静态的valueOf方法,也可以直接使用new,但建议使用valueOf,为什么呢?

IntegerCache表示Integer缓存,其中的cache变量是一个静态Integer数组,在静态初始化代码块中被初始化,默认情况下,保存了从-128到127,共256个整数对应的Integer对象。

在valueOf代码中,如果数值位于被缓存的范围,即默认-128到127,则直接从IntegerCache中获取已预先创建的Integer对象,只有不在缓存范围时,才通过new创建对象。

通过共享常用对象,可以节省内存空间,由于Integer是不可变的,所以缓存的对象可以安全的被共享。Boolean/Byte/Short/Long/Character都有类似的实现。这种共享常用对象的思路,是一种常见的设计思路,在<设计模式>这本著作中,它被赋予了一个名字,叫享元模式,英文叫Flyweight,即共享的轻量级元素。

计算机程序的思维逻辑 (28) - 剖析包装类 (下)

Character类除了封装了一个char外,它有很多静态方法,封装了Unicode字符级别的各种操作,是Java文本处理的基础,注意不是char级别,Unicode字符并不等同于char。

Character类在Unicode字符级别,而非char级别,封装了字符的各种操作,通过将字符处理的细节交给Character类,其他类就可以在更高的层次上处理文本了。

Unicode基础

Unicode给世界上每个字符分配了一个编号,编号范围从0x000000到0x10FFFF。编号范围在0x0000到0xFFFF之间的字符,为常用字符集,称BMP(Basic Multilingual Plane)字符。编号范围在0x10000到0x10FFFF之间的字符叫做增补字符(supplementary character)。

Java内部采用UTF-16编码,char表示一个字符,但只能表示BMP中的字符,对于增补字符,需要使用两个char表示,一个表示高代理项,一个表示低代理项。

使用int可以表示任意一个Unicode字符,低21位表示Unicode编号,高11位设为0。整数编号在Unicode中一般称为代码点(Code Point),表示一个Unicode字符,与之相对,还有一个词代码单元(Code Unit)表示一个char。

Character类中有很多相关静态方法:

检查code point和char

code point与char的转换

按code point处理char数组或序列

CharSequence是一个接口,它与一个char数组是类似的,有length方法,有charAt方法根据索引获取字符,String类就实现了该接口。

字符属性

Unicode主要是给每个字符分配了一个编号,其实,除了分配编号之外,还分配了一些属性,Character类封装了对Unicode字符属性的检查和操作。

获取字符类型(general category):

public static int getType(int codePoint)
public static int getType(char ch)

Unicode给每个字符分配了一个类型,这个类型是非常重要的,很多其他检查和操作都是基于这个类型的。

getType方法的参数可以是int类型的code point,也可以是char类型,char只能处理BMP字符,而int可以处理所有字符,Character类中很多方法都是既可以接受int,也可以接受char。返回值是int,表示类型,Character类中定义了很多静态常量表示这些类型。

检查字符是否在Unicode中被定义:

public static boolean isDefined(int codePoint) 

每个被定义的字符,其getType()返回值都不为0,如果返回值为0,表示无定义。注意与isValidCodePoint的区别,后者只要数字不大于0x10FFFF都返回true。

检查字符是否为数字:

public static boolean isDigit(int codePoint)

getType()返回值为DECIMAL_DIGIT_NUMBER的字符为数字,需要注意的是,不光字符'0','1',...'9'是数字,中文全角字符的0到9,即'0','1','9'也是数字。

检查是否为字母(Letter):

public static boolean isLetter(int codePoint)

如果getType()的返回值为下列之一,则为Letter:

UPPERCASE_LETTER
LOWERCASE_LETTER
TITLECASE_LETTER
MODIFIER_LETTER
OTHER_LETTER

除了TITLECASE_LETTER和MODIFIER_LETTER,其他我们上面已经看到过了,而这两个平时碰到的也比较少,就不介绍了。

检查是否为字母(Alphabetic)

public static boolean isAlphabetic(int codePoint)

这也是检查是否为字母,与isLetter的区别是,isLetter返回true时,isAlphabetic也必然返回true,此外,getType()值为LETTER_NUMBER时,isAlphabetic也返回true,而isLetter返回false。Letter_NUMBER中常见的字符有罗马数字字符,如:'Ⅰ','Ⅱ','Ⅲ','Ⅳ'。

检查是否为空格字符

public static boolean isSpaceChar(int codePoint)

getType()值为SPACE_SEPARATOR,LINE_SEPARATOR和PARAGRAPH_SEPARATOR时,返回true。这个方法其实并不常用,因为它只能严格匹配空格字符本身,不能匹配实际产生空格效果的字符,如tab控制键'\t'。

更常用的检查空格的方法

public static boolean isWhitespace(int codePoint) 

'\t','\n',全角空格' ',和半角空格' '的返回值都为true。

检查是否为小写字符

public static boolean isLowerCase(int codePoint) 

常见的主要就是小写英文字母a到z。

检查是否为大写字符

public static boolean isUpperCase(int codePoint)

常见的主要就是大写英文字母A到Z。

检查是否为表意象形文字

public static boolean isIdeographic(int codePoint) 

大部分中文都返回为true。

检查是否为ISO 8859-1编码中的控制字符

public static boolean isISOControl(int codePoint) 

0到31,127到159表示控制字符。

检查是否为镜像(mirrowed)字符

public static boolean isMirrored(int codePoint)

常见镜像字符有( ) { } < > [ ],都有对应的镜像。

计算机程序的思维逻辑 (29) - 剖析String

基本用法

所有字符转换为大写字符,返回新字符串,原字符串不变

public String toUpperCase()

所有字符转换为小写字符,返回新字符串,原字符串不变

public String toLowerCase()

字符串连接,返回当前字符串和参数字符串合并后的字符串,原字符串不变

public String concat(String str)

字符串替换,替换单个字符,返回新字符串,原字符串不变

public String replace(char oldChar, char newChar)

字符串替换,替换字符序列,返回新字符串,原字符串不变

public String replace(CharSequence target, CharSequence replacement) 

删掉开头和结尾的空格,返回新字符串,原字符串不变

public String trim() 

分隔字符串,返回分隔后的子字符串数组,原字符串不变

public String[] split(String regex)

走进String内部

String类内部用一个字符数组表示字符串,String有两个构造方法,可以根据char数组创建String,需要说明的是,String会根据参数新创建一个数组,并拷贝内容,而不会直接用参数中的字符数组。String中的大部分方法,内部也都是操作的这个字符数组。

String类内部用一个字符数组表示字符串,实例变量定义为:

private final char value[];

String有两个构造方法,可以根据char数组创建String

public String(char value[])
public String(char value[], int offset, int count)

String中还有一些方法,与这个char数组有关:

返回指定索引位置的char

public char charAt(int index)

返回字符串对应的char数组

public char[] toCharArray()

注意,返回的是一个拷贝后的数组,而不是原数组。

将char数组中指定范围的字符拷贝入目标数组指定位置

public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) 

按Code Point处理字符

与Character类似,String类也提供了一些方法,按Code Point对字符串进行处理。

编码转换

String内部是按UTF-16BE处理字符的,对BMP字符,使用一个char,两个字节,对于增补字符,使用两个char,四个字节。

Java使用Charset这个类表示各种编码,它有两个常用静态方法:

public static Charset defaultCharset()
public static Charset forName(String charsetName)

第一个方法返回系统的默认编码,第二方法返回给定编码名称的Charset对象。

第二方法返回给定编码名称的Charset对象,与我们在第六节介绍的编码相对应,其charset名称可以是:US-ASCII, ISO-8859-1, windows-1252, GB2312, GBK, GB18030, Big5, UTF-8。

String类提供了如下方法,返回字符串按给定编码的字节表示:

public byte[] getBytes()
public byte[] getBytes(String charsetName)
public byte[] getBytes(Charset charset)

第一个方法没有编码参数,使用系统默认编码,第二方法参数为编码名称,第三个为Charset。

String类有如下构造方法,可以根据字节和编码创建字符串,也就是说,根据给定编码的字节表示,创建Java的内部表示。

public String(byte bytes[])
public String(byte bytes[], int offset, int length)
public String(byte bytes[], int offset, int length, String charsetName)
public String(byte bytes[], int offset, int length, Charset charset)
public String(byte bytes[], String charsetName)
public String(byte bytes[], Charset charset) 除了通过String中的方法进行编码转换,Charset类中也有一些方法进行编码/解码,本节就不介绍了。重要的是认识到,Java的内部表示与各种编码是不同的,但可以相互转换。 不可变性
与包装类类似,String类也是不可变类,即对象一旦创建,就没有办法修改了。String类也声明为了final,不能被继承,内部char数组value也是final的,初始化后就不能再变了。 String类中提供了很多看似修改的方法,其实是通过创建新的String对象来实现的,原来的String对象不会被修改。

比如说,我们来看concat()方法的代码:

public String concat(String str) {
int otherLen = str.length();
if (otherLen == 0) {
return this;
}
int len = value.length;
char buf[] = Arrays.copyOf(value, len + otherLen);
str.getChars(buf, len);
return new String(buf, true);
} 通过Arrays.copyOf方法创建了一块新的字符数组,拷贝原内容,然后通过new创建了一个新的String。

与包装类类似,定义为不可变类,程序可以更为简单、安全、容易理解。但如果频繁修改字符串,而每次修改都新建一个字符串,性能太低,这时,应该考虑Java中的另两个类StringBuilder和StringBuffer。

常量字符串

Java中的字符串常量是非常特殊的,除了可以直接赋值给String变量外,它自己就像一个String类型的对象一样,可以直接调用String的各种方法。Java中的字符串常量是非常特殊的,除了可以直接赋值给String变量外,它自己就像一个String类型的对象一样,可以直接调用String的各种方法。实际上,这些常量就是String类型的对象,在内存中,它们被放在一个共享的地方,这个地方称为字符串常量池,它保存所有的常量字符串,每个常量只会保存一份,被所有使用者共享。当通过常量的形式使用一个字符串的时候,使用的就是常量池中的那个对应的String类型的对象。需要注意的是,如果不是通过常量直接赋值,而是通过new创建的,==就不会返回true了。

String类中以String为参数的构造方法代码如下:

public String(String original) {
this.value = original.value;
this.hash = original.hash;
}

hash是String类中另一个实例变量,表示缓存的hashCode值

hashCode

我们刚刚提到hash这个实例变量,它的定义如下:

private int hash; // Default to 0

它缓存了hashCode()方法的值,也就是说,第一次调用hashCode()的时候,会把结果保存在hash这个变量中,以后再调用就直接返回保存的值。

我们来看下String类的hashCode方法,代码如下:

public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;

for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}

如果缓存的hash不为0,就直接返回了,否则根据字符数组中的内容计算hash,计算方法是:

s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]

s表示字符串,s[0]表示第一个字符,n表示字符串长度,s[0]*31^(n-1)表示31的n-1次方再乘以第一个字符的值。

为什么要用这个计算方法呢?这个式子中,hash值与每个字符的值有关,每个位置乘以不同的值,hash值与每个字符的位置也有关。使用31大概是因为两个原因,一方面可以产生更分散的散列,即不同字符串hash值也一般不同,另一方面计算效率比较高,31*h与32*h-h即 (h<<5)-h等价,可以用更高效率的移位和减法操作代替乘法操作。

在Java中,普遍采用以上思路来实现hashCode。

正则表达式

String类中,有一些方法接受的不是普通的字符串参数,而是正则表达式。

Java中有专门的类如Pattern和Matcher用于正则表达式,但对于简单的情况,String类提供了更为简洁的操作,String中接受正则表达式的方法有:

分隔字符串

public String[] split(String regex) 

检查是否匹配

public boolean matches(String regex)

字符串替换

public String replaceFirst(String regex, String replacement)
public String replaceAll(String regex, String replacement)

计算机程序的思维逻辑 (30) - 剖析StringBuilder
StringBuilder和StringBuffer类,这两个类的方法基本是完全一样的,它们的实现代码也几乎一样,唯一的不同就在于,StringBuffer是线程安全的,而StringBuilder不是。
这里需要知道的就是,线程安全是有成本的,影响性能,而字符串对象及操作,大部分情况下,没有线程安全的问题,适合使用StringBuilder。 StringBuilder
基本用法

通过new新建StringBuilder,通过append添加字符串,然后通过toString获取构建完成的字符串。 基本实现原理
内部组成和构造方法

与String类似,StringBuilder类也封装了一个字符数组,定义如下:


char[] value;

与String不同,它不是final的,可以修改。另外,与String不同,字符数组中不一定所有位置都已经被使用,它有一个实例变量,表示数组中已经使用的字符个数,定义如下:


int count;

StringBuilder继承自AbstractStringBuilder,它的默认构造方法是:


public StringBuilder() {
super(16);
}

调用父类的构造方法,父类对应的构造方法是:


AbstractStringBuilder(int capacity) {
value = new char[capacity];
}

也就是说,new StringBuilder()这句代码,内部会创建一个长度为16的字符数组,count的默认值为0。

append的实现

append会直接拷贝字符到内部的字符数组中,如果字符数组长度不够,会进行扩展,实际使用的长度用count体现。具体来说,ensureCapacityInternal(count+len)会确保数组的长度足以容纳新添加的字符,str.getChars会拷贝新添加的字符到字符数组中,count+=len会增加实际使用的长度。如果字符数组的长度小于需要的长度,则调用expandCapacity进行扩展。

expandCapacity的代码是:

void expandCapacity(int minimumCapacity) {
int newCapacity = value.length * 2 + 2;
if (newCapacity - minimumCapacity < 0)
newCapacity = minimumCapacity;
if (newCapacity < 0) {
if (minimumCapacity < 0) // overflow
throw new OutOfMemoryError();
newCapacity = Integer.MAX_VALUE;
}
value = Arrays.copyOf(value, newCapacity);
}

扩展的逻辑是,分配一个足够长度的新数组,然后将原内容拷贝到这个新数组中,最后让内部的字符数组指向这个新数组,这个逻辑主要靠下面这句代码实现:

value = Arrays.copyOf(value, newCapacity);

我们主要看下newCapacity是怎么算出来的。

参数minimumCapacity表示需要的最小长度,需要多少分配多少不就行了吗?不行,因为那就跟String一样了,每append一次,都会进行一次内存分配,效率低下。这里的扩展策略,是跟当前长度相关的,当前长度乘以2,再加上2,如果这个长度不够最小需要的长度,才用minimumCapacity。
这是一种指数扩展策略。为什么要加2?大概是因为在原长度为0时也可以一样工作吧。为什么要这么扩展呢?这是一种折中策略,一方面要减少内存分配的次数,另一方面也要避免空间浪费。在不知道最终需要多长的情况下,指数扩展是一种常见的策略,广泛应用于各种内存分配相关的计算机程序中。

那如果预先就知道大概需要多长呢?可以调用StringBuilder的另外一个构造方法:

public StringBuilder(int capacity)

字符串构建完后,我们来看toString代码:

public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}

基于内部数组新建了一个String,注意,这个String构造方法不会直接用value数组,而会新建一个,以保证String的不可变性。

更多构造方法和append方法

String还有两个构造方法,分别接受String和CharSequence参数,它们的代码分别如下:

    super(str.length() + 16);
append(str);
} public StringBuilder(CharSequence seq) {
this(seq.length() + 16);
append(seq);
}

逻辑也很简单,额外多分配16个字符的空间,然后调用append将参数字符添加进来。append有多种重载形式,可以接受各种类型的参数,将它们转换为字符,添加进来。

还有一个append方法,可以添加一个Code Point:

public StringBuilder appendCodePoint(int codePoint) 

如果codePoint为BMP字符,则添加一个char,否则添加两个char。

其他修改方法

public StringBuilder insert(int offset, String str)

在指定索引offset处插入字符串str,原来的字符后移,offset为0表示在开头插,为length()表示在结尾插

来看下insert的实现代码:

    if ((offset < 0) || (offset > length()))
throw new StringIndexOutOfBoundsException(offset);
if (str == null)
str = "null";
int len = str.length();
ensureCapacityInternal(count + len);
System.arraycopy(value, offset, value, offset + len, count - offset);
str.getChars(value, offset);
count += len;
return this;
}

这个实现思路是,在确保有足够长度后,首先将原数组中offset开始的内容向后挪动n个位置,n为待插入字符串的长度,然后将待插入字符串拷贝进offset位置。

挪动位置调用了System.arraycopy方法,这是个比较常用的方法,它的声明如下:

public static native void arraycopy(Object src,  int  srcPos,
Object dest, int destPos,
int length);

将数组src中srcPos开始的length个元素拷贝到数组dest中destPos处。这个方法有个优点,即使src和dest是同一个数组,它也可以正确的处理。

arraycopy的声明有个修饰符native,表示它的实现是通过Java本地接口实现的,Java本地接口是Java提供的一种技术,用于在Java中调用非Java语言实现的代码,实际上,arraycopy是用C++语言实现的。为什么要用C++语言实现呢?因为这个功能非常常用,而C++的实现效率要远高于Java。

其他插入方法

与append类似,insert也有很多重载的方法,如下列举一二

public StringBuilder insert(int offset, double d)
public StringBuilder insert(int offset, Object obj)

删除

删除指定范围内的字符

public StringBuilder delete(int start, int end) 

其实现代码为:

    if (start < 0)
throw new StringIndexOutOfBoundsException(start);
if (end > count)
end = count;
if (start > end)
throw new StringIndexOutOfBoundsException();
int len = end - start;
if (len > 0) {
System.arraycopy(value, start+len, value, start, count-end);
count -= len;
}
return this;
}

也是通过System.arraycopy实现的,System.arraycopy被大量应用于StringBuilder的内部实现中。

删除一个字符

public StringBuilder deleteCharAt(int index)

替换

public StringBuilder replace(int start, int end, String str)

替换一个字符

public void setCharAt(int index, char ch)
 

翻转字符串

public StringBuilder reverse()

这个方法不只是简单的翻转数组中的char,对于增补字符,简单翻转后字符就无效了,这个方法能保证其字符依然有效,这是通过单独检查增补字符,进行二次翻转实现的。

长度方法

StringBuilder中有一些与长度有关的方法

确保字符数组长度不小于给定值

public void ensureCapacity(int minimumCapacity)

返回字符数组的长度

public int capacity() 

返回数组实际使用的长度

public int length()

注意capacity()方法与length()方法的的区别,capacity返回的是value数组的长度,length返回的是实际使用的字符个数,是count实例变量的值。

直接修改长度

public void setLength(int newLength)

count设为newLength,如果原count小于newLength,则多出来的字符设置默认值为'\0'。

缩减使用的空间

public void trimToSize()

减少value占用的空间,新建了一个刚好够用的空间。

与String类似的方法

查找子字符串、取子字符串、获取其中的字符或Code Point

String的+和+=运算符

Java中,String可以直接使用+和+=运算符,这是Java编译器提供的支持,背后,Java编译器会生成StringBuilder,+和+=操作会转换为append。

既然直接使用+和+=就相当于使用StringBuilder和append,那还有什么必要直接使用StringBuilder呢?在简单的情况下,确实没必要。不过,在稍微复杂的情况下,Java编译器没有那么智能,它可能会生成很多StringBuilder,尤其是在有循环的情况下。对于简单的情况,可以直接使用String的+和+=,对于复杂的情况,尤其是有循环的时候,应该直接使用StringBuilder。

计算机程序的思维逻辑 (31) - 剖析Arrays

用法

toString

Arrays的toString方法可以方便的输出一个数组的字符串形式,方便查看,它有九个重载的方法,包括八种基本类型数组和一个对象类型数组。

数组排序 - 基本类型

排序是一个非常常见的操作,同toString一样,对每种基本类型的数组,Arrays都有sort方法(boolean除外),排序按照从小到大升序排。

数组排序 - 对象类型

除了基本类型,sort还可以直接接受对象类型,但对象需要实现Comparable接口。

数组排序 - 自定义比较器

sort还有另外两个重载方法,可以接受一个比较器作为参数:

public static <T> void sort(T[] a, Comparator<? super T> c)
public static <T> void sort(T[] a, int fromIndex, int toIndex,
Comparator<? super T> c)

排序是通过比较来实现的,sort方法在排序的过程中,需要对对象进行比较的时候,就调用比较器的compare方法。

String类有一个public静态成员,表示忽略大小写的比较器:

public static final Comparator<String> CASE_INSENSITIVE_ORDER
= new CaseInsensitiveComparator();

sort默认都是从小到大排序,如果希望按照从大到小排呢?对于对象类型,可以指定一个不同的Comparator,可以用匿名内部类来实现Comparator,比如可以这样:

Arrays.sort(arr, new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return o2.compareToIgnoreCase(o1);
}
});
以上代码使用一个匿名内部类实现Comparator接口,返回o2与o1进行忽略大小写比较的结果,这样就能实现,忽略大小写,且按从大到小排序。为什么o2与o1比就逆序了呢?因为默认情况下,是o1与o2比。

Collections类中有两个静态方法,可以返回逆序的Comparator,如

public static <T> Comparator<T> reverseOrder()
public static <T> Comparator<T> reverseOrder(Comparator<T> cmp)

这样,上面字符串忽略大小写逆序排序的代码可以改为:

Arrays.sort(arr, Collections.reverseOrder(String.CASE_INSENSITIVE_ORDER));

传递比较器Comparator给sort方法,体现了程序设计中一种重要的思维方式,将不变和变化相分离,排序的基本步骤和算法是不变的,但按什么排序是变化的,sort方法将不变的算法设计为主体逻辑,而将变化的排序方式设计为参数,允许调用者动态指定,这也是一种常见的设计模式,它有一个名字,叫策略模式,不同的排序方式就是不同的策略。

二分查找

二分查找既可以针对基本类型数组,也可以针对对象数组,对对象数组,也可以传递Comparator,也都可以指定查找范围。

针对int数组

public static int binarySearch(int[] a, int key)
public static int binarySearch(int[] a, int fromIndex, int toIndex,
int key)

如果能找到,binarySearch返回找到的元素索引,如果没找到,返回一个负数,这个负数等于:-(插入点+1),插入点表示,如果在这个位置插入没找到的元素,可以保持原数组有序。

需要注意的是,binarySearch针对的必须是已排序数组,如果指定了Comparator,需要和排序时指定的Comparator保持一致,另外,如果数组中有多个匹配的元素,则返回哪一个是不确定的。

数组拷贝

与toString一样,也有多种重载形式,如:

public static long[] copyOf(long[] original, int newLength)
public static <T> T[] copyOf(T[] original, int newLength)

后面那个是泛型用法,这里表示的是,这个方法可以支持所有对象类型,参数是什么数组类型,返回结果就是什么数组类型。

newLength表示新数组的长度,如果大于原数组,则后面的元素值设为默认值。回顾一下默认值,对于数值类型,值为0,对于boolean,值为false,对于char,值为'\0',对于对象,值为null。

除了copyOf方法,Arrays中还有copyOfRange方法,以支持拷贝指定范围的元素,如:

public static int[] copyOfRange(int[] original, int from, int to)

from表示要拷贝的第一个元素的索引,新数组的长度为to-from,to可以大于原数组的长度,如果大于,与copyOf类似,多出的位置设为默认值。

数组比较

支持基本类型和对象类型,如下所示:

public static boolean equals(boolean[] a, boolean[] a2)
public static boolean equals(double[] a, double[] a2)
public static boolean equals(Object[] a, Object[] a2)

只有数组长度相同,且每个元素都相同,才返回true,否则返回false。对于对象,相同是指equals返回true。

填充值

Arrays包含很多fill方法,可以给数组中的每个元素设置一个相同的值:

public static void fill(int[] a, int val)

也可以给数组中一个给定范围的每个元素设置一个相同的值:

public static void fill(int[] a, int fromIndex, int toIndex, int val)

哈希值

针对数组,计算一个数组的哈希值:public static int hashCode(int a[])

多维数组

在创建数组时,除了第一维的长度需要指定外,其他维的长度不需要指定,甚至,第一维中,每个元素的第二维的长度可以不一样。

多维数组到底是什么呢?其实,可以认为,多维数组只是一个假象,只有一维数组,只是数组中的每个元素还可以是一个数组,这样就形成二维数组,如果其中每个元素还都是一个数组,那就是三维数组。

多维数组的操作

Arrays中的toString,equals,hashCode都有对应的针对多维数组的方法:

public static String deepToString(Object[] a)
public static boolean deepEquals(Object[] a1, Object[] a2)
public static int deepHashCode(Object a[])

这些deepXXX方法,都会判断参数中的元素是否也为数组,如果是,会递归进行操作。

实现原理

toString

toString的实现利用了StringBuilder

拷贝

copyOf和copyOfRange利用了 System.arraycopy

public static int[] copyOfRange(int[] original, int from, int to) {
int newLength = to - from;
if (newLength < 0)
throw new IllegalArgumentException(from + " > " + to);
int[] copy = new int[newLength];
System.arraycopy(original, from, copy, 0,
Math.min(original.length - from, newLength));
return copy;
}

二分查找

private static <T> int binarySearch0(T[] a, int fromIndex, int toIndex,
T key, Comparator<? super T> c) {
int low = fromIndex;
int high = toIndex - 1;

while (low <= high) {
int mid = (low + high) >>> 1;
T midVal = a[mid];
int cmp = c.compare(midVal, key);
if (cmp < 0)
low = mid + 1;
else if (cmp > 0)
high = mid - 1;
else
return mid; // key found
}
return -(low + 1); // key not found.
}

排序

Arrays的sort是如何实现的呢?

对于基本类型的数组,Java采用的算法是双枢轴快速排序(Dual-Pivot Quicksort),这个算法是Java 1.7引入的,在此之前,Java采用的算法是普通的快速排序,双枢轴快速排序是对快速排序的优化,新算法的实现代码位于类java.util.DualPivotQuicksort中。

对于对象类型,Java采用的算法是TimSort, TimSort也是在Java 1.7引入的,在此之前,Java采用的是归并排序,TimSort实际上是对归并排序的一系列优化,TimSort的实现代码位于类java.util.TimSort中。

在这些排序算法中,如果数组长度比较小,它们还会采用效率更高的插入排序。

为什么基本类型和对象类型的算法不一样呢?排序算法有一个稳定性的概念,所谓稳定性就是对值相同的元素,如果排序前和排序后,算法可以保证它们的相对顺序不变,那算法就是稳定的,否则就是不稳定的。

快速排序更快,但不稳定,而归并排序是稳定的。对于基本类型,值相同就是完全相同,所以稳定不稳定没有关系。但对于对象类型,相同只是比较结果一样,它们还是不同的对象,其他实例变量也不见得一样,稳定不稳定可能就很有关系了,所以采用归并排序。

这些算法的实现是比较复杂的,所幸的是,Java给我们提供了很好的实现,绝大多数情况下,我们会用就可以了。

更多方法

其实,Arrays中包含的数组方法是比较少的,很多常用的操作没有,比如,Arrays的binarySearch只能针对已排序数组进行查找,那没有排序的数组怎么方便查找呢?

Apache有一个开源包(http://commons.apache.org/proper/commons-lang/),里面有一个类ArrayUtils (位于包org.apache.commons.lang3),里面实现了更多的常用数组操作,这里列举一些,与Arrays类似,每个操作都有很多重载方法,我们只列举一个。

翻转数组元素

public static void reverse(final int[] array)

对于基本类型数组,Arrays的sort只能从小到大排,如果希望从大到小,可以在排序后,使用reverse进行翻转。

查找元素

//从头往后找
public static int indexOf(final int[] array, final int valueToFind) //从尾部往前找
public static int lastIndexOf(final int[] array, final int valueToFind) //检查是否包含元素
public static boolean contains(final int[] array, final int valueToFind)

删除元素

因为数组长度是固定的,删除是通过创建新数组,然后拷贝除删除元素外的其他元素来实现的。

//删除指定位置的元素
public static int[] remove(final int[] array, final int index) //删除多个指定位置的元素
public static int[] removeAll(final int[] array, final int... indices) //删除值为element的元素,只删除第一个
public static boolean[] removeElement(final boolean[] array, final boolean element)

添加元素

同删除一样,因为数组长度是固定的,添加是通过创建新数组,然后拷贝原数组内容和新元素来实现的。

//添加一个元素
public static int[] add(final int[] array, final int element) //在指定位置添加一个元素
public static int[] add(final int[] array, final int index, final int element) //合并两个数组
public static int[] addAll(final int[] array1, final int... array2)

判断数组是否是已排序的

public static boolean isSorted(int[] array)

计算机程序的思维逻辑 (32) - 剖析日期和时间

日期和时间是一个比较复杂的概念,Java API中对它的支持不是特别好,有一个第三方的类库反而特别受欢迎,这个类库是Joda-Time,Java 1.8受Joda-Time影响,重新设计了日期和时间API,新增了一个包java.time。

虽然之前的设计有一些不足,但Java API依然是被大量使用的,本节介绍Java 1.8之前API中对日期和时间的支持,下节介绍Joda-Time,Java 1.8中的新API与Joda-Time比较类似。

基本概念

所有计算机系统内部都用一个整数表示时刻,这个整数是距离格林尼治标准时间1970年1月1日0时0分0秒的毫秒数。

时刻是一个绝对时间,对时刻的解读,如年月日周时分秒等,则是相对的,与年历和时区相关。

Date表示时刻,与年月日无关,Calendar表示日历,与时区和Locale相关,可进行各种运算,是日期时间操作的主要类,DateFormat/SimpleDateFormat在Date和字符串之间进行相互转换。

Java日期和时间API

Java API中关于日期和时间,有三个主要的类:

  • Date:表示时刻,即绝对时间,与年月日无关。
  • Calendar:表示年历,Calendar是一个抽象类,其中表示公历的子类是GregorianCalendar
  • DateFormat:表示格式化,能够将日期和时间与字符串进行相互转换,DateFormat也是一个抽象类,其中最常用的子类是SimpleDateFormat。

还有两个相关的类:

  • TimeZone: 表示时区
  • Locale: 表示国家和语言

Date

Date是Java API中最早引入的关于日期的类,一开始,Date也承载了关于年历的角色,但由于不能支持国际化,其中的很多方法都已经过时了,被标记为了@Deprecated,不再建议使用。

Date表示时刻,内部主要是一个long类型的值,fastTime表示距离纪元时的毫秒数。

Date有两个构造方法:

public Date(long date) {
fastTime = date;
} public Date() {
this(System.currentTimeMillis());
} 第一个构造方法,就是根据传入的毫秒数进行初始化,第二个构造方法是默认构造方法,它根据System.currentTimeMillis()的返回值进行初始化。System.currentTimeMillis()是一个常用的方法,它返回当前时刻距离纪元时的毫秒数。

Date中的大部分方法都已经过时了,其中没有过时的主要方法有:

返回毫秒数

public long getTime() 

判断与其他Date是否相同

public boolean equals(Object obj)

主要就是比较内部的毫秒数是否相同。

与其他Date进行比较

public int compareTo(Date anotherDate)

Date实现了Comparable接口,比较也是比较内部的毫秒数,如果当前Date的毫秒数小于参数中的,返回-1,相同返回0,否则返回1。

除了compareTo,还有另外两个方法,与给定日期比较,判断是否在给定日期之前或之后,内部比较的也是毫秒数。

public boolean before(Date when)
public boolean after(Date when)

哈希值

public int hashCode()

哈希值算法与Long类似。

TimeZone

TimeZone表示时区,它是一个抽象类,有静态方法用于获取其实例。

获取当前的默认时区

TimeZone tz = TimeZone.getDefault();

System.out.println(tz.getID());

默认时区是在哪里设置的呢,可以更改吗?Java中有一个系统属性,user.timezone,保存的就是默认时区,系统属性可以通过System.getProperty获得System.out.println(System.getProperty("user.timezone"));

系统属性可以在Java启动的时候传入参数进行更改,如

java -Duser.timezone=Asia/Shanghai xxxx

TimeZone也有静态方法,可以获得任意给定时区的实例

,比如:

获取美国东部时区

TimeZone tz = TimeZone.getTimeZone("US/Eastern");

ID除了可以是名称外,还可以是GMT形式表示的时区,如:

TimeZone tz = TimeZone.getTimeZone("GMT+08:00");

国家和语言Locale

Locale表示国家和语言,它有两个主要参数,一个是国家,另一个是语言,每个参数都有一个代码,不过国家并不是必须的。

中国的大陆代码是CN,*地区的代码是TW,美国的代码是US,中文语言的代码是zh,英文是en。

Locale类中定义了一些静态变量,表示常见的Locale,比如:

  • Locale.US:表示美国英语
  • Locale.ENGLISH:表示所有英语
  • Locale.*:表示*中文
  • Locale.CHINESE:表示所有中文
  • Locale.SIMPLIFIED_CHINESE:表示大陆中文

与TimeZone类似,Locale也有静态方法获取默认值,如:

Locale locale = Locale.getDefault();
System.out.println(locale.toString());

在我的电脑上,输出为:

zh_CN

Calendar

Calendar类是日期和时间操作中的主要类,它表示与TimeZone和Locale相关的日历信息,可以进行各种相关的运算。

内部组成

与Date类似,Calendar内部也有一个表示时刻的毫秒数,定义为:

protected long  time;

除此之外,Calendar内部还有一个数组,表示日历中各个字段的值,定义为:

protected int   fields[];

这个数组的长度为17,保存一个日期中各个字段的值,都有哪些字段呢?Calendar类中定义了一些静态变量,表示这些字段,主要有:

  • Calendar.YEAR:表示年
  • Calendar.MONTH:表示月,一月份是0,Calendar同样定义了表示各个月份的静态变量,如Calendar.JULY表示7月。
  • Calendar.DAY_OF_MONTH:表示日,每月的第一天是1。
  • Calendar.HOUR_OF_DAY:表示小时,从0到23。
  • Calendar.MINUTE:表示分钟,0到59。
  • Calendar.SECOND:表示秒,0到59。
  • Calendar.MILLISECOND:表示毫秒,0到999。
  • Calendar.DAY_OF_WEEK:表示星期几,周日是1,周一是2,周六是7,Calenar同样定义了表示各个星期的静态变量,如Calendar.SUNDAY表示周日。

获取Calendar实例

Calendar是抽象类,不能直接创建对象,它提供了四个静态方法,可以获取Calendar实例,分别为:

public static Calendar getInstance()
public static Calendar getInstance(Locale aLocale)
public static Calendar getInstance(TimeZone zone)
public static Calendar getInstance(TimeZone zone, Locale aLocale)

最终调用的方法都是需要TimeZone和Locale的,如果没有,则会使用上面介绍的默认值。getInstance方法会根据TimeZone和Locale创建对应的Calendar子类对象,在中文系统中,子类一般是表示公历的GregorianCalendar。

getInstance方法封装了Calendar对象创建的细节,TimeZone和Locale不同,具体的子类可能不同,但都是Calendar,这种隐藏对象创建细节的方式,是计算机程序中一种常见的设计模式,它有一个名字,叫工厂方法,getInstance就是一个工厂方法,它生产对象。

获取日历信息

与new Date()类似,新创建的Calendar对象表示的也是当前时间,与Date不同的是,Calendar对象可以方便的获取年月日等日历信息。内部,Calendar会将表示时刻的毫秒数,按照TimeZone和Locale对应的年历,计算各个日历字段的值,存放在fields数组中,Calendar.get方法获取的就是fields数组中对应字段的值。 

设置和修改时间

Calendar支持根据Date或毫秒数设置时间:

public final void setTime(Date date)
public void setTimeInMillis(long millis)

也支持根据年月日等日历字段设置时间:

public final void set(int year, int month, int date)
public final void set(int year, int month, int date, int hourOfDay, int minute)
public final void set(int year, int month, int date, int hourOfDay, int minute, int second)
public void set(int field, int value)

除了直接设置,Calendar支持根据字段增加和减少时间:

public void add(int field, int amount)

amount为正数表示增加,负数表示减少。

Calendar的这些方法中一个比较方便和强大的地方在于,它能够自动调整相关的字段。

内部,根据字段设置或修改时间时,Calendar会更新fields数组对应字段的值,但一般不会立即更新其他相关字段或内部的毫秒数的值,不过在获取时间或字段值的时候,Calendar会重新计算并更新相关字段。

简单总结下,Calenar做了一项非常繁琐的工作,根据TimeZone和Locale,在绝对时间毫秒数和日历字段之间自动进行转换,且对不同日历字段的修改进行自动同步更新。

除了add,Calendar还有一个类似的方法:

public void roll(int field, int amount)

与add的区别是,这个方法不影响时间范围更大的字段值。比如说:

Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.HOUR_OF_DAY, 13);
calendar.set(Calendar.MINUTE, 59);
calendar.add(Calendar.MINUTE, 3);

calendar首先设置为13:59,然后分钟字段加3,执行后的calendar时间为14:02。如果add改为roll,即:

calendar.roll(Calendar.MINUTE, 3);

则执行后的calendar时间会变为13:02,在分钟字段上执行roll不会改变小时的值。

转换为Date或毫秒数

转换为Date或毫秒数

Calendar可以方便的转换为Date或毫秒数,方法是:

public final Date getTime()
public long getTimeInMillis()

Calendar的比较

与Date类似,Calendar之间也可以进行比较,也实现了Comparable接口,相关方法有:

public boolean equals(Object obj)
public int compareTo(Calendar anotherCalendar)
public boolean after(Object when)
public boolean before(Object when)

DateFormat

DateFormat类主要在Date和字符串表示之间进行相互转换,它有两个主要的方法:

public final String format(Date date)
public Date parse(String source)

format将Date转换为字符串,parse将字符串转换为Date。

Date的字符串表示与TimeZone和Locale都是相关的,除此之外,还与两个格式化风格有关,一个是日期的格式化风格,另一个是时间的格式化风格。

DateFormat定义了四个静态变量,表示四种风格,SHORT、MEDIUM、LONG和FULL,还定义了一个静态变量DEFAULT,表示默认风格,值为MEDIUM,不同风格输出的信息详细程度不同。

与Calendar类似,DateFormat也是抽象类,也用工厂模式创建对象,提供了多个静态方法创建DateFormat对象,有三类方法:

public final static DateFormat getDateTimeInstance()
public final static DateFormat getDateInstance()
public final static DateFormat getTimeInstance()

getDateTimeInstance既处理日期也处理时间,getDateInstance只处理日期,getTimeInstance只处理时间

每类工厂方法都有两个重载的方法,接受日期和时间风格以及Locale作为参数:

DateFormat getDateTimeInstance(int dateStyle, int timeStyle)
DateFormat getDateTimeInstance(int dateStyle, int timeStyle, Locale aLocale)

DateFormat的工厂方法里,我们没看到TimeZone参数,不过,DateFormat提供了一个setter方法,可以设置TimeZone:

public void setTimeZone(TimeZone zone)

DateFormat虽然比较方便,但如果我们要对字符串格式有更精确的控制,应该使用SimpleDateFormat这个类。

SimpleDateFormat

SimpleDateFormat是DateFormat的子类,相比DateFormat,它的一个主要不同是,它可以接受一个自定义的模式(pattern)作为参数,这个模式规定了Date的字符串形式。

SimpleDateFormat有个构造方法,可以接受一个pattern作为参数.

pattern中的英文字符a-z和A-Z表示特殊含义,其他字符原样输出,这里:

  • yyyy:表示四位的年
  • MM:表示月,两位数表示
  • dd:表示日,两位数表示
  • HH:表示24小时制的小时数,两位数表示
  • mm:表示分钟,两位数表示
  • ss:表示秒,两位数表示
  • E:表示星期几

这里需要特意提醒一下,hh也表示小时数,但表示的是12小时制的小时数,而a表示的是上午还是下午。更多的特殊含义可以参看SimpleDateFormat的Java文档。如果想原样输出英文字符,可以用单引号括起来。

除了将Date转换为字符串,SimpleDateFormat也可以方便的将字符转化为Date,需要注意的是,parse会抛出一个受检异常(checked exception),异常类型为ParseException,调用者必须进行处理。

局限性

Date中的过时方法

Calendar操作比较啰嗦臃肿

DateFormat的线程安全性

DateFormat/SimpleDateFormat不是线程安全的,

多个线程同时使用一个DateFormat实例的时候,会有问题,因为DateFormat内部使用了一个Calendar实例对象,多线程同时调用的时候,这个Calendar实例的状态可能就会紊乱。

解决这个问题大概有以下方案:

  • 每次使用DateFormat都新建一个对象
  • 使用线程同步
  • 使用ThreadLocal
  • 使用Joda-Time,Joda-Time是线程安全的

计算机程序的思维逻辑 (33) - Joda-Time

Joda-Time的官网是http://www.joda.org/joda-time/。

与Date/Calendar的设计有一个很大的不同,Joda-Time中的主要类都被设计为了不可变类,我们之前介绍过不可变类,包装类/String都是不可变类,不可变类有一个很大的优点,那就是简单、线程安全,所有看似的修改操作都是通过创建新对象来实现的。

与Calendar不同,DateTime为每个日历字段都提供了单独的方法,取值的范围也都是符合常识的,易于理解和使用。

参考:Calendar.get(feilds)。

Calendar.DAY_OF_WEEK:表示星期几,周日是1,周一是2,周六是7,Calenar同样定义了表示各个星期的静态变量,如Calendar.SUNDAY表示周日。 

Java API中,格式化必须使用一个DateFormat对象,而Joda-Time中,DateTime自己就有一个toString方法,可以接受一个pattern参数。Joda-Time也有与DateFormat类似的类。DateTimeFormatter是具体的格式化类,提供了print方法将DateTime转换为字符串。DateTimeFormat是一个工厂类,专门生成具体的格式化类,除了forPattern方法,它还有一些别的工厂方法。

程序设计的一个基本思维是关注点分离,程序一般总是比较复杂的,涉及方方面面,解决的思路就是分解,将复杂的事情尽量分解为不同的方面,或者说关注点,各个关注点之间耦合度要尽量低。

具体来说,对应到Java,每个类应该只关注一点。上面的例子中,因为生成DateTimeFormatter的方式比较多,就将生成DateTimeFormatter这个事单独拿了出来,就有了工厂类DateTimeFormat,只关注生产DateTimeFormatter,Joda-Time中还有别的工厂类,比如ISODateTimeFormat,工厂类是一种常见的设计模式。

与上节介绍的格式化类不同,Joda-Time的DateTimeFormatter是线程安全的,可以安全的被多个线程共享。

上节介绍Calendar时提到,修改时期和时间有两种方式,一种是直接设置绝对值,另一种是在现有值的基础上进行相对增减操作,DateTime也支持这两种方式。

不过,需要注意的是,DateTime是不可变类,修改操作是通过创建并返回新对象来实现的,原对象本身不会变。

DateTime有很多withXXX方法来设置绝对时间。DateTime中非常方便的一点是,方法的返回值是修改后的DateTime对象,可以接着进行下一个方法调用,这样,代码就非常简洁,也非常容易阅读,这种一种流行的设计风格,称为流畅接口 (Fluent Interface),相比之下,使用Calendar,就必须要写多行代码,比较臃肿。

参考:Calendar

public final void set(int year, int month, int date)

public final void set(int year, int month, int date, int hourOfDay, int minute)

public final void set(int year, int month, int date, int hourOfDay, int minute, int second)

public void set(int field, int value)

DateTime有很多plusXXX和minusXXX方法,用于相对增加和减少时间。

withMillisOfDay直接设置当天毫秒信息,会同时将时分秒等信息进行修改。

millisOfDay()的返回值比较特别,它是一个属性,具体类为DateTime的一个内部类Property,这个属性代表当天毫秒信息,这个属性有一些方法,可以接着对日期进行修改,withMaximumValue就是将该属性的值设为最大值。

JDK API中没有关于时间段计算的类,而Joda-Time包含丰富的表示时间段和用于时间段计算的方法

Joda-Time有一个类,Period,表示按日历信息的时间段,

只要给定起止时间,Period就可以自动计算出来,两个时间之间有多少月、多少天、多少小时等。

如果只关心一共有多少天,或者一共有多少周呢?Joda-Time有专门的类,比如Years用于年,Days用于日,Minutes用于分钟

我们一直在用DateTime表示完整的日期和时间,但在年龄的例子中,只需要关心日期,在迟到的例子中,只需要关心时间,Joda-Time分别有单独的日期类LocalDate和时间类LocalTime。

LocalDate和LocalTime可以与DateTime进行相互转换,比如:

DateTime dt = new DateTime(1990,11,20,12,30);
LocalDate date = dt.toLocalDate();
LocalTime time = dt.toLocalTime();
DateTime newDt = DateTime.now().withDate(date).withTime(time);

Joda-Time中的类可以方便的与JDK中的类进行相互转换。

JDK -> Joda

Date、Calendar可以方便的转换为DateTime对象:

DateTime dt = new DateTime(new Date());
DateTime dt2 = new DateTime(Calendar.getInstance());

也可以方便的转换为LocalDate和LocalTime对象:

LocalDate.fromDateFields(new Date());
LocalDate.fromCalendarFields(Calendar.getInstance());
LocalTime.fromDateFields(new Date());
LocalTime.fromCalendarFields(Calendar.getInstance());

Joda -> JDK

DateTime对象也可以方便的转换为JDK对象:

DateTime dt = new DateTime();
Date date = dt.toDate();
Calendar calendar = dt.toCalendar(Locale.CHINA);

LocalDate也可以转换为Date对象:

LocalDate localDate = new LocalDate(2016,8,18);
Date date = localDate.toDate();

Joda-Time之所以易用的一些设计思维,比如,关注点分离,为方便操作,提供单独的功能明确的类和方法,设计API为流畅接口,设计为不可变类,使用工厂类等。