面向对象基础
约 16547 字大约 55 分钟
2025-07-13
1.1 方法
1.1.1 private
Field
一个 class
可以包含多个 field
。直接将 field
使用 public
暴露给外部可能会破坏封装性,因此可以使用 private
修饰 field
,拒绝外部访问。
使用 private
修饰 field
后,外部代码不能直接访问这些 field
。为了能够让外部代码可以间接修改 field
,我们需要使用方法 (method
)。
例如:
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;
}
}
外部代码可以通过调用 setName()
和 setAge()
方法来间接修改 private
字段。在方法内部,我们可以检查参数的有效性,以保证数据的安全性。同样,外部代码可以通过 getName()
和 getAge()
方法间接获取 private
字段的值。
一个类通过定义方法,可以给外部代码暴露一些操作的接口,同时,内部自己保证逻辑一致性。
调用方法的语法是 实例变量.方法名(参数);
。
1.1.2 定义方法
定义方法的语法是:
修饰符 方法返回类型 方法名(方法参数列表) {
若干方法语句;
return 方法返回值;
}
方法返回值通过 return
语句实现,如果没有返回值,返回类型设置为 void
,可以省略 return
。
1.1.3 private
方法
和 private
字段一样,private
方法不允许外部调用。定义 private
方法的理由是内部方法是可以调用 private
方法的。
例如:
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;
}
}
上述代码中,calcAge()
是一个 private
方法,外部代码无法调用,但是,内部方法 getAge()
可以调用它。
Person
类只定义了 birth
字段,没有定义 age
字段,获取 age
时,通过方法 getAge()
返回的是一个实时计算的值,并非存储在某个字段的值。这说明方法可以封装一个类的对外接口,调用方不需要知道也不关心 Person
实例在内部到底有没有 age
字段。
1.1.4 this
变量
在方法内部,可以使用一个隐含的变量 this
,它始终指向当前实例。因此,通过 this.field
就可以访问当前实例的字段。
如果没有命名冲突,可以省略 this
。
class Person {
private String name;
public String getName() {
return name; // 相当于this.name
}
}
但是,如果有局部变量和字段重名,那么局部变量优先级更高,就必须加上 this
。
class Person {
private String name;
public void setName(String name) {
this.name = name; // 前面的this不可少,少了就变成局部变量name了
}
}
1.1.5 可变参数
可变参数用 类型...
定义,可变参数相当于数组类型。
例如:
class Group {
private String[] names;
public void setNames(String... names) {
this.names = names;
}
}
setNames()
方法定义了一个可变参数。调用时,可以传入任意数量的 String
类型的参数。可变参数可以保证无法传入 null
,因为传入 0 个参数时,接收到的实际值是一个空数组而不是 null
。
1.1.6 参数绑定
调用方把参数传递给实例方法时,调用时传递的值会按参数位置一一绑定。
基本类型参数的传递,是调用方值的复制。双方各自的后续修改,互不影响。
例如:
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
上述代码中,修改外部的局部变量 n
,不影响实例 p
的 age
字段,原因是 setAge()
方法获得的参数,复制了 n
的值,因此,p.age
和局部变量 n
互不影响。
引用类型参数的传递,调用方的变量,和接收方的参数变量,指向的是同一个对象。双方任意一方对这个对象的修改,都会影响对方(因为指向同一个对象)。
例如:
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()); // "Bart Simpson"
上述代码中,setName()
的参数是一个数组。一开始,把 fullname
数组传进去,然后,修改 fullname
数组的内容,结果发现,实例 p
的字段 p.name
也会被修改。
请注意区分以下代码:
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"
上述代码两次输出都是 "Bob"
,这是因为 String
类型的 bob
变量被重新赋值时,实际上创建了一个新的 String
对象,p.name
仍然指向原来的 "Bob"
对象。
1.2 构造方法
在创建对象实例时,我们常常需要同时初始化实例的字段。构造方法允许在创建实例时,通过参数一次性完成初始化,避免了手动调用 setName()
、setAge()
等方法,确保实例在创建时就处于正确的状态。
1.2.1 构造方法的定义与特点
构造方法是一种特殊的方法,用于初始化类的实例。它的名称与类名相同,没有返回值(包括 void
)。调用构造方法必须使用 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;
}
}
在这个例子中,Person(String name, int age)
就是一个构造方法,它接收 name
和 age
作为参数,用于初始化 Person
实例的字段。
1.2.2 默认构造方法
如果一个类没有定义任何构造方法,编译器会自动为该类生成一个默认构造方法。默认构造方法没有参数,也不执行任何操作。
class Person {
public Person() {
}
}
需要注意的是,一旦自定义了构造方法,编译器将不再自动生成默认构造方法。如果需要同时使用自定义构造方法和默认构造方法,必须显式地将它们都定义出来。
class Person {
private String name;
private int age;
public Person() {
}
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}
1.2.3 字段的默认初始化
如果在构造方法中没有显式地初始化字段,字段将使用默认值。引用类型字段默认初始化为 null
,数值类型字段使用数值类型的默认值(例如,int
类型默认值为 0
,double
类型默认值为 0.0
),布尔类型字段默认初始化为 false
。
class Person {
private String name; // 默认初始化为 null
private int age; // 默认初始化为 0
public Person() {
}
}
当然,也可以在声明字段时直接进行初始化:
class Person {
private String name = "Unnamed";
private int age = 10;
}
当字段既在声明时初始化,又在构造方法中初始化时,构造方法中的初始化会覆盖声明时的初始化。在 Java 中,对象实例的初始化顺序是:
- 先初始化字段(包括默认初始化和声明时的初始化)。
- 执行构造方法的代码进行初始化。
1.2.4 多个构造方法
一个类可以定义多个构造方法,这被称为构造方法的重载。编译器会根据调用 new
操作符时传入的参数数量、类型和顺序,自动选择匹配的构造方法。
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() {
}
}
1.2.5 构造方法之间的调用
为了避免代码重复,一个构造方法可以通过 this(…)
语法调用同一个类的其他构造方法。this(…)
必须是构造方法中的第一条语句。
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)
}
}
在这个例子中,Person(String name)
构造方法调用了 Person(String name, int age)
构造方法,并将 age
设置为默认值 18
。Person()
构造方法则调用了 Person(String name)
构造方法,并将 name
设置为默认值 "Unnamed"
。通过这种方式,可以减少代码重复,提高代码的可维护性。
好的,下面是对您提供的 Java 课件内容进行整理后的笔记:
1.3 方法重载
在一个类中,可以定义多个功能类似但参数不同的同名方法,这种特性称为方法重载 (Overload)。方法重载使得功能相似的方法使用同一名字,便于记忆和调用。通常情况下,重载方法的返回值类型相同。
以下是在 Hello
类中重载 hello()
方法的示例:
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 + "!");
}
}
}
例如,String
类提供了多个重载的 indexOf()
方法,用于查找子串:
int indexOf(int ch)
:根据字符的 Unicode 码查找。int indexOf(String str)
:根据字符串查找。int indexOf(int ch, int fromIndex)
:根据字符查找,但指定起始位置。int indexOf(String str, int fromIndex)
:根据字符串查找,但指定起始位置。
以下代码展示了 String
类的 indexOf()
方法的用法:
// String.indexOf()
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);
}
}
代码解释:
int n1 = s.indexOf('t');
:使用indexOf(char ch)
方法查找字符 't' 在字符串s
中第一次出现的位置。由于区分大小写,所以这里返回的是 'T' 的位置,即 0。int n2 = s.indexOf("st");
:使用indexOf(String str)
方法查找字符串 "st" 在字符串s
中第一次出现的位置,返回 2。int n3 = s.indexOf("st", 4);
:使用indexOf(String str, int fromIndex)
方法从索引 4 开始查找字符串 "st" 在字符串s
中第一次出现的位置,返回 5。
1.4 继承
在面向对象编程中,继承是一种强大的机制,它允许我们创建一个新类 (子类),该类继承现有类 (父类) 的属性和方法,从而实现代码的复用和扩展。
假设我们已经定义了一个 Person
类,包含 name
和 age
字段:
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
类,它也需要包含 name
和 age
字段,并且还需要新增一个 score
字段,那么我们可以使用继承来避免重复编写代码。
1.4.1 Java 中的继承
在 Java 中,使用 extends
关键字来实现继承。以下是 Student
类继承 Person
类的示例:
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
类自动获得了 Person
类的所有字段和方法,我们只需要定义 Student
类特有的 score
字段和相关方法即可。
警告
子类自动获得了父类的所有字段,严禁定义与父类重名的字段!
在面向对象编程的术语中,Person
类被称为超类 (super class)、父类 (parent class) 或基类 (base class),而 Student
类被称为子类 (subclass) 或扩展类 (extended class)。
1.4.2 继承树
在 Java 中,所有的类都直接或间接地继承自 Object
类。如果一个类没有明确使用 extends
关键字声明继承关系,那么编译器会自动添加 extends Object
。因此,除了 Object
类之外,所有的类都有父类。
我们可以将类之间的继承关系表示为一棵继承树。例如,Person
类和 Student
类的继承关系可以表示为:
┌───────────┐
│ Object │
└───────────┘
▲
│
┌───────────┐
│ Person │
└───────────┘
▲
│
┌───────────┐
│ Student │
└───────────┘
Java 只允许一个类继承自一个类,即一个类只能有一个直接父类。Object
类是唯一的例外,它没有父类。
1.4.3 protected 关键字
子类无法直接访问父类的 private
字段或方法。为了让子类可以访问父类的字段,我们需要将 private
访问修饰符改为 protected
。用 protected
修饰的字段可以被子类访问:
class Person {
protected String name;
protected int age;
}
class Student extends Person {
public String hello() {
return "Hello, " + name; // OK!
}
}
protected
关键字将字段和方法的访问权限控制在继承树内部,即一个 protected
字段或方法可以被其子类以及子类的子类所访问。
1.4.4 super 关键字
super
关键字用于表示父类 (超类)。子类可以使用 super.fieldName
来引用父类的字段。例如:
class Student extends Person {
public String hello() {
return "Hello, " + super.name;
}
}
在上述代码中,使用 super.name
、this.name
或 name
的效果是一样的,编译器会自动定位到父类的 name
字段。
然而,在某些情况下,必须使用 super
关键字。例如,当子类需要调用父类的构造方法时,必须使用 super()
。
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) {
super(name, age); // 调用父类的构造方法 Person(String, int)
this.score = score;
}
}
在 Java 中,任何类的构造方法的第一行语句必须是调用父类的构造方法。如果没有明确地调用父类的构造方法,编译器会自动添加 super();
。如果父类没有无参数的构造方法,子类就必须显式调用 super()
并给出参数,以便让编译器定位到父类的一个合适的构造方法。
需要注意的是,子类不会继承任何父类的构造方法。子类默认的构造方法是编译器自动生成的,而不是继承的。
1.4.5 阻止继承
通常,只要一个类没有使用 final
修饰符,那么任何类都可以从该类继承。从 Java 15 开始,允许使用 sealed
修饰类,并通过 permits
明确写出能够从该类继承的子类名称。
例如,定义一个 Shape
类:
public sealed class Shape permits Rect, Circle, Triangle {
...
}
上述 Shape
类是一个 sealed
类,它只允许指定的 3 个类继承它。这种 sealed
类主要用于一些框架,防止继承被滥用。
1.4.6 向上转型
如果一个引用变量的类型是 Student
,那么它可以指向一个 Student
类型的实例:
Student s = new Student();
如果一个引用类型的变量是 Person
,那么它可以指向一个 Person
类型的实例:
Person p = new Person();
由于 Student
继承自 Person
,因此,一个引用类型为 Person
的变量可以指向 Student
类型的实例:
Person p = new Student(); // OK
这种把一个子类类型安全地变为父类类型的赋值,被称为向上转型 (upcasting)。向上转型实际上是把一个子类型安全地变为更加抽象的父类型。
Student s = new Student();
Person p = s; // upcasting, ok
Object o1 = p; // upcasting, ok
Object o2 = s; // upcasting, ok
1.4.7 向下转型
与向上转型相反,如果把一个父类类型强制转型为子类类型,就是向下转型 (downcasting)。例如:
Person p1 = new Student(); // upcasting, ok
Person p2 = new Person();
Student s1 = (Student) p1; // ok
Student s2 = (Student) p2; // runtime error! ClassCastException!
向下转型很可能会失败。失败的时候,Java 虚拟机会抛出 ClassCastException
。
为了避免向下转型出错,Java 提供了 instanceof
操作符,可以先判断一个实例究竟是不是某种类型:
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
。
从 Java 14 开始,判断 instanceof
后,可以直接转型为指定变量,避免再次强制转型。例如:
Object obj = "hello";
if (obj instanceof String s) {
// 可以直接使用变量 s:
System.out.println(s.toUpperCase());
}
1.4.8 区分继承和组合
在使用继承时,需要注意逻辑一致性。继承应该用于表示 "is-a" 关系,而组合应该用于表示 "has-a" 关系。
例如,Student
是 Person
的一种,因此 Student
应该从 Person
继承。但是,Student
和 Book
之间是 "has-a" 关系,即 Student
可以拥有一本 Book
,因此 Student
不应该从 Book
继承,而应该持有一个 Book
实例。
class Student extends Person {
protected Book book;
protected int score;
}
因此,继承是 is 关系,组合是 has 关系。
1.5 多态
1.5.1 覆写
在继承关系中,子类可以定义一个与父类方法签名完全相同的方法,这被称为覆写( Override
)。
例如,在 Person
类中定义了 run()
方法:
class Person {
public void run() {
System.out.println("Person.run");
}
}
在子类 Student
中,覆写这个 run()
方法:
class Student extends Person {
@Override
public void run() {
System.out.println("Student.run");
}
}
Override
和 Overload
的区别在于,如果方法签名不同,就是 Overload
,Overload
方法是一个新方法;如果方法签名相同,并且返回值也相同,就是 Override
。
注
方法名相同,方法参数相同,但方法返回值不同,也是不同的方法。在 Java
程序中,出现这种情况,编译器会报错。
class Person {
public void run() { ... }
}
class Student extends Person {
// 不是Override,因为参数不同:
public void run(String s) { ... }
// 不是Override,因为返回值不同:
public int run() { ... }
}
代码解释:
Student
类定义了一个带String
类型参数的run(String s)
方法。由于参数列表不同,Student
类中的run(String s)
方法并不是对Person
类中run()
方法的重写,而是Student
类新增的一个方法,属于方法的重载(Overload)。- 由于返回值类型不同,
Student
类中的int run()
方法也不是对Person
类中run()
方法的重写,而是Student
类新增的一个方法。
加上 @Override
可以让编译器帮助检查是否进行了正确的覆写。如果希望进行覆写,但是不小心写错了方法签名,编译器会报错。
// 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) {}
}
但是 @Override
不是必需的。
在上一节中,我们已经知道,引用变量的声明类型可能与其实际类型不符,例如:
Person p = new Student();
现在,我们考虑一种情况,如果子类覆写了父类的方法:
// override
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");
}
}
那么,一个实际类型为 Student
,引用类型为 Person
的变量,调用其 run()
方法,调用的是 Person
还是 Student
的 run()
方法?
运行一下上面的代码就可以知道,实际上调用的方法是 Student
的 run()
方法。因此可得出结论:
Java
的实例方法调用是基于运行时的实际类型的动态调用,而非变量的声明类型。
这个非常重要的特性在面向对象编程中称之为 多态 。它的英文拼写非常复杂: Polymorphic
。
1.5.2 多态的概念
多态是指,针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法。例如:
Person p = new Student();
p.run(); // 无法确定运行时究竟调用哪个run()方法
多态的特性就是,运行期才能动态决定调用的子类方法。对某个类型调用某个方法,执行的实际方法可能是某个子类的覆写方法。
现在,让我们通过一个例子来理解多态的作用。
假设我们定义一种收入,需要给它报税,那么先定义一个 Income
类:
class Income {
protected double income;
public double getTax() {
return income * 0.1; // 税率10%
}
}
对于工资收入,可以减去一个基数,那么我们可以从 Income
派生出 Salary
,并覆写 getTax()
:
class Salary extends Income {
@Override
public double getTax() {
if (income <= 5000) {
return 0;
}
return (income - 5000) * 0.2;
}
}
如果你享受国务院特殊津贴,那么按照规定,可以全部免税:
class StateCouncilSpecialAllowance extends Income {
@Override
public double getTax() {
return 0;
}
}
现在,我们要编写一个报税的财务软件,对于一个人的所有收入进行报税,可以这么写:
public static double totalTax(Income... incomes) {
double total = 0;
for (Income income: incomes) {
total = total + income.getTax();
}
return total;
}
来试一下:
// Polymorphic
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()
,不需要修改任何代码。
可见,多态具有一个非常强大的功能,就是允许添加更多类型的子类实现功能扩展,却不需要修改基于父类的代码。
在上述代码中,totalTax
方法接收一个 Income
类型的数组,并计算总税收。关键在于,totalTax
方法内部调用了 income.getTax()
方法。由于多态的特性,实际执行的 getTax()
方法取决于 income
变量的实际类型。如果 income
是 Salary
类型的实例,那么将调用 Salary
类中覆写的 getTax()
方法;如果 income
是 StateCouncilSpecialAllowance
类型的实例,那么将调用 StateCouncilSpecialAllowance
类中覆写的 getTax()
方法。这种动态绑定使得 totalTax
方法能够处理不同类型的收入,而无需修改其代码。
1.5.3 覆写 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();
}
}
在覆写 equals()
方法时,首先需要判断传入的对象是否是当前类的实例,可以使用 instanceof
关键字进行判断。如果是当前类的实例,则可以将其强制转换为当前类类型,然后比较对象的属性是否相等。同时,覆写 equals()
方法时,通常也需要覆写 hashCode()
方法,以保证相等的对象具有相同的哈希值。
1.5.4 调用 super
在子类的覆写方法中,如果要调用父类的被覆写的方法,可以通过 super
来调用。例如:
class Person {
protected String name;
public String hello() {
return "Hello, " + name;
}
}
class Student extends Person {
@Override
public String hello() {
// 调用父类的hello()方法:
return super.hello() + "!";
}
}
在这个例子中,子类 Student
覆写了父类 Person
的 hello()
方法,并在子类的 hello()
方法中通过 super.hello()
调用了父类的 hello()
方法,然后在此基础上添加了 "!"。
1.5.5 final 关键字
继承可以允许子类覆写父类的方法。如果一个父类不允许子类对它的某个方法进行覆写,可以把该方法标记为 final
。用 final
修饰的方法不能被 Override
:
class Person {
protected String name;
public final String hello() {
return "Hello, " + name;
}
}
class Student extends Person {
// compile error: 不允许覆写
@Override
public String hello() {
}
}
如果一个类不希望任何其他类继承自它,那么可以把这个类本身标记为 final
。用 final
修饰的类不能被继承:
final class Person {
protected String name;
}
// compile error: 不允许继承自Person
class Student extends Person {
}
对于一个类的实例字段,同样可以用 final
修饰。用 final
修饰的字段在初始化后不能被修改。例如:
class Person {
public final String name = "Unamed";
}
对 final
字段重新赋值会报错:
Person p = new Person();
p.name = "New Name"; // compile error!
可以在构造方法中初始化 final
字段:
class Person {
public final String name;
public Person(String name) {
this.name = name;
}
}
这种方法更为常用,因为可以保证实例一旦创建,其 final
字段就不可修改。
总而言之,final
关键字可以用于修饰类、方法和字段,分别表示类不可被继承、方法不可被覆写和字段不可被修改。
1.6 抽象类
1.6.1 多态与方法覆写
在 Java 中,多态允许子类覆写父类的方法,从而实现不同的行为。例如,Person
类可以有 Student
和 Teacher
两个子类,它们都可以覆写 run()
方法:
class Person {
public void run() { … }
}
class Student extends Person {
@Override
public void run() { … }
}
class Teacher extends Person {
@Override
public void run() { … }
}
如果父类 Person
的 run()
方法没有实际意义,只是为了定义一个规范,那么应该如何处理?
1.6.2 抽象方法的引入
直接去掉方法的执行语句是不行的,会导致编译错误。因为在普通方法中,必须提供方法的具体实现。
去掉父类的 run()
方法也不行,因为这样会失去多态的特性。例如,如果有一个 runTwice()
方法接受 Person
对象作为参数,那么在 Person
类中没有 run()
方法会导致编译错误:
public void runTwice(Person p) {
p.run(); // Person 没有 run() 方法,会导致编译错误
}
为了解决这个问题,可以使用抽象方法。
1.6.3 抽象方法的定义
如果父类的方法本身不需要实现任何功能,仅仅是为了定义方法签名,目的是让子类去覆写它,那么,可以把父类的方法声明为抽象方法:
abstract class Person {
public abstract void run();
}
把一个方法声明为 abstract
,表示它是一个抽象方法,本身没有实现任何方法语句。
1.6.4 抽象类的定义
由于抽象方法本身是无法执行的,所以包含抽象方法的类也必须声明为抽象类。使用 abstract
关键字修饰的类就是抽象类。
抽象类无法被实例化:
Person p = new Person(); // 编译错误
抽象类存在的意义在于它定义了一种规范,强制子类实现其定义的抽象方法。如果子类没有覆写抽象方法,那么子类也必须声明为抽象类。
1.6.5 抽象类的作用
抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错。因此,抽象方法实际上相当于定义了“规范”。
例如,Person
类定义了抽象方法 run()
,那么,在实现子类 Student
的时候,就必须覆写 run()
方法:
abstract class Person {
public abstract void run();
}
class Student extends Person {
@Override
public void run() {
System.out.println("Student.run");
}
}
以上代码展示了如何定义一个抽象类 Person
,以及如何通过继承该抽象类并实现其抽象方法 run()
来创建一个具体的子类 Student
。
1.6.6 面向抽象编程
当我们定义了抽象类 Person
,以及具体的 Student
、Teacher
子类的时候,我们可以通过抽象类 Person
类型去引用具体的子类的实例:
Person s = new Student();
Person t = new Teacher();
这种引用抽象类的好处在于,我们对其进行方法调用,并不关心 Person
类型变量的具体子类型:
// 不关心 Person 变量的具体子类型:
s.run();
t.run();
同样的代码,如果引用的是一个新的子类,我们仍然不关心具体类型:
// 同样不关心新的子类是如何实现 run() 方法的:
Person e = new Employee();
e.run();
这种尽量引用高层类型,避免引用实际子类型的方式,称之为面向抽象编程。
面向抽象编程的本质就是:
- 上层代码只定义规范 (例如:
abstract class Person
); - 不需要子类就可以实现业务逻辑(正常编译);
- 具体的业务逻辑由不同的子类实现,调用者并不关心。
1.7 接口
在 Java 中,接口 (interface
) 是一种纯抽象的类型定义,它比抽象类更加抽象,不允许包含实例字段。接口主要用于定义规范,强制实现类遵循特定的方法签名,从而实现多态性。可以将接口视为一种契约,规定了实现类必须提供的行为。
1.7.1 接口的声明与实现
使用 interface
关键字声明一个接口:
interface Person {
void run();
String getName();
}
接口中定义的所有方法默认都是 public abstract
的,因此这两个修饰符可以省略。
使用 implements
关键字使一个类实现接口:
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;
}
}
一个类可以实现多个接口,从而获得多个接口定义的行为。例如:
class Student implements Person, Hello {
// ...
}
1.7.2 抽象类与接口的对比
abstract class | interface | |
---|---|---|
继承 | 只能 extends 一个 class | 可以 implements 多个 interface |
字段 | 可以定义实例字段 | 不能定义实例字段 |
抽象方法 | 可以定义抽象方法 | 可以定义抽象方法 |
非抽象方法 | 可以定义非抽象方法 | 可以定义 default 方法 |
1.7.3 接口的继承
一个接口可以通过 extends
关键字继承自另一个接口,从而扩展接口的方法:
interface Hello {
void hello();
}
interface Person extends Hello {
void run();
String getName();
}
Person
接口继承自 Hello
接口后,拥有 hello()
、run()
和 getName()
三个抽象方法签名。
1.7.4 继承关系的设计
合理设计 interface
和 abstract class
的继承关系可以充分复用代码。一般来说,公共逻辑适合放在 abstract class
中,具体逻辑放到各个子类,而接口层次代表抽象程度。
如下为 Java 集合类的一组接口、抽象类以及具体子类的继承关系图。其中
- 接口:
Iterable
、Collection
、List
。 - 抽象类:
AbstractCollection
、AbstractList
。 - 具体类:
ArrayList
和LinkedList
。
┌───────────────┐
│ Iterable │
└───────────────┘
▲ ┌───────────────────┐
│ │ Object │
┌───────────────┐ └───────────────────┘
│ Collection │ ▲
└───────────────┘ │
▲ ▲ ┌───────────────────┐
│ └──────────│AbstractCollection │
┌───────────────┐ └───────────────────┘
│ List │ ▲
└───────────────┘ │
▲ ┌───────────────────┐
└──────────│ AbstractList │
└───────────────────┘
▲ ▲
│ │
│ │
┌────────────┐ ┌────────────┐
│ ArrayList │ │ LinkedList │
└────────────┘ └────────────┘
在使用时,实例化的对象通常是某个具体的子类,但通过接口引用它,因为接口比抽象类更抽象:
List list = new ArrayList(); // 用 List 接口引用具体子类的实例
Collection coll = list; // 向上转型为 Collection 接口
Iterable it = coll; // 向上转型为 Iterable 接口
1.7.5 default 方法
在接口中,可以定义 default
方法,实现类可以选择性地覆写。
interface Person {
String getName();
default void run() {
System.out.println(getName() + " run");
}
}
default
方法的目的是,当我们需要给接口新增一个方法时,可以避免修改所有子类。如果新增的是 default
方法,那么子类可以选择覆写该方法,而不需要强制修改。
default
方法和抽象类的普通方法有所不同:interface
没有字段,因此接口的 default
方法无法访问字段,而抽象类的普通方法可以访问实例字段。
下面是 default
方法的示例代码:
// interface
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;
}
}
代码解释:
Person
接口定义了一个getName()
抽象方法和一个run()
default
方法。Student
类实现了Person
接口,并提供了getName()
方法的实现。- 由于
run()
方法是default
方法,Student
类可以选择不覆写它。 - 在
Main
类的main
方法中,创建了一个Student
对象,并调用了run()
方法,由于Student
类没有覆写run()
方法,因此调用的是Person
接口中定义的default
方法。default
方法中调用了getName()
方法,实现了在接口中定义默认行为,并在实现类中根据具体情况进行定制。
1.8 静态字段和静态方法
1.8.1 实例字段与静态字段
在 Java 的 class
中,字段分为实例字段和静态字段。
- 实例字段:每个实例都拥有独立的字段副本,实例间的同名字段互不影响。
- 静态字段:使用
static
关键字修饰,所有实例共享同一个静态字段,存储在类的共享“空间”中。
由于静态字段不属于实例,而是属于类,因此推荐使用 类名.静态字段
的方式访问静态字段,而非 实例变量.静态字段
。尽管后者在语法上可行,但容易造成误解,因为它实际上是由编译器自动转换为 类名.静态字段
进行访问的。
可以将静态字段理解为描述类本身特性的字段。
1.8.2 静态方法
与静态字段类似,静态方法使用 static
关键字修饰。
调用实例方法需要通过实例变量,而调用静态方法可以直接通过类名调用,无需创建实例。静态方法类似于其他编程语言中的函数。
静态方法属于类,不属于实例。因此,在静态方法内部,无法访问
this
变量,也无法访问实例字段,只能访问静态字段。
虽然可以通过实例变量调用静态方法,但实际上编译器会将其转换为使用类名调用。建议直接使用 类名.静态方法
的形式。
静态方法常用于工具类 (如 Arrays.sort()
、Math.random()
) 和辅助方法。Java 程序的入口 main()
方法就是一个静态方法。
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;
}
}
这段代码展示了如何通过类名 Person
调用静态方法 setNumber()
来设置静态字段 number
的值,以及如何通过类名访问静态字段 number
并打印其值。静态方法 setNumber()
接收一个 int
类型的参数 value
,并将该值赋给静态字段 number
。由于静态字段和静态方法都属于类,因此可以直接通过类名进行访问和调用,而无需创建类的实例。
1.8.3 接口的静态字段
interface
作为纯抽象类,不能定义实例字段,但可以定义静态字段。interface
的静态字段必须是 final
类型,即常量。
实际上,interface
的字段只能是 public static final
类型,因此这三个修饰符可以省略,编译器会自动添加。
例如:
public interface Person {
// 编译器会自动加上 public static final:
int MALE = 1;
int FEMALE = 2;
}
由于接口中的字段默认是 public static final
的,因此可以省略这些修饰符。这意味着这些字段是公开的、静态的且不可修改的,可以直接通过接口名访问,例如 Person.MALE
和 Person.FEMALE
。这种方式常用于定义一组相关的常量,以便在程序中统一使用。
1.9 包
在 Java 开发中,如果不同开发者编写了同名的类(例如 Person
类),或者自定义类与 JDK 自带的类名冲突(例如 Arrays
类),就会出现命名冲突。
1.9.1 package
Java 使用 package
(包) 来解决命名冲突问题。package
定义了一种名字空间,使得即使类名相同,但只要属于不同的包,JVM 也能区分它们。
在定义 class
时,必须在第一行声明该 class
属于哪个包:
package ming; // 声明包名 ming
public class Person {
}
包可以有多层结构,用 .
分隔(例如:java.util
)。
注
包没有父子关系。java.util
和 java.util.zip
是不同的包,两者没有任何继承关系。
警告
没有定义包名的 class
属于默认包,容易引起名字冲突,应避免使用。
1.9.2 文件组织
Java 文件的目录层次必须与包的层次一致。例如,如果包名为 com.example.myapp
,则对应的 Java 文件应该放在 com/example/myapp/
目录下。编译后的 .class
文件也应按照包结构存放。
以下是一个示例的目录结构:
package_sample
src
hong
Person.java
ming
Person.java
mr
jun
Arrays.java
1.9.3 包作用域
位于同一个包中的类可以访问包作用域的字段和方法。不使用 public
、protected
或 private
修饰的字段和方法就是包作用域。
例如:
package hello;
public class Person {
// 包作用域:
void hello() {
System.out.println("Hello!");
}
}
Main
类也定义在 hello
包下面:
package hello;
public class Main {
public static void main(String[] args) {
Person p = new Person();
p.hello(); // 可以调用,因为 Main 和 Person 在同一个包
}
}
1.9.4 import
语句
在一个 class
中,我们通常需要引用其他的 class
。import
语句用于导入其他包中的类,从而可以在代码中使用简单类名,而无需写出完整类名。
有以下几种使用 import
的方式:
直接写出完整类名:
package ming; public class Person { public void run() { // 写完整类名: mr.jun.Arrays mr.jun.Arrays arrays = new mr.jun.Arrays(); } }
使用
import
语句导入:package ming; import mr.jun.Arrays; // 导入完整类名 public class Person { public void run() { // 写简单类名: Arrays Arrays arrays = new Arrays(); } }
使用
import
语句导入整个包:package ming; import mr.jun.*; // 导入 mr.jun 包的所有 class public class Person { public void run() { Arrays arrays = new Arrays(); } }
不推荐使用
import mr.jun.*
,因为在导入了多个包后,很难看出Arrays
类属于哪个包。import static
语法:import static
可以导入一个类的静态字段和静态方法。实际开发中很少使用。package main; import static java.lang.System.*; // 导入 System 类的所有静态字段和静态方法 public class Main { public static void main(String[] args) { out.println("Hello, world!"); // 相当于调用 System.out.println(…) } }
1.9.5 类名查找顺序
Java 编译器最终编译出的 .class
文件只使用完整类名。当编译器遇到一个 class
名称时:
- 如果是完整类名,直接根据完整类名查找。
- 如果是简单类名,按以下顺序查找:
- 查找当前
package
是否存在这个class
。 - 查找
import
的包是否包含这个class
。 - 查找
java.lang
包是否包含这个class
。
- 查找当前
如果以上规则无法确定类名,则编译报错。
例如:
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
}
}
1.9.6 默认 import
编写 class
的时候,编译器会自动帮我们做两个 import
动作:
- 默认自动
import
当前package
的其他class
。 - 默认自动
import java.lang.*
。
注
自动导入的是 java.lang
包,但类似 java.lang.reflect
这些包仍需要手动导入。
如果有两个 class
名称相同,例如,mr.jun.Arrays
和 java.util.Arrays
,那么只能 import
其中一个,另一个必须写完整类名。
1.9.7 最佳实践
Java 中,包的最佳实践如下:
- 为避免名字冲突,建议使用倒置的域名来确保包名的唯一性(例如:
org.apache
、com.liaoxuefeng.sample
)。 - 子包可以根据功能自行命名。
- 避免和
java.lang
包的类重名(例如:String
、System
、Runtime
等)。 - 避免和 JDK 常用类重名(例如:
java.util.List
、java.text.Format
、java.math.BigInteger
等)。
1.9.8 编译和运行
假设目录结构如下:
work
bin| bin
目录存放编译后的 class
文件。
src| src
目录按包结构存放 Java 源码。
com
itranswarp
sample
Main.java
world
Person.java
编译:
javac -d ./bin src/**/*.java
-d
指定输出的class
文件存放目录。src/**/*.java
表示src
目录下的所有.java
文件,包括任意深度的子目录。
注
Windows 不支持
**
这种搜索全部子目录的做法,所以在 Windows 下编译必须依次列出所有.java
文件:javac -d bin src\com\itranswarp\sample\Main.java src\com\itranswarp\world\Persion.java
使用 Windows 的 PowerShell 可以利用
Get-ChildItem
来列出指定目录下的所有.java
文件:javac -d .\bin (Get-ChildItem -Path .\src -Recurse -Filter *.java).FullName
运行:
java -cp bin com.itranswarp.sample.Main
-cp
指定 classpath,这里是bin
目录。com.itranswarp.sample.Main
是要运行的类名(包含包名)。
1.10 Java 作用域详解
在 Java 中,public
、protected
和 private
是常用的修饰符,用于限定访问作用域。
1.10.1 public
访问修饰符
类和接口: 定义为
public
的类(class
)或接口(interface
)可以被任何其他类访问。package abc; public class Hello { public void hi() {} }
上述代码中,
Hello
类是public
的,因此可以被其他包的类访问。package xyz; class Main { void foo() { // Main 可以访问 Hello Hello h = new Hello(); } }
字段和方法: 定义为
public
的字段(field
)或方法(method
)可以被其他类访问,前提是首先有访问该类的权限。package abc; public class Hello { public void hi() {} }
上述代码中,
hi()
方法是public
的,可以被其他类调用,前提是要能访问Hello
类。package xyz; class Main { void foo() { Hello h = new Hello(); h.hi(); } }
1.10.2 private
访问修饰符
字段和方法:被声明为
private
的字段和方法只能在该类内部访问,不能被其他类访问。private
访问权限被严格限定在类的内部,与方法的声明顺序无关。建议将private
方法放在类的后面,因为public
方法定义了类对外提供的功能,更应该优先关注。例如,以下代码定义了一个
Hello
类,其中包含一个private
的hi()
方法:package abc; public class Hello { // 不能被其他类调用: private void hi() { } public void hello() { this.hi(); } }
嵌套类:如果一个类内部定义了嵌套类(
nested class
),那么嵌套类拥有访问外部类的private
成员的权限。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(); } } }
这段代码展示了 Java 中嵌套类的概念,在
Main
类中定义了一个静态内部类Inner
。Inner
类可以访问Main
类的private
静态方法hello()
。这是因为嵌套类被认为是外部类的一部分,因此拥有访问外部类私有成员的权限。
1.10.3 protected
访问修饰符
protected
作用于继承关系。被声明为 protected
的字段和方法可以被子类访问,以及子类的子类访问。
例如,以下代码定义了一个 Hello
类,其中包含一个 protected
的 hi()
方法:
package abc;
public class Hello {
// protected方法:
protected void hi() {
}
}
子类可以访问 protected
方法:
package xyz;
class Main extends Hello {
void foo() {
// 可以访问protected方法:
hi();
}
}
1.10.4 包(package
)作用域
包作用域是指一个类可以访问同一个包(package
)中没有使用 public
、private
修饰的类,以及没有使用 public
、protected
、private
修饰的字段和方法。
例如,以下代码定义了一个包作用域的 Hello
类和一个包作用域的 hi()
方法:
package abc;
// package权限的类:
class Hello {
// package权限的方法:
void hi() {
}
}
在同一个包中的类可以访问 Hello
类和 hi()
方法:
package abc;
class Main {
void foo() {
// 可以访问package权限的类:
Hello h = new Hello();
// 可以调用package权限的方法:
h.hi();
}
}
注意,包名必须完全一致,包之间没有父子关系,例如 com.apache
和 com.apache.abc
是不同的包。
1.10.5 局部变量
在方法内部定义的变量称为局部变量,局部变量的作用域从变量声明处开始到对应的块结束。方法参数也是局部变量。
例如,以下代码展示了局部变量的作用域:
package abc;
public class Hello {
void hi(String name) { // 1
String s = name.toLowerCase(); // 2
int len = s.length(); // 3
if (len < 10) { // 4
int p = 10 - len; // 5
for (int i=0; i<10; i++) { // 6
System.out.println(); // 7
} // 8
} // 9
} // 10
}
name
的作用域是 1 ~ 10s
的作用域是 2 ~ 10len
的作用域是 3 ~ 10p
的作用域是 5 ~ 9i
的作用域是 6 ~ 8
在使用局部变量时,应该尽可能缩小局部变量的作用域,尽可能延后声明局部变量。
1.10.6 final
修饰符
final
与访问权限不冲突,它有很多作用:
修饰类(
class
):阻止被继承。package abc; // 无法被继承: public final class Hello { private int n = 0; protected void hi(int t) { long i = t; } }
修饰方法(
method
):阻止被子类覆写(override
)。package abc; public class Hello { // 无法被覆写: protected final void hi() { } }
修饰字段(
field
):阻止被重新赋值。package abc; public class Hello { private final int n = 0; protected void hi() { this.n = 1; // error! } }
修饰局部变量:阻止被重新赋值。
package abc; public class Hello { protected void hi(final int t) { t = 1; // error! } }
1.10.7 最佳实践
- 如果不确定是否需要
public
,就不要声明为public
,即尽可能少地暴露对外的字段和方法。 - 把方法定义为
package
权限有助于测试,因为测试类和被测试类只要位于同一个package
,测试代码就可以访问被测试类的package
权限方法。 - 一个
.java
文件只能包含一个public
类,但可以包含多个非public
类。如果有public
类,文件名必须和public
类的名字相同。
1.11 内部类
在 Java 程序中,通常情况下,不同的类被组织在不同的包下面,同一个包下的类在同一层次,没有父子关系。
java.lang
Math
Runnable
String
...
但还有一种类,它被定义在另一个类的内部,被称为内部类(Nested Class)。 Java 的内部类有多种形式,虽然不常用,但仍需了解其使用方式。
1.11.1 Inner Class(成员内部类)
如果一个类定义在另一个类的内部,这个类就是 Inner Class。
class Outer {
class Inner {
// 定义了一个Inner Class
}
}
上述代码中,Outer
是一个普通类,而 Inner
是一个 Inner Class。它与普通类的最大区别在于,Inner Class 的实例不能单独存在,必须依附于一个 Outer
Class 的实例。以下是一个示例:
// inner 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);
}
}
}
要实例化一个 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
。
1.11.2 Anonymous Class(匿名类)
另一种定义 Inner Class 的方法是通过匿名类(Anonymous 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();
}
}
在 asyncHello()
方法中,实例化了一个 Runnable
。由于 Runnable
是一个接口,不能直接实例化,因此这里实际上是定义了一个实现了 Runnable
接口的匿名类,并通过 new
实例化该匿名类,然后转型为 Runnable
。定义匿名类时必须立即实例化它,定义匿名类的语法如下:
Runnable r = new Runnable() {
// 实现必要的抽象方法...
};
匿名类和 Inner Class 一样,可以访问 Outer Class 的 private
字段和方法。使用匿名类的原因是,通常情况下不关心类名,可以减少代码量。
观察 Java 编译器编译后的 .class
文件,Outer
类被编译为 Outer.class
,而匿名类被编译为 Outer$1.class
。如果有多个匿名类,Java 编译器会将每个匿名类依次命名为 Outer$1
、Outer$2
、Outer$3
……
除了接口,匿名类也可以继承自普通类。示例代码如下:
// Anonymous Class
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"));
}
}
map1
是一个普通的 HashMap
实例,而 map2
是一个匿名类实例,该匿名类继承自 HashMap
。map3
也是一个继承自 HashMap
的匿名类实例,并且添加了静态代码块来初始化数据。观察编译输出可发现 Main$1.class
和 Main$2.class
两个匿名类文件。
代码解释:
HashMap<String, String> map2 = new HashMap<>() {};
这行代码创建了一个继承自HashMap
的匿名类,但没有添加任何额外的方法或字段。它本质上创建了一个与HashMap
功能相同的实例。HashMap<String, String> map3 = new HashMap<>() { ... };
这行代码也创建了一个继承自HashMap
的匿名类,但它包含一个初始化块(用双括号{{ ... }}
包裹的代码块)。这个初始化块在匿名类被创建时执行,用于向HashMap
中添加初始键值对。 这个代码块可以看作是匿名内部类的构造函数的一部分,在创建对象时会被执行。
总的来说,这段代码展示了如何使用匿名类来创建 HashMap
的实例,并以不同的方式对其进行初始化。 这种方法在需要定制化 HashMap
的创建过程时非常有用。
1.11.3 Static Nested Class(静态内部类)
最后一种内部类与 Inner Class 类似,但使用 static
修饰,称为静态内部类(Static Nested Class)。
// 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);
}
}
}
用 static
修饰的内部类和 Inner Class 有很大的不同。它不再依附于 Outer
的实例,而是一个完全独立的类,因此无法引用 Outer.this
。但它可以访问 Outer
的 private
静态字段和静态方法。如果把 StaticNested
移到 Outer
之外,就失去了访问 private
的权限。
好的,下面是对您提供的课件内容进行整理后的笔记:
1.12 Classpath 和 Jar
1.12.1 什么是 Classpath
Classpath
是 Java 虚拟机(JVM)使用的一个环境变量,用于指示 JVM 如何搜索 .class
文件。由于 Java 是编译型语言,JVM 实际执行的是编译后的字节码文件(.class
文件),因此需要通过 Classpath
找到这些类文件。
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
文件后,便停止搜索。如果所有路径下都找不到,则会报错。
1.12.2 Classpath 的设定方法
建议在启动 JVM 时设置 Classpath
变量,而不是在系统环境变量中设置,以避免污染系统环境。具体做法是给 java
命令传入 -classpath
参数:
java -classpath .;C:\work\project1\bin;C:\shared abc.xyz.Hello
# 简写
java -cp .;C:\work\project1\bin;C:\shared abc.xyz.Hello
如果没有设置系统环境变量,也没有传入 -cp
参数,JVM 默认的 Classpath
为 .
,即当前目录。
java abc.xyz.Hello
在集成开发环境(IDE)中运行 Java 程序时,IDE 会自动传入 -cp
参数,通常包括当前工程的 bin
目录和引入的 jar 包。
警告
不要将任何 Java 核心库添加到 Classpath
中!JVM 不需要 Classpath
来加载核心库。
1.12.3 Classpath 示例
假设存在一个编译后的 Hello.class
文件,其包名为 com.example
,当前目录为 C:\work
。则目录结构必须如下所示:
C:\work
com
example
Hello.class
要运行该 Hello.class
文件,需要在当前目录下执行以下命令:
C:\work> java -cp . com.example.Hello
JVM 会根据 Classpath
设置的 .
在当前目录下查找 com.example.Hello
,实际搜索的文件路径是 com/example/Hello.class
。如果文件不存在或目录结构与包名不符,将会报错。
1.12.4 Jar 包
为了方便管理大量的 .class
文件,可以使用 jar 包将它们打包成一个文件。jar 包可以包含以 package
组织的目录层级以及所有文件(包括 .class
文件和其他文件)。
Jar 包实际上是一个 zip 格式的压缩文件,可以将其视为目录。要执行 jar 包中的 class
,可以将 jar 包添加到 Classpath
中:
java -cp ./hello.jar abc.xyz.Hello
JVM 会自动在 hello.jar
文件中搜索指定的类。
1.12.5 创建 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
等。下面展示了正确的目录结构:
如果目录结构如下:
hello.zip
包含了 bin
目录,说明打包过程存在问题。 JVM 无法从 JAR 包中找到正确的 class,因为正确的路径应该是 hong/Person.class
,而不是 bin/hong/Person.class
。
1.12.6. MANIFEST.MF 文件
Jar 包可以包含一个特殊的 /META-INF/MANIFEST.MF
文件,这是一个纯文本文件,可以指定 Main-Class
和其他信息。JVM 会自动读取该文件,如果存在 Main-Class
,则可以直接使用以下命令运行 jar 包,而无需指定类名:
java -jar hello.jar
好的,下面是对您提供的课件内容的整理,按照您提出的要求,我将课件内容整理为详尽的笔记,包含代码解释、图片描述以及行文规范。
1.13 Class 版本
1.13.1 JDK 版本与 Class 文件版本
在 Java 开发中,我们常说的 Java 8、Java 11、Java 17 等,指的是 JDK 的版本,也就是 JVM 的版本,更确切地说,是 java.exe
程序的版本。例如:
$ java -version
java version "17" 2021-09-14 LTS
每个版本的 JVM 能够执行的 Class 文件版本是不同的。例如
Java 版本 | Class 文件版本 |
---|---|
Java 11 | 55 |
Java 17 | 61 |
如果用 Java 11 编译一个 Java 程序,输出的 Class 文件版本默认就是 55,这个 Class 既可以在 Java 11 上运行,也可以在 Java 17 上运行。但如果用 Java 17 编译,默认输出的 Class 文件版本是 61,它可以在 Java 17 或 Java 18 上运行,但不能在 Java 11 上运行。
如果使用低于 Java 17 的 JVM 运行,会得到一个 UnsupportedClassVersionError
,错误信息类似:
java.lang.UnsupportedClassVersionError: Xxx has been compiled by a more recent version of the Java Runtime...
出现 UnsupportedClassVersionError
错误,表示当前要加载的 Class 文件版本超过了 JVM 的能力,必须使用更高版本的 JVM 才能运行。
1.13.2 指定 Class 文件版本
可以使用 Java 17 编译 Java 程序,并指定输出的 Class 版本要兼容 Java 11 (即 Class 版本 55),这样编译生成的 Class 文件就可以在 Java 11 及更高版本的环境中运行。
可以通过两种方式指定编译输出版本:
使用
javac
命令行参数--release
:$ javac --release 11 Main.java
参数
--release 11
表示源码兼容 Java 11,编译的 Class 输出版本为 Java 11 兼容,即 Class 版本 55。使用参数
--source
指定源码版本,使用参数--target
指定输出 Class 版本:$ javac --source 9 --target 11 Main.java
上述命令如果使用 Java 17 的 JDK 编译,它会把源码视为 Java 9 兼容版本,并输出 Class 为 Java 11 兼容版本。
注
--release
参数和 --source --target
参数只能二选一,不能同时设置。
1.13.3 潜在问题
指定版本如果低于当前的 JDK 版本,可能会存在一些潜在的问题。例如,使用 Java 17 编译 Hello.java
,参数设置 --source 9
和 --target 11
:
public class Hello {
public static void hello(String name) {
System.out.println("hello".indent(4));
}
}
用低于 Java 11 的 JVM 运行 Hello
会得到一个 LinkageError
,因为无法加载 Hello.class
文件。而用 Java 11 运行 Hello
会得到一个 NoSuchMethodError
,因为 String.indent()
方法是从 Java 12 才添加进来的,Java 11 的 String
类并没有 indent()
方法。
警告
如果使用 --release 11
则会在编译时检查该方法是否在 Java 11 中存在。
因此,如果运行时的 JVM 版本是 Java 11,则编译时最好也使用 Java 11,而不是用高版本的 JDK 编译输出低版本的 Class。
如果使用 javac
编译时不指定任何版本参数,那么相当于使用 --release 当前版本
编译,即源码版本和输出版本均为当前版本。
在开发阶段,多个版本的 JDK 可以同时安装,当前使用的 JDK 版本可以通过 JAVA_HOME
环境变量切换。
1.13.4 源码版本
在编写源代码的时候,我们通常会预设一个源码的版本。在编译的时候,如果用 --source
或 --release
指定源码版本,则使用指定的源码版本检查语法。
例如,使用了 Lambda 表达式的源码版本至少要为 8 才能编译,使用了 var
关键字的源码版本至少要为 10 才能编译,使用 switch
表达式的源码版本至少要为 12 才能编译,且 12 和 13 版本需要启用 --enable-preview
参数。
1.14 模块
从 Java 9 开始,JDK 引入了模块 (Module) 的概念。模块主要为了解决 " 依赖 " 的问题。
1.14.1 模块的由来
在 Java 9 之前的版本:
- 最小可执行文件是
.class
文件。 - 多个
.class
文件会被打包成jar
文件,方便管理。 - 一个大型 Java 程序会生成自己的 jar 文件,同时引用依赖的第三方 jar 文件。
- JVM 自带的 Java 标准库也以 jar 文件形式存放 (rt.jar)。
运行一个 Java 程序,需要指定 classpath,例如:
java -cp app.jar:a.jar:b.jar:c.jar com.liaoxuefeng.sample.Main
如果漏写了某个运行时需要用到的 jar,那么在运行期极有可能抛出 ClassNotFoundException
。
因此,jar 只是用于存放 class 的容器,它并不关心 class 之间的依赖。
Java 9 引入模块后,可以给 a.jar
增加说明,表明它依赖 b.jar
才能运行,使得程序在编译和运行的时候能自动定位到 b.jar
。这种自带“依赖关系”的 class 容器就是模块。
为了表明 Java 模块化的决心,从 Java 9 开始,原有的 Java 标准库已经由一个单一巨大的 rt.jar
分拆成了几十个模块,这些模块以 .jmod
扩展名标识,可以在 $JAVA_HOME/jmods
目录下找到它们,模块之间的依赖关系已经被写入到模块内的 module-info.class
文件了。所有的模块都直接或间接地依赖 java.base
模块,只有 java.base
模块不依赖任何模块,它可以被看作是“根模块”,好比所有的类都是从 Object
直接或间接继承而来。
把一堆 class 封装为 jar 仅仅是一个打包的过程,而把一堆 class 封装为模块则不但需要打包,还需要写入依赖关系,并且还可以包含二进制代码 (通常是 JNI 扩展)。此外,模块支持多版本,即在同一个模块中可以为不同的 JVM 提供不同的版本。
1.14.2 编写模块
创建模块和原有的创建 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
这个文件,这就是模块的描述文件。在这个模块中,它长这样:
module hello.world {
requires java.base; // 可不写,任何模块都会自动引入java.base
requires java.xml;
}
其中,module
是关键字,后面的 hello.world
是模块的名称,它的命名规范与包一致。花括号的 requires xxx;
表示这个模块需要引用的其他模块名。除了 java.base
可以被自动引入外,这里我们引入了一个 java.xml
的模块。
当我们使用模块声明了依赖关系后,才能使用引入的模块。例如,Main.java
代码如下:
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
目录下,命令如下:
javac -d bin src/module-info.java src/com/itranswarp/sample/*.java
编译成功后,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
这个模块文件,这就是最后打包出来的模块。
1.14.3 运行模块
要运行一个 jar,使用 java -jar xxx.jar
命令。要运行一个模块,只需要指定模块名。
运行模块的命令如下:
java --module-path hello.jar --module hello.world
由于 .jmod
不能被放入 --module-path
中,所以这里使用 .jar
文件。
1.14.4 打包 JRE
为了支持模块化,Java 9 首先带头把自己的一个巨大无比的 rt.jar
拆成了几十个 .jmod
模块,原因是,运行 Java 程序的时候,实际上用到的 JDK 模块并没有那么多。不需要的模块,完全可以删除。
过去发布一个 Java 应用程序,要运行它,必须下载一个完整的 JRE,再运行 jar 包。而完整的 JRE 块头很大。现在,JRE 自身的标准库已经分拆成了模块,只需要带上程序用到的模块,其他的模块就可以被裁剪掉。
可以使用 JDK 提供的 jlink
命令来裁剪 JRE。命令如下:
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:
jre/bin/java --module hello.world
要分发自己的 Java 应用程序,只需要把这个 jre
目录打个包给对方发过去,对方直接运行上述命令即可,既不用下载安装 JDK,也不用知道如何配置我们自己的模块,极大地方便了分发和部署。
1.14.5 访问权限
在 Java 中,class 的访问权限分为 public、protected、private 和默认的包访问权限。引入模块后,这些访问权限的规则就要稍微做些调整。
class 的这些访问权限只在一个模块内有效,模块和模块之间,例如,a 模块要访问 b 模块的某个 class,必要条件是 b 模块明确地导出了可以访问的包。
例如,编写的模块 hello.world
用到了模块 java.xml
的一个类 javax.xml.XMLConstants
,之所以能直接使用这个类,是因为模块 java.xml
的 module-info.java
中声明了若干导出:
module java.xml {
exports java.xml;
exports javax.xml.catalog;
exports javax.xml.datatype;
...
}
只有它声明的导出的包,外部代码才被允许访问。换句话说,如果外部代码想要访问 hello.world
模块中的 com.itranswarp.sample.Greeting
类,必须将其导出:
module hello.world {
exports com.itranswarp.sample;
requires java.base;
requires java.xml;
}
因此,模块进一步隔离了代码的访问权限。