Java 中的断言是什么?在什么时候应该使用它们?

721

有哪些现实生活中的例子可以帮助我们理解Java assert关键字的重要作用?


11
在现实生活中,你几乎从未见过它们。推测:如果您使用断言,您需要考虑三种状态:断言通过,断言失败,断言已关闭,而不仅仅是两种。默认情况下,断言已关闭,因此这是最可能的状态,并且很难确保它对于您的代码是启用的。这意味着断言是一种过早的优化,其用途有限。正如您在@Bjorn的答案中所看到的,甚至很难想出一个用例,您不希望始终失败断言。 - Yishai
43
@Yishai:如果你需要这样做,那么你的做法就是错误的。断言是一种过早优化且使用有限的技术。这几乎是离谱的。以下是Sun公司对此的看法:“在Java技术中使用断言”,这也很值得阅读:“使用断言编程的好处(也称为断言语句)”。 - David Tonhofer
6
@DavidTonhofer,在现实生活中,你几乎不会看到它们。这是可以验证的。可以检查尽可能多的开源项目。我并不是说你不验证不变量。那不是同一件事。换句话说,如果断言如此重要,为什么默认情况下关闭? - Yishai
20
参考资料,仅供参考:软件断言与代码质量之间的关系:“我们还将软件断言与流行的漏洞查找技术(如源代码静态分析工具)的有效性进行比较。通过我们的案例研究,我们观察到,随着文件中断言密度的增加,故障密度显著降低。” - David Tonhofer
7
断言可以被关闭,这意味着您不能保证它们在生产环境中会"触发",而这正是您最需要的地方。 - Thorbjørn Ravn Andersen
显示剩余9条评论
20个回答

482

断言(通过assert关键字)在Java 1.4中添加。它们用于验证代码中的不变量的正确性。它们永远不应该在生产代码中触发,并且表明代码路径的错误或误用。它们可以通过java命令上的-ea选项在运行时激活,但默认情况下未启用。

一个例子:

public Foo acquireFoo(int id) {
    Foo result = (id > 50) ? fooService.read(id) : new Foo(id);

    assert result != null;

    return result;
}

