第三章 继承

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

一、类、超类和子类

我们来回忆一下在前一章中讨论过的 Employee 类。假如你在某个公司工作,这个公司里经理的待遇与普通员工的待遇存在着一些差异。不过,他们之间也存在着很多相同的地方,例如,他们都领取薪水。只是普通员工在完成本职工作之后仅领取薪水,而经理在完成了预期的业绩之后还能得到奖金。这种情形就需要使用继承。为什么呢?因为需要为经理定义一个新类 Manager,并增加一些新功能。但可以重用 Employee 类中已经编写的部分代码,并保留原来 Employee 类中的所有字段。

1、定义子类

可以如下继承 Employee 类来定义 Manager 类,这里使用关键字 extends 表示继承。

public class Manager extends Employee {
	...
}

关键字 extends 表明正在构造的新类派生于已存在的类。这个已存在的类称为超类、基类或父类;新类称为子类、派生类或孩子类。超类和子类是最常用的两个术语。

尽管 Employee 类是一个超类,但并不是它优于子类或者拥有比子类更多的功能。实际上恰恰相反,子类比超类拥有的功能更多。

在 Manager 类中,增加了一个用于存储奖金信息的字段,以及一个用于设置这个字段的新方法:

public class Manager extends Employee {
	private double bonus;
	...
	public void setBonus(double bonus) {
		this.bonus = bonus;
	}
}

这样定义的方法和字段并没有什么特别之处。如果有一个 Manager 对象,就可以使用 setBonus 方法。

Manager boss = ...;
boss.setBonus(5000);

当然,由于 setBonus 方法不是在 Employee 类中定义的,所有属于 Employee 类的对象不能使用它。

然而,尽管在 Manager 类中没有显式地定义 getName 和 getHireDay 等方法,但是可以用 Manager 对象使用这些方法,这是因为 Manager 类自动地继承了超类 Employee 中的这些方法。

类似地,从超类中还继承了 name、salary 和 hireDay 这 3 个字段。这样一来,每个 Manager 对象就包含了 4 个字段:name、salary、hireDay 和 bonus。

通过扩展超类定义子类的时候,只需要指出子类与超类的不同之处。因此在设计类的时候,应该将最一般的方法放在超类中,而将更特殊的方法放在子类中,这种将通用功能抽取到超类的做法在面向对象程序设计中十分普遍。

2、覆盖方法

超类中的有些方法对子类 Manager 并不一定适用/。具体来说,Manage 类中的 getSalary 方法应该返回薪水和奖金的总和。为此,需要提供一个新的方法来覆盖(override)超类中的这个方法。

public class Manager extends Employee {
	...
	public double getSalary() {
		...
	}
	...
}

应该如何实现这个方法呢?乍看起来似乎很简单,只需要返回 salary 和 bonus 字段的总和就可以了:

public double getSalary() {
	return salary + bonus; // won't work
}

不过,这样做是不行的。回想一下,只有 Employee 方法能直接访问 Employee 类的私有字段。这意味着,Manager 类的 getSalary 方法不能直接访问 salary 字段。如果 Manager 类的方法想要访问那些私有字段,就要像所有其他方法一样使用公共接口,在这里就是要使用 Employee 类中的公共方法 getSalary:

public class getSalary() {
	double baseSalary = getSalary(); // still won't work
	return baseSalary + bonus;
}

上面这段代码仍然有问题。问题出现在调用 getSalary 的语句上,它只是在调用自身,这是因为 Manager 类也有一个 getSalary 方法(就是我们正在实现的这个方法),所以这条语句将会导致无限次地调用自己,知道整个程序最终崩溃。

这里需要指出:我们希望调用超类 Employee 中的 getSalary 方法,而不是当前类的这个方法。为此,可以使用特殊的关键字 super 来解决这个问题:super.getSalary()

这个语句调用的是 Employee 类中的 getSalary 方法。下面是 Manager 类中 getSalary 方法的正确版本:

public double getSalary() {
	double baseSalary = super.getSalary();
	return baseSalary + bonus;
}

注意:在子类中可以增加字段、增加方法或覆盖超类的方法,不过,继承绝对不会删除任何字段或方法。

3、子类构造器

我们来提供一个 Manaer 类的构造器:

public Manager(String name, double salary, int year, 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 参数的构造器“ 的简写形式。

由于 Manager 类的构造器不能访问 Employee 类的私有字段,所有必须通过一个构造器来初始化这些字段。可以利用特殊的 super 语法调用这个构造器。使用 super 调用构造器的语句必须是子类构造器的第一条语句。

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

回想一下,关键字 this 有两个含义:一是指示隐式参数的引用,二是调用该类的其他构造器。类似地,super 关键字也有两个含义:一是调用超类的方法,二是调用超类的构造器。在调用构造器的时候,this 和 super 这两个关键字紧密相关。调用构造器的语句只能作为另一个构造器的第一条语句出现。构造器参数可以传递给当前类(this)的另一个构造器,也可以传递给超类(super)的构造器。

重新定义 Manager 对象的 getSalary 方法之后,奖金就会自动地添加到经理的薪水中。

下面给出一个例子来说明这个类的使用。我们要创建一个新经理,并设置他的奖金:

Manager boss = new Manager("Carl Cracker", 80000, 1987, 12, 15);
boss.setBonus(5000);

下面定义一个包含 3 个员工的数组:

var staff = new Employee[3];

在数组中混合填入经理和员工:

staff[0] = boss;
staff[1] = new Employee("Harry Hacker", 50000, 1989, 10, 1);
staff[2] = new Employee("Tony Tester", 40000, 1990, 3, 15);

输出每个人的薪水:

for(Employee e: staff)
	System.out.println(e.getName() + " " + e.getSalary());

运行这条循环语句将会输出下列数据:

Carl Cracker 85000.0
Harry Hacker 50000.0
Tony Tester 40000.0

这里的 staff[1] 和 staff[2] 仅输出了基本薪水,这是因为它们是 Employee 对象,而 staff[0] 是一个 manage 对象,它的 getSalary 方法会将奖金与基本薪水相加。

需要提醒大家的是,以下调用

e.getSalary()

能够选出应该执行的正确 getSalary 方法。请注意,尽管这里将 e 声明为 Employee 类型,但实际上 e 既可以引用 Employee 类型的对象,也可以引用 Manage 类型的对象。

当 e 引用 Employee 对象时,e.getSalary() 调用的是 Employee 类中的 getSalary 方法;当 e 引用 Manager 对象时,e.getSalary() 调用的是 Manager 类中的 getSalary 方法。虚拟机知道 e 实际引用的对象类型,因此能够正确地调用相应的方法。

一个对象变量(例如,变量 e)可以指示多种实际类型的现象称为多态。在运行时能够自动地选择适当的方法,称为动态绑定。

4、多态

有一个简单规则可以用来判断是否应该将数据设计为继承关系,这就是 “is-a” 规则,它指出子类的每个对象也是超类的对象。例如,每个经理都是员工,因此,将 Manager 类设计为 Employee 类的子类是有道理的;反之则不然,并不是每一名员工都是经理。

“is-a” 规则的另一种表述是替换原则。它指出程序中出现超类对象的任何地方都可以使用子类对象替换。

例如,可以将子类的对象赋值给超类变量。

Employee e;
e = new Employee(...);
e = new Manager(...);

在 Java 程序设计语言中,对象变量是多态的。一个 Employee 类型的变量既可以引用一个 Employee 类型的对象,也可以引用 Employee 类的任何一个子类的对象。

Manager boss = new Manager(...);
Employee[] staff = new Employee[3];
staff[0] = boss;

在这个例子中,变量 staff[0] 与 boss 引用同一个对象。但编译器只将 staff[0] 看成是一个 Employee 对象。

这意味着,可以这样调用 boss.setBonus(5000),但不能这样调用 staff[0].setBonus(5000),这是因为 staff[0] 声明的类型是 Employee,而 setBonus 不是 Employee 类的方法。

不过,不能将超类的引用赋给子类变量。例如,下面的赋值是非法的:

Manager m = staff[i]; // ERROR

原因很清楚:不是所有员工都是经理。如果赋值成功,m 有可能引用了一个不是经理的 Employee 对象,而在后面可能会调用 m.setBonus(…),这就会发生运行时错误。

5、阻止继承:final 类和方法

有时候,我们可能希望组织人们利用某个类定义子类。不允许扩展的类被称为 final 类。如果在定义类的时候使用了 final 修饰符就表明这个类是 final 类。例如。假设希望阻止人们派生 Executive 类的子类,就可以在声明这个类的时候使用 final 修饰符。声明格式如下所示:

public final class Executive extends Manager {
	...
}

类中的某个特定方法也可以被声明为 final。如果这样做,子类就不能覆盖这个方法(final 类中 所有方法自动地成为 final 方法)。例如

public class Employee {
	...
	public final String getName() {
		return name;
	}
	...
}

注意:前面曾经说过,字段也可以声明为 final。对于 final 字段来说,构造对象之后就不允许改变它们的值了。不过,如果将一个类声明为 final,只有其中的方法自动地称为 final,而不包括字段。

6、抽象类

从某种程度上看,祖先类更有一般性,人们只将它作为派生其他类地基类,而不是用来构造你想使用的特定的示例。例如,考虑扩展 Employee 类层次结构。员工是一个人,学生也是一个人。下面扩展我们的类层次结构来加入类 Person 和类 Student。

为什么要那么麻烦提供这样一个高层次地抽象呢?每个人都有一些属性,如姓名。学生与员工都有姓名属性,因此铜鼓哦引入一个公共的超类,我们就可以把 getName 方法放在继承层次结构中更高的一层。

现在,再增加一个 getDescription 方法,它可以返回对一个人地简短描述。

在 Employee 类和 Student 类中实现这个方法很容易。但是在 Person 类中应该提供什么内容呢?除了姓名之外,Person 类对这个人一无所知。当然,可以让 Person.getDescription() 返回一个空字符串。不过还有一个更好的方法,就是使用 abstract 关键字,这样就完全不需要实现这个方法了。

public abstract String getDescription();

为了提高程序的清晰度,包含一个或多个抽象类方法的类本身必须被声明为抽象的。

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;
	}
}

