Laboratorium Podstaw Informatyki
Kierunek Elektrotechnika
Ćwiczenie 7.1
Programowanie obiektowe
w języku C++
Zakład Metrologii AGH
Kraków 1998
Wprowadzenie
Obiektowy sposób programowania wyrósł z idei dzielenia programów na bezpieczne w używaniu wymienne elementy. W języku C były to moduły, w C++ są to obiekty definiowane przez klasy. W obiektowym opisie algorytmów i struktur danych używa się nowych pojęć, krótko wyjaśnionych poniżej, którymi będziemy się posługiwać w dalszej części ćwiczenia.
Enkapsulacja (kapsułkowanie) - umieszczenie w jednej strukturze nazywanej obiektem, danych i funkcji na nich operujących. Dostęp do danych obiektu jest możliwy tylko przez interfejs tworzony przez funkcje. Budowa obiektu jest definiowana przez klasę.
Klasa - definiuje dane i funkcje operujące na nich. Odpowiada typowi języka C, a w szerszym ujęciu modułowi.
Obiekt - instancja klasy, czyli jej konkretne wystąpienie. Odpowiada zmiennej danego typu w języku C.
Abstrakcja danych - oddzielenie deklaracji interfejsu klasy od jego implementacji. W ten sposób abstrakcyjne pojęcie np. listy może być zaimplementowane na wiele sposobów bez zmiany deklaracji interfejsu funkcyjnego.
Dziedziczenie - przenoszenie cech klasy podstawowej na klasę pochodną. Np. dla klasy podstawowej Zwierzę klasa pochodna Ssak dziedziczy cechy klasy podstawowej. Można, jak dla każdego zwierzęcia, określić dla niego np. średni czas życia. Klasa pochodna może posiadać też cechy dodatkowe, np. dla Ssaków - skład mleka matki.
Polimorfizm - inaczej wielopostaciowość. Nawiązując do poprzedniego przykładu, każdy obiekt klasy pochodnej od klasy Zwierzę, jak np. Ptak, Płaz, Gad, pozostaje zwierzęciem. Zwierzę może więc występować w wielu postaciach.
Dzięki abstrakcji danych, osiągniętej przez enkapsuklację, obiekty są łatwo wymiennymi elementami programu. Przykładowo dla klasy listy uporządkowanej można zmienić na szybszy algorytm działania funkcji wyszukiwania elementów, bez konieczności ponownej kompilacji modułów wykorzystujących obiekty tej klasy. Do wykorzystania klasy istotna jest tylko deklaracja jej interfejsu.
Możliwość dziedziczenia uwalnia użytkownika klasy od konieczności modyfikacji jej źródeł w celu dopasowania własności do bieżącego zastosowania. W programowaniu obiektowym należy utworzyć klasę pochodną i modyfikować tylko jej implementację. Żeby z gotowej klasy podstawowej listy otrzymać klasę listy uporządkowanej nie trzeba mieć źródeł z implementacją klasy podstawowej. Wystarczy utworzyć od niej klasę pochodną i pokryć implementację funkcji wstawiania elementów.
Zadanie 1 - enkapsulacja, abstrakcja danych, dziedziczenie
Lista numerowana
Pierwszy program obiektowy rozpoczniemy od analizy abstrakcyjnego pojęcia listy ponumerowanych elementów. Przyjmijmy że lista powinna umożliwiać wstawianie elementów na dowolnej pozycji odpowiadającej numerowi, odczyt elementu z pozycji, odczyt ilości elementów. Z tego założenia wynika interfejs złożony z funkcji: Wstaw(Wartość, Pozycja), Odczytaj(Pozycja), Rozmiar(). W momencie tworzenia lista powinna być pusta.
Zastanówmy się teraz nad możliwymi implementacjami tego pojęcia. Najbardziej naturalna wydaję się implementacja z użyciem struktury dynamicznej. Z kolei indeksowanie elementów w funkcjach Wstaw() i Odczytaj() łatwiej zrealizować na tablicy. Mimo tego, że implementacja tablicowa wymaga przesuwania elementów przy wstawianiu, wybierzemy ją ze względu na łatwość kodowania operacji.
Inicjowaniem danych obiektu zajmuje się zawsze szczególna funkcja, nazywana konstruktorem, o nazwie takiej samej jak nazwa klasy. Jest ona wołana automatycznie w momencie powstawania obiektu na drodze definicji lub alokacji dynamicznej. Jej dopełnieniem jest destruktor - funkcja wołana w momencie niszczenia obiektu.
Obiekt dzieli się na część prywatną oznaczoną słowem kluczowym private i część dostępną na zewnątrz oznaczoną słowem public. Domyślnie, przy braku oznaczenia, jest przyjmowana część prywatna.
Oto definicja klasy Lista:
class Lista {
int Tablica[MAX_EL];
int Ilosc;
public:
Lista():Ilosc(0) {} /* konstruktor inicjuje licznik elementów */
~Lista() {} /* destruktor pusty */
void Wstaw(int Wartosc, int Pozycja) {
int i;
for(i=Ilosc;i>=Pozycja;i--) Tablica[i+1]=Tablica[i];
Tablica[Pozycja]=Wartosc;
Ilosc++;
}
int Odczytaj(int Pozycja) { return Tablica[Pozycja]; }
int Rozmiar() { return Ilosc; }
};
W powyższej definicji widać podobieństwo do konstrukcji struktury języka C. W istocie klasy są strukturami, które oprócz danych zawierają funkcje. W części prywatnej klasy Lista znajdują się dane opisujące listę: tablica elementów listy i licznik ilości tych elementów. Dostęp do danych prywatnych mają tylko funkcje zawarte w klasie. Użytkownik nie może więc używać tych zmiennych w wyrażeniach. Używać może tylko funkcji interfejsu z części publicznej, zasłaniających szczegóły implementacyjne klasy.
Sposób używania tak zdefiniowanej klasy, przy założeniu że jej definicja znajduje się w pliku lista.h, przedstawiono poniżej.
#include <iostream.h>
#include „lista.h”
void main() {
Lista lista_numerow;
int numer;
for(numer=0;numer<10;numer++) lista_numerow.Wstaw(numer, numer);
lista_numerow.Wstaw(135, 3);
for(numer=0;numer<lista_numerow.Rozmiar();numer++)
cout << lista_numerow.Odczytaj(numer) << endl; /* wypisanie el. listy */
}
Notacja odwoływania się do funkcji klasy jest zgodna z notacją wybierania elementów struktury. Nieznany dotąd zapis pojawia się przy okazji wypisywania elementów listy. Język C++ zmienił notację wyjścia/wejścia programu przez zdefiniowanie operatorów wyjścia (<<) i wejścia (>>). Symbol cout oznacza standardowy strumień wyjścia. Sposób wypisywania standardowych typów całkowitych, zmiennoprzecinkowych, znakowych i wskaźnikowych jest zdefiniowany w bibliotece iostream. Dla nowo tworzonych typów można ten sposób zdefiniować. Symbol endl oznacza znak końca linii `\n'. Szczegóły dotyczące obiektowej biblioteki wejścia/wyjścia są opisane w dodatku do tej instrukcji.
Skompiluj i uruchom opisany program z katalogu lista1. Dodaj do klasy Lista funkcję usuwania elementu na określonej pozycji. Przetestuj zmodyfikowaną klasę. |
Lista uporządkowana
Załóżmy, że dysponując kodem źródłowym klasy listy z poprzedniego punktu ćwiczenia, otrzymujemy zadanie opracowania klasy listy uporządkowanej. Należy dodatkowo ukryć przed użytkownikiem indeksowanie elementów listy i zastąpić je iterowaniem za pomocą pary funkcji OdczytajPierwszy(), OdczytajNastepny(), typowym dla listy jednokierunkowej. Programista nie patrzący na elementy programu jak na obiekty zacznie pracę od przerabiania źródeł funkcji dostępnej klasy lub ich zastępowania nowymi wersjami. Programowanie obiektowe polega tymczasem na wykorzystywaniu klasy opisującej pojęcie ogólniejsze jako podstawy, na której buduje się klasę pojęcia bardziej szczegółowego. Mechanizmem pozwalającym na takie postępowanie jest dziedziczenie własności klasy podstawowej przez klasę pochodną, wykorzystane poniżej do zbudowania żądanej klasy.
class ListaUporzadkowana : private Lista {
int ElAktualny;
public:
ListaUporzadkowana() : Lista() {
ElAktualny=0;
}
int Dodaj(int wartosc) {
int i, j;
i=0;
do {
j=Odczytaj(i);
if(j<wartosc) i++;
} while(j<wartosc && i<Rozmiar());
Wstaw(wartosc, i);
}
int OdczytajPierwszy(int& wartosc) { // Zwraca wartość odczytaną przez referencję
if(Rozmiar()) {
wartosc=Odczytaj(ElAktualny=0);
return 1;
} else return 0;
}
int OdczytajNastepny(int& wartosc) { // Zwraca wartość odczytaną przez referencję
if(ElAktualny<Rozmiar()-1) {
wartosc=Odczytaj(++ElAktualny);
return 1;
} else return 0;
}
}
Dziedziczenie zapisane sekwencją: class ListaUporzadkowana : private Lista oznacza, że składowe klasy podstawowej Lista są prywatne dla klasy pochodnej ListaUporzadkowana i nie są udostępniane dla użytkowników tej klasy. Podobnie składowe Tablica i Ilosc klasy Lista zostały w niej zadeklarowane jako prywatne, więc klasa pochodna nie może odwoływać się do nich bezpośrednio, a jedynie, jak w powyższym kodzie, za pomocą funkcji interfejsu publicznego Wstaw(), Odczytaj(), Rozmiar().
Zbuduj z użyciem mechanizmu dziedziczenia klasę ListaUporzadkowanaZSuma zawierającą interfejs funkcji jak klasa ListaUporzadkowana i dodatkowo funkcję SumaElementów() podającą wartość sumy elementów z listy. Wybierz klasę podstawową Lista lub ListaUporzadkowana i sposób dziedziczenia składowych publicznych private lub public. Przetestuj implementację klasy. |
Zadanie 2 - polimorfizm, funkcje wirtualne i przeciążanie operatorów
Polimorfizm na ekranie
Zajmijmy się kolejnym abstrakcyjnym pojęciem - elementem ekranowym, opisującym wszystko co może pojawić się na ekranie. Jest to pojęcie abstrakcyjne, bo nie wiąże się z nim żaden konkretny kształt elementu. Jedyne co można powiedzieć o elemencie ekranowym, to że może być narysowany na ekranie. Klasa ElementEkranowy() będzie więc definiowała następujący wspólny interfejs wszystkich elementów ekranowych.
class ElementEkranowy {
public:
void Rysuj() {
outtext(”Nie wiem co, jak i gdzie !”);
}
};
Rysować, ale co, jak i gdzie ? Ten problem rozwiążą klasy pochodne, opisujące przykładowe konkretne elementy ekranowe: prostokąt i okrąg.
class Prostokat: public ElementEkranowy {
int X1, Y1, X2, Y2;
public:
Prostokat(int x1, int y1, int x2, int y2) {
X1=x1;
Y1=y1;
X2=x2;
Y2=y2;
}
void Rysuj() {
rectangle(X1, Y1, X2, Y2);
}
};
class Okrag : public ElementEkranowy {
int X, Y, R;
public:
Okrag(int x, int y, int promien) {
X=x;
Y=y;
R=promien;
}
void Rysuj() {
circle(X, Y, R);
}
};
Powyższe definicje dziedziczące z klasy podstawowej wprowadzają do programu polimorfizm. Element ekranowy może być okręgiem lub prostokątem.
W programie głównym chcemy utworzyć kilka obiektów wyprowadzonych z klasy ElementEkranowy i narysować je na ekranie.
void main() {
ElementEkranowy* figury[3];
int i;
figury[0]=new Prostokat(10, 10, 50, 50);
figury[1]=new Okrag(60, 40, 20);
figury[2]=new Prostokat(30, 40, 50, 60);
for(i=0;i<3;i++) figury[i]->Rysuj();
}
Uzyskujemy dla tego kodu dziwny z pozoru efekt. W pętli rysowania wołana jest funkcja z klasy bazowej, zamiast pokrywających ją funkcji z klas pochodnych. Jednak tablica figury przechowuje wskaźniki do obiektów abstrakcyjnych klasy ElementEkranowy, a nie do obiektów konkretnych, więc program działa poprawnie, choć niezgodnie z naszymi oczekiwaniami. W tej sytuacji z pomocą przychodzą nam funkcje wirtualne. Wg. raportu języka C++ [Stroustrup] „jeśli klasa podstawowa zawiera funkcję wirtualną vf, a wyprowadzona z niej klasa pochodna także zawiera funkcję vf tego samego typu, to wywołanie vf dla obiektu klasy pochodna powoduje wykonanie funkcji pochodna::vf, nawet jeśli dostęp do klasy pochodnej odbywa się przez wskaźnik lub referencję do klasy podstawowa”. Oznacza to w naszym przypadku, że jeśli poprzedzimy deklarację funkcji Rysuj() w klasie ElementEkranowy słowem kluczowym virtual, to program zachowa się zgodnie z naszymi oczekiwaniami.
Doprowadź definicje klas w programie ekran do pożądanego stanu. Wyprowadź klasę pochodną Kółko z klasy Okrąg. Zauważ, że kółko to przypadek okręgu z wypełnieniem. Dodaj obiekt utworzonej klasy do tablicy wyświetlanych elementów ekranowych programu ekran. |
Prosta klasa liczb zespolonych
Naszym celem jest konstrukcja klasy liczb zespolonych, która nada sens sekwencji instrukcji:
Zespolona a(1, 2), b(3, 2), c;
c=a+b*~b;
cout << a << `+' << b << `*' << ~b << `=` << c << endl;
Operator jednoargumentowy ~ oznacza wyznaczenie liczby sprzężonej do danej liczby zespolonej. Efekt na ekranie powinien mieć postać ciągu znaków:
(1+2j)+(3+2j)*(3-2j)=(14+2j)
Zadanie polega więc na określeniu znaczenia (przeciążeniu) operatorów +, *, ~, =, << dla utworzonego przez użytkownika typu. Zacznijmy od definicji klasy.
class Zespolona {
double real, imag;
public:
// konstruktor z domyślnymi wartościami parametrów
Zespolona(double r=0, double i=0) { real=r; imag=i; }
Zespolona operator~() { // operator sprzężenia liczby zespolonej
return Zespolona(real, -imag);
}
friend Zespolona operator+(Zespolona a, Zespolona b) {
return Zespolona(a.real+b.real, a.imag+b.imag);
}
friend Zespolona operator*(Zespolona a, Zespolona b) {
return Zespolona(a.real*b.real-a.imag*b.imag, a.imag*b.real+b.imag*a.real);
}
friend ostream& operator<<(ostream& s, Zespolona a) {
s << `(` << a.real;
int opcje=s.flags(s.flags()|ios::showpos); // drukuj znak liczby
s << a.imag << `j' << `)';
s.flags(opcje); // przywróć poprzednie opcje
return s;
}
};
Wybór rodzaju definicji operatora, metoda klasy jak operator~(), czy funkcje typu friend jak pozostałe operatory z powyższej klasy, należy do użytkownika. Dla większości operatorów wybrano realizację funkcjami zaprzyjaźnionymi ze względu na możliwości automatycznej konwersji operandów przez kompilator. Funkcje zaprzyjaźnione (poprzedzone słowem kluczowym friend) mają dostęp do składowych prywatnych klasy (real i imag), ale nie są metodami klasy.
Nie ma konieczności definiowania operatora =, ponieważ dla każdej nowej klasy jego domyślne działanie polega na skopiowaniu danych klasy. Takiego zachowania operatora właśnie oczekujemy.
Operator wyprowadzenia danych na standardowe wyjście definiujemy jako zaprzyjaźniony, aby uniknąć dodawania niepotrzebnych nam poza tym funkcji interfejsu do składowych prywatnych klasy.
Dodaj do klasy Zespolona operator == porównania dwóch liczb zespolonych. Dodaj funkcje wyznaczania modułu abs() i argumentu arg() liczby zespolonej. Wypisz na ekran wartość wyrażenia: Zespolona(1,-2)+Zespolona(2,-1) == Zespolona(1).abs()*~Zespolona(3,3). |
Zadanie 3 - wykorzystanie bibliotek obiektowych
Klasy kontenerowe w Borland C++
Biblioteka obiektowa klas kontenerowych dostarczana z kompilatorem Borland C++ zawiera szereg klas które mogą przechowywać obiekty innych klas. Jest to np. tablica (klasa Array) , która udostępnia operacje indeksowania, dodawania i usuwania elementów. Wszystkie elementy muszą być pochodnymi klasy Object, dzięki czemu mogą być przechowywane w jednej strukturze danych (wszystkie klasy kontenerowe pochodzą od Object).
Klasą kontenerową jest także Stack implementująca pojęcie stosu obiektów, z operacjami położenia obiektu na stosie, zdjęcia ze stosu, podejrzenia elementu na wierzchołku stosu i czyszczenia stosu. Inną klasą kontenerową jest String, czyli ciąg znaków. Spróbuj utworzyć z użyciem tych klas aplikację wczytującą ciągi znaków wprowadzane przez użytkownika i wypisującą je w odwrotnej kolejności. Porównaj swój tekst źródłowy z plikiem borlandc\classlib\examples\reverse.cpp. |
Prosta aplikacja w Turbo Vision
Turbo Vision jest obiektową biblioteką do tworzenia aplikacji DOS-owych z jednolitym sposobem obsługi, stosowanym w środowiskach programowania firmy Borland. Hierarchia klas zawarta w tej bibliotece ułatwia programowanie najbardziej pracochłonnej części aplikacji - interfejsu użytkownika. Spójrzmy na najprostszy program z wykorzystaniem tej biblioteki (plik tvguid01.cpp z katalogu borlandc\tvision\docdemo):
#define Uses_TApplication // Używane klasy (wskazówka dla preprocesora)
#include <tv.h> // Deklaracje Turbo Vision
// Klasa tworzonej aplikacji dziedziczy z klasy aplikacji Turbo Vision TApplication
class MojaAplikacja : public TApplication {
public:
// Konstruktor woła konstruktor klasy bazowej bez modyfikacji linii statusu i menu
MojaAplikacja() :TProgInit(&MojaAplikacja::initStatusLine,
&MojaAplikacja::initMenuBar,
&MojaAplikacja::initDeskTop) {}
};
void main() {
MojaAplikacja mojaAplikacja; // Konstruktory utworzą interfejs użytkownika
mojaAplikacja.run(); // Zaczyna się interakcja z użytkownikiem
}
Po uruchomieniu tego programu zobaczymy, że ekran został zorganizowany w sposób przypominający interfejs kompilatora Borland C++. Jednak jedyną operacją możliwą do wykonania jest opuszczenie programu przez naciśnięcie domyślnej kombinacji klawiszy. Cechy użytkowe można nadać aplikacji przez pokrycie odziedziczonych z TApplication metod.
Zapoznaj się z tekstem programu w pliku borlandc\tvision\docdemo\tvguid05.cpp. Skompiluj i uruchom tę aplikację. Dodaj nową opcję menu i tekst w linii statusu. |
Dla tych, którzy chcą wiedzieć więcej - informacje dodatkowe
Wzorce
Spotkaliśmy się przy okazji ćwiczenia poświęconego dynamicznym strukturom danych z problemem definiowania elementów struktury zbiorczej (przechowującej elementy innego typu), dla której można zmieniać na poziomie kodu źródłowego typ przechowywany. W języku C rozwiązania tego problemu miały charakter protez, np. w postaci dodatkowego pliku nagłówkowego z definicją typu przechowywanego. Język C++ umożliwia tworzenie klas których parametrem jest typ. Dzięki temu można stworzyć jedną wersję klasy-wzorca np. stosu, która może definiować obiekty stosu liczb typu int jak i typu ElementEkranowy*. Spójrzmy na prostą deklarację klasy takiego stosu [Stroustrup, str. 285] i jej wykorzystanie:
template<class T>
class Stos {
T* v;
T* p;
public:
Stos(int r) (v=p=new T[r]; }
~Stos() {delete[] v; }
void Poloz(T a) { *p++=a; }
T Zdejmij() { return *--p; }
int Rozmiar() const { return p-v; }
}
/* Użycie wzorca dla konkretnego typu w funkcji */
void f(Stos<ElementEkranowy*>& sfg) {
while(sfg.Rozmiar()) {
sfg.Zdejmij()->Rysuj();
}
}
Na podstawie wzorca, dla każdego typu podstawianego do wzorca wygenerowane zostaną odpowiednie metody wraz z konstruktorem i destruktorem. Nie uzyskujemy więc w ten sposób na objętości kodu wynikowego, a jedynie na rozmiarach kodu źródłowego i jego przejrzystości.
Podobnie jak dla klas wzorców, można definiować funkcje wzorce. Szczegóły w [Stroustrup, str. 300].
Strumienie wejścia-wyjścia
Język C++ zastępuje znany z biblioteki stdio sposób zapisywania informacji na wyjście i odczytywania z wejścia. Dotychczasowy sposób był często źródłem błędów ze względu na zmienną ilość parametrów wywołania funkcji grup printf() i scanf(). Taka konstrukcja funkcji uniemożliwia sprawdzanie zgodności typu parametrów na etapie kompilacji. Nowy sposób wykorzystuje możliwości programowania obiektowego przez przeciążenie operatorów << i >> dla typu strumienia wejścia/wyjścia i typu podlegającemu wypisywaniu/wczytywaniu. Przykładem wykorzystania nowego sposobu jest sekwencja instrukcji wypisania na standardowym wyjściu:
int i = 2;
float f = 3.14;
char *s = ” = ”;
char c = `*`;
cout << i << c << f << s << i*f << endl;
Tworzy ona na wyjściu linię:
2*3.14 = 6.28
Tak prostą notację uzyskano dzięki zdefiniowaniu dla każdego standardowego typu przeciążonego operatora o deklaracji na przykład dla typu char i operatora wyjścia:
friend ostream& operator << (ostream&s, char& c);
Jak widać typem wynikowym działania operatora jest typ ostream dzięki czemu możliwe jest wypisanie wielu obiektów w jednym wyrażeniu.
Określenia formatu wypisywania dokonuje się za pomocą tzw. manipulatorów zadeklarowanych w pliku iomanip.h i wstawianych do strumienia wyjściowego. Na przykład sekwencja:
cout << hex << setfill(`_') << setw(3) << 2 << `+' << 9 << ” = ” << 2+9 << endl
wytworzy linię wyjścia:
__2+__9 = __b
W operacjach na plikach tekstowych używa się obiektów strumieni ifstream/ofstream. Do operacji na tablicach znakowych utworzono obiekty istrstream/ostrstream.
Możliwość kontynuowania czytania ze strumienia można sprawdzić przez kontrolę wartości obiektu strumienia. Przykładowa pętla przetwarzania do końca strumienia wejściowego to sekwencja:
while(cin >> z) cout << z << `\n';
Brak możliwości czytania strumienia najczęściej jest spowodowany końcem strumienia, ale możliwe są inne przyczyny. Można je sprawdzić przez wywołania składowych klas ostream/istream o nazwach eof(), fail(), bad(), good().
Twórca nowego typu łatwo może określić sposób jego wypisywania/wczytywania przez przeciążenie operatorów << i >>.
Dla tych, którzy chcą być najlepsi - zadania dodatkowe
Klasa macierzy
Pociągający wydaję się pomysł stworzenia klasy macierzy umożliwiającej stosowanie w kodzie źródłowym matematycznej notacji operacji transpozycji, dodawania, mnożenia i odwracania macierzy. Wariacje na temat takiej klasy zawarł Stroustrup w swoim dziele o języku C++. Jej implementacja rodzi wiele problemów, których rozwiązywanie uczy programowania obiektowego na zaawansowanym poziomie.
Zapoznaj się z sugestiami twórcy języka dotyczącymi takiej klasy. Zaimplementuj pojęcie macierzy z wymienionymi operacjami i notacją operatorową. |
Obiektowa aplikacja MS-Windows
Kompilatory C++ firmy Microsoft i Borland wprowadzają hierarchię klas, ułatwiającą programowanie aplikacji przeznaczonych dla systemu MS Windows. Zalety programowania obiektowego, z enkapsulacją, dziedziczeniem i konstruktorami obiektów, ograniczają ilość kodu użytkownika opisującego aplikację. Dodatkowo nowe wersje kompilatorów tych firm oferują narzędzia graficzne organizujące pracę programisty w komplikującym się środowisku (Application i Class Wizard w Visual C++).
Zapoznaj się z dokumentacją do biblioteki MFC (Microsoft) lub OWL (Borland). Zbuduj prostą aplikację z użyciem klas hierarchii. |
Literatura
Stroustrup B.: Język C++; WNT, Warszawa 1994
Brain M., Campbell K.: Understanding C++; Interface Technologies 1997, opis dostępny w sieci Internet pod adresem http://www.iftech.com
Laboratorium Podstaw Informatyki Strona 5
Zakład Metrologii AGH