类与对象

类的定义与对象创建

前面我们介绍了什么是类,什么是对象,首先我们就来看看如何去定义一个类。

比如现在我们想要定义一个人类,我们可以右键src目录,点击创建新的类:

image-20220919204004526

我们在对类进行命名时,一般使用英文单词,并且首字母大写,跟变量命名一样,不能出现任何的特殊字符。

image-20220919204159248

可以看到,现在我们的目录下有了两个.java源文件,其中一个是默认创建的Main.java,还有一个是我们刚刚创建的类。

我们来看看创建好之后,一个类写了哪些内容:

1
2
3
public class Person {

}

可以发现,这不是跟一开始创建的Main中写的格式一模一样吗?没错,Main也是一个类,只不过我们一直都将其当做主类在使用,也就是编写主方法的类,关于方法我们会在后面进行介绍。

现在我们就创建好了一个类,既然是人类,那么肯定有人相关的一些属性,比如名字、性别、年龄等等,那么怎么才能给这个类添加一些属性呢?

我们可以将这些属性直接作为类的成员变量(成员变量相当于是这个类所具有的属性,每个实例创建出来之后,这些属性都可能会各不相同)定义到类中。

1
2
3
4
5
public class Person {   //这里定义的人类具有三个属性,名字、年龄、性别
String name; //直接在类中定义变量,表示类具有的属性
int age;
String sex;
}

可能会有小伙伴疑问,这些变量啥时候被赋值呢?实际上这些变量只有在一个具体的对象中才可以使用。

那么现在人类的属性都规定好了,我们就可以尝试创建一个实例对象了,实例对应的应该是一个具体的人:

1
new 类名();
1
2
3
4
public static void main(String[] args) {
new Person(); //我们可以使用new关键字来创建某个类的对象,注意new后面需要跟上 类名()
//这里创建出来的,就是一个具体的人了
}

实际上整个流程为:

image-20220919205550104

只不过这里仅仅是创建出了这样的一个对象,我们目前没有办法去操作这个对象,比如想要修改或是获取这个人的名字等等。

对象的使用

既然现在我们知道如何创建对象,那么我们怎么去访问这个对象呢,比如我现在想要去查看或是修改它的名字。

我们同样可以使用一个变量来指代某个对象,只不过引用类型的变量,存储的是对象的引用,而不是对象本身:

1
2
3
4
5
6
7
public static void main(String[] args) {
//这里的a存放的是具体的某个值
int a = 10;
//创建一个变量指代我们刚刚创建好的对象,变量的类型就是对应的类名
//这里的p存放的是对象的引用,而不是本体,我们可以通过对象的引用来间接操作对象
Person p = new Person();
}

至于为什么对象类型的变量存放的是对象的引用,比如:

1
2
3
4
public static void main(String[] args) {
Person p1 = new Person();
Person p2 = p1;
}

这里,我们将变量p2赋值为p1的值,那么实际上只是传递了对象的引用,而不是对象本身的复制,这跟我们前面的基本数据类型有些不同,p2和p1都指向的是同一个对象(如果你学习过C语言,它就类似于指针一样的存在)

image-20220919211443657

我们可以来测试一下:

1
2
3
4
5
public static void main(String[] args) {
Person p1 = new Person();
Person p2 = p1;
System.out.println(p1 == p2); //使用 == 可以判断两个变量引用的是不是同一个对象
}

但是如果我们像这样去编写:

1
2
3
4
5
public static void main(String[] args) {
Person p1 = new Person(); //这两个变量分别引用的是不同的两个对象
Person p2 = new Person();
System.out.println(p1 == p2); //如果两个变量存放的是不同对象的引用,那么肯定就是不一样的了
}

实际上我们之前使用的String类型,也是一个引用类型,我们会在下一章详细讨论。我们在上一章介绍的都是基本类型,而类使用的都是引用类型。

现在我们有了对象的引用之后,我们就可以进行操作了:

image-20220919210058797

我们可以直接访问对象的一些属性,也就是我们在类中定义好的那些,对于不同的对象,这些属性都具体存放值也会不同。

比如我们可以修改对象的名字:

1
2
3
4
5
public static void main(String[] args) {
Person p = new Person();
p.name = "小明"; //要访问对象的属性,我们需要使用 . 运算符
System.out.println(p.name); //直接打印对象的名字,就是我们刚刚修改好的结果了
}

注意,不同对象的属性是分开独立存放的,每个对象都有一个自己的空间,修改一个对象的属性并不会影响到其他对象:

1
2
3
4
5
6
7
public static void main(String[] args) {
Person p1 = new Person();
Person p2 = new Person();
p1.name = "小明"; //这个修改的是第一个对象的属性
p2.name = "大明"; //这里修改的是第二个对象的属性
System.out.println(p1.name); //这里我们获取的是第一个对象的属性
}

关于对象类型的变量,我们也可以不对任何对象进行引用:

1
2
3
public static void main(String[] args) {
Person p1 = null; //null是一个特殊的值,它表示空,也就是不引用任何的对象
}

注意,如果不引用任何的对象,那肯定是不应该去通过这个变量去操作所引用的对象的(都没有引用对象,我操作谁啊我)

虽然这样可以编译通过,但是在运行时会出现问题:

1
2
3
4
5
public static void main(String[] args) {
Person p = null; //此时变量没有引用任何对象
p.name = "小红"; //我任性,就是要操作
System.out.println(p.name);
}

我们来尝试运行一下这段代码:

image-20220919213732810

此时程序在运行的过程中,出现了异常,虽然我们还没有学习到异常,但是各位可以将异常理解为程序在运行过程中出现了问题,此时不得不终止程序退出。

这里出现的是空指针异常,很明显是因为我们去操作一个值为null的变量导致的。在我们以后的学习中,这个异常是出现频率最高的。

我们来看最后一个问题,对象创建成功之后,它的属性没有进行赋值,但是我们前面说了,变量使用之前需要先赋值,那么创建对象之后能否直接访问呢?

1
2
3
4
5
6
public static void main(String[] args) {
Person p = new Person();
System.out.println("name = "+p.name);
System.out.println("age = "+p.age);
System.out.println("sex = "+p.sex);
}

我们来看看运行结果:

image-20220919214248053

我们可以看到,如果直接创建对象,那么对象的属性都会存在初始值,如果是基本类型,那么默认是统一为0(如果是boolean的话,默认值为false)如果是引用类型,那么默认是null

方法创建与使用

前面我们介绍了类的定义以及对象的创建和使用。

现在我们的类有了属性,我们可以为创建的这些对象设定不同的属性值,比如每个人的名字都不一样,性别不一样,年龄不一样等等。只不过光有属性还不行,对象还需要具有一定的行为,就像我们人可以行走,可以跳跃,可以思考一样。

而对象也可以做出一些行为,我们可以通过定义方法来实现(在C语言中叫做函数)

方法是语句的集合,是为了完成某件事情而存在的。完成某件事情,可以有结果,也可以做了就做了,不返回结果。比如计算两个数字的和,我们需要得到计算后的结果,所以说方法需要有返回值;又比如,我们只想吧数字打印在控制台,只需要打印就行,不用给我结果,所以说方法不需要有返回值。

方法的定义如下:

1
2
3
返回值类型 方法名称() {
方法体...
}

首先是返回值类型,也就是说这个方法完成任务之后,得到的结果的数据类型(可以是基本类型,也可以是引用类型)当然,如果没有返回值,只是完成任务,那么可以使用void表示没有返回值,比如我们现在给人类编写一个自我介绍的行为:

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

//自我介绍只需要完成就行,没有返回值,所以说使用void
void hello(){
//完成自我介绍需要执行的所有代码就在这个花括号中编写
//这里编写代码跟我们之前在main中是一样的(实际上main就是一个函数)
//自我介绍需要用到当前对象的名字和年龄,我们直接使用成员变量即可,变量的值就是当前对象的存放值
System.out.println("我叫 "+name+" 今年 "+age+" 岁了!");
}
}

注意,方法名称同样可以随便起,但是规则跟变量的命名差不多,也是尽量使用小写字母开头的单词,如果是多个单词,一般使用驼峰命名法最规范。

image-20220920101033325

现在我们给人类定义好了一个方法(行为)那么怎么才能让对象执行这个行为呢?

1
2
3
4
5
6
public static void main(String[] args) {
Person p = new Person();
p.name = "小明";
p.age = 18;
p.hello(); //我们只需要使用 . 运算符,就可以执行定义好的方法了,只需要 .方法名称() 即可
}

像这样执行定义好的方法,我们一般称为方法的调用,我们来看看效果:

image-20220919220837991

比如现在我们要让人类学会加法运算,我们也可以通过定义一个方法的形式来完成,只不过,要完成加法运算,我们需要别人给人类提供两个参与加法运算的值才可以,所以我们这里就要用到参数了:

1
2
3
4
5
6
//我们的方法需要别人提供参与运算的值才可以
//我们可以为方法设定参数,在调用方法时,需要外部传入参数才可以
//参数的定义需要在小括号内部编写,类似于变量定义,需要填写 类型和参数名称,多个参数用逗号隔开
int sum(int a, int b){ //这里需要两个int类型的参数进行计算

}

那么现在参数从外部传入之后,我们怎么使用呢?

1
2
3
int sum(int a, int b){   //这里的参数,相当于我们在函数中定义了两个局部变量,我们可以直接在方法中使用
int c = a + b; //直接c = a + b
}

那么现在计算完成了,我们该怎么将结果传递到外面呢?首先函数的返回值是int类型,我们只需要使用return关键字来返回一个int类型的结果就可以了:

1
2
3
4
5
int sum(int a, int b){
int c = a + b;
return c; //return后面紧跟需要返回的结果,这样就可以将计算结果丢出去了
//带返回值的方法,是一定要有一个返回结果的!否则无法通过编译!
}

我们来测试一下吧:

1
2
3
4
5
6
7
public static void main(String[] args) {
Person p = new Person();
p.name = "小明";
p.age = 18;
int result = p.sum(10, 20); //现在我们要让这个对象帮我们计算10 + 20的结果
System.out.println(result); //成功得到30,实际上这里的println也是在调用方法进行打印操作
}

注意: 方法定义时编写的参数,我们一般称为形式参数,而调用方法实际传入的参数,我们成为实际参数。

是不是越来越感觉我们真的在跟一个对象进行交互?只要各位有了这样的体验,基本上就已经摸到面向对象的门路了。

关于return关键字,我们还需要进行进一步的介绍。

在我们使用return关键字之后,方法就会直接结束并返回结果,所以说在这之后编写的任何代码,都是不可到达的:

image-20220919222813469

return后编写代码,会导致编译不通过,因为存在不可达语句。

如果我们的程序中出现了分支语句,那么必须保证每一个分支都有返回值才可以:

image-20220919223037197

只要有任何一个分支缺少了return语句,都无法正常通过编译,总之就是必须考虑到所有的情况,任何情况下都必须要有返回值。

当然,如果方法没有返回值,我们也可以使用return语句,不需要跟上任何内容,只不过这种情况下使用,仅仅是为了快速结束方法的执行:

1
2
3
4
void test(int a){
if(a == 10) return; //当a等于10时直接结束方法,后面无论有没有代码都不会执行了
System.out.println("Hello World!"); //不是的情况就正常执行
}

最后我们来讨论一下参数的传递问题:

1
2
3
void test(int a){   //我们可以设置参数来让外部的数据传入到函数内部
System.out.println(a);
}

实际上参数的传递,会在调用方法的时候,对参数的值进行复制,方法中的参数变量,不是我们传入的变量本身,我们来下面的这个例子:

1
2
3
4
5
void swap(int a, int b){   //这个函数的目的很明显,就是为了交换a和b的值
int tmp = a;
a = b;
b = a;
}

那么我们来测试一下:

1
2
3
4
5
6
public static void main(String[] args) {
Person p = new Person();
int a = 5, b = 9; //外面也叫a和b
p.swap(a, b);
System.out.println("a = "+a+", b = "+b); //最后的结果会变成什么样子呢?
}

我们来看看结果是什么:

image-20220919224219071

我们发现a和b的值并没有发生交换,但是按照我们的方法逻辑来说,应该是会交换才对,这是为什么呢?实际上这里仅仅是将值复制给了函数里面的变量而已(相当于是变量的赋值)

image-20220919224623727

所以说我们交换的仅仅是方法中的a和b,参数传递仅仅是值传递,我们是没有办法直接操作到外面的a和b的。

那么各位小伙伴看看下面的例子:

1
2
3
void modify(Person person){
person.name = "lbwnb"; //修改对象的名称
}
1
2
3
4
5
6
public static void main(String[] args) {
Person p = new Person();
p.name = "小明"; //先在外面修改一次
p.modify(p); //调用方法再修改一次
System.out.println(p.name); //请问最后name会是什么?
}

我们来看看结果:

image-20220919224957971

不对啊,前面不是说只是值传递吗,怎么这里又可以修改成功呢?

确实,这里同样是进行的值传递,只不过各位小伙伴别忘了,我们前面可是说的清清楚楚,引用类型的变量,仅仅存放的是对象的引用,而不是对象本身。那么这里进行了值传递,相当于将对象的引用复制到了方法内部的变量中,而这个内部的变量,依然是引用的同一个对象,所以说这里在方法内操作,相当于直接操作外面的定义对象。

image-20220919225455752

方法进阶使用

有时候我们的方法中可能会出现一些与成员变量重名的变量:

1
2
3
4
//我们希望使用这个方法,来为当前对象设定名字
void setName(String name) {

}

此时类中定义的变量名称也是name,那么我们是否可以这样编写呢:

1
2
3
void setName(String name) {
name = name; //出现重名时,优先使用作用域最接近的,这里实际上是将方法参数的局部变量name赋值为本身
}

我们来测试一下:

1
2
3
4
5
public static void main(String[] args) {
Person p = new Person();
p.setName("小明");
System.out.println(p.name);
}

我们发现,似乎这样做并没有任何的效果,name依然是没有修改的状态。那么当出现重名的时候,因为默认情况下会优先使用作用域最近的变量,我们怎么才能表示要使用的变量是类的成员变量呢?

1
2
Person p = new Person();
p.name = "小明"; //我们之前在外面使用时,可以直接通过对象.属性的形式访问到

同样的,我们如果想要在方法中访问到当前对象的属性,那么可以使用this关键字,来明确表示当前类的示例对象本身:

1
2
3
void setName(String name) {
this.name = name; //让当前对象的name变量值等于参数传入的值
}

这样就可以修改成功了,当然,如果方法内没有变量出现重名的情况,那么默认情况下可以不使用this关键字来明确表示当前对象:

1
2
3
String getName() {
return name; //这里没有使用this,但是当前作用域下只有对象属性的name变量,所以说直接就使用了
}

我们接着来看方法的重载。

有些时候,参数类型可能会多种多样,我们的方法需要能够同时应对多种情况:

1
2
3
int sum(int a, int b){
return a + b;
}
1
2
3
4
public static void main(String[] args) {
Person p = new Person();
System.out.println(p.sum(10, 20)); //这里可以正常计算两个整数的和
}

但是要是我们现在不仅要让人类会计算整数,还要会计算小数呢?

image-20220920102347110

当我们使用小数时,可以看到,参数要求的是int类型,那么肯定会出现错误,这个方法只能用于计算整数。此时,为了让这个方法支持使用小数进行计算,我们可以将这个方法进行重载。

一个类中可以包含多个同名的方法,但是需要的形式参数不一样,方法的返回类型,可以相同,也可以不同,但是仅返回类型不同,是不允许的!

1
2
3
4
5
6
7
int sum(int a, int b){
return a + b;
}

double sum(double a, double b){ //为了支持小数加法,我们可以进行一次重载
return a + b;
}

这样就可以正常使用了:

1
2
3
4
5
public static void main(String[] args) {
Person p = new Person();
//当方法出现多个重载的情况,在调用时会自动进行匹配,选择合适的方法进行调用
System.out.println(p.sum(1.5, 2.2));
}

包括我们之前一直在使用的println方法,其实也是重载了很多次的,因为要支持各种值的打印。

注意,如果仅仅是返回值的不同,是不支持重载的:

image-20220920102933047

当然,方法之间是可以相互调用的:

1
2
3
4
5
6
7
void test(){
System.out.println("我是test"); //实际上这里也是调用另一个方法
}

void say(){
test(); //在一个方法内调用另一个方法
}

如果我们这样写的话:

1
2
3
4
5
6
7
void test(){
say();
}

void say(){
test();
}

各位猜猜看会出现什么情况?

image-20220921001914601

此时又出现了一个我们不认识的异常,实际上什么原因导致的我们自己都很清楚,方法之间一直在相互调用,没有一个出口。

方法自己也可以调用自己:

1
2
3
void test(){
test();
}

像这样自己调用自己的行为,我们称为递归调用,如果直接这样编写,会跟上面一样,出现栈溢出错误。但是如果我们给其合理地设置出口,就不会出现这种问题,比如我们想要计算从1加到n的和:

1
2
3
4
int test(int n){
if(n == 0) return 0;
return test(n - 1) + n; //返回的结果是下一层返回的结果+当前这一层的n
}

是不是感觉很巧妙?实际上递归调用在很多情况下能够快速解决一些很麻烦的问题,我们会在后面继续了解。

构造方法

我们接着来看一种比较特殊的方法,构造方法。

我们前面创建对象,都是直接使用new关键字就能直接搞定了,但是我们发现,对象在创建之后,各种属性都是默认值,那么能否实现在对象创建时就为其指定名字、年龄、性别呢?要在对象创建时进行处理,我们可以使用构造方法(构造器)来完成。

实际上每个类都有一个默认的构造方法,我们可以来看看反编译的结果:

1
2
3
4
5
6
7
8
public class Person {
String name;
int age;
String sex;

public Person() { //反编译中,多出来了这样一个方法,这其实就是构造方法
}
}

构造方法不需要填写返回值,并且方法名称与类名相同,默认情况下每个类都会自带一个没有任何参数的无参构造方法(只是不用我们去写,编译出来就自带)当然,我们也可以手动声明,对其进行修改:

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

Person(){ //构造方法不需要指定返回值,并且方法名称与类名相同
name = "小明"; //构造方法会在对象创建时执行,我们可以将各种需要初始化的操作都在这里进行处理
age = 18;
sex = "男";
}
}

构造方法会在new的时候自动执行:

1
2
3
4
public static void main(String[] args) {
Person p = new Person(); //这里的new Person()其实就是在调用无参构造方法
System.out.println(p.name);
}

当然,我们也可以为构造方法设定参数:

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

Person(String name, int age, String sex){ //跟普通方法是一样的
this.name = name;
this.age = age;
this.sex = sex;
}
}

注意,在我们自己定义一个构造方法之后,会覆盖掉默认的那一个无参构造方法,除非我们手动重载一个无参构造,否则要创建这个类的对象,必须调用我们自己定义的构造方法:

1
2
3
4
public static void main(String[] args) {
Person p = new Person("小明", 18, "男"); //调用自己定义的带三个参数的构造方法
System.out.println(p.name);
}

我们可以去看看反编译的结果,会发现此时没有无参构造了,而是只剩下我们自己编写的。

当然,要给成员变量设定初始值,我们不仅可以通过构造方法,也可以直接在定义时赋值:

1
2
3
4
5
public class Person {
String name = "未知"; //直接赋值,那么对象构造好之后,属性默认就是这个值
int age = 10;
String sex = "男";
}

这里需要特别注意,成员变量的初始化,并不是在构造方法之前之后,而是在这之前就已经完成了:

1
2
3
4
5
6
Person(String name, int age, String sex){
System.out.println(age); //在赋值之前看看是否有初始值
this.name = name;
this.age = age;
this.sex = sex;
}

我们也可以在类中添加代码块,代码块同样会在对象构造之前进行,在成员变量初始化之后执行:

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

{
System.out.println("我是代码块"); //代码块中的内容会在对象创建时仅执行一次
}

Person(String name, int age, String sex){
System.out.println("我被构造了");
this.name = name;
this.age = age;
this.sex = sex;
}
}

只不过一般情况下使用代码块的频率比较低,标准情况下还是通过构造方法进行进行对象初始化工作,所以说这里做了解就行了。

静态变量和静态方法

前面我们已经了解了类的大部分特性,一个类可以具有多种属性、行为,包括对象该如何创建,我们可以通过构造方法进行设定,我们可以通过类创建对象,每个对象都会具有我们在类中设定好的属性,包括我们设定好的行为,所以说类就像是一个模板,我们可以通过这个模板快速捏造出一个又一个的对象。我们接着来看比较特殊的静态特性。

静态的内容,我们可以理解为是属于这个类的,也可以理解为是所有对象共享的内容。我们通过使用static关键字来声明一个变量或一个方法为静态的,一旦被声明为静态,那么通过这个类创建的所有对象,操作的都是同一个目标,也就是说,对象再多,也只有这一个静态的变量或方法。一个对象改变了静态变量的值,那么其他的对象读取的就是被改变的值。

1
2
3
4
5
6
public class Person {
String name;
int age;
String sex;
static String info; //这里我们定义一个info静态变量
}

我们来测试一下:

1
2
3
4
5
6
public static void main(String[] args) {
Person p1 = new Person();
Person p2 = new Person();
p1.info = "杰哥你干嘛";
System.out.println(p2.info); //可以看到,由于静态属性是属于类的,因此无论通过什么方式改变,都改变的是同一个目标
}

所以说一般情况下,我们并不会通过一个具体的对象去修改和使用静态属性,而是通过这个类去使用:

1
2
3
4
public static void main(String[] args) {
Person.info = "让我看看";
System.out.println(Person.info);
}

同样的,我们可以将方法标记为静态:

1
2
3
static void test(){
System.out.println("我是静态方法");
}

静态方法同样是属于类的,而不是具体的某个对象,所以说,就像下面这样:

image-20220920234401275

因为静态方法属于类的,所以说我们在静态方法中,无法获取成员变量的值:

image-20220920235418115

成员变量是某个具体对象拥有的属性,就像小明这个具体的人的名字才叫小明,而静态方法是类具有的,并不是具体对象的,肯定是没办法访问到的。同样的,在静态方法中,无法使用this关键字,因为this关键字代表的是当前的对象本身。

但是静态方法是可以访问到静态变量的:

1
2
3
4
5
static String info;

static void test(){
System.out.println("静态变量的值为:"+info);
}

因为他们都属于类,所以说肯定是可以访问到的。

我们也可以将代码块变成静态的:

1
2
3
4
5
static String info;

static { //静态代码块可以用于初始化静态变量
info = "测试";
}

那么,静态变量,是在什么时候进行初始化的呢?

我们在一开始介绍了,我们实际上是将.class文件丢给JVM去执行的,而每一个.class文件其实就是我们编写的一个类,我们在Java中使用一个类之前,JVM并不会在一开始就去加载它,而是在需要时才会去加载(优化)一般遇到以下情况时才会会加载类:

  • 访问类的静态变量,或者为静态变量赋值
  • new 创建类的实例(隐式加载)
  • 调用类的静态方法
  • 子类初始化时
  • 其他的情况会在讲到反射时介绍

所有被标记为静态的内容,会在类刚加载的时候就分配,而不是在对象创建的时候分配,所以说静态内容一定会在第一个对象初始化之前完成加载。

我们可以来测试一下:

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
public class Person {
String name = test(); //这里我们用test方法的返回值作为变量的初始值,便于观察
int age;
String sex;

{
System.out.println("我是普通代码块");
}

Person(){
System.out.println("我是构造方法");
}

String test(){
System.out.println("我是成员变量初始化");
return "小明";
}

static String info = init(); //这里我们用init静态方法的返回值作为变量的初始值,便于观察

static {
System.out.println("我是静态代码块");
}

static String init(){
System.out.println("我是静态变量初始化");
return "test";
}
}

现在我们在主方法中创建一个对象,观察这几步是怎么在执行的:

image-20220921000953525

可以看到,确实是静态内容在对象构造之前的就完成了初始化,实际上就是类初始化时完成的。

当然,如果我们直接访问类的静态变量:

1
2
3
public static void main(String[] args) {
System.out.println(Person.info);
}

那么此时同样会使得类初始化,进行加载:

image-20220921001222465

可以看到,在使用时,确实是先将静态内容初始化之后,才得到值的。当然,如果我们压根就没有去使用这个类,那么也不会被初始化了。

有关类与对象的基本内容,我们就全部讲解完毕了。


包和访问控制

通过前面的学习,我们知道该如何创建和使用类。

包声明和导入

包其实就是用来区分类位置的东西,也可以用来将我们的类进行分类(类似于C++中的namespace)随着我们的程序不断变大,可能会创建各种各样的类,他们可能会做不同的事情,那么这些类如果都放在一起的话,有点混乱,我们可以通过包的形式将这些类进行分类存放。

包的命名规则同样是英文和数字的组合,最好是一个域名的格式,比如我们经常访问的www.baidu.com,后面的baidu.com就是域名,我们的包就可以命名为com.baidu,当然,各位小伙伴现在还没有自己的域名,所以说我们随便起一个名称就可以了。其中的.就是用于分割的,对应多个文件夹,比如com.test

image-20220921120040350

我们可以将类放入到包中:

image-20220921115055000

我们之前都是直接创建的类,所以说没有包这个概念,但是现在,我们将类放到包中,就需要注意了:

1
2
3
4
5
6
7
package com.test;   //在放入包中,需要在类的最上面添加package关键字来指明当前类所处的包

public class Main { //将Main类放到com.test这个包中
public static void main(String[] args) {

}
}

这里又是一个新的关键字package,这个是用于指定当前类所处的包的,注意,所处的包和对应的目录是一一对应的。

不同的类可以放在不同的包下:

image-20220921120241184

当我们使用同一个包中的类时,直接使用即可(之前就是直接使用的,因为都直接在一个缺省的包中)而当我们需要使用其他包中的类时,需要先进行导入才可以:

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

import com.test.entity.Person; //使用import关键字导入其他包中的类

public class Main {
public static void main(String[] args) {
Person person = new Person(); //只有导入之后才可以使用,否则编译器不知道这个类从哪来的
}
}

这里使用了import关键字导入我们需要使用的类,当然,只有在类不在同一个包下时才需要进行导入,如果一个包中有多个类,我们可以使用*表示导入这个包中全部的类:

1
import com.test.entity.*;

实际上我们之前一直在使用的System类,也是在一个包中的:

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
package java.lang;

import java.io.*;
import java.lang.reflect.Executable;
import java.lang.annotation.Annotation;
import java.security.AccessControlContext;
import java.util.Properties;
import java.util.PropertyPermission;
import java.util.StringTokenizer;
import java.util.Map;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.security.AllPermission;
import java.nio.channels.Channel;
import java.nio.channels.spi.SelectorProvider;
import sun.nio.ch.Interruptible;
import sun.reflect.CallerSensitive;
import sun.reflect.Reflection;
import sun.security.util.SecurityConstants;
import sun.reflect.annotation.AnnotationType;

import jdk.internal.util.StaticProperty;

/**
* The <code>System</code> class contains several useful class fields
* and methods. It cannot be instantiated.
*
* <p>Among the facilities provided by the <code>System</code> class
* are standard input, standard output, and error output streams;
* access to externally defined properties and environment
* variables; a means of loading files and libraries; and a utility
* method for quickly copying a portion of an array.
*
* @author unascribed
* @since JDK1.0
*/
public final class System {
...
}

可以看到它是属于java.lang这个包下的类,并且这个类也导入了很多其他包中的类在进行使用。那么,为什么我们在使用这个类时,没有导入呢?实际上Java中会默认导入java.lang这个包下的所有类,因此我们不需要手动指定。

IDEA非常智能,我们在使用项目中定义的类时,会自动帮我们将导入补全,所以说代码写起来非常高效。

注意,在不同包下的类,即使类名相同,也是不同的两个类:

1
2
3
4
package com.test.entity;

public class String { //我们在自己的包中也建一个名为String的类
}

当我们在使用时:

![image-20220921121404900](/Users/nagocoler/Library/Application Support/typora-user-images/image-20220921121404900.png)

由于默认导入了系统自带的String类,并且也导入了我们自己定义的String类,那么此时就出现了歧义,编译器不知道到底我们想用的是哪一个String类,所以说我们需要明确指定:

1
2
3
4
5
public class Main {
public static void main(java.lang.String[] args) { //主方法的String参数是java.lang包下的,我们需要明确指定一下,只需要在类名前面添加包名就行了
com.test.entity.String string = new com.test.entity.String();
}
}

我们只需要在类名前面把完整的包名也给写上,就可以表示这个是哪一个包里的类了,当然,如果没有出现歧义,默认情况下包名是可以省略的,可写可不写。

可能各位小伙伴会发现一个问题,为什么对象的属性访问不了了?

image-20220921122514457

编译器说name属性在这个类中不是public,无法在外部进行访问,这是什么情况呢?这里我们就要介绍的到Java的访问权限控制了。

访问权限控制

实际上Java中是有访问权限控制的,就是我们个人的隐私的一样,我不允许别人随便来查看我们的隐私,只有我们自己同意的情况下,才能告诉别人我们的名字、年龄等隐私信息。

