在C#中处理嵌套的"using"语句

30

我注意到我的代码中嵌套using语句的层级最近有所增加。原因可能是我越来越多地使用async/await模式,这通常会至少再添加一个using用于CancellationTokenSourceCancellationTokenRegistration

那么,如何减少using的嵌套,以使代码看起来不像圣诞树?类似的问题在Stack Overflow上已经被问过了,我想总结一下我从答案中学到的内容。

使用相邻的using而不需要缩进。以下是一个虚假的例子:

using (var a = new FileStream())
using (var b = new MemoryStream())
using (var c = new CancellationTokenSource())
{
    // ... 
}

这可能有效,但通常在使用之间有一些代码(例如,可能太早创建另一个对象):

// ... 
using (var a = new FileStream())
{
    // ... 
    using (var b = new MemoryStream())
    {
        // ... 
        using (var c = new CancellationTokenSource())
        {
            // ... 
        }
    }
}

将同类型对象组合起来(或强制转换为IDisposable),放入单个using中,例如:

// ... 
FileStream a = null;
MemoryStream b = null;
CancellationTokenSource c = null;
// ...
using (IDisposable a1 = (a = new FileStream()), 
    b1 = (b = new MemoryStream()), 
    c1 = (c = new CancellationTokenSource()))
{
    // ... 
}

这跟上面的限制一样,而且更加啰嗦,阅读起来也不太流畅,在我看来。

将该方法重构成几个方法。

就我所知,这是一种首选方式。但我很好奇,为什么下面的方法会被认为是一种坏习惯呢?

public class DisposableList : List<IDisposable>, IDisposable
{
    public void Dispose()
    {
        base.ForEach((a) => a.Dispose());
        base.Clear();
    }
}

// ...

using (var disposables = new DisposableList())
{
    var a = new FileStream();
    disposables.Add(a);
    // ...
    var b = new MemoryStream();
    disposables.Add(b);
    // ...
    var c = new CancellationTokenSource();
    disposables.Add(c);
    // ... 
}

[更新] 在评论中有很多有效观点认为嵌套 using 语句可以确保每个对象都调用 Dispose,即使一些内部的 Dispose 调用会抛出异常。然而,还存在一个有点难以理解的问题:除了最外层的异常之外,所有由于处理嵌套 'using' 帧而可能抛出的嵌套异常都将丢失。更多信息请参见这里


1
您可以尝试像方法提取这样的技术。我的意思是尝试将这个特定的方法分成小的独立部分并将它们移动到不同的方法中。通过这种方式,您也许能够将多个“using”块移到不同的方法中。 - Hossain Muctadir
3
通常情况下,如果你使用超过2个嵌套的using语句,那么你的方法可能已经有点太复杂了,因此需要进行重构。如果你更或多或少地遵循“干净代码”原则,通常不会出现太多嵌套的using语句。 @MuctadirDinar:同样的想法! - alzaimar
1
通常情况下,我发现3层嵌套就足够了。我觉得普通的嵌套缩进非常易读,比你提出的其他任何替代方案都更清晰。也许在4或5层嵌套后可能会有点混乱,但即使是这种情况,我宁愿有明显的代码稍微长一点,也不愿意使用非标准的模式进行研究,当我阅读代码时。现在的显示器普遍都很宽,所以我不会过于担心水平空间。 - Joe Enos
没错。在极端情况下考虑重构。代码通常需要注释,而重构(拆分成方法)既可以注释代码,又可以使其更易读。 - alzaimar
2
3 显然很糟糕:在编写代码时需要特别注意不要忘记处理对象,而且会产生非常难以阅读的异常代码。顺便说一下:正如问题 3 变体所展示的那样,如果早期的 Dispose 抛出异常,则存在永远无法处理某些对象的可能性。 - Alexei Levenkov
显示剩余4条评论
5个回答

16
在单个方法中,第一个选项将是我的选择。但在某些情况下,DisposableList非常有用。特别是当您有许多需要处理的可处理字段时(在这种情况下,您无法使用using)。该实现是一个不错的起点,但它有一些问题(由Alexei在评论中指出):
  1. 需要您记住将项目添加到列表中。(尽管您也可以说您必须记住使用using。)
  2. 如果其中一个处理方法抛出异常,则停止处理过程,留下其余未经处理的项目。
让我们解决这些问题:
public class DisposableList : List<IDisposable>, IDisposable
{
    public void Dispose()
    {
        if (this.Count > 0)
        {
            List<Exception> exceptions = new List<Exception>();

            foreach(var disposable in this)
            {
                try
                {
                    disposable.Dispose();
                }
                catch (Exception e)
                {
                    exceptions.Add(e);
                }
            }
            base.Clear();

            if (exceptions.Count > 0)
                throw new AggregateException(exceptions);
        }
    }

    public T Add<T>(Func<T> factory) where T : IDisposable
    {
        var item = factory();
        base.Add(item);
        return item;
    }
}

现在我们捕获来自Dispose调用的任何异常,并在遍历所有项后抛出一个新的AggregateException。我添加了一个帮助器Add方法,允许更简单的使用:
using (var disposables = new DisposableList())
{
    var file = disposables.Add(() => File.Create("test"));
    // ...
    var memory = disposables.Add(() => new MemoryStream());
    // ...
    var cts = disposables.Add(() => new CancellationTokenSource());
    // ... 
}

感谢您提出的有关聚合异常的重要观点,以及关于代码本身的整洁性和可维护性的建议,特别是在因素分解方面。 - noseratio - open to work
有趣的是,聚合异常与嵌套“using”的标准行为不同,其中一些异常可能会明显丢失。 - noseratio - open to work
@Noseratio 我并不感到惊讶。这是在规范中定义的行为。这通常很难调试,因为你看到的异常并不是问题的原因。我认为上面的行为实际上会使这些问题更容易调试,如果它们确实出现了。 - Mike Zboray
1
@Noseratio 实际上,忽略我上一句话。AggregateException 仍然可能隐藏您问题的来源。由于我们无法访问导致我们进入列表的 Dispose 的异常,因此您需要决定是否值得吞咽 Dispose 异常。我同意 Marc Gravell 在这里的声明(https://dev59.com/EHRB5IYBdhLWcg3wl4Oc#577620),通常原始异常是有趣的。无论如何,我至少会尝试记录它们。 - Mike Zboray
这是我尝试避免吞咽或替换内部异常的方法,@mikez。 - noseratio - open to work

6

您应该始终参考您的虚拟示例。当这不可能时,就像您提到的那样,很有可能将内部内容重构为单独的方法。如果这也没有意义,您应该坚持使用第二个示例。其他所有代码似乎都不太易读、不太明显,而且也不常见。


6

我建议继续使用块。为什么?

  • 它清楚地显示了你对这些对象的意图
  • 你不必再尝试着处理try-finally块,这样容易出错而且代码可读性较低。
  • 稍后可以重构嵌套的using语句(将其提取到方法中)
  • 你不会通过创建自己的逻辑来混淆你的同事程序员,增加新的抽象层次。

1
另一个选项是简单地使用 try-finally 块。这可能看起来有点啰嗦,但它确实减少了不必要的嵌套。
FileStream a = null;
MemoryStream b = null;
CancellationTokenSource c = null;

try
{
   a = new FileStream();
   // ... 
   b = new MemoryStream();
   // ... 
   c = new CancellationTokenSource();
}
finally 
{
   if (a != null) a.Dispose();
   if (b != null) b.Dispose();
   if (c != null) c.Dispose();
}

6
我建议你不要使用 "using",因为这只是一种避免嵌套的技巧,即个人口味。不要使用技巧。实际上,嵌套本身并没有什么不好的。 - alzaimar
2
+0. 你的建议与问题中的第三个选项存在相同的问题 - 在编写代码时需要特别注意不要忘记处理对象,这会产生不寻常的代码,更难以阅读(此外,你的建议鼓励复制粘贴,增加了更多错误的风险)。两个版本都存在某些对象永远不会被处理的可能性,如果之前的“Dispose”抛出异常。 - Alexei Levenkov
1
@alzaimar 为什么要使用 using 语句?它只是一个方便提供的 try-finally 块的简短形式。使用其中任何一种都是个人口味,我并没有建议嵌套是一件坏事,我只是在回复 OP 的愿望。 - codemonkeh
2
这对我来说是最好的选择。因为它清楚地显示了使用了什么,需要被处理掉什么。同时,Using语句只是try和Finally块的快捷方式。它只是提高了编码的可读性,而不是try和finally。所以对于程序员来说,什么易于阅读取决于他自己,这对每个程序员都是不同的。 - CreativeManix
4
如果早期的 Dispose 调用引发异常,那么你的方法无法清理资源。这样就打败了 using 语句的整个目的。请注意修改。 - Sam Harwell
显示剩余7条评论

1
你上次的建议掩盖了一个事实,即 abc 应该显式地进行处理。这就是为什么它很丑陋。
正如我在评论中提到的,如果你使用干净的代码原则,通常不会遇到这些问题。

我倾向于同意,但请详细说明这与我的第二个代码片段(嵌套的using块,每个都带有花括号)有何不同?你是说分离使处置更加明确吗? - noseratio - open to work
1
尽量不要使用技巧来避免嵌套。问问自己:“这段代码可读吗?我的同事能够理解而不需要询问吗?”如果两个问题的答案都是“是”,那么一切都没问题。如果你倾向于注释你的代码,请将其拆分。这种技术在大多数情况下可以避免过多的嵌套层次。 - alzaimar

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