我在一些项目的过程中开发了一种用于创建不可变(只读)对象和不可变对象图的模式 . 不可变对象具有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 回答
有关信息,第二种方法称为“冰棒不变性” .
Eric Lippert有一系列关于不变性的博客文章here . 我'm still getting to grips with the CTP (C# 4.0), but it looks interesting what optional / named parameters (to the .ctor) might do here (when mapped to readonly fields)... [update: I'已在此博客here]
有关信息,我可能不会制作这些方法
virtual
- 我们可能不会提出类似的建议:此外 - AOP(如PostSharp)可能是添加所有ThrowIfFrozen()检查的可行选项 .
(如果我更改了术语/方法名称,请道歉 - 在撰写回复时SO不会保留原始帖子)
另一种选择是创建某种Builder类 .
例如,在Java(以及C#和许多其他语言)中,String是不可变的 . 如果要执行多个操作来创建String,可以使用StringBuilder . 这是可变的,然后一旦你完成,你就会让它返回给你最后的String对象 . 从那时起,它是不可改变的 .
您可以为其他课程做类似的事情 . 你有不可变元素,然后是ElementBuilder . 所有构建器都会存储您设置的选项,然后在完成它时构造并返回不可变元素 .
这是一个更多的代码,但我认为它比在一个应该是不可变的类的setter上更清晰 .
在我最初对每次修改都要创建一个新的
System.Drawing.Point
这一事实感到不安之后,几年前我完全接受了这个概念 . 实际上,我现在默认将每个字段创建为readonly
,只将其更改为如果有令人信服的理由是可变的 - 这种情况很少令人惊讶 .我不太关心跨线程问题(我很少使用相关的代码) . 由于语义表达,我发现它更好,更好 . 不可变性是一个很难错误使用的界面的缩影 .
您仍在处理状态,因此如果您的对象在变为不可变之前并行化,则仍然可能被咬住 .
更实用的方法可能是使用每个setter返回对象的新实例 . 或者创建一个可变对象并将其传递给构造函数 .
(相对)新的软件设计范例称为域驱动设计,它区分实体对象和值对象 .
实体对象被定义为必须映射到持久性数据存储中的密钥驱动对象的任何内容,例如员工,客户端或发票等......其中更改对象的属性意味着您需要将更改保存到某个地方的数据存储中,并且具有相同“键”的类的多个实例的存在意味着需要同步它们,或者将它们的持久性协调到数据存储,以便一个实例的更改不会覆盖其他实例 . 更改实体对象的属性意味着您正在更改有关该对象的内容 - 而不是更改您引用的WHICH对象...
值对象otoh是可以被视为不可变的对象,其效用严格按其属性值定义,并且多个实例不需要以任何方式协调...如地址,电话号码或轮子在汽车上,或文件中的字母......这些东西完全由它们的属性定义......文本编辑器中的大写“A”对象可以与整个文档中的任何其他大写“A”对象透明地互换,你不需要一把钥匙来区别于其他所有'A'在这个意义上它是不可变的,因为如果你把它改成'B'(就像更改电话号码对象中的电话号码字符串一样,你不是更改与某个可变实体关联的数据,您将从一个值切换到另一个...就像更改字符串的值一样...
System.String是具有setter和mutating方法的不可变类的一个很好的例子,只是每个mutating方法返回一个新实例 .
由@Cory Foy和@Charles Bretana扩展,实体和 Value 观之间存在差异 . 值对象应始终是不可变的,我真的不认为对象应该能够自行冻结,或者允许自己在代码库中任意冻结 . 它有一种非常难闻的气味,我担心它可能很难找到一个物体被冻结的地方,以及为什么它被冻结,以及在调用一个物体之间可以将状态从解冻变为冻结的事实 .
这并不是说有时你想给某个(可变的)实体提供某些东西并确保它不会被改变 .
因此,另一种可能性是复制ReadOnlyCollection <T>的语义,而不是冻结对象本身
你的对象可以在需要时将其作为可变部分,然后在你希望它时是不可变的 .
请注意,ReadOnlyCollection <T>还实现ICollection <T>,其在接口中具有
Add( T item)
方法 . 但是,接口中还定义了bool IsReadOnly { get; }
,以便消费者可以在调用将引发异常的方法之前进行检查 .不同之处在于您不能将IsReadOnly设置为false . 集合是或者不是只读的,并且在集合的生命周期中永远不会改变 .
在编译时让C给你的const正确性会很好,但是开始有它自己的一组问题,我很高兴C#不会去那里 .
ICloneable - 我想我只想回顾以下内容:
Brad Abrams - Design Guidelines, Managed code and the .NET Framework
这是一个重要的问题,我很乐意看到更直接的框架/语言支持来解决它 . 你需要的解决方案需要大量的样板 . 通过使用代码生成来自动化一些样板可能很简单 .
您将生成一个包含所有freezable属性的分部类 . 为此制作可重复使用的T4模板会相当简单 .
模板会采用这个输入:
命名空间
class 名称
属性名称/类型元组列表
并输出一个C#文件,包含:
名称空间声明
分部课
每个属性,具有相应的类型,支持字段,getter和调用FailIfFrozen方法的setter
freezable属性上的AOP标签也可以工作,但它需要更多依赖项,而T4内置于较新版本的Visual Studio中 .
另一个非常类似的场景是
INotifyPropertyChanged
界面 . 该问题的解决方案可能适用于此问题 .我对这种模式的问题是你不会对不变性施加任何编译时限制 . 编码器负责确保将对象设置为不可变,例如将其添加到缓存或其他非线程安全结构 .
这就是为什么我会以泛型类的形式扩展这种编码模式的编译时限制,如下所示:
这是一个如何使用它的示例:
只是简化元素属性的提示:将automatic properties与
private set
一起使用,并避免显式声明数据字段 . 例如这是第9 Channels 的新视频,其中Anders Hejlsberg从采访中的36:30开始谈论C#的不变性 . 他给出了冰棒不变性的一个非常好的用例,并解释了这是你目前需要自己实现的 . 听到他的声音听起来是值得考虑更好地支持在未来的C#版本中创建不可变对象图表
Expert to Expert: Anders Hejlsberg - The Future of C#
针对您未解决的特定问题的另外两个选项:
构建自己的反序列化器,可以调用私有属性setter . 虽然在开始时 Build 解串器的努力将更多,但它使事情更清洁 . 编译器将使您甚至不会尝试调用setter,并且类中的代码将更容易阅读 .
在每个接受XElement(或其他一些XML对象模型)的类中放置一个构造函数,并从中填充自己 . 显然,随着课程数量的增加,作为一种解决方案,这很快变得不那么理想 .
有一个抽象类ThingBase,子类MutableThing和ImmutableThing怎么样? ThingBase将包含受保护结构中的所有数据,为字段提供公共只读属性,为其结构提供受保护的只读属性 . 它还将提供可重写的AsImmutable方法,该方法将返回ImmutableThing .
MutableThing将使用读/写属性遮蔽属性,并提供默认构造函数和接受ThingBase的构造函数 .
不可变的东西将是一个密封的类,它会覆盖AsImmutable以简单地返回它自己 . 它还将提供一个接受ThingBase的构造函数 .
我不喜欢能够将对象从可变状态更改为不可变状态的想法,这种想法似乎打败了我的设计点 . 你什么时候需要这样做?只有代表VALUES的对象才应该是不可变的
您可以使用可选的命名参数和nullables来创建一个具有非常少的样板的不可变setter . 如果你真的想将属性设置为null,那么你可能会遇到更多麻烦 .
你会像这样使用它