为什么可变结构体被认为是“邪恶”的?

554

在 Stack Overflow 上的讨论中,我已经多次看到关于可变结构体是“邪恶”的评论(例如这个问题的答案中)。

在 C# 中,可变性和结构体有什么实际问题?


24
声称可变结构体是邪恶的就像声称可变的int、bool和所有其他值类型都是邪恶的一样。在可变性和不可变性之间存在情况,这些情况取决于数据扮演的角色,而不是内存分配/共享的类型。 - Slipp D. Thompson
63
@slipp中的intbool类型是不可变的。 - Blorgbeard
2
在C#中,属性的问题导致了“.”语法,使得使用引用类型数据和值类型数据进行操作看起来相同,尽管它们是明显不同的。这不是结构体的问题,而是C#属性的问题——一些语言提供了一种替代的a[V][X] = 3.14语法来进行原地变异。在C#中,你最好提供结构体成员变异器方法,比如'MutateV(Action<ref Vector2> mutator),并像这样使用它:a.MutateV((v) => { v.X = 3; })(由于C#关键字ref`的限制,示例过于简化,但通过一些解决方法应该是可能的)。 - Slipp D. Thompson
3
@Slipp,我对这种类型的结构体持完全相反的看法。为什么您认为已在 .NET 库中实现的结构体,例如 DateTime 或 TimeSpan(类似的结构体),是不可变的?也许更改这种结构体变量的一个成员可能是有用的,但这样做太不方便了,会导致太多问题。实际上,您关于处理器计算的观点是错误的,因为 C# 并不编译成汇编语言,而是编译成 IL(Intermediate Language,中间语言)。在 IL 中(假设我们已经有名为“x”的变量),这个单一操作需要 4 条指令:“ldloc.0”(将 0 索引变量加载到... - Sushi271
1
...更重要的事情,比如空值条件运算符或自动属性初始化器。至于“public”...我实际上很喜欢C#的做法。这样做实际上更容易重构,因为在一个成员的一个地方删除/添加一个公共成员不会影响所有其他成员。在C++中,我经常不得不添加两个标签(例如public:和private:),或将方法移动到类的其他部分。不方便。无论如何,提到这个话题让我意识到我们应该结束那个讨论。我们开始争论我们自己的观点,我们可能会争论多年而找不到共同点... - Sushi271
显示剩余7条评论
16个回答

10

当某物能够发生变异时,它便获得了一种身份感。

struct Person {
    public string name; // mutable
    public Point position = new Point(0, 0); // mutable

    public Person(string name, Point position) { ... }
}

Person eric = new Person("Eric Lippert", new Point(4, 2));
因为Person是可变的,所以考虑到更改Eric的位置比克隆Eric,移动克隆并销毁原始数据更自然。这两个操作都将成功更改eric.position的内容,但其中一个比另一个更直观。同样地,传递Eric(作为引用)以便方法可以修改他更直观。给方法传递Eric的克隆品几乎总是会令人惊讶的。任何想要改变Person的人都必须记得请求对Person的引用,否则他们将做错事情。
如果将类型设置为不可变,则问题就消失了;如果我无法修改eric,那么我收到ericeric的克隆品对我来说没有区别。更一般地,如果一个类型所有可观察状态都保存在以下成员中,则按值传递是安全的:
  • 不可变
  • 引用类型
  • 安全地按值传递
如果满足这些条件,则可变值类型的行为类似于引用类型,因为浅复制仍然允许接收者修改原始数据。
不过,不可变的Person是否直观取决于你要做什么。如果Person只代表有关人员的一组数据,那么它并不会令人困惑;Person变量确实表示抽象的值,而不是对象。(在这种情况下,重命名为PersonData可能更合适。)如果Person实际上是模拟一个人,即使避免了认为正在修改原始数据的陷阱,不断地创建和移动克隆品的想法也是愚蠢的。在这种情况下,仅将Person设置为引用类型(即类)可能更自然。
当然,如函数式编程所教我们的,将所有内容设为不可变会有益处(没有人可以秘密持有对eric的引用并改变他),但由于这不符合OOP的惯例,因此对于与您的代码一起工作的其他人来说仍然会很难以理解。

3
关于"identity"的观点很好;值得注意的是,只有当多个引用指向同一个东西时,身份才是相关的。如果 foo 在宇宙中是指向目标唯一的引用,且没有任何东西捕获该对象的身份散列值,则突变字段 foo.X 在语义上等同于使 foo 指向一个新对象,该对象与它之前所引用的对象完全相同,但 X 包含了所需的值。对于类类型,通常很难知道是否存在多个引用指向某个东西,但是对于结构体来说很容易:它们不存在这个问题。 - supercat
1
如果Thing是一个可变的类类型,那么Thing[]将会封装对象标识——无论是否希望这样做——除非能确保数组中任何外部引用存在的Thing都不会被改变。如果不想让数组元素封装标识,则通常必须确保它所持有的任何项都不会被改变,或者不会存在任何外部引用指向它所持有的任何项[混合方法也可以起作用]。两种方法都不是非常方便。如果Thing是一个结构体,则Thing[]仅封装值。 - supercat
对于对象来说,它们的身份来自于它们的位置。引用类型的实例由于存储在内存中的位置而具有其身份,您只传递它们的身份(引用),而不是它们的数据,而值类型则将其身份存储在外部位置。您的Eric值类型的身份仅来自于存储他的变量。如果您传递他,他将失去自己的身份。 - IS4

6

Eric Lippert的例子存在几个问题。它是为了说明结构体被复制以及如果不小心使用可能会导致问题而刻意构造的。从这个例子来看,我觉得它实际上是一个不好的编程习惯的结果,而不是关于结构体或类的问题。

  1. 结构体应该只有公共成员,不需要任何封装。如果需要封装,则应该将其定义为类型/类。实际上,您不需要两种构造方式来表示同一件事情。

  2. 如果您有一个封装结构体的类,应该在类中调用方法来改变成员结构体的值。这是一个良好的编程习惯。

一个正确的实现应该如下所示。

struct Mutable {
public int x;
}

class Test {
    private Mutable m = new Mutable();
    public int mutate()
    { 
        m.x = m.x + 1;
        return m.x;
    }
  }
  static void Main(string[] args) {
        Test t = new Test();
        System.Console.WriteLine(t.mutate());
        System.Console.WriteLine(t.mutate());
        System.Console.WriteLine(t.mutate());
    }

看起来这是一个编程习惯问题,而不是结构体本身的问题。结构体应该是可变的,这是它的思想和意图。

更改后的结果 voila 表现得像预期的那样:

1 2 3 按任意键继续. . .


4
设计小型不透明结构以像不可变类对象一样运作并没有问题;MSDN指南在尝试创建行为类似于对象的内容时是合理的。有些情况下,结构体很合适,例如需要轻量级类对象或需要将多个变量粘在一起的情况。然而,许多人却没有意识到结构体有两种不同的用法,并且适用于一种用法的指南并不适用于另一种用法。 - supercat

6

个人而言,当我看到以下代码时,感觉它相当笨重:

data.value.set ( data.value.get () + 1 ) ;

与简单地使用以下代码相比:

data.value++ 或 data.value = data.value + 1 ;

数据封装在传递类时很有用,可以确保以受控的方式修改值。但是,如果您只是拥有公共的set和get函数,并且这些函数仅仅是将值设置为传入的任何值,那么这如何改进仅传递公共数据结构呢?

当我在类中创建一个私有结构时,我创建该结构以将一组变量组织成一组。我希望能够在类范围内修改该结构,而不是获取该结构的副本并创建新实例。

对我来说,这会阻止结构的有效使用,用于组织公共变量,如果我需要访问控制,我会使用类。


1
直截了当地说!结构体是没有访问控制限制的组织单元!不幸的是,C#已经使它们在这个目的上变得无用了! - ThunderGr
这完全错了,因为你的两个示例都展示了可变结构体。 - vidstige
C#使它们在这个目的上变得无用,因为这不是结构体的目的。 - Luiz Felipe

6

这与结构体无关(也与C#无关),但在Java中,如果可变对象是哈希映射的键,则可能会出现问题。如果您在将其添加到映射中后更改它们并更改了其哈希码,则可能会发生一些不好的事情。


3
如果您在map中使用类作为键,那么这也是正确的。 - Marc Gravell

6
可变数据有许多优点和缺点。百万美元的缺点是别名。如果同一个值在多个地方使用,并且其中一个更改了它,则它将似乎已经神奇地更改为正在使用它的其他地方。这与竞争条件相关,但并不完全相同。
有时,百万美元的优点是模块化。可变状态可以使您将正在更改的信息隐藏在不需要知道它的代码中。
解释器的艺术》详细探讨了这些权衡,并给出了一些例子。

在C#中,结构体不能被别名。每个结构体赋值都是一份拷贝。 - recursive
@recursive:在某些情况下,这是可变结构体的主要优势,这让我质疑结构体不应该是可变的观念。编译器有时会隐式地复制结构体并不会降低可变结构体的有用性。 - supercat

1

如果使用得当,我不认为它们是邪恶的。我不会在我的生产代码中使用它,但我会在类似结构化单元测试模拟的东西中使用它,在这种情况下,结构体的寿命相对较短。

以Eric的例子为例,也许您想创建该Eric的第二个实例,但进行一些调整,因为这是您测试的性质(即复制,然后修改)。如果我们只是在剩余的测试脚本中使用Eric2,那么第一个Eric发生的事情并不重要,除非您计划将其用作测试比较。

这对于测试或修改浅定义特定对象的旧代码非常有用(结构体的要点),但通过具有不可变结构体,这可以防止令人讨厌的使用。


在我看来,结构体本质上是一堆用胶带粘在一起的变量。在.NET中,结构体可以假装成其他东西而不是一堆用胶带粘在一起的变量,我建议当实际可行时,一个要假装成其他东西而不是一堆用胶带粘在一起的变量的类型应该表现为一个统一的对象(对于结构体来说,这意味着不可变性),但有时将一堆变量用胶带粘在一起是很有用的。即使在生产代码中,我认为拥有一个类型... - supercat
这段代码明显没有语义,除了“每个字段包含最后写入它的内容”,将所有语义推到使用该结构的代码中,而不是尝试让结构体做更多的事情。例如,给定一个Range<T>类型,其成员为类型为TMinimumMaximum字段,以及代码Range<double> myRange = foo.getRange();,有关MinimumMaximum包含什么的任何保证都应来自于foo.GetRange();。将Range作为公开字段结构体会清楚地表明它不会添加任何自己的行为。 - supercat

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