什么是不可变性,为什么我应该关注它?

63

我已经读了几篇关于不变性的文章,但仍然无法很好地理解这个概念。

最近我在这里发了一个帖子提到了不变性,但由于这本身是一个话题,所以我现在要开一个专门的帖子。

我在之前的帖子中提到,我认为不变性是使对象只读并降低其可见性的过程。另一位成员说它并没有真正与此有关系。这个页面系列的一部分)使用了一个不可变的类/结构体的例子,并使用了readonly和其他概念来锁定它。

在这个例子中,“状态”究竟是什么定义?状态是一个我还没有真正掌握的概念。

从设计指南的角度来看,一个不可变的类必须是不接受用户输入的,而且只会返回值的类?

我的理解是,任何只返回信息的对象都应该是不可变的和“锁定”的,对吗?因此,如果我想在一个专用类中返回当前时间,我应该使用引用类型,因为这将起作用类型的引用,从而使我受益于不变性。

15个回答

61

什么是不可变性?

  • 不可变性主要应用于对象(字符串,数组,自定义动物类)
  • 通常,如果一个类有一个不可变版本,则可变版本也可用。例如,Objective-C和Cocoa定义了NSString类(不可变)和NSMutableString类。
  • 如果一个对象是不可变的,在创建后就不能被更改(基本上是只读的)。您可以将其视为“只有构造函数可以更改对象”。

这与用户输入没有直接关系;甚至您的代码也无法更改不可变对象的值。但是,您始终可以创建一个新的不可变对象来替换它。以下是一个伪代码示例;请注意,在许多语言中,您可以简单地执行myString =“hello”;而不是使用下面的构造函数,但是我包含它以增加清晰度:

String myString = new ImmutableString("hello");
myString.appendString(" world"); // Can't do this
myString.setValue("hello world"); // Can't do this
myString = new ImmutableString("hello world"); // OK

你提到了“只返回信息的对象”,但这并不自动使它成为不可变性的良好候选者。不可变对象往往会始终返回与其构造时相同的值,因此我倾向于说当前时间并不理想,因为它经常变化。然而,你可以有一个MomentOfTime类,它是用特定的时间戳创建的,并且总是在未来返回那个时间戳。
不可变性的好处:
  • If you pass an object to another function/method, you shouldn't have to worry about whether that object will have the same value after the function returns. For instance:

    String myString = "HeLLo WoRLd";
    String lowercasedString = lowercase(myString);
    print myString + " was converted to " + lowercasedString;
    

    What if the implementation of lowercase() changed myString as it was creating a lowercase version? The third line wouldn't give you the result you wanted. Of course, a good lowercase() function wouldn't do this, but you're guaranteed this fact if myString is immutable. As such, immutable objects can help enforce good object-oriented programming practices.

  • It's easier to make an immutable object thread-safe

  • It potentially simplifies the implementation of the class (nice if you're the one writing the class)

状态

如果你将一个对象的所有实例变量的值写下来,那就是该对象在某一时刻的状态。程序的状态是其所有对象在某一时刻的状态。状态随时间迅速变化,为了保持运行,程序需要改变状态。

然而,不可变对象在时间上具有固定的状态。一旦创建,不可变对象的状态不会改变,尽管整个程序的状态可能会改变。这使得跟踪发生的事情更容易(并且带来其他好处)。


1
如果你不是只针对面向对象编程,我会投赞成票的。 - Rayne
@Rayne - 你能否举一个除了OOP以外的不可变性(immutability)例子?const关键字可以用类似的方式工作;但是我认为const是由编译器强制执行的,而不可变性是由契约强制执行的(也就是说,开发人员需要确保其实现正确)。 - PCheese
@PCheese 请查看下面我的帖子,在一个叫做Clojure的函数式编程语言中,我举了一个例子。 - Rayne
我认为经常被忽略的一个问题是,类类型的存储位置有可能封装其目标的标识,可变方面的状态,两者都封装,或者都不封装。List<SomeClassType> 将本身封装所包含对象的标识,但不会封装它们的状态。如果使用 List<T> 来封装其中对象的可变状态,并且希望避免这些状态意外更改,必须限制将该 List<T> 暴露给任何可能试图更改其中对象状态的东西。 - supercat
"当前时间...经常在变化。" 说得很微妙,这让我微笑了。 - jinglesthula
似乎没有任何模糊的优点能够解释为什么某些数据类型的默认值是不可变的。在您的第二个要点中,为什么Cocoa没有使用“NSSstring”和“NSImmutableString”这样的类呢?难道不可变版本真的更常见吗? - Morgan Rogers

24

不可变性

简单来说,一旦初始化后,在内存中没有被修改的内容就是不可变的。

使用C、Java和C#等命令式语言编写的程序可能会随意操作内存数据。一块物理内存一旦被设置,可以在程序执行期间任何时刻被执行线程整体或部分修改。事实上,命令式语言鼓励这种编程方式。

以这种方式编写程序对于单线程应用非常成功。然而,随着现代应用程序开发向单个进程内的多个并发线程移动,引入了潜在的问题和复杂性。

当只有一个执行线程时,您可以想象这个单个线程“拥有”内存中的所有数据,因此可以随意操作它。但是,当涉及多个执行线程时,不存在隐含的所有权概念。

相反,这个负担落在程序员身上,必须非常努力地确保内存结构对于所有读者都处于一致状态。锁定结构必须小心使用,以防止一个线程在另一个线程更新数据时看到数据。如果没有这种协调,线程将不可避免地消耗正在更新过程中的数据。这种情况的结果是不可预测的,通常是灾难性的。此外,在代码中正确使用锁定结构非常困难。如果做得不好,会瘫痪性能或在最坏的情况下导致死锁,无法恢复执行。

使用不可变数据结构可以减轻在代码中引入复杂锁定的需求。当保证程序生命周期内的某个内存段不会更改时,多个读者可以同时访问该内存。他们无法观察到该特定数据处于不一致状态。

许多函数式编程语言,如Lisp、Haskell、Erlang、F#和Clojure,通过其本质鼓励使用不可变数据结构。正是由于这个原因,随着我们向越来越复杂的多线程应用程序开发和多计算机架构移动,它们正在重新引起人们的兴趣。

状态

应用程序的状态可以简单地视为给定时间点上所有内存和CPU寄存器的内容。

从逻辑上讲,程序的状态可以分为两种:

  1. 堆的状态
  2. 每个执行线程的栈的状态

在像C#和Java这样的托管环境中,一个线程不能访问另一个线程的内存。因此,每个线程都“拥有”其堆栈的状态。堆栈可以被视为保存值类型(struct)的局部变量和参数以及对象引用的位置。这些值与外部线程隔离。

然而,堆上的数据可以在所有线程之间共享,因此必须小心控制并发访问。所有引用类型(class)对象实例都存储在堆上。

在面向对象编程中,类的实例状态由其字段确定。这些字段存储在堆上,因此可以从所有线程访问。如果类定义了允许在构造函数完成后修改字段的方法,则该类是可变的(不是不可变的)。如果字段无法以任何方式更改,则该类型是不可变的。重要的是要注意,具有所有C#readonly/Java final字段的类不一定是不可变的。这些结构确保引用不能更改,但是所引用的对象可能会随时更改。例如,一个字段可能具有指向对象列表的不可更改引用,但是列表的实际内容可以随时修改。

通过将类型定义为真正不可变,可以将其状态视为已冻结,因此该类型对于多个线程的访问是安全的。

实际上,定义所有类型为不可变可能会不方便。修改不可变类型的值可能涉及相当多的内存复制。有些语言使这个过程更容易,但无论如何,CPU最终都需要做一些额外的工作。许多因素有助于确定花费在复制内存的时间是否超过锁定争用所带来的影响。

很多研究都投入到不可变数据结构(例如列表和树)的开发中。当使用这样的结构(比如列表)时,“添加”操作会返回一个新的列表引用,其中包含了新添加的项。对先前列表的引用没有任何更改,仍然具有一致的数据视图。


9
简言之:一旦您创建了不可变对象,就无法更改该对象的内容。.Net不可变对象的示例包括String和Uri。
当您修改字符串时,只需获得一个新字符串。原始字符串不会更改。Uri仅具有只读属性,没有可用于更改Uri内容的方法。
不可变对象很重要的案例有很多,大多数情况与安全有关。Uri在这里是一个很好的例子。(例如,您不希望某些不受信任的代码更改Uri。)这意味着您可以传递对不可变对象的引用,而不必担心内容将发生更改。
希望这能帮到您。

7

不可变的事物永远不会改变。可变的事物可以改变。可变的事物会发生变异。不可变的事物看起来像是在改变,但实际上是创建了一个新的可变的事物。

例如,这里有一个Clojure中的地图:

(def imap {1 "1" 2 "2"})
(conj imap [3 "3"])
(println imap)

第一行创建了一个新的不可变的Clojure map。第二行将3和"3"添加到map中。这可能看起来像是修改旧map,但实际上它返回了一个新的map,其中包含添加了3 "3"的内容。这是不可变性的一个典型例子。如果这是一个可变的map,它会直接将3 "3" 添加到 同一个旧map中。第三行打印出map。

{3 "3", 1 "1", 2 "2"}

不可变性有助于保持代码的清洁和安全。正因为这个原因,函数式编程语言倾向于不可变性和少状态性。


如果 conj 正在创建一个新的 map,为什么这个 map 没有新的名称?换句话说:我如何访问 imap 的旧值?如果不能访问旧值,那么显然 conj 正在修改该 map。 - Iraimbilanja
如果您不使用imaps的旧值,Clojure会自动将其垃圾回收... 您可以通过再次使用它来保留imaps的旧值,例如将其分配给一个变量。 - Rayne
我真的不太理解,但是我也不懂Clojure,所以... :) - Iraimbilanja
@iraimbilanja,我用Clojure解释不可变性的方式与面向对象编程是相同的概念,但表述方式似乎有些奇怪。在函数式编程语言中,不可变性真正发挥了作用。 - Rayne
等一下...明白了...所以imap的值是不可变的,但imap本身是可变的,也就是说它可以指向不同的对象。所以它不是一个纯函数式的东西,我明白了。 - Iraimbilanja
def 创建一个新的Clojure变量。该变量可以更改以引用不同的值。Clojure不是纯函数式的,但它偏爱不可变的方法。有refs和atoms来具有实际的可变状态。 - Rayne

3

为什么要使用不可变性?

  1. 它们更不容易出错,更安全。

  2. 不可变类比可变类更容易设计、实现和使用。

  3. 不可变对象是线程安全的,因此不存在同步问题。

  4. 不可变对象是良好的 Map 键和 Set 元素,因为这些通常在创建后不会更改。

  5. 不可变性使得编写、使用和理解代码更加容易(类不变式一旦建立就不会改变)。

  6. 不可变性使得程序并行化更容易,因为对象之间没有冲突。

  7. 即使出现异常,程序的内部状态也将保持一致。

  8. 对不可变对象的引用可以被缓存,因为它们不会改变。(例如,在哈希中提供快速操作)。

请参阅我的博客以获取更详细的答案。


3

好问题。

多线程。如果所有类型都是不可变的,那么竞态条件就不存在了,你可以安全地向代码中添加任意数量的线程。

显然,在没有可变性的情况下,除了进行复杂计算外,你无法完成太多工作,因此通常需要一些可变性来创建功能性商业软件。然而,值得注意的是,事务性操作应该保持不变性。

查找函数式编程和纯度概念以获取更多有关哲学的信息。你将越多地存储在调用堆栈中(传递给方法的参数),而不是通过引用(如集合或静态可用对象)使它们可用,你的程序就越纯粹,且越不容易出现竞争条件。随着现今更多的多核处理器,这个话题变得更加重要。

此外,不可变性减少了程序中的可能性,降低了潜在的复杂性和错误率。


2
"

...我为什么要担心它呢?

一个实际的例子是字符串的重复连接。例如,在.NET中:

"
string SlowStringAppend(string [] files)
{
    // Declare an string
    string result="";

    for (int i=0;i<files.length;i++)
    {
        // result is a completely new string equal to itself plus the content of the new
        // file
        result = result + File.ReadAllText(files[i]);
    }

    return result;
}    

string EfficientStringAppend(string [] files)
{
    // Stringbuilder manages a internal data buffer that will only be expanded when absolutely necessary
    StringBuilder result=new SringBuilder();

    for (int i=0;i<files.length;i++)
    {
        // The pre-allocated buffer (result) is appended to with the new string 
        // and only expands when necessary.  It doubles in size each expansion
        // so need for allocations become less common as it grows in size. 
        result.Append(File.ReadAllText(files[i]));
    }

    return result.ToString();
}

不幸的是,使用第一种(慢)函数方法仍然很常见。对于不可变性的理解使得使用StringBuilder非常重要。


2

不可变对象是指你可以安全地假设它不会改变的对象;它有一个重要的属性,那就是每个使用它的人都可以假设他们看到的是相同的值。

不可变性通常也意味着你可以把这个对象看作一个“值”,而且相同的对象副本和对象本身之间没有实质性的区别。


2

还有一件事情需要补充,除了以上提到的所有内容,您还希望以下对象具有不变性:


对于值对象来说,+1。在很多其他情况下,不可变性可能有意义,也可能没有意义,这取决于你如何使用对象。但我认为,将值对象设为不可变几乎总是有意义的。 - AwesomeTown
这个回答如何回答问题? - Rayne
抱歉打扰了 - 但在内存层面上的不可变性对实体也是有好处的。如果您想更改一个值,可以在持久化层面上进行更改,然后重新加载它。不可变性的主要好处之一是它使您构建的程序不依赖于对象的内部状态才能正确工作。 - Mathieson

2

你不能改变一个不可变对象,因此你必须替换它... "去改变它"。也就是说,先替换再丢弃。在这个意义上,“替换”意味着将指针从一个内存位置(旧值)更改为另一个内存位置(新值)。

请注意,在这样做时,我们现在使用了额外的内存。一些用于旧值,一些用于新值。还要注意,有些人会因为看到代码而感到困惑,比如:

string mystring = "inital value";
mystring = "new value";
System.Console.WriteLine(mystring); // Outputs "new value";

并且他们自己想,“但是我正在改变它,在那里看,黑白分明! mystring输出'new value'......我以为你说我不能更改它?!!"

但实际上,在幕后发生的是这种新内存的分配,即mystring现在指向不同的内存地址和空间。在这种情况下,“不可变”并不是指mystring的值,而是变量mystring用于存储其值的内存。

在某些语言中,存储旧值的内存必须手动清理,即程序员必须显式释放它......并记得这样做。在其他语言中,这是语言的自动功能,例如.Net中的垃圾收集。

其中一个使内存使用率爆炸的地方是高度迭代循环,特别是在像Ashs的帖子中使用字符串的情况下。假设您正在通过迭代循环构建HTML页面,其中您不断将下一个HTML块附加到最后,并且仅出于乐趣,您正在高容量服务器上执行此操作。这种“新值内存”的不断分配可能会很快变得昂贵,并且如果没有正确清除“旧值内存”,则可能最终导致致命错误。

另一个问题是有些人认为像垃圾回收(GC)这样的事情会立即发生。但实际上并不是这样的。有各种优化措施,使得垃圾回收在更空闲的时期进行。因此,在内存被标记为丢弃和实际被垃圾收集器释放之间可能会有显着的延迟...如果你只是把问题推迟到GC,那么你可能会遭受大量的内存使用峰值。
如果GC没有机会在你耗尽内存之前运行,那么事情不会像其他没有自动垃圾回收的语言那样崩溃。相反,GC将作为最高优先级的进程启动,以释放已丢弃的内存,无论时机如何,并成为阻塞进程,同时清理事物。显然,这不太好。
因此,基本上,你需要考虑这些问题,并查看你正在使用的语言的文档,以了解避免/减轻这种风险的最佳实践/模式。
就像Ashs的帖子中所述,在.Net和字符串方面,当需要不断更改字符串的值时,建议使用可变的StringBuilder类,而不是不可变的字符串类。
其他语言/类型也将有其自己的解决方法。

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