Come evitare i comportamenti persi quando si separa il codice dai dati

0

Mi sono imbattuto in qualcosa che trovo decisamente frustrante aggiungendo nuove funzionalità al nostro ampio code base esistente.

Prefazione

Abbiamo una varietà di classi (ItemA, ItemB, ItemC ...) che ereditano da una classe base (TheBaseClass). Diremo che ci sono circa 10 classi di oggetti. Tratteniamo una raccolta di istanze di oggetti misti di magnitudini da poche centinaia a poche migliaia.

List<TheBaseClass> items = new List<TheBaseClass>()

Questo elenco è l'output di un passaggio precedente che assembla gli elementi in base ad alcuni dati di input, di solito alcuni elementi di pattern factory in corso per ottenere l'Item. Quindi prendiamo questo elenco e passiamo attraverso una serie di fasi di elaborazione e calcolo. Alcuni di questi metodi modificano i valori sugli oggetti esistenti, altri modificano la raccolta stessa (di solito inserendo nuovi elementi in una varietà di condizioni, questo può includere look-ahead e behinds durante l'iterazione). I passaggi a volte agiscono semplicemente su TheBaseClass, ma molti eseguono l'iterazione attraverso l'individuazione di specifiche implementazioni di Item o agiscono su oggetti diversi in modi diversi.

Ecco un esempio di una fase del processo che agisce su tutti i tipi utilizzando un dizionario con i puntatori di funzione per ogni tipo:

public class ProcessingStep1()
{
    Dictionary<Type, CalculateDelegate> lookupTable = new Dictionary<Type, CalculateDelegate>();

    public ProcessingStep1()
    {
        lookupTable.Add(typeof(ItemA), ProcessItemA);
        lookupTable.Add(typeof(ItemB), ProcessItemB);
        // ...
    }

    public method CalculateAThing(IEnumerable<TheBaseClass> items)
    {
        foreach (TheBaseClass item in items)
        {
            // desired to throw an exception if the key is not found
            CalculateDelegate calculate = lookupTable[item.GetType()]; 
        }
    }

    private ProcessItemA(TheBaseClass item)
    {
        // need to cast to access a property from the derived type
        var something = (item as ItemA).PropertyOnItemA;
        item.BaseClassProperty = doMath(something);
    }

    private ProcessItemB(TheBaseClass item)
    {
        // no need to cast, but the behavior is different for ItemB than others
        item.BaseClassProperty = somethingSpecific;

    }

    // ...
}

Separazione del codice dai dati

Questi passaggi di elaborazione variano in base alle impostazioni e agli input nel super processo, oltre a pochi "percorsi" definiti di passaggi da eseguire, i passaggi stessi possono essere implementazioni di un'interfaccia e possono variare singolarmente.

Questo, insieme al fatto che le fasi di elaborazione possono modificare la raccolta stessa, è un argomento strong per mantenere questi metodi come entità separate piuttosto che come membri dei tipi Item e TheBaseClass, come questo

public abstract class TheBaseClass
{
    public abstract void CalculateAThing();
}

Le lotte

Ora ho dovuto aggiungere un nuovo elemento, ItemD. Se ItemD dovesse implementare un'interfaccia o metodi astratti, otterrei dei controlli del tempo compilati relativi al comportamento previsto di cui è richiesto un articolo. Tuttavia qui ho bisogno di fare affidamento sulla mia precedente conoscenza della nostra base di codice (se ce l'ho) o su un ciclo di sviluppo continuo di cercare di ottenere una collezione con ItemD presente attraverso tutti i rami possibili che ProcessingSteps può fare, facendo affidamento sulle eccezioni ( dal dizionario in alto) o da me catturando e identificando i bug di runtime dell'output.

Sono bloccato in questa architettura e nella base di codice, e non identificherò i miei altri limiti perché penso che limiterà la discussione, ma sto cercando come è gestito in altri domini o anche in funzioni lingue in modo tale da evitarlo in futuro se un nuovo design si stava dirigendo nella stessa direzione.

    
posta plast1k 26.07.2017 - 16:26
fonte