抽象方法完全充当着占位方法的角色,它们在子类中具体实现。扩展抽象类可以有两种选择。一种是在子类中保留抽象类中的部分或所有对象方法仍未定义,这样就必须将子类也标记为抽象类;另一种方法是定义全部方法,这样一来,子类就不是抽象的了。

例如,通过扩展抽象 Person 类,并实现 getDescription 方法来定义 Student 类。由于在 Student 类中不再含有抽象方法,所以不需要将这个类声明为抽象类。

即使不含抽象方法,也可以将类声明为抽象类。

抽象类不能被实例化。也就是说,如果将一个类声明为 abstract,就不能创建这个类的对象。例如,表达式 new Person("Vince Vu") 是错误的,但可以创建一个具体子类的对象。

需要注意,可以定义一个抽象类的对象变量,但是这样一个变量只能引用非抽象子类的对象。例如,

Person p = new Student("Vince Vu", "Economics");

这里的 p 是一个抽象类型 Person 的变量,它引用了一个非抽象子类 Student 的实例。

在 Java 程序设计语言中,抽象方法是一个重要的概念。

7、受保护访问

我们已经知道,最好将类中的字段都标记为 private,而方法标记为 public。任何声明为 private 的内容对其他类都是不可见的。前面已经看到,这对于子类来说也完全适用,即子类也不能访问超类的私有字段。

不过,在有些时候,我们可能希望限制超类中的某个方法或字段只允许子类访问。为此,需要将这些类方法或字段声明为受保护(protected)。例如,如果将超类 Employee 中的 hireDay 字段,而不是 private,这样,子类 Manager 方法就可以直接访问这个字段。

在 Java 中,保护字段只能由同一个包中的类访问。即如果一个子类在另一个不同的包中,它就不能够访问超类中的字段或方法。有了这个限制,就能避免滥用保护机制,不能通过派生子类来访问受保护的字段。

受保护的方法更具有实际意义。如果需要限制某个方法的调用,就可以将它声明为 protected。这表明子类得到了信任,可以正确地使用这个方法,而其他类则不行。

这种方法有一个很好的示例就是 Object 类中的 clone 方法,想了解的小伙伴可以去深入研究。

下面对 Java 中 4 个访问控制修饰符做个小结:

  1. 仅对本类可见——private。
  2. 对外部完全可见——public。
  3. 对本包和所有子类——protected。
  4. 对本包可见——默认,不需要修饰符。

二、所有类的超类

Object 类是 Java 中所有类的始祖,在 Java 中每个类都扩展了 Object。但是并不需要这样写:

public class Employee extends Object

如果没有明确地指出超类,Object 就被认为是这个类的超类。由于在 Java 中每个类都是由 Object 类扩展而来的,所以,熟悉这个类提供的所有服务十分重要。

1、Object 类型的变量

可以使用 Object 类型的变量引用任何类型的对象:

Object obj = new Employee("Harry Hacker", 35000);

当然,Object 类型的变量只能用于作为各种值的一个泛型容器。要想对其中的内容进行具体的操作,还需要清楚对象的原始类型,并进行相应的强制类型转换:

Employee e = (Employee) obj;

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

所有的数组类型,不管是对象数组还是基本类型的数组都扩展了 Object 类。

2、equals 方法

Object 类中的 equals 方法用于检测一个对象是否等于另外一个对象。Object 类中实现的 equals 方法将确定两个对象引用是否相等。这是一个合理的默认行为:如果两个对象引用相等,这两个对象就肯定相等。对于很多类来说,这已经足够了。例如,比较两个 PrintStream 对象是否相等并没有多大的意义。不过,经常需要基于状态检测对象的相等性,如果两个对象有相同的状态,才认为这两个对象是相等的。

例如,如果两个员工对象的姓名、薪水和雇佣日期都一样,就认为它们是相等的(在实际的员工数据库中,比较 ID 才能有意义。我们主要用这个示例展示 equals 方法的实现机制)。

public class Employee {
	...
	public boolean equals(Object otherObject) {
		if (this == otherObject) return true;

		if (otherObject == null) return false;

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

		Employee other = (Employee) otherObject;

		return name.equals(other.name)
			&& salary == other.salary
			&& hireDay.equals(other.hireDay);
	}
}

getClass 方法将返回一个对象所属的类,有关这个方法的详细内容我们稍后介绍。在我们的检测中,只有在两个对象属于同一个类时,才有可能相等。

提示:为了防备 name 或 hireDay 可能为 null 的情况,需要使用 Object.equals 方法。如果两个参数都为 null,Object.equals(a, b) 调用将返回 true;如果其中一个参数为 null,则返回 false;否则,如果两个参数都不为 null,则调用 a.equals(b)。

在子类中定义 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;
	}
}

3、hashCode 方法

散列码(hash code)是由对象导出的一个整数值。散列码是没有规律的。如果 x 和 y 是两个不同的对象,x.hashCode() 与 y.hasCode() 基本上不会相同。

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());

结果如下:

对象散列码对象散列码
s2556t2556
sb20526976tb20527144

请注意,字符串 s 与 t 有相同的散列码,这是因为字符串的散列码是由内容导出的。而字符串构建器 sb 与 tb 却有着不同的散列码,因为在 StringBuilder 类中没有定义 hashCode 方法,而 Object 类的默认 hashCode 方法会从对象的存储地址得出散列码。

hashCode 方法应该返回一个整数(也可以是负数)。要合理地组合实例字段的散列码,以便能够让不同对象产生的散列码分布更加均匀。

例如,下面是 Employee 类的 hashCode 方法:

public class Employee {
	public int hahCode() {
		return 7 * name.hashCode()
			+ 11 * new Double(salary).hashCode()
			+ 13 * hireDay.hashCode();
	}
	...
}

不过,还可以做得更好。首先,最好使用 null 安全的方法 Objects.hashCode。如果其参数为 null,这个方法会返回 0,否则返回对参数调用 hashCode 的结果。另外,使用静态方法 Double.hashCode 来避免创建 Double 对象:

public int hahCode() {
		return 7 * Objects.hashCode(name)
			+ 11 * Double.hashCode(salary)
			+ 13 * Objects.hashCode(hireDay);
	}

还有更好的做法是,需要组合多个散列码时,可以调用 Objects.hash 并提供所有这些参数。这个方法会对各个参数调用 Objects.hashCode,并组合这些散列值。这样 Employee.hashCode 方法可以简单地写成为:

public int hashCode() {
	return objects.hash(name, salary, hireDay);
}

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

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

4、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
		+ "]";
}

实际上,还可以设计得更好一些。最好通过调用 getClass().getName() 获得类名的字符串,而不要将类名硬编码写到 toString 方法中。

public String toString() {
	return getClass.getName()
		+ ",salary=" + salary
		+ ",hireDay=" + hireDay
		+ "]";
}

这样 toString 方法也可以由子类调用。

当然,设计子类的时候应该定义自己的 toString 方法,并加入子类的字段。如果超类使用了 getClass().getName(),那么子类只要调用 super.toString() 就可以了。

随处可见 toString 方法的主要原因是:只要对象与一个字符串通过操作符 “+” 连接起来,Java 编译器就会自动地调用toString 方法来获得这个对象的字符串描述。例如:

var p = new Point(10, 20);
String message = "The current position is " + p;
	// 这里调用了 p.toString()

强烈建议为自定义的每一个类添加 toString 方法。这样做不仅自己受益,所有使用这个类的程序员也会从这个日志记录中受益匪浅。

java.lang.Object

  • Class getClass()
    返回包含对象信息的类对象。
  • boolean equals(Object otherObject)
    比较两个对象是否相等,如果两个对象指向同一块存储区域,方法返回 true;否则方法返回 false。要在自定义的类中覆盖这个方法。
  • String toString()
    返回表示该对象值的字符串。要在自定义的类中覆盖这个方法。

java.lang.Class

  • String getName()
    返回这个类的方法。
  • Class getSuperClass()
    以 Class 对象的形式返回这个类的超类。

三、泛型数组列表

对于数组容量,在很多程序设计语言中,必须在编译时就确定整个数组的大小,这有时令人很头疼。而在 Java 中,解决这个问题最简单的方法就是使用 Java 中的 ArrayList 类。ArrayList 类类似于数组,但在添加或删除元素时,它能够自动地调整数组容量,而不需要为此编写任何代码。

ArrayList 是一个有类型参数的泛型类。为了指定数组列表保存的元素对象的类型,需要用一对尖括号将类名括起来追加到 ArrayList 后面,例如, ArrayList<Employee>

1、声明数组列表

声明和构造一个一个保存 Employee 对象的数组列表:

ArrayList<Employee> staff = new ArrayList<Employee>();

在 Java 10 中,最好使用 var 关键字以避免重复写类名:

var staff = new ArrayList<Employee>();

如果没有使用 var 关键字,可以省去右边的类型参数:

ArrayList<Employee> staff = new ArrayList<>();

这称为 “菱形” 语法,因为空尖括号 <> 就像是一个菱形。

注意:如果使用 var 声明 ArrayList,就不要使用菱形语法。以下声明:var elements = new ArrayList<>(),会生成一个 ArrayList<Objecct>。

使用 add 方法可以将元素添加到数组列表中。例如,下面展示了如何将 Employee 对象添加到一个数组列表中:

staff.add(new Employee("Harry Hacker", ...));
staff.add(new Employee("Tony Tester", ...));

数组列表管理着一个内部的对象引用数组。最终,这个数组的空间有可能全部用尽。这时就显现出数组列表的魅力了:如果调用 add 而内部数组已经满了,数组列表就会自动创建一个更大的数组,并将所有的对象从较小的数组中拷贝到较大的数组中。

如果已经知道或能够狗基础数组可能存储的元素数量,就可以在填充数组之前调用 ensureCapacity 方法:staff.ensureCapacity(100);

这个方法调用将分配一个包含 100 个对象的内部数组。这样一来,前 100 次 add 调用不会带来开销很大的重新分配空间。

另外,还可以把初始容量传递给 ArrayList 构造器:

ArrayList<Employee> staff = new ArrayList<>(100);

size 方法将返回数组列表中包含的实际元素个数。例如,staff.size(),将返回 staff 数组列表的当前元素个数,它等价于数组 a 的 a.length。

一旦能够确认数组列表的大小将保持恒定,不再发生变化,就可以调用 trimToSize 方法。这个方法将存储块的大小调整为保存当前元素数量所需要的存储空间。垃圾回收器将回收多余的存储空间。

一旦消减了数组列表的大小,添加新元素就需要花时间再次移动存储块,所以应该在确认不会再向数组列表添加任何元素时再调用 trimToSize。

java.util.ArrayList<>

  • ArrayList()
    构造一个空数组列表。
  • ArrayList(int initialCapacity)
    用指定容器构造一个空数组列表。
  • boolean add(E obj)
    在数组列表的末尾追加一个元素。永远返回 true。
  • int size()
    返回当前存储在数组列表中的元素个数。(当然,这个值永远不会大于数组列表的容量。)
  • void ensureCapacity(int capacity)
    确保数组列表在不重新分配内部存储数组的情况下有足够的容量存储给定数量的元素。
  • void trimToSize()
    将数组列表的存储容量削减到当前大小。

2、访问数组列表元素

当我们想要访问或改变数组的元素时,需要使用 get 和 set 方法。

例如,要设置第 1 个元素,可以使用:staff.set(i, harry),它等价于对数组 a 的元素赋值(与数组一样,下标值从 0 开始):a[i] = harry;

