《JAVA核心技术 卷I》 第五章 - 继承

第五章-继承

1. 继承

1.1 继承的定义和特性

  • 继承的基本思想是,可以基于已有的类创建新的类。继承已存在的类就是复用(继承)这些类的方法,而且可以增加一些新的方法和字段,使新类能够适应新的情况

  • 关键字extends表明正在构造的新类派生于一个已存在的类。这个已存在的类称为超类(superclass)/基类(base class)/父类(parent class);新类称为子类(subclass)/派生类(derived class)/孩子类(child class)

  • 在子类中可以增加字段,增加方法或覆盖超类的方法,但是继承绝对不会删除任何字段或方法。在覆盖一个方法的时候,子类方法不能低于超类方法的可见性(权限修饰符级别)

  • 如果子类的构造器没有显式的调用超类的构造器,将自动的调用超类的无参数构造器。如果超类没有无参数的构造器,并且在子类的构造器中又没有显式的调用超类的其他构造器,编译器就会报告一个错误

  • 由一个公共超类派生出来的所有类的集合称为继承层次。在继承层次中,从某个特定的类到其祖先的路径称为该类的继承链

  • 在Java中,只有基本类型不是对象,例如字符,数值和布尔类型的值

1.2 多态性

  • 一个对象变量可以指示多种实际类型的现象称为多态。在运行时能够自动的选择适当的方法,称为动态绑定。多态性的使用前提:1.类的继承关系 2.方法的重写

  • 继承遵守“替换原则”,即程序中出现超类对象的任何地方都可以使用子类对象替换。反之,不能将超类的引用赋给子类变量。这一原则也体现了多态性

    //假设Manager类为Employee类的子类
    Employee e;
    Manager m;
    e = new Employee(...);
    e = new Manager(...);
    m = new Employee(...); //错误
    

    在有了多态性后,在编译期,只能调用父类中声明的方法,但在运行期,实际执行的是子类重写父类的方法。但是对象的多态性只适用于方法,不适用于属性(属性始终使用的是子类中的值)

  • 若子类重写了父类方法,就意味着子类里定义的方法彻底覆盖了父类里的同名方法,系统不可能把父类里的方法转移到子类中。对于实例变量则不存在这样的现象,即使子类里定义了与父类完全相同的实例变量,这个实例变量依旧不可能覆盖父类中定义的实例变量

  • 所有数组都要牢记创建时的元素类型,并负责监督仅将类型兼容的引用储存到数组中

    //假设Manager类是Employee类的子类
    Manager[] managers = new Manager[10];
    Employee[] staff = managers; //这样做是允许的,体现了多态性
    
    staff[0] = new Employee("Jack");	//通过
    //乍一看没有问题,Employee数组接纳了Employee类型的元素,可是由于赋值引用的关系,staff本质上依旧是Manager类型的数组。而此时子类数组居然接纳了超类引用的元素,这造成了混乱和潜在的隐患。如果调用managers[0].getBonus(1000)时,就会调用一个不存在的方法,就会出错
    
  • 不仅可以对基础数据类型进行数据转换,父类也可以强制类型转换为子类。这种转换只能在继承层次内进行,并且在超类强制转换为子类之前,应该使用instanceof进行检查。不过一般情况下,最好尽量少用强制类型转换和instanceof运算符

  • 可以定义一个抽象类的对象变量,这个变量只能引用非抽象子类的对象,抽象类是不能创建实例的。但是即便如此抽象类变量依旧利用多态调用方法,由于抽象类永远不会有实例,所以调用方法调用时默认调用指定子类内的方法。

