Czy wyliczenia tworzą kruche interfejsy?

17

Rozważ poniższy przykład. Każda zmiana wyliczenia ColorChoice wpływa na wszystkie podklasy IWindowColor.

Czy wytryski powodują kruche interfejsy? Czy istnieje coś lepszego niż wyliczenie, które pozwala na większą elastyczność polimorficzną?

enum class ColorChoice
{
    Blue = 0,
    Red = 1
};

class IWindowColor
{
public:
    ColorChoice getColor() const=0;
    void setColor( const ColorChoice value )=0;
};

Edycja: przepraszam za użycie koloru jako mojego przykładu, nie o to chodzi w tym pytaniu. Oto inny przykład, który pozwala uniknąć czerwonego śledzia i zawiera więcej informacji na temat tego, co rozumiem przez elastyczność.

enum class CharacterType
{
    orc = 0,
    elf = 1
};

class ISomethingThatNeedsToKnowWhatTypeOfCharacter
{
public:
    CharacterType getCharacterType() const;
    void setCharacterType( const CharacterType value );
};

Ponadto, wyobraź sobie, że uchwyty do odpowiedniej podklasy ISomethingThatNeedsToKnowWhatTypeOfCharacter są wydawane na podstawie fabrycznego wzorca projektowego. Teraz mam interfejs API, którego nie można rozszerzyć w przyszłości dla innej aplikacji, w której dopuszczalnymi typami znaków są {człowiek, karzeł}.

Edycja: Aby być bardziej konkretnym na temat tego, nad czym pracuję. Projektuję silne wiązanie tej specyfikacji ( MusicXML ) i używam klas enum do reprezentowania tych typów w specyfikacji, które są zadeklarowane za pomocą xs: enumeration. Próbuję myśleć o tym, co się stanie, gdy pojawi się kolejna wersja (4.0). Czy moja biblioteka klas może pracować w trybie 3.0 i 4.0? Jeśli następna wersja jest w 100% kompatybilna wstecz, to może. Ale jeśli wartości wyliczenia zostały usunięte ze specyfikacji, to jestem martwy w wodzie.

Matthew James Briggs
źródło
2
Kiedy mówisz „elastyczność polimorficzna”, jakie dokładnie masz na myśli możliwości?
Ixrec,
3
Używanie wyliczenia kolorów tworzy kruche interfejsy, a nie tylko „używanie wyliczenia”.
Doc Brown
3
Dodanie nowego wariantu wyliczania powoduje uszkodzenie kodu przy użyciu tego wyliczenia. Z drugiej strony dodanie nowej operacji do wyliczenia jest dość samowystarczalne, ponieważ wszystkie przypadki, które należy załatwić, znajdują się właśnie tam (w przeciwieństwie do interfejsów i nadklas, gdzie dodanie metody innej niż domyślna jest poważną przełomową zmianą). To zależy od tego, jakie zmiany są naprawdę konieczne.
1
Re MusicXML: Jeśli z plików XML nie można łatwo stwierdzić, której wersji schematu używa każdy z nich, uderza mnie to jako krytyczny błąd projektowy w specyfikacji. Jeśli musisz to jakoś obejść, prawdopodobnie nie będziemy w stanie pomóc, dopóki nie dowiemy się dokładnie, co zdecydują się włamać w wersji 4.0 i nie możesz zapytać o konkretny problem, który powoduje.
Ixrec,

Odpowiedzi:

25

Przy właściwym stosowaniu wyliczenia są znacznie bardziej czytelne i niezawodne niż „magiczne liczby”, które zastępują. Zwykle nie widzę, aby kod był bardziej kruchy. Na przykład:

  • setColor () nie musi tracić czasu na sprawdzanie, czy valuejest prawidłową wartością koloru, czy nie. Kompilator już to zrobił.
  • Możesz napisać setColor (Color :: Red) zamiast setColor (0). Wierzę, że enum classfunkcja we współczesnym C ++ pozwala nawet zmusić ludzi do pisania pierwszego zamiast drugiego.
  • Zwykle nie jest to ważne, ale większość wyliczeń można zaimplementować za pomocą dowolnego typu całki o rozmiarze, dzięki czemu kompilator może wybrać najbardziej dogodny rozmiar bez zmuszania do myślenia o takich rzeczach.

