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

我已经在几个项目的过程中开发了一个创build不可变(只读)对象和不可变对象图的模式。 不可变对象具有100%线程安全的优点,因此可以跨线程重用。 在我的工作中,我经常在Web应用程序中使用这种模式来configuration设置,以及在内存中加载和caching的其他对象。 caching对象应该永远是不可变的,因为你想保证它们不会意外地改变。

现在,您可以轻松devise不可变对象,如下例所示:

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创build不可变的对象呢?

那么,在我的模式中,对象首先是完全可变的,直到你用一个方法调用来冻结它们。 一旦一个对象被冻结,它将永远保持不变 – 它不能再变成一个可变的对象。 如果你需要一个可变的对象版本,你只需要克隆它。

好的,现在到一些代码。 我在下面的代码片段试图沸腾到最简单的forms。 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()方法来将整个对象graphics变为不可变的。 一旦使用这种模式开始创build更大的对象模型,泄漏对象的风险就会增加。 泄漏对象是在对对象进行更改之前无法调用FailIfImmutable()方法的对象。 为了testing泄漏,我还开发了一个通用的泄漏检测器类,用于unit testing。 它使用reflection来testing所有属性和方法是否将ImmutableElementException引发为不可变状态。 换句话说,在这里使用TDD。

我已经成长为喜欢这种模式,并find很大的好处。 所以我想知道的是,如果你们中有人使用类似的模式? 如果是的话,你知道有什么好的资源来logging吗? 我基本上是在寻找潜在的改进,以及在这个话题上可能已经存在的标准。

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

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

有关信息,我可能不会使这些方法virtual – 我们可能不希望子类能够使其不冻结。 如果你想让他们能够添加额外的代码,我会build议这样的:

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

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

(道歉,如果我已经改变了术语/方法的名称 – 所以当撰写答复时不保留原始post)

另一个select是创build一些Builder类。

例如,在Java(以及C#和许多其他语言)中,string是不可变的。 如果你想做多个操作来创build一个string,你可以使用一个StringBuilder。 这是可变的,然后一旦你完成,你有最后的String对象返回给你。 从此它是不可改变的。

你可以为你的其他课程做类似的事情。 你有你的不可变元素,然后是一个ElementBuilder。 所有的build设者会做的是存储你设置的选项,然后当你最终确定它构造并返回不可变的元素。

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

在我最初对每个修改都必须创build一个新的System.Drawing.Point的事实感到不舒服之后,几年前我完全接受了这个概念。 实际上,我现在将每个字段默认为readonly ,只有在有令人信服的理由时才会将其更改为可变的 – 这种情况出乎意料地很less。

我不太在意跨线程问题,尽pipe(我很less在相关的地方使用代码)。 我只是觉得它好多了,因为语义上的performance力。 不变性是一个很难正确使用的界面的缩影。

你仍然在处理状态,因此如果你的对象在不可变的情况下被并行化,它仍然会被咬死。

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

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

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

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

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

@Cory Foy和@Charles Bretana在实体和价值之间有所不同的地方扩大了这一点。 而价值对象应该永远是不变的,我真的不认为一个对象应该能够冻结自己,或者让自己在代码库中被任意地冻结。 它有一个非常难闻的气味,我担心它可能很难追查到哪里是一个对象被冻结,为什么它被冻结,以及事实之间的事实,在一个对象可以改变状态从解冻到冻结。

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

所以,不是冻结对象本身,而是复制ReadOnlyCollection <T>的语义

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

你的对象在需要的时候可以把它看成是可变的,然后在你想要的时候变成不可变的。

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

不同的是,你不能只将IsReadOnly设置为false。 集合是或不是只读的,集合的生命周期永远不会改变。

在编译时C ++提供给你的const正确性会很好,但是开始有它自己的问题,我很高兴C#不会去那里。


ICloneable – 我想我只是回头看看以下内容:

不要实现ICloneable

不要在公共API中使用ICloneable

Brad Abrams – devise指南,托pipe代码和.NET Framework

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

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

该模板将用于input:

  • 命名空间
  • class级名称
  • 属性名称/types元组列表

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

  • 名称空间声明
  • 偏class
  • 每个属性都有相应的types,一个后台字段,一个getter和一个调用FailIfFrozen方法的setter

可冻结属性上的AOP标签也可以工作,但是需要更多的依赖关系,而T4被embedded到新版本的Visual Studio中。

另一个非常像这样的情况是INotifyPropertyChanged接口。 这个问题的解决scheme可能适用于这个问题。

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

这就是为什么我会用一个通用类的forms来扩展这种编码模式,就像这样:

 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 

只是简化元素属性的提示:使用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; } } 

这里是第9频道的一个新video,来自采访中的36:30的Anders Hejlsberg开始谈论C#中的不可变性。 他给出了冰棒不可变性的一个很好的用例,并解释了这是你目前需要实现的东西。 听到他的声音是我的耳朵说,这是值得考虑更好的支持创build不可变的对象图在未来版本的C#

Expert to Expert:Anders Hejlsberg – C#的未来

还有两个其他选项可以解决您的特定问题:

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

  2. 把一个构造函数放在每个带有XElement的类中(或者其他一些XML对象模型),并从中自行填充。 很明显,随着class级数量的增加,这种解决scheme很快就变得不太可取了。

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

MutableThing会影射读/写属性的属性,并提供默认的构造函数和接受ThingBase的构造函数。

不可变的事情将是一个密封的类,重写AsImmutable只需返回自己。 它也会提供一个接受ThingBase的构造函数。

我不喜欢把对象从一个可变的状态改变成一个不可变的状态的想法,这种似乎把devise的关键点击败了我。 你什么时候需要这样做? 只有表示VALUES的对象应该是不可变的

你可以使用可选的命名参数和可空的值来创build一个具有很less样板的不可变的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);