Java SE 学习笔记 2️⃣
类与对象
类的定义与对象创建
前面我们介绍了什么是类,什么是对象,首先我们就来看看如何去定义一个类。
比如现在我们想要定义一个人类,我们可以右键src
目录,点击创建新的类:
我们在对类进行命名时,一般使用英文单词,并且首字母大写,跟变量命名一样,不能出现任何的特殊字符。
可以看到,现在我们的目录下有了两个.java
源文件,其中一个是默认创建的Main.java,还有一个是我们刚刚创建的类。
我们来看看创建好之后,一个类写了哪些内容:
1 | public class Person { |
可以发现,这不是跟一开始创建的Main中写的格式一模一样吗?没错,Main也是一个类,只不过我们一直都将其当做主类在使用,也就是编写主方法的类,关于方法我们会在后面进行介绍。
现在我们就创建好了一个类,既然是人类,那么肯定有人相关的一些属性,比如名字、性别、年龄等等,那么怎么才能给这个类添加一些属性呢?
我们可以将这些属性直接作为类的成员变量(成员变量相当于是这个类所具有的属性,每个实例创建出来之后,这些属性都可能会各不相同)定义到类中。
1 | public class Person { //这里定义的人类具有三个属性,名字、年龄、性别 |
可能会有小伙伴疑问,这些变量啥时候被赋值呢?实际上这些变量只有在一个具体的对象中才可以使用。
那么现在人类的属性都规定好了,我们就可以尝试创建一个实例对象了,实例对应的应该是一个具体的人:
1 | new 类名(); |
1 | public static void main(String[] args) { |
实际上整个流程为:
只不过这里仅仅是创建出了这样的一个对象,我们目前没有办法去操作这个对象,比如想要修改或是获取这个人的名字等等。
对象的使用
既然现在我们知道如何创建对象,那么我们怎么去访问这个对象呢,比如我现在想要去查看或是修改它的名字。
我们同样可以使用一个变量来指代某个对象,只不过引用类型的变量,存储的是对象的引用,而不是对象本身:
1 | public static void main(String[] args) { |
至于为什么对象类型的变量存放的是对象的引用,比如:
1 | public static void main(String[] args) { |
这里,我们将变量p2赋值为p1的值,那么实际上只是传递了对象的引用,而不是对象本身的复制,这跟我们前面的基本数据类型有些不同,p2和p1都指向的是同一个对象(如果你学习过C语言,它就类似于指针一样的存在)
我们可以来测试一下:
1 | public static void main(String[] args) { |
但是如果我们像这样去编写:
1 | public static void main(String[] args) { |
实际上我们之前使用的String类型,也是一个引用类型,我们会在下一章详细讨论。我们在上一章介绍的都是基本类型,而类使用的都是引用类型。
现在我们有了对象的引用之后,我们就可以进行操作了:
我们可以直接访问对象的一些属性,也就是我们在类中定义好的那些,对于不同的对象,这些属性都具体存放值也会不同。
比如我们可以修改对象的名字:
1 | public static void main(String[] args) { |
注意,不同对象的属性是分开独立存放的,每个对象都有一个自己的空间,修改一个对象的属性并不会影响到其他对象:
1 | public static void main(String[] args) { |
关于对象类型的变量,我们也可以不对任何对象进行引用:
1 | public static void main(String[] args) { |
注意,如果不引用任何的对象,那肯定是不应该去通过这个变量去操作所引用的对象的(都没有引用对象,我操作谁啊我)
虽然这样可以编译通过,但是在运行时会出现问题:
1 | public static void main(String[] args) { |
我们来尝试运行一下这段代码:
此时程序在运行的过程中,出现了异常,虽然我们还没有学习到异常,但是各位可以将异常理解为程序在运行过程中出现了问题,此时不得不终止程序退出。
这里出现的是空指针异常,很明显是因为我们去操作一个值为null的变量导致的。在我们以后的学习中,这个异常是出现频率最高的。
我们来看最后一个问题,对象创建成功之后,它的属性没有进行赋值,但是我们前面说了,变量使用之前需要先赋值,那么创建对象之后能否直接访问呢?
1 | public static void main(String[] args) { |
我们来看看运行结果:
我们可以看到,如果直接创建对象,那么对象的属性都会存在初始值,如果是基本类型,那么默认是统一为0
(如果是boolean的话,默认值为false)如果是引用类型,那么默认是null
。
方法创建与使用
前面我们介绍了类的定义以及对象的创建和使用。
现在我们的类有了属性,我们可以为创建的这些对象设定不同的属性值,比如每个人的名字都不一样,性别不一样,年龄不一样等等。只不过光有属性还不行,对象还需要具有一定的行为,就像我们人可以行走,可以跳跃,可以思考一样。
而对象也可以做出一些行为,我们可以通过定义方法来实现(在C语言中叫做函数)
方法是语句的集合,是为了完成某件事情而存在的。完成某件事情,可以有结果,也可以做了就做了,不返回结果。比如计算两个数字的和,我们需要得到计算后的结果,所以说方法需要有返回值;又比如,我们只想吧数字打印在控制台,只需要打印就行,不用给我结果,所以说方法不需要有返回值。
方法的定义如下:
1 | 返回值类型 方法名称() { |
首先是返回值类型,也就是说这个方法完成任务之后,得到的结果的数据类型(可以是基本类型,也可以是引用类型)当然,如果没有返回值,只是完成任务,那么可以使用void
表示没有返回值,比如我们现在给人类编写一个自我介绍的行为:
1 | public class Person { |
注意,方法名称同样可以随便起,但是规则跟变量的命名差不多,也是尽量使用小写字母开头的单词,如果是多个单词,一般使用驼峰命名法最规范。
现在我们给人类定义好了一个方法(行为)那么怎么才能让对象执行这个行为呢?
1 | public static void main(String[] args) { |
像这样执行定义好的方法,我们一般称为方法的调用,我们来看看效果:
比如现在我们要让人类学会加法运算,我们也可以通过定义一个方法的形式来完成,只不过,要完成加法运算,我们需要别人给人类提供两个参与加法运算的值才可以,所以我们这里就要用到参数了:
1 | //我们的方法需要别人提供参与运算的值才可以 |
那么现在参数从外部传入之后,我们怎么使用呢?
1 | int sum(int a, int b){ //这里的参数,相当于我们在函数中定义了两个局部变量,我们可以直接在方法中使用 |
那么现在计算完成了,我们该怎么将结果传递到外面呢?首先函数的返回值是int类型,我们只需要使用return
关键字来返回一个int类型的结果就可以了:
1 | int sum(int a, int b){ |
我们来测试一下吧:
1 | public static void main(String[] args) { |
注意: 方法定义时编写的参数,我们一般称为形式参数,而调用方法实际传入的参数,我们成为实际参数。
是不是越来越感觉我们真的在跟一个对象进行交互?只要各位有了这样的体验,基本上就已经摸到面向对象的门路了。
关于return
关键字,我们还需要进行进一步的介绍。
在我们使用return
关键字之后,方法就会直接结束并返回结果,所以说在这之后编写的任何代码,都是不可到达的:
在return
后编写代码,会导致编译不通过,因为存在不可达语句。
如果我们的程序中出现了分支语句,那么必须保证每一个分支都有返回值才可以:
只要有任何一个分支缺少了return
语句,都无法正常通过编译,总之就是必须考虑到所有的情况,任何情况下都必须要有返回值。
当然,如果方法没有返回值,我们也可以使用return
语句,不需要跟上任何内容,只不过这种情况下使用,仅仅是为了快速结束方法的执行:
1 | void test(int a){ |
最后我们来讨论一下参数的传递问题:
1 | void test(int a){ //我们可以设置参数来让外部的数据传入到函数内部 |
实际上参数的传递,会在调用方法的时候,对参数的值进行复制,方法中的参数变量,不是我们传入的变量本身,我们来下面的这个例子:
1 | void swap(int a, int b){ //这个函数的目的很明显,就是为了交换a和b的值 |
那么我们来测试一下:
1 | public static void main(String[] args) { |
我们来看看结果是什么:
我们发现a和b的值并没有发生交换,但是按照我们的方法逻辑来说,应该是会交换才对,这是为什么呢?实际上这里仅仅是将值复制给了函数里面的变量而已(相当于是变量的赋值)
所以说我们交换的仅仅是方法中的a和b,参数传递仅仅是值传递,我们是没有办法直接操作到外面的a和b的。
那么各位小伙伴看看下面的例子:
1 | void modify(Person person){ |
1 | public static void main(String[] args) { |
我们来看看结果:
不对啊,前面不是说只是值传递吗,怎么这里又可以修改成功呢?
确实,这里同样是进行的值传递,只不过各位小伙伴别忘了,我们前面可是说的清清楚楚,引用类型的变量,仅仅存放的是对象的引用,而不是对象本身。那么这里进行了值传递,相当于将对象的引用复制到了方法内部的变量中,而这个内部的变量,依然是引用的同一个对象,所以说这里在方法内操作,相当于直接操作外面的定义对象。
方法进阶使用
有时候我们的方法中可能会出现一些与成员变量重名的变量:
1 | //我们希望使用这个方法,来为当前对象设定名字 |
此时类中定义的变量名称也是name
,那么我们是否可以这样编写呢:
1 | void setName(String name) { |
我们来测试一下:
1 | public static void main(String[] args) { |
我们发现,似乎这样做并没有任何的效果,name依然是没有修改的状态。那么当出现重名的时候,因为默认情况下会优先使用作用域最近的变量,我们怎么才能表示要使用的变量是类的成员变量呢?
1 | Person p = new Person(); |
同样的,我们如果想要在方法中访问到当前对象的属性,那么可以使用this
关键字,来明确表示当前类的示例对象本身:
1 | void setName(String name) { |
这样就可以修改成功了,当然,如果方法内没有变量出现重名的情况,那么默认情况下可以不使用this
关键字来明确表示当前对象:
1 | String getName() { |
我们接着来看方法的重载。
有些时候,参数类型可能会多种多样,我们的方法需要能够同时应对多种情况:
1 | int sum(int a, int b){ |
1 | public static void main(String[] args) { |
但是要是我们现在不仅要让人类会计算整数,还要会计算小数呢?
当我们使用小数时,可以看到,参数要求的是int类型,那么肯定会出现错误,这个方法只能用于计算整数。此时,为了让这个方法支持使用小数进行计算,我们可以将这个方法进行重载。
一个类中可以包含多个同名的方法,但是需要的形式参数不一样,方法的返回类型,可以相同,也可以不同,但是仅返回类型不同,是不允许的!
1 | int sum(int a, int b){ |
这样就可以正常使用了:
1 | public static void main(String[] args) { |
包括我们之前一直在使用的println
方法,其实也是重载了很多次的,因为要支持各种值的打印。
注意,如果仅仅是返回值的不同,是不支持重载的:
当然,方法之间是可以相互调用的:
1 | void test(){ |
如果我们这样写的话:
1 | void test(){ |
各位猜猜看会出现什么情况?
此时又出现了一个我们不认识的异常,实际上什么原因导致的我们自己都很清楚,方法之间一直在相互调用,没有一个出口。
方法自己也可以调用自己:
1 | void test(){ |
像这样自己调用自己的行为,我们称为递归调用,如果直接这样编写,会跟上面一样,出现栈溢出错误。但是如果我们给其合理地设置出口,就不会出现这种问题,比如我们想要计算从1加到n的和:
1 | int test(int n){ |
是不是感觉很巧妙?实际上递归调用在很多情况下能够快速解决一些很麻烦的问题,我们会在后面继续了解。
构造方法
我们接着来看一种比较特殊的方法,构造方法。
我们前面创建对象,都是直接使用new
关键字就能直接搞定了,但是我们发现,对象在创建之后,各种属性都是默认值,那么能否实现在对象创建时就为其指定名字、年龄、性别呢?要在对象创建时进行处理,我们可以使用构造方法(构造器)来完成。
实际上每个类都有一个默认的构造方法,我们可以来看看反编译的结果:
1 | public class Person { |
构造方法不需要填写返回值,并且方法名称与类名相同,默认情况下每个类都会自带一个没有任何参数的无参构造方法(只是不用我们去写,编译出来就自带)当然,我们也可以手动声明,对其进行修改:
1 | public class Person { |
构造方法会在new的时候自动执行:
1 | public static void main(String[] args) { |
当然,我们也可以为构造方法设定参数:
1 | public class Person { |
注意,在我们自己定义一个构造方法之后,会覆盖掉默认的那一个无参构造方法,除非我们手动重载一个无参构造,否则要创建这个类的对象,必须调用我们自己定义的构造方法:
1 | public static void main(String[] args) { |
我们可以去看看反编译的结果,会发现此时没有无参构造了,而是只剩下我们自己编写的。
当然,要给成员变量设定初始值,我们不仅可以通过构造方法,也可以直接在定义时赋值:
1 | public class Person { |
这里需要特别注意,成员变量的初始化,并不是在构造方法之前之后,而是在这之前就已经完成了:
1 | Person(String name, int age, String sex){ |
我们也可以在类中添加代码块,代码块同样会在对象构造之前进行,在成员变量初始化之后执行:
1 | public class Person { |
只不过一般情况下使用代码块的频率比较低,标准情况下还是通过构造方法进行进行对象初始化工作,所以说这里做了解就行了。
静态变量和静态方法
前面我们已经了解了类的大部分特性,一个类可以具有多种属性、行为,包括对象该如何创建,我们可以通过构造方法进行设定,我们可以通过类创建对象,每个对象都会具有我们在类中设定好的属性,包括我们设定好的行为,所以说类就像是一个模板,我们可以通过这个模板快速捏造出一个又一个的对象。我们接着来看比较特殊的静态特性。
静态的内容,我们可以理解为是属于这个类的,也可以理解为是所有对象共享的内容。我们通过使用static
关键字来声明一个变量或一个方法为静态的,一旦被声明为静态,那么通过这个类创建的所有对象,操作的都是同一个目标,也就是说,对象再多,也只有这一个静态的变量或方法。一个对象改变了静态变量的值,那么其他的对象读取的就是被改变的值。
1 | public class Person { |
我们来测试一下:
1 | public static void main(String[] args) { |
所以说一般情况下,我们并不会通过一个具体的对象去修改和使用静态属性,而是通过这个类去使用:
1 | public static void main(String[] args) { |
同样的,我们可以将方法标记为静态:
1 | static void test(){ |
静态方法同样是属于类的,而不是具体的某个对象,所以说,就像下面这样:
因为静态方法属于类的,所以说我们在静态方法中,无法获取成员变量的值:
成员变量是某个具体对象拥有的属性,就像小明这个具体的人的名字才叫小明,而静态方法是类具有的,并不是具体对象的,肯定是没办法访问到的。同样的,在静态方法中,无法使用this
关键字,因为this关键字代表的是当前的对象本身。
但是静态方法是可以访问到静态变量的:
1 | static String info; |
因为他们都属于类,所以说肯定是可以访问到的。
我们也可以将代码块变成静态的:
1 | static String info; |
那么,静态变量,是在什么时候进行初始化的呢?
我们在一开始介绍了,我们实际上是将.class
文件丢给JVM去执行的,而每一个.class
文件其实就是我们编写的一个类,我们在Java中使用一个类之前,JVM并不会在一开始就去加载它,而是在需要时才会去加载(优化)一般遇到以下情况时才会会加载类:
- 访问类的静态变量,或者为静态变量赋值
- new 创建类的实例(隐式加载)
- 调用类的静态方法
- 子类初始化时
- 其他的情况会在讲到反射时介绍
所有被标记为静态的内容,会在类刚加载的时候就分配,而不是在对象创建的时候分配,所以说静态内容一定会在第一个对象初始化之前完成加载。
我们可以来测试一下:
1 | public class Person { |
现在我们在主方法中创建一个对象,观察这几步是怎么在执行的:
可以看到,确实是静态内容在对象构造之前的就完成了初始化,实际上就是类初始化时完成的。
当然,如果我们直接访问类的静态变量:
1 | public static void main(String[] args) { |
那么此时同样会使得类初始化,进行加载:
可以看到,在使用时,确实是先将静态内容初始化之后,才得到值的。当然,如果我们压根就没有去使用这个类,那么也不会被初始化了。
有关类与对象的基本内容,我们就全部讲解完毕了。
包和访问控制
通过前面的学习,我们知道该如何创建和使用类。
包声明和导入
包其实就是用来区分类位置的东西,也可以用来将我们的类进行分类(类似于C++中的namespace)随着我们的程序不断变大,可能会创建各种各样的类,他们可能会做不同的事情,那么这些类如果都放在一起的话,有点混乱,我们可以通过包的形式将这些类进行分类存放。
包的命名规则同样是英文和数字的组合,最好是一个域名的格式,比如我们经常访问的www.baidu.com
,后面的baidu.com就是域名,我们的包就可以命名为com.baidu
,当然,各位小伙伴现在还没有自己的域名,所以说我们随便起一个名称就可以了。其中的.
就是用于分割的,对应多个文件夹,比如com.test
:
我们可以将类放入到包中:
我们之前都是直接创建的类,所以说没有包这个概念,但是现在,我们将类放到包中,就需要注意了:
1 | package com.test; //在放入包中,需要在类的最上面添加package关键字来指明当前类所处的包 |
这里又是一个新的关键字package
,这个是用于指定当前类所处的包的,注意,所处的包和对应的目录是一一对应的。
不同的类可以放在不同的包下:
当我们使用同一个包中的类时,直接使用即可(之前就是直接使用的,因为都直接在一个缺省的包中)而当我们需要使用其他包中的类时,需要先进行导入才可以:
1 | package com.test; |
这里使用了import
关键字导入我们需要使用的类,当然,只有在类不在同一个包下时才需要进行导入,如果一个包中有多个类,我们可以使用*
表示导入这个包中全部的类:
1 | import com.test.entity.*; |
实际上我们之前一直在使用的System
类,也是在一个包中的:
1 | package java.lang; |
可以看到它是属于java.lang
这个包下的类,并且这个类也导入了很多其他包中的类在进行使用。那么,为什么我们在使用这个类时,没有导入呢?实际上Java中会默认导入java.lang
这个包下的所有类,因此我们不需要手动指定。
IDEA非常智能,我们在使用项目中定义的类时,会自动帮我们将导入补全,所以说代码写起来非常高效。
注意,在不同包下的类,即使类名相同,也是不同的两个类:
1 | package com.test.entity; |
当我们在使用时:

由于默认导入了系统自带的String类,并且也导入了我们自己定义的String类,那么此时就出现了歧义,编译器不知道到底我们想用的是哪一个String类,所以说我们需要明确指定:
1 | public class Main { |
我们只需要在类名前面把完整的包名也给写上,就可以表示这个是哪一个包里的类了,当然,如果没有出现歧义,默认情况下包名是可以省略的,可写可不写。
可能各位小伙伴会发现一个问题,为什么对象的属性访问不了了?
编译器说name属性在这个类中不是public,无法在外部进行访问,这是什么情况呢?这里我们就要介绍的到Java的访问权限控制了。
访问权限控制
实际上Java中是有访问权限控制的,就是我们个人的隐私的一样,我不允许别人随便来查看我们的隐私,只有我们自己同意的情况下,才能告诉别人我们的名字、年龄等隐私信息。
所以说Java中引入了访问权限控制(可见性),我们可以为成员变量、成员方法、静态变量、静态方法甚至是类指定访问权限,不同的访问权限,有着不同程度的访问限制:
private
- 私有,标记为私有的内容无法被除当前类以外的任何位置访问。什么都不写
- 默认,默认情况下,只能被类本身和同包中的其他类访问。protected
- 受保护,标记为受保护的内容可以能被类本身和同包中的其他类访问,也可以被子类访问(子类我们会在下一章介绍)public
- 公共,标记为公共的内容,允许在任何地方被访问。
这四种访问权限,总结如下表:
当前类 | 同一个包下的类 | 不同包下的子类 | 不同包下的类 | |
---|---|---|---|---|
public | ✅ | ✅ | ✅ | ✅ |
protected | ✅ | ✅ | ✅ | ❌ |
默认 | ✅ | ✅ | ❌ | ❌ |
private | ✅ | ❌ | ❌ | ❌ |
比如我们刚刚出现的情况,就是因为是默认的访问权限,所以说在当前包以外的其他包中无法访问,但是我们可以提升它的访问权限,来使得外部也可以访问:
1 | public class Person { |
这样我们就可以在外部正常使用这个属性了:
1 | public static void main(String[] args) { |
实际上如果各位小伙伴观察仔细的话,会发现我们创建出来的类自带的访问等级就是public
:
1 | package com.test.entity; |
也就是说这个类实际上可以在任何地方使用,但是我们也可以将其修改为默认的访问等级:
1 | package com.test.entity; |
如果是默认等级的话,那么在外部同样是无法访问的:
但是注意,我们创建的普通类不能是protected
或是private
权限,因为我们目前所使用的普通类要么就是只给当前的包内使用,要么就是给外面都用,如果是private
谁都不能用,那这个类定义出来干嘛呢?
如果某个类中存在静态方法或是静态变量,那么我们可以通过静态导入的方式将其中的静态方法或是静态变量直接导入使用,但是同样需要有访问权限的情况下才可以:
1 | public class Person { |
我们来尝试一下静态导入:
1 | import static com.test.entity.Person.test; //静态导入test方法 |
至此,有关包相关的内容,我们就讲解到这里。
封装、继承和多态
封装、继承和多态是面向对象编程的三大特性。
封装,把对象的属性和方法结合成一个独立的整体,隐藏实现细节,并提供对外访问的接口。
继承,从已知的一个类中派生出一个新的类,叫子类。子类实现了父类所有非私有化的属性和方法,并根据实际需求扩展出新的行为。
多态,多个不同的对象对同一消息作出响应,同一消息根据不同的对象而采用各种不同的方法。
正是这三大特性,让我们的Java程序更加生动形象。
类的封装
封装的目的是为了保证变量的安全性,使用者不必在意具体实现细节,而只是通过外部接口即可访问类的成员,如果不进行封装,类中的实例变量可以直接查看和修改,可能给整个代码带来不好的影响,因此在编写类时一般将成员变量私有化,外部类需要使用Getter和Setter方法来查看和设置变量。
我们可以将之前的类进行改进:
1 | public class Person { |
我们可以来试一下:
1 | public static void main(String[] args) { |
也就是说,外部现在只能通过调用我定义的方法来获取成员属性,而我们可以在这个方法中进行一些额外的操作,比如小明可以修改名字,但是名字中不能包含"小"这个字:
1 | public void setName(String name) { |
我们甚至还可以将构造方法改成私有的,需要通过我们的内部的方式来构造对象:
1 | public class Person { |
通过这种方式,我们可以实现单例模式:
1
2
3
4
5
6
7
8
9
10
11 public class Test {
private static Test instance;
private Test(){}
public static Test getInstance() {
if(instance == null)
instance = new Test();
return instance;
}
}单例模式就是全局只能使用这一个对象,不能创建更多的对象,我们就可以封装成这样,关于单例模式的详细介绍,还请各位小伙伴在《Java设计模式》视频教程中再进行学习。
封装思想其实就是把实现细节给隐藏了,外部只需知道这个方法是什么作用,而无需关心实现,要用什么由类自己来做,不需要外面来操作类内部的东西去完成,封装就是通过访问权限控制来实现的。
类的继承
前面我们介绍了类的封装,我们接着来看一个非常重要特性:继承。
在定义不同类的时候存在一些相同属性,为了方便使用可以将这些共同属性抽象成一个父类,在定义其他子类时可以继承自该父类,减少代码的重复定义,子类可以使用父类中非私有的成员。
比如说我们一开始使用的人类,那么实际上人类根据职业划分,所掌握的技能也会不同,比如画家会画画,歌手会唱,舞者会跳,Rapper会rap,运动员会篮球,我们可以将人类这个大类根据职业进一步地细分出来:
实际上这些划分出来的类,本质上还是人类,也就是说人类具有的属性,这些划分出来的类同样具有,但是,这些划分出来的类同时也会拥有他们自己独特的技能。在Java中,我们可以创建一个类的子类来实现上面的这种效果:
1 | public class Person { //先定义一个父类 |
接着我们可以创建各种各样的子类,想要继承一个类,我们只需要使用extends
关键字即可:
1 | public class Worker extends Person{ //工人类 |
1 | public class Student extends Person{ //学生类 |
类的继承可以不断向下,但是同时只能继承一个类,同时,标记为final
的类不允许被继承:
1 | public final class Person { //class前面添加final关键字表示这个类已经是最终形态,不能继承 |
当一个类继承另一个类时,属性会被继承,可以直接访问父类中定义的属性,除非父类中将属性的访问权限修改为private
,那么子类将无法访问(但是依然是继承了这个属性的):
1 | public class Student extends Person{ |
同样的,在父类中定义的方法同样会被子类继承:
1 | public class Person { |
子类直接获得了此方法,当我们创建一个子类对象时就可以直接使用这个方法:
1 | public static void main(String[] args) { |
是不是感觉非常人性化,子类继承了父类的全部能力,同时还可以扩展自己的独特能力,就像一句话说的: 龙生龙凤生凤,老鼠儿子会打洞。
如果父类存在一个有参构造方法,子类必须在构造方法中调用:
1 | public class Person { |
可以看到,此时两个子类都报错了:
因为子类在构造时,不仅要初始化子类的属性,还需要初始化父类的属性,所以说在默认情况下,子类其实是调用了父类的构造方法的,只是在无参的情况下可以省略,但是现在父类构造方法需要参数,那么我们就需要手动指定了:
既然现在父类需要三个参数才能构造,那么子类需要按照同样的方式调用父类的构造方法:
1 | public class Student extends Person{ |
1 | public class Worker extends Person{ |
我们在使用子类时,可以将其当做父类来使用:
1 | public static void main(String[] args) { |
虽然我们这里使用的是父类类型引用的对象,但是这并不代表子类就彻底变成父类了,这里仅仅只是当做父类使用而已。
我们也可以使用强制类型转换,将一个被当做父类使用的子类对象,转换回子类:
1 | public static void main(String[] args) { |
但是注意,这种方式只适用于这个对象本身就是对应的子类才可以,如果本身都不是这个子类,或者说就是父类,那么会出现问题:
1 | public static void main(String[] args) { |
此时直接出现了类型转换异常,因为本身不是这个类型,强转也没用。
那么如果我们想要判断一下某个变量所引用的对象到底是什么类,那么该怎么办呢?
1 | public static void main(String[] args) { |
如果变量所引用的对象是对应类型或是对应类型的子类,那么instanceof
都会返回true
,否则返回false
。
最后我们需要来特别说明一下,子类是可以定义和父类同名的属性的:
1 | public class Worker extends Person{ |
此时父类的name属性和子类的name属性是同时存在的,那么当我们在子类中直接使用时:
1 | public void work(){ |
所以说,我们在使用时,实际上这里得到的结果为null
:
那么,在子类存在同名变量的情况下,怎么去访问父类的呢?我们同样可以使用super
关键字来表示父类:
1 | public void work(){ |
这样得到的结果就不一样了:
但是注意,没有super.super
这种用法,也就是说如果存在多级继承的话,那么最多只能通过这种方法访问到父类的属性(包括继承下来的属性)
顶层Object类
实际上所有类都默认继承自Object类,除非手动指定继承的类型,但是依然改变不了最顶层的父类是Object类。所有类都包含Object类中的方法,比如:
我们发现,除了我们自己在类中编写的方法之外,还可以调用一些其他的方法,那么这些方法不可能无缘无故地出现,肯定同样是因为继承得到的,那么这些方法是继承谁得到的呢?
1 | public class Person extends Object{ |
所以说我们的继承结构差不多就是:
既然所有的类都默认继承自Object,我们来看看这个类里面有哪些内容:
1 | public class Object { |
这里我们可以尝试调用一下Object为我们提供的toString()
方法:
1 | public static void main(String[] args) { |
这里就是按照上面说的格式进行打印:
当然,我们直接可以给println
传入一个Object类型的对象:
1 | public void println(Object x) { |
有小伙伴肯定会好奇,这里不是接受的一个Object类型的值的,为什么任意类型都可以传入呢?因为所有类型都是继承自Object,如果方法接受的参数是一个引用类型的值,那只要是这个类的对象或是这个类的子类的对象,都可以作为参数传入。
我们也可以试试看默认提供的equals
方法:
1 | public static void main(String[] args) { |
因为默认比较的是两个对象是否为同一个对象,所以说这里得到的肯定是false,但是有些情况下,实际上我们所希望的情况是如果名字、年龄、性别都完全相同,那么这肯定是同一个人,但是这里却做不到这样的判断,我们需要修改一下equals
方法的默认实现来完成,这就要用到方法的重写了。
方法的重写
注意,方法的重写不同于之前的方法重载,不要搞混了,方法的重载是为某个方法提供更多种类,而方法的重写是覆盖原有的方法实现,比如我们现在不希望使用Object类中提供的equals
方法,那么我们就可以将其重写了:
1 | public class Person{ |
在重写Object提供的equals
方法之后,就会按照我们的方式进行判断了:
1 | public static void main(String[] args) { |
有时候为了方便查看对象的各个属性,我们可以将Object类提供的toString
方法重写了:
1 |
|
这样,我们直接打印对象时,就会打印出对象的各个属性值了:
1 | public static void main(String[] args) { |
注意,静态方法不支持重写,因为它是属于类本身的,但是它可以被继承。
基于这种方法可以重写的特性,对于一个类定义的行为,不同的子类可以出现不同的行为,比如考试,学生考试可以得到A,而工人去考试只能得到D:
1 | public class Person { |
1 | public class Student extends Person{ |
1 | public class Worker extends Person{ |
这样,不同的子类,对于同一个方法会产生不同的结果:
1 | public static void main(String[] args) { |
这其实就是面向对象编程中多态特性的一种体现。
注意,我们如果不希望子类重写某个方法,我们可以在方法前添加final
关键字,表示这个方法已经是最终形态:
1 | public final void exam(){ |
或者,如果父类中方法的可见性为private
,那么子类同样无法访问,也就不能重写,但是可以定义同名方法:
虽然这里可以编译通过,但是并不是对父类方法的重写,仅仅是子类自己创建的一个新方法。
还有,我们在重写父类方法时,如果希望调用父类原本的方法实现,那么同样可以使用super
关键字:
1 |
|
然后就是访问权限的问题,子类在重写父类方法时,不能降低父类方法中的可见性:
1 | public void exam(){ |
因为子类实际上可以当做父类使用,如果子类的访问权限比父类还低,那么在被当做父类使用时,就可能出现无视访问权限调用的情况,这样肯定是不行的,但是相反的,我们可以在子类中提升权限:
1 | protected void exam(){ |
1 |
|
可以看到作为子类时就可以正常调用,但是如果将其作为父类使用,因为访问权限不足所有就无法使用,总之,子类重写的方法权限不能比父类还低。
抽象类
在我们学习了类的继承之后,实际上我们会发现,越是处于顶层定义的类,实际上可以进一步地进行抽象,比如我们前面编写的考试方法:
1 | protected void exam(){ |
这个方法再子类中一定会被重写,所以说除非子类中调用父类的实现,否则一般情况下永远都不会被调用,就像我们说一个人会不会考试一样,实际上人怎么考试是一个抽象的概念,而学生怎么考试和工人怎么考试,才是具体的一个实现,所以说,我们可以将人类进行进一步的抽象,让某些方法完全由子类来实现,父类中不需要提供实现。
要实现这样的操作,我们可以将人类变成抽象类,抽象类比类还要抽象:
1 | public abstract class Person { //通过添加abstract关键字,表示这个类是一个抽象类 |
而具体的实现,需要由子类来完成,而且如果是子类,必须要实现抽象类中所有抽象方法:
1 | public class Worker extends Person{ |
抽象类由于不是具体的类定义(它是类的抽象)可能会存在某些方法没有实现,因此无法直接通过new关键字来直接创建对象:
要使用抽象类,我们只能去创建它的子类对象。
抽象类一般只用作继承使用,当然,抽象类的子类也可以是一个抽象类:
1 | public abstract class Student extends Person{ //如果抽象类的子类也是抽象类,那么可以不用实现父类中的抽象方法 |
注意,抽象方法的访问权限不能为private
:
因为抽象方法一定要由子类实现,如果子类都访问不了,那么还有什么意义呢?所以说不能为私有。
接口
接口甚至比抽象类还抽象,他只代表某个确切的功能!也就是只包含方法的定义,甚至都不是一个类!接口一般只代表某些功能的抽象,接口包含了一些列方法的定义,类可以实现这个接口,表示类支持接口代表的功能(类似于一个插件,只能作为一个附属功能加在主体上,同时具体实现还需要由主体来实现)
咋一看,这啥意思啊,什么叫支持接口代表的功能?实际上接口的目标就是将类所具有某些的行为抽象出来。
比如说,对于人类的不同子类,学生和老师来说,他们都具有学习这个能力,既然都有,那么我们就可以将学习这个能力,抽象成接口来进行使用,只要是实现这个接口的类,都有学习的能力:
1 | public interface Study { //使用interface表示这是一个接口 |
我们可以让类实现这个接口:
1 | public class Student extends Person implements Study { //使用implements关键字来实现接口 |
1 | public class Teacher extends Person implements Study { |
接口不同于继承,接口可以同时实现多个:
1 | public class Student extends Person implements Study, A, B, C { //多个接口的实现使用逗号隔开 |
所以说有些人说接口其实就是Java中的多继承,但是我个人认为这种说法是错的,实际上实现接口更像是一个类的功能列表,作为附加功能存在,一个类可以附加很多个功能,接口的使用和继承的概念有一定的出入,顶多说是多继承的一种替代方案。
接口跟抽象类一样,不能直接创建对象,但是我们也可以将接口实现类的对象以接口的形式去使用:
当做接口使用时,只有接口中定义的方法和Object类的方法,无法使用类本身的方法和父类的方法。
接口同样支持向下转型:
1 | public static void main(String[] args) { |
这里的使用其实跟之前的父类是差不多的。
从Java8开始,接口中可以存在方法的默认实现:
1 | public interface Study { |
如果方法在接口中存在默认实现,那么实现类中不强制要求进行实现。
接口不同于类,接口中不允许存在成员变量和成员方法,但是可以存在静态变量和静态方法,在接口中定义的变量只能是:
1 | public interface Study { |
跟普通的类一样,我们可以直接通过接口名.的方式使用静态内容:
1 | public static void main(String[] args) { |
接口是可以继承自其他接口的:
1 | public interface A exetnds B { |
并且接口没有继承数量限制,接口支持多继承:
1 | public interface A exetnds B, C, D { |
接口的继承相当于是对接口功能的融合罢了。
最后我们来介绍一下Object类中提供的克隆方法,为啥要留到这里才来讲呢?因为它需要实现接口才可以使用:
1 | package java.lang; |
实现接口后,我们还需要将克隆方法的可见性提升一下,不然还用不了:
1 | public class Student extends Person implements Study, Cloneable { //首先实现Cloneable接口,表示这个类具有克隆的功能 |
接着我们来尝试一下,看看是不是会得到一个一模一样的对象:
1 | public static void main(String[] args) throws CloneNotSupportedException { //这里向上抛出一下异常,还没学异常,所以说照着写就行了 |
可以发现,原对象和克隆对象,是两个不同的对象,但是他们的各种属性都是完全一样的:
通过实现接口,我们就可以很轻松地完成对象的克隆了,在我们之后的学习中,还会经常遇到接口的使用。
注意: 以下内容为选学内容,在设计模式篇视频教程中有详细介绍。
克隆操作可以完全复制一个对象的所有属性,但是像这样的拷贝操作其实也分为浅拷贝和深拷贝。
- 浅拷贝: 对于类中基本数据类型,会直接复制值给拷贝对象;对于引用类型,只会复制对象的地址,而实际上指向的还是原来的那个对象,拷贝个基莫。
- 深拷贝: 无论是基本类型还是引用类型,深拷贝会将引用类型的所有内容,全部拷贝为一个新的对象,包括对象内部的所有成员变量,也会进行拷贝。
那么clone方法出来的克隆对象,是深拷贝的结果还是浅拷贝的结果呢?
1
2
3
4
5 public static void main(String[] args) throws CloneNotSupportedException {
Student student = new Student("小明", 18, "男");
Student clone = (Student) student.clone();
System.out.println(student.name == clone.name);
}
可以看到,虽然Student对象成功拷贝,但是其内层对象并没有进行拷贝,依然只是对象引用的复制,所以Java为我们提供的
clone
方法只会进行浅拷贝。
枚举类
假设现在我们想给小明添加一个状态(跑步、学习、睡觉),外部可以实时获取小明的状态:
1 | public class Student extends Person implements Study { |
但是这样会出现一个问题,如果我们仅仅是存储字符串,似乎外部可以不按照我们规则,传入一些其他的字符串。这显然是不够严谨的,有没有一种办法,能够更好地去实现这样的状态标记呢?我们希望开发者拿到使用的就是我们预先定义好的状态,所以,我们可以使用枚举类来完成:
1 | public enum Status { //enum表示这是一个枚举类,枚举类的语法稍微有一些不一样 |
使用枚举类也非常方便,就像使用普通类型那样:
1 | private Status status; //类型变成刚刚定义的枚举类 |
这样,别人在使用时,就能很清楚地知道我们支持哪些了:
枚举类型使用起来就非常方便了,其实枚举类型的本质就是一个普通的类,但是它继承自Enum
类,我们定义的每一个状态其实就是一个public static final
的Status类型成员变量:
1 | //这里使用javap命令对class文件进行反编译得到 Compiled from "Status.java" |
既然枚举类型是普通的类,那么我们也可以给枚举类型添加独有的成员方法:
1 | public enum Status { |
这样,枚举就可以按照我们想要的中文名称打印了:
1 | public static void main(String[] args) { |
枚举类还自带一些继承下来的实用方法,比如获取枚举类中的所有枚举,只不过这里用到了数组,我们会在下一章进行介绍。
至此,面向对象基础内容就全部讲解完成了,下一章我们还将继续讲解面向对象的其他内容。
————————————————
版权声明:本文为柏码知识库版权所有,禁止一切未经授权的转载、发布、出售等行为,违者将被追究法律责任。
原文链接:https://www.itbaima.cn/document/jviyz2hsht9ete5k
面向对象高级篇
经过前面的学习,我们已经了解了面向对象编程的大部分基础内容,这一部分,我们将继续探索面向对象编程过程中一些常用的东西。
基本类型包装类
Java并不是纯面向对象的语言,虽然Java语言是一个面向对象的语言,但是Java中的基本数据类型却不是面向对象的。Java中的基本类型,如果想通过对象的形式去使用他们,Java提供的基本类型包装类,使得Java能够更好的体现面向对象的思想,同时也使得基本类型能够支持对象操作!
包装类介绍
所有的包装类层次结构如下:
其中能够表示数字的基本类型包装类,继承自Number类,对应关系如下表:
- byte -> Byte
- boolean -> Boolean
- short -> Short
- char -> Character
- int -> Integer
- long -> Long
- float -> Float
- double -> Double
我们可以直接使用,这里我们以Integer类为例:
1 | public static void main(String[] args) { |
包装类实际上就是将我们的基本数据类型,封装成一个类(运用了封装的思想)我们可以来看看Integer类中是怎么写的:
1 | private final int value; //类中实际上就靠这个变量在存储包装的值 |
包装类型支持自动装箱,我们可以直接将一个对应的基本类型值作为对应包装类型引用变量的值:
1 | public static void main(String[] args) { |
这是怎么做到的?为什么一个对象类型的值可以直接接收一个基本类类型的值?实际上这里就是自动装箱:
1 | public static void main(String[] args) { |
这里本质上就是被自动包装成了一个Integer类型的对象,只是语法上为了简单,就支持像这样编写。既然能装箱,也是支持拆箱的:
1 | public static void main(String[] args) { |
实际上上面的写法本质上就是:
1 | public static void main(String[] args) { |
这里就是自动拆箱,得益于包装类型的自动装箱和拆箱机制,我们可以让包装类型轻松地参与到基本类型的运算中:
1 | public static void main(String[] args) { |
因为包装类是一个类,不是基本类型,所以说两个不同的对象,那么是不相等的:
1 | public static void main(String[] args) { |
那么自动装箱的呢?
1 | public static void main(String[] args) { |
我们发现,通过自动装箱转换的Integer对象,如果值相同,得到的会是同一个对象,这是因为:
1 | public static Integer valueOf(int i) { |
IntegerCache会默认缓存-128~127之间的所有值,将这些值提前做成包装类放在数组中存放,虽然我们目前还没有学习数组,但是各位小伙伴只需要知道,我们如果直接让 -128~127之间的值自动装箱为Integer类型的对象,那么始终都会得到同一个对象,这是为了提升效率,因为小的数使用频率非常高,有些时候并不需要创建那么多对象,创建对象越多,内存也会消耗更多。
但是如果超出这个缓存范围的话,就会得到不同的对象了:
1 | public static void main(String[] args) { |
这样就不会得到同一个对象了,因为超出了缓存的范围。同样的,Long、Short、Byte类型的包装类也有类似的机制,感兴趣的小伙伴可以自己点进去看看。
我们来看看包装类中提供了哪些其他的方法,包装类支持字符串直接转换:
1 | public static void main(String[] args) { |
当然,字符串转Integer有多个方法:
1 | public static void main(String[] args) { |
我们甚至可以对十六进制和八进制的字符串进行解码,得到对应的int值:
1 | public static void main(String[] args) { |
也可以将十进制的整数转换为其他进制的字符串:
1 | public static void main(String[] args) { |
当然,Integer中提供的方法还有很多,这里就不一一列出了。
特殊包装类
除了我们上面认识的这几种基本类型包装类之外,还有两个比较特殊的包装类型。
其中第一个是用于计算超大数字的BigInteger,我们知道,即使是最大的long类型,也只能表示64bit的数据,无法表示一个非常大的数,但是BigInteger没有这些限制,我们可以让他等于一个非常大的数字:
1 | public static void main(String[] args) { |
我们可以通过调用类中的方法,进行运算操作:
1 | public static void main(String[] args) { |
我们来看看结果:
可以看到,此时数值已经非常大了,也可以轻松计算出来。咱们来点更刺激的:
1 | public static void main(String[] args) { |
可以看到,这个数字已经大到一排显示不下了:
一般情况,对于非常大的整数计算,我们就可以使用BigInteger来完成。
我们接着来看第二种,前面我们说了,浮点类型精度有限,对于需要精确计算的场景,就没办法了,而BigDecimal可以实现小数的精确计算。
1 | public static void main(String[] args) { |
可以看到,确实可以精确到这种程度:
但是注意,对于这种结果没有终点的,无限循环的小数,我们必须要限制长度,否则会出现异常。
数组
我们接着来看一个比较特殊的类型,数组。
假设出现一种情况,我们想记录100个数字,要是采用定义100个变量的方式可以吗?是不是有点太累了?这种情况我们就可以使用数组来存放一组相同类型的数据。
一维数组
数组是相同类型数据的有序集合,数组可以代表任何相同类型的一组内容(包括引用类型和基本类型)其中存放的每一个数据称为数组的一个元素,我们来看看如何去定义一个数组变量:
1 | public static void main(String[] args) { |
注意,数组类型比较特殊,它本身也是类,但是编程不可见(底层C++写的,在运行时动态创建)即使是基本类型的数组,也是以对象的形式存在的,并不是基本数据类型。所以,我们要创建一个数组,同样需要使用new
关键字:
1 | public static void main(String[] args) { |
除了上面这种方式之外,我们也可以使用其他方式:
1 | 类型[] 变量名称 = new 类型[数组大小]; |
创建出来的数组每个位置上都有默认值,如果是引用类型,就是null,如果是基本数据类型,就是0,或者是false,跟对象成员变量的默认值是一样的,要访问数组的某一个元素,我们可以:
1 | public static void main(String[] args) { |
注意,数组的下标是从0开始的,不是从1开始的,所以说第一个元素的下标就是0,我们要访问第一个元素,那么直接输入0就行了,但是注意千万别写成负数或是超出范围了,否则会出现异常。
我们也可以使用这种方式为数组的元素赋值:
1 | public static void main(String[] args) { |
因为数组本身也是一个对象,数组对象也是具有属性的,比如长度:
1 | public static void main(String[] args) { |
注意,这个length
是在一开始就确定的,而且是final
类型的,不允许进行修改,也就是说数组的长度一旦确定,不能随便进行修改,如果需要使用更大的数组,只能重新创建。
当然,既然是类型,那么肯定也是继承自Object类的:
1 | public static void main(String[] args) { |
但是,很遗憾,除了clone()之外,这些方法并没有被重写,也就是说依然是采用的Object中的默认实现:
所以说通过toString()
打印出来的结果,好丑,只不过我们可以发现,数组类型的类名很奇怪,是[
开头的。
因此,如果我们要打印整个数组中所有的元素,得一个一个访问:
1 | public static void main(String[] args) { |
有时候为了方便,我们可以使用简化版的for语句foreach
语法来遍历数组中的每一个元素:
1 | public static void main(String[] args) { |
是不是感觉这种写法更加简洁?只不过这仅仅是语法糖而已,编译之后依然是跟上面一样老老实实在遍历的:
1 | public static void main(String[] args) { //反编译的结果 |
对于这种普通的数组,其实使用还是挺简单的。这里需要特别说一下,对于基本类型的数组来说,是不支持自动装箱和拆箱的:
1 | public static void main(String[] args) { |
还有,由于基本数据类型和引用类型不同,所以说int类型的数组时不能被Object类型的数组变量接收的:
但是如果是引用类型的话,是可以的:
1 | public static void main(String[] args) { |
1 | public static void main(String[] args) { |
多维数组
前面我们介绍了简单的数组(一维数组)既然数组可以是任何类型的,那么我们能否创建数组类型的数组呢?答案是可以的,套娃嘛,谁不会:
1 | public static void main(String[] args) { |
存放数组的数组,相当于将维度进行了提升,比如上面的就是一个2x10的数组:
这个中数组一共有2个元素,每个元素都是一个存放10个元素的数组,所以说最后看起来就像一个矩阵一样。甚至可以继续套娃,将其变成一个三维数组,也就是存放数组的数组的数组。
1 | public static void main(String[] args) { |
在访问多维数组时,我们需要使用多次[]
运算符来得到对应位置的元素。如果我们要遍历多维数组话,那么就需要多次嵌套循环:
1 | public static void main(String[] args) { |
可变长参数
我们接着来看数组的延伸应用,实际上我们的方法是支持可变长参数的,什么是可变长参数?
1 | public class Person { |
我们在使用时,可以传入0 - N个对应类型的实参:
1 | public static void main(String[] args) { |
那么我们在方法中怎么才能得到这些传入的参数呢,实际上可变长参数本质就是一个数组:
1 | public void test(String... strings){ //strings这个变量就是一个String[]类型的 |
注意,如果同时存在其他参数,那么可变长参数只能放在最后:
1 | public void test(int a, int b, String... strings){ |
这里最后我们再来说一个从开始到现在一直都没有说的东西:
1 | public static void main(String[] args) { //这个String[] args到底是个啥??? |
实际上这个是我们在执行Java程序时,输入的命令行参数,我们可以来打印一下:
1 | public static void main(String[] args) { |
可以看到,默认情况下直接运行什么都没有,但是如果我们在运行时,添加点内容的话:
1 | java com/test/Main lbwnb aaaa xxxxx #放在包中需要携带主类完整路径才能运行 |
可以看到,我们在后面随意添加的三个参数,都放到数组中了:
这个东西我们作为新手一般也不会用到,只做了解就行了。
字符串
字符串类是一个比较特殊的类,它用于保存字符串。我们知道,基本类型char
可以保存一个2字节的Unicode字符,而字符串则是一系列字符的序列(在C中就是一个字符数组)Java中没有字符串这种基本类型,因此只能使用类来进行定义。注意,字符串中的字符一旦确定,无法进行修改,只能重新创建。
String类
String本身也是一个类,只不过它比较特殊,每个用双引号括起来的字符串,都是String类型的一个实例对象:
1 | public static void main(String[] args) { |
我们也可以象征性地使用一下new关键字:
1 | public static void main(String[] args) { |
注意,如果是直接使用双引号创建的字符串,如果内容相同,为了优化效率,那么始终都是同一个对象:
1 | public static void main(String[] args) { |
但是如果我们使用构造方法主动创建两个新的对象,那么就是不同的对象了:
1 | public static void main(String[] args) { |
至于为什么会出现这种情况,我们在JVM篇视频教程中会进行详细的介绍,这里各位小伙伴只需要记住就行了。因此,如果我们仅仅是想要判断两个字符串的内容是否相同,不要使用==
,String类重载了equals
方法用于判断和比较内容是否相同:
1 | public static void main(String[] args) { |
既然String也是一个类,那么肯定是具有一些方法的,我们可以来看看:
1 | public static void main(String[] args) { |
因为双引号括起来的字符串本身就是一个实例对象,所以说我们也可以直接用:
1 | public static void main(String[] args) { |
字符串类中提供了很多方便我们操作的方法,比如字符串的裁剪、分割操作:
1 | public static void main(String[] args) { |
1 | public static void main(String[] args) { |
字符数组和字符串之间是可以快速进行相互转换的:
1 | public static void main(String[] args) { |
1 | public static void main(String[] args) { |
当然,String类还有很多其他的一些方法,这里就不一一介绍了。
StringBuilder类
我们在之前的学习中已经了解,字符串支持使用+
和+=
进行拼接操作。
但是拼接字符串实际上底层需要进行很多操作,如果程序中大量进行字符串的拼接似乎不太好,编译器是很聪明的,String的拼接会在编译时进行各种优化:
1 | public static void main(String[] args) { |
编译之后就变成这样了:
1 | public static void main(String[] args) { |
对于变量来说,也有优化,比如下面这种情况:
1 | public static void main(String[] args) { |
如果直接使用加的话,每次运算都会生成一个新的对象,这里进行4次加法运算,那么中间就需要产生4个字符串对象出来,是不是有点太浪费了?这种情况实际上会被优化为下面的写法:
1 | public static void main(String[] args) { |
这里创建了一个StringBuilder的类型,这个类型是干嘛的呢?实际上它就是专门用于构造字符串的,我们可以使用它来对字符串进行拼接、裁剪等操作,它就像一个字符串编辑器,弥补了字符串不能修改的不足:
1 | public static void main(String[] args) { |
它还支持裁剪等操作:
1 | public static void main(String[] args) { |
当然,StringBuilder类的编辑操作也非常多,这里就不一一列出了。
正则表达式
我们现在想要实现这样一个功能,对于给定的字符串进行判断,如果字符串符合我们的规则,那么就返回真,否则返回假,比如现在我们想要判断字符串是不是邮箱的格式:
1 | public static void main(String[] args) { |
那么现在请你设计一个Java程序用于判断,你该怎么做?是不是感觉很麻烦,但是我们使用正则表达式就可以很轻松解决这种字符串格式匹配问题。
正则表达式(regular expression)描述了一种字符串匹配的模式(pattern),可以用来检查一个串是否含有某种子串、将匹配的子串替换或者从某个串中取出符合某个条件的子串等。
我们先来看看下面的这个例子:
1 | public static void main(String[] args) { |
用于规定给定组件必须要出现多少次才能满足匹配的,我们一般称为限定符,限定符表如下:
字符 | 描述 |
---|---|
* | 匹配前面的子表达式零次或多次。例如,zo* 能匹配 “z” 以及 “zoo”。***** 等价于 {0,}。 |
+ | 匹配前面的子表达式一次或多次。例如,zo+ 能匹配 “zo” 以及 "zoo",但不能匹配 “z”。+ 等价于 {1,}。 |
? | 匹配前面的子表达式零次或一次。例如,do(es)? 可以匹配 “do” 、 “does”、 “doxy” 中的 “do” 。? 等价于 {0,1}。 |
{n} | n 是一个非负整数。匹配确定的 n 次。例如,o{2} 不能匹配 “Bob” 中的 o,但是能匹配 “food” 中的两个 o。 |
{n,} | n 是一个非负整数。至少匹配n 次。例如,o{2,} 不能匹配 “Bob” 中的 o,但能匹配 “foooood” 中的所有 o。o{1,} 等价于 o+。o{0,} 则等价于 o*。 |
{n,m} | m 和 n 均为非负整数,其中 n <= m。最少匹配 n 次且最多匹配 m 次。例如,o{1,3} 将匹配 “fooooood” 中的前三个 o。o{0,1} 等价于 o?。请注意在逗号和两个数之间不能有空格。 |
如果我们想要表示一个范围内的字符,可以使用方括号:
1 | public static void main(String[] args) { |
对于普通字符来说,我们可以下面的方式实现多种字符匹配:
字符 | 描述 |
---|---|
[ABC] | 匹配 […] 中的所有字符,例如 [aeiou] 匹配字符串 “google runoob taobao” 中所有的 e o u a 字母。 |
[^ABC] | 匹配除了 […] 中字符的所有字符,例如 [^aeiou] 匹配字符串 “google runoob taobao” 中除了 e o u a 字母的所有字母。 |
[A-Z] | [A-Z] 表示一个区间,匹配所有大写字母,[a-z] 表示所有小写字母。 |
. | 匹配除换行符(\n、\r)之外的任何单个字符,相等于 [^\n\r] |
[\s\S] | 匹配所有。\s 是匹配所有空白符,包括换行,\S 非空白符,不包括换行。 |
\w | 匹配字母、数字、下划线。等价于 [A-Za-z0-9_] |
当然,这里仅仅是对正则表达式的简单使用,实际上正则表达式内容非常多,如果需要完整学习正则表达式,可以到:https://www.runoob.com/regexp/regexp-syntax.html
正则表达式并不是只有Java才支持,其他很多语言比如JavaScript、Python等等都是支持正则表达式的。
内部类
上一章我们详细介绍了类,我们现在已经知道该如何创建类、使用类了。当然,类的创建其实可以有多种多样的方式,并不仅仅局限于普通的创建。内部类顾名思义,就是创建在内部的类,那么具体是什么的内部呢,我们接着就来讨论一下。
**注意:**内部类很多地方都很绕,所以说一定要仔细思考。
成员内部类
我们可以直接在类的内部定义成员内部类:
1 | public class Test { |
成员内部类和成员方法、成员变量一样,是对象所有的,而不是类所有的,如果我们要使用成员内部类,那么就需要:
1 | public static void main(String[] args) { |
虽然看着很奇怪,但是确实是这样使用的。我们同样可以使用成员内部类中的方法:
1 | public static void main(String[] args) { |
注意,成员内部类也可以使用访问权限控制,如果我们我们将其权限改为private
,那么就像我们把成员变量访问权限变成私有一样,外部是无法访问到这个内部类的:
可以看到这里直接不认识了。
这里我们需要特别注意一下,在成员内部类中,是可以访问到外层的变量的:
1 | public class Test { |
每个类可以创建一个对象,每个对象中都有一个单独的类定义,可以通过这个成员内部类又创建出更多对象,套娃了属于是。
所以说我们在使用时:
1 | public static void main(String[] args) { |
那现在问大家一个问题,外部能访问内部类里面的成员变量吗?
那么如果内部类中也定义了同名的变量,此时我们怎么去明确要使用的是哪一个呢?
1 | public class Test { |
包括对方法的调用和super关键字的使用,也是一样的:
1 | public class Inner { |
所以说成员内部类其实在某些情况下使用起来比较麻烦,对于这种成员内部类,我们一般只会在类的内部自己使用。
静态内部类
前面我们介绍了成员内部类,它就像成员变量和成员方法一样,是属于对象的,同样的,静态内部类就像静态方法和静态变量一样,是属于类的,我们可以直接创建使用。
1 | public class Test { |
不需要依附任何对象,我们可以直接创建静态内部类的对象:
1 | public static void main(String[] args) { |
静态内部类由于是静态的,所以相对外部来说,整个内部类中都处于静态上下文(注意只是相当于外部来说)是无法访问到外部类的非静态内容的:
只不过受影响的只是外部内容的使用,内部倒是不受影响,还是跟普通的类一样:
1 | public static class Inner { |
其实也很容易想通,因为静态内部类是属于外部类的,不依附任何对象,那么我要是直接访问外部类的非静态属性,那到底访问哪个对象的呢?这样肯定是说不通的。
局部内部类
局部内部类就像局部变量一样,可以在方法中定义。
1 | public class Test { |
既然是在方法中声明的类,那作用范围也就只能在方法中了:
1 | public class Test { |
只不过这种局部内部类的形式,使用频率很低,基本上不会用到,所以说了解就行了。
匿名内部类
匿名内部类是我们使用频率非常高的一种内部类,它是局部内部类的简化版。
还记得我们在之前学习的抽象类和接口吗?在抽象类和接口中都会含有某些抽象方法需要子类去实现,我们当时已经很明确地说了不能直接通过new的方式去创建一个抽象类或是接口对象,但是我们可以使用匿名内部类。
1 | public abstract class Student { |
正常情况下,要创建一个抽象类的实例对象,只能对其进行继承,先实现未实现的方法,然后创建子类对象。
而我们可以在方法中使用匿名内部类,将其中的抽象方法实现,并直接创建实例对象:
1 | public static void main(String[] args) { |
此时这里创建出来的Student对象,就是一个已经实现了抽象方法的对象,这个抽象类直接就定义好了,甚至连名字都没有,就可以直接就创出对象。
匿名内部类中同样可以使用类中的属性(因为它本质上就相当于是对应类型的子类)所以说:
1 | Student student = new Student() { |
同样的,接口也可以通过这种匿名内部类的形式,直接创建一个匿名的接口实现类:
1 | public static void main(String[] args) { |
当然,并不是说只有抽象类和接口才可以像这样创建匿名内部类,普通的类也可以,只不过意义不大,一般情况下只是为了进行一些额外的初始化工作而已。
Lambda表达式
前面我们介绍了匿名内部类,我们可以通过这种方式创建一个临时的实现子类。
特别的,如果一个接口中有且只有一个待实现的抽象方法,那么我们可以将匿名内部类简写为Lambda表达式:
1 | public static void main(String[] args) { |
在初学阶段,为了简化学习,各位小伙伴就认为Lambda表达式就是匿名内部类的简写就行了(Lambda表达式的底层其实并不只是简简单单的语法糖替换,感兴趣的可以在新特性篇视频教程中了解)
那么它是一个怎么样的简写规则呢?我们来看一下Lambda表达式的具体规范:
- 标准格式为:
([参数类型 参数名称,]...) ‐> { 代码语句,包括返回值 }
- 和匿名内部类不同,Lambda仅支持接口,不支持抽象类
- 接口内部必须有且仅有一个抽象方法(可以有多个方法,但是必须保证其他方法有默认实现,必须留一个抽象方法出来)
比如我们之前写的Study接口,只要求实现一个无参无返回值的方法,所以说直接就是最简单的形式:
1 | () -> System.out.println("我是学习方法!"); //跟之前流程控制一样,如果只有一行代码花括号可省略 |
当然,如果有一个参数和返回值的话:
1 | public static void main(String[] args) { |
注意,如果方法体中只有一个返回语句,可以直接省去花括号和return
关键字:
1 | Study study = (a) -> { |
1 | Study study = (a) -> "今天学会了"+a; |
如果参数只有一个,那么可以省去小括号:
1 | Study study = a -> "今天学会了"+a; |
是不是感觉特别简洁,实际上我们程序员追求的就是写出简洁高效的代码,而Java也在朝这个方向一直努力,近年来从Java 9开始出现的一些新语法基本都是各种各样的简写版本。
如果一个方法的参数需要的是一个接口的实现:
1 | public static void main(String[] args) { |
当然,这还只是一部分,对于已经实现的方法,如果我们想直接作为接口抽象方法的实现,我们还可以使用方法引用。
方法引用
方法引用就是将一个已实现的方法,直接作为接口中抽象方法的实现(当然前提是方法定义得一样才行)
1 | public interface Study { |
那么使用时候,可以直接使用Lambda表达式:
1 | public static void main(String[] args) { |
只不过还能更简单,因为Integer类中默认提供了求两个int值之和的方法:
1 | //Integer类中就已经有对应的实现了 |
此时,我们可以直接将已有方法的实现作为接口的实现:
1 | public static void main(String[] args) { |
我们发现,Integer.sum的参数和返回值,跟我们在Study中定义的完全一样,所以说我们可以直接使用方法引用:
1 | public static void main(String[] args) { |
方法引用其实本质上就相当于将其他方法的实现,直接作为接口中抽象方法的实现。任何方法都可以通过方法引用作为实现:
1 | public interface Study { |
如果是普通从成员方法,我们同样需要使用对象来进行方法引用:
1 | public static void main(String[] args) { |
因为现在只需要一个String类型的返回值,由于String的构造方法在创建对象时也会得到一个String类型的结果,所以说:
1 | public static void main(String[] args) { |
反正只要是符合接口中方法的定义的,都可以直接进行方法引用,对于Lambda表达式和方法引用,在Java新特性介绍篇视频教程中还有详细的讲解,这里就不多说了。
异常机制
在理想的情况下,我们的程序会按照我们的思路去运行,按理说是不会出现问题的,但是,代码实际编写后并不一定是完美的,可能会有我们没有考虑到的情况,如果这些情况能够正常得到一个错误的结果还好,但是如果直接导致程序运行出现问题了呢?
1 | public static void main(String[] args) { |
此时我们可以看到,出现了运算异常:
那么这个异常到底是什么样的一种存在呢?当程序运行出现我们没有考虑到的情况时,就有可能出现异常或是错误!
异常的类型
我们在之前其实已经接触过一些异常了,比如数组越界异常,空指针异常,算术异常等,他们其实都是异常类型,我们的每一个异常也是一个类,他们都继承自Exception
类!异常类型本质依然类的对象,但是异常类型支持在程序运行出现问题时抛出(也就是上面出现的红色报错)也可以提前声明,告知使用者需要处理可能会出现的异常!
异常的第一种类型是运行时异常,如上述的列子,在编译阶段无法感知代码是否会出现问题,只有在运行的时候才知道会不会出错(正常情况下是不会出错的),这样的异常称为运行时异常,异常也是由类定义的,所有的运行时异常都继承自RuntimeException
。
1 | public static void main(String[] args) { |
又比如下面的这种情况:
1 | public static void main(String[] args) { |
异常的另一种类型是编译时异常,编译时异常明确指出可能会出现的异常,在编译阶段就需要进行处理(捕获异常)必须要考虑到出现异常的情况,如果不进行处理,将无法通过编译!默认继承自Exception
类的异常都是编译时异常。
1 | protected native Object clone() throws CloneNotSupportedException; |
比如Object类中定义的clone
方法,就明确指出了在运行的时候会出现的异常。
还有一种类型是错误,错误比异常更严重,异常就是不同寻常,但不一定会导致致命的问题,而错误是致命问题,一般出现错误可能JVM就无法继续正常运行了,比如OutOfMemoryError
就是内存溢出错误(内存占用已经超出限制,无法继续申请内存了)
1 | public static void main(String[] args) { |
比如这样的一个无限递归的方法,会导致运行过程中无限制地向下调用方法,导致栈溢出:
这种情况就是错误了,已经严重到整个程序都无法正常运行了。又比如:
1 | public static void main(String[] args) { |
实际上我们电脑的内存是有限的,不可能无限制地使用内存来存放变量,所以说如果内存不够用了,会直接:
此时没有更多的可用内存供我们的程序使用,那么程序也就没办法继续运行下去了,这同样是一个很严重的错误。
当然,我们这一块主要讨论的目录依然是异常。
自定义异常
异常其实就两大类,一个是编译时异常,一个是运行时异常,我们先来看编译时异常。
1 | public class TestException extends Exception{ |
编译时异常只需要继承Exception就行了,编译时异常的子类有很多很多,仅仅是SE中就有700多个。
异常多种多样,不同的异常对应着不同的情况,比如在类型转换时出错那么就是类型转换异常,如果是使用一个值为null的变量调用方法,那么就会出现空指针异常。
运行时异常只需要继承RuntimeException就行了:
1 | public class TestException extends RuntimeException{ |
RuntimeException继承自Exception,Exception继承自Throwable:
运行时异常同同样也有很多,只不过运行时异常和编译型异常在使用时有一些不同,我们会在后面的学习中慢慢认识。
当然还有一种类型是Error,它是所有错误的父类,同样是继承自Throwable的。
抛出异常
当别人调用我们的方法时,如果传入了错误的参数导致程序无法正常运行,这时我们就可以手动抛出一个异常来终止程序继续运行下去,同时告知上一级方法执行出现了问题:
1 | public static int test(int a, int b) { |
异常的抛出同样需要创建一个异常对象出来,我们抛出异常实际上就是将这个异常对象抛出,异常对象携带了我们抛出异常时的一些信息,比如是因为什么原因导致的异常,在RuntimeException的构造方法中我们可以写入原因。
当出现异常时:
程序会终止,并且会打印栈追踪信息,因为各位小伙伴才初学,还不知道什么是栈,我们这里就简单介绍一下,实际上方法之间的调用是有层级关系的,而当异常发生时,方法调用的每一层都会在栈追踪信息中打印出来,比如这里有两个at
,实际上就是在告诉我们程序运行到哪个位置时出现的异常,位于最上面的就是发生异常的最核心位置,我们代码的第15行。
并且这里会打印出当前抛出的异常类型和我们刚刚自定义异常信息。
注意,如果我们在方法中抛出了一个非运行时异常,那么必须告知函数的调用方我们会抛出某个异常,函数调用方必须要对抛出的这个异常进行对应的处理才可以:
1 | private static void test() throws Exception { //使用throws关键字告知调用方此方法会抛出哪些异常,请调用方处理好 |
注意,如果不同的分支条件会出现不同的异常,那么所有在方法中可能会抛出的异常都需要注明:
1 | private static void test(int a) throws FileNotFoundException, ClassNotFoundException { //多个异常使用逗号隔开 |
当然,并不是只有非运行时异常可以像这样明确指出,运行时异常也可以,只不过不强制要求:
1 | private static void test(int a) throws RuntimeException { |
至于如何处理明确抛出的异常,我们会下一个部分中进行讲解。
最后再提一下,我们在重写方法时,如果父类中的方法表明了会抛出某个异常,只要重写的内容中不会抛出对应的异常我们可以直接省去:
1 |
|
异常的处理
当程序没有按照我们理想的样子运行而出现异常时(默认会交给JVM来处理,JVM发现任何异常都会立即终止程序运行,并在控制台打印栈追踪信息)现在我们希望能够自己处理出现的问题,让程序继续运行下去,就需要对异常进行捕获,比如:
1 | public static void main(String[] args) { |
我们可以将代码编写到try
语句块中,只要是在这个范围内发生的异常,都可以被捕获,使用catch
关键字对指定的异常进行捕获,这里我们捕获的是NullPointerException空指针异常:
可以看到,当我们捕获异常之后,程序可以继续正常运行,并不会像之前一样直接结束掉。
注意,catch中捕获的类型只能是Throwable的子类,也就是说要么是抛出的异常,要么是错误,不能是其他的任何类型。
我们可以在catch
语句块中对捕获到的异常进行处理:
1 | public static void main(String[] args) { |
如果某个方法明确指出会抛出哪些异常,除非抛出的异常是一个运行时异常,否则我们必须要使用try-catch语句块进行异常的捕获,不然就无法通过编译:
1 | public static void main(String[] args) { |
当然,如果我们确实不想在当前这个方法中进行处理,那么我们可以继续踢皮球,抛给上一级:
1 | public static void main(String[] args) throws IOException { //继续编写throws往上一级抛 |
注意,如果已经是主方法了,那么就相当于到顶层了,此时发生异常再往上抛出的话,就会直接交给JVM进行处理,默认会让整个程序终止并打印栈追踪信息。
注意,如果我们要捕获的异常,是某个异常的父类,那么当发生这个异常时,同样可以捕获到:
1 | public static void main(String[] args) throws IOException { |
当代码可能出现多种类型的异常时,我们希望能够分不同情况处理不同类型的异常,就可以使用多重异常捕获:
1 | try { |
但是要注意一下顺序:
1 | try { |
只不过这样写好像有点丑,我们也可以简写为:
1 | try { |
如果简写的话,那么发生这些异常的时候,都会采用统一的方式进行处理了。
最后,当我们希望,程序运行时,无论是否出现异常,都会在最后执行任务,可以交给finally
语句块来处理:
1 | try { |
try
语句块至少要配合catch
或finally
中的一个:
1 | try { |
思考:try
、catch
和finally
执行顺序?
断言表达式
我们可以使用断言表达式来对某些东西进行判断,如果判断失败会抛出错误,只不过默认情况下没有开启断言,我们需要在虚拟机参数中手动开启一下:
开启断言之后,我们就可以开始使用了。
断言表达式需要使用到assert
关键字,如果assert后面的表达式判断结果为false,将抛出AssertionError错误。
1 | public static void main(String[] args) { |
比如我们可以判断变量的值,如果大于10就抛出错误:
1 | public static void main(String[] args) { |
我们可以在表达式的后面添加错误信息:
1 | public static void main(String[] args) { |
这样就会显示到错误后面了:
断言表达式一般只用于测试,我们正常的程序中一般不会使用,这里只做了解就行了。
常用工具类介绍
前面我们学习了包装类、数组和字符串,我们接着来看看常用的一些工具类。工具类就是专门为一些特定场景编写的,便于我们去使用的类,工具类一般都会内置大量的静态方法,我们可以通过类名直接使用。
数学工具类
Java提供的运算符实际上只能进行一些在小学数学中出现的运算,但是如果我们想要进行乘方、三角函数之类的高级运算,就没有对应的运算符能够做到,而此时我们就可以使用数学工具类来完成。
1 | public static void main(String[] args) { |
当然,三角函数肯定也是安排上了的:
1 | Math.sin(Math.PI / 2); //求π/2的正弦值,这里我们可以使用预置的PI进行计算 |
可能在某些情况下,计算出来的浮点数会得到一个很奇怪的结果:
1 | public static void main(String[] args) { |
正常来说,sinπ的结果应该是0才对,为什么这里得到的是一个很奇怪的数?这个E是干嘛的,这其实是科学计数法的10,后面的数就是指数,上面的结果其实就是:
其实这个数是非常接近于0,这是因为精度问题导致的,所以说实际上结果就是0。
我们也可以快速计算对数函数:
1 | public static void main(String[] args) { |
还有一些比较特殊的计算:
1 | public static void main(String[] args) { |
向上取整就是找一个大于当前数字的最小整数,向下取整就是砍掉小数部分。注意,如果是负数的话,向上取整就是去掉小数部分,向下取整就是找一个小于当前数字的最大整数。
这里我们再介绍一下随机数的生成,Java中想要生成一个随机数其实也很简单,我们需要使用Random类来生成(这个类时java.util包下的,需要手动导入才可以)
1 | public static void main(String[] args) { |
结果为,可以看到确实是一堆随机数:
只不过,程序中的随机并不是真随机,而是根据某些东西计算出来的,只不过计算过程非常复杂,能够在一定程度上保证随机性(根据爱因斯坦理论,宏观物质世界不存在真随机,看似随机的事物只是现目前无法计算而已,唯物主义的公理之一就是任何事物都有因果关系)
数组工具类
前面我们介绍了数组,但是我们发现,想要操作数组实在是有点麻烦,比如我们要打印一个数组,还得一个一个元素遍历才可以,那么有没有一个比较方便的方式去使用数组呢?我们可以使用数组工具类Arrays。
这个类也是java.util
包下类,它用于便捷操作数组,比如我们想要打印数组,可以直接通过toString方法转换字符串:
1 | public static void main(String[] args) { |
是不是感觉非常方便?这样我们直接就可以打印数组了!
除了这个方法,它还支持将数组进行排序:
1 | public static void main(String[] args) { |
感兴趣的小伙伴可以在数据结构与算法篇视频教程中了解多种多样的排序算法,这里的排序底层实现实际上用到了多种排序算法。
数组中的内容也可以快速进行填充:
1 | public static void main(String[] args) { |
我们可以快速地对一个数组进行拷贝:
1 | public static void main(String[] args) { |
1 | public static void main(String[] args) { |
我们也可以将一个数组中的内容拷贝到其他数组中:
1 | public static void main(String[] args) { |
对于一个有序的数组(从小到大排列)我们可以使用二分搜索快速找到对应的元素在哪个位置:
1 | public static void main(String[] args) { |
这里提到了二分搜索算法,我们会在后面的实战练习中进行讲解。
那要是现在我们使用的是多维数组呢?因为现在数组里面的每个元素就是一个数组,所以说toString会出现些问题:
1 | public static void main(String[] args) { |
只不过别担心,Arrays也支持对多维数组进行处理:
1 | public static void main(String[] args) { |
同样的,因为数组本身没有重写equals方法,所以说无法判断两个不同的数组对象中的每一个元素是否相同,Arrays也为一维数组和多维数组提供了相等判断的方法:
1 | public static void main(String[] args) { |
这里肯定有小伙伴疑问了,不是说基本类型的数组不能转换为引用类型的数组吗?为什么这里的deepEquals接受的是Object[]
也可以传入参数呢?这是因为现在是二维数组,二维数组每个元素都是一个数组,而数组本身的话就是一个引用类型了,所以说可以转换为Object类型,但是如果是一维数组的话,就报错:
总体来说,这个工具类对于我们数组的使用还是很方便的。