(一)Java入门基础学习教程1
(一)Java入门基础学习教程2
Java的面向对象编程
Java是一种面向对象的编程语言。面向对象编程,英文是Object-Oriented Programming,简称OOP。
- 什么是面向对象编程?
- 和面向对象编程不同的,是面向过程编程。面向过程编程,是把模型分解成一步一步的过程。
- 比如,老板告诉你,要编写一个TODO任务,必须按照以下步骤一步一步来:
- 读取文件;
- 编写TODO;
- 保存文件。
- 而面向对象编程,顾名思义,你得首先有个对象:
- 有了对象后,就可以和对象进行互动:
:::info
GirlFriend gf = new GirlFriend();
gf.name = “Alice”;
gf.send(“flowers”); ::: 所以,面向对象编程,是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。
通过本章的学习,完全可以理解并掌握面向对象的基本思想,但不保证能找到对象。
面向对象基础
面向对象编程,是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。
现实世界中,我们定义了“人”这种抽象概念,而具体的人则是“小明”、“小红”、“小军”等一个个具体的人。所以,“人”可以定义为一个类(class),而具体的人则是实例(instance):
现实世界 | 计算机模型 | Java代码 |
---|---|---|
人 | 类 / class | class Person { } |
小明 | 实例 / ming | Person ming = new Person() |
小红 | 实例 / hong | Person hong = new Person() |
小军 | 实例 / jun | Person jun = new Person() |
同样的,“书”也是一种抽象的概念,所以它是类,而《Java核心技术》、《Java编程思想》、《Java学习笔记》则是实例:
现实世界 | 计算机模型 | Java代码 |
---|---|---|
书 | 类 / class | class Book { } |
Java核心技术 | 实例 / book1 | Book book1 = new Book() |
Java编程思想 | 实例 / book2 | Book book2 = new Book() |
Java学习笔记 | 实例 / book3 | Book book3 = new Book() |
class和instance
所以,只要理解了class
和instance
的概念,基本上就明白了什么是面向对象编程。class
是一种对象模版,它定义了如何创建实例,因此,class
本身就是一种数据类型:
而instance
是对象实例,instance
是根据class
创建的实例,可以创建多个instance
,每个instance
类型相同,但各自属性可能不相同:
定义class
在Java中,创建一个类,例如,给这个类命名为Person
,就是定义一个class
:
:::info
class Person {
public String name;
public int age;
}
:::
一个
**class**
可以包含多个字段(**field**
),字段用来描述一个类的特征。 上面的**Person**
类,我们定义了两个字段,一个是**String**
类型的字段,命名为**name**
,一个是**int**
类型的字段,命名为**age**
。 因此,通过**class**
,把一组数据汇集到一个对象上,实现了数据封装。
public
是用来修饰字段的,它表示这个字段可以被外部访问。
我们再看另一个Book
类的定义:
:::info
class Book {
public String name;
public String author;
public String isbn;
public double price;
}
:::
请指出Book
类的各个字段。
创建实例
定义了
**class**
,只是定义了对象模版,而要根据对象模版创建出真正的对象实例,必须用**new**
操作符。**new**
操作符可以创建一个实例,然后,我们需要定义一个引用类型的变量来指向这个实例:
:::info
Person ming = new Person();
:::
上述代码创建了一个Person
类型的实例,并通过变量ming
指向它。
注意区分
**Person ming**
是定义**Person**
类型的变量**ming**
,而n**ew Person()**
是创建**Person**
实例。
有了指向这个实例的变量,我们就可以通过这个变量来操作实例。访问实例变量可以用_变量.字段_
例如:
:::info
ming.name = “Xiao Ming”; // 对字段name赋值
ming.age = 12; // 对字段age赋值
System.out.println(ming.name); // 访问字段name
Person hong = new Person();
hong.name = “Xiao Hong”;
hong.age = 15;
:::
上述两个变量分别指向两个不同的实例,它们在内存中的结构如下:
┌──────────────────┐
ming ──────>│Person instance │
├──────────────────┤
│name = "Xiao Ming"│
│age = 12 │
└──────────────────┘
┌──────────────────┐
hong ──────>│Person instance │
├──────────────────┤
│name = "Xiao Hong"│
│age = 15 │
└──────────────────┘
两个instance
拥有class
定义的name
和age
字段,且各自都有一份独立的数据,互不干扰。
:::danger
!!!!一个Java源文件可以包含多个类的定义,但只能定义一个public类,且public类名必须与文件名一致。如果要定义多个public类,必须拆到多个Java源文件中。
:::
在OOP中,
**class**
和**instance**
是“模版”和“实例”的关系; 定义**class**
就是定义了一种数据类型,对应的**instance**
是这种数据类型的实例;**class**
定义的**field**
,在每个**instance**
都会拥有各自的**field**
,且互不干扰; 通过**new**
操作符创建新的**instance**
,然后用变量指向它,即可通过变量来引用这个**instance**
; 访问实例字段的方法是变量名.字段名; 指向**instance**
的变量都是引用变量。
方法
一个class
可以包含多个field
,例如,我们给Person
类就定义了两个field
:
:::info
class Person {
public String name;
public int age;
}
:::
但是,直接把field
用public
暴露给外部可能会破坏封装性。
比如,代码可以这样写:
:::info
Person ming = new Person();
ming.name = “Xiao Ming”;
ming.age = -99; // age设置为负数
:::
显然,直接操作field
,容易造成逻辑混乱。
为了避免外部代码直接去访问field
,我们可以用private
修饰field
,拒绝外部访问:
:::info
class Person {
private String name;
private int age;
}
:::
试试private
修饰的field
有什么效果:
public class Main {
public static void main(String[] args) {
Person ming = new Person();
ming.name = "Xiao Ming"; // 对字段name赋值
ming.age = 12; // 对字段age赋值
}
}
class Person {
private String name;
private int age;
}
输出结果如下:
:::danger
编译错误
:::
是不是编译报错?把访问field
的赋值语句去了就可以正常编译了。
满脸??????????????????????????????是不是
把field
从public
改成private
,外部代码不能访问这些field
,那我们定义这些field
有什么用?怎么才能给它赋值?怎么才能读取它的值?
所以我们需要使用方法(method
)来让外部代码可以间接修改field
:
public class Main {
public static void main(String[] args) {
Person ming = new Person();
ming.setName("Xiao Ming"); // 设置name
ming.setAge(12); // 设置age
System.out.println(ming.getName() + ", " + ming.getAge());
}
}
class Person {
private String name;
private int age;
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return this.age;
}
public void setAge(int age) {
if (age < 0 || age > 100) {
throw new IllegalArgumentException("invalid age value");
}
this.age = age;
}
}
输出结果如下:
:::success
Xiao Ming, 12
:::
虽然外部代码不能直接修改private
字段,但是,外部代码可以调用方法setName()
和setAge()
来间接修改private
字段。
在方法内部,我们就有机会检查参数对不对。
比如,setAge()
就会检查传入的参数,参数超出了范围,直接报错。这样,外部代码就没有任何机会把age
设置成不合理的值。
对setName()
方法同样可以做检查,例如,不允许传入null
和空字符串:
:::info
public void setName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException(“invalid name”);
}
this.name = name.strip(); // 去掉首尾空格
}
:::
同样,外部代码不能直接读取private
字段,但可以通过getName()
和getAge()
间接获取private
字段的值。
所以,一个类通过定义方法,就可以给外部代码暴露一些操作的接口,同时,内部自己保证逻辑一致性。
调用方法的语法是实例变量.方法名(参数);。一个方法调用就是一个语句,所以不要忘了在末尾加;。例如:ming.setName("Xiao Ming");
。
定义方法
从上面的代码可以看出,定义方法的语法是:
:::info
修饰符 方法返回类型 方法名(方法参数列表) {
若干方法语句;
return 方法返回值;
}
:::
方法返回值通过return
语句实现,如果没有返回值,返回类型设置为void
,可以省略return
。
private方法
有public
方法,自然就有private
方法。和private
字段一样,private
方法不允许外部调用,那我们定义private
方法有什么用?
定义private
方法的理由是内部方法是可以调用private
方法的。例如:
public class Main {
public static void main(String[] args) {
Person ming = new Person();
ming.setBirth(2008);
System.out.println(ming.getAge());
}
}
class Person {
private String name;
private int birth;
public void setBirth(int birth) {
this.birth = birth;
}
public int getAge() {
return calcAge(2019); // 调用private方法
}
// private方法:
private int calcAge(int currentYear) {
return currentYear - this.birth;
}
}
输出结果如下:
:::success
11
:::
观察上述代码,calcAge()
是一个private
方法,外部代码无法调用,但是,内部方法getAge()
可以调用它。
此外,我们还注意到,这个Person
类只定义了birth
字段,没有定义age
字段,获取age
时,通过方法getAge()
返回的是一个实时计算的值,并非存储在某个字段的值。这说明方法可以封装一个类的对外接口,调用方不需要知道也不关心Person
实例在内部到底有没有age
字段。
this变量
在方法内部,可以使用一个隐含的变量this
,它始终指向当前实例。因此,通过this.field
就可以访问当前实例的字段。
如果没有命名冲突,可以省略this
。
例如:
:::info
class Person {
private String name;
public String getName() {
return name; // 相当于this.name
}
}
:::
但是,如果有局部变量和字段重名,那么局部变量优先级更高,就必须加上this
:
:::info
class Person {
private String name;
public void setName(String name) {
this.name = name; // 前面的this不可少,少了就变成局部变量name了
}
}
:::
方法参数
方法可以包含0个或任意个参数。方法参数用于接收传递给方法的变量值。调用方法时,必须严格按照参数的定义一一传递。
例如:
:::info
class Person {
…
public void setNameAndAge(String name, int age) {
…
}
}
:::
调用这个setNameAndAge()
方法时,必须有两个参数,且第一个参数必须为String
,第二个参数必须为int
:
:::info
Person ming = new Person();
ming.setNameAndAge(“Xiao Ming”); // 编译错误:参数个数不对 ming.setNameAndAge(12, “Xiao Ming”); // 编译错误:参数类型不对
:::
可变参数
可变参数用类型...
定义,可变参数相当于数组类型:
:::info
class Group {
private String[] names;
public void setNames(String… names) {
this.names = names;
}
}
:::
上面的setNames()
就定义了一个可变参数。调用时,可以这么写:
:::info
Group g = new Group();
g.setNames(“Xiao Ming”, “Xiao Hong”, “Xiao Jun”); // 传入3个String
g.setNames(“Xiao Ming”, “Xiao Hong”); // 传入2个String
g.setNames(“Xiao Ming”); // 传入1个String
g.setNames(); // 传入0个String
:::
完全可以把可变参数改写为String[]
类型:
:::info
class Group {
private String[] names;
public void setNames(String[] names) {
this.names = names;
}
}
:::
但是,调用方需要自己先构造String[]
,比较麻烦。例如:
:::info
Group g = new Group();
g.setNames(new String[] {“Xiao Ming”, “Xiao Hong”, “Xiao Jun”}); // 传入1个String[]
:::
另一个问题是,调用方可以传入null
:
:::info
Group g = new Group();
g.setNames(null);
:::
而可变参数可以保证无法传入null
,因为传入0个参数时,接收到的实际值是一个空数组而不是null
。
参数绑定
调用方把参数传递给实例方法时,调用时传递的值会按参数位置一一绑定。
那什么是参数绑定?
我们先观察一个基本类型参数的传递:
public class Main {
public static void main(String[] args) {
Person p = new Person();
int n = 15; // n的值为15
p.setAge(n); // 传入n的值
System.out.println(p.getAge()); // 15
n = 20; // n的值改为20
System.out.println(p.getAge()); // 15还是20?
}
}
class Person {
private int age;
public int getAge() {
return this.age;
}
public void setAge(int age) {
this.age = age;
}
}
输出结果如下:
:::success
15
15
:::
运行代码,从结果可知,修改外部的局部变量n
,不影响实例p
的age
字段,原因是setAge()
方法获得的参数,复制了n
的值,因此,p.age
和局部变量n
互不影响。
结论:基本类型参数的传递,是调用方值的复制。双方各自的后续修改,互不影响。
我们再看一个传递引用参数的例子:
public class Main {
public static void main(String[] args) {
Person p = new Person();
String[] fullname = new String[] { "Homer", "Simpson" };
p.setName(fullname); // 传入fullname数组
System.out.println(p.getName()); // "Homer Simpson"
fullname[0] = "Bart"; // fullname数组的第一个元素修改为"Bart"
System.out.println(p.getName()); // "Homer Simpson"还是"Bart Simpson"?
}
}
class Person {
private String[] name;
public String getName() {
return this.name[0] + " " + this.name[1];
}
public void setName(String[] name) {
this.name = name;
}
}
输出结果如下:
:::success
Homer Simpson
Bart Simpson
:::
注意到setName()
的参数现在是一个数组。一开始,把fullname
数组传进去,然后,修改fullname
数组的内容,结果发现,实例p
的字段p.name
也被修改了!
结论:引用类型参数的传递,调用方的变量,和接收方的参数变量,指向的是同一个对象。双方任意一方对这个对象的修改,都会影响对方(因为指向同一个对象嘛)。
有了上面的结论,我们再看一个例子:
public class Main {
public static void main(String[] args) {
Person p = new Person();
String bob = "Bob";
p.setName(bob); // 传入bob变量
System.out.println(p.getName()); // "Bob"
bob = "Alice"; // bob改名为Alice
System.out.println(p.getName()); // "Bob"还是"Alice"?
}
}
class Person {
private String name;
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
}
输出结果如下:
:::success
Bob
Bob
:::
不要怀疑引用参数绑定的机制,试解释为什么上面的代码两次输出都是"Bob"
。
本章小结
- 方法可以让外部代码安全地访问实例字段;
- 方法是一组执行语句,并且可以执行任意逻辑;
- 方法内部遇到
return
时返回,void
表示不返回任何值(注意和返回null
不同);- 外部代码通过
public
方法操作实例,内部代码可以调用private
方法;- 理解方法的参数绑定。
构造方法
创建实例的时候,我们经常需要同时初始化这个实例的字段,例如:
:::info
Person ming = new Person();
ming.setName(“小明”);
ming.setAge(12);
:::
初始化对象实例需要3行代码,而且,如果忘了调用setName()
或者setAge()
,这个实例内部的状态就是不正确的。
能否在创建对象实例时就把内部字段全部初始化为合适的值?
完全可以。
这时,我们就需要构造方法。
创建实例的时候,实际上是通过构造方法来初始化实例的。我们先来定义一个构造方法,能在创建Person
实例的时候,一次性传入name
和age
,完成初始化:
public class Main {
public static void main(String[] args) {
Person p = new Person("Xiao Ming", 15);
System.out.println(p.getName());
System.out.println(p.getAge());
}
}
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return this.name;
}
public int getAge() {
return this.age;
}
}
输出结果如下:
:::success
Xiao Ming
15
:::
由于构造方法是如此特殊,所以构造方法的名称就是类名。 构造方法的参数没有限制,在方法内部,也可以编写任意语句。但是,和普通方法相比,构造方法没有返回值(也没有
void
),调用构造方法,必须用new
操作符。
默认构造方法
是不是任何
class
都有构造方法?是的。那前面我们并没有为
Person
类编写构造方法,为什么可以调用new Person()
?原因是如果一个类没有定义构造方法,编译器会自动为我们生成一个默认构造方法,它没有参数,也没有执行语句,类似这样:
:::info
class Person {
public Person() {
}
}
:::
要特别注意的是,如果我们自定义了一个构造方法,那么,编译器就不再自动创建默认构造方法:
public class Main {
public static void main(String[] args) {
Person p = new Person(); // 编译错误:找不到这个构造方法
}
}
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return this.name;
}
public int getAge() {
return this.age;
}
}
输出结果如下:
:::success
:::
如果既要能使用带参数的构造方法,又想保留不带参数的构造方法,那么只能把两个构造方法都定义出来:
public class Main {
public static void main(String[] args) {
Person p1 = new Person("Xiao Ming", 15); // 既可以调用带参数的构造方法
Person p2 = new Person(); // 也可以调用无参数构造方法
}
}
class Person {
private String name;
private int age;
public Person() {
}
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return this.name;
}
public int getAge() {
return this.age;
}
}
输出结果如下: :::success
:::
没有在构造方法中初始化字段时,引用类型的字段默认是null
,数值类型的字段用默认值,int
类型默认值是0
,布尔类型默认值是false
:
:::info
class Person {
private String name; // 默认初始化为null
private int age; // 默认初始化为0
public Person() {
}
}
:::
也可以对字段直接进行初始化:
:::info
class Person {
private String name = “Unamed”;
private int age = 10;
}
:::
那么问题来了:既对字段进行初始化,又在构造方法中对字段进行初始化:
:::info
class Person {
private String name = “Unamed”;
private int age = 10;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}
:::
当我们创建对象的时候,new Person("Xiao Ming", 12)
得到的对象实例,字段的初始值是啥?
在Java中,创建对象实例的时候,按照如下顺序进行初始化: 先初始化字段,例如,
int age = 10
;表示字段初始化为10
,double salary
;表示字段默认初始化为0
,String name
;表示引用类型字段默认初始化为null
;执行构造方法的代码进行初始化。 因此,构造方法的代码由于后运行,所以,
new Person("Xiao Ming", 12)
的字段值最终由构造方法的代码确定。
多构造方法
可以定义多个构造方法,在通过
new
操作符调用的时候,编译器通过构造方法的参数数量、位置和类型自动区分:
:::info
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public Person(String name) {
this.name = name;
this.age = 12;
}
public Person() {
}
}
:::
如果调用
new Person("Xiao Ming", 20)
;,会自动匹配到构造方法public Person(String, int)
。如果调用
new Person("Xiao Ming");
,会自动匹配到构造方法public Person(String)
。如果调用
new Person();
,会自动匹配到构造方法public Person()
。一个构造方法可以调用其他构造方法,这样做的目的是便于代码复用。调用其他构造方法的语法是
this(…)
:
:::info
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public Person(String name) {
this(name, 18); // 调用另一个构造方法Person(String, int)
}
public Person() {
this(“Unnamed”); // 调用另一个构造方法Person(String)
}
}
:::
本章小结
实例在创建时通过
new
操作符会调用其对应的构造方法,构造方法用于初始化实例; 没有定义构造方法时,编译器会自动创建一个默认的无参数构造方法; 可以定义多个构造方法,编译器根据参数自动判断; 可以在一个构造方法内部调用另一个构造方法,便于代码复用。
方法重载
在一个类中,我们可以定义多个方法。如果有一系列方法,它们的功能都是类似的,只有参数有所不同,那么,可以把这一组方法名做成同名方法。
例如,在Hello类中,定义多个hello()
方法:
:::info
class Hello {
public void hello() {
System.out.println(“Hello, world!”);
}
public void hello(String name) {
System.out.println(“Hello, “ + name + “!”);
}
public void hello(String name, int age) {
if (age < 18) {
System.out.println(“Hi, “ + name + “!”);
} else {
System.out.println(“Hello, “ + name + “!”);
}
}
}
:::
这种方法名相同,但各自的参数不同,称为方法重载(Overload
)。
注意:方法重载的返回值类型通常都是相同的。
方法重载的目的是,功能类似的方法使用同一名字,更容易记住,因此,调用起来更简单。
举个例子,
String
类提供了多个重载方法indexOf()
,可以查找子串:
int indexOf(int ch)
:根据字符的Unicode码查找;int indexOf(String str)
:根据字符串查找;int indexOf(int ch, int fromIndex)
:根据字符查找,但指定起始位置;int indexOf(String str, int fromIndex)
根据字符串查找,但指定起始位置。
试一试:
public class Main {
public static void main(String[] args) {
String s = "Test string";
int n1 = s.indexOf('t');
int n2 = s.indexOf("st");
int n3 = s.indexOf("st", 4);
System.out.println(n1);
System.out.println(n2);
System.out.println(n3);
}
}
输出结果如下:
本章小结
方法重载是指多个方法的方法名相同,但各自的参数不同; 重载方法应该完成类似的功能,参考
String
的indexOf()
; 重载方法返回值类型应该相同。
继承
在前面的章节中,我们已经定义了
Person
类:
:::info
class Person {
private String name;
private int age;
public String getName() {…}
public void setName(String name) {…}
public int getAge() {…}
public void setAge(int age) {…}
}
:::
现在,假设需要定义一个Student
类,字段如下:
:::info
class Student {
private String name;
private int age;
private int score;
public String getName() {…}
public void setName(String name) {…}
public int getAge() {…}
public void setAge(int age) {…}
public int getScore() { … }
public void setScore(int score) { … }
}
:::
仔细观察,发现Student
类包含了Person
类已有的字段和方法,只是多出了一个score
字段和相应的getScore()
、setScore()
方法。
能不能在Student
中不要写重复的代码?
这个时候,继承就派上用场了。
继承是面向对象编程中非常强大的一种机制,它首先可以复用代码。当我们让
Student
从Person
继承时,Student
就获得了Person
的所有功能,我们只需要为Student
编写新增的功能。Java使用
extends
关键字来实现继承:
:::info
class Person {
private String name;
private int age;
public String getName() {…}
public void setName(String name) {…}
public int getAge() {…}
public void setAge(int age) {…}
}
class Student extends Person {
// 不要重复name和age字段/方法,
// 只需要定义新增score字段/方法:
private int score;
public int getScore() { … }
public void setScore(int score) { … }
}
:::
可见,通过继承,
Student
只需要编写额外的功能,不再需要重复代码。
:::danger 注意:子类自动获得了父类的所有字段,严禁定义与父类重名的字段! :::
在OOP的术语中,我们把
Person
称为超类(super class
),父类(parent class
),基类(base class
),把Student
称为子类(subclass
),扩展类(extended class
)。
继承树
注意到我们在定义
Person
的时候,没有写extends
。在Java中,没有明确写extends
的类,编译器会自动加上extends Object
。所以,任何类,除了Object
,都会继承自某个类。
下图是Person
、Student
的继承树:
┌───────────┐
│ Object │
└───────────┘
▲
│
┌───────────┐
│ Person │
└───────────┘
▲
│
┌───────────┐
│ Student │
└───────────┘
Java只允许一个class继承自一个类,因此,一个类有且仅有一个父类。只有
Object
特殊,它没有父类。
类似的,如果我们定义一个继承自Person
的Teacher
,它们的继承树关系如下:
┌───────────┐
│ Object │
└───────────┘
▲
│
┌───────────┐
│ Person │
└───────────┘
▲ ▲
│ │
│ │
┌───────────┐ ┌───────────┐
│ Student │ │ Teacher │
└───────────┘ └───────────┘
protected
继承有个特点,就是子类无法访问父类的
private
字段或者private
方法。
例如,Student
类就无法访问Person
类的name
和age
字段:
:::info
class Person {
private String name;
private int age;
}
class Student extends Person {
public String hello() {
return “Hello, “ + name; // 编译错误:无法访问name字段
}
}
:::
这使得继承的作用被削弱了。为了让子类可以访问父类的字段,我们需要把private
改为protected
。
用protected
修饰的字段可以被子类访问:
:::info
class Person {
protected String name;
protected int age;
}
class Student extends Person {
public String hello() {
return “Hello, “ + name; // OK!
}
}
:::
因此,
protected
关键字可以把字段和方法的访问权限控制在继承树内部,一个protected
字段和方法可以被其子类,以及子类的子类所访问,后面我们还会详细讲解。
super
super
关键字表示父类(超类)。子类引用父类的字段时,可以用super.fieldName
。
例如:
:::info
class Student extends Person {
public String hello() {
return “Hello, “ + super.name;
}
}
:::
实际上,这里使用super.name
,或者this.name
,或者name
,效果都是一样的。编译器会自动定位到父类的name
字段。
但是,在某些时候,就必须使用super
。我们来看一个例子:
public class Main {
public static void main(String[] args) {
Student s = new Student("Xiao Ming", 12, 89);
}
}
class Person {
protected String name;
protected int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}
class Student extends Person {
protected int score;
public Student(String name, int age, int score) {
this.score = score;
}
}
输出结果如下:
:::danger
编译错误
:::
运行上面的代码,会得到一个编译错误,大意是在Student
的构造方法中,无法调用Person
的构造方法。
这是因为在Java中,任何class
的构造方法,第一行语句必须是调用父类的构造方法。如果没有明确地调用父类的构造方法,编译器会帮我们自动加一句super();
,所以,Student
类的构造方法实际上是这样:
:::info
class Student extends Person {
protected int score;
public Student(String name, int age, int score) {
super(); // 自动调用父类的构造方法
this.score = score;
}
}
:::
但是,Person
类并没有无参数的构造方法,因此,编译失败。
解决方法是调用Person
类存在的某个构造方法。
例如:
:::info
class Student extends Person {
protected int score;
public Student(String name, int age, int score) {
super(name, age); // 调用父类的构造方法Person(String, int)
this.score = score;
}
}
:::
这样就可以正常编译了!
因此我们得出结论:如果父类没有默认的构造方法,子类就必须显式调用super()并给出参数以便让编译器定位到父类的一个合适的构造方法。
这里还顺带引出了另一个问题:即子类不会继承任何父类的构造方法。子类默认的构造方法是编译器自动生成的,不是继承的。
阻止继承
正常情况下,只要某个
class
没有final
修饰符,那么任何类都可以从该class
继承。从Java 15开始,允许使用
sealed
修饰class
,并通过permits
明确写出能够从该class
继承的子类名称。
例如,定义一个Shape
类:
:::info
public sealed class Shape permits Rect, Circle, Triangle {
…
}
:::
上述Shape
类就是一个sealed
类,它只允许指定的3
个类继承它。如果写:
:::info
public final class Rect extends Shape {…}
:::
是没问题的,因为Rect
出现在Shape
的permits
列表中。
但是,如果定义一个Ellipse
就会报错:
:::info
public final class Ellipse extends Shape {…}
// Compile error: class is not allowed to extend sealed class: Shape
:::
原因是Ellipse
并未出现在Shape
的permits
列表中。这种sealed
类主要用于一些框架,防止继承被滥用。
sealed
类在Java 15中目前是预览状态,要启用它,必须使用参数--enable-preview
和--source 15
。
向上转型
如果一个引用变量的类型是
Student
,那么它可以指向一个Student
类型的实例:
:::info Student s = new Student(); :::
如果一个引用类型的变量是
Person
,那么它可以指向一个Person
类型的实例:
:::info
Person p = new Person();
:::
现在问题来了:
如果Student是从Person继承下来的,那么,一个引用类型为Person的变量,能否指向Student类型的实例?
:::info
Person p = new Student(); // ???
:::
测试一下就可以发现,这种指向是允许的!
这是因为Student
继承自Person
,因此,它拥有Person
的全部功能。Person
类型的变量,如果指向Student
类型的实例,对它进行操作,是没有问题的!
这种把一个子类类型安全地变为父类类型的赋值,被称为向上转型(upcasting
)。
向上转型实际上是把一个子类型安全地变为更加抽象的父类型:
:::info
Student s = new Student();
Person p = s; // upcasting, ok
Object o1 = p; // upcasting, ok
Object o2 = s; // upcasting, ok
:::
注意到继承树是Student > Person > Object
,所以,可以把Student
类型转型为Person
,或者更高层次的Object
。
向下转型
和向上转型相反,如果把一个父类类型强制转型为子类类型,就是向下转型(
downcasting
)。
例如:
:::info
Person p1 = new Student(); // upcasting, ok
Person p2 = new Person();
Student s1 = (Student) p1; // ok
Student s2 = (Student) p2; // runtime error! ClassCastException!
:::
如果测试上面的代码,可以发现:
Person
类型p1
实际指向Student
实例,Person
类型变量p2
实际指向Person
实例。在向下转型的时候,把p1
转型为Student
会成功,因为p1
确实指向Student
实例,把p2
转型为Student
会失败,因为p2
的实际类型是Person
,不能把父类变为子类,因为子类功能比父类多,多的功能无法凭空变出来。
因此,向下转型很可能会失败。失败的时候,Java虚拟机会报ClassCastException
。
为了避免向下转型出错,Java提供了instanceof
操作符,可以先判断一个实例究竟是不是某种类型:
:::info
Person p = new Person();
System.out.println(p instanceof Person); // true
System.out.println(p instanceof Student); // false
Student s = new Student();
System.out.println(s instanceof Person); // true
System.out.println(s instanceof Student); // true
Student n = null;
System.out.println(n instanceof Student); // false
:::
instanceof
实际上判断一个变量所指向的实例是否是指定类型,或者这个类型的子类。如果一个引用变量为null
,那么对任何instanceof
的判断都为false
。
利用instanceof
,在向下转型前可以先判断:
:::info
Person p = new Student();
if (p instanceof Student) {
// 只有判断成功才会向下转型:
Student s = (Student) p;// 一定会成功
}
:::
从Java 14开始,判断instanceof
后,可以直接转型为指定变量,避免再次强制转型。
例如,对于以下代码:
:::info
Object obj = “hello”;
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.toUpperCase());
}
:::
可以改写如下:
public class Main {
public static void main(String[] args) {
Object obj = "hello";
if (obj instanceof String s) {
// 可以直接使用变量s:
System.out.println(s.toUpperCase());
}
}
}
输出结果如下:
:::success
区分继承和组合
在使用继承时,我们要注意逻辑一致性。
考察下面的Book
类:
:::info
class Book {
protected String name;
public String getName() {…}
public void setName(String name) {…}
}
:::
这个Book
类也有name
字段,
那么,我们能不能让Student
继承自Book
呢?
:::info
class Student extends Book {
protected int score;
}
:::
显然,从逻辑上讲,这是不合理的,Student
不应该从Book
继承,而应该从Person
继承。
究其原因,是因为Student
是Person
的一种,它们是is
关系,而Student
并不是Book
。实际上Student
和Book
的关系是has
关系。
具有has
关系不应该使用继承,而是使用组合,即Student
可以持有一个Book
实例:
:::info
class Student extends Person {
protected Book book;
protected int score;
}
:::
因此,继承是is
关系,组合是has
关系。
本章小结
- 继承是面向对象编程的一种强大的代码复用方式;
- Java只允许单继承,所有类最终的根类是
Object
;protected
允许子类访问父类的字段和方法;- 子类的构造方法可以通过
super()
调用父类的构造方法;- 可以安全地向上转型为更抽象的类型;
- 可以强制向下转型,最好借助
instanceof
判断;- 子类和父类的关系是
is
,has关
系不能用继承。
多态
在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法,被称为覆写(Override)。
例如,在Person类中,我们定义了run()方法:
:::info
class Person {
public void run() {
System.out.println(“Person.run”);
}
}
:::
在子类Student中,覆写这个run()方法:
:::info
class Student extends Person {
@Override
public void run() {
System.out.println(“Student.run”);
}
}
:::
Override和Overload不同的是,如果方法签名不同,就是Overload,Overload方法是一个新方法;如果方法签名相同,并且返回值也相同,就是Override。
:::danger
注意:方法名相同,方法参数相同,但方法返回值不同,也是不同的方法。在Java程序中,出现这种情况,编译器会报错。
:::
:::info
class Person {
public void run() { … }
}
class Student extends Person {
// 不是Override,因为参数不同:
public void run(String s) { … }
// 不是Override,因为返回值不同:
public int run() { … }
}
:::
加上@Override
可以让编译器帮助检查是否进行了正确的覆写。希望进行覆写,但是不小心写错了方法签名,编译器会报错。
public class Main {
public static void main(String[] args) {
}
}
class Person {
public void run() {}
}
public class Student extends Person {
@Override // Compile error!
public void run(String s) {}
}
输出结果如下:
:::success
:::
但是@Override
不是必需的。
在上一节中,我们已经知道,引用变量的声明类型可能与其实际类型不符,例如:
:::info
Person p = new Student();
:::
public class Main {
public static void main(String[] args) {
Person p = new Student();
p.run(); // 应该打印Person.run还是Student.run?
}
}
class Person {
public void run() {
System.out.println("Person.run");
}
}
class Student extends Person {
@Override
public void run() {
System.out.println("Student.run");
}
}
输出结果如下:
:::success
::: 那么,一个实际类型为Student,引用类型为Person的变量,调用其run()方法,调用的是Person还是Student的run()方法?
运行一下上面的代码就可以知道,实际上调用的方法是Student的run()方法。因此可得出结论:
Java的实例方法调用是基于运行时的实际类型的动态调用,而非变量的声明类型。
这个非常重要的特性在面向对象编程中称之为多态。它的英文拼写非常复杂:Polymorphic。
多态
多态是指,针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法。
例如:
:::info
Person p = new Student(); p.run();
// 无法确定运行时究竟调用哪个run()方法
:::
有童鞋会问,从上面的代码一看就明白,肯定调用的是Student的run()方法啊。
但是,假设我们编写这样一个方法:
:::info
public void runTwice(Person p) {
p.run();
p.run();
}
:::
它传入的参数类型是Person,我们是无法知道传入的参数实际类型究竟是Person,还是Student,还是Person的其他子类,因此,也无法确定调用的是不是Person类定义的run()方法。
所以,多态的特性就是,运行期才能动态决定调用的子类方法。对某个类型调用某个方法,执行的实际方法可能是某个子类的覆写方法。这种不确定性的方法调用,究竟有什么作用?
我们还是来举栗子。
假设我们定义一种收入,需要给它报税,那么先定义一个Income类:
:::info
class Income {
protected double income;
public double getTax() {
return income 0.1; // 税率10%
}
}
:::
对于工资收入,可以减去一个基数,那么我们可以从Income派生出SalaryIncome,并覆写getTax():
:::info
class Salary extends Income {
@Override
public double getTax() {
if (income <= 5000) {
return 0;
}
return (income - 5000) 0.2;
}
}
:::
如果你享受国务院特殊津贴,那么按照规定,可以全部免税:
:::info
class StateCouncilSpecialAllowance extends Income {
@Override
public double getTax() {
return 0;
}
}
:::
现在,我们要编写一个报税的财务软件,对于一个人的所有收入进行报税,可以这么写:
:::info
public double totalTax(Income… incomes) {
double total = 0;
for (Income income: incomes) {
total = total + income.getTax();
}
return total;
}
:::
来试一下:
public class Main {
public static void main(String[] args) {
// 给一个有普通收入、工资收入和享受国务院特殊津贴的小伙伴算税:
Income[] incomes = new Income[] {
new Income(3000),
new Salary(7500),
new StateCouncilSpecialAllowance(15000)
};
System.out.println(totalTax(incomes));
}
public static double totalTax(Income... incomes) {
double total = 0;
for (Income income: incomes) {
total = total + income.getTax();
}
return total;
}
}
class Income {
protected double income;
public Income(double income) {
this.income = income;
}
public double getTax() {
return income * 0.1; // 税率10%
}
}
class Salary extends Income {
public Salary(double income) {
super(income);
}
@Override
public double getTax() {
if (income <= 5000) {
return 0;
}
return (income - 5000) * 0.2;
}
}
class StateCouncilSpecialAllowance extends Income {
public StateCouncilSpecialAllowance(double income) {
super(income);
}
@Override
public double getTax() {
return 0;
}
}
观察totalTax()方法:利用多态,totalTax()方法只需要和Income打交道,它完全不需要知道Salary和StateCouncilSpecialAllowance的存在,就可以正确计算出总的税。如果我们要新增一种稿费收入,只需要从Income派生,然后正确覆写getTax()方法就可以。把新的类型传入totalTax(),不需要修改任何代码。
可见,多态具有一个非常强大的功能,就是允许添加更多类型的子类实现功能扩展,却不需要修改基于父类的代码。
覆写Object方法
因为所有的class最终都继承自Object,而Object定义了几个重要的方法:
- toString():把instance输出为String;
- equals():判断两个instance是否逻辑相等;
- hashCode():计算一个instance的哈希值。
在必要的情况下,我们可以覆写Object的这几个方法。
例如:
class Person {
...
// 显示更有意义的字符串:
@Override
public String toString() {
return "Person:name=" + name;
}
// 比较是否相等:
@Override
public boolean equals(Object o) {
// 当且仅当o为Person类型:
if (o instanceof Person) {
Person p = (Person) o;
// 并且name字段相同时,返回true:
return this.name.equals(p.name);
}
return false;
}
// 计算hash:
@Override
public int hashCode() {
return this.name.hashCode();
}
}
调用super
在子类的覆写方法中,如果要调用父类的被覆写的方法,可以通过super
来调用。
例如:
class Person {
protected String name;
public String hello() {
return "Hello, " + name;
}
}
Student extends Person {
@Override
public String hello() {
// 调用父类的hello()方法:
return super.hello() + "!";
}
}
final
继承可以允许子类覆写父类的方法。如果一个父类不允许子类对它的某个方法进行覆写,可以把该方法标记为final。用final修饰的方法不能被Override:
class Person {
protected String name;
public final String hello() {
return "Hello, " + name;
}
}
Student extends Person {
// compile error: 不允许覆写
@Override
public String hello() {
}
}
如果一个类不希望任何其他类继承自它,那么可以把这个类本身标记为final。
用final修饰的类不能被继承:
:::info
final class Person {
protected String name;
}
// compile error: 不允许继承自Person
Student extends Person {
}
:::
对于一个类的实例字段,同样可以用final修饰。用final修饰的字段在初始化后不能被修改。
例如:
:::info
class Person {
public final String name = “Unamed”;
}
:::
对final字段重新赋值会报错:
:::info
Person p = new Person();
p.name = “New Name”; // compile error!
:::
可以在构造方法中初始化final字段:
:::info
class Person {
public final String name;
public Person(String name) {
this.name = name;
}
}
:::
这种方法更为常用,因为可以保证实例一旦创建,其final字段就不可修改。
本章小结
- 子类可以覆写父类的方法(Override),覆写在子类中改变了父类方法的行为;
- Java的方法调用总是作用于运行期对象的实际类型,这种行为称为多态;
- final修饰符有多种作用:
- final修饰的方法可以阻止被覆写;
- final修饰的class可以阻止被继承;
- final修饰的field必须在创建对象时初始化,随后不可修改。
抽象类
由于多态的存在,每个子类都可以覆写父类的方法
例如: :::info class Person {
public void run() { … }
}
class Student extends Person {
@Override
public void run() { … }
}
class Teacher extends Person {
@Override
public void run() { … }
} ::: 从Person类派生的Student和Teacher都可以覆写run()方法。
如果父类Person的run()方法没有实际意义,能否去掉方法的执行语句? :::info class Person {
public void run(); // Compile Error!
} ::: 答案是不行,会导致编译错误,因为定义方法的时候,必须实现方法的语句。
能不能去掉父类的run()方法?
答案还是不行,因为去掉父类的run()方法,就失去了多态的特性。例如,runTwice()就无法编译:
:::info
public void runTwice(Person p) {
p.run(); // Person没有run()方法,会导致编译错误
p.run();
}
:::
如果父类的方法本身不需要实现任何功能,仅仅是为了定义方法签名,目的是让子类去覆写它,那么,可以把父类的方法声明为抽象方法:
:::info
class Person {
public abstract void run();
}
:::
把一个方法声明为abstract,表示它是一个抽象方法,本身没有实现任何方法语句。因为这个抽象方法本身是无法执行的,所以,Person类也无法被实例化。编译器会告诉我们,无法编译Person类,因为它包含抽象方法。
必须把Person类本身也声明为abstract,才能正确编译它:
:::info
abstract class Person {
public abstract void run();
}
:::
抽象类
如果一个class定义了方法,但没有具体执行代码,这个方法就是抽象方法,抽象方法用abstract修饰。
因为无法执行抽象方法,因此这个类也必须申明为抽象类(abstract class)。
使用abstract修饰的类就是抽象类。我们无法实例化一个抽象类: :::info Person p = new Person(); // 编译错误 ::: 无法实例化的抽象类有什么用?
因为抽象类本身被设计成只能用于被继承,因此,抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错。因此,抽象方法实际上相当于定义了“规范”。
例如,Person类定义了抽象方法run(),那么,在实现子类Student的时候,就必须覆写run()方法:
public class Main {
public static void main(String[] args) {
Person p = new Student();
p.run();
}
}
abstract class Person {
public abstract void run();
}
class Student extends Person {
@Override
public void run() {
System.out.println("Student.run");
}
}
输出结果如下:
面向抽象编程
当我们定义了抽象类Person,以及具体的Student、Teacher子类的时候,我们可以通过抽象类Person类型去引用具体的子类的实例:
:::info
Person s = new Student();
Person t = new Teacher();
:::
这种引用抽象类的好处在于,我们对其进行方法调用,并不关心Person类型变量的具体子类型:
:::info
// 不关心Person变量的具体子类型:
s.run();
t.run();
:::
同样的代码,如果引用的是一个新的子类,我们仍然不关心具体类型:
:::info
// 同样不关心新的子类是如何实现run()方法的:
Person e = new Employee();
e.run();
:::
这种尽量引用高层类型,避免引用实际子类型的方式,称之为面向抽象编程。
面向抽象编程的本质就是:
- 上层代码只定义规范(例如:abstract class Person);
- 不需要子类就可以实现业务逻辑(正常编译);
-
本章小结
通过abstract定义的方法是抽象方法,它只有定义,没有实现。抽象方法定义了子类必须实现的接口规范;
- 定义了抽象方法的class必须被定义为抽象类,从抽象类继承的子类必须实现抽象方法;
- 如果不实现抽象方法,则该子类仍是一个抽象类;
- 面向抽象编程使得调用者只关心抽象方法的定义,不关心子类的具体实现。
接口
在抽象类中,抽象方法本质上是定义接口规范:即规定高层类的接口,从而保证所有子类都有相同的接口实现,这样,多态就能发挥出威力。
如果一个抽象类没有字段,所有方法全部都是抽象方法:
:::info
abstract class Person {
public abstract void run();
public abstract String getName();
}
:::
就可以把该抽象类改写为接口:interface。
在Java中,使用interface可以声明一个接口:
:::info
interface Person {
void run();
String getName();
}
:::
所谓interface,就是比抽象类还要抽象的纯抽象接口,因为它连字段都不能有。因为接口定义的所有方法默认都是public abstract的,所以这两个修饰符不需要写出来(写不写效果都一样)。
当一个具体的class去实现一个interface时,需要使用implements关键字。举个例子:
:::info
class Student implements Person {
private String name;
public Student(String name) {
this.name = name;
}
@Override
public void run() {
System.out.println(this.name + “ run”);
}
@Override
public String getName() {
return this.name;
}
}
:::
我们知道,在Java中,一个类只能继承自另一个类,不能从多个类继承。但是,一个类可以实现多个interface
例如:
:::info
class Student implements Person, Hello { // 实现了两个interface
…
}
:::
术语
注意区分术语:
Java的接口特指interface的定义,表示一个接口类型和一组方法签名,而编程接口泛指接口规范,如方法签名,数据格式,网络协议等。
抽象类和接口的对比如下:
abstract class | interface | |
---|---|---|
继承 | 只能extends一个class | 可以implements多个interface |
字段 | 可以定义实例字段 | 不能定义实例字段 |
抽象方法 | 可以定义抽象方法 | 可以定义抽象方法 |
非抽象方法 | 可以定义非抽象方法 | 可以定义default方法 |
接口继承
一个interface可以继承自另一个interface。interface继承自interface使用extends,它相当于扩展了接口的方法。
例如:
:::info
interface Hello {
void hello();
}
interface Person extends Hello {
void run();
String getName();
}
:::
此时,Person接口继承自Hello接口,因此,Person接口现在实际上有3个抽象方法签名,其中一个来自继承的Hello接口。
继承关系
合理设计interface和abstract class的继承关系,可以充分复用代码。一般来说,公共逻辑适合放在abstract class中,具体逻辑放到各个子类,而接口层次代表抽象程度。可以参考Java的集合类定义的一组接口、抽象类以及具体子类的继承关系:
┌───────────────┐
│ Iterable │
└───────────────┘
▲ ┌───────────────────┐
│ │ Object │
┌───────────────┐ └───────────────────┘
│ Collection │ ▲
└───────────────┘ │
▲ ▲ ┌───────────────────┐
│ └──────────│AbstractCollection │
┌───────────────┐ └───────────────────┘
│ List │ ▲
└───────────────┘ │
▲ ┌───────────────────┐
└──────────│ AbstractList │
└───────────────────┘
▲ ▲
│ │
│ │
┌────────────┐ ┌────────────┐
│ ArrayList │ │ LinkedList │
└────────────┘ └────────────┘
在使用的时候,实例化的对象永远只能是某个具体的子类,但总是通过接口去引用它,因为接口比抽象类更抽象:
:::info
List list = new ArrayList(); // 用List接口引用具体子类的实例
Collection coll = list; // 向上转型为Collection接口
Iterable it = coll; // 向上转型为Iterable接口
:::
default方法
在接口中,可以定义default方法。例如,把Person接口的run()方法改为default方法:
public class Main {
public static void main(String[] args) {
Person p = new Student("Xiao Ming");
p.run();
}
}
interface Person {
String getName();
default void run() {
System.out.println(getName() + " run");
}
}
class Student implements Person {
private String name;
public Student(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
}
输出结果如下:
:::success Xiao Ming run ::: 实现类可以不必覆写default方法。default方法的目的是,当我们需要给接口新增一个方法时,会涉及到修改全部子类。如果新增的是default方法,那么子类就不必全部修改,只需要在需要覆写的地方去覆写新增方法。
default方法和抽象类的普通方法是有所不同的。因为interface没有字段,default方法无法访问字段,而抽象类的普通方法可以访问实例字段。
本章小结
Java的接口(interface)定义了纯抽象规范,一个类可以实现多个接口;
接口也是数据类型,适用于向上转型和向下转型;
接口的所有方法都是抽象方法,接口不能定义实例字段;
接口可以定义default方法(JDK>=1.8)。
静态字段和静态方法
在一个class中定义的字段,我们称之为实例字段。实例字段的特点是,每个实例都有独立的字段,各个实例的同名字段互不影响。
还有一种字段,是用static修饰的字段,称为静态字段:static field。
实例字段在每个实例中都有自己的一个独立“空间”,但是静态字段只有一个共享“空间”,所有实例都会共享该字段。举个例子:
:::info
class Person {
public String name;
public int age;
// 定义静态字段number:
public static int number;
}
:::
我们来看看下面的代码:
public class Main {
public static void main(String[] args) {
Person ming = new Person("Xiao Ming", 12);
Person hong = new Person("Xiao Hong", 15);
ming.number = 88;
System.out.println(hong.number);
hong.number = 99;
System.out.println(ming.number);
}
}
class Person {
public String name;
public int age;
public static int number;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}
输出结果如下:
:::success
88
99
:::
对于静态字段,无论修改哪个实例的静态字段,效果都是一样的:所有实例的静态字段都被修改了,原因是静态字段并不属于实例:
┌──────────────────┐
ming ──>│Person instance │
├──────────────────┤
│name = "Xiao Ming"│
│age = 12 │
│number ───────────┼──┐ ┌─────────────┐
└──────────────────┘ │ │Person class │
│ ├─────────────┤
├───>│number = 99 │
┌──────────────────┐ │ └─────────────┘
hong ──>│Person instance │ │
├──────────────────┤ │
│name = "Xiao Hong"│ │
│age = 15 │ │
│number ───────────┼──┘
└──────────────────┘
虽然实例可以访问静态字段,但是它们指向的其实都是Person class的静态字段。所以,所有实例共享一个静态字段。
因此,不推荐用实例变量.静态字段去访问静态字段,因为在Java程序中,实例对象并没有静态字段。在代码中,实例对象能访问静态字段只是因为编译器可以根据实例类型自动转换为类名.静态字段来访问静态对象。
推荐用类名来访问静态字段。可以把静态字段理解为描述class本身的字段(非实例字段)。对于上面的代码,更好的写法是:
:::info
Person.number = 99;
System.out.println(Person.number);
:::
静态方法
有静态字段,就有静态方法。用static修饰的方法称为静态方法。
调用实例方法必须通过一个实例变量,而调用静态方法则不需要实例变量,通过类名就可以调用。静态方法类似其它编程语言的函数。例如:
public class Main {
public static void main(String[] args) {
Person.setNumber(99);
System.out.println(Person.number);
}
}
class Person {
public static int number;
public static void setNumber(int value) {
number = value;
}
}
输出结果如下:
:::success
99
:::
因为静态方法属于class而不属于实例,因此,静态方法内部,无法访问this变量,也无法访问实例字段,它只能访问静态字段。
通过实例变量也可以调用静态方法,但这只是编译器自动帮我们把实例改写成类名而已。
通常情况下,通过实例变量访问静态字段和静态方法,会得到一个编译警告。
静态方法经常用于工具类。例如:
- Arrays.sort()
- Math.random()
静态方法也经常用于辅助方法。注意到Java程序的入口main()也是静态方法。
接口的静态字段
因为interface是一个纯抽象类,所以它不能定义实例字段。但是,interface是可以有静态字段的,并且静态字段必须为final类型:
:::info
public interface Person {
public static final int MALE = 1;
public static final int FEMALE = 2;
}
:::
实际上,因为interface的字段只能是public static final类型,所以我们可以把这些修饰符都去掉,上述代码可以简写为:
:::info
public interface Person {
// 编译器会自动加上public statc final:
int MALE = 1;
int FEMALE = 2;
}
:::
编译器会自动把该字段变为public static final类型。
本章小结
- 静态字段属于所有实例“共享”的字段,实际上是属于class的字段;
- 调用静态方法不需要实例,无法访问this,但可以访问静态字段和其他静态方法;
- 静态方法常用于工具类和辅助方法。
包
在前面的代码中,我们把类和接口命名为Person
、Student
、Hello
等简单名字。
在现实中,如果小明写了一个Person
类,小红也写了一个Person
类,现在,小白既想用小明的Person
,也想用小红的Person
,怎么办?
如果小军写了一个Arrays类,恰好JDK也自带了一个Arrays类,如何解决类名冲突?
在Java中,我们使用package来解决名字冲突。
Java定义了一种名字空间,称之为包:package。一个类总是属于某个包,类名(比如Person)只是一个简写,真正的完整类名是包名.类名。
例如:
小明的Person类存放在包ming下面,因此,完整类名是ming.Person;
小红的Person类存放在包hong下面,因此,完整类名是hong.Person;
小军的Arrays类存放在包mr.jun下面,因此,完整类名是mr.jun.Arrays;
JDK的Arrays类存放在包java.util下面,因此,完整类名是java.util.Arrays。
在定义class的时候,我们需要在第一行声明这个class属于哪个包。
小明的Person.java文件: :::info package ming;
// 申明包名ming
public class Person {
} ::: 小军的Arrays.java文件: :::info package mr.jun;
// 申明包名mr.jun
public class Arrays {
} ::: 在Java虚拟机执行的时候,JVM只看完整类名,因此,只要包名不同,类就不同。
包可以是多层结构,用.隔开。例如:java.util。 :::danger 要特别注意:包没有父子关系。java.util和java.util.zip是不同的包,两者没有任何继承关系。 ::: 没有定义包名的class,它使用的是默认包,非常容易引起名字冲突,因此,不推荐不写包名的做法。
我们还需要按照包结构把上面的Java文件组织起来。假设以package_sample作为根目录,src作为源码目录,那么所有文件结构就是:
package_sample
└─ src
├─ hong
│ └─ Person.java
│ ming
│ └─ Person.java
└─ mr
└─ jun
└─ Arrays.java
即所有Java文件对应的目录层次要和包的层次一致。
编译后的.class文件也需要按照包结构存放。如果使用IDE,把编译后的.class文件放到bin目录下,那么,编译的文件结构就是:
package_sample
└─ bin
├─ hong
│ └─ Person.class
│ ming
│ └─ Person.class
└─ mr
└─ jun
└─ Arrays.class
编译的命令相对比较复杂,我们需要在src目录下执行javac命令: :::info javac -d ../bin ming/Person.java hong/Person.java mr/jun/Arrays.java ::: 在IDE中,会自动根据包结构编译所有Java源码,所以不必担心使用命令行编译的复杂命令。
包作用域
位于同一个包的类,可以访问包作用域的字段和方法。不用public、protected、private修饰的字段和方法就是包作用域。
例如,Person类定义在hello包下面:
:::info
package hello;
public class Person {
// 包作用域:
void hello() {
System.out.println(“Hello!”);
}
}
:::
Main类也定义在hello包下面:
:::info
package hello;
public class Main {
public static void main(String[] args) {
Person p = new Person();
p.hello(); // 可以调用,因为Main和Person在同一个包
}
}
:::
import
在一个class中,我们总会引用其他的class。例如,小明的ming.Person类,如果要引用小军的mr.jun.Arrays类,他有三种写法:
第一种,直接写出完整类名,例如:
:::info
// Person.java
package ming; public class Person {
public void run() {
mr.jun.Arrays arrays = new mr.jun.Arrays();
}
}
:::
很显然,每次写完整类名比较痛苦。
因此,第二种写法是用import语句,导入小军的Arrays,然后写简单类名:
:::info
// Person.java
package ming;
// 导入完整类名:
import mr.jun.Arrays;
public class Person {
public void run() {
Arrays arrays = new Arrays();
}
}
:::
在写import的时候,可以使用,表示把这个包下面的所有class都导入进来(但不包括子包的class):
:::info
// Person.java
package ming;
// 导入mr.jun包的所有class:
import mr.jun.;
public class Person {
public void run() {
Arrays arrays = new Arrays();
}
}
:::
我们一般不推荐这种写法,因为在导入了多个包后,很难看出Arrays类属于哪个包。
还有一种import static的语法,它可以导入可以导入一个类的静态字段和静态方法:
:::info
package main;
// 导入System类的所有静态字段和静态方法:
import static java.lang.System.;
public class Main {
public static *void main(String[] args) {
// 相当于调用System.out.println(…)
out.println(“Hello, world!”);
}
}
:::
import static很少使用。
Java编译器最终编译出的.class文件只使用完整类名,因此,在代码中,当编译器遇到一个class名称时:
- 如果是完整类名,就直接根据完整类名查找这个class;
- 如果是简单类名,按下面的顺序依次查找:
- 查找当前package是否存在这个class;
- 查找import的包是否包含这个class;
- 查找java.lang包是否包含这个class。
如果按照上面的规则还无法确定类名,则编译报错。
我们来看一个例子:
:::info
// Main.java
package test;
import java.text.Format;
public class Main {
public static void main(String[] args) {
java.util.List list; // ok,使用完整类名 -> java.util.List
Format format = null; // ok,使用import的类 -> java.text.Format
String s = “hi”; // ok,使用java.lang包的String -> java.lang.String
System.out.println(s); // ok,使用java.lang包的System -> java.lang.System
MessageFormat mf = null; // 编译错误:无法找到MessageFormat: MessageFormat cannot be resolved to a type
}
}
:::
因此,编写class的时候,编译器会自动帮我们做两个import动作:
- 默认自动import当前package的其他class;
默认自动import java.lang.*。 :::info 注意:自动导入的是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可以被其他任何类访问: :::info package abc;
public class Hello {
public void hi() {
}
} ::: 上面的Hello是public,因此,可以被其他包的类访问: :::info package xyz;
class Main {
void foo() {
// Main可以访问Hello
Hello h = new Hello();
}
} ::: 定义为public的field、method可以被其他类访问,前提是首先有访问class的权限: :::info package abc;
public class Hello {
public void hi() {
}
} ::: 上面的hi()方法是public,可以被其他类调用,前提是首先要能访问Hello类: :::info package xyz;
class Main {
void foo() {
Hello h = new Hello();
h.hi();
}
} :::private
定义为private的field、method无法被其他类访问: :::info package abc;
public class Hello {
// 不能被其他类调用:
private void hi() {
}
public void hello() {
this.hi();
}
} ::: 实际上,确切地说,private访问权限被限定在class的内部,而且与方法声明顺序无关。推荐把private方法放到后面,因为public方法定义了类对外提供的功能,阅读代码的时候,应该先关注public方法: :::info package abc;
public class Hello {
public void hello() {
this.hi();
}
private void hi() {
}
} ::: 由于Java支持嵌套类,如果一个类内部还定义了嵌套类,那么,嵌套类拥有访问private的权限:public class Main {
public static void main(String[] args) {
Inner i = new Inner();
i.hi();
}
// private方法:
private static void hello() {
System.out.println("private hello!");
}
// 静态内部类:
static class Inner {
public void hi() {
Main.hello();
}
}
}
输出结果如下: :::success
::: 定义在一个class内部的class称为嵌套类(nested class),Java支持好几种嵌套类。
protected
protected作用于继承关系。定义为protected的字段和方法可以被子类访问,以及子类的子类:
:::info
package abc;
public class Hello {
// protected方法:
protected void hi() {
}
}
:::
上面的protected方法可以被继承的类访问:
:::info
package xyz;
class Main extends Hello {
void foo() {
// 可以访问protected方法:
hi();
}
}
:::
package
最后,包作用域是指一个类允许访问同一个package的没有public、private修饰的class,以及没有public、protected、private修饰的字段和方法。
:::info
package abc;
// package权限的类:
class Hello {
// package权限的方法:
void hi() {
}
}
:::
只要在同一个包,就可以访问package权限的class、field和method:
:::info
package abc;
class Main {
void foo() {
// 可以访问package权限的类:
Hello h = new Hello();
// 可以调用package权限的方法:
h.hi();
}
}
:::
注意,包名必须完全一致,包没有父子关系,com.apache和com.apache.abc是不同的包。
局部变量
在方法内部定义的变量称为局部变量,局部变量作用域从变量声明处开始到对应的块结束。方法参数也是局部变量。
:::info
package abc;
public class Hello {
void hi(String name) { // ①
String s = name.toLowerCase(); // ②
int len = s.length(); // ③
if (len < 10) { // ④
int p = 10 - len; // ⑤
for (int i=0; i<10; i++) { _// ⑥_
System.out.println(); // ⑦
} // ⑧
} // ⑨
} // ⑩
}
:::
我们观察上面的hi()
方法代码:
- 方法参数name是局部变量,它的作用域是整个方法,即①~⑩;
- 变量s的作用域是定义处到方法结束,即②~⑩;
- 变量len的作用域是定义处到方法结束,即③~⑩;
- 变量p的作用域是定义处到if块结束,即⑤~⑨;
- 变量i的作用域是for循环,即⑥~⑧。
使用局部变量时,应该尽可能把局部变量的作用域缩小,尽可能延后声明局部变量。
final
Java还提供了一个final修饰符。final与访问权限不冲突,它有很多作用。
用final修饰class可以阻止被继承:
:::info
package abc;
// 无法被继承:
public final class Hello {
private int n = 0;
protected void hi(int t) {
long i = t;
}
}
:::
用final修饰method可以阻止被子类覆写:
:::info
package abc;
public class Hello {
// 无法被覆写:
protected final void hi() {
}
}
:::
用final修饰field可以阻止被重新赋值:
:::info
package abc;
public class Hello {
private final int n = 0;
protected void hi() {
this.n = 1; // error!
}
}
:::
用final修饰局部变量可以阻止被重新赋值:
:::info
package abc;
public class Hello {
protected void hi(final int t) {
t = 1; // error!
}
}
:::
最佳的实践方法
如果不确定是否需要public,就不声明为public,即尽可能少地暴露对外的字段和方法。
把方法定义为package权限有助于测试,因为测试类和被测试类只要位于同一个package,测试代码就可以访问被测试类的package权限方法。
一个.java文件只能包含一个public类,但可以包含多个非public类。如果有public类,文件名必须和public类的名字相同。
本章小结
Java内建的访问权限包括public、protected、private和package权限;
Java在方法内部定义的变量是局部变量,局部变量的作用域从变量声明开始,到一个块结束;
final修饰符不是访问权限,它可以修饰class、field和method;
一个.java文件只能包含一个public类,但可以包含多个非public类。
内部类
在Java程序中,通常情况下,我们把不同的类组织在不同的包下面,对于一个包下面的类来说,它们是在同一层次,没有父子关系:
java.lang
├── Math
├── Runnable
├── String
└── ...
还有一种类,它被定义在另一个类的内部,所以称为内部类(Nested Class)。Java的内部类分为好几种,通常情况用得不多,但也需要了解它们是如何使用的。
Inner Class
如果一个类定义在另一个类的内部,这个类就是Inner Class:
:::info
class Outer {
class Inner {
// 定义了一个Inner Class
}
}
:::
上述定义的Outer是一个普通类,而Inner是一个Inner Class,它与普通类有个最大的不同,就是Inner Class的实例不能单独存在,必须依附于一个Outer Class的实例。
示例代码如下:
public class Main {
public static void main(String[] args) {
Outer outer = new Outer("Nested"); // 实例化一个Outer
Outer.Inner inner = outer.new Inner(); // 实例化一个Inner
inner.hello();
}
}
class Outer {
private String name;
Outer(String name) {
this.name = name;
}
class Inner {
void hello() {
System.out.println("Hello, " + Outer.this.name);
}
}
}
输出结果如下:
:::success
::: 观察上述代码,要实例化一个Inner,我们必须首先创建一个Outer的实例,然后,调用Outer实例的new来创建Inner实例: :::info 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)来定义。
示例代码如下:
public class Main {
public static void main(String[] args) {
Outer outer = new Outer("Nested");
outer.asyncHello();
}
}
class Outer {
private String name;
Outer(String name) {
this.name = name;
}
void asyncHello() {
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Hello, " + Outer.this.name);
}
};
new Thread(r).start();
}
}
输出结果如下: :::success
:::
观察asyncHello()方法,我们在方法内部实例化了一个Runnable。Runnable本身是接口,接口是不能实例化的,所以这里实际上是定义了一个实现了Runnable接口的匿名类,并且通过new实例化该匿名类,然后转型为Runnable。
在定义匿名类的时候就必须实例化它,定义匿名类的写法如下:
:::info
Runnable r = new Runnable() {
// 实现必要的抽象方法…
};
:::
匿名类和Inner Class一样,可以访问Outer Class的private字段和方法。之所以我们要定义匿名类,是因为在这里我们通常不关心类名,比直接定义Inner Class可以少写很多代码。
观察Java编译器编译后的.class文件可以发现,Outer类被编译为Outer.class,而匿名类被编译为Outer$1.class。如果有多个匿名类,Java编译器会将每个匿名类依次命名为Outer$1、Outer$2、Outer$3……
除了接口外,匿名类也完全可以继承自普通类。观察以下代码:
import java.util.HashMap;
public class Main {
public static void main(String[] args) {
HashMap<String, String> map1 = new HashMap<>();
HashMap<String, String> map2 = new HashMap<>() {}; // 匿名类!
HashMap<String, String> map3 = new HashMap<>() {
{
put("A", "1");
put("B", "2");
}
};
System.out.println(map3.get("A"));
}
}
输出结果如下: :::success
::: map1是一个普通的HashMap实例,但map2是一个匿名类实例,只是该匿名类继承自HashMap。map3也是一个继承自HashMap的匿名类实例,并且添加了static代码块来初始化数据。观察编译输出可发现Main$1.class和Main$2.class两个匿名类文件。
Static Nested Class
最后一种内部类和Inner Class类似,但是使用static修饰,称为静态内部类(Static Nested Class):
public class Main {
public static void main(String[] args) {
Outer.StaticNested sn = new Outer.StaticNested();
sn.hello();
}
}
class Outer {
private static String NAME = "OUTER";
private String name;
Outer(String name) {
this.name = name;
}
static class StaticNested {
void hello() {
System.out.println("Hello, " + Outer.NAME);
}
}
}
输出结果如下: :::success
::: 用static修饰的内部类和Inner Class有很大的不同,它不再依附于Outer的实例,而是一个完全独立的类,因此无法引用Outer.this,但它可以访问Outer的private静态字段和静态方法。如果把StaticNested移到Outer之外,就失去了访问private的权限。
本章小结
Java的内部类可分为Inner Class、Anonymous Class和Static 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系统上,用;分隔,带空格的目录用””括起来,可能长这样: :::info C:\work\project1\bin;C:\shared;”D:\My Documents\project1\bin” ::: 在Linux系统上,用:分隔,可能长这样: :::info /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参数: :::info java -classpath .;C:\work\project1\bin;C:\shared abc.xyz.Hello ::: 或者使用-cp的简写: :::info java -cp .;C:\work\project1\bin;C:\shared abc.xyz.Hello ::: 没有设置系统环境变量,也没有传入-cp参数,那么JVM默认的classpath为.,即当前目录: :::info 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怎么可能笨到连自己的核心库在哪都不知道? :::danger 不要把任何Java核心库添加到classpath中!JVM根本不依赖classpath加载核心库! ::: 更好的做法是,不要设置classpath!默认的当前目录.对于绝大多数情况都够用了。
假设我们有一个编译后的Hello.class,它的包名是com.example,当前目录是C:\work,那么,目录结构必须如下:
C:\work
└─ com
└─ example
└─ Hello.class
运行这个Hello.class必须在当前目录下使用如下命令: :::info 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中: :::info java -cp ./hello.jar abc.xyz.Hello ::: 这样JVM会自动在hello.jar文件里去搜索某个类。
那么问题来了:如何创建jar包?
因为jar包就是zip包,所以,直接在资源管理器中,找到正确的目录,点击右键,在弹出的快捷菜单中选择“发送到”,“压缩(zipped)文件夹”,就制作了一个zip文件。然后,把后缀从.zip改为.jar,一个jar包就创建成功。
假设编译输出的目录结构是这样:
package_sample
└─ bin
├─ hong
│ └─ Person.class
│ ming
│ └─ Person.class
└─ mr
└─ jun
└─ Arrays.class
这里需要特别注意的是,jar包里的第一层目录,不能是bin,而应该是hong、ming、mr。如果在Windows的资源管理器中看,应该长这样:
如果长这样:
说明打包打得有问题,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,我们就不必在命令行指定启动的类名,而是用更方便的命令: :::info 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程序,一般来说,命令行写这样:
:::info
java -cp app.jar:a.jar:b.jar:c.jar com.liaoxuefeng.sample.Main
:::
:::danger
注意: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工程为例,它的目录结构如下:
oop-module
├── bin
├── build.sh
└── src
├── com
│ └── itranswarp
│ └── sample
│ ├── Greeting.java
│ └── Main.java
└── module-info.java
其中,bin目录存放编译后的class文件,src目录存放源码,按包名的目录结构存放,仅仅在src目录下多了一个module-info.java这个文件,这就是模块的描述文件。在这个模块中,它长这样:
:::info
module hello.world {
requires java.base; // 可不写,任何模块都会自动引入java.base
requires java.xml;
}
:::
其中,module是关键字,后面的hello.world是模块的名称,它的命名规范与包一致。花括号的requires xxx;表示这个模块需要引用的其他模块名。除了java.base可以被自动引入外,这里我们引入了一个java.xml的模块。
当我们使用模块声明了依赖关系后,才能使用引入的模块。例如,Main.java代码如下:
:::info
package com.itranswarp.sample;
// 必须引入java.xml模块后才能使用其中的类:
import javax.xml.XMLConstants;
public class Main {
public static void main(String[] args) {
Greeting g = new Greeting();
System.out.println(g.hello(XMLConstants.XML_NS_PREFIX));
}
}
:::
如果把requires java.xml;从module-info.java中去掉,编译将报错。可见,模块的重要作用就是声明依赖关系。
下面,我们用JDK提供的命令行工具来编译并创建模块。
首先,我们把工作目录切换到oop-module,在当前目录下编译所有的.java文件,并存放到bin目录下,命令如下: :::info $ javac -d bin src/module-info.java src/com/itranswarp/sample/*.java ::: 如果编译成功,现在项目结构如下:
oop-module
├── bin
│ ├── com
│ │ └── itranswarp
│ │ └── sample
│ │ ├── Greeting.class
│ │ └── Main.class
│ └── module-info.class
└── src
├── com
│ └── itranswarp
│ └── sample
│ ├── Greeting.java
│ └── Main.java
└── module-info.java
注意到src目录下的module-info.java被编译到bin目录下的module-info.class。
下一步,我们需要把bin目录下的所有class文件先打包成jar,在打包的时候,注意传入—main-class参数,让这个jar包能自己定位main方法所在的类:
:::info
$ jar —create —file hello.jar —main-class com.itranswarp.sample.Main -C bin .
:::
现在我们就在当前目录下得到了hello.jar这个jar包,它和普通jar包并无区别,可以直接使用命令java -jar hello.jar来运行它。但是我们的目标是创建模块,所以,继续使用JDK自带的jmod命令把一个jar包转换成模块:
:::info
$ jmod create —class-path hello.jar hello.jmod
:::
于是,在当前目录下我们又得到了hello.jmod这个模块文件,这就是最后打包出来的传说中的模块!
运行模块
要运行一个jar,我们使用java -jar xxx.jar命令。要运行一个模块,我们只需要指定模块名。
试试:
:::info
$ java —module-path hello.jmod —module hello.world
:::
结果是一个错误:
:::info
Error occurred during initialization of boot layer java.lang.module.FindException: JMOD format not supported at execution time: hello.jmod
:::
原因是.jmod不能被放入—module-path中。换成.jar就没问题了:
:::info
$ java —module-path hello.jar —module hello.world Hello, xml!
:::
那我们辛辛苦苦创建的hello.jmod有什么用?答案是我们可以用它来打包JRE。
打包JRE
前面讲了,为了支持模块化,Java 9首先带头把自己的一个巨大无比的rt.jar拆成了几十个.jmod模块,原因就是,运行Java程序的时候,实际上我们用到的JDK模块,并没有那么多。不需要的模块,完全可以删除。
过去发布一个Java应用程序,要运行它,必须下载一个完整的JRE,再运行jar包。而完整的JRE块头很大,有100多M。怎么给JRE瘦身呢?
现在,JRE自身的标准库已经分拆成了模块,只需要带上程序用到的模块,其他的模块就可以被裁剪掉。怎么裁剪JRE呢?并不是说把系统安装的JRE给删掉部分模块,而是“复制”一份JRE,但只带上用到的模块。为此,JDK提供了jlink命令来干这件事。命令如下: :::info $ 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: :::info $ jre/bin/java —module hello.world Hello, xml! ::: 要分发我们自己的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中声明了若干导出:
:::info
module java.xml {
exports java.xml;
exports javax.xml.catalog;
exports javax.xml.datatype;
…
}
:::
只有它声明的导出的包,外部代码才被允许访问。换句话说,如果外部代码想要访问我们的hello.world模块中的com.itranswarp.sample.Greeting类,我们必须将其导出:
:::info
module hello.world {
exports com.itranswarp.sample;
requires java.base;
requires java.xml;
}
:::
因此,模块进一步隔离了代码的访问权限。
本章小结
Java 9引入的模块目的是为了管理依赖;
使用模块可以按需打包JRE;
使用模块对类的访问权限有了进一步限制。