Spis treści:

Kategoria:C#


Typy wyliczeniowe w C#

Typy wyliczeniowe jako ograniczenie

Kobieta po śmierci trafia do nieba i natychmiast rozpoczyna poszukiwania swego męża. Święty Piotr sprawdza w kartotece, ale w rubrykach: normalni, błogosławieni, święci nie ma nazwiska poszukiwanego. Podejrzewając najgorsze, czyli zsyłkę do konkurencji, św. Piotr pyta ze współczuciem:
- A ile lat byliście państwo małżeństwem?
- Ponad 50 lat! - odpowiada żona pochlipując.
- To trzeba było od razu tak mówić! - uradowany Piotr podrywa się do kartoteki.
- Z pewnością znajdziemy go w dziale "męczennicy"!

Typy wyliczeniowe w C#, ale także w innych językach programowania, mają bardzo ważne zadanie. Pozwalają w łatwy sposób zdefiniować dozwolony zestaw wartości w pewnym obszarze. Jeżeli zatem jakaś metoda przyjmuje tylko trzy wartości, znacznie lepiej przekazać tam jakiś typ przyjmujący dokładnie trzy wartości. Alternatywą jest zastosowanie najmniejszego typu całkowitego zdolnego pomieścić wszystkie te stanyW rzeczywistości typ wyliczeniowy jest przechowywany jako jeden z podstawowych typów całkowitych: byte, sbyte, short, ushort, int, uint, long lub ulong.. Wewnętrzna struktura takiego typu pociąga za sobą szereg konsekwencji. Mogą to być zalety, ale także wady. Wszystko zależy od osoby posługującej się narzędziami. Szczegóły za chwilę.

Enum w najprostszej postaci

Typ wyliczeniowy - jaki jest, każdy widzi. Popatrzmy na przykład:

public enum Priority
{
    Low,
    Medium,
    High,
    RealTime
}

Wszędzie tam, gdzie wskażemy typ Priority, powinniśmy używać jednej z czterech zdefiniowanych wartości. Nic ponadto. Kod wykorzystujący wspomniany typ wyliczeniowy zyskuje na czytelności, a pomoc narzędzi podpowiadających składnię zabezpiecza nas przed prostymi błędami. Popatrzmy na przykłady:

//Obiekt reprezentujący proces
var processEnum = new {
    Name = "Winword.exe",
    Priority = Priority.High
};
var processExpl = new {
    Name = "Winword.exe",
    Priority = 3
};

//Wyszukiwanie procesów w systemie
public IEnumerable<Process> FindProcesses(Priority priority)
{
    return allProcesses.Where(a => a.Priority == priority);
}
public IEnumerable<Process> FindProcesses(byte priority)
{
    return allProcesses.Where(a => a.Priority == priority);
}

Przedstawiłem dwie wersje dwóch różnych konstrukcji językowych wykorzystujących odpowiednio: typ wyliczeniowy oraz alternatywną, całkowitoliczbową reprezentację priorytetu jakiegoś procesu w systemie. Dla kogoś z zewnątrz wersja z wartościami nazwanymi (enum) będzie zapewne czytelniejsza. Wersja z typem wyliczeniowym jest też mniej podatna na błędy. Nie da się do właściwości przypisaćNie da się przypisać wprost, korzystając z pól typu wyliczeniowego. wartości innej, niż Low, Medium, High i RealTime. Do pola typu byte można z łatwością przekazać wartość 58 reprezentującą najprawdopodobniej błędne dane. Jest to przecież prawidłowa wartość typu byte. Typ wyliczeniowy, niejawnie dziedziczący z klasy Enum, pozwala rozwiązać kilka innych, powszechnie spotykanych problemów.

Pobieranie nazw i wartości typu enum

Pola typu wyliczeniowego mogą być z powodzeniem stosowane w kontrolkach interfejsu użytkownika - będą one reprezentowały zestaw możliwych do wyboru opcji. Aby pobrać wszystkie możliwe nazwy i ich wartości można posłużyć się kodem podobnym do poniższego:

Array values = Enum.GetValues(typeof(Priority));
foreach (var value in values)
    Console.WriteLine("{0}={1}", value, (int)value);

W oknie konsoli otrzymamy następujący rezultat:

Low=0
Medium=1
High=2
RealTime=3

Wewnętrzna reprezentacja w postaci liczb całkowitych pozwala wykonać rzutowanie na typ int. Warto przy okazji zapamiętać, że wartości typu wyliczeniowego są domyślnie numerowane od 0. Można to jednak zmienić przypisując polom dowolne wartości, nawet takie, które się powtarzają:

public enum Priority
{
    Low = 10,
    Medium = 20,
    High = 30,
    RealTime = 30
}

Można oczywiście pobrać same nazwy, korzystając z takiego kodu:

string[] names = Enum.GetNames(typeof(Priority));
foreach (var name in names)
    Console.WriteLine(name);

Tym razem w oknie konsoli pojawią się następujące wartości:

Low
Medium
High
RealTime

Domyślna nazwa wartości jest odpowiednikiem nazwy pola. A gdybyśmy zechcieli zastosować bardziej skomplikowane nazwy? Nie da się przecież wprowadzić nazwę ze spacją.NET nie ogranicza nas w tym zakresie - robi to kompilator. W .NET istnieje możliwość nadania funkcji dowolnej nazwy dającej się wyrazić za pomocą znaków Unicode.!

Atrybuty na polach typu Enum

Pola typu wyliczeniowego mogą się nazywać różnie. Gdy jednak trzeba pójść o krok dalej i uruchomić specjalistyczne mechanizmy wiązań, lokalizacji lub jeszcze inne, wykorzystujące dodatkowe wartości tekstowe, warto pomyśleć o atrybutach. Popatrzmy na nieco zmodyfikowaną deklarację typu wyliczeniowego:

public enum Priority
{
    [Description("Niski priorytet")]
    Low = 10,
    [Description("Domyślny priorytet")]
    Medium = 20,
    [Description("Podwyższony priorytet")]
    High = 30,
    [Description("Priorytet czasu rzeczywistego")]
    RealTime = 30
}

Jak dobrać się do tego typu atrybutów i uzyskać rozszerzone opisy poszczególnych pól? Z pomocą przychodzi mechanizm refleksjiMożna także wykorzystać inne, alternatywne metody. Warto wspomnieć chociażby o drzewach wyrażeń, przy częstym pobieraniu opisów można pomyśleć o buforowaniu wartości.:

var members = typeof(Priority).GetFields(BindingFlags.Static | BindingFlags.Public);
foreach (var member in members)
{
    Console.WriteLine(member.GetCustomAttribute<DescriptionAttribute>().Description);
}

Po uruchomieniu aplikacji i wykonaniu się powyższych instrukcji otrzymamy następujący rezultat:

Niski priorytet
Domyślny priorytet
Podwyższony priorytet
Priorytet czasu rzeczywistego

Podobne operacje można wykonać z dowolnym, także własnym atrybutem. Typy wyliczeniowe pojawiają się czasem w kontekście pewnej koncepcji - pól bitowych, zwanych czasem flagami.

Pola bitowe, flagi i typ wyliczeniowy

Zdarza się, że wartości definiowane przez pola powinny się wzajemnie wykluczać, ale zdarza się także, że wartości te powinny ze sobą współgrać i występować w dowolnych kombinacjach. Popatrzmy na przykład:

[Flags]
public enum Right
{
    None = 0,
    Read = 1,
    Write = 2,
    Delete = 4
}

W pokazanym przypadku każde z pól zajmuje pojedynczy bit w pamięci (kolejne potęgi liczby 2). Jeżeli zatem połączymy uprawnienia Read (1) i Write (2), otrzymamy wartość (3). Oznacza to, że oba przełączniki są włączone. Logicznie może to oznaczać prawa do odczytu (Read) i zapisu (Write). Kombinacje poszczególnych flag można wyrażać za pomocą operacji logicznych:

Right right = Right.Read | Right.Write;
Console.WriteLine(right);

W oknie konsoli wyświetlony zostanie następujący rezultat:

Read, Write

Metoda ToString wyświetla wynik w postaci tekstowej i czasem jest to dobre. Gdybyśmy zechcieli otrzymać wartość liczbową, należałoby wartość rzutować na typ int. Otrzymalibyśmy wtedy liczbę 3. Bardzo przydatną metodą do kontroli flag jest Enum.HasFlag. Określa ona, czy zmienna ma włączone wszystkie wymagane flagi:

Console.WriteLine(right.HasFlag(Right.Read));
//Otrzymamy wynik True

Pokazana metoda pozwala sprawdzić, czy wszystkie wymagane flagi są włączone. Gdybyśmy chcieli sprawdzić, czy co najmniej jedna z flag jest włączona, moglibyśmy użyć następującej funkcji:

public static class EnumExtensions
{
    public static bool HasAny(this Enum value, Enum flag)
    {
        Debug.Assert(value.GetType() == flag.GetType(), "Types do not match.");
        if (Enum.GetUnderlyingType(value.GetType()) == typeof(ulong))
        {
            return (Convert.ToUInt64(value) & Convert.ToUInt64(flag)) != 0;
        }
        else
        {
            return (Convert.ToInt64(value) & Convert.ToInt64(flag)) != 0;
        }
    }
}

Jak wspomniałem na początku, typ wyliczeniowy może być zbudowany na bazie jednego z typów całkowitych: byte, sbyte, short, ushort, int, uint, long lub ulong. To dlatego należy użyć największego z możliwych - na wszelki wypadek. Popatrzmy teraz na przykładowe wywołanie opatrzone komentarzem:

//Right right = Right.Read | Right.Write;

