Programowanie obiektowe - jak uniknąć powielania procesów, które różnią się nieznacznie w zależności od zmiennej

64

Coś, co pojawia się dość często w mojej obecnej pracy, polega na tym, że istnieje uogólniony proces, który musi się wydarzyć, ale potem dziwna część tego procesu musi się nieco różnić w zależności od wartości określonej zmiennej, a ja nie jestem całkiem pewien, jaki jest najbardziej elegancki sposób na poradzenie sobie z tym.

Posłużę się przykładem, który zwykle mamy, który robi rzeczy nieco inaczej w zależności od kraju, z którym mamy do czynienia.

Mam więc klasę, nazwijmy to 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);
    }
}

Tyle że tylko niektóre z tych działań muszą się wydarzyć w niektórych krajach. Na przykład tylko 6 krajów wymaga etapu kapitalizacji. Postać do podziału może się zmieniać w zależności od kraju. Wymiana akcentu 'e'może być wymagana tylko w zależności od kraju.

Oczywiście możesz to rozwiązać, robiąc coś takiego:

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

Ale kiedy masz do czynienia ze wszystkimi możliwymi krajami na świecie, staje się to bardzo uciążliwe. I niezależnie od tego, ifstwierdzenia utrudniają czytanie logiki (przynajmniej, jeśli wyobrażasz sobie bardziej złożoną metodę niż w przykładzie), a złożoność cykliczna zaczyna się dość szybko pełzać.

Więc w tej chwili robię coś takiego:

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

Handlery:

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

Nie jestem też pewien, czy mi się podoba. Logika jest nadal nieco zaciemniona przez cały proces tworzenia fabryki i nie można po prostu spojrzeć na oryginalną metodę i zobaczyć, co się dzieje, na przykład, gdy wykonywany jest proces „GBR”. Ty też skończyć tworzenie wielu klas (w bardziej skomplikowanych przykładów niż to) w stylu GbrPunctuationHandler, UsaPunctuationHandleritp ... co oznacza, że trzeba spojrzeć na kilka różnych klas, aby dowiedzieć się wszystkich możliwych działań, które mogą się zdarzyć podczas interpunkcji obsługa. Oczywiście nie chcę jednej wielkiej klasy z miliardem ifoświadczeń, ale równie 20 klas o nieco odmiennej logice również czuje się niezręcznie.

Zasadniczo myślę, że wpadłem w jakiś węzeł OOP i nie wiem, jak go rozwiązać. Zastanawiałem się, czy istnieje jakiś wzorzec, który pomógłby w tego rodzaju procesie?

John Darvill
źródło
Wygląda na to, że masz PreProcessfunkcjonalność, która może być zaimplementowana inaczej w zależności od niektórych krajów, DetermineSeparatormoże być dostępna dla wszystkich z nich i PostProcess. Wszystkie z nich mogą mieć protected virtual voiddomyślną implementację, a następnie możesz mieć określone dla Processorsposzczególnych krajów
Icepickle
Twoim zadaniem jest zrobienie w określonym czasie czegoś, co zadziała i może być utrzymane w dającej się przewidzieć przyszłości przez ciebie lub kogoś innego. Jeśli kilka opcji może spełnić oba warunki, możesz wybrać dowolną z nich, zgodnie z własnymi upodobaniami.
Dialecticus,
2
Realną opcją jest posiadanie konfiguracji. Dlatego w kodzie nie sprawdzasz konkretnego kraju, ale określoną opcję konfiguracji. Ale każdy kraj będzie miał określony zestaw tych opcji konfiguracji. Na przykład zamiast if (country == "DEU")ciebie sprawdź if (config.ShouldRemovePunctuation).
Dialecticus,
11
Jeżeli kraje mają różne opcje, dlaczego jest ciąg zamiast instancją klasy, że modele te opcje? country
Damien_The_Unbeliever
@Damien_The_Unbeliever - czy mógłbyś się trochę rozwinąć? Czy odpowiedź Roberta Brautigama poniżej jest zgodna z twoją sugestią? - ah widzę teraz twoją odpowiedź, dzięki!
John Darvill,

