如何确定数据对象类型是否应设计为不可变的?


23

我喜欢不可变的“模式”,因为它的优点,在过去,我发现设计具有不可变数据类型(某些,大多数甚至全部)的系统是有益的。通常,这样做的时候,我发现自己编写的bug更少,调试起来也容易得多。

但是,我的同龄人通常会避开一成不变。它们一点也不缺乏经验(远非如此),但是它们以经典的方式编写数据对象-私有成员,每个成员都有一个getter和setter。然后通常他们的构造函数不带参数,或者为了方便起见可以带一些参数。很多时候,创建对象是这样的:

Foo a = new Foo();
a.setProperty1("asdf");
a.setProperty2("bcde");

也许他们到处都这样做。也许他们甚至都没有定义使用这两个字符串的构造函数,无论它们多么重要。也许他们以后不再更改这些字符串的值,并且永远不需要更改。显然,如果这些事情是对的,那么将对象更好地设计为不可变的,对吗?(构造函数具有两个属性,根本没有二传手)。

您如何确定对象类型是否应设计为不可变的?有很好的判断标准吗?

我目前正在讨论是否将自己项目中的某些数据类型切换为不可变,但我必须将其证明给同龄人,并且这些类型中的数据可能会(很少)更改-那时您当然可以更改这是一成不变的方法(创建一个新方法,从旧对象复制属性,但要更改的属性除外)。但是我不确定这仅仅是我对不可变事物的热爱,还是对它们的真正需求。


1
用Erlang编程,解决了整个问题,一切都是不可变的
Zachary K

1
@ZacharyK我实际上是想提一些关于函数式编程的内容,但是由于我在函数式编程语言方面的经验有限而受到限制。
Ricket 2012年

Answers:


27

似乎您正在向后退。一个应该默认为不可变的。仅在绝对必须/不能完全使它作为不可变对象工作时,才使该对象可变。


11

不变对象的主要好处是保证线程安全。在以多个核心和线程为标准的世界中,这种好处变得非常重要。

但是使用可变对象非常方便。它们的性能很好,并且只要您不从单独的线程修改它们,并且您对自己的工作有很好的了解,它们就会非常可靠。


15
不变对象的好处是它们更容易推理。在多线程环境中,这非常容易。在普通的单线程算法中,它仍然更容易。例如,您永远不必关心状态是否一致,对象是否通过了在特定上下文中需要使用的所有突变,等等。最佳可变对象也使您几乎不必关心它们的确切状态:例如缓存。
9000

-1,我同意@ 9000。线程安全是次要的(考虑对象,这些对象以不可变的形式出现,但由于例如记忆而具有内部可变状态)。另外,提高可变性能是过早的优化,如果您要求用户“知道他们在做什么”,则有可能捍卫一切。如果我一直都知道自己在做什么,那我就永远不会写出带有错误的程序。
2014年

4
@Doval:没有什么可以不同意的。9000是绝对正确的;不可变对象更容易的原因有关。这部分是使它们对并发编程如此有用的原因。另一部分是您不必担心状态变化。这里的过早优化是无关紧要的。不可变对象的使用只涉及设计,而不是性能,而对数据结构的不良选择是过早的悲观化。
罗伯特·哈维

@RobertHarvey我不确定“预先选择不好的数据结构”是什么意思。大多数可变数据结构都有不可变的版本,可提供类似的性能。如果您需要一个列表,并且可以选择使用不可变列表和可变列表,则可以选择不可变列表,直到您确定它是应用程序的瓶颈,并且可变版本会更好。
2014年

2
@Doval:我读过冈崎。除非您使用完全支持功能范式的语言(如Haskell或Lisp),否则通常不会使用这些数据结构。我反对不变结构是默认选择的观点。绝大多数商业计算系统仍然围绕可变结构(即关系数据库)进行设计。从不可变的数据结构开始是一个不错的主意,但它仍然是非常象牙的塔。
罗伯特·哈维

6

有两种主要方法可以确定对象是否不可变。

a)基于对象的性质

