OperatoryCzęść NR.16 Nie raz używałeś operatorów + – do typów wartościowych. Pytanie brzmi jak te operacje przenieść do swoich własnych klas i struktur.

Na początek mała powtórka z tego czym są i jak działają operatory.

Operatory, czym są tak naprawdę? Powtórka wiedzy

  • Używasz operatorów w kombinacji z operandami, które łącznie tworzą wyrażenie. Każdy operator ma swój własny cel i w zależności od typu operandów wykonuje różne polecenie. Przykładowe + dodaje liczby całkowite ale również dodaje , a raczej łączy dwa łańcuchy znaków.
  • Każdy operator ma swoją odpowiednią kolejność. Mnożenie “ * ” wykona się wcześniej niż dodawanie “+”.
  • Każdy z operatorów ma swoją “łączność”. Mówiąc jaśniej każdy operator ocenia operacje w określonym kierunku “z lewej do prawej” czy “z prawej do lewej”. Operator “ = “ jest operacją wykonującą się od prawej strony do lewej więc a = b = c to samo co a = (b = c).
  • Mamy też do dyspozycji operatory jednoargumentowe jak inkrementacja (i++) i dekrementacja (i--)
  • Operacje dwuargumentowe wymagają dwóch operandów.

Zanim przejdziemy do nadpisywania operatorów należy zaznaczyć czego nie możemy nadpisać.

Jakie zachowania nie mogą być nadpisane

  • Kolejność i łączność nie może być zmieniona dla danego operatora. Kolejność działań bazuje na symbolu operatora jak “+” , a nie na typie danych operandów jak np. int. Czyli kolejność działań (a + b / c – d) zawsze będzie taka sama bez względu na typ danych a,b,c,d.
  • Nie możesz zmienić ilości argumentów dla danego operatora czyli inkrementacja zawsze będzie potrzebować jednego operatora , a mnożenie dwóch.
  • Nie możesz stworzyć nowych symbolów dla operatorów jak np. “ /+” czy “ ** ” . W takich wypadkach lepiej tworzyć swoją własną metodę niezależną od symbolów.
  • Nie możesz nadpisać cech operatorów dla wbudowanych typów. Czyli dodawanie dla typu wbudowanego int zawsze będzie wykonywać dodawanie. Zresztą w praktyce nadpisanie takiego podstawowego zachowania jest trudniejsze niż się wydaje. Procesor i itp..
  • Pewnych symboli nie można nadpisać. Jak np. symbolu kropki, który daje nam możliwość dostępu do składowych danego obiektu bądź zainicjowanej klasy.

Wszystkie te zasady mają w pełni logiczne wyjaśnienie. Chcemy dać możliwość użycia tych operacji w swoim klasach i strukturach ,ale operacje te muszą wciąż zachowywać się w znajomy sposób. Czyli dodawanie nie powinno wykonywać odejmowania. W końcu programista trzeci oczekuje od naszego kodu normalnego zachowania.

Przeciążanie operatorów

Tyle wprowadzenia. Teraz jak przeciążać operatory.

Aby zdefiniować zachowania danego operatora musi go nadpisać. Nadpisanie wygląda jak utworzenie nowej metody która, zwraca coś i potrzebuje określonej ilości parametrów do działania. Różnica polega na zastosowaniu słowa kluczowego “operator” wraz z symbolem operatora, którego nadpisujemy.

W tym przykładzie w strukturze Minuty zostaje nadpisany operator plus. Dodałem do niej też właściwość dla przypomnienia poprzedniego wpisu.

struct Minuty 
{
    public static Minuty operator +(Minuty lhs,Minuty rhs)
    {
        return new Minuty(lhs.value + rhs.value);
    }

    private int value;

    public Minuty(int firstValue)
    {
        value = firstValue;
    }

    public int Value
    {
        get { return this.value; }
        set { this.value = value; }
    }
}

Jakie są najważniejsze punkty i obserwacje w tym przykładzie:

  • Metoda nadpisująca operator musi być publiczna.
  • Operator musi być statyczny. Skoro muszą być statyczne operatory nie mogą być wirtualne, abstrakcyjne czy zamknięte.
  • Każdy operator ma określoną liczbę argumentów dla znaku “+” muszą to być dwa argumenty.

Teraz, gdy operator został nadpisany operacja dodawania jest możliwa. A kompilator wezwie za nas odpowiednią metodę przy dodawaniu.

Minuty m1 = new Minuty(12);
Minuty m2 = new Minuty(22);

Minuty m3 = m1 + m2;

Trzeba pamiętać, że dodawanie może nastąpić tylko pomiędzy klasami Minuty, w końcu wciąż w rzeczywistości wykonuje się tamta metoda, która potrzebuje dwóch parametrów klasy Minuty. Czyli nie mogę wykonać dodawania z klasą Minuty ,a z typem int. Przynajmniej na razie…

