例外:为什么要早丢?为什么要迟到?


156

关于隔离中的异常处理,有许多众所周知的最佳实践。我知道“做与不做”已经足够了,但是在大型环境中的最佳实践或模式方面,事情变得复杂了。“早点扔,迟到”-我已经听过很多次了,但仍然让我感到困惑。

如果在低层发生了空指针异常,为什么还要提早抛出并迟到?为什么要在更高层次上进行捕获?对于我来说,在较高级别(例如业务层)捕获低级异常对我来说没有任何意义。这似乎违反了每一层的关注。

想象一下以下情况:

我有一个计算数字的服务。为了计算图形,服务访问存储库以获取原始数据,并访问其他一些服务以准备计算。如果在数据检索层出现问题,为什么我应该将DataRetrievalException抛出更高级别?相反,我希望将异常包装为有意义的异常,例如CalculationServiceException。

为什么要早扔,为什么要迟到?


104
“赶上很晚”的想法是,尽早赶上是有用的,而不是尽可能早地赶上。例如,如果您有文件解析器,那么就没有必要处理找不到的文件。您打算做什么,恢复路径是什么?没有一个,所以不要抓。转到词法分析器,您在那里做什么,如何从中恢复以使程序可以继续?不能,让异常通过。您的扫描仪如何处理?它不能,让它过去。调用代码如何处理呢?它可以尝试其他文件路径或警告用户,因此请注意。
Phoshi 2014年

16
在极少数情况下应该捕获NullPointerException(我假设这就是NPE的意思)。如果可能的话,应该首先避免它。如果您遇到NullPointerExceptions,则您需要修复一些残破的代码。这也很容易解决。
Phil

6
伙计们,在建议以重复方式结束此问题之前,请确保对其他问题的回答不能很好地回答此问题。
布朗

1
(Citebot)today.java.net/article/2003/11/20/…如果这不是报价的来源,请提供您认为最可能是原始报价的来源的引用。
rwong 2014年

1
这仅是对任何一个提出此问题并进行Android开发的人的提醒。在Android上,必须在本地捕获和处理异常-使用与首次捕获相同的功能。这是因为异常不会在消息处理程序之间传播-如果发生这种情况,您的应用程序将被杀死。因此,在进行Android开发时,您不应引用此建议。
rwong 2014年

Answers:


118

以我的经验,最好在发生错误的地方抛出异常。这样做是因为您最了解触发异常的原因。

随着异常退回各层,捕获和重新抛出是向异常添加其他上下文的好方法。这可能意味着抛出其他类型的异常,但是在执行此操作时请包含原始异常。

最终,异常将到达您可以对代码流做出决策的层(例如,提示用户采取行动)。这是您最终应该处理异常并继续正常执行的地方。

通过对代码库的实践和经验,可以很容易地判断何时向错误添加其他上下文,以及实际上最适合最终处理错误的位置。

抓住→重新投掷

这样做可以在您可以有用地添加更多信息的情况下,使开发人员不必遍历所有层次来理解问题。

抓住→手柄

执行此操作,以便您可以最终决定通过软件执行的适当但不同的执行流程。

捕获→错误返回

在某些情况下,这是适当的,但应考虑捕获异常并将错误值返回给调用方,以将其重构为Catch→Rethrow实现。


是的,我已经知道并且毫无疑问,我应该在错误产生的地方抛出异常。但是为什么我不应该抓住NPE而是让它爬上Stacktrace?我总是会抓住NPE并将其包装为有意义的异常。我也看不出为什么我应该向服务或ui层抛出DAO-Exception的任何优势。我总是会在服务层捕获它,并将其包装到带有其他详细信息的服务异常中,为什么调用该服务失败。
shylynx

8
@shylynx捕获异常,然后重新抛出更有意义的异常是一件好事。您不应该做的是太早捕获异常,然后再不将其抛出。俗语警告的错误是过早捕获异常,然后尝试在代码的错误层处理它。
西蒙B

在接收到异常时使上下文变得明显,使团队中的开发人员的工作更加轻松。NPE需要进一步调查以了解问题所在
Michael Shaw

4
@shylynx一个人可能会问这个问题:“为什么您的代码中有个点可以引发NullPointerException??为什么不早点检查null并引发异常(也许是IllegalArgumentException),以便调用者确切地知道坏点是从哪里null传入的?” 我相信这将是俗语中“提早投入”部分所暗示的内容。
jpmc26 2014年

2
@jpmc我仅以NPE为例来强调有关层和异常的关注。我也可以用IllegalArgumentException替换它。
shylynx

56

您希望尽快引发异常,因为这样可以更轻松地找到原因。例如,考虑一个可能因某些参数而失败的方法。如果您验证参数并且在方法的开头失败,您将立即知道错误在于调用代码中。如果等到失败之前需要使用参数,则必须遵循执行过程,然后确定错误是否在调用代码中(错误的参数)或方法是否存在错误。越早抛出异常,它就越接近其根本原因,就越容易找出问题出在哪里。

在较高级别上处理异常的原因是,较低级别不知道处理错误的适当方法是什么。实际上,根据调用代码的不同,可能有多种适当的方法来处理同一错误。以打开文件为例。如果您尝试打开一个配置文件,但该文件不存在,则可以忽略该异常并继续使用默认配置。如果要打开对程序的执行至关重要的私有文件,但由于某种原因丢失了私有文件,则唯一的选择可能是关闭程序。

用正确的类型包装异常是一个纯粹的正交问题。


1
+1可以清楚地说明不同级别的重要性。关于文件系统错误的出色示例。
Juan Carlos Coto 2015年

24

其他人已经很好总结了为什么提早抛出。让我专注于为什么迟到的原因,对此我还没有满意的解释。

为何例外?

关于为什么首先存在异常的情况似乎存在很大的困惑。让我在这里分享一个大秘密:异常的原因和异常处理是... ABSTRACTION

您是否看到过这样的代码:

static int divide(int dividend, int divisor) throws DivideByZeroException {
    if (divisor == 0)
        throw new DivideByZeroException(); // that's a checked exception indeed

    return dividend / divisor;
}

static void doDivide() {
    int a = readInt();
    int b = readInt(); 
    try {
        int res = divide(a, b);
        System.out.println(res);
    } catch (DivideByZeroException e) {
        // checked exception... I'm forced to handle it!
        System.out.println("Nah, can't divide by zero. Try again.");
    }
}

那不是应该使用异常的方式。上面的代码确实存在于现实生活中,但是它们更多的是一种异常,并且确实是例外(双关语)。例如,即使在纯数学中,除法的定义也是有条件的:总是必须处理特殊的零以限制输入域的“调用者代码”。它很丑。呼叫者总是很痛苦。不过,在这种情况下,先检查再做模式是很自然的做法:

static int divide(int dividend, int divisor) {
    // throws unchecked ArithmeticException for 0 divisor
    return dividend / divisor;
}

static void doDivide() {
    int a = readInt();
    int b = readInt();
    if (b != 0) {
        int res = divide(a, b);
        System.out.println(res);
    } else {
        System.out.println("Nah, can't divide by zero. Try again.");
    }
}

另外,您可以像这样在OOP风格上进行全面突击:

static class Division {
    final int dividend;
    final int divisor;

    private Division(int dividend, int divisor) {
        this.dividend = dividend;
        this.divisor = divisor;
    }

    public boolean check() {
        return divisor != 0;
    }

    public int eval() {
        return dividend / divisor;
    }

    public static Division with(int dividend, int divisor) {
        return new Division(dividend, divisor);
    }
}

static void doDivide() {
    int a = readInt();
    int b = readInt(); 
    Division d = Division.with(a, b);
    if (d.check()) {
        int res = d.eval();
        System.out.println(res);
    } else {
        System.out.println("Nah, can't divide by zero. Try again.");
    }
}

如您所见,调用者代码具有预检查的负担,但是此后不执行任何异常处理。如果ArithmeticException来自divide或的调用来自eval,则必须执行异常处理并修复代码,因为您忘记了check()。出于类似的原因,捕获a NullPointerException几乎总是错误的事情。

现在有些人说他们想查看方法/函数签名中的特殊情况,即显式扩展输出。他们是赞成检查异常的人。当然,更改输出域应强制任何直接调用者代码进行调整,而这确实可以通过检查异常来实现。但是您不需要例外!这就是为什么要使用Nullable<T> 泛型类案例类代数数据类型联合类型的原因一些面向对象的人甚至可能更喜欢返回 null简单的错误情况,例如:

static Integer divide(int dividend, int divisor) {
    if (divisor == 0) return null;
    return dividend / divisor;
}

static void doDivide() {
    int a = readInt();
    int b = readInt(); 
    Integer res = divide(a, b);
    if (res != null) {
        System.out.println(res);
    } else {
        System.out.println("Nah, can't divide by zero. Try again.");
    }
}

从技术上讲,可以将异常用于上述目的,但这是重点:此类用法不存在异常。异常是专业的抽象。异常与间接有关。例外允许扩展“结果”域而不破坏直接的客户合同,并将错误处理推迟到“其他地方”。如果你的代码抛出这是在相同的代码直接调用方处理的异常,中间没有任何抽象层,那么你正在做它

如何赶上后期?

所以我们到了。我已经指出,在上述情况下使用异常并不是如何使用异常。但是,存在一个真正的用例,其中异常处理提供的抽象和间接是必不可少的。了解此类用法也将有助于理解后期建议。

该用例是:针对资源抽象编程 ...

是的,应该针对抽象而不是具体的实现业务逻辑进行编程。顶级IOC “接线”代码将实例化资源抽象的具体实现,并将其传递给业务逻辑。这里没有新内容。但是这些资源抽象的具体实现可能会抛出它们自己的实现特定的异常,不是吗?

那么谁可以处理那些实现特定的异常呢?那么,是否有可能在业务逻辑中处理任何特定于资源的异常?不,不是。业务逻辑是针对抽象编程的,抽象确实排除了那些实现特定异常细节的知识。

“ Aha!”,您可能会说:“但这就是为什么我们可以对异常进行子类化并创建异常层次结构”(请查看Spring先生!)。我告诉你,这是一个谬论。首先,每本有关OOP的合理书籍都指出具体继承是不好的,但是以某种方式,JVM的核心组件(异常处理)与具体继承紧密相关。具有讽刺意味的是,约书亚·布洛赫(Joshua Bloch)在获得可以使用的JVM的经验之前还无法写出他的《有效Java》一书,是吗?它更多地是下一代的“经验教训”书。其次,更重要的是,如果您捕获了高级异常,那么您将如何处理它?PatientNeedsImmediateAttentionException:我们要给她吃药还是要砍断她的腿!?关于所有可能的子类的switch语句怎么样?您的多态性就到了,抽象就到了。你明白了。

那么谁可以处理资源特定的异常呢?一定是知道凝结物的人!实例化资源的人!当然,“接线”代码!看一下这个:

针对抽象编码的业务逻辑...没有混凝土资源错误处理!

static interface InputResource {
    String fetchData();
}

static interface OutputResource {
    void writeData(String data);
}

static void doMyBusiness(InputResource in, OutputResource out, int times) {
    for (int i = 0; i < times; i++) {
        System.out.println("fetching data");
        String data = in.fetchData();
        System.out.println("outputting data");
        out.writeData(data);
    }
}

同时,其他地方的具体实现...

static class ConstantInputResource implements InputResource {
    @Override
    public String fetchData() {
        return "Hello World!";
    }
}

static class FailingInputResourceException extends RuntimeException {
    public FailingInputResourceException(String message) {
        super(message);
    }
}

static class FailingInputResource implements InputResource {
    @Override
    public String fetchData() {
        throw new FailingInputResourceException("I am a complete failure!");
    }
}

static class StandardOutputResource implements OutputResource {
    @Override
    public void writeData(String data) {
        System.out.println("DATA: " + data);
    }
}

最后是接线代码...谁处理具体的资源异常?一个了解他们的人!

static void start() {
    InputResource in1 = new FailingInputResource();
    InputResource in2 = new ConstantInputResource();
    OutputResource out = new StandardOutputResource();

    try {
        ReusableBusinessLogicClass.doMyBusiness(in1, out, 3);
    }
    catch (FailingInputResourceException e)
    {
        System.out.println(e.getMessage());
        System.out.println("retrying...");
        ReusableBusinessLogicClass.doMyBusiness(in2, out, 3);
    }
}

现在忍受我。上面的代码简单。您可能会说您拥有一个具有多个范围的IOC容器管理资源的企业应用程序/ Web容器,并且需要自动重试和会话或请求范围资源的重新初始化等。较低级别范围的接线逻辑可能会提供给抽象工厂以创建资源,因此不知道确切的实现。只有更高级别的范围才真正知道那些更低级别的资源会引发哪些异常。现在等等!

不幸的是,异常仅允许在调用堆栈上进行间接访问,并且具有不同基数的不同作用域通常在多个不同线程上运行。没有办法通过例外进行沟通。这里我们需要更强大的功能。答:异步消息传递。在较低级别作用域的根目录捕获每个异常。不要忽略任何东西,不要让任何东西漏掉。这将关闭并处置在当前作用域的调用堆栈上创建的所有资源。然后,通过在异常处理例程中使用消息队列/通道将错误消息传播到更高范围的示波器,直到达到已知凝固的级别。那是知道如何处理它的人。

