使用回调函数而不是抛出异常?

5

想法

我正在考虑在C# / .NET中使用回调函数代替抛出异常。

优缺点

优点:

  • 没有像未经检查的异常控制流一样的隐藏goto
  • 代码更加清晰,特别是涉及多个异常时
  • 抛出的异常在方法签名中有所记录,调用者被迫考虑如何处理异常,但可以轻松地将应用程序范围的异常处理程序、"UnhandledExceptionHandler"或null传递给它们。因此,它们有点像"软"检查的异常,但更易于维护,因为异常可以通过重载方法后抛出,或者通过不再在异常处理程序上调用"handle"来移除异常)。
  • 也适用于异步调用
  • 异常处理程序可以处理在不同位置抛出的多个异常
  • 明确指出哪些异常应该被处理。仍然可以使用普通方式抛出异常,例如不希望被处理的异常,如"NotImplementedException"。

缺点:

  • 不符合C#和.NET的习惯用法
  • 抛出方法必须立即返回返回值以中断控制流。如果返回类型是值类型,则会出现困难。
  • ?(见下面的问题)

问题

我可能会忽略一些关键缺点,因为我想知道为什么这种方法没有被使用。我错过了哪些缺点?

示例:

与其这样写:

void ThrowingMethod() {
    throw new Exception();
}

并且

void CatchingMethod() {
    try {
         ThrowingMethod();
    } catch(Exception e) {
         //handle exception
    }
}

我会去做

void ThrowingMethod(ExceptionHandler exceptionHandler) {
    exceptionHandler.handle(new Exception());
}

void CatchingMethod() {
     ThrowingMethod(exception => */ handle exception */ );
}

使用

delegate void ExceptionHandler(Exception exception);

在某个地方定义了一个方法,"handle(...)" 是一个扩展方法,用于检查空值、检索堆栈跟踪,并在抛出异常时没有异常处理程序时可能抛出 "UnhandledException"。


在以前没有抛出异常的方法中抛出异常的示例

void UsedToNotThrowButNowThrowing() {
   UsedToNotThrowButNowThrowing(null);
}

//overloads existing method that did not throw to now throw
void UsedToNotThrowButNowThrowing(ExceptionHandler exceptionHandler) {
    //extension method "handle" throws an UnhandledException if the handler is null
    exceptionHandler.handle(exceptionHandler);
}

Example with methods that return values

TResult ThrowingMethod(ExceptionHandler<TResult> exceptionHandler) {
        //code before exception
        return exceptionHandler.handle(new Exception()); //return to interrupt execution
        //code after exception
    }

TResult CatchingMethod() {
     return ThrowingMethod(exception => */ handle exception and return value */ );
}

使用

delegate TResult ExceptionHandler<TResult>(Exception exception);

我的第一个评论是,你缺少一个捕获低层堆栈中捕获的异常的结构。例如,您正在处理文件,使用许多方法。然后在某个时候,您遇到了读取错误(故障硬件等)。在Exception情况下,您可以抛出IOError并在远处捕获它,给用户一个一般性错误,回溯堆栈,释放资源并完成。 - Bart Friederichs
5
此问题似乎不适合讨论,因为它涉及到计算机编程范式的概念而非一个具体的编程问题。请尝试访问programmers.stackexchange.com进行提问。 - Bart Friederichs
请不要传递 null... 否则在被调用者中,您将不得不编写 if (h==null) { do something; } else { safe to call h.something() } - Ali
3个回答

0

可扩展性。

