【问题标题】:Immutable object pattern in C# - what do you think? [closed]C# 中的不可变对象模式 - 你怎么看? [关闭]
【发布时间】:2010-09-20 18:51:31
【问题描述】:

在几个项目的过程中,我开发了一种用于创建不可变(只读)对象和不可变对象图的模式。不可变对象具有 100% 线程安全的优点,因此可以跨线程重用。在我的工作中,我经常在 Web 应用程序中将这种模式用于配置设置以及我在内存中加载和缓存的其他对象。缓存对象应始终是不可变的,因为您希望确保它们不会被意外更改。

现在,您当然可以轻松地设计不可变对象,如下例所示:

public class SampleElement
{
  private Guid id;
  private string name;

  public SampleElement(Guid id, string name)
  {
    this.id = id;
    this.name = name;
  }

  public Guid Id
  {
    get { return id; }
  }

  public string Name
  {
    get { return name; }
  }
}

这对于简单的类来说很好——但对于更复杂的类,我不喜欢通过构造函数传递所有值的概念。在属性上有设置器更可取,并且构建新对象的代码更易于阅读。

那么如何使用 setter 创建不可变对象?

嗯,在我的模式中,对象一开始是完全可变的,直到你用一个方法调用冻结它们。一旦一个对象被冻结,它将永远保持不可变——它不能再次变成一个可变对象。如果您需要对象的可变版本,只需克隆它即可。

好的,现在开始一些代码。我在下面的代码中 sn-ps 试图将模式归结为最简单的形式。 IElement 是所有不可变对象最终必须实现的基本接口。

public interface IElement : ICloneable
{
  bool IsReadOnly { get; }
  void MakeReadOnly();
}

Element 类是 IElement 接口的默认实现:

public abstract class Element : IElement
{
  private bool immutable;

  public bool IsReadOnly
  {
    get { return immutable; }
  }

  public virtual void MakeReadOnly()
  {
    immutable = true;
  }

  protected virtual void FailIfImmutable()
  {
    if (immutable) throw new ImmutableElementException(this);
  }

  ...
}

让我们重构上面的 SampleElement 类来实现不可变对象模式:

public class SampleElement : Element
{
  private Guid id;
  private string name;

  public SampleElement() {}

  public Guid Id
  {
    get 
    { 
      return id; 
    }
    set
    {
      FailIfImmutable();
      id = value;
    }
  }

  public string Name
  {
    get 
    { 
      return name; 
    }
    set
    {
      FailIfImmutable();
      name = value;
    }
  }
}

您现在可以通过调用 MakeReadOnly() 方法更改 Id 属性和 Name 属性,只要对象未被标记为不可变。一旦它是不可变的,调用 setter 将产生 ImmutableElementException。

最后说明: 完整的模式比这里显示的代码 sn-ps 更复杂。它还包含对不可变对象集合和不可变对象图的完整对象图的支持。完整模式使您可以通过调用最外层对象的 MakeReadOnly() 方法将整个对象图变为不可变。一旦开始使用这种模式创建更大的对象模型,泄漏对象的风险就会增加。泄漏对象是在对对象进行更改之前未能调用 FailIfImmutable() 方法的对象。为了测试泄漏,我还开发了一个用于单元测试的通用泄漏检测器类。它使用反射来测试是否所有属性和方法都将 ImmutableElementException 抛出为不可变状态。 换句话说,这里使用的是 TDD。

我越来越喜欢这种模式,并从中发现了很多好处。所以我想知道的是你们中是否有人使用类似的模式?如果是,您是否知道任何记录它的好资源?我本质上是在寻找潜在的改进以及可能已经存在于该主题的任何标准。

【问题讨论】:

  • .NET Framework 指南建议不要实现 ICloneable 或在公共 API 中使用它,因为 ICloneable 的协定没有指定满足协定所需的克隆实现类型。
  • 如果您使用的是 WPF,其中大部分已经可以通过从 Freezable 类下降来获得。
  • 谢谢乔 - 不知道这门课。看起来很不错。
  • 你可能会考虑抛出一个已经存在的 ReadOnlyException,而不是创建你自己的 ImmutableElementException。

标签: c# functional-programming design-patterns immutability


【解决方案1】:

关于信息,第二种方法称为“popsicle immutability”。

