在C#中,为什么String是行为类似于值类型的引用类型?


371

字符串是一种引用类型,即使它具有值类型的大多数特征,例如不可变且具有==重载以比较文本而不是确保它们引用相同的对象。

那么为什么字符串不只是一个值类型?


由于对于不可变类型,区别主要是实现细节(不考虑is测试),因此答案可能是“出于历史原因”。由于无需物理复制不可变对象,因此复制性能不能成为原因。现在,如果不破坏实际使用is检查(或类似约束)的代码,就无法进行更改。
Elazar

顺便说一句,对于C ++,这是相同的答案(尽管在语言中值和引用类型之间的区别并不明显),但使std::string行为像集合一样的决定是一个旧错误,现在无法解决。
Elazar

Answers:


333

字符串不是值类型,因为它们可能很大,并且需要存储在堆中。值类型(到目前为止,在CLR的所有实现中)都存储在堆栈中。堆栈分配字符串会破坏各种情况:32位堆栈只有1MB,64位堆栈只有4MB,您必须将每个字符串装箱,这会产生复制损失,您不能内在字符串和内存使用情况会气球等

(编辑:添加了关于值类型存储是实现细节的说明,这导致这种情况,即我们的类型具有不从System.ValueType继承的值语义。感谢Ben。)


75
我在这里挑剔,但这只是因为它给了我一个机会链接到与该问题相关的博客文章:值类型不一定存储在堆栈中。这在ms.net中最常见,但在CLI规范中完全没有指定。值和引用类型之间的主要区别在于,引用类型遵循按值复制的语义。见blogs.msdn.com/ericlippert/archive/2009/04/27/...blogs.msdn.com/ericlippert/archive/2009/05/04/...
奔Schwehn

8
@Qwertie:String大小不可变。当添加到它时,实际上是在创建另一个String对象,为其分配新的内存。
codekaizen 2010年

5
也就是说,从理论上讲,字符串可以是值类型(结构),但“值”仅是对字符串的引用。.NET设计人员自然决定削减中间人(.NET 1.0中的结构处理效率很低,并且遵循Java是很自然的,在Java中,字符串已经被定义为引用类型,而不是原始类型。此外,如果字符串是值类型然后将其转换为对象将需要将其装箱,这是不必要的低效率)。
Qwertie 2010年

7
@codekaizen Qwertie是正确的,但我认为措辞令人困惑。一个字符串的大小可能与另一个字符串不同,因此,与真值类型不同,编译器无法事先知道要分配多少空间来存储字符串值。例如,an Int32始终为4个字节,因此,每次您定义字符串变量时,编译器都会分配4个字节。编译器在遇到int变量(如果是值类型)时应分配多少内存?了解当时该值尚未分配。
凯文·布罗克

2
抱歉,我的注释中有错字,我现在无法解决;应该是...。例如,Int32总是为4个字节,因此,每次您定义int变量时,编译器都会分配4个字节。遇到string变量(如果是值类型)时,编译器应分配多少内存?了解当时尚未分配该值。
凯文·布洛克

57

它不是值类型,因为如果它是一个值类型,性能(空间和时间!)会很糟糕,并且每次将其值传递给方法或从方法返回时都必须复制其值,等等。

它具有使世界保持理智的有价值的语义。你能想象如果

string s = "hello";
string t = "hello";
bool b = (s == t);

设置bfalse?想象一下,几乎对任何应用程序进行编码都是多么困难。


44
Java不精通。
杰森

3
@Matt:完全是。当我切换到C#时,这有点令人困惑,因为我一直(有时还是这样做)使用.equals(..)来比较字符串,而我的队友只是使用“ ==”。我从来不明白为什么他们不留下“ ==”来比较引用,尽管您认为90%的时间中您可能想比较内容而不是字符串的引用。
朱里(Juri)

7
@Juri:实际上,我认为检查引用绝对不是可取的,因为有时new String("foo");其他人new String("foo")可以在相同的引用中进行评估,这不是您期望new操作员执行的操作。(或者你能告诉我一个我想比较参考文献的情况吗?)
Michael

1
@Michael好,您必须在所有比较中都包括一个参考比较,以捕获具有null的比较。比较引用与字符串的另一个好地方是比较而不是相等比较。比较时,两个等效的字符串应返回0。尽管如此,检查这种情况虽然要花费整个比较的时间,但是这不是一个有用的捷径。检查ReferenceEquals(x, y)是一种快速的测试,您可以立即返回0,而将其与null测试混合甚至不会增加任何工作。
乔恩·汉娜

