随着不断学习,我们已经走完了JavaSE基础语法的所有内容,博主的单独语法篇共十二篇,感兴趣的也可以去看看,内容基本一致,目录是重新排布的,数组和方法都在初识Java章节。
适合:老手复习和新手从零开始,不过更适合新手从零开始,因为每一章涉及其他章节的知识不会强行加入,是循序渐进的,而遗留问题也会在后面章节解决,当然老手对于一些知识点也是一看便知全貌。
接下来我们开始:
文章目录
- JavaSE
- 初识Java
- 基本知识
- 注释
- 标识符
- 关键字
- 字面常量
- 基本数据类型
- 变量
- 类型转换
- 类型提升
- 初识字符串类型
- 运算符
- 语句
- 顺序结构
- 分支结构
- if
- switch
- 循环语句
- while循环
- for循环
- do-while循环
- 输入输出
- 方法
- 方法的定义
- 方法的执行
- 实参与形参
- 方法重载
- 方法签名
- 数组
- 创建与初始化
- 数组的类型
- 数组应用
- 转字符串
- 排序
- 查找(二分)
- 填充
- 拷贝
- 判等
- 二维数组
- 创建及初始化
- 遍历
- 本质和内存分布
- 不规则二维数组
- 类和对象
- 面向对象
- 类和对象的初步认识
- 类的定义
- 类的实例化
- 访问对象
- this关键字
- 构造方法
- 对象的初始化
- 封装
- 包
- 包的概念
- 导入包中的类
- 自定义包
- 包的访问权限控制
- 常用的包
- static成员
- static修饰成员变量
- static修饰成员方法
- static成员变量初始化
- 代码块
- 代码块的概念
- 普通代码块
- 构造代码块
- 静态代码块
- 不同代码块、构造方法的执行顺序
- 对象的打印
- 继承
- 继承的概念
- 继承的语法
- 继承体系下访问成员变量
- 继承体系下访问成员方法
- super关键字
- 子类构造方法
- 继承体系下的代码块
- protected关键字
- 继承方式
- final关键字
- 继承与组合
- 多态
- 多态的概念
- 多态的实现条件
- 方法的重写
- 重写与重载的区别
- 动态绑定和静态绑定
- 向上转型
- 向下转型
- 理解多态
- 抽象类
- 抽象类的概念
- 抽象类的语法
- 抽象类的特性
- 抽象类的意义
- 接口
- 接口的概念
- 接口的语法
- 接口的特性
- 接口的使用
- 实现多个接口
- 接口与多态
- 接口间的继承
- 抽象类和接口的区别
- Object类
- Comparable与Comparator接口
- Comparable接口
- Comparator接口
- Clonable接口与深拷贝
- 内部类
- 静态内部类
- 实例内部类
- 匿名内部类
- 局部内部类
- String类
- String的构造及内存分布
- 构造
- 内存分布
- 常用方法
- 判等
- 比较
- 查找
- 转化
- 替换
- 拆分
- 截取
- 字符串的不可变性
- StringBuilder和StringBuffer
- 异常
- 异常的概念
- 异常的体系结构
- 异常的分类
- 异常的抛出
- 异常的处理
- throws
- try-catch
- finally
- 异常的处理流程
- 自定义异常类
JavaSE
初识Java
基本知识
Java的源文件后缀名.java
,经过Java编译器javac
转化为 .class
的字节码文件(二进制文件),然后将在JVM
中(java虚拟机)运行java代码。
安装JDK
时就会安装好JVM
,JDK
中有JRE
,JRE
中有JVM
。
JDK
: java的开发者工具
JRE
:java运行时环境
JVM
:java虚拟机
Java的第一个代码——hello world
public class Test{
public static void main(String[] args){//注意左括号的位置,这是Java的书写风格
System.out.println("hello world");
}
}
public class
的书写较固定。Test
是类名【采用大驼峰命名方式】,被public
修饰的类名必须和文件名相同。args
就是数组,与C语言常用的格式不同,C语言中int args[10]
就是创建了一个数组,元素个数是10,args
的类型是int[10]
类型,所以C语言的常用写法是不够严谨的,java中对C语言这种不规范的写法进行了修正:String[] args
,这是创建了一个字符串数组。- java的风格规定:左括号在当前行,即便换行后也不会报错。
注释
Java的注释主要有以下三种:
- 单行注释:
//注释内容
(最常用) - 多行注释:
/*注释内容*/
(不推荐) - 文档注释:
/**文档注释*/
(常见于方法和类之上,用来描述方法和类的作用),我们可以使用javadoc
工具解析,生成一套以网页文件形式体现的程序说明文档)
注意:
- 多行注释不能嵌套使用
- 多行注释中可以嵌套单行注释
- 不论是单行注释还是多行注释,都不参与编译,即编译之后生成的
.class
文件中不包含注释信息
对于单行注释和多行注释我们在C语言阶段都了解过了,所以我们这里重点说一下文档注释:
如下,我们书写了一段文档注释:
/**
文档注释:
@version v1.0.0
@author will
作用HelloWorld类,入门第一个程序练习
*/
public class blog {
public static void main(String[] args) {
System.out.println("hello world");
}
}
在代码所在路径,打开cmd控制台,然后输入如下命令:
javadoc -d blog -author -version -encoding UTF-8 -charset UTF-8 blog.java
执行后,回到路径:
我们发现路径中多了一个文件夹,打开箭头所指的文件:
这便是上面提到的以网页文件形式体现的程序说明文档
标识符
public class Test{
public static void main(String[] args){
System.out.println("hello world");
}
}
前面提到,在这段代码中,Test
称为类名,main
称为方法名,其实它们就是标识符,即:在程序中由用户给类名、方法名或变量名所取的名字。
-
硬性规则:
标识符中可以包含
字母
、数字
、下划线(_)
、$
标识符不能以数字开头,也不能是关键字,并且大小写敏感(严格区分大小写)
-
软性建议:
类名:采用大驼峰命名方式方法名:首字母小写,后面的每个单词的首字母大写(小驼峰)
变量名:与方法名建议相同
关键字
具有特殊含义的标识符称为关键字,比如,上面代码中的不同颜色的public
、class
等,都是关键字。
关键字是由Java语言提前定义好的,有特殊含义的标识符或者保留字。
字面常量
常量即程序运行期间,固定不变的量称为常量
我们的写的第一个Java代码中的 “hello world” 就是字面常量。
public class Constant {
public static void main(String[] args) {
System.out.println(10);
System.out.println(12.5);
System.out.println("hello world");
System.out.println(true);
System.out.println('a');
}
}
上面的代码中打印的都是常量,只是类型不同而已。
基本数据类型
Java中数据类型主要分为两类:基本数据类型和引用数据类型
基本数据类型有四大类:
- 整型:
int
、long
、short
、byte
- 浮点型:
float
、double
- 字符型:
char
- 布尔型:
boolean
注意:
-
不论是在16位系统还是32位系统,
int
都占 4 个字节,long
都占 8 个字节 -
整型和浮点型都是带有符号的
-
整型默认是
int
类型;浮点型默认是double
类型如果我们期望它是
float
类型,我们要在数字后面加上一个f
或F
,这样就不会报错了。
其中大部分在C语言学习中见过并且使用过。具体的数据类型知识将在变量部分介绍。
变量
前面介绍了常量,它们在程序运行中始终不变。而在程序中有些内容可能会经常改变,比如:商品的价格、人的年龄等,对于这些经常改变的内容,在Java程序中,称为变量。上面简单提到的数据类型就是来定义不同种类的变量的。
定义变量的语法格式
数据类型 变量名 = 初始值;
比如:
int a = 10;
double d = 12.5;
整型变量
我们用int
定义变量时,要注意:
int
不论在何种系统下都是4个字节- 使用变量前,要对其进行赋值,如果没有赋值,编译报错,十分严格
- 给变量赋值时,不能超过
int
的表示范围,否则会导致溢出,直接报错 int
的包装类型是Integer
长整型变量
我们用long
定义变量时,要注意:
long
不论在何种系统下都是8个字节- 长整型变量的初始值后(或赋值)加
l
或L
,便于区分,不过不是强制的。 long
的包装类型是Long
短整型变量
我们用short
定义变量时,要注意:
short
不论在何种系统下都是2个字节short
的包装类型是Short
字节型变量
这是我们C语言阶段没有见过的,使用时注意:
byte
不论在何种系统下都是1个字节byte
的范围是:-128~127
byte
的包装类型是Byte
双精度浮点型
我们使用double
定义变量时,注意:
-
double
不论在何种系统下都是8个字节 -
double
类型的内存布局遵循 IEEE 754 标准(与C语言一样),会存在一定的误差,因此浮点数是一个近似值,并不是精确值 -
double
的包装类型是Double
单精度浮点型
注意:
float
不论在何种系统下都是4个字节- 遵守 IEEE 754标准
- 赋值时,值的后面加上
f
或F
,因为Java中的浮点型默认是double
类型的
字符型变量
-
使用 单引号 + 单个字符 的形式表示字符字面值
char ch = 'A'; char ch1 = '1'; char ch2 = '我';
-
计算机的字符本质上是一个整数,所以java中的字符可以存放整型
-
C语言中使用
ASCII
表示字符,而Java中使用Unicode
表示字符,一个字符占用2个字节,表示的字符种类更多,包括中文
布尔型变量
-
布尔型变量只有两种可能的取值:
false
或true
,分别表示假和真 -
使用
boolean
来定义变量:boolean ret = false;
-
Java的
boolean
类型不能和int
类型相互转换,不存在 1 表示 true,0 表示 false 的用法(一定要和C语言区别开) -
Java虚拟机规范中,并没有明确规定
boolean
类型占几个字节,也没有专门用来处理boolean
的字节码指令,在Oracle
公司的虚拟机实现中,占1个字节
类型转换
Java作为一个强类型编程语言,当不同类型之间的变量 互相赋值 的时候,会有严格的校验。
在Java中,当参与运算数据类型不一致时,就会进行类型转换。Java中类型转换主要分为两类:自动类型转换(隐式)和 强制类型转换(显式)。
自动类型转换
代码不需要经过任何处理,在代码编译时,编译器会自动进行处理。特点: 数据范围小的转化为数据范围大的时会自动进行。
例如:
int a = 100;
long b = 120L;
b = a;//编译不报错,b是long类型,a是int类型,小转大,编译器将a自动提升至long类型,然后赋值
a = b;//编译报错,大转小
我们知道,整数默认类型是int
类型,那么我们给byte
类型的变量赋值时,只要数值不超过byte
的表示范围,编译器就会将整数转换为byte
类型。
byte a = 100;//没有超过byte的范围,编译器将100转换为byte
byte b = 800;//编译失败,800超过了byte的数据范围,有数据丢失
强制类型转换
当进行操作时,代码需要经过一定的格式处理,不能自动完成。特点: 数据范围大的到数据范围小的。
以上面的代码为例:
int a = 100;
long b = 120L;
b = a;//自动转换
a = b;//编译失败
a = (int)b;//long->int,数据范围由大到小,强制类型转换,否则编译失败
- 不同数字类型的变量之间赋值,表示范围小的类型能够隐式转换为范围较大的类型
- 如果需要把范围大的类型赋值给范围小的类型变量,需要强制类型转换,不过可能会出现精度丢失
- 将一个字面值常量进行赋值的时候,Java会自动针对数字范围进行检查
- 强制类型转换不一定能成功,不相干的类型不能相互转换,例如
boolean
类型就不可以与其他类型转换。
类型提升
不同类型数据之间 相互运算 时,数据类型小的会被提升到数据类型大的。
两种情况:
int
与long
之间:int
会被提升为long
类型(小转大)
int a = 10;
long b = 20;
int c = a + b;//编译出错,int与long相加,int会类型提升至long类型,所以其实是两个long类型相加,表达式结果也需要用一个long类型的变量接收。(当然也可以强制类型转换,不过有些情况下会出现精度丢失)
long d = a + b;//编译成功
byte
与byte
的运算(不足4字节的提升至4字节类型)
byte a = 10;
byte b = 20;
byte c = a + b;//编译出错,原因:
//虽然byte和byte是相同类型,但是计算时a和b会被提升至int类型,所以需要一个int类型的变量接收。
int d = a + b;//编译成功!
byte
会被提升至int
,原因是:计算机的CPU通常是按照4个字节为单位从内存中读写数据,这样,硬件上实现就会方便。
总结:
- 不同类型的数据混合运算,范围小的会提升成范围大的
- 对于
short
、byte
这种比4个字节小的类型,会先提升成4个字节的int
,再运算
初识字符串类型
与C语言不同,Java中使用String
类定义字符串类型,这里仅作简单介绍,本文String类
章节会详细讲解,不过建议按照文章顺序慢慢来,因为String类
的学习需要前面章节的铺垫。
比如:
public static void main(String[] args) {
String str1 = "hello ";
String str2 = "world!";
System.out.println(str1);//直接打印字符串str1
System.out.println(str2);//直接打印字符串str2
System.out.println(str1 + str2);//字符串拼接
}
其中字符串的拼接操作相较于C语言是很方便的。
字符串与整型的转化
int
转成String
int num = 100;
//方法一
String str1 = num + "";
//方法二
String str2 = String.valueOf(num);
System.out.println(str1);
System.out.println(str2);
String
转成int
String str = "100";
//方法一
int num = Integer.valueOf(str);
//方法二
int num1 = Integer.parseInt(str);
System.out.println(num);
System.out.println(num1);
注意:Java字符串中末尾没有\0
字符串类型就简单介绍到这里。
运算符
Java中的大部分运算符及规则与C语言相同,我们这里主要介绍特别注意的点:
所以先看一张图:
注意(与C语言不同的,或者是比较容易忘记的,特别注意的):
-
Java中不存在 1 和 0 表示 真 和 假 的规则
-
Java的
/
和%
运算符的右操作数不能为0
-
Java可以对浮点型执行
%
运算符,但是没有意义,一般都是对整数取模。(与C语言不同)public static void main(String[] args) { double d = 11.5; System.out.println(d % 2); }
-
对于
<
等 关系运算符,不能连续使用。(与C语言一致,Python允许) -
Java中的逻辑运算的结果都是
boolean
类型,左右表达式也必须是boolean
类型的结果 -
Java中的位运算符
&
和|
能够分别代替逻辑运算符&&
和||
,不过,用于逻辑判断的&
和|
不存在短路规则public static void main(String[] args) { int a = 10; int b = 20; boolean b1 = a < b || 10 / 0 == 0;// || 存在短路,a>b为true,后面的10/0==0不进行计算,10/0不会报错 boolean b2 = a < b | 10 / 0 ==0;// | 不存在短路,a>b为true,后面的10/0==0进行计算,由于/的右操作数不能为0,程序报错 }
-
最开始的图片忽视了位运算符。位运算符有
^
,&
,|
,~
,规则与C语言一致,注意&
和|
的逻辑用法。 -
移位运算符有:
<<
、>>
、>>>
。>>
就是右移,最左边补符号位;>>>
是无符号右移,最左边补0,注意,相应的,也会有>>>=
语句
顺序结构
比较简单,就是按照代码顺序逐条执行。
分支结构
if
if (布尔表达式){
//语句
}
if (布尔表达式){
//语句
}else{
//语句
}
if (布尔表达式){
//语句
} else if{
//语句
} else{
//语句
}
Java的if
语句与C语言的差别不大,不过,笔者总结了几点需要注意的问题:
-
Java的代码书写风格
-
悬垂
else
问题:else
与离它最近的还未被匹配的if
语句匹配,不是看对齐。 -
Java中不存在 1 和 0 表示 真 和 假 的规则,所以
if
括号里的必须是布尔表达式,其计算结果为布尔类型,举个例子:
C语言中
if
永真的简单写法:if(1) { }
Java不能像C语言那样写,遵循Java的代码风格以及规则,我们这么写:
if(true){ }
讲了这么多,我们介绍一下Java分支语句的代码风格:
我们随便写一块代码,判断一个整数的正负性:
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
int num = scanner.nextInt();
if(num > 0){
System.out.println("是正数");
}else if(num < 0){
System.out.println("是负数");
}else{
System.out.println("为0");
}
}
- 左花括号不换行
else
和else if
不换行
if
括号里必须是布尔表达式不必多说。
switch
switch (表达式){
case 常量值1:{
//语句1
[break;]
}
case 常量值2:{
//语句2
[break;]
}
……
default:{
//上面情况都不满足执行的语句
[break;]
}
}
执行流程
- 先计算表达式的值
- 与
case
依次比较,一旦有对应的匹配就执行该项下的语句,直到break
或switch
末尾结束。 - 当表达式的值没有与
case
项匹配,执行default
下面的语句
Java的switch
语句与C语言差别也不大:
switch
后面的表达式不能是:long
、boolean
、float
、double
类型,也不能表达复杂的条件。- 多个
case
后面的常量值不可以重复 - 注意Java的代码风格
switch
括号里只能是以下类型的表达式:- 基本类型:
int
、byte
、short
、char
- 引用类型:
String常量值
、枚举类型
- 基本类型:
循环语句
while循环
while (循环条件){
循环语句;
}
循环条件为true
,则执行循环语句,否则结束循环。
注意:
- Java中不存在 1 和 0 表示 真 和 假 的规则,所以循环条件必须是布尔表达式,计算结果是
boolean
类型 - 注意Java的代码风格
for循环
for (表达式1; 布尔表达式2; 表达式3){
表达式4;
}
- 表达式1:用于初始化循环变量初始值设置,在循环最开始时执行,且只执行一次
- 布尔表达式2:循环条件,满足则循环继续,否则循环结束
- 表达式3:循环变量的更新方式
- 表达式4:循环体语句
仍然注意布尔表达式和代码风格的问题,其他的于C语言一致。
do-while循环
do {
循环语句;
}while (循环条件);
先执行循环语句,再判定循环条件,条件成立循环继续,否则循环终止,所以do-while
的循环语句至少被执行一次。
do-while
循环后的;
不要忘记- 循环条件是布尔表达式,表达式的结果是
boolean
类型 - 代码风格
输入输出
输出到控制台
Java中的输出主要有三种:
public static void main(String[] args) {
System.out.println("第一种");
System.out.print("第二种");
System.out.printf("第三种");
}
println
最常用,特点是打印后自动换行print
与println
唯一的差别是:print
不自动换行printf
,格式化输出,与C语言的printf
用法一致
从键盘输入
看一段代码,从键盘读取一个整数并打印:
import java.util.Scanner;//导入包
public class BlogShow {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);//相对固定的写法
int num = scanner.nextInt();//定义变量接收
System.out.println(num);
}
}
我们看到,Java从键盘输入相比于C语言麻烦,需要3行语句
初学阶段,很多涉及到的语法,如类、导入包等知识没有详细讲解,后面笔者会陆续发文介绍,由浅入深,慢慢从初学到熟悉再到精通!
方法
什么是方法?
方法就是一个代码片段,Java的方法类似于C语言的函数,方法的意义在于:
- 能够模块化地组织代码
- 能偶做到代码被重复使用,而不需要重新写一份,一份代码可以在多个位置使用
- 便于理解代码,增加代码可读性
方法的定义
方法的语法格式
修饰符 返回值类型 方法名称(参数类型 形参……){
方法具体代码;
[return 返回值];
}
与C语言对比,Java的方法定义多了修饰符,且代码风格规定花括号不换行。
我们实现一个简单的两个整数的加法方法:
public static int add(int x, int y){
return x + y;
}
注意:
- Java学习初期,修饰符固定写
public static
即可 - 返回值类型必须与返回的实体类型一致,如果没有返回值,返回类型处写成
void
- 方法命名建议采用小驼峰命名方式,即从第二个单词开始,首字母大写,例如
sumOfScore
- 在Java中方法必须写在类中
- Java中方法不能嵌套定义
- Java中没有方法声明一说,如果将方法的定义写在了调用前,也是没问题的
方法的执行
方法的调用过程:
调用方法——>传递参数——>找到方法地址——>执行被调方法的方法体——>被调方法结束返回——>回到主调方法继续往下执行
实参与形参
与C语言一样,形参只是方法在定义时需要借助的一个变量,用来保存方法在调用时传递过来的值。
在Java中,实参永远都是拷贝到形参中,形参和实参本质是两个实体
以交换两数为例:
public static void swap(int x, int y){
int tmp = x;
x = y;
y = tmp;
}
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
int a = scanner.nextInt();
int b = scanner.nextInt();
System.out.println("交换前" + "a:" + a + " " + "b:" + b);
swap(a, b);
System.out.println("交换后" + "a:" + a + " " + "b:" + b);
}
【分析】
实参a和b是main方法中的两个变量,其空间在main方法的栈中,而形参x和y是swap方法中的两个变量,x和y的空间在swap方法运行时的栈中,因此:a和b与x和y是没有任何关联的。
在swap方法调用时,只是将实参a和b中的值拷贝了一份传递给了形参x和y,所以对形参x和y的操作对实参a和b没有影响。
对于基础类型来说,形参相当于实参的拷贝,即传值调用。那么我们怎么使用方法来交换两个变量的值呢?
传引用类型参数(例如数组):
public static void swap(int[] array){
int tmp = array[0];
array[0] = array[1];
array[1] = tmp;
}
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
int[] array = new int[2];
array[0] = scanner.nextInt();
array[1] = scanner.nextInt();
System.out.println("交换前:" + array[0] + " " + array[1]);
swap(array);
System.out.println("交换前:" + array[0] + " " + array[1]);
}
方法重载
用一种情景引入:
public static int add(int x, int y) {
return x + y;
}
如上,我们实现了一个整数加法方法。如果我们这时候想求两个double
类型数据的和,上面的方法不适用了,我们该怎么办?
首先想到的是再实现一个方法,针对double
类型,这确实可以解决问题,麻烦的一点是,我们需要提供许多不同的方法名,调用时还需要考虑调用哪个,而这些方法的逻辑都是相加。针对这个问题,Java提供了方法重载,允许将上述方法名都写成同一个,例如add
方法重载的概念
在Java中,如果多个方法的名字相同,参数列表不同,则称该几种方法被重载了。
【代码示例】
public static int add(int x, int y) {
return x + y;
}
public static double add(double x, double y){
return x + y;
}
public static int add(int x, int y, int z){
return x + y + z;
}
public static void main(String[] args) {
System.out.println(add(10, 20));
System.out.println(add(1.5, 2.5));
System.out.println(add(10, 20, 30));
}
没有报错,结果也没有问题。
在IDEA上也会有相应的提示。
什么情况下叫方法的重载?
- 方法名一样
- 参数列表不一样,参数的【个数、类型、顺序】
- 与返回值无关(也就是说,只有返回值不一样,两个方法不能称为构成重载)
- 这样的若干个方法互为方法的重载
编译器在编译代码时,会对实参类型进行推演,根据推演的结果来确定调用哪个方法。
方法签名
在同一个作用域中不能定义两个相同的标识符。那么为什么类中就可以定义方法名相同的方法呢?
因为有方法签名,重载方法的函数名虽然相同,但是它们的方法签名不同。
方法签名即,经过编译器编译修改后的最终的名字。完整名字包含:
方法全路径名 + 参数列表 + 返回值类型
我们可以使用JDK自带的javap反汇编工具查看,具体操作:
- 在源代码目录打开
cmd
- 使用
javac
编译生成.class
文件,如果源代码有汉字,那么要统一编码 - 输入:
javap -v 字节码文件名
完成后寻找签名:
-
work
是类名 -
add
是方法名 -
括号里的内容代表方法的参数列表信息
-
括号后面代表返回值信息
-
如图,
I
表示int
类型,D
表示double
类型,更多的信息如下:
数组
学过C语言的小伙伴应该对数组了解不少也使用了很多吧,接下来我们介绍一下Java中的数组。
创建与初始化
数组的创建
C语言中,创建一个数组通常这么写:
int array[10];
C语言形式有以下特点:
- 数组名在方括号的左边
- 如果不初始化,方括号中必须给定数组大小,且必须是整型常量
array是数组名,类型认为是int[10]
,这与我们创建变量的规则类型 + 变量名
不符。所以,Java不倾向于这样写。
Java创建一个数组有以下常见写法:(注意事项均在注释中给出)
int array1[];//与C语言的风格类似,不过Java中的[]中不能写数字,要是空的,否则报错
int[] array2;//第三种的简写版,符合 类型+变量名 的习惯,[]中仍然不能写数字,否则报错
int[] array3 = new int[10];//看起来最难懂的,是第二种的完整版,前面的[]必须为空,后面的[]中不能为空,且这种写法不能直接花括号初始化,具体怎么初始化见数组的初始化的内容。
数组的初始化
常见的初始化方式有三种:
int array1[] = {1, 2, 3};
int[] array2 = {1, 2, 3};
int[] array3 = new int[]{1, 2, 3};
- 前两种初始化都是基于创建的前两种进行初始化的
- 第三种初始化与上面第三种创建方式相似,需要注意的是:
- 第二个
[]
必须为空 - 第二个方括号后不需要加
=
,直接花括号初始化即可
- 第二个
了解到这,我们看一下第三种的错误写法,这里很易混
int array[] = new int[3] = {1, 2, 3};//ERROR,错误的,第二个方括号必须是空
int array[] = new int[];//ERROR,错误的,没有初始化,第二个方括号不能为空
如果我们没有对数组进行初始化,数组中的元素有其默认值:
-
如果数组中存储的是基本类型,默认值如下表:
-
如果数组中元素存储的是引用类型,默认值是
null
不过,在Java中也允许使用C语言的书写方式,这并不会报错
数组的使用
【数组元素的访问】
与C语言一样,Java中的数组是一块连续的空间,空间的编号都是从0开始,该编号称为数组的下标,通过下标就可以访问数组元素了,所以对于有5个元素的数组,它的元素下标从0 ~ 4。
public static void main(String[] args) {
int[] array = new int[]{1, 2, 3, 4, 5};
System.out.println(array[0]);
System.out.println(array[1]);
System.out.println(array[2]);
System.out.println(array[3]);
System.out.println(array[4]);
}
【数组的遍历】
我们介绍三种数组遍历的方式,它们均能打印出所有的数组元素。
int[] array = new int[]{1, 2, 3, 4, 5};
//1.for循环遍历
for (int i = 0; i < array.length; i++) {
System.out.print(array[i] + " ");
}
//2.foreach循环遍历,增强型for循环
for (int x:array) {
System.out.print(x + " ");
}
//3.导入包,使用 Arrays.toString
import java.util.Arrays;
System.out.println(Arrays.toString(array));
我们看到三种方式都将数组元素打印出来了。
-
我们使用
for
循环打印时,要考虑循环次数,就是数组容量大小,Java中不存在C语言的sizeof
,Java中通过数组对象.length
获取数组长度,如上面的代码所示。 -
foreach
更方便,不过功能没有for
循环强大。for
后面的括号中有一个:
,它的右边是集合的名称,左边是集合元素类型+变量名(自定义的)。不过,在需要使用到下标(索引)的情景下,foreach
循环就无能为力了。 -
第三种方式是将数组元素转换成字符串类型,然后进行打印,打印的格式是固定如此的。
数组的类型
数组是引用类型,引用类型不直接存储数据值,而是存储对数据的引用的类型。
说到引用类型,不得不先了解JVM的内存划分:
我们发现这与C语言的内存划分不同。
我们这里仅理解 运行时数据区 的 虚拟机栈 和 堆 即可。
-
虚拟机栈: 就是我们C语言学习过程中常说的栈:
存储与方法调用相关的信息,每个方法在执行时,都会先创建一个栈帧,栈帧中包含:局部变量表、操作数栈、动态链接、返回地址等其他信息。比如局部变量。当方法运行结束后,即栈帧中保存的数据也会被销毁
-
堆: 与C语言的堆不同,C语言动态开辟的内存都是在堆上,而Java的堆:
JVM所管理的最大内存区域,使用
new
创建的对象都是在堆上保存,堆是随着程序开始运行而创建,随着程序的退出而销毁,堆中的数据只要还有在使用,就不会被销毁
引用数据类型创建的变量,一般称作对象的引用,其空间中存储的是对象所在空间的地址。
我们观察一个代码:
public static void main(String[] args) {
int[] array = new int[]{1, 2, 3, 4, 5};
System.out.println(array);
}
打印结果如下:
对于这个打印结果:
[
表示 引用变量array指向的是一个数组对象I
表示其指向的数组对象的每个元素都是int
类型@
是分割符1b6d3586
可以理解为 “地址”
看下面段代码:
public static void Test(){
int a = 10;
int b = 20;
int[] array = new int[]{1, 2, 3, 4, 5}
}
调用Test
函数时,会为Test
方法在栈上创建一个栈帧,Test
栈帧内创建了局部变量a
、b
、array
,由于a
、b
都是基本类型,所以在栈区存储的就是其数据,array
是引用类型,所以它存储的是指向数组对象的"地址",引用变量array
创建时,同时会在堆区开辟一块空间,这块空间存储数组对象的数据。array
变量存储的"地址"指向堆上指定位置的数组对象,我们常说:“引用指向了对象”。
再看一段代码:
public static void main(String[] args) {
int[] array1 = new int[]{1, 2, 3, 4, 5};
int[] array2 = new int[]{6, 7, 8, 9, 0};
array1 = array2;
}
这段代码中array1 = array2
的效果是什么?这是引用变量的相互赋值。
效果是:array1
引用指向了array2
引用指向的对象。
我们通过代码验证一下:
public static void main(String[] args) {
int[] array1 = new int[]{1, 2, 3, 4, 5};
int[] array2 = new int[]{6, 7, 8, 9, 0};
array1 = array2;
//赋值后打印验证
System.out.println(Arrays.toString(array1));
System.out.println(Arrays.toString(array2));
System.out.println(array1);
System.out.println(array2);
}
引用指向对象,我们可以将一个引用的值赋给另一个引用,就是 “一个引用指向了另一个引用指向的对象”,这时候,其中一个对象没有引用指向它了,Java会自动检测没有引用指向的对象,它在堆上的内存会被自动回收。
空引用
null
就是空引用,我们可以给引用变量初始化为空引用。
null
就是不指向任何对象的引用,与C语言的NULL
类似,都是指一块无效的内存地址。
数组应用
我们这里介绍的是Java内部实现好的功能(类的成员方法),对于数组的这些功能,我们导入包中的Arrays
类,便可以直接使用了。
转字符串
介绍遍历数组时我们已经介绍了这一应用:
Arrays.toString()
传入数组即可。
public static void main(String[] args) {
int[] array = new int[]{1, 2, 3, 4, 5};
System.out.println(Arrays.toString(array));
}
验证转换后的结果是String
类型,我们用一个String
类型的变量接收,不会报错。
排序
Arrays.sort()
public static void main(String[] args) {
int[] array = new int[]{9, 8, 7, 6, 5, 4, 3, 2, 1, 0};
System.out.println("排序前:");
System.out.println(Arrays.toString(array));
System.out.println("排序后:");
Arrays.sort(array);
System.out.println(Arrays.toString(array));
}
- 此方法默认排升序
查找(二分)
Arrays.binarySearch()
public static void main(String[] args) {
int[] array = new int[]{1, 2, 3, 4, 5};
int ret = Arrays.binarySearch(array, 2);
System.out.println("查找后的下标为: " + ret);
}
当然,Java中还重载了很多的有关二分查找的类方法,例如:不同类型数据的查找、指定范围内查找
我们再介绍一下指定范围内查找的方法:
public static void main(String[] args) {
int[] array = new int[]{1, 2, 3, 4, 5};
int ret = Arrays.binarySearch(array, 0, 2, 3);
System.out.println("在下标0~1之间查找:" + ret);
}
这里我们介绍两点:
-
指定范围内查找,参数有4个,分别是数组、开始点、结束点、待查找值
值得注意的是,按指定区间查找都是[)
的,如果我们想在下标为1~3的区间查找,我们传入的起始值分别要是:1、4 -
为什么返回值是-3?
首先-3肯定代表查找失败,此方法内部实现的查找失败的返回值是:
return -(low + 1)
,low
就是我们自己实现二分的left
填充
Arrays.fill()
public static void main(String[] args) {
int[] array = new int[10];
System.out.println("填充前: " + Arrays.toString(array));
Arrays.fill(array, 5);
System.out.println("填充后: " + Arrays.toString(array));
}
当然,我们也可以在指定范围内填充,传入的起始点也是遵循[)
的
public static void main(String[] args) {
int[] array = new int[10];
System.out.println("填充前: " + Arrays.toString(array));
Arrays.fill(array, 1, 5, 5);
System.out.println("填充后: " + Arrays.toString(array));
}
拷贝
Arrays.copyOf()
这个类的方法有两个作用:1. 拷贝 2. 扩容
另外,指定范围内拷贝:
Arrays.copyOfRange()
【拷贝功能】
public static void main(String[] args) {
int[] array1 = new int[]{1, 2, 3, 4, 5};
int[] array2 = new int[5];
System.out.println("拷贝前:" + Arrays.toString(array2));
array2 = Arrays.copyOf(array1, 5);
System.out.println("拷贝后:" + Arrays.toString(array2));
}
【扩容功能】
public static void main(String[] args) {
int[] array1 = new int[]{1, 2, 3, 4, 5};
int[] array2 = new int[5];
System.out.println("扩容前:" + Arrays.toString(array2));
array2 = Arrays.copyOf(array1, 10);
System.out.println("扩容后:" + Arrays.toString(array2));
}
【指定范围内拷贝】
public static void main(String[] args) {
int[] array1 = new int[]{1, 2, 3, 4, 5};
int[] array2 = new int[5];
System.out.println("拷贝前:" + Arrays.toString(array2));
array2 = Arrays.copyOfRange(array1, 1, 3);
System.out.println("拷贝下标1~2的元素后:" + Arrays.toString(array2));
}
此外,还有一种数组拷贝的方法:
System.arraycopy()
public static void main(String[] args) {
int[] array1 = new int[]{1, 2, 3, 4, 5};
int[] array2 = new int[5];
System.out.println("拷贝前:" + Arrays.toString(array2));
System.arraycopy(array1, 0, array2, 0, 3);
System.out.println("拷贝后:" + Arrays.toString(array2));
}
System.arraycopy(array1, 0, array2, 0, 3);
语句的含义是:从array1
数组的 0 下标位置开始拷贝 3 个元素到 array2
数组,从 0 位置开始。
- 四个参数:待拷贝数组、拷贝起始位置、待存放数组、存放的起始位置、拷贝的元素个数
判等
Arrays.equal()
public static void main(String[] args) {
int[] array1 = new int[]{1, 2, 3, 4, 5};
int[] array2 = new int[]{1, 2, 3, 4, 5};
System.out.println(Arrays.equals(array1, array2));
}
我们判断两个数组相等不能简单的使用==
实现,因为数组名存放的是"地址"。
我们可以调用这样一个方法实现判等操作。
二维数组
二维数组本质上就是一维数组的数组,其每一个元素都是一维数组
创建及初始化
//创建方法一 :规则与一维数组一样,后两个[]内必须有内容,不能为空
数据类型[][] 数据类型 = new 数据类型[行数][列数];//默认都是0
//创建方法一并初始化 :此时所有的[]都必须为空
数据类型[][] 数组名称 = new 数据类型[][]{初始化};
//创建方法二 :第一种的简写
数据类型[][] 数组名称;
//创建方法二并创建 :
数据类型[][] 数组名称 = {初始化};
我们继续将Java与C语言对比:
-
语句开始的两个
[]
中不能添加内容 -
C语言中定义二维数组时可以省略行数,而Java中可以省略列数。
这里针对的是第一种创建方式并初始化的情况,后两个
[]
,这里有一个知识点,就是不规则数组 -
由于C语言不可省略列数,所以初始化时可以在
{}
中直接罗列数据,C语言会帮我们区分行列;而Java中不允许,必须在{}
内部再加{}
来手动区分行数和列数
遍历
双循环遍历即可打印
public static void main(String[] args) {
int[][] array = new int[][]{{1, 2}, {3, 4}, {5, 6}};
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 2; j++) {
System.out.print(array[i][j] + " ");
}
System.out.println();
}
}
利用Arrays.deepToString()
public static void main(String[] args) {
int[][] array = new int[][]{{1, 2}, {3, 4}, {5, 6}};
System.out.println(Arrays.deepToString(array));
}
foreach
循环
public static void main(String[] args) {
int[][] array = new int[][]{{1, 2}, {3, 4}, {5, 6}};
for (int[] arraytmp : array) {
for (int data : arraytmp) {
System.out.print(data + " ");
}
System.out.println();
}
}
现在有两个问题:
-
foreach
遍历虽然不需要行数和列数,但是它是有局限性的。如果我们采用第一种方式遍历,我们怎么拿到二维数组的行数和列数呢?每次都要自己数吗?当然不用,有办法,就像遍历一维数组时的
array.length
-
deepToString
能遍历我了解了,前面一维数组介绍的toString
不行吗?public static void main(String[] args) { int[][] array = new int[][]{{1, 2}, {3, 4}, {5, 6}}; System.out.println(Arrays.toString(array)); }
想要真正理解上面的问题,我们得了解二维数组的本质和内存分布
本质和内存分布
二维数组就是一个特殊的一维数组,存储的元素是一维数组的"地址",前面toString
打印结果我们都已经见过了。
每一个"地址"都能找到特定的一维数组。
public static void main(String[] args) {
int[][] array = new int[][]{{1, 2}, {3, 4}, {5, 6}};
System.out.println(array);
System.out.println(Arrays.toString(array));
}
对于第一行打印:
[[
指二维数组I
指每个元素是int
类型@
是分隔符- 后面的是"地址"
- 所以,二维数组也是一个引用变量,指向了一个数组对象,数组中存放的是引用变量,数组元素数量是行数,每个引用变量指向了一个数组对象,数组中存放的是
int
类型
根据上述,我们可以画出内存分布图:
图中的地址都是随便给出的,这不重要。
了解到这,我们可以写出第一种遍历方式的通写形式了:
public static void main(String[] args) {
int[][] array = new int[][]{{1, 2}, {3, 4}, {5, 6}};
for (int i = 0; i < array.length; i++) {
for (int j = 0; j < array[i].length; j++) {
System.out.print(array[i][j] + " ");
}
System.out.println();
}
}
外层循环使用array.length
就是行数,内层循环使用array[i].length
是列数
不规则二维数组
我们知道,Java中的二维数组的列数可以省略,行数不可以省略,例如:
int[][] array = new int[2][];//不清楚列数
这时候,我们尝试遍历:
public static void main(String[] args) {
int[][] array = new int[2][];
for (int i = 0; i < array.length; i++) {
for (int j = 0; j < array[i].length; j++) {
System.out.println(array[i][j] + " ");
}
System.out.println();
}
}
抛出了"空指针异常"(Java中没有指针,习惯这么讲),此时没有初始化列:
我们如果想要使用,必须对列初始化,这就能体现出不规则了。
根据上图,二维数组的每个元素(引用类型)为null
,说明不指向任何对象。
public static void main(String[] args) {
int[][] array = new int[2][];
array[0] = new int[3];
array[1] = new int[5];
System.out.println(Arrays.deepToString(array));
}
每一行的列数不必相等,这就体现了不规则
进行到这,我们二维数组的两个元素指向了各自的数组对象,此时可以打印了:
其实,我们也可以通过初始化数组体现不规则:
public static void main(String[] args) {
int[][] array = new int[][]{{1, 3}, {5, 6, 7}};
System.out.println(Arrays.deepToString(array));
}
类和对象
之前我们学习过Java的基本知识、方法和数组。但是有不少遗留问题,包括调用数组类的方法对数组进行方便的操作。
面向对象
Java是一门面向对象的语言(OOP),在Java中,一切皆对象。面向对象是解决问题的一种思想,注重对象之间的交互。
以洗衣服为例,面向过程(如C语言)会关注洗衣服的过程:
拿盆子——>放水——>放衣服——>放洗衣粉——>…——>晾衣服
而面向过程,关注的就是不同的对象:
人、衣服、洗衣粉、洗衣机
整个过程是由这4个对象(可以更多)之间的交互完成的,我们不需要关注洗衣机是如何洗衣服的,是如何甩干的
类和对象的初步认识
什么是类?什么是对象?它们的关系?
类是用来对一个实体(对象)来进行描述的。
面向对象的程序设计关注的是对象,对象就是生活中的实体,如某某同学就是一个具体的对象,要描述某某同学,就要知道他的"属性",包括:名字、学号、年龄等,我们将这些属性归纳起来放在一个类中。
类是抽象的一种自定义类型,对象则是一个真实存在的实体。
比如,某某同学就是一个真实存在的对象,我们可以定义一个学生类来描述这一对象。
类的定义
类是来描述一个对象的,对象是一个真正存在的实体。类又可以叫类型,利用这一类型可以创建一个变量。
类是一个自定义的类型,可以用来定义变量
Java的类与C语言的结构体有相似之处,理解了结构体的定义,对理解类的定义有帮助,我们在用C语言做通讯录项目时,需要描述一个联系人的信息,将能描述一个联系人的信息存储在一个结构体中。而Java中的类不仅可以有成员变量,还可以有成员方法。
【创建类】
class 类名 {
字段/属性/成员变量
方法/成员方法/行为
}
- 类名 采用 大驼峰命名方式
- 字段/属性/成员变量 在 类内,成员方法外 定义
- 初期所有的成员变量都要使用
public
修饰,public
是访问修饰限定符的一种,后面会讲到 - 一般一个Java文件中只定义一个类
main
方法所在的类一般要使用public
修饰(注意:Eclipse默认会在public
修饰的类中寻找main
方法)public
修饰的类必须要和文件名相同
我们先尝试定义一个狗类:
public class Dog {
public String name;//狗的名字
public int age;//狗的年龄
public String color;//狗的颜色
public String kind;//狗的品种
//狗的吃行为
public void eat(){
System.out.println(name + "吃骨头");
}
//狗的叫行为
public void bark(){
System.out.println("汪汪汪~~~");
}
}
对于成员变量和成员方法,我们先将前面都加上public
修饰符,public
修饰的成员变量和方法可以随意访问,后面会讲到其他的访问修饰限定符:private
、protected
类的实例化
前面提到,类只是一种类型,有了这个类,我们就能使用这个类定义对象。
用类类型创建对象的过程,称为类的实例化。需要用到new
关键字。
public class Dog {
public String name;
public int age;
public String color;
public String kind;
public void eat(){
System.out.println(name + "吃骨头");
}
public void bark(){
System.out.println("汪汪汪~~~");
}
}
class Main {
public static void main(String[] args) {
Dog dog1 = new Dog();//实例化了一个狗对象
}
}
当我们实例化了一个对象后,这个对象就实际存在了,将会在堆区申请一块空间,存放这个对象的信息,而上代码中的dog1
实际上是引用变量,这一引用指向了实例化的狗对象。
我们可以打印一下,验证dog1
是一个引用变量:
-
Demo1
是自定义的包名,这个Dog
类在此包中,什么是包,后面讲 -
Dog
是类名 -
@
是分隔符 -
1b6d3586
就是引用指向的对象的地址
我们可以实例化多个对象,对象之间的成员变量并不矛盾,因为实例化了多个对象,相当于每个对象在堆区都有一块自己的空间,我们通过下面的一段代码和内存图来理解:
public static void main(String[] args) {
Dog dog1 = new Dog();
Dog dog2 = new Dog();
}
访问对象
有了一个对象后,我们想要对它的成员变量进行赋值和访问,就要用到:
对象的引用
.
成员方法/成员变量
例如:
public class Dog {
public String name;
public int age;
public String color;
public String kind;
public void eat(){
System.out.println(name + "吃骨头");
}
public void bark(){
System.out.println("汪汪汪~~~");
}
}
class Main {
public static void main(String[] args) {
Dog dog1 = new Dog();
dog1.name = "dahuang";
dog1.age = 12;
dog1.color = "黄";
dog1.kind = "田园犬";
dog1.eat();
}
}
补充一个知识点:我们知道,Java中局部变量未初始化就使用,编译器会报错,而成员变量未初始化使用不会报错。
局部变量都是在方法内定义的,而成员变量是在类内、方法外定义的。
【局部变量未初始化】
public static void main(String[] args) {
int a;
System.out.println(a);
}
【成员变量未初始化】
public static void main(String[] args) {
Dog dog = new Dog();
System.out.println(dog.name);
System.out.println(dog.age);
}
我们发现,成员变量没有初始化就使用并没有报错,且打印的值是一个默认值,对于基本类型,默认值是其对应的 0 值(boolean
类型初始为false
);引用变量初始默认值为null
this关键字
this
代表当前对象的引用,谁调用就是谁的引用。
this
引用的是调用成员方法的对象
我们通过一个例子引出this
关键字:我们现在想对成员变量赋值太麻烦了,我们可以定义一个成员方法,每次调用这个成员方法即可完成赋值,同时实现一个打印成员变量的成员方法:
public class Dog {
public String name;
public int age;
public String color;
public String kind;
public void eat(){
System.out.println(name + "吃骨头");
}
public void bark(){
System.out.println("汪汪汪~~~");
}
public void setDog(String name, int age, String color, String kind) {
name = name;
age = age;
color = color;
kind = kind;
}
public void printDog() {
System.out.println("名字:" + name);
System.out.println("年龄:" + age);
System.out.println("颜色:" + color);
System.out.println("品种:" + kind);
}
}
我们看到,上面的成员方法的形参名与当前类的成员变量名重复了,这种情况下,能设置成功吗,我们验证一下:
public static void main(String[] args) {
Dog dog1 = new Dog();
dog1.setDog("dahuang", 12, "huang", "tianyuanquan");
dog1.printDog();
}
为什么会出现这样的情况呢?
//同名
public void setDog(String name, int age, String color, String kind){
name = name;
age = age;
color = color;
kind = kind;
}
在方法内,name
、age
、color
、kind
都是局部变量,此时局部变量优先使用。
按照上面的解释,我们方法体的语句逻辑就是:形参给形参赋值。当然不会改变成员变量的值。
【解决方案一】
改变成员方法形参名(不推荐)
【解决方案二】
使用this
关键字
public void setDog(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
this
关键字就是当前对象的引用,当前对象的引用.
成员变量 就能访问到该对象的成员变量,再进行赋值,就可以。
那么问题来了,我们在调用成员方法时,Java是如何确定当前的对象的呢?
我们看一段代码:
public void setDog(Dog this, String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
对于成员方法,我们在第一个形参位置加上Dog this
,并没有报错,这是因为Java的成员方法默认有隐藏的第一个参数,通过这个参数,this
就能确定当前对象的引用。
我们总结一下有关this
的一部分知识:
- 习惯使用
this
,可以避免很多不必要的错误。基于此,我们上面实现的打印方法体内也要加上this
this
的类型:对应类类型引用,即哪个对象调用就是哪个对象的引用类型this
只能在成员方法中使用(可以在构造代码块使用,本文后面会讲),不能在静态方法中使用。this
可以在非静态代码块中使用,其实,非静态方法的花括号内部也就是一块非静态代码块。(代码块的知识后面会讲)- 在成员方法中,
this
只能引用当前对象,不能再引用其他对象 this
是成员方法第一个隐藏的参数,编译器会自动传递,在成员方法执行时,编译器会负责将调用成员方法的对象的引用传递给改成员方法,this
负责接收this
的用法:this.成员变量
:访问当前对象的成员变量this.成员方法
:访问当前对象的成员方法this()
:调用当前类的其他构造方法
关于this
访问当前对象的成员变量,我们在引出this
的例子中已经演示了,接下来我们先演示this
访问当前对象的成员方法。
将前面实现的赋值方法与打印方法合并,达到赋值后接着打印的效果:
public class Dog {
public String name;
public int age;
public String color;
public String kind;
public void eat(){
System.out.println(name + "吃骨头");
}
public void bark(){
System.out.println("汪汪汪~~~");
}
public void setDog(Dog this, String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
this.printDog();//this访问成员方法
}
public void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
}
}
class Main {
public static void main(String[] args) {
Dog dog = new Dog();
dog.setDog("dahuang", 8, "huang", "labuladuo");
}
}
用法三需要了解构造方法的知识,我们接下来讲。
构造方法
构造方法(也称为构造器)是一个特殊的成员方法,名字必须与类相同,没有返回值(这里指不需要写返回值,而不是将返回值写成void
),在创建对象时,由编译器自动调节,并且在整个对象的生命周期内只调用一次。
构造方法的作用是给对象的成员变量初始化。
【语法格式】
修饰符 方法名(参数列表) {
方法体
}
- 绝大多数情况下被
public
修饰,极少特殊情况下被private
修饰 - 方法名与类名相同
- 支持重载
其实,我们在实例化对象的时候,就调用了构造方法:
一个对象的生成至少需要两步:
- 为对象分配内存空间
- 调用合适的构造方法
构造方法可以自定义且支持重载,如果没有显性定义构造方法,Java会自动提供一个不带参数的空构造方法,反过来,如果显性定义了构造方法,Java就不自动提供构造方法了。
上面的理论解释了为什么我们没有定义构造方法还能创建对象使用空构造方法。
我们实现两个构造方法:一个不带参数,一个带参数。
public class Dog {
public String name;
public int age;
public String color;
public String kind;
public void eat(){
System.out.println(name + "吃骨头");
}
public void bark(){
System.out.println("汪汪汪~~~");
}
public void setDog(Dog this, String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
this.printDog();
}
public void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
}
//带4个参数的构造方法
public Dog(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
//不带参数的构造方法
public Dog() {
}
}
我们可以调用合适的构造方法:
public static void main(String[] args) {
Dog dog1 = new Dog();
Dog dog2 = new Dog("泥鳅", 3, "黑黄相间", "中华田园犬");
dog1.printDog();
System.out.println("==========");
dog2.printDog();
}
了解完构造方法后,我们补充this
的第三种用法:调用当前类的其他构造方法
public class Dog {
public String name;
public int age;
public String color;
public String kind;
public void eat(){
System.out.println(name + "吃骨头");
}
public void bark(){
System.out.println("汪汪汪~~~");
}
public void setDog(Dog this, String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
this.printDog();
}
public void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
}
public Dog(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
//this()调用当前类的其他构造方法
public Dog() {
this("皮皮", 3, "黑白相间", "中华田园犬");
}
}
class Main {
public static void main(String[] args) {
Dog dog = new Dog();
dog.printDog();
}
}
打印结果如下:
打印结果显示,this()
调用了当前类的带4个参数的构造方法。
注意:
- 如果要使用
this()
调用当前类的其他构造方法,这条语句必须是方法体的第一句 - 不能形成环(不允许两个构造方法互相调用)
IDEA快速生成构造方法:
-
在类内部区域,右击鼠标,出现以下选项卡,点击红框选项:
-
继续点击红框区域:
-
选择构造方法的参数列表:(如下例,如果想生成4个参数的构造方法,全选)
-
点击OK,生成成功!
对象的初始化
- 构造方法初始化
- 默认初始化
- 就地初始化
构造方法初始化上面已经介绍了。
【默认初始化】
这就要再提一下前面补充的一个知识点:局部变量在使用时必须要初始化,成员变量不用
那么具体是为什么呢?
我们想搞清楚为什么,就需要知道new
关键字背后发生的事情:
Dog dog = new Dog("泥鳅", 4, "黄黑", "中华田园犬");
执行这条语句,JVM层面做了很多的事情:
-
- 检测对应的类是否被加载,如果没有加载则加载
Java源代码经过Javac编译后生成字节码文件,而字节码文件会被加载到JVM中运行,字节码文件加载到Java虚拟机的过程就叫做 加载。
这里涉及到Java的一个优点:被加载过的类不会再被加载
联想到C语言中的预处理过程,会将包含的头文件中的所有数据直接拷贝到源代码中(包括用不到的),并且如果同一个头文件重复出现,也会拷贝两份,从中能够看出Java的一些优势
-
- 为对象分配内存空间
-
- 处理并发问题
-
- 初始化所分配的空间
对象空间被申请好了之后,对象中包含的成员已经设置好了初始值:
-
- 设置对象头信息
-
- 调用构造方法初始化
【就地初始化】
就地初始化即 在声明成员变量时,就直接给出初始值。
public class Dog {
public String name = "大黄";
public int age = "2";
public String color = "黄色";
public String kind = "中华田园犬";
public void eat(){
System.out.println(name + "吃骨头");
}
public void bark(){
System.out.println("汪汪汪~~~");
}
}
注意:代码编译完成后,编译器会将所有给成员初始化的这些语句添加到各个构造方法中
封装
对于 面向对象的语言 来说,有几个重要的特性:封装
、继承
、 多态
而在类和对象阶段,我们主要研究封装性。
我们主要讨论:
什么是封装?(封装的概念)
为什么要封装?(封装的意义)
代码层面实现封装(封装的实现)
【封装的概念】
封装:将数据和操作数据的方法有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互
关于封装,就类似于我们平时使用的手机,手机的内部电路构造被手机壳屏蔽了,我们看不到其中的细节,不过也不是完全封装起来,手机商给用户留了很多接口,如充电接口、耳机接口,还有屏幕,能让用户和手机完成交互即可。
【封装的意义】
-
提高代码安全性、私密性
把对象的私有数据和公共数据分离开,保护了私有数据,减少了模块间的干扰
-
“高内聚”
封装细节,便于修改内部代码,提高可维护性
-
“低耦合”
简化外部调用,便于调用者使用,便于扩展和协作
【封装的实现】
Java中主要通过类和访问权限来实现封装:类可以将数据以及封装数据的方法结合在一起,而访问权限用来控制方法或者字段能否直接在类内外使用。
Java提供4种访问修饰限定符实现封装,分别是:private
、default
、protected
、public
- 其中
default
就是 没有访问控制修饰符 的情况,表示默认权限 protected
涉及到子类,子类在Java的继承部分(本文的继承篇),这里不介绍public
即公开权限,没有限制private
是私有权限,被private
修饰的成员变量或方法只能在当前类使用- 注意一点:
private
、protected
不能修饰外部类(这里涉及到内部类与外部类,内部类的类名可以被private
和protected
修饰,本文的内部类部分会讲),这是语法不允许的
public
不做过多介绍,protected
在继承章节介绍。我们主要介绍private
以及default(默认权限)
。
private
我们将我们之前实现的Dog
类的所有成员变量和部分成员方法改成由private
修饰,然后测试private
影响的访问权限:
public class Dog {
private String name;
private int age;
private String color;
private String kind;
public void eat(){
System.out.println(name + "吃骨头");
}
public void bark(){
System.out.println("汪汪汪~~~");
}
private void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
}
public Dog(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public Dog() {
}
}
我们测试的方式是:在Dog(当前类)
和Main(其他新建的类)
中分别定义一个main
方法,分别测试,看能否访问成功:
public class Dog {
private String name;
private int age;
private String color;
private String kind;
public void eat(){
System.out.println(name + "吃骨头");
}
public void bark(){
System.out.println("汪汪汪~~~");
}
private void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
}
public Dog(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public Dog() {
}
public static void main(String[] args) {
Dog dog = new Dog("泥鳅", 1, "黑黄", "松狮");
dog.age = 2;//直接方法访问private修饰的成员变量
dog.printDog();//直接访问private修饰的成员方法
}
}
证明被private
修饰的成员变量和成员方法可以在当前类访问
class Main {
public static void main(String[] args) {
Dog dog = new Dog("小黄", 3, "黄", "田园犬");
dog.name = 4;
dog.printDog();
}
}
证明被private
修饰的成员变量和成员方法不可以在其他类访问
那么,如何在其他类中访问private
修饰的成员变量和成员方法呢?
与手机的处理方式一样,我们给用户提供一个接口,让用户间接地访问。
这里就涉及到get
、set
方法(针对成员变量),而private
修饰的成员方法,我们一般在当前类中新建一个由合适的访问限定修饰符修饰的方法,调用private
修饰的方法,这样就既隐藏了代码,又不妨碍用户使用,我们直接看代码:
public class Dog {
private String name;
private int age;
private String color;
private String kind;
public void eat(){
System.out.println(name + "吃骨头");
}
public void bark(){
System.out.println("汪汪汪~~~");
}
private void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
}
//调用private修饰的成员方法
public void printInfo() {
this.printDog();
}
public Dog(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public Dog() {
}
//get方法
public String getName() {
return this.name;
}
public int getAge() {
return this.age;
}
//set方法
public void setName(String name) {
this.name = name;
}
public void setAge(int age) {
this.age = age;
}
public static void main(String[] args) {
Dog dog = new Dog("泥鳅", 1, "黑黄", "松狮");
dog.age = 2;
dog.printDog();
}
}
实现了上述接口,我们就可以在其他类中使用成员变量和成员方法了:
class Main {
public static void main(String[] args) {
Dog dog = new Dog();
//给对象赋值
dog.setName("大哈");
dog.setAge(4);
//打印信息
System.out.println(dog.getName());
System.out.println(dog.getAge());
dog.printInfo();
}
}
IDEA快速生成get
、set
:
-
在当前类的区域右击鼠标,点击
Generate...
-
选择生成
get
、set
或get
和set
方法 -
选择要生成
get
和set
方法的成员变量,IDEA会自动检测没有生成的,且生成的不予显示:
default(默认权限)
默认权限就是包访问权限,只能在当前类所在的包内访问,我们简单介绍一下,之后我们就介绍包。
先将Demo1
包中的Dog
类的成员变量和部分成员方法改成默认权限:
public class Dog {
//修改为默认权限
String name;
int age;
String color;
String kind;
public void eat(){
System.out.println(name + "吃骨头");
}
public void bark(){
System.out.println("汪汪汪~~~");
}
//修改为默认权限
void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
}
public void printInfo() {
this.printDog();
}
public Dog(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public Dog() {
}
public String getName() {
return this.name;
}
public int getAge() {
return this.age;
}
public void setName(String name) {
this.name = name;
}
public void setAge(int age) {
this.age = age;
}
}
我们先验证同包的可访问性:(在Dog
包中新建其他类访问)
class Main {
public static void main(String[] args) {
Dog dog = new Dog();
dog.name = "大哈";
dog.age = 4;
dog.printDog();
}
}
能够直接访问,证明了默认权限同包的可访问性。
接下来,我们验证一下不同包的不可访问性:
下图显示我们的Dog
类在Demo1
包中:
我们新建一个包:
在新建包Demo2
中新建一个Test
类:
在Test
类中实例化一个对象并尝试直接访问:
package Demo2;//声明该类所在包,后面由类的部分专门讲
import Demo1.Dog;//导入Dog类,后面有类的部分专门讲
public class Test {
public static void main(String[] args) {
Dog dog = new Dog();
dog.name = "叶子";
dog.age = 5;
dog.printDog();
}
}
我们只能依靠get
、set
和提供的其他接口访问:
package Demo2;
import Demo1.Dog;
public class Test {
public static void main(String[] args) {
Dog dog = new Dog();
dog.setName("叶子");
dog.setAge(5);
dog.printInfo();
}
}
到这里,我们应该体会到了面向对象语言的封装特性。
包
包的概念
在面向对象的体系中,提出了软件包的概念。为了更好地管理类,把多个类收集在一起成为一组,称为软件包。
类比文件夹,为了更好地管理不同的文件,我们选择将具有相同属性的文件放到相同文件下,并可以不断分类。
在Java中也引入了包,包是对类、接口等的封装机制的体现,是一种对类或者接口等很好的组织方式, 比如,我们可以使用包和合适的限定符,达到一个包中的类不能在其他包中的类使用的效果;另外,包还解决了类名命名冲突的问题,在同一个工程中允许存在相同名称的类,只要处在不同的包中即可。
导入包中的类
Java中已经提供了很多现成的类供我们使用,例如我们在数组部分经常使用的Arrays
类,导入这些类需要关键字import
我们介绍三种导入包中类的方式:
- 不使用
import
- 使用
import
导入具体的类 - 使用
import
和通配符*
导入某个包中的所有类
我们想要创建一个Date
类(Java现成类)的对象:
【不使用import
】
public class Test {
public static void main(String[] args) {
java.util.Date date = new java.util.Date();
System.out.println(date.getTime());
}
}
不导入包,就要在使用Date
类时加上其所在路径(或者理解成包名称、子包名称),不过这太麻烦了。
【使用import
导入具体的类】
import java.util.Date;//导入Date类
public class Test {
public static void main(String[] args) {
Date date = new Date();
System.out.println(date.getTime());
}
}
【使用import
和通配符*
导入包中所有类】
util
包中不止有Date
类,我们常用的数组Arrays
类也在里面,我们可以使用通配符*
导入util
包中的所有类,具体用法如下:
import java.util.*;//使用通配符
public class Test {
public static void main(String[] args) {
Date date = new Date();
System.out.println(date.getTime());
int[] array = new int[]{1, 2, 3};
System.out.println(Arrays.toString(array));
}
}
不过,我们还是建议显式地指定要导入的类名,否则还是容易出现冲突的情况。
说到这,我们举几个例子体会一下导入包时的注意事项:
如我们要使用Date
这个类,而util
和sql
两个包中都有Date
类(重名)
【情况一】
import java.util.Date;
import java.sql.Date;
//报错!!!
【情况二】
import java.util.*;
import java.sql.*;
public class Package {
public static void main(String[] args) {
Date d = new Date();//报错!!!
java.util.Date d2 = new Date();
}
}
【情况三】
import java.util.Date;
import java.sql.*;
public class Package {
public static void main(String[] args) {
Date d = new Date();
java.util.Date d2 = new Date();
}
}
//不报错
不报错,不过此时的Date
是哪个包中的?util
还是sql
?
其实是util
包里的,util
的Date
类实例化时为无参构造方法,而sql
的Date
类实例化时没有无参构造方法。所以,在上面的import
情况下,我们可以直接使用Date
实例化一个对象,使用无参构造方法,如果报错,证明:不指明路径使用Date
类,默认是sql
包中的,如果不报错,证明默认是util
包中的。
以下证明:
import java.util.Date;
import java.sql.*;
public class Package {
public static void main(String[] args) {
Date d = new Date();
}
}
并没有报错,证明:
如果存在重名类,优先使用的是没有使用通配符的包里的类,而要使用另一个重名的类,则需要使用第一种导入包的方式,在使用时加上路径,以区分重名类
【静态导入】
还有一个知识点:静态导入。
如果一个类中的方法全部是使用 static 声明的静态方法,则在导入时就可以直接使用 import static
的方式导入:
结果将会导入包中的静态方法和字段,静态导入的意义在于,它能够简化代码,使得我们更加方便。
静态导入的格式:
import static 包.类.*;
例如:
import static java.lang.Math.*;//静态导入
public class Test {
public static void main(String[] args) {
double x = 30;
double y = 40;
//不静态导入,常规写法
double ret1 = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
//静态导入,可简写
double ret2 = sqrt(pow(x, 2) + pow(y, 2));
System.out.println(ret1);
System.out.println(ret2);
}
}
【注意】
Java的import
与 C++的#include
差别很大。C++必须用#include
来引入其他文件内容,而Java的import
只是为了写代码的方便。
自定义包
自定义包要用到 package
关键字,指定代码所在包。
注意:
package
语句声明了当前类所在的包- 包名尽量指定成唯一的名字,通常会用公司域名的颠倒形式(如:
com.baidu.www
) - 包名要和代码路径相匹配,例如创建
com.xiaokuer.www
的包,那么会存在一个对应的路径com/xiaokuer/www
来存储代码 - 如果一个类没有使用
package
语句,那么该类会被放到一个默认包中
我们接下来在IDEA中演示一下:
-
先按照下图打开包的命名界面
-
按照上面建议的命名方式命名,例如
com.xiaokuer.www
-
起好名字后按下回车
-
上面提到了,创建例如
com.xiaokuer.www
为包名的包,会生成com/xiaokuer/www
的路径,我们可以查看一下: -
我们可以调整一下IDEA对于自定义包的显示:(将对号取消)
调整之后,就更加直观了。
-
我们在
www
路径下创建一个类: -
命名好后,我们发现,在新建的
.java
文件的上方自动生成了package
语句,声明了其所在包:
包的访问权限控制
即通过访问限定符限定成员变量或成员方法,这部分内容在本文 封装 部分已经介绍了,这里不再赘述。
常用的包
java.lang
:系统常用基础类(String、Object),此包从JDK1.1后自动导入java.lang.reflect
:java反射编程包java.net
:进行网络编程开发包java.sql
:进行数据库开发的支持包java.util
:是Java提供的工具程序包。java.io
:I/O编程开发包
static成员
static修饰成员变量
static
修饰的成员变量,称为静态成员变量,也可以称为类成员。静态成员变量:不属于某个具体的对象,是所有对象所共享的。(这一点相当重要)
【静态成员变量的特性】
-
不属于某个具体的对象,是类的属性,所有对象共享的,不存储在某个对象的空间中
-
访问方法:1. 通过类名访问 2. 通过对象访问
我们推荐通过类名访问
-
类成员存储在方法区中
-
类变量的生命周期伴随类的一生,随着类的加载而创建,随类的卸载而销毁
为了介绍上面的特性,我们仍然使用类Dog
,添加一个成员变量owner
,代表狗狗的铲屎官。
我们假设此类描述的都是同一个人家的狗狗,那么,我们实例化的每个对象的owner
成员变量一致,都是同一个人。
此时,如果每个实例化的狗狗都要带上这个属性且要初始化太麻烦了,我们就可以采用static
修饰成员变量的方式解决这个问题:
package com.xiaokuer.www;
public class Dog {
public String name;
public int age;
public String color;
public String kind;
public static String owner;
public void eat() {
System.out.println(this.name + " 吃饭");
}
public Dog(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public Dog() {
}
public void setInfo(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
System.out.println("铲屎官:" + this.owner);
}
}
我们将owner
成员变量用static
修饰,此时owner
依赖于对象,也不在某个对象的空间内,而是在在方法区。
【验证】静态成员变量不在某个对象的空间,而是在方法区:
package com.xiaokuer.www;
public class DemoOne {
public static void main(String[] args) {
Dog dog1 = new Dog("大哈", 3, "黄白", "哈士奇");
Dog dog2 = new Dog("叶子", 4, "黑白", "边牧");
dog1.owner = "猪爸";
dog1.printDog();
System.out.println("========");
dog2.printDog();
}
}
如代码显示,我们通过dog1
对象访问并设置静态成员变量的值,然后打印两个对象的信息:
发现:两个对象打印出来的owner
信息都是通过dog1
对象访问设置好的信息。
【验证】静态成员变量不依赖于对象
public static void main(String[] args) {
Dog.owner = "猪爸";
}
我们直接设置静态成员变量owner
的值即可,不需要实例化对象。
【静态成员访问的方法】
- 通过类名访问
- 通过对象的引用访问
通过类名访问不再赘述,其反映了静态成员变量的不依赖对象性。
关于通过对象的引用访问静态成员变量,实际上对象的引用为null
也不影响:
public static void main(String[] args) {
Dog dog = null;
dog.owner = "猪爸";
System.out.println(dog.owner);
}
静态成员变量不依赖于对象,所以没有抛出"空指针异常"。
static修饰成员方法
static
修饰的成员方法,称为静态成员方法,也叫类方法,是类的方法,不是某个对象所特有
【静态成员方法的特性】
- 静态成员方法不依赖于对象,不属于某个具体的对象
- 访问方法:1. 类名访问 2. 对象调用
- 不能在静态方法中直接访问任何非静态成员变量
- 不能在静态方法中直接调用任何非静态方法。前面提到静态方法中有隐藏的
this
参数,this
与对象强相关,在静态方法中调用时无法传递this
引用 - 静态方法无法重写,不能用来实现多态(关于多态,在本文的多态篇介绍)
我们看一段代码:
public class Dog {
public String name;
public int age;
public String color;
public String kind;
public static String owner;
public void eat() {
System.out.println(this.name + " 吃饭");
}
public Dog(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public Dog() {
}
public void setInfo(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
System.out.println("铲屎官:" + this.owner);
}
//静态方法中直接访问非静态成员变量
public static void test() {
age += 1;
this.name = "大哈";
}
}
要想在静态方法中访问非静态的成员变量,必须实例化对象,通过对象的引用访问非静态成员变量:
public static void test() {
Dog dog = new Dog();
dog.age += 1;
dog.name = "大哈";
dog.printDog();
}
我们验证:静态方法不依赖于对象,可以直接通过类名调用
public class DemoOne {
public static void main(String[] args) {
Dog.test();
}
}
在非静态方法中可以调用静态方法,反之不可:
public class Dog {
public String name;
public int age;
public String color;
public String kind;
public static String owner;
public void eat() {
System.out.println(this.name + " 吃饭");
}
public Dog(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public Dog() {
}
public void setInfo(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
System.out.println("铲屎官:" + this.owner);
}
public static void test() {
Dog dog = new Dog();
dog.age += 1;
dog.name = "大哈";
dog.printDog();
}
//非静态方法调用静态方法,不报错!
public void useStatic() {
//写法一
test();
//写法二
Dog.test();
}
//静态方法直接调用非静态方法,报错!
public static void useNonStatic() {
printDog();
}
}
要想在静态方法调用非静态方法,必须实例化对象,用对象的引用调用非静态方法。
static成员变量初始化
三种方法:
- 就地初始化
- 静态代码块初始化
- 构造方法初始化
【就地初始化】
即,定义时直接初始化:
public class Dog {
public String name;
public int age;
public String color;
public String kind;
//就地初始化
public static String owner = "猪爸";
public void eat() {
System.out.println(this.name + " 吃饭");
}
public Dog(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public Dog() {
}
public void setInfo(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
System.out.println("铲屎官:" + this.owner);
}
}
【静态代码块初始化】
代码块是一个新知识点,我们在这里给出代码,具体知识点在本文的后面介绍:
public class Dog {
public String name;
public int age;
public String color;
public String kind;
public static String owner;
//静态代码块初始化
static {
//写法一
owner = "猪爸";
//写法二
Dog.owner = "猪爸";
}
public void eat() {
System.out.println(this.name + " 吃饭");
}
public Dog(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public Dog() {
}
public void setInfo(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
System.out.println("铲屎官:" + this.owner);
}
}
【构造方法初始化】
这一方法不常用,给出代码:
//构造方法
public Dog(String owner) {
Dog.owner = owner;
}
public class DemoOne {
public static void main(String[] args) {
//构造方法初始化
Dog dog = new Dog("猪爸");
System.out.println(Dog.owner);
}
}
代码块
代码块的概念
使用{}
定义的一段代码称为代码块。
根据代码块的定义位置以及关键字,可分为以下四种:
- 普通代码块
- 构造/实例代码块
- 静态代码块
- 同步代码块(多线程部分内容,暂时不讨论)
普通代码块
普通代码块,即定义在方法中的代码块。
是方法定义的一部分。
public class DemoOne {
public static void main(String[] args) {
//普通代码块
int a = 10;
int b = 20;
System.out.println(a + b);
}
}
构造代码块
构造代码块,又称实例代码块,即定义在类中的不加修饰符的代码块。
一般用来初始化实例成员变量。
格式:
{
//语句
}
关于构造代码块,我们要讨论的事情较多:
- 构造代码块的执行时机
- 构造代码块的执行次数
- 不同构造代码块初始化的结果(多个构造代码块的执行顺序)
我们先写一段代码:
public class Dog {
private String name;
private int age;
private String color;
private String kind;
private static String owner;
//构造代码块
{
this.name = "大哈";
this.age = 3;
//用于检验构造代码块的执行时机和次数
System.out.println("构造代码块执行了");
}
public void eat() {
System.out.println(this.name + " 吃饭");
}
public void setInfo(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
System.out.println("铲屎官:" + this.owner);
}
}
了解了构造代码块的书写后,请问:构造代码块何时执行?执行几次?
构造代码块在实例化对象时执行。每次实例化对象时都会执行一次。
验证:
public static void main(String[] args) {
System.out.println("不实例化对象,观察是否打印构造代码块中的语句:");
}
结果是:不执行。
public static void main(String[] args) {
System.out.println("实例化对象,观察是否打印构造代码块中的语句:");
Dog dog = new Dog();
}
执行了!
public static void main(String[] args) {
System.out.println("实例化多个对象,观察是否多次打印构造代码块中的语句:");
Dog dog1 = new Dog();
Dog dog2 = new Dog();
}
验证构造代码块的初始化效果:
public static void main(String[] args) {
Dog dog = new Dog();
dog.printDog();
}
到此,我们验证了构造代码块的执行时机和执行次数,接下来我们看一段代码,说出main
方法执行后打印结果是什么:
public class Dog {
private String name;
//就地初始化age为2
private int age = 2;
private String color;
private String kind;
private static String owner;
//构造代码块一,初始化age为3
{
this.age = 3;
}
//构造代码块二,初始化age为4
{
this.age = 4;
}
public void eat() {
System.out.println(this.name + " 吃饭");
}
public void setInfo(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
System.out.println("铲屎官:" + this.owner);
}
public static void main(String[] args) {
Dog dog = new Dog();
System.out.println(dog.age);
}
}
结果是 4。给出结论:当代码块的属性一样(都是构造代码块)时,初始化的最后结果与定义顺序有关,最终值是最后初始化的值(如果就地初始化在所有构造代码块后面,则最终的值为就地初始化的值)
我们调整顺序验证:
public class Dog {
private String name;
{
this.age = 3;
}
{
this.age = 4;
}
private int age = 2;
private String color;
private String kind;
private static String owner;
public void eat() {
System.out.println(this.name + " 吃饭");
}
public void setInfo(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
System.out.println("铲屎官:" + this.owner);
}
public static void main(String[] args) {
Dog dog = new Dog();
System.out.println(dog.age);
}
}
验证成功!
【总结】
-
构造代码块在实例化对象(创建对象时)执行,执行次数与实例化的对象的个数保持一致
-
当定义的多个代码块的属性一样(都是构造代码块)时,初始化的最后结果与定义顺序有关,最终值是最后初始化的值
或者说:多块构造代码块的执行顺序与定义顺序有关
静态代码块
静态代码块,即使用static
定义的代码块。
一般用于初始化静态成员变量
格式:
static{
//语句
}
我们依旧讨论:
- 静态代码块的执行时机
- 静态代码块的执行次数
- 多个静态代码块初始化的结果(多个静态代码块的执行顺序)
我们给出结论:静态代码块在类被加载时执行,且只会执行一次
验证:
public class Dog {
private String name;
private int age;
private String color;
private String kind;
private static String owner;
//静态代码块
static {
owner = "小裤儿";
//检验静态代码块的执行时机和执行次数
System.out.println("静态代码块被执行了");
}
public void eat() {
System.out.println(this.name + " 吃饭");
}
public void setInfo(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
System.out.println("铲屎官:" + this.owner);
}
public static void main(String[] args) {
System.out.println("不实例化对象,观察是否打印静态代码块中的语句:");
}
}
执行了!且执行时机很早(加载类时)。
我们尝试实例化多个对象,观察:
public static void main(String[] args) {
System.out.println("实例化多个对象,观察:");
Dog dog1 = new Dog();
Dog dog2 = new Dog();
}
静态代码块只会在加载类时执行一次!
多个静态代码块的执行顺序,说出main
方法执行的打印结果:
public class Dog {
private String name;
private int age;
private String color;
private String kind;
private static String owner = "猪爸";
static {
owner = "小裤儿";
}
static {
owner = "四角儿";
}
public void eat() {
System.out.println(this.name + " 吃饭");
}
public void setInfo(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
System.out.println("铲屎官:" + this.owner);
}
public static void main(String[] args) {
System.out.println(Dog.owner);
}
}
与多个构造代码块一样,与执行顺序有关。
当代码块的属性一样(都是静态代码块)时,初始化的最后结果与定义顺序有关,最终值是最后初始化的值(如果就地初始化在所有静态代码块后面,则最终的值为就地初始化的值)
交换顺序验证:
public class Dog {
private String name;
private int age;
private String color;
private String kind;
static {
owner = "小裤儿";
}
static {
owner = "四角儿";
}
private static String owner = "猪爸";
public void eat() {
System.out.println(this.name + " 吃饭");
}
public void setInfo(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
System.out.println("铲屎官:" + this.owner);
}
public static void main(String[] args) {
System.out.println(Dog.owner);
}
}
验证成功!
【总结】
-
静态代码块在类加载时执行,同一个类只会加载一次,所以静态代码块只会执行一次
-
当代码块的属性一样(都是静态代码块)时,初始化的最后结果与定义顺序有关,最终值是最后初始化的值(如果就地初始化在所有静态代码块后面,则最终的值为就地初始化的值)
多块静态代码块的执行顺序与定义顺序有关
不同代码块、构造方法的执行顺序
类中总会出现构造代码块、静态代码块都存在的情况,这种情况下,执行顺序是什么?加上构造方法呢?
我们在代码块中加入打印语句,当打印出对应语句时,证明代码块执行:
public class Dog {
private String name;
private int age;
private String color;
private String kind;
private static String owner;
//构造函数
public Dog(String name, int age, String color, String kind) {
System.out.println("构造方法被执行了");
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
//构造代码块
{
System.out.println("构造代码块被执行了");
}
//静态代码块
static {
System.out.println("静态代码块被执行了");
}
public void eat() {
System.out.println(this.name + " 吃饭");
}
public void setInfo(String name, int age, String color, String kind) {
this.name = name;
this.age = age;
this.color = color;
this.kind = kind;
}
public void printDog() {
System.out.println("名字:" + this.name);
System.out.println("年龄:" + this.age);
System.out.println("颜色:" + this.color);
System.out.println("品种:" + this.kind);
System.out.println("铲屎官:" + this.owner);
}
public static void main(String[] args) {
Dog dog = new Dog("大哈", 3, "黄白", "哈士奇");
}
}
由打印结果可知:执行顺序:静态代码块 > 构造代码块 > 构造方法
它们的执行顺序与定义顺序无关,是固定的。
对象的打印
对象的打印,我们可以通过自定义一个成员方法即可打印,不过,如果类的成员变量过多,我们实现打印函数就会十分麻烦。
这里介绍一个简单的方法 (具体的原理会在本文的Object
类部分讲解,同时需要读者掌握继承和多态等知识):IDEA生成
-
在类的区域右击,出现以下选项卡,红框区域
-
选择下图红框的
toString
-
选择想打印的成员方法
-
生成
-
调用
public static void main(String[] args) { Dog dog = new Dog(); //传入对象的引用 System.out.println(dog); }
为什么?按照惯例,我们打印的应该是一个"地址"。
其实这里是:toString
的重写,需要继承和多态的知识,我们先看现象即可。
继承
继承是面向对象语言的一大特性,Java作为面向对象语言的代表,使用类实例化的对象对生活中的事物进行描述,而现实生活中的事物往往是错综复杂的,不同的对象之间可能会有紧密的联系,很多的相同点。 我们用在类描述这些具有相同属性对象时,往往会定义重复的成员便变量或成员方法,造成代码冗余。
例如,我们定义猫类和狗类:
package demo1;
public class Cat {
public String name;
public int age;
public String color;
public void run() {
System.out.println(this.name + " 正在跑");
}
public void mew() {
System.out.println(this.name + "正在喵喵叫");
}
}
package demo1;
public class Dog {
public String name;
public int age;
public String color;
public void run() {
System.out.println(this.name + " 正在跑");
}
public void bark() {
System.out.println(this.name + " 正在汪汪叫");
}
}
名字、年龄、颜色成员变量重复了,run
成员方法重复了,针对这种情况,我们需要继承。
面对对象思想中提出了继承的概念,专门用来进行共性抽取,实现代码复用
继承的概念
继承机制:是面对对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行拓展,增加新功能,这样新产生的类,称为派生类。
继承主要解决的问题是:共性的抽取,实现代码复用。
紧接上面的猫狗例子,我们通俗地介绍一下继承的使用场景:
名字、颜色等描述猫和狗的属性,可以用来描述任何动物,那么我们创建一个动物类,将猫和狗的共性进行抽取,此时猫和狗的特有功能就是上面所讲的拓展,这两个类称为动物类的派生类。
图中给出了父类、子类的概念:
父类:被继承的类
子类:父类的拓展,继承的类
我们什么时候用到继承关系呢?
出现A is B
的情况,我们可以考虑用继承。以上例,猫和狗都是动物,猫和狗类就可以继承动物类。
继承的语法
想在Java代码中体现继承关系,需要用到 extends
关键字:
修饰符 class 子类 extends 父类 {
//...
}
我们将猫狗例子更改成继承体系:
//Animal.java
package demo1;
public class Animal {
public String name;
public int age;
public String color;
public void run() {
System.out.println(this.name + " 正在跑");
}
}
//Dog.java
package demo1;
public class Dog extends Animal {
public void bark() {
System.out.println(this.name + " 正在汪汪叫");
}
}
//Cat.java
package demo1;
public class Cat extends Animal {
public void mew() {
System.out.println(this.name + "正在喵喵叫");
}
}
如上,我们建立了继承体系。
【注意】
- 父类中的成员变量和成员方法会继承到子类当中
- 子类继承父类后,要添加自己特有的成员,体现出与父类的不同,否则就没必要继承了
继承体系下访问成员变量
子类继承了父类的成员,那么子类能直接访问父类的成员变量吗?
在上面的继承体系下,有代码:
package demo1;
public class Dog extends Animal {
public void test() {
name = " ";
this.name = "";
age++;
this.age++;
}
public void bark() {
System.out.println(this.name + " 正在汪汪叫");
}
}
子类中能直接访问父类继承下来的成员变量(访问限定修饰符合理, 子类不能直接访问父类中被private
修饰的成员)
【子类和父类不存在同名变量】
这种情况下,根据访问的变量,访问父类或子类的成员变量。
【子类和父类变量同名】
这种情况下,访问同名变量,访问的是父类的还是子类的?
我们验证,打印结果是 1 还是 10:
public class Animal {
public String name;
public int age;//Animal中的age默认初始化为0
public String color;
public void run() {
System.out.println(this.name + " 正在跑");
}
}
public class Dog extends Animal {
//Dog中的age就地初始化为10
int age = 10;
public void bark() {
System.out.println(this.name + " 正在汪汪叫");
}
public static void main(String[] args) {
Dog dog = new Dog();
System.out.println(dog.age);
}
}
打印 10 ,证明优先访问的是子类自己的成员变量。
在子类方法中或者通过子类对象访问成员时:(就近原则)
- 如果子类存在要访问的成员变量,则优先访问自己的
- 如果子类中不存在要访问的成员变量,则访问父类继承下来的,如果父类中也没有定义,则会编译报错
- 如果子类和父类中都存在要访问的成员变量(同名),则优先访问自己的
自己有优先自己,自己没有从父类找
继承体系下访问成员方法
与访问成员变量类似:
- 通过子类对象访问父类与子类中不同名方法时,优先在子类中寻找,找到就访问,否则在父类中寻找,找到就访问,否则,编译出错。
- 通过子类对象访问父类与子类同名方法时,如果父类和子类的同名方法构成重载,根据调用方法时传递的参数选择合适的方法访问,如果没有则报错。如果不构成重载,语法上子类必须重写同名方法,此时优先调用的是子类重写过的同名方法。(关于重写,会在多态的一篇中详解)
我们验证一下同名方法构成重载的情况:
//Animal.java
public class Animal {
public String name;
public int age;
public String color;
public void run() {
System.out.println(this.name + " 正在跑");
}
}
//Dog.java
public class Dog extends Animal {
public void run(int a) {
System.out.println("子类的run");
}
public void bark() {
System.out.println(this.name + " 正在汪汪叫");
}
public static void main(String[] args) {
Dog dog = new Dog();
dog.run(1);
}
}
我们调用run
传入的参数与子类中的参数列表对应,所以调用子类的成员方法。
super关键字
学习时,可以与this
进行对比理解,后面会讲,我们先接着看:
super
关键字,相当于子类对象从父类继承下来的部分成员的引用,其主要作用:在子类方法中访问父类成员
super
的用法:
super
访问父类的成员变量super
访问父类的成员方法super()
访问父类的构造方法
【super
访问父类的成员变量】
//Animal.java
public class Animal {
public String name;
public int age;
public String color;
public void run() {
System.out.println(this.name + "正在跑");
}
public void test() {
System.out.println("这是父类的成员方法");
}
}
//Dog.java
public class Dog extends Animal {
public int age;
public void bark() {
System.out.println(this.name + "正在汪汪叫");
}
public void func() {
age = 5;//等价于this.age
this.age = 20;//覆盖掉之前的赋值
super.age = 10;//super调用父类的age进行赋值,否则默认访问子类的age
}
public void printInfo() {
System.out.println("子类成员变量age的值是:" + this.age);
System.out.println("父类成员变量age的值是:" + super.age);
}
public static void main(String[] args) {
Dog dog = new Dog();
dog.func();
dog.printInfo();
}
}
【super
访问父类的成员方法】
一般来说,父类和子类成员方法有以下情况:
- 方法名不同。 通过方法名访问指定的方法即可,先找子类再找父类
- 方法名相同,构成重载。 通过调用时传入的参数即可访问指定参数列表的方法
- 方法名相同,子类方法对父类方法重写。 这时候需要
super
关键字调用父类的同名方法(重写在多态时会介绍,我们先给出代码,观察现象即可)
//Animal.java
public class Animal {
public String name;
public int age;
public String color;
public void run() {
System.out.println(this.name + "正在跑");
}
public void test() {
System.out.println("这是父类的成员方法");
}
}
//Cat.java
public class Cat extends Animal{
public void mew() {
System.out.println(this.name + "正在喵喵叫");
}
//重写test方法
public void test() {
System.out.println("重写父类的test方法");
}
public void use() {
//默认调用
System.out.println("这是Java默认调用的test方法:");
test();
System.out.println("利用super调用父类的被重写方法:");
super.test();
}
public static void main(String[] args) {
Cat cat = new Cat();
cat.use();
}
}
【super()
访问父类的构造方法】
这一用法的应用场景在下一模块子类构造方法中介绍:
class A {
public int a;
public A(int a) {
this.a = a;
}
}
class B extends A {
public int b;
//super调用父类的构造方法对父类成员变量初始化
public B(int a) {
super(a);
}
}
【注意】
super
只能在非静态方法中使用(构造代码块也可以),不过,super
可以通过.
调用静态方法super
只能指代子类的直接父类,不能指代其父类的父类
【this
与super
】
相同点:
- 都是Java的关键字,用法类似,都有三种
- 都不能在静态方法中使用,但是可以访问静态方法
- 必须是构造方法的第一条语句(意味着
this()
与super()
不能共存)
不同点:
-
this
代表当前对象的引用,super
相当于子类对象中从父类继承部分的成员的引用。它们指代的内容可以用一张图简单表示: -
构造方法中一定存在
super()
语句,用户没有写编译器默认添加,但是this()
不写则没有 -
构造方法中,
this()
调用当前类的构造方法,super()
调用父类的构造方法 -
在非静态成员方法中,
this
用来访问本类的成员,super
用来访问父类的成员
子类构造方法
Java中,子类对象构造时,需要先调用基类构造方法对基类的成员进行初始化,然后执行子类的构造方法
有父才有子,所以在构造子类对象的时候,要先调用父类的构造方法,将从基类继承下来的成员构造完整,再调用子类自己的构造方法,将子类自己新增的成员初始化完整。这里就要用到super
的第三种用法:super()访问父类的构造方法
我们验证 子类先调用父类构造方法的强制性:
public class Demo {
public int a;
public String s;
//父类的构造方法
public Demo(int a, String s) {
this.a = a;
this.s = s;
}
}
class Child extends Demo {
public int b;
//没有调用父类的构造方法初始化父类的成员变量
}
public class Demo {
public int a;
public String s;
//父类的构造方法
public Demo(int a, String s) {
this.a = a;
this.s = s;
}
}
class Child extends Demo {
public int b;
//子类构造方法中先使用super()调用父类的构造方法对父类的成员变量进行初始化
public Child(int a, String s) {
super(a, s);
}
public static void main(String[] args) {
Child child = new Child(10, "hello");
}
}
完成上面的修改后,程序不再报错。
【注意】
- 如果父类中显性提供了无参的构造方法或者没有显性提供任何构造方法(Java默认提供无参构造方法),子类的构造方法中默认有
super();
语句 - 如果父类中显性提供了有参的构造方法,那么子类必须手动补充
super()
语句,调用父类的构造方法对父类的成员变量进行初始化 super()
语句必须是构造方法的第一条语句,所以super()
与this()
不共存
继承体系下的代码块
此模块主要讨论继承体系下的静态代码块、构造代码块、构造方法的执行顺序。
我们直接看一段代码:
//Animal.java
public class Animal {
public String name;
public int age;
static {
System.out.println("父类Animal的静态代码块执行了......");
}
{
System.out.println("父类Animal的构造代码块执行了......");
}
public Animal(String name, int age) {
this.name = name;
this.age = age;
System.out.println("父类Animal的构造方法被执行了......");
}
}
//Dog.java
public class Dog extends Animal {
static {
System.out.println("子类Dog的静态代码块被执行了......");
}
{
System.out.println("子类Dog的构造代码块被执行了......");
}
public Dog(String name, int age) {
super(name, age);
System.out.println("子类Dog的构造方法被执行了......");
}
}
//Cat.java
public class Cat extends Animal {
static {
System.out.println("子类Cat的静态代码块被执行了......");
}
{
System.out.println("子类Cat的构造代码块被执行了......");
}
public Cat(String name, int age) {
super(name, age);
System.out.println("子类Cat的构造方法被执行了......");
}
}
//Test.java
//这是测试类
public class Test {
public static void main(String[] args) {
Cat cat = new Cat("mimi", 2);
}
}
当我们执行上面的代码,打印结果是多少?
根据打印结果,我们可以得到:
继承体系下的执行顺序:
父类的静态代码块 > 子类的静态代码块 > 父类的构造代码块 > 父类的构造方法 > 子类的构造代码块 > 子类的构造方法
了解了上面的知识后,我们修改一下Test
测试类,判断修改后的打印结果:
public class Test {
public static void main(String[] args) {
Cat cat1 = new Cat("mimi", 2);
System.out.println("=========");//分割线
Cat cat2 = new Cat("lala", 3);
}
}
- 在类和对象篇中已经介绍了,静态代码块在类被加载时执行且只执行一次,所以静态代码块只执行一次
- 每次实例化对象时,构造代码块和构造方法都会执行一次
IDEA:在子类中快速生成调用父类构造方法的构造方法
//Base.java
//父类
package Demo3;
public class Base {
public int a;
public String s;
public Base(int a, String s) {
this.a = a;
this.s = s;
}
}
如图为子类,操作方法为:
鼠标放在红线部分,alt
+ enter
选择绿框选项,生成成功:
protected关键字
protected
关键字属于类和对象篇的遗留问题了,学习了继承后,我们对子类有了认识,现在我们详细介绍一下:
被protected
修饰的成员,同包底下任何一个类都是可以访问的,不同的包下只有子类才能访问
注意: 被protected
关键字修饰的成员可以在不同包的子类中访问。
对于注意事项,我们验证:
package Demo1;//Demo1中
public class Base {
protected int a;//protected修饰
}
package Demo2;//Demo2中
//验证不同包下的子类的可访问性
import Demo1.Base;
public class Test1 extends Base {
public int b;
public void printInfo() {
System.out.println(a);
}
public static void main(String[] args) {
Test1 test1 = new Test1();
test1.printInfo();
}
}
package Demo2;//Demo2中
//验证不同包的非子类的不可访问性
public class Test2 {
public int c;
public void printInfo() {
System.out.println(a);
}
public static void main(String[] args) {
Test2 test2 = new Test2();
test2.printInfo();
}
}
【补充】
被private
修饰的父类成员不能在子类中直接访问,但实际上它们被继承下来了。
class A {
private int a;
public int b;
}
class B extends A {
public void test() {
super.a = 10;//编译报错,被private修饰的父类成员不能在子类中直接访问
super.b = 10;
}
}
继承方式
继承方式有很多种,但Java中只支持以下几种继承方式:
-
Java不支持多继承,即一个子类只能有一个父类
-
我们的代码尽量不要出现超过三层的继承关系,如果出现过多层的继承,就要考虑对代码重构了
-
Java中如果想实现多继承,需要用到接口的知识,后面会讲
final关键字
final
关键字可以用来修饰变量、成员方法或类。
-
修饰变量或字段,表示常量(即不能修改)
public static void main(String[] args) { final int a = 10; a = 20; }
-
修饰类:表示此类不能被继承
被
final
修饰的类叫做密封类final class A { } class B extends A { }
-
修饰方法:表示该方法不能被重写
被
final
修饰的方法叫做密封方法class A { public final void test() { System.out.println("被final修饰,不能重写"); } } class B extends A { public void test() { System.out.println("失败"); } }
继承与组合
组合也是一种表示类之间关系的方式,能够实现代码复用。
实现组合,只需要将一个类的实例作为另一个类的成员变量。
前面提到:继承是A is B
的关系,而组合是A has B
的关系,如,学校有老师、学生。
我们结合一段代码了解:
//Student.java
public class Student {
public String name;
public int age;
//......
}
//School.java
public class School {
//组合
public Student[] students = new Student[100];
}
在实际应用中,组合的使用多余继承,我们在写代码时还是能用组合不用继承。
多态
多态的概念
多态是面向对象的重要特性, 是指 一个引用在不同的情况下的多种状态。
具体讲就是完成某个行为,当不同的对象去完成时会产生不同的状态。
结合实际,把一张图片给黑白打印机,将会打印出黑白照片,给彩色打印机,将会打印出彩色照片。
总的来说:同一件事情,发生在不同对象身上,就会产生不同的结果。
多态的实现条件
Java中实现多态,必须满足以下条件:
- 继承体系下
- 子类必须对父类方法进行重写
- 通过父类的引用调用重写的方法(向上转型 + 动态绑定)
满足以上条件实现多态后:代码运行时,当传递不同类实例化的对象,会调用对应类中的方法
而想要理解多态,我们必须熟悉:方法的重写 和 向上转型,接下来一一介绍。
方法的重写
重写,也叫覆写或覆盖。
重写是子类对父类非静态、非private
修饰、非final
修饰、非构造方法等的实现过程(方法体)的重新编写。
【规则】
- 重写要求方法的参数列表不变
- 重写要求方法的返回值不变,除非返回类型具有父子关系,例如,返回值类型分别为父类和子类(父类方法的返回类型为父子关系中的父,子类方法的返回值为父子关系中的子;反之不可)
- 子类重写后的方法的访问权限必须大于等于父类方法的访问权限,例如,父类方法被
public
修饰,子类就不可以被protected
修饰,此时只能被public
修饰 - 子类不能重写父类中被
static
、private
、final
修饰的方法 - 子类不能重写父类的构造方法
我一段含有重写的代码:
//Animal.java
public class Animal {
public String name;
public int age;
//...
//父类被重写的方法
public void eat() {
System.out.println(this.name + "吃饭");
}
}
//Dog.java
public class Dog extends Animal {
//子类重写父类方法
public void eat() {
System.out.println(this.name + "吃狗粮");
}
}
关于重写,有一个小知识:
重写的方法,可以使用注解@Override
,这个注解可以帮我们进行校验,校验重写后的方法是否存在问题:
//Animal.java
public class Animal {
public String name;
public int age;
public void eat() {
System.out.println(this.name + "吃饭");
}
}
//Dog.java
public class Dog extends Animal {
@Override
public void eat() {
System.out.println(this.name + "吃狗粮");
}
}
当我们修改重写后的方法名,就会报错提示:
重写与重载的区别
其中,重写返回类型可以不相同,不过必须构成父子关系。
方法重写是子类与父类的一种多态性表现;而方法重载是一个类的多态性的表现。
动态绑定和静态绑定
【动态绑定】
动态绑定也称后期绑定(晚绑定),即在编译时,不能确定方法的行为,需要等到程序运行时,才能确定具体调用哪个类的方法。
观察以下代码,说出打印结果:(建议先看下一个模块向上转型
,这部分知识联系密切,不可严格分割)
//Animal.java
public class Animal {
public String name;
public int age;
//...
public void eat() {
System.out.println(this.name + "吃饭");
}
}
//Dog.java
public class Dog extends Animal {
@Override
public void eat() {
System.out.println(this.name + "吃狗粮");
}
}
//Test.java
public class Test {
public static void main(String[] args) {
Animal animal = new Dog();
animal.name = "大哈";
animal.eat();
}
}
我们用Animal
类的引用访问eat
方法,为什么访问到的是子类Dog
的eat
呢?
这里就涉及到了动态绑定。
动态绑定的条件:
- 父类引用引用了子类对象
- 通过满足上述上条件的父类调用了父类和子类重写的那个方法
动态绑定效果:
父类引用最终调用了其引用的子类中的重写方法
对于上述例子,其程序在编译时调用的确实是父类的eat
方法,我们可以通过观察反汇编代码:
当程序运行时,就能确认调用子类的eat
方法
如果在父类的构造方法中调用子类和父类重写的方法,此时会发生动态绑定,最终调用的是子类重写后的方法
public class Test {
public Test() {
this.func();
}
public void func() {
System.out.println("父类方法");
}
}
class Child extends Test {
// public Child() {
// super();
// }
@Override
public void func() {
System.out.println("子类方法");
}
public static void main(String[] args) {
Child child = new Child();
}
}
打印结果是:
我们实例化子类对象,调用子类构造方法,子类构造方法先调用父类构造方法,父类构造方法中调用了func
方法,此时发生动态绑定,调用子类重写后的func
方法。
这样的代码我们不会写,我们要避免在构造方法中调用重写方法!
【静态绑定】
静态绑定也称为前期绑定(早绑定),即在编译时,根据用户所传递实参类型就确定了具体调用哪个方法。 如,方法重载,根据调用参数时的传入的参数,(不同的方法签名)在编译期间就可以确定调用哪个方法。
向上转型
向上转型:创建一个子类对象,将其当作父类对象使用(父类的引用 引用了子类的对象)
向上转型有三种方式:
- 直接赋值
- 方法传参
- 方法返回
【直接赋值】
//Base.java
public class Base {
public int a;
}
//Child.java
public class Child extends Base {
public int b;
}
//Test.java
public class Test {
public static void main(String[] args) {
//直接赋值
Base base = new Child();//父类Base的引用base指向了子类Child的对象
}
}
【方法传参】
//Base.java
public class Base {
public int a;
}
//Child.java
public class Child extends Base {
public int b;
}
//Test.java
public class Test {
public void test(Base base) {
System.out.println("方法传参实现向上转型");
}
public void useTest() {
Child child = new Child();
test(child);//传一个Child类的对象,用Base对象的引用接收,向上转型
}
public static void main(String[] args) {
Test test = new Test();
test.useTest();
}
}
【方法返回】
//Base.java
public class Base {
public int a;
}
//Child.java
public class Child extends Base {
public int b;
}
//Test.java
public class Test {
//返回类型为Base类,实际返回的是Child类,向上转型
public Base test() {
Child child = new Child();
return child;
}
}
向上转型的缺点:不能调用到子类特有的方法
//Base.java
public class Base {
public int a;
}
//Child.java
public class Child extends Base {
public int b;
public void myChild() {
System.out.println("子类特有的方法");
}
}
//Test.java
public class Test {
public static void main(String[] args) {
//正常子类引用指向子类对象,可以访问到子类特有的方法
Child child = new Child();
child.myChild();
//向上转型后,虽然base指向Child类对象,但是不能调用子类特有的方法了
Base base = new Child();
base.myChild();
}
}
向下转型
向下转型,就是子类的引用引用了父类的对象。
向下转型是有风险的,要强制类型转换才能实现。
以动物(父类)、猫(子类)、狗(子类)为例,猫和狗一定是动物,但是,动物不一定是猫。
向下转型能够处理向上转型不能够访问子类特有方法的问题:
//Animal.java
public class Animal {
public String name;
public int age;
//...
public void eat() {
System.out.println(this.name + "吃饭");
}
}
//Dog.java
public class Dog extends Animal {
@Override
public void eat() {
System.out.println(this.name + "吃狗粮");
}
public void bark() {
System.out.println(this.name + "正在汪汪叫");
}
}
//Cat.java
public class Cat extends Animal {
@Override
public void eat() {
System.out.println(this.name + "吃猫粮");
}
}
//Test.java
public class Test {
public static void main(String[] args) {
Animal animal = new Dog();//向上转型
animal.name = "大哈";
animal.eat();
Dog dog = (Dog)animal;//向下转型
dog.bark();//调用子类Dog特有的方法
}
}
向下转型是有风险的,例如(仅修改上述代码的Test
类):
public class Test {
public static void main(String[] args) {
Animal animal = new Cat();
animal.name = "小猫";
//animal此时指向的是猫对象
//向下转型成狗类
Dog dog = (Dog)animal;
//调用狗类特有方法
dog.bark();
}
}
如果上述代码可行,我们打印的结果就是:小猫正在汪汪叫
在我们的认知下,小猫怎么能汪汪叫呢,Java也是这么想的:
为了避免报错,我们可以使用 instanceof
关键字:
public class Test {
public static void main(String[] args) {
Animal animal = new Cat();
animal.name = "小猫";
if(animal instanceof Dog) {
Dog dog = (Dog) animal;
dog.bark();
}
}
}
instanceof
的作用是:判断左边的对象是否是右边类的实例,返回boolean
类型的值
所以,上例中,animal instanceof Dog
就是判断animal
的对象是否是Dog
类的实例,显然,animal
指向的是Cat
类对象,所以,返回false
。
理解多态
了解了上面的知识后,我们看一段代码,体现了多态:
//Animal.java
public class Animal {
public String name;
public int age;
//...
public void eat() {
System.out.println(this.name + "吃饭");
}
}
//Dog.java
public class Dog extends Animal {
@Override
public void eat() {
System.out.println(this.name + "吃狗粮");
}
}
//Cat.java
public class Cat extends Animal {
@Override
public void eat() {
System.out.println(this.name + "吃猫粮");
}
}
//Test.java
public class Test {
public static void func(Animal animal) {
animal.eat();//父类引用调用重写方法(动态绑定)
}
public static void main(String[] args) {
Dog dog = new Dog();
Cat cat = new Cat();
//传入Dog类和Cat类,Animal类接收,发生向上转型(方法传参)
Test.func(dog);
System.out.println("============");
Test.func(cat);
}
}
打印结果如下:
从调用者的角度看,我们给同一个func
方法传入不同的对象(猫、狗),得到的结果不一样(吃猫粮,吃狗粮),这就是多态。
【多态的优缺点】
优点:
简化代码,降低代码的"圈复杂度",避免大量使用
if-else
语句圈复杂度,简单讲就是代码中条件语句和循环语句的个数,个数越多越复杂。
使代码的可拓展性变强
对于优点一,我们设置一个场景,根据场景写代码体会:
假设我们要画一些图形:
父类和子类代码:
//Shape.java
public class Shape {
public void draw() {
System.out.println("画一个图形");
}
}
//Pentagram.java
public class Pentagram extends Shape {
@Override
public void draw() {
System.out.println("五角星☆");
}
}
//Rotundity.java
public class Rotundity extends Shape {
@Override
public void draw() {
System.out.println("圆形⭕");
}
}
//Square.java
public class Square extends Shape {
@Override
public void draw() {
System.out.println("正方形□");
}
}
测试类代码(实现画图形):
- 不使用多态
//Test.java
public class Test {
public static void main(String[] args) {
//实例化三个对象,后续通过它们调用自己的方法
Pentagram pentagram = new Pentagram();
Rotundity rotundity = new Rotundity();
Square square = new Square();
String[] strings = {"pentagram", "rotundity", "square"};
for(String s : strings){
if(s.equals("pentagram")){
pentagram.draw();
}else if(s.equals("rotundity")){
rotundity.draw();
}else{
square.draw();
}
}
}
}
if-else
语句使用较多。
- 使用多态
public class Test {
public static void main(String[] args) {
Pentagram pentagram = new Pentagram();
Rotundity rotundity = new Rotundity();
Square square = new Square();
Shape[] shapes = {pentagram, rotundity, square};
//向上转型
for(Shape shape : shapes){
shape.draw();//体现动态绑定
}
}
}
代码的圈复杂度明显降低了。
缺点:
降低代码运行效率
属性没有多态性
子类和父类具有同名属性时,通过父类引用,只能访问父类自己的属性
构造方法没有多态性
抽象类
抽象类的概念
Java使用类实例化对象来描述现实生活中的实体,不过,不是所有的类都能描述实体的,如果一个类中没有包含足够的信息来描绘一个具体的对象,这样的类就是抽象类(准确来说,我们可以将这样的类设计成抽象类)。
以画图形为例,有如下代码:
//Shape.java
public class Shape {
public void draw() {
System.out.println("画一个图形");
}
}
//Pentagram.java
public class Pentagram extends Shape {
@Override
public void draw() {
System.out.println("五角星☆");
}
}
//Rotundity.java
public class Rotundity extends Shape {
@Override
public void draw() {
System.out.println("圆形⭕");
}
}
我们在实现Shape
类的draw
方法时,由于Shape
是一个宽泛的概念,不是一个具体的图形,所以我们实际上是没办法实现的,上代码中的实现也没有实际的意义,因此,我们可以将Shape
类设计成抽象类,而对于Shape
类中的draw
方法,我们可以将它设计成抽象方法.
抽象类的语法
Java中,被 abstract
修饰的类称为 抽象类,抽象类中被 abstract
修饰的方法称作 抽象方法。
抽象方法不用给出具体的实现。(这是强制的,不能给出方法体)
将上面的Shape
类设计成抽象类,draw
方法设计成抽象方法:
//Shape.java
public abstract class Shape {
public abstract void draw();
}
注意: 抽象类也是类,抽象类也可以有普通的成员方法、成员变量或构造方法。
抽象类与普通类的区别是抽象类有abstract
修饰,且可以存在抽象方法(不存在也可以)。
抽象类的特性
-
抽象类不能实例化对象
public class Test { public static void main(String[] args) { Shape shape = new Shape();//实例化抽象类,报错 } }
抽象类虽然不能实例化,但是抽象类的引用可以引用其子类的对象
//Shape.java public abstract class Shape { public abstract void draw(); } //Pentagram.java public class Pentagram extends Shape { @Override public void draw() { System.out.println("五角星☆"); } } //Rotundity.java public class Rotundity extends Shape { @Override public void draw() { System.out.println("圆形⭕"); } } //Test.java public class Test { public static void func(Shape shape) { shape.draw(); } public static void main(String[] args) { Pentagram pentagram = new Pentagram(); Rotundity rotundity = new Rotundity(); Shape[] shapes = {pentagram, rotundity}; //向上转型 for(Shape shape : shapes){ func(shape); } } }
-
抽象类必须被继承,且继承抽象类的子类必须重写抽象类中的抽象方法,如果不想重写抽象方法,需要加
abstract
修饰。不过,一旦这个类被一个普通的类继承,则此普通类必须重写所有的抽象方法。public abstract class Test { public abstract void func1(); } abstract class A extends Test { public abstract void func2(); } class B extends A { @Override public void func1() { System.out.println("重写抽象类Test的抽象方法"); } @Override public void func2() { System.out.println("重写抽象类A的抽象方法"); } }
抽象类
A
继承了抽象类Test
,普通类B
继承了A
,那么普通类B
必须重写A
类和Test
类中的所有抽象方法。 -
由于抽象方法必须被重写,所以抽象方法不能被
private
、final
、static
修饰 -
抽象类中可以不包含抽象方法,但抽象方法必须在抽象类中
public class Test { public abstract void func(); }
-
抽象类中可以存在构造方法,用于子类实例化时初始化父类的成员
//Shape.java public abstract class Shape { public int a; //抽象类的构造方法 public Shape(int a) { this.a = a; } public abstract void draw(); } //Pentagram.java public class Pentagram extends Shape { public Pentagram(int a) { super(a); } @Override public void draw() { System.out.println("五角星☆"); } } //Rotundity.java public class Rotundity extends Shape { public Rotundity(int a) { super(a); } @Override public void draw() { System.out.println("圆形⭕"); } } //Test.java public class Test { public static void main(String[] args) { Shape shape = new Pentagram(20); System.out.println(shape.a); } }
打印结果是:
抽象类的意义
我们可能会有一个疑问,普通类也可以被继承,普通的成员方法也可以被重写,为什么要用抽象类和抽象方法呢?
抽象类中的抽象方法如果没有被重写,编译器会报错。是的,使用抽象类和抽象方法的好处就是多了一层编译器的校验,能帮助我们检查抽象方法是否被重写。
抽象类不能被实例化。 很多引用场景,比如上面的画图形场景,其实际工作不应该由父类完成,而应由子类完成,此时误用父类编译器会报错。
总的来说,就是为了预防出错,并让我们尽早发现问题,充分利用编译器的校验,在实际开发中是非常有意义的。
接口
接口的概念
接口(英文:Interface),在JAVA编程语言中是一个抽象(引用)类型,是抽象方法的集合。一个类通过继承接口的方式,从而来继承接口的抽象方法。
接口并不是类,编写接口的方式和类很相似,但是它们属于不同的概念。类描述对象的属性和方法。接口则包含类要实现的方法。
我们很容易联想到实际生活中电脑的USB接口,它是一个标准,符合这个标准,就可以与电脑交互(可以插U盘、鼠标等)。
Java中的接口也是公共行为的规范,我们在实现时,只要符合标准,就可以通用。所以,接口可以看作是:多个类的公共规范,是一种引用类型
接口的语法
接口的定义与类的定义类似,只需要把关键字class
换成interface
接口只能被public
、abstract
修饰,abstract
默认不显性显示,如果省略public
则使用默认的访问权限
public interface 接口名 {
//...
}
- 接口的命名一般是以
I
开头 - 接口的命名一般使用形容词词性的单词,这样就能显示出接口的标准
接口的特性
-
接口是一种引用类型,但是不能直接实例化对象(接口实际上比抽象类更加抽象),不过接口可以引用实现该接口的类的对象(向上转型)
public static void main(String[] args) { ITest iTest = new ITest();//报错,不能直接实例化接口 }
interface ITest { //待实现的方法 void func(); } class A implements ITest { @Override public void func() { System.out.println("类A实现的func"); } } class NewTest { public static void main(String[] args) { //接口引用了A类对象 ITest iTest = new A(); } }
-
接口可以定义变量,且接口的成员变量都是
public
、static
、final
修饰的,且接口定义的变量必须手动初始化即使我们不写修饰符,成员变量也默认被
public
、static
、final
修饰public abstract interface ITest { //下面定义的变量都是由 public static final 修饰的(不论是否显性书写或写全) public int a = 1; static int b = 2; final int c = 3; int d = 4; public static int e = 5; static final int f = 6; public final int g = 7; public static final int h = 8; }
-
接口可以定义方法,接口的方法默认是
public
、abstract
修饰的,属于抽象方法,不需要方法体。如果想要在接口中具体实现一个方法,那么它必须是由default
或static
修饰public interface ITest { //以下写法的修饰符都是 public abstract(无论是否写或写全) void func0(); public abstract void func1(); public void func2(); abstract void func3(); //被default或static修饰的方法有具体的实现 default void func4() { System.out.println("接口中被default修饰的方法有方法体"); } static void func5() { System.out.println("接口中被static修饰的方法有方法体"); } }
-
实现接口的类,必须重写接口中的所有抽象方法,如果此类不想重写接口中的抽象方法,则它必须由
abstract
修饰,一旦此类被另一个非抽象类继承,那么这个非抽象类必须重写所有未被重写的抽象方法interface IA { //接口抽象方法 void abfunc1(); } abstract class A implements IA { public void myA() { System.out.println("A类特有的方法"); } //A类抽象方法 abstract void abfunc2(); } class B extends A { @Override public void abfunc1() { System.out.println("重写IA接口中的抽象方法"); } @Override void abfunc2() { System.out.println("重写B类中的抽象方法"); } }
-
接口中不能有静态代码块、构造代码块或构造方法
-
由于接口的成员方法默认都有
public
修饰,所以重写后的方法必须是public
修饰(重写后的方法的访问权限要大于等于父类) -
上述表述的重写严格来说不准确,不过,语法要求与重写一致(这里不必纠结,只要记住抽象方法要被重写即可)
-
接口文件的文件名必须与接口名相同,且接口文件编译后也是
.class
为后缀的字节码文件
接口的使用
接口不能直接实例化对象,接口的使用需要一个 “实现类” 来 实现 接口中的方法,要用到关键字implements
public class 类 implements 接口 {
//...
}
接口与类之间implements
是实现关系,父类和子类之间extends
是继承关系
interface ITest {
//待实现的方法
void func();
}
class A implements ITest {
@Override
public void func() {
System.out.println("类A实现的func");
}
}
继承表达的是is A
的关系,如狗是动物;接口表达的含义是具有xxx特性
,如狗会跑
实现多个接口
Java中不支持多继承,不过一个类可以实现多个接口,这个类需要实现所有接口的抽象方法:
//ISwimming.java
public interface ISwimming {
void swim();
}
//IRunning.java
public interface IRunning {
void run();
}
//Dog.java
public class Dog implements IRunning, ISwimming {
public String name;
public int age;
public Dog(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public void run() {
System.out.println(this.name + "正在跑");
}
@Override
public void swim() {
System.out.println(this.name + "正在游泳");
}
}
- 一个类实现多个接口时,每个接口中的抽象方法都要实现,否则类必须设置为抽象类
一个类可以继承一个父类并同时实现多个接口:
//Animal.java
public class Animal {
public String name;
public int age;
}
//IRunning.java
public interface IRunning {
void run();
}
//ISwimming.java
public interface ISwimming {
void swim();
}
//Dog.java
public class Dog extends Animal implements IRunning, ISwimming {
public Dog(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public void run() {
System.out.println(this.name + "正在跑");
}
@Override
public void swim() {
System.out.println(this.name + "正在游泳");
}
}
- 继承与实现都存在时,必须先写
extends
继承,后写implements
实现 - 对于上面的代码,理解一点:只要一个类实现了如
IRunning
接口,它就可以run()
,而不必是动物。(有些废话,主要是强调接口使用)
接口与多态
Java中,接口可以实现多态,看如下代码:
//IEat.java
public interface IEat {
void eat();
}
//Animal.java
public class Animal {
public String name;
public int age;
}
//Cat.java
public class Cat extends Animal implements IEat {
public Cat(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public void eat() {
System.out.println(this.name + "正在吃猫粮...");
}
}
//Dog.java
public class Dog extends Animal implements IEat {
public Dog(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public void eat() {
System.out.println(this.name + "正在吃狗粮...");
}
}
//Test.java
public class Test {
//体现多态
public static void testEat(IEat iEat) {
iEat.eat();
}
public static void main(String[] args) {
testEat(new Dog("大哈", 2));//匿名对象
testEat(new Cat("小猫", 3));
}
}
对于上述代码,我们给同一个方法testEat
传入不同的对象Cat
和Dog
,打印结果不同:
接口间的继承
接口的继承相当于把所有的接口合并在一起。
接口间可以实现继承
//IExcellentQuality.java
public interface IExcellentQuality {
void func1();
}
//IKind.java
public interface IKind extends IExcellentQuality {
void func2();
}
上述代码逻辑上就是:善良的 是 品质优良的
-
一个接口继承了另一个接口,子接口不实现父接口的抽象方法,而当一个非抽象类实现了子接口,那么这个类要重写子接口和父接口所有的抽象方法
public class Person implements IKind { @Override public void func2() { System.out.println("kind"); } @Override public void func1() { System.out.println("excellent quality"); } }
Java中,类与类之间不可以实现多继承,但接口与接口之间可以实现多继承
比如以下例子,两栖动物既可以游泳又可以行走:
//IRunning.java
public interface IRunning {
void run();
}
//ISwimming.java
public interface ISwimming {
void swim();
}
//IAmphibious.java
public interface IAmphibious extends ISwimming, IRunning {
void func();
}
【总结】
- 一个接口继承了另一个接口,子接口不实现父接口的抽象方法,而当一个非抽象类实现了子接口,那么这个类要重写子接口和父接口所有的抽象方法
- 接口与接口之间可以实现多继承
抽象类和接口的区别
抽象类中可以包含普通的方法和字段,这些字段和方法可以被子类直接使用(普通方法不需要被重写),而接口中不能定义普通的字段和方法,子类必须重写接口中的所有抽象方法。
几点具体的区别:
-
- 抽象类中的方法可以有方法体,就是能实现方法的具体功能,但是接口中的方法不行(除非被
default
或static
修饰)。
- 抽象类中的方法可以有方法体,就是能实现方法的具体功能,但是接口中的方法不行(除非被
-
- 抽象类中的成员变量可以是各种类型的,而接口中的成员变量只能是 public static final 类型的。
-
- 接口中不能含有静态代码块以及静态方法(用 static 修饰的方法),而抽象类是可以有静态代码块和静态方法。(从
Java8
开始接口中可以定义静态方法)
- 接口中不能含有静态代码块以及静态方法(用 static 修饰的方法),而抽象类是可以有静态代码块和静态方法。(从
-
- 一个类只能继承一个抽象类,而一个类却可以实现多个接口。
另外,我们补充一下实现接口与继承的区别:
1.不同的关键字,即实现接口(implements),继承(extends);
2.(一个类只能继承一个父类,而一个方法可以实现多个接口) 在面向对象编程的时候只能是单继承,但 是实现接口可以有多个,简单点说,就是实现接口可以有好多个,但是继承的中父类只能只有一个,因为父亲只有一个,这说明了继承在Java中具有单根性,子类只能去继承一个父类;
3.在接口中只能定义全局变量和抽象方法,而在继承中可以定义普通的属性、方法等等…
4.(接口中的抽象方法必须被重写,而继承中子类可以选择不重写父类的方法(不考虑父类是抽象类)) 当某个接口被实现的时候,在类中一定要重写接口中的抽象方法,而继承中子类可以选择是否重写父类的方法,不是强制的
Object类
Java的Object
类是所有Java类的父类,每一个类都直接或间接地继承自Object
类。 也就是说,如果我们创建了一个类,如果这个类没有继承任何类,其默认也会继承Object
类。
Object
类位于java.lang
包中(编译时自动导入),它定义了所有对象的通用行为和方法,比如常用的toString()
、equals()
方法,每个类都继承了Object
类的所有公共方法,它们默认存在于每个Java对象中。
综上所述,Java的Object
类在Java中扮演着重要的角色,它是所有Java类的基类和起点,为Java面向对象编程提供了基础。
用Object
类接收所有类的对象:
public class Student {
public String name;
public int age;
public String id;
public Student(String name, int age, String id) {
this.name = name;
this.age = age;
this.id = id;
}
}
class Main {
public static void main(String[] args) {
Object object = new Student("张三", 17, "男");
System.out.println(object);
}
}
Object类中的方法
我们会详细讲解equals()
和toString()
方法及其重写,对于其他的方法,我们简单一介绍。
如下图为Object
类中的所有方法:
【toString】
取得对象的信息,返回对象的字符串的表示,默认输出的是一个对象在内存中的地址值
package demo8;
public class Student {
public String name;
public int age;
public String id;
public Student(String name, int age, String id) {
this.name = name;
this.age = age;
this.id = id;
}
}
class Main {
public static void main(String[] args) {
Student student1 = new Student("张三", 17, "男");
System.out.println(student1);
System.out.println(student1.toString());
}
}
打印结果为:
我们看到,两种打印的结果都是字符串形式,此时有一个问题,第一次打印没有调用toString
方法,为什么打印结果和调用toString
方法一致呢?
这是因为println
方法底层调用了toString
方法:
我不想打印地址,想打印对象信息怎么办? 实际情况下,我们通常会有这样的问题,重写即可
IDEA中可以快速帮我们重写,步骤如下:
先在要重写toString
的类中右击鼠标,根据红框提示点击…
此时,我们再次打印观察:
package demo8;
public class Student {
public String name;
public int age;
public String id;
public Student(String name, int age, String id) {
this.name = name;
this.age = age;
this.id = id;
}
@Override
public String toString() {
return "Student{" +
"name='" + name + '\'' +
", age=" + age +
", id='" + id + '\'' +
'}';
}
}
class Main {
public static void main(String[] args) {
Student student1 = new Student("张三", 17, "男");
System.out.println(student1);
System.out.println(student1.toString());
}
}
打印结果为:
此时,我们发现成功打印出了对象的具体信息。
我们可以通过重写Object
类中的toString
方法来实现对象具体信息的打印
【equals】
比较对象是否一致,默认比较的是地址
package demo8;
public class Student {
public String name;
public int age;
public String id;
public Student(String name, int age, String id) {
this.name = name;
this.age = age;
this.id = id;
}
@Override
public String toString() {
return "Student{" +
"name='" + name + '\'' +
", age=" + age +
", id='" + id + '\'' +
'}';
}
}
class Main {
public static void main(String[] args) {
Student student1 = new Student("张三", 17, "男");
Student student2 = new Student("张三", 17, "男");
System.out.println(student1.equals(student2));
}
}
打印结果是:
如打印结果显示,equals
方法比较的不是对象的信息,而实际上比较的是对象的地址,如上代码,虽然两个Student
对象的信息一致,但是是new
了两个不同的对象,所有打印结果是false
。
如果想要比较两个对象的信息,重写equals
方法
我们依旧可以按照上面的步骤在IDEA自动生成:
package demo8;
import java.util.Objects;
public class Student {
public String name;
public int age;
public String id;
public Student(String name, int age, String id) {
this.name = name;
this.age = age;
this.id = id;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Student student = (Student) o;
return age == student.age && Objects.equals(name, student.name) && Objects.equals(id, student.id);
}
@Override
public int hashCode() {
return Objects.hash(name, age, id);
}
@Override
public String toString() {
return "Student{" +
"name='" + name + '\'' +
", age=" + age +
", id='" + id + '\'' +
'}';
}
}
class Main {
public static void main(String[] args) {
Student student1 = new Student("张三", 17, "男");
Student student2 = new Student("张三", 17, "男");
System.out.println(student1.equals(student2));
}
}
重写toString
方法时,一般也要重写hashCode
方法,由于我们还没有学习哈希表的知识,所以我们一笔带过,之后数据结构部分就自然懂得。
此时打印结果:
Comparable与Comparator接口
我们可能会遇到这样的问题:怎么对一个对象数组进行排序? 比如对一个狗类对象数组进行排序,而想到这,我们又会有一个问题:怎么比较两个对象?如果我想自定义标准,怎么办?
与基本类型的比较并排序不同,对象数组没有一个统一的标准来进行比较来排序,此时就可以基于Comparable
接口实现或者基于比较器实现(Comparator
接口)
- 自然排序:基于Comparable接口
- 定制排序:基于Comparator接口
本文均以自定义的Person
类为例:
public 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 void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
Comparable接口
使用了Comparable
接口,意味着此类可排序,使用方法是:在想要实现比较的类中实现Comparable
接口并重写compareTo()
方法
我们先观察一下Comparable
接口的源码:
我们发现,Comparable
接口中只有一个CompareTo
方法
观察完毕后,我们看如下实现代码:
//Person.java
//实现Comparable接口
public class Person implements Comparable<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 void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
//重写compareTo方法,这里按照name(字符串)比较
@Override
public int compareTo(Person o) {
if(this.name.compareTo(o.name) > 0) {
return 1;
}else if(this.name.compareTo(o.name) < 0) {
return -1;
}else {
return 0;
}
}
}
//Test.java
import java.util.Arrays;
public class Test {
public static void main(String[] args) {
Person[] people = new Person[]{new Person("zhangsan", 23), new Person("lisi", 15),
new Person("wangwu", 42)};
//实现了Comparable接口后,可以通过Arrays类中的排序方法给对象数组排序了
Arrays.sort(people);
for(Person p : people) {
System.out.println(p);
}
}
}
打印结果如下:
-
Comparable
接口后面的<>
是泛型知识,传入要比较的类即可 -
实现
Comparable
接口必须重写CompareTo
方法,方法要求返回int
类型的值,一般内部实现的逻辑:调用方法的对象 > 作为参数的对象,返回正数;调用方法的对象 < 作为参数的对象,返回负数;调用方法的对象 == 作为参数的对象,返回0
-
一个类实现了
Comparable
接口,那么就可以调用Arrays
类中的sort
方法对存放此类对象的数组进行排序 -
一个实现了
Comparable
接口的类只能重写一个compareTo
方法,这也意味着标准被固定
Comparator接口
前面提到,基于Comparable
接口实现比较的标准固定,且不便在原代码修改,这种情况下,我们可以通过Comparator
接口(比较器)实现,方法是:定义一个或多个比较器类,实现Comparator
接口,并重写Compare
方法
我们先观察一下Comparator
接口的一部分源码:
这部分源码显示,Comparator
接口中包含compare
和equals
方法,实现了Comparator
接口的类可以不重写equals
方法,但是一定要重写compare
方法
观察完毕后,我们看如下实现代码:
//Person.java
public 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 void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
//AgeComparator.java
//以年龄比较的比较器
import java.util.Comparator;
public class AgeComparator implements Comparator<Person> {
@Override
public int compare(Person o1, Person o2) {
return o1.getAge() - o2.getAge();
}
}
//NameComparator.java
//以名字比较的比较器
import java.util.Comparator;
public class NameComparator implements Comparator<Person> {
@Override
public int compare(Person o1, Person o2) {
return o1.getName().compareTo(o2.getName());
}
}
//Test.java
import java.util.Arrays;
public class Test {
public static void main(String[] args) {
Person[] people = new Person[]{new Person("zhangsan", 23), new Person("lisi", 15),
new Person("wangwu", 42)};
//使用时,必须实例化比较器对象
AgeComparator ageComparator = new AgeComparator();
NameComparator nameComparator = new NameComparator();
//正常使用
System.out.println(ageComparator.compare(new Person("张三", 16), new Person("李四", 10)));
//与Arrays类中的sort方法配合使用
Arrays.sort(people, ageComparator);
for(Person p : people) {
System.out.println(p);
}
}
}
打印结果如下:
- 要比较的对象的类不需要实现
Comparator
接口,比较器需要实现Comparator
接口,并在<>
内给出要比较的类,并重写compare
方法 - 重写的
compare
方法有两个参数,分别是要比较的两个对象,重写的compare
方法要求:返回值的正负以及零,表示不同的比较结果。例如,左参数对象大于右参数对象,返回正值;左参数对象小于右参数对象,返回负值;左右参数对象相等,返回0 - 使用时,必须实例化比较器类对象。可以选择直接调用其中的
compare
方法比较单一对象,也可以配合Arrays
类中的sort
方法对对象数组进行排序,此时sort
方法需要两个参数:1. 对象数组 2. 比较器对象 - 比较器可以创建若干个,意味着我们可以定义多个标准,相对灵活一些
区别:
Comparable
相当于 “内部比较器”,Comparator
相当于 “外部比较器”- 对于基于
Comparable
的比较,需要手动实现接口,侵入性比较强,但一旦实现,每次调用该类都有顺序,属于内部顺序- 对于基于
Comparator
的比较,需要实现一个比较器对象,对待比较类的侵入性弱,但对算法代码实现侵入性强侵入性:让用户代码产生对框架的依赖,这些代码不能直接脱离框架使用,不利于代码的复用
关于对象的比较,数据结构部分会经常用到
Clonable接口与深拷贝
考虑:怎样将对象克隆一份?
答案就在本文,我们先给出一步一步的思考过程,然后总结:
首先设置情景:我们有一个
Person类
,我们要在TestClone测试类
中克隆Person类
实例化出来的的对象//Person.java public class Person { public String name; public int age; public Person(String name, int age) { this.name = name; this.age = age; } } //TestClone.java public class TestClone { public static void main(String[] args) { //测试代码 } }
先在要克隆的对象的类中重写clone
方法
public class Person {
public String name;
public int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
//重写clone方法
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}
重写后我们写下测试代码如下:
public class Testclone {
public static void main(String[] args) {
Person person1 = new Person("zhangsan", 23);
Person person2 = person1.clone();
}
}
我们发现报错了,这是因为clone()
方法的返回值是Object
类型:
对此,我们要强制类型转换:
我们发现,代码仍然报错:
这是因为一个类对象想被clone()
必须实现Clonable接口
,我们观察一下Clonable接口
:
这是一个空接口,空接口也称标记接口,这里表示当前类实例化的对象可以被克隆。我们只需记住要clone
时一定要实现Clonable接口
完成上述操作后还是会报错,我们需要throws CloneNotSupportedException
这样一段代码:
这里涉及到异常
的知识,这里不具体介绍,在异常篇会详细介绍。
我们直接看代码:
//Person.java
public class Person implements Cloneable {
public String name;
public int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
//重写clone方法
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
//重写toString方法,方便对象字段的打印
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
//TestClone.java
public class TestClone {
//在main函数的参数列表之后添加了异常捕获
public static void main(String[] args) throws CloneNotSupportedException {
Person person1 = new Person("zhangsan", 23);
Person person2 = (Person)person1.clone();
//打印观察效果
System.out.println("克隆参考person1:" + person1);
System.out.println("person2克隆后:" + person2);
}
}
【注意事项总结】
- 在要被克隆的对象的类中重写
clone()
方法 - 要被克隆的对象的类要实现
Clonable接口
- 调用
clone()
的方法的参数列表的后面要加上throws CloneNotSupportedException
处理异常 - 调用
clone()
方法时要对返回值执行强制类型转换,将其转换成要克隆的对象的类型
我们接下来介绍一下克隆操作的内存情况(以上面的Person
为例):
首先,创建person1
对象,在堆区开辟一块空间,person1
指向这块空间。
克隆时,会在堆区开辟一块新的空间,将person1
的成员变量的值给到新空间,然后将这块新空间的地址返回被接收。
【深拷贝】
深拷贝与浅拷贝只针对 引用类型
我们对上面的情景进一步修改,创建一个
Money类
,在Person类
中增加一个Money类
的对象。
如下代码:
//Money.java
public class Money {
public double money = 10;
}
//Person.java
public class Person implements Cloneable {
public String name;
public int age;
public Money m = new Money();//实例化Money对象成员
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
//TestClone.java
public class TestClone {
public static void main(String[] args) throws CloneNotSupportedException {
Person person1 = new Person("zhangsan", 23);
Person person2 = (Person) person1.clone();
person1.m.money = 100;//仅对person1的money进行修改
System.out.println("person1的money: " + person1.m.money);
System.out.println("person2的money: " + person2.m.money);
}
}
上述代码打印结果是:
我们只对person1
的money
进行修改,为什么person2
的money
也变化了?
这就是浅拷贝的缘故,浅拷贝只复制指向某个对象的地址,而不复制对象本身,新旧对象还是共享同一块内存。
我们给出克隆完成后的内存分布:
我们知道,类就是一种引用类型,存放其指向的对象的地址。
堆上为person1
对象开辟了一块空间,这块空间包含了引用类型m
,堆区为对象m
开辟一块空间,存放money
值
浅拷贝时,堆上会开辟一块空间,然后将person1
在堆上的数据拷贝给新的堆空间,对于引用变量m
,也是只将值拷贝,最后将这块堆空间的地址返回,person2
接收。造成的结果是person1
和person2
的m
指向了同一个Money
对象,所以,通过person1
改变其money
的值会影响到person2
那么,怎么将上述Money
对象也克隆一份呢?
这里就要用到深拷贝,深拷贝会另外创造一个一模一样的对象,新对象跟原对象不共享内存,修改新对象不会改到原对象:
分析:我们需要将person1
的m对象
也克隆一份给到person1
的m
代码实现:
-
修改
Money
类 (实现Clonable接口
并重写clone()方法
),让其可克隆//Money.java public class Money implements Cloneable { public double money = 10; @Override protected Object clone() throws CloneNotSupportedException { return super.clone(); } }
-
修改
Person
类的clone()
//Person.java public class Person implements Cloneable { public String name; public int age; public Money m = new Money(); public Person(String name, int age) { this.name = name; this.age = age; } //修改 @Override protected Object clone() throws CloneNotSupportedException { Person tmp = (Person) super.clone();//接收克隆后的对象 tmp.m = (Money) this.m.clone();//克隆原对象的m对象并赋值给新对象的m return tmp; } @Override public String toString() { return "Person{" + "name='" + name + '\'' + ", age=" + age + '}'; } }
-
测试
//TestClone.java public class TestClone { public static void main(String[] args) throws CloneNotSupportedException { Person person1 = new Person("zhangsan", 23); Person person2 = (Person) person1.clone(); person1.m.money = 100; System.out.println("person1的money: " + person1.m.money); System.out.println("person2的money: " + person2.m.money); } }
【总结】
- 如果想要克隆的对象的类中含有自定义的引用类型的成员(排除
String
),则必须代码实现深拷贝 - 深拷贝和浅拷贝是代码实现的,说
clone()
方法是深拷贝或浅拷贝是不准确的
内部类
Java内部类是一种特殊的类定义方式,它允许在一个类的内部定义另一个类。
内部类可以访问其所在外部类的成员变量和成员方法,这使得它非常适用于封装与外部类紧密相关的私有逻辑。
内部类是类的五大成员之一。【成员变量、成员方法、构造方法、代码块、内部类】
内部类有以下四种:
- 静态内部类
- 实例内部类
- 匿名内部类
- 局部内部类
内部类的优势:
- 每个内部类都可以独立地继承一个接口的实现,无论外部类是否继承了接口的实现,这使多继承的方案得到进一步的完善
- 内部类可以实现特殊的逻辑关系,并能对外隐藏
- 内部类方便编写实现事件驱动程序
- 内部类方便编写线程代码
内部类有一个单独的字节码文件,字节码文件格式:
静态内部类
Java的静态内部类是用static
修饰的内部类
【特点】
-
静态内部类实例化对象不依赖于外部类,不需要先实例化外部类对象
格式:
外部类.静态内部类 变量名 = new 外部类.静态内部类();
-
可以使用
public
、protected
、default(默认权限)
、private
修饰静态内部类 -
静态内部类中不能直接访问其外部类的非静态的成员,要依赖外部类的对象进行访问
-
静态内部类中可以定义静态成员变量和方法
-
静态内部类中可以有构造方法
-
如果静态内部类成员与其外部类成员同名,可以使用对应的类名访问指定类中的同名成员(记得考虑成员是静态还是非静态)
如下代码:
public class OutClass {
public String s1 = "外部类的非静态成员";
public static String outs2 = "外部类的静态成员";
public int i = 10;
static class InnerClass {
public String s1 = "静态内部类的非静态成员变量";
public String inners2 = "静态内部类的静态成员变量";
//静态内部类的构造方法
public InnerClass(String s1) {
this.s1 = s1;
}
public void test1() {
System.out.println("静态内部类的非静态成员方法执行了...");
//静态内部类中不能直接访问其外部类的非静态的成员,要依赖外部类的对象进行访问
OutClass outClass = new OutClass();
System.out.println(outClass.i);
}
public static void test2() {
System.out.println("静态内部类的静态成员方法");
}
}
}
class Main {
public static void main(String[] args) {
//静态内部类实例化对象不依赖于外部类,不需要先实例化外部类对象
OutClass.InnerClass innerClass = new OutClass.InnerClass("新静态内部类的非静态成员变量");
innerClass.test1();
}
}
实例内部类
即在外部类类内,方法外定义的非static
修饰的内部类
【特点】
- 实例内部类依赖其外部类,实例化 实例内部类对象时,要先实例化外部类对象
- 可以使用
public
、protected
、default(默认权限)
、private
修饰实例内部类 - 实例内部类中不能定义静态成员变量,除非它由
final
修饰 - 实例内部类中不能定义静态方法
- 实例内部类可以存在构造方法
- 实例内部类可以直接访问其外部类的成员,包括
private
、static
修饰的成员 - 当实例内部类成员与其外部类成员同名时,会优先访问实例内部类自己的成员,如果想访问其外部类的同名成员,要通过下面的方式:
外部类.this.成员
如下代码:
public class OutClass {
private String s1 = "外部类的非静态成员变量";
public static String s2 = "外部类的静态成员变量";
public int a = 10;
class InnerClass {
public String s = "实例内部类的非静态成员";
public static final String ss = "实例内部类的静态成员,必须由final修饰";
public int a = 20;
public InnerClass() {
}
public InnerClass(String s) {
this.s = s;
}
public void test() {
//同名成员默认访问实例内部类自己的
System.out.println(a);
//通过 父类.this.同名成员 访问外部类的同名成员a
System.out.println(OutClass.this.a);
//实例内部类可以访问其外部类的所有成员,包括private、static修饰的
System.out.println(s1);
System.out.println(s2);
}
}
}
class Main {
public static void main(String[] args) {
//实例化实例内部类对象
//1.分两步:先实例化外部类的对象,再通过外部类对象实例化实例内部类对象
OutClass outClass = new OutClass();
OutClass.InnerClass innerClass1 = outClass.new InnerClass();
//2.实例化完外部类对象后直接实例化实例内部类对象
OutClass.InnerClass innerClass2 = new OutClass().new InnerClass();
innerClass1.test();
}
}
匿名内部类
匿名内部类没有类名,直接在创建对象时定义并实现,一般用于创建只需要使用一次的类对象
匿名内部类用于简化代码、增加代码的可读性和可维护性
【特点】
- 匿名内部类中不能定义任何的静态成员
- 由于匿名内部类没有类名,自然也不存在构造方法
- 一个匿名内部类一定是在
new
后面,用来隐式地实现一个接口或继承一个类 - 匿名内部类的类内就是花括号的范围
- 匿名内部类可以用来给方法传参,以此实现多态
- 可以通过匿名内部类实现的接口或匿名内部类继承的父类的引用变量接收,不过前提是花括号后面不能用
.
访问任何成员
//ITest.java
public interface ITest {
void func();
}
//Animal.java
public class Animal {
private String name;
//...
public void test() {
System.out.println("Animal类中的方法...");
}
}
//OutClass.java
public class OutClass {
//匿名内部类可以作为方法的参数,实现多态
public static void func(ITest iTest) {
iTest.func();
}
public static void test() {
//匿名内部类隐含实现接口,并直接调用自己的成员方法
new ITest() {
public int a = 10;
public void innerClassTest() {
System.out.println("匿名内部类中的成员方法...");
}
@Override
public void func() {
System.out.println("重写接口的抽象方法...");
}
}.innerClassTest();
//匿名内部类隐含继承Animal类,并可以调用父类的方法,证明这里确实隐含地继承了Animal类
new Animal() {
public void childTest() {
System.out.println("匿名内部类的成员方法...");
}
}.test();
//可以通过匿名内部类实现的接口或匿名内部类继承的父类的引用变量接收,不过前提是花括号后面不能用 . 访问任何成员
ITest iTest = new ITest() {
@Override
public void func() {
System.out.println("实现接口方法...");
}
};
Animal animal = new Animal() {
public String name;
};
}
}
class Main {
public static void main(String[] args) {
OutClass.test();
//匿名内部类作为方法参数传入,在此方法内部实现了多态
OutClass.func(new ITest() {
@Override
public void func() {
System.out.println("实现接口的方法...");
}
});
}
}
局部内部类
定义在方法体内的类就是局部内部类,在方法外就不能使用了。
【特点】
- 局部内部类位于方法体内,其只能在方法体内使用
- 局部内部类中不能定义静态成员
- 局部内部类中可以存在构造方法
- 局部内部类可以直接访问其所在方法的局部变量以及方法所在类的成员
- 局部内部类不能被访问修饰符
public
、protected
、private
以及static
修饰
public class OutClass {
public String s1 = "局部内部类所在方法的类的非静态成员变量";
private static String s2 = "局部内部类所在方法的类的静态私有成员变量";
public static void innerClassFunc() {
int a = 10;
final int b = 20;
class InnerClass {
public String s1 = "局部内部类的非静态成员变量";
//public static s2 = "局部内部类中 不能存在 静态的成员";
public InnerClass() {
}
public InnerClass(String s1) {
this.s1 = s1;
}
public void test() {
System.out.println(a);
System.out.println(b);
System.out.println(s1);
System.out.println(s2);
}
}
InnerClass innerClass = new InnerClass();
innerClass.test();
}
}
class Main {
public static void main(String[] args) {
OutClass.innerClassFunc();
}
}
String类
C语言中没有专门的字符串类型,一般使用字符数组或字符指针表示字符串,而字符串的函数需要包含头文件才能使用,这种数据与方法分离的做法不符合面向对象的思想。
Java中提供了String类
来表示字符串
String的构造及内存分布
构造
字符串的构造方式主要有三种:
-
常量串构造
String str1 = "hello";
-
直接
new
String
对象String str2 = new String("hello");
-
使用字符数组进行构造
char[] ch = new char[]{'h', 'e', 'l', 'l', 'o'}; String str3 = new String(ch);
内存分布
核心就是 字符串常量池
JDK6及以前,字符串常量池存放在在永久代(内存的永久保存区域)
JDK7之后,字符串常量池存放在堆
字符串常量构造字符串时,Java在字符串常量池中寻找该字符串的内存地址,找到则将地址赋值给变量,如果没有找到,则在常量池开辟空间并将这块空间赋值该字符串,将这块地址赋值给变量。
new
构造字符串时,Java先在堆区开辟一块空间,存放String对象
,然后在字符串常量池中寻找字符串的内存地址,找到则将该地址的引用传给堆区的对象,如果没有找到,则在常量池开辟空间并将这块空间赋值该字符串,将地址的引用传给堆区的对象,最后,将堆区对象的地址赋值给变量。
如下代码及内存分布:
public static void main(String[] args) {
String s1 = "hello";
String s2 = "hello";
String s3 = new String("hello");
String s4 = new String("hello");
}
我们在IDEA
中观察String
的源码:
我们可以看到,String
类中包含很多的成员变量,我们仅关注value
数组即可,它存放的就是字符串常量池的某个字符串的地址,其内存分布简单表示如下图:
String str1 = new String("hello");
字符串非常重要,我们接下来介绍一下Java中字符串的常用方法。
常用方法
判等
字符串的判等区分两种:
==
:判断是否引用同一个对象boolean equals()
:判断字符串的内容是否相等
字符串类型是引用类型,==
判断两个字符串的地址,而equals()
方法判断两个字符串的内容是否相等(无关地址)
我们看下面一段代码,体会==
与equals()
的区别:
public class Test {
public static void main(String[] args) {
//new了三个不同的对象
String str1 = new String("hello");
String str2 = new String("HELLO");
String str3 = new String("hello");
System.out.println(str1 == str2);//不同对象的地址不同
System.out.println(str1 == str3);//不同对象的地址不同
System.out.println(str1.equals(str2));//两个字符串的内容不同
System.out.println(str1.equals(str3));//两个字符串的内容相同
System.out.println("=============");
str2 = str1;//将引用变量str1的值赋给str2,此时str1和str2指向同一个对象
System.out.println(str1 == str2);//指向同一个对象后,地址相同
System.out.println(str1.equals(str2));//指向同一个对象,内容自然也相同
}
}
打印观察:
我们再来看一段代码:
public static void main(String[] args) {
String str1 = "hello";
String str2 = "HELLO";
String str3 = "hello";
System.out.println(str1 == str2);//直接指向了字符串常量池的不同字符串,地址当然不同
System.out.println(str1 == str3);//直接指向了字符串常量池的同一字符串,地址相同
System.out.println(str1.equals(str2));//内容不同
System.out.println(str1.equals(str3));//内容相同
System.out.println("=============");
str2 = str1;
System.out.println(str1 == str2);
System.out.println(str1.equals(str2));
}
打印结果是:
前后两次区别在于构造字符串的方式不同,第一次是使用new
构造,第二次是直接使用常量串构造;
第一次不同对象指向字符串常量池的同一地址,==
结果是false
;而第二次相同的常量串构造,==
结果是true
,这刚好验证了我们上面介绍的内存分布情况。
比较
如下两种:
int compareTo()
int compareToIgnoreCase()
【compareTo】
前面介绍的equals()
的返回值是boolean
类型且只能判断是否相等,而compareTo()
返回值是int
类型,不同的比较结果返回不同特征的值。
compareTo()
的比较规则如下:
- 先按照字典序大小依次比较,如果出现不等的字符,直接返回两个字符的大小差值
- 如果前n个字符都相等(n为要比较的两个字符串的长度较小值),返回两个字符串长度差值
- 按照上面的比较规则,如果
>
就返回一个正数;如果<
就返回一个负数;如果==
就返回0
例如:
public static void main(String[] args) {
String s1 = new String("hello");
String s2 = new String("Hello");
String s3 = new String("zero");
String s4 = new String("hello");
System.out.println(s1.compareTo(s2));//第一个字符'h'与'H'比较,'h' > 'H',所以返回正值
System.out.println(s1.compareTo(s3));//第一个字符'h'与'z'比较,'h' < 'z',所以返回负值
System.out.println(s1.compareTo(s4));//所有字符依次比较,全部相等,返回0
}
【compareToIgnoreCase】
与compareTo()
的区别是,compareToIgnoreCase()
比较时忽略大小写,如:
public static void main(String[] args) {
String s1 = "HELLO";
String s2 = "hello";
System.out.println(s1.compareTo(s2));
System.out.println(s1.compareToIgnoreCase(s2));
}
注意:
compareTo()
和compareToIgnoreCase()
比较时的主体都是调用方法的那个字符串而非传入的参数
查找
char charAt()
int indexOf()
及其重载方法
int lastIndexOf()
及其重载方法
【charAt】
给charAt()
方法传入一个int
类型的值,这个值即下标值,它将返回指定下标位置的字符(char
类型)
public static void main(String[] args) {
String s1 = "hello";
System.out.println(s1.charAt(0));
System.out.println(s1.charAt(1));
System.out.println(s1.charAt(2));
System.out.println(s1.charAt(3));
System.out.println(s1.charAt(4));
}
【indexOf及其重载方法】
-
int indexOf(int ch)
:返回 ch 第一次出现的位置,没有则返回 -1public static void main(String[] args) { String s1 = new String("hello"); System.out.println(s1.indexOf('h')); System.out.println(s1.indexOf('l')); System.out.println(s1.indexOf('i')); }
-
int indexOf(int ch, int fromIndex)
:从fromIndex(下标值)
位置开始找,返 ch 第一次出现的位置,没有找到则返回 -1public static void main(String[] args) { String s1 = "abcabcdabcde"; System.out.println(s1.indexOf('c', 4)); System.out.println(s1.indexOf('f', 0)); }
-
int indexOf(String str)
:返回 str 第一次出现的位置,没有找到则返回 -1public static void main(String[] args) { String s1 = new String("hello"); System.out.println(s1.indexOf("ll")); System.out.println(s1.indexOf("lll")); }
-
int indexOf(String str, int fromIndex)
:从fromIndex(下标值)
的位置开始找,返回 str 第一次出现的位置,没有则返回 -1public static void main(String[] args) { String s1 = "abcabcdabcde"; System.out.println(s1.indexOf("abc", 4)); System.out.println(s1.indexOf("abcf", 2)); }
【lastIndexOf】
与前面indexOf()
的区别是:查找方向不同,lastIndexOf()
从后往前找
4种方式:
int lastIndexOf(int ch)
:从后往前找,返回 ch 第一次出现的位置,没有则返回 -1;int lastIndexOf(int ch, int fromIndex)
:从fromIndex
的位置开始从后往前找,返回 ch 第一次出现的位置,没有则返回 -1;int lastIndexOf(String str)
:从后往前找,返回 str 第一次出现的位置,没有则返回 -1;int lastIndexOf(String str, int fromIndex)
:从fromIndex
的位置开始从后往前找,返回 str 第一次出现的位置,没有则返回 -1;
public static void main(String[] args) {
String s1 = "hello";
String s2 = "abcabcdabcde";
System.out.println(s1.lastIndexOf('l'));
System.out.println(s1.lastIndexOf('l', 2));
System.out.println(s2.lastIndexOf("abc"));
System.out.println(s2.lastIndexOf("abc", 6));
}
转化
转化部分介绍:
- 数值和字符串转化
- 大小写转化
- 字符串转数组
- 格式化
【数值和字符串转化】
数值转化为字符串:
String String.valueOf()
字符串转化为数值:
包装类对应类型 包装类.valueOf(String str)
当然不是所有的转化都会成功
注意:需要使用类名调用valueOf()
方法,这是因为valueOf()
方法被static
修饰
public static void main(String[] args) {
String s1 = String.valueOf(123);
int i1 = Integer.valueOf("123");
int i2 = Integer.parseInt("123");
int i3 = Integer.valueOf("123");
double d1 = Double.valueOf("12.2");
System.out.println(s1);
System.out.println(i1);
System.out.println(i2);
System.out.println(i3);
System.out.println(d1);
}
上图中穿插了一个parseInt()
方法,其与valueOf()
没有差别,观察valueOf()
方法:
发现,valueOf()
底层就调用了parseInt()
方法
【大小写转化】
转大写:
String toUpperCase()
转小写
String toLowerCase()
public static void main(String[] args) {
String s1 = "hello";
String s2 = s1.toUpperCase();
System.out.println(s2);
System.out.println(s2.toLowerCase());
}
【字符串转数组】
char[] toCharArray()
public static void main(String[] args) {
String s1 = "hello";
char[] chars = s1.toCharArray();
for (char tmp : chars) {
System.out.println(tmp);
}
}
【格式化】
String format()
需要了解占位符的知识,Java较少使用,但学过C语言的应该不陌生
public static void main(String[] args) {
String s1 = String.format("%d-%d-%d", 2024, 5, 31);
System.out.println(s1);
}
替换
String replace()
String replaceAll(String regex, String replacement)
:替换所有指定内容
String replaceFirst(String regex, String replacement)
:替换首个指定内容
public static void main(String[] args) {
String s1 = "hello";
String s2 = s1.replace('l', 'o');
String s3 = s1.replace("ll", "oo");
String s4 = s1.replaceAll("l", "o");
String s5 = s1.replaceFirst("ll", "oo");
System.out.println(s2);
System.out.println(s3);
System.out.println(s4);
System.out.println(s5);
}
拆分
String[] split(String regex)
:以regex
为分割符拆分字符串,返回一个字符串数组
String[] split(String regex, int limit)
:将字符串以指定的格式,拆分为limit
组
【String[] split(String regex)
:以regex
为分割符拆分字符】
public static void main(String[] args) {
String s1 = "hello world!";
String[] strings1 = s1.split(" ");
for (String tmp : strings1) {
System.out.print(tmp + " ");
}
//换行
System.out.println();
//检验分割后返回的数组
System.out.println(Arrays.deepToString(strings1));
}
如果有多个分割符可以使用|
,如:
public static void main(String[] args) {
String s1 = "I am@OK!";
String[] strings = s1.split(" |@");
System.out.println(Arrays.deepToString(strings));
}
【String[] split(String regex, int limit)
:将字符串以指定的格式,拆分为limit
组】
public static void main(String[] args) {
String s1 = "i am a bit";
String[] strings = s1.split(" ", 2);//仅被分为两组
System.out.println(Arrays.deepToString(strings));
}
有些特殊字符作为分割符可能无法正确切分,需要加上转义
- 字符
|
、*
、,
、+
、.
都得加上转义字符,即\\
- 如果想以
\
为分割符,就得写成\\\\
;单独的\
不能出现在字符串中,必须以\\
表示\
- 如果一个字符串中有多个分割符,可以使用
|
作为连字符,上面演示过了
public static void main(String[] args) {
String s1 = "2024.5.31";
String[] strings1 = s1.split("\\.");
System.out.println(Arrays.deepToString(strings1));
String s2 = "2024+5+31";
strings1 = s2.split("\\+");
System.out.println(Arrays.deepToString(strings1));
String s3 = "2024\\5\\31";
strings1 = s3.split("\\\\");
System.out.println(Arrays.deepToString(strings1));
}
截取
String substring(int beginIndex)
:从beginIndex
下标位置截取到结尾
String substring(int beginIndex, int endIndex)
:从beginIndex
下标位置截取到endIndex - 1
下标位置,也就是说[beginIndex, endIndex)
public static void main(String[] args) {
String s1 = "hello world!";
String s2 = s1.substring(6);
System.out.println(s2);
String s3 = s1.substring(6, 11);
System.out.println(s3);
}
【补充】
String trim()
:去掉字符串中的左右空格,保留中间空格
public static void main(String[] args) {
String s1 = new String(" hello world ");
String s2 = s1.trim();
System.out.println(s2);
}
字符串的不可变性
String
是一种不可变对象。 字符串不可修改,我们从String
类源码观察:
如图:
-
第一个
final
表示String类
不能被继承; -
第二个
final
表示value
不能指向新的对象,但是其内容可以改变,我们可以验证这一点:
public static void main(String[] args) {
final String[] strings = new String[]{"hello", " world", "!"};
//修改内容,不报错
strings[1] = "bit";
//修改strings指向的对象,报错!
strings = new String[10];
}
- 真正决定
String
不可被修改的是蓝框里的private
修饰符,value
被private
修饰,意味着value
只能在当前的String
类中使用,而String
类没有提供value
的get
和set
方法,所以我们没有办法拿到value
值,自然就不能修改字符串了。反过来,只要给value
提供get
和set
方法,我们就可以修改字符串了。
基于字符串的不可变性,我们补充一点注意点:
上面我们介绍的所有的方法,都不是在原字符串上进行操作的,都是重新创建了一个新的字符串返回
【字符串的追加(拼接)操作】
使用+
即可
public static void main(String[] args) {
String s1 = "hello";
String s2 = s1 + " world";
System.out.println(s2);
}
此操作容易被误认为修改了字符串,其实并不是。
这段代码实际上如此:
public static void main(String[] args) {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("hello");
stringBuilder.append(" world");
String s2 = stringBuilder.toString();
System.out.println(s2);
}
并不是在原字符串上修改的,而是使用了StringBuilder
类,这是个什么类呢?我们接下来讲!
StringBuilder和StringBuffer
Java中字符串的不可变性使得字符串的操作要不断地new
新的对象,使得便捷性与效率并不理想。
为了方便字符串的修改,Java提供了StringBuilder
和StringBuffer
两个类,String
调用这两个类的构造方法或append
方法即可实现转化,这两个类中有非常多的操作字符串的方法(包含String
类方法并比String
类更丰富),如上面展示过的append()
追加方法、reverse()
反转方法,调用这些方法时无需创建新对象,直接在它们的类对象上操作即可,若要转换成字符串,只需调用toString()
方法并接收即可。
我们演示一下:
public static void main(String[] args) {
//创建StringBuilder类对象
StringBuilder stringBuilder = new StringBuilder("hello");
//直接操作
stringBuilder.reverse();
//转换为字符串
String str = stringBuilder.toString();
//打印
System.out.println(stringBuilder);
System.out.println(str);
}
以后我们想要对字符串执行追加操作时,要尽量使用StringBuilder
或StringBuffer
两个类的追加方法,它们的运行效率更高!
StringBuilder
、StringBuffer
类与String
类最大的区别就是String
类的内容无法修改,而StringBuilder
、StringBuffer
的内容可以修改。
【StringBuilder
与StringBuffer
的区别】
StringBuffer
采用同步处理,属于线程安全操作,用于保障多线程安全StringBuilder
未采用同步处理,属于线程不安全操作,用于单线程
那么,我们以后都使用StringBuffer
?
不是的。虽然StringBuffer
可以保证多线程安全,但是相比StringBuilder
多了一把"锁",“开锁” 和 “上锁” 也是会有损耗的,其效率会较低。对于现在的我们,只需要记住,具体场景具体分析,合理选择。
异常
异常的概念
在Java中,将程序执行过程中发生的不正常行为称为异常。(不包含语法错误和逻辑错误)
Java中的异常,其实是一个一个的类,可以是JVM自动抛出的,也可以是程序员通过throw语句
手动抛出的。
我们举几个例子,并观察其源码:
-
ArithmeticException
算术异常public static void main(String[] args) { System.out.println(10 / 0); }
-
NullPointerException
空指针异常public static void main(String[] args) { int[] array = null; array[0] = 10; }
异常的体系结构
Java维护了一个异常的体系结构,如下图所示:
观察图片,我们得出:
Throwable
是异常体系的顶层类,其他所有的类都直接或间接地继承它,它派生出两个子类:Error
和Exception
Error
是指Java虚拟机无法解决的严重问题,程序员是不能改变和处理的,遇到这样的问题,建议让程序直接终止,因此我们编写程序时不需要关心这一类错误。比如,JVM的内部错误、资源耗尽、线程死锁等。常见的:StackOverflowError栈溢出
Exception
即我们平时常说的狭义的异常(后面讲解的异常均指Exception)
,表示程序可以处理的异常可以捕获并可以尝试恢复,所以遇到这类异常我们要尽力处理,使程序恢复运行。主要指编码、环境、用户操作输入出现问题
异常的分类
Java的异常(广义)根据Javac对异常的处理要求,分为 检查异常 和 非检查异常
【检查异常】
检查异常即编译器要求必须处置的异常,其在编译期间抛出,如果我们不处置该类异常,代码将不会通过编译
检查异常 包含:除了Error
和RunTimeException
类及其子类外的所有类
检查异常 通常采用try-catch捕获
或者throws声明抛出
的方式处理
【非检查异常】
非检查异常即不强制要求处置的异常,非检查异常不会在编译期间检查,而是在运行时抛出
非检查异常 包含:Error
和 RunTimeException
类及其子类
检查异常 不应该使用Java的异常处理机制来处置,而应该采用修改代码的方式(因为这些异常的出现大部分是代码本身出错),当然,我们可以使用try-catch
和throws
来处置它们
根据异常发生时机不同,可将Exception
分为:编译时异常 和 运行时异常
-
编译时异常
在程序编译期间发生的异常,称为编译时异常,属于受检查异常(受查异常)。
常见的就是克隆对象时的
CloneNotSupportedException
异常class Obj implements Cloneable { public int a; public String s; @Override protected Object clone() throws CloneNotSupportedException { return super.clone(); } } public class TestException { public static void main(String[] args) { Obj obj = new Obj(); Obj obj1 = (Obj)obj.clone();//红线提示 } }
如图,受查异常会在运行前就有红线提示。(上代码解决方案是在
main
方法的参数列表后加上throws CloneNotSupportedException
)【注意】
不是所有的红线都是受查异常,例如我们将某个关键字写错或忘记写分号,这些都是基本的语法错误,不属于异常。
我们必须手动在代码里添加捕获语句来处理该异常,这类异常是我们主要处理的异常对象。
-
运行时异常
在程序执行期间发生的异常,称为运行时异常,属于非受检查异常(非受查异常)
RunTimeException
及其子类对应的异常,称为运行时异常,如常见的NullPointerException
、ArrayIndexOutBoundsException
等RunTimeException
异常会由Java虚拟机自动抛出并自动捕获,此类异常出现的绝大多数情况是代码本身有问题,我们应该从逻辑上解决这些问题,修改我们的代码。
异常的抛出
这里我们介绍 throw
关键字。
前面提到,异常可以是JVM自动抛出的,也可以是程序员通过throw语句
手动抛出的,throw
一般用于抛出自定义异常(后面会讲)
throw
语句用来抛出一个异常类对象,告知调用者相关的错误信息
【语法格式】
保证
throw
后面是异常类对象即可
throw new 异常类名();
异常类名 引用变量名 = new 异常类名();
throw 引用变量名;
我们以抛出非自定义异常为例,看一段代码:
public class TestException1 {
public static void test() {
int[] array = new int[]{1, 2, 3, 4, 5};
System.out.println("请输入下标值:");
Scanner scanner = new Scanner(System.in);
int index = scanner.nextInt();
if(index > 5 || index < 0) {
throw new ArrayIndexOutOfBoundsException();
}else {
System.out.println(array[index]);
}
}
public static void main(String[] args) {
test();
}
}
如上代码,我们输入 7 ,抛出ArrayIndexOutOfBoundsException
异常
构造异常类时可以传入字符串来给出错误提示
public class TestException1 {
public static void test(int x) {
if(x == 0) {
throw new ArithmeticException("将会出现10 / 0的不正确语法!");
}else {
System.out.println(10 / x);
}
}
public static void main(String[] args) {
test(0);//触发if语句手动抛出ArithmaticException异常并打印错误信息
}
}
【注意事项】
throw
语句只能位于方法体内- 如果抛出的异常是非检查异常,则可以不用处理,直接交给JVM处理,一旦交给JVM处理,程序将会终止报错
- 如果抛出的异常是检查异常,则必须处理,否则编译不通过
异常的处理
异常的处理主要有两种方式:
一种是消极的处理方式 throws
声明抛出;另一种是 try-catch
捕获异常
throws
【语法格式】
修饰符 返回类型 方法名(参数列表)
throws
异常类型1,异常类型2… {}
当方法中抛出检查异常时,用户不想处理该异常,此时就可以使用throws
将异常抛给方法的调用者来处理(当前方法不处理异常,提醒方法的调用者处理该异常)
throws
语句又叫 异常的声明,它告诉调用者调用此方法可能会抛出对应的异常。实际上并没有处理异常,而是交给了此方法的调用者(上层)来处理该异常,如果不断地throws
,异常最终将会交给JVM处理,此时程序就会报错终止。
【举例】
//CloneObject.java
public class CloneObject implements Cloneable {
private int a;
private String s;
public CloneObject(int a, String s) {
this.a = a;
this.s = s;
}
@Override
protected Object clone() throws CloneNotSupportedException { //将CloneNotSupportedException异常交给调用者,此处为main方法
return super.clone();
}
}
//TestException1.java
public class TestException1 {
public static void main(String[] args) throws CloneNotSupportedException { //main方法将异常抛给上层,即JVM
CloneObject cloneObject1 = new CloneObject(12, "haha");
CloneObject cloneObject2 = (CloneObject) cloneObject1.clone();
}
}
【注意事项】
throws
语句必须位于方法的参数列表后throws
后的异常必须是Exception
类及其子类- 如果
throws
语句后面的多个异常存在父子关系,则只需要保留父类即可 throws
语句处理异常的方式是消极的,它并没有实际处理异常,而是不断地将异常抛给其上层调用者,最终会抛给JVM处理
try-catch
throws
没有真正处理异常,而是将异常抛给调用者,由调用者处理。try-catch
则是一种积极的处理异常的方式
【语法格式】
try {
} catch(异常类型 变量名) {
}
//后续代码…
try
内部书写可能出现异常的代码,catch
用于捕捉指定的异常,当try
内部语句出现异常,catch
语句将尝试捕获,如果异常类型匹配,则会捕获成功,执行catch
内部的语句,执行完毕后,继续执行后续的语句;如果捕获失败(异常类型不匹配),程序将会直接终止并报错。
另外,当try
内部出现异常,出现异常的语句之后的语句将不会执行,直接跳转到catch
语句尝试捕获
我们看一段代码:
public static void main(String[] args) {
int[] array = null;
try {
array[0] = 1;
System.out.println("异常语句后的语句...");
}catch (NullPointerException a) {
System.out.println("执行了catch语句...");
}
System.out.println("执行了后续语句...");
}
打印结果为:
分析: 我们创建了一个数组引用变量,它不指向任何对象(null),接着执行try
内部语句,将会出现NullPointerException
异常,此时后面的打印语句不会执行,直接跳转到catch
语句尝试捕获,类型匹配,捕获成功,执行catch
内部语句,最后执行后续语句,得到上图打印结果。
当catch
语句捕获到了异常,我们可能难以定位捕获异常的位置。
此时,我们可以通过printStackTrace
方法打印追踪栈的信息,帮助我们进行定位
public static void main(String[] args) {
int[] array = null;
try {
array[0] = 1;
System.out.println("异常语句后的语句...");
}catch (NullPointerException a) {
System.out.println("执行了catch语句...");
a.printStackTrace();//打印错误信息
}
System.out.println("执行了后续语句...");
}
这里有一个问题,printStackTrace
方法在println
之前,为什么先打印println
的信息?
这是因为,printStackTrace
内部的打印逻辑不同于println
,现阶段不必追究,知道这一现象即可。
try
中可能会抛出多个异常,此时要使用多个catch
来一一捕获,防止某个异常没有被捕获
我们看一段代码:
public static void main(String[] args) {
int[] array = null;
try {
array[0] = 1;
System.out.println(10 / 0);
System.out.println("异常语句之后的语句...");
}catch (NullPointerException e) {
e.printStackTrace();
System.out.println("捕获空指针异常的catch语句执行了...");
}catch (ArithmeticException e) {
e.printStackTrace();
System.out.println("捕获算术异常的catch语句执行了...");
}
System.out.println("后续语句...");
}
问: 此时会打印几个异常的错误信息?
打印结果显示,只打印了一个异常的错误信息,原因很简单,当try
中出现了一个异常时,后面的语句将不再执行,也就是上述代码中会出现算术异常的10 / 0
语句不会执行。
对于可能出现多个异常的情景,有两种方便但不推荐的写法:
-
仅存在一个
catch
语句且定义待捕获的类为Exception
,通过一个catch
语句捕获所有的异常public static void main(String[] args) { try { System.out.println("可能出现异常的语句..."); } catch(Exception e) { System.out.println("捕获到了异常..."); } }
Exception
是所有异常(狭义)类的父类,这种写法虽然方便,但是具体抛出了什么异常,我们不清楚,所以不推荐这样写。不过,我们可以在已有的多个
catch
语句的后面再加上一个Exception的catch
语句以兜底。public static void main(String[] args) { int[] array = null; try { array[0] = 1; System.out.println(10 / 0); System.out.println("异常语句之后的语句..."); }catch (NullPointerException e) { e.printStackTrace(); System.out.println("捕获空指针异常的catch语句执行了..."); }catch (ArithmeticException e) { e.printStackTrace(); System.out.println("捕获算术异常的catch语句执行了..."); }catch (Exception e) { e.printStackTrace(); System.out.println("捕获到了异常..."); } System.out.println("后续语句..."); }
-
使用
|
连字符在一个catch
语句定义多个异常public static void main(String[] args) { try { System.out.println("可能出现异常的语句..."); } catch(NullPointerException | ArithmeticException e) { System.out.println("捕获到了异常..."); } System.out.println("后续语句..."); }
与一个
Exception
一样,我们不清楚具体抛出的异常。我们只需要了解可以使用|
的语法即可
如果异常之间具有父子关系,则必须保证子类异常在前面的catch
语句,父类异常在后面的catch
语句
具体实例参考Exception
兜底代码
【总结】
try
块内出现异常位置后的语句不会执行try
中可能会抛出多个异常,此时要使用多个catch
来一一捕获,防止某个异常没有被捕获,避免只使用一个catch
语句捕获所有的异常以及使用|
在一个catch
语句中出现多个异常类- 如果
catch
语句的异常类出现了父子关系,则必须保证子类在上,父类在下,这是强制性的 - 如果抛出异常的类型与
catch
不匹配,异常将会继续向外抛,知道抛给JVM中断程序
finally
编程过程中,有些代码,不论是否发生异常,都需要执行,比如程序中打开的资源:网络连接、数据库、IO流等,在程序正常或异常退出时,必须对资源进行回收。另外,异常会引发程序的跳转,导致一些语句无法执行。
针对这些问题,Java提供了 finally
关键字,它必须依附try-catch
语句,即try-catch-finally
【语法格式】
try {
} catch(异常类型 变量名) {
} finally {
}
finally
块内的语句一定会执行,不论是否发生异常、异常是否被捕获成功
问:finally
语句与try-catch
后面的语句都会执行,为什么还需要finally
呢?
我们看一段代码:
public class TestException1 {
public static int testFinally() {
Scanner scanner = new Scanner(System.in);
try {
int ret = scanner.nextInt();
return ret;
}catch (InputMismatchException e) {
e.printStackTrace();
}finally {
System.out.println("finally语句执行了...");
}
System.out.println("后续语句执行了...");
scanner.close();
return 0;
}
public static void main(String[] args) {
System.out.println(testFinally());
}
}
当输入的类型匹配时,例如输入 10:
此时,我们发现当没有抛出异常,try
块内的代码都会执行,执行返回语句返回 10,同时finally
的语句也会执行,但是后续语句没有执行,因为已经返回了。
当我们输入的类型不匹配时(即抛出了异常),如输入 “str”:
此时抛出异常被catch
捕获,再执行finally
和 后续语句。
通过上例,我们发现,异常引发的程序跳转使得后续代码可能执行,但是finally
代码必定会执行,所以这就是finally
语句出现的意义。
避免在finally
中出现return
语句
public static int testFinallyAndReturn() {
try {
return 10;
}catch (NullPointerException e) {
e.printStackTrace();
}finally {
return 20;
}
}
public static void main(String[] args) {
System.out.println(testFinallyAndReturn());
}
这段代码打印结果是什么?
finally
执行的时机是在方法返回之前(如果try-catch
中有return
会在此return
之前执行finally
;但是如果finally
中也存在return
,那么就会执行finally
中的return
,从而不会执行到try-catch
中原有的return
异常的处理流程
如果发生异常的方法中没有合适的异常处理方式,就会沿着调用栈向上(这里的上指的是调用者)传递。
方法之间是存在相互调用关系的,这种调用关系我们可以使用"调用栈"来描述。
JVM中有一块内存空间称为"虚拟机栈"专门存储方法之间的调用关系,前面介绍的
printStackTrace
方法就可以查看出现异常代码的调用栈
如上面举过例子的一段代码:
public static int testFinally() {
Scanner scanner = new Scanner(System.in);
try {
int ret = scanner.nextInt();
return ret;
}catch (InputMismatchException e) {
e.printStackTrace();
}finally {
System.out.println("finally语句执行了...");
}
System.out.println("后续语句执行了...");
scanner.close();
return 0;
}
public static void main(String[] args) {
System.out.println(testFinally());
}
黄框内部显示了出现异常代码的调用栈,最上面是栈顶。当出现异常时,此异常会一直向栈底回溯,这种行为叫做异常的冒泡,如果传递过程中一直没有合适的处理方式,异常最终会交给JVM处理,程序就会异常终止。
【异常处理流程总结】
- 程序先执行
try
中的代码 - 如果
try
中的代码出现异常,就会结束try
中的代码,catch
开始尝试捕获(看异常类型是否匹配) - 如果捕获成功(异常类型匹配),就会执行
catch
中的代码;如果捕获失败(异常类型不匹配),异常就会向上传递到上层调用者,如果一直到main
方法也没有合适的代码处理异常,就会交给JVM处理,此时程序就会异常终止 - 无论是否捕获成功,
finally
代码都会执行
自定义异常类
Java中虽然已经内置了丰富的异常类,但是并不能完全表示实际开发中所遇到的一些异常,此时我们就需要自定义异常类,维护符合我们实际情况的异常结构。
观察Java内置的InputMismatchException
异常源码:
观察框选部分,然后我们介绍规则。
【自定义异常的规则】
- 自定义异常要继承
Exception
或RunTimeException
,继承自Exception
默认为检查异常;继承自RunTimeException
默认为非检查异常 - 按照国际惯例,自定义异常类应该包含4个构造方法:
- 无参构造方法
- 带
String
参数的构造方法,并使用super
传递给父类 - 带
String
参数 和Throwable
参数的构造方法,并使用super
传递给父类 - 带
Throwable
参数的构造方法,并使用super
传递给父类
- 我们自定义异常类时,不必严格遵守国际惯例,只包含前两个无参和
String
参数的构造方法即可
了解完毕后,我们应用自定义异常实现段用户登录代码:
//UserNameException.java
public class UserNameException extends Exception {
public UserNameException() {
}
public UserNameException(String s) {
super(s);
}
}
//PassWordException.java
public class PassWordException extends Exception {
public PassWordException() {
}
public PassWordException(String s) {
super(s);
}
}
//Login.java
import java.util.Scanner;
public class Login {
private String userName;
private String passWord;
public void setUserName(String userName) {
this.userName = userName;
}
public void setPassWord(String passWord) {
this.passWord = passWord;
}
public void loginInfo(String userName, String passWord) throws UserNameException, PassWordException {
if(!this.userName.equals(userName)) {
throw new UserNameException("用户名错误!");
}
if(!this.passWord.equals(passWord)) {
throw new PassWordException("密码错误!");
}
System.out.println("登录成功!");
}
}
class Test {
public static void main(String[] args) {
Login login = new Login();
Scanner scanner = new Scanner(System.in);
login.setUserName("abc");
login.setPassWord("123");
try {
System.out.println("请输入用户名:");
String userName = scanner.nextLine();
System.out.println("请输入密码:");
String passWord = scanner.nextLine();
login.loginInfo(userName, passWord);
}catch (UserNameException e) {
System.out.println("用户名错误!");
e.printStackTrace();
}catch (PassWordException e) {
System.out.println("密码错误!");
e.printStackTrace();
}finally {
scanner.close();
}
}
}
熟悉了异常部分的知识,理解这些代码是容易的,希望大家多多练习!
以上就是JavaSE基础语法的合集了,希望能给大家带来帮助
接下来我们将会踏上 Java数据结构 之旅,敬请期待!