设计契约中有哪些合理的前置条件?

4
假设我们有一个名为Student的类,其构造函数如下:
/** Initializes a student instance.
 * @param matrNr    matriculation number (allowed range: 10000 to 99999)
 * @param firstName first name (at least 3 characters, no whitespace) 
 */
public Student(int matrNr, String firstName) {
    if (matrNr < 10000 || matrNr > 99999 || !firstName.matches("[^\\s]{3,}"))
        throw new IllegalArgumentException("Pre-conditions not fulfilled");
    // we're safe at this point.
}

如果我说错了,请纠正我,但我认为在这个例子中,我遵循了按合同设计的范例,只是简单地规定了可能输入值的(相当静态的)约束,并且如果这些约束没有被满足就引发了一个通用的未经检查的异常。
现在,有一个后端类来管理学生列表,通过他们的注册号进行索引。它持有一个Map<Integer, Student>来保存这个映射,并通过一个addStudent方法提供对它的访问:
public void addStudent(Student student) {
    students.put(student.getMatrNr(), student);
}

现在假设这个方法有一个限制条件,比如说“数据库中不能已经存在相同学号的学生”。
我可以想到两种实现方式:

方案A

定义一个自定义的UniquenessException类,如果已经存在相同学号的学生,则由addStudent引发此异常。调用代码将类似于以下内容:
try {
    campus.addStudent(new Student(...));
catch (UniquenessError) {
    printError("student already existing.");
}

选项B

将要求作为前置条件声明,如果不符合,则简单地引发IAE。此外,提供一个方法canAddStudent(Student stud),事先检查addStudent是否会失败。调用代码将类似于:

Student stud = new Student(...);
if (campus.canAddStudent(stud))
    campus.addStudent(stud);
else
    printError("student already existing.");

从软件工程的角度来看,我认为选项A更加清晰,原因如下:

  • 它可以轻松地使调用代码线程安全(感谢Voo指向 TOCTTOU,该链接似乎描述了这个确切的问题)

因此,我想知道:

  1. 是否有第三种选项更好?
  2. 选项B有我没想到的优点吗?
  3. 从设计合同的角度来看,使用选项B并将唯一性定义为addStudent方法的前提条件是否允许?
  4. 何时定义前提条件并仅引发IAE,何时使用“适当”的异常?我认为“除非它取决于系统的当前状态,否则将其作为前提条件”可能是这样一个规则。还有更好的吗?

更新:看起来还有另一个不错的选择,即提供一个public boolean tryAddStudent(...)方法,它不会抛出异常,而是使用返回值来指示错误/失败。


1
99999作为入学号的上限似乎有些低了。在我的大学里,不到12年就会超出这个范围。 - Fabian Barney
@Fabian:我实际上并不是在尝试编写一些学生管理软件。这只是一个例子(我没有提到吗?) - Niklas B.
2个回答

2
在您的选项B中,我不会使用Map<Integer,Student>然后执行以下操作:
if (campus.hasStudent(12000)) 
    printError("student already existing.");
else
    campus.addStudent(new Student(...));

如果您的使用场景不太适合使用 Map 抽象类(您提到了并发问题),我建议您使用 ConcurrentMap<Integer,Student>,然后按照以下方式操作:

final Student candidate = new Student(...);
final Student res = putIfAbsent(student.getMatrNr(), candidate)
if ( res != null ) {
    throw new IllegalStateException("Class contract violation: \"student already exists!\", please read the doc");
}

那么我们可以同样使用一个布尔型的返回值来表示成功或失败吗? - Niklas B.
1
实际上,我认为应该是 if (res == null) - Voo
1
@Voo:糟糕...代码已修复。在这种情况下,我们使用*if (res != null)*,因为如果已经有一个值,我们想要抛出异常。 - TacticalCoder
@NiklasB.:“那么我们可以使用布尔返回值来表示成功或失败?”我并不是说你应该(或不应该)在你的情况下使用异常。我只是说,使用常规的Map抽象并不是很强大,因为你必须自己处理同步问题。所以,改用ConcurrentMap,并利用它的putIfAbsent方法(它已经为你处理了讨厌的同步问题)。现在由你决定是否要使用返回值(如putIfAbsent所做的),布尔值或抛出异常(我只是评论Map)。 - TacticalCoder
@user988052:是的,这个问题实际上是针对在这里使用异常还是前置条件是否明智,而不是如何使线程安全(毕竟,即使使用手动同步,这也很容易)。但感谢提示,测试和设置方法也适用于实际问题(提供类似“tryAddStudent”方法的内容)。 - Niklas B.
@NiklasB.:嘿嘿,我知道,这就是我在“答案”中开头加上“这个太长了,不适合放在评论里”的原因。但我提到它是因为你提到选项A更容易做到“线程安全”:) - TacticalCoder

2

我认为后端类管理学生列表的方式与合同无关,也就是说,它持有一个Map<Integer, Student> 不属于合同的一部分。因此,在hasStudent(int matrNr)中引入注册号似乎也有点不好。

我建议校园可能应该有一个方法Boolean hasStudent(Student student),它将根据任何条件检查校园是否拥有该学生。如果合同要求唯一性,并且确实是异常情况,那么您可以使用合同检查:

   Student student= new Student(int matrNbr, String name);
   if (campus.hasStudent(student) {
      throw new UniquenessException();
   }
   else {
      campus.add(student);
   }

抛出的异常应与合同、参数和返回值相关。如果添加操作不需要满足唯一性,也不会引发异常,则不要抛出异常。相反,将添加操作的成功作为返回值(如java.util.HashSet.add())。这样,如果实际上添加了学生,则campus.add(Student)将返回true

布尔返回值与异常相比有什么优势? - Niklas B.
异常表明特殊情况,必须进行处理。检查布尔值是可选的--调用类可能会或可能不会关心学生的新实例是否实际添加。此外,异常是昂贵的,因为它们必须打包堆栈跟踪等信息。 - Matthew Flynn
感谢您的澄清 :) - Niklas B.

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接