1
...将字符串作为该样式的值类型而不是类类型将意味着a的默认值string可以表现为空字符串(如在.net之前的系统中)而不是空引用。实际上,我自己的偏好是拥有一个String包含引用类型的值类型,其中NullableString前者的默认值等于,String.Empty后者的默认值为null,并且具有特殊的装箱/拆箱规则(例如,将默认的装箱值NullableString将产生对的引用String.Empty
2012年

26

引用类型和值类型之间的区别基本上是语言设计中的性能折衷。引用类型是在堆上创建的,因此它们在构造,销毁和垃圾回收上有一些开销。另一方面,值类型会增加方法调用的开销(如果数据大小大于指针),因为整个对象而不是指针被复制。因为字符串可以(通常是)比指针的大小大得多,所以它们被设计为引用类型。而且,正如Servy指出的那样,必须在编译时就知道值类型的大小,而字符串并非总是如此。

可变性问题是一个单独的问题。引用类型和值类型都可以是可变的或不可变的。但是,值类型通常是不可变的,因为可变值类型的语义可能会造成混淆。

引用类型通常是可变的,但如果有意义,可以将其设计为不可变的。字符串被定义为不可变的,因为它使某些优化成为可能。例如,如果同一字符串文字在同一程序中多次出现(这很常见),则编译器可以重用同一对象。

那么,为什么“ ==”重载以按文本比较字符串?因为它是最有用的语义。如果两个字符串在文本上相等,则由于优化,它们可能不是同一对象引用。因此,比较引用几乎没有用,而比较文本几乎总是您想要的。

更笼统地说,字符串具有所谓的值语义。这是比值类型更笼统的概念,值类型是C#特定的实现细节。值类型具有值语义,但是引用类型也可能具有值语义。当类型具有值语义时,您不能真正判断基础实现是引用类型还是值类型,因此可以考虑实现细节。


值类型和引用类型之间的区别实际上与性能无关。关于变量是包含实际对象还是对对象的引用。字符串永远不可能是值类型,因为字符串的大小是可变的。它必须是常量才能成为值类型;性能几乎与它无关。创建引用类型也不是很昂贵。
Servy

2
@Sevy:字符串的大小恒定的。
JacquesB 2013年

因为它仅包含对可变大小的字符数组的引用。如果值类型只有真正的“值”是引用类型,那将更加令人困惑,因为它仍然具有用于所有密集用途的引用语义。
2013年

1
@Sevy:数组的大小是恒定的。
JacquesB 2013年

1
创建数组后,它的大小是恒定的,但是整个世界中的所有数组都不都是完全相同的大小。这就是我的意思。为了使字符串成为值类型,存在的所有字符串都必须大小完全相同,因为这是.NET中设计值类型的方式。它需要能够在实际拥有值之前为此类值类型保留存储空间,因此必须在编译时知道其大小。这样的string类型将需要具有某个固定大小的char缓冲区,这既是限制性的又是非常低效的。
Servy

16

这是对一个老问题的较晚答案,但是所有其他答案都没有抓住重点,那就是.NET在2005年的.NET 2.0之前没有泛型。

String是引用类型而不是值类型,因为对于Microsoft而言,确保以最有效的方式将字符串存储在非通用集合(例如)中至关重要System.Collections.ArrayList

在非通用集合中存储值类型需要对类型进行特殊转换,object即装箱。当CLR装箱值类型时,它将值包装在a内System.Object并将其存储在托管堆中。

从集合中读取值需要反向操作,这称为拆箱。

装箱和拆箱的成本都是不可忽略的:装箱需要额外的分配,装箱需要类型检查。

一些答案错误地声称 string由于其大小是可变的因此永远不可能实现为值类型。实际上,使用“小型字符串优化”策略将字符串实现为固定长度的数据结构很容易:字符串将以Unicode字符序列的形式直接存储在内存中,除了大型字符串会作为指向外部缓冲区的指针存储之外。两种表示形式都可以设计为具有相同的固定长度,即指针的大小。

如果从一开始就存在泛型,我想将字符串作为值类型可能是更好的解决方案,它具有更简单的语义,更好的内存使用率和更好的缓存局部性。List<string>仅包含小字符串的A 可能是一个连续的内存块。


我的,谢谢你的回答!我一直在寻找所有其他答案,这些内容都说明了有关堆和栈分配的问题,而stack是实现细节。毕竟,string仅包含其大小和指向char数组的指针,因此它不会是“巨大的值类型”。但这是此设计决策的简单且相关的原因。谢谢!
V0ldek

8

不仅字符串是不可变的引用类型。 多播代表。 这就是为什么写安全

protected void OnMyEventHandler()
{
     delegate handler = this.MyEventHandler;
     if (null != handler)
     {
        handler(this, new EventArgs());
     }
}

我认为字符串是不可变的,因为这是使用它们和分配内存的最安全的方法。为什么它们不是值类型?先前的作者对堆栈大小等都是正确的。我还要补充一点,当在程序中使用相同的常量字符串时,将字符串作为引用类型可以节省程序集的大小。如果您定义

string s1 = "my string";
//some code here
string s2 = "my string";

可能会在您的程序集中只分配一次“ my string”常量的两个实例。

如果您想像通常的引用类型一样管理字符串,请将字符串放入新的StringBuilder(string s)中。或使用MemoryStreams。

如果要创建一个库,希望在函数中传递巨大的字符串,则可以将参数定义为StringBuilder或Stream。


1
有很多不可变引用类型的示例。再以字符串为例,在当前的实现中确实可以保证-从技术上讲,每个模块都是这样(不是每个程序集)-但这几乎总是同一件事……
Marc Gravell

5
最后一点:如果您尝试传递大字符串,StringBuilder将无济于事(因为它实际上实际上是作为字符串实现的)-StringBuilder对于多次操作字符串很有用。
马克·格雷韦尔

您是说代理处理程序,而不是hadler吗?(对不起,很抱歉..但是它非常接近(我不知道)一个姓氏……)
Pure.Krome,2009年

6

同样,字符串的实现方式(每个平台各不相同)以及何时开始将它们缝合在一起。就像使用StringBuilder。它分配了一个缓冲区供您复制,一旦到达末尾,它将为您分配更多的内存,希望这样做不会影响较大的串联性能。

也许乔恩·斯基特(Jon Skeet)可以在这里帮忙吗?


5

主要是性能问题。

具有字符串的行为类似于LIKE值类型,这在编写代码时会有所帮助,但是让其成为值类型会严重影响性能。

要深入了解,请看一看有关.net框架中字符串的不错的文章



2

您怎么知道string引用类型?我不确定它的实现方式是否重要。C#中的字符串精确地是不可变的,因此您不必担心此问题。


它是一种引用类型(我相信),因为它不是从System.ValueType派生的。从MSDN上,System.ValueType的备注:数据类型分为值类型和引用类型。值类型在结构中是堆栈分配的或内联分配的。引用类型是堆分配的。
Davy8 2009年

引用和值类型均从最终的基类Object派生。如果值类型必须表现得像对象,则将使值类型看起来像引用对象的包装器分配在堆上,然后将值类型的值复制到堆中。
戴维

包装器已标记,因此系统知道它包含值类型。此过程称为装箱,而反向过程称为拆箱。装箱和拆箱允许将任何类型都视为对象。(在后站点,可能应该只链接到该文章。)
Davy8,2009年

2

实际上,字符串与值类型几乎没有相似之处。对于初学者来说,并不是所有的值类型都是不可变的,您可以随意更改Int32的值,并且它仍然是堆栈上的相同地址。

字符串是不可变的,这有很好的理由,它与作为引用类型无关,但与内存管理有很大关系。当字符串大小改变时,创建新对象比在托管堆上转移内容更有效。我认为您正在将值/引用类型和不可变对象概念混合在一起。

就“ ==”而言:就像您说的那样,“ ==”是运算符重载,并且再次实现它是有很好的理由的,目的是使框架在处理字符串时更有用。


我意识到,值类型并不是从定义上是不变的,但是大多数最佳实践似乎都建议在创建自己的值类型时应该使用它们。我说的是特征,而不是值类型的属性,对我而言,这意味着值类型通常会显示这些值,但不一定按定义进行
Davy8 2009年

5
@ WebMatrix,@ Davy8:基本类型(int,double,bool等)是不可变的。
杰森

1
@Jason,我认为不可变术语主要适用于初始化后无法更改的对象(引用类型),例如字符串值更改时的字符串,内部创建字符串的新实例以及原始对象保持不变的对象。这如何应用于值类型?
WebMatrix

8
不知何故,在“ int n = 4; n = 9;”中,并不是说您的int变量是“不变的”,而是“常量”。这是因为值4是不可变的,不会更改为9。您的int变量“ n”首先具有值4,然后具有另一个值9;但是这些值本身是不可变的。坦白说,对我来说,这非常接近wtf。
丹尼尔·达拉纳斯

1
+1。我讨厌听到这样的“字符串就像值类型”,而实际上却并非如此。
乔恩·汉纳

1

不仅仅是字符串是由字符数组组成的。我将字符串视为字符数组[]。因此它们在堆上,因为参考内存位置存储在堆栈上,并指向堆上数组内存位置的开头。在为堆分配完美的字符串大小之前未知。

这就是字符串实际上是不可变的原因,因为即使更改了相同大小的字符串,编译器也不知道该字符串,因此必须分配一个新数组并将字符分配给该数组中的位置。如果您认为字符串是语言保护您免于动态分配内存的一种方式,那么这是有道理的(类似于编程,请参阅C)


1
“在分配之前未知字符串大小”-在CLR中不正确。
codekaizen 2013年

-1

冒着再次神秘地投票的危险……许多人提到值类型和基元类型的堆栈和内存的事实是因为它们必须适合微处理器中的寄存器。如果所占用的位数比寄存器的位数多,则不能将某物压入/弹出堆栈。指令例如是“ pop eax”-因为eax在32位系统上为32位宽。

浮点基元类型由FPU处理,FPU的宽度为80位。

这一切都是在OOP语言混淆原始类型的定义之前就已经确定的,我认为值类型是专门为OOP语言创建的术语。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.