Su materiali di consumo

0

Data questa classe astratta:

public abstract class File
{
    public abstract string Name { get; set; }
    public abstract void Add(File newFile);
}

È possibile generare la base di un composito:

    public class LogFile : File
    {
        private string _name;

        public override string Name
        {
            get
            {
                return _name;
            }
            set
            {
                _name = value;
            }
        }

        public override void Add(File newFile)
        {
            throw new NotImplementedException();
        }
    }

    public class LogFiles : File
    {
        private IList<File> _files = new List<File>();

        public override string Name
        {
            get
            {
                throw new NotImplementedException();
            }
            set
            {
                throw new NotImplementedException();
            }
        }

        public override void Add(File newFile)
        {
            _files.Add(newFile);
        }
    }

Appena creato, viene generato un codice di eccezione. Il più delle volte quando scrivi il codice iniziale, questo a portata di mano. Passando dalla classe di raccolta alla classe dell'oggetto (che è principalmente ciò di cui mi occupo) puoi facilmente dimenticare quale tipo di oggetto hai a che fare. Tanto più che le variabili vengono in genere denominate usando il nome astratto della classe:

    File thisFile = new LogFile {Name = "Monday.log"};

    File thatFile = new LogFiles();
    thatFile.Add(new LogFile() { Name = "Tuesday.log" });
    thatFile.Add(new LogFile() { Name = "Wednesday.log" });
    thatFile.Name = "My files"; // Runtime error

Tuttavia, se questo codice viene lasciato in, quando questi oggetti sono consumati a monte (come compositi in buona fede), ci possono essere problemi quando viene utilizzato il costruttore senza parametri e una proprietà non implementata genera un'eccezione. Un esempio comune sono le varie forme di serializzazione:

static void SerializeThis(File fileSet)
{
    var json = new JavaScriptSerializer().Serialize(fileSet);                  
}

In questo caso, il codice può essere semplicemente rifattorizzato per rimuovere le eccezioni:

    public class LogFile : File
    {
        private string _name;

        public override string Name
        {
            get
            {
                return _name;
            }
            set
            {
                _name = value;
            }
        }

        public override void Add(File newFile)
        {            
        }
    }

    public class LogFiles : File
    {
        private IList<File> _files = new List<File>();

        public override string Name 
        {
            get
            {
                return string.Empty;
            }

            set
            {   

            }
        }

        public override void Add(File newFile)
        {
            _files.Add(newFile);
        }
    }

Ma questo rimuove le misure di salvaguardia per le classi derivate e lascia il codice un po 'crufty. Mi sembra una dicotomia tra la ragion d'essere di un composito: "Non mi importa se sono un oggetto o una collezione di oggetti" e mi assicuro che ogni classe derivata sia usata correttamente (come una classe derivata) .

Non posso fare a meno di pensare che mi manca qualcosa di fondamentale qui - qualche pensiero?

Modifica

Per inciso, alcuni strumenti di codifica come ReSharper implementano le proprietà automatiche piuttosto che getter e setter contenenti eccezioni. Anche se questo non è completamente infallibile, è sicuramente un approccio migliore.

    
posta Robbie Dee 23.11.2017 - 13:39
fonte

2 risposte

1

Il nocciolo del problema sembra essere la mancanza di chiarezza in come il composito è consumato in ogni caso. Allo stato attuale, l'oggetto e la classe di raccolta hanno ruoli chiaramente definiti ma sono composti in nome (ad esempio la loro definizione). In quanto tale, questo richiede un po 'di pesante sollevamento da parte del consumatore.

Le acque verrebbero ulteriormente confuse facendo riferimento all'oggetto e alla raccolta per nome della classe. È necessario utilizzare un comportamento più sottile per determinare le classi oggetto e raccolta.

Un approccio migliore sarebbe quello di definire un tipo di comportamento predefinito che dovrebbe servire tutti e 3 gli usi: oggetto, raccolta e agnostico.

