首页 文章

值类型是否按定义不可变?

提问于
浏览
33

我经常读到 struct 应该是不可变的 - 根据定义它们不是吗?

你认为 int 是不可变的吗?

int i = 0;
i = i + 123;

好像没问题 - 我们得到一个新的 int 并将其分配回 i . 那这个呢?

i++;

好的,我们可以把它想象成一条捷径 .

i = i + 1;

struct Point 怎么样?

Point p = new Point(1, 2);
p.Offset(3, 4);

这真的改变了点 (1, 2) 吗?我们难道不应该把它当作 Point.Offset() 返回新点的下列快捷方式吗?

p = p.Offset(3, 4);

这种想法的背景是这样的 - 没有身份的 Value 类型怎么可能是可变的?您必须至少两次查看它以确定它是否发生了变化 . 但是如果没有身份,你怎么能这样做呢?

我不想通过考虑 ref 参数和拳击来使这个推理复杂化 . 我也知道 p = p.Offset(3, 4);p.Offset(3, 4); 更能表达不变性 . 但问题仍然存在 - 根据定义,值不是不可变的值类型吗?

UPDATE

我认为至少涉及两个概念 - 变量或字段的可变性以及变量值的可变性 .

public class Foo
{
    private Point point;
    private readonly Point readOnlyPoint;

    public Foo()
    {
        this.point = new Point(1, 2);
        this.readOnlyPoint = new Point(1, 2);
    }

    public void Bar()
    {
        this.point = new Point(1, 2);
        this.readOnlyPoint = new Point(1, 2); // Does not compile.

        this.point.Offset(3, 4); // Is now (4, 6).
        this.readOnlyPoint.Offset(3, 4); // Is still (1, 2).
    }
}

在示例中,我们必须使用字段 - 可变字段和不可变字段 . 因为值类型字段包含整个值,所以存储在不可变字段中的值类型也必须是不可变的 . 结果我仍然感到非常惊讶 - 我没有想到readonly字段保持不变 .

变量(除了常量)总是可变的,因此它们意味着对值类型的可变性没有限制 .


答案似乎不是那么直接,所以我会重新解释这个问题 .

鉴于以下内容 .

public struct Foo
{
    public void DoStuff(whatEverArgumentsYouLike)
    {
        // Do what ever you like to do.
    }

    // Put in everything you like - fields, constants, methods, properties ...
}

你能给出完整版的 Foo 和一个用法例子 - 可能包括 ref 参数和装箱 - 这样就不可能重写所有出现的

foo.DoStuff(whatEverArgumentsYouLike);

foo = foo.DoStuff(whatEverArgumentsYouLike);

