Dlaczego warto używać modyfikatora sealed

O modyfikatorze sealed już kiedyś pisałem – dzięki niemu możemy zabronić dziedziczenia po danej klasie:

sealed class SealedClass
{
 
}
class ChildClass:SealedClass // BLAD
{
}

Moim zdaniem jest on niedoceniany i zbyt rzadko używany.  Może dlatego, że nie wnosi on nic nowego do funkcjonalności czy łatwości w pisaniu kodu a “jedynie” stanowi ważny element w projekcie oraz w tym, jak inni użytkownicy z takiej biblioteki będą korzystać.

Pierwszą zaletą SEALED jest wydajność. Oczywiście w większości przypadków taka optymalizacja nie ma jakiegokolwiek znaczenia ale dobrze wiedzieć, że oprócz dobrego design osiągniemy lepszą wydajność. W programowaniu obiektowym, wywoływanie metod wirtualnych jest trochę wolniejsze ponieważ najpierw trzeba sprawdzić, która dokładnie metoda ma zostać wykonana (polimorfizm). Do tego  służy tzw. Virtual method table – po szczegóły odsyłam do Wikipedii.  Przykład:

internal class Program
{
    class BaseClass
    {
        internal virtual void HelloWorld()
        {
        }
    }
    sealed class SampleClass : BaseClass
    {
    }
    private static void Main(string[] args)
    {
        SampleClass sampleClass = new SampleClass();
        sampleClass.HelloWorld();
    }
}

Pomimo, że zostanie wyemitowana instrukcja IL callvirt, CLR wywoła metodę w sposób niewirtualny a zatem pozbawiony przeszukiwania VMT. Gdyby nie modyfikator sealed, SampleClass mógłby wskazywać na klasy pochodne, które mają już inną implementacje metody HelloWorld – stąd wywołanie byłoby trochę wolniejsze.

Powyższa optymalizacja należy jednak do ciekawostek. W praktyce dobrze to wiedzieć i pisać kod wydajniejszy ale zdecydowanie nie przynosi to zauważalnego efektu. Ważniejszą kwestią jest stabilność klasy. Jeśli nie zaprojektowało się klasy z myślą o możliwości jej rozszerzenia wtedy powinno się użyć sealed.  W końcu klasa nienapisana z myślą o metodach wirtualnych itp. może przynieść efekty nieoczekiwane. Warto zabezpieczyć się przed tym zagrożeniem i “zapieczętować ją”. Pochodne klasy mogą w sposób nieświadomy modyfikować stan klasy bazowej czy nie wywoływać bazowych metod kiedy jest to potrzebne. Nie każdy pisany kod musi być zawsze maksymalnie rozszerzalny i skalowalny. Czasami potrzebna jest pojedyncza klasa i nie warto tracić czasu na przygotowanie jej pod przyszłe, ewentualne rozszerzenia. Pisząc rozszerzalną klasę należy każde pole, metodę przemyśleć tak, aby klasa pochodna nie mogła popsuć bazowej implementacji. Zasada jest taka, że inny programista, nie znając bazowej implementacji nie powinien przypadkowo zakłócić stabilności klas – poprawny projekt zawsze wymusza to.

Jeśli w pewnym momencie okaże się, że jednak potrzebne są pewne rozszerzenia wtedy nie ma problemu z usunięciem sealed. W drugą stronę jest to oczywiście niemożliwe. Klasa raz niezapieczętowana nie może później już być zapieczętowana ponieważ mogłoby to spowodować błędy w kompatybilności. Jeśli klasa najpierw była unsealed, wtedy  w międzyczasie ktoś mógł już napisać klasę pochodną. W takim przypadku dodanie sealed po prostu spowodowałoby, że jakiś kod nie skompiluje się.

Dobrym zwyczajem jest rozpoczęcie implementacji od maksymalnych ograniczeń w dostępności do danych. Metody zatem powinno pisać się jako niewirtualne, prywatne a klasy jako sealed oraz internal (jeśli nie są one zagnieżdżone).

Warto wspomnieć, że modyfikator sealed można również dołączać do metod przeciążanych:

class BaseClass
{
   internal virtual void HelloWorld()
   {

   }
}
class SampleClass : BaseClass
{
   sealed internal override void HelloWorld()
   {
       base.HelloWorld();
   }
}

W takim przypadku SampleClass:HelloWorld jest ostatnią przeciążoną implementacją. Dalsze klasy dziedziczące po SampleClass nie będą mogły już dostarczać własnej implementacji HelloWorld. Podobnie sealed może zostać połączony z właściwościami – nie powinno to zaskakiwać bo są one tak naprawdę zwykłymi metodami.

Code Review: obiekty COM oraz zasada “double dot”.

Praca z obiektami COM może być trudna i czasami frustrująca. Przykład:

Worksheet sheet = excelApp.Worksheets.Open(...);
// Jakaś logika. Odczytywanie lub modyfikacja arkusza itp.
Marshal.ReleaseComObject(sheet);
Marshal.ReleaseComObject(excelApp);

O obiektach COM należy pamiętać, również po zakończeniu pracy z nimi – należy zwolnić wszelkie zasoby. Nie zawsze jest to proste i oczywiste. Powyższy kod spowoduje memory leak ponieważ Worksheets również musi zostać zwolniony. Metoda Open jest wywołana na obiekcie Worksheets, który został zapomniany w powyższym fragmencie kodu.

Zasada pracy z obiektami COM jest prosta i mówi, aby unikać podwójnych kropek czyli odwoływania się do dwóch różnych obiektów. Po refaktoryzacji kod powinien wyglądać następująco:

Worksheets sheets = excelApp.Worksheets; 
Worksheet sheet = sheets.Open(...);
...
Marshal.ReleaseComObject(sheet);
Marshal.ReleaseComObject(sheets);
Marshal.ReleaseComObject(excelApp);

Jak widać, nie ma w żadnej linii podwójnych odwołań. Dzięki temu, łatwo zauważyć, które obiekty powinny zostać zwolnione. Warto przestrzegać tej zasady ponieważ późniejsza analiza memory leak może spowodować wiele problemów.

Boxing oraz unboxing–jak to działa od strony CLR

Dziś trochę o podstawach C# ale myślę, że wszyscy znajdą coś wartościowego w tym wpisie bo chcę pokazać jak to działa od środka CLR. Na początek przykład boxing’u:

static void Main(string[] args)
{
   int value = 3;
   object referencedType = value;
}

Boxing to nic innego jak utworzenie typu referencyjnego na podstawie value type. Object to typ referencyjny przechowywany na stercie, z kolei integer to zwykły value type przechowywany na stosie. Opisowo, boxing składa się z 3 operacji:

  1. Alokacja pamięci na stercie. Oczywiście musimy zarezerwować pamięć dla Integer’a oraz dla dodatkowych pól wymaganych dla każdego typu referencyjnego (m.in. Sync block Index). Każdy obiekt na stercie (nie tylko po boxing’u) ma te dodatkowe pola – taka jest wewnętrzna architektura. Już w tym momencie widać, że value type są bardziej oszczędne jeśli chodzi o pamięć.
  2. Wartość ze stosu jest kopiowana na stertę, w miejsce zaalokowanej pamięci w pierwszym kroku.
  3. Wskaźnik na nowo utworzony obiekt zostaje zwrócony.

Jak widać, po mimo, że w c# nie wygląda to skomplikowanie, w rzeczywistości jest to proces dużo bardziej skomplikowany niż może wydawać się.  Przyjrzyjmy się teraz wygenerowanemu IL:

.method private hidebysig static void Main(string[] args) cil managed
{
    .entrypoint
    .maxstack 1
    .locals init (
        [0] int32 'value',
        [1] object referencedType)
    L_0000: nop 
    L_0001: ldc.i4.3 
    L_0002: stloc.0 
    L_0003: ldloc.0 
    L_0004: box int32
    L_0009: stloc.1 
    L_000a: ret 
}

To co nas interesuje to tak naprawdę:

L_0001: ldc.i4.3 
L_0002: stloc.0 
L_0003: ldloc.0 
L_0004: box int32
L_0009: stloc.1 

