background image

 

53 

Modele etapów tworzenia oprogramowania 

 

Już w latach sześćdziesiątych zauważono, że przebieg procesu 
tworzenia  oprogramowania  informatycznego  podlega  takim 
samym  regułom,  jak  tworzenie  dowolnego  urządzenia  np. 
budowli.  Wtedy  właśnie  wprowadzono  pojęcie  Inżynierii 
Oprogramowania (Software Engineering). 
 
Poniżej  przedstawione  zostaną  trzy  wybrane  modele  cyklu 
życia systemu informatycznego:  
- model kaskadowy,  
- prototypowanie błyskawiczne, 
- programowanie ekstremalne. 
 
Model kaskadowy 
 

 
 
 
 
                                                                                                                 

przekazanie 

 
 

 

 
 
 
 

 
 
 
 
Model KASKADOWY cyklu życia systemu 
 
Ścisła 

interpretacja 

modelu 

kaskadowego 

traktuje 

poszczególne 

fazy 

jako 

niezależne 

okresy 

realizacji 

przedsięwzięcia.  Według  tej  interpretacji  okresy  te  nie 

Analiza 

Projektowanie 

Implementacja 

Testowanie 

Konserwacja 

background image

 

54 

nakładają  się  na  siebie,  zaś  ich  wykonanie  przebiega 
sekwencyjnie, bez procesów iteracyjnych.  
 
W  rzeczywistości  proces  ten  musi  mieć  charakter  iteracyjny 
(w  postaci  powrotów  do  wcześniejszych  faz  modelu  w 
przypadku  wykrycia  błędów  powstałych  w  tychże  fazach)  i 
przyrostowy  (w  każdej  fazie  nawrotu  następuje  wzbogacenie 
modelu). 
 

Do zalet modelu kaskadowego należy zaliczyć: 

Łatwość zarządzania przedsięwzięciem, 

Łatwość harmonogramowania poszczególnych etapów, 

Łatwość określenia kosztów całego przedsięwzięcia, 

Łatwość tworzenia dokumentacji. 

 
Wadami tego modelu są; 

Wysoki  koszt  błędów  popełnionych  we  wstępnych  fazach 
projektu  (błędy z fazy analizy i projektowania mogą wyjść 
na jaw dopiero w fazie testowania lub konserwacji) 

Długa  przerwa  w  kontaktach  z  klientem  (od  określenia 
wymagań – do przekazania). 

 
 
Prototypowanie błyskawiczne 

 

                                                                    przekazanie 

 
 
 

 
 
Prototypowanie błyskawiczne 
 

Prototyp 

Prototyp 

Prototyp 

 

Prototyp 

N-1 

background image

 

55 

Model  prototypowy  cyklu  życia  systemów  powstał  jako 
antidotum na wymienione wyżej wady modelu kaskadowego, 
z zwłaszcza duże koszty błędów popełnionych w fazie analizy 
wymagań,  co  ma  miejsce  zwłaszcza  w  przypadku 
nowatorskich  i  złożonych  systemów.  Model  ten  jest 
przeciwieństwem modelu kaskadowego. 
 
W modelu prototypowym wyróżnia się następujące fazy: 

Ogólne określenie wymagań, 

Budowa prototypu pierwszego, 

Weryfikacja prototypu przez klienta, (!!!) 

Budowa kolejnego prototypu, 

. . . . . . . . .  

Przekazanie systemu klientowi, 

Dalsze doskonalenie systemu, 

. . . . . . . . .  

 
Głównym  celem  budowy  prototypów  jest  lepsze  określenie 
wymagań, realizowane poprzez: 

Wykrycie  nieporozumień  pomiędzy  klientem  a  twórcami 
systemu, 

Wykrycie brakujących i trudnych usług. 

 
Prócz  tego  pojawiają  się  dodatkowe  zalety  budowy 
prototypów: 

Możliwość 

szybkiej 

demonstracji 

pracującej 

wersji 

systemu, 

Możliwość  szkoleń,  zanim  zbudowany  zostanie  pełen 
system. 

 
 
 

background image

 

56 

Model ten posiada oczywiście swoje wady. Zaliczyć do nich 
należy: 

Dodatkowo ponoszony i trudny do określenia koszt budowy 
prototypów, 

Trudny  do  określenia  moment  zakończenia  całego 
przedsięwzięcia  (bardzo  często  proces  ten  nigdy  się  nie 
kończy), 

