1. Programowanie obiektowe

1.5. Metody

Metody nadają klasom charakter. Typowa struktura danych (rekord) - pochodząca jeszcze z dawnego programowania strukturalnego - dzięki metodom przekształca się z pasywnego worka łączącego pola różnego typu - w aktywny obiekt. 

Z praktycznego punktu widzenia metody nie różnią się znacznie od standardowych funkcji. Podstawową różnicą jest fakt - iż definiowane są wewnątrz klasy.  Przy czym należy rozróżniać deklarację metody od jej definicji (implementacji). Jak wspomniałem, zazwyczaj deklaracje metod zamieszcza się w deklaracji klasy (w pliku nagłówkowym), natomiast kod implementujący je (realne ciało funkcji)  jest umieszczany w pliku implementacji. 

Warto jednak wiedzieć, że umieszczenie kodu metod bezpośrednio w bloku definicji klasy (w nagłówku, po słowie class) sprawi, że kompilator potraktuje je jako metody inline, czyli rozwinięte w miejscu wywołania, i wstawi cały kod przy każdym odwołaniu się do nich. Dla krótkich, jednolinijkowych metod jest to korzystne rozwiązanie, przyspieszające działanie programu. Jednak dla dłuższych metod może prowadzić do znacznego zwiększenia rozmiaru pliku wykonywalnego.

Istnieje także szereg modyfikatorów deklaracji funkcji - zazwyczaj wprowadzanych po to, by zmodyfikować ich zachowanie. Na przykład, wybrane metody można uczynić stałymi. Taka  metoda  może modyfikować żadnego z pól klasy, do której należy – nie zmienia jej stanu. Może jedynie odczytywać wartości, dokonywać ich przekształceń i zwracać wyniki. 

Dlaczego zdecydować się na taki zabieg?  Z jednej strony - jest  to wskazówka dla kompilatora pomagająca w optymalizacji kodu wynikowego. Ważniejszym zastosowaniem jest zabezpieczanie przed przypadkową modyfikacją stanu obiektu w metodzie, która nie miała tego dokonywać. Sztandarowym przykładem jest tutaj getter - metoda odczytująca wartość jednego z pól zazwyczaj nie powinna zmieniać ani odczytywanego, ani żadnego innego pola.  Lecz prawdziwą przydatność odkryjecie w momencie przekazywania parametrow do funkcji. Jeśli parametrem jest obiekt jakiejś klasy, i jest przekazany jako stały (oznaczony słowem kluczowym const) - to można dla takiego obiektu wywołać tylko stałe metody.  

Sugerowałbym Wam stosowanie metod z postfix-em const (stałych). Technicznie zapis zmieniający metodę w stałą - jest banalnie prosty: wystarczy tylko dodać za listą jej parametrów magiczne słówko const, np.:


class CInnaMoja {
private :
  int m_pole;
public :
  int pole() const { return m_pole; }
};

Metoda pole() (będąca de facto getterem dla pola m_pole) będzie tutaj słusznie metodą stałą – nie ma takiej możliwości i potrzeby by odczyt pola modyfikował stan obiektu.

Czasami także spotkacie dualizm - istnieje metoda oznaczona jako const, i druga, o takiej samej nazwie, bez const. Typowy przykład: 


class CMoja {
private :
  int m_wartosc;
public :
  int& wartosc() { return m_wartosc; }
  const int& wartosc() const { return m_wartosc; }
};

Na pierwszy rzut oka - wygląda dziwnie. Ale daje nam potem większą elastyczność korzystania z tej klasy:


void f1(const CMoja& v) {
  auto val = v.wartosc(); // mogę odczytać wartość
  val += 10; // tu będzie błąd - nie mogę jej zmienić
  // przekazana była stała referencja, można korzystać tylko
  // ze stałych metod. 
};

void f2(CMoja& v) {
  auto val = v.wartosc(); // mogę odczytać wartość
  val += 10; // teraz już błędu nie będzie, stan obiektu zmieni się 
};

Dzięki kontekstowi wywołania - prawidłowo też zadziała mechanizm przesłaniania nazw, pozwalający kompilatorowi na dobranie odpowiedniej wersji metody (z const lub bez).

Implementacja metod

Zajmijmy się teraz nieco dokładniej implementacją metod dostępnych w klasach. Operację tę rozpoczynamy od dołączenia do pliku z implementacją nagłówka z definicją naszej klasy, np.:


#include "klasa.h"

Potem możemy już przystąpić do pisania kodu metod. Postępujemy tutaj bardzo podobnie, jak w przypadku zwykłych, globalnych funkcji. Składnia metody wygląda analogicznie do składni funkcji, jedyną różnicą jest podanie przed nazwą metody nazwy klasy do której owa metoda należy. Wpisanie jej jest konieczne: po pierwsze mówi ona kompilatorowi, że ma do czynienia z metodą klasy, a nie zwyczajną funkcją; po drugie zaś pozwala bezbłędnie zidentyfikować macierzystą klasę danej metody. Między nazwą klasy a nazwą metody widoczny jest operator zasięgu ::