所以说Java中引入了访问权限控制(可见性),我们可以为成员变量、成员方法、静态变量、静态方法甚至是类指定访问权限,不同的访问权限,有着不同程度的访问限制:

  • private - 私有,标记为私有的内容无法被除当前类以外的任何位置访问。
  • 什么都不写 - 默认,默认情况下,只能被类本身和同包中的其他类访问。
  • protected - 受保护,标记为受保护的内容可以能被类本身和同包中的其他类访问,也可以被子类访问(子类我们会在下一章介绍)
  • public - 公共,标记为公共的内容,允许在任何地方被访问。

这四种访问权限,总结如下表:

当前类同一个包下的类不同包下的子类不同包下的类
public
protected
默认
private

比如我们刚刚出现的情况,就是因为是默认的访问权限,所以说在当前包以外的其他包中无法访问,但是我们可以提升它的访问权限,来使得外部也可以访问:

1
2
3
4
5
public class Person {
public String name; //在name变量前添加public关键字,将其可见性提升为公共等级
int age;
String sex;
}

这样我们就可以在外部正常使用这个属性了:

1
2
3
4
public static void main(String[] args) {
Person person = new Person();
System.out.println(person.name); //正常访问到成员变量
}

实际上如果各位小伙伴观察仔细的话,会发现我们创建出来的类自带的访问等级就是public

1
2
3
4
5
package com.test.entity;

public class Person { //class前面有public关键字

}

也就是说这个类实际上可以在任何地方使用,但是我们也可以将其修改为默认的访问等级:

1
2
3
4
5
package com.test.entity;

class Person { //去掉public变成默认等级

}

如果是默认等级的话,那么在外部同样是无法访问的:

image-20220921142724239

但是注意,我们创建的普通类不能是protected或是private权限,因为我们目前所使用的普通类要么就是只给当前的包内使用,要么就是给外面都用,如果是private谁都不能用,那这个类定义出来干嘛呢?

如果某个类中存在静态方法或是静态变量,那么我们可以通过静态导入的方式将其中的静态方法或是静态变量直接导入使用,但是同样需要有访问权限的情况下才可以:

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

public static void test(){
System.out.println("我是静态方法!");
}
}

我们来尝试一下静态导入:

1
2
3
4
5
6
7
import static com.test.entity.Person.test;    //静态导入test方法

public class Main {
public static void main(String[] args) {
test(); //直接使用就可以,就像在这个类定义的方法一样
}
}

至此,有关包相关的内容,我们就讲解到这里。


封装、继承和多态

封装、继承和多态是面向对象编程的三大特性。

封装,把对象的属性和方法结合成一个独立的整体,隐藏实现细节,并提供对外访问的接口。

继承,从已知的一个类中派生出一个新的类,叫子类。子类实现了父类所有非私有化的属性和方法,并根据实际需求扩展出新的行为。

多态,多个不同的对象对同一消息作出响应,同一消息根据不同的对象而采用各种不同的方法。

正是这三大特性,让我们的Java程序更加生动形象。

类的封装

封装的目的是为了保证变量的安全性,使用者不必在意具体实现细节,而只是通过外部接口即可访问类的成员,如果不进行封装,类中的实例变量可以直接查看和修改,可能给整个代码带来不好的影响,因此在编写类时一般将成员变量私有化,外部类需要使用Getter和Setter方法来查看和设置变量。

我们可以将之前的类进行改进:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Person {
private String name; //现在类的属性只能被自己直接访问
private int age;
private String sex;

public Person(String name, int age, String sex) { //构造方法也要声明为公共,否则对象都构造不了
this.name = name;
this.age = age;
this.sex = sex;
}

public String getName() {
return name; //想要知道这个对象的名字,必须通过getName()方法来获取,并且得到的只是名字值,外部无法修改
}

public String getSex() {
return sex;
}

public int getAge() {
return age;
}
}

我们可以来试一下:

1
2
3
4
public static void main(String[] args) {
Person person = new Person("小明", 18, "男");
System.out.println(person.getName()); //只能通过调用getName()方法来获取名字
}

也就是说,外部现在只能通过调用我定义的方法来获取成员属性,而我们可以在这个方法中进行一些额外的操作,比如小明可以修改名字,但是名字中不能包含"小"这个字:

1
2
3
4
public void setName(String name) {
if(name.contains("小")) return;
this.name = name;
}

我们甚至还可以将构造方法改成私有的,需要通过我们的内部的方式来构造对象:

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

private Person(){} //不允许外部使用new关键字创建对象

public static Person getInstance() { //而是需要使用我们的独特方法来生成对象并返回
return new Person();
}
}

通过这种方式,我们可以实现单例模式:

1
2
3
4
5
6
7
8
9
10
11
public class Test {
private static Test instance;

private Test(){}

public static Test getInstance() {
if(instance == null)
instance = new Test();
return instance;
}
}

单例模式就是全局只能使用这一个对象,不能创建更多的对象,我们就可以封装成这样,关于单例模式的详细介绍,还请各位小伙伴在《Java设计模式》视频教程中再进行学习。

封装思想其实就是把实现细节给隐藏了,外部只需知道这个方法是什么作用,而无需关心实现,要用什么由类自己来做,不需要外面来操作类内部的东西去完成,封装就是通过访问权限控制来实现的。

类的继承

前面我们介绍了类的封装,我们接着来看一个非常重要特性:继承。

在定义不同类的时候存在一些相同属性,为了方便使用可以将这些共同属性抽象成一个父类,在定义其他子类时可以继承自该父类,减少代码的重复定义,子类可以使用父类中非私有的成员。

比如说我们一开始使用的人类,那么实际上人类根据职业划分,所掌握的技能也会不同,比如画家会画画,歌手会唱,舞者会跳,Rapper会rap,运动员会篮球,我们可以将人类这个大类根据职业进一步地细分出来:

image-20220921150139125

实际上这些划分出来的类,本质上还是人类,也就是说人类具有的属性,这些划分出来的类同样具有,但是,这些划分出来的类同时也会拥有他们自己独特的技能。在Java中,我们可以创建一个类的子类来实现上面的这种效果:

1
2
3
4
5
public class Person {   //先定义一个父类
String name;
int age;
String sex;
}

接着我们可以创建各种各样的子类,想要继承一个类,我们只需要使用extends关键字即可:

1
2
3
public class Worker extends Person{    //工人类

}
1
2
3
public class Student extends Person{   //学生类

}

类的继承可以不断向下,但是同时只能继承一个类,同时,标记为final的类不允许被继承:

1
2
3
public final class Person {  //class前面添加final关键字表示这个类已经是最终形态,不能继承

}

当一个类继承另一个类时,属性会被继承,可以直接访问父类中定义的属性,除非父类中将属性的访问权限修改为private,那么子类将无法访问(但是依然是继承了这个属性的):

1
2
3
4
5
public class Student extends Person{
public void study(){
System.out.println("我的名字是 "+name+",我在学习!"); //可以直接访问父类中定义的name属性
}
}

同样的,在父类中定义的方法同样会被子类继承:

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

public void hello(){
System.out.println("我叫 "+name+",今年 "+age+" 岁了!");
}
}

子类直接获得了此方法,当我们创建一个子类对象时就可以直接使用这个方法:

1
2
3
4
5
public static void main(String[] args) {
Student student = new Student();
student.study(); //子类不仅有自己的独特技能
student.hello(); //还继承了父类的全部技能
}

是不是感觉非常人性化,子类继承了父类的全部能力,同时还可以扩展自己的独特能力,就像一句话说的: 龙生龙凤生凤,老鼠儿子会打洞。

如果父类存在一个有参构造方法,子类必须在构造方法中调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Person {
protected String name; //因为子类需要用这些属性,所以说我们就将这些变成protected,外部不允许访问
protected int age;
protected String sex;
protected String profession;

//构造方法也改成protected,只能子类用
protected Person(String name, int age, String sex, String profession) {
this.name = name;
this.age = age;
this.sex = sex;
this.profession = profession;
}

public void hello(){
System.out.println("["+profession+"] 我叫 "+name+",今年 "+age+" 岁了!");
}
}

可以看到,此时两个子类都报错了:

image-20220921153512798

因为子类在构造时,不仅要初始化子类的属性,还需要初始化父类的属性,所以说在默认情况下,子类其实是调用了父类的构造方法的,只是在无参的情况下可以省略,但是现在父类构造方法需要参数,那么我们就需要手动指定了:

既然现在父类需要三个参数才能构造,那么子类需要按照同样的方式调用父类的构造方法:

1
2
3
4
5
6
7
8
9
public class Student extends Person{
public Student(String name, int age, String sex) { //因为学生职业已经确定,所以说学生直接填写就可以了
super(name, age, sex, "学生"); //使用super代表父类,父类的构造方法就是super()
}

public void study(){
System.out.println("我的名字是 "+name+",我在学习!");
}
}
1
2
3
4
5
6
public class Worker extends Person{
public Worker(String name, int age, String sex) {
super(name, age, sex, "工人"); //父类构造调用必须在最前面
System.out.println("工人构造成功!"); //注意,在调用父类构造方法之前,不允许执行任何代码,只能在之后执行
}
}

我们在使用子类时,可以将其当做父类来使用:

1
2
3
4
public static void main(String[] args) {
Person person = new Student("小明", 18, "男"); //这里使用父类类型的变量,去引用一个子类对象(向上转型)
person.hello(); //父类对象的引用相当于当做父类来使用,只能访问父类对象的内容
}

虽然我们这里使用的是父类类型引用的对象,但是这并不代表子类就彻底变成父类了,这里仅仅只是当做父类使用而已。

我们也可以使用强制类型转换,将一个被当做父类使用的子类对象,转换回子类:

1
2
3
4
5
public static void main(String[] args) {
Person person = new Student("小明", 18, "男");
Student student = (Student) person; //使用强制类型转换(向下转型)
student.study();
}

但是注意,这种方式只适用于这个对象本身就是对应的子类才可以,如果本身都不是这个子类,或者说就是父类,那么会出现问题:

1
2
3
4
5
public static void main(String[] args) {
Person person = new Worker("小明", 18, "男"); //实际创建的是Work类型的对象
Student student = (Student) person;
student.study();
}

image-20220921160309835

此时直接出现了类型转换异常,因为本身不是这个类型,强转也没用。

那么如果我们想要判断一下某个变量所引用的对象到底是什么类,那么该怎么办呢?

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
Person person = new Student("小明", 18, "男");
if(person instanceof Student) { //我们可以使用instanceof关键字来对类型进行判断
System.out.println("对象是 Student 类型的");
}
if(person instanceof Person) {
System.out.println("对象是 Person 类型的");
}
}

如果变量所引用的对象是对应类型或是对应类型的子类,那么instanceof都会返回true,否则返回false

最后我们需要来特别说明一下,子类是可以定义和父类同名的属性的:

1
2
3
4
5
6
7
public class Worker extends Person{
protected String name; //子类中同样可以定义name属性

public Worker(String name, int age, String sex) {
super(name, age, sex, "工人");
}
}

此时父类的name属性和子类的name属性是同时存在的,那么当我们在子类中直接使用时:

1
2
3
public void work(){
System.out.println("我是 "+name+",我在工作!"); //这里的name,依然是作用域最近的哪一个,也就是在当前子类中定义的name属性,而不是父类的name属性
}

所以说,我们在使用时,实际上这里得到的结果为null

image-20220921160742714

那么,在子类存在同名变量的情况下,怎么去访问父类的呢?我们同样可以使用super关键字来表示父类:

1
2
3
public void work(){
System.out.println("我是 "+super.name+",我在工作!"); //这里使用super.name来表示需要的是父类的name变量
}

这样得到的结果就不一样了:

image-20220921160851193

但是注意,没有super.super这种用法,也就是说如果存在多级继承的话,那么最多只能通过这种方法访问到父类的属性(包括继承下来的属性)

顶层Object类

实际上所有类都默认继承自Object类,除非手动指定继承的类型,但是依然改变不了最顶层的父类是Object类。所有类都包含Object类中的方法,比如:

image-20220921214642969

我们发现,除了我们自己在类中编写的方法之外,还可以调用一些其他的方法,那么这些方法不可能无缘无故地出现,肯定同样是因为继承得到的,那么这些方法是继承谁得到的呢?

1
2
3
4
public class Person extends Object{   
//除非我们手动指定要继承的类是什么,实际上默认情况下所有的类都是继承自Object的,只是可以省略

}

所以说我们的继承结构差不多就是:

image-20220921214944267

既然所有的类都默认继承自Object,我们来看看这个类里面有哪些内容:

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
public class Object {

private static native void registerNatives(); //标记为native的方法是本地方法,底层是由C++实现的
static {
registerNatives(); //这个类在初始化时会对类中其他本地方法进行注册,本地方法不是我们SE中需要学习的内容,我们会在JVM篇视频教程中进行介绍
}

//获取当前的类型Class对象,这个我们会在最后一章的反射中进行讲解,目前暂时不会用到
public final native Class<?> getClass();

//获取对象的哈希值,我们会在第五章集合类中使用到,目前各位小伙伴就暂时理解为会返回对象存放的内存地址
public native int hashCode();

//判断当前对象和给定对象是否相等,默认实现是直接用等号判断,也就是直接判断是否为同一个对象
public boolean equals(Object obj) {
return (this == obj);
}

//克隆当前对象,可以将复制一个完全一样的对象出来,包括对象的各个属性
protected native Object clone() throws CloneNotSupportedException;

//将当前对象转换为String的形式,默认情况下格式为 完整类名@十六进制哈希值
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}

//唤醒一个等待当前对象锁的线程,有关锁的内容,我们会在第六章多线程部分中讲解,目前暂时不会用到
public final native void notify();

//唤醒所有等待当前对象锁的线程,同上
public final native void notifyAll();

//使得持有当前对象锁的线程进入等待状态,同上
public final native void wait(long timeout) throws InterruptedException;

//同上
public final void wait(long timeout, int nanos) throws InterruptedException {
...
}

//同上
public final void wait() throws InterruptedException {
...
}

//当对象被判定为已经不再使用的“垃圾”时,在回收之前,会由JVM来调用一次此方法进行资源释放之类的操作,这同样不是SE中需要学习的内容,这个方法我们会在JVM篇视频教程中详细介绍,目前暂时不会用到
protected void finalize() throws Throwable { }
}

这里我们可以尝试调用一下Object为我们提供的toString()方法:

1
2
3
4
5
public static void main(String[] args) {
Person person = new Student("小明", 18, "男");
String str = person.toString();
System.out.println(str);
}

