C#中的字符串不可变性

25

我对StringBuilder类的内部实现很好奇,于是我决定查看Mono的源代码并将其与Microsoft的实现的反编译代码进行比较。基本上,Microsoft的实现使用 char[] 来内部存储字符串表示,并使用一堆不安全的方法来操作它。这很直观,没有引起任何问题。但当我发现Mono在StringBuilder内部使用了一个字符串时,我感到困惑:

private int _length;
private string _str;

一开始的想法是:"这个 StringBuilder 没有意义"。但后来我发现可以使用指针来改变一个字符串:

public StringBuilder Append (string value) 
{
     // ...
     String.CharCopy (_str, _length, value, 0, value.Length);
}

internal static unsafe void CharCopy (char *dest, char *src, int count) 
{
    // ...
    ((short*)dest) [0] = ((short*)src) [0]; dest++; src++;
}    

我曾经稍微学过C/C++,所以不能说这段代码让我感到困惑,但是我认为字符串是完全不可变的(也就是说绝对没有办法可以改变它)。因此,实际问题是:

  • 我能创建一个完全不可变的类型吗?
  • 除了性能考虑之外,使用这样的代码是否有任何理由? (用于更改不可变类型的不安全代码)
  • 那么字符串是否本质上是线程安全的呢?

2
我自己在一段时间前发现了这个,并对此感到非常着迷,以至于我写了一篇关于它的博客文章,我将在这里包含,因为根据这个问题,我认为你可能会觉得它很有趣。 - Dan Tao
6个回答

44

我能创建一个完全不可变的类型吗?

你可以创建一个CLR强制执行不可变性的类型,然后使用"unsafe"来关闭CLR执行机制。这就是为什么"unsafe"被称为"不安全"的原因,因为它关闭了安全系统。在不安全的代码中,如果你足够努力,就可以将进程中的每个字节的内存都设为可写状态,包括不可变字节和CLR中执行不可变性的代码。

你也可以使用反射来打破不可变性。反射和不安全的代码都需要授予极高的信任级别。

除了性能问题之外,还有什么理由使用这样的代码吗?

当然,使用不可变数据结构有很多好处。不可变数据结构非常好。以下是一些使用不可变数据结构的好理由:

  • 与可变数据结构相比,不可变数据结构更容易推理。当你询问“这个列表是否为空?”并获得答案时,你知道答案不仅在现在正确,而且永远都是正确的。对于可变数据结构,你实际上无法询问“这个列表是否为空?”你只能问“这个列表现在是否为空?”,然后答案逻辑上回答了问题“这个列表过去是否为空?”

关于不可变类型的问题的答案保持真实永久性,这具有安全性的影响。假设你有以下代码:

void Frob(Bar bar)
{
    if (!IsSafe(bar)) throw something;
    DoSomethingDangerous(bar);
}
如果Bar是可变类型,则存在竞争条件;在检查之后但在发生危险情况之前,bar可能会在另一个线程上变得不安全。如果Bar是不可变类型,则问题的答案始终保持不变,这样更安全。 (例如,想象一下在安全检查之后但在打开文件之前可以更改包含路径的字符串的情况。)
将不可变数据结构作为参数并将其作为结果返回且不执行副作用的方法称为“纯方法”。纯方法可以进行记忆化,这会以增加的内存使用量换取增加的速度,通常是大幅增加的速度。
不可变数据结构通常可以在多个线程上同时使用而无需锁定。锁定用于防止在面对突变时对象的不一致状态的创建,但是不可变对象没有突变。(某些所谓的不可变数据结构在逻辑上是不可变的,但实际上在其内部进行突变;例如,想象一下查找表,它不会更改其内容,但会重新组织其内部结构,如果可以推断出下一个查询可能是什么。这样的数据结构不会自动线程安全。)
在有效地重复使用其内部部件的不可变数据结构中,构建新结构以从旧结构中"拍摄快照"会使程序状态变得更容易,而不会浪费大量内存。这使得撤销-重做操作变得微不足道。编写调试工具以显示如何达到特定程序状态也变得更加容易。
如果所有人都遵守规则,则字符串是线程安全的。如果有人使用不安全代码或私有反射,则不存在规则执行。您必须信任,如果有人使用高特权代码,则他们正在正确地使用它,并且不会对字符串进行突变。仅出于善意使用您的运行不安全代码的权力;伴随着强大的权力而来的是巨大的责任。
那么我是否需要使用锁定?这是一个奇怪的问题。请记住,锁是协作性的。只有当访问特定对象的所有人都同意必须使用锁定策略时,锁才起作用。如果这不是约定俗成的锁定策略,则使用锁是无意义的;您小心地锁定和解锁前门,而其他人则走在打开的后门中。
如果您知道由不安全代码进行突变的字符串,并且不希望看到不一致的部分突变,并且执行不安全突变的代码记录了在该突变期间取出特定锁,则是的,在访问该字符串时需要使用锁定。但是,这种情况非常罕见;理想情况下,不会有人使用不安全代码来操作另一个线程上的其他代码可以访问的字符串,因为这样做是一个非常糟糕的主意。这就是为什么我们要求对执行此操作的代码进行完全信任。这就是为什么我们要求用于此类函数的C#源代码挥舞着一个大红旗,上面写着“此代码是不安全的,请认真审查!”

少费力气就能变得恶劣:几个随机的TerminateThread调用 :-) - Richard

3
如果您不安全地操作,C# 中的字符串也可能会发生变异(如我所记得的)。

1
是的,没错。但是,由于字符串被内部化了,所以在尝试这样做之前,您应该真正了解字符串的工作原理。 - Guffa
1
即使是不安全的,也可以使用超过640kb的内存! - jeremy-george

3
没有完全不可变的类型,一个不可变的类是因为它不允许任何外部代码改变它。使用反射或不安全代码仍然可以更改它的值。
您可以使用 readonly 关键字创建不可变变量,但这仅适用于值类型。如果将其用于引用类型,则只保护引用,而不保护指向的对象。
存在多种原因使得使用不可变类型,如性能和鲁棒性。
字符串被认为是不可变的(除了 StringBuilder)这一事实意味着编译器可以基于此进行优化。编译器永远不必生成代码来复制字符串以防止在传递参数时被更改。
从不可变类型创建的对象也可以安全地在线程之间传递。由于它们不能被更改,所以不存在不同线程同时更改它们的风险,因此不需要同步访问它们。
不可变类型可用于避免编码错误。如果您知道某个值不应更改,则通常最好确保无法出现因过失而导致的更改。

2

这里并没有黑魔法。字符串类是不可变的,只是因为它没有任何公共字段、属性或方法允许您修改内部字符串。任何改变字符串的方法都会返回一个新的字符串实例。当然,您也可以通过自己的类来实现这个功能。


是的,我明白这并不是什么特别的事情,但我完全忘记了线程安全。我一直认为可以在没有锁的情况下使用不可变类型,现在我不这么认为了。所以现在我很困惑:我要么一开始就错了,要么现在错了(甚至两者都有可能)。 - n535
当然,您不必保护一个不可变类的对象。没有人可以更改它。虽然这并不是非常实用,但您基本上总是使用过时的数据。 - Hans Passant

1

我能创建一个完全不可变的类型吗?

可以。有一个构造函数来设置私有字段,只有属性而没有方法。

除了性能问题之外,还有其他原因使用这样的代码吗?

一个例子:这种类型不需要锁定就可以安全地从多个并发线程中使用,这使得编写正确的代码更容易(没有锁定错误)。

另外:对于具有足够特权的代码来说,总是可以绕过.NET保护:使用反射读取和写入私有字段,或者使用不安全的代码直接操作对象的内存。

这在.NET之外也是正确的,一个特权进程(即具有进程或线程令牌中的“上帝”权限之一,例如启用所有权)可以打破任何其他进程的限制,加载dll、注入运行任意代码的线程、读取或写入内存(包括覆盖执行预防等)。系统的完整性只有系统所有者的合作才能保证。


谢谢,我没有考虑到锁定机制。 - n535
6
在不安全的代码中,这种类型并非不可变类型。在不安全的代码中,没有任何东西是不可变的;你可以在不安全的代码中写入每个字节的内存。 - Eric Lippert
1
仅具有私有字段并不意味着在非安全代码中某物是不可变的:您仍然可以使用反射... - Timwi
1
@Bryce Wagner:如果你的不可变类型有一个可变属性,那么它就不是一个不可变类型。 - Guffa
1
@Timwi:你总是可以使用反射或不安全的代码来破坏事物。不可变类型的目的在于它们不能被正常代码中的错误所改变。 - Guffa
显示剩余5条评论


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