【问题标题】:In C#, why is String a reference type that behaves like a value type?在 C# 中,为什么 String 是一种行为类似于值类型的引用类型?
【发布时间】:2010-10-12 19:46:01
【问题描述】:

String 是一种引用类型,尽管它具有值类型的大部分特征,例如不可变和重载 == 以比较文本而不是确保它们引用同一个对象。

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

【问题讨论】:

  • 由于对于不可变类型的区别主要是实现细节(撇开is 测试),答案可能是“出于历史原因”。复制的性能不是原因,因为不需要物理复制不可变对象。现在,如果不破坏实际使用 is 检查(或类似约束)的代码,就不可能进行更改。
  • 顺便说一句,这与 C++ 的答案相同(尽管值和引用类型之间的区别在语言中并不明确),使 std::string 表现得像一个集合的决定是一个老错误,不能立即修复。

标签: c# string clr value-type reference-type


【解决方案1】:

字符串是具有值语义的引用类型。这种设计是一种折衷,它允许某些性能优化。

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

但字符串具有值语义,这意味着它们是不可变的,并且按值(即字符串的逐个字符)进行比较,而不是通过比较引用。这允许某些优化:

Interning 意味着如果多个字符串已知相等,编译器可以只使用单个字符串,从而节省内存。此优化仅在字符串不可变时有效,否则更改一个字符串会对其他字符串产生不可预知的结果。

字符串文字(在编译时已知)可以被编译器实习并存储在内存的特殊静态区域中。这节省了运行时的时间,因为它们不需要被分配和垃圾收集。

不可变字符串确实会增加某些操作的成本。例如,您不能就地替换单个字符,您必须为任何更改分配一个新字符串。但与优化带来的好处相比,这只是一个很小的成本。

值语义有效地为用户隐藏了引用类型和值类型之间的区别。如果一个类型具有值语义,那么对于用户来说,该类型是值类型还是引用类型都无关紧要 - 它可以被视为实现细节。

【讨论】:

  • 值类型和引用类型之间的区别根本与性能无关。它是关于变量是否包含实际对象或对对象的引用。字符串永远不可能是值类型,因为字符串的大小是可变的;它必须是常量才能成为值类型;性能几乎与它无关。引用类型的创建成本也不高。
  • @Sevy:字符串的大小常量。
  • 因为它只包含对可变大小的字符数组的引用。拥有一个唯一真正的“值”是引用类型的值类型会更加令人困惑,因为它仍然具有用于所有密集目的的引用语义。
  • @Sevy:数组的大小是恒定的。
  • 一旦你创建了一个数组,它的大小是不变的,但世界上所有数组的大小并不完全相同。这就是我的观点。要使字符串成为值类型,所有存在的字符串都需要具有完全相同的大小,因为这就是 .NET 中值类型的设计方式。它需要能够在实际拥有值之前为此类值类型保留存储空间,因此必须在编译时知道大小。这样的string 类型需要有一个固定大小的 char 缓冲区,这既限制又非常低效。
【解决方案2】:

这是对一个老问题的较晚答案,但所有其他答案都没有抓住重点,即 .NET 直到 2005 年的 .NET 2.0 才有泛型。

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

在非泛型集合中存储值类型需要对类型object 进行特殊转换,这称为装箱。当 CLR 将值类型装箱时,它会将值包装在 System.Object 中并将其存储在托管堆中。

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

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

一些答案​​错误地声称string 永远不可能实现为值类型,因为它的大小是可变的。实际上,将字符串实现为包含两个字段的固定长度数据结构很容易:字符串长度的整数和指向 char 数组的指针。您还可以在此基础上使用小字符串优化策略。

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

【讨论】:

  • 我的,谢谢你的回答!我一直在查看所有其他关于堆和堆栈分配的答案,而stack is an implementation detail。毕竟,string 只包含它的大小和指向char 数组的指针,所以它不会是一个“巨大的值类型”。但这是这个设计决定的一个简单的相关原因。谢谢!
  • @V0ldek:这不是真的,.net 中的字符串对象不包含指向单独分配的字符数组的指针。大小和字符存储在同一个地方。
  • @JacquesB 我是根据type definition in the BCL 来判断的。它只有大小和第一个字符。不过我可能错了,整个课程只是一些神奇的本机互操作。
  • @V0ldek:注意_firstChar 字段不是指针,它是一个字符。其余字符(如果有)直接位于其后。但是,是的,很多魔法正在发生。
【解决方案3】:

简单来说,任何具有确定大小的值都可以被视为值类型。

【讨论】:

  • 这应该是一条评论
  • 对于 c# 新手来说更容易理解
【解决方案4】:

字符串不是值类型,因为它们可能很大,并且需要存储在堆上。值类型(在迄今为止的所有 CLR 实现中)存储在堆栈中。堆栈分配字符串会破坏各种事情:堆栈对于 32 位只有 1MB,对于 64 位只有 4MB,你必须对每个字符串进行装箱,会产生复制惩罚,你不能实习字符串和内存使用会气球,等等......

(编辑:添加了关于值类型存储作为实现细节的说明,这导致我们有一个类型的值语义不是从 System.ValueType 继承的。感谢 Ben。) p>

【讨论】:

  • 我在这里吹毛求疵,但这只是因为它让我有机会链接到与问题相关的博客文章:值类型不一定存储在堆栈中。这在 ms.net 中最常见,但 CLI 规范根本没有指定。值类型和引用类型之间的主要区别在于,引用类型遵循按值复制的语义。见docs.microsoft.com/en-us/archive/blogs/ericlippert/…docs.microsoft.com/en-us/archive/blogs/ericlippert/…
  • @Qwertie:String 不是可变大小。添加到它时,实际上是在创建另一个 String 对象,并为其分配新内存。
  • 也就是说,理论上,字符串可以是值类型(结构),但“值”只不过是对字符串的引用。 .NET 设计者自然决定去掉中间人(结构处理在 .NET 1.0 中效率低下,很自然地遵循 Java,其中字符串已经被定义为引用,而不是原始类型。另外,如果字符串是一个值类型然后将其转换为对象将需要将其装箱,这是不必要的低效率)。
  • @codekaizen Qwertie 是对的,但我认为措辞令人困惑。一个字符串的大小可能与另一个字符串不同,因此,与真值类型不同,编译器无法事先知道要分配多少空间来存储字符串值。例如,Int32 始终为 4 个字节,因此编译器在您定义字符串变量时分配 4 个字节。当遇到int 变量(如果它是值类型)时,编译器应该分配多少内存?了解当时尚未分配该值。
  • 抱歉,我的评论中有一个错字,我现在无法修复;那应该是......例如,Int32 始终是 4 个字节,因此编译器在您定义 int 变量时分配 4 个字节。当遇到string 变量(如果它是值类型)时,编译器应该分配多少内存?了解当时尚未分配该值。
【解决方案5】:

冒着又一次神秘的否决票的风险......许多人提到堆栈和内存与值类型和原始类型有关的事实是因为它们必须适合微处理器中的寄存器。如果它占用的位多于寄存器的位,则您不能将某些东西推入/弹出堆栈……指令是,例如“pop eax”——因为 eax 在 32 位系统上是 32 位宽。

浮点基本类型由 80 位宽的 FPU 处理。

这一切早在出现一种 OOP 语言来混淆原始类型的定义之前就已经决定了,我认为值类型是专门为 OOP 语言创建的术语。

【讨论】:

    【解决方案6】:

    主要是性能问题。

    让字符串表现得像值类型有助于编写代码,但让它成为值类型会对性能产生巨大影响。

    如需深入了解,请查看 .net 框架中字符串的 nice article

    【讨论】:

      【解决方案7】:

      不只是字符串由字符数组组成那么简单。我将字符串视为字符数组 []。因此它们在堆上,因为参考内存位置存储在堆栈上,并指向堆上数组内存位置的开头。字符串大小在分配之前是未知的……非常适合堆。

      这就是为什么一个字符串是不可变的,因为当你改变它时,即使它的大小相同,编译器也不知道,必须分配一个新数组并将字符分配给数组中的位置。如果您将字符串视为语言保护您免于动态分配内存的一种方式,这是有道理的(像编程一样阅读 C)

      【讨论】:

      • "字符串大小在分配之前是未知的" - 这在 CLR 中是不正确的。
      【解决方案8】:

      实际上,字符串与值类型几乎没有相似之处。首先,并非所有值类型都是不可变的,您可以随意更改 Int32 的值,它仍然是堆栈上的相同地址。

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

      至于“==”:就像你说的“==”是一个运算符重载,再次实现它是为了让框架在处理字符串时更有用。

      【讨论】:

      • 我意识到值类型在定义上并不是不可变的,但大多数最佳实践似乎建议在创建自己的值类型时应该是。我说的是特性,而不是值类型的属性,这对我来说意味着值类型经常表现出这些,但不一定是定义
      • @WebMatrix, @Davy8:原始类型(int、double、bool、...)是不可变的。
      • @Jason,我认为不可变术语主要适用于初始化后无法更改的对象(引用类型),例如字符串值更改时的字符串,在内部创建字符串的新实例,以及原始对象保持不变。这如何应用于值类型?
      • 不知何故,在“int n = 4; n = 9;”中,并不是说你的 int 变量是“不可变的”,在“恒定”的意义上;就是值 4 是不可变的,它不会更改为 9。您的 int 变量“n”首先具有值 4,然后是另一个值 9;但值本身是不可变的。坦率地说,对我来说这非常接近 wtf。
      • +1。我讨厌听到这种“字符串就像值类型”,而实际上它们根本不是。
      【解决方案9】:

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

      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";
      

      有可能“我的字符串”常量的两个实例只会在您的程序集中分配一次。

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

      如果您要创建一个库,您希望在函数中传递大量字符串,请将参数定义为 StringBuilder 或 Stream。

      【讨论】:

      • 有很多不可变引用类型的例子。再举一个字符串示例,在当前实现下确实可以保证 - 技术上 它是每个 module (不是每个程序集) - 但这几乎总是同样的事情......
      • 最后一点:如果您尝试 pass 一个大字符串,StringBuilder 将无济于事(因为它实际上是作为字符串实现的) - StringBuilder 对于 多次操作一个字符串。
      【解决方案10】:

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

      【讨论】:

      • 它是一个引用类型(我相信),因为它不是从 System.ValueType 派生自 MSDN System.ValueType 备注:数据类型分为值类型和引用类型。值类型要么是堆栈分配的,要么是在结构中内联分配的。引用类型是堆分配的。
      • 引用类型和值类型都派生自终极基类 Object。如果值类型需要表现得像对象,则在堆上分配一个使值类型看起来像引用对象的包装器,并将值类型的值复制到其中。
      • 包装器被标记以便系统知道它包含一个值类型。这个过程称为装箱,相反的过程称为拆箱。装箱和拆箱允许将任何类型视为对象。 (在后站,可能应该只是链接到文章。)
      【解决方案11】:

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

      它具有保持世界理智的价值语义。你能想象如果编码会有多困难吗

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

      b 设置为false?想象一下,几乎任何应用程序的编码都是多么困难。

      【讨论】:

      • Java 并不以简洁着称。
      • @Matt:完全正确。当我切换到 C# 时,这有点令人困惑,因为我总是使用(有时仍然这样做).equals(..) 来比较字符串,而我的队友只使用“==”。我一直不明白他们为什么不留下“==”来比较引用,尽管如果您认为,90% 的时间您可能想要比较内容而不是字符串的引用。
      • @Juri:实际上我认为检查参考文献是不可取的,因为有时new String("foo"); 和另一个new String("foo") 可以在同一个参考文献中进行评估,这不是你所期望的@ 987654326@运营商做。 (或者你能告诉我一个我想比较参考文献的案例吗?)
      • @Michael 好吧,您必须在所有比较中包含参考比较才能捕获与 null 的比较。比较引用与字符串的另一个好地方是比较而不是相等比较。两个等效的字符串,比较时应该返回 0。尽管检查这种情况需要花费整个比较的时间,所以不是一个有用的捷径。检查ReferenceEquals(x, y) 是一种快速测试,您可以立即返回 0,当与您的空测试混合时,甚至不会增加任何工作。
      • ...让字符串成为该样式的值类型而不是类类型将意味着 string 的默认值可以表现为空字符串(因为它在 pre- .net 系统)而不是作为空引用。实际上,我自己的偏好是有一个值类型String,其中包含一个引用类型NullableString,前者的默认值等于String.Empty,后者的默认值为null,并且特殊的装箱/拆箱规则(这样装箱默认值 NullableString 将产生对 String.Empty 的引用)。
      【解决方案12】:

      此外,字符串的实现方式(每个平台不同)以及何时开始将它们拼接在一起。就像使用StringBuilder。它分配一个缓冲区供你复制,一旦你到达终点,它会为你分配更多的内存,希望如果你做一个大的连接性能不会受到阻碍。

      也许 Jon Skeet 可以在这里帮忙?

      【讨论】:

        猜你喜欢
        • 2013-07-26
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2011-12-06
        • 2017-01-07
        • 2011-01-25
        • 1970-01-01
        • 2012-04-14
        相关资源
        最近更新 更多