Enum jako flaga w C#

Rzadko zdarza mi się pisać o ficzerach języka C#, a już na pewno nie o takich, które są dostępne od wielu lat. Niemniej, dziś miałem okazję ponownie wykorzystać ciekawą i mniej znaną „odsłonę” enum-ów dostępną poprzez atrybut FlagsAttribute, która być może okaże się dla Ciebie przydatna.

 

Klasyczne użycie typów wyliczeniowych

Gdybym z własnych obserwacji miał powiedzieć kiedy typy wyliczeniowe są stosowane, odparłbym „kiedy bool nie wystarcza”. Zazwyczaj bowiem, zaczyna się od właśnie od zmiennej typu bool, która gości w w encji, value object, DTO itd. Dla przykładu mamy taki oto typ:

 

public class ReceivedFile
{
    public string FullName { get; set; }
    public string AbsolutePath { get; set; }
    public bool IsReadOnly { get; set; }
}

 

Właściwość IsReadOnly w sposób jednoznaczny wskazuje czy otrzymany plik służy jedynie do odczytu co może posłużyć do zaimplementowania jakiejś logiki biznesowej. Problem pojawiłby się w sytuacji gdyby nasza domena ewoluowała i pojawiłaby się potrzeba obsługi jeszcze jednego rodzaju plików: do wykonania/uruchomienia. Jakie zmiany zaproponował(a)byś w naszym kodzie. Przyjmij na tym etapie, że możemy otrzymać plik, który jest ALBO do odczytu ALBO do zapisu ALBO do uruchomienia. Na 90% Twoja odpowiedź brzmiała „typ wyliczeniowy”. No to siup:

 

    public enum FileAccess : byte
    {
        Write = 0,
        Read = 1,
        Execute = 2
    }
    
    public class ReceivedFile
    {
        public string FullName { get; set; }
        public string AbsolutePath { get; set; }
        public FileAccess Access { get; set; }
    } 

 

W tym momencie mamy wszystko co jest nam potrzebne do poprawnego oprogramowania logiki. Tak jak pisałem na początku tego paragrafu – typowe flow pojawiania się enumów w projektach „Agile”. Co jednak gdy wymagania zostaną przedefiniowane po raz kolejny i dopuszczalnym będzie, aby plik mógł być jednocześnie do odczytu I do zapisu I do uruchomienia? Tu często dochodzi do sytuacji dość kuriozalnej, ponieważ niektórzy wybierają drogę… z powrotem do świata bool-i:

 

public class ReceivedFile
{
    public string FullName { get; set; }
    public string AbsolutePath { get; set; }
    public bool IsReadable { get; set; }
    public bool IsWritable { get; set; }
    public bool IsExecutable { get; set; }
}

 

Jak wygląda alternatywa?

 

Typ wyliczeniowy jako flaga

Myśląc o rozwiązaniu nowo powstałego problemu, mnie osobiście na myśl przychodzą uprawnienia plików/katalogów z systemów UNIX:

 

 

Ustawiając odpowiednie kombinacje „flag” możemy sprawić, aby konkretny zaspób posiadał określone uprawanienia. Przekładając to na nasz kod, oczekiwałbym aby istaniła mozliwość przypisania do enuma FileAccess więcej niż jedej wartości. Okazuje się, że jest to możliwe dzięki wspomnianemu na poczatku tego wpisu atrybutowi FlagsAttribute. Zmodyfikujmy nasz kod, aby zobaczyć jak działa:

 

[Flags]
public enum FileAccess
{
    Write = 1,
    Read = 2,
    Execute = 4
}

public class ReceivedFile
{
    public string FullName { get; set; }
    public string AbsolutePath { get; set; }
    public FileAccess Access { get; set; }
}</pre>

 

Jak widzisz definicja samej klasy pozostała bez zmian. Róznice możesz jednak dostrzec w samym typie wyliczeniowym. Po pierwsze pojawił się attrybut, który dekoruje enuma. Po drugie wartości liczbowe konkretnych wyliczeń również uległy zmiany tj. z 0,1,2 na 1,2,4 (czyli kolejne potegi liczby 2). Powód tej zmiany będzie bardzie jzrozumiały gdy ów liczby przedstawimy w systemie dwójkowym:

 

(Write) 1 = 00000001 => 001

(Read) 2 = 00000010 => 010

(Execute) 4 = 00000100 => 100

 

Myśle, że domyślasz się już o co chodzi. „Ustawiając” odpowednie bity we właściwości Access, będziemy mogli zawrzeć różne kombinacje uprawnień dla konkretnej instacji klasy ReceivedFile. Przykładowo:

 

110 => Execute, Read

001 => Write

101 => Execute, Write

 

To jest bezposredni powód operowania na potegach liczby 2. Każda z nich posiada tylko jeden bit równy 1. W innym przypadku operacje bitowe (do których zaraz dojdziemy) skutkowałyby dziwnymi rezultatami. W tym miejscy chciałbym jeszcze zawrzeć mały „tip” związany z zapisem kolejnych wyliczeń w enumach. Jeżeli chcesz mieć pewność, że kolejne wartości są na pewno potegą liczby 2, możesz użyć nastepującego zapisu:

 

    [Flags]
    public enum FileAccess
    {
        Write =   1 << 0,
        Read =    1 << 1,
        Execute = 1 << 2 //itd.
    }

 