要得到一个数组列表的元素,可以使用:Employee e = staff.get(i),这等价于:Employee e = a[i]

注意:没有泛型类时,原始的 ArrayList 类提供的 get 方法别无选择,只能返回 Object,因此,get 方法的调用者必须对返回值进行强制类型转换:Employee e = (Employee) staff.get(i)。

有时需要在数组列表的中间插入元素,为此可以使用 add 方法并提供一个索引参数。

in n = staff.size() / 2;
staff.add(n, e);

位置 n 及之后的所有元素都要向后移动一个位置,为新元素留出空间。插入新元素后,如果数组列表新的大小超过了容量,数组列表就会重新分配它的存储数组。

同样地,可以从数组列表中间删除一个元素:

Employee e = staff.remove(n);

位于这个元素之后的所有元素都向前移动一个位置,并且数组的大小减 1。

插入和删除元素的操作效率很低。对于较小的数组列表来说,不必担心这个问题。但如果存储的元素数比较多,又经常需要在中间插入、删除元素,就应该考虑使用链表了。

java.util.ArrayList< E >

  • E set(int index, E obj)
    将值 obj 放置在数组列表的指定索引位置,返回之前的内容。
  • E get(int index)
    得到指定索引位置存储的值。
  • void add(int index, E obj)
    后移元素从而将 obj 插入指定索引位置。
  • E remove(int index)
    删除指定索引位置的元素,并将后面的所有元素前移。返回所删除的元素。

3、对象包装器与自动装箱

有时,需要将 int 这样的基本类型转换为对象。所有的基本类型都有一个与之对应的类。例如,Integer 类对应基本类型 int。通常,这些类称为包装器(Wrapper)。这些包装器类有显而易见的名字:Integer、Long、Float、Double、Short、Byte、Character 和 Boolean(前 6 个类派生于公共的超类 Number)。包装器类是不可变的,即一旦构造了包装器,就不允许更改包装在其中的值。同时,包装器类还是 final,因此不能派生它们的子类。

假设想要定义一个整型数组列表。遗憾的是,尖括号中的类型参数不允许时基本类型,也就是说,不允许写成 ArrayList<int>。这里就可以用到 Integer 包装器类。我们可以声明一个 Integer 对象的数组列表。

var list = new ArrayList<Intger>();

注意:由于每个值分别包装在对象中,所以 ArrayList<Integer> 的效率远远低于 int[] 数组。因此,只有当方便性比执行效率更重要的时候,才会考虑对较小的集合中使用这种构造。

幸运的是,有一个很有用的特性,从而可以很容易地向 ArrayList<Integer> 添加 int 类型的元素。下面这个调用 list.add(3),将自动地变换成 list.add(Integer.valueOf(3))。这种变换称为自动装箱。

相反地,当将一个 Integer 对象赋值给一个 int 值时,将会自动地拆箱。也就是说,编译器将以下语句:

int n = list.get(i);

转换成

int n = list.get(i).intValue();

自动地装箱和拆箱甚至也适用于算术表达式。例如,可以将自增运算应用于一个包装器引用:

Integer n = 3;
n++;

编译器将自动地插入一条对象拆箱的指令,然后进行自增计算,最后再将结果装箱。

大多数情况下容易有一种假象,认为基本类型与它们的对象包装器是一样的。但它们有一点有很大不同:同一性。我们知道,== 运算符可以应用于包装器对象,不过检测的是对象是否有相同的内存位置,因此,下面的比较通常会失败:

Integer a = 1000;
Integer b = 1000;
if (a == b) ...

不过,Java 实现却有可能让它成立。如果将经常出现的值包装到相同的对象中,这种比较就可能成功。这种不确定的结果并不是我们所希望的。解决这个问题的办法是再比较两个包装器对象时调用 equals 方法。

另外,如果一个条件表达式中混合使用 Integer 和 Double 类型,Integer 值就会拆箱,提升为 double,再装箱为 Double:

Integer n = 1;
Double x = 2.0;
System.out.println(true ? n : x); // prints 1.0

