JAVA之面向对象

什么是面向对象编程(OOP)?

面向对象编程(OOP)是一种通过对象的方式,将现实世界的概念映射到计算机模型的编程方法。与面向过程编程不同,面向对象编程注重对象之间的互动,而不是按步骤逐步执行。

面向对象的基本概念

  • :定义对象的模板或蓝图。
  • 实例:类的具体实现,称为对象。
  • 方法:对象的行为或功能。

面向对象的实现方式

  • 继承:子类继承父类的属性和方法。
  • 多态:不同对象可以通过相同的接口以不同方式执行。

Java语言的机制

  • package:组织类的工具。
  • classpath:定义Java程序运行时所需的类路径。
  • jar:将Java类和资源打包成单一文件的格式。

Java标准库的核心类

  • 字符串:用于处理文本。
  • 包装类型:将基本数据类型封装成对象。
  • JavaBean:一种Java类标准,用于封装多个对象属性。
  • 枚举:定义常量集合的特殊类。
  • 常用工具类:提供各种实用功能的方法集合。

面向对象编程是一种通过“类”和“实例”来模拟现实世界的编程方法。简单来说,“类”是一个模板,用来创建对象,“实例”则是根据模板生成的具体对象。

现实世界与计算机模型的对应关系

  • 现实世界: “人”是一个抽象概念,具体的“小明”、“小红”是实际的人。
  • 计算机模型: “人”可以用类(class)表示,小明、小红等是实例(instance)。

对应的Java代码

  • 定义“人”这个类:
    1
    class Person { }
  • 创建“小明”、“小红”这两个实例:
    1
    2
    Person ming = new Person(); // 小明
    Person hong = new Person(); // 小红

示例类:Book

让我们定义一个“书”的类(class),其中包含一些字段(field),如书名、作者、ISBN和价格:

1
2
3
4
5
6
class Book {
public String name; // 书名
public String author; // 作者
public String isbn; // ISBN编号
public double price; // 价格
}

创建实例

定义好类后,可以用 new 创建实例。例如:

1
Book book1 = new Book(); // 创建一本书的实例

操作实例

通过实例变量操作实例的数据:

1
2
book1.name = "Java核心技术"; // 设置书名
book1.author = "Cay S. Horstmann"; // 设置作者

示例 1: 定义和使用 Person

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Person {
public String name;
public int age;
}

public class Main {
public static void main(String[] args) {
// 创建两个Person实例
Person ming = new Person();
ming.name = "Xiao Ming";
ming.age = 12;

Person hong = new Person();
hong.name = "Xiao Hong";
hong.age = 15;

// 输出实例的信息
System.out.println(ming.name + " is " + ming.age + " years old.");
System.out.println(hong.name + " is " + hong.age + " years old.");
}
}

输出:

1
2
Xiao Ming is 12 years old.
Xiao Hong is 15 years old.

示例 2: 定义和使用 Book

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Book {
public String name;
public String author;
public String isbn;
public double price;
}

public class Main {
public static void main(String[] args) {
// 创建两个Book实例
Book book1 = new Book();
book1.name = "Java核心技术";
book1.author = "Cay S. Horstmann";
book1.isbn = "978-7111624199";
book1.price = 99.0;

Book book2 = new Book();
book2.name = "Java编程思想";
book2.author = "Bruce Eckel";
book2.isbn = "978-7111452464";
book2.price = 129.0;

// 输出实例的信息
System.out.println("Book 1: " + book1.name + ", Author: " + book1.author + ", Price: " + book1.price);
System.out.println("Book 2: " + book2.name + ", Author: " + book2.author + ", Price: " + book2.price);
}
}

输出:

1
2
Book 1: Java核心技术, Author: Cay S. Horstmann, Price: 99.0
Book 2: Java编程思想, Author: Bruce Eckel, Price: 129.0

示例 3: 使用多个实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Person {
public String name;
public int age;
}

class Book {
public String name;
public String author;
public double price;
}

public class Main {
public static void main(String[] args) {
// 创建Person实例
Person jun = new Person();
jun.name = "Xiao Jun";
jun.age = 14;

// 创建Book实例
Book book3 = new Book();
book3.name = "Java学习笔记";
book3.author = "某某作者";
book3.price = 59.0;

// 输出信息
System.out.println(jun.name + " is reading " + book3.name + " by " + book3.author);
}
}

输出:

1
Xiao Jun is reading Java学习笔记 by 某某作者

类方法

1. 定义类与字段

1
2
3
4
class Person {
private String name;
private int age;
}

将字段 nameage 定义为 private,使外部无法直接访问。

2. 使用 gettersetter 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class Main {
public static void main(String[] args) {
Person ming = new Person();
ming.setName("Xiao Ming");
ming.setAge(12);
System.out.println(ming.getName() + ", " + ming.getAge());
}
}

class Person {
private String name;
private int age;

public String getName() {
return name;
}

public void setName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Invalid name");
}
this.name = name.strip();
}

public int getAge() {
return age;
}

public void setAge(int age) {
if (age < 0 || age > 100) {
throw new IllegalArgumentException("Invalid age value");
}
this.age = age;
}
}

通过 gettersetter 方法控制 private 字段的访问,并在方法中加入逻辑检查。

3. 参数绑定

  • 基本类型参数: 值复制,修改局部变量不影响对象字段。
  • 引用类型参数: 指向同一个对象,修改引用会影响对象字段。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Main {
public static void main(String[] args) {
Person p = new Person();
String[] fullname = new String[] { "Homer", "Simpson" };
p.setName(fullname);
System.out.println(p.getName()); // 输出: "Homer Simpson"
fullname[0] = "Bart";
System.out.println(p.getName()); // 输出: "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;
}
}

此示例展示了引用类型参数的绑定,修改数组内容会影响对象字段。

这个代码展示了引用类型参数传递的机制。

1. 引用类型参数的传递

在Java中,方法调用时的参数传递有两种情况:

  • 基本数据类型(如 intfloatboolean 等):传递的是参数的值的副本,方法内修改副本不会影响原来的变量。
  • 引用数据类型(如数组,String,对象):传递的是对象的引用,方法内的修改会影响原来的对象,因为它们指向同一个内存地址。

2. setName() 方法的执行过程

当你调用 p.setName(fullname) 时,fullname 数组的引用被传递给 setName() 方法。在这个方法内部,这个引用被赋值给 Person 类的 name 字段:

1
2
3
public void setName(String[] name) {
this.name = name;
}

此时,this.namefullname 指向同一个数组对象。换句话说,p.namefullname 这两个变量都指向相同的内存地址。

3. 修改数组的影响

接着,当你在 main 方法中修改数组 fullname 的第一个元素:

1
fullname[0] = "Bart";

因为 fullnamep.name 都指向同一个数组对象,所以修改 fullname 数组的内容会直接反映在 p.name 中。

因此,调用 getName() 时,输出结果会变成 "Bart Simpson",而不是原来的 "Homer Simpson"

4. 代码输出分析

  • 第一次调用 getName(): 输出 "Homer Simpson",因为在 fullname 数组传递给 setName() 时,数组内容还没有修改。
  • 第二次调用 getName(): 输出 "Bart Simpson",因为在调用 setName() 后,我们修改了数组的第一个元素,而 p.name 仍然指向同一个数组对象。

引用类型参数的传递意味着两个变量可以指向同一个对象。通过这种方式,修改一个变量(比如数组元素)会影响到所有指向同一个对象的变量。因此,在操作引用类型数据时,理解这种共享引用机制至关重要。

4. 使用 this 关键字

1
2
3
4
5
6
7
class Person {
private String name;

public void setName(String name) {
this.name = name; // 必须使用this来区分局部变量和字段
}
}

在字段与局部变量同名时,通过 this 关键字区分。

5. 可变参数

1
2
3
4
5
6
7
class Group {
private String[] names;

public void setNames(String... names) {
this.names = names;
}
}

可变参数允许传递多个参数,便于使用。

构造方法与初始化

构造方法用于在创建对象实例时初始化对象的内部状态。通过构造方法,我们可以确保在对象创建时内部字段被正确地初始化。以下是构造方法的基本实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Person {
private String name;
private int age;

// 带参数的构造方法
public Person(String name, int age) {
this.name = name;
this.age = age;
}

// 无参数构造方法
public Person() {
}

public String getName() {
return name;
}

public int getAge() {
return age;
}
}

在创建Person实例时,我们可以使用带参数的构造方法进行初始化,从而避免需要手动调用setName()setAge()等方法。例如:

1
2
3
4
5
6
7
public class Main {
public static void main(String[] args) {
Person ming = new Person("小明", 12);
System.out.println(ming.getName()); // 输出: 小明
System.out.println(ming.getAge()); // 输出: 12
}
}

多个构造方法

我们可以定义多个构造方法,以便根据不同的需求初始化对象。例如,既可以创建无参数构造方法,也可以创建带参数的构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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); // 调用另一个构造方法
}

public Person() {
this("Unnamed"); // 调用另一个构造方法
}

public String getName() {
return name;
}

public int getAge() {
return age;
}
}

在上面的代码中,通过调用this(…),一个构造方法可以复用另一个构造方法的逻辑,减少重复代码。