这里就是按照上面说的格式进行打印:

image-20220921221053801

当然,我们直接可以给println传入一个Object类型的对象:

1
2
3
4
5
6
7
public void println(Object x) {
String s = String.valueOf(x); //这里同样会调用对象的toString方法,所以说跟上面效果是一样的
synchronized (this) {
print(s);
newLine();
}
}

有小伙伴肯定会好奇,这里不是接受的一个Object类型的值的,为什么任意类型都可以传入呢?因为所有类型都是继承自Object,如果方法接受的参数是一个引用类型的值,那只要是这个类的对象或是这个类的子类的对象,都可以作为参数传入。

我们也可以试试看默认提供的equals方法:

1
2
3
4
5
public static void main(String[] args) {
Person p1 = new Student("小明", 18, "男");
Person p2 = new Student("小明", 18, "男");
System.out.println(p1.equals(p2));
}

因为默认比较的是两个对象是否为同一个对象,所以说这里得到的肯定是false,但是有些情况下,实际上我们所希望的情况是如果名字、年龄、性别都完全相同,那么这肯定是同一个人,但是这里却做不到这样的判断,我们需要修改一下equals方法的默认实现来完成,这就要用到方法的重写了。

方法的重写

注意,方法的重写不同于之前的方法重载,不要搞混了,方法的重载是为某个方法提供更多种类,而方法的重写是覆盖原有的方法实现,比如我们现在不希望使用Object类中提供的equals方法,那么我们就可以将其重写了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Person{
...

@Override //重写方法可以添加 @Override 注解,有关注解我们会在最后一章进行介绍,这个注解默认情况下可以省略
public boolean equals(Object obj) { //重写方法要求与父类的定义完全一致
if(obj == null) return false; //如果传入的对象为null,那肯定不相等
if(obj instanceof Person) { //只有是当前类型的对象,才能进行比较,要是都不是这个类型还比什么
Person person = (Person) obj; //先转换为当前类型,接着我们对三个属性挨个进行比较
return this.name.equals(person.name) && //字符串内容的比较,不能使用==,必须使用equals方法
this.age == person.age && //基本类型的比较跟之前一样,直接==
this.sex.equals(person.sex);
}
return false;
}
}

在重写Object提供的equals方法之后,就会按照我们的方式进行判断了:

1
2
3
4
5
public static void main(String[] args) {
Person p1 = new Student("小明", 18, "男");
Person p2 = new Student("小明", 18, "男");
System.out.println(p1.equals(p2)); //此时由于三个属性完全一致,所以说判断结果为真,即使是两个不同的对象
}

有时候为了方便查看对象的各个属性,我们可以将Object类提供的toString方法重写了:

1
2
3
4
5
6
7
8
9
@Override
public String toString() { //使用IDEA可以快速生成
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
", sex='" + sex + '\'' +
", profession='" + profession + '\'' +
'}';
}

这样,我们直接打印对象时,就会打印出对象的各个属性值了:

1
2
3
4
public static void main(String[] args) {
Person person = new Student("小明", 18, "男");
System.out.println(person);
}

image-20220921223249343

注意,静态方法不支持重写,因为它是属于类本身的,但是它可以被继承。

基于这种方法可以重写的特性,对于一个类定义的行为,不同的子类可以出现不同的行为,比如考试,学生考试可以得到A,而工人去考试只能得到D:

1
2
3
4
5
6
7
8
9
public class Person {
...

public void exam(){
System.out.println("我是考试方法");
}

...
}
1
2
3
4
5
6
7
8
public class Student extends Person{
...

@Override
public void exam() {
System.out.println("我是学生,我就是小镇做题家,拿个 A 轻轻松松");
}
}
1
2
3
4
5
6
7
8
public class Worker extends Person{
...

@Override
public void exam() {
System.out.println("我是工人,做题我并不擅长,只能得到 D");
}
}

这样,不同的子类,对于同一个方法会产生不同的结果:

1
2
3
4
5
6
7
public static void main(String[] args) {
Person person = new Student("小明", 18, "男");
person.exam();

person = new Worker("小强", 18, "男");
person.exam();
}

image-20220921224525855

这其实就是面向对象编程中多态特性的一种体现。

注意,我们如果不希望子类重写某个方法,我们可以在方法前添加final关键字,表示这个方法已经是最终形态:

1
2
3
public final void exam(){
System.out.println("我是考试方法");
}

image-20220921224907373

或者,如果父类中方法的可见性为private,那么子类同样无法访问,也就不能重写,但是可以定义同名方法:

image-20220921225651487

虽然这里可以编译通过,但是并不是对父类方法的重写,仅仅是子类自己创建的一个新方法。

还有,我们在重写父类方法时,如果希望调用父类原本的方法实现,那么同样可以使用super关键字:

1
2
3
4
5
@Override
public void exam() {
super.exam(); //调用父类的实现
System.out.println("我是工人,做题我并不擅长,只能得到 D");
}

然后就是访问权限的问题,子类在重写父类方法时,不能降低父类方法中的可见性:

1
2
3
public void exam(){
System.out.println("我是考试方法");
}

image-20220921225234226

因为子类实际上可以当做父类使用,如果子类的访问权限比父类还低,那么在被当做父类使用时,就可能出现无视访问权限调用的情况,这样肯定是不行的,但是相反的,我们可以在子类中提升权限:

1
2
3
protected void exam(){
System.out.println("我是考试方法");
}
1
2
3
4
@Override
public void exam() { //将可见性提升为public
System.out.println("我是工人,做题我并不擅长,只能得到 D");
}

image-20220921225840122

可以看到作为子类时就可以正常调用,但是如果将其作为父类使用,因为访问权限不足所有就无法使用,总之,子类重写的方法权限不能比父类还低。

抽象类

在我们学习了类的继承之后,实际上我们会发现,越是处于顶层定义的类,实际上可以进一步地进行抽象,比如我们前面编写的考试方法:

1
2
3
protected void exam(){
System.out.println("我是考试方法");
}

这个方法再子类中一定会被重写,所以说除非子类中调用父类的实现,否则一般情况下永远都不会被调用,就像我们说一个人会不会考试一样,实际上人怎么考试是一个抽象的概念,而学生怎么考试和工人怎么考试,才是具体的一个实现,所以说,我们可以将人类进行进一步的抽象,让某些方法完全由子类来实现,父类中不需要提供实现。

要实现这样的操作,我们可以将人类变成抽象类,抽象类比类还要抽象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public abstract class Person {   //通过添加abstract关键字,表示这个类是一个抽象类
protected String name; //大体内容其实普通类差不多
protected int age;
protected String sex;
protected String profession;

protected Person(String name, int age, String sex, String profession) {
this.name = name;
this.age = age;
this.sex = sex;
this.profession = profession;
}

public abstract void exam(); //抽象类中可以具有抽象方法,也就是说这个方法只有定义,没有方法体
}

而具体的实现,需要由子类来完成,而且如果是子类,必须要实现抽象类中所有抽象方法:

1
2
3
4
5
6
7
8
9
10
11
public class Worker extends Person{

public Worker(String name, int age, String sex) {
super(name, age, sex, "工人");
}

@Override
public void exam() { //子类必须要实现抽象类所有的抽象方法,这是强制要求的,否则会无法通过编译
System.out.println("我是工人,做题我并不擅长,只能得到 D");
}
}

抽象类由于不是具体的类定义(它是类的抽象)可能会存在某些方法没有实现,因此无法直接通过new关键字来直接创建对象:

image-20220921231744420

要使用抽象类,我们只能去创建它的子类对象。

抽象类一般只用作继承使用,当然,抽象类的子类也可以是一个抽象类:

1
2
3
4
5
6
7
8
9
10
public abstract class Student extends Person{   //如果抽象类的子类也是抽象类,那么可以不用实现父类中的抽象方法
public Student(String name, int age, String sex) {
super(name, age, sex, "学生");
}

@Override //抽象类中并不是只能有抽象方法,抽象类中也可以有正常方法的实现
public void exam() {
System.out.println("我是学生,我就是小镇做题家,拿个 A 轻轻松松");
}
}

注意,抽象方法的访问权限不能为private

image-20220921232435056

因为抽象方法一定要由子类实现,如果子类都访问不了,那么还有什么意义呢?所以说不能为私有。

接口

接口甚至比抽象类还抽象,他只代表某个确切的功能!也就是只包含方法的定义,甚至都不是一个类!接口一般只代表某些功能的抽象,接口包含了一些列方法的定义,类可以实现这个接口,表示类支持接口代表的功能(类似于一个插件,只能作为一个附属功能加在主体上,同时具体实现还需要由主体来实现)

咋一看,这啥意思啊,什么叫支持接口代表的功能?实际上接口的目标就是将类所具有某些的行为抽象出来。

比如说,对于人类的不同子类,学生和老师来说,他们都具有学习这个能力,既然都有,那么我们就可以将学习这个能力,抽象成接口来进行使用,只要是实现这个接口的类,都有学习的能力:

1
2
3
public interface Study {    //使用interface表示这是一个接口
void study(); //接口中只能定义访问权限为public抽象方法,其中public和abstract关键字可以省略
}

我们可以让类实现这个接口:

1
2
3
4
5
6
7
8
9
10
public class Student extends Person implements Study {   //使用implements关键字来实现接口
public Student(String name, int age, String sex) {
super(name, age, sex, "学生");
}

@Override
public void study() { //实现接口时,同样需要将接口中所有的抽象方法全部实现
System.out.println("我会学习!");
}
}
1
2
3
4
5
6
7
8
9
10
public class Teacher extends Person implements Study {
protected Teacher(String name, int age, String sex) {
super(name, age, sex, "教师");
}

@Override
public void study() {
System.out.println("我会加倍学习!");
}
}

接口不同于继承,接口可以同时实现多个:

1
2
3
public class Student extends Person implements Study, A, B, C {  //多个接口的实现使用逗号隔开

}

所以说有些人说接口其实就是Java中的多继承,但是我个人认为这种说法是错的,实际上实现接口更像是一个类的功能列表,作为附加功能存在,一个类可以附加很多个功能,接口的使用和继承的概念有一定的出入,顶多说是多继承的一种替代方案。

接口跟抽象类一样,不能直接创建对象,但是我们也可以将接口实现类的对象以接口的形式去使用:

image-20220921234735828

当做接口使用时,只有接口中定义的方法和Object类的方法,无法使用类本身的方法和父类的方法。

接口同样支持向下转型:

1
2
3
4
5
6
7
public static void main(String[] args) {
Study study = new Teacher("小王", 27, "男");
if(study instanceof Teacher) { //直接判断引用的对象是不是Teacher类型
Teacher teacher = (Teacher) study; //强制类型转换
teacher.study();
}
}

这里的使用其实跟之前的父类是差不多的。

从Java8开始,接口中可以存在方法的默认实现:

1
2
3
4
5
6
7
public interface Study {
void study();

default void test() { //使用default关键字为接口中的方法添加默认实现
System.out.println("我是默认实现");
}
}

如果方法在接口中存在默认实现,那么实现类中不强制要求进行实现。

接口不同于类,接口中不允许存在成员变量和成员方法,但是可以存在静态变量和静态方法,在接口中定义的变量只能是:

1
2
3
4
5
6
7
8
9
public interface Study {
public static final int a = 10; //接口中定义的静态变量只能是public static final的

public static void test(){ //接口中定义的静态方法也只能是public的
System.out.println("我是静态方法");
}

void study();
}

跟普通的类一样,我们可以直接通过接口名.的方式使用静态内容:

1
2
3
4
public static void main(String[] args) {
System.out.println(Study.a);
Study.test();
}

接口是可以继承自其他接口的:

1
2
3
public interface A exetnds B {

}

并且接口没有继承数量限制,接口支持多继承:

1
2
3
public interface A exetnds B, C, D {

}

接口的继承相当于是对接口功能的融合罢了。

最后我们来介绍一下Object类中提供的克隆方法,为啥要留到这里才来讲呢?因为它需要实现接口才可以使用:

1
2
3
4
package java.lang;

public interface Cloneable { //这个接口中什么都没定义
}

实现接口后,我们还需要将克隆方法的可见性提升一下,不然还用不了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Student extends Person implements Study, Cloneable {   //首先实现Cloneable接口,表示这个类具有克隆的功能
public Student(String name, int age, String sex) {
super(name, age, sex, "学生");
}

@Override
public Object clone() throws CloneNotSupportedException { //提升clone方法的访问权限
return super.clone(); //因为底层是C++实现,我们直接调用父类的实现就可以了
}

@Override
public void study() {
System.out.println("我会学习!");
}
}

接着我们来尝试一下,看看是不是会得到一个一模一样的对象:

1
2
3
4
5
6
7
public static void main(String[] args) throws CloneNotSupportedException {  //这里向上抛出一下异常,还没学异常,所以说照着写就行了
Student student = new Student("小明", 18, "男");
Student clone = (Student) student.clone(); //调用clone方法,得到一个克隆的对象
System.out.println(student);
System.out.println(clone);
System.out.println(student == clone);
}

可以发现,原对象和克隆对象,是两个不同的对象,但是他们的各种属性都是完全一样的:

image-20220922110044636

通过实现接口,我们就可以很轻松地完成对象的克隆了,在我们之后的学习中,还会经常遇到接口的使用。

注意: 以下内容为选学内容,在设计模式篇视频教程中有详细介绍。

克隆操作可以完全复制一个对象的所有属性,但是像这样的拷贝操作其实也分为浅拷贝和深拷贝。

  • 浅拷贝: 对于类中基本数据类型,会直接复制值给拷贝对象;对于引用类型,只会复制对象的地址,而实际上指向的还是原来的那个对象,拷贝个基莫。
  • 深拷贝: 无论是基本类型还是引用类型,深拷贝会将引用类型的所有内容,全部拷贝为一个新的对象,包括对象内部的所有成员变量,也会进行拷贝。

那么clone方法出来的克隆对象,是深拷贝的结果还是浅拷贝的结果呢?

1
2
3
4
5
public static void main(String[] args) throws CloneNotSupportedException {
Student student = new Student("小明", 18, "男");
Student clone = (Student) student.clone();
System.out.println(student.name == clone.name);
}

image-20220922110750697

可以看到,虽然Student对象成功拷贝,但是其内层对象并没有进行拷贝,依然只是对象引用的复制,所以Java为我们提供的clone方法只会进行浅拷贝。


枚举类

假设现在我们想给小明添加一个状态(跑步、学习、睡觉),外部可以实时获取小明的状态:

1
2
3
4
5
6
7
8
9
10
11
12
public class Student extends Person implements Study {

private String status; //状态,可以是跑步、学习、睡觉这三个之中的其中一种

public String getStatus() {
return status;
}

public void setStatus(String status) {
this.status = status;
}
}

