面向对象编程

面向对象编程

在本章中,我们将讨论:

  • 面向对象的基本概念,包括:
    • 实例
    • 方法
  • 面向对象的实现方式,包括:
    • 继承
    • 多态
  • Java语言本身提供的机制,包括:
    • package
    • classpath
    • jar
  • 以及Java标准库提供的核心类,包括:
    • 字符串
    • 包装类型
    • JavaBean
    • 枚举
    • 常用工具类

面向对象基础

一个Java源文件可以包含多个类的定义,但只能定义一个public类,且public类名必须与文件名一致。如果要定义多个public类,必须拆到多个Java源文件中
定义class就是定义了一种数据类型,对应的instance是这种数据类型的实例;通过new操作符创建新的instance,然后用变量指向它,即可通过变量来引用这个instance指向instance的变量都是引用变量

方法

直接操作field,容易造成逻辑混乱。为了避免外部代码直接去访问field,我们可以用private修饰field,拒绝外部访问
image
把field从public改成private,外部代码不能访问这些field,那我们定义这些field有什么用?怎么才能给它赋值?怎么才能读取它的值?所以我们需要使用方法(method)来让外部代码可以间接修改field,虽然外部代码不能直接修改private字段,但是,外部代码可以调用方法setName()和setAge()来间接修改private字段。在方法内部,我们就有机会检查参数对不对。比如,setAge()就会检查传入的参数,参数超出了范围,直接报错。这样,外部代码就没有任何机会把age设置成不合理的值。
所以,一个类通过定义方法,就可以给外部代码暴露一些操作的接口,同时,内部自己保证逻辑一致性

有public方法,自然就有private方法。和private字段一样,private方法不允许外部调用,那我们定义private方法有什么用?定义private方法的理由是内部方法是可以调用private方法的

this变量:
在方法内部,可以使用一个隐含的变量this,它始终指向当前实例。因此,通过this.field就可以访问当前实例的字段。如果没有命名冲突,可以省略this。image
但是,如果有局部变量和字段重名,那么局部变量优先级更高,就必须加上this:
image
可变参数:
可变参数用类型...定义,可变参数相当于数组类型
image
上面的setNames()就定义了一个可变参数。调用时,可以这么写:
image
完全可以把可变参数改写为String[]类型:
image
但是,调用方需要自己先构造String[],比较麻烦。例如:
image
另一个问题是,调用方可以传入null:
image
可变参数可以保证无法传入null,因为传入0个参数时,接收到的实际值是一个空数组而不是null

