ABC obiektów w TP

ŁF

ABC obiektów w TP

Obiekt. Z czym to się je? Mówiąc krótko: zbiór procedur i funkcji (czyli metod), za pomocą których obsługuje się dane, które obiekt zawiera. A nieco dłużej... Wyobraź sobie moduł (unit) z procedurami, funkcjami i danymi. Procedury i funkcje są powiązane tematycznie, tzn. wszystkie służą np.: do obsługi ekranu albo do obrabiania łańcucha znaków (stringa). Gdzieś w głębi biblioteki znajdują się zmienne, w któych są trzymane niezbędne dane (zawartość stringa czy też bufor na ekran). Ty nie wiesz dokładnie, gdzie są TE dane, więcej: nie obchodzi Cię to. Teoretycznie nie masz do nich bezpośredniego dostępu, możesz je odczytywać i modyfikować tylko za pośrednictwem metod - funkcji i procedur - którymi Twoja biblioteka dysponuje. I to jest meritum obiektów: całkowite oddzielenie danych od reszty kodu; dostęp do nich jest możliwy tylko w obrębie modułu lub obiektu. Tylko metody obiektu działają na jego danych (i tylko jego danych).
Jeszcze jedna ważna rzecz: tą hipotetyczną bibliotekę możesz powielać w pamięci, tzn. możesz zwiększyć ilość danych (np.: obsługiwać dwa, trzy, cztery (...) stringi), które nadal będą obsługiwane tymi samymi metodami. Wynika z tego jeden wniosek: obiekty łatwo się modyfikuje, często wielokrotnie łatwiej, niż "zwykłe" procedury - czyli te nie powiązane z danymi.

Dobra. Jeśli się przegryzłeś przez powyższy akapit, to znaczy że jednak potrzebujesz się dowiedzieć o co w tym wszystkim chodzi, więc trochę konkretów.
Wyobraź sobie jakiś rekord, na przykład:

type
  TDane = record
    a : string;
    b : integer;
  end;
i do tego procedurki pracujące na tym rekordzie:
procedure SetA(const s : string;var dane : TDane);
begin
  dane.a := s
end;

procedure SetB(const i : integer;var dane : TDane);
begin
  dane.b := i
end;

function GetA(const dane : TDane) : string;
begin
  GetA := dane.a
end;

function GetB(const dane : TDane) : integer;
begin
  GetB := dane.b
end;
Oczywiście to jest mocno przejaskrawiony przykład, no bo niby po co wielka procedura, żeby raptem zmienić zawartość pola rekordu? Przecież można to zrobić na piechotę - dane.a := '' i już...
Częściowo tłumaczy to wprowadzona zasada dotycząca oddzielenia danych od reszty programu - ale nie dziwię się, że masz wątpliwości co do jej słuszności. Postaram się je - nieco przekornie - rozwiać, ale to za moment.
Teraz wyczarujemy z procedur i rekordu obiekt.
type
  TDane = object
    a : string;
    b : integer;

    procedure SetA(const s : string);
    procedure SetB(const i : integer);
    function GetA : string;
    function GetB : integer;
  end;

procedure TDane.SetA(const s : string);
begin
  a := s
end;

procedure TDane.SetB(const i : integer);
begin
  b := i
end;

function TDane.GetA : string;
begin
  GetA := a
end;

function TDane.GetB : integer;
begin
  GetB := b
end;
Jak widzisz znacznie upraszcza się korzystanie z 'wewnętrznych' danych obiektu - są one traktowane wewnątrz procedur i funkcji będących metodami obiektu jak zmienne globalne. Ponadto - metody oddzielają warstwę danych (a przynajmniej mogą) od kodu programu spoza obiektu. Dane są (teoretycznie) dostępne tylko z wewnątrz obiektu (nie możesz siędo nich odwołać z żadnego innego miejsca).
Teraz załóżmy, że robisz nową wersję programu i raptem długość stringa przestaje Ci wystarczać - albo też chcesz przy wpisywaniu danych zmieniać stronę kodową liter. Gdybyś zmieniał zawartość pola "a" na piechotę (tzn. używał dane.a := ...), z pominięciem pól obiektu, to byś miał trochę roboty - zależnie od tego, w ilu miejscach to robiłeś. A tak masz gotową jedną procedurę z obiektu, zmieniasz w niej parę linijek, możesz nawet zmienić typ "a" na jakiś inny, ot choćby na array[1..1234] of char - modyfikujesz tylko obiekt (konkretnie: metodę obiektu), a nie odwołania do niego.
Stąd zasadność przywiązywania danych do procedur i funkcji (tzn. operacji na danych za pośrednictwem wyspecjalizowanych procedur i funkcji). Hm... to po co obiekty, skoro powyższa zasada o nich nic nie mówi? Przecież nie trzeba tworzyć obiektu tylko po to, żeby dane obsługiwała jedna procedura/funkcja! No tak, ale przecież jak się wtedy zabezpieczysz przed bezpośrednim dostępem do danych, z pominięciem Twoich metod? Owszem, przecież to Twój kod i pamiętasz, która funkcja co robi i do których danych się nie dotykać - ale kiedy na Twoim kodzie będzie pracować ktoś inny (albo Ty za kilka miesięcy), to tego wiedzieć nie będzie. Chyba, że zrobisz wszystko w oddzielnym module. Ale spróbuj zdublować moduł w pamięci - nie da się! A obiekty się da...
Oj, pokrętne są moje tłumaczenia, ale mam nadzieję, że mimo to zrozumiałeś o co mi chodzi i jaka jest przewaga obiektów nad innymi sposobami ochrony dostępu do danych (i po co w ogóle potrzeba ochrony danych już na poziomie kodu).

Więc teraz dziedziczenie: sedno obiektów. Załóżmy, że chcesz mieć dwie wersje obiektu TDane - jedną obsługującą dodatkowo liczby rzeczywiste, a drugą dodatkowo longinty. Czy musisz pisać całość na nowo? Nie! Wykorzystasz dziedziczenie.

type
  TDane1 = object(TDane)
    x : real;
    procedure SetX(const r : real);
    function GetX : real;
  end;

  TDane2 = object(TDane)
    x : longint;
    procedure SetX(const r : real); {!!!}
    function GetX : longint;
  end;

procedure TDane1.SetX; {nie musisz powtarzać nagłówka z deklaracją parametrów}
begin
  x := r
end;

function TDane1.GetX;
begin
  GetX := x
end;

procedure TDane2.SetX;
begin
  x := round(r)
end;

function TDane2.GetX;
begin
  GetX := x
end;
Oto dysponujesz dwoma obiektami, które możesz używać zamiennie - w zależności, czy chcesz przechowywać wartość x jako rzeczywistą czy jako całkowitą. Jako właściclel kodu źródłowego możesz w ten sposób robić dowolne przekręty, powodując mnóstwo zmian - np.: zmieniając tylko jedną literkę w nazwie obiektu, co spowoduje zamianę używanego obiektu, wszystkich metod i sposobu przechowywania danych. Ważne jest tylko, żeby odpowiednie procedury miałe te same nazwy i te same (bądź analogiczne) parametry.
Ale to tylko efekt uboczny. Jak widzisz, oba obiekty dysponują tymi samymi metodami (GetA, GetB, SetA, SetB), co obiekt TDane. Zostały one odziedziczone. Ponadto dołożyliśmy do nich dodatkowe metody. One też mogą zostać odziedziczone przez kolejny obiekt, który będzie potomnym obiektem dla TDane1 albo TDane2 (i przy okazji potomnym dla TDane).
W ten sposób możesz zbudować program klocek po klocku - na początku definiując obiekt, w którym są zawarte najważniejsze, najbardziej podstawowe i ogólne procedury, a potem budując potomne obiekty zawierające więcej szczegółów. Dzięki temu program jest zbudowany bardzo logicznie i przejrzyście, nie ma potrzeby dublowania kodu, a przede wszystkim łatwo jest modyfikować i usprawniać program, bo każda czynność jest zdefiniowana tylko w jednym miejscu. Oczywiście wszystko kosztem czegoś: programy obiektowe są bardzo pamięciożerne i mają nie najlepszą wydajność (wystarczy porównać Linuksa, który jest cały napisany w strukturalnym C, z Windowsem napisanym w obiektowym C++ - Linux bez wysiłku chodzi z 8MB RAMu na procesorze 486... ale za to Windows jest dużo łatwiejszy, intuicyjny i bardziej rozbudowany).

Tak więc mamy obiekt nadrzędny i obiekt potomny. Obiekt potomny dziedziczy wszystkie metody (i zmienne) obiektu nadrzędnego, przy czym niektóre może "nadpisać", zastępując je własnymi, działającymi inaczej. No właśnie: czy i jak wywołać metodę, która została zastąpiona własną? Umożliwia to słówko inherited. Wywołanie metody, która została zastąpiona, wygląda tak:

{...}
procedure TDane1.SetA(const s : string);
begin
  inherited SetA(s);
{ a := s }
end;
{...}
O dziwo procedurę inherited SetA możesz wywoływać również z dowolnej innej metody tego (TDane1) obiektu - ale z tego się właściwie nie korzysta, gdyż zaciemnia to tylko kod, a tak naprawdę do niczego nie przydaje.

Posuwając się dalej znajdujemy jeszcze jedną rzecz, która praktycznie nie do pomyślenia jest bez użycia obiektów (no, chyba, że się nieźle rzeźbi w kodzie). Otóż wyobraź sobie wywoływanie metody z Twojego obiektu - przynajmniej z wklepanego kodu wynika, że wywołana została TA procedura z TEGO obiektu; ale to co widzisz przy debugowaniu programu, to jakaś inna procedura - z obiektu potomnego. Co, że to jest bez sensu? To zobacz: masz np.: obiekt uniwersalny, definiujący interfejs do pisania po pliku: metody assign, reset, close, seek, read, write itp. - a obiekty potomne "podmieniają" te metody na własne, dzięki czemu może się okazać, że jeden obiekt rzeczywiście obsługuje pliki, a drugi pamięć EMS czy też ekran. Ale wszystkie metody (czyli ten interfejs) masz zdefiniowane w jednym module (chodzi mi o nagłówki typu procedure x(y : z);, nie o sam kod).
Mając coś takiego, możesz napisać jakąś procedurę, która za parametr przyjmuje nazwę tego podstawowego obiektu, tymczasem w jej wywołaniach podawać jako parametr nazwy obiektów pochodnych; i to jest chyba głównym uzasadnieniem używania obiektów. W tym konkretnym przypadku możesz np. zapisać jakieś dane albo na dysku, albo w pamięci, albo na ekranie, a wszystko zależnie od tego, który obiekt podstawisz do wywołania tamtej procedury - ale jej kod pozostanie cały czas TAKI SAM.

Zasada jest taka: masz zmienną np. "d" typu TDane. Masz też zmienne "d1" i "d2", odpowiednio typów TDane1 i TDane2 (de facto masz zmienne określające trzy obiekty). Czyli coś takiego:

type
  TDane = object
    a : string;
    b : integer;

    procedure SetA(const s : string);
    procedure SetB(const i : integer);
    function GetA : string;
    function GetB : integer;
  end;

  TDane1 = object(TDane)
    x : real;
    procedure SetA(const s : string);
    procedure SetX(const r : real);
    function GetX : real;
  end;

  TDane2 = object(TDane)
    x : longint;
    procedure SetA(const s : string);
    procedure SetX(const r : real); {!!!}
    function GetX : longint;
  end;

procedure TDane.SetA(const s : string);
begin
  a := s
end;

procedure TDane.SetB(const i : integer);
begin
  b := i
end;

function TDane.GetA : string;
begin
  GetA := a
end;

function TDane.GetB : integer;
begin
  GetB := b
end;

procedure TDane1.SetX; {nie musisz powtarzać nagłówka z deklaracją parametrów}
begin
  x := r
end;

function TDane1.GetX;
begin
  GetX := x
end;

procedure TDane2.SetX;
begin
  x := round(r)
end;

function TDane2.GetX;
begin
  GetX := x
end;

procedure TDane1.SetA(const s : string);
begin
  a := 'TDane1: '+s
end;

procedure TDane2.SetA(const s : string);
begin
  a := 'TDane1: '+s
end;

var
  d : tdane;
  d1: tdane1;
  d2: tdane2;
Teraz małe info: możesz przypisać d := d1 lub d := d2 - ale nie odwrotnie. Przypisując d := d1 kopiujesz wszystkie wspólne pola obiektu d1 do obiektu d. Odwrotnie to nie pójdzie (zresztą to jest chyba jedyny przypadek, kiedy możesz (na zmiennych!) zrobić a := b, ale nie b := a). Widzisz więc, że typ TDane1 jest "zgodny" z typem TDane (ale nie odwrotnie!) - bo obiekt TDane zawiera się w obiekcie TDane1 (TDane1 ma wszystkie metody obiektu TDane - bo je odziedziczył - plus jakieś własne).
Spójrz na metodę SetA: mamy ją w trzech wersjach (jedną w głównym obiekcie oraz dwie w dwóch obiektach potomnych); różne jej wersje różnie modyfikują zawartość danych wpisywanych do zmiennej a stąd wiadomo, która wersja je zmodyfikowała (bez uciekania się do pomocy debugera). Teraz można zabrać się do eksperymentowania.
procedure SetA(var dane : TDane);
begin
  dane.SetA('wywołanie z zewnętrznej procedury');
end;

begin
  SetA(d);
  SetA(d1);
end.
Kompiluje się, uruchamia, kończy, i ... I coś jest nie tak. Co takiego? Otóż dla obu obiektów została wywołana metoda SetA; my chcielibyśmy, żeby to wywołanie trafiało w dwie różne metody (bo zdefiniowaliśmy dwie różne metody SetA, jedną dla obiektu d, drugą dla d1) - intuicyjnie ~:-) można wyczuć, że tak właśnie powinno być! Tymczasem dla obu obiektów wykonuje się metoda obiektu TDane (d)... I tak dochodzimy do metod wirtualnych. One nam naprawią tą niedoróbkę.
Najpierw przepiszmy definicję obiektów na nowo (reszta kodu bez zmian):
type
  TDane = object
    a : string;
    b : integer;

    constructor Init;

    procedure SetA(const s : string); virtual;
    procedure SetB(const i : integer);
    function GetA : string;
    function GetB : integer;
  end;

  TDane1 = object(TDane)
    x : real;
    procedure SetA(const s : string); virtual;
    procedure SetX(const r : real);
    function GetX : real;
  end;

  TDane2 = object(TDane)
    x : longint;
    procedure SetA(const s : string); virtual;
    procedure SetX(const r : real);
    function GetX : longint;
  end;
{... tu reszta kodu}

procedure SetA(var dane : TDane);
begin
  dane.SetA('wywołanie z zewnętrznej procedury');
end;

begin
  d.Init;
  d1.Init;

  SetA(d);
  SetA(d1);
end.
Gdy teraz wykonamy "eksperyment" z akapitu wyżej, okaże się, że całość działa tak, jak byśmy chcieli. Chociaż procedura SetA traktuje otrzymany parametr jako obiekt TDane, wywoływana jest metoda z obiektu TDane1 (oczywiście jeśli to on został podany jako parametr...).
Jak widzisz, przy definicji metody SetA pojawia się magiczne słówko virtual. To ono zapewnia takie właśnie działanie "nadpisanych" metod - czyli powoduje, że wywoływana jest metoda nie obiektu formalnie podanego w parametrach funkcji (tutaj TDane), tylko metoda obiektu faktycznie tam zamieszczonego (czyli TDane1). To rodzi wielkie możliwości, pozwalając na pisanie uniwersalnych metod obsługujących różne rodzaje obiektów (np.: możesz zrobić obiekt z abstrakcyjnym interfejsem do obsługi plików - abstrakcyjny znaczy tutaj nie obsługującym niczego konkretnego - po czym nadpisać jego wszystkie bądź część metod w potomnych obiektach implementujących faktyczne pisanie po plikach, albo po pamięci, po ekranie, po czymkolwiek! chociażby nawet po karcie dźwiękowej; potem możesz korzystać z obiektów potomnych nie zajmując się za bardzo tym, który akurat konkretnie obsługujesz). Jest to niesamowita oszczędność kodu (obiekty potomne mogą mieć wspólny kod części metod), a co za tym idzie jego większa przejrzystość (przynajmniej teoretycznie) i łatwość we wprowadzaniu poprawek i ulepszeń.
Słowo virtual jest dziedziczone, tzn. jeśli chcesz nadpisać metodę wirtualną, to musisz ją zadeklarować w obiekcie potomnym jako virtual. Natomiast jeśli zastępujesz zwykłą metodę, to możesz (ale nie musisz) nową metodę uczynić wirtualną.

Jak zapewne zauważyłeś oprócz słowa virtual pojawia się też nowy typ metody: constructor. Otóż metody wirtualne wymagają zastosowania konstruktora, czyli zdefiniowania jakiejś procedury, która będzie specjalnie naznaczona, żeby kompilator wiedział, że to konstruktor - poza tym słowo constructor można traktować na równi z procedure. Taki konstruktor MUSI być wywołany przed pierwszym odwołaniem się do jakiejkolwiek wirtualnej metody obiektu, czyli najlepiej gdzieś na początku kodu.
Konstruktor zajmuje się przeed wszystkim utworzeniem VMT - Virtual Method Table (Tablica Metod Wirtualnych obiektu), dzięki której możliwy jest taki bajer, jak ten opisany powyżej.
W parze ze słowem constructor idzie słowo destructor, i służy do zniszczenia tablicy VMT; w praktyce czasem można pominąć tą metodę, aczkolwiek dobrym nawykiem jest jej używać.

Poza specjalnymi właściwościami opisanymi wyżej konstruktor i destruktor to zupełnie zwyczajne metody, które mogą być dziedziczone i mogą wykonywać Twój kod, np.: konstruktor może inicjalizować wszystkie zmienne i alokować pamięć, a destruktor może tą pamięć zwalniać. Dodatkowo konstruktor i destruktor nie mogą być deklarowane ze słowem virtual (co i tak nie utrudnia właściwie niczego).

Zwróć uwagę na kod tej procedury, a dokładniej deklarację parametru dane:

procedure SetA(<u>var</u> dane : TDane);
begin
  dane.SetA('wywołanie z zewnętrznej procedury');
end;
W TP jest błąd!. Jeśli zamiast SetA(var dane : TDane); będzie SetA(dane : TDane); tzn. zabraknie tam słowa var, to tp zrobi sobie kopię podanego parametru (no to chyba wiadomo), ale zrobi to błędnie, bo nie kopiuje obiektu rzeczywiście podanego, tylko obiekt zadeklarowany (czyli wewnątrz metody SetA będziemy dysponować nie metodami obiektu faktycznie podanego, tylko tego formalnego, czyli tutaj TDane). Dlatego bardzo uważaj, jak deklarujesz parametry, które są obiektami (dodawaj słowo var). BTW - FreePascal jest pozbawiony powyższego błędu.

Tyle wcześniej było o ochronie danych, której jak do tej pory nie zapewniliśmy (do danych można było dostać się "ręcznie", bez pośrednictwa metod obiektu). Dzieje się tak dlatego, że domyślnie wszystkie metody i dane w obiekcie są publiczne - tzn. są widoczne na zewnątrz obiektu. Ale nie zawsze jest to nam na rękę, więc używa się słów private i public, które regulują pokazywanie danych i metod na zewnątrz obiektu.
Popatrz na poniższy przykład:

type
  TDane = object
  private
    a : string;
    b : integer;
  public
    constructor Init;

    procedure SetA(const s : string); virtual;
    procedure SetB(const i : integer);
    function GetA : string;
    function GetB : integer;
  end;
Wszystkie metody są publiczne, natomiast dane są ukryte ("prywatne"). W praktyce ochrona danych jest kiepsko dopracowana i zabezpiecza je tylko na zewnątrz modułu - czyli wewnątrz modułu, w którym został zadeklarowany obiekt, wszystkie jego pola są publiczne. W zasadzie taki zakres ochrony powinien wystarczać, bo jeśli masz dostęp do kodu źródłowego, to i tak możesz zrobić z danymi to, co Ci się podoba - a jedynym zabezpieczeniem przed tym jest rozprowadzanie kodu w postaci skompilowanej biblioteki (albo użycie parsera zamazującego kod).

To tyle. Mam nadzieję, że lektura tego artykułu rozjaśniła Ci chociaż trochę w głowie...

8 komentarzy

co prawda musiałem ten artykuł przeczytać 3 razy, ale jak go w końcu zrozumiałem to moja radość nie miała końca :)

Enter się zepsuł?

to jest dział TP a nie Delphi - ŁF

a dlaczego nie? przeciesz delphi pochodzi od pascala (po lamersku teraz powiedzialem :( , ale...). jak na oko tych kodów źródłowych jest mała rożnica. Jeśli nawet... to bedzie przynajmniej odrózniony text kodów.


z forum -> Naprawione niby

ŁF, ale TP nie ma obiektowego! Jest dla niego obiektowy odpowiednik C++ o nazwie OP (ObjectPascal) czyli... Delphi!

czyzuw, ja też nie za bardzo ale chyba dam radę...

nic nie rozumiem

mi się podoba. Co tu więcej mówić?:D

dawno nie pisałem w Turbo Pascalu więc ten artykuł <ort></ort> jest idealny dla zapominalskich :)

Przypominam o tagach delphi do kolorowania kodu :)
Pozdrawiam

// to jest dział TP a nie Delphi - ŁF