不变的类在什么时候成为负担?


16

在设计用于保存数据模型的类时,我已经读过它对创建不可变对象很有用,但是到什么时候构造函数参数列表和深层副本的负担变得太大,您必须放弃不可变限制?

例如,这是一个不可变的类,用于表示命名的事物(我使用C#语法,但该原理适用于所有OO语言)

class NamedThing
{
    private string _name;    
    public NamedThing(string name)
    {
        _name = name;
    }    
    public NamedThing(NamedThing other)
    {
         this._name = other._name;
    }
    public string Name
    {
        get { return _name; }
    }
}

可以构造,查询和复制已命名的事物到新的已命名事物,但是名称不能更改。

这一切都很好,但是当我想添加另一个属性时会发生什么呢?我必须向构造函数添加一个参数并更新副本构造函数;据我所知,这不是太多的工作,但是当我想使一个复杂的对象不可变时,问题就开始了。

如果该类包含可能包含其他复杂类的属性和集合,在我看来,构造函数参数列表将成为一场噩梦。

那么,什么时候一个类变得太复杂而无法改变呢?


我总是尽力使模型中的类不可变。如果您有庞大且较长的构造器参数列表,那么您的类可能太大而可以拆分吗?如果您的下层对象也是不可变的,并且遵循相同的模式,那么您的上层对象就不会受到太大影响。我发现从头开始时,将现有类更改为不可变比将数据模型更改为不可变要困难得多。
没人

1
您可以看一下在此问题中建议的构建器模式:stackoverflow.com/questions/1304154/…–
蚂蚁

您看过MemberwiseClone吗?您不必为每个新成员更新副本构造函数。
凯文·克莱恩

3
@Tony如果您的集合及其包含的所有内容也是不可变的,则不需要深层副本,浅层副本就足够了。
mjcopple 2011年

2
顺便说一句,在类需要“相当”不可变但不是完全不可变的类中,我通常使用“一次设置”字段。我发现这解决了庞大的构造函数的问题,但是却提供了不可变类的大多数优点。(即,您的内部类代码不必担心值的更改)
Earlz 2011年

Answers:


23

什么时候成为负担?很快(特别是如果您选择的语言没有为不变性提供足够的句法支持。)

不变性已成为解决多核难题的灵丹妙药。但是大多数OO语言的不变性迫使您在模型和过程中添加人工制品和实践。对于每个复杂的不可变类,您必须具有一个同样复杂的(至少在内部)构建器。无论您如何设计,它仍然会引入强耦合(因此我们最好有充分的理由来引入它们。)

不可能在小型非复杂类中为所有模型建模。因此,对于大型类和结构,我们人为地对其进行分区-不是因为在我们的域模型中有意义,而是因为我们必须在代码中处理其复杂的实例化和构建器。

当人们在诸如Java或C#之类的通用语言中将不变性的概念过分夸大,从而使所有事物变得不变时,情况更糟。然后,结果是,人们看到人们在不轻松支持这种表达的语言中强制使用s-expression结构。

工程是通过折衷和权衡进行建模的行为。通过命令使所有内容都是不可变的,因为有人读过,用X或Y功能语言(完全不同的编程模型),所有内容都是不可变的,这是不可接受的。那不是一个好的工程。

小而可能是单一的事物可以变得不可变。当有意义的时候,可以使更复杂的事情变得不可变。但是,不变性不是万灵丹。减少错误,提高可伸缩性和性能的能力并不是不变性的唯一功能。这是适当的工程实践的功能。毕竟,人们已经编写了出色的,可扩展的软件,而且没有一成不变。

如果不变性无缘无故地在领域模型的上下文中没有意义的情况下完成,那么不变性就会变得非常快(这会增加意外的复杂性)。

我尝试避免这种情况(除非我使用的是具有良好语法支持的编程语言。)


6
路易斯,您是否注意到,用简单却合理的工程原理进行解释的写得好,务实的正确答案却不会像使用最新的编码时尚那样获得那么多的选票?这是一个很好的答案。
2011年

3
谢谢:)我自己注意到了代表趋势,但这没关系。Fad迷们流失的代码,后来我们以更好的每小时收费来修复,
哈哈

4
银色子弹?没有。值得在C#/ Java中有点尴尬吗(这并不是那么糟糕)?绝对。另外,多核在不变性中的作用很小……真正的好处是易于推理。
Mauricio Scheffer

@Mauricio-如果您这么说(Java中的不变性不是那么糟糕)。从1998年到2011年一直从事Java工作,我想有所不同,在简单的代码库中,这并不是一件容易的事。但是,人们有不同的经历,我承认我的POV并非没有主观性。很抱歉,不能同意。但是,我确实同意,对于不变性而言,易于推理是最重要的。
luis.espinal 2012年