89
实际上,Oracle建议您不要使用 assert 来检查公共方法参数(http://docs.oracle.com/javase/1.4.2/docs/guide/lang/assert.html)。这应该抛出一个异常而不是终止程序。 - SJuan76
21
但是你仍然没有解释它们为什么存在。为什么不能做一个if()检查并抛出异常? - El Mac
11
@ElMac - 断言(assertions) 是用于开发/调试/测试阶段的,而不是用于生产环境。if 块会在生产环境中运行。简单的断言不会对系统造成重大影响,但执行复杂数据验证的昂贵断言可能会导致生产环境崩溃,因此在生产环境中关闭了它们。 - hoodaticus
2
@hoodaticus,你的意思是说我可以在生产代码中打开/关闭所有断言的事实是原因吗?因为我可以进行复杂的数据验证,然后使用异常处理它。如果我有生产代码,我可以关闭复杂(可能昂贵)的断言,因为它应该可以工作并已经经过测试了?理论上,它们不应该使程序崩溃,因为否则你就会遇到问题。 - El Mac
12
“assert”语句的引入对此规则没有影响。不要使用assert语句来检查公共方法的参数。这是因为该方法保证始终执行参数检查,因此assert语句是不适当的。无论是否启用assert语句,该方法都必须检查其参数。此外,assert语句不会抛出指定类型的异常,它只能抛出AssertionError。 - Bakhshi
显示剩余2条评论

375

假设你需要编写一个控制核电站的程序。很明显,即使是最小的错误也可能会产生灾难性的后果,因此你的代码必须没有漏洞(为了论证而假设JVM没有漏洞)。

Java不是可验证的语言,这意味着:你无法计算出你操作的结果将是完美的。这主要是由于指针引起的:它们可以指向任何地方或不存在,因此它们不能被计算为这个确切的值,至少在合理的代码范围内是无法计算的。鉴于这个问题,在整体上无法证明你的代码是正确的。但你能做的是,在每次发现漏洞时都能够证明它。

这个想法基于按契约设计(DbC)范式:你首先定义(用数学精度)你的方法应该做什么,然后在实际执行期间通过测试来验证这一点。例如:

// Calculates the sum of a (int) + b (int) and returns the result (int).
int sum(int a, int b) {
  return a + b;
}

虽然这很明显可以正常工作,但大多数程序员不会看到其中隐藏的错误(提示:Ariane V号发生了类似的错误导致坠毁)。现在,DbC定义了您必须始终检查函数的输入和输出以验证其是否正确运行。Java可以通过断言来实现此操作:

// Calculates the sum of a (int) + b (int) and returns the result (int).
int sum(int a, int b) {
    assert (Integer.MAX_VALUE - a >= b) : "Value of " + a + " + " + b + " is too large to add.";
  final int result = a + b;
    assert (result - a == b) : "Sum of " + a + " + " + b + " returned wrong sum " + result;
  return result;
}
如果这个函数出现问题,你会注意到它。你会知道你的代码有问题,你知道它在哪里,以及是什么导致了问题(类似于异常)。更重要的是,当它发生时,你停止执行以防止任何进一步的代码使用错误的值并潜在地损害它所控制的任何内容。
Java异常是一个类似的概念,但它们无法验证所有内容。如果你想要更多的检查(以执行速度为代价),你需要使用断言。这样做会让你的代码变得臃肿,但最终你可以在惊人短的开发时间内交付产品(越早修复一个bug,成本就越低)。此外:如果代码中有任何错误,你都会检测到,没有任何bug会悄悄溜走并在后面引起问题。
这仍然不能保证无错代码,但它比通常的程序更接近这个目标。

36
我选择这个例子是因为它很好地展示了看似没有bug的代码中隐藏的错误。如果这与其他人呈现的类似,那么他们可能有相同的想法。;) - TwoThe
10
你选择使用断言是因为当断言条件不成立时,它会失败。而 if 语句可以有任何行为。探寻极端情况是单元测试的工作。使用契约式设计可以很好地规定合同,但就像现实生活中的合同一样,你需要进行控制以确保它们得到遵守。通过插入一个看门狗,断言就可以在合同被违反时发出警告。就像一位唠叨的律师在每次你执行或违反签署的合同时大喊“WRONG",然后让你回家,以防止你继续工作并进一步违反合同! - Eric Tobias
5
在这种简单情况下是否必要:不需要,但是设计按照DbC的规定,__每个__结果都必须被检查。想象一下现在有人把这个函数修改成更加复杂的形式,那么他也必须调整后置检查,然后它就会突然变得有用起来。 - TwoThe
4
抱歉再次提问,但我有一个具体的问题。 @TwoThe 的做法与仅使用断言(assert)并带有消息的new IllegalArgumentException抛出有什么区别? 除了要在方法声明中添加“throws”和在其他地方处理该异常的代码外,我的意思是。为什么要使用“assert”,而不是抛出新的异常?或者为什么不使用“if”而不是“assert”?我真的搞不清楚:( - Blueriver
24
如果a可以是负数,那么检查溢出的断言是错误的。第二个断言是无用的;对于int值,a + b - b == a总是成立的。只有当计算机基本上损坏时,该测试才会失败。为了防范这种情况,需要在多个CPU之间检查一致性。 - kevin cline
显示剩余14条评论

84

断言是开发阶段的工具,用于捕获代码中的错误。它们被设计成易于移除,因此在生产代码中不存在。因此,断言不是您交付给客户的“解决方案”的一部分。它们是内部检查,以确保您正在做出正确的假设。最常见的例子是测试 null 值。许多方法都是这样编写的:

void doSomething(Widget widget) {
  if (widget != null) {
    widget.someMethod(); // ...
    ... // do more stuff with this widget
  }
}

在像这样的方法中,小部件通常不应为空。因此,如果它为空,则代码中必定存在错误,您需要追踪该错误。但是上面的代码永远不会告诉您这一点。因此,在撰写“安全”代码的良好意图下,您也隐藏了一个错误。编写以下代码要好得多:

/**
 * @param Widget widget Should never be null
 */
void doSomething(Widget widget) {
  assert widget != null;
  widget.someMethod(); // ...
    ... // do more stuff with this widget
}

这样做可以确保您尽早捕获此错误。(在合同中指定该参数永远不应为null也很有用。) 在开发期间测试代码时,请务必打开断言功能。(说服同事这样做通常很困难,我觉得这很烦人。)

现在,一些同事会反对这段代码,认为您仍应将空检查放入以防止在生产中出现异常。在这种情况下,断言仍然很有用。您可以这样编写:

void doSomething(Widget widget) {
  assert widget != null;
  if (widget != null) {
    widget.someMethod(); // ...
    ... // do more stuff with this widget
  }
}

这样,您的同事会高兴地看到 null 检查用于生产代码,但在开发期间,当 widget 为 null 时,您不再隐藏错误。

这里有一个实际的例子:我曾经编写了一个方法,用于比较两个任意值是否相等,其中任一值都可以为 null:

/**
 * Compare two values using equals(), after checking for null.
 * @param thisValue (may be null)
 * @param otherValue (may be null)
 * @return True if they are both null or if equals() returns true
 */
public static boolean compare(final Object thisValue, final Object otherValue) {
  boolean result;
  if (thisValue == null) {
    result = otherValue == null;
  } else {
    result = thisValue.equals(otherValue);
  }
  return result;
}

当thisValue不为空时,这段代码委托equals()方法的工作。但是它假设equals()方法正确地履行了处理null参数的equals()契约。

我的同事对我的代码提出了反对意见,告诉我我们许多类的equals()方法存在错误,没有测试null,所以我应该在这个方法中加入检查。这是否明智,或者我们应该强制报错,以便我们可以发现并修复它们,这是有争议的,但我听从了我的同事,并添加了一个null检查,我已经用注释标记:

public static boolean compare(final Object thisValue, final Object otherValue) {
  boolean result;
  if (thisValue == null) {
    result = otherValue == null;
  } else {
    result = otherValue != null && thisValue.equals(otherValue); // questionable null check
  }
  return result;
}

这里的额外检查,other != null,只有在equals()方法未按其合同要求检查null时才是必需的。

与其与同事进行关于让错误代码留在我们的代码库中的争论不休,我只是在代码中放置了两个断言。这些断言将在开发阶段让我知道,如果我们的某个类未能正确实现equals(),那么我就可以修复它:

public static boolean compare(final Object thisValue, final Object otherValue) {
  boolean result;
  if (thisValue == null) {
    result = otherValue == null;
    assert otherValue == null || otherValue.equals(null) == false;
  } else {
    result = otherValue != null && thisValue.equals(otherValue);
    assert thisValue.equals(null) == false;
  }
  return result;
}

记住以下几点:

  1. 断言只是开发阶段使用的工具。

  2. 断言的作用是让您知道是否存在 bug,不仅在您的代码中,还有在您的 代码库 中。(这里的断言实际上会标记其他类中的错误。)

  3. 即使我的同事对我们的类很有信心,这里的断言仍然很有用。新的类可能没有测试 null,而这个方法可以为我们标记这些错误。

  4. 在开发过程中,您应该始终打开断言,即使您编写的代码不使用断言。我的 IDE 默认设置为始终这样做以进行任何新的可执行文件。

  5. 断言在生产中不改变代码的行为,所以我的同事很高兴空检查在那里,并且即使 equals() 方法有 bug,该方法也将正确执行。我很高兴因为我可以在开发中捕获任何有缺陷的 equals() 方法。

此外,您应该通过放置一个临时断言来测试您的断言策略,以便您确信是否通过日志文件或输出流中的堆栈跟踪通知您失败。


“隐藏错误”的好处以及断言在开发过程中如何暴露错误的优点! - Brent Bradburn
这些检查都不会影响速度,因此在生产环境中没有关闭它们的理由。它们应该转换为日志记录语句,以便您可以检测到在“开发阶段”中未显示出来的问题。(实际上,无论如何都不存在所谓的“开发阶段”。当您决定停止完全维护代码时,开发就结束了。) - Aleksandr Dubinsky

30

什么时候应该使用Assert?

有很多好答案解释了assert关键字的作用,但很少有人回答真正的问题:"在实际中什么情况下应该使用assert关键字?" 答案是:

几乎从不使用

作为一个概念,断言是很棒的。良好的代码有许多if (...) throw ...语句(及其相关语句如Objects.requireNonNullMath.addExact)。然而,某些设计决策极大地限制了assert关键字本身的效用。

assert关键字背后的驱动思想是过早优化,主要特点是能够轻松关闭所有检查。实际上,assert检查默认是关闭的。

然而,保持不变量检查在生产环境中继续进行非常重要。这是因为完美的测试覆盖率是不可能的,所有的生产代码都会有错误,而断言应该有助于诊断和缓解这些错误。

因此,应该优先使用if (...) throw ...,就像对于检查公共方法的参数值和抛出IllegalArgumentException一样需要使用它。

偶尔,你可能会想编写一个不希望花费过长时间来处理的不变量检查(而且被调用的次数足够多,以至于这很重要)。然而,这种检查会减慢测试速度,这也是不希望看到的。这样耗时的检查通常被编写为单元测试。尽管如此,有时候出于这个原因使用assert也是有意义的。

不要仅仅因为assert更加简洁美观就使用它,而不是使用if(...) throw ...(我说这话感到非常痛苦,因为我喜欢代码整洁美观)。如果你真的无法忍受,并且可以控制应用程序的启动方式,那么可以使用assert,但在生产环境中一定要启用断言。 诚然,这是我倾向于做的。我正在推动一个Lombok注解,使assert的行为更像if (...) throw ...在这里投票支持它。

(抱怨:JVM开发人员是一群可怕的、过早优化的编码者。这也是为什么Java插件和JVM出现了如此多的安全问题的原因。他们拒绝在生产代码中包含基本的检查和断言,我们现在仍在为此付出代价。)


2
@aberglas 一个捕获所有异常的语句是 catch (Throwable t)。没有理由不去尝试捕获、记录或重试/恢复 OutOfMemoryError、AssertionError 等异常。 - Aleksandr Dubinsky
1
我已经捕获并从OutOfMemoryError中恢复了。 - MiguelMunoz
3
在我的回答中,我说断言的概念非常好。但assert关键字的实现很糟糕。我将编辑我的答案,以使表达更清晰,明确我指的是该关键字而不是概念本身。 - Aleksandr Dubinsky
2
我喜欢它抛出 AssertionError 而不是 Exception 的事实。太多开发人员仍然没有学会,如果代码只能抛出类似于 IOException 的东西,就不应该捕获 Exception。我曾经在我的代码中遇到过完全被捕获的 bug,因为有人捕获了 Exception。Assertions 不会陷入这种陷阱。Exceptions 适用于您期望在生产代码中看到的情况。至于日志记录,即使错误很少,你也应该记录所有的错误。例如,你真的想让一个 OutOfMemoryError 没有记录就消失吗? - MiguelMunoz
2
我非常不同意。你想要断言是另外一种东西,但在Guava和其他地方已经有了PreconditionsVerify。 断言(在Java中的设计)类似于测试-不应在生产中运行。它们编写起来比测试便宜得多;它们不能取代测试,但它们可以很好地补充测试。 它们可以检查在生产中成本过高的条件。 它们不得被误用为前提条件。 - maaartinus
显示剩余6条评论

14

这是最常见的用例。假设您要切换枚举值:

switch (fruit) {
  case apple:
    // do something
    break;
  case pear:
    // do something
    break;
  case banana:
    // do something
    break;
}
只要你处理每种情况,你就没问题。但总有一天,有人会将 fig 添加到你的枚举中,却忘记在 switch 语句中添加它。这将产生一个错误,可能很难捕获,因为影响直到你离开 switch 语句后才能感受到。但如果你像这样编写你的 switch,你就能立即捕获它:
switch (fruit) {
  case apple:
    // do something
    break;
  case pear:
    // do something
    break;
  case banana:
    // do something
    break;
  default:
    assert false : "Missing enum value: " + fruit;
}

5
因此,您应启用警告并将其视为错误。任何一款还算不错的编译器都有能力告知您,只要您允许它告知您,您是否缺少枚举检查,并且它将在编译时执行此操作,这比在运行时(也许有一天)发现问题要好得多。 - Mike Nakis
12
为什么在这里使用断言而不是某种异常,例如IllegalArgumentException? - liltitus27
9
如果启用了断言(-ea),这将抛出一个AssertionError。在生产中希望的行为是什么?静默无操作并在执行后潜在地造成灾难吗?可能不是。我建议显式地throw new AssertionError("Missing enum value: " + fruit); - aioobe
2
有一个很好的理由可以只是抛出 AssertionError。至于在生产中的适当行为,断言的整个目的就是防止这种情况发生在生产中。断言是开发阶段工具,用于捕获错误,可以轻松从生产代码中删除。在这种情况下,没有理由从生产代码中删除它。但在许多情况下,完整性测试可能会减慢速度。通过将这些测试放在不在生产代码中使用的断言中,您可以自由地编写彻底的测试,而不必担心它们会减慢生产代码的速度。 - MiguelMunoz
这似乎是错误的。在我看来,你不应该使用 default,这样编译器就可以在缺少情况时警告你。你可以使用 return 而不是 break(这可能需要提取方法),然后在 switch 之后处理缺失的情况。这样你既可以得到警告,也可以有机会进行 assert - maaartinus

13

Java中的assert关键字是什么作用?

让我们来看编译后的字节码。

我们得出结论:

public class Assert {
    public static void main(String[] args) {
        assert System.currentTimeMillis() == 0L;
    }
}

生成的字节码与以下代码几乎完全相同:

public class Assert {
    static final boolean $assertionsDisabled =
        !Assert.class.desiredAssertionStatus();
    public static void main(String[] args) {
        if (!$assertionsDisabled) {
            if (System.currentTimeMillis() != 0L) {
                throw new AssertionError();
            }
        }
    }
}

当命令行传递了-ea时,Assert.class.desiredAssertionStatus()返回true,否则返回false

我们使用System.currentTimeMillis()确保它不会被优化忽略(assert true;会被优化掉)。

合成字段是为了让Java只需要在加载时调用一次Assert.class.desiredAssertionStatus(),然后在那里缓存结果。参见: “static synthetic”的含义是什么?

我们可以使用以下方式进行验证:

javac Assert.java
javap -c -constants -private -verbose Assert.class

使用 Oracle JDK 1.8.0_45 版本时,会生成一个合成的静态字段(参见:什么是“static synthetic”的含义?):

static final boolean $assertionsDisabled;
  descriptor: Z
  flags: ACC_STATIC, ACC_FINAL, ACC_SYNTHETIC

与静态初始化程序一起:

 0: ldc           #6                  // class Assert
 2: invokevirtual #7                  // Method java/lang Class.desiredAssertionStatus:()Z
 5: ifne          12
 8: iconst_1
 9: goto          13
12: iconst_0
13: putstatic     #2                  // Field $assertionsDisabled:Z
16: return

而主要的方法是:

 0: getstatic     #2                  // Field $assertionsDisabled:Z
 3: ifne          22
 6: invokestatic  #3                  // Method java/lang/System.currentTimeMillis:()J
 9: lconst_0
10: lcmp
11: ifeq          22
14: new           #4                  // class java/lang/AssertionError
17: dup
18: invokespecial #5                  // Method java/lang/AssertionError."<init>":()V
21: athrow
22: return

我们得出结论:

  • 没有字节码级别的assert支持:它是Java语言的概念
  • assert可以用系统属性-Pcom.me.assert=true来很好地模拟,以替换命令行上的-ea,还需要throw new AssertionError().

2
那么 catch (Throwable t) 子句也能捕获断言违规吗?对我来说,这限制了它们的实用性,只适用于断言体耗时的情况,这种情况很少见。 - Evgeni Sergeev
2
我不确定为什么它会限制断言的有用性。除非是非常罕见的情况,否则你永远不应该捕获Throwable。如果你确实需要捕获Throwable但又不想捕获断言,那么你可以先捕获AssertionError然后重新抛出它。 - MiguelMunoz

13

断言被用于检查后置条件和“永远不应该失败”的前置条件。正确的代码永远不会触发断言;当它们被触发时,它们应该指示一个错误(希望是在实际问题所在的附近)。

断言的一个例子可能是检查一组特定的方法是否按正确的顺序调用(例如,在Iterator中调用next()之前是否先调用了hasNext())。


1
在调用next()之前,不必先调用hasNext()。 - DJClayworth
6
你不需要避免触发断言。 :-) - Donal Fellows