Eric Lippert 从here 开始有一系列关于不变性的博客文章。我仍然在掌握 CTP (C# 4.0),但看起来有趣的是可选/命名参数(到 .ctor)在这里可能会做什么(当映射到只读字段时)...... [更新:我在这个here上写了博客]

对于信息,我可能不会使用这些方法 virtual - 我们可能不希望子类能够使其不可冻结。如果您希望他们能够添加额外的代码,我建议您这样做:

[public|protected] void Freeze()
{
    if(!frozen)
    {
        frozen = true;
        OnFrozen();
    }
}
protected virtual void OnFrozen() {} // subclass can add code here.

另外 - AOP(例如 PostSharp)可能是添加所有 ThrowIfFrozen() 检查的可行选项。

(抱歉,如果我更改了术语/方法名称 - 所以在撰写回复时不会保持原始帖子可见)

【讨论】:

  • 谢谢 - 正是我正在寻找的链接。 Eric 非常准确地描述了为什么这在您使用循环引用和序列化时很有用。
  • 再次感谢 - 我完全同意虚拟方法不是最佳设计。我将根据您的建议进行重构。我也喜欢你的冷冻术语:-)
  • 我认为最好就“冰棒不变性”是什么说几句话,而不仅仅是引用它。只是为了让人们只能从答案的文本中了解为什么它是正确的答案。如果可能的话。谢谢。
【解决方案2】:

另一种选择是创建某种 Builder 类。