练习

请在Person类中增加一个带参数的构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Main {
public static void main(String[] args) {
// 给Person增加构造方法:
Person ming = new Person("小明", 12);
System.out.println(ming.getName()); // 输出: 小明
System.out.println(ming.getAge()); // 输出: 12
}
}

class Person {
private String name;
private int age;

// 增加的构造方法
public Person(String name, int age) {
this.name = name;
this.age = age;
}

public String getName() {
return name;
}

public int getAge() {
return age;
}
}

通过这样的构造方法,实例创建时的字段初始化更加简洁、可靠。

更加复杂的一些样例:

1. 多重初始化与对象嵌套

考虑一个Student类,其中包含一个嵌套的Address对象,这个Address对象本身也需要初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class Address {
private String city;
private String street;

public Address(String city, String street) {
this.city = city;
this.street = street;
}

public String getCity() {
return city;
}

public String getStreet() {
return street;
}
}

class Student {
private String name;
private int age;
private Address address;

public Student(String name, int age, Address address) {
this.name = name;
this.age = age;
this.address = address;
}

public String getName() {
return name;
}

public int getAge() {
return age;
}

public Address getAddress() {
return address;
}
}

public class Main {
public static void main(String[] args) {
Address addr = new Address("Beijing", "Chang'an Street");
Student student = new Student("Li Hua", 20, addr);
System.out.println(student.getName() + " lives in " + student.getAddress().getCity() + ", " + student.getAddress().getStreet());
}
}

在这个例子中,Student类的构造方法不仅初始化了自身的字段,还初始化了一个Address对象,展示了对象嵌套初始化的情况。

2. 构造方法的逻辑处理

在某些情况下,构造方法中需要进行复杂的逻辑处理。考虑一个BankAccount类,初始化时需要处理余额、利率、账户类型等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class BankAccount {
private String accountNumber;
private double balance;
private String accountType;
private double interestRate;

public BankAccount(String accountNumber, double initialDeposit, String accountType) {
this.accountNumber = accountNumber;
this.balance = initialDeposit;
this.accountType = accountType;

// 根据账户类型设置不同的利率
if (accountType.equalsIgnoreCase("Savings")) {
this.interestRate = 0.05;
} else if (accountType.equalsIgnoreCase("Checking")) {
this.interestRate = 0.01;
} else {
this.interestRate = 0.02;
}
}

public double calculateInterest() {
return balance * interestRate;
}

public String getAccountNumber() {
return accountNumber;
}

public double getBalance() {
return balance;
}

public String getAccountType() {
return accountType;
}
}

public class Main {
public static void main(String[] args) {
BankAccount account = new BankAccount("123456789", 1000.0, "Savings");
System.out.println("Account " + account.getAccountNumber() + " has an interest of " + account.calculateInterest());
}
}

在这个例子中,构造方法不仅初始化了基本字段,还根据账户类型设置了不同的利率,展示了构造方法中的逻辑处理。

3. 构造方法中的参数校验

有时候,构造方法需要对传入的参数进行校验,以确保对象状态的有效性。考虑一个Employee类,需要确保年龄在合理范围内:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Employee {
private String name;
private int age;
private double salary;

public Employee(String name, int age, double salary) {
if (age < 18 || age > 65) {
throw new IllegalArgumentException("Age must be between 18 and 65.");
}
this.name = name;
this.age = age;
this.salary = salary;
}

public String getName() {
return name;
}

public int getAge() {
return age;
}

public double getSalary() {
return salary;
}
}

public class Main {
public static void main(String[] args) {
try {
Employee emp = new Employee("John Doe", 45, 50000.0);
System.out.println("Employee " + emp.getName() + " is " + emp.getAge() + " years old with a salary of $" + emp.getSalary());
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
}

在这个例子中,Employee类的构造方法对年龄进行校验,如果传入的年龄不在合理范围内,将抛出异常,展示了如何在构造方法中进行参数校验。

4. 构造方法的重载与默认值

有时候,我们希望构造方法支持多种初始化方式,并且在某些情况下提供默认值。例如,一个Car类可以通过多种方式初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Car {
private String make;
private String model;
private int year;
private String color;

public Car(String make, String model, int year, String color) {
this.make = make;
this.model = model;
this.year = year;
this.color = color;
}

public Car(String make, String model) {
this(make, model, 2020, "Black"); // 默认值为2020年和黑色
}

public Car() {
this("Unknown", "Unknown", 2020, "White"); // 默认值为Unknown, 2020年和白色
}

public String getDetails() {
return year + " " + make + " " + model + " in " + color;
}
}

public class Main {
public static void main(String[] args) {
Car car1 = new Car("Toyota", "Corolla", 2022, "Red");
Car car2 = new Car("Honda", "Civic");
Car car3 = new Car();
System.out.println(car1.getDetails());
System.out.println(car2.getDetails());
System.out.println(car3.getDetails());
}
}

在这个例子中,Car类提供了多个构造方法,允许不同的初始化方式,并为未提供的参数设置了默认值。

方法重载 (Method Overloading)

方法重载是指在同一个类中,可以定义多个方法名相同但参数列表不同的方法。这些方法可以具有不同的参数个数、类型或顺序,但它们必须具有相同的方法名。这使得方法的调用更加灵活和易于记忆。

方法重载的规则

  1. 方法名相同:重载的方法必须有相同的方法名。
  2. 参数列表不同:重载的方法必须具有不同的参数列表。这包括参数的数量、类型或顺序。
  3. 返回类型:方法的返回类型不参与方法重载的匹配过程。换句话说,仅仅通过改变返回类型不能实现方法的重载。

示例

假设你有一个 Hello 类,你希望根据不同的参数形式提供不同的问候功能。你可以使用方法重载来实现这一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 + "!");
}
}
}

在上面的代码中,hello 方法被重载了三次,每次参数列表不同。这样,我们可以使用相同的方法名来处理不同的情况。

方法重载的实际应用

String 类中,也有许多重载的方法。例如,indexOf 方法用于查找子串的起始位置,但它提供了多种重载形式,以支持不同的查找需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Main {
public static void main(String[] args) {
String s = "Test string";
// 查找字符 't' 的位置
int n1 = s.indexOf('t'); // 返回第一个 't' 的索引
// 查找子串 "st" 的位置
int n2 = s.indexOf("st"); // 返回第一个 "st" 的索引
// 从索引 4 开始查找子串 "st"
int n3 = s.indexOf("st", 4); // 从索引 4 开始查找 "st"

System.out.println(n1); // 输出:0
System.out.println(n2); // 输出:5
System.out.println(n3); // 输出:10
}
}

解释

  1. s.indexOf('t'):查找字符 't' 在字符串中的第一个出现位置。返回值是字符 't' 的第一个索引,结果是 0
  2. s.indexOf("st"):查找子串 "st" 在字符串中的第一个出现位置。结果是 5
  3. s.indexOf("st", 4):从索引 4 开始查找子串 "st"。结果是 10,因为从索引 4 开始,第二次出现 "st" 在位置 10

方法重载使得同一个方法名可以处理不同类型的输入或不同的用例,使代码更加清晰和易于维护。

方法重载的深层次分析

1. 参数类型和顺序的细微区别

重载不仅仅依赖于参数的数量,还依赖于参数的类型和顺序。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class OverloadExample {
// 参数是一个 int 类型
public void process(int a) {
System.out.println("Processing int: " + a);
}

// 参数是一个 double 类型
public void process(double a) {
System.out.println("Processing double: " + a);
}

// 参数是一个 int 和 double 类型
public void process(int a, double b) {
System.out.println("Processing int and double: " + a + ", " + b);
}
}

在上面的代码中,process 方法被重载了三次,它们的参数类型和顺序不同:

  • process(int a):处理一个 int 类型的参数。
  • process(double a):处理一个 double 类型的参数。
  • process(int a, double b):处理一个 int 和一个 double 类型的参数。

2. 自动类型提升

Java 支持自动类型提升(auto-promotions),这意味着如果方法的参数类型不完全匹配,Java 会尝试将实参转换为目标方法所需的类型。这可能会导致选择不明确的情况。例如:

1
2
3
4
5
6
7
8
9
class PromotionExample {
public void process(int a) {
System.out.println("Processing int: " + a);
}

public void process(double a) {
System.out.println("Processing double: " + a);
}
}

调用 process(10) 时,10int 类型,因此会调用 process(int a) 方法。然而,调用 process(10.5) 时,10.5double 类型,因此会调用 process(double a) 方法。

但是,如果调用 process(10L)10Llong 类型),Java 会尝试将 long 类型提升为 double 类型,因为 long 类型无法直接匹配 int 类型的方法。结果是 process(double a) 方法会被调用。

3. 重载与构造函数

构造函数也可以重载。构造函数重载与普通方法的重载规则相同,只要构造函数的参数列表不同即可。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Person {
String name;
int age;

// 默认构造函数
public Person() {
this.name = "Unknown";
this.age = 0;
}

// 带有一个参数的构造函数
public Person(String name) {
this.name = name;
this.age = 0;
}

// 带有两个参数的构造函数
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}

4. 重载与类型转换

