面向对象三大特性
一、封装
1.1.1 封装的定义
封装(encapsulation)有时也被称为数据隐藏
。通俗来说封装就是将一个真实的对象需要进行的所有操作(方法),以及进行这些操作所需要的信息(数据,也叫做属性值)全部组合在一个包中(类中)的过程,这样一个对象就不必依赖其他对象来完成自己的操作。(讲人话就是将一个物体身上具有的属性和针对这些属性进行的操作全部提取出来放到一个类中,并用private
关键字修饰属性字段)封装有以下三个优点(这里只做简单介绍,后面会通过例子详细解释):
- 提高代码的安全性:
- 数据隐藏可以防止外部对对象内部状态的不正当访问和修改。
- 提高代码的可维护性:
- 通过封装,修改类的内部实现不会影响外部代码,只需保证接口不变。
- 实现细节的隐藏:
- 只暴露必要的接口,隐藏实现细节,增强代码的模块化。
1.1.2 一个有趣的问题
我不知道大家是否和我一样有这样一个疑问:为什么使用private
修饰属性字段后能保证属性值的安全性?先说一下我为什么会有怎么一个问题哈,因为在实际开发中我们在封装类时属性字段都是用private
修饰的,但是我们必须提供对应的get
和set
方法,问题就在于在其他类中我们使用get
和set
方法仍然可以很容易获取到类的属性字段值并对其进行修改,那这和直接使用对象名.字段名
的区别在什么地方?甚至使用get
和set
还更麻烦一点而且好像也没有提高代码安全性吧???
这个问题可谓是困扰了我很久很久很久,终于有一天我实在受不了了决定好好研究一下到底是为什么,然后我就在我的箱子底下找到了一本名为《Java核心技术 卷I》的这么一本书,果然还是得从最基础的开始看起才能找到答案呀。哈哈哈,这下终于通透了,废话不多说,直接上解释。
要解释这个问题我们先来看一下我平时在项目开发中是怎么对类进行封装的。
@Data
public class Student {
//姓名
private String name;
//年龄
private Integer age;
//性别
private String gender;
//学号
private String stuId;
}
上面这段代码用到了Lombok
中的一个Data
注解,没有学过的小伙伴也别担心,这个注解的作用很简单就是自动帮我们生成属性字段对应的get
和set
方法,等同于下面这样:
public class Student {
//姓名
private String name;
//年龄
private Integer age;
//性别
private String gender;
//学号
private String stuId;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
public String getGender() {
return gender;
}
public void setGender(String gender) {
this.gender = gender;
}
public String getStuId() {
return stuId;
}
public void setStuId(String stuId) {
this.stuId = stuId;
}
}
这就是我平时对类进行的封装,相信大家也能够理解我为什么会产生上面的那个问题了吧,因为这样对类进行的封装真就和直接使用对象名.字段名
是一样的,甚至这样做还更复杂了,到底是哪个环节出现了问题?我明明是严格按照bilibili
大学老师教的规范来做的呀
(-̩̩̩-̩̩̩-̩̩̩-̩̩̩-̩̩̩___-̩̩̩-̩̩̩-̩̩̩-̩̩̩-̩̩̩)
其实上面这种写法是没有问题的,这样封装的类确实可以提高代码的安全性,只是我一直没有用到点上,废话不多说直接开始演示。现在对于上面的这个学生类我有一个需求就是学生的年龄必须在35岁以下,学生的性别只能是男|女
这时候应该怎么做呢,按照我以前的做法那当然是在需要给学生的年龄和性别赋值地方候用if
判断一下咯,像下面这样:
Student stu = new Student();
Integer age = 20;
String gender = "未知";
if(age.compareTo(35) < 0){
stu.setAge(age);
}
if(gender.equals("男") | gender.equals("女")){
stu.setGender(gender);
}
这样做其实也没有什么大问题,问题也就是不便于维护而已嘛,如果系统中有几百处这样的判断,然后哪天老板心情大好说:“小xx啊,我们这个学生的年龄可以范围可以放宽一点改成40岁以下都可以嘛”。这个时候不过也就是要在整个系统中找到这几百个地方修改一下的小事情嘛,反正我还年轻,只要不用动脑,体力活我完全扛得住呀,哈哈哈哈。这里面还有一个问题就是要是这几百处判断有一处你忘记加了,就可能会出现年龄为1000,性别为未知的学生,老板看见这个学生不知道是什么感受呢,哈哈哈。
所以这个时候我们封装的优势就可以体现出来了,上面的代码修改成下面这样就可以完美解决问题:
public class Student {
//姓名
private String name;
//年龄
private Integer age;
//性别
private String gender;
//学号
private String stuId;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
if(age.compareTo(35) < 0){
this.age = age;
}else {
throw new RuntimeException("学生年龄不能超过35岁");
}
}
public String getGender() {
return gender;
}
public void setGender(String gender) {
if(gender.equals("男") | gender.equals("女")){
this.gender = gender;
}else {
throw new RuntimeException("未知性别");
}
}
public String getStuId() {
return stuId;
}
public void setStuId(String stuId) {
this.stuId = stuId;
}
}
哪天老板再让你修改你就可以很自信的说上一句:"No problem"了。其实从这里就可以看出数据隐藏(封装)的优势所在。数据隐藏(封装)不仅可以在对属性值进行赋值之前进行合法性校验保证数据安全,还能在需求发生变化时轻松应对变化,提高了系统的可维护性。
1.1.3 封装的优点
-
提高代码的安全性
和直接使用
对象名.字段名
来访问对象的属性值相比,使用get
和set
方法的优势在于,我可以在set
方法中对传入的值进行合法性校验,如果传入的值不合法是不会对真实的属性值产生影响的,这就避免了程序员的失误操作可能会对数据造成的破坏性影响,就像上面提到的如果在你的代码中你忘记了对学生的年龄和性别进行合法性判断就会出现年龄为1000,性别为未知的学生。也避免了外部对对象内部的恶意访问和篡改。 -
提高代码的可维护性
现在这也很好理解了,正如上面所说的那样,如果哪天需求发生变化我们只需要在封装类这一处进行修改,极大提高了代码的健壮性和可维护性。
-
实现细节的隐藏
对于一个类来说,我们对外只提供具体的方法供外部调用,至于方法的具体实现过程外部是不需要考虑的,这样做的好处就是增强了代码的模块化,提高了代码的可维护性。
二、继承
2.1.1 继承的定义
先来看一下《Java核心技术 卷I》中对继承的描述:“继承的基本思想是,可以基于已有的类创建新的类。继承已存在的类就是复用(继承)这些类的方法,而且可以增加一些新的方法和字段,使新类可以适应新的情况”。我觉得这段描述非常好,里面提到一个关键词:代码复用(说继承就是在复用已存在类的代码,个人认为总结得非常到位)。那么我们现在就可以给继承下一个定义了,继承就是利用extends
(这里只讨论Java)关键字让两个类之间具有父子关系(或者是is-a
)关系,被继承的类叫做超类
、基类
或父类
,另一个类叫做子类
、派生类
或孩子类
,其中子类能够继承父类中所有属性(包括private
修饰的)和所有非private
修饰的方法,除此之外子类还能扩展父类没有的功能和方法,子类也能用自己的方式来实现父类的功能(方法重写)。所以子类可以看作是父类的一个特殊化,因为子类除了拥有父类所有的属性和方法外,还具有自己特有的属性和方法,因此子类可以完全替换父类,反之则不成立。
2.1.2 哪些东西可以被继承?
私有(private) | 非私有(public | protected) | |
---|---|---|
属性字段 | 可以继承 | 可以继承 |
构造方法 | 不能继承 | 不能继承 |
方法 | 不能继承 | 可以继承 |
构造方法
在Java中构造方法无论使用何种权限修饰符来修饰都是不能被子类继承的,其实这也很好理解,我们上面提到继承其实就是在复用父类里面的属性或方法,既然是复用,那也就是会把父类中的方法或属性拷贝一份到子类的内存空间中,那么如果构造方法能够被继承也就是能够被拷贝到子类中,那就会出现在子类中构造方法的名字和类名不一样的情况,这显然与Java设计的初衷不符,所以构造方法是不能够被子类继承的,但是允许子类通过super
关键字来调用父类的构造方法。
属性字段
在Java中使用private
修饰的属性字段(成员变量)是会被继承到子类当中的,只是因为使用了private
关键字修饰无法在子类中直接访问,但是可以通过对应的get
和set
方法进行访问。
方法
在Java中使用private
修饰的方法是无法被子类继承的。在这里其他课程中可能说static
和final
修饰的方法不能被子类继承,但是我认为是可以被继承的,具体的可以看我的另外一篇文章:Java中static和final修饰的方法是否能被子类继承。这是我的个人观点,不一定正确,有问题的同学可以把问题发到评论区,我们可以一起讨论一下。
关于继承这块内容如果不太理解的同学可以参考这个视频:哪些东西可以被父类继承
2.1.3 在使用构造器创建子类对象时为什么要先调用父类构造器?
在Java中使用new
关键字创建一个子类对象时,在子类的构造器方法的第一行隐藏着super()
这么一段代码,其实就是在调用父类的空参构造方法,我们也可以选择调用父类的其他构造方法,只是一定要注意调用父类构造方法的代码一定要在子类构造方法的第一行,如果没有写Java虚拟机会默认调用父类空参构造方法,如果调用了一个父类不存在的构造方法代码会报错。来看下面这么一段代码。
public class InheritanceTest {
public static void main(String[] args) {
new Child();
}
}
class Parent {
public Parent(){
System.out.println("This is Parent class");
}
}
class Child extends Parent{
public Child(){
System.out.println("This is Child class");
}
}
代码运行结果如下:
运行结果如图所示,这说明我们在使用子类的构造器创建子类对象时会先调用父类的构造器(默认为空参构造器)那这么做的目的是什么呢?
2.1.3.1 使用new
关键字时底层发生了什么?
我们先来了解一下一个对象的创建过程,即我们使用new
关键字时虚拟机都做了什么:
-
将类加载到方法区(关于虚拟机中内存的划分可自行百度,这里不做解释)
类加载涉及以下过程:
- 加载:查找并加载类的二进制数据。
- 连接:
- 验证:确保类的二进制数据没有错误,并且符合Java语言规范。
- 准备:为类的静态变量分配内存,并将其初始化为默认值。
- 解析:将类、接口、字段和方法的符号引用替换为直接引用。
- 初始化:执行类的初始化块和静态初始化块。
-
分配内存
在将类成功加载到方法区后,JVM会为新创建的对象在堆里面分配一块内存空间(这块空间是有编号的,这个编号就是对象在内存中的地址值),内存分配包括为对象的所有成员变量(所有成员变量包括从父类继承下来的成员变量)分配空间。
-
默认初始化
在内存分配之后,JVM会将分配的内存清零,即所有成员变量会被初始化为默认值:
-
数字类型(如
int
、long
、float
、double
)初始化为0
或0.0
。 -
字符类型(
char
)初始化为\u0000
(null字符)。 -
布尔类型(
boolean
)初始化为false
。 -
引用类型(如对象引用)初始化为
null
。
-
-
显示初始化
除了上面这种由JVM为成员变量初始化默认值的方式以外,如果我们已经为成员变量显式地赋过值了,那么这时候成员变量的初始化值就是我们显式赋的值。像下面这样
public class InheritanceTest { public static void main(String[] args) { new Child(); } } class Parent { private String parent = "张三"; public Parent(){ System.out.println("parnt="+parent); } } class Child extends Parent{ private String child = "张四"; public Child(){ System.out.println("child="+child); } }
输出结果为:
这时默认值不再是null
而是我们显式赋的值
5. 构造器初始化
-
父类构造器调用:如果是子类对象,首先会调用父类的构造器(无论是隐式调用还是显式调用
super()
)。 -
实例初始化块:执行类中的实例初始化块。
-
显式初始化:在构造器中显式初始化实例变量。
-
返回引用
以上步骤都结束以后
new
关键字会返回新创建对象在堆里面的地址(地址即是引用,这两者为同一东西,只是称呼不同)
其实以上步骤中的3、4、5都是在对成员变量进行初始化,只是初始化的方式不同而已。
从上面的过程可以看出,子类调用父类的构造器只是创建一个对象众多过程中的一步,其作用就是对成员变量进行初始化,这是Java中成员变量初始化的三种方法中的其中一种。
public class InheritanceTest {
public static void main(String[] args) {
new Child();
}
}
class Parent {
String parent;
public Parent(){
this.parent = "张三";
}
}
class Child extends Parent{
String child = "李四";
public Child(){
System.out.println("parnt="+parent);
System.out.println("child="+child);
}
}
如上面这段代码所示我们完全可以在父类的构造方法中完成对某些变量的显示初始化,这样可以保证如果子类中的某些资源需要依赖父类中的某些资源才能正常执行的情况下,父类中的资源会先于子类中的资源进行初始化保证程序正常执行。
三、多态
3.1 多态的定义
多态是继封装、继承之后,面向对象的第三大特性。那么究竟什么是多态呢?简单来说多态就是一个物体的多种表现形态(可能这样说还是有点抽象哈哈哈,不急马上我们就举个例子)观察下面代码
public class Polymorphism {
public static void main(String[] args) {
Student stu = new Student();
Person person = new Student();
}
}
//人类
class Person {
}
//学生类
class Student extends Person {
}
在这个例子中学生对象即有学生形态又有人的形态,这种一个对象可以表现多种形态的机制就叫做多态(其实可以简单理解为父类变量可以存储子类对象)。多态的在Java中的表现形式就是父类引用指向子类对象。即父类类型 对象名称 = 子类对象
3.2 多态的前提条件
-
有继承关系
-
有父类引用指向子类对象
-
有方法重写
3.3 多态中成员变量或成员方法访问的特点
3.3.1 访问成员变量
在java中多态情况下访问成员变量,在编译阶段编译器会检查父类中是否存在该变量,如果不存在则会报错,在运行阶段会调用父类中的成员变量。精简一下就是编译看父类,运行也看父类。这种机制其实就是静态绑定
。
3.3.2 访问成员方法
public class InheritanceTest {
public static void main(String[] args){
Parent parent = new Child();
parent.show();
}
}
class Parent {
public void show(){
System.out.println("this is parent show");
}
}
class Child extends Parent {
@Override
public void show() {
System.out.println("this is child show");
}
}
代码运行结果:
可以看到,我们用父类类型的引用去调用成员方法时调用的是子类中的方法。我们再看下面这样一段代码。
这段代码中我将父类中的show
方法注释掉了,然后编译器报了一个无法解析show
在Parent
中。结合上面两段代码可以看出,在多态情况下的成员方法访问特点:编译时会检查父类中有无这个方法,但是调用的时候会去调用子类中的方法(包括子类从父类继承下来的方法),精简一下就是:编译看父类,运行看子类。这个就是Java中的动态绑定机制
。动态绑定的意思是方法调用的实际目标是在运行时确定的,而不是在编译时。正是因为动态绑定这种机制的存在,才会出现多态。
3.4 动态的有点和缺点
3.4.1 优点
- 提高代码的可维护性
当需求发生变化时我们只需要修改具体类中的具体方法实现,而不需要 修改其他地方。 - 提高代码的可扩展性
合理的设计可以让你的系统任意的增加模块而不需要修改原来的代码,只需要增加类和重写父类中的方法即可,这就可以很好地符合软件设计原则中的开闭原则
了。个人认为多态的运用是一门艺术,合理的运用可以让你设计出一个非常优雅的系统。所以在学习高级技术的同时也别忘了经常回头看一下我们梦开始的地方
呀。
3.4.2 缺点
- 无法使用子类中特用的方法
3.5 向上转型和向下转型
3.5.1 向上转型
向上或向下转型非常简单。向上转型就是将子类对象赋值给父类变量,这个过程是自动进行的不需要强转。
public class InheritanceTest {
public static void main(String[] args){
//向上转型
Parent parent = new Child();
}
}
class Parent {
}
class Child extends Parent {
public void show() {
}
}
3.5.5 向下转型
向下转型就是将一个父类对象赋值给一个子类变量,按道理来说这应该是不合理的,但是在某些情况下是可以实现的,但是需要强制转换。
public class InheritanceTest {
public static void main(String[] args){
//向下转型
Parent parent = new Child();
Child child = (Child) parent;
}
}
class Parent {
}
class Child extends Parent {
public void show() {
}
}
如上代码中的这种情况就是符合向下转型的情况,因为父类引用中存放的本来就是一个子类对象。但是如果是下面这种情况的话,运行时会报类型转换异常。
public class InheritanceTest {
public static void main(String[] args){
//向下转型
Parent parent = new Parent();
Child child = (Child) parent;
}
}
class Parent {
}
class Child extends Parent {
public void show() {
}
}
以上就是面向对象三大特性的内容,作者码字不易,如果您喜欢这篇文章的话给作者一个点赞+关注吧。您的关注就是作者最大的动力。