Zazwyczaj zamieszczamy w pliku implementacji kod kolejnych metod należących do tej samej klasy kolejno,  jedną po drugiej - tak łatwiej zapanować nad wszystkimi metodami. 

Pamiętajcie także, że w przypadku metod stałych - w implementacji należy także powtórzyć postfix const. Inaczej wystąpi błąd kompilacji. 

Blok instrukcji metody tradycyjnie jest zawarty między nawiasami klamrowymi. Cóż ciekawego można o nim powiedzieć? Niewiele: nie różni się prawie wcale od analogicznych bloków zwykłych funkcji. W zasadzie jedyną istotną różnicą jest bezpośredni i niejawny dostęp do wszystkich pól i metod swojej klasy - tak, jakby były one jego zmiennymi albo funkcjami lokalnymi.

Magiczne słówko this

Z poziomu metody mamy dostęp do jeszcze jednej, bardzo ważnej i przydatnej informacji. Chodzi tutaj o obiekt, dla którego metoda jest wywoływana; mówiąc ściśle, o odwołanie się do jego tożsamości, bądź też wskaźnika do samego siebie.  Ten wskaźnik uzyskujemy właśnie przy pomocy słowa this

W C++ nazwy metod są na etapie kompilacji przekształcane do nowej postaci – dodawana jest do nich informacja o typach parametrów, oraz – niejawnie – na stosie umieszczany jest dodatkowy parametr, który jest wskaźnikiem na instancję klasy, dla której metoda została wywołana. Dlatego też nie można stosować wskaźnika do metody zamiennie ze wskaźnikiem do funkcji, nawet jeśli typ wartości zwracanej i liczba jej parametrów jest dokładnie taka sama.

W niektórych językach rola this jest znacznie większa niż w C++ - to jedyna technika by odwołać się do własnych pól w klasie pisanej przykładowo w PHP. W C++ nie jest to niezbędne. this stosuje się w kilku przypadkach. Większość z nich poznacie później, natomiast teraz ... teraz przedstawię Wam dwa z nich. Pierwszy – to gdy w ciele metody zdefiniujemy zmienną lokalną nazywającą się tak samo jak pole klasy. Wtedy by do pola klasy się odwołać – potrzebujemy this. Druga – to ułatwienie działania systemowi podpowiadania wbudowanemu w nowoczesne środowiska programowania. Pisząc w ciele metody this-> ograniczamy przeszukiwanie potencjalnych symboli tylko do pól i metod danej klasy i klas nadrzędnych.

Metody statyczne

Wspomniałem wcześniej o istnieniu dodatkowego typu metod, nie omawianych do tej pory – to metody statyczne. W zasadzie nazywanie ich metodami jest działaniem nieco na wyrost - w sensie ich powiązania z konkretnym obiektem. Tak naprawdę – to nie jest metoda, lecz zwykła funkcja zdefiniowana w ciele klasy. Zwykła funkcja – czyli nie zna tożsamości obiektu ją wywołującego - nie ma w niej dostępu do tożsamości obiektu wywołującego, nie można korzystać z this. Natomiast jest zdefiniowana w ciele klasy - więc mamy pełen dostęp do wszystkich pól i metod - o ile znamy obiekt. Więc - metody statyczne mogą operować na wszystkich polach jej instancji, o ile mają przekazany taki obiekt jako parametr.


Przeładowanie (redefinicja) operatorów

W języku C++ duży nacisk położono na udostępnienie konstrukcji językowych, które pozwolą na tworzenie nowych typów danych tak, by korzystanie z nich było maksymalnie proste i podobne do typów wbudowanych. Dlatego też chcemy, by można było np. budować wyrażenia w których skład wchodzą typy użytkownika korzystając z tej samej składni, z której korzysta się budując wyrażenia ze zmiennych typów fundamentalnych.

Przykładowo - chcąc obliczyć sumę dwóch liczb rzeczywistych, wykorzystamy kod podobny do poniższego:


double x{10.5}, y{11.5}; 
auto z = x+y; 

Co jeśli wprowadzimy własny typ - powiedzmy liczby zespolone - i dla nich będziemy chcieli napisać podobny kod?


class complex {
   ... 
};

complex x{10.5, 2}, y{11.5, 3.1}; 
auto z = x+y; 

Żeby to mogło zadziałać - musimy wprowadzić własną definicję operatora dodawania dla typu complex.

Przeciążanie operatorów w języku C++ stanowi ważny element programowania obiektowego, który umożliwia programistom dostosowanie zachowania wbudowanych operatorów do niestandardowych typów danych. Podstawowym założeniem tej techniki jest możliwość definiowania nowego działania dla operatorów w kontekście użytkownych klas, co pozwala na bardziej elastyczne i ekspresywne programowanie.

W języku C++, dla własnych typów danych (klas) operatory są w rzeczywistości funkcjami specjalnego rodzaju, które mogą być wywoływane za pomocą operatorów w wyrażeniach. Na przykład, operator dodawania `+` jest zamieniany przez kompilator na wywołanie metody lub funkcji o nazwie `operator+`. Przeciążanie operatorów polega więc na zdefiniowaniu tych funkcji lub metod w sposób odpowiedni dla naszych klas. Zacznijmy od podejścia funkcyjnego. Ogólnie - postać funkcji na operatora dwuargumentowego (takiego jak dodawanie) wygląda następująco:


typ operator@(typ arg1, typ arg2) {
    // Kod realizujący działanie operatora @ dla typu danych typ
}

gdzie @ jest symbolem operatora (np +), a typ - nazwą klasy dla której definiujemy operator. Tak więc, by zmusić własny typ complex do posiadania operatora dodawania, wystarczy że zdefiniujemy następującą funkcję:


class complex {
   ...
};

complex operator+(const complex& x, const complex& y) {
   /// tu powinien być kod dodwania
};

complex x{10.5, 2}, y{11.5, 3.1}; 
auto z = x+y; 

Alternatywą jest zdefiniowanie operatora wewnątrz klasy:


class complex {
   ...
   complex operator+(const complex& other) {
      /// tu powinien być kod dodwania
    }
};

complex x{10.5, 2}, y{11.5, 3.1}; 
auto z = x+y; 

Zarówno jeden, jak i drugi sposób mają swoje zalety i wady, które należy wziąć pod uwagę w zależności od kontekstu i preferencji programisty. W przypadku stosowania funkcji - należy rozważyć stosowanie dodatkowej przestrzeni nazw w której definiujemy nasze klasy, by nnie zaśmiecać nadmiernie globalnej przestrzeni nazw. Stosowanie funkcji daje nam:

  • Większą elastyczność: Funkcje mogą być zdefiniowane niezależnie od klas, po ich definicji, oraz bez dostępu do ich implementacji - co pozwala nam na stosowanie przeciążania różnych operatorów dla różnych, w tym nie naszych, typów danych.
  • Symetrię: Funkcje operatorowe mogą być przeciążane dla różnych typów danych w sposób symetryczny, co ułatwia czytelność kodu.

Podejście takie nie jest jednak pozbawione wad, z których podstawową jest brak dostępu do prywatnych składowych klasy. Funkcje zewnętrzne do klasy nie mają dostępu do jej prywatnych składowych, co może prowadzić do konieczności wykorzystania metod publicznych, i w konsekwencji niższej wydajności kodu.

To co jest wadą w przypadku funkcji, staje się zaletą w przypadku metody. Metoda operatorowa ma dostęp do prywatnych składowych klasy, co ułatwia jej efektywną implementację. Ponadto podejście oparte na metodach sprawia, że operatory mogą być dziedziczone, i wirtualne - co w przypadku skomplikowanych hierarchii klas stanowi znaczący zysk.

W praktyce wybór między tymi dwoma podejściami zależy od kontekstu, preferencji programisty oraz struktury klasy i hierarchii dziedziczenia, oraz operatora. Wykorzystany przeze mnie wcześniej w klasie wektor operator indeksowania zazwyczaj jest implementowany jako metoda klasy. Z drugiej strony - operatory wejścia / wyjścia (<< i >>), czy dwuargumentowe operatory arytmetyczne - częściej implementowane są poza klasami.

Pamiętajcie - przeciążanie operatorów to nie bajer, a użyteczna technika. Dzięki niemu kod wykorzystujący Wasze typy staje się bardziej zwięzły i czytelny. Operatory mogą być używane w naturalny sposób, co ułatwia zrozumienie intencji kodu. Programiści mogą tworzyć niestandardowe typy danych, które zachowują się podobnie do wbudowanych typów. Przeciążanie operatorów pozwala na definiowanie spójnych interfejsów dla tych typów. Użycie przeciążonych operatorów jest zgodne z konwencjami języka C++, co sprawia, że kod jest łatwiejszy do zrozumienia dla innych programistów.

Uwagi dotyczące przeciążania operatorów

Nie wszystkie operatory mogą być przeciążone w dowolny sposób. Istnieją pewne ograniczenia dotyczące liczby argumentów oraz ich typów dla poszczególnych operatorów. Nie można zmieniać znaczenia operatorów które mają postać tekstową (np. sizeof), nie można zmieniać operatorów rzutowań (np. static_cast), oraz wybranych pozostałych operatorów:
. (kropka)  .*  ::  ?:
Przeciążanie operatorów powinno być używane w sposób spójny z semantyką danego operatora. Działanie przeciążonego operatora powinno być zgodne z intuicją programisty - dodawanie powinno zostać dodawaniem. Nie można też wprowadzić własnych operatorów, oraz zmienić działania istniejących dla typów fundamentalnych.