有时方法的重载可能与类型转换相关。例如,如果有一个方法接受 Object 类型参数和一个方法接受具体类型参数,调用这些方法可能会受到自动类型转换的影响:

1
2
3
4
5
6
7
8
9
class TypeConversionExample {
public void process(Object obj) {
System.out.println("Processing Object: " + obj);
}

public void process(String str) {
System.out.println("Processing String: " + str);
}
}

调用 process("Hello") 会匹配 process(String str) 方法,因为 StringObject 的子类。调用 process(new Object()) 会匹配 process(Object obj) 方法,因为 Object 是最通用的类型。

常见陷阱

  1. 方法签名:确保重载方法的签名(方法名和参数列表)是唯一的。返回类型不在签名中,因此不能仅通过改变返回类型来实现重载。
  2. 自动类型提升:注意自动类型提升可能导致的重载方法选择不明确。为了避免错误,尽量避免混用不同类型的参数。
  3. 调用模糊性:如果两个重载方法的参数列表差异不明显,可能会导致编译器无法确定调用哪个方法。尽量使方法的参数列表区别更明确,以减少这种问题。

继承和代码复用

在Java中,继承是一种让类重用另一个类的功能的机制。你可以通过extends关键字实现继承,从而避免代码重复。

基本概念

继承的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Person {
private String name;
private int age;

public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getAge() { return age; }
public void setAge(int age) { this.age = age; }
}

class Student extends Person {
private int score;

public int getScore() { return score; }
public void setScore(int score) { this.score = score; }
}

解释: - Student 类通过 extends Person 继承了 Person 类的字段和方法。 - Student 不需要重复定义 nameage 字段,只需定义 score 相关的功能。

继承的特性

  1. 访问权限

    • private 修饰的字段和方法不能被子类访问。
    • protected 修饰的字段和方法可以被子类访问。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Person {
    protected String name;
    }

    class Student extends Person {
    public String getName() {
    return name; // OK
    }
    }
  2. 构造方法

    • 子类必须调用父类的构造方法。如果父类没有无参数的构造方法,子类构造方法中必须显式调用父类的构造方法。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class Person {
    public Person(String name, int age) { ... }
    }

    class Student extends Person {
    public Student(String name, int age, int score) {
    super(name, age); // 调用父类构造方法
    this.score = score;
    }
    }
  3. super 关键字

    • 用于引用父类的字段和方法。
    • 在构造方法中,super 用于调用父类的构造方法。
    1
    2
    3
    4
    5
    6
    class Student extends Person {
    public Student(String name, int age, int score) {
    super(name, age); // 调用父类构造方法
    this.score = score;
    }
    }
  4. instanceof 操作符

    • 检查一个对象是否是某个类的实例,或者是其子类的实例。
    1
    2
    3
    4
    Person p = new Student();
    if (p instanceof Student) {
    Student s = (Student) p; // 向下转型
    }
  5. 向上转型和向下转型

    • 向上转型:子类实例可以赋值给父类引用。
    • 向下转型:父类引用可以强制转换为子类实例,需确保实际对象是子类实例。
    1
    2
    Person p = new Student(); // 向上转型
    Student s = (Student) p; // 向下转型

继承与组合

  • 继承:用于表示“is-a”关系(例如,Student is a Person)。

  • 组合:用于表示“has-a”关系(例如,Student has a Book)。

    1
    2
    3
    4
    class Book { ... }
    class Student extends Person {
    private Book book; // 组合
    }

例子:

复杂继承示例

示例 1:多层继承

场景:假设我们要构建一个动物分类系统,其中有不同的动物,每种动物都有自己的特性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 基类:动物
class Animal {
protected String name;

public Animal(String name) {
this.name = name;
}

public void eat() {
System.out.println(name + " is eating.");
}
}

// 子类:哺乳动物
class Mammal extends Animal {
public Mammal(String name) {
super(name);
}

public void walk() {
System.out.println(name + " is walking.");
}
}

// 子类:猫
class Cat extends Mammal {
public Cat(String name) {
super(name);
}

public void meow() {
System.out.println(name + " says meow.");
}
}

// 子类:狮子
class Lion extends Cat {
public Lion(String name) {
super(name);
}

@Override
public void eat() {
System.out.println(name + " is hunting.");
}
}

// 使用
public class Main {
public static void main(String[] args) {
Lion leo = new Lion("Leo");
leo.eat(); // Lion-specific behavior
leo.walk(); // Inherited from Mammal
leo.meow(); // Inherited from Cat
}
}

解释: - Lion 继承自 CatCat 继承自 MammalMammal 继承自 Animal。 - Lion 重写了 eat() 方法,表现出狮子的特定行为。

示例 2:接口与继承

场景:我们希望定义一个系统,涉及不同类型的可移动对象,例如车和飞行器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 接口:可移动
interface Movable {
void move();
}

// 类:车
class Car implements Movable {
private String model;

public Car(String model) {
this.model = model;
}

@Override
public void move() {
System.out.println(model + " is driving.");
}
}

// 类:飞行器
class Aircraft implements Movable {
private String model;

public Aircraft(String model) {
this.model = model;
}

@Override
public void move() {
System.out.println(model + " is flying.");
}
}

// 类:电动车(扩展了Car)
class ElectricCar extends Car {
public ElectricCar(String model) {
super(model);
}

public void charge() {
System.out.println(super.model + " is charging.");
}
}

// 使用
public class Main {
public static void main(String[] args) {
ElectricCar tesla = new ElectricCar("Tesla Model S");
tesla.move(); // 从Car继承
tesla.charge(); // 特有方法

Aircraft jet = new Aircraft("Jet");
jet.move(); // 从Aircraft实现的Movable接口
}
}

解释: - Movable 是一个接口,CarAircraft 实现了它。 - ElectricCar 继承自 Car,继承了 Carmove() 方法,并增加了充电功能。

接口(interface)在Java中是一种特殊的引用类型,用于定义类的行为规范。它允许我们声明方法,但不提供这些方法的具体实现。接口主要用于以下目的:

  1. 定义行为:接口可以定义一组方法,这些方法需要被实现类提供具体的行为。实现接口的类必须实现接口中的所有方法。

  2. 多重继承:Java不支持类的多重继承,但一个类可以实现多个接口,这种方式允许类继承多个行为特征。

  3. 实现多态:接口允许我们在程序中使用不同的实现类来处理相同的接口类型,从而实现多态性。

具体示例

1. 定义接口

1
2
3
4
// 定义一个接口 Movable
interface Movable {
void move(); // 接口中的方法没有方法体
}

解释: - interface 关键字用于定义接口。 - Movable 接口声明了一个 move() 方法,但没有提供方法的具体实现。

2. 实现接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 实现 Movable 接口的 Car 类
class Car implements Movable {
private String model;

public Car(String model) {
this.model = model;
}

@Override
public void move() {
System.out.println(model + " is driving.");
}
}

// 实现 Movable 接口的 Aircraft 类
class Aircraft implements Movable {
private String model;

public Aircraft(String model) {
this.model = model;
}

@Override
public void move() {
System.out.println(model + " is flying.");
}
}

解释: - CarAircraft 类通过 implements 关键字实现了 Movable 接口。 - 这两个类都需要提供 move() 方法的具体实现。

3. 使用接口

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
Movable myCar = new Car("Toyota");
Movable myAircraft = new Aircraft("Boeing");

myCar.move(); // Output: Toyota is driving.
myAircraft.move(); // Output: Boeing is flying.
}
}

解释: - Movable 类型的变量 myCarmyAircraft 可以指向实现了 Movable 接口的对象。 - 调用 move() 方法时,根据对象的实际类型(CarAircraft),会执行相应的实现。

示例 3:抽象类与多态

场景:创建一个图形绘制系统,使用抽象类来定义不同的图形。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 抽象类:图形
abstract class Shape {
protected String color;

public Shape(String color) {
this.color = color;
}

public abstract void draw();
}

// 子类:圆形
class Circle extends Shape {
private double radius;

public Circle(String color, double radius) {
super(color);
this.radius = radius;
}

@Override
public void draw() {
System.out.println("Drawing a " + color + " circle with radius " + radius);
}
}

// 子类:矩形
class Rectangle extends Shape {
private double width, height;

public Rectangle(String color, double width, double height) {
super(color);
this.width = width;
this.height = height;
}

@Override
public void draw() {
System.out.println("Drawing a " + color + " rectangle with width " + width + " and height " + height);
}
}

// 使用
public class Main {
public static void main(String[] args) {
Shape[] shapes = {
new Circle("red", 5.0),
new Rectangle("blue", 4.0, 6.0)
};

for (Shape shape : shapes) {
shape.draw(); // 多态
}
}
}

解释: - Shape 是一个抽象类,定义了一个抽象方法 draw()。 - CircleRectangle 继承自 Shape 并实现了 draw() 方法。 - 使用多态,通过 Shape 类型的数组来处理不同类型的图形。

抽象类和抽象方法

面向抽象编程(Programming to an Interface)是一种编程范式,它强调代码的设计与实现应基于抽象,而不是具体的实现细节。这种方法通过使用抽象类或接口,允许我们编写更加灵活和可扩展的代码。

1. 抽象类与抽象方法的基本概念