参数绑定:

  • 基本类型参数的传递,是调用方值的复制。双方各自的后续修改,互不影响。
  • 引用类型参数的传递,调用方的变量,和接收方的参数变量,指向的是同一个对象。双方任意一方对这个对象的修改,都会影响对方(因为指向同一个对象嘛

构造方法

创建实例的时候,实际上是通过构造方法来初始化实例的。由于构造方法是如此特殊,所以构造方法的名称就是类名。构造方法的参数没有限制,在方法内部,也可以编写任意语句。但是,和普通方法相比,构造方法没有返回值(也没有void),调用构造方法,必须用new操作符。
默认构造方法:
如果一个类没有定义构造方法,编译器会自动为我们生成一个默认构造方法,它没有参数,也没有执行语句,类似这样:
image
要特别注意的是,如果我们自定义了一个构造方法,那么,编译器就不再自动创建默认构造方法.
image
如果既要能使用带参数的构造方法,又想保留不带参数的构造方法,那么只能把两个构造方法都定义出来,
没有在构造方法中初始化字段时,引用类型的字段默认是null,数值类型的字段用默认值,int类型默认值是0,布尔类型默认值是false.
也可以对字段直接进行初始化
image
那么问题来了:既对字段进行初始化,又在构造方法中对字段进行初始化:
image
当我们创建对象的时候,new Person("Xiao Ming", 12)得到的对象实例,字段的初始值是啥?
在Java中,创建对象实例的时候,按照如下顺序进行初始化

  • 初始化字段,例如,int age = 10;表示字段初始化为10,double salary;表示字段默认初始化为0,String name;表示引用类型字段默认初始化为null;
  • 执行构造方法的代码进行初始化。

因此,构造方法的代码由于后运行,所以,new Person("Xiao Ming", 12)的字段值最终由构造方法的代码确定。

多构造方法:
可以定义多个构造方法,在通过new操作符调用的时候,编译器通过构造方法的参数数量、位置和类型自动区分,一个构造方法可以调用其他构造方法,这样做的目的是便于代码复用。调用其他构造方法的语法是this(…)
image

方法重载

这种方法名相同,但各自的参数不同,称为方法重载(Overload)。注意:方法重载的返回值类型通常都是相同的
方法重载的目的是,功能类似的方法使用同一名字,更容易记住,因此,调用起来更简单
举个例子,String类提供了多个重载方法indexOf(),可以查找子串

  • int indexOf(int ch):根据字符的Unicode码查找;
  • int indexOf(String str):根据字符串查找;
  • int indexOf(int ch, int fromIndex):根据字符查找,但指定起始位置;
  • int indexOf(String str, int fromIndex): 根据字符串查找,但指定起始位置。

试一试:
image

继承

继承是面向对象编程中非常强大的一种机制它首先可以复用代码。当我们让Student从Person继承时,Student就获得了Person的所有功能,我们只需要为Student编写新增的功能。
Java使用extends关键字来实现继承:
image
注意:子类自动获得了父类的所有字段,严禁定义与父类重名的字段
在OOP的术语中,我们把Person称为超类(super class),父类(parent class),基类(base class),把Student称为子类(subclass),扩展类(extended class)。
继承树:
在Java中,没有明确写extends的类,编译器会自动加上extends Object。所以,任何类,除了Object,都会继承自某个类。下图是Person、Student的继承树:
image
Java只允许一个class继承自一个类,因此,一个类有且仅有一个父类。只有Object特殊,它没有父类。

protected:
继承有个特点,就是子类无法访问父类的private字段或者private方法。例如,Student类就无法访问Person类的name和age字段:
image
这使得继承的作用被削弱了。为了让子类可以访问父类的字段,我们需要把private改为protected。用protected修饰的字段可以被子类访问
image
因此,protected关键字可以把字段和方法的访问权限控制在继承树内部,一个protected字段和方法可以被其子类,以及子类的子类所访问,后面我们还会详细讲解。
super:
super关键字表示父类(超类)。子类引用父类的字段时,可以用super.fieldName。
image
实际上,这里使用super.name,或者this.name,或者name,效果都是一样的。编译器会自动定位到父类的name字段。但是,在某些时候,就必须使用super。我们来看一个例子:
image
运行上面的代码,会得到一个编译错误,大意是在Student的构造方法中,无法调用Person的构造方法。
这是因为在Java中,任何class的构造方法,第一行语句必须是调用父类的构造方法。如果没有明确地调用父类的构造方法,编译器会帮我们自动加一句super();,所以,Student类的构造方法实际上是这样:
image
但是,Person类并没有无参数的构造方法,因此,编译失败。
解决方法是调用Person类存在的某个构造方法。例如:
image
这样就可以正常编译了!
因此我们得出结论:

  • 如果父类没有默认的构造方法,子类就必须显式调用super()并给出参数以便让编译器定位到父类的一个合适的构造方法
  • 子类不会继承任何父类的构造方法。子类默认的构造方法是编译器自动生成的,不是继承的。

阻止继承:
正常情况下,只要某个class没有final修饰符,那么任何类都可以从该class继承。从Java 15开始,允许使用sealed修饰class,并通过permits明确写出能够从该class继承的子类名称
image
上述Shape类就是一个sealed类,它只允许指定的3个类继承它。
sealed类在Java 15中目前是预览状态要启用它,必须使用参数--enable-preview和--source 15

向上转型:
这种把一个子类类型安全地变为父类类型的赋值,被称为向上转型(upcasting)。向上转型实际上是把一个子类型安全地变为更加抽象的父类型:
image
向下转型:
和向上转型相反,如果把一个父类类型强制转型为子类类型,就是向下转型(downcasting)。例如:
image
如果测试上面的代码,可以发现:
Person类型p1实际指向Student实例,Person类型变量p2实际指向Person实例。在向下转型的时候,把p1转型为Student会成功,因为p1确实指向Student实例,把p2转型为Student会失败,因为p2的实际类型是Person,不能把父类变为子类,因为子类功能比父类多,多的功能无法凭空变出来
因此,向下转型很可能会失败。失败的时候,Java虚拟机会报ClassCastException
为了避免向下转型出错,Java提供了instanceof操作符,可以先判断一个实例究竟是不是某种类型
image
instanceof实际上判断一个变量所指向的实例是否是指定类型,或者这个类型的子类。如果一个引用变量为null,那么对任何instanceof的判断都为false
利用instanceof,在向下转型前可以先判断
image
从Java 14开始,判断instanceof后,可以直接转型为指定变量,避免再次强制转型。例如,对于以下代码:
image
可以改写如下:
image
这种使用instanceof的写法更加简洁。
区分继承和组合:
在使用继承时,我们要注意逻辑一致性
考察下面的Book类:
image
这个Book类也有name字段,那么,我们能不能让Student继承自Book呢?
image
显然,从逻辑上讲,这是不合理的,Student不应该从Book继承,而应该从Person继承。
究其原因,是因为StudentPerson的一种,它们是is关系,而Student并不是Book。实际上Student和Book的关系是has关系
具有has关系不应该使用继承,而是使用组合,即Student可以持有一个Book实例
image
因此,继承是is关系,组合是has关系

多态

在继承关系中子类如果定义了一个与父类方法签名完全相同的方法,被称为覆写(Override)
Override和Overload不同的是,

  • 如果方法签名不同,就是Overload,Overload方法是一个新方法
  • 如果方法签名相同,并且返回值也相同,就是Override。

注意:方法名相同,方法参数相同,但方法返回值不同,也是不同的方法。在Java程序中,出现这种情况,编译器会报错
image
image
Duplicate method f(int) in type Main
加上@Override可以让编译器帮助检查是否进行了正确的覆写。希望进行覆写,但是不小心写错了方法签名,编译器会报错
image
但是@Override不是必需的

引用变量的声明类型可能与其实际类型不符,例如:Person p = new Student();
Java的实例方法调用基于运行时的实际类型的动态调用,而非变量的声明类型。这个非常重要的特性在面向对象编程中称之为多态。它的英文拼写非常复杂:Polymorphic
多态是指,针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法。例如:
image
所以,多态的特性就是,运行期才能动态决定调用的子类方法。对某个类型调用某个方法,执行的实际方法可能是某个子类的覆写方法
可见,多态具有一个非常强大的功能,就是允许添加更多类型的子类实现功能扩展,却不需要修改基于父类的代码

覆写Object方法:
因为所有的class最终都继承自Object,而Object定义了几个重要的方法:

  • toString():把instance输出为String;
  • equals():判断两个instance是否逻辑相等;
  • hashCode():计算一个instance的哈希值。

在必要的情况下,我们可以覆写Object的这几个方法。例如:
image

调用super:
在子类的覆写方法中,如果要调用父类的被覆写的方法,可以通过super来调用。例如:
image

final:
继承可以允许子类覆写父类的方法。如果一个父类不允许子类对它的某个方法进行覆写,可以把该方法标记为final。用final修饰的方法不能被Override
image
如果一个类不希望任何其他类继承自它,那么可以把这个类本身标记为final。用final修饰的类不能被继承
image
对于一个类的实例字段,同样可以用final修饰。用final修饰的字段在初始化后不能被修改。可以在构造方法中初始化final字段
image
这种方法更为常用,因为可以保证实例一旦创建,其final字段就不可修改。

小结:

  • 子类可以覆写父类的方法(Override),覆写在子类中改变了父类方法的行为;
  • Java的方法调用总是作用于运行期对象的实际类型,这种行为称为多态;
  • final修饰符有多种作用:
    • final修饰的方法可以阻止被覆写;
    • final修饰的class可以阻止被继承;
    • final修饰的field必须在创建对象时初始化,随后不可修改。

抽象类

  • 如果父类Person的run()方法没有实际意义,能否去掉方法的执行语句?
    image
    答案是不行,会导致编译错误,因为定义方法的时候,必须实现方法的语句
  • 能不能去掉父类的run()方法?答案还是不行,因为去掉父类的run()方法,就失去了多态的特性。例如,runTwice()就无法编译:
    image

如果父类的方法本身不需要实现任何功能,仅仅是为了定义方法签名,目的是让子类去覆写它,那么,可以把父类的方法声明为抽象方法
image
把一个方法声明为abstract,表示它是一个抽象方法,本身没有实现任何方法语句。因为这个抽象方法本身是无法执行的,所以,Person类也无法被实例化。编译器会告诉我们,无法编译Person类,因为它包含抽象方法。必须把Person类本身也声明为abstract,才能正确编译它.
因为无法执行抽象方法,因此这个类也必须申明为抽象类(abstract class)。

无法实例化的抽象类有什么用?
因为抽象类本身被设计成只能用于被继承,因此,抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错。因此,抽象方法实际上相当于定义了“规范”
这种尽量引用高层类型,避免引用实际子类型的方式,称之为面向抽象编程
面向抽象编程的本质就是:

  • 上层代码只定义规范(例如:abstract class Person);
  • 不需要子类就可以实现业务逻辑(正常编译)
  • 具体的业务逻辑由不同的子类实现,调用者并不关心

小结:

  • 通过abstract定义的方法是抽象方法它只有定义,没有实现抽象方法定义了子类必须实现的接口规范
  • 定义了抽象方法的class必须被定义为抽象类从抽象类继承的子类必须实现抽象方法
  • 如果不实现抽象方法,则该子类仍是一个抽象类
  • 面向抽象编程使得调用者只关心抽象方法的定义,不关心子类的具体实现。

接口

在抽象类中,抽象方法本质上是定义接口规范:即规定高层类的接口从而保证所有子类都有相同的接口实现,这样,多态就能发挥出威力
如果一个抽象类没有字段,所有方法全部都是抽象方法
image
就可以把该抽象类改写为接口:interface。
在Java中,使用interface可以声明一个接口:
image
所谓interface,就是比抽象类还要抽象的纯抽象接口,因为它连字段都不能有。因为接口定义的所有方法默认都是public abstract的,所以这两个修饰符不需要写出来(写不写效果都一样)
当一个具体的class去实现一个interface时,需要使用implements关键字。举个例子:
image
我们知道,在Java中,一个类只能继承自另一个类,不能从多个类继承。但是,一个类可以实现多个interface,例如:image
Java的接口特指interface的定义,表示一个接口类型和一组方法签名,而编程接口泛指接口规范,如方法签名,数据格式,网络协议等。
抽象类和接口的对比如下:
image
接口继承:
一个interface可以继承自另一个interface。interface继承自interface使用extends,它相当于扩展了接口的方法。例如:image
此时,Person接口继承自Hello接口,因此,Person接口现在实际上有3个抽象方法签名,其中一个来自继承的Hello接口。
继承关系:
合理设计interface和abstract class的继承关系,可以充分复用代码。一般来说,公共逻辑适合放在abstract class中,具体逻辑放到各个子类,而接口层次代表抽象程度。可以参考Java的集合类定义的一组接口、抽象类以及具体子类的继承关系:
image
在使用的时候,实例化的对象永远只能是某个具体的子类,但总是通过接口去引用它,因为接口比抽象类更抽象
image
default方法:
在接口中,可以定义default方法。例如,把Person接口的run()方法改为default方法:
image

  • 实现类可以不必覆写default方法default方法的目的是,当我们需要给接口新增一个方法时,会涉及到修改全部子类。如果新增的是default方法,那么子类就不必全部修改,只需要在需要覆写的地方去覆写新增方法
  • default方法和抽象类的普通方法是有所不同的。因为interface没有字段,default方法无法访问字段,而抽象类的普通方法可以访问实例字段

小结:

  • Java的接口(interface)定义了纯抽象规范,一个类可以实现多个接口;
  • 接口也是数据类型,适用于向上转型和向下转型;
  • 接口的所有方法都是抽象方法,接口不能定义实例字段
  • 接口可以定义default方法(JDK>=1.8)

底下评论

  • (廖雪峰):default方法主要目的是怕接口增加新方法实现类挂了,比如jdbc接口升级了,老驱动就会挂,有default方法就可以给老版本续命.
  • 我想说的是Java8之后,抽象类与接口已经基本没区别了,唯一的区别是接口不能定义实例字段。感觉Java语言设计之初,对增加接口考虑的不全面,本来是想搞个抽象类的精简版,但后面又发现不行,又往里加东西,现在这两者其实没多少区别了。还不如在一开始就别增加接口这个概念,就让Java支持多继承,就像Python那样,我个人感觉挺好的。我理解Java的接口对应Python的Mixin类(我叫它插件类)。就我所知,Java接口承担两个作用:Mixin类,支持多继承(即implements多个接口)和实现函数,即函数接口。
    对比Java和Python,Python没增加接口这个概念,而是支持多继承;另外Python定义了函数概念。
    Python的多继承灵活性高些,但对开发者也有要求,如果开发者定义类使用了多继承,但不是Mixin模式,那类可读性会比较差,相当于类有多个主干,有点乱。Java的继承树强制要求有主干,有插件,结构更清晰简单,在语言层面解决了一个类有多个主干的问题。不过接口类设计的跟抽象类太像了,又有点不伦不类。
    对比两者,我更喜欢Python的实现方式。一家之言

静态字段和静态方法

在一个class中定义的字段,

  • 我们称之为实例字段。实例字段的特点是,每个实例都有独立的字段,各个实例的同名字段互不影响。
  • 还有一种字段,是用static修饰的字段,称为静态字段:static field。

image
实例字段在每个实例中都有自己的一个独立“空间”,但是静态字段只有一个共享“空间”,所有实例都会共享该字段
对于静态字段,无论修改哪个实例的静态字段,效果都是一样的:所有实例的静态字段都被修改了,原因是静态字段并不属于实例.虽然实例可以访问静态字段,但是它们指向的其实都是Person class的静态字段。所以,所有实例共享一个静态字段。
因此,不推荐用实例变量.静态字段去访问静态字段,因为在Java程序中,实例对象并没有静态字段。在代码中,实例对象能访问静态字段只是因为编译器可以根据实例类型自动转换为类名.静态字段来访问静态对象推荐用类名来访问静态字段。可以把静态字段理解为描述class本身的字段(非实例字段)。

用static修饰的方法称为静态方法。调用实例方法必须通过一个实例变量,而调用静态方法则不需要实例变量,通过类名就可以调用。静态方法类似其它编程语言的函数。因为静态方法属于class而不属于实例,因此,静态方法内部,无法访问this变量,也无法访问实例字段,它只能访问静态字段。通过实例变量也可以调用静态方法,但这只是编译器自动帮我们把实例改写成类名而已。通常情况下,通过实例变量访问静态字段和静态方法,会得到一个编译警告
静态方法经常用于工具类。例如:

  • Arrays.sort()
  • Math.random()

静态方法也经常用于辅助方法。注意到Java程序的入口main()也是静态方法。

接口的静态字段:
因为interface是一个纯抽象类,所以它不能定义实例字段。但是,interface是可以有静态字段的,并且静态字段必须为final类型
image
实际上,因为interface的字段只能是public static final类型,所以我们可以把这些修饰符都去掉,上述代码可以简写为:
image
编译器会自动把该字段变为public static final类型。
小结:

  • 静态字段属于所有实例“共享”的字段,实际上是属于class的字段;
  • 调用静态方法不需要实例,无法访问this,但可以访问静态字段和其他静态方法;
  • 静态方法常用于工具类辅助方法

在Java中,我们使用package来解决名字冲突
Java定义了一种名字空间,称之为包:package一个类总是属于某个包类名(比如Person)只是一个简写,真正的完整类名是包名.类名
JDK的Arrays类存放在包java.util下面,因此,完整类名是java.util.Arrays。
在定义class的时候,我们需要在第一行声明这个class属于哪个包
小明的Person.java文件:
image
Java虚拟机执行的时候,JVM只看完整类名,因此,只要包名不同,类就不同。
包可以是多层结构,用.隔开。例如:java.util。
要特别注意包没有父子关系。java.util和java.util.zip是不同的包,两者没有任何继承关系。
没有定义包名的class,它使用的是默认包,非常容易引起名字冲突,因此,不推荐不写包名的做法
我们还需要按照包结构把上面的Java文件组织起来。假设以package_sample作为根目录,src作为源码目录,那么所有文件结构就是:
image
所有Java文件对应的目录层次要和包的层次一致
编译后的.class文件也需要按照包结构存放。如果使用IDE,把编译后的.class文件放到bin目录下,那么,编译的文件结构就是:
image
编译的命令相对比较复杂,我们需要在src目录下执行javac命令
javac -d ../bin ming/Person.java hong/Person.java mr/jun/Arrays.java
在IDE中,会自动根据包结构编译所有Java源码,所以不必担心使用命令行编译的复杂命令。
包作用域:
位于同一个包的类,可以访问包作用域的字段和方法不用public、protected、private修饰的字段和方法就是包作用域。例如,Person类定义在hello包下面:
image
Main类也定义在hello包下面:
image
import:
在一个class中,我们总会引用其他的class。例如,小明的ming.Person类,如果要引用小军的mr.jun.Arrays类,他有三种写法:

  • 第一种,直接写出完整类名,例如:
    image
    很显然,每次写完整类名比较痛苦。

  • 第二种写法是用import语句,导入小军的Arrays,然后写简单类名:
    image

  • 在写import的时候,可以使用*,表示把这个包下面的所有class都导入进来(但不包括子包的class)
    image
    我们一般不推荐这种写法,因为在导入了多个包后,很难看出Arrays类属于哪个包。

还有一种import static的语法,它可以导入一个类的静态字段和静态方法
image
import static很少使用。
Java编译器最终编译出的.class文件只使用完整类名,因此,在代码中,当编译器遇到一个class名称时:

  • 如果是完整类名,就直接根据完整类名查找这个class;
  • 如果是简单类名,按下面的顺序依次查找
    • 查找当前package是否存在这个class
    • 查找import的包是否包含这个class;
    • 查找java.lang包是否包含这个class

如果按照上面的规则还无法确定类名,则编译报错。
因此,编写class的时候,编译器会自动帮我们做两个import动作

  • 默认自动import当前package的其他class
  • 默认自动import java.lang.*。
    注意:自动导入的是java.lang包,但类似java.lang.reflect这些包仍需要手动导入。

如果有两个class名称相同,例如,mr.jun.Arrays和java.util.Arrays,那么只能import其中一个,另一个必须写完整类名。
最佳实践:

  • 为了避免名字冲突,我们需要确定唯一的包名。推荐的做法是使用倒置的域名来确保唯一性。例如:
    • org.apache
    • org.apache.commons.log
    • com.liaoxuefeng.sample

子包就可以根据功能自行命名。

  • 要注意不要和java.lang包的类重名,即自己的类不要使用这些名字:

    • String
    • System
    • Runtime
    • ...
  • 要注意也不要和JDK常用类重名

    • java.util.List
    • java.text.Format
    • java.math.BigInteger
    • ...

小结:

  • Java内建的package机制是为了避免class命名冲突
  • JDK的核心类使用java.lang包,编译器会自动导入
  • JDK的其它常用类定义在java.util.,java.math.,java.text.*,……;
  • 包名推荐使用倒置的域名,例如org.apache。

作用域

在Java中,我们经常看到public、protected、private这些修饰符。在Java中,这些修饰符可以用来限定访问作用域
public:
定义为public的class、interface可以被其他任何类访问
image
上面的Hello是public,因此,可以被其他包的类访问
image
定义为public的field、method可以被其他类访问前提是首先有访问class的权限.
private:
定义为private的field、method无法被其他类访问.实际上,确切地说,private访问权限被限定在class的内部,而且与方法声明顺序无关。推荐把private方法放到后面,因为public方法定义了类对外提供的功能,阅读代码的时候,应该先关注public方法.
由于Java支持嵌套类如果一个类内部还定义了嵌套类,那么,嵌套类拥有访问private的权限
image
定义在一个class内部的class称为嵌套类(nested class),Java支持好几种嵌套类。
protected:
关键字protected处理的是继承的概念;基类的创建者会希望有某个特定成员,把对它的访问权限赋予派生类而不是所有类,这就需要protected来完成这一工作;protected也提供包访问权限,也就是说,相同包内的其他类可以访问protected元素。
package:
最后,包作用域是指一个类允许访问同一个package的没有public、private修饰的class,以及没有public、protected、private修饰的字段和方法
image
只要在同一个包,就可以访问package权限的class、field和method
image
注意包名必须完全一致,包没有父子关系,com.apache和com.apache.abc是不同的包
最佳实践:

  • 如果不确定是否需要public,就不声明为public,即尽可能少地暴露对外的字段和方法
  • 把方法定义为package权限有助于测试,因为测试类被测试类只要位于同一个package,测试代码就可以访问被测试类的package权限方法
  • 一个.java文件只能包含一个public类,但可以包含多个非public类。如果有public类,文件名必须和public类的名字相同

小结:

  • Java内建的访问权限包括publicprotectedprivatepackage权限;
  • Java在方法内部定义的变量是局部变量,局部变量的作用域从变量声明开始,到一个块结束;
  • final修饰符不是访问权限,它可以修饰class、field和method;
  • 一个.java文件只能包含一个public类,但可以包含多个非public类

内部类

Java程序中,

  • 通常情况下,我们把不同的类组织在不同的包下面,对于一个包下面的类来说,它们是在同一层次,没有父子关系image
  • 还有一种类,它被定义在另一个类的内部,所以称为内部类(Nested Class)。Java的内部类分为好几种,通常情况用得不多,但也需要了解它们是如何使用的。

Inner Class:
如果一个类定义在另一个类的内部,这个类就是Inner Class
image
上述定义的Outer是一个普通类,而Inner是一个Inner Class,它与普通类有个最大的不同,就是Inner Class的实例不能单独存在,必须依附于一个Outer Class的实例。示例代码如下:
image
观察上述代码,要实例化一个Inner,我们必须首先创建一个Outer的实例,然后,调用Outer实例的new来创建Inner实例
Outer.Inner inner = outer.new Inner();
这是因为Inner Class除了有一个this指向它自己还隐含地持有一个Outer Class实例,可以用Outer.this访问这个实例。所以,实例化一个Inner Class不能脱离Outer实例
Inner Class和普通Class相比,

  • 除了能引用Outer实例外,
  • 还有一个额外的“特权”,就是可以修改Outer Class的private字段,因为Inner Class的作用域在Outer Class内部,所以能访问Outer Class的private字段和方法

观察Java编译器编译后的.class文件可以发现,Outer类被编译为Outer.class,而Inner类被编译为Outer$Inner.class
Anonymous Class:
还有一种定义Inner Class的方法,它不需要在Outer Class中明确地定义这个Class,而是在方法内部,通过匿名类(Anonymous Class)来定义。示例代码如下:
image
观察asyncHello()方法(async,异步),我们在方法内部实例化了一个Runnable。Runnable本身是接口,接口是不能实例化的,所以这里实际上是定义了一个实现了Runnable接口的匿名类,并且通过new实例化该匿名类,然后转型为Runnable
在定义匿名类的时候就必须实例化它,定义匿名类的写法如下:
image
匿名类和Inner Class一样,可以访问Outer Class的private字段和方法之所以我们要定义匿名类,是因为在这里我们通常不关心类名,比直接定义Inner Class可以少写很多代码
观察Java编译器编译后的.class文件可以发现,Outer类被编译为Outer.class,而匿名类被编译为Outer$1.class。如果有多个匿名类,Java编译器会将每个匿名类依次命名为Outer$1、Outer$2、Outer$3……
除了接口外,匿名类也完全可以继承自普通类。观察以下代码:
image
map1是一个普通的HashMap实例,但map2是一个匿名类实例,只是该匿名类继承自HashMap。map3也是一个继承自HashMap的匿名类实例,并且添加了static代码块来初始化数据。观察编译输出可发现Main$1.class和Main$2.class两个匿名类文件
Static Nested Class:
最后一种内部类和Inner Class类似,但是使用static修饰,称为静态内部类(Static Nested Class)
image
用static修饰的内部类和Inner Class有很大的不同,不再依附于Outer的实例,而是一个完全独立的类,因此无法引用Outer.this,但它可以访问Outer的private静态字段和静态方法。如果把StaticNested移到Outer之外,就失去了访问private的权限。
小结:
Java的内部类可分为Inner ClassAnonymous ClassStatic Nested Class三种:

  • Inner Class和Anonymous Class本质上是相同的,都必须依附于Outer Class的实例,即隐含地持有Outer.this实例,并拥有Outer Class的private访问权限;
  • Static Nested Class是独立类,但拥有Outer Class的private访问权限。

classpath和jar

在Java中,我们经常听到classpath这个东西。网上有很多关于“如何设置classpath”的文章,但大部分设置都不靠谱。
到底什么是classpath?classpath是JVM用到的一个环境变量,它用来指示JVM如何搜索class
因为Java是编译型语言,源码文件是.java,而编译后的.class文件才是真正可以被JVM执行的字节码。因此,JVM需要知道,如果要加载一个abc.xyz.Hello的类,应该去哪搜索对应的Hello.class文件
所以,classpath就是一组目录的集合它设置的搜索路径与操作系统相关。

  • 例如,在Windows系统上,用;分隔,带空格的目录用""括起来,可能长这样:
    C:\work\project1\bin;C:\shared;"D:\My Documents\project1\bin"
  • 在Linux系统上,用:分隔,可能长这样:
    /usr/shared:/usr/local/bin:/home/liaoxuefeng/bin

现在我们假设classpath是.;C:\work\project1\bin;C:\shared,当JVM在加载abc.xyz.Hello这个类时,会依次查找

  • <当前目录>\abc\xyz\Hello.class
  • C:\work\project1\bin\abc\xyz\Hello.class
  • C:\shared\abc\xyz\Hello.class

注意到.代表当前目录如果JVM在某个路径下找到了对应的class文件,就不再往后继续搜索。如果所有路径下都没有找到,就报错
classpath的设定方法有两种:

  • 在系统环境变量中设置classpath环境变量,不推荐
  • 在启动JVM时设置classpath变量,推荐

我们强烈不推荐在系统环境变量中设置classpath,那样会污染整个系统环境。在启动JVM时设置classpath才是推荐的做法。实际上就是给java命令传入-classpath或-cp参数
java -classpath .;C:\work\project1\bin;C:\shared abc.xyz.Hello
或者使用-cp的简写:
java -cp .;C:\work\project1\bin;C:\shared abc.xyz.Hello
没有设置系统环境变量,也没有传入-cp参数,那么JVM默认的classpath为.,即当前目录
java abc.xyz.Hello
上述命令告诉JVM只在当前目录搜索Hello.class。
在IDE中运行Java程序,IDE自动传入的-cp参数是当前工程的bin目录和引入的jar包
通常,我们在自己编写的class中,会引用Java核心库的class,例如,String、ArrayList等。这些class应该上哪去找
有很多“如何设置classpath”的文章会告诉你把JVM自带的rt.jar放入classpath,但事实上,根本不需要告诉JVM如何去Java核心库查找class,JVM怎么可能笨到连自己的核心库在哪都不知道不要把任何Java核心库添加到classpath中!JVM根本不依赖classpath加载核心库
更好的做法是,不要设置classpath!默认的当前目录.对于绝大多数情况都够用了。
假设我们有一个编译后的Hello.class,它的包名是com.example,当前目录是C:\work,那么,目录结构必须如下:
image
运行这个Hello.class必须在当前目录下使用如下命令:
C:\work> java -cp . com.example.Hello
JVM根据classpath设置的.在当前目录下查找com.example.Hello,即实际搜索文件必须位于com/example/Hello.class。如果指定的.class文件不存在,或者目录结构和包名对不上,均会报错
jar包:
如果有很多.class文件,散落在各层目录中,肯定不便于管理。如果能把目录打一个包,变成一个文件,就方便多了jar包就是用来干这个事的,它可以把package组织的目录层级,以及各个目录下的所有文件(包括.class文件和其他文件)都打成一个jar文件,这样一来,无论是备份,还是发给客户,就简单多了。
jar包实际上就是一个zip格式的压缩文件,而jar包相当于目录。如果我们执行一个jar包的class,就可以把jar包放到classpath中
java -cp ./hello.jar abc.xyz.Hello
这样JVM会自动在hello.jar文件里去搜索某个类
那么问题来了:如何创建jar包
因为jar包就是zip包,所以,直接在资源管理器中,找到正确的目录,点击右键,在弹出的快捷菜单中选择“发送到”,“压缩(zipped)文件夹”,就制作了一个zip文件。然后,把后缀从.zip改为.jar,一个jar包就创建成功
假设编译输出的目录结构是这样:
image
这里需要特别注意的是,jar包里的第一层目录,不能是bin,而应该是hong、ming、mr。如果在Windows的资源管理器中看,应该长这样:
image
如果长这样:
image
说明打包打得有问题,JVM仍然无法从jar包中查找正确的class,原因是hong.Person必须按hong/Person.class存放而不是bin/hong/Person.class
jar包还可以包含一个特殊的/META-INF/MANIFEST.MF文件,MANIFEST.MF是纯文本,可以指定Main-Class和其它信息JVM会自动读取这个MANIFEST.MF文件,如果存在Main-Class,我们就不必在命令行指定启动的类名,而是用更方便的命令:
java -jar hello.jar
jar包还可以包含其它jar包,这个时候,就需要在MANIFEST.MF文件里配置classpath了
在大型项目中,不可能手动编写MANIFEST.MF文件,再手动创建zip包。Java社区提供了大量的开源构建工具,例如Maven,可以非常方便地创建jar包
小结:

  • JVM通过环境变量classpath决定搜索class的路径和顺序
  • 不推荐设置系统环境变量classpath,始终建议通过-cp命令传入
  • jar包相当于目录,可以包含很多.class文件,方便下载和使用
    -** MANIFEST.MF文件可以提供jar包的信息,如Main-Class,这样可以直接运行jar包**。

模块

Java 9开始,JDK又引入了模块(Module)
什么是模块?这要从Java 9之前的版本说起。
我们知道,.class文件是JVM看到的最小可执行文件,而一个大型程序需要编写很多Class,并生成一堆.class文件,很不便于管理,所以,jar文件就是class文件的容器
在Java 9之前,一个大型Java程序会生成自己的jar文件,同时引用依赖的第三方jar文件,而JVM自带的Java标准库,实际上也是以jar文件形式存放的,这个文件叫rt.jar,一共有60多M。
如果是自己开发的程序,除了一个自己的app.jar以外,还需要一堆第三方的jar包运行一个Java程序,一般来说,命令行写这样:
java -cp app.jar:a.jar:b.jar:c.jar com.liaoxuefeng.sample.Main
注意JVM自带的标准库rt.jar不要写到classpath中,写了反而会干扰JVM的正常运行
如果漏写了某个运行时需要用到的jar,那么在运行期极有可能抛出ClassNotFoundException
所以,jar只是用于存放class的容器,它并不关心class之间的依赖
从Java 9开始引入的模块,主要是为了解决“依赖”这个问题。如果a.jar必须依赖另一个b.jar才能运行那我们应该给a.jar加点说明啥的,让程序在编译和运行的时候能自动定位到b.jar,这种自带“依赖关系”的class容器就是模块
为了表明Java模块化的决心,从Java 9开始,原有的Java标准库已经由一个单一巨大的rt.jar分拆成了几十个模块,这些模块以.jmod扩展名标识,可以在$JAVA_HOME/jmods目录下找到它们

  • java.base.jmod
  • java.compiler.jmod
  • java.datatransfer.jmod
  • java.desktop.jmod
  • ...

这些.jmod文件每一个都是一个模块,模块名就是文件名。例如:模块java.base对应的文件就是java.base.jmod。模块之间的依赖关系已经被写入到模块内的module-info.class文件了所有的模块都直接或间接地依赖java.base模块,只有java.base模块不依赖任何模块,它可以被看作是“根模块”,好比所有的类都是从Object直接或间接继承而来
把一堆class封装为jar仅仅是一个打包的过程,而把一堆class封装为模块则不但需要打包,还需要写入依赖关系,并且还可以包含二进制代码(通常是JNI扩展)。此外,模块支持多版本,即在同一个模块中可以为不同的JVM提供不同的版本
编写模块:
那么,我们应该如何编写模块呢?还是以具体的例子来说。首先,创建模块和原有的创建Java项目是完全一样的,以oop-module工程为例,它的目录结构如下:
image
其中,bin目录存放编译后的class文件,src目录存放源码,按包名的目录结构存放仅仅在src目录下多了一个module-info.java这个文件,这就是模块的描述文件。在这个模块中,它长这样:
image
其中,module是关键字,后面的hello.world是模块的名称,它的命名规范与包一致。花括号的requires xxx;表示这个模块需要引用的其他模块名。除了java.base可以被自动引入外,这里我们引入了一个java.xml的模块。
当我们使用模块声明了依赖关系后,才能使用引入的模块。例如,Main.java代码如下:
image
如果把requires java.xml;从module-info.java中去掉,编译将报错。可见,模块的重要作用就是声明依赖关系
下面,我们用JDK提供的命令行工具编译并创建模块

  • 首先,我们把工作目录切换到oop-module,在当前目录下编译所有的.java文件,并存放到bin目录下,命令如下:
    $ javac -d bin src/module-info.java src/com/itranswarp/sample/*.java
    如果编译成功,现在项目结构如下:
    image
    注意到src目录下的module-info.java被编译到bin目录下的module-info.class。

  • 下一步,我们需要把bin目录下的所有class文件先打包成jar,在打包的时候,注意传入--main-class参数,让这个jar包能自己定位main方法所在的类
    $ jar --create --file hello.jar --main-class com.itranswarp.sample.Main -C bin .
    现在我们就在当前目录下得到了hello.jar这个jar包,它和普通jar包并无区别,可以直接使用命令java -jar hello.jar来运行它。但是我们的目标是创建模块,所以,继续使用JDK自带的jmod命令把一个jar包转换成模块
    $ jmod create --class-path hello.jar hello.jmod
    于是,在当前目录下我们又得到了hello.jmod这个模块文件,这就是最后打包出来的传说中的模块!

运行模块:
要运行一个jar,我们使用java -jar xxx.jar命令。要运行一个模块,我们只需要指定模块名。试试:
$ java --module-path hello.jmod --module hello.world
结果是一个错误:
image
原因是.jmod不能被放入--module-path中。换成.jar就没问题了:
image
那我们辛辛苦苦创建的hello.jmod有什么用?答案是我们可以用它来打包JRE
打包JRE:
面讲了,为了支持模块化,Java 9首先带头把自己的一个巨大无比的rt.jar拆成了几十个.jmod模块,原因就是,运行Java程序的时候,实际上我们用到的JDK模块,并没有那么多。不需要的模块,完全可以删除
过去发布一个Java应用程序要运行它,必须下载一个完整的JRE,再运行jar包。而完整的JRE块头很大,有100多M。怎么给JRE瘦身呢?
现在,JRE自身的标准库已经分拆成了模块,只需要带上程序用到的模块,其他的模块就可以被裁剪掉。怎么裁剪JRE呢?并不是说把系统安装的JRE给删掉部分模块,而是“复制”一份JRE,但只带上用到的模块。为此,JDK提供了jlink命令来干这件事。命令如下:
$ jlink --module-path hello.jmod --add-modules java.base,java.xml,hello.world --output jre/
我们在--module-path参数指定了我们自己的模块hello.jmod,然后,在--add-modules参数中指定了我们用到的3个模块java.base、java.xml和hello.world,用,分隔。最后,在--output参数指定输出目录。
现在,在当前目录下,我们可以找到jre目录,这是一个完整的并且带有我们自己hello.jmod模块的JRE。试试直接运行这个JRE
image
分发我们自己的Java应用程序,只需要把这个jre目录打个包给对方发过去,对方直接运行上述命令即可,既不用下载安装JDK,也不用知道如何配置我们自己的模块,极大地方便了分发和部署
访问权限:
前面我们讲过,Java的class访问权限分为public、protected、private和默认的包访问权限。引入模块后,这些访问权限的规则就要稍微做些调整。
确切地说,class的这些访问权限只在一个模块内有效,模块和模块之间,例如,a模块要访问b模块的某个class,必要条件是b模块明确地导出了可以访问的包
举个例子:我们编写的模块hello.world用到了模块java.xml的一个类javax.xml.XMLConstants,我们之所以能直接使用这个类,是因为模块java.xml的module-info.java中声明了若干导出
image
只有它声明的导出的包,外部代码才被允许访问。换句话说,如果外部代码想要访问我们的hello.world模块中的com.itranswarp.sample.Greeting类,我们必须将其导出
image
因此,模块进一步隔离了代码的访问权限
小结:

  • Java 9引入的模块目的是为了管理依赖
  • 使用模块可以按需打包JRE
  • 使用模块对类的访问权限有了进一步限制

Java核心类

本节我们将介绍Java的核心类,包括:

  • 字符串
  • StringBuilder
  • StringJoiner
  • 包装类型
  • JavaBean
  • 枚举
  • 常用工具类

字符串和编码

在Java中,String是一个引用类型,它本身也是一个class。但是,Java编译器对String有特殊处理,即可以直接用"..."来表示一个字符串:
String s1 = "Hello!";
实际上字符串在String内部是通过一个char[]数组表示的,因此,按下面的写法也是可以的:
String s2 = new String(new char[] {'H', 'e', 'l', 'l', 'o', '!'});
因为String太常用了,所以Java提供了"..."这种字符串字面量表示方法
Java字符串的一个重要特点就是字符串不可变这种不可变性是通过内部的private final char[]字段,以及没有任何修改char[]的方法实现的
字符串比较:
当我们想要比较两个字符串是否相同时,要特别注意,我们实际上是想比较字符串的内容是否相同。必须使用equals()方法而不能用==
我们看下面的例子:
image
从表面上看,两个字符串用和equals()比较都为true,但实际上那只是Java编译器在编译期,会自动把所有相同的字符串当作一个对象放入常量池,自然s1和s2的引用就是相同的。
所以,这种
比较返回true纯属巧合。换一种写法,==比较就会失败:
image
执行转换成大写的语句在执行期间才会发生,才编译期间不会发生。
要忽略大小写比较,使用equalsIgnoreCase()方法
String类还提供了多种方法来搜索子串、提取子串。常用的方法有:
image
注意到contains()方法的参数是CharSequence而不是String,因为CharSequence是String的父类
搜索子串的更多的例子:
image
提取子串的例子:
image
注意索引号是从0开始的。
去除首尾空白字符
使用trim()方法可以移除字符串首尾空白字符。空白字符包括空格,\t,\r,\n:
" \tHello\r\n ".trim(); // "Hello"
注意:trim()并没有改变字符串的内容,而是返回了一个新字符串
另一个strip()方法也可以移除字符串首尾空白字符。它和trim()不同的是,类似中文的空格字符\u3000也会被移除
image
String还提供了isEmpty()isBlank()来判断字符串是否为空空白字符串
image
替换子串:
要在字符串中替换子串,有两种方法。

  • 一种是根据字符或字符串替换:
    image
  • 另一种是通过正则表达式替换:
    image
    上面的代码通过正则表达式,把匹配的子串统一替换为","。关于正则表达式的用法我们会在后面详细讲解。

分割字符串:
要分割字符串,使用split()方法,并且传入的也是正则表达式
image
拼接字符串:
拼接字符串使用静态方法join()它用指定的字符串连接字符串数组
image
格式化字符串:
字符串提供了formatted()方法format()静态方法,可以传入其他参数,替换占位符,然后生成新的字符串
image
有几个占位符,后面就传入几个参数。参数类型要和占位符一致。我们经常用这个方法来格式化信息。常用的占位符有:

  • %s:显示字符串;
  • %d:显示整数;
  • %x:显示十六进制整数;
  • %f:显示浮点数。

占位符还可以带格式,例如%.2f表示显示两位小数。如果你不确定用啥占位符,那就始终用%s,因为%s可以显示任何数据类型。要查看完整的格式化语法,请参考JDK文档

类型转换:
要把任意基本类型或引用类型转换为字符串,可以使用静态方法valueOf()。这是一个重载方法,编译器会根据参数自动选择合适的方法:
image
把字符串转换为其他类型,就需要根据情况。例如,把字符串转换为int类型:
image
把字符串转换为boolean类型
image
要特别注意,Integer有个getInteger(String)方法,它不是将字符串转换为int,而是把该字符串对应的系统变量转换为Integer
image
转换为char[]:
String和char[]类型可以互相转换,方法是:
image
通过new String(char[])创建新的String实例时,它并不会直接引用传入的char[]数组,而是会复制一份,所以,修改外部的char[]数组不会影响String实例内部的char[]数组,因为这是两个不同的数组
字符编码:
在早期的计算机系统中,为了给字符编码,美国国家标准学会(American National Standard Institute:ANSI)制定了一套英文字母、数字和常用符号的编码,它占用一个字节,编码范围从0到127,最高位始终为0,称为ASCII编码。例如,字符'A'的编码是0x41,字符'1'的编码是0x31。
如果要把汉字也纳入计算机编码,很显然一个字节是不够的GB2312标准使用两个字节表示一个汉字,其中第一个字节的最高位始终为1,以便和ASCII编码区分开。例如,汉字'中'的GB2312编码是0xd6d0。类似的,日文有Shift_JIS编码,韩文有EUC-KR编码,这些编码因为标准不统一,同时使用,就会产生冲突
为了统一全球所有语言的编码,全球统一码联盟发布了Unicode编码,它把世界上主要语言都纳入同一个编码,这样,中文、日文、韩文和其他语言就不会冲突

Unicode编码需要两个或者更多字节表示,我们可以比较中英文字符在ASCII、GB2312和Unicode的编码:
英文字符'A'的ASCII编码和Unicode编码:
image
英文字符的Unicode编码就是简单地在前面添加一个00字节
中文字符'中'的GB2312编码和Unicode编码:
image
我们经常使用的UTF-8又是什么编码呢?因为英文字符的Unicode编码高字节总是00,包含大量英文的文本会浪费空间,所以,出现了UTF-8编码,它是一种变长编码,用来把固定长度的Unicode编码变成1~4字节的变长编码。通过UTF-8编码,英文字符'A'的UTF-8编码变为0x41,正好和ASCII码一致,而中文'中'的UTF-8编码为3字节0xe4b8ad。
UTF-8编码的另一个好处是容错能力强。如果传输过程中某些字符出错,不会影响后续字符,因为UTF-8编码依靠高字节位来确定一个字符究竟是几个字节,它经常用来作为传输编码
在Java中,char类型实际上就是两个字节的Unicode编码。如果我们要手动把字符串转换成其他编码,可以这样做:
image
注意:转换编码后,就不再是char类型,而是byte类型表示的数组
如果要把已知编码的byte[]转换为String,可以这样做:
image
始终牢记Java的String和char在内存中总是以Unicode编码表示
延伸阅读:
对于不同版本的JDK,String类在内存中有不同的优化方式。具体来说,早期JDK版本的String总是以char[]存储,它的定义如下:
image
较新的JDK版本的String则以byte[]存储如果String仅包含ASCII字符,则每个byte存储一个字符,否则,每两个byte存储一个字符,这样做的目的是为了节省内存,因为大量的长度较短的String通常仅包含ASCII字符:
image
对于使用者来说,String内部的优化不影响任何已有代码,因为它的public方法签名是不变的
小结:

  • Java字符串String是不可变对象;
  • 字符串操作不改变原字符串内容,而是返回新字符串
  • 常用的字符串操作:提取子串、查找、替换、大小写转换等;
  • Java使用Unicode编码表示String和char
  • 转换编码就是将String和byte[]转换,需要指定编码
  • 转换为byte[]时,始终优先考虑UTF-8编码。

StringBuilder

Java编译器对String做了特殊处理,使得我们可以直接用+拼接字符串。
为了能高效拼接字符串Java标准库提供了StringBuilder,它是一个可变对象,可以预分配缓冲区,这样,往StringBuilder中新增字符时,不会创建新的临时对象
image
StringBuilder还可以进行链式操作
image
如果我们查看StringBuilder的源码,可以发现,进行链式操作的关键是,定义的append()方法会返回this,这样,就可以不断调用自身的其他方法
注意对于普通的字符串+操作,并不需要我们将其改写为StringBuilder,因为Java编译器在编译时就自动把多个连续的+操作编码为StringConcatFactory的操作。在运行期,StringConcatFactory会自动把字符串连接操作优化为数组复制或者StringBuilder操作
你可能还听说过StringBuffer,这是Java早期的一个StringBuilder的线程安全版本,它通过同步来保证多个线程操作StringBuffer也是安全的,但是同步会带来执行速度的下降。
StringBuilder和StringBuffer接口完全相同,现在完全没有必要使用StringBuffer
小结:

  • StringBuilder是可变对象,用来高效拼接字符串
    -** StringBuilder可以支持链式操作,实现链式操作的关键是返回实例本身**;
  • StringBuffer是StringBuilder的线程安全版本,现在很少使用

StringJoiner

要高效拼接字符串,应该使用StringBuilder。
类似用分隔符拼接数组的需求很常见,所以Java标准库还提供了一个StringJoiner来干这个事:
image
给StringJoiner指定“开头”和“结尾”:
image
String.join()
String还提供了一个静态方法join(),这个方法在内部使用了StringJoiner来拼接字符串,在不需要指定“开头”和“结尾”的时候,用String.join()更方便
image
小结

  • 用指定分隔符拼接字符串数组时,使用StringJoiner或者String.join()更方便;
  • 用StringJoiner拼接字符串时,还可以额外附加一个“开头”和“结尾”

包装类型

Java的数据类型分两种:

  • 基本类型:byte,short,int,long,boolean,float,double,char
  • 引用类型所有class和interface类型

那么,如何把一个基本类型视为对象(引用类型)?
实际上,因为包装类型非常有用,Java核心库为每种基本类型都提供了对应的包装类型
image
我们可以直接使用,并不需要自己去定义:
image
Auto Boxing
因为int和Integer可以互相转换
image
所以,Java编译器可以帮助我们自动在int和Integer之间转型:
image
这种直接把int变为Integer的赋值写法,称为自动装箱(Auto Boxing),反过来,把Integer变为int的赋值写法,称为自动拆箱(Auto Unboxing)
注意自动装箱和自动拆箱只发生在编译阶段,目的是为了少写代码
装箱和拆箱会影响代码的执行效率,因为编译后的class代码是严格区分基本类型和引用类型的。

不变类
所有的包装类型都是不变类。我们查看Integer的源码可知,它的核心代码如下:
image
因此,一旦创建了Integer对象,该对象就是不变的
对两个Integer实例进行比较要特别注意:绝对不能用==比较,因为Integer是引用类型,必须使用equals()比较:
image
仔细观察结果的童鞋可以发现,==比较,较小的两个相同的Integer返回true,较大的两个相同的Integer返回false,这是因为Integer是不变类,编译器把Integer x = 127;自动变为Integer x = Integer.valueOf(127);为了节省内存,Integer.valueOf()对于较小的数,始终返回相同的实例,因此,==比较恰好为true,但我们绝不能因为Java标准库的Integer内部有缓存优化就用==比较,必须用equals()方法比较两个Integer。
按照语义编程,而不是针对特定的底层实现去“优化”.
因为Integer.valueOf()可能始终返回同一个Integer实例,因此,在我们自己创建Integer的时候,以下两种方法:

  • 方法1:Integer n = new Integer(100);
  • 方法2:Integer n = Integer.valueOf(100);

方法2更好,因为方法1总是创建新的Integer实例方法2把内部优化留给Integer的实现者去做,即使在当前版本没有优化,也有可能在下一个版本进行优化
我们把能创建“新”对象的静态方法称为静态工厂方法Integer.valueOf()就是静态工厂方法,它尽可能地返回缓存的实例以节省内存
创建新对象时,优先选用静态工厂方法而不是new操作符。
如果我们考察Byte.valueOf()方法的源码,可以看到,标准库返回的Byte实例全部是缓存实例,但调用者并不关心静态工厂方法以何种方式创建新实例还是直接返回缓存的实例
进制转换:
Integer类本身还提供了大量方法,例如,最常用的静态方法parseInt()可以把字符串解析成一个整数
image
Integer还可以把整数格式化为指定进制的字符串
image
注意:上述方法的输出都是String,在计算机内存中,只用二进制表示,不存在十进制或十六进制的表示方法int n = 100在内存中总是以4字节的二进制表示
image
我们经常使用的System.out.println(n);是依靠核心库自动把整数格式化为10进制输出并显示在屏幕上使用Integer.toHexString(n)则通过核心库自动把整数格式化为16进制
这里我们注意到程序设计的一个重要原则:数据的存储和显示要分离
Java的包装类型还定义了一些有用的静态变量:
image
最后,所有的整数和浮点数的包装类型都继承自Number,因此,可以非常方便地直接通过包装类型获取各种基本类型
image
处理无符号整型
在Java中,并没有无符号整型(Unsigned)的基本数据类型。byte、short、int和long都是带符号整型,最高位是符号位。而C语言则提供了CPU支持的全部数据类型,包括无符号整型无符号整型和有符号整型的转换在Java中就需要借助包装类型的静态方法完成
例如,byte是有符号整型,范围是-128+127,但**如果把byte看作无符号整型**,它的范围就是0255。我们把一个负的byte按无符号整型转换为int
image
因为byte的-1的二进制表示是11111111,以无符号整型转换后的int就是255。类似的,可以把一个short按unsigned转换为int,把一个int按unsigned转换为long。
小结

  • Java核心库提供的包装类型可以把基本类型包装为class;
  • 自动装箱和自动拆箱都是在编译期完成的(JDK>=1.5)
  • 装箱和拆箱会影响执行效率,且拆箱时可能发生NullPointerException
  • 包装类型的比较必须使用equals();
  • 整数和浮点数的包装类型都继承自Number
  • 包装类型提供了大量实用方法

JavaBean

如果读写方法符合以下这种命名规范
image
那么这种class被称为JavaBean。
boolean字段比较特殊,它的读方法一般命名为isXyz():
image
我们通常把一组对应的读方法(getter)和写方法(setter)称为属性(property)。例如,name属性:

  • 对应的读方法是String getName()
  • 对应的写方法是setName(String)

只有getter的属性称为只读属性(read-only),例如,定义一个age只读属性:

  • 对应的读方法是int getAge()
  • 无对应的写方法setAge(int)

类似的,只有setter的属性称为只写属性(write-only)。
很明显,只读属性很常见,只写属性不常见。
属性只需要定义getter和setter方法,不一定需要对应的字段
可以看出,getter和setter也是一种数据封装的方法
JavaBean的作用
JavaBean主要用来传递数据,即把一组数据组合成一个JavaBean便于传输。此外,JavaBean可以方便地被IDE工具分析,生成读写属性的代码,主要用在图形界面的可视化设计中
通过IDE,可以快速生成getter和setter。例如,在Eclipse中,先输入以下代码:
image
然后,点击右键,在弹出的菜单中选择“Source”,“Generate Getters and Setters”,在弹出的对话框中选中需要生成getter和setter方法的字段,点击确定即可由IDE自动完成所有方法代码。
枚举JavaBean属性
枚举一个JavaBean的所有属性,可以直接使用Java核心库提供的Introspector
image
运行上述代码,可以列出所有的属性,以及对应的读写方法。注意class属性是从Object继承的getClass()方法带来的。
小结

  • JavaBean是一种符合命名规范的class,它通过getter和setter来定义属性
  • 属性是一种通用的叫法,并非Java语法规定
  • 可以利用IDE快速生成getter和setter
  • 使用Introspector.getBeanInfo()可以获取属性列表。

枚举类

在Java中,我们可以通过static final来定义常量。例如,我们希望定义周一到周日这7个常量,可以用7个不同的int表示:
image
也可以把常量定义为字符串类型,例如,定义3种颜色的常量:
image
无论是int常量还是String常量,使用这些常量来表示一组枚举值的时候,有一个严重的问题就是,编译器无法检查每个值的合理性。例如:
image
上述代码编译和运行均不会报错,但存在两个问题:

  • 注意到Weekday定义的常量范围是0~6,并不包含7,编译器无法检查不在枚举中的int值
  • 定义的常量仍可与其他变量比较,但其用途并非是枚举星期值

enum
为了让编译器能自动检查某个值在枚举的集合内,并且,不同用途的枚举需要不同的类型来标记,不能混用,我们可以使用enum来定义枚举类
image
注意到定义枚举类是通过关键字enum实现的,我们只需依次列出枚举的常量名
int定义的常量相比,使用enum定义枚举有如下好处:

  • 首先,enum常量本身带有类型信息,即Weekday.SUN类型是Weekday,编译器会自动检查出类型错误。例如,下面的语句不可能编译通过
    image
  • 其次,不可能引用到非枚举的值,因为无法通过编译
  • 最后,不同类型的枚举不能互相比较或者赋值,因为类型不符。例如,不能给一个Weekday枚举类型的变量赋值为Color枚举类型的值:
    image
    这就使得编译器可以在编译期自动检查出所有可能的潜在错误。

enum的比较
使用enum定义的枚举类是一种引用类型。前面我们讲到,引用类型比较,要使用equals()方法,如果使用比较,它比较的是两个引用类型的变量是否是同一个对象。因此,引用类型比较,要始终使用equals()方法,但enum类型可以例外
这是因为**enum类型的每个常量在JVM中只有一个唯一实例,所以可以直接用
比较
image
enum类型
通过enum定义的枚举类,和其他的class有什么区别?
答案是
没有任何区别enum定义的类型就是class**,只不过它有以下几个特点:

  • 定义的enum类型总是继承自java.lang.Enum,且无法被继承
  • 只能定义出enum的实例,而无法通过new操作符创建enum的实例
  • 定义的每个实例都是引用类型的唯一实例
  • 可以将enum类型用于switch语句。

例如,我们定义的Color枚举类:
image
编译器编译出的class大概就像这样
image
所以,编译后的enum类和普通class并没有任何区别。但是我们自己无法按定义普通class那样来定义enum,必须使用enum关键字,这是Java语法规定的
因为enum是一个class,每个枚举的值都是class实例,因此,这些实例有一些方法:
name()
返回常量名,例如:String s = Weekday.SUN.name(); // "SUN"
ordinal()
返回定义的常量的顺序,从0开始计数,例如:int n = Weekday.MON.ordinal(); // 1,
因为enum本身是class,所以我们可以定义private的构造方法,并且,给每个枚举常量添加字段
image
这样就无需担心顺序的变化,新增枚举常量时,也需要指定一个int值。
注意:枚举类的字段也可以是非final类型,即可以在运行期修改,但是不推荐这样做!
默认情况下,对枚举常量调用toString()会返回和name()一样的字符串。但是,toString()可以被覆写,而name()则不行。我们可以给Weekday添加toString()方法:
image
覆写toString()的目的是在输出时更有可读性
注意:判断枚举常量的名字,要始终使用name()方法,绝不能调用toString()!
switch:
最后,枚举类可以应用在switch语句中。因为枚举类天生具有类型信息和有限个枚举常量,所以比int、String类型更适合用在switch语句中
image
加上default语句,可以在漏写某个枚举常量时自动报错,从而及时发现错误。注意在Switch的case中用的是枚举常量。
小结

  • Java使用enum定义枚举类型它被编译器编译为final class Xxx extends Enum { … }
  • 通过name()获取常量定义的字符串,注意不要使用toString();
  • 通过ordinal()返回常量定义的顺序(无实质意义);
  • 可以为enum编写构造方法、字段和方法
  • enum的构造方法要声明为private,字段强烈建议声明为final
  • enum适合用在switch语句中。

记录类

使用String、Integer等类型的时候,这些类型都是不变类,一个不变类具有以下特点:

  • 定义class时使用final,无法派生子类
  • 每个字段使用final,保证创建实例后无法修改任何字段

从Java 14开始,引入了新的Record类。我们定义Record类时,使用关键字record。把上述Point类改写为Record类,代码如下:
image
仔细观察Point的定义public record Point(int x, int y) {}
把上述定义改写为class,相当于以下代码:
image
除了用final修饰class以及每个字段外,编译器还自动为我们创建了构造方法,和字段名同名的方法,以及覆写toString()、equals()和hashCode()方法
换句话说,使用record关键字,可以一行写出一个不变类
和enum类似,我们自己不能直接从Record派生,只能通过record关键字由编译器实现继承
构造方法:
编译器默认按照record声明的变量顺序自动创建一个构造方法,并在方法内给字段赋值。那么问题来了,如果我们要检查参数,应该怎么办?
假设Point类的x、y不允许负数,我们就得给Point的构造方法加上检查逻辑
image
注意到方法public Point {...}被称为Compact Constructor,它的目的是让我们编写检查逻辑编译器最终生成的构造方法如下:
image
作为record的Point仍然可以添加静态方法一种常用的静态方法是of()方法,用来创建Point
image
这样我们可以写出更简洁的代码
image
小结:
从Java 14开始,提供新的record关键字,可以非常方便地定义Data Class:

  • 使用record定义的是不变类
  • 可以编写Compact Constructor对参数进行验证
  • 可以定义静态方法

BigInteger

在Java中,由CPU原生提供的整型最大范围是64位long型整数。使用long型整数可以直接通过CPU指令进行计算,速度非常快
如果我们使用的整数范围超过了long型怎么办?这个时候,就只能用软件来模拟一个大整数。java.math.BigInteger就是用来表示任意大小的整数。BigInteger内部用一个int[]数组来模拟一个非常大的整数,对BigInteger做运算的时候,只能使用实例方法,例如,加法运算:
image
和long型整数运算比,BigInteger不会有范围限制,但缺点是速度比较慢
BigInteger和Integer、Long一样,也是不可变类,并且也继承自Number类。因为Number定义了转换为基本类型的几个方法

  • 转换为byte:byteValue()
  • 转换为short:shortValue()
  • 转换为int:intValue()
  • 转换为long:longValue()
  • 转换为float:floatValue()
  • 转换为double:doubleValue()

因此,通过上述方法,可以把BigInteger转换成基本类型。如果BigInteger表示的范围超过了基本类型的范围,转换时将丢失高位信息,即结果不一定是准确的。如果需要准确地转换成基本类型,可以使用intValueExact()、longValueExact()等方法,在转换时如果超出范围,将直接抛出ArithmeticException异常
如果BigInteger的值甚至超过了float的最大范围(3.4x1038),那么返回的float是什么呢?
image
小结:

  • BigInteger用于表示任意大小的整数;
  • BigInteger是不变类,并且继承自Number
  • 将BigInteger转换成基本类型时可使用longValueExact()等方法保证结果准确。

BigDecimal

BigDecimal可以表示一个任意大小精度完全准确的浮点数。
image
BigDecimal用scale()表示小数位数,例如:
image
通过BigDecimal的stripTrailingZeros()方法,可以将一个BigDecimal格式化为一个相等的,但去掉了末尾0的BigDecimal:
image
如果一个BigDecimal的scale()返回负数,例如,-2,表示这个数是个整数,并且末尾有2个0。
可以对一个BigDecimal设置它的scale,如果精度比原始值低,那么按照指定的方法进行四舍五入或者直接截断
image
对BigDecimal做加、减、乘时,精度不会丢失,但是做除法时,存在无法除尽的情况,这时,就必须指定精度以及如何进行截断:
image
还可以对BigDecimal做除法的同时求余数:
image
用divideAndRemainder()方法时,返回的数组包含两个BigDecimal,分别是商和余数,其中商总是整数,余数不会大于除数。我们可以利用这个方法判断两个BigDecimal是否是整数倍数:
image
比较BigDecimal:
在比较两个BigDecimal的值是否相等时,要特别注意,使用equals()方法不但要求两个BigDecimal的值相等,还要求它们的scale()相等:
image
必须使用compareTo()方法来比较,它根据两个值的大小分别返回负数、正数和0,分别表示小于、大于和等于
总是使用compareTo()比较两个BigDecimal的值,不要使用equals()!
如果查看BigDecimal的源码,可以发现,实际上一个BigDecimal是通过一个BigInteger和一个scale来表示的,即BigInteger表示一个完整的整数,而scale表示小数位数
image
BigDecimal也是从Number继承的,也是不可变对象。
小结:

  • BigDecimal用于表示精确的小数,常用于财务计算
  • 比较BigDecimal的值是否相等,必须使用compareTo()而不能使用equals()

常用工具类

Java的核心库提供了大量的现成的类供我们使用。本节我们介绍几个常用的工具类
Math:
顾名思义,Math类就是用来进行数学计算的,它提供了大量的静态方法来便于我们实现数学计算

  • 求绝对值:
    image
  • 取最大或最小值:
    image
  • 计算\(x^y\)次方:
    image
  • 计算\(\sqrt x\)
    image
  • 计算\(e^x\)次方:
    image
  • 计算以10为底的对数:
    image
  • 三角函数:
    image
  • Math还提供了几个数学常量
    image
  • 生成一个随机数x,x的范围是0 <= x < 1:
    image
    如果我们要生成一个区间在[MIN, MAX)的随机数,可以借助Math.random()实现,计算如下:
    image

Java标准库还提供了一个StrictMath,它提供了和Math几乎一模一样的方法。这两个类的区别在于,由于浮点数计算存在误差,不同的平台(例如x86和ARM)计算的结果可能不一致(指误差不同),因此,StrictMath保证所有平台计算结果都是完全相同的,而Math会尽量针对平台优化计算速度,所以,绝大多数情况下,使用Math就足够了
Random:
Random用来创建伪随机数。所谓伪随机数,是指只要给定一个初始的种子,产生的随机数序列是完全一样的。
生成一个随机数,可以使用nextInt()nextLong()nextFloat()nextDouble()
image
我们创建Random实例时,如果不给定种子,就使用系统当前时间戳作为种子,因此每次运行时,种子不同,得到的伪随机数序列就不同。如果我们在创建Random实例时指定一个种子,就会得到完全确定的随机数序列
image
前面我们使用的Math.random()实际上内部调用了Random类,所以它也是伪随机数,只是我们无法指定种子。

SecureRandom:
有伪随机数,就有真随机数。实际上真正的真随机数只能通过量子力学原理来获取,而我们想要的是一个不可预测的安全的随机数,SecureRandom就是用来创建安全的随机数的:
image
SecureRandom无法指定种子,它使用RNG(random number generator)算法JDK的SecureRandom实际上有多种不同的底层实现

  • 有的使用安全随机种子加上伪随机数算法来产生安全的随机数
  • 有的使用真正的随机数生成器

实际使用的时候,可以优先获取高强度的安全随机数生成器,如果没有提供,再使用普通等级的安全随机数生成器

SecureRandom的安全性通过操作系统提供的安全的随机种子来生成随机数这个种子是通过CPU的热噪声、读写磁盘的字节、网络流量等各种随机事件产生的“熵”
在密码学中,安全的随机数非常重要。如果使用不安全的伪随机数,所有加密体系都将被攻破。因此,时刻牢记必须使用SecureRandom来产生安全的随机数。
需要使用安全随机数的时候,必须使用SecureRandom,绝不能使用Random!
小结
Java提供的常用工具类有:

  • Math:数学计算
  • Random:生成伪随机数
  • SecureRandom:生成安全的随机数

`

posted on 2022-02-11 14:21  朴素贝叶斯  阅读(65)  评论(0编辑  收藏  举报

导航