Programmazione orientata agli oggetti: come evitare la duplicazione nei processi che differiscono leggermente in base a una variabile


64

Qualcosa che emerge molto nel mio lavoro attuale è che c'è un processo generalizzato che deve accadere, ma poi la parte strana di quel processo deve avvenire in modo leggermente diverso a seconda del valore di una certa variabile, e non lo sono abbastanza sicuro qual è il modo più elegante per gestirlo.

Userò l'esempio che di solito abbiamo, che sta facendo le cose in modo leggermente diverso a seconda del paese con cui abbiamo a che fare.

Quindi ho una lezione, chiamiamola Processor:

public class Processor
{
    public string Process(string country, string text)
    {
        text.Capitalise();

        text.RemovePunctuation();

        text.Replace("é", "e");

        var split = text.Split(",");

        string.Join("|", split);
    }
}

Solo che alcune di queste azioni devono avvenire per alcuni paesi. Ad esempio, solo 6 paesi richiedono il passaggio di capitalizzazione. Il personaggio su cui dividere potrebbe cambiare in base al Paese. La sostituzione dell'accento 'e'potrebbe essere richiesta solo in base al Paese.

Ovviamente potresti risolverlo facendo qualcosa del genere:

public string Process(string country, string text)
{
    if (country == "USA" || country == "GBR")
    {
        text.Capitalise();
    }

    if (country == "DEU")
    {
        text.RemovePunctuation();
    }

    if (country != "FRA")
    {
        text.Replace("é", "e");
    }

    var separator = DetermineSeparator(country);
    var split = text.Split(separator);

    string.Join("|", split);
}