Moim zdaniem jest to o wiele pewniejsze, szczególnie w przypadku gdy wartości mamy dużo. Warto nadmienić, że ów sposób nie spowolni kodu, ponieważ podczas komilacji zostanie to zamienione na:

 


    [Flags]
    public enum FileAccess
    {
        Write = 0x1,
        Read = 0x2,
        Execute = 0x4
    }

 

Pozostaje pytanie w jaki sposób modyfikować wartości pola Access? Rozszerzmy nieco nasz kod:

 


    public class ReceivedFile
    {
        public string FullName { get; set; }
        public string AbsolutePath { get; set; }
        public FileAccess Access { get; set; }
        
        public void AddAccess(FileAccess access)
            => Access |= access;

        public void RemoveAccess(FileAccess access)
        {
            if (HasAccess(access))
            {
                Access ^= access;
            }
        }
        
        public bool HasAccess(FileAccess access)
            => (Access & access) == access;
    }

 

Omówmy po kolei każdą operację zaczynając od metody AddAccess. Wykorzystuje ona bitowy operator OR w celu dodania uprawnień. Przykładowe dodanie uprawnienia Read wygląda następująco:

 

00000000

00000010

OR ——————–

00000010

 
Metoda RemoveAccess w pierwszej kolejności sprawdza czy zadane uprawnienie jest zawarte w polu Access. Jeżeli tak, usuwa je uzywając operatora XOR. Przykład usuniecia wcześniej nadanego uprawnienia:

 

00000010

00000010

XOR ——————–

00000000

 

Powód sprawdzenia przed usunięciem wynika z charakterystyki bramki XOR, która zwraca 1 tylko dla pary różnych bitów (tj. 0 i 1). W przypadku gdyby uprawnienie nie istaniło, wywołując metodę  RemoveAccess tak na prawdę bysmy je dodali:

 

00000000

00000010

XOR ——————–

00000010

 

Jeżeli mozna to zrobić bez if-a daj proszę znac w komentarzu 😉 Ostatnia metoda HasAccess sprawdza, czy zadane uprawnienie jest obecene w Access. Używa do tego operatora bitowego AND. Przykład sprawdzenia czy nadano uprawnienie Execute w Access, który posiada wszystkie uprawnienia:

 

00000111

00000100

AND——————–

00000100 

00000100  == 00000100  => Posiada uprawnienie

 

Wiesz już zatem jak działa to w praktyce. Zobaczmy działanie prostego programu, który to potwierdzi:

 

    class Program
    {
        static void Main(string[] args)
        {
            var file = new ReceivedFile();
            
            file.AddAccess(FileAccess.Read);
            Console.WriteLine($"ACCESS CHECK #1: {file.Access}");
            
            file.AddAccess(FileAccess.Write);
            Console.WriteLine($"ACCESS CHECK #2: {file.Access}");
            
            Console.WriteLine($"HAS READ ACCESS: {file.HasAccess(FileAccess.Read)}");
            
            file.RemoveAccess(FileAccess.Read);
            Console.WriteLine($"ACCESS CHECK #3: {file.Access}");
            Console.WriteLine($"HAS READ ACCESS: {file.HasAccess(FileAccess.Read)}");

            file.AddAccess(FileAccess.Execute);
            Console.WriteLine($"NUMERIC ACCESS: {(int) file.Access}");
        }
    }

 

Wynik prezentuje się nastepująco:

 

 

 

  • Pawel Lukasik

    RemoveAccess da się w jednej linijce.

    public void RemoveAccess(FileAccess access)
    {
    Access &= ~access;
    }

    Notabene te stosowanie nazw Access i access to chyba proszenie się o kłopoty 😉

    • O dzięki za podpowiedź! Co masz na mysli pisząc o kłopotach? Chodzi Ci o możliwą pomyłkę przy przypisywaniu wartości?

      • Pawel Lukasik

        Tak. Różnią się tylko wielkością pierwszej litery. Łatwo się pomylić.

  • Pingback: dotnetomaniak.pl()

  • Daniel Dziubecki

    Propsy !

  • Z tego co pamiętam to na Enum’ach jest też metoda HasFlag której można użyć do sprawdzenia czy pojedyńcza flaga jest ustawiona: Access.HasFlag(FileAccess.Read). Pod spodem działa to podobnie jak Twoja metoda HasAccess.

    • Z tego co mi wiadomo `Enum.HasFlag` ma dość spory narzut wydajnościowy (wbrew oczekiwanion implementacje frameworkowa jest dość złożona) więc jeśli zależy nam na wydajności to rozwiązanie zaproponowane w tym poście jest znacznie lepsze. Więcej szczegółów na temat wydajności enumów oznaczonych [Flag] można znaleźć w książce „Writing high-performance .net code”