java.lang.Integer

  • int intValue()
    将这个 Integer 对象的值作为一个 int 返回(覆盖 Number 类中的 intValue 方法)。
  • static String toString(int i)
    返回一个新的 String 对象,表示指定数值 i 的十进制表示。
  • static String toString(int i, int ratix)
    返回数值 i 基于 radix 参数指定进制的表示。
  • static int parseInt(String s)
  • static int parseInt(String s, int radix)
    返回字符串 s 表示的整数,指定字符串必须表示一个十进制整数(第一种方法),或者采用 radix 参数指定的进制(第二种方法)。
  • static Integer valueOf(String s)
  • static Integer valueOf(String s, int radix)
    返回一个新的 Integer 对象,用字符串 s 表示的整数初始化。指定字符串必须表示一个十进制整数(第一种方法),或者采用 radix 参数指定的进制(第二种方法)。

java.text.NumberFormat

  • Number parse(String s)
    返回数字值,假设给定的 String 表示一个数值。

4、参数数量可变的方法

可以提供参数数量可变的方法。

前面已经看到过这样一个方法:pringf。例如,下面的方法调用:

System.out.printf("%d", n);
System.out.printf("%d %s", n, "widgets");

这两条语句都调用同一个方法,不过一个调用两个参数,另一个调用有三个参数。

printf 方法是这样定义的:

public class PrintStream {
	public PrintStream printf(String fmt, Object... args) {
		return format(fmt, args);
	}
}

这里的省略号 … 是 Java 代码的一部分,它表明这个方法可以接收任意数量的对象(除 fmt 参数之外)。

编译器需要转换每个 printf 调用,将参数绑定到数组中,并在必要的时候进行自动装箱:

System.out.printf("%d %f", new Object[] {new Integer(n), "widgets"} );

5、枚举类

下面是一个典型的枚举类型的例子:

public enum Size { SMALL, MEDIUM. LARGE, EXTRA_LARGE }

实际上,这个声明定义的类型是一个类,它刚好有 4 个实例,不可能构造新的对象。

因此,在比较两个枚举类型的值时,并不需要调用 equals,直接使用 “==” 就可以了。

如果需要的话,可以为枚举类型增加构造器、方法和字段。当然构造器只是在构造枚举常量的时候调用。下面是一个实例:

public enum Size {
	SMALL("S"), MEDIUM("M"), LARGE("L"), EXTRA_LARGE("XL");

	private String abbreviation;

	private Size(String abbreviation) {
		this.abbrevation = abbrevation;
	}
	
	public String getAbbrevation() {
		return abbrevation;
	}
}

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

所有的枚举类型都是 Enum 类的子类。它们继承了这个类的许多方法。其中最有用的一个是 toString,这个方法会返回枚举常量名。例如,Size.SMALL.toString() 将返回字符串 “SMALL”。

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 的数组。

java.lang.Enum< E >

  • static Enum valueOf(Class enumClass, String name)
    返回给定类中有指定名字的枚举常量。
  • String toString()
    返回枚举常量名。
  • int ordinal()
    返回枚举常量在 enum 声明中的位置,位置从 0 开始。
  • int compareTo(E other)
    如果枚举常量出现在 other 之前,返回一个负整数;如果 this == other,则返回 0;否则,返回一个正整数。枚举常量的出现次序在 enum 声明中给出。

四、反射

反射库提供了一个丰富且精巧的工具集,可以用来编写能够动态操纵 Java 代码的程序。使用反射,Java 可以支持用户界面生成器、对象关系映射以及很多其他需要动态查询类能力的开发工具。

1、Class 类

在程序运行期间,Java 运行时系统始终为所有对象维护一个运行时类型标识。这个信息会跟踪每个对象所属的类。虚拟机利用运行时类型信息选择要执行的正确地方法。

不过,可以使用一个特殊的 Java 类访问这些信息。保存这些信息的类名为 Class,这个名字有些让人困惑。Object 类中的 getClass() 方法将会返回一个 Class 类型的实例。

Employee e;
...
Class cl = e.getClass();

就像 Employee 对象描述一个特定员工的属性一样,Class 对象会描述一个特定类的属性。可能最常用的 Class 方法就是 getName。这个方法将返回类的名字。

如果类在一个包里,包的名字也作为类名的一部分。

还可以使用静态方法 forName 获得类名对应的 Class 对象。

String className = "java.util.Random";
Class cl = Class.forName(className);

虚拟机为每个类型管理一个唯一的 Class 对象。因此,可以利用 == 运算符实现两个类对象的比较。例如,

if (e.getClass() == Employee.class) ...

如果 e 是一个 Employee 实例,这个测试将通过。与条件 e instanceof Employee 不同,如果 e 是每个子类(如 Manager)的实例,这个测试将失败。

