Kiedy enum NIE jest zapachem kodu?

17

Dylemat

Czytałem wiele książek o najlepszych praktykach na temat praktyk zorientowanych obiektowo, a prawie każda książka, którą przeczytałem, miała tę część, w której mówiły, że enumy to zapach kodu. Myślę, że przegapili tę część, w której wyjaśniają, kiedy wyliczenia są ważne.

Jako taki szukam wytycznych i / lub przypadków użycia, w których wyliczenia NIE są zapachem kodu, a właściwie prawidłową konstrukcją.

Źródła:

„OSTRZEŻENIE Zazwyczaj wyliczenia są zapachami kodowymi i powinny zostać przekształcone w klasy polimorficzne. [8]” Seemann, Mark, Dependency Injection w .Net, 2011, s. 1. 342

[8] Martin Fowler i in., Refactoring: Improving the Design of Existing Code (New York: Addison-Wesley, 1999), 82.

Kontekst

Przyczyną mojego dylematu jest API transakcyjne. Dają mi strumień danych Tick przesyłając tę ​​metodę:

void TickPrice(TickType tickType, double value)

gdzie enum TickType { BuyPrice, BuyQuantity, LastPrice, LastQuantity, ... }

Próbowałem utworzyć opakowanie wokół tego interfejsu API, ponieważ łamanie zmian jest sposobem na życie dla tego interfejsu API. Chciałem śledzić wartość każdego ostatnio otrzymanego typu kleszcza na moim opakowaniu i zrobiłem to, korzystając ze słownika typów kleszczy:

Dictionary<TickType,double> LastValues

Wydawało mi się, że to właściwe użycie wyliczenia, jeśli są one używane jako klucze. Ale mam inne przemyślenia, ponieważ mam miejsce, w którym podejmuję decyzję na podstawie tej kolekcji i nie mogę wymyślić sposobu, w jaki sposób mogę wyeliminować instrukcję zmiany, mógłbym użyć fabryki, ale ta fabryka nadal będzie miała gdzieś instrukcja switch. Wydawało mi się, że po prostu się poruszam, ale wciąż pachnie.

Łatwo jest znaleźć DON'T wyliczeń, ale DO, nie takie łatwe, i byłbym wdzięczny, gdyby ludzie mogli dzielić się swoją wiedzą, zaletami i wadami.

Namysł

Niektóre decyzje i działania opierają się na nich TickTypei wydaje mi się, że nie mogę wymyślić sposobu na wyeliminowanie instrukcji enum / switch. Najczystszym rozwiązaniem, jakie mogę wymyślić, jest użycie fabryki i zwrot implementacji na podstawie TickType. Nawet wtedy nadal będę mieć instrukcję switch, która zwraca implementację interfejsu.

Poniżej wymieniono jedną z przykładowych klas, w których mam wątpliwości, że mógłbym źle użyć wyliczenia:

public class ExecutionSimulator
{
  Dictionary<TickType, double> LastReceived;
  void ProcessTick(TickType tickType, double value)
  {
    //Store Last Received TickType value
    LastReceived[tickType] = value;

    //Perform Order matching only on specific TickTypes
    switch(tickType)
    {
      case BidPrice:
      case BidSize:
        MatchSellOrders();
        break;
      case AskPrice:
      case AskSize:
        MatchBuyOrders();
        break;
    }       
  }
}
stromms
źródło
26
Nigdy nie słyszałem o tym, że enumy są zapachem kodu. Czy możesz podać referencję? Myślę, że mają one ogromny sens dla ograniczonej liczby potencjalnych wartości
Richard Tingle
25
Jakie książki mówią, że wyliczenia są zapachem kodu? Zdobądź lepsze książki.
JacquesB
3
Tak więc odpowiedź na pytanie brzmiałaby „przez większość czasu”.
gnasher729,
5
Ładna, bezpieczna wartość typu, która przekazuje znaczenie? Czy to gwarantuje, że odpowiednie klucze są używane w słowniku? Kiedy to jest zapach kodu?
7
Myślę, że bez kontekstu lepsze sformułowanie enums as switch statements might be a code smell ...
brzmiałoby

Odpowiedzi:

31