正如 @mungflesh 正确指出的那样,你必须传递这些处理程序。我的第一个关注点不是开销,而是可扩展性:它会影响方法签名。这可能会导致与 Java 中的已检查异常相同的可扩展性问题(我不知道 C#,我只做 C++ 和一些 Java)。

想象一下深度为 50 的调用堆栈(在我看来并不极端)。有一天,一个深入链中没有抛出异常的被调用者变成了一个现在可以抛出异常的方法。如果它是未经检查的异常,您只需要更改顶层代码以处理新错误。如果它是已检查的异常或者您应用了您的想法,则必须更改整个调用链中涉及的所有方法签名。不要忘记签名更改会传播:您更改了这些方法的签名,您必须在其他调用这些方法的所有其他地方更改您的代码,可能会生成更多的签名更改。简而言之,可扩展性差。 :(


这里是一些伪代码,展示了我的意思。使用未检查的异常,您可以按照以下方式处理深度为50的调用堆栈中的更改:
f1() {
  try {    // <-- This try-catch block is the only change you have to make
    f2();  
  }
  catch(...) {
    // do something with the error
  }
}

f2() { // None of the f2(), f3(), ..., f49() has to be changed
  f3();
}

...

f49() {
  f50();
}

f50() {
  throw SomeNewException; // it was not here before
}

处理与您的方法相同的变化:

f1() {
  ExceptionHandler h;
  f2(h);
}

f2(ExceptionHandler h) { // Signature change
  f3(h); // Calling site change
}

...

f49(ExceptionHandler h) { // Signature change
  f50(h); // Calling site change
}

f50(ExceptionHandler h) {
  h.SomeNewException(); // it was not here before
}

所有涉及的方法(f2...f49)现在都有一个新的签名,并且调用位置也必须更新(例如,f2()变成了f2(h),等等)。请注意,f2...f49甚至不需要知道这个改变,然而它们的签名和调用位置都必须被修改。


换句话说:现在所有的中间调用都必须处理错误处理程序,即使这是他们根本不需要知道的细节。使用未经检查的异常,这些细节可以被隐藏起来。

未经检查的异常确实是“隐藏的goto控制流”,但至少它们具有良好的可扩展性。毫无疑问,它们可能会很快导致难以维护的混乱...

+1,不过这是一个有趣的想法。


我认为我理解了你所描述的方式。那个没有抛出的方法将会自己调用添加了委托参数的新方法,该委托参数现在通过传递 null 或 "NoEventHandler" 作为新异常的 EventHandler 来“抛出”。因此,您基本上将没有抛出的方法的代码移动到抛出异常的新方法中,并从没有抛出的方法中调用该方法。你知道我的意思吗?还是我仍然有所不明白? - user65199
@cHao:是的,Java有未检查异常,但你仍然被迫处理已知不会抛出的已检查异常。例如,在Java中,即使你已经检查了文件是否存在,你仍然被迫处理FileNotFoundException。我认为调用者而不是被调用者应该决定如何处理(未检查异常的优点),而不必深入挖掘整个调用树的文档(已检查异常的优点)。 - user65199
@ExercitusVir 好的,在我的例子中,f1 知道某些地方可能会出错,而且 f1 知道如何处理异常。换句话说,是 f1 拥有 try-catch 块或创建 ExceptionHandler。在你的例子中,是 f49,因此可扩展性问题尚不明显。通常,顶层方法知道可能出现什么问题以及如何处理它,所有中间调用都不需要担心。因此,请相应地创建一个示例,其中 f1 创建 ExceptionHandler,而 f50 成为抛出方法。 - Ali
@ExercitusVir 好的,我接受你处理null值的方法。但我仍然不明白在f1必须创建ExceptionHandler(因为只有他知道/关心)并且f50成为调用链深处的抛出方法(之前是非抛出异常)时,你如何处理这种情况(而不像我在我的答案中显示的传播异常处理程序)。当我在处理大型Java项目时,我遇到了非常相似的检查异常情况。在我看来,你的方法具有完全相同的可扩展性问题。 - Ali
1
@Ali: 我并不是checked exceptions的忠实拥护者,我只是在逆向思考。但是请记住,所有异常,无论是checked还是unchecked,都可能导致可扩展性问题。每一个冒泡到上层的异常都有可能成为应用程序杀手。现在你又添加了一个新的异常 - 直到某些奇怪的情况触发它,才会知道它的存在。确实,要更改50级深度的函数使其抛出checked异常很困难。但它应该被更改。潜在异常是接口的一部分,而接口应该在将50层调用放置在其周围之前进行完善。 :) - cHao
显示剩余17条评论

0

如果我理解正确,如果一个方法中存在两种可能的异常,那么该方法需要接受两个不同的参数。为了模拟已检查的异常并让调用者知道可能出现的异常,您必须为不同类型的可能异常传递不同的处理程序。因此,在多态情况下,当您定义接口或抽象类时,您会强制将可能的异常写入未编写的代码中,以便具体实现不允许生成新类型的异常。

例如,假设您正在实现Stream类和FileStream具体类。您必须传递一个文件未找到异常处理程序,这是不好的,因为它强制MemoryStream接受文件未找到异常处理程序,或者另一方面,您不允许在FileStream中生成文件未找到异常,因为签名不允许。


0

首先,您需要将这些处理程序传递给应用程序中几乎每个方法,这会增加很多开销。这是一个非常沉重的依赖关系,并且在构建应用程序之前需要做出决定。

其次,还有处理系统抛出的异常和第三方程序集中的其他异常的问题。

第三,异常意味着在抛出它的点停止程序的执行,因为它确实是“异常”,而不仅仅是可以处理的错误,从而允许执行继续。


非常好的观点。谢谢!如果存在异常,您仍然可以使用try...catch,并在没有处理程序的情况下抛出“UnhandledException”,但我肯定需要调查性能影响。这很可能是一个交易破坏者。 - user65199

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