Odpowiedzi:

53

Sugerowałbym zawarcie wszystkich opcji w jednej klasie:

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

i przekaż to do Processmetody:

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);
}
Michał Turczyn
źródło
4
Nie jestem pewien, dlaczego coś takiego nie zostało wypróbowane przed skokiem do CountrySpecificHandlerFactory... o_0
Mateen
Dopóki nie ma zbyt wyspecjalizowanych opcji, zdecydowanie wybrałbym tę drogę. Jeśli opcje są serializowane do pliku tekstowego, pozwala to również programistom na definiowanie nowych wariantów / aktualizację istniejących bez potrzeby zmiany aplikacji.
Tom
4
To public class ProcessOptionsnaprawdę powinno być po prostu [Flags] enum class ProcessOptions : int { ... }...
Drunken Code Monkey,
I myślę, że jeśli będą potrzebować, mogą mieć mapę krajów do ProcessOptions. Bardzo wygodne
theonlygusti
24

Kiedy środowisko .NET postanowiło radzić sobie z tego rodzaju problemami, nie modelowało wszystkiego jako string. Masz na przykład CultureInfoklasę :

Dostarcza informacji o konkretnej kulturze (zwanej lokalizacją dla niezarządzanego tworzenia kodu). Informacje obejmują nazwy kultury, system pisania, używany kalendarz, kolejność sortowania ciągów oraz formatowanie dat i liczb.

Teraz ta klasa może nie zawierać określonych funkcji, których potrzebujesz, ale możesz oczywiście stworzyć coś analogicznego. A potem zmieniasz Processmetodę:

public string Process(CountryInfo country, string text)

Twoja CountryInfoklasa może następnie mieć bool RequiresCapitalizationwłaściwość itp., Która pomoże twojej Processmetodzie odpowiednio ukierunkować jej przetwarzanie.

Damien_The_Unbeliever
źródło
13

Może mógłbyś mieć jeden Processorna kraj?

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

I jedna klasa bazowa do obsługi typowych części przetwarzania:

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

Powinieneś także przerobić typy zwracanych danych, ponieważ nie będą się kompilować tak, jak je napisałeś - czasami stringmetoda nic nie zwraca.

Corentin Pane
źródło
Wydaje mi się, że starałem się podążać za kompozycją nad mantrą o dziedziczeniu. Ale tak, to zdecydowanie opcja, dziękuję za odpowiedź.
John Darvill,
Słusznie. Myślę, że w niektórych przypadkach dziedziczenie jest uzasadnione, ale tak naprawdę zależy od tego, w jaki sposób planujesz załadować / zapisać / wywołać / zmienić swoje metody i przetwarzanie.
Corentin Pane,
3
Czasami dziedziczenie jest właściwym narzędziem do pracy. Jeśli masz proces, który zachowuje się w większości w ten sam sposób w kilku różnych sytuacjach, ale ma także kilka części, które będą zachowywać się inaczej w różnych sytuacjach, to dobry znak, że powinieneś rozważyć użycie dziedziczenia.
Tanner Swett
5

Możesz utworzyć wspólny interfejs za pomocą Processmetody ...

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

Następnie wdrażasz go dla każdego kraju ...

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

Następnie możesz stworzyć wspólną metodę tworzenia instancji i wykonywania każdej klasy związanej z krajem ...

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

Następnie musisz po prostu utworzyć i używać procesorów tak ...

// 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."));

Oto działający przykład skrzypiec dotnet ...

Umieszczasz wszystkie przetwarzanie specyficzne dla kraju w każdej klasie kraju. Utwórz wspólną klasę (w klasie Przetwarzanie) dla wszystkich rzeczywistych indywidualnych metod, aby każdy procesor kraju stał się listą innych wspólnych wywołań, zamiast kopiować kod w każdej klasie kraju.

Uwaga: musisz dodać ...

using System.Assembly;

aby metoda statyczna utworzyła instancję klasy kraju.

Przywróć Monikę Cellio
źródło
Czy odbicie nie jest wyjątkowo powolne w porównaniu z brakiem odbitego kodu? czy warto w tym przypadku?
jlvaquero,
@jlvaquero Nie, refleksja wcale nie jest wyjątkowo powolna. Oczywiście istnieje spowolnienie wydajności w stosunku do określania typu w czasie projektowania, ale tak naprawdę jest to znikoma różnica w wydajności i zauważalna tylko w przypadku jej nadmiernego użycia. Wdrożyłem duże systemy przesyłania wiadomości zbudowane wokół ogólnej obsługi obiektów i nie mieliśmy żadnego powodu kwestionować wydajności, a to z ogromną przepustowością. Bez zauważalnej różnicy w wydajności zawsze będę używać prostego w utrzymaniu kodu, takiego jak ten.
Przywróć Monikę Cellio
Jeśli zastanawiasz się, czy nie chcesz usunąć ciągu kraju z każdego połączenia do Processi zamiast tego użyć go raz, aby uzyskać prawidłowy procesor IP? Zazwyczaj przetwarzasz dużo tekstu zgodnie z przepisami tego samego kraju.
Davislor,
@Davislor Tak właśnie działa ten kod. Po wywołaniu Process("GBR", "text");wykonuje metodę statyczną, która tworzy instancję procesora GBR i wykonuje na tym metodę Process. Wykonuje to tylko w jednym przypadku dla tego konkretnego typu kraju.
Przywróć Monikę Cellio
@Archer Racja, więc w typowym przypadku, gdy przetwarzasz wiele ciągów znaków zgodnie z regułami dla tego samego kraju, bardziej efektywne byłoby utworzenie instancji raz - lub wyszukiwanie stałej instancji w tablicy mieszającej / Słowniku i powrót odniesienie do tego. Następnie możesz wywołać transformację tekstu w tej samej instancji. Tworzenie nowej instancji dla każdego połączenia, a następnie odrzucanie jej, zamiast ponownego użycia dla każdej rozmowy, jest marnotrawstwem.
Davislor,
3

Kilka wersji temu C # swtich otrzymało pełne wsparcie dla dopasowania wzorca . Łatwo jest więc wykonać przypadek „dopasowania wielu krajów”. Chociaż nadal nie ma zdolności do upadku, jedno wejście może dopasować wiele przypadków z dopasowaniem wzorca. Może to sprawić, że ten spam będzie nieco jaśniejszy.

Npw przełącznik można zwykle zastąpić kolekcją. Musisz używać Delegatów i Słownika. Proces można zastąpić.

public delegate string ProcessDelegate(string text);

Następnie możesz stworzyć Słownik:

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

Użyłem functionNames do przekazania Delegata. Ale możesz użyć składni Lambda, aby podać tam cały kod. W ten sposób możesz po prostu ukryć całą kolekcję, tak jak każdą inną dużą kolekcję. Kod staje się prostym wyszukiwaniem:

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

Są to właściwie dwie opcje. Możesz rozważyć użycie Wyliczeń zamiast ciągów dla dopasowania, ale jest to drobny szczegół.

Krzysztof
źródło
2

Być może wybrałbym (w zależności od szczegółów twojego przypadku użycia) Countrybycie „prawdziwym” obiektem zamiast łańcucha. Słowem kluczowym jest „polimorfizm”.

Zasadniczo wyglądałoby to tak:

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

Następnie możesz utworzyć wyspecjalizowane kraje dla tych, których potrzebujesz. Uwaga: nie musisz tworzyć Countryobiektów dla wszystkich krajów, możesz je mieć LatinlikeCountry, a nawetGenericCountry . Tam możesz zebrać, co należy zrobić, a nawet ponownie użyć innych, takich jak:

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

