C#中的不可变对象模式 - 你怎么看?

时间:2008-11-04 21:49:03

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

我在一些项目的过程中开发了一种用于创建不可变(只读)对象和不可变对象图的模式。不可变对象具有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是更理想的,构建新对象的代码更容易阅读。

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

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

好的,现在谈谈一些代码。我在下面的代码片段中试图将模式简化为最简单的形式。 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。

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

我已经逐渐喜欢这种模式,并在其中找到了很大的好处。所以我想知道的是,如果你们中的任何一个人使用类似的模式?如果是,您是否知道记录它的任何好资源?我基本上正在寻找潜在的改进以及可能已存在于此主题的任何标准。

15 个答案:

答案 0 :(得分:31)

有关信息,第二种方法称为“冰棒不变性”。

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()检查的可行选项。

(如果我更改了术语/方法名称,请道歉 - 在撰写回复时SO不会保留原始帖子)

答案 1 :(得分:17)

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

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

您可以为其他课程做类似的事情。你有不可变元素,然后是ElementBuilder。所有构建器都会存储您设置的选项,然后在最终确定它时构造并返回不可变元素。

这是一个更多的代码,但我认为它比在一个应该是不可变的类的setter上更清晰。

答案 2 :(得分:9)

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

我不太关心跨线程问题(我很少使用相关的代码)。由于语义表达,我发现它更好,更好。不可变性是一个很难错误使用的界面的缩影。

答案 3 :(得分:8)

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

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

答案 4 :(得分:6)

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

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

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

答案 5 :(得分:4)

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

答案 6 :(得分:4)

扩大@Cory Foy和@Charles Bretana的观点,实体和价值观之间存在差异。值对象应始终是不可变的,我真的不认为对象应该能够自行冻结,或者允许自己在代码库中任意冻结。它有一种非常难闻的气味,我担心它可能很难找到一个物体被冻结的地方,以及为什么它被冻结,以及在调用一个物体之间可以将状态从解冻变为冻结的事实。

这并不是说有时你想给某个(可变的)实体提供某些东西并确保它不会被改变。

因此,不是冻结对象本身,另一种可能性是复制ReadOnlyCollection<的语义。 T>

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

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

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

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

在编译时得到C ++为你提供的const-correctness会很好,但是开始有它自己的一组问题,我很高兴C#不会去那里。


ICloneable - 我想我只想回顾以下内容:

  

不要实施ICloneable

     

不要在公共API中使用ICloneable

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

答案 7 :(得分:4)

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

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

模板会将此作为输入:

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

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

  • 名称空间声明
  • partial class
  • 每个属性,具有相应的类型,支持字段,getter和调用FailIfFrozen方法的setter

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

另一个非常类似的场景是INotifyPropertyChanged界面。该问题的解决方案可能适用于此问题。

答案 8 :(得分:3)

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

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

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

答案 9 :(得分:2)

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

答案 10 :(得分:2)

仅提示简化元素属性:将automatic propertiesprivate set一起使用,并避免明确声明数据字段。 e.g。

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;
  }
}

答案 11 :(得分:2)

以下是第9频道的新视频,Anders Hejlsberg从采访中的36:30开始谈论C#的不变性。他给出了冰棒不变性的一个非常好的用例,并解释了这是你目前需要自己实现的。听到他的声音听起来是值得考虑更好地支持在未来版本的C#中创建不可变对象图表

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

答案 12 :(得分:2)

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

  1. 构建自己的反序列化程序,可以调用私有属性setter。虽然在开始时建立解串器的努力将更多,但它使事情更清洁。编译器将使您甚至不会尝试调用setter,并且类中的代码将更容易阅读。

  2. 在每个接受XElement(或其他一些XML对象模型)的类中放置一个构造函数,并从中填充自己。显然,随着课程数量的增加,这很快就会变得不那么理想了。

答案 13 :(得分:2)

如何使用一个抽象类ThingBase,具有子类MutableThing和ImmutableThing? ThingBase将包含受保护结构中的所有数据,为字段提供公共只读属性,为其结构提供受保护的只读属性。它还将提供一个可重写的AsImmutable方法,它将返回一个ImmutableThing。

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

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

答案 14 :(得分:2)

您可以使用可选的命名参数和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);