Pewne  zaskoczenie  klienta,  który  musi  długo  czekać  na 
odbiór  systemu,  którego  „prawie  całkowite”  wykonanie 
(demonstrowany prototyp) zajęło tak mało czasu. 

 
Prototypowanie błyskawiczne jest często łączone z nasilonym 
korzystaniem z gotowych komponentów. 
 
Przedstawione 

dwie 

metodyki 

(model 

kaskadowy 

prototypowanie  błyskawiczne),  stoją  jak  gdyby  na  dwóch 
przeciwstawnych  sobie  biegunach  koncepcji  tworzenia 
oprogramowania i w rzeczywistości żadne z nich w tak czystej 
postaci nie jest stosowane. Dodatkowo ostatnio rozwijane jest 
odbiegające  jeszcze  dalej  od  prototypowania  błyskawicznego 
podejście, zwane programowanie ekstremalnym. 
 
Programowanie ekstremalne (eXtreme Programming - XP) 
 
Ogólnie,  podejście  to,  które  pojawiło  się  w  1999  roku, 
charakteryzuje: 

•  Stawianie  programisty,  a  nie  analityka  systemowego  i 

projektanta, w centrum zainteresowania, 

•  Korzystanie  z  tzw.  wzorców  projektowych  (nazwa  jest 

myląca,  są  to  bowiem  wzorce  na  poziomie  kodu,  a  nie 
projektu),  przykłady  wzorców  projektowych:  wzorzec 
fasady, fabryka abstrakcyjna 

•  Uznanie kodu za dokumentację projektu, 

background image

 

57 

•  Ścisła 

współpraca 

programistów 

przyszłym 

użytkownikiem. 

 
Podejście  to  ma  wiele  cech  prototypowania  błyskawicznego, 
zatarła 

się 

jednak 

granica 

między 

poszczególnymi 

prototypami. Było to możliwe dzięki: 

•  Rozwojowi  paradygmatu  obiektowego,  co  wyraziło  się 

opracowaniem wielu bibliotek wzorców projektowych, 

•  Pojawieniu się nowych metodyk w zakresie architektury 

oprogramowania,  jak:  refaktoryzacja,  czy  transformacje 
architektury oprogramowania, 

•  Rozwój  i  wzrost  znaczenia  metodyk  testowania,  na 

przykład 

Test 

Driven 

Development(TDD) 

– 

programowanie sterowane testami, 

 
Stosuje  się  tu,  mniej  lub  bardziej  świadomie,  czteroetapowy 
model sukcesu: 

1. Określ cel,    2. Wykonaj działanie, 
3. Odbierz informację zwrotną, 
4.  Skoryguj  działanie  tak,  aby  kolejny  efekt  był  bliższy  
     sukcesowi. 

 
Fasadę dla XP tworzą, również cztery, główne wartości: 

1. Komunikacja  w  zespole  (do  stałej  praktyki  należy 

programowanie w parach) i komunikacja z klientem, 

2. Prostota  (stałe  utrzymywanie  przejrzystości  i  spójności 

projektu), 

3. Informacja zwrotna (informacje te programiści uzyskują 

zarówno  od  klienta,  jak  i  na  podstawie  wyników 
przeprowadzanych testów), 

4. Odwaga  w  podejmowaniu  i  wdrażaniu  kluczowych 

decyzji,  wynikająca  z  wysokiego  profesjonalizmu,  przy 

background image

 

58 

pełnej  świadomości  odpowiedzialności  za  podjęte 
decyzje. 

 
 
Związki między klasami 

 

Główne związki między klasami, widziane przez pryzmat 
struktury programu (implementację): 
1. dziedziczenie, 
2. zawieranie 
3. należenie, albo posiadanie 
4. używanie, 
5. związki zaprogramowane. 

 
 

Związek zawierania 
 
class X 
{. . . . . 
  public: 
     X(int); 
      . . . . .  }; 
 
 
class C1 
{ X a;    // klasyczne zawieranie 
  public: 
      C1(int i): a(i) { } 
        
// niejawne wywołanie konstruktora klasy X 
}; 
 
 
 

 
 

       C1 

X

background image

 

59 

 
 
Związek  posiadania  
 

 

 

 

 

 

 

 

obiekt C2 

class C2 
{ X *p;                                                     
// wskaźnik do obiektu posiadanego 

 

 

obiekt typu X 

public:                                                                                        
    C2(int i): p( new X(i)) { }                                   
   // ten konstruktor kreuje i inicjuje obiekt posiadany  
    C2(X *q): p(q) { }                                                                                                
     // ... a ten „dopina się” do obiektów istniejących, 
    // może się też dopinać do obiektów klas potomnych 
    ~C2( ) {delete p;}  // utrata obiektu posiadanego   
X *udostępnij( ) { return p; } 
X *zamien( X *q) { X *t = p; p = q; return t; } 
}; 

 