Ma quando hai a che fare con tutti i possibili paesi del mondo, diventa molto ingombrante. E a prescindere, le ifdichiarazioni rendono la logica più difficile da leggere (almeno, se immagini un metodo più complesso dell'esempio), e la complessità ciclomatica inizia a insinuarsi piuttosto velocemente.

Quindi al momento sto facendo qualcosa del genere:

public class Processor
{
    CountrySpecificHandlerFactory handlerFactory;

    public Processor(CountrySpecificHandlerFactory handlerFactory)
    {
        this.handlerFactory = handlerFactory;
    }

    public string Process(string country, string text)
    {
        var handlers = this.handlerFactory.CreateHandlers(country);
        handlers.Capitalier.Capitalise(text);

        handlers.PunctuationHandler.RemovePunctuation(text);

        handlers.SpecialCharacterHandler.ReplaceSpecialCharacters(text);

        var separator = handlers.SeparatorHandler.DetermineSeparator();
        var split = text.Split(separator);

        string.Join("|", split);
    }
}

gestori:

public class CountrySpecificHandlerFactory
{
    private static IDictionary<string, ICapitaliser> capitaliserDictionary
                                    = new Dictionary<string, ICapitaliser>
    {
        { "USA", new Capitaliser() },
        { "GBR", new Capitaliser() },
        { "FRA", new ThingThatDoesNotCapitaliseButImplementsICapitaliser() },
        { "DEU", new ThingThatDoesNotCapitaliseButImplementsICapitaliser() },
    };

    // Imagine the other dictionaries like this...

    public CreateHandlers(string country)
    {
        return new CountrySpecificHandlers
        {
            Capitaliser = capitaliserDictionary[country],
            PunctuationHanlder = punctuationDictionary[country],
            // etc...
        };
    }
}

public class CountrySpecificHandlers
{
    public ICapitaliser Capitaliser { get; private set; }
    public IPunctuationHanlder PunctuationHanlder { get; private set; }
    public ISpecialCharacterHandler SpecialCharacterHandler { get; private set; }
    public ISeparatorHandler SeparatorHandler { get; private set; }
}

Che ugualmente non sono davvero sicuro che mi piaccia. La logica è ancora un po 'oscurata da tutta la creazione della fabbrica e non puoi semplicemente guardare il metodo originale e vedere cosa succede quando viene eseguito un processo "GBR", per esempio. Finisci anche per creare molte classi (in esempi più complessi di questo) nello stile GbrPunctuationHandler, UsaPunctuationHandlerecc ... il che significa che devi guardare diverse classi per capire tutte le possibili azioni che potrebbero accadere durante la punteggiatura la manipolazione. Ovviamente non voglio una classe gigante con un miliardo di ifaffermazioni, ma ugualmente 20 classi con una logica leggermente diversa mi sembrano goffe.

Fondamentalmente penso di essermi preso una specie di nodo OOP e non conosco bene un modo per districarlo. Mi chiedevo se ci fosse uno schema là fuori che potesse aiutare con questo tipo di processo?


Sembra che tu abbia una PreProcessfunzionalità, che potrebbe essere implementata in modo diverso in base ad alcuni dei paesi, DetermineSeparatorpuò essere lì per tutti loro, e a PostProcess. Tutti possono essere protected virtual voidcon un'implementazione predefinita e quindi puoi avere specifici Processorsper paese
Icepickle

Il tuo compito è quello di rendere in un determinato arco di tempo qualcosa che funzioni e possa essere mantenuto in un futuro prevedibile, da te o da qualcun altro. Se diverse opzioni possono soddisfare entrambe le condizioni, sei libero di sceglierne una qualsiasi, in base ai tuoi gusti.
Dialecticus

2
Un'opzione valida per te è avere una configurazione. Quindi nel tuo codice non verifichi un Paese specifico, ma un'opzione di configurazione specifica. Ma ogni paese avrà un set specifico di tali opzioni di configurazione. Ad esempio invece di if (country == "DEU")controllare if (config.ShouldRemovePunctuation).
Dialecticus

11
Se i paesi hanno varie opzioni, perché countryuna stringa anziché un'istanza di una classe che modella queste opzioni?
Damien_The_Unbeliever

@Damien_The_Unbeliever - potresti approfondire un po '? La risposta di Robert Brautigam è in basso sulla falsariga di ciò che stai suggerendo? - ah ora puoi vedere la tua risposta, grazie!
John Darvill,

Risposte:


53

Suggerirei di incapsulare tutte le opzioni in una classe:

public class ProcessOptions
{
  public bool Capitalise { get; set; }
  public bool RemovePunctuation { get; set; }
  public bool Replace { get; set; }
  public char ReplaceChar { get; set; }
  public char ReplacementChar { get; set; }
  public char JoinChar { get; set; }
  public char SplitChar { get; set; }
}

e passalo nel Processmetodo:

public string Process(ProcessOptions options, string text)
{
  if(options.Capitalise)
    text.Capitalise();

  if(options.RemovePunctuation)
    text.RemovePunctuation();

  if(options.Replace)
    text.Replace(options.ReplaceChar, options.ReplacementChar);

  var split = text.Split(options.SplitChar);

  string.Join(options.JoinChar, split);
}

4
Non so perché una cosa del genere non sia stata provata prima di saltare a CountrySpecificHandlerFactory... o_0
Mateen Ulhaq

Finché non ci sono opzioni troppo specializzate, andrei sicuramente in questo modo. Se le opzioni sono serializzate in file di testo, consente anche ai non programmatori di definire nuove varianti / aggiornare quelle esistenti senza dover passare all'applicazione.
Tom,

4
Che public class ProcessOptionsin realtà dovrebbe essere solo [Flags] enum class ProcessOptions : int { ... }...
Drunken Code Monkey

E immagino che se ne hanno bisogno, potrebbero avere una mappa dei paesi per ProcessOptions. Molto conveniente.
theonlygusti,

24

Quando .NET Framework ha deciso di gestire questo tipo di problemi, non ha modellato tutto come string. Quindi hai, ad esempio, la CultureInfoclasse :

Fornisce informazioni su una cultura specifica (chiamata locale per lo sviluppo di codice non gestito). Le informazioni includono i nomi per la cultura, il sistema di scrittura, il calendario utilizzato, l'ordinamento delle stringhe e la formattazione per date e numeri.

Ora, questa classe potrebbe non contenere le funzionalità specifiche di cui hai bisogno, ma puoi ovviamente creare qualcosa di analogo. E poi cambi il tuo Processmetodo:

public string Process(CountryInfo country, string text)

La tua CountryInfoclasse può quindi avere una bool RequiresCapitalizationproprietà, ecc., Che aiuta il tuo Processmetodo a dirigere l'elaborazione in modo appropriato.


13

Forse potresti averne uno Processorper paese?

public class FrProcessor : Processor {
    protected override string Separator => ".";

    protected override string ProcessSpecific(string text) {
        return text.Replace("é", "e");
    }
}

public class UsaProcessor : Processor {
    protected override string Separator => ",";

    protected override string ProcessSpecific(string text) {
        return text.Capitalise().RemovePunctuation();
    }
}

E una classe di base per gestire parti comuni dell'elaborazione:

public abstract class Processor {
    protected abstract string Separator { get; }

    protected virtual string ProcessSpecific(string text) { }

    private string ProcessCommon(string text) {
        var split = text.Split(Separator);
        return string.Join("|", split);
    }

    public string Process(string text) {
        var s = ProcessSpecific(text);
        return ProcessCommon(s);
    }
}

Inoltre, dovresti rielaborare i tipi restituiti perché non verranno compilati come li hai scritti, a volte un stringmetodo non restituisce nulla.


Immagino che stavo cercando di seguire la composizione sul mantra dell'eredità. Ma sì, è sicuramente un'opzione, grazie per la risposta.
John Darvill,

Giusto. Penso che l'ereditarietà sia giustificata in alcuni casi, ma dipende davvero da come si pianifica di caricare / archiviare / invocare / modificare i metodi e l'elaborazione alla fine.
Corentin Pane,

3
A volte, l'eredità è lo strumento giusto per il lavoro. Se hai un processo che si comporterà principalmente allo stesso modo in diverse situazioni, ma ha anche diverse parti che si comporteranno in modo diverso in situazioni diverse, è un buon segno che dovresti considerare l'uso dell'ereditarietà.
Tanner Swett,

5

Puoi creare un'interfaccia comune con un Processmetodo ...

public interface IProcessor
{
    string Process(string text);
}

Quindi lo implementi per ogni paese ...

public class Processors
{
    public class GBR : IProcessor
    {
        public string Process(string text)
        {
            return $"{text} (processed with GBR rules)";
        }
    }

    public class FRA : IProcessor
    {
        public string Process(string text)
        {
            return $"{text} (processed with FRA rules)";
        }
    }
}

È quindi possibile creare un metodo comune per istanziare ed eseguire ogni classe relativa al paese ...

// also place these in the Processors class above
public static IProcessor CreateProcessor(string country)
{
    var typeName = $"{typeof(Processors).FullName}+{country}";
    var processor = (IProcessor)Assembly.GetAssembly(typeof(Processors)).CreateInstance(typeName);
    return processor;
}

public static string Process(string country, string text)
{
    var processor = CreateProcessor(country);
    return processor?.Process(text);
}

Quindi devi solo creare e utilizzare i processori in questo modo ...

// create a processor object for multiple use, if needed...
var processorGbr = Processors.CreateProcessor("GBR");
Console.WriteLine(processorGbr.Process("This is some text."));

// create and use a processor for one-time use
Console.WriteLine(Processors.Process("FRA", "This is some more text."));

Ecco un esempio di violino dotnet funzionante ...

Inserite tutte le elaborazioni specifiche per Paese in ogni classe di Paese. Crea una classe comune (nella classe Elaborazione) per tutti i metodi individuali effettivi, in modo che ciascun processore paese diventi un elenco di altre chiamate comuni, anziché copiare il codice in ogni classe Paese.

Nota: dovrai aggiungere ...

using System.Assembly;

affinché il metodo statico crei un'istanza della classe country.


La riflessione non è notevolmente lenta rispetto a nessun codice riflesso? ne vale la pena per questo caso?
jlvaquero,

@jlvaquero No, la riflessione non è affatto lenta. Ovviamente c'è un impatto sulle prestazioni quando si specifica un tipo in fase di progettazione, ma è davvero una differenza di prestazioni trascurabile e si nota solo quando lo si fa eccessivamente. Ho implementato grandi sistemi di messaggistica basati sulla gestione generica di oggetti e non abbiamo avuto motivo di mettere in discussione le prestazioni, e questo ha enormi quantità di throughput. Senza alcuna differenza evidente nelle prestazioni, vado sempre con un codice semplice da mantenere, come questo.
Ripristina Monica Cellio l'

Se stai riflettendo, non vorresti rimuovere la stringa di paese da ogni chiamata a Process, e invece utilizzarla una volta per ottenere l'IProcessore corretto? Solitamente elaborerai molto testo secondo le regole dello stesso Paese.
Davislor,

@Davislor Questo è esattamente ciò che fa questo codice. Quando lo chiami Process("GBR", "text");esegue il metodo statico che crea un'istanza del processore GBR ed esegue il metodo Process su quello. Lo esegue solo su un'istanza, per quel tipo di paese specifico.
Ripristina Monica Cellio il

@Archer Bene, quindi nel caso tipico in cui stai elaborando più stringhe in base alle regole per lo stesso paese, sarebbe più efficiente creare l'istanza una volta o cercare un'istanza costante in una tabella hash / Dizionario e restituire un riferimento a quello. È quindi possibile chiamare la trasformazione del testo nella stessa istanza. Creare una nuova istanza per ogni chiamata e quindi scartarla, anziché riutilizzarla per ogni chiamata, è dispendioso.
Davislor,

3

Alcune versioni fa, allo swtich C # è stato fornito pieno supporto per la corrispondenza dei modelli . In questo modo il caso "Abbinamento di più paesi" è facile. Anche se non ha ancora fallito l'abilità, un input può abbinare più casi con il pattern matching. Potrebbe forse rendere questo if-spam un po 'più chiaro.

Npw di solito un interruttore può essere sostituito con una collezione. Devi utilizzare i delegati e un dizionario. Il processo può essere sostituito con.

public delegate string ProcessDelegate(string text);

Quindi potresti creare un dizionario:

var Processors = new Dictionary<string, ProcessDelegate>(){
  { "USA", EnglishProcessor },
  { "GBR", EnglishProcessor },
  { "DEU", GermanProcessor }
}

Ho usato functionNames per consegnare il delegato. Ma potresti usare la sintassi Lambda per fornire l'intero codice lì. In questo modo potresti semplicemente nascondere l'intera collezione come faresti con qualsiasi altra grande collezione. E il codice diventa una semplice ricerca:

ProcessDelegate currentProcessor = Processors[country];
string processedString = currentProcessor(country);

Quelle sono praticamente le due opzioni. Si consiglia di utilizzare le enumerazioni anziché le stringhe per la corrispondenza, ma si tratta di un dettaglio minore.


2

Vorrei forse (a seconda dei dettagli del tuo caso d'uso) andare con l' Countryessere un oggetto "reale" anziché una stringa. La parola chiave è "polimorfismo".

Quindi in sostanza sembrerebbe così:

public interface Country {
   string Process(string text);
}

Quindi puoi creare paesi specializzati per quelli di cui hai bisogno. Nota: non è necessario creare Countryoggetti per tutti i paesi, è possibile avere LatinlikeCountryo addirittura GenericCountry. Lì puoi raccogliere ciò che dovrebbe essere fatto, anche riutilizzando gli altri, come:

public class France {
   public string Process(string text) {
      return new GenericCountry().process(text)
         .replace('a', 'b');
   }
}

O simili. Countrypotrebbe essere in realtà Language, non sono sicuro del caso d'uso, ma ho capito bene.

Inoltre, il metodo ovviamente non dovrebbe essere Process(), dovrebbe essere la cosa che devi effettivamente fare. Come Words()o qualunque altra cosa.


1
Ho scritto qualcosa di più elaborato, ma penso che questo sia fondamentalmente ciò che mi piace di più. Se il caso d'uso deve cercare questi oggetti in base a una stringa di paese, può utilizzare la soluzione di Christopher con questo. L'implementazione delle interfacce potrebbe persino essere una classe le cui istanze definiscono tratti come nella risposta di Michal, da ottimizzare per lo spazio piuttosto che per il tempo.
Davislor,

1

Vuoi delegare a (annuire alla catena di responsabilità) qualcosa che conosce la propria cultura. Quindi usa o crea un costrutto di tipo Country o CultureInfo, come menzionato sopra in altre risposte.

Ma in generale e fondamentalmente il tuo problema è che stai prendendo costrutti procedurali come 'processore' e applicandoli a OO. OO riguarda la rappresentazione di concetti del mondo reale da un settore aziendale o problematico nel software. Il processore non si traduce in nulla nel mondo reale a parte il software stesso. Ogni volta che hai classi come Processore o Manager o Governatore, dovrebbero suonare le campane di allarme.


0

Mi chiedevo se ci fosse uno schema là fuori che potesse aiutare con questo tipo di processo

La catena di responsabilità è il tipo di cosa che potresti cercare ma in OOP è alquanto ingombrante ...

Che dire di un approccio più funzionale con C #?

using System;


namespace Kata {

  class Kata {


    static void Main() {

      var text = "     testing this thing for DEU          ";
      Console.WriteLine(Process.For("DEU")(text));

      text = "     testing this thing for USA          ";
      Console.WriteLine(Process.For("USA")(text));

      Console.ReadKey();
    }

    public static class Process {

      public static Func<string, string> For(string country) {

        Func<string, string> baseFnc = (string text) => text;

        var aggregatedFnc = ApplyToUpper(baseFnc, country);
        aggregatedFnc = ApplyTrim(aggregatedFnc, country);

        return aggregatedFnc;

      }

      private static Func<string, string> ApplyToUpper(Func<string, string> currentFnc, string country) {

        string toUpper(string text) => currentFnc(text).ToUpper();

        Func<string, string> fnc = null;

        switch (country) {
          case "USA":
          case "GBR":
          case "DEU":
            fnc = toUpper;
            break;
          default:
            fnc = currentFnc;
            break;
        }
        return fnc;
      }

      private static Func<string, string> ApplyTrim(Func<string, string> currentFnc, string country) {

        string trim(string text) => currentFnc(text).Trim();

        Func<string, string> fnc = null;

        switch (country) {
          case "DEU":
            fnc = trim;
            break;
          default:
            fnc = currentFnc;
            break;
        }
        return fnc;
      }
    }
  }
}

NOTA: ovviamente non deve essere tutto statico. Se la classe Process ha bisogno di stato è possibile utilizzare una classe istanziata o una funzione parzialmente applicata;).

È possibile creare il processo per ciascun paese all'avvio, memorizzarne uno in una raccolta indicizzata e recuperarli quando necessario con il costo O (1).


0

Mi dispiace di aver coniato molto tempo fa il termine "oggetti" per questo argomento perché induce molte persone a concentrarsi sull'idea minore. La grande idea è la messaggistica .

~ Alan Kay, sulla messaggistica

Vorrei semplicemente implementare le routine Capitalise, RemovePunctuationecc come sottoprocessi che possono essere messaged con una texte countryparametri, e ritornerei un testo elaborato.

Utilizza i dizionari per raggruppare i paesi che si adattano a un attributo specifico (se preferisci gli elenchi, funzionerebbe anche con un leggero costo di rendimento). Ad esempio: CapitalisationApplicableCountriese PunctuationRemovalApplicableCountries.

/// Runs like a pipe: passing the text through several stages of subprocesses
public string Process(string country, string text)
{
    text = Capitalise(country, text);
    text = RemovePunctuation(country, text);
    // And so on and so forth...

    return text;
}

private string Capitalise(string country, string text)
{
    if ( ! CapitalisationApplicableCountries.ContainsKey(country) )
    {
        /* skip */
        return text;
    }

    /* do the capitalisation */
    return capitalisedText;
}

private string RemovePunctuation(string country, string text)
{
    if ( ! PunctuationRemovalApplicableCountries.ContainsKey(country) )
    {
        /* skip */
        return text;
    }

    /* do the punctuation removal */
    return punctuationFreeText;
}

private string Replace(string country, string text)
{
    // Implement it following the pattern demonstrated earlier.
}

0

Sento che le informazioni sui paesi dovrebbero essere conservate nei dati, non nel codice. Pertanto, anziché una classe CountryInfo o un dizionario CapitalisationApplicableCountries, è possibile disporre di un database con un record per ciascun paese e un campo per ogni fase di elaborazione, quindi l'elaborazione può passare attraverso i campi per un determinato paese ed elaborare di conseguenza. La manutenzione è quindi principalmente nel database, con il nuovo codice necessario solo quando sono necessari nuovi passaggi e i dati possono essere leggibili dall'uomo nel database. Questo presuppone che i passaggi siano indipendenti e non interferiscano tra loro; se non è così le cose sono complicate.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.