例如,在 Java(以及 C# 和许多其他语言)中,字符串是不可变的。如果要执行多个操作来创建字符串,请使用 StringBuilder。这是可变的,然后一旦你完成,你就会让它返回给你最终的 String 对象。从那时起,它就不会改变了。

您可以为其他课程做类似的事情。你有你的不可变元素,然后是一个 ElementBuilder。构建器所要做的就是存储您设置的选项,然后当您完成它时,它会构造并返回不可变的元素。

代码有点多,但我认为它比在一个应该是不可变的类上设置 setter 更干净。

【讨论】:

  • 是的,元素构建器是个好主意。我没有选择这个解决方案,因为我的对象模型也可以使用需要对象设置器的 XmlSerializer 类转换为 Xml 并再次转换回对象。
  • 这种方法虽然有效,但对要维护的代码量有很大影响。非常适合代码生成器工具;对人类来说不是那么好。
【解决方案3】:

在我最初对每次修改都必须创建一个新的System.Drawing.Point 感到不安之后,几年前我完全接受了这个概念。事实上,我现在默认将每个字段都创建为 readonly,并且只有在有令人信服的理由时才将其更改为可变的 - 令人惊讶的是,这种情况很少见。

不过,我不太关心跨线程问题(我很少使用与此相关的代码)。由于语义表达能力,我发现它好多了。不变性是一个很难被错误使用的接口的缩影。

【讨论】:

    【解决方案4】:

    您仍在处理状态,因此如果您的对象在变为不可变之前被并行化,仍然可能会被咬。

    更实用的方法可能是使用每个 setter 返回对象的新实例。或者创建一个可变对象并将其传递给构造函数。

    【讨论】:

      【解决方案5】:

      (相对)新的软件设计范式称为领域驱动设计,区分实体对象和值对象。

      实体对象被定义为必须映射到持久数据存储中的键驱动对象的任何事物,例如员工、客户或发票等...其中更改对象的属性意味着您需要将更改保存到某处的数据存储中,并且具有相同“键”的类的多个实例的存在意味着需要同步它们,或者将它们的持久性协调到数据存储中,以便一个实例的更改不会覆盖其他。更改实体对象的属性意味着您正在更改有关对象的某些内容 - 不更改您引用的对象...

      值对象otoh,是可以被认为是不可变的对象,其效用由它们的属性值严格定义,并且多个实例不需要以任何方式协调......比如地址或电话号码,或者汽车的轮子,或者文档中的字母……这些东西完全由它们的属性定义……文本编辑器中的大写“A”对象可以与任何其他大写“A”对象透明地互换文档,您不需要密钥将其与所有其他“A”区分开来。从这个意义上说,它是不可变的,因为如果将其更改为“B”(就像更改电话号码对象中的电话号码字符串一样,您没有更改与某个可变实体关联的数据,而是从一个值切换到另一个值...就像更改字符串的值一样...

      【讨论】:

      • 我喜欢实体/值的区别,并认为可以合理地为所有对象定义 Equals 以表示等价。仅当两个实体引用以相同的实体为目标时,它们才会彼此等价,而如果它们以等价的值为目标,则值引用将是等价的。没有非空实体引用等同于任何非空值引用。太糟糕了 .NET Framework 中的某些类型将 Equals 定义为等价以外的意思。
      【解决方案6】:

      @Cory Foy 和@Charles Bretana 扩展了实体和值之间存在差异的观点。虽然值对象应该始终是不可变的,但我真的不认为一个对象应该能够冻结自己,或者允许自己在代码库中任意冻结。它有一种非常难闻的气味,我担心它很难追踪一个对象被冻结的确切位置,以及它被冻结的原因,以及在调用一个对象之间它可以将状态从解冻更改为冻结的事实.

      这并不是说有时你想给某物一个(可变的)实体并确保它不会被改变。

      所以,不是冻结对象本身,另一种可能是复制 ReadOnlyCollection 的语义

      List<int> list = new List<int> { 1, 2, 3};
      ReadOnlyCollection<int> readOnlyList = list.AsReadOnly();
      

      您的对象可以在需要时将其作为可变部分,然后在您希望时将其变为不可变。

      请注意,ReadOnlyCollection 还实现了 ICollection,它在接口中有一个 Add( T item) 方法。但是,接口中还定义了bool IsReadOnly { get; },以便消费者可以在调用将引发异常的方法之前进行检查。

      不同之处在于您不能只将 IsReadOnly 设置为 false。集合要么是只读的,要么不是只读的,并且在集合的生命周期内永远不会改变。

      如果有 C++ 在编译时为您提供的 const 正确性,那会很好,但这开始有它自己的一系列问题,我很高兴 C# 没有做到这一点。


      ICloneable - 我想我只是参考以下内容:

      不要实现 ICloneable

      不要在公共 API 中使用 ICloneable

      Brad Abrams - Design Guidelines, Managed code and the .NET Framework

      【讨论】:

      • 我不同意它有难闻的气味。正如 Marc 所指出的,这种形式的不变性被称为“冰棒不变性”,在某些情况下非常有用。我只是希望编译器支持这种类型的不可变对象。
      • 我原则上同意冰棒风格,但“难闻的气味”是使用不当。有时物体被冻结,有时被解冻。如果操作不正确,可能会导致垃圾客户端代码。
      • 我建议查看 ReadOnlyCollection 是因为我更喜欢明确性而不是可以告诉对象它不再可变的方法调用。但是,我也在谈论更一般的不变性。
      【解决方案7】:

      System.String 是具有设置器和变异方法的不可变类的一个很好的例子,只是每个变异方法都返回一个新实例。

      【讨论】:

      • 我不知道它的行为是这样的,一个有趣的模式!
      • 谨防双刃剑;你可以很快得到大量的 gen0 对象,让 GC 去操心。显然,由于每个字符串的大小(因此是 StringBuilder),这对字符串特别不利,但它仍然是流畅的变异 API 的考虑因素。
      【解决方案8】:

      这是一个重要的问题,我很乐意看到更直接的框架/语言支持来解决它。您拥有的解决方案需要大量样板文件。使用代码生成自动化一些样板文件可能很简单。

      您将生成一个包含所有可冻结属性的分部类。为此制作一个可重用的 T4 模板会相当简单。

      模板会将此作为输入:

      • 命名空间
      • 类名
      • 属性名称/类型元组列表

      并会输出一个 C# 文件,其中包含:

      • 命名空间声明
      • 部分类
      • 每个属性,以及对应的类型、支持字段、getter 和调用 FailIfFrozen 方法的 setter

      可冻结属性上的 AOP 标记也可以工作,但它需要更多的依赖项,而 T4 内置在较新版本的 Visual Studio 中。

      另一个非常相似的场景是INotifyPropertyChanged 接口。该问题的解决方案可能适用于该问题。

      【讨论】:

        【解决方案9】:

        我对这种模式的问题是您没有对不变性施加任何编译时限制。编码器负责确保在将对象添加到缓存或其他非线程安全结构之前将其设置为不可变。

        这就是为什么我会以泛型类的形式通过编译时限制来扩展这种编码模式,如下所示:

        public class Immutable<T> where T : IElement
        {
            private T value;
        
            public Immutable(T mutable) 
            {
                this.value = (T) mutable.Clone();
                this.value.MakeReadOnly();
            }
        
            public T Value 
            {
                get 
                {
                    return this.value;
                }
            }
        
            public static implicit operator Immutable<T>(T mutable) 
            {
                return new Immutable<T>(mutable);
            }
        
            public static implicit operator T(Immutable<T> immutable)
            {
                return immutable.value;
            }
        }
        

        这是一个示例,您将如何使用它:

        // All elements of this list are guaranteed to be immutable
        List<Immutable<SampleElement>> elements = 
            new List<Immutable<SampleElement>>();
        
        for (int i = 1; i < 10; i++) 
        {
            SampleElement newElement = new SampleElement();
            newElement.Id = Guid.NewGuid();
            newElement.Name = "Sample" + i.ToString();
        
            // The compiler will automatically convert to Immutable<SampleElement> for you
            // because of the implicit conversion operator
            elements.Add(newElement);
        }
        
        foreach (SampleElement element in elements)
            Console.Out.WriteLine(element.Name);
        
        elements[3].Value.Id = Guid.NewGuid();      // This will throw an ImmutableElementException
        

        【讨论】:

          【解决方案10】:

          简化元素属性的提示:使用automatic propertiesprivate set 并避免显式声明数据字段。例如

          public class SampleElement {
            public SampleElement(Guid id, string name) {
              Id = id;
              Name = name;
            }
          
            public Guid Id {
              get; private set;
            }
          
            public string Name {
              get; private set;
            }
          }
          

          【讨论】:

          • 感谢您的提示 - C# 3.0 中的另一个漂亮的小功能 :-) 如果不是因为我需要能够使用序列化/反序列化我的对象图,我肯定会使用私有 Setter XmlSerializer。
          • 如果 XmlSerializer 足够聪明,可以将构造函数参数与属性相关联并使用该构造函数而不是默认构造函数构造对象,那就太好了
          【解决方案11】:

          这是第 9 频道的一个新视频,Anders Hejlsberg 在采访中从 36:30 开始谈论 C# 中的不变性。他为冰棒不变性提供了一个非常好的用例,并解释了这是您目前需要自己实现的东西。听到他说在未来的 C# 版本中为创建不可变对象图提供更好的支持是值得思考的

          Expert to Expert: Anders Hejlsberg - The Future of C#

          【讨论】:

            【解决方案12】:

            针对您的特定问题的另外两个尚未讨论的选项:

            1. 构建您自己的反序列化器,它可以调用私有属性设置器。虽然一开始构建反序列化器的工作量会更大,但它使事情变得更清洁。编译器将阻止您尝试调用 setter,并且您的类中的代码将更易于阅读。

            2. 在每个类中放置一个构造函数,该构造函数接受一个 XElement(或某种其他形式的 XML 对象模型)并从中填充自身。显然,随着类数量的增加,这种解决方案很快就会变得不那么理想。

            【讨论】:

              【解决方案13】:

              拥有一个带有子类 MutableThing 和 ImmutableThing 的抽象类 ThingBase 怎么样? ThingBase 将包含受保护结构中的所有数据,为字段提供公共只读属性,并为其结构提供受保护的只读属性。它还将提供一个可覆盖的 AsImmutable 方法,该方法将返回一个 ImmutableThing。

              MutableThing 将使用读/写属性隐藏属性,并提供默认构造函数和接受 ThingBase 的构造函数。

              不可变的东西将是一个密封的类,它覆盖 AsImmutable 以简单地返回自身。它还将提供一个接受 ThingBase 的构造函数。

              【讨论】:

                【解决方案14】:

                我不喜欢能够将对象从可变状态更改为不可变状态的想法,这在我看来似乎违背了设计的意义。你什么时候需要这样做?只有代表 VALUES 的对象才应该是不可变的

                【讨论】:

                • 每当我有一个更大的对象模型用于配置设置等时,我基本上都会使用这种模式。对象模型可以使用 XmlSerializer 类序列化为 XML 并再次返回到对象,该类需要对象上的 setter。
                • 你能澄清一下吗?代表 VALUES 的对象绝对应该是im可变的。重新使对象不可变,这只是达到目的的手段。最后是一个不可变的类,这是多线程/函数式代码非常需要的。
                • 我对循环引用和序列化的需求基本上要求冰棒不变性,正如上面讨论的两个答案。
                • @Marc 哎呀!不幸的地方打错了!,更正
                【解决方案15】:

                您可以将可选的命名参数与 nullables 一起使用,以创建一个不可变的 setter,只需要很少的样板。如果您确实想将某个属性设置为 null,那么您可能会遇到更多麻烦。

                class Foo{ 
                    ...
                    public Foo 
                        Set
                        ( double? majorBar=null
                        , double? minorBar=null
                        , int?        cats=null
                        , double?     dogs=null)
                    {
                        return new Foo
                            ( majorBar ?? MajorBar
                            , minorBar ?? MinorBar
                            , cats     ?? Cats
                            , dogs     ?? Dogs);
                    }
                
                    public Foo
                        ( double R
                        , double r
                        , int l
                        , double e
                        ) 
                    {
                        ....
                    }
                }
                

                你会这样使用它

                var f = new Foo(10,20,30,40);
                var g = f.Set(cat:99);
                

                【讨论】:

                  猜你喜欢
                  • 1970-01-01
                  • 1970-01-01
                  • 1970-01-01
                  • 2010-09-10
                  • 1970-01-01
                  • 2010-09-12
                  • 2011-11-05
                  • 1970-01-01
                  • 1970-01-01
                  相关资源
                  最近更新 更多