Jednak użycie wyliczenia koloru jest wątpliwe, ponieważ w wielu (większości?) Sytuacjach nie ma powodu, aby ograniczać użytkownika do tak małego zestawu kolorów; równie dobrze możesz pozwolić im przekazywać dowolne wartości RGB. W projektach, nad którymi pracuję, mała lista kolorów, takich jak ta, pojawiałaby się tylko jako część zestawu „motywów” lub „stylów”, które powinny działać jak cienka abstrakcja nad konkretnymi kolorami.

Nie jestem pewien, do czego zmierza twoje pytanie dotyczące „elastyczności polimorficznej”. Wyliczenia nie mają żadnego kodu wykonywalnego, więc nie ma nic, co można by uczynić polimorficznym. Być może szukasz wzorca poleceń ?

Edycja: Po edycji nadal nie jestem pewien, jakiego rodzaju rozszerzalności szukasz, ale nadal uważam, że wzorzec poleceń jest najbliższą rzeczą, jaką uzyskasz w przypadku „wyliczenia polimorficznego”.

Ixrec
źródło
Gdzie mogę dowiedzieć się więcej o tym, że 0 nie może być zaliczane do enum?
TankorSmash
5
@TankorSmash C ++ 11 wprowadził „klasę wyliczania”, zwaną także „wyliczeniami o zasięgu”, której nie można niejawnie przekonwertować na podstawowy typ liczbowy. Unikają również zanieczyszczania przestrzeni nazw, tak jak robią to stare typy „enum” w stylu C.
Matthew James Briggs,
2
Wyliczenia są zwykle poparte liczbami całkowitymi. Istnieje wiele dziwnych rzeczy, które mogą się zdarzyć podczas serializacji / deserializacji lub rzutowania między liczbami całkowitymi i wyliczeniami. Zakładanie, że wyliczenie zawsze będzie miało prawidłową wartość, nie zawsze jest bezpieczne.
Eric
1
Masz rację, Eric (i to jest problem, z którym sam miałem do czynienia wiele razy). Jednak na etapie deserializacji należy się tylko martwić o potencjalnie niepoprawną wartość. Za każdym razem, gdy używasz wyliczeń, możesz założyć, że wartość jest poprawna (przynajmniej dla wyliczeń w językach takich jak Scala - niektóre języki nie mają bardzo silnego sprawdzania typu wyliczeń).
Kat
1
@Ixrec ”, ponieważ w wielu (większości?) Sytuacjach nie ma powodu, aby ograniczać użytkownika do tak małego zestawu kolorów” Istnieją uzasadnione przypadki. .Net Console emuluje starą konsolę Windows, która może mieć tekst tylko w jednym z 16 kolorów (16 kolorów standardu CGA) msdn.microsoft.com/en-us/library/…
Pharap
15

Każda zmiana wyliczenia ColorChoice wpływa na wszystkie podklasy IWindowColor.

Nie, nie ma. Istnieją dwa przypadki: realizatorzy też

  • zapisz, zwróć i przekaż wartości wyliczenia, nigdy na nich nie operując, w takim przypadku zmiany wyliczenia nie mają na nie wpływu, lub

  • działają na poszczególnych wartościach wyliczeniowych, w którym to przypadku wszelkie zmiany w wyliczeniu muszą oczywiście, oczywiście, nieuchronnie, koniecznie , zostać uwzględnione z odpowiednią zmianą logiki implementatora.

Jeśli umieścisz „Kula”, „Prostokąt” i „Piramida” w wyliczeniu „Kształt” i przekażesz taki enum do drawSolid()funkcji, którą napisałeś, aby narysować odpowiednią bryłę, a następnie pewnego ranka zdecydujesz się dodać „ Ikositetrahedron ”do wyliczenia, nie można oczekiwać, że drawSolid()funkcja pozostanie niezmieniona; Jeśli spodziewałeś się, że w jakiś sposób narysuje dwunastościany bez konieczności pisania właściwego kodu do rysowania dwunastościanów, to twoja wina, a nie wina wyliczenia. Więc:

Czy wytryski powodują kruche interfejsy?

Nie, nie robią tego. To, co powoduje kruche interfejsy, to programiści myślący o sobie jako ninja, próbujący skompilować swój kod bez włączonej wystarczającej liczby ostrzeżeń. Następnie kompilator nie ostrzega ich, że w ich drawSolid()funkcji znajduje się switchinstrukcja, w której brakuje caseklauzuli dla nowo dodanej wartości wyliczenia „Ikositetrahedron”.

Sposób, w jaki powinien działać, jest analogiczny do dodawania nowej czystej metody wirtualnej do klasy bazowej: musisz następnie zaimplementować tę metodę na każdym spadkobiercy, w przeciwnym razie projekt nie buduje i nie powinien.


Prawdę mówiąc, wyliczenia nie są konstrukcją obiektową. Są bardziej pragmatycznym kompromisem między paradygmatem obiektowym a paradygmatem programowania strukturalnego.

Czysto zorientowany obiektowo sposób robienia rzeczy wcale nie ma wyliczeń, a zamiast tego ma przedmioty do końca.

Zatem czysto obiektowym sposobem implementacji przykładu z ciałami stałymi jest oczywiście użycie polimorfizmu: zamiast mieć jedną monstrualną scentralizowaną metodę, która wie, jak narysować wszystko i trzeba powiedzieć, którą bryłę narysować, deklarujesz „ Solidna klasa z abstrakcyjną (czystą wirtualną) draw()metodą, a następnie dodajesz podklasy „Kula”, „Prostokąt” i „Piramida”, z których każda ma własną implementację, z draw()której umie rysować.

W ten sposób, kiedy wprowadzisz podklasę „Ikositetrahedron”, będziesz musiał po prostu podać jej draw()funkcję, a kompilator przypomni ci o tym, nie pozwalając na utworzenie instancji „Icositetrahedron” w innym przypadku.

Mike Nakis
źródło
Czy masz jakieś wskazówki dotyczące rzucania ostrzeżenia o czasie kompilacji dla tych przełączników? Zwykle rzucam wyjątki czasu wykonywania; ale czas kompilacji może być świetny! Nie tak świetne ... ale przychodzą na myśl testy jednostkowe.
Vaughan Hilts
1
Minęło trochę czasu, odkąd ostatni raz użyłem C ++, więc nie jestem pewien, ale oczekiwałbym, że podanie parametru -Wall do kompilatora i pominięcie default:klauzuli powinno to zrobić. Szybkie wyszukiwanie na ten temat nie przyniosło żadnych konkretnych rezultatów, więc może to być przydatne jako przedmiot innego programmers SEpytania.
Mike Nakis,
W C ++ można włączyć kontrolę czasu kompilacji, jeśli ją włączysz. W języku C # każda kontrola musiałaby odbywać się w czasie wykonywania. Za każdym razem, gdy jako parametr przyjmuję wyliczenie nie będące flagą, sprawdzam poprawność za pomocą Enum.IsDefined, ale nadal oznacza to, że musisz ręcznie zaktualizować wszystkie zastosowania wyliczenia, gdy dodajesz nową wartość. Zobacz: stackoverflow.com/questions/12531132/…
Mike obsługuje Monikę
1
Mam nadzieję, że programista zorientowany obiektowo nigdy nie stworzyłby obiektu do przesyłania danych, tak jak Pyramidfaktycznie wiedziałby, jak zrobić draw()piramidę. W najlepszym wypadku może pochodzić Solidi mieć GetTriangles()metodę, którą można przekazać do SolidDrawerusługi. Myślałem, że uciekamy od przykładów obiektów fizycznych jako przykładów obiektów w OOP.
Scott Whitlock,
1
Jest ok, po prostu nie lubiłem nikogo, kto bawi dobre stare programowanie obiektowe. :) Zwłaszcza, że ​​większość z nas łączy funkcjonalne programowanie i OOP więcej niż ściśle OOP.
Scott Whitlock,
14