抽象类:抽象类是不能被实例化的类,它通常包含一个或多个抽象方法。抽象类为子类提供了一个统一的接口,并可以包含一些具体的方法实现。抽象类的主要目的是被继承,并强制子类实现其定义的抽象方法。

抽象方法:抽象方法是没有具体实现的方法,定义时使用 abstract 关键字。这些方法必须在子类中实现,否则子类本身也必须声明为抽象类。

1
2
3
4
5
6
abstract class Animal {
public abstract void makeSound(); // 抽象方法,没有实现
public void sleep() {
System.out.println("The animal is sleeping.");
}
}

解释: - Animal 类是一个抽象类,其中 makeSound() 是抽象方法,必须由子类实现。 - sleep() 是一个具体方法,子类可以直接继承使用。

2. 子类继承抽象类并实现抽象方法

当一个子类继承抽象类时,必须实现所有的抽象方法。如果没有实现,子类也必须声明为抽象类。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Dog extends Animal {
@Override
public void makeSound() {
System.out.println("Woof! Woof!");
}
}

class Cat extends Animal {
@Override
public void makeSound() {
System.out.println("Meow! Meow!");
}
}

解释: - DogCat 继承了 Animal,并各自实现了 makeSound() 方法。 - 这样,每个子类都有了特定的行为,但在使用上依然可以统一为 Animal 类型处理。

3. 面向抽象编程的好处

统一接口,灵活实现:在面向抽象编程中,我们可以使用抽象类或接口类型的变量来引用不同的子类对象,从而调用子类的实现方法。这样做的好处是,代码的调用者不需要了解具体的子类,只需知道接口或者抽象类的定义即可。

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
Animal myDog = new Dog();
Animal myCat = new Cat();

myDog.makeSound(); // Output: Woof! Woof!
myCat.makeSound(); // Output: Meow! Meow!
}
}

解释: - 在 Main 类中,我们使用了 Animal 类型来声明变量 myDogmyCat,但实际引用了具体的 DogCat 对象。 - 这样,我们在使用时并不关心对象的具体类型,只需调用 makeSound() 方法,即可得到正确的行为。

4. 面向抽象编程的扩展性

由于面向抽象编程依赖于抽象类或接口定义行为规范,而不依赖于具体实现,所以代码具有很好的扩展性。当需要增加新的行为或修改现有行为时,可以通过新增子类或实现新的接口来完成,而无需修改现有代码。

扩展示例

1
2
3
4
5
6
class Bird extends Animal {
@Override
public void makeSound() {
System.out.println("Chirp! Chirp!");
}
}
1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
Animal myBird = new Bird();
myBird.makeSound(); // Output: Chirp! Chirp!
}
}

解释: - 新增 Bird 类并实现 makeSound() 方法,扩展了 Animal 的行为。 - 由于 Main 类的代码只依赖于抽象的 Animal 类型,因此无需对 Main 类进行任何修改,就可以使用新的 Bird 类。

5. 面向抽象编程的应用场景

多态性:通过面向抽象编程,可以实现多态性,即同一操作在不同的对象上可能表现出不同的行为。例如,makeSound()DogCat 中表现不同。

解耦合:将具体实现与使用代码分离,通过依赖抽象类或接口,可以减少代码的耦合度,便于后续的维护和扩展。

增强可测试性:在单元测试中,通过面向抽象编程可以轻松地使用不同的子类或模拟对象(Mock Object)来测试代码的不同部分,从而提高代码的可测试性。

6. 实现与接口的结合

在实际应用中,接口(interface)常与抽象类结合使用。接口定义行为规范,而抽象类可以提供部分实现,子类通过实现接口并继承抽象类来实现具体的业务逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
interface Movable {
void move();
}

abstract class Vehicle implements Movable {
public abstract void fuel();
}

class Car extends Vehicle {
@Override
public void move() {
System.out.println("Car is moving.");
}

@Override
public void fuel() {
System.out.println("Car is refueling.");
}
}

class Bicycle extends Vehicle {
@Override
public void move() {
System.out.println("Bicycle is moving.");
}

@Override
public void fuel() {
System.out.println("Bicycle doesn't need fuel.");
}
}

解释: - Movable 接口定义了 move() 方法。 - Vehicle 抽象类实现了 Movable 接口,并添加了 fuel() 抽象方法。 - CarBicycle 类继承了 Vehicle 并实现了 move()fuel() 方法,提供了具体的行为。

通过这种设计,程序可以更灵活地适应不同的业务需求,并通过面向抽象编程实现高扩展性和可维护性。

接口

在Java中,抽象类和接口的设计分别代表不同的抽象程度,并在不同场景下发挥其作用。为了更好地理解它们的区别以及如何合理使用,我们可以详细分析各自的特点及应用场景。

抽象类与接口的对比

特性 抽象类(abstract class 接口(interface
继承方式 只能继承一个抽象类 可以实现多个接口
字段 可以包含实例字段 不能包含实例字段
方法 可以定义抽象方法和非抽象方法 可以定义抽象方法和默认方法 (default method)
构造器 可以有构造器 不能有构造器
使用场景 用于共享代码和定义类的基础行为 用于定义类的行为规范
继承关系 适合在类之间建立具体的继承层次关系 适合在不同的类之间建立共同的行为接口

接口继承与扩展

在Java中,一个接口可以继承另一个接口,这种继承关系通过 extends 关键字实现。接口继承其他接口后,可以扩展接口的方法。例如:

1
2
3
4
5
6
7
8
interface Hello {
void hello();
}

interface Person extends Hello {
void run();
String getName();
}

在这个例子中,Person 接口继承了 Hello 接口,这意味着 Person 接口中包含了 Hello 接口定义的 hello() 方法。任何实现 Person 接口的类都需要实现 run()getName()hello() 三个方法。

面向接口编程的优势

面向接口编程是一种设计原则,旨在通过使用接口来定义系统的行为规范,使得上层代码不依赖于具体的实现类,而是依赖于接口。这样做的优势包括:

  1. 提高代码的灵活性:上层代码只需依赖接口,而不需要知道具体的实现类,这使得代码可以更容易地进行扩展和修改。

  2. 增强代码的可测试性:由于接口定义了行为规范,可以通过传入不同的实现类来测试代码的不同行为。

  3. 实现多态性:接口可以使得不同的类表现出相同的行为(即多态性),从而提高代码的重用性。

default 方法与抽象类的普通方法

Java 8引入了接口中的 default 方法,允许接口提供默认的实现而不要求子类必须覆写。使用 default 方法可以有效减少子类的代码重复,尤其是在接口需要扩展新功能时。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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;
}
}

在这个例子中,Student 类没有覆写 run() 方法,它直接继承了接口中的 default 实现。

与抽象类的普通方法相比,default 方法有一些限制: - 不能访问实例字段:接口中没有字段,因此 default 方法不能访问实现类中的字段,而抽象类的普通方法可以访问实例字段。 - 多重继承冲突:如果一个类实现了多个接口,这些接口有相同的 default 方法签名时,会产生冲突,需要显式地覆写该方法来解决冲突。

对限制一进行举例:

1. 接口中的 default 方法

在接口中,default 方法不能直接访问实现类中的实例字段。来看以下代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
interface Person {
String getName();

default void introduce() {
// 尝试访问 name 字段(会失败)
// System.out.println("Hello, my name is " + name); // 编译错误,因为接口中没有字段
System.out.println("Hello, my name is " + getName());
}
}

class Student implements Person {
private String name;

public Student(String name) {
this.name = name;
}

@Override
public String getName() {
return this.name;
}
}

public class Main {
public static void main(String[] args) {
Student student = new Student("Alice");
student.introduce(); // 调用接口中的 default 方法
}
}

解释: - 在 Person 接口中,introduce() 是一个 default 方法。它不能直接访问实现类 Student 中的 name 字段,因为接口不能包含实例字段。 - 但是,introduce() 可以通过调用 getName() 方法来获取 name 的值,因为 getName() 是由实现类 Student 提供的。

输出

1
Hello, my name is Alice

2. 抽象类中的普通方法

与接口不同,抽象类中的普通方法可以直接访问实例字段。来看以下代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
abstract class Person {
protected String name;

public Person(String name) {
this.name = name;
}

public abstract String getName();

public void introduce() {
// 可以直接访问 name 字段
System.out.println("Hello, my name is " + name);
}
}

class Student extends Person {

public Student(String name) {
super(name);
}

@Override
public String getName() {
return this.name;
}
}

public class Main {
public static void main(String[] args) {
Student student = new Student("Bob");
student.introduce(); // 调用抽象类中的普通方法
}
}

解释: - 在 Person 抽象类中,introduce() 是一个普通方法,它直接访问了抽象类的 name 字段。 - Student 类继承了 Person 抽象类,并通过构造器初始化了 name 字段。

输出

1
Hello, my name is Bob

解释一下限制二:

当一个类实现了多个接口,这些接口有相同的 default 方法签名时,会产生多重继承冲突。为了避免这种冲突,Java要求在实现类中显式地覆写冲突的 default 方法。下面是一个代码示例来解释这个限制。

示例代码

假设我们有两个接口 PersonWorker,它们都定义了一个相同签名的 default 方法 greet()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
interface Person {
default void greet() {
System.out.println("Hello from Person!");
}
}

interface Worker {
default void greet() {
System.out.println("Hello from Worker!");
}
}

class Employee implements Person, Worker {
@Override
public void greet() {
// 显式指定要调用哪个接口的default方法,或者定义自己的实现
// 例如,调用Person接口的greet:
Person.super.greet();
// 或者调用Worker接口的greet:
// Worker.super.greet();

// 也可以自定义实现:
// System.out.println("Hello from Employee!");
}
}

public class Main {
public static void main(String[] args) {
Employee e = new Employee();
e.greet(); // 调用解决冲突后的greet方法
}
}

解释

  1. 接口定义
    • PersonWorker 接口都定义了一个相同的 default 方法 greet(),分别输出不同的消息。
  2. 冲突产生
    • Employee 类实现了 PersonWorker 两个接口。由于这两个接口都有相同签名的 default 方法 greet(),Java 无法自动决定应该使用哪个 greet() 方法,这就导致了多重继承冲突。
  3. 解决冲突
    • Employee 类中,必须显式地覆写 greet() 方法来解决冲突。在覆写的方法内部,可以选择调用 Person.super.greet()Worker.super.greet(),也可以定义自己的实现。
    • 例如,在 greet() 方法中,调用了 Person.super.greet() 来使用 Person 接口的 greet() 方法。

输出结果

如果运行上述代码,输出将是:

1
Hello from Person!

  • 当实现多个接口时,如果这些接口有相同签名的 default 方法,Java 会强制要求在实现类中显式覆写该方法来解决冲突。
  • 在覆写的方法中,可以选择调用其中一个接口的 default 方法,或者定义一个新的实现来解决冲突。

静态字段 (Static Field)

在Java中,静态字段是属于类本身的,而不是某个实例。当一个字段被 static 修饰时,这个字段在所有实例中都是共享的。无论有多少个实例,静态字段只存在一份,并且它的值对所有实例都是相同的。

示例解释

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
public String name;
public int age;

// 静态字段,属于Person类
public static int number;

public Person(String name, int age) {
this.name = name;
this.age = age;
}
}

在上面的代码中,nameage 是实例字段,而 number 是静态字段。实例字段 nameage 是每个 Person 实例独有的,而静态字段 number 是所有 Person 实例共享的。

来看一个实例使用静态字段的例子:

1
2
3
4
5
6
7
8
9
10
11
12
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); // 输出 88

hong.number = 99;
System.out.println(ming.number); // 输出 99
}
}

解释:

  • ming.number = 88; 时,静态字段 number 的值被设置为 88
  • hong.number 被打印时,输出的是 88,因为 number 是静态的,所有实例共享同一个值。
  • hong.number = 99; 时,静态字段 number 被修改为 99
  • 再次打印 ming.number 时,输出的是 99,因为 minghong 实际上共享同一个 number 字段。

关键点: - 静态字段与实例无关,而是与类绑定的。所有实例共享一个静态字段。 - 通常,我们推荐使用 类名.静态字段 来访问静态字段,避免混淆。

静态方法 (Static Method)

静态方法是用 static 修饰的方法,属于类本身,而不是类的实例。调用静态方法不需要实例化类,可以直接使用 类名.静态方法 来调用。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Person {
public static int number;

// 静态方法
public static void setNumber(int value) {
number = value;
}
}

public class Main {
public static void main(String[] args) {
// 调用静态方法设置静态字段的值
Person.setNumber(99);
System.out.println(Person.number); // 输出 99
}
}

解释:

  • setNumber 方法是一个静态方法,它设置了 number 字段的值。
  • setNumber 方法直接通过类名 Person 调用,而不需要创建 Person 的实例。

静态方法的限制:

  • 不能访问实例字段: 因为静态方法不依赖于任何实例,所以它不能访问 this 关键字,也不能访问实例字段。它只能访问静态字段和静态方法。
  • 通常用于工具类和辅助方法: 静态方法通常用于不依赖实例的数据操作,如数学运算 (Math.random()) 或数组操作 (Arrays.sort())。

接口的静态字段

在Java中,接口 (interface) 是一种特殊的抽象类。接口不能包含实例字段,但可以定义静态字段,并且这些字段必须是 final 类型的常量。

示例代码

1
2
3
4
5
public interface Person {
// 定义静态常量
int MALE = 1; // 编译器自动添加 public static final
int FEMALE = 2;
}

解释:

  • 在接口中,字段默认是 public static final 类型,表示它们是全局常量。你可以直接通过 接口名.常量名 来访问这些常量。
  • 这些常量可以用于表示固定的值,如性别分类等。
  • 静态字段 是属于类的,可以被所有实例共享。
  • 静态方法 也是属于类的,不需要实例化就能调用,但不能访问实例字段。
  • 接口中的静态字段 是全局常量,用来表示固定的值,它们是 public static final 类型。

在Java中,package)用于组织和管理类,使它们在命名上互不冲突。通过定义包,我们可以避免类名冲突,并且更好地组织代码结构,尤其是在大型项目中。

1. 包的定义和使用

1.1 定义包

在编写Java类时,我们可以在文件的第一行通过package语句来定义该类所属的包。例如:

1
2
3
4
5
package com.example.mypackage;

public class MyClass {
// 类的内容
}

这里,com.example.mypackage就是包名,表示MyClass类属于这个包中。

1.2 包名的结构

包名通常采用倒置域名的形式。例如,域名example.com可以被倒置为com.example,然后在其下根据项目需求进一步细分。例如:

  • com.example.util:工具类包。
  • com.example.model:模型类包。

这种命名方式可以有效避免包名冲突,因为域名在全球是唯一的。

1.3 包与类的关系

类的完整名称是包名.类名。例如,如果我们有一个类Personcom.example.mypackage包中,它的完整类名就是com.example.mypackage.Person

Java虚拟机(JVM)在执行时使用完整的类名来区分类。因此,只要包名不同,即使类名相同,它们也被视为不同的类。

2. 文件目录结构

Java文件的目录结构应与包结构对应。假设我们有如下的包和类:

  • 包:com.example.mypackage
  • 类:MyClass

则对应的文件路径应为:

1
src/com/example/mypackage/MyClass.java

编译后生成的.class文件应存放在对应的目录中:

1
bin/com/example/mypackage/MyClass.class

3. 包作用域

在Java中,访问控制符包括publicprotectedprivate包作用域(默认访问权限)。如果一个类的成员(字段或方法)没有被publicprotectedprivate修饰,它就是包作用域。

包作用域的成员只能被同一个包中的其他类访问。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example;

public class Person {
// 包作用域方法:
void sayHello() {
System.out.println("Hello!");
}
}

package com.example;

public class Main {
public static void main(String[] args) {
Person p = new Person();
p.sayHello(); // 可以访问,因为Main和Person在同一个包中
}
}

4. import语句

在一个类中,我们经常需要引用其他包中的类。这时可以通过import语句导入这些类,来避免每次使用时都写完整类名。

4.1 单个类的导入

1
2
3
4
5
6
7
import com.example.util.MyUtilityClass;

public class MyClass {
public void doSomething() {
MyUtilityClass utility = new MyUtilityClass();
}
}

4.2 导入整个包

1
2
3
4
5
6
7
8
import com.example.util.*;

public class MyClass {
public void doSomething() {
MyUtilityClass utility = new MyUtilityClass();
AnotherUtilityClass anotherUtility = new AnotherUtilityClass();
}
}

这种写法导入了com.example.util包下的所有类(但不包括子包中的类),虽然方便,但不推荐使用,因为可能导致名称冲突,难以明确具体引用的是哪个类。

4.3 静态导入

静态导入允许我们导入某个类的静态成员(字段或方法),从而可以直接使用这些静态成员而无需类名。例如:

1
2
3
4
5
6
7
import static java.lang.Math.*;

public class MyClass {
public void calculate() {
double result = sqrt(PI); // 相当于Math.sqrt(Math.PI)
}
}

5. Java编译和运行

5.1 编译源码

假设我们有如下的项目目录结构:

1
2
3
4
5
6
7
8
work
└── src
└── com
└── example
├── util
│ └── MyUtilityClass.java
└── main
└── Main.java

编译所有的Java文件,并指定输出目录为bin

1
javac -d ./bin src/**/*.java
  • -d ./bin:指定编译输出的.class文件存放在bin目录中。
  • src/**/*.java:编译src目录及其子目录下的所有.java文件。

注意:Windows下不支持**这种通配符,需要逐个指定Java文件进行编译。

5.2 运行程序

编译完成后,类文件将按照包结构存放在bin目录中,例如:

1
2
bin/com/example/util/MyUtilityClass.class
bin/com/example/main/Main.class

运行Main类:

1
java -cp bin com.example.main.Main

其中,-cp bin指定了classpath,即Java运行时需要搜索的类路径,com.example.main.Main是我们要运行的类的完整类名。

6. 最佳实践

6.1 使用唯一的包名

为了避免名字冲突,建议使用倒置的域名来作为包名前缀。例如:

  • org.apache.commons.logging:Apache Commons Logging包。
  • com.mycompany.myapp:你自己的应用包。

6.2 避免与JDK类重名

避免使用与JDK标准库中类重名的类名,例如:

  • String
  • System
  • List
  • Format

6.3 避免与常用类重名

避免使用与常用库(如Apache Commons、Google Guava等)中的类重名的类名。

包的应用

1. 基本包的使用

假设我们有两个开发者:小明和小红,他们各自开发了自己的类,分别用来表示一个人(Person 类)。为了避免类名冲突,他们将各自的 Person 类放在不同的包中。

小明的代码:

目录结构:

1
2
3
4
project/
└── src/
└── ming/
└── Person.java

Person.java (位于 ming 包):

1
2
3
4
5
6
7
package ming;

public class Person {
public void sayHello() {
System.out.println("Hello from Ming's Person!");
}
}

小红的代码:

目录结构:

1
2
3
4
project/
└── src/
└── hong/
└── Person.java

Person.java (位于 hong 包):

1
2
3
4
5
6
7
package hong;

public class Person {
public void sayHello() {
System.out.println("Hello from Hong's Person!");
}
}

小白想要使用小明和小红的 Person 类:

Main.java (位于 main 包):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main;

import ming.Person as MingPerson;
import hong.Person as HongPerson;

public class Main {
public static void main(String[] args) {
MingPerson mingPerson = new MingPerson();
HongPerson hongPerson = new HongPerson();

mingPerson.sayHello();
hongPerson.sayHello();
}
}

输出结果:

1
2
Hello from Ming's Person!
Hello from Hong's Person!

通过将 Person 类分别放在 minghong 包中,小白能够同时使用这两个类,而不会发生类名冲突。

2. 使用 import 导入类

在很多情况下,直接使用类的完整路径会显得冗长,这时我们可以使用 import 来简化代码。

不使用 import 的方式:

1
2
3
4
5
6
7
8
9
10
11
package main;

public class Main {
public static void main(String[] args) {
ming.Person mingPerson = new ming.Person();
hong.Person hongPerson = new hong.Person();

mingPerson.sayHello();
hongPerson.sayHello();
}
}

使用 import 的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main;

import ming.Person;
import hong.Person;

public class Main {
public static void main(String[] args) {
Person mingPerson = new Person(); // 会出现冲突
Person hongPerson = new Person(); // 会出现冲突

mingPerson.sayHello();
hongPerson.sayHello();
}
}

当包名相同时(如上面的 ming.Personhong.Person),我们不能简单地使用 import,需要使用完整类名或为类起别名。

3. 包结构的实际应用

在实际开发中,我们通常会将项目划分为多个子包,以便组织代码。比如开发一个在线商店系统,我们可以将不同功能模块的代码放在不同的包中。

目录结构:

1
2
3
4
5
6
7
8
9
10
project/
└── src/
└── com/
└── shop/
├── customer/
│ └── Customer.java
├── order/
│ └── Order.java
└── product/
└── Product.java

Customer.java (位于 com.shop.customer 包):

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.shop.customer;

public class Customer {
private String name;

public Customer(String name) {
this.name = name;
}

public String getName() {
return name;
}
}

Order.java (位于 com.shop.order 包):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.shop.order;

import com.shop.customer.Customer;

public class Order {
private Customer customer;

public Order(Customer customer) {
this.customer = customer;
}

public void printOrder() {
System.out.println("Order for customer: " + customer.getName());
}
}

Product.java (位于 com.shop.product 包):

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.shop.product;

public class Product {
private String name;

public Product(String name) {
this.name = name;
}

public String getName() {
return name;
}
}

Main.java (位于 com.shop.main 包):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.shop.main;

import com.shop.customer.Customer;
import com.shop.order.Order;
import com.shop.product.Product;

public class Main {
public static void main(String[] args) {
Customer customer = new Customer("Alice");
Product product = new Product("Laptop");
Order order = new Order(customer);

order.printOrder();
}
}

通过这种包结构,代码得到了很好的组织,方便管理和维护。不同的模块功能被清晰地分离到不同的包中,避免了名字冲突,并增强了代码的可读性。

4. 使用 import static

有时我们希望直接使用一个类的静态方法或字段,而不希望每次都写类名。这时可以使用 import static

Main.java (使用 import static):

1
2
3
4
5
6
7
8
9
package main;

import static java.lang.System.out;

public class Main {
public static void main(String[] args) {
out.println("Hello, world!"); // 直接使用静态方法 System.out.println
}
}

通过 import static,可以直接调用 System.out.println,使代码更加简洁。

访问修饰符

在 Java 中,访问修饰符(publicprotectedprivate 和 包级别权限)用于控制类、方法和字段的可访问性。这些修饰符的使用可以帮助我们定义类的外部接口、保护内部实现,并且控制代码的可见性和使用范围。

1. public

public 访问修饰符意味着这个类、方法或字段对所有其他类都是可见的,无论它们位于哪个包中。

应用场景

  • 类级别:如果你希望一个类在任何包中都可以被访问,则将其声明为 public
  • 方法和字段:通常用于定义类对外公开的 API。比如,如果某个方法或字段需要被类的外部调用或访问(例如工具类中的方法),它们通常会被声明为 public

示例:

1
2
3
4
5
6
7
8
9
package com.example;

public class Greeting {
public String message;

public void sayHello() {
System.out.println("Hello, " + message);
}
}

在这个例子中,Greeting 类以及它的 message 字段和 sayHello() 方法都是 public 的,因此任何其他类都可以创建 Greeting 类的实例,并调用 sayHello() 方法。

2. private

private 访问修饰符意味着这个方法或字段只能在它所在的类内部被访问,不能被任何其他类访问。

应用场景

  • 字段:通常用于保护类的内部状态,防止外部直接修改类的字段。
  • 方法:用于封装类的内部逻辑,只在类的内部使用,不希望被外部调用。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.example;

public class BankAccount {
private double balance;

public BankAccount(double initialBalance) {
this.balance = initialBalance;
}

public void deposit(double amount) {
if (amount > 0) {
this.balance += amount;
}
}

public double getBalance() {
return this.balance;
}
}

在这个例子中,balance 字段是 private 的,意味着它只能在 BankAccount 类的内部被访问。外部代码无法直接修改 balance,必须通过 deposit() 方法来更新余额。

3. protected

protected 访问修饰符意味着这个字段或方法可以被同一个包中的其他类访问,也可以被任何继承它的子类访问。

应用场景

  • 继承:如果你希望类的某些字段或方法只对子类可见,但不希望对外部完全公开,可以使用 protected

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example;

public class Animal {
protected String name;

protected void makeSound() {
System.out.println("Some generic animal sound");
}
}

class Dog extends Animal {
public void bark() {
this.name = "Dog";
makeSound(); // 可以调用父类的protected方法
System.out.println(name + " says: Woof!");
}
}

在这个例子中,name 字段和 makeSound() 方法是 protected 的,因此可以在 Animal 类的子类 Dog 中访问。

4. 包级别权限(无修饰符)

如果你不为类、方法或字段指定任何访问修饰符,它们将具有包级别权限(即 “default” 访问级别)。这种权限意味着它们只能被同一个包中的其他类访问。

应用场景

  • 内部实现:当你希望类或成员变量仅在包内部被访问,而不需要对包外部公开时,可以使用包级别权限。这在包内封装复杂逻辑,但仍允许包内其他类互相协作时非常有用。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example;

// 没有修饰符,意味着该类具有包级别权限
class PackageClass {
void showMessage() {
System.out.println("This is a package-private method");
}
}

public class Main {
public static void main(String[] args) {
PackageClass pc = new PackageClass();
pc.showMessage(); // 可以访问,因为在同一个包中
}
}

在这个例子中,PackageClass 类和 showMessage() 方法具有包级别权限,因此 Main 类可以访问它们,但 com.example 包之外的类无法访问。

5. final

final 修饰符可以用来修饰类、方法和字段,它有以下作用: - :表示该类不能被继承。 - 方法:表示该方法不能被子类重写。 - 字段:表示该字段的值在初始化后不能被修改(成为常量)。 - 局部变量:表示该变量的值在初始化后不能被修改。

应用场景

  • 不可变类final 类用于定义不可变的类,确保类的行为不会被子类改变。
  • 常量final 字段通常与 static 一起使用,用来定义常量。
  • 方法优化:将方法声明为 final 可以让编译器进行优化,因为它知道该方法不会被重写。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example;

public final class Constants {
public static final double PI = 3.14159;
}

class Circle {
private final double radius;

public Circle(double radius) {
this.radius = radius;
}

public double getArea() {
return Constants.PI * radius * radius;
}
}

在这个例子中,Constants 类是 final 的,表示它不能被继承。PI 字段是 static final 的,表示它是一个常量。在 Circle 类中,radius 字段也是 final 的,因此一旦被赋值就不能被更改。

最佳实践

  • 最小暴露原则:如果不确定是否需要 public,则尽量少使用 public,减少类和成员对外的暴露。
  • 封装内部实现:使用 private 来封装类的内部实现细节,保护类的内部状态。
  • 继承与可访问性:使用 protected 来定义类对继承的子类开放的 API,但不对外部完全公开。
  • 包内协作:使用包级别权限来限制访问权限在包内,帮助包内类之间的协作。

举例:

1. public 修饰符的应用

场景: 创建一个工具类,提供通用的字符串操作方法。

示例:

1
2
3
4
5
6
7
8
9
package com.utils;

public class StringUtils {
// 公开的方法,任何类都可以访问
public static String reverse(String input) {
StringBuilder sb = new StringBuilder(input);
return sb.reverse().toString();
}
}

应用场景说明: - StringUtils 类及其 reverse() 方法被声明为 public,这样任何包中的类都可以使用这个方法来反转字符串。 - 这是典型的工具类设计,工具类中的方法通常被声明为 public,因为它们需要对外提供广泛的功能。

2. private 修饰符的应用

场景: 在银行账户类中保护账户余额,防止外部直接修改。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.bank;

public class BankAccount {
// 私有字段,外部类无法直接访问或修改
private double balance;

public BankAccount(double initialBalance) {
this.balance = initialBalance;
}

// 公开的存款方法
public void deposit(double amount) {
if (amount > 0) {
this.balance += amount;
}
}

// 公开的取款方法
public void withdraw(double amount) {
if (amount > 0 && amount <= balance) {
this.balance -= amount;
}
}

// 公开的查询余额方法
public double getBalance() {
return this.balance;
}
}

应用场景说明: - balance 字段被声明为 private,保护了账户的内部状态,使得外部类不能直接修改余额,避免数据不一致的风险。 - 外部类只能通过 deposit()withdraw()getBalance() 方法来操作或查询余额,这样可以确保每次余额的变化都通过合法的业务逻辑进行。

3. protected 修饰符的应用

场景: 创建一个动物类,允许子类定义各自的叫声,但不允许外部直接调用。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package com.zoo;

public class Animal {
protected String name;

// 受保护的构造方法,防止直接实例化,但允许子类调用
protected Animal(String name) {
this.name = name;
}

// 受保护的方法,子类可以调用,但外部类不可以
protected void makeSound() {
System.out.println(name + " makes a sound");
}
}

class Dog extends Animal {
public Dog() {
super("Dog");
}

// 子类可以调用受保护的方法
@Override
protected void makeSound() {
System.out.println(name + " says: Woof!");
}
}

class Main {
public static void main(String[] args) {
Dog dog = new Dog();
dog.makeSound(); // 调用的是Dog类中的makeSound方法
}
}

应用场景说明: - Animal 类的构造方法和 makeSound() 方法被声明为 protected,这样 Animal 类无法被直接实例化,但可以被继承,并允许子类如 Dog 自定义 makeSound() 方法的行为。 - 外部类 Main 无法直接调用 Animal 类中的 makeSound() 方法,但可以通过 Dog 类的实例调用该方法。

4. 包级别权限(无修饰符)的应用

场景: 在一个包中创建多个类,某些类只在包内使用,不希望暴露给包外的类。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example;

// 包级别权限类,只能在com.example包内访问
class PackageClass {
void display() {
System.out.println("This is a package-private method");
}
}

public class Main {
public static void main(String[] args) {
PackageClass pc = new PackageClass();
pc.display(); // 在同一个包内,可以访问display方法
}
}

应用场景说明: - PackageClass 类及其 display() 方法都没有任何修饰符,因此它们具有包级别权限,表示它们只能在 com.example 包内被访问。 - 这种设计用于包内协作,允许包内的类互相访问和使用,但避免了包外部类的直接访问,保持包的封装性。

5. final 修饰符的应用

场景: 创建一个不可变的类,防止子类继承或修改类中的字段。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.constants;

// 定义一个不可变的类
public final class ImmutablePoint {
private final int x;
private final int y;

public ImmutablePoint(int x, int y) {
this.x = x;
this.y = y;
}

public int getX() {
return x;
}

public int getY() {
return y;
}
}

应用场景说明: - ImmutablePoint 类被声明为 final,表示它不能被继承,确保了类的不可变性。 - 类中的 xy 字段也被声明为 final,它们只能在构造函数中被赋值,并且之后不能再被修改,确保实例一旦创建就无法改变状态。

内部类

Java中的内部类是一种非常有用的结构,用于在类的内部定义其他类。内部类的主要类型包括普通内部类(Inner Class)、匿名类(Anonymous Class)、静态内部类(Static Nested Class)。

1. 普通内部类(Inner Class)

定义: 一个类定义在另一个类的内部,这个内部类与外部类有很强的关联。它能够访问外部类的所有成员,包括private成员。

特性: - 内部类的实例依附于外部类的实例,不能独立存在。 - 内部类可以直接访问外部类的private字段和方法。 - 编译后的内部类文件名通常为Outer$Inner.class

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Outer {
private String name;

Outer(String name) {
this.name = name;
}

class Inner {
void hello() {
System.out.println("Hello, " + Outer.this.name);
}
}
}

public class Main {
public static void main(String[] args) {
Outer outer = new Outer("Nested");
Outer.Inner inner = outer.new Inner(); // 必须通过outer.new创建Inner实例
inner.hello(); // 输出: Hello, Nested
}
}

在这个例子中,Inner类依赖于Outer类的实例,并且可以访问Outer类的私有字段name

2. 匿名类(Anonymous Class)

定义: 匿名类是在没有显式定义类名的情况下,在方法内部通过实例化一个接口或类时定义的类。通常用于简化代码,不需要重复创建单独的类文件。

特性: - 通常用来快速实现接口或继承类,特别是在需要简单实现的场景下。 - 可以访问外部类的private字段和方法。 - 编译后的匿名类文件通常命名为Outer$1.classOuter$2.class等。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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();
}
}

public class Main {
public static void main(String[] args) {
Outer outer = new Outer("Anonymous");
outer.asyncHello(); // 异步输出: Hello, Anonymous
}
}

在这个例子中,我们使用匿名类实现了Runnable接口,并通过线程执行run方法。在没有显式定义类名的情况下创建了一个实现Runnable的类。

1. 外部类 Outer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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();
}
}
  • name字段Outer类中定义了一个私有字段name,用于存储传入的名字。

  • 构造方法 Outer(String name):这个构造方法用于初始化Outer对象,并为name字段赋值。

  • asyncHello方法:这是Outer类中的一个方法,用于异步地输出一个问候语。这个方法展示了匿名类的使用。

2. 匿名类 Runnable

1
2
3
4
5
6
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Hello, " + Outer.this.name);
}
};
  • Runnable接口Runnable是一个接口,里面定义了一个方法run(),该方法在新线程中执行任务。

  • 匿名类实现Runnable:在asyncHello方法内部,我们创建了一个实现Runnable接口的匿名类。在这个匿名类中,重写了run()方法。run()方法中,我们使用了System.out.println()打印问候语,并且引用了Outer.this.name来访问外部类Outername字段。

3. 线程启动

1
new Thread(r).start();
  • 创建线程:我们将上面创建的匿名类实例r传入Thread的构造方法中,这样Thread对象就知道在新线程中执行rrun()方法。

  • 启动线程start()方法启动了新线程,并执行匿名类中的run()方法。在这个方法中,我们打印了Hello,加上Outer实例的name

4. 主类 Main

1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
Outer outer = new Outer("Anonymous");
outer.asyncHello(); // 异步输出: Hello, Anonymous
}
}
  • main方法:这是程序的入口。在main方法中,我们创建了Outer类的实例,并传入了字符串"Anonymous"

  • 调用asyncHello:我们调用了outer对象的asyncHello()方法,这将启动一个新线程,在新线程中执行打印操作。

5. 执行过程

  • asyncHello()被调用时,一个新线程被启动。这个新线程执行匿名类的run()方法,打印出Hello, Anonymous

  • 因为使用了新线程,打印操作是异步执行的,这意味着它可能会在main方法结束后才执行。这就是“异步输出”的含义。

3. 静态内部类(Static Nested Class)

定义: 静态内部类与普通内部类不同,它用static修饰,因此不依赖外部类的实例。它与外部类的实例没有直接关联,只能访问外部类的静态成员。

特性: - 可以直接实例化,而无需外部类的实例。 - 不能访问外部类的非静态成员,只能访问静态成员。 - 编译后的静态内部类文件通常命名为Outer$StaticNested.class

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Outer {
private static String NAME = "OUTER";

static class StaticNested {
void hello() {
System.out.println("Hello, " + Outer.NAME);
}
}
}

public class Main {
public static void main(String[] args) {
Outer.StaticNested nested = new Outer.StaticNested();
nested.hello(); // 输出: Hello, OUTER
}
}

在这个例子中,StaticNestedOuter的静态内部类,可以直接实例化并访问Outer的静态字段NAME

总结

  • 普通内部类:依附于外部类实例,能访问外部类的所有成员,适用于需要紧密耦合的类。
  • 匿名类:简化实现接口或继承类的代码,适用于简单、一次性的类定义。
  • 静态内部类:独立于外部类实例,适用于逻辑上关联但不依赖于外部类实例的类。

什么是classpath?

classpath 是 Java 虚拟机(JVM)使用的一个环境变量,用来指示 JVM 如何搜索所需的 .class 文件(Java 字节码文件)。它定义了 JVM 在运行 Java 程序时应该去哪些地方寻找类文件,以便正确加载和执行。

classpath的作用

由于 Java 源码文件(.java)在编译后生成 .class 文件,而 JVM 执行的是 .class 文件,因此 JVM 需要知道应该在哪里寻找这些文件。classpath 就是用来指定这些路径的。

如何设置classpath

classpath 可以通过两种方式设置:

  1. 在系统环境变量中设置(不推荐):这种方法会影响整个系统的所有 Java 程序,可能导致意外的问题。

  2. 在启动 JVM 时通过命令行设置(推荐):在启动 Java 程序时,通过传递 -classpath 或简写 -cp 参数来设置 classpath,这是一种更灵活和安全的方式。

    示例:

    1
    java -cp .;C:\work\project1\bin;C:\shared abc.xyz.Hello
    这个命令指定了 JVM 应该依次在当前目录 (.)、C:\work\project1\binC:\shared 中查找 abc.xyz.Hello 类。

classpath的默认值

如果没有显式设置 classpath,JVM 的默认 classpath 是当前目录 (.)。这意味着 JVM 会在当前目录下寻找类文件。

使用jar包

当项目中有很多 .class 文件时,将这些文件打包成一个 .jar 文件可以更方便地管理和分发。jar 文件本质上是一个压缩的 ZIP 文件,其中包含了目录结构和 .class 文件。我们可以通过将 .jar 文件包含在 classpath 中,让 JVM 在 .jar 文件中查找所需的类。

示例:

1
java -cp ./hello.jar abc.xyz.Hello
或者,如果 jar 包中包含了一个 META-INF/MANIFEST.MF 文件,并且这个文件指定了 Main-Class,那么可以直接通过以下命令运行:
1
java -jar hello.jar

  • classpath 是 JVM 用来查找 .class 文件的路径集合。
  • 推荐在启动 JVM 时使用 -cp 参数设置 classpath
  • jar 文件可以将多个 .class 文件打包,以便更方便地管理和运行 Java 应用程序。

Java Class 文件版本问题

在 Java 开发中,不同版本的 JDK 会生成不同版本的 .class 文件,而这些 .class 文件只有在相应或更高版本的 JVM 上才能运行。

Class 文件版本

每个 JDK 版本对应特定的 .class 文件版本。例如:

  • Java 8 对应 .class 文件版本 52
  • Java 11 对应 .class 文件版本 55
  • Java 17 对应 .class 文件版本 61

JVM 只能运行等于或低于其版本的 .class 文件。如果尝试在 Java 11 的 JVM 上运行 Java 17 编译的 .class 文件,会遇到 UnsupportedClassVersionError 错误。

兼容低版本 JVM

为了确保 .class 文件能在较低版本的 JVM 上运行,可以在编译时指定目标版本。例如,用 Java 17 编译 Java 11 兼容的 .class 文件:

1
javac --release 11 Main.java

这将确保编译出的 .class 文件兼容 Java 11 及更高版本的 JVM。

另一种方式是分别指定源码版本和目标 .class 版本:

1
javac --source 9 --target 11 Main.java

注意,--release--source--target 选项是互斥的,不能同时使用。

潜在问题

如果使用高版本 JDK 编译低版本 .class 文件,可能会遇到不兼容的问题。例如,使用 Java 17 编译一个使用了 Java 12 引入的 String.indent() 方法的代码,即使目标是 Java 11 的 .class 文件,在 Java 11 上运行时仍会出现 NoSuchMethodError

因此,使用 --release 参数会更安全,因为它在编译时会检查源码是否使用了目标版本不支持的特性。

多版本 JDK 环境

在开发环境中,可以同时安装多个版本的 JDK,并通过设置 JAVA_HOME 环境变量来切换当前使用的 JDK 版本。

源码版本

编写源代码时,源码版本决定了可以使用的 Java 语法特性。例如:

  • Lambda 表达式要求至少 Java 8 (--source 8)
  • var 关键字要求至少 Java 10 (--source 10)
  • Switch 表达式要求至少 Java 12 (--source 12)

有些新特性在发布前需要启用预览功能,例如 switch 表达式在 Java 12 和 13 版本中需要启用 --enable-preview 参数。

  • .class 文件版本与 JVM 版本紧密相关,使用 javac 编译时可以指定目标版本。
  • 使用 --release 参数是确保编译输出兼容低版本 JVM 的最佳实践。
  • 多版本 JDK 环境下,可以通过 JAVA_HOME 切换 JDK 版本,确保源码与目标 JVM 版本兼容。

模块

模块化是Java 9引入的重要特性,它主要是为了解决“依赖管理”问题。简单来说,模块是比jar更高级的打包形式,它不仅包含class文件,还明确了依赖关系,允许模块之间清晰地相互引用。

什么是模块?

在Java 9之前,Java程序使用jar文件来打包class文件,但jar文件不关心class之间的依赖关系。这可能导致在运行时遇到ClassNotFoundException的问题,因为某些依赖的jar文件可能被遗漏。模块引入后,除了将class文件打包外,还需定义模块的依赖关系。

模块的创建

模块的创建和传统的Java项目类似,只是在源代码目录中多了一个module-info.java文件,这个文件定义了模块名和依赖关系。例如:

1
2
3
4
module hello.world {
requires java.base; // 可省略,因为所有模块都会自动引入
requires java.xml; // 依赖的其他模块
}

在使用命令行工具时,我们可以使用javac编译项目,并使用jar命令生成jar包,接着使用jmod将jar转换成模块文件:

1
2
3
$ javac -d bin src/module-info.java src/com/itranswarp/sample/*.java
$ jar --create --file hello.jar --main-class com.itranswarp.sample.Main -C bin .
$ jmod create --class-path hello.jar hello.jmod

模块的运行和JRE打包

模块可以通过java --module-path命令运行,但.jmod文件不能直接执行,需要通过jar运行或用于打包JRE。

Java 9通过模块化将标准库的rt.jar拆分成多个模块,使得只需打包运行时真正用到的部分,减少了JRE的体积。我们可以使用jlink命令将应用和所需的模块打包为一个定制的JRE:

1
2
$ jlink --module-path hello.jmod --add-modules java.base,java.xml,hello.world --output jre/
$ jre/bin/java --module hello.world

访问权限

模块还增强了访问权限的控制。默认情况下,模块内的class只能被同一模块访问,如果希望外部访问某个class,必须在module-info.java中显式导出:

1
2
3
4
5
6
module hello.world {
exports com.itranswarp.sample; // 导出包以允许外部访问

requires java.base;
requires java.xml;
}

这进一步提高了代码的封装性和安全性。

例子概述

假设我们要开发一个简单的Java程序,该程序有两个模块:

  1. greeting.module:用于提供问候功能。
  2. main.module:使用greeting.module中的功能并输出问候语。

步骤 1:创建模块结构

首先,我们在项目目录中创建以下结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
myapp/
├── greeting.module/
│ ├── src/
│ │ ├── module-info.java
│ │ └── com/
│ │ └── example/
│ │ └── greeting/
│ │ └── Greeting.java
└── main.module/
├── src/
│ ├── module-info.java
│ └── com/
│ └── example/
│ └── main/
│ └── Main.java

步骤 2:编写模块代码

1. greeting.module 的代码

首先在greeting.module中定义一个简单的问候类。

greeting.module/src/com/example/greeting/Greeting.java

1
2
3
4
5
6
7
package com.example.greeting;

public class Greeting {
public String getGreetingMessage() {
return "Hello from Greeting Module!";
}
}

接着定义模块描述文件,声明该模块的导出包:

greeting.module/src/module-info.java

1
2
3
module greeting.module {
exports com.example.greeting;
}

2. main.module 的代码

然后在main.module中使用Greeting类。

main.module/src/com/example/main/Main.java

1
2
3
4
5
6
7
8
9
10
package com.example.main;

import com.example.greeting.Greeting;

public class Main {
public static void main(String[] args) {
Greeting greeting = new Greeting();
System.out.println(greeting.getGreetingMessage());
}
}

最后,定义main.module的模块描述文件,声明对greeting.module的依赖:

main.module/src/module-info.java

1
2
3
module main.module {
requires greeting.module;
}

步骤 3:编译并运行模块

1. 编译模块

我们需要分别编译两个模块。假设当前工作目录是myapp,可以执行以下命令:

编译greeting.module

1
javac -d greeting.module/bin greeting.module/src/module-info.java greeting.module/src/com/example/greeting/Greeting.java

编译main.module

1
javac --module-path greeting.module/bin -d main.module/bin main.module/src/module-info.java main.module/src/com/example/main/Main.java

2. 运行模块

编译完成后,可以通过以下命令运行main.module

1
java --module-path greeting.module/bin:main.module/bin --module main.module/com.example.main.Main

运行结果将输出:

1
Hello from Greeting Module!

解释

  1. 模块结构greeting.modulemain.module是两个独立的模块,它们各自有自己的module-info.java文件来定义模块名和依赖关系。
  2. 模块依赖main.module通过requires greeting.module;声明依赖greeting.module,因此可以使用其中导出的Greeting类。
  3. 访问控制greeting.module中的Greeting类被定义在导出的包中,因此可以被main.module访问。