1.3 理解方法调用

  • 假设要调用transfer.method(args),隐式参数transfer声明为类Clazz的一个对象。

    1. 编译器查看对象的声明类型和方法名。编译器将会一一列举Clazz类中所有名为method的方法和其超类中所有名为method而且可访问的方法
    2. 编译器要确定方法调用中提供的参数类型。如果在所有名为method的方法中,存在一个与所提供参数类型完全匹配的方法,就选择这个方法。这个过程称为重载解析。由于允许类型转换(int可以转换为double,子类可以转换为父类),这一过程可能会变得比较复杂。如果编译器最终没有找到与参数类型匹配的方法,或者发现经过类型转换后有多个方法与之匹配,编译器就会报错
      • 如果在子类中定义了一个与超类签名相同的方法,那么子类中的这个方法就会覆盖超类中这个相同签名的方法。虽然返回类型不是签名的一部分,但是在覆盖一个方法时,需要保证返回类型的兼容性(不能随便修改)。允许子类将覆盖方法的返回值类型改为原返回类型的子类型
    3. 如果是private方法,static方法,final方法或者构造器,那么编译器将可以准确地知道应该调用哪个方法,这称为静态绑定。如果调用的方法依赖于隐式参数地实际类型,那么就是动态绑定
    4. 由于程序运行并采用动态绑定调用方法的时候,需要完成相关方法的搜索,时间开销相当大。因此,虚拟机预先为每个类计算了一个方法表,其中列出了所有方法的签名和要调用的实际方法。这样一来,在真正调用方法的时候,虚拟机仅查找这个表就行了。
  • 举例,假设调用e.getSalary()这一方法,那么解析过程为

    1. 虚拟机获取e的实际类型的方法表,具体选择哪个由虚拟机决定
    2. 虚拟机查找定义了getSalary()签名的类,此时虚拟机已经知道应该调用哪个方法
    3. 虚拟机调用方法

1.4 final关键字

  • 将方法或类声明为final的主要原因是,确保他们不会在子类中改变语义。(如果有一个String引用,它引用的一定是一个String对象,而不可能是其它类对象)。

    • 内联

      在早期Java中,为了避免动态绑定带来的系统开销,可以使用final关键字。如果一个方法没有被覆盖并且很短,编译器就能够对其进行优化处理,这一过程称为内联。例如,内联调用e.getName()将被替换为访问字段e.name。但如果该类有一个子类,那么getName在另一个类中就有可能被覆盖,编译器就无法知道覆盖的代码将会做什么操作(要运行时才知道),这样就无法作内联处理了。

      如今的虚拟机即时编译器优化了这方面的处理,编译器可以准确的知道类之间的继承关系,并能够检测出是否有类确实覆盖了给定的方法。如果一个方法很简单,被频繁调用并确实没有被覆盖,那么即时编译器就会对该方法进行内联处理。

2. equals方法

2.1 equals方法的性质

  • 由于Object类中带有equals方法,而所有类都是Object的子类,所以所有的类都可以使用equals方法。对于未重写equals方法的类,Object内自带的方法仅比较两个对象的地址(即if(A == B)

  • 为了防止有的时候两个比较的类的值可能为null,需要使用Objects.equals方法。如果两个参数都为null,Objects.equals(a,b)调用将会返回true;如果其中一个参数为null,则返回true;如果两个参数都不为null,则调用a.equals(b)

  • Java语言规范要求equals方法具有下面的特性:

    1. 自反性:对于任何非空引用x,x.equals(x)应该返回true
    2. 对称性:对于任何引用x和y,当且仅当y.equals(x)返回true时,x.equals(y)返回true
    3. 传递性:对于任何引用x,y和z,如果x.equals(y)返回true,且y.equals(z)返回true,则x.equals(z)也应该返回true
    4. 一致性:如果x和y引用的对象没有发生变化,反复调用x.equals(y)应该返回同样的结果
    5. 对于任何非空引用x,x.equals(null)应该返回false

    根据以上规则,就可以解释为什么不建议在equals方法内使用instanceof方法来判断两个对象是否相等,因为这违反了对称性。一般更建议使用getClass来判断

    • 若m为e的子类,那么e.equals(m)返回的是true,如果使用m.equals(e)返回的就是false

2.2 良好的equals方法规范

  • 下面给出编写一个良好的equals方法的建议:

    1. 显示参数变量名命名为otherObject,稍后需要将它强制转换为另一个名为other的变量

    2. 检测this和otherObject是否相等:

      if(this == otherObject) return true;

    3. 检测otherObject是否为null,如果为null,返回false

      if(otherObject == null) return false;

    4. 比较this与otherObject的类,使用getClass检测

      if(getClass() != otherObject.getClass()) return false;

    5. 将otherObject强制转换为相应类类型的变量

      [ClassName] other = ([ClassName]) otherObject;

    6. 现在根据相等性概念的要求来比较字段。使用==比较基本类型字段,使用Objects.equals比较对象字段。如果所有字段都匹配,就返回true;否则返回false。如果在子类中重新定义equals,就要在其中包含一个super.equals(other)调用

  • 对于数组类型的字段,可以使用静态的Arrays.equals方法检测相应的数组元素是否相等

  • 下面是实现重写equals方法的一种常见错误

    //错误的
    public class Employee{
        public boolean equals(Employee other){
            //......
        }
    }
    //这个equals根本没有重写Object类中的equals,而是定义了一个完全无关的方法
    
    //正确的
    public class Employee{
        public boolean equals(Object other){
            //......
        }
    }
    //为了避免这种情况,建议在重写前面加上@Override标记
    

2.3 散列码与equals方法

  • 散列码(hash code)是由对象导出的一个整型值。散列码是没有规律的。由于hashCode方法定义在Object类中,所以每个对象都有一个默认的散列码,其值由对象的储存地址得出。

    如果重新定义equals方法,就必须为用户可能插入散列表的对象重新定义hashCode方法

    //一个良好的hashCode方法
    //Object.hashCode是一个null安全的方法,它会自动为传入的对象生成一个散列码
    public int hashCode(){
        return 7*Objects.hashCode(name) + 11*Double.hashCode(salary) + 13*Objects.hashCOde(hireDay);
    }
    
    //最佳的hashCode方法
    //Objects.hash会自动地根据传入的字段生成散列码
    public int hashCode(){
        return Objects.hash(name,salary,hireDay);
    }
    

    equals与hashCode的定义必须相容:如果x.equals(y)返回true,那么x.hashCode()就必须与y.hashCode()返回相同的值。

    如果存在数组类型的字段,那么可以使用静态的Arrays.hashCode方法计算一个散列码,这个散列码由数组元素的散列码组成

2.4 toString()

  • 常用toString方法的主要原因是:只要对象与一个字符通过操作符"+"连接起来,Java编译器就会自动的调用toString方法来获得这个对象的字符串描述。

    Point p = new Point(10,20);
    String message = "The current position is " + p;
    //p会自动调用p.toString
    
    • 数组类型比较特别,它们的toString方法不会打印数组内容,而是打印对象的类名和散列码。如果要打印数组内容,可以使用Arrays.toString。如果要打印多维数组,那么要使用Arrays.deepToString
    • 强烈建议为自定义的每一个类添加toString方法。这样做不但自己受益,所有使用这个类的程序员也会从这个日志记录支持中受益匪浅

3. ArrayList类(泛型数组列表)

  • ArrayList类类似于数组,但在添加或删除元素时,它能够自动地调整数组容量(ArrayList不是链表!)。ArrayList是一个有类型参数泛型类。为了指定数组列表保存的元素对象的类型,需要用一对尖括号将类名括起来追加到ArrayList后面。例如,ArrayList<Employee>

    //声明和构造一个保存Employee对象的数组列表
    ArrayList<Employee> staff = new ArrayList<Employee>();
    //可以使用类型推断来简写这一声明
    ArrayList<Employee> staff = new ArrayList<>();
    
    //使用add方法可以将元素添加到数组列表中
    staff.add(new Employee(...));
    //如果调用add而内部数组已经满了,数组列表就会自动地创建一个更大的数组,并将所有的对象从较小的数组中拷贝到较大的数组中。
    //如果已经知道或能够估计出数组可能储存的元素数量,也可以显式声明数组大小
    ArrayList<Employee> staff = new ArrayList<>(100);
    //注意:new ArrayList<>(100)和new Employee[100]不同,如果是第二种,那么就会分配一个由100个元素的数组,数组内有100个空位置。而容量为100的数组列表,在完成初始化构造后,其内不包含任何元素。
    
    //size方法将返回数组列表中包含的实际元素个数,等价于数组a的a.length
    staff.size();
    
    //不能使用[]语法格式访问或改变数组列表的元素,而要使用get和set方法
    staff.set(1,harry);
    Employee e = staff.get(1);
    //注意,set的位置必须要已经存在元素才行。也就是说要用add方法为数组添加新元素,而不是set方法,set方法指示用来替换数组中已经加入的元素
    var list = new ArrayList<Employee>(100);
    list.set(0,x);	//错误,位置0不存在
    
    • 下面是使用ArrayList的一些技巧
    int count = 0;
    ArrayList<Integer> list = new ArrayList<>();
    while(count != 20){
    	count++;
    	list.add(count);
    }
    Integer[] IntegerArr = new Integer[list.size()];
    list.toArray(IntegerArr);	//使用toArray方法将数组元素拷贝到一个数组中
    //这样既可以灵活的扩展数组,又可以方便的访问数组元素
    
    //有时需要在数组列表的中间插入元素,可以使用add方法并提供一个索引参数
    int n = staff.size() / 2;
    staff.add(n,e);
    //同样的也可以这样从数组列表中删除一个元素
    Employee e = staff.remove(n);
    //在中间插入/删除一个元素的时候,所有的元素都要往后/往前移动一个位置。如果数组列表新的大小超过了容量,它就会重新分配它的储存数组
    //插入和删除元素的操作效率很低,如果储存的元素比较多,又经常需要在中间插入,删除元素,就应该考虑使用链表
    
  • 假设要定义一个整型数组列表,就必要用使用包装器类。包装器类是不可变的,一旦构造了包装器,就不允许更改包装在其中的值。(如果改变了其中的值,那么必然是进行了拆箱再装箱的操作)。

    //不允许
    ArrayList<int> list = new ArrayList<>();
    //允许
    ArrayList<Integer> list = new ArrayList<>();
    
    //需要注意包装器类是对象,和基本类型有本质的区别,比如下面的
    Integer a = 1000;
    Integer b = 1000;
    a == b; //此时两者比较的并不是值,而是包装器类对象的地址
    

4. 枚举类

4.1 构造器

  • 枚举类的构造器总是私有的。可以省略private修饰符,如果声明一个enum构造器为public或protected,会出现语法错误

    public enum Size{
        SMALL("S"),MEDIUM("M"),LARGE("L"),EXTRA_LARGE("XL");
        
        private String abbreviation;
        
        public String getAbbreviation() {return abbreviation;}
    }
    

4.2 方法

//1.toString()
//Enum类变量的toString方法会返回字符串
Size.SMALL.toString(); //返回字符串SMALL

//2.valueOf()
//valueOf是toString的逆方法
Size s = Enum.valueOf(Size.class,"SMALL");

//3.values()
//每个枚举类型都有一个静态的values方法,它将返回一个包含全部枚举值的数组
Size[] values = Size.values();

//4.ordinal()
//ordinal方法返回enum声明中枚举常量的位置,位置从0开始计数
Size.MEDIUM,ordinal();	//返回1(前面有Size.SMALL)

5. 继承的设计技巧

  1. 将公共操作和字段放在超类中

  2. 不要使用受保护的字段

    protected权限修饰符对于字段的保护效果很有限,即便将字段声明为protected权限,任何派生的子类也依旧可以修改字段的内容。不过protected方法对于指示那些不提供一般用途而应在子类中重新定义法方法很有用。(抽象方法)

  3. 使用继承实现"is - a"关系

    这能显著的减少代码量,但是不要滥用继承,一些不恰当的继承可能会带来很多的麻烦

  4. 除非所有的继承都有意义,否则不要使用继承

    对于一些父类,它们有额外的方法可以改变其内的值。这将会间接影响到子类,带来一些不可知的变化,最好保证父类的不可变性。

  5. 在覆盖方法时,不要改变预期的行为

  6. 使用多态,而不要使用类型信息

    如果看到类似下面的代码,就应该考虑多态,而不是使用多个类型信息

    if(x is of type 1){
    	action1(x);
    } 
    else if(x is of type 2){
        action2(x);
    }
    

    如果action1和action2的目的相同,可以考虑封装成不同类型的超类或接口,这样就可以直接调用x.action();从而使用多态性固有的动态分派机制执行正确的动作

    使用多态方法或接口实现的代码比使用多个类型检测的代码更易于维护和发展

  7. 不要滥用反射

反射相关的内容见笔记<第八章 - 泛型程序设计>

posted @   Solitary-Rhyme  阅读(63)  评论(0编辑  收藏  举报
编辑推荐:
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
点击右上角即可分享
微信分享提示