在using语句中修改值类型是否属于未定义行为?

11

这个问题实际上是这个问题的一个衍生问题,但我认为它值得有自己的答案。

根据ECMA-334第15.13节(关于using语句,以下称为资源获取):

资源获取中声明的局部变量是只读的,并且必须包含初始化器。如果嵌套语句尝试通过赋值或++--运算符修改这些局部变量,或者将它们作为refout参数传递,则会发生编译时错误。

这似乎解释了为什么下面的代码是非法的。

struct Mutable : IDisposable
{
    public int Field;
    public void SetField(int value) { Field = value; }
    public void Dispose() { }
}

using (var m = new Mutable())
{
    // This results in a compiler error.
    m.Field = 10;
}

那这个呢?

using (var e = new Mutable())
{
    // This is doing exactly the same thing, but it compiles and runs just fine.
    e.SetField(10);
}
上面的代码片段在C#中是否未定义和/或非法?如果合法,这段代码与上面摘自规范的节选之间有什么关系?如果它是非法的,那么为什么它能够运行?是否存在一些微妙的漏洞允许它,还是它能够运行只是纯粹的运气(因此,一个人不应该依赖这种看似无害的代码的功能)?

调用方法是使用赋值吗?是使用 ++-- 运算符吗?还是将其作为 refout 参数传递? - Anon.
@Anon:这就是我的问题。在值类型上调用修改该值状态的方法本质上与赋值没有什么区别,对吧?这就是为什么严格禁止修改字段的原因? - Dan Tao
你从哪里想出这些东西的?值得注意的是,属性也不起作用。这只是底层的方法调用,防止远程成为原因。这听起来像一个漏洞。好吧,缺陷。提到Eric Lippert的名字通常会让他来拜访。完成。 - Hans Passant
如下所述:使用与此无关。它引入的唯一影响是使变量只读。而且,不允许通过赋值运算符更改只读值类型的值,这似乎不仅包括var本身,还包括所有成员。在只读值上允许函数调用,并且函数可以更改结构体成员的值。对象类型的行为不同。 - Mario The Spoon
1
正如一些答案所表明的那样,只有 e 这个只读变量的 副本 被修改了。这是明确定义和完全定义的行为。每当一个结构体变量被视为只读时,每次在该变量上调用实例方法时,都会首先进行复制,然后在复制上调用该方法。因此,如果该方法被证明对结构体进行了改变,则只会影响到复制品,而不会保留复制品。来源:在 foreach 循环中更改另一个结构体中的结构体 - Jeppe Stig Nielsen
4个回答

3
我会按照标准的方式阅读,这样做可以使内容更加通俗易懂。
using( var m = new Mutable() )
{
   m = new Mutable();
}

被禁止 - 理由似乎很明显。

但是对于可变结构体,我不知道为什么不允许。因为对于类,这段代码是合法的,并且可以编译通过...(我知道是对象类型...)

另外,我不明白为什么改变值类型的内容会危及RA。有人能解释一下吗?

也许某个进行语法检查的人误读了标准 ;-)

Mario


2
+1 这就是我理解的。它并没有说明它们是“不可变的”,只是本地变量本身是“只读的”。然而,我无法评论结构版本失败的原因。 - user7116
1
原因是它是一个值类型。在只读值类型上调用赋值运算符是不允许的。编译器发出的错误消息并不准确。这意味着在一个只读结构体上,你不能调用成员的赋值运算符。 - Mario The Spoon
我指的是这种赋值方式 myStruct.Field = ... 而不是 myStruct = ...。我和你一样迷惑,为什么前者被禁止了。 - user7116
似乎C#也将结构体的成员视为与结构体实例本身相同的值类型。因此,对于C#,myStruct.field =和myStruct =是相同的(在使用var的赋值运算符上)。我无法更清楚地表达它。但由于值类型始终被视为整体(在堆栈上分配为一组),对成员的任何操作似乎也遵守与对结构体实例本身进行操作的相同规则(我无法在标准中跟踪到这一点)。 - Mario The Spoon
当您声明一个只读结构并尝试对成员进行赋值时,情况似乎是相同的。编译器也不允许使用“private readonly Mutable xxx = new Mutable(); { xxx.Field = 10; }”。 - Mario The Spoon
1
但是为了回答你最初的问题:你调用函数的代码是完全合法的,不应该有任何负面影响! - Mario The Spoon

2

总之

struct Mutable : IDisposable
{
    public int Field;
    public void SetField( int value ) { Field = value; }
    public void Dispose() { }
}


class Program

{
    protected static readonly Mutable xxx = new Mutable();

