有一个标志来指示是否应该抛出错误


64

我最近开始在一个与一些年龄较大的开发人员(大约50岁以上)一起工作的地方。他们致力于处理无法解决系统故障的航空关键应用。结果,较老的程序员倾向于以这种方式进行编码。

他倾向于在对象中放入一个布尔值,以指示是否应该引发异常。

public class AreaCalculator
{
    AreaCalculator(bool shouldThrowExceptions) { ... }
    CalculateArea(int x, int y)
    {
        if(x < 0 || y < 0)
        {
            if(shouldThrowExceptions) 
                throwException;
            else
                return 0;
        }
    }
}

(在我们的项目中,该方法可能会失败,因为我们正在尝试使用当时无法使用的网络设备。区域示例仅是异常标志的示例)

对我来说,这似乎是一种代码气味。编写单元测试变得有些复杂,因为每次都必须测试异常标志。另外,如果出现问题,您是否想立即知道?确定如何继续是呼叫者的责任吗?

他的逻辑/理由是我们的程序需要做一件事,向用户显示数据。任何其他不会阻止我们这样做的异常都应忽略。我同意不应忽视它们,而应该冒泡并由适当的人员来处理,而不必为此处理标志。

这是处理异常的好方法吗?

编辑:只是为了提供更多有关设计决策的信息,我怀疑这是因为如果该组件失败,程序仍可以运行并完成其主要任务。因此,当用户可以正常工作时,我们不想抛出异常(不处理它?)并让它删除程序。

编辑2:为了提供更多上下文,在本例中,该方法被调用来重置网卡。当网卡断开连接并重新连接时,会出现问题,它被分配了另一个IP地址,因此Reset将引发异常,因为我们将尝试使用旧的IP重置硬件。


22
c#对此Try-Parse模式有一个约定。更多信息:docs.microsoft.com/zh-cn/dotnet/standard/design-guidelines/… 该标志与此模式不匹配。
彼得

18
这基本上是一个控制参数,它改变了内部方法的执行方式。不管情况如何,这都是不好的。martinfowler.com/bliki/FlagArgument.htmlsoftwareengineering.stackexchange.com/questions/147977/...medium.com/@amlcurran/...
BIC

1
除了从彼得在Try-解析评论,这里大约是棘手的例外一篇好文:blogs.msdn.microsoft.com/ericlippert/2008/09/10/...
Linaith

2
“因此,我们不希望抛出异常(不处理它?),并且让程序在用户正常工作时将其删除。”-您知道您可以捕获异常吗?
immibis

1
我很确定这已经在其他地方讨论过了,但是考虑到简单的Area示例,我将更想知道那些负数将来自何处以及您是否能够在其他地方处理该错误情况(例如,例如,正在读取包含长度和宽度的文件的任何内容);但是,“我们正在尝试使用当时无法使用的网络设备”。这可能是一个完全不同的答案,这是第三方API还是诸如TCP / UDP之类的行业标准?
jrh

Answers:


74

这种方法的问题在于,尽管永远不会引发异常(因此,应用程序永远不会因未捕获的异常而崩溃),但返回的结果不一定正确,并且用户可能永远不会知道数据存在问题(或这个问题是什么以及如何纠正它)。

为了使结果正确且有意义,调用方法必须检查结果中是否存在特殊数字-即,特定的返回值用于表示执行该方法时出现的问题。对于正定数量(如面积)返回的负数(或零)是旧代码中的一个很好的例子。但是,如果调用方法不知道(或忘记了!)检查这些特殊数字,则可以继续进行处理而不会发现错误。然后,数据显示给用户,显示区域0,用户知道这是不正确的,但是他们没有指出发生了什么错误,发生位置或原因的原因。然后他们想知道其他任何值是否错误...

如果引发了异常,则处理将停止,错误(理想情况下)将被记录下来,并可能以某种方式通知用户。然后,用户可以修复所有错误,然后重试。适当的异常处理(和测试!)将确保关键应用程序不会崩溃或以其他方式进入无效状态。


1
@Quirk令人印象深刻的是Chen如何仅在3或4行中违反了“单一责任原则”。那是真正的问题。另外,他正在谈论的问题(程序员未能考虑每行错误的后果)总是带有未检查的异常的可能性,并且仅有是带有检查的异常的可能性。我想我已经看过针对检查异常的所有论点,但没有一个是有效的。
TKK

@TKK个人来说,在某些情况下,我遇到了我真的很喜欢.NET中的检查异常的情况。如果有一些高端的静态分析工具可以确保API文档作为抛出的异常是准确的,那将是很好的,尽管这可能几乎是不可能的,尤其是在访问本地资源时。
jrh

1
@jrh是的,如果某些东西将某种异常安全性混入.NET中,这就像将TypeScript将类型安全性混入JS中一样,那将是很好的。
TKK

47

这是处理异常的好方法吗?

不,我认为这是非常糟糕的做法。抛出异常与返回值是API的一项根本变化,它改变了方法的签名,并从接口的角度使方法的行为大不相同。

通常,在设计类及其API时,应考虑到

  1. 在同一程序中,可能同时存在具有不同配置的类的多个实例,并且,

  2. 由于依赖项注入和许多其他编程实践,一个使用客户端的对象可能会创建对象,然后将其交给另一个使用它们的对象-因此,通常我们在对象创建者和对象用户之间存在分隔。

现在考虑调用者为处理实例而必须采取的方法,例如,调用计算方法:调用者既要检查面积是否为零,又要捕获异常-哎呀!测试注意事项不仅涉及类本身,还涉及调用者的错误处理...

我们应该始终为消费客户简化一切。构造函数中更改实例方法的API的这种布尔配置与使使用客户端的程序员(也许您或您的同事)陷入成功的困境是相反的。

为了提供这两种API,您最好提供两种不同的类-一种总是抛出错误,而一种总是返回0的错误,或者为单个类提供两种不同的方法,这是更好,更正常的做法。通过这种方式,使用客户端可以轻松地准确知道如何检查和处理错误。

使用两种不同的类或两种不同的方法,可以更轻松地使用IDE查找方法的用户和重构功能等。因为这两个用例不再被合并。代码的读取,编写,维护,检查和测试也更加简单。


另一方面,我个人认为我们不应该使用布尔配置参数,因为实际的调用者都只是传递一个常量。这种配置参数化将两个单独的用例放在一起,没有任何实际好处。

查看您的代码库,看看是否曾经在构造函数中将变量(或非常数表达式)用于布尔配置参数!我对此表示怀疑。


进一步的考虑是要问为什么计算区域会失败。如果无法进行计算,最好的方法是将其放入构造函数中。但是,如果在对象进一步初始化之前不知道是否可以进行计算,则可以考虑使用不同的类来区分那些状态(尚未准备好计算面积与准备好计算面积)。

我读到您的失败情况是针对远程处理的,因此可能不适用;只是一些值得深思的东西。


确定如何继续是呼叫者的责任吗?

是的我同意。被调用方在错误情况下确定0的区域是正确答案似乎为时过早(特别是因为0是有效区域,因此无法分辨出错误和实际0的区别,尽管可能不适用于您的应用)。


实际上,您根本不需要检查异常,因为您必须调用方法之前检查参数。将结果检查为零不会区分合法参数0、0和非法否定参数。该API真是可怕的恕我直言。
BlackJack

C99和C ++ iostream的Annex K MS推送是API的示例,其中的钩子或标志从根本上改变了对故障的反应。
Deduplicator

37

他们致力于处理无法解决系统故障的航空关键应用。结果是 ...

那是一个有趣的介绍,给我的印象是这种设计的动机是避免在某些情况下“因为系统可能崩溃”而引发异常。但是,如果系统“由于异常而可能宕机”,则这清楚地表明:

  • 至少没有正确处理异常,也没有严格处理异常

因此,如果使用的程序AreaCalculator存在错误,则您的同事不希望该程序“过早崩溃”,而是返回一些错误的值(希望没人注意到它,或者希望没人对它做任何重要的事情)。那实际上是掩盖错误,以我的经验,它迟早会导致后续错误,而这些错误很难找到根本原因。

恕我直言,编写一个在任何情况下都不会崩溃但显示错误数据或计算结果的程序通常并不比使程序崩溃更好。唯一正确的方法是让呼叫者有机会注意到错误,进行处理,让他决定是否必须告知用户错误的行为,是否可以继续进行处理,或者是否更安全。完全停止该程序。因此,我建议以下之一:

  • 很难忽略一个函数会抛出异常的事实。文档和编码标准是您的朋友,定期的代码审查应支持组件的正确使用和正确的异常处理。

  • 训练团队在使用“黑匣子”组件时期望并处理异常,并牢记程序的整体行为。

  • 如果由于某些原因您认为无法让调用代码(或编写该代码的开发人员)正确使用异常处理,那么作为最后的选择,您可以设计一个具有显式错误输出变量且完全没有异常的API,例如

    CalculateArea(int x, int y, out ErrorCode err)

    因此,调用者很难忽略该函数可能会失败。但这是恕我直言,在C#中非常难看;它是C语言中一种古老的防御性编程技术,没有例外,因此如今通常无需工作。


3
“编写一个在任何情况下都不会崩溃但显示错误数据或计算结果的程序通常不会比使程序崩溃更好”。我完全同意,尽管我可以想象在航空领域我可能更喜欢飞机与关闭飞机计算机相比,仪器仍然显示错误的值。对于所有不太重要的应用程序,最好不要掩盖错误。
Trilarion

18
@Trilarion:如果用于飞行计算机的程序不包含适当的异常处理,则通过使组件不引发异常来“修复此问题”是一种非常误导的方法。如果程序崩溃,则应该有一些可以接管的冗余备份系统。例如,如果程序没有崩溃并显示错误的高度,则飞行员可能会认为“一切都很好”,而飞机则冲入了下一个山峰。
布朗

7
@Trilarion:如果飞行计算机显示错误的高度,并且飞机因此而坠毁,它也将无济于事(尤其是当备用系统在那里并且未通知需要接管时)。飞机计算机的备份系统不是一个新主意,谷歌将其称为“飞机计算机的备份系统”,我敢肯定,全世界的工程师总是将冗余系统构建到任何现实生活中的关键系统中(如果是因为没有松动的话)保险)。
布朗

4
这个。如果您无法承受程序崩溃的费用,则也无法承受静默地给出错误答案。正确的答案是在所有情况下都有适当的异常处理。对于网站而言,这意味着将全局处理程序将意外错误转换为500。对于更特定的情况,您可能还具有其他处理程序,例如,如果需要在一个元素失败的情况下继续进行处理,则在循环内使用try/ catch
jpmc26

2
错误的结果总是最糟糕的失败。这让我想起了优化的规则:“在优化之前先将其正确设置,因为更快地获得错误的答案仍然无济于事。”
Toby Speight

13

编写单元测试变得有些复杂,因为每次都必须测试异常标志。

任何带有n个参数的函数都比带有n-1个参数的函数更难测试。将其扩展到荒谬的地方,论点变成函数根本不应该具有参数,因为这使它们最容易测试。

编写易于测试的代码是一个好主意,但是将测试的简单性放在编写对必须调用它的人员有用的代码上是一个可怕的主意。如果问题中的示例具有确定是否引发异常的开关,则希望该行为的数字调用程序可能需要将其添加到函数中。复杂与过于复杂之间的界线在哪里?任何试图告诉您在所有情况下都适用的亮点的人都应该保持怀疑的态度。

另外,如果出现问题,您是否想立即知道?

那取决于您对错误的定义。问题中的示例将错误定义为“给定的尺寸小于零且shouldThrowExceptions为真”。如果尺寸小于零,则给定尺寸是错误的;如果shouldThrowExceptions为false ,则是错误的,因为开关会引发不同的行为。简而言之,这不是例外情况。

真正的问题在于,该开关的命名很差,因为它不能描述该函数的功能。是否给它起了更好的名字,例如treatInvalidDimensionsAsZero,您会问这个问题吗?

确定如何继续是呼叫者的责任吗?

呼叫者确实确定如何继续。在这种情况下,它将通过设置或清除来提前执行此操作,shouldThrowExceptions并且该功能将根据其状态运行。

该示例在病理上很简单,因为它只执行一次计算并返回。如果使它稍微复杂一点,例如计算数字列表的平方根之和,则抛出异常会给呼叫者带来无法解决的问题。如果我传递了的列表,[5, 6, -1, 8, 12]并且该函数在上抛出了异常-1,则我无法告诉该函数继续运行,因为它已经中止并丢弃了总和。如果列表是一个庞大的数据集,则在调用该函数之前生成没有任何负数的副本可能是不切实际的,因此我不得不提前说出应如何对待无效数字,要么以“只是忽略它们”的形式”,或者提供一个lambda来做出决定。

他的逻辑/理由是我们的程序需要做一件事,向用户显示数据。任何其他不会阻止我们这样做的异常都应忽略。我同意不应忽视它们,而应该冒泡并由适当的人员来处理,而不必为此处理标志。

同样,没有一种万能的解决方案。在该示例中,该函数可能已写入规范中,该规范说明了如何处理负尺寸。您想要做的最后一件事是通过在日志中填充“通常会在此处引发异常,但调用者不打扰”的消息来降低日志的信噪比。

作为那些年龄较大的程序员之一,我想请您离开我的草坪。;-)


我同意命名和意图非常重要,在这种情况下,正确的参数名称可以真正打开表,所以+1,但是1. our program needs to do 1 thing, show data to user. Any other exception that doesn't stop us from doing so should be ignored心态可能会导致用户根据错误的数据做出决定(因为实际上程序需要做一件事-帮助用户做出明智的决定)2.类似的情况bool ExecuteJob(bool throwOnError = false)通常很容易出错,并且导致很难仅仅通过阅读就可以推断出代码。
Eugene Podskal

@EugenePodskal我认为假设是“显示数据”的意思是“显示正确的数据”。发问者不是说成品不起作用,只是说它可能写为“错误”。在第二点上,我不得不看一些硬数据。我当前的项目中有一些经常使用的函数,它们具有一个throw / nothrow开关,并且比任何其他函数都难于推理,但这只是一个数据点。
Blrfl

好的答案,我认为这种逻辑不仅适用于OP,还适用于更广泛的情况。顺便说一句,正是出于这些原因,cpp的新版本具有抛出/不抛出版本。我知道有一些细微的差别,但是……
drjpizzle

8

安全关键和“正常”代码可能导致对“良好实践”的表象截然不同。有很多重叠之处-有些东西是有风险的,在两者中都应避免-但仍然存在重大差异。如果您添加要求确保能够响应的要求,则这些偏差会变得非常大。

这些通常与您期望的事情有关:

  • 对于git而言,相对于以下错误而言,错误的答案可能是非常糟糕的:拖延/中止/挂起甚至崩溃(相对于意外更改签入代码,这实际上不是问题)。

    但是:对于具有g力计算失速的仪表板,并阻止进行风速计算可能是不可接受的。

有些不太明显:

  • 如果您已经测试了很多,那么相对而言,一阶结果(如正确答案)就没有太大的担心。您知道您的测试将涵盖此内容。但是,如果存在隐藏状态或控制流,您将不知道这不会是导致更微妙的事情的原因。这很难通过测试排除。

  • 可证明的安全性是相对重要的。没有多少客户会坐下来对购买的货源是否安全进行推理。另一方面,如果您在航空市场...

这如何适用于您的示例:

我不知道。有许多思考过程可能在安全关键代码中采用了诸如“生产代码中无人投掷”之类的先导规则,在更常见的情况下,这很愚蠢。

有些与嵌入式有关,有些与安全有关,有些则与其他有关……有些很好(需要严格的性能/内存范围),有些不好(我们没有适当地处理异常,因此最好不要冒险)。大多数时候,即使知道为什么会这样做,也无法真正回答问题。例如,如果要使审核代码更加容易,而不是使代码变得更好,那么这是一种好习惯吗?你真的不知道。他们是不同的动物,需要区别对待。

综上所述,对我来说似乎有点怀疑,

安全关键的软件和软件设计决策可能不应该由陌生人在软件工程的stackexchange上做出。即使这是不良系统的一部分,也很有可能这样做。除了作为“深思熟虑的食物”之外,别无所求。


7

有时抛出异常不是最好的方法。不仅是由于堆栈展开,而且有时是因为捕获异常是有问题的,尤其是在语言或界面接缝处。

处理此问题的一种好方法是返回丰富的数据类型。此数据类型的状态足以描述所有快乐路径和所有不快乐路径。关键是,如果您与此功能(成员/全局/其他)交互,则将被迫处理结果。

话虽这么说,但这种丰富的数据类型不应强制采取行动。想象一下在您所在地区的示例var area_calc = new AreaCalculator(); var volume = area_calc.CalculateArea(x, y) * z;。似乎有用volume的区域应包含乘以深度的面积-可以是立方体,圆柱体等。

但是,如果area_calc服务关闭了怎么办?然后area_calc .CalculateArea(x, y)返回包含错误的丰富数据类型。将其乘以合法z吗?这是一个好问题。您可以强制用户立即进行检查。但是,这确实破坏了错误处理的逻辑。

var area_calc = new AreaCalculator();
var area_result = area_calc.CalculateArea(x, y);
if (area_result.bad())
{
    //handle unhappy path
}
var volume = area_result.value() * z;

var area_calc = new AreaCalculator();
var volume = area_calc.CalculateArea(x, y) * z;
if (volume.bad())
{
    //handle unhappy path
}

本质上,逻辑分布在两行中,在第一种情况下由错误处理划分,而在第二种情况下,所有相关逻辑都在一行上,然后进行错误处理。

在第二种情况下volume是丰富的数据类型。它不仅仅是一个数字。这使存储空间更大,并且volume仍然需要针对错误情况进行调查。另外volume,在用户选择处理错误之前,它可能会提供其他计算结果,从而使其可以在多个不同的位置显示出来。根据情况的具体情况,这可能是好事,也可能是坏事。

另外,volume也可能只是普通的数据类型-只是一个数字,但是错误情况会怎样呢?如果值处于满意状态,则该值可能会隐式转换。如果它处于不满意的状态,则可能会返回默认值/错误值(对于区域0或-1似乎是合理的)。或者,它可能在界面/语言边界的这一侧引发异常。

... foo() {
   var area_calc = new AreaCalculator();
   return area_calc.CalculateArea(x, y) * z;
}
var volume = foo();
if (volume <= 0)
{
    //handle error
}

... foo() {
   var area_calc = new AreaCalculator();
   return area_calc.CalculateArea(x, y) * z;
}

try { var volume = foo(); }
catch(...)
{
    //handle error
}

通过传递错误的或可能错误的值,将给用户带来很大的责任来验证数据。这是bug的来源,因为就编译器而言,返回值是合法的整数。如果未检查某些内容,则在出现问题时会发现它。第二种情况通过允许异常处理不快乐的路径,而快乐的路径遵循正常的处理,从而融合了两个方面的优点。不幸的是,它确实迫使用户明智地处理异常,这很难。

为了清楚起见,不满意的路径是业务逻辑(异常域)未知的情况,验证失败是一条快乐的路径,因为您知道如何通过业务规则(规则的域)进行处理。

最终的解决方案将是允许所有情况(在合理范围内)的解决方案。

  • 用户应该能够查询不良情况并立即处理
  • 用户应该能够对丰富类型进行操作,就像遵循了快乐路径一样,并且可以传播错误详细信息。
  • 用户应该能够通过强制转换(合理的隐式/显式)提取快乐路径值,从而生成不快乐路径的异常。
  • 用户应该能够提取出满意的路径值,或者使用默认值(是否提供)

就像是:

Rich::value_type value_or_default(Rich&, Rich::value_type default_value = ...);
bool bad(Rich&);
...unhappy path report... bad_state(Rich&);
Rich& assert_not_bad(Rich&);
class Rich
{
public:
   typedef ... value_type;

   operator value_type() { assert_not_bad(*this); return ...value...; }
   operator X(...) { if (bad(*this)) return ...propagate badness to new value...; /*operate and generate new value*/; }
}

//check
if (bad(x))
{
    var report = bad_state(x);
    //handle error
}

//rethrow
assert_not_bad(x);
var result = (assert_not_bad(x) + 23) / 45;

//propogate
var y = x * 23;

//implicit throw
Rich::value_type val = x;
var val = ((Rich::value_type)x) + 34;
var val2 = static_cast<Rich::value_type>(x) % 3;

//default value
var defaulted = value_or_default(x);
var defaulted_to = value_or_default(x, 55);

@TobySpeight足够公平,这些东西是上下文敏感的,并且具有范围。
Kain0_0

我认为这里的问题是'assert_not_bad'块。我认为这些最终将与原始代码尝试解决的位置相同。在测试中,需要注意这些,但是,如果它们确实是断言,则应在实际飞机上生产之前将其剥离。否则,有些好处。
drjpizzle

@drjpizzle我认为,如果添加一个用于测试的防护罩足够重要,那么在生产环境中运行时将防护罩留在适当位置也很重要。警卫本身的存在暗示着怀疑。如果您怀疑代码足以在测试期间保护它,则出于技术原因,您对此表示怀疑。即条件可能/确实发生。执行测试并不能证明该条件永远不会在生产中达到。这意味着可能存在某种已知情况,需要以某种方式进行处理。我认为问题是如何处理的。
Kain0_0

3

我将从C ++的角度回答。我很确定所有核心概念都可以移植到C#中。

听起来您的首选样式是“总是抛出异常”:

int CalculateArea(int x, int y) {
    if (x < 0 || y < 0) {
        throw Exception("negative side lengths");
    }
    return x * y;
}

对于C ++代码而言,这可能是一个问题,因为异常处理非常繁重 –它使故障案例运行缓慢,并使故障案例分配内存(有时甚至不可用),并且通常使事情难以预测。EH的重量级是您听到人们说“不要将异常用于控制流”之类的原因之一。

因此,某些库(例如<filesystem>)使用C ++调用的“双重API”或C#调用的Try-Parse模式(感谢Peter的技巧!)

int CalculateArea(int x, int y) {
    if (x < 0 || y < 0) {
        throw Exception("negative side lengths");
    }
    return x * y;
}

bool TryCalculateArea(int x, int y, int& result) {
    if (x < 0 || y < 0) {
        return false;
    }
    result = x * y;
    return true;
}

int a1 = CalculateArea(x, y);
int a2;
if (TryCalculateArea(x, y, a2)) {
    // use a2
}

您可以立即看到“双重API”的问题:大量的代码重复,没有为用户提供使用哪种API是“正确” API的指南,用户必须在有用的错误消息CalculateArea)和错误消息之间做出艰难的选择。速度TryCalculateArea),因为更快的版本采用了我们有用的"negative side lengths"异常并将其简化为无用的false东西-“出了点问题,不要问我是什么或在哪里。” (一些双重API使用更具表现力的错误类型,例如int errno或C ++的错误类型,std::error_code但这仍然不能告诉您错误发生在哪里 -只是它确实发生在某个地方。)

如果您不能决定代码的行为方式,则可以随时将决定权交给调用者!

template<class F>
int CalculateArea(int x, int y, F errorCallback) {
    if (x < 0 || y < 0) {
        return errorCallback(x, y, "negative side lengths");
    }
    return x * y;
}

int a1 = CalculateArea(x, y, [](auto...) { return 0; });
int a2 = CalculateArea(x, y, [](int, int, auto msg) { throw Exception(msg); });
int a3 = CalculateArea(x, y, [](int, int, auto) { return x * y; });

这实际上是您的同事正在做的事情;除了他将“错误处理程序”分解为全局变量之外:

std::function<int(const char *)> g_errorCallback;

int CalculateArea(int x, int y) {
    if (x < 0 || y < 0) {
        return g_errorCallback("negative side lengths");
    }
    return x * y;
}

g_errorCallback = [](auto) { return 0; };
int a1 = CalculateArea(x, y);
g_errorCallback = [](const char *msg) { throw Exception(msg); };
int a2 = CalculateArea(x, y);

将重要参数从显式函数参数转移到全局状态几乎总是一个坏主意。我不推荐它。(在您的情况下,它不是全局状态,而仅仅是实例范围的成员状态就可以稍微减轻一点(但不是很多)。)

此外,您的同事不必要地限制了可能的错误处理行为的数量。他没有决定使用任何错误处理lambda,而是只决定了两个:

bool g_errorViaException;

int CalculateArea(int x, int y) {
    if (x < 0 || y < 0) {
        return g_errorViaException ? throw Exception("negative side lengths") : 0;
    }
    return x * y;
}

g_errorViaException = false;
int a1 = CalculateArea(x, y);
g_errorViaException = true;
int a2 = CalculateArea(x, y);

这可能是所有这些可能策略中的“酸点”。通过强迫最终用户使用您恰好两个错误处理回调之一,您已经摆脱了最终用户的所有灵活性。并且您遇到了共享全球状态的所有问题;而且您仍然在各地支付该条件分支的费用。

最后,C ++(或带条件编译的任何语言)的通用解决方案将是迫使用户在编译时全局地决定整个程序,以便可以完全优化未使用的代码路径:

int CalculateArea(int x, int y) {
    if (x < 0 || y < 0) {
#ifdef NEXCEPTIONS
        return 0;
#else
        throw Exception("negative side lengths");
#endif
    }
    return x * y;
}

// Now these two function calls *must* have the same behavior,
// which is a nice property for a program to have.
// Improves understandability.
//
int a1 = CalculateArea(x, y);
int a2 = CalculateArea(x, y);

这种工作方式的一个示例是C和C ++中的assert,该在预处理器宏上确定其行为NDEBUG


如果返回一个std::optionalfrom TryCalculateArea(),则很容易在一个带有编译时标记的函数模板中统一双重接口的两个部分的实现。
重复数据删除器

@Deduplicator:也许带有std::expected。有了just std::optional,除非我误解了您提出的解决方案,否则它仍然会遭受我所说的:用户必须在有用的错误消息和速度之间做出艰难的选择,因为更快的版本会占用我们有用的"negative side lengths"异常并将其简化为无用的false“出了点问题,不要问我是什么地方。”
Quuxplusone

这就是libc ++ <filesystem>实际上执行与OP同事模式非常接近的原因:它std::error_code *ec一直贯穿API的每个级别,然后在底部进行道德上的等效if (ec == nullptr) throw something; else *ec = some error code。(它将实际内容抽象ifErrorHandler,但它是相同的基本思想。)
Quuxplusone

嗯,这将是保留扩展的错误信息而不抛出异常的一种选择。可能合适,或者不值得您付出额外的费用。
重复数据删除器

1
这个答案包含了很多好的想法……肯定需要更多的投票:-)
cmaster

1

我觉得应该提到您的同事从何处获得他们的模式。

如今,C#具有TryGet模式public bool TryThing(out result)。这使您可以获得结果,同时仍然让您知道该结果是否是有效值。(例如,所有int值都是的有效结果Math.sum(int, int),但是如果该值溢出,则该特定结果可能是垃圾)。但是,这是一个相对较新的模式。

在使用out关键字之前,您要么必须抛出一个异常(代价高昂,而调用方必须捕获该异常或杀死整个程序),然后为每个结果创建一个特殊的结构(类之前的类或泛型实际上是一件事情)以表示值和可能的错误(制作和膨胀软件非常耗时),或者返回默认的“错误”值(可能不是错误)。

您的同事使用的方法可以使他们在测试/调试新功能时异常早期失败,同时还为他们提供仅返回默认错误值的运行时安全性和性能(性能一直是30年前的关键问题)。现在,这就是软件编写的模式,并且预期的模式正在向前发展,因此即使现在有更好的方法,也很自然地继续这样做。这种模式很可能是从软件时代继承的,或者是您的大学从未长大的模式(旧习惯很难打破)。

其他答案已经涵盖了为什么这被认为是不好的做法,所以我只建议您阅读一下TryGet模式(也许还封装了对对象应赋予其调用者的承诺)。


out关键字之前,您要编写一个bool函数,该函数采用指向结果的指针,即ref参数。您可以在1998年的VB6中做到这一点。out关键字仅使您在编译时确定在函数返回时已分配参数,仅此而已。不过,这一个很好的实用模式。
Mathieu Guindon

@MathieuGuindon是的,但是GetTry尚不是一个众所周知的/已建立的模式,即使是这样,我也不完全肯定会使用它。毕竟,导致Y2K的部分原因是不能存储大于0-99的任何东西。
Tezra

0

有时候您想采用他的方法,但我不认为它们是“正常”情况。确定您所处情况的关键是:

他的逻辑/理由是我们的程序需要做一件事,向用户显示数据。任何其他不会阻止我们这样做的异常都应忽略。

检查要求。如果您的要求实际上说您有一份工作要向用户显示数据,那么他是对的。但是,根据我的经验,大多数情况下,用户关心显示哪些数据。他们想要正确的数据。有些系统确实只是想安静地失败,并让用户找出出了问题,但是我认为它们是该规则的例外。

发生故障后我要问的关键问题是“系统是否处于用户期望和软件不变式有效的状态?” 如果是这样,那么一定要返回并继续前进。实际上,这不是大多数程序中发生的情况。

至于标志本身,异常标志通常被认为是代码异味,因为用户需要以某种方式知道模块处于哪种模式才能理解该功能的运行方式。如果处于!shouldThrowExceptions模式下,用户需要知道他们有责任检测错误并在发生错误时保持期望和不变性。他们还负责在函数被调用的那一行。像这样的标志通常很容易混淆。

但是,它确实发生了。考虑到许多处理器允许在程序内更改浮点行为。希望拥有更宽松标准的程序可以通过更改寄存器(实际上是标志)来简单地做到这一点。诀窍是您应该非常谨慎,以免意外踩到别人的脚趾。代码通常会检查当前标志,将其设置为所需的设置,进行操作,然后再将其重新设置。这样,没有人会对更改感到惊讶。


0

这个特定的示例具有一个有趣的功能,可能会影响规则...

CalculateArea(int x, int y)
{
    if(x < 0 || y < 0)
    {
        if(shouldThrowExceptions) 
            throwException;
        else
            return 0;
    }
}

我在这里看到的是前提条件检查。前提条件检查失败意味着调用堆栈中的错误较高。因此,问题就变成了代码是否负责报告位于其他位置的错误?

这里的一些张力的是归因于一个事实,即这个接口呈现出原始的困扰 - xy被推测应该代表长度的真实测量。在特定领域类型是合理选择的编程环境中,我们实际上会将前提条件检查移到了数据源附近,换句话说,将数据完整性的责任进一步推到了调用堆栈的上方,在那里我们有了一个对上下文有更好的理解。

话虽这么说,我认为采用两种不同的策略来管理失败的支票根本没有什么错。我更喜欢使用组合来确定所使用的策略。Feature标志将在合成根目录中使用,而不是在库方法的实现中使用。

// Configurable dependencies
AreaCalculator(PreconditionFailureStrategy strategy)

CalculateArea(int x, int y)
{
    if (x < 0 || y < 0) {
        return this.strategy.fail(0);
    }
    // ...
}

他们致力于处理无法解决系统故障的航空关键应用。