但是这样会出现一个问题,如果我们仅仅是存储字符串,似乎外部可以不按照我们规则,传入一些其他的字符串。这显然是不够严谨的,有没有一种办法,能够更好地去实现这样的状态标记呢?我们希望开发者拿到使用的就是我们预先定义好的状态,所以,我们可以使用枚举类来完成:

1
2
3
public enum Status {   //enum表示这是一个枚举类,枚举类的语法稍微有一些不一样
RUNNING, STUDY, SLEEP; //直接写每个状态的名字即可,最后面分号可以不打,但是推荐打上
}

使用枚举类也非常方便,就像使用普通类型那样:

1
2
3
4
5
6
7
8
9
private Status status;   //类型变成刚刚定义的枚举类

public Status getStatus() {
return status;
}

public void setStatus(Status status) {
this.status = status;
}

这样,别人在使用时,就能很清楚地知道我们支持哪些了:

image-20220922111426974

枚举类型使用起来就非常方便了,其实枚举类型的本质就是一个普通的类,但是它继承自Enum类,我们定义的每一个状态其实就是一个public static final的Status类型成员变量:

1
2
3
4
5
6
7
8
9
//这里使用javap命令对class文件进行反编译得到 Compiled from "Status.java"
public final class com.test.Status extends java.lang.Enum<com.test.Status> {
public static final com.test.Status RUNNING;
public static final com.test.Status STUDY;
public static final com.test.Status SLEEP;
public static com.test.Status[] values();
public static com.test.Status valueOf(java.lang.String);
static {};
}

既然枚举类型是普通的类,那么我们也可以给枚举类型添加独有的成员方法:

1
2
3
4
5
6
7
8
9
10
11
12
public enum Status {
RUNNING("睡觉"), STUDY("学习"), SLEEP("睡觉"); //无参构造方法被覆盖,创建枚举需要添加参数(本质就是调用的构造方法)

private final String name; //枚举的成员变量
Status(String name){ //覆盖原有构造方法(默认private,只能内部使用!)
this.name = name;
}

public String getName() { //获取封装的成员变量
return name;
}
}

这样,枚举就可以按照我们想要的中文名称打印了:

1
2
3
4
5
public static void main(String[] args) {
Student student = new Student("小明", 18, "男");
student.setStatus(Status.RUNNING);
System.out.println(student.getStatus().getName());
}

枚举类还自带一些继承下来的实用方法,比如获取枚举类中的所有枚举,只不过这里用到了数组,我们会在下一章进行介绍。

至此,面向对象基础内容就全部讲解完成了,下一章我们还将继续讲解面向对象的其他内容。

————————————————
版权声明:本文为柏码知识库版权所有,禁止一切未经授权的转载、发布、出售等行为,违者将被追究法律责任。
原文链接:https://www.itbaima.cn/document/jviyz2hsht9ete5k

image-20220922170926093

面向对象高级篇

经过前面的学习,我们已经了解了面向对象编程的大部分基础内容,这一部分,我们将继续探索面向对象编程过程中一些常用的东西。

基本类型包装类

Java并不是纯面向对象的语言,虽然Java语言是一个面向对象的语言,但是Java中的基本数据类型却不是面向对象的。Java中的基本类型,如果想通过对象的形式去使用他们,Java提供的基本类型包装类,使得Java能够更好的体现面向对象的思想,同时也使得基本类型能够支持对象操作!

包装类介绍

所有的包装类层次结构如下:

5c3a6a27-6370-4c60-9bbc-8039e11e752d

其中能够表示数字的基本类型包装类,继承自Number类,对应关系如下表:

  • byte -> Byte
  • boolean -> Boolean
  • short -> Short
  • char -> Character
  • int -> Integer
  • long -> Long
  • float -> Float
  • double -> Double

我们可以直接使用,这里我们以Integer类为例:

1
2
3
public static void main(String[] args) {
Integer i = new Integer(10); //将10包装为一个Integer类型的变量
}

包装类实际上就是将我们的基本数据类型,封装成一个类(运用了封装的思想)我们可以来看看Integer类中是怎么写的:

1
2
3
4
5
private final int value;  //类中实际上就靠这个变量在存储包装的值

public Integer(int value) {
this.value = value;
}

包装类型支持自动装箱,我们可以直接将一个对应的基本类型值作为对应包装类型引用变量的值:

1
2
3
public static void main(String[] args) {
Integer i = 10; //将int类型值作为包装类型使用
}

这是怎么做到的?为什么一个对象类型的值可以直接接收一个基本类类型的值?实际上这里就是自动装箱:

1
2
3
public static void main(String[] args) {
Integer i = Integer.valueOf(10); //上面的写法跟这里是等价的
}

这里本质上就是被自动包装成了一个Integer类型的对象,只是语法上为了简单,就支持像这样编写。既然能装箱,也是支持拆箱的:

1
2
3
4
public static void main(String[] args) {
Integer i = 10;
int a = i;
}

实际上上面的写法本质上就是:

1
2
3
4
public static void main(String[] args) {
Integer i = 10;
int a = i.intValue(); //通过此方法变成基本类型int值
}

这里就是自动拆箱,得益于包装类型的自动装箱和拆箱机制,我们可以让包装类型轻松地参与到基本类型的运算中:

1
2
3
4
5
public static void main(String[] args) {
Integer a = 10, b = 20;
int c = a * b; //直接自动拆箱成基本类型参与到计算中
System.out.println(c);
}

因为包装类是一个类,不是基本类型,所以说两个不同的对象,那么是不相等的:

1
2
3
4
5
6
public static void main(String[] args) {
Integer a = new Integer(10);
Integer b = new Integer(10);

System.out.println(a == b); //虽然a和b的值相同,但是并不是同一个对象,所以说==判断为假
}

那么自动装箱的呢?

1
2
3
4
public static void main(String[] args) {
Integer a = 10, b = 10;
System.out.println(a == b);
}

我们发现,通过自动装箱转换的Integer对象,如果值相同,得到的会是同一个对象,这是因为:

1
2
3
4
5
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high) //这里会有一个IntegerCache,如果在范围内,那么会直接返回已经提前创建好的对象
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}

IntegerCache会默认缓存-128~127之间的所有值,将这些值提前做成包装类放在数组中存放,虽然我们目前还没有学习数组,但是各位小伙伴只需要知道,我们如果直接让 -128~127之间的值自动装箱为Integer类型的对象,那么始终都会得到同一个对象,这是为了提升效率,因为小的数使用频率非常高,有些时候并不需要创建那么多对象,创建对象越多,内存也会消耗更多。

但是如果超出这个缓存范围的话,就会得到不同的对象了:

1
2
3
4
public static void main(String[] args) {
Integer a = 128, b = 128;
System.out.println(a == b);
}

这样就不会得到同一个对象了,因为超出了缓存的范围。同样的,Long、Short、Byte类型的包装类也有类似的机制,感兴趣的小伙伴可以自己点进去看看。

我们来看看包装类中提供了哪些其他的方法,包装类支持字符串直接转换:

1
2
3
4
public static void main(String[] args) {
Integer i = new Integer("666"); //直接将字符串的666,转换为数字666
System.out.println(i);
}

当然,字符串转Integer有多个方法:

1
2
3
4
5
public static void main(String[] args) {
Integer i = Integer.valueOf("5555");
//Integer i = Integer.parseInt("5555");
System.out.println(i);
}

我们甚至可以对十六进制和八进制的字符串进行解码,得到对应的int值:

1
2
3
4
public static void main(String[] args) {
Integer i = Integer.decode("0xA6");
System.out.println(i);
}

也可以将十进制的整数转换为其他进制的字符串:

1
2
3
public static void main(String[] args) {
System.out.println(Integer.toHexString(166));
}

当然,Integer中提供的方法还有很多,这里就不一一列出了。

特殊包装类

除了我们上面认识的这几种基本类型包装类之外,还有两个比较特殊的包装类型。

其中第一个是用于计算超大数字的BigInteger,我们知道,即使是最大的long类型,也只能表示64bit的数据,无法表示一个非常大的数,但是BigInteger没有这些限制,我们可以让他等于一个非常大的数字:

1
2
3
4
public static void main(String[] args) {
BigInteger i = BigInteger.valueOf(Long.MAX_VALUE); //表示Long的最大值,轻轻松松
System.out.println(i);
}

我们可以通过调用类中的方法,进行运算操作:

1
2
3
4
5
public static void main(String[] args) {
BigInteger i = BigInteger.valueOf(Long.MAX_VALUE);
i = i.multiply(BigInteger.valueOf(Long.MAX_VALUE)); //即使是long的最大值乘以long的最大值,也能给你算出来
System.out.println(i);
}

我们来看看结果:

image-20220922211414392

可以看到,此时数值已经非常大了,也可以轻松计算出来。咱们来点更刺激的:

1
2
3
4
5
public static void main(String[] args) {
BigInteger i = BigInteger.valueOf(Long.MAX_VALUE);
i = i.pow(100); //long的最大值来个100次方吧
System.out.println(i);
}

可以看到,这个数字已经大到一排显示不下了:

image-20220922211651719

一般情况,对于非常大的整数计算,我们就可以使用BigInteger来完成。

我们接着来看第二种,前面我们说了,浮点类型精度有限,对于需要精确计算的场景,就没办法了,而BigDecimal可以实现小数的精确计算。

1
2
3
4
5
6
7
public static void main(String[] args) {
BigDecimal i = BigDecimal.valueOf(10);
i = i.divide(BigDecimal.valueOf(3), 100, RoundingMode.CEILING);
//计算10/3的结果,精确到小数点后100位
//RoundingMode是舍入模式,就是精确到最后一位时,该怎么处理,这里CEILING表示向上取整
System.out.println(i);
}

可以看到,确实可以精确到这种程度:

image-20220922212222762

但是注意,对于这种结果没有终点的,无限循环的小数,我们必须要限制长度,否则会出现异常。


数组

我们接着来看一个比较特殊的类型,数组。

假设出现一种情况,我们想记录100个数字,要是采用定义100个变量的方式可以吗?是不是有点太累了?这种情况我们就可以使用数组来存放一组相同类型的数据。

image-20220922214604430

一维数组

数组是相同类型数据的有序集合,数组可以代表任何相同类型的一组内容(包括引用类型和基本类型)其中存放的每一个数据称为数组的一个元素,我们来看看如何去定义一个数组变量:

1
2
3
public static void main(String[] args) {
int[] array; //类型[]就表示这个是一个数组类型
}

注意,数组类型比较特殊,它本身也是类,但是编程不可见(底层C++写的,在运行时动态创建)即使是基本类型的数组,也是以对象的形式存在的,并不是基本数据类型。所以,我们要创建一个数组,同样需要使用new 关键字:

1
2
3
4
public static void main(String[] args) {
int[] array = new int[10]; //在创建数组时,需要指定数组长度,也就是可以容纳多个int变量的值
Object obj = array; //因为同样是类,肯定是继承自Object的,所以说可以直接向上转型
}

除了上面这种方式之外,我们也可以使用其他方式:

1
2
3
4
5
类型[] 变量名称 = new 类型[数组大小];
类型 变量名称[] = new 类型[数组大小]; //支持C语言样式,但不推荐!

类型[] 变量名称 = new 类型[]{...}; //静态初始化(直接指定值和大小)
类型[] 变量名称 = {...}; //同上,但是只能在定义时赋值

创建出来的数组每个位置上都有默认值,如果是引用类型,就是null,如果是基本数据类型,就是0,或者是false,跟对象成员变量的默认值是一样的,要访问数组的某一个元素,我们可以:

1
2
3
4
public static void main(String[] args) {
int[] array = new int[10];
System.out.println("数组的第一个元素为:"+array[0]); //使用 变量名[下标] 的方式访问
}

注意,数组的下标是从0开始的,不是从1开始的,所以说第一个元素的下标就是0,我们要访问第一个元素,那么直接输入0就行了,但是注意千万别写成负数或是超出范围了,否则会出现异常。

我们也可以使用这种方式为数组的元素赋值:

1
2
3
4
5
public static void main(String[] args) {
int[] array = new int[10];
array[0] = 888; //就像使用变量一样,是可以放在赋值运算符左边的,我们可以直接给对应下标位置的元素赋值
System.out.println("数组的第一个元素为:"+array[0]);
}

因为数组本身也是一个对象,数组对象也是具有属性的,比如长度:

1
2
3
4
public static void main(String[] args) {
int[] array = new int[10];
System.out.println("当前数组长度为:"+array.length); //length属性是int类型的值,表示当前数组长度,长度是在一开始创建数组的时候就确定好的
}

注意,这个length是在一开始就确定的,而且是final类型的,不允许进行修改,也就是说数组的长度一旦确定,不能随便进行修改,如果需要使用更大的数组,只能重新创建。

当然,既然是类型,那么肯定也是继承自Object类的:

1
2
3
4
5
public static void main(String[] args) {
int[] array = new int[10];
System.out.println(array.toString());
System.out.println(array.equals(array));
}

但是,很遗憾,除了clone()之外,这些方法并没有被重写,也就是说依然是采用的Object中的默认实现:

image-20220922220403391

