47

在几个项目的过程中,我开发了一种用于创建不可变(只读)对象和不可变对象图的模式。不可变对象具有 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 创建不可变对象呢?

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

好的,现在开始一些代码。我在以下代码片段中尝试将模式归结为最简单的形式。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 抛出为不可变状态。换句话说,这里使用了 TDD。

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

4

15 回答 15

31

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

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

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

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

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

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

于 2008-11-04T22:28:39.797 回答
17

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

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

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

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

于 2008-11-04T21:59:32.073 回答
10

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

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

于 2008-11-04T21:58:42.027 回答
8

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

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

于 2008-11-04T21:53:14.473 回答
6

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

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

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

于 2008-11-04T22:27:30.847 回答
5

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

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

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

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

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

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

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

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


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

不要实现 ICloneable

不要在公共 API 中使用 ICloneable

Brad Abrams - 设计指南、托管代码和 .NET Framework

于 2008-11-04T23:48:19.280 回答
4

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

于 2008-11-04T21:56:12.827 回答
4

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

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

模板会将此作为输入:

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

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

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

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

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

于 2010-11-03T21:05:54.483 回答
4

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

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

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
于 2014-02-06T13:36:49.287 回答
2

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

于 2008-11-04T21:56:37.523 回答
2

简化元素属性的提示:使用自动属性private 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;
  }
}
于 2008-11-05T12:37:53.470 回答
2

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

专家对专家:Anders Hejlsberg - C# 的未来

于 2009-03-08T09:37:26.633 回答
2

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

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

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

于 2009-06-23T20:23:35.333 回答
2

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

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

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

于 2010-11-30T20:51:28.737 回答
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);
于 2013-04-10T14:56:15.337 回答