如果有一个 Class 类型的对象,可以用它构造类的实例。调用 getConstructor 方法将得到一个 Constructor 类型的对象,然后使用 newInstance 方法来构造一个实例。例如:

var className = "java.util.Random";
Class cl = Class.forName(className);
Object obj = cl.getConstructor().newInstance();

如果这个类没有无参数的构造器,getConstructor 方法会抛出一个异常。

java.lang.Class

  • static Class forName(String className)
    返回一个 Class 对象,表示名为 className 的类。
  • Constructor getConstructor(Class… parameterTypes)
    生成一个对象,描述有指定参数类型的构造器。

java.lang.reflect.Constructor

  • Object newInstance(Object… params)
    将 params 传递到构造器,来构造这个构造器声明类的一个新实例。

java.lang.Throwable

  • void printStackTrace()
    将 Throwable 对象和堆栈轨迹打印到标准错误流。

五、继承的设计技巧

最后,提供一些对设计继承很有帮助的一些技巧。

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

这就是将姓名字段放在 Person 类中,而没有将它重复放在 Employee 和 Student 类中的原因。

2、不要使用受保护的字段。

有些人认为,将大多数的实例字段定义为 protected 是一个不错的注意,“以防万一”,这样子类就能够在需要的时候访问这些字段。然而,protected 机制并不能够带来更多的保护,这有两方面的原因。第一,子类集合是无限制的,任何一个人都能够由你的类派生一个子类,然后编写代码直接访问 protected 实例字段,从而破坏了封装性。第二,在 Java 中,在同一个包中的所有类i都可以访问 protected 字段,而不管它们是否为这个类的子类。

3、使用继承实现 “is-a” 关系。

使用继承很容易达到节省代码的目的,但有时候也被人们滥用了。例如, 假设需要定义一个钟点工类。 钟点工的信息包含姓名和雇佣日期, 但是没有薪水。 他们按小时计薪, 并且不会因为拖延时间而获得加薪。这似乎在诱导人们由 Employee 派生出子类 Contractor, 然后 再增加一个 hourlyWage 域。

public class Contractor extends Employee {
   private double hourlyWage; 
}

这并不是一个好主意。 因为这样一来, 每个钟点工对象中都包含了薪水和计时工资这两个域。 在实现打印支票或税单方法耐候, 会带来诞的麻烦, 并且与不采用继承, 会多写很制戈码。

原因就在于:钟点工与雇员之间不属于“ is-a” 关系。钟点工不是特殊的雇员。

4、除非所有继承的方法都有意义, 否则不要使用继承。

假设想编写一个 Holiday 类毫无疑问, 每个假日也是一日, 并且一日可以用 GregorianCalendar 类的实例表示, 因此可以使用继承。

class Holiday extends CregorianCalendar { . . , }

很遗憾, 在继承的操作中, 假日集不是封闭的。 在 GregorianCalendar 中有一个公有方法 add, 可以将假日转换成非假日:

Holiday Christmas;
Christmas.add(Calendar.DAY_OF_MONTH, 12);

因此, 继承对于这个例子来说并不太适宜。
需要指出, 如果扩展 LocalDate 就不会出现这个问题。 由于这个类是不可变的, 所以没有任何方法会把假日变成非假日。

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

置换原则不仅应用于语法, 而且也可以应用于行为,这似乎更加重要。 在覆盖一个方法的时候, 不应该毫无原由地改变行为的内涵。就这一点而言, 编译器不会提供任何帮助, 即编译器不会检查重新定义的方法是否有意义。

例如,可以重定义Holiday类中add方法“ 修正” 原方法的问题, 或什么也不做, 或抛出一个异常, 或继续到下一个假日。

然而这些都违反了置换原则。

6、使用多态, 而非类型信息。

无论什么时候, 对于下面这种形式的代码:

if (x is oftype1)
	ction1(x);
else if (x is oftype2)
	action2(y); 

都应该考虑使用多态性。
使用多态方法或接口编写的代码比使用对多种类型进行检测的代码更加易于维护和扩展。

7、不要滥用反射

反射机制使得人们可以通过在运行时查看域和方法, 让人们编写出更具有通用性的程序。
这种功能对于编写系统程序来说极其实用, 但是通常不适于编写应用程序。
反射是很脆弱的, 即编译器很难帮助人们发现程序中的错误, 因此只有在运行时才发现错误并导致异常

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

一支帆

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值