这段代码是防御式编程还是糟糕的实践?

31

我和我的同事在就这段代码进行辩论:

var y = null;
if (x.parent != null)
    y = x.parent.somefield;

我认为,在代码所在的地方,x.parent 不应该可能为 null。当它为 null 时,我们就会遇到严重的问题,我想知道发生了什么!因此,我们不应该加入空值检查,让下游异常发生。

我的同事说这是防御式编程。空值检查可以确保代码不会破坏应用程序。

我的问题是,这是防御式编程吗?还是一种糟糕的实践方式?

注:重点不在于谁是对的。我正在尝试从这个例子中学习。


6
当x.parent为空时,抛出异常并进行处理! - failedprogramming
1
最好在它被删除之前,将你的问题改为更少基于观点的形式。 - shenku
1
@failedprogramming 我本来想这么做,但被告知会在各处创建异常处理代码......我仍然认为这比隐藏问题要好。 - Allen Zhang
1
如果你选择采用防御性方法,我建议在日志文件中记录你输入了防御性代码条件的情况(如果需要,可以对这些日志进行限制)。知道你的代码实际上并没有按预期运行总是有益的!所以将其记录到日志文件中(可能要进行节流,以避免日志爆炸)。一个计算器指标来统计你进入此代码块的次数也可能有用。 (请注意,由于字符限制,该翻译文本可能略微简化。) - Tomer Ben David
1
@MichaelFreidgeim 这个问题缺少上下文,因此在Code Review上是不适合讨论的。请在未来提供帮助中心的链接,并使用表明该帖子可能不适合讨论的措辞来推荐Code Review。例如:“这个问题可能适合在Code Review上讨论,请在发布之前检查它是否适合讨论以及如何发布一个好问题。” - Peilonrayz
显示剩余2条评论
5个回答

23

看起来你的同事对“防御性编程”和/或异常有所误解。

防御性编程

防御性编程是关于保护免受某些类型的错误的影响。

在这种情况下,x.parent == null是一个错误,因为你的方法需要使用x.parent.SomeField。如果parent为null,则SomeField的值显然无效。任何使用无效值进行的计算或任务都会产生错误和不可预测的结果。

因此,你需要防范这种可能性。一种非常好的保护方式是,在发现x.parent == null时抛出NullPointerException。异常将阻止你从SomeField获取无效值。它将阻止你使用无效值进行任何计算或执行任何任务。并且它将中止所有当前工作,直到错误得到适当的解决

请注意,异常不是错误;parent中的无效值才是实际的错误。异常实际上是一种保护机制。异常是一种防御性编程技术,而不是应该被避免的东西。

由于C#已经抛出异常,你实际上不需要做任何事情。事实证明,在“以防御性编程之名”的同事的努力中,他正在撤销语言提供的内置防御性编程。

异常

我注意到许多程序员过度担心异常。异常本身并不是错误,它们只是报告错误。

你的同事说:“空引用检查确保代码不会破坏应用程序”。这表明他认为异常会破坏应用程序。它们通常不会“破坏”整个应用程序。

如果糟糕的异常处理使应用程序处于不一致的状态,则异常可能会破坏应用程序。(但是如果隐藏错误,则更有可能发生这种情况。)如果异常“逃脱”线程,则它们也可能会破坏应用程序。(显然逃脱主线程意味着你的程序已经非常不雅观地终止了。但即使逃脱子线程也足够糟糕,以至于操作系统的最佳选择是GPF应用程序。)

异常将中止当前操作。这是它们必须要做的事情。因为如果你编写了一个名为DoSomething的方法,它调用DoStep1DoStep1中的错误意味着DoSomething无法正常地完成其工作。继续调用DoStep2毫无意义。

然而,如果你能够在某个时间点上完全解决特定的错误,那么一定要这样做。但请注意强调“完全解决”;这并不意味着只是隐藏错误。仅记录错误通常是无法解决它的。这意味着达到这样的程度:如果另一个方法调用您的方法并正确使用它,“已解决的错误”不会对调用者的工作能力产生负面影响。(无论调用者是谁。)
也许最好的完全解决错误的例子是应用程序的主处理循环。它的工作是等待队列中的消息,从队列中取出下一个消息并调用适当的代码来处理该消息。如果在返回到主消息循环之前引发异常并且未解决,则需要解决该异常。否则,异常将逃脱主线程,应用程序将终止。
许多语言在其标准框架中提供默认的异常处理程序(带有程序员覆盖/拦截机制)。默认处理程序通常只会向用户显示错误消息,然后吞下异常。
为什么?因为只要您没有实现不良的异常处理,程序就处于一致状态。当前消息已中止,可以处理下一条消息,就好像什么都没发生一样。当然,您可以覆盖此处理程序:
- 写入日志文件。 - 发送调用堆栈信息进行故障排除。 - 忽略某些类别的异常。(例如,“Abort”可能意味着您甚至不需要告诉用户,也许因为您先前显示了一条消息。) 等等。
如果可以在未引发异常的情况下解决错误,则最好这样做。但是,在某些情况下,错误无法在第一次出现时或不能预先检测到。在这些情况下,应该引发/抛出异常来报告错误,并通过实现异常处理程序(C#中的catch块)来解决它。
注意:异常处理程序有两个不同的目的:首先,它们为您提供了一个特定的清理(或回滚代码)的位置,因为存在错误/异常。其次,它们为解决错误和吞下异常提供了一个位置。重要提示:在前一种情况下,非常重要的一点是重新引发/抛出异常,因为它还没有被解决。
在关于引发异常和处理异常的评论中,您说:“我想这样做,但有人告诉我这会在各处创建异常处理代码。”
这是另一个误解。根据前面的侧注,只有在以下情况下才需要异常处理:
  • 您可以解决错误,这样就完成了。
  • 或者需要实现回滚代码。

问题可能是由于缺陷的因果分析引起的。抛出异常并不意味着您需要回滚代码。有许多其他原因会导致异常被抛出。当方法发生错误时,需要执行清理操作,因此需要回滚代码。换句话说,无论如何都需要异常处理代码。这表明,防止过度异常处理的最佳方法是以设计方式减少错误时的清理需求。

因此,不要“不抛出异常”以避免过度的异常处理。我同意,过度的异常处理是不好的(请参见上面的设计考虑)。但如果您甚至不知道有错误发生,那么不回滚是更糟糕的。


@Gabriel,虽然你试图改进答案是出于好意,但x.parent绝对不是一个参数。因此,你的建议更改实际上会使错误变得具有误导性。 - Disillusioned
非常好的答案!我完全同意。换句话说,抛出异常可以帮助开发人员知道何时传递了不正确的输入。我经常喜欢考虑哪些代码(客户端、API等)和哪些资源涉及到,以便理解何时应该处理或冒泡异常。 - Nicholas Miller

19
  • 如果数据源是类中的另一个方法或相关类,则可以放松防御措施,因为您可以合理地假设很少有错误的数据,并且如果出现错误,则可以在调试/测试期间早期发现和修复。对于此情况,应检查和验证数据,以确保其符合预期。
  • 如果数据源是用户输入、文件、URL 或其他外部输入,则必须彻底验证,以避免使用不完整、不准确或恶意信息导致问题。
  • 如果输入来自同一系统内的另一层/层,则应再次验证,以确保跨越边界时的数据有效性。
  • 如何处理验证?

    • 在检查失败时,抛出异常并记录日志。
    • 通过返回值或输出参数向调用方提供错误代码或消息。
    • 结合以上两种方式。
  • 使用if(与你示范的代码一样)来跳过某些分配或使用可能对某些情况很好。例如,如果用户正在输入某些数据,并且这只是显示提示工具提示或其他次要信息,则可能可以安全地跳过。但是,如果代码片段执行某些重要操作,填充某些强制条件或执行某些其他进程,则不是正确的方法,因为它会导致下一个代码运行出现问题。问题在于,当您跳过某些代码时,务必安全执行此操作,没有任何副作用或意外后果,否则您将隐藏某个错误,而这在后期开发阶段非常难以调试。

  • 优雅地中止当前进程是早期验证的好选择,当失败完全是预期的并且您确切地知道如何应对时。一个例子可能是缺少必需字段,该过程被中断并向用户显示一个消息,要求提供缺少的信息。简单忽略错误是不行的,但也不严重到需要抛出异常打断正常的程序流程。当然,根据您的架构,仍然可以使用异常,但无论如何都要捕获它并优雅地恢复。

  • 当“不可能”真的发生时,抛出异常始终是一种选择。在这些情况下,您可能无法提供合理的响应以继续某些变化或仅取消当前进程,这可能是由于某个地方的错误或坏输入,但重要的是您想知道它并了解所有有关它的详细信息,因此最好的方式是让它尽可能大声地爆炸,以便异常上升并到达全局处理程序,中断所有内容,将其保存到日志文件/DB /任何位置,向您发送崩溃报告,并找到一种恢复执行的方法(如果可行或安全)。至少,如果您的应用程序崩溃,请以最优雅的方式崩溃,并留下痕迹以供进一步分析。

  • 通常情况下,这要取决于具体情况。但是仅仅使用if语句避免编写异常处理程序肯定是一种不好的做法。异常处理程序必须始终存在,然后某些代码可能会依赖它 - 无论是否适当 - 如果失败对系统来说不是至关重要的话。


    2
    数据来自第三个案例编号。这个答案非常有用。 - Allen Zhang

    2
    我不会称之为防御式编程,我称之为“啦啦啦我听不到你的声音”编程 :) 因为代码似乎有效地忽略了潜在的错误条件。
    显然,我们不知道代码中接下来会发生什么,但由于您没有包含一个else子句,我只能假设您的代码即使在x.parent实际上为空的情况下也继续运行。
    请记住,“不应该为null”和“绝对不可能为null”并不一定是相同的;因此,在这种情况下,当您尝试取消引用y时,它可能导致后面出现异常。
    引用块中写到:“那么问题就是 - 在您要解决的问题(“域”)的上下文中,什么更可接受,这在很大程度上取决于您打算如何使用y。”
    如果y在此代码之后为null不是问题(假设您稍后进行防御性检查y!= null),那么这是可以接受的-虽然个人而言,我不喜欢这种风格-您最终要进行防御性检查每个取消引用,因为您永远无法确定是否与崩溃只有一个null引用的距离...
    如果y在代码之后不能为null,因为它会在以后引起异常或丢失数据,那么当您知道不变量不正确时继续进行就是一个坏主意。

    如果 y 为空,代码将继续执行。但是后果是客户端将收到缺少重要信息的响应。而且它会破坏客户端。在我看来,这是错误的。 - Allen Zhang
    1
    我同意 - 因此第二点 - 在这种情况下继续进行是一个坏主意。因此,真正的“防御”意图在这里失败了 - 正确的防御应该是使用异常快速失败。 - Stephen Byrne

    1
    简而言之,我认为这不是防御性编程。我同意那些认为这段代码隐藏了系统错误而不是暴露和修复它的人。这段代码违反了“快速失败”的原则。
    当且仅当x.parent是一个强制非空属性(从上下文中似乎很明显)时,这是正确的。然而,如果x.parent是一个可选属性(即可以合理地具有空值),那么根据您表达的业务逻辑,这段代码可能是正确的。
    我总是考虑使用空值(0、""、空对象)代替null,这需要大量无关的if语句。

    -2

    经过几年思考,我采用以下“防御性”编程风格——95% 的方法都返回字符串作为成功/失败的结果。

    我会返回 string.Empty 表示成功,如果失败会返回相应的信息文本。在返回错误文本的同时,我也会将其写入日志中。

    public string Divide(int numA, int numB, out double division)
    {
        division = 0;
        if (numB == 0)
            return Log.Write(Level.Error, "Divide - numB-[{0}] not valid.", numB);
    
        division = numA / numB;
        return string.Empty;
    }
    

    然后我使用它:

    private string Process(int numA, int numB)
    {
        double division = 0;
        string result = string.Empty;
        if (!string.IsNullOrEmpty(result = Divide(numA, numB, out divide))
            return result;
    
        return SendResult(division);
    }
    

    当您拥有日志监控系统时,它可以让系统继续运行,但同时通知管理员。


    不要返回错误代码(或错误字符串) https://blogs.msdn.microsoft.com/kcwalina/2005/03/16/design-guidelines-update-exception-throwing/ - granadaCoder
    你应该了解 Either<Left,Right> 类。这肯定会改善你的设计。 - Daniel Botero Correa
    在做某事超过十年后,我发现了它的名字——“铁路导向编程”... https://blog.logrocket.com/what-is-railway-oriented-programming/ - Daniel Gordon

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