所以说通过toString()打印出来的结果,好丑,只不过我们可以发现,数组类型的类名很奇怪,是[开头的。

因此,如果我们要打印整个数组中所有的元素,得一个一个访问:

1
2
3
4
5
6
public static void main(String[] args) {
int[] array = new int[10];
for (int i = 0; i < array.length; i++) {
System.out.print(array[i] + " ");
}
}

有时候为了方便,我们可以使用简化版的for语句foreach语法来遍历数组中的每一个元素:

1
2
3
4
5
6
public static void main(String[] args) {
int[] array = new int[10];
for (int i : array) { //int i就是每一个数组中的元素,array就是我们要遍历的数组
System.out.print(i+" "); //每一轮循环,i都会更新成数组中下一个元素
}
}

是不是感觉这种写法更加简洁?只不过这仅仅是语法糖而已,编译之后依然是跟上面一样老老实实在遍历的:

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {   //反编译的结果
int[] array = new int[10];
int[] var2 = array;
int var3 = array.length;

for(int var4 = 0; var4 < var3; ++var4) {
int i = var2[var4];
System.out.print(i + " ");
}

}

对于这种普通的数组,其实使用还是挺简单的。这里需要特别说一下,对于基本类型的数组来说,是不支持自动装箱和拆箱的:

1
2
3
4
public static void main(String[] args) {
int[] arr = new int[10];
Integer[] test = arr;
}

还有,由于基本数据类型和引用类型不同,所以说int类型的数组时不能被Object类型的数组变量接收的:

image-20220924114859252

但是如果是引用类型的话,是可以的:

1
2
3
4
public static void main(String[] args) {
String[] arr = new String[10];
Object[] array = arr; //数组同样支持向上转型
}
1
2
3
4
public static void main(String[] args) {
Object[] arr = new Object[10];
String[] array = (String[]) arr; //也支持向下转型
}

多维数组

前面我们介绍了简单的数组(一维数组)既然数组可以是任何类型的,那么我们能否创建数组类型的数组呢?答案是可以的,套娃嘛,谁不会:

1
2
3
public static void main(String[] args) {
int[][] array = new int[2][10]; //数组类型数组那么就要写两个[]了
}

存放数组的数组,相当于将维度进行了提升,比如上面的就是一个2x10的数组:

image-20220922221557130

这个中数组一共有2个元素,每个元素都是一个存放10个元素的数组,所以说最后看起来就像一个矩阵一样。甚至可以继续套娃,将其变成一个三维数组,也就是存放数组的数组的数组。

1
2
3
4
5
6
public static void main(String[] args) {
int[][] arr = { {1, 2},
{3, 4},
{5, 6}}; //一个三行两列的数组
System.out.println(arr[2][1]); //访问第三行第二列的元素
}

在访问多维数组时,我们需要使用多次[]运算符来得到对应位置的元素。如果我们要遍历多维数组话,那么就需要多次嵌套循环:

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) {
int[][] arr = new int[][]{{1, 2},
{3, 4},
{5, 6}};
for (int i = 0; i < 3; i++) { //要遍历一个二维数组,那么我们得一列一列一行一行地来
for (int j = 0; j < 2; j++) {
System.out.println(arr[i][j]);
}
}
}

可变长参数

我们接着来看数组的延伸应用,实际上我们的方法是支持可变长参数的,什么是可变长参数?

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

public void test(String... strings){

}
}

我们在使用时,可以传入0 - N个对应类型的实参:

1
2
3
4
public static void main(String[] args) {
Person person = new Person();
person.test("1!", "5!", "哥们在这跟你说唱"); //这里我们可以自由传入任意数量的字符串
}

那么我们在方法中怎么才能得到这些传入的参数呢,实际上可变长参数本质就是一个数组:

1
2
3
4
5
public void test(String... strings){   //strings这个变量就是一个String[]类型的
for (String string : strings) {
System.out.println(string); //遍历打印数组中每一个元素
}
}

注意,如果同时存在其他参数,那么可变长参数只能放在最后:

1
2
3
public void test(int a, int b, String... strings){

}

这里最后我们再来说一个从开始到现在一直都没有说的东西:

1
2
3
public static void main(String[] args) {   //这个String[] args到底是个啥???

}

实际上这个是我们在执行Java程序时,输入的命令行参数,我们可以来打印一下:

1
2
3
4
5
public static void main(String[] args) {
for (String arg : args) {
System.out.println(arg);
}
}

可以看到,默认情况下直接运行什么都没有,但是如果我们在运行时,添加点内容的话:

1
java com/test/Main lbwnb aaaa xxxxx   #放在包中需要携带主类完整路径才能运行

可以看到,我们在后面随意添加的三个参数,都放到数组中了:

image-20220922223152648

这个东西我们作为新手一般也不会用到,只做了解就行了。


字符串

字符串类是一个比较特殊的类,它用于保存字符串。我们知道,基本类型char可以保存一个2字节的Unicode字符,而字符串则是一系列字符的序列(在C中就是一个字符数组)Java中没有字符串这种基本类型,因此只能使用类来进行定义。注意,字符串中的字符一旦确定,无法进行修改,只能重新创建。

String类

String本身也是一个类,只不过它比较特殊,每个用双引号括起来的字符串,都是String类型的一个实例对象:

1
2
3
public static void main(String[] args) {
String str = "Hello World!";
}

我们也可以象征性地使用一下new关键字:

1
2
3
public static void main(String[] args) {
String str = new String("Hello World!"); //这种方式就是创建一个新的对象
}

注意,如果是直接使用双引号创建的字符串,如果内容相同,为了优化效率,那么始终都是同一个对象:

1
2
3
4
5
public static void main(String[] args) {
String str1 = "Hello World";
String str2 = "Hello World";
System.out.println(str1 == str2);
}

但是如果我们使用构造方法主动创建两个新的对象,那么就是不同的对象了:

1
2
3
4
5
public static void main(String[] args) {
String str1 = new String("Hello World");
String str2 = new String("Hello World");
System.out.println(str1 == str2);
}

至于为什么会出现这种情况,我们在JVM篇视频教程中会进行详细的介绍,这里各位小伙伴只需要记住就行了。因此,如果我们仅仅是想要判断两个字符串的内容是否相同,不要使用==,String类重载了equals方法用于判断和比较内容是否相同:

1
2
3
4
5
public static void main(String[] args) {
String str1 = new String("Hello World");
String str2 = new String("Hello World");
System.out.println(str1.equals(str2)); //字符串的内容比较,一定要用equals
}

既然String也是一个类,那么肯定是具有一些方法的,我们可以来看看:

1
2
3
4
public static void main(String[] args) {
String str = "Hello World";
System.out.println(str.length()); //length方法可以求字符串长度,这个长度是字符的数量
}

因为双引号括起来的字符串本身就是一个实例对象,所以说我们也可以直接用:

1
2
3
public static void main(String[] args) {
System.out.println("Hello World".length()); //虽然看起来挺奇怪的,但是确实支持这种写法
}

字符串类中提供了很多方便我们操作的方法,比如字符串的裁剪、分割操作:

1
2
3
4
5
public static void main(String[] args) {
String str = "Hello World";
String sub = str.substring(0, 3); //分割字符串,并返回一个新的子串对象
System.out.println(sub);
}
1
2
3
4
5
6
7
public static void main(String[] args) {
String str = "Hello World";
String[] strings = str.split(" "); //使用split方法进行字符串分割,比如这里就是通过空格分隔,得到一个字符串数组
for (String string : strings) {
System.out.println(string);
}
}

字符数组和字符串之间是可以快速进行相互转换的:

1
2
3
4
5
public static void main(String[] args) {
String str = "Hello World";
char[] chars = str.toCharArray();
System.out.println(chars);
}
1
2
3
4
5
public static void main(String[] args) {
char[] chars = new char[]{'奥', '利', '给'};
String str = new String(chars);
System.out.println(str);
}

当然,String类还有很多其他的一些方法,这里就不一一介绍了。

StringBuilder类

我们在之前的学习中已经了解,字符串支持使用++=进行拼接操作。

但是拼接字符串实际上底层需要进行很多操作,如果程序中大量进行字符串的拼接似乎不太好,编译器是很聪明的,String的拼接会在编译时进行各种优化:

1
2
3
4
public static void main(String[] args) {
String str = "杰哥" + "你干嘛"; //我们在写代码时使用的是拼接的形式
System.out.println(str);
}

编译之后就变成这样了:

1
2
3
4
public static void main(String[] args) {
String str = "杰哥你干嘛";
System.out.println(str);
}

对于变量来说,也有优化,比如下面这种情况:

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
String str1 = "你看";
String str2 = "这";
String str3 = "汉堡";
String str4 = "做滴";
String str5 = "行不行";
String result = str1 + str2 + str3 + str4 + str5; //5个变量连续加
System.out.println(result);
}

如果直接使用加的话,每次运算都会生成一个新的对象,这里进行4次加法运算,那么中间就需要产生4个字符串对象出来,是不是有点太浪费了?这种情况实际上会被优化为下面的写法:

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) {
String str1 = "你看";
String str2 = "这";
String str3 = "汉堡";
String str4 = "做滴";
String str5 = "行不行";
StringBuilder builder = new StringBuilder();
builder.append(str1).append(str2).append(str3).append(str4).append(str5);
System.out.println(builder.toString());
}

这里创建了一个StringBuilder的类型,这个类型是干嘛的呢?实际上它就是专门用于构造字符串的,我们可以使用它来对字符串进行拼接、裁剪等操作,它就像一个字符串编辑器,弥补了字符串不能修改的不足:

1
2
3
4
5
6
public static void main(String[] args) {
StringBuilder builder = new StringBuilder(); //一开始创建时,内部什么都没有
builder.append("AAA"); //我们可以使用append方法来讲字符串拼接到后面
builder.append("BBB");
System.out.println(builder.toString()); //当我们字符串编辑完成之后,就可以使用toString转换为字符串了
}

它还支持裁剪等操作:

1
2
3
4
5
public static void main(String[] args) {
StringBuilder builder = new StringBuilder("AAABBB"); //在构造时也可以指定初始字符串
builder.delete(2, 4); //删除2到4这个范围内的字符
System.out.println(builder.toString());
}

当然,StringBuilder类的编辑操作也非常多,这里就不一一列出了。

正则表达式

我们现在想要实现这样一个功能,对于给定的字符串进行判断,如果字符串符合我们的规则,那么就返回真,否则返回假,比如现在我们想要判断字符串是不是邮箱的格式:

1
2
3
4
public static void main(String[] args) {
String str = "aaaa731341@163.com";
//假设邮箱格式为 数字/字母@数字/字母.com
}

那么现在请你设计一个Java程序用于判断,你该怎么做?是不是感觉很麻烦,但是我们使用正则表达式就可以很轻松解决这种字符串格式匹配问题。

正则表达式(regular expression)描述了一种字符串匹配的模式(pattern),可以用来检查一个串是否含有某种子串、将匹配的子串替换或者从某个串中取出符合某个条件的子串等。

我们先来看看下面的这个例子:

1
2
3
4
5
public static void main(String[] args) {
String str = "oooo";
//matches方法用于对给定正则表达式进行匹配,匹配成功返回true,否则返回false
System.out.println(str.matches("o+")); //+表示对前面这个字符匹配一次或多次,这里字符串是oooo,正好可以匹配
}

用于规定给定组件必须要出现多少次才能满足匹配的,我们一般称为限定符,限定符表如下:

字符描述
*匹配前面的子表达式零次或多次。例如,zo* 能匹配 “z” 以及 “zoo”。***** 等价于 {0,}
+匹配前面的子表达式一次或多次。例如,zo+ 能匹配 “zo” 以及 "zoo",但不能匹配 “z”+ 等价于 {1,}
?匹配前面的子表达式零次或一次。例如,do(es)? 可以匹配 “do”“does”“doxy” 中的 “do”? 等价于 {0,1}
{n}n 是一个非负整数。匹配确定的 n 次。例如,o{2} 不能匹配 “Bob” 中的 o,但是能匹配 “food” 中的两个 o
{n,}n 是一个非负整数。至少匹配n 次。例如,o{2,} 不能匹配 “Bob” 中的 o,但能匹配 “foooood” 中的所有 oo{1,} 等价于 o+o{0,} 则等价于 o*
{n,m}m 和 n 均为非负整数,其中 n <= m。最少匹配 n 次且最多匹配 m 次。例如,o{1,3} 将匹配 “fooooood” 中的前三个 oo{0,1} 等价于 o?。请注意在逗号和两个数之间不能有空格。

如果我们想要表示一个范围内的字符,可以使用方括号:

1
2
3
4
public static void main(String[] args) {
String str = "abcabccaa";
System.out.println(str.matches("[abc]*")); //表示abc这几个字符可以出现 0 - N 次
}

对于普通字符来说,我们可以下面的方式实现多种字符匹配:

字符描述
[ABC]匹配 […] 中的所有字符,例如 [aeiou] 匹配字符串 “google runoob taobao” 中所有的 e o u a 字母。
[^ABC]匹配除了 […] 中字符的所有字符,例如 [^aeiou] 匹配字符串 “google runoob taobao” 中除了 e o u a 字母的所有字母。
[A-Z][A-Z] 表示一个区间,匹配所有大写字母,[a-z] 表示所有小写字母。
.匹配除换行符(\n、\r)之外的任何单个字符,相等于 [^\n\r]
[\s\S]匹配所有。\s 是匹配所有空白符,包括换行,\S 非空白符,不包括换行。
\w匹配字母、数字、下划线。等价于 [A-Za-z0-9_]

当然,这里仅仅是对正则表达式的简单使用,实际上正则表达式内容非常多,如果需要完整学习正则表达式,可以到:https://www.runoob.com/regexp/regexp-syntax.html

正则表达式并不是只有Java才支持,其他很多语言比如JavaScript、Python等等都是支持正则表达式的。


内部类

上一章我们详细介绍了类,我们现在已经知道该如何创建类、使用类了。当然,类的创建其实可以有多种多样的方式,并不仅仅局限于普通的创建。内部类顾名思义,就是创建在内部的类,那么具体是什么的内部呢,我们接着就来讨论一下。

**注意:**内部类很多地方都很绕,所以说一定要仔细思考。

成员内部类

我们可以直接在类的内部定义成员内部类:

1
2
3
4
5
6
7
public class Test {
public class Inner { //内部类也是类,所以说里面也可以有成员变量、方法等,甚至还可以继续套娃一个成员内部类
public void test(){
System.out.println("我是成员内部类!");
}
}
}

成员内部类和成员方法、成员变量一样,是对象所有的,而不是类所有的,如果我们要使用成员内部类,那么就需要:

1
2
3
4
public static void main(String[] args) {
Test test = new Test(); //我们首先需要创建对象
Test.Inner inner = test.new Inner(); //成员内部类的类型名称就是 外层.内部类名称
}

虽然看着很奇怪,但是确实是这样使用的。我们同样可以使用成员内部类中的方法:

1
2
3
4
5
public static void main(String[] args) {
Test test = new Test();
Test.Inner inner = test.new Inner();
inner.test();
}

注意,成员内部类也可以使用访问权限控制,如果我们我们将其权限改为private,那么就像我们把成员变量访问权限变成私有一样,外部是无法访问到这个内部类的:

image-20220924122217070

可以看到这里直接不认识了。

这里我们需要特别注意一下,在成员内部类中,是可以访问到外层的变量的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Test {
private final String name;

public Test(String name){
this.name = name;
}
public class Inner {
public void test(){
System.out.println("我是成员内部类:"+name);
//成员内部类可以访问到外部的成员变量
//因为成员内部类本身就是某个对象所有的,每个对象都有这样的一个类定义,这里的name是其所依附对象的
}
}
}

image-20220924123600217

每个类可以创建一个对象,每个对象中都有一个单独的类定义,可以通过这个成员内部类又创建出更多对象,套娃了属于是。

所以说我们在使用时:

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
Test a = new Test("小明");
Test.Inner inner1 = a.new Inner(); //依附于a创建的对象,那么就是a的
inner1.test();

