使用IDisposable和"using"作为实现异常安全的"作用域行为"方式是否滥用?

116

在C++中,我经常使用一种方法,即让一个类A通过其构造函数和析构函数处理另一个类B的状态进入和退出条件,以确保如果该范围内的某些内容抛出异常,则在退出范围时B将具有已知状态。尽管这不是RAII的纯粹意义上的含义,但它仍然是一种成熟的模式。

在C#中,我经常想要执行:

class FrobbleManager
{
    ...

    private void FiddleTheFrobble()
    {
        this.Frobble.Unlock();
        Foo();                  // Can throw
        this.Frobble.Fiddle();  // Can throw
        Bar();                  // Can throw
        this.Frobble.Lock();
    }
}

需要像这样完成

private void FiddleTheFrobble()
{
    this.Frobble.Unlock();

    try
    {            
        Foo();                  // Can throw
        this.Frobble.Fiddle();  // Can throw
        Bar();                  // Can throw
    }
    finally
    {
        this.Frobble.Lock();
    }
}

如果我想在FiddleTheFrobble返回时保证Frobble状态。代码加上下面这段会更好:

```java public Frobble FiddleTheFrobble() { synchronized(frobbleLock) { // do some fiddling } return frobble; } ```
private void FiddleTheFrobble()
{
    using (var janitor = new FrobbleJanitor(this.Frobble))
    {            
        Foo();                  // Can throw
        this.Frobble.Fiddle();  // Can throw
        Bar();                  // Can throw
    }
}

其中FrobbleJanitor的样子大致上

class FrobbleJanitor : IDisposable
{
    private Frobble frobble;

    public FrobbleJanitor(Frobble frobble)
    {
        this.frobble = frobble;
        this.frobble.Unlock();
    }

    public void Dispose()
    {
        this.frobble.Lock();
    }
}

这就是我想要的方法。现实则告诉我们,因为我想要使用 需要 使用 FrobbleJanitorusing。我可以将其视为代码审查问题,但有些事情让我感到不安。

问题:上述是否被认为是滥用usingIDisposable


24
仅从类和方法名称来看,就值得点赞了:-)。说实话,还有实际问题需要解答。 - Joey
3
@Johannes:是啊,我能理解为什么——大多数人只是随便弹奏他们的小提琴,但我必须反对这种做法。;-) 顺便说一下,你的名字和我的很相似,点个赞。 - Johann Gerell
也许你可以在这个例子中使用 lock(this) { .. } 作用域来实现相同的结果? - oɔɯǝɹ
1
@oɔɯǝɹ:你可以使用lock()来防止不同线程同时访问某个东西,但这与我所描述的情况无关。 - Johann Gerell
1
下一个版本的C#中将会有IScopeable,但没有Finalizer :) - Jon Raynor
显示剩余2条评论
12个回答

75

我认为这是对 using 语句的滥用。我知道我的观点在少数派。

我认为这是滥用的原因有三个:

首先,因为我期望 "using" 被用于 使用资源在完成后处理它。改变程序状态不是 使用资源,并且将其更改回来也不是 处理 任何东西。因此,“using” 来进行状态修改和恢复是滥用;代码对于普通读者来说是误导的。

其次,因为我期望 "using" 被用于 礼貌而非必需。当你使用 "using" 处理完文件后丢弃它的原因不是因为这样做是必需的,而是因为这样做是正确和礼貌的 -- 可能会有其他人正在等待使用该文件,因此告知 "处理完成" 是道德上正确的事情。我期望我可以重构 "using",使得使用的资源被长时间持有,并稍后处理,而这样做的唯一影响是略微不便其他进程。具有语义上的程序状态影响的 "using" 块是滥用的,因为它将程序状态的重要变化隐藏在一个看起来是为了方便和礼貌而存在的构造中,而不是为了必需。

第三,您的程序操作取决于其状态;小心地操作状态的需要正是我们首先讨论这个问题的原因。让我们考虑如何分析您的原始程序。