Przeciążanie operatorów
…ponieważ na to też jest sposób…

Minuty m3 = m1 + new Minuty(12);

…i ten sposób jest lepszy, niż ten kod powyżej.

Tworzenie operatorów symetrycznych

Aby struktura minuty mogła otrzymywać int w wyrażeniu dodawania musimy napisać nie jedną , a dwie kolejne metody dodawania. Dlaczego dwie?, ponieważ dla C# umiejscowienie typu wartości ma znaczenie.

Jeśli napiszemy metodę, która przyjmujeint + Minuty nie znaczy to, że automatycznie kompilator rozumie, że operacja Minuty + int. to samo coint + Minuty. Dla niego są to dwie różne operacje więc musimy napisać dwie metody.

Przeczy to trochę naturalnej logice ale jeśli chcesz napisać operacjea + b , gdziea i b są różnego typu to automatycznie powinieneś napisać metodę b + a.

Oto jak to wygląda w kodzie:

struct Minuty 
{
    public static Minuty operator +(Minuty lhs,Minuty rhs)
    {
        return new Minuty(lhs.value + rhs.value);
    }

    public static Minuty operator +(Minuty lhs,int rhs)
    {
        return new Minuty(lhs.value + rhs);
    }

    public static Minuty operator +(int lhs, Minuty rhs)
    {
        return new Minuty(lhs + rhs.value);
    }
    //dalszy kod 

Teraz dodawanie jest możliwe. Jednak zaleca się nie przesadzać z ilością typów, które struktura może przyjmować w wyniku dodawania. Czyli pisanie kolejnych metod dla double,float i tak dalej nie ma już sensu. Lepiej żeby sam użytkownik podjął odpowiednie kroki w celu wykonania takiego dodawania.

Operatory += , *= , –= , /= A przeciążanie operatorów

Są też operatory compound assignment, które ja nazywam operatorami skrótowymi ponieważ nie wiem jakie jest ich oficjalne tłumaczenie. Mam nadzieję, że będzie mi to wybaczone.

Operatory te są dosyć przydatne ponieważ dzięki nim wyrażenie takie…

a += 10;

…jest skrótem tego wyrażenia…

a = a + 10;

Dla nas robi to różnicę w pisaniu ilości kodu natomiast dla kompilatora nie. Głównie wyrażenia a #= 10 (gdzie # to operator) jest zawsze tłumaczone przez kompilator jako a = a # 10;.

Skoro wcześniej nadpisaliśmy operator “+” to znaczy ,że przy tych operatorach wykonają się nasze metody.

Minuty m4 = new Minuty(30);
int pomocnik = 25;

m4 += m4; 
// taka sama operacja jak 
m4 = m4 + m4; 

m4 += pomocnik; 
// taka sama operacja jak 
m4 = m4 + pomocnik; 

Wszystkie operacje są poprawne istnieje przecież metoda przeciążona, która obsługuje minuty + minutyjak i metoda obsługująca minuty + int. Dla kompilatora właśnie to tak wygląda.

Operatory inkrementacji i dekrementacji

Analogicznie, jak w przypadku operatorów dwuargumentowych, podobnie wygląda przeciążanie operatorów jednoargumentowych jak inkrementacja i dekrementacja. Musimy przestrzegać tych samych zasad, co wcześniej czyli metoda musi być statyczna i publiczna.

struct Minuty 
{
    public static Minuty operator ++(Minuty arg)
    {
        arg.value++;
        return arg;
    }

Operatory inkrementacji i dekrementacji mogą być użyte w formie prefiksu i sufiksu(postfix).

Dla przypomnienia wyrażenie prefiks zmieni wartość przed daną operacją ,natomiast sufiks(postfix) zmieni wartość po danej operacji.

int a = 10;
Console.Write(++a);//11 prefiks 
a = 10;
Console.Write(a++);//10 postfiks sufiks 

Punkt jest jednak taki ,że dla kompilatora nie ma to znaczenia i skorzysta on z tej samej metody, którą nadpisaliśmy.

Minuty m4 = new Minuty(10);
Minuty m5 = m4++;

m4 = new Minuty(10);
Minuty m6 = ++m4;

Wewnątrz kodu IL , który zostanie utworzony przez kompilator będzie to wyglądać mnie więcej tak.

Minuty m4 = new Minuty(10);

Minuty prefiks = m4++;
Minuty m5 = prefiks;

m4 = new Minuty(10);// ustalenie tej samej wartość 
Minuty m6 = m4; 

//postfiks 
m4++;

Jest to tylko przykład ale ważne jest ,że dla kompilatora nie ma to znaczenia i zostanie wykonana ta sama metoda z operatem x++;

Różnice w operatorze, w strukturze i klasie

Istnieje poważna różnica pomiędzy działaniem inkrementacji w strukturze , a w klasie.

Obecny kod się skompiluje jeśli zmienimy strukturę “Minuty” na klasę ,ale postfiks nie będzie już wykazywał poprawnej odpowiedzi. Kod obecnej metody operatora ++ nie jest więc poprawny dla klasy.

Klasa jest typem referencyjnym i patrząc na kod tłumaczący, co tak naprawdę się dzieje, gdy wykonuje się postfiks (kod wyżej i niżej) możesz zdać sobie sprawę, w czym jest problem.

Minuty m6 = m4; 

//postfiks
m4++;

W czasie przypisania zmienne m6 i m4 będą referowały się do tego samego obiektu. Czyli zmiana m4 wypłynie na m6.

Jeśli “Minuta” jest strukturą w wyniku przyrównania zostanie utworzona kopia i zmiana oryginału później nie wypłynie na jej kopie. Problemu więc nie ma.

Tak czy siak dla klasy poprawna implementacja wygląda tak:

class Minuty 
{
    public static Minuty operator ++(Minuty arg)
    {
        return new Minuty(arg.value + 1);
    }

Teraz operator ++ będzie zwracał zupełnie nowy obiekt z wartością zwiększoną o jeden. Ponieważ jest to nowy obiekt problem już nie występuje. Nie jest to wydajne pamięciowo rozwiązane, zwłaszcza jeśli operator ten będzie używany w jakieś pętli. Za każdym razem tworzymy nowy obiekt i sprzątacz w C# “Garbage Collector” może nie nadążać z usuwaniem niepotrzebnych obiektów, które referowały się wcześniej do danej zmiennej.

Jest to główny powód, dla którego kod ten nie został bezpośrednio użyty od razu dla struktury, gdyż nie jest on potrzebny.

Operatory == i !=

W tym wpisie pozostały nam tylko operatory sprawdzające równości. Chociaż tematyka związana z operatorami pojawi się jeszcze w następnym wpisie.

“==” i “!=” są to dwa oddzielne operatory. Jednak przy zdefiniowaniu jednego, z logicznego punktu widzenia, musi istnieć i drugi.

Dla klasy “Minuty” deklaracja tych operatorów wygląda następująco. W praktyce w dużych strukturach i klasach na pewno będzie to wyglądać inaczej.

struct Minuty 
{
    public static bool operator ==(Minuty lhs, Minuty rhs)
    {
        return lhs.value == rhs.value;
    }