Test b = new Test("小红");
Test.Inner inner2 = b.new Inner(); //依附于b创建的对象,那么就是b的
inner2.test();
}

那现在问大家一个问题,外部能访问内部类里面的成员变量吗?

那么如果内部类中也定义了同名的变量,此时我们怎么去明确要使用的是哪一个呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Test {
private final String name;

public Test(String name){
this.name = name;
}
public class Inner {

String name;
public void test(String name){
System.out.println("方法参数的name = "+name); //依然是就近原则,最近的是参数,那就是参数了
System.out.println("成员内部类的name = "+this.name); //在内部类中使用this关键字,只能表示内部类对象
System.out.println("成员内部类的name = "+Test.this.name);
//如果需要指定为外部的对象,那么需要在前面添加外部类型名称
}
}
}

包括对方法的调用和super关键字的使用,也是一样的:

1
2
3
4
5
6
7
8
9
10
public class Inner {

String name;
public void test(String name){
this.toString(); //内部类自己的toString方法
super.toString(); //内部类父类的toString方法
Test.this.toString(); //外部类的toSrting方法
Test.super.toString(); //外部类父类的toString方法
}
}

所以说成员内部类其实在某些情况下使用起来比较麻烦,对于这种成员内部类,我们一般只会在类的内部自己使用。

静态内部类

前面我们介绍了成员内部类,它就像成员变量和成员方法一样,是属于对象的,同样的,静态内部类就像静态方法和静态变量一样,是属于类的,我们可以直接创建使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Test {
private final String name;

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

public static class Inner {
public void test(){
System.out.println("我是静态内部类!");
}
}
}

不需要依附任何对象,我们可以直接创建静态内部类的对象:

1
2
3
4
public static void main(String[] args) {
Test.Inner inner = new Test.Inner(); //静态内部类的类名同样是之前的格式,但是可以直接new了
inner.test();
}

静态内部类由于是静态的,所以相对外部来说,整个内部类中都处于静态上下文(注意只是相当于外部来说)是无法访问到外部类的非静态内容的:

image-20220924124919135

只不过受影响的只是外部内容的使用,内部倒是不受影响,还是跟普通的类一样:

1
2
3
4
5
6
7
public static class Inner {

String name;
public void test(){
System.out.println("我是静态内部类:"+name);
}
}

其实也很容易想通,因为静态内部类是属于外部类的,不依附任何对象,那么我要是直接访问外部类的非静态属性,那到底访问哪个对象的呢?这样肯定是说不通的。

局部内部类

局部内部类就像局部变量一样,可以在方法中定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Test {
private final String name;

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

public void hello(){
class Inner { //直接在方法中创建局部内部类

}
}
}

既然是在方法中声明的类,那作用范围也就只能在方法中了:

1
2
3
4
5
6
7
8
9
10
11
12
public class Test {
public void hello(){
class Inner{ //局部内部类跟局部变量一样,先声明后使用
public void test(){
System.out.println("我是局部内部类");
}
}

Inner inner = new Inner(); //局部内部类直接使用类名就行
inner.test();
}
}

只不过这种局部内部类的形式,使用频率很低,基本上不会用到,所以说了解就行了。

匿名内部类

匿名内部类是我们使用频率非常高的一种内部类,它是局部内部类的简化版。

还记得我们在之前学习的抽象类和接口吗?在抽象类和接口中都会含有某些抽象方法需要子类去实现,我们当时已经很明确地说了不能直接通过new的方式去创建一个抽象类或是接口对象,但是我们可以使用匿名内部类。

1
2
3
public abstract class Student {
public abstract void test();
}

正常情况下,要创建一个抽象类的实例对象,只能对其进行继承,先实现未实现的方法,然后创建子类对象。

而我们可以在方法中使用匿名内部类,将其中的抽象方法实现,并直接创建实例对象:

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
Student student = new Student() { //在new的时候,后面加上花括号,把未实现的方法实现了
@Override
public void test() {
System.out.println("我是匿名内部类的实现!");
}
};
student.test();
}

此时这里创建出来的Student对象,就是一个已经实现了抽象方法的对象,这个抽象类直接就定义好了,甚至连名字都没有,就可以直接就创出对象。

匿名内部类中同样可以使用类中的属性(因为它本质上就相当于是对应类型的子类)所以说:

1
2
3
4
5
6
7
8
Student student = new Student() {
int a; //因为本质上就相当于是子类,所以说子类定义一些子类的属性完全没问题

@Override
public void test() {
System.out.println(name + "我是匿名内部类的实现!"); //直接使用父类中的name变量
}
};

同样的,接口也可以通过这种匿名内部类的形式,直接创建一个匿名的接口实现类:

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
Study study = new Study() {
@Override
public void study() {
System.out.println("我是学习方法!");
}
};
study.study();
}

当然,并不是说只有抽象类和接口才可以像这样创建匿名内部类,普通的类也可以,只不过意义不大,一般情况下只是为了进行一些额外的初始化工作而已。

Lambda表达式

前面我们介绍了匿名内部类,我们可以通过这种方式创建一个临时的实现子类。

特别的,如果一个接口中有且只有一个待实现的抽象方法,那么我们可以将匿名内部类简写为Lambda表达式:

1
2
3
4
public static void main(String[] args) {
Study study = () -> System.out.println("我是学习方法!"); //是不是感觉非常简洁!
study.study();
}

在初学阶段,为了简化学习,各位小伙伴就认为Lambda表达式就是匿名内部类的简写就行了(Lambda表达式的底层其实并不只是简简单单的语法糖替换,感兴趣的可以在新特性篇视频教程中了解)

那么它是一个怎么样的简写规则呢?我们来看一下Lambda表达式的具体规范:

  • 标准格式为:([参数类型 参数名称,]...) ‐> { 代码语句,包括返回值 }
  • 和匿名内部类不同,Lambda仅支持接口,不支持抽象类
  • 接口内部必须有且仅有一个抽象方法(可以有多个方法,但是必须保证其他方法有默认实现,必须留一个抽象方法出来)

比如我们之前写的Study接口,只要求实现一个无参无返回值的方法,所以说直接就是最简单的形式:

1
() -> System.out.println("我是学习方法!");   //跟之前流程控制一样,如果只有一行代码花括号可省略

当然,如果有一个参数和返回值的话:

1
2
3
4
5
6
7
public static void main(String[] args) {
Study study = (a) -> {
System.out.println("我是学习方法");
return "今天学会了"+a; //实际上这里面就是方法体,该咋写咋写
};
System.out.println(study.study(10));
}

注意,如果方法体中只有一个返回语句,可以直接省去花括号和return关键字:

1
2
3
Study study = (a) -> {
return "今天学会了"+a; //这种情况是可以简化的
};
1
Study study = (a) -> "今天学会了"+a;

如果参数只有一个,那么可以省去小括号:

1
Study study = a -> "今天学会了"+a;

是不是感觉特别简洁,实际上我们程序员追求的就是写出简洁高效的代码,而Java也在朝这个方向一直努力,近年来从Java 9开始出现的一些新语法基本都是各种各样的简写版本。

如果一个方法的参数需要的是一个接口的实现:

1
2
3
4
5
6
7
public static void main(String[] args) {
test(a -> "今天学会了"+a); //参数直接写成lambda表达式
}

private static void test(Study study){
study.study(10);
}

当然,这还只是一部分,对于已经实现的方法,如果我们想直接作为接口抽象方法的实现,我们还可以使用方法引用。

方法引用

方法引用就是将一个已实现的方法,直接作为接口中抽象方法的实现(当然前提是方法定义得一样才行)

1
2
3
public interface Study {
int sum(int a, int b); //待实现的求和方法
}

那么使用时候,可以直接使用Lambda表达式:

1
2
3
public static void main(String[] args) {
Study study = (a, b) -> a + b;
}

只不过还能更简单,因为Integer类中默认提供了求两个int值之和的方法:

1
2
3
4
//Integer类中就已经有对应的实现了
public static int sum(int a, int b) {
return a + b;
}

此时,我们可以直接将已有方法的实现作为接口的实现:

1
2
3
4
public static void main(String[] args) {
Study study = (a, b) -> Integer.sum(a, b); //直接使用Integer为我们通过好的求和方法
System.out.println(study.sum(10, 20));
}

我们发现,Integer.sum的参数和返回值,跟我们在Study中定义的完全一样,所以说我们可以直接使用方法引用:

1
2
3
4
public static void main(String[] args) {
Study study = Integer::sum; //使用双冒号来进行方法引用,静态方法使用 类名::方法名 的形式
System.out.println(study.sum(10, 20));
}

方法引用其实本质上就相当于将其他方法的实现,直接作为接口中抽象方法的实现。任何方法都可以通过方法引用作为实现:

1
2
3
public interface Study {
String study();
}

如果是普通从成员方法,我们同样需要使用对象来进行方法引用:

1
2
3
4
5
6
7
8
public static void main(String[] args) {
Main main = new Main();
Study study = main::lbwnb; //成员方法因为需要具体对象使用,所以说只能使用 对象::方法名 的形式
}

public String lbwnb(){
return "卡布奇诺今犹在,不见当年倒茶人。";
}

因为现在只需要一个String类型的返回值,由于String的构造方法在创建对象时也会得到一个String类型的结果,所以说:

1
2
3
public static void main(String[] args) {
Study study = String::new; //没错,构造方法也可以被引用,使用new表示
}

反正只要是符合接口中方法的定义的,都可以直接进行方法引用,对于Lambda表达式和方法引用,在Java新特性介绍篇视频教程中还有详细的讲解,这里就不多说了。


异常机制

在理想的情况下,我们的程序会按照我们的思路去运行,按理说是不会出现问题的,但是,代码实际编写后并不一定是完美的,可能会有我们没有考虑到的情况,如果这些情况能够正常得到一个错误的结果还好,但是如果直接导致程序运行出现问题了呢?

1
2
3
4
5
6
7
public static void main(String[] args) {
test(1, 0); //当b为0的时候,还能正常运行吗?
}

private static int test(int a, int b){
return a/b; //没有任何的判断而是直接做计算
}

此时我们可以看到,出现了运算异常:

image-20220924164357033

那么这个异常到底是什么样的一种存在呢?当程序运行出现我们没有考虑到的情况时,就有可能出现异常或是错误!

异常的类型

我们在之前其实已经接触过一些异常了,比如数组越界异常,空指针异常,算术异常等,他们其实都是异常类型,我们的每一个异常也是一个类,他们都继承自Exception类!异常类型本质依然类的对象,但是异常类型支持在程序运行出现问题时抛出(也就是上面出现的红色报错)也可以提前声明,告知使用者需要处理可能会出现的异常!

异常的第一种类型是运行时异常,如上述的列子,在编译阶段无法感知代码是否会出现问题,只有在运行的时候才知道会不会出错(正常情况下是不会出错的),这样的异常称为运行时异常,异常也是由类定义的,所有的运行时异常都继承自RuntimeException

1
2
3
4
public static void main(String[] args) {
Object object = null;
object.toString(); //这种情况就会出现运行时异常
}

image-20220924164637887

又比如下面的这种情况:

1
2
3
4
public static void main(String[] args) {
Object object = new Object();
Main main = (Main) object;
}

image-20220924164844005

异常的另一种类型是编译时异常,编译时异常明确指出可能会出现的异常,在编译阶段就需要进行处理(捕获异常)必须要考虑到出现异常的情况,如果不进行处理,将无法通过编译!默认继承自Exception类的异常都是编译时异常。

1
protected native Object clone() throws CloneNotSupportedException;

比如Object类中定义的clone方法,就明确指出了在运行的时候会出现的异常。

还有一种类型是错误,错误比异常更严重,异常就是不同寻常,但不一定会导致致命的问题,而错误是致命问题,一般出现错误可能JVM就无法继续正常运行了,比如OutOfMemoryError就是内存溢出错误(内存占用已经超出限制,无法继续申请内存了)

1
2
3
4
5
6
7
public static void main(String[] args) {
test();
}

private static void test(){
test();
}

比如这样的一个无限递归的方法,会导致运行过程中无限制地向下调用方法,导致栈溢出:

image-20220924165500108

这种情况就是错误了,已经严重到整个程序都无法正常运行了。又比如:

1
2
3
public static void main(String[] args) {
Object[] objects = new Object[Integer.MAX_VALUE]; //这里申请一个超级大数组
}

实际上我们电脑的内存是有限的,不可能无限制地使用内存来存放变量,所以说如果内存不够用了,会直接:

image-20220924165657392

此时没有更多的可用内存供我们的程序使用,那么程序也就没办法继续运行下去了,这同样是一个很严重的错误。

当然,我们这一块主要讨论的目录依然是异常。

自定义异常

异常其实就两大类,一个是编译时异常,一个是运行时异常,我们先来看编译时异常。

1
2
3
4
5
public class TestException extends Exception{
public TestException(String message){
super(message); //这里我们选择使用父类的带参构造,这个参数就是异常的原因
}
}

编译时异常只需要继承Exception就行了,编译时异常的子类有很多很多,仅仅是SE中就有700多个。

image-20220924202450589

异常多种多样,不同的异常对应着不同的情况,比如在类型转换时出错那么就是类型转换异常,如果是使用一个值为null的变量调用方法,那么就会出现空指针异常。

运行时异常只需要继承RuntimeException就行了:

1
2
3
4
5
public class TestException extends RuntimeException{
public TestException(String message){
super(message);
}
}

RuntimeException继承自Exception,Exception继承自Throwable:

image-20220924203130042

运行时异常同同样也有很多,只不过运行时异常和编译型异常在使用时有一些不同,我们会在后面的学习中慢慢认识。

当然还有一种类型是Error,它是所有错误的父类,同样是继承自Throwable的。

抛出异常

当别人调用我们的方法时,如果传入了错误的参数导致程序无法正常运行,这时我们就可以手动抛出一个异常来终止程序继续运行下去,同时告知上一级方法执行出现了问题:

1
2
3
4
5
public static int test(int a, int b) {
if(b == 0)
throw new RuntimeException("被除数不能为0"); //使用throw关键字来抛出异常
return a / b;
}

异常的抛出同样需要创建一个异常对象出来,我们抛出异常实际上就是将这个异常对象抛出,异常对象携带了我们抛出异常时的一些信息,比如是因为什么原因导致的异常,在RuntimeException的构造方法中我们可以写入原因。

当出现异常时:

image-20220924200817314

程序会终止,并且会打印栈追踪信息,因为各位小伙伴才初学,还不知道什么是栈,我们这里就简单介绍一下,实际上方法之间的调用是有层级关系的,而当异常发生时,方法调用的每一层都会在栈追踪信息中打印出来,比如这里有两个at,实际上就是在告诉我们程序运行到哪个位置时出现的异常,位于最上面的就是发生异常的最核心位置,我们代码的第15行。