如果您将此带到我的办公室进行代码审查,我会首先问的问题是 "如果抛出异常,锁定 frobble 是否正确?" 从您的程序中可以明显看出,无论发生什么事情,它都会强制重新锁定 frobble。在未知状态下抛出异常。 我们不知道 Foo、Fiddle 或 Bar 抛出异常的原因,或者他们对其他状态进行了什么修改,而这些修改没有被清理。你能 说服 我,在这种可怕的情况下,重新锁定总是正确的吗?

也许是,也许不是。我的观点是,对于以原始方式编写的代码,代码审查者知道要询问该问题。使用 "using" 的代码,我不知道要问这个问题;我认为 "using" 块分配资源,使用它一段时间,然后在完成后礼貌地处理它,而不是在异常情况下使用"using"块的结束括号对我的程序状态进行修改,当任意多的程序状态一致性条件被违反时

使用 "using" 块具有语义效果,使得此程序片段:

}

非常有意义。当我看到那个单独的右花括号时,我不会立即想到“该右花括号具有对程序全局状态产生深远影响的副作用”。但是,当你像这样滥用“using”时,它突然变得重要起来。

如果我看到你的原始代码,第二件事我会问的是:“如果在解锁后,在try语句块进入之前抛出异常会发生什么?”如果运行的是非优化程序集,编译器可能在try之前插入了一个无操作指令,而在线程中断异常发生在无操作指令上是有可能发生的。虽然这种情况很少见,但在现实生活中确实会发生,特别是在Web服务器上。在这种情况下,解锁已经发生,但锁却从未发生,因为异常是在try之前抛出的。这段代码完全有可能存在此问题,实际上应该写成:

bool needsLock = false;
try
{
    // must be carefully written so that needsLock is set
    // if and only if the unlock happened:

    this.Frobble.AtomicUnlock(ref needsLock);
    blah blah blah
}
finally
{
    if (needsLock) this.Frobble.Lock();
}

或许它会,或许不会,但我知道该如何提问。使用 "using" 版本时,它容易出现相同的问题:在锁定 Frobble 之后、进入与 using 相关的 try-protected 区域之前,可能会抛出线程中止异常。但是对于 "using" 版本,我认为这是一个 "那又怎样?" 的情况。如果发生这种不幸的情况,那很遗憾,但我认为 "using" 只是为了礼貌而存在,并没有改变非常重要的程序状态。我假设如果在恰好错误的时间发生一些可怕的线程中止异常,那么垃圾回收器最终将通过运行终结器来清理那个资源。


18
虽然我回答问题的方式不同,但我认为这是一个有说服力的帖子。然而,我对你声称“using”是礼貌而不是正确性问题的观点有异议。我认为资源泄漏是正确性问题,尽管它们通常不是高优先级的bug,但仍然是微小的正确性问题。因此,“using”块已经对程序状态产生了语义影响,它们所防止的不仅仅是给其他进程带来的“不便”,而可能是破坏了环境。这并不意味着问题暗示的不同类型的语义影响也是适当的。 - kvb
15
资源泄漏是一种正确性问题。然而,如果没有使用 "using" 语句并不会导致资源泄漏;当终结器运行时,资源最终会被清理。清理可能不会像您期望的那样及时和彻底,但它最终会发生。 - Eric Lippert
7
很棒的帖子,这是我的两分钱 :) 我怀疑很多对 using 的“滥用”是因为它在C#中是一个贫穷者的面向方面编程织机。开发人员真正想要的是一种保证某些公共代码将在块的末尾运行而不必复制该代码的方法。C#今天不支持AOP(除了MarshalByRefObject和PostSharp之外)。然而,使用语句的编译器转换使得通过重定义决定性资源处理的语义来实现简单的AOP成为可能。虽然这不是一个好的做法,但有时值得放弃。 - LBushkin
11
虽然我一般同意你的立场,但有些情况下重新利用using的好处太有吸引力了,难以放弃。我能想到的最好的例子是跟踪和报告代码时间:using(TimingTrace.Start("MyMethod")) {/* 代码 */}。再次强调,这是AOP - Start()捕获块开始前的起始时间,Dispose()捕获结束时间并记录活动。它不改变程序状态,并且对异常是不可知的。它还很有吸引力,因为它避免了相当多的管道工作,而且经常使用作为一种模式可以减轻混淆的语义。 - LBushkin
6
有趣的是,我一直认为使用锁是支持RAII的一种手段。在我看来,将锁视为一种资源类型相当直观。 - Brian
显示剩余6条评论

