Paradygmaty programowania/Wykład 5: Programowanie obiektowe — przegląd

From Studia Informatyczne

Spis treści

Wstęp

Idea programowania zorientowanego obiektowo swoimi korzeniami sięga języka Simula 67, zaprojektowanego przez Ole-Johana Dahla i Kristena Nygaarda jako język do zastosowań symulacyjnych. W pełni rozwinięta została w Smalltalku 80, zaprojektowanym jako język „czysto obiektowy”, o jednolitej, klarownej składni. Prawdziwa powszechność i sukces komercyjny przyszły natomiast wraz z językiem C++, a później Java.

Język obiektowy musi posiadać trzy podstawowe cechy:

  • Abstrakcyjne typy danych.
  • Dziedziczenie.
  • Dynamiczne wiązania wywołań metod z metodami (ściślej: z definicjami metod).

Przedstawimy teraz podstawowe definicje dotyczące programowania obiektowego.

Klasy, obiekty i podklasy

  • Abstrakcyjne typy danych w językach obiektowych zwykle nazywane są klasami.
  • Instancje klas są nazywane obiektami.
  • Klasa, która zdefiniowana została poprzez dziedziczenie z innej klasy, nazywana jest klasą pochodną bądź podklasą.
  • Klasa, z której wywiedziono podklasę, nazywana jest klasą bazową lub nadklasą.
  • Podprogramy, które definiują operacje na obiektach klasy, to metody.
  • Odwołania do metod czasem nazywa się komunikatami.
  • Zbiór wszystkich metod danego obiektu nazywany jest protokołem komunikatów tegoż obiektu.

Sterowanie dostępem

  • W najprostszym przypadku klasa dziedziczy wszystkie byty swojej nadklasy.
  • Sterowanie dostępem pozwala programiście na ukrycie części typu abstrakcyjnego przed klientem poprzez zadeklarowanie pewnych bytów jako publiczne (public), a innych jako prywatne (private).
  • Trzeciej kategorii sterowania dostępem, chroniony (protected), używa się, aby umożliwić dostęp do bytów klasom pochodnym, a jednocześnie zabronić dostępu do nich klientom.
  • Klasa pochodna może modyfikować dziedziczone metody. Zmodyfikowana metoda ma tę samą nazwę; często także ten sam protokół. Mówi się wówczas o przedefiniowaniu odziedziczonej metody.

Dziedziczenie pojedyncze i wielokrotne

  • Jeżeli nowa klasa jest podklasą tylko jednej klasy bazowej, to taki proces derywacji nazywany jest dziedziczeniem pojedynczym.
  • Jeżeli nowa klasa ma więcej niż jedną nadklasę, to proces nazywamy dziedziczeniem wielokrotnym.

Metody i klasy abstrakcyjne

  • Zdarza się, że klasa jest na tyle wysoko w hierarchii dziedziczenia, że nie ma sensu tworzyć dla niej instancji.
  • Podobnie, nie ma sensu implementować metody, która jest zbyt wysoko w hierarchii (jako że została już zaimplementowana w podklasach).
  • Taka metoda nazywana jest metodą abstrakcyjną.
  • Klasa, która zawiera przynajmniej jedną metodę abstrakcyjną, nazywana jest klasą abstrakcyjną.

Metody i zmienne obiektu i klasy

  • Klasy mogą mieć metody i zmienne instancyjne lub metody i zmienne klasowe.
  • W tym pierwszym przypadku każdy obiekt z danej klasy ma swój własny zbiór instancji zmiennych, w których przechowywany jest stan obiektu.
  • W drugim przypadku, metody i zmienne przynależą do całej klasy i w klasie jest tylko jedna ich kopia. Mówi się o metodach/zmiennych klasowych lub statycznych.
  • Metody instancyjne są zazwyczaj przechowywane w jednym egzemplarzu (tak jak statyczne), ale wywołać je można tylko za pomocą instancji obiektu.
  • Wywołanie metody statycznej nie wymaga powoływania do życia instancji obiektu.

Polimorfizm i dynamiczne wiązania

  • Zmienne typu klasy bazowej mogą odwoływać się także do obiektów dowolnej podklasy tejże klasy bazowej.
  • Nadklasa może definiować metody, które są przedefiniowywane przez jej podklasy.
  • Kiedy taka przedefiniowana metoda jest wywoływana przez zmienną klasy bazowej, to wywołanie to jest dynamicznie wiązane z właściwą metodą.
  • Jest to rodzaj polimorfizmu: dynamiczne wiązanie wywołań z definicjami metod.

Problemy implementacyjne

Jak zwykle, mamy wiele kwestii do rozstrzygnięcia...

Czy wszystko jest obiektem?

  • W czystym modelu obliczeń zorientowanych obiektowo wszystkie typy są klasami.
  • Nie ma rozróżnienia pomiędzy klasami predefiniowanymi w języku a klasami definiowanymi przez użytkownika.
  • Wszystkie obliczenia realizowane są poprzez przekazywanie komunikatów, czyli wywołania metod.
  • Jest to rozwiązanie eleganckie, ale nawet proste operacje muszą być wówczas wykonywane przy użyciu mechanizmu przekazywania komunikatów.
  • Jedną z alternatyw jest zachowanie imperatywnego modelu typów i dodanie modelu obiektowego, tak jak np. w C++. To bywa kłopotliwe.
  • Inny pomysł to imperatywna struktura typów dla prostych typów skalarnych, a obiektowa dla pozostałych (np. w Javie). To również bywa mylące...

Czy podklasy są podtypami?

  • Innymi słowy, czy można powiedzieć, że obiekt z podklasy jest też obiektem jej klasy bazowej?
  • Przykład: Mamy klasę zwierzę i dwie podklasy z niej wywiedzione: pies i kot. Czy zatem każdy obiekt typu pies jest również obiektem typu zwierzę? A co z kotem...?
  • Owa relacja „bycia obiektem nadklasy” gwarantowałaby, że wszędzie tam, gdzie może pojawić się obiekt z klasy bazowej, może się również pojawić (bez wystąpienia błędu typu) obiekt z podklasy.
  • Wywiedziona klasa może być nazwana podtypem, jeżeli jej obiekty są w powyższej relacji z klasą bazową.
  • Podklasa ma w tej sytuacji ograniczone pole manewru: może jedynie dodawać zmienne i metody oraz przedefiniowywać dziedziczone metody w sposób zapewniający zgodność typów.
  • Relacje bycia podtypem i dziedziczenia nie są tożsame (do tego jeszcze wrócimy).

Sprawdzanie zgodności typów i polimorfizm

  • Polimorfizm rozumiemy tu jako użycie polimorficznego wskaźnika (referencji) do wywołania metody, która została przedefiniowana w jednej z klas pochodnych.
  • Mówi się także, że metoda została nadpisana.
  • Owa polimorficzna zmienna (wskaźnik lub referencja) jest typu klasy bazowej. W klasie tej znajduje się przynajmniej deklaracja protokołu wołanej metody, przedefiniowanej następnie w klasie pochodnej.
  • Zmienna polimorficzna może wskazywać na obiekty zarówno klasy bazowej, jak i pochodnej. Stąd konkretnego typu wskazywanego obiektu nie da się określić statycznie.
  • Wiązanie wywołań realizowanych za pomocą zmiennych polimorficznych z metodami musi się zatem odbywać dynamicznie.
  • Kiedy następuje sprawdzenie zgodności typów dla takiego wywołania?
  • W języku silnie typowanym sprawdzenie powinno być statyczne. W konsekwencji trzeba narzucić istotne ograniczenia, np. przedefiniowana metoda musi zachować taki sam protokół.
  • Alternatywą jest dynamiczne sprawdzanie zgodności typów, czyli wykonywane dopiero w chwili, gdy następuje wywołanie.

Dziedziczenie pojedyncze i wielokrotne

  • Czy język pozwala na dziedziczenie wielokrotne?
  • Jeśli tak, to jak rozstrzygamy odwołania do bytów (o takiej samej nazwie) mających definicje w dwóch klasach bazowych?
  • Rozstrzyganie takich wieloznaczności może być jawne, jak np. w C++ za pomocą operatora zakresu ::.
  • Jeśli ta sama zmienna jest dziedziczona z klasy położonej o dwa poziomy wyżej w hierarchii dziedziczenia za pośrednictwem dwóch klas bazowych, to czy dziedziczone jest jedno wcielenie, czy dwa?


Przykład problemu z „dziedziczeniem na rozdrożu”

  • Załóżmy, że klasa A zawiera pole x:
     class A {
       ...
       int x;
       ...
     };
  • Dwie klasy, B1 i B2, są podklasami klasy A, dziedzicząc pole x:
     class B1 : A {...};
     class B2 : A {...};
  • Kolejna klasa, powiedzmy C, jest podklasą dziedziczącą po B1 i B2:
     class C : B1, B2 {...};
  • Ile pól x jest zatem w klasie C?

Alokacja i dealokacja obiektów

  • Załóżmy, że:
    • Klasa B jest podklasą i podtypem klasy A.
    • Zmienna xb jest typu B.
    • Zmienna xa jest typu A.
  • Podstawienie „xa := xb” jest zatem prawidłowe.
  • Zauważmy, że obiekt xb jest większy niż obiekt xa (gdyż może mieć dodatkowe pola).
  • Co zrobić, jeśli obiekt xa jest na stosie i niemożliwe jest powiększenie zaalokowanego dla niego obszaru pamięci?
  • Drugie pytanie, związane z pamięcią: Czy obiekty alokowane na stercie są dealokowane jawnie czy niejawnie?
  • Zauważmy, że dla obiektów alokowanych na stosie ta kwestia nie jest istotna: zachowują się one tak samo jak zwykłe zmienne lokalne, alokowane przy wejściu do podprogramu i dealokowane przy wyjściu.

Wiązanie statyczne i dynamiczne

  • Czy wiązanie wywołań z metodami zawsze jest dynamiczne?
  • Innymi słowy — czy tam, gdzie to możliwe, kompilator wiąże wywołania z metodami statycznie?
  • Być może programista powinien mieć możliwość wskazania, o jakie wiązanie chodzi.

Przegląd języków obiektowych

Smalltalk

Smalltalk to pierwszy język, który w pełni odpowiadał paradygmatowi programowania obiektowego. Program składa się tu wyłącznie z obiektów; nawet stałe są obiektami. Wszystkie obiekty są alokowane na stercie, a odwołania do obiektów są poprzez zmienne referencyjne. Nie ma jawnej dealokacji.

Dziedziczenie

  • Tylko dziedziczenie pojedyncze, z typowymi zasadami.
  • Metoda zasłonięta przez metodę z podklasy może być wywoływana przez umieszczenie przed wywołaniem słowa kluczowego super. Wyszukiwanie metody rozpoczyna się wtedy nie lokalnie, lecz w nadklasie.
  • Podklasy mają dostęp do wszystkich składników klasy bazowej (nie można niczego ukryć).
  • Z powyższego wynika, że podklasy są podtypami (skoro nie można niczego ukryć, to obiekt z podklasy zawiera wszystkie składniki potrzebne do tego, by być dobrym obiektem klasy bazowej).

Wiązanie dynamiczne

  • Stosowane jest zawsze dynamiczne wiązanie wywołań z metodami (co zarazem oznacza tu dynamiczne sprawdzanie zgodności typów — zob. niżej).
  • W chwili wywołania szuka się żądanej metody w klasie wołanego obiektu.
  • Jeśli tam jej nie ma, przeszukuje się nadklasę tej klasy, następnie nadklasę nadklasy itd.
  • Poszukiwanie może ewentualnie dotrzeć aż do systemowej klasy Object, będącej przodkiem dla wszystkich klas.
  • Jeśli nie uda się znaleźć żądanej metody w tym ciągu, jest to błąd wykonania.
  • Powyższy mechanizm wyszukiwania metod jest zarazem jedynym mechanizmem sprawdzania zgodności typów. Zmienne typów nie mają.
  • Innymi słowy, jedyny możliwy „błąd typu” to brak metody dla danego wywołania.
  • Mamy tu do czynienia z prawdziwym polimorfizmem dynamicznym. Kod jest w całości uniwersalny.

Zalety

  • Prosta, regularna składnia.
  • Piękna, czysta idea o dużej sile wyrazu: wszystko osiąga się tu za pomocą wywołujących się obiektów.
  • Duże możliwości rozbudowy języka, także w zakresie struktur sterujących (w podstawowym Smalltalku poza wywołaniami nie ma właściwie struktur sterujących).

Wady

  • Niska efektywność (bo niemal wszystko robione jest dynamicznie...).
  • Trudne wykrywanie błędów.

C++

C++ to wciąż chyba najpopularniejszy język obiektowy... O klasach w C++ już mówiliśmy. C++ jest językiem hybrydowym, wykorzystującym paradygmat imperatywny i obiektowy. Obiekty mogą być alokowane na stosie lub na stercie. Dla obiektów na stercie mamy jawną dealokację za pomocą delete. Każda klasa ma co najmniej jeden konstruktor i może mieć destruktor (wywoływane niejawnie w chwili alokacji i dealokacji obiektu).

Dziedziczenie

  • C++ pozwala na dziedziczenie wielokrotne.
  • Klasa może być „samoistna”, tzn. może nie mieć nadklasy.
  • Klasy mogą mieć składniki prywatne, niewidoczne dla podklas.
  • Podklasy nie są zatem podtypami.
  • Składniki prywatne dostępne są tylko dla własnych funkcji klasy i dla klas zaprzyjaźnionych (zadeklarowanych z użyciem friends).
  • Klasa pochodna może zmienić tryb dostępu do odziedziczonych składników.
  • Definicja klasy pochodnej może być opatrzona kwalifikatorami private, protected lub public (podobnie jak poszczególne składniki). W ten sposób ustalamy domyślny tryb dostępu do odziedziczonych składników.
  • Metoda przedefiniowująca inną metodę musi mieć taki sam protokół. W przeciwnym razie zostanie uznana za nową metodę (czyli nie będzie mogła być wywołana przez zmienną polimorficzną z nadklasy).

Dynamiczne wiązanie wywołań z metodami

  • Obiekty zadeklarowane za pomocą zmiennych niewskaźnikowych są alokowane na stosie (jak zwykłe zmienne lokalne); wiązanie jest wówczas zawsze statyczne.
  • Zmienna wskaźnikowa mająca typ pewnej klasy bazowej może wskazywać obiekty tejże klasy oraz klas pochodnych — a zatem jest polimorficzna.
  • Zmienne niewskaźnikowe nie mogą być polimorficzne.
  • Gdy używamy zmiennej polimorficznej do wywołania metody zdefiniowanej w jednej z klas pochodnych, wywołanie to musi zostać związane z właściwą definicją metody.
  • Metody, które mają być wiązane dynamicznie, deklaruje się ze słowem kluczowym virtual.
  • Ściślej, virtual oznacza, że dana metoda może być zredefiniowana w klasach pochodnych, a zatem jej wywołanie należy traktować jako polimorficzne.
  • Jak widać, programista decyduje o tym, czy stosować wiązanie statyczne, czy dynamiczne. Wiązanie statyczne jest oczywiście szybsze.
  • Metodę abstrakcyjną deklaruje się za pomocą virtual i dodatkowego pseudopodstawienia „=0”, np.
     virtual void wydrukuj()=0;
  • Klasa zawierająca metodę abstrakcyjną jest abstrakcyjna, a więc nie można tworzyć obiektów z tej klasy. Można natomiast oczywiście deklarować wskaźniki do niej, pozwalające na realizację wywołań polimorficznych.

Wady i zalety

  • Zaleta: Wysoka efektywność kodu, biorąca się m.in. ze statycznego w większości wiązania wywołań i statycznego sprawdzania typów.
  • Wada: Złożoność języka, dwoistość systemu typów.
  • Zaleta i wada: Bardzo szczegółowe sterowanie dostępem przy dziedziczeniu.

Java

Pierwotne typy skalarne (np. int, float) nie są w Javie obiektami; pozostałe typy — tak. To powoduje, że częstokroć potrzebne jest „opakowywanie” prostych zmiennych w obiekty-pojemniki (niejawne i czasami ryzykowne). Wszystkie klasy pochodzą od klasy Object; klasy samoistne nie są dozwolone. Wszystkie obiekty są alokowane dynamicznie na stercie. Dealokacja jest niejawna. Jeśli przed dealokacją potrzebne są prace porządkowe, można zdefiniować metodę finalize, która zostanie wywołana przed dealokacją obiektu. Uwaga: dealokacja (a zatem i wywołanie metody finalize) może, ale nie musi, zostać wykonana. Skromny pod względem użycia pamięci program może nigdy nie doprowadzić do zbierania śmieci; wówczas cała pamięć zostanie po prostu zwrócona systemowi operacyjnemu po zakończeniu programu. Metody finalize nie należy zatem mylić z destruktorem. Jej rolą jest raczej obsługa nietypowych sytuacji, np. gdy pobraliśmy pamięć poza plecami Javy.

Dziedziczenie

  • Dopuszczalne jest tylko dziedziczenie pojedyncze. Ale...
  • Za pomocą słowa kluczowego interface deklaruje się klasy abstrakcyjne, po których można dziedziczyć do woli.
  • Innymi słowy, oprócz jednej klasy bazowej, każda klasa może mieć dowolną liczbę „interfejsów bazowych”.
  • Klasy interface mogą zawierać jedynie deklaracje metod (tzn. same nagłówki) i stałych — są zatem abstrakcyjne od początku do końca.
  • Ten mechanizm ma pewne cechy dziedziczenia wielokrotnego, bez typowych dla niego problemów.
  • Można definiować również metody abstrakcyjne i klasy abstrakcyjne; używa się do tego słowa kluczowego abstract.
  • Uwaga: klasę abstrakcyjną „od początku do końca”, czyli zawierającą same metody abstrakcyjne, należy deklarować jako interface.

Dynamiczne wiązanie wywołań z metodami

  • Wywołania są wiązane z metodami dynamicznie, chyba że metoda jest zadeklarowana ze słowem kluczowym final, static lub private.
  • Każda z tych deklaracji oznacza, że metoda nie może być przedefiniowana.
  • Wiązanie dynamiczne nie miałoby więc sensu.
  • Modyfikator final jawnie wskazuje na „ostateczność” metody.
  • Metody statyczne nie mogą być przedefiniowane (a co najwyżej zasłonięte przez metodę statyczną, co jednak wiadome jest już w czasie kompilacji i nie wymaga wiązania dynamicznego).

Wady i zalety

  • Zaleta: Język bardziej jednorodny (bardziej konsekwentnie obiektowy) niż C++.
  • Zaleta: Prostsze sterowanie dostępem.
  • Wada: Wciąż dwoistość typów, rodząca niefortunne konwersje.

C#

Język ten cechuje duże podobieństwo do Javy. Oprócz zwykłych klas (class) dostępne są też „klasy lekkie”, deklarowane są pomocą struct. Klasy lekkie nie pozwalają na dziedziczenie, dlatego mogą być bezproblemowo alokowane na stosie (nie ma problemu z rozmiarem podklas).

Dziedziczenie

  • Składnia jak w C++.
  • Dodatkowe sterowanie przez słowa kluczowe new i base: new ukrywa metodę o takiej samej nazwie z klasy bazowej, base ją „odkrywa”.
  • Wszystkie klasy pochodzą od klasy Object.

Dynamiczne wiązanie wywołań z metodami

  • Metody, które mają być wiązane dynamicznie, muszą być jawnie oznaczone jako virtual (w klasie bazowej) i override (w klasach pochodnych).
  • Klasy abstrakcyjne definiuje się za pomocą słowa kluczowego: abstract.

Ada

Ada stała się językiem istotnie obiektowym poczynając od wersji z roku 1995, choć od początku miała pewne cechy programowania obiektowego (np. pakiety rodzajowe). Ważną innowacją były typy znaczone, pozwalające na dynamiczny polimorfizm. Każdy obiekt tego rodzaju zawiera niejawny znacznik przechowujący informację o konkretnym typie wartości przechowywanej w obiekcie. Można pisać konstruktory i destruktory; trzeba je jawnie wywoływać.

Dziedziczenie

  • Klasy pochodne tworzy się z typów znaczonych.
  • Do odziedziczonych elementów można dołożyć nowe.
  • Nie ma możliwości, by odziedziczyć tylko część elementów.
  • Innymi słowy, podklasy mogą tylko rozszerzać klasę bazową — są więc podtypami.
  • Dziedziczenie wielokrotnie rozwiązane jest podobnie jak w Javie. Zasadniczo dozwolone jest tylko dziedziczenie pojedyncze, natomiast interfejsy mogą być dziedziczone wielokrotnie.
  • Interfejsy są nową cechą Ady, wprowadzaną w wersji z roku 2006.

Dynamiczne wiązanie wywołań z metodami

  • Wiązanie dynamiczne uzyskuje się stosując typ ogólnoklasowy, reprezentujący ogół typów w hierarchii klas zakotwiczonej w pewnym typie.
  • Typ ogólnoklasowy jest tworzony automatycznie przez kompilator. Dla typu znaczonego T, stanowiącego początek (pod)drzewa klas, typ ogólnoklasowy oznaczany jest przez T’class.
  • Zmienna typu ogólnoklasowego zachowuje się jak zmienna polimorficzna, a więc wywołania realizowane za jej pośrednictwem są wiązane dynamicznie.
  • Polimorficzne mogą być nie tylko zmienne wskaźnikowe.
  • Abstrakcyjne typy bazowe deklaruje się za pomocą abstract.

Wady i zalety

  • Wada: Skromne sterowanie dostępem.
  • Wada: Brak niejawnych, automatycznych konstruktorów i destruktorów.
  • Zaleta: Decyzję o dynamicznym bądź statycznym wiązaniu wywołania podejmuje się w samym wywołaniu. Nie wymaga to zmian w klasie bazowej.
  • Zaleta: Wiązanie dynamiczne nie jest ograniczone do wskaźników do obiektów. Można używać samych obiektów.

JavaScript

JavaScript zaprojektowany został pierwotnie jako język skryptowy dla serwerów webowych. Ma właściwie niewiele wspólnego z Javą. Ma prosty, dynamiczny system typów; nie ma klas. Zmienne mogą być wartościami lub referencjami. Każdy obiekt to lista par (własność, wartość) — podobnie jak hash w Perlu. Pusty obiekt (bez własności) tworzy się wywołując new. Można też tworzyć obiekt i inicjować go za pomocą konstruktora, np. x = new f(...). Nie ma konstrukcji enkapsulacyjnych.

Implementacja konstrukcji obiektowych

Dwa zasadnicze problemy:

  • Alokacja pamięci dla zmiennych instancyjnych.
  • Dynamiczne wiązanie wywołań z metodami.

Przechowywanie danych instancyjnych

  • Zmienne należące do instancji obiektu są przechowywane w rekordzie instancyjnym.
  • Jako że jego struktura jest statyczna, jest tworzona w czasie kompilacji.
  • W czasie wykonania struktura ta używana jest jako wzorzec do tworzenia danych w instancjach.
  • Podklasy rozszerzają strukturę rekordu instancyjnego.

Dynamiczne wiązanie wywołań z metodami

  • Metody wiązane statycznie nie wymagają obsługi w rekordzie instancyjnym.
  • Metody wiązane dynamicznie, które mogą być wywoływane z danej klasy, są zapisane w tablicy metod wirtualnych (określanej zwykle mianem vtable).
  • Rekord instancyjny każdego obiektu zawiera wskaźnik do vtable.
  • Podstawienia pod zmienną polimorficzną muszą dodatkowo ustawiać odpowiednio ów wskaźnik.