Wyliczenia nie tworzą kruchych interfejsów. Niewłaściwe użycie enum.

Do czego służą wyliczenia?

Wyliczenia są zaprojektowane do użycia jako zestawy znaczących nazw stałych. Należy ich używać, gdy:

  • Wiesz, że żadne wartości nie zostaną usunięte.
  • (I) Wiesz, że jest bardzo mało prawdopodobne, aby nowa wartość była potrzebna.
  • (Lub) Akceptujesz, że nowa wartość będzie potrzebna, ale rzadko na tyle, aby uzasadnić naprawienie całego kodu, który łamie się z tego powodu.

Dobre wykorzystanie wyliczeń:

  • Dni tygodnia: (zgodnie z .Net's System.DayOfWeek) O ile nie masz do czynienia z jakimś niezwykle niejasnym kalendarzem, będą tylko 7 dni w tygodniu.
  • Kolory nierozciągliwe: (według .Net System.ConsoleColor) Niektórzy mogą się z tym nie zgadzać, ale .Net zdecydował się to zrobić z jakiegoś powodu. W systemie konsoli .Net dostępnych jest tylko 16 kolorów do użycia przez konsolę. Te 16 kolorów odpowiada starszej palecie kolorów znanej jako CGA lub „Color Graphics Adapter” . Żadne nowe wartości nigdy nie zostaną dodane, co oznacza, że ​​w rzeczywistości jest to rozsądne zastosowanie wyliczenia.
  • Wyliczenia reprezentujące stany ustalone: (zgodnie z Javą Thread.State) Projektanci Javy zdecydowali, że w modelu wątków Java zawsze będzie ustalony zestaw stanów, w których Threadmoże znajdować się a, aby uprościć sprawy, te różne stany są reprezentowane jako wyliczenia . Oznacza to, że wiele sprawdzeń opartych na stanie jest prostymi „if” i przełącznikami, które w praktyce działają na wartościach całkowitych, bez programisty, który musi się martwić o rzeczywiste wartości.
  • Bitflagi reprezentujące opcje niewykluczające się wzajemnie: (jak na .Net's System.Text.RegularExpressions.RegexOptions) Flagi bitowe są bardzo powszechnym zastosowaniem wyliczeń. Tak powszechne, że w .Net wszystkie wyliczenia mają HasFlag(Enum flag)wbudowaną metodę. Obsługują one również operatory bitowe i istnieje FlagsAttributemożliwość oznaczenia wyliczenia jako przeznaczonego do użycia jako zestawu bitflagów. Używając wyliczenia jako zestawu flag, możesz reprezentować grupę wartości boolowskich w pojedynczej wartości, a także mieć wyraźne nazwy flag dla wygody. Byłoby to niezwykle korzystne do reprezentowania flag rejestru statusu w emulatorze lub do reprezentowania uprawnień do pliku (odczyt, zapis, wykonywanie) lub w prawie każdej sytuacji, w której zestaw powiązanych opcji nie wyklucza się wzajemnie.

Niewłaściwe użycie wyliczeń:

  • Klasy / typy postaci w grze: O ile gra nie jest jednorazową wersją demonstracyjną, z której prawdopodobnie nie skorzystasz ponownie, nie należy używać enu dla klas postaci, ponieważ są szanse, że będziesz chciał dodać o wiele więcej klas. Lepiej jest mieć jedną klasę reprezentującą postać i mieć „typ” postaci w grze reprezentowany inaczej. Jednym ze sposobów radzenia sobie z tym jest wzorzec TypeObject , inne rozwiązania obejmują rejestrację typów znaków w rejestrze słownika / typu lub ich kombinację.
  • Rozszerzalne kolory: jeśli używasz wyliczenia dla kolorów, które mogą być później dodane, nie jest dobrym pomysłem reprezentowanie tego w postaci wyliczenia, w przeciwnym razie zostaniesz na zawsze dodając kolory. Jest to podobne do powyższego problemu, dlatego należy zastosować podobne rozwiązanie (tj. Wariant TypeObject).
  • Rozszerzalny stan: Jeśli masz maszynę stanu, która może wprowadzić wiele innych stanów, nie jest dobrym pomysłem reprezentowanie tych stanów za pomocą wyliczeń. Preferowaną metodą jest zdefiniowanie interfejsu dla stanu maszyny, zapewnienie implementacji lub klasy, która otacza interfejs i deleguje wywołania metod (podobnie do wzorca strategii ), a następnie zmienia jego stan poprzez zmianę, która podana implementacja jest aktualnie aktywna.
  • Bitflagi reprezentujące wzajemnie wykluczające się opcje: Jeśli używasz wyliczeń do reprezentowania flag, a dwie z tych flag nigdy nie powinny występować razem, to postrzeliłeś się w stopę. Wszystko, co jest zaprogramowane do reagowania na jedną z flag, nagle zareaguje na dowolną flagę, dla której zaprogramowano reagowanie jako pierwsza - lub, co gorsza, może odpowiedzieć na oba. Tego rodzaju sytuacja wymaga tylko kłopotów. Najlepszym podejściem jest potraktowanie braku flagi jako warunku alternatywnego, jeśli to możliwe (tzn. Nieobecność Trueflagi False). To zachowanie może być dalej wspierane przez użycie specjalistycznych funkcji (tj. IsTrue(flags)I IsFalse(flags)).
Pharap
źródło
Dodam przykłady bitflagów, jeśli ktoś może znaleźć działający lub dobrze znany przykład wyliczeń używanych jako bitflagi. Wiem, że istnieją, ale niestety nie mogę ich teraz przypomnieć.
Pharap,
1
.Net za RegexOptions msdn.microsoft.com/en-us/library/...
BLSully
@BLSully Doskonały przykład. Nie mogę powiedzieć, że kiedykolwiek z nich korzystałem, ale istnieją z jakiegoś powodu.
Pharap,
4

Wyliczenia to doskonała poprawa w porównaniu z magicznymi numerami identyfikacyjnymi dla zamkniętych zestawów wartości, które nie są powiązane z wieloma funkcjami. Zwykle nie obchodzi cię, jaki numer jest faktycznie związany z wyliczeniem; w tym przypadku łatwo jest rozszerzyć, dodając nowe wpisy na końcu, nie powinna wynikać kruchość.

Problem występuje, gdy masz znaczną funkcjonalność związaną z wyliczeniem. Oznacza to, że masz taki kod:

switch (my_enum) {
case orc: growl(); break;
case elf: sing(); break;
...
}

Jeden lub dwa z nich są w porządku, ale gdy tylko pojawi się tego rodzaju znaczący wyliczenie, switchstwierdzenia te mają tendencję do mnożenia się jak tribble. Teraz za każdym razem, gdy przedłużasz wyliczanie, musisz wytropić wszystkie powiązane switches, aby upewnić się, że wszystko pokryłeś. To jest kruche. Źródła takie jak Clean Code sugerują, że powinieneś mieć jeden switchnajwyżej enum.

Zamiast tego w tym przypadku powinieneś użyć zasad OO i stworzyć interfejs dla tego typu. Nadal możesz zachować wyliczenie do komunikowania się z tym typem, ale gdy tylko będziesz musiał coś z tym zrobić, tworzysz obiekt powiązany z wyliczeniem, prawdopodobnie używając Fabryki. Jest to o wiele łatwiejsze w utrzymaniu, ponieważ musisz tylko znaleźć jedno miejsce do aktualizacji: swoją Fabrykę i dodając nową klasę.

congusbongus
źródło
1

Jeśli jesteś ostrożny w sposobie ich używania, nie uważam wyliczeń za szkodliwe. Ale jest kilka rzeczy do rozważenia, jeśli chcesz użyć ich w jakimś kodzie biblioteki, w przeciwieństwie do pojedynczej aplikacji.

  1. Nigdy nie usuwaj ani nie zmieniaj kolejności wartości. Jeśli w którymś momencie masz na liście wartość wyliczającą, wartość ta powinna być związana z tą nazwą na całą wieczność. Jeśli chcesz, możesz w pewnym momencie zmienić nazwy wartości na deprecated_orccokolwiek, ale nie usuwając ich, możesz łatwiej zachować zgodność ze starym kodem skompilowanym ze starym zestawem wyliczeń. Jeśli jakiś nowy kod nie radzi sobie ze starymi stałymi wyliczania, wygeneruj odpowiedni błąd lub upewnij się, że żadna taka wartość nie osiągnie tego fragmentu kodu.

  2. Nie rób na nich arytmetyki. W szczególności nie porównuj zamówień. Dlaczego? Ponieważ wtedy możesz spotkać się z sytuacją, w której nie możesz zachować istniejących wartości i jednocześnie zachować rozsądny porządek. Weźmy na przykład wyliczenie dla kierunków kompasu: N = 0, NE = 1, E = 2, SE = 3,… Teraz, jeśli po aktualizacji wprowadzisz NNE i tak dalej między istniejącymi kierunkami, możesz dodać je na końcu listy i w ten sposób przerywa porządkowanie lub przeplata się je z istniejącymi kluczami, a tym samym łamie ustalone mapowania używane w starszym kodzie. Lub przestarzałe wszystkie stare klucze i masz zupełnie nowy zestaw kluczy, wraz z pewnym kodem kompatybilności, który tłumaczy stare i nowe klucze ze względu na starszy kod.

  3. Wybierz odpowiedni rozmiar. Domyślnie kompilator użyje najmniejszej liczby całkowitej, która może zawierać wszystkie wartości wyliczeniowe. Co oznacza, że ​​jeśli przy pewnej aktualizacji Twój zestaw możliwych wyliczeń powiększy się z 254 do 259, nagle potrzebujesz 2 bajtów na każdą wartość wyliczenia zamiast jednego. Może to zniszczyć strukturę i układy klas w całym miejscu, więc staraj się tego uniknąć, stosując wystarczającą wielkość w pierwszym projekcie. C ++ 11 daje tutaj dużą kontrolę, ale w przeciwnym razie podanie wpisu również LAST_SPECIES_VALUE=65535powinno pomóc.

  4. Posiadaj centralny rejestr. Ponieważ chcesz naprawić mapowanie między nazwami a wartościami, źle jest pozwolić innym użytkownikom kodu na dodawanie nowych stałych. Żaden projekt używający twojego kodu nie powinien mieć możliwości zmiany tego nagłówka w celu dodania nowych mapowań. Zamiast tego powinni cię zepsuć, aby je dodać. Oznacza to, że w przypadku ludzkiego i krasnoludów, o których wspomniałeś, wyliczenia są rzeczywiście kiepskie. Tam lepiej byłoby mieć jakiś rejestr w czasie wykonywania, w którym użytkownicy twojego kodu mogą wstawić ciąg znaków i uzyskać unikalny numer, ładnie opakowany w jakiś nieprzezroczysty typ. „Liczba” może nawet wskazywać na dany ciąg, to nie ma znaczenia.

Pomimo faktu, że mój ostatni punkt powyżej sprawia, że ​​wyliczenia są źle dopasowane do twojej hipotetycznej sytuacji, twoja faktyczna sytuacja, w której niektóre specyfikacje mogą się zmienić i trzeba by zaktualizować jakiś kod, wydaje się całkiem dobrze pasować do centralnego rejestru twojej biblioteki. Zatem wyliczenia powinny być odpowiednie, jeśli weźmiecie do serca moje inne sugestie.

MvG
źródło