在 Stack Overflow 上的讨论中,我已经多次看到关于可变结构体是“邪恶”的评论(例如这个问题的答案中)。
在 C# 中,可变性和结构体有什么实际问题?
结构体是值类型,这意味着在传递时会复制它们。
因此,如果您更改的是副本,则仅更改该副本,而不是原始副本或其他可能存在的副本。
如果您的结构体是不可变的,则由于按值传递而自动生成的所有副本将是相同的。
如果你想要修改它,你必须有意识地使用修改后的数据创建一个新的结构体实例。(而不是复制一份)
从哪里开始呢 ;-p
Eric Lippert的博客 总是给出有趣的引言:
这是可变值类型恶劣的另一个原因。 尽量使值类型不可变。
首先,您很容易丢失更改...例如,从列表中获取内容:
Foo foo = list[0];
foo.Name = "abc";
那改变了什么?没有任何有用的变化...
属性也是一样:
myObj.SomeProperty.Size = 22; // the compiler spots this one
强制你去做:
Bar bar = myObj.SomeProperty;
bar.Size = 22;
myObj.SomeProperty = bar;
更不严格地说,存在一个大小问题; 可变对象倾向于具有多个属性; 然而如果您有一个包含两个int
、一个 string
、一个 DateTime
和一个 bool
的结构体,那么您很快就会消耗大量内存。使用类时,多个调用者可以共享对同一实例的引用(引用很小)。
++
运算符是允许的。在这种情况下,编译器会自己写出明确的赋值,而不是让程序员费心。 - Konrad RudolphSomeProperty
实际上不是属性(也许它是一个字段?),要么SomeProperty
的类型实际上不是struct
。下面是一个最小化的重现,显示了CS1612:https://sharplab.io/#v2:EYLgtghgzgLgpgJwDQxASwDYB8ACAGAAhwEYBuAWACgcBmIgJgIGECBvKgzougZQHswcHjAQBXAMYwC/QQAUEfAA6IYATzYBzODFJRtpAL4cutIgBYCAWQAUASjbGuXGXHlKVqgHQ80ALzgEALwE9HgUlE5GlFFUprBiktICQiISUuwRJnRoAHZSPv7hBkA= - Marc Gravell我不会说邪恶,但是可变性通常是程序员过于热衷于提供最大功能的标志。实际上,这通常是不必要的,反过来使得接口更小,更容易使用,更难出错(=更健壮)。
其中一个例子是比赛条件中的读/写和写/写冲突。这些在不可变结构中根本不可能发生,因为写操作不是有效操作。
此外,我声称几乎从未真正需要可变性,程序员只是认为它可能在未来需要。例如,改变日期根本没有意义。相反,基于旧日期创建一个新日期。这是一项廉价操作,因此性能不是一个考虑因素。
float
组件。如果这种方法返回一个包含六个组件的暴露字段结构体,显然修改结构体的字段不会修改它所接收自的图形对象。如果这种方法返回一个可变类对象,也许改变它的属性将改变底层的图形对象,也许不会——没有人真正知道。 - supercat可变结构体并非邪恶。
在高性能环境中,它们是绝对必需的。例如,当缓存行或垃圾回收成为瓶颈时。
我不会称这些完全有效用例中使用不可变结构体为“邪恶”。
我可以理解C#语法不能帮助区分值类型成员和引用类型成员的访问,因此我完全支持更喜欢强制执行不可变性的不可变结构体。
然而,与其简单地将不可变结构体标记为“邪恶”,我建议拥抱该语言并提倡更有帮助和建设性的原则。
例如:“结构体是值类型,默认情况下会被复制。如果不想复制它们,需要一个引用” 或者 “首先尝试使用只读结构体”。
有公共可变字段或属性的结构体并不邪恶。
与属性设置器不同的结构体方法会改变 "this",这有点邪恶,只是因为 .net 没有提供区分它们和不改变 "this" 的方法的手段。不改变 "this" 的结构体方法可以在只读结构体上调用,而无需进行防御性复制。改变 "this" 的方法在只读结构体上根本不应该被调用。由于 .net 不想禁止在只读结构体上调用不修改 "this" 的结构体方法,但也不想允许只读结构体被修改,它在只读环境下进行了防御性复制,可以说是两全其美。
尽管在只读环境中自我修改方法的处理存在问题,但是可变结构体通常提供比可变类类型更好的语义。请考虑以下三个方法签名:
struct PointyStruct {public int x,y,z;}; class PointyClass {public int x,y,z;};
void Method1(PointyStruct foo); void Method2(ref PointyStruct foo); void Method3(PointyClass foo);
对于每个方法,请回答以下问题:
答案:
问题1:
Method1()
: 不可以 (明确的意图)
Method2()
: 可以 (明确的意图)
Method3()
: 可以 (不确定的意图)
问题2:
Method1()
: 不可以
Method2()
: 不可以 (除非使用 unsafe)
Method3()
: 可以
Method1无法修改foo,也永远得不到一个引用。Method2获得了一个短暂的foo引用,可以任意次序地多次修改foo的字段,直到返回之前都可以使用该引用,但它不能保留该引用。在Method2返回之前,除非使用不安全代码,否则可能已经复制了所有的“foo”引用。与Method2不同,Method3获得了一个可以共享的foo引用,并且无法预测它可能做什么。它可能根本不会改变foo,可能会更改foo然后返回,或者可能将foo的引用传递给另一个线程,在某个未来的时间以某种任意的方式进行突变。唯一限制Method3对传入的可变类对象可能做什么的方法是将可变对象封装到只读包装器中,这很丑陋和繁琐。
结构体数组提供了很好的语义。假设有类型为Rectangle的RectArray[500],则显而易见如何将元素123复制到元素456,然后在一段时间后将元素123的宽度设置为555,而不干扰元素456。“RectArray[432] = RectArray[321];…; RectArray[123].Width = 555;”。知道Rectangle是一个带有名为Width的整数字段的结构体就足以理解上述语句。
假设RectClass是一个具有与Rectangle相同字段的类,想要在类型为RectClassArray [500]的数组上执行相同的操作。也许该数组应该包含500个预初始化的不可变引用可变的RectClass对象。在这种情况下,正确的代码可能是“RectClassArray [321] .SetBounds(RectClassArray [456]);……; RectClassArray [321] .X = 555;”。也许假定该数组包含不会更改的实例,因此正确的代码更像是“RectClassArray [321] = RectClassArray [456];……; RectClassArray [321] = New RectClass(RectClassArray [321 ]); RectClassArray [321] .X = 555; "要知道应该做什么,必须对RectClass(例如,它是否支持复制构造函数,复制-从方法等)和数组的预期使用了解得更多。远不如使用struct干净。确保除数组以外的任何容器类都没有提供结构数组的干净语义的好方法。如果希望使用字符串索引集合,则最好的方法可能是提供一个通用的“ActOnItem”方法,该方法将接受索引的字符串,一般参数和一个委托,该委托将被传递通过引用传递通用参数和集合项。这将允许几乎与结构数组相同的语义,但除非vb.net和C#的人们能提供漂亮的语法,否则即使代码表现合理,代码也会看起来笨拙(传递通用参数将允许使用静态委托并避免创建任何临时类实例)。
个人而言,我对Eric Lippert等人关于可变值类型的憎恶感到恼怒。它们提供比滥用引用类型更干净的语义。尽管.net对值类型的支持存在一些限制,但在许多情况下,可变值类型都比任何其他类型的实体更适合。
还有一些边角案例可能会导致程序员在视角上出现不可预测的行为。
// Simple mutable structure.
// Method IncrementI mutates current state.
struct Mutable
{
public Mutable(int i) : this()
{
I = i;
}
public void IncrementI() { I++; }
public int I { get; private set; }
}
// Simple class that contains Mutable structure
// as readonly field
class SomeClass
{
public readonly Mutable mutable = new Mutable(5);
}
// Simple class that contains Mutable structure
// as ordinary (non-readonly) field
class AnotherClass
{
public Mutable mutable = new Mutable(5);
}
class Program
{
void Main()
{
// Case 1. Mutable readonly field
var someClass = new SomeClass();
someClass.mutable.IncrementI();
// still 5, not 6, because SomeClass.mutable field is readonly
// and compiler creates temporary copy every time when you trying to
// access this field
Console.WriteLine(someClass.mutable.I);
// Case 2. Mutable ordinary field
var anotherClass = new AnotherClass();
anotherClass.mutable.IncrementI();
// Prints 6, because AnotherClass.mutable field is not readonly
Console.WriteLine(anotherClass.mutable.I);
}
}
假设我们有一个存储Mutable
结构体的数组,并且我们正在调用该数组的第一个元素的IncrementI
方法。您对此调用期望什么行为?它应该更改数组的值还是仅更改一个副本?
Mutable[] arrayOfMutables = new Mutable[1];
arrayOfMutables[0] = new Mutable(5);
// Now we actually accessing reference to the first element
// without making any additional copy
arrayOfMutables[0].IncrementI();
// Prints 6!!
Console.WriteLine(arrayOfMutables[0].I);
// Every array implements IList<T> interface
IList<Mutable> listOfMutables = arrayOfMutables;
// But accessing values through this interface lead
// to different behavior: IList indexer returns a copy
// instead of an managed reference
listOfMutables[0].IncrementI(); // Should change I to 7
// Nope! we still have 6, because previous line of code
// mutate a copy instead of a list value
Console.WriteLine(listOfMutables[0].I);
因此,只要您和团队清楚地了解自己正在做什么,可变结构就不是邪恶的。但是,在太多角落情况下,程序行为会与预期不同,这可能会导致微妙且难以产生和理解的错误。
T[]
和整数索引,并提供将对类型为“ArrayRef<T>”的属性的访问解释为访问适当的数组元素),如果一个类想要为任何其他目的公开“ArrayRef<T>”,它可以提供一个方法-而不是属性-来检索它。不幸的是,目前没有这样的规定。 - supercatpublic static void IncrementI(ref Mutable m) { m.I++; }
,编译器应该在大多数情况下阻止您做“错误”的事情。 - springy76值类型基本上代表不可变的概念。例如,拥有数学值(如整数、向量等)并能够修改它是没有意义的。那就像重新定义一个值的含义一样。与其更改值类型,不如分配另一个唯一的值。请考虑这样一个事实:通过比较所有属性的值来比较值类型。关键是,如果属性相同,则它是该值的相同通用表示。
正如Konrad所提到的,更改日期也没有意义,因为该值代表时间中的唯一点,而不是具有任何状态或上下文依赖性的时间对象实例。
希望这对您有所帮助。确保更多关注你尝试使用值类型捕获的概念,而不是实际细节。
int
迭代器,如果它是不可变的,那将是完全无用的。我认为你混淆了“值类型的编译器/运行时实现”和“被赋予值类型的变量”,后者肯定可以被任何可能的值所改变。 - Slipp D. Thompsonint[]
这样的可变类类型的结构体字段可能会封装标识或不变的值集,但不能用于封装可变值而不同时封装不需要的标识。 - supercatnamedtuple
),但这是另一种需要考虑的范例。point.x = point.x + 1
相对于:
point = Point(point.x + 1, point.y)
即使在不可变的情况下,内存消耗至少会相同,甚至可能会更多(尽管该情况将是暂时的,针对当前堆栈,具体取决于语言)。
但最终,结构体只是“结构体”,并非对象。在面向对象编程中,对象的主要属性是它们的“标识”,大多数情况下不超过其内存地址。结构体代表数据结构(不是一个适当的对象,因此它们无论如何都没有标识),数据可以被修改。在其他语言中,“记录”(而不是“结构体”,如Pascal所示)是这个词,并且具有相同的目的:只是一个数据记录变量,旨在从文件中读取,修改和转储到文件中(这是主要用途,在许多语言中,您甚至可以定义记录中的数据对齐方式,而对于适当称为对象的情况则不一定如此)。
想要一个好的例子吗?结构体用于轻松读取文件。Python拥有这个库,因为由于它是面向对象的并且不支持结构体,它必须以另一种方式实现它,这种方式有点丑陋。实现结构体的语言拥有该功能...内置。尝试使用Pascal或C等语言中的适当结构读取位图头。如果结构正确构建和对齐,这将很容易(在Pascal中,您不会使用基于记录的访问,但会使用函数来读取任意二进制数据)。因此,对于文件和直接(本地)内存访问,结构体比对象更好。至今,我们习惯于使用JSON和XML,因此忘记了使用二进制文件(并作为副作用忘记了使用结构体)。但是:它们存在,并且具有目的。
它们并不邪恶。只需将它们用于正确的目的。
如果您考虑钉子,您将希望将螺钉视为钉子,发现螺钉更难插入墙壁,这将是螺钉的错,它们将成为罪魁祸首。
int
和bool
类型是不可变的。 - Blorgbearda[V][X] = 3.14
语法来进行原地变异。在C#中,你最好提供结构体成员变异器方法,比如'MutateV(Action<ref Vector2> mutator),并像这样使用它:
a.MutateV((v) => { v.X = 3; })(由于C#关键字
ref`的限制,示例过于简化,但通过一些解决方法应该是可能的)。 - Slipp D. Thompson