2 risposte

3

Mi sembra che tu abbia reinventato la ruota. Il tuo elenco di delegati sostituisce il VMT (male).

Il modo OO corretto per farlo è quello di memorizzare il "delegato" nella Tabella dei metodi virtuali (VMT) , che viene fatto automaticamente quando usi il polimorfismo, se hai impostato correttamente le cose. Qualsiasi comportamento specifico della classe viene deciso tramite chiamate a metodi virtuali.

abstract class TheBaseClass
{
    public abstract void CalculateAThing();
}

class ItemA : TheBaseClass
{
    public int PropertyOnItemA { get; set; }

    public override void CalculateAThing()
    {
        this.BaseClassProperty = doMath(this.PropertyOnItemA);  //No casting needed because type is known
    }
}

class ItemB : TheBaseClass
{
    public override void CalculateAThing()
    {
        this.BaseClassProperty = somethingSpecific;
    }
}


public class ProcessingStep1()
{
    //Got rid of the list of delegates and the ctor that builds it
    //Any class-specific logic is held in the class itself, i.e. is encapsulated

    public method CalculateAThing(IEnumerable<TheBaseClass> items)
    {
        foreach (TheBaseClass item in items)
        {
            item.CalculateAThing();
        }
    }
}

Quando devi aggiungere un ItemD o ItemE , devi solo sovrascrivere CalculateAThing e implementare la logica specifica dell'oggetto come parte della classe a cui si applica. Questo collega il comportamento con i dati e il comportamento e i dati specifici del tipo con il tipo, ovvero l'intera idea dietro l'incapsulamento e il polimorfismo.

Se insisti a mantenere il codice al di fuori di TheBaseClass e nella tua classe ProcessingStep1 , puoi comunque mantenere il processo decisionale in TheBaseClass e l'implementazione in ProcessingStep1 utilizzando un callback, come questo:

abstract class TheBaseClass
{
    abstract public void DoTheProcessing(ProcessingStep1 processor);
}

class ItemA : TheBaseClass
{
    public override void DoTheProcessing(ProcessingStep1 processor)
    {
        processor.ProcessItemA(this);
    }
}

class ItemB : TheBaseClass
{
    public override void DoTheProcessing(ProcessingStep1 processor)
    {
        processor.ProcessItemB(this);
    }
}

Che consentirà ai tuoi metodi specifici del tipo di accettare istanze specifiche:

public method CalculateAThing(IEnumerable<TheBaseClass> items)
{
    foreach (TheBaseClass item in items)
    {
        item.DoTheProcessing(this);
    }
}

private ProcessItemA(ItemA item)
{
    item.BaseClassProperty = doMath(item.PropertyOnItemA);  //No cast needed
}

private ProcessItemB(ItemB item)
{
    item.BaseClassProperty = somethingSpecific;
}

Una volta che si evita quell'elenco dei delegati e tutti quei cast inutili, la possibilità di "perdere" alcuni metodi va giù, e può essere rilevata in fase di compilazione.

    
risposta data 26.07.2017 - 18:17
fonte
0

Solo per costruire la risposta di @John Wu, se hai comportamenti personalizzati, ogni elemento dovrebbe implementarli tramite un'interfaccia.

Esempio:

class ItemA : TheBaseClass, ISpecialBehavior
{
    public override void DoTheProcessing(ProcessingStep1 processor)
    {
        processor.ProcessItemA(this);
    }

    public void ExexcuteSpecialBehavior()
    {
        //Implement Here
    }
}

Quindi, nella classe ProcessingStep1, mentre si scorre su ciascun elemento, verificare se quell'elemento implementa le interfacce personalizzate e quindi chiamare l'implementazione personalizzata. Quindi ogni elemento definisce il proprio comportamento personalizzato. C'è qualche collegamento nella classe ProcessingStep1 per sapere quali comportamenti personalizzati eseguire che appartengono a quella fase di elaborazione tramite l'interfaccia, ma è responsabilità di sapere come eseguire l'elaborazione in modo che ci si possa aspettare.

    
risposta data 26.07.2017 - 19:18
fonte