12 回答

  • 40

    我不想通过考虑参数和拳击来使这个推理复杂化 . 我也知道p = p.Offset(3,4);表达不变性比p.Offset(3,4)更好;确实 . 但问题仍然存在 - 根据定义,值不是不可变的值吗?

    那么,你真的不是在现实世界中经营,不是吗?在实践中,值函数在函数之间移动时复制自身的倾向与不变性很好地融合,但它们实际上并不是不可变的,除非你使它们不可变,因为正如你所指出的那样,你可以使用对它们的引用像其他任何东西 .

  • 0

    去年我写了一篇关于你可以通过不使结构不可变而遇到的问题的博客文章 .

    The full post can be read here

    这是事情可能出现严重错误的一个例子:

    //Struct declaration:
    
    struct MyStruct
    {
      public int Value = 0;
    
      public void Update(int i) { Value = i; }
    }
    

    代码示例:

    MyStruct[] list = new MyStruct[5];
    
    for (int i=0;i<5;i++)
      Console.Write(list[i].Value + " ");
    Console.WriteLine();
    
    for (int i=0;i<5;i++)
      list[i].Update(i+1);
    
    for (int i=0;i<5;i++)
      Console.Write(list[i].Value + " ");
    Console.WriteLine();
    

    此代码的输出是:

    0 0 0 0 0
    1 2 3 4 5
    

    现在让我们这样做,但用数组替换泛型 List<>

    List<MyStruct> list = new List<MyStruct>(new MyStruct[5]); 
    
    for (int i=0;i<5;i++)
      Console.Write(list[i].Value + " ");
    Console.WriteLine();
    
    for (int i=0;i<5;i++)
      list[i].Update(i+1);
    
    for (int i=0;i<5;i++)
      Console.Write(list[i].Value + " ");
    Console.WriteLine();
    

    输出是:

    0 0 0 0 0
    0 0 0 0 0
    

    解释很简单 . 不,这不是拳击/拆箱......

    从数组访问元素时,运行时将直接获取数组元素,因此Update()方法适用于数组项本身 . 这意味着更新了数组中的结构本身 .

    在第二个例子中,我们使用了泛型 List<> . 当我们访问特定元素时会发生什么?好吧,调用indexer属性,这是一个方法 . 当方法返回时,值类型总是被复制,所以这正是发生的事情:list 's indexer method retrieves the struct from an internal array and returns it to the caller. Because it concerns a value type, a copy will be made, and the Update() method will be called on the copy, which of course has no effect on the list' s原始项目 .

    换句话说,始终确保您的结构是不可变的,因为您永远不确定何时会创建副本 . 大多数时候这是显而易见的,但在某些情况下,它真的会让你大吃一惊......

  • 2

    可变性和值类型是两个独立的事物 .

    将类型定义为值类型,表示运行时将复制值而不是对运行时的引用 . 另一方面,可变性取决于实现,每个类可以根据需要实现它 .

  • 2

    如果你的逻辑足够远,那么所有类型都是不可变的 . 当您修改引用类型时,您可能会认为您实际上是在将新对象写入同一地址,而不是修改任何内容 .

    或者你可以说任何语言中的一切都是可变的,因为偶尔以前用过一件事的记忆会被另一个人覆盖 .

    有了足够的抽象,忽略了足够的语言功能,你可以得到任何你喜欢的结论 .

    这就错过了重点 . 根据.NET规范,值类型是可变的 . 你可以修改它 .

    int i = 0;
    Console.WriteLine(i); // will print 0, so here, i is 0
    ++i;
    Console.WriteLine(i); // will print 1, so here, i is 1
    

    但它仍然是一样的我 . 变量 i 仅声明一次 . 在此声明之后发生的任何事情都是修改 .

    在类似具有不可变变量的函数式语言中,这是不合法的 . 我不可能 . 声明变量后,它具有固定值 .

    在.NET中,情况并非如此,没有什么可以阻止我在声明之后修改 i .

    思考之后关于它多一点,这是另一个可能更好的例子:

    struct S {
      public S(int i) { this.i = i == 43 ? 0 : i; }
      private int i;
      public void set(int i) { 
        Console.WriteLine("Hello World");
        this.i = i;
      }
    }
    
    void Foo {
      var s = new S(42); // Create an instance of S, internally storing the value 42
      s.set(43); // What happens here?
    }
    

    在最后一行,根据你的逻辑,我们可以说我们实际上构造了一个新对象,并用该值覆盖旧对象 . 但那是不可能的!要构造一个新对象,编译器必须将 i 变量设置为42.但它是私有的!它只能通过用户定义的构造函数访问,该构造函数明确禁止值43(将其设置为0),然后通过我们的 set 方法,它具有令人讨厌的副作用 . 编译器无法仅使用它喜欢的值创建新对象 . s.i 可以设置为43的唯一方法是通过调用 set() 来修改当前对象 . 编译器不能只这样做,因为它会改变程序的行为(它会打印到控制台)

    因此,对于所有结构都是不可变的,编译器必须作弊并破坏语言规则 . 当然,如果我们愿意违反规则,我们可以证明任何事情 . 我可以证明所有整数也是相同的,或者定义一个新类会导致你的计算机着火 . 只要我们遵守语言规则,结构就是可变的 .

  • 6

    我认为令人困惑的是,如果你有一个应该像值类型一样的引用类型,那么使它成为不可变的是一个好主意 . 值类型和引用类型之间的主要区别之一是,通过ref类型上的一个名称进行的更改可以显示在另一个名称中 . 值类型不会发生这种情况:

    public class foo
    {
        public int x;
    }
    
    public struct bar
    {
        public int x;
    }
    
    
    public class MyClass
    {
        public static void Main()
        {
            foo a = new foo();
            bar b = new bar();
    
            a.x = 1;
            b.x = 1;
    
            foo a2 = a;
            bar b2 = b;
    
            a.x = 2;
            b.x = 2;
    
            Console.WriteLine( "a2.x == {0}", a2.x);
            Console.WriteLine( "b2.x == {0}", b2.x);
        }
    }
    

    生产环境 :

    a2.x == 2
    b2.x == 1
    

    现在,如果你有一个类型,你想要有 Value 语义,但不想实际使它成为一个值类型 - 可能因为它需要的存储太多或其他什么,你应该考虑不变性是一部分该设计 . 使用不可变的ref类型,对现有引用所做的任何更改都会生成一个新对象,而不是更改现有对象,因此您将获得值类型的行为,即您持有的任何值都不能通过其他名称更改 .

    当然,System.String类是此类行为的主要示例 .

  • 5

    您可以编写可变的结构,但最佳做法是使值类型不可变 .

    例如,DateTime总是在执行任何操作时创建新实例 . 点是可变的,可以改变 .

    回答你的问题:不,它们不是定义不可变的,它取决于它们是否应该是可变的情况 . 例如,如果它们应该作为字典键,它们应该是不可变的 .

  • 1

    根据定义,值不是值不可变的吗?

    不,他们不是:例如,如果你看一下 System.Drawing.Point 结构,它的 X 属性上有一个setter和一个getter .

    但是,可以说所有值类型都应该使用不可变API来定义 .

  • 4

    如果对象创建后状态不会更改,则该对象是不可变的 .

    简答:不,根据定义,值类型不是不可变的 . Both structs and classes can be either mutable or immutable. 所有四种组合都是可能的 . 如果结构或类具有非只读公共字段,具有setter的公共属性或设置私有字段的方法,则它是可变的,因为您可以在不创建该类型的新实例的情况下更改其状态 .


    答案很长:首先,不变性问题仅适用于具有字段或属性的结构或类 . 最基本的类型(数字,字符串和null)本质上是不可变的,因为没有任何东西(字段/属性)可以改变它们 . A 5是5是5.对5的任何操作只返回另一个不可变值 .

    您可以创建可变结构,例如 System.Drawing.Point . XY 都有修改结构字段的setter:

    Point p = new Point(0, 0);
    p.X = 5;
    // we modify the struct through property setter X
    // still the same Point instance, but its state has changed
    // it's property X is now 5
    

    有些人似乎把不可靠性与 Value 类型通过 Value (因此他们的名字)而不是通过引用传递的事实相混淆 .

    void Main()
    {
        Point p1 = new Point(0, 0);
        SetX(p1, 5);
        Console.WriteLine(p1.ToString());
    }
    
    void SetX(Point p2, int value)
    {
        p2.X = value;
    }
    

    在这种情况下 Console.WriteLine() 写道“ {X=0,Y=0} ” . 这里 p1 未被修改,因为 SetX() 修改了 p2 这是 p1 的副本 . 这是因为 p1 是一个值类型,不是因为它是不可变的(它不是) .

    为什么值类型应该是不可变的?很多原因......见this question . 主要是因为可变值类型会导致各种不那么明显的错误 . 在上面的例子中,程序员可能在调用 SetX() 后期望 p1(5, 0) . 或者想象一下可以在以后改变的值进行排序 . 然后,您的已排序集合将不再按预期排序 . 字典和哈希也是如此 . Fabulous Eric Lippertblog)写了whole series about immutability以及为什么他认为这是C#的未来 . Here's one of his examples,它允许你"modify"只读变量 .


    更新:您的示例:

    this.readOnlyPoint.Offset(3, 4); // Is still (1, 2).
    

    正是Lippert在帖子中提到的关于修改只读变量的内容 . Offset(3,4) 实际上修改了 Point ,但它是 readOnlyPoint 的副本,它从未被分配给任何东西,所以它丢失了 .

    这就是原因可变值类型是邪恶的:它们让你认为你正在修改某些东西,有时你实际上是在修改副本,这会导致意外的错误 . 如果 Point 是不可变的, Offset() 将不得不返回一个新的 Point ,并且您将无法将其分配给 readOnlyPoint . 然后你去"Oh right, it's read-only for a reason. Why was I trying to change it? Good thing the compiler stopped me now."


    更新:关于你的改写请求...我想我知道你得到了什么 . 在某种程度上,您可以将结构视为内部不可变的,修改结构与将其替换为修改后的副本相同 . 就我所知,它甚至可能就是CLR在内存中所做的事情 . (这就是闪存的工作原理 . 你不能只编辑几个字节,你需要将整块KB的内容读入内存,修改你想要的几个,然后再写回整个块 . )但是,即使它们是"internally immutable",是一个实现细节,对于我们开发人员作为结构的用户(他们的界面或API,如果你愿意),他们可以被更改 . 我们不能忽视这一事实和"think of them as immutable" .

    在评论中你说“你不能引用字段或变量的值” . 您假设每个结构变量都有不同的副本,因此修改一个副本不会影响其他副本 . 这并非完全正确 . 如果......,下面标出的线不可更换

    interface IFoo { DoStuff(); }
    struct Foo : IFoo { /* ... */ }
    
    IFoo otherFoo = new Foo();
    IFoo foo = otherFoo;
    foo.DoStuff(whatEverArgumentsYouLike); // line #1
    foo = foo.DoStuff(whatEverArgumentsYouLike); // line #2
    

    第1行和第2行的结果不一样......为什么?因为 foootherFoo 引用了同一个盒装的Foo实例 . 第1行中 foo 的变化反映在 otherFoo 中 . 第2行用新值替换 foo 并且对 otherFoo 没有任何作用(假设 DoStuff() 返回一个新的 IFoo 实例并且不修改 foo 本身) .

    Foo foo1 = new Foo(); // creates first instance
    Foo foo2 = foo1; // create a copy (2nd instance)
    IFoo foo3 = foo2; // no copy here! foo2 and foo3 refer to same instance
    

    修改 foo1 不会影响 foo2foo3 . 修改 foo2 将反映在 foo3 中,但不会反映在 foo1 中 . 修改 foo3 将反映在 foo2 中,但不会反映在 foo1 中 .

    混乱?坚持使用不可变值类型,消除了修改任何值的冲动 .


    更新:在第一个代码示例中修复拼写错误

  • 4

    当对象/结构以无法更改数据的方式传递给函数时,它们是不可变的,并且返回的结构是一个 new 结构 . 经典的例子是

    String s = "abc";

    s.toLower();

    如果 toLower 函数被写入,那么它们会返回一个替换"s"的新字符串,它是不可变的,但如果函数逐字母替换"s"中的字母并且从不声明"new String",则它是可变的 .

  • 1

    不,他们不是 . 例:

    Point p = new Point (3,4);
    Point p2 = p;
    p.moveTo (5,7);
    

    在此示例中, moveTo() 是就地操作 . 它改变隐藏在引用 p 后面的结构 . 您可以通过查看 p2 看到它:它的位置也会发生变化 . 对于不可变结构, moveTo() 必须返回一个新结构:

    p = p.moveTo (5,7);
    

    现在, Point 是不可变的,当你在代码中的任何地方创建对它的引用时,你赢了't get any surprises. Let'看看 i

    int i = 5;
    int j = i;
    i = 1;
    

    这是不同的 . i 不是不可变的, 5 是 . 第二个赋值不会复制对包含 i 的结构的引用,但它会复制 i 的内容 . 因此,在幕后,会发生一些完全不同的事情:您获得变量的完整副本,而不是内存中的地址副本(参考) .

    与对象等效的是复制构造函数:

    Point p = new Point (3,4);
    Point p2 = new Point (p);
    

    这里, p 的内部结构被复制到一个新的对象/结构中, p2 将包含对它的引用 . 但这是一个非常昂贵的操作(与上面的整数赋值不同),这就是为什么大多数编程语言都有区别的原因 .

    随着计算机变得越来越强大并获得更多内存,这种区别将会消失,因为它会导致大量的错误和问题 . 在下一代中,只有不可变对象,任何操作都将受到事务的保护,甚至 int 将是一个完整的对象 . 就像垃圾收集一样,它将是程序稳定性的一大进步,在最初几年引起很多悲痛,但它将允许编写可靠的软件 . 今天,计算机还不够快 .

  • 9

    要定义类型是可变的还是不可变的,必须定义"type"所指的内容 . 当声明引用类型的存储位置时,声明仅分配空间来保存对存储在别处的对象的引用;声明不会创建有问题的实际对象 . 尽管如此,在大多数情况下,人们谈论特定的引用类型,人们不会谈论拥有引用的存储位置,而是讨论由该引用标识的对象 . 人们可以写入持有对象引用的存储位置这一事实意味着对象本身绝不可变 .

    相反,当声明值类型的存储位置时,系统将在该存储位置内为该值类型所拥有的每个公共或私有字段分配嵌套存储位置 . 关于值类型的所有内容都保存在该存储位置中 . 如果定义 Point 类型的变量 foo 及其两个字段 XY ,则分别按住3和6 . 如果将 fooPoint 的"instance"定义为该对字段,则当且仅当 foo 是可变的时,该实例才是可变的 . 如果将 Point 的实例定义为这些字段中保存的值(例如"3,6"),那么这样的实例根据定义是不可变的,因为更改其中一个字段会导致 Point 持有不同的实例 .

    我认为将值类型"instance"视为字段而不是它们持有的值更有帮助 . 根据该定义,存储在可变存储位置中并且存在任何非默认值的任何值类型将始终是可变的,无论它是如何声明的 . 语句 MyPoint = new Point(5,8) 构造 Point 的新实例,其字段为 X=5Y=8 ,然后通过将其字段中的值替换为新创建的 Point 的值来改变 MyPoint . 即使结构体无法修改其构造函数之外的任何字段,结构类型也无法保护实例的所有字段都被其他实例的内容覆盖 .

    顺便提一下,一个简单的例子,其中一个可变结构可以实现通过其他方法无法实现的语义:假设 myPoints[] 是一个可供多个线程访问的单元素数组,有二十个线程同时执行代码:

    Threading.Interlocked.Increment(myPoints[0].X);
    

    如果 myPoints[0].X 开始等于零并且20个线程执行上述代码,无论是否同时, myPoints[0].X 将等于20 . 如果有人试图模仿上述代码:

    myPoints[0] = new Point(myPoints[0].X + 1, myPoints[0].Y);
    

    然后,如果任何线程在另一个线程读取它并回写修改后的值之间读取 myPoints[0].X ,则增量的结果将丢失(结果是 myPoints[0].X 可以任意地以1到20之间的任何值结束 .

  • 1

    不,值类型根据定义不是不可变的 .

    首先,我最好问一个问题“值类型的行为类似于不可变类型吗?”而不是询问它们是否是不可变的 - 我认为这引起了很多混乱 .

    struct MutableStruct
    {
        private int state;
    
        public MutableStruct(int state) { this.state = state; }
    
        public void ChangeState() { this.state++; }
    }
    
    struct ImmutableStruct
    {
        private readonly int state;
    
        public MutableStruct(int state) { this.state = state; }
    
        public ImmutableStruct ChangeState()
        {
            return new ImmutableStruct(this.state + 1);
        }
    }
    

    [未完待续...]

相关问题