国家交通和安全委员会真的很好。我可能会建议灰胡子的替代实现技术,但是我不愿意与他们争论在错误报告子系统中设计舱壁。

从更广泛的角度来看:企业的成本是多少?网站崩溃比生命攸关的系统要便宜得多。


我喜欢关于在保持现代化的同时保留所需灵活性的另一种方法的建议。
drjpizzle

-1

方法要么处理异常,要么处理异常,因此无需使用C#等语言中的标志。

public int Method1()
{
  ...code

 return 0;
}

如果...代码中出现问题,则调用者将需要处理该异常。如果没有人处理该错误,程序将终止。

public int Method1()
{
try {  
...code
}
catch {}
 ...Handle error 
}
return 0;
}

在这种情况下,如果在...代码中发生错误,则Method1正在处理该问题,程序应继续进行。

处理异常的位置由您决定。当然,您可以通过捕获和不执行任何操作来忽略它们。但是,我将确保您仅忽略预期会发生的某些特定类型的异常。忽略(exception ex)很危险,因为您不想忽略某些异常,例如关于内存不足之类的系统异常。


3
OP发布的当前设置与确定是否愿意抛出异常有关。OP的代码不会导致不必要的吞噬,例如内存不足异常。如果有的话,异常使系统崩溃的断言意味着代码库不会捕获异常,因此不会吞噬任何异常。OP的业务逻辑有意或无意抛出的那些。
Flater

-1

这种方法打破了“快速失败,很难失败”的理念。

为什么快速失败:

  • 故障发生的速度越快,故障的可见症状就越接近实际的故障原因。这使调试更加容易-在最佳情况下,堆栈跟踪的第一行中就是错误行。
  • 失败的速度越快(并适当地捕获错误),则混淆其余程序的可能性就越小。
  • 您越难失败(即,引发异常而不是仅返回“ -1”代码或类似的代码),则调用者实际上更在乎错误,而不仅仅是继续使用错误的值,这很有可能。

的缺点不是没有又快又狠:

  • 如果您避免了可见的故障,即假装一切都很好,那么您往往会很难发现实际的错误。想象一下,您的示例的返回值是某个例程的一部分,该例程计算100个区域的总和。即,调用该函数100次并求和返回值。如果您静默地抑制错误,则根本无法找到实际错误发生的位置。并且以下所有计算将完全错误。
  • 如果您延迟失败(通过为区域返回不可能的返回值,例如“ -1”),则会增加函数调用者不理会它并忘记处理错误的可能性;即使他们掌握了即将发生的故障信息。

最后,实际的基于异常的错误处理的好处是,您可以给出“带外”错误消息或对象,可以轻松地挂接错误日志,警报等,而无需在域代码中写一行额外的代码。

因此,不仅有简单的技术原因,而且还有“系统”原因,这使得快速失败非常有用。

归根结底,在我们当前的时代中,并非一帆风顺的是,异常处理是轻量级且非常稳定的,这只是犯罪的一半。我完全理解抑制异常很好的想法是从哪里来的,但是这不再适用了。

尤其是在您的特定情况下,您甚至可以选择是否引发异常:这意味着调用者无论如何都要做出决定。因此,让调用者捕获异常并适当地处理它完全没有缺点。

评论中的一点:

在其他答案中已经指出,当您使用的硬件是飞机时​​,在关键应用中快速失败是不希望的。

快速失败并不意味着您的整个应用程序崩溃。这意味着在发生错误的地方,它是本地故障。在OP的示例中,计算某个区域的低级方法不应以错误的值静默替换错误。它应该清楚地失败。

链上的某些调用者显然必须捕获该错误/异常并进行适当处理。如果在飞机上使用此方法,则可能会导致某些错误LED点亮,或者至少显示“错误计算区域”而不是错误的区域。


3
在其他答案中已经指出,当您使用的硬件是飞机时​​,在关键应用中快速失败是不希望的。
HAEM

1
@HAEM,那么这对于快速而努力失败的含义是一种误解。我已经在答案中添加了一段有关此的内容。
AnoE

即使其目的不是要“那么努力”,也要冒这样的风险。
drjpizzle

这很重要,@ drjpizzle。如果您习惯于快速失败,那么根据我的经验,这不是“冒险”或“玩这种火”。互惠生。这意味着您已经习惯于思考“如果我在这里遇到异常会发生什么情况”,其中“这里”意味着无处不在,并且您倾向于知道当前正在编程的地方是否会遇到重大问题(飞机失事,无论那种情况)。玩火将指望一切都将正常进行,并且怀疑每个组件都假装一切都很好……
AnoE
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.