59

我已经阅读了几篇关于不变性的文章,但仍然没有很好地遵循这个概念。

我最近在这里发了一个帖子,其中提到了不变性,但是由于这本身就是一个主题,所以我现在正在制作一个专门的帖子。

我在过去的帖子中提到,我认为不变性是使对象只读并赋予其低可见性的过程。另一位成员说这与那件事没有任何关系。此页面系列的一部分)使用不可变类/结构的示例,并使用只读和其他概念将其锁定。

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

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

我的理解是,任何只返回信息的对象都应该是不可变的并且“锁定”,对吗?因此,如果我想使用该方法在专用类中返回当前时间,我应该使用引用类型,因为它可以作为类型的引用,因此我可以从不变性中受益。

4

15 回答 15

58

什么是不变性?

  • 不变性主要应用于对象(字符串、数组、自定义 Animal 类)
  • 通常,如果有一个类的不可变版本,那么一个可变版本也是可用的。例如,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 类,并在未来始终返回该时间戳。

不变性的好处

  • 如果将对象传递给另一个函数/方法,则不必担心该对象在函数返回后是否具有相同的值。例如:

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

    如果在lowercase()创建小写版本时更改了 myString 的实现怎么办?第三行不会给你想要的结果。当然,一个好的lowercase()函数不会这样做,但是如果 myString 是不可变的,你就可以保证这个事实。因此,不可变对象可以帮助实施良好的面向对象编程实践。

  • 使不可变对象成为线程安全的更容易

  • 它可能会简化类的实现(如果您是编写类的人,那就太好了)

状态

如果您要获取一个对象的所有实例变量并在纸上写下它们的值,那将是该对象在给定时刻的状态。程序的状态是它所有对象在给定时刻的状态。状态随时间迅速变化;程序需要更改状态才能继续运行。

然而,不可变对象随着时间的推移具有固定的状态。一旦创建,不可变对象的状态就不会改变,尽管整个程序的状态可能会改变。这样可以更轻松地跟踪正在发生的事情(并查看上面的其他好处)。

于 2009-03-08T00:00:35.990 回答
23

不变性

简而言之,内存在初始化后不被修改时是不可变的。

用 C、Java 和 C# 等命令式语言编写的程序可以随意操作内存中的数据。物理内存区域一旦被留出,就可以在程序执行期间的任何时间由执行线程全部或部分修改。事实上,命令式语言鼓励这种编程方式。

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

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

相反,这个负担落在了程序员身上,他们必须竭尽全力确保内存中的结构对于所有读者来说都处于一致的状态。必须谨慎使用锁定结构,以防止一个线程在另一个线程更新数据时查看数据。如果没有这种协调,线程将不可避免地消耗刚刚更新到一半的数据。这种情况的结果是不可预测的,而且往往是灾难性的。此外,在代码中使锁定正确工作是出了名的困难,如果做得不好会削弱性能,或者在最坏的情况下,会出现死锁,无法恢复地停止执行。

使用不可变数据结构减轻了在代码中引入复杂锁定的需要。如果保证一段内存在程序的生命周期内不会发生变化,那么多个读取器可以同时访问该内存。他们不可能观察到处于不一致状态的特定数据。

许多函数式编程语言,例如 Lisp、Haskell、Erlang、F# 和 Clojure,就其本质而言鼓励不可变数据结构。正是由于这个原因,当我们转向日益复杂的多线程应用程序开发和多计算机计算机体系结构时,他们重新获得了兴趣。

状态

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

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

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

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

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

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

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

在实践中,将所有类型定义为不可变可能很不方便。修改不可变类型的值可能涉及相当多的内存复制。某些语言比其他语言使这个过程更容易,但无论哪种方式,CPU 最终都会做一些额外的工作。许多因素有助于确定复制内存所花费的时间是否超过锁定争用的影响。

很多研究都涉及到不可变数据结构的开发,例如列表和树。当使用这样的结构时,比如说一个列表,“添加”操作将返回一个对添加了新项目的新列表的引用。对上一个列表的引用没有看到任何变化,并且仍然具有一致的数据视图。

于 2009-03-08T00:11:49.490 回答
9

简单来说:一旦创建了不可变对象,就无法更改该对象的内容。.Net 不可变对象的示例是 String 和 Uri。

当你修改一个字符串时,你只是得到一个新的字符串。原始字符串不会改变。Uri 只有只读属性,没有可用于更改 Uri 内容的方法。

不可变对象很重要的情况多种多样,并且在大多数情况下都与安全性有关。Uri 就是一个很好的例子。(例如,您不希望 Uri 被一些不受信任的代码更改。)这意味着您可以传递对不可变对象的引用,而不必担心内容会改变。

希望这可以帮助。

于 2009-03-07T23:02:56.737 回答
7

不变的东西永远不会改变。可变的东西可以改变。可变的东西会发生变异。不可变的事物看似改变,但实际上创造了一个新的可变事物。

例如,这是 Clojure 中的地图

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

第一行创建了一个新的不可变 Clojure 映射。第二行将 3 和“3”连接到地图。这看起来好像是在修改旧地图,但实际上它返回的是添加了 3 个“3”的新地图。这是不变性的一个典型例子。如果这是一个可变映射,它会简单地将 3“3”直接添加同一个旧映射。第三行打印地图

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

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

于 2009-03-07T23:11:28.557 回答
3

好问题。

多线程。如果所有类型都是不可变的,则不存在竞争条件,并且您可以安全地在代码中抛出任意数量的线程。

显然,如果没有可变性保存复杂的计算,您将无法完成那么多,因此您通常需要一些可变性来创建功能性业务软件。然而,值得认识到不变性应该存在于何处,例如任何事务性。

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

不变性还减少了程序中的可能性,从而降低了潜在的复杂性和潜在的错误。

于 2009-03-07T23:22:59.717 回答
2

不可变对象是您可以放心假设不会改变的东西。它具有重要的属性,每个使用它的人都可以假设他们看到的是相同的值。

不变性通常还意味着您可以将对象视为“值”,并且对象的相同副本与对象本身之间没有有效的区别。

于 2009-03-07T23:03:32.850 回答
2

让我再补充一件事。除了上面提到的所有内容之外,您还希望以下内容具有不变性:

于 2009-03-07T23:49:19.200 回答
1

使事物不可变可以防止大量常见错误。

例如,学生永远不应该让他们的学生#改变他们。如果您不提供设置变量的方法(并将其设为 const 或 final 或您的语言支持的任何内容),那么您可以在编译时强制执行。

如果事情是可变的并且您不希望它们在您传递它们时改变,您需要制作一个您传递的防御性副本。然后,如果您调用的方法/功能更改了项目的副本,则原始项目不会受到影响。

使事物不可变意味着您不必记住(或花时间/记忆)来制作防御性副本。

如果你真的努力工作,并考虑你所做的每个变量,你会发现绝大多数变量(我通常有 90-95%)一旦被赋予一个值就永远不会改变。这样做可以使程序更易于遵循并减少错误的数量。

要回答关于状态的问题,状态是“对象”(是类或结构)的变量所具有的值。如果您将一个人的“对象”状态设为眼睛颜色,头发颜色,头发长度等……其中一些(例如眼睛颜色)不会改变,而其他一些(例如头发长度)会改变。

于 2009-03-07T23:12:55.123 回答
1

“……我何必担心呢?”

一个实际的例子是字符串的重复连接。例如在 .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 如此重要。

于 2009-03-08T03:19:40.817 回答
1

你不能改变一个不可变的对象,因此你必须替换它......“改变它”。即更换然后丢弃。从这个意义上说,“替换”意味着将指针从一个内存位置(旧值)更改为另一个(新值)。

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

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

然后自己想,“但我正在改变它,看看那里,黑白的!mystring输出'新值'......我以为你说我不能改变它?!!”

但实际上在幕后,发生的是这种新内存的分配,即 mystring 现在指向不同的内存地址和空间。从这个意义上说,“不可变”不是指 mystring 的值,而是指变量 mystring 用来存储其值的内存。

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

真正让 re:memory 使用量大增的地方之一是在高度迭代的循环中,特别是在 Ashs 的帖子中使用字符串。假设您在一个迭代循环中构建一个 HTML 页面,您不断地将下一个 HTML 块附加到最后一个块,并且只是为了好玩,您是在大容量服务器上执行此操作的。如果“旧值内存”没有正确清理,这种不断分配的“新值内存”很快就会变得昂贵,最终是致命的。

另一个问题是,有些人认为垃圾收集 (GC) 之类的事情会立即发生。但事实并非如此。发生了各种优化,以便将垃圾收集设置为在更多空闲期间发生。因此,在内存被标记为已丢弃和垃圾收集器实际释放内存之间可能会有很大的延迟......因此,如果您只是将问题推迟到 GC,您可能会遭受较大的内存使用高峰。

如果 GC 在内存不足之前没有机会运行,那么事情不一定会像其他没有自动垃圾收集的语言那样崩溃。相反,GC 将作为最高优先级的进程启动以释放丢弃的内存,无论时机多么糟糕,并在清理时成为阻塞进程。显然,这并不酷。

因此,基本上,您需要在编写代码时牢记这些事情,并查看您使用的语言的文档,以获得可以避免/减轻这种风险的最佳实践/模式。

正如在 Ashs 的帖子中,在 .Net 和字符串中,在需要不断更改字符串值时,推荐的做法是使用可变 StringBuilder 类,而不是不可变字符串类。

其他语言/类型也会有自己的解决方法。

于 2009-03-08T04:48:34.987 回答
1

