我经常读到 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).
    }
}

在示例中,我们需要字段 - 一个可变字段和一个不可变字段。由于值类型字段包含整个值,因此存储在不可变字段中的值类型也必须是不可变的。我仍然对结果感到非常惊讶 - 我没想到只读字段会保持不变。

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


答案似乎不是那么直接,所以我将重新表述这个问题。

鉴于以下情况。

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);
有帮助吗?

解决方案

如果对象一旦创建对象就不会更改对象,则该对象是不可变的。

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


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

您可以创建可变结构,例如 System.Drawing.Point. 。两个都 XY 有修改结构体字段的设置器:

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 这是一个 复制p1. 。发生这种情况是因为 p1 是一个 值类型, ,不是因为它是 不可变的 (事实并非如此)。

为什么 应该 值类型是不可变的吗?很多原因...看 这个问题. 。主要是因为可变值类型会导致各种不那么明显的错误。在上面的例子中,程序员可能期望 p1 成为 (5, 0) 打电话后 SetX(). 。或者想象一下按稍后可能更改的值进行排序。那么你的排序集合将不再按预期排序。字典和哈希也是如此。这 出色的埃里克·利珀特 (博客)写了一个 关于不变性的整个系列 以及为什么他相信这是 C# 的未来。 这是他的例子之一 它允许您“修改”只读变量。


更新:你的例子:

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

这正是 Lippert 在他关于修改只读变量的文章中提到的内容。 Offset(3,4) 实际上修改了一个 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 行反映在 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 不会影响 foo2 或者 foo3. 。修改 foo2 将体现在 foo3, ,但不在 foo1. 。修改 foo3 将体现在 foo2 但不在 foo1.

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


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

其他提示

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

将类型定义为值类型,表示运行时将复制值而不是对运行时的引用。另一方面,可变性取决于实现,每个类都可以按照自己的意愿实现它。

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

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

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

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

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

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

但这没有抓住重点。根据 .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(). 。编译器不能这样做,因为它会改变程序的行为(它会打印到控制台)

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

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

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

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

不,他们不是:如果你看 System.Drawing.Point 例如,struct 上有一个 setter 和一个 getter X 财产。

然而,可以说所有值类型都是正确的 应该 使用不可变的 API 进行定义。

我认为令人困惑的是,如果您有一个应该像值类型一样工作的引用类型,那么最好将其设置为不可变。值类型和引用类型之间的主要区别之一是,通过引用类型上的一个名称进行的更改可能会显示在另一个名称中。值类型不会发生这种情况:

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

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

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

去年,我写了一篇博文,内容是关于不制作结构体可能会遇到的问题 变。

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

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

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

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

不,他们不是。例子:

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 将是一个完整的对象。就像垃圾收集一样,这将是程序稳定性的一大进步,在最初几年会造成很多痛苦,但它将允许编写可靠的软件。如今,计算机的速度还不够快。

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

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

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

[待续...]

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

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

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

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

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

如果 myPoints[0].X 开始时等于零,二十个线程执行上述代码,无论是否同时, myPoints[0].X 将等于二十。如果尝试使用以下代码来模仿上述代码:

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

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

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

String s = "abc";

s.toLower();

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

许可以下: CC-BY-SA归因
不隶属于 StackOverflow
scroll top