SUMMA SUMMARUM

因此,根据我的解释,在任何您不破坏抽象的最方便的地方,捕获迟到的手段即可捕获异常。不要太早赶上!在创建具体异常并抛出资源抽象实例的层捕获异常,该层知道资源的抽象。“接线”层。

HTH。编码愉快!


您是正确的,提供接口的代码会比使用接口的代码更多地了解可能出问题的地方,但是假设一种方法使用接口类型相同的两个资源,并且需要以不同的方式处理故障?或者,如果内部这些资源之一(作为其创建者未知的实现细节)使用了相同类型的其他嵌套资源?抛出业务层WrappedFirstResourceExceptionWrappedSecondResourceException要求“布线”层查看该异常内部以查看问题的根本原因...
supercat

...可能有点怪异,但比假设任何FailingInputResource异常都是使用进行运算的结果要好in1。实际上,我认为在许多情况下,正确的方法是让布线层传递异常处理对象,并让业务层包含catch,然后调用该对象的handleException方法。该方法可以重新抛出或提供默认数据,或显示“中止/重试/失败”提示,并让操作员根据需要的应用程序来决定要执行的操作等。
超级猫2014年

@supercat我明白你在说什么。我想说一个具体的资源实现负责知道它可能引发的异常。它不必指定所有内容(所谓的undefined behavior),但是必须确保没有歧义。另外,应记录未经检查的运行时异常。如果它与文档相矛盾,那就是一个错误。如果希望调用者代码对异常做任何明智的事情,则最起码的要求是资源将它们包装在某种程度上UnrecoverableInternalException,类似于HTTP 500错误代码。
Daniel Dinnyes 2014年

@supercat关于您对可配置错误处理程序的建议:完全正确!在我的最后一个示例中,错误处理逻辑是硬编码的,调用了静态doMyBusiness方法。为了简洁起见,完全有可能使其更具动态性。此类Handler将使用一些输入/输出资源实例化,并具有handle接收实现的类的方法ReusableBusinessLogicInterface。然后,您可以组合/配置它们以在其上方某处的布线层中使用不同的处理程序,资源和业务逻辑实现。
Daniel Dinnyes 2014年

10

为了正确回答这个问题,让我们退后一步,提出一个更基本的问题。

为什么我们首先要有例外?

我们抛出异常以使我们的方法的调用者知道我们无法完成被要求执行的操作。异常的类型说明了为什么我们不能做我们想做的事情。

让我们看一些代码:

double MethodA()
{
    return PropertyA - PropertyB.NestedProperty;
}

如果PropertyB为null,则此代码显然可以引发null引用异常。在这种情况下,我们可以做两件事来“纠正”这种情况。我们可以:

  • 如果没有自动创建PropertyB;要么
  • 让异常冒泡直到调用方法。

在此处创建PropertyB可能非常危险。此方法必须创建PropertyB的原因是什么?当然,这将违反单一责任原则。很可能,如果PropertyB在此处不存在,则表明出了点问题。在部分构造的对象上调用该方法,或者PropertyB错误地设置为null。通过在此处创建PropertyB,我们可以隐藏一个更大的bug,以后可能会咬我们,例如导致数据损坏的bug。

相反,如果让null引用冒泡,那么我们将让调用此方法的开发人员尽快知道出了问题。错过了调用此方法的重要先决条件。

因此,实际上,我们提早提出建议是因为它可以更好地分离我们的关注点。一旦发生故障,我们就会通知上游开发人员。

为什么我们“迟到”是一个不同的故事。我们真的不想迟到,我们真的想尽早了解到如何正确处理问题。在某些时候,这将是十五层以后的抽象,而在某些时候,这将是在创建时。

关键是我们希望在抽象层捕获异常,该异常层使我们能够在拥有适当处理异常所需的所有信息的时刻处理异常。


我认为您使用错误的上游开发人员。另外,您说它违反了单一责任原则,但实际上许多按需初始化和值缓存都是通过这种方式实现的(当然,适当的并发控制也是如此)
Daniel Dinnyes

在给定的示例中,如何在减法运算之前检查空值,例如if(PropertyB == null) return 0;
user1451111

1
您能否也详细说明最后一段,特别是“ 抽象层”的含义。
user1451111

如果我们正在做一些IO工作,那么捕获IO异常的抽象层就是我们要做的工作。到那时,我们掌握了决定是否要重试或向用户抛出消息框或使用一组默认值创建对象所需的所有信息。
斯蒂芬

