为什么CLR允许突变装箱的不可变值类型?

12

我有一个情况,我有一个简单的、不可变的值类型:

public struct ImmutableStruct
{
    private readonly string _name;

    public ImmutableStruct( string name )
    {
        _name = name;
    }

    public string Name
    {
        get { return _name; }
    }
}

当我封装一个值类型的实例时,我通常希望在解封后,封装的内容保持不变。但令我惊讶的是,情况并非如此。使用反射可以轻易地通过重新初始化其中包含的数据来修改封箱内存。

class Program
{
    static void Main( string[] args )
    {
        object a = new ImmutableStruct( Guid.NewGuid().ToString() );

        PrintBox( a );
        MutateTheBox( a );
        PrintBox( a );;
    }

    private static void PrintBox( object a )
    {
        Console.WriteLine( String.Format( "Whats in the box: {0} :: {1}", ((ImmutableStruct)a).Name, a.GetType() ) );
    }

    private static void MutateTheBox( object a )
    {
        var ctor = typeof( ImmutableStruct ).GetConstructors().Single();
        ctor.Invoke( a, new object[] { Guid.NewGuid().ToString() } );
    }
}

示例输出:

盒子里有什么:013b50a4-451e-4ae8-b0ba-73bdcb0dd612 :: ConsoleApplication1.ImmutableStruct 盒子里有什么:176380e4-d8d8-4b8e-a85e-c29d7f09acd0 :: ConsoleApplication1.ImmutableStruct

(实际上MSDN中有一个小提示,表明这是预期的行为)

为什么CLR会以这种微妙的方式允许更改装箱的(不可变的)值类型?我知道只读并不能保证,我也知道使用“传统”的反射可以轻松地更改值实例。当引用到Box复制到意外的地方时,这种行为就成为了一个问题。

我所想的一件事是,这使得在值类型上使用反射成为可能——因为System.Reflection API仅使用object。但是使用Nullable<>值类型时,反射会遇到问题(如果它们没有值,则会被装箱为null)。这里的情况是怎样的呢?


从未见过像那样调用ConstructorInfo - leppie
2
您正在调用ConstructorInfo(object, object[]),重新运行构造函数。这不会违反任何只读性。解决方法通常是医生建议的:如果疼痛,请停止操作。 - Hans Passant
@Hans:当然,我知道这一切(readonly不是clr强制保证,尽管这取决于具体的实现,CLI符合实现不禁止实施readonly)。当然,我永远不会在生产代码中使用它。对于值类型来说,重新运行构造函数是一个非常奇怪的特例,而且肯定是不可能用引用类型实现的。 - Johannes Rudolph
3个回答

15

就CLR而言,盒子不是不可变的。实际上,在C++/CLI中,我相信有一种直接突变它们的方式。

然而,在C#中,拆箱操作总是会进行复制 - 防止您从突变盒子的是C# 语言,而非CLR。IL解包指令只是提供了一个带类型的指针到盒子里面。从ECMA-335第III部分4.32节(unbox指令):

解包指令将值类型的装箱表示形式obj(类型为O)转换为valueTypePtr(受控可变性托管指针(§1.8.1.2.2),类型&),即其未装箱形式。valuetype是元数据标记(typeref、typedef或typespec)。在obj中包含的valuetype类型必须是验证器可分配给valuetype的。

box不同,后者需要复制值类型以在对象中使用,unbox不要求从对象中复制值类型。通常,它只是计算已经存在于装箱对象内部的值类型的地址。

C#编译器始终生成导致unbox后跟复制操作或等效于unbox后跟ldobjunbox.any的IL。生成的IL当然不是C#规范的一部分,但这是(C# 4规范的第4.3节):

对于非空值类型的拆箱操作,首先检查对象实例是否为给定的非空值类型的装箱值,然后将该值从实例中复制出来。

对于一个可空类型,如果源操作数是null,则将其拆箱为null值;否则将对象实例拆箱为可空类型底层类型的封装结果。

在这种情况下,您正在使用反射,从而绕过了C#提供的保护(这是一种非常奇怪的反射使用方式...调用目标实例上的构造函数非常奇怪-我以前从未见过这种情况)。


我也不知道,这就让我在这里想了。C#保证始终会进行复制,这很好,但是只有使用unbox.any才能实现(无论是否可空都会创建副本!)。相反,ECMA关于unbox的说法是:“通常,unbox只是计算已经存在于装箱对象内部的值类型的地址。” - Johannes Rudolph
据我所知,unbox不需要复制,因为它输出一个地址,通常与ldobj一起使用(4.13 Partition III):"ldobj指令将一个值复制到评估堆栈"。无论如何,我认为只有csc v1使用了unbox,csc v2及更高版本似乎对所有内容都发出unbox.any。感谢您详尽的回答! - Johannes Rudolph

3

补充一点。

在IL中,如果使用一些'不安全'(即不可验证)的代码,可以改变装箱值。

C#的等价物大致如下:

unsafe void Foo(object o)
{
  void* p = o;
  ((int*)p) = 2;
}

object a = 1;
Foo(a);
// now a is 2

0

只有在以下情况下,值类型实例才应被视为不可变:

  1. 不存在任何创建结构实例的方法与默认实例在任何方面都不可区分。例如,没有字段的结构可以合理地被认为是不可变的,因为没有东西可以改变。
  2. 持有实例的存储位置由永远不会改变它的某个私有对象持有。

虽然第一个场景是类型而不是实例的属性,但“可变性”的概念对于无状态类型而言相当无关紧要。这并不意味着这些类型是无用的(*),而是对于它们来说,可变性的概念是无关紧要的。否则,即使假装是不可变的,持有任何状态的结构类型也是可变的。请注意,具有讽刺意味的是,如果不尝试使结构体“不可变”,而仅公开其字段(可能使用工厂方法而不是构造函数来设置其值),通过其“构造函数”来改变结构体实例将不起作用。

(*)一个没有字段的结构类型可以实现接口并满足new约束;无法使用传入泛型类型的静态方法,但可以定义一个简单的结构来实现接口,并将结构的类型传递给能够创建新虚拟实例并使用其方法的代码。例如,可以定义一个类型FormattableInteger<T> where T:IFormatableIntegerFormatter,new(),其ToString()方法将执行T newT = new T(); return newT.Format(value);。使用这种方法,如果有一个包含20,000个FormattableInteger<HexIntegerFormatter>的数组,则默认存储整数的方法将作为类型的一部分存储一次,而不是每个实例都存储20,000次。


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