    static void Main( string[] args )
    {
        //not allowed by compiler
        //xxx.Field = 10;

        xxx.SetField( 10 );

        //prints out 0 !!!! <--- I do think that this is pretty bad
        System.Console.Out.WriteLine( xxx.Field );

        using ( var m = new Mutable() )
        {
            // This results in a compiler error.
            //m.Field = 10;
            m.SetField( 10 );

            //This prints out 10 !!!
            System.Console.Out.WriteLine( m.Field );
        }



        System.Console.In.ReadLine();
    }

与我之前写的相反,我建议不要在using块内使用函数来修改结构体。这似乎可以工作,但将来可能会停止工作。

Mario


2
我怀疑它能够编译和运行的原因是SetField(int)是一个函数调用,而不是赋值或者ref或者out参数调用。编译器通常无法知道SetField(int)是否会改变变量。
根据规范,这种做法是完全合法的。
考虑其他选择。在C#编译器中进行静态分析以确定给定的函数调用是否会改变值显然是成本禁止的。规范旨在避免所有这些情况。
另一种选择是,对于在using语句中声明的值类型变量,不允许任何方法调用。这可能不是一个坏主意,因为在结构上实现IDisposable只会带来麻烦。但当C#语言首次开发时,我认为他们对使用结构体有很高的期望(正如您最初使用的GetEnumerator()示例所示)。

2
这听起来很合理,我倾向于同意你的观点。但问题仍然存在,即这种行为是否确实被定义了。 - Dan Tao
2
该值不会通过 ++--refout 改变,因此我认为它被定义为允许的。底线:不要声明可变结构体。它们很容易令人困惑。 - dtb
1
@dtb:我能理解你的推理;我不确定的是,规范中是否提到了那些特定运算符、refout仅仅是作为局部变量应该是只读的事实的例子,还是作为唯一不允许的情况。无论哪种方式,对我来说都有点模糊不清。虽然我总体上同意你不使用可变结构体,但它们确实存在于BCL中(考虑我的现在已编辑掉的将List<T>.Enumerator包装在using中的示例)。 - Dan Tao
2
相比之下,readonly 字段仅在类型的构造函数/初始化器内部被视为变量;在任何其他地方,它都被视为普通值。如果您在一个保存在 readonly 字段中的结构体上调用了 SetValue 方法(并且您不在外部类型的构造函数/初始化器内部),那么您正在修改该字段值的副本。该字段本身保持不变。 - LukeH
一个本应该(而且仍然应该)很容易解决的解决方案是,微软定义一个属性来指定结构体方法或属性是否会修改“this”,并让编译器拒绝尝试调用被标记为修改“this”的只读值上的方法,同时允许调用那些没有被标记的方法。当实现像ThreadSafeBitVector这样的东西时,最好说MyVector.ClearBits(3,10);而不是ThreadSafeBitVector.ClearBits(ref MyVector,3,10);,但编译器会允许前者,而它不应该这样做。 - supercat
显示剩余11条评论

2
这种行为是未定义的。在C# 4.0规范第7.6.4节(成员访问)的末尾,Peter Sestoft在The C# Programming language中指出:“如果字段是只读的……那么结果是一个值”所述的两个项目在字段具有结构类型且该结构类型具有可变字段(不推荐组合-请参见有关此点的其他注释)时具有略微令人惊讶的效果。他提供了一个例子。我创建了自己的示例,下面显示更多细节。
然后,他接着说:
“有点奇怪的是,如果s是使用语句声明的结构类型的局部变量,并且也具有使s成为不可变的影响,则s.SetX()会按预期更新s.x。”
在这里,我们看到其中一位作者承认这种行为是不一致的。根据第7.6.4节,readonly字段被视为值并且不会更改(副本更改)。因为第8.13节告诉我们使用语句将资源视为只读:
嵌入语句中的资源变量是只读的,使用 using 语句中的资源应该像只读字段一样行为。根据 7.6.4 规则,我们应该处理一个值而非一个变量。但令人惊讶的是,资源的原始值确实会改变,正如这个示例所演示的那样。
    //Sections relate to C# 4.0 spec
    class Test
    {
        readonly S readonlyS = new S();

        static void Main()
        {
            Test test = new Test();
            test.readonlyS.SetX();//valid we are incrementing the value of a copy of readonlyS.  This is per the rules defined in 7.6.4
            Console.WriteLine(test.readonlyS.x);//outputs 0 because readonlyS is a value not a variable
            //test.readonlyS.x = 0;//invalid

            using (S s = new S())
            {
                s.SetX();//valid, changes the original value.  
                Console.WriteLine(s.x);//Surprisingly...outputs 2.  Although S is supposed to be a readonly field...the behavior diverges.
                //s.x = 0;//invalid
            }
        }

    }

    struct S : IDisposable
    {
        public int x;

        public void SetX()
        {
            x = 2;
        }

        public void Dispose()
        {

        }
    }    

这种情况很奇怪。总之,避免创建只读的可变字段。

点赞。当一个结构体变量在只读“上下文”中被考虑时,该变量的实例方法不会直接被调用,它们会在“安全副本”上被调用,就像我刚才在评论问题中所说的那样。 - Jeppe Stig Nielsen
我想知道在using中变量不够只读的“bug”是否与(IDisposable)ss装箱有关。当我们通过List<>进行foreach时,形式上我们有类似于using (var e = theList.GetEnumerator()) { ... }的东西,其中e是一个必须被改变以使foreach进展的结构体。方法MoveNext()会改变一个结构体吗?我将在有更多时间时进一步研究这个问题。 - Jeppe Stig Nielsen
原始的s - 在using()内部不会被改变。 s.SetX()行会改变一个看不见的副本。此外,由于编译器的黑客,使用Roslyn编译时将不会有装箱操作。阅读Lippert的博客获取更多详细信息。 - l33t

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