继承

基本思想: 基于已有的类创建新的类, 复用已有类的方法, 同时可以增加一些新的方法和字段

  • 反射是程序在运行期间更多地了解类以及属性的能力
  • CPP中使用:表示继承, 除了公共继承以外, 还存在私有继承和保护继承
  • Javaextends关键字表示继承, 所有继承都是公共继承
  • extends表示正在构造的类(子类, 派生类)派生于一个已经存在的类(超类, 基类, 父类)
    • 子类比超类拥有更多的功能
  • “声明为私有的类成员不会被这个类的子类继承”
    • 这里其实是子类不能直接访问这些私有成员
    • 但是子类的每个实例对象中依然会包含超类中的私有字段
  • 记录不能被扩展, 记录也不能继承别的类
  • 如果希望使用超类中的方法, 就使用super关键字, super只是用于指示编译器调用超类方法的特殊关键字
    • 同时可以使用super()来调用超类中对应的构造器
    • 不管是this还是super, 在调用其他构造器的时候, 都必须放在第一行, 否则会报错
  • 一个对象可以指示多种实际类型
    • 比如一个类的超类以及他的子类都在一个数组中, 使用foreach循环的时候, 循环变量可以同时指示多个不同的类, 那么这就是多态
  • 运行的时候可以自动的选择适合的方法, 就是动态绑定
  • CPP中, 如果希望实现动态绑定, 可以将成员函数设置为virtual
  • Java中默认会执行动态绑定, 如果不希望方法是虚拟的, 可以使用final关键字
  • CPP中, 一个类可以有多个超类
  • Java中不支持多重继承, 但是可以使用接口实现多重继承的功能

对象方法调用过程

假设需要x.f(args), x是类C的一个对象, 具体调用过程如下:

  1. 编译器查看对象的声明类型和方法名, 可能存在多个名为f的方法, 他们具有不同的参数类型, 编译器会一一列举出C中的所有名为f的方法, 以及C的超类中所有名为f非私有方法
  2. 重载解析: 编译器确定方法调用中提供的参数类型, 如果所有名为f的方法中存在一个与所提供的参数类型完全匹配的方法, 就直接使用这个方法
    • 如果找不到匹配的方法, 或者找到了多个匹配的方法, 编译器就会抛出异常
    • 方法的名字与参数类型会组成签名, 如果子类的签名和超类的重复了, 子类的方法会覆盖超类的方法
    • 尽管返回类型不是签名的一部分, 但是在覆盖的时候, 需要保证子类的返回类型是超类返回类型的子类型
  3. 如果是private, static, final方法 或者 构造器方法, 编译器就可以准确知道调用哪个方法, 这称为静态绑定. 如果调用方法依赖于隐式参数的实际类型, 就是动态绑定. 所以只要不是上述四种方法, 就是动态绑定
  4. 程序使用动态绑定时, 虚拟机必须调用与x引用对象实际类型对应的方法, 比如x的实际类型是D, DC的子类, 如果D定义了方法f(String), 那么就会调用这个方法, 否则就会在D的超类中寻找这个方法
    • 如果每一次调用方法都需要执行一次搜索的话, 时间消耗非常大, 所以虚拟机预先为每个类计算了一个方法表, 列出了所有方法的签名和需要调用的方法
    • 虚拟机加载一个类以后就可以构建这个方法表
  • 覆盖一个方法的时候, 子类方法的可见性不能低于超类方法的可见性
  • 如果超类方法是public, 子类也必须要是public方法, 如果漏了, 就会报错

final方法

  • 使用final修饰一个类, 就可以阻止定义这个类的子类
    1
    public final class E extends M {}
    • final类的方法自动变为final方法
    • final类的字段不会自动变为final字段
  • 如果将类中的某个方法设定为final, 则该类的所有子类都不能覆盖这个方法
    1
    2
    3
    public class E {
    public final String getName() {}
    }
  • final字段表示以后都不会改变的字段
  • 枚举和记录总是final的, 因为他们不允许被扩展