13

我经历了一个阶段,坚持在可能的情况下使类保持不变。对于几乎所有东西,不可变数组等,都有构建器。我发现您的问题的答案很简单:不可变类在什么时候成为负担? 很快 一旦要序列化某些内容,就必须能够反序列化,这意味着它必须是可变的。一旦您想使用ORM,大多数人都坚持认为属性是可变的。等等。

最终,我用对可变对象的不可变接口替换了该策略。

class NamedThing : INamedThing
{
    private string _name;    
    public NamedThing(string name)
    {
        _name = name;
    }    

    public NamedThing(NamedThing other)
    {
        this._name = other._name;
    }

    public string Name
    {
        get { return _name; }
        set { _name = value; }
    }
}

interface INamedThing
{
    string Name { get; }
}

现在,该对象具有灵活性,但是您仍然可以告诉调用代码它不应编辑这些属性。


8
撇开次要问题,我同意当使用命令式语言进行编程时,不可变对象会很快成为麻烦,但是我不确定一个不变的接口是否真的可以解决相同的问题,或者根本解决任何问题。使用不可变对象的主要原因是,您可以随时将它扔到任何地方,而不必担心别人会破坏您的状态。如果基础对象是可变的,那么您就没有保证,尤其是使它可变的原因是,需要进行各种更改。
Aaronaught 2011年

1
@Aaronaught-有趣的一点。我认为这是心理上的事,而不是实际的保护。但是,您的最后一行有错误的前提。保持其可变性的原因更多是需要各种方法来实例化它并通过反射进行填充,而不是一旦实例化就进行突变。
pdr

1
@Aaronaught:以相同的方式IComparable<T>保证if X.CompareTo(Y)>0Y.CompareTo(Z)>0then X.CompareTo(Z)>0接口具有合同。如果的合同IImmutableList<T>规定所有实例和属性的值必须在任何实例暴露给外界之前“固定下来”,那么所有合法实现都将这样做。没有什么可以阻止IComparable实现违反传递性的,但是这样做是非法的。如果SortedDictionary由于非法而导致故障IComparable,...
超级猫

1
@Aaronaught:我为什么要相信的实现IReadOnlyList<T>将是不可改变的,因为(1)没有这样的规定,在接口文档中所述,和(2)的最常见的实现,List<T>甚至不是只读?我不太清楚我的用语有什么歧义:如果可以读取其中的数据,则集合是可读的。如果它可以保证除非包含某些外部引用的代码将对其进行更改,否则它不能更改包含的数据,则它是只读的。如果可以保证它不能更改,那么它是不变的。
2014年

1
@supercat:顺便说一句,微软同意我的看法。他们发布了一个不可变的collections包,并注意到它们都是具体类型,因为您无法保证抽象类或接口是真正不可变的。
Aaronaught

8

我认为对此没有普遍的答案。类越复杂,就越难以推断其状态变化,并且创建新副本的成本也就越高。因此,超出某些(个人)复杂性级别,将变得太痛苦而无法使/保持类不变。

请注意,太复杂的类或长的方法参数列表本身就是设计气味,而与不变性无关。

因此通常首选的解决方案是将此类分解为多个不同的类,每个类都可以独立设置为可变或不变的。如果这不可行,则可以将其变为可变的。


5

如果将所有不可变字段都存储在一个internal中,则可以避免复制问题struct。这基本上是纪念品模式的一种变体。然后,当您要复制时,只需复制纪念品:

class MyClass
{
    struct Memento
    {
        public int field1;
        public string field2;
    }

    private readonly Memento memento;

    public MyClass(int field1, string field2)
    {
        this.memento = new Memento()
            {
                field1 = field1,
                field2 = field2
            };
    }

    private MyClass(Memento memento) // for copying
    {
        this.memento = memento;
    }

    public int Field1 { get { return this.memento.field1; } }
    public string Field2 { get { return this.memento.field2; } }

    public MyClass WithNewField1(int newField1)
    {
        Memento newMemento = this.memento;
        newMemento.field1 = newField1;
        return new MyClass(newMemento);
    }
}

我认为内部结构不是必需的。这只是执行MemberwiseClone的另一种方式。
Codism 2011年

@Codism-是和否。有时您可能需要其他不想克隆的成员。如果您在一个getter中使用惰性评估并将结果缓存在成员中怎么办?如果执行MemberwiseClone,则将克隆缓存的值,然后将更改缓存值所依赖的成员之一。将状态与缓存分开是比较干净的。
Scott Whitlock

可能值得一提的是内部结构的另一个优点:它使对象易于将其状态复制到存在其他引用的对象。OOP中常见的歧义源是返回对象引用的方法是否返回的对象视图可能在接收者的控制范围之外发生变化。如果方法不返回对象引用,而是从调用者接受对象引用并将状态复制到该对象,则该对象的所有权将更加清楚。这种方法不适用于可自由继承的类型,但是……
超级猫

...对于可变数据持有人来说非常有用。该方法还使拥有“并行”可变且不变的类(从抽象的“可读”基类派生)并使其构造函数能够相互复制数据变得非常容易。
supercat 2014年

3

您在这里有几件事在工作。不变的数据集非常适合多线程可伸缩性。本质上,您可以相当多地优化内存,以便一组参数成为该类的一个实例-随处可见。因为对象永远不变,所以您不必担心在访问其成员时进行同步。这是好事。但是,正如您指出的那样,对象越复杂,就越需要一些可变性。我将从以下方面开始推理:

  • 有什么商业理由可以使对象更改其状态?例如,存储在数据库中的用户对象基于其ID是唯一的,但是它必须能够随时间更改状态。另一方面,当您更改网格上的坐标时,它不再是原始坐标,因此使坐标不变是有意义的。与字符串相同。
  • 可以计算某些属性吗?简而言之,如果对象的新副本中的其他值是您传入的某些核心值的函数,则可以在构造函数中或根据需要计算它们。这可以减少维护量,因为您可以在复制或创建时以相同的方式初始化这些值。
  • 新的不可变对象有多少个值?在某个时候,创建对象的复杂性变得不那么重要,在那时,拥有更多对象的实例可能会成为问题。示例包括不可变的树结构,传入参数超过三个的对象等。参数越多,搞乱参数顺序或使错误的参数归零的可能性就越大。

在仅支持不可变对象的语言(例如Erlang)中,如果有任何操作似乎可以修改不可变对象的状态,则最终结果是具有更新后值的对象的新副本。例如,将项目添加到引导程序/列表时:

myList = lists:append([[1,2,3], [4,5,6]])
% myList is now [1,2,3,4,5,6]

这可能是处理更复杂对象的理智方式。例如,当您添加树节点时,结果是带有添加的节点的新树。上面示例中的方法返回一个新列表。在本段的示例中,tree.add(newNode)它将返回带有添加节点的新树。对于用户而言,它变得易于使用。对于语言编写者来说,当语言不支持隐式复制时,这将变得乏味。该阈值取决于您自己的耐心。对于您的库用户,我发现的最合理的限制是大约三到四个参数顶部。


如果有人倾向于使用可变对象引用作为值[意味着没有引用包含在其所有者内且从不公开],那么构造一个包含所需“已更改”内容的新不可变对象就相当于直接修改该对象,虽然可能会更慢。但是,可变对象也可以用作实体。如何使事物表现得像没有可变对象的实体?
supercat 2014年

0

如果您有多个最终课程成员,并且不想让他们暴露于需要创建它的所有对象,则可以使用构建器模式:

class NamedThing
{
    private string _name;    
    private string _value;
    private NamedThing(string name, string value)
    {
        _name = name;
        _value = value;
    }    
    public NamedThing(NamedThing other)
    {
        this._name = other._name;
        this._value = other._value;
    }
    public string Name
    {
        get { return _name; }
    }

    public static class Builder {
        string _name;
        string _value;

        public void setValue(string value) {
            _value = value;
        }
        public void setName(string name) {
            _name = name;
        }
        public NamedThing newObject() {
            return new NamedThing(_name, _value);
        }
    }
}

优点是您可以轻松创建仅具有不同名称的不同值的新对象。


我认为您的构建器是静态的是不正确的。在设置静态名称或静态值之后,但在调用之前,另一个线程可以更改静态名称或静态值newObject
ErikE

只有构建器类是静态的,但其成员不是。这意味着对于您创建的每个构建器,他们都有自己的成员集以及相应的值。该类必须是静态的,以便可以在包含类之外使用和实例化(NamedThing在这种情况下)
Salandur

我明白您在说什么,我只是预想一个问题,因为它不会导致开发人员“陷入成功困境”。它使用静态变量的事实意味着,如果a Builder被重用,则存在我提到的事情发生的真实风险。可能有人在构建很多对象,并决定由于大多数属性都是相同的,因此只需简单地重用Builder,实际上,就让它成为注入依赖项的全局单例吧!哎呀 引入了主要错误。因此,我认为这种混合实例化与静态混合的模式是不好的。
ErikE 2015年

1
就Java内部类而言,C#中的@Salandur内部类始终是“静态”的。
塞巴斯蒂安·雷德尔

0

那么,什么时候一个类变得太复杂而无法改变呢?