//True - prawo ma jedną z flag: Write lub Delete
var hasAny = right.HasAny(Right.Write | Right.Delete);
//False - prawo nie ma wszystkich flag: Write i Delete
var hasFlag = right.HasFlag(Right.Write | Right.Delete);

Bazując na poprzednim przykładzie można rozwinąć klasę rozszerzającą umieszczając w niej kolejne metody.

Zagrożenia - enum nie taki bezpieczny

Podstawą typów wyliczeniowych są typy całkowite. To, że nad definicją umieścimy atrybut Flags, to tylko nasza dobra wola. Dobra wola i dobra praktyka pozwalająca w porę dostrzec niebezpieczeństwo. Dlaczego? Popatrzmy na przykład:

Priority priority = Priority.Medium | Priority.High;
Console.WriteLine(priority);

Console.WriteLine(priority.HasFlag(Priority.RealTime));

Zaskakujące może być nie tylko to, że wszystko się poprawnie skompiluje. Zaskakujące mogą być same wyniki! Na tyle zaskakujące, że spotkałem się już z tego rodzaju błędami w kodzie produkcyjnym. Zachowajmy jednak kolejność. Przed krótkim wyjaśnieniem popatrzmy na rezultaty:

Realtime
True

Skąd to się bierze? Po pierwsze, domyślnymi wartościami pól wyliczeniowych są kolejne wartości całkowite, począwszy od 0. Będzie to zatem Low(0), Medium(1), High(2) i RealTime(3). Jeżeli przy takim układzie wykonamy operację sumy logicznej, otrzymamy 1 | 2 = 3. Wartość 3 to odpowiednik pola RealTime! Nie jest to takie oczywiste i może niepotrzebnie wprowadzać w błąd.

Wartości spoza zakresu

Wydaje się, że zdefiniowanie odpowiedniego typu wyliczeniowego zagwarantuje nam poprawność danych. Skoro priorytet może przyjąć cztery wartości, zawsze dostaniemy jedną z nich! Nic bardziej mylnego. Popatrzmy na kolejny przykład:

Right invalidRight = (Right)31;
Priority invalidPriority = (Priority)invalidRight;

Znów zaskakujące jest to, że kod się skompiluje. Zaskakujące jest to, że wykonają się wszystkie rzutowania i zaskakujące jest to, że obie zmienne będą przechowywały wartość 31 oznaczającą... no właśnie. Nie wiadomo do końca co to może oznaczać. Nie ma pewności, że logika całej aplikacji będzie wiedziała co z tą niejednoznacznością zrobić. To tym bardziej powinno nas zmusić do zmiany sposobu myślenia o typach wyliczeniowych. Traktujmy je jako ładniejsze opakowania typów całkowitych - zwiększające czytelność i ograniczające liczbę błędów, ale nie gwarantujące poprawności danych.

Definiowanie typu bazowego

Domyślnym typem bazowym pól wyliczeniowych jest int. Jeżeli zatem nie wskażemy tego wprost, typ wyliczeniowy będzie zajmował 4 bajty pamięci i umożliwiał obsługę 32 bitowych flag. Można to zmienić:

public enum Priority
{
    Low,
    Medium,
    High,
    RealTime
}

public enum Right : byte
{
    None = 0,
    Read = 1,
    Write = 2,
    Delete = 4,
}

Gdybyśmy teraz pobrali rozmiary poszczególnych pól otrzymalibyśmy co następuje:

Rozmiar typu byte - 1
Console.WriteLine(sizeof(Right));
Rozmiar typu int (domyślny) - 4
Console.WriteLine(sizeof(Priority));

W zdecydowanej większości zastosowań nie ma potrzeby modyfikowania typów bazowych, ale warto o takiej możliwości wiedzieć i rozumieć jej konsekwencje.

Wnioski

Typy wyliczeniowe, enum, ubogie więzy integralności w .NET - nie ważne jak to sobie nazwiemy, ale jak to zastosujemy. Odpowiednio użyte mogą ogromnie zwiększyć czytelność kodu, doprowadzając wręcz do idealnej postaci samokomentującego się tworu. Warto je zatem stosować. Ale, tak jak na drodze, należy stosować zasadę ograniczonego zaufania i mieć na uwadze, że, mimo wszystko, coś może pójść nie tak.

Kategoria:C#

, 2013-12-20

Komentarze:

Darek (2021-04-30 00:26:11)
Świetny artykuł. Ile można znaleźć o typach wyliczeniowych ale wszystko to samo, można by pomyśleć że w c# są strasznie ubogie a tu nagle taki tekst! Dzięki
Dodaj komentarz
Wyślij
Ostatnie komentarze
Dzieki za rozjasnienie zagadnienia upsert. wlasnie sie ucze programowania :).
Co się stanie gdy spróbuję wyszukać:
SELECT * FROM NV_Airport WHERE Code='SVO'
SELECT * FROM V_Airport WHERE Code=N'SVO'
(odwrotnie są te N-ki)
Będzie konwersja czy nie znajdzie żadnego rekordu?