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

众所周知,字符串是不可变的。String是不可变的,而StringBuilder类是可变的,原因是什么?

109589 次浏览

字符串管理是一个昂贵的过程。保持字符串不可变允许重复的字符串被重用,而不是重新创建。

字符串和其他具体对象通常表示为不可变对象,以提高可读性和运行时效率。安全性是另一个问题,进程不能更改字符串并向字符串中注入代码

你永远不需要防御性地复制不可变数据。尽管您需要复制它以使其变异,但由于缺乏防御性复制,通常能够自由地混叠并且永远不必担心这种混叠的意外后果,因此可以获得更好的性能。

不可变字符串还可以防止并发相关的问题。

使字符串不可变有很多优点。它提供了自动的线程安全性,并使字符串以一种简单有效的方式表现得像一个内在类型。它还允许在运行时提高效率(例如允许有效的字符串实习以减少资源使用),并且具有巨大的安全优势,因为第三方API调用不可能更改您的字符串。

添加StringBuilder是为了解决不可变字符串的一个主要缺点——不可变类型的运行时构造会带来很大的GC压力,并且固有的很慢。通过创建一个显式的、可变的类来处理这个问题,可以在不向字符串类添加不必要的复杂性的情况下解决这个问题。

为什么在c#中字符串类型是不可变的

String是一个引用类型,所以它不会被复制,而是通过引用传递。 将其与c++ std::string进行比较 对象(它不是不可变的),其中 通过值传递。这意味着如果 类中使用字符串作为键 哈希表,在c++里没问题,因为 c++将复制该字符串来存储 输入哈希表(实际上 Std::hash_map,但仍然)稍后 比较。所以即使你以后 修改std::string实例, 你很好。但是在。net中,当你使用 哈希表中的字符串,它将存储 对该实例的引用。现在 假设一下字符串 不是不可变的 发生了: 1. 有人将值x和键“hello”插入到哈希表中。 2. 哈希表计算String的哈希值,并放置一个 对字符串和值的引用 X在适当的桶中。 3.用户将String实例修改为"bye"。 4. 现在有人想要哈希表中与"hello"相关的值。它 最终找到了正确的桶, 但是当比较字符串时,它说 “再见”!="hello",所以没有值为 返回。 5. 也许有人想要"bye"这个值?"bye"可能有不同的意思 哈希,哈希表会在 不同的桶。没有“再见”键 这个桶,所以我们的入口仍然不是 发现。< / p > 使字符串不可变意味着 第三步是不可能的。如果有人 修改他正在创建的字符串 新的字符串对象,留下旧的 一个人。也就是说钥匙在 哈希表仍然是"hello",因此 仍然正确。< / p > 所以,可能在其他事情中, 不可变字符串是启用的一种方式 通过引用传递的字符串 用作哈希表中的键或 类似的字典对象

想象一下,您将一个可变字符串传递给一个函数,但不期望它被更改。如果函数改变了字符串呢?例如,在c++中,你可以简单地按值调用(std::stringstd::string&参数的区别),但在c#中,它都是关于引用的,所以如果你传递可变字符串,每个函数都可能改变它并触发意想不到的副作用。

这只是众多原因之一。性能是另一个问题(例如,被拘禁的字符串)。

顺便提一下,一个经常被遗忘的关于安全性的观点,想象一下如果字符串是可变的:

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

你可以看到,如果你被允许在字符串传递后改变它们,那将是非常非常糟糕的。

字符串在. net中作为引用类型传递。

引用类型将一个指针放在堆栈上,指向驻留在托管堆上的实际实例。这与Value类型不同,后者将整个实例保存在堆栈上。

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

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

string类型有一个重载的=操作符,它创建了一个自身的副本,而不是指针的副本——这使得它的行为更像一个值类型。但是,如果只复制了指针,第二个字符串操作可能会意外地覆盖另一个类的私有成员的值,从而导致一些非常糟糕的结果。

正如其他文章所提到的,StringBuilder类允许在没有GC开销的情况下创建字符串。

