继承
⏱️时间安排:14天
⏳开始时间:2022-12-10
⌛结束时间:2022-12-24
类、父类和子类
以员工和经理为例,定义一个新类 Manager,并增加一些功能。但可以重用 Employee 类中已经编写的部分代码,并且保留 Employee 类中的所有字段。从理论上将,在 Manager 和 Employee 之间存在着明显的 “is-a” 的关系,每个经理都是员工:“is-a” 关系是继承的一个明显特征。
定义子类
可以如下继承 Employee 类来定义 Manager 类,这里使用关键字 extends
表示继承。
public class Manager extends Employee{
added methods and fields
}
提示
在 Java 中,所有的继承都是公共继承,而没有 C++ 中的私有继承和保护继承。
关键字 extends 表明正在构造的新类派生于一个已存在的类。这个已存在的类称为父类(parent class);新类称为子类(subclass)。
子类在父类的基础上封装了更多的数据,拥有更多的功能。
在 Manager 类中,增加一个新的字段,以及一个用于设置这个字段的新方法。
public class Manager extends Employee{
private double bonus;
...
public void setBonus(double bonus){
this.bonus = bonus;
}
}
这里定义的方法和字段并没有什么特别之处。如果由一个 Manager 对象,就可以使用 setBonus 方法。
这里,由于 setBonus 方法不是在 Employee类中定义的,所以属于 Employee 类的对象不能使用它。
尽管子类中没有显示定义父类中的方法,但是可以对子类的对象使用这些方法,这是因为子类会自动继承父类的方法。
提示
通过拓展父类定义子类时,只需指出子类与父类的 不同之处。因此在设计类的时候,应该将最一般的方法放在父类中,而更特殊的方法放在子类中。
覆盖方法
父类中的有些方法对子类并不一定适用,为此,需要提供一个新方法来 覆盖(override)父类中的这个方法:
public class Manager extends Employee{
return salary + bonus;
}
这时子类的方法访问了父类的私有字段,然而父类的私有字段只能由父类直接访问。如果子类的方法想要访问父类的私有字段,就要像其他方法一样使用父类的公共接口,在这里就是要使用 Employee 类中的公共方法 getSalary。
然而子类中也有方法 getSalary,子类方法调用 getSalary 方法,只是在调用自身。这里需要指出:我们希望调用父类的方法,而不是当前子类的方法。为此,可以使用特殊的关键字 super
解决这个问题。
super.getSalary();
这个语句调用的是父类的 getSalary 方法。下面是完整示例。
public double getSalary(){
double baseSalary = super.getSalary();
return baseSalary + bonus;
}
提示
有些人认为 super 与 this 引用是类似的概念,实际上,这样的比较并不恰当。这是因为 super
不是一个对象的引用,例如,不能将值 super 赋给另一个对象变量,它只是一个指示编译器调用父类方法的关键字。
正如前面看到的那样,在子类中可以 增加 字段、增加 方法或 覆盖 父类的方法,不过,继承绝不会删除任何字段或方法。
子类构造器
下面是子类构造器的例子:
public Manager(String name, double salary, int month, int day){
super(name, salary, year, month, day);
bonus = 0;
}
这里关键字 super
具有不同的含义。语句
super(name, salary, year, month, day);
是“调用超类 Employee 中带有 n、s、year、month 和 day 参数的构造器”的简写形式
由于子类的构造器不能直接访问父类的私有字段,所以必须通过一个构造器来初始化这些私有字段。可以使用特殊的 super 语法来调用这个构造器。
注意
使用 super 调用构造器的语句必须是子类构造器的第一条语句
如果子类的构造器没有显式地调用超类的构造器,将自动地调用超类的无参数构造器,如果超类没有无参数的构造器,并且在子类的构造器中又没有显式地调用超类的其他构造器,Java 编译器就会报告一个错误。
提示
关键字 this 有两个含义:一是指示隐式参数的引用,二是调用该类的其他构造器。类似地,super 关键字也有两个含义:一是调用超类的方法,二是调用超类的构造器。
在调用构造器的时候,this 和 super 这两个关键字紧密相关。调用构造器的语句只能作为另一个构造器的第一条语句出现。构造器参数可以传递给当前类(this) 的另一个构造器,也可以传递给超类 (super) 的构造器。
多态
在 Java 程序设计语言中,对象变量是 多态 (polymorphic)的。一个父类的变量既可以引用一个父类的对象,也可以引用该父类的任何一个子类的对象。下例:
Manager boss = new Manager(...);
Employee staff = new Employee;
staff = boss;
在这个例子中,变量 staff 与 boss 引用同一个对象。但是编译器只将 staff 看成是一个 Employee 对象。
这意味着,可以调用
boss.setBonus(5000); //OK
但不能调用
staff.setBonus(5000); //Error
这是因为 staff 声明的类型是 Employee,而 setBonus 不是 Employee 类的方法。
注意
不能将父类的引用赋给子类变量。例如,下面的赋值是非法的:
Manager m = staff; //Error
原因很清楚:不是所有的员工都是经理。如果赋值成功,m 有可能引用一个 Employee 对象,而在后面有可能会调用 m.setBonus(...)
,这就会发生运行时错误。
阻止继承:final 类和方法
有时候,我们希望阻止某个类定义子类。不允许扩展的类被称为 final 类。如果在定义类的时候使用了 final 修饰符就表明这个类是 final 类。例如,假如希望阻止派生 Manager 类的子类,就可以在声明这个类的时候使用 final 修饰符。声明格式如下所示:
public final class Manager extends Employee{
...
}
类中的某个特定方法也可以被声明为 final。如果这样做,子类就不能覆盖这个方法(final 类中的所有方法自动地成为 final 方法)。例如:
public class Employee{
...
public final String getName(){
return name;
}
...
}
提示
前面所过,字段也可以声明为 final。对于 final 字段,构造对象之后就不允许改变它们的值了。不过,如果将一个类声明为 final,只有其中的方法自动地成为 final,而不包括字段。
将方法或类声明为 final 的主要依据是:它们不会在子类中改变语义。例如,Calendar 类中的 getTime 和 setTime 方法都被声明为 final。这表明 Calendar 类的设计者负责实现 Date 类与日历状态之间的转换,而不允许子类来添乱。同样地,String 类也是 final 类,这意味着不允许任何人来定义 String 的子类。换言之,如果有一个 String 引用,它引用的一定是一个 String 对象,而不可能是其他类的对象。
强制类型转换
正像有时候需要将浮点数转换成整数一样,有时候也可能需要将某个类的对象引用转换为另一个类的对象引用。要完成对象引用的强制类型转换,转换语法与数值表达式的强制类型转换类似,仅需要用一对圆括号将目标类名括起来,并放置在需要转换的对象引用之前。例如:
Manager boss = (Manager)staff;
进行强制类型转化的唯一原因是:在暂时忽略对象的实际类型之后再使用对象的全部功能。即把原来引用子类的元素复原成子类的对象,以便使用子类对象具有的全部功能。
将一个值存入变量时,如果将一个子类的引用赋给一个父类变量,编译器是允许的。但将一个父类的引用赋给一个子类变量时,会导致承诺过多,并产生一个 ClassCastException
异常。如果没有捕获这个异常,程序就会终止。因此,在进行强制类型转换之前,应先查看是否能够成功转换。为此需要使用 instanceof
操作符。例如:
if(staff instanceof Manager){
Manager boss = staff;
}
综上所述:
- 只能在继承层次内进行强制类型转换。
- 在将父类强制类型转换成子类之前,应该使用
instanceof
进行检查
提示
实际上,通过强制类型转换来转换对象的类型通常不是一种好的做法。在大多数示例中,因为实现多态性的动态绑定机制能够自动地找到正确的方法。只有在使用子类中特有的方法时才需要进行强制类型转换。
抽象类
如果自下而上在类的继承层次结构中上移,位于上层的类更具有一般性,可能更加抽象。从某种角度看,父类更具有一般性,人们只将其作为派生其他类的基类,而不是用来构造想使用的特定实例。
考虑拓展 Employee 类层次结构。员工是一个人,学生也是一个人。下面拓展类层次结构来加入类 Person 和类 Student。类 Person 是类 Employee 和类 Student 的父类。我们可以把一般的属性和方法放到父类中。
有时候设计好了父类,但不知道要在父类中提供什么内容。这时可以使用 abstract 关键字,这样就不需要实现方法了。例如:
public abstract String getDescription(){
//no implementation required
}
为了提高程序的清晰的,包含一个或多个抽象方法的类本身必须是抽象的。
public abstract class Person{
...
public abstract String getDescription();
}
除了抽象方法之外,抽象类还可以包含字段和具体方法。例如,Person 类保存着一个人的姓名,有一个返回姓名的具体方法。
public abstract class Person{
private String name;
public Person(String name){
this.name = name;
}
public abstract String getDescription();
public String getName(){
return name;
}
}
提示
有些人认为,在抽象类中不能包含具体方法。建议尽量将通用的字段和方法(不管是不是抽象的)放在父类(不管是不是抽象类)中
抽象方法充当着占位方法的角色,它们在子类中具体实现。拓展抽象类可以有两种选择。一种是定义部分方法,子类仍为抽象类;另一种是定义全部方法,这样一来,子类就不是抽象的了。
提示
即使不含抽象方法,也可以将类声明为抽象类。
抽象类不能实例化,例如,表达式 new Person("Tom")
是错误的,但是可以创建一个具体子类的对象。
需要注意,可以定义抽象类的 对象变量,但是这样个变量只能引用非抽象子类的对象。例如:
Person p = new Student("Tom", "Mathematics and Applied Mathematics")
这里 p 是抽象类型 Person 的变量,它引用了非抽象子类 Student 的实例。
下面定义一个拓展抽象类 Person 的具体子类 Student:
public class Student extends Person{
private String major;
public Student(String name, String major){
super(name);
this.major = major;
}
public String getDescription(){
return "a student majoring in " + major;
}
}
Student 类定义了 getDescription 方法。因此,在 Student 类中的全部方法都是具体的,这个类不再是抽象类。
提示
下面的代码将 Student 对象赋给 Person 引用,并输出对象的姓名和描述:
var people = new Person[1];
people[0] = new Student(...);
System.out.println(p.getName() + ", " + people.getDescription());
有些人可能会认为 people.getDescription()
这个语句调用了一个没有定义的方法。由于不能构造抽象类 Person 的对象,所以变量 people 永远不会引用 Person 对象,而是引用具体子类的对象,而这些对象中定义了 getDescription 方法。所以能在变量 people 上直接调用 getDescription 方法。
受保护访问
前面我们知道,最好将类中的字段标记为 private,而方法标记为 public。任何声明为 private 的内容对其他类都是不可见的。这对子类来说也同样适用,即子类也不能访问父类的私有字段
不过,在有些时候,可能希望限制超类中的某个方法只允许子类访问,或者希望允许子类的方法访问父类的字段。为此可以将这些类方法或字段声明为受保护(protected)的。
在 Java 中,保护字段只能由同一个包中的类访问。把子类放在另一个不同的包中,可以避免滥用保护机制,不能通过派生子类来访问受保护的字段。
提示
在实际应用中,要谨慎使用受保护字段。假设你的类要提供给其他程序员使用,而你在设计这个类时提供了一些受保护的字段。其他程序员可能会由这个类再派生出新类,并开始访问你的受保护的字段。再这种情况下,如果你想修改你的类的实现,就势必会影响那些程序员。这违背了 OOP 提倡数据封装的精神。
下面对 Java 中的 4 个访问修饰符做个小结:
- 仅对本类可见 —— private。
- 对外部完全可见 —— public。
- 对本包和所有子类可见 —— protected。
- 对本包可见 —— 默认,不需要修饰符。
Object:所有类的父类
Object 类是 Java 中所有类的父类,在 Java 中每个类都拓展了 Object。当时不需要使用 extends
明确的继承 Object 类。如果没有明确地指出父类,Object 就被认为是这个类的父类。由于 Java 中每个类都是由 Object 类拓展来的,熟悉 Object 类提供的功能显得十分重要,下面将介绍一些基本的内容。
Object 类型的变量
可以使用 Object 类型的变量引用任何类型的对象:
Object obj = new Employee(...);
当然,Object 类型的变量只能作为存放各种值的一个泛型容器。要想对其中的内容进行具体的操作,还需要清楚对象的原始类型,并进行相应的强制类型转换:
Employee e = (Employee) obj;
提示
在 Java 中,只有基本数据类型不是对象
所有的数组类型,不管是对象数组还是基本数据类型数组都拓展了 Object 类。
Employee[] staff = new Employee[10];
obj = staff; //OK
obj = new int[10]; //OK
equals 方法
Object 类中的 equals 方法用于检测一个对象是否等于另外一个对象。Object 中实现的 equals 方法将确定两个对象的引用是否相等。如果两个对象的引用相等,这两个对象就肯定相等,对于很多类来说这已经足够了。不过,有时需要基于状态检测对象的相等性,如果两个对象有相同的状态,才认为两个对象是相等的。
例如,如果两个员工的 name 是一样的,就认为它们是相等的。实现代码如下:
public class Employee{
...
public boolean equals(Object otherObject){
if(this == otherObject){
return true;
}
if(otherObject == null){
return false;
}
if(getClass() != otherObject.getClass()){
return false;
} //getClass 方法将返回一个对象所属的类
//经过上面的判断,可以确定 otherObject 是非 null 的 Employee 类的对象
Employee other = (Employee) otherObject;
return name.equals(other.name);
}
}
提示
为了防备 name 可能为 null 的情况,需要使用 Objects.equals 方法。如果两个参数都为 null,Objects.equals(a, b)调用将返回 true;如果其中一个参数为 null,则返回 false。
在子类中定义 equals 方法时,首先调用父类的 equals。如果检测失败,对象就不可能相等。如果父类中的字段都相等,就需要比较子类中的字段。
public class Manager extends Employee{
...
public boolean equals(Object otherObject){
if(!super.equals(otherObject)){
return false;
}
Manager other = (Manager) otherObject;
return bonus == other.bonus;
}
}
拓展知识
equals 方法设计如果隐式和显式参数不属于同一个类,equals 方法将会如何处理呢?在前面的例子中,如果发现类不匹配,equals 方法就会返回 false。但是,许多程序员却喜欢用 instanceof 进行检测:
if(!(otherObject instanceof Employee)) return false;
这样就允许 otherObject 属于这个类的子类。但是这种方法可能会招致麻烦,所以不建议采用这种处理方式。Java 语言规范要求 equals 方法具有下面的特性:
自反性:对于任何非空引用 x,x.equals(x) 应该返回 true。
对称性:对于任何引用 x 和 y,当且仅当 y.equals(x) 返回 true 时,x.equals(y) 返回 true。
传递性:对于任何引用 x、y 和 z,如果 x.equals(y) 返回 true,y.equals(z) 返回 true,那么 x.equals(x) 也应当返回 true
一致性:如果 x 和 y 引用的对象没有发生变化,反复调用 x.equals(y) 应该返回同样的结果。
对于任何非空引用 x,x.equals(null) 应该返回 false。
这些规则当然合理。不过,就对称性规则来说,当参数类型不属于同一个类时会产生一些微妙的结果。例如:
e.equals(m);
这里 e 是一个 Employee 对象,m 是一个 Manager 对象,并且两个对象有相同的状态。如果在 Employee.equals 中使用 instanceof 继续检测,这个调用将返回 true。然而反过来调用:
m.equals(e);
也需要返回 true。对称性规则不允许这个方法调用返回 false 或者抛出异常。这就使得 Manager 类受到了束缚。这个类的 equals 方法必须愿意将自己与任何一个 Employee 对象进行比较,而不考虑经理特有的那部分信息!这让人感觉使用 instanceof 测试并不是那么好。
就现在来看,有两种不同的情形:
- 如果子类可以有自己的相等性概念,则对称性需求将强制使用 getClass 检测。
- 如果由父类决定相等性概念,那么就可以使用 instanceof 检测,这样可以在不同子类的对象之间进行相等性比较。
下面是编写一个完美 equals 方法的建议:
显式参数命名为 otherObject,稍后再将它强制转换成另一个名为 other 的变量。
检测 this 与 otherObject 是否相等:
if (this == otherObject) return true;
这是一种经常采用的形式,因为检查身份要比逐个比较字段开销小。检测 otherObject 是否为 null,如果为 null,则返回 false,这项检测是很有必要的:
if (otherObject == null) return false;
比较 this 与 otherObject 的类。如果 equals 的语义可以在子类中改变,就使用 getClass 检测:
if (getClass() != otherObject getClass()) return false;
如果所有的子类都有相同的相等性语义,则可以使用 instanceof 检测:
if(!(otherObject instanceof ClassName)) return false;
- 将 otherObject 强制转换为相应类类型的变量:
ClassName other = (ClassName) otherObject;
- 现在根据相等性概念的要求来比较字段。使用 == 比较基本类型字段,equals 比较对象字段。如果所有的字段都匹配,就返回 true;否则返回 false。
return field1 == other.field1
&& Object.equals(field2, other.field2)
&& ...;
- 如果在子类中重新定义 equals,就要在其中包含一个
super.equals(other)
调用。
提示
对于数组类型的字段,可以使用静态的 Arrays.equals
方法检测相应的数组元素是否相等。
注意
下面是实现 equals 方法时一种常见的错误。
public class Employee{
...
public boolean equals(Employee other){
return other != null
&& getClass() == other.getClass
&& Objects.equals(name, other.name)
&& salary == other.salary;
}
...
}
这个方法声明的显示参数类型是 Employee。因此,它没有覆盖 Object 类的 equals 方法,而是定义了一个完全无关的方法。
为了避免这种错误,可以使用 @Override 标记要覆盖父类方法的子类方法:
@Override
public boolean equals(Object other){
...
}
如果出现了错误,并且正在定义一个新方法,编译器就会报告错误。
hashCode 方法
散列码(hash code)是由对象导出的一个整数值。散列码是没有规律的。如果 x 和 y 是两个不同的对象,x.hashCode() 与 y.hashCode() 基本上不会相同。下表列出了几个通过调用 String 类的 hashCode 方法得到的散列码。
字符串 | 散列码 |
---|---|
Hello | 69609650 |
World | 83766130 |
String 类使用以下算法计算散列码:
int hash = 0;
for(int i = 0; i < length(); i++){
hash = 31 * hash + charAt(i);
}
由于 hashCode 方法定义在 Object 类中,因此每个对象都有一个默认的散列码,其值由对象的地址得出。来看下面的例子:
var s = "OK";
var sb = new StringBuilder(s);
System.out.println(s.hashCode() + " | " + sb.hashCode());
var t = new String("OK");
var tb = new StringBuilder(t);
System.out.println(t.hashCode() + " | " + tb.hashCode());
下面是输出结果:
2524 | 2003749087
2524 | 931919113
可见,字符串 s 和 t 有着相同的散列码,这是因为 String 类的散列码是由内容导出的。而字符串构造器 sb 与 tb 却有着不同的散列码,因为在 StringBuilder 类中没有定义 hashCode 方法,而 Object 类的默认 hashCode 方法会从对象的地址得出散列码。
提示
如果重新定义了 equals 方法,就必须为用户可能插入散列表的对象重新定义 hashCode 方法。
equals 与 hashCode 的定义必须相容:如果 x.equals(y) 返回 true,那么 x.hashCode() 就必须与 y.hashCode 返回相同的值。例如,如果定义 Employee.equals 比较员工的 ID,那么 hashCode 方法就需要散列 ID,而不是员工的姓名或地址。
hashCode 方法应该返回一个整数。要合理地组合实例字段的散列码,以便能够让不同对象产生的散列码分布更加均匀。
toString 方法
在 Object 中还有一个重要的方法,就是 toString 方法,它会返回表示对象值的一个字符串。下面是一个典型的例子。Point 类的 toString 方法将会返回下面这样的字符串:
java.awt.Point[x=10,y=20]
绝大多数的 toString 方法都遵循这样的格式:类的名字,随后是一对方括号括起来的字段值。下面是 Employee 类中的 toString 方法的实现:
public String toString(){
return "Employee[name = " + name
+ ", salary = " + salary
+ ", hireDay = " + hireDay
+ "]";
}
提示
上面代码中的类名 Employee 最好通过调用 getClass().getName()
获得类名得字符串来替代,而不要将类名硬编码到 toString 方法中。
这样设计的 toString 方法也可以由子类调用。子类在定义自己得 toString 方法,并加入子类得字段时。如果父类使用了 getClass().getName()
,那么子类只要调用 super.toString()
就可以了。
如果 x 是任意对象,使用 System.out.println(x)
,println 方法会自动调用 x.toString(),并打印输出得到的字符串。
Object 类定义了 toString 方法,可以打印对象的类名和散列码。例如,调用 System.out.println(System.out)
会得到输出:java.io.PrintStream@2f6684
。得到这样的结果的原因是 PrintStream 类的实现者没有覆盖 toString 方法。
注意
数组继承了 Object 类的 toString 方法,更有甚者,数组类型将采用一种古老的格式打印。例如:
int[] num = {1, 2, 3, 4};
String s = "" + num;
会生成字符串 "[I@41629346"(前缀 [I 表明这是一个整型数组)。补救的方法是调用静态方法 Arrays.toString
。代码:
String s = Arrays.toString(num);
将输出字符串 "[1, 2, 3, 4]"。
要想打印多维数组(即数组的数组),则需要调用 Arrays.deepToString
方法。
拓展知识
ArrayList 集合在许多程序设计语言,如 C/C++ 语言中,必须在编译时就确定整个数组的大小。而在 Java 中,允许在运行时确定数组的大小。
int size = ...;
var staff = new Employee [size];
不过,这段代码并没有完全解决运行时动态更改数组的问题。一旦确定了数组的大小,就不容易改变它了。在 Java 中,解决这个问题最简单的方法是使用 Java 中的另外一个类,名为 ArrayList。ArrayList 类类似于数组,但在添加或删除元素时,它能够自动调整数组容量。
ArrayList 是一个有 类型参数(type parameter)的 泛型类(generic class)。为了指定数组列表保存的元素对象的类型,需要用一对尖括号来追加到 ArrayList 后面,如 ArrayList<Employee>
。
下面将介绍如何处理数组列表。
声明数组列表
声明和构造一个保存 Employee 对象的数组列表:
ArrayList<Employee> staff = new ArrayList<Employee>();
Java 10 以后,最好使用 var 关键字以避免重复写类名:
var satff = new ArrayList<Employee>();
如果没有使用 var 关键字,可以省去右边的类型参数:
ArrayList<Employee> staff = new ArrayList<>();
这称为 “菱形” 语法,因为空尖括号 <> 就像是一个菱形。可以结合 new 操作符使用菱形语法。编译器会检查新值要做什么。如果赋值给一个变量,或传递某个方法,或者从某个方法返回,编译器会检查这个变量、参数或方法的泛型类型,然后将这个类型放在 <> 中。在这个例子中,new ArrayList<>()
将赋值给一个类型为 ArrayList<Employee> 的变量,所以泛型类型为 Employee。
注意
如果使用 var 声明 ArrayList,就不要使用菱形语法。以下声明:
var elements = new ArrayList<>();
会生成一个 ArrayList<Object>
。
使用 add 方法可以将元素添加到数组列表中。例如,下面展示了如何将 Employee 对象添加到一个数组列表中:
staff.add(new Employee("Tom",...));
staff.add(new Employee("Bob",...));
数组列表管理着一个内部的对象引用数组。最终,这个数组的空间有可能全部用尽。这时,如果调用 add 而内部数组已经满了,数组列表就会自动创建一个更大的数组,并将所有的对象从较小的数组中拷贝到较大的数组中。
提示
如果已经知道或能够估计出数组可能存储的元素数量,就可以在填充数组之前调用 ensureCapacity 方法:
staff.ensureCapacity(100);
这个方法调用将分配一个包含 100 个对象的内部数组。这样一来,前 100 次 add 调用不会导致重新分配空间,造成开销过大。
另外,还可以把初始容量传递给 ArrayList 构造器:
ArrayList<Employee> staff = new ArrayList<100>;
- size 方法
size 方法将返回数组列表中包含的实际元素个数。例如:
staff.size();
将返回 staff 数组列表当前元素个数,它等价于数组 a 的 a.length。
- trimToSize 方法
一旦能够确认数组列表的大小将保持恒定,不在发生变化,就可以调用 trimToSize 方法。这个方法将存储块的大小调整为保存当前元素数量所需的存储空间。垃圾回收器将回收多余的存储空间。
一旦削减了数组列表的大小,添加新元素就需要花时间再次移动存储块,所以应该在确认不会再向数组列表添加元素时再调用 trimToSize。
数组和数组列表对比
数组长度固定,可以存基本数据类型也可以存引用数据类型;数组列表长度可变,可以存引用数据类型,而基本数据类型则需通过包装类来储存。
访问数组列表的元素
数组列表自动扩容便利的同时增加了访问元素语法的复杂度。我们不能使用简单的 [] 语法格式访问或改变数组的元素,而要使用 get 和 set 方法。
- set 方法
例如,要设置第 i 个元素,可以使用:
staff.set(i, Tom);
它等价于对数组 a 的元素赋值(与数组一样,下标值从 0 开始):
a[i] = Tom;
注意
只有当数组列表的大小大于 i 时,才能够调用 list.set(i, x)。例如,下面这段代码是错误的:
var list = new ArrayList<Employee>(100); //capacity 100, size 0
list.set(0, x); // no element 0 yet
要使用 add 方法为数组添加新元素,而不是 set 方法,set 方法只是用来替换数组中已经加入的元素。
- get 方法
要得到一个数组列表的元素,可以使用:
Employee e = staff.get(i);
这等价于:
Employee e = a[i];
提示
下面介绍一个技巧,这个技巧既可以灵活地扩展数组,又可以方便地访问数组元素。首先,创建一个数组列表,并添加所有的元素。
var list = new ArrayList<X>();
while(...){
x = ...;
list.add(x);
}
执行完上述操作后,使用 toArray 方法将数组元素拷贝到一个数组中。
var a = new X[list.size()];
list.toArray(a);
- add 方法
有时需要在数组列表的中间插入元素,为此可以使用 add 方法并提供一个索引参数。
int n = staff.size() / 2;
staff.add(n, e);
位置 n 及之后的所有元素都要向后移动一个位置,为新元素留出空间。插入新元素后,如果数组列表新的大小超过了容量,数组列表就会重新分配它的存储数组。
- remove 方法
同样的,可以从数组列表中删除一个元素:
Employee e = staff.remove(n);
位于这个位置之后的所有元素都向前移动一个位置,并且数组大小减 1。
数组列表插入和删除元素的操作效率都很低。对于较小得数组列表来说,不必担心这个问题。但如果存储得元素比较多,而且又经常需要在中间插入、删除元素,就应该考虑使用链表了。
- ArraysList 成员方法表
方法名 | 说明 |
---|---|
void add(int index, E e) | 向指定位置插入元素 |
boolean add(E e) | 添加元素,返回值表示是否添加成功 |
boolean remove(E e) | 删除指定元素,返回值表示是否删除成功 |
E remove(int index) | 删除指定索引的元素,返回被删除元素 |
E set(int index, E e) | 修改指定索引下的元素,返回原来的元素 |
E get(int index) | 获取指定索引的元素 |
int size() | 集合的长度,也就是集合中元素的个数 |
暂时跳过
对象包装器与自动装箱参数数量可变的方法
可以提供参数数量可变的方法(有时这些方法被称为 “变参方法”(varargs)方法)。
前面已经看到过这样一个方法:printf。例如,下面的方法调用:
System.out.printf("%d", n);
和
System.out.printf("%d, %s", n, "widgets");
这两条语句都调用同一个方法,不过前一个调用有两个参数,后一个调用有三个参数。
printf 方法是这样定义的:
public class PrintStream{
public PrintStream print(String fmt, Object... args){
return format(fmt, args);
}
}
这里的省略号 ... 是代码的一部分,它表明这个方法可以接收任意数量的对象(除 fmt 参数之外)。
实际上,printf 方法接收两个参数,一个是格式字符串,另一个是 Object[] 数组,其中保存着所有其他参数(如果调用者提供的是整数或者其他基本类型的值,会把它们自动装箱为对象)。同时不可避免地要扫描 fmt 字符串,并将第 i 个格式说明符与 arg[i] 的值完全匹配起来。
换句话说,对于 print 的实现者来说,Object... 参数类型与 Object[] 完全一样。
编译器需要转换每个 printf 调用,将参数绑定到数组中,并在必要的时候进行自动装箱:
System.out.printf("%d %s", new Object[]{new Integer(n), "widgets"});
当然,也可以自己定义有可变参数的方法,可以为参数指定任意类型,甚至是基本类型。下例:
public static double max(double... values){
double largest = Double.NEGATIVE_INFINITY;
for(double v : values){
if(v > largest){
largest = v;
}
}
return largest;
}
可以像下面这样调用这个方法:
double m = max(3.1, 40.4, -5);
编译器将 new double[]{3.1, 40.4, -5}
传递给 max 方法。
提示
如果一个已有方法的最后一个参数是数组,可以把它重新定义为有可变参数的方法,而不会破坏任何已有的代码。如果愿意,甚至可以将 main 方法声明为以下形式:
public static void main(String... args){
}
枚举类
下面是定义一个枚举类型的例子:
public enum Size{SMALL, MEDIUM, LARGE, EXTRA_LARGE};
实际上,这个声明定义的是一个类,它有四个实例,不可能构造新的对象。
因此,在比较两个枚举类型的值时,并不需要调用 equals,直接使用 “==” 就可以了。
如果需要的话,可以为枚举类型增加构造器、方法和字段。当然,构造器只是在构造枚举常量的时候调用。下例:
public enum Size{
SMALL("S"), MEDIUM("M"), LARGE("L"), EXTRA_LARGE("XL"); //调用枚举类构造函数
private String abbreviation;
private Size(String abbreviation){
this.abbreviation = abbreviation;
}
public String getAbbreviation(){
return abbreviation;
}
}
注意
枚举类的构造器总是私有的。可以省略 private 修饰符。但是如果声明一个 enum 构造器为 public 或 protected,则可能会出现语法错误。
- toString 方法
所有的枚举类型都是 Enum 类的子类。它们继承了这个类的许多方法。其中最有用的一个是 toString 方法,这个方法会返回枚举常量名。例如,Size.SMALL.toString()
将返回字符串 "SMALL"。
- valueOf 方法
toString 方法的逆方法是静态方法 valueOf。例如,以下语句:
Size s = Enum.valueOf(Size.class, "SMALL");
将 s 设置为 Size.SMALL。
每个枚举类型都有一个静态的 values 方法,它将返回一个包含全部枚举值的数组。例如,以下调用:
Size[] values = Size.values();
返回包含元素 Size.SMALL、Size.MEDIUM、Size.LARGE、Size.EXTRA_LARGE 的数组。
- oridinal 方法
oridinal 方法返回 enum 声明中枚举常量的位置,位置从 0 开始。例如:Size.MEDIUM.oridinal()
返回 1。
暂时跳过
反射继承的设计技巧
在本节的最后,下面将给出对设计继承很有帮助的一些技巧
- 将公共操作和字段放在父类中。
如,将姓名字段放在 Person 类中,而不是将它重复地放在 Employee 类和 Student 类中。
- 不要使用受保护的字段。
有些程序员认为,将大多数的实例字段定义为 protected 是一个不错的主意,这样子类就能在需要的时候访问这些字段。然而,protected 机制并不能够带来更多的保护,这有两方面的原因。第一,子类集合是无限制的,任何一个人都能够由你的类派生一个子类,然后编写代码直接访问 protected 实例字段,从而破坏了封装性。第二,在 Java 中,在同一个包中的所有类都可以访问 protected 字段,而不管它们是否是这个类的子类。
不过,protected 对于指示那些不提供一般用途而应该在子类中重新定义的方法很有用。
- 使用继承实现 “is-a” 关系。
使用继承很容易达到节省代码量的目的,但有时候也会被滥用。例如,假设需要定义一个 Contractor 类。钟点工有姓名和雇佣日期,但是没有工资。他们按小时计薪,并且不会因为加班而获得加薪。这似乎在诱导人由 Employee 类派生出子类 Contractor,然后再增加一个 hourlyWage 字段。
public class Contractor extends Employee{
private double hourlyWage;
...
}
不过,这并不是一个好主意。因为这样一来,每个钟点工对象中都同时包含工资和时薪这两个字段。在实现其他方法时可能会带来许多麻烦。与不采用继承相比,使用继承来实现最后反而会多写很多代码。
钟点工与员工之间不属于 “is-a” 关系。钟点工不是特殊的员工。
- 除非所有继承的方法都有意义,否则不要使用继承。
假设想编写一个 Holiday 类。毫无疑问,每个假日也是一天,并且一天可以用 GregorianCalendar 类的实例表示,因此可以使用继承。
class Holiday extends GregorianCalendar{
...
}
然而,在继承的操作中,假日集合不是 封闭的。GregorianCalendar 中有一个公共方法 add,这个方法可以将假日转换为非假日:
Holiday christmas;
christmas.add(Calendar.DAY_OF_MONTH, 12);
因此,继承对于这个例子来说并不合适。
需要指出的是,如果扩展 LocalDate 就不会出现这个问题。由于 LocalDate 类是不可变的,所以没有任何方法能够把假日变成非假日。
- 在覆盖方法时,不要改变预期的行为。
在子类中覆盖方法时,不要偏离最初的设计想法。
- 使用多态,而不要使用类型信息。
只要看到类似下面的代码
if(x if of type 1){
action1(x);
}else if(x if type 2){
action2(x);
}
action1 与 action2 表示的如果是相同的概念,就应该为这个概念定义一个方法,并将其放置在两个类型的父类或接口中,然后,就可以调用
x.action();
使用多态性固有的动态分配机制执行正确的动作。
使用多态方法或接口实现的代码比使用多个类型检测的代码更易于维护和扩展。
- 不要滥用反射。