36
我不完全这么认为。IDisposable 技术上确实是用于具有非托管资源的对象,但是 using 指令只是一种实现常见模式的巧妙方式: try .. finally { dispose }
一个纯粹主义者会认为 '是 - 这是滥用',从纯粹主义的角度来看确实如此;但是我们大多数人并不是从纯粹主义的角度编码,而是从半艺术的角度。在我看来,以这种方式使用 'using' 结构相当有艺术性。
你可能应该在 IDisposable 之上再加一个接口,以将其进一步推远,并向其他开发人员解释该接口为什么意味着 IDisposable。
虽然还有很多其他的替代方法,但最终我想不出任何一个与这种方式一样简洁的方法,所以就采用它吧!

2
我相信这也是一个艺术化的运用"using"。我认为Samual Jack的帖子链接Eric Gunnerson的评论证实了这种"using"的实现方式。我可以看到Andras和Eric都提出了很好的观点。 - IAbstract
1
我之前与Eric Gunnerson讨论过这个问题,根据他的说法,C#设计团队的意图是使用using来表示作用域。事实上,他推测如果在lock语句之前设计了using,可能甚至不会有lock语句。它本来应该是一个带有Monitor调用或其他内容的using块。更新:刚刚意识到下一个答案是由语言团队的Eric Lippert回答的。也许C#团队本身对此并没有完全达成一致意见?我的讨论背景是TimedLock类:http://bit.ly/lKugIO - Haacked
2
@Haacked - 很有趣,你的评论完全证明了我的观点。你曾经和团队中的一个人谈过,他完全赞同这个想法;然后指出来自同一团队的Eric Lippert持不同意见。从我的角度来看,C#提供了一个利用接口的关键字,并且还产生了一个漂亮的代码模式,在许多场景下都能发挥作用。如果我们不应该滥用它,那么C#应该找到另一种方式来强制执行。无论如何,设计师们可能需要在这里整理好他们的思路!同时:using(Html.BeginForm()) 先生?我可以使用吗? :) - Andras Zoltan

29
埃里克·冈纳森(Eric Gunnerson)曾是 C# 语言设计团队成员,他给了这个问题相同的答案:

Doug问道:

关于具有超时的锁语句……

我以前用过这个技巧来处理多个方法中的常见模式。通常是锁获取,但也有其他一些情况。问题在于,它总让人觉得像一个黑客,因为对象不仅仅是可以被处理而已,更像是“在作用域结束时回调”。

Doug,

当我们决定使用 using 语句时,我们决定将其命名为“using”,而不是更特定于处理对象的名称,正是为了完全适用于这种情况。


4
那句话应该表达他所指的“完全这种情况”,因为我怀疑他并不是指这个未来的问题。 - Frank Schwieterman
4
@Frank Schwieterman: 我完成了引语。显然,C#团队的人认为using特性不仅仅用于资源处理是有限制的。 - paercebal

27

如果您只想要一些干净的、有限范围的代码,您也可以使用lambda表达式,如下所示

myFribble.SafeExecute(() =>
    {
        myFribble.DangerDanger();
        myFribble.LiveOnTheEdge();
    });

.SafeExecute(Action fribbleAction)方法包装了try-catch-finally块。


