Wstęp do programowania obiektowego, wykład 7
Klasy i funkcje abstrakcyjne Przeciążanie funkcji
Definiowanie i interpretacja złożonych typów danych w C++
Wskaźniki do funkcji
KLASA ABSTRAKCYJNA
Klasa abstrakcyjna
Klasa abstrakcyjna – klasa w której co najmniej jedna metoda nie jest zdefiniowana, a tylko zadeklarowana.
Takie metody powinny być wirtualne - w dziedziczących klasach muszą być dostarczone
konkretne implementacje tych metod, aby można było tworzyć ich obiekty.
Obiektów samej klasy abstrakcyjnej tworzyć nie można, bo klasa nie jest całkowicie zdefiniowana.
Klasa abstrakcyjna służy zwykle jako definicja
interfejsu, czyli zbioru metod jakie występują w klasach dziedziczących.
Metoda czysto wirtualna (abstrakcyjna)
Metoda, która nie jest zdefiniowana, a tylko zadeklarowana to tzw. metoda czysto wirtualna (inaczej metoda
abstrakcyjna).
W C++ deklaruje się taką metodę, pisząc po nawiasie kończącym listę argumentów „=0”, np. :
virtual double liczPole() = 0;
Wystarczy jedna deklaracja metody abstrakcyjnej, aby cała klasa była abstrakcyjna (nawet jeżeli posiada zdefiniowane inne metody).
W C++ można metodę abstrakcyjną zdefiniować (zdefinować jej ciało), ale klasa tak czy inaczej będzie abstrakcyjna. Do takiej definicji odwołujemy się przez specyfikator zakresu „::”. Jest to niepolecane.
Klasa konkretna / polimorfizm
Przeciwieństwem klasy abstrakcyjnej jest klasa konkretna – taka, której wszystkie metody
zostały zaimplementowane i można tworzyć jej obiekty.
Do obiektów klas pochodnych można się
odnosić poprzez wskaźniki i referencje o typie statycznym klasy bazowej (w szczególności
również abstrakcyjnej) – jest to jeden z
warunków polimorfizmu.
Przykład: figury (UML)
Na diagramach UML klasy i metody abstrakcyjne oznaczamy kursywą.
Figura
#int kolor +Figura(int kk) +double liczPole() +double liczObwod()
Kwadrat +double bok
+Kwadrat(int kk, double bb) +double liczPole()
+double liczObwod()
Kolo +double promien
+Kolo(int kk, double pp) +double liczPole() +double liczObwod()
Trojkat +double a
+double b +double c
+Trojkat(int kk, double aa, double bb, double cc) +double liczPole()
+double liczObwod()
Przykład: figury (kod 1/2)
class Figura { protected:
int kolor;
public:
Figura(int kk):kolor(kk){}
virtual double liczPole() = 0;
virtual double liczObwod() = 0;
};
class Kwadrat : public Figura { double bok;
public:
Kwadrat(int kk, double bb) : Figura(kk), bok(bb) { } double liczPole() { return bok*bok; }
double liczObwod() { return 4*bok; } };
class Koło :public Figura {
Przykład: figury (kod 2/2)
int main(void) {
Figura* tab[] = { new Kolo(1.5), new Kwadrat(1.9),
new Kolo(2.25), new Trojkat(3, 2.4, 5.2) };
for (int i = 0; i <= 4; i++) { if (tab[i] != NULL){
cout << "Pole figury numer "<< i << "to" << tab[i]->
liczPole() << endl;
cout << „Obwód figury numer "<< i << "to" << tab[i]->
liczPole() << endl;
} }
Klasa Figura jest tu abstrakcyjna, bo zawiera metody czysto wirtualne; trudno byłoby rozsądnie
zaimplementować metody takie jak liczPole czy liczObwód dla nieznanych figur geometrycznych.
Dopiero dla konkretnych figur, jak kwadrat czy koło, można to policzyć.
Dopiero w klasach dziedziczących z klasy Figura,
definiujemy implementacje metod wirtualnych. Każda klasa konkretna musi mieć zaimplementowane wszystkie odziedziczone metody (uprzednio) abstrakcyjne.
Różne figury przechowujemy za pomocą wskaźników o typie statycznym Figura, a różnych typach
dynamicznych. Wywołania metod są polimorficzne (wirtualne).
PRZECIĄŻANIE
FUNKCJI
W C++ możliwe jest zadeklarowanie dwóch lub więcej funkcji o tej samej nazwie. Nazywa się toprzeciążeniem.
Zdefiniowanie metod o tej samej nazwie i sygnaturze w dwóch klasach powiązanych dziedziczeniem to jest tzw.przesłonięcie, a nie przeciążenie.
Kompilator określa, do której funkcji odnosi się każde wywołanie na podstawie typów parametrów formalnych i aktualnych z ewentualnymi konwersjamiKryteria rozstrzygania najlepiej pasującej funkcji przeciążonej
Typ wartości zwracanej przez funkcję nie jest uwzględniany.
Poszukiwanie najlepiej pasującej funkcji odbywa się według kryteriów przedstawionych poniżej w podanej kolejności:Dokładne dopasowanie (brak konwersji lub konwersje trywialne, np. tablicy do wskaźnika)
Dopasowanie z użyciem promocji (bool do int, char do int, float do double etc.)
Dopasowanie z użyciem konwersji standardowych (int do double, double do int etc.)
Dopasowanie z użyciem konwersji zdefiniowanych przez użytkownika
Dopasowanie z użyciem wielokropka (...) - zmienna ilość argumentów funkcji
DEFINIOWANIE I INTERPRETACJA
ZŁOŻONYCH TYPÓW
DANYCH W C++
Przez typy „złożone” rozumiemy takie, które są złożeniami typów
różnego rodzaju: na przykład tablica wskaźników, odnośnik do tablicy albo wskaźnik do wskaźnika do tablicy wskaźników itp.
Definiowanie typów pochodnych może czasem prowadzić do
skomplikowanych wyrażeń. Czym na przykład są x, y, z, f po następujących deklaracjach:
int tab[] = {1,2,3};
int (&x)[3] = tab;
int *y[3] = {tab,tab,tab};
int *(&z)[3] = y;
int &(*f)(int*,int&);
Linia 1 określa oczywiście, że tab jest typu 'trzyelementowa tablica
zmiennych typu int', co w wyrażeniach w sposób naturalny konwertowane jest do typu int*. Pozostałe deklaracje mogą sprawiać kłopoty.
Ogólne zasady interpretacji złożonych typów
1. zaczynamy od nazwy deklarowanej zmiennej,
2. patrzymy w prawo: jeśli jest tam nawias otwierający okrągły '(', to będzie to funkcja (odczytujemy liczbę i typ parametrów); jeśli będzie tam nawias otwierający kwadratowy '[', to będzie to tablica (odczytujemy rozmiar),
3. jeśli po prawej stronie nic nie ma lub jest nawias okrągły zamykający ')', to przechodzimy na lewo i czytamy następne elementy kolejno od prawej do lewej aż do końca lub do napotkania nawiasu otwierającego,
4. jeśli napotkaliśmy nawias okrągły otwierający, to wychodzimy z całego tego nawiasu i kontynuujemy znów od jego prawej strony,
5. gwiazdkę (*) czytamy „jest wskaźnikiem do”,
6. ampersand (&) czytamy „jest odniesieniem do”,
7. po odczytaniu liczby i typu parametrów funkcji dalszy ciąg określa typ zwracany tej funkcji,
8. po odczytaniu wymiaru tablicy dalszy ciąg określa typ elementów tablicy.
Przykład 1
int (&x)[3] = tab;
x jest:
na prawo nawias zamykający, więc patrzymy na lewo:
ODNOŚNIKIEM DO,
patrzymy dalej w lewo, napotykamy nawias otwierający;
wychodzimy zatem z nawiasu, patrzymy na prawo: jest nawias otwierający kwadratowy, więc: TABLICY
TRZYELEMENTOWEJ,
przechodzimy na lewo: ZMIENNYCH TYPU int.
Ponieważ x jest odnośnikiem (referencją), musieliśmy od razu dokonać inicjalizacji - widać, że jest ona prawidłowa, bo tab właśnie jest trzyelementową tablicą int-ów.
Przykład 2
int *y[3] = {tab,tab,tab};
y jest:
na prawo nawias kwadratowy otwierający, więc:
TRZYELEMENTOWĄ TABLICĄ,
patrzymy w lewo i czytamy do końca w lewo, bo nie ma już żadnych nawiasów: WSKAŹNIKÓW DO
ZMIENNYCH TYPU int.
Tu nie musieliśmy od razu dokonywać inicjalizacji, ale ta której dokonaliśmy jest prawidłowa, bo tab
standardowo jest konwertowana do typu int*.
Wszystkie elementy tablicy y wskazują na ten sam adres, a mianowicie na pierwszy element tablicy tab.
Przykład 3
int *(&z)[3] = y;
z jest:
1. na prawo nawias okrągły zamykający, więc patrzymy na lewo: ODNOŚNIKIEM DO,
2. na lewo nawias okrągły otwierający, więc
wychodzimy z całego nawiasu i przechodzimy na prawo: TRZYELEMENTOWEJ TABLICY,
3. patrzymy w lewo i czytamy do końca w lewo, bo nie ma już żadnych nawiasów: WSKAŹNIKÓW DO
ZMIENNYCH TYPU int.
Tu znów musieliśmy od razu dokonać inicjalizacji - do jej wykonania użyliśmy tablicy y z poprzedniego przykładu.
WSKAŹNIKI DO
FUNKCJI
Funkcje a pamięć
Wartość zmiennej zapisana jest gdzieś w pamięci komputera, a zatem ma określony adres, który można przechowywać we
wskaźnikach.
Podobnie funkcja, w postaci binarnego kodu, jest zapisana gdzieś w pamięci komputera.
Posiada więc też adres.
Stąd wynika pojęcie wskaźnika do funkcji
(inaczej wskaźnika funkcyjnego) – jest to
wskaźnik wskazujący na adres kodu funkcji.
Wskaźniki do funkcji a arytmetyka wskaźników
Wskaźniki do zmiennych są typowane: określając typ zmiennej wskazywanej dla zwykłego wskaźnika określamy jednocześnie rozmiar (i format zapisu) pojedynczego
wskazywanego przez ten wskaźnik obiektu.
Takie obiekty możemy układać w pamięci kolejno jeden po drugim tworząc tablice.
Jeśli znamy adres pierwszego z nich, to znając rozmiar
pojedynczego obiektu znamy położenie drugiego, trzeciego i, ogólnie, n-tego.
W przypadku funkcji jest inaczej. Każda funkcja może być inna i jej binarny kod może mieć inny rozmiar. Stąd:
wskaźniki do funkcji nie obsługują arytmetyki wskaźników.
Typ wskaźnika
Informacja o typie wskaźnika do zmiennej jest potrzebna:
aby prawidłowo interpretować binarną reprezentację danej (4B int-a to co innego niż 4B float-a),
aby kontrolować poprawność operacji na niej (tzw.
sprawdzanie zgodności typów – wykonywane przez kompilator).
W przypadku funkcji kompilator sprawdza czy ich wywołanie jest zgodne ze specyfikacją wyrażoną w definicji (po uwzględnieniu konwersji).
W przypadku wskaźników funkcyjnych trzeba określać nie tylko adres funkcji, ale też typy i liczbę parametrów oraz typ zwracanej wartości.
Przykład wskaźnika do funkcji
int (*pfun)(int);
Czytamy zgodnie z zasadami o złożonych typach danych:
ZMIENNA pfun JEST - na prawo nawias zamykający, więc patrzymy w lewo - WSKAŹNIKIEM DO -
wychodzimy z nawiasu, patrzymy w prawo - FUNKCJI Z JEDNYM PARAMETREM TYPU int
ZWRACAJĄCEJ - patrzymy znów w lewo - WARTOŚĆ TYPU int.
Wniosek: zmienna pfun jest zmienną wskaźnikową przystosowaną do przechowywania adresów funkcji, które pobierają jeden argument typu int i zwracają wartość typu int.
Nawiasy wokół wskaźników do funkcji
Nie można było opuścić nawiasów wokół
*pfun. Po opuszczeniu otrzymalibyśmy:
int *pfun(int);
co przeczytalibyśmy tak: ZMIENNA fun JEST - na prawo nawias otwierający - FUNKCJĄ Z JEDNYM PARAMETREM TYPU int
ZWRACAJĄCĄ - patrzymy w lewo -
WSKAŹNIK DO - dalej w lewo - ZMIENNEJ
TYPU int. A zatem byłaby to deklaracja funkcji
pfun, a nie wskaźnika do funkcji.
Wartość początkowa wskaźnika
Po definicji
int *k;
zmienna wskaźnikowa k istnieje, ale na razie nie zawiera żadnego użytecznego adresu.
Podobnie: wskaźnik do funkcji fun już
istnieje, ale nie zawiera adresu żadnej
konkretnej funkcji.
Ustalanie wartości wskaźnika do funkcji
Podobnie jak do wskaźnika p typu int* wpisujemy adres istniejącej zmiennej k typu int pisząc
p = &k;
tak na zmienną pfun można przypisać
pfun = &fun;
gdzie fun jest nazwą pewnej funkcji o nazwie fun odpowiedniego typu.
Konwersja od fun do &fun jest konwersją trywialną, tak więc równie dobrze w przypisaniu możemy pominąć operator wyłuskania adresu i napisać po prostu:
pfun = fun;
Należy pamiętać, że nie piszemy nawiasów po nazwie funkcji - nawiasy pełnią rolę operatora wywołania funkcji, tak więc:
pfun = fun();
spowodowałoby wywołanie funkcji fun i próbę przypisania rezultatu do zmiennej pfun.
Przykładowy program
#include <iostream>
#include <cmath> // funkcje matematyczne using namespace std;
const double PI = 4*atan(1.);
double nasza(double);
int main(void) {
double (*f)(double); //deklaracja wskaźnika do funkcji
f = sin; //ustawienie wskaźnika na funkcję sin
cout << "sin(PI/2) = " << (*f)(PI/2) << endl; //wywołanie f = &cos; //ustawienie wskaźnika na funkcję cos cout << " cos(PI) = " << f(PI) << endl; //wywołanie
f = nasza; //ustawienie wskaźnika na funkcję nasza
cout << " nasza(3) = " << f(3) << endl; //wywołanie }
Wskaźnik funkcyjny może być parametrem funkcji
Deklaracja takiego parametru wygląda tak samo jak deklaracja wskaźnika do funkcji:
double fun( double (*f)(double), double a, double b) {
return f(a) + f(b);
}
Jest to definicja funkcji fun, której pierwszym parametrem jest funkcja pobierająca i zwracająca double, a dwoma następnymi są zwykłe parametry typu double.
W treści funkcji f jest wywoływana kolejno dla argumentu a i b.
Wywyłanie w programie na przykład tak (funkcja atan oznacza biblioteczną funkcję arcus tangens):
#include <cmath>
const double PI = 4*atan(1.);
// ...
double result = fun(sin, 0, PI/2);
Definiowanie własnych typów
Można uprościć skomplikowany zapis wprowadzając synonim (alias) skomplikowanego typu za pomocą instrukcji typedef.
W naszym przypadku moglibyśmy zdefiniować synonim następującego typu:
wskaźnik do funkcji z jednym parametrem typu double zwracającej double:
typedef double (*FUNDtoD)(double);
i użyć go potem w deklaracji naszej funkcji fun:
double fun( FUNDtoD, double, double);
lub w deklaracjach/definicjach funkcyjnych zmiennych wskaźnikowych
#include <cmath>
// ...
FUNDtoD f = sin;
// ...
f = atan;
Interpretacja złożonych typów:
przykład 4
double (*fun[3])(double);
Czytamy: ZMIENNA fun JEST:
1. na prawo nawias kwadratowy otwierający - TRZYELEMENTOWĄ TABLICĄ
2. przechodzimy na lewo - WSKAŹNIKÓW DO
3. wychodzimy z nawiasu, patrzymy w prawo -
FUNKCJI Z JEDNYM PARAMETREM TYPU double ZWRACAJĄCEJ
4. patrzymy znów w lewo - WARTOŚĆ TYPU double.
Wniosek: zmienna fun jest tablicą trzech wskaźników do funkcji.
Interpretacja złożonych typów:
przykład 5
int &(*f)(int*,int&);
f jest:
1. na prawo nawias okrągły zamykający, więc patrzymy na lewo: WSKAŹNIKIEM DO,
2. na lewo nawias okrągły otwierający, więc
wychodzimy z całego nawiasu i przechodzimy na prawo: FUNKCJI O DWÓCH PARAMETRACH, PIERWSZYM TYPU int*, DRUGIM
ODNOŚNIKOWYM TYPU int&,
3. patrzymy w lewo i czytamy do końca w lewo, bo nie ma już żadnych nawiasów: ZWRACAJĄCEJ
ODNIESIENIE DO int.