10

断言允许检测代码中的缺陷。您可以在测试和调试时启用它们,在程序生产中关闭。

当您知道某个情况是正确的时候,为什么还需要进行断言?只有在一切正常工作的情况下才是真实的。如果程序存在缺陷,它可能实际上并不是真实的。在进程的早期检测到这一点可以让您知道出了什么问题。

一个assert语句包含此语句以及可选的String消息。

assert语句的语法有两种形式:

assert boolean_expression;
assert boolean_expression: error_message;

以下是一些基本规则,它们决定了断言应该在什么地方使用以及不应该使用的地方。断言应该用于:

  1. 验证私有方法的输入参数。但不应该用于公共方法。当传入错误参数时,public 方法应该抛出常规异常。

  2. 在程序中任何位置,以确保一个几乎肯定为真的事实的有效性。

例如,如果你确定它只能是1或2,你可以使用这样的断言:

...
if (i == 1)    {
    ...
}
else if (i == 2)    {
    ...
} else {
    assert false : "cannot happen. i is " + i;
}
...
  1. 在任何方法的结尾验证后置条件。这意味着,在执行业务逻辑之后,您可以使用断言来确保变量或结果的内部状态与您期望的一致。例如,打开套接字或文件的方法可以在最后使用断言来确保套接字或文件确实已打开。

