Java 学习记录


前言

本文从一个 JS/TS 的开发者角度,记录 Java 学习过程中一些陌生/与 JS 类似但细节不一样的地方。

基本结构

每一个 .java 文件都是一个类,类名与文件名相同,例如针对 Person.java 文件,类名应该为 Person

1
2
3
class Person {
// ...
}

而对于其入口文件,则要求必须使用 public 修饰符。且必须包含一个名称固定为 main方法,作为程序的入口,这个方法需要由 public static void 修饰。

1
2
3
4
5
public class Person {
public static void main( String[] args ) {
// ...
}
}

一个 .java 文件中可以编写多个类,但是只能有一个 public 类,其他类不能使用 public 修饰符,

这些修饰符将在后面说明。

面向对象

一个 .java 文件内有且只能存在一个 public 类,且需要与文件同名

一个类默认情况下是自动继承 Object 类,Object 类是所有类的父类。

1
2
3
class Person extends Object {
// ...
}

方法

定义方法的语法是:

1
2
3
4
修饰符 方法返回类型 方法名( 方法参数列表 ) {
若干方法语句;
return 方法返回值;
}

例如:

1
2
3
4
5
class Person {
public void sayHello() {
System.out.println( "Hello" );
}
}

可变参数

可以通过 ... 修饰符来表示可变参数,这样可以接受任意个数的参数。方法内接收到的参数为一个数组

1
2
3
4
5
6
7
8
9
10
11
class Person {
public void sayHello( String... names ) {
for ( String name : names ) {
System.out.println( "Hello " + name );
}
}
}

// 调用
Person p = new Person();
p.sayHello( "Tom", "Jerry" );

方法重载

可以通过定义多个方法名相同,但参数列表不同的方法来实现方法重载。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person {
public void sayHello() {
System.out.println( "Hello" );
}
public void sayHello( String name ) {
System.out.println( "Hello " + name );
}
}

// 调用
Person p = new Person();
p.sayHello(); // Hello
p.sayHello( "Tom" ); // Hello Tom

构造函数

构造函数没有返回值,也不需要手动添加类型 void,起函数名应与类名相同。

1
2
3
public 类名( 方法参数列表 ) {
若干方法语句;
}

默认构造函数

若一个类没有显式定义构造函数,则编译器会默认添加一个构造函数

1
2
3
4
5
class Person {
// 默认添加
public Person() {
}
}

构造函数重载

Java 的构造函数允许存在多个,但是参数列表必须不同。这使得允许在创建实例对象时传入不同的参数来实现函数重载。

1
2
3
4
5
6
7
8
9
10
11
class Person {
public Person() {
System.out.println( "无参构造函数" );
}
public Person( String name ) {
System.out.println( "有参构造函数" );
}
}

Person p1 = new Person(); // 无参构造函数
Person p2 = new Person( "Tom" ); // 有参构造函数

继承

在发生类继承后,若子类没有显式定义构造函数,则会默认添加一个构造函数,并在其中调用父类的无参构造函数

1
2
3
4
5
6
class Student extends Person {
Public Student() {
// 默认添加
super();
}
}

阻止继承

当一个类被 final 修饰时,该类不再允许被继承

1
2
3
final class Person {
// ...
}

限制继承

Java 15 开始,允许使用 sealed 修饰 class,意为密封类,被修饰的类要求必须存在继承类。

使用 sealed 修饰后,可以通过 permits 来限制允许继承的子类名称

1
2
3
public sealed class Person permits Student, Teacher {
// ...
}

被指定允许继承的子类在继承时,必须由 non-sealedfinalsealed 修饰

1
2
3
public non-sealed class Student extends Person {
// ...
}

函数覆写

当子类中定义了一个与父类中名称相同参数列表相同返回值类型相同的方法时,称为函数覆写。

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
public void sayHello() {
System.out.println( "Hello" );
}
}

class Student extends Person {
@Override
public void sayHello() {
System.out.println( "Hello Student" );
}
}

对于注解 @Override,它是可选的,但是建议使用,因为它可以帮助我们检查是否真的覆写了父类中的方法。

若方法不希望被子类覆写,可以使用 final 修饰符

1
2
3
4
5
class Person {
public final void sayHello() {
System.out.println( "Hello" );
}
}

向上转型

如果一个类继承了另一个类,那么可以将子类的实例赋值给父类的引用变量,这种操作称为向上转型。

1
2
Person p = new Student();
Object o = new Student();

当然,如果使用了向上转型,那么只能调用父类中存在的方法,不能调用子类中独有的方法(若子类中存在对应的函数复写,实际调用的依然是子类的方法,详见多态

向下转型

向上转型是可以自动实现的,而向下转型则需要手动强制转换。只允许对使用向上转型的对象进行向下转型。

1
2
Person p = new Student();
Student s = (Student) p;

若一个实例对象本身就是使用父类构造函数创建的,则强制进行向下转型会得到一个 ClassCastException 异常。

1
2
Person p = new Person();
Student s = (Student) p; // ClassCastException

instanceof

为了避免强制向下转型时出现错误,可以使用 instanceof 运算符来判断一个对象是否是某个类的实例。

1
2
3
4
5
Person p1 = new Student();
Person p2 = new Person();

System.out.println( p1 instanceof Student ); // true
System.out.println( p2 instanceof Student ); // false

Java 14 之后,使用 instanceof 进行条件判断以后,可以直接转型为指定名称的变量,避免再次强制转型。

例如下面这个示例,使用 Object 声明的变量显然是无法使用 Person 类独有的 echo() 方法的。此时我们往往需要进行如下操作:

1
2
3
4
Object obj = new Student();
if ( obj instanceof Student ) {
( (Student) obj ).echo();
}

而借助 Java 14 的新特性,我们可以直接将 obj 转型为 Student 类型的新变量 stu

1
2
3
4
Object obj = new Student();
if ( obj instanceof Student stu ) {
stu.echo();
}

多态

针对函数覆写中的示例,我们有如下调用:

1
2
Person p = new Student();
p.sayHello(); // Hello Student

可以看到,虽然 p 变量的引用类型为 Person,但实际调用的却是实际类型 Student 中覆写过后的方法。

通过这一点可以得到结论:Java 的实例方法调用,是基于运行时的实际类型的动态调用,而非声明时的类型。而这个特性在 oop 中被称作“多态”

TS 中也有类似的实现方式

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
sayHello() {
console.log( "Hello" );
}
}
class Student extends Person {
sayHello() {
console.log( "Hello Student" );
}
}
const p: Person = new Student();
p.sayHello(); // Hello Student

静态属性与静态方法

这两个可以通过类名直接访问,这点是毋庸置疑的。但在 Java 中,实例对象同样可以访问静态属性和静态方法。

1
2
3
4
5
6
7
8
9
10
11
class Person {
static int age = 18;
static void sayHello() {
System.out.println( "Hello" );
}
}

// 调用
Person p = new Person();
System.out.println( p.age ); // 18
p.sayHello(); // Hello

因为当使用实例对象去访问静态属性时,Java 编译器实际上自行将其转换为了 类名.静态属性 的方式。

尽管可以这样,但是依然只推荐使用类名去访问静态属性和静态方法。

接口

如果一个抽象类中的所有方法都是抽象的,那么这个抽象类可以转而使用接口代替。

1
2
3
4
5
6
7
8
9
10
interface Person {
void sayHello();
}

class Student implements Person {
@Override
public void sayHello() {
System.out.println( "Hello Student" );
}
}

接口中所有的方法都是默认自带 public abstract 修饰符的,编写时不需要手动添加。

接口中的属性

而在接口中,是可以定义属性的,此时必须要提供初始值

1
2
3
interface Person {
int age = 18;
}

这些接口中的属性默认是 public static final 修饰的,不需要显式注明这些关键词。
因此在类继承接口后,类将会拥有这些静态属性,可以直接通过类名访问。

作用域

JAVA 中修饰作用域的关键字有:publicprotectedprivate

  • public:任何地方都可以访问,不同包的类也可以访问
  • protected:仅允许继承的子类访问
  • private:仅允许本类访问

而当什么都不修饰时,表示为包内作用域,只允许相同包的 class 访问。包的概念在下文将会提及。

Java 中定义了一种命名空间,称为包(package),这也是 Java 自己的模块化支持(对比 ESM)。

包的定义是在 Java 源文件的第一行,使用 package 关键字定义。

1
2
3
4
5
6
// Person.java
package com.example;

class Person {
// ...
}

这里我们定义了包为 com.example,但不止是这样就结束了,与文件名和类名的关系一样,包名也要严格遵守目录结构。
因此我们还必须把这个 .java 文件放在 com/example 目录下。

需要注意的是,反过来同样也是严格要求的,即出现在 com/example 目录下的 .java 文件,其必须通过 package 修饰符手动定义包名为 com.example
也就是说,包名和目录结构是互相对应的。

包没有父子关系,com.maricom.mari.doc 完全没有任何关系

包内作用域

com/example 目录下,我们可以继续定义其他的类,这些类都将会被归类到 com.example 这个包下,相互之间可以自由访问,这被称作为包内作用域

如下示例,同包下的两个类是可以互相访问的。

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

public sealed class Person permits Student {
void sayHello() {
System.out.println( "Hello" );
}
}

// com/example/Student.java
package com.example;

public non-sealed class Student extends Person {
@Override
void sayHello() {
System.out.println( "Hello Student" );
}
}

而没有使用 publicprotectedprivate 修饰的类、方法、属性,它们的作用域是包内的,只有在同一个包下的类才能访问。
也因为这个特性,同一个包内不可以定义两个相同名称的 class,哪怕没有使用 public 修饰。

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

public class Main {
public static void main( String[] args ) {
Student stu = new Student();
stu.sayHello(); // Hello
}
}

上面这个示例中,同包内的 Teacher 类可以访问 Student 类,且能够调用 Student 类内部未使用任何修饰符修饰的 sayHello() 方法。

跨包访问

当有时候我们需要使用不在本包作用于的 class,例如我们在 com.mari 包下的 Food 类中,希望访问 com.example 包下的 Student 类。

一种方法是直接写出完整类名:

1
2
3
4
5
6
7
package com.mari;

public class Main {
public static void main( String[] args ) {
com.example.Student stu = new com.example.Student();
}
}

可以看出,不同于同包下的直接使用 Student,这里需要写出完整的类名 com.example.Student。显然这种写法有些过于繁琐了。

因此我们可以使用另一种办法: 使用 import 关键字引入包名。

1
2
3
4
5
6
7
8
9
package com.mari;
// 引入完整包名
import com.example.Student;

public class Main {
public static void main( String[] args ) {
Student stu = new Student();
}
}

不过如果在两个不同的包内存在同名类,我们还都希望使用的话,只能对其他的使用完整类名了。

如果希望引入包内的所有内容,可以使用 * 通配符。

1
2
3
4
5
6
7
8
9
10
package com.mari;
// 引入包内所有内容
import com.example.*;

public class Main {
public static void main( String[] args ) {
Student stu = new Student();
Person p = new Person();
}
}

但通常情况下不建议使用这种写法,因为不够直观,在导入了多个包后无法看出使用的类来自哪个包。

在跨包的情况下,我们只能使用 public 修饰的类、方法、属性。针对 包内作用域 中例子,此时我们将无法访问没有添加任何修饰符的 sayHello() 方法。

1
2
3
4
5
6
7
8
9
10
package com.mari;
// 引入包内所有内容
import com.example.*;

public class Main {
public static void main( String[] args ) {
Student stu = new Student();
stu.sayHello(); // Error
}
}

编译器分析步骤

Java 编译器在遇到一个类名时,会按照以下模式进行分析:

  • 如果是完整类名,直接按路径查找
  • 如果是简单类名,则分为几个步骤依次查找:
    • 在当前包内查找
    • import 引入的包内查找
    • java.lang 包内查找

当按照这种模式全部无法找到目标类,则抛出错误。

为此我们可以看出 Java 编译器自动为我们实现了两个 import 动作:

1
2
import 当钱包.*;
import java.lang.*;