Avere un costruttore minimo pur garantendo invarianti

7

Recentemente sono incappato in un codice che sembrava sbagliato. L'ho modificato solo per vedere che le mie modifiche hanno infranto il codice. Ho cercato di strutturare il codice in diversi altri modi per far sì che il codice fosse e sia corretto, ma non ero soddisfatto di alcuna alternativa che avrei potuto trovare.

La logica del codice è la seguente:

  • Un Path è composto da zero o più Segment s.
  • Un Segment contiene:
    • una o più proprietà (qui solo una per brevità) e
    • un riferimento al Path in cui è attivo.
  • Invarianti che devono essere rispettati:
    • Il riferimento Path su Segment è sempre inizializzato.
    • Un Segment è un elemento di alcuni Path 's Segments .
    • Il riferimento Part su Segment fa riferimento a Part , che Segments contiene Segment .

Ecco il codice che ho visto, quando pensavo di aver trovato un bug:

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;
    }
}

Semplicemente intravvedendo a CreatePath e senza ispezionare ulteriormente ho concluso che c'era chiaramente un bug, dal momento che segment non è stato aggiunto a path.Segments e quindi violando un'invarianza.

A quanto pare ho sbagliato perché il costruttore di Segment ha fatto più lavoro di quanto mi aspettassi. Il costruttore attuale assomigliava a questo:

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

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

Pur riconoscendo l'intento del costruttore di garantire gli invarianti, non mi è piaciuto il modo in cui è stato fatto principalmente per due motivi:

  • Il costruttore fa più lavoro che convalidare argomenti e inizializzare i campi.
  • CreatePath sembra corretto solo quando conosci i componenti interni del costruttore.

Questo per me rompe il principio di Almost Astonishment - almeno ero sbalordito.

Ho provato a trovare modi alternativi per raggiungere questo obiettivo, ma non sono completamente soddisfatto di nessuno di essi.

Il codice originale in cui la coerenza è garantita all'interno del costruttore di Segment . Il problema è che segment sembra inutilizzato in CreatePath se non conosci i componenti interni del costruttore.

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;
        }
    }
}

Questa prima alternativa estrae la parte di consistenza del costruttore e in CreatePath . Il costruttore è ora privo di sorprese e segment è chiaramente utilizzato in CreatePath . Il problema minore è che ora non è garantito che segment sia aggiunto a Segments del giusto Path .

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;
        }
    }
}

Il mio secondo tentativo Aggiunge un AddSegment a Path che garantisce la coerenza. Questo ancora non ti impedisce di chiamare new Segment(somePath, 42) e di rompere la consistenza.

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;
        }
    }
}

Il terzo e ultimo esempio potrei trovare le interfacce out Segment in ISegment e rende Segment una classe privata a Path . Questo dovrebbe ora garantire la piena coerenza tra un Path e il suo Segment s. Per me sembra un approccio ingombrante e quasi un uso improprio delle interfacce per nascondere il costruttore di 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;
        }
    }
}

Quindi la mia domanda è come strutturare il codice in un modo, per garantire gli invarianti pur mantenendo il codice C # idiomatico. Forse mi sto perdendo un modello di design ben noto per realizzare questo?

    
posta Jonas Nyrup 16.11.2017 - 14:35
fonte

4 risposte

10

Un modo per evitare l'uso di un costruttore fuorviante è renderlo privato e invece fornire un metodo factory static che indica cosa fa. Ti consente anche di mantenere pulito il costruttore. (Non direi che non ha nessun effetto collaterale . Una funzione che cambia si dice che alcuni stati oltre a restituire un valore abbiano un effetto collaterale, poiché i costruttori non sono funzioni e non possono restituire alcun valore, produrre effetti collaterali è tutto ciò che possono fare: cambiano lo stato dell'oggetto da non inizializzato a inizializzato.)

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;
    }
}

Puoi utilizzare questo metodo factory statico nella classe 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);
    }
}

Ora non è più possibile creare un segmento senza aggiungerlo a un percorso e senza impostare il riferimento al suo percorso.

    
risposta data 16.11.2017 - 19:19
fonte
7

Non è chiaro il motivo per cui il segmento deve fare riferimento a un percorso. Non mi sembra particolarmente buona idea. Se può essere evitato - questo è ciò che vorrei rifattore per cominciare. Il problema che stai avendo è il risultato diretto di una dipendenza circolare, che (nella mia esperienza) non è mai una buona cosa. Temo che non ci sia un modo carino per risolvere questo problema, a meno che non elimini la causa.

Detto questo, se devi mantenere il riferimento Path per il motivo w / e, penso che la tua ultima opzione sia la migliore. Non capisco proprio perché avresti bisogno di un'interfaccia però. Finché AddSegment(int property) è l'unico modo per aggiungere un segmento al percorso, non importa se la classe Segment è pubblica o meno. La classe Path rimarrà coerente. Tuttavia, probabilmente aggiungerei un sovraccarico che accetta 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;
    }
}
    
risposta data 16.11.2017 - 15:41
fonte
1

È possibile rafforzare la coerenza fino a un certo grado rendendo il costruttore Segment interno e inserendo le classi Path e Segment in una DLL diversa rispetto al codice dell'applicazione principale:

// 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;
    }
}

Quindi usa la classe Path in un altro file 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

Poiché il costruttore Segment è contrassegnato come internal , devi passare attraverso il metodo Path.AddSegment per creare nuovi oggetti Segment.

Il costruttore di segmenti è privo di effetti collaterali, perché il metodo Path.AddSegment aggiunge il nuovo segmento alla raccolta Path.

    
risposta data 16.11.2017 - 21:15
fonte
0

Se creato Segment deve assolutamente essere referenziato a Path , riflette alcuni requisiti aziendali e sembra che questo requisito costituisca un'invarianza di Segment . E gli oggetti dovrebbero essere creati sempre validi , con tutti gli invarianti garantiti. Poiché il codice eseguito mentre la creazione dell'oggetto risiede nel costruttore, è un posto molto logico per far rispettare i suoi invarianti.

E sono d'accordo con Nikita, sono sicuro che potresti evitare quella dipendenza circolare. È considerato un cattiva pratica , almeno nella progettazione basata sul dominio.

    
risposta data 07.12.2017 - 13:07
fonte

Leggi altre domande sui tag