Niech 

 

class XX: public X { }; 
class XXX: public X { }; 

 

wtedy 

 

void f( ) 

   C2 *p1 = new C2( new X ); 
//obiekt dynamiczny klasy C2 „posiada” obiekt klasy X 
   C2 *p2 = new C2( new XX ); 
//obiekt dynamiczny klasy C2 „posiada” obiekt klasy XX 
  C2 *p3 = new C2( new XXX ); 
//obiekt dynamiczny klasy C2 „posiada” obiekt klasy XXX 

 

 

    p 

background image

 

60 

 
Użycie referencji tworzy klasy operujące na obiektach klas X 
bezpośrednio, bez konieczności używania wskaźników. 
 
class C3 

  X &r;                                      obiekt C3 
     public:       
  C3( X &q): r(q) { };                       obiekt o nazwie q klasy X 
  . . . . . . . . . . . . . . . . 
}; 

 

 

 

 

Związki  posiadania  z  użyciem  wskaźników  i  referencji 
tworzą swoistą hierarchię obiektów a nie klas. 

 

Związki używania 
 
Cytaty:  [ B. Stroustrup ] 

 

1/

„Wiedza o tym, jakich klas używa dana klasa i w jaki sposób,  

    jest często decydująca do wyrażenia i zrozumienia  
    projektu.” 

 

Związek,  zwany  na  poziomie  implementacji  związkiem 
użycia,  jest na poziomie analizy obiektowej zwany związkiem 
zależności. 

 

 

RozkładZajęć

dodaj(p : Przedmiot)
usuń(p : Przedmiot)

Przedmiot

Iterator

<<friend>>

 

   r 

 

Zależność 

Zależność 

background image

 

61 

    W  tym  przykładzie  klasa  RozkładZajęć  używa  klasy 
Przedmiot

. Zmiany dokonane w specyfikacji klasy Przedmiot 

mogą mieć wpływ na definicję klasy RozkładZajęć, ale nie na 
odwrót. 

 

Klasyfikacja sposobów, w jakich jedna klasa X może używać 
innej klasy Y: 
1.  X używa nazwy klasy Y (jak w przykładzie powyżej), 
2.  X używa Y, ponieważ: 

2.1. X czyta składową Y, 
2.2. X zapisuje składową Y, 
2.3. X wywołuje funkcje składową Y, 

3. X tworzy obiekt klasy Y, tj. X przydziela pamięć dla 

statycznego lub automatycznego obiektu klasy Y, lub 
tworzy dynamiczny obiekt Y za pomocą operatora new, 

4.  X pobiera rozmiar Y 

 

Związki zaprogramowane (inaczej ukryte) 

 

Załóżmy, 

że 

projekcie 

tworzonego 

systemu 

wyspecyfikowano,  że  każda  operacja,  która  nie  może  być 
obsłużona  przez  klasę  A,  powinna  być  obsłużona  przez  klasę 
B, posiadaną przez klasę A. 

 

class B 
{ . . . . . .  
   void f( );   void g( );   void h( ); } 

 

class A 
{ B *p; 
   . . . . . .  
   void f( );   void ff( ); 
   void g( ) { p→g( ); }  // delegowanie g( ) 
   void h( ) { p→h( ); }  // delegowanie h( ) 
}; 

background image

 

62 

Związki zaprogramowane są głęboko ukryte w implementacji, 
przez  to  mało  widoczne  a  ich  skutki  są  trudne  do 
przewidzenia. 

 
 

Interfejsy i implementacje 

 

 
 
 
 
 
Komponent  jest  fizyczną,  wymienną,  częścią  systemu 
informatycznego.  Komponent,  obok  swojej  implementacji, 
wykorzystuje  i  realizuje  pewien  zbiór  własnych  interfejsów. 
Interfejs  komponentu jest zestawem operacji, zamkniętych w 
klasie  (lub  klasach)  interfejsowych,  które  to  operacje 
wyznaczają  usługi  oferowane  przez  komponent.  Taki  zestaw 
usług określa tzw. szwy systemu. 

 

Idealny interfejs: 

•  udostępnia  wszystkie  obowiązki  komponentu  „reszcie 

