何时使用值类型和引用类型来处理不可变类型?(.NET)

8

对于可变类型,值类型和引用类型的行为差异是明显的:

// Mutable value type
PointMutStruct pms1 = new PointMutStruct(1, 2);
PointMutStruct pms2 = pms1;
// pms1 == (1, 2); pms2 == (1, 2);
pms2.X = 3;
MutateState(pms1); // Changes the X property to 4.
// pms1 == (1, 2); pms2 == (3, 2);

// Mutable reference type
PointMutClass pmc1 = new PointMutClass(1, 2);
PointMutClass pmc2 = pmc1;
// pmc1 == (1, 2); pmc2 == (1, 2);
pmc2.X = 3;
MutateState(pmc1); // Changes the X property to 4.
// pmc1 == (4, 2); pmc2 == (4, 2);

然而,对于不可变类型来说,这种差异就不那么明显了:

// Immutable value type
PointImmStruct pis1 = new PointImmStruct(1, 2);
PointImmStruct pis2 = pis1;
// pis1 == (1, 2); pis2 == (1, 2);
pis2 = new PointImmStruct(3, pis2.Y);
// Can't mutate pis1
// pis1 == (1, 2); pis2 == (3, 2);

// Immutable reference type
PointImmClass pic1 = new PointImmClass(1, 2);
PointImmClass pic2 = pic1;
// pic1 == (1, 2); pic2 == (1, 2);
pic2 = new PointImmClass(3, pic2.Y);
// Can't mutate pic1 either
// pic1 == (1, 2); pic2 == (3, 2);

不可变的引用类型通常也使用值语义(例如,典型的例子是System.String):

string s1 = GenerateTestString(); // Generate identical non-interned strings
string s2 = GenerateTestString(); // by dynamically creating them
// object.ReferenceEquals(strA, strB)) == false;
// strA.Equals(strB) == true
// strA == strB

Eric Lippert曾在他的博客上讨论过(例如这里),值类型通常(什么时候并不重要)在堆栈上分配是一种实现细节,它通常不应该决定你将对象作为值类型还是引用类型。

鉴于这种对于不可变类型的行为区别有些模糊,我们有哪些标准来决定是否将不可变类型作为引用类型或值类型呢?

此外,由于不可变类型强调值而不是变量,那么不可变类型是否应该始终实现值语义呢?

3个回答

7
我认为你所链接的Eric的博客文章已经给出了你需要的答案:
我很遗憾文档没有集中讨论最相关的内容;通过关注一个在很大程度上不相关的实现细节,我们放大了该实现细节的重要性,并掩盖了使值类型在语义上有用的重要性。我非常希望所有那些解释“堆栈”是什么的文章能花时间解释“按值复制”到底意味着什么,以及误解或滥用“按值复制”会导致什么样的错误。
如果您的对象应该具有“按值复制”的语义,请将它们制作成值类型。如果它们应该具有“按引用复制”的语义,请将它们制作成引用类型。
他还说了这句话,我也同意:
我总是根据类型是否在语义上表示值或在语义上表示对某物的引用来选择值类型 vs 引用类型。

值类型始终存储在表示它们的变量或字段中,无论这些变量或字段存储在何处。引用类型对象实际上从不存储在表示它们的变量或字段中;它们总是存储在堆上的其他位置。如果我有一个类的对象Foo1,其中包含一个非空字段Bar1,它是某个引用类型,那么Foo1和Bar1是独立的堆对象。如果我有一个类的对象Foo2,其中包含一个值类型Bar2,那么Bar2将存储在堆对象Foo2中。 - supercat
@supercat 这个问题围绕着一篇博客文章展开,该文章认为使用运行时的_实现细节_不应成为决定是否使用值类型的关键因素,而是值类型的“按值复制”语义优于引用类型的“按引用复制”语义。我不太确定你的评论除了强调这篇文章建议程序员避免的那些_实现细节_之外还添加了什么内容。 - jdmichal
如果一个类型是不可变的,复制引用和复制值在语义上将是相同的。除非不可变类型需要与其他类具有继承关系(这确实是一种有用的模式),否则不可变值类型和不可变类类型之间的主要区别将与性能有关。主要问题应该是是否更好为类型的每个不同实例创建单独的堆对象,但可能共享已知相同的实例,或避免创建堆对象来保存实例。 - supercat
我个人认为,对可变值类型的鄙视在很大程度上是由于.NET处理它们的一些限制和怪癖所导致的不幸后果。在许多情况下,实现最佳语义和性能的方法是通过引用传递可变值类型。 - supercat
@supercat,我不认为我真正理解你的观点。值类型之所以可以在堆栈上创建,是因为它们具有按值复制的语义。(保证值对象永远不会泄漏到堆栈上方。) 话虽如此,我认为这在文章中已经涵盖了,甚至不能保证值对象分配在堆栈上。应该向程序员公开的唯一事情是创建的类型是按值复制还是按引用复制。其他所有内容都应该被视为 VM 的程序员不透明运行时优化。 - jdmichal
Lippert的文章甚至没有提到可变性。 - Kyle Delaney

2

有一类重要的不可变类型(Eric Lippert也曾经详细讨论过)必须被实现为引用类型:递归类型,例如列表节点、树节点等等。值类型不能有循环定义,例如链表节点:

class IntNode
{
    private readonly int value;
    private readonly IntNode next;
}

1

.NET 的 String 类暗示了一个答案。它是不可变的,但是是引用类型。尽可能让你的不可变类型像值类型一样运作。实际上它是否是值类型并不重要。

所以我能想到的唯一标准是:如果复制它会很昂贵(例如 String 可能涉及大量复制!),那么就将其设为引用类型。如果复制速度很快,就选择值类型。此外,还要考虑是否需要比较引用 - 这可能是不可变引用类型唯一棘手的部分。


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