目录
- 前言
- 基础
- 继承
- 作用
- 理论分析
- 父类属性的抽取
- 构造函数
- 调用父类构造函数会不会创建一个父类的对象?
- 生命周期角度
- 谁的属性用谁的构造函数初始化
- 示例解析
- 代码
- 代码调试展示
- 构造函数初始化成员变量
- 总结
前言
在Java中,继承是一项至关重要的特性,它允许创建具有层次结构的类,从而更有效地组织和管理代码。通过继承,子类可以获取父类的属性和方法,实现代码重用和扩展。
本文主要想从属性和构造函数的角度分析Java的继承机制。
基础
继承
先说下继承的内容:
继承定义了类如何相互关联,共享特性。继承的工作方式是定义父类和子类,或叫做基类和派生类,其中子类继承了父类的所有特性。子类不但继承了父类的所有特性,还可以定义新的特性。
如果子类继承于父类
第一、子类拥有父类非private的属性和功能
第二、子类具有自己的属性和功能,即子类可以扩展父类没有的属性和功能
第三、子类还可以以自己的方式实现父类的功能(方法重写)。
对象的继承代表了一种is-a的关系,如果两个对象A和B,可以描述为‘B是A’,则表明B可以继承A。‘猫是哺乳动物’,就说明了猫与哺乳动物之间继承与被继承的关系。实际上,继承者还可以理解为是对被继承者的特殊化,因为它除了具备被继承者的特性外,还具备自己独有的个性。
作用
本文主要讲解父类属性的抽取和构造函数的作用,所以先介绍一下它们:
1、父类属性的作用:
父类属性是指在父类中定义的属性,它们可以被所有子类共享和继承。通过抽取公共属性到父类中,可以减少子类中的重复代码,提高代码的可维护性和可读性。
2、父类构造函数的作用:
父类构造函数用于初始化父类中的属性。当创建子类对象时,父类的构造函数会被自动调用,以确保父类属性被正确初始化。在子类构造函数中,可以使用super关键字来显式调用父类的构造函数。如果子类构造函数中没有显式调用父类构造函数,则会自动调用父类的无参构造函数(如果存在的话)。
理论分析
父类属性的抽取
当大多数子类都拥有某些属性,而只有少数子类没有这些属性时,是否应该将这些属性抽取到父类中?
这是一个设计决策的问题。可以从以下几方面去考虑
1. 代码重用和一致性:
将这些属性抽取到父类中可以实现代码重用,因为所有(或大多数)子类都将继承这些属性。这也有助于保持一致性,因为所有相关的子类都将以相同的方式访问和修改这些属性。
2. 类的复杂性和可维护性:
如果父类包含了许多子类并不需要的属性,它可能会变得过于复杂,这可能会增加维护和理解的难度。然而,如果大多数子类都需要这些属性,并且它们对于理解类的行为很重要,那么将它们放在父类中可能会使类结构更清晰。
3. 灵活性和可扩展性:
将属性放在父类中可能会限制灵活性,因为所有子类都将继承这些属性,即使某些子类并不需要它们。如果未来需要添加新的子类,并且这些子类不需要这些属性,那么将它们放在父类中可能会增加不必要的复杂性。另一方面,如果未来需要为大多数子类添加新的属性,并且这些属性与现有属性相关,那么将它们放在父类中可能会更容易实现。
4. 业务需求的稳定性:
如果业务需求相对稳定,并且大多数子类都将长期需要这些属性,那么将它们放在父类中可能是一个好选择。如果业务需求经常变化,或者未来可能会有更多的子类不需要这些属性,那么将它们单独定义在需要它们的子类中可能更灵活。
5. 接口和组合的使用:
作为替代方案,可以考虑使用接口来定义这些属性,并通过组合而不是继承来将这些属性添加到需要的子类中。这可以提供更大的灵活性,因为子类可以选择实现哪些接口,并且可以在不修改父类的情况下添加或删除属性。
也就是说这不是写死的内容,是一个可以动态调整的方案,如果为了满足大多数(比如1000个子类里有999个都有相同的属性)可以向上抽,但要知道为此付出的代价是什么,抽取到父类后,即使少数不需要这些属性的子类也会继承这些属性,导致一定的内存开销。这种开销在大多数情况下是可以接受的,特别是当这些属性不是非常大或复杂的数据结构时。如果不抽取到父类,而是在每个需要的子类中分别定义这些属性,修改时可能只需要修改特定的子类,但会导致代码重复和难以维护。也就是需要去考虑到底是复用的代价高还是不复用未来需要修改的时候直接去改付出的代价更高。
是否将这些属性抽取到父类并没有一个绝对的答案,而是需要根据具体的应用场景、性能要求、代码复用与可维护性需求等多个因素进行权衡和决策。
构造函数
在Java中,当创建一个子类对象时,首先需要调用父类的构造函数来初始化从父类继承来的部分。这是因为子类继承了父类的属性和方法,而这些属性和方法(特别是非静态的实例属性和方法)是依赖于对象的实例来存在的。因此,在子类对象被创建之前,必须先确保其父类部分也被正确初始化。
调用父类构造函数会不会创建一个父类的对象?
调用父类的构造函数主要是用于初始化子类对象中父类部分的属性和方法。对象的创建是通过构造函数来完成的,但构造函数本身并不等同于对象。构造函数是一个特殊的方法,用于在对象创建时初始化对象的状态。
当创建一个子类对象时,以下步骤会发生:
- 分配内存空间给新的子类对象。
- 调用父类的构造函数来初始化子类对象中父类部分的属性和方法。这一步是隐式进行的,除非显式地在子类的构造函数中使用了super关键字来调用父类的另一个构造函数
- 执行子类构造函数中的其余代码,以初始化子类特有的属性和方法。
重要的是要理解,尽管子类对象在内存中是独立的,但它包含了从父类继承来的属性和方法。这些属性和方法是子类对象的一部分,但它们是通过父类的构造函数来初始化的。
即使子类没有显式地调用父类的构造函数(通过super),Java编译器也会自动插入一个对父类无参构造函数的调用(如果父类有一个可访问的无参构造函数的话)。如果父类没有无参的构造函数,或者无参构造函数不是公开的,那么子类必须显式地调用一个存在的、可访问的父类构造函数,否则编译器会报错。
总结来说:继承允许一个类(子类)获得另一个类(父类)的属性和方法。但这并不意味着在内存中会为每个子类对象都创建一个对应的父类对象。相反,子类对象在其内部结构中包含了从父类继承的属性和方法,这些属性和方法是通过子类对象的引用来访问的。
当创建一个子类对象时,Java虚拟机会为该对象分配内存,并根据子类的定义(包括从父类继承的部分)来初始化该对象的内部状态。这个过程中,父类的构造函数被调用以确保从父类继承的属性和方法被正确初始化。但是,这个初始化过程是在子类对象的上下文中进行的,而不是在独立的父类对象上进行的。
生命周期角度
上面的问题如果结合生命周期来说
当创建一个子类对象时,Java虚拟机会加载并链接相关的类(包括父类和子类),以确保它们准备好在运行时被使用(类加载过程)。这个过程中,父类的.class
文件会被读取,并且父类中定义的属性和方法会被加入到类的内部表示中(通常称为类的元数据或类对象)。然后,当子类对象被创建时,它会包含指向这些从父类继承的属性和方法的引用。
这些引用允许子类对象在需要时调用父类的方法或访问父类的属性。实际上,当子类对象调用一个从父类继承的方法时,Java虚拟机会使用动态绑定(也称为运行时多态性)来找到并调用正确的父类方法实现。
也就是说子类对象“共享”父类的属性和方法,这里共享主要是指子类对象在运行时能够访问和使用这些从父类继承的属性和方法,而这些属性和方法的定义是存储在父类的.class
文件中的。这种“共享”是通过Java的类加载机制、运行时环境和动态绑定来实现的。
在对象创建时,内存空间被分配,但对象还没有被完全初始化。对象初始化是通过调用构造函数来完成的,这包括调用父类的构造函数来初始化从父类继承的部分。
父类生命周期:
创建父类对象 ---- 初始化父类对象 ---- 使用父类对象 ---- 回收父类对象
子类生命周期:
创建子类对象 ---- (调用父类构造函数初始化从父类继承的部分) ---- 初始化子类特有的部分 ---- 使用子类对象 ---- 回收子类对象
子类对象的创建和初始化过程涉及到了对父类构造函数的调用,但这并不表示创建了一个独立的父类对象。这个过程是在为子类对象设置其从父类继承的属性和方法。
谁的属性用谁的构造函数初始化
父类属性的初始化:父类中定义的属性应该由父类的构造函数来初始化。当创建子类对象时,如果子类构造函数没有显式调用父类的其他构造函数(通过super()),那么它会自动调用父类的无参构造函数(如果存在)。这样,父类构造函数就会执行,从而初始化父类定义的属性。
子类属性的初始化:子类中可以定义自己的属性,这些属性应该由子类的构造函数来初始化。子类构造函数在执行时,可以首先调用父类的构造函数(通过super()),以确保父类部分被正确初始化。然后,子类构造函数可以继续初始化子类自己定义的属性。
继承与属性初始化:当子类继承父类时,子类对象将包含父类定义的属性(除非它们是私有的,在那种情况下,子类无法直接访问它们,但它们仍然存在于子类对象中)。然而,这些父类属性的初始化是由父类的构造函数来完成的,而不是子类的构造函数。子类构造函数只负责初始化子类自己定义的属性,并通过调用父类构造函数来确保父类部分的正确初始化。
示例解析
代码
//父类
public abstract class Parent {//所有子类共有的属性protected String commonProperty;//构造方法public Parent(String commonProperty) {this.commonProperty = commonProperty;}//大多数子类共有的属性,一部分子类没有protected int sharedByMostProperty;public void setSharedByMostProperty(int sharedByMostProperty){this.sharedByMostProperty=sharedByMostProperty;}public int getSharedByMostProperty(){return sharedByMostProperty;}//抽象方法abstract void doSomething();//普通方法public void showProperties(){System.out.println("所有子类共有的属性"+commonProperty);System.out.println("大部分子类共有的属性"+sharedByMostProperty);}}
//子类A有所有子类的属性,也拥有大部分子类都有的属性
public class ChildA extends Parent{//子类A拥有所有子类的属性,也拥有大部分子类都有的属性public ChildA(String commonProperty,int sharedByMostProperty){super(commonProperty); 可以通过父类的setter设置,也可以直接在这里赋值this.sharedByMostProperty=sharedByMostProperty;}@Overridevoid doSomething() {System.out.println("子类A实现抽象方法");}}
//子类B
public class ChildB extends Parent{// 子类B特有的属性private double uniqueToChildB;
//给父类共有属性赋值,和自己独有属性赋值public ChildB(String commonProperty,double uniqueToChildB){super(commonProperty);this.uniqueToChildB=uniqueToChildB;}@Overridevoid doSomething() {System.out.println("子类B实现抽象方法");}//子类独有的方法public void doSomethingElse(){System.out.println("子类独有的方法");/*System.out.println("从父类继承的大部分子类共有的属性: " + this.sharedByMostProperty);*/}
}
//客户端
public class Client {public static void main(String[] args) throws InterruptedException {//创建子类A的实例Parent childA=new ChildA("Common Value", 42);//childAchildA.doSomething();childA.showProperties();//创建子类B的实例,调用子类独有方法,必须创建子类型变量ChildB childB=new ChildB("Common Value", 3.14);childB.doSomething();//ChildB 的实例仍然有一个 sharedByMostProperty 属性,只是它的值将是默认初始值childB.showProperties();childB.doSomethingElse();}
}
这个例子我想说明的问题是:
当创建一个子类对象时,该对象内部已经包含了父类部分的所有属性和方法(除非被子类覆盖或隐藏)。这意味着子类对象可以直接调用这些继承自父类的方法,而无需创建额外的父类对象。
当子类调用从父类继承下来的方法(没有重写),执行的是父类中的方法实现。这个过程是通过子类对象内部的引用(或称为“指针”)来完成的,它指向父类方法的具体实现。子类调用继承自父类的方法时,实际上是在操作子类对象本身,而不是创建一个新的父类对象。子类对象只是借用了父类的方法来实现某些功能,是不会实例化父类的。
这一点是面向对象编程中继承机制的核心之一,它允许代码的重用和扩展,同时避免了不必要的对象创建和内存开销。
##字节码展示
在上面的字节码文件中可以看到
在子类ChildB的构造函数(Ljava/lang/String;D)V中,有一条
INVOKESPECIAL org/example/test/sharedByMostProperty/Parent. (Ljava/lang/String;)V指令。
这条指令表示ChildB的构造函数在初始化自己之前,会先调用父类Parent的构造函数来初始化父类部分。
传递给父类构造函数的参数(在这里是一个String类型的参数)表明ChildB至少有一个与父类构造函数相对应的属性或参数,这通常是父类中定义的属性。
代码调试展示
当子类调用从父类继承下来的方法(没有重写),执行的是父类中的方法实现。这里也显示并没有创建父类对象
客户端代码调试,只有子类对象,没有父类的,即便调用的是从父类继承下来的方法(未重写)
构造函数初始化成员变量
当创建一个对象时,Java虚拟机会为对象的实例变量分配内存空间,并根据变量的类型自动为它们赋予初始值。这些初始值是在对象内存空间分配时自动设置的,与构造函数无关。
接下来,父类构造函数被调用,这一步是确保父类部分被正确初始化的关键。
如果构造函数中为某个属性赋值了,那么这个赋值操作会覆盖该属性在内存分配时自动赋予的初始值。换句话说,构造函数中的赋值是先有了初值(由JVM自动赋予),然后又被构造函数中的代码赋予了新的、理想的值。
(子类将参数值通过父类构造函数传递给父类)
上面的过程总结:
构造函数调用之前,实例变量已经被JVM赋予了初值。
构造函数中的赋值操作会覆盖这些初值。
如果构造函数中没有为某个属性赋值,则该属性保留其初值。
总结
本文主要想从属性和构造函数的角度去解析继承关系中子类与父类的关系。希望本文能为读者在Java面向对象编程的道路上提供一点点帮助,也请大家不吝赐教,分享您的见解与经验,共同学习进步。