świata” w sposób pełny i logicznie spójny, 

•  nie  ujawnia  użytkownikowi  szczegółów  implementacyj-

nych, 

•  jest wyrażony za pomocą typów z poziomu użytkownika, 

•  w  ograniczony  i  dobrze  zdefiniowany  sposób  zależy  od 

innych interfejsów. 

 

// przykład interfejsu w złym stylu 
class X {    
   Y a;  
public: 
   void f( const char *, . . . );  
  // funkcja interfejsowa ze zmienną liczbą parametrów 

 

implementacja 

   interfejsy 

komponent 

background image

 

63 

   void ustaw_a( Y& ); 
 //  funkcja interfejsowa z parametrem w postaci referencji do  
     nieznanej klasy 
   Y& wez_a( ); 
//  typ  wyniku  funkcji  interfejsowej  w  postaci  referencji  do  
     nieznanej klasy 
}; 
 
Zawarte w klasie interfejsowej powyższego przykładu metody 
realizują  interfejs  na  bardzo  niskim  poziomie  abstrakcji, 
ujawniając  szczegóły  implementacji.  Nie  są  samoopisujące 
się.  

 

Zasady praktyczne projektowania 

 

Motto: 
„Nie 

ma 

jednej 

„właściwej” 

metody 

projektowania. 

Projektowanie 

wymaga 

wyczucia, 

doświadczenia 

inteligencji.” 
 
1. Użyj  publicznego  dziedziczenia  do  reprezentowania  relacji 

bycia.  

      Jeśli  class  Y:  public  X  {  ...  };  to  klasa  Y  jest  swego   
      rodzaju klasą X. 
2. Użyj wskaźników, lub referencji, do reprezentowania relacji 

posiadania. 

3. Upewnij  się,  że  zależności  używania  są  zrozumiałe, 

minimalne i niecykliczne. 

4. Wyrażaj  interfejsy  w  kategoriach  typów  z  dziedziny 

zastosowań. 

 
 
 
 
 
 

background image

 

64 

Rozkład zobowiązań w modelowanym systemie 
 
Ilościowy  rozkład  zobowiązań  dla  poszczególnych  klas 
powinien  w  modelowanym  systemie  być  taki,  aby 
poszczególne  klasy  obejmowały  porównywalne  względem 
siebie  ilości  atrybutów  i  usług.  Poniższy  rysunek  przedstawia 
poglądowo jako niedopuszczalne modele 1 i 3. 
 
 
 
 
 
 
 
MODEL 1 – każde dana jest klasą  
                      ( klasy są jednopolowe ) 
 
MODEL 2 – model opcjonalny (ze zrównoważonym  
                      rozkładem zobowiązań) 
 
MODEL 3 – model z jedną tylko klasą  
                      = model wg. paradygmatu strukturalnego 
 
Modele 1 i 3 są nie do przyjęcia. 

 

Aktualne 

podejście 

do 

tworzenia 

oprogramowania 

charakteryzuje: 

-  zacieranie 

podziałów 

między 

etapami 

analizy, 

projektowania i implementacji, 

-  rozdział 

metodyce 

tworzenia 

systemów 

informatycznych 

korporacyjnych 

(opartych 

na 

platformach  typu:  Windows,  Unix,  OS  )i  systemów 
WEB-owych (opartych o Internet), 

 

MODEL 1 

 

 

MODEL 2 

 

MODEL3 

background image

 

65 

-  ciągle  wzrastający  udział  i  specjalizacja  gotowych 

komponentów w procesie tworzenia oprogramowania, 

-  stosowanie wzorców projektowych. 

 
 

Funkcje operatorowe 
( czyli przeciążanie operatorów) 
 
Dlaczego zmuszeni jesteśmy pisać funkcje operatorowe ? 

•  Aby rozszerzać liczbę operatorów działających na znanych 

typach. Np. typowi int towarzyszą obsługujące go operatory 
+  -  *  /.  Każdy  z  tych  operatorów  możemy  jednak 
przeciążyć, nadając im nowe, dodatkowe znaczenia. 

•  Aby  definiować  nowe  operatory  dla  nowych  klas,  takich 

jak: liczby zespolone, obiekty algebry wektorów, macierzy i 
wyznaczników, napisy, sygnały, itd. 

 

Zadeklarujmy  w  klasie  COMPLEX  zaprzyjaźnienie  z  nią 
dwóch  funkcji  operatorowych,  implementujących  wybrane 
operacje na liczbach zespolonych. 
 
class COMPLEX 
{ double  re, im;  
   public: 
     COMPLEX( double r, double i ): re(r), im(i) {} 
     friend COMPLEX operator+( COMPLEX, COMPLEX); 
     friend COMPLEX operator*( COMPLEX, COMPLEX); 
 }; 
 
Przykłady użycia: 
COMPLEX a(1,2), b(0,1), c(0,0);  
a = b + c;   
//  użyto  operatora  +  dla  typu  COMPLEX,  wymagana  będzie 
jednak definicja operatora przypisania = 

background image

 

66 

b = b + c * a;  // użyto operatorów „+” i „* ” 
// zapis jest równoważny b = b + (c * a);   
// bowiem przeciążone operatory zachowują swoją 
// składnię ( priorytety i wiązania ) 
c = a * b + COMPLEX(10,20); 
// w wyrażeniu wystąpił obiekt tymczasowy  
// (część pogrubiona wyrażenia) 
COMPLEX d = operator+( a ,b ); 
// jawne użycie funkcji operatorowej 
// zapis jest równoważny COMPLEX d = a + b ; 
 
Ogólne zasady przeciążania operatorów: 
1.  można  przeciążać  wszystkie  operatory,  za  wyjątkiem 

.    .*    ::    ? : 

2.  nie  można  zmieniać  składni  operatorów,  w  szczególności: 

priorytetów  i  wiązań,  zamieniać  operator  unarny  na 
binarny, i odwrotnie, np. definiować binarny operator ! 

3.  nie  można  wprowadzać  nowych  symboli  operatorów, 

np.  operatora  **  dla  potęgowania  (bo  brak  w  C++  składni 
dla takich operatorów). 

 
Ogólnie funkcja operatora może być: 
1.  funkcja  składową  klasy,  na  obiektach  której  ma  działać 

operator, 

2.  funkcją 

nie 

będącą 

składową 

klasy 

(funkcją 

zaprzyjaźnioną). 

 

Przeciążanie operatorów jednoargumentowych (unarnych) 

 

Operator unarny można zdefiniować jako: 
1.  bezparametrową funkcję składową klasy, 
2.  funkcję o jednym argumencie, zaprzyjaźnioną z klasą. 
 
 

background image

 

67 

Na przykład użycie ++ x będzie oznaczać wywołanie: 
 

dla przypadku 1. -  x . operator++( ); 

 

dla przypadku 2. -  operator++( x ); 

 
Przeciążanie operatorów dwuargumentowych (binarnych) 

 

Można je definiować jako: 
1.  jednoparametrowe,  funkcje  składowe  klasy  (podlegają 

dziedziczeniu  !),  wówczas  x  @  y  (gdzie  @  jest  symbolem 
operatora)  odpowiada  wywołaniu  funkcji  operatorowej 
x . operator@( y ) 

2.  dwuparametrowe funkcje zaprzyjaźnioną z klasą, wówczas 

x @ y 

odpowiada 

wywołaniu 

funkcji 

operatorowej 

 operator@( x, y ) 

 
Przykłady definicji funkcji operatorów unarnych: 
 

•  jako funkcji składowych klasy 

 

     COMPLEX COMPLEX:: operator−( ) 
                     { re = − re;  im = − im; 
                        return * this; 
                      } 
 

•  jako funkcji zaprzyjaźnionej z klasą 

 

     COMPLEX operator−( COMPLEX &c ) 
                     { return COMPLEX(−c.re, −c.im ; } 
 
 
 
 
 

background image

 

68 

Przykłady przeciążania operatorów binarnych: 
 
class WEKTOR 

   double x, y; 
   public: 
     WEKTOR( ): x(0), y(0) { } 
     WEKTOR( double px, double py): x(px), y(py) { } 
     WEKTOR operator+  ( WEKTOR & ); 
     WEKTOR operator+=( WEKTOR & ); 
friend WEKTOR operator−(WEKTOR &,WEKTOR &); 
friend WEKTOR operator*(double, WEKTOR &); 
friend ostream & operator<<( ostream &,WEKTOR &); 
}; 
 
inline WEKTOR WEKTOR:: operator+ 
                             ( WEKTOR & U ); 
{ return WEKTOR( this→x+U.x, this→y+U.y ); } 
 
inline WEKTOR operator−( WEKTOR & U,  
                            WEKTOR & V); 
{ return WEKTOR( U.x−V.x, U.y−V.y ); } 
 
inline WEKTOR operator*( double k, WEKTOR & U); 
{ return WEKTOR( k * U.x, k * U.y ); } 
 
inline ostream & operator<<( ostream & st, 
                            WEKTOR & U); 
{ st << ‘[‘ << U.x << ‘,’ << U.y << ‘]’ ; return st; } 

 

 
 
 

background image

 

69 

Przykłady użycia: 

 

WEKTOR A( 1, 1 ),  B( 5, 5 ), C( -3, 3 ); 
cout << ‘\n’ << A + B << ‘\n’ << A − B << ‘\n’ <<  
              2 * C + A; 

 

Otrzymane wyniki: 

 

[ 6, 6 ] 
[ - 4, - 4 ] 
[ - 5, 7 ] 
 
Przeciążanie złożonych operatorów przypisania  op= 

 

Złożone operatory przypisania to operatory typu: +=, *=, <<=. 
W języku C++ zdefiniowano 11 takich operatorów. Pozwalają 
one  zapisać  skrótowo  wyrażenia,  w  których  zmienne  stojące 
po  lewej  stronie  operatora  przypisania,  występują  również  w 
wyrażeniach  stojących  po  prawej  stronie  tego  operatora.  Na 
przykład zamiast x = x + y możemy zapisać x += y. 

 

Ograniczenia w przeciążaniu operatorów przypisania: 

•  można używać wyłącznie funkcji składowych, 

•  operatorów przypisania nie można dziedziczyć. 

 
Poniżej 

przykład 

przeciążenia 

złożonego 

operatora 

przypisania dla klasy WEKTOR za pomocą funkcji składowej: 
 
WEKTOR & WEKTOR:: operator+=( WEKTOR & U ) 
{ this→x += U.x;   this→y += U.y; return * this;  

 
Jest  to  sytuacja,  w  której  użycie  zmiennej  this  jest  istotnie 
konieczne  –  aby  przekazać  wektor  po  wykonaniu  operacji 
polegającej na jego zwiększeniu o wektor U. 

background image

 

70 

Przykład użycia tak zdefiniowanego operatora   C += A + B; 

 

Zapis ten równoważny jest wywołaniu funkcji operatorowych 
C.operator += ( A.operator+ (B) ); 
 

W  wyniku  działania  operatora  +  z  wyrażenia  A  +  B 
tworzony  jest  obiekt  tymczasowy  (użyty  za  chwilę  jako 
drugi  argument  operatora  +=  po  poddaniu  go  konwersji 
trywialnej do WEKTOR &). 

Obiekt tymczasowy jest po użyciu usuwany, chociaż można 
tak zapisać funkcję operatorową, aby nie był usuwany. 

Dzięki  użyciu  referencji  w  wyniku  funkcji,  pierwszy 
argument operatora przypisania może być l-wartością i stać 
po lewej stronie operatora przypisania.

 

 
Przeciążanie operatora indeksowania [ ] 

 

Przystępując  do  studiowania  tego  rozdziału  musimy  mieć 
świadomość,  że  wydobywając  i-ty  element  tablicy  t  poprzez 
zapis 

t[i] 

posługujemy 

się 

 

gruncie 

rzeczy 

dwuargumentowym  operatorem  indeksowania  w  sposób 
następujący  t[]i  ,  gdzie  lewym  argumentem  operatora 
indeksowania  jest  nazwa  tablicy,  a  prawym  –  położenie 
interesującego  nas  jej  elementu.  Ponieważ  wyrażenie  t[i] 
powinno  być  l-wyrażeniem  (aby  można  było  do  niego 
podstawiać  wartości)  deklaracja  funkcji  operatorowej  dla 
tablicy przechowującej liczby całkowite powinna mieć postać 

int & operator[](int i); 

Funkcje  tę  należy  zgłosić  w  klasie,  która  obsługuje  jakąś 
kolekcję danych typu int, np. tablicę danych typu int. 

 

W  poniższym  przykładzie  rozważymy  klasę  STRING 
posiadającą i obsługującą łańcuch znakowy 
 

background image

 

71 

class STRING 

   char *str; 
   public: 
      STRING( void ):     str( NULL ) { } 
      // poniżej konstruktor kopiujący 
      STRING( char *s ): str( strdup( s )) { } 
      // poniżej sprzątający po sobie destruktor 
      ∼STRING( void ) { delete str; } 
      // poniżej definicja przeciążonego operatora indeksowania 
      char & operator[ ]( int index ) 
      { return *( str + index ); } 
      // poniżej definicja przeciążonego  
     // operatora wyprowadzenia do strumienia 
      friend ostream & operator<<( ostream & st, STRING & s)  
      { return (st << s.str) ; } 
 
Przykłady  użycia obu operatorów: 

 

STRING s( ”Adam” );   
s[1] = ‘l’ ;  cout << “   “<< s[0]; 
// powyżej wykorzystano przeciążony operator  
// indeksowania dla obiektu s klasy STRING 
cout << “   “ << s ; 
// powyżej wykorzystano przeciążony operator  
// wyprowadzenia do strumienia obiektu s klasy STRING 

 

Wynik:   A   Alam 
 
 
 
 
 
 

background image

 

72 

Tablica asocjacyjna 
(czyli wykorzystanie operatora [ ] ) 

 

W  poprzednim  przykładzie  do  indeksowania  tablicy  użyto 
danych  typu  int,  ale  można  tu  użyć  w  zasadzie  dowolnego 
typu ( sic ! ). 
 
Tablica 

asocjacyjna 

(zwana 

też 

słownikiem 

lub 

odwzorowaniem)  przechowuje  pary  wartości  pozwalając 
dotrzeć  poprzez  obiekt  zwany  kluczem  do  obiektu  zwanego 
wartością. 
 

Przykład prostej tablicy asocjacyjnej: 

#include <string> 
using namespace std;                                 klucz     wartość 
  
class TAB_ASOC                                                                    2 

    struct PARA                                                               1 
   { 
      string klucz;                                                             0 
      int    wartosc; 
   } * tab; 

   

int max; 

   int wolny; 
   public: 
      TAB_ASOC( int );  // konstruktor 
      int & operator[ ]( const string );   (1) 
      // operator indeksowania [ ] zwraca referencję do  
      // drugiej części pary 
}; 
 
 

 

 

Wojtek 

24 

 

 

tab 

wolny=1 

max=3 

background image

 

73 

 
Zdefiniowana  powyżej  tablica  asocjacyjna  przechowuje  pary, 
w  których  (służące  do  indeksowania)  pole  klucz,  jest 
dowolnym napisem, a wartością jest dana typu int. 
 
// definicja konstruktora 
TAB_ASOC:: TAB_ASOC( int r ) 
{   max = (r < 16 ) ? 16 : r ;  wolny = 0 ; 
     wektor = new PARA[ max ]; 

 
 
Definicje  operatora  indeksowania  tablic  asocjacyjnych  są 
zwykle  rozbudowane  i  obdarzone  „pewną  inteligencją”. 
Potrafią zwykle: 
-  utrzymywać niezbędny rozmiar wektora par, 
-  wyszukiwać  i  przekazywać  referencję  do  wyszukanej 

drugiej części pary, 

-  dodawać  do  wektora  nową  parę,  jeśli  nie  została  ona  tam 

jeszcze dotychczas umieszczona. 

 
 
 

Przykład użycia: 

 

TAB_ASOC * zlicz_slowa( void ) 
{ const MAX = 256; // maksymalna długość słowa 
   char buf [MAX] ;  //  bufor pojedynczego słowa 
   TAB_ASOC wek( 512 );  
   while( cin >> buf ) wek[ buf ] ++ ; 
   return wek ; 

 
 
 

background image

 

74 

 
Powyższa przykładowa funkcja zlicz_slowa( ) po wywołaniu: 

 

1. 

tworzy  tablicę  asocjacyjną  o  rozmiarze  początkowym 
512 par,

 

2. pobiera z wejścia do bufora słowa buf pojedyncze słowa 

i  wstawia  je  do  tablicy  asocjacyjnej  wraz  z  wartością  1 
(jeśli  słowo  nie  zostało  tam  jeszcze  umieszczone),  lub 
zwiększa  o  1  drugą  część  pary  (wartość)  jeżeli  słowo 
zostało w tablicy asocjacyjnej umieszczone wcześniej, 

3. zwiększa w miarę potrzeb rozmiar tablicy asocjacyjnej, 
4. po  dojściu  do  końca  czytanego  tekstu  funkcja  kończy 

swoje  działanie  zwracając  wskazanie  do  utworzonej 
przez siebie tablicy asocjacyjnej. 

 
Wykonywanie pk. 2 i 3 odbywa się w poleceniu wek[ buf ]++ 
i jest możliwe dzięki „zaszyciu” powyższej funkcjonalności w 
funkcji  operatorowej  implementującej  działanie  operatora 
indeksowania [ ]. 
Tego  typu  podejście  jest  już  realizacją  jednego  z 
paradygmatów  programowania  uogólnionego,  które  zakłada 
jednolitą dla różnych struktur danych, metodę obsługi poleceń 
(w tym przypadku polecenia wyszukiwania). 

 
Iteratory 

(czyli przeciążenie operatora wywołania funkcji ( )  

 
Zadaniem  iteratora  jest  zawsze  dostarczanie  obiektów  w 
określonym  porządku.  Aby  iterator  miał  dostęp  do 
składowych struktury, którą iteruje, definiujemy go jako klasę 
zaprzyjaźnioną z tą strukturą.  
 

background image

 

75 

Zdefiniujemy  przykładowo  klasę  iteratora  dla  tablicy 
asocjacyjnej: 
 
class ITERATR_ASOC 
{   
   const TAB_ASOC * ta ; // wskazanie iterowanej tabl. asocj. 
   int ind;  // indeks bieżący w tablicy asocjacyjnej * ta 
   public: 

    

  ITERATOR_ASOC( const TAB_ASOC & s ) 

      { ta = & s;  ind = 0 ; }  // konstruktor 
      PARA * operator( ) ( void )  // funkcja iteratora 
} ; 
 
W  definicji  klasy  TAB_ASOC  należy  umieścić  deklarację 
zaprzyjaźnienia w postaci: 

 

friend class ITERATOR_ASOC ; 

 

Operator  wywołania  funkcji  ( )  jest  zwykle  używany  w 
sposób, który ilustruje wywołanie sin(x), gdzie sin jest nazwą 
funkcji, a x jej argumentem. Nic nie stoi na przeszkodzie aby 
powyższe  wyrażenie  zapisać  sin( )x  i  spróbować  przeciążać 
ten operator tak, jak to zrobiono w klasie ITERATOR_ASOC. 

 

W  klasie  ITERATOR_ASOC  zdefiniowaliśmy  funkcje 
iteratora  w  postaci  funkcji  operatorowej,  wywoływanej  na 
rzecz  obiektu  klasy  ITERATOR_ASOC  (czyli  iteratora)  z 
pustą  listą  argumentów.  Funkcja  ta  dostarcza  wskazanie 
aktualnego  elementu  tablicy  asocjacyjnej  (pary).  Jest  to 
możliwe,  ponieważ  obiekt  klasy  iteratora  posiada  swoją 
prywatną  daną  (ind  ),  która  posiada  to  aktualne  wskazanie  w 
iterowanym obiekcie klasy TAB_ASOC. 
 

background image

 

76 

Iterator  (  tj.  obiekt  klasy  iteratora  )  inicjuje  się  w  momencie 
jego  deklaracji.  Natomiast  każde  użycie  metody  w  postaci 
funkcji operatorowej operator( ) zwraca zwykle wskazanie do 
aktualnego 

elementu 

iterowanej 

tablicy 

asocjacyjnej, 

(inkrementując jednocześnie indeks ind), lub 0 - po dojściu do 
zajętego obszaru tablicy asocjacyjnej. 

 

Przykłady użycia: 
TAB_ASOC wek( 512 );   
// deklaracja tablicy asocjacyjnej 
ITERATOR_ASOC następny( wek ); 
// utworzenie obiektu klasy iteratora „przypiętego” do 
// tablicy asocjacyjnej wek 
TAB_ASOC:: PARA * p ;   
// deklaracja wskazania pary jako zmiennej pomocniczej 
while ( p = następny( ) ) 
   cout << p → nazwa << ” : ” << p → wartosc << ‘\n’ ; 
// następny jako nazwa obiektu klasy iteratora występuje 
// w roli lewego argumentu operatora ( ),  
// prawy argument nie występuje. 

 

powyższym 

przykładzie 

każdorazowe 

wywołanie 

następny(  )  zwraca  wskazanie  kolejnej  pary  w  iterowanej 
tablicy asocjacyjnej, co umożliwia wydrukowanie w pętli obu 
składowych  pary.  Po  dojściu  do  końca  zajętego  obszaru 
tablicy  asocjacyjnej  zwrócona  zostanie  wartość  0  i  działanie 
pętli zakończy się. 

 

Można  jednocześnie  aktywować  wiele  iteratorów  tego 
samego typu, 

Można  zdefiniować  również  inne  iteratory,  np. 
pierwszy( ),  ostatni(  ),  seek(  parametr  ).  Muszą  to  być 
jednak klasy iteratory a nie tylko funkcje. 

 

Koniec części IV wykładu