什么是不可变性,为什么我应该担心它?
什么是不可变性,为什么我应该担心它?
我读了几篇关于不可变性的文章,但对这个概念仍然不太理解。\n最近我在这里发了一个帖子,提到了不可变性,但由于这是一个独立的话题,我现在要单独开一个帖子讨论。\n我在之前的帖子中提到,我认为不可变性是使对象只读并给予低可见性的过程。另一个成员说这与那无关。\n这个页面(系列的一部分)使用了一个不可变的类/结构的例子,并使用了readonly和其他概念来锁定它。\n在这个例子中,状态的定义是什么?状态是一个我没有真正理解的概念。\n从设计指南的角度来看,一个不可变的类必须是不接受用户输入的,而只返回值的类?\n我的理解是,任何只返回信息的对象都应该是不可变的和\"锁定的\",对吗?所以,如果我想在一个专用的类中返回当前时间,我应该使用引用类型,因为这样会使用类型的引用,从而获得不可变性的好处。
什么是不可变性,为什么我应该关注它?
简单来说,当内存在初始化后不被修改时,它是不可变的。
使用C、Java和C#等命令式语言编写的程序可以随意操作内存中的数据。一块物理内存被设置好后,可以在程序执行过程中的任何时候被执行线程整体或部分地修改。实际上,命令式语言鼓励这种编程方式。
以这种方式编写程序对于单线程应用程序非常成功。然而,随着现代应用程序开发向单个进程内的多个并发线程的操作方式发展,引入了一系列潜在的问题和复杂性。
当只有一个执行线程时,可以想象这个单个线程“拥有”内存中的所有数据,因此可以随意操作它。然而,当涉及多个执行线程时,没有隐含的所有权概念。
相反,这个责任落在程序员身上,程序员必须极力确保内存结构对所有读者都处于一致的状态。必须谨慎使用锁定构造来防止一个线程在另一个线程更新数据时看到数据。如果没有这种协调,线程必然会使用只完成了一半更新的数据。这种情况的结果是不可预测的,通常是灾难性的。此外,在代码中正确地使用锁定是非常困难的,如果做得不好,可能会降低性能,或者在最糟糕的情况下导致无法恢复的死锁。
使用不可变的数据结构可以减轻在代码中引入复杂锁定的需求。当一个内存区域在程序的生命周期内保证不会改变时,多个读者可以同时访问该内存。它们不可能观察到该特定数据处于不一致状态。
许多函数式编程语言,如Lisp、Haskell、Erlang、F#和Clojure,天生鼓励使用不可变的数据结构。正因为如此,随着我们向越来越复杂的多线程应用程序开发和多计算机体系结构的发展,它们正在重新引起人们的兴趣。
状态
应用程序的状态简单地可以被认为是在某个时间点上所有内存和CPU寄存器的内容。
从逻辑上讲,一个程序的状态可以分为两部分:
1.堆的状态
2.每个执行线程的栈的状态
在C#和Java等托管环境中,一个线程不能访问另一个线程的内存。因此,每个线程“拥有”其栈的状态。栈可以被认为保存值类型(struct)的局部变量和参数,以及对对象的引用。这些值与外部线程隔离。
然而,堆上的数据可以在所有线程之间共享,因此必须谨慎控制并发访问。所有引用类型(class)对象实例都存储在堆上。
在面向对象编程中,一个类的实例的状态由其字段确定。这些字段存储在堆上,因此可以从所有线程访问。如果一个类定义了允许在构造函数完成后修改字段的方法,那么该类是可变的(不是不可变的)。如果字段不能以任何方式改变,那么该类型是不可变的。重要的是要注意,具有所有C# readonly/Java final字段的类不一定是不可变的。这些结构确保引用不能改变,但所引用的对象可以改变。例如,一个字段可能具有对对象列表的不可更改的引用,但列表的实际内容可以随时修改。
通过将类型定义为真正的不可变类型,可以将其状态视为冻结的,因此该类型可以安全地被多个线程访问。
实际上,将所有类型定义为不可变的可能会有些不方便。修改不可变类型上的值可能涉及大量的内存复制。一些语言比其他语言更容易完成这个过程,但无论如何,CPU都将进行额外的工作。许多因素决定了内存复制所花费的时间是否超过了锁定争用的影响。
对不可变数据结构(如列表和树)进行了大量的研究。使用这些结构时,比如列表,"add"操作将返回一个引用,该引用指向添加了新项的新列表。对先前列表的引用不会发生任何变化,并且仍然对数据具有一致的视图。
什么是不可变性,为什么我应该关心它?
不可变性是主要应用于对象(字符串、数组、自定义的Animal类)的。通常,如果一个类有一个不可变的版本,那么也会有一个可变的版本。例如,Objective-C和Cocoa定义了NSString类(不可变)和NSMutableString类。
如果一个对象是不可变的,在创建后就无法更改(基本上是只读的)。可以将其视为“只有构造函数可以更改对象”。这与用户输入没有直接关系,即使是您的代码也无法更改不可变对象的值。但是,您始终可以创建一个新的不可变对象来替换它。以下是一个伪代码示例;请注意,在许多编程语言中,您可以简单地使用myString = "hello";
而不是使用下面的构造函数,但为了清晰起见,我还是将其包含在内:
String myString = new ImmutableString("hello"); myString.appendString(" world"); // 不能这样做 myString.setValue("hello world"); // 不能这样做 myString = new ImmutableString("hello world"); // 可以
您提到了“只返回信息的对象”;这并不自动使它成为不可变性的良好候选对象。不可变对象倾向于始终返回构造时的相同值,因此我倾向于认为当前时间并不理想,因为它经常变化。但是,您可以创建一个MomentOfTime类,该类使用特定的时间戳创建,并始终返回未来的那个时间戳。
不可变性的好处:
- 如果将一个对象传递给另一个函数/方法,在该函数返回后,您不必担心该对象的值是否仍然相同。例如:
String myString = "HeLLo WoRLd"; String lowercasedString = lowercase(myString); print myString + " was converted to " + lowercasedString;
如果lowercase()
的实现在创建小写版本时更改了myString,那么第三行就无法给出您想要的结果。当然,一个好的lowercase()
函数不会这样做,但是如果myString是不可变的,则可以保证这一点。因此,不可变对象可以帮助执行良好的面向对象编程实践。
- 更容易使不可变对象线程安全。
- 可以简化类的实现(如果您是编写该类的人)。
状态:
如果您将对象的所有实例变量的值写在纸上,那就是该对象在给定时刻的状态。程序的状态是其所有对象在给定时刻的状态。状态会随时间快速变化;程序需要改变状态才能继续运行。
然而,不可变对象的状态随时间固定。创建后,不可变对象的状态不会改变,尽管整个程序的状态可能会发生变化。这使得跟踪发生的事情变得更容易(并且可以看到上述其他好处)。
关于不可变性的例子:
- 使用const关键字可以以类似的方式工作,但是我认为const是由编译器强制执行的,而不可变性是由契约强制执行的(也就是说,开发人员需要确保它正确实现)。
- 除了面向对象编程之外,您能否给出不可变性的其他例子?在函数式编程语言Clojure中,我做了一个例子。
- 一个经常被忽视的问题是一个类类型的存储位置可以封装其目标的标识、目标的可变状态、两者或者都不封装。一个
List
将封装其中包含的对象的标识,但不封装它们的状态。如果使用
List
来封装对象的可变状态,并且希望避免这些状态意外更改,那么必须限制将该
List
暴露给任何可能尝试更改其中对象的东西。
- 不可变对象的好处似乎没有解释为什么它是某些数据类型的“默认”选项。在您的第二个要点中,为什么Cocoa不使用类“NSSstring”和“NSImmutableString”?不可变版本真的更常见吗?