Un modo possibile è di rendere virtuali i metodi e le proprietà nella classe base. Questo evita il problema spinoso del codice che deve essere scritto per metodi e proprietà in classi derivate che non verranno mai utilizzate (ho aggiunto il getter di AllFiles per completezza):

    public class File
    {
        protected string _name = string.Empty;
        protected IList<File> _allFiles = new List<File>();

        public virtual string Name
        {
            get
            {
                return _name;
            }

            set
            {
                _name = value;
            }
        }

        public virtual void Add(File newFile)
        {
            _allFiles.Add(newFile);
        }

        public virtual IList<File> AllFiles
        {
            get
            {
                return _allFiles;
            }
        }
    }

Questo ha un numero di vantaggi:

In primo luogo, le classi derivate sono più pulite perché contengono solo gli override richiesti.

In secondo luogo, costringe lo sviluppatore a considerare il composito olisticamente in primo piano piuttosto che essere la somma delle sue parti. Questo è particolarmente importante quando il composito viene consumato in altri strati che lo sviluppatore potrebbe avere poco controllo.

In terzo luogo, rimuove semplicemente le congetture su cosa mettere in metodi a cui il consumatore non è interessato - un problema persistente nella soluzione canonica di classe astratta propagandata praticamente ovunque. Questo, in definitiva, è ciò che non mi è mai sembrato giusto sin dal primo momento in cui ho usato lo schema.

    
risposta data 23.11.2017 - 15:41
fonte
6

Il tuo design è difettoso.

Il tuo esempio viola il principio di sostituzione di Liskov (LSP) perché il contratto (la classe astratta File ) afferma implicitamente che puoi ottenere e impostare un nome ( string Name { get; set; } ) e che puoi aggiungere un nuovo file ( void Add(File newFile) ). Eppure la classe LogFiles non consente di ottenere e impostare nomi e la classe LogFile non consente di aggiungere un nuovo file. Non implementano il contratto che affermano di implementare ( : File ). File mescola una semantica di un singolo oggetto con una semantica di raccolta; tuttavia, un'implementazione può fornire l'una o l'altra, ma non entrambe. Dovresti pensare a dividere la classe File in due interfacce distinte invece di usare il pattern composito (vedi Principio di segregazione dell'interfaccia ISP ). Una classe concreta implementerebbe l'una o l'altra. Questo dice anche al consumatore come può usare un oggetto.

var fileObject = GetFileObject();
// Using the new C# 7.0 pattern matching
switch (fileObject)
{
    case ISingleFile singleFile:
        singleFile.Name = file1;
        break;
    case IMultiFile multiFile:
        multiFile.Add(file1);
        multiFile.Add(file2);
        multiFile.Add(file3);
        break;
}

Il contratto è più di una firma, è anche una dichiarazione implicita o esplicita di ciò che dovresti essere in grado di fare. Le classi di implementazione / derivazione possono ampliare l'insieme di possibilità, ma non è possibile restringerle.

Nel modello di design composito hai un componente (interfaccia o classe astratta) che definisce le operazioni adatte per le foglie e compositi, dove entrambi derivano dal componente. Il composito contiene una raccolta di componenti.

public interface IComponent
{
    void Operation();
    ... possibly more operations here but NO collection specific operation like Add()
}

public class Leaf : IComponent
{
    public void Operation() { <implementation> }
}

public class Composite : IComponent
{
    private ICollection<IComponent> _collection = new ...;