Wyliczenia są przeznaczone do użycia, gdy dosłownie wyliczyłeś każdą możliwą wartość, jaką może przyjąć zmienna. Zawsze. Pomyśl o przypadkach użycia, takich jak dni tygodnia lub miesiące w roku lub wartości konfiguracji rejestru sprzętu. Rzeczy, które są zarówno bardzo stabilne, jak i reprezentowalne przez prostą wartość.

Pamiętaj, że jeśli tworzysz warstwę antykorupcyjną, nie możesz uniknąć gdzieś instrukcji switch ze względu na projekt, który pakujesz, ale jeśli zrobisz to dobrze, możesz ograniczyć ją do tego jednego miejsca i użyj polimorfizmu w innym miejscu.

Karl Bielefeldt
źródło
3
To jest najważniejszy punkt - dodanie dodatkowej wartości do wyliczenia oznacza znalezienie każdego użycia tego typu w kodzie i potencjalnie konieczność dodania nowej gałęzi dla nowej wartości. Porównaj z typem polimorficznym, gdzie dodanie nowej możliwości oznacza po prostu utworzenie nowej klasy, która implementuje interfejs - zmiany są skoncentrowane w jednym miejscu, więc łatwiej jest je wprowadzić. Jeśli masz pewność, że nowy typ tiksu nigdy nie zostanie dodany, wyliczenie jest w porządku, w przeciwnym razie powinno być owinięte w interfejs polimorficzny.
Jules
To jedna z odpowiedzi, której szukałem. Po prostu nie mogę tego uniknąć, gdy metoda, którą zawijam, wykorzystuje wyliczenie, a celem jest scentralizowanie tych wyliczeń w jednym miejscu. Muszę zrobić opakowanie tak, aby jeśli API zmienił te wyliczenia, musiałbym tylko zmienić opakowanie, a nie odbiorców opakowania.
stromms,
@Jules - „Jeśli masz pewność, że nowy typ tiksu nigdy nie zostanie dodany ...” To stwierdzenie jest błędne na tak wielu poziomach. Klasa dla każdego typu, pomimo tego, że każdy typ ma dokładnie takie samo zachowanie, jest tak daleka od „rozsądnego” podejścia, jak to tylko możliwe. Dopiero wtedy, gdy będziesz mieć inne zachowania i zaczniesz potrzebować instrukcji „przełącz / jeśli-wtedy”, gdzie należy narysować linię. Z pewnością nie opiera się na tym, czy w przyszłości mogę dodać więcej wartości wyliczeniowych.
Dunk
@dunk Myślę, że źle zrozumiałeś mój komentarz. Nigdy nie zasugerowałem stworzenia wielu typów o identycznym zachowaniu - to byłoby szalone.
Jules
1
Nawet jeśli „wyliczyłeś każdą możliwą wartość”, nie oznacza to, że ten typ nigdy nie będzie miał dodatkowej funkcjonalności na wystąpienie. Nawet coś takiego jak Miesiąc może mieć inne przydatne właściwości, takie jak liczba dni. Zastosowanie wyliczenia natychmiast zapobiega rozszerzeniu koncepcji, którą modelujesz. Jest to w zasadzie mechanizm / wzór anty-OOP.
Dave Cousineau
17

Po pierwsze, zapach kodu nie oznacza, że ​​coś jest nie tak. Oznacza to, że coś może być nie tak. enumpachnie, ponieważ jest często nadużywane, ale to nie znaczy, że musisz ich unikać. Wystarczy, że zaczniesz pisać enum, zatrzymasz się i zaczniesz szukać lepszych rozwiązań.

Szczególny przypadek, który ma miejsce najczęściej, występuje wtedy, gdy różne wyliczenia odpowiadają różnym typom o różnych zachowaniach, ale z tym samym interfejsem. Na przykład rozmawianie z różnymi backendami, renderowanie różnych stron itp. Są one o wiele bardziej naturalnie realizowane za pomocą klas polimorficznych.

W twoim przypadku TickType nie odpowiada różnym zachowaniom. Są to różne typy zdarzeń lub różne właściwości bieżącego stanu. Myślę więc, że jest to idealne miejsce na wyliczenie.

