完整的不变性和面向对象的编程


43

在大多数OOP语言中,对象通常是可变的,只有少数例外(例如,python中的元组和字符串)。在大多数功能语言中,数据是不可变的。

可变对象和不可变对象都带来了它们自己的优点和缺点的完整列表。

有一些语言试图将两个概念结合起来,例如scala,其中您拥有(明确声明的)可变和不可变的数据(如果我错了,请更正我,我对scala的了解远远超过了限制)。

我的问题是:在OOP上下文中,完全(原文如此)不变性(即对象一旦创建就不能变异)是否有意义?

是否有这种模型的设计或实现?

基本上,(完整的)不变性和OOP是对立的还是正交的?

动机:在OOP中,您通常数据进行操作,更改(变异)基础信息,并在这些对象之间保留引用。例如,类的对象Person具有father引用另一个Person对象的成员。如果更改父亲的名字,则该子对象立即可见,无需更新。由于不可变,您将需要为父亲和孩子构造新的对象。但是与共享对象,多线程,GIL等相比,您将减少很多麻烦。


2
通过仅将对象访问点公开为不对数据进行突变的方法或只读属性,可以使用OOP语言模拟不变性。不可变性在OOP语言中的作用与在任何功能性语言中的作用相同,只是您可能会缺少某些功能性语言功能。
罗伯特·哈维

4
可变性不是C#和Java之类的OOP语言的属性,也不是可变性。您可以通过编写类的方式指定可变性或不可变性。
罗伯特·哈维

9
您的推测似乎是可变性是面向对象的核心功能。不是。可变性只是对象或值的属性。面向对象包含许多与变异几乎没有关系的内在概念(封装,多态性,继承等),您仍然可以从这些功能中受益。即使您将所有内容都变得一成不变。
罗伯特·哈维

2
@MichaelT问题不是关于使特定的东西可变,而是关于使所有的东西不变。

Answers:


43

OOP和不变性几乎完全相互正交。但是,命令式编程和不变性并非如此。

OOP可以归纳为两个核心功能:

  • 封装:我不会直接访问对象的内容,而是通过特定的接口(“方法”)与此对象进行通信。此界面可以向我隐藏内部数据。从技术上讲,这特定于模块化编程而不是OOP。通过定义的接口访问数据大致等同于抽象数据类型。

  • 动态调度:当我在对象上调用方法时,执行的方法将在运行时解析。(例如,在基于类的OOP中,我可能会sizeIList实例上调用方法,但该调用可能会解析为LinkedList类中的实现)。动态调度是允许多态行为的一种方法。

没有可变性(没有内部状态可能被外部干预破坏)的封装意义不大,但是即使一切都是不可变的,封装仍然倾向于使抽象变得更容易。

命令性程序由顺序执行的语句组成。语句具有诸如更改程序状态之类的副作用。由于具有不变性,因此无法更改状态(当然,可以创建状态)。因此,命令式编程本质上与不变性不兼容。

