可变类型的不可变视图

7
我有一个项目,需要在执行进程之前构建大量的配置数据。在配置阶段,将数据作为可变数据非常方便。但是,一旦配置完成,我希望将数据的不可变视图传递给功能过程,因为该过程将依赖于配置的不可变性进行许多计算(例如,基于初始配置预先计算事物的能力)。我已经提出了使用接口来公开只读视图的可能解决方案,但我想知道是否有人遇到了这种方法的问题,或者是否有其他建议来解决这个问题。
我目前正在使用的模式的一个示例:
public interface IConfiguration
{
    string Version { get; }

    string VersionTag { get; }

    IEnumerable<IDeviceDescriptor> Devices { get; }

    IEnumerable<ICommandDescriptor> Commands { get; }
}

[DataContract]
public sealed class Configuration : IConfiguration
{
    [DataMember]
    public string Version { get; set; }

    [DataMember]
    public string VersionTag { get; set; }

    [DataMember]
    public List<DeviceDescriptor> Devices { get; private set; }

    [DataMember]
    public List<CommandDescriptor> Commands { get; private set; }

    IEnumerable<IDeviceDescriptor> IConfiguration.Devices
    {
        get { return Devices.Cast<IDeviceDescriptor>(); }
    }

    IEnumerable<ICommandDescriptor> IConfiguration.Commands
    {
        get { return Commands.Cast<ICommandDescriptor>(); }
    }

    public Configuration()
    {
        Devices = new List<DeviceDescriptor>();
        Commands = new List<CommandDescriptor>();
    }
}

编辑

根据Lippert先生和cdhowie的意见,我整理了以下内容(为简化起见,删除了一些属性):

[DataContract]
public sealed class Configuration
{
    private const string InstanceFrozen = "Instance is frozen";

    private Data _data = new Data();
    private bool _frozen;

    [DataMember]
    public string Version
    {
        get { return _data.Version; }
        set
        {
            if (_frozen) throw new InvalidOperationException(InstanceFrozen);
            _data.Version = value;
        }
    }

    [DataMember]
    public IList<DeviceDescriptor> Devices
    {
        get { return _data.Devices; }
        private set { _data.Devices.AddRange(value); }
    }

    public IConfiguration Freeze()
    {
        if (!_frozen)
        {
            _frozen = true;
            _data.Devices.Freeze();
            foreach (var device in _data.Devices)
                device.Freeze();
        }
        return _data;
    }

    [OnDeserializing]
    private void OnDeserializing(StreamingContext context)
    {
        _data = new Data();
    }

    private sealed class Data : IConfiguration
    {
        private readonly FreezableList<DeviceDescriptor> _devices = new FreezableList<DeviceDescriptor>();

        public string Version { get; set; }

        public FreezableList<DeviceDescriptor> Devices
        {
            get { return _devices; }
        }

        IEnumerable<IDeviceDescriptor> IConfiguration.Devices
        {
            get { return _devices.Select(d => d.Freeze()); }
        }
    }
}

FreezableList<T>IList<T>的可冻结实现,正如你所期望的那样。这样做可以获得隔离效益,但代价是增加了一些复杂性。


你真的需要将 Configuration 设为公共访问吗? - Vlad
@Vlad,公开支持一个独立的配置编辑器项目。 - Dan Bryant
5个回答

13
你所描述的方法非常适用于“客户端”(接口消费者)和“服务器”(类提供者)之间有相互约定:
  • 客户端会礼貌地使用该接口,不试图利用服务器的实现细节。
  • 服务器会礼貌地避免在客户端引用对象后对其进行更改。
如果编写客户端和服务器端的人之间没有良好的合作关系,则情况会迅速恶化。无礼的客户端当然可以通过将对象转换为公共配置类型来“抛弃”不可变性。无礼的服务器可能会分发一个不可变视图,并在客户端最不希望时更改对象。
一种很好的方法是防止客户端看到可变类型。
public interface IReadOnly { ... }
public abstract class Frobber : IReadOnly
{
    private Frobber() {}
    public class sealed FrobBuilder
    {
        private bool valid = true;
        private RealFrobber real = new RealFrobber();
        public void Mutate(...) { if (!valid) throw ... }
        public IReadOnly Complete { valid = false; return real; }
    }
    private sealed class RealFrobber : Frobber { ... }
}

如果你想创建和修改一个Frobber对象,你可以使用Frobber.FrobBuilder。完成修改后,调用Complete方法将得到一个只读接口。(此时FrobBuilder对象将无效)。由于所有可变性的实现细节都被隐藏在私有嵌套类中,你不能将IReadOnly接口转换为RealFrobber,只能转换为没有公共方法的Frobber!

恶意客户端也无法创建自己的Frobber,因为Frobber是抽象的且拥有私有构造函数。唯一创建Frobber的方式是通过该建造者。


我喜欢这个解决方案,尽管它让变异有些笨拙。目前,应用程序的客户端和服务器部分都由我负责,但我可以看到在维护工程师决定方便地"弃置"不变性或忘记服务器在交接后不应修改配置时可能出现的问题。我对这种方法的主要关注点是,我与之合作过的一些初级开发人员会很难理解这种结构。 - Dan Bryant
我认为你的意思是让RealFrobber实现IReadOnly,而不是Frobber?否则,Frobber必须具有公共方法或至少显式接口实现。我不得不将其标记为解决方案,仅仅是为了完整性和谨慎;'dummy'外部类意味着即使在同一程序集中的其他类也无法违反不可变性契约。 - Dan Bryant
顺便说一句,你不能完全摆脱Frobber,只需将RealFrobber作为FrobBuilder的嵌套私有类即可。 - Dan Bryant
@Dan:是的,有很多种方法可以做到这一点。我建议的方式只是其中一种可能性。这种模式的常见用途是让构建器能够构建许多不同特定类型的Frobbers,它们都是实现细节,并且都实现了基础类型的所有接口,但你不必这样做。 - Eric Lippert
@Dan Bryant:另一种设计是使用抽象类型FrobBase,该类型具有所需的所有只读属性,并带有一个密封的子类ImmutableFrob和一个可能未密封的类MutableFrob。ImmutableFrob应该有一个构造函数,它从FrobBase复制其数据。想要知道对象是不变的代码可以使用ImmutableFrob类型的对象。想要改变对象的代码将使用MutableFrob。不会自己改变对象,但不关心其他东西可能会改变对象的代码将使用FrobBase。 - supercat

3
这样做是可行的,但是“恶意”方法可能会尝试将转换为,从而绕过您强制实施的接口限制。如果您不担心这个问题,那么您的方法将很好地工作。
我通常会像这样做:
public class Foo {
    private bool frozen = false;

    private string something;

    public string Something {
        get { return something; }
        set {
            if (frozen)
                throw new InvalidOperationException("Object is frozen.");

            // validate value

            something = value;
        }
    }

    public void Freeze() {
        frozen = true;
    }
}

或者,您可以将可变类深度克隆为不可变类。


1
嗯,一个非常恶意的客户端甚至可以通过反射将“frozen”的值还原。 - Vlad
4
@Vlad: cdhowie是正确的。假设具有完全信任的恶意代码的攻击并不是有趣的攻击。具有完全信任的代码不必使用反射来更改“frozen”的值;它可以使用不安全的代码获取指向内存的指针,直接破坏它想要更改的位。具有完全信任的代码可以启动调试器,附加到进程中,暂停所有线程,完全重写内部内存结构,然后恢复线程。完全信任的代码可以做任何你作为机主能够做的事情。 - Eric Lippert
@cdhowie,这与我想要的非常接近。 它与Eric的方法类似,有些棘手,因为它防止使用自动属性(所有可变属性都必须进行冻结检查)。 它也只在运行时捕获使用故障(客户端必须知道不要尝试写入属性,因为配置将被冻结)。 - Dan Bryant
1
@Dan:并没有说你不能同时使用接口。 客户端就不会看到设置访问器,但是在接口周围进行转换将不会产生更多的访问权限;他们只会被异常消息所招呼。 - cdhowie
@cdhowie,是的,那是真的。我需要做一些额外的工作来更好地隔离集合(这本来就应该做)。你的解决方案比Eric的简单,但他的有一个额外的好处,即在首先冻结实例之前无法获取接口。 - Dan Bryant
显示剩余4条评论

2
为什么不能提供一个单独的不可变视图对象?
public class ImmutableConfiguration {
    private Configuration _config;
    public ImmutableConfiguration(Configuration config) { _config = config; }
    public string Version { get { return _config.Version; } }
}

如果您不喜欢额外的输入,可以将集合成员设置为内部而不是公共的 - 可以在程序集内部访问,但不能被其客户端访问?


不可变包装器是一个好的方法,有很多很好的例子可以使用(特别是ReadOnlyCollection)。这也比Eric的方法更容易理解,但它只能防止客户端执行强制转换(“粗鲁”的服务器仍然可以在传递视图后改变Configuration)。 - Dan Bryant

1

我经常使用一个基于COM的大型框架(ESRI的ArcGIS Engine),在某些情况下它处理修改的方式非常相似:有用于只读访问的“默认”IFoo接口,以及用于修改的IFooEdit接口(如果适用)。

这个框架相当有名,我没有听说过任何关于这个特定设计决策的普遍抱怨。

最后,我认为在决定哪个“视角”成为默认视角时,值得再考虑一下:只读视角还是完全访问视角。我个人会将只读视图设为默认。


0

这样怎么样:

struct Readonly<T>
{
    private T _value;
    private bool _hasValue;

    public T Value
    {
        get
        {
            if (!_hasValue)
                throw new InvalidOperationException();
            return _value;
        }
        set
        {
            if (_hasValue)
                throw new InvalidOperationException();
            _value = value;
        }
    }
}


[DataContract]
public sealed class Configuration
{
    private Readonly<string> _version;

    [DataMember]
    public string Version
    {
        get { return _version.Value; }
        set { _version.Value = value; }
    }
}

我把它叫做只读,但我不确定这是否是最好的名称。


这种方法可以在运行时通过抛出异常来强制实现不可变性,但是它使得在编译时检测对象是否真正不可变变得困难。开发人员(以及最终用户,如果错误的使用代码被发布)只有在运行时才能发现,假设可变性的代码开发将永远无法按预期工作。此外,我会称其为“SetOnce”,而不是“ReadOnly”,因为这更准确地描述了它的机制。 - James Dunne
没有不变结构类型这样的东西;在你的例子中,任何可以访问 _version.Value 并想随意重写它的代码都可以说 _version = new ReadOnly<string>(); _version.Value = whatever;。即使为 ReadOnly<T> 结构添加参数化构造函数并且不提供其他的可变方法,一个类型为 ReadOnly<T> 的字段提供的保护也不会比类型为 T 的字段多。 - supercat

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接