    public static bool operator !=(Minuty lhs, Minuty rhs)
    {
        return lhs.value != rhs.value;
    }

Ciekawe ,że operatory te mogą zwracać coś innego niż wartość logiczną, tylko szczerze nie wiem dlaczego nie miałyby zwracać wartości logicznej, w końcu jest to poprawne zachowanie. Tak czy siak nigdy nie komplikuj sprawy żeby operator zwracał zawsze wartość logiczną bool.

Teraz ważna rzeczy.Żarówka

Istnieje pewien związek pomiędzy tymi operatorami ,a metodami jak Equals() i GetHashCode() , które pochodzą od klasy Object albo od klasy System.ValueType jeśli mówimy o strukturach.

Jeśli twoja klasa bądź struktura ma te operatory, to wypadałoby też by ta klasa/struktura nadpisała metody Equals() i GetHashCode().

Metoda Equals() powinna wykonywać dokładnie to samo co operator “==” tak jest we wszystkich wbudowanych klasach .NET.

Z metodą “GetHashCode” jest trochę gorzej bo na nią można poświęcić oddzielny wpis. Metoda ta musi zwracać wyróżniający się klucz int. Zwykle ten klucz jest rezultatem funkcji XOR wszystkich wartości jakie posiada dana struktura/klasa.

W praktyce ta metoda ma dużą funkcję w porównywaniu obiektów czyli w operatorze (==) i w metodzie Equals(). Ponieważ klucze tych obiektów są porównywane i są zależne od zmiennych wewnątrz danej klasy/struktury. Na pewno jest to lepsze rozwiązanie niż sprawdzanie wszystkich pól po kolei za pomocą dziwacznej funkcji if.

Więcej info o GetHashCode() w dokumentacji MSDN jak i metodzie Equals() tutaj.

Co dalej:

W następnym wpisie pomęczymy operatory jeszcze bardziej. Swoją drogą trzeba też coś powiedzieć o klasach z przestrzeni System.Collection.Generics oraz o delegatach i zdarzeniach.

Spis treści kursu: