dr inż. Piotr Szwed

 

Język C/C++

 

Laboratorium Informatyki

Katedra Automatyki

C3, pok. 213

 

e-mail: pszwed@ia.agh.edu.pl

http://pszwed.ia.agh.edu.pl

 

                                 user cpp

                                     pass: cz1230

 

 

 

 

Literatura

·    Help

·      Kernighan, Ritchie, Język C

·      Bjarne Strostrup, Język C++

·      Bruce Eckel, Thinking in C++, http://www.bruceeckel.com

inne:

·      J. Grębosz, Symfonia C++

·      J. Grębosz, Pasja C++

 


 Programowanie obiektowe

·      Języki programowania pozwalają na modelowanie rzeczywistego problemu, który powinien być rozwiązany z użyciem oprogramowania.

·      Charakter języka określa jakość i rodzaj modelu. Wiele języków stanowi w rzeczywistości wygodny interfejs do architektury komputera, na którym będzie uruchamiany program (asembler, FORTRAN, C, BASIC, Pascal)

·      Niektóre języki usiłują modelować problem, który ma zostać rozwiązany sprowadzając go do założonego zbioru elementów  (np.: PROLOG rozwiązuje problemy przeszukując drzewa decyzyjne, LISP – listy).

·      Programowanie obiektowe dostarcza narzędzi do bezpośredniej reprezentacji elementów pojawiających się w rozwiązywanym problemie w postaci konstrukcji języka programowania.

 

Trzy etapy konstrukcji programu

1.   Analiza obiektowa – identyfikuje obiekty występujące w świecie rzeczywistym, który ma być modelowany przez program (np.: osoby, instytucje, urządzenia, dokumenty, itd.)

2.   Projektowanie obiektowe odwzorowuje te obiekty w elementy platformy oprogramowania (klasy, obiekty, rekordy bazy danych) a także dodaje elementy specyficzne dla danej platformy (okna, okna dialogowe, elementy interfejsu użytkownika).

3.   Programowanie obiektowe obejmuje implementację rezultatów projektowania za pomocą wybranego obiektowego języka programowania.

 

W rezultacie elementy występujące w rzeczywistym problemie mogą zostać bezpośrednio odwzorowane na elementy występujące w języku programowania. Konstruując program można „myśleć” w kategorii dziedziny problemu i elastycznie dostosowywać go do dziedziny.

 

 


Typowe założenia czystego podejścia obiektowego

Każdy element programu jest obiektem.

·      Dowolny element dziedziny może zostać zaimplementowany jako obiekt i włączony do programu.

·      Obiekt może być traktowany jak zmienna przechowująca dane. W idealnej postaci, dane te są ukryte.

·      Aby zmodyfikować obiekt lub odczytać jego dane wysyłamy do obiektu odpowiednie polecenie. W odpowiedzi obiekt wykonuje operacje na swojej zawartości.

Program jest siecią obiektów, które nawzajem przesyłają do siebie komunikaty.

·      Aby skierować polecenie do obiektu wysyłamy do niego polecenie za pomocą komunikatu. W języku C++ wysłanie komunikatu może utożsamiane z wywołaniem funkcji.

·      Implementacje obiektowe dostarczają także narzędzi do konstrukcji rozproszonych programów, gdzie poszczególne obiekty rezydują na różnych maszynach (CORBA, DCOM+). Wówczas komunikat jest rzeczywistym komunikatem sieciowym

Każdy obiekt ma własną pamięć, na którą składają się inne obiekty.

Aby stworzyć nowy rodzaj obiektu możemy zgrupować obiekty niższego typu (także zmienne proste). Pozwala to ukryć złożoność modelowanego problemu.

Każdy obiekt ma typ.

Każdy obiekt należy do pewnej klasy. Klasa jest tu synonimem typu. (Obiekty mogą równocześnie należeć do wielu klas.)

Wszystkie obiekty danego typu mogą przyjmować te same komunikaty.

·      Z danym typem można związać jego interfejs – zbiór komunikatów, które obiekt może przyjmować.

·      To założenie jest bardzo istotne w programowaniu obiektowym. Obiekt typu Circle jest również obiektem typu Shape , stąd do okręgu można wysyłać te same komunikaty, co do innych obiektów typu Shape. Dzięki temu można tworzyć kod, który w jednolity sposób będzie obsługiwał wszystkie obiekty, o interfejsie typu Shape.

Klasy

W trakcie wykonania programu tworzymy unikalne obiekty, różniące się miejscem w pamięci i stanem zmiennych. Każdy z nich należy do pewnej klasy, która określa wspólne własności i zachowanie.

 

Przykład:

·      Obiekt klasy Konto ma stan konta, historię transakcji

·      Poszczególne obiekty klasy Konto różnią się kwotą stanu konta i przechowywanymi w historii konta transakcjami

 

Obiektowe języki programowania pozwalają na:

·      definiowanie klas obiektów

·      tworzenie obiektów danej klasy (instancji klasy)

 

Definiowanie klas

Definicja klasy obiektów obejmuje: interfejs klasy (zbiór komunikatów, które obiekt może przyjmować) oraz implementację klasy, czyli opis, w jaki sposób obiekt danej klasy będzie reagował w odpowiedzi na wysyłane komunikaty.

       

 

class Ligth

{

double voltage;

public:

    void on(){voltage = 230;}

    void off(){voltage = 0;}

    void brighten(){if(voltage<=220) voltage+=10;}

    void dim(){if(voltage>=10) voltage-=10;}

};

 

 

 


Instancje klasy

Obiekty należące do danej klasy tworzone są podobnie jak zmienne innych typów.

Wysyłanie komunikatów realizowane jest poprzez wywołanie funkcji należącej do obiektu.

 

Light lt;

tworzy instancję klasy

lt.on();

lt.dim();

lt.off()

wysyła komunikat do obiektu lt.

 

Kontrola dostępu

Specyfikacja klasy obejmuje:

·      atrybuty (zmienne, pola składowe)

·      metody (funkcje składowe)

 

Dostęp do atrybutów w wielu językach (C++, Java ) jest realizowany bezpośrednio. Niektóre języki/platformy (SmallTalk, CORBA, DCOM) wymagają przesyłania komunikatów przy dostępie do atrybutów (metody typu set(), get() )

 

Dwie role użytkowników klasy

·      Twórca klasy projektuje interfejs klasy i go implementuje

·      Użytkownik końcowy klasy (klient) wykorzystuje gotową klasę w swojej aplikacji

 

Część atrybutów i metod ma charakter ogólnie dostępnego interfejsu, natomiast część pełni funkcję pomocniczą przy implementacji klasy.

 

Zazwyczaj twórca klasy stara się ukryć jej pomocnicze elementy:

·      aby uchronić się przed błędami powstałymi w skutek nieodpowiedniego dostępu.

·      tworząc bibliotekę klas ustalić i zawsze realizować pewien publiczny interfejs, natomiast dowolnie zmieniać część prywatną implementacji w kolejnych wersjach biblioteki

 

C++ (również Java) używa następujących słów kluczowych określających dostęp:

 

public

pola i metody składowe tego typu są ogólnie dostępne

private

pola i metody składowe tego typu nie są dostępne z zewnątrz

protected

pola i metody nie są dostępne z zewnątrz, natomiast są dostępne dla obiektów klas potomnych, dziedziczących po danej klasie.

 

Relacje pomiędzy klasami i obiektami

Konstruując obiektowy program zazwyczaj tworzy się pewną strukturę złożoną z wielu powiązanych obiektów różnych klas. Relacje (połączenia) obiektów zaliczają się zazwyczaj do jednego z poniższych typów:

·      agregacja (ang. aggregation)

·      asocjacja (ang. association)

·      dziedziczenie (ang. inheritance)

 

Ustalenie tych relacji jest jednym z zdań analizy i projektowania obiektowego. Najczęściej sprawdza się prawidłowość wyboru posługując się językiem naturalnym: stosując frazy „ma, zawiera”, „używa”  oraz „jest, jest rodzajem”.

 

Dodatkowo parametrem tych relacji jest krotność określająca liczbę elementów występujących w relacji.

 

·      Krotność n (n ³ 1) oznacza dokładnie n elementów

·      Krotność (n,*) oznacza dokładnie n lub więcej elementów

·      Krotność (m,n) oznacza od m do n elementów

 


Agregacja

Pomiędzy obiektami zachodzi relacja agregacji, jeżeli jeden z nich jest właścicielem drugiego.

 

B ma, zawiera A:

    Lista ma Elementy

    Faktura ma PozycjeFaktury

    Pojazd ma Silnik

 

       

 

Liczba instancji obiektu, które może zawierać obiekt B ustalona jest przez krotność relacji. Np.:

 

    SearchResultsPage ma 1–25 SearchLinks

 

Szczególnym typem agregacji jest kompozycja. W przypadku, kiedy liczba posiadanych obiektów jest stała mogą być one bezpośrednio użyte jako atrybuty klasy.

 

 

class Engine

{

...

};

 

 

class Car

{

    Engine engine;

};

 

·      Stosując kompozycję (agregację) wykorzystujemy gotowe klasy używając je jako sprawdzone elementy konstrukcyjne programów.

·      Atrybuty klasy podrzędnej są bardzo często deklarowane jako, prywatne stąd możliwa jest wymiana kodu danej klasy bez zaburzania kodu klienta, który ją wykorzystuje.

·      Stosując wskaźniki (lub referencje) możemy dynamicznie ustalać rodzaj i strukturę atrybutów.

 

Asocjacja

Obiekty pełniąc swoje funkcje komunikują się z innymi obiektami. Asocjacje reprezentują relacje pomiędzy obiektami związane z koniecznością przesyłania komunikatów.

·      W przypadku asocjacji obiekt wysyłający komunikat nie jest posiadaczem obiektu–adresata, ale użytkownikiem.

·      Frazą języka naturalnego służącą do sprawdzania asocjacji jest „używa, wykorzystuje”.

 

B używa A

    Kierowca używa Samochodu

    Raport drukuje się (używa) na Drukarce

 

      

 

·      W zasadzie asocjacja jest relacją symetryczną. Asocjacja symetryczna jest jednak trudna w implementacji i często zbędna. (Drukarka nie musi wiedzieć, że jest na niej drukowany Raport).

·      Często stosuje się asocjację niesymetryczną oznaczaną strzałką. Kierunek asocjacji może być także wyznaczony na podstawie podanych krotności.

 

class Driver

{

    Car*car;

public:

    void useCar(Car*_car){

       car = _car;

    }

};

class Car

{

};

 

Implementując asocjację w C++ należy posłużyć się wskaźnikami lub referencjami. W przypadku asocjacji ustalanej na czas wykonania metody wprowadzanie dodatkowych atrybutów nie jest konieczne:

       report.print(printer)


Dziedziczenie

Implementacja programów, które modelują rzeczywistość w postaci zbioru obiektów może być realizowana również za pomocą nieobiektowych języków programowania.

 

Typowym przykładem jest struktura FILE opisująca plik. Wywołania funkcji fread, fprintf, fwrite mogą być traktowane jako przesyłanie komunikatów do struktury FILE.Dlatego funkcje biblioteki klasy stdio mogą być łatwo przeniesione do wnętrza obiektu.

 

class File

{

    FILE*file;

public:

    bool open(const char*name,const char*mode)

    {

        file = fopen(name,mode);

        return file!=0;

    }

    bool close(){fclose(file);file=0;}

    size_t read( void *buffer, size_t size, size_t count);

    printf(const char*format,…);

    void put(int c){putc(c,file);}

};

 

Problemem tego typu implementacji jest niestety spłaszczenie struktury obiektów (na pliku tekstowym można wykonać operację fwrite, chociaż nie ma to sensu) oraz konieczność powtórzenia implementacji dla obiektów o podobnym zachowaniu (np.: funkcje sprintf drukujące do tablicy znakowej).

·      Dziedziczenie umożliwia na stworzenie nowej defincji klasy (tzw. klasy potomnej ) wykorzystującej istniejącą klasę (klasę bazową).

·      Interfejs klasy bazowej jest w pełni zachowany. Obiekt klasy potomnej może przyjmować te same komunikaty – a więc należy również do klasy bazowej.

·      Klasa potomna może dodawać nowe elementy do interfejsu, a także w odmienny sposób reagować na komunikaty zdefiniowane w interfejsie klasy bazowej.

 

Reprezentacja graficzna dziedziczenia

.

       

 

Często klasa bazowa nazywana jest generalizacją natomiast klasa potomna specjalizacją.

Decydując się na ustalenie, czy pomiędzy obiektami należy wprowadzić relację dziedziczenia posługujemy się frazą „jest, jest rodzajem

B jest (jest rodzajem) A

    Okrąg jest Kształtem

    Trójkąt jest Kształtem

    SamochódOsobowy jest Pojazdem

 

Przykład:

       

 

Klasa bazowa Shape określa pewien interfejs funkcjonalny. Interfejs ten jest dziedziczony przez klasy potomne.

·      Klasy potomne mogą dodać nowe elementy interfejsu

·      Klasy potomne mogą przedefiniować metody klasy bazowej.

 

Przykład – do klasy Triangle dodano nowe metody

       

 

 

class Shape{

public:

    virtual void draw();

    virtual void erase();

    Color getColor();

    void setColor(Color);

};

 

class Circle : public Shape

{

};

 

 

class Square : public Shape

{

};

 

class Triangle : public Shape

{

public:

    void flipVertical();

    void flipHorizontal();

 

};

 

Słowo kluczowe virtual informuje kompilator, że dana metoda klasy może zostać przedefiniowana w klasie potomnej.


Przykład – Metody draw i erase zostały przedefiniowane dla klas Circle, Square i Triangle.

 

       

 

 

Metody draw i erase zostały przedefiniowane dla klas Circle, Square i Triangle.

 

class Circle : public Shape

{

public:

    void draw();

    void erase();

};

class Square : public Shape

{

public:

    void draw();

    void erase();

};

 

class Triangle : public Shape

{

public:

    void draw();

    void erase();

 

};

 

 

Dziedziczenie wielokrotne (wielobazowe[ps1] )

Hierarchie obiektów są bardzo często rzutem opisu rzeczywistej dziedziny na konstrukcje języka programowania.

 

Niejednokrotnie analizując dziedzinę, możemy podać obiekty, które należą do różnych klas, a równocześnie żadna z klas nie może zostać uznana za generalizację lub specjalizacją drugiej.

 

Przykład

 

      

 

Charakterystyczną cechą klasy Driver jest posiadanie prawa jazdy (DrivingLicense). Charakterystyczną cechą posiadacza pojazdu CarOwner jest posiadania dowodu rejestracyjnego RegistrationCard.

Bardzo często występują obiekty, które należą do obu klas równocześnie, a więc jak DriverAndOwner powinny dziedziczyć po dwóch klasach bazowych.

 

Język C++ pozwala na dziedziczenie wielobazowe, języki SmallTalk, Java nie.

 

class Driver

{

};

 

class Owner

{

};

class DriverAndOwner:

    public Driver,

    public Owner

{

};

 

 

 

Polimorfizm

Konstruując hierarchie klas bardzo często odwołujemy się do obiektów należących do różnych klas potomnych za pośrednictwem interfejsu klasy bazowej.

 

Wysyłając do klasy potomnej Shape komunikat draw możemy zignorować informacje o rzeczywistym typie obiektu, licząc na to, że prawidłowo zostanie wywołana funkcja należąca do jednej z klas potomnych.

 

      

 

// pseudokod

Drawing::draw()

{

    foreach shape in shapes do shape.draw()

}

 

W powyższym przykładzie funkcja Shape::draw jest funkcją polimorficzną. Rzeczywiste zachowanie obiektu w odpowiedzi na komunikat jest uzależnione od tego, do którego obiektu został on wysłany.

 

Hierarchie klas mogą być elastycznie rozszerzane i skalowane. Dodanie nowego typu do hierarchii klas nie wymaga żadnych zmian w kodzie klas odwołujących się do obiektów za pośrednictwem interfejsu klasy bazowej.

 

W języku C klasa Shape mogą być zaimplementowane jako struktura zawierająca wspólne atrybuty, pole określające typ oraz unię struktur definiujących dane poszczególnych typów obiektów.

 

Zazwyczaj funkcja działająca na obiektach ma postać rozbudowanej instrukcji switch-case sterowanej typem elementu.

 


void draw(const Shape*shape)

{

    switch(shape->type)

    {

       case tCircle:

       // draw circle acessing shape®circle;

       break;

 

       case tSquare:

       // draw square acessing shape®square;

       break;

 

       case tTriangle:

       // draw triangle accessing shape®triangle;

       break;

    }

}

 

Dodanie nowego typu obiektu do tak zaimplementowanej hierarchii klas wymaga dodania nowego ciągu instrukcji case dla nowego typu elementu we wszystkich funkcjach zaimplementowanych w powyższy sposób.

 

Realizacja polimorfizmu przez kompilator różni się od standardowej implementacji funkcji niewirtualnych. Wybór funkcji, którą należy wykonać dla danego obiektu ustalany jest nie w momencie kompilacji, ale w trakcie wykonania.

 

W przypadku funkcji wirtualnych, kompilator dodaje do obiektu dodatkowe dane, dzięki którym możliwe jest automatyczne wyznaczenie funkcji, która powinna zostać wykonana dla danego obiektu.

 


Wykorzystując polimorfizm możemy implementować funkcje, które będą prawidłowo wykonywane niezależnie od typu obiektu, na którym działają:

 

void doSomethingWithShape(const Shape*shape)

{

    shape->draw ();

}

 

Circle circle;

doSomethingWithShape(&circle);

Square square;

doSomethingWithShape(&square);

 

Mechanizm pozwalający na traktowanie obiektu potomnego tak, jakby był obiektem klasy bazowej nazywany jest rzutowaniem w górę (ang. upcasting).

 

 

       

 

Polimorfizm i rzutowanie w górę może być zaimplementowane bezpośrednio w C. (Pierwsze kompilatory języka C++ były po prostu translatorami do języka C.) Wykorzystanie standardowej składni języka obiektowego jest jednak znacznie wygodniejsze, ponieważ zwalnia programistę od konieczności implementacji mechanizmów, które mogą być w jednolity sposób wygenerowane przez kompilator.

 


Referencje w C++

Referencje w języku C++ są nowym typem, który bardzo przypomina wskaźniki. Podobnie, jak w przypadku wskaźników, wartościami zmiennych typu referencyjnego są adresy, a także referencja przechowuje informacje o typie wskazywanego obiektu.

·      W odróżnieniu od wskaźników, przy korzystaniu z referencji nie stosuje się operatora dereferencji (*). Jest on wywoływany automatycznie.

·      Referencji nie można przestawiać, tak aby wskazywała inny obiekt.

 

Za pośrednictwem referencji możemy:

·      odczytać lub zmodyfikować wartość (atrybuty) obiektu zajmującego pamięć identyfikowaną przez adres

·      wywołać metodę obiektu.

 

Składnia deklaracji:

type-specifier & reference

 

type-specifier

       definiuje typ wskazywanego obiektu

 

reference

       identyfikator zmiennej

 

·      Referencje są najczęściej używane jako argumenty funkcji. Podobnie jak w przypadku wskaźników, obiekty przekazywane są przez adres.

·      Referencje mogą być używane jako wartości zwracane przez funkcje. Najczęściej są to funkcje składowe obiektu i zwracają referencję do obiektu, do którego należą.

class A

{

public:

    A&foo()

    {

    //...

    return *this;

    }

};

·      Referencje mogą być bezpośrednio używane jako zmienne:

int x=7;

int&r1 = x; // (1)

const int&r2 = 12; // (2)

r1++; // (3)

printf(”r1=%d, r2=%d”,r1,r2);

Instrukcja (1) deklaruje referencję inicjując ją adresem obiektu x. Instrukcja (2) alokuje pamięć dla zmiennej typu int, inicjuje ją wartością 12 oraz deklaruje referencję, która wskazuje to miejsce.

Wszelkie operacje na referencjach są w rzeczywistości operacjami na obiektach wskazywanych przez referencje. Instrukcja (3) zwiększy wartość zmiennej x.

·      Referencje mogą być bezpośrednio używane jako pola klas, ale wymagają inicjalizacji poprzez listę inicjalizacyjną konstruktora klasy:

class A

{

    int&r;

public:

    A(int a):r(a){}

};

Podstawową różnicą pomiędzy referencjami i wskaźnikami jest to, że wartością referencji musi być adres istniejącego obiektu. Wartość referencji jest ustalana w momencie inicjalizacji i jest to statycznie sprawdzane przez kompilator. Wartością referencji nie może być 0 (NULL).

    int &r; // błąd r nie wskazuje obiektu

Niestety, w wielu przypadkach kompilator można łatwo oszukać:

class RefInt {

public:

      RefInt(int *a):r(*a){}

      int&r;

};

 

RefInt q(0); // q.r ma błędną wartość

 

int*p=0;

int&r=*p; // r ma błędną wartość

Klasy w C++

Defincje klas

W języku C++ klasy można definiować używając słów kluczowych class, struct lub union.

Definicja klasy najczęściej obejmuje:

·      atrybuty (pola)

·      metody (funkcje składowe)

·      jeden lub kilka konstruktorów

·      jeden destruktor

 

Deklaracja klas z użyciem słów struct i union jest zapewniona dla zgodności z językiem C. Standardowo, dostęp do ich metod i pól jest publiczny. W przypadku użycia słowa kluczowego class, standardowo dostęp jest prywatny.

 

class File

{

    FILE*fp;

public:

    File(); // standardowy konstruktor

    File(const char*name, const char*mode); //

    ~File(); // destruktor

    int open(const char*name, const char*mode);

    int close();

    int get();

    int put(int);

};

 

void f(){

    File file; // obiekt typu File

    File file2(”plikwy.txt”,”wt”);

    File *pfile = new File(”plikwe.txt”,”rt”);

    pfile®get();

    delete pfile;

}

 


Składnia:

 

class identifier  [base-class-specifier]

{

    member-list

}  ;

 

Nazwy klas

Nazwa klasy staje się widoczna dla kompilatora bezpośrednio po przetworzeniu nagłówka klasy, stąd można deklarować wewnątrz klasy pola będące wskaźnikami i referencjami do danej klasy

 

class ListElement

{

public:

    int data;

ListElement*next;

};

 

 

Deklaracja klasy wprowadza nowy identyfikator do przestrzeni nazw. Deklaracje te są równocześnie definicjami typu w danej jednostce translacji (kompilowanym pliku źródłowym).

·      Pragnąc odwołać się do nazwy klasy jako argumentu funkcji (lub w prototypie funkcji) lub na liście pól składowych innej klasy musimy klasę tę zdefiniować wcześniej.

 

class A{...};

foo(const A&);


W przypadku, kiedy argumentami funkcji (polami klasy) są referencje lub wskaźniki możemy zadeklarować typ bez podania pełnej definicji. Typ ten musi jednak być widoczny w momencie implementacji.

 

class A; // nondefining declaration

foo(const A&);

 

class A{public:void dump()const;};

 

foo(const A&a)

{

    a.dump();

}

 

Definicje klas są zazwyczaj używane w większej liczbie jednostek translacji, stąd typową praktyką jest umieszczenie ich w plikach nagłówkowych (*.h). Implementacje metod klas umieszcza się w plikach *.cpp (odrębnych jednostkach translacji).

 

a.h

 

b.h

class A

{

public:

    A();

};

 

#include "a.h"

class B

{

public:

    B();

    A a;

};

 

Definicja klasy B wymaga znajomości definicji klasy A, stąd nagłówek a.h jest włączany do nagłówka b.h.

 

a.cpp

 

b.cpp

 

main.cpp

#include "a.h"

A::A(){}

 

 

#include "b.h"

B::B(){}

 

 

//#include "a.h"

#include "b.h"

int main()

{

    A a;

    B b;

    return 0;

}

 

 

W danej jednostce translacji może pojawić się dokładnie jedna definicja klasy. Problemem jest, śledzenie zależności pomiędzy plikami nagłówkowymi.

Najczęściej stosowanym zabezpieczeniem jest użycie dyrektyw warunkowej kompilacji:

 

a.h

#if !defined _a_h_

#define _a_h_

 

class A

{

public:

    A();

};

 

#endif // _a_h_

 

 

Przykład

 

#include "a.h" // (1)

#include "b.h" // (2)

void main()

{

    A a;

    B b;

}

 

1.   Klasa A zostanie zdefiniowana. Zdefiniowany zostanie symbol preprocesora _a_h_

2.   Włączony zostanie nagłówek b.h. Przetwarzanie b.h pociągnie włączenie a.h. Ponieważ _a_h_ istnieje w słowniku symboli preprocesora, powtórna definicja klasy A jest pomijana.

 

Elementy[p2]  składowe klas

Klasy mogą mieć następujące elementy składowe:

·      funkcje składowe (metody)

·      dane (atrybuty)

·      klasy zagnieżdżone (wewnętrzne)

·      wyliczenia (enum)

·      pola bitowe

·      deklaracje klas zaprzyjaźnionych (friend)

·      wewnętrzne deklaracje typów

Elementy klas mogą być zadeklarowane z użyciem modyfikatorów: const i static.

Funkcje składowe

Definicje klas mogą obejmować dane i funkcje działające na tych danych. Funkcje, które nie są zadeklarowane jako static, są traktowane jako funkcje należące do obiektu.

 

Przykład w C

 

typedef struct {double x,y;}StrComplex;

 

void init(StrComplex*pc, double _x, double _y)

{

    pc->x=_x ;pc->y=_y;

}

double module(const StrComplex*pc)

{

    return sqrt(pc->x*pc->x + pc->y*pc->y);

}

void dump(const StrComplex*pc)

{

    printf("[x=%g, y=%g,module=%g]”,

    pc->x,pc->y, module(pc)) ;

}

 

StrComplex c;

init(&c,2.4,3.76) ;

dump(&c) ;

 


Przykład w C++

class Complex

{

public:

    double x,y;

    Complex(double _x,double _y):x(_x),y(_y){}

    double module()const{return sqrt(x*x+y*y ) ;}

    void dump()const;

    void set(double _x,double _y){x=_x ;y=_y ;}

};

 

void Complex::dump()const

{

    printf("[x=%g, y=%g,module=%g]”,

    this->x, this->y, this->module()) ;

}

 

Complex c(2.4,3.76) ;

c.dump();

Complex *pc = &c ;

pc->dump() ;

Complex&rc = c ;

rc.set(2.0,3.0) ;

rc.dump() ;

 

 

1.   W metodach należących do obiektu możemy bezpośrednio odwoływać się do jego danych. (Domyślnie odwołujemy się do tego obiektu, którego metoda jest wołana – this.)

2.   W funkcjach składowych należących obiektu możemy wołać inne metody danego obiektu.

3.   Wołając metody spoza obiektu wskazujemy obiekt, do którego wysyłamy komunikaty podając nazwę obiektu, wskaźnik lub referencję.

4.   Funkcje, które nie modyfikują obiektu mogą być zadeklarowane jako const.

    void dump(const StrComplex*pc);

       void dump()const;

5.   Funkcje składowe mogą być implementowane wewnątrz definicji klasy lub poza nią – dump().

 


Wskaźnik this

Wewnątrz niestatycznych metod obiektu można posługiwać się niemodyfikowalnym (const) wskaźnikiem this do obiektu danej klasy. Jest on domyślnym ukrytym argumentem każdej niestatycznej funkcji składowej.

 

    CLASS * const this;

 

Wewnątrz metod zadeklarowanych jako const (nie mających prawa modyfikować zawartości obiektu ) wskaźnik this jest widoczny jako:

    const CLASS * const this;

 

Za pośrednictwem wskaźnika this można realizować dostęp do funkcji składowych i danych.

·      W niektórych przypadkach pomaga to rozwiązać niejednoznaczności.

·      Wskaźnika this używa się także często przy konieczności zwrócenia referencji do danego obiektu.

 

Complex& Complex ::set(double x,double y)

{

    this->x=x ;

    this->y=y ;

    return *this ;

}

 

·      Może on służyć do ustalania asocjacji pomiędzy obiektami.

class Owner

{

public:

    add (Child*child)

    {

       child->owner=this;

       list.add(child;)

    }

    ChildList list;

};

 

class Child

{

public:

    Owner*owner;

};

Funkcje [p3] inline

Funkcje inline, to funkcje, których wywołanie jest bezpośrednio zastępowane kodem funkcji. W przypadku bardzo krótkich funkcji ich użycie jest bardziej ekonomiczne, ponieważ znika dodatkowy narzut na wywołanie funkcji, powrót z wywołania oraz przesyłanie w obie strony danych poprzez stos. Wygenerowany kod może działać szybciej i być mniejszy.

 

class Int

{

    int value;

public:

    Int(int v):value(v){}

    int get()const{return value;} // inline

    void set(int v){value = v;} // inline

};

 

Funkcje zaimplementowane wewnątrz definicji klasy są tłumaczone jako funkcje inline. Alternatywnie, funkcje które są implementowane poza definicją klasy mogą być kompilowane jako funkcje inline po poprzedzeniu ich słowem kluczowym inline (traktowanym jako wskazówka dla kompilatora).

 

inline void Complex::dump()const

{

    printf("[x=%g, y=%g,module=%g]”,

    x, y, module(pc)) ;

}

 

Kompilator ignoruje słowo kluczowe inline w przypadku, kiedy funkcja jest funkcją rekurencyjną lub może być wywoływana za pośrednictwem wskaźnika.


Składowe typu static

W klasie można deklarować zarówno pola, jak i metody typu static. Traktuje się je jako elementy składowe klasy, a nie obiektu, stąd mogą być one dzielone przez wszystkie obiekty danej klasy, a także używane z zewnątrz.

 

Metody statyczne nie mają dostępu do wskaźnika this, ponieważ w ich przypadku brak jest obiektu, który mógłby wskazywać. Stąd, w metodach statycznych można używać wyłącznie danych zadeklarowanych jako statyczne.

 

class A

{

public:

    A(){instanceCounter ++;}

    ~ A(){instanceCounter --;}

    static int getInstaceCounter()

    {return instanceCounter;}

    void dump()const;

private:

    static int instanceCounter;

};

 

void A::dump()const

{

printf("A has %d instances", getInstaceCounter());

}

 

 

Metody statyczne mogą być wołane:

·      z metody niestatycznej (poprzez bezpośrednie użycie nazwy)

·      z zewnątrz za pośrednictwem obiektu

·      z zewnątrz poprzez podanie operatora zasięgu (scope)

printf("%d", A::getInstaceCounter());

Podobne reguły dotyczą zasad dostępu do statycznych atrybutów klasy.

Dostęp z zewnątrz do statycznych składowych sterowany jest standardowymi modyfikatorami praw dostępu (public, protected, private).

 

Pola statyczne klasy istnieją niezależnie od tego, czy istnieje jakikolwiek obiekt danej klasy. Z tego powodu pojawienie się statycznych pól w definicji klasy jest traktowane jak deklaracja extern , która wymaga odrębnej definicji, podczas której można także inicjować zmienne statyczne wartościami początkowymi.

 

int A::instanceCounter=0;

 

Klasy zagnieżdżone (wewnętrzne)

Klasy zagnieżdżone (ang. nested, inner class) są to klasy zadeklarowane wewnątrz innej klasy. Najczęściej stosuje się je jako pomocnicze struktury danych oraz tam gdzie nie chce się wprowadzać nowej nazwy do przestrzeni nazw kolidującej z istniejącymi nazwami.

Deklaracja klasy zagnieżdżonej jest jedynie deklaracją typu;

 

class A

{

public:

    A();

};

 

class B

{

public:

    B();

    class A

    {public:

       A();

    };

    A a;

}

 

A::A(){printf("A ");} // konstruktor A

B::B():a(){printf("B ");} // konstruktor B

// konstruktor wewnętrznej klasy

B::A::A(){printf("A in B ");}

 

 

·      Używając klasy zagnieżdżonej w metodach klasy zewnętrznej możemy posługiwać się bezpośrednio nazwą klasy.

·      Poza metodami klasy zewnętrznej musimy podawać pełną nazwę, postaci  Outer::Inner,
       B::A aInB;

·      Dostęp do definicji klas zagnieżdżonych jest sterowany standardowymi modyfikatorami dostępu. Z zewnątrz nie możemy uzyskać dostępu do definicji klasy , jeżeli dostęp do niej jest ograniczony jako private lub protected.

 

Typy wewnętrzne i wyliczenia

Wewnątrz klas można deklarować zagnieżdżone typy za pośrednictwem konstrukcji typedef. Zasady dostępu do zagnieżdżonej definicji typu są analogiczne jak w przypadku klas.

 

class Tree

{

public:

   typedef Tree * PTREE;

   PTREE Left;

   PTREE Right;

   void *vData;

};

 

PTREE pTree;  // Error: not in class scope.

Tree::PTREE pTree;  // Ok.

 

Podobne reguły dotyczą dostępu do symboli wyliczeniowych:

 

class HasState

{

    int state;

public:

    enum {good=0, bad, };

    HasState(){

    state = good;}

    int getState()const

       {return state;}

};

HasState hs;

int s = hs.getState();

if(s== HasState::good)     printf("good");

if(s== HasState::bad)     printf("bad");

 

\

 

 

Dziedziczenie [ps4] i kompozycja

Techniki programowania obiektowego zyskały bardzo dużą popularność ze względu na możliwość łatwego wykorzystania istniejącego kodu (ang. reuse). Biblioteki obiektowe definiują zazwyczaj zbiór użytecznych komponentów, które następnie bez żadnych zmian oryginalnego kodu można włączać do aplikacji.

Wykorzystanie istniejących komponentów może być realizowane dwiema drogami:

·      przez kompozycję (agregację) czyli użycie istniejącego obiektu jako podobiektu (atrybutu) nowej klasy

·      przez dziedziczenie, czyli stworzenie nowej klasy obiektów rozszerzających funkcjonalność istniejącej klasy.

Zazwyczaj projektant biblioteki przewiduje przyszłe wykorzystanie klas przez dziedziczenie lub kompozycję:

 

Przykład

Dialog

Klasa przygotowana do wykorzystania przez dziedziczenie. Zapewnia podstawowe usługi:

·      komunikację z obiektami składowymi,

·      reakcję na przyciski OK., Cancel,

·      reakcję na klawisze Enter i Esc.

InputLine,

Button

Klasy, które mogą być wykorzystane jako komponenty klasy MyDialog bez żadnych zmian. Równocześnie jednak można stworzyć własne klasy pochodne: PasswordInputLine, BitmapButton

 

Klasy abstrakcyjne (C++,Java). W niektórych przypadkach projektant przewiduje, że aby wykorzystać klasę, jej końcowy użytkownik musi przez dziedziczenie zdefiniować jej pewne funkcje.

 

Klasy finalne. Język Java zapewnia też konstrukcje zabraniające dziedziczenia, ponieważ poprzez dziedziczenie można uzyskiwać dostęp do chronionych metod i atrybutów.


Przykład kompozycji

 

class A

{

    int i; // A zawiera //int

public:

    A(int _i):i(_i){}

    void f(){     printf("A[i=%d]",i);
    }

};

class B

{

    A a; // B zawiera a

public:

    B():a(5){}

    void f(){a.f();}

};

 

void main()

{

    B b;

    b.f();

}

 

A(int _i):i(_i){} jest definicją konstruktora klasy A. Wartość początkowa atrybutu i jest ustalana poprzez listę inicjalizacyjną konstruktora. Ten sam kod może być przepisany jako A(int _i) {i=_i;}, ponieważ typ int ma standardowy konstruktor pusty nadający mu wartość nieokreśloną.

 

B():a(5){} jest definicją konstruktora klasy B. Obiekt składowy a musi być zainicjowany wartością, ponieważ brak jest standardowego konstruktora A::A().

 

void f(){a.f();} deleguje wykonanie metody do obiektu a.

 


Przykład dziedziczenia

 

class C : public A

{

    int j;

public:

    C():A(5),j(3){}

    int f(){

       printf("C[j=%d ",j);

       A::f();

       printf("]");

       return j;

    }

};

void main()

{

    int k;

    C c;

    k = c.f();

    c.A::f();

    A&ra = c;

    ra.f();

    C&rc = c;

    k = rc.f();

 

}

 

·      C():A(5),j(3){} jest definicją konstruktora klasy C. Zamiast inicjować obiekt składowy wybraną wartością jawnie wywołujemy konstruktor klasy bazowej z argumentem.

·      Funkcja C::f() przedefiniowuje (ang. redefines) funkcję A::f(). Implementacja funkcji w klasie bazowej jest dalej dostępna jako A::f(). Redefinicja zmienia typ funkcji z void na int.

·      ra jest referencją typu A&. Za jej pośrednictwem wywołamy funkcję A::f().Funkcja A::f() nie jest funkcją wirtualną.

·      ca jest referencją typu C&. Za jej pośrednictwem wywołamy funkcję C::f().

 

Składnia dziedziczenia

class derived : base-list {…}

base-list :

    base-specifier
    base-list , base-specifier

base-specifier :

    [virtual] [access-specifier] complete-class-name

access-specifier :

    private
    protected
    public

Przykłady

class X:public A {…} // jednobazowe

class Y:public A, public B {…} // wielobazowe

class Z: A, public B, protected C, virtual D {…}

 

Specyfikacja dostępu

Słowa kluczowe private, protected, public definiują prawa dostępu w klasie pochodnej do elementów klasy bazowej. Brak słowa kluczowego (w przypadku deklaracji klasy) jest równoważny zastosowaniu dostępu private.

 

Dostęp w klasie bazowej

Sposób dziedziczenia

Dostęp w klasie pochodnej

public
protected
private

public

public
protected
Brak dostępu

public
protected
private

protected

protected
protected
Brak dostępu

public
protected
private

private

Private
private
Brak dostępu

 

Przykład:

Po zmianie definicji klasy C na

 

    class C : private A {…}

 

za pośrednictwem klasy C nie mamy dostępu z zewnątrz do żadnego komponentu (pola, metody, definicji) klasy A.

 

A a(1);

a.f();

Poprawne

C c;

c.A::f();

A::f() niedostępne jako metoda obiektu klasy C

A&ra = c;

Konwersja typu jest potencjalnie możliwa, ale zabroniona przez tryb dziedziczenia

 


Dziedziczenie wielobazowe[p5] 

Dziedziczenie wielobazowe jest konstrukcją pozwalające na stworzenie klasy potomnej łączącej w sobie funkcjonalność kilku klas bazowych. W definicji klasy potomnej podaje się na liście klas bazowych kilka klas – potencjalnie stosując różne prawa dostępu.

 

Przykład

class A1

{

    int i;

public:

    A1(int _i):i(_i){}

    void f(){

    printf("A1[i=%d] ",i);

    }

};

 

class A2

{

public:

    A2(){}

    void f(){    printf("A2 ");

    }

};

class B:

    public A1,

    public A2

{

public:

    B():A2(),A1(7){}

    void f()

    {

       printf("B[ ");

       A1::f();

       A2::f();

       printf("]");

    }

};

void main()

{

    B b;

    b.f();

 

    b.A1::f();

 

    b.A2::f();

}

 

·      B():A2(),A1(7){} konstruktor klasy potomnej. Na liście inicjalizacyjnej wołane są konstruktory klasy A1 i A2. Wywołanie konstruktora A2() można pominąć.

·      Kolejność wołania konstruktorów jest zgodna z kolejnością deklaracji, a nie kolejnością na liście inicjalizacyjnej.

·      Metoda B::f() przedefiniowuje metody A1::f() oraz A2::f(). Są one jednak dalej dostępne przez podanie nazwy poprzedzonej operatorem zasięgu.

Dziedziczenie wielokrotne klasy bazowej

W przypadku rozbudowanych hierarchii klas, pewna klasa może zostać odziedziczona wielokrotnie.

 

 

class A

{

    int k;

public:

    A(int _k):k(_k){}

    void f(){     printf("A[k=%d]",k);

    }

};

 

class A1 : public A

{

    int i;

public:

    A1(int _i):A(1),i(_i){}

    void f(){

    printf("A1[i=%d] ",i);

    }

 

};

 

class A2 : public A

{

public:

    A2():A(2){}

    void f(){printf("A2 ");}

};

class B:

    public A1,

    public A2

{

public:

    B():A2(),A1(7){}

};

B b;

b.A1::f();

b.A2::f();

 

 

 

Klasa B zawiera w sobie dwie kopie obiektu A – za pośrednictwem klasy A1 oraz klasy A2. Rezultatem tego jest, że nazwa funkcji A::f() może być odziedziczona dwiema różnymi ścieżkami i nie identyfikuje jednoznacznie funkcji (A::f() w A1 lub A::f() w A2).

 


Przykłady

 

    b.A::f()

 

Kod tej postaci jest błędny ze względu na niejednoznaczność odwzorowania nazw (ang. ambiguity). W praktyce nie można bezpośrednio uzyskać dostępu do żadnej instancji klasy A osadzonej w B.

 

Jednym z możliwych rozwiązań jest wyłuskanie podobiektu poprzez rzutowanie w górę (upcasting):

 

    A2&ra = b;

    ra.A::f();

 

Podobnie, można posłużyć się wskaźnikami:

       ((A*)(A2*)&b)->f()

 

Dziedziczenie wirtualne

Definiując klasy A1 i A2 jako dziedziczące wirtualnie po klasie A można zapewnić użycie wspólnego obiektu klasy A w klasach dziedziczących równocześnie po A1 i A2.

 

class A1 : virtual public A

{...}

 

class A2 : virtual public A

{...}

 

class B:

    public A1,

    public A2

{

public:

    B():A(3),A1(7),A2(){}

};

 

·      Wywołanie b.A::f() jest poprawne, ponieważ klasa B zawiera dokładnie jeden obiekt klasy A.

·      Konstruktor klasy B musi jawnie zainicjować składowy obiekt klasy A. Inicjacje za pośrednictwem konstruktorów A1 oraz A2 są ignorowane.

Dziedziczenie wirtualne jest związane wyłącznie ze ścieżką dziedziczenia, a nie z klasą. Dana klasa może zostać równocześnie odziedziczona wirtualnie i niewirtualnie, stąd aby wykorzystać zalety dziedziczenia wirtualnego, należy je konsekwentnie stosować na wszystkich ścieżkach.

 

Przykład

 

class A3 : public A

{...}

 

class C:

    public A1,

    public A2

    public A3

{

public:

    C():A(3),A1(7),A2(),A3{}

};

 

 

W praktyce, dziedziczenie wirtualne stosuje się rzadko. Z reguły biblioteki budują hierarchie klas dziedziczących wyłącznie publicznie. Aby usunąć problemy konfliktu i niejednoznaczności nazw, należałoby zmodyfikować tryb dziedziczenia po klasach znajdujących się u podstawy hierarchii, a więc zmodyfikować kod bibliotek (czego zawsze unika się.)

 

Funkcje[p6]  wirtualne

Wskaźniki do funkcji

Po skompilowaniu każdej funkcji przydziela się pewien obszar w pamięci. Podczas wywołania funkcji – po przeprowadzeniu niezbędnych inicjalizacji – program dokonuje skoku do instrukcji mieszczącej się pod adresem początkowym bloku kodu.

Adres tego obszaru może zostać pobrany i wykorzystany do wywołania funkcji.


Typową praktyką przy projektowaniu bibliotek w C/C++ jest możliwość przekazania wskaźnika do funkcji, która, na przykład, będzie odpowiedzialna za: wyświetlanie pewnych informacji, porównywanie elementów, zapis i odczyt danych.

 

Dzięki takim rozwiązaniom biblioteka może zostać w łatwy sposób dostosowana do potrzeb końcowego użytkownika (wyświetlanie informacji w dialogu danego środowiska lub na konsoli, odczyt z pliku lub z pamięci, itd.)

 

Język C++ jest językiem zwracającym uwagę na zgodność typów. W przypadku wskaźników do funkcji typ określony jest przez typ zwracanej wartości i typy argumentów. Deklaracje wskaźników do funkcji jest kłopotliwa. Najlepiej posłużyć się prostym przepisem:

 

       jeżeli funkcja jest zadeklarowana jako

    return-type function(arg-list)

       wówczas

    return-type (*function-pointer)(arg-list)

 

deklaruje wskaźnik do funkcji zwracającej return-type i biorącej za argumenty arg-list.

 

W przypadku bardziej złożonych definicji najlepiej użyć deklaracji za pośrednictwem typedef:

    typedef return-type (*fp-type)(arg-list);

    fp-type function-pointer;

 

Przykład:

void foo(int a)

{

    printf(“%d”,a)

}

typedef void (*VOID_INT_FP)(int);

void main()

{

    VOID_INT_FP myptr= foo;

    myptr (7);

}

Przykład funkcji wirtualnych

 

W przedstawionych wcześniej przykładach dziedziczenia występowały funkcje niewirtualne. Jeżeli funkcja f() występowała w klasie bazowej i pochodnej, wówczas wywołanie jej za pośrednictwem referencji (wskaźnika) do klasy bazowej powodowało wykonanie wersji bazowej funkcji.

 

B b;

b.f(); // woła B::f()

A&ra = b;

ra.f() // woła A::f()

 

Cechą charakterystyczną polimorfizmu jest możliwość wywołania funkcji obiektu niezależnie od sposobu dostępu. Zazwyczaj funkcje polimorficzne wywoływane są za pośrednictwem wskaźnika do klasy bazowej.

 

Konstrukcją realizującą polimorfizm w języku C++ są funkcje wirtualne. W przypadku funkcji wirtualnych pobranie adresu obiektu i wywołanie funkcji za pośrednictwem referencji lub wskaźnika zawsze wywoła funkcję obiektu klasy potomnej – nawet jeżeli referencja (wskaźnk) jest typu bazowego.

 

class A

{

public:

    A(){}

    virtual void f(){

        printf("A::f");

    }

    virtual void g(){}

};

 

class B : public A

{

   

public:

    B():A(){}

    void f(){     printf("B::f ");

    }

};

 

class C : public B

{

public:

    C():B(){}

    void g(){

    printf("C::g ");

    }

};

 

void call_g(A&a)

{

    a.g();

}

 

void call_f(A&a)

{

    a.f();

}

void main(){

    A a; B b; C c;

    call_f(a); // A::f()

    call_f(b); // B::f()

    call_f(c); // B::f()

    call_g(a); // A::g()

    call_g(b); // A::g()

    call_g(c); // C::g()

}

 

·      W przypadku zwykłych funkcji kompilator w momencie kompilacji jest w stanie określić, która funkcja zostanie wywołana. Kryterium jest sposób dostępu – obiekt, referencja lub wskaźnik określonego typu.

·      W przypadku funkcji wirtualnych wybór funkcji do wykonania dokonywany jest dynamicznie w trakcie działania programu na podstawie typu rzeczywistego obiektu wskazywanego przez wskaźnik lub referencję.

 

 

Implementacja funkcji wirtualnych

Implementacja funkcji wirtualnych opiera się na wskaźnikach do funkcji.

·      Dla każdej klasy zawierającej funkcje zadeklarowane jako wirtualne kompilator generuje pojedynczy obiekt będący tablicą wskaźników do funkcji zadeklarowanych jako wirtualne. Tablica ta nazywana jest VTABLE. Wskaźniki do funkcji są umieszczone zawsze w stałej kolejności.

·      Do każdego obiektu klasy dodawane jest ukryte pole vptr, które jest wskaźnikiem na VTABLE odpowiedniego typu.

·      Jeżeli w klasie bazowej zdefiniowane są funkcje wirtualne, wówczas dla każdej klasy potomnej generowane są analogiczne struktury danych, nawet jeżeli nie redefiniuje funkcji wirtualnych klasy bazowej.

 

 

Kompilator dodaje do wywołania funkcji wirtualnej (np.: ptr->f()) dodatkowy kod, który:

 

1.   odczytuje wskaźnik vptr wskazywanego obiektu,

2.   na jego podstawie odnajduje odpowiednią tablicę VTABLE

3.   wywołuje funkcję, której adres jest umieszczony w VTABLE pod adresem odpowiadającym funkcji f().

Przykład

 

 

 

 

class Shape

{

public:

     virtual double area()const{return 0;}

     virtual void draw()const{cout<<"Shape"<<endl;}

     virtual void move(double x, double y){}

};

 

class Circle : public Shape

{

     double xc,yc,r;

public:

     Circle(double _xc, double _yc,double _r)
          :xc(_xc),yc(_yc),r(_r){}

     double area()const{return 3.14*r*r;}

     void draw()const{
          cout<<"Circle[("<<xc<<","<<yc<<"),"<<r<<"]"<<endl;
     }

     void move(double x, double y){xc+=x;yc+=y;}

};

 

class Rect : public Shape

{

     double x1,y1,x2,y2;

public:

     Rect(double _x1, double _y1,double _x2, double _y2)
          :x1(_x1),y1(_y1),x2(_x2),y2(_y2){}

     double area()const{return fabs( (x2-x1)*(y2-y1));}

     void draw()const{
         cout<<"Rect["<<x1<<","<<y1<<","
         <<x2<<","<<y2<<"]"<<endl;
         }

     void move(double x, double y){x1+=x;y1+=y;x2+=x;y2+=y;}

 };

 


 

double totalArea(Shape*tab[],int n)

{

     double area=0;

     for(int i=0;i<n;i++)area+=tab[i]->area();

     return area;

}

 

void moveAll(Shape*tab[],int n, double x, double y)

{

     for(int i=0;i<n;i++)tab[i]->move(x,y);

}

 

void drawAll(Shape*tab[],int n)

{

     for(int i=0;i<n;i++)tab[i]->draw();

}

 

 

void main()

{

     Shape*tab[4];

     tab[0]=new Circle(0,0,10);

     tab[1]=new Rect(-10,-10,10,10);

     tab[2]=new Rect(100,100,120,110);

     tab[3]=new Circle(100,100,5);

    

     cout<<"Area "<<totalArea(tab,4)<<endl;

     drawAll(tab,4);

     moveAll(tab,4,-100,-100);

     cout<<"Moved by -100,-100"<<endl;

     drawAll(tab,4);

 

     for(int i=0i<4;i++)delete tab[i];

}

 

Wynik

Area 992.5

Circle[(0,0),10]

Rect[-10,-10,10,10]

Rect[100,100,120,110]

Circle[(100,100),5]

Moved by -100,-100

Circle[(-100,-100),10]

Rect[-110,-110,-90,-90]

Rect[0,0,20,10]

Circle[(0,0),5]

 


Specyfikacja interfejsu

Bardzo często projektując hierarchię klas definiuje się pewien interfejs, który powinien zostać zachowany w klasie potomnej. Dzięki temu można tworzyć funkcje, które będą działały niezależnie od rzeczywistego obiektu.

W specyfikacji interfejsu można wyróżnić dwa rodzaje funkcji:

·      które muszą zostać przedefiniowane,

·      które mogą zostać przedefiniowane.

 

Przykład

 

class Connection

{

public:

    virtual int readByte()=0; // must be overriden

    virtual void showProgress(){} // can be overriden

};

 

void readInt(Connection&c,int&target)

{

    char buf[4];

    for(int i=0;i<4;i++) buf[i]= c. readByte();

    target = *(int*)buf;

    c.showProgress();

}

 

Klasy dziedziczące po Connection muszą przedefiniować readByte(), mogą przedefiniować showProgress().

 

Projektant biblioteki przewiduje, że użytkownik końcowy nigdy nie będzie tworzył obiektów klasy Connection, ale zawsze obiekt klasy potomnej (np.: SerialConnection, HttpConnection), dla którego działanie podstawowych funkcji – readByte() – zostanie przedefiniowane.

 

W powyższym przykładzie funkcja readByte() jest czystą funkcją wirtualną (ang. pure virtual function). Nie ma ona implementacji. W VTABLE klasy na jej miejscu wstawiony jest zerowy wskaźnik.

Klasa, w której zdefiniowane są jakiekolwiek czyste funkcje wirtualne nazywana jest klasą abstrakcyjną.

Klasy abstrakcyjne definiuje się wyłącznie jako dodatkowe konstrukcje ułatwiające wykorzystanie polimorfizmu. Nie jest możliwe utworzenie obiektu klasy abstrakcyjnej. Zawsze konieczne jest zdefiniowanie klasy potomnej.

 

W C++ możliwe jest zdefiniowanie standardowej implementacji czystej funkcji wirtualnej, ale nie umieszczenie wskaźnika do kodu w VTABLE. Końcowy użytkownik musi jawnie odwołać się do funkcji w swojej implementacji. Dzięki temu standardowa implementacja może być dzielona w różnych klasach potomnych.

 

Przykład

class Connection

{

public:

    virtual int readByte()=0;

    virtual void showProgress()=0;

};

 

void Connection::showProgress(){}

 

class MyConnection : public Connection

{

public:

    virtual int readByte(){…};

    virtual void showProgress(){

       Connection:: showProgress();

    }

};

 

 

 


Przedefiniowanie funkcji wirtualnych

Użycie funkcji wirtualnych narzuca pewne ograniczenia. Wskaźniki umieszczane w VTABLE kolejnych klas hierarchii muszą być tych samych typów. Stąd nie jest możliwa zmiana deklaracji funkcji wirtualnych w klasach potomnych.

 

Jedynym wyjątkiem jest typ zwracanej wartości. Możliwe jest zwracanie wskaźnika lub referencji do pewnej klasy potomnej typu zwracanego w bazowej funkcji wirtualnej.

 

Przykład

 

class BaseReturnType {...};

class DerivedReturnType : public BaseReturnType {...};

 

class Base{

    virtual BaseReturnType& foo();

};

class Derived : public Base{

    DerivedReturnType& foo();

};

 

Derived:: foo() zwraca wartość typu DerivedReturnType&. Referencja tego typu może być automatycznie zrzutowana w górę (upcasted) do referencji typu BaseReturnType&.

 

Typowe zastosowanie:

class Object{

    virtual Object*clone()const;

};

 

class MyObject : public Object{

    MyObject*clone()const{

       return new MyObject(*this);

    }

};

 


Funkcje wirtualne i dziedziczenie wielobazowe

Klasa dziedzicząca po kilku klasach bazowych może przedefiniowywać funkcje wirtualne występujące w różnych hierarchiach.

 

Przykład

 

class A1

{

public:

    virtual void f(){cout<<"A1::f"<<endl;}

    virtual void g(){cout<<"A1::g"<<endl;}

};

 

class A2

{

public:

    virtual void g(){cout<<"A2::g"<<endl;}

};

 

class B : public A1, public A2

{

public:

    void f(){cout<<"B::f"<<endl;}

    void g(){cout<<"B::g"<<endl;}

};

 

void main()

{

    A1*b1=new B();

    b1->f(); // wypisze B::f

    b1->g(); // wypisze B::g

    A2*b2=new B();

    b2->g();// wypisze B::g

}

 

W powyższym przykładzie:

·      Klasa B przedefiniowuje funkcje wirtualne występujące w różnych klasach bazowych (potencjalnie hierarchiach klas).

·      Funkcje A1::g() i A2::g() są przedefiniowane przez jedną funkcję B::g()

 

Rozwiązania stosowane przy implementacji interfejsu

Bardzo często zadaniem programisty jest zaimplementowanie pewnego interfejsu funkcjonalnego. W tym celu może skorzystać z gotowej klasy zdefiniowanej w pewnej bibliotece lub zbudować klasę od podstaw.

 

Przykład: zadaniem jest zbudowanie klasy potomnej klasy IStack zdefiniowanej jako:

class IStack

{

public:

    virtual void push(int i)=0;

    virtual int pop()=0;

};

Abstrakcyjna klasa IStack definiuje interfejs funkcjonalny stosu.

 

Jeżeli skorzystamy z bibliotecznej (wyimaginowanej) klasy List zadeklarowanej jako:

class List

{

public:

    void pushFront(int i);

    void pushBack(int i);

    void deleteFront();

    void deleteBack();

    int getFront()const;

    int getBack()const;

};

stos możemy zaimplementować w postaci prostej klasy wykorzystującej gotową funkcjonalność listy:

class StackAsList : public List,public IStack

{

public:

    void push(int i){pushBack(i);}

    int pop(){

        int ret=getBack();

        deleteBack();

        return ret;

    }

};

 

Alternatywnymi implementcjami mogą być:

·      wykorzystanie podobiektu klasy List jako atrybutu

 

class StackUsingList : public IStack

{

List list;

public:

void push(int i){list.pushBack(i);}

int pop(){

     int ret=list.getBack();

     list.deleteBack();

     return ret;

}

};

·      implementacja od podstaw.

 

class MyStack: public IStack

{

int *tab;

int count;

public:

Stack(){tab = new int[1000];count=0;}

~Stack(){delete []tab;}

void push(int i){tab[count++]=i;}

int pop(){return tab[--count];}

};

 

·      Separacja interfejsu stosu IStack od jego implementacji ma wiele zalet: dopuszczalne są różne implementacje: w postaci tablicy o stałych rozmiarach, listy, tablicy o zmiennych rozmiarach (wektora).

·      Niezależnie od zrealizowanej implementacji, wskaźnik lub referencję do obiektu typu StackAsList, StackUsingList lub MyStack możemy przekazać do funkcji działającej na obiekcie klasy IStack:

 

void useStack(IStack&stack)

{

    stack.push(7);

    stack.push(5);

    cout<<stack.pop();

    cout<<stack.pop();

}

·      Jeżeli budujemy implementację klasy realizującej założony interfejs od podstaw, musimy w praktyce powtarzać funkcjonalność, która zapewne jest już gdzieś zdefiniowana (zwłaszcza zarządzanie pamięcią).

·      Korzystając z gotowej klasy oszczędzamy na czasie implementacji (i w większości przypadków tworzymy kod wolny od błędów i działający bardziej efektywnie).

·      Implementacja wykorzystująca dziedziczenie wielobazowe StackAsList będzie preferowana, jeżeli zależy nam na połączeniu funkcjonalności obu klas: na przykład zarówno na funkcjonalności stosu, jak i listy (iteracja po zawartości, wypisanie zawartości, możliwość wstawiania elementów w inny sposób). Obiekt klasy StackAsList może być przekazany do funkcji oczekującej listy jako argumentu:

 

void useList(List&list)

{

for(int i=0;i<2000;i++)list.pushFront(i);

}

 

 


Konstruktory[p7]  i destruktory

·      Konstruktor jest specjalną funkcją odpowiedzialną za prawidłową inicjalizację obiektu –nadanie polom danych poprawnych wartości początkowych.

·      Zadaniem destruktora jest przeprowadzenie wymaganych operacji przy usuwaniu obiektu – np.: zwolnienie przydzielonej pamięci na stercie, zamknięcie plików, zwolnienie innych przydzielonych zasobów systemowych.

Konstruktor

Konstruktor jest specjalną funkcją o takiej samej nazwie, jak nazwa klasy. Konstruktor nie może zwracać wartości. Możliwe jest zdefiniowanie kilku różnych konstruktorów klasy.

 

Konstruktor jest wołany w momencie tworzenia obiektu. Przydział pamięci dla obiektu i wywołanie konstruktora należy traktować rozłącznie. Często pamięć jest przydzielana wcześniej, natomiast moment wywołania konstruktora wynika z logiki przetwarzania

 

Konstruktory są wołane przy tworzeniu obiektów:

 

Globalnych

Przed rozpoczęciem wykonania programu, np.: przed wejściem do funkcji main

Lokalnych

Deklarowanych wewnątrz funkcji lub instrukcji blokowej. Konstruktor jest wołany w momencie osiągnięcia odpowiedniej instrukcji.

Dynamicznie tworzonych

Obiekty tworzone są dynamicznie w wyniku wywołania operatora new. Operator new przydziela pamięć obiektu na stercie. Jeżeli alokacja pamięci przebiegła pomyślnie, woła natychmiast konstruktor.

Obiektów tymczasowych

Obiekty tymczasowe mogą być tworzone jako rezultaty wywołania funkcji zwracających obiekty lub w niektórych przypadkach rzutowania.

Pól składowych klas

Jeżeli składowymi obiektu są podobiekty innych klas, wówczas podczas tworzenia obiektu nadrzędnego wołane będą konstruktory komponentów.

Podobiektów klas bazowych

W podczas konstrukcji obiektu klasy potomnej wołane są konstruktory inicjujące komponenty klas bazowych.

 

Operacje wykonywane przez konstruktor:

1.   Inicjalizuje wskaźnik na obiekt wirtualnej klasy bazowej (o ile klasa dziedziczy wirtualnie).

2.   Woła konstruktor klasy bazowej i konstruktory komponentów danych (atrybutów) w kolejności deklaracji.

3.   Jeżeli klasa definiuje lub dziedziczy funkcje wirtualne, inicjalizuje wskaźnik vptr obiektu wskazujący VTABLE klasy.

4.   Wykonuje kod zdefiniowany w ciele konstruktora.

 

Konstruktor zdefiniowany jest jako

class-name([arg-list])

    [:ctor-initializer]

    {body}

 

arg-list  jest opcjonalną listą argumentów konstruktora

 

ctor-initializer jest listą inicjalizacyjną.

Może ona zawierać:

·      nazwy bezpośrednich klas bazowych

·      nazwy wirtualnych klas bazowych (również niebezpośrednich)

·      nazwy (identyfikatory) atrybutów klasy

 

Po odpowiednich nazwach w nawiasach podawane są argumenty inicjalizujące.

 

Lista inicjalizacyjna nie jest kodem wykonywanym, lecz elementem języka umożliwiającym przesłanie parametrów do konstruktorów klas bazowych i atrybutów klasy. Odpowiednie konstruktory wołane są w kolejności wynikającej z deklaracji.

 

Dwa typy konstruktorów mają szczególne znaczenie:

·      bezargumentowy konstruktor standardowy X() – używany np.: przy tworzeniu tablic obiektów;

·      konstruktor kopiujący X(const X&)

Ich rolę mogą przejąć konstruktory z dodatkowymi parametrami, o ile zdefiniowane są defaultowe wartości argumentów.

 

X::X(const char*a=””){...}

X::X(const X&, const char*a=”x”) {...}

 

Konstruktory i funkcje wirtualne

·      Wpierw wołane są konstruktory klas bazowych (począwszy od korzeni hierarchii klas), następnie obiektów składowych.

·      Dopiero po tym etapie następuje ustawienie wskaźnika vptr do tablicy funkcji wirtualnych VTABLE i przejście do wykonania ciała konstruktora.

·      Oznacza to, że w trakcie wykonania kodu konstruktora dysponujemy zestawem funkcji wirtualnych odpowiadających danemu poziomowi hierarchii.

 

Przykład

 

class A

{

public:

    A(){f();}

    virtual void f(){

        printf("A::f ");

    }

};

 

class C : public B

{

public:

    C():B(){f();}

    void f(){

        printf("C::f ");

    }

};

 

 

class B : public A

{

   

public:

    B():A(){f();}

    void f(){

        printf("B::f ");

    }

};

 

void main()

{

    C c;

}

// wypisze A::f B::f C::f

Destruktor

Destruktor jest funkcją wywoływaną przy usuwaniu obiektu. Zadeklarowany jest jako:

       ~class-name()

 

·      W klasie może być zdefiniowany dokładnie jeden destruktor.

·      Destruktor nie może mieć argumentów

·      Destruktor nie może zwracać wartości.

 

Destruktory są wołane dla następujących typów obiektów:

 

Globalnych

Po zakończeniu programu, np.: po wyjściu z funkcji main

Lokalnych

Destruktor jest wołany w momencie osiągnięcia końca bloku instrukcji.

Dynamicznie tworzonych

Dynamicznie tworzone obiekty zwalniane są za pomocą operatora delete. Operator ten wywołuje destruktor oraz zwalnia pamięć obiektu.

Obiektów tymczasowych

 

Jawnie

W niektórych przypadkach destruktory mogą być wywołane jawnie. Dotyczy to głównie obiektów, dla których pamięć przydzielana jest w niestandardowy sposób oraz szczególnych przypadków dziedziczenia wielobazowego.

 

Kolejność wykonania destruktorów

Destruktory wołane są w kolejności odwrotnej do konstruktorów:

1.   Wpierw wykonywane jest ciało destruktora klasy

2.   Następnie wywoływane są destruktory obiektów składowych klasy (komponentów) w kolejności odwrotnej do kolejności deklaracji. Dotyczy to komponentów, które nie są zadeklarowane jako static. Statyczne komponenty są usuwane jak obiekty globalne.

3.   Dalej wołane są destruktory niewirtualnych klas bazowych

4.   Na końcu wołane są destruktory wirtualnych klas bazowych (o ile obiekt dziedziczy wirtualnie)

Wywołanie funkcji wirtualnych w destruktorze

Zasady wywołania funkcji wirtualnych w destruktorach są analogiczne, jak w przypadku konstruktorów. Na danym poziomie hierarchii dostępne są wyłącznie lokalne i odziedziczone wersje funkcji wirtualnych.

 

Przykład

 

class A

{

public:

    ~A(){f();}

    virtual void f(){

        printf("~A::f ");

    }

};

class C : public B

{

public:

    ~C(){f();}

    void f(){

        printf("~C::f ");

    }

};

class B : public A

{

   

public:

    ~B(){

        printf("~B ");

        f();

    }

};

void main()

{

    C c;

}

// ~C::f ~B~A::f ~A::f

 

Wirtualne destruktory

Bardzo często wykorzystując polimorfizm posługujemy się wskaźnikami do klasy bazowej. Na przykład, definiujemy rysunek Drawing jako tablicę wskaźników do klasy bazowej Shape. Rzeczywistymi obiektami, które mieszczą się pod adresami przechowywanymi we wskaźników są jednak obiekty klas potomnych: Circle, Triangle, Square.

 

Usuwając za pomocą operatora delete obiekt identyfikowany przez wskaźnik typu Shape* ryzykujemy, że wywołany zostanie jedynie destruktor klasy Shape, zamiast odpowiednio destruktora Circle, Triangle, lub Square.

 


Chcąc usuwać obiekty typu Shape możemy utworzyć funkcję, która poprzez rzutowanie wywoła właściwe wersje destruktorów:

 

void deleteShape(Shape*shape){

    switch(shape->getType()){

       case CIRCLE:

       delete (Circle*)shape;return;

       case TRIANGLE:

       delete (Triangle*)shape;return;

       case SQUARE:

       delete (Square*)shape;return;

    }

}

 

Podobne rozwiązanie jest jednak bardzo niewygodne i przeczy idei wykorzystania polimorfizmu.

 

Język C++ umożliwia polimorficzne wywołanie destruktorów, zezwalając na zadeklarowanie destruktora jako virtual. Dzięki temu wskaźnik do kodu destruktora stanie się składnikiem tablicy VTABLE i wywołanie destruktora za pośrednictwem operatora delete wywoła zawsze właściwy destruktor obiektu.

 

Wersja 1 (destruktor niewirtualny)

 

class A

{

public:

    ~A(){printf("~A ");}

};

 

class B : public A

{

public:

    ~B(){printf("~B ");}

};

 

void main()

{

    A*a=new B;

    delete a;

// wypisze

// ~A

}

 

 

 

 

 

Wersja 2 (destruktor wirtualny)

 

class A

{

public:

    virtual ~A(){printf("~A ");}

};

 

class B : public A

{

public:

    ~B(){printf("~B ");}

};

void test()

{

    A*a=new B;

    delete a;

// wypisze

// ~B ~A

}

 

 

W przypadku dziedziczenia wielobazowego stosowanie wirtualnych destruktorów powinno działać poprawnie, jeżeli posługujemy się wskaźnikami do klasy bazowej hierarchii, w której zdefiniowano destruktor jako wirtualny.

 


Operator przypisania i konstruktor kopiujący

Język C++ implementuje standardowe operacje na obiektach:

·      przypisanie (podstawienie)

·      inicjalizacja z użyciem konstruktora kopiującego

 

Podczas przypisania danemu obiektowi nadaje się wartość drugiego obiektu. Standardowa implementacja przypisania polega na skopiowaniu kolejnych pól obiektu. To działanie może zostać przedefiniowane poprzez dostarczenie własnego operatora przypisania postaci:

 

    X&X::operator=(const X&);

 

Podczas inicjalizacji z użyciem konstruktora kopiującego obiekt jest inicjowany wartością innego obiektu. Podobnie, jak w przypadku operatora przypisania, standardowa implementacja polega na skopiowaniu kolejnych pól drugiego obiekt. W przypadku klas alokujących pamięć konieczna jest odrębna implementacja konstruktor kopiującego:

 

    X::X(const X&);

 

Ze zjawiskiem inicjalizacji z użyciem konstruktora kopiującego można się spotkać w następujących przypadkach:

·      Jawna inicjalizacja obiektu

 

class A {...};

A a1;

A a2=a1; /* wołany jest konstruktor kopiujący, a nie operator przypisania! */

A a3(a1);

 


·      Inicjalizacja związana z przesyłaniem argumentów do funkcji.

Argumenty do funkcji mogą być przesyłane przez wartość lub referencję (adres). W pierwszym przypadku na stosie przydziela się miejsce na nowy obiekt, a następnie automatycznie wołany jest konstruktor kopiujący, który inicjuje formalny parametr funkcji wartością argumentu wywołania:

class A {...};

void foo(A a)

{ // tu zostanie stworzona kopia argumentu

}

A a1;

foo(a1);

·      Inicjalizacja związana z przesyłaniem rezultatów wywołania funkcji.

Funkcje mogą zwracać obiekty przez wartość lub referencje. W przypadku zwracanej referencji obiekt nie może być obiektem automatycznym. W przypadku zwracanej wartości pamięć obiektu jest przydzielana na stosie.

 

class Ret

{

public:

  Ret(const Ret&o){

  printf("Ret::copy constuctor\n");}

  Ret(){printf("Ret::constuctor\n");}

};

 

Ret foo()

{

  Ret r;

  return r;  // tu konstr. kopiujący

}

 

void main()

{

    foo();

}

 

Rezultat

Ret::constuctor

Ret::copy constuctor


Przeciążanie[p8]  funkcji i operatorów

W języku C wymagane jest stosowanie unikalnych nazw funkcji. Ograniczenie to jest bardzo niewygodne, ponieważ bardzo często chcielibyśmy wzorować się na językach naturalnych – gdzie ten sam czasownik w zależności od kontekstu oznacza różne akcje.

 

Przykład

·      Operacje wash car i wash face w języku C muszą być wyrażone jako wash_car(struct car*) oraz wash_face(struct face*).

·      W języku C++ możemy użyć tej samej nazwy w różnych kontekstach:

 

wash(struct car*)

wash(struct face*)

class Car{

    public: void wash();

};

 

Kompilator C++ konwertując do postaci wewnętrznej automatycznie tworzy własne „dekorowane” nazwy funkcji, które umieszcza w tabeli symboli pliku OBJ

 

wash(struct car*)

wash@@YAXPAUcar@@@Z

void Car::wash()

wash@Car@@QAEXXZ

 

Sposób generacji nazw wewnętrznych rozróżnia funkcje globalne oraz metody klasy, bierze także pod uwagę formalne parametry funkcji.

 

Dalszym udogodnieniem jest możliwość stosowanie przestrzeni nazw (ang. namespace). Umieszczając globalne funkcje w różnych przestrzeniach nazw mamy możliwość wielokrotnego definiowania funkcji o tych samych nazwach i argumentach.


Przykład

 

// globalna funkcja identyfikowana jako ::f

void f(){}

 

namespace First{

    // globalna funkcja identyfikowana jako First::f

    void f(){}

}

 

namespace Second{

    // globalna funkcja identyfikowana jako Second::f

    void f(){}

}

 

Przestrzenie nazw mają szersze zastosowanie niż wyłącznie możliwość wielokrotnego definiowania funkcji. W przestrzeniach nazw można także definiować klasy i deklarować zmienne. W pełni kwalifikowane nazwy są zawsze poprzedzane nazwą przestrzeni nazw i operatorem zasięgu :: .

 

Termin przeciążanie funkcji (operatorów) odnosi się do funkcji zdefiniowanych w tym samym zasięgu nazw (przestrzeni nazw lub w klasie). Interpretując wywołanie funkcji, kompilator wybierze wywołanie odpowiedniej implementacji na podstawie argumentów występujących w wywołaniu.

 

Przykład:

 

double max(double d1, double d2){

    return (d1>d2 ? d1:d2);

}

double max(int d1, int d2){

    return (d1>d2 ? d1:d2);

}

 

max(1.0,2.0) ; // wywoła wersję double

max(1,2) ; // wywoła wersję int

 

Rozróżnienie typów argumentów.

W trakcie wywołania przeładowanych funkcji kompilator wybiera wersję funkcji najlepiej pasującą do typu argumentów. Jeżeli odpowiednia funkcja zostanie odnaleziona, wówczas jest ona wołana. W przeciwnym przypadku kompilator będzie raportował niejednoznaczność traktowaną jako błąd.

 

Występują tu następujące możliwości:

·      dokładne dopasowanie argumentów wywołania do jednej z definicji

·      następuje trywialna konwersja

type-name

type-name&

type-name&

type-name

type-name[]

type-name*

type-name

const type-name

type-name*

const type-name*

·      następuje konwersja całkowitoliczbowa pomiędzy danymi typu int, long, unsigned

·      istnieje standardowa konwersja pomiędzy argumentami

void*

const void*

DerivedClass*

BaseClass*

DerivedClass&

BaseClass&

·      istnieją zdefiniowane przez użytkownika konwersje

String

operator const char*()

const char*

String

·      w definicji funkcji pojawia się elipsa ...

 

Możliwość doboru przeładowanej wersji funkcji sprawdzana jest w momencie wywołania funkcji. Wtedy też raportowane są niejednoznaczności.

 

Przykład

// nieodróżnialne od double max(double d1, double d2)

double max(const double&d1, const double&d2){

    return (d1>d2 ? d1:d2);

}

 

// możliwe są dwie ścieżki automatycznej konwersji

max(1.0,2);

Standardowe argumenty

Alternatywą do implementacji kilku przeciążonych wersji funkcji o podobnym zachowaniu jest zadeklarowanie jej standardowych argumentów.

 

Przykład

int print(char *s ); // (1) Print a string.

// Print a double with default precision

int print(double dvalue); // (2)

// Print a double with a given precision.

int print(double dvalue, int prec);  // (3)

 

Funkcje (2)oraz (3) są bardzo do siebie podobne, najprawdopodobniej funkcja (2) może być zaimplementowana jako 

 

int print(double dvalue)

{

    return print(dvalue,DEFAULT_PRECISION);

}

Analogiczny efekt, jaki daje przeciążenie uzyskamy rezygnując z implementacji funkcji (2) oraz deklarując funkcję (3) jako:

 

int print(double dvalue, int prec = DEFAULT_PRECISION);

 

·      Kompilator napotykając wywołanie: print(4.5,3) wywoła funkcję (3) z argumentem prec = 3.

·      Napotykając wywołanie print(5.1) automatycznie wygeneruje wywołanie print(5.1, DEFAULT_PRECISION).

 

1.           Standardowe argumenty mogą być używane tylko w przypadku, kiedy ostatnie argumenty funkcji są pominięte w wywołaniu. Muszą być deklarowane jako ostatnie parametry funkcji.

2.           Standardowa wartość argumentu musi być zdefiniowana dokładnie raz. Nie wolno jej przedefiniowywać (nawet nadając im te same wartości).

3.           W kolejnych deklaracjach można uzupełniać poprzedzające standardowe argumenty.

4.           Deklarując wskaźniki do funkcji można również podać standardowe argumenty ich wywołania.

Przeciążanie operatorów

Przeciążanie operatorów w C++ jest zabiegiem wyłącznie syntaktycznym. Wszystkie operatory są implementowane jako funkcje. Różnicą jest postać wywołania.

Zamiast pisać

       x5 = plus(plus(plus(x1,x2),x3),x4) ;

możemy użyć zapisu

       x5= x1+x2+x3+x4 ;

 

·      Podobnie, jak w przypadku przeciążonych funkcji, kompilator automatycznie dobiera odpowiedni operator dokonując, jeżeli jest to wymagane, automatycznych konwersji.

·      Nie można redefiniować trójargumenowego operatora warunkowego wyboru oraz kilu innych (.  ::  .*).

 

Składnia

·      Operatory w C++ definiuje się z użyciem słowa kluczowego operator, po którym następuje nazwa operatora. Operatory mogą być składowymi klas lub mogą być zadeklarowane jako funkcje globalne.

·      Liczba argumentów operatora uzależniona jest od jego typu oraz miejsca definicji.

 

 

Unarny

Binarny

Globalny

1 argument

2 argumenty

Lokalny (metoda klasy)

0 argumentów

1 argument

 

·      Składnia wywołania przeciążonych operatorów jest zgodna ze składnią operatorów C/C++ dla wbudowanych typów.

·      Operatory nie mogą mieć standardowych argumentów

·      Wszystkie przeciążone operatory (poza operatorem przypisania operator= ) są dziedziczone.

·      Podczas wywołania operatorów pierwszym argumentem musi być zawsze typ (referencja typu), dla której operator został zdefiniowany. Kompilator nigdy nie stosuje konwersji dla pierwszego argumentu.

 

Przykład

class String

{

public:

    char buf[256];

 

    String(const char*txt=""){strcpy(buf,txt);}

    operator const char*()const {return buf;}

    String&operator=(const String&s) {

       strcpy(buf,s.buf);

       return *this;

    }

    char&operator[](int idx){

       if(idx<0 || idx>255)return buf[0];

       return buf[idx];

    }

};

 

String&operator+=(String&s,const char*txt){

    strcat(s.buf,txt);

    return s;

}

 

const String operator+(const String&s,const char*txt) {

    String r(s.buf);

    strcat(r.buf,txt);

    return r;

}

 

bool operator==(const String&s1,const char*txt)

{

    return !strcmp(s1.buf,txt);

}

void main(){

    String a("Ala ma");

    a+=" ";

    String b("kota");

    a+=b; // automatycznie wywola

           //String::operator const char*()

    if(a=="Ala ma kota"){…}

    b = b + " i psa";

    for(int i=0;i<strlen(b);i++)putchar(b[i]);

}


Operatory inkrementacji i dekrementacji

 

Unarne (jednoargumentowe) operatory inkrementacji i dekrementacji występują w dwóch odmianach: prefiksowej i postfiksowej;

Typowa implementacja jest następująca:

 

class Int{

    int value;

public:

    Int&operator++(){

       value++;return *this;

    }

    Int operator++(int){

       Int temp = *this;

       ++*this;

       return temp;

    }

};

 

Operator przypisania

·      Operator przypisania operator= musi być zadeklarowany jako metoda klasy. Zazwyczaj deklaracja ma postać:

       X&operator=(const X&)

·      Operator ten nie jest dziedziczony, ponieważ musi skopiować wszystkie pola klasy.

·      Dla wielu klas kompilator jest w stanie wygenerować automatyczny operator przypisania, który wywoła operatory przypisania poszczególnych atrybutów.

 

class TwoStrings

{

public:

    String s1;

    String s2;

};

 

TwoStrings a,b;

a = b; // automatycznie wywoła a.s1= b.s1;

           //a.s2=b.s2;

Operator przypisania powinien być definiowany dla klas alokujących pamięć. Zazwyczaj także definiujemy wtedy konstruktor kopiujący.

 

Modelowy przykład klasy alokującej pamięć

 

class Vector

{

    double *data;

    int size;

public:

    Vector(int s=0):size(s),data(0){

       if(size)data = new double[size];

    }

    Vector(const Vector&other){

       copy(other);

    }

    ~Vector(){free();}

    Vector&operator=(const Vector&other){

       if(&other != this){

           free();

           copy(other);

       }

       return *this;

    }

protected:

    void free(){

       if(data)delete []data;

       data =0;

       size=0;

    }

    void copy(const Vector&other){

       size = other.size;data=0;

       if(size)data = new double[size];

        for(int i=0;i<size;i++)data[i]=other.data[i];

    }

};

 

if(&other != this)– zabezpiecza przed zwolnieniem pamięci obiektu, dla bezpośredniego lub pośredniego wywołania

Vector x;

x = x;

Operator wywołania [p9] funkcji

Tą nazwą określa się operator (   ) . Operator ten jest operatorem dwuargumentowym i ma postać

 

       expression (expression-list)

 

expression – jest zazwyczaj nazwą funkcji,

expression-list – listą argumentów.

 

Najczęściej operator ( ) jest przeciążany dla klas opisujących macierze, ze względu na zdolność do przyjęcia większej liczby argumentów.

 

class Matrix

{

    double e[100][100];

public:

    double&operator()(int row,int col) {

       return e[row][col];

    }

};

 

void main(){

    Matrix m;

    m(2,3)=7.5;

    printf("%f",m(2,3));

}

 


Operator dostępu do składowych

Operator ten jest definiowany jako

class-type*operator->()

Musi być on metodą pewnej klasy, która pełni rolę „sprytnego” wskaźnika (ang. smart pointer) pozwalającego na dostęp do pól i metod obiektu class-type.

 

Podobne funkcje może pełnić operator dereferencji zdefiniowany jako:

    class-type&operator*()

Operator powinien zwrócić referencję do obiektu wskazywanego przez obiekt SmartPointer.

 

Zazwyczaj obiekt typu SmartPointer realizuje dodatkowe zabezpieczenia lub zlicza referencje do obiektu, co pozwala na automatyczne usuwanie nieużywanych obiektów.

 

 

Przykład

 

class Object

{

    int refCount;

    friend class SmartPointer;

public:

    Object(){refCount=0;}

    void dump()const{

       printf("Object.refcount=%d",refCount);

    }

};

 


class SmartPointer

{

    Object*obj;

    void release(){

       if(obj){

           obj->refCount--;

           if(obj->refCount==0)delete obj;

       }

    }

    void bind(Object*_obj){

       obj=_obj;

       if(obj)obj->refCount++;

    }

public:

    SmartPointer(Object*_obj=0){bind(_obj);}

    SmartPointer(const SmartPointer&p){bind(p.obj);}

    ~SmartPointer(){release();}

    SmartPointer&operator=(Object*_obj){

       if(obj!=_obj){ release(); bind(_obj);}

       return *this;

    }

    SmartPointer&operator=(const SmartPointer&p){

       if(p.obj!=obj){ release(); bind(p.obj); }

       return *this;

    }

    Object*operator->(){return obj;}

    class NullPointerException{};

    Object&operator*(){

       if(!obj)throw NullPointerException();

       return *obj;

    }

};

 

void main()

{

    SmartPointer sp1=new Object();

    sp1->dump(); // Object.refcount=1

    {

       SmartPointer sp2=sp1;

       sp2->dump(); // Object.refcount=2

    }

    (*sp1).dump(); // Object.refcount=1

} // obiekt jest usuwany

 

Operatory new i delete

Możliwe jest przeciążenie globalnych i lokalnych operatorów new i delete, np.: aby przyspieszyć alokację pamięci lub pominąć w kodzie jej zwalnianie (cały blok zostanie zwolniony przy końcu programu).

 

#include <cstdlib>

#include <iostream>

 

using namespace std;

 

 

class MyStorage

{

      static void*start;

      static void*end;

      static void*current;

      MyStorage(){initAllocator();}

      ~MyStorage(){freeAllocator();}      

      static MyStorage _myStorage;

public:      

      static void initAllocator();

      static void freeAllocator();      

      static void*alloc(size_t size);

      static void free(void*){}

};

 

void *MyStorage::start=0;

void *MyStorage::end=0;

void *MyStorage::current=0;

 

MyStorage MyStorage::_myStorage;

 

·     Zmienne start i end – wskaźniki na początek i koniec bloku pamięci.

·     Klasa MyStorage jest singletonem (nie da się utworzyć instancji klasy, ponieważ konstruktor jest prywatny)


 

void MyStorage::initAllocator(){

     if(start)return;

     size_t size=1024;//1024*1024*256;

     start=malloc(size);

     current=start;

     if(start){end = (char*)start+size;}

     printf("start:%p\n",start);

     printf("end:%p\n",end);

}

 

void MyStorage::freeAllocator(){

     if(start)free(start);

     start=0;

}

 

void*MyStorage::alloc(size_t size)

{

    if(start==0)throw std::bad_alloc();

    printf("\n%p --> ",current);

    printf("%d ",size);

   

    void*old=current;

    current = (char*)current +
       sizeof(void*)*(
           size/sizeof(void*)
           + (size%sizeof(void*)?1:0)
       );

    if(current>end)throw std::bad_alloc();

    printf(" (%p) ",current);

   

    return old;

}

 

Globalne operatory new i delete.

 

void *::operator new(size_t size){

     return MyStorage::alloc(size);

}

 

void *::operator new[](size_t size){

     return MyStorage::alloc(size);

}

 

void ::operator delete(void*block){

     return MyStorage::free(block);

}

 

void ::operator delete[](void*block){

     return MyStorage::free(block);

}

 

Test

 

class A{

public:

      A(){printf(" this:%p",this);}

};

 

int main(int argc, char *argv[])

{

   

    for(int i=0;;i++){

        try{

            char*t=new char[256];

            strcpy(t,"Ala ma kota");

            delete []t;

            new A();

            A*a=new A[3];

           

        }

        catch(std::bad_alloc&){

            cout<<"No storage at "<<i<<endl;

            break;                     

        }

                                  

    }

    system("PAUSE");

    return EXIT_SUCCESS;

}

 


Wynik:

start:00552480

end:00552880

 

00552480 --> 256  (00552580)

00552580 --> 1  (00552584)  this:00552580

00552584 --> 3  (00552588)  this:00552584 this:00552585 this:00552586

00552588 --> 256  (00552688)

00552688 --> 1  (0055268C)  this:00552688

0055268C --> 3  (00552690)  this:0055268C this:0055268D this:0055268E

00552690 --> 256  (00552790)

00552790 --> 1  (00552794)  this:00552790

00552794 --> 3  (00552798)  this:00552794 this:00552795 this:00552796

00552798 --> 256 No storage at 3

 

Operatory rzutowania w C++

Operatory rzutowania muszą być stosowane, jeżeli zachodzi konieczność zmiany typu zmiennych lub stałych, a równocześnie kompilator nie jest w stanie dokonać konwersji typów w sposób automatyczny.

Konwersje automatyczne dotyczą przede wszystkim:

·      konwersji pomiędzy typami całkowitoliczbowymi

·      konwersji typów całkowitoliczbowych do zmiennoprzecinkowych

·      konwersji wskaźników lub referencji klas potomnych do klas bazowych.

·      konwersji wskaźników lub referencji bez modyfikatora const do wskaźników (referencji) typu const.

W innych przypadkach konwersja typów musi być dokonana jawnie przez skorzystanie z operatorów rzutowania.

 

Składnia podstawowych operatorów rzutowania w C/C++ jest następująca:

(TYPE)expression [C i C++]

lub

TYPE(expression) [tylko C++]

 

Przykład

void moveto(int x,int y);

void lineto(int x,int y);

 

#define M_PI 3.14159265358979323846

 

void drawCircle(double cx,double cy, double radius)

{

    for(double angle=0;angle<=2*M_PI;angle+=M_PI/32){

       int x=int(cx+cos(angle)*radius);

       int y=int(cy+sin(angle)*radius);

       if(angle==0)moveto(x,y);

       else lineto(x,y);

    }

}

 

Twórcy języka C++ uznali, że rzutowanie jest operacją potencjalnie niebezpieczną – i dlatego warto oznaczyć miejsca, w których następuje rzutowanie specjalnymi operatorami o zmienionej składni. Operatory te są także bardziej elastyczne, miedzy innymi możliwe jest sprawdzanie poprawności rzutowania.

 

Nowe[ps10]  operatory w C++ to

·      static_cast

·      const_cast

·      reinterpret_cast

·      dynamic_cast

 

Operator static_cast

 

Składnia:

static_cast<TYPE>(expr)

 

TYPE – symbol docelowego typu

expr – wyrażenie lub stała

 

Operator konwertuje wyrażenie expr do typu TYPE. Możliwość poprawnego zastosowania operatora jest sprawdzana statycznie – w trakcie kompilacji. Operator jest przeznaczony do konwersji pomiędzy typami wbudowanymi, wskaźnikami do klas należącej do wspólnej hierarchii oraz wskaźnikami typu void*.

 

Przykład 1:

int*ptr=static_cast<int*>( malloc(20*sizeof(int)) );

double x=2.7;

int ix=static_cast<int>(  x  ); // obetnie .7

cout<<ix<<endl; //wypisze 2

 

Przykład 2

class Base{};

class Derived:public Base {};

 

void foo()

{

    Derived d;

    Base*pb=&d;

    Derived*pd=static_cast<Derived*>(  pb  );

}

 

Przykład 3

void fooerror()

{

    Base*ptrToBase=new Base();

    int *ptrToInt=static_cast<int*>(ptrToBase);

}

 

Kompilator odmówi dokonania konwersji w trakcie kompilacji:

 

error C2440: 'static_cast' : cannot convert from 'class Base *' to 'int *'

Types pointed to are unrelated; conversion requires reinterpret_cast, C-style cast or function-style cast

 

Operator const_cast

Operator pozwala na odrzucenie modyfikatora const lub volatile. Analogiczny efekt można bezwiednie uzyskać stosując standardowy operator rzutowania.

Usuwanie modyfikatora const jest potencjalnie niebezpieczne, ponieważ wyłącza on standardowy mechanizm sprawdzania prawa do modyfikacji danych. Z tego powodu taką zmianę lepiej jest świadomie oznaczyć specjalnym operatorem.

 

Składnia:

const_cast<TYPE>(expr)

 

Przykład

void main()

{

    /* volatile */ const int i=0;

    int* pi = (int*)&i;

    *pi=2;

    cout<<i<<endl; // wypisze 0; 2 dla volatile & g++

    cout<<*pi<<endl; // wypisze 2

   

    pi = const_cast<int*>(&i);

    *pi=3;

    cout<<i<<endl; // wypisze 0; 3 dla volatile & g++

    cout<<*pi<<endl; //wypisze 3

}

Dość nieoczekiwane działanie programu jest związane z wewnętrznymi optymalizacjami.

Operator reinterpret_cast

Operator jest najbardziej niebezpieczny z całej grupy i potencjalnie może być źródłem błędów i pojawiających się przy próbach przeniesienia programu na inne platformy.

 

Zmienia on interpretację danych traktując je jak sekwencje bitów należących do zupełnie innego typu danych.

 

Składnia:

reinterpret_cast<TYPE>(expr)

 

Przykład

struct coord {double x,y,z;};

 

void print(const struct coord*c)

{

    printf("[%g,%g,%g]",c->x,c->y,c->z);

}

 

void main()

{

    struct coord c;

    double*cd=reinterpret_cast<double*>(&c); //(1)

    cd[0]=1;

    cd[1]=2;

    cd[2]=3;

    print(&c);

    struct coord* pc=
       reinterpret_cast<struct coord*>(cd); //(2)

    print(pc);}

 

Wynik : [1,2,3][1,2,3]

 

Pierwsze wywołanie operatora (1) rzutuje wskaźnik do struktury struct coord na tablicę typu double*. Modyfikacja danych następuje wewnątrz tablicy. W instrukcji (2) 3-elementowa tablica cd jest z powrotem rzutowana na wskaźnik do struktury.

 

Tego typu implementacja wymaga wiedzy o rozmiarach danych i sposobie ich rozmieszczania przez kompilator. Gdyby w powyższym przykładzie zastąpić double przez short kod dla standardowych opcji pakowania struktur 32-bitowego kompilatora działałby błędnie.

 

Downcasting

Termin downcasting oznacza rzutowanie w dół, czyli konwersję wskaźnika lub referencji do typu bazowego w hierarchii klas do typu potomnego. Określenie „w dół” wynika z tego, że zazwyczaj na diagramach struktur dziedziczenia klasy potomne są umieszczane poniżej klas bazowych.

 

Dlaczego czasem konieczne jest poruszanie się w dół hierarchii dziedziczenia?

 

 

Rzutowanie w dół powinno być zjawiskiem występującym stosunkowo rzadko w dobrze skonstruowanej hierarchii klas.

Zazwyczaj pragnąc dodać jakąś funkcjonalność do istniejącej hierarchii, dodajemy wirtualną metodę w klasie bazowej i przeciążamy ją w klasach pochodnych.

 

Przykład:

1.   Definiujemy pustą wirtualną funkcję Shape::draw() i przeciążamy ją w klasach Circle, Square, Triangle, FilledTriangle.

2.   Definiujemy pustą wirtualną funkcję Shape::flipVertical() i przeciążamy ją w klasie Triangle. (Dla klas Circle i Square zastosowanie metody nie ma sensu, klasa FilledTriangle odziedziczy implementację po Triangle).

3.   Implementujemy metody klasy Drawing

 

Drawing::draw()

{

  for (each shape in shapes )shape.draw()

}

Drawing::flipVertical()

{

  for (each shape in shapes )shape.flipVertical()

}

Zalety

·      Naturalne w językach obiektowych wykorzystanie polimorfizmu.

Wady

·      Zazwyczaj tak prowadzone projektowanie obiektowe prowadzi do bardzo szerokiego interfejsu klasy bazowej stojącej u szczytu hierarchii. Duża grupa klas dziedziczy puste implementacje.

·      W przypadku gotowej hierarchii klas dostarczonej przez zewnętrznego producenta oprogramowania w postaci biblioteki (pliki nagłówkowe i skompilowany plik biblioteczny LIB lub DLL) nie jest możliwe rozszerzanie interfejsu klasy bazowej.

 

Przykład (pseudokod)

Organizacja zapisu rysunku w sytuacji, kiedy brak jest odpowiednich funkcji w hierarchii klas.

writeXX(stream,Circle&);

writeXX(stream,Square&);

writeXX(stream,Triangle&);

 

Drawing::saveAsXX(stream)

{

    for (each shape in shapes) {

       if (shape is Circle)
           writeXX(stream,(Circle&)shape)

       if (shape is Square)
           writeXX(stream,(Square&)shape)

       // itd.

    }

}

 

RTTI[p11]  – Run Time Type Information

RTTI jest mechanizmem, który pozwala na określenie rzeczywistego typu obiektu wskazywanego przez wskaźnik lub referencję typu bazowego.

Możliwe są tu dwie implementacje:

·      jawne wprowadzenie do klas kodu odpowiedzialnego za RTTI

·      obsługa automatyczna oparta na wykorzystaniu informacji o typie zapisanych w VTABLE

 

Przykłady manulanych implementacji RTTI

Wirtualna funkcja zwracająca typ

Jest to jedno z najczęściej spotykanych rozwiązać. Definiujemy zbiór stałych, np.: CIRCLE, SQUARE, TRIANGLE. W klasie bazowej definiujemy czystą wirtualną funkcję

       virtual int getType()const=0;

i przeciążamy ją w klasach potomnych:

       int Circle::getType()const{return CIRCLE;}

    int Triangle::getType()const{return TRIANGLE;}

 

Użycie makr preprocesora

Typowym przykładem są klasy MFC. Informacja o typie może dotyczyć tylko wybranych klas należących do hierarchii rozpoczynającej się wspólną klasą bazową CObject.

 

W plikach nagłówkowych umieszczane są specjalne makra preprocesora.

 

class A : public CObject {

    DECLARE_DYNAMIC(A)

};

 

class B : public A {

    DECLARE_DYNAMIC(B)

};

 

W kodzie (pliku CPP) powinny być dodane kolejne makra definiujące relację pomiędzy klasą bazową i potomną.

 

IMPLEMENT_DYNAMIC(A,CObject)

IMPLEMENT_DYNAMIC(B,A)

 

Dostęp do informacji o typie jest realizowany za pośrednictwem funkcji IsKindOf() klasy CObject.

 

CObject*object = new B();

if( object-> IsKindOf( RUNTIME_CLASS(A) ) ){…}

if( object-> IsKindOf( RUNTIME_CLASS(B) ) ){…}

 

Automatyczna implementacja RTTI

Automatyczna obsługa RTTI pojawiła się w kompilatorach stosunkowo późno, dlatego nie jest jeszcze powszechnie stosowana – najczęściej w bibliotekach można spotkać jawne implementacje RTTI połączone ze statycznymi operatorami rzutowania.

 

Przesłanką za wprowadzeniem automatycznej obsługi RTTI na poziomie języka była powszechność tego typu mechanizmów w różnych bibliotekach (i równocześnie brak kompatybilności pomiędzy rozwiązaniami różnych producentów).

 

Włączenie obsługi RTTI wiąże się z dodatkowym narzutem czasowym i niewielkim wzrostem objętości wygenerowanego kodu.

 

Wbudowana obsługa RTTI jest dostępna za pośrednictwem dwóch mechanizmów:

·      operatora dynamic_cast

·      operatora typeid

Operator dynamic_cast

Operator dynamic_cast jest przeznaczony do bezpiecznego rzutowania w dół hierarchii dziedziczenia (ang. downcasting) lub do rzutowania wskaźnika do typu void*.

 

Składnia:

dynamic_cast<TYPE>(expr),

gdzie TYPE jest typem wskaźnikowym lub referencją; typem wyrażenia expr musi być również typ wskaźnikowy lub referencja.

 

Aby zastosować ten operator muszą być spełnione dwa warunki:

·      Typem wyrażenia expr musi być typ polimorficzny (czyli musi istnieć VTABLE dla tego typu)

·      Zazwyczaj konieczne jest jawne włączenie opcji generacji RTTI w kompilatorze

 

Przykład

 

class Shape

{

   virtual ~Shape(){} // typ polimorficzny

};

 

class Circle:public Shape {};

class Square:public Shape {};

class Triangle:public Shape {};

class FilledTriangle:public Triangle {};

 

void whoAmI(Shape*shape)

{

   if( dynamic_cast<Circle*>(shape)!=0 )
      cout<<"Circle ";

   if( dynamic_cast<Square*>(shape)!=0 )

      cout<<"Square ";

   if( dynamic_cast<Triangle*>(shape)!=0 )

      cout<<"Triangle ";

   if( dynamic_cast<FilledTriangle*>(shape)!=0 )

      cout<<"FilledTriangle ";

}

 

void main()

{

   Shape*table[4];

   table[0]=new Circle();

   table[1]=new Square();

   table[2]=new Triangle();

   table[3]=new FilledTriangle();

   for(int i=0;i<4;i++){

      cout<<"I am a ";

      whoAmI(table[i]);

      cout<<endl;}

   }

 

Rezultat:

I am a Circle

I am a Square

I am a Triangle

I am a Triangle FilledTriangle

 

Operator typeid

Operator typeid pozwala na określenie typu obiektu w trakcie wykonania programu.

 

Składnia:

    typeid(TYPE)

lub

    typeid(expr)

 

 

Operator typeid zwraca stałą referencję typu const type_info & do obiektu klasy type_info. Obiekty te są generowane automatycznie przez kompilator dla każdej klasy po włączeniu opcji użycia RTTI.

 

Argument TYPE powinien być nazwą klasy lub (dla kompatybilności) typem wbudowanym.

 

Typem wyrażenia expr powinna być:

·      referencja

·      wskaźnik, na którym zastosowano operator dereferencji *.
Bez dereferencji rezultatem działania operatora byłaby informacja o typie wskaźnikowym, a nie wskazywanym obiekcie.

·      Typ wyrażenia powinien być typem polimorficznym (zawierającym co najmniej jedną funkcję wirtualną) . W przeciwnym wypadku rezultatem będzie informacja o typie wyrażenia, a nie wskazywanym obiekcie.

 

Klasa type_info zdefiniowana jest w pliku nagłówkowym <typeinfo.h> lub <typeinfo> jako:

 

class type_info {

public:

   virtual ~type_info();

   int operator==(const type_info& rhs) const;

   int operator!=(const type_info& rhs) const;

   int before(const type_info& rhs) const;

   const char* name() const;

   const char* raw_name() const;

private:

   ...

};

 

·      Operatory == i != umożliwiają porównanie typów.

·      Funkacja name() zwraca nazwę typu obiektu, natomiast raw_name() wewnętrzną dekorowaną nazwę tworzoną przez kompilator.

 

Przykłady:

void writeIfCircle(ostream&os,Shape&shape)

{

    if( typeid(shape) != typeid(Circle) )return;

    Circle&circle=static_cast<Circle&>(shape);

    // write circle here

}

 

void whoAmI(Shape*shape)

{

   cout<<typeid(*shape).name();

}

// dla obiektu typu FilledTriangle wypisze:

// wyłącznie ‘FilledTriangle’

 

Podsumowanie

·      Oba operatory zachowują się poprawnie dla typów polimorficznych. Dla innych typów program może nie zostać skompilowany lub ich działanie może być nieoczekiwane.

·      Operator typeid pozwala wyłącznie na określenie rzeczywistego typu, natomiast nie pozwala na stwierdzenie, czy należy on również do klasy umieszczonej w środku hierarchii.

·      Operator dynamic_cast pozwala rzutowanie na poziom pośredni.

·      Oba operatory nie pozwalają na określenie typu lub rzutowanie wskaźnika typu void*. Wskaźnik void* nie ma informacji o typie.

·      Operator typeid działa również dla typów wbudowanych.

·      Jeżeli argumentami operatorów są referencje i nie jest możliwe określenie typu lub przeprowadzenie bezpiecznego rzutowania – oba operatory generują wyjątki. Powinny być one przechwytywane. Alternatywą jest wołanie wyłącznie operatora dynamic_cast dla typów wskaźnikowych.

·      Implementacja informacji o typie jest stosunkowo prosta: dla każdej klasy generowana jest wewnętrzna struktura type_info. VTABLE (tworzona wyłącznie dla typów polimorficznych) zawiera dodatkowy wskaźnik na tę strukturę.

 

 

·      Techniki programowania wykorzystujące rzutowanie w dół oparte na RTTI powinny być stosowane z umiarem, ponieważ prowadzą do kodu wykonywanego warunkowo (wewnątrz instrukcji if-else lub switch-case). Tego typu kod jest trudny w konserwacji i kłopotliwy w przypadku rozszerzeń. Bardzo często może być on wyeliminowany przez użycie funkcji polimorficznych.

 

Wyjątki[ps12] 

Dobrze zaprojektowany program powinien wykrywać i ewentualnie raportować błędne sytuacje, które mają miejsce podczas wykonania.

 

Źródła błędów mogą być różne:

·      błędy programistów,

·      sytuacja w środowisku wykonania (błąd otwarcia pliku, nieoczekiwany koniec pliku, zły format, brak pamięci)

·      błędy lub specjalne akcje użytkownika (np.: użycie przycisku cancel podczas długotrwałej operacji)

 

Tradycyjna obsługa błędów

Tradycyjne mechanizmy obsługi błędów to:

·      wyjście z programu (funkcje exit, abort)

·      powrót z funkcji, zwrot kodu błędu

·      zignorowanie błędu

·      wywołanie funkcji specjalnych (typu raise / signal)

·      wykonanie nielokalnego goto (funkcje setjmp, longjmp)

 

Rozwiązaniem typowym dla języka C jest przekazanie informacji o wystąpieniu błędu za pośrednictwem wartości zwracanej przez funkcję, ewentualnie nadanie globalnej zmiennej, np.: errno, wartości kodu błędu.

 

W rzeczywistości, programy prawie nigdy nie testują wartości zwracanych przez kolejno wołane funkcje – raczej ograniczają się do wybranych, mających istotne znaczenie dla przebiegu sterownia, a także błędów związanych z dostępem do plików i alokacją pamięci.

 

Problemem związanym z obsługą błędów jest nadanie odpowiedniej struktury kodu. Podstawowym celem funkcji jest realizacja wolnego od błędów scenariusza działania i kod odpowiadający takiemu scenariuszowi jest implementowany w pierwszej kolejności.

 

Obsługa alternatywnych scenariuszy dla sytuacji błędnych często jest dodawana później w trakcie uruchamiania i konserwacji, (co nie znaczy, że nie przewiduje się wystąpienia błędów podczas projektowania).

 

Skłania to do definiowania funkcji zwracających dwie podstawowe wartości: informujące o sukcesie lub porażce.

Dobór zwracanych wartości zależy od założonego poziomu dyskryminacji błędów. Na przykład

·      1,true – sukces; 0, false – porażka

·      0 – sukces; wartość niezerowa – szczegółowy kod błędu

 

Implementacja wszystkich funkcji tak, aby zwracały informacje o błędach, dodanie kodu do testowania tych wartości, propagacja informacji zwrotnej o błędach jest bardzo żmudna, powiększa znacznie kod programu i często jest bardzo odległa od zupełności.

 

Mechanizm wyjątków

Wyjątki (ang. exception) są konstrukcją, który ma z założenia ułatwić proces obsługi błędów, pozwalając skupić się na poprawnych scenariuszach wykonania.

 

Użycie wyjątków nie zwalania od testowania występowania błędów i nie zwalania od ich obsługi. Pozwala jednak na użycie mechanizmu automatycznej propagacji informacji o błędach od miejsca stwierdzenia ich wystąpienia do miejsca ich obsługi z pominięciem etapów pośrednich.

 


Generacja wyjątków

Funkcja, która w trakcie wykonania napotka błąd, którego obsługa wykracza poza zakres jej normalnego działania może stworzyć obiekt zawierający informację o błędzie i przesłać go do bliżej nieokreślonego odbiorcy, który będzie w stanie tę informację przechwycić i podjąć odpowiednie działanie. Proces ten nazywany jest generacją (wyrzucaniem, ang. throw) wyjątków.

 

Składnia:

    throw object;

 

Instrukcja throw przypomina pod pewnym względem instrukcje return. Po jej wykonaniu funkcja kończy działanie zwracając pewną wartość.

 

Zwracane wartości mogą w zasadzie być dowolnego typu: wbudowanego (np.: int, unisgned, char*) lub zdefiniowanego przez użytkownika. Najczęściej jednak definiuje się specjalne klasy przeznaczone wyłącznie do przekazywania informacji o błędach. W potocznym języku właśnie te klasy lub obiekty tych klas nazywane są wyjątkami.

 

Przechwytywanie wyjątków

Po wygenerowaniu wyjątku sterowanie przechodzi do bloku instrukcji odpowiedzialnych za obsługę wyjątku (ang. exception handler).

Składnia przypomina nieco definicję funkcji:

 

catch(type t){...}

 

Parametr type jest zazwyczaj typem podstawowym lub referencją; parametr t jest opcjonalny.

 


Kod obsługi wyjątku może być umieszczony wewnątrz funkcji, która wygenerowała wyjątek lub w jednej z wołających ją funkcji wyższego poziomu. Musi być umieszczony po bloku try postaci:

 

       try{...}

 

Blok try definiuje standardowy scenariusz wykonania funkcji. Funkcja „próbuje” wykonać blok try. Jeżeli jedna z wołanych funkcji wyrzuci wyjątek i odpowiadający mu blok catch jest zdefiniowany w funkcji, wówczas sterowanie przejdzie do tego bloku:

 

try{

    loadData(filename);

}

catch(OpenError e){

    … // handle OpenError

}

catch(ReadError e){

    … // handle ReadError

}

 

 

 

Po zgłoszeniu wyjątku przerywany jest normalny tryb wykonywania programu i następuje przeskok sterowania do bloku handlera wyjątku. Podczas przejścia zwalniany jest stos, na którym umieszczane były argumenty wywołania poszczególnych funkcji, adresy powrotu i obiekty automatyczne.

 

·      Funkcja f5 generuje wyjątek E

·      Stos wywołań funkcji jest przeglądany w poszukiwaniu pierwszego handlera zdolnego obsłużyć wyjątek E. (Analogiczny handler może być umieszczony w funkcji wyższego poziomu, jednakże nie zostanie on osiągnięty.)

·      Następuje przeskok do bloku catch(E) w funkcji f1. Zwalniana jest pamięć przydzielona na stosie podczas wykonania funkcji f5,f4,f3,f2 i bloku try w funkcji f1. Przy zwalnianiu pamięci wołane są destruktory obiektów automatycznych.

·      Mechanizm oczyszczania stosu nie pozwala niestety na usuwanie obiektów stworzonych dynamicznie.

 

 

Przykład

class A

{

public:

    A(){cout<<"A"<<" ";}

    ~A(){cout<<"~A"<<" ";}

};

 

class Vector

{

    int*buf;

    int size;

public:

    class OutOfBounds{};

    Vector(int s=0):
       size(s>0?s:0), // size
³ 0

       buf(s>0?new int[s]:0)

       {if(!buf)size=0;}

    int&operator[](int i){

       if(i<0 || i>= size)throw OutOfBounds();

       return buf[i];

    }

};

 

void main()

{

    Vector v(10);

    try{

       A a;

       // A *pa = new A(); // nie usunięty

       int index = -1;

       cin>>index;

       cout<<v[index];

    }

    catch(Vector::OutOfBounds){

       cerr<<"Invalid index"<<endl;

    }

}

Input:

-1

Wynik

A ~A Invalid index

 

·      Funkcja main odczyta wartość indeksu wektora i wypisze element na ekranie. Standardowy scenariusz działania zawarty jest w bloku try.

·      W przypadku wartości indeksu spoza zakresu generowany jest wyjątek typu Vector::OutOfBounds

·      Wystąpienie wyjątku powoduje przeskok z kontekstu funkcji Vector::operator[]do bloku obsługującego wyjątek: catch(Vector::OutOfBounds){...}

·      Przejściu do bloku handlera towarzyszy oczyszczanie stosu – wołany jest destruktor ~A() obiektu a.

 

Mechanizm generacji i przechwytywania wyjątków może być użyty jako alternatywny mechanizm sterowania. Obsługa wyjątków jest jednak mniej efektywna, niż powrót z funkcji i zwrot wartości, z tego powodu nie powinna być nadużywana – zwłaszcza tam, gdzie możliwe jest obsłużenie błędów wewnątrz funkcji.

 

Nieobsłużone wyjątki

Jeżeli na stosie wywołań brak jest funkcji zawierającej handler danego typu wyjątków, wówczas uruchamiany jest mechanizm awaryjny: wołana jest funkcja: terminate(). Standardowa implementacja terminate() woła funkcję abort() – powoduje ona wyjście z programu.

 

Funkcja terminate() może być zastąpiona własną funkcją, która na przykład zapisze ważne dane lub wywoła powtórną inicjalizację systemu.

Typy wyjątków

W języku C++ można generować wyjątki dowolnego typu.

Argumentem instrukcji throw jest obiekt, a nie typ lub klasa.

 

Wyjątek jest przechwytywany na podstawie typu. W funkcji może być zdefiniowany dokładnie jeden handler dla wyjątków danego typu.

Obiekty przesyłane do handlera wyjątków mogą nieść dodatkowe informacje:

 

class FileNotFound

{

public:

    FileNotFound(const char*_name)

    {

       strcpy(name,_name);

    }

    char name[_MAX_PATH];

};

 

void read(const char*name)

{

    FILE*file=fopen(name,"rt");

    if(!file)throw FileNotFound(name);

    //…

    fclose(file);

}

 

void main()

{

    try{

       read("test.txt");

    }

    catch(FileNotFound e){

       printf("File not found;%s\n",e.name);

    }

}

 

Grupowanie

Wyjątki związane z określonym typem błędów często grupuje się tak, aby móc je przetwarzać za pomocą wspólnego handlera.

 

Przykład1

enum Matherr {overflow, underflow, divbyzero, }

 

try{

}

catch(Matherr m){

    switch(m){

       case overflow:

       //…

       case underflow:

       //…

    }

}

 

Przykład2

class Matherr{};

class Overflow : public Matherr{};

class Underflow : public Matherr{};

class Divbyzero : public Matherr{};

 

try{

}

catch(Divbyzero){

    // handle Divbyzero only

}

catch(Matherr){

    // handle other Matherr

}

 

Drugi przykład pokazuje grupowanie poprzez dziedziczenie. Jest to bardzo często stosowana praktyka, ponieważ umożliwia twórcom bibliotek łatwe rozszerzanie zbioru wyjątków przez dodawanie nowych klas do istniejącej hierarchii.

 

W wielu przypadkach kod użytkownika przechwytujący wyjątki generowane w starej wersji biblioteki będzie w stanie obsłużyć nowe typy wyjątków posługując się handlerem dla klasy bazowej.

 

Przykład:

class Base

{

public:

virtual const char*what()const{return "Base";}

};

class Derived : public Base

{

public:

const char*what()const{return "Derived";}

};

 

void f(){ throw Derived(); }

 

void test1()

{

    try{

       f();

    }

    catch(Base e){

       cout<<e.what()<<endl;

    }

}

 

Wynik

Base

 

Implementacja handlera wyjątków, którego parametrem jest typ bazowy powoduje zatracenie dodatkowych informacji zdefiniowanych w klasie pochodnej.

 

Jeżeli hierarchia wyjątków zapewnia wirtualne funkcje o charakterze informacyjnym, np.: what(), wówczas, aby skorzystać z tych informacji, należy użyć handlera o parametrze typu referencyjnego.

 

void test2()

{

    try{

       f();

    }

    catch(Base& e){

       cout<<e.what()<<endl;

    }

}

 

Wynik

Derived

 

Przechwytywanie wszystkich wyjątków

Możliwa jest definicja handlera, który będzie przechwytywał wszystkie wyjątki. Ma on postać:

 

catch(…){

// cout<<”an exception occurred”<<endl

}

 

Powtórne wyrzucenie wyjątków

Instrukcja throw umieszczona wewnątrz bloku catch powoduje powtórne wyrzucenie przechwyconego wyjątku.

 

catch(E){

    if(can_handle){

    // handle it

    }else

    throw;

}

 

 

[ps13] Wyjątki w konstruktorach

Możliwość pojawienia się wyjątków w konstruktorach obiektów jest największą słabością mechanizmu obsługi wyjątków. Jeżeli wyjątek został zgłoszony w konstruktorze, wówczas obiekt nie został w pełni stworzony i podczas oczyszczania stosu nie zostanie wywołany destruktor obiektu.

Wywołane zostaną natomiast destruktory obiektów będących atrybutami częściowo zainicjowanego obiektu złożonego.

 

Przykład

class A{public: ~A(){cout<<"~A ";} };

class B{public: ~B(){cout<<"~B ";} };

 

class Composite

{

    A a;

    B b;

public:

    Composite():a(),b()

    {

       throw 0;

    }

    ~Composite(){cout <<"~Composite ";}

};

 

void main()

{

    try{

       Composite c;

 

    }catch(int)    {

       cout<<"Exception"<<endl;

    }

}

 

Wynik

~B ~A Exception

 

Problem pojawia się, jeżeli przed wygenerowaniem wyjątku tworzony obiekt uzyskał dostęp do pewnych zasobów, np.: został otwarty plik lub przydzielona pamięć na stercie. Tego typu zasoby nie będą możliwe do odzyskania.

 

Zalecaną techniką jest opakowanie żądanych zasobów klasami i ich alokacja w postaci atomowych obiektów – atrybutów.

 

class BadDesign

{

    int*buf;

public:

    BadDesign(int size)

    {

       buf=new int[size];

       throw 0;

    }

};

 

class IntVector

{

public:

    IntVector(int size):b(size>0?new int[size]:0){}

    ~IntVector(){if(b)delete b;}

    int*buf(){return b;}

    int*b;

};

 

class GoodDesign

{

    IntVector buf;;

public:

    GoodDesign(int size):buf(size)

    {

       throw 0;

    }

};

 

 

Specyfikacja wyjątków[p14] 

Typowa specyfikacja funkcji obejmuje typ zwracanej wartości oraz typy argumentów. Dane te są wystarczające, aby poprawnie wywołać funkcję, jednak specyfikacja nie dostarcza informacji o typach generowanych wyjątków.

 

Język C++ umożliwia rozszerzenie specyfikacji funkcji o listę potencjalnie generowanych wyjątków. Tego typu informacje mogą być istotne dla użytkownika funkcji bibliotecznych, ponieważ umożliwiają tworzenie bezpiecznego kodu obsługującego wszystkie rodzaje wyjątków generowanych wewnątrz biblioteki.

 

Przykłady

class Ex1;

class Ex2;

void f() throw (Ex1,Ex2);

Prototyp funkcji f() informuje, że może ona generować wyjątki klas Ex1, Ex2.

 

void g() throw ();

Funkcja g() nie generuje żadnych wyjątków.

 

void h();

Prototyp nie dostarcza informacji o generowanych wyjątkach. Oznacza to, że funkcja może potencjalnie generować wszystkie wyjątki.

 


W języku Java występuje analogiczna konstrukcja (throws), jednak ma ona charakter znacznie bardziej obligatoryjny. Funkcja nadrzędna wołająca funkcję f() powinna dostarczyć handlery zdolne przechwycić wyjątki Ex1 i Ex2 lub zadeklarować, że może je odsyłać dalej.

 

class Ex1{};

class Ex2{};

 

void f() throw (Ex1,Ex2)

{

    //…

}

 

void foo1()

{

    try{

       f();

    }

catch(Ex1){}

catch(Ex2){}

}

 

void foo2() throw (Ex1,Ex2)

{

    f();

}

 

void foo3()

{

    f();

}

 

Pomijając drobne różnice składni, kompilator języka Java nie skompilowałby funkcji foo3(), kompilator C++ skompiluje wszystkie funkcje.


Kontenery[ps15] 

Kontenery są obiektami umożliwiającymi:

·      grupowanie (agregację) innych obiektów

·      dostęp sekwencyjny lub swobodny do zawartych w nich elementów.

 

 

 

Typowe kontenery to:

·      wektor (ang. vector)

·      lista (ang. list)

·      kolejka (ang. queue)

·      stos (ang. stack)

·      zbiór i wielozbiór (ang. set, multiset)

·      słownik (ang. map, multimap)

 

Wybór typu kontenera jest uzależniony od założonego sposobu dostępu do elementów i strategii umieszczania elementów w kontenerze:

·      wektor jest najbardziej efektywny, jeżeli w momencie jego kreacji znana jest liczba elementów i alokujemy dla niego pamięć w jednym wywołaniu new/malloc

·      lista jest efektywna, jeżeli często dodajemy i usuwamy pojedyncze elementy

·      efektywna implementacja zbioru to zazwyczaj drzewo, konieczne są funkcje do porównywania elementów

·      słownik zakłada, że do realizacji dostępu do elementów posługujemy się odwzorowaniem klucz®wartość

 

Przykład -- klasa List

Przedstawiony przykład to jeden z bardziej typowych kontenerów – lista jednokierunkowa przechowująca wartości całkowite.

 

 

 

 

class ListElement

{

public:

    ListElement*next;

    int value;

};

 

class List

{

public:

    ListElement*start;

    ListElement*end;

 

    List();

    ~List();

    void pushFront(int v);

    void pushBack(int v);

    void insertAt(int where,int v);

    void deleteFront();

    void deleteBack();

    void dump()const;

};

 

 


List::List():start(0),end(0){}

 

void List::pushFront(int v)

{

    ListElement *le = new ListElement();

    le->value=v;

    le->next=start;

    start=le;

    if(end==0)end=start;

}

void List::pushBack(int v)

{

    ListElement *le = new ListElement();

    le->value=v;

    le->next=0;

    if(end)end->next=le;

    end=le;

    if(start==0)start=end;

}

 

void List::insertAt(int where,int v)

{

    ListElement *le = new ListElement();

    le->value=v;

 

    ListElement*ip=0;

    int count=0;

    for(ListElement*i=start; i!=0 && count<where;
             i=i->next,count++){

        ip=i;

    }

 

    if(ip){

        le->next=ip->next;

        ip->next=le;

    }else{

        le->next=start;

        start=le;

    }

    if(end==0)end=start;

    if(le->next==0)end=le;

}

 

 

void List::deleteFront()

{

    if(!start)return;

    ListElement*todel=start;

    start=start->next;

    delete todel;

    if(start==0)end=0;

}

void List::dump()const

{

    cout<<"[ ";

    for(ListElement*i=start;i!=0;i=i->next)
        cout<<i->value<<" ";

    cout<<"]"<<endl;

}

 

 

List::~List()

{

    while(start!=0)deleteFront();

}

 

void List::deleteBack()

{

    ListElement*newEnd=0;

    for(ListElement*i=start;i!=0;i=i->next){

        if(i->next!=0 && i->next==end)newEnd=i;

    }

    delete end;

    end = newEnd;

    if(end!=0)end->next=0;

    if(end==0)start=end;

}

 

Test

void main()

{

    List l;

    for(int i=0;i<5;i++){l.pushFront(i);l.pushBack(i+5);}

    l.insertAt(5,100);

    l.insertAt(0,100);

    l.insertAt(1000,100);

 

    l.dump();

    for( i=0;i<6;i++){

    l.deleteBack();

    l.deleteFront();

    l.dump();

    }

}

 

Wynik

[ 100 4 3 2 1 0 100 5 6 7 8 9 100 ]

[ 4 3 2 1 0 100 5 6 7 8 9 ]

[ 3 2 1 0 100 5 6 7 8 ]

[ 2 1 0 100 5 6 7 ]

[ 1 0 100 5 6 ]

[ 0 100 5 ]

[ 100 ]

Problemy

Programista jest zazwyczaj w stanie zaimplementować kontener zdolnego grupować elementy danego typu. Przy praktycznych implementacjach pojawiają się jednak problemy projektowe:

 

1.   W jaki sposób zapewnić wielokrotne wykorzystanie (ang. reuse) raz stworzonego kodu kontenera?

2.   Czy kontener składuje element tego samego typu czy różnych typów?

3.   Czy kontener odpowiada za destrukcję zawartych w nim elementów?

4.   Czy na kontenerach wykonywane są operacje kopiowania i przypisania?


Wielokrotne użycie

Implementacje kontenerów danego rodzaju (np.: list, wektorów) różniących się jedynie typem przechowywanych obiektów są najczęściej bardzo podobne.

 

W języku C++ można wskazać co najmniej trzy techniki wielokrotnego wykorzystania stworzonego kodu kontenera.

·      kopiowanie kodu

·      skorzystanie z dziedziczenia

·      użycie szablonów

 

Kopiowanie kodu

Kopiowanie kodu i zmiana typów przechowywanych obiektów jest techniką najprostszą. Ze względu na konieczność ręcznej modyfikacji kodu łatwo jest wprowadzić wiele błędów. Technika powiększa rozmiary kodu wykonywalnego programów.

Dziedziczenie

Jest to rozwiązanie typowe dla języków obiektowych typu SmallTalk, Java, a także dla wczesnych implementacji bibliotek w C++.

Zakłada się, że drogą do wielokrotnego użycia kodu kontenera jest dziedziczenie.

 

1.   Kontener implementowany jest w ten sposób, by przechowywać obiekty klasy bazowej (Object, CObject, itd.). Kontener przechowuje wskaźniki do elementów.

2.   Sam kontener jest również klasą potomną klasy Object, stąd możliwa jest implementacja skomplikowanych struktur, w których kontenery mogą zawierać inne kontenery.

3.   Biblioteka dostarczana jest w postaci gotowego zestawu klas. Czasem stanowią one część języka (SmallTalk, Java).

4.   Chcąc umieścić własne dane w kontenerze należy stworzyć klasę dziedziczącą po klasie Object lub innej klasie, której przodkiem jest Object.

 

Konsekwencje

1.   Ta technika projektowania kontenerów promuje użycie biblioteki klas o wspólnym korzeniu, który stanowi klasa Object. Jest to tak zwana hierarchia obiektowa (ang. object-based hierarchy)

2.   W językach typu SmallTalk lub Java praktycznie każdy typ danych, nawet jeśli nie jest to wyspecyfikowane jawnie, dziedziczy po klasie Object. Nie jest możliwe dziedziczenie wielobazowe. Stąd obiekt każdej klasy może być umieszczony w kontenerze.

3.   Wielu dostawców kompilatorów C++ zaproponowało swoje hierarchie obiektowe. Przykładem może być biblioteka MFC, która w ciągu około 15 lat rozrosła się do bardzo dużych rozmiarów.

 

Przykład Fragment (około 1/4) hierarchii klas MFC

 

 

Problemy

Wykorzystanie oferowanej przez producentów kompilatorów hierarchii klas jest efektywne, jeżeli:

·      projekt realizowany wyłącznie dla jednej platformy (np.: Windows)

·      projekt nie zawiera fragmentów obiektowego kodu przygotowanego dla innych platform lub pochodzących od innego dostawcy.

W przeciwnym przypadku konieczne są różne, nieraz sztuczne adaptacje.

Przykład

Jeżeli nie chcemy modyfikować istniejącej hierarchii Shape, wówczas, pragnąc umieścić obiekty w bibliotecznym kontenerze CList musimy dodać klasę CShape pełniącą rolę adaptera. Powinna ona zawierać wskaźnik typu Shape*.

 


Klasa Vector

class Object

{

public:

    virtual ~Object(){}

};

 

class Vector : public Object

{

protected:

    Object**start;

    Object**end;

    int capacity;

public:

    Vector(){start=end=0;capacity=0;}

    virtual ~Vector(){if(start)delete start;}

    int getSize()const{return end-start;}

    int getCapacity()const{return capacity;}

   

    bool reserve(int newCapacity);

    bool pushBack(Object*object);

    Object*get(int index)const

    {

       if(index<0 || index>=(end-start))return 0;

       return start[index];

    }

    friend class Iterator;

};

 


 

bool Vector::pushBack(Object*object)

{

    if(capacity==getSize() &&
    !reserve(capacity+16))return false;

    *end=object;

    end++;

    return true;

}

 

bool Vector::reserve(int newCapacity)

{

    if(newCapacity < capacity)return false;

    Object**tmp=new Object*[newCapacity];

    if(capacity){

        memcpy(tmp,start,capacity*sizeof(Object*));

       delete []start;

    }

    end=tmp+(end-start);

    capacity=newCapacity;

    start=tmp;

    return true;

}

 

 


Przedstawiony przykład kontenera:

·      Może grupować obiekty różnych typów, pod warunkiem, że należą one do klas pochodnych po Object.

·      Nie usuwa obiektów podczas destrukcji, stąd użytkownik powinien zadbać o to w implementując dodatkowy kod. Akceptowalnym rozwiązaniem jest dziedziczenie – stworzenie klasy potomnej rozszerzającej kod o destruktor.

 

class OwningVector : public Vector

{

public:

    ~OwningVector(){

       for(int i=0;i<getSize();i++)delete get(i);

    }

};

 

·      Jako klasa alokująca pamięć, kontener nie jest poprawnie zabezpieczony przed naruszeniem spójności danych w wyniku użycia konstruktora kopiującego lub operatora przypisania.

·      Jego wadą jest konieczność dodatkowych adaptacji (klasa Int), przy umieszczaniu w kontenerze typów prostych.
(W języku Java dla wbudowanych typów prostych zdefiniowane są analogi obiektowe.)

 

class Int: public Object

{

public:

    int value;

    Int(int v=0){value=v;}

};

 

void main()

{

    Vector v;

    for(int i=0;i<20;i++)v.pushBack(new Int(i));

   

    for(i=0;i<v.getSize();i++){

        Int*pint=dynamic_cast<Int*>(v.get(i));

        if(pint)cout<<pint->value<<endl;

    }

}

Iteratory

Iteratory są specjalnymi klasami przeznaczonymi do realizacji sekwencyjnego dostępu do elementów kontenera.

 

[W języku Java iterator występuje pod nazwą Enumeration.]

 

Typowy interfejs iteratora obejmuje

·      inicjalizację wiążącą iterator z kontenerem

·      funkcje do przesuwania iteratora, tak aby wskazywał następny element.

·      testowanie, czy iterator nie osiągnął końca kontenera

·      dostęp do aktualnie wskazywanego elementu

 

Przykład

class Iterator

{

    const Vector&vector;

    Object**current;

public:

    Iterator(const Vector&v)
    :vector(v),current(v.start) {}

    Iterator&operator++(){current++;return *this;}

    Iterator&operator++(int){current++;return *this;} // operator postfiksowy może być lepiej
    // zaimplementowany

    operator bool()const{return current<vector.end;}

    Object*get()const{return *current;}

};

 

void main()

{

    Vector v;

    for(int i=0;i<20;i++)v.pushBack(new Int(i));

   

    for(Iterator it(v);it;it++){

       Int*pint=dynamic_cast<Int*>(it.get());

       if(pint)cout<<pint->value<<endl;

    }

}

 

Podstawowym celem iteratorów jest zapewnienie wspólnego interfejsu oraz ukrycie implementacji kontenera.

·      Za pomocą tego samego interfejsu możliwa jest iteracja po elementach listy, wektora, słownika, zbioru, itp. Ułatwia to zmianę typu użytego kontenera, ponieważ kod realizujący dostęp pozostaje bez zmian.

·      Przedstawiony tu przykład przesuwa wskaźnik na następny element tablicy. Iterator listy będzie przesuwał wskaźnik na następny element listy.

·      Bardzo często iteratory są implementowane jako wewnętrzne klasy kontenera. W samych kontenerach umieszczane są funkcje składowe tworzące iteratory odpowiedniego typu:

 

Vector::Iterator Vector::getIterator()

{

    return Iterator(*this);

}

 

Kontenery w roli właścicieli obiektów[ps16] 

Kontener, który może stać się argumentem konstruktora kopiującego lub operatora przypisania jest traktowany jako właściciel obiektów.

 

Powinien on:

·      odpowiadać za usuwanie elementów przy destrukcji kontenera

·      być w stanie sporządzić kopię zawartości kontenera będącego argumentem konstruktora kopiującego lub operatora przypisania.

 

 

Przykład:

 

Vector v1;

for(int i=0;i<2;i++)v1.pushBack(new Int(i));

Vector v2=v1;

v2.pushBack(new Int(2));

 

 

 

 

v2=v1;

v2=v1;

v2.pushBack(new Int(2));

 

 

Możliwe są tu dwa rozwiązania:

·      Specjalizacja kontenera. Kontener posiada dodatkową informację o rzeczywistym typie przechowywanych obiektów i na tej podstawie jest w stanie sporządzić kopie indywidualnych elementów.

·      Klonowanie. Interfejs obiektów zawartych w kontenerze jest rozszerzony tak, aby każdy obiekt był w stanie sporządzić swoją kopię.

 

Specjalizacja kontenera

Wiedząc, że kontener będzie przechowywał obiekty określonego typu, np.: Int, tworzymy wyspecjalizowany kontener dziedziczący po kontenerze podstawowym.

 

 

 

class IntVector : public Vector

{

protected:

    void copy(const IntVector&other);

    void free();

public:

    IntVector(){}

    IntVector(const IntVector&other){copy(other);}  

    ~IntVector(){free();}

    IntVector&operator=(const IntVector&other)

    {

       if(&other!=this){

           free();

           copy(other);

       }

       return *this;

    }

};

 


void main()

{

    IntVector v;

    for(int i=0;i<5;i++)v.pushBack(new Int(i));

    IntVector v2=v;

    v2.pushBack(new Int(100));

    for(i=0;i<v.getSize();i++){

       Int*pint=dynamic_cast<Int*>(v.get(i));

       if(pint)cout<<pint->value<<" ";

    }

    cout<<endl;

    for(i=0;i<v2.getSize();i++){

       Int*pint=dynamic_cast<Int*>(v2.get(i));

       if(pint)cout<<pint->value<<" ";

    }

}

 

Rezultat:

0 1 2 3 4

0 1 2 3 4 100

 

Implementacje funkcji copy

void IntVector::copy(const IntVector&other)

{

    start=end=0;capacity=0;

    if(other.getSize()){

       start = new Object*[other.capacity];

       capacity=other.capacity;

       for(int i=0;i<other.getSize();i++){

           start[i]=new Int(*(Int*)other.get(i));

       }

       end= start+other.getSize();

    }

}

 

W operator rzutowania (Int*) w funkcji copy() może zostać zastąpiony przez static_cast<Int*>(). Realizuje on rzutowanie w dół hierarchii (ang. downcasting). Funkcja free() powinna usuwać wszystkie obiekty i zwalniać tablicę start.

 

Kontenery przechowujące obiekty różnego typu

Powyższa technika jest efektywna, jeżeli w kontenerze przechowywane są elementy tego samego typu.

Jeżeli obiekty należą do pewnej hierarchii klas, wówczas implementacja funkcji kopiującej zawartość drugiego kontenera copy musi określić typ rzeczywistego obiektu przed sporządzeniem jego kopii. Może to prowadzić do rozbudowanych instrukcji warunkowych realizowanych podczas rzutowania w dół hierarchii.

 

 

class ShapeVector : public Vector

{

protected:

    void copy(const ShapeVector&other);

    void free();

    Object*cloneObject(const Object*);

public:

    ShapeVector (){}

    ShapeVector (const ShapeVector &other){
           copy(other);
    }  

    ~ShapeVector (){free();}

    ShapeVector &operator=(const ShapeVector &other) {

       if(&other!=this){free();  copy(other);}    return *this;

    }

};

 

Funkcje copy i cloneObject

void ShapeVector::copy(const ShapeVector &other)

{

    start=end=0;capacity=0;

    if(other.getSize()){

       start = new Object*[other.capacity];

       capacity=other.capacity;

       for(int i=0;i<other.getSize();i++){

           start[i]=cloneObject(other.get(i));

       }

       end= start+other.getSize();

    }

}

 

Object*ShapeVector::cloneObject(const Object*object)

{

    if(typeid(*object)==typeid(Circle))

       return new Circle(*(Circle*)object);

    if(typeid(*object)==typeid(Triangle))

       return new Triangle(*(Triangle*)object);

    if(typeid(*object)==typeid(Square))

       return new Square(*(Square*)object);

    return 0;

}

 

Funkcja cloneObject odpowiada za sporządzenie kopii obiektu. Ponieważ twórca kontenera wie z góry, jakie klasy budują hierarchię Shape, może skonstruować kod, w którym testowany jest rzeczywisty typ obiektu wskazywanego przez parametr funkcji object i po przeprowadzeniu rzutowania sporządzana jego kopia.

 

Użycie funkcji wymaga włączenia RTTI. Alternatywnie w klasie Shape można zdefiniować polimorficzną funkcję getType() zwracającą stałą całkowitą identyfikującą typ i skonstruować funkcję w postaci instrukcji switch-case.

 

Wadą tego rozwiązania jest konieczność modyfikacji kodu kontenera, w przypadku, kiedy hierarchia klas jest rozszerzana.

Przykładowe użycie

void main()

{

    ShapeVector v;

    v.pushBack(new Circle());

    v.pushBack(new Triangle());

    v.pushBack(new Square());

    ShapeVector v2=v;

    v2.pushBack(new Circle);

 

    for(int i=0;i<v.getSize();i++){

        Shape*shape=dynamic_cast<Shape*>(v.get(i));

       if(shape)shape->dump();

    }

    cout<<endl;

    for(i=0;i<v2.getSize();i++){

        Shape*shape=dynamic_cast<Shape*>(v2.get(i));

       if(shape)shape->dump();

    }

}

 

Rezultat:

Circle Triangle Square

Circle Triangle Square Circle

 

Klonowanie

Przedstawiony wcześniej przykład pokazywał wyspecjalizowany kontener, który znając typ swoich elementów był w stanie stworzyć ich kopię. Do tego celu konieczne było rzutowanie wskaźników w dół hierarchii (downacasting).

 


Rozwiązanie alternatywne polega na umieszczeniu w interfejsie klasy Object polimorficznej funkcji:

 

    virtual Object*clone()const;

 

za pomocą której kontener będzie w stanie sporządzić kopie dowolnych obiektów.

Funkcja ta powinna zostać przedefiniowana dla poszczególnych klas hierarchii, np.:

 

Object*Circle::clone()const

{

    return new Circle(*this);

}

 

Object*Square::clone()const

{

    return new Square (*this);

}

 

Funkcje typu free(), copy(), konstruktor kopiujący, operator przypisania mogą być bezpośrednio zaimplementowane w klasie kontenera, ponieważ znajomość typu przechowywanych obiektów nie jest już wymagana do stworzenia poprawnej implementacji.

 

void Vector::copy(const Vector &other)

{

    start=end=0;capacity=0;

    if(other.getSize()){

       start = new Object*[other.capacity];

       capacity=other.capacity;

       for(int i=0;i<other.getSize();i++){

           start[i]=other.get(i)->clone();

       }

       end= start+other.getSize();

    }

}

 


Zalety

·      Nie jest konieczne tworzenie wyspecjalizowanych kontenerów typu IntVector, ShapeVector. Podstawowa implementacja kontenera zapewnia wystarczającą funkcjonalność.

·      Kontener może grupować obiekty dowolnego typu, pod warunkiem, że należą do klas potomnych klasy Object.

·      Kontener może być argumentem operatora przypisania i konstruktora kopiującego.

Wady

·      Rozwiązanie wymaga wprowadzenia kolejnej funkcji do interfejsu klasy Object.

·      Zdefiniowanie funkcji clone jako czystej funkcji wirtualnej może okazać się bardzo niewygodne. Każda klasa hierarchii będzie musiała zapewnić jej implementację (nawet jeżeli nie będzie nigdy jej wykorzystywała).

Inne rozwiązania

·      W klasie Object można zdefiniować standardową implementację funkcji clone jako:

 

    Object* Object::clone()const{
       throw CloneNotSupported;

    }

   

Jeżeli klasa nie przeciąży tej funkcji, wówczas przy próbie wywołania funkcji clone zostanie wygenerowany wyjątek.

·      Funkcja clone może być zrealizowana począwszy od pewnego miejsca w hierarchii, np.: klasy Shape. Równocześnie używane będzie specjalizowany kontener, który będzie rzutował wskaźniki typu Object* do Shape* i dla nich wywoływał funkcję clone.

·      Podstawową wersję kontenera można również zabezpieczyć przed przypadkowym wywołaniem niezaimplementowanego konstruktora kopiującego lub operatora przypisania:

 

Vector::Vector(const Vector&) {
       throw CloneNotSupported;

    }

   


Szablony

Szablony (ang. templates) są mechanizmem, który w języku C++ zyskał bardzo szerokie uznanie, zwłaszcza po pojawieniu się około 1995 roku standardowej biblioteki C++, znanej także jako STL (Standard Template Library). Kontenery skonstruowane jako szablony są alternatywnym rozwiązaniem w stosunku do kontenerów obiektowych.

 

Jak łatwo zauważyć, kontenery obiektowe są bardzo niewydajne, jeżeli chcielibyśmy w nich składować elementy typów prostych (jak: int, char, double). Równocześnie klasy implementujące funkcjonalność wektora elementów typu char, int, itd. są bardzo przydatne jako reprezentacje napisów, zbiorów, tablic o rozmiarach ustalanych w trakcie wykonania programu.

 

Idea użycia szablonów jest nieco podobna do wykorzystania makr preprocesora. W pierwszym wydaniu książki C++ Programming Lanuguage, Stroustrup zaproponował implementację kontenerów właśnie za pomocą makr preprocesora.

 

Rozwiązanie to nie zostało w praktyce zaakceptowane, ale stało się podstawą do opracowaniu mechanizmu szablonów i włączenia go do specyfikacji języka.

 

Szablony są reprezentują zupełnie odmienne podejście do wielokrotnego użycia kodu – zamiast mechanizmu dziedziczenia następuje wielokrotne użycie kodu źródłowego. Kontenery zdefiniowane w postaci szablonów nie zawierają wskaźników do obiektów potomnych klasy Object, ale nieokreślone parametry, które w momencie użycia szablonu są zastępowane przez kompilator odpowiednimi wartościami typów lub wartościami liczbowymi.

 

Makra preprocesora

Makra preprocesora pozwalają na wielokrotnie wstawienie do kodu instrukcji, których działanie może przypominać wywołanie funkcji lub procedury.

Podczas działanie preprocesora następuje substytucja tekstu bez sprawdzania typów. Pozwala to na zdefiniowanie wzorców kodu, który może działać dla różnych typach danych.

 

Przykład:

#define min(a,b)  (a<b?a:b)

 

#define sum(table,size,result)              \

{                                           \

    for(int i=0;i<size;i++)result+=table[i]; \

}

 

#define duplicate(table,size,T,result)          \

{                                               \

    result=new T[size];                          \

    for(int i=0;i<size;i++)result[i]=table[i];    \

}

 

int x=1,y=2;

int m= min(x,y) ;

 

float t[3]={1.1F,200,-45};

float result=0;

sum(t,3,result);

float *tcopy;

duplicate(t,3,float,tcopy);

 

char s[]="Ala ma kota";

char*copy;

duplicate(s,strlen(s)+1,char,copy);

 

·      Makro min będzie działało poprawnie dla dowolnego typu, dla którego zdefiniowany jest operator <.

·      Makro sum dla typu, który definiuje operator +=

·      Makro duplicate, dla typu ze zdefiniowanym operatorem przypisania.

 

Składnia szablonów

Szablony oferują znacznie większe możliwości, niż makra preprocesora. Pozwalają na zdefiniowanie parametryzowanych wzorców funkcji i klas.

 

Składnia

template < [typelist] [, [ arglist ]] > declaration,

 

typelist – jest listą typów użytych w szablonie; definicje typów oddzielone są przecinkami i mają postać:

       class identifier lub
       typename identifier

 

arglist – jest listą argumentów (analogiczną do argumentów funkcji)

declaration – jest deklaracją funkcji lub klasy, w której zazwyczaj używane są identyfikatory zdefiniowane w liście typów i argumentów.

 

Przykład

template<class T, int size>

class Array

{

public:

    T buffer[size];

    operator const T*()const{return buffer;}

    operator T*(){return buffer;}

};

 

 

Instancjacja szablonów

·      Wywołanie funkcji lub utworzenie obiektu klasy zdefiniowanej w szablonie wymaga podania parametrów szablonu: nazw typów lub wartości argumentów (obiektów lub wartości liczbowych).

·      Kod funkcji i definicje klas nie są generowane w trakcie wykonania programu, lecz w trakcie kompilacji, na podstawie statycznej analizy typów parametrów użytych w wywołaniu.

·      Proces przypisywania parametrów szablonowi nazywany instancjacją szablonu. Dla każdego zestawu użytych parametrów generowana jest pojedyncza instancja kodu funkcji lub klasy.

 

Przykład:

void main()

{

    Array<char,256> string;

    strcpy(string,"Text");

    cout<<(const char*)string<<endl;

}

 

Szablony funkcji

Szablon funkcji definiuje nieskończony zbiór funkcji, którego elementy mogą zostać utworzone w wyniku instancjacji.

 

W definicji szablonu funkcji składowa declaration jest deklaracją funkcji parametryzowaną listą typów i argumentów szablonu;

 

Wygenerowane funkcje są dołączane do kodu wynikowego programu.

 

Przykłady:

template<class T>

T sum(const T*table, int size)

{

    T result;

    for(int i=0;i<size;i++)result+=table[i];

    return result;

}

 

template<class T>

T*duplicate(const T*table, int size)

{

    T*result=new T[size];

    for(int i=0;i<size;i++)result[i]=table[i];

    return result;

}

 

template<class T>

T min(T a, T b)

{

    return a<b?a:b;

}

 

Instancjacja

Proces instancjacji (czyli generowania kodu funkcji) może następować automatycznie w wyniku znalezienia przez kompilator jej wywołania. Typ funkcji jest określony na podstawie typu użytych argumentów.

 

void main()

{

    float t[3]={1.1F,200,-45};

    float result= sum(t,3);

    // sum<float>(t,3)

    float*fc=duplicate(t,3);

    // duplicate<float > (t,3)

    char s[]="Ala ma kota";

    char*copy= duplicate(s, strlen(s)+1);

    // duplicate< char >(s, strlen(s)+1)

}

 

Brak jawnej specyfikacji parametrów szablonu jest wygodny – typ funkcji do wygenerowania pozostawiany jest kompilatorowi, ale równocześnie może prowadzić do niejednoznaczności. W takim przypadku raportowane są błędy.

 

Przykład

float a=min(1.0F,2.1);

 

Pierwszy z parametrów jest typu float, a drugi typu double. Kompilator nie jest w stanie dopasować argumentów, do zdefiniowanego wcześniej szablonu funkcji
template<class T> T min(T a, T b)

i tym samym automatycznie wygenerować jej instancji.

 

Jawna instancjacja

Instancja funkcji może być zdefiniowana jawnie przez podanie parametrów, dla których powinien być wygenerowany kod.

 

Składnia:

identifier<arglist>

 

identifier – identyfikator funckji zdefiniowanej w szablonie

arglist – lista argumentów (nazw typów i obiektów) oddzielonych przecinkami.

 

Przykład

float a=min<float>(1.0F,2.1);

 

Wygenerowana zostanie funkcja float min(float,float);

Wołanie funkcji zostanie prawidłowo skompilowane (z ostrzeżeniem o możliwym obcięciu parametru 2.1 typu double).

 

Wskaźniki do funkcji wygenerowanych z szablonów

Zgodnie z definicją języka, możliwe jest pobranie wskaźnika do instancji funkcji wygenerowanej na podstawie szablonu.

Typowym przykładem może być przekazanie wskaźnika do funkcji do porównywania elementów do procedury qsort.

 

Przykład

template<typename T>

int compare(const void*a,const void*b)

{

    if(*(const T*)a < *(const T*)b)return -1;

    if(*(const T*)a > *(const T*)b)return 1;

    if(*(const T*)a ==*(const T*)b)return 0;

}

 

int main(int argc, char *argv[])

{

    int a[]={1,9,5,6,2,-5};

    qsort(a,sizeof(a)/sizeof(a[0]),sizeof(a[0]),
    &compare<int>);

 

    for(int =0;i<sizeof(a)/sizeof(a[0]);i++)
       cout<<a[i]<<" ";

    cout<<endl;

    return 0 ;

}

 

Wynik:

-5 1 2 5 6 9

 

Wyrażenie &compare<int> (lub compare<int> ) oznacza adres wygenerowanej funkcji dla parametru wzorca int.

 

[Niestety, powyższy kod nie kompiluje się pod VisualC++ 6.0, skompilowano za pomocą g++].


Szablony[ps17]  klas

Szablony klas definiują nieskończony zbiór klas, które mogą powstać w procesie instancjacji przez przypisanie parametrom szablonu identyfikatorów typów i argumentów będących obiektami.

 

Szablony klas są mechanizmem ogólnego przeznaczenia, jednak najczęściej ich rolą jest zdefiniowanie kodu kontenera, który może być wielokrotnie wykorzystany przy generacji kontenerów zdolnych przechowywać obiekty określonego typu.

 

Przykład

template <class T>class Iterator;

 

template<class T>

class Vector

{

protected:

    T*start;

    T*end;

    int capacity;

    virtual void copy(const Vector&other);

    virtual void free();

public:

    Vector(){start=end=0;capacity=0;}

    Vector (const Vector &other){copy(other);}   

    ~Vector(){free();}

    Vector &operator=(const Vector &other)   {

       if(&other!=this){free();copy(other);}

       return *this;

    }

    int getSize()const{return end-start;}

    int getCapacity()const{return capacity;}

    bool reserve(int newCapacity);

    bool pushBack(const T&t);

    bool get(T&t,int index)const  {

       if(index<0||index>=(end-start))return false;

       t= start[index];

       return true;

    }

    friend class Iterator<T>;

};

 

template<class T>

void Vector<T>::copy(const Vector &other)

{

    start=end=0;capacity=0;

    if(other.getSize()){

       start = new T[other.capacity];

       capacity=other.capacity;

       for(int i=0;i<other.getSize();i++){

           start[i]=other.start[i];

       }

       end= start+other.getSize();

    }

}

template<class T>

void Vector<T>::free()

{

    if(start)delete []start;

    start=0;capacity=0;

}

template<class T>

bool Vector<T>::reserve(int newCapacity)

{

    if(newCapacity<capacity)return false;

    T*tmp=new T[newCapacity];

    if(capacity){

       for(int i=0;i<getSize();i++)tmp[i]=start[i];

       delete []start;

    }

    end=tmp+(end-start);

    capacity=newCapacity;

    start=tmp;

    return true;

}

 

template<class T>

bool Vector<T>::pushBack(const T&t)

{

    if(capacity==getSize() && !reserve(capacity+16))
       return false;

    *end=t;

    end++;

    return true;

}


 

template<class T>

class Iterator

{

    const Vector<T>&vector;

    T*current;

public:

    Iterator(const Vector<T>&v)
       :vector(v),current(v.start){}

    Iterator&operator++(){current++;return *this;}

    Iterator&operator++(int){current++;return *this;}
    operator bool()const{return current<vector.end;}

    T&get()const{return *current;}

};

 

Użycie

void test()

{

    Vector<int> v;

    v.pushBack(1);

    v.pushBack(2);

    v.pushBack(3);

    Vector<int> v2=v;

    v2.pushBack(4);

 

    for(int i=0;i<v.getSize();i++){

       int t;

       v.get(t,i);

       cout<<t<<" ";

    }

    cout<<endl;

    for(Iterator<int> it(v2);it;it++)
       cout<<it.get()<<" ";

}

 

Wynik

1 2 3

1 2 3 4

 

Omówienie

·      Przykład definiuje wzorzec dwóch klas Vector<T> oraz Iterator<T>. Instancjacja klas następuje w funkcji main przez jawne podanie parametrów:
Vector<int> oraz Iterator<int>.

·      Funkcja Vector<T>::get jest zdefiniowana jako funkcja inline, funkcja Vector<T>::copy jako zwykła metoda; wszystkie funkcje klasy Iterator<T> są zdefiniowane jako inline.

·      Klasa Iterator<T> jest zadeklarowana przed podaniem jej definicji jako: template <class T>class Iterator. Dzięki temu możliwe jest umieszczenie odwołanie do niej w deklaracji friend class Iterator<T>;

·      Poprawna generacja instancji klas Vector<T> oraz Iterator<T> wymaga, aby klasa(typ) T miała konstruktor bezargumentowy (lub nie miała konstruktora). Wymagane jest także poprawne działanie operatora przypisania.

·      Kontenery zdefiniowane w postaci szablonów przechowują obiekty (wartości) określonego typu, a nie wskaźniki. Vector<int> zawiera grupuje wartości całkowite. Instancja szablonu Vector<Shape> zawiera obiekty typu Shape.

 

[ps18] Struktura kodu szblonów

·      Proces instancjacji wymaga, aby wszystkie definicje klas i funkcji były widoczne w danej jednostce kompilacji. Dotyczy to zarówno metod inline i standardowych metod klas. Wzorzec funkcji może być zdefiniowany po wystąpieniu odwołanie jednakże wcześniej musi być widoczna deklaracja prototypu funkcji.

Przykład

template<class T>T min(T a, T b) ;

 

void main()

{

    int a = min(2,4) ;

}

 

template<class T>

T min(T a, T b)

{

    return a<b?a:b;

}

 

·      Równocześnie, w danej jednostce kompilacji nie mogą być widoczne dwie definicje szablonu klasy lub definicje funkcji. (Prototypy funkcji i deklaracje klas bez podania ich definicji mogą być podane wielokrotnie.)

·      Kompilator i linker generują zawsze unikalny kod instancji klas i funkcji, nawet, jeżeli proces instancjacji zachodzi w różnych jednostkach kompilacji. Oznacza to, że zostanie wygenerowana dokładnie jedna instancja klasy Vector<int>, nawet, jeżeli była ona wykorzystywana w dwóch różnych modułach.

 

Typowym rozwiązaniem jest umieszczenie jednej lub kilku definicji szablonów w pliku nagłówkowym oraz zabezpieczenie ich przed wielokrotnym włączeniem do danej jednostki kompilacji za pomocą instrukcji preprocesora:

 

#if !defined _Vector_h_

#define _Vector_h_

//deklaracja parametryzowanej klasy Vector<T>

//definicje parametryzowanych metod klasy Vector<T>

//definicja parametryzowanej klasy Iterator<T>

// inne definicje funkcji

#endif

 

Błędy instancjacji szablonów

Kłopotliwą cechą szblonów jest to, że błędy raportowane są dopiero w momencie instancjacji (czyli generacji funkcji lub klas dla konkretnych wartości parametrów).

 

Proces skanowania definicji szablonów przed ich instancjacją ogranicza się w zasadzie do sprawdzenia poprawnej liczby nawiasów otwierających i zamykających, stąd możliwe jest skompilowanie kodu, w którym pojawi się błędna definicja klasy lub funkcji:

 

template<class T>

void fooerr()

{

    if;

}

 

Kompilator raportuje także błędy instancjacji, jeżeli typ/klasa będąca parametrem nie realizuje pewnego oczekiwanego interfejsu.

 

Przykład

template <class T>

int compare(const T&a,const T&b)

{

    if(a<b)return -1;

    if(a==b)return 0;

    return 1;

}

 

class C{};

 

void main()

{

    cout<<compare<int>(1,2)<<endl; //ok

    cout<<compare<C>(C(),C()); // error

}

 

Szablony i dziedziczenie

Klasy wygenerowane przez instancjację szblonów mogą stać się klasami bazowymi dla klas stworzonych przez użytkownika.

 

template <class T, int size=100>

class A

{

public:

virtual void foo()=0;

    T buf[size];

};

 

class B:public A<int>

{

public:

    void foo(){cout<<"B::foo()"<<endl;};

};

 

void main()

{

    A<int>*ptr=new B();

    ptr->foo();

}

 

 

Relacja dziedziczenia pomiędzy parametrami szablonów klas nie przenosi się jednak na dziedziczenie instancji wygenerowanych klas:

Vector<Triangle> nie dziedziczy po Vector<Shape>;

Vector<Triangle*> nie dziedziczy po Vector<Shape*>.

 


Adaptacja kodu szablonów

Dostosowanie kodu szablonów do potrzeb użytkownika może odbywać się na dwa sposoby:

·      przez dziedziczenie,

·      przez dostarczenie specyficznych parametrów instancjacji.

Obie metody muszą być przewidziane podczas projektowania szablonu.

W przypadku dziedziczenia następuje przeciążenie funkcji wirtualnych, np.: copy, free dla szablonu Vector<T>.

Adaptacja za pomocą parametrów jest typowym podejściem stosowanym przy projektowaniu szablonów kontenerów.

 

W języku C modyfikacja działania funkcji (np.: qsort) jest często realizowana przez dostarczenie wskaźnika do własnej funkcji o określonym interfejsie
np.: int compare(const void*, const void*).

 

Składnia definicji szablonów wymusza bardzo specyficzne rozwiązanie: przy generacji instancji funkcji lub klasy przekazywany jest typ (klasa), którego jedynym przeznaczeniem jest zapewnienie odpowiednich funkcji, np.: funkcji do porównywania elementów.

Alternatywnym rozwiązaniem jest przekazanie obiektu określonej klasy.

 

Przykład

·      Załóżmy, że chcielibyśmy użyć szablonu Vector<T> do przechowywania obiektów typu Shape. W rzeczywistości kontener powinien grupować obiekty różnych typów: Circle, Square, Triangle, stąd elementami kontenera powinny być raczej wskaźniki typu bazowego Shape*.

·      Kontener powinien być właścicielem obiektów, stąd implementacja funkcji free powinna zostać zmieniona.

·      Aby kontener zachowywał się poprawnie dla operacji kopiowania (konstruktor kopiujący i operator przypisania) należy również zmienić implementację funkcji copy.

 

Dziedziczenie

Pierwszym nasuwającym się rozwiązaniem jest dziedziczenie. Tworzymy nową klasę i zmieniamy implementację funkcji odpowiedzialnych za zwalnianie pamięci i kopiowanie zawartości.

 

class ShapeVector: public Vector<Shape*>

{

protected:

    void copy(const Vector&other);

    void free();

};

 

Jeżeli brak jawnie wydzielonych funkcji tego typu, po prostu implementujemy własne wersje destruktora, konstruktora kopiującego i operatora przypisania.

 

 

Modyfikowalne parametry instancjacji

Drugie rozwiązanie wymaga ingerencji w kod szablonu.

Załóżmy, że szablony Vector i Iterator mają dodatkowy parametr przewidziany przez projektanta: klasę PointerAdapter, czyli są zdefiniowane jako Vector<T,PointerAdapter> oraz Iterator<T,PointerAdapter>

 

Zakłada się, że klasa PointerAdapter implementuje interfejs postaci :

 

·      static T&clone(const T&t) ;
funkcja stworzy kopię obiektu przekazanego jako argument

·      static void free(T&);
funkcja zwolni pamięć obiektu będącego jej argumentem

 


Zmieniona postać metod copy i free klasy Vector<T,PointerAdapter>:

 

template <class T, class PointerAdapter>

void Vector<T,PointerAdapter>::
copy(const Vector &other)

{

    start=end=0;capacity=0;

    if(other.getSize()){

         start = new T[other.capacity];

         capacity=other.capacity;

         for(int i=0;i<other.getSize();i++){

             start[i]=
             PointerAdapter::clone(other.start[i]);

         }

         end= start+other.getSize();

    }

}

template <class T, class PointerAdapter>

void Vector<T,PointerAdapter>::free()

{

    for(int i=0;i<getSize();i++)
         PointerAdapter::free(start[i]);

    if(start)delete []start;

    start=0;

}

 

Standardowa implementacja parametru PointerAdapter może być następująca:

 

template <class T>

class DefaultAdapter

{

public:

    static T&clone(const T&t){return t;}

    static void free(T&){}

};

 

Klasa DefaultAdapter<T> powinna być używany dla tych instancji szablonu, dla których nie są wymagane operacje na wskaźnikach.

Składnia definicji szablonów pozwala na określenie standardowych argumentów instancjacji w podobny sposób, jak dla standardowych argumentów funkcji.

 

template <class T, class PointerAdapter = DefaultAdapter<T>    >

class Vector

{

...

};

 

template <class T, class PointerAdapter = DefaultAdapter<T>    >

class Iterator

{

...

};

// VC++ nie akceptuje <T,class X<T>>

// konieczna spacja <T,class X<T> > ???

 

Dzięki użyciu standardowego parametru możliwe będzie pominięcie drugiego argumentu szablonu dla tych klas, gdzie adaptacje nie są wymagane

 

void main()

{

    Vector<int> v;

    for(int i=0;i<20;i++)v.pushBack(i);

    for(Iterator<int> it=v;it;it++)cout<<it.get()<<" ";

}

 


Pragnąc używać kontenera zawierającego elementy typu Shape* zdefiniujemy własną klasę ShapeAdapter implementującą interfejs PointerAdapter.

 

class ShapeAdapter

{

public:

    static Shape*clone(const Shape*object)

    {

       if(typeid(*object)==typeid(Circle))

           return new Circle(*(Circle*)object);

       if(typeid(*object)==typeid(Triangle))

           return new Triangle(*(Triangle*)object);

       if(typeid(*object)==typeid(Square))

           return new Square(*(Square*)object);

       return 0;

    }

    static void free(Shape*object)

    {

       if(object)delete object;

    }

};

 

Przykład użycia

void main()

{

    Vector<Shape*,ShapeAdapter> v;

    v.pushBack(new Circle());

    v.pushBack(new Triangle());

    v.pushBack(new Square());

    Vector<Shape*,ShapeAdapter> v2=v;

    v2.pushBack(new Circle);

 

    for(Iterator<Shape*,ShapeAdapter> it(v2);it;it++)
       it.get()->dump();

}

 

Wynik

Circle Triangle Square Circle

 

Standardowe kontenery

Wielu producentów kompilatorów oferujących początkowo kontenery obiektowe rozbudowało swoje biblioteki o kontenery wykorzystujące szablony. Przykładem jest biblioteka MFC z szablonami:

CArray, CList, CMap,

CTypedPtrArray, CTypedPtrList, CTypedPtrMap.

 

Około 1995 roku pojawiła się biblioteka STL, która przerodziła się w standardową bibliotekę C++.

Zalety:

·      w obecnej chwili biblioteka jest faktycznym standardem;

·      dostarczana jest w postaci kodu źródłowego szablonów

·      biblioteka definiuje wiele podstawowych klas: string, list, vector, map, set, itd.

·      biblioteka implementuje standardowe algorytmy (np.: sort, replace)

·      biblioteka oferuje poprawione wersje klas strumieni

·      kod biblioteki został zoptymalizowany

 

Literatura:

·      STL tutorial i STL sample programs w MSDN

·      Bjarne Strostrup, Język C++ 3.ed,

·      Bruce Eckel, Thinking in C++ Vol.2

 

Krótkie omówienie

Szablony STL umieszczone są w plikach o charakterystycznych nazwach – nie mają one rozszerzenia. Nazwy plików odpowiadają nazwom szablonów.

 

Wszystkie definicje STL umieszczone są w odrębnej przestrzeni nazw (ang. namespace) std. Stąd nazwą kontenera jest na przykład std::vector. Aby pominąć przedrostek std należy skorzystać z deklaracji using namespace std;

 

Przykład

#include <vector>

using namespace std;

 

void main()

{

std::vector<int> v1;

vector<int>v2;

for(int i=0;i<20;i++)v1.push_back(i);

}

 

Biblioteka STL w specyficzny sposób definiuje pojęcie iteratora. Iteratory mogą być traktowane jak wskaźniki, które podczas iteracji będą przebiegały kolejne elementy kontenera, aż do momentu, kiedy osiągną adres danych poza kontenerem.

 

Aby uzyskać dostęp do elementu należy użyć operatora dereferencji ‘*’. Jeżeli obiekty w kontenerze należą do pewnej klasy, dostęp do pól i metod obiektu zapewni operator ‘->’.

 

Przykład iteracji:

std::vector<int> v1;

for(int i=0;i<20;i++)v1.push_back(i);

 

for(vector<int>::const_iterator     it=v1.begin();

    it!=v1.end();

    it++)

    cout<<*it<<" ";

 

·      vector<int>::const_iterator – jest to typ iteratora zdefiniowany jako klasa wewnętrzna szablonu.

·      it=v1.begin() – ustawia iterator tak, by wskazywał pierwszy element kontenera

·      it!=v1.end() – testuje, czy iterator nie wyszedł poza sekwencję elementów w kontenerze

·      it++– inkrementuje iterator

·      *it – realizuje dostęp do elementu wskazywanego przez iterator

Kontenery[ps19]  STL i wskaźniki

Kontenery STL przechowują wartości obiektów. Kontenery mogą być argumentami dla konstruktora kopiującego i operatora przypisania, pod warunkiem, że zawarte w nich elementy mogą być bezpiecznie kopiowane.

Programista, który chciałby w kontenerach STL przechowywać wskaźniki do obiektów należących do stworzonej przez siebie hierarchii klas, musi dokonać adaptacji kontenerów przez dziedziczenie.

 

Przykład

#include <vector>

 

class ShapeVector: public std::vector<Shape*>

{

    void free();

    void copy(const ShapeVector&other);

    static Shape*clone(const Shape*object);

public:

    ShapeVector(){ }

    ShapeVector(const ShapeVector&other)
       {copy(other);}

    ~ShapeVector(){free();}

    ShapeVector&operator=(const ShapeVector&other){

       if(&other!=this){free();copy(other);}

       return *this;

    }

}; 

 

void ShapeVector::free(){

    for(ShapeVector::const_iterator it=begin();

       it!=end();it++)   delete *it;

       clear(); //!!!

}

 

void ShapeVector::copy(const ShapeVector&other){

    for(ShapeVector::const_iterator it=other.begin();

    it!=other.end();it++){

       Shape*shapeCopy=clone(*it);

       push_back(shapeCopy);

    }

}


 

Shape*ShapeVector::clone(const Shape*object)

{

    if(typeid(*object)==typeid(Circle))

       return new Circle(*(Circle*)object);

    if(typeid(*object)==typeid(Triangle))

       return new Triangle(*(Triangle*)object);

    if(typeid(*object)==typeid(Square))

       return new Square(*(Square*)object);

    return 0;

}

 

void main()

{

    ShapeVector v;

    v.push_back(new Circle());

    v.push_back(new Triangle());

    v.push_back(new Square());

    ShapeVector v2=v;

    v2.push_back(new Circle);

 

    ShapeVector::const_iterator it;

    for(it=v.begin();it!=v.end();it++)
       (*it)->dump();

    cout<<endl;

    for(it=v2.begin();it!=v2.end();it++)
       (*it)->dump();

}

 

Rezultat

Circle Triangle Square

Circle Triangle Square Circle

 


Suplement A
Przykład kontenera  – lista w hierarchii obiektowej

 

class CloneNotSupported{};

 

class Object

{

public:

    virtual ~Object(){}

    virtual Object*clone()const{

        throw CloneNotSupported();

    }

};

 

 

 

class List : public Object

{

    friend class Iterator;

    class ListElement

    {

        public:

        ListElement*next;

        Object*object;

    };

    ListElement*start;

    ListElement*end;

 

public:

    List(){start=end=0;}

    ~List(){free();}

    List(const List&other){copy(other);}

    List&operator=(const List&other){

        if(&other!=this){

            free();

            copy(other);

        }

        return *this;

    }

 

    void pushBack(Object*object)

    {

        ListElement*le=new ListElement();

        le->next=0;

        le->object = object;

        if(end)end->next=le;

        end=le;

        if(!start)start=end;

    }

    void pushFront(Object*object)

    {

        ListElement*le=new ListElement();

        le->object = object;

        le->next=start;

        start=le;

        if(!end)end=start;

    }

protected:

    void free()

    {

        while(start){

            ListElement*tmp=start;

            start=start->next;

            delete tmp->object;

            delete tmp;

        }

        start=end=0;

    }

    void copy(const List&other)

    {

        start=end=0;

        for(ListElement*i=other.start;i;i=i->next){

            pushBack(i->object->clone());

        }

    }

};

 

class Iterator

{

    const List&list;

    List::ListElement*current;

public:

    Iterator(const List&l):list(l),current(l.start){}

    Iterator&operator++(){

        if(current)current=current->next;

        return *this;

    }

    Iterator&operator++(int){

        if(current->next)current=current->next; //?

        return *this;

    }

    operator bool()const{return current!=0;}

    Object*get()const{return current->object;}

};

 

 

class Int: public Object

{

public:

    int value;

    Int(int v=0){value=v;}

    void dump()const{std::cout<<value<<std::endl;}

    // Object*clone()const{return new Int(*this);}

};

 

 

 

 

 

int main(int argc, char* argv[])

{

 // stwórz listę, dodaj elementy, wypisz

    List list;

    for(int i=0;i<100;i++){

        list.pushFront(new Int(2*i));

        list.pushBack(new Int(2*i+1));

    }

    for(Iterator i(list);i;i++){

        Int*pi=dynamic_cast<Int*>(i.get());

        if(pi)pi->dump();

    }

// utwórz kopię listy i wypisz zawartość

    try{

        List list2(list);

        for(Iterator i(list2);i;i++){

            Int*pi=dynamic_cast<Int*>(i.get());

            if(pi)pi->dump();

        }

    }

    catch(CloneNotSupported){

        std::cout<<"Clone not supported"<<std::endl;

    }

 

    return 0;

}

 

·      Jeżeli funkcja clone w klasie dziedziczącej po Object nie zostanie zaimplementowana, wówczas konstruktor kopiujący lub operator przypisania wołając bazową wersję funkcji wyrzuci wyjątek.

·      Usuwając komentarz z klasy Int zapewniamy poprawne działanie programu (bez wyrzucania wyjątków).

 

 


 [ps1]AGH, 3.3.11

 [p2]08.03.2012

 [p3]7.3.13

 [ps4]AGH 15,3,2012

 [p5]14-3--2012

 [p6]22.03.2012

 [p7]21.3.2013

 [p8]4-4-2013

 [p9]11-04-2013

 [ps10]14.4.2011

 [p11]26.04.2012

 [ps12]AGH 21.04.11

 [ps13]AGH 6.05.10

 [p14]25-4-2013

 [ps15]AGH 6.5.11

 [ps16]AGH 12.5.11

 [ps17]23.5.13

 [ps18]AGH 29.05.10

 [ps19]2.6.11