对象引用的强制类型转换

  • 现在有M类是E的子类
    1
    2
    3
    4
    5
    6
    var s = new E[3];
    s[0] = new M();
    s[1] = new E();
    s[2] = new E();
    M b = (M) s[0]; // 强制类型转换
    M c = (M) s[1]; // 抛出异常 ClassCastException
  • 为了避免抛出异常, 可以使用instanceof
    1
    2
    3
    if (s[i] instanceof M) {
    b = (M) s[i];
    }
    • JDK 16中有更加简单的写法, 可以直接在instanceof语句中声明子类变量
      1
      2
      3
      if (s[i] instanceof M b) {
      b.setB();
      }
  • 只能在继承层次内使用强制类型转换
  • 将超类转换为子类之前, 需要使用instanceof检查类型
  • 如果xnull, x instanceof C不会抛出异常, 返回false
  • 一般情况下最好少用强制类型转换和instanceof

protected

  • 将超类中的某个字段声明为protected, 子类就可以进行访问
  • 受保护的字段只能由同一个包中的类进行访问, 如果子类在不同的包中, 就不能访问了
  • 相比之下, protected方法更有意义, 表示可以相信子类能够正确的使用这个方法
  • 所以Java中的protected允许所有子类, 以及同一个包中的所有其他类访问, 不如CPP中的安全

Object

Object类是Java中所有类的超类

equals方法

  1. 显式参数命名为otherObject
  2. 检测thisotherObject是否相同
1
2
3
if (this == otherObject) {
return true;
}
  1. 检测otherObject是否为null, 如果为null, 则返回false
1
2
3
if (otherObject == null) {
return false;
}
  1. 比较thisotherObject的类
    如果equals的语义可以在子类中改变, 就使用getClass检测
1
2
3
4
5
if (getClass() != otherObject.getClass()) {
return false;
}
ClassName other = (ClassName) otherObject;
// 这个判断对于匿名子类会失败

如果所有的子类都有相同的相等性语义, 就可以使用instanceof检测

1
2
3
if (!(otherObject instanceof ClassName other)) {
return false;
}
  1. 使用相等性概念来比较字段, 使用==比较基本类型字段, 使用Objects.equals比较对象字段, 如果所有的字段都匹配, 返回true
1
return field1 == other.field1 && Objects.equals(field2, other.field2) && ... ;
  • 对于数组类型, 使用Arrays.equals()方法检查相应的数组元素, 如果是多维数组, 可以使用Arrays.deepEquals()
1
2
3
4
5
6
7
8
9
10
11
12
public class E {
public boolean equals(E other) {
return other != null
&& getClass() == other.getClass()
&& Objects.equals(name, other.name)
&& salary == other.salary
&& Objects.equals(hireDay, other.hireDay);
}
}
// 这里有错误, 因为参数类型是E, 没有覆盖Object类的equals方法, 而是定义了一个新的方法
// 为了避免这个错误, 可以使用@Override public boolean equals(Object other)
// 此时编译器就会给出报错信息, 因为当前方法没有覆盖Object中的任何方法

hashCode

hashCode方法定义在Object类中, 所以每个对象都有一个默认的散列码, 由对象的存储地址得出

  • 自定义hashCode方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class E {
public int hashCode() {
return 7 * name.hashCode()
+ 11 * Double.valueOf(s).hashCode()
+ 13 * hireDay.hashCode();
}
}
// 可以使用Objects.hashCode(), 这是null安全的, 如果参数为null, 会直接返回0
// 可以使用静态方法Double.hashCode()避免创建一个Double对象

public class E {
public int hashCode() {
return 7 * Objects.hashCode(name)
+ 11 * Double.hashCode(s)
+ 13 * Objects.hashCode(hireDay);
}
}
// 如果有多个内容需要hash, 可以直接调用Objects.hash()

public class E {
public int hashCode() {
return Objects.hash(name, s, hireDay);
}
}
  • 如果有数组类型, 可以使用静态Arrays.hashCode()计算一个散列码, 这个散列码由数组元素的散列码组成
  • 记录类型会自动提供一个hashCode(), 由字段值的散列码得到一个散列码

