c# - Vistas inmutables de tipos mutables
.net interface (5)
Tengo un proyecto en el que necesito construir una buena cantidad de datos de configuración antes de poder ejecutar un proceso. Durante la etapa de configuración, es muy conveniente tener los datos como mutables. Sin embargo, una vez que se ha completado la configuración, me gustaría pasar una vista inmutable de esa información al proceso funcional, ya que ese proceso dependerá de la inmutabilidad de la configuración para muchos de sus cálculos (por ejemplo, la capacidad de precomputar cosas basadas en en la configuración inicial.) He encontrado una posible solución utilizando interfaces para exponer una vista de solo lectura, pero me gustaría saber si alguien ha tenido problemas con este tipo de enfoque o si hay otras recomendaciones sobre cómo resuelve este problema.
Un ejemplo del patrón que estoy usando actualmente:
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>();
}
}
EDITAR
Basándome en los comentarios del Sr. Lippert y cdhowie, reuní lo siguiente (eliminé algunas propiedades para simplificar):
[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>
es, como era de esperar, una implementación freezable de IList<T>
. Esto gana beneficios de aislamiento, a costa de alguna complejidad adicional.
¿Por qué no puedes proporcionar una vista inmutable separada del objeto?
public class ImmutableConfiguration {
private Configuration _config;
public ImmutableConfiguration(Configuration config) { _config = config; }
public string Version { get { return _config.Version; } }
}
o si no le gusta el tipeo extra, haga que los miembros del conjunto sean internos en lugar de públicos, accesibles dentro del ensamblado pero no por los clientes del mismo.
El enfoque que describes funciona muy bien si el "cliente" (el consumidor de la interfaz) y el "servidor" (el proveedor de la clase) tienen un acuerdo mutuo que:
- el cliente será amable y no intentará aprovechar los detalles de implementación del servidor
- el servidor será cortés y no mutará el objeto después de que el cliente tenga una referencia al mismo.
Si no tiene una buena relación de trabajo entre las personas que escriben el cliente y las personas que escriben el servidor, las cosas se ponen en forma de pera rápidamente. Un cliente grosero puede, por supuesto, "descartar" la inmutabilidad mediante conversión al tipo de configuración pública. Un servidor rudo puede repartir una vista inmutable y luego mutar el objeto cuando el cliente menos lo espera.
Un buen enfoque es evitar que el cliente vea el tipo mutable:
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 { ... }
}
Ahora, si quiere crear y mutar un Frobber, puede hacer un Frobber.FrobBuilder. Cuando haya terminado sus mutaciones, llame a Complete y obtenga una interfaz de solo lectura. (Y luego el generador se vuelve inválido). Dado que todos los detalles de implementación de mutabilidad están ocultos en una clase privada anidada, no se puede "descartar" la interfaz IReadOnly a RealFrobber, solo a Frobber, que no tiene métodos públicos.
El cliente hostil tampoco puede crear su propio Frobber, porque Frobber es abstracto y tiene un constructor privado. La única forma de hacer un Frobber es a través del constructor.
Esto funcionará, pero los métodos "maliciosos" pueden intentar IConfiguration
una IConfiguration
a una Configuration
y de ese modo eludir las restricciones impuestas por la interfaz. Si no te preocupa eso, tu enfoque funcionará bien.
Normalmente hago algo como esto:
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;
}
}
Alternativamente, puedes clonar profundamente tus clases mutables en clases inmutables.
Qué tal si:
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; }
}
}
Lo llamé Readonly, pero no estoy seguro de que sea el mejor nombre.
Trabajo regularmente con un marco grande basado en COM (ArcGIS Engine de ESRI) que maneja las modificaciones de manera muy similar en algunas situaciones: existen las interfaces IFoo
"predeterminadas" para el acceso de solo lectura, y IFooEdit
interfaces IFooEdit
(donde corresponda) para modificaciones .
Ese marco es bastante conocido, y no conozco ninguna queja generalizada sobre esta decisión de diseño en particular.
Finalmente, creo que definitivamente vale la pena pensar algo más para decidir qué "perspectiva" es la opción predeterminada: la perspectiva de solo lectura o la de acceso completo. Personalmente, haré que la vista de solo lectura sea la predeterminada.