1
啊!好的,我猜你的意思是SafeExecute是一个Fribble方法,它在包装的try/finally中调用了Unlock()Lock()。那我道歉了。我一开始把SafeExecute看成了一个通用扩展方法,因此提到了缺乏入口和出口状态。我的错。 - Johann Gerell
1
非常小心这种方法。在捕获本地变量的情况下,可能会出现一些危险的微妙意外的生命周期延长! - jason
4
为什么要隐藏try/catch/finally?这会使得你的代码对他人来说难以阅读。 - Frank Schwieterman
@Jason:方法可以改为myFribble.SafeExecute(Action<TDangerousFrobble> frobbleAction)。DangerousFrobble实例可以是Fribble类的私有成员,或者DangerousFrobble可以完全声明为Fribble的私有内部类。然后,DangerousFrobble实例仅在此方法的范围内向调用者公开。 - herzmeister
2
@Yukky Frank:这并不是我的想法去“隐藏”什么,而是提问者的要求。:-P 话虽如此,这个要求至少是关于“不要重复自己”的问题。你可能有许多方法都需要相同的样板代码来清晰地获取/释放某些东西,但你不希望将其强加给调用者(可以考虑封装)。另外,我们也可以更明确地给方法命名,比如使用.SafeExecute(...),以便充分传达它们的功能。 - herzmeister
显示剩余5条评论

11

这是一个很棘手的问题。IDisposable有一个契约,由finalizer支持。在您的情况下,finalizer是无用的。您不能强制客户使用using语句,只能鼓励他这样做。但您可以通过像这样的方法来强制使用:

void UseMeUnlocked(Action callback) {
  Unlock();
  try {
    callback();
  }
  finally {
    Lock();
  }
}

但是,如果没有lambda表达式的话,这有点棘手。尽管如此,我已经像你一样使用了IDisposable。

然而,在你的帖子中有一个细节使得这个方法非常接近反模式。你提到这些方法可能会抛出异常。调用者不能忽略这个问题。他可以采取三种行动:

  • 什么都不做,因为异常无法恢复。正常情况下,调用Unlock并没有什么影响。
  • 捕获和处理异常
  • 在他的代码中恢复状态,并让异常传递到调用链中。

后两种需要调用者显式编写try块。现在using语句就成了障碍。它可能导致客户端陷入一种昏迷状态,让他相信你的类正在处理状态,不需要进行额外的工作。这几乎从来都是不准确的。


强制大小写是由“herzmeister der welten”上面给出的,我认为——即使原帖似乎不喜欢这个例子。 - Benjamin Podszun
是的,我把他的“SafeExecute”解释成了一个通用的扩展方法。现在我明白了,它很可能是一个“Fribble”方法,该方法在包装的try/finally中调用“Unlock()”和“Lock()”。我的错。 - Johann Gerell
忽略一个异常并不意味着它是不可恢复的。这意味着它将在堆栈上方被处理。例如,异常可以用于优雅地退出线程。在这种情况下,您必须正确释放资源,包括已锁定的锁。 - paercebal

8
一个真实世界的例子是ASP.net MVC的BeginForm。简单来说,你可以写成:
Html.BeginForm(...);
Html.TextBox(...);
Html.EndForm();

或者

using(Html.BeginForm(...)){
    Html.TextBox(...);
}

Html.EndForm会调用Dispose函数,而Dispose函数只是输出</form>标签。好处在于{ }括号创建了一个可见的“范围”,使得更容易看出表单内和表单外的内容。
我不会过度使用它,但实际上IDisposable只是一种说法,“你必须在完成后调用此函数”。MvcForm使用它确保表单已关闭,Stream使用它确保流已关闭,您可以使用它确保对象已解锁。
个人而言,只有以下两个规则为真时才会使用它,但它们是由我随意设置的:
1. Dispose应该是一个总是需要运行的函数,因此除了Null-Checks之外不应该有任何条件。 2. 在Dispose()之后,对象不应再次使用。如果我想要一个可重复使用的对象,我宁愿给它打开/关闭方法而不是dispose。因此,当尝试使用已释放对象时,我会抛出InvalidOperationException。
最后,这一切都关乎期望。如果一个对象实现了IDisposable,我就认为它需要进行一些清理,所以我会调用它。我认为通常比使用“Shutdown”函数要好。
话虽如此,我不喜欢这行代码:
this.Frobble.Fiddle();

由于FrobbleJanitor现在“拥有”Frobble,我想是否最好改为在Janitor上调用Frobble上的Fiddle?