Winston Ewert
źródło
Mówisz, że gdy wyliczenia zawierają Rzeczowniki jako wpisy (np. Kolory), prawdopodobnie są one używane poprawnie. A kiedy są czasownikami (Połączone, Rendering), to znak, że jest niewłaściwie używany?
stromms,
2
@stromms, gramatyka to straszny przewodnik po architekturze oprogramowania. Gdyby TickType był interfejsem zamiast wyliczenia, jakie byłyby metody? Nic nie widzę, dlatego wydaje mi się, że jest to przypadek enum. Inne przypadki, takie jak status, kolory, backend itp., Mogę wymyślić metody i dlatego są to interfejsy.
Winston Ewert,
@WinstonEwert i po edycji wydaje się, że są to różne zachowania.
Och Więc jeśli mogę wymyślić metodę wyliczenia, to może to być kandydat na interfejs? To może być bardzo dobry punkt.
stromms,
1
@stromms, czy możesz udostępnić więcej przykładów przełączników, aby uzyskać lepszy pomysł na korzystanie z TickType?
Winston Ewert,
8

Podczas przesyłania danych wyliczenia nie mają zapachu kodu

IMHO, przy przesyłaniu danych za pomocą enumswskazania, że ​​pole może mieć wartość z ograniczonego (rzadko zmieniającego się) zestawu wartości, jest dobre.

