44

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

你认为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);

这个想法的背景是这样的——一个没有身份的值类型怎么可能是可变的?您必须至少查看两次才能确定它是否发生了变化。但是,如果没有身份,你怎么能做到这一点呢?

我不想通过考虑ref参数和装箱来使推理复杂化。我也知道p = p.Offset(3, 4);表达不变性要好得多p.Offset(3, 4);。但问题仍然存在——值类型在定义上不是不可变的吗?

更新

我认为至少涉及两个概念——变量或字段的可变性和变量值的可变性。

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);
4

12 回答 12

55

如果对象的状态在对象创建后不改变,则该对象是不可变的。

简短的回答:不,值类型根据定义不是不可变的。结构和类都可以是可变的或不可变的。所有四种组合都是可能的。如果结构或类具有非只读的公共字段、带有 setter 的公共属性或设置私有字段的方法,则它是可变的,因为您可以更改其状态而无需创建该类型的新实例。


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

您可以创建可变结构,例如System.Drawing.Point. 两者都X具有Y修改结构字段的设置器:

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

有些人似乎将不可变性与值类型通过值(因此它们的名称)而不是通过引用传递的事实混淆了。

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是. 发生这种情况是因为它是一个值类型,而不是因为它是不可变的(它不是)。p1p1

为什么值类型应该是不可变的很多原因......看到这个问题。主要是因为可变值类型会导致各种不那么明显的错误。在上面的例子中,程序员可能期望p1(5, 0)调用SetX(). 或者想象一下按一个以后可以更改的值进行排序。然后您的排序集合将不再按预期排序。字典和哈希也是如此。神话般的Eric Lippert (博客) 写了一个关于不变性的完整系列,以及为什么他认为这是 C# 的未来。这是他的一个示例,可让您“修改”只读变量。


更新:您的示例:

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

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

就是为什么可变值类型是邪恶的:它们让你认为你正在修改某些东西,而有时你实际上是在修改一个副本,这会导致意想不到的错误。如果Point是不可变的,Offset()则必须返回一个新的Point,并且您将无法将其分配给readOnlyPoint. 然后你说“哦,对,它是只读的是有原因的。我为什么要尝试更改它?幸好编译器现在阻止了我。”


更新:关于你改写的请求......我想我知道你在说什么。在某种程度上,您可以“认为”结构是内部不可变的,修改结构与用修改后的副本替换它是一样的。据我所知,它甚至可能是 CLR 在内存中内部所做的事情。(这就是闪存的工作原理。你不能只编辑几个字节,你需要将整个千字节块读入内存,修改你想要的少数,然后将整个块写回。)然而,即使它们“内部不可变” ",这是一个实现细节,对于我们作为结构用户的开发人员(他们的接口或 API,如果你愿意的话)来说,它们是可以更改的。我们不能忽视这一事实并“认为它们是不可变的”。

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

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不会影响foo2or foo3。修改foo2将反映在 中foo3,但不会反映在 中foo1。修改foo3将反映在foo2但不会反映在foo1.

令人困惑?坚持不可变的值类型,你就消除了修改它们的冲动。


更新:修复了第一个代码示例中的错字

于 2009-05-15T15:15:04.497 回答
11

可变性和值类型是两个不同的东西。

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

于 2009-05-15T12:39:31.203 回答
8

您可以编写可变结构,但最佳实践是使值类型不可变。

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

回答您的问题:不,根据定义,它们不是不可变的,这取决于它们是否应该是可变的。例如,如果它们应该用作字典键,它们应该是不可变的。

于 2009-05-15T12:36:50.023 回答
5

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

或者您可以争辩说,在任何语言中,一切都是可变的,因为有时以前用于一件事的记忆会被另一件事覆盖。

有了足够多的抽象,而忽略了足够多的语言特性,你就可以得出你喜欢的任何结论。

这没有抓住重点。根据 .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只声明一次。在此声明之后发生的任何事情都是修改。

在具有不可变变量的函数式语言中,这是不合法的。++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()。编译器不能这样做,因为它会改变程序的行为(它会打印到控制台)

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

于 2009-05-15T12:47:47.487 回答
4

我不想通过考虑ref 参数和装箱来使推理复杂化。我也知道p = p.Offset(3, 4);表达不变性要好得多 p.Offset(3, 4);。但问题仍然存在——值类型在定义上不是不可变的吗?

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

于 2009-05-15T12:39:32.607 回答
4

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

不,它们不是:System.Drawing.Point例如,如果您查看 struct ,它的X属性上有一个 setter 和一个 getter。

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

于 2009-05-15T12:40:45.567 回答
2

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

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

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

当然 System.String 类是这种行为的一个典型例子。

于 2009-05-15T13:34:48.073 回答
2

去年,我写了一篇博客文章,讨论了不让结构不可变可能遇到的问题。

完整的帖子可以在这里阅读

这是事情如何变得可怕错误的一个例子:

//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 属性被调用,这是一个方法。值类型在方法返回时总是被复制,所以这正是发生的情况:列表的索引器方法从内部数组中检索结构并将其返回给调用者。因为它涉及到一个值类型,所以会做一个副本,并在副本上调用Update()方法,这当然对列表的原始项没有影响。

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

于 2009-06-02T14:15:39.657 回答
1

不,他们不是。例子:

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

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

p = p.moveTo (5,7);

现在,Point它是不可变的,当您在代码中的任何位置创建对它的引用时,您不会有任何意外。让我们看看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是一个成熟的对象。就像垃圾收集一样,它将在程序稳定性方面向前迈出一大步,在最初的几年里会引起很多悲伤,但它会让编写可靠的软件成为可能。今天,计算机的速度还不够快。

于 2009-05-15T12:55:07.530 回答
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);
    }
}

[待续...]

于 2009-05-17T21:47:44.140 回答
1

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

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

我认为将值类型“实例”视为字段而不是它们持有的值更有帮助。根据该定义,存储在可变存储位置的任何值类型,并且存在任何非默认值,将始终是可变的,无论它是如何声明的。一条语句用字段和MyPoint = new Point(5,8)构造 , 的新实例,然后通过将其字段中的值替换为新创建的值来进行变异。即使 struct 无法在其构造函数之外修改其任何字段,但 struct 类型也无法保护实例不被另一个实例的内容覆盖其所有字段。PointX=5Y=8MyPointPoint

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

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

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

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

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

于 2012-06-09T20:35:09.767 回答
0

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

String s = "abc";

s.toLower();

如果toLower函数是这样编写的,所以返回一个替换“s”的新字符串,它是不可变的,但如果函数逐个字母替换“s”内的字母并且从不声明“新字符串”,它是可变的。

于 2009-05-15T12:45:12.640 回答