关于c#:值类型是否按定义不可变?

Are value types immutable by definition?

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

你认为int是不可变的吗?

1
2
int i = 0;
i = i + 123;

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

1
i++;

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

1
i = i + 1;

struct Point怎么样?

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

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

1
p = p.Offset(3, 4);

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

我不想通过考虑ref参数和装箱来推理这个问题。我也知道p = p.Offset(3, 4);p.Offset(3, 4);更好地表达了不变性。但问题仍然存在 - 根据定义,值不是不可变的值吗?

UPDATE

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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字段保持不变。

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

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

鉴于以下内容。

1
2
3
4
5
6
7
8
9
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参数和装箱 - 这样就不可能重写所有出现的

1
foo.DoStuff(whatEverArgumentsYouLike);

1
foo = foo.DoStuff(whatEverArgumentsYouLike);

An object is immutable if its state
doesn’t change once the object has
been created.

Ok.

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

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

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

1
2
3
4
5
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

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

1
2
3
4
5
6
7
8
9
10
11
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是值类型,而不是因为它是不可变的(它不是)。

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

更新:您的示例:

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

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

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

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

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

1
2
3
4
5
6
7
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本身)。

1
2
3
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中。

混乱?坚持不可变的值类型,你消除了修改其中任何一个的冲动。

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

好。


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

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


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

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

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


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

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

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

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

1
2
3
4
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

在考虑了一下之后,这是另一个可能更好的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
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()修改当前对象。编译器不能只这样做,因为它会改变程序的行为(它会打印到控制台)

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


aren't value types immutable by definition?

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

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


I don't want to complicate reasoning
about this by considering ref
parameters and boxing. I am also aware
that p = p.Offset(3, 4); expresses
immutability much better than
p.Offset(3, 4); does. But the
question remains - aren't value types
immutable by definition?

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


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

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

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

1
2
3
4
5
6
7
8
//Struct declaration:

struct MyStruct
{
  public int Value = 0;

  public void Update(int i) { Value = i; }
}

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
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();

此代码的输出是:

1
2
0 0 0 0 0
1 2 3 4 5

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

1
2
3
4
5
6
7
8
9
10
11
12
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();

输出是:

1
2
0 0 0 0 0
0 0 0 0 0

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

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

在第二个例子中,我们使用了泛型List<>。当我们访问特定元素时会发生什么?好吧,调用indexer属性,这是一种方法。当方法返回时,值类型总是被复制,所以这正是发生的事情:列表的索引器方法从内部数组中检索结构并将其返回给调用者。因为它涉及值类型,所以将创建一个副本,并且将在副本上调用Update()方法,这当然不会影响列表的原始项目。

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


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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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);
    }
}

生产:

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

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

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


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

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

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

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

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

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

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

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


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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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);
    }
}

[未完待续...]


不,他们不是。例:

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

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

1
p = p.moveTo (5,7);

现在,Point是不可变的,当您在代码中的任何位置创建对它的引用时,您将不会有任何意外。我们来看看i

1
2
3
int i = 5;
int j = i;
i = 1;

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

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

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

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

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


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

String s ="abc";

s.toLower();

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