并且这里会打印出当前抛出的异常类型和我们刚刚自定义异常信息。

注意,如果我们在方法中抛出了一个非运行时异常,那么必须告知函数的调用方我们会抛出某个异常,函数调用方必须要对抛出的这个异常进行对应的处理才可以:

1
2
3
private static void test() throws Exception {    //使用throws关键字告知调用方此方法会抛出哪些异常,请调用方处理好
throw new Exception("我是编译时异常!");
}

注意,如果不同的分支条件会出现不同的异常,那么所有在方法中可能会抛出的异常都需要注明:

1
2
3
4
5
6
private static void test(int a) throws FileNotFoundException, ClassNotFoundException {  //多个异常使用逗号隔开
if(a == 1)
throw new FileNotFoundException();
else
throw new ClassNotFoundException();
}

当然,并不是只有非运行时异常可以像这样明确指出,运行时异常也可以,只不过不强制要求:

1
2
3
private static void test(int a) throws RuntimeException {
throw new RuntimeException();
}

至于如何处理明确抛出的异常,我们会下一个部分中进行讲解。

最后再提一下,我们在重写方法时,如果父类中的方法表明了会抛出某个异常,只要重写的内容中不会抛出对应的异常我们可以直接省去:

1
2
3
4
@Override
protected Object clone() {
return new Object();
}

异常的处理

当程序没有按照我们理想的样子运行而出现异常时(默认会交给JVM来处理,JVM发现任何异常都会立即终止程序运行,并在控制台打印栈追踪信息)现在我们希望能够自己处理出现的问题,让程序继续运行下去,就需要对异常进行捕获,比如:

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
try { //使用try-catch语句进行异常捕获
Object object = null;
object.toString();
} catch (NullPointerException e){ //因为异常本身也是一个对象,catch中实际上就是用一个局部变量去接收异常

}
System.out.println("程序继续正常运行!");
}

我们可以将代码编写到try语句块中,只要是在这个范围内发生的异常,都可以被捕获,使用catch关键字对指定的异常进行捕获,这里我们捕获的是NullPointerException空指针异常:

image-20220924195434572

可以看到,当我们捕获异常之后,程序可以继续正常运行,并不会像之前一样直接结束掉。

注意,catch中捕获的类型只能是Throwable的子类,也就是说要么是抛出的异常,要么是错误,不能是其他的任何类型。

我们可以在catch语句块中对捕获到的异常进行处理:

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) {
try {
Object object = null;
object.toString();
} catch (NullPointerException e){
e.printStackTrace(); //打印栈追踪信息
System.out.println("异常错误信息:"+e.getMessage()); //获取异常的错误信息
}
System.out.println("程序继续正常运行!");
}

image-20220924201405697

如果某个方法明确指出会抛出哪些异常,除非抛出的异常是一个运行时异常,否则我们必须要使用try-catch语句块进行异常的捕获,不然就无法通过编译:

1
2
3
4
5
6
7
public static void main(String[] args) {
test(10); //必须要进行异常的捕获,否则报错
}

private static void test(int a) throws IOException { //明确会抛出IOException
throw new IOException();
}

当然,如果我们确实不想在当前这个方法中进行处理,那么我们可以继续踢皮球,抛给上一级:

1
2
3
4
5
6
7
public static void main(String[] args) throws IOException {  //继续编写throws往上一级抛
test(10);
}

private static void test(int a) throws IOException {
throw new IOException();
}

注意,如果已经是主方法了,那么就相当于到顶层了,此时发生异常再往上抛出的话,就会直接交给JVM进行处理,默认会让整个程序终止并打印栈追踪信息。

注意,如果我们要捕获的异常,是某个异常的父类,那么当发生这个异常时,同样可以捕获到:

1
2
3
4
5
6
7
8
public static void main(String[] args) throws IOException {
try {
int[] arr = new int[1];
arr[1] = 100; //这里发生的是数组越界异常,它是运行时异常的子类
} catch (RuntimeException e){ //使用运行时异常同样可以捕获到
System.out.println("捕获到异常");
}
}

当代码可能出现多种类型的异常时,我们希望能够分不同情况处理不同类型的异常,就可以使用多重异常捕获:

1
2
3
4
5
6
7
8
9
try {
//....
} catch (NullPointerException e) {

} catch (IndexOutOfBoundsException e){

} catch (RuntimeException e){

}

但是要注意一下顺序:

1
2
3
4
5
6
7
8
9
try {
//....
} catch (RuntimeException e){ //父类型在前,会将子类的也捕获

} catch (NullPointerException e) { //永远都不会被捕获

} catch (IndexOutOfBoundsException e){ //永远都不会被捕获

}

只不过这样写好像有点丑,我们也可以简写为:

1
2
3
4
5
try {
//....
} catch (NullPointerException | IndexOutOfBoundsException e) { //用|隔开每种类型即可

}

如果简写的话,那么发生这些异常的时候,都会采用统一的方式进行处理了。

最后,当我们希望,程序运行时,无论是否出现异常,都会在最后执行任务,可以交给finally语句块来处理:

1
2
3
4
5
6
7
try {
//....
}catch (Exception e){

}finally {
System.out.println("lbwnb"); //无论是否出现异常,都会在最后执行
}

try语句块至少要配合catchfinally中的一个:

1
2
3
4
5
6
try {
int a = 10;
a /= 0;
} finally { //不捕获异常,程序会终止,但在最后依然会执行下面的内容
System.out.println("lbwnb");
}

思考:trycatchfinally执行顺序?

断言表达式

我们可以使用断言表达式来对某些东西进行判断,如果判断失败会抛出错误,只不过默认情况下没有开启断言,我们需要在虚拟机参数中手动开启一下:

image-20220924220327591

开启断言之后,我们就可以开始使用了。

断言表达式需要使用到assert关键字,如果assert后面的表达式判断结果为false,将抛出AssertionError错误。

1
2
3
public static void main(String[] args) {
assert false;
}

比如我们可以判断变量的值,如果大于10就抛出错误:

1
2
3
4
public static void main(String[] args) {
int a = 10;
assert a > 10;
}

image-20220924220704026

我们可以在表达式的后面添加错误信息:

1
2
3
4
public static void main(String[] args) {
int a = 10;
assert a > 10 : "我是自定义的错误信息";
}

这样就会显示到错误后面了:

image-20220924220813609

断言表达式一般只用于测试,我们正常的程序中一般不会使用,这里只做了解就行了。


常用工具类介绍

前面我们学习了包装类、数组和字符串,我们接着来看看常用的一些工具类。工具类就是专门为一些特定场景编写的,便于我们去使用的类,工具类一般都会内置大量的静态方法,我们可以通过类名直接使用。

数学工具类

Java提供的运算符实际上只能进行一些在小学数学中出现的运算,但是如果我们想要进行乘方、三角函数之类的高级运算,就没有对应的运算符能够做到,而此时我们就可以使用数学工具类来完成。

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
//Math也是java.lang包下的类,所以说默认就可以直接使用
System.out.println(Math.pow(5, 3)); //我们可以使用pow方法直接计算a的b次方

Math.abs(-1); //abs方法可以求绝对值
Math.max(19, 20); //快速取最大值
Math.min(2, 4); //快速取最小值
Math.sqrt(9); //求一个数的算术平方根
}

当然,三角函数肯定也是安排上了的:

1
2
3
4
5
6
7
Math.sin(Math.PI / 2);     //求π/2的正弦值,这里我们可以使用预置的PI进行计算
Math.cos(Math.PI); //求π的余弦值
Math.tan(Math.PI / 4); //求π/4的正切值

Math.asin(1); //三角函数的反函数也是有的,这里是求arcsin1的值
Math.acos(1);
Math.atan(0);

可能在某些情况下,计算出来的浮点数会得到一个很奇怪的结果:

1
2
3
public static void main(String[] args) {
System.out.println(Math.sin(Math.PI)); //计算 sinπ 的结果
}

image-20220923231536032

正常来说,sinπ的结果应该是0才对,为什么这里得到的是一个很奇怪的数?这个E是干嘛的,这其实是科学计数法的10,后面的数就是指数,上面的结果其实就是:

  • 1.2246467991473532×10161.2246467991473532 \times 10^{-16}

其实这个数是非常接近于0,这是因为精度问题导致的,所以说实际上结果就是0。

我们也可以快速计算对数函数:

1
2
3
4
5
6
7
public static void main(String[] args) {
Math.log(Math.E); //e为底的对数函数,其实就是ln,我们可以直接使用Math中定义好的e
Math.log10(100); //10为底的对数函数
//利用换底公式,我们可以弄出来任何我们想求的对数函数
double a = Math.log(4) / Math.log(2); //这里是求以2为底4的对数,log(2)4 = ln4 / ln2
System.out.println(a);
}

还有一些比较特殊的计算:

1
2
3
4
public static void main(String[] args) {
Math.ceil(4.5); //通过使用ceil来向上取整
Math.floor(5.6); //通过使用floor来向下取整
}

向上取整就是找一个大于当前数字的最小整数,向下取整就是砍掉小数部分。注意,如果是负数的话,向上取整就是去掉小数部分,向下取整就是找一个小于当前数字的最大整数。

这里我们再介绍一下随机数的生成,Java中想要生成一个随机数其实也很简单,我们需要使用Random类来生成(这个类时java.util包下的,需要手动导入才可以)

1
2
3
4
5
6
public static void main(String[] args) {
Random random = new Random(); //创建Random对象
for (int i = 0; i < 30; i++) {
System.out.print(random.nextInt(100)+" "); //nextInt方法可以指定创建0 - x之内的随机数
}
}

结果为,可以看到确实是一堆随机数:

image-20220923234642670

只不过,程序中的随机并不是真随机,而是根据某些东西计算出来的,只不过计算过程非常复杂,能够在一定程度上保证随机性(根据爱因斯坦理论,宏观物质世界不存在真随机,看似随机的事物只是现目前无法计算而已,唯物主义的公理之一就是任何事物都有因果关系)

数组工具类

前面我们介绍了数组,但是我们发现,想要操作数组实在是有点麻烦,比如我们要打印一个数组,还得一个一个元素遍历才可以,那么有没有一个比较方便的方式去使用数组呢?我们可以使用数组工具类Arrays。

这个类也是java.util包下类,它用于便捷操作数组,比如我们想要打印数组,可以直接通过toString方法转换字符串:

1
2
3
4
public static void main(String[] args) {
int[] arr = new int[]{1, 4, 5, 8, 2, 0, 9, 7, 3, 6};
System.out.println(Arrays.toString(arr));
}

image-20220923235747731

是不是感觉非常方便?这样我们直接就可以打印数组了!

除了这个方法,它还支持将数组进行排序:

1
2
3
4
5
public static void main(String[] args) {
int[] arr = new int[]{1, 4, 5, 8, 2, 0, 9, 7, 3, 6};
Arrays.sort(arr); //可以对数组进行排序,将所有的元素按照从小到大的顺序排放
System.out.println(Arrays.toString(arr));
}

感兴趣的小伙伴可以在数据结构与算法篇视频教程中了解多种多样的排序算法,这里的排序底层实现实际上用到了多种排序算法。

数组中的内容也可以快速进行填充:

1
2
3
4
5
public static void main(String[] args) {
int[] arr = new int[10];
Arrays.fill(arr, 66);
System.out.println(Arrays.toString(arr));
}

我们可以快速地对一个数组进行拷贝:

1
2
3
4
5
6
public static void main(String[] args) {
int[] arr = new int[]{1, 2, 3, 4, 5};
int[] target = Arrays.copyOf(arr, 5);
System.out.println(Arrays.toString(target)); //拷贝数组的全部内容,并生成一个新的数组对象
System.out.println(arr == target);
}
1
2
3
4
5
6
public static void main(String[] args) {
int[] arr = new int[]{1, 2, 3, 4, 5};
int[] target = Arrays.copyOfRange(arr, 3, 5); //也可以只拷贝某个范围内的内容
System.out.println(Arrays.toString(target));
System.out.println(arr == target);
}

我们也可以将一个数组中的内容拷贝到其他数组中:

1
2
3
4
5
6
public static void main(String[] args) {
int[] arr = new int[]{1, 2, 3, 4, 5};
int[] target = new int[10];
System.arraycopy(arr, 0, target, 0, 5); //使用System.arraycopy进行搬运
System.out.println(Arrays.toString(target));
}

对于一个有序的数组(从小到大排列)我们可以使用二分搜索快速找到对应的元素在哪个位置:

1
2
3
4
public static void main(String[] args) {
int[] arr = new int[]{1, 2, 3, 4, 5};
System.out.println(Arrays.binarySearch(arr, 5)); //二分搜索仅适用于有序数组
}

这里提到了二分搜索算法,我们会在后面的实战练习中进行讲解。

那要是现在我们使用的是多维数组呢?因为现在数组里面的每个元素就是一个数组,所以说toString会出现些问题:

1
2
3
4
public static void main(String[] args) {
int[][] array = new int[][]{{2, 8, 4, 1}, {9, 2, 0, 3}};
System.out.println(Arrays.toString(array));
}

image-20220924114142785

只不过别担心,Arrays也支持对多维数组进行处理:

1
2
3
4
public static void main(String[] args) {
int[][] array = new int[][]{{2, 8, 4, 1}, {9, 2, 0, 3}};
System.out.println(Arrays.deepToString(array)); //deepToString方法可以对多维数组进行打印
}

同样的,因为数组本身没有重写equals方法,所以说无法判断两个不同的数组对象中的每一个元素是否相同,Arrays也为一维数组和多维数组提供了相等判断的方法:

1
2
3
4
5
6
public static void main(String[] args) {
int[][] a = new int[][]{{2, 8, 4, 1}, {9, 2, 0, 3}};
int[][] b = new int[][]{{2, 8, 4, 1}, {9, 2, 0, 3}};
System.out.println(Arrays.equals(a, b)); //equals仅适用于一维数组
System.out.println(Arrays.deepEquals(a, b)); //对于多维数组,需要使用deepEquals来进行深层次判断
}

这里肯定有小伙伴疑问了,不是说基本类型的数组不能转换为引用类型的数组吗?为什么这里的deepEquals接受的是Object[]也可以传入参数呢?这是因为现在是二维数组,二维数组每个元素都是一个数组,而数组本身的话就是一个引用类型了,所以说可以转换为Object类型,但是如果是一维数组的话,就报错:

image-20220924115440998

总体来说,这个工具类对于我们数组的使用还是很方便的。