在“Using……”块中,结构体类型的深拷贝也会被处理吗?

6
假设我有一个实现了IDisposable接口的结构体类型,如果我使用以下代码:
using (MyStruct ms = new MyStruct())
{
     InnerAction(ms);   //Notice "InnerAction" is "InnerAction(MyStruct ms)"
}

当然,在使用块之后,ms被处理掉了。但是,“InnerAction”中的结构体怎么样呢?它是否因为深度复制而仍然存在,还是也被处理掉了?
如果它仍然存在(没有被处理掉),我必须对“InnerAction”使用“ref”吗?
请给我证明 :)
谢谢大家。

1
没有“深拷贝”,因此没有需要处理的内容。 - Henrik
11
IDisposable 结构类型是一个不好的想法。 - xanatos
1
可变结构体是一个不好的想法。为了实现可释放性,建议必须具有状态,因此必须是可变的。 - weston
3个回答

4

情况比你想象的还要糟糕:ms甚至没有被处理。

原因在于using语句创建了一个内部副本,然后在try/finally语句中调用副本上的dispose方法。

考虑这个LinqPad示例

void Main()
{
    MyStruct ms;
    using (ms = new MyStruct())
    {
        InnerAction(ms);
    }

    ms.IsDisposed.Dump();
    _naughtyCachedStruct.IsDisposed.Dump();
}

MyStruct _naughtyCachedStruct;

void InnerAction(MyStruct s)
{
    _naughtyCachedStruct = s;
}

struct MyStruct : IDisposable
{
    public Boolean IsDisposed { get; set; }

    public void Dispose()
    {
        IsDisposed = true;
    }
}

以下是反编译的IL代码:

IL_0000:  nop         
IL_0001:  ldloca.s    01 // CS$0$0000
IL_0003:  initobj     UserQuery.MyStruct
IL_0009:  ldloc.1     // CS$0$0000
IL_000A:  dup         
IL_000B:  stloc.0     // ms
IL_000C:  dup         
IL_000D:  stloc.0     // ms
IL_000E:  stloc.2     // CS$3$0001
IL_000F:  nop         
IL_0010:  ldarg.0     
IL_0011:  ldloc.0     // ms

请注意,在IL_000E中,编译器生成了一个本地变量(CS$3$0001),并将ms的副本存储在那里。稍后...
IL_001B:  ldloca.s    02 // CS$3$0001
IL_001D:  constrained. UserQuery.MyStruct
IL_0023:  callvirt    System.IDisposable.Dispose
IL_0028:  nop         
IL_0029:  endfinally  

Dispose会针对这个本地变量调用,而不是针对存储在位置0的ms进行调用。

结果是msInnerAction持有的副本都没有被释放。

结论:不要在using语句中使用结构体。

编辑:如@Weston在评论中指出的那样,您可以手动将结构体装箱并对装箱实例进行操作,因为它随后将位于堆上。通过这种方式,您可以使实例被释放,但如果您将其在using语句中转换回结构体,则最终只会存储一个在实例被释放之前的副本。此外,装箱会消除保持离开堆的好处,这可能是您在这里所追求的。

MyStruct ms = new MyStruct();
var disposable = (IDisposable)ms;
using (disposable)
{
    InnerAction(disposable);
}

((MyStruct)disposable).IsDisposed.Dump();

这是我想到的,但是我看到了这个:https://dev59.com/u3M_5IYBdhLWcg3wjj0p#1330596 它说“值类型不使用装箱”... - weston
它们没有被限制在using引用堆上的对象的方式中。上面的IL显示了这一点。 - codekaizen
哦,是的,我猜拆箱可能会解决这个问题,因为它会引用相同的实例。 - weston
我刚刚检查了一下:就在 using 操作右侧的结构实例上(堆上的装箱结构体),它确实“保存”了它:http://share.linqpad.net/tihvmj.linq。 - codekaizen
在上一个例子中,你看过 ms.IsDisposed.Dump(); 了吗?它是false,所以装箱版本不是原始的。 - weston
1
如果返回值是True,那是因为它没有被装箱到堆上;只有装箱实例才是“true”。 - codekaizen

2

您的代码行为取决于MyStruct的内部实现。

考虑以下实现:

struct MyStruct : IDisposable
{
    private A m_A = new A();
    private B m_B = new B();

    public void Dispose()
    {
        m_A.Dispose();
        m_B.Dispose();
    }
}

class A : IDisposable
{
    private bool m_IsDisposed;
    public void Dispose()
    {
        if (m_IsDisposed)
            throw new ObjectDisposedException();
        m_IsDisposed = true;
    }
}

class B : IDisposable
{
    private bool m_IsDisposed;
    public void Dispose()
    {
        if (m_IsDisposed)
            throw new ObjectDisposedException();
        m_IsDisposed = true;
    }
}

在上面的代码中,MyStruct实现只将Dispose调用委托给其他引用类型。在这种情况下,在using块结束后,您示例中的实例可能被视为“Disposed”。可以通过保存对表示类是否已处理的布尔成员的内部引用来实现类似的行为。
但是,在@codekaizen的答案和@xanatos的评论中的示例中,行为是仅处理副本,如所示。
总之,您有能力使您的结构使用Disposed模式正确运行,但我建议避免这样做,因为这很容易出现错误。

1
这是一个很好的见解,本质上是 System.Threading.CancellationToken 使用的模式,它用于提供对共享状态的安全访问。 - codekaizen

0

我认为C#的实现者决定使用using与结构体一起使用时,会导致该结构体上的所有方法(包括Dispose)都接收到它的副本,这种行为会导致代码变慢,而不是操作原始数据,排除了本来可能有用的语义,并且在任何情况下都不能使本来错误的代码正常工作。尽管如此,这种行为就是它的样子。

因此,我建议不要以任何预期修改结构体本身的方式实现IDisposable。只有符合以下一种或两种模式的结构类型才应该实现IDisposable

  1. 该结构用于封装对对象的不可变引用,并且该结构的行为就像该对象的状态是其自身的一样。我想不出在哪里看到过使用此模式来封装需要处理的对象,但这似乎是可能的。

  2. 该结构的类型实现了继承自IDisposable的接口,其中一些实现需要清理。如果该结构本身不需要清理并且其处理方法什么也不做,则在副本上调用处理方法的事实除了系统将浪费时间在调用一个无用的方法之前制作一个无用的结构副本外,没有任何影响。

请注意,C#的using语句的行为不仅在涉及Dispose时会出问题,而且在涉及其他方法的调用时也会出问题。考虑:

void showListContents1(List<string> l)
{
  var en = l.GetEnumerator();
  try
  {
    while(en.MoveNext())
      Console.WriteLine("{0}", en.Current);
  }
  finally
  {
    en.Dispose();
  }
}

void showListContents(List<string> l)
{
  using(var en = l.GetEnumerator())
  {
    while(en.MoveNext())
      Console.WriteLine("{0}", en.Current);
  }
}

虽然这两种方法看起来相同,但第一种方法可以工作,而第二种方法则不行。在第一种方法中,每次调用 MoveNext 都会作用于变量 en,因此会推进枚举器。而在第二种方法中,每次调用 MoveNext 都会作用于 en 的不同副本;它们都无法推进枚举器 en。尽管第二种情况中对 enDispose 调用不会有问题,因为该副本什么也不做。不幸的是,C# 处理结构类型 using 参数的方式也会破坏 using 语句内的代码。


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