    public void Operation()
    {
        foreach (IComponent c in _collection) c.Operation();
    }
    public void Add(IComponent c) { _collection.Add(c); }
    public void Remove ( ..., GetChild(... etc.
}

Le operazioni del componente devono essere progettate per funzionare anche con foglie e materiali compositi. Solo il composito ha operazioni relative a una raccolta di componenti.

Come esempio concreto (e più realistico) il tuo esempio potrebbe essere riscritto in questo modo

// The component
public interface ILogger
{
    void Write(string text);
}

// A Leaf
public class FileLogger : ILogger
{
    private readonly string _fileName;

    public FileLogger(string fileName)
    {
        _fileName = fileName;
    }

    public void Write(string text)
    {
        System.IO.File.AppendAllText(_fileName, text + "\r\n");
    }
}

// Another Leaf type
public class ConsoleLogger : ILogger
{
    public void Write(string text)
    {
        Console.WriteLine(text);
    }
}

// The composite
public class CompositeLogger : ILogger
{
    private readonly List<ILogger> _loggers = new List<ILogger>();

    public void Write(string text)
    {
        foreach (var logger in _loggers) {
            logger.Write(text);
        }
    }

    public void Add(ILogger logger)
    {
        _loggers.Add(logger);
    }

    // Not required here: Remove, GetChild, etc.
}

Naturalmente, le foglie possono avere membri aggiuntivi specifici per la loro implementazione; tuttavia, questi membri specializzati non devono far parte del componente (classe base o interfaccia), altrimenti violerebbero il principio di sostituzione di Liskov (LSP) . In questo esempio concreto, un FileLogger potrebbe esporre il nome del file come proprietà e il ConsoleLogger un colore del testo per l'output della console. Perchè no. Ma includere tali proprietà nell'interfaccia ILogger sarebbe una violazione di LSP in quanto queste non avrebbero senso per altri tipi di logger, inclusi i logger compositi. Questo è dove il tuo design è difettoso.

Si noti che è possibile aggiungere logger e logger compositi a CompositeLogger e quindi creare un intero albero o una gerarchia di componenti. Questo è il punto del modello di design composito.

Utilizzerai questi registratori attraverso Iniezione di dipendenza . Cioè dovresti iniettare un ILogger (ad esempio mediante iniezione del costruttore) nei componenti del tuo software per eseguire alcune registrazioni. Il punto è che questi componenti non sanno (e non hanno bisogno di sapere) quale tipo di registratore stanno usando. È possibile iniettare un registratore di file, in seguito sostituirlo con un registratore di console e anche in seguito decidere di accedere a file e console mediante l'inserimento di un programma di registrazione composito senza dover apportare modifiche a nessuno dei componenti.

Consumiamo questo composto. Ad esempio creiamo un calcolatore

public class Calculator : ICalculator // Interface not shown here
{
    private readonly ILogger _logger;

    public Calculator (ILogger logger)
    {
        _logger = logger;
    }

    public double Result { get; set; }

    public void Add(double value)
    {
        _logger.Write($"Result before adding {value}: {Result}");
        Result += value;
        _logger.Write($"Result after adding {value}: {Result}");
    }

    public void Subtract(double value)
    {
        _logger.Write($"Result before subtracting {value}: {Result}");
        Result -= value;
        _logger.Write($"Result after subtracting {value}: {Result}");
    }
}

Ora inizializziamo e usiamo compositi e calcolatori:

ILogger fileLogger1 = new FileLogger(myLogFile);
ILogger fileLogger2 = new FileLogger(anotherLogFile);
ILogger consoleLogger = new ConsoleLogger();

ICalculator consoleLoggingCalculator = new Calculator(consoleLogger);
consoleLoggingCalculator.Add(100);
consoleLoggingCalculator.Subtract(17);

var allfilesLogger = new CompositeLogger();
allfilesLogger.Add(fileLogger1);
allfilesLogger.Add(fileLogger2);
ICalculator filesLoggingCalculator = new Calculator(allfilesLogger);
filesLoggingCalculator.Add(100);
filesLoggingCalculator.Subtract(17);

var allLoggers = new CompositeLogger();
allLoggers.Add(consoleLogger);
allLoggers.Add(allfilesLogger);
ICalculator logToAllCalculator = new Calculator(allLoggers);
logToAllCalculator.Add(100);
logToAllCalculator.Subtract(17);

Nessun problema nel consumo, nessuna eccezione!

Nota che non puoi farlo (corrisponde al tuo esempio thatFile.Name = "My files"; // Runtime error ):

consoleLogger.Add(fileLogger1); // COMPILER ERROR!
    
risposta data 23.11.2017 - 14:53
fonte

Leggi altre domande sui tag