“在给定的示例中,如何在减法运算之前检查null,例如if(PropertyB == null)返回0;” uck 那将告诉调用方法我要减去有效的东西。当然,这是上下文相关的,但是在大多数情况下,在此处进行错误检查将不是一个好习惯。
斯蒂芬

6

一旦看到值得扔掉的东西,就应该扔掉,以避免将对象置于无效状态。这意味着,如果传递了空指针,则应尽早检查它并在NPE 有机会滴入低位之前抛出NPE 。

知道如何解决错误后立即进行捕获(通常不在抛出错误的地方,否则可以使用if-else),如果传递了无效参数,则提供该参数的层应处理后果。


1
您写道:马上扔,...赶上...!为什么?与“提早投掷,追赶迟到”相反,这是完全相反的方法。
shylynx 2014年

1
@shylynx我不知道“早丢,晚抓”来自何处,但其价值值得怀疑。捕获“迟到”到底是什么意思?捕获异常(如果有的话)在哪里有意义取决于问题。唯一清楚的是您希望尽早发现问题(并抛出)。
2014年

2
我认为“延迟捕获”的意思是您知道如何解决错误之前先进行捕获,然后再进行捕获-例如,有时您会看到捕获所有内容的函数,以便它们可以打印错误消息,然后重新抛出异常。
2014年

@Hurkyl:“延迟捕获”的一个问题是,如果异常在不了解该信息的层中冒出气泡,那么可能无法对这种情况进行处理的代码将很难知道事情的真实性。预期。举一个简单的例子,假设如果用户文档文件的解析器需要从磁盘加载CODEC,并且在读取该磁盘时发生磁盘错误,则调用解析器的代码如果认为在读取用户时发生磁盘错误,则可能无法正常工作。文献。
超级猫2014年

4

有效的业务规则是“如果较低级别的软件无法计算值,则...”

这只能在较高级别上表达,否则较低级别的软件将尝试根据自身的正确性来更改其行为,这只会以结局而告终。


2

首先,例外是​​针对特殊情况。在您的示例中,如果由于无法加载原始数据而没有原始数据,则无法计算任何数字。

根据我的经验,在遍历堆栈时抽象出异常是个好习惯。通常,要执行此操作的点是每当异常跨越两层之间的边界时。

如果在数据层中收集原始数据时出错,则引发异常以通知请求数据的人。不要尝试在此处解决此问题。处理代码的复杂度可能很高。而且,数据层仅负责请求数据,而不负责处理在执行此操作时发生的错误。这就是“早点扔”的意思。

在您的示例中,捕获层是服务层。服务本身是一个新的层,位于数据访问层之上。因此,您想在那里捕获异常。也许您的服务具有一些故障转移基础结构,并尝试从另一个存储库请求数据。如果仍然失败,则将异常包装在服务的调用者可以理解的内容内(如果是Web服务,则可能是SOAP错误)。将原始异常设置为内部异常,以便以后的层可以准确记录发生了什么问题。

调用服务的层(例如,UI)可能会捕获服务故障。这就是“赶上”的意思。如果您无法在较低的层中处理该异常,则将其重新抛出。如果最顶层不能处理该异常,请处理它!这可能包括记录或显示它。

您应该抛出异常的原因(如上述通过将它们包装在更通用的示例中所述)是因为用户很可能无法理解存在错误,因为例如,指向无效内存的指针。而且他不在乎。他只关心该数字不能由服务部门计算出来,这是应该显示给他的信息。

而且,您可以(在理想的世界中)从UI中完全忽略try/ catch代码。而是使用一个全局异常处理程序,该处理程序能够理解可能由较低层引发的异常,将其写入一些日志中,然后将其包装到包含有意义的(可能是本地化的)错误信息的错误对象中。这些对象可以轻松地以您希望的任何形式(消息框,通知,消息祝酒等)呈现给用户。


1

通常,尽早抛出异常是一个好习惯,因为您不希望违约的合同在不必要的情况下流经代码。例如,如果您希望某个函数参数为正整数,则应在函数调用时强制执行该约束,而不必等到该变量在代码堆栈中的其他位置使用。

赶上很晚了,我无法真正置评,因为我有自己的规则,而且规则会因项目而异。我尝试做的一件事是将异常分为两组。一种仅用于内部使用,另一种仅用于外部使用。内部异常是由我自己的代码捕获和处理的,而外部异常是由要调用我的代码处理的。基本上,这是稍后捕获的一种形式,但并非完全如此,因为它为我提供了必要时在内部代码中偏离规则的灵活性。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.