Instrukcja ldc umieszcza wartość 3 na tzw. execution  stack.  Jeśli pojęcie jest obce, zachęcam do poczytania o nim na MSDN. W skrócie jest to taki wewnętrzny stos, na którym IL pracuje. Instrukcja stloc.0 zdejmuje wartość ze stosu i umieszcza ją w zmiennej o indeksie 0  czyli w value (patrz kod c# wyżej).

Kolejna instrukcja (ldloc.0) ładuje zmienną o indeksie zero (value), umieszczając ją na wspomnianym execution stack. Następnie mamy oczekiwaną instrukcję box, która wykonuje oczywiście boxing. Instrukcja stloc.1 umieszcza wynik w zmiennej o indeksie jeden czyli w referencedType.

Oczywiście należy unikać boxing’u ponieważ wiążę się to z alokacją dodatkowej pamięci a potem z usunięciem tych zasobów przez GC.

Przyjrzyjmy się teraz “analogicznej” operacji uboxingu:

int value = 3;
object referencedType = value;
int unboxedValue = (int) referencedType;

IL:

L_000a: ldloc.1 
L_000b: unbox.any int32
L_0010: stloc.2 

Kod IL powinien być już jasny: załadowanie zmiennej o indeksie 1 na execution stack, wykonanie uboxing’u oraz zdjęcie wyniku do zmiennej o indeksie dwa. Unboxing składa się z następujących operacji:

1. Jeśli wartością jest NULL, zostaje wyrzucony wyjątek NullReferenceException. Poniższy kod zakończy się wyjątkiem:

object referencedType = null;
int unboxedValue = (int) referencedType;

2. Jeśli typ na stercie nie jest taki sam jak żądany podczas uboxing’u wyrzucany jest wyjątkiem InvalidCastException. Przykład:

object referencedType = 242f;
int unboxedValue = (int) referencedType;

Warto zaznaczyć, że typy muszą być identyczne. Jeśli został umieszczony Int16 należy użyć Int16 a nie np. Int32, który jest o szerszym zasięgu i mogłoby się wydawać, że powinien zadziałać.

3. Zwracany jest wskaźnik do wartości umieszczonej na starcie, którą potem zwykle jest kopiowana.

Teoretycznie uboxing jest operacją dużo SZYBSZĄ niż boxing. CLR nie wymaga aby wartość po uboxing’u była kopiowana. To co unboxing robi to zwrócenie wskaźnika do obiektu unboxed, który znajduję w instancji. Innymi słowy operacja zwraca wskaźnik na value type, który znajduje się w obiekcie referencyjnym.  W C# jednak unboxing zawsze wiąże się z kopiowaniem wartości ponieważ nie ma możliwości wykonania  unboxing’u bez przypisania go do zmiennej. C++\CLI pozwala wykonać unboxing’u bez kopiowania – w c# jest to po prostu niemożliwe i jak widać po IL, zawsze jest dodawana operacja wykonująca kopiowanie danych.

Ze względu na te wszystkie operacje, kluczowe staje się rozpoznanie sytuacji w której boxing\unboxing są wykonywane.  Jeśli mamy metodę przyjmującą object zawsze przed przekazaniem value type, jest dokonywany boxing:

private void Method(object argument)
{        
}
Method(3); // boxing

To chyba był prosty przykład… Kolejny przykład, może jednak zadziwić:

int value = 3;
Type type=value.GetType();

Niestety taka niepozorna operacja również powoduje boxing. Dlaczego? GetType jest niewirtualną metodą zadeklarowaną w System.Object, który jest typem referencyjny. Z tego względu, aby wykonać taką metodę, należy przekazać wskaźnik this. Zasada jest prosta: wszystkie metody, zarówno wirtualne jak niewirtualne zadeklarowane w typie referencyjnym (jakim jest System.Object), wymagają boxingu. A co w takim przypadku:?

struct Color
{
    public override string ToString()
    {
    return "test";
    }
}
static void Main(string[] args)
{
    Color color;
    string str = color.ToString();
}

Boxing nie zostanie wykonywany. Przyjrzyjmy się jeszcze kolejnemu przykładowi:

class Program
{
    struct Color
    {        
    }
    static void Main(string[] args)
    {
        Color color;
        string str = color.ToString();
    }
}

Pierwszy przykład przeładowuje ToString i nie wykonuje bazowej implementacji. Z tego względu nie ma potrzeby wykonania kodu znajdującego się w referencyjnym typie System.Object. Drugi przykład nie przeładowuje metody zatem niezbędne jest wykonanie bazowej implementacji, znajdującej się w System.Object.

W c# rzutowane i boxing mają taką samą składnie jednak jak widać znaczącą się to różnią. Szczególnie następujący kod może być mylący:

struct ValueType
{        
   public void ChangeValue(int value)
   {
       Value = value;
   }
   public int GetValue()
   {
       return Value;
   }
   private int Value;
}
static void Main(string[] args)
{
   object referencedType = new ValueType();
   ((ValueType)referencedType).ChangeValue(10);
   Console.WriteLine( ((ValueType)referencedType).GetValue());
}

Dla referencyjnych typów, na ekranie wartość 10 zostałaby wyświetlona. Ze względu na boxing a nie proste rzutowanie,  za każdym razem obiekt jest kopiowany, skutkując wartością 0 wyświetloną na ekranie.

Code review: operator rzutowania

Rozważmy, następujący kawałek kodu:

struct Color
{
   public static explicit operator Color(short value)
   {
    // jakas tam konwersja - nieistotne dla przykladu
       return new Color {R = value};
   }
   public short R, G, B;
}

Co w nim nie tak? Operatory nie są wspierane przez wszystkie języki. Podczas kompilacji wszystkie operatory zamieniane są na zwykłe metody. Na przykład operator rzutowania implicit zostanie zamieniony na op_Explicit oraz oznaczony  przez CLR specjalnym atrybutem specialname:

.class sequential ansi sealed nested private beforefieldinit Color
   extends [mscorlib]System.ValueType
{
   .method public hidebysig specialname static valuetype Program/Color op_Explicit(int16 'value') cil managed
   {
   }

   .field public int16 B

   .field public int16 G

   .field public int16 R
}

Dobre API musi dostarczać metody, które umożliwiają konwersję za pomocą innych metod niż przeładowane operatory. Dzięki temu, użytkownicy wszystkich języków będą mogli cieszyć się napisanym przez nas API. Warto zatem dostarczyć dodatkowe metody oraz konstruktory, które przyjmują wartości z których można skonstruować dany obiekt:

struct Color
{
   public Color(short value)
   {
       R =G=B= value;
   }
   public static Color FromShort(short value)
   {
       // jakas tam konwersja - nieistotne dla przykladu
       return new Color { R = value,G=value,B=value };   
   }
   public static explicit operator Color(short value)
   {
       return FromShort(value);
   }
   public short R, G, B;
}

Użytkownik zatem może stworzyć Color na podstawie short za pomocą konstruktora, operatora lub statycznej, publicznej funkcji. W C# operator jest najwygodniejszy w użyciu ale i użytkownicy innych języków będą mogli z takiego API korzystać.

Porównywanie obiektów

W C# istnieje kilka metod sprawdzania czy obiekty są takie same. Czasami budzi to zamieszanie i dlatego w dzisiejszym wpisie chciałbym rozjaśnić wszelkie różnice i wyjaśnić jak prawidłowo powinno to być zaimplementowane.

Mamy dwa sposoby porównywania obiektów. Pierwszy to sprawdzenie czy wskazują na taki sam obszar pamięci. Druga metoda polega na porównaniu wszystkich pól i sprawdzeniu czy są takie same. Jeśli pola mają taką samą wartość to przyjmujemy, że obiekty są sobie równe (value identity).

W .NET Framework metoda to object.Equals ma na celu porównanie typu value identity. Zachowuje ona się tak naprawdę dziwnie ponieważ domyślna implementacja sprawdza czy obiekty są sobie równe pod kątem wskaźnika  a nie wartości.

Wzorowa implementacja Equals (value identity), zgodna z przyjętymi praktykami powinna wyglądać następująco:

  1. Jeśli obiekt obj jest NULL wtedy zwróć false – mamy oczywiście tutaj pewność, że this nie jest NULL zatem jeśli obj jest NULL’em to wartości muszą być różne.
  2. Sprawdź czy wskaźniki pokazują ten sam obszar pamięci. Jeśli tak to mamy pewność, że są one sobie równe i nie musimy dokonywać zbędnych porównań.
  3. Jeśli obj.GetType() != this.GetType() zwróć również false – obiekty różnego typu w większości przypadków są różne (ale nie zawsze!).
  4. Porównaj wszystkie pola, właściwości obiektu this z obj. Jeśli, której z nich się różni zwróć od razu false.
  5. Wywołaj base. Equal aby wykonać analogiczne operacje ale dla bazowych właściwości (krok wymagany gdy mamy hierarchię klas).

Jak wspomniałem, object.Equal domyślnie porównuje referencję. Idąc tropem powyższych wskazówek, Equals powinien być zaimplementowany następująco (na przykładzie klasy Person zwierającej jedno pole FirstName):

class Person
{
   public string FirstName { get; set; }

   public override bool Equals(object other)
   {
       if (ReferenceEquals(null, other)) return false;
       if (ReferenceEquals(this, other)) return true;
       if (this.GetType() != other.GetType())
           return false;

       return Equals((Person)other);
   }
   public bool Equals(Person other)
   {
       return Equals(other.FirstName, FirstName);
   }
}

ReferenceEquals sprawdza czy wskaźniki są takie same. Oprócz Equals często wymagane jest zaimplementowanie metody GetHashCode() zwracającej hash obiektu. Wykorzystywany jest on np. w słownikach. Dużo szybsze jest porównanie liczby (hash’u) niż wszystkich pól po kolei. Z tego względu przed wywołaniem Equals, najpierw jest wywołane GetHashCode. Jeśli hash’e są różne wtedy mamy pewność, że obiekty są również różne i nie trzeba wywoływać już Equals. W przypadku jednak gdy hash’e są takie same, istnieje prawdopodobieństwo, że  obiekty mają takie same wartości. Wtedy dla pewności, należy wywołać Equals aby dokonać pełnego porównania. GetHashCode daje 100% pewność wyłącznie w sytuacjach gdy nie ma konfliktów – dwóch różnych obiektów o tym samym hash’u. Konfliktów nie możemy wykluczyć dlatego zawsze musimy wywoływać również Equals.

Przykładowa implementacja GetHashCode dla Person:

public override int GetHashCode()
{
  return (FirstName != null ? FirstName.GetHashCode() : 0);
}

GetHashCode musi być liczony za każdym razem od nowa – zdecydowanie odradzam optymalizacje polegające na buforowaniu hash’a np. w bazie danych. Implementacja .NET Framework’owa może się z czasem zmienić dlatego, zawsze jest konieczne porównanie “świeżo” wyliczonych wartości.

W c# istnieje jednak jeszcze jeden sposób porównania – za pomocą operatora ==.  Domyślna implementacja == po prostu porównuje wskaźniki tak jak ReferenceEquals.  Jeśli nie zaimplementowaliśmy operator== w naszej klasie wtedy poniższe wywołanie zwróci false:

Person person1 = new Person {FirstName = "Piotr"};
Person person2 = new Person { FirstName = "Piotr" };
Console.WriteLine(person1==person2); // FALSE - brak operator==
Console.WriteLine(object.Equals(person1, person2)); // TRUE

Dysponując metodą Equals, implementacja operator== oraz operator!= jest bardzo prosta:

class Person
{
   public string FirstName { get; set; }

   public static bool operator ==(Person left,
                                Person right)
   {
       return Equals(left,right);
   }

   public static bool operator !=(Person left, Person right)
   {
       return !(left == right);
   }
   public override bool Equals(object other)
   {
       if (ReferenceEquals(null, other)) return false;
       if (ReferenceEquals(this, other)) return true;
       if (this.GetType() != other.GetType())
           return false;

       return Equals((Person)other);
   }
   public bool Equals(Person other)
   {
       return Equals(other.FirstName, FirstName);
   }
   public override int GetHashCode()
   {
       return (FirstName != null ? FirstName.GetHashCode() : 0);
   }
}

Ważna uwaga: operatory w c# nie są wirtualne. Dlatego poniższe wywołanie może wydawać się dziwne:

object person1 = new Person {FirstName = "Piotr"};
object person2 = new Person { FirstName = "Piotr" };
Console.WriteLine(person1==person2); // FALSE !
Console.WriteLine(object.Equals(person1, person2));

Pierwsze porównanie zwróci false. Operatory są po prostu przeładowane (overloaded) a nie przedefiniowane (overriding). Oznacza to, że kompilator stara się dopasować jak najbardziej pasującą wersje metody operator==. Dla powyższego przypadku będzie to operator==(object,object) występujący w klasie object.  Rozważmy inny przykład:

class Person2:Person
{   
}

Person2 person1 = new Person2 { FirstName = "Piotr" };
Person2 person2 = new Person2 { FirstName = "Piotr" };
Console.WriteLine(person1==person2);// TRUE
Console.WriteLine(object.Equals(person1, person2));

Pomimo, że nie posługujemy się bezpośrednio klasą Person, kompilator uznał, że operator==(Person,Person) jest bardziej adekwatny niż operator==(object,object).  Zasada jest taka sama jak dla dwóch metodach o tej samej nazwie i różnych parametrach:

   static void Method(object a,object b)
   {       
     // 1
   }
   static void Method(Person a, Person b)
   {
    // 2
   }
   Method(new Person2(), new Person2()); // wywoła 2

 

Warto również zwrócić uwagę, że Equals  a operator== to dwie różne metody. Implementacja wyłącznie Equals nie daje możliwości wykorzystania porównania za pomocą zwykłego operatora ==.

Pisząc testy jednostkowe warto zwrócić uwagę na następujące przypadki:

  1. a.Equals( a ) zawsze musi zwracać true.
  2. a. Equals (NULL) zawsze musi zwracać false – chyba, że zaimplementowaliśmy NULL Pattern (special case pattern).
  3. Jeśli a.Equals( b ) zwraca true to b.Equals ( a ) również musi zwracać true.
  4. Jeśli a.Equals ( b) zwraca true oraz b.Equals ( c ) zwraca true to a. Equals ( c ) również musi zwracać true.

W .NET istnieją również interfejs IComparable zawierający metodę CompareTo. Wykorzystywana ona jest jedynie w sortowaniu a nie w porównywaniu dwóch wartości.

Unie w C#–zastosowanie

W ostatnim poście przedstawiłem sposób na implementację unii w C#. Dziś kilka przykładów. Proszę jednak zwrócić uwagę, że unie zostały wprowadzone w czystym C, który nie miał nic wspólnego z programowaniem obiektowym. Dzisiaj możemy wiele konstrukcji lepiej napisać, wykorzystując klasy. Unie również ważną rolę odgrywały w optymalizacji.  W c# sprawa wygląda inaczej, ponieważ jeśli zgodziliśmy się na zarządzaną pamięć przez GC, raczej nie zależy nam na oszczędzaniu kilku bajtów.

Niemniej jednak klasyczny przykład wykorzystania unii to struktury VARIANT. Struktura taka posiada jedno pole identyfikujące oraz zagnieżdżaną unię. Identyfikator opisuje, jak struktura powinna się zachować i jakie dane opisuje:

public struct Variant
{
   public int Type;
   public Union Union;       
}
[StructLayout(LayoutKind.Explicit)]
public struct Union
{
   [FieldOffset(0)]
   public char CharValue;
   [FieldOffset(0)]
   public short ShortValue;
   [FieldOffset(0)]
   public float FloatValue;
}

Zamiast int Type można byłoby użyć ENUM – zdecydowanie bardziej nadaję się do tego przykładu. Nie chciałem jednak zaciemniać kodu. Unia posiada kilka pól o  różnym typie danych. Następnie przejrzyjmy się funkcji, która wyświetla taką strukturę:

static private void Display(Variant variant)
{
   switch (variant.Type)
   {
       case 0:
           Console.WriteLine(variant.Union.CharValue);
           break;
       case 1:
           Console.WriteLine(variant.Union.ShortValue);
           break;
       case 2:
           Console.WriteLine(variant.Union.FloatValue);
           break;
   }
}

Unia przechowuje dane w tej samej pamięci. Zatem za pomocą unii, łatwo uzyskać dane w różnym formacie. W praktyce nie ograniczamy oczywiście się do  short czy float ale do różnych buforów (byte[], void*) itp.  Poniższy kod wyświetli dane w różnym formacie w zależności od Type:

Variant variant=new Variant();
variant.Union.CharValue = 'A';

variant.Type = 0;
Display(variant);

variant.Type = 1;
Display(variant);

variant.Type = 2;
Display(variant);

Proszę zwrócić szczególnie uwagę na wynik Type==2 czyli liczby zmiennoprzecinkowej. Powyższa struktura VARIANT szczególnie jest przydatna do opisywania kolorów. W końcu ten sam kolor można wyrazić za pomocą np. tablicy 3 bajtów (RGB) oraz liczby całkowitej składającej się z tych pól. Za pomocą unii nie trzeba dokonywać konwersji pomiędzy byte[] a int – unia umożliwia dostęp do tych samych danych za pomocą różnych accessor’ów.

Unie również były namiastką polimorfizmu ale tego nie będę opisywał na blogu .NET bo w świecie C# są oczywiście do tego dużo bardziej wyrafinowane mechanizmy. Powyższy przykład też można byłoby lepiej napisać wykorzystując programowanie obiektowe…. Dla mnie jednak zaletą unii jest możliwość zapisu i odczytu tej samej komórki pamięci za pomocą różnych interfejsów dostępowych. Zwykłe jest to bardzo przydatne w programowaniu low-level. Na przykład (źródło wikibooks):

union item {
  short theItem;  
  struct { char lo; char hi; } portions;
};

Fragment kodu jest w CPP ale powinien być zrozumiały. W łatwy sposób możemy modyfikować mniej i bardziej istotne bajty, bez dokonywania operacji bitowych (shifting itp.).

Klasa BigInteger

Dziś trochę o .NET Framework i strukturze BigInteger. Myślę, że komuś może to oszczędzić czasu na pisaniu własnych implementacji. Każdy z nas korzystał z typów takich jak short, int czy long. W większości przypadków w zupełności one wystarczającą i pokrywają większość scenariuszy. Czasami jednak aplikacja musi operować na dużo większych liczbach niż 32 czy 64 bitowe zmienne.

BigInteger potrafi przechować dowolnie wielką liczbę całkowitą (ujemną lub dodatnią). BigInteger znajduje się w bibliotece System.Numerics i należy ją najpierw dołączyć do projektu aby móc korzystać z tej struktury. Inicjalizacja struktury może odbywać się na kilka sposób. Na przykład możliwe jest przekazanie liczby za pomocą konstruktora:

BigInteger bigInteger1 = new BigInteger(13131.424f);
BigInteger bigInteger2 = new BigInteger(242);

Console.WriteLine(bigInteger1);
Console.WriteLine(bigInteger2);

Liczba zmiennoprzecinkowa (float) zostanie ucięta i zamiast 13131.424, zostanie przechowana wartość 13131, co wydaje się naturalne. Konstruktor jednak przyjmuje różne typy danych takie jak decimal, int, float itp.

Możliwe jest również operacja przypisania:

int smallNumber = 343;
BigInteger bigNumber = smallNumber;

Console.WriteLine(bigNumber);

Z powyższego kodu wynika, że BigInteger zachowuje się jak zwykła zmienna (struktura posiada przeładowane operatory).

Praktyczniejszym chyba jednak przykładem inicjalizacji BigInteger jest przekazanie tablicy bajtów. Oczywiście w praktyce przekazuje się duże tablice, opisujące wielkie liczby ale dla celów wpisu ograniczymy się do dwuelementowej tablicy bajtów – łatwiej będzie to zrozumieć:

byte[] number = new byte[] {5, 10};
BigInteger bigNumber = new BigInteger(number);

Console.WriteLine(bigNumber);

Cyfra pięć w zapisie binarnym to: 00000101 z kolei dziesięć: 00001010. Łącząc te wartości otrzymujemy 0000101000000101 (little endian ) co w zapisie dziesiętnym daje 2565. Taką właśnie liczbę otrzymamy wywołując Console.WriteLine na BigInteger.

Równie praktyczną metodą jest parsowanie stirng’a:

BigInteger bigNumber = BigInteger.Parse("3543453543636341112414141");
Console.WriteLine(bigNumber);

Parse zachowuje się jak Int.Parse. Jeśli podana wartość to nieprawidłowa liczba, zostanie wyrzucony wyjątek. Z tego względu lepiej to zapisać:

try
{
    BigInteger bigNumber = BigInteger.Parse("3543453543636341112414141");
    Console.WriteLine(bigNumber);
}
catch (FormatException)
{
    Console.WriteLine("Błąd");
}

Istnieje również funkcja TryParse, która jest lepsza w przypadkach gdy nieprawidłowy input jest czymś normalnym:

BigInteger bigNumber;
if (BigInteger.TryParse("3543453543636341112414141", out bigNumber))
  Console.WriteLine(bigNumber);
else
  Console.WriteLine("Błąd");

BigInteger ma kilka przydatnych metod do wykonywania operacji matematycznych. Na przykład za pomocą Pow można podnieść liczbę do dowolnej potęgi:

BigInteger bigNumber1 = BigInteger.Parse("3543453543636341112414141");
bigNumber1=BigInteger.Pow(bigNumber1, 10);
Console.WriteLine(bigNumber1);

Ponadto, jak wspomniałem, BigInteger przeładowuje operatory dzięki czemu w łatwy sposób można wykonać operacje dodawania, mnożenia itp.:

BigInteger bigNumber1 = BigInteger.Parse("3543453543636341112414141");
Console.WriteLine(bigNumber1*bigNumber1);
Console.WriteLine(bigNumber1+bigNumber1);
Console.WriteLine(bigNumber1/bigNumber1);
Console.WriteLine(bigNumber1-bigNumber1);

BigInteger został tak zaprojektowany, aby maksymalnie przypominać zwykłe typy (int, long). Oprócz podstawowych operatów (+,/,-,*) można wykonywać operacje na bitach (shift, XOR itp).

Należy pamiętać, że struktura BigInteger jest immutable. Oznacza to, że nie możemy zmodyfikować żadnego pola wewnętrznego tego pola. Operacja Pow() zwraca nowy obiekt, podobnie ze standardową sumą czy ilorazem. Dla powyższych przykładów jest to naturalne jednak dla inkrementacji okazuje się to bardzo mylące:

BigInteger bigNumber1 = BigInteger.Parse("1");
bigNumber1++;
Console.WriteLine(bigNumber1);

Funkcja nie modyfikuje tutaj bigNumber1 a tworzy nowy obiekt i następnie przypisuje go do bigNumber1. Efekt końcowy jest taki sam, ale implementacja wewnętrzna wykonuje więcej operacji niż mogłoby się wydawać. Z tego względu lepsze jest (jeśli to możliwe) inkrementowanie zwykłego Int32 lub Int64 a następne wykonanie operacji Add na BigInteger. Inkrementacja w pętli BigInteger nie jest dobrym pomysłem.

StructLayout–zastosowanie

W poprzednim poście omówiłem atrybut StructLayout. Dzisiaj z kolei więcej przykładów. Głównie StructLayout wprowadzono aby móc wykonywać kod niezarządzany w .NET. Czasami wciąż zachodzi potrzeba wykorzystania niektórych funkcji z WinAPI. Cześć funkcji przyjmuje jako parametr struktury danych, które oczywiście musimy zmapować na strukturę c#. Na przykład, załóżmy, że mamy następującą strukturę:

typedef struct _DISPLAY_DEVICE {
  DWORD cb;
  TCHAR DeviceName[32];
  TCHAR DeviceString[128];
  DWORD StateFlags;
  TCHAR DeviceID[128];
  TCHAR DeviceKey[128];
} 

Naszym zadaniem jest napisanie analogicznej w c#, którą potem będziemy mogli zapisać do powyższej. W C# może to wyglądać następująco:

[StructLayout(LayoutKind.Sequential, Pack = 1)]
public unsafe struct DISPLAY_DEVICE
{
    public int cb;
    public fixed char DeviceName[32];
    public fixed char DeviceString[128];
    public int StateFlags;
    public fixed char DeviceID[128];
    public fixed char DeviceKey[128];
}

Słowo kluczowe fixed już omawiałem kiedyś na blogu – zachęcam do wyszukania i poczytania jeśli nie jest ono jasne. Musimy określić Pack=1 ponieważ struktura jest mapowana (marshaling) na niezarządzaną a więc  poszczególne bajty muszą się pokrywać. Na przykład, DeviceString musi zaczynać się na tym samym miejscu w c# co w CPP.

Przed c# nie było możliwe zadeklarowanie tablic w powyższy sposób (bezpośrednio w strukturze). Jedynym sposobem była deklaracja pojedynczego elementu ale z FieldOffset takim, że jest tam wystarczająca ilość pamięci na poszczególne elementy. Wtedy za pomocą wskaźnika (unsafe) można było korzystać jak z normalnej tablicy. Nie będę tego tutaj omawiać, bo od c# 2.0 można już deklarować to w powyższy sposób.

Obiekty COM to nie jedyne zastosowanie. Załóżmy, że piszemy loader jakiegoś binarnego pliku (BMP, pliki map itp). Wtedy jeśli w pliku struktura ma PACKING=1  (bo na przykład została napisana w CPP) wtedy aby ją odczytać również musimy mieć taką samą  w C#. Oczywiście mam na myśli sytuację gdy chcemy przeczytać całą strukturę za jednym razem zamiast czytania poszczególnych bajtów i następnego przydzielania ich do poszczególnych pól struktury. Pamiętajmy, że domyślne zachowanie Packing zależy od aktualnej platformy (architektury CPU). Jeśli jednak chcemy strukturę przechowywać w pliku wtedy oczekujemy takiego samego zachowania na wszystkich platformach.

Innym interesującym zastosowaniem są unie. Unia w programowaniu to struktura danych, której rozmiar jest równy maksymalnemu polu w niej. Z tego względu jednocześnie można tam umieścić wyłącznie jedną wartość – inne będą za każdym razem nadpisywane. Załóżmy, że z takiej struktury chcemy zrobić unię:

struct Sample
{
   public int ValueA;
   public int ValueB;
}

Odpalmy poniższy test aby zweryfikować nasze oczekiwania odnośnie struktury:

Sample sample=new Sample();
sample.ValueA = 1;
sample.ValueB = 2;
Debug.Assert(sample.ValueA==1);
Debug.Assert(sample.ValueB == 2);
Debug.Assert(Marshal.SizeOf(sample)==8);

Pola ValueA oraz ValueB są niezależne od siebie i mogą przechowywać jednocześnie różne wartości. Potrzeba na to 8 bajtów. Deklaracja unii z kolei wygląda następująco:

[StructLayout(LayoutKind.Explicit)]
struct Sample
{
   [FieldOffset(0)]
   public int ValueA;
   [FieldOffset(0)]
   public int ValueB;
}

ValueA i ValueB zaczynają się od tego samego miejsca w pamięci a zatem pokrywają się. Zweryfikujmy to:

Sample sample=new Sample();
sample.ValueA = 1;
Debug.Assert(sample.ValueA == 1);
Debug.Assert(sample.ValueB == 1);

sample.ValueB = 2;
Debug.Assert(sample.ValueA==2);
Debug.Assert(sample.ValueB == 2);

Debug.Assert(Marshal.SizeOf(sample)==4);

StructLayout – wprowadzenie

Zanim przejdę do wyjaśniania po co został wprowadzony atrybut StructLayout, najpierw wyjaśnię jak pola w strukturach danych są rozmieszczane w pamięci. Weźmy na przykład taką strukturę:

struct SampleStruct
{
   public byte OneByte;
   public int FourBytes;
   public byte OneByte1;
}

Ile pamięci powinno zostać zaalokowanej dla powyższej struktury? Może wydawać się, że 6 ponieważ Integer zajmuje 4 a Byte 1. Ze względu na optymalizacje nie jest to takie proste i oczywiste. Na moim komputerze jest to 12. Procesor operuje (w zależności od architektury) na danych o wielkości 4 bajty (32 bity). Z tego względu najszybciej dla niego jest odczytać fragmenty znajdujące się na pozycji 4n. Gdyby powyższa struktura zajmowała 6 bajtów wtedy Integer zaczynałby się od drugiego bajta. Aby procesor mógł odczytać taką zmienną musiałby przeczytać dwa kawałki pamięci (pamiętamy, że CPU operuje na 4 bajtowych fragmentach). Prosta optymalizacja polega zatem na wypełnieniu struktury pustymi bajtami. Tak więc OneByte zajmuje nie jeden bajt a cztery. Podobnie sprawa wygląda z ostatnim polem. Dzięki temu, CPU może za jednym razem odczytać każdą ze zmiennych – nie ma konieczności czytania dwóch fragmentów pamięci.

Nie zawsze jednak chcemy takiej optymalizacji. StructLayout daje nam większą kontrolę jak pola struktury będą wypełniane lub porządkowane. Parametr LayoutKind  posiada 3 wartości:

  1. Sequential – domyślna wartość. Pola są szeregowane jeden po drugim. Jeśli OneByte jest pierwszy w kolejności to i w pamięci będzie jako pierwszy.
  2. Explicit – pozwala uporządkować dowolnie pola. Programista może samodzielnie określić gdzie dane pola powinny zaczynać się.
  3. Auto – nie mamy żadnego wpływu  i wszystko zostanie  automatycznie ustalone przez środowisko uruchomieniowe.

Dla C# Sequential jest domyślną wartością ale oprócz LayoutKind można ustalić tzw. Pack. Zobaczmy przykład:

[StructLayout(LayoutKind.Sequential,Pack = 1)]
struct SampleStruct
{
   public byte OneByte;
   public int FourBytes;
   public byte OneByte1;
}

Pack określa wspomniane wypełnianie. Pack równy jeden znaczy, że pola mogą mieć minimalnie jeden bajt. W praktyce oznacza to, że SampleStruct będzie miał rozmiar 6 – łatwo sprawdzić za pomocą funkcji Sizeof:

Console.WriteLine(Marshal.SizeOf(new SampleStruct()));

Analogicznie, Pack=2 spowoduje, że struktura zostanie upakowana do 8 bajtów:

class Program
{
    [StructLayout(LayoutKind.Sequential,Pack = 2)]
    struct SampleStruct
    {
        public byte OneByte;
        public int FourBytes;
        public byte OneByte1;
    }

    static void Main(string[] args)
    {
        // 8 bajtów
        Console.WriteLine(Marshal.SizeOf(new SampleStruct()));
    }
}

A co się stanie z poniższą deklaracją (Pack=3)?

[StructLayout(LayoutKind.Sequential,Pack = 3)]
struct SampleStruct
{
   public byte OneByte;
   public int FourBytes;
   public byte OneByte1;
}

Powyższy kod nawet się nie skompiluje i dostaniemy następujący błąd:

System.Runtime.InteropServices.StructLayoutAttribute' attribute -- 'Incorrect argument value.'

.NET akceptuje wyłącznie następujące wartości Pack: 0, 1, 2, 4, 8, 16, 32, 64,128. Dla innych liczb, kompilacja po prostu będzie niemożliwa. Zero ( 0 ) jest wartością domyślną i oznacza, że pakowanie będzie dokonane przez runtime i zależy od aktualnej platformy. Jeden ( 1 ) w praktyce oznacza, że kolejne pola nie będą miały przerw między sobą – brak wspomnianego wypełniania pustymi bajtami. Pozostałe wartości (2 i większe) oznaczają, że pola będą rozpoczynały się od wielokrotności pola PACK. Zatem jeśli Pack=2 to poszczególne pola mogą zaczynać się od 0,2,4,6,8 itp.  W praktyce wartości wyższe niż 4 lub 8 są po prostu ignorowane ponieważ procesor nie operuje na tak dużych porcjach danych. Poniższy kod wciąż zwraca 12:

[StructLayout(LayoutKind.Sequential,Pack = 128)]
struct SampleStruct
{
   public byte OneByte;
   public int FourBytes;
   public byte OneByte1;
}

static void Main(string[] args)
{
   // PACK został zignorowany - niezgodny z architekturą aktualnego CPU
   Console.WriteLine(Marshal.SizeOf(new SampleStruct()));
}

Pack można używać zarówno dla Sequential jak i Explicit. Dla Auto poskutkuje to błędem runtime:

class Program
{
    [StructLayout(LayoutKind.Auto,Pack = 4)]
    struct SampleStruct
    {
        public byte OneByte;
        public int FourBytes;
        public byte OneByte1;
    }

    static void Main(string[] args)
    {
        // PACK został zignorowany - niezgodny z architekturą aktualnego CPU
        Console.WriteLine(Marshal.SizeOf(new SampleStruct()));
    }
}

image

Aby przekonać się, że Sequential naprawdę jest wartością domyślną spróbujmy zmienić kolejność pól struktury:

class Program
{
    struct SampleStruct
    {
        public int FourBytes;
        public byte OneByte;
        public byte OneByte1;                
    }

    static void Main(string[] args)
    {
        Console.WriteLine(Marshal.SizeOf(new SampleStruct()));
    }
}

Bez używania Pack, na ekranie wyświetli się wartość 8 –  po drobnej zamianie kolejności nasza struktura ma mniejszy rozmiar. Ucieszy to na pewno osoby lubiące optymalizować wszystko co się daSmile Dodam, że jest to znacznie lepsze niż używanie parametru Pack – Pack=1 nie jest dobrym rozwiązaniem ze względu na specyfikę pracy CPU i jego operowanie na porcjach danych (słowach).

Ostatnią wartością jest Explicit. Daje ona największe możliwości jeśli chodzi o alokację pamięci. Jeśli zdecydujemy się na Explicit, sami musimy za pomocą atrybutu FieldOffset określić gdzie dane pole zaczyna się w pamięci. Aby otrzymać rozmiar 8 bez wspomnianej wyżej zmiany kolejności pól wystarczy:

class Program
{
    [StructLayout(LayoutKind.Explicit)]
    struct SampleStruct
    {
        [FieldOffset(0)]
        public byte OneByte1;
        [FieldOffset(2)]
        public int FourBytes;
        [FieldOffset(1)]
        public byte OneByte;        
    }

    static void Main(string[] args)
    {
        // 8
        Console.WriteLine(Marshal.SizeOf(new SampleStruct()));
    }
}

Brak atrybutu FieldOffset spowoduje błąd na etapie kompilacji – jest to po prostu niezbędne dla Explicit. Powyższy przykład chyba jest jasny: pola Byte zaczynają się od 0 i 1 a Integer od 2. Oczywiście lepiej użyć Sequential i zmianę kolejności pól lub atrybut Pack. Nie zawsze się jednak tak się da ale o tym w następnym poście.

W dzisiejszym poście  było dość teoretycznie, w zasadzie bez praktycznego przykładu. W następnym wpisie  wyjaśnię dlaczego StructLayot został dostarczony i kiedy faktycznie z niego należy korzystać. Po dzisiejszym poście kwestia API powinna być już jasna.