Artykuł zgłoszony do konkursu na stronie http://strefainzyniera.pl
Wstęp
Większość oprogramowania, jakie przyjdzie nam pisać, będzie wymagała użycia operatorów. I nie mówię tu wyłącznie o operatorach znanych nam z matematyki – czyli „+”, „-” i tak dalej. Nawet w najprostszych aplikacjach używających chociażby instrukcji warunkowej używamy operatorów porównania. Manipulując zmiennymi zwykle używamy operatorów przypisania i konwersji.
Operatory, to nic innego, jak funkcje, które zwracają wartość, za wejście biorąc wartości, które w wersji prefiksowej występują zaraz po znaku, postfiksowej gdy przed znakiem, zaś infiksowej, pobiera sąsiadów po obu stronach. Jeżeli operator pobiera trzy wartości, symbole składające się na niego rozdzielają wartości wejściowe funkcji wywoływanej (niewidocznie dla programisty) przez operator. Tylko operator wywołania „()” oraz operator pobrania elementu wskazywanego przez indeks „[]” biorą swój operand w środek znaków, jakie się na niego składają.
Dziś omówimy jakie możliwości dają nam operatory w języku C#. Pełną ich listę możemy znaleźć w dokumentacji języka, dostępnej pod adresem http://msdn.microsoft.com/pl-pl/library/6a71f45d.aspx .
Zapoznanie się z priorytetami dostępnymi w C#
Rozpocznijmy od najbardziej znanych nam operatorów – matematycznych: „+” jest znakiem dodawania (istnieje wersja unarna, która zwraca obecną wartość – ale się z nią nie spotkałam), „-” odejmowania (w wersji infiksowej).oraz zmiany znaku (w wersji prefiksowej), „*” mnożenia, zaś „/” dzielenia. Ostatnim jest reszta z dzielenia, reprezentowana znakiem „%”. Można do tej grupy dołączyć operator inkrementacji „++” oraz dekrementacji „–„, które kolejno dodają i odejmują jedynkę. Występują w wersji prefixowej oraz postfixowej. Pozostałe działania matematyczne wykonujemy przy użyciu funkcji matematycznych.
Następną grupą, o której warto wspomnieć, są operatory logiczne i bitowe. Zacznijmy od negacji, zapisywalnej znakiem „!” – zmienia w wyniku true na false, a false na true i nie robi nic więcej. Jeżeli chcemy odwrócić wszystkie bity, na przykład w uint, użyjemy operatora „~”. Innymi operatorami, które operują na pojedynczych bitach są „&” czyli iloczyn logiczny (AND), „|” suma logiczna (OR) oraz„^” – różnica symetryczna (XOR). Można do tej grupy również dołączyć przesunięcia bitowe „<<” oraz „>>”, które po lewej mają liczbę do przesunięcia, a po prawej o ile bitów ma się przesunąć w stronę jaką pokazują dzióbki. Operatorami logicznymi (zwracającymi true lub false) poza negacją („!”) są AND „&&” oraz OR „||”. Najczęściej możemy je spotkać w wyrażeniach warunkowych (jak chociażby if, czy pętli while), gdzie służą do łączenia warunków (w przypadku && wszystkie muszą być spełnione, w przypadku || co najmniej jeden z nich). Jeśli już mowa o wyrażeniach warunkowych, warto wspomnieć o operatorach relacji, czyli porównania. Kolejno „!=” różny, „<” mniejszy, „>” większy, „<=” mniejszy lub równy, „>=” większy lub równy, „==” równy. Warto zwrócić uwagę na dwa znaki równości (częstym błędem jest napisanie o jeden za mało). Jeżeli napisalibyśmy tylko jeden, mielibyśmy do czynienia ze znakiem przypisania – czyli zmiany wartości zmiennej z lewej strony, na wartość (podana wprost, wartość funkcji, wartość innej zmiennej itd.) z prawej strony. Inne operatory przypisania to „*=” , „ /=” , „%=” , „ +=”, „ -=”, „ <<=”, „ >>=” , ”&=”, „ ^= „ oraz „|=”, które działają w taki sposób, że najpierw bierzemy wartości po obu stronach operatora, wykonujemy działanie, na które wskazuje pierwszy znak, a następnie przypisujemy do jego lewej wartości. Na przykład jeśli mamy x=1; x +=5, to do wartości x (w tym przypadku 1) dodajemy 5, oraz przypisujemy wynik tego działania do tego x. X od tej chwili ma wartość 6. Oczywiście liczby nie musimy podawać wprost, możemy tam dla przykładu dać funkcję, która zwraca liczbę i to ta liczba (zwrócona) będzie dodana do zmiennej. Ogólnie x *= y to jest to samo co x = x * y – w przypadku dłuższych nazw zmiennych czy wyrażeń, czytelność kodu zostaje poprawiona.
Wracając do operatorów relacji, mamy jeszcze operatory is oraz as, czyli jest oraz jako.
Operator „is” sprawdza czy zmienna jest danego typu, lub implementuje dany interfejs. Obiekt jest danego typu, również za sprawą dziedziczenia, rzutowania, pudełkowania lub odpudełkowania (boxing, unboxing), użycia konwersji zdefiniowanej przez użytkownika, a także wskazania referencją, wskaźnikiem itd. Słowem – zwraca true, jeżeli obiekt może być użyty jako obiekt danego typu i nie jest nullem. Zazwyczaj ten operator jest używany w połączeniu do sprawdzenia warunku np. if (obiekt is int){dzialanie_na_liczbie(obiekt);}. Bliźniaczym do niego jest operator „as”, czyli jako. O ile is zwracał true, jeśli obiekt można było użyć jako obiekt danego typu, a false jeśli nie można (tak, mamy zwykły operator logiczny), to as jest w zasadzie operatorem konwersji i mówi, aby użyć lewego operandu jako obiekt typu pokazanego po prawej stronie. Czyli „wyrażenie as typ”. Wprowadzono ten operator (podobnie jak operatory przypisania i działania na raz jak +=), dla skrócenia zapisu, jako ekwiwalent dla „wyrażenie is typ ? (typ)wyrażenie : (typ)null” (w którym występuje operator warunku i rzutowania).
Mówiąc o typach, warto wspomnieć o operatorze typeof, który zwraca typ obiektu (będący sam obiektem typu System.Type). Robi tak naprawdę to samo, co funkcja GetType.
Przy okazji warto opisać użyty powyżej operator (). Występuje on w wersji prefiksowej oraz postfiksowej. W przypadku wersji prefiksowej, mamy do czynienia z operatorem rzutowania, jak powyżej. W przypadku postfiksowej mamy operator wywołania, który służy do wywołania funkcji (a w zasadzie metody) znajdującym się pod adresem, na jaki wskazuje nazwa. Służy on również do wywoływania delegatów.
W przykładzie powyżej znajduje się również operator „?:”, zwany operatorem warunku. Mamy „warunek ? pierwsze_wyrażenie : drugie_wyrażenie”, gdzie pierwsze jest wykonane w przypadku gdy warunek zwróci true, zaś drugi gdy zwróci false.
Kolejnym operatorem, który działa zależnie od warunku jest „??”, który zmienia wartość zmiennej tylko w przypadku, gdy jest ona nullem (co za tym idzie, może być użyty wyłącznie w przypadku typów nullable, takich jak int? czy bool? – ogólnie, z pytajnikiem na końcu nazwy typu, oraz np. stringa czy typu zdefiniowanego przez nas). Jeżeli nie jest nullem, zostawia to, co jest. Warto użyć chociażby do utworzenia obiektu, jeśli zmienna ma wartość null (chociażby w setterze przy implementacji singletona). Przykładem użycia może być sytuacja, kiedy tworzymy nowy obiekt, jeśli nie istnieje inny, a jeśli istnieje, to go przypisujemy: „Klasa jakiśobiekt = obiekt ?? new Klasa();”, albo jeśli chcemy przypisać nullable int do int, i chcemy przypisać jakąś wartość, jeżeli tamta będzie nullem: „int? x = null; int y = x ?? 0;”
Ciekawym operatorem jest operator wyrażeń lambda „=>”. Nie zwraca on wartości liczbowej, ale funkcję, którą możemy przypisać chociażby do delegaty, lub wywołać. Wywołanie wygląda następująco: „() => { //ciało funkcji }” . Zapis w cudzysłowie wygląda co najmniej strasznie, ale przyjrzyjmy się tu bliżej. Mamy w klamerkach ciało funkcji. Operator „=>” zwraca funkcję o ciele w klamerkach, przyjmującej parametry podane w nawiasie. Innym przykładem użycia jest „x => x * x”. Wyrażenie to możemy przypisać do delegaty (tzn typDelegaty Del = x => x * x i wywołać w kodzie del(2); jak zwykłą funkcję, dostaniemy kwadrat liczby ), i przeczytalibyśmy to „x takie że x *x” Opis wyrażeń lambda (i programowania funkcyjnego) wychodzi daleko poza ramy tego artykułu – tutaj pokazano wyłącznie użycie operatora.
Przejdźmy do operatorów operujących nie tyle na wartościach, co na samych zmiennych i tego jak są one reprezentowane. Zacznijmy od kropki ( „.”) będącej operatorem wyłuskania, tzn dostępu do elementu (np. funkcji, własności, wartości…) obiektu jakiejś klasy, enuma lub struktury. Przykładem niech będzie dzień.poniedziałek, człowiek.noga czy ekspres.zaparz_kawe(); . Kolejnym operatorem, jaki można dołączyć do tej grupy, jest unarny operator „&”, który zwraca adres tego, co występuje za nim, oraz „*”, który zwraca to, co znajduje się pod adresem tego, co występuje za nim (dokonuje dereferencji). Oba mogą wystąpić wyłącznie w sekcji unsafe, gdzie mamy dostęp do pamięci i możemy używać jawnie wskaźników. Operatorem, który również jest związany ze wskaźnikami i jest używany w sekcji unsafe, jest również „->”, który oznacza dobranie się do składnika złożonego obiektu, wskazywanego wskaźnikiem. Wyrażenie „x->y” jest równoznaczne z „(*x).y”.
Najmłodszy z operatorów – operator „await” ułatwia pracę z metodami asynchronicznymi (wydaje się nam, jakby były zwykłe). Z jednej strony aplikacja nam się nie przycina (mulącą funkcję wywołaliśmy asynchronicznie), z drugiej zaś możemy się dobrać do tego co stoi za awaitem bez callbacków. Używany tylko w metodach oznaczonych słówkiem async.
Operator new służy do tworzenia nowego obiektu. Operator default zwraca wartość domyślną dla danego typu. Dla liczb będzie to zero, dla obiektu null, dla boola false, dla typów generycznych pierwszy element. Operator checked sprawdza, czy aby przypadkiem nie doszło do przepełnienia zmiennych, rzucając wyjątek typu System.OverflowException, jeśli to jednak miało miejsce. Wyłącza takie działanie operator unchecked. Jeżeli zmienna się przepełni, zamiast dużej liczby dodatniej, pojawi się duża liczba ujemna (przekręci się licznik). Przy użyciu unchecked skompiluje się nawet kod, gdzie do inta zapisujemy maxint + stała (normalnie wyrzuciłoby błąd kompilacji).
Operator sizeof zwraca nam ilość bajtów zajmowanych przez daną zmienną. Może przyjąć za parametr typ, typ enum, wskaźnik, strukturę (nie zawierającą zmiennych o typach referencyjnych) – Ma ono sens tylko w przypadku typów niezarządzalnych.
Operator stackalloc służy do alokacji miejsca na stosie w sekcji unsafe.
Ciekawostką jest, że w języku C# przecinek nie jest operatorem (mimo że jest dozwolone użycie go do rozdzielenia inicjalizacji). Zacytuję dokumentację: „C# does not support the comma operator. It allows comma separated initialization for example inside a for statement (e.g. (for i = 0, j = 0; i < foo.size(); i++) { … } but doesn’t consider the comma in this case to be an operator.” Wydaje się to logiczne. W przypadku C/C++ jest on operatorem sekwencji. Nie pasuje on jednak do operatorów w rozumieniu nowoczesnych języków – rozdzielenie przecinkiem nie powoduje bowiem zwrócenia wartości po wykonaniu danej operacji – czyli mówiąc prościej – nic nie jest obliczane, nic nie jest tworzone, do żadnej wartości się nie dostajemy – słowem nie jest wykonywana żadna operacja. Po prostu oddzielamy od siebie elementy. Przecinek jest więc separatprem, nie operatorem.
Priorytety operatorów
Mamy następujące grupy operatorów, od najwyższego priorytetu do najniższego. Jeżeli priorytety występują w tej samej grupie (jak na przykład dodawanie i odejmowanie), wykonujemy je od lewej do prawej. W każdej chwili możemy wywołać funkcję GetPriority(//tutaj co); aby sprawdzić jaki priorytet ma dane działanie. Im niższa liczba, tym wyższy priorytet. 1. „.”, „->”, „() – jako wywołanie funkcji”, „[]”, „++” – dla x++, „–„, „new”, „stackalloc” (przydział na stosie)”, „typeof”, „checked”, „unchecked”, „default”, „await”
2. „sizeof”, „+” , „-” – ten od zmiany znaku, „!”, „~”, „()”- jako rzutowanie, ++ – dla ++x, „*”- dla wyłuskania, „&” – dla dereferencji
3. „*”, „/”, „%”
4. „+”, „-”
5. „<<„, „>>”
6. „<„, „>”, „<=”, „>=, „is”, as”
7. „==”, „!=”
8. „&” – dla and
9. „^”
10. „|”
11. „&&”
12. „||”
13. „?:”,
14. „=”, „*=”, „/=”, „+=”, „-=”, „<<=”, „>>=”, „&=”, „^=”, „|=”, „??”, „=>”
Łączenie i przeciążanie operatorów
Większość operatorów dwuargumentowych (poza operatorami przypisania, operatorem „??” i „=>”) jest łączona lewostronnie (tzn od lewej do prawej). Operatory przypisania, operator alternatywy dla nulla (??) oraz lambda (=>) prawostronnie (tzn od prawej do lewej, niejako od tyłu).
Operatory są zdefiniowane dla klas wbudowanych – w końcu kompilator nie wie co ma dostać w wyniku dodania np. dwóch obiektów klasy reprezentujących książkę. Jeśli chcemy, możemy napisać własne zachowanie dla następujących operatorów: „++”, „–„, „+”, „-„, „!”, „~”, „*”, „/”, „%”, „<<„, „>>”, „<„, „>”, „<=”, „>=”, „==”, „!=”, „&”, „|”, „^”, „true”, „false” (przy czym 2 ostatnie występują wyłącznie jako operatory przeładowane). Przykład to
public static Klasa operator +(Klasa prawy,Klasa lewy) { return new klasa(prawy.wartosc + lewy.wartosc); }
Możemy przeciążyć również operator „[]”, ale do tego musimy użyć indekserów (zainteresowani mogą o nich poczytać w sieci – jako zadanie domowe).
Podsumowanie
W powyższym artykule zostały przybliżone informacje na temat operatorów w języku C#. Przedstawiono znaczenie każdego z symboli, priorytety operatorów, oraz możliwości ich łączenia, przeciążania.