为什么.NET中的字符串是不可变的?

202

众所周知,String 是不可变的。String 不可变的原因是什么?为什么需要引入StringBuilder类来支持可变字符串?


9
类似问题:为什么在Java和.NET中字符串不能被修改?为了确保数据的安全性和稳定性,Java和.NET都将字符串定义为不可变类型。这意味着一旦字符串被创建,就不能对其进行任何更改。当你尝试更改一个字符串时,实际上是创建了一个新的字符串对象,而原始对象则保持不变。虽然这可能会导致一些开销,但这种设计使得字符串操作更加稳定和可靠。此外,它有助于防止一些常见的安全漏洞,例如缓冲区溢出攻击。 - ram
13个回答

258
  1. 不可变类型的实例天生是线程安全的,因为没有线程能够修改它,这消除了一个线程以一种干扰另一个线程的方式修改它的风险(引用本身是另外一回事)。
  2. 同样地,无法产生更改的别名(如果x和y都引用同一对象,对x的更改会导致对y的更改)可以允许相当大量的编译器优化。
  3. 还可以进行节约内存的优化。其中最明显的示例是内部化和原子化,尽管我们可以使用同样原理的其他版本。我曾经通过比较不可变对象并替换到重复引用的方式来实现半GB的内存节省(耗时,但在该情况下,为了节省大量内存而多花一分钟启动时间是性能上的收益)。对于可变对象则无法这样做。
  4. 传递不可变类型作为方法参数不会产生副作用,除非它是outref(因为这会更改引用,而不是对象)。因此程序员知道,如果string x = "abc"在方法开头,而且在方法体中未改变,那么在方法结束时x == "abc"
  5. 从概念上讲,语义更像值类型;特别是相等性基于状态而不是标识。这意味着"abc" == "ab" + "c"。虽然这不需要不可变性,但是这样一个指向字符串的引用在其生命周期内总是等于"abc"(这确实需要不可变性),使得在维护先前值的相等性关系很重要的键的使用,更容易确保正确性(字符串确实常被用作键)。
  • 从概念上讲,不可变的状态更有意义。如果我们在圣诞节上增加一个月,我们没有改变圣诞节,而是在一月下旬产生了一个新日期。因此,Christmas.AddMonths(1)应该生成一个新的DateTime而不是更改可变对象。(另一个例子是,如果我作为一个可变对象改变我的名字,改变的只是我使用的名字,“Jon”仍然是不变的,其他人仍然会叫做Jon。
  • 复制是快速和简单的,只需 return this就可以创建一个克隆。由于无论如何都不能更改此副本,所以将某物视为其自身的副本是安全的。
  • [编辑,我忘了这个]. 内部状态可以在对象之间安全共享。例如,如果您正在实现一个由数组、起始索引和计数支持的列表,那么创建子范围的最昂贵的部分将是复制对象。但是,如果它是不可变的,则子范围对象可以引用相同的数组,只需要更改起始索引和计数,构建时间将发生非常大的变化。
  • 总之,对于不需要经常变更状态的对象来说,不可变性可以带来很多优点。主要的缺点在于需要额外的构建,尽管即使在这里,它也经常被夸大了(请记住,在StringBuilder变得比等效的串联序列更有效之前,您必须进行多次追加,具有其固有的构造)。

    如果可变性是一个对象设计目的的一部分,那么它将是一个缺点(谁想被Employee对象建模,而该对象的薪水永远不会改变),但有时甚至在这种情况下它也可能很有用(在许多Web和其他无状态应用程序中,执行读操作的代码与执行更新操作的代码是分开的,并且使用不同的对象可能是自然的——我不会将对象变成不可变的,然后强制使用该模式,但如果我已经拥有了该模式,为了提高性能和正确性保障,我可能会将我的“读取”对象变成不可变的)。

    Copy-on-write是一种折中方案。在这里,“真实”类持有对“状态”类的引用。在复制操作时,状态类是共享的,但如果更改状态,则会创建状态类的新副本。这种方法在C++中比在C#中更常用,这就是为什么std:string具有不可变类型的一些优点,但仍然保持可变性的原因。


    2
    @IanBoyd 是的,但这是一个好的折中还是更糟的两者结合的中间地带是另一回事。在此不详细讨论,但是http://drdobbs.com/cpp/184403779对COW在STL字符串类型中的使用进行了有趣的评论。有趣的是,结论可能是拥有单独的可变和不可变类型会更好,这当然正是我们在这里谈论的内容。 - Jon Hanna
    不可变字符串(以及不可变类和普通数据结构)的另一个优点是,如果一个例程接受一个不可变字符串(或POD结构),那么在调用该例程时语义上捕获该值就没有任何问题。相比之下,如果将可变对象传递给“SetAttribute”函数并随后更改它,则可能不清楚该更改是否不会影响属性,是否会以“预期”的方式影响它,或者是否会以某种意外的方式破坏它。 - supercat
    请注意,自C++11起,C++标准明确禁止使用COW语义来处理std::string - Max Truxa
    1
    @JonHanna 这个答案似乎解释了不可变类型的好处,但并没有真正解释为什么String是不可变的。为什么某些其他.NET引用类型不是不可变的?您能否详细说明一下String本身为什么是不可变的? - Howiecamp
    2
    字符串是不可变的,因为框架设计者决定它应该是这样。很可能是因为在这个答案中列出的原因。 - Lasse V. Karlsen

    79
    使字符串不可变性有许多优点。它提供了自动线程安全,并使字符串表现得像一个内在类型,简单有效。它还允许在运行时进行额外的效率(例如允许有效的字符串池化以减少资源使用),并具有巨大的安全优势,因为第三方API调用无法更改您的字符串。
    StringBuilder被添加是为了解决不可变字符串的一个主要缺点 - 构建不可变类型的运行时会导致很多GC压力和固有的慢速问题。通过创建一个明确的、可变的类来处理这个问题,可以解决这个问题,而不会给字符串类添加不必要的复杂性。

    7
    在这里需要说明的是,不可变性本身并不慢,即使字符串类的特定实现是。字符串不一定需要作为字符数组实现,完全可以将字符串实现为不可变绳,其具有 O(1) 的连接和 O(lg n) 的子字符串操作。 - Juliet
    8
    @Juliet:但你在这里也做出了权衡——你获得了O(1)的连接和lg n的子字符串,但你失去了常数时间元素访问和缓存局部性。有一个原因,为什么字符串通常没有像绳索一样被实现。 - Billy ONeal
    把你的字符串交给System.Reflection等对象,我们就会看到它们有多么难以改变。谁会在这样的地方说出这样的话呢? - Behrooz

    25

    字符串并非真正的不可变,它们只是公开不可变。这意味着您无法通过公共接口修改它们。但在内部,它们实际上是可变的。

    如果您不相信我,请查看使用反编译工具查看String.Concat定义的最后几行代码...

    int length = str0.Length;
    string dest = FastAllocateString(length + str1.Length);
    FillStringChecked(dest, 0, str0);
    FillStringChecked(dest, length, str1);
    return dest;
    

    正如您所看到的,FastAllocateString返回一个空但已分配的字符串,接着被FillStringChecked修改。

    实际上,FastAllocateString是一个extern方法,而FillStringChecked是不安全的,因此它使用指针来复制字节。

    也许有更好的例子,但这是我目前找到的。


    2
    请参阅此博客文章以获取更多信息:https://blog.getpaint.net/2015/07/21/net-strings-are-immutable-except-that-theyre-not/ - Jan

    15

    字符串管理是一项昂贵的过程。使字符串不可变可以重复使用字符串,而不是重新创建。


    1
    这是Java的一半原因,但在.Net中有六个,安全性是另一个非常重要的因素。 - Nick Craver
    2
    啊..原来字符串是引用类型而不是值类型..其实这对我来说是个大问题 如果字符串是不可变的,为什么不使用值类型呢..? 不管怎样,谢谢。 - ktutnik
    6
    你认为通过堆栈传递大约100MB(甚至更大)的字符串是一个好主意吗? - apocalypse

    14

    为什么 C# 中的字符串类型是不可变的?

    字符串是引用类型,因此永远不会被复制,而是通过引用传递。与C++的std::string对象(它不是不可变的)相比,它是按值传递的。这意味着如果您想在Hashtable中使用字符串作为键,在C++中就没问题了,因为C++将复制字符串以将键存储在Hashtable(实际上是std::hash_map,但仍然)中以供以后比较。因此,即使稍后修改std::string实例,也没关系。但是在.Net中,当您在Hashtable中使用String时,它将存储对该实例的引用。现在假设一下字符串不是不可变的,看看会发生什么: 1.某人将一个名为“hello”的键插入到Hashtable中。 2.Hashtable计算String的哈希值,并将指向字符串和值x的引用放置在适当的桶中。 3.用户将String实例修改为“bye”。 4.现在有人想要与“hello”相关联的hashtable中的值。它最终会在正确的桶中查找,但在比较字符串时,它会说“bye”!=“hello”,因此没有返回值。 5.也许有人想要价值“bye”? “bye”可能有不同的哈希,因此散列表将查找不同的桶。在那个桶中没有“bye”键,因此我们的条目仍然没有找到。 使字符串不可变意味着步骤3是不可能的。如果有人修改了字符串,他正在创建一个新的字符串对象,留下旧的对象。这意味着Hashtable中的键仍然是“hello”,因此仍然正确。因此,不可变的字符串可能是使按引用传递的字符串可以用作Hashtable或类似字典对象中的键的一种方法。

    7

    仅仅为了补充一下,一个经常被忽视的观点是安全性。想象一下这样的场景,如果字符串是可变的:

    string dir = "C:\SomePlainFolder";
    
    //Kick off another thread
    GetDirectoryContents(dir);
    
    void GetDirectoryContents(string directory)
    {
      if(HasAccess(directory) {
        //Here the other thread changed the string to "C:\AllYourPasswords\"
        return Contents(directory);
      }
      return null;
    }
    

    你可以想象,如果一旦传递了字符串就允许其进行变异,那么会有多么糟糕。

    是的,我看到问题了,但我没有看到任何安全问题..实际上,如果你已经知道字符串是可变的,那么很容易通过使用克隆来解决它,而不仅仅是像那样传递它。我有什么遗漏吗? - ktutnik
    2
    在多线程场景中,您可以更改该字符串的内容,并绕过访问检查,从而有效地绕开它并访问任何您想要的东西。这是安全性问题的众多例子之一。这个答案没有回答“如果它们是可变的,你会做什么?”......那是一个不同的问题,问题是“为什么它们现在不可变?” - Nick Craver

    6

    您永远不必防御性地复制不可变数据。尽管您需要复制它来改变它,但通常可以自由别名,并且无需担心此别名的意外后果,这可以通过缺少防御性复制来提高性能。


    5

    .NET 中的字符串被视为引用类型。

    引用类型在栈上放置了一个指针,指向托管堆中实际的实例。这与值类型不同,值类型将整个实例保存在栈上。

    当传递值类型作为参数时,运行时会在堆栈上创建值的副本,并将该值传递到方法中。这就是为什么必须使用'ref'关键字来传递整型以返回更新后的值。

    当传递引用类型时,运行时会在堆栈上创建指针的副本。复制的指针仍然指向引用类型的原始实例。

    字符串类型具有重载的=运算符,它创建自身的副本,而不是指针的副本-使其更像值类型。但是,如果只复制指针,则第二个字符串操作可能会意外覆盖另一个类的私有成员的值,从而导致一些非常不好的结果。

    正如其他帖子所提到的,StringBuilder类允许创建字符串,而无需GC开销。


    3
    实际上,字符串没有重载的=操作符。如果你写了string a = b,则ReferenceEquals(a, b)成立;同时,ReferenceEquals(a, a.Clone())也成立。关键是由于字符串是不可变的,我们可以像复制一样使用=,尽管实际上并没有复制。我们不必担心b的更改会影响a,因为无法更改b。 - Jon Hanna

    3

    为了提高可读性和运行效率,字符串和其他具体对象通常被表达为不可变对象。安全性是另一个考虑因素,进程无法更改您的字符串并将代码注入到字符串中。


    3
    假设你将一个可变字符串传递给函数,但不希望它被更改。那么如果该函数更改了该字符串怎么办?例如在C++中,您可以直接使用传值调用(std::stringstd::string&参数之间的区别)。但在C#中,一切都与引用有关,因此,如果您在各个函数之间传递可变字符串,则每个函数都可能更改它并触发意外的副作用。
    这只是各种原因之一。性能是另一个原因(例如,内部化字符串)。

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