为什么是不变性?

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

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

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

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

  5. 不变性使编写、使用和推理代码变得更容易(类不变式建立一次,然后不变)。

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

  7. 即使您有异常,程序的内部状态也会保持一致。

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

有关更详细的答案,请参阅我的博客:http:
//javaexplorer03.blogspot.in/2015/07/minimize-mutability.html

于 2016-12-24T04:04:25.333 回答
0

看,我还没有阅读您发布的链接。

但是,这是我的理解。
每个程序都拥有一些关于它的数据(状态)的知识,这些知识可以通过用户输入/外部更改等进行更改。

变量(变化的值)被保留以保持状态。不可变意味着一些不会改变的数据。你可以说,它在某种程度上与 readonly 或 constant 相同(可以这样看)。

AFAIK,函数式编程具有不可变的东西(即,您不能对保存该值的变量使用赋值。您可以做的是创建另一个可以保存原始值+更改的变量)。

.net 有字符串类,这是一个例子。
即你不能在它的位置修改字符串

字符串 s = "你好"; 我可以写 s.Replace("el", "a"); 但这不会修改变量 s 的内容。

我能做的是 s = s.Replace("el","a");
这将创建一个新变量并将其值分配给 s(覆盖 s 的内容)。

在我的理解中,如果我有错误,专家可以纠正错误。

编辑:不可变 = 不可分配,一旦它持有一些价值并且不能被替换(也许?)

于 2009-03-07T23:06:34.607 回答
0

WPF API 中提供了不可变对象提供的潜在性能优势的示例。许多 WPF 类型的通用基类是Freezable.

几个 WPF 示例表明冻结对象(使它们在运行时不可变)可以显着提高应用程序性能,因为不需要锁定和复制。

就我个人而言,我希望不变性的概念更容易用我最常用的语言 C# 来表达。有一个readonly可用于字段的修饰符。我还希望看到readonly类型上的修饰符,该修饰符仅允许用于仅具有只读字段的只读类型的类型。本质上,这意味着所有状态都需要在构建时注入,并且整个对象图都将被冻结。我想这是 CLR 固有的元数据,那么它可以很容易地用于优化 GC 的垃圾分析。

于 2009-03-08T14:06:06.363 回答
0

对不起,为什么不变性会阻止竞争条件(在这个例子中,读后写危险)?

shared v = Integer(3)
v = Integer(v.value() + 1) # in parallel
于 2009-04-09T08:33:25.563 回答
0

不变性与价值观有关,而价值观与事实有关。如果某物是不可改变的,那么它就有价值,因为如果某物是可以改变的,那么就意味着没有特定的价值可以与它联系起来。对象以状态 A 初始化,并在程序执行期间突变为状态 B 和状态 C。这意味着对象不代表单个特定值,而只是一个容器,对内存中某个位置的抽象,仅此而已。你不能信任这样的容器,你不能相信这个容器有你认为应该有的价值。

让我们举个例子 - 让我们想象在代码中创建了 Book 类的实例。

Book bookPotter =  new Book();
bookPotter.setAuthor('J.K Rowling');
bookPotter.setTitle('Harry Potter');

这个实例有一些字段设置,如作者和标题。一切正常,但在代码的某些部分再次使用了设置器。

Book bookLor =  bookPotter; // only reference pass
bookLor.setAuthor('J.R.R Tolkien');
bookLor.setTitle('Lords of The Rings');

不要被不同的变量名所欺骗,真的是同一个实例。该代码再次在同一实例上使用设置器。这意味着 bookPotter 从来都不是真正的哈利波特书,bookPotter 只是指向未知书所在位置的指针。也就是说,它看起来更像是一个书架而不是书。你对这样的对象有什么信任?它是哈利波特的书还是 LoR 的书,或者两者都不是?

类的可变实例只是一个指向具有类特征的未知状态的指针。

那么如何避免突变呢?规则很简单:

  • 通过构造函数或生成器构造具有所需状态的对象
  • 不要为对象的封装状态创建设置器
  • 不要在其任何方法中更改对象的任何封装状态

这几个规则将允许拥有更可预测和更可靠的对象。回到我们的示例并遵循上述规则:

Book bookPotter =  new Book('J.K Rowling', 'Harry Potter');
Book bookLor = new Book('J.R.R Tolkien', 'Lord of The Rings');

一切都是在构造阶段设置的,在这种情况下是构造函数,但对于更大的结构,它可以是构造函数。对象中不存在 setter,book 不能变异为不同的 setter。在这种情况下 bookPotter 代表了哈利波特书的价值,您可以确定这是不可改变的事实。

如果您对更广泛的不变性感兴趣,在这篇中等文章中更多的是关于与 JavaScript 相关的主题 - https://medium.com/@macsikora/the-state-of-immutability-169d2cd11310

于 2018-01-01T21:38:57.193 回答