想象一下,一个操作系统处理的字符串是其他线程的字符串 在你背后修改。你怎么验证没有 复制一份?< / p >
  1. 不可变类型的实例本质上是线程安全的,因为没有线程可以修改它,线程以干扰另一个线程的方式修改它的风险被消除了(引用本身是另一回事)。
  2. 类似地,混叠不能产生变化(如果x和y都指向同一个对象,x的改变必然导致y的改变)的事实允许相当大的编译器优化。
  3. 内存节省优化也是可能的。实习和原子化是最明显的例子,尽管我们可以做相同原则的其他版本。我曾经通过比较不可变对象和替换重复的引用,使它们都指向相同的实例,从而节省了大约半GB的内存(耗时,但在这种情况下,额外启动一分钟以节省大量内存是性能上的胜利)。可变对象不能这样做。
  4. 将不可变类型作为方法传递给形参不会产生副作用,除非它是outref(因为这会改变引用,而不是对象)。因此,程序员知道如果string x = "abc"在方法的开始,并且在方法的主体中没有改变,那么x == "abc"在方法的结束。
  5. 从概念上讲,语义更像值类型;特别地,平等是基于国家而不是身份。这意味着"abc" == "ab" + "c".;虽然这并不需要不可变性,但事实上,对这样一个字符串的引用在其生命周期内总是等于“abc”(这确实需要不可变性),这使得它作为键使用时,保持与前一个值相等是至关重要的,更容易确保的正确性(字符串确实通常用作键)。
  6. 从概念上讲,不可变更有意义。如果我们在圣诞节后加上一个月,我们并没有改变圣诞节,而是在1月底产生了一个新的日期。因此,Christmas.AddMonths(1)生成一个新的DateTime而不是改变一个可变的DateTime是有意义的。(另一个例子,如果我作为一个可变对象改变了我的名字,改变的是我使用的名字,“Jon”仍然是不可变的,其他Jon将不受影响。
  7. 复制是快速和简单的,创建一个克隆return this。因为副本无论如何都不能更改,所以假装某个东西是它自己的副本是安全的。
  8. (编辑,我忘了这个)。内部状态可以安全地在对象之间共享。例如,如果你正在实现一个由数组、起始索引和计数支持的列表,那么创建子范围最昂贵的部分将是复制对象。然而,如果它是不可变的,那么子范围对象可以引用相同的数组,只有开始索引和计数必须改变,非常对构造时间有相当大的改变。

总之,对于那些没有经历变化作为其目的一部分的对象来说,不可变有很多好处。主要的缺点是需要额外的构造,尽管在这里它也经常被夸大(请记住,在StringBuilder变得比具有固有构造的等效串联更有效之前,您必须执行几次追加)。

如果可变性是对象目的的一部分,这将是一个缺点(谁会希望被一个工资永远不会改变的Employee对象建模),尽管有时它也很有用(在许多web和其他无状态应用程序中,执行读取操作的代码与执行更新的代码是分开的,使用不同的对象可能是自然的-我不会让一个对象不可变,然后强制这种模式,但如果我已经有了那种模式,我可能会让我的“read”对象为性能和正确性保证增益而不可变)。

即写即抄是一个中间地带。这里的“real”类包含对“state”类的引用。在复制操作时共享状态类,但如果更改状态,则会创建状态类的新副本。这是c++比c#更经常使用的,这就是为什么std:string在保持可变的同时,享受了不可变类型的一些优点,但不是全部。

字符串不是真正不可变的。它们只是公共不可变的。 这意味着您不能从它们的公共接口修改它们。但是在内部是可变的

如果你不相信我,看看使用反射器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是不安全的,因此它使用指针来复制字节。

也许还有更好的例子,但这是我目前为止找到的一个。

类数据存储不能在存储类控制之外修改的数据有五种常见方法:

  1. 作为值类型原语 通过持有对类对象的可自由共享引用,该类对象的感兴趣属性都是不可变的
  2. 通过保存对可变类对象的引用,该类对象将永远不会暴露给任何可能改变感兴趣的任何属性的对象
  3. 作为一个结构体,无论是"mutable"还是"immutable",其所有字段的类型都是#1-#4(不是#5)。
  4. 保存一个对象引用的唯一现存副本,该对象的属性只能通过该引用发生突变。

因为字符串是可变长度的,所以它们不能是值类型的原语,它们的字符数据也不能存储在结构体中。在剩下的选项中,唯一不要求字符串的字符数据存储在某种不可变对象中的选项是#5。虽然可以围绕选项5设计框架,但该选择要求任何想要在其控制之外更改字符串副本的代码都必须为自己创建一个私有副本。虽然这几乎是不可能做到的,但这样做所需的额外代码量,以及创建所有内容的防御副本所需的额外运行时处理量,将远远超过让string可变所带来的轻微好处,特别是,因为有一个可变的字符串类型(System.Text.StringBuilder)可以完成可变string所能完成的99%。