Uważam, że lepiej jest przekazywać dowolnie stringslub ints. Ciągi mogą powodować problemy z powodu zmian pisowni i wielkich liter. Ints pozwalają na transmisję z wartościami wielkości i mają małe semantykę (np otrzymam 3od usługi handlu, co to znaczy? LastPrice? LastQuantity? Coś jeszcze?

Przesyłanie obiektów i używanie hierarchii klas nie zawsze jest możliwe; na przykład nie pozwala odbiorcy na rozróżnienie, która klasa została wysłana.

W moim projekcie usługa używa hierarchii klas do efektów operacji, tuż przed przesłaniem przez DataContractobiekt z hierarchii klas jest kopiowana do unionpodobnego obiektu, który zawiera symbol enumwskazujący typ. Klient otrzymuje DataContracti tworzy obiekt klasy w hierarchii przy użyciu enumwartości switchi tworzy obiekt poprawnego typu.

Innym powodem, dla którego nie chcemy przesyłać obiektów klasy, jest to, że usługa może wymagać zupełnie innego zachowania dla przesyłanego obiektu (np. LastPrice) Niż klient. W takim przypadku wysyłanie klasy i jej metod jest niepożądane.

Czy instrukcje przełączników są złe?

IMHO, pojedyncza informacja, switch która wywołuje różne konstruktory w zależności od, enumnie jest zapachem kodu. Niekoniecznie jest lepsza lub gorsza od innych metod, takich jak refleksja oparta na nazwie typu; zależy to od faktycznej sytuacji.

Po włączeniu przełączników enumwszędzie jest zapach kodu, zapewnia alternatywy, które są często lepsze:

  • Użyj obiektu różnych klas hierarchii, które zastąpiły metody; tj. polimorfizm.
  • Użyj wzorca gościa, gdy klasy w hierarchii rzadko się zmieniają i kiedy (wiele) operacji powinno być luźno powiązanych z klasami w hierarchii.
Kasper van den Berg
źródło
W rzeczywistości TickType jest przesyłany przez drut jako int. Moje opakowanie jest tym, które używa, TickTypektóre jest rzutowane z otrzymanego int. Kilka zdarzeń wykorzystuje ten typ tygrysa z dziko zmieniającymi się podpisami, które są odpowiedziami na różne żądania. Czy powszechną praktyką jest intciągłe używanie do różnych funkcji? np. TickPrice(int type, double value)używa 1,3 i 6, typepodczas gdy TickSize(int type, double valueużywa 2,4 i 5? Czy sens ma nawet rozdzielenie ich na dwa zdarzenia?
stromms,
2

co jeśli wybierzesz bardziej złożony typ:

    abstract class TickType
    {
      public abstract string Name {get;}
      public abstract double TickValue {get;}
    }

    class BuyPrice : TickType
    {
      public override string Name { get { return "Buy Price"; } }
      public override double TickValue { get { return 2.35d; } }
    }

    class BuyQuantity : TickType
    {
      public override string Name { get { return "Buy Quantity"; } }
      public override double TickValue { get { return 4.55d; } }
    }
//etcetera

wtedy możesz załadować swoje typy z refleksji lub sam je zbudować, ale najważniejsze jest to, że trzymasz się zasady otwartego zamykania SOLID

Chris
źródło
1

TL; DR

Aby odpowiedzieć na pytanie, mam teraz trudności z myśleniem, że wyliczenia czasu nie są zapachem kodu na pewnym poziomie. Istnieje pewna intencja, aby deklarowali efektywnie (istnieje wyraźnie ograniczona, ograniczona liczba możliwości dla tej wartości), ale ich z natury zamknięty charakter czyni je gorszymi architektonicznie.

Przepraszam, gdy zmieniam tonę mojego starszego kodu. / westchnienie; ^ D


Ale dlaczego?

Całkiem niezła recenzja, dlaczego tak jest w przypadku LosTechies tutaj :

// calculate the service fee
public double CalculateServiceFeeUsingEnum(Account acct)
{
    double totalFee = 0;
    foreach (var service in acct.ServiceEnums) { 

        switch (service)
        {
            case ServiceTypeEnum.ServiceA:
                totalFee += acct.NumOfUsers * 5;
                break;
            case ServiceTypeEnum.ServiceB:
                totalFee += 10;
                break;
        }
    }
    return totalFee;
} 

Ma to te same problemy, co powyższy kod. W miarę powiększania się aplikacji, szanse na podobne wyciągi branżowe będą rosły. W miarę wprowadzania kolejnych usług premium będziesz musiał ciągle modyfikować ten kod, co narusza zasadę otwartego i zamkniętego. Są tu także inne problemy. Funkcja obliczania opłaty za usługę nie powinna znać faktycznych kwot każdej usługi. To jest informacja, którą należy zamknąć.

Lekko na bok: wyliczenia są bardzo ograniczoną strukturą danych. Jeśli nie używasz wyliczenia do tego, czym naprawdę jest, oznaczoną liczbą całkowitą, potrzebujesz klasy, aby naprawdę poprawnie modelować abstrakcję. Możesz użyć niesamowitej klasy Enumeration Jimmy'ego, aby użyć klas także do oznaczania ich etykietami.

Przeanalizujmy to, aby zastosować zachowanie polimorficzne. Potrzebujemy abstrakcji, która pozwoli nam ograniczyć zachowanie niezbędne do obliczenia opłaty za usługę.

public interface ICalculateServiceFee
{
    double CalculateServiceFee(Account acct);
}

...

Teraz możemy stworzyć nasze konkretne implementacje interfejsu i dołączyć je [do] konta.

public class Account{
    public int NumOfUsers{get;set;}
    public ICalculateServiceFee[] Services { get; set; }
} 

public class ServiceA : ICalculateServiceFee
{
    double feePerUser = 5; 

    public double CalculateServiceFee(Account acct)
    {
        return acct.NumOfUsers * feePerUser;
    }
} 

public class ServiceB : ICalculateServiceFee
{
    double serviceFee = 10;
    public double CalculateServiceFee(Account acct)
    {
        return serviceFee;
    }
} 

Kolejny przypadek wdrożenia ...

Najważniejsze jest to, że jeśli masz zachowanie zależne od wartości wyliczeniowych, dlaczego zamiast tego nie masz różnych implementacji podobnego interfejsu lub klasy nadrzędnej, które zapewniają, że ta wartość istnieje? W moim przypadku patrzę na różne komunikaty o błędach w oparciu o kody stanu REST. Zamiast...

private static string _getErrorCKey(int statusCode)
{
    string ret;

    switch (statusCode)
    {
        case StatusCodes.Status403Forbidden:
            ret = "BRANCH_UNAUTHORIZED";
            break;

        case StatusCodes.Status422UnprocessableEntity:
            ret = "BRANCH_NOT_FOUND";
            break;

        default:
            ret = "BRANCH_GENERIC_ERROR";
            break;
    }

    return ret;
}

... może powinienem zawrzeć kody statusu w klasach.

public interface IAmStatusResult
{
    int StatusResult { get; }    // Pretend an int's okay for now.
    string ErrorKey { get; }
}

Następnie za każdym razem, gdy potrzebuję nowego typu IAmStatusResult, koduję go ...

public class UnauthorizedBranchStatusResult : IAmStatusResult
{
    public int StatusResult => 403;
    public string ErrorKey => "BRANCH_UNAUTHORIZED";
}

... a teraz mogę upewnić się, że wcześniejszy kod zdaje sobie sprawę, że ma IAmStatusResultzasięg i odwołuje się do niego entity.ErrorKeyzamiast do bardziej skomplikowanego, deadend _getErrorCode(403).

I, co ważniejsze, za każdym razem, gdy dodam nowy typ wartości zwracanej, nie trzeba dodawać żadnego innego kodu, aby ją obsłużyć . Co wiesz, enumi switchprawdopodobnie były to zapachy kodowe.

Zysk.

bułka z masłem
źródło
Jak o przypadek, w którym, na przykład, ja mam klas polimorficznych i chcę skonfigurować, które jeden Używam poprzez parametr wiersza poleceń? Wiersz poleceń -> enum -> przełącznik / mapa -> implementacja wydaje się całkiem uzasadnionym przypadkiem użycia. Myślę, że kluczową rzeczą jest to, że wyliczenie jest wykorzystywane do aranżacji, a nie jako implementacja logiki warunkowej.
Ant P
@AntP Dlaczego w tym przypadku nie użyć StatusResultwartości? Można argumentować, że wyliczanie jest przydatne jako niezapomniany dla człowieka skrót w tym przypadku użycia, ale prawdopodobnie nadal nazwałbym to zapachem kodu, ponieważ istnieją dobre alternatywy, które nie wymagają zamkniętego zbioru.
ruffin
Jakie alternatywy? Sznurek? Jest to arbitralne rozróżnienie z punktu widzenia „wyliczenia należy zastąpić polimorfizmem” - każde z tych podejść wymaga mapowania wartości na typ; unikanie wyliczenia w tym przypadku niczego nie osiąga.
Ant P
@AntP Nie jestem pewien, gdzie krzyżują się tutaj przewody. Jeśli odwołujesz się do właściwości w interfejsie, możesz ominąć ten interfejs w dowolnym miejscu i nigdy nie aktualizować tego kodu podczas tworzenia nowej (lub usuwania istniejącej) implementacji tego interfejsu . Jeśli masz enum, to nie trzeba kodu aktualizacji za każdym razem dodać lub usunąć wartość i potencjalnie wiele miejsc, w zależności od tego, jak skonstruowany kod. Używanie polimorfizmu zamiast wyliczenia jest trochę jak upewnienie się, że kod jest w normalnej formie Boyce-Codda .
ruffin
Tak. Załóżmy teraz, że masz N takich polimorficznych implementacji. W artykule Los Techies po prostu iterują przez wszystkie z nich. Ale co, jeśli chcę warunkowo zastosować tylko jedną implementację na podstawie np. Parametrów wiersza poleceń? Teraz musimy zdefiniować mapowanie z pewnej wartości konfiguracyjnej na jakiś typ implementacji do wstrzykiwania. Wyliczenie w tym przypadku jest tak samo dobre, jak każdy inny typ konfiguracji. Jest to przykład sytuacji, w której nie ma już możliwości zastąpienia „brudnego enum” polimorfizmem. Rozgałęzienie według abstrakcji może zabrać cię tylko do tej pory.
Ant P
0

To, czy użycie wyliczenia jest zapachem kodu, zależy od kontekstu. Myślę, że możesz znaleźć pomysły na odpowiedź na twoje pytanie, jeśli weźmiesz pod uwagę problem z ekspresją . Tak więc masz kolekcję różnych typów i zestaw operacji na nich, i musisz uporządkować swój kod. Istnieją dwie proste opcje:

  • Zorganizuj kod zgodnie z operacjami. W takim przypadku możesz użyć wyliczenia do oznaczenia różnych typów i mieć instrukcję przełączającą w każdej procedurze, która używa oznaczonych danych.
  • Zorganizuj kod zgodnie z typami danych. W takim przypadku można zastąpić wyliczenie interfejsem i użyć klasy dla każdego elementu wyliczenia. Następnie implementujesz każdą operację jako metodę w każdej klasie.

Które rozwiązanie jest lepsze?

Jeśli, jak wskazał Karl Bielefeldt, twoje typy są stałe i oczekujesz, że system będzie się rozwijał głównie poprzez dodawanie nowych operacji na tych typach, wówczas użycie wyliczenia i posiadanie instrukcji switch jest lepszym rozwiązaniem: za każdym razem, gdy potrzebujesz nowej operacji, po prostu zaimplementuj nową procedurę, podczas gdy za pomocą klas będziesz musiał dodać metodę do każdej klasy.

Z drugiej strony, jeśli oczekujesz raczej stabilnego zestawu operacji, ale uważasz, że z czasem będziesz musiał dodać więcej typów danych, korzystanie z rozwiązania obiektowego jest wygodniejsze: ponieważ nowe typy danych muszą zostać zaimplementowane, po prostu dodawaj nowe klasy implementujące ten sam interfejs, natomiast jeśli korzystasz z wyliczenia, musisz zaktualizować wszystkie instrukcje switch we wszystkich procedurach korzystających z wyliczenia.

Jeśli nie możesz sklasyfikować swojego problemu w żadnej z dwóch powyższych opcji, możesz spojrzeć na bardziej wyrafinowane rozwiązania (patrz np. Ponownie na cytowaną powyżej stronę Wikipedii w celu krótkiej dyskusji i odniesienia do dalszego czytania).

Powinieneś więc spróbować zrozumieć, w jakim kierunku może ewoluować Twoja aplikacja, a następnie wybrać odpowiednie rozwiązanie.

Ponieważ książki, na które się powołujesz, dotyczą paradygmatu zorientowanego obiektowo, nic dziwnego, że są one stronnicze w stosunku do używania wyliczeń. Jednak rozwiązanie zorientowane obiektowo nie zawsze jest najlepszą opcją.

Konkluzja: wyliczenia niekoniecznie są zapachem kodu.

Giorgio
źródło
zwróć uwagę, że pytanie zostało zadane w kontekście C #, języka OOP. interesujące jest też to, że grupowanie według operacji lub według rodzaju to dwie strony tej samej monety, ale nie uważam, że jedna jest „lepsza” od drugiej, jak to opisujesz. wynikowa ilość kodu jest taka sama, a języki OOP już ułatwiają organizowanie według typu. produkuje również znacznie bardziej intuicyjny (łatwy do odczytania) kod.
Dave Cousineau
@DaveCousineau: „Nie widzę, aby jedno było„ lepsze ”od drugiego, jak to opisujesz”: Nie powiedziałem, że jedno jest zawsze lepsze od drugiego. Powiedziałem, że jedno jest lepsze od drugiego w zależności od kontekstu. Na przykład, jeśli operacje są mniej więcej stałe i planujemy dodanie nowych typów (np GUI ze stałym paint(), show(), close() resize()operacje i niestandardowe widgety) to podejście zorientowane obiektowo jest lepszy w tym sensie, że pozwala na dodanie nowego typu bez wpływu zbyt dużo istniejącego kodu (w zasadzie implementujesz nową klasę, która jest lokalną zmianą).
Giorgio
Nie uważam też, że jest „lepszy”, jak to opisałeś, co oznacza, że ​​nie widzę, że zależy to od sytuacji. Ilość kodu, który musisz napisać, jest dokładnie taka sama w obu scenariuszach. Jedyna różnica polega na tym, że jeden sposób jest przeciwny do OOP (wyliczeń), a drugi nie.
Dave Cousineau,
@DaveCousineau: Ilość kodu, który musisz napisać, jest prawdopodobnie taka sama, lokalizacja (miejsca w kodzie źródłowym, które musisz zmienić i ponownie skompilować) zmienia się drastycznie. Na przykład w OOP, jeśli dodasz nową metodę do interfejsu, musisz dodać implementację dla każdej klasy, która implementuje ten interfejs.
Giorgio
Czy to nie tylko kwestia szerokości kontra głębokości? Dodanie 1 metody do 10 plików lub 10 metod do 1 pliku.
Dave Cousineau