关于您的“我不喜欢这行代码” - 当我思考问题时,我其实短暂地想过以那种方式来实现,但我不想让我的问题语义变得混乱。但是我有点同意您的观点。有点。 :) - Johann Gerell
2
点赞您提供了一个来自 Microsoft 的示例。请注意,在您提到的情况下,有一个特定的异常需要抛出:ObjectDisposedException。 - Antitoon

4

注:由于我的C++背景,我的观点可能存在偏见,因此应该针对可能的偏见评估我的答案的价值...

C#语言规范怎么说?

引用C#语言规范

8.13 using语句

[...]

资源是实现System.IDisposable的类或结构,其中包括一个名为Dispose的无参数方法。使用资源的代码可以调用Dispose来表示不再需要该资源。如果未调用Dispose,则自动处理最终会因垃圾回收而发生。

当然,使用资源的代码是从using关键字开始并一直持续到与using关联的作用域结束的代码。

所以我想这是可以的,因为锁是一种资源。

也许using关键字的选择不太好。也许它应该被称为scoped

那么,我们几乎可以将任何东西视为资源。一个文件句柄。一个网络连接……一个线程?

一个线程???

使用(或滥用)using关键字?

滥用using关键字以确保线程工作在退出作用域之前结束,这会是闪亮的吗?

Herb Sutter似乎认为这很闪亮,因为他提供了一种有趣的使用IDispose模式等待线程工作结束的方法:

http://www.drdobbs.com/go-parallel/article/showArticle.jhtml?articleID=225700095

这里是从文章中复制粘贴的代码:

// C# example
using( Active a = new Active() ) {    // creates private thread
       …
       a.SomeWork();                  // enqueues work
       …
       a.MoreWork();                   // enqueues work
       …
} // waits for work to complete and joins with private thread

虽然没有提供Active对象的C#代码,但是该代码使用IDispose模式编写,其C ++版本包含在析构函数中。通过查看C ++版本,我们可以看到一个析构函数,在退出之前等待内部线程结束,如本文的另一个摘录所示:

~Active() {
    // etc.
    thd->join();
 }

所以,就赫伯来说,这是“闪亮”的。

4
我们在代码库中广泛使用这种模式,在许多地方都可以看到 - 我相信这里也讨论过。总的来说,我认为这样做没有什么问题,它提供了一种有用的模式,对真正的害处没有影响。

4
Chiming in on this: 我赞同这里的大部分观点,这是很脆弱但很有用的。我想向您指出 System.Transaction.TransactionScope 类似于您想要达到的效果。
一般来说,我喜欢这种语法,它减少了很多无关紧要的东西。但请考虑给助手类起一个好名字——也许是...Scope,就像上面的例子一样。名称应该表明它封装了一段代码。*Scope、*Block或类似的名称应该可以胜任。

1
"Janitor"来自于Andrei Alexandrescu关于作用域守卫的一篇早期C++文章,甚至在ScopeGuard进入Loki之前就已经存在了。 - Johann Gerell
@Benjamin:我喜欢TransactionScope类,以前没听说过。也许是因为我在.Net CF领域,它没有被包含... :-( - Johann Gerell

3
我认为你的问题的答案是否定的,这不会滥用IDisposable接口。
我理解IDisposable接口的方式是,一旦对象被处理,就不能再使用它(除非您可以随意调用其Dispose方法)。
由于每次到达using语句时都会显式创建一个新的FrobbleJanitor对象,因此您从未两次使用相同的FrobbeJanitor对象。而且,由于它的目的是管理另一个对象,Dispose似乎适合释放这个(托管)资源的任务。
(顺便说一下,展示正确实现Dispose的标准示例代码几乎总是建议释放托管资源,而不仅仅是未经处理的资源,例如文件系统句柄。)
唯一让我担心的是,使用var janitor = new FrobbleJanitor()不够清晰,而更明确的try..finally块可以直接看到Lock和Unlock操作。但采取哪种方法可能归结为个人偏好的问题。

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