3. [TD]:类
关键词:类、接口、继承、异常、多态
推荐阅读:
- [ref1]:类与接口一书第2章的第2.1、2.2、2.4和2.7节
- 第3.3节(String类)、第3.5节(ArrayList类)、第3.6节(Arrays类)
在练习 ELECTIONS 的第 1 部分中,未使用任何类。我们构建了一个解决方案,就像在 C 语言中构建的那样。现在,我们将引入 Java 类的概念。
3.1. Support
![]() | ![]() |
文件夹 [support / chap-03] 包含本章的 Eclipse 项目。
接下来我们将使用 JDK 1.8 版本,因为后续的一些项目需要该版本。若需了解当前使用的 JDK 版本,请按以下步骤操作:
![]() |
![]() |
- 在 [4] 中,所使用的 JRE(Java 运行时环境)。 此处的 JRE 实际上是 JDK(Java 开发工具包),此处为 [jdk1.8.0_60]。 如果不是 JDK,或者您的版本低于 1.8,请按以下步骤操作:[5-21];
![]() |
- 为 [8],即 Eclipse 当前默认使用的 JRE;
- 将 [11] 替换为 Eclipse 当前已知的 JDK 和 JRE;
![]() |
- 在 [15] 中,请选择 JDK 而不是 JRE。本文档使用的 Maven 项目需要 JDK;
![]() |
![]() |
- 在 [21] 中,有一个版本 >=1.8 的 JDK;
- 在 [22-23] 中,访问该项目的切面(同一 Eclipse 项目的不同视图);
![]() |
- 在 [24] 中,请确认您使用的 Java 版本 >=1.8;
3.2. 类 [ListeElectorale]
在C语言中,我们可能会使用一个结构体来表示参与选举的名单。它可以采用以下形式:
Java语言中并不存在“结构”这一概念。必须用“类”来代替它。因此,我们决定创建一个类来存储候选列表的信息。该类的框架如下:
package istia.st.elections;
public class ListeElectorale {
/**
* identité de la liste
*/
private int id;
/**
* nom de la liste
*/
private String nom;
/**
* nombre de voix de la liste
*/
private int voix;
/**
* nombre de sièges de la liste
*/
private int sieges;
/**
* indique si la liste est éliminée ou non
*/
private boolean elimine;
/**
* constructeur par défaut
*/
public ListeElectorale() {
}
/**
*
* @param nom String : le nom de la liste
* @param voix int : son nombre de voix
* @param sieges int : son nombre de sieges
* @param elimine boolean : son état éliminé ou non
*/
public ListeElectorale(int id,String nom, int voix, int sieges, boolean elimine) {
...
}
/**
*
* @return int : l'identifiant de la liste
*/
public int getId() {
...
}
/**
* initialise l'identifiant de liste
* @param id int : identifiant de la liste
* @throws ElectionsException si id<1
*/
public void setId(int id) {
...
}
/**
*
* @return String : le nom de la liste
*/
public String getNom() {
...
}
/**
* initialise le nom de la liste
* @param nom String : nom de la liste
* @throws ElectionsException si le nom est vide ou blanc
*/
public void setNom(String nom) {
...
}
/**
*
* @return int : le nombre de voix de la liste
*/
public int getVoix() {
...
}
/**
* initialise le nombre de voix de la liste
* @param voix int : le nombre de voix de la liste
*/
public void setVoix(int voix) {
...
}
/**
*
* @return int : le nombre de sièges de la liste
*/
public int getSieges() {
...
}
/**
* fixe le nombre de sièges de la liste
* @param sieges int : le nombre de sièges de la liste
*/
public void setSieges(int sieges) {
...
}
/**
*
* @return boolean : valeur du champ elimine
*/
public boolean isElimine() {
...
}
/**
*
* @param sieges int
*/
public void setElimine(boolean elimine) {
...
}
/**
*
* @return String : identité de la liste électorale
*/
public String toString() {
...
}
}
- 第 8 行:唯一标识列表的编号。此处并非必需,但为将来使用预留。
- 第13行:候选名单的名称。
- 第17行:该名单的得票数
- 第21行:该名单获得的席位数
- 第25行:布尔值,表示该名单是否被淘汰(得票率低于选举门槛)。
每个名为 [xyz] 的私有字段均可通过名为 [setXyz] 的方法进行初始化。而 [getXyz] 方法则用于获取私有字段 [xyz] 的值。 在特殊情况下,若 [xyz] 是一个布尔型字段,则方法 [getXyz] 可以被方法 [isXyz] 替代。 这些方法的特定命名遵循一项名为 JavaBean 的编码标准。因此,我们定义了以下公共方法:
- getId(第 48 行)、setId(第 57 行)
- getNom(第65行)、setNom(第74行)
- getVoix(第82行),setVoix(第90行)
- getSieges(第 98 行)、setSieges(第 106 行)
- isElimine(第 114 行),setElimine(第 122 行)
- 第 30-31 行:定义了一个无参数的构造函数。该构造函数允许创建一个 [ListeElectorale] 对象而不对其进行初始化。随后可通过 set 方法对其进行初始化。
- 第 40-42 行:定义了一个构造函数,用于创建 [ListeElectorale] 对象,同时初始化其五个私有字段。
- 第130-132行:定义了[toString]方法,该方法返回一个字符串,其中包含对象五个字段的值。
ListeElectorale类的测试程序可能如下:
package istia.st.elections.tests;
import istia.st.elections.ListeElectorale;
public class MainTest1ListeElectorale {
public static void main(String[] args) {
// 创建选民名册
ListeElectorale listeElectorale1 = new ListeElectorale(1, "A", 32000,
0, false);
// 显示名单信息
System.out.println("listeElectorale1=" + listeElectorale1);
// 修改席位数量
listeElectorale1.setSieges(2);
// 显示名单1的身份信息
System.out.println("listeElectorale1=" + listeElectorale1);
// 新建选民名册
ListeElectorale listeElectorale2 = listeElectorale1;
// 显示名单2信息
System.out.println("listeElectorale2=" + listeElectorale2);
// 席位数量变更
listeElectorale2.setSieges(3);
// 显示两个名单的身份信息
System.out.println("listeElectorale2=" + listeElectorale2);
System.out.println("listeElectorale1=" + listeElectorale1);
}
}
该测试的 Eclipse 环境可能如下所示:
![]() |
- [1]:该项目名为 [elections-02A]
- [2]:应用程序将被放置在一个包中,此处为 [istia.st.elections]
- [3]:[ListeElectorale.java]是类[ListeElectorale]的源代码
- [4]:测试类将被放置在一个包中,此处为 [istia.st.elections.tests]
- [5]:测试类 [MainTest1ListeElectorale]
执行上述程序后,屏幕显示如下:

待完成任务:参考上述内容,补全类 ListeElectorale 的代码。
3.3. 创建异常类 [ElectionsException]
在Java语言的各种异常类中,有一个名为[RuntimeException]的类。该类继承自[Exception]类,后者是所有异常类的根类。 [RuntimeException] 实例或其派生实例的特殊之处在于,无需对其进行声明或处理。这类异常被称为“非受控异常”。
让我们来看一个简单的示例。类 [BufferedReader] 的实例可用于从数据流中读取文本行。该类拥有一个方法 [readLine],其签名如下:
可以看出,该方法可能抛出类型为 [IOException] 的异常。该类的结构树如下:
类 [IOException] 继承自类 [Exception](第 3 行)。 编译器强制我们处理并声明 [java.lang.Exception] 类型或其派生类型的异常([RuntimeException] 分支除外,我们稍后将介绍该分支)。因此,要读取键盘输入的一行文本,我们必须编写类似以下代码:
再举一个例子。要将字符串转换为整数,可以使用静态方法 [Integer.parseInt],其签名如下:
参数 [s] 即是要转换为整数的字符串。可以看到该方法可能抛出类型为 [NumberFormatException] 的异常。该类的继承关系如下:
类 [NumberFormatException] 继承自类 [RuntimeException](第 4 行)。编译器并不强制我们处理和声明类型为 [java.lang.RuntimeException] 或其派生类的异常。因此,我们可以编写如下代码:
我们无需添加 [try - catch] 子类来处理 [Integer.parseInt](第 9 行)可能抛出的异常。
创建和使用从 [RuntimeException] 派生的异常类各有优缺点:
- 优点方面:代码更简洁
- 缺点方面:这可能会让人回想起 C 语言的编程方式,即每个函数都返回一个错误代码,而很少有人使用这种方式,正是为了使代码更简洁。当发生此类未处理的错误时,程序会崩溃,通常以一种不太优雅的方式。
我们决定创建一个特殊类,将应用程序 ELECTIONS 中可能发生的所有异常归入其中。该类将命名为 [ElectionsException],并继承自 [RuntimeException] 类。其代码如下:
package istia.st.elections;
public class ElectionsException extends RuntimeException {
private static final long serialVersionUID = 1L;
public ElectionsException() {
super();
}
public ElectionsException(String message) {
super(message);
}
public ElectionsException(Throwable cause) {
super(cause);
}
public ElectionsException(String message, Throwable cause) {
super(message, cause);
}
}
- 第1行:我们将该类放置在[istia.st.elections]包中;
- 第3行:该类继承自[RuntimeException]。因此,它属于未受控类;
- 第 4 行:一个序列化标识符,目前可以忽略;
- 在我们的应用程序中,我们将使用两种类型的构造函数:
- 如下所示,第15-17行中的经典构造函数:
在这种情况下,调用会抛出此类异常的方法可按以下方式进行处理:
// 异常测试
try {
listeElectorale2.setSieges(-3);
} catch (ElectionsException ex) {
System.err.println("L'exception suivante s'est produite : ["
+ ex.toString() + "]");
}
- (续)
- 或者第14-20行用于上报已发生的异常,将其封装为类型为[ElectionsException]的异常:
try {
...;
} catch (SQLException ex) {
// 封装异常
throw new ElectionsException("erreur de fermeture de la connexion à la BD",ex);
}
这种第二种方法的优势在于保留了第一个异常可能包含的信息。在这种情况下,调用会抛出此类异常的方法可以按以下方式处理它:
try {
...;
} catch (ElectionsException ex) {
System.out.println(ex.getMessage() + ", Cause : "+ ex.getCause().getMessage());
System.exit(1);
}
待完成的工作:修改类 ListeElectorale 的代码,使得当请求的初始化不正确时(例如将名称初始化为空字符串),set 方法会抛出类型为 [ElectionsException] 的异常。
此新版本的测试Eclipse项目可能如下所示:
![]() |
- [1]:该项目名为 [elections-02B]
- [2]:应用程序位于一个包中,此处为 [istia.st.elections]
- [3]:类 [ListeElectorale] 和 [ElectionsException]
- [4]:测试类被放置在一个包中,此处为 [istia.st.elections.tests]
- [5]:测试类 [MainTest1ListeElectorale]
已研究过的测试类 [MainTest1ListeElectorale] 经过微调,用于测试异常情况:
package istia.st.elections.tests;
import istia.st.elections.ElectionsException;
import istia.st.elections.ListeElectorale;
public class MainTest1ListeElectorale {
public static void main(String[] args) {
// 创建选民名册
ListeElectorale listeElectorale1 = new ListeElectorale(1, "A", 32000,
0, false);
// 显示名单信息
System.out.println("listeElectorale1=" + listeElectorale1);
// 修改席位数量
listeElectorale1.setSieges(2);
// 显示名单1的身份信息
System.out.println("listeElectorale1=" + listeElectorale1);
// 新建选民名册
ListeElectorale listeElectorale2 = listeElectorale1;
// 显示名单2信息
System.out.println("listeElectorale2=" + listeElectorale2);
// 席位数量变更
listeElectorale2.setSieges(3);
// 显示两个名单的身份信息
System.out.println("listeElectorale2=" + listeElectorale2);
System.out.println("listeElectorale1=" + listeElectorale1);
// 异常测试
try {
listeElectorale2.setSieges(-3);
} catch (ElectionsException ex) {
System.err.println("L'exception suivante s'est produite : ["
+ ex.toString() + "]");
}
}
}
- 第28行:尝试用一个被禁止的值初始化座位数
- 第30行:若发生异常,则显示异常信息
测试执行结果如下:

可以发现,当尝试使用无效值初始化座位数时(代码第28行),类[ListeElectorale]确实抛出了异常。
3.4. 单元测试类
前面的测试类型依赖于目视检查。我们通过查看屏幕上显示的内容是否符合预期来进行验证。这种方法在专业环境中并不推荐。测试应尽可能实现自动化,并力求无需人工干预。毕竟,人会感到疲劳,其验证测试的能力会随着一天的推移而逐渐减弱。
应用程序会随着时间推移而演进。每次更新后,都必须验证应用程序是否“退化”,c.a.d,即它是否仍能通过最初编写时所做的功能测试。这类测试被称为“回归测试”。稍具规模的应用程序可能需要数百个测试。 实际上,我们会测试应用程序中每个类的每个方法。这被称为单元测试。如果这些测试未实现自动化,可能会占用大量开发人员的时间。
为此开发了多种测试自动化工具。其中一款名为 [JUnit],这是一个用于管理测试的类库。我们将使用该工具对 [ListeElectorale] 类进行测试。
一个名为 JUnit(版本号 4.x)的测试程序结构如下:
package istia.st.elections.tests;
import org.junit.Assert;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
public class JUnitEssai {
@Before
public void avant() throws Exception {
System.out.println("tearUp");
}
@After
public void après() throws Exception {
System.out.println("tearDown");
}
@Test
public void t1() {
System.out.println("test1");
Assert.assertEquals(1, 1);
}
@Test
public void t2() {
System.out.println("test2");
Assert.assertEquals(1, 2);
}
}
- 第 1 行:该类位于 [istia.st.elections.tests] 包中;
- 第 11 行:标注为 [@Before] 的方法将在每次单元测试之前执行;
- 第 16 行:标注为 [@After] 的方法将在每次单元测试后执行;
- 第 21 行:带有注解 [@Test] 的方法是单元测试所测试的方法。带有注解 [@Test] 的方法将依次执行,除非测试人员另有指定,测试人员可以自行选择要测试的方法。 每次执行 [@Test] 方法之前,都会先执行 [@Before] 方法。每次执行 [@Test] 方法之后,都会先执行 [@After] 方法;
- 第 22-25 行:定义了一个名为 [t1] 的测试方法;
- 第 18 行:[Assert.assert*] 方法之一,用于验证断言。该类包含以下 [assert] 方法:
- assertEquals(表达式1, 表达式2):验证两个表达式的值是否相等。支持多种表达式类型(int、String、float、double、boolean、char、short)。如果两个表达式不相等,则抛出类型为 [AssertionFailedError ] 的异常,
- assertEquals(实数1, 实数2, delta):验证两个实数是否在误差delta范围内相等,即c.a.d abs(实数1-实数2)<=delta。 例如,可以编写 assertEquals(实数1, 实数2, 1E-6) 来验证两个值是否在 10⁻⁶ 误差范围内相等,
- assertEquals(message, expression1, expression2) 和 assertEquals(message, 实数1, 实数2, delta) 是用于指定错误消息的变体,该消息将与 [AssertionFailedError] 类型的异常相关联,当 [assertEquals] 方法失败时会抛出该异常,
- assertNotNull(Object) 和 assertNotNull(message, Object):验证 Object 不为 null,
- assertNull(Object) 和 assertNull(message, Object):验证 Object 是否等于 null,
- assertSame(Object1, Object2) 和 assertSame(message, Object1, Object2):验证引用 Object1 和 Object2 是否指向同一个对象,
- assertNotSame(Object1, Object2) 和 assertNotSame(message, Object1, Object2):验证引用 Object1 和 Object2 是否指向不同的对象;
- 第 24 行:此断言应通过;
- 第 30 行:此断言应失败;
在 Eclipse 环境中,可以按以下方式创建测试类 JUnit:
![]() |
- [1]:右键单击要添加测试类的包,然后选择 [JUnit / New / JUnit Test Case]
![]() |
- [1]:选择一个版本 JUnit;
- [2]:选择要创建测试类的文件夹;
- [3]:选择要创建测试类的包;
- [4]:测试类的名称;
- [5]:选择要包含在将要生成的类中的方法;
- [6]:已生成类 JUnitEssai
前一个向导生成的类几乎为空:
package istia.st.elections.tests;
import org.junit.Assert;
import org.junit.After;
import org.junit.Before;
public class JUnitEssai {
@Before
public void setUp() throws Exception {
}
@After
public void tearDown() throws Exception {
}
}
让我们按以下方式补充并修改上述代码:
package istia.st.elections.tests;
import org.junit.Assert;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
public class JUnitEssai2 {
@Before
public void avant() throws Exception {
System.out.println("tearUp");
}
@After
public void après() throws Exception {
System.out.println("tearDown");
}
@Test
public void t1() {
System.out.println("test1");
Assert.assertEquals(1, 1);
}
@Test
public void t2() {
System.out.println("test2");
Assert.assertEquals(1, 2);
}
}
在 Eclipse 中,右键单击测试类,然后选择 [Run as / JUnit test] 选项,即可执行该类:

执行此测试后,结果如下:

上图显示方法 [test2] 已失败。 每次测试失败时,都会关联一条错误消息。对于 [test2],即上文显示的那条。该消息指明了发生错误的行号(第 30 行)。第 30 行中,失败的调用是:
Assert.assertEquals(1, 2);
第一个参数称为预期值,第二个参数称为实际值。上文提到的 [test2] 错误信息表明,预期值为 2,但实际值为 3。
最后,各种测试方法在控制台上输出的消息如下:

这些消息表明,在每次测试方法执行之前和之后,[@Before] 和 [@After] 方法确实分别被调用。
测试类并不一定由开发人员亲自编写,也可以由编写应用程序规格说明的人员负责编写。 某些被称为 TDD(测试驱动开发)的开发方法主张,在编写待测试类之前先编写测试类。这有时有助于澄清那些否则可能被多义解释的规格说明。
现在,我们为类 [ListeElectorale] 创建一个名为 [JUnitTest1ListeElectorale] 的测试类。在 Eclipse 中,操作步骤如前所述:
![]() | ![]() |
我们按以下方式补充向导生成的代码:
package istia.st.elections.tests;
import org.junit.Assert;
import istia.st.elections.ElectionsException;
import istia.st.elections.ListeElectorale;
import org.junit.Test;
public class JUnitTest1ListeElectorale {
@Test
public void t1() {
// 创建选民名册
ListeElectorale liste = new ListeElectorale(1, "a", 32000, 0, false);
// 验证
Assert.assertEquals("a", liste.getNom());
Assert.assertEquals(32000, liste.getVoix());
Assert.assertEquals(false, liste.isElimine());
Assert.assertEquals(0, liste.getSieges());
// ID有效性验证
boolean erreur = false;
try {
liste.setId(-4);
} catch (ElectionsException e) {
erreur = true;
}
Assert.assertEquals(true, erreur);
// 姓名有效性验证
erreur = false;
try {
liste.setNom("");
} catch (ElectionsException e) {
erreur = true;
}
Assert.assertEquals(true, erreur);
// 选票有效性验证
erreur = false;
try {
liste.setVoix(-4);
} catch (ElectionsException e) {
erreur = true;
}
Assert.assertEquals(true, erreur);
// 席位有效性验证
erreur = false;
try {
liste.setSieges(-4);
} catch (ElectionsException e) {
erreur = true;
}
Assert.assertEquals(true, erreur);
}
}
运行测试后得到以下结果:

测试成功。现在我们可以认为[ListeElectorale]类已可正常运行。
3.5. MainElections:版本 2
推荐阅读:
- [1]:类与接口一书第2章的第2.1、2.2、2.4和2.7节
- 第 3.3 节(String 类)、第 3.5 节(ArrayList 类)、第 3.6 节(Arrays 类)
我们希望重写应用程序 [Elections],并添加以下新约束:
- 将使用类 [ListeElectorale] 来表示候选列表
- 应用程序将通过键盘请求以下信息:
- 待填补的席位数量
- 列表的名称和声音。事先并不知道有多少个列表。最后一个列表将通过名称为字符串“*”来标识。
- 由于事先无法确定名单数量,这些名单将首先存储在一个类型为 [ArrayList] 的对象中。随后,当所有名单均已录入后,它们将被转移到一个名单数组中。
- 结果将按获得席位数从高到低的顺序显示。
要对数组 T 进行排序,可以使用 [Arrays] 类的各种静态方法:
- Arrays.sort(T):若数组 T 具有自然排序顺序(如数字按升序、日期按升序、字符串按字母顺序等),则按该顺序对其进行排序
- Arrays.sort(T,comparateur):用于对没有自然排序顺序的数组 T 进行排序。本例中,待排序的列表数组需根据列表的特定字段(即获得的席位数)进行排序。
在方法 Arrays.sort(T,比较器) 中,参数“比较器”是一个实现了以下 Comparator 接口的对象:

- compare 方法用于比较数组 T 中的两个元素
- equals 方法用于判断两个对象是否相等
这两个方法比较的是 Object 类型 obj1 和 obj2。 究竟是 obj1<obj2、obj1=obj2 还是 obj1>obj2 更大,取决于希望在两个对象之间建立的排序关系。具体如何判断:
- obj1 小于 obj2
- obj1 大于 obj2
- obj1 等于 obj2
所有 Java 类都继承自 Object 类,该类已自带 [equals] 方法。若要对类型为 O 的对象数组 T 进行排序,O 类的 [equals] 方法并无用处。因此,可以保留 Object 类提供的默认实现。 此时只需实现 [compare] 方法。该方法会被 [Arrays.sort] 方法反复调用。后者每次都会将待排序数组 T 中的两个元素 obj1 和 obj2 作为参数传递给 compare 方法。 在本例中,这些元素的类型为 [ListeElectorale]。此处需注意多态性的应用。方法 [compare] 被定义为接收 [Object] 类型的参数。 这意味着它可以接收类型为 [Object] 或其派生类型的参数(多态性)。由于 [Object] 是所有 Java 类的父类,因此实际参数可以是类型 [ListeElectorale]。
对于升序排序,方法 [compare] 应返回:
- -1,若 obj1 小于 obj2
- +1,若 obj1 大于 obj2
- 若 obj1 等于 obj2,则返回 0
若按降序排序,则 +1 和 -1 的值互换。 术语“小于”、“大于”、“等于”表达了一种排序关系。对于 [ListeElectorale] 类型的对象,若列表1的票数少于列表2,则列表1“小于”列表2。
在与类 [MainElections] 相同的源文件中,可以添加第二个类:
- 第 2 行:该类未声明为 public。在 Java 源文件中,可以包含多个类,但只有一个类可以具有 public 属性,即与源文件同名的类。
在之前的 compare 方法中, 参数类型为 Object,,这导致第 7 行和第 8 行必须将方法参数从类型 Object 转换为类型 ListeElectorale。 方法 compare 的签名由接口 Comparator 强制规定,该接口是为比较任意对象而编写的。 自 JDK 1.5 版本起,存在一个通用的 Comparator 接口:Comparator<T>,其中 T 是任意 Java 类型。 接口 Comparator<T> 中的方法 compare 用于比较 T 类型的对象,而非 Object 类型的对象,从而避免了之前的类型转换。 用于比较 ListeElectorale 类型对象的类可能如下所示:
// 选民名单比较类
class CompareListesElectorales implements Comparator<ListeElectorale> {
// 根据席位数量比较两份选民名单
public int compare(ListeElectorale listeElectorale1,
ListeElectorale listeElectorale2) {
...
}
}
- 第 2 行:该类实现了 Comparator<ListeElectorale> 接口
- 第 5-6 行:方法 compare 的参数类型为 ListeElectorale。现在不再需要类型转换。
JDK 1.5 引入了泛型类/接口的概念,适用于 JDK 1.4 中的多种类/接口,这些类/接口最初仅处理 Object 类型的对象。列表、字典等便是如此。
我们在前文提到,由于无法预知列表的数量,因此无法将其存储在数组中。这些列表可以存储在一个实现“对象列表”概念的 ArrayList 对象中。 该类存储 Object 类型的对象。自 JDK 1.5 版本起,已支持类型化对象列表。 因此,在将列表传输到数组之前,将使用 ArrayList<ListeElectorale> 对象来存储这些列表。如果该数组名为 tListes,则可通过以下语句对其进行排序:
// 候选人名单排序
Arrays.sort(tListes, new CompareListesElectorales());
其中 CompareListesElectorales 是实现 Comparator<ListeElectorale> 接口的类。
待完成任务:根据这些新规范重写 [Elections] 应用程序。
Eclipse 项目可能如下所示:
![]() |
[1] 的运行示例如下:
















