面向对象编程,是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。在本章,我们将讨论面向对象的基本概念(类,实例,方法等)、面向对象的实现方式(继承,多态)、Java语言提供的机制(package,class path,jar)以及Java标准库提供的核心类(字符串、包装类型、JavaBean、枚举、常用工具类等)。通过本章的学习,完全可以理解并掌握面向对象的基本思想。
现实世界中,我们定义了“人”这种抽象概念,而具体的人则是“小明”、“小红”、“小军”等一个个具体的人。所以,“人”可以定义为一个类(class),而具体的人则是实例(instance)。同样的,“书”也是一种抽象的概念,所以它是类,而《Java核心技术》、《Java编程思想》、《Java学习笔记》则是实例。
所以只要理解了class和instance的概念,基本上就明白了什么是面向对象编程。class是一种对象模板,而instance是对象实例。
在Java中,创建一个类,例如:
1 |
|
一个class可以包含多个字段(field),字段用来描述一个类的特征。上面的Person类,我们定义了两个字段,一个String类型的字段name,一个int类型的字段age。因此,通过class把一组数据汇集到一个对象上,实现了数据封装。
public
是用来修饰字段的,它表示这个字段可以被外部访问。
定义了class,只是定义了对象模板,而要根据对象模板创建出真正的对象实例,必须用new操作符。new操作符可以创建一个实例,然后我们要定义一个引用类型的变量来指向这个实例。
1 |
|
上述代码创建了一个Person类型的实例,并通过变量ming指向它。new Person()是创建Person实例,Person ming是定义Person类型的变量ming,指向刚创建的Person实例。有了这个指向实例的变量,我们就可以通过这个变量来操作实例,访问实例变量用变量.字段
。
1 |
|
上述两个变量分别指向两个不同的实例,两个instance拥有class定义的name和age字段,且各自都有一份独立的数据,互不干扰。
方法
一个class可以包含多个field,但是直接把field用public暴露给外部可能会破坏封装性,直接操作field也容易造成逻辑混乱。为了避免外部代码直接访问field,我们可以用private
修饰field,拒绝外部访问。
把field从public改成private,外部代码不能访问这些field,那我们定义这些field有什么用?怎么才能给它赋值?怎么才能读取它的值?
所以我们使用方法(method)来让外部代码可以间接修改field。
1 |
|
虽然外部代码不能直接修改private字段,但是,外部代码可以调用方法setName()和setAge()来间接修改private字段。在方法内部,我们就有机会检查参数的合理性。比如,setAge()就会检查传入的参数,若参数超出了范围就会报错。这样,外部代码就没有任何机会把age设置成不合理的值。
所以,一个class通过定义method,就可以给外部代码暴露一些操作的接口,同时内部保证逻辑一致性。 调用方法的语法是实例变量.方法名(参数);
。
有public方法,自然就有private方法。和private字段一样,private方法不允许外部调用,那我们定义private方法有什么用?
定义private方法的理由是内部方法是可以调用private方法的。
1 |
|
上述代码中,calcAge()是一个private方法,外部代码无法调用,但是内部方法getAge()可以调用它。 我们还注意到,这个Person类只定义了birth字段,没有定义age字段,获取age时,通过方法getAge()返回的是一个实时计算的值,并非存储在某个字段的值。这说明方法可以封装一个类的对外接口,调用方不需要知道也不关心Person实例在内部到底有没有age字段。
在方法内部,可以使用一个隐含的变量this,它始终指向当前实例。因此,通过this.field
就可以访问当前实例字段。如果没有命名冲突,可以忽略this。
1 |
|
但是如果有局部变量和字段重名,那么局部变量优先级更高,就必须加上this。
1 |
|
方法参数
方法可以包含0个或任意个参数,方法参数用于接受传递给方法的变量值。调用方法时必须严格按照参数的定义一一传递。
可变参数
可变参数用类型…
定义,可变参数相当于数组类型。
1 |
|
完全可以把可变参数改写为String[]
类型。但是,调用方需要自己先构造String[],比较麻烦。
1 |
|
参数绑定
调用方把参数传递给实例方法时,调用时传递的值会按参数位置一一绑定。基本类型参数的传递,是调用方值的复制。双方各自的后续修改,互不影响。引用类型参数的传递,调用方的变量和接收方的参数变量,指向的是同一个对象。双方任意一方对这个对象的修改,都会影响对方(因为指向同一个对象嘛)。
构造方法
创建实例的时候,我们经常需要同时初始化这个实例的字段。创建实例的时候,实际上是通过构造方法来初始化实例的。由于构造方法是如此特殊,所以构造方法的名称就是类名。构造方法的参数没有限制,在方法内部,也可以编写任意语句。但是,和普通方法相比,构造方法没有返回值(也没有void),调用构造方法,必须用new
操作符。
默认构造方法
是不是任何class都有构造方法?是的。如果一个class没有定义构造方法,编译器会自动为我们生成一个默认构造方法,它没有参数,也没有执行语句。如果我们自定义了一个构造方法,那么编译器就不再自动创建默认构造方法。如果既要能使用带参数的构造方法,又想保留不带参数的构造方法,那么只有把两个构造方法都定义出来。
没有在构造方法中初始化字段时,引用类型的字段默认是null,数值类型的字段用默认值,int类型默认值是0,布尔类型默认值是false。
既对字段进行初始化,又在构造方法中对字段进行初始化。当我们创建对象的时候,得到的对象实例,字段的初始值是啥?
在Java中,创建对象实例的时候,先初始化字段,再按构造方法的代码初始化。
多构造方法
可以定义多个构造方法,在通过new操作符调用时,编译器通过构造方法的参数数量、位置和类型自动区分。
一个构造方法可以调用其他构造方法,这样做的目的是便于代码复用。调用其他构造方法的语法是this(…)
。
1 |
|
方法重载
在一个类中,我们可以定义多个方法。如果有一系列方法,它们的功能都是类似的,只有参数有所不同,那么,可以把这一组方法名做成同名方法。这种方法名相同,但各自的参数不同,称为方法重载(Overload)。方法重载的目的是,功能类似的方法使用同一名字,更容易记住,因此,调用起来更简单。
举个例子,String
类提供了多个重载方法indexOf()
,可以查找子串:
int indexOf(int ch)
:根据字符的Unicode码查找;int indexOf(String str)
:根据字符串查找;int indexOf(int ch, int fromIndex)
:根据字符查找,但指定起始位置;int indexOf(String str, int fromIndex)
根据字符串查找,但指定起始位置。
继承
继承是面向对象编程中非常强大的一种机制,它首先可以复用代码。当我们让Student
从Person
继承时,Student
就获得了Person
的所有功能,我们只需要为Student
编写新增的功能。Java使用extends
关键字来实现继承。
1 |
|
注意到我们在定义Person的时候,没有写extends。在Java中,没有明确写extends的类,编译器会自动加上extends Object
。所以,任何类,除了Object
,都会继承自某个类。
Java只允许一个class继承自一个类,因此,一个类有且仅有一个父类。只有Object特殊,它没有父类。
protected
继承有个特点,子类无法访问父类的private字段,这就使得继承的作用被削弱了。为了让子类可以访问父类的字段,我们把private改成protected
,protected修饰的字段可以被子类访问。
因此,protected关键字可以把字段和方法权限控制在继承树内部,protected字段和方法可以被其子类,以及子类的子类所访问。
super
super
关键字表示父类。子类引用父类的字段,可以用field.fieldName
。实际上,这里使用super.name,或者this.name,或者name,效果都是一样的。编译器会自动定位到父类的name字段。
1 |
|
在Java中,任何class的构造方法,第一行语句必须是调用父类的构造方法。如果没有明确地调用父类的构造方法,编译器会帮我们自动加一句super()。父类没有默认的构造方法,子类就必须显式调用super(),并给出参数以便让编译器定位到父类的一个合适的构造方法。
这里还顺带引出了另一个问题:即子类不会继承任何父类的构造方法。子类默认的构造方法是编译器自动生成的,不是继承的。
阻止继承
正常情况下,只要某个class没有final
修饰符,那么任何类都可以从该class继承。
向上转型
这种把一个子类类型安全的变为父类类型的赋值,被称为向上转型(upcasting)。
向下转型
和向上转型相反,如果把一个父类类型强制转型为子类类型,就是向下转型(downcasting)。不能把父类变为子类,因为子类功能比父类多,多的功能无法凭空变出来。因此,向下转型很可能会失败。失败的时候,JVM会报ClassCastException
。
为了避免向下转型出错,Java提供了instanceof操作符,先判断一个实例究竟是不是某种类型。
1 |
|
区分继承和组合
在使用继承时,我们要注意逻辑一致性。继承是is关系,组合是has关系。比如,Student继承自Book,这在逻辑上是不通的,Student不是Book,是持有Book。
多态
在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法,被称为覆写(Override
)。Override和Overload不同的是,如果方法签名如果不同,就是Overload,Overload方法是一个新方法;如果方法签名相同,并且返回值也相同,就是Override。
在Java中,方法名相同,方法参数相同,但方法返回值不同,也是不同的方法。在Java程序中,出现这种情况,编译器会报错。
加上@Override
可以让编译器帮助检查是否进行了正确的覆写。希望进行覆写,但是不小心写错了方法签名,编译器会报错。但是@Override不是必需的。
Java的实例方法调用是基于运行时的实际类型的动态调用,而非变量的声明类型。这个非常重要的特性在面向对象编程中称之为多态。多态是指,针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法。
覆写Object方法
因为所有的class最终都继承自Object
,而Object定义了几个重要的方法:
toString()
:把instance输出为String
;equals()
:判断两个instance是否逻辑相等;hashCode()
:计算一个instance的哈希值。
在必要的情况下,我们可以覆写Object的这几个方法。
调用super
在子类的覆写方法中,如果要调用父类的被覆写的方法,可以通过super来调用。
final
继承允许子类覆写父类的方法。如果一个父类不允许子类对它的某个方法覆写,可以把该方法标记为final。用final标识的方法不能被Override。
对于一个类的实例字段,同样可以用final修饰。用final修饰的字段在初始化后不能被修改。
如果一个类不希望任何其他类继承自它,那么可以把这个类本身标记为final。用final修饰的类不能被继承。
抽象类
如果父类的方法本身不需要实现任何功能,仅仅是为了定义方法签名,目的是让子类覆写它,那么可以把父类的方法声明为抽象方法。
把一个方法声明为abstract,表示它是一个抽象方法,本身没有任何实现方法的语句。因为这个抽象方法本身无法执行,所以其所在的类也无法被实例化。编译器会告诉我们,无法编译Person
类,因为它包含抽象方法。必须把Person
类本身也声明为abstract
,才能正确编译它。
1 |
|
无法实例化的抽象类有什么用?因为抽象类本身被设计成只能用于被继承,因此,抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错。因此,抽象方法实际上相当于定义了“规范”。
面向抽象编程
这种尽量引用高层类型,避免引用实际子类型的方式,称之为面向抽象编程。
面向抽象编程的本质就是:
- 上层代码只定义规范(例如:
abstract class Person
); - 不需要子类就可以实现业务逻辑(正常编译);
- 具体的业务逻辑由不同的子类实现,调用者并不关心。
接口
在抽象类中,抽象方法本质上是定义接口规范,即规定高层类的接口,从而保证所有子类都有相同的接口实现,这样多态就能发挥威力。如果一个抽象类没有字段,所有方法全部是抽象方法,就可以把该抽象类改写为接口interface
。
在Java中,使用interface可以声明一个接口:
1 |
|
所谓interface,就是比抽象类还要抽象的纯抽象接口,因为它连字段都不能有。因为接口定义的所有方法默认都是public abstract
的,所以这两个修饰符不需要写出来(写不写效果都一样)。当一个具体的class去实现一个interface时,需要使用implements
关键字。
我们知道,在Java中,一个类只能继承自另一个类,不能从多个类继承。但是,一个类可以实现多个interface。
1 |
|
接口继承
一个interface可以继承自另一个interface。interface继承自interface使用extends
,相当于扩展了接口的方法。
继承关系
合理设计interface和abstract class的继承关系,可以充分复用代码。一般来说,公共逻辑适合放在abstract class中,具体逻辑放到各个子类,而接口层次代表抽象程度。
在使用的时候,实例化的对象永远只能是某个具体的子类,但总是通过接口引用它,因为接口比抽象类更抽象。
default方法
default
方法的目的是,当我们需要给接口新增一个方法时,会涉及到修改全部子类。如果新增的是default方法,那么子类就不必全部修改,只需要在需要覆写的地方去覆写新增方法。
default方法和抽象类的普通方法是有所不同的。因为interface没有字段,default方法无法访问字段,而抽象类的普通方法可以访问实例字段。
静态字段和静态方法
在class中定义的字段,我们称之为实例字段。实例字段的特点是,每个实例都有独立的字段,各个实例的同名字段互不影响。还有一种字段,使用static
修饰的字段,成为静态字段。实例字段在每个实例中都有自己的一个独立“空间”,但是静态字段只有一个共享“空间”,所有实例都会共享该字段。
对于静态字段,无论修改哪个实例的静态字段,效果都是一样的:所有实例的静态字段都被修改了,原因是静态字段并不属于实例。
虽然实例可以访问静态字段,但是它们指向的其实都是Person class
的静态字段。所以,所有实例共享一个静态字段。
因此,不推荐用实例变量.静态字段
去访问静态字段,因为在Java程序中,实例对象并没有静态字段。在代码中,实例对象能访问静态字段只是因为编译器可以根据实例类型自动转换为类名.静态字段
来访问静态对象。
推荐用类名来访问静态字段。可以把静态字段理解为描述class本身的字段(非实例字段)。
有静态字段,就有静态方法。用static修饰的方法称为静态方法。调用实例方法必须通过一个实例变量,而调用静态方法则不需要实例变量,通过类名就可以调用。静态方法类似其它编程语言的函数。
因为静态方法属于class而不属于实例,因此,静态方法内部,无法访问this
变量,也无法访问实例字段,它只能访问静态字段。通过实例变量也可以调用静态方法,但这只是编译器自动帮我们把实例改写成类名而已。通常情况下,通过实例变量访问静态字段和静态方法,会得到一个编译警告。静态方法经常用于工具类。静态方法也经常用于辅助方法。注意到Java程序的入口main()
也是静态方法。
接口的静态字段
interface是一个纯抽象类,所以它不能定义实例字段。但是,interface可以有静态字段,且必须为final类型。编译器会自动把该字段变为public static final
类型。
包
在Java中,我们用package来解决名字冲突。
Java定义了一种名字空间,称之为package包。一个类总是属于一个包的,类名只是简写,真正的完整类名是包名.类名
。
JVM执行的时候只看完整类名,因此只要包名不同,类就不同。
要特别注意,包没有父子关系 。java.util和java.util.zip是不同的包,两者没有任何继承关系。
包作用域
位于同一个包的类,可以访问包作用域的字段和方法。不用public
、protected
、private
修饰的字段和方法就是包作用域。
import
在一个class中,我们总会引用其他的class。第一种方法,直接写出完整类名。第二种方法是用import语句,然后写简单类名。在写import
的时候,可以使用*
,表示把这个包下面的所有class
都导入进来。还有一种import static
的语法,它可以导入可以导入一个类的静态字段和静态方法。
Java编译器最终编译出的.class
文件只使用完整类名,因此,在代码中,当编译器遇到一个class名称时:
- 如果是完整类名,就直接根据完整类名查找这个class;
- 如果是简单类名,按下面的顺序依次查找:
- 查找当前package是否存在这个class;
- 查找import的包是否包含这个class;
- 查找java.lang包是否包含这个class。
如果按照上面的规则还无法确定类名,则编译报错。
编写class的时候,编译器会自动帮我们做两个import动作:
- 默认自动
import
当前package
的其他class
; - 默认自动
import java.lang.*
。
如果有两个class名称相同,那么只能import其中一个,另一个必须写完整类名。为了避免名字冲突,我们需要确定唯一的包名。推荐的做法是使用倒置的域名来确保唯一性。要注意不要和java.lang
包的类重名,即自己的类不要使用这些名字,String,System,Runtime。也要注意不要和JDK同名。
作用域
定义为public的class、interface可以被其他任何类访问:
定义为private的field、method无法被其他类访问。 private访问权限被限定在class的内部,而且与方法声明顺序无关。推荐把private方法放到后面,因为public方法定义了类对外提供的功能,阅读代码的时候,应该先关注public方法。由于Java支持嵌套类,如果一个类内部还定义了嵌套类,那么,嵌套类拥有访问private的权限:
protected作用于继承关系。定义为protected的字段和方法可以被子类访问,以及子类的子类。
包作用域是指一个类允许访问同一个package的没有public、private修饰的class,以及没有public、protected、private修饰的字段和方法。
在方法内部定义的变量称为局部变量,局部变量作用域从变量声明处开始到对应的块结束。方法参数也是局部变量。使用局部变量时,应该尽可能把局部变量的作用域缩小,尽可能延后声明局部变量。
final字段。用final修饰class可以阻止被继承。用final修饰method可以阻止被子类覆写。用final修饰field可以阻止被重新赋值。用final修饰局部变量可以阻止被重新赋值。
如果不确定是否需要public,就不声明为public,即尽可能少地暴露对外的字段和方法。
把方法定义为package权限有助于测试,因为测试类和被测试类只要位于同一个package,测试代码就可以访问被测试类的package权限方法。
一个.java
文件只能包含一个public类,但可以包含多个非public类。如果有public类,文件名必须和public类的名字相同。
内部类
如果一个类定义在另一个类的内部,这个类就是Inner Class。它与普通类有个最大的不同,就是Inner Class的实例不能单独存在,必须依附于一个Outer Class的实例。
Inner Class和普通Class相比,除了能引用Outer实例外,还有一个额外的“特权”,就是可以修改Outer Class的private字段,因为Inner Class的作用域在Outer Class内部,所以能访问Outer Class的private字段和方法。
还有一种定义Inner Class的方法,它不需要在Outer Class中明确地定义这个Class,而是在方法内部,通过匿名类(Anonymous Class)来定义。 匿名类和Inner Class一样,可以访问Outer Class的private字段和方法。之所以我们要定义匿名类,是因为在这里我们通常不关心类名,比直接定义Inner Class可以少写很多代码。 除了接口外,匿名类也完全可以继承自普通类。
用static修饰的内部类和Inner Class有很大的不同,它不再依附于Outer的实例,而是一个完全独立的类,因此无法引用Outer.this,但它可以访问Outer的private静态字段和静态方法。如果把静态内部类(Static Nested Class)移到Outer之外,就失去了访问private的权限。
classpath和jar
classpath
classpath是JVM用到的一个环境变量,它用来指示JVM如何搜索class。因为Java是编译型语言,源码文件是.java,而编译后的.class文件才是真正可以被JVM执行的字节码。因此,JVM需要知道,如果要加载一个abc.xyz.Hello的类,应该去哪搜索对应的Hello.class文件。所以,classpath就是一组目录的集合,它设置的搜索路径与操作系统相关。
classpath的设定方法有两种:
- 在系统环境变量中设置classpath环境变量,不推荐;
- 在启动JVM时设置classpath变量,推荐。
我们强烈不推荐在系统环境变量中设置classpath,那样会污染整个系统环境。在启动JVM时设置classpath才是推荐的做法。实际上就是给java命令传入-classpath或-cp参数。没有设置系统环境变量,也没有传入-cp参数,那么JVM默认的classpath为.
,即当前目录。
在IDE中运行Java程序,IDE自动传入的-cp参数是当前工程的bin目录和引入的jar包。
jar包
如果有很多.class文件,散落在各层目录中,肯定不便于管理。如果能把目录打一个包,变成一个文件,就方便多了。jar包就是用来干这个事的,它可以把package组织的目录层级,以及各个目录下的所有文件(包括.class文件和其他文件)都打成一个jar文件,这样一来,无论是备份,还是发给客户,就简单多了。如果我们要执行一个jar包的class,就可以把jar包放到classpath中。 这样JVM会自动在hello.jar文件里去搜索某个类。
那么问题来了:如何创建jar包?因为jar包就是zip包,所以,直接在资源管理器中,找到正确的目录,点击右键,在弹出的快捷菜单中选择“发送到”,“压缩(zipped)文件夹”,就制作了一个zip文件。然后,把后缀从.zip改为.jar,一个jar包就创建成功。
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包。
模块
从Java 9开始,JDK又引入了模块(Module)。jar只是用于存放class的容器,它并不关心class之间的依赖。从Java 9开始引入的模块,主要是为了解决“依赖”这个问题。如果a.jar
必须依赖另一个b.jar
才能运行,那我们应该给a.jar
加点说明啥的,让程序在编译和运行的时候能自动定位到b.jar
,这种自带“依赖关系”的class容器就是模块。
从Java 9开始,原有的Java标准库已经由一个单一巨大的rt.jar
分拆成了几十个模块,这些模块以.jmod
扩展名标识,可以在$JAVA_HOME/jmods
目录下找到它们。
这些.jmod
文件每一个都是一个模块,模块名就是文件名。例如:模块java.base
对应的文件就是java.base.jmod
。模块之间的依赖关系已经被写入到模块内的module-info.class
文件了。所有的模块都直接或间接地依赖java.base
模块,只有java.base
模块不依赖任何模块,它可以被看作是“根模块”,好比所有的类都是从Object
直接或间接继承而来。
把一堆class封装为jar仅仅是一个打包的过程,而把一堆class封装为模块则不但需要打包,还需要写入依赖关系,并且还可以包含二进制代码(通常是JNI扩展)。此外,模块支持多版本,即在同一个模块中可以为不同的JVM提供不同的版本。
在src
目录下多了一个module-info.java
这个文件,这就是模块的描述文件。在这个模块中,它长这样:
1 |
|
其中,module
是关键字,后面的hello.world是模块的名称,它的命名规范与包一致。花括号的requires xxx;
表示这个模块需要引用的其他模块名。除了java.base
可以被自动引入外,这里我们引入了一个java.xml
的模块。当我们使用模块声明了依赖关系后,才能使用引入的模块。
可以用JDK提供的命令行工具来编译并创建模块。运行模块。打包JRE。运行Java程序的时候,实际上我们用到的JDK模块,并没有那么多。不需要的模块,完全可以删除。
过去发布一个Java应用程序,要运行它,必须下载一个完整的JRE,再运行jar包。而完整的JRE块头很大,有100多M。怎么给JRE瘦身呢?现在,JRE自身的标准库已经分拆成了模块,只需要带上程序用到的模块,其他的模块就可以被裁剪掉。怎么裁剪JRE呢?并不是说把系统安装的JRE给删掉部分模块,而是“复制”一份JRE,但只带上用到的模块。
要分发我们自己的Java应用程序,只需要把这个jre
目录打个包给对方发过去,对方直接运行上述命令即可,既不用下载安装JDK,也不用知道如何配置我们自己的模块,极大地方便了分发和部署。
访问权限。class的这些访问权限只在一个模块内有效,模块和模块之间,例如,a模块要访问b模块的某个class,必要条件是b模块明确地导出了可以访问的包。模块进一步隔离了代码的访问权限。