Tener un constructor mínimo mientras se aseguran invariantes

7

Recientemente me topé con un código que se veía mal. Lo edité solo para ver que mis cambios rompieron el código. Intenté estructurar el código de otras maneras para que el código se viera y fuera correcto, pero no estaba satisfecho con ninguna alternativa que pudiera encontrar.

La lógica del código es la siguiente:

  • Un Path se compone de cero o más Segment s.
  • Un Segment contiene:
    • una o más propiedades (aquí solo una por brevedad), y
    • una referencia al Path en que se encuentra.
  • Invariantes que deben mantenerse:
    • La referencia Path en un Segment siempre se inicializa.
    • Un Segment es un elemento de algunos Path 's Segments .
    • La referencia Part en un Segment hace referencia al Part , que Segments contiene el Segment .

Aquí está el código que miré, cuando pensé que había encontrado un error:

public class Path
{
    public List<Segment> Segments { get; } = new List<Segment>();
}

public class Segment
{
    public int Property { get; }

    public Path Path { get; }

    ...
}

public static class Test
{
    public static Path CreatePath()
    {
        var path = new Path();
        var segment = new Segment(path, 42);
        return path;
    }
}

Al solo vislumbrar CreatePath y sin inspeccionar más, llegué a la conclusión de que claramente había un error, ya que segment no se agregó a path.Segments y, por lo tanto, viola un invariante.

Aparentemente me equivoqué porque el constructor de Segment hizo más trabajo del que esperaba. El constructor real se veía así:

public Segment(Path path, int property)
{
    Property = property;
    Path = path;

    path.Segments.Add(this); // <-- 
}

Aunque reconozco la intención del constructor de garantizar los invariantes, no me gustó la forma en que se hizo principalmente por dos razones:

  • El constructor hace más trabajo que validando argumentos e inicializando campos.
  • CreatePath solo se ve correcto cuando conoce las partes internas del constructor.

Esto, para mí, rompe el Principio del menor asombro , al menos me sorprendió.

Traté de encontrar formas alternativas para lograr esto, pero no estoy del todo satisfecho con ninguna de ellas.

El código original donde se asegura la consistencia dentro del constructor de Segment . El problema es que segment no se usa en CreatePath si no conoce los aspectos internos del constructor.

namespace PathExample0
{
    public class Path
    {
        public List<Segment> Segments { get; } = new List<Segment>();
    }

    public class Segment
    {
        public int Property { get; }

        public Path Path { get; }

        public Segment(Path path, int property)
        {
            Property = property;
            Path = path;

            path.Segments.Add(this);
        }
    }

    public static class Test
    {
        public static Path CreatePath()
        {
            var path = new Path();
            var segment = new Segment(path, 42);
            return path;
        }
    }
}

Esta primera alternativa saca la parte de consistencia del constructor y en el CreatePath . El constructor ahora está libre de sorpresas y segment se usa claramente en CreatePath . El problema menor ahora es que no se garantiza que se agregue segment a Segments del Path correcto.

namespace PathExample1
{
    public class Path
    {
        public List<Segment> Segments { get; } = new List<Segment>();
    }

    public class Segment
    {
        public int Property { get; }

        public Path Path { get; }

        public Segment(Path path, int property)
        {
            Property = property;
            Path = path;
        }
    }

    public static class Test
    {
        public static Path CreatePath()
        {
            var path = new Path();
            var segment = new Segment(path, 42);
            path.Segments.Add(segment);
            return path;
        }
    }
}

Mi segundo intento Agrega un AddSegment a Path que garantiza la consistencia. Esto todavía no le impide llamar a new Segment(somePath, 42) y romper la consistencia.

namespace PathExample2
{
    public class Path
    {
        private readonly List<Segment> segments = new List<Segment>();

        public IReadOnlyList<Segment> Segments => segments;

        public void AddSegment(int property)
        {
            var segment = new Segment(this, property);
            segments.Add(segment);
        }
    }

    public class Segment
    {
        public int Property { get; }

        public Path Path { get; }

        public Segment(Path path, int property)
        {
            Property = property;
            Path = path;
        }
    }

    public static class Test
    {
        public static Path CreatePath()
        {
            var path = new Path();
            path.AddSegment(42);
            return path;
        }
    }
}

El tercer y último ejemplo que podría crear con las interfaces out Segment en ISegment y convierte a Segment en una clase privada a Path . Esto ahora debería garantizar la plena consistencia entre un Path y su Segment s. Para mí, es como un enfoque engorroso y casi como un mal uso de las interfaces para ocultar el constructor de Segment .

namespace PathExample3
{
    public interface ISegment
    {
        Path Path { get; }

        int Property { get; }
    }

    public class Path
    {
        private readonly List<Segment> segments = new List<Segment>();

        public IReadOnlyList<ISegment> Segments => segments;

        public void AddSegment(int property)
        {
            var segment = new Segment(this, property);
            segments.Add(segment);
        }

        private class Segment : ISegment
        {
            public int Property { get; }

            public Path Path { get; }

            public Segment(Path path, int property)
            {
                Property = property;
                Path = path;
            }
        }
    }