很容易发现这些情况,因为我们知道这些对象在构造之后不会改变。例如,如果您有一个RequestHistory实体,并且自然而言,历史记录一旦构建就不会更改。这些对象可以直接设计为不可变的类。请记住,请求对象是可变的,因为它可以随时间更改其状态以及分配给谁的对象,但是请求历史记录不会更改。例如,上周有一个历史记录元素,当它从提交状态转移到分配状态时,该历史记录实体永远不会改变。因此,这是一个经典的不变案例。

b)根据设计选择,外部因素

这类似于java.lang.String的示例。字符串实际上可以随时间变化,但是根据设计,由于缓存/字符串池/并发因素,它们使字符串成为不可变的。类似地,如果缓存/并发和相关性能在应用程序中至关重要,则缓存/并发等在使对象不可变方面可以起到很好的作用。但是,在分析所有影响之后,应该非常谨慎地做出此决定。

不可变对象的主要优点是它们不受滚滚杂草的影响,即对象在整个生命周期内都不会发生任何变化,这使编码和维护变得非常容易。


4

我目前正在讨论是否将自己项目中的某些数据类型切换为不可变,但我必须将其证明给同龄人,并且这些类型中的数据可能会(很少)更改-那时您当然可以更改这是一成不变的方法(创建一个新方法,从旧对象复制属性,但要更改的属性除外)。

最小化程序状态是非常有益的。

询问他们是否要在您的一个类中使用可变值类型来临时存储客户端类。

如果他们同意,请问为什么?可变状态不属于这种情况。迫使他们创建它实际所属的状态,并使数据类型的状态尽可能明确是很好的原因。


4

答案在某种程度上取决于语言。您的代码看起来像Java,在这个问题上,尽可能地困难。在Java中,对象只能通过引用传递,并且克隆被完全破坏。

没有简单的答案,但是可以肯定的是,您希望使小的价值对象不可变。Java正确地使字符串不可变,但是错误地使日期和日历可变。

因此,一定要使小值对象不可变,并实现一个复制构造函数。忘记所有有关Cloneable的内容,它的设计是如此糟糕,以至于毫无用处。

对于较大值的对象,如果不方便使它们不可变,则使它们易于复制。


听起来很像在编写C或C ++时如何在堆栈和堆之间进行选择:)
Ricket 2012年

@Ricket:不是很多IMO。堆栈/堆取决于对象的生存期。在C ++中,在堆栈上具有可变对象是很常见的。
凯文·克莱恩

1

也许他们以后不再更改这些字符串的值,并且永远不需要更改。显然,如果这些事情是对的,那么将对象更好地设计为不可变的,对吗?

有点违反直觉的,以后再不需要更改字符串是一个很好的论点,即对象是否不可变都无关紧要。无论编译器是否强制执行,程序员都已将它们视为有效不变的。

不变性通常不会受到伤害,但也不一定总是有帮助。判断对象是否可以从不变性中受益的简单方法是,是否需要在更改对象之前对其进行复制或获取互斥量。如果它永远不会改变,那么不变性并不会真正给您带来任何好处,有时会使事情变得更复杂。

确实有关于在无效状态下构造对象的风险的观点,但这实际上是与不变性分开的问题。构造后,对象既可以是可变的也可以始终处于有效状态。

该规则的例外是,由于Java不支持命名参数或默认参数,因此有时仅使用重载的构造函数来设计一个保证有效对象的类就变得笨拙。对于具有两种属性的情况并没有那么多,但是如果要在代码的其他部分中使用相似但较大的类来频繁使用该模式,则还有一些要说的是一致性。


我很好奇,关于可变性的讨论未能认识到可变性与可以安全地公开或共享对象的方式之间的关系。可以使用不受信任的代码安全地共享对深度不可变的类对象的引用。如果可以信任持有引用的每个人都永远不要修改对象或将其暴露给可能这样做的代码,则可以共享对可变类实例的引用。通常,根本不应该共享对可能发生突变的类实例的引用。如果在宇宙中的任何地方都只存在对一个对象的引用……
超级猫

...并且没有人查询其“身份哈希码”,将其用于锁定或以其他方式访问其“隐藏Object特征”,那么直接更改对象在语义上与将引用替换为对新对象的引用没有区别相同,但所指示的更改相同。Java最大的语义弱点之一是恕我直言,它无法通过代码来表明变量仅应是对事物的唯一非短暂引用。引用只能传递给在返回后可能无法保留副本的方法。
2014年