在我看来,使小类在您所显示的语言中不可变是不值得的。我在这里使用的是小字,而不是复杂的字,因为即使您向该类添加了十个字段,并且确实在它们上进行了花哨的操作,我还是怀疑它会占用千字节,更不用说兆字节,更不用说千兆字节了,所以任何使用您实例的函数如果希望避免引起外部副作用,则class可以简单地对整个对象进行廉价复制,以避免修改原始对象。

持久数据结构

我发现个人用于不变性的地方是大型的中央数据结构,这些数据结构聚集了一堆小数据,例如您正在显示的类的实例,例如存储了一百万的类NamedThings。通过属于一个不变的持久数据结构并位于仅允许只读访问的接口后面,属于该容器的元素变得不可变,而无需元素类(NamedThing)进行处理。

廉价副本

持久数据结构允许对其区域进行转换并使其唯一,从而避免了对原始数据的修改,而不必完全复制数据结构。那才是真正的美。如果您想天真地编写避免副作用的函数,这些函数会输入占用千兆字节内存的数据结构,并且仅修改一兆字节的内存,那么您就必须复制整个怪异的东西以避免触摸输入并返回新的输出。在这种情况下,要么复制千兆字节以避免副作用,要么造成副作用,这使得您必须在两个不愉快的选择之间进行选择。

使用持久性数据结构,它使您可以编写这样的函数,并且避免制作整个数据结构的副本,如果您的函数仅转换了兆字节的内存容量,则仅需要大约兆字节的额外内存用于输出。

负担

至于负担,至少在我看来是直接的负担。我需要人们正在谈论的那些构建者或所谓的“瞬态”,以便他们能够有效地表达对庞大数据结构的转换而无需动手。像这样的代码:

void transform_stuff(MutList<Stuff>& stuff, int first, int last)
{
     // Transform stuff in the range, [first, last).
     for (; first != last; ++first)
          transform(stuff[first]);
}

...那么必须这样写:

ImmList<Stuff> transform_stuff(ImmList<Stuff> stuff, int first, int last)
{
     // Grab a "transient" (builder) list we can modify:
     TransientList<Stuff> transient(stuff);

     // Transform stuff in the range, [first, last)
     // for the transient list.
     for (; first != last; ++first)
          transform(transient[first]);

     // Commit the modifications to get and return a new
     // immutable list.
     return stuff.commit(transient);
}

但是,以这两条额外的代码行为交换,此函数现在可以安全地在具有相同原始列表的线程之间进行调用,不会引起任何副作用,等等。这也使得将操作变为不可撤消的用户操作非常容易,因为undo可以只存储旧列表的廉价浅表副本。

异常安全或错误恢复

在这种情况下,并不是每个人都能从持久数据结构中受益匪浅(我在VFX域中的核心概念undo系统和非破坏性编辑中发现了对它们的大量使用),但是有一点适用于每个人都要考虑的是异常安全错误恢复

如果要使原始变异函数具有异常安全性,则需要回滚逻辑,为此,最简单的实现需要复制整个列表:

void transform_stuff(MutList<Stuff>& stuff, int first, int last)
{
    // Make a copy of the whole massive gigabyte-sized list 
    // in case we encounter an exception and need to rollback
    // changes.
    MutList<Stuff> old_stuff = stuff;

    try
    {
         // Transform stuff in the range, [first, last).
         for (; first != last; ++first)
             transform(stuff[first]);
    }
    catch (...)
    {
         // If the operation failed and ran into an exception,
         // swap the original list with the one we modified
         // to "undo" our changes.
         stuff.swap(old_stuff);
         throw;
    }
}

在这一点上,异常安全的可变版本比使用“构建器”的不可变版本在计算上更加昂贵,并且更难以正确编写。许多C ++开发人员只是忽略了异常安全性,也许这对他们的领域来说是很好的选择,但就我而言,我想确保即使在发生异常情况下我的代码也能正常运行(甚至编写故意抛出异常以测试异常的测试)安全性),因此我必须能够回退某个函数引起的任何副作用(如果发生任何异常)。

如果您想成为异常安全的对象并从错误中正常恢复而不会导致应用程序崩溃和刻录,那么您必须还原/撤消函数在发生错误/异常时可能引起的任何副作用。在那里,构建器实际上可以节省比计算时间还多的程序员时间,原因是:...

您无需担心不会产生任何副作用的函数中的副作用!

回到基本问题:

不变的类在什么时候成为负担?

在语言中,它们始终是围绕可变性而不是不变性的负担,这就是为什么我认为您应该在收益远远超过成本的情况下使用它们。但是,在足够大的层次上,对于足够大的数据结构,我确实相信,在许多情况下,这是一个值得权衡的问题。

同样在我的系统中,我只有少数几种不可变的数据类型,它们都是庞大的数据结构,旨在存储大量元素(图像/纹理的像素,ECS的实体和组件以及的顶点/边/多边形)。网格)。

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.