不应该使用断言来:

  1. 验证公共方法的输入参数。由于断言可能不总是被执行,应该使用常规的异常机制。

  2. 验证由用户输入的内容的约束条件。同上。

  3. 用于副作用。

例如,这不是一个适当的用法,因为此处断言被用于调用doSomething()方法的副作用。

public boolean doSomething() {
...    
}
public void someMethod() {       
assert doSomething(); 
}

唯一可以证明这种情况的是当您尝试查找代码中是否启用了断言时:

boolean enabled = false;    
assert enabled = true;    
if (enabled) {
    System.out.println("Assertions are enabled");
} else {
    System.out.println("Assertions are disabled");
}

8

以下是来自Stack类的一个实际示例(摘自Java断言文章

public int pop() {
   // precondition
   assert !isEmpty() : "Stack is empty";
   return stack[--num];
}

85
在C语言中,这样做会受到不良反应:断言(assertion)是指绝对不应该发生的事情——弹出一个空栈应该抛出NoElementsException或者类似的异常。请参考Donal的回复。 - Konerak
4
同意。尽管这是从官方教程中摘录的,但它是一个不好的例子。 - DJClayworth
4
应该抛出http://docs.oracle.com/javase/6/docs/api/java/util/NoSuchElementException.html,就像http://docs.oracle.com/javase/6/docs/api/java/util/LinkedList.html#pop()中一样。 - Aram Kocharyan
8
可能存在内存泄漏问题。你应该设置stack[num] = null;以便垃圾回收器能够正常工作。 - H.Rabiee
3
我认为在私有方法中,使用断言是正确的,因为对于类或方法的故障而引发异常会很奇怪。在公共方法中,如果从外部某处调用它,你无法确定其他代码如何使用它。它真的检查 isEmpty() 吗?你不知道。 - Vlasec
显示剩余2条评论

5
除了这里提供的所有优秀答案之外,官方Java SE 7编程指南还有一个非常简洁的手册,介绍如何使用assert;其中有几个恰当的示例,说明何时使用断言是一个好主意(以及重要的是,何时不是),以及它与抛出异常的区别。 链接

1
我同意。这篇文章有很多出色的例子,我尤其喜欢其中一个可以确保仅在对象持有锁时调用方法的例子。 - MiguelMunoz

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