0

我对此可能有一个过低的看法,可能是因为我使用的是C和C ++,它们并不能使所有内容都变得不可变如此简单,但是我将不可变的数据类型视为优化细节。,以便编写更多内容。高效的功能,没有副作用,并且能够非常轻松地提供撤消系统和无损编辑等功能。

例如,这可能是非常昂贵的:

/// @return A new mesh whose vertices have been transformed
/// by the specified transformation matrix.
Mesh transform(Mesh mesh, Matrix4f matrix);

...如果Mesh不是设计为持久性数据结构,而是一种需要完全复制(在某些情况下可能跨越千兆字节)的数据类型,即使我们要做的只是更改它的一部分(例如,在上述场景中,我们仅修改顶点位置)。

因此,当我达到不变性并设计数据结构以允许对其的未修改部分进行浅表复制和引用计数时,可以使上述功能在不深层复制整个网格的同时仍能写出足够的网格的情况下变得相当有效。该函数没有副作用,从而大大简化了线程安全性,异常安全性,撤消操作,无损地应用操作等功能。

就我而言,它太昂贵了(至少从生产率的角度来看),使得所有内容都不可变,因此我将其保存在那些过于昂贵而无法完整复制的类中。这些类通常是庞大的数据结构,例如网格和图像,我通常使用可变接口通过“构建器”对象表达对它们的更改,以获取新的不可变副本。而且,我没有做太多的事情来尝试在类的中央级别实现不变的保证,而没有帮助我在没有副作用的函数中使用该类。我在Mesh上面使不可变的愿望不是直接使网格不可变,而是允许轻松编写没有副作用的函数,这些副作用不会输入网格并输出新的网格,而无需付出大量的内存和计算成本。

结果,在我的整个代码库中,我只有4种不可变的数据类型,它们都是庞大的数据结构,但是我大量使用它们来帮助我编写没有副作用的函数。如果像我一样,如果您使用的语言无法使所有内容保持不变,那么该答案可能适用。在这种情况下,您可以将精力转移到使大部分函数避免副作用上,这时您可能希望使选择的数据结构(PDS类型)成为不可变的,以避免出现昂贵的完整副本。同时,当我有这样的功能时:

/// @return The v1 * v2.
Vector3f vec_mul(Vector3f v1, Vector3f v2);

...那么我就没有使矢量不变的诱惑,因为它们很便宜,只需要完整地复制即可。通过将Vector变成可以浅复制未修改部分的不可变结构,在此没有性能优势。这样的成本将超过仅复制整个向量的成本。


-1
Foo a = new Foo();
a.setProperty1("asdf");
a.setProperty2("bcde");

如果Foo只有两个属性,那么编写带有两个参数的构造函数很容易。但是,假设您添加了另一个属性。然后,您必须添加另一个构造函数:

public Foo(String a, String b, SomethingElse c)

在这一点上,它仍然是可管理的。但是,如果有10个属性该怎么办?您不想要带有10个参数的构造函数。您可以使用构建器模式来构造实例,但这会增加复杂性。到这个时候,您会在想“为什么我不像普通人那样为所有属性添加设置器”?


4
具有十个参数的构造函数是否比未设置property7时发生的NullPointerException差?构建器模式是否比检查每种方法中未初始化属性的代码复杂?构造对象时最好检查一次。
凯文·克莱恩

2
正如几十年前针对这种情况所说的那样:“如果您的过程有十个参数,则可能会遗漏一些。”
9000

1
为什么不想要带有10个参数的构造函数?您在什么时候画线?我认为带有10个参数的构造函数对于付出不变性的好处来说是一个很小的代价。另外,或者您只用一行用逗号分隔10件事(如果看起来更好,则可选地分散成几行甚至10行),或者在单独设置每个属性时总有10行...
Ricket

1
@Ricket:因为它增加了将参数置于错误顺序的风险。如果您使用的是setter或构建器模式,那是不太可能的。
Mike Baranczak

4
如果您有一个带有10个参数的构造函数,也许是时候考虑将这些参数封装到它们自己的一个或多个类中了,和/或对类的设计进行批判性考虑。
亚当李尔
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.