现在发生的事情是,OOP在历史上一直与命令式编程联系在一起(Simula基于Algol),并且所有主流的OOP语言都具有命令式的根源(C ++,Java,C#,…都植根于C)。这并不意味着OOP本身将是必要的或可变的,这仅意味着这些语言对OOP的实现允许可变性。


2
非常感谢,特别是对两个核心功能的定义。
Hyperboreus 2014年

动态调度不是OOP的核心功能。封装也不是真的(正如您已经承认的那样)。
2014年

5
@OrangeDog是的,没有普遍接受的OOP定义,但是我需要一个定义才能使用。因此,我选择了与事实相近的东西,而无需撰写整篇论文。但是,我确实将动态调度视为OOP与其他范例的唯一主要区别特征。看起来像OOP但实际上可以静态解决所有调用的东西实际上只是具有即席多态性的模块化编程。对象是一对方法和数据,因此等效于闭包。
阿蒙2014年

2
“对象是方法和数据的配对”。您所需要的只是与不变性无关的东西。
停止Harming Monica 2014年

3
@CodeYogi数据隐藏是最常见的封装类型。但是,对象内部存储数据的方式并不是应该隐藏的唯一实现细节。同样重要的是隐藏公共接口的实现方式,例如,是否使用任何辅助方法。一般来讲,此类辅助方法也应是私有的。综上所述:封装是一种原理,而数据隐藏是一种封装技术。
阿蒙

25

注意,面向对象的程序员之间存在一种文化,人们认为如果您正在执行OOP,则大多数对象都是可变的,但这与OOP是否需要可变性是一个单独的问题。同样,由于人们接触了函数式编程,这种文化似乎正朝着更加不变的方向缓慢变化。

Scala很好地说明了面向对象不需要可变性。虽然Scala 支持可变性,但不鼓励使用它。惯用Scala非常面向对象,并且几乎完全不可变。它主要允许可变性以与Java兼容,并且因为在某些情况下不可变对象效率低下或难以处理。

例如,比较一个Scala列表和一个Java列表。Scala的不可变列表包含与Java可变列表相同的所有对象方法。实际上,更多是因为Java将静态函数用于诸如sort之类的操作,而Scala添加了诸如的函数样式方法map。OOP的所有标志(封装,继承和多态性)都以面向对象的程序员熟悉的形式提供并适当使用。

您将看到的唯一区别是,当您更改列表时,将得到一个新对象。与可变对象相比,这通常需要使用不同的设计模式,但这并不需要您完全放弃OOP。


17

通过仅将对象访问点公开为不对数据进行突变的方法或只读属性,可以使用OOP语言模拟不变性。不可变性在OOP语言中的作用与在任何功能性语言中的作用相同,不同之处在于您可能会缺少某些功能性语言功能。

您的推测似乎是可变性是面向对象的核心功能。但是可变性只是对象或值的属性。面向对象包含许多与变异几乎没有关系的内在概念(封装,多态性,继承等),即使您使所有内容都变得不可变,您仍然会从这些功能中受益。

并非所有功能语言都需要不变性。Clojure具有允许类型可变的特定注释,并且大多数“实用”功能语言都有一种方法来指定可变类型。

一个更好的问题可能是“在命令式编程中完全不变性是否有意义?” 我想说的是,这个问题的明显答案是“否”。为了在命令式编程中实现完全不变性,您将不得不放弃for循环之类的东西(因为您必须对循环变量进行突变),而有利于递归,现在无论如何,您实际上都是在以一种功能性方式进行编程。


谢谢。您能否详细说明一下您的最后一段(“明显”可能有点主观)。
Hyperboreus 2014年

已经做到了……。–
罗伯特·哈维

1
@Hyperboreus有多种方法可以实现多态。动态分配,静态临时多态性(又名函数重载)和参数多态性(又名泛型)的子类型是最常见的实现方式,所有方式都有其优点和缺点。现代的OOP语言将所有这三种方式结合在一起,而Haskell主要依赖于参数多态性和即席多态性。
阿蒙2014年

3
@RobertHarvey您说您需要可变性,因为您需要循环(否则必须使用递归)。在两年前开始使用Haskell之前,我还认为我也需要可变变量。我只是说还有其他“循环”方式(映射,折叠,过滤器等)。一旦退出循环表,为什么还需要可变变量?
cimmanon

1
@RobertHarvey但这恰恰是编程语言的重点:暴露给您的东西,而不是隐藏在后台的东西。后者是编译器或解释器的责任,而不是应用程序开发人员的责任。否则返回汇编器。
Hyperboreus 2014年

5

将对象归类为封装值或实体通常很有用,区别在于,如果某物是一个值,则引用该对象的代码永远不会以其自身未初始化的任何方式看到其状态变化。相比之下,保存对实体的引用的代码可能期望其以超出引用所有者控制的方式进行更改。

尽管可以使用可变或不可变类型的对象使用封装值,但只有满足以下至少一项条件,对象才能充当值:

  1. 对该对象的引用永远不会暴露于任何可能更改封装在其中的状态的对象。

  2. 至少有一个对该对象的引用的持有者知道任何现有引用都可以用于所有用途。

由于不可变类型的所有实例自动满足第一个要求,因此将它们用作值很容易。相比之下,确保使用可变类型时要满足这两个要求要困难得多。可以将对不可变类型的引用作为封装封装在其中的状态的方法自由地传递,而对存储在可变类型中的状态的传递则需要构造不可变的包装对象,或者将私有持有的对象封装的状态复制到其他对象中。由数据的接收者提供或构造。

不可变类型对于传递值非常有效,并且通常至少在某种程度上可用于操纵它们。但是,它们在处理实体方面并不是很好。对于具有纯不可变类型的系统中的实体,最接近实体的功能是一种功能,在给定系统状态的情况下,该功能将报告该系统某些部分的属性,或产生一个新的系统状态实例,例如除了其中的某些特定部分(在某些可选方式上会有所不同)以外,我们提供了其他内容。此外,如果实体的目的是将某些代码与现实世界中存在的某些东西接口,则该实体可能无法避免暴露可变状态。

例如,如果一个人通过TCP连接接收到一些数据,则可以产生一个新的“世界状态”对象,该对象将其数据包括在其缓冲区中,而不会影响对旧“世界状态”的任何引用,但会影响到旧的“世界状态”不包含最后一批数据的世界状态将是有缺陷的,不应使用,因为它们将不再与实际TCP套接字的状态匹配。


4

在C#中,某些类型是不可变的,例如字符串。

这似乎进一步表明该选择已被认真考虑。

当然,如果必须数十万次修改不可变类型,那么对性能的要求确实很高。这就是为什么在这种情况下建议使用StringBuilder类而不是string类的原因。

我已经使用分析器进行了实验,使用不可变类型确实需要更多的CPU和RAM。

如果您只想修改4000个字符的字符串中的一个字母,则必须将每个字符都复制到RAM的另一个区域中,这也是很直观的。


6
频繁修改不可变数据并不需要像重复string级联那样灾难性地慢。对于几乎所有类型的数据/用例,可以(通常已经)发明一种有效的持久性结构。即使恒定因素有时更糟,其中大多数也具有大致相同的性能。

@delnan我也认为答案的最后一段更多地是关于实现细节而不是(im)可变性。
Hyperboreus 2014年

@Hyperboreus:您认为我应该删除该部分吗?但是,如果字符串是不可变的,该如何更改?我的意思是..我认为,但是可以肯定的是,我可能是错的,这可能是对象不是不可变的主要原因。
回顾2014年

1
@Revious绝不。离开它,这样会引起讨论以及更多有趣的观点和观点。
Hyperboreus 2014年

1
@Revious是的,阅读会变慢,尽管不如更改string(传统表示形式)慢。经过1000次修改后的“字符串”(以我正在谈论的表示形式)就像一个新创建的字符串(取模内容);X操作后,没有有用的或广泛使用的持久性数据结构会降低质量。内存碎片并不是一个严重的问题(是的,您会有很多分配,但是碎片在现代垃圾收集器中完全不是问题)

0

在OOP或与此相关的大多数其他范式中,所有事物的完全不变性没有多大意义,原因之一是:

每个有用的程序都有副作用。

一个不会引起任何改变的程序是毫无价值的。您可能甚至都没有运行它,因为效果是相同的。

即使您认为自己没有做任何更改,只是简单地汇总了以某种方式收到的数字列表,也要考虑是否需要对结果进行处理-无论是将其打印到标准输出,将其写入文件,或任何地方 这涉及到突变缓冲区和更改系统状态。

可变性限制为需要更改的部分可能很有意义。但是,如果绝对没有什么需要改变的,那么您就没有做任何值得做的事情。


4
我没有看到纯函数语言,所以我看不到您的答案与问题有什么关系。以erlang为例:不变的数据,没有破坏性的分配,对副作用不屑一顾。同样,您以功能语言使用状态,只是状态“流经”功能,与在状态上运行的功能不同。状态会发生变化,但不会在原地发生变化,但是将来的状态会替换当前的状态。不变性不是关于是否更改了内存缓冲区,而是关于这些突变是否从外部可见。
Hyperboreus 2014年

而未来的状态究竟如何代替当前的状态?在OO程序中,该状态是某处对象的属性。替换状态需要更改一个对象(或用另一个对象替换,这需要更改引用该对象的对象(或用另一个对象替换,这……你明白了))。您可能会想出某种类型的monadic hack,其中每个动作最终都会创建一个全新的应用程序……但是即使如此,程序的当前状态也必须记录在某个地方。
cHao

7
-1。这是不正确的。您将突变与副作用混为一谈,尽管功能语言通常将它们视为相同,但它们却有所不同。每个有用的程序都有副作用。并非每个有用的程序都具有突变。
Michael Shaw 2014年

@Michael:当涉及到面向对象编程时,突变和副作用是如此交织在一起,以致于无法将它们分开。如果您没有突变,那么没有大量黑客攻击就不会有副作用。
cHao 2014年


-2

我认为这取决于您对OOP的定义是否使用消息传递样式。

纯函数不必突变任何东西,因为它们返回的值可以存储在新变量中。

var brandNewVariable = pureFunction(foo);

使用消息传递样式,您告诉对象存储新数据,而不是询问该对象应在新变量中存储什么新数据。

sameOldObject.changeMe(foo);

通过使对象的方法成为纯函数,而这些对象恰好位于对象的内部而不是外部,则可以拥有对象而不对其进行突变。

var brandNewVariable = nonMutatingObject.askMe(foo);

但是不可能将消息传递样式和不可变对象混合在一起。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.