Lub podobne. Countrymoże tak naprawdę Languagenie jestem pewien co do przypadku użycia, ale rozumiem o co chodzi.

Ponadto, metoda nie powinna oczywiście Process()być rzeczą, którą naprawdę musisz zrobić. Coś Words()lub coś.

Robert Bräutigam
źródło
1
Napisałem coś bardziej wyrazistego, ale myślę, że to jest to, co najbardziej lubię najbardziej. Jeśli przypadek użycia wymaga wyszukania tych obiektów na podstawie ciągu kraju, może użyć rozwiązania Christophera z tym. Implementacją interfejsów może być nawet klasa, której instancje nadają cechy takie jak w odpowiedzi Michała, aby zoptymalizować raczej przestrzeń niż czas.
Davislor,
1

Chcesz przekazać (skinąć głową na łańcuch odpowiedzialności) coś, co wie o swojej kulturze. Więc użyj lub stwórz konstrukcję typu Country lub CultureInfo, jak wspomniano powyżej w innych odpowiedziach.

Ale ogólnie i zasadniczo twój problem polega na tym, że bierzesz konstrukcje proceduralne, takie jak „procesor” i stosujesz je do OO. OO polega na reprezentowaniu rzeczywistych koncepcji z dziedziny biznesu lub problematyki w oprogramowaniu. Procesor nie przekłada się na nic w świecie rzeczywistym oprócz samego oprogramowania. Ilekroć masz zajęcia takie jak Procesor, Menedżer lub Gubernator, powinny zadzwonić dzwonki alarmowe.

Szczery
źródło
0

Zastanawiałem się, czy istnieje jakiś wzorzec, który pomógłby w tego rodzaju procesie

Łańcuch odpowiedzialności jest tym, czego możesz szukać, ale w OOP jest nieco kłopotliwy ...

Co z bardziej funkcjonalnym podejściem do 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;
      }
    }
  }
}

UWAGA: Oczywiście nie musi to być wszystko statyczne. Jeśli klasa procesu potrzebuje stanu, możesz użyć klasy instancji lub częściowo zastosowanej funkcji;).

Możesz zbudować Proces dla każdego kraju podczas uruchamiania, przechowywać każdy z nich w zindeksowanej kolekcji i odzyskać je w razie potrzeby z kosztem O (1).

jlvaquero
źródło
0

Przykro mi, że już dawno wymyśliłem termin „obiekty” w tym temacie, ponieważ wielu ludzi skupia się na mniejszym pomyśle. Ideą jest przesyłanie wiadomości .

~ Alan Kay, On Messaging

Chciałbym po prostu wdrożyć procedury Capitalise, RemovePunctuationitp jako podprocesów, które mogą być z messaged texti countryparametrów, a wróci przetworzony tekst.

Skorzystaj ze słowników, aby pogrupować kraje, które pasują do określonego atrybutu (jeśli wolisz listy, to działałoby również przy niewielkim koszcie wydajności). Na przykład: CapitalisationApplicableCountriesi 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.
}
Igwe Kalu
źródło
0

Uważam, że informacje o krajach powinny być przechowywane w danych, a nie w kodzie. Dlatego zamiast klasy CountryInfo lub słownika CapitalizationApplicableCountries możesz mieć bazę danych z zapisem dla każdego kraju i polem dla każdego etapu przetwarzania, a następnie przetwarzanie może przejść przez pola dla danego kraju i odpowiednio przetworzyć. Utrzymanie odbywa się wtedy głównie w bazie danych, a nowy kod jest potrzebny tylko wtedy, gdy potrzebne są nowe kroki, a dane mogą być czytelne dla ludzi w bazie danych. Zakłada się, że kroki są niezależne i nie kolidują ze sobą; jeśli tak nie jest, sprawy są skomplikowane.

Steve J.
źródło