面向对象
面向对象(OOP)编程(Object-Oriented Programming,OOP)
介绍
面向对象编程(Object-Oriented Programming,OOP)是一种软件开发的方法论和编程范式,它将现实世界中的实体抽象为对象,通过对象之间的交互来实现程序的设计和开发。
在面向对象编程中,程序的核心思想是将系统看作是一组相互作用的对象集合。每个对象都具有特定的属性(数据)和行为(方法),对象之间通过消息传递进行交互,以实现系统的功能。
关键概念
- 类(Class):
- 类是对象的模板,它描述了对象的属性和行为。
- 类定义了对象的结构和行为规范,包括属性的声明和方法的定义。
- 通过实例化类,可以创建对象,并使用类定义的属性和方法。
- 对象(Object):
- 对象是类的实例,具有特定的属性和行为。
- 对象是内存中分配的实体,可以通过引用变量进行访问和操作。
- 每个对象都有自己的状态(属性值)和行为(方法)。
- 封装(Encapsulation):
- 封装是将数据和方法封装在一个单元(类)中,形成一个独立的功能单元。
- 封装提供了访问修饰符(如public、private、protected)来控制对数据的访问权限。
- 封装隐藏了对象的实现细节,提供了数据的安全性和代码的模块化。
- 继承(Inheritance):
- 继承是一种机制,允许一个类(子类/派生类)继承另一个类(父类/基类)的属性和方法。
- 子类可以扩展父类的功能,添加新的属性和方法,或者修改父类的行为。
- 继承提供了代码的重用性,减少了重复编写相似代码的工作量。
- 多态(Polymorphism):
- 多态是指同一类型的对象在不同的情境下表现出不同的行为。
- 多态性允许使用基类(父类)的引用变量来引用子类的对象。
- 多态性通过方法的重写(覆盖)和动态绑定实现,提高了代码的灵活性和可扩展性。
- 抽象类(Abstract Class):
- 抽象类是一种特殊的类,不能被实例化,只能被继承。
- 抽象类可以包含抽象方法和非抽象方法,用于定义一组相关类的共享行为。
- 子类必须实现抽象类中的抽象方法,除非子类也是抽象类。
- 接口(Interface):
- 接口定义了一组方法的规范,但没有具体的实现。
- 类可以实现一个或多个接口,强制类实现接口定义的方法。
- 接口提供了一种契约,用于实现类之间的解耦和代码的扩展性。
对象的内存解析
JVM内存结构划分
HotSpot Java虚拟机的架构图如下。其中我们主要关心的是运行时数据区部分(Runtime Data Area)。
堆(Heap) :此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配。
栈(Stack) :是指虚拟机栈。虚拟机栈用于存储局部变量等。局部变量表存放了编译期可知长度的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,是对象在堆内存的首地址)。 方法执行完,自动释放。
方法区(Method Area) :用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
说明:
- 堆:凡是new出来的结构(对象、数组)都放在堆空间中。
- 对象的属性存放在堆空间中。
- 创建一个类的多个对象(比如p1、p2),则每个对象都拥有当前类的一套"副本"(即属性)。当通过一个对象修改其属性时,不会影响其它对象此属性的值。
- 当声明一个新的变量使用现有的对象进行赋值时(比如p3 = p1),此时并没有在堆空间中创建新的对象。而是两个变量共同指向了堆空间中同一个对象。当通过一个对象修改属性时,会影响另外一个对象对此属性的调用。
- 方法区(Method Area):
方法区是用于存储类的结构信息、静态变量、常量等数据的区域。它是所有线程共享的内存区域,用于存储加载的类信息、字节码、符号引用等。在较早的JVM版本中,方法区也被称为永久代(Permanent Generation)。 - 堆(Heap):
堆是Java虚拟机中最大的一块内存区域,用于存储对象实例。所有通过new关键字创建的对象都存储在堆中。堆是被所有线程共享的,它在JVM启动时被创建,**用于动态分配和回收内存。**堆可以进一步划分为新生代(Young Generation)和老年代(Old Generation)。- 新生代:新生代是对象的初始分配区域,用于存储新创建的对象。新生代又分为Eden区和两个Survivor区(通常是一个From区和一个To区),对象首先在Eden区分配,经过垃圾回收后,存活的对象会被移动到Survivor区。在多次垃圾回收后,仍然存活的对象会被晋升到老年代。
- 老年代:老年代用于存储生命周期较长的对象。当对象在新生代经过多次垃圾回收后仍然存活,就会被晋升到老年代。
- 栈(Stack):
栈是线程私有的内存区域,用于存储线程执行方法时的局部变量、方法参数、方法调用和返回等信息。每个线程在创建时都会分配一个对应的栈,栈的大小可以在JVM启动时进行配置。栈采用先进后出(LIFO)的数据结构,每个方法在执行时都会创建一个栈帧,栈帧中存储着方法的局部变量和操作数栈等信息。 - 本地方法栈(Native Method Stack):
本地方法栈类似于栈,但用于执行本地方法(由本地语言(如C或C++)编写的方法)。它与栈的作用类似,用于存储本地方法的局部变量和操作。 - PC寄存器(Program Counter Register):
PC寄存器用于存储当前线程执行的字节码指令地址。每个线程都有自己的PC寄存器,用于记录线程当前执行的位置。 - 堆外内存(Off-Heap):
堆外内存是指在Java堆之外分配的内存,由于不受Java堆大小的限制,可以用于存储一些特殊需求的数据,如直接内存(Direct Memory)。
内存区域 | 描述 |
---|---|
方法区(Method Area) | 存储类的结构信息、静态变量、常量等数据的区域,也称为永久代(Permanent Generation)(旧版本)。 |
堆(Heap) | 存储对象实例的区域,包括新生代和老年代。 |
栈(Stack) | 线程的栈空间,存储线程执行方法时的局部变量、方法参数、方法调用和返回等信息。 |
本地方法栈(Native Method Stack) | 用于执行本地方法的栈空间,存储本地方法的局部变量和操作。 |
PC寄存器(Program Counter Register) | 存储当前线程执行的字节码指令地址。 |
堆外内存(Off-Heap) | 在Java堆之外分配的内存,用于特殊需求的数据存储,如直接内存(Direct Memory)。 |
相关面试题
对象名中存储的是什么呢?
答:对象地址。类、数组都是引用数据类型,引用数据类型的变量中存储的是对象的地址,或者说指向堆中对象的首地址。
对象
成员变量VS局部变量
需要注意的是,局部变量在使用之前必须先进行初始化,而成员变量有默认值,如果没有显式初始化,将具有其对应类型的默认值。
对比点 | 成员变量(实例变量) | 局部变量 |
---|---|---|
声明位置和方式 | 在类中,方法外 | 在方法体{}中或方法的形参列表、代码块中 |
存储位置 | 堆 | 栈 |
生命周期 | 随着对象的生命周期存在 | 随着方法调用的生命周期存在 |
作用域 | 可通过对象访问,本类中直接调用,其他类中使用"对象.实例变量" | 限定在其声明的方法体或代码块中,出了作用域就不能使用 |
修饰符 | public、protected、private、final、volatile、transient等 | final |
默认值 | 有默认值 | 没有默认值,需要手动初始化。形参由实参初始化。 |
权限修饰符
权限修饰符的可见性是按照包(package)来划分的,同一包中的类可以互相访问默认(default)和protected级别的成员,而private级别的成员仅对本类可见。public级别的成员对所有类可见,无论是否在同一包中。
以下是Java中权限修饰符对成员变量和方法的修饰的表格展示:
权限修饰符 | 类内部 | 同一包内 | 子类 | 其他包 |
---|---|---|---|---|
public | ✔ | ✔ | ✔ | ✔ |
protected | ✔ | ✔ | ✔ | ✘ |
默认(无修饰符) | ✔ | ✔ | ✘ | ✘ |
private | ✔ | ✘ | ✘ | ✘ |
需要注意的是:
- “✔” 表示可以访问,“✘” 表示不可访问。
- 类内部指的是同一个类中的其他成员或方法。
- 同一包内指的是在同一个包中的其他类。
- 子类指的是继承自该类的其他类。
- 其他包指的是不在同一个包中的其他类。
以下是对每个权限修饰符的说明:
- public:任何类都可以访问该成员变量或方法。
- protected:只有同一包中的类和继承该类的子类可以访问该成员变量或方法。
- 默认(无修饰符):只有同一包中的类可以访问该成员变量或方法。
- private:只有同一个类中的其他成员或方法可以访问该成员变量或方法。
绕过权限修饰符
权限修饰符是通过编译器和Java虚拟机(JVM)共同实现的。编译器在编译源代码时会根据权限修饰符的规则进行检查和限制,然后将相关信息写入编译后的字节码文件中。JVM在执行字节码时会根据字节码中的权限修饰符信息来进行访问控制。
在JVM执行字节码时,会使用访问控制规则来实现权限修饰符的功能。JVM会根据权限修饰符来判断是否允许访问某个类的字段或方法。如果访问违反了权限修饰符的规则,JVM会抛出相应的访问权限异常(如IllegalAccessError
)。
权限修饰符只提供了源代码级别的访问控制,而在运行时,通过反射等手段仍然可以绕过权限修饰符来访问和修改对象的字段和方法。
绕过方式 | 描述 |
---|---|
反射(Reflection) | 通过java.lang.reflect 包中的类和方法,在运行时获取和操作类的字段、方法和构造函数,绕过权限修饰符的限制。 |
反序列化(Deserialization) | 在对象被反序列化时,私有字段和方法的访问修饰符被忽略,可以绕过权限修饰符的限制访问和修改对象的状态。 |
内部类访问外部类的私有成员 | 内部类可以访问外部类的私有成员,包括私有字段和私有方法。通过在外部类的内部类中调用外部类的私有成员,可以绕过权限修饰符的限制。 |
继承关系 | 子类可以访问父类的受保护(protected)成员,即使子类和父类不在同一包中。这也可以看作是一种绕过权限修饰符的方式。 |
其他方式 | 还有其他一些特殊情况或技术可以绕过权限修饰符,例如使用本地方法(native methods)、使用特定的编译器选项或编译工具等。这些方式具体取决于环境和上下文。 |
静态修饰符(static)
需要注意的是,静态变量和静态方法属于类级别的,而不是实例级别的。静态变量在内存中只有一份拷贝,被所有的对象共享。静态方法可以通过类名直接调用,无需创建对象。
静态成员变量和方法的主要特点是它们与类的实例无关,可以直接通过类名访问,且对所有的对象都是共享的。
- 静态字段的生命周期与类的生命周期一致,即使没有创建类的实例,静态字段仍然存在。
- 静态字段在类加载时被初始化,存储在方法区(Method Area)中的静态数据区。
静态修饰符 | 成员变量(静态变量) | 方法(静态方法) |
---|---|---|
定义方式 | 在类中,方法外定义。 | 在类中定义,使用关键字static 修饰。 |
存储位置 | 存储在静态存储区,即方法区(类的静态区域)。 | 存储在静态存储区,即方法区(类的静态区域)。 |
初始化时机 | 类加载时进行初始化,仅初始化一次。 | 类加载时进行初始化,仅初始化一次。 |
初始化顺序 | 静态变量按照定义的顺序进行初始化。 | 静态方法可以随时调用,但在使用前需要确保类已加载和静态变量已初始化。 |
访问方式 | 可通过类名直接访问,也可以通过对象访问。 | 可通过类名直接调用,也可以通过对象调用。 |
对象依赖性 | 静态变量不依赖于特定的对象,对所有对象共享。 | 静态方法不依赖于特定的对象,不可直接访问实例变量和实例方法,只能访问静态变量和静态方法。 |
类变量和实例变量区别 | 所有对象共享相同的静态变量。 | 每个对象都有自己的实例变量,各个对象之间的实例变量相互独立。 |
生命周期 | **从类加载到类卸载期间存在。**静态字段的生命周期与类的生命周期一致,即使没有创建类的实例,静态字段仍然存在。 | **从类加载到类卸载期间存在。**静态字段的生命周期与类的生命周期一致,即使没有创建类的实例,静态字段仍然存在。 |
可见性 | 可以使用不同的访问修饰符(public、protected、default、private)限制访问。 | 可以使用不同的访问修饰符(public、protected、default、private)限制访问。 |
静态上下文 | 在静态上下文中,只能直接引用其他静态成员变量和静态方法。 | 在静态上下文中,只能直接引用其他静态成员变量和静态方法。 |
修改静态成员
使用静态修饰符的成员仍然可以通过一些方式被绕过或修改,**这可能导致不可预测的行为或安全问题。**下面是一些可能影响静态成员的情况:
情况 | 描述 |
---|---|
反射(Reflection) | 通过java.lang.reflect 包中的类和方法,在运行时获取和操作类的静态字段和静态方法,即使它们被声明为私有(private)或受保护(protected)。此操作可以绕过静态修饰符的限制。 |
类加载器(ClassLoader) | 通过自定义类加载器加载和修改类的字节码,包括静态字段和静态方法。这可以用于修改静态成员的值或替换静态方法的实现。 |
多线程访问(Multithreading) | 当多个线程同时访问静态成员时,需要考虑线程安全性。如果没有适当的同步机制,可能会导致竞态条件或数据不一致的问题。 |
其他情况(例如安全漏洞或编程错误) | 在某些情况下,可能会发现静态成员的安全漏洞或编程错误,导致不可预测的行为发生。这可能包括未经授权的修改、访问或共享静态成员的敏感数据等。请谨慎处理静态成员的使用。 |
代码块(block)
代码块类型 | 定义位置 | 执行时机 | 主要用途 |
---|---|---|---|
普通代码块 | 方法内部、构造方法内部、类内部(不在方法内) | 调用所在方法或创建对象时执行 | 限制变量的作用范围、执行一段特定的代码逻辑 |
静态代码块 | 类内部,使用static 关键字定义 | 类加载时执行,仅初始化一次 | 类的静态成员变量的初始化、执行一段需要全局初始化的代码逻辑 |
同步代码块 | 使用synchronized 关键字包围的代码块 | 线程获取锁时执行 | 控制多线程并发访问的同步性,避免竞态条件 |
实例初始化块 | 类内部,不带任何关键字直接定义 | 创建对象时执行,每次创建对象都会执行 | 初始化实例成员变量的共同代码部分,提供给多个构造方法共享的初始化逻辑 |
静态初始化块 | 类内部,使用static 关键字和{} 包围的代码块 | 类加载时执行,仅初始化一次 | 初始化静态成员变量的共同代码部分,提供给多个静态成员初始化的共享逻辑 |
代码块风险
潜在问题 | 描述 |
---|---|
变量作用域混淆 | 在代码块中声明的变量具有局部作用域,可能与外部作用域中的变量同名,导致变量作用域混淆和意外行为。 |
隐式对象创建 | 在代码块中创建对象时,需要注意对象的生命周期和资源管理,避免资源泄漏或内存泄漏的问题。 |
死锁风险 | 如果代码块涉及多个线程,需要注意同步和锁的使用,避免死锁的发生,确保程序的正常执行。 |
代码块嵌套过深 | 过多嵌套的代码块降低代码的可读性和可维护性,建议保持代码块简洁和扁平化的结构。 |
变量生命周期过长 | 代码块中声明的变量生命周期过长会占用过多的内存资源,及时释放不再需要的变量以避免内存消耗。 |
逻辑错误 | 在代码块中编写逻辑时,需要仔细考虑条件和循环的边界条件,以及代码块内部的执行顺序,确保程序的正确行为。 |
方法
可变参数
可变参数(Varargs) | 描述 |
---|---|
参数的顺序和类型 | 可变参数在参数列表中必须是最后一个参数,且每个方法或函数只能有一个可变参数。如果方法或函数有多个参数,可变参数之前的参数类型和顺序都必须明确指定。 |
与重载方法的关系 | 当方法或函数同时存在可变参数和重载(Overload)的情况时,编译器会根据参数的数量和类型选择最匹配的方法或函数进行调用。如果没有精确匹配的方法或函数,编译器会尝试进行自动类型转换或抛出编译错误。 |
与泛型的结合 | 可变参数可以与泛型一起使用。在定义可变参数时,可以指定泛型的类型。例如,可以声明一个可变参数为List,其中T是泛型类型参数。这样,在调用方法或函数时,可以传递不同类型的List参数给可变参数。 |
引用类型参数
方法传递引用类型参数时,复制的是参数的引用值(即内存地址),而不是实际对象本身。传递的引用值指向堆内存中的对象。因此,在方法内部,可以通过参数的引用值访问和修改对象的状态。但是,如果在方法内部重新分配引用或将引用指向新的对象,原始参数的引用值不会受到影响。
类型 | 示例 |
---|---|
自定义类 | Person , Car , Book |
标准库中的类型 | String , ArrayList , HashMap |
数组类型 | int[] , String[] , Person[] |
接口类型 | Runnable , Comparator , List |
泛型类型 | List<T> , Map<K, V> , Optional<T> |
重载(Overloading)和重写(Overriding)
特征 | 重载 (Overloading) | 重写 (Overriding) |
---|---|---|
定义 | 在同一个类中,使用相同的方法名,但参数列表不同。 | 子类中覆盖父类的方法,方法签名(名称、参数列表)必须相同。 |
发生位置 | 同一个类中的不同方法之间。 | 子类中重写父类的方法。 |
关联关系 | 方法名相同,参数列表不同。 | 子类和父类之间存在继承关系。 |
编译时多态性 | 是 | 是 |
运行时多态性 | 否 | 是 |
方法签名 | 方法名相同,参数列表不同。 | 方法名和参数列表完全相同。 |
返回类型 | 可以相同也可以不同。 | 必须相同。 |
访问修饰符 | 可以相同也可以不同。 | 可以相同也可以更宽松(子类可以扩大访问权限)。 |
异常抛出 | 可以相同也可以不同。 | 可以相同也可以更具体(子类可以抛出更少的异常)。 |
静态/实例方法 | 可以是静态方法或实例方法。 | 可以是实例方法(不能是静态方法)。 |
决定调用的方法 | 编译时根据方法的参数列表静态绑定。 | 运行时根据对象的实际类型动态绑定。 |
用途 | 提供相似功能但参数不同的方法。 | 扩展和修改从父类继承的方法的行为。 |
封装
概念
封装是面向对象编程的一个重要概念,**它指的是将数据和方法封装在类中,并通过访问权限修饰符来控制对这些数据和方法的访问。**通过封装,我们可以隐藏类的内部实现细节,只暴露必要的接口给外部使用。
细节
**封装的核心思想是使用访问权限修饰符(如private、public、protected)来改变变量和方法的引用范围。**使用private关键字修饰的变量和方法只能在类的内部访问和调用,外部代码无法直接访问和修改它们。如果外部代码需要访问和修改private变量,可以提供相应的公开的get和set方法,通过这些方法来间接操作private变量。
封装提供了以下优点:
- 提高了代码的安全性和可靠性,防止外部代码对内部数据进行不合法的操作。
- 简化了代码的使用,外部代码只需要调用公开的接口方法,而不需要了解类的内部实现细节。
- 可以隐藏内部实现细节,提供更好的抽象和封装,使代码更加模块化和可维护。
高内聚,低耦合
封装在软件设计中通常与高内聚和低耦合的原则密切相关
- 高内聚:高内聚是指将具有相关功能和责任的元素组织在一起,形成一个独立的模块或类。高内聚的设计使得模块内部的功能紧密相关,模块的职责清晰,且各个元素之间的交互性强。封装有助于实现高内聚,因为封装将相关的数据和方法封装在同一个对象中,使得对象具有清晰的职责和功能。
- 低耦合:低耦合是指模块或类之间的依赖关系较弱,彼此之间相对独立。低耦合的设计使得系统的各个模块可以独立开发、测试和维护,提高了系统的灵活性和可扩展性。封装有助于实现低耦合,因为封装将对象的内部实现细节隐藏起来,其他模块只需要通过对象的公共接口进行交互,而不需要了解对象的具体实现。
构造器
构造器(Constructor)是一种特殊类型的方法,用于创建和初始化对象。
凡是类,都有构造器
在Java中,构造器与类同名,没有返回类型,并且在使用new
关键字实例化对象时被调用。构造器在对象创建时被自动调用,用于执行对象的初始化操作。
以下是构造器的关键细节:
-
命名与定义:构造器的名称必须与类名完全相同。
- 它没有返回类型,包括
void
,也不使用static
关键字进行修饰。构造器定义在类的内部,可以有多个构造器,通过参数列表的不同来区分。
- 它没有返回类型,包括
-
对象实例化:使用
new
关键字调用构造器来创建对象实例。- 构造器会为对象分配内存空间,并初始化对象的成员变量。
-
初始化操作:构造器用于执行对象的初始化操作。
- 可以在构造器中对对象的成员变量进行赋值、调用其他方法、处理异常等操作。构造器可以根据需要接受参数,用于传递初始化数据。
-
默认构造器:如果没有显式定义构造器,Java会自动提供一个无参的默认构造器。
- 默认构造器会执行一些默认的初始化操作,如成员变量的默认值赋值。
-
重载构造器:可以定义多个构造器,它们具有相同的名称但参数列表不同,被称为构造器的重载。
- 重载构造器可以根据不同的参数组合来满足对象的不同初始化需求。
相关面试题
构造器涉及到一些难题,以下是一些常见的问题:
-
构造器和普通方法的区别:
-
主要区别在于构造器用于对象的初始化,没有返回类型,而普通方法用于对象的操作和行为,有返回类型。
-
命名和调用方式:构造器的名称必须与类名完全相同,而普通方法可以有任意合法的方法名。构造器在使用
new
关键字实例化对象时自动调用,而普通方法需要通过对象引用调用。 -
调用方式和时机:构造器在对象创建时被自动调用,用于完成对象的初始化。普通方法可以在对象创建后的任意时刻被调用,用于执行特定的功能或操作。
-
-
构造器的重载和继承:
- 构造器的重载和继承的关系。子类的构造器可以调用父类的构造器,使用
super
关键字,同时也可以定义自己的构造器来满足特定需求。
- 构造器的重载和继承的关系。子类的构造器可以调用父类的构造器,使用
-
默认构造器的作用和限制:
-
默认构造器用于提供一个无参的初始化方式,但如果显式定义了其他构造器,系统不会再提供默认构造器。
-
默认构造器只能执行默认的初始化操作,无法实现自定义的初始化逻辑。
-
-
构造器的异常处理:
- 构造器可以抛出异常,但在对象创建过程中,如果构造器抛出异常,对象将无法成功创建。
-
构造器链和
this()
关键字:- 构造器链是指一个构造器调用另一个构造器,用于避免代码重复。
this()
关键字用于在构造器内部调用同一类的其他构造器。
- 构造器链是指一个构造器调用另一个构造器,用于避免代码重复。
继承
关键字this和super
this:引用当前对象的实例或调用当前对象的构造方法。
- 使用 this 可以消除属性名与参数名/局部变量名相同导致的歧义。
super:引用父类的成员变量、成员方法或调用父类的构造方法
- 当使用
super
关键字访问成员时,不仅限于直接父类(基类,超类);如果在爷爷类和当前类中存在同名成员,也可以使用super
访问爷爷类的成员。此外,如果多个基类中都有同名的成员,使用super
访问时会遵循就近原则。- 对于 A->B->C 的继承关系,如果在 B 类和 C 类中存在同名成员,使用
super
关键字可以访问爷爷类 A 的同名成员。通过super
关键字,可以在 C 类中访问 A 类的成员。 - 涉及子类创建实例过程中:父类的构造方法会按照继承链从上到下的顺序执行,确保父类的初始化工作得以完成。
- 对于 A->B->C 的继承关系,如果在 B 类和 C 类中存在同名成员,使用
特点 | this | super |
---|---|---|
用途 | 引用当前对象的实例或调用当前对象的构造方法 | 引用父类的成员变量、成员方法或调用父类的构造方法 |
表示方式 | this 关键字 | super 关键字 |
实例方法中的使用 | 可以使用this 关键字引用当前对象的实例 | 可以使用super 关键字引用父类的成员变量、成员方法或调用父类的构造方法 |
构造方法中的使用 | 可以使用this 关键字调用同类中的其他构造方法 | 可以使用super 关键字调用父类的构造方法 |
参数传递 | 可以使用this 关键字将当前对象的引用作为参数传递给其他方法 | 可以使用super 关键字将当前对象的引用作为参数传递给父类的构造方法 |
方法重写时的使用 | 可以使用this 关键字调用当前类中的被重写方法 | 可以使用super 关键字调用父类中的被重写方法 |
字段隐藏 | 可以使用this 关键字引用当前类中的字段 | 可以使用super 关键字引用父类中被子类字段隐藏的同名字段 |
内部类中的使用 | 可以使用this 关键字引用当前内部类的实例 | 可以使用super 关键字引用外部类中的成员变量、成员方法或调用外部类的构造方法 |
继承概念和好处
概念
类B,称为子类、派生类(derived class)、SubClass
类A,称为父类、超类、基类(base class)、SuperClass
- 基类(Base Class):基类是派生类的父类,它是被派生类继承的类。基类定义了一组共享的属性和方法。
- 派生类(Derived Class):派生类是从基类派生出来的新类,它继承了基类的属性和方法,并可以添加自己的属性和方法。
- 继承(Inheritance):继承是派生类获取基类属性和方法的机制。通过继承,派生类可以重用基类的代码,并在此基础上进行修改和扩展。
- 单继承(Single Inheritance):单继承是指派生类只能继承一个基类的属性和方法。大多数面向对象编程语言支持单继承。
- 多继承(Multiple Inheritance):多继承是指派生类可以同时继承多个基类的属性和方法。一些面向对象编程语言(如C++)支持多继承。
- 方法重写(Method Overriding):派生类可以对继承自基类的方法进行重写,即在派生类中重新定义方法的实现。通过方法重写,派生类可以修改继承方法的行为,以适应自己的需求。
- super关键字:在派生类中,super关键字表示对基类的引用。它可以用来调用基类的构造函数、访问基类的属性和方法,以及在方法重写时调用基类的实现。
通过继承,子类可以获得父类的属性和方法,并可以在子类中添加新的属性和方法。
继承要求子类和父类之间满足"是一个"(is-a)的关系,即子类可以被看作是父类的一种特殊类型。通过继承,子类可以重用父类的代码,并且可以通过覆盖(重写)父类的方法来实现自己特有的行为。
- 继承层级:
- Java支持单继承,一个子类只能继承自一个父类。
- 一个父类可以有多个子类,形成继承层级。
- 访问权限:
- 子类继承父类后,可以访问父类中的公共(public)和受保护(protected)成员。
- 私有(private)成员不会被继承,子类无法直接访问。
- 重写方法:
- 子类可以重写(覆盖)父类的方法,以实现自己的逻辑。
- 重写方法必须具有相同的方法名称、参数列表和返回类型。
- 子类中的重写方法可以使用
@Override
注解进行标记。
- 调用父类构造方法:
- 子类的构造方法可以使用
super()
关键字来调用父类的构造方法。 - 如果没有显式调用父类的构造方法,系统会默认调用父类的无参构造方法。
- 子类的构造方法可以使用
- 继承关系的特殊类:
Object
类是Java中的根类,所有类都直接或间接继承自Object
类。final
关键字可以用于类或方法,表示禁止继承或重写。
继承的好处
-
继承的出现减少了代码冗余,提高了代码的复用性。
-
继承的出现,更有利于功能的扩展。
-
继承的出现让类与类之间产生了
is-a
的关系,为多态的使用提供了前提。- 继承描述事物之间的所属关系,这种关系是:
is-a
的关系。可见,父类更通用、更一般,子类更具体。
- 继承描述事物之间的所属关系,这种关系是:
继承的弊端
- 耦合性增加:继承会增加类之间的耦合性。子类与父类之间的紧密联系意味着对父类的修改可能会影响到所有的子类。因此,继承应谨慎使用,避免过度依赖继承关系。
- 继承层次复杂化:随着继承层次的增加,继承关系变得更加复杂。当继承关系过于深入或过于复杂时,可能会导致代码难以理解、维护和扩展。
- 限制了类的设计和重构:继承是一种静态的关系,它在编译时就确定了类之间的关系。这样一来,如果需要在子类中改变继承自父类的行为,可能需要修改父类的代码,这可能会破坏现有的代码结构。
运算符 instanceof
instanceof
运算符在实际编程中常用于类型检查和对象引用的安全转换。
-
作用:检查一个对象是否是某个类或其子类的实例
-
它通过使用对象的元数据信息来进行类型检查,会遍历对象的元数据信息,沿着继承链向上查找,比较对象的元数据信息与目标类的元数据信息是否匹配。
-
它可以帮助您在运行时确定对象的类型,以便进行相应的操作或处理。
-
需要注意的是,
instanceof
运算符只能用于引用类型的对象,不能用于基本数据类型。
语法形式为:
object instanceof Class
多态
概念
指的是同一个方法或操作在不同的对象上可以表现出不同的行为。
核心思想
核心思想是通过使用抽象和动态绑定来实现代码的灵活性和扩展性。
多态种类
多态形式 | 描述 |
---|---|
继承多态(子类多态)(对象多态) | 通过继承关系实现的多态性。父类的引用可以指向子类的对象,通过父类的引用调用相同的方法时,具体执行的是子类中的方法。 |
接口多态 | 多个类可以实现同一个接口,通过接口类型的引用调用方法时,根据实际对象的类型来动态调用对应的方法。(使用接口创建对象实例) |
编译时多态(方法重载) | 在编译时根据方法的参数类型或个数确定调用哪个方法。编译器会根据方法的签名进行静态绑定,选择合适的方法进行调用。 |
运行时多态(动态绑定) | 在运行时根据对象的实际类型确定调用哪个方法。通过继承和方法重写的机制,可以在运行时根据对象的实际类型来调用相应的方法,实现动态绑定。(父类类型的引用来指向子类的对象)实际执行的是子类中重写的方法,而不是父类中的方法。 |
参数多态(泛型多态) | 通过泛型编程实现的多态性。使用泛型可以编写适用于多种类型的代码,提高代码的重用性和灵活性。参数多态允许在编译时不指定具体的类型,而在使用时根据实际的类型进行类型检查和类型推断。 |
运算符多态(运算符重载) | 通过定义类的成员函数或全局函数来重新定义运算符的行为。不同的操作数类型可以触发不同的运算符重载函数,实现多态性。 |
异常处理多态 | 不同类型的异常可以被捕获和处理,提供不同的错误处理逻辑。 |
构造器多态 | 通过使用不同的构造器来创建对象,根据构造器的参数类型和个数来确定具体的构造逻辑。 |
数组多态 | 通过数组的多态性,可以存储不同类型的对象,并通过数组的引用来访问不同类型的元素。 |
匿名内部类多态 | 通过使用匿名内部类,可以在创建对象的同时定义和实现一个接口或抽象类的方法。这种方式可以在需要临时实现某个接口或抽象类的场景下,实现多态性。 |
方法参数多态和方法返回类型多态 | 方法的参数类型可以使用父类类型,从而接受不同子类的对象作为参数,实现多态性。方法内部可以根据实际传入的对象类型来执行不同的逻辑。**方法的返回类型可以是父类类型,但实际返回的是子类对象。**这样可以使方法返回不同类型的对象,实现多态性。 |
编译时多态(Compile-time Polymorphism)和运行时多态(Runtime Polymorphism)
编译时,看左边;运行时,看右边
编译时多态(Compile-time Polymorphism)和运行时多态(Runtime Polymorphism)是Java中的两种不同类型的多态性。
-
编译时多态(也称为静态多态):
- 编译时多态是指在编译时根据方法的声明信息来确定要调用的具体方法。它是通过方法的重载(Overloading)实现的。
- 在编译时,编译器根据方法的参数列表和方法签名来选择合适的方法,以确定要调用的方法。
- 编译时多态发生在编译阶段,因此也称为静态多态。编译时多态是通过静态绑定(Static Binding)实现的。
-
运行时多态(也称为动态多态):
- 运行时多态是指在运行时根据对象的实际类型来确定要调用的具体方法。它是通过方法的重写(Overriding)实现的。
- 在运行时,根据对象的实际类型,虚拟机(JVM)动态地绑定方法调用,以调用正确的方法。运行时多态发生在运行阶段,因此也称为动态多态。(当父类创建子类对象,并且调用子类的重写方法时)
- 运行时多态是通过动态绑定(Dynamic Binding)实现的。
- 运行时多态使得程序能够根据对象的实际类型来执行相应的方法,实现了更灵活和动态的行为。
特征 | 编译时多态 (Compile-time Polymorphism) | 运行时多态 (Runtime Polymorphism) |
---|---|---|
定义 | 在编译时根据方法的声明信息确定要调用的具体方法。 | 在运行时根据对象的实际类型确定要调用的具体方法。 |
绑定时机 | 编译阶段 | 运行阶段 |
方法调用决定 | 根据方法的参数列表静态绑定。 | 根据对象的实际类型动态绑定。 |
方法重载 | 是 | 是 |
方法重写 | 不涉及方法重写,仅根据方法签名和参数列表选择方法。 | 通过子类重写父类的方法,调用时根据对象的实际类型选择方法。 |
发生时间 | 编译时 | 运行时 |
实现机制 | 静态绑定 | 动态绑定 |
关键特征 | 方法重载 | 方法重写 |
适用场景 | 在编译时根据方法的参数列表选择适当的方法。 | 在运行时根据对象的实际类型选择适当的方法。 |
示例 | 方法重载(多个同名方法,参数不同) | 方法重写(子类重写父类的方法) |
继承多态(也称为子类多态或对象多态)
概念
它允许以父类类型的引用来引用子类对象,并根据实际运行时的对象类型来调用相应的方法。
细节
编译时,看左边;运行时,看右边。
- “看左边”:
- 表示使用父类的引用,因此只能访问父类中定义的方法和属性,而无法访问子类特有的方法和属性。
- “看右边”:
- 表示实际运行的是子类的对象,因此可以调用子类重写(覆盖)父类的方法,即运行时会根据对象的实际类型来确定调用哪个方法。
注意使用前提:
- 类的继承关系:存在父类和子类之间的继承关系。
- 方法的重写:子类重写(覆盖)了父类的方法。
OCP开闭原则(Open-Closed Principle)
**使用父类做方法的形参,是多态使用最多的场合。**即使增加了新的子类,方法也无需改变,提高了扩展性,符合开闭原则。
开闭原则:
- 对扩展开放,对修改关闭:通过使用父类作为方法参数类型,可以在不修改现有方法的情况下,引入新的子类作为实参,实现功能的扩展。如果需要添加新的子类,只需要编写新的子类并继承父类,而无需修改现有的方法。
- 提高扩展性和灵活性:使用父类作为方法参数类型可以处理不同类型的对象,这提高了代码的扩展性和灵活性。新的子类可以通过继承父类并实现自己的特定逻辑,而无需改变现有的方法和调用方式。
- 对扩展开放,对修改关闭
- 通俗解释:软件系统中的各种组件,如模块(Modules)、类(Classes)以及功能(Functions)等,应该在不修改现有代码的基础上,引入新功能
虚方法调用(Virtual Method Invocation)
概念
它允许通过父类引用调用子类对象的方法,实现动态绑定(Dynamic Binding)和运行时多态性(Runtime Polymorphism)。
当使用父类引用变量引用子类对象时,如果通过该引用变量调用的方法是虚方法,并且子类重写了该方法,那么在运行时会根据子类的实际类型来确定要执行的方法。
底层实现
- 虚表(Virtual Table):每个包含虚方法的类都会有一个虚表,也称为虚函数表。虚表是一个存储了该类所有虚方法的函数指针数组。每个对象都会包含一个指向其对应类虚表的指针。
- 对象布局:每个对象在内存中的布局通常包括一个指向其类虚表的指针。这个指针被称为虚表指针(vptr),它指向对象所属类的虚表。
- 方法调用:当通过父类引用变量调用一个虚方法时,首先会根据引用变量的静态类型(即声明时的类型)找到对应的虚表。然后,根据虚表中的函数指针找到要调用的方法实现。
- 动态绑定:在运行时,通过虚表指针(vptr)获取到对象所属类的虚表,并根据虚表中的函数指针来调用相应的方法实现。这个过程是动态的,因为在运行时才能确定对象的实际类型,从而确定要调用的方法。
通过虚表和虚表指针的机制,虚方法调用实现了多态性和动态绑定。它使得程序能够根据对象的实际类型来确定要执行的方法,提供了灵活性和可扩展性。
拓展:静态链接和动态链接
-
静态链接(或早期绑定)
- 链接过程在编译期间进行。
- 编译时将程序中使用到的函数和库的目标代码直接嵌入到最终的可执行文件中。
- 程序执行时,操作系统加载整个可执行文件到内存中,并直接执行其中的代码。
- 结果是生成一个独立的、自包含的可执行文件,不需要依赖外部的库文件。
- 静态链接会导致代码冗余和文件体积增大。
- 适用于调用静态方法、私有方法、final方法、父类构造器以及本类的重载构造器等情况。
动态链接(或晚期绑定)
- 链接过程在运行时进行。
- 程序运行时,通过在操作系统中查找并加载外部库文件,并将目标代码与已加载的库函数进行链接。
- 结果是生成一个依赖于外部库的可执行文件,可执行文件中只包含对外部库函数的引用。
- 程序执行时,操作系统根据引用信息加载相应的库文件,并将其与程序进行链接。
- 减少了代码冗余和文件体积,允许在运行时替换更新库文件,提供了更灵活和可维护的方式。
- 适用于调用重写的方法(针对父类)和实现的方法(针对接口)等情况。
向上转型(Upcasting)和**向下转型**(Downcasting)
- 向上转型(对象多态):当左边的变量的类型(父类) > 右边对象/变量的类型(子类),我们就称为向上转型
- 此时,编译时按照左边变量的类型处理,就只能调用父类中有的变量和方法,不能调用子类特有的变量和方法了
- 但是,运行时,仍然是对象本身的类型,所以执行的方法是子类重写的方法体。
- 此时,一定是安全的,而且也是自动完成的
- 本质:父类的引用指向了子类的对象。
- 语法:父类类型引用名 = new 子类类型();
- 特点:编译类型看左边,运行类型看右边。可以调用父类中的所有成员(需遵守访问权限),不能调用子类中特有成员。最终运行效果看子类的具体实现!
- 向下转型:当左边的变量的类型(子类)<右边对象/变量的编译时类型(父类),我们就称为向下转型
- 此时,编译时按照左边变量的类型处理,就可以调用子类特有的变量和方法了
- 但是,运行时,仍然是对象本身的类型
- 在向下转型时,需要先进行类型判断,以确保类型转换的安全性。如果将一个对象转型为其子类的类型,但该对象的实际类型不是该子类或其子类的类型,则会抛出 ClassCastException 异常。
- 语法:子类类型引用名 = (子类类型) 父类类型引用名;
- 特点:只能强制转换父类的引用,不能强制转换父类的对象,要求父类的引用必须指向的是当前目标类型的对象。
- **向下转型可以访问子类特有的属性和方法,即子类的所有属性和方法。**但是需要注意类型转换的安全性和正确性。