toString()

  • 使用getClass().getName()获得类名的字符串
  • 每一个子类都应该实现自己的toString(), 如果超类中实现了, 可以直接使用super.toString()
  • 只要一个对象与一个字符串通过+连接, 编译器就会自动调用toString()方法获得这个字符串的描述
  • 如果x是任意一个对象, 使用System.out.println(x)也会自动调用x.toString()
  • Objects定义了toString(), 会打印对象的类名和散列码

ArrayList

ArrayList是一个有类型参数的泛型类

  • 使用var可以避免重复写类型 var staff = new ArrayList<E>();
    • 如果使用了var, 就需要声明类型; 如果不使用var, 可以使用菱形语法 ArrayList<E> staff = new Arraylist<>();
  • 使用add添加元素, 如果满了会自动扩容
  • 如果可以估计大小, 可以在添加元素之前使用staff.ensureCapacity(nums)来设置分配的空间
    • 也可以使用var staff = new ArrayList<E>(nums)将初始容量传递给构造器
  • 如果数组的大小保持恒定不会发生变化了, 可以使用staff.trimToSize()将内存块的大小调整为当前所需空间, GC回收多余的空间

对象包装器

  • 有时候需要将int转换为对象, 所有基本类型都有一个与之对应的类
  • Integer, Long, Float, Double, Short, Byte, Character, Boolean 前六个派生于公共超类Number
    • 包装类不可变, 一旦构造了包装器, 就不能更改其中的值
    • 包装器类还是final, 所以不能派生子类
    • 尖括号中的类型参数不能是基本数据类型, 必须要是包装器类型
    • 因为每一个值都包装在对象中, 所以ArrayList<integer> 效率低于int[]
  • 当使用list.add(3)的时候, 会自动转换为list.add(Integer.valueOf(3)), 这就是自动装箱
  • 同样的, 当我们使用int n = list.get(i)的时候, 实际上是将Integer对象赋值给int类型, 这是自动拆箱, 等价于list.get(i).intValue()
  • Integer n = 3; n ++;这里面实际上先自动拆箱, 然后+1, 再自动装箱
  • 不要使用包装器类构造器, 可以使用Integer.valueOf(i), 也可以依赖自动装箱:Integer a = i, 不要使用new Integer(i), 这个将会被删除
  • 包装器类引用可以为null, 所以会触发NPE
  • 如果表达式中混用了Integer, Double, 则Integer会自动拆箱, 提升为double, 再自动装箱为Double
  • 自动装箱和拆箱是编译器做的工作, 不是虚拟机

可变参数个数方法

1
2
3
4
5
public class PrintStream {
public PrintStream printf(String fmt, Object... args) {
return format(fmt, args);
}
}
  • 这里的...表示接受任意数量的对象, 是一个Object数组, 保存着除了fmt之外的其他参数
  • 如果调用者给了其他类型或者基本类型的值, 就会自动装箱为对象, 现在就只需要fmt中扫描到第i个格式说明, 与args[i]值匹配

抽象类

  • 如果一个类中存在抽象方法, 类本身必须声明为抽象的; 抽象类可以没有抽象方法; 抽象类可以有具体字段和方法
    1
    2
    3
    public abstract class P {
    public abstract String getD();
    }
  • 抽象类的子类可以保留抽象类中的部分或所有抽象方法, 那么子类依然是抽象的; 也可以全部实现, 则子类不是抽象的
  • 抽象类不能被实例化, 但是可以存在抽象类的变量, 只是只能引用其非抽象子类
    1
    P p = new S();
  • 接口是抽象类的泛化

密封类

  • 比如有一个抽象类JSONValue, 还有两个final子类, 分别是JSONNumber, JSONArray
  • 两个子类是final的, 所以无法被派生了, 但是不能阻止别人派生JSONValue
  • 可以将JSONValue声明为密封类
    1
    2
    3
    4
    5
    public abstract sealed class JSONValue
    permits JSONArray, JSONNumber, JSONString, JSONBoolean, JSONObject, JSONNull {
    ...
    }
    // 这样使用sealed声明为密封类, 可以保证JSONValue只有六个定义好的子类, 无法派生别的子类了
  • 一个密封类的子类必须是可以访问的, 不能是嵌套在别的类中的私有类, 也不能位于另一个包中
  • 密封类允许的公共子类, 必须要在同一个包中, 如果使用了模块, 还必须要在同一个模块中
  • 声明密封类可以不加permits, 但是这样的话所有子类都必须要在同一个文件中声明, 这样的话, 子类就不是公共的了
  • 密封类的子类必须声明为sealed, final, non-sealed中的一种,最后一种允许继续派生

反射

Class

  • Java始终为所有对象维护一个运行时类型标识, 跟踪每个对象所属的类
  • 可以用Class类访问这些信息, Object.getClass()返回一个Class对象的实例
    • 最常用的方法就是getName(), 返回一个对象类型的名称, 包名也作为类名的一部分
  • 也可以直接使用类名.class的方法访问这个类
  • 虚拟机为每个类型管理一个唯一的Class对象, 所以可以使用==比较
    1
    2
    3
    if (e.getClass() == E.class) {}
    // 如果e是一个E的实例, 则为true, 如果e是M的实例, 其中M是E的子类, 则为false
    // 如果是 e instanceof E的话, 那么当e是M的实例时, 依然会返回true
  • 如果有一个Class类型的对象, 可以用他构造实例
    1
    2
    3
    4
    var className = "java.uril.Random";
    Class cl = Class.forName(className);
    Object obj = cl.getConstructor().newInstance();
    // 如果这个类没有无参构造器, 则会抛出异常InvocationTargetException
  • Class.forName()会抛出一个检查型异常, 没有办法保证指定名字的类一定存在, 所以需要在函数后面加上throws ReflectOperationException

异常

  • 分两种: 非检查型异常和检查型异常
    • 检查型异常: 编译器会检查你是否知道这个异常, 并做好准备处理
    • 非检查型异常: 比如数组越界, null引用访问, 编译器不期望你为这些异常提供处理方法

应用

  1. 资源文件加载

    • 获得拥有资源的类的Class对象, 比如ResourcesTest.class
    • 调用部分可以接受描述资源位置URL的方法, 比如URL url = cl.getResource("about.txt");
    • 否则, 使用getResourceStream()得到输入流读取文件
  2. 国际化

    • 与语言相关的字符串都放在资源文件中, 每个语言对应一个文件

利用反射分析类

  • java.util.reflect包中有三个类: Field, Method, Constructor, 分别用于描述类的字段, 方法和构造器
    • 三个类都有一个方法, 名为getName()
    • Field.getType()可以返回字段类型的一个对象, 对象的类型同样是Class
    • Method, Constructor有报告类型参数的方法, Method有报告返回类型的方法, 三者都有getModifiers() 返回一个整数, 用不同的0/1位描述修饰符, 比如public, static
    • 可以使用Modifier类的静态方法分析getModifiers()返回的整数, 需要做的就是在返回的整数基础上, 调用Modifier类中适当的方法
    • 可以用Modifier.toString()打印修饰符
  • Class中的getFields(), getMethods(), getConstructors()分别返回这个类支持的公共字段, 方法和构造器
  • Class中的getDeclaredFields(), getDeclaredMethods(), getDeclaredConstructors()返回这个类声明的全部字段, 方法和构造器, 包括私有成员, 包成员, protected成员, 有包访问权限的成员, 但是不会包括超类的成员

利用反射分析对象

  • 利用反射可以查看在编译时还不知道的对象字段
  • 利用Field中的get, 比如f是一个Field类型的对象, obj是包含f字段的类的对象, 则f.get(obj)将会返回一个对象, 值为obj的当前字段值
    1
    2
    3
    4
    var h = new E();
    Class cl = h.getClass();
    Field f = cl.getDeclaredField("name");
    Object v = f.get(h);
  • 同样可使用f.set(obj, val)设置值, 但是如果name是一个私有字段, 则不能使用get, set, 会抛出IllageAccessException
  • 只能对可以访问的字段使用get, set, Java允许查看一个对象中的字段, 但是无法访问
  • 不过可以调用f.setAccessible(true)覆盖Java的访问控制
  • setAccessible()Field, Method, Constructor的公共超类AccessibleObject中的方法

通用toString()

  • 使用getDeclaredFields获得实例字段, 使用setAccessible()将字段设置为可以访问的, 再对每个字段调用toString()
  • 不过如果引用循环会导致无限递归, ObjectAnalyzer会跟踪已经访问过的对象

使用反射编写泛型数组

  • java.util.reflect中的Array类, Arrays.copyOf()就使用了这个类, 这个方法可以用来扩展一个数组
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public static Object goodCopyOf(Object a, int newLength) {
    Class cl = a.getClass();
    if (!cl.isArray()) return null;
    Class componentType = cl.getComponentType();
    // 如果对象是一个数组类型, 返回对应元素的Class, 否则返回null
    int len = Array.getLength(a);
    Object newArray = Array.newInstance(componentType, newLength);
    System.arraycopy(a, 0, newArray, 0, Math.min(length, newLength));
    return newArray;
    }
  • 这个goodCopyOf可以扩展任意数组, 参数声明为Object类型, 而不是Object[], 因为int[]可以转换为一个Object, 而不是转换成对象数组
  • 如果是Object[]的话, 在强制类型转换回去的时候会抛出异常ClassCastException

使用反射调用任意的方法

  • 可以使用Field.get()查看一个方法的字段, 使用Method.invoke()调用包装在当前Method中的方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    Object invoke(Object obj, Object... args);
    // 第一个参数是隐式参数, 其他参数是显式参数
    // 如果是静态方法, 则第一个参数会被忽略为null
    // 比如m1表示E类中的getName()
    String n = (String) m1.invoke(h);
    // 如果返回的是基本数据类型, invoke会返回其包装类型, 需要强制类型转换后使用自动拆箱
    // 比如m2表示E类中的getSalary()
    double d = (Double) m2.invoke(h);
    // 使用getMethod()可以得到一个类中的方法
    Method m1 = E.class.getMethod("getName");
    Method m2 = E.class.getMethod("getSalary", double.class);
    // 可以获得构造器方法
    Class cl = Random.class;
    Constructor cons = cl.getConstructor(long.class);
    Object obj = cons.newInstance(42L);
  • Method, Construtor类扩展了Executable类, 并且Executablesealed的, 只允许Method, Constructor作为子类
  • 比如调用Math.sqrt()
    1
    2
    3
    4
    5
    6
    7
    8
    double dx = (to - from) / (n - 1);
    Math.class.getMethod("sqrt", double.class);
    for (double x = from; x <= to; x += dx) {

    double y = (Double) f.invoke(null, x);
    // 因为Math.sqrt是一个静态方法, 所以invoke的第一个参数为null
    System.out.printf("%10.4f | %10.4f%n", x, y);
    }
  • 反射能完成所有操作, 但是很不方便, invoke参数错误还会抛出异常. 同时invoke返回类型一定是Object的, 所以必须来回强制类型转换
  • 编译器就丧失了检查代码的机会, 反射获得方法指针的代码比直接调用慢的多
  • 所以一般只有绝对必要的时候才会引入Method对象, 更好的方法是使用lambda表达式
  • 不要使用回调函数的Method对象, 要是用回调的接口, 这样执行速度更快, 也更好维护

继承设计技巧

  1. 公共字段和方法放在超类中
  2. 不要使用protected
  3. 使用继承实现is-a关系
  4. 除非所有继承的方法都有意义, 否则不要使用继承
  5. 覆盖方法不要改变预期的行为
  6. 不要滥用反射
  7. 使用多态, 不要使用类型信息
    1
    2
    3
    4
    5
    6
    7
    if (x is of type 1) 
    action1(x)
    else
    action2(x)
    // 这种形式的代码都可以用多态实现
    // 如果action1() action2()表示通用的概念, 可以定义为这两个类型的公共超类或接口中的方法, 然后可以调用
    // x.action(), 利用多态的动态分配机制执行正确的动作