前言
本文从一个 JS/TS 的开发者角度,记录 Java 学习过程中一些陌生/与 JS 类似但细节不一样的地方。
基本结构
每一个 .java
文件都是一个类,类名与文件名相同,例如针对 Person.java
文件,类名应该为 Person
。
1 | class Person { |
而对于其入口文件,则要求必须使用 public
修饰符。且必须包含一个名称固定为 main
的方法,作为程序的入口,这个方法需要由 public static void
修饰。
1 | public class Person { |
一个 .java
文件中可以编写多个类,但是只能有一个 public
类,其他类不能使用 public
修饰符,
这些修饰符将在后面说明。
面向对象
一个 .java
文件内有且只能存在一个 public
类,且需要与文件同名
一个类默认情况下是自动继承 Object
类,Object
类是所有类的父类。
1 | class Person extends Object { |
方法
定义方法的语法是:
1 | 修饰符 方法返回类型 方法名( 方法参数列表 ) { |
例如:
1 | class Person { |
可变参数
可以通过 ...
修饰符来表示可变参数,这样可以接受任意个数的参数。方法内接收到的参数为一个数组
1 | class Person { |
方法重载
可以通过定义多个方法名相同,但参数列表不同的方法来实现方法重载。
1 | class Person { |
构造函数
构造函数没有返回值,也不需要手动添加类型 void
,起函数名应与类名相同。
1 | public 类名( 方法参数列表 ) { |
默认构造函数
若一个类没有显式定义构造函数,则编译器会默认添加一个构造函数
1 | class Person { |
构造函数重载
Java 的构造函数允许存在多个,但是参数列表必须不同。这使得允许在创建实例对象时传入不同的参数来实现函数重载。
1 | class Person { |
继承
在发生类继承后,若子类没有显式定义构造函数,则会默认添加一个构造函数,并在其中调用父类的无参构造函数。
1 | class Student extends Person { |
阻止继承
当一个类被 final
修饰时,该类不再允许被继承
1 | final class Person { |
限制继承
Java 15 开始,允许使用 sealed
修饰 class,意为密封类,被修饰的类要求必须存在继承类。
使用 sealed
修饰后,可以通过 permits
来限制允许继承的子类名称
1 | public sealed class Person permits Student, Teacher { |
被指定允许继承的子类在继承时,必须由 non-sealed
或 final
或 sealed
修饰
1 | public non-sealed class Student extends Person { |
函数覆写
当子类中定义了一个与父类中名称相同、参数列表相同、返回值类型相同的方法时,称为函数覆写。
1 | class Person { |
对于注解
@Override
,它是可选的,但是建议使用,因为它可以帮助我们检查是否真的覆写了父类中的方法。
若方法不希望被子类覆写,可以使用 final
修饰符
1 | class Person { |
向上转型
如果一个类继承了另一个类,那么可以将子类的实例赋值给父类的引用变量,这种操作称为向上转型。
1 | Person p = new Student(); |
当然,如果使用了向上转型,那么只能调用父类中存在的方法,不能调用子类中独有的方法(若子类中存在对应的函数复写,实际调用的依然是子类的方法,详见多态)
向下转型
向上转型是可以自动实现的,而向下转型则需要手动强制转换。只允许对使用向上转型的对象进行向下转型。
1 | Person p = new Student(); |
若一个实例对象本身就是使用父类构造函数创建的,则强制进行向下转型会得到一个 ClassCastException
异常。
1 | Person p = new Person(); |
instanceof
为了避免强制向下转型时出现错误,可以使用 instanceof
运算符来判断一个对象是否是某个类的实例。
1 | Person p1 = new Student(); |
Java 14 之后,使用 instanceof
进行条件判断以后,可以直接转型为指定名称的变量,避免再次强制转型。
例如下面这个示例,使用 Object 声明的变量显然是无法使用 Person 类独有的 echo()
方法的。此时我们往往需要进行如下操作:
1 | Object obj = new Student(); |
而借助 Java 14 的新特性,我们可以直接将 obj
转型为 Student
类型的新变量 stu
:
1 | Object obj = new Student(); |
多态
针对函数覆写中的示例,我们有如下调用:
1 | Person p = new 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 | class Person { |
因为当使用实例对象去访问静态属性时,Java 编译器实际上自行将其转换为了 类名.静态属性
的方式。
尽管可以这样,但是依然只推荐使用类名去访问静态属性和静态方法。
接口
如果一个抽象类中的所有方法都是抽象的,那么这个抽象类可以转而使用接口代替。
1 | interface Person { |
接口中所有的方法都是默认自带 public abstract
修饰符的,编写时不需要手动添加。
接口中的属性
而在接口中,是可以定义属性的,此时必须要提供初始值
1 | interface Person { |
这些接口中的属性默认是 public static final
修饰的,不需要显式注明这些关键词。
因此在类继承接口后,类将会拥有这些静态属性,可以直接通过类名访问。
作用域
JAVA 中修饰作用域的关键字有:public
、protected
、private
。
public
:任何地方都可以访问,不同包的类也可以访问protected
:仅允许继承的子类访问private
:仅允许本类访问
而当什么都不修饰时,表示为包内作用域,只允许相同包的 class 访问。包的概念在下文将会提及。
包
Java 中定义了一种命名空间,称为包(package
),这也是 Java 自己的模块化支持(对比 ESM)。
包的定义是在 Java 源文件的第一行,使用 package
关键字定义。
1 | // Person.java |
这里我们定义了包为 com.example
,但不止是这样就结束了,与文件名和类名的关系一样,包名也要严格遵守目录结构。
因此我们还必须把这个 .java
文件放在 com/example
目录下。
需要注意的是,反过来同样也是严格要求的,即出现在 com/example
目录下的 .java
文件,其必须通过 package
修饰符手动定义包名为 com.example
。
也就是说,包名和目录结构是互相对应的。
包没有父子关系,
com.mari
和com.mari.doc
完全没有任何关系
包内作用域
在 com/example
目录下,我们可以继续定义其他的类,这些类都将会被归类到 com.example
这个包下,相互之间可以自由访问,这被称作为包内作用域。
如下示例,同包下的两个类是可以互相访问的。
1 | // com/example/Person.java |
而没有使用 public
、protected
、private
修饰的类、方法、属性,它们的作用域是包内的,只有在同一个包下的类才能访问。
也因为这个特性,同一个包内不可以定义两个相同名称的 class,哪怕没有使用 public
修饰。
1 | // com/example/Main.java |
上面这个示例中,同包内的 Teacher
类可以访问 Student
类,且能够调用 Student
类内部未使用任何修饰符修饰的 sayHello()
方法。
跨包访问
当有时候我们需要使用不在本包作用于的 class,例如我们在 com.mari
包下的 Food
类中,希望访问 com.example
包下的 Student
类。
一种方法是直接写出完整类名:
1 | package com.mari; |
可以看出,不同于同包下的直接使用 Student
,这里需要写出完整的类名 com.example.Student
。显然这种写法有些过于繁琐了。
因此我们可以使用另一种办法: 使用 import
关键字引入包名。
1 | package com.mari; |
不过如果在两个不同的包内存在同名类,我们还都希望使用的话,只能对其他的使用完整类名了。
如果希望引入包内的所有内容,可以使用 *
通配符。
1 | package com.mari; |
但通常情况下不建议使用这种写法,因为不够直观,在导入了多个包后无法看出使用的类来自哪个包。
在跨包的情况下,我们只能使用 public
修饰的类、方法、属性。针对 包内作用域 中例子,此时我们将无法访问没有添加任何修饰符的 sayHello()
方法。
1 | package com.mari; |
编译器分析步骤
Java 编译器在遇到一个类名时,会按照以下模式进行分析:
- 如果是完整类名,直接按路径查找
- 如果是简单类名,则分为几个步骤依次查找:
- 在当前包内查找
- 在
import
引入的包内查找 - 在
java.lang
包内查找
当按照这种模式全部无法找到目标类,则抛出错误。
为此我们可以看出 Java 编译器自动为我们实现了两个 import 动作:
1 | import 当钱包.*; |