c# - ReadOnlyCollection vs Liskov-Cómo modelar correctamente las representaciones inmutables de una colección mutable
immutability directed-acyclic-graphs (6)
La forma en que me gusta (pero tal vez sea solo yo), es tener los métodos de lectura en una interfaz y los métodos de edición en la misma clase. Para su DAG, es muy poco probable que tenga múltiples implementaciones de la estructura de datos, por lo que tener una interfaz para editar el gráfico es una especie de exageración y, por lo general, no es muy bonito.
Me parece que la clase que representa la estructura de datos y la interfaz son bastante limpias.
por ejemplo:
public interface IDAG<out T>
{
public int NodeCount { get; }
public bool AreConnected(int from, int to);
public T GetItem(int node);
}
public class DAG<T> : IDAG<T>
{
public void SetCount(...) {...}
public void SetEdge(...) {...}
public int NodeCount { get {...} }
public bool AreConnected(...) {...}
public T GetItem(...) {...}
}
Luego, cuando necesite editar la estructura, pase la clase, si solo necesita la estructura de solo lectura, pase la interfaz. Es un ''solo de lectura'' falso porque siempre se puede lanzar como la clase, pero el de solo lectura nunca es real de todos modos ...
Esto le permite tener una estructura de lectura más compleja. Al igual que en Linq, puede ampliar su estructura de lectura con métodos de extensión definidos en la interfaz. Por ejemplo:
public static class IDAGExtensions
{
public static List<T> FindPathBetween(this IDAG<T> dag, int from, int to)
{
// Use backtracking to determine if a path exists between `from` and `to`
}
public static IDAG<U> Cast<U>(this IDAG<T> dag)
{
// Create a wrapper for the DAG class that casts all T outputs as U
}
}
Esto es extremadamente útil para separar la definición de la estructura de datos de ''lo que puedes hacer con ella''.
La otra cosa que permite esta estructura es establecer el tipo genérico como out T
Eso te permite tener contravarianza de tipos de argumentos.
El principio de la sustitución de Liskov requiere que los subtipos deben satisfacer los contratos de supertipos. En mi entendimiento, esto implicaría que ReadOnlyCollection<T>
viola a Liskov. El contrato de ICollection<T>
expone las operaciones de Add
y Remove
, pero el subtipo de solo lectura no satisface este contrato. Por ejemplo,
IList<object> collection = new List<object>();
collection = new System.Collections.ObjectModel.ReadOnlyCollection<object>(collection);
collection.Add(new object());
-- not supported exception
Claramente hay una necesidad de colecciones inmutables. ¿Hay algo roto en la forma de modelado de .NET? ¿Cuál sería la mejor manera de hacerlo? IEnumerable<T>
hace un buen trabajo al exponer una colección mientras que, al menos, parece ser inmutable. Sin embargo, la semántica es muy diferente, principalmente porque IEnumerable
no expone explícitamente ningún estado.
En mi caso particular, estoy tratando de construir una clase de DAG inmutable para soportar un FSM . Obviamente, necesitaré los métodos AddNode
/ AddEdge
al principio, pero no quiero que sea posible cambiar la máquina de estados una vez que ya se esté ejecutando. Tengo dificultades para representar la similitud entre las representaciones inmutables y mutables del DAG.
En este momento, mi diseño implica usar un DAG Builder por adelantado y luego crear el gráfico inmutable una vez, en cuyo punto ya no es editable. La única interfaz común entre el Constructor y el DAG inmutable concreto es un Accept(IVisitor visitor)
. Me preocupa que esto pueda ser sobre diseñado o demasiado abstracto ante opciones posiblemente más simples. Al mismo tiempo, tengo problemas para aceptar que puedo exponer métodos en la interfaz de mi gráfica que pueden NotSupportedException
la NotSupportedException
si el cliente obtiene una implementación particular. ¿Cuál es la forma correcta de manejar esto?
Las colecciones de solo lectura en .Net no van en contra de LSP.
Parece que te molesta que la colección de solo lectura lance una excepción no compatible si se llama al método add, pero no hay nada excepcional en ello.
Muchas clases representan objetos de dominio que pueden estar en uno de varios estados y no todas las operaciones son válidas en todos los estados: las secuencias solo se pueden abrir una vez, las ventanas no se pueden mostrar después de eliminarlas, etc.
Lanzar excepciones en esos casos es válido siempre que haya una manera de probar el estado actual y evitar las excepciones.
Las colecciones .Net fueron diseñadas para soportar los estados: solo lectura y lectura / escritura. Es por eso que el método IsReadWrite está presente. Permite a las personas que llaman probar el estado de la colección y evitar excepciones.
LSP requiere que los subtipos cumplan con el contrato del súper tipo, pero un contrato es más que una simple lista de métodos; es una lista de entradas y comportamiento esperado basado en el estado del objeto:
"Si me da esta información, cuando esté en este estado, espere que esto suceda".
ReadOnlyCollection respeta completamente el contrato de ICollection al lanzar una excepción no compatible cuando el estado de la colección es de solo lectura. Vea la sección de excepciones en la documentación de ICollection .
Me gusta la idea de diseñar mis estructuras de datos inmutables en primer lugar. A veces simplemente no es factible, pero hay una manera de lograr esto con bastante frecuencia.
Para su DAG, lo más probable es que tenga alguna estructura de datos en un archivo o una interfaz de usuario y podría pasar todos los nodos y bordes como IEnumerables a su constructor de clase de DAG inmutable. Luego, puede usar los métodos Linq para transformar sus datos de origen en nodos y bordes.
El constructor (o un método de fábrica) puede construir las estructuras privadas de la clase de una manera que sea eficiente para su algoritmo y hacer validaciones de datos por adelantado, como una bicicleta.
Esta solución se distingue del patrón del constructor de una manera que la construcción iterativa de la estructura de datos no es posible, pero a menudo eso no es realmente necesario.
Personalmente, no me gustan las soluciones con interfaces separadas para el acceso de lectura y lectura / escritura implementadas por la misma clase porque la funcionalidad de escritura no está realmente oculta ... la conversión de la instancia a la interfaz de lectura / escritura expone los métodos de mutación. La mejor solución en tal escenario es tener un método AsReadOnly que crea una estructura de datos realmente inmutable copiando los datos.
No creo que su solución actual con el constructor esté sobre-diseñada.
Resuelve dos problemas:
Violación de LSP
Tiene una interfaz editable cuyas implementaciones nunca lanzaránNotSupportedException
s enAddNode
/AddEdge
y tiene una interfaz no editable que no tiene estos métodos en absoluto.Acoplamiento temporal
Si eligiera una interfaz en lugar de dos, esa interfaz tendría que soportar de alguna manera la "fase de inicialización" y la "fase inmutable", probablemente por algunos métodos que marcan el inicio y posiblemente el final de esas fases.
Puede usar implementaciones de interfaz explícitas para separar sus métodos de modificación de las operaciones necesarias en la versión de solo lectura. También en su implementación de solo lectura, tenga un método que tome un método como argumento. Esto le permite aislar su construcción del DAC de la navegación y la consulta. Vea el siguiente código y sus comentarios:
// your read only operations and the
// method that allows for building
public interface IDac<T>
{
IDac<T> Build(Action<IModifiableDac<T>> f);
// other navigation methods
}
// modifiable operations, its still an IDac<T>
public interface IModifiableDac<T> : IDac<T>
{
void AddEdge(T item);
IModifiableDac<T> CreateChildNode();
}
// implementation explicitly implements IModifableDac<T> so
// accidental calling of modification methods won''t happen
// (an explicit cast to IModifiable<T> is required)
public class Dac<T> : IDac<T>, IModifiableDac<T>
{
public IDac<T> Build(Action<IModifiableDac<T>> f)
{
f(this);
return this;
}
void IModifiableDac<T>.AddEdge(T item)
{
throw new NotImplementedException();
}
public IModifiableDac<T> CreateChildNode() {
// crate, add, child and return it
throw new NotImplementedException();
}
public void DoStuff() { }
}
public class DacConsumer
{
public void Foo()
{
var dac = new Dac<int>();
// build your graph
var newDac = dac.Build(m => {
m.AddEdge(1);
var node = m.CreateChildNode();
node.AddEdge(2);
//etc.
});
// now do what ever you want, IDac<T> does not have modification methods
newDac.DoStuff();
}
}
Desde este código, el usuario solo puede llamar a Build(Action<IModifiable<T>> m)
para obtener acceso a una versión modificable. y la llamada al método devuelve una inmutable. No hay forma de acceder a él como IModifiable<T>
sin una IModifiable<T>
explícita intencional, que no está definida en el contrato para su objeto.
Siempre podría tener una interfaz gráfica (de solo lectura), y extenderla con una interfaz gráfica de lectura / escritura modificable:
public interface IDirectedAcyclicGraph
{
int GetNodeCount();
bool GetConnected(int from, int to);
}
public interface IModifiableDAG : IDirectedAcyclicGraph
{
void SetNodeCount(int nodeCount);
void SetConnected(int from, int to, bool connected);
}
(No puedo averiguar cómo dividir estos métodos en get
/ set
mitades de una propiedad).
// Rubbish implementation
public class ConcreteModifiableDAG : IModifiableDAG
{
private int nodeCount;
private Dictionary<int, Dictionary<int, bool>> connections;
public void SetNodeCount(int nodeCount) {
this.nodeCount = nodeCount;
}
public void SetConnected(int from, int to, bool connected) {
connections[from][to] = connected;
}
public int GetNodeCount() {
return nodeCount;
}
public bool GetConnected(int from, int to) {
return connections[from][to];
}
}
// Create graph
IModifiableDAG mdag = new ConcreteModifiableDAG();
mdag.SetNodeCount(5);
mdag.SetConnected(1, 5, true);
// Pass fixed graph
IDirectedAcyclicGraph dag = (IDirectedAcyclicGraph)mdag;
dag.SetNodeCount(5); // Doesn''t exist
dag.SetConnected(1, 5, true); // Doesn''t exist
Esto es lo que me gustaría que Microsoft hubiera hecho con sus clases de colección de solo lectura: crearon una interfaz para el cómputo de obtención, índice de obtención por índice, etc., y la extendieron con una interfaz para agregar, cambiar valores, etc.