    public static class Test
    {
        public static Path CreatePath()
        {
            var path = new Path();
            path.AddSegment(42);
            return path;
        }
    }
}

Entonces, mi pregunta es cómo estructurar el código de una manera, para asegurar las invariantes mientras se conserva el código C # idiomático. ¿Quizás me esté perdiendo un patrón de diseño conocido para lograr esto?

    
pregunta Jonas Nyrup 16.11.2017 - 14:35

4 respuestas

10

Una forma de evitar el uso de un constructor engañoso es hacerlo privado y en su lugar proporcionar un método de fábrica estático que indica lo que hace. También le permite mantener limpio el constructor. (No diría que no tiene efecto secundario . Una función que cambia se dice que algunos estados, además de devolver un valor, tienen un efecto secundario. Dado que los constructores no son funciones y no pueden devolver ningún valor, todo lo que pueden hacer es producir efectos secundarios. Cambian el estado del objeto de no inicializado a inicializado.)

public class Segment
{
    private Segment(Path path, int property)
    {
        Path = path;
        Property = property;
    }

    public int Property { get; }
    public Path Path { get; }

    public static Segment AddToPath(Path path, int property)
    {
        var segment = new Segment(path, property);
        path.Segments.Add(segment);
        return segment;
    }
}

Puedes usar este método de fábrica estático en la clase Path

public class Path
{
    private readonly List<Segment> segments = new List<Segment>();

    public IReadOnlyList<Segment> Segments => segments;

    public Segment AddSegment(int property)
    {
        return Segment.AddToPath(this, property);
    }
}

Ahora ya no es posible crear un segmento sin agregarlo a una ruta y sin establecer la referencia a su ruta.

    
respondido por el Olivier Jacot-Descombes 16.11.2017 - 19:19
7

No está claro por qué el segmento tiene que hacer referencia a una ruta. No me parece una idea particularmente buena. Si se puede evitar, para empezar, eso es lo que me gustaría refactorizar. El problema que tiene es el resultado directo de tener una dependencia circular, lo que (en mi experiencia) nunca es bueno. Me temo que no hay una manera bonita de resolver este problema, a menos que elimine la causa.

Dicho esto, si tiene que mantener la referencia Path por w / e razón, creo que su última opción es la mejor. Sin embargo, no veo por qué necesitarías una interfaz. Siempre que AddSegment(int property) sea la única forma de agregar un segmento a la ruta, no importa si la clase Segment es pública o no. La clase Path se mantendrá consistente. Sin embargo, probablemente agregaría una sobrecarga que tome un segmento:

public class Path
{
    private readonly List<Segment> segments = new List<Segment>();

    public IReadOnlyList<Segment> Segments => segments;

    public void AddSegment(int property)
    {
        AddSegment(new Segment(property));
    }

    public void AddSegment(Segment segment)
    {
        if (segment.Path != null) throw ...; 
        segment.Path = segment;
        segments.Add(segment);
    }
}

public class Segment
{
    public int Property { get; }

    public Path Path { get; internal set; }

    public Segment(int property)
    {
        Property = property;
    }
}
    
respondido por el Nikita B 16.11.2017 - 15:41
1

Puede imponer consistencia hasta cierto punto al hacer que el constructor del segmento sea interno y colocar las clases de ruta y segmento en una DLL diferente a la del código de la aplicación principal:

// In one DLL
public class Path
{
    private List<Segment> segments;

    public IEnumerable<Segment> Segments => segments;

    public Segment AddSegment(int property)
    {
        var segment = new Segment(this, property);

        segments.Add(segment);

        return segment;
    }
}

public class Segment
{
    public int Property { get; private set; }
    public Path Path { get; private set; }

    internal Segment(Path path, int property)
    {
        Path = path ?? throw new ArgumentNullException(nameof(path));
        Property = property;
    }
}

Luego use la clase Path en otro archivo DLL:

// In another DLL

var path = new Path();
var segment1 = path.AddSegment(42);   // Compiles
var segment2 = new Segment(path, 56); // Does not compile. Constructor is internal

Debido a que el constructor del segmento está marcado como internal , debe pasar por el método Path.AddSegment para crear nuevos objetos de segmento.

El constructor de segmentos está libre de efectos secundarios, porque el método Path.AddSegment agrega el nuevo segmento a la colección de la ruta.

    
respondido por el Greg Burghardt 16.11.2017 - 21:15
0

Si creamos Segment absolutamente, se debe hacer referencia a Path , esto refleja algunos requisitos comerciales, y parece que este requisito constituye una invariante de Segment . Y los objetos deben crearse siempre válido , con todas las invariantes aseguradas. Dado que el código ejecutado mientras que la creación del objeto reside en el constructor, es un lugar muy lógico para hacer cumplir sus invariantes.

Y estoy de acuerdo con Nikita, estoy seguro de que podrías evitar esa dependencia circular. Se considera un mala práctica , al menos en diseño controlado por dominio.

    
respondido por el Zapadlo 07.12.2017 - 13:07

Lea otras preguntas en las etiquetas