Dedykacja Książka ta jest dedykowana pamięci Davida Levine.
Część 1.
Rozdział 1. Zaczynamy Wprowadzenie Witamy w „C++ dla każdego.” Ten rozdział pomoże ci efektywnie programować w C++. Dowiesz się z niego: •
dlaczego C++ jest standardowym językiem tworzenia oprogramowania
•
Jakie kroki należy wykonać przy opracowaniu programu w C++
•
w jaki sposób wpisać, skompilować i zbudować swój pierwszy, działający program w C++.
Krótka historia języka C++ Od czasu pierwszych komputerów elektronicznych, zbudowanych do wspomagania artyleryjskich obliczeń trajektorii podczas drugiej wojny światowej, języki programowania przebyły długą drogę. Na początku programiści używali najbardziej prymitywnych instrukcjami komputera: języka
1
maszynowego. Te instrukcje były zapisywane jako długie ciągi zer i jedynek. Dlatego wymyślono tzw. asemblery, zamieniające instrukcje maszynowe na czytelne dla człowieka i łatwiejsze do zapamiętania mnemoniki, takie jak ADD czy MOV. Z czasem pojawiły się języki wyższego poziomu, takie jak BASIC czy COBOL. Te języki umożliwiały stosowanie zapisu przypominającego słowa i zdania, np. LET I = 100. Te instrukcje były tłumaczone przez interpretery i kompilatory na język maszynowy. Interpreter tłumaczy odczytywany program, bezpośrednio zamieniając jego instrukcje (czyli kod) na działania. Kompilator natomiast tłumaczy kod na pewną formę pośrednią. Ten proces jest nazywany kompilacją; w jej wyniku otrzymujemy plik obiektowy. Następnie kompilator wywołuje program łączący (tzw. linker), który zamienia plik obiektowy na program wykonywalny. Ponieważ interpretery odczytują kod programu bezpośrednio i wykonują go na bieżąco, są łatwiejsze w użyciu dla programistów. Obecnie większość programów interpretowanych jest nazywanych skryptami, zaś sam interpreter nosi nazwę Script Engine (w wolnym tłumaczeniu: motor skryptu). Niektóre języki, takie jak Visual Basic, nazywają interpreter biblioteką czasu działania. Java nazywa swój interpreter maszyną wirtualną (VM, Virtual Machine), jednak w pewnych przypadkach taka maszyna wirtualna jest dostarczana przez przeglądarkę WWW (taką jak Internet Explorer lub Netscape). Kompilatory wymagają wprowadzenia dodatkowego kroku związanego z kompilowaniem kodu źródłowego (czytelnego dla człowieka) na kod obiektowy (czytelny dla maszyny). Ten dodatkowy krok jest dość niewygodny, ale dzięki niemu kompilowane programy działają bardzo szybko, gdyż czasochłonne zadanie przetłumaczenia kodu źródłowego na język maszynowy jest wykonywane tylko raz (podczas kompilacji) i nie jest już konieczne podczas działania programu. Kolejną zaletą wielu języków kompilowanych (takich jak C++) jest posiadanie tylko programu wykonywalnego (bez konieczności posiadania interpretera). W przypadku języka interpretowanego, do uruchomienia programu konieczne jest posiadanie interpretera. Przez wiele lat głównym celem programistów było uzyskanie niewielkich fragmentów szybko działającego kodu. Programy musiały być niewielkie, gdyż pamięć była droga; musiały być także szybkie, gdyż droga była również moc obliczeniowa. Gdy komputery stały się mniejsze, tańsze i szybsze, a także gdy spadła cena pamięci, te priorytety uległy zmianie. Obecnie czas pracy programisty jest dużo droższy niż koszty eksploatacji większości komputerów wykorzystywanych w codziennej pracy. Teraz najważniejszy jest dobrze napisany, łatwy w konserwacji kod. Łatwość konserwacji oznacza, że gdy zmienią się wymagania wobec działania programu, program można zmienić i rozbudować, bez ponoszenia większych wydatków.
UWAGA Słowo „program” jest używane w dwóch kontekstach: w odniesieniu do zestawu poszczególnych instrukcji (kodu źródłowego), tworzonego przez programistę oraz w odniesieniu się do całego programu przyjmujacego postać pliku wykonywalnego. Może to powodować znaczne nieporozumienia, w związku z czym będziemy starać się dokonać rozróżnienia pomiędzy kodem źródłowym a plikiem wykonywalnym.
2
Rozwiązywanie problemów Problemy, które obecnie rozwiązują programiści, są zupełnie inne niż problemy rozwiązywane dwadzieścia lat temu. W latach osiemdziesiątych programy były tworzone w celu zarządzania dużymi ilościami nie poddanych obróbce danych danych. Zarówno osoby piszące kod, jak i osoby korzystające z programów, zajmowały się komputerami profesjonalnie. Obecnie z komputerów korzysta dużo osób, większość z nich ma niewielkie pojęcie o tym, jak działa program i komputer. Komputery są narzędziem używanym przez ludzi do konkretnej pracy, a nie w celu dodatkowego zmagania się z samym komputerem. Można uważać za ironię, że wraz z pojawieniem się coraz łatwiejszych do opanowania przez ogół użytkowników programów, tworzymy programy, które same w sobie stają się coraz bardziej wymyślne i skomplikowane. Minęły już czasy wpisywania przez użytkownika tajemniczych poleceń po znaku zachęty, które powodowały wyświetlenie strumienia nie przetworzonych danych. Obecne programy korzystają z wymyślnych „przyjaznych interfejsów użytkownika”, posiadających wiele okien, menu, okien dialogowych oraz innych elementów, które wszyscy dobrze znamy. Wraz z rozwojem sieci WWW, komputery wkroczyły w nową erę penetracji rynku; korzysta z nicj więcej osób niż kiedykolwiek, a ich oczekiwania są bardzo duże. Przez kilka lat, jakie upłynęły od czasu pierwszego wydania tej książki, programy stały się bardziej złożone, w związku z czym powstało zapotrzebowanie na pomocne w ich opanowaniu techniki programistyczne. Wraz ze zmianą wymagań dotyczących oprogramowania, zmieniły się także same języki i technika pisania programów. Choć historia tych przemian jest fascynująca, w tej książce skupimy się na transformacjach jakie nastąpiły w trakcie przejścia od programowania proceduralnego do programowania obiektowego.
Programowanie proceduralne, strukturalne i obiektowe Do niedawna program był traktowany jako seria procedur, działających na danych. Procedura (funkcja) jest zestawem specyficznych, wykonywanych jedna po drugiej instrukcji. Dane były całkowicie odseparowane od procedur, zaś zadaniem programisty było zapamiętanie, która funkcja wywołuje inne funkcje, oraz jakie dane były w wyniku tego zmieniane. W celu uniknięcia wielu potencjalnych błędów opracowane zostało programowanie strukturalne. Główną ideą programowania strukturalnego jest: „dziel i rządź.” Program komputerowy może być uważany za zestaw zadań. Każde zadanie, które jest zbyt skomplikowane aby można było je łatwo opisać, jest rozbijane na zestaw mniejszych zadań składowych, aż do momentu gdy, wszystkie zadania są wystarczająco łatwe do zrozumienia. Na przykład, obliczenie przeciętnej pensji przeciętnego pracownika przedsiębiorstwa jest dość złożonym zadaniem. Można je jednak podzielić na następujące podzadania:
1. Obliczenie, ile zarabiają poszczególne osoby. 2. Policzenie ilości pracowników.
3
3. Zsumowanie wszystkich pensji. 4. Podzielenie tej sumy przez ilość pracowników.
Sumowanie pensji można podzielić na następujące kroki: 1. Odczytanie danych dotyczących każdego pracownika. 2. Odwołanie się do danych dotyczących pensji. 3. Dodanie pensji do naliczanej sumy. 4. Przejście do danych dotyczących następnego pracownika.
Z kolei, uzyskanie danych na temat pracownika można rozbić na:
1. Otwarcie pliku pracowników. 2. Przejście do właściwych danych. 3. Odczyt danych z dysku.
Programowanie strukturalne stanowi niezwykle efektywny sposób rozwiązywania złożonych problemów. Jednak pod koniec lat osiemdziesiątych ograniczenia takiej metody programowania objawiły się aż nazbyt jasno. Po pierwsze, w trakcie tworzenia oprogramowania naturalnym dążeniem jest traktowanie danych (na przykład danych pracownika) oraz tego, co można z nimi zrobić (sortować, modyfikować, itd.), jako pojedynczej całości. Niestety, w programowaniu strukturalnym struktury danych są oddzielone od manipulujących nimi funkcji, a w programie strukturalnym nie istnieje naturalny sposób ich połączenia. Programowanie strukturalne jest często nazywane programowaniem proceduralnym, gdyż skupia się na procedurach (a nie na „obiektach”). Po drugie, programiści zmuszeni są wciąż wymyślać nowe rozwiązania starych problemów. Czasem nazywa się to „wymyślaniem koła”; stanowi to przeciwieństwo „ponownego wykorzystania.” Idea ponownego wykorzystania oznacza tworzenie komponentów, posiadających znane wcześniej właściwości, które mogą być w miarę potrzeb dołączane do programu. Pomysł został zapożyczony z rozwiązań sprzętowych — gdy inżynier potrzebuje nowego tranzystora, zwykle nie musi go wymyślać — przegląda duże pudło z tranzystorami i wybiera ten, który spełnia dane wymagania, ewentualnie tylko nieco go modyfikując. Inżynier oprogramowania nie miał podobnej możliwości. Na to zapotrzebowanie próbuje odpowiedzieć programowanie zorientowane obiektowo, dostarcza ono technik zarządzania złożonymi elementami, umożliwia ponowne wykorzystanie komponentów i łączy w logiczną całość dane oraz manipulujące nimi funkcje. Zadaniem programowania zorientowanego obiektowo jest modelowanie „obiektów” (tzn. rzeczy), a nie „danych.” Modelowanymi obiektami mogą być zarówno elementy na ekranie, takie jak
4
przyciski czy pola list, jak i obiekty świata rzeczywistego, np. motocykle, samoloty, koty czy woda. Obiekty posiadają charakterystyki (szybki, obszerny, czarny, mokry) oraz możliwości (przyspieszanie, latanie, mruczenie, bulgotanie). Zadaniem programowania zorientowanego obiektowo jest reprezentacja tych obiektów w języku programowania.
C++ i programowanie zorientowane obiektowo Język C++ wspiera programowanie zorientowane obiektowo, obejmuje swym działaniem trzy podstawy takiego stylu programowania: kapsułkowanie, dziedziczenie oraz polimorfizm.
Kapsułkowanie Gdy inżynier chce dodać do tworzonego urządzenia rezystor, zwykle nie buduje go samodzielnie od początku — podchodzi do pojemnika z rezystorami, sprawdza kolorowe paski, oznaczające właściwości, i wybiera potrzebny element. Z punktu widzenia inżyniera rezystor jest „czarną skrzynką” — nieważny jest sposób w jaki działa (o ile tylko zachowuje się zgodnie ze swoją specyfikacją). Inżynier nie musi zastanawiać się nad wnętrzem rezystora, aby użyć go w swoim projekcie. Właściwość samozawierania się jest nazywana kapsułkowaniem. W kapsułkowaniu możemy zakładać opcję ukrywania danych. Ukrywanie danych jest możliwością, dzięki której obiekt może być używany przez osobę nie posiadającą wiedzy o tym, w jaki sposób działa. Skoro możemy korzystać z lodówki bez znajomości zasad działania kompresora, możemy też użyć dobrze zaprojektowanego obiektu nie znając jego wewnętrznych danych składowych. Sytuacja wygląda podobnie, gdy z rezystora korzysta inżynier: nie musi wiedzieć niczego o jego wewnętrznym stanie. Wszystkie właściwości rezystora są zakapsułkowane w obiekcie rezystora (nie są rozrzucone po całym układzie elektronicznym). Do efektywnego korzystania z rezystora nie jest potrzebna wiedza o sposobie jego działania. Można powiedzieć, że jego dane są ukryte wewnątrz obudowy. C++ wspiera kapsułkowanie poprzez tworzenie typów zdefiniowanych przez użytkownika, zwanych klasami. O tym, jak tworzyć klasy, dowiesz się z rozdziału szóstego, „Programowanie zorientowane obiektowo.” Po stworzeniu, dobrze zdefiniowana klasa działa jako spójna całość — jest używana jako jednostka. Wewnętrzne działanie klasy powinno być ukryte. Użytkownicy dobrze zdefiniowanych klas nie muszą wiedzieć, w jaki sposób one działają; muszą jedynie wiedzieć, jak z nich korzystać.
Dziedziczenie i ponowne wykorzystanie Gdy inżynierowie z Acme Motors chcą zbudować nowy samochód, mają do wyboru dwie możliwości: mogą zacząć od początku lub zmodyfikować istniejący już model. Być może ich model, Gwiazda, jest prawie doskonały, ale chcą do niego dodać turbodoładowanie i sześciobiegową skrzynię biegów. Główny inżynier nie chciałby zaczynać od początku, zamiast tego wolałby zbudować nowy, podobny model, z tym dodatkowym wyposażeniem. Nowy model
5
ma nosić nazwę Kwazar. Kwazar jest rodzajem Gwiazdy, wyposażonym w nowe elementy (według NASA, kwazary są bardzo jasnymi ciałami, wydzielającymi ogromne ilości energii). C++ wspiera dziedziczenie. Można dzięki niemu deklarować nowe typy, będące rozszerzeniem istniejących już typów. Mówi się, że nowa podklasa jest wyprowadzona z istniejącego typu i czasem nazywa się ją typem wyprowadzonym (pochodnym). Kwazar jest wyprowadzony z Gwiazdy i jako taki dziedziczy jej możliwości, ale w razie potrzeby może je uzupełnić lub zmodyfikować. Dziedziczenie i jego zastosowania w C++ zostaną omówione w rozdziale dwunastym, „Dziedziczenie” oraz szesnastym, „Zaawansowane dziedziczenie.”
Polimorfizm Nowy Kwazar może reagować na naciśnięcie pedału gazu inaczej niż Gwiazda. Kwazar może korzystać z wtrysku paliwa i turbodoładowania, natomiast w Gwieździe benzyna po prostu wpływa do gaźnika. Użytkownik jednak nie musi wiedzieć o tych różnicach, po prostu naciska na pedał gazu i samochód robi to, co do niego należy, bez względu na to, jakim jest pojazdem. C++ sprawia że różne obiekty „robią odpowiednie rzeczy” poprzez mechanizm zwany polimorfizmem funkcji i polimorfizmem klas. „Poli” oznacza wiele, zaś „morfizm” oznacza w tym przypadku formę. Pojęcie „polimorfizm” oznacza, że ta sama nazwa może przybierać wiele form, zostanie ono szerzej omówione w rozdziale dziesiątym, „Funkcje zaawansowane” oraz czternastym, „Polimorfizm.”
Jak ewoluowało C++ Gdy powszechnie znane stały się analiza, projektowanie i programowanie zorientowane obiektowo, Bjarne Stroustrup sięgnął do najpopularniejszego języka przenaczonego do tworzenia komercyjnego oprogramowania, C, i rozszerzył go, uzupełniając o elementy umożliwiające programowanie zorientowane obiektowo. Choć C++ stanowi nadzbiór języka C, a wszystkie poprawne programy C są także poprawnymi programami C++, różnica pomiędzy C a C++ jest bardzo znacząca. C++ przez wiele lat czerpało korzyści ze swego pokrewieństwa z C, gdyż programiści mogli łatwo przejść z C do tego nowego języka. Aby w pełni skorzystać z jego zalet, wielu programistów musiał pozbyć się swoich przyzwyczajeń i nauczyć nowego sposobu formułowania i rozwiązywania problemów programistycznych.
Czy należy najpierw poznać C? Natychmiast nasuwa się więc pytanie: skoro C++ jest nadzbiorem C, to czy powinienem najpierw nauczyć się C? Stroustrup i większość innych programistów C++ nie tylko zgadza się, że wcześniejsze poznanie języka C nie jest konieczne, ale także że brak jego znajomości może stanowić pewną zaletę.
6
Programowanie w C opiera się na programowaniu strukturalnym; natomiast C++ jest oparte na programowaniu zorientowanym obiektowo. Poznawanie języka C tylko po to, by „oduczyć” się niepożądanych nawyków nabytych podczas pracy z C, jest błędem. Nie zakładamy że masz jakiekolwiek doświadczenie programistyczne. Jeśli jednak jesteś programistą C, kilka pierwszych rozdziałów tej książki będzie stanowić dla ciebie powtórzenie posiadanych już wiadomości. Prawdziwą pracę nad tworzeniem obiektowo zorientowanego oprogramowania zaczniemy dopiero od rozdziału szóstego.
C++ a Java i C# C++ jest obecnie dominującym językiem oprogramowania komercyjnego. W ostatnich latach pojawiła się dla niego silna konkurencja w postaci Javy, jednak „wahadło wróciło” i wielu programistów, którzy porzucili C++ dla Javy, zaczyna do niego powracać. Języki te są tak podobne, że opanowanie jednego jest równoznaczne z opanowaniem dziewięćdziesięciu procent drugiego. C# jest nowym językiem, opracowanym przez Microsoft dla platformy .Net. C# stanowi w zasadzie podzbiór C++, i choć oba języki różnią się w kilku zasadniczych sprawach, poznanie C++ oznacza poznanie około dziewięćdziesięciu procent C#. Upłynie jeszcze wiele lat, zanim przekonamy się, czy C# będzie poważnym konkurentem dla C++; jednak nawet, gdy tak się stanie, praca włożona w poznanie C++ z pewnością okaże się doskonałą inwestycją.
Standard ANSI Międzynarodowy standard języka C++ został stworzony przez komitet ASC (Accredited Standards Committee), działający w ramach ANSI (American National Standards Institute). Standard C++ jest nazywany standardem ISO (International Standards Organization), standardem NCITS (National Committee for Information Technology Standards), standardem X3 (starsza nazwa NCITS) oraz standardem ANSI/ISO. W tej książce będziemy odwoływali się do standardu ANSI, gdyż to określenie jest najbardziej popularne. Standard ANSI próbuje zapewnić przenośność C++ — zapewnia na przykład to, że kod zgodny ze standardem ANSI napisany dla kompilatora Microsoftu skompiluje się bez błędów w kompilatorze innego producenta. Ponieważ kod w tej książce jest zgodny ze standardem ANSI, powinien kompilować się bez błędów na Macintoshu, w Windows lub w komputerze z procesorem Alpha. Dla większości osób uczących się języka C++, standard ANSI będzie niewidoczny. Ten standard istnieje już od dłuższego czasu i obsługuje go większość głównych producentów. Włożyliśmy wiele trudu, by zapewnić że cały kod w tej książce jest zgodny z ANSI.
7
Przygotowanie do programowania C++, bardziej niż inne języki, wymaga od programisty zaprojektowania programu przed jego napisaniem. Banalne problemy, takie jak te przedstawiane w kilku pierwszych rozdziałach książki, nie wymagają projektowania. Jednak złożone problemy, z którymi profesjonalni programiści zmagają się każdego dnia, wymagają projektowania; zaś im dokładniejszy i pełniejszy projekt, tym większe prawdopodobieństwo, że program rozwiąże problemy w zaplanowanym czasie, nie przekraczając budżetu. Dobry projekt sprawia także, że program jest w dużym stopniu pozbawiony błędów i łatwy w konserwacji. Obliczono, że połączony koszt debuggowania i konserwacji stanowi co najmniej dziewięćdziesiąt procent kosztów tworzenia oprogramowania. Ponieważ dobry projekt może te koszty zredukować, staje się ważnym czynnikiem wpływającym na ostateczne wydatki związane z tworzenia programu. Pierwszym pytaniem, jakie powinniśmy zadać, przygotowując się do projektowania programu jest: jaki problem ma zostać rozwiązany? Każdy program powinien ustanawiać jasny, dobrze określony celem – przekonasz się, że w tej książce nawet najprostszy program spełnia ten postulat. Drugie pytanie, stawiane przez każdego dobrego programistę, to: czy można to osiągnąć bez konieczności pisania własnego oprogramowania? Ponowne wykorzystanie starego programu, użycie pióra i papieru lub zakup istniejącego oprogramowania, jest często lepszym rozwiązaniem problemu niż pisanie nowego programu. Programista znajdujący takie alternatywy nigdy nie będzie narzekał na brak pracy; znajdowanie najtańszych rozwiązań dzisiejszych problemów otwiera nowe możliwości na przyszłość. Zakładając, że rozumiesz problem, i że wymaga on napisania nowego programu, jesteś gotów do rozpoczęcia projektowania. Proces pełnego zrozumienia problemu (analiza) i znajdowania jego rozwiązania (projekt) stanowi niezbędną podstawę dla pisania komercyjnych aplikacji na najwyższym, profesjonalnym poziomie.
Twoje środowisko programowania Zakładamy, że twój kompilator posiada tryb, w którym może wypisywać tekst bezpośrednio na ekranie, bez konieczności wykorzystania środowiska graficznego, na przykład Windows czy Macintosh. Poszukaj opcji takiej, jak console, console wizard czy easy window lub przejrzyj dokumentację kompilatora. Kompilator może posiadać własny, wbudowany edytor tekstów, lub do tworzenia plików programów możesz użyć komercyjnego edytora lub procesora tekstów. Ważne jest, by bez względu na to, jaki program stosujemy, miał on możliwość zapisywania zwykłych plików tekstowych, nie zawierających osadzonych w tekście kodów i poleceń formatowania. Bezpiecznymi pod tym względem edytorami są na przykład Notatnik w Windows, program Edit w DOS-ie, Brief, Epsilon, Emacs i vi. Wiele komercyjnych procesorów tekstu, takich jak WordPerfect, Word czy tuziny innych, także oferuje możliwość zapisywania zwykłych plików tekstowych.
8
Pliki tworzone za pomocą edytora tekstów są nazywane plikami źródłowymi, w przypadku języka C++ zwykle posiadają nazwy z rozszerzeniem .cpp, .cp lub .c. W tej książce wszystkie pliki źródłowe posiadają rozszerzenie .cpp, ale sprawdź w swoim kompilatorze, jakich plików oczekuje. UWAGA Większość kompilatorów C++ nie „zwraca uwagi” na rozszerzenia nadawane nazwom plików źródłowych, ale jeśli nie określisz tych plików, wiele z nich domyślnie korzysta z rozszerzenia .cpp. Należy jednak zachować ostrożność, gdyż niektóre kompilatory traktują pliki .c jako pliki języka C, zaś pliki .cpp jako pliki języka C++. Sprawdź koniecznie dokumentację kompilatora.
Tak
Nie
Do tworzenia plików źródłowych używaj prostego edytora tekstów lub skorzystaj z edytora wbudowanego w kompilator.
Nie używaj procesora tekstów zapisującego wraz z tekstem specjalne znaki formatujące. Jeśli korzystasz z takiego procesora, zapisuj pliki jako tekst ASCII.
Zapisuj pliki, nadając im rozszerzenie .c, .cp lub .cpp. Sprawdź w dokumentacji kompilatora i linkera, w jaki sposób należy kompilować i budować programy.
Tworzenie programu Choć kod źródłowy w pliku wygląda na niezrozumiały i każdy, kto nie zna C++, będzie miał trudności ze zrozumieniem jego przeznaczenia, kod ten przyjmuje czytelną dla człowieka postać. Plik kodu źródłowego nie jest programem i, w odróżnieniu od pliku wykonywalnego, nie może zostać wykonany (uruchomiony).
Tworzenie pliku obiektowego za pomocą kompilatora Do zamiany kodu źródłowego w program używamy kompilatora. Sposób uruchomienia go i wskazania mu plików źródłowych zależy od konkretnego kompilatora; sprawdź w tym celu posiadaną przez ciebie dokumentację.
9
Gdy kod źródłowy zostanie skompilowany, tworzony jest plik obiektowy. Ten plik ma często rozszerzenie .obj1 – jednak w dalszym ciągu nie jest to program wykonywalny. Aby zmienić go w program wykonywalny, należy użyć tzw. linkera, czyli programu łączącego.
Tworzenie pliku wykonywalnego za pomocą linkera Programy C++ zwykle powstają w wyniku łączenia jednego lub więcej plików .obj z jedną lub więcej bibliotekami. Biblioteka (ang. library) jest zbiorem połączonych plików, dostarczanym wraz z kompilatorem. Może też zostać nabyta osobno lub stworzona i skompilowana samodzielnie. Wszystkie kompilatory C++ są dostarczane wraz z bibliotekami użytecznych funkcji (lub procedur) oraz klas, które można zastosować w programie. O klasach i funkcjach porozmawiamy szczegółowo w następnych rozdziałach. Kroki konieczne do stworzenia pliku wykonywalnego to: 1. Stworzenie pliku kodu źródłowego z rozszerzeniem .cpp. 2. Skompilowanie kodu źródłowego do pliku z rozszerzeniem .obj. 3. Połączenie pliku .obj z wymaganymi bibliotekami w celu stworzenia programu wykonywalnego.
Cykl tworzenia programu Gdyby każdy program zadziałał już przy pierwszej próbie uruchomienia, wtedy pełny cykl tworzenia wyglądałby następująco: pisanie programu, kompilowanie kodu źródłowego, łączenie plików .obj, uruchomienie programu wykonywalnego. Niestety, prawie każdy program (nawet najbardziej trywialny) może zawierać błędy, często nazywane „pluskwami”. Niektóre błędy uniemożliwiają kompilację, inne uniemożliwiają łączenie, zaś jeszcze inne objawiają się dopiero podczas działania programu. Bez względu na rodzaj błędu, należy go poprawić – oznacza to edycję kodu źródłowego, ponowną kompilacja i łączenie, oraz ponowne uruchomienie programu. Cały ten cykl został przedstawiony na rysunku 1.1, schematycznie obrazuje on kolejne kroki w cyklu tworzenia programu wykonywalnego.
Rys. 1.1. Kroki wykonywane podczas tworzenia programu w języku C++
1
Plik .obj jest kodem wynikowym programu (ang. object code). Stanowi translację (przekład) tekstu źródłowego na język zrozumiały dla komputera. Kod wynikowy jest zawsze wczytywany przez linker (konsolidator) — przyp.tłum.
10
11
HELLO.cpp — twój pierwszy program w C++ Tradycyjne książki o programowaniu zaczynają od wypisania na ekranie słów „Witaj Świecie”2 lub od innej „wariacji na ten temat”. Ta uświecona tradycją formuła zostanie zachowana także i tu. Wpisz swój pierwszy program bezpośrednio do edytora, dokładnie przepisując jego treść. Gdy będziesz pewien, że został wpisany poprawnie, zapisz go do pliku, skompiluj, połącz i uruchom. Program wypisze na ekranie słowa „Witaj Świecie”. Nie martw się na razie tym, jak działa; teraz powinieneś jedynie poznać cykl tworzenia programu. Każdy element programu zostanie omówiony w kilku następnych rozdziałach. OSTRZEŻENIE Na przedstawionym poniżej listingu po lewej stronie umieszczone zostały numery linii. Te numery służą jedynie jako punkty odniesienia dla opisu w tekście. Nie należy ich wpisywać do kodu programu. Na przykład, w linii 1. listingu 1.1 należy wpisać:
#include
Listing 1.1. HELLO.cpp, program „Witaj Świecie”. 0: 1: 2: 3: 4: 5: 6:
#include int main() { std::cout << "Witaj Swiecie!\n"; return 0; }
Upewnij się, czy wpisałeś kod dokładnie tak, jak na listingu. Zwróć szczególną uwagę na znaki przestankowe. Znaki << w linii 4. są symbolem przekierowania, który na większości klawiatur uzyskuje się wciskając klawisz Shift, po czym dwukrotnie naciskając klawisz przecinka. Pomiędzy słowami std i cout w linii 4. występują dwa dwukropki (:). Linie 4. i 5. kończą się średnikiem (;). Upewnij się także, czy postępujesz zgodnie z zaleceniami kompilatora. Większość kompilatorów potrafi połączyć (zbudować) program wykonywalny automatycznie, ale sprawdź to w dokumentacji. Jeśli pojawią się błędy, dokładnie przejrzyj kod i sprawdź, czym różni się od kodu z listingu. Gdy zauważysz błąd w pierwszej linii, na przykład cannot find file iostream (nie można znaleźć pliku iostream), sprawdź w dokumentacji kompilatora, w jaki sposób należy ustawić ścieżkę do dołączanych plików lub zmienne środowiskowe. Gdy otrzymasz błąd informujący o braku prototypu dla main, tuż przed linią 2. dopisz linię int main();. W takim przypadku musisz dopisać tę linię przed początkiem funkcji main w każdym programie
2
Jak zwykle w takich przypadkach, pojawia się problem polskich znaków diakrytycznych. Wpisanie w kodzie programu słów „Witaj Świecie” w dosłownym brzmieniu, spowodowałoby pojawianie się na ekranie dziwnego znaczka (w miejscu litery Ś). W związku z tym w treści listingów, w tekstach wypisywanych przez program, zrezygnowałem ze stosowania polskich znaków diakrytycznych, zastępując je odpowiednikami łacińskimi. — przyp.tłum.
12
pojawiającym się w tej książce. Większość kompilatorów tego nie wymaga, ale istnieje kilka wyjątków. Pełny program będzie wyglądał następująco: 1: 2: 3: 4: 5: 6: 7: 8:
#include int main(); // większość kompilatorów nie wymaga tej linii int main() { std::cout << "Witaj Swiecie!\n"; return 0; }
UWAGA Trudno jest czytać program samemu, nie wiedząc jak są wymawiane specjalne znaki i słowa kluczowe. Pierwszą linię odczytujemy jako : „hasz-inklad ajoustrim”. Linia 6. to „es-ti-di-siaut Witaj Świecie.”
Spróbuj uruchomić plik HELLO.exe; program powinien wypisać: Witaj Swiecie!
bezpośrednio na ekranie. Jeśli tak się stało, gratulacje! Właśnie wpisałeś, skompilowałeś i uruchomiłeś swój pierwszy program w C++. Być może nie wygląda to efektownie, ale każdy profesjonalny programista C++ zaczynał dokładnie od tego właśnie programu.
Korzystanie z bibliotek standardowych
Jeśli masz bardzo stary kompilator, przedstawiony powyżej program nie będzie działał — nie zostaną odnalezione nowe biblioteki standardu ANSI. W takim przypadku zmień kod programu na:
0: 1: 2: 3: 4: 5: 6:
13
#include int main() { cout << "Witaj Swiecie!\n"; return 0; }
Zwróć uwagę, że tym razem nazwa biblioteki kończy się na .h (kropka-h) i że nie korzystamy już z std:: na początku linii 4. Jest to stary, poprzedzający ANSI styl plików nagłówkowych. Jeśli twój kompilator zadziała z tym programem, lecz nie poradzi sobie z wersją przedstawioną wcześniej, oznacza to, że jest prawdziwym antykiem. Nadaje się jedynie do wykorzystania w trakcie czytania kilku pierwszych rozdziałów, ale gdy przejdziemy do wzorców i wyjątków, taki kompilator już nie wystarczy.
Zaczynamy pracę z kompilatorem Ta książka nie jest związana z określonym kompilatorem. Oznacza to, że zawarte w niej programy powinny działać z każdym zgodnym z ANSI kompilatorem C++, na każdej dostępnej platformie (Windows, Mac, UNIX, Linux, itd.). Większość programistów pracuje jednak w Windows, zaś większość profesjonalnych programistów używa kompilatorów Microsoftu. Nie jestem w stanie opisać szczegółów kompilowania i łączenia za pomocą każdego istniejącego kompilatora, ale mogę jedynie pokazać od czego zacząć w kompilatorze Visual C++ 6. Inne kompilatory działają podobne, zatem będziesz wiedział, od czego rozpocząć. Kompilatory mimo wszystko różnią się od siebie, więc pamiętaj o przejrzeniu dokumentacji3.
Budowanie projektu Hello World Aby stworzyć i przetestować program Hello World, wykonaj następujące kroki: 1.
Uruchom kompilator.
2.
W menu File (plik) wybierz polecenie New (nowy).
3.
Wybierz pozycję Win32 Console Application (aplikacja konsoli Win32) i w polu Project name wpisz nazwę projektu, taką jak Przyklad 1. Następnie kliknij na przycisku OK.
4.
W oknie dialogowym wybierz opcję An Empty Project (pusty projekt) i kliknij na przycisku OK.
5.
W menu File wybierz polecenie New.
6.
Wybierz pozycję C++ Source File (plik źródłowy C++) i nadaj jej nazwę prz1.
7.
Wpisz kod programu, w sposób opisany nieco wcześniej.
8.
W menu Build (buduj) wybierz polecenie Build Przyklad1.exe.
9.
Sprawdź, czy nie pojawiły się błędy kompilacji lub łączenia.
10. Naciśnij Ctrl+F5, aby uruchomić program. 11. Naciśnij spację, aby zakończyć program. 3
Szczegółowy opis tworzenia projektu za pomocą kompilatorów Borlanda można znaleźć w książce Andrzeja Daniluka „C++Builder 5. Ćwiczenia praktyczne,” Helion 2001. — przyp.redakcji.
14
Często zadawane pytanie
Mogę uruchomić program, ale znika on tak szybko, że nie mogę odczytać wypisywanego tekstu. Co się dzieje?
Odpowiedź
Sprawdź w dokumentacji kompilatora; powinna ona zawierać informacje na temat sposobu zachowania na ekranie wyników działania programu. W przypadku kompilatorów Microsoftu najprościej jest użyć kombinacji Ctrl+F5.
W przypadku starszych kompilatorów Borlanda należy kliknąć prawym przyciskiem myszy w oknie edycji kodu, kliknąć na poleceniu Target Export, zmienić opcję Platform na Win 3.1 (16), po czym ponownie przekompilować i uruchomić program. Okno wyników pozostanie otwarte do momentu, w którym sam je zamkniesz.
Na zkończenie, w każdym kompilatorze, bezpośrednio przed instrukcją return (tj. pomiędzy liniami 4. i 5. na listingu 1.1), możesz dodać przedstawione poniżej linie:
int x; std::cin >> x;
Spowodują one wstrzymanie działania programu i oczekiwanie na wprowadzenie jakiejś wartości. Aby zakończyć działanie programu, wpisz liczbę (na przykład 1), po czym naciśnij klawisz Enter.
Znaczenie std::cin i std::cout zostanie omówione w następnych rozdziałach. Na razie uznaj je za swego rodzaju magiczne zaklęcia.
Prawdopodobnie bardzo wielu czytelników posiada kompilator Borlanda (np. C++Builder). Pisząc programy dla Windows w środowisku Buildera, należy zwrócić uwagę na pewne charakterystyczne dla tego środowiska cechy. 1. Dobrym zwyczajem jest poinformowanie kompilatora o zakończeniu listy plików nagłówkowych, tj. plików zapisanych w ostrych nawiasach (absolutnie nie dotyczy to tzw. modułów z rozszerzeniem .h). Dokonujemy tego, korzystając z dyrektywy prekompilatora
15
#pragma hdrstop (ang. header stop). Zapis ten znacznie przyśpieszy proces konsolidacji
projektu. 2. Jeżeli tworzymy aplikacje konsolowe za pomocą Borland C++Buildera w celu „przytrzymania” ekranu (w tym wypadku normalnego tekstowego okienka DOS), zawsze możemy użyć funkcji getch()przynależnej do prototypu conio.h. Należy jednak pamiętać, że funkcja ta podtrzymywana jest obecnie jedynie w Win32 i nie należy już do szerokiego standardu ANSI C/C++. 3. Przestrzeń strumieni wejścia-wyjścia w C++Builder jest dostatecznie dobrze zdefiniowana, dlatego w tym wypadku nie jest konieczne jawne wskazywanie kompilatorowi miejsca ich pochodzenia. Poniższy przykład ilustruje te cechy. 0: 1: 2: 3: 4: 5: 6: 7: 8: 9:
#include #include #pragma hdrstop int main() { cout << "Witaj Swiecie "<< endl; cout << "Nacisnij klawisz..."; getch(); return 0; }
Należy zwrócić uwagę, iż przy następującym zapisie, wykorzystującym jawne wskazanie przestrzeni strumieni wejścia-wyjścia, działanie programu będzie również poprawne: 0: 1: 2: 3: 4: 5: 6: 7: 8: 9:
#include #include #pragma hdrstop int main() { std::cout << "Witaj Swiecie "<< std::endl; std::cout << "Nacisnij klawisz..."; getch(); return 0; }
Błędy kompilacji Błędy kompilacji mogą pojawić się z wielu powodów. Zwykle są rezultatem pomyłki przy wpisywaniu lub innych, mniej istotnych przyczyn. Dobry kompilator nie tylko poinformuje, co jest nie tak, ale także wskaże dokładnie miejsce kodu, w którym został popełniony błąd. Najlepsze kompilatory sugerują nawet, co należy z tym zrobić!
16
Możesz zobaczyć, co się stanie gdy celowo umieścimy w programie błąd. Jeśli program HELLO.cpp działa poprawnie, zmodyfikuj go teraz i usuń zamykający nawias klamrowy z linii 6. Teraz program będzie wyglądał tak, jak na listingu 1.2. Listing 1.2. Demonstracja błędu kompilacji. 0: 1: 2: 3: 4: 5:
#include int main() { std::cout << "Witaj Swiecie!\n"; return 0;
Ponownie skompiluj program; powinieneś zauważyć błąd podobny do tego: Hello.cpp(7) : fatal error C1004: unexpected end of file found4
Ten błąd informuje o nazwie pliku i numerze linii, w której wystąpił problem, oraz o przyczynie pojawienia się problemu (przyznam jednak, że ten komunikat jest nieco tajemniczy). Czasem błędy informują jedynie o ogólnej przyczynie problemu. Gdyby kompilator mógł idealnie zidentyfikować każdy z problemów, mógłby poprawiać kod samodzielnie.
4 W kompilatorach najnowszej generacji firmy Borland komunikat o ww. błędzie jest wyświetlony w formie nie wymagającej głębszego zastanowiania się nad jego znaczeniem:
[C++ Error] Hello.cpp(6): E2134 Compound statement missing } — przyp.redakcji.
17
Rozdział 2. Anatomia programu C++ Programy C++ składają się z obiektów, funkcji, zmiennych i innych elementów składowych. Większość tej książki jest poświęcona dogłębnemu stanowi obszerny opisowi tych elementów, ale byjednakże w celu zrozumiećnia zasad ich współdziałania, jak one do siebie pasują, musisz najpierw poznać pełny cały działający program. W tym rozdziale: •
Ppoznasz elementy programu C++,.
•
Ddowiesz się, jak te elementy ze sobą współpracują,.
•
Ddowiesz się, czym jest funkcja i do czego służy.
Prosty program Nawet prosty program HELLO.cpp z rozdziału pierwszego, „Zaczynamy”, miał wiele interesujących elementów. W tym podrozdziale omówimy go bardziej szczegółowo. Listing 2.1 zawiera przypomnieniena treścić programu HELLO.cpp z poprzedniego rozdziału. Listing 2.1. HELLO.cpp demonstruje elementy programu C++. 0: 1: 2: 3: 4: 5: 6:
#include int main() { std::cout << "Witaj Swiecie!\n"; return 0; }
Wynik działania: Witaj Swiecie!
1
1
W środowisku programowania, takim, jak np. Visual, pod napisem Witaj Swiecie! pojawi się jeszcze dodatkowo napis: Press any key to continue. Naciśnięcie jakiegokolwiek klawisza zamknie działanie programu HELLO.exe i usunie z ekranu jego okno (ramkę). — przyp.redakcji.
Analiza: W linii 0. do bieżącego pliku jest dołączany plik iostream. Oto jak tosposób jego działania: pierwszy znak jest symbolem #, który stanowi sygnał dla preprocesora. Za każdym razem gdy uruchamiasz kompilację, uruchamiany jest preprocesor. Preprocesor odczytuje kod źródłowy, wyszukując linii zaczynających się od znaku # (hasz) i operuje na nich jeszcze przed uruchomieniem właściwego kompilatora. Preprocesor zostanie szczegółowo omówiony opisany w rozdziale 21., „Co dalej.” Polecenie #include jest instrukcją preprocesora, mówiącą mu: „Po mnie następuje nazwa pliku. Znajdź ten plik i wstaw go w to miejsce.” Nawiasy kątowe dookoła nazwy pliku informują preprocesor, by szukał pliku w standardowych miejscach dla tego typu plików. Jeśli twój kompilator jest odpowiednio skonfigurowany, nawiasy kątowe powodują, że preprocesor szuka pliku iostream w kartotece zawierającej wszystkie pliki nagłówkowe dostarczane wraz z kompilatorem. Plik iostream (Input Output Stream — strumień wejścia-wyjścia) jest używany przez obiekt cout, asystujący przy wypisywaniu tekstu na ekranie. Efektem działania linii 0. jest wstawienie zawartości pliku iostream w do kodu programu, tak jakby został on wpisany przez ciebie. Preprocesor działa przed każdym rozpoczęciem kompilacji, poprzedzając jej właściwą fazę. Preprocesor tłumaczyPonadto zamienia wszystkie linie rozpoczynające się od znaku hasz (#) na specjalne polecenia, przygotowując ostateczny kod źródłowy dla kompilatora. Linia 2. rozpoczyna rzeczywisty program od funkcji o nazwie main(). Funkcję tę posiada każdy program C++. Funkcja jest blokiem kodu wykonującym jedną lub więcej operacji. Zwykle funkcje są wywoływane przez inne funkcje, lecz funkcja main() jest pod tym względem specjalnaodbiega od standardu. Gdy program rozpoczyna działanie, funkcja main() jest ona wywoływana automatycznie. Funkcja main (), podobnie jak inne funkcje, musi określić rodzaj zwracanej przez siebie wartości. Typem zwracanej przez nią w programie HELLO.cpp wartości zwracanej przez funkcję main() w programie HELLO.cpp jest typ int, cto oznacza że po zakończeniu działania ta funkcja ta zwraca systemowi operacyjnemu wartość całkowitą (ang. integer). W tym przypadku zwracaną wartością jest 0, tak jak to widzimy w linii 5. Zwrócenie wartości systemowi operacyjnemu jest stosunkowo mało ważną i rzadko wykorzystywaną możliwością, ale standard C++ wymaga, by funkcja main() była została zadeklarowana tak jak pokazano.
UWAGA Niektóre kompilatory pozwalają na deklarację main() , jeśli funkcja main ma zwracać typ void. Nie jest to zgodne ze standardem C++ i nie powinieneś się do tego przyzwyczajać. Niech funkcja main() zwraca wartość typu int, zaś w ostatniej linii tej funkcji po prostu zwracaj wartość 0.
UWAGA Niektóre systemy operacyjne umożliwiają sprawdzanie (testowanie), jaka wartość została zwrócona przez program. Zgodnie z konwencją, zwrócenie wartości 0 oznacza, że program normalnie zakończył działanie normalnie.
Wszystkie funkcje rozpoczynają się od nawiasu otwierającego ({) i kończą nawiasem zamykającym (}). Nawiasy dla funkcji main() znajdują się w liniach 3. i 6. Wszystko, co znajduje się pomiędzy nawiasem otwierającym a zamykającym, jest uważane za treść funkcji. Prawdziwa zawartość treść programu znajduje się w linii 4. Obiekt cout jest używany do wypisywania komunikatów na ekranie. Obiektami zajmiemy się ogólnie w rozdziale 6., „Programowanie zorientowane obiektowo”, zaś obiekt cout i powiązany z nim obiekt cin omówimy szczegółowo w rozdziale 17., „Strumienie.” Te dwa obiekty, cin i cout, są w C++ używane, odpowiednio,: do obsługi wejścia (na przykład z klawiatury) oraz wyjścia (na przykład na ekran). Obiekt cout jest dostarczany przez bibliotekę standardową bibliotekę. Biblioteka jest kolekcją klas. Standardowa biblioteka jest standardową kolekcją dostarczaną wraz z każdym kompilatorem zgodnym z ANSI. Używając specyfikatora przestrzeni nazw, std, informujemy kompilator, że obiekt cout jest częścią biblioteki standardowej. Ponieważ możesz mieć kilka, pochodzących od różnych dostawców, obiektów o tych samych nazwach, C++ dzieli „świat” na „przestrzenie nazw.”. Przestrzeń nazw jest sposobem na powiedzenie, że: „gdy mówię cout, mam na myśli to, że cout jest częścią standardowej przestrzeni nazw, a nie jakiejś innej przestrzeni nazw.” Mówimy to kompilatorowi poprzez umieszczenie przed nazwą cout znaków sdt i podwójnego dwukropka. Więcej na temat różnych przestrzeni nazw powiemy sobie w następnych rozdziałach. Oto jak sposób używanycia jest obiektu cout: wpisz słowo cout, a po nim operator przekierowania wyjścia (<<). To, co następuje po operatorze przekierowania wyjścia, zostanie wypisane na ekranie. Jeśli chcesz, by został wypisany łańcuch znaków, pamiętaj o ujęciu go w cudzysłowy, ( tak jak widzimy w linii 4.) Łańcuch tekstowy jest serią znaków drukowalnych znaków. Dwa ostatnie znaki, \n, informują obiekt cout , by po słowach „Witaj Świecie!” umieścił nową linię. Ten specjalny kod zostanie wyjaśniony opisany szczegółowo podczas omawiania obiektu cout w rozdziale 18., „Przestrzenie nazw.” Funkcja main() kończy się w linii 6. nawiasem zamykającym.
Rzut oka na klasę cout W rozdziale 17. zobaczysz, w jaki sposób używa się obiektu cout do wypisywania danych na ekranie. Na razie możesz z niego korzystać, nie wiedząc właściwie, jak działa. Aby wypisać wartość na ekranie, napisz słowo cout, po nim operator wstawiania (<<), uzyskiwany w wyniku dwukrotnego wpisania znaku mniejszości (<). Choć w rzeczywistości są to dwa znaki, C++ traktuje je jako pojedynczy symbol.
Po znaku wstawiania wpisz dane przeznaczone do wypisania dane. Listing 2.2 ilustruje sposób użycia tego obiektu. Wpisz w tym przykładzie dokładnie to, co pokazano na listingu, z wyjątkiem tegotym, że zamiast nazwiska Jesse Liberty wpisz swoje własne (chyba, że rzeczywiście nazywasz się Jesse Liberty). Listing 2.2. Użycie cout. 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21:
// Listing 2.2 uŜycie std::cout #include int main() { std::cout << "Hej tam.\n"; std::cout << "To jest 5: " << 5 << "\n"; std::cout << "Manipulator std::endl "; std::cout << "wypisuje nowa linie na ekranie."; std::cout << std::endl; std::cout << "To jest bardzo duza liczba:\t" << 70000; std::cout << std::endl; std::cout << "To jest suma 8 i 5:\t"; std::cout << 8+5 << std::endl; std::cout << "To jest ulamek:\t\t"; std::cout << (float) 5/8 << std::endl; std::cout << "I bardzo, bardzo duza liczba:\t"; std::cout << (double) 7000 * 7000 << std::endl; std::cout << "Nie zapomnij zamienic Jesse Liberty "; std::cout << "na swoje nazwisko...\n"; std::cout << "Jesse Liberty jest programista C++!\n"; return 0; }
Wynik działania: Hej tam. To jest 5: 5 Manipulator std::endl wypisuje nowa linie na ekranie. To jest bardzo duza liczba: 70000 To jest suma 8 i 5: 13 To jest ulamek: 0.625 I bardzo, bardzo duza liczba: 4.9e+007 Nie zapomnij zamienic Jesse Liberty na swoje nazwisko... Jesse Liberty jest programista C++! UWAGA Niektóre kompilatory zawierają błąd,; wymagający by przed przekazaniem sumy do obiektu cout należy umieścić ją w nawiasach. Tak więc linia 12. powinna być zmieniona na: 12
std::cout << (8+5) << std::endl;
Analiza: W linii 1., instrukcja #include powoduje włączenie zawartości pliku iostream do kodu źródłowego zawartości pliku iostream. Jest ona wymagana, jeśli używasz obiektu cout i powiązanych z nim funkcji. W linii 4. znajduje się najprostsze zastosowanie obiektu cout, do wypisania ciągu znaków. Symbol \n jest specjalnym znakiem formatującym. Informuje on cout by wypisał na ekranie znak nowej linii (tzn. aby dalsze wypisywanie rozpoczął od następnej linii ekranu).
W linii 5. przekazujemy do cout trzy wartości, oddzielone operatorem wstawiania. Pierwszą z tych wartości jest łańcuch "To jest 5: ". Zwróć uwagę na odstęp po dwukropku. Ten odstęp (spacja) jest częścią łańcucha. Następnie do operatora wstawiania jest przekazywana wartość 5 oraz znak nowej linii (zawsze w cudzysłowach lub apostrofach). To, co powoduje wypisanie na ekranie linii To jest 5: 5
Ponieważ po pierwszym łańcuchu nie występuje znak nowej linii, następna wartość jest wypisywana tuż za nim. Nazywa się to konkatenacją (łączeniem) dwóch wartości. W linii 6. wypisywany jest informacyjny komunikat informacyjny, po czym (w linii 8.) następuje użyciety zostaje manipulatora endl. Przeznaczeniem endl jest wypisanie nowej linii na ekranie. (Inne zastosowania dla endl zostaną omówione w rozdziale 16.). Zwróć uwagę, że endl także pochodzi z biblioteki standardowej.
UWAGA endl pochodzi od słów „end line” (zakończ linię) i w rzeczywistości jest to „end-L”, a nie „end-jeden”.
W linii 9. został wprowadzony nowy znak formatujący, \t. Powoduje on wstawienie znaku tabulacji i jest używany w celu wyrównywania wydruków wyników w liniach od 9. do 15 w celu wyrównywania wydruków wyników. Linia 9. pokazuje, że wypisywane mogą być wypisywane nie tylko liczby całkowite, ale także długie liczby całkowite. Linia 12. demonstruje, że cout potrafi wykonać proste dodawanie. Do obiektu jest przekazywana wartość 8+5, lecz wypisywana jest suma 13. W linii 14. do cout jest wstawiana wartość 5/8. Symbol (float) informuje cout , że chcemy aby ta wartość została obliczona jako rozwinięcie dziesiętne, więc wypisywany jest ułamek. W linii 16. cout otrzymuje wartość 7000 * 7000, zaś symbol (double) służy do poinformowania cout , że jest to wartość zmiennoprzecinkowa. Wszystko to zostanie wyjaśnione w rozdziale 3., „Zmienne i stałe,” przy okazji omawiania typów danych. W linii 16. podstawiłeś swoje nazwisko, zaś wynik potwierdza, że naprawdę jesteś programistą C++. Musi być to prawda, skoro tak uważa komputer tak uważa!
Używanie przestrzeni nazw standardowych Z pewnością zauważyłeś, że przed każdym cout i endl występuje std::, co po jakimś czasie może być irytujące. Choć korzystanie z odnośnika do przestrzeni nazw jest poprawną formeą, jednak jest okazuje się dosyć żmudne przy wpisywaniu. Standard ANSI oferuje dwa rozwiązania tejgo niewielkiejgo niedogodnościproblemu.
Pierwszym z nich jest poinformowanie kompilatora, ( na początku listingu kodu), że będziemy używać cout i endl z biblioteki standardowej, tak jak pokazano na listingu 2.3. Listing 2.3. Użycie słowa kluczowego using. 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24:
// Listing 2.3 - uŜycie słowa kluczowego "using" #include int main() { using std::cout; using std::endl; cout << "Hej tam.\n"; cout << "To jest 5: " << 5 << "\n"; cout << "Manipulator endl "; cout << "wypisuje nowa linie na ekranie."; cout << endl; cout << "To jest bardzo duza liczba:\t" << 70000; cout << endl; cout << "To jest suma 8 i 5:\t"; cout << 8+5 << endl; cout << "To jest ulamek:\t\t"; cout << (float) 5/8 << endl; cout << "I bardzo, bardzo duza liczba:\t"; cout << (double) 7000 * 7000 << endl; cout << "Nie zapomnij zamienic Jesse Liberty "; cout << "na swoje nazwisko...\n"; cout << "Jesse Liberty jest programista C++!\n"; return 0; }
Wynik działania: Hej tam. To jest 5: 5 Manipulator endl wypisuje nowa linie na ekranie. To jest bardzo duza liczba: 70000 To jest suma 8 i 5: 13 To jest ulamek: 0.625 I bardzo, bardzo duza liczba: 4.9e+007 Nie zapomnij zamienic Jesse Liberty na swoje nazwisko... Jesse Liberty jest programista C++!
Analiza: Zauważ, że wynik jest identyczny. Jedyną różnicą pomiędzy listingiem 2.3 ia 2.2 jest to, że w liniach 4. i 5. informujemy kompilator, że będziemy używać dwóch obiektów ze standardowej biblioteki. Używamy do tego słowa kluczowego using. Gdy to zrobimy, nie musimy już kwalifikować obiektów cout i endl. Drugim sposobem uniknięcia niedogodności pisania std:: przed cout i endl jest po prostu poinformowanie kompilatora, że będziemy używać całej przestrzeni nazw standardowych;, tj., że każdy obiekt, który nie zostanie oznaczony, z założenia będzie pochodził z przestrzeni nazw standardowych. W tym przypadku, zamiast pisać using std::cout; napiszemy po prostu using namespace std;, tak jak pokazano na listingu 2.4. Listing 2.4. Użycie słowa kluczowego namespace. 0: 1: 2:
// Listing 2.3 - uŜycie przestrzeni nazw standardowych #include int main()
3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23:
{ using namespace std; cout << "Hej tam.\n"; cout << "To jest 5: " << 5 << "\n"; cout << "Manipulator endl "; cout << "wypisuje nowa linie na ekranie."; cout << endl; cout << "To jest bardzo duza liczba:\t" << 70000; cout << endl; cout << "To jest suma 8 i 5:\t"; cout << 8+5 << endl; cout << "To jest ulamek:\t\t"; cout << (float) 5/8 << endl; cout << "I bardzo, bardzo duza liczba:\t"; cout << (double) 7000 * 7000 << endl; cout << "Nie zapomnij zamienic Jesse Liberty ", cout << "na swoje nazwisko...\n", cou << "Jesse Liberty jest programista C++!\n"; return 0; }
Analiza: Także tym razem wynik jest identyczny z wynikami uzyskiwanymi we wcześniejszymich wersjamich programu. Zaletą zapisu using namespace std; jest to, że nie musimy określać obiektów, z których chcemy korzystać (na przykład cout oraz endl). Wadą jest to, że ryzykujemyo niezamierzonego użyciea obiektów z niewłaściwej biblioteki. Puryści preferują zapisywanie std:: przed każdym wystąpieniem cout i endl. Osoby bardziej leniwe wolą używać using namespace std; i nNa tym zakończmy temat. W tej książce w większości przypadków będziemy pisać, z jakich obiektów korzystamy, ale od czasu do czasu, dla samej odmiany, spwypróbujemy także pozostałyche stylówstyle.
Komentarze Gdy piszesz program, to, co chcesz osiągnąć, zawsze jest jasne i oczywiste to, co chcesz osiągnąć. Jednak co zabawne, miesiąc gdy do niego wracasz później, gdy do niego wracasz, kkod może okazać się całkiem niezrozumiały. Nie jestem w stanie przewidzieć, co może być niezrozumiałego w twoim programie, ale tak jestzdarza się to zawsze. Aby sobie z tym poradzić, a także, by pomóc innym w zrozumieniu twojego kodu, powinieneś używać komentarzy. Komentarze są tekstem całkowicie ignorowanym przez kompilator, lecz mogą natomiast informować czytającego o tym, co robisz w danym punkcie programu.
Rodzaje komentarzy Komentarze w C++ występują w dwóch odmianach: jako komentarze podwójnego ukośnika (//) oraz jako komentarze ukośnika i gwiazdki (/*). Komentarz podwójnego ukośnika, nazywany komentarzem w stylu C++, informuje kompilator, by zignorował wszystko, co po nim następuje, aż do końca linii.
Komentarz ukośnika i gwiazdki informuje kompilator, by zignorował wszystko to, co jest zawarte pomiędzy znakami /* oraz */. Te znaki są nazywane komentarzami w stylu C. Każdemu znakowi /* musi odpowiadać zamykający komentarz znak */. Jak można się domyślać, komentarze w stylu C są używane także w programach C, lecz ; jednakże komentarze C++ nie są częścią oficjalnej definicji języka C. Większość programistów używa w większości przypadkówprzeważnie komentarzy w stylu C++, rezerwując komentarze w stylu C rezerwując dlado wyłączania z kompilacji większych bloków kodu. Komentarze w stylu C++ mogą występować w blokach kodu „wyskomentowanych” komentarzami w stylu C. Ignorowana jest zawartość całego „wyskomentowanego” bloku, włącznie z komentarzami w stylu C++.
Używanie komentarzy Niektórzy programiści zalecają stosowanie komentarzy przed każdą funkcją, ( w celu wyjaśnienia, jakie czynności co ta funkcja robi wykonuje i jakie wartości zwraca). Osobiście nie zgadzam się z tym, gdyż uważam, że komentarze w nagłówkach funkcji zwykle są nieaktualne, bo prawie nikt nie pamięta o tym, by zaktualizować je po modyfikacji kodu je zaktualizować. Funkcje powinny mieć takieprzyjmować takie nazwy, by na ich podstawie których można było jasno określić, do czego służą. Z kolei niejasne i skomplikowane fragmenty kodu powinny zostać przeprojektowane i przepisane, tak, aby same się objaśniały. Dość często zdarza się, że komentarze stanowią dla leniwego programisty wymówkę pretekst dla niedbałości. Nie sugeruję oczywiście by nigdyżeby w ogóle nie korzystać z komentarzy, choć oczywiście nie powinny służyć do wyjaśniania niejasnego kodu. Zamiast tegoW takim przypadku należy poprawić sam kod. Mówiąc krótko, pisz swoje programy dobrze, zaś komentarzy używaj w celu poprawy jegozwiększenia ich zrozumiałości. Listing 2.5 demonstruje użycie komentarzy, i pokazujące, że nie wpływają one na działanie programu ani na otrzymywane wyniki. Listing 2.5. HELP.cpp demonstruje komentarze. 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: same 14: 15: 16:
#include int main() { using std::cout; /* to jest komentarz w stylu C i rozciąga się on aŜ do zamykającego znaku gwiazdki i ukośnika */ cout << "Witaj Swiecie!\n"; // ten komentarz kończy się wraz z końcem tej linii cout << "Ten komentarz sie zakonczyl!\n"; // komentarze podwójnego ukośnika mogą występować w linii /* podobnie jak komentarze ukośnika i gwiazdki */ return 0; }
Wynik: Witaj Swiecie!
Ten komentarz sie zakonczyl!
Analiza: Komentarze w liniach od 6. do 8. są całkowicie ignorowane przez kompilator, podobnie jak komentarze w liniach 10., 13. oraz 14. Komentarz w linii 10. kończy się wraz z końcem linii, lecz komentarze w liniach 6. i 14. wymagają użycia zamykającego znaku komentarza.
I jJeszcze jedna uwaga na temat komentarzy Komentarze, które informują o czymś oczywistym, są bezużyteczne. W rzeczywistości, mMogą być wręcz szkodliwe, gdyż np. gdy kod może ulecgnie zmianie, lecz a programista może zapomnieć o aktualizacji komentarza. Jednak to, co jest oczywiste dla jednej osoby, może być niezrozumiałe dla innej, więc zatem musisz samodzielnie to osądzićocenić użyteczność komentarza. Mówiąc oOgólnie rzecz biorąc, komentarze powinny informować nie o tym, co się dzieje, ale o tym, dlaczego tak się dzieje.
Funkcje Choć Funkcja main() nie jest zwykłą funkcją, jednak jest to funkcja niezwykła. Aby być użyteczną,Normalnie funkcja musi być wywołana w czasie działania programu. Funkcja main() jest wywoływana przez system operacyjny. Program jest wykonywany „linia po linii” – w kolejności, w jakiej ich występowaniaują w kodzie źródłowym, aż do napotkania wywołania funkcji. Wtedy działanie programu się „rozgałęzia” się w celu wykonania funkcji. Gdy funkcja zakończy działanie, zwraca sterowanie do linii kodu następującej bezpośrednio po linii, w której funkcja została wywołana. Dobrąym przykłądem jest analogią jest ostrzenie ołówka. Jeśli rysujesz obrazek ia w ołówku złamie się grafit, przestajesz rysować, idziesz naostrzyć ołówek, po czym wracasz do tego miejsca rysunku, w którym przerwałeś rysowanie. Gdy program wymaga wykonania usługi, może w tym celu wywołać funkcję, po czym po zakończeniu jej działanie zakończeniu podjąć działanie w tym miejscu, w którym je przerwał działanie. Tę ideęPrzebieg tego procesu demonstruje listing 2.6. Listing 2.6. Demonstracja Przykład wywołania funkcji. 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14:
#include // funkcja DemonstrationFunction // wypisuje informacyjny komunikat void DemonstrationFunction() { std::cout << "Wewnatrz funkcji DemonstrationFunction\n"; } // funkcja main - wypisuje komunikat, następnie // wywołuje funkcję DemonstrationFunction, po czym wypisuje // drugi komunikat. int main() { std::cout << "Wewnatrz funkcji main\n" ;
15: 16: 17: 18:
DemonstrationFunction(); std::cout << "Ponownie w funkcji main\n"; return 0; }
Wynik: Wewnatrz funkcji main Wewnatrz funkcji DemonstrationFunction Ponownie w funkcji main
Analiza: Funkcja DemonstrationFunction() jest zdefiniowana w liniach od 5. do 7. Gdy zostanie wywołana, wypisuje na ekranie komunikat, po czym wraca. Linia 12. stanowi początek rzeczywistego programu. W linii 14. funkcja main() wypisuje komunikat informujący, że program znajduje się wewnątrz funkcji main(). Po wypisaniu tego komunikatu, wywołuje funkcję DemonstrateFunction()w w linii 15 wywołuje funkcję DemonstrateFunction(). To wywołanie powoduje że wykonane zostają instrukcje zawarte wewnątrz funkcji DemonstrationFunction (). W tym przypadku, cała funkcja składa się z kodu w linii 6., który kod ten wypisuje kolejny komunikat. Gdy funkcja DemonstrateFunction() kończy działanie (linia 7.), program powraca do linii, z której ta funkcja została wywołana. W tym przypadku program wraca do linii 16., w której funkcja main() wypisuje ostatnią linię komunikatu. UWAGA Zwróć uwagę, że w funkcji DemonstrationFunction nie ma sensu stosować instrukcji using w funkcji DemonstrationFunction, , gdyż z obiektu cout korzystamy tylko raz, w związku z czym w zupełności wystarczy zastosowanie zapisu std::cout. W funkcji main() mMógłbym zdecydować się na skorzystanie z instrukcji using w funkcji main(),, ale także tym razem po prostu użyłem obiektu wraz z nazwą przestrzeni nazw, tak jak pokazano w liniach 14. i 16.
Korzystanie z funkcji Funkcje albo zwracają albo wartość, albo zwracają typ void, który oznacza, że nie zwracają niczego. Funkcja dodająca dwie liczby całkowite może zwracać ich sumę, i jakofunkcja taka będzie zdefiniowana jako zwracająca wartość całkowitą. Funkcja, która jedynie wypisującae komunikat, nie musi niczego zwracać i jako taka może zostać zadeklarowana jako zwracająca typ void. Funkcja składa się z nagłówka oraz ciała. Z kolei nNagłówek składa się ze zwracanego typu, nazwy funkcji oraz parametrów funkcji. Parametry funkcji umożliwiają przekazywanie wartości do funkcji. Tak więcZatem, jeśli funkcja ma dodawać dwie liczby, będą one parametrami funkcji. Oto typowy nagłówek funkcji: int Sum(int a, int b)
Parametr jest deklaracją typu wartości, jaka zostanie przekazana funkcji; sama wartość przekazywana w wywołaniu funkcji jest nazywana argumentem. Wielu programistów używa
określeń parametr i argument jako synonimów. Inni zwracają uwagę na to techniczne rozróżnienie. W tej książce obu terminów będziemy używać zamiennie. Ciało funkcji składa się z otwierającego nawiasu klamrowego, braku lub pewnej liczby instrukcji lub ich braku, oraz klamrowego nawiasu zamykającego. Instrukcje określają działanie funkcji. Funkcja może zwracać wartość, używając instrukcji return. Ta instrukcja jednocześnie powoduje również wyjście z funkcji. Jeśli nie umieścisz instrukcji return wewnątrz funkcji nie umieścisz instrukcji return, funkcja automatycznie na koniec zwróci wartość typu void. Zwracana wartość musi mieć typ zgodny z typem zadeklarowanym w nagłówku funkcji. UWAGA Funkcje zostaną omówione bardziej szczegółowo w rozdziale 5., „Funkcje.” Typy, jakie mogą być przez funkcje zwracane przez funkcje, zostaną dokładniej omówione w rozdziale 3., „Zmienne i stałe.” Informacje podane zamieszczone w tym rozdziale mają na celu jedynie pobieżne ogólne zaprezentowanie funkcji, gdyż są one używane w praktycznie wszystkich programach C++.
Listing 2.7 przedstawia funkcję, która otrzymuje dwa parametry całkowite i zwraca wartość całkowitą. Nie martw się na razie o składnię lubi sposób posługiwania się wartościami całkowitymi (na przykład int x); zostanieą to one dokładnie omówione w rozdziale 3. Listing 2.7. FUNC.cpp demonstruje prostą funkcję. 0: #include 1: int Add (int x, int y) 2: { 3: std::cout << "Funkcja Add() otrzymala " << x << " oraz " << y << "\n"; 4: return (x+y); 5: } 6: 7: int main() 8: { 9: using std::cout; 10: using std::cin; 11: 12: 13: cout << "Jestem w funkcji main()!\n"; 14: int a, b, c; 15: cout << "Wpisz dwie liczby: "; 16: cin >> a; 17: cin >> b; 18: cout << "\nWywoluje funkcje Add()\n"; 19: c=Add(a,b); 20: cout << "\nPonownie w funkcji main().\n"; 21: cout << "c zostalo ustawione na " << c; 22: cout << "\nOpuszczam program...\n\n"; 23: return 0; 24: }
Wynik: Jestem w funkcji main()! Wpisz dwie liczby: 3 5 Wywoluje funkcje Add() Funkcja Add() otrzymala 3 oraz 5 Ponownie w funkcji main().
c zostalo ustawione na 8 Opuszczam program...
Analiza: Funkcja Add() jest zdefiniowana w linii 1. Otrzymuje dwa parametry w postaci liczb całkowitych i zwraca wartość całkowitą. Sam program zaczyna się w linii 7. Program prosi użytkownika o dwie liczby (linie od 15. do 17.). Użytkownik wpisuje liczby, oddzielając je spacją, po czym wciska naciska klawisz Enter. Funkcja main() w linii 19. przekazuje funkcji Add()wartości wpisane przez użytkownika. Przetwarzanie przechodzi do funkcji Add(), która rozpoczyna się od linii 1. Parametry a i b są wypisywane, po czym sumowane. Rezultat sumowania jest zwracany w linii 4., po czym następuje wyjście z funkcji. Znajdujący się wW liniach 16. i 17. obiekt cin służy do uzyskania liczb dla zmiennych a i b, zaś obiekt cout jest używany do wypisania tych wartości na ekranie. Zmienne i inne aspekty tego programu zostaną dogłębnie szerzej omówione w kilku następnych rozdziałach.
Rozdział 3. Zmienne i stałe Program musi mieć możliwość przechowywania używanych przez siebie danych, z których korzysta. Dzięki Zzmienneym i stałeym mamy możliwość oferują różne sposoby reprezentowania, przechowywania i manipulowania tymi danymi. W tym rozdziale dowiesz sięZ tego rozdziału dowiesz się •
Jjak deklarować i definiować zmienne oraz stałe,.
•
Jjak przypisywać wartości zmiennym oraz jak nimi manipulować, tymi wartościami.
•
Jjak wypisywać wartość zmiennej na ekranie.
Czym jest zmienna? W C++ zmienna jest miejscem dosłuży do przechowywania informacji. – Zmienna jest to jest miejscem w pamięci komputera, w którym możesz umieścić wartość, i z którego możesz ją później odczytać. Zwróć uwagę, że jest to jedynie tymczasowe miejsce przechowywania. Gdy wyłączysz komputer, wszystkie zmienne zostają utracone. Trwałe pPrzechowywanie trwałe jest zupełnie innym zagadnieniemprzebiega zupełnie inaczej. Zwykle zmienne są przechowywane trwale w wynikudzięki umieszczeniau ich w bazie danych lub w pliku na dysku. Przechowywanie w pliku na dysku jest zostanie omaówiaone w rozdziale 16., „Zaawansowane dziedziczenie.”
Dane są przechowywane w pamięci Pamięć komputera można traktować jako szereg pojemników. Każdy pojemnik jest jednym z bardzo wielu takich samych pojemników, ułożonych jeden za drugim. Każdy pojemnik — czyli miejsce w pamięci — jest oznaczony kolejnym numerem. Te numery są nazywane adresami pamięci. Zmienna rezerwuje jeden lub więcej pojemników, w których może przechowywać wartość.
Nazwa zmiennej (na przykład myVariable) jest stanowi etykietkąę jednego z tych pojemników,; dzięki której niej można go łatwo zlokalizować bez znajomościnie znając rzeczywistego adresu pamięci. Rysunek 3.1 przedstawia schematyczną reprezentację tej idei przebiegu tego procesu. Jak na nim widać, zmienna myVariable rozpoczyna się od adresu 103. W zależności od rozmiaru tej zmiennej, może ona zająć w pamięci jeden lub więcej adresów w pamięci.
Rysunek 3.1. Schematyczna reprezentacja pamięci
UWAGA Skrót RAM oznacza Random Access Memory (pamięć o dostępie swobodnym). Gdy uruchamiasz program, to jest on ładowany z pliku na dysku do pamięci RAM z pliku na dysku. W pamięci RAM są także tworzone są wszystkie zmienne. Gdy programiści mówią oużywają terminu „pamięcić”, zwykle mają na myśli pamięć RAM, do której się odwołują.
Przydzielanie pamięci Gdy definiujesz zmienną w C++, musisz poinformować kompilator o jej rodzaju tej zmiennej: czy jest to liczba całkowita, znak, czy coś innego. Ta informacja mówi kompilatorowi, ile miejsca ma zarezerwować dla zmiennej oraz jaki rodzaj wartości będzie w tej zmiennejniej przechowywany. Każdy pojemnik ma rozmiar jednego bajtu. Jeśli tworzona zmienna ma rozmiar czterech bajtów, to wymaga czterech bajtów pamięci, czyli czterech pojemników. Typ zmiennej (na przykład liczba całkowita) mówi kompilatorowi, ile pamięci (ile pojemników) ma przygotować dla zmiennej. Swojego czasu programiści musieli koniecznie znać się na bitach i bajtach, gdyż stanowią one podstawowe jednostki dla przechowywania wszelkiego rodzaju danych. Programy komputerowe pozwalają na uzyskanie lepszej abstrakcjiucieczkę od tych szczegółów, ale jest w dalszym ciągu pomocna jest wiedza o tym, jak dane są przechowywaneiu danych.. Szybki Krótki przegląd koncepcji stanowiących podstawę matematyki dwójkowej możesz znaleźć w dodatku A, „Binarnie i szesnastkowo.” UWAGA Jeśli przeraża cię matematyka sprawia że z krzykiem wybiegasz z pokoju, wtedy nie przejmuj się dodatkiem A; tak naprawdę nie jest ci potrzebny. Prawdą jest, że programiści nie muszą już być równocześnie matematykami, choć zawsze pożądana jest umiejętność logicznego i racjonalnego myślenia jest zawsze pożądana.
Rozmiar liczb całkowitych W danym komputerze każdy typ zmiennych zajmujeW każdym komputerze każdy typ zmiennych zajmuje stałą, niezmienną ilość miejsca. To Oznaczya to, że liczba całkowita może mieć w jednym komputerze dwa bajty, zaś w innym cztery, lecz w danym komputerze ma zawsze ten sam, niezmienny rozmiar. Zmienna typu char (używanegoa do przechowywania znaków) ma najczęściej rozmiar jednego bajtu. Krótka liczba całkowita (short) ma w większości komputerów rozmiar dwóch bajtów, zaś długa liczba całkowita (long) ma zwykle cztery bajty. Natomiast liczba całkowita (bez słowa kluczowego short lub long) może mieć dwa lub cztery bajty. Można by sądzićprzypuszczać, że język powinien to precyzyjnie określać precyzyjnie, ale tak nie jest. Ustalono Jjedynie co musi zostać zapewnione, to to, że typ short musi mieć rozmiar mniejszy lub równy niż typowi int (integer, liczba całkowita), który z kolei musi mieć rozmiar mniejszy lub równy typowi long. Najprawdopodobniej jednak pracujesz z komputerem, w którym typ short ma dwa bajty, zaś typy int i long mają po cztery bajty. Rozmiar liczb całkowitych jest wyznaczany przez procesor (16 lub 32 bity) oraz kompilator. W nowoczesnych, 32-bitowych procesorach Pentium z nowoczesnymi najnowszymi kompilatorami (na przykład Visual C++4 lub nowsze), liczby całkowite mają cztery bajty. W tej książce zakładamy, że liczby całkowite (typ int) mają cztery bajty, choć w twoim przypadku może być inaczej. Znak jest pojedynczą literą, cyfrą lub symbolem i zajmuje pojedynczy bajt pamięci. Skompiluj i uruchom w swoim komputerze listing 3.1; pokaże on dokładny rozmiar każdego z tych typów. Listing 3.1. Sprawdzanie rozmiarów typów zmiennych istniejących w twoim komputerze. 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22:
#include int main() { using std::cout; cout << "Rozmiar zmiennej typu int to:\t\t" << sizeof(int) << " bajty.\n"; cout << "Rozmiar zmiennej typu short int to:\t" << sizeof(short) << " bajty.\n"; cout << "Rozmiar zmiennej typu long int to:\t" << sizeof(long) << " bajty.\n"; cout << "Rozmiar zmiennej typu char to:\t\t" << sizeof(char) << " bajty.\n"; cout << "Rozmiar zmiennej typu float to:\t\t" << sizeof(float) << " bajty.\n"; cout << "Rozmiar zmiennej typu double to:\t" << sizeof(double) << " bajty.\n"; cout << "Rozmiar zmiennej typu bool to:\t" << sizeof(bool) << " bajty.\n"; return 0; }
Wynik: Rozmiar Rozmiar Rozmiar Rozmiar Rozmiar Rozmiar Rozmiar
zmiennej zmiennej zmiennej zmiennej zmiennej zmiennej zmiennej
typu typu typu typu typu typu typu
int to: short int to: long int to: char to: float to: double to: bool to:
4 2 4 1 4 8 1
bajty. bajty. bajty. bajty. bajty. bajty. bajty.
UWAGA W twoim komputerze rozmiary zmiennych mogą być inne.
Większość listingu 3.1 powinna już być ci znanaznajoma. Podzieliłem linie tak, aby mieściły się na w szerokości całej stroniestrony książki., tak w Więc w rzeczywistości linie 6. i 7. powinny stanowić linię pojedynczą linię. Kompilator ignoruje tak zwane białe spacje (spacje, tabulatory, przejścia do nowej linii), więc linie 6 i 7 traktuje linie 6. i 7. jak jedną całość. Nowym elementem w tym programie jest użycie w liniach od 6. do 19. operatora (funkcji) sizeof(). Ten operator jest dostarczany przez kompilator; i informuje on o rozmiarze obiektu przekazywanego mu jako parametr. Na przykład w linii 7., do operatora sizeof() jest przekazywane słowo kluczowe int. Używając Za pomocą tego operatora byłem w stanie sprawdzić że w moim komputerze zmienne typu int mają ten sam rozmiar, co zmienne typu long, czyli cztery bajty.
Zapis ze znakiem i bez znaku Wszystkie typy całkowite występują w dwóch odmianach: signed (ze znakiem) oraz unsigned (bez znaku). Czasem potrzebna jest liczba ujemna, a czasem dodatnia. Liczby całkowite (krótkie i długie) bez słowa kluczowego unsigned są traktowane jako liczby ze znakiem. Liczby całkowite signed są albo dodatnie albo ujemne, zaś liczby całkowite unsigned są zawsze dodatnie. Liczby ze znakiem i liczby bez znaku mają po tyle samo bajtów, więc największa liczba, jaką można przechować w zmiennej całkowitej bez znaku jest dwa razy większa niż największa liczba dodatnia jaką można przechować w zmiennej całkowitej ze znakiem. Zmienna typu unsigned short może pomieścić wartości od 0 do 65 535. Połowa tych wartości (reprezentowana przez zmienną typu signed short) jest ujemna, więc zmienna tego typu może przechowywać jedynie wartości od –32 768 do 32 767. Jeśli wydaje ci się to skomplikowane, zajrzyj do dodatku A.
Podstawowe typy zmiennych Język C++ posiada jeszcze kilka innych wbudowanych typów zmiennych. Można je wygodnie podzielić na typy całkowite, typy zmiennopozycyjne oraz typy znakowe. Zmienne zmiennoprzecinkowe zawierają wartości, które można wyrazić w postaci ułamków dziesiętnych — stanowią obszerny podzbiór liczb rzeczywistych. Zmienne znakowe mają rozmiar jednego bajtu i są używane do przechowywania 256 znaków i symboli pochodzących z zestawów znaków ASCII i rozszerzonego ASCII. Zestaw ASCII jest standardowym zestawem znaków używanych w komputerach. ASCII stanowi skrót od American Standard Code for Information Interchange. Prawie każdy komputerowy
system operacyjny obsługuje zestaw ASCII, choć wiele systemów obsługuje także inne, międzynarodowe zestawy znaków. W tabeli 3.1 przedstawione zostały typy zmiennych używanych w programach C++. Tabela pokazuje typ zmiennej, jej rozmiar w pamięci (zakładany w tej książce) oraz rodzaj wartości, jakie mogą być przechowywane w zmiennej takiego typu. Zakres przechowywanych wartości zależy od rozmiaru zmiennej, więc sprawdź w swoim komputerze wynik działania programu z listingu 3.1. Tabela 3.1. Typy zmiennych Typ
Rozmiar
Wartości
bool
1 bajt
prawda lub fałsz
unsigned short int
2 bajty
Od 0 do 65 535
short int
2 bajty
Od –32 768 do 32 767
unsigned long int
4 bajty
Od 0 do 4 294 967 295
long int
4 bajty
Od –2 147 483 648 do 2 147 483 647
int (16 bitów)
2 bajty
Od –32 768 do 32 767
int (32 bity)
4 bajty
Od –2 147 483 648 do 2 147 483 647
unsigned int (16 bitów)
2 bajty
Od 0 do 65 535
unsigned int (32 bity)
4 bajty
Od 0 do 4 294 967 295
char
1 bajt
256 różnych znaków
float
4 bajty
Od 1.2e-38 do 3.4e38 (dodatnich lub ujemnych)
double
8 bajtów
Od 2.2e-308 do 1.8e308 (dodatnich lub ujemnych)
UWAGA Rozmiary zmiennych mogą się różnić od pokazanych w tabeli 3.1 (w zależności od używanego kompilatora i komputera). Jeśli twój komputer dał taki sam wynik, jaki pokazano pod listingiem 3.1, tabela 3.1 powinna być zgodna z twoim kompilatorem. Jeśli wynik działania listingu 3.1 w twoim komputerze jest inny, powinieneś sprawdzić w instrukcji kompilatora jaki zakres wartości może być przechowywany w zmiennych różnych typów.
Definiowanie zmiennej Zmienną tworzy się lub definiuje poprzez określenie jej typu, po którym wpisuje się jedną lub więcej spacji, zaś po nich nazwę zmiennej i średnik. Nazwę zmiennej może stanowić praktycznie dowolna kombinacja liter, lecz nie może ona zawierać spacji. Poprawnymi nazwami zmiennych są
na przykład: x, J23qrsnf czy myAge. Dobra nazwa zmiennej nie tylko informuje o tym, do czego jest ona przeznaczona, ale także znacznie ułatwia zrozumienie działania programu. Przedstawiona poniżej instrukcja definiuje zmienną całkowitą o nazwie myAge: int myAge;
UWAGA Gdy deklarujesz zmienną, jest dla niej alokowana (przygotowywana i rezerwowana) pamięć. Wartość zmiennej stanowi to, co w danej chwili znajduje się w tym miejscu pamięci. Za chwilę zobaczysz, jak można przypisać nowej zmiennej określoną wartość.
W praktyce należy unikać tak przerażających nazw, jak J23qrsnf oraz ograniczyć użycie nazw jednoliterowych (takich jak x czy i) do zmiennych stosowanych jedynie pomocniczo. Postaraj się używać nazw opisowych, takich jak myAge (mój wiek) czy howMany (jak dużo). Są one łatwiejsze do zrozumienia trzy tygodnie po ich napisaniu i nie będziesz łamać sobie głowy nad tym, co chciałeś osiągnąć pisząc, tę linię kodu. Przeprowadź taki eksperyment: w oparciu o znajmość kilku pierwszych linii kodu, spróbuj odgadnąć do, czego on służy: Przykład 1: int main() { unsigned short x; unsigned short y; unsigned short z; z = x * y; return 0; };
Przykład 2: int main() { unsigned short Szerokosc; unsigned short Dlugosc; unsigned short Obszar; Obszar = Szerokosc * Dlugosc; return 0; };
UWAGA Jeśli skompilujesz ten program, kompilator ostrzeże cię, że te wartości nie zostały zainicjalizowane. Wkrótce dowiesz się, jak sobie poradzić z tym problemem.
Oczywiście, łatwiejsze do odgadnięcia jest przeznaczenie drugiego programu, a niedogodność polegająca wpisywaniu dłuższych nazw zmiennych zostaje z nawiązką nagrodzona (przez łatwość konserwacji drugiego programu).
Uwzględnianie wielkości liter Język C++ uwzględnia wielkość liter. Innymi słowy, odróżnia małe i duże litery. Zmienna o nazwie age różni się od zmiennej Age, która z kolei jest uważana za różną od zmiennej AGE.
UWAGA Niektóre kompilatory umożliwiają wyłączenie rozróżniania dużych i małych liter. Nie daj się jednak skusić – twoje programy nie będą wtedy działać z innymi kompilatorami, zaś inni programiści C++ będą mieli wiele problemów z twoim kodem.
Istnieją różne konwencje nazywania zmiennych, i choć nie ma znaczenia, którą z nich przyjmiesz, ważne jest, by zachować ją w całym kodzie programu. Niespójne nazewnictwo może znacznie utrudnić zrozumienie twojego kodu przez innych programistów. Wielu programistów decyduje się na używanie dla swoich zmiennych nazw składających się wyłącznie z małych liter. Jeśli nazwa wymaga użycia dwóch słów (na przykład „moje auto”), można zastosować dwie popularne konwencje: moje_auto lub mojeAuto. Druga forma jest nazywana zapisem wielbłąda, gdyż duża litera w jego środku przypomina nieco garb tego zwierzęcia. Niektórzy uważają, że znak podkreślenia (moje_auto) jest łatwiejszy do odczytania, jednak inni wolą go unikać, gdyż trudniej się go wpisuje. W tej książce będziemy stosować zapis wielbłąda, w którym wszystkie kolejne słowa będą zaczynać się od wielkiej litery: myCar (mojeAuto), theQuickBrownFox (szybkiRudyLis), itd. UWAGA Wielu zaawansowanych programistów korzysta ze stylu zapisu nazywanego notacją węgierską. Polega ona na poprzedzaniu każdej nazwy zmiennej zestawem znaków opisującym jej typ. Zmienne całkowite (integer) mogą rozpoczynać się od małej litery „i”, a zmienne typu long mogą zaczynać się od małej litery „l”. Inne litery oznaczają stałe, zmienne globalne, wskaźniki, itd. Taki zapis ma dużo większe znaczenie w przypadku języka C, dlatego nie będzie stosowany w tej książce.
Ten zapis jest nazywany notacją węgierską, ponieważ człowiek, który go wymyślił, Charles Simonyi z Microsoftu, jest Węgrem. Jego monografię można znaleźć pod adresem http://www.strangecreations.com/library/c/naming.txt.
Microsoft ostatnio zrezygnował z notacji węgierskiej, zaś zalecenia projektowe dla języka C# wyraźnie odradzają jej wykorzystanie. Strategię tę stosujemy również w języku C++.
Słowa kluczowe Niektóre słowa są zarezerwowane przez C++ i nie można używać ich jako nazw zmiennych. Są to słowa kluczowe, używane do sterowania działaniem programu. Należą do nich if, while, for czy main. Dokumentacja kompilatora powinna zawierać pełną listę słów kluczowych, ale słowem kluczowym prawie na pewno nie jest każda sensowna nazwa zmiennej. Lista słów kluczowych języka C++ znajduje się w dodatku B.
Tak
Nie
Definiuj zmienną, zapisując jej typ, a następnie jej nazwę.
Nie używaj słów kluczowych języka C++ jako nazw zmiennych.
Używaj znaczących nazw dla zmiennych. Pamiętaj, że język C++ uwzględnia wielkość znaków.
Nie używaj zmiennych bez znaku dla wartości ujemnych.
Zapamiętaj ilość bajtów, jaką każdy typ zmiennej zajmuje w pamięci oraz jakie wartości można przechowywać w zmiennych danego typu.
Tworzenie kilku zmienych jednocześnie W jednej instrukcji możesz tworzyć kilka zmiennych tego samego typu; w tym celu powinieneś zapisać typ, a po nim nazwy zmiennych, oddzielone przecinkami. Na przykład: unsigned int myAge, myWeight; // dwie zmienne typu unsigned int long int area, width, length; // trzy zmienne typu long
Jak widać, myAge i myWeight są zadeklarowane jako zmienne typu unsigned int. Druga linia deklaruje trzy osobne zmienne typu long; ich nazwy to area (obszar), width (szerokość) oraz length (długość). W obrębie jednej instrukcji nie można deklarować zmiennych o różnych typach.
Przypisywanie zmiennym wartości Do przypisywania zmiennej wartości służy operator przypisania (=). Na przykład zmiennej Width przypisujemy wartość 5, zapisując: unsigned short Width; Width = 5;
UWAGA Typ long jest skróconym zapisem dla long int, zaś short jest skróconym zapisem dla short int.
Możesz połączyć te kroki i zainicjalizować zmienną w chwili jej definiowania, zapisując: unsigned short Width = 5;
Inicjalizacja jest podobna do przypisania, a w przypadku zmiennych całkowitych różnica między nimi jest niewielka. Później, gdy poznasz stałe, przekonasz się, że pewne wartości muszą być zainicjalizowane, gdyż nie można im niczego przypisywać. Zasadniczą różnicą między inicjalizacją a przypisaniem jest to, że inicjalizacja odbywa się w chwili tworzenia zmiennej. Ponieważ można definiować kilka zmienych jednocześnie, podczas tworzenia można również inicjalizować więcej niż jedną zmienną. Na przykład: // Tworzymy dwie zmienne typu long i inicjalizujemy je long width = 5, length = 7;
W tym przykładzie inicjalizujemy zmienną width typu long, nadając jej wartość 5 oraz zmienną length tego samego typu, nadając jej wartość 7. Można także mieszać definicje i inicjalizacje: int myAge = 39, yourAge, hisAge = 40;
W tym przykładzie stworzyliśmy trzy zmienne typu int, inicjalizując pierwszą i trzecią z nich. Listing 3.2 przedstawia pełny, gotowy do kompilacji program, który oblicza obszar prostokąta i wypisuje wynik na ekranie. Listing 3.2. Przykład użycia zmiennych 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19:
// Demonstracja zmiennych #include int main() { using std::cout; using std::endl; unsigned short int Width = 5, Length; Length = 10; // tworzymy zmienną typu unsigned short i inicjalizujemy // ją iloczynem szerokości (Width) i długości (Length) unsigned short int Area = (Width * Length); cout << "Szerokosc:" << Width << "\n"; cout << "Dlugosc: " << Length << endl; cout << "Obszar: " << Area << endl; return 0; }
Wynik Szerokosc:5 Dlugosc: 10 Obszar: 50
Analiza Linia 1. dołącza wymagany plik nagłówkowy dla biblioteki iostream, dzięki czemu możemy korzystać z obiektu cout. Linia 3. rozpoczyna program. Linie 5. i 6. definiują cout i endl jako część przestrzeni nazw standardowych (std).
W linii 8. zdefiniowana jest zmienna całkowita Width typu unsigned sort, która zostaje zainicjalizowana wartością 5. Definiowana jest także inna zmienna typu unsigned short, zmienna Length, lecz nie jest ona inicjalizowana. W linii 9. zmiennej Length przypisywana jest wartość 10. W linii 13. jest definiowana zmienna całkowita Area typu unsigned short, która jest inicjalizowana przez wartość uzyskaną w wyniku mnożenia wartości zawartej w zmiennej Width przez wartość zawartą w zmiennej Length. W liniach od 15. do 17. wartości zmiennych są wypisywane na ekranie. Zwróć uwagę, że słowo endl powoduje przejście do nowej linii.
typedef Ciągłe wpisywanie unsigned short int może być żmudne, a co gorsza, może spowodować wystąpienie błędu. C++ umożliwia użycie słowa kluczowego typedef (od type definition, definicja typu), dzięki któremu możesz stworzyć skróconą formę takiego zapisu. Dzięki skróconemu zapisowi tworzysz synonim, lecz zwróć uwagę, że nie jest to nowy typ (będziesz go tworzyć w rozdziale 6., „Programowanie zorientowane obiektowo”). Przy zapisywaniu synonimu typu używa się słowa kluczowego typedef, po którym wpisuje się istniejący typ, zaś po nim nową nazwę typu. Całość kończy się średnikiem. Na przykład: typedef unsigned short int USHORT;
tworzy nową nazwę typu, USHORT, której można użyć wszędzie tam, gdzie mógłbyś użyć zapisu unsigned short int. Listing 3.3 jest powtórzeniem listingu 3.2, jednak zamiast typu unsigned short int została w nim użyta definicja USHORT. Listing 3.3. Przykład użycia typedef 0: // ***************** 1: // Demonstruje uŜycie słowa kluczowego typedef 2: #include 3: 4: typedef unsigned short int USHORT; //definiowane poprzez typedef 5: 6: int main() 7: { 8: 9: using std::cout; 10: using std::endl; 11: 12: USHORT Width = 5; 13: USHORT Length; 14: Length = 10; 15: USHORT Area = Width * Length; 16: cout << "Szerokosc:" << Width << "\n"; 17: cout << "Dlugosc: " << Length << endl; 18: cout << "Obszar: " << Area <
Wynik Szerokosc:5 Dlugosc: 10 Obszar: 50 UWAGA * oznacza mnożenie.
Analiza W linii 4. definiowany jest synonim USHORT dla typu unsigned short int. Poza tym program jest bardzo podobny do programu z listingu 3.2, a wyniki jego działania są takie same.
Kiedy używać typu short, a kiedy typu long? Jedną z przyczyn kłopotów początkujących programistów C++ jest konieczność wyboru pomiędzy zadeklarowaniem zmiennej jako wartości typu long lub jako wartości typu short. Reguła jest bardzo prosta: jeśli istnieje możliwość, że jakakolwiek wartość, która może zostać umieszczona w zmiennej, przekroczy dozwolony zakres wartości dla danego typu, należy użyć typu o większym zakresie. Jak pokazano w tabeli 3.1, zmienne typu unsigned short (zakładając że zajmują dwa bajty) mogą przechowywać wartości z zakresu od 0 do 65 535, zaś zmienne całkowite signed short dzielą ten zakres pomiędzy liczby dodatnie a ujemne; stąd maksymalne wartości stanowią w tym przypadku połowę maksymalnej wartości dla typu unsigned. Choć zmienne całkowite unsigned long mieszczą bardzo duże liczby (4 294 967 295), w dalszym ciągu są one znacznie ograniczone. Jeśli potrzebujesz większej liczby, musisz użyć typu float lub double, zmniejszając jednak precyzję ich przechowywania. Zmienne typu float i double mogą przechowywać naprawdę bardzo duże wartości, ale w większości komputerów ich precyzja ogranicza się do 7 lub 9 pierwszych cyfr. Oznacza to, że po tych kilku cyfrach liczba jest zaokrąglana. Krótsze zmienne zajmują mniej pamięci. Obecnie pamięć jest tania, więc nie wahaj się używać typu int, który w twoim komputerze zajmuje najprawdopodobniej cztery bajty.
Zawinięcie liczby całkowitej bez znaku Zmienne całkowite typu unsigned long mogą pomieścić duże wartości, ale co się stanie, gdy rzeczywiście zabraknie w nich miejsca? Gdy typ unsigned int osiągnie swoją maksymalną wartość, „przewija się” i zaczyna od zera, podobnie jak licznik kilometrów w samochodzie. Listing 3.4 pokazuje, co się dzieje, gdy w krótkiej zmiennej całkowitej spróbujesz umieścić zbyt dużą wartość. Listing 3.4. Przykład umieszczenia zbyt dużej wartości w zmiennej całkowitej bez znaku 0: 1: 2: 3:
#include int main() {
4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15:
using std::cout; using std::endl; unsigned short int smallNumber; smallNumber = 65535; cout << "krotka liczba:" << smallNumber << endl; smallNumber++; cout << "krotka liczba:" << smallNumber << endl; smallNumber++; cout << "krotka liczba:" << smallNumber << endl; return 0; }
Wynik krotka liczba:65535 krotka liczba:0 krotka liczba:1
Analiza W linii 7. zmienna smallNumber deklarowana jest jako zmienna typu unsigned short int. W moim komputerze zmienne tego typu mają dwa bajty i mogą pomieścić wartości od 0 do 65 535. W linii 8. zmiennej tej jest przypisywana maksymalna wartość, która jest następnie wypisywana w linii 9. W linii 10. zmienna smallNumber jest inkrementowana, czyli zwiększana o 1. Symbolem inkrementacji jest podwójny znak plus (++) (tak jak w nazwie języka C++, co symbolizuje inkrementację języka C). Tak więc wartością zmiennej smallNumber powinno być teraz 65 536. Ponieważ jednak zmienne typu unsigned short nie mogą przechowywać wartości większych od 65 535, wartość ta jest przewijana do 0, które jest wypisywane w linii 11. W linii 12. zmienna smallNumber jest inkrementowana ponownie, po czym wypisywana jest jej nowa wartość, czyli 1.
Zawinięcie liczby całkowitej ze znakiem Liczby całkowite ze znakiem różnią się od liczb całkowitych bez znaku, gdyż połowa wartości, jakie mogą reprezentować, jest ujemna. Zamiast tradycyjnego samochodowego licznika kilometrów, możesz wyobrazić sobie zegar podobny do pokazanego na rysunku 3.2. Liczby na tym zegarze rosną zgodnie z ruchem wskazówek zegara i maleją w kierunku przeciwnym. Spotykają się na dole tarczy (czyli na godzinie szóstej).
Rys. 3.2. Gdyby zegary stosowały liczby ze znakiem...
W odległości jednej liczby od zera istnieje albo 1 (w kierunku zgodnym z ruchem wskazówek) albo –1 (w kierunku przeciwnym). Gdy skończą się liczby dodatnie, przejdziesz do największej liczby ujemnej, a potem z powrotem do zera. Listing 3.5 pokazuje, co się stanie gdy do maksymalnej liczby dodatniej w zmiennej całkowitej typu short int dodasz 1. Listing 3.5. Przykład zwiększenia maksymalnej wartości dodatniej w licznie całkowitej ze znakiem. 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11:
#include int main() { short int smallNumber; smallNumber = 32767; std::cout << "krotka liczba:" << smallNumber << std::endl; smallNumber++; std::cout << "krotka liczba:" << smallNumber << std::endl; smallNumber++; std::cout << "krotka liczba:" << smallNumber << std::endl; return 0; }
Wynik krotka liczba:32767 krotka liczba:-32768 krotka liczba:-32767
Analiza W linii 3. zmienna smallNumber deklarowana jest jako zmienna typu signed short int (jeśli nie wskażemy jawnie, że zmienna jest unsigned, bez znaku, zakłada się że jest signed, ze znakiem). Program działa bardzo podobnie do poprzedniego, jednak osiągany przez niego wynik jest całkiem inny. Aby w pełni zrozumieć jego działanie, musisz wiedzieć w jaki sposób liczby całkowite ze znakiem są reprezentowane bitowo w dwubajtowych zmiennych całkowitych. Podobnie jak w przypadku liczb całkowitych bez znaku, liczby całkowite ze znakiem po najwyższej wartości dodatniej przewijają się do najwyższej wartości ujemnej.
Znaki Zmienne znakowe (typu char) zwykle mają rozmiar jednego bajtu, co wystarczy do przechowania jednej z 256 wartości (patrz dodatek C). Typ char może być interpretowany jako mała liczba (od 0 do 255) lub jako element zestawu kodów ASCII. Skrót ASCII pochodzi od słów American Standard Code for Information Interchange. Zestaw znaków ASCII oraz jego odpowiednik ISO (International Standards Organization) służą do kodowania wszystkich liter (alfabetu łacińskiego), cyfr oraz znaków przestankowych. UWAGA Komputery nie mają pojęcia o literach, znakach przestankowych i zdaniach. Rozpoznają tylko liczby. Zauważają tylko odpowiedni poziom napięcia na określonym złączu przewodów. Jeśli występuje napięcie, jest ono symbolicznie oznaczane jako jedynka, zaś gdy nie występuje, jest oznaczane jako zero. Poprzez grupowanie zer i jedynek, komputer jest w stanie generować wzory, które mogą być interpretowane jako liczby, które z kolei można przypisywać literom i znakom przestankowym.
W kodzie ASCII mała litera „a” ma przypisaną wartość 97. Wszystkie duże i małe litery, wszystkie cyfry oraz wszystkie znaki przestankowe mają przypisane wartości pomiędzy 0 a 127. Dodatkowe 128 znaków i symboli jest zarezerwowanych dla „wykorzystania” przez producenta komputera, choć standard kodowania stosowany przez firmę IBM stał się niejako „obowiązkowy”. UWAGA ASCII wymawia się jako „eski.”
Znaki i liczby Gdy w zmiennej typu char umieszczasz znak, na przykład „a”, w rzeczywistości jest on liczbą pochodzącą z zakresu od 0 do 255. Kompilator wie jednak, w jaki sposób odwzorować znaki (umieszczone wewnątrz apostrofów) na jedną z wartości kodu ASCII. Odwzorowanie litery na liczbę jest umowne; nie ma szczególnego powodu, dla którego mała litera „a” ma wartość 97. Dopóki zgadzają się na to klawiatura, kompilator i ekran, nie ma żadnych problemów. Należy jednak zdawać sobie sprawę z dużej różnicy pomiędzy wartością 5 a znakiem „5.” Ten ostatni ma w rzeczywistości wartość 53, podobnie jak litera „a,” która ma wartość 97. Ilustruje to listing 3.6.
Listing 3.6. Wypisywanie znaków na podstawie ich kodów 0: 1: 2: 3: 4: 5: 6:
#include int main() { for (int i = 32; i<128; i++) std::cout << (char) i; return 0; }
Wynik !"#$%&'()*+,-./0123456789:;<=>?@ABCDEF GHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijkl mnopqrstuvwxyz{|}~_
Ten prosty program wypisuje znaki o wartościach od 32 do 127.
Znaki specjalne Kompilator C++ rozpoznaje pewne specjalne znaki formatujące. Najpopularniejsze z nich przedstawia tabela 3.2. Kody te umieszcza się w kodzie programu, wpisując znak odwrotnego ukośnika, a po nim znak specjalny. Aby umieścić w kodzie znak tabulacji, należy wpisać apostrof, odwrotny ukośnik, literę „t” oraz zamykający apostrof: char tabCharacter = '\t';
Ten przykład deklaruje zmienną typu char (o nazwie tabCharacter) oraz inicjalizuje ją wartością \t, która jest rozpoznawana jako tabulator. Specjalne znaki formatujące używane są do wypisywania tekstu na ekranie, do zapisu tekstu do pliku lub do innego urządzenia wyjściowego. Znak specjalny \ zmienia znaczenie znaku, który po nim następuje. Na przykład, normalnie znak n oznacza po prostu literę n, lecz gdy jest poprzedzony znakiem specjalnym \, oznacza przejście do nowej linii. Tabela 3.2. Znaki specjalne Znak
Oznacza
\a
Bell (dzwonek)
\b
Backspace (znak wstecz)
\f
Form feed (koniec strony)
\n
New line (nowa linia)
\r
Carriage return (powrót „karetki”; powrót na początek linii)
\t
Tab (tabulator)
\v
Vertical tab (tabulator pionowy)
\'
Single quote (apostrof)
\"
Double quote (cudzysłów)
\?
Question mark (znak zapytania)
\\
Backslash (lewy ukośnik)
\0oo
Zapis ósemkowy
\xhhh
Zapis szesnastkowy
Stałe Do przechowywania danych służą także stałe. Jednak w odróżnieniu od zmiennej, jak sama nazwa sugeruje, wartość stałej nie ulega zmianie. Podczas tworzenia stałej trzeba ją zainicjalizować; później nie można już przypisywać jej innej wartości.
Literały C++ posiada dwa rodzaje stałych: literały i stałe symboliczne. Literał jest wartością wpisywaną bezpośrednio w danym miejscu programu. Na przykład: int myAge = 39;
myAge jest zmienną typu int; z kolei 39 jest literałem. Literałowi 39 nie można przypisać
wartości, zaś jego wartość nie może ulec zmianie.
Stałe symboliczne Stała symboliczna jest reprezentowana poprzez swoją nazwę (podobnie jak w przypadku zmiennych). Jednak w odróżnieniu od zmiennej, po zainicjalizowaniu stałej, nie można później zmieniać jej wartości. Jeśli w programie występuje zmienna całkowita o nazwie students (studenci) oraz inna zmienna o nazwie classes (klasy), możemy obliczyć ilość studentów znając ilość klas (przy zakładając że w każdej klasie jest piętnastu studentów): students = classess * 15;
W tym przykładzie, 15 jest literałem. Kod będzie jednak łatwiejszy w czytaniu i w konserwacji, jeśli zastąpimy literał stałą symboliczną: students = classess * studentsPerClass;
Jeśli zdecydujesz się zmienić ilość studentów przypadającą na każdą z klas, możesz to zrobić w definicji stałej studentsPerClass (studentów na klasę), bez konieczności zmiany tej wartości w każdym miejscu jej wystąpienia. W języku C++ istnieją dwa sposoby deklarowania stałych symbolicznych. Starszy, tradycyjny (obecnie uważany za przestarzały) polega na wykorzystaniu dyrektywy preprocesora, #define.
Definiowanie stałych za pomocą #define Aby zdefiniować stałą w tradycyjny sposób, możesz napisać: #define studentsPerClass 15
Zwróć uwagę, że stała studentsPerClass nie ma określonego typu (int, char, itd.). Dyrektywa #define umożliwia jedynie proste podstawianie tekstu. Za każdym razem, gdy preprocesor natrafia na słowo studentsPerClass, zastępuje je napisem 15. Ponieważ działanie preprocesora poprzedza działanie kompilatora, kompilator nigdy nie „widzi” takich stałych; zamiast tego „widzi” po prostu liczbę 15.
Definiowanie stałych za pomocą const Mimo, że działa instrukcja #define, do definiowania stałych w C++ używa się nowszego, lepszego sposobu: const unsigned short int studentsPerClass = 15;
Ten przykład także deklaruje stałą symboliczną o nazwie studentsPerClass, ale tym razem ta stała ma typ, którym jest unsigned short int. Metoda ta ma kilka zalet, dzięki którym kod programu jest łatwiejszy w konserwacji i jest bardziej odporny na błędy. Natomiast zdefiniowana w ten sposób stała posiada typ; dzięki temu kompilator może wymusić użycie jej zgodnie z tym typem. UWAGA Stałe nie mogą być zmieniane podczas działania programu. Jeśli chcesz na przykład zmienić wartość stałej studentsPerClass, musisz zmodyfikować kod źródłowy, po czym skompilować program ponownie.
TAK
NIE
Sprawdzaj, czy liczby nie przekraczają Nie używaj słów kluczowych jako nazw maksymalnego rozmiaru dopuszczalnego dla zmiennych. zmiennych całkowitych i czy nie zawijają się do niewłaściwych wartości. Nadawaj zmiennym nazwy znaczące, dobrze odzwierciedlające ich zastosowanie.
Stałe wyliczeniowe Stałe wyliczeniowe umożliwiają tworzenie nowych typów, a następnie definiowanie ich zmiennych. Wartości takich zmiennych ograniczają się do wartości określonych w definicji typu. Na przykład, możesz zadeklarować typ COLOR (kolor) jako wyliczenie, dla którego możesz zdefiniować pięć wartości: RED, BLUE, GREEN, WHITE oraz BLACK. Składnię definicji wyliczenia stanowią słowo kluczowe enum, nazwa typu, otwierający nawias klamrowy, lista wartości oddzielonych przecinkami, zamykający nawias klamrowy oraz średnik. Oto przykład: enum COLOR { RED, BLUE, GREEN, WHITE, BLACK };
Ta instrukcja wykonuje dwa zadania: 1.
Sprawia, że nowe wyliczenie otrzymuje nazwę COLOR, tj. tworzony jest nowy typ.
2.
Powoduje, że RED (czerwony) jest stałą symboliczną o wartości 0, BLUE (niebieski) jest stałą symboliczną o wartości 1, GREEN (zielony) jest stałą symboliczną o wartości 2, itd.
Każda wyliczana stała posiada wartość całkowitą. Jeśli tego nie określisz, zakłada się że pierwsza stała ma wartość 0, następna 1, itd. Każda ze stałych może zostać zainicjalizowana dowolną wartością. Stałe, które nie zostaną zainicjalizowane, będą miały wartości naliczane począwszy od wartości od jeden większej od wartości stałych zainicjalizowanych. Zatem, jeśli napiszesz: enum COLOR { RED=100, BLUE, GREEN=500, WHITE, BLACK=700 };
RED będzie mieć wartość 100, BLUE będzie mieć wartość 101; GREEN wartość 500, WHITE (biały) wartość 501, zaś BLACK (czarny) wartość 700.
Możesz definiować zmienne typu COLOR, ale mogą one przyjmować tylko którąś z wyliczonych wartości (w tym przypadku RED, BLUE, GREEN, WHITE lub BLACK, albo 100, 101, 500, 501 lub 700). Zmiennej typu COLOR możesz przypisać dowolną wartość koloru. W rzeczywistości możesz przypisać jej dowolną wartość całkowitą, nawet jeśli nie odpowiada ona dozwolonemu kolorowi; dobry kompilator powinien w takim przypadku wypisać ostrzeżenie. Należy zdawać sobie sprawę, że stałe wyliczeniowe to w rzeczywistości zmienne typu unsigned int oraz że te stałe odpowiadają zmiennym całkowitym. Możliwość nazywania wartości okazuje się bardzo pomocna, na przykład podczas pracy z kolorami, dniami tygodnia czy podobnymi zestawami. Program używający typu wyliczeniowego został przedstawiony na listingu 3.7. Listing 3.7. Przykład stałych wyliczeniowych 0: 1: 2: 3:
#include int main() { enum Days { Sunday, Monday, Tuesday,
4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15:
Wednesday, Thursday, Friday, Saturday }; Days today; today = Monday; if (today == Sunday || today == Saturday) std::cout << "\nUwielbiam weekendy!\n"; else std::cout << "\nWracaj do pracy.\n"; return 0; }
Wynik Wracaj do pracy.
Analiza W linii 3. definiowana jest stała wyliczeniowa Days (dni), posiadająca siedem, odpowiadających dniom tygodnia, wartości. Każda z tych wartości jest wartością całkowitą, numerowaną od 0 w górę (tak więc Monday — poniedziałek — ma wartość 1)1. Tworzymy też zmienną typu Days — tj. zmienną, która będzie przyjmować wartość z listy wyliczonych stałych. W linii 7. przypisujemy jej wartość wyliczeniową Monday, którą następnie sprawdzamy w linii 9. Stała wyliczeniowa zawarta w linii 3. może być zastąpiona serią stałych całkowitych, tak jak pokazano na listingu 3.8. Listing 3.8. Ten sam program wykorzystujący stałe całkowite 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20:
#include int main() { const int Sunday = 0; const int Monday = 1; const int Tuesday = 2; const int Wednesday = 3; const int Thursday = 4; const int Friday = 5; const int Saturday = 6; int today; today = Monday; if (today == Sunday || today == Saturday) std::cout << "\nUwielbiam weekendy!\n"; else std::cout << "\nWracaj do pracy.\n"; return 0; }
Wynik Wracaj do pracy.
Analiza
1
Amerykanie liczą dni tygodnia zaczynając od niedzieli. — przyp.tłum.
Wynik działania tego programu jest identyczny z wynikiem programu z listingu 3.7. W tym programie każda ze stałych (Sunday, Monday, itd.) została zdefiniowana jawnie i nie istnieje typ wyliczeniowy Days. Stałe wyliczeniowe mają tę zaletę, że się same dokumentują — przeznaczenie typu wyliczeniowego Days jest oczywiste.
Rozdział 4. Wyrażenia i instrukcje Program stanowi zestaw kolejno wykonywanych instrukcji. Jakość działania programu zależy od możliwości wykonywania określonego zestawu instrukcji w danych warunkach. Z tego rozdziału dowiesz się: •
czym są instrukcje,
•
czym są bloki,
•
czym są wyrażenia,
•
jak, w zależności od warunków, kierować wykonaniem programu,
•
czym jest prawda i jak działać na jej podstawie.
Instrukcje W C++ instrukcje kontrolują kolejność działania programu, obliczają wyrażenia lub nie robią nic (instrukcja pusta). Wszystkie instrukcje C++ kończą się średnikiem (nawet instrukcja pusta, która składa się wyłącznie ze średnika). Jedną z najczęściej występujących instrukcji jest instrukcja przypisania: x = a + b;
W przeciwieństwie do znaczenia, jakie ma w algebrze, ta instrukcja nie oznacza tutaj, że x równa się a+b. Należy ją traktować jako „przypisz wartość sumy a i b do x” lub „przypisz a+b do x” lub „niech x równa się a+b.” Choć ta instrukcja wykonuje dwie czynności, nadal jest pojedynczą instrukcją (stąd tylko jeden średnik). Operator przypisania przypisuje to, co znajduje się po prawej stronie znaku równości elementowi znajdującemu się po lewej stronie.
Białe spacje Białe spacje (tabulatory, spacje i znaki nowej linii) są w instrukcjach ignorowane. Omawiana poprzednio instrukcja przypisania może zostać zapisana jako: x=a+b;
lub jako: x +
b
=a ;
Choć ostatni zapis jest poprawny, jest równocześnie niemądry. Białe spacje mogą być używane w celu poprawienia czytelności programu lub stworzenia okropnego, niemożliwego do rozszyfrowania kodu. C++ daje do wyboru wiele możliwości, ale ich rozważne użycie zależy od ciebie. Znaki białych spacji nie są widoczne. Gdy zostaną wydrukowane, na papierze będą widoczne jako odstępy.
Bloki i instrukcje złożone Wszędzie tam, gdzie może znaleźć się instrukcja pojedyncza, może znaleźć się także instrukcja złożona, zwana także blokiem. Blok rozpoczyna się od otwierającego nawiasu klamrowego ({) i kończy nawiasem zamykającym (}). Choć każda instrukcja w bloku musi kończyć się średnikiem, sam blok nie wymaga jego zastosowania (jak pokazano w poniższym przykładzie): { temp = a; a = b; b = temp; }
Ten blok kodu działa jak pojedyncza instrukcja i zamienia wartości w zmiennych a i b.
TAK Jeśli użyłeś otwierającego nawiasu klamrowego, pamiętaj także o nawiasie zamykającym. Kończ instrukcje średnikiem. Używaj rozważnie białych spacji, tak aby kod był czytelny.
Wyrażenia Wszystko, co staje się wartością, w C++ jest uważane za wyrażenie. Mówi się, że wyrażenie zwraca wartość. Skoro instrukcja 3+2; zwraca wartość 5, więc jest wyrażeniem. Wszystkie wyrażenia są jednocześnie instrukcjami. Możesz zdziwić się, ile miejsc w kodzie kwalifikuje się jako wyrażenia. Oto trzy przykłady: 3.2 PI SecondsPerMinute
// zwraca wartość 3.2 // stała typu float zwracająca wartość 3.14 // stała typu int zwracająca 60
Gdy założymy, że PI jest stałą, którą zainicjalizowałem wartością 3.14 i że SecondsPerMinute (sekund na minutę) jest stałą wynoszącą 60, wtedy wszystkie te trzy instrukcje są wyrażeniami. Nieco bardziej skomplikowane wyrażenie x = a + b;
nie tylko dodaje do siebie a oraz b, a wynik umieszcza w x, ale także zwraca wartość tego przypisania (nową wartość x). Zatem instrukcja przypisania także jest wyrażeniem. Ponieważ jest wyrażeniem, może wystąpić po prawej stronie operatora przypisania: y = x = a + b;
Ta linia jest przetwarzana w następującym porządku:
Dodaj a do b. Przypisz wynik wyrażenia a + b do x. Przypisz rezultat wyrażenia przypisania, x = a + b, do y.
Jeśli a, b, x oraz y byłyby zmiennymi całkowitymi, zaś a miałoby wartość 2, a b miałoby wartość 5, wtedy zarówno zmiennej x, jak i y zostałaby przypisana wartość 7. Ilustruje to listing 4.1. Listing 4.1. Obliczanie wyrażeń złożonych 0: 1: 2: 3: 4: 5: 6: 7: 8:
#include int main() { using std::cout; using std::endl; int a=0, b=0, x=0, y=35; cout << "a: " << a << " b: " << b; cout << " x: " << x << " y: " << y << endl;
9: 10: 11: 12: 13: 14: 15:
a = 9; b = 7; y = x = a+b; cout << "a: " << a << " b: " << b; cout << " x: " << x << " y: " << y << endl; return 0; }
Wynik a: 0 b: 0 x: 0 y: 35 a: 0 b: 7 x: 16 y: 16
Analiza W linii 6. deklarowane i inicjalizowane są cztery zmienne. Ich wartości są wypisywane w liniach 7. i 8. W linii 9. zmiennej a jest przypisywana wartość 9. W linii 10., zmiennej b jest przypisywana wartość 7. W linii 11. zmienne a i b są sumowane, zaś wynik sumowania jest przypisywany zmiennej x. To wyrażenie (x = a+b) powoduje obliczenie sumy a oraz b i przypisanie jej do zmiennej x, wartość tego przypisania jest następnie przypisywana zmiennej y.
Operatory Operator jest symbolem, który powoduje, że kompilator rozpoczyna działanie. Operatory działają na operandach, zaś wszystkie operandy w C++ są wyrażeniami. W C++ istnieje kilka kategorii operatorów. Dwie z tych kategorii to: •
operatory przypisania,
•
operatory matematyczne.
Operator przypisania Operator przypisania (=) powoduje, że operand znajdujący się po lewej stronie operatora przypisania zmienia wartość na wartość operandu znajdującego się po prawej stronie operatora. To wWyrażenie: x = a + b;
przypisuje operandowi x wynik dodawania wartości a i b. Operand, który może wystąpić po lewej stronie operatora przypisania jest nazywany l-wartością (lvalue). To, co Natomiast ten, który może znaleźć się po prawej stronie, jest nazywanye (jak można się domyślić), r-wartością (r-value). Stałe są r-wartościami. Nie mogą być l-wartościami. Zatem możesz napisać: x = 35;
// OK
lecz nie możesz napisać: 35 = x;
// błąd, 35 nie moŜe być l-wartością!
L-wartość jest operandem, który może znaleźć się po lewej stronie wyrażenia. R-wartość jest operandem, który może występować po prawej stronie wyrażenia. Zwróć uwagę, że wszystkie lwartości mogą być r-wartościami, ale że nie wszystkie r-wartości mogą być l-wartościami. Przykładem r-wartości, która nie jest l-wartością, może być literał. Zatem możesz napisać x = 5;, lecz nie możesz napisać 5 = x; (x może być l- lub r-wartością, lecz 5 może być tylko rwartością).
Operatory matematyczne Piątka operatorów matematycznych to: dodawanie (+), odejmowanie (–), mnożenie (*), dzielenie (/) oraz reszta z dzielenia (%). Dodawanie i odejmowanie działają rutynowo, choć odejmowanie liczb całkowitych bez znaku może prowadzić do zadziwiających rezultatów gdy wynik będzie ujemny. Z czymś takim spotkałeś się w poprzednim rozdziale, kiedy opisywaliśmy przepełnienie (przewinięcie wartości). Listing 4.2 pokazuje, co się stanie gdy odejmiesz dużą liczbę całkowitą bez znaku od małej liczby całkowitej bez znaku. Listing 4.2. Przykład odejmowania i przepełnienia wartości całkowitej 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17:
// Listing 4.2 - Demonstracja odejmowania // i przepełnienia wartości całkowitej. #include int main() { using std::cout; using std::endl; unsigned int difference; unsigned int bigNumber = 100; unsigned int smallNumber = 50; difference = bigNumber - smallNumber; cout << "Roznica to: " << difference; difference = smallNumber - bigNumber; cout << "\nTeraz roznica to: " << difference <
Wynik Roznica to: 50 Teraz roznica to: 4294967246
Analiza Operator odejmowania jest wywoływany w linii 12., zaś wynik jest wypisywany w linii 13. (taki, jakiego mogliśmy oczekiwać). Operator odejmowania jest ponownie wywoływany w linii 14., jednak tym razem od małej liczby całkowitej bez znaku jest odejmowana duża liczba całkowita bez znaku. Wynik powinien być ujemny, ale ponieważ wartości są obliczane (i wypisywane) jako liczby całkowite bez znaku, efektem tej operacji jest przepełnienie, tak jak opisywaliśmy w
poprzednim rozdziale. Ten temat jest szczegółowo omawiany w dodatku C, „Kolejność operatorów.”
Dzielenie całkowite i reszta z dzielenia Dzielenie całkowite poznałeś w drugiej klasie szkoły podstawowej. Gdy w dzieleniu całkowitym podzielisz 21 przez 4 (21/4), otrzymasz w wyniku 5 (oraz pewną resztę). Resztę z dzielenia całkowitego zwraca operator reszty z dzielenia (tzw. operator modulo). Aby otrzymać resztę, oblicz 21 modulo 4 (21 % 4). W wyniku otrzymasz 1. Obliczanie reszty z dzielenia może być bardzo przydatne. Możesz na przykład zechcieć wypisywać komunikat po każdej dziesiątej akcji. Każda liczba, dla której wynikiem reszty z dzielenia przez 10 jest zero, stanowi pełną wielokrotność dziesięciu. Tak więc 1 % 10 wynosi 1, 2 % 10 wynosi 2, itd., aż do 10 % 10, które ponownie wynosi 0. 11 % 10 to znów 1, wzór ten powtarza się aż do następnej wielokrotności dziesięciu, którą jest liczba 20. 20 % 0 to ponownie 0. Tę technikę wykorzystujemy wewnątrz pętli, które zostaną omówione w rozdziale 7. Często zadawane pytanie
Gdy dzielę 5/3, otrzymuję w wyniku 1. Czy coś robię nie tak?
Odpowiedź
Gdy dzielisz jedną liczbę całkowitą przez inną, w wyniku otrzymujesz także liczbę całkowitą. Zatem 5/3 wyniesie 1. (W rzeczywistości wynikiem jest 1 i reszta 2. Aby otrzymać resztę, spróbuj napisać 5%3, uzyskasz w ten sposób wartość 2.)
Aby uzyskać ułamkową wartość zmiennoprzecinkowychozycyjnych.
z
dzielenia,
musisz
użyć
zmiennych
5.0/3.0 da wartość zmiennoprzecinkowąozycyjną 1.66667.
Jeśli zmiennoprzecinkowa jest dzielna zmiennoprzecinkowyozycyjny iloraz.
lub
dzielnikozycyjna,
kompilator
wygeneruje
Łączenie operatora przypisania z operatorem matematycznym Często zdarza się, że chcemy do zmiennej dodać wartość, zaś wynik umieścić z powrotem w tej zmiennej. Jeśli masz zmienną myAge (mój wiek) i chcesz zwiększyć jej wartość o dwa, możesz napisać: int myAge = 5; int temp; temp = myAge + 2; // czyli 5 + 2 jest umieszczane w zmiennej temp myAge = temp; // wynik umieszczamy z powrotem w myAge
Ta metoda jest jednak bardzo żmudna i nieefektywna. W C++ istnieje możliwość umieszczenia tej samej zmiennej po obu stronach operatora przypisania; w takim przypadku poprzedni przykład można zapisać jako: myAge = myAge + 2;
Jest to dużo lepsza metoda. W algebrze to wyrażenie nie miałoby sensu, ale w C++ jest traktowane jako „dodaj dwa do wartości zawartej w zmiennej myAge, zaś wynik umieść ponownie w tej zmiennej”. Jeszcze prostsze w zapisie, choć może nieco trudniejsze do odczytania, jest: myAge += 2;
Operator += sumuje r-wartość z l-wartością, zaś wynik umieszcza ponownie w l-wartości. Ten operator wymawia się jako „plus-równa się”, zatem cała instrukcja powinna zostać odczytana jako „myAge plus-równa się dwa”. Jeśli zmienna myAge miałaby początkowo wartość 4, to po wykonaniu tej instrukcji przyjęłaby wartość 6. Oprócz operatora += istnieją także operatory -= (odejmowania), /= (dzielenia), *= (mnożenia), %= (reszty z dzielenia) i inne.
Inkrementacja i dekrementacja Najczęściej dodawaną (i odejmowaną), z ponownym przypisaniem wyniku zmiennej, wartością jest 1. W C++ zwiększenie wartości o jeden jest nazywane inkrementacją, zaś zmniejszenie o jeden — dekrementacją. Służą do tego specjalne operatory.
Operator inkrementacji (++) zwiększa wartość zmiennej o jeden, zaś operator dekrementacji (--) zmniejsza ją o jeden. Jeśli chcemy inkrementować zmienną C, możemy użyć następującej instrukcji: C++;
// zaczynamy od C i inkrementujemy
Ta instrukcja stanowi odpowiednik bardziej jawnie zapisanej operacji: C = C + 1;
którą, jak wiemy, możemy zapisać w nieco prostszy sposób: C += 1;
UWAGA Jak można się domyślić, język C++ otrzymał swoją nazwę dzięki zastosowaniu operatora inkrementacji do nazwy języka, od którego pochodzi (C). C++ jest kolejną, poprawioną wersją języka C.
Przedrostki i przyrostki Zarówno operator inkrementacji (++), jak i dekrementacji (--) występuje w dwóch odmianach: przedrostkowej i przyrostkowej. Odmiana przedrostkowa jest zapisywana przed nazwą zmiennej (++myAge), zaś odmiana przyrostkowa — po niej (myAge++). W przypadku instrukcji prostej nie ma znaczenia, której wersji użyjesz, jednak w wyrażeniach złożonych, gdy inkrementujesz (lub dekrementujesz) zmienną, a następnie przypisujesz rezultat innej zmiennej, różnica jest bardzo ważna. Operator przedrostkowy jest obliczany przed przypisaniem, zaś operator przyrostkowy — po przypisaniu. Operator przedrostkowy działa następująco: zwiększ wartość zmiennej i zapamiętaj ją. Operator przyrostkowy działa inaczej: zapamiętaj pierwotną wartość zmiennej, po czym zwiększ wartość w zmiennej. Na początku może to wydawać się dość niezrozumiałe, ale jeśli x jest zmienną całkowitą o wartości 5, to gdy napiszesz int a = ++x;
poinformujesz kompilator, by inkrementował zmienną x (nadając jej wartość 6), po czym pobrał tę wartość i przypisał ją zmiennej a. Zatem po wykonaniu tej instrukcji zarówno zmienna x, jak i zmienna a mają wartość 6.
Jeśli następnie napiszesz int b = x++;
to poinformujesz kompilator, by pobrał wartość zmiennej x (wynoszącą 6) i przypisał ją zmiennej b, po czym powrócił do zmiennej x i inkrementował ją. W tym momencie zmienna b ma wartość 6, a zmienna x ma wartość 7. Zastosowanie i działanie obu wersji operatora przedstawia listing 4.3. Listing 4.3. Przykład działania operatora przedrostkowego i przyrostkowego 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24:
// Listing 4.3 - demonstruje uŜycie // przedrostkowych i przyrostkowych operatorów // inkrementacji i dekrementacji #include int main() { using std::cout; int myAge = 39; // inicjalizujemy dwie zmienne całkowite int yourAge = 39; cout << "Ja mam: " << myAge << " lat.\n"; cout << "Ty masz: " << yourAge << " lat\n"; myAge++; // inkrementacja przyrostkowa ++yourAge; // inkrementacja przedrostkowa cout << "Minal rok...\n"; cout << "Ja mam: " << myAge << " lat.\n"; cout << "Ty masz: " << yourAge << " lat\n"; cout << "Minal kolejny rok\n"; cout << "Ja mam: " << myAge++ << " lat.\n"; cout << "Ty masz: " << ++yourAge << " lat\n"; cout << "Wypiszmy to jeszcze raz.\n"; cout << "Ja mam: " << myAge << " lat.\n"; cout << "Ty masz: " << yourAge << " lat\n"; return 0; }
Wynik Ja mam: 39 lat. Ty masz: 39 lat Minal rok... Ja mam: 40 lat. Ty masz: 40 lat Minal kolejny rok Ja mam: 40 lat. Ty masz: 41 lat Wypiszmy to jeszcze raz. Ja mam: 41 lat. Ty masz: 41 lat
Analiza W liniach 8. i 9. deklarujemy dwie zmienne całkowite i inicjalizujemy je wartością 39. Ich wartości są wypisywane w liniach 10. i 11.
W linii 12. zmienna myAge (mój wiek) jest inkrementowana za pomocą operatora przyrostkowego, zaś w linii 13. zmienna yourAge (twój wiek) jest inkrementowana za pomocą operatora przedrostkowego. Wyniki wypisywane w liniach 15. i 16. są identyczne (40). W linii 18. zmienna myAge jest inkrementowana jako część instrukcji wypisywania danych, za pomocą operatora przyrostkowego. Ponieważ jest to operator przyrostkowy, inkrementacja odbywa się już po wypisaniu tekstu, dlatego ponownie wypisywana jest wartość 40. Dla odróżnienia od linii 18., w linii 19. zmienna yourAge jest inkrementowana za pomocą operatora przedrostkowego. Ponieważ w takim przypadku inkrementacja odbywa się przed wypisaniem tekstu, zostaje wypisana wartość 41. Na zakończenie, w liniach 21. i 22., ponownie wypisywane są wartości zmiennych. Ponieważ instrukcje inkrementacji zostały dokończone, zmienna myAge, podobnie jak zmienna yourAge przyjmuje wartość 41.
Kolejność działań Które działanie instrukcji złożonej, takiej jak ta x = 5 + 3 * 8;
jest wykonywane jako pierwsze: dodawanie czy mnożenie? Gdyby najpierw wykonywane było dodawanie, wynik wynosiłby 8 * 8, czyli 64. Gdyby najpierw wykonywane było mnożenie, uzyskalibyśmy wynik 5 + 24, czyli 29. Każdy operator posiada swój priorytet, który określa kolejność wykonywania działań. Pełną listę priorytetów operatorów można znaleźć w dodatku C. Mnożenie ma priorytet nad dodawaniem, więc w tym przypadku wartością wyrażenia jest 29. Gdy dwa operatory matematyczne osiągają ten sam priorytet, są obliczane w kolejności od lewej do prawej. Zatem w wyrażeniu x = 5 + 3 + 8 * 9 + 6 * 4;
mnożenia są wykonywane jako pierwsze (najpierw lewe, potem prawe). Otrzymujemy 8*9 = 72 oraz 6*4 = 24. Teraz wyrażenie można zapisać jako x = 5 + 3 + 72 + 24.
Następnie obliczane są dodawania, od lewej do prawej: 5 + 3 = 8; 8 + 72 = 80; 80 + 24 = 104. Bądź ostrożny. Niektóre operatory, takie jak operator przypisania, są obliczane w kolejności od prawej do lewej!
Co zrobić gdy kolejność wykonywania działań nie odpowiada naszym potrzebom? Weźmy na przykład takie wyrażenie: TotalSeconds = NumMinutesToThink + NumMinutesToType * 60
W tym wyrażeniu nie chcemy mnożyć zmiennej NumMinutesToType (ilość minut wpisywania) przez 60, a następnie dodawać otrzymanej wartości do zmiennej NumMinutesToThink (ilość minut namysłu). Chcemy zsumować obie zmienne, aby otrzymać łączną ilość minut, a dopiero potem przemnożyć ją przez ilość sekund w minucie, w celu otrzymania łącznej ilości sekund (TotalSeconds). W tym przypadku, w celu zmiany kolejności działań użyjemy nawiasów. Działania na elementach w nawiasach są zawsze wykonywane przed innymi operacjami matematycznymi. Zatem zamierzony wynik uzyskamy dopiero wtedy, gdy napiszemy: TotalSeconds = (NumMinutesToThink + NumMinutesToType) * 60
Zagnieżdżanie nawiasów W przypadku złożonych wyrażeń można zagnieżdżać nawiasy jeden wewnątrz drugiego. Na przykład, przed obliczeniem łącznej ilości „osobosekund” (TotalPersonSeconds) możesz zechcieć obliczyć łączną ilość sekund oraz łączną ilość osób zajętych pracą: TotalPersonSeconds = ( ( (NumMinutesToThink+*NumMinutesToType) * 60) * (PeopleInTheOffice + PeopleOnVacation) )
To złożone wyrażenie jest odczytywane od wewnątrz na zewnątrz. Najpierw sumowane są zmienne NumMinutesToThink oraz NumMinutesToType, gdyż znajdują się w wewnętrznych nawiasach. Potem ta suma jest mnożona przez 60. Następnie sumowane są zmienne PeopleInTheOffice (osoby w biurze) oraz PeopleOnVacation (osoby na urlopie). Na koniec łączna ilość osób jest przemnażana przez łączną ilość sekund. Z tym przykładem wiąże się jeszcze jedno ważne zagadnienie. To wyrażenie jest łatwe do zrozumienia przez komputer, lecz jest bardzo trudne do odczytania, zrozumienia lub zmodyfikowania przez człowieka. Oto to samo wyrażenie, przepisane z użyciem kilku tymczasowych zmiennych całkowitych: TotalMinutes = NumMinutesToThink + NumMinutesToType; TotalSeconds = TotalMinutes * 60; TotalPeople = PeopleInTheOffice + PeopleOnVacation; TotalPersonSeconds = TotalPeople * TotalSeconds;
Napisanie tego przykładu wymaga więcej czasu do napisania i skorzystania z większej ilości tymczasowych zmiennych, lecz sprawi, że będzie on dużo łatwiejszy do zrozumienia. Gdy dodasz do niego komentarz, opisujący, do czego służy ten kod oraz gdy zamienisz wartość 60 na stałą symboliczną, otrzymasz łatwy do zrozumienia i modyfikacji kod.
TAK
NIE
Pamiętaj, że wyrażenia mają wartość.
Nie zagnieżdżaj nawiasów zbyt głęboko, gdyż wyrażenie stanie się zbyt trudne do zrozumienia i modyfikacji.
Używaj operatora przedrostkowego (++zmienna) do inkrementacji lub dekrementacji zmiennej przed jej użyciem w wyrażeniu. Używaj operatora przyrostkowego (zmienna++) do inkrementacji lub dekrementacji zmiennej po jej użyciu w wyrażeniu. W celu zmiany kolejności działań używaj nawiasów.
Prawda i fałsz W poprzednich wersjach C++, prawda i fałsz były reprezentowane jako liczby całkowite; w standardzie ANSI wprowadzono nowy typ: bool. Typ ten może mieć tylko dwie wartości, true (prawda) oraz false (fałsz). Można sprawdzić prawdziwość każdego wyrażenia. Wyrażenia, których matematycznym wynikiem jest zero, zwracają wartość false. Wszystkie inne wyrażenia zwracają wartość true. UWAGA Wiele kompilatorów oferowało typ bool już wcześniej, był on wewnętrznie reprezentowany jako typ long int i miał rozmiar czterech bajtów. Nowe, zgodne z ANSI kompilatory często korzystają z jednobajtowych zmiennych typu bool.
Operatory relacji Operatory relacji są używane do sprawdzania, czy dwie liczby są równe, albo czy jedna z nich jest większa lub mniejsza od drugiej. Każdy operator relacji zwraca prawdę lub fałsz. Operatory relacji zostaną przedstawione nieco dalej, w tabeli 4.1. UWAGA Wszystkie operatory relacji zwracają wartość typu bool, czyli wartość true albo false. W poprzednich wersjach C++ operatory te zwracały albo wartość 0 dla fałszu, albo wartość różną od zera (zwykle 1) dla prawdy.
Jeśli zmienna całkowita myAge ma wartość 45, zaś zmienna całkowita yourAge ma wartość 50, możesz sprawdzić, czy są równe, używając operatora „równa się”: myAge == yourAge; // czy wartość myAge jest równa wartości yourAge?
Wyrażenie ma wartość false (fałsz), gdyż wartości tych zmiennych nie są równe. Z kolei wyrażenie myAge < yourAge; // czy myAge jest mniejsze od yourAge?
ma wartość true (prawda). OSTRZEŻENIE Wielu początkujących programistów C++ myli operator przypisania (=) z operatorem relacji równości (==). Może to prowadzić do uporczywych i trudnych do wykrycia błędów w programach.
Sześć operatorów relacji to: równe (==), mniejsze (<), większe (>), mniejsze lub równe (<=), większe lub równe (>=) oraz różne (!=). Zostały one zebrane (wraz z przykładami użycia w kodzie) w tabeli 4.1. Tabela 4.1. Operatory relacji Nazwa
Operator
Przykład
Wynik
Równe
==
100 == 50;
false (fałsz)
50 == 50;
true (prawda)
100 != 50;
true (prawda)
50 != 50;
false (fałsz)
100 > 50;
true (prawda)
50 > 50;
false (fałsz)
100 >= 50;
true (prawda)
50 >= 50;
true (prawda)
100 < 50;
false (fałsz)
50 < 50;
false (fałsz)
100 <= 50;
false (fałsz)
50 <= 50;
true (prawda)
Nie równe
Większe
Większe lub równe
Mniejsze
Mniejsze lub równe
TAK
!=
>
>=
<
<=
NIE
Pamiętaj, że operatory relacji zwracają wartość true (prawda) lub false (fałsz).
Nie myl operatora przypisania (=) z operatorem relacji równości (==). Jest to jeden z najczęstszych błędów popełnianych przez programistów C++. Strzeż się go.
Instrukcja if Program jest wykonywany linia po linii, w takiej kolejności, w jakiej linie te występują w tekście kodu źródłowego. Instrukcja if umożliwia sprawdzenie spełnienia warunku (na przykład, czy dwie zmienne są równe) i przejście do wykonania innej części kodu. Najprostsza forma instrukcji if jest następująca: if (wyraŜenie) instrukcja;
Wyrażenie w nawiasach może być całkowicie dowolne, ale najczęściej jest to jedno z wyrażeń relacji. Jeśli wyrażenie to ma wartość false, wtedy instrukcja jest pomijana. Jeśli wyrażenie jest prawdziwe (ma wartość true), wtedy instrukcja jest wykonywana. Weźmy poniższy przykład: if (bigNumber > smallNumber) bigNumber = smallNumber;
Ten kod porównuje zmienną bigNumber (duża liczba) ze zmienną smallNumber (mała liczba). Jeśli wartość zmiennej bigNumber jest większa, w drugiej linii tej zmiennej jest przypisywana wartość zmiennej smallNumber. Ponieważ blok instrukcji ujętych w nawiasy klamrowe stanowi odpowiednik instrukcji pojedynczej, warunkowo wykonywany fragment kodu może być dość rozbudowany: if (wyraŜenie) { instrukcja1; instrukcja2; instrukcja3; }
Oto prosty przykład wykorzystania tej możliwości: if (bigNumber > smallNumber) {
bigNumber = smallNumber; std::cout << "duza liczba: " << bigNumber << "\n"; std::cout << "mala liczba: " << smallNumber << "\n"; }
Tym razem, jeśli zmienna bigNumber jest większa od zmiennej smallNumber, przypisywana jest jej wartość zmiennej smallNumber, a ponadto wypisywany jest informacyjny komunikat. Listing 4.4 przedstawia szczegółowo przykład warunkowego wykonywania kodu (z zastosowaniem operatorów relacji). Listing 4.4. Przykład warunkowego wykonania kodu (z zastosowaniem operatorów relacji) 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43:
Wynik
// Listing 4.5 - demonstruje instrukcje if // uŜywane z operatorami relacji #include int main() { using std::cout; using std::cin; int MetsScore, YankeesScore; cout << "Wpisz wynik dla Metsow: "; cin >> MetsScore; cout << "\nWpisz wynik dla Yankees: "; cin >> YankeesScore; cout << "\n"; if (MetsScore > YankeesScore) cout << "Let's Go Mets!\n"; if (MetsScore < YankeesScore) { cout << "Go Yankees!\n"; } if (MetsScore == YankeesScore) { cout << "Remis? Eeee, nie moze byc...\n"; cout << "Podaj mi prawdziwy wynik dla Yanks: "; cin >> YankeesScore; if (MetsScore > YankeesScore) cout << "Wiedzialem! Let's Go Mets!"; if (YankeesScore > MetsScore) cout << "Wiedzialem! Go Yanks!"; if (YankeesScore == MetsScore) cout << "Coz, rzeczywiscie byl remis!"; } cout << "\nDzieki za informacje.\n"; return 0; }
Wpisz wynik dla Metsow: 10 Wpisz wynik dla Yankees: 10 Remis? Eeee, nie moze byc... Podaj mi prawdziwy wynik dla Yanks: 8 Wiedzialem! Let's Go Mets! Dzieki za informacje.
Analiza Ten program pyta użytkownika o wyniki spotkań dwóch drużyn baseballowych; wyniki są przechowywane w zmiennych całkowitych. Te zmienne są porównywane w instrukcjach if w liniach 17., 20. i 25. (W poprzednich wydaniach książki Yankees występowali przeciw Red Sox. W tym roku mamy inną serię, więc zaktualizowałem przykład!) Jeśli jeden z wyników jest wyższy niż drugi, wypisywany jest komunikat informacyjny. Jeśli wyniki są równe, wtedy program przechodzi do bloku kodu zaczynającego się w linii 25.9 i kończącego w linii 39. Pojawia się w nim prośba o ponowne podanie drugiego wyniku, po czym wyniki są porównywane jeszcze raz. Zwróć uwagę, że gdyby początkowy wynik Yankees był większy niż wynik Metsów, wtedy w instrukcji if w linii 17. otrzymalibyśmy wynik false, co spowodowałoby że linia 18. nie zostałaby wykonana. Test w linii 20. miałby wartość true, więc wykonana zostałaby instrukcja w linii 22.. Następnie zostałaby wykonana instrukcja if w linii 25. i jej wynikiem byłoby false (jeśli wynikiem w linii 17. była prawda). Tak więc program pominąłby cały blok, aż do linii 39. Ten przykład ilustruje że otrzymanie wyniku true w jednej z instrukcji if nie powoduje zaprzestania sprawdzania pozostałych instrukcji if. Zauważ, że wykonywaną zawartością dwóch pierwszych instrukcji if są pojedyncze linie (wypisujące „Let’s Go Mets!” lub „Go Yankees!”). W pierwszym przykładzie (w linii 18.) nie umieściłem linii w nawiasach klamrowych, gdyż pojedyncza instrukcja nie wymaga ich zastosowania. Nawiasy klamrowe są jednak dozwolone, więc użyłem ich w liniach 21. i 23. OSTRZEŻENIE Wielu początkujących programistów C++ nieświadomie umieszcza średnik za nawiasem zamykającym instrukcję if: if(SomeValue < 10); SomeValue = 10;
Zamiarem programisty było tu sprawdzenie, czy zmienna SomeValue (jakaś wartość) jest mniejsza niż 10, i gdy warunek ten zostałby spełniony, przypisalibyśmy tej zmiennej minimalną wartość 10. Uruchomienie tego fragmentu kodu pokazuje, że zmienna SomeValue jest zawsze ustawiana na 10! Dlaczego? Ponieważ instrukcja if kończy się średnikiem (czyli instrukcją pustą).
Pamiętaj, że wcięcia w kodzie źródłowym nie mają dla kompilatora żadnego znaczenia. Ten fragment mógłby zostać zapisany (bardziej poprawnie) jako: if(SomeValue < 10) // sprawdzenie ; // nic nie rób
SomeValue = 10; // przypisz
Usunięcie średnika występującego w pierwszym przykładzie spowoduje, że druga linia stanie się częścią instrukcji if i kod zadziała zgodnie z planem.
Styl wcięć Listing 4.3 pokazuje jeden ze stylów „wcinania” instrukcji if. Nie ma chyba jednak lepszego sposobupowodu na wszczęcie wojny religijnej niż zapytanie grupy programistów, jaki jest najlepszy styl wyrównywania nawiasów klamrowych. Choć dostępne są tuziny ich odmian, wygląda na to, że najczęściej stosowane są trzy z nich: •
umieszczenie otwierającego nawiasu klamrowego po warunku i wyrównanie nawiasu klamrowego zamykającego blok zawierający początek instrukcji if: if (wyraŜenie){ instrukcje }
•
wyrównanie nawiasów klamrowych z instrukcją if i wcięcie jedynie bloku instrukcji: if (wyraŜenie) { instrukcje }
•
wcięcie zarówno instrukcji, jak i nawiasów klamrowych: if (wyraŜenie) { instrukcje }
W tej książce stosujemy drugą z podanych wyżej wersji, gdyż uważam, że najlepiej pokazuje gdzie zaczyna się, a gdzie się kończy blok instrukcji. Pamiętaj jednak, że nie ma znaczenia który styl sam wybierzesz, o ile tylko będziesz go konsekwentnie stosował.
else Często zdarza się, że w swoim programie chcesz wykonać jakiś fragment kodu, jeżeli spełniony zostanie pewien warunek, oraz inny fragment kodu, gdy warunek ten nie zostanie spełniony. Na listingu 4.4 chcieliśmy wypisać komunikat (Let’s Go Mets!), pod warunkiem, że pierwszy test
(MetsScore > YankeesScore) da wartość true, lub inny komunikat (Go Yankees!), gdy ten test da wartość false. Pokazana już wcześniej metoda — sprawdzenie najpierw pierwszego warunku, a potem drugiego — działa poprawnie, lecz jest nieco żmudna. Dzięki zastosowaniu słowa kluczowego else możemy to zamienić na bardziej czytelny fragment kodu: if (wyraŜenie) instrukcja; else instrukcja;
Użycie słowa kluczowego else demonstruje listing 4.5. Listing 4.5. Użycie słowa kluczowego else 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19:
// Listing 4.5 - demonstruje instrukcję if // z klauzulą else #include int main() { using std::cout; using std::cin; int firstNumber, secondNumber; cout << "Prosze wpisac wieksza liczbe: "; cin >> firstNumber; cout << "\nProsze wpisac mniejsza liczbe: "; cin >> secondNumber; if (firstNumber > secondNumber) cout << "\nDzieki!\n"; else cout << "\nPomylka! Druga liczba jest wieksza!"; return 0; }
Wynik Prosze wpisac wieksza liczbe: 10 Prosze wpisac mniejsza liczbe: 12 Pomylka! Druga liczba jest wieksza!
Analiza Obliczany jest warunek w instrukcji if w linii 13. Jeśli warunek jest spełniony (prawdziwy), wykonywana jest instrukcja w linii 14.; jeśli nie jest spełniony (jest fałszywy), wykonywana jest instrukcja w linii 16. Gdyby klauzula else w linii 15. zostałaby usunięta, wtedy instrukcja w linii 16. byłaby wykonywana zawsze, bez względu na to, czy warunek instrukcji if byłby spełniony, czy nie. Pamiętaj że instrukcja if kończy się po linii 14. Gdyby nie było else, linia 16. byłaby po prostu kolejną linią programu.
Pamiętaj, że obie instrukcje wykonywane warunkowo można zastąpić blokami kodu ujętymi w nawiasy klamrowe. Instrukcja if
Składnia instrukcji if jest następująca:
Forma 1 if (wyraŜenie) instrukcja; następna instrukcja;
Jeśli wyraŜenie ma wartość true, to instrukcja jest wykonywana i program przechodzi do wykonania następnej instrukcji. Jeśli wyraŜenie nie jest prawdziwe, instrukcja jest ignorowana i program przechodzi bezpośrednio do następnej instrukcji.
Pamiętaj, że instrukcja może być pojedynczą instrukcją zakończoną średnikiem lub blokiem instrukcji ujętym w nawiasy klamrowe.
Forma 2 if (wyraŜenie) instrukcja1; else instrukcja2; następna instrukcja;
Jeśli wyraŜenie ma wartość true, wykonywana jest instrukcja1; w przeciwnym razie wykonywana jest instrukcja2. Następnie program przechodzi do wykonania następnej instrukcji.
Przykład 1 if (SomeValue < 10) cout << "SomeValue jest mniejsze niŜ 10"; else cout << "SomeValue nie jest mniejsze niŜ 10"; cout << "Gotowe." << endl;
Zaawansowane instrukcje if Warto zauważyć, że w klauzuli if lub else może być zastosowana dowolna instrukcja, nawet inna instrukcja if lub else. Z tego powodu możemy natrafić na złożone instrukcje if, przyjmujące postać:
if (wyraŜenie1) { if (wyraŜenie2) instrukcja1; else { if (wyraŜenie3) instrukcja2; else instrukcja3; } } else instrukcja4;
Ta rozbudowana instrukcja if działa następująco: jeśli wyraŜenie1 ma wartość true i wyraŜenie2 ma wartość true, wykonaj instrukcję1. Jeśli wyraŜenie1 ma wartość true, lecz wyraŜenie2 ma wartość false, wtedy, jeśli wyraŜenie3 ma wartość true, wykonaj instrukcję2. Jeśli wyraŜenie1 ma wartość true, lecz wyraŜenie2 i wyraŜenie3 mają wartość false, wtedy wykonaj instrukcję3. Na zakończenie, jeśli wyraŜenie1 ma wartość false, wykonaj instrukcję4. Jak widać, złożone instrukcje if mogą wprawiać w zakłopotanie! Przykład takiej złożonej instrukcji if zawiera listing 4.6. Listing 4.6. Złożona, zagnieżdżona instrukcja if 0: // Listing 4.6 - a złoŜona zagnieŜdŜona 1: // instrukcja if 2: #include 3: int main() 4: { 5: // Poproś o dwie liczby. 6: // Przypisz je zmiennym bigNumber i littleNumber 7: // Jeśli bigNumber jest większe niŜ littleNumber, 8: // sprawdź, czy się dzielą bez reszty. 9: // Jeśli tak, sprawdź, czy są to te same liczby. 10: 11: using namespace std; 12: 13: int firstNumber, secondNumber; 14: cout << "Wpisz dwie liczby.\nPierwsza: "; 15: cin >> firstNumber; 16: cout << "\nDruga: "; 17: cin >> secondNumber; 18: cout << "\n\n"; 19: 20: if (firstNumber >= secondNumber) 21: { 22: if ((firstNumber%secondNumber) == 0) // dziela sie bez reszty? 23: { 24: if (firstNumber == secondNumber) 25: cout << "One sa takie same!\n"; 26: else 27: cout << "One dziela sie bez reszty!\n"; 28: }
29: 30: 31: 32: 33: 34: 35:
else cout << "One nie dziela sie bez reszty!\n"; } else cout << "Hej! Druga liczba jest wieksza!\n"; return 0; }
Wynik Wpisz dwie liczby. Pierwsza: 10 Druga: 2
One dziela sie bez reszty!
Analiza Program prosi o wpisanie dwóch liczb, jednej po drugiej. Następnie są one porównywane. Pierwsza instrukcja if, w linii 20., sprawdza, czy pierwsza liczba jest większa lub równa drugiej. Jeśli nie, wykonywana jest klauzula else w linii 32. Jeśli pierwsza instrukcja if jest prawdziwa, wykonywany jest blok kodu zaczynający się w linii 21., po czym w linii 22. przeprowadzany jest kolejny test w instrukcji if. W tym przypadku sprawdzamy, czy reszta z dzielenia pierwszej liczby przez drugą wynosi zero, to jest czy obie liczby są przez siebie podzielne. Jeśli tak, liczby te mogą być takie same lub mogą być podzielne przez siebie. Instrukcja if w linii 24. sprawdza, czy te liczby są równe i w obu przypadkach wyświetla odpowiedni komunikat. Jeśli warunek instrukcji if w linii 22. nie zostanie spełniony, wtedy wykonywana jest instrukcja else w linii 29.
Użycie nawiasów klamrowych w zagnieżdżonych instrukcjach if Choć dozwolone jest pomijanie nawiasów klamrowych w instrukcjach if zawierających tylko pojedyncze instrukcje, i choć dozwolone jest zagnieżdżanie instrukcji if: if (x > y) // gdy x jest większe od y if (x < z) // oraz gdy x jest mniejsze od z x = y; // wtedy przypisz zmiennej x wartość zmiennej y
leczmoże to powodować zbyt dużo problemów ze zrozumieniem struktury kodu w przypadku, gdy piszesz duże zagnieżdżone instrukcje. Pamiętaj, białe spacje i wcięcia są ułatwieniem dla programisty, lecz nie stanowią żadnej różnicy dla kompilatora. Łatwo jest się pomylić i błędnie wstawić instrukcję else do niewłaściwej instrukcji if. Problem ten ilustruje listing 4.7.
Listing 4.7. Przykład: nawiasy klamrowe ułatwiają zorientowanie się, które instrukcje else należą do których instrukcji if. 0: // Listing 4.7 – demonstruje, dlaczego nawiasy klamrowe 1: // mają duŜe znaczenie w zagnieŜdŜonych instrukcjach if 2: #include 3: int main() 4: { 5: int x; 6: std::cout << "Wpisz liczbe mniejsza niz 10 lub wieksza niz 100: "; 7: std::cin >> x; 8: std::cout << "\n"; 9: 10: if (x >= 10) 11: if (x > 100) 12: std::cout << "Wieksza niz 100, Dzieki!\n"; 13: else // nie tego else chcieliśmy! 14: std::cout << "Mniejsza niz 10, Dzieki!\n"; 15: 16: return 0; 17: }
Wynik Wpisz liczbe mniejsza niz 10 lub wieksza niz 100: 20 Mniejsza niz 10, Dzieki!
Analiza Programista miał zamiar poprosić o liczbę mniejszą niż 10 lub większą od 100, sprawdzić czy wartość jest poprawna, po czym wypisać podziękowanie. Jeśli instrukcja if w linii 10. jest prawdziwa, zostaje wykonana następna instrukcja (w linii 11.). W tym przypadku linia 11. jest wykonywana, gdy wprowadzona liczba jest większa niż 10. Linia 11. także zawiera instrukcję if. Ta instrukcja jest prawdziwa, gdy wprowadzona liczba jest większa od 100. Jeśli liczba jest większa niż 100, wtedy wykonywana jest instrukcja w linii 12. Jeśli wprowadzona liczba jest mniejsza od 10, wtedy instrukcja if w linii 10. daje wynik false i program przechodzi do następnej linii po instrukcji if, czyli w tym przypadku do linii 167. Jeśli wpiszesz liczbę mniejszą niż 10, otrzymasz wynik: Wpisz liczbe mniejsza niz 10 lub wieksza niz 100: 9
Klauzula else w linii 13. miała być dołączona do instrukcji if w linii 10., i w związku z tym została odpowiednio wcięta. Jednak w rzeczywistości ta instrukcja else jest dołączona do instrukcji if w linii 11., co powoduje, że w programie występuje subtelny błąd. Błąd jest subtelny, gdyż kompilator go nie zauważy i nie zgłosi. Jest to w pełni poprawny program języka C++, lecz nie wykonuje tego, do czego został stworzony. Na dodatek, w większości testów przeprowadzanych przez programistę będzie działał poprawnie. Dopóki wprowadzane będą liczby większe od 100, program będzie działał poprawnie.
Listing 4.8 przedstawia rozwiązanie tego problemu – wstawienie koniecznych nawiasów klamrowych. Listing 4.8. Przykład właściwego użycia nawiasów klamrowych w instrukcji if 0: // Listing 4.8 - demonstruje właściwe uŜycie nawiasów 1: // klamrowych w zagnieŜdŜonych instrukcjach if 2: #include 3: int main() 4: { 5: int x; 6: std::cout << "Wpisz liczbe mniejsza niz 10 lub wieksza niz 100: "; 7: std::cin >> x; 8: std::cout << "\n"; 9: 10: if (x >= 10) 11: { 12: if (x > 100) 13: std::cout << "Wieksza niz 100, Dzieki!\n"; 14: } 15: else // poprawione! 16: std::cout << "Mniejsza niz 10, Dzieki!\n"; 17: return 0; 18: }
Wynik Wpisz liczbe mniejsza niz 10 lub wieksza niz 100: 9 Mniejsza niz 10, Dzieki!
Analiza Nawiasy klamrowe w liniach 11. i 14. powodują, że cały zawarty między nimi kod jest traktowany jak pojedyncza instrukcja, dzięki czemu instrukcja else w linii 15. odnosi się teraz do instrukcji if w linii 10., czyli tak, jak zamierzono. UWAGA Programy przedstawione w tej książce zostały napisane w celu zilustrowania omawianych zagadnień. Są więc z założenia uproszczone i nie zawierają żadnych mechanizmów kontroli błędów wpisywanych przez użytkownika danych. W profesjonalnym kodzie należy przewidzieć każdy błąd i odpowiednio na niego zareagować.
Operatory logiczne Często zdarza się, że chcemy zadać więcej niż jedno relacyjne pytanie na raz. „Czy jest prawdą że x jest większe od y i czy jest jednocześnie prawdą, że y jest większe od z?” Aby móc podjąć działanie, program musi mieć możliwość sprawdzenia, czy oba te warunki są prawdziwe — lub czy przynajmniej któryś z nich jest prawdziwy. Wyobraźmy sobie skomplikowany system alarmowy, działający zgodnie z następującą zasadą: „gdy zabrzmi alarm przy drzwiach I jest już po szóstej po południu I NIE ma świąt LUB jest
weekend, wtedy zadzwoń po policję”. Do tego rodzaju obliczeń stosowane są trzy operatory logiczne języka C++. Zostały one przedstawione w tabeli 4.2.
Tabela 4.2. Operatory logiczne Operator
Symbol
Przykład
I (AND)
&&
wyraŜenie1 && wyraŜenie2
LUB (OR)
||
wyraŜenie1 || wyraŜenie2
NIE (NOT)
!
!wyraŜenie
Logiczne I Instrukcja logicznego I (AND) oblicza dwa wyrażenia, jeżeli oba mają wartość true, wartością całego wyrażenia I także jest true. Jeśli prawdą jest, że jesteś głodny I prawdą jest, że masz pieniądze, WTEDY możesz kupić obiad. Zatem if ( (x == 5) && (y == 5) )
będzie prawdziwe, gdy zarówno x, jak i y ma wartość 5, zaś będzie nieprawdziwe, gdy któraś z tych zmiennych będzie miała wartość różną od 5. Zapamiętaj, że aby całe wyrażenie było prawdziwe, prawdziwe muszą być oba wyrażenia. Zauważ, że logiczne I to podwójny symbol, &&. Pojedynczy symbol, &, jest zupełnie innym operatorem, który opiszemy w rozdziale 21., „Co dalej.”
Logiczne LUB Instrukcja logicznego LUB (OR) oblicza dwa wyrażenia, gdy któreś z nich ma wartość true, wtedy wartością całego wyrażenia LUB także jest true. Jeśli prawdą jest, że masz gotówkę LUB prawdą jest że, masz kartę kredytową, WTEDY możesz zapłacić rachunek. Nie potrzebujesz jednocześnie gotówki i karty kredytowej, choć posiadanie obu jednocześnie nie przeszkadza. Zatem if ( (x == 5) || (y == 5) )
będzie prawdziwe, gdy x lub y ma wartość 5, lub gdy obie zmienne mają wartość 5. Zauważ, że logiczne LUB to podwójny symbol, ||. Pojedynczy symbol, |, jest zupełnie innym operatorem, który opiszemy w rozdziale 21., „Co dalej.”
Logiczne NIE Instrukcja logicznego NIE (NOT) ma wartość true, gdy sprawdzane wyrażenie ma wartość false. Jeżeli sprawdzane wyrażenie ma wartość true, operator logicznyego NIE zwraca wartość false. Zatem if ( !(x == 5) )
jest prawdziwe tylko wtedy, gdy x jest różne od 5. Identycznie działa zapis: if (x != 5)
Skrócone obliczanie wyrażeń logicznych Gdy kompilator oblicza instrukcję I, na przykład taką, jak: if ( (x == 5) && (y == 5) )
wtedy najpierw sprawdza prawdziwość pierwszego wyrażenia (x == 5). Gdy jest ono nieprawdziwe, POMIJA sprawdzanie prawdziwości drugiego wyrażenia (y == 5), gdyż instrukcja I wymaga, aby oba wyrażenia były prawdziwe. Gdy kompilator oblicza instrukcję LUB, na przykład taką, jak: if ( (x == 5) || (y == 5) )
wtedy w przypadku prawdziwości pierwszego wyrażenia (x == 5), nigdy NIE JEST sprawdzane drugie wyrażenie (y == 5), gdyż w instrukcji LUB wystarczy prawdziwość któregokolwiek z wyrażeń.
Kolejność operatorów logicznych Operatory logiczne, podobnie jak operatory relacji, są w języku C++ wyrażeniami, więc zwracają wartości; w tym przypadku wartość true lub false. Tak jak wszystkie wyrażenia, posiadają priorytet (patrz dodatek C), określający kolejność ich obliczania. Ma on znaczenie podczas wyznaczania wartości instrukcji if ( x > 5 && y > 5 || z > 5)
Być może programista chciał, by to wyrażenie miało wartość true, gdy zarówno x, jak i y są większe od 5 lub gdy z jest większe od 5. Z drugiej strony, programista mógł chcieć, by to wyrażenie było prawdziwe tylko wtedy, gdy x jest większe od 5 i gdy y lub z jest większe od 5. Jeśli x ma wartość 3, zaś y i z mają wartość 10, wtedy prawdziwa jest pierwsza interpretacja (z jest większe od 5, więc x i y są ignorowane). Jednak w drugiej interpretacji otrzymujemy wartość false (x nie jest większe od 5, więc nie ma znaczenia, co jest po prawej stronie symbolu &&, gdyż obie jego strony muszą być prawdziwe). Choć o kolejności obliczeń decydują priorytety operatorów, jednak do zmiany ich kolejności i jasnego wyrażenia naszych zamiarów możemy użyć nawiasów: if ( (x > 5) && (y > 5 || z > 5) )
Używając poprzednio opisanych wartości otrzymujemy dla tego wyrażenia wartość false. Ponieważ x nie jest większe od 5, lewa strona instrukcji I jest nieprawdziwa, więc całe wyrażenie jest traktowane jako nieprawdziwe. Pamiętaj, że instrukcja I wymaga, by obie strony były prawdziwe. UWAGA Dobrym pomysłem jest używanie dodatkowych nawiasów – pomagają one lepiej oznaczyć operatory, które chcesz pogrupować. Pamiętaj, że twoim celem jest pisanie programów, które nie tylko działają, ale są także łatwe do odczytania i zrozumienia.
Kilka słów na temat prawdy i fałszu W języku C++ wartość zero jest traktowana jako logiczna wartość false, zaś wszystkie inne wartości są traktowane jako logiczna wartość true. Ponieważ wyrażenie zawsze posiada jakąś wartość, wielu programistów wykorzystuje ją w swoich instrukcjach if. Instrukcja taka jak if(x) x = 0;
// jeśli x ma wartość true (róŜną od zera)
może być odczytywana jako “jeśli x ma wartość różną od zera, ustaw x na 0”. Jest to efektowna sztuczka; zamiast tego lepiej będzie, gdy napiszesz: if (x != 0) // jeśli x ma wartość róŜną od zera x = 0;
Obie instrukcje są dozwolone, ale druga z nich lepiej wyraża intencje programisty. Do dobrych obyczajów programistów należy pozostawienie pierwszej z form dla prawdziwych testów logicznych (a nie dla sprawdzania czy wartość jest różna od zera).
Te dwie instrukcje także są równoważne: if (!x) // jeśli x ma wartość false (równą zeru) if (x == 0) // jeśli x ma wartość zero
Druga z nich jest nieco łatwiejsza do zrozumienia i wyraźniej sugeruje, że sprawdzamy matematyczną wartość zmiennej x, a nie jej stan logiczny.
TAK
NIE
Aby lepiej wyrazić kolejność obliczeń, Nie używaj if(x) jako synonimu dla if(x != umieszczaj nawiasy wokół wyrażeń logicznych. 0); druga z tych form jest bardziej czytelna. Aby uniknąć błędów i lepiej wyrazić przynależność instrukcji else, używaj nawiasów klamrowych w zagnieżdżonych instrukcjach if.
Nie używaj if(!x) jako synonimu dla if(x == 0); druga z tych form jest bardziej czytelna.
Operator warunkowy (trójelementowy) Operator warunkowy (?:) jest w języku C++ jedynym operatorem trójelementowym, tj. operatorem korzystającym z trzech wyrażeń. Operator warunkowy składa się z trzech wyrażeń i zwraca wartość: (wyraŜenie1) ? (wyraŜenie2) : (wyraŜenie3)
Tę linię odczytuje się jako: „jeśli wyraŜenie1 jest prawdziwe, zwróć wartość wyraŜenia2; w przeciwnym razie zwróć wartość wyraŜenia3”. Zwracana wartość jest zwykle przypisywana zmiennej. Listing 4.9 przedstawia instrukcję if przepisaną z użyciem operatora warunkowego. Listing 4.9. Przykład użycia operatora warunkowego 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12:
// Listing 4.9 - demonstruje operator warunkowy // #include int main() { using namespace std; int x, y, z; cout << "Wpisz dwie liczby.\n"; cout << "Pierwsza: "; cin >> x; cout << "\nDruga: "; cin >> y;
13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28:
cout << "\n"; if (x > y) z = x; else z = y; cout << "z: " << z; cout << "\n"; z =
(x > y) ? x : y;
cout << "z: " << z; cout << "\n"; return 0; }
Wynik Wpisz dwie liczby. Pierwsza: 5 Druga: 8 z: 8 z: 8
Analiza Tworzone są trzy zmienne całkowite: x, y oraz z. Wartości dwóch pierwszych są nadawane przez użytkownika. Instrukcja if w linii 15. sprawdza, która wartość jest większa i przypisuje ją zmiennej z. Ta wartość jest wypisywana w linii 20. Operator warunkowy w linii 23. przeprowadza ten sam test i przypisuje zmiennej z większą z wartości. Można go odczytać jako: jeśli x jest większe od y, zwróć wartość x; w przeciwnym razie zwróć wartość y”. Zwracana wartość jest przypisywana zmiennej z, zaś jej wartość jest wypisywana w linii 25. Jak widać, instrukcja warunkowa stanowi krótszy odpowiednik instrukcji if...else.
Rozdział 5. Funkcje Choć w programowaniu zorientowanym obiektowo zainteresowanie użytkowników zaczęło koncentrować się na obiektach, jednak mimo to funkcje w dalszym ciągu pozostają głównym komponentem każdego programu. Funkcje globalne występują poza obiektami, zaś funkcje składowe (zwane także metodami składowymi) występują wewnątrz obiektów, wykonując ich pracę. Z tego rozdziału dowiesz się: •
czym jest funkcja i z jakich części się składa,
•
jak deklarować i definiować funkcje,
•
jak przekazywać argumenty do funkcji,
•
jak zwracać wartość z funkcji.
Zaczniemy od funkcji globalnych; w następnym rozdziale dowiesz się, w jaki sposób funkcje działają wewnątrz obiektów.
Czym jest funkcja? Ogólnie, funkcja jest podprogramem, operującym na danych i zwracającym wartość. Każdy program C++ posiada przynajmniej jedną funkcję, main(). Gdy program rozpoczyna działanie, funkcja main() jest wywoływana automatycznie. Może ona wywoływać inne funkcje, które z kolei mogą wywoływać kolejne funkcje. Ponieważ funkcje te nie stanowią części jakiegoś obiektu, są nazywane „globalnymi” — mogą być dostępne z dowolnego miejsca programu. W tym rozdziale, gdy będziemy mówić o funkcjach, będziemy mieli na myśli właśnie funkcje globalne (chyba, że postanowimy inaczej). Każda funkcja posiada nazwę; gdy ta nazwa zostanie napotkana przez program, przechodzi on do wykonywania kodu zawartego wewnątrz ciała tej funkcji. Nazywa się to wywołaniem funkcji. Gdy funkcja wraca, wykonanie programu jest wznawiane od instrukcji następującej po wywołaniu tej funkcji. Ten przepływ sterowania został pokazany na rysunku 5.1.
Rysunek 5.1. Gdy program wywołuje funkcję, sterowanie przechodzi do jej ciała, po czym jest wznawiane od instrukcji występującej po wywołaniu tej funkcji
Dobrze zaprojektowane funkcje wykonują określone, łatwo zrozumiałe zadania. Złożone zadania powinny być dzielone na kilka, odpowiednio wywoływanych funkcji. Funkcje występują w dwóch odmianach: zdefiniowane przez użytkownika (programistę) oraz wbudowane. Funkcje wbudowane stanowią część pakietu dostarczanego wraz z kompilatorem — zostały one stworzone przez producenta kompilatora, z którego korzystasz. Funkcje zdefiniowane przez użytkownika są funkcjami, które piszesz samodzielnie.
Zwracane wartości, parametry i argumenty Funkcja może zwracać wartość. Gdy wywołujesz funkcję, może ona wykonać swoją pracę, po czym zwrócić wartość stanowiącą rezultat tej pracy. Ta wartość jest nazywana wartością zwracaną, zaś jej typ musi być zadeklarowany. Zatem, gdy piszesz: int myFunction();
deklarujesz, że funkcja myFunction zwraca wartość całkowitą. Możesz także przekazywać wartości do funkcji. Te wartości pełnią rolę zmiennych, którymi możesz manipulować wewnątrz funkcji. Opis przekazywanych wartości jest nazywany listą parametrów. int myFunction(int someValue, float someFloat);
Ta deklaracja wskazuje, że funkcja myFunction nie tylko zwraca liczbę całkowitą, ale także, że jej parametrami są: wartość całkowita oraz wartość typu float. Parametr opisuje typ wartości, jaka jest przekazywana funkcji podczas jej wywołania. Wartości przekazywane funkcji są nazywane argumentami. int theValueReturned = myFunction(5, 6.7);
W tej deklaracji Wwidzimy, tutaj że zmienna całkowita theValueReturned (zwracana wartość) jest inicjalizowana wartością zwracaną przez funkcję myFunction, której zostały przekazane wartości 5 oraz 6.7 (jako argumenty). Typy argumentów muszą odpowiadać zadeklarowanym typom parametrów.
Deklarowanie i definiowanie funkcji Aby użyć funkcji w programie, należy najpierw zadeklarować funkcję, a następnie ją zdefiniować. Deklaracja informuje kompilator o nazwie funkcji, typie zwracanej przez nią wartości, oraz o jej parametrach. Z kolei definicja informuje, w jaki sposób dana funkcja działa. Żadna funkcja nie może zostać wywołana z jakiejkolwiek innej funkcji, jeśli nie zostanie wcześniej zadeklarowana. Deklaracja funkcji jest nazywana prototypem.
Deklarowanie funkcji Istnieją trzy sposoby deklarowania funkcji: •
zapisanie prototypu funkcji w pliku, a następnie użycie dyrektywy #include w celu dołączenia go do swojego programu,
•
zapisanie prototypu w pliku, w którym dana funkcja jest używana,
•
zdefiniowanie funkcji zanim zostanie wywołana przez inne funkcje. Jeśli tego nie dokonasz, definicja będzie pełnić jednocześnie rolę deklaracji funkcji.
Choć możesz zdefiniować funkcję przed jej użyciem i uniknąć w ten sposób konieczności tworzenia jej prototypu, nie należy to do dobrych obyczajów programistycznych z trzech powodów. Po pierwsze, niedobrze jest, gdy funkcje muszą występować w pliku źródłowym w określonej kolejności. Powoduje to, że w razie wprowadzenia zmian trudno jest zmodyfikować taki program. Po drugie, istnieje możliwość, że w pewnych warunkach funkcja A() musi być w stanie wywołać funkcję B(), a funkcja B() także musi być w stanie wywołać funkcję A(). Nie jest możliwe zdefiniowanie funkcji A() przed zdefiniowaniem funkcji B() i jednoczesne zdefiniowanie funkcji B() przed zdefiniowaniem funkcji A(), dlatego przynajmniej jedna z nich zawsze musi zostać zadeklarowana.
Po trzecie, prototypy funkcji stanowią wydajną technikę debuggowania (usuwania błędów w programach). Jeśli z prototypu wynika, że funkcja otrzymuje określony zestaw parametrów lub że zwraca określony typ wartości, to w przypadku gdy funkcja nie jest zgodna z tym prototypem, kompilator, zamiast czekać na wystąpienie błędu podczas działania programu, może wskazać tę niezgodność. Przypomina to dwustronną księgowość. Prototyp i definicja sprawdzają się wzajemnie, redukując prawdopodobieństwo, że zwykła literówka spowoduje błąd w programie.
Prototypy funkcji Wiele z wbudowanych funkcji posiada już gotowe prototypy. Występują one w plikach, które są dołączane do programu za pomocą dyrektywy #include. W przypadku funkcji pisanych samodzielnie, musisz stworzyć samodzielnie także ich prototypy. Prototyp funkcji jest instrukcją, co oznacza, że kończy się on średnikiem. Składa się ze zwracanego przez funkcję typu oraz tzw. sygnatury funkcji. Sygnatura funkcji to jej nazwa oraz lista parametrów. Lista parametrów jest listą wszystkich parametrów oraz ich typów, oddzielonych od siebie przecinkami. Elementy prototypu funkcji przedstawia rysunek 5.2.
Rysunek 5.2. Elementy prototypu funkcji
Zwracany typ oraz sygnatura prototypu i definicji funkcji muszą zgadzać się dokładnie. Jeśli nie są one zgodne, wystąpi błąd kompilacji. Zauważ jednak, że prototyp funkcji nie musi zawierać nazw parametrów, a jedynie ich typy. Poniższy prototyp jest poprawny: long Area(int, int);
Ten prototyp deklaruje funkcję o nazwie Area (obszar), która zwraca wartość typu long i posiada dwa parametry, będące wartościami całkowitymi. Choć ten zapis jest poprawny, jednak jego stosowanie nie jest dobrym pomysłem. Dodanie nazw parametrów powoduje, że prototyp staje się bardziej czytelny. Ta sama funkcja z nazwanymi parametrami mogłaby być zadeklarowana następująco: long Area(int length, int width );
W tym przypadku jest oczywiste, do czego służy ta funkcja oraz jakie są jej parametry.
Zwróć uwagę, że wszystkie funkcje zwracają wartość pewnego typu. Jeśli ten typ nie zostanie podany jawnie, zakłada się, że jest wartością całkowitą, a konkretnie typem int. Twoje programy będą jednak łatwiejsze do zrozumienia, jeśli we wszystkich funkcjach, włącznie z funkcją main(), będziesz deklarował zwracany typ.
Definiowanie funkcji Definicja funkcji składa się z nagłówka funkcji oraz z jej ciała. Nagłówek przypomina prototyp funkcji, w którym wszystkie parametry muszą być nazwane a na końcu nagłówka nie występuje średnik. Ciało funkcji jest ujętym w nawiasy klamrowe zestawem instrukcji. Rysunek 5.3 przedstawia nagłówek i ciało funkcji.
Rysunek 5.3. Nagłówek i ciało funkcji
Listing 5.1 demonstruje program zawierający prototyp oraz deklarację funkcji Area(). Listing 5.1. Deklaracja i definicja funkcji oraz ich wykorzystanie w programie 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13:
// Listing 5.1 - demonstruje uŜycie prototypów funkcji #include int Area(int length, int width); //prototyp funkcji int main() { using std::cout; using std::cin; int lengthOfYard; int widthOfYard; int areaOfYard;
14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30:
cout << "\nJak szerokie jest twoje podworko? "; cin >> widthOfYard; cout << "\nJak dlugie jest twoje podworko? "; cin >> lengthOfYard; areaOfYard= Area(lengthOfYard,widthOfYard); cout << "\nTwoje podworko ma "; cout << areaOfYard; cout << " metrow kwadratowych\n\n"; return 0; } int Area(int l, int w) { return l * w; }
Wynik Jak szerokie jest twoje podworko? 100 Jak dlugie jest twoje podworko? 200 Twoje podworko ma 20000 metrow kwadratowych
Analiza Prototyp funkcji Area() znajduje się w linii 3. Porównaj ten prototyp z definicją funkcji, zaczynającą się od linii 27. Zwróć uwagę, że nazwa, zwracany typ oraz typy parametrów są takie same. Gdyby były różne, wystąpiłby błąd kompilacji. W rzeczywistości jedyną różnicę stanowi fakt, że prototyp funkcji kończy się średnikiem i nie posiada ciała. Zwróć także uwagę, że nazwy parametrów w prototypie to length (długość) oraz width (szerokość), ale nazwy parametrów w definicji to l oraz w. Jak wspomniano wcześniej, nazwy w prototypie nie są używane i służą wyłącznie jako informacja dla programisty. Do dobrych obyczajów programistycznych należy dopasowanie nazw parametrów prototypu do nazw parametrów definicji (nie jest to wymaganie języka). Argumenty są przekazywane funkcji w takiej kolejności, w jakiej zostały zadeklarowane i zdefiniowane parametry, lecz ich nazwy nie muszą do siebie pasować. Gdy przekażesz zmienną widthOfYard (szerokość podwórka), a po niej lengthOfYard (długość podwórka), wtedy funkcja FindArea (znajdź obszar) użyje wartości widthOfYard jako długości oraz wartości lengthOfYard jako szerokości. Ciało funkcji jest zawsze ujęte w nawiasy klamrowe, nawet jeśli (tak jak w tym przypadku) składa się z jednej tylko instrukcji.
Wykonywanie funkcji Gdy wywołujesz funkcję, jej wykonanie rozpoczyna się od pierwszej instrukcji następującej po otwierającym nawiasie klamrowym ({). Rozgałęzienie działania można uzyskać za pomocą instrukcji if. (Instrukcja if oraz instrukcje z nią związane zostaną omówione w rozdziale 7.).
Funkcje mogą także wywoływać inne funkcje, a nawet wywoływać siebie same (patrz podrozdział „Rekurencja” w dalszej części tego rozdziału).
Zmienne lokalne Zmienne można przekazywać funkcjom; można również deklarować zmienne wewnątrz ciała funkcji. Zmienne deklarowane wewnątrz ciała funkcji są nazywane „lokalnymi”, gdyż istnieją tylko lokalnie wewnątrz danej funkcji. Gdy funkcja wraca (kończy działanie), zmienne lokalne przestają być dostępne i zostają zniszczone przez kompilator. Zmienne lokalne są definiowane tak samo, jak wszystkie inne zmienne. Parametry przekazywane do funkcji także są uważane za zmienne lokalne i mogą być używane identycznie, jak zmienne zadeklarowane wewnątrz ciała funkcji. Przykład użycia parametrów oraz zmiennych zadeklarowanych lokalnie wewnątrz funkcji przedstawia listing 5.2.
Listing 5.2. Użycie zmiennych lokalnych oraz parametrów 0: #include 1: 2: float Convert(float); 3: int main() 4: { 5: using namespace std; 6: 7: float TempFer; 8: float TempCel; 9: 10: cout << "Podaj prosze temperature w stopniach Fahrenheita: "; 11: cin >> TempFer; 12: TempCel = Convert(TempFer); 13: cout << "\nOdpowiadajaca jej temperatura w stopniach Celsjusza: "; 14: cout << TempCel << endl; 15: return 0; 16: } 17: 18: float Convert(float TempFer) 19: { 20: float TempCel; 21: TempCel = ((TempFer - 32) * 5) / 9; 22: return TempCel; 23: }
Wynik Podaj prosze temperature w stopniach Fahrenheita: 212 Odpowiadajaca jej temperatura w stopniach Celsjusza: 100 Podaj prosze temperature w stopniach Fahrenheita: 32 Odpowiadajaca jej temperatura w stopniach Celsjusza: 0
Podaj prosze temperature w stopniach Fahrenheita: 85 Odpowiadajaca jej temperatura w stopniach Celsjusza: 29.4444
Analiza W liniach 7. i 8. są deklarowane dwie zmienne typu float, z których jednak przechowuje temperaturę w stopniach Fahrenheita, zaś druga w stopniach Celsjusza. W linii 10. użytkownik jest proszony o podanie temperatury w stopniach Fahrenheita, zaś uzyskana wartość jest przekazywana funkcji Convert() (konwertuj). Wykonanie programu przechodzi do pierwszej linii funkcji Convert() w linii 20., w której jest deklarowana zmienna lokalna, także o nazwie TempCel (temperatura w stopniach Celsjusza). Zwróć uwagę, że ta zmienna lokalna nie jest równoważna zmiennej TempCel w linii 8. Ta zmienna istnieje tylko wewnątrz funkcji Convert(). Wartość przekazywana jako parametr, TempFer (temperatura w stopniach Fahrenheita), także jest tylko lokalną kopią zmiennej przekazywanej przez funkcję main(). Ta funkcja mogłaby posiadać parametr o nazwie FerTemp i zmienną lokalną CelTemp, a program działałby równie dobrze. Aby przekonać się że program działa, możesz wpisać te nazwy i ponownie go skompilować. Lokalnej zmienneja funkcji, TempCel, jest przypisywana wartość, będąca wynikiem odjęcia 32 od parametru TempFer, pomnożenia przez 5, a następnie podzielenia przez 9. Ta wartość jest następnie zwracana jako wartość funkcji, która w linii 12. jest przypisywana zmiennej TempCel wewnątrz funkcji main(). Ta wartość jest wypisywana w linii 14. Program został uruchomiony trzykrotnie. Za pierwszym razem została podana wartość 212, w celu upewnienia się, czy punkt wrzenia wody w stopniach Fahrenheita (212) daje właściwy wynik w stopniach Celsjusza (100). Drugi test sprawdza temperaturę zamarzania wody. Trzeci test to przypadkowa wartość, wybrana w celu wygenerowania wyniku ułamkowego.
Zakres Zmienna posiada zakres, który określa, jak długo i w których miejscach programu jest ona dostępna. Zmienne zadeklarowane wewnątrz bloku mają zakres obejmujący ten blok; mogą być dostępne tylko wewnątrz tego bloku i „przestają istnieć” po wyjściu programu z tego bloku. Zmienne globalne mają zakres globalny i są dostępne w każdym miejscu programu.
Zmienne globalne Zmienne zdefiniowane poza funkcją mają zakres globalny i są dostępne z każdej funkcji w programie, włącznie z funkcją main(). Zmienne lokalne o takich samych nazwach, jak zmienne globalne nie zmieniają zmiennych globalnych. Jednak zmienna lokalna o takiej samej nazwie, jak zmienna globalna przesłania „ukrywa” zmienną globalną. Jeśli funkcja posiada zmienną o takiej samej nazwie jak zmienna
globalna, to ta nazwa użyta wewnątrz funkcji odnosi się do zmiennej lokalnej, a nie do globalnej. Ilustruje to listing 5.3. Listing 5.3. Przykład zmiennych lokalnych i globalnych 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25:
#include void myFunction(); int x = 5, y = 7; int main() { using std::cout;
// prototyp // zmienne globalne
cout << "x z funkcji main: " << x << "\n"; cout << "y z funkcji main: " << y << "\n\n"; myFunction(); cout << "Wrocilem z myFunction!\n\n"; cout << "x z funkcji main: " << x << "\n"; cout << "y z funkcji main: " << y << "\n"; return 0; } void myFunction() { using std::cout; int y = 10; cout << "x z funkcji myFunction: " << x << "\n"; cout << "y z funkcji myFunction: " << y << "\n\n"; }
Wynik x z funkcji main: 5 y z funkcji main: 7 x z funkcji myFunction: 5 y z funkcji myFunction: 10 Wrocilem z myFunction! x z funkcji main: 5 y z funkcji main: 7
Analiza Ten prosty program ilustruje kilka kluczowych i potencjalnie niezrozumiałych zagadnień, dotyczących zmiennych lokalnych i globalnych. W linii 3. są deklarowane dwie zmienne globalne, x oraz y. Zmienna globalna x jest inicjalizowana wartością 5, zaś zmienna globalna y jest inicjalizowana wartością 7. W liniach 8. i 9. w funkcji main() te wartości są wypisywane na ekranie. Zauważ, że funkcja main() nie definiuje tych zmiennych; ponieważ są one globalne, są one dostępne w funkcji main(). Gdy w linii 10. zostaje wywołana funkcja myFunction(), działanie programu przechodzi do linii 17. a w linii 21. jest definiowana lokalna zmienna y, inicjalizowana wartością 10. W linii 23.
funkcja myFunction() wypisuje wartość zmiennej x; w tym przypadku zostaje użyta wartość globalnej zmiennej x, tak jak w funkcji main(). Jednak w linii 24., w której zostaje użyta nazwa zmiennej y, wykorzystywana jest lokalna zmienna y, gdyż przesłoniła (ukryła) ona zmienną globalną o tej samej nazwie. Funkcja kończy swoje działanie i zwraca sterowanie do funkcji main(), która ponownie wypisuje wartości zmiennych globalnych. Zauważ, że przypisanie wartości do zmiennej lokalnej y w funkcji myFunction() w żaden sposób nie wpłynęło na wartość globalnej zmiennej y.
Zmienne globalne: ostrzeżenie W C++ dozwolone są zmienne globalne, ale prawie nigdy nie są one używane. C++ wyrosło z języka C, zaś w tym języku zmienne globalne były niebezpiecznym, choć niezbędnym narzędziem. Zmienne globalne są konieczne, gdyż zdarzają się sytuacje, w których dane muszą być łatwo dostępne dla wielu funkcji i nie chcemy ich przekazywać z funkcji do funkcji w postaci parametrów. Zmienne globalne są niebezpieczne, gdyż zawierają wspólne dane, które mogą być zmienione przez którąś z funkcji w sposób niewidoczny dla innych. Może to powodować bardzo trudne do odszukania błędy. W rozdziale 15., „Specjalne klasy i funkcje,” poznasz alternatywę dla zmiennych globalnych, stanowią ją statyczne zmienne składowe.
Kilka słów na temat zmiennych lokalnych Zmienne można definiować w dowolnym miejscu funkcji, nie tylko na jej początku. Zakresem zmiennej jest blok, w którym została zdefiniowana. Dlatego, jeśli zdefiniujesz zmienną wewnątrz nawiasów klamrowych wewnątrz funkcji, będzie ona dostępna tylko wewnątrz tego bloku. Ilustruje to listing 5.4. Listing 5.4. Zakres zmiennych ogranicza się do bloku, w którym zostały zadeklarowane 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17:
// Listing 5.4 - demonstruje Ŝe zakres zmiennej // ogranicza się do bloku, w którym została zadeklarowana #include void myFunc(); int main() { int x = 5; std::cout << "\nW main x ma wartosc: " << x; myFunc(); std::cout << "\nPonownie w main, x ma wartosc: " << x; return 0; }
18: void myFunc() 19: { 20: int x = 8; 21: std::cout << "\nW myFunc, lokalne x: " << x << std::endl; 22: 23: { 24: std::cout << "\nW bloku w myFunc, x ma wartosc: " << x; 25: 26: int x = 9; 27: 28: std::cout << "\nBardzo lokalne x: " << x; 29: } 30: 31: std::cout << "\nPoza blokiem, w myFunc, x: " << x << std::endl; 32: }
Wynik W main x ma wartosc: 5 W myFunc, lokalne x: 8 W bloku w myFunc, x ma wartosc: 8 Bardzo lokalne x: 9 Poza blokiem, w myFunc, x: 8 Ponownie w main, x ma wartosc: 5
Analiza Ten program zaczyna działanie od inicjalizacji lokalnej zmiennej x w linii 9., lokalnej zmiennej x w funkcji main(). Komunikat wypisywany w linii 10. potwierdza, że x zostało zainicjalizowane wartością 5. Wywoływana jest funkcja myFunc(), w której, w linii 20., wartością 8 jest inicjalizowana lokalna zmienna, także o nazwie x. Jej wartość jest wypisywana w linii 21. W linii 23. rozpoczyna się blok, a w linii 24. ponownie wypisywana jest wartość zmiennej x z funkcji. Wewnątrz bloku, w linii 26., tworzona jest nowa zmienna, także o nazwie x, która jednak jest lokalna dla bloku. Jest ona inicjalizowana wartością 9. Wartość najnowszej zmiennej jest wypisywana w linii 28. Lokalny blok kończy się w linii 29., gdzie zmienna stworzona w linii 26. „wychodzi” z zakresu i nie jest już widoczna. Gdy w linii 31. jest wypisywana wartość x, jest to wartość zmiennej x zadeklarowanej w linii 20. Na tę zmienną nie miała wpływu deklaracja zmiennej x w linii 26.; jej wartość wynosi wciąż 8. W linii 32., funkcja myFunc() wychodzi z zakresu, a jej zmienna lokalna x staje się niedostępna. Wykonanie wraca do linii 14., w której jest wypisywana wartość lokalnej zmiennej x, stworzonej w linii 9. Nie ma na nią wpływu żadna ze zmiennych zdefiniowanych w funkcji myFunc(). Mimo wszystko, program ten sprawiałby dużo mniej kłopotów, gdyby te trzy zmienne posiadały różne nazwy!
Instrukcje funkcji Praktycznie ilość rodzajów instrukcji, które mogą być umieszczone wewnątrz ciała funkcji, jest nieograniczona. Choć wewnątrz danej funkcji nie można definiować innych funkcji, jednak można je wywoływać, z czego korzysta oczywiście funkcja main() w większości programów C++. Funkcje mogą nawet wywoływać same siebie (co zostanie wkrótce omówione w podrozdziale poświęconym rekurencji). Chociaż rozmiar funkcji w języku C++ nie jest ograniczony, jednak dobrze zaprojektowane funkcje są zwykle niewielkie. Wielu programistów radzi, by funkcja mieściła się na pojedynczym ekranie tak, aby można ją było widzieć w całości. Ta reguła jest często łamana, także przez bardzo dobrych programistów. Jednakże mniejsze funkcje są łatwiejsze do zrozumienia i utrzymania. Każda funkcja powinna spełniać pojedyncze, dobrze określone zadanie. Jeśli funkcja zbytnio się rozrasta, poszukaj miejsc, w których możesz podzielić ją na mniejsze podzadania.
Kilka słów na temat argumentów funkcji Argumenty funkcji nie muszą być tego samego typu. Najzupełniej poprawne i sensowne jest na przykład posiadanie funkcji, której argumentami są liczba całkowita, dwie liczby typu long oraz znak (char). Argumentem funkcji może być każde poprawne wyrażenie języka C++, łącznie ze stałymi, wyrażeniami matematycznymi i logicznymi, a także innymi funkcjami zwracającymi wartość.
Użycie funkcji jako parametrów funkcji Choć jest dozwolone, by parametrem funkcji była inna zwracająca wartość funkcja, może to spowodować trudności w debuggowaniu kodu i jego nieczytelność. Na przykład, przypuśćmy, że masz funkcje: myDouble() (podwojenie), triple() (potrojenie), square() (do kwadratu) oraz cube() (do trzeciej potęgi), z których każda zwraca wartość. Mógłbyś napisać: Answer = (myDouble(triple(square(cube(myValue)))));
Ta instrukcja pobiera zmienną, myValue i przekazuje ją jako argument do funkcji cube(), której zwracana wartość jest przekazywana jako argument do funkcji square(), której zwracana wartość jest przekazywana z kolei jako argument do funkcji triple(), zaś jej zwracana wartość jest przekazywana do funkcji myDouble(). Ostatecznie zwracana wartość tej funkcji jest przypisywana zmiennej Answer (odpowiedź). Trudno jest przewidzieć, do czego służy ten kod (wartość jest potrajana przed, czy po podniesieniu do kwadratu?), a gdy odpowiedź jest niepoprawna, trudno będzie sprawdzić, która z funkcji działa niewłaściwie.
Alternatywą jest użycie w każdym kroku oddzielnej, pośredniej zmiennej: unsigned unsigned unsigned unsigned unsigned
long long long long long
myValue = 2; cubed = cube(myValue); squared = square(cubed); tripled = triple(squared); Answer = myDouble(tripled);
// // // //
cubed = 8 squared = 64 tripled = 192 Answer = 384
Teraz każdy pośredni wynik może zostać sprawdzony, zaś kolejność wykonywania jest bardzo dobrze widoczna.
Parametry są zmiennymi lokalnymi Argumenty przekazywane funkcji są lokalne dla tej funkcji. Zmiany dokonane w argumentach nie wpływają na wartości w funkcji wywołującej. Nazywa się to przekazywaniem przez wartość, co oznacza, że wewnątrz funkcji jest tworzona lokalna kopia każdego z argumentów. Te lokalne kopie są traktowane tak samo, jak każda inna zmienna lokalna. Ilustruje to listing 5.5. Listing 5.5. Przykład przekazywania przez wartość 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: " y: 11: 12: y: " 13: 14: 15: 16: 17: 18: 19: 20: " << 21: 22: 23: 24: 25: 26: << y 27:
// Listing 5.5 - demonstracja przekazywania przez wartość. #include void swap(int x, int y); int main() { int x = 5, y = 10; std::cout << "Funkcja Main. Przed funkcja Swap, x: " << x << " << y << "\n"; swap(x,y); std::cout << "Funkcja Main. Po funkcji Swap, x: " << x << " << y << "\n"; return 0; } void swap (int x, int y) { int temp; std::cout << "Funkcja Swap. Przed zamiana, x: " << x << " y: y << "\n"; temp = x; x = y; y = temp; std::cout << "Funkcja Swap. Po zamianie, x: " << x << " y: " << "\n"; }
Wynik Funkcja Main. Przed funkcja Swap, x: 5 y: 10
Funkcja Swap. Przed zamiana, x: 5 y: 10 Funkcja Swap. Po zamianie, x: 10 y: 5 Funkcja Main. Po funkcji Swap, x: 5 y: 10
Analiza Program inicjalizuje w funkcji main() dwie zmienne, po czym przekazuje je do funkcji swap() (zamień), która wydaje się wzajemnie wymieniać ich wartości. Jednak gdy ponownie sprawdzimy ich wartość w funkcji main(), okazuje się, że pozostały niezmienione! Zmienne są inicjalizowane w linii 8., a ich wartości są pokazywane w linii 10. Następnie wywoływana jest funkcja swap(), do której przekazywane są zmienne. Działanie programu przechodzi do funkcji swap(), gdzie w linii 20 wartości są wypisywane ponownie. Mają tę samą kolejność, jak w funkcji main(), czego zresztą oczekiwaliśmy. W liniach od 22. do 24. wartości są zamieniane, co potwierdza komunikat wypisywany w linii 26. Rzeczywiście, wewnątrz funkcji swap() wartości zostały zamienione. Wykonanie programu powraca następnie do linii 12., z powrotem do funkcji main(), gdzie okazuje się, że wartości nie są już zamienione. Jak zapewne się domyślasz, wartości przekazane funkcji swap() zostały przekazane przez wartość, co oznacza, że w tej funkcji zostały utworzone ich lokalne kopie. Właśnie te zmienne lokalne są zamieniane w liniach od 22. do 24., bez odwoływania się do zmiennych funkcji main(). W rozdziale 8., „Wskaźniki,” oraz rozdziale 10., „Funkcje zaawansowane,” poznasz alternatywne sposobyę przekazywania przez wartość, którea umożliwiąłaby zmianę wartości przekazanych przez w funkcjię main().
Kilka słów na temat zwracanych wartości Funkcja zwraca albo wartość, albo typ void (pusty). Typ void jest dla kompilatora sygnałem, że funkcja nie zwraca żadnej wartości. Aby zwrócić wartość z funkcji, użyj słowa kluczowego return, a po nim wartości, którą chcesz zwrócić. Wartość ta może być wyrażeniem zwracającym wartość. Na przykład: return 5; return (x > 5); return (MyFunction());
Wszystkie te instrukcje są poprawne, pod warunkiem, że funkcja MyFunction() także zwraca wartość. Wartością w drugiej instrukcji, return (x > 5); będzie false, gdy x nie jest większe od 5, lub true w odwrotnej sytuacji. Zwracana jest wartość wyrażenia, false lub true, a nie wartość x.
Gdy program natrafia na słowo kluczowe return, następująca po nim wartość jest zwracana jako wartość funkcji. Wykonanie programu powraca natychmiast do funkcji wywołującej, zaś instrukcje występujące po instrukcji return nie są już wykonywane. Pojedyncza funkcja może zawierać więcej niż jedną instrukcję return. Ilustruje to listing 5.6. Listing 5.6. Przykład kilku instrukcji return zawartych w tej samej funkcji 0: // Listing 5.6 - Demonstracja kilku instrukcji return 1: // zawartych w tej samej funkcji. 2: 3: #include 4: 5: int Doubler(int AmountToDouble); 6: 7: int main() 8: { 9: using std::cout; 10: 11: int result = 0; 12: int input; 13: 14: cout << "Wpisz liczbe do podwojenia (od 0 do 10 000): "; 15: std::cin >> input; 16: 17: cout << "\nPrzed wywolaniem funkcji Doubler... "; 18: cout << "\nwejscie: " << input << " podwojone: " << result << "\n"; 19: 20: result = Doubler(input); 21: 22: cout << "\nPo powrocie z funkcji Doubler...\n"; 23: cout << "\nwejscie: " << input << " podwojone: " << result << "\n"; 24: 25: return 0; 26: } 27: 28: int Doubler(int original) 29: { 30: if (original <= 10000) 31: return original * 2; 32: else 33: return -1; 34: std::cout << "Nie mozesz tu byc!\n"; 35: }
Wynik Wpisz liczbe do podwojenia (od 0 do 10 000): 9000 Przed wywolaniem funkcji Doubler... wejscie: 9000 podwojone: 0 Po powrocie z funkcji Doubler... wejscie: 9000
podwojone: 18000
Wpisz liczbe do podwojenia (od 0 do 10 000): 11000
Przed wywolaniem funkcji Doubler... wejscie: 11000 podwojone: 0 Po powrocie z funkcji Doubler... wejscie: 11000 podwojone: -1
Analiza W liniach 14. i 15. program prosi o podanie liczby, która jest wypisywana w linii 18., razem z wynikiem w zmiennej lokalnej. Następnie w linii 20. jest wywoływana funkcja Doubler() (podwojenie), której argumentem jest zmienna input (wejście). Rezultat jest przypisywany lokalnej zmiennej result (wynik), a w linii 23. ponownie wypisywane są wartości. W linii 30., w funkcji Doubler(), następuje sprawdzenie, czy parametr jest większy od 10000. Jeśli nie, funkcja zwraca podwojoną liczbę pierwotną. Jeśli jest większy od 10000, funkcja zwraca –1 jako wartość błędu. Instrukcja w linii 34. nigdy nie jest wykonywana, ponieważ bez względu na to, czy wartość jest większa od 10000, czy nie, funkcja wraca (do main) w linii 31. lub 33. — czyli przed przejściem do linii 34. Dobry kompilator ostrzeże, że ta instrukcja nie może zostać wykonana, zaśa dobry programista ją usunie!powinien się tym zająć! Często zadawane pytanie
Jaka jest różnica pomiędzy int main() a void main(); której formy powinienem użyć? Używałem obu i obie działają poprawnie, dlaczego więc powinienem używać int main(){ return 0;}?
Odpowiedź: W większości kompilatorów działają obie formy, ale zgodna z ANSI jest tylko forma int main() i tylko jej użycie gwarantuje, że program będzie mógł być bez zmian kompilowany także w przyszłości.
Oto różnica: int main() zwraca wartość do systemu operacyjnego. Gdy program kończy działanie, ta wartość może być odczytana przez, na przykład, program wsadowy.
Nie będziemy używać tej zwracanej wartości (rzadko się z niej korzysta), ale wymaga jej standard ANSI.
Parametry domyślne Funkcja wywołująca musi przekazać wartość dla każdego parametru zadeklarowanego w prototypie i definicji funkcji. Przekazywana wartość musi być zgodna z zadeklarowanym typem. Zatem, gdy masz funkcję zadeklarowaną jako long myFunction(int);
wtedy funkcja ta musi otrzymać wartość całkowitą. Jeśli definicja funkcji jest inna lub nie przekażesz jej wartości całkowitej, wystąpi błąd kompilacji. Jedyny wyjątek od tej reguły obowiązuje, gdy prototyp funkcji deklaruje domyślną wartość parametru. Ta domyślna wartość jest używana wtedy, gdy nie zostanie przekazany argument funkcji. Poprzednią deklarację można przepisać jako long myFunction (int x = 50);
Ten prototyp informuje, że funkcja myFunction() zwraca wartość typu long i otrzymuje parametr będący wartością całkowitą. Jeśli argument nie zostanie podany, użyta zostanie domyślna wartość 50. Ponieważ nazwy parametrów nie są wymagane w prototypach funkcji, tę deklarację można zapisać następująco: long myFunction (int = 50);
Definicja funkcji nie zmienia się w wyniku zadeklarowania parametru domyślnego. W tym przypadku nagłówek definicji funkcji przyjmie postać: long myFunction (int x)
Jeśli funkcja wywołująca nie przekaże parametru, kompilator wypełni parametr x domyślną wartością 50. Nazwa domyślnego parametru w prototypie nie musi być tak sama, jak nazwa w nagłówku funkcji; domyślna wartość jest przypisywana na podstawie pozycji, a nie nazwy. Wartość domyślna może zostać przypisana każdemu parametrowi funkcji. Istnieje tylko jedno ograniczenie: jeśli któryś z parametrów nie ma wartości domyślnej, nie może jej mieć także żaden z wcześniejszych parametrów. Jeśli prototyp funkcji ma postać: long myFunction (int Param1, int Param2, int Param3);
to parametrowi Param1 możesz przypisać domyślną wartość tylko wtedy, gdy przypiszesz ją również parametrom Param2 i Param3. Użycie parametrów domyślnych ilustruje listing 5.7. Listing 5.7. Użycie parametrów domyślnych 0: // Listing 5.7 - demonstruje uŜycie 1: // domyślnych wartości parametrów 2: 3: #include 4: 5: int VolumeCube(int length, int width = 25, int height = 1); 6: 7: int main() 8: { 9: int length = 100; 10: int width = 50; 11: int height = 2; 12: int area; 13: 14: area = VolumeCube(length, width, height); 15: std::cout << "Za pierwszym razem objetosc wynosi: " << area << "\n"; 16: 17: area = VolumeCube(length, width); 18: std::cout << "Za drugim razem objetosc wynosi: " << area << "\n"; 19: 20: area = VolumeCube(length); 21: std::cout << "Za trzecim razem objetosc wynosi: " << area << "\n"; 22: return 0; 23: } 24: 25: int VolumeCube(int length, int width, int height) 26: { 27: 28: return (length * width * height); 29: }
Wynik Za pierwszym razem objetosc wynosi: 10000 Za drugim razem objetosc wynosi: 5000 Za trzecim razem objetosc wynosi: 2500
Analiza W linii 5., prototyp funkcji VolumeCube() (objętość sześcianu) określa, że ta funkcja posiada trzy parametry, będące wartościami całkowitymi. Dwa ostatnie posiadają wartości domyślne. Ta funkcja oblicza objętość sześcianu, którego wymiary zostały jej przekazane. Jeśli nie zostanie podana szerokość (width), funkcja użyje szerokości równej 25 i wysokości (height) równej 1. Jeśli zostanie podana szerokość, lecz nie zostanie podana wysokość, funkcja użyje wysokości równej 1. Nie ma możliwości przekazania wysokości bez przekazania szerokości. W liniach od 9. do 11. inicjalizowane są wymiary, a w linii 14. są one przekazywane funkcji VolumeCube(). Obliczana jest objętość, zaś wynik jest wypisywany w linii 15.
Wykonanie przechodzipowraca do linii 17., w której ponownie wywoływana jest funkcja VolumeCube() (lecz tym razem bez podawania wysokości). Używana jest wartość domyślna, a objętość jest ponownie obliczana i wypisywana. Wykonanie przechodzipowraca do linii 20., lecz tym razem nie jest przekazywana ani szerokość, ani wysokość. Wykonanie po raz trzeci przechodzi powraca do linii 25. Użyte zostają domyślne wartości. Obliczana i wypisywana jest objętość. TAK
NIE
Pamiętaj, że parametry funkcji pełnią wewnątrz tej funkcji rolę zmiennych lokalnych.
Nie próbuj tworzyć domyślnej wartości dla pierwszego parametru, jeśli nie istnieje domyślna wartość dla drugiego. Nie zapominaj, że argumenty przekazywane przez wartość nie wpływają na zmienne w funkcji wywołującej. Nie zapominaj, że zmiana zmiennej globalnej w jednej z funkcji zmienia jej wartość we wszystkich funkcjach.
Przeciążanie funkcji C++ umożliwia tworzenie większej ilości funkcji o tej samej nazwie. Nazywa się to przeciążaniem lub przeładowaniem funkcji (ang. function overloading). Listy parametrów funkcji przeciążonych funkcji muszą się różnić się od siebie albo typami parametrów, albo ich ilością, albo jednocześnie typami i ilością. Oto przykład: int myFunction (int, int); int myFunction (long, long); int myFunction (long);
Funkcja myFunction() jest przeciążona z trzema listami parametrów. Pierwsza i druga wersja różnią się od siebie typem parametrów, zaś trzecia wersja różni się od nich ilością parametrów. Typy wartości zwracanych przez funkcje przeciążone funkcje mogą być takie same lub różne. UWAGA Dwie funkcje o tych samych nazwach i listach parametrów, różniące się tylko typem zwracanej wartości, powodują wystąpienie błędu kompilacji. Aby zmienić zwracany typ, musisz zmienić także sygnaturę funkcji (tj. jej nazwę i (lub) listę parametrów).
Przeciążanie funkcji zwane jest także polimorfizmem funkcji. „Poli” oznacza „wiele”, zaś „morf” oznacza „formę”; tak więc „polimorfizm” oznacza „wiele form”. Polimorfizm funkcji oznacza możliwość „przeciążenia” funkcji więcej niż jednym znaczeniem. Zmieniając ilość lub typ parametrów, możemy nadawać jednej lub więcej funkcjom tę samą
nazwę, a mimo to, na podstawie użytych parametrów, zostanie wywołana właściwa funkcja. Dzięki temu można na przykład tworzyć funkcje uśredniające liczby całkowite, zmiennoprzecinkowe i inne wartości bez konieczności tworzenia osobnych nazw dla każdej funkcji, np. AverageInts() (uśredniaj wartości całkowite), AverageDoubles() (uśredniaj wartości typu double), itd. Przypuśćmy, że piszesz funkcję, która podwaja każdą wartość, którą jej przekażesz. Chciałbyś mieć możliwość przekazywania jej wartości typu int, long, float oraz double. Bez przeciążania funkcji musiałbyś wymyślić cztery jej nazwy: int DoubleInt(int); long DoubleLong(long); float DoubleFloat(float); double DoubleDouble(double);
Dzięki przeciążaniu funkcji możesz zastosować deklaracje: int Double(int); long Double(long); float Double(float); double Double(double);
Są one łatwiejsze do odczytania i wykorzystania. Nie musisz pamiętać, którą funkcję należy wywołać; po prostu przekazujesz jej zmienną, a właściwa funkcja zostaje wywołana automatycznie. Takie zastosowanie przeciążania funkcji przedstawia listing 5.8. Listing 5.8. Przykład polimorfizmu funkcji 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25:
// Listing 5.8 - demonstruje // polimorfizm funkcji #include int Double(int); long Double(long); float Double(float); double Double(double); using namespace std; int main() { int long float double int long float double
myInt = 6500; myLong = 65000; myFloat = 6.5F; myDouble = 6.5e20; doubledInt; doubledLong; doubledFloat; doubledDouble;
cout << "myInt: " << myInt << "\n"; cout << "myLong: " << myLong << "\n";
26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52: 53: 54: 55: 56: 57: 58: 59: 60: 61: 62: 63: 64:
cout << "myFloat: " << myFloat << "\n"; cout << "myDouble: " << myDouble << "\n"; doubledInt = Double(myInt); doubledLong = Double(myLong); doubledFloat = Double(myFloat); doubledDouble = Double(myDouble); cout cout cout cout
<< << << <<
"doubledInt: " << doubledInt << "\n"; "doubledLong: " << doubledLong << "\n"; "doubledFloat: " << doubledFloat << "\n"; "doubledDouble: " << doubledDouble << "\n";
return 0; } int Double(int original) { cout << "Wewnatrz Double(int)\n"; return 2 * original; } long Double(long original) { cout << "Wewnatrz Double(long)\n"; return 2 * original; } float Double(float original) { cout << "Wewnatrz Double(float)\n"; return 2 * original; } double Double(double original) { cout << "Wewnatrz Double(double)\n"; return 2 * original; }
Wynik myInt: 6500 myLong: 65000 myFloat: 6.5 myDouble: 6.5e+020 Wewnatrz Double(int) Wewnatrz Double(long) Wewnatrz Double(float) Wewnatrz Double(double) doubledInt: 13000 doubledLong: 130000 doubledFloat: 13 doubledDouble: 1.3e+021
Analiza Funkcja MyDouble() jest przeciążona dla parametrów typu int, long, float oraz double. Ich prototypy znajdują się w liniach od 5. do 8., zaś definicje w liniach od 42. do 64.
Zwróć uwagę, że w tym przykładzie, w linii 10., użyłem instrukcji using namespace std; poza jakąkolwiek funkcją. Sprawia to, że ta instrukcja stała się dla tego pliku globalną i że przestrzeń nazw std jest używana we wszystkich zdefiniowanych w tym pliku funkcjach. W ciele głównego programu jest deklarowanych osiem zmiennych lokalnych. W liniach od 14. do 17. są inicjalizowane cztery z tych zmiennych, zaś w liniach od 29. do 32. pozostałym czterem zmiennym są przypisywane wyniki przekazania każdej z pierwszych czterech zmiennych do funkcji MyDouble(). Zauważ, że w chwili wywoływania tej funkcji, funkcja wywołująca nie rozróżnia, która wersja ma zostać wywołana; po prostu przekazuje argument, i to już zapewnia wywołanie właściwej wersji.a kompilator zajmuje się resztą. Kompilator sprawdza argumenty i na tej podstawię wybiera właściwą wersjęz funkcji MyDouble(). Z wypisywanych komunikatów wynika, że wywoływane są kolejno poszczególne wersje funkcji (tak jak mogliśmy się spodziewać).
Zagadnienia związane z funkcjami Ponieważ funkcje są istotną częścią programowania, omówimy teraz kilka zagadnień, które mogą się okazać przydatne przy rozwiązywaniu pewnych problemów. cię zainteresować w momencie natrafienia na rzadko występujące problemy. Właściwe wykorzystanie funkcji typu inline może pomóc w zwiększeniu wydajności programu. Natomiast rekurencyjne wywoływanie rekurencja funkcji jest jednym z tych cudownych elementów zagadnień programowania, które mogą łatwo rozwiązać skomplikowane problemy, trudne do rozwiązania w inny sposób.
Funkcje typu inline Gdy definiujesz funkcję, kompilator zwykle tworzy w pamięci osobny zestaw instrukcji. Gdy wywołujesz funkcję, wykonanie programu przechodzi (wykonuje skok) do tego zestawu instrukcji, zaś gdy funkcja skończy działanie, wykonanie wraca do instrukcji następnej po wywołaniu funkcji. Jeśli wywołujesz funkcję dziesięć razy, program za każdym razem „skacze” do tego samego zestawu instrukcji. Oznacza to, że istnieje tylko jedna kopia funkcji, a nie dziesięć. Z wchodzeniem do funkcji i wychodzeniem z niej wiąże się pewien niewielki narzut. Okazuje się, że pewne funkcje są bardzo małe, zawierają tylko jedną czy dwie linie kodu, więc istnieje możliwość poprawienia efektywności działania programu przez rezygnację z wykonywania skoków w celu wykonania jednej czy dwóch krótkich instrukcji. Gdy programiści mówią o efektywności, zwykle mają na myśli szybkość działania programu; jeśli unikniemy wywoływania funkcji, program będzie działał szybciej. Jeśli funkcja zostanie zadeklarowana ze słowem kluczowym inline, kompilator nie tworzy prawdziwej funkcji tylko kopiuje kod z funkcji typu inline bezpośrednio do kodu funkcji wywołującej (w miejscu wywołania funkcji inline). Nie odbywa się żaden skok; program działa tak, jakbyś zamiast wywołania funkcji wpisał instrukcje tej funkcji ręcznie. Zauważ, że funkcje typu inline mogą oznaczać duże koszty (w sensie czasu procesora). Gdy funkcja jest wywoływana w dziesięciu różnych miejscach programu, jej kod jest kopiowany do każdego z tych dziesięciu miejsc. Niewielkie zwiększenie szybkości może zostać zniwelowane
przez znaczny wzrost objętości pliku wykonywalnego, co w efekcie może doprowadzić do spowolnienia działania programu! Współczesne kompilatory prawie zawsze lepiej radzą sobie z podjęciem takiej decyzji niż programista, dlatego dobrym pomysłem jest rezygnacja z deklarowania funkcji jako inline, chyba że faktycznie składa się ona z jednej czy dwóch linii. Jeśli masz jakiekolwiek wątpliwości, zrezygnuj z użycia słowa kluczowego inline. Funkcja typu inline została przedstawiona na listingu 5.9. Listing 5.9. Przykład funkcji typu inline 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32:
// Listing 5.9 - demonstruje funkcje typu inline #include inline int Double(int); int main() { int target; using std::cout; using std::cin; using std::endl; cout << "Wpisz liczbe: "; cin >> target; cout << "\n"; target = Double(target); cout << "Wynik: " << target << endl; target = Double(target); cout << "Wynik: " << target << endl;
target = Double(target); cout << "Wynik: " << target << endl; return 0; } int Double(int target) { return 2*target; }
Wynik Wpisz liczbe: 20 Wynik: 40 Wynik: 80 Wynik: 160
Analiza W linii 4. funkcja MyDouble() jest deklarowana jako funkcja typu inline, otrzymująca parametr typu int i zwracająca wartość całkowitą. Ta deklaracja jest taka sama, jak w przypadku
innych prototypów, jednak tuż przed typem zwracanej wartości zastosowano słowo kluczowe inline. Program kompiluje się do kodu, który ma postać taką, jakby w każdym miejscu wystąpienia instrukcji target = Double(target);
ręcznie wpisano target = 2 * target;
W czasie działania programu instrukcje są już na miejscu, wkompilowane do pliku .obj. Dzięki temu unika się „skoków” w wykonaniu kodu (kosztem nieco obszerniejszego pliku wykonywalnego). UWAGA Słowo kluczowe inline jest wskazówką dla kompilatora, że dana funkcja może być funkcją kopiowaną do kodu. Kompilator może jednak zignorować tę wskazówkę i stworzyć zwyczajną, wywoływaną funkcję.
Rekurencja Funkcja może wywoływać samą siebie. Nazywa się to rekurencją (lub rekursją). Rekurencja może być bezpośrednia lub pośrednia. Rekurencja bezpośrednia ma miejsce, gdy funkcja wywołuje samą siebie; rekurencja pośrednia następuje wtedy, gdy funkcja wywołuje inną funkcję, która z kolei (być może także pośrednio) wywołuje funkcję pierwotną. Niektóre problemy najłatwiej rozwiązuje się stosując właśnie rekurencję. Zwykle są to czynności, w trakcie których operuje się na danych, a potem w podobny sposób operuje się na wyniku. Oba rodzaje rekurencji, pośrednia i bezpośrednia, występują w dwóch wersjach: takiej, która się kończy i zwraca wynik, oraz takiej, która się nigdy nie kończy i zwraca błąd czasu działania. Programiści uważają, że ta druga jest bardzo zabawna (gdy przytrafia się komuś innemu). Należy pamiętać, że gdy funkcja wywołuje siebie samą, tworzona jest nowa kopia lokalnych zmiennych tej funkcji. Zmienne lokalne w wersji wywoływanej są zupełnie niezależne od zmiennych lokalnych w wersji wywołującej i w żaden sposób nie mogą na siebie wpływać. Zmienne lokalne w funkcji main() również nie są związane ze zmiennymi lokalnymi w wywoływanej przez nią funkcji (ilustrował to listing 5.4). Aby zilustrować zastosowanie rekurencji, wykorzystajmy obliczanie ciągu Fibonacciego:
1, 1, 2, 3, 5, 8, 13, 21, 34...
Każda wartość, począwszy od trzeciej, stanowi sumę dwóch poprzednich elementów. Zadaniem Fibonacciego może być na przykład wyznaczenie dwunastego elementu takiego ciągu. Aby rozwiązać to zadanie, musimy dokładnie sprawdzać ciąg. Pierwsze dwa elementy mają wartość jeden. Każdy kolejny element stanowi sumę dwóch poprzednich elementów. Np., siódmy element jest sumą elementu piątego i szóstego. Przyjmujemy regułę, że n-ty element jest sumą n-2 i n-1 elementu (przy założeniu, że n jest większe od dwóch). Funkcje rekurencyjne wymagają istnienia warunku zatrzymania (tzw. warunku stopu). Musi wydarzyć się coś, co powoduje zatrzymanie rekurencji, gdyż w przeciwnym razie nigdy się ona nie skończy (tzn. zakończy się błędem działania programu). W ciągu Fibonacciego warunkiem stopu jest n < 3 (tzn. gdy n stanie się mniejsze od trzech, możemy przestać pracować nad zadaniem). Algorytm jest to zestaw kroków podejmowanych w celu rozwiązania zadania. Jeden z algorytmów obliczania elementów ciągu Fibonacciego jest następujący:
1.
Poproś użytkownika o podanie numeru elementu ciągu.
2.
Wywołaj funkcję fib(), przekazując jej uzyskany od użytkownika numer elementu.
3.
Funkcja fib() sprawdza argument (n). Jeśli n < 3, zwraca wartość 1; w przeciwnym razie wywołuje (rekurencyjnie) samą siebie, przekazując jako argument wartość n-2. Następnie wywołuje się ponownie, przekazując wartość n-1, po czym zwraca sumę pierwszego i drugiego wywołania.
Gdy wywołasz fib(1), zwróci ona wartość 1. Gdy wywołasz fib(2), także zwróci 1. Jeśli wywołasz fib(3), to zwróci ona sumę z wywołań fib(2) oraz fib(1). Ponieważ fib(2) zwraca 1 a fib(1) też zwraca 1, fib(3) zwróci 2 (sumę 1+1). Jeżeli wywołasz fib(4), to zwróci ona sumę z wywołań fib(3) oraz fib(2). Już wiesz, że fib(3) zwraca 2 (z wywołań fib(2) i fib(1)) oraz, że fib(2) zwraca 1. fib(4) zsumuje te liczby i zwróci 3 (co stanowi czwarty element szeregu). Wiemy więc że fib(3) zwraca wartość 2 (w wyniku wywołania fib(1) i fib(2)) oraz że fib(2) zwraca wartość 1, więc fib(4) zsumuje te wartości i zwróci 3, czyli wartość czwartego elementu ciągu. Gdy wywołasz fib(5), zwróci ona sumę fib(4) oraz fib(3). Sprawdziliśmy, że fib(4) zwraca 3, zaś fib(3) zwraca 2, więc zwróconą sumą będzie 5. Ta metoda nie jest najbardziej efektywnym sposobem rozwiązywania tego problemu (w fib(20) funkcja fib() jest wywoływana 13 529 razy!), ale działa. Bądź ostrożny — jeśli podasz zbyt dużą liczbę, w komputerze zabraknie pamięci potrzebnej do działania programu. Przy każdym wywołaniu funkcji fib() rezerwowany jest fragment pamięci. Gdy funkcja wraca, pamięć jest zwalniana. W przypadku rekurencji pamięć jest wciąż rezerwowana przed zwolnieniem, więc może się bardzo szybko skończyć. Listing 5.10 przedstawia implementację funkcji fib(). OSTRZEŻENIE Gdy uruchomisz listing 5.10, użyj niewielkiej liczby (mniejszej niż 15). Ponieważ program używa rekurencji, może zużyć mnóstwo pamięci.
Listing 5.10. Rekurencyjne obliczanie elementów ciągu Fibonacciego 0:
// Obliczanie ciągu Fibonacciego z uŜyciem rekurencji
Komentarz [PaG1]: Poniżej brak jednego akapitu - str. 118.
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36:
#include int fib (int n); int main() { int n, answer; std::cout << "Podaj numer elementu ciagu: "; std::cin >> n; std::cout << "\n\n"; answer = fib(n); std::cout << "Wartoscia " << n << "-go elementu ciagu "; std::cout << "Fibonacciego jest " << answer << "\n"; return 0; } int fib (int n) { std::cout << "Przetwarzanie fib(" << n << ")... "; if (n < 3 ) { std::cout << "Zwraca 1!\n"; return (1); } else { std::cout << "Wywoluje fib(" << n-2 << ") "; std::cout << "oraz fib(" << n-1 << ").\n"; return( fib(n-2) + fib(n-1)); } }
Wynik Podaj numer elementu ciagu: 6 Przetwarzanie fib(6)... Przetwarzanie fib(4)... Przetwarzanie fib(2)... Przetwarzanie fib(3)... Przetwarzanie fib(1)... Przetwarzanie fib(2)... Przetwarzanie fib(5)... Przetwarzanie fib(3)... Przetwarzanie fib(1)... Przetwarzanie fib(2)... Przetwarzanie fib(4)... Przetwarzanie fib(2)... Przetwarzanie fib(3)... Przetwarzanie fib(1)... Przetwarzanie fib(2)... Wartoscia 6-go elementu
Wywoluje fib(4) oraz fib(5). Wywoluje fib(2) oraz fib(3). Zwraca 1! Wywoluje fib(1) oraz fib(2). Zwraca 1! Zwraca 1! Wywoluje fib(3) oraz fib(4). Wywoluje fib(1) oraz fib(2). Zwraca 1! Zwraca 1! Wywoluje fib(2) oraz fib(3). Zwraca 1! Wywoluje fib(1) oraz fib(2). Zwraca 1! Zwraca 1! ciagu Fibonacciego jest 8
UWAGA Niektóre kompilatory mają problem z użyciem operatorów wewnątrz w instrukcji zawierającej cout. Jeśli w linii 32. pojawi się ostrzeżenie, umieść nawiasy wokół operacji odejmowania tak, by linie 32. i 33. wyglądały następująco: 32: 33:
std::cout << "Wywoluje fib(" << (n-2) << ") "; std::cout << "oraz fib(" << (n-1) << ").\n";
Analiza W linii 9. program prosi o podanie numeru elementu ciągu i przypisuje ten numer zmiennej n. Następnie wywołuje funkcję fib(), przekazując jej tę wartość. Wykonanie przechodzi do funkcji fib(), która wypisuje wartość swojego argumentu w linii 23. W linii 25. argument n jest sprawdzany w celu upewnienia się czy jest mniejszy od 3; jeśli tak, funkcja fib() zwraca wartość 1. W przeciwnym razie zwraca sumę wartości otrzymanych w wyniku wywołania funkcji fib() z argumentami n-2 oraz n-1. Funkcja nie może zwrócić tej sumy do momentu powrotu z obu wywołań fib(). Możemy sobie wyobrazić przedstawić jak program ciągle wykonuje skoki do fibzagłębia się coraz bardziej, do chwili, w której natrafia na wywołanie, w którym funkcja fib() zwraca wartość. Jedyne wywołania zwracające wartość bezpośrednio to wywołania fib(2) oraz fib(1). Te wartości są następnie przekazywane w górę, do oczekujących na nie funkcji, które z kolei przekazują sumę do swoich funkcji wywołujących. Tę rekurencję dla funkcji fib() przedstawiają rysunki 5.4 oraz 5.5.
Rysunek 5.4. Użycie rekurencji
Rysunek 5.5. Powrót z rekurencji
W tym przykładzie n ma wartość 6, dlatego w funkcji main() jest wywoływana funkcja fib(6). Wykonanie przechodzi do funkcji fib(), w której (w linii 25.) następuje sprawdzenie czy n jest mniejsze od 3. Wartość n jest większa od 3, więc funkcja fib(6) zwraca sumę wartości zwracanych przez funkcje fib(4) oraz fib(5). 34:
return( fib(n-2) + fib(n-1));
Oznacza to, że odbywa się wywołanie fib(4) (ponieważ n == 6, więc fib(n-2) to w istocie fib(4)) oraz wywołanie fib(5) (czyli fib(n-1)), po czym funkcja, w której się znajdujemy (w tym przypadku fib(6)) czeka, aż te wywołania zwrócą wartości. Gdy wartości te zostaną zwrócone, funkcja ta zwraca rezultat sumowania tych wartości. Ponieważ fib(5) otrzymuje argument większy od 3, funkcja fib() zostaje wywołana ponownie, tym razem z argumentami 3 i 4. Funkcja fib(4) wywołuje z kolei funkcje fib(2) oraz fib(3). Wypisywane komunikaty pokazują te wywołania oraz zwracane wartości. Skompiluj, zbuduj i uruchom ten program, podając wartość 1, następnie 2, potem 3 i tak aż do 6. Uruchamiając program uważnie śledź komunikaty. To doskonała okazja, aby rozpocząć samodzielne eksperymenty z debuggerem. Umieść punkt przerwania w linii 21., po czym obserwuj wchodź wewnątrz (into) każde go wywołaniea funkcji fib(), śledząc wartość n w każdym rekurencyjnym wywołaniu tej funkcji.
W programach C++ rekurencja nie jest używana zbyt często, ale może stanowić wydajne i eleganckie narzędzie rozwiązywania pewnych problemów. UWAGA Rekurencja jest elementem programowania zaawansowanego. Została tu zaprezentowana, ponieważ zrozumienie podstaw jej działania może okazać się przydatne, jednak nie przejmuj się zbytnio, jeśli nie zrozumiałeś w pełni wszystkich jej szczegółów.
Jak działają funkcje — rzut oka „pod maskę” Gdy wywołujesz daną funkcję, program przechodzi do tej funkcji, przekazywane są parametry i następuje wykonanie ciała funkcji. Gdy funkcja zakończy działanie, zwracana jest wartość (chyba, że zwracana jest wartość typu void) i sterowanie powraca do funkcji wywołującej. Jak to się odbywa? Skąd kod wie, gdzie skoczyć? Gdzie są przechowywane przekazywane zmienne? Co się dzieje ze zmiennymi zadeklarowanymi w ciele funkcji? W jaki sposób jest przekazywana wartość zwracana przez funkcję? Skąd kod wie, w którym miejscu ma wznowić działanie po powrocie z funkcji? Większość książek wprowadzających w zagadnienia programowania nie próbuje odpowiadać na te pytania, ale bez zrozumienia tych mechanizmów pisanie programów wciąż pozostaje „programowaniem z elementami magii.” Wyjaśnienie zasad działania funkcji wymaga poruszenia tematu pamięci komputera.
Poziomy abstrakcji Jednym z największych wyzwań dla początkujących programistów jest konieczność posługiwania się wieloma poziomami abstrakcji. Oczywiście, komputery są jedynie urządzeniami elektronicznymi. Nie mają pojęcia o oknach czy menu, nie znają programów ani instrukcji, a nawet nie wiedzą nic o zerach i jedynkach. W rzeczywistości jedyne zmiany, jakie zauważają, to zmiany napięcia mierzonego w odpowiednich punktach układów elektronicznych. Nawet to jest dla nich pewną abstrakcją: w rzeczywistości elektryczność jest tylko wygodną intelektualną koncepcją dla zaprezentowania działania cząstek subatomowych, które z kolei są abstrakcją dla czegoś innego (!). Bardzo niewielu programistów zadaje sobie trud zejścia poniżej poziomu wartości w pamięci RAM. W końcu nie trzeba znać fizyki cząsteczkowej, aby prowadzić samochód, robić kanapki czy kopać piłkę; nie trzeba też znać się na elektronice, aby programować komputer. Konieczne jest jednak zrozumienie, w jaki sposób jest zorganizowana pamięć komputera. Bez wyraźnego obrazu tego, gdzie znajdują się tworzone zmienne i w jaki sposób przekazywane są wartości między funkcjami, programowanie nadal pozostanie tajemnicą.
Dzielenie Podział pamięci Gdy uruchamiasz program, system operacyjny (taki jak DOS, Unix czy Microsoft Windows) przygotowuje różne obszary pamięci (w zależności od wymagań kompilatora). Jako programista
C++, często będziesz miał do czynienia z globalną przestrzenią nazw, stertą, rejestrami, przestrzenią kodu oraz stosem. Zmienne globalne występują w globalnej przestrzeni nazw. O globalnej przestrzeni nazw i stercie pomówimy dokładniej w następnych rozdziałach, teraz skupimy się na rejestrach, przestrzeni kodu oraz stosie. Rejestry są specjalnym obszarem pamięci wbudowanym w procesor (CPU, Central Processing Unit). Odpowiadają za wewnętrzne wykonywanie programu przez procesor. Większość tego, co dzieje się w rejestrach, wykracza poza tematykę tej książki; interesuje nas tylko zestaw rejestrów, który w danej chwili wskazuje następną instrukcję kodu. Zestaw rejestrów nosi wspólną nazwę wskaźnika instrukcji (ang. instruction pointer). Zadaniem wskaźnika instrukcji jest śledzenie, która linia kodu ma zostać wykonana jako następna. Kod występuje w przestrzeni kodu, która jest częścią pamięci przygotowaną tak, by zawierała binarną postać instrukcji stanowiących stworzonych jako program. Każda linia kodu źródłowego została przetłumaczona na serię instrukcji procesora, z których każda znajduje się w pamięci pod określony adresem. Wskaźnik instrukcji zawiera adres następnej instrukcji przeznaczonej przeznaczonejdo wykonania. Ilustruje to rysunek 5.6.
Rys. 5.6. Wskaźnik instrukcji
Stos jest specjalnym obszarem pamięci, zaalokowanym przez program w celu przechowywania danych potrzebnych wszystkim funkcjom programu. Jest nazywany stosem, gdyż stanowi kolejkę LIFO (last-in, first-out — ostatni wchodzi, pierwszy wychodzi), przypominającą stos talerzy w restauracji (pokazany na rysunku 5.7).
Rys. 5.7. Stos
„Ostatni wchodzi, pierwszy wychodzi” – oznacza, że to, co zostanie umieszczone na stosie jako ostatnie, zostanie z niego zdjęte jako pierwsze. Większość kolejek przypomina kolejki w sklepie: pierwsza osoba w kolejce jest obsługiwana jako pierwsza. Stos przypomina stos monet: gdy ułożysz na stole dziesięć monet, jedna na drugiej, a następnie część z nich zabierasz, zabierasz najpierw te monety, które ułożyłeś jako ostatnie. Gdy dane są umieszczane (ang. push) na stosie, stos rośnie; gdy są zdejmowane ze stosu (ang. pop), stos maleje. Nie ma możliwości wyjęcia talerza ze stosu bez zdjęcia wszystkich talerzy, które zostały umieszczone na nim później. Stos talerzy jest najczęściej przedstawianą analogią. Jest ona poprawna, ale działanie pamięci wygląda nieco inaczej. Bardziej odpowiednie jest wyobrażenie sobie szeregu pojemników ułożonych jeden na drugim. Szczytem stosu jest ten pojemnik, na który w danej chwili wskazuje wskaźnik stosu (ang. stack pointer), będący jeszcze jednym rejestrem. Każdy z pojemników ma kolejny adres, a jeden z tych adresów jest przechowywany w rejestrze wskaźnika stosu. Wszystko, co znajduje się poniżej tego magicznego adresu, znanego jako szczyt stosu, jest uważane za zawartość stosu. Wszystko, co znajduje się powyżej szczytu stosu, jest uważane za znajdujące się poza stosem, a co za tym idzie, niepoprawne. Ilustruje to rysunek 5.8.
Rys. 5.8. Wskaźnik stosu
Gdy odkładasz daną na stos, jest ona umieszczana w pojemniku znajdującym się powyżej wskaźnika stosu, a następnie wskaźnik stosu jest przesuwany o jeden pojemnik w górę. Gdy zdejmujesz daną ze stosu, jedyną czynnością odbywającą się w rzeczywistości jest przesunięcie wskaźnika stosu o jeden pojemnik w dół. Pokazuje to rysunek 5.9.
Rys. 5.9. Przesunięcie wskaźnika stosu
Dane powyżej wskaźnika stosu (czyli poza stosem) mogą (ale nie muszą) ulec zmianie w dowolnej chwili. Wartości te nazywamy „odpadami” (aby lepiej uświadomić sobie, że nie powinniśmy na nie liczyć).
Stos i funkcje Poniżej przedstawiono przybliżony opis tego, co się dzieje, gdy program przechodzi do wykonania funkcji. (Poszczególne rozwiązania różnią się, w zależności od systemu operacyjnego i kompilatora). 1.
Zwiększany jest adres we wskaźniku instrukcji i wskazuje on instrukcję następną po tej, która wywołujeaniu funkcjęi. Ten adres jest następnie umieszczany na stosie; stanowi adres powrotu z funkcji.
2.
Na stosie jest tworzone miejsce dla zadeklarowanego typu wartości zwracanej przez funkcję. Gdy zwracany typ jest zadeklarowany jako int, w przypadku systemu z dwubajtowymi liczbami całkowitymi, na stos są odkładane dwa kolejne bajty, ale nie jest w nich umieszczana żadna wartość („odpady”, które się w nich dotąd znajdowały, pozostają tam nadal).
3.
Do wskaźnika instrukcji jest ładowany adres wywoływanej funkcji (ten adres jest zawarty w kodzie aktualnie wykonywanej instrukcji wywołania funkcji), dzięki czemu następna wykonywana instrukcja będzie już instrukcją funkcji.
4.
Odczytywany jest adres bieżącego szczytu stosu, następnie zostaje on umieszczony w specjalnym wskaźniku nazywanym ramką stosu (ang. stack frame). Wszystko, co zostanie umieszczone na stosie od tego momentu, jest uważane za „lokalne” dla funkcji.
5.
Na stosie umieszczane są argumenty funkcji.
6.
Wykonywana jest instrukcja wskazywana przez wskaźnik instrukcji (następuje wykonanie pierwszej instrukcji w funkcji).
7.
W trakcie ich definiowania, lokalne zmienne zostają umieszczane na stosie.
Gdy funkcja jest gotowa do powrotu, zwracana wartość jest umieszczana w miejscu stosu zarezerwowanym w kroku 2. Następnie stos jest zwijany (tzn. wskaźnik stosu przesuwa się) aż do wskaźnika ramki stosu, co oznacza odrzucenie wszystkich lokalnych zmiennych i argumentów funkcji. Zwracana wartość jest zdejmowana ze stosu i przypisywana jako wartość instrukcji wywołania funkcji. Następnie ze stosu zdejmowana jest wartość odłożona w kroku 1.; wartość ta zostaje umieszczona we wskaźniku instrukcji. Program, posiadając wartość zwróconą przez funkcję, wznawia działanie od instrukcji następującej bezpośrednio po instrukcji wywołania funkcji. Niektóre ze szczegółów tego procesu zmieniają się w zależności od kompilatora i komputera, ale podstawowy jego przebieg jest niezmienny. Gdy wywołujesz funkcję, na stosie odkładany jest adres powrotu i argumenty. W trakcie działania tych funkcji, na stos są odkładane zmienne lokalne. Gdy funkcja wraca, ze stosu zostaje usunięte wszystko. W następnych rozdziałach poznamy inne miejsca pamięci, używane do przechowywania danych, które muszą istnieć dłużej niż czas życia funkcji.
Rozdział 6. Programowanie zorientowane obiektowo Klasy rozszerzają wbudowane w C++ możliwości, ułatwiające rozwiązywanie złożonych, „rzeczywistych” problemów. Z tego rozdziału dowiesz się: •
czym są klasy i obiekty,
•
jak definiować nową klasę oraz tworzyć obiekty tej klasy,
•
czym są funkcje i dane składowe,
•
czym są konstruktory i jak z nich korzystać.
Czy C++ jest zorientowane obiektowo? Język C++ stanowi pomost pomiędzy programowaniem zorientowanym obiektowo a językiem C, najpopularniejszym językiem programowania aplikacji komercyjnych. Celem jego autorów było stworzenie obiektowo zorientowanego języka dla tej szybkiej i efektywnej platformy. Język C jest etapem pośrednim pomiędzy wysokopoziomowymi językami aplikacji „firmowych”, takimi jak COBOL, a niskopoziomowym, wysokowydajnym, lecz trudnym do użycia asemblerem. C wymusza programowanie „strukturalne”, w którym poszczególne zagadnienia są dzielone na mniejsze jednostki powtarzalnych działań, zwanych funkcjami. Programyu, które piszemy na początku dwudziestego pierwszego wieku, są dużo bardziej złożone niż te, które były pisane pod koniec wieku dwudziestego. Programy stworzone w językach proceduralnych są trudne w zarządzaniu i konserwacji, a ich rozbudowa jest niemożliwa. Graficzne interfejsy użytkownika, Internet, telefonia cyfrowa i bezprzewodowa oraz wiele innych technologii, znacznie zwiększyły poziom skomplikowania nowych projektów, a wymagania konsumentów dotyczące jakości interfejsu użytkownika wzrosły.
W obliczu rosnących wymagań, programiści bacznie przyjrzeli się przemysłowi informatycznemu. Wnioski, do jakich doszli, były co najmniej przygnębiające. Oprogramowanie powstawało z opóźnieniem, posiadało błędy, działało niestabilnie i było drogie. Projekty regularnie przekraczały budżet i trafiały na rynek z opóźnieniem. Koszt obsługi tych projektów był znaczny, zmarnowano ogromne ilości pieniędzy. Jedynym wyjściem z tej sytuacji okazało się tworzenie oprogramowania zorientowanego obiektowo. Języki programowania obiektowego stworzyły silne więzy pomiędzy strukturami danych a metodami manipulowania tymi danymi. A co najważniejsze, w programowaniu zorientowanym obiektowo nie już musisz myśleć o strukturach danych i manipulujących nimi funkcjami; myślisz o obiektach. Rzeczach. Świat jest wypełniony przedmiotami: samochodami, psami, drzewami, chmurami, kwiatami. Rzeczy. Każda rzecz ma charakterystykę (szybki, przyjazny, brązowy, puszysty, ładny). Większość rzeczy cechuje jakieś zachowanie (ruch, szczekanie, wzrost, deszcz, uwiąd). Nie myślimy o danych psa i o tym, jak moglibyśmy nimi manipulować — myślimy o psie jako o rzeczy: do czego jest podobny i co robi.
Tworzenie nowych typów Poznałeś już kilka typów zmiennych, m.in. liczby całkowite i znaki. Typ zmiennej dostarcza nam kilka informacji o niej. Na przykład, jeśli zadeklarujesz zmienne Height (wysokość) i Width (szerokość) jako liczby całkowite typu unsigned short int, wiesz, że w każdej z nich możesz przechować wartość z przedziału od 0 do 65 5356 (przy założeniu że typ unsigned short int zajmuje dwa bajty pamięci). Są to liczby całkowite bez znaku; próba przechowania w nich czegokolwiek innego powoduje błąd. W zmiennej typu unsigned short nie możesz umieścić swojego imienia, nie powinieneś nawet próbować. Deklarując te zmienne jako unsigned short int, wiesz, że możesz dodać do siebie wysokość i szerokość oraz przypisać tę wartość innej zmiennej. Typ zmiennych informuje: •
o ich rozmiarze w pamięci,
•
jaki rodzaj informacji mogą zawierać,
•
jakie działania można na nich wykonywać.
W tradycyjnych językach, takich jak C, typy były wbudowane w język. W C++ programista może rozszerzyć język, tworząc potrzebne mu typy, zaś każdy z tych nowych typów może być w pełni funkcjonalny i dysponować tą samą siłą, co typy wbudowane.
Po co tworzyć nowy typ? Programy są zwykle pisane w celu rozwiązania jakiegoś realnego problemu, takiego jak prowadzenie rejestru pracowników czy symulacja działania systemu grzewczego. Choć istnieje możliwość rozwiązywania tych problemów za pomocą programów napisanych wyłącznie przy użyciu liczb całkowitych i znaków, jednak w przypadku większych, bardziej rozbudowanych
problemów, dużo łatwiej jest stworzyć reprezentacje obiektów, o których się mówi. Innymi słowy, symulowanie działania systemu grzewczego będzie łatwiejsze, gdy stworzymy zmienne reprezentujące pomieszczenia, czujniki ciepła, termostaty i bojlery. Im bardziej te zmienne odpowiadają rzeczywistości, tym łatwiejsze jest napisanie programu.
Klasy i składowe Nowy typ zmiennych tworzy się, deklarując klasę. Klasa jest właściwie grupą zmiennych — często o różnych typach — połączonych skojarzonych z zestawem powiązanych odnoszących się do nich funkcji. Jedną z możliwości myślenia o samochodzie jest potraktowanie go jako zbioru kół, drzwi, foteli, okien, itd. Inna możliwość to wyobrażenie sobie, co samochód może zrobić: jeździć, przyspieszać, zwalniać, zatrzymywać się, parkować, itd. Klasa umożliwia kapsułkowanie, czyli upakowanie, tych różnych części oraz różnych działań w jeden zbiór, który jest nazywana obiektem. Upakowanie wszystkiego, co wiesz o samochodzie, w jedną klasę przynosi programiście liczne korzyści. Wszystko jest na miejscu, ułatwia to odwoływanie się, kopiowanie i manipulowanie danymi. Klienty twojej klasy — tj. te części programu, które z niej korzystają — mogą używać twojego obiektu bez zastanawiania się, co znajduje się w środku i jak on działa. Klasa może składać się z dowolnej kombinacji zmiennych prostych oraz zmiennych innych klas. Zmienna wewnątrz klasy jest nazywana zmienną składową lub daną składową. Klasa Car (samochód) może posiadać składowe reprezentujące siedzenia, typ radia, opony, itd. Zmienne składowe są zmiennymi w danej klasie. Stanowią one część klasy, tak jak koła i silnik stanowią część samochodu. Funkcje w danej klasie zwykle manipulują zmiennymi składowymi. Funkcje klasy nazywa się funkcjami składowymi lub metodami klasy. Metodami klasy Car mogą być Start() (uruchom) oraz Brake() (hamuj). Klasa Cat (kot) może posiadać zmienne składowe, reprezentujące wiek i wagę; jej metodami mogą być Sleep() (śpij), Meow() (miaucz) czy ChaseMice() (łap myszy). Funkcje składowe (metody) są funkcjami w klasie. Podobnie jak zmienne składowe, stanowią część klasy i określają, co dana klasa może zrobić.
Deklarowanie klasy Aby zadeklarować klasę, użyj słowa kluczowego class, po którym następuje otwierający nawias klamrowy, a następnie lista danych składowych i metod tej klasy. Deklaracja kończy się zamykającym nawiasem klamrowym i średnikiem. Oto deklaracja klasy o nazwie Cat (kot): class Cat { unsigned int unsigned int void Meow(); };
itsAge; itsWeight;
Zadeklarowanie takiej klasy nie powoduje zaalokowania pamięci dla obiektu Cat. Informuje jedynie kompilator, czym jest typ Cat, jakie dane zawiera (itsAge — jego wiek oraz itsWeight — jego waga) oraz co może robić (Meow() — miaucz). Informuje także kompilator, jak duża jest zmienna typu Cat — to jest, jak dużo miejsca w pamięci ma przygotować w przypadku tworzenia zmiennej typu Cat. W tym przykładzie, o ile typ int ma cztery bajty, zmienna typu Cat zajmuje osiem bajtów: cztery bajty dla zmiennej itsAge i cztery dla zmiennej itsWeight. Funkcja Meow() nie zajmuje miejsca, gdyż dla funkcjia składowych (metod) miejsce nie jest rezerwowane.
Kilka słów o konwencji nazw Jako programista, musisz nazwać wszystkie swoje zmienne składowe, funkcje składowe oraz klasy. Jak przeczytałeś w rozdziale 3., „Stałe i zmienne,” nazwy te powinny być zrozumiałe i znaczące. Dobrymi nazwami klas mogą być wspomniana Cat, Rectangle (prostokąt) czy Employee (pracownik). Meow(), ChaseMice() czy StopEngine() (zatrzymaj silnik) również są dobrymi nazwami funkcji, gdyż informują, co robią te funkcje. Wielu programistów nadaje nazwom zmiennych składowych przedrostek „its” (jego), tak jak w zmiennych itsAge, itsWeight czy itsSpeed (jego szybkość). Pomaga to w odróżnieniu zmiennych składowych od innych zmiennych. Niektórzy programiści wolą przedrostek „my” (mój), tak jak w nazwach myAge, myWeight czy mySpeed. Jeszcze inni używają po prostu litery m (od słowa member — składowa), czasem wraz ze znakiem podkreślenia (_): mAge i m_age, mWeight i m_weight czy mSpeed i m_speed. Język C++ uwzględnia wielkość liter, dlatego wszystkie nazwy klas powinny przestrzegać tej samej konwencji. Dzięki temu nigdy nie będziesz musiał sprawdzać pisowni nazwy klasy (czy to było Rectangle, rectangle czy RECTANGLE?). Niektórzy programiści lubią poprzedzić każdą nazwę klasy określoną literą — na przykład cCat czy cPerson — podczas, gdy inni używają wyłącznie dużych lub małych liter. Ja sam korzystam z konwencji, w której wszystkie nazwy klas rozpoczynają się od dużej litery, tak jak Cat czy Person (osoba). Wielu programistów rozpoczyna wszystkie nazwy funkcji od dużej litery, zaś wszystkie nazwy zmiennych — od małej. Słowa zwykle rozdzielane są znakiem podkreślenia — tak jak w Chase_Mice — lub poprzez zastosowanie dużej litery dla każdego słowa — na przykład ChaseMice czy DrawCircle (rysuj okrąg). Ważne jest, by wybrać określony styl i trzymać się go w każdym programie. Z czasem rozwiniesz swój styl nie tylko na konwencje nazw, ale także na wcięcia, wyrównanie nawiasów klamrowych oraz styl komentarzy. UWAGA W firmach programistycznych powszechne jest określenie standardu wielu elementów stylu zapisu kodu źródłowego. Sprawia on, że wszyscy programiści mogą łatwo odczytywać wzajemnie swój kod.
Definiowanie obiektu Definiowanie obiektu nowego typu przypomina definiowanie zmiennej całkowitej: unsigned int GrossWeight; // definicja zmiennej typu unsigned int Cat Mruczek; // definicja zmiennej typu Cat
Ten kod definiuje zmienną o nazwie GrossWeight (łączna waga), której typem jest unsigned int. Oprócz tego definiuje zmienną o nazwie Mruczek, która jest obiektem klasy (typu) Cat.
Klasy a obiekty Nigdy nie karmi się definicji kota, lecz konkretnego kota. Należy dokonać rozróżnienia pomiędzy ideą kota a konkretnym kotem, który właśnie ociera się o twoje nogi. C++ również dokonuje rozróżnienia pomiędzy klasą Cat, będącą ideą kota, a poszczególnymi obiektami typu Cat. Tak więc Mruczek jest obiektem typu Cat, tak jak GrossWeight jest zmienną typu unsigned int. Obiekt jest indywidualnym egzemplarzem klasy.
Dostęp do składowych klasy Gdy zdefiniujesz już faktyczny obiekt Cat — na przykład Mruczek — w celu uzyskania dostępu do jego składowych możesz użyć operatora kropki (.). Aby zmiennej składowej itsWeight obiektu Mruczek przypisać wartość 50, powinieneś napisać: Mruczek.itsWeight = 50;
Aby wywołać funkcję Meow(), możesz napisać: Mruczek.Meow();
Gdy używasz metody klasy, oznacza to, że wywołujesz tę metodę. W tym przykładzie wywołałeś metodę Meow() obiektu Mruczek.
Przypisywać należy obiektom, nie klasom W C++ nie przypisuje się wartości typom; przypisuje się je zmiennym. Na przykład, nie można napisać: int = 5;
// źle
Kompilator uzna to za błąd, gdyż nie można przypisać wartości pięć typowi całkowitemu. Zamiast tego musisz zdefiniować zmienną typu całkowitego i przypisać jej wartość 5. Na przykład: int x ; x = 5;
// definicja zmiennej typu int // ustawienie wartości zmiennej x na 5
Jest to skrócony zapis stwierdzenia: „Przypisz wartość 5 zmiennej x, która jest zmienną typu int.” Nie można również napisać: Cat.itsAge = 5;
// źle
Kompilator uzna to za błąd, gdyż nie możesz przypisać wartości 5 do elementu itsAge klasy Cat. Zamiast tego musisz zdefiniować egzemplarz obiektu klasy Cat i dopiero wtedy przypisać wartość jego składowej. Na przykład: Cat Mruczek; Mruczek.itsAge = 5;
// podobnie jak // podobnie jak
int x; x = 5;
Czego nie zadeklarujesz, tego klasa nie będzie miała Przeprowadź taki eksperyment: podejdź do trzylatka i pokaż mu kota. Następnie powiedz: To jest Mruczek. Mruczek zna sztuczkę. Mruczek, zaszczekaj! Dziecko roześmieje się i powie: „Nie, głuptasie, koty nie szczekają!” Jeśli napisałeś: Cat Mruczek; Mruczek.Bark();
// tworzy obiekt Cat o nazwie Mruczek // nakazuje Mruczkowi szczekać
Kompilator wypisze: „Nie, głuptasie, koty (cats) nie szczekają!” (Być może w twoim kompilatorze ten komunikat będzie brzmiał nieco inaczej.) Kompilator wie, że Mruczek nie może szczekać, gdyż klasa Cat nie posiada metody Bark() (szczekaj). Kompilator nie pozwoli Mruczkowi nawet zamiauczeć, jeśli nie zdefiniujesz dla niego funkcji Meow() (miaucz).
TAK
NIE
Do deklarowania klasy używaj słowa kluczowego class.
Nie myl deklaracji z definicją. Deklaracja mówi czym jest klasa, a definicja przygotowuje pamięć dla obiektu.
W celu uzyskania dostępu do zmiennych i funkcji składowych klasy używaj operatora kropki (.).
Nie myl klasy z obiektem. Nie przypisuj klasie wartości. Wartości przypisuj danym składowym obiektu.
Prywatne i publiczne W deklaracji klasy używanych jest także kilka innych słów kluczowych. Dwa najważniejsze z nich to: public (publiczny) i private (prywatny). Wszystkie składowe klasy — dane i metody — są domyślnie prywatne. Prywatne składowe mogą być używane tylko przez metody należące do danej klasy. Składowe publiczne są dostępne dla innych funkcji i klas. To rozróżnienie jest ważne, choć na początku może sprawiać kłopot. Aby to lepiej wyjaśnić, spójrzmy na poprzedni przykład: class Cat { unsigned int unsigned int viod Meow(); };
itsAge; itsWeight;
W tej deklaracji, składowe itsAge, itsWeight oraz Meow() są prywatne, gdyż wszystkie składowe klasy są prywatne domyślnie. Oznacza to, że dopóki nie postanowisz inaczej, pozostaną one prywatne. Jeśli jednak w funkcji main() napiszesz na przykład: Cat Bobas; Bobas.itsAge = 5;
// błąd! nie moŜna uŜywać prywatnych danych!
kompilator uzna to za błąd. We wcześniejszej deklaracji powiedziałeś kompilatorowi, że składowych itsAge, itsWeight oraz Meow() będziesz używał tylko w funkcjach składowych klasy Cat. W powyższym fragmencie kodu próbujesz odwołać się do zmiennej składowej obiektu Bobas spoza metody klasy Cat. To, że Bobas jest obiektem klasy Cat, nie oznacza, że możesz korzystać z tych elementów obiektu Bobas, które są prywatne. Właśnie to jest źródłem niekończących się kłopotów początkujących programistów C++. Jużę słyszę, jak narzekasz: „Hej! Właśnie napisałem, że Bobas jest kotem, tj. obiektem klasy Cat. Dlaczego Bobas nie ma dostępu do swojego własnego wieku?” Odpowiedź brzmi: Bobas ma dostęp, ale ty nie masz. Bobas, w swoich własnych metodach, ma dostęp do wszystkich swoich składowych, zarówno publicznych, jak i prywatnych. Nawet, jeśli to ty tworzysz obiekt klasy Cat, nie możesz przeglądać ani zmieniać tych jego składowych, które są prywatne.
Aby mieć dostęp do składowych obiektu Cat, powinieneś napisać: class Cat { public: unsigned int unsigned int void Meow(); };
itsAge; itsWeight;
Teraz składowe itsAge, itsWeight oraz Meow() są publiczne. Bobas.itsAge = 5; kompiluje się bez problemów. Listing 6.1 przedstawia deklarację klasy Cat z publicznymi zmiennymi składowymi. Listing 6.1. Dostęp do publicznych składowych w prostej klasie 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20:
// Demonstruje deklaracje klasy oraz // definicje obiektu tej klasy. #include class Cat { public: int itsAge; int itsWeight; };
// deklaruje klasę Cat (kot) // // // //
następujące po tym składowe są publiczne zmienna składowa zmienna składowa zwróć uwagę na średnik
int main() { Cat Mruczek; Mruczek.itsAge = 5; // przypisanie do zmiennej składowej std::cout << "Mruczek jest kotem i ma " ; std::cout << Mruczek.itsAge << " lat.\n"; return 0; }
Wynik Mruczek jest kotem i ma 5 lat.
Analiza Linia 5. zawiera słowo kluczowe class. Informuje ono kompilator, że następuje po nim deklaracja klasy. Nazwa nowej klasy następuje bezpośrednio po słowie kluczowym class. W tym przypadku nazwą klasy jest Cat (kot). Ciało deklaracji rozpoczyna się w linii 6. od otwierającego nawiasu klamrowego i kończy się zamykającym nawiasem klamrowym i średnikiem w linii 10. Linia 7. zawiera słowo kluczowe public, które wskazuje, że wszystko, co po nim nastąpi, będzie publiczne, aż do natrafienia na słowo kluczowe private lub koniec deklaracji klasy. Linie 8. i 9. zawierają deklaracje składowych klasy, itsAge (jego wiek) oraz itsWeight (jego waga).
W linii 13. rozpoczyna się funkcja main(). W linii 15. Mruczek jest definiowany jako egzemplarz klasy Cat — tj. jako obiekt klasy Cat. W linii 16. wiek Mruczka jest ustawiany na 5. W liniach 17. i 18. zmienna składowa itsAge zostaje użyta do wypisania informacji o kocie Mruczku. UWAGA Spróbuj wykomentować linię 7., po czym skompiluj program ponownie. W linii 16. wystąpi błąd, gdyż zmienna składowa itsAge nie będzie już składową publiczną. Domyślnie, wszystkie składowe klasy są prywatne.
Oznaczanie danych składowych jako prywatnych Powinieneś przyjąć jako ogólną regułę, że dane składowe klasy należy utrzymywać jako prywatne. W związku z tym musisz stworzyć publiczne funkcje składowe, zwane funkcjami dostępowymi lub akcesorami. Funkcje te umożliwią odczyt zmiennych składowych i przypisywanie im wartości. Te funkcje dostępowe (Aakcesory) są funkcjami składowymi, używanymi przez inne części programu w celu odczytywania i ustawiania prywatnych zmiennych składowych. Publiczny akcesor jest funkcją składową zmienną klasy, używaną albo do odczytu wartości prywatnej zmiennej składowej klasy, albo do ustawiania wartości tej zmiennej. Dlaczego miałbyś utrudniać sobie życie dodatkowym poziomem pośredniego dostępu? Łatwiej niż posługiwać się akcesorami jest używać danych,. Akcesory umożliwiają oddzielenie szczegółów przechowywania danych klasy od szczegółów jej używania. Dzięki temu możesz zmieniać sposób przechowywania danych klasy bez konieczności przepisywania funkcji, które z tych danych korzystają. Jeśli funkcja, która chce poznać wiek kota, odwoła się bezpośrednio do zmiennej itsAge klasy Cat, będzie musiała zostać przepisana, jeżeli ty, jako autor klasy Cat, zdecydujesz się na zmianę sposobu przechowywania tej zmiennej. Jednak posiadając funkcję składową GetAge() (pobierz wiek), klasa Cat może łatwo zwrócić właściwą wartość bez względu na to, w jaki sposób przechowywany będzie wiek. Funkcja wywołująca nie musi wiedzieć, czy jest on przechowywany jako zmienna typu unsigned int czy long, lub czy wiek jest obliczany w miarę potrzeb. Ta technika ułatwia zapanowanie nad programem. Przedłuża istnienie kodu, gdyż zmiany projektowe nie powodują, że program staje się przestarzały. Listing 6.2 przedstawia klasę Cat zmodyfikowaną tak, by zawierała prywatne dane składowe i publiczne akcesory. Zwróć uwagę, że ten listing przedstawia wyłącznie deklarację klasy, nie ma w nim kodu wykonywalnego. Listing 6.2. Klasa z akcesorami 0: 1: 2: 3: 4: 5: 6: 7: 8: 9:
// Deklaracja klasy Cat // Dane składowe są prywatne, publiczne akcesory pośredniczą // w ustawianiu i odczytywaniu wartości składowych prywatnych class Cat { public: // publiczne akcesory unsigned int GetAge(); void SetAge(unsigned int Age);
10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22:
unsigned int GetWeight(); void SetWeight(unsigned int Weight); // publiczna funkcja składowa void Meow(); // prywatne dane składowe private: unsigned int itsAge; unsigned int itsWeight; };
Analiza Ta klasa posiada pięć metod publicznych. Linie 8. i 9. zawierają akcesory dla składowej itsAge. Linie 11. i 12. zawierają akcesory dla składowej itsWeight. Te akcesory ustawiają zmienne składowe i zwracają ich wartości. W linii 15. jest zadeklarowana publiczna funkcja składowa Meow(). Ta funkcja nie jest akcesorem. Nie zwraca wartości ani ich nie ustawia; wykonuje inną usługę dla klasy – wypisuje słowo Miau. Zmienne składowe są zadeklarowane w liniach 19. i 20. Aby ustawić wiek Mruczka, powinieneś przekazać wartość metodzie SetAge() (ustaw wiek), na przykład: Cat Mruczek; Mruczek.SetAge(5); // ustawia wiek Mruczka // uŜywając publicznego akcesora
Prywatność a ochrona Zadeklarowanie metod lub danych jako prywatnych umożliwia kompilatorowi wyszukanie w programach pomyłek, zanim staną się one błędami. Każdy szanujący się programista potrafi znaleźć sposób na obejście prywatności składowych. Stroustrup, autor języka C++, stwierdza że „...mechanizmy ochrony z poziomu języka chronią przed pomyłką, a nie przed świadomym oszustwem.” (WNT, 1995). Słowo kluczowe class
Składnia słowa kluczowego class jest następująca: class nazwa_klasy { // słowa kluczowe kontroli dostępu // zadeklarowane zmienne i składowe klasy };
Słowo kluczowe class służy do deklarowania nowych typów. Klasa stanowi zbiór danych składowych danych klasy, które są zmiennymi różnych typów, także innych klas. Klasa zawiera także funkcje klasy — tzw. metody — które są funkcjami używanymi do manipulowania danymi w danej klasie i wykonywania innych usług dla klasy.
Obiekty nowego typu definiuje się w taki sam sposób, w jaki definiuje się inne zmienne. Należy określić typ (klasę), a po nim nazwę zmiennej (obiektu). Do uzyskania dostępu do funkcji i danych klasy służy operator kropki (.).
Słowa kluczowe kontroli dostępu określają, które sekcje klasy są prywatne, a które publiczne. Domyślnie wszystkie składowe klasy są prywatne. Każde słowo kluczowe zmienia kontrolę dostępu od danego miejsca aż do końca klasy, lub kontrolę wystąpienia następnego słowa kluczowego kontroli dostępu. Deklaracja klasy kończy się zamykającym nawiasem klamrowym i średnikiem.
Przykład 1 class Cat { public: unsigned int Age; unsigned int Weight; void Meow(); }; Cat Mruczek; Mruczek.Age = 8; Mruczek.Weight = 18; Mruczek.Meow();
Przykład 2 class Car { public: // pięć następnych składowych jest publicznych void Start(); void Accelerate(); void Brake(); void SetYear(int year); int GetYear(); private: // pozostała część jest prywatna int Year; char Model [255]; }; // koniec deklaracji klasy Car OldFaithful; // tworzy egzemplarz klasy int bought; // lokalna zmienna typu int OldFaithful.SetYear(84); // ustawia składową Year na 84 bought = OldFaithful.GetYear(); // ustawia zmienną bought na 84 OldFaithful.Start(); //wywołuje metodę Start
TAK
NIE
Deklaruj zmienne składowe jako prywatne.
Nie używaj prywatnych zmiennych składowych klasy poza tą klasą.
Używaj publicznych akcesorów,. czyli publicznych funkcji dostępowych. Odwołuj się do prywatnych zmiennych składowych ze składowych funkcji składowych klasy.
Implementowanie metod klasy Akcesory stanowią publiczny interfejs do prywatnych danych klasy. Każdy akcesor musi posiadać, wraz z innymi zadeklarowanymi metodami klasy, implementację. Implementacja jest nazywana definicją funkcji. Definicja funkcji składowej rozpoczyna się od nazwy klasy, po której występują dwa dwukropki, nazwa funkcji klasy i jej parametry. Listing 6.3 przedstawia pełną deklarację prostej klasy Cat, wraz z implementacją jej akcesorów i jednej ogólnej funkcji tej klasy. Listing 6.3. Implementacja metod prostej klasy 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30:
// Demonstruje deklarowanie klasy oraz // definiowanie jej metod #include class Cat { public: int GetAge(); void SetAge (int age); void Meow(); private: int itsAge; };
// dla cout // początek deklaracji klasy // // // // // //
początek sekcji publicznej akcesor akcesor ogólna funkcja początek sekcji prywatnej zmienna składowa
// GetAge, publiczny akcesor // zwracający wartość składowej itsAge int Cat::GetAge() { return itsAge; } // definicja SetAge, akcesora // publicznego // ustawiającego składową itsAge void Cat::SetAge(int age) { // ustawia zmienną składową itsAge // zgodnie z wartością przekazaną w parametrze age itsAge = age; }
31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52:
// definicja metody Meow // zwraca: void // parametery: brak // działanie: wypisuje na ekranie słowo "miauczy" void Cat::Meow() { std::cout << "Miauczy.\n"; } // tworzy kota, ustawia jego wiek, sprawia, // Ŝe miauczy, wypisuje jego wiek i ponownie miauczy. int main() { Cat Mruczek; Mruczek.SetAge(5); Mruczek.Meow(); std::cout << "Mruczek jest kotem i ma " ; std::cout << Mruczek.GetAge() << " lat.\n"; Mruczek.Meow(); return 0; }
Wynik Miauczy. Mruczek jest kotem i ma 5 lat. Miauczy.
Analiza Linie od 5. do 13. zawierają definicję klasy Cat (kot). Linia 7. zawiera słowo kluczowe public, które informuje kompilator, że to, co po nim następuje, jest zestawem publicznych składowych. Linia 8. zawiera deklarację publicznego akcesora GetAge() (pobierz wiek). GetAge() zapewnia dostęp do prywatnej zmiennej składowej itsAge (jego wiek), zadeklarowanej w linii 12. Linia 9. zawiera publiczny akcesor SetAge() (ustaw wiek). Funkcja SetAge() otrzymuje parametr typu int, który następnie przypisuje składowej itsAge. Linia 10. zawiera deklarację metody Meow() (miaucz). Funkcja Meow() nie jest akcesorem. Jest to ogólna metoda klasy, wypisująca na ekranie słowo „Miauczy.” Linia 11. rozpoczyna sekcję prywatną, która obejmuje jedynie zadeklarowaną w linii 12. prywatną składową itsAge. Deklaracja klasy kończy się zamykającym nawiasem klamrowym i średnikiem. Linie od 17. do 20. zawierają definicję składowej funkcji GetAge(). Ta metoda nie ma parametrów i zwraca wartość całkowitą. Zauważ, że ta metoda klasy zawiera nazwę klasy, dwa dwukropki oraz nazwę funkcji (linia 17.). Ta składnia informuje kompilator, że definiowana funkcja GetAge() jest właśnie tą funkcją, która została zadeklarowana w klasie Cat. Poza formatem tego nagłówka, definiowanie deklaracja funkcji własnej GetAge() niczym nie różni się od innych klas. definiowania innych (zwykłych) funkcji. Funkcja GetAge() posiada tylko jedną linię i zwraca po prostu wartość zmiennej składowej itsAge. Zauważ, że funkcja main() nie ma dostępu do tej zmiennej składowej, gdyż jest ona prywatna dla klasy Cat. Funkcja main() ma za to dostęp do publicznej metody GetAge().
Ponieważ ta metoda jest składową klasy Cat, ma pełny dostęp do zmiennej itsAge. Dzięki temu może zwrócić funkcji main() wartość zmiennej itsAge. Linia 25. zawiera definicję funkcji składowej SetAge(). Ta funkcja posiada parametr w postaci wartości całkowitej i przypisuje składowej itsAge jego wartość (linia 29.). Ponieważ jest składową klasy Cat, ma bezpośredni dostęp do jej zmiennych prywatnych i publicznych. Linia 36. rozpoczyna definicję (czyli implementację) metody Meow() klasy Cat. Jest to jednoliniowa funkcja wypisująca na ekranie słowo „Miaucz”, zakończone znakiem nowej linii. Pamiętaj, że znak \n wypisuje powoduje przejście do noweją liniię. Linia 43. rozpoczyna ciało funkcji main(), czyli właściwy program. W tym przypadku funkcja main() nie posiada argumentów. W linii 45., funkcja main() deklaruje obiekt Cat o nazwie Mruczek. W linii 46. zmiennej itsAge tego obiektu jest przypisywana wartość 5 (poprzez użycie akcesora SetAge()). Zauważ, że wywołanie tej metody następuje dzięki użyciu nazwy obiektu (Mruczek), po której zastosowano operator kropki (.) i nazwę metody (SetAge()). W podobny sposób wywoływane są wszystkie inne metody wszystkich klas. Linia 47. wywołuje funkcję składową Meow(), zaś w linii 48. za pomocą akcesora GetAge(),wypisywany jest komunikat. Linia 50. ponownie wywołuje funkcję Meow().
Konstruktory i destruktory Istnieją dwa sposoby definiowania zmiennej całkowitej. Można zdefiniować zmienną, a następnie, w dalszej części programu, przypisać jej wartość. Na przykład: int Weight; ... Weight = 7;
// definiujemy zmienną // tu inny kod // przypisujemy jej wartość
Możemy też zdefiniować zmienną i jednocześnie zainicjalizować ją. Na przykład: int Weight = 7;
// definiujemy i inicjalizujemy wartością 7
Inicjalizacja łączy w sobie definiowanie zmiennej oraz początkowe przypisanie wartości. Nic nie stoi na przeszkodzie temu, by zmienić później wartość zmiennej. Inicjalizacja powoduje tylko że zmienna nigdy nie będzie pozbawiona sensownej wartości. W jaki sposób zainicjalizować składowe klasy? Klasy posiadają specjalne funkcje składowe, zwane konstruktorami. Konstruktor (ang. constructor) może w razie potrzeby posiadać parametry, ale nie może zwracać wartości — nawet typu void. Konstruktor jest metodą klasy o takiej samej nazwie, jak nazwa klasy. Gdy zadeklarujesz konstruktor, powinieneś także zadeklarować destruktor (ang. destructor). Konstruktor tworzy i inicjalizuje obiekt danej klasy, zaś destruktor porządkuje obiekt i zwalnia pamięć, którą mogłeś w niej zaalokować. Destruktor zawsze nosi nazwę klasy, poprzedzoną
znakiem tyldy (~). Destruktory nie mają argumentów i nie zwracają wartości. Dlatego deklaracja destruktora klasy Cat ma następującą postać: ~Cat();
Domyślne konstruktory i destruktory Jeśli nie zadeklarujesz konstruktora lub destruktora, zrobi to za ciebie kompilator. Istnieje wiele rodzajów konstruktorów; niektóre z nich posiadają argumenty, inne nie. Konstruktor, którego można wywołać bez żadnych nie posiadającyargumentów, jest nazywany konstruktorem domyślnym. Istnieje tylko jeden rodzaj destruktora. On także nie posiada argumentów. Jeśli nie stworzysz konstruktora lub destruktora, kompilator stworzy je za ciebie. Konstruktor dostarczany przez kompilator jest konstruktorem domyślnym — czyli konstruktorem bez argumentów. Taki konstruktor domyślny możesz stworzyć samodzielnie. Stworzone przez kompilator domyślny konstruktor i destruktor nie mają żadnych argumentów, a na dodatek w ogóle nic nie robią!
Użycie domyślnego konstruktora Do czego może przydać się konstruktor, który nic nie robi? Jest to problem techniczny: wszystkie obiekty muszą być konstruowane i niszczone, dlatego w odpowiednich momentach wywoływane są te nic nie robiące funkcje. Aby móc zadeklarować obiekt bez przekazywania parametrów, na przykład Cat Filemon;
// Filemon nie ma parametrów
musisz posiadać konstruktor w postaci Cat();
Gdy definiujesz obiekt klasy, wywoływany jest konstruktor. Gdyby konstruktor klasy Cat miał dwa parametry, mógłbyś zdefiniować obiekt Cat, pisząc Cat Mruczek (5, 7);
Gdyby konstruktor miał jeden parametr, napisałbyś Cat Mruczek (3);
W przypadku, gdy konstruktor nie ma żadnych parametrów (gdy jest konstruktorem domyślnym), możesz opuścić nawiasy i napisać Cat Mruczek;
Jest to wyjątek od reguły, zgodnie z którą wszystkie funkcje wymagają zastosowania nawiasów, nawet jeśli nie mają parametrów. Właśnie dla tego możesz napisać: Cat Mruczek;
Jest to interpretowane jako wywołanie konstruktora domyślnego. Nie dostarczamy mu parametrów i pomijamy nawiasy. Zwróć uwagę, że nie musisz używać domyślnego konstruktora dostarczanego przez kompilator. Zawsze możesz napisać własny konstruktor domyślny — tj. konstruktor bez parametrów. Możesz zastosować w nim ciało funkcji, w którym możesz zainicjalizować obiekt. Zgodnie z konwencją, gdy deklarujesz konstruktor, powinieneś także zadeklarować destruktor, nawet jeśli nie robi on niczego. Nawet jeśli destruktor domyślny będzie działał poprawnie, nie zaszkodzi zadeklarować własnego. Dzięki niemu kod staje się bardziej przejrzysty. Listing 6.4 zawiera nową wersję klasy Cat, w której do zainicjalizowania obiektu kota użyto konstruktora. Wiek kota został ustawiony zgodnie z wartością otrzymaną jako parametr konstruktora. Listing 6.4. Użycie konstruktora i destruktora 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24:
// Demonstruje deklarowanie konstruktora // i destruktora dla klasy Cat // Domyślny konstruktor został stworzony przez programistę #include
// dla cout
class Cat { public: Cat(int initialAge); ~Cat(); int GetAge(); void SetAge(int age); void Meow(); private: int itsAge; };
// początek deklaracji klasy // // // // //
początek sekcji publicznej konstruktor destruktor akcesor akcesor
// początek sekcji prywatnej // zmienna składowa
// konstruktor klasy Cat Cat::Cat(int initialAge) { itsAge = initialAge; } Cat::~Cat()
// destruktor, nic nie robi
25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52: 53: 54: 55: 56: 57: 58: 59: 60: 61: 62: 63: 64: 65: 66: 67:
{ } // GetAge, publiczny akcesor // zwraca wartość składowej itsAge int Cat::GetAge() { return itsAge; } // definicja SetAge, akcesora // publicznego void Cat::SetAge(int age) { // ustawia zmienną składową itsAge // zgodnie z wartością przekazaną w parametrze age itsAge = age; } // definicja metody Meow // zwraca: void // parametery: brak // działanie: wypisuje na ekranie słowo "miauczy" void Cat::Meow() { std::cout << "Miauczy.\n"; } // tworzy kota, ustawia jego wiek, sprawia, // Ŝe miauczy, wypisuje jego wiek i ponownie miauczy. int main() { Cat Mruczek(5); Mruczek.Meow(); std::cout << "Mruczek jest kotem i ma " ; std::cout << Mruczek.GetAge() << " lat.\n"; Mruczek.Meow(); Mruczek.SetAge(7); std::cout << "Teraz Mruczek ma " ; std::cout << Mruczek.GetAge() << " lat.\n"; return 0; }
Wynik Miauczy. Mruczek jest kotem i ma 5 lat. Miauczy. Teraz Mruczek ma 7 lat.
Analiza Listing 6.4 przypomina listing 6.3, jednak w linii 9. dodano konstruktor, posiadający argument w postaci wartości całkowitej. Linia 10. deklaruje destruktor, który nie posiada parametrów. Destruktory nigdy nie mają parametrów, zaś destruktory i konstruktory nie zwracają żadnych wartości — nawet typu void.
Linie od 19. do 22. zawierają implementację konstruktora. Jest ona podobna do implementacji akcesora SetAge(). Konstruktor nie zwraca wartości. Linie od 24. do 26. przedstawiają implementację destruktora ~Cat(). Ta funkcja nie robi nic, ale jeśli deklaracja klasy zawiera deklarację destruktora, zdefiniowany musi zostać wtedy także ten destruktor. Linia 58. zawiera definicję obiektu Mruczek, stanowiącego egzemplarz klasy Cat. Do konstruktora obiektu Mruczek przekazywana jest wartość 5. Nie ma potrzeby wywoływania funkcji SetAge(), gdyż Mruczek został stworzony z wartością 5 znajdującą się w zmiennej składowej itsAge, tak jak pokazano w linii 61. W linii 63. zmiennej itsAge obiektu Mruczek jest przypisywana wartość 7. Tę nową wartość wypisuje linia 65.
TAK
NIE
W celu zainicjalizowania obiektów używaj konstruktorów.
Pamiętaj, że konstruktory i destruktory nie mogą zwracać wartości. Pamiętaj, że destruktory nie mogą mieć parametrów.
Funkcje składowe const Jeśli zadeklarujesz metodę klasy jako const, obiecujesz w ten sposób, że metoda ta nie zmieni wartości żadnej ze składowych klasy. Aby zadeklarować metodę w ten sposób, umieść słowo kluczowe const za nawiasami, lecz przed średnikiem. Pokazana poniżej deklaracja funkcji składowej const o nazwie SomeFunction() nie posiada argumentów i zwraca typ void: void SomeFunction() const;
Wraz z modyfikatorem const często deklarowane są akcesory. Klasa Cat posiada dwa akcesory: void SetAge(int anAge); int GetAge();
Funkcja SetAge() nie może być funkcją const, gdyż modyfikuje wartość zmiennej składowej itsAge. Natomiast funkcja GetAge() może być const, gdyż nie modyfikuje wartości żadnej ze składowych klasy. Funkcja GetAge() po prostu zwraca bieżącą wartość składowej itsAge. Zatem deklaracje tych funkcji można przepisać następująco: void SetAge(int anAge); int GetAge() const;
Gdy zadeklarujesz funkcję jako const, zaś implementacja tej funkcji modyfikuje obiekt poprzez modyfikację wartości którejkolwiek ze składowych, kompilator zgłosi błąd. Na przykład, gdy napiszesz funkcję GetAge() w taki sposób, że będziesz zapamiętywał ilość zapytań o wiek kota, spowodujesz błąd kompilacji. Jest to spowodowane tym, że wywołując tę metodę, modyfikujesz zawartość obiektu Cat. UWAGA Deklaruj Używaj funkcje jako const wszędzie, gdzie to jest możliwe. Deklaruj je tam, gdzie nie przewidujesz modyfikowania obiektu. Kompilator może w ten sposób pomóc ci w wykryciu błędów w programie; tak jest szybciej i dokładniej.
Deklarowanie funkcji jako const wszędzie tam, gdzie jest to możliwe, należy do tradycji programistycznej. Za każdym razem, gdy to zrobisz, umożliwisz kompilatorowi wykrycie pomyłki zanim stanie się ona błędem, który ujawni się już podczas działania programu.
Interfejs a implementacja Jak wiesz, klienty są tymi elementami programu, które tworzą i wykorzystują obiekty twojej klasy. Publiczny interfejs swojej klasy — deklarację klasy — możesz traktować jako kontrakt z tymi klientami. Ten kontrakt informuje, jak zachowuje się dana klasa. Na przykład, w deklaracji klasy Cat, stworzyłeś kontrakt informujący, że wiek każdego kota może być zainicjalizowany w jego konstruktorze, modyfikowany za pomocą akcesora SetAge() oraz odczytywany za pomocą akcesora GetAge(). Oprócz tego obiecujesz, że każdy kot może miauczeć (funkcją Meow()). Zwróć uwagę, że w publicznym interfejsie nie ma ani słowa o zmiennej składowej itsAge; jest to szczegół implementacji, który nie stanowi elementu kontraktu. Na żądanie dostarczysz wieku (GetAge()) i ustawisz go (SetAge()), ale sam mechanizm (itsAge) jest niewidoczny. Gdy uczynisz funkcję GetAge()funkcją const — a powinieneś to zrobić — kontrakt obiecuje także, że funkcja GetAge() nie modyfikuje obiektu Cat, dla którego jest wywołana. C++ jest językiem zapewniającym silną kontrolę typów, co oznacza, że kompilator wymusza przestrzeganie kontraktu, zgłaszając błędy kompilacji za każdym razem, gdy naruszysz reguły tego kontraktu. Listing 6.5 przedstawia program, który nie skompiluje się z powodu naruszenia ustaleń takiego kontraktu. OSTRZEŻENIE Listing 6.5 nie skompiluje się!
Listing 6.5. Przykład naruszenia ustaleń interfejsu 0: 1: 2: 3: 4: 5: 6:
// Demonstruje błędy kompilacji // Ten program się nie kompiluje! #include class Cat {
// dla cout
7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52: 53: 54: 55: 56: 57: 58: 59: 60: 61: 62: 63:
Analiza
public: Cat(int initialAge); ~Cat(); int GetAge() const; void SetAge (int age); void Meow(); private: int itsAge; };
// akcesor typu const
// konstruktor klasy Cat, Cat::Cat(int initialAge) { itsAge = initialAge; std::cout << "Konstruktor klasy Cat\n"; } Cat::~Cat() // destruktor, nic nie robi { std::cout << "Destruktor klasy Cat\n"; } // GetAge, funkcja const, // ale narusza zasadę const! int Cat::GetAge() const { return (itsAge++); // narusza const! } // definicja SetAge, publicznego // akcesora void Cat::SetAge(int age) { // ustawia zmienną składową itsAge // zgodnie z wartością przekazaną w parametrze age itsAge = age; } // definicja metody Meow // zwraca: void // parametery: brak // działanie: wypisuje na ekranie słowo "miauczy" void Cat::Meow() { std::cout << "Miauczy.\n"; } // demonstruje róŜne naruszenia reguł interfejsu // oraz wynikające z tego błędy kompilatora int main() { Cat Mruczek; // nie pasuje do deklaracji Mruczek.Meow(); Mruczek.Bark(); // Nie, głuptasie, koty nie szczekają. Mruczek.itsAge = 7; // itsAge jest składową prywatną return 0; }
Program w przedstawionej powyżej postaci się nie kompiluje, więc nie ma wyników działania. Pisanie go było dość zabawne, ponieważ zawiera tak dużo błędów. Linia 10 deklaruje funkcję GetAge() jako akcesor typu const — i tak powinno być. Jednak w ciele funkcji GetAge(), w linii 32., inkrementowana jest zmienna składowa itsAge. Ponieważ ta metoda została zadeklarowana jako const, nie może zmieniać wartości tej zmiennej. Dlatego podczas kompilacji programu zostanie to zgłoszone jako błąd. W linii 12., funkcja Meow() nie jest zadeklarowana jako const. Choć nie jest to błędem, stanowi zły obyczaj. Należałoby wziąć pod uwagę, że ta metoda nie modyfikuje zmiennych składowych klasy. Dlatego funkcja Meow() powinna być funkcją const. Linia 58. pokazuje definicję obiektu Mruczek klasy Cat. W tym programie klasa Cat posiada konstruktor, który wymaga podania argumentu, będącego wartością całkowitą. Oznacza to, że musisz taki argument przekazać. Ponieważ w linii 58. nie występuje argument konstruktora, kompilator zgłosi błąd. UWAGA Jeśli stworzysz jakikolwiek konstruktor, kompilator zrezygnuje z dostarczenia swojego konstruktora domyślnego. Gdy stworzysz konstruktor wymagający parametru, nie będziesz miał konstruktora domyślnego, chyba że stworzysz go sam.
Linia 60. zawiera wywołanie metody Bark() dla obiektu Mruczek. Metoda Bark() nie została zadeklarowana, więc jest niedozwolona. Linia 61. zawiera przypisanie wartości 7 do zmiennej itsAge. Ponieważ itsAge jest składową prywatną, kompilator zgłosi błąd kompilacji. Po co używać kompilatora do wykrywania błędów?
Gdyby można było tworzyć programy w stu procentach pozbawione błędów, byłoby cudowanie, jednak tylko bardzo niewielu programistów jest w stanie tego dokonać. Wielu programistów opracowało jednak system pozwalający zminimalizować ilość błędów przez wczesne ich wykrycie i poprawienie.
Choć błędy kompilatora są irytujące i stanowią dla programisty przekleństwo, jednak są czymś dużo lepszym niż opisana dalej alternatywa. Język o słabej kontroli typów umożliwia naruszanie zasad kontraktu bez słowa sprzeciwu ze strony kompilatora, jednak program może załamać się w trakcie działania — na przykład wtedy, gdy pracuje z nim twój szef.
Błędy czasu kompilacji — tj. błędy wykryte podczas kompilowania programu — są zdecydowanie lepsze niż błędy czasu działania — tj. błędy wykryte podczas działania programu. Są lepsze, gdyż dużo łatwiej i precyzyjniej można określić ich przyczynę. Może się zdarzyć że program zostanie wykonany wielokrotnie bez wykonania wszystkich istniejących ścieżek wykonania kodu. Dlatego błąd czasu działania może przez dłuższy czas pozostać niezauważony. Błędy kompilacji są wykrywane podczas każdej kompilacji, są więc dużo łatwiejsze do zidentyfikowania i poprawienia. Celem dobrego programowania jest ochrona przed pojawianiem się błędów czasu działania. Jedną ze znanych i sprawdzonych technik jest wykorzystanie kompilatora do wykrycia pomyłek już na wczesnym etapie tworzenia programu.
Gdzie umieszczać deklaracje klasy i definicje metod Każda funkcja, którą zadeklarujesz dla klasy, musi posiadać definicję. Definicja jest nazywana także implementacją funkcji. Podobnie jak w przypadku innych funkcji, definicja metody klasy posiada nagłówek i ciało. Definicja musi znajdować się w pliku, który może zostać znaleziony przez kompilator. Większość kompilatorów C++ wymaga, by taki plik miał rozszerzenie .c lub .cpp. W tej książce korzystamy z rozszerzenia .cpp, ale aby mieć pewność, sprawdź, czego oczekuje twój kompilator. UWAGA Wiele kompilatorów zakłada, że pliki z rozszerzeniem .c są programami C, zaś pliki z rozszerzeniem .cpp są programami C++. Możesz używać dowolnego rozszerzenia, ale rozszerzenie .cpp wyeliminuje ewentualne nieporozumienia.
W tym pliku, w którym umieszczasz implementację funkcji, możesz umieścić również jej deklarację funkcji, ale nie należy to do dobrych obyczajów. Zgodnie z konwencją zaadoptowaną przez większość programistów, deklaracje umieszcza się w tak zwanych plikach nagłówkowych, zwykle posiadających tę samą nazwę, lecz z rozszerzeniem .h, .hp lub .hpp. W tej książce dla plików nagłówkowych stosujemy rozszerzenie .hpp, ale sprawdź w swoim kompilatorze, jakie rozszerzenie powinieneś stosować. Na przykład, deklarację klasy Cat powinieneś umieścić w pliku o nazwie CAT.hpp, zaś definicję metod tej klasy w pliku o nazwie CAT.cpp. Następnie powinieneś dołączyć do pliku .cpp plik nagłówkowy, poprzez umieszczenie na początku pliku CAT.cpp następującej dyrektywy: #include "Cat.hpp"
Informuje ona kompilator, by wstawił w tym miejscu zawartość pliku CAT.hpp tak, jakbyś ją wpisał ręcznie. Uwaga: niektóre kompilatory nalegają, by wielkość liter w nazwie pliku w dyrektywie #include zgadzała się z wielkością liter w nazwie pliku na dysku. Dlaczego masz się trudzić, rozdzielając program na pliki .hpp i .cpp, skoro i tak plik .hpp jest wstawiany do pliku .cpp? W większości przypadków klienty klasy nie dbają o szczegóły jej implementacji. Odczytanie pliku nagłówkowego daje im wystarczającą ilość informacji by zignorować plik implementacji. Poza tym, ten sam plik .hpp możesz dołączać do wielu różnych plików .cpp. UWAGA Deklaracja klasy mówi kompilatorowi, czym jest ta klasa, jakie dane zawiera oraz jakie funkcje posiada. Deklaracja klasy jest nazywana jej interfejsem, gdyż informuje kompilator w jaki sposób ma z nią współdziałać. Ten interfejs jest zwykle przechowywany w pliku .hpp, często nazywanym plikiem nagłówkowym.
Definicja funkcji mówi kompilatorowi, jak działa dana funkcja. Definicja funkcji jest nazywana implementacją metody klasy i jest przechowywana w pliku .cpp. Szczegóły dotyczące implementacji klasy należą wyłącznie do jej autora. Klienty klasy — tj. części programu
używające tej klasy — nie muszą, ani nie powinny wiedzieć, jak zaimplementowane zostały funkcje.
Implementacja inline Możesz poprosić kompilator, by uczynił zwykłą funkcję funkcją inline, funkcjami inline mogą stać się również metody klasy. W tym celu należy umieścić słowo kluczowe inline przed typem zwracanej wartości. Na przykład, implementacja inline funkcji GetWeight() wygląda następująco: inline int Cat::GetWeight() { return itsWeight; // zwraca daną składową itsWeight }
Definicję funkcji można także umieścić w deklaracji klasy, co automatycznie sprawia, że ta funkcja staje się funkcją inline. Na przykład: class Cat { public: int GetWeight() { return itsWeight; } void SetWeight(int aWeight); };
// inline
Zwróć uwagę na składnię definicji funkcji GetWeight(). Ciało funkcji inline zaczyna się natychmiast po deklaracji metody klasy; po nawiasach nie występuje średnik. Podobnie jak w innych funkcjach, definicja zaczyna się od otwierającego nawiasu klamrowego i kończy zamykającym nawiasem klamrowym. Jak zwykle, białe spacje nie mają znaczenia; możesz zapisać tę deklarację jako: class Cat { public: int GetWeight() const { return itsWeight; } // inline void SetWeight(int aWeight); };
Listingi 6.6 i 6.7 odtwarzają klasę Cat, tym razem jednak deklaracja klasy została umieszczona w pliku CAT.hpp, zaś jej definicja w pliku CAT.cpp. Oprócz tego, na listingu 6.7 akcesor Meow() został zadeklarowany jako funkcja inline.
Listing 6.6. Deklaracja klasy Cat w pliku CAT.hpp 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11:
#include class Cat { public: Cat (int initialAge); ~Cat(); int GetAge() const { return itsAge;} // inline! void SetAge (int age) { itsAge = age;} // inline! void Meow() const { std::cout << "Miauczy.\n";} // inline! private: int itsAge; };
Listing 6.7. Implementacja klasy Cat w pliku CAT.cpp 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29:
// Demonstruje funkcje inline // oraz dołączanie pliku nagłówkowego // pamiętaj o włączeniu plików nagłówkowych! #include "cat.hpp"
Cat::Cat(int initialAge) { itsAge = initialAge; } Cat::~Cat() { }
//konstruktor
//destruktor, nic nie robi
// tworzy kota, ustawia jego wiek, sprawia // Ŝe miauczy, wypisuje jego wiek i ponownie miauczy. int main() { Cat Mruczek(5); Mruczek.Meow(); std::cout << "Mruczek jest kotem i ma " ; std::cout << Mruczek.GetAge() << " lat.\n"; Mruczek.Meow(); Mruczek.SetAge(7); std::cout << "Teraz Mruczek ma " ; std::cout << Mruczek.GetAge() << " lat.\n"; return 0; }
Wynik Miauczy. Mruczek jest kotem i ma 5 lat. Miauczy. Teraz Mruczek ma 7 lat.
Analiza
Kod zaprezentowany na listingach 6.6 i 6.7 jest podobny do kodu z listingu 6.4, trzy metody zostały zadeklarowane w pliku deklaracji jako inline, a deklaracja została przeniesiona do pliku CAT.hpp (listing 6.6). Funkcja GetAge() jest deklarowana w linii 6., gdzie znajduje się także jej implementacja. Linie 7. i 8. zawierają kolejne funkcje inline, jednak w stosunku do poprzednich, „zwykłych” implementacji, działanie tych funkcji nie zmienia się. Linia 4. listingu 6.7 zawiera dyrektywę #include "cat.hpp", która powoduje wstawienie do pliku zawartości pliku CAT.hpp. Dołączając plik CAT.hpp, informujesz prekompilator, by odczytał zawartość tego pliku i wstawił ją w miejscu wystąpienia dyrektywy #include (tak jakbyś, począwszy od linii 5, sam wpisał tę zawartość). Ta technika umożliwia umieszczenie deklaracji w pliku innym niż implementacja, a jednocześnie zapewnienie kompilatorowi dostępu do niej. W programach C++ technika ta jest powszechnie wykorzystywana. Zwykle deklaracje klas znajdują się w plikach .hpp, które są dołączane do powiązanych z nimi plików .cpp za pomocą dyrektyw #include. Linie od 18. do 29. stanowią powtórzenie funkcji main() z listingu 6.4. Oznacza to, że funkcje inline działają tak samo jak zwykłe funkcje.
Klasy, których danymi składowymi są inne klasy Budowanie złożonych klas przez deklarowanie prostszych klas i dołączanie ich do deklaracji bardziej skomplikowanej klasy nie jest niczym niezwykłym. Na przykład, możesz zadeklarować klasę koła, klasę silnika, klasę skrzyni biegów, itd., a następnie połączyć je w klasę „samochód”. Deklaruje to relację posiadania. Taka deklaracja posiada związek relacji. Samochód ma posiada silnik, koła i skrzynię biegów. Weźmy inny przykład. Prostokąt składa się z odcinków. Odcinek jest zdefiniowany przez dwa punkty. Punkt jest zdefiniowany przez współrzędną x i współrzędną y. Listing 6.8 przedstawia pełną deklarację klasy Rectangle (prostokąt), która może wystąpić w pliku RECTANGLE.hpp. Ponieważ prostokąt jest zdefiniowany jako cztery odcinki łączące cztery punkty, zaś każdy punkt odnosi się do współrzędnej w układzie, najpierw zadeklarujemy klasę Point (punkt) jako przechowującą współrzędne x oraz y punktu. Listing 6.9 zawiera implementacje obu klas. Listing 6.8. Deklarowanie kompletnej klasy 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11:
// początek Rect.hpp #include class Point // przechowuje współrzędne x,y { // bez konstruktora, uŜywa domyślnego public: void SetX(int x) { itsX = x; } void SetY(int y) { itsY = y; } int GetX()const { return itsX;} int GetY()const { return itsY;} private:
12: int itsX; 13: int itsY; 14: }; // koniec deklaracji klasy Point 15: 16: 17: class Rectangle 18: { 19: public: 20: Rectangle (int top, int left, int bottom, int right); 21: ~Rectangle () {} 22: 23: int GetTop() const { return itsTop; } 24: int GetLeft() const { return itsLeft; } 25: int GetBottom() const { return itsBottom; } 26: int GetRight() const { return itsRight; } 27: 28: Point GetUpperLeft() const { return itsUpperLeft; } 29: Point GetLowerLeft() const { return itsLowerLeft; } 30: Point GetUpperRight() const { return itsUpperRight; } 31: Point GetLowerRight() const { return itsLowerRight; } 32: 33: void SetUpperLeft(Point Location) {itsUpperLeft = Location;} 34: void SetLowerLeft(Point Location) {itsLowerLeft = Location;} 35: void SetUpperRight(Point Location) {itsUpperRight = Location;} 36: void SetLowerRight(Point Location) {itsLowerRight = Location;} 37: 38: void SetTop(int top) { itsTop = top; } 39: void SetLeft (int left) { itsLeft = left; } 40: void SetBottom (int bottom) { itsBottom = bottom; } 41: void SetRight (int right) { itsRight = right; } 42: 43: int GetArea() const; 44: 45: private: 46: Point itsUpperLeft; 47: Point itsUpperRight; 48: Point itsLowerLeft; 49: Point itsLowerRight; 50: int itsTop; 51: int itsLeft; 52: int itsBottom; 53: int itsRight; 54: }; 55: // koniec Rect.hpp
Listing 6.9. RECTANGLE.cpp 0: 1: 2: 3: 4: 5: 6: 7: 8: 9:
// początek rect.cpp #include "rect.hpp" Rectangle::Rectangle(int top, int left, int bottom, int right) { itsTop = top; itsLeft = left; itsBottom = bottom; itsRight = right;
10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44:
itsUpperLeft.SetX(left); itsUpperLeft.SetY(top); itsUpperRight.SetX(right); itsUpperRight.SetY(top); itsLowerLeft.SetX(left); itsLowerLeft.SetY(bottom); itsLowerRight.SetX(right); itsLowerRight.SetY(bottom); }
// oblicza obszar prostokąta przez obliczenie // i pomnoŜenie szerokości i wysokości int Rectangle::GetArea() const { int Width = itsRight-itsLeft; int Height = itsTop - itsBottom; return (Width * Height); } int main() { //inicjalizuje lokalną zmienną typu Rectangle Rectangle MyRectangle (100, 20, 50, 80 ); int Area = MyRectangle.GetArea(); std::cout << "Obszar: " << Area << "\n"; std::cout << "Wsp. X lewego gornego rogu: "; std::cout << MyRectangle.GetUpperLeft().GetX(); return 0; }
Wynik Obszar: 3000 Wsp. X lewego gornego rogu: 20
Analiza Linie od 3. do 14. listingu 6.8 deklarują klasę Point (punkt), która służy do przechowywania współrzędnych x i y określonego punktu rysunku. W swojej postaci, tym programie nie wykorzystujemy należycie klasy Point. Jej zastosowania wymagają jednak inne metody rysunkowe. UWAGA Gdy nadasz klasie nazwę Rectangle, niektóre kompilatory zgłoszą błąd, W takim przypadku po prostu zmień nazwę klasy na myRectangle.
W deklaracji klasy Point, w liniach 12. i 13., zadeklarowaliśmy dwie zmienne składowe (itsX oraz itsY). Te zmienne przechowują współrzędne punktu. Zakładamy, że współrzędna x rośnie w prawo, a współrzędna y w górę. Istnieją także inne systemy. W niektórych programach okienkowych współrzędna y rośnie „w dół” okna.
Klasa Point używa akcesorów inline, zwracających i ustawiających współrzędne X i Y punktu. Te akcesory zostały zadeklarowane w liniach od 7. do 10. Punkty używają konstruktora i destruktora domyślnego. W związku z tym ich współrzędne trzeba ustawiać jawnie. Linia 17. rozpoczyna deklarację klasy Rectangle (prostokąt). Klasa ta kłada się z czterech punktów reprezentujących cztery narożniki prostokąta. Konstruktor klasy Rectangle (linia 20.) otrzymuje cztery wartości całkowite, top (górna), left (lewa), bottom (dolna) oraz right (prawa). Do czterech zmiennych składowych (listing 6.9) kopiowane są cztery parametry konstruktora i tworzone są cztery punkty. Oprócz standardowych akcesorów, klasa Rectangle posiada funkcję GetArea() (pobierz obszar), zadeklarowaną w linii 43. Zamiast przechowywać obszar w zmiennej, funkcja GetArea() oblicza go w liniach od 28. do 30.i 29 listingu 6.9. W tym celu oblicza szerokość i wysokość prostokąta, następnie mnoży je przez siebie. Uzyskanie współrzędnej x lewego górnego wierzchołka prostokąta wymaga dostępu do punktu UpperLeft (lewy górny) i zapytania o jego współrzędną X. Ponieważ funkcja GetUpperLeft() jest funkcją klasy Rectangle, może ona bezpośrednio odwoływać się do prywatnych danych tej klasy, włącznie ze zmienną (itsUpperLeft). Ponieważ itsUpperLeft jest obiektem klasy Point, a zmienna itsX tej klasy jest prywatna, funkcja GetUpperLeft() nie może odwoływać się do niej bezpośrednio. Zamiast tego, w celu uzyskania tej wartości musi użyć publicznego akcesora GetX(). Linia 33. listingu 6.9 stanowi początek ciała programu. Pamięć nie jest alokowana aż do linii 36.; w obszarze tym nic się nie dzieje. Jedyna rzecz, jaką zrobiliśmy, to poinformowanie kompilatora, jak ma stworzyć punkt i prostokąt (gdyby były potrzebne w przyszłości). W linii 36. definiujemy obiekt typu Rectangle, przekazując mu wartości Top, Left, Bottom oraz Right. W linii 38. tworzymy lokalną zmienną Area (obszar) typu int. Ta zmienna przechowuje obszar stworzonego przez nas prostokąta. Zmienną Area inicjalizujemy za pomocą wartości zwróconej przez funkcję GetArea() klasy Rectangle. Klient klasy Rectangle może stworzyć obiekt tej klasy i uzyskać jego obszar, nie znając nawet implementacji funkcji GetArea(). Plik RECT.hpp został przedstawiony na listingu 6.8. Obserwując plik nagłówkowy, który zawiera deklarację klasy Rectangle, programista może wysnuć wniosek, że funkcja GetArea() zwraca wartość typu int. Sposób, w jaki funkcja GetArea() uzyskuje tę wartość, nie interesuje klientów klasy Rectangle. Autor klasy Rectangle mógłby zmienić funkcję GetArea(); nie wpłynęłoby to na programy, które z niej korzystają. Często zadawane pytanie
Jaka jest różnica pomiędzy deklaracją a definicją?
Odpowiedź: Deklaracja wprowadza nową nazwę, lecz nie alokuje pamięci; dokonuje tego definicja.
Wszystkie deklaracje (z kilkoma wyjątkami) są także definicjami. Najważniejszym wyjątkiem jest deklaracja funkcji globalnej (prototyp) oraz deklaracja klasy (zwykle w pliku nagłówkowym).
Struktury Bardzo bliskim kuzynem słowa kluczowego class jest słowo kluczowe struct, używane do deklarowania struktur. W C++ struktura jest odpowiednikiem klasy, ale wszystkie jej składowe są domyślnie publiczne. Możesz zadeklarować strukturę dokładnie tak, jak klasę; możesz zastosować w niej te same zmienne i funkcje składowe. Gdy przestrzegasz jawnego deklarowania publicznych i prywatnych sekcji klasy, nie ma żadnej różnicy pomiędzy klasą a strukturą. Spróbuj wprowadzić do listingu 6.8 następujące zmiany: •
w linii 3., zmień class Point na struct Point,
•
w linii 17., zmień class Rectangle na struct Rectangle.
Następnie skompiluj i uruchom program. Otrzymane wyniki nie powinny się od siebie różnić.
Dlaczego dwa słowa kluczowe spełniają tę samą funkcję Prawdopodobnie zastanawiasz się dlaczego dwa słowa kluczowe spełniają tę samą funkcję. Przyczyn należy szukać w historii języka. Język C++ powstawał jako rozszerzenie języka C. Język C posiada struktury, ale nie posiadają one metod. Bjarne Stroustrup, twórca języka C++, rozbudował struktury, ale zmienił ich nazwę na klasy, odzwierciedlając w ten sposób ich nowe, rozszerzone możliwości.
TAK Umieszczaj deklarację klasy w pliku .hpp, zaś funkcje składowe definiuj w pliku .cpp. Używaj const wszędzie tam, gdzie jest to możliwe. Zanim przejdziesz dalej, postaraj się dokładnie zrozumieć zasady działania klasy.
Rozdział 7. Sterowanie przebiegiem działania programu Większość działań programu powiązanych jest z warunkowymi rozgałęzieniami i pętlami. W rozdziale 4., „Wyrażenia i instrukcje”, poznałeś sposób, w jaki należy rozgałęzić działanie programu za pomocą instrukcji if. W tym rozdziale: •
dowiesz się, czym są pętle i jak się z nich korzysta,
•
nauczysz się tworzyć różnorodne pętle,
•
poznasz alternatywę dla głęboko zagnieżdżonych instrukcji if-else.
Pętle Wiele problemów programistycznych rozwiązywanych jest przez powtarzanie operacji wykonywanych na tych samych danych. Dwie podstawowe techniki to: rekurencja (omawiana w rozdziale 5., „Funkcje”) oraz iteracja. Iteracja oznacza ciągłe powtarzanie tych samych czynności. Podstawową metodą wykorzystywaną przy iteracji jest pętla.
Początki pętli: instrukcja goto W początkowym okresie rozwoju informatyki, programy były nieporadne, proste i krótkie. Pętle składały się z etykiety, zestawu wykonywanych instrukcji i skoku. W C++ etykieta jest zakończoną dwukropkiem nazwą (:). Etykieta może być umieszczona po lewej stronie instrukcji języka C++, zaś skok odbywa się w wyniku wykonania instrukcji goto (idź do) z nazwą etykiety. Ilustruje to listing 7.1. Listing 7.1. Pętla z użyciem słowa kluczowego goto 0:
// Listing 7.1
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15:
// Pętla z instrukcją goto #include int main() { int counter = 0; // inicjalizujemy licznik loop: counter ++; // początek pętli std::cout << "Licznik: " << counter << "\n"; if (counter < 5) // sprawdzamy wartość goto loop; // skok do początku std::cout << "Gotowe. Licznik: " << counter << ".\n"; return 0; }
Wynik Licznik: 1 Licznik: 2 Licznik: 3 Licznik: 4 Licznik: 5 Gotowe. Licznik: 5.
Analiza W linii 7., zmienna counter (licznik) jest inicjalizowana wartością 0. W linii 8 występuje etykieta loop (pętla), oznaczająca początek pętli. Zmienna counter jest inkrementowana, następnie wypisywana jest jej nowa wartość. W linii 10. sprawdzana jest wartość zmiennej. Gdy jest ona mniejsza od 5, wtedy instrukcja if jest prawdziwa i wykonywana jest instrukcja goto. W efekcie wykonanie programu wraca do linii 8. Program działa w pętli do chwili, gdy, wartość zmiennej counter osiągnie 5; to powoduje że program wychodzi z pętli i wypisuje końcowy komunikat.
Dlaczego nie jest zalecane stosowanie instrukcji goto? Programiści unikają instrukcji goto, i mają ku temu znaczące powody. Instrukcja goto umożliwia wykonanie skoku do dowolnego miejsca w kodzie źródłowym, do przodu lub do tyłu. Nierozważne użycie tej instrukcji sprawia że kod źródłowy jest zagmatwany, nieestetyczny i trudny do przeanalizowania, kod taki nazywany „kodem spaghetti”. Instrukcja goto
Aby użyć instrukcji goto, powinieneś napisać słowo kluczowe goto, a następnie nazwę etykiety. Spowoduje to wykonanie skoku bezwarunkowego.
Przykład
if (value > 10) goto end; if (value < 10) goto end; cout << "Wartosc jest rowna 10!"; end: cout << "gotowe";
Aby uniknąć użycia instrukcji goto, opracowano bardziej skomplikowane, ściślewysoce kontrolowalne instrukcje pętli: for, while oraz do...while.
Pętle while Pętla while (dopóki) powoduje powtarzanie zawartej w niej sekwencji instrukcji tak długo, jak długo zaczynające pętlę wyrażenie warunkowe pozostaje prawdziwe. W przykładzie z listingu 7.1, licznik był inkrementowany aż do osiągnięcia wartości 5. Listing 7.2 przedstawia ten sam program przepisany tak, aby można było skorzystać z pętli while. Listing 7.2. Pętla while 0: // Listing 7.2 1: // Pętla while 2: 3: #include 4: 5: int main() 6: { 7: int counter = 0; // inicjalizacja warunku 8: 9: while(counter < 5) // sprawdzenie, czy warunek jest spełniony 10: { 11: counter++; // ciało pętli 12: std::cout << "Licznik: " << counter << "\n"; 13: } 14: 15: std::cout << "Gotowe. Licznik: " << counter << ".\n"; 16: return 0; 17: }
Wynik Licznik: 1 Licznik: 2 Licznik: 3 Licznik: 4 Licznik: 5 Gotowe. Licznik: 5.
Analiza Ten prosty program demonstruje podstawy działania pętli while. Gdy warunek jest spełniony, wykonywane jest ciało pętli. W tym przypadku w linii 9. sprawdzane jest, czy zmienna counter
(licznik) ma wartość mniejszą od 5. Jeśli ten warunek jest spełniony (prawdziwy), wykonywane jest ciało pętli: w linii 11. następuje inkrementacja licznika, zaś jego wartość jest wypisywana w linii 12. Gdy warunek w linii 9. nie został spełniony (tzn. gdy zmienna counter ma wartość większą lub równą 5), wtedy całe ciało pętli while (linie od 10. do 13.) jest pomijane i program przechodzi do następnej instrukcji, czyli w tym przypadku do linii 14. Instrukcja while
Składnia instrukcji while jest następująca: while ( warunek ) instrukcja;
warunek jest wyrażeniem języka C++, zaś instrukcja jest dowolną instrukcją lub blokiem instrukcji C++. Gdy wartością wyrażenia warunek jest true (prawda), wykonywana jest instrukcja, po czym następuje powrót do początku pętli i ponowne sprawdzenie warunku. Czynność ta powtarza się, dopóki warunek zwraca wartość true. Gdy wyrażenie warunek ma wartość false, działanie pętli while kończy się i program przechodzi do instrukcji następujących po pętli.
Przykład // zliczanie do 10 int x = 0; while (x < 10) cout << "X: " << x++;
Bardziej skomplikowane instrukcje while Warunek sprawdzany w pętli while może być złożony, tak jak każde poprawne wyrażenie języka C++. Może zawierać wyrażenia tworzone za pomocą operatorów logicznych && (I), || (LUB) oraz ! (NIE). Taką nieco bardziej skomplikowaną instrukcję while przedstawia listing 7.3. Listing 7.3. Warunek złożony w instrukcji while 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16:
// Listing 7.3 // ZłoŜona instrukcja while #include using namespace std; int main() { unsigned short small; unsigned long large; const unsigned short MAXSMALL=65535; cout << "Wpisz mniejsza liczbe: "; cin >> small; cout << "Wpisz duza liczbe: "; cin >> large;
17: 18: 19: 20: 21: 22: linii 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: }
cout << "mala: " << small << "..."; // w kaŜdej iteracji sprawdzamy trzy warunki while (small < large && large > 0 && small < MAXSMALL) { if (small % 5000 == 0) // wypisuje kropkę co kaŜde 5000 cout << "."; small++; large-=2; } cout << "\nMala: " << small << " Duza: " << large << endl; return 0;
Wynik Wpisz Wpisz mala: Mala:
mniejsza liczbe: 2 duza liczbe: 100000 2......... 33335 Duza: 33334
Analiza Ten program to gra. Podaj dwie liczby, mniejszą i większą. Mniejsza liczba jest zwiększana o jeden, a większa liczba jest zmniejszana o dwa. Celem gry jest odgadnięcie, kiedy się „spotkają”. Linie od 12. do 15. służą do wprowadzania liczb. W linii 20. rozpoczyna się pętla while, której działanie będzie kontynuowane, dopóki spełnione są wszystkie trzy poniższe warunki: 1.
Mniejsza liczba nie jest większa od większej liczby.
2.
Większa liczba nie jest ujemna ani równa zeru.
3.
Mniejsza liczba nie przekracza maksymalnej wartości dla małych liczb całkowitych (MAXSMALL).
W linii 23. wartość zmiennej small (mała) jest obliczana modulo 5 000. Nie powoduje to zmiany wartości tej zmiennej; chodzi jedynie o to, że wartość 0 jest wynikiem działania modulo 5 000 tylko wtedy, gdy wartość zmiennej small jest wielokrotnością pięciu tysięcy. Za każdym razem, gdy otrzymujemy wartość zero, na ekranie wypisywana jest kropka, przedstawiająca postęp działań. W linii 25.6 następuje inkrementacja zmiennej small, zaś w linii 27.8 zmniejszenie zmiennej large (duża) o dwa. Jeżeli w pętli while nie zostanie spełniony któryś z trzech warunków, pętla kończy działanie, a wykonanie programu przechodzi do linii 29., dza zamykającyego nawiasu klamrowyego pętli while. UWAGA Operator reszty z dzielenia (modulo) oraz warunki złożone zostały opisane w rozdziale 3, „Stałe i zmienne.”
continue oraz break Może się zdarzyć, że przed wykonaniem całego zestawu instrukcji w pętli będziesz chcieć powrócić do jej początku. Służy do tego instrukcja continue (kontynuuj). Może zdarzyć się także, że będziesz chcieć wyjść z pętli jeszcze przed spełnieniem warunku końca. Instrukcja break (przerwij) powoduje natychmiastowe wyjście z pętli i przejście wykonywania do następnych instrukcji programu. Listing 7.4 demonstruje użycie tych instrukcji. Tym razem gra jest nieco bardziej skomplikowana. Użytkownik jest proszony o podanie liczby mniejszej i większej, liczby pomijanej oraz liczby docelowej. Mniejsza liczba jest zwiększana o jeden, a większa liczba jest zmniejszana o dwa. Za każdym razem, gdy mniejsza liczba jest wielokrotnością liczby pomijanej, nie jest wykonywane zmniejszanie. Gra kończy się, gdy mniejsza liczba staje się większa od większej liczby. Gdy większa liczba dokładnie zrówna się z liczbą docelową. wypisywany jest komunikat i gra zatrzymuje się. Listing 7.4. Instrukcje break i continue 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39:
// Listing 7.4 // Demonstruje instrukcje break i continue #include int main() { using namespace std; unsigned short small; unsigned long large; unsigned long skip; unsigned long target; const unsigned short MAXSMALL=65535; cout << "Wpisz cin >> small; cout << "Wpisz cin >> large; cout << "Wpisz cin >> skip; cout << "Wpisz cin >> target;
mniejsza liczbe: "; wieksza liczbe: "; liczbe pomijana: "; liczbe docelowa: ";
cout << "\n"; // ustalamy dla pętli trzy warunki zatrzymania while (small < large && large > 0 && small < MAXSMALL) { small++; if (small % skip == 0) // pomijamy zmniejszanie? { cout << "pominieto dla " << small << endl; continue; } if (large == target) {
// osiągnięto wartość docelową?
40: 41: 42: 43: 44: 45: 46: 47: endl; 48: 49: }
cout << "Osiagnieto wartosc docelowa!"; break; } large-=2; }
// koniec pętli while
cout << "\nMniejsza: " << small << " Wieksza: " << large << return 0;
Wynik Wpisz Wpisz Wpisz Wpisz
mniejsza liczbe: 2 wieksza liczbe: 20 liczbe pomijana: 4 liczbe docelowa: 6
pominieto dla 4 pominieto dla 8 Mniejsza: 10 Wieksza: 8
Analiza W tej grze użytkownik przegrał; zmienna small (mała) stała się większa, zanim zmienna large (większa) zrównała się z liczbą docelową 6. W linii 26. są sprawdzane warunki instrukcji while. Jeśli zmienna small jest mniejsza od zmiennej large, zmienna large jest większa od zera, a zmienna small nie przekroczyła maksymalnej wartości dla małych krótkich liczb całkowitych (short), program wchodzi do ciała pętli. W linii 32. jest obliczana reszta z dzielenia (modulo) wartości zmiennej small przez wartość pomijaną. Jeśli zmienna small jest wielokrotnością zmiennej skip (pomiń), wtedy wykonywana jest instrukcja continue i program wraca do początku pętli, do linii 26. W efekcie pominięte zostają: sprawdzanie wartości docelowej i zmniejszanie zmiennej large. W linii 38. następuje porównanie zmiennej target (docelowa) ze zmienną large. Jeśli są równe, wygrywa użytkownik. Wypisywany jest wtedy komunikat i wykonywana jest instrukcja break. Powoduje ona natychmiastowe wyjście z pętli i kontynuację wykonywania programu od linii 46.
UWAGA Instrukcje continue oraz break powinny być używane ostrożnie. Wraz z goto stanowią one dwie najbardziej niebezpieczne instrukcje języka (są one niebezpieczne z tych samych powodów co instrukcja goto). Programy zmieniające nagle kierunek działania są trudniejsze do zrozumienia, a używanie instrukcji continue i break według własnego uznania może uniemożliwić analizę nawet niewielkich pętli while.
Instrukcja continue
continue;
Powoduje pominięcie pozostałych instrukcji pętli while lub for i powrót do początku pętli. Przykład użycia tej instrukcji znajduje się na listingu 7.4.
Instrukcja break
break;
Powoduje natychmiastowe wyjście z pętli while lub for. Wykonanie programu przechodzi do zamykającego nawiasu klamrowego.
Przykład while (warunek) { if (warunek2) break; // instrukcje }
Pętla while(true) Sprawdzanym w pętli while warunkiem może być każde poprawne wyrażenie języka C++. Dopóki ten warunek pozostanie spełniony, działanie pętli while nie zostanie przerwane. Używając wartości true jako wyrażenia w instrukcji while, możesz stworzyć pętlę, która będzie wykonywana bez końca. Listing 7.5 przedstawia liczenie do 10 za pomocą takiej konstrukcji języka. Listing 7.5. Pętla while 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15:
// Listing 7.5 // Demonstruje pętlę
(true)
#include int main() { int counter = 0; while (true) { counter ++; if (counter > 10) break; } std::cout << "Licznik: " << counter << "\n";
16: 17:
return 0; }
Wynik Licznik: 11
Analiza W linii 9. rozpoczyna się pętla while z warunkiem, który zawsze jest spełniony. W linii 11. pętla inkrementuje wartość zmiennej licznikowej, po czym w linii 12. sprawdza, czy licznik przekroczył wartość 10. Jeśli nie, działanie pętli trwa. Jeśli licznik przekroczy wartość 10, wtedy instrukcja break w linii 13. powoduje wyjście z pętli, a działanie programu przechodzi do linii 15., w której wypisywany jest komunikat końcowy. Program działa, lecz nie jest elegancki – stanowi dobry przykład użycia złego narzędzia. Ten sam efekt można osiągnąć, umieszczając funkcję sprawdzania wartości licznika tam, gdzie powinna się ona znaleźć — w warunku instrukcji while. OSTRZEŻENIE Niekończące się pętle, takie jak while(true), mogą doprowadzić do zawieszenia się komputera gdy warunek wyjścia nie zostanie nigdy spełniony. Używaj ich ostrożnie i dokładnie testuj ich działanie.
C++ oferuje wiele sposobów wykonania danego zadania. Prawdziwa sztuka polega na wybraniu odpowiedniego narzędzia dla odpowiedniego zadania.
TAK
NIE
W celu wykonywania pętli, dopóki spełniony jest warunek, używaj pętli while.
Nie używaj instrukcji goto.
Bądź ostrożny używając instrukcji continue i break. Upewnij się, czy pętla while w pewnym momencie kończy działanie.
Pętla do...while Istnieje możliwość, że ciało pętli while nigdy nie zostanie wykonane. Instrukcja while sprawdza swój warunek przed wykonaniem którejkolwiek z zawartych w niej instrukcji, a gdy ten warunek nie jest spełniony, całe ciało pętli jest pomijane. Ilustruje to listing 7.6. Listing 7.6. Pominięcie ciała pętli while 0: 1: 2:
// Listing 7.6 // Demonstruje pominięcie ciała pętli while // w momencie, gdy warunek nie jest spełniony.
3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19:
#include int main() { int counter; std::cout << "Ile pozdrowien?: "; std::cin >> counter; while (counter > 0) { std::cout << "Hello!\n"; counter--; } std::cout << "Wartosc licznika: " << counter; return 0; }
Wynik Ile pozdrowien?: 2 Hello! Hello! Wartosc licznika: 0 Ile pozdrowien?: 0 Wartosc licznika: 0
Analiza W linii 10. użytkownik jest proszony o wpisanie wartości początkowej. Ta wartość jest umieszczana w zmiennej całkowitej counter (licznik). Wartość licznika jest sprawdzana w linii 12. i dekrementowana w ciele pętli while. Za pierwszym razem wartość licznika została ustawiona na 2, dlatego ciało pętli while zostało wykonane dwukrotnie. Jednak za drugim razem użytkownik wpisał 0. Wartość licznika została sprawdzona w linii 12. i tym razem warunek nie został spełniony; tj. zmienna counter nie była większa od zera. Zostało więc pominięte całe ciało pętli i komunikat „Hello” nie został wypisany ani razu. Co zrobić komunikat „Hello” został wypisany co najmniej raz? Nie może tego zapewnić pętla while, gdyż jej warunek jest sprawdzany przed wypisywaniem komunikatu. Można to osiągnąć umieszczając instrukcję if przed pętlą while: if (counter < 1) // wymuszamy minimalną wartość counter = 1;
ale to rozwiązanie nie jest zbyt eleganckie.
do...while Pętla do...while (wykonuj...dopóki) wykonuje ciało pętli przed sprawdzeniem warunku i sprawia że instrukcje w pętli zostaną wykonane co najmniej raz. Listing 7.7 stanowi zmodyfikowaną wersję listingu 7.6, w której została użyta pętla do...while. Listing 7.7. Przykład pętli do...while. 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18:
// Listing 7.7 // Demonstruje pętlę do...while #include int main() { using namespace std; int counter; cout << "Ile pozdrowien? "; cin >> counter; do { cout << "Hello\n"; counter--; } while (counter >0 ); cout << "Licznik ma wartosc: " << counter << endl; return 0; }
Wynik Ile pozdrowien? 2 Hello Hello Licznik ma wartosc: 0
Analiza W linii 9. użytkownik jest proszony o wpisanie początkowej wartości, która jest umieszczana w zmiennej counter. W pętli do...while, ciało pętli jest wykonywane przed sprawdzeniem warunku, dlatego w każdym przypadku zostanie wykonane co najmniej raz. W linii 13. wypisywany jest komunikat, w linii 14. dekrementowany jest licznik, zaś dopiero w linii 15. następuje sprawdzenie warunku. Jeśli warunek jest spełniony, wykonanie programu wraca do początku pętli w linii 13.; w przeciwnym razie przechodzi do linii 16. Instrukcje break i continue w pętlach do...while działają tak jak w pętli loop. Jedyna różnica pomiędzy pętlą while a pętlą do...while pojawia się w chwili sprawdzania warunku. Instrukcja do...while
Składnia instrukcji do...while jest następująca: do instrukcja while (warunek);
Wykonywana jest instrukcja, po czym sprawdzany jest warunek. Jeśli warunek jest spełniony, pętla jest powtarzana; w przeciwnym razie jej działanie się kończy. Pod innymi względami instrukcje i warunki są identyczne, jak w pętli while.
Przykład 1 // liczymy do 10 int x = 0; do cout << "X: " << x++; while (x < 10);
Przykład 2 // wypisujemy małe litery alfabetu char ch = 'a'; do { cout << ch << ' '; ch++; } while ( ch <= 'z' );
TAK Używaj pętli do...while, gdy chcesz mieć pewność że pętla zostanie wykonana co najmniej raz. Używaj pętli while, gdy chcesz pominąć pętlę (gdy warunek nie jest spełniony). Sprawdzaj wszystkie pętle, aby mieć pewność, że robią to, czego oczekujesz.
Pętle for Gdy korzystasz z pętli while, ustawiasz warunek początkowy, sprawdzasz, czy jest spełniony, po czym w każdym wykonaniu pętli inkrementujesz lub w inny sposób zmieniasz zmienną kontrolującą jej wykonanie. Demonstruje to listing 7.8. Listing 7.8. Następna pętla while 0: 1: 2: 3: 4: 5: 6: 7: 8:
// Listing 7.8 // Pętla while #include int main() { int counter = 0;
9: 10: 11: 12: 13: 14: 15: 16: 17: 18:
while(counter < 5) { counter++; std::cout << "Petla! }
";
std::cout << "\nLicznik: " << counter << ".\n"; return 0; }
Wynik Petla! Petla! Licznik: 5.
Petla!
Petla!
Petla!
Analiza W linii 8. ustawiany jest warunek: zmienna counter (licznik) ustawiana jest na zero. W linii 10. następuje sprawdzenie, czy licznik jest mniejszy od 5. Inkrementacja licznika odbywa się w linii 12. W linii 16. wypisywany jest prosty komunikat, ale można przypuszczać, że przy każdej inkrementacji licznika można wykonać bardziej konkretną pracę. Pętla for (dla) łączy powyższe trzy etapy w jedną instrukcję. Są to: inicjalizacja, test i inkrementacja. Pętla for składa się ze słowa kluczowego for, po którym następuje para nawiasów. Wewnątrz nawiasów znajdują się trzy, oddzielone średnikami, instrukcje. Pierwsza instrukcja służy do inicjalizacji. Można w niej umieścić każdą poprawną instrukcję języka C++, ale zwykle po prostu tworzy się i inicjalizuje zmienną licznikową. Drugą instrukcją jest test, którym może być każde poprawne wyrażenie języka. Pełni ono taką samą funkcję, jak warunek w pętli while. Trzecia instrukcja jest działaniem. Zwykle w jego wyniku wartość zmiennej licznikowej jest zwiększana lub zmniejszana, ale oczywiście można tu zastosować każdą poprawną instrukcję języka C++. Zwróć uwagę, że instrukcje pierwsza i trzecia mogą być dowolnymi instrukcjami, lecz druga instrukcja musi być wyrażeniem — czyli instrukcją języka C++, zwracającą wartość. Pętlę for demonstruje listing 7.9. Listing 7.9. Przykład pętli for 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14:
// Listing 7.9 // Pętla for #include int main() { int counter; for (counter = 0; counter < 5; counter++) std::cout << "Petla! "; std::cout << "\nLicznik: " << counter << ".\n"; return 0; }
Wynik Petla! Petla! Petla! Petla! Petla!
Licznik: 5.
Analiza Instrukcja for w linii 9. łączy w sobie inicjalizację zmiennej counter, sprawdzenie, czy jej wartość jest mniejsza od 5, oraz inkrementację tej zmiennej. Ciało pętli for znajduje się w linii 10. Oczywiście, w tym miejscu mógłby zostać użyty blok instrukcji. Składnia pętli for
Składnia instrukcji for jest następująca: for (inicjalizacja; test; akcja ) instrukcja;
Instrukcja inicjalizacja jest używana w celu zainicjalizowania stanu licznika lub innego przygotowania do wykonania pętli. Instrukcja test jest dowolnym wyrażeniem języka C++, które i jest obliczane przed każdym wykonaniem zawartości pętli. Jeśli wyrażenie test ma wartość true, wykonywane jest ciało pętli, po czym wykonywana jest instrukcja akcja z nagłówka pętli (zwykle po prostu następuje inkrementacja zmiennej licznikowej).
Przykład 1 // dziesięć razy wpisuje napis "Hello" for (int i = 0; i < 10; i++) cout << "Hello! ";
Przykład 2 for (int i = 0; i < 10; i++) { cout << "Hello!" << endl; cout << "wartoscia i jest: " << i << endl; }
Zaawansowane pętle for Instrukcje for są wydajne i działają w sposób elastyczny. Trzy niezależne instrukcje (inicjalizacja, test i akcja) umożliwiają stosowanie różnorodnych rozwiązań. Pętla for działa w następującej kolejności: 1.
Przeprowadza inicjalizację.
2.
Oblicza wartość warunku wyrażenie.
3.
Jeśli warunek wyrażenie ma wartość true, wykonuje ciało pętli, a następnie wykonuje instrukcję akcji.
Przy każdym wykonaniu pętli powtarzane są kroki 2 i 3.
Wielokrotna inicjalizacja i inkrementacja Inicjalizowanie więcej niż jednej zmiennej, testowanie złożonego wyrażenia logicznego czy wykonywanie więcej niż jednej instrukcji nie są niczym niezwykłym. Inicjalizacja i akcja mogą być zastąpione kilkoma instrukcjami C++, oddzielonymi od siebie przecinkami. Listing 7.10 przedstawia inicjalizację i inkrementację dwóch zmiennych. Listing 7.10. Przykład instrukcji wielokrotnych w pętli for 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12:
//listing 7.10 // demonstruje wielokrotne instrukcje // w pętli for #include int main() { for (int i=0, j=0; i<3; i++, j++) std::cout << "i: " << i << " j: " << j << std::endl; return 0; }
Wynik i: 0 j: 0 i: 1 j: 1 i: 2 j: 2
Analiza W linii 9. dwie zmienne, i oraz j, są inicjalizowane wartością 0. Obliczany jest test (i < 3); ponieważ jest prawdziwy, wykonywane jest ciało pętli for, w którym wypisywane są wartości zmiennych. Na koniec wykonywana jest trzecia klauzula instrukcji for, w której są inkrementowane zmienne i oraz j. Po wykonaniu linii 10., warunek jest sprawdzany ponownie, jeśli wciąż jest spełniony, działania się powtarzają (inkrementowane są zmienne i oraz j) i ponownie wykonywane jest ciało pętli. Dzieje się tak do momentu, w którym warunek nie będzie spełniony; wtedy nie jest wykonywana instrukcja akcji, a działanie programu wychodzi z pętli.
Puste instrukcje w pętli for Każdą z instrukcji w nagłówku pętli for można pominąć. W tym celu należy oznaczyć jej położenie średnikiem (;). Aby stworzyć pętlę for, która działa dokładnie tak, jak pętla while, pomiń pierwszą i trzecią instrukcję. Przedstawia to listing 7.11. Listing 7.11. Puste instrukcje w nagłówku pętli for 0: 1: 2: 3: 4: 5: 6: 7: 8:
// Listing 7.11 // Pętla for z pustymi instrukcjami #include int main() { int counter = 0;
9: 10: 11: 12: 13: 14: 15: 16: 17: 19: }
for( ; counter < 5; ) { counter++; std::cout << "Petla! }
";
std::cout << "\nLicznik: " << counter << ".\n"; return 0;
Wynik Petla! Petla! Licznik: 5.
Petla!
Petla!
Petla!
Analiza Być może poznajesz, że ta pętla wygląda dokładnie tak, jak pętla while z listingu 7.8. W linii 8. inicjalizowana jest zmienna counter. Instrukcja for w linii 10. nie inicjalizuje żadnych wartości, lecz zawiera test warunku counter < 5. Nie występuje także instrukcja inkrementacji, więc ta pętla działa dokładnie tak samo, gdybyśmy napisali: while (counter < 5)
Jak już wiesz, C++ oferuje kilka sposobów osiągnięcia tego samego celu. Żaden doświadczony programista C++ nie użyłby pętli for w ten sposób, przykład ten ilustruje jedynie elastyczność instrukcji for. W rzeczywistości, dzięki zastosowaniu instrukcji break i continue, istnieje możliwość stworzenia pętli for bez żadnej instrukcji w nagłówku. Pokazuje to listing 7.12. Listing 7.12. Instrukcja for z pustym nagłówkiem 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23:
//Listing 7.12 ilustruje //instrukcję for z pustym nagłówkiem #include int main() { int counter=0; // inicjalizacja int max; std::cout << "Ile pozdrowień?"; std::cin >> max; for (;;) // pętla, która się nie kończy { if (counter < max) // test { std::cout << "Hello!\n"; counter++; // inkrementacja } else break; } return 0; }
Wynik Ile pozdrowien?3 Hello! Hello! Hello!
Analiza Z tej pętli usunęliśmy wszystko, co się dało. Inicjalizacja, test i akcja zostały przeniesione poza instrukcję for. Inicjalizacja odbywa się w linii 8., przed pętlą for. Test jest przeprowadzany w osobnej instrukcji if, w linii 14., i gdy się powiedzie, w linii 17. jest wykonywana akcja, czyli inkrementacja zmiennej counter. Jeśli warunek nie jest spełniony, w linii 20. następuje wyjście z pętli (spowodowane użyciem instrukcji break). Choć program ten jest nieco absurdalny, jednak czasem pętle for(;;) lub while(true) są właśnie tym, czego nam potrzeba. Bardziej sensowny przykład wykorzystania takiej pętli zobaczysz w dalszej części rozdziału, przy okazji omawiania instrukcji switch.
Puste pętle for Ponieważ w samym nagłówku pętli for można wykonać tak wiele pracy, więc czasem ciało pętli może już niczego nie robić. Dlatego pamiętaj o zastosowaniu instrukcji pustej (;) jako ciała funkcji. Średnik może zostać umieszczony w tej samej linii, co nagłówek pętli, ale wtedy łatwo go przeoczyć. Użycie pętli for z pustym ciałem przedstawia listing 7.13. Listing 7.13. Instrukcja pusta w ciele pętli for. 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11:
//Listing 7.13 //Demonstruje instrukcję pustą // w ciele pętli for #include int main() { for (int i = 0; i<5; std::cout << "i: " << i++ << std::endl) ; return 0; }
Wynik i: i: i: i: i:
0 1 2 3 4
Analiza Pętla for w linii 8. zawiera trzy instrukcje. Instrukcja inicjalizacji definiuje i inicjalizuje zmienną licznikową i wartością 0. Instrukcja warunku sprawdza, czy i < 5, zaś instrukcja akcji wypisuje wartość zmiennej i oraz inkrementuje ją.
Ponieważ ciało pętli nie wykonuje żadnych czynności, użyto w nim instrukcji pustej (;). Zwróć uwagę, że ta pętla for nie jest najlepiej zaprojektowana: instrukcja akcji wykonuje zbyt wiele pracy. Lepiej więc byłoby zmienić tę pętlę w następujący sposób: 8: 9:
for (int i = 0; i<5; i++) std::cout << "i: " << i << std::endl;
Choć obie wersje działają tak samo, druga z nich jest łatwiejsza do zrozumienia.
Pętle zagnieżdżone Pętle mogą być zagnieżdżone, tj. pętla może znajdować się w ciele innej pętli. Pętla wewnętrzna jest wykonywana wielokrotnie, przy każdym wykonaniu pętli zewnętrznej. Listing 7.14 przedstawia zapisywanie znaczników do macierzy, za pomocą zagnieżdżonych pętli for. Listing 7.14. Zagnieżdżone pętle for 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23:
//Listing 7.14 //Ilustruje zagnieŜdŜone pętle for #include int main() { using namespace std; int rows, columns; char theChar; cout << "Ile wierszy? "; cin >> rows; cout << "Ile kolumn? "; cin >> columns; cout << "Jaki znak? "; cin >> theChar; for (int i = 0; i
Wynik Ile wierszy? 4 Ile kolumn? 12 Jaki znak? x xxxxxxxxxxxx xxxxxxxxxxxx xxxxxxxxxxxx xxxxxxxxxxxx
Analiza
Użytkownik jest proszony o podanie ilości wierszy i kolumn oraz znaku, jaki ma zostać użyty do wypisania wydrukowania zarysu macierzy. Pierwsza pętla for, w linii 16., inicjalizuje ustawia wartość początkową licznika (i) na 0, po czym przechodzi do wykonania ciała zewnętrznej pętli. W linii 18., pierwszej linii ciała zewnętrznej pętli for, tworzona jest kolejna pętla for. Jest w niej inicjalizowany drugi licznik (j), także wartością 0, po czym program przechodzi do wykonania ciała pętli wewnętrznej. W linii 19. wypisywany jest wybrany znak, a program wraca do nagłówka wewnętrznej pętli. Zwróć uwagę, że wewnętrzna pętla for posiada tylko jedną instrukcję (wypisującą znak). Gdy sprawdzany warunek jest spełniony (j < columns), zmienna j jest inkrementowana i wypisywany jest następny znak. Czynność powtarzana jest tak długo, aż j zrówna się z ilością kolumn. Gdy warunek w wewnętrznej pętli nie zostanie spełniony (w tym przypadku po wypisaniu dwunastu znaków), wykonanie programu przechodzi do linii 20., w której wypisywany jest znak nowej linii. Następuje powrót do nagłówka pętli zewnętrznej, w którym odbywa się sprawdzenie warunku (i < rows). Jeśli ten warunek zostaje spełniony, zmienna i jest inkrementowana i ponownie wykonywane jest ciało pętli. W drugiej iteracji zewnętrznej pętli for ponownie rozpoczyna się wykonanie pętli wewnętrznej. Zmiennej j ponownie przypisywana jest wartość 0 i cała pętla wykonywana jest jeszcze raz. Powinieneś zwrócić uwagę, że wewnętrzna pętla jest wykonywana w całości przy każdym wykonaniu pętli zewnętrznej. Dlatego wypisywanie znaku powtarza się (columns · rows) razy. UWAGA Wielu programistów oznacza nadaje zmiennym licznikowym nazwy i oraz j. Ta tradycja sięga czasów języka FORTRAN, w którym jedynymi zmiennymi licznikowymi były zmienne i, j, k, l, m oraz n.
Inni programiści wolą używać dla zmiennych licznikowych bardziej opisowych nazw, takich jak licznik1 czy licznik2. Jednak zmienne i oraz j są tak popularne, że nie powodują żadnych nieporozumień, gdy zostaną użyte w nagłówkach pętli for.
Zakres zmiennych w pętlach for W przeszłości zakres zmiennych zadeklarowanych w pętlach for rozciągał się także na blok zewnętrzny. Standard ANSI ograniczył ten zakres do bloku pętli for (nie gwarantują tego jednak wszystkie kompilatory). Możesz sprawdzić swój kompilator za pomocą poniższego kodu: #inlude int main() { // czy i ogranicza się tylko do pętli for? for (int i = 0; i < 5; i++) { std::cout << "i: " << i << std::endl; } i = 7; // nie powinno być w tym zakresie! return 0;
}
Jeśli kod skompiluje się bez kłopotów, oznacza to, że twój kompilator nie obsługuje tego aspektu standardu ANSI. Jeśli kompilator zaprotestuje, że i nie jest zdefiniowane (w linii i = 7), oznacza to, że obsługuje nowy standard. Aby stworzyć kod, który skompiluje się za każdym razem, możesz zmienić go następująco: #inlude int main() { int i; // zadeklarowane poza pętlą for (i = 0; i < 5; i++) { std::cout << "i: " << i << std::endl; } i = 7; // teraz jest w zakresie w kaŜdym kompilatorze return 0; }
Podsumowanie pętli W rozdziale 5., „Funkcja,” nauczyłeś się, jak rozwiązywać problem ciągu Fibonacciego za pomocą rekurencji. Dla przypomnienia: ciąg Fibonacciego rozpoczyna się od wyrazów 1, 1, 2, 3..., zaś każde kolejne wyrazy stanowią sumę dwóch poprzednich:
1, 1, 2, 3, 5, 8, 13, 21, 34...
N-ty wyraz ciągu jest sumą wyrazów n-1 i n-2. Problemem rozwiązywanym w rozdziale piątym było obliczenie wartości n-tego wyrazu ciągu. W tym celu używaliśmy rekurencji. Tym razem, jak pokazuje listing 7.15, użyjemy iteracji. Listing 7.15. Obliczanie wyrazów ciągu Fibonacciego za pomocą iteracji. 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11:
// Listing 7.15 // Demonstruje obliczanie wartości n-tego // wyrazu ciągu Fibonacciego za pomocą iteracji #include int fib(int position); int main() { using namespace std; int answer, position; cout << "Ktory wyraz ciagu? ";
12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36:
cin >> position; cout << "\n"; answer = fib(position); cout << position << " wyraz ciagu Fibonacciego "; cout << "ma wartosc " << answer << ".\n"; return 0; } int fib(int n) { int minusTwo=1, minusOne=1, answer=2; if (n < 3) return 1; for (n -= 3; { minusTwo minusOne answer = }
n; n--) = minusOne; = answer; minusOne + minusTwo;
return answer; }
Wynik Ktory wyraz ciagu? 4 4 wyraz ciagu Fibonacciego ma wartosc 3. Ktory wyraz ciagu? 5 5 wyraz ciagu Fibonacciego ma wartosc 5. Ktory wyraz ciagu? 20 20 wyraz ciagu Fibonacciego ma wartosc 6765. Ktory wyraz ciagu? 30 30 wyraz ciagu Fibonacciego ma wartosc 832040.
Analiza W listingu 7.15 obliczyliśmy wartości wyrazów ciągu Fibonacciego, stosując iterację zamiast rekurencji. Ta metoda jest szybsza i zajmuje mniej pamięci niż rekurencja. W linii 11. użytkownik jest proszony o podanie numeru wyrazu ciągu. Następuje wywołanie funkcji fib(), która oblicza wartość tego wyrazu. Jeśli numer wyrazu jest mniejszy od 3, funkcja zwraca wartość 1. Począwszy od trzeciego wyrazu, funkcja iteruje (działa w pętli), używając następującego algorytmu: 1.
Ustawia pozycję wyjściową: przypisuje zmiennej answer (wynik) wartość 2, zaś zmiennym minusTwo (minus dwa) i minusOne (minus jeden) wartość 1. Zmniejsza numer wyrazu o trzy, gdyż pierwsze dwa wyrazy zostały już obsłużone przez pozycję wyjściową.
2.
Dla każdego wyrazu, aż do wyrazu poszukiwanego, obliczana jest wartość ciągu Fibonacciego. Odbywa się to poprzez: a.
Przypisanie bieżącej wartości zmiennej minusOne do zmiennej minusTwo.
b.
Przypisanie bieżącej wartości zmiennej answer do zmiennej minusOne.
3.
c.
Zsumowanie wartości zmiennych minusOne oraz minusTwo i przypisanie tej sumy zmiennej answer.
d.
Dekrementację zmiennej licznikowej n.
Gdy zmienna n osiągnie zero, funkcja zwraca wartość zmiennej answer.
Dokładnie tak samo rozwiązywalibyśmy ten problem na papierze. Gdybyś został poproszony o podanie wartości piątego wyrazu ciągu Fibonacciego, napisałbyś:
1, 1, 2,
i pomyślałbyś: „jeszcze dwa wyrazy.” Następnie dodałbyś 2+1 i dopisałbyś 3, myśląc: „Jeszcze jeden.” Na koniec dodałbyś 3+2 i otrzymałbyś w wyniku 5. Rozwiązanie tego zadania polega na każdorazowym przesuwaniu operacji sumowania w prawo i zmniejszaniu ilości pozostałych do obliczenia wyrazów ciągu. Zwróć uwagę na warunek sprawdzany w linii 28. (n). Jest to idiom języka C++, który stanowi odpowiednik n != 0. Ta pętla for jest wykonywana, dopóki wartość n nie osiągnie zera (które odpowiada wartości logicznej false). Zatem nagłówek tej pętli for mógłby zostać przepisany następująco: for (n -=3; n != 0; n--)
dzięki temu pętla byłaby bardziej czytelna. Jednak ten idiom jest tak popularny, że nie ma sensu z nim walczyć. Skompiluj, zbuduj i uruchom ten program, po czym porównaj jego działanie z korzystającym z rekurencji programem z rozdziału piątego. Spróbuj obliczyć wartość 25. wyrazu ciągu i porównaj czas działania obu programów. Rekurencja to elegancka metoda, ale ponieważ z wywołaniem funkcji wiąże się pewien narzut, i ponieważ jest ona wywoływana tak wiele razy, metoda ta jest wolniejsza od iteracji. Wykonywanie operacji arytmetycznych na mikrokomputerach zostało zoptymalizowane, dlatego rozwiązania iteracyjne powinny działać bardzo szybko. Uważaj, by nie wpisać zbyt wysokiego numeru wyrazu ciągu. Ciąg Fibonacciego rośnie bardzo szybko i nawet przy niewielkich wartościach zmienne całkowite typu long zostają przepełnione.
Instrukcja Switch Z rozdziału 4. dowiedziałeś się jak korzystać z instrukcji if i else. Gdy zostaną one zbyt głęboko zagnieżdżone, stają się całkowicie niezrozumiałe. Na szczęście C++ oferuje pewną alternatywę. W odróżnieniu od instrukcji if, ,która sprawdza jedną wartość, instrukcja switch (przełącznik) umożliwia podjęcie działań na podstawie jednej z wielu różnych wartości. Ogólna postać instrukcji switch wygląda następująco:
switch (wyraŜenie) { case wartośćJeden: instrukcja; break; case wartośćDwa: instrukcja; break; .... case wartośćN: instrukcja; break; default: instrukcja; }
wyraŜenie jest dowolnym wyrażeniem języka C++, zaś jego instrukcje są dowolnymi
instrukcjami lub blokami instrukcji, pod warunkiem jednak, że ich wynikiem jest liczba typu integer (lub jej wynik jest jednoznacznie konwertowalny do takiej liczby). Wartości muszą być stałymi (literałami lub wyrażeniami o stałej wartości). Należy jednak również pamiętać, że instrukcja switch sprawdza jedynie czy wartośćajedynie równość wyraŜeniaodpowiada którejś z wartości; nie można stosować operatorów relacji ani operacji logicznych. Jeśli któraś z wartości case jest równa wartości wyraŜenia, program przechodzi do instrukcji tuż po tej wartości w związanej z nią klauzulicase i jego wykonanie jest kontynuowane aż do napotkania instrukcji break (przerwij). Jeśli wartość wyraŜenia nie pasuje do żadnej z wartości klauzul case, wykonywana jest klauzulainstrukcja default (domyślnay). Jeśli klauzula nie występuje default i wartość wyraŜenia nie pasuje do żadnej z wartości klauzul case, instrukcjaa switch jest pomijana nie spowoduje żadnej akcji i program przechodzi do następnych instrukcji w kodzie. UWAGA Stosowanie w instrukcji switch klauzuliprzypadku default jest dobrym pomysłem. Jeśli nie znajdziesz dla niegoj innego zastosowania, użyj gojej do wykrycia sytuacji, której wystąpienie nie było przewidziane; wypisz wtedy odpowiedni komunikat błędu. Może to być bardzo pomocne podczas debuggowania programu.
Należy pamiętać, że w przypadku braku instrukcji break na końcu bloku instrukcji w (klauzulipo case), wykonanie przechodzi także do następnegoj przypadku klauzuli case. Czasem takie działanie jest zamierzone, ale zwykle jest po prostu błędem. Jeśli zdecydujesz się na wykonanie instrukcji w kilku kolejnych klauzulachprzypadkach case, pamiętaj o umieszczeniu obok komentarza, który wyjaśni, że nie pominąłeś instrukcji break przypadkowo. Listing 7.16 przedstawia użycie instrukcji switch. Listing 7.16. Przykład instrukcji switch 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11:
//Listing 7.16 // Demonstruje instrukcję switch #include int main() { using namespace std; unsigned short int number; cout << "Wpisz liczbe pomiedzy 1 i 5: "; cin >> number; switch (number)
12: 13: 14: 15: dalej 16: dalej 17: dalej 18: dalej 19: 20: 21: 22: 23: 24: 25: 26: }
{ case 0: case 5:
cout << "Za mala, przykro mi!"; break; cout << "Dobra robota!\n"; // przejście
case 4:
cout << "Niezle!\n";
// przejście
case 3:
cout << "Wysmienicie!\n";
// przejście
case 2:
cout << "Cudownie!\n";
// przejście
case 1:
cout << "Niesamowicie!\n"; break; cout << "Zbyt duza!\n"; break;
default:
} cout << "\n\n"; return 0;
Wynik Wpisz liczbe pomiedzy 1 i 5: 3 Wysmienicie! Cudownie! Niesamowicie! Wpisz liczbe pomiedzy 1 i 5: 8 Zbyt duza!
Analiza Użytkownik jest proszony o podanie liczby. Ta liczba jest przekazywana do instrukcji switch. Jeśli ma wartość 0, odpowiada klauzuli case w linii 13., dlatego jest wypisywany komunikat: „Za mala, przykro mi!”, po czym instrukcja break kończy działanie instrukcji switch. Jeśli liczba ma wartość 5, wykonanie przechodzi do linii 15., w której wypisywany jest odpowiedni komunikat, po czym przechodzi do linii 16., w której wypisywany jest kolejny komunikat, i tak dalej, aż do napotkania instrukcji break w linii 20. Efektem działania tej instrukcji switch dla liczb pomiędzy 1 a 5 jest wypisanie odpowiadającej ilości komunikatów. Jeśli wartością liczby nie jest ani 0 ani 5, zakłada się, że jest ona zbyt duża i w takim przypadku w linii 21. wykonywana jest instrukcja klauzuli default.
Instrukcja switch
Składnia instrukcji switch jest następująca: switch (wyraŜenie) { case wartośćJeden: instrukcja; case wartośćDwa: instrukcja; .... case wartośćN: instrukcja;
default: instrukcja; }
Instrukcja switch umożliwia rozgałęzienie programu (w zależności od wartości wyrażenia). Na początku wykonywania instrukcji następuje obliczenie wartości wyraŜenia, gdy odpowiada ona którejś z wartości klauzulprzypadku case, wykonanie programu przechodzi do tego właśnie przypadkudanej klauzuli. Wykonywanie instrukcji jest kontynuowane aż do końca ciała iinstrukcji switch lub do czasu napotkania instrukcji break.
Jeśli wartość wyraŜenia nie odpowiada żadnej z wartości klauzulprzypadków case i występuje przypadek klauzuladefault, wykonanie przechodzi do klauzuliprzypadku default. W przeciwnym razie wykonywanie instrukcji switch się kończy.
Przykład 1 switch (wybor) { case 0: cout << "Zero!" << endl; break; case 1: cout << "Jeden!" << endl; break; case 2: cout << "Dwa!" << endl; break; default: cout << "Domyślna!" << endl; }
Przykład 2 switch (wybor) { case 0: case 1: case 2: cout << "Mniejsza niŜ 3!"; break; case 3: cout << "Równa 3!"; break; default: cout << "Większa niŜ 3!"; }
Użycie instrukcji switch w menu Listing 7.17 wykorzystuje omawianą wcześniej pętli for(;;). Takie pętle są nazywane pętlami nieskończonymi, gdyż są wykonywane bez końca, aż do natrafienia na kończącą ich działanie
instrukcję. Pętla nieskończona jest używana do tworzenia menu, pobrania polecenia od użytkownika, wykonania odpowiednich działań i powrót do menu. Jej działanie powtarza się dopóty, dopóki użytkownik nie zdecyduje się na wyjście z menu. UWAGA Niektórzy programiści wolą pisać: #define EVER ;; for (EVER) { // instrukcje... }
Pętla nieskończona nie posiada warunku wyjścia. Aby opuścić taką pętlę, należy użyć instrukcji break. Pętle nieskończone są także zwane pętlami wiecznymi. Listing 7.17. Przykład pętli nieskończonej 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41:
//Listing 7.17 //UŜywa nieskończonej pętli do //interakcji z uŜytkownikiem #include // prototypy int menu(); void DoTaskOne(); void DoTaskMany(int); using namespace std; int main() { bool exit = false; for (;;) { int choice = menu(); switch(choice) { case (1): DoTaskOne(); break; case (2): DoTaskMany(2); break; case (3): DoTaskMany(3); break; case (4): continue; // nadmiarowa! break; case (5): exit=true; break; default: cout << "Prosze wybrac ponownie!\n"; break; } // koniec instrukcji switch if (exit) break;
42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52: 53: 54: 55: 56: 57: 58: 59: 60: 61: 62: 63: 64: 65: 66: 67: 68: 69: 70: 71: 72:
} return 0; }
// koniec pętli for(;;) // koniec main()
int menu() { int choice; cout << " **** Menu ****\n\n"; cout << "(1) Pierwsza opcja.\n"; cout << "(2) Druga opcja.\n"; cout << "(3) Trzecia opcja.\n"; cout << "(4) Ponownie wyswietl menu.\n"; cout << "(5) Wyjscie.\n\n"; cout << ": "; cin >> choice; return choice; } void DoTaskOne() { cout << "Opcja pierwsza!\n"; } void DoTaskMany(int which) { if (which == 2) cout << "Opcja druga!\n"; else cout << "Opcja trzecia!\n"; }
Wynik **** Menu **** (1) (2) (3) (4) (5)
Pierwsza opcja. Druga opcja. Trzecia opcja. Ponownie wyswietl menu. Wyjscie.
: 1 Opcja pierwsza! **** Menu **** (1) (2) (3) (4) (5)
Pierwsza opcja. Druga opcja. Trzecia opcja. Ponownie wyswietl menu. Wyjscie.
: 3 Opcja trzecia! **** Menu **** (1) Pierwsza opcja. (2) Druga opcja.
(3) Trzecia opcja. (4) Ponownie wyswietl menu. (5) Wyjscie. : 5
Analiza Ten program łączy w sobie kilka zagadnień omawianych w tym i poprzednich rozdziałach. Oprócz tego przedstawia popularne zastosowanie instrukcji switch. W linii 15. zaczyna się pętla nieskończona. Wywoływana jest w niej funkcja menu(), wypisująca na ekranie menu i zwracająca numer polecenia wybranego przez użytkownika. Na podstawie tego numeru polecenia, instrukcja switch (zajmująca linie od 18. do 38.) wywołuje odpowiednią funkcję obsługi polecenia. Gdy użytkownik wybierze polecenie 1., następuje „skok” do instrukcji case 1: w linii 20. W linii 21. wykonanie przechodzi do funkcji DoTaskOne() (wykonaj zadanie 1.), wypisującej komunikat i zwracającej sterowanie. Po powrocie z tej funkcji program wznawia działanie od linii 22., w której instrukcja break kończy działanie instrukcji switch, co powoduje przejście do linii 39. W linii 40. sprawdzana jest wartość zmiennej exit (wyjście). Jeśli wynosi true, w linii 41. wykonywana jest instrukcja break, powodująca wyjście z pętli for(;;); jeśli zmienna ma wartość false, program wraca do początku pętli w linii 15. Zwróć uwagę, że instrukcja continue w linii 30. jest nadmiarowa. Gdybyśmy ją pominęli i napotkali instrukcję break, instrukcja switch zakończyłaby działanie, zmienna exit miałaby wartość false, pętla zostałaby wykonana ponownie, a menu zostałoby wypisane ponownie. Jednak dzięki tej instrukcji continue może pominąćmożna pominąć sprawdzanie zmiennej exit.
TAK
NIE
Aby uniknąć głęboko zagnieżdżonych instrukcji Nie zapominaj o instrukcji break na końcu if, używaj instrukcji switch. każdegoj przypadku klauzuli case, chyba że celowo chcesz by program przeszedł Pieczołowicie dokumentuj wszystkie bezpośrednio dalej. zamierzone przejścia pomiędzy klauzulamiprzypadkami case. W instrukcjach switch stosuj klauzuleprzypadek default, choćby do wykrycia sytuacji pozornie niemożliwej.
Program podsumowujący wiadomości {uwaga skład: jest to zawartość rozdziału „Week 1 In Review” } Listing 7.18. Program podsumowujący wiadomości 0:
#include
1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52: 53: 54: 55: 56: 57: 58: 59: 60: 61:
using namespace std; enum CHOICE { DrawRect = 1, GetArea, GetPerim, ChangeDimensions, Quit}; // Deklaracja klasy Rectangle class Rectangle { public: // konstruktory Rectangle(int width, int height); ~Rectangle(); // akcesory int GetHeight() const { return itsHeight; } int GetWidth() const { return itsWidth; } int GetArea() const { return itsHeight * itsWidth; } int GetPerim() const { return 2*itsHeight + 2*itsWidth; } void SetSize(int newWidth, int newHeight); // Inne metody
private: int itsWidth; int itsHeight; }; // Implementacja metod klasy void Rectangle::SetSize(int newWidth, int newHeight) { itsWidth = newWidth; itsHeight = newHeight; }
Rectangle::Rectangle(int width, int height) { itsWidth = width; itsHeight = height; } Rectangle::~Rectangle() {} int DoMenu(); void DoDrawRect(Rectangle); void DoGetArea(Rectangle); void DoGetPerim(Rectangle); int main () { // inicjalizujemy prostokąt jako 30,5 Rectangle theRect(30,5); int choice = DrawRect; int fQuit = false; while (!fQuit) { choice = DoMenu(); if (choice < DrawRect || choice > {
Quit)
62: 63: 64: 65: 66: 67: 68: 69: 70: 71: 72: 73: 74: 75: 76: 77: 78: 79: 80: 81: 82: 83: 84: 85: 86: 87: 88: 89: 90: 91: 92: 93: 94: 95: 96: 97: 98: 99: 100: 101: 102: 103: 104: 105: 106: 107: 108: 109: 110: 111: 112: 113: 114: 115: 116: 117: 118: 119: 120: 121: 122:
cout << "\nBledny wybor, prosze sprobowac ponownie.\n\n"; continue; } switch (choice) { case DrawRect: DoDrawRect(theRect); break; case GetArea: DoGetArea(theRect); break; case GetPerim: DoGetPerim(theRect); break; case ChangeDimensions: int newLength, newWidth; cout << "\nNowa szerokosc: "; cin >> newWidth; cout << "Nowa wysokosc: "; cin >> newLength; theRect.SetSize(newWidth, newLength); DoDrawRect(theRect); break; case Quit: fQuit = true; cout << "\nWyjscie...\n\n"; break; default: cout << "Blad wyboru!\n"; fQuit = true; break; } // koniec instrukcji switch } // koniec petli while return 0; } // koniec funkcji main int DoMenu() { int choice; cout << "\n\n *** Menu *** \n"; cout << "(1) Rysuj prostokat\n"; cout << "(2) Obszar\n"; cout << "(3) Obwod\n"; cout << "(4) Zmien rozmiar\n"; cout << "(5) Wyjscie\n"; cin >> choice; return choice; } void DoDrawRect(Rectangle theRect) { int height = theRect.GetHeight(); int width = theRect.GetWidth(); for (int i = 0; i
123: 124: 125: 126: 127: 128: 129: 130: 131: 132: 133: 134:
}
void DoGetArea(Rectangle theRect) { cout << "Obszar: " << theRect.GetArea() << endl; } void DoGetPerim(Rectangle theRect) { cout << "Obwod: " << theRect.GetPerim() << endl; }
Wynik *** Menu *** (1) Rysuj prostokat (2) Obszar (3) Obwod (4) Zmien rozmiar (5) Wyjscie 1 ****************************** ****************************** ****************************** ****************************** ******************************
*** Menu *** (1) Rysuj prostokat (2) Obszar (3) Obwod (4) Zmien rozmiar (5) Wyjscie 2 Obszar: 150
*** Menu *** (1) Rysuj prostokat (2) Obszar (3) Obwod (4) Zmien rozmiar (5) Wyjscie 3 Obwod: 70
(1) (2) (3) (4) (5)
*** Menu *** Rysuj prostokat Obszar Obwod Zmien rozmiar Wyjscie
4 Nowa szerokosc: 10 Nowa wysokosc: 8 ********** ********** ********** ********** ********** ********** ********** **********
*** Menu *** (1) Rysuj prostokat (2) Obszar (3) Obwod (4) Zmien rozmiar (5) Wyjscie 2 Obszar: 80
*** Menu *** (1) Rysuj prostokat (2) Obszar (3) Obwod (4) Zmien rozmiar (5) Wyjscie 3 Obwod: 36
(1) (2) (3) (4) (5) 5
*** Menu *** Rysuj prostokat Obszar Obwod Zmien rozmiar Wyjscie
Wyjscie...
Analiza Ten program wykorzystuje większość wiadomości, jakie zdobyłeś czytając poprzednie rozdziały. Powinieneś umieć wpisać, skompilować, połączyć i uruchomić program, a ponadto zrozumieć w jaki sposób działa (pod warunkiem że uważnie czytałeś dotychczasowe rozdziały). Sześć pierwszych linii przygotowuje nowe typy i definicje, które będą używane w programie. W liniach od 6. do 26. jest zadeklarowana klasa Rectangle (prostokąt). Zawiera ona publiczne akcesory przeznaczone do odczytywania i ustawiania wysokości i szerokości prostokąta, a także
metody obliczania jego obszaru i obwodu. Linie od 29. do 40. zawierają definicje tych funkcji klasy, które nie zostały zdefiniowane inline. Prototypy funkcji dla funkcji globalnych znajdują się w liniach od 44. do 47., zaś sam program zaczyna się w linii 49. Działanie programu polega na wygenerowaniu prostokąta, a następnie wypisaniu menu, zawierającego pięć opcji: rysowanie prostokąta, obliczanie jego obszaru, obliczanie jego obwodu, zmiana rozmiarów prostokąta oraz wyjście. W linii 55. ustawiany jest znacznik (flaga); jeśli wartością tego znacznika jest false, działanie pętli jest kontynuowane. Wartość true jest przypisywana do tego znacznika tylko wtedy, gdy użytkownik wybierze z menu polecenie Wyjście. Inne opcje, z wyjątkiem Zmień rozmiar, wywołują odpowiednie funkcje. Dzięki temu działanie instrukcji switch jest bardziej przejrzyste. Opcja Zmień rozmiar nie może wywoływać funkcji, gdyż zmieniłoby to rozmiary prostokąta. Jeśli prostokąt zostałby przekazany (przez wartość) do funkcji takiej, jak na przykład DoChangeDimensions() (zmień rozmiary), wtedy rozmiary zostałyby zmienione jedynie w lokalnej kopii prostokąta w tej funkcji i nie zostałyby odzwierciedlone w prostokącie w funkcji main(). Z rozdziału 8., „Wskaźniki,” oraz rozdziału 10., „Funkcje zaawansowane,” dowiesz się, w jaki sposób ominąć to ograniczenie. Na razie jednak zmiana rozmiarów odbywa się bezpośrednio w funkcji main(). Zwróć uwagę, że użycie wyliczeniatypu wyliczeniowego sprawiło, że instrukcja switch jest bardziej przejrzysta i łatwiejsza do zrozumienia. Gdyby przełączanie zależało od liczb (1 – 5) wybranych przez użytkownika, musiałbyś stale zaglądać do opisu menu, aby dowiedzieć się, do czego służy dana opcja. W linii 60. następuje sprawdzenie, czy opcja wybrana przez użytkownika mieści się w dozwolonym zakresie. Jeśli nie, jest wypisywany komunikat błędu i następuje odświeżenie (czyli ponowne wypisanie) menu. Zauważ, że instrukcja switch posiada „niemożliwyą” klauzulęprzypadek default. Stanowi ona pomoc przy debuggowaniu. Gdy program działa, instrukcja ta nigdy nie powinna zostać wykonana.
Rozdział 8. Wskaźniki Jedną z najbardziej przydatnych dla programisty C++ rzeczy jest możliwość bezpośredniego manipulowania pamięcią za pomocą wskaźników. Z tego rozdziału dowiesz się: •
czym są wskaźniki,
•
jak deklarować wskaźniki i używać ich,
•
czym jest sterta i w jaki sposób można manipulować pamięcią.
Wskaźniki stanowią podwójne wyzwanie dla osoby uczącej się języka C++: po pierwsze, mogą być niezrozumiałe, a po drugie, na początku może nie być jasne, do czego mogą się przydać. W tym rozdziale krok po kroku wyjaśnimy działanie wskaźników. Aby w pełni zrozumieć potrzebę ich używania, musisz zapoznać się z zawartością kolejnych rozdziałów.
Czym jest wskaźnik? Wskaźnik (ang. pointer) jest zmienną, przechowującą adres pamięci. To wszystko. Jeśli rozumiesz to proste stwierdzenie, wiesz już wszystko o wskaźnikach. Jeszcze raz: wskaźnik jest zmienną przechowującą adres pamięci.
Kilka słów na temat pamięci Aby zrozumieć, do czego służą wskaźniki, musisz wiedzieć kilka rzeczy o pamięci komputera. Pamięć jest podzielona na kolejno numerowane lokalizacje. Każda zmienna umieszczona jest w danym miejscu pamięci, jednoznacznie określonym przez tzw. adres pamięci. Rysunek 8.1 przedstawia schemat miejsca przechowywania zmiennej typu unsigned long o nazwie theAge (wiek). Rys. 8.1. Schemat przechowywania zmiennej theAge
Użycie operatora adresu (&) W każdym komputerze pamięć jest adresowana w inny sposób, za pomocą różnych, złożonych schematów. Zwykle programista nie musi znać konkretnego adresu danej zmiennej, tymi szczegółami zajmuje się kompilator. Jeśli jednak chcesz uzyskać tę informację, możesz użyć operatora adresu (&), który zwraca adres obiektu znajdującego się w pamięci. Jego wykorzystanie przedstawiono na listingu 8.1. Listing 8.1. Przykład użycia operatora adresu 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24:
// Listing 8.1 Demonstruje operator adresu // oraz adresy zmiennych lokalnych #include int main() { using namespace std; unsigned short shortVar=5; unsigned long longVar=65535; long sVar = -65535; cout << "shortVar:\t" << shortVar; cout << "\tAdres zmiennej shortVar:\t"; cout << &shortVar << "\n"; cout << "longVar:\t" << longVar; cout << "\tAdres zmiennej longVar:\t" ; cout << &longVar << "\n"; cout << "sVar:\t\t" << sVar; cout << "\tAdres zmiennej sVar:\t" ; cout << &sVar << "\n"; return 0;
25:
}
Wynik shortVar: 0012FF7C longVar: 0012FF78 sVar: 0012FF74
5
Adres zmiennej shortVar:
65535
Adres zmiennej longVar:
-65535
Adres zmiennej sVar:
Analiza Tworzone i inicjalizowane są trzy zmienne: typu unsigned short w linii 8., typu unsigned long w linii 9. oraz long w linii 10. Ich wartości i adresy są wypisywane w liniach od 12 do 16. Adresy zmiennych uzyskiwane są za pomocą operatora adresu (&). Wartością zmiennej shortVar (krótka zmienna) jest 5 (tak jak można było oczekiwać). W moim komputerze Pentium (32-bitowym) ta zmienna ma adres 0012FF7C. Adres zależy od komputera i może być nieco inny przy każdym uruchomieniu programu. W twoim komputerze adresy tych zmiennych także mogą się różnić. Deklarując typ zmiennej, informujesz kompilator, ile miejsca w pamięci powinien dla niej zarezerwować, jednak adres jest przydzielany zmiennej automatycznie. Na przykład długie (long) zmienne całkowite zajmują zwykle cztery bajty, co oznacza, że zmienna posiada adres dla czterech bajtów pamięci. Zwróć uwagę, że twój kompilator, podobnie jak mój, może nalegać na to, by zmienne otrzymywały adresy będące wielokrotnością 4 (tj. zmienna longVar otrzymuje adres położony cztery bajty za zmienną shortVar, mimo iż zmienna shortVar potrzebuje tylko dwóch bajtów!)
Przechowywanie adresu we wskaźniku Każda zmienna posiada adres. Możesz umieścić go we wskaźniku nawet bez znajomości adresu danej zmiennej. Przypuśćmy na przykład, że zmienna howOld jest całkowita. Aby zadeklarować wskaźnik o nazwie pAge, mogący zawierać adres tej zmiennej, możesz napisać: int *pAge = 0;
Spowoduje to zadeklarowanie zmiennej pAge jako wskaźnika do typu int. Innymi słowy, zmienna pAge jest zadeklarowana jako przechowująca adresy wartości całkowitych. Zwróć uwagę, że pAge jest zmienną. Gdy deklarujesz zmienną całkowitą (typu int), kompilator rezerwuje tyle pamięci, ile jest potrzebne do przechowania wartości całkowitej. Gdy deklarujesz zmienną wskaźnikową taką jak pAge, kompilator rezerwuje ilość pamięci wystarczającą do przechowania adresu (w większości komputerów zajmuje on cztery bajty). pAge jest po prostu kolejnym typem zmiennej.
Puste i błędne wskaźniki W tym przykładzie wskaźnik pAge jest inicjalizowany wartością zero. Wskaźnik, którego wartością jest zero, jest nazywany wskaźnikiem pustym (ang. null pointer). Podczas tworzenia wskaźników, powinny być one zainicjalizowane jakąś wartością. Jeśli nie wiesz, jaką wartość przypisać wskaźnikowi, przypisz mu wartość 0. Wskaźnik, który nie jest zainicjalizowany, jest nazywany wskaźnikiem błędnym (ang. wild pointer). Błędne wskaźniki są bardzo niebezpieczne. UWAGA Pamiętaj o zasadzie bezpiecznego programowania: inicjalizuj swoje wskaźniki!
Musisz jawnie przypisać wskaźnikowi adres zmiennej howOld. Poniższy przykład pokazuje, jak to zrobić: unsigned short int howOld = 50; // tworzymy zmienną unsigned short int * pAge = 0; // tworzymy wskaźnik pAge = &howOld; // umieszczamy adres zmiennej hOld w zmiennej pAge
Pierwsza linia tworzy zmienną — howOld typu unsigned short int — oraz inicjalizuje ją wartością 50. Druga linia deklaruje zmienną pAge jako wskaźnik do typu unsigned short int i ustawia ją na zero. To, że zmienna pAge jest wskaźnikiem, można poznać po gwiazdce (*) umieszczonej pomiędzy typem zmiennej a jej nazwą. Trzecia, ostatnia linia, przypisuje wskaźnikowi pAge adres zmiennej howOld. Przypisywanie adresu można poznać po użyciu operatora adresu (&). Gdyby operator adresu został pominięty, wskaźnikowi pAge zostałaby przypisana wartość zmiennej howOld. Oczywiście, wartość ta mogłaby być poprawnym adresem. Teraz wskaźnik pAge zawiera adres zmiennej howOld. Ta zmienna ma wartość 50. Można uzyskać ten rezultat wykonując o jeden krok mniej, na przykład: unsigned short int howOld = 50; // tworzymy zmienną unsigned short int * pAge = &howOld; // tworzymy wskaźnik do howOld
pAge jest wskaźnikiem, zawierającym teraz adres zmiennej howOld. Używając wskaźnika pAge, możesz sprawdzić wartość zmiennej howOld, która w tym przypadku wynosi 50. Dostęp do zmiennej howOld poprzez wskaźnik pAge jest nazywany dostępem pośrednim (dostęp do niej rzeczywiście odbywa się poprzez ten wskaźnik). Z dalszej części rozdziału dowiesz się, jak w ten sposób odwoływać się do wartości zmiennej.
Dostęp pośredni oznacza dostęp do zmiennej o adresie przechowywanym we wskaźniku. Użycie wskaźników stanowi pośredni sposób uzyskania wartości przechowywanej pod danym adresem. UWAGA W przypadku zwykłej zmiennej, jej typ informuje kompilator, ile potrzebuje pamięci do przechowania jej wartości. W przypadku wskaźników sytuacja wygląda inaczej: każdy wskaźnik zajmuje cztery bajty. Typ wskaźnika informuje kompilator, ile potrzeba miejsca w pamięci do przechowania obiektu, którego adres zawiera wskaźnik!
W deklaracji unsigned shot int * pAge = 0;
// tworzymy wskaźnik
zmienna pAge jest zadeklarowana jako wskaźnik do typu unsigned short int. Mówi ona kompilatorowi, że wskaźnik ten (który do przechowania adresu wymaga czterech bajtów) będzie przechowywał adres obiektu typu unsigned short int, który zajmuje dwa bajty.
Nazwy wskaźników Podobnie jak inne zmienne, wskaźniki mogą mieć dowolne nazwy. Wielu programistów przestrzega konwencji, w której nazwy wszystkiech wskaźników poprzedza się literką p (pointer), np. pAge czy pNumber.
Operator wyłuskania Operator wyłuskania (*) jest zwany także operatorem dostępu pośredniego albo dereferencją. Podczas wyłuskiwania wskaźnika otrzymywana jest wartość wskazywana przez adres zawarty w tym wskaźniku. Zwykłe zmienne zapewniają bezpośredni dostęp do swoich wartości. Gdy tworzysz nową zmienną typu unsigned short int o nazwie yourAge i chcesz jej przypisać wartość zmiennej howOld, możesz napisać: unsigned short int yourAge; yourAge = howOld;
Wskaźnik umożliwia pośredni dostęp do wartości zmiennej, której adres zawiera. Aby przypisać wartość zmiennej howOld do zmiennej yourAge za pomocą wskaźnika pAge, powinieneś napisać: unsigned short int yourAge; yourAge = *pAge;
Operator wyłuskania (*) znajdujący się przed zmienną pAge oznacza „wartość przechowywana pod adresem.” To przypisanie można potraktować jako: „Weź wartość przechowywaną pod adresem zawartym w pAge i przypisz ją do zmiennej yourAge”.
UWAGA W przypadku wskaźników gwiazdka (*) może posiadać dwa znaczenia (może symbolizować część deklaracji wskaźnika albo operator wyłuskania).
Gdy deklarujesz wskaźnik, * jest częścią deklaracji i następuje po typie wskazywanego obiektu. Na przykład: // tworzymy wskaźnik do typu unsigned short unsigned short * page = 0;
Gdy wskaźnik jest wyłuskiwany, operator wyłuskiwania wskazuje, że odwołujemy się do wartości, znajdującej się w miejscu pamięci określonym przez adres zawarty we wskaźniku, a nie do tego adresu. // wartości wskazywanej przez pAge przypisujemy wartość 5 *pAge = 5;
Zwróć także uwagę, że ten sam znak (*) jest używany jako operator mnożenia. Kompilator wie z kontekstu, o który operator chodzi w danym miejscu programu.
Wskaźniki, adresy i zmienne Należy dokonać rozróżnienia pomiędzy wskaźnikiem, adresem zawartym w tym wskaźniku, a zmienną o adresie zawartym w tym wskaźniku. Nieumiejętność rozróżnienia ich jest najczęstszym powodem nieporozumień ze wskaźnikami. Weźmy następujący fragment kodu: int theVariable = 5; int * pPointer = &theVariable;
Zmienna theVariable jest zadeklarowana jako zmienna typu int i jest inicjalizowana wartością 5. Zmienna pPointer jest zadeklarowana jako wskaźnik do typu int i jest inicjalizowana adresem zmiennej theVariable. pPointer jest wskaźnikiem. Adres zawarty w pPointer jest adresem zmiennej theVariable. Wartością znajdującą się pod adresem zawartym w pPointer jest 5. Schemat zmiennych theVariable i pPointer przedstawia rysunek 8.2.
Rys. 8.2. Schematyczna reprezentacja pamięci
Na tym rysunku wartość 5 została umieszczona pod adresem 101. Jest on podany jako liczba dwójkowa 0000 0000 0000 0101
Jest to dwubajtowa (16-bitowa) wartość, której wartością dziesiętną jest 5. Zmienna wskaźnikowa ma adres 106. Jej wartość to 0000 0000 0000 0000 0000 0000 0110 0101
Jest to binarna reprezentacja wartości 101 (dziesiętnie), stanowiącej adres zmiennej theVariable, która zawiera wartość 5. Przedstawiony powyżej układ pamięci jest uproszczony, ale ilustruje przeznaczenie wskaźników zawierających adresy pamięci.
Operowanie danymi poprzez wskaźniki Gdy przypiszesz wskaźnikowi adres zmiennej, możesz użyć tego wskaźnika w celu uzyskania dostępu do danych zawartych w tej zmiennej. Listing 8.2 pokazuje, w jaki sposób adres lokalnej zmiennej jest przypisywany wskaźnikowi i w jaki sposób ten wskaźnik może operować wartością w tej zmiennej. Listing 8.2. Operowanie danymi poprzez wskaźnik 0: // Listing 8.2 UŜycie wskaźnika 1: 2: #include 3: 4: typedef unsigned short int USHORT; 5: 6: int main() 7: { 8: 9: using std::cout; 10: 11: USHORT myAge; // zmienna 12: USHORT * pAge = 0; // wskaźnik 13: 14: myAge = 5; 15: 16: cout << "myAge: " << myAge << "\n"; 17: pAge = &myAge; // wskaźnikowi pAge przypisuje adres zmiennej myAge 18: cout << "*pAge: " << *pAge << "\n\n"; 19: 20: cout << "Ustawiam *pAge = 7...\n"; 21: *pAge = 7; // ustawia myAge na 7 22: 23: cout << "*pAge: " << *pAge << "\n";
24: 25: 26: 27: 28: 29: 30: 31: 32: 33:
cout << "myAge: " << myAge << "\n\n"; cout << "Ustawiam myAge = 9...\n"; myAge = 9; cout << "myAge: " << myAge << "\n"; cout << "*pAge: " << *pAge << "\n"; return 0; }
Wynik myAge: 5 *pAge: 5 Ustawiam *pAge = 7... *pAge: 7 myAge: 7 Ustawiam myAge = 9... myAge: 9 *pAge: 9
Analiza Program deklaruje dwie zmienne: myAge typu unsigned short oraz wskaźnik do typu unsigned short, zmienną pAge. W linii 14. zmiennej myAge jest przypisywana wartość 5; potwierdza to komunikat wypisywany w linii 16. W linii 17. wskaźnikowi pAge jest przypisywany adres zmiennej myAge. W linii 18 następuje wyłuskanie wskaźnika pAge i wypisanie otrzymanej wartości (to pokazuje, że wartość o adresie zawartym w pAge jest wartością 5, czyli wartością zmiennej myAge). W linii 21. zmiennej o adresie zawartym w pAge jest przypisywana wartość 7. Powoduje to przypisanie tej wartości zmiennej myAge, co potwierdzają komunikaty wypisywane w liniach 23. i 24. W linii 29. zmiennej myAge jest przypisywana wartość 9. Ta wartość jest pobierana bezpośrednio w linii 29., zaś w linii 30. pośrednio (poprzez wyłuskanie wskaźnika pAge).
Sprawdzanie adresu Wskaźniki umożliwiają operowanie adresami nawet bez znajomości ich faktycznych wartości. Do tej pory musiałeś przyjmować jako oczywiste, że gdy przypisujesz wskaźnikowi adres zmiennej, to wartością wskaźnika staje się rzeczywiście adres tej zmiennej. Dlaczego nie miałbyś się teraz co do tego upewnić? Przedstawia to listing 8.3. Listing 8.3. Sprawdzanie zawartości wskaźnika 0: 1: 2: 3: 4: 5: 6: 7:
// Listing 8.3 Co zawiera wskaźnik?. #include
int main() { using std::cout;
8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39:
unsigned short int myAge = 5, yourAge = 10; // wskaźnik unsigned short int * pAge = &myAge; cout << "myAge:\t" << myAge << "\t\tyourAge:\t" << yourAge << "\n"; cout << "&myAge:\t" << &myAge << "\t&yourAge:\t" << &yourAge <<"\n"; cout << "pAge:\t" << pAge << "\n"; cout << "*pAge:\t" << *pAge << "\n";
cout << "\nPonowne przypisanie: pAge = &yourAge...\n\n"; pAge = &yourAge; // ponowne przypisanie do wskaźnika cout << "myAge:\t" << myAge << "\t\tyourAge:\t" << yourAge << "\n"; cout << "&myAge:\t" << &myAge << "\t&yourAge:\t" << &yourAge <<"\n"; cout << "pAge:\t" << pAge << "\n"; cout << "*pAge:\t" << *pAge << "\n"; cout << "\n&pAge:\t" << &pAge << "\n"; return 0; }
Wynik myAge: &myAge: pAge: *pAge:
5 0012FF7C 0012FF7C 5
yourAge: &yourAge:
10 0012FF78
Ponowne przypisanie: pAge = &yourAge... myAge: &myAge: pAge: *pAge:
5 0012FF7C 0012FF78 10
&pAge:
0012FF74
(Twoje wyniki mogą być inne.)
Analiza
yourAge: &yourAge:
10 0012FF78
W linii 9. deklarowane są dwie zmienne, myAge oraz yourAge, obie typu unsigned short. W linii 12. deklarowana jest zmienna pAge, będąca wskaźnikiem do typu unsigned short; ten wskaźnik jest inicjalizowany adresem zmiennej myAge. W liniach od 14. do 18. następuje wypisanie wartości i adresów zmiennych myAge i yourAge. Linia 20. wypisuje zawartość wskaźnika pAge, którą jest adres zmiennej myAge. Linia 21. wypisuje rezultat wyłuskania wskaźnika pAge, czyli wypisuje wartość zmiennej wskazywanej przez ten wskaźnik (wartość zmiennej myAge, wynoszącą 5). W taki właśnie sposób działają wskaźniki. Linia 20. pokazuje, że wskaźnik pAge zawiera adres zmiennej myAge, zaś linia 21. pokazuje, w jaki sposób wartość przechowywana w zmiennej myAge może zostać uzyskana w wyniku wyłuskania wskaźnika pAge. Zanim przejdziesz dalej, upewnij się, czy to rozumiesz. Przestudiuj kod i porównaj z wynikiem. W linii 25. zmiennej pAge jest przypisywany nowy adres, tym razem adres zmiennej yourAge. Ponownie wypisywane są wartości i adresy. Wyniki pokazują, że wskaźnik pAge zawiera teraz adres zmiennej yourAge i że jako efekt wyłuskania uzyskujemy wartość przechowywaną w tej zmiennej. Linia 36. wypisuje adres zmiennej pAge. Tak jak wszystkie inne zmienne, swój adres posiada także wskaźnik. Adres ten także może być umieszczony we wskaźniku. (Przypisywanie adresu wskaźnika do innego wskaźnika zostanie omówione wkrótce.)
TAK W celu uzyskania dostępu do danych przechowywanych pod adresem zawartym we wskaźniku używaj operatora wyłuskania (*). Inicjalizuj wszystkie wskaźniki albo adresem poprawnym, albo adresem pustym (0). Pamiętaj o różnicy pomiędzy adresem we wskaźniku, a wartością pod tym adresiem.
Użycie wskaźników
Aby zadeklarować wskaźnik, napisz typ zmiennej lub obiektu, którego adres będzie przechowywany w tym wskaźniku, gwiazdkę (*) oraz nazwę wskaźnika. Na przykład: unsigned short int * pPointer = 0;
Aby zainicjalizować wskaźnik (przypisać mu adres), poprzedź nazwę zmiennej, której adres chcesz przypisać, operatorem adresu (&). Na przykład; unsigned short int theVariable = 5; unsigned short int * pPointer = & theVariable;
Aby wyłuskać wskaźnik, poprzedź nazwę wskaźnika operatorem wyłuskania (*). Na przykład: unsigned short int theValue = *pPointer;
Do czego służą wskaźniki? Jak dotąd, poznałeś krok po kroku proces przypisywania wskaźnikowi adresu zmiennej. W praktyce jednak nie będziesz tego robił nigdy. Po co miałbyś utrudniać sobie życie używaniem wskaźników, skoro masz do dyspozycji zmienną, do której masz pełny dostęp? Jedynym powodem, dla którego operujemy wskaźnikami na zmiennych automatycznych (tj. lokalnych), jest zademonstrowanie sposobu działania wskaźników. Teraz, gdy poznałeś już składnię wskaźników, możesz poznać ich praktyczne zastosowania. Wskaźniki są najczęściej używane do wykonywania trzech zadań: •
zarządzania danymi na stercie,
•
uzyskiwania dostępu do składowych danych i funkcji składowych klasy.
•
przekazywania zmiennych do funkcji poprzez referencję.
W pozostałej części rozdziału skupimy się na zarządzaniu danymi na stercie oraz dostępie do danych i funkcji składowych klasy. Przekazywanie zmiennych przez referencję omówimy w następnym rozdziale.
Stos i sterta W rozdziale 5., w podrozdziale „Jak działają funkcje — rzut oka <>” wspomniano o pięciu obszarach pamięci: •
globalnej przestrzeni nazw,
•
stercie,
•
rejestrach,
•
przestrzeni kodu,
•
stosie.
Zmienne lokalne znajdują się na stosie (podobnie jak parametry funkcji). Kod występuje, oczywiście, w przestrzeni kodu, zaś zmienne globalne w globalnej przestrzeni nazw. Rejestry są używane do kontrolowania wewnętrznych zadań procesora, takich jak śledzenie szczytu stosu czy miejsca wykonania programu. Cała pozostała pamięć jest prawie w całości przeznaczona na tak zwaną stertę (ang. heap). Problem ze zmiennymi lokalnymi polega na tym, że nie są one trwałe: gdy funkcja kończy działanie, zmiennej te są niszczone. Rozwiązują ten problem zmienne globalne, nie są one jednak dostępne bez ograniczeń w całym programie; powoduje to, kod jest trudny do zrozumienia i zmodyfikowania. Umieszczanie danych na stercie uwalnia od obu tych niedogodności.
Możesz uznawać stertę za obszerny blok pamięci, zawierający dziesiątki tysięcy kolejno ponumerowanych pojemników, oczekujących na twoje dane. Jednak w odróżnieniu od stosu, nie możesz nadawać tym pojemnikom etykietek. Musisz poprosić o adres pojemnika, który rezerwujesz, a następnie przechować ten adres we wskaźniku. Można także znaleźć inną analogię: wyobraź sobie, że przyjaciel dał ci numer telefonu do firmy kurierskiej. Wracasz do domu, programujesz ten numer w swoim aparacie telefonicznym pod określonym przyciskiem, po czym wyrzucasz kartkę z numerem. Gdy naciśniesz przycisk, telefon wybierze jakiś numer i połączy cię z firmą kurierską. Nie pamiętasz numeru i nie wiesz, gdzie znajduje się firma, ale przycisk umożliwia ci dostęp do niej. Firma to twoje dane na stercie. Nie wiesz gdzie jest, ale wiesz jak się z nią skontaktować. Służy do tego jej adres — w tym przypadku jest nim numer telefonu. Nie musisz znać tego numeru; wystarczy, że masz go we wskaźniku (przycisku w telefonie). Wskaźnik umożliwia ci dostęp do danych, bez konieczności przeprowadzania szczegółowych, dodatkowych działań. Gdy funkcja kończy działanie, stos jest czyszczony automatycznie. Wszystkie zmienne lokalne wychodzą poza zakres i są usuwane ze stosu. Sterta nie jest czyszczona aż do chwili zakończenia działania programu, dlatego to ty jesteś odpowiedzialny za zwolnienie wszelkiej zaalokowanej przez siebie pamięci. Zaletą sterty jest to, że zaalokowana (zarezerwowana) na niej pamięć pozostaje dostępna aż do momentu, w którym ją zwolnisz. Jeśli pamięć na stercie zaalokujesz w funkcji, po wyjściu z tej funkcji pamięć pozostanie nadal dostępna. Zaletą tej metody korzystania z pamięci (w przeciwieństwie do zmiennych globalnych) jest to, że dostęp do tej pamięci mają tylko te funkcje, które posiadają do niej wskaźnik. Dzięki temu można ściśle kontrolować interfejs do danych – eliminuje to potencjalny problem nieoczekiwanej i niezauważalnej zmiany danych przez inną funkcję. Aby ten mechanizm działał, musisz mieć możliwość tworzenia wskaźnika do obszaru pamięci na stercie oraz przekazywania tego wskaźnika pomiędzy funkcjami. Proces ten opisują następne podrozdziały.
Operator new W języku C++, do alokowania pamięci na stercie służy słowo kluczowe new (nowy). Po tym słowie kluczowym następuje typ obiektu, jaki chcesz zaalokować – dzięki temu kompilator wie, ile miejsca powinien zarezerwować. Instrukcja new unsigned short int alokuje na stercie dwa bajty, a instrukcja new long alokuje cztery bajty. Zwracaną wartością jest adres pamięci. Musi on zostać przypisany do wskaźnika. Aby stworzyć na stercie obiekt typu unsigned short, możesz napisać: unsigned short int * pPointer; pPointer = new unsigned short int;
Można oczywiście zainicjalizować wskaźnik w trakcie jego tworzenia: unsigned short int * pPointer = new unsigned short int;
W obu przypadkach, wskaźnik pPointer wskazuje teraz położony na stercie obiekt typu unsigned short int. Możesz użyć tego wskaźnika tak, jak każdego innego wskaźnika do zmiennej i przypisać obiektowi na stercie dowolną wartość: *pPointer = 72;
Oznacza to: „Umieść 72 jako wartość obiektu wskazywanego przez pPointer” lub „Przypisz obszarowi sterty wskazywanemu przez wskaźnik pPointer wartość 72”. UWAGA Gdy operator new nie jest w stanie zarezerwować pamięci na stercie (w końcu pamięć ma ograniczoną objętość), zgłasza wyjątek (patrz rozdział 20., „Wyjątki i obsługa błędów”).
delete Gdy skończysz korzystać z obszaru pamięci na stercie, musisz użyć słowa kluczowego delete (usuń) z właściwym wskaźnikiem. Instrukcja delete zwalnia pamięć zaalokowaną na stercie, tj. zwraca ją stercie. Pamiętaj, że sam wskaźnik — w przeciwieństwie do pamięci, na którą wskazuje — jest zmienną lokalną. Gdy funkcja, w której został zadeklarowany, kończy działanie, wskaźnik wychodzi poza zakres i jest niszczony. Pamięć zaalokowana operatorem new nie jest zwalniana automatycznie; staje się niedostępna — taka sytuacja jest nazywana wyciekiem pamięci (ang. memory leak). Nazwa wzięła się stąd, że pamięć nie może być odzyskana, aż do momentu zakończenia działania programu (z punktu widzenia programu, pamięć „wycieka” z komputera). Aby zwrócić stercie pamięć, użyj słowa kluczowego delete. Na przykład: delete pPointer;
Gdy zwalniasz wskaźnik, w rzeczywistości zwalniasz jedynie pamięć, której adres jest zawarty w tym wskaźniku. Mówisz: „Zwróć stercie pamięć, na którą wskazuje ten wskaźnik”. Wskaźnik nadal pozostaje wskaźnikiem i można mu ponownie przypisać adres. Listing 8.4 przedstawia alokowanie zmiennej na stercie, użycie tej zmiennej, a następnie zwolnienie jej. OSTRZEŻENIE Gdy używasz słowa kluczowego delete dla wskaźnika, zwalniana jest pamięć, na którą on wskazuje. Ponowne wywołanie delete dla tego wskaźnika spowoduje załamanie programu! Gdy zwalniasz wskaźnik, ustaw go na zero (null, wskaźnik pusty). Kompilator gwarantuje, że wywołanie delete z pustym wskaźnikiem jest bezpieczne. Na przykład: Animal delete pDog = // ... delete
*pDog = new Animal; pDog; // zwalnia pamięć 0; // ustawia wskaźnik na null pDog;
// nieszkodliwe
Listing 8.4. Alokowanie, użycie i zwolnienie wskaźnika 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20:
// Listing 8.4 // Alokowanie i zwalnianie wskaźnika #include int main() { using std::cout; int localVariable = 5; int * pLocal= &localVariable; int * pHeap = new int; *pHeap = 7; cout << "localVariable: " << localVariable << "\n"; cout << "*pLocal: " << *pLocal << "\n"; cout << "*pHeap: " << *pHeap << "\n"; delete pHeap; pHeap = new int; *pHeap = 9; cout << "*pHeap: " << *pHeap << "\n"; delete pHeap; return 0; }
Wynik localVariable: 5 *pLocal: 5 *pHeap: 7 *pHeap: 9
Analiza W linii 7. program deklaruje i inicjalizuje lokalną zmienną. W linii 8. deklaruje i inicjalizuje wskaźnik, przypisując mu adres tej zmiennej. W linii 9. deklaruje wskaźnik, lecz inicjalizuje go wartością uzyskaną w wyniku wywołania operatora new int. Powoduje to zaalokowanie na stercie miejsca dla wartości typu int. Linia 10. przypisuje wartość 7 do nowo zaalokowanej pamięci. Linia 11. wypisuje wartość zmiennej lokalnej, a linia 12. wypisuje wartość wskazywaną przez pLocal (lokalna) Jak należało oczekiwać, są one takie same. Linia 13. wypisuje wartość wskazywaną przez pHeap (sterta) i pokazuje, że rzeczywiście mamy dostęp do wartości zaalokowanej w linii 10. W linii 14. pamięć zaalokowana w linii 9. jest zwracana na stertę (w wyniku wywołania delete). Czynność ta zwalnia pamięć i odłącza od niej wskaźnik. Teraz pHeap może wskazywać inne miejsce w pamięci. Nowy adres i wartość przypisujemy mu w liniach 15. i 16., zaś w linii 17. wypisujemy wynik. Linia 18. zwalnia pamięć i zwraca ją stercie. Choć linia 18. jest nadmiarowa (zakończenie programu automatycznie powoduje zwolnienie pamięci), do dobrych obyczajów należy jawne zwalnianie wskaźników. Gdy program będzie modyfikowany lub rozbudowywany, zapamiętanie tego kroku może okazać się bardzo przydatne.
Wycieki pamięci Inną sytuacją, która może doprowadzić do wycieku pamięci, jest ponowne przypisanie wskaźnikowi adresu, bez wcześniejszego zwolnienia pamięci, na którą w danym momencie wskazuje. Spójrzmy na poniższy fragment kodu: 0: 1: 2: 3:
unsigned short int * pPointer = new unsigned short int; *pPointer = 72; pPointer = new unsigned short int; pPointer = 84;
Linia 0 tworzy pPointer i przypisuje mu adres rezerwowanego na stercie obszaru. Linia 1. umieszcza w tym obszarze wartość 72. Linia 2. ponownie przypisuje wskaźnikowi pPointer adres innego obszaru pamięci. Linia 3. umieszcza w tym obszarze wartość 84. Pierwotny obszar — w którym jest zawarta wartość 72 — jest niedostępny, gdyż wskaźnik do tej pamięci został wypełniony innym adresem. Nie ma sposobu na odzyskanie pierwotnego obszaru, nie ma też sposobu na zwolnienie go przed zakończeniem działania programu. Ten kod powinien zostać napisany następująco: 0: 1: 2: 3: 4:
unsigned short int * pPointer = new unsigned short int; *pPointer = 72; delete pPointer; pPointer = new unsigned short int; pPointer = 84;
Teraz pamięć, wskazywana pierwotnie przez pPointer, jest zwalniana w linii 2. UWAGA Za każdym razem, gdy użyjesz w programie słowa kluczowego new, powinieneś użyć także odpowiadającego mu słowa kluczowego delete. Należy pamiętać, na co wskazuje dany wskaźnik (aby mieć pewność, że zostanie to zwolnione, gdy przestanie potrzebne).
Tworzenie obiektów na stercie Możesz stworzyć nie tylko wskaźnik do zmiennej całkowitej, ale i wskaźnik do dowolnego obiektu. Jeśli zadeklarowałeś obiekt typu Cat (kot), możesz zadeklarować wskaźnik do tej klasy i stworzyć na stercie egzemplarz obiektu tej klasy (tak jak mogłeś stworzyć go na stosie). Składnia jest taka sama, jak w przypadku innych zmiennych: Cat *pCat = new Cat;
Powoduje to wywołanie domyślnego konstruktora klasy — czyli konstruktora, który nie ma parametrów. Konstruktor jest wywoływany za każdym razem, gdy tworzony jest obiekt klasy (na stosie lub na stercie).
Usuwanie obiektów Gdy wywołujesz delete ze wskaźnikiem tdo obiektu na stercie, przed zwolnieniem pamięci obiektu wywoływany jest jego destruktor. Dzięki temu klasa ma szansę „posprzątania po sobie,” tak jak w przypadku obiektów niszczonych na stosie. Tworzenie i usuwanie obiektów na stercie przedstawia listing 8.5. Listing 8.5. Tworzenie i usuwanie obiektów na stercie 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36:
// Listing 8.5 // Tworzenie obiektów na stercie // z uŜyciem new oraz delete #include class SimpleCat { public: SimpleCat(); ~SimpleCat(); private: int itsAge; }; SimpleCat::SimpleCat() { std::cout << "Wywolano konstruktor.\n"; itsAge = 1; } SimpleCat::~SimpleCat() { std::cout << "Wywolano destruktor.\n"; } int main() { std::cout << "SimpleCat Mruczek...\n"; SimpleCat Mruczek; std::cout << "SimpleCat *pFilemon = new SimpleCat...\n"; SimpleCat * pFilemon = new SimpleCat; std::cout << "delete pFilemon...\n"; delete pFilemon; std::cout << "Wyjscie, czekaj na Mruczka...\n"; return 0; }
Wynik SimpleCat Mruczek... Wywolano konstruktor. SimpleCat *pFilemon = new SimpleCat...
Wywolano konstruktor. delete pFilemon... Wywolano destruktor. Wyjscie, czekaj na Mruczka... Wywolano destruktor.
Analiza Linie od 6. do 13. deklarują okrojoną klasę SimpleCat (prosty kot). Linia 9. deklaruje konstruktor tej klasy, zaś linie od 15. do 19. zawierają jego definicję. Linia 10 deklaruje destruktor klasy, a linie od 21. do 24. zawierają jego definicję. W linii 29. na stosie tworzony jest obiekt Mruczek, powoduje to wywołanie konstruktora klasy. W linii 31. na stercie tworzony jest egzemplarz obiektu SimpleCat, wskazywany przez zmienną pFilemon; w wyniku tego działania następuje ponowne wywołanie konstruktora. W linii 33. znajduje się słowo kluczowe delete ze wskaźnikiem pFilemon, dlatego wywoływany jest destruktor. Gdy funkcja main() kończy działanie, obiekt Mruczek wychodzi z zakresu i ponownie wywoływany jest destruktor.
Dostęp do składowych klasy W przypadku obiektów Cat stworzonych lokalnie, dostęp do składowych funkcji i danych odbywa się za pomocą operatora kropki (.). Aby odwołać się do składowych utworzonego na stercie obiektu Cat, musisz wyłuskać wskaźnik i wywołać operator kropki dla obiektu wskazywanego przez ten wskaźnik. Aby odwołać się do funkcji składowej GetAge(), możesz napisać: (*pFilemon).GetAge();
Aby zapewnić, wyłuskanie wskaźnika pFilemon przed odwołaniem się do metody GetAge(), użyte zostały nawiasy. Ponieważ taki zapis jest dość skomplikowany, C++ oferuje skrótowy operator dostępu pośredniego: operator wskazywania (->). Składa się on z ze znaku minus (-) i znaku większości (>), zapisanych razem. Kompilator traktuje je jako pojedynczy symbol. Dostęp do składowych funkcji i danych utworzonego na stercie obiektu przedstawia listing 8.6. Listing 8.6. Dostęp do składowych funkcji i danych utworzonego na stercie obiektu. 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12:
// Listing 8.6 // Dostęp do składowych funkcji i danych obiektu // utworzonego na stercie, z uŜyciem operatora -> #include class SimpleCat { public: SimpleCat() {itsAge = 5; } ~SimpleCat() {} int GetAge() const { return itsAge; } void SetAge(int age) { itsAge = age; }
13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25:
private: int itsAge; }; int main() { SimpleCat * Mruczek = new SimpleCat; std::cout << "Mruczek ma " << Mruczek->GetAge() << " lat\n"; Mruczek->SetAge(7); std::cout << "Mruczek ma " << Mruczek->GetAge() << " lat\n"; delete Mruczek; return 0; }
Wynik Mruczek ma 5 lat Mruczek ma 7 lat
Analiza W linii 19. na stercie tworzony jest egzemplarz obiektu klasy SimpleCat. Domyślny konstruktor ustawia jego zmienną składową itsAge (jego wiek) na 5, zaś w linii 20. wywoływana jest metoda GetAge(). Ponieważ zmienna Mruczek jest wskaźnikiem, w celu uzyskania dostępu do danych i funkcji składowych został użyty operator wskazania (->). W linii 21. zostaje wywołana metoda SetAge(), po czym w linii 22. ponownie wywoływana jest metoda GetAge().
Dane składowe na stercie Wskaźnikami do obiektów znajdujących się na stercie może być jedna lub więcej danych składowych klasy. Pamięć może być alokowana w konstruktorze klasy lub w którejś z jej metod, zaś do jej zwolnienia można wykorzystać destruktor. Przedstawia to listing 8.7. Listing 8.7. Wskaźniki jako dane składowe 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20:
// Listing 8.7 // Wskaźniki jako dane składowe // dostępne poprzez operator -> #include class SimpleCat { public: SimpleCat(); ~SimpleCat(); int GetAge() const { return *itsAge; } void SetAge(int age) { *itsAge = age; } int GetWeight() const { return *itsWeight; } void setWeight (int weight) { *itsWeight = weight; } private: int * itsAge; int * itsWeight; };
21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42:
SimpleCat::SimpleCat() { itsAge = new int(5); itsWeight = new int(2); } SimpleCat::~SimpleCat() { delete itsAge; delete itsWeight; } int main() { SimpleCat *Mruczek = new SimpleCat; std::cout << "Mruczek ma " << Mruczek->GetAge() << " lat\n"; Mruczek->SetAge(7); std::cout << "Mruczek ma " << Mruczek->GetAge() << " lat\n"; delete Mruczek; return 0; }
Wynik Mruczek ma 5 lat Mruczek ma 7 lat
Analiza Klasa SimpleCat (prosty kot) deklaruje (w liniach 18. i 19.) dwie zmienne składowe; obie te zmienne są wskaźnikami do wartości całkowitych. Konstruktor (linie od 22. do 26.) inicjalizuje na stercie pamięć dla tych wskaźników i przypisuje obiektom wartości domyślne. Zwróć uwagę, że dla tworzonych obiektów int możemy wywołać pseudo-konstruktor, przekazując mu domyślną wartość obiektu. Umożliwia to stworzenie obiektu na stercie i zainicjalizowanie jego wartości (w linii 24. jest to wartość 5, zaś w linii 25., wartość 2). Destruktor (linie od 28. do 32.) zwalnia zaalokowaną pamięć. Nie ma sensu przypisywać wskaźnikom wartości pustej (null), gdyż po opuszczeniu destruktora i tak nie będą już dostępne. Jest to jedna z okazji, przy których można bezpiecznie złamać regułę mówiącą, że usuwanym wskaźnikom należy przypisać wartość pustą (choć oczywiście nie zaszkodzi postępować zgodnie z tą regułą). Funkcja wywołująca (w tym przypadku funkcja main()) nie jest świadoma, że zmienne składowe itsAge i itsWeight (jego waga) są wskaźnikami do pamięci na stercie. Funkcja main()tak samo odwołuje się do akcesorów GetAge() i SetAge(), zaś szczegóły zarządzania pamięcią są ukryte w implementacji klasy — i tak właśnie powinno być. Gdy w linii 40. zwalniany jest obiekt Mruczek, następuje wywołanie jego destruktora. Destruktor zwalnia wszystkie wskaźniki składowe. Gdyby wskaźniki te wskazywały na obiekty innych klas (lub być może tej samej klasy), zostałyby wywołane także destruktory tych klas. Przechowywanie własnych zmiennych składowych jako referencji jest całkiem nierozsądne, chyba że istnieje ku temu ważny powód. W tym przypadku nie ma takiego powodu, ale być może w innej sytuacji przechowywanie zmiennych okaże się bardzo przydatne.
Nasuwa się oczywiste pytanie: co próbujesz uzyskać? Musisz zacząć od projektu. Jeśli zaprojektujesz obiekt, który odwołuje się do innego obiektu, a ten drugi obiekt może zaistnieć przed zaistnieniem pierwszego obiektu i trwać jeszcze po jego zniszczeniu, wtedy pierwszy obiekt musi odwoływać się do drugiego obiektu poprzez referencję. Na przykład: pierwszy obiekt może być oknem, a drugi dokumentem. Okno potrzebuje dostępu do dokumentu, ale nie kontroluje czasu jego życia. Dlatego okno musi odwoływać się do obiektu poprzez referencję. W C++ można to osiągnąć poprzez użycie wskaźników lub referencji. Referencje zostaną opisane w rozdziale 9. Często zadawane pytanie
Gdy zadeklaruję na stosie obiekt, którego dane składowe tworzone są na stercie, co znajdzie się na stosie, a co na stercie? Na przykład: #include class SimpleCat { public: SimpleCat(); ~SimpleCat(); int GetAge() const { return *itsAge; } // inne metody private: int * itsAge; int * itsWeight; }; SimpleCat::SimpleCat() { itsAge = new int(5); itsWeight = new int(2); } SimpleCat::~SimpleCat() { delete itsAge; delete itsWeight; } int main() { SimpleCat Mruczek; std::cout << "Mruczek ma " << Mruczek.GetAge() << " lat\n"; Mruczek.SetAge(7); std::cout << "Mruczek ma " << Mruczek.GetAge() << " lat\n"; return 0; }
Odpowiedź: Na stosie znajdzie się lokalny obiekt Mruczek. Ten obiekt zawiera dwa wskaźniki, z których każdy zajmuje nacztery bajty stosu i zawiera adres zmiennej całkowitej zaalokowanej na stercie. W tym przykładzie, na stosie i na stercie zajętych zostanie po osiem bajtów.
Wskaźnik this Każda funkcja składowa klasy posiada ukryty parametr, jest nim wskaźnik this (to). Wskaźnik this wskazuje na ten egzemplarz obiektu klasy, dla którego wywołana została dana funkcja składowa. W każdym wywołaniu funkcji GetAge() lub SetAge() występuje ukryty parametr w postaci wskaźnika this. Wskaźnika this można użyć jawnie; pokazuje to listing 8.8. Listing 8.8. Użycie wskaźnika this 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48:
// Listing 8.8 // UŜycie wskaźnika this
#include class Rectangle { public: Rectangle(); ~Rectangle(); void SetLength(int length) { this->itsLength = length; } int GetLength() const { return this->itsLength; } void SetWidth(int width) { itsWidth = width; } int GetWidth() const { return itsWidth; } private: int itsLength; int itsWidth; }; Rectangle::Rectangle() { itsWidth = 5; itsLength = 10; } Rectangle::~Rectangle() {} int main() { Rectangle theRect; cout << "theRect ma dlugosc " << " metrow.\n"; cout << "theRect ma szerokosc << " metrow.\n"; theRect.SetLength(20); theRect.SetWidth(10); cout << "theRect ma dlugosc " << " metrow.\n"; cout << "theRect ma szerokosc << " metrow.\n"; return 0; }
<< theRect.GetLength() " << theRect.GetWidth()
<< theRect.GetLength() " << theRect.GetWidth()
Wynik theRect theRect theRect theRect
ma ma ma ma
dlugosc 10 metrow. szerokosc 5 metrow. dlugosc 20 metrow. szerokosc 10 metrow.
Analiza Akcesory SetLength() (ustaw długość) oraz GetLength() (pobierz długość) w jawny sposób korzystają ze wskaźnika this przy dostępie do zmiennych składowych obiektu Rectangle (prostokąt). Akcesory SetWidth() (ustaw szerokość) oraz GetWidth() (pobierz szerokość) nie korzystają z tego wskaźnika jawnie. Nie ma żadnej różnicy pomiędzy działaniami tych akcesorów, choć składnia z użyciem wskaźnika this jest łatwiejsza do zrozumienia. Gdyby tutaj kończyły się wiadomości na temat wskaźnika this, wspominanie o nim nie miałoby sensu. Należy pamiętać że wskaźnik this jest wskaźnikiem; oznacza to, że zawiera adres obiektu. Może zatem okazać się bardzo przydatny. Praktyczne zastosowanie wskaźnika this poznasz w rozdziale 10., „Funkcje zaawansowane”; zostanie w nim omówione zagadnienie przeciążania operatorów. Na razie pamiętaj tylko o istnieniu wskaźnika this oraz jego przeznaczeniu: wskazywaniu na swój obiekt klasy. Nie musisz martwić się tworzeniem i usuwaniem wskaźnika this – wszystkim zajmuje się kompilator.
Utracone wskaźniki Utracone wskaźniki są jednym ze źródeł trudnych do zlokalizowania błędów. Wskaźnik zostaje utracony, gdy wywołasz dla niego delete — a więc zwolnisz pamięć, na którą wskazuje — a następnie nie przypiszesz mu wartości pustej. Jeśli spróbujesz później użyć wskaźnika bez ponownego przypisania mu adresu obiektu, wynik będzie nieprzewidywalny i, o ile masz szczęście, program się załamie. Przypomina to sytuację, w której firma kurierska zmieniła adres, a ty użyłeś zaprogramowanego przycisku w telefonie. Może nie zdarzyłoby się nic strasznego — telefon zadzwoniłby gdzieś w magazynie na którejś z pustyń. Może się jednak zdarzyć, że numer tego telefonu został przydzielony fabryce amunicji, a twój telefon doprowadziłby do wybuchu, który wysadziłby w powietrze całe miasto! Innymi słowy, nie używaj wskaźników po ich zwolnieniu. Wskaźnik nadal wskazuje to samo miejsce w pamięci, ale kompilator może umieścić w nim zupełnie nowe dane; użycie wskaźnika może spowodować załamanie programu. Co gorsza, program może działać pozornie normalnie i załamać się kilka minut później. Można to nazwać bombą z opóźnionym zapłonem, co wcale nie jest zabawne. W celu zachowania bezpieczeństwa, po zwolnieniu wskaźnika przypisz mu wartość pustą (0). To spowoduje rozbrojenie wskaźnika. Listing 8.9 przedstawia tworzenie utraconego wskaźnika.
OSTRZEŻENIE Przedstawiony poniżej program celowo tworzy utracony wskaźnik. NIE uruchamiaj go. Jeśli będziesz miał masz szczęście, załamie się sam program. załamie się sam.
Listing 8.9. Tworzenie utraconego wskaźnika 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23:
// Listing 8.9 // Demonstruje utracony wskaźnik typedef unsigned short int USHORT; #include int main() { USHORT * pInt = new USHORT; *pInt = 10; std::cout << "*pInt: " << *pInt << std::endl; delete pInt; long * pLong = new long; *pLong = 90000; std::cout << "*pLong: " << *pLong << std::endl; *pInt = 20;
// o, nie! on był usunięty!
std::cout << "*pInt: " << *pInt << std::endl; std::cout << "*pLong: " << *pLong << std::endl; delete pLong; return 0; }
Wynik *pInt: 10 *pLong: 90000 *pInt: 20 *pLong: 65556
(Nie próbuj odtworzyć tego wyniku; jeśli masz szczęście, w twoim komputerze uzyskasz inny wynik, jeśli nie masz szczęścia, komputer ci się zawiesi.) Analiza Linia 8. deklaruje zmienną pInt jako wskaźnik do typu USHORT; zmienna ta wskazuje nowo zaalokowaną pamięć. Linia 9. umieszcza w tej pamięci wartość 10, zaś linia 10. wypisuje jej wartość. Po wypisaniu wartości wskaźnik jest zwalniany za pomocą instrukcji delete. W tym momencie utracony został wskaźnik pInt. Linia 13. deklaruje nowy wskaźnik, pLong, wskazujący pamięć zaalokowaną operatorem new. W linii 14. obiektowi wskazywanemu przez pLong jest przypisywana wartość 9000, zaś w linii 15. wypisywana jest wartość tego obiektu. Linia 17. przypisuje wartość 20 do miejsca w pamięci, na które wskazuje pInt, ale wskaźnik ten nie wskazuje na poprawny wynik. Pamięć wskazywana przez pInt została zwolniona w wyniku wywołania delete, więc przypisanie jej wartości może okazać się katastrofą.
Linia 19. wypisuje wartość wskazywaną przez pInt. Oczywiście, jest nią 20. Linia 20. powinna wypisać wartość wskazywaną przez pLong, powinna ona wynosić 90000, jednak w tajemniczy sposób ta wartość zmieniła się na 65556. Nasuwają się dwa pytania: 1.
Jak mogła zmienić się wartość wskazywana przez pLong, skoro nie był wykorzystywany wskaźnik pLong?
2.
Gdzie została umieszczona wartość 20, która w linii 17. została przypisana obiektowi wskazywanemu przez pInt?
Jak można się domyślić, pytania te są ze sobą powiązane. Gdy w linii 17., w pamięci wskazywanej przez pInt umieszczana była wartość, w miejscu wskazywanym dotąd przez pInt kompilator ufnie umieścił 20. Jednak ponieważ w linii 11. ta pamięć została zwolniona, kompilator mógł ją przydzielić czemuś innemu. Gdy w linii 13. został stworzony wskaźnik pLong, otrzymał on poprzedni adres pamięci wskazywanej przez pInt. (Proces ten różni się w poszczególnych komputerach, w zależności od pamięci, w której przechowywane są wartości.) Gdy do miejsca wskazywanego uprzednio przez pInt zostało przypisane 20, zastąpiło ono wartość wskazywaną przez pLong. Proces ten nazywa się nadpisaniem wartości i często występuje w przypadku użycia utraconego wskaźnika. Jest to szczególnie uciążliwy błąd, gdyż zmieniona wartość nie była związana z utraconym wskaźnikiem. Zmiana wartości wskazywanej przez pLong była jedynie efektem ubocznym użycia utraconego wskaźnika pInt. W obszernym programie taki błąd jest bardzo trudny do wykrycia. Dla zabawy, zastanówmy się, jak mogła znaleźć się w pamięci wartość 65 556: 1.
Wskaźnik pInt wskazywał określone miejsce w pamięci, w którym została umieszczona wartość 10.
2.
Instrukcja delete zwolniła wskaźnik pInt, dzięki czemu zwolniło się miejsce do przechowania innej wartości. Następnie to samo miejsce w pamięci zostało przydzielone wskaźnikowi pLong.
3.
W miejscu wskazywanym przez pLong została umieszczona wartość 90000. W komputerze, w którym uruchomiono ten przykładowy program, do przechowania tej wartości użyto czterech bajtów (00 01 5F 90), przechowywanych w odwróconej kolejności. Ta wartość została była przechowywana jako 5F 90 00 01.
4.
W pamięci wskazywanej przez pInt została umieszczona wartość 20 (czyli w zapisie szesnastkowym: 00 14). Ponieważ pInt przez cały czas wskazywało ten sam adres, zastąpione zostały pierwsze dwa bajty pamięci wskazywanej przez pLong, co dało wartość 00 14 00 01.
5.
Gdy wartość wskazywana przez pLong została wypisana, odwrócenie bajtów dało wynik 00 01 00 14, co odpowiada wartości dziesiętnej 65556.
Często zadawane pytanie
Jaka jest różnica pomiędzy wskaźnikiem pustym a wskaźnikiem utraconym?
Odpowiedź: Gdy zwalniasz wskaźnik, informujesz kompilator, by zwolnił pamięć. Wskaźnik istnieje nadal i zawiera ten sam adres. Jest jedynie wskaźnikiem utraconym.
Gdy napiszesz myPtr = 0; zmieniasz go ze wskaźnika utraconego na wskaźnik pusty.
Normalnie, gdy usuniesz (zwolnisz) wskaźnik, po czym usuniesz go ponownie, wynik będzie nieprzewidywalny. Oznacza to, że może zdarzyć się dosłownie wszystko — jeśli masz szczęście, program się załamie. Natomiast przy zwalnianiu pustego wskaźnika nie dzieje się nic; jest to bezpieczne.
Użycie utraconego lub pustego wskaźnika (na przykład napisanie myPtr = 5;) jest niedozwolone i może spowodować załamanie programu. Jeśli wskaźnik jest pusty, program musi się załamać – stanowi to kolejną przewagę wskaźnika pustego nad wskaźnikiem utraconym. Programiści zdecydowanie wolą, gdy program załamuje się w sposób przewidywalny, znacznie ułatwia to usuwanie błędów.
Wskaźniki const W przypadku wskaźników, słowo kluczowe const możesz umieścić przed typem, po nim lub w obu tych miejscach. Wszystkie poniższe deklaracje są poprawne: const int *pOne; int * const pTwo; const int * const pThree;
pOne jest wskaźnikiem do stałej wartości całkowitej. Wskazywana przez niego wartość nie może być zmieniana. pTwo jest stałym wskaźnikiem do wartości całkowitej. Wartość może być zmieniana, ale pTwo nie może wskazywać na nic innego. pThree jest stałym wskaźnikiem do stałej wartości całkowitej. Wskazywana przez niego wartość nie może być zmieniana, pThree nie może również wskazywać na nic innego.
Aby dowiedzieć się, która wartość jest stała, wystarczy spojrzeć na prawo od słowa kluczowego const. Jeśli znajduje się tam typ, stała jest wartość. Jeśli znajduje się zmienna, wtedy stały jest wskaźnik. const int * p1; // wskazywana wartość typu int jest stała int * const p2; // p2 jest stałe i nie moŜe wskazywać na nic innego
Wskaźniki const i funkcje składowe const Z rozdziału 6., „Programowanie zorientowane obiektowo”, dowiedziałeś się, że funkcja składowa może być zadeklarowana za pomocą słowa kluczowego const. Gdy funkcja zostanie zadeklarowana w taki właśnie sposób, przy każdej próbie zmiany danych obiektu wewnątrz tej funkcji kompilator zgłosi błąd. Jeśli zadeklarujesz wskaźnik do obiektu const, za pomocą pomocy tego wskaźnika możesz wywoływać tylko metody const. Ilustruje to listing 8.10. Listing 8.10. Użycie wskaźnika do obiektu const 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47:
// Listing 8.10 // UŜycie wskaźników z metodami const #include using namespace std; class Rectangle { public: Rectangle(); ~Rectangle(); void SetLength(int length) { itsLength = length; } int GetLength() const { return itsLength; } void SetWidth(int width) { itsWidth = width; } int GetWidth() const { return itsWidth; } private: int itsLength; int itsWidth; }; Rectangle::Rectangle() { itsWidth = 5; itsLength = 10; } Rectangle::~Rectangle() {} int main() { Rectangle* pRect = new Rectangle; const Rectangle * pConstRect = new Rectangle; Rectangle * const pConstPtr = new Rectangle; cout << "szerokosc pRect: " << pRect->GetWidth() << " metrow\n"; cout << "szerokosc pConstRect: " << pConstRect->GetWidth() << " metrow\n"; cout << "szerokosc pConstPtr: " << pConstPtr->GetWidth() << " metrow\n"; pRect->SetWidth(10); // pConstRect->SetWidth(10); pConstPtr->SetWidth(10); cout << "szerokosc pRect: " << pRect->GetWidth()
48: 49: 50: 51: 52: 53: 54:
<< " metrow\n"; cout << "szerokosc pConstRect: " << pConstRect->GetWidth() << " metrow\n"; cout << "szerokosc pConstPtr: " << pConstPtr->GetWidth() << " metrow\n"; return 0; }
Wynik szerokosc szerokosc szerokosc szerokosc szerokosc szerokosc
pRect: 5 metrow pConstRect: 5 metrow pConstPtr: 5 metrow pRect: 10 metrow pConstRect: 5 metrow pConstPtr: 10 metrow
Analiza Linie od 6. do 19. deklarują klasę Rectangle (prostokąt). Linia 14. deklaruje metodę GetWidth() (pobierz szerokość) jako funkcję składową const. Linia 32. deklaruje wskaźnik do obiektu typu Rectangle. Linia 33. deklaruje pConstRect, który jest wskaźnikiem do stałego obiektu typu Rectangle. Linia 34. deklaruje pConstPtr, który jest stałym wskaźnikiem do obiektu typu Rectangle. Linie od 36. do 41. wypisują ich wartości. W linii 43. wskaźnik pRect jest używany do ustawienia szerokości prostokąta na 10. W linii 44. zostałby użyty wskaźnik pConstRect, ale został on zadeklarowany jako wskazujący na stały obiekt typu Rectangle. W związku z tym nie może legalnie wywoływać funkcji składowej, nie będącej funkcją const, dlatego został wykomentowany. W linii 45. wskaźnik pConstPtr wywołuje metodę SetWidth() (ustaw szerokość). Wskaźnik pConstPtrWidth został zadeklarowany jako stały wskaźnik do obiektu typu Rectangle. Innymi słowy, zawartość wskaźnika jest stała i nie może wskazywać na nic innego, natomiast wskazywany prostokąt nie jest stały.
Wskaźniki const this Gdy deklarujesz obiekt jako const, deklarujesz jednocześnie, że wskaźnik this jest wskaźnikiem do obiektu const. Wskaźnik const this może być używany tylko z funkcjami składowymi const. Stałe obiekty i stałe wskaźniki omówimy szerzej w następnym rozdziale, przy okazji omawiania referencji do stałych obiektów.
TAK
NIE
Jeśli nie chcesz, by obiekty przekazywane przez Nie usuwaj wskaźnika więcej niż jeden raz. referencję były zmieniane, chroń je słowem kluczowym const.
Jeśli obiekt może być zmieniany, przekazuj go przez referencję. Jeśli nie chcesz, by zmieniany był mały obiekt, przekazuj go przez wartość.
Działania arytmetyczne na wskaźnikach — temat dla zaawansowanych Wskaźniki można od siebie odejmować. Jedną z użytecznych technik jest przypisanie dwóm wskaźnikom różnych elementów tablicy, a następnie odjęcie wskaźników od siebie (w celu obliczenia ilości elementów rozdzielających dwa wskazywane elementy). Może to być bardzo użyteczne w przypadku przetwarzania tablic znaków. Pokazuje to listing 8.11. Listing 8.11. Wydzielanie słów z łańcucha znaków 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36:
#include #include #include bool GetWord(char* theString, char* word, int& wordOffset); // program sterujący int main() { const int bufferSize = 255; char buffer[bufferSize+1]; // zawiera cały łańcuch char word[bufferSize+1]; // zawiera słowo int wordOffset = 0; // zaczynamy od początku std::cout << "Wpisz lancuch znakow:\n"; std::cin.getline(buffer,bufferSize); while (GetWord(buffer,word,wordOffset)) { std::cout << "Wydzielono slowo: " << word << std::endl; } return 0; }
// funkcja wydzielająca słowa z łańcucha. bool GetWord(char* theString, char* word, int& wordOffset) { if (!theString[wordOffset]) return false;
// koniec łańcucha?
char *p1, *p2; p1 = p2 = theString+wordOffset;
// wskazuje następne słow
37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52: 53: 54: 55: 56: 57: 58: 59: 60: 61: 62: 63: 64: 65: 66: 67: 68: 69: 70: 71: 72: 73: 74: 75:
// pomijamy wiodące spacje for (int i = 0; i<(int)strlen(p1) && !isalnum(p1[0]); i++) p1++; // sprawdzamy, czy mamy słowo if (!isalnum(p1[0])) return false; // p1 wskazuje teraz na początek następnego słowa; // niech na nie wskazuje takŜe p2 p2 = p1; // przesuwamy p2 na koniec słowa while (isalnum(p2[0])) p2++; // teraz p2 wskazuje na koniec słowa // p1 wskazuje na początek słowa // róŜnicą jest długość słowa int len = int (p2 - p1); // kopiujemy słowo do bufora strncpy (word,p1,len); // kończymy je bajtem zerowym word[len]='\0'; // szukamy początku następnego słowa for (int j = int(p2-theString); j<(int)strlen(theString) && !isalnum(p2[0]); j++) { p2++; } wordOffset = int(p2-theString); return true; }
Wynik Wpisz lancuch znakow: Ten kod po raz pierwszy pojawil sie w raporcie jezyka C++ Wydzielono slowo: Ten Wydzielono slowo: kod Wydzielono slowo: po Wydzielono slowo: raz Wydzielono slowo: pierwszy Wydzielono slowo: pojawil Wydzielono slowo: sie Wydzielono slowo: w Wydzielono slowo: raporcie Wydzielono slowo: jezyka Wydzielono slowo: C
Analiza
W linii 15. użytkownik jest proszony o wpisanie łańcucha znaków. Otrzymany łańcuch jest przekazywany funkcji GetWord() (pobierz słowo) w linii 18., wraz z buforem do przechowania pierwszego słowa i zmienną wordOffset (przesunięcie słowa), inicjalizowaną w linii 13. wartością zero. Słowa zwracane przez funkcję GetWord() są wypisywane do chwili, w której funkcja ta zwróci wartość false. Każde wywołanie funkcji GetWord() powoduje skok do linii 29. W linii 32. sprawdzamy, czy wartością theString[wordOffset] jest zero – to będzie oznaczać, że doszliśmy do końca łańcucha; w takim przypadku funkcja GetWord() zwróci wartość false. Zwróć uwagę na fakt, że C++ uważa zero za wartość false. Moglibyśmy przepisać tę linię następująco: 32:
if (theString[wordOffset] == 0)
// koniec łańcucha?
W linii 35. są deklarowane dwa wskaźniki do znaków, p1 i p2, które w linii 36. są inicjalizowane tak, aby wskazywały na miejsce łańcucha o przesunięciu wordOffset względem jego początku. Początkowo zmienna wordOffset ma wartość zero, więc oba wskaźniki wskazują początek łańcucha. Linie 39. i 40. przechodzą poprzez łańcuch, do chwili, w której wskaźnik p1 wskaże pierwszy znak alfanumeryczny. Linie 43. i 44. zapewniają, że faktycznie znaleźliśmy znak alfanumeryczny; jeśli tak nie jest, zwracamy wartość false. Wskaźnik p1 wskazuje teraz na następne słowo, a linia 48. ustawia wskaźnik p2 tak, aby wskazywał na to samo miejsce. Następnie linie 51. i 52. powodują, że wskaźnik p2 przechodzi poprzez słowo, zatrzymując się na pierwszym znaku nie będącym znakiem alfanumerycznym. W tym momencie wskaźnik p2 wskazuje na koniec słowa, na którego początek wskazuje wskaźnik p1. Odejmując wskaźnik p1 od wskaźnika p2 w linii 55. i rzutując rezultat do wartości całkowitej, możemy obliczyć długość słowa. Następnie kopiujemy słowo do bufora word (słowo), przekazując wskaźnik (wskazujący początkowy znak – p1) oraz obliczoną długość słowa. W linii 63. na końcu słowa w buforze dopisujemy wartość null (zero). Wskaźnik p2 jest inkrementowany tak,l by wskazywał na początek następnego słowa, następnie przesunięcie tego słowa (względem początku łańcucha) jest umieszczane w zmiennej referencyjnej wordOffset. Na koniec, funkcja zwraca wartość true, aby wskazać, że znaleziono następne słowo. Jest to klasyczny przykład kodu, który najlepiej analizować uruchamiając w debuggerze, śledząc jego działanie krok po kroku.
Rozdział 9. Referencje W poprzednim rozdziale poznałeś wskaźniki i dowiedziałeś się, jak za ich pomocą można operować obiektami na stercie oraz jak odwoływać się do obiektów pośrednio. Referencje mają prawie te same możliwości, co wskaźniki, ale posiadają przy tym dużo prostszą składnię. Z tego rozdziału dowiesz się: •
czym są referencje,
•
czym różnią się od wskaźników,
•
jak się je tworzy i wykorzystuje,
•
jakie są ich ograniczenia,
•
w jaki sposób przekazywać obiekty i wartości do i z funkcji za pomocą referencji.
Czym jest referencja? Referencja jest aliasem (inną nazwą); gdy tworzysz referencję, inicjalizujesz ją nazwą innego obiektu, będącego celemu referencji. Od tego momentu referencja działa jak alternatywna nazwa celu. Wszystko, co robisz z referencją, w rzeczywistości jest robione dotyczyz jej obiektuem docelowego. Referencję tworzy się, zapisując typ obiektu docelowego, operator referencji (&) oraz nazwę referencji. Nazwy referencji mogą być dowolne, ale wielu programistów woli poprzedzać jej nazwę literą „r”. Jeśli masz zmienną całkowitą o nazwie someInt, możesz stworzyć referencję do niej pisząc: int &rSomeRef = someInt;
Odczytuje się to jako: „rSomeRef jest referencją do wartości zmiennej typu int. Ta referencja została zainicjalizowana tak, aby odnosiła się do zmiennej someInt.” Sposób tworzenia referencji i korzystania z niej przedstawia listing 9.1. UWAGA Operator referencji (&) ma taki sam symbol, jak operator adresu. Nie są to jednak te same operatory (choć oczywiście są ze sobą powiązane).
Zastosowanie spacji przed operatorem referencji jest obowiązkowe, użycie spacji pomiędzy operatorem referencji a nazwą zmiennej referencyjnej jest opcjonalne. Tak więc: int &rSomeRef = someInt; // ok int & rSomeRef = someInt; // ok
Listing 9.1. Tworzenie referencji i jej użycie 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20:
//Listing 9.1 // Demonstruje uŜycie referencji #include int main() { using namespace std; int intOne; int &rSomeRef = intOne; intOne = 5; cout << "intOne: " << intOne << endl; cout << "rSomeRef: " << rSomeRef << endl; rSomeRef = 7; cout << "intOne: " << intOne << endl; cout << "rSomeRef: " << rSomeRef << endl; return 0; }
Wynik intOne: 5 rSomeRef: 5 intOne: 7 rSomeRef: 7
Analiza W linii 8. jest deklarowana lokalna zmienna intOne. W linii 9. referencja rSomeRef (jakaś referencja) jest deklarowana i inicjalizowana tak, by odnosiła się do zmiennej intOne. Jeśli zadeklarujesz referencję, lecz jej nie zainicjalizujesz, kompilator zgłosi błąd powstały podczas kompilacji. Referencje muszą być zainicjalizowane. W linii 11. zmiennej intOne jest przypisywana wartość 5. W liniach 12. i 13. są wypisywane wartości zmiennej intOne i referencji rSomeRef; są one oczywiście takie same.
W linii 17. referencji rSomeRef jest przypisywana wartość 7. Ponieważ jest to referencja, czyli inna nazwa zmiennej intOne, w rzeczywistości wartość ta jest przypisywana tej zmiennej (co potwierdzają komunikaty wypisywane w liniach 16. i 17.).
Użycie operatora adresu z referencją Gdy pobierzesz adres referencji, uzyskasz adres jej celu. Wynika to z natury referencji (są one aliasami dla obiektów docelowych). Pokazuje to listing 9.2. Listing 9.2. Odczytywanie adresu referencji 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19:
//Listing 9.2 // Demonstruje uŜycie referencji #include int main() { using namespace std; int intOne; int &rSomeRef = intOne; intOne = 5; cout << "intOne: " << intOne << endl; cout << "rSomeRef: " << rSomeRef << endl; cout << "&intOne: " << &intOne << endl; cout << "&rSomeRef: " << &rSomeRef << endl; return 0; }
Wynik intOne: 5 rSomeRef: 5 &intOne: 0012FF7C &rSomeRef: 0012FF7C
UWAGA W twoim komputerze dwie ostatnie linie mogą wyglądać inaczej.
Analiza W tym przykładzie referencja rSomeRef ponownie odnosi się do zmiennej intOne. Tym razem jednak wypisywane są adresy obu zmiennych; są one identyczne. C++ nie umożliwia dostępu do adresu samej referencji, gdyż jego użycie, w odróżnieniu od użycia adresu zmiennej, nie miałoby sensu. Referencje są inicjalizowane podczas tworzenia i zawsze stanowią synonim dla swojego obiektu docelowego (nawet gdy zostanie zastosowany operator adresu). Na przykład, jeśli masz klasę o nazwie President, jej egzemplarz możesz zadeklarować następująco:
President George_Washington;
Możesz wtedy zadeklarować referencję do klasy President i zainicjalizować ją tym obiektem: President &FatherOfOurCountry = George_Washington;
Istnieje tylko jeden obiekt klasy President; oba identyfikatory odnoszą się do tego samego egzemplarza obiektu tej samej klasy. Wszelkie operacje, jakie wykonasz na zmiennej FatherOfOurCountry (ojciec naszego kraju), będą odnosić się do obiektu George_Washington. Należy odróżnić symbol & w linii 9. listingu 9.2 (deklarujący referencję o nazwie rSomeRef) od symboli & w liniach 15. i 16., które zwracają adresy zmiennej całkowitej intOne i referencji rSomeRef. Zwykle w trakcie używania referencji nie używa się operatora adresu. Referencji używa się tak, jak jej zmiennej docelowej. Pokazuje to linia 13.
Nie można zmieniać przypisania referencji Nawet doświadczonym programistom C++, którzy wiedzą, że nie można zmieniać przypisania referencji, gdyż jest ona aliasem swojego obiektu docelowego, zdarza się próba zmiany jej przypisania. To, co wygląda w takiej sytuacji na ponowne przypisanie referencji, w rzeczywistości jest przypisaniem nowej wartości obiektowi docelowemu. Przedstawia to listing 9.3. Listing 9.3. Przypisanie do referencji 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24:
//Listing 9.3 //Ponowne przypisanie referencji #include int main() { using namespace std; int intOne; int &rSomeRef = intOne; intOne = 5; cout << "intOne:\t" << intOne << endl; cout << "rSomeRef:\t" << rSomeRef << endl; cout << "&intOne:\t" << &intOne << endl; cout << "&rSomeRef:\t" << &rSomeRef << endl; int intTwo = 8; rSomeRef = intTwo; // to nie to o czym myślisz! cout << "\nintOne:\t" << intOne << endl; cout << "intTwo:\t" << intTwo << endl; cout << "rSomeRef:\t" << rSomeRef << endl; cout << "&intOne:\t" << &intOne << endl; cout << "&intTwo:\t" << &intTwo << endl; cout << "&rSomeRef:\t" << &rSomeRef << endl;
25: 26:
return 0; }
Wynik intOne: 5 rSomeRef: &intOne: &rSomeRef:
5 0012FF7C 0012FF7C
intOne: 8 intTwo: 8 rSomeRef: &intOne: &intTwo: &rSomeRef:
8 0012FF7C 0012FF74 0012FF7C
Analiza Także w tym programie zostały zadeklarowane (w liniach 8. i 9.) zmienna całkowita i referencja do niej. W linii 11. zmiennej jest przypisywana wartość 5, po czym w liniach od 12. do 15. wypisywane są wartości i ich adresy. W linii 17. tworzona jest nowa zmienna, intTwo, inicjalizowana wartością 8. W linii 18. programista próbuje zmienić przypisanie referencji rSomeRef tak, aby odnosiła się do zmiennej intTwo, lecz mu się to nie udaje. W rzeczywistości referencja rSomeRef w dalszym ciągu jest aliasem dla zmiennej intOne, więc to przypisanie stanowi ekwiwalent dla: intOne = intTwo;
Potwierdzają to wypisywane w liniach 19. do 21. komunikaty, pokazujące wartości zmiennej intOne i referencji rSomeRef. Ich wartości są takie same, jak wartość zmiennej intTwo. W rzeczywistości, gdy w liniach od 22. do 24. są wypisywane adresy, okazuje się, że rSomeRef w dalszym ciągu odnosi się do zmiennej intOne, a nie do zmiennej intTwo.
TAK
NIE
W celu stworzenia aliasu do obiektu używaj referencji.
Nie zmieniaj przypisania referencji.
Inicjalizuj wszystkie referencje.
Nie myl operatora adresu z operatorem referencji.
Do czego mogą odnosić się referencje? Referencje mogą odnosić się do każdego z obiektów, także do obiektów zdefiniowanych przez użytkownika. Zwróć uwagę, że referencja odnosi się do obiektu, a nie do klasy, do której ten obiekt należy. Nie możesz napisać: int & rIntRef = int; // źle
Musisz zainicjalizować referencję rIntRef tak, aby odnosiła się do konkretnej zmiennej całkowitej, na przykład: int howBig = 200; int & rIntRef = howBig;
W ten sam sposóbNie możesz zainicjalizować też referencji do klasy CAT: CAT & rCatRef = CAT; // źle
Musisz zainicjalizować referencję rIntCatRef tak, aby odnosiła się do konkretnego egzemplarza tej klasy: CAT mruczek; CAT & rCatRef = mruczek;
Referencje do obiektów są używane w taki sam sposób, jak obiekty. Dane i funkcje składowe są dostępne poprzez ten sam operator dostępu do składowych (.) i, podobnie jak w typach wbudowanych, referencja działa jak inna nazwa obiektu. Ilustruje to listing 9.4. Listing 9.4. Referencje do obiektów 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17:
// Listing 9.4 // Referencje do obiektów klas #include class SimpleCat { public: SimpleCat (int age, int weight); ~SimpleCat() {} int GetAge() { return itsAge; } int GetWeight() { return itsWeight; } private: int itsAge; int itsWeight; }; SimpleCat::SimpleCat(int age, int weight)
18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33:
{ itsAge = age; itsWeight = weight; } int main() { SimpleCat Mruczek(5,8); SimpleCat & rCat = Mruczek; std::cout std::cout std::cout std::cout return 0;
<< << << <<
"Mruczek ma: "; Mruczek.GetAge() << " lat. \n"; "i wazy: "; rCat.GetWeight() << " funtow. \n";
}
Wynik Mruczek ma: 5 lat. i wazy: 8 funtow.
Analiza W linii 25. zmienna Mruczek jest deklarowana jako obiekt klasy SimpleCat (prostyzwykły kot). W linii 26. jest deklarowana referencja rCat do obiektu klasy SimpleCat, która odnosi się do obiektu Mruczek. W liniach 29. i 31. są wykorzystywane akcesory klasy SimpleCat, najpierw poprzez obiekt klasy, a potem poprzez referencję. Zwróć uwagę, że dostęp do nich jest identyczny. Także w tym przypadku referencja jest inną nazwą (aliasem) rzeczywistego obiektu.
Referencje
Referencję deklaruje się, zapisując typ, operator referencji (&) oraz nazwę referencji. Referencje muszą być inicjalizowane w trakcie ich tworzenia.
Przykład 1 int hisAge; int &rAge = hisAge;
Przykład 2 CAT Filemon; CAT &rCatRef = Filemon;
PusteZerowe wskaźniki i pustezerowe referencje Gdy wskaźniki nie są inicjalizowane lub zostaną zwolnione, powinno się im przypisać wartość zerową null (0). W przypadku referencji sytuacja wygląda inaczej. Referencja nie może być pustazerowa, a program zawierający referencję do nie istniejącego (czyli pustego) obiektu, jest uważany za niewłaściwy. Gdy program jest niewłaściwy, może zdarzyć się prawie wszystko. Może się zdarzyć, że taki program działa, ale równie dobrze może też usunąć wszystkie pliki z dysku. Większość kompilatorów obsługuje puste obiekty, powodując załamanie programu tylko wtedy, gdy spróbujesz użyć takiego obiektu. Obsługa pustych obiektów nie jest dobrym pomysłem. Gdy przeniesiesz program do innego komputera lub kompilatora, puste obiekty mogą spowodować tajemnicze błędy w działaniu programu.
Przekazywanie argumentów funkcji przez referencję Z rozdziału 5., „Funkcje”, dowiedziałeś się, że funkcje mają dwa ograniczenia: argumenty są przekazywane przez wartość, a funkcja może zwrócić tylko jedną wartość. Przekazywanie argumentów funkcji poprzez referencję może zlikwidować oba te ograniczenia. W C++, przekazywanie przez referencję odbywa się na dwa sposoby: z wykorzystaniem wskaźników i z wykorzystaniem referencji. Zauważ różnicę: przekazujesz poprzez referencję, używając wskaźnika lub przekazujesz poprzez referencję, używając referencji. Składnia użycia wskaźnika jest inna niż użycia referencji, ale ogólny efekt jest taki sam. W dużym uproszczeniu można powiedzieć, że zamiast tworzyć w funkcji kopię przekazywanego obiektu, program przekazuje jej obiekt oryginalny. Z rozdziału 5. dowiedziałeś się, że argumenty funkcji są im przekazywane poprzez stos. Gdy funkcja otrzymuje wartość poprzez referencję (z użyciem wskaźnika lub referencji), na stosie umieszczany jest adres obiektu, a nie cały obiekt. W niektórych komputerach adres jest przechowywany w rejestrze i nie jest umieszczany na stosie. Kompilator wie, jak odwołać się do oryginalnego obiektu, więc zmiany są dokonywane w tym obiekcie, a nie w jego kopii. Przekazanie obiektu przez referencję umożliwia funkcji dokonywanie zmian w tym obiekcie. Przypomnij sobie, że listing 5.5 z rozdziału piątego pokazywał, że wywołanie funkcji swap() nie miało wpływu na wartości w funkcji wywołującej. Listing 5.5 został tu dla wygody odtworzonypowtórzony jako listing 9.5. Listing 9.5. Przykład przekazywania przez wartość 0: 1: 2:
//Listing 9.5 Demonstruje przekazywanie przez wartość #include
3: 4: 5: 6: 7: 8: 9: 10: 11: " y: 12: 13: y: " 14: 15: 16: 17: 18: 19: 20: 21: << y 22: 23: 24: 25: 26: 27: y << 28: 29:
using namespace std; void swap(int x, int y); int main() { int x = 5, y = 10; cout << "Funkcja main(). Przed funkcja swap(), x: " << x << " << y << "\n"; swap(x,y); cout << "Funkcja main(). Po funkcji swap(), x: " << x << " << y << "\n"; return 0; } void swap (int x, int y) { int temp; cout << "Funkcja swap(). Przed zamiana, x: " << x << " y: " << "\n"; temp = x; x = y; y = temp; cout << "Funkcja swap(). Po zamianie, x: " << x << " y: " << "\n"; }
Wynik Funkcja Funkcja Funkcja Funkcja
main(). swap(). swap(). main().
Przed funkcja swap(), x: 5 y: 10 Przed zamiana, x: 5 y: 10 Po zamianie, x: 10 y: 5 Po funkcji swap(), x: 5 y: 10
Analiza Wewnątrz funkcji main() program inicjalizuje dwie zmienne i przekazuje je funkcji swap() (zamień), która wydaje się je zamieniać. Jednak gdy ponownie sprawdzimy ich wartości w funkcji main(), okaże się, że nie uległy one zmianie! Problem polega na tym, że zmienne x i y są przekazywane funkcji swap() poprzez wartość. Oznacza to, że wewnątrz tej funkcji są tworzone ich lokalne kopie. Nam potrzebne jest przekazanie zmiennych x i y przez referencję. W C++ istnieją dwie możliwości rozwiązania tego problemu: parametry funkcji swap() możesz zamienić na wskaźniki do oryginalnych wartości, lub przekazać referencje do pierwotnych wartości.
Tworzenie funkcji swap() otrzymującej wskaźniki Przekazując wskaźnik, przekazujesz adres obiektu, dlatego funkcja może manipulować wartością znajdującą się pod tym adresem. Aby za pomocą wskaźników umożliwić funkcji swap() zamianę wartości swoich argumentów, powinieneś zadeklarować ją jako przyjmującą dwa wskaźniki do zmiennych całkowitych. Następnie, poprzez wyłuskanie wskaźników (czyli dereferencję), możesz zamienić wartości zmiennych miejscami miejscami. Demonstruje to listing 9.6. Listing 9.6. Przekazywanie przez referencję za pomocą wskaźników 0: //Listing 9.6 Demonstruje przekazywanie przez referencję 1: 2: #include 3: 4: using namespace std; 5: void swap(int *x, int *y); 6: 7: int main() 8: { 9: int x = 5, y = 10; 10: 11: cout << "Funkcja main(). Przed funkcja swap(), x: " << x << " y: " << y << "\n"; 12: swap(&x,&y); 13: cout << "Funkcja main(). Po funkcji swap(), x: " << x << " y: " << y << "\n"; 14: return 0; 15: } 16: 17: void swap (int *px, int *py) 18: { 19: int temp; 20: 21: cout << "Funkcja swap(). Przed zamiana, *px: " << *px << 22: " *py: " << *py << "\n"; 23: 24: temp = *px; 25: *px = *py; 26: *py = temp; 27: 28: cout << "Funkcja swap(). Po zamianie, *px: " << *px << 29: " *py: " << *py << "\n"; 30: 31: }
Wynik Funkcja Funkcja Funkcja Funkcja
main(). swap(). swap(). main().
Przed funkcja swap(), x: 5 y: 10 Przed zamiana, *px: 5 *py: 10 Po zamianie, *px: 10 *py: 5 Po funkcji swap(), x: 10 y: 5
Analiza Udało się! W linii 5. został zmieniony prototyp funkcji swap(), w którym zadeklarowano że oba parametry funkcji są wskaźnikami do zmiennych typu int, a nie zmiennymi tego typu. Gdy w linii 12. następuje wywołanie funkcji swap(), jako argumenty są jej przekazywane adresy zmiennych x i y.
W linii 19., w funkcji swap(),deklarowana jest lokalna zmienna temp1. Ta zmienna nie musi być wskaźnikiem; w czasie życia funkcji swap() przechowuje ona wartość *px (tj. wartość zmiennej x zadeklarowanej w funkcji wywołującej). Gdy funkcja swap() zakończy działanie, zmienna temp nie będzie już potrzebna. W linii 24. zmiennej temp przypisywana jest wartość wskazywana przez px. W linii 25. zmiennej wskazywanej przez px przypisywana jest wartość wskazywana przez py. W linii 26. zmiennejwartość umieszczona przechowywana w zmiennej temp (tj. oryginalna wartość wskazywana przez px) jest umieszczana w zmiennej wskazywanej przez py. Efektem przeprowadzonych przez nas działań jest zamiana wartości tych zmiennych, których adresy zostały przekazane do funkcji swap().
Implementacja funkcji swap() za pomocą referencji Przedstawiony wcześniej program działa, ale składnia pokazanej w nim funkcji swap() ma dwie wady. Po pierwsze, konieczność wyłuskiwania wskaźników wewnątrz funkcji swap() ułatwia popełnieni błędów i zmniejsza czytelność programu. Po drugie, konieczność przekazania adresów zmiennych przez funkcję wywołującą zdradza użytkownikom sposób działania funkcji swap(). W języku C++ użytkownik funkcji nie ma możliwości poznania sposobu jej działania. Przekazywanie wskaźników do argumentówparametrów oznacza konieczność odpowiednich przygotowań w funkcji wywołującej, a przecież przygotowania te powinny należeć do obowiązków funkcji wywoływanej. W listingu 9.7 funkcja swap() została ponownie przepisana, tym razem z zastosowaniem referencji, a nie wskaźników. Listing 9.7. Funkcja swap() przepisana z zastosowaniem referencji 0: //Listing 9.7 Demonstruje przekazywanie przez referencję 1: // z zastosowaniem referencji! 2: 3: #include 4: 5: using namespace std; 6: void swap(int &x, int &y); 7: 8: int main() 9: { 10: int x = 5, y = 10; 11: 12: cout << "Funkcja main(). Przed funkcja swap(), x: " << x << " y: " 13: << y << "\n"; 14: 15: swap(x,y); 16: 17: cout << "Funkcja main(). Po funkcji swap(), x: " << x << " y: " 18: << y << "\n"; 19: 20: return 0; 21: }
1
Ta nazwa jest skrótem od słowa temporary (tymczasowa) i bardzo często występuje w programach. — przyp.tłum.
22: 23: 24: 25: 26: 27: " 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38:
void swap (int &rx, int &ry) { int temp; cout << "Funkcja swap(). Przed zamiana, rx: " << rx << " ry: << ry << "\n"; temp = rx; rx = ry; ry = temp;
cout << "Funkcja swap(). Po zamianie, rx: " << rx << " ry: " << ry << "\n"; }
Wynik Funkcja Funkcja Funkcja Funkcja
main(). swap(). swap(). main().
Przed funkcja swap(), x: 5 y: 10 Przed zamiana, rx: 5 ry: 10 Po zamianie, rx: 10 ry: 5 Po funkcji swap(), x: 10 y: 5
Analiza Podobnie, jak w przykładzie ze wskaźnikami, także i tu (w linii 10.) deklarowane są dwie zmienne, których wartości wypisywane są w linii 12. W linii 15. następuje wywołanie funkcji swap(), ale zwróć uwagę, że tym razem nie są przekazywane adresy zmiennych x i y, lecz same zmienne. Funkcja wywołująca po prostu przekazuje zmienne. Gdy wywoływana jest funkcja swap(), działanie programu przechodzi do linii 23., w której zmienne zostają zidentyfikowane jako referencje. Ich wartości są wypisywane w linii 27., zwróć uwagę, że nie wymagają one przeprowadzania żadnych dodatkowych operacji. Są to aliasy oryginalnych wartości, które mogą zostać użyte jako te wartości. W liniach od 30. do 32. wartości są zamieniane, a następnie ponownie wypisywane w linii 35. Wykonanie programu wraca do funkcji wywołującej, zatem funkcja main() (w linii 17.) ponownie wypisuje wartości zmiennych. Ponieważ parametry funkcji swap() zostały zadeklarowane jako referencje, wartości w funkcji main() zostały przekazane przez referencję, dlatego są zamienione również w tej funkcji. Referencje ułatwiają korzystanie z normalnych zmiennych, zachowując przy tym możliwość przekazywania argumentów poprzez referencję.
Nagłówki i prototypy funkcji Listing 9.6 zawierał funkcję swap(), używającą wskaźników, zaś listing 9.7 zawierał tę samą funkcję używającą referencji. Stosowanie funkcji korzystającej z referencji jest łatwiejsze; łatwiejsze jest także zrozumienie kodu, ale skąd funkcja wywołująca wie, czy wartości są
przekazywane poprzez wartość, czy poprzez referencję? Jako klient (czyli użytkownik) funkcji swap(), programista musi mieć pewność, że funkcja ta faktycznie zamieni swoje parametry. Oto kolejne zastosowanie prototypów funkcji. Sprawdzając parametry zadeklarowane w prototypie, który zwykle znajduje się w pliku nagłówkowym wraz z innymi prototypami, programista wie, że wartości przekazywane do funkcji swap() są przekazywane poprzez referencję i wie, jak powinien ich użyć. Gdyby funkcja swap() była częścią klasy, informacji tych dostarczyłaby deklaracja klasy, także umieszczana zwykle w pliku nagłówkowym. W języku C++ wszystkich informacji potrzebnych klientom klas i funkcji mogą dostarczyć pliki nagłówkowe; pełnią one rolę interfejsu dla klasy lub funkcji. Implementacja jest natomiast ukrywana przed klientem. Dzięki temu programista może skupić się na analizowanym aktualnie problemie i korzystać z klasy lub funkcji bez zastanawiania się, w jaki sposób ona działa. Gdy John Roebling projektował Most Brookliński, zajmował się takimi szczegółami, jak sposób wylewania betonu czy metoda produkcji drutu do kabli nośnych. Znał każdy fizyczny i chemiczny proces związany z tworzeniem materiałów przeznaczonych do budowy mostu. Obecnie inżynierowie oszczędzają czas, używając dobrze znanych materiałów budowlanych, nie zastanawiając się, w jaki sposób są one tworzone przez producenta. Języka C++ umożliwia programistom korzystanie z „dobrze znanych” klas i funkcji, bez konieczności zajmowania się szczegółami ich działania. Te „części składowe” zostały złożonemogą zostać połączone w celu stworzenia programu (podobnie jak łączone są kable, rury, klamry i inne części w celu stworzenia mostu czy budynku). Inżynier przeglądający specyfikację betonu w celu poznania jego wytrzymałości, ciężaru własnego, czasu krzepnięcia, itd., a programista przegląda interfejs funkcji lub klasy w celu poznania usług, jakich ona dostarcza, parametrów, których potrzebuje i wartości, jakie zwraca.
Zwracanie kilku wartości Jak wspominaliśmy wcześniej, funkcja może zwracać (bezpośrednio) tylko jedną wartość. Co zrobić, gdy chcesz otrzymać od funkcji dwie wartości? Jednym ze sposobów rozwiązania tego problemu jest przekazanie funkcji dwóch obiektów poprzez referencje. Funkcja może wtedy wypełnić te obiekty właściwymi wartościami. Ponieważ przekazywanie przez referencję umożliwia funkcji zmianę pierwotnego obiektu, może ona zwrócić dwie oddzielne informacje. Dzięki temu wartość zwracana przez funkcję bezpośrednio może zostać wykorzystana w inny sposób, na przykład do zgłoszenia informacji o błędach. Także w tym przypadku do zwracania wartości w tenten sposób można użyć wskaźników lub referencji. Listing 9.8 przedstawia funkcję zwracającą trzy wartości: dwie zwracane jako parametry mające postać przekazywane przez wskaźników i jedną jako wartość zwracaną otną funkcji. Listing 9.8. Zwracanie wartości poprzez wskaźniki 0: 1: 2: 3:
//Listing 9.8 // Zwracanie kilku wartości z funkcji #include
4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41:
using namespace std; short Factor(int n, int* pSquared, int* pCubed); int main() { int number, squared, cubed; short error; cout << "Wpisz liczbe (0 - 20): "; cin >> number; error = Factor(number, &squared, &cubed); if (!error) { cout << cout << cout << } else cout << return 0;
"liczba: " << number << "\n"; "do kwadratu: " << squared << "\n"; "do trzeciej potegi: " << cubed << "\n";
"Napotkano blad!!\n";
} short Factor(int n, int *pSquared, int *pCubed) { short Value = 0; if (n > 20) Value = 1; else { *pSquared = n*n; *pCubed = n*n*n; Value = 0; } return Value; }
Wynik Wpisz liczbe (0 - 20): 3 liczba: 3 do kwadratu: 9 do trzeciej potegi: 27
Analiza W linii 10. zostały zadeklarowane trzy krótkie zmienne całkowite: number (liczba), squared (do kwadratu) oraz cubed (do trzeciej potęgi). Wartość zmiennej number jest wpisywana przez użytkownika. Ta liczba oraz adresy zmiennych squared i cubed są przekazywane do funkcji Factor() (czynnik). Funkcja Factor() sprawdza pierwszy parametr, który jest przekazywany przez wartość. Jeśli jest większy od 20 (maksymalnej wartości, jaką może obsłużyć funkcja), zwracanej wartości Value (wartość) przypisywany jest prosty kod błędu. Zwróć uwagę, że wartość zwracana funkcjia Factor() jest zarezerwowana dla może zwrotuócić tę albo tej wartościć błędu lubalbo wartościć 0, oznaczająceją, że wszystko poszło dobrze; wartość tę funkcja zwraca w linii 40.
Obliczane w funkcji wartości, czyli podniesiona do potęgi drugiej i do trzeciej potęgi liczba, są zwracane nie poprzez instrukcję return, ale bezpośrednio poprzez zmianę wartości zmiennych wskazywanych przez wskaźniki przekazane do funkcji. W liniach 36. i 37. zmiennym wskazywanym poprzez wartościomwskaźniki przypisywane są wyobliczone wartości wcześniej. W linii 38. zmiennej Value jest przypisywany kod sukcesu, który jest zwracany w linii 40. Jednym z ulepszeń wprowadzonych do tej funkcji mogłaoby być napisaniedeklaracja: enum ERROR_VALUE { SUCCESS, FAILURE};
Dzięki temu, zamiast zwracać wartości 0 lub 1, program mógłby zwracać odpowiednią wartość stałą typu wyliczeniowegoa ERROR_VALUE (wartość błędu), czyli albo SUCCESS (sukces) lubalbo FAILURE (porażka).
Zwracanie wartości przez referencję Choć program z listingu 9.8 działa poprawnie, byłby łatwiejszy w użyciu i modyfikacji, gdyby zamiast wskaźników zastosowano w nim referencje. Listing 9.9 przedstawia ten sam program przepisany tak, aby wykorzystywał referencje i typ wyliczeniowye ERR_CODE (kod błędu). Listing 9.9. Listing 9.8 przepisany z zastosowaniem referencji 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30:
//Listing 9.9 // Zwracanie kilku wartości z funkcji // z zastosowaniem referencji #include using namespace std; typedef unsigned short USHORT; enum ERR_CODE { SUCCESS, ERROR }; ERR_CODE Factor(USHORT, USHORT&, USHORT&); int main() { USHORT number, squared, cubed; ERR_CODE result; cout << "Wpisz liczbe (0 - 20): "; cin >> number; result = Factor(number, squared, cubed); if (result == SUCCESS) { cout << "liczba: " << number << "\n"; cout << "do kwadratu: " << squared << "\n"; cout << "do trzeciej potegi: " << cubed << "\n"; } else cout << "Napotkano blad!!\n"; return 0;
31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43:
} ERR_CODE Factor(USHORT n, USHORT &rSquared, USHORT &rCubed) { if (n > 20) return ERROR; // prosty kod błędu else { rSquared = n*n; rCubed = n*n*n; return SUCCESS; } }
Wynik Wpisz liczbe (0 - 20): 3 liczba: 3 do kwadratu: 9 do trzeciej potegi: 27
Analiza Listing 9.9 jest prawie identyczny z listingiem 9.8, z dwiema różnicami. Dzięki zastosowaniu wyliczenia ERR_CODE zgłaszanie błędów w liniach 36. i 41., a także ich obsługa w linii 22., są bardziej przejrzyste. Istotną zmianą jest to, że tym razem funkcja Factor() została zadeklarowana jako przyjmująca referencje, a nie wskaźniki, do zmiennych squared i cubed. Dzięki temu operowanie tymi parametrami jest prostsze i bardziej zrozumiałe.
Przekazywanie przez referencję zwiększa efektywność działania programu Za każdym razem, gdy przekazujesz obiekt do funkcji poprzez wartość, tworzona jest kopia tego obiektu. Za każdym razem, gdy zwracasz z funkcji obiekt poprzez wartość, tworzona jest kolejna kopia. Z rozdziału 5. dowiedziałeś się, że obiekty te są kopiowane na stos. Wymaga to sporej ilości czasu i pamięci. W przypadku niewielkich obiektów, takich jak wbudowane typy całkowite, koszt ten jest niewielki. Jednak w przypadku większych, zdefiniowanych przez użytkownika obiektów, ten koszt staje się dużo większy. Rozmiar zdefiniowanego przez użytkownika obiektu umieszczonego na stosie jest sumą rozmiarów wszystkich jego zmiennych składowych. Każda z tych zmiennych także może być obiektem zdefiniowanym przez użytkownika, a przekazywanie takich rozbudowanych struktur przez kopiowanie ich na stos może być mało wydajne i zużywać dużoe pamięci. Pojawiają się także dodatkowe koszty. W przypadku tworzonych przez ciebie klas, za każdym razem gdy kompilator tworzy kopię tymczasową, wywoływany jest specjalny konstruktor: konstruktor kopiującyi. Działanie konstruktorów kopiującychi i metody ich tworzenia zostaną
omówione w następnym rozdziale, na razie wystarczy, że będziesz wiedział, że konstruktor taki jest wywoływany za każdym razem, gdy na stosie jest umieszczana tymczasowa kopia obiektu. Gdy niszczony jest obiekt tymczasowy (na zakończenie działania funkcji), wywoływany jest destruktor obiektu. Jeśli obiekt jest zwracany z funkcji poprzez wartość, konieczne jest stworzenie i zniszczenie kopii także i tego obiektu. W przypadku dużych obiektów, takie wywołania konstruktorów i destruktorów mogą być kosztowne ze względu na szybkość i zużycie pamięci. Aby to zilustrować, listing 9.9 tworzy okrojony, zdefiniowany przez użytkownika obiekt klasy: SimpleCat. Prawdziwy obiekt byłby większy i droższy, ale nasz obiekt wystarczy do pokazania, jak często wywoływany jest konstruktor kopiującyi oraz destruktor. Listing 9.10 tworzy obiekt typu SimpleCat, po czym wywołuje dwie funkcje. Pierwsza z nich otrzymuje obiekt poprzez wartość i zwraca go również poprzez wartość. Druga funkcja otrzymuje wskaźnik do obiektu i zwraca także wskaźnik, bez przekazywania samego obiektu. Listing 9.10. Przekazywanie obiektów poprzez referencję, za pomocą wskaźników 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40:
//Listing 9.10 // Przekazywanie wskaźników do obiektów #include using namespace std; class SimpleCat { public: SimpleCat (); SimpleCat(SimpleCat&); ~SimpleCat(); };
// konstruktor // konstruktor kopiującyi // destruktor
SimpleCat::SimpleCat() { cout << "Konstruktor klasy SimpleCat...\n"; } SimpleCat::SimpleCat(SimpleCat&) { cout << "Konstruktor kopiującyi klasy SimpleCat...\n"; } SimpleCat::~SimpleCat() { cout << "Destruktor klasy SimpleCat...\n"; } SimpleCat FunctionOne (SimpleCat theCat); SimpleCat* FunctionTwo (SimpleCat *theCat); int main() { cout << "Tworze obiekt...\n"; SimpleCat Mruczek; cout << "Wywoluje funkcje FunctionOne...\n"; FunctionOne(Mruczek); cout << "Wywoluje funkcje FunctionTwo...\n"; FunctionTwo(&Mruczek); return 0;
41: 42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52: 53: 54: 55:
} // FunctionOne, parametr przekazywany poprzez wartość SimpleCat FunctionOne(SimpleCat theCat) { cout << "FunctionOne. Wracam...\n"; return theCat; } // FunctionTwo, parametr przekazywany poprzez wskaźnik SimpleCat* FunctionTwo (SimpleCat *theCat) { cout << "FunctionTwo. Wracam...\n"; return theCat; }
Wynik Tworze obiekt... Konstruktor klasy SimpleCat... Wywoluje funkcje FunctionOne... Konstruktor kopiującyi klasy SimpleCat... FunctionOne. Wracam... Konstruktor kopiującyi klasy SimpleCat... Destruktor klasy SimpleCat... Destruktor klasy SimpleCat... Wywoluje funkcje FunctionTwo... FunctionTwo. Wracam... Destruktor klasy SimpleCat...
Analiza W liniach od 6. do 12. została zadeklarowana bardzo uproszczona klasa SimpleCat. Zarówno konstruktor, jak i konstruktor kopiującyi oraz destruktor wypisują odpowiednie dla siebie komunikaty, dzięki którym wiadomo, w którym momencie zostały wywołane. W linii 34. funkcja main() wypisuje komunikat widoczny w pierwszej linii wyniku. W linii 35. tworzony jest egzemplarz obiektu klasy SimpleCat. Powoduje to wywołanie konstruktora tej klasy, co potwierdza druga linia wyniku. W linii 36. funkcja main() zgłasza (poprzez wypisanie komunikatu w trzeciej linii wydruku), że wywołuje funkcję FunctionOne., która wypisuje komunikat w trzeciej linii wyniku. Ponieważ ta funkcja otrzymuje obiekt typu SimpleCat przekazywany poprzez wartość, na stosie tworzona jest lokalna dla tej funkcji kopia obiektu klasy SimpleCat. To powoduje wywołanie konstruktora kopiującegoi, który wypisuje czwartą linię wyniku. Wykonanie programu przechodzi do wywoływanej funkcji, do linii 46., w której wypisywany jest komunikat informacyjny, stanowiący piątą linię wyniku. Następnie funkcja wraca i zwraca obiekt typu SimpleCat poprzez wartość. To powoduje utworzenie kolejnej kopii obiektu (łącznie zpoprzez wywołaniem konstruktora kopiującegoi, wypisującego też szóstą linię wyniku). Wartość zwracana przez funkcję FunctionOne() nie jest niczemu przypisywana, więc tymczasowy obiekt utworzony na stosie jest odrzucany, co powoduje wywołanie destruktora, który wypisuje siódmą linię wyniku. Ponieważ działanie funkcji FunctionOne() się zakończyło, jej
lokalna kopia obiektu wychodzi z zakresu i jest niszczona; powoduje to wywołanie destruktora i wypisanie ósmej linii wyniku. Program wraca do funkcji main(), w której zostaje teraz wywołana funkcja FunctionTwo(), lecz tym razem jej parametr jest przekazywany przez referencję. Nie jest tworzona żadna kopia, dlatego nie jest wypisywany żaden komunikat konstruktora. Funkcja FunctionTwo() wypisuje jedynie własny komunikat w dziesiątej linii wyniku, po czym zwraca obiekt typu SimpleCat, także poprzez wskaźnik, zatem także tym razem nie jest wywoływany konstruktor ani destruktor. Program kończy swoje działanie i obiekt Mruczek wychodzi z zakresu, powodując jeszcze jedno wywołanie destruktora, wypisującego komunikat w jedenastej linii wyniku. Ponieważ parametr funkcji FunctionOne() jest przekazywany i zwracany przez wartość, jej wywołanie wiąże się z dwoma wywołaniami konstruktora kopiującegoi i dwoma wywołaniami destruktora; natomiast wywołanie funkcji FunctionTwo() nie wymagało wywołania ani konstruktora, ani destruktora.
Przekazywanie wskaźnika const Choć przekazywanie wskaźnika jest dużo bardziej efektywne w funkcji FunctionTwo(), jednak jest także bardziej niebezpieczne. Funkcja FunctionTwo() nie powinna mieć możliwości zmiany otrzymanego obiektu SimpleCat, mimo, żeale otrzymuje wskaźnik do tego obiektu. ToTen wskaźnik daje jej jednak możliwość zmiany wartości tego obiektu, co nie jest możliwe w przypadku przekazywania obiektu przez wartość. Przekazywanie poprzez wartość przypomina przekazanie do muzeum reprodukcji arcydzieła, zamiast prawdziwego obrazu. Nawet, gdy do muzeum zakradnie się wandal, oryginał nie poniesie uszczerbku. Przekazywanie poprzez referencję przypomina przesłanie do muzeum swojego adresu domowego i zaproszenie gości do oglądania oryginałów. Rozwiązaniem tego problemu jest przekazanie wskaźnika do stałego (const) obiektu typu SimpleCat. W ten sposób zabezpieczamy ten obiekt przed wywoływaniem metod tej klasy innych niż metody typu niż const , chroniąc go tym samym metod tej klasy, czyli chronimy go przed zmianami. Przekazanie referencji typu const umożliwia gościom oglądanie oryginału, ale nie umożliwia jego modyfikacji. Demonstruje to listing 9.11. Listing 9.11. Przekazywanie wskaźnika do obiektu const 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13:
//Listing 9.11 // Przekazywanie wskaźników do obiektów #include using namespace std; class SimpleCat { public: SimpleCat(); SimpleCat(SimpleCat&); ~SimpleCat(); int GetAge() const { return itsAge; }
14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52: 53: 54: 55: 56: 57: 58: 59: 60: 61: 62: 63: 64: 65: 66: 67: 68:
void SetAge(int age) { itsAge = age; } private: int itsAge; }; SimpleCat::SimpleCat() { cout << "Konstruktor klasy SimpleCat...\n"; itsAge = 1; } SimpleCat::SimpleCat(SimpleCat&) { cout << "Konstruktor kopiującyi klasy SimpleCat...\n"; } SimpleCat::~SimpleCat() { cout << "Destruktor klasy SimpleCat...\n"; } const SimpleCat * const FunctionTwo (const SimpleCat * const theCat); int main() { cout << "Tworze obiekt...\n"; SimpleCat Mruczek; cout << "Mruczek ma " ; cout << Mruczek.GetAge(); cout << " lat\n"; int age = 5; Mruczek.SetAge(age); cout << "Mruczek ma " ; cout << Mruczek.GetAge(); cout << " lat\n"; cout << "Wywoluje funkcje FunctionTwo...\n"; FunctionTwo(&Mruczek); cout << "Mruczek ma " ; cout << Mruczek.GetAge(); cout << " lat\n"; return 0; } // functionTwo, otrzymuje wskaźnik const const SimpleCat * const FunctionTwo (const SimpleCat * const theCat) { cout << "FunctionTwo. Wracam...\n"; cout << "Mruczek ma teraz " << theCat->GetAge(); cout << " lat \n"; // theCat->SetAge(8); const! return theCat; }
Wynik Tworze obiekt... Konstruktor klasy SimpleCat...
Mruczek ma 1 lat Mruczek ma 5 lat Wywoluje funkcje FunctionTwo... FunctionTwo. Wracam... Mruczek ma teraz 5 lat Mruczek ma 5 lat Destruktor klasy SimpleCat...
Analiza Klasa SimpleCat zawiera dwa akcesory: GetAge() w linii 13., będący funkcją const oraz SetAge() w linii 14., nie będący funkcją const. Oprócz tego posiada zmienną składową itsAge, deklarowaną w linii 17. Konstruktor, konstruktor kopiującyi oraz destruktor wypisują odpowiednie komunikaty. Jednak konstruktor kopiującyi nie jest wywoływany, gdyż obiekt przekazywany jest poprzez referencję i nie jest tworzona żadna kopia. Na początku programu, w linii 42., tworzony jest obiekt, a w liniachi od 43. do 45. jest wypisywany wiek początkowy. W linii 47. zmienna składowa itsAge jest ustawiana za pomocą akcesora SetAge(), zaś wynik jest wypisywany w liniachi od 48. do 50. W tym programie nie jest używana funkcja FunctionOne(). i Pposługujemy się tylko funkcją FunctionTwo(). Uległa ona jednak niewielkiej zmianie; jej nagłówek został zmodyfikowany tak, że funkcja przyjmuje teraz stały wskaźnik do stałego obiektu i zwraca stały wskaźnik do stałego obiektu. Ponieważ parametr i wartość zwracana otna wciąż są przekazywane poprzez referencje, nie są tworzone żadne kopie, nie jest zatem wywoływany konstruktor kopiującyi. Jednak obecnie obiekt wskazywany w funkcji FunctionTwo() jest obiektem const, więc nie można wywoływać jego metod, nie będących metodami const, czyli nie można wywołać jego metody SetAge(). Gdyby wywołanie tej metody w linii 66. nie zostało umieszczone w komentarzu, program nie skompilowałby się. Zwróć uwagę, że obiekt tworzony w funkcji main() nie jest const, więc możemy dla niego wywołać funkcję SetAge(). Do funkcji FunctionTwo()przekazywany jest adres tego zwykłego obiektu, ale ponieważ deklaracja tej funkcji określa, że ten parametr jest wskaźnikiem const do obiektu const, obiekt ten jest traktowany, jakby był stały!
Referencje jako metoda alternatywna Listing 9.11 rozwiązuje problem tworzenia dodatkowych kopii i w ten sposób zmniejsza ilość wywołań konstruktora kopiującegoi i destruktora. Używa stałych wskaźników do stałych obiektów, rozwiązując w ten sposób problem zmiany obiektu przez funkcję. Jednak w dalszym ciągu jest dość nieczytelny, gdyż obiekty przekazywane do funkcji są wskaźnikami. Ponieważ wiemy, że ten obiekt nie jest pusty, możemy ułatwić sobie pracę w funkcji, stosując przekazanie przez referencję, a nie przez wskaźnik. Pokazuje to listing 9.12. Listing 9.12. Przekazywanie referencji do obiektów 0: 1: 2: 3: 4:
//Listing 9.12 // Przekazywanie wskaźników do obiektów #include
5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52: 53: 54: 55: 56: 57: 58: 59: 60: 61: 62: 63: 64: 65:
using namespace std; class SimpleCat { public: SimpleCat(); SimpleCat(SimpleCat&); ~SimpleCat(); int GetAge() const { return itsAge; } void SetAge(int age) { itsAge = age; } private: int itsAge; }; SimpleCat::SimpleCat() { cout << "Konstruktor klasy SimpleCat...\n"; itsAge = 1; } SimpleCat::SimpleCat(SimpleCat&) { cout << "Konstruktor kopiującyi klasy SimpleCat...\n"; } SimpleCat::~SimpleCat() { cout << "Destruktor klasy SimpleCat...\n"; } const SimpleCat & FunctionTwo (const SimpleCat & theCat); int main() { cout << "Tworze obiekt...\n"; SimpleCat Mruczek; cout << "Mruczek ma " ; cout << Mruczek.GetAge(); cout << " lat\n"; int age = 5; Mruczek.SetAge(age); cout << "Mruczek ma " ; cout << Mruczek.GetAge(); cout << " lat\n"; cout << "Wywoluje funkcje FunctionTwo...\n"; FunctionTwo(Mruczek); cout << "Mruczek ma " ; cout << Mruczek.GetAge(); cout << " lat\n"; return 0; } // functionTwo, otrzymuje referencję do obiektu const const SimpleCat & FunctionTwo (const SimpleCat & theCat) { cout << "FunctionTwo. Wracam...\n"; cout << "Mruczek ma teraz " << theCat.GetAge(); cout << " lat \n"; // theCat.SetAge(8); const! return theCat;
66:
}
Wynik Tworze obiekt... Konstruktor klasy SimpleCat... Mruczek ma 1 lat Mruczek ma 5 lat Wywoluje funkcje FunctionTwo... FunctionTwo. Wracam... Mruczek ma teraz 5 lat Mruczek ma 5 lat Destruktor klasy SimpleCat...
Analiza Wynik jest identyczny z wynikiem z listingu 9.11. Jedyną istotną różnicą w programie jest to, że obecnie funkcja FunctionTwo() otrzymuje i zwraca referencję do stałego obiektu. Także tym razem praca z referencjami jest nieco prostsza od pracy ze wskaźnikami, a na dodatek zapewnia tę samą efektywność oraz bezpieczeństwo obiektu const. Referencje const
Programiści C++ zwykle nie uznają różnicy pomiędzy „stałą referencją do obiektu typu SimpleCat” a „referencją do stałego obiektu typu SimpleCat”. Referencje nigdy nie mogą otrzymać ponownego przypisania i odnosić się do innego obiektu, więc są zawsze stałe. Jeśli słowo kluczowe const zostanie zastosowane w odniesieniu do referencji, sprawi, że to obiekt związany z referencją staje się stały.
Kiedy używać wskaźników, a kiedy referencji Programiści C++ zdecydowanie przedkładają referencje nad wskaźniki. Referencje są bardziej przejrzyste i łatwiejsze w użyciu, ponadto lepiej ukrywają szczegóły implementacji, co mogliśmy zobaczyć w poprzednim przykładzie. Nie można zmieniać obiektu docelowego referencji. Jeśli chcesz najpierw wskazać na jeden obiekt, a potem na inny, musisz użyć wskaźnika. Referencje nie mogą być zerowe puste, więc jeśli istnieje jakakolwiek możliwość, że dany obiekt będzie pusty (tzn., że może przestać istnieć), nie możesz użyć referencji. Musisz użyć wskaźnika. To ostatnie zagadnienie dotyczy operatora new. Gdy new nie może zaalokować pamięci na stercie, zwraca wskaźnik null (wskaźnik zerowy, czyli pusty). Ponieważ referencje nie mogą być puste, nie wolno ci przypisać referencji do tej pamięci, dopóki nie upewnisz się, że nie jest pusta. Właściwy sposób przypisania pokazuje poniższy przykład: int *pInt = new int; if (pInt != NULL) int &rInt = *pInt;
W tym przykładzie deklarowany jest wskaźnik pInt do typu int; jest on inicjalizowany adresem pamięci zwracanym przez operator new. Następnie jest sprawdzany adres w pInt i jeśli nie jest on pusty, wyłuskiwany jest wskaźnik. Rezultatem wyłuskania wskaźnika do typu int jest obiekt int, więc referencja rInt jest inicjalizowana jako odnosząca się do tego obiektu. W efekcie, referencja rInt staje się aliasem do wartości int o adresie zwróconym przez operator new.
TAK
NIE
Jeśli jest to możliwe, przekazuje parametry przez wartośćreferencję.
Nie używaj wskaźników tam, gdzie można użyć referencji.
Jeśli jest to możliwe, przekazuj przez referencję wartość zwracaną otnąprzez funkcję. Jeśli jest to możliwe, używaj const do ochrony referencji i wskaźników.
Łączenie referencji i wskaźników Dozwolone jest jednoczesne deklarowanie wskaźników oraz referencji na tej samej liście parametrów funkcji, a także obiektów przekazywanych przez wartość. Na przykład: CAT * SomeFunction (Person &theOwner, House *theHouse, int age);
Ta deklaracja informuje, że funkcja SomeFunction ma trzy parametry. Pierwszy z nich jest referencjąa do obiektu klasy Person (osoba), drugim jest wskaźnik do obiektu klasy House (dom), zaś trzecim jest wartość typu int. Funkcja zwraca wskaźnik do obiektu klasy CAT. Pytanie, gdzie powinien zostać umieszczony operator referencji (&) lub wskaźnika (*), jest bardzo kontrowersyjne. Możesz zastosować któryś z poniższych zapisów: 1: CAT& rMruczek; 2: CAT & rMruczek; 3: CAT &rMruczek;
UWAGA Białe spacje są całkowicie ignorowane, dlatego wszędzie tam, gdzie można umieścić spację, można także umieścić dowolną ilość innych spacji, tabulatorów czy nowych linii.
Jeśli powyższe zapisy Pozostawiając zagadnienia wyrażeńsą równoważne, który z zapisów nich jest najlepszy? Oto argumenty przemawiające za wszystkimi trzema:
Argumentem przemawiającym za przypadkiem 1. jest to, że rMruczek jest zmienną, której nazwą jest rMruczek, zaś typ może być traktowany jako „referencja do obiektu klasy CAT”. Zgodnie z tą argumentacją, & powinno znaleźć się przy typie.
Argumentem przeciwko przypadkowi 1. jest to, że typem jest klasa CAT. Symbol & jest częścią „deklaratora” zawierającego nazwę klasy i znak ampersand (&). Jednak umieszczenie & przy CAT może spowodować wystąpienie poniższego błędu: CAT& rMruczek, rFilemon;
Szybkie sprawdzenie tej linii może doprowadzić cię do odkrycia, że zarówno rMruczek, jak i rFilemon są referencjami do obiektów klasy CAT, ale w rzeczywistości tak nie jest. Ta deklaracja informuje, że rMruczek jest referencją do klasy CAT, zaś rFilemon (mimo zastosowanego przedrostka) nie jest referencją, lecz zwykłym obiektem klasy CAT. Tę deklarację należy przepisać następująco: CAT
&rMruczek, rFilemon;
Wniosek płynący z powyższych rozważań brzmi następująco: deklaracje referencji i zmiennych nigdy nie powinny występować w tej samej linii. Oto poprawny zapis: CAT& rMruczek; CAT Filemon;
Wielu programistów optuje za zastosowaniem operatora pośrodku, tak jak pokazuje przypadek 2.
Oczywiście, wszystko, co powiedziano dotąd na temat operatora referencji (&), odnosi się także do operatora wskaźnika (*). Należy zdawać sobie sprawę, że styl zapisu zależy od programisty. Wybierz więc styl, który ci odpowiada i konsekwentnie stosuj go w programach; przejrzystość kodu jest w końcu jednym z twoich głównych celów.
Deklarując referencje i wskaźniki, wielu programistów przestrzega następujących konwencji:
1.
Umieszczaj znak ampersand lub gwiazdkę pośrodku, ze spacją po obu stronach.
2.
Nigdy nie deklaruj w tej samej linii referencji, wskaźników i zmiennych.
Nie pozwól funkcji zwracać referencji do obiektu, którego nie ma w zakresie! Gdy programiści C++ nauczą się korzystać z referencji, przejawiają tendencję do używania ich bez zastanowienia, wszędzie, gdzie tylko się da. Można z tym przesadzić. Pamiętaj, że referencja jest zawsze aliasem do innego obiektu. Gdy przekazujesz referencje do lub z funkcji, pamiętaj, by zadać sobie pytanie: „Czym jest obiekt, do którego odnosi się referencja, i czy będzie istniał przez cały czas, gdy będę z niego korzystał?” Listing 9.13 pokazuje niebezpieczeństwo zwrócenia referencji do obiektu, który już nie istnieje. Listing 9.13. Zwracanie referencji do nieistniejącego obiektu 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39:
Wynik
// Listing 9.13 // Zwracanie referencji do obiektu // który juŜ nie istnieje #include
class SimpleCat { public: SimpleCat (int age, int weight); ~SimpleCat() {} int GetAge() { return itsAge; } int GetWeight() { return itsWeight; } private: int itsAge; int itsWeight; }; SimpleCat::SimpleCat(int age, int weight) { itsAge = age; itsWeight = weight; } SimpleCat &TheFunction(); int main() { SimpleCat &rCat = TheFunction(); int age = rCat.GetAge(); std::cout << "rCat ma " << age << " lat!\n"; return 0; } SimpleCat &TheFunction() { SimpleCat Mruczek(5,9); return Mruczek; }
Błąd kompilacji: próba zwrócenia referencji do lokalnego obiektu! OSTRZEŻENIE Ten program nie skompiluje się z kompilatorem firmy Borland. Skompiluje się jednak z kompilatorem firmy Microsoft, jednakco mimo wszystko powinno to być uważane za błąd.
Analiza W liniach od 7. do 17. deklarowana jest klasa SimpleCat. W linii 29. referencja do klasy SimpleCat jest inicjalizowana rezultatem wywołania funkcji TheFunction(), zadeklarowanej w linii 25. jako zwracająca referencję do obiektów klasy SimpleCat. W ciele funkcji TheFunction() jest deklarowany lokalny obiekt typu SimpleCat; konstruktor inicjalizuje jego wiek i wagę. Następnie ten obiekt lokalny jest zwracany poprzez referencję. Niektóre kompilatory są na tyle inteligentne, by wychwycić ten błąd i nie pozwolić na uruchomienie programu. Inne pozwolą na jego skompilowanie i uruchomienie, co może spowodować nieprzewidywalne zachowanie komputera. Gdy funkcja TheFunction() kończy działanie, jej obiekt lokalny, Mruczek, jest niszczony (zapewniam, że bezboleśnie). Referencja zwracana przez tę funkcję staje się aliasem do nieistniejącego obiektu, a to poważny błąd.
Zwracanie referencji do obiektu na stercie Być może kusi cię rozwiązanie problemu z listingu 9.13 – modyfikacja funkcji TheFunction() tak, by tworzyła Mruczka na stercie. Dzięki temu, gdy funkcja zakończy działanie, Mruczek będzie nadal istniał. W tym miejscu pojawia się następujący problem: co zrobisz z pamięcią zaalokowaną dla obiektu Mruczek, gdy nie będzie już potrzebny? To zagadnienie ilustruje listing 9.14. Listing 9.14. Wycieki pamięci 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18:
// Listing 9.14 // Unikanie wycieków pamięci #include class SimpleCat { public: SimpleCat (int age, int weight); ~SimpleCat() {} int GetAge() { return itsAge; } int GetWeight() { return itsWeight; } private: int itsAge; int itsWeight; }; SimpleCat::SimpleCat(int age, int weight)
19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44:
{ itsAge = age; itsWeight = weight; } SimpleCat & TheFunction(); int main() { SimpleCat & rCat = TheFunction(); int age = rCat.GetAge(); std::cout << "rCat ma " << age << " lat!\n"; std::cout << "&rCat: " << &rCat << std::endl; // jak się go pozbędziesz z pamięci? SimpleCat * pCat = &rCat; delete pCat; // a do czego teraz odnosi się rCat?? return 0; } SimpleCat &TheFunction() { SimpleCat * pMruczek = new SimpleCat(5,9); std::cout << "pMruczek: " << pMruczek << std::endl; return *pMruczek; }
Wynik pMruczek: 004800F0 rCat ma 5 lat! &rCat: 004800F0 OSTRZEŻENIE Ten program kompiluje się, uruchamia i sprawia wrażenie, że działa poprawnie. Jest jednak swego rodzaju bombą zegarową, która może w każdej chwili wybuchnąć.
Funkcja TheFunction() została zmieniona tak, że już nie zwraca referencji do lokalnej zmiennej. W linii 41. funkcja alokuje pamięć na stercie i przypisuje jej adres do wskaźnika. Adres zawarty w tym wskaźniku jest wypisywany w następnej linii, po czym wskaźnik jest wyłuskiwany, a wskazywany przez niego obiekt typu SimpleCat jest zwracany przez referencję. W linii 28. wynik funkcji TheFunction() jest przypisywany referencji do obiektu klasy SimpleCat, po czym ta referencja jest używana w celu uzyskania wieku kota, wypisywanego w linii 30. Aby udowodnić, że referencja zadeklarowana w funkcji main() odnosi się do obiektu umieszczonego na stercie przez funkcję TheFunction(), do referencji rCat został zastosowany operator adresu. Oczywiście, wyświetla on adres obiektu, do którego odnosi się referencja, zgodny z adresem pamięci na stercie. Jak dotąd wszystko jest w porządku. Ale w jaki sposób możemy zwolnić tę pamięć? Nie można wywołać operatora delete nadla referencji. Sprytnym rozwiązaniem jest utworzenie kolejnego wskaźnika i zainicjalizowanie go adresem uzyskanym od referencji rCat. Dzięki temu można zwolnić pamięć i „powstrzymać” jej wyciek. Powstaje jednak pewien problem: do czego odnosi
się referencja rCat po wykonaniu linii 34.? Jak już wspomnieliśmy, referencja zawsze musi stanowić alias rzeczywistego obiektu; jeśli odnosi się do obiektu pustego (tak, jak w tym przypadku), program jest błędny. UWAGA Jeszcze raz należy przypomnieć, że program z referencją do pustego obiektu może się skompilować, ale jest błędny i jego działanie jest nieprzewidywalne.
Istnieją trzy rozwiązania tego problemu. Pierwszym jest zadeklarowanie obiektu typu SimpleCat w linii 28. i zwrot tego obiektu z funkcji TheFunction() poprzez wartość. Drugim jest zadeklarowanie w funkcji TheFunction() obiektu SimpleCat na stercie, lecz ze zwróceniem wskaźnika. Wtedy funkcja wywołująca może sama usunąć ten wskaźnik gdy, nie będzie już potrzebować obiektu. Trzecim rozwiązaniem, tym właściwym, jest zadeklarowanie obiektu w funkcji wywołującej i przekazanie go funkcji TheFunction() przez referencję.
Wskaźnik, wskaźnik, kto ma wskaźnik? Gdy program alokuje pamięć na stercie, otrzymuje wskaźnik. Przechowywanie tego wskaźnika jest koniecznością, gdy zostanie on utracony, pamięć nie będzie mogła zostać zwolniona i powiększy stanie się tzw. wyciek iem pamięci. W czasie przekazywania bloku pamięci pomiędzy funkcjami, ktoś przez cały czas „posiada” ten wskaźnik. Zwykle wartości w bloku są przekazywane poprzez referencje, zaś funkcja, która stworzyła pamięć, zajmuje się jej zwolnieniem. Jest to jednak reguła poparta doświadczeniem, a nie zasada wyryta w kamieniu. Tworzenie pamięci w jednej funkcji i zwalnianie jej w innej może być niebezpieczne. Nieporozumienia co do tego, kto posiada wskaźnik, mogą spowodować dwa następujące problemy: zapomnienie o zwolnieniu wskaźnika lub dwukrotnie zwolnienie go. W obu przypadkach jest to poważny błąd programu. Bezpieczniej jest budować funkcje tak, by usuwały pamięć, którą stworzyły. Jeśli piszesz funkcję, która musi stworzyć pamięć, po czym przekazać ją funkcji wywołującej, zastanów się nad zmianą jej interfejsu. Niech funkcja wywołująca sama alokuje pamięć i przekazuje ją innej funkcji przez referencję. Dzięki temu zarządzanie pamięcią pozostaje w tej funkcji, która jest przygotowana do jej usunięcia.
TAK
NIE
Gdy jesteś do tego zmuszony, przekazuj parametry przez wartość.
Nie przekazuj referencjię, jeśli obiekt, do którego się ona odnosi, może znaleźć się poza zakresem.
Gdy jesteś do tego zmuszony, zwracaj wynik funkcji przez wartość.
Nie używaj referencji do pustych obiektów.
Rozdział 10. Funkcje zaawansowane W rozdziale 5., „Funkcje”, poznałeś podstawy pracy z funkcjami. Teraz, gdy wiesz także, jak działają wskaźniki i referencje, możesz zgłębić zagadnienia dotyczące funkcji. Z tego rozdziału dowiesz się, w jaki sposób: •
przeciążać funkcje składowe,
•
przeciążać operatory,
•
pisać funkcje, mając na celu tworzenie klas z dynamicznie alokowanymi zmiennymi.
Przeciążone funkcje składowe Z rozdziału 5. dowiedziałeś się jak implementować polimorfizm funkcji, czyli ich przeciążanie, przez tworzenie dwóch lub więcej funkcji o tych samych nazwach, lecz innych parametrach. Funkcje składowe klas mogą być przeciążane w dokładnie ten sam sposób. Klasa Rectangle (prostokąt), zademonstrowana na listingu 10.1, posiada dwie funkcje DrawShape() (rysuj kształt). Pierwsza z nich, nie posiadająca parametrów, rysuje prostokąt na podstawie bieżących wartości składowych danego egzemplarza klasy. Druga funkcja otrzymuje dwie wartości (szerokość i długość) i rysuje na ich podstawie prostokąt, ignorując bieżące wartości zmiennych składowych. Listing 10.1. Przeciążone funkcje składowe 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11:
//Listing 10.1 PrzeciąŜanie funkcji składowych klasy #include // Deklaracja klasy Rectangle class Rectangle { public: // konstruktory Rectangle(int width, int height); ~Rectangle(){}
12: // przeciąŜona funkcja składowa klasy 13: void DrawShape() const; 14: void DrawShape(int aWidth, int aHeight) const; 15: 16: private: 17: int itsWidth; 18: int itsHeight; 19: }; 20: 21: // implementacja konstruktora 22: Rectangle::Rectangle(int width, int height) 23: { 24: itsWidth = width; 25: itsHeight = height; 26: } 27: 28: 29: // PrzeciąŜona funkcja DrawShape - nie ma parametrów 30: // Rysuje kształt w oparciu o bieŜące wartości zmiennych składowych 31: void Rectangle::DrawShape() const 32: { 33: DrawShape( itsWidth, itsHeight); 34: } 35: 36: 37: // PrzeciąŜona funkcja DrawShape - z dwoma parametrami 38: // Rysuje kształt w oparciu o podane wartości 39: void Rectangle::DrawShape(int width, int height) const 40: { 41: for (int i = 0; i
Wynik DrawShape(): ****************************** ****************************** ****************************** ****************************** ******************************
DrawShape(40,2): **************************************** ****************************************
Analiza Listing 10.1 prezentuje okrojoną wersję programu, zamieszczonego w podsumowaniu wiadomości po rozdziale 7. Aby zaoszczędzić miejsce, z programu usunięto sprawdzanie niepoprawnych wartości, a także niektóre z akcesorów. Główny program został sprowadzony do dużo prostszej postaci, w której nie ma już menu. Najważniejszy kod znajduje się w liniach 13. i 14., gdzie przeciążona została funkcja DrawShape(). Implementacja tych przeciążonych funkcji składowych znajduje się w liniach od 29. do 49. Zwróć uwagę, że funkcja w wersji bez parametrów po prostu wywołuje funkcję z parametrami, przekazując jej bieżące zmienne składowe. Postaraj się nigdy nie powtarzać tego samego kodu w kilkudwóch funkcjach, może to spowodować wiele problemów z zachowaniem ich w zgodności w trakcie wprowadzaniu poprawek go w synchronizacji(może stać się to przyczyną błędów). Główna funkcja tworzy w liniach od 51. do 61. obiekt prostokąta, po czym wywołuje funkcję DrawShape(), najpierw bez parametrów, a potem z dwoma parametrami typu int. Kompilator na podstawie ilości i typu podanych parametrów wybiera metodę. Można sobie wyobrazić także trzecią przeciążoną funkcję o nazwie DrawShape(), która otrzymywałaby jeden wymiar oraz wartość wyliczeniową, określającą, czy jest on wysokością czy szerokością (wybór należałby do użytkownika).
Użycie wartości domyślnych Podobnie, jak w przypadku funkcji składowych klasy, funkcje globalne również mogą mieć jedną lub więcej wartości domyślnych. W przypadku deklaracji wartości domyślnych w funkcjach składowych stosujemy takie same reguły, jak w funkcjach globalnych, co ilustruje listing 10.2. Listing 10.2. Użycie wartości domyślnych 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17:
//Listing 10.2 Domyślne wartości w funkcjach składowych #include using namespace std; // Deklaracja klasy Rectangle class Rectangle { public: // konstruktory Rectangle(int width, int height); ~Rectangle(){} void DrawShape(int aWidth, int aHeight, bool UseCurrentVals = false) const; private: int itsWidth;
18: int itsHeight; 19: }; 20: 21: // implementacja konstruktora 22: Rectangle::Rectangle(int width, int height): 23: itsWidth(width), // inicjalizacje 24: itsHeight(height) 25: {} // puste ciało konstruktora 26: 27: 28: // dla trzeciego parametru jest uŜywana domyślna wartość 29: void Rectangle::DrawShape( 30: int width, 31: int height, 32: bool UseCurrentValue 33: ) const 34: { 35: int printWidth; 36: int printHeight; 37: 38: if (UseCurrentValue == true) 39: { 40: printWidth = itsWidth; // uŜywa bieŜących wartości klasy 41: printHeight = itsHeight; 42: } 43: else 44: { 45: printWidth = width; // uŜywa wartości z parametrów 46: printHeight = height; 47: } 48: 49: 50: for (int i = 0; i
Wynik DrawShape(0,0,true)... ******************************
****************************** ****************************** ****************************** ****************************** DrawShape(40,2)... **************************************** ****************************************
Analiza Listing 10.2 zastępuje przeciążone funkcje DrawShape() pojedynczą funkcją z domyślnym parametrem. Ta funkcja, zadeklarowana w linii 13., posiada trzy parametry. Dwa pierwsze, aWidth (szerokość) i aHeight (wysokość) są typu int, zaś trzeci, UseCurrentVals (użyj bieżących wartości), jestwartością logiczną zmienną typu bool o domyślnej wartości false. Implementacja tej nieco udziwnionej funkcji rozpoczyna się w linii 28. Sprawdzany jest w niej trzeci parametr, UseCurrentValues. Jeśli ma on wartość true, wtedy do ustawienia lokalnych zmiennych printWidth (wypisywana szerokość) i printHeight (wypisywana wysokość) są używane zmienne składowe klasy, itsWidth oraz itsHeight. Jeśli parametr UseCurrentValues ma wartość false, podaną przez użytkownika, lub ustawioną domyślnie, wtedy zmiennym printWidth i printHeight są przypisywane wartości dwóch pierwszych argumentów funkcji. Zwróć uwagę, że gdy parametr UseCurrentValues ma wartość true, wartości dwóch pierwszych parametrów są całkowicie ignorowane.
Wybór pomiędzy wartościami domyślnymi a przeciążaniem funkcji Listingi 10.1 i 10.2 dają ten sam wynik, lecz przeciążone funkcje z listingu 10.1 są łatwiejsze do zrozumienia i wygodniejsze w użyciu. Poza tym, gdy jest potrzebna trzecia wersja — na przykład, gdy użytkownik chce dostarczyć szerokości albo wysokości osobno — można łatwo stworzyć kolejną przeciążoną funkcję. Z drugiej strony, w miarę dodawania kolejnych wersji, wartości domyślne mogą szybko stać się zbyt skomplikowane. W jaki sposób podjąć decyzję, czy użyć przeciążania funkcji, czy wartości domyślnych? Oto ogólna reguła: Przeciążania funkcji używaj, gdy: •
nie istnieje sensowna wartość domyślna,
•
używasz różnych algorytmów,
•
chcesz korzystać z różnych rodzajów parametrów funkcji.
Konstruktor domyślny Jak mówiliśmy w rozdziale 6., „Programowanie zorientowane obiektowo”, jeśli nie zadeklarujesz konstruktora klasy jawnie, zostanie dla niej stworzony konstruktor domyślny, który nie ma żadnych parametrów i nic nie robi. Możesz jednak stworzyć własny konstruktor domyślny, który także nie posiada parametrów, ale odpowiednio „przygotowuje” obiekt do działania. Taki konstruktor także jest nazywany konstruktorem „domyślnym”, bo zgodnie z konwencją, jest nim konstruktor nie posiadający parametrów. Może to budzić wątpliwości, ale zwykle jasno wynika z kontekstu danego miejsca w programie. Zwróć uwagę, że gdy stworzysz jakikolwiek konstruktor, kompilator nie dostarcza już konstruktora domyślnego. Gdy potrzebujesz konstruktora nie posiadającego parametrów i stworzysz jakikolwiek inny konstruktor, musisz stworzyć także konstruktor domyślny!
Przeciążanie konstruktorów Przeznaczeniem konstruktora jest przygotowanie obiektu; na przykład, celem konstruktora Rectangle jest stworzenie poprawnego obiektu prostokąta. Przed wykonaniem konstruktora nie istnieje żaden prostokąt, a jedynie miejsce w pamięci. Gdy konstruktor kończy działanie, w pamięci istnieje kompletny, gotowy do użycia obiekt prostokąta. Konstruktory, tak jak wszystkie inne funkcje składowe, mogą być przeciążane. Możliwość przeciążania ich jest bardzo przydatna. Na przykład: możesz mieć obiekt prostokąta posiadający dwa konstruktory. Pierwszy z nich otrzymuje szerokość oraz długość i tworzy prostokąt o podanych rozmiarach. Drugi nie ma żadnych parametrów i tworzy prostokąt o rozmiarach domyślnych. Ten pomysł wykorzystano na listingu 10.3. Listing 10.3. Przeciążanie konstruktora 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20:
// Listing 10.3 // PrzeciąŜanie konstruktorów #include using namespace std; class Rectangle { public: Rectangle(); Rectangle(int width, int length); ~Rectangle() {} int GetWidth() const { return itsWidth; } int GetLength() const { return itsLength; } private: int itsWidth; int itsLength; }; Rectangle::Rectangle() {
21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47:
itsWidth = 5; itsLength = 10; } Rectangle::Rectangle (int width, int length) { itsWidth = width; itsLength = length; } int main() { Rectangle Rect1; cout << "Rect1 szerokosc: " << Rect1.GetWidth() << endl; cout << "Rect1 dlugosc: " << Rect1.GetLength() << endl; int aWidth, aLength; cout << "Podaj szerokosc: "; cin >> aWidth; cout << "\nPodaj dlugosc: "; cin >> aLength; Rectangle Rect2(aWidth, aLength); cout << "\nRect2 szerokosc: " << Rect2.GetWidth() << endl; cout << "Rect2 dlugosc: " << Rect2.GetLength() << endl; return 0; }
Wynik Rect1 szerokosc: 5 Rect1 dlugosc: 10 Podaj szerokosc: 20 Podaj dlugosc: 50 Rect2 szerokosc: 20 Rect2 dlugosc: 50
Analiza Klasa Rectangle jest zadeklarowana w liniach od 6. do 17. Posiada dwa konstruktory: „domyślny” konstruktor w linii 9. oraz drugi konstruktor w linii 10., przyjmujący dwie liczby całkowite. W linii 33. za pomocą domyślnego konstruktora tworzony jest prostokąt; jego rozmiary są wypisywane w liniach 34. i 35. W liniach od 38. do 41. użytkownik jest proszony o podanie szerokości i długości, po czym w linii 43. wywoływany jest konstruktor, który otrzymuje dwa parametry. Na koniec, w liniach 44. i 45. wypisywane są rozmiary drugiego prostokąta. Tak jak w przypadku innych funkcji przeciążonych, kompilator wybiera właściwy konstruktor na podstawie typów i ilości parametrów.
Inicjalizowanie obiektów Do tej pory ustawiałeś zmienne składowe wewnątrz ciała konstruktora. Konstruktory są jednak wywoływane w dwóch fazach: inicjalizacji i ciała. Większość zmiennych może być ustawiana w dowolnej z tych faz, podczas inicjalizacji lub w wyniku przypisania w ciele konstruktora. Lepiej zrozumiałe, i często bardziej efektywne, jest inicjalizowanie zmiennych składowych w fazie inicjalizacji konstruktora. Sposób inicjalizowania zmiennych składowych przedstawia poniższy przykład: CAT(): itsAge(5), itsWeight(8) { }
// nazwa konstruktora i parametry // lista inicjalizacyjnaji // ciało konstruktora
Po nawiasie zamykającym listę parametrów wpisz dwukropek. Następnie wpisz nazwę zmiennej składowej oraz parę nawiasów. Wewnątrz nawiasów wpisz wyrażenie, którego wartość ma zainicjalizować zmienną składową. Jeśli chcesz zainicjalizować kilka zmiennych, każdą z inicjalizacji oddziel przecinkiem. Listing 10.4 przedstawia definicję konstruktora z listingu 10.3, w której zamiast przypisania w ciele konstruktora zastosowano inicjalizację zmiennych. Listing 10.4. Fragment kodu, przedstawiający inicjalizację zmiennych składowych 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11:
//Listing 10.4 - Inicjalizacja zmiennych składowych Rectangle::Rectangle(): itsWidth(5), itsLength(10) { } Rectangle::Rectangle (int width, int length): itsWidth(width), itsLength(length) { }
Bez wyniku.
Niektóre zmienne muszą być inicjalizowane i nie można im niczego przypisywać; dotyczy to referencji i stałych. Wewnątrz ciała konstruktora można zawrzeć także inne przypisania i działania, jednak najlepiej maksymalnie wykorzystać fazę inicjalizacji.
Konstruktor kopiującyi Oprócz domyślnego konstruktora i destruktora, kompilator dostarcza także domyślnegoy konstruktora kopiującegoi. Konstruktor kopiującyi jest wywoływany za każdym razem, gdy tworzona jest kopia obiektu. Gdy przekazujesz obiekt przez wartość, czy to jako parametr funkcji czy też jako jej wartość zwracaną zwrotną, tworzona jest tymczasowa kopia tego obiektu. Jeśli obiekt jest obiektem zdefiniowanym przez użytkownika, wywoływany jest konstruktor kopiującyi danej klasy, taki mogłeś zobaczyć w poprzednim rozdziale na listingu 9.6. Wszystkie konstruktory kopiującei posiadają jeden parametr; jest nim referencja do obiektu tej samej klasy. Dobrym pomysłem jest oznaczenie tej referencji jako const, gdyż wtedy konstruktor nie ma możliwości modyfikacji otrzymanego obiektu. Na przykład: CAT(const CAT & theCat);
W tym przypadku konstruktor CAT otrzymuje stałą referencję do istniejącego obiektu klasy CAT. Celem konstruktora kopiującegoi jest utworzenie kopii obiektu theCat. Domyślny konstruktor kopiującyi po prostu kopiuje każdą zmienną składową z obiektu otrzymanego jako parametr do odpowiedniej zmiennej składowej obiektu tymczasowego. Nazywa się to kopiowaniemą składowych (czyli kopiowaniemą płytkimą), i choć w przypadku większości składowych nie jest potrzebne nic więcej, proces ten jednak nie sprawdza się w przypadku zmiennych będących wskaźnikami do obiektów na stercie. W płytkiej kopii (czyli bezpośredniej kopii składowych) kopiowane są dokładne wartości składowych jednego obiektu do składowych drugiego obiektu. Wskaźniki zawarte w obu obiektach wskazują wtedy na to samo miejsce w pamięci. W przypadku głębokiej kopii, wartości zaalokowane na stercie są kopiowane do nowo alokowanej pamięci. Gdyby klasa CAT zawierała zmienną składową itsAge, będącą wskaźnikiem do zmiennej całkowitej zaalokowanej na stercie, wtedy domyślny konstruktor kopiującyi skopiowałby wartość zmiennej itsAge otrzymanego obiektu do zmiennej itsAge nowego obiektu. Oba obiekty wskazywałyby więc to samo miejsce w pamięci, co ilustruje rysunek 10.1.
Rys. 10.1. Użycie domyślnego konstruktora kopiującegoi
Gdy któryś z obiektów CAT znajdzie się poza zakresem, nastąpi katastrofa. Jak opisano w rozdziale 8., „Wskaźniki”, zadaniem destruktora jest uporządkowanie i zwolnienie pamięci po obiekcie. Jeśli destruktor pierwotnego obiektu CAT zwolni tę pamięć, zaś wskaźnik w nowym obiekciet CAT nadal będzie na nią wskazywał, oznaczać to będzie pojawienie się błędnego (zagubionego) wskaźnika, a program znajdzie się w śmiertelnym niebezpieczeństwie. Ten problem ilustruje rysunek 10.2.
Rys. 10.2. Powstawanie zagubionego wskaźnika
Rozwiązaniem tego problemu jest stworzenie własnego konstruktora kopiującegoi, alokującego wymaganą pamięć. Po zaalokowaniu pamięci, stare wartości mogą być skopiowane do nowej pamięci. Pokazuje to listing 10.5. Listing 10.5. Konstruktor kopii 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29:
// Listing 10.5 // Konstruktory kopii #include using namespace std; class CAT { public: CAT(); // domyślny konstruktor CAT (const CAT &); // konstruktor kopiującyi ~CAT(); // destruktor int GetAge() const { return *itsAge; } int GetWeight() const { return *itsWeight; } void SetAge(int age) { *itsAge = age; } private: int *itsAge; int *itsWeight; }; CAT::CAT() { itsAge = new int; itsWeight = new int; *itsAge = 5; *itsWeight = 9; } CAT::CAT(const CAT & rhs)
30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52: 53: 54: 55: 56: 57: 58: 59: 60:
{ itsAge = new int; itsWeight = new int; *itsAge = rhs.GetAge(); // dostęp publiczny *itsWeight = *(rhs.itsWeight); // dostęp prywatny } CAT::~CAT() { delete itsAge; itsAge = 0; delete itsWeight; itsWeight = 0; } int main() { CAT mruczek; cout << "Wiek Mruczka: " << mruczek.GetAge() << endl; cout << "Ustawiam wiek Mruczka na 6 lat...\n"; mruczek.SetAge(6); cout << "Tworze Filemona z Mruczka\n"; CAT filemon(mruczek); cout << "Wiek Mruczka: " << mruczek.GetAge() << endl; cout << "Wiek Filemona: " << filemon.GetAge() << endl; cout << "Ustawiam wiek Mruczka na 7 lat...\n"; mruczek.SetAge(7); cout << "Wiek Mruczka: " << mruczek.GetAge() << endl; cout << "Wiek Filemona: " << filemon.GetAge() << endl; return 0; }
Wynik Wiek Mruczka: 5 Ustawiam wiek Mruczka na 6 lat... Tworze Filemona z Mruczka Wiek Mruczka: 6 Wiek Filemona: 6 Ustawiam wiek Mruczka na 7 lat... Wiek Mruczka: 7 Wiek Filemona: 6
Analiza W liniach od 6. do 19. deklarowana jest klasa CAT. Zwróć uwagę, że w linii 9. został zadeklarowany konstruktor domyślny, a w linii 10. został zadeklarowany konstruktor kopiującyi. W liniach 17. i 18. są deklarowane dwie zmienne składowe, będące wskaźnikami do zmiennych typu int. Zwykle klasa nie ma powodów do przechowywania danych składowych typu int w postaci wskaźników, ale w tym przypadku służy to do zilustrowania operowania zmiennymi składowymi na stercie. Domyślny konstruktor, zdefiniowany w liniach od 21. do 27. alokuje miejsce na stercie dla dwóch zmiennych typu int, po czym przypisuje im wartości. Definicja konstruktora kopiującegoi rozpoczyna się w linii 29. Zwróć uwagę, że jego parametrem jest rhs. Jest to często stosowana nazwa dla parametru konstruktora kopiującegoi, stanowiąca
skrót od wyrażenia right-hand side (prawa strona). Gdy spojrzysz na przypisania w liniach 33. i 34., przekonasz się, że obiekt przekazywany jako parametr znajduje się po prawej stronie znaku równości. Oto sposób, w jaki działa: W liniach 31. i 32. alokowana jest pamięć na stercie. Następnie, w liniach 33. i 34., wartościom w nowej pamięci są przypisywane wartości danych z istniejącego obiektu CAT. Parametr rhs jest obiektem CAT przekazanym do konstruktora kopiującegoi jako stała referencja. Jako obiekt klasy CAT, parametr rhs posiada wszystkie składowe tej klasy. Każdy obiekt CAT może odwoływać się do wszystkich (także prywatnych) składowych innych obiektów tej samej klasy; jednak do tradycji programistycznej należy korzystanie z akcesorów wszędzie tam, gdzie jest to możliwe. Funkcja składowa rhs.GetAge() zwraca wartość przechowywaną w pamięci wskazywanej przez zmienną składową itsAge obiektu rhs. Rysunek 10.3 przedstawia, co dzieje się w programie. Wartości wskazywane przez zmienne składowe istniejącego obiektu CAT są kopiowane do pamięci zaalokowanej dla nowego obiektu CAT.
Rys. 10.3. Przykład głębokiej kopiowaniai głębokiego
W linii 47. tworzony jest obiekt CAT o nazwie mruczek. Wypisywany jest jego wiek, po czym w linii 50. wiek Mruczka jest ustawiany na 6 lat. W linii 52. tworzony jest nowy obiekt klasy CAT, tym razem o nazwie filemon. Jest on tworzony za pomocą konstruktora kopiującegoi, któremu przekazano obiekt mruczek. Gdyby mruczek został przekazany do funkcji przez wartość (nie przez referencję), wtedy kompilator użyłby tego samego konstruktora kopii. W liniach 53. i 54. jest wypisywany wiek Mruczka i Filemona. Oczywiście, wiek Filemona jest taki sam, jak wiek Mruczka i wynosi 6 lat, a nie domyślne 5. W linii 56. wiek Mruczka jest ustawiany na 7 lat, po czym wiek obu obiektów jest wypisywany ponownie. Tym razem Mruczek ma 7 lat, ale Filemon wciąż ma 6, co dowodzi, że dane tych obiektów są przechowywane w osobnych miejscach pamięci. Gdy obiekt klasy CAT wychodzi z zakresu, automatycznie wywoływany jest jego destruktor. Implementacja destruktora klasy CAT została przedstawiona w liniach od 37. do 43. Dla obu wskaźników, itsAge oraz itsWeight, wywoływany jest operator delete, zwalniający
zaalokowaną dla nich pamięć sterty. Oprócz tego, dla bezpieczeństwa, obu wskaźnikom jest przypisywana wartość NULL.
Przeciążanie operatorów C++ posiada liczne typy wbudowane, takie jak int, float, char, itd. Każdy z nich posiada własne wbudowane operatory, takie jak dodawanie (+) czy mnożenie (*). C++ umożliwia stworzenie takich operatorów także dla klas definiowanych przez użytkownika. Aby umożliwić pełne poznanie procesu przeciążania operatorów, na listingu 10.6 stworzono nową klasę o nazwie Counter (licznik). Obiekt typu Counter będzie używany do (uwaga!) zliczania pętli oraz innych zadań, w których wartość musi być inkrementowana, dekrementowana czy śledzona w inny sposób. Listing 10.6. Klasa Counter 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28:
// Listing 10.6 // Klasa Counter #include using namespace std; class Counter { public: Counter(); ~Counter(){} int GetItsVal()const { return itsVal; } void SetItsVal(int x) {itsVal = x; } private: int itsVal; }; Counter::Counter(): itsVal(0) {} int main() { Counter i; cout << "Wartoscia i jest " << i.GetItsVal() << endl; return 0; }
Wynik Wartoscia i jest 0
Analiza W obecnej postaci klasa Counter jest raczej bezużyteczna. Sama klasa jest zdefiniowana w liniach od 6. do 17. Jej jedyną zmienną składową jest wartość typu int. Domyślny konstruktor,
zadeklarowany w linii 9. i zaimplementowany w linii 19., inicjalizuje jedyną zmienną składową, itsVal (jego wartość), wartością na zero. W odróżnieniu od wbudowanego typu int, obiekt klasy Counter nie może być inkrementowany, dekrementowany, dodawany, przypisywany, nie można też nim operować w inny sposób. Sprawia za to, że wypisywanie jego wartości staje się jeszcze bardziej skomplikowane!
Pisanie funkcji inkrementacji Dzięki przeciążeniu operatorów możemy odzyskać większość działań, których klasa ta została pozbawiona. Istnieją na przykład dwa sposoby uzupełnienia obiektu Counter o inkrementację. Pierwszy z nich polega na napisaniu metody do inkrementacji, zobaczymy to na listingu 10.7. Listing 10.7. Dodawanie operatora inkrementacji 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31:
// Listing 10.7 // Klasa Counter #include using namespace std; class Counter { public: Counter(); ~Counter(){} int GetItsVal()const { return itsVal; } void SetItsVal(int x) {itsVal = x; } void Increment() { ++itsVal; } private: int itsVal; }; Counter::Counter(): itsVal(0) {} int main() { Counter i; cout << "Wartoscia i jest " << i.GetItsVal() << endl; i.Increment(); cout << "Wartoscia i jest " << i.GetItsVal() << endl; return 0; }
Wynik Wartoscia i jest 0 Wartoscia i jest 1
Analiza
Listing 10.7 zawiera nową funkcję Increment() (inkrementuj), zdefiniowaną w linii 13. Choć ta funkcja działa poprawnie, jest jednak nieco kłopotliwa w użyciu. Program aż prosi się o uzupełnienie go o operator ++, co oczywiście możemy zrobić.
Przeciążanie operatora przedrostkowego Operatory przedrostkowe można przeciążyć, deklarując funkcję o postaci: zwracanyTyp operator op()
gdzie op jest przeciążanym operatorem. Operator ++ można przeciążyć, pisząc: void operator++ ()
Tę alternatywę demonstruje listing 10.8.
Listing 10.8. Przeciążanie operatora++ 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33:
// Listing 10.8 // Klasa Counter // przedrostkowy operator inkrementacji #include using namespace std; class Counter { public: Counter(); ~Counter(){} int GetItsVal()const { return itsVal; } void SetItsVal(int x) {itsVal = x; } void Increment() { ++itsVal; } void operator++ () { ++itsVal; } private: int itsVal; }; Counter::Counter(): itsVal(0) {} int main() { Counter i; cout << "Wartoscia i jest " << i.GetItsVal() << endl; i.Increment(); cout << "Wartoscia i jest " << i.GetItsVal() << endl; ++i; cout << "Wartoscia i jest " << i.GetItsVal() << endl;
34: 35:
return 0; }
Wynik Wartoscia i jest 0 Wartoscia i jest 1 Wartoscia i jest 2
Analiza W linii 15. został przeciążony operator++, który jest używany w linii 32. Jego składnia jest już zbliżona do składni typów wbudowanych, takich jak int. Teraz możesz wziąć pod uwagę wykonywanie podstawowych zadań, dla których została stworzona klasa Counter (na przykład wykrywanie sytuacji, w której licznik przekracza największą wartość). Jednak w zapisie operatora inkrementacji tkwi poważny defekt. Jeśli umieścisz obiekt typu Counter po prawej stronie przypisania, kompilator zgłosi błąd. Na przykład: Counter a = ++i;
W tym przykładzie mieliśmy zamiar stworzyć nowy obiekt a należący do klasy Counter, a następnie, po inkrementacji tej zmiennej, przypisać mu wartość i. To przypisanie obsłużyłby wbudowany konstruktor kopiującyi, ale wykorzystywany obecnie operator inkrementacji nie zwraca obiektu typu Counter. Zamiast tego zwraca typ void. Nie można przypisywać obiektów void obiektom Counter. (Z pustego i Salomon nie naleje!)
Zwracanie typów w przeciążonych funkcjach operatorów To, czego nam teraz potrzeba, to zwrócenie obiektu klasy Counter, który mógłby być przypisany innemu obiektowi tej klasy. Który z obiektów powinien zostać zwrócony? Jednym z rozwiązań jest stworzenie obiektu tymczasowego i zwrócenie go. Pokazuje to listing 10.9. Listing 10.9. Zwracanie obiektu tymczasowego 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15:
// Listing 10.9 // operator++ zwraca tymczasowy obiekt #include using namespace std; class Counter { public: Counter(); ~Counter(){} int GetItsVal()const { return itsVal; } void SetItsVal(int x) {itsVal = x; } void Increment() { ++itsVal; } Counter operator++ ();
16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46:
private: int itsVal; }; Counter::Counter(): itsVal(0) {} Counter Counter::operator++() { ++itsVal; Counter temp; temp.SetItsVal(itsVal); return temp; } int main() { Counter i; cout << "Wartoscia i jest " << i.GetItsVal() << endl; i.Increment(); cout << "Wartoscia i jest " << i.GetItsVal() << endl; ++i; cout << "Wartoscia i jest " << i.GetItsVal() << endl; Counter a = ++i; cout << "Wartoscia a jest: " << a.GetItsVal(); cout << " , a wartosc i to: " << i.GetItsVal() << endl; return 0; }
Wynik Wartoscia Wartoscia Wartoscia Wartoscia
i i i a
jest 0 jest 1 jest 2 jest: 3 , a wartosc i to: 3
Analiza W tej wersji operator++ został zadeklarowany w linii 15. jako zwracający obiekt typu Counter. W linii 29. jest tworzona zmienna tymczasowa temp, której wartość jest ustawiana zgodnie z wartością bieżącego obiektu. Ta tymczasowa wartość jest zwracana i natychmiast przypisywana zmiennej a w linii 42.
Zwracanie obiektów tymczasowych bez nadawania im nazw Nie ma potrzeby nadawania nazwy obiektowi tymczasowemu tworzonemu w linii 29. Gdyby klasa Counter miała konstruktor przyjmujący wartość, jako wartość zwrotną operatora inkrementacji moglibyśmy po prostu zwrócić wynik tego konstruktora. Pokazuje to listing 10.10. Listing 10.10. Zwracanie obiektu tymczasowego bez nadawania mu nazwy 0: 1:
// Listing 10.10 // operator++ zwraca tymczasowy obiekt bez nazwy
2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49:
#include using namespace std; class Counter { public: Counter(); Counter(int val); ~Counter(){} int GetItsVal()const { return itsVal; } void SetItsVal(int x) {itsVal = x; } void Increment() { ++itsVal; } Counter operator++ (); private: int itsVal; }; Counter::Counter(): itsVal(0) {} Counter::Counter(int val): itsVal(val) {} Counter Counter::operator++() { ++itsVal; return Counter (itsVal); } int main() { Counter i; cout << "Wartoscia i jest " << i.GetItsVal() << i.Increment(); cout << "Wartoscia i jest " << i.GetItsVal() << ++i; cout << "Wartoscia i jest " << i.GetItsVal() << Counter a = ++i; cout << "Wartoscia a jest: " << a.GetItsVal(); cout << ", zas wartosc i to: " << i.GetItsVal() return 0; }
Wynik Wartoscia Wartoscia Wartoscia Wartoscia
Analiza
i i i a
jest 0 jest 1 jest 2 jest: 3, zas wartosc i to: 3
endl; endl; endl;
<< endl;
W linii 11. został zadeklarowany nowy konstruktor, przyjmujący wartość typu int. Jego implementacja znajduje się w liniach od 27. do 29.; inicjalizuje ona zmienną składową itsVal za pomocą wartości otrzymanej jako argument konstruktora. Implementacja operatora++ może zostać teraz uproszczona. W linii 33. wartość itsVal jest inkrementowana. Następnie, w linii 34., tworzony jest tymczasowy obiekt klasy Counter, który jest inicjalizowany wartością zmiennej itsVal, po czym zwracany jako rezultat operatora++. To rozwiązanie jest bardziej eleganckie, ale powoduje, że musimy zadać następne pytanie: dlaczego w ogólne musimy tworzyć obiekt tymczasowy? Pamiętajmy, że każdy obiekt tymczasowy musi zostać najpierw skonstruowany, a później zniszczony — te operacje mogą być potencjalnie dość kosztowne. Poza tym, obiekt już istnieje i posiada właściwą wartość, więc dla czego nie mielibyśmy zwrócić właśnie jego? Rozwiążemy ten problem, używając wskaźnika this.
Użycie wskaźnika this Wskaźnik this jest przekazywany wszystkim funkcjom składowym, nawet przeciążonym operatorom, takim jak operator++(). Wskaźnik this wskazuje na i, więc gdy zostanie wyłuskany, zwróci tylko obiekt i, który w swojej zmiennej itsVal zawiera już właściwą wartość. Zwracanie wyłuskanego wskaźnika this i zaniechanie tworzenia niepotrzebnego obiektu tymczasowego przedstawia listing 10.11. Listing 10.11. Zwracanie wskaźnika this 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30:
// Listing 10.11 // Zwracanie wyłuskanego wskaźnika this #include using namespace std; class Counter { public: Counter(); ~Counter(){} int GetItsVal()const { return itsVal; } void SetItsVal(int x) {itsVal = x; } void Increment() { ++itsVal; } const Counter& operator++ (); private: int itsVal; }; Counter::Counter(): itsVal(0) {}; const Counter& Counter::operator++() { ++itsVal; return *this; }
31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44:
int main() { Counter i; cout << "Wartoscia i jest " << i.GetItsVal() << i.Increment(); cout << "Wartoscia i jest " << i.GetItsVal() << ++i; cout << "Wartoscia i jest " << i.GetItsVal() << Counter a = ++i; cout << "Wartoscia a jest: " << a.GetItsVal(); cout << ", zas wartosc i to: " << i.GetItsVal() return 0; }
endl; endl; endl;
<< endl;
Wynik Wartoscia Wartoscia Wartoscia Wartoscia
i i i a
jest 0 jest 1 jest 2 jest: 3, zas wartosc i to: 3
Analiza Implementacja operatora++, zawarta w liniach od 26. do 30., została zmieniona w taki sposób, aby wyłuskiwała wskaźnik this i zwracała bieżący obiekt. Dzięki temu zmiennej a może być przypisany bieżący egzemplarz klasy Counter. Jak wspomnieliśmy wcześniej, gdyby obiekt klasy Counter alokował pamięć, należałoby przysłonić domyślny konstruktor kopiującyi. Jednak w tym przypadku domyślny konstruktor kopiującyi działa poprawnie. Zwróć uwagę, że zwracaną wartością jest referencja do obiektu klasy Counter, dzięki czemu unikamy tworzenia dodatkowego obiektu tymczasowego. Jest to zmienna const, ponieważ nie powinna być modyfikowana przez funkcję wykorzystującą zwracany obiekt klasy Counter.
Dlaczego stała referencja? Zwracany obiekt Counter musi być obiektem const. Gdyby nim nie był, można by wykonać na zwracanym obiekcie operacje, które mogłyby zmienić jego dane składowe. Na przykład, gdyby zwracana wartość nie była stała, mógłbyś napisać: 40:
Counter a = ++++i;
Można to rozumieć jako wywołanie operatora inkrementacji (++) na wyniku wywołania operatora inkrementacji, opcja ta powinno być zablokowane. Spróbuj wykonać taki eksperyment: zarówno w deklaracji, jak i w implementacji (linie 15. i 26.) zmień zwracaną wartość na wartość nie będącą const, po czym zmień linię 40. na pokazaną powyżej (++++i). Umieść punkt przerwania w debuggerze na linii 40. i wejdź do funkcji. Zobaczysz, że do operatora inkrementacji wejdziesz dwa razy. Inkrementacja zostanie zastosowana do (teraz nie będącej stałą) wartości zwracanej.
Aby się przed tym zabezpieczyć, deklarujemy wartość zwracaną jako const. Gdy zmienisz linie 15. i 26. z powrotem na stałe, zaś linię 40. pozostawisz bez zmian (++++i), kompilator zaprotestuje przeciwko wywołaniu operatora inkrementacji dla obiektu stałego.
Przeciążanie operatora przyrostkowego Jak dotąd, udało się nam przeciążyć operator przedrostkowy. A co zrobić, gdy chcemy przeciążyć operator przyrostkowy? Kompilator nie potrafi odróżnić przedrostka od przyrostka. Zgodnie z konwencją, jako parametr deklaracji operatora dostarczana jest zmienna całkowita. Wartość parametru jest ignorowana; sygnalizuje on tylko, że jest to operator przyrostkowy.
Różnica pomiędzy przedrostkiem a przyrostkiem Zanim będziemy mogli napisać operator przyrostkowy, musimy zrozumieć, czym różni się on od operatora przedrostkowego. Omawialiśmy to szczegółowo w rozdziale 4., „Wyrażenia i instrukcje”. (Patrz listing 4.3.). Przypomnijmy: przedrostek mówi: „Inkrementuj, po czym pobierz”, zaś przyrostek mówi: „Pobierz, a następnie inkrementuj”. Operator przedrostkowy może po prostu inkrementować wartość, a następnie zwrócić sam obiekt, zaś operator przyrostkowy musi zwracać wartość istniejącą przed dokonaniem inkrementacji. W tym celu musimy stworzyć obiekt tymczasowy, który będzie zawierał pierwotną wartość, następnie inkrementować wartość pierwotnego obiektu, po czy, zwrócić obiekt tymczasowy. Przyjrzyjmy się temu procesowi od początku. Weźmy następującą linię kodu: a = x++;
Jeśli x miało wartość 5, wtedy po wykonaniu tej instrukcji a ma wartość 5, zaś x ma wartość 6. Zwracamy wartość w x i przypisujemy ją zmiennej a, po czym inkrementujemy wartość x. Jeśli x jest obiektem, jego przyrostkowy operator inkrementacji musi zachować pierwotną wartość (5) w obiekcie tymczasowym, inkrementować wartość x do 6, po czym zwrócić obiekt tymczasowy w celu przypisania oryginalnej wartości do zmiennej a. Zwróć uwagę, że skoro zwracamy obiekt tymczasowy, musimy zwracać go poprzez wartość, a nie poprzez referencję (w przeciwnym razie obiekt ten znajdzie się poza zakresem natychmiast po wyjściu programu z funkcji). Listing 10.12 przedstawia użycie operatora przedrostkowego i przyrostkowego. Listing 10.12. Operator przedrostkowy i przyrostkowy 0: 1: 2: 3: 4: 5: 6: 7:
// Listing 10.12 // Operator przedrostkowy i przyrostkowy #include using namespace std; class Counter
8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52: 53:
{ public: Counter(); ~Counter(){} int GetItsVal()const { return itsVal; } void SetItsVal(int x) {itsVal = x; } const Counter& operator++ (); // przedrostkowy const Counter operator++ (int); // przyrostkowy private: int itsVal; }; Counter::Counter(): itsVal(0) {} const Counter& Counter::operator++() { ++itsVal; return *this; } const Counter Counter::operator++(int theFlag) { Counter temp(*this); ++itsVal; return temp; } int main() { Counter i; cout << "Wartoscia i jest " << i.GetItsVal() << i++; cout << "Wartoscia i jest " << i.GetItsVal() << ++i; cout << "Wartoscia i jest " << i.GetItsVal() << Counter a = ++i; cout << "Wartoscia a jest: " << a.GetItsVal(); cout << ", zas wartosc i to: " << i.GetItsVal() a = i++; cout << "Wartoscia a jest: " << a.GetItsVal(); cout << ", zas wartosc i to: " << i.GetItsVal() return 0; }
endl; endl; endl;
<< endl;
<< endl;
Wynik Wartoscia Wartoscia Wartoscia Wartoscia Wartoscia
i i i a a
jest 0 jest 1 jest 2 jest: 3, zas wartosc i to: 3 jest: 3, zas wartosc i to: 4
Analiza Operator przyrostkowy jest deklarowany w linii 15. i implementowany w liniach od 31. do 36. Operator przedrostkowy jest deklarowany w linii 14.
Parametr przekazywany do operatora przyrostkowego w linii 32. (theFlag) sygnalizuje jedynie kompilatorowi, że jest tochodzi tu o operator przyrostkowy; wartość tego parametru nigdy nie jest wykorzytywana.
Operator dodawania Operator inkrementacji jest operatorem unarnym, tj. operatorem działającym na tylko jednym obiekcie. Operator dodawania (+) jest operatorem binarnym, co oznacza, że do działania potrzebuje dwóch obiektów. W jaki więc sposób można zaimplementować przeciążenie operatora + dla klasy Counter? Naszym celem jest zadeklarowanie dwóch zmiennych typu Counter, a następnie dodanie ich, tak jak w poniższym przykładzie: Counter varOne, varTwo, varThree; varThree = varOne + varTwo;
Także w tym przypadku mógłbyś zacząć od napisania funkcji Add() (dodaj), która jako argument przyjmowałaby obiekt klasy Counter, dodawałaby wartości, po czym zwracałaby obiekt klasy Counter jako wynik. Takie postępowanie ilustruje listing 10.13. Listing 10.13. Funkcja Add() 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31:
// Listing 10.13 // Funkcja Add #include using namespace std; class Counter { public: Counter(); Counter(int initialValue); ~Counter(){} int GetItsVal()const { return itsVal; } void SetItsVal(int x) {itsVal = x; } Counter Add(const Counter &); private: int itsVal; }; Counter::Counter(int initialValue): itsVal(initialValue) {} Counter::Counter(): itsVal(0) {} Counter Counter::Add(const Counter & rhs) {
32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44:
return Counter(itsVal+ rhs.GetItsVal()); } int main() { Counter varOne(2), varTwo(4), varThree; varThree = varOne.Add(varTwo); cout << "varOne: " << varOne.GetItsVal()<< endl; cout << "varTwo: " << varTwo.GetItsVal() << endl; cout << "varThree: " << varThree.GetItsVal() << endl; return 0; }
Wynik varOne: 2 varTwo: 4 varThree: 6
Analiza Funkcja Add() została zadeklarowana w linii 15. Otrzymuje ona stałą referencję do obiektu klasy Counter, który zawiera wartość przeznaczoną do dodania do wartości w bieżącym obiekcie. Zwraca obiekt klasy Counter, który jest przypisywany lewej stronie instrukcji przypisania w linii 38. Innymi słowy, varOne jest obiektem, varTwo jest parametrem funkcji Add(), zaś wynik tej funkcji jest przypisywany do varThree. Aby stworzyć varThree bez inicjalizowania wartości tego obiektu, potrzebny jest konstruktor domyślny. Ten konstruktor inicjalizuje zmienną składową itsVal jako zero, co pokazują linie od 26. do 28. Ponieważ zmienne varOne i varTwo powinny być zainicjalizowane wartościami różnymi od zera, został stworzony kolejny konstruktor, znajdujący się w liniach od 22. do 24. Innym rozwiązaniem tego problemu jest zastosowanie wartości domyślnej 0 w konstruktorze zadeklarowanym w linii 11.
Przeciążanie operatora dodawania Funkcja Add() znajduje się w liniach od 30. do 33. listingu 10.13. Funkcja działa poprawnie, ale jej użycie jest mało naturalne. Przeciążenie operatora + spowoduje, że użycie klasy Counter będzie mogło przebiegać bardziej naturalnie. Pokazuje to listing 10.14.
Listing 10.14. operator+ 0: 1: 2: 3: 4: 5: 6: 7: 8:
// Listing 10.14 //PrzeciąŜony operator dodawania (+) #include using namespace std; class Counter {
9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42:
public: Counter(); Counter(int initialValue); ~Counter(){} int GetItsVal()const { return itsVal; } void SetItsVal(int x) {itsVal = x; } Counter operator+ (const Counter &); private: int itsVal; }; Counter::Counter(int initialValue): itsVal(initialValue) {} Counter::Counter(): itsVal(0) {} Counter Counter::operator+ (const Counter & rhs) { return Counter(itsVal + rhs.GetItsVal()); } int main() { Counter varOne(2), varTwo(4), varThree; varThree = varOne + varTwo; cout << "varOne: " << varOne.GetItsVal()<< endl; cout << "varTwo: " << varTwo.GetItsVal() << endl; cout << "varThree: " << varThree.GetItsVal() << endl; return 0; }
Wynik varOne: 2 varTwo: 4 varThree: 6
Analiza operator+ został zadeklarowany w linii 15., a jego implementacja znajduje się w liniach od 28.
do 31. Porównaj go z deklaracją i definicją funkcji Add() w poprzednim listingu: są prawie identyczne. Jednak ich składnia jest całkiem inna. Bardziej naturalny jest zapis: varThree = varOne + varTwo;
niż zapis: varThree = varOne.Add(varTwo);
Nie jest to duża zmiana, ale na tyle ważna, by program stał się łatwiejszy do odczytania i zrozumienia. Operator jest wykorzystywany w linii 36: 36:
varThree = varOne + varTwo;
Kompilator tłumaczy ten zapis na: varThree = varOne.operator+(varTwo);
Mógłbyś oczywiście napisać tę linię sam, a kompilator zaakceptowałby to bez zastrzeżeń. Metoda operator+ jest wywoływana dla operandu po lewej stronie, a jako argument otrzymuje operand po prawej stronie.
Zagadnienia związane z przeciążaniem operatorów Przeciążane operatory mogą być funkcjami składowymi, takimi, jak opisane w tym rozdziale, lub funkcjami globalnymi. Te ostatnie zostaną opisane w rozdziale 15., „Specjalne klasy i funkcje”, przy okazji omawiania funkcji zaprzyjaźnionych. Jedynymi operatorami, które muszą być funkcjami składowymi klasy, są operator: przypisania (=), indeksu tablicy ([]), wywołania funkcji (()) oraz wskaźnika (->). Operator [] zostanie omówiony w rozdziale 13., przy okazji omawiania tablic. Przeciążanie operatora -> zostanie omówione w rozdziale 15., przy okazji omawiania wskaźników inteligentnych.
Ograniczenia w przeciążaniu operatorów Operatory dla typów wbudowanych (takich jak int) nie mogą być przeciążane. Nie można zmieniać priorytetu operatorów ani ilości operandów operatora, tj. operator unarny nie może stać się operatorem binarnym i na odwrót. Nie można także tworzyć nowych operatorów, dlatego niemożliwe jest zadeklarowanie symbolu ** jako operatora „podnoszenia do potęgi.” Niektóre operatory C++ są operatorami unarnymi i wymagają tylko jednego operandu (na przykład myValue++). Inne operatory są binarne, czyli wymagają dwóch operandów (na przykład a+b). W C++ istnieje tylko jeden operator trójargumentowy: operator ? (na przykład a > b ? x : y).
Co przeciążać? Przeciążanie operatorów jest jednym z najczęściej nadużywanych przez początkujących programistów aspektów języka C++. Tworzenie nowych zastosowań dla niektórych z mniej znanych operatorów jest bardzo kuszące, ale niezmiennie prowadzi do tworzenia nieczytelnego kodu. Oczywiście, doprowadzenie to tego, by operator + odejmował, a operator * dodawał może być zabawne, ale żaden profesjonalny programista tego nie zrobi. Duże niebezpieczeństwo kryje się także w machinalnym użyciu operatora + do łączenia ciągów liter czy operatora / do dzielenia łańcuchów. Oczywiście, istnieją powody, dla których stosujemy te operatory, ale istnieje jeszcze więcej powodów, by zachować przy tym dużą ostrożność. Pamiętaj że celem przeciążania operatorów jest zwiększenie użyteczności i przejrzystości obiektów.
TAK
NIE
Stosuj przeciążanie operatorów wtedy, gdy zwiększa to przejrzystość programu.
Nie twórz operatorów działających niezgodnie z przeznaczeniem.
Zwracaj z przeciążanego operatora obiekt jego klasy.
Operator przypisania Czwartą, ostatnią funkcją, która jest dostarczana przez kompilator (gdy nie stworzysz jej sam) jest operator przypisania (operator=()). Ten operator jest wywoływany za każdym razem, gdy przypisujesz coś do obiektu. Na przykład: CAT catOne(5,7); CAT catTwo(3,4); // ... tutaj inny kod catTwo = catOne;
W tym fragmencie tworzony jest obiekt catOne; jego zmienna składowa itsAge jest inicjalizowana wartością 5, a zmienna składowa itsWeight wartością 7. W następnej linii tworzony jest obiekt catTwo, którego zmienne składowe są inicjalizowane wartościami 3 i 4. Po jakimś czasie obiektowi catTwo jest przypisywany obiekt catOne. Pojawiają się więc dwa problemy: co się stanie, gdy zmienna składowa itsAge jest wskaźnikiem i co się dzieje z pierwotnymi wartościami w obiekcie catTwo? Posługiwanie się zmiennymi składowymi, przechowującymi swoje wartości na stercie, zostało omówione już wcześniej, podczas omawiania działania konstruktora kopiującegoi. Te same zagadnienia odnoszą się także do przedstawionego tutaj przypadku, tak jak pokazano na rysunkach 10.1 i 10.2.
Programiści C++ dokonują rozróżnienia pomiędzy kopiowaniemą płytkimą, czyli kopiowaniemą składowych klasy, a kopiowaniemą głębokimą. WPrzy kopiowaniui płytkim ej kopiowane są jedynie składowe, więc oba obiekty wskazują to samo miejsce na stercie. WPrzy kopiowaniu głębokim ej kopii na stercie alokowany jest nowy obszar pamięci. Ilustrował to rysunek 10.3. Jednak w przypadku operatora przypisania pojawia się kolejny problem. Obiekt catTwo już istnieje i posiada zaalokowaną pamięć. Jeśli nie chcemy doprowadzić do wycieku pamięci, pamięć musi zostać zwolniona. Ale co zrobić, gdy przypiszemy obiekt catTwo samemu sobie? catTwo = catTwo;
Nikt nie ma oczywiście zamiaru tego robić, ale może się to zdarzyć przypadkiem, gdy referencje i wyłuskane wskaźniki ukryją fakt, że przypisanie odnosi się do tego samego obiektu. Jeśli nie rozwiążesz tego problemu, obiekt catTwo może usunąć swoją zaalokowaną pamięć, po czym, gdy już będzie gotów do skopiowania pamięci z obiektu po prawej stronie operatora przypisania, znajdzie się w ogromnym kłopocie: tej wartości już nie będzie! Aby się przed tym zabezpieczyć, operator przypisania musi sprawdzać, czy operand po prawej stronie operatora nie jest tym samym obiektem. W tym celu może sprawdzić wskaźnik this. Klasę z przeciążonym operatorem przypisania przedstawia listing 10.15. Listing 10.15. Operator przypisania 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32:
// Listing 10.15 // Operator przypisania #include using namespace std; class CAT { public: CAT(); // domyślny konstruktor // konstruktor kopiującyi oraz destruktor zostały usunięte! int GetAge() const { return *itsAge; } int GetWeight() const { return *itsWeight; } void SetAge(int age) { *itsAge = age; } CAT & operator=(const CAT &); private: int *itsAge; int *itsWeight; }; CAT::CAT() { itsAge = new int; itsWeight = new int; *itsAge = 5; *itsWeight = 9; }
CAT & CAT::operator=(const CAT & rhs) {
33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52: 53:
if (this == &rhs) return *this; *itsAge = rhs.GetAge(); *itsWeight = rhs.GetWeight(); return *this; }
int main() { CAT mruczek; cout << "Wiek Mruczka: " << mruczek.GetAge() << endl; cout << "Ustawiam wiek Mruczka na 6...\n"; mruczek.SetAge(6); CAT filemon; cout << "Wiek Filemona: " << filemon.GetAge() << endl; cout << "Kopiuje Mruczka do Filemona...\n"; filemon = mruczek; cout << "Wiek Filemona: " << filemon.GetAge() << endl; return 0; }
Wynik Wiek Mruczka: 5 Ustawiam wiek Mruczka na 6... Wiek Filemona: 5 Kopiuje Mruczka do Filemona... Wiek Filemona: 6
Analiza Listing 10.15 stanowi powrót do klasy CAT, z której (dla zaoszczędzenia miejsca) usunięto konstruktor kopiującyi oraz destruktor. Operator przypisania jest deklarowany w linii 15., zaś jego definicja znajduje się w liniach od 31. do 38. W linii 33. następuje sprawdzenie, czy bieżący obiekt (obiekt CAT, do którego następuje przypisanie), jest tym samym obiektem, co przypisywany obiekt CAT. Odbywa się to poprzez sprawdzenie, czy adres rhs jest taki sam, jak adres zawarty we wskaźniku this. Oczywiście, można przeciążyć także operator równości (==), dzięki czemu możesz sam określić co oznacza „równość” twoich obiektów.
Obsługa konwersji typów danych Co się stanie, gdy spróbujesz przypisać zmienną typu wbudowanego, takiego jak int czy unsigned short, do obiektu klasy zdefiniowanej przez użytkownika? Na listingu 10.16 ponownie skorzystamy z klasy Counter, próbując przypisać obiektowi tej klasy zmienną typu int. OSTRZEŻENIE Listing 10.16 nie skompiluje się!
Listing 10.16. Próba przypisania obiektowi typu Counter zmiennej typu int 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29:
// Listing 10.16 // Ten kod nie skompiluje się! #include using namespace std; class Counter { public: Counter(); ~Counter(){} int GetItsVal()const { return itsVal; } void SetItsVal(int x) {itsVal = x; } private: int itsVal; }; Counter::Counter(): itsVal(0) {} int main() { int theShort = 5; Counter theCtr = theShort; cout << "theCtr: " << theCtr.GetItsVal() << endl; return 0; }
Wynik Błąd kompilacji! Nie moŜna dokonać konwersji z typu int do typu Counter.
Analiza Klasa Counter zadeklarowana w liniach od 7. do 17. posiada jedynie konstruktor domyślny. Nie deklaruje żadnej konkretnej metody zamiany zmiennych typu int w obiekty klasy Counter, więc linia 26. powoduje błąd kompilacji. Kompilator nie wie, dopóki go o tym nie poinformujesz, że mając zmienną typu int, powinien przypisać jej wartość do zmiennej składowej itsVal. Listing 10.17 poprawia ten błąd, tworząc operator konwersji: konstruktor przyjmuje wartość typu int i tworzy obiekt klasy Counter. Listing 10.17. Konwersja typu int na typ Counter 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10:
// Listing 10.17 // Konstruktor jako operator konwersji #include using namespace std; class Counter { public: Counter();
11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35:
Counter(int val); ~Counter(){} int GetItsVal()const { return itsVal; } void SetItsVal(int x) {itsVal = x; } private: int itsVal; }; Counter::Counter(): itsVal(0) {} Counter::Counter(int val): itsVal(val) {}
int main() { int theShort = 5; Counter theCtr = theShort; cout << "theCtr: " << theCtr.GetItsVal() << endl; return 0; }
Wynik theCtr: 5
Analiza Ważna zmiana pojawia się w linii 11., gdzie deklarowany jest przeciążony konstruktor, przyjmujący wartość typu int, oraz w liniach od 24. do 26., gdzie konstruktor ten jest implementowany. Efektem jego działania jest stworzenie z obiektu typu int obiektu typu Counter. Na tej podstawie kompilator może wywołać konstruktor, którego argumentem jest wartość int.
Krok 1: Tworzymy licznik o nazwie theCtr. Odpowiada to zapisowi x = 5;, który tworzy zmienną całkowitą x i inicjalizuje ją wartością 5. W naszym przypadku tworzymy obiekt klasy Counter o nazwie theCtr i inicjalizujemy go zmienną całkowitą theShort.
Krok 2: Przypisujemy obiektowi theCtr wartość zmiennej theShort. Zmienna theShort jest zmienną całkowitą, a nie zmienną typu Counter! Najpierw musimy przekonwertować ją do typu Counter. Kompilator spróbuje dokonać dla nas pewnych konwersji automatycznie, ale musimy go tego nauczyć. Osiągniemy to poprzez stworzenie dla klasy Counter konstruktora, którego jedynym parametrem jest wartość całkowita: class Counter
{ Counter(int val); // ... };
Konstruktor tworzy obiekty typu Counter na podstawie wartości typu int. Aby tego dokonać, tworzy tymczasowy, pozbawiony nazwy obiekt klasy Counter. Dla zilustrowania tego przykładu przypuśćmy, że ten tymczasowy obiekt typu Counter, tworzony ze zmiennej typu int, ma nazwę wasShort.
Krok 3: Przypisujemy wasShort do theCtr, co odpowiada zapisowi: *theCtr = wasShort;
W tym kroku wasShort (tymczasowy obiekt stworzony podczas działania konstruktora) jest zastępowany zapisem znajdującym się po prawej stronie operatora przypisania. Teraz, gdy kompilator potrafi stworzyć dla nas obiekt tymczasowy, może zainicjalizować nim zmienną theCtr. Aby zrozumieć ten proces, musisz uświadomić sobie, że wszystkie przeciążenia operatorów działają w ten sam sposób — deklarujesz przeciążony operator, używając słowa kluczowego operator. W przypadku operatorów binarnych (takich jak = czy +), parametrem operatora staje się zmienna położona po jego prawej stronie. Jest to zapewniane przez kompilator. Tak więc: a = b;
staje się a.operator=(b);
Co się jednak stanie, gdy spróbujesz odwrócić przypisanie: 0: 1: 2:
Counter theCtr(5); int theShort = theCtr; cout << "theShort: " << theShort << endl;
Także w tym przypadku Znów wystąpi błąd kompilacji. Choć kompilator wie już, w jaki sposób stworzyć obiekt typu Counter z wartości typu int, nie ma pojęcia, jak odwrócić ten proces.
Operatory konwersji Aby rozwiązać ten i podobne problemy, język C++ dostarcza operatorów konwersji, które mogą być dodawane do tworzonych klas. Dzięki temu klasa może jawnie określić, w jaki sposób ma być dokonywana konwersja do typów wbudowanych. Pokazuje to listing 10.18. Zwróć uwagę, że operatory konwersji nie określają zwracanej wartości, mimo, iż w efekcie zwracają wartość przekonwertowaną. Listing 10.18. Konwersja z typu Counter na typ unsigned short() 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37:
// Listing 10.18 - Operatory konwersji #include class Counter { public: Counter(); Counter(int val); ~Counter(){} int GetItsVal()const { return itsVal; } void SetItsVal(int x) {itsVal = x; } operator unsigned short(); private: int itsVal; }; Counter::Counter(): itsVal(0) {} Counter::Counter(int val): itsVal(val) {} Counter::operator unsigned short () { return ( int (itsVal) ); } int main() { Counter ctr(5); int theShort = ctr; std::cout << "theShort: " << theShort << std::endl; return 0; }
Wynik theShort: 5
Analiza W linii 12. deklarowany jest operator konwersji. Zwróć uwagę, że nie posiada on wartości zwrotnej. Implementacja tej funkcji znajduje się w liniach od 26. do 29. Linia 28. zwraca wartość itsVal, przekonwertowaną na wartość typu int.
Teraz kompilator wie już, w jaki sposób zamieniać wartości typu int w obiekty typu Counter i odwrotnie. Dzięki temu można je sobie wzajemnie przypisywać.
Rozdział 11.
Analiza i projektowanie zorientowane obiektowo Gdy skoncentrujesz się wyłącznie na składni języka C++, łatwo zapomnisz, dlaczego techniki te są używane do tworzenia programów. Z tego rozdziału dowiesz się, jak: - używać analizy zorientowanej obiektowo w celu zrozumienia problemów, które próbujesz rozwiązać, - używać modelowania zorientowanego obiektowo do tworzenia stabilnych, pewnych i możliwych do rozbudowania rozwiązań, - używać zunifikowanego języka modelowania (UML, Unified Modeling Language) do dokumentowania analizy i projektu.
1
Budowanie modeli Jeśli chcemy ogarnąć złożony problem, musimy stworzyć „model świata”. Zadaniem tego modelu jest symboliczne przedstawienie świata rzeczywistego. Taki abstrakcyjny model powinien być prostszy niż świat rzeczywisty, ale powinien poprawnie go odzwierciedlać, tak, aby na podstawie modelu można było przewidzieć zachowanie przedmiotów istniejących w realnym świecie. Klasycznym modelem świata jest dziecięcy globus. Model ten nie jest tylko rzeczą; choć nigdy nie mylimy go z Ziemią, odwzorowuje on Ziemię na tyle dobrze, że możemy poznać jej budowę oglądając powierzchnię globusa. W modelu występują oczywiście znaczne uproszczenia. Na globusie mojej córki nigdy nie pada deszcz, nie ma powodzi, trzęsień ziemi, itd., ale mogę go użyć, aby przewidzieć, ile czasu zajmie mi podróż z domu do Indianapolis, gdybym musiał osobiście stawić się w wydawnictwie i usprawiedliwić się, dlaczego rękopis się opóźnia („Wiesz, wszystko szło dobrze, ale nagle pogubiłem się w metaforach i przez kilka godzin nie mogłem się z nich wydostać”). Metoda, która nie jest prostsza od modelowanej rzeczy, nie jest przydatna. Komik Steve Wright zażartował kiedyś: „Mam mapę, na której jeden cal równa się jednemu calowi. Mieszkam na E5.” Projektowanie oprogramowania zorientowane obiektowo zajmuje się budowaniem dobrych modeli. Składa się z dwóch ważnych elementów: języka modelowania oraz procesu.
Projektowanie modelowania
oprogramowania:
język
Język modelowania jest najmniej znaczącym aspektem obiektowo zorientowanej analizy i projektowania; niestety, przyciąga on najwięcej uwagi. Język modelowania nie jest tylko niż konwencją, określającą sposób rysowania modelu na papierze. Możemy zdecydować, że trójkąty będą reprezentować klasy, a przerywane linie będą symbolizować dziedziczenie. Przy takich założeniach możemy stworzyć model geranium tak, jak pokazano na rysunku 11.1. Rys. 11.1. Generalizacja – specjalizacja
2
Na tym rysunku widać, że Geranium jest szczególnym rodzajem Kwiatu. Jeśli zarówno ty, jak i ja zgodzimy się na rysowanie diagramów dziedziczenia (generalizacji – specjalizacji) w ten sposób, wtedy wzajemnie się zrozumiemy. Prawdopodobnie wkrótce zechcemy stworzyć model mnóstwa złożonych zależności, w tym celu opracujemy nasz złożony zestaw konwencji i reguł rysowania. Oczywiście, musimy przedstawić nasze konwencje wszystkim osobom, z którymi pracujemy; będzie je musiał poznać każdy nowy pracownik lub współpracownik. Możemy współpracować z innymi firmami, posiadającymi własne konwencje, w związku z czym będziemy potrzebować czasu na wynegocjowanie wspólnej konwencji i wyeliminowanie ewentualnych nieporozumień. Dużo wygodniej byłoby, gdyby wszyscy zgodzili się na wspólny język modelowania. (Wygodnie byłoby, gdyby wszyscy mieszkańcy Ziemi zgodzili się na używanie wspólnego języka, ale to już inne zagadnienie.) Takim lingua franca w projektowaniu oprogramowania jest UML – Unified Modeling Language (zunifikowany język modelowania)1. Zadaniem UML jest udzielenie odpowiedzi na pytania w rodzaju: „Jak rysować relację dziedziczenia?” Model geranium z rysunku 11.1 w UML mógłby zostać przedstawiony tak, jak na rysunku 11.2. Rys. 11.2. Specjalizacja narysowana w UML
1
Często określenie „język UML” utożsamia się z bardziej ogólnym pojęciem, jakim jest metodyka (projektowania) UML — przyp.red.
3
W UML klasy są rysowane w postaci prostokątów, zaś dziedziczenie jest przedstawiane jako linia zakończona strzałką. Strzałka przebiega w kierunku od klasy bardziej wyspecjalizowanej do klasy bardziej ogólnej. Dla większości osób taki kierunek strzałki jest niezgodny ze zdrowym rozsądkiem, ale nie ma to większego znaczenia; gdy wszyscy się na to zgodzimy, cały system zadziała poprawnie. Szczegóły działania UML są raczej proste. Diagramy nie są trudne w użyciu i zrozumieniu; zostaną opisane w trakcie ich wykorzystywania. Choć na temat UML można napisać całą książkę, jednak w 90 procentach przypadków będziesz korzystał jedynie z małego podzbioru tego języka; podzbiór ten jest bardzo łatwy do zrozumienia.
Projektowanie oprogramowania: proces Proces obiektowo zorientowanej analizy i projektowania jest dużo bardziej złożony i ważniejszy niż język modelowania. Oczywiście, słyszy się o nim dużo mniej. Dzieje się tak dlatego, że niezgodności dotyczące języka modelowania zostały już w dużym stopniu wyeliminowane; przemysł informatyczny zdecydował się na używanie UML. Debata na temat procesu wciąż trwa. Metodolog jest osobą, która opracowuje lub studiuje jedną lub więcej metod. Zwykle metodolodzy opracowują i publikują własne metody. Metoda jest językiem modelowania i procesem. Trzech wiodących w branży metodologów to: Grady Booch, który opracował metodę Boocha, Ivar Jacobson, który opracował obiektowo zorientowaną inżynierię oprogramowania oraz James Rumbaugh, który opracował technologię OMT (Object Modeling Technology). Ci trzej mężczyźni stworzyli wspólnie tzw. Rational Unified Process (dawniej znany jako Objectory), metodę oraz komercyjny produkt firmy Rational Software Inc. Wszyscy trzej są
4
zatrudnieni we wspomnianej wyżej firmie, gdzie są znani jako trzej przyjaciele (Three Amigos)2. Ten rozdział przedstawia w ogólnym zarysie stworzony przez nich procesem. Nie będę szczegółowo go przedstawiał, gdyż nie wierzę w niewolnicze przywiązanie do akademickiej teorii – dużo bardziej niż postępowanie zgodne z metodą interesuje mnie sprzedanie produktu. Inne metody również dostarczają ciekawych rozwiązań, więc będę starał się wybierać z nich to, co wydaje mi się najlepsze i łączyć to użyteczną całość. Proces projektowania oprogramowania jest iteracyjny. Oznacza to, że opracowując program „przechodzimy” przez cały proces wielokrotnie, coraz lepiej rozumiejąc jego wymagania. Projekt ukierunkowuje implementację, ale szczegóły, na które zwracamy uwagę podczas implementacji, wpływają z kolei na projekt. Nie próbujemy opracować jakiegokolwiek niebanalnego projektu w pojedynczym, uporządkowanym procesie liniowym; zamiast tego rozwijamy fragmenty projektu, wciąż poprawiając jego założenia oraz ulepszając szczegóły implementacji. Opracowywanie iteracyjne można odróżnić od opracowywania kaskadowego. W opracowywaniu kaskadowym wynik jednego etapu staje się wejściem dla następnego, przy czym nie istnieje możliwość powrotu (patrz rysunek 11.3). W procesie opracowywania kaskadowego wymagania są szczegółowo przedstawione klientowi i podpisane przez niego („Tak, właśnie tego potrzebuję”); następnie wymagania te są przekazywane projektantowi. Projektant tworzy projekt, po czym przekazuje go programiście, w celu implementacji. Z kolei programista
2
Każdy z tych panów był autorem odrębnej metodyki projektowania.
J. Rumbaugh opracował Object Modelling Technique (OMT), która jest wystarczająca w przypadku modelowania dziedziny zagadnienia (problem domain). Nie odzwierciedla jednak dokładnie ani wymagań użytkowników systemów, ani wymagań implementacji. I. Jacobson rozwinął Object-Oriented System Engineering (OOSE), który w sposób zadowalający uwzględnia aspekty modelowania użytkowników i cyklu życia systemu jako całości. Nie odzwierciedla jednak w sposób wystarczający sposobu modelowania dziedziny oraz aspektu implementacji. G. Booch jest autorem Object-Oriented Analysis and Design Methods (OOAD), spełniającej wszelkie wymogi w dziedzinie projektowania, konstrukcji i związków ze środowiskiem implementacji. Nie uwzględnia jednak w sposób dostateczny fazy rozpoznania i analizy wymagań użytkowników. UML ma stanowić syntezę wymienionych metodyk. Ma jednak wielu krytyków. W wielu publikacjach można przeczytać, że jest to rzecz przereklamowana i w niewystarczający sposób zdefiniowana. Konkurencją dla ciągle uzupełnianej metodyki UML jest m.in. metodyka i notacja oparta na tzw. technice “design by contracts”. — przyp.red
5
wręcza kod osobie zajmującej się kontrolą jakości, która sprawdza jego działanie i przekazuje go klientowi. Wspaniałe w teorii, katastrofalne w praktyce. Rys. 11.3. Model kaskadowy
Przy opracowywaniu iteracyjnym zaczynamy od koncepcji; pomysłu, jak moglibyśmy to zbudować. W miarę poznawania szczegółów nasza wizja może rozrastać się i ewoluować. Gdy już dobrze znamy wymagania, możemy rozpocząć projektowanie, doskonale zdając sobie sprawę, że pytania, które się wtedy pojawią, mogą wprowadzić zmiany w wymaganiach. Pracując nad projektem, zaczynamy tworzyć prototyp, a następnie implementację produktu. Zagadnienia pojawiające się podczas opracowywania programu wpływają na zmiany w projekcie i mogą nawet wpłynąć na zrozumienie wymagań. Projektujemy i implementujemy tylko części produktu, powtarzając za każdym razem fazy projektowania i implementacji. Choć poszczególne etapy tego procesu są powtarzane, jednak opisanie ich w sposób cykliczny jest prawie niemożliwe. Dlatego opiszę je w następującej kolejności: koncepcja początkowa, analiza, projekt, implementacja, testowanie, prezentacja. Nie zrozum mnie źle — w rzeczywistości, podczas tworzenia pojedynczego produktu przechodzimy przez każdy z tych kroków wielokrotnie. Po prostu proces iteracyjny byłby trudny do przedstawienia, gdybyśmy chcieli pokazać cykliczne wykonywanie każdego z kroków. Oto kolejne kroki iteracyjnego procesu projektowania: 1. Konceptualizacja. 2. Analiza. 3. Projektowanie. 4. Implementacja. 5. Testowanie 6. Prezentacja.
6
Konceptualizacja to „tworzenie wizji”. Jest pojedynczym zdaniem, opisującym dany pomysł. Analiza jest procesem zrozumienia wymagań. Projektowanie jest procesem tworzenia modelu klas, na podstawie którego wygenerujemy kod. Implementacja jest pisaniem kodu (na przykład w C++). Testowanie jest upewnianiem się, czy wykonaliśmy wszystko poprawnie. Prezentacja to pokazanie produktu klientom. Bułka z masłem. Cała reszta to detale.
Kontrowersje
Pojawia się mnóstwo kontrowersji na temat tego, co dzieje się na każdym etapie procesu projektowania iteracyjnego, a nawet na temat nazw poszczególnych etapów. Zdradzę ci sekret: to nie ma znaczenia. Podstawowe kroki są w każdym obiektowo zorientowanym procesie takie same: dowiedz się, co chcesz zbudować, zaprojektuj rozwiązanie i zaimplementuj projekt.
Choć w grupach dyskusyjnych i listach mailingowych dyskutujących o technologii obiektowej dzieli się włos na czworo, podstawowa analiza i projektowanie obiektowe są niezmienne. W tym rozdziale przedstawię pewien punkt widzenia na ten temat, mając nadzieję, że utworzę w ten sposób fundament, na którym będziesz mógł stworzyć architekturę swojej aplikacji.
Celem tej pracy jest stworzenie kodu, który spełnia założone wymagania i który jest stabilny, możliwy do rozbudowania i łatwy do modyfikacji. Najważniejsze jest stworzenie kodu o wysokiej jakości (w określonym czasie i przy założonym funduszu).
Programowanie ekstremalne Ostatnio pojawiła się nowa koncepcja analizy i projektowania, zwana programowaniem ekstremalnym. Programowanie to zostało omówione przez Kena Becka w książce Extreme Programming Expanded: Embrace Change (Addison-Wesley, 1999 ISBN 0201616416).
7
W tej książce Beck przedstawia kilka radykalnych i cudownych pomysłów, np. by nie kodować niczego, dopóki nie będzie można sprawdzić, czy to działa, a także programowanie w parach (dwóch programistów przy jednym komputerze). Jednak z naszego punktu widzenia, najważniejszym jego stwierdzeniem jest to, że zmianom ulegają wymagania. Należy więc sprawić, by program działał i utrzymywać to działanie; należy projektować dla wymagań, które się zna i nie tworzyć projektów „na wyrost”. Jest to, oczywiście, ogromne uproszczenie wypowiedzi Becka i sądzę, że mógłby uznać je za przeinaczenie, jednak w każdym razie wierzę w jego sedno: spraw by program działał, tworząc go dla wymagań, które rozumiesz i staraj się nie doprowadzić do sytuacji, w której, programu nie można już zmienić. Bez zrozumienia wymagań (analiz) i planowania (projekt), trudno jest stworzyć stabilny i łatwy do modyfikacji program, jednak staraj się nie kontrolować zbyt wielu czynności.
Pomysł Wszystkie wspaniałe programy powstają z jakiegoś pomysłu. Ktoś ma wizję produktu, który uważa za warty wdrożenia. Pożyteczne pomysły rzadko kiedy powstają w wyniku pracy zbiorowej. Pierwszą fazą obiektowo zorientowanej analizy i projektowania jest zapisanie takiego pomysłu w pojedynczym zdaniu (a przynajmniej krótkim akapicie). Pomysł ten staje się myślą przewodnią tworzonego programu, zaś zespół, który zbiera się w celu zaimplementowania tego pomysłu, powinien podczas pracy odwoływać się do tej myśli przewodniej — w razie potrzeby nawet ją modyfikując. Nawet jeśli pomysł narodził się w wyniku zespołowej pracy działu marketingu, „wizjonerem” powinna zostać jedna osoba. Jej zadaniem jest utrzymywanie „czystości idei”. W miarę rozwoju projektu wymagania będą ewoluować. Harmonogram pracy może (i powinien) modyfikować to, co próbujesz osiągnąć w pierwszej iteracji programowania, jednak wizjoner musi zapewnić, że wszystko to, co zostanie stworzone, odzwierciedli pierwotny pomysł. Właśnie jego bezwzględne poświęcenie i żarliwe zaangażowanie doprowadza do ukończenia projektu. Gdy stracisz z oczu pierwotny zamysł, twój produkt jest skazany na niepowodzenie.
8
Analiza wymagań Faza konceptualizacji, w której precyzowany jest pomysł, jest bardzo krótka. Może trwać krócej niż błysk olśnienia połączony z czasem wymaganym do zapisania pomysłu na kartce. Często zdarza się, że dołączasz do projektu jako ekspert zorientowany obiektowo wtedy, gdy wizja została już sprecyzowana. Niektóre firmy mylą pomysł z wymaganiami. Wyraźna wizja jest potrzebna, lecz sama w sobie nie jest wystarczająca. Aby przejść do analizy, musisz zrozumieć, w jaki sposób produkt będzie używany i jak musi działać. Celem fazy analizy jest sprecyzowanie tych wymagań. Efektem końcowym tej fazy jest stworzenie dokumentu zawierającego opracowane wymagania. Pierwszą częścią tego dokumentu jest analiza przypadków użycia produktu.
Przypadki użycia Istotną częścią analizy, projektowania i implementacji są przypadki użycia. Przypadek użycia jest ogólnym opisem sposobu, w jaki produkt będzie używany. Przypadki użycia nie tylko ukierunkowują analizę, ale także pomagają w określeniu klas i są szczególnie ważne podczas testowania produktu. Tworzenie stabilnego i wyczerpującego zestawu przypadków użycia może być najważniejszym zadaniem całej analizy. Właśnie wtedy jesteś najbardziej uzależniony od ekspertów w danej dziedzinie, to oni wiedzą najwięcej o dziedzinie pracy, której wymagania próbujesz określić. Przypadki użycia są w niewielkim stopniu związane z interfejsem użytkownika, nie są natomiast związane z wnętrzem budowanego systemu. Każda osoba (lub system) współpracująca z projektowanym systemem jest nazywana aktorem. Dokonajmy krótkiego podsumowania: - przypadek użycia – opis sposobu, w jaki używane będzie oprogramowanie, - eksperci – osoby znające się na dziedzinie, dla której tworzysz produkt, - aktor – każda osoba (lub system) współpracująca z projektowanym systemem. Przypadek użycia jest opisem interakcji zachodzących pomiędzy aktorem a samym systemem. W trakcie analizy przypadku użycia system jest traktowany
9
jako „czarna skrzynka.” Aktor „wysyła komunikat” do systemu, po czym zwracana jest informacja, zmienia się stan systemu, statek kosmiczny zmienia kierunek, itd.
Identyfikacja aktorów Należy pamiętać, że nie wszyscy aktorzy są ludźmi. Systemy współpracujące z budowanym systemem także są aktorami. Gdy budujesz na przykład bankomat, aktorami mogą być urzędnik bankowy i klient – a także inny system współpracujący z aktualnie tworzonym systemem, na przykład system śledzenia pożyczek czy udzielania kredytów studenckich. Oto podstawowa charakterystyki aktorów: - są oni na zewnątrz dla systemu, - współpracują z systemem. Często najtrudniejszą częścią analizy przypadków użycia jest jej początek. Zwykle najlepszą metodą „ruszenia z miejsca” jest sesja burzy mózgów. Po prostu spisz listę osób i systemów, które będą pracować z nowym systemem. Pamiętaj, że mówiąc o ludziach, w rzeczywistości mamy na myśli role – urzędnika bankowego, kasjera, klienta, itd. Jedna osoba może pełnić więcej niż jedną rolę. We wspomnianym przykładzie z bankomatem, na naszej liście mogą wystąpić następujące role: - klient - personel banku - system bankowy - osoba wypełniająca bankomat pieniędzmi i materiałami Na początku nie ma potrzeby wychodzenia poza tę listę. Wygenerowanie trzech czy czterech aktorów może wystarczyć do rozpoczęcia generowania przypadków użycia. Każdy z tych aktorów pracuje z systemem w inny sposób; chcemy wykryć te interakcje w naszych sposobach użycia.
10
Wyznaczanie pierwszych przypadków użycia Zacznijmy od roli klienta. Podczas burzy mózgów możemy określić następujące przypadki użycia dla klienta: - klient sprawdza stan swojego rachunku, - klient wpłaca pieniądze na swój rachunek, - klient wypłaca pieniądze ze swojego rachunku, - klient przelewa pieniądze z rachunku na rachunek, - klient otwiera rachunek, - klient zamyka rachunek. Czy powinniśmy dokonać rozróżnienia pomiędzy „klient wpłaca pieniądze na swój rachunek bieżący” a „klient wpłaca pieniądze na lokatę”, czy też powinniśmy te działania połączyć (tak jak na powyższej liście) w „klient wpłaca pieniądze na swój rachunek?” Odpowiedź na to pytanie zależy od tego, czy takie rozróżnienie ma znaczenie dla danej dziedziny (dziedzina jest rzeczywistym środowiskiem, które modelujemy – w tym przypadku jest nią bankowość). Aby sprawdzić, czy te działania są jednym przypadkiem użycia, czy też dwoma, musisz zapytać, czy ich mechanizmy są różne (czy klient w każdym z przypadków robi coś innego) i czy różne są wyniki (czy system odpowiada na różne sposoby). W naszym przykładzie, w obu przypadkach odpowiedź brzmi „nie”: klient składa pieniądze na każdy z rachunków w ten sam sposób, przy czym wynik także jest podobny, gdyż bankomat odpowiada, zwiększając stan odpowiedniego rachunku. Zakładając, że aktor i system działają i odpowiadają mniej więcej identycznie, bez względu na to, na jaki rachunek dokonuje wpłaty, te dwa przypadki użycia są w rzeczywistości jednym sposobem. Później, gdy opracujemy scenariusze przypadków użycia, możemy wypróbować obie wariacje i sprawdzić, czy ich rezultatem są jakiekolwiek różnice. Odpowiadając na poniższe pytania, możesz odkryć dodatkowe przypadki użycia: 1. Dlaczego aktor używa tego systemu?
11
Klient używa tego systemu, aby zdobyć gotówkę, złożyć depozyt lub sprawdzić bieżący stan rachunku. 2. Jakiego wyniku oczekuje aktor po każdym żądaniu? Zwiększenia stanu rachunku lub uzyskania gotówki na zakupy. 3. Co spowodowało, że aktor używa w tym momencie systemu? Być może ostatnio otrzymał wypłatę lub jest na zakupach. 4. Co aktor musi zrobić, aby użyć systemu? Włożyć kartę do szczeliny w bankomacie. Aha! Potrzebujemy przypadku użycia dla logowania się klienta do systemu. 5. Jakie informacje aktor musi dostarczyć systemowi? Musi wprowadzić kod PIN. Aha! Potrzebujemy przypadków użycia dla uzyskania i edycji kodu PIN. 6. Jakich informacji aktor oczekuje od systemu? Stanu rachunku itd. Często dodatkowe przypadki użycia możemy znaleźć, skupiając się na atrybutach obiektów w danej dziedzinie. Klient posiada nazwisko, kod PIN oraz numer rachunku; czy występują przypadki użycia dla zarządzania tymi obiektami? Rachunek posiada swój numer, stan oraz historię transakcji; czy wykryliśmy te elementy w przypadkach użycia? Po szczegółowym przeanalizowaniu przypadków użycia dla klienta, następnym krokiem w opracowywaniu listy przypadków użycia jest opracowanie przypadków użycia dla wszystkich pozostałych aktorów. Poniższa lista przedstawia pierwszy zestaw przypadków użycia dla naszego przykładu z bankomatem: - klient sprawdza stan swojego rachunku, - klient wpłaca pieniądze na swój rachunek, - klient wypłaca pieniądze ze swojego rachunku, - klient przekazuje pieniądze z rachunku na rachunek, - klient otwiera rachunek,
12
- klient zamyka rachunek, - klient loguje się do swojego rachunku, - klient sprawdza ostatnie transakcje, - urzędnik bankowy loguje się do specjalnego konta przeznaczonego do zarządzania, - urzędnik bankowy dokonuje zmian w rachunku klienta, - system bankowy aktualizuje stan rachunku klienta na podstawie działań zewnętrznych, - zmiany rachunku użytkownika są odzwierciedlane w systemie bankowym, - bankomat sygnalizuje niedobór pieniędzy, - technik uzupełnia w bankomacie gotówkę i materiały.
Tworzenie modelu dziedziny Gdy masz już pierwszą wersję przypadków użycia, możesz zacząć wypełniać dokument wymagań szczegółowym modelem dziedziny. Model dziedziny jest dokumentem zawierającym wszystko to, co wiesz o danej dziedzinie (zagadnieniu, nad którym pracujesz). Jako część modelu dziedziny tworzysz obiekty dziedziny, opisujące wszystkie obiekty wymienione w przypadkach użycia. Przykład z bankomatem zawiera następujące obiekty: klient, personel banku, system bankowy, rachunek bieżący, lokata, itd. Dla każdego z tych obiektów dziedziny chcemy uzyskać tak ważne dane, jak nazwa obiektu (na przykład klient, rachunek, itd.), czy obiekt jest aktorem, podstawowe atrybuty i zachowanie obiektu, itd. Wiele narzędzi do modelowania wspiera zbieranie tych informacji w opisach „klas.” Na przykład, rysunek 11.4 przedstawia sposób, w jaki te informacje są zbierane w systemie Rational Rose. Rys. 11.4. Rational Rose
13
Należy zdawać sobie sprawę, że to, co opisujemy, nie jest obiektem projektu, ale obiektem dziedziny. Odzwierciedla sposób funkcjonowania świata, a nie sposób działania naszego systemu. Możemy określić relacje pomiędzy obiektami dziedziny pojawiającymi się w przykładzie z bankomatem, używając UML – korzystając z takich samych konwencji rysowania, jakich użyjemy później do opisania relacji pomiędzy klasami w dziedzinie. Jest to jedna z ważniejszych zalet UML: możemy używać tych samych narzędzi na każdym etapie projektu. Na przykład, używając konwencji UML dla klas i powiązań generalizacji, możemy przedstawić rachunki bieżące i rachunki lokat jako specjalizacje bardziej ogólnej koncepcji rachunku bankowego, tak jak pokazano na rysunku 11.5. Rys. 11.5. Specjalizacje
14
Na diagramie z rysunku 11.5 prostokąty reprezentują różne obiekty dziedziny; zaś strzałki wskazują generalizację. UML zakłada, że linie są rysowane w kierunku od klasy wyspecjalizowanej do bardziej ogólnej klasy „bazowej.” Dlatego, zarówno Rachunek bieżący, jak i Rachunek lokaty, wskazują na Rachunek bankowy, informując że każdy z nich jest wyspecjalizowaną formą Rachunku bankowego.
UWAGA Pamiętajmy, że w tym momencie widzimy tylko zależności pomiędzy obiektami w dziedzinie. Później być może zdecydujesz się na zastosowanie w projekcie obiektów o nazwach RachunekBiezacy oraz RachunekBankowy i może odwzorujesz te zależności, używając dziedziczenia, ale będą to decyzje podjęte w czasie projektowania. W czasie analizy dokumentujemy jedynie obiekty istniejące w danej dziedzinie.
UML jest bogatym językiem modelowania i można w nim umieścić dowolną ilość relacji. Jednak podstawowe relacje wykrywane podczas analizy to: generalizacja (lub specjalizacja), zawieranie i powiązanie.
Generalizacja Generalizacja jest często porównywana z „dziedziczeniem,” lecz istnieje pomiędzy nimi wyraźna, istotna różnica. Generalizacja opisuje relację; dziedziczenie jest programową implementacją generalizacji – jest sposobem przedstawienia generalizacji w kodzie. Odwrotnością generalizacji jest specjalizacja. Kot jest wyspecjalizowaną formą zwierzęcia, zaś zwierzę jest generalną formą kota lub psa. Specjalizacja określa, że obiekt wyprowadzony jest podtypem obiektu bazowego. Zatem rachunek bieżący jest rachunkiem bankowym. Ta relacja jest symetryczna: rachunek bankowy generalizuje ogólne zachowanie i atrybuty rachunku bieżącego i rachunku lokaty. Podczas analizowania dziedziny chcemy przedstawić te zależności dokładnie tak, jak występują w realnym świecie.
Zawieranie Często obiekt składa się z wielu podobiektów. Na przykład samochód składa się z kierownicy, kół, drzwi, radia, itd. Rachunek bieżący składa się ze stanu, historii transakcji, identyfikatora klienta, itd. Mówimy, że rachunek bieżący posiada te
15
elementy; zawieranie modeluje właśnie takie relacje posiadania. UML ilustruje relację zawierania za pomocą strzałki z rombem, wskazującej obiekt zawierany (patrz rysunek 11.6). Rys. 11.6. Zawieranie
Diagram z rysunku 11.6 sugeruje, że rachunek osobisty posiada stan. Można połączyć oba diagramy, przedstawiając w ten sposób dość złożony zestaw relacji (patrz rysunek 11.7). Rys. 11.7. Relacje pomiędzy obiektami
16
Diagram z rysunku 11.7 informuje, że rachunek bieżący i rachunek lokaty są rachunkami bankowymi oraz, że rachunki bankowe posiadają zarówno stan, jak i historię transakcji.
Powiązania Trzecią relacją, wykrywaną zwykle podczas analizowania dziedziny, jest proste powiązanie. Powiązanie sugeruje, że dwa obiekty w jakiś sposób ze sobą współpracują. Ta definicja staje się dużo bardziej precyzyjna w fazie projektowania, ale w fazie analizy sugerujemy jedynie, że obiekt A współpracuje z obiektem B, i że jeden obiekt nie zawiera drugiego; a także, że żaden z nich nie jest specjalizacją drugiego. W UML powiązania między obiektami są przedstawiane jako zwykła prosta linia pomiędzy obiektami, co pokazuje rysunek 11.8. Diagram z rysunku 11.8 wskazuje, że obiekt A w jakiś sposób współpracuje z obiektem B. Rys. 11.8.
17
Tworzenie scenariuszy Gdy mamy już gotowy wstępny zestaw przypadków użycia oraz narzędzi, dzięki którym możemy przedstawić relacje pomiędzy obiektami w dziedzinie, jesteśmy gotowi do uporządkowania przypadków użycia i zdefiniowania ich przeznaczenia. Każdy przypadek użycia można „rozbić” na serie scenariuszy. Scenariusz jest opisem określonego zestawu okoliczności towarzyszących danemu przypadkowi użycia. Na przykład, przypadek użycia „klient wypłaca pieniądze ze swojego rachunku” może posiadać następujące scenariusze: - klient żąda trzystu dolarów z rachunku bieżącego, otrzymuje gotówkę, po czym system drukuje kwit, - klient żąda trzystu dolarów z rachunku bieżącego, lecz na rachunku znajduje się tylko dwieście dolarów. Klient jest informowany, że na koncie znajduje się zbyt mało środków, aby spełnić jego żądanie, - klient żąda trzystu dolarów z rachunku bieżącego, ale tego dnia pobrał już sto dolarów, a limit dzienny wynosi trzysta dolarów. Klient jest informowany o problemie i może się zdecydować na pobranie jedynie dwustu dolarów, - klient żąda trzystu dolarów z rachunku bieżącego, ale skończył się papier w drukarce kwitów. Klient jest informowany o problemie i może się zdecydować na pobranie pieniędzy bez potwierdzenia w postaci kwitu. I tak dalej. Każdy scenariusz przedstawia wariant tego samego przypadku użycia. Często te sytuacje są sytuacjami wyjątkowymi (zbyt mało środków na rachunku, zbyt mało gotówki w bankomacie, itd.). Czasem warianty dotyczą niuansów w podejmowaniu decyzji w samym sposobie użycia (na przykład, czy przed podjęciem gotówki klient chce dokonać transferu środków).
18
Nie musimy analizować każdego ewentualnego scenariusza. Szukamy tych scenariuszy, które prezentują wymagania systemu lub szczegóły interakcji z aktorem.
Tworzenie wytycznych Teraz, jako część metodologii, będziemy tworzyć wytyczne dla udokumentowania każdego ze scenariuszy. Te wytyczne znajdą się w dokumentacji wymagań. Zwykle chcemy, by każdy scenariusz zawierał: - warunki wstępne – jakie warunki muszą być spełnione, aby scenariusz się rozpoczął, - włączniki – co powoduje, że scenariusz się rozpoczyna, - akcje, jakie podejmuje aktor, - wyniki lub zmiany powodowane przez system, - informację zwrotną otrzymywaną przez aktora, - informacje o występowaniu cyklicznych operacji i o przyczynach ich wykonywania, - schematyczny opis przebiegu scenariusza, - okoliczności powodujące zakończenie scenariusza, - warunki końcowe – jakie warunki muszą być spełnione w momencie zakończenia scenariusza. Ponadto, każdemu sposobowi użycia i każdemu scenariuszowi powinno się nadać nazwę. Możesz spotkać się z następującą sytuacją: Przypadek użycia:
Klient wypłaca pieniądze.
Scenariusz:
Pomyślne pobranie gotówki z rachunku bieżącego.
Warunki wstępne:
Klient jest już zalogowany do systemu.
Włącznik:
Klient żąda gotówki.
Opis:
Klient decyduje się na wypłacenie gotówki z rachunku
19
bieżącego. Na rachunku znajduje się wystarczająca ilość środków, w bankomacie jest wystarczająco dużo pieniędzy i papieru na kwity, a sieć działa. Bankomat prosi klienta o podanie wysokości wypłaty, klient prosi o trzysta dolarów, co w tym momencie jest kwotą dozwoloną. Maszyna wydaje trzysta dolarów i wypisuje kwit; klient odbiera pieniądze i kwit. Warunki końcowe: Rachunek klienta jest obciążany kwotą trzystu dolarów, zaś klient otrzymuje trzysta dolarów w gotówce. Ten przypadek użycia może zostać przedstawiony za pomocą prostego diagramu, pokazanego na rysunku 11.9. Rys. 11.9. Diagram przypadku użycia
Ten diagram nie dostarcza zbyt wielu informacji, poza wysokopoziomową abstrakcją interakcji pomiędzy aktorem (klientem) a systemem. Diagram stanie się nieco bardziej użyteczny, gdy przedstawimy interakcję pomiędzy sposobami użycia. Tylko nieco bardziej użyteczny, gdyż możliwe są tylko dwie interakcje: <> (<>) i <> (<>). Stereotyp <> wskazuje, że jeden przypadek użycia jest nadzestawem innego. Na przykład, nie jest możliwa wypłata gotówki bez wcześniejszego zalogowania się. Tę relację przedstawiamy za pomocą diagramu, pokazanego na rysunku 11.10. Rys. 11.10. Stereotyp <>
20
Rysunek 11.10 pokazuje, że przypadek użycia Wypłata Gotówki „korzysta z” przypadku użycia Logowanie i w pełni implementuje Logowanie jako część Wypłaty Gotówki. Przypadek użycia <> został opracowany w celu wskazania relacji warunkowych i częściowo odnosił się do dziedziczenia, ale wywoływał tyle nieporozumień wśród projektantów obiektowych (związanych z odróżnieniem go od <>), że wielu z nich odrzuca go, uważając że nie jest wystarczająco dobrze zrozumiany. Ja używam <> aby uniknąć kopiowania i wklejania całego przypadku użycia, a <> używam wtedy, gdy korzystam z przypadku użycia tylko w określonych warunkach.
Diagramy interakcji Choć diagram przypadku użycia może mieć ograniczoną wartość, można powiązać go z przypadkiem użycia, który może znacznie wzbogacić dokumentację i ułatwić zrozumienie interakcji. Na przykład wiemy, że scenariusz Wypłata Gotówki reprezentuje interakcję pomiędzy następującymi obiektami dziedziny: klientem, rachunkiem bieżącym oraz interfejsem użytkownika. Możemy przedstawić tę interakcję na diagramie interakcji, widocznym na rysunku 11.11. Rys. 11.11. Diagram interakcji w języku UML
21
Diagram interakcji z rysunku 11.11 przedstawia te szczegóły scenariusza, które mogą nie zostać zauważane podczas czytania tekstu. Współdziałające ze sobą obiekty są obiektami dziedziny, a cały bankomat (ATM) wraz z interfejsem użytkownika traktowany jest jako pojedynczy obiekt, wywoływany szczegółowo jest tylko określony rachunek bankowy. Ten prosty przykład bankomatu pokazuje jedynie ograniczony zestaw interakcji, ale szczegółowe ich przeanalizowanie może okazać się bardzo pomocne w zrozumieniu zarówno dziedziny problemu, jak i wymagań nowego systemu.
Tworzenie pakietów Ponieważ dla każdego problemu o znacznej złożoności generuje się wiele przypadków użycia, UML umożliwia grupowanie ich w pakiety. Pakiet przypomina kartotekę lub folder – jest zbiorem obiektów modelowania (klas, aktorów, itd.). Aby opanować złożoność przypadków użycia, możemy tworzyć pakiety pogrupowane według charakterystyk, mających znaczenie dla danego projektu. Możesz więc pogrupować swoje przypadki użycia według rodzaju rachunku (wszystko, co odnosi się do rachunku bieżącego albo do lokaty), według wpływów albo obciążeń, według rodzaju klienta czy według jakiejkolwiek innej charakterystyki, która ma sens w danym przypadku.
22
Pojedynczy przypadek użycia może występować w kilku różnych pakietach, ułatwiając w ten sposób projektowanie.
Analiza aplikacji Oprócz tworzenia przypadków użycia, dokument wymagań powinien zawierać założenia i ograniczenia twojego klienta, a także wymagania wobec sprzętu i systemu operacyjnego. Wymagania aplikacji są założeniami pochodzącymi od konkretnego klienta – zwykle określiłbyś je podczas projektowania i implementacji, ale klient zadecydował o nich za ciebie. Wymagania aplikacji są często narzucane przez konieczność współpracy z istniejącymi systemami. W takim przypadku kluczowym elementem analizy jest zrozumienie sposobów działania istniejących systemów. W idealnych warunkach analizujesz problem, projektujesz rozwiązanie, po czym decydujesz, jaka platforma i system operacyjny najlepiej odpowiadają potrzebom twojego projektu. Taka sytuacja jest nie tylko idealna, ale i rzadka. Dużo częściej zdarza się, że klient zainwestował już w określony sprzęt lub system operacyjny. Plany jego firmy opierają się na działaniu twojego oprogramowania w istniejącym już systemie, więc musisz poznać te wymagania jak najwcześniej i odpowiednio się do nich dostosować.
Analiza systemów Czasem oprogramowanie jest zaprojektowane jako samodzielne; współpracuje ono jedynie z końcowym użytkownikiem. Często jednak twoim zadaniem będzie współpraca z istniejącym systemem. Analiza systemów to proces zbierania wszystkich informacji na temat systemów, z którymi będziesz współpracował. Czy twój nowy system będzie serwerem, dostarczającym usługi istniejącym systemom, czy też będzie ich klientem? Czy będziesz mógł negocjować interfejs pomiędzy systemami, czy też musisz się dostosować do istniejącego standardu? Czy inne systemy pozostaną niezmienne, czy też przez cały czas będziesz śledził zachodzące w nich zmiany? Na te i inne pytania należy odpowiedzieć podczas fazy analizowania, jeszcze przed przystąpieniem do projektowania nowego systemu. Oprócz tego, powinieneś poznać ograniczenia wynikające ze współpracy z innymi systemami.
23
Czy spowolnią one szybkość odpowiedzi twojego systemu? Czy nakładają one na twój system wysokie wymagania, zajmując zasoby i czas procesora?
Tworzenie dokumentacji Gdy już określisz zadania systemu i sposób jego działania, nadchodzi czas, aby podjąć pierwszą próbę stworzenia dokumentu, określającego czas i budżet produkcji. Często termin jest narzucony przez klienta z góry: „Masz na to osiemnaście miesięcy.” Byłoby wspaniale, gdybyś mógł przeanalizować wymagania i oszacować czas, jaki zajmie ci zaprojektowanie i zaimplementowanie rozwiązania. W praktyce większość systemów powstaje w bardzo krótkim terminie i przy niskich kosztach, zaś prawdziwa sztuka polega na określeniu, jak duża część założeń może zostać spełniona w zadanym czasie — oraz przy założonym budżecie. Oto wytyczne, o których powinieneś pamiętać, określając harmonogram i budżet projektu: - jeśli musisz zmieścić się w pewnym przedziale, wtedy założeniem optymistycznym jest najprawdopodobniej jego ograniczenie zewnętrzne, - zgodnie z prawem Liberty’ego, wszystko będzie trwać dłużej niż tego oczekujesz – nawet jeśli uwzględnisz to prawo. Konieczne będzie też określenie priorytetów. Nie skończysz w wyznaczonym terminie – po prostu. Zadbaj, by system działał w momencie, gdy kończy się czas ukończenia prac i by był wystarczający sprawny dla pierwszego wydania. Gdy budujesz most zbliża się termin ukończenia prac, a nie została jeszcze wykonana ścieżka rowerowa, to niedobrze; możesz jednak otworzyć już most i zacząć pobierać myto. Jeśli jednak most sięga dopiero połowy rzeki, to już bardzo źle. Dokumentów planowania przeważnie są błędne. Na tak wczesnym etapie projektu praktycznie nie jest możliwe właściwe oszacowanie czasu jego trwania. Gdy już znasz wymagania, możesz w przybliżeniu określić ilość czasu, jaką zajmie projektowanie systemu, jego implementacja i testowanie. Do tego musisz zaplanować dodatkowo od dwudziestu do dwudziestu pięciu procent „zapasu”, który możesz zmniejszać w trakcie wykonywania zlecenia (gdy dowiadujesz się coraz więcej).
24
UWAGA Uwzględnienie „zapasu” czasu nie może być wymówką dla uniknięcia tworzenia planu. Jest jedynie ostrzeżeniem, że nie można na nim do końca polegać. W trakcie prac nad projektem lepiej poznasz działanie systemu, a obliczenia staną się bardziej dokładne.
Wizualizacje Ostatnim elementem dokumentu wymagań jest wizualizacja. Jest to nazwa wszystkich diagramów, rysunków, zrzutów ekranu, prototypów i wszelkich innych wizualnych reprezentacji, przeznaczonych do wsparcia analizy i projektu graficznego interfejsu użytkownika dla produktu. W przypadku dużych projektów możesz opracować pełny prototyp, który pomoże tobie (i twoim klientom) zrozumieć jak będzie działał system. W niektórych przypadkach prototyp staje się odzwierciedleniem wymagań; prawdziwy system jest projektowany tak, by implementował funkcje zademonstrowane w prototypie.
Dokumentacja produktu Na koniec każdej fazy analizy i projektowania stworzysz serię dokumentów produktu. Tabela 11.1 pokazuje kilka z takich dokumentów dla fazy analizy. Są one używane przez klienta w celu upewnienia się, czy rozumiesz jego potrzeby, przez końcowego użytkownika jako wsparcie i wytyczne dla projektu, zaś przez zespół projektowy do zaprojektowania i zaimplementowania kodu. Wiele z tych dokumentów dostarcza także materiału istotnego zarówno dla zespołu zajmującego się dokumentacją, jak i zespołu kontroli jakości, informując ,w jaki sposób powinien zachowywać się system. Tabela 11.1. Dokumenty produktu tworzone podczas fazy analizy Dokument
Opis
Raport przypadków użycia
Dokument opisujący szczegółowo przypadki użycia, scenariusze, stereotypy, warunki wstępne, warunki końcowe oraz wizualizacje.
Analiza dziedziny
Dokument i diagramy, opisujące powiązania
25
pomiędzy obiektami dziedziny. Diagramy analizy współpracy
Diagramy współpracy, opisujące interakcje pomiędzy obiektami dziedziny.
Diagramy analizy działań
Diagramy działań, opisujące pomiędzy obiektami dziedziny.
Analiza systemu
Raport i diagramy, opisujące na niższym poziomie system i sprzęt, dla którego będzie tworzony projekt.
Dokument analizy zastosowań
Raport i diagramy, opisujące wymagania klienta wobec konkretnego produktu.
Raport ograniczeń działania
Raport charakteryzujący wydajność ograniczenia narzucone przez klienta.
Dokument kosztów i harmonogramu
Raport z wykresami Ganta i Perta, opisującymi zakładany harmonogram, etapy i koszty.
interakcje
oraz
Projektowanie Analiza skupia się na dziedzinie problemu, natomiast projektowanie zajmuje się stworzeniem rozwiązania. Projektowanie jest procesem przekształcenia wymagań w model, który może być zaimplementowany w postaci oprogramowania. Rezultatem tego procesu jest stworzenie dokumentu projektowego. Dokument projektowy jest podzielony na dwie części: projekt klas oraz mechanizmy architektury. Część projektu klas dzieli się z kolei na projekt statyczny (szczegółowo określający poszczególne klasy, ich powiązania i charakterystyki) oraz projekt dynamiczny (określający, jak te klasy ze sobą współpracują). Część mechanizmów architektury zawiera informacje na temat implementacji przechowywania obiektów, rozproszonego systemu obiektów, konkurencji pomiędzy elementami, itd. W następnej części rozdziału skupimy się na aspekcie projektowania klas; zaś do projektowania mechanizmów architektury wykorzystamy wiadomości zawarte w następnych rozdziałach tej książki.
26
Czym są klasy? Jako programista C++, przywykłeś do tworzenia klas. Metodologia projektowania wymaga operowania klasami C++ poprzez klasy projektu, mimo, iż są one dość ściśle powiązane. Klasa C++ zapisana w kodzie programu stanowi implementację klasy zaprojektowanej. Każda klasa stworzona w kodzie będzie stanowić odzwierciedlenie klasy w projekcie, ale nie należy mylić jednej z drugą. Oczywiście, klasy projektu można zaimplementować także w innym języku, jednak składnia definicji klasy może być inna. Z tego powodu przez większość czasu będziemy mówić o klasach bez dokonywania takiego rozróżnienia, gdyż różnice między nimi są zbyt abstrakcyjne. Gdy mówimy, że w naszym modelu klasa Cat posiada metodę Meow(), naszym zdaniem oznacza to, że metodę Meow() umieścimy także w naszej klasie C++. Klasy modelu przedstawia się w postaci diagramów UML, zaś klasy C++ jako kod, który może zostać skompilowany. Rozróżnienie, choć subtelne, jest jednak istotne. Największym wyzwaniem dla wielu nowicjuszy jest określenie początkowego zestawu klas i zrozumienie, z czego składa się dobrze zaprojektowana klasa. Jedną z technik jest wypisanie scenariuszy przypadków użycia, a następnie stworzenie osobnej klasy dla każdego rzeczownika. Spójrzmy na poniższy scenariusz przypadku użycia: Klient decyduje się na wypłatę gotówki z rachunku osobistego. Na rachunku znajduje się wystarczająca ilość środków, w bankomacie jest wystarczająca ilość gotówki i papieru, działa także sieć. Bankomat prosi klienta o podanie kwoty wypłaty, zaś klient prosi o wypłatę trzystu dolarów, co w tym momencie jest możliwe. Maszyna wydaje trzysta dolarów i drukuje kwit, po czym klient bierze pieniądze i odbiera kwit. Z tego scenariusza możesz wybrać następujące klasy: - klient - gotówka - rachunek bieżący - rachunek
27
- kwity - bankomat - sieć - kwota - wypłata - maszyna - pieniądze Możesz następnie usunąć z listy synonimy, po czym stworzyć klasy dla każdego z następujących rzeczowników: - klient - gotówka (pieniądze, kwota, wypłata) - rachunek bieżący - rachunek - kwity - bankomat (maszyna) - sieć Jak na razie, to niezły początek. Możesz następnie przedstawić na diagramie relacje pomiędzy niektórymi z tych klas (patrz rysunek 11.12). Rysunek 11.12. Wstępnie zdefiniowane klasy
28
Przekształcenia Proces, który zaczął się w poprzednim podrozdziale, jest nie tyle wybieraniem rzeczowników ze scenariusza, ile początkiem przekształcania obiektów z analizy dziedziny w obiekty projektowe. To ważny, pierwszy krok. Wiele obiektów dziedziny będzie posiadało w projekcie reprezentacje. Obiekt jest nazywany reprezentacją w celu odróżnienia, na przykład, rzeczywistego papierowego kwitu wydawanego przez bankomat od obiektu w projekcie, który jest jedynie zaimplementowaną w kodzie abstrakcją. Najprawdopodobniej odkryjesz, że większość obiektów dziedziny posiada izomorficzną reprezentację w projekcie – tj. pomiędzy obiektem dziedziny a obiektem projektu istnieje relacja „jeden do jednego”. Zdarza się jednak, że pojedynczy obiekt dziedziny jest reprezentowany w projekcie przez całą serię obiektów. Kiedy indziej seria obiektów dziedziny może być reprezentowana przez pojedynczy obiekt projektowy. Zwróć uwagę, że w rysunku 11.12 już zauważyliśmy fakt, iż RachunekBieŜący jest specjalizacją Rachunku. Nie przygotowywaliśmy się do wyszukiwania relacji generalizacji, ale ta była tak oczywista, że od razu ją zauważyliśmy. Z analizy dziedziny wiemy, że Bankomat wydaje Gotówkę i Kwity, więc natychmiast wyszukaliśmy tę informację w projekcie.
29
Relacja pomiędzy Klientem a RachunkiemBieŜacym jest już mniej oczywista. Wiemy, że taka relacja istnieje, ale ponieważ jej szczegóły nie są oczywiste, na razie nie będziemy się nią zajmować.
Inne przekształcenia Gdy przekształcimy już obiekty dziedziny, możemy zacząć szukać innych użytecznych obiektów projektowych. Mogą być nimi np. interfejsy. Każdy interfejs pomiędzy nowym systemem a systemami już istniejącymi, powinien zostać ujęty w klasie interfejsu. Jeśli współpracujesz z bazą danych (obojętne, jakiego rodzaju), baza ta także jest dobrym kandydatem na klasę interfejsu. Klasy interfejsów umożliwiają ukrycie szczegółów interfejsu i w ten sposób chronią nas przed zmianami w innych systemach. Klasy interfejsów pozwalają na zmianę własnego projektu lub dostosowywanie się do zmian w projekcie innych systemów, bez zmian w pozostałej części kodu. Dopóki dwa systemy współpracują ze sobą poprzez uzgodniony interfejs, mogą się zmieniać niezależnie od siebie.
Manipulowanie danymi Gdy stworzysz klasy dla manipulowania danymi, a musisz przekształcać dane z formatu do formatu (na przykład ze skali Celsjusza do Fahrenheita lub z systemu angielskiego na metryczny), możesz ukryć szczegóły takiej transformacji w klasie. Możesz użyć tej techniki, przekazując dane w żądanym formacie do innego systemu lub transmitując je poprzez Internet. Gdy musisz manipulować danymi w określonym formacie, możesz ukryć szczegóły protokołu w klasie manipulowania danymi.
Widoki Każdy „widok” lub „raport” generowany przez system (lub w przypadku, gdy generujesz wiele raportów, każdy zestaw raportów) jest kandydatem na klasę. Reguły tworzenia raportu – sposób gromadzenia informacji i ich przedstawiania – można ukryć wewnątrz klasy.
30
Urządzenia Gdy twój system współpracuje z urządzeniami (takimi jak drukarki, modemy, skanery, itd.) lub operuje nimi, specyfika protokołu komunikacji z urządzeniem także powinna zostać ukryta w klasie. Także w tym przypadku, przez stworzenie klas dla interfejsu urządzenia, możesz podłączać nowe urządzenia z nowymi protokołami, nie naruszając przy tym żadnych pozostałych części swojego kodu; po prostu tworzysz nową klasę interfejsu, obsługującą ten sam (lub wyprowadzony) interfejs. I gotowe!
Model statyczny Gdy określisz już wstępny zestaw klas, pora rozpocząć modelowanie powiązań i interakcji pomiędzy nimi. W tym rozdziale najpierw opiszemy model statyczny, a dopiero potem model dynamiczny. W rzeczywistym procesie projektowania będziesz swobodnie przechodził pomiędzy tymi modelami, dodając nowe klasy i, szkicując je w miarę postępu prac. Model statyczny skupia się na trzech obszarach: odpowiedzialności, atrybutach i powiązaniach. Najważniejszy z nich – i na nim skupisz się najpierw – jest zestaw odpowiedzialności dla każdej z klas. Najważniejszą wytyczną będzie teraz: Każda klasa powinna być odpowiedzialna za jedną rzecz. Nie chcę przez to powiedzieć, że każda klasa ma tylko jedną metodę; wiele klas będzie miało tuziny metod. Jednak wszystkie te metody muszą być spójne i wzajemnie do siebie przystające; tj. wszystkie muszą być ze sobą powiązane i zapewniać klasie zdolność osiągnięcia określonego obszaru odpowiedzialności. W dobrze zaprojektowanym systemie, każdy obiekt jest egzemplarzem dobrze zdefiniowanej i dobrze zrozumianej klasy, odpowiedzialnej za określony obszar. Klasy zwykle delegują zewnętrzne odpowiedzialności na inne, powiązane z nimi klasy. Dzięki stworzeniu klas, które zajmują się tylko jednym obszarem, umożliwiasz tworzenie kodu łatwego do konserwacji i rozbudowy. Aby określić obszar odpowiedzialności projektowanie od użycia kart CRC.
swoich
klas,
możesz
zacząć
Karty CRC CRC oznacza Class, Responsibility i Collaboration (klasa, odpowiedzialność, współpraca). Karta CRC jest tylko zwykłą kartką z notatnika. To proste
31
urządzenie umożliwia nawiązanie współpracy z innymi osobami w celu określenia podstawowych odpowiedzialności dla początkowego zestawu klas. W tym celu ułóż na stole stos pustych kart CRC, a przy stole zorganizuj serię sesji CRC.
W jaki sposób przeprowadzać sesję CRC Każda sesja CRC powinna odbywać się w grupie od trzech do sześciu osób; przy większej ich ilości staje się nieefektywna. Powinieneś wyznaczyć koordynatora, którego zadaniem będzie zapewnienie właściwego przebiegu sesji i pomaganie jej uczestnikom w zidentyfikowaniu najważniejszych zagadnień. Powinien być obecny co najmniej jeden doświadczony architekt oprogramowania, najlepiej ktoś z dużym doświadczeniem w obiektowo zorientowanej analizie i projektowaniu. Oprócz tego, w sesji powinien wziąć udział co najmniej jeden ekspert w danej dziedzinie, rozumiejący wymagania systemu i mogący udzielić fachowej porady na temat działania systemu. Najważniejszym elementem sesji CRC jest nieobecność menedżerów. Sprawia ona, że sesja jest kreatywnym, swobodnie toczącym się spotkaniem, na przebieg którego nie może mieć wpływu chęć zrobienia wrażenia na czyimś szefie. Celem jej jest eksperyment, podjęcie ryzyka, odkrycie wymagań klas oraz zrozumienie, w jaki sposób mogą one ze sobą współpracować. Sesję CRC rozpoczyna się od zebrania grupy przy stole, na którym znajduje się niewielki stos kartek. Na górze każdej karty CRC wypisuje się nazwę pojedynczej klasy. Poniżej narysuj pionową linię biegnącą w poprzez kartki, następnie opisz rubrykę po lewej stronie jako Odpowiedzialności, zaś po prawej stronie jako Współpraca. Zacznij od wypełnienia kart dla najważniejszych zidentyfikowanych dotąd klas. Na odwrocie każdej karty zapisz jedno lub dwuzdaniową definicję. Możesz także wskazać, jaką klasę specjalizuje dana klasa (o ile jest to wiadome w czasie posługiwania się kartą CRC). Poniżej nazwy klasy napisz po prostu Superklasa: oraz nazwę klasy, od której ta klasa pochodzi.
Skup się na odpowiedzialnościach Celem sesji CRC jest zidentyfikowanie odpowiedzialności każdej z klas. Nie zwracaj większej uwagi na atrybuty, wychwytuj tylko te najważniejsze. Najważniejszym zadaniem jest zidentyfikowanie odpowiedzialności. Jeśli w celu
32
wypełnienia odpowiedzialności klasa musi delegować pracę na inną klasę, zapisz tę informację w rubryce Współpraca. W miarę postępu prac zwracaj uwagę na listę odpowiedzialności. Gdy na karcie CRC zabraknie miejsca, zadaj sobie pytanie, czy nie żądasz od klasy zbyt wiele. Pamiętaj, każda klasa powinna być odpowiedzialna za jeden ogólny obszar pracy, zaś wymienione na karcie odpowiedzialności powinny być spójne i przystające – tj. powinny współgrać ze sobą w celu zapewnienia ogólnej odpowiedzialności klasy. Nie powinieneś teraz skupiać się na powiązaniach ani na interfejsie klasy lub na tym, która metoda będzie publiczna, a która prywatna. Postaraj się jedynie zrozumieć, co robi każda z klas.
Antropomorfizacja i ukierunkowanie na przypadki użycia Kluczową cechą kart CRC jest ich antropomorfizacja – tj. przypisywanie każdej z klas ludzkich atrybutów. Oto sposób jej działania: gdy masz już wstępny zestaw klas, wróć do scenariuszy użycia. Rozdziel karty wśród uczestników sesji i razem prześledźcie scenariusz. Na przykład, zastanówmy się nad następującym scenariuszem: Klient decyduje się na wypłatę gotówki z rachunku osobistego. Na rachunku znajduje się wystarczająca ilość środków, w bankomacie jest wystarczająca ilość gotówki i papieru, działa także sieć. Bankomat prosi klienta o podanie kwoty wypłaty, zaś klient prosi o wypłatę trzystu dolarów, co jest w tym momencie możliwe. Maszyna wydaje trzysta dolarów i drukuje kwit, klient odbiera pieniądze i kwit. Załóżmy, że w sesji uczestniczy pięć osób: Amy, koordynator i projektant obiektowo zorientowanego oprogramowania; Barry, główny programista; Charlie, klient; Dorris, ekspert w danej dziedzinie oraz Ed, programista. Amy trzyma kartę CRC reprezentującą RachunekBieŜący i mówi: „Mówię klientowi, ile pieniędzy jest dostępnych. Klient prosi mnie o wypłacenie trzystu dolarów. Wysyłam do dystrybutora polecenie wypłacenia trzystu dolarów w gotówce.” Barry podnosi swoją kartę i mówi: „Jestem dystrybutorem; wydaję trzysta dolarów i wysyłam do Amy komunikat nakazujący jej zmniejszenie stanu rachunku o trzysta dolarów. Komu mam powiedzieć, że maszyna zawiera teraz o trzysta dolarów mniej? Czy też ja to śledzę?” Charlie odpowiada: „Myślę, że
33
potrzebujemy obiektu do śledzenia ilości gotówki w maszynie.” Ed mówi: „Nie, dystrybutor powinien wiedzieć, ile ma gotówki; to należy do jego zadań.” Amy nie zgadza się z tym i mówi: „Nie, ktoś powinien koordynować wydawanie pieniędzy. Dystrybutor musi wiedzieć, czy gotówka jest dostępna i czy klient posiada wystarczającą ilość środków na koncie, powinien wypłacić pieniądze i w odpowiednim momencie zamknąć szufladę. Powinien delegować na kogoś innego odpowiedzialność za śledzenie ilości dostępnej gotówki – na pewien rodzaj wewnętrznego rachunku. osoba, która zna ilość dostępnej gotówki, może także poinformować biuro o tym, że zasoby bankomatu powinny zostać uzupełnione. W przeciwnym razie dystrybutor miałby zbyt wiele zadań.” Dyskusja trwa dalej. Trzymając karty i współpracując z innymi, odkrywa się wymagania i możliwości delegacji; każda klasa „ożywa” i „odkrywa” swoje odpowiedzialności. Gdy grupa zbytnio zagłębi się w projekt, koordynator może podjąć decyzję o przejściu do następnego zagadnienia.
Ograniczenia kart CRC Choć karty CRC mogą stanowić dobre narzędzie dla rozpoczęcia projektowania, posiadają one duże ograniczenia. Podstawowym problemem jest to, że nie zapewniają dobrego skalowania. W bardzo skomplikowanym projekcie posługiwanie się kartami CRC może być trudne. Karty CRC nie odzwierciedlają także wzajemnych relacji pomiędzy klasami. Choć można zapisać na nich zakres współpracy, jednak nie da się nimi wymodelować tej współpracy. Patrząc na kartę CRC, nie jesteś w stanie powiedzieć, czy klasa agreguje inną klasę, kto kogo tworzy itd. Karty CRC nie wychwytują także atrybutów, więc trudno jest z nich przejść bezpośrednio do kodu. Karty CRC są statyczne; choć możesz za ich pomocą ustalić interakcje między klasami, same karty CRC nie wychwytują tej informacji. Karty CRC są dobre na początek, ale jeśli chcesz stworzyć stabilny i kompletny model swojego projektu, powinieneś przedstawić klasy w języku UML. Choć przejście do UML nie jest zbyt trudne, jest jednak operacją jednokierunkową. Gdy przeniesiesz swoje klasy do diagramów UML, nie będzie już odwrotu; nie wrócisz do kart CRC. Po prostu synchronizacji obu modeli jest zbyt trudna.
Przekształcanie kart CRC na UML Każda karta CRC może zostać przekształcona bezpośrednio w klasę wymodelowaną w UML. Odpowiedzialności są przekształcane na metody klasy,
34
ewentualnie dodawane są także wychwycone atrybuty. Definicja klasy z odwrotnej strony karty jest umieszczana w dokumentacji klasy. Rysunek 11.13 przedstawia relację pomiędzy kartą CRC RachunekBieŜący, a stworzoną na podstawie tej karty klasą UML. Rys. 11.13. Karta CRC
Klasa: RachunekBieżący Superklasa: Rachunek Odpowiedzialności: śledzenie stanu bieżącego przyjmowanie depozytów i transfery na rachunek wypisywanie czeków transfery z rachunku śledzenie dziennego limitu wypłaty gotówki z bankomatu Współpraca: inne rachunki system bankowy dystrybutor gotówki
35
Relacje pomiędzy klasami Gdy klasy zostaną już przedstawione w UML, możesz zwrócić uwagę na relacje pomiędzy różnymi klasami. Podstawowe modelowane relacje to: - generalizacja - powiązania - agregacja - kompozycja Relacja generalizacji jest w C++ implementowana poprzez publiczne dziedziczenie. Pamiętając jednak że najważniejszy jest projekt, nie skupimy się mechanizmie działania relacji, ale na semantyce: co z takiej relacji wynika. Relacje sprawdziliśmy już w fazie analizy, teraz skupimy się nie tylko na obiektach dziedziny, ale także na obiektach w naszym projekcie. Naszym zadaniem jest określenie „wspólnej funkcjonalności” w powiązanych ze sobą klasach i wydzielenie z nich klas bazowych, obejmujących te wspólne właściwości. Gdy określisz „wspólną funkcjonalność”, powinieneś przenieść ją z klas specjalizowanych do klasy bardziej ogólnej. Gdy zauważymy, że oba rachunki, bieżący i lokaty, potrzebują metod do transferu pieniędzy do i z rachunku, metodę TransferŚrodków() przeniesiemy do klasy bazowej Rachunek. Im więcej funkcji przeniesiemy z klas potomnych, tym bardziej polimorficzny stanie się projekt. Jedną z możliwości dostępnych w C++, lecz niedostępnych w Javie, jest wielokrotne dziedziczenie (Java ma podobną, choć ograniczoną, możliwość posiadania wielu interfejsów). Wielokrotne dziedziczenie pozwala klasie na dziedziczenie po więcej niż jednej klasie bazowej, wprowadzając składowe i metody z dwóch lub więcej klas. Doświadczenie wykazało, że wielokrotne dziedziczenie powinno być używane rozważnie, gdyż może skomplikować zarówno projekt, jak i implementację. Wiele problemów rozwiązywanych dawniej poprzez wielokrotne dziedziczenie obecnie rozwiązuje się poprzez agregację. Należy jednak pamiętać, że wielokrotne dziedziczenie jest użytecznym narzędziem, zaś projekt może
36
wymagać, by pojedyncza klasa specjalizowała zachowanie dwóch lub więcej innych klas.
Wielokrotne dziedziczenie a zawieranie Czy obiekt jest sumą swoich części? Czy ma sens modelowanie obiektu Samochód jako specjalizacji Kierownicy, Drzwi i Kół, tak jak pokazano na rysunku 11.14? Rys. 11.14. Fałszywe dziedziczenie
Trzeba powrócić do źródeł: publiczne dziedziczenie powinno zawsze modelować generalizację. Ogólnie przyjętym wyrażeniem tej reguły jest stwierdzenie, że dziedziczenie powinno modelować relację jest czymś. Jeśli chcesz wymodelować relację posiada (na przykład samochód posiada kierownicę), powinieneś użyć agregacji, jak pokazano na rysunku 11.15. Rys. 11.15. Agregacja
37
Diagram z rysunku 11.15 wskazuje, że samochód posiada kierownicę, cztery koła oraz od dwóch do pięciu drzwi. Jest to właściwy model relacji pomiędzy samochodem a jego elementami. Zwróć uwagę, że romby na rysunku nie są wypełnione; rysujemy je w ten sposób, aby wskazać, że modelujemy agregację, a nie kompozycję. Kompozycja implikuje kontrolę czasu życia obiektu. Choć samochód posiada koła i drzwi, mogą one istnieć jako elementy samochodu, a także jako samodzielne obiekty. Rysunek 11.16 modeluje kompozycję. Ten model pokazuje, że ciało jest nie tylko agregacją głowy, dwóch rąk i dwóch nóg, ale także, że obiekty te (głowa, ręce, nogi) są tworzone w momencie tworzenia ciała i znikają w chwili, gdy znika ciało. Nie mogą istnieć niezależnie; ciało jest złożone z tych rzeczy, a czas ich istnienia jest powiązany. Rys. 11.16. Kompozycja
38
Cechy i główne typy Jak zaprojektować klasy potrzebne do zaprezentowania różnych linii modelowych typowego producenta samochodów? Przypuśćmy, że zostałeś wynajęty do zaprojektowania systemu dla Acme Motors, który aktualnie produkuje pięć modeli: Pluto (powolny, kompaktowy samochód z niewielkim silnikiem), Venus (czterodrzwiowy sedan ze średnim silnikiem), Mars (sportowe coupe z największym silnikiem, opracowanym w celu uzyskiwania rekordowych szybkości), Jupiter (minwan z takim samym silnikiem, jak w sportowym coupe, lecz z możliwością zmiany biegów przy niższych obrotach oraz dostosowaniemm do napędzania większej masy) oraz Earth (furgonetka z niewielkim silnikiem o wysokich obrotach). Możesz zacząć od stworzenia podtypów samochodów, odzwierciedlających różne modele, po czym tworzyć egzemplarze każdego z modelu w miarę ich schodzenia z linii montażowej, tak jak pokazano na rysunku 11.17. Rys. 11.17. Modelowanie podtypów
Czym różnią się te modele? Typem nadwozia oraz rozmiarem i charakterystykami silnika. Te elementy mogą być łączone i dopasowywane w celu stworzenia różnych modeli. Możemy to wymodelować w UML za pomocą stereotypu cecha, tak jak pokazano na rysunku 11.18. Rys. 11.18. Modelowanie cech
39
Diagram z rysunku 11.18 wskazuje, że klasy mogą być wyprowadzone z klasy „samochód” dzięki mieszaniu i dopasowaniu trzech atrybutów cech. Rozmiar silnika określa siłę pojazdu, zaś charakterystyka wydajności określa, czy samochód jest pojazdem sportowym, czy rodzinnym. Dzięki temu możemy stworzyć silną, sportową furgonetkę, słaby rodzinny sedan, itd. Każdy atrybut może być zaimplementowany za pomocą zwykłego wyliczenia. Typ nadwozia można zaimplementować w kodzie za pomocą poniższego wyliczenia: enum TypNadwozia = { sedan, coupe, minivan, furgonetka };
Może się jednak okazać, że pojedyncza wartość nie wystarcza do wymodelowania określonej cechy. Na przykład, charakterystyka wydajności może być raczej złożona. W takim przypadku cecha może zostać wymodelowana jako klasa, zaś określona cecha obiektu może istnieć jako konkretny egzemplarz tej klasy. Model samochodu może modelować charakterystykę wydajności, np. typ wydajność, zawierający informacje o tym, w którym momencie silnik zmienia bieg i jak wysokie obroty może osiągnąć. Stereotyp UML dla klasy obejmującej cechę — klasa ta może służyć do tworzenia egzemplarzy klasy (Samochód) należącej logicznie do innego typu (np. SamochódSportowy i SamochódLuksusowy) — to <>. W tym przypadku, klasa Wydajność jest typem głównym dla klasy Samochód. Gdy tworzymy egzemplarz klasy Samochód, jednocześnie tworzymy obiekt Wydajność, wiążąc go z danym obiektem Samochód, jak pokazano na rysunku 11.19.
40
Rys. 11.19. Cecha jako typ główny
Typy główne umożliwiają tworzenie różnorodnych typów logicznych, bez potrzeby używania dziedziczenia. Dzięki temu można obsłużyć duży i złożony zestaw typów, bez gwałtownego wzrostu złożoności klas, jaki mógłby nastąpić przy używaniu samego dziedziczenia. W C++ typy główne są najczęściej implementowane za pomocą wskaźników. W tym przypadku klasa Samochód zawiera wskaźnik do egzemplarza klasy CharakterystykaWydajności (patrz rysunek 11.20). Zamianę cech nadwozia i silnika w typy główne pozostawię ambitnym czytelnikom. Rys. 11.20. Relacja pomiędzy obiektem Samochód a jego typem głównym
41
Class Samochod : public Pojazd { public: Samochod(); ~Samochod(); // inne publiczne metody private: CharakterystykaWydajnosci * pWydajnosc; };
I jeszcze jedna uwaga. Typy główne umożliwiają tworzenie nowych typów (a nie tylko egzemplarzy) w czasie działania programu. Ponieważ typy logiczne różnią się od siebie jedynie atrybutami powiązanego z nim typu głównego, atrybuty te mogą być parametrami konstruktora typu głównego. Oznacza to, że w czasie działania programu możesz na bieżąco tworzyć nowe typy samochodów. Przekazując różne rozmiary silnika i różne punkty zmiany biegów do typu głównego, możesz efektywnie tworzyć nowe charakterystyki wydajności. Przypisując te charakterystyki różnym samochodom, możesz zwiększać zestaw typów samochodów w czasie działania programu.
Model dynamiczny Oprócz modelowania relacji pomiędzy klasami, bardzo ważne jest także wymodelowanie sposobu współpracy pomiędzy klasami. Na przykład, klasy RachunekBieŜący, Bankomat oraz Kwit mogą współpracować z klasą Klient, wypełniając przypadek użycia „Wypłata gotówki.” Wkrótce wrócimy do diagramów sekwencji używanych wcześniej w fazie analizy, ale tym razem, na podstawie metod opracowanych dla klas, wypełnimy je szczegółami, tak jak na rysunku 11.21. Rys. 11.21. Diagram sekwencji
42
Ten prosty diagram interakcji pokazuje współdziałanie pomiędzy klasami projektowymi oraz ich następstwo w czasie. Sugeruje, że klasa Bankomat deleguje na klasę RachunekBieŜący całą odpowiedzialność za zarządzanie stanem rachunku, zaś klasa RachunekBieŜący przenosi na klasę Bankomat zadanie wyświetlania informacji dla użytkownika. Diagramy interakcji występują w dwóch odmianach. Odmiana pokazana na rysunku 11.21 jest nazywana diagramem sekwencji. Diagramy współpracy dostarczają innego widoku tych samych informacji. Diagramy sekwencji kładą nacisk na kolejność zdarzeń, zaś diagramy współpracy obrazują współdziałanie pomiędzy klasami. Diagram współpracy można stworzyć bezpośrednio z diagramu sekwencji; programy takie jak Rational Rose potrafią stworzyć taki diagram po jednym kliknięciu na przycisku (patrz rysunek 11.22). Rys. 11.22. Diagram współpracy
43
Diagramy zmian stanu Przechodząc do zagadnienia interakcji pomiędzy obiektami, musimy poznać różne możliwe stany każdego z obiektów. Przejścia pomiędzy stanami możemy wymodelować na diagramie stanu (lub diagramie zmian stanów). Rysunek 12.23 przedstawia różne stany obiektu RachunekBieŜący w czasie, gdy klient jest zalogowany do systemu. Rys. 11.23. Stan rachunku klienta
44
Każdy diagram stanu rozpoczyna się od stanu start, a kończy na stanie koniec. Poszczególne stany posiadają nazwy, zaś zmiany stanów mogą być opisane za pomocą etykiet. StraŜnik wskazuje warunek, który musi być spełniony, aby obiekt mógł przejść ze stanu do stanu.
Superstany Klient może w każdej chwili zmienić zamiar i zrezygnować z logowania się. Może to uczynić po włożeniu karty w celu zidentyfikowania swojego rachunku lub już po wprowadzeniu kodu PIN. W obu przypadkach system musi zaakceptować jego żądanie anulowania operacji i powrócić do stanu „nie zalogowany” (patrz rysunek 11.24). Rys. 11.24. Użytkownik może zrezygnować
45
Jak widać, w bardziej skomplikowanych diagramach, stan Anulowany szybko zaczyna przeszkadzać. Jest to szczególnie irytujące, gdyż anulowanie jest stanem wyjątkowym, który nie powinien dominować w diagramie. Możemy uprościć ten diagram, używając superstanu, tak jak pokazano na rysunku 11.25. Rys. 11.25. Superstan
46
Diagram z rysunku 11.25 dostarcza takich samych informacji, jak diagram z rysunku 11.24, lecz jest dużo bardziej przejrzysty i łatwiejszy do odczytania. Od momentu rozpoczęcia logowania, aż do chwili jego zakończenia przez system, możesz ten proces anulować. Gdy to uczynisz, powrócisz do stanu „Nie zalogowany.”
47
Rozdział 12. Dziedziczenie W poprzednim rozdziale poznałeś wiele zagadnieńrelacji związanych z projektowaniem obiektowym, włącznie m.in. ze relacjąę specjalizacji/ą i generalizacjią. Język C++ implementuje jąe poprzez dziedziczenie. W Z tymego rozdzialełu dowiesz się: •
Cczym jest dziedziczenie,.
•
Ww jaki sposób wyprowadzać klasę z innej klasy,.
•
Cczym jest chroniony dostęp chroniony i jak z niego korzystać,.
•
Cczym są funkcje wirtualne.
Czym jest dziedziczenie? Czym jest pies? Co widzisz, gdy patrzysz na swoje zwierzę? Ja widzę cztery łapy na służbiei pyska. Biolodzy widzą sieć interesujących organów, fizycy — atomy i działające różnorodne siły, zaś taksonom widzi przedstawiciela gatunku canine domesticus. Właśnie ten ostatni przypadek interesuje nas w tym momencieSkupmy się na tym ostatnim przypadku. Pies jest przedstawicielem psowatych, psowate są przedstawicielami ssaków, i tak dalej. Taksonomowie dzielą świat żywych stworzeń na królestwa, typy, klasy, rzędy, rodziny, rodzaje i gatunki. Hierarchia specjalizacji/-generalizacji ustanawia relację typu jest-czymś. Homo sapiens jest przedstawicielem naczelnych. Taką relację widzimy wszędzie wokół: wóz kempingowy jest rodzajem samochodu, który z kolei jest rodzajem pojazdu. Budyń jest rodzajem deseru, który jest rodzajem pożywienia. Gdy mówimy, że coś jest rodzajem czegoś innego, zakładamym że stanowi to specjalizację tej rzeczy. Tak więc sSamochód jest zatemspecjalnym rodzajem pojazdu.
Dziedziczenie i wyprowadzanie Pies dziedziczy — to jest, automatycznie otrzymuje — wszystkie cechy ssaka. Ponieważ jest ssakiem, wiemy że porusza się oraz i oddycha powietrzem. Wszystkie ssaki, z definicji, poruszają się i oddychają. Pies wzbogaca te elementy o cechy takie jak, szczekanie, machanie ogonem, zjadanie dopiero co ukończonego rozdziału mojej książki, warczenie, gdy próbuję zasnąć... Przepraszam. Gdzie skończyłem? A, wiem: Psy możemy podzielić na psy przeznaczone do pracy, psy do sportów i teriery, zaś psy do sportów możemy podzielić na psy myśliwskie, spaniele i tak dalej. Można także dokonywać dalszego podziału, na przykład psy myśliwskie można podzielić na Llabradory czy Ggoldeny. Golden jest rodzajem psa myśliwskiego, będącego który jest psem do sportów, należącymego do rodzaju psów, czyli będącego ssakiem, a więc zwierzęciem, czyli rzeczą żywą. Tę hierarchię przedstawia rysunek 12.1.
Rys. 12.1. Hierarchia zwierząt
C++ próbuje reprezentować te relacje, umożliwiając nam definiowanie klas, które są wyprowadzane z innych klas. Wyprowadzanie jest sposobem wyrażenia relacji typu jest-czymś. Nową klasę, Dog (pies), można wyprowadzić z klasy Mammal (ssak). Nie musimy wtedy wyraźnie mówić, że pies porusza się, gdyż dziedziczy tę cechę od ssaków. Klasa dodająca nowe właściwości do istniejącej już klasy jest wyprowadzona z klasy oryginalnejpierwotnej. Ta oryginalna pierwotna klasa jest nazywana klasą bazową.
Jeśli klasa Dog jest wyprowadzona z klasy Mammal, oznacza to, że klasa Mammal jest klasą bazową (nadrzędną) klasy Dog. Klasy wyprowadzone (pochodne) stanową nadzbiór swoich klas bazowych. Tak jak pPies przejmuje swoje cechy od ssaków, tak klasa Dog przejmie pewne metody lub dane klasy Mammal. Zwykle klasa bazowa posiada więcej niż jedną klasę pochodną. Ponieważ zarówno psy, jak i koty oraz konie są ssakami, więc ich klasy zostały były wyprowadzone z klasy Mammal.
Królestwo zwierząt Aby ułatwić przedstawienie procesu dziedziczenia i wyprowadzania, w tym rozdziale skupimy się na związkach pomiędzy różnymi klasami reprezentującymi zwierzęta. Możesz sobie wyobrazić, że bawimy się w dziecięcą grę — symulację farmy. Z czasem oOpracujemy cały zestaw zwierząt, obejmujący konie, krowy, psy, koty, owce, itd. Stworzymy metody dla tych klas, tak aby zwierzęta mogły funkcjonować tak, jak oczekiwałoby tego dziecko, ale na razie każdą z tych metod zastąpimy zwykłą instrukcją wydruku. ZastępowanieZminimalizowanieMinimalizowanie funkcji (czyli pozostawienie tylko jej pniaszkieletu) oznacza, że napiszemy tylko tyle kodu, ile wystarczy do aby pokazaćnia, że funkcja została wywołana., pozostawiając sSzczegóły pozostawimy na później, gdy będziemy mieć więcej czasu. Jeśli tylko masz ochotę, możesz wzbogacić minimalny kod zaprezentowany w tym rozdziale i, sprawiającć, by zwierzęta zachowywały się bardziej realistycznie.
Składnia wyprowadzania Gdy deklarujesz klasę, możesz wskazać klasę, od której pochodzi, zapisując po nazwie tworzonej klasy dwukropek, rodzaj wyprowadzania (publiczny lub inny) oraz klasę bazową. Oto przykład: class Dog : public Mammal
Rodzaj wyprowadzania omówimy opiszemy w dalszej części rozdziału. Na razie zawsze będziemy używać wyprowadzania publicznego (oznaczonego słowem kluczowym public). Klasa bazowa musi być wcześniej zdefiniowana wcześniej, gdyż w przeciwnym razie kompilator zgłosi błąd. Listing 12.1 ilustruje sposób deklarowania klasy Dog , wyprowadzonej z klasy Mammal. Listing 12.1. Proste dziedziczenie 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12:
//Listing 12.1 Proste dziedziczenie #include using namespace std; enum BREED { GOLDEN, CAIRN, DANDIE, SHETLAND, DOBERMAN, LAB }; class Mammal { public: // konstruktory Mammal(); ~Mammal();
13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48:
//akcesory int GetAge() const; void SetAge(int); int GetWeight() const; void SetWeight(); //inne metody void Speak() const; void Sleep() const;
protected: int itsAge; int itsWeight; }; class Dog : public Mammal { public: // konstruktory Dog(); ~Dog(); // akcesory BREED GetBreed() const; void SetBreed(BREED); // inne metody WagTail(); BegForFood(); protected: BREED itsBreed; };
Ten kod nie wyświetla wyników, gdyż zawiera jedynie zestaw deklaracji klas (bez ich implementacji). Mimo to jest można w nim wiele do zobaczeniayć. Analiza: W liniach od 7. do 28. deklarowana jest klasa Mammal (ssak). Zwróć uwagę, że w tym przykładzie klasa Mammal nie jest wyprowadzana z żadnej innej klasy. W rzeczywistym realnym świecie ssaki pochodzą od (to znaczy, że są rodzajem) zwierząt. W programie C++ możesz zaprezentować jedynie część informacji, które posiadasz na temat danego obiektu. Rzeczywistość jest zdecydowanie zbyt skomplikowana, aby uchwycić ją w całości, więc każda hierarchia w C++ jest umowną reprezentacją dostępnych danych. Sztuka dobrego projektowania polega na reprezentowaniu interesujących nas obszarów tak, aby reprezentowały rzeczywistość w wystarczająco dobrymożliwie najlepszy sposób reprezentowały rzeczywistość. Hierarchia musi się gdzieś zaczynać; w tym programie rozpoczyna się od klasy Mammal. Z powodu tej decyzjigo, pewne zmienne składowe, które mogły należeć do wyższej klasy, nie są tu reprezentowane. Wszystkie zwierzęta z pewnością posiadają na przykład wagę i wiek, więc jeśli klasa Mammal byłaby wyprowadzona z klasy Animal (zwierzę), moglibyśmy oczekiwać, że
dziedziczy te atrybuty. Jednak w naszym przykładzie atrybuty te atrybuty występują w klasie Mammal. Aby utrzymać zachować niewielką i spójną postać programu, w klasie Mammal zostało umieszczonych jedynie sześć metod — cztery akcesory oraz metody Speak() (mówienie) i Sleep() (spanie). Klasa Dog (pies) dziedziczy po klasie Mammal, co wskazuje linia 30. Każdy obiekt typu Dog będzie posiadał trzy zmienne składowe: itsAge (wiek), itsWeight (waga) oraz itsBread (rasa). Zwróć uwagę, że deklaracja klasy Dog nie obejmuje zmiennych składowych itsAge oraz itsWeight. Obiekty klasy Dog dziedziczą te zmienne od klasy Mammal, razem z metodami tej klasy (z wyjątkiem operatora kopiującegoi oraz destruktora i konstruktora).
Prywatne kontra chronione Być może w liniach 25. i 46. listingu 12.1 zauważyłeś nowe słowo kluczowe dostępu, protected (chronione). Wcześniej dane klasy były deklarowane jako prywatne. Jednak prywatne składowe nie są dostępne dla klas pochodnych. Moglibyśmy uczynić zmienne itsAge i itsWeight składowymi publicznymi, ale nie byłoby to pożądane. Nie chcemy, by inne klasy mogły bezpośrednio odwoływać się do tych danych składowych. UWAGA Istnieje argument powód, by wszystkie dane składowe klasy oznaczać jako prywatne, ia nigdye jako chronione. Argument Powód ten przedstawił Stroustrup (twórca języka C++) w swojej książce The Design and Evolution of C++, ISBN 0-201-543330-3, Addison Wesley, 1994. Metody Cchronione metody nie są jednak uważane za problematyczne kłopotliwe i mogą być bardzo użyteczne.
To, czego pPotrzebujemy, teraz to oznaczeniea, które mówi: „Uczyń te zmienne widocznymi dla tej klasy i dla klasy z niej wyprowadzonych”. Takim oznaczeniem jest właśnie słowo kluczowe protected — chroniony. Chronione funkcje i dane składowe są w pełni widoczne dla klas pochodnych i nie są dostępne dla innych klas. Ogólnie, iIstnieją trzy specyfikatory dostępu: publiczny (public), chroniony (protected) oraz private (prywatny). Jeśli funkcja posiada obiekt twojej klasy, może odwoływać się do wszystkich jej publicznych funkcji i danych składowych. Z kolei funkcje składowe klasy mogą odwoływać się do wszystkich prywatnych funkcji i danych składowych swojej własnej klasy, oraz do wszystkich chronionych funkcji i danych składowych wszystkich klas, z których ich klasa jest wyprowadzona. Tak więc, funkcja Dog::WagTail() (macha ogonem) może odwoływać się do prywatnej danej itsBreaed oraz do prywatnych danych klasy Mammal. Nawet jeśli gdyby pomiędzy klasą Mammal , a klasą Dog występowałyby inne klasy (na przykład DomesticAnimals — zwierzęta domowe), klasa Dog w dalszym ciągu mogłaby się odwoływać do prywatnych składowych klasy Mammal, zakładając że wszystkie te inne klasy używałyby dziedziczenia publicznego. Dziedziczenie prywatne zostanie omówione w rozdziale 16., „Dziedziczenie Zzaawansowane dziedziczenie”.
Listing 12.2 przedstawia sposób tworzenia obiektów typu Dog oraz dostępu do do danych i funkcji zawartych w tym typie. Listing 12.2. Użycie klasy wyprowadzonej klasy 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52: 53: 54: 55:
//Listing 12.2 UŜycie klasy wyprowadzonej klasy
#include using std::cout; enum BREED { GOLDEN, CAIRN, DANDIE, SHETLAND, DOBERMAN, LAB }; class Mammal { public: // konstruktory Mammal():itsAge(2), itsWeight(5){} ~Mammal(){} //akcesory int GetAge() const { return itsAge; } void SetAge(int age) { itsAge = age; } int GetWeight() const { return itsWeight; } void SetWeight(int weight) { itsWeight = weight; } //inne metody void Speak()const { cout << "Dzwiek ssaka!\n"; } void Sleep()const { cout << "Ciiiicho. Wlasnie spie.\n"; }
protected: int itsAge; int itsWeight; }; class Dog : public Mammal { public: // konstruktory Dog():itsBreed(GOLDEN){} ~Dog(){} // akcesory BREED GetBreed() const { return itsBreed; } void SetBreed(BREED breed) { itsBreed = breed; } // inne metody void WagTail() const { cout << "Macham ogonem...\n"; } void BegForFood() const {cout << "Prosze o jedzenie...\n"; } private: BREED itsBreed; }; int main() { Dog fido; fido.Speak(); fido.WagTail(); cout << "Fido ma " << fido.GetAge() << " lat(a)\n";
56: 57:
return 0; }
Wynik: Dzwiek ssaka! Macham ogonem... Fido ma 2 lat(a)
Analiza: W liniach od 7. do 28. jest deklarowana klasa Mammal (wszystkie jej funkcje są funkcjami są typu inline w — w celu zaoszczędzenia miejsca na wydruku). W liniach od 30. do 48. deklarowana jest klasa Dog, będąca klasą pochodną klasy Mammal. Tak więcZatem, z definicji, wszystkie obiekty typu Dog posiadają wiek, wagę i rasę. W linii 52. deklarowany jest obiekt typu Dog o nazwie fido. Obiekt fido dziedziczy wszystkie atrybuty klasy Mammal oraz posiada własne atrybuty klasy Dog. Tak więc fido potrafi machać ogonem (metoda WagTail()), ale potrafi także wydawać dźwięki (Speak()) oraz spać (Sleep()).
Konstruktory i destruktory Obiekty klasy Dog są obiektami klasy Mammal. Taka jest esencjaNa tym właśnie polega relacjia jest-czymś. Gdy tworzony jest obiekt fido, najpierw wywoływany jest jego konstruktor bazowy, tworzący część klasę Mammal.-ową nowego obiektu. Następnie wywoływany jest konstruktor klasy Dog, uzupełniający tworzenie obiektu klasy Dog. Ponieważ nie podaliśmy obiektowi żadnych parametrów, w tym przypadku jest wywoływany konstruktor domyślny. Obiekt fido nie istnieje do chwili całkowitego zakończenia tworzenia go, co oznacza, że musi zostać skonstruowana zarówno jego część Mammal, jak i część Dog., To znaczyczyli , muszą być wywołane oba konstruktory. Gdy obiekt fido jest niszczony, najpierw wywoływany jest destruktor klasy Dog, a dopiero potem destruktor klasy Mammal. Każdy destruktor ma okazję uporządkować własną część obiektu. Pamiętaj, aby posprzątać po swoim psie! Demonstruje to listing 12.3. Listing 12.3. Wywoływane konstruktory i destruktory 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14:
//Listing 12.3 Wywoływane konstruktory i destruktory. #include enum BREED { GOLDEN, CAIRN, DANDIE, SHETLAND, DOBERMAN, LAB }; class Mammal { public: // konstruktory Mammal(); ~Mammal(); // akcesory int GetAge() const { return itsAge; } void SetAge(int age) { itsAge = age; }
15: int GetWeight() const { return itsWeight; } 16: void SetWeight(int weight) { itsWeight = weight; } 17: 18: // inne metody 19: void Speak()const { std::cout << "Dzwiek ssaka!\n"; } 20: void Sleep()const { std::cout << "Ciiiicho. Wlasnie spie.\n"; } 21: 22: 23: protected: 24: int itsAge; 25: int itsWeight; 26: }; 27: 28: class Dog : public Mammal 29: { 30: public: 31: 32: // konstruktory 33: Dog(); 34: ~Dog(); 35: 36: // akcesory 37: BREED GetBreed() const { return itsBreed; } 38: void SetBreed(BREED breed) { itsBreed = breed; } 39: 40: // inne metody 41: void WagTail() const { std::cout << "Macham ogonkiem...\n"; } 42: void BegForFood() const { std::cout << "Prosze o jedzenie...\n"; } 43: 44: private: 45: BREED itsBreed; 46: }; 47: 48: Mammal::Mammal(): 49: itsAge(1), 50: itsWeight(5) 51: { 52: std::cout << "Konstruktor klasy Mammal...\n"; 53: } 54: 55: Mammal::~Mammal() 56: { 57: std::cout << "Destruktor klasy Mammal...\n"; 58: } 59: 60: Dog::Dog(): 61: itsBreed(GOLDEN) 62: { 63: std::cout << "Konstruktor klasy Dog...\n"; 64: } 65: 66: Dog::~Dog() 67: { 68: std::cout << "Destruktor klasy Dog...\n"; 69: } 70: int main() 71: { 72: Dog fido;
73: 74: 75: 76: 77:
fido.Speak(); fido.WagTail(); std::cout << "Fido ma " << fido.GetAge() << " lat(a)\n"; return 0; }
Wynik: Konstruktor klasy Mammal... Konstruktor klasy Dog... Dzwiek ssaka! Macham ogonkiem... Fido ma 1 lat(a) Destruktor klasy Dog... Destruktor klasy Mammal...
Analiza: Listing 12.3 jest podobny do listingu 12.2, z tym tą różnicą, że konstruktory i destruktory wypisują teraz komunikaty na ekranie. Wywoływany jest najpierw konstruktor klasy Mammal, następnie konstruktor klasy Dog. W tym momencie obiekt klasy Dog już w pełni istnieje i można wywoływać jego metody. Gdy fido wychodzi z zakresu, wywoływany jest jego destruktor klasy Dog, a następnie destruktor klasy Mammal.
Przekazywanie argumentów do konstruktorów bazowych Istnieje możliwośćMoże się zdarzyć, że zechcemy przeciążyć konstruktor klasy Mammal tak, aby przyjmował określony wiek, oraz że zechcemy przeciążyć konstruktor klasy Dog tak, aby przyjmował rasę. W jaki sposób możemy przesłać właściwe parametry wieku i wagi do odpowiedniego konstruktora klasy Mammal? Co zrobić, gdy obiekty klasy Dog chcą inicjalizować swoją wagę, a obiekty klasy Mammal nie? Inicjalizacja klasy bazowej może być wykonana podczas inicjalizacji klasy pochodnej, przez zapisanie nazwy klasy bazowej, po której następują parametry oczekiwane przez tę klasę. Demonstruje to listing 12.4. Listing 12.4. Przeciążone konstruktory w wyprowadzonych klasach 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15:
//Listing 12.4 PrzeciąŜone konstruktory w wyprowadzonych klasach #include using namespace std; enum BREED { GOLDEN, CAIRN, DANDIE, SHETLAND, DOBERMAN, LAB }; class Mammal { public: // konstruktory Mammal(); Mammal(int age); ~Mammal(); //akcesory
16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49: } 50: 51: 52: 53: 54: 55: 56: 57: 58: 59: 60: 61: 62: 63: 64: 65: 66: 67: 68: 69: 70: 71: 72: 73: 74: 75:
int GetAge() const { return itsAge; } void SetAge(int age) { itsAge = age; } int GetWeight() const { return itsWeight; } void SetWeight(int weight) { itsWeight = weight; } // inne metody void Speak()const { cout << "Dzwiek ssaka!\n"; } void Sleep()const { cout << "Ciiiicho. Wlasnie spie.\n"; }
protected: int itsAge; int itsWeight; }; class Dog : public Mammal { public: // konstruktory Dog(); Dog(int age); Dog(int age, int weight); Dog(int age, BREED breed); Dog(int age, int weight, BREED breed); ~Dog(); // akcesory BREED GetBreed() const { return itsBreed; } void SetBreed(BREED breed) { itsBreed = breed; } // inne metody void WagTail() const { cout << "Macham ogonem...\n"; } void BegForFood() const { cout << "Prosze o jedzenie...\n";
private: BREED itsBreed; }; Mammal::Mammal(): itsAge(1), itsWeight(5) { cout << "Konstruktor klasy Mammal...\n"; } Mammal::Mammal(int age): itsAge(age), itsWeight(5) { cout << "Konstruktor klasy Mammal(int)...\n"; } Mammal::~Mammal() { cout << "Destruktor klasy Mammal...\n"; } Dog::Dog(): Mammal(),
76: 77: 78: 79: 80: 81: 82: 83: 84: 85: 86: 87: 88: 89: 90: 91: 92: 93: 94: 95: 96: 97: 98: 99: 100: 101: 102: 103: 104: 105: 106: 107: 108: 109: 110: 111: 112: 113: 114: 115: 116: 117: 118: 119: 120: 121: 122: 123: 124: 125: 126: 127: 128:
itsBreed(GOLDEN) { cout << "Konstruktor klasy Dog...\n"; } Dog::Dog(int age): Mammal(age), itsBreed(GOLDEN) { cout << "Konstruktor klasy Dog(int)...\n"; } Dog::Dog(int age, int weight): Mammal(age), itsBreed(GOLDEN) { itsWeight = weight; cout << "Konstruktor klasy Dog(int, int)...\n"; } Dog::Dog(int age, int weight, BREED breed): Mammal(age), itsBreed(breed) { itsWeight = weight; cout << "Konstruktor klasy Dog(int, int, BREED)...\n"; } Dog::Dog(int age, BREED breed): Mammal(age), itsBreed(breed) { cout << "Konstruktor klasy Dog(int, BREED)...\n"; } Dog::~Dog() { cout << "Destruktor klasy Dog...\n"; } int main() { Dog fido; Dog rover(5); Dog buster(6,8); Dog yorkie (3,GOLDEN); Dog dobbie (4,20,DOBERMAN); fido.Speak(); rover.WagTail(); cout << "Yorkie ma " << yorkie.GetAge() << " lat(a)\n"; cout << "Dobbie wazy "; cout << dobbie.GetWeight() << " funtow\n"; return 0; }
UWAGA Linie wyniku zostały ponumerowane, tak, aby można się było do nich odwoływać w analizie.
Wynik: 1: 2:
Konstruktor klasy Mammal... Konstruktor klasy Dog...
3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24:
Konstruktor klasy Mammal(int)... Konstruktor klasy Dog(int)... Konstruktor klasy Mammal(int)... Konstruktor klasy Dog(int, int)... Konstruktor klasy Mammal(int)... Konstruktor klasy Dog(int, BREED)... Konstruktor klasy Mammal(int)... Konstruktor klasy Dog(int, int, BREED)... Dzwiek ssaka! Macham ogonem... Yorkie ma 3 lat(a) Dobbie wazy 20 funtow Destruktor klasy Dog... Destruktor klasy Mammal... Destruktor klasy Dog... Destruktor klasy Mammal... Destruktor klasy Dog... Destruktor klasy Mammal... Destruktor klasy Dog... Destruktor klasy Mammal... Destruktor klasy Dog... Destruktor klasy Mammal...
Analiza: Na listingu 12.4, w linii 12.1, konstruktor klasy Mammal został przeciążony tak, że przyjmuje wartość całkowitą, określającą wiek ssaka. Implementacja w liniach od 62. do 67. inicjalizuje składową itsAge wartością przekazaną do tego konstruktora, zaś składową itsWeight inicjalizuje za pomocą wartością 5. Klasa Dog posiada pięć przeciążonych konstruktorów, zadeklarowanych w liniach od 36. do 40. Pierwszym z nich jest konstruktor domyślny. Drugi konstruktor otrzymuje wiek, będący tym samym parametrem, co parametr konstruktora klasy Mammal. Trzeci konstruktor otrzymuje zarówno wiek, jak i wagę, czwarty otrzymuje wiek i rasę, zaś piąty otrzymuje wiek, wagę oraz rasę. Zwróć uwagę na linię 75., w której domyślny konstruktor klasy Dog wywołuje domyślny konstruktor klasy Mammal. Choć wywołanie to nie jest to bezwzględnie wymaganekonieczne, służy jako dokumentacjauje, że mieliśmy zamiar wywołać konstruktor bazowy, nie posiadający żadnych parametrów. Konstruktor bazowy zostałyby wywołany w każdym przypadku, ale w ten sposób wyraźniej przedstawiamy nasze intencje. Implementacja konstruktora klasy Dog , przyjmującego wartość całkowitą znajduje się w liniach od 81. do 86. W swojej fazie inicjalizacji (linie 82. i 83.), obiekt Dog inicjalizuje swoją klasę bazową, przekazując jej parametr, po czym inicjalizuje swoją rasę. Inny konstruktor klasy Dog jest zawarty w liniach od 88. do 94. Ten konstruktor otrzymuje dwa parametry. Także w tym przypadku inicjalizuje on swoją klasę bazową, wywołując jej odpowiedni konstruktor, lecz tym razem dodatkowo przypisuje wartość zmiennej itsWeight w klasie bazowej. Zauważ, że nie można tu przypisywać wartości zmiennym klasy bazowej w fazie inicjalizacji. Ponieważ klasa Mammal nie posiada konstruktora przyjmującego ten parametr, musimy uczynić to wewnątrz ciała konstruktora klasy Dog. Przejrzyj pozostałe konstruktory, aby upewnić się, że pojąłeś zrozumiałeś sposób ich działania. Zwróć uwagę, co jest inicjalizowane, a co musi poczekać na przejście do ciała konstruktora.
Linie wyników działania tego programu zostały ponumerowane tak, aby można się było odwoływać się do nich podczas analizy. Pierwsze dwie linie wyników reprezentują tworzenie obiektu fido, za użyciem pomocą domyślnego konstruktora. Linie 3. i 4. wyników reprezentują tworzenie obiektu rover. Linie 5. i 6. odnoszą się do obiektu buster. Zwróć uwagę, że wywoływany konstruktor klasy Mammal jest konstruktorem przyjmującym jedną wartość całkowitą, mimo iż wywoływanym konstruktorem klasy Dog jest konstruktor przyjmujący dwie wartości całkowite. Po stworzeniu wWszystkiche stworzone obiekty są zostają używane te w programie, po czym i wychodzą poza zakres. Podczas niszczenia każdego obiektu najpierw wywoływany jest destruktor klasy Dog, a następnie destruktor klasy Mammal (łącznie po pięć razy).
Przesłanianie funkcji Obiekt klasy Dog posiada dostęp do wszystkich funkcji składowych klasy Mammal, a także do wszystkich funkcji składowych, na przykład takich jak WagTail(), które mogłyby zostać dodane w przez deklaracjię klasy Dog. Może także przesłaniać funkcje klasy bazowej. Przesłonięcie (ang. override) funkcji oznacza zmianę implementacji funkcji klasy bazowej w klasie z niej wyprowadzonej. Gdy tworzysz obiekt klasy wyprowadzonej, wywoływana jest właściwa funkcja. Gdy klasa wyprowadzona tworzy funkcję o tym samym zwracanym typie i sygnaturze, co funkcja składowa w klasie bazowej, ale z inną implementacją, mówimy, że tafunkcja klasy bazowej została przesłonięta. Gdy przesłaniasz funkcję, jej sygnatura musi się zgadzać z sygnaturą tej funkcji w klasie bazowej. Sygnatura jest innym prototypem funkcji niż typ zwracany; zawiera nazwę, listę parametrów oraz słowo kluczowe const (o ile jest używane). Zwracane typy mogą się od siebie różnić. Listing 12.5 pokazuje, co się stanie, gdy w klasie Dog przesłonimy metodę Speak() klasy Mammal. Dla zaoszczędzenia miejsca, z tych klas zostały usunięte akcesory. Listing 12.5. Przesłanianie metod klasy bazowej w klasie potomnej 0: //Listing 12.5 Przesłanianie metod klasy bazowej w klasie potomnej 1: 2: #include 3: using std::cout; 4: 5: enum BREED { GOLDEN, CAIRN, DANDIE, SHETLAND, DOBERMAN, LAB }; 6: 7: class Mammal 8: { 9: public: 10: // konstruktory 11: Mammal() { cout << "Konstruktor klasy Mammal...\n"; } 12: ~Mammal() { cout << "Destruktor klasy Mammal...\n"; } 13: 14: // inne metody 15: void Speak()const { cout << "Dzwiek ssaka!\n"; } 16: void Sleep()const { cout << "Ciiiicho. Wlasnie spie.\n"; } 17: 18:
19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: } 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48:
protected: int itsAge; int itsWeight; }; class Dog : public Mammal { public: // konstruktory Dog(){ cout << "Konstruktor klasy Dog...\n"; } ~Dog(){ cout << "Destruktor klasy Dog...\n"; } // inne metody void WagTail() const { cout << "Macham ogonkiem...\n"; } void BegForFood() const { cout << "Prosze o jedzenie...\n"; void Speak() const { cout << "Hau!\n"; } private: BREED itsBreed; }; int main() { Mammal bigAnimal; Dog fido; bigAnimal.Speak(); fido.Speak(); return 0; }
Wynik Konstruktor klasy Mammal... Konstruktor klasy Mammal... Konstruktor klasy Dog... Dzwiek ssaka! Hau! Destruktor klasy Dog... Destruktor klasy Mammal... Destruktor klasy Mammal...
Analiza W linii 35., w klasaie Dog zostaje przesłania onięta metodęa Speak(), z klasy Mammal, co powoduje, że obiekty klasy Dog w momencie wywołania tej metody wypisują komunikat Hau!. W linii 43. tworzony jest obiekt klasy Mammal, o nazwie bigAnimal (duże zwierzę), powodując wypisanie pierwszej linii wyników (w efekcie wywołania konstruktora klasy Mammal). W linii 44. tworzony jest obiekt klasy Dog, o nazwie fido, powodując wypisanie dwóch następnych linii wyników (powstających w efekcie wywołania konstruktora klasy Mammal, a następnie konstruktora klasy Dog). W linii 45. obiekt klasy Mammal wywołuje swoją metodę Speak(), zaś w linii 46. swoją metodę Speak() wywołuje obiekt klasy Dog. Jak widać w wynikuna wydruku, wywoływane sązostały odpowiednie metody z obu klas. Na zakończenie, oba obiekty wychodzą z zakresu, więc są wywoływane ich destruktory.
Przesłanianie a przeciążanie
Te określenia są do siebie podobne i odnoszą się do podobnych rzeczy. Gdy przeciążasz metodę, tworzysz kilka funkcji o tej samej nazwie, ale z innymi sygnaturami. Gdy przesłaniasz metodę, tworzysz metodę w klasie wyprowadzonej; posiada ona taką samą nazwę i sygnaturę, jak przesłaniana metoda w klasie bazowej.
Ukrywanie metod klasy bazowej W poprzednim listingu metoda Speak() klasy Dog ukryła metodę klasy bazowej. Właśnie tego wtedy potrzebowaliśmy, ale w innych sytuacjach metoda ta może dawać nieoczekiwane rezultaty. Gdyby klasa Mammal posiadała przeciążoną metodę Move() (ruszaj), zaś klasa Dog przesłoniłaby tę metodę, wtedy metoda klasy Dog ukryłaby w klasie Mammal wszystkie metody o tej nazwie. Jeśli klasa Mammal posiada trzy przeciążone metody o nazwie Move() — jedną bez parametrów, drugą z parametrem w postaci liczby wartości całkowitej oraz trzecią z parametrem całkowitym i kierunkowym iem — zaś klasa Dog przesłania jedynie metodę Move() bez parametrów, wtedy dostęp do pozostałych dwóch metod poprzez obiekt klasy Dog nie będzie jest łatwy. Problem ten ilustruje listing 12.6. Listing 12.6. Ukrywanie metod 0: //Listing 12.6 Ukrywanie metod 1: 2: #include 3: 4: 5: class Mammal 6: { 7: public: 8: void Move() const { std::cout << "Mammal przeszedl jeden krok\n"; } 9: void Move(int distance) const 10: { 11: std::cout << "Mammal przeszedl "; 12: std::cout << distance <<" kroki.\n"; 13: } 14: protected: 15: int itsAge; 16: int itsWeight; 17: }; 18: 19: class Dog : public Mammal 20: { 21: public: 22: // MoŜesz zauwaŜyć ostrzeŜenie, Ŝe ukrywasz funkcję! 23: void Move() const { std::cout << "Dog przeszedl 5 krokow.\n"; } 24: }; 25: 26: int main() 27: { 28: Mammal bigAnimal; 29: Dog fido; 30: bigAnimal.Move();
31: 32: 33: 34: 35:
bigAnimal.Move(2); fido.Move(); // fido.Move(10); return 0; }
Wynik Mammal przeszedl jeden krok Mammal przeszedl 2 kroki. Dog przeszedl 5 krokow.
Analiza Z tych klas zostały usunięte wszystkie dodatkowe metody i dane. W liniach 8. i 9. klasa Mammal deklaruje przeciążone metody Move(). W linii 23. klasa Dog przesłania wersję metody Move(), która nie posiada żadnych parametrów. Metody te są wywoływane w liniach od 30. do 32., zaś w wynikach widzimy, że zostały one wykonane. Linia 33. została jednak wykomentowana, gdyż powoduje powstanie błędu kompilacji. Choć k Klasa Dog mogłaby wywoływać metodę Move(int), gdyby nie przesłoniła wersji metody Move() bez parametrów., jednak pPonieważ jednak metoda ta została przesłonięta, w tym celukonieczne jest przesłonięcie obu wersji. W przeciwnym razie metody, które nie zostały przesłonięte, są ukrywane. Przypomina to regułę, według której w momencie dostarczenia przez nas jakiegokolwiek konstruktora, kompilator nie dostarcza już konstruktora domyślnego. Oto obowiązująca reguła: Gdy przesłonisz jakąkolwiek z przeciążonych metod, wszystkie inne przeciążenia tej metody zostają ukryte. Jeśli nie chcesz ich ukrywać, musisz przesłonić je wszystkie. Ukrycie metody klasy bazowej, gdy chcemy ją przesłonić, jest dość częstym błędem; wynika on z tego, że nie zastosowaliśmy słowa kluczowego const. Słowo kluczowe const stanowi część sygnatury i pominięcie go powoduje zmianę sygnatury, a więc ukrycie metody, a nie przesłonięcie jej. Przesłanianie a ukrywanie
W następnym podrozdziale zajmiemy się metodami wirtualnymi. Przesłonięcie metody wirtualnej umożliwia uzyskanie polimorfizmu, zaś ukrycie jej uniemożliwia polimorfizm. Więcej informacji na ten temat uzyskasz już wkrótce.
Wywoływanie metod klasy bazowej Jeśli przesłoniłeś metodę klasy bazowej, wciąż możesz ją wywoływać, używając pełnej kwalifikowanej nazwy metody. W tym celu zapisuje się nazwę klasy bazowej, dwa dwukropki oraz nazwę metody. Na przykład: Mammal::Move(). Istnieje możliwość przepisania linii 33. z listingu 12.6 tak, aby można ją było skompilować:
33:
fido.Mammal::Move(10);
Taka linia wywołuje metodę klasy Mammal w sposób jawny. Proces ten w pełni ilustruje listing 12.7. Listing 12.7. Wywoływanie metody bazowej z metody przesłoniętej 0: 1: 2: 3: 4: 5: 6: 7: 8:
//Listing 12.7 Wywoływanie metody bazowej z metody przesłoniętej #include using namespace std; class Mammal { public: void Move() const { cout << "Mammal przeszedl jeden krok\n";
} 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40:
void Move(int distance) const { cout << "Mammal przeszedl " << distance; cout << " kroki.\n"; } protected: int itsAge; int itsWeight; }; class Dog : public Mammal { public: void Move()const; }; void Dog::Move() const { cout << "W metodzie Move klasy dog...\n"; Mammal::Move(3); } int main() { Mammal bigAnimal; Dog fido; bigAnimal.Move(2); fido.Mammal::Move(6); return 0; }
Wynik Mammal przeszedl 2 kroki. Mammal przeszedl 6 kroki.
Analiza
W linii 35. tworzony jest obiekt bigAnimal klasy Mammal, zaś w linii 36. jest tworzony obiekt fido klasy Dog. Metoda wywoływana w linii 37. wykonuje metodę Move() klasy Mammal, przyjmującą pojedynczy argument typu int. Programista chciał wywołać metodę Move(int) obiektu klasy Dog, ale miał problem. W klasie Dog została przesłonięta metoda Move(), lecz nie została przesłonięta wersja tej metody z parametrem typu int. Rozwiązał ten problem, jawnie wywołując metodę Move(int) klasy bazowej, w linii 38.
TAK
NIE
Rozszerzaj funkcjonalność testowanych klas, stosując wyprowadzanie.
Nie ukrywaj metod klasy bazowej przez zmianę sygnatury metod w klasie pochodnej.
Zmieniaj zachowanie określonych funkcji w klasie wyprowadzonej przez przesłanianie metod klasy bazowej.
Metody wirtualne W tym rozdziale sygnalizujemy fakt, iż obiekt klasy Dog jest obiektem klasy Mammal. Jak dotąd oznaczało to jedynie, że obiekt Dog dziedziczy atrybuty (dane) oraz możliwości (metody) swojej klasy bazowej. Jednak w języku C++ relacja jest-czymś sięga nieco głębiej. C++ rozszerza swój polimorfizm, pozwalając, by wskaźnikom do klas bazowych przypisywane były wskaźniki do obiektów klas pochodnych. Zatem można napisać: Mammal* pMammal = new Dog;
W ten sposób tworzymy na stercie nowy obiekt klasy Dog, zaś otrzymany do niego wskaźnik przypisujemy wskaźnikowi do obiektów klasy Mammal. Jest to poprawne, gdyż pies (ang. dog) jest ssakiem (ang. mammal). UWAGA Na tym właśnie polega polimorfizm. Na przykład, możesz stworzyć wiele rodzajów okien, np. okna dialogowe, okna przewijane lub listy, każdemu z nich przydzielając wirtualną metodę draw() (rysuj). Tworząc wskaźnik do okna i przypisując okna dialogowe i inne wyprowadzone typy temu wskaźnikowi, możesz wywoływać metodę draw(); bez względu na bieżący typ obiektu, na który on wskazuje. Za każdym zostanie wywołania właściwa funkcja draw().
Możesz użyć tego wskaźnika do wywołania dowolnej metody klasy Mammal. Z pewnością jednak bardziej spodoba ci się, że zostaną wywołane właściwe metody przesłaniające onięte metody z
klasy Dog. Proces ten umożliwiają funkcje wirtualne. Mechanizm ten ilustruje listing 12.8, pokazuje on także, co się dzieje z metodami, które nie są wirtualne. Listing 12.8. Używanie metod wirtualnych 0: //Listing 12.8 UŜywanie metod wirtualnych 1: 2: #include 3: using std::cout; 4: 5: class Mammal 6: { 7: public: 8: Mammal():itsAge(1) { cout << "Konstruktor klasy Mammal...\n"; } 9: virtual ~Mammal() { cout << "Destruktor klasy Mammal...\n"; } 10: void Move() const { cout << "Mammal przeszedl jeden krok\n"; } 11: virtual void Speak() const { cout << "Metoda Speak klasy Mammal\n"; } 12: protected: 13: int itsAge; 14: 15: }; 16: 17: class Dog : public Mammal 18: { 19: public: 20: Dog() { cout << "Konstruktor klasy Dog...\n"; } 21: virtual ~Dog() { cout << "Destruktor klasy Dog...\n"; } 22: void WagTail() { cout << "Macham ogonkiem...\n"; } 23: void Speak()const { cout << "Hau!\n"; } 24: void Move()const { cout << "Dog przeszedl 5 krokow...\n"; } 25: }; 26: 27: int main() 28: { 29: 30: Mammal *pDog = new Dog; 31: pDog->Move(); 32: pDog->Speak(); 33: 34: return 0; 35: }
Wynik Konstruktor klasy Mammal... Konstruktor klasy Dog... Mammal przeszedl jeden krok Hau!
Analiza W linii 11. została zadeklarowana wirtualna metoda klasy Mammal — metoda Speak(). Projektant tej klasy sygnalizuje w ten sposób, że oczekuje, iż ta klasa może być typem bazowym innej klasy. Klasa pochodna najprawdopodobniej zechce przesłonić tę funkcję.
W linii 30. tworzony jest wskaźnik (pDog) do klasy Mammal, lecz jest mu przypisywany adres nowego obiektu klasy Dog. Ponieważ obiekt klasy Dog jest obiektem klasy Mammal, przypisanie to jest poprawne. Następnie wskaźnik ten jest używany do wywołania funkcji Move(). Ponieważ kompilator wie tylko, że pDog jest wskaźnikiem do klasy Mammal, zagląda do obiektu tej klasy w celu znalezienia metody Move(). W linii 32. za pomocą tego wskaźnika zostaje wywołana metoda Speak(). Ponieważ metoda ta jest metodą wirtualną, wywołana zostaje przesłonięta metoda Speak() przesłonięta w z klasy ie Dog. To prawie magia. Funkcja wywołująca wie tylko, iż posiada wskaźnik do obiektu klasy Mammal, a mimo to zostaje wywołana metoda klasy Dog. W rzeczywistości, gdybyśmy mieli tablicę wskaźników do klasy Mammal, a każdy z nich wskazywałby obiekt klasy wyprowadzonej z tej klasy, moglibyśmy wywoływać jej po kolei, a wywoływane funkcje zostałybybyłyby właściwe. Proces ten ilustruje listing 12.9. Listing 12.9. Wywoływanie wielu funkcji wirtualnych 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40:
//Listing 12.9 Wywoływanie wielu funkcji wirtualnych #include using namespace std; class Mammal { public: Mammal():itsAge(1) { } virtual ~Mammal() { } virtual void Speak() const { cout << "Ssak mowi!\n"; } protected: int itsAge; }; class Dog : public Mammal { public: void Speak()const { cout << "Hau!\n"; } };
class Cat : public Mammal { public: void Speak()const { cout << "Miau!\n"; } };
class Horse : public Mammal { public: void Speak()const { cout << "Ihaaa!\n"; } }; class Pig : public Mammal { public: void Speak()const { cout << "Kwik!\n"; } };
41: 42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52: 53: 54: 55: 56: 57: 58: 59: 60: 61: 62: 63: 64: 65: 66: 67: 68:
int main() { Mammal* theArray[5]; Mammal* ptr; int choice, i; for ( i = 0; i<5; i++) { cout << "(1)pies (2)kot (3)kon (4)swinia: "; cin >> choice; switch (choice) { case 1: ptr = new Dog; break; case 2: ptr = new Cat; break; case 3: ptr = new Horse; break; case 4: ptr = new Pig; break; default: ptr = new Mammal; break; } theArray[i] = ptr; } for (i=0;i<5;i++) theArray[i]->Speak(); return 0; }
Wynik (1)pies (2)kot (1)pies (2)kot (1)pies (2)kot (1)pies (2)kot (1)pies (2)kot Hau! Miau! Ihaaa! Kwik! Ssak mowi!
(3)kon (3)kon (3)kon (3)kon (3)kon
(4)swinia: (4)swinia: (4)swinia: (4)swinia: (4)swinia:
1 2 3 4 5
Analiza Ten skrócony program, w którym pozostawiono jedynie najistotniejsze części każdej z klas, przedstawia funkcje wirtualne w ich najczystszej postaci. Zadeklarowane zostały cztery klasy: Dog, Cat, Horse (koń) oraz Pig (świnia), wszystkie wyprowadzone z klasy Mammal. W linii 10. funkcja Speak() klasy Mammal została zadeklarowana jako metoda wirtualna. Metoda ta zostaje przesłonięta we wszystkich czterech klasach pochodnych, w liniach 18., 25., 32. i 38. Użytkownik jest proszony o wybranie obiektu, który ma zostać stworzony, po czym w liniach od 47. do 64.5 do tablicy zostają dodane wskaźniki. UWAGA W czasie kompilacji nie ma możliwości sprawdzenia, które obiekty zostaną stworzoney i które wersje metody Speak() mają byćzostaną wywołane. Wskaźnik ptr jest przypisywany do obiektu już podczas wykonywania programu. Nazywa się to wiązaniem dynamicznym
(dokonywanym podczas działania programu), w przeciwieństwie do wiązania statycznego (dokonywanego podczas kompilacji).
Często zadawane pytanie
Jeśli oznaczę metodę składową jako wirtualną w klasie bazowej, to czy muszę oznaczać ją jako wirtualną także w klasach pochodnych?
Odpowiedź: Nie, jeśli oznaczysz metodę jako wirtualną, a potem przesłonisz ją w klasie pochodnej, pozostanie ona w dalszym ciągu wirtualna. Jednak warto oznaczyć ją jako wirtualną (choć nie jest to konieczne) — dzięki temu kod jest łatwiejszy do zrozumienia.
Jak działają funkcje wirtualne Gdy tworzony jest obiekt klasy pochodnej, na przykład obiekt klasy Dog, najpierw wywoływany jest konstruktor klasy bazowej, a następnie konstruktor klasy pochodnej. Rysunek 12.2 pokazuje, jak wygląda obiekt klasy Dog po utworzeniu go. Zwróć uwagę, że część Mammal obiektu jest w pamięci spójna z częścią Dog.
Rys. 12.2. Obiekt klasy Dog po utworzeniu
Gdy w obiekcie tworzona jest funkcja wirtualna, jest ona śledzona przez ten obiekt. Wiele kompilatorów buduje tablicę funkcji wirtualnych, nazywaną v-table. Dla każdego typu przechowywana jest jedna taka tablica, zaś każdy obiekt tego typu przechowuje do niej wskaźnik (nazywany vptr lub v-pointer). Choć implementacje różnią się od siebie, wszystkie kompilatory muszą wykonywać te same czynności, dlatego przedstawiony poniżej opis nie będzie odbiegał od rzeczywistości. Wskaźnik vptr każdego z obiektów wskazuje na tablicę v-table, która z kolei zawiera wskaźniki do każdej z funkcji wirtualnych. (Uwaga: wskaźniki do funkcji zostaną szerzej omówione w rozdziale 15., „Specjalne klasy i funkcje”). Gdy tworzona jest część Mammal klasy Dog, wskaźnik vptr jest inicjalizowany tak, by wskazywał właściwą część tablicy v-table, jak pokazano na rysunku 12.3.
Rys. 12.3. V-table klasy Mammal
Gdy zostaje wywołany konstruktor klasy Dog i dodawana jest część Dog tego obiektu, wskaźnik vptr jest modyfikowany tak, by wskazywał na przesłonięcia funkcji wirtualnych (o ile istnieją) w klasie Dog (patrz rysunek 12.4).
Rys. 12.4. V-table klasy Dog
Gdy używany jest wskaźnik do klasy Mammal, wskaźnik vptr cały czas wskazuje właściwą funkcję, w zależności od „rzeczywistego” typu obiektu. W chwili wywołania metody Speak() wywoływana jest właściwa funkcja.
Nie możesz przejść stamtąd dotąd tu Gdyby obiekt klasy Dog miał metodę (na przykład WagTail()), która niew występowałaby w klasie Mammal, w celu odwołania się do tej metody nie mógłbyś użyć wskaźnika do klasy Mammal (chyba że jawnie rzutowałbyś ten wskaźnik do klasy Dog). Ponieważ WagTail() nie jest funkcją wirtualną i ponieważ nie występuje ona w klasie Mammal, nie możesz jej użyć, nie posiadając obiektu klasy Dog lub wskaźnika do klasy Dog. Choć możesz przekształcić wskaźnik do klasy Mammal we wskaźnik do klasy Dog, istnieją dużo lepsze i bezpieczniejsze sposoby wywołania metody WagTail(). C++ odradza jawne rzutowanie (konwersję) typów, gdyż jest ono podatne na błędy. Ten problem zostanie omówiony przy okazji rozważań na temat wielokrotnego dziedziczenia w rozdziale 15. oraz przy omawianiu szablonów w rozdziale 20., „Wyjątki i obsługa błędów”.
Okrajanie Zwróć uwagę, że funkcje wirtualne działają tylko w przypadku wskaźników i referencji. Przekazanie obiektu przez wartość uniemożliwia wywołanie funkcji wirtualnych. Problem ten ilustruje listing 12.10. Listing 12.10. Okrajanie (przycięcie) danych podczas przekazywania przez wartość 0: //Listing 12.10 Okrajanie danych podczas przekazywania przez wartość 1: 2: #include 3: 4: class Mammal 5: { 6: public: 7: Mammal():itsAge(1) { } 8: virtual ~Mammal() { } 9: virtual void Speak() const { std::cout << "Ssak mowi!\n"; } 10: protected: 11: int itsAge; 12: }; 13: 14: class Dog : public Mammal 15: { 16: public: 17: void Speak()const { std::cout << "Hau!\n"; } 18: }; 19: 20: class Cat : public Mammal 21: { 22: public: 23: void Speak()const { std::cout << "Miau!\n"; } 24: }; 25: 26: void ValueFunction (Mammal); 27: void PtrFunction (Mammal*); 28: void RefFunction (Mammal&); 29: int main() 30: { 31: Mammal* ptr=0; 32: int choice; 33: while (1) 34: { 35: bool fQuit = false; 36: std::cout << "(1)pies (2)kot (0)Wyjscie: "; 37: std::cin >> choice; 38: switch (choice) 39: { 40: case 0: fQuit = true; 41: break; 42: case 1: ptr = new Dog; 43: break; 44: case 2: ptr = new Cat; 45: break; 46: default: ptr = new Mammal; 47: break; 48: } 49: if (fQuit) 50: break; 51: PtrFunction(ptr);
52: 53: 54: 55: 56: 57: 58: 59: 60: 61: 62: 63: 64: 65: 66: 67: 68: 69: 70: 71:
RefFunction(*ptr); ValueFunction(*ptr); } return 0; } void ValueFunction (Mammal MammalValue) { MammalValue.Speak(); } void PtrFunction (Mammal * pMammal) { pMammal->Speak(); } void RefFunction (Mammal & rMammal) { rMammal.Speak(); }
Wynik (1)pies (2)kot (0)Wyjscie: 1 Hau! Hau! Ssak mowi! (1)pies (2)kot (0)Wyjscie: 2 Miau! Miau! Ssak mowi! (1)pies (2)kot (0)Wyjscie: 0
Analiza W liniach od 4. do 24. są deklarowane okrojone klasy Mammal, Dog oraz Cat. Deklarowane są trzy funkcje — PtrFunction(), RefFunction() oraz ValueFunction(). Przyjmują one, odpowiednio, wskaźnik do obiektu klasy Mammal, referencję do takiego obiektu oraz sam obiekt Mammal. Wszystkie trzy funkcje robią to samo: wywołują metodę Speak(). Użytkownik jest proszony o wybranie albo klasy DogPies albo lub CatKot i, w zależności od tego wyboru, w liniach od 42. do 45. tworzony jest wskaźnik do odpowiedniego typu (Dog lub Cat). W pierwszej linii wydruku niku użytkownik wybrał klasę Dog. Obiekt klasy Dog został utworzony na stercie (w linii 42.). Następnie nowo utworzony obiekt jest przekazywany wspomnianym wyżej trzem funkcjom poprzez wskaźnik, referencję i wartość. Wskaźnik i referencje wywołują funkcje wirtualne, zatem zostaje wywołana funkcja składowa Dog::Speak(). Obrazują to następne dwie linie wydruku (nikówpo wyborze dokonanym przez użytkownika). Wyłuskany wskaźnik jest jednak przekazywany poprzez wartość. Funkcja oczekuje obiektu klasy Mammal, więc kompilator „okraja” obiekt klasy Dog, pozostawiając jedynie część stanowiącą obiekt klasy Mammal. W tym momencie wywoływana jest więc metoda Speak() klasy Mammal, co odzwierciedla trzecia linia wydruku (nikupo wyborze dokonanym przez użytkownika).
Ten eksperyment zostaje następnie powtórzony dla klasy Cat; uzyskaliśmy podobne rezultaty.
Destruktory wirtualne Dozwolone, i dość często stosowane, jest przekazywanie wskaźnika do wyprowadzonego obiektu tam, gdzie oczekiwany jest wskaźnik do klasy bazowej. Co się dzieje, gdy taki wskaźnik do wyprowadzonej klasy zostaje usunięty? Jeśli destruktor jest wirtualny, a taki powinien być, dzieją następuje to, co powinno się właściwe rzeczy: zostaje wywoływany destruktor klasy wyprowadzonej. Ponieważ destruktor klasy wyprowadzonej automatycznie wywołuje destruktor klasy bazowej, cały obiekt zostanie zniszczony. Obowiązuje tu następująca zasada: jeśli jakiekolwiek funkcje w klasie są wirtualne, destruktor także powinien być wirtualny.
Wirtualne konstruktory kopiującei Konstruktory nie mogą być wirtualney, więc nie istnieje coś takiego, jak wirtualny konstruktor kopiującyi. Zdarza się jednak, że musimy przekazać wskaźnik do obiektu bazowego i otrzymać kopię właściwie utworzonego obiektu klasy wyprowadzonej. Powszechnie stosowanym rozwiązaniem tego problemu jest stworzenie metody o nazwie Clone() (klonuj) w klasie bazowej i uczynienie jej metodą wirtualną. Metoda Clone() tworzy i zwraca nową kopię obiektu bieżącej klasy. Ponieważ metoda Clone() zostaje przesłonięta w każdej z klas pochodnych, tworzona jest kopia klasy wyprowadzonej. Ilustruje to listing 12.11. Listing 12.11. Wirtualny konstruktor kopiującyi 0: //Listing 12.11 Wirtualny konstruktor kopiującyi 1: 2: #include 3: using namespace std; 4: 5: class Mammal 6: { 7: public: 8: Mammal():itsAge(1) { cout << "Konstruktor klasy Mammal...\n"; } 9: virtual ~Mammal() { cout << "Destruktor klasy Mammal...\n"; } 10: Mammal (const Mammal & rhs); 11: virtual void Speak() const { cout << "Ssak mowi!\n"; } 12: virtual Mammal* Clone() { return new Mammal(*this); } 13: int GetAge()const { return itsAge; } 14: protected: 15: int itsAge; 16: }; 17: 18: Mammal::Mammal (const Mammal & rhs):itsAge(rhs.GetAge()) 19: { 20: cout << "Konstruktor kopiujacyi klasy Mammal...\n"; 21: } 22: 23: class Dog : public Mammal 24: {
25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52: 53: 54: 55: 56: 57: 58: 59: 60: 61: 62: 63: 64: 65: 66: 67: 68: 69: 70: 71: 72: 73: 74: 75: 76: 77: 78: 79: 80: 81: 82: 83: 84: 85:
public: Dog() { cout << "Konstruktor klasy Dog...\n"; } virtual ~Dog() { cout << "Destruktor klasy Dog...\n"; } Dog (const Dog & rhs); void Speak()const { cout << "Hau!\n"; } virtual Mammal* Clone() { return new Dog(*this); } }; Dog::Dog(const Dog & rhs): Mammal(rhs) { cout << "Konstruktor kopiujacyi klasy Dog...\n"; } class Cat : public Mammal { public: Cat() { cout << "Konstruktor klasy Cat...\n"; } ~Cat() { cout << "Destruktor klasy Cat...\n"; } Cat (const Cat &); void Speak()const { cout << "Miau!\n"; } virtual Mammal* Clone() { return new Cat(*this); } }; Cat::Cat(const Cat & rhs): Mammal(rhs) { cout << "Konstruktor kopiujacyi klasy Cat...\n"; } enum ANIMALS { MAMMAL, DOG, CAT}; const int NumAnimalTypes = 3; int main() { Mammal *theArray[NumAnimalTypes]; Mammal* ptr; int choice, i; for ( i = 0; i> choice; switch (choice) { case DOG: ptr = new Dog; break; case CAT: ptr = new Cat; break; default: ptr = new Mammal; break; } theArray[i] = ptr; } Mammal *OtherArray[NumAnimalTypes]; for (i=0;iSpeak(); OtherArray[i] = theArray[i]->Clone(); } for (i=0;iSpeak(); return 0;
86:
}
Wynik 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19:
(1)dog (2)cat (3)Mammal: 1 Konstruktor klasy Mammal... Konstruktor klasy Dog... (1)dog (2)cat (3)Mammal: 2 Konstruktor klasy Mammal... Konstruktor klasy Cat... (1)dog (2)cat (3)Mammal: 3 Konstruktor klasy Mammal... Hau! Konstruktor kopiujacyi klasy Konstruktor kopiujacyi klasy Miau! Konstruktor kopiujacyi klasy Konstruktor kopiujacyi klasy Ssak mowi! Konstruktor kopiujacyi klasy Hau! Miau! Ssak mowi!
Mammal... Dog... Mammal... Cat... Mammal...
Analiza Listing 12.11 jest bardzo podobny do dwóch poprzednich listingów, z wyjątkiem tego, iż tym razem do klasy Mammal została dodana nowa wirtualna metoda, o nazwie Clone(). Ta metoda zwraca wskaźnik do nowego obiektu klasy Mammal poprzez wywołanie konstruktora kopiującegoi, przekazując samą siebie (*this) jako stałą (const) referencję. Metoda Clone() została przeciążona zarówno w klasie Dog, jak i w klasie Cat, w których inicjalizuje dane tych klas i przekazuje kopie samych siebie do swoich własnych konstruktorów kopiującychi. Ponieważ metoda Clone() jest wirtualna, w efekcie otrzymujemy wirtualny konstruktor kopiującyi, co pokazano w linii 81. Użytkownik jest proszony o wybranie klasy Dog, Cat lub Mammal, a w liniach od 62. do 74. tworzony jest odpowiedni obiekt. Wskaźnik dla każdego z wyborów jest umieszczany w tablicy w linii 75. Gdy program „przechodzi” przez kolejne elementy tablicy, wywołuje metodę Speak() i Clone() każdego ze wskazywanych obiektów (w liniach 80. i 81.). Rezultatem wywołania metody Clone() jest wskaźnik do kopii obiektu, który w linii 81 jest przechowywany w drugiej tablicy. W pierwszej linii wydruku niku użytkownik wybiera pierwszą opcję, tworząc klasę Dog. Wywoływane są konstruktory klasy Mammal oraz klasy Dog. Czynność tę powtarza się dla klas Cat oraz Mammal w liniach wyników od 4. do 8ników. Linia 9. wyników reprezentuje wywołanie metody Speak() pierwszego obiektu, należącego do klasy Dog. Wywoływana jest wirtualna metoda Speak(), zatem zostaje wywołana metoda właściwej klasy. Następnie wywoływana jest metoda Clone(), a ponieważ ona także jest wirtualna, w efekcie zostaje wywołana metoda Clone() klasy Dog. Powoduje to wywołanie konstruktora klasy Mammal oraz konstruktora kopiującegoi klasy Dog.
Te same czynności powtarza się dla klasy Cat w liniach wyników od 12. do 14., a następnie dla klasy Mammal w liniach 15. i 16. Na zakończenie program „przechodzi” przez drugą tablicę, w której dla każdego z nowych obiektów zostaje wywołana metoda Speak().
Koszt metod wirtualnych Ponieważ obiekty zawierające metody wirtualne muszą przechowywać tablice funkcji wirtualnych (v-table), z posiadaniem metod wirtualnych wiąże się pewne obciążenie. Jeśli posiadasz bardzo małą klasę, z której nie zamierzasz wyprowadzać innych klas, być może nie ma powodu umieszczania w niej jakichkolwiek metod wirtualnych. Gdy zadeklarujesz którąś z metod jako wirtualną, poniesiesz już większość kosztów posiadania tablicy funkcji wirtualnych (choć każda z kolejnych funkcji wirtualnych także powoduje pewne niewielkie obciążenie pamięci). Powinieneś także posiadać wirtualny destruktor (zakładając także, że wszystkie inne metody również najprawdopodobniej będą wirtualne). Dokładnie przyjrzyj się każdej z niewirtualnych metod i upewnij się że, czy wiesz, dlaczego nie są one wirtualne.
TAK
NIE
Gdy spodziewasz się, że będziesz wyprowadzał nowe klasy z klasy, którą właśnie tworzysz, użyj metod wirtualnych.
Nie oznaczaj konstruktora jako funkcji wirtualnej.
Jeśli którakolwiek z metod jest wirtualna, użyj także wirtualnego destruktora.
Rozdział 13. Tablice i listy połączone W poprzednich rozdziałach deklarowaliśmy pojedyncze obiekty typu int, char, i tym podobne. Często chcemy jednak deklarować zbiory obiektów, takie jak 20 wartości typu int czy kilka obiektów typu CAT. Z tego rozdziału dowiesz się: •
czym są tablice i jak się je deklaruje,
•
czym są łańcuchy i jak je tworzyć za pomocą tablic znaków,
•
jaki jest związek pomiędzy tablicami a wskaźnikami,
•
w jaki sposób posługiwać się arytmetyką na wskaźnikach odnoszących się do tablic.
Czym jest tablica? Tablica (ang. array) jest zbiorem miejsc przechowywania danych, w którym każde z tych miejsc zawiera dane tego samego typu. Każde miejsce przechowywania jest nazywane elementem tablicy. Tablicę deklaruje się, zapisując typ, nazwę tablicy oraz jej rozmiar. Rozmiar tablicy jest zapisywany jako ujęta w nawiasy kwadratowe ilość elementów tablicy. Na przykład linia: long LongArray[25];
deklaruje tablicę składającą się z dwudziestu pięciu wartości typu long, noszącą nazwę LongArray. Gdy kompilator natrafi na tę deklarację, zarezerwuje miejsce do przechowania wszystkich dwudziestu pięciu elementów. Ponieważ każda wartość typu long wymaga czterech bajtów pamięci, ta deklaracja rezerwuje sto bajtów ciągłego obszaru pamięci, tak jak pokazano na rysunku 13.1.
Rys. 13.1. Deklarowanie tablicy
Elementy tablicy Do każdego z elementów tablicy możemy się odwołać, podając przesunięcie (ang. offset) względem nazwy tablicy. Elementy tablicy są liczone od zera. Tak więc pierwszym elementem tablicy jest arrayName[0]. W przykładzie z tablicą LongArray, pierwszym elementem jest LongArray[0], drugim LongArray[1], itd. Może to być nieco mylące. Tablica SomeArray[3] zawiera trzy elementy. Są to: SomeArray[0], SomeArray[1] oraz SomeArray[2]. Tablica SomeArray[n] zawiera n elementów ponumerowanych od SomeArray[0] do SomeArray[n-1]. Elementy tablicy LongArray[25] są ponumerowane od LongArray[0] do LongArray[24]. Listing 13.1 przedstawia sposób zadeklarowania tablicy pięciu wartości całkowitych i wypełnieniau jej wartościami. Listing 13.1. Użycie tablicy wartości całkowitych 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15:
//Listing 13.1 - Tablice #include int main() { int myArray[5]; int i; for ( i=0; i<5; i++) // 0-4 { std::cout << "Wartosc elementu myArray[" << i << "]: "; std::cin >> myArray[i]; } for (i = 0; i<5; i++) std::cout << i << ": " << myArray[i] << "\n"; return 0; }
Wynik Wartosc elementu myArray[0]: 3 Wartosc elementu myArray[1]: 6 Wartosc elementu myArray[2]: 9
Wartosc elementu myArray[3]: 12 Wartosc elementu myArray[4]: 15 0: 3 1: 6 2: 9 3: 12 4: 15
Analiza W linii 5. jest deklarowana tablica o nazwie myArray, zawierająca pięć zmiennych całkowitych. W linii 7. rozpoczyna się pętla zliczająca od 0 do 4, czyli przeznaczona dla wszystkich elementów pięcioelementowej tablicy. Użytkownik jest proszony o podanie kolejnych wartości, które są umieszczane w odpowiednich miejscach tablicy. Pierwsza wartość jest umieszczana w elemencie myArray[0], druga w elemencie myArray[1] , i tak dalej. Druga pętla, for, służy do wypisania na ekranie wartości kolejnych elementów tablicy. UWAGA Elementy tablic liczy się od 0, a nie od 1. Z tego powodu początkujący programiści języka C++ często popełniają błędy. Zawsze, gdy korzystasz z tablicy, pamiętaj, że tablica zawierająca 10 elementów jest liczona od ArrayName[0] do ArrayName[9]. Element ArrayName[10] nie jest używany.
Zapisywanie poza koniec tablicy Gdy zapisujesz wartość do elementu tablicy, kompilator na podstawie rozmiaru elementu oraz jego indeksu oblicza miejsce, w którym powinien ją umieścić. Przypuśćmy, że chcesz zapisać wartość do elementu LongArray[5], który jest szóstym elementem tablicy. Kompilator mnoży indeks (5) przez rozmiar elementu, który w tym przypadku wynosi 4. Następnie przemieszcza się od początku tablicy o otrzymaną liczbę bajtów (20) i zapisuje wartość w tym miejscu. Gdy poprosisz o zapisanie wartości do pięćdziesiątego elementu tablicy LongArray, kompilator zignoruje fakt, iż taki element nie istnieje. Obliczy miejsce wstawienia wartości (200 bajtów od początku tablicy) i zapisze ją w otrzymanym miejscu pamięci. Miejsce to może zawierać praktycznie dowolne dane i zapisanie tam nowej wartości może dać zupełnie nieoczekiwane wyniki. Jeśli masz szczęście, program natychmiast się załamie. Jeśli nie, dziwne wyniki pojawią się dużo później i będziesz miał problem z ustaleniem, co jest ich przyczyną. Kompilator przypomina ślepca poruszającego się mijającego kolejne domy. Zaczyna od pierwszego domu, MainStreet[0]. Gdy poprosisz go o przejście do szóstego domu na Main Street, mówi sobie: „Muszę minąć jeszcze pięć domów. Każdy dom to cztery duże kroki. Muszę więc przejść jeszcze dwadzieścia kroków. Gdy poprosisz go o przejście do MainStreet[100], mimo iż przy Main Street stoi tylko 25 domów, przejdzie 400 kroków. Z pewnością, zanim przejdzie ten dystans, wpadnie pod ciężarówkę. Uważaj więc, gdzie go wysyłasz. Listing 13.2 pokazuje, co się dzieje, gdy zapiszesz coś za końcem tablicy. OSTRZEŻENIE Nie uruchamiaj tego programu, może on załamać system!
Listing 13.2. Zapis za końcem tablicy 0: //Listing 13.2 1: // Demonstruje, co się stanie, gdy zapiszesz 2: // wartość za końcem tablicy 3: 4: #include 5: using namespace std; 6: 7: int main() 8: { 9: // wartownicy 10: long sentinelOne[3]; 11: long TargetArray[25]; // tablica do wypełnienia 12: long sentinelTwo[3]; 13: int i; 14: for (i=0; i<3; i++) 15: sentinelOne[i] = sentinelTwo[i] = 0; 16: 17: for (i=0; i<25; i++) 18: TargetArray[i] = 0; 19: 20: cout << "Test 1: \n"; // sprawdzamy bieŜące wartości (powinny być 0) 21: cout << "TargetArray[0]: " << TargetArray[0] << "\n"; 22: cout << "TargetArray[24]: " << TargetArray[24] << "\n\n"; 23: 24: for (i = 0; i<3; i++) 25: { 26: cout << "sentinelOne[" << i << "]: "; 27: cout << sentinelOne[i] << "\n"; 28: cout << "sentinelTwo[" << i << "]: "; 29: cout << sentinelTwo[i]<< "\n"; 30: } 31: 32: cout << "\nPrzypisywanie..."; 33: for (i = 0; i<=25; i++) 34: TargetArray[i] = 20; 35: 36: cout << "\nTest 2: \n"; 37: cout << "TargetArray[0]: " << TargetArray[0] << "\n"; 38: cout << "TargetArray[24]: " << TargetArray[24] << "\n"; 39: cout << "TargetArray[25]: " << TargetArray[25] << "\n\n"; 40: for (i = 0; i<3; i++) 41: { 42: cout << "sentinelOne[" << i << "]: "; 43: cout << sentinelOne[i]<< "\n"; 44: cout << "sentinelTwo[" << i << "]: "; 45: cout << sentinelTwo[i]<< "\n"; 46: } 47: 48: return 0; 49: }
Wynik Test 1: TargetArray[0]: 0 TargetArray[24]: 0 sentinelOne[0]: 0 sentinelTwo[0]: 0
sentinelOne[1]: sentinelTwo[1]: sentinelOne[2]: sentinelTwo[2]:
0 0 0 0
Przypisywanie... Test 2: TargetArray[0]: 20 TargetArray[24]: 20 TargetArray[25]: 20 sentinelOne[0]: sentinelTwo[0]: sentinelOne[1]: sentinelTwo[1]: sentinelOne[2]: sentinelTwo[2]:
20 0 0 0 0 0
Analiza W liniach 10. i 12. deklarowane są dwie tablice, zawierające po trzy zmienne całkowite, które pełnią rolę wartowników (ang. sentinel) wokół docelowej tablicy (TargetArray). Tablice wartowników są wypełniane zerami. Gdy dokonamy zapisu do pamięci poza tablicą TargetArray, najprawdopodobniej zmodyfikujemy zawartość wartowników. Niektóre kompilatory zliczają pamięć do góry, inne w dół. Z tego powodu umieściliśmy wartowników po obu stronach tablicy. Linie od 20. do 30. potwierdzają wartości wartowników w teście 1. W linii 34. elementy tablicy są wypełniane wartością 20, ale licznik zlicza aż do elementu 25, który nie istnieje w tablicy TargetArray. Linie od 37. do 39. wypisują wartości elementów tablicy TargetArray w drugim teście. Zwróć uwagę, że element TargetArray[25] wypisuje wartość 20. Jednak gdy wypisujemy wartości wartowników sentinelOne i sentinelTwo, okazuje się, że wartość elementu sentinelOne[0] uległa zmianie. Stało się tak, ponieważ pamięć położona o 25 elementów dalej od elementu TargetArray[0] zajmuje to samo miejsce, co element sentinelOne[0]. Gdy odwołujemy się do nieistniejącego elementu TargetArray[0], w rzeczywistości odwołujemy się do elementu sentinelOne[0]. Taki błąd może być bardzo trudny do wykrycia, gdyż wartość elementu sentinelOne[0] zostaje zmieniona w miejscu programu, które pozornie nie jest związane z zapisem wartości do tej tablicy. W tym programie użyto „magicznych liczb”, takich jak 3 dla rozmiarów tablic wartowników i 25 dla rozmiaru tablicy TargetArray. Bezpieczniej jednak jest używać stałych tak, aby można było zmieniać te wartości w jednym miejscu. Pamiętaj, że ponieważ kompilatory różnią się od siebie, otrzymany przez ciebie wynik może być nieco inny.
Błąd słupka w płocie Zapisanie wartości o jedną pozycję za końcem tablicy jest tak często popełnianym błędem, że otrzymał on nawet swoją własną nazwę. Nazywany jest błędem słupka w płocie. Nazwa ta nawiązuje do problemu, jaki wiele osób ma z obliczeniem ilości słupków potrzebnych do utrzymania dziesięciometrowego płotu, z słupkami rozmieszonymi co metr. Większość osób odpowie, że potrzeba dziesięciu słupków, ale oczywiście potrzebnych jest ich jedenaście. Wyjaśnia to rysunek 13.2.
Rys. 13.2. Błąd słupka w płocie
Typ zliczania „o jeden więcej” może być początkowo udręką programisty. Jednak z czasem przywykniesz do tego, że elementy w dwudziestopięcioelementowej tablicy zliczane są tylko do elementu numer dwadzieścia cztery, oraz do tego, że wszystko liczone jest począwszy od zera. UWAGA Niektórzy programiści określają element ArrayName[0] jako element zerowy. Nie należy się na to zgadzać, gdyż jeśli ArrayName[0] jest elementem zerowym, to czym jest ArrayName[1]? Pierwszym? Jeśli tak, to czy będziesz pamiętał, gdy zobaczysz ArrayName[24], że nie jest to element dwudziesty czwarty, ale dwudziesty piąty? Lepiej jest powiedzieć, że element ArrayName[0] ma zerowy offset i jest pierwszym elementem.
Inicjalizowanie tablic Deklarując po raz pierwszy prostą tablicę wbudowanych typów, takich jak int czy char, możesz ją zainicjalizować. Po nazwie tablicy umieść znak równości (=) oraz ujętą w nawiasy klamrowe listę rozdzielonych przecinkami wartości. Na przykład int IntegerArray[5] = { 10, 20, 30, 40, 50 };
deklaruje IntegerArray jako tablicę pięciu wartości całkowitych. Przypisuje elementowi IntegerArray[0] wartość 10, elementowi IntegerArray[1] wartość 20, i tak dalej. Gdy pominiesz rozmiar tablicy, zostanie stworzona tablica na tyle duża, by mogła pomieścić inicjalizujące je elementy. Jeśli napiszesz: int IntegerArray[] = { 10, 20, 30, 40, 50 };
stworzysz taką samą tablicę, jak w poprzednim przykładzie. Jeśli chcesz znać rozmiar tablicy, możesz poprosić kompilator, aby go dla ciebie obliczył. Na przykład const USHORT IntegerArrayLength = sizeof(IntegerArray) / sizeof(IntegerArray[0]);
przypisuje stałej IntegerArrayLength typu USHORT wynik dzielenia rozmiaru całej tablicy przez rozmiar pojedynczego jej elementu. Wynik ten odpowiada ilości elementów w tablicy. Nie można inicjalizować więcej elementów niż wynosi rozmiar tablicy. Tak więc zapis int IntegerArray[5] = { 10, 20, 30, 40, 50, 60 };
spowoduje błąd kompilacji, gdyż została zadeklarowana tablica pięcioelementowa, a my próbujemy zainicjalizować sześć elementów. Można natomiast napisać int IntegerArray = {10, 20};
TAK
NIE
Pozwól, by kompilator sam określał rozmiar inicjalizowanych tablic.
Nie dokonuj zapisów za końcem tablicy.
Nadawaj tablicom znaczące nazwy, tak jak wszystkim innym zmiennym. Pamiętaj, że pierwszy element tablicy ma offset (przesunięcie) wynoszący zero.
Deklarowanie tablic Tablica może mieć dowolną nazwę, zgodną z zasadami nazywania zmiennych, ale nie może mieć takiej samej nazwy jak zmienna lub inna tablica wewnątrz danego zakresu. Dlatego nie można mieć jednocześnie tablicy o nazwie myCats[5] oraz zmiennej myCats. Rozmiar tablicy można określić, używając stałej lub wyliczenia. Ilustruje to listing 13.3. Listing 13.3. Użycie stałej i wyliczenia jako rozmiaru tablicy 0: 1: 2: 3: 4:
// Listing 13.3 // UŜycie stałej i wyliczenia jako rozmiaru tablicy #include int main()
5: 6: 7: 8: 9: 10: 11: 12:
{ enum WeekDays { Sun, Mon, Tue, Wed, Thu, Fri, Sat, DaysInWeek }; int ArrayWeek[DaysInWeek] = { 10, 20, 30, 40, 50, 60, 70 }; std::cout << "Wartoscia Wtorku jest: " << ArrayWeek[Tue]; return 0; }
Wynik Wartoscia Wtorku jest: 30
Analiza Linia 6. tworzy typ wyliczeniowye o nazwie WeekDays (dni tygodnia). Zawiera ono osiem składowych. Stałej Sun (od sunday — niedziela) odpowiada wartość 0, zaś stałej DaysInWeek (dni w tygodniu) odpowiada wartość 7. W linii 10. wyliczeniowa stała Tue (od tuesday — wtorek) pełni rolę offsetu tablicy. Ponieważ stała Tue odpowiada wartości dwa, w linii 10. zwracany i wypisywany jest trzeci element tablicy, ArrayWeek[2]. Tablice
Aby zadeklarować tablicę, zapisz typ przechowywanego w niej obiektu, nazwę tablicy oraz jej rozmiar, określający ilość obiektów, które powinny być przechowane w tej tablicy.
Przykład 1 int MyIntegerArray[90];
Przykład 2 long * ArrayOfPointersToLong[8];
Aby odwołać się do elementów tablicy, użyj operatora indeksu.
Przykład 1 int theNinethInteger = MyIntegerArray[8];
Przykład 2 long * pLong = ArrayOfPointersToLongs[8];
Elementy tablic są liczone od zera. Tablica n elementów zawiera elementy liczone od zera do n–1.
Tablice obiektów W tablicach można przechowywać dowolne obiekty, zarówno wbudowane, jak i te zdefiniowane przez użytkownika. Deklarując tablicę, informujesz kompilator o typie przechowywanych obiektów oraz ilości obiektów, dla jakiej ma zostać zaalokowane miejsce. Kompilator, na podstawie deklaracji klasy, zna ilość miejsca zajmowanego przez każdy z obiektów. Klasa musi posiadać domyślny konstruktor nie posiadający argumentów (aby obiekty mogły zostać stworzone podczas definiowania tablicy). Na proces dostępu do danych składowych w tablicy obiektów składają się dwa kroki. Właściwy element tablicy jest wskazywany za pomocą operatora indeksu ([]), po czym dodawany jest operator składowej (.), wydzielający określoną zmienną składową obiektu. Listing 13.4 demonstruje sposób tworzenia i wykorzystania tablicy pięciu obiektów typu CAT. Listing 13.4. Tworzenie tablicy obiektów 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31:
// Listing 13.4 - Tablica obiektów #include using namespace std; class CAT { public: CAT() { itsAge = 1; itsWeight=5; } ~CAT() {} int GetAge() const { return itsAge; } int GetWeight() const { return itsWeight; } void SetAge(int age) { itsAge = age; } private: int itsAge; int itsWeight; }; int main() { CAT Litter[5]; int i; for (i = 0; i < 5; i++) Litter[i].SetAge(2*i +1); for (i = 0; i < 5; i++) { cout << "Kot nr " << i+1<< ": "; cout << Litter[i].GetAge() << endl; } return 0;
32:
}
Wynik Kot Kot Kot Kot Kot
nr nr nr nr nr
1: 2: 3: 4: 5:
1 3 5 7 9
Analiza Linie od 5. do 17. deklarują klasę CAT. Klasa CAT musi posiadać domyślny konstruktor, aby w tablicy mogły być tworzone jej obiekty. Pamiętaj, że jeśli stworzysz jakikolwiek inny konstruktor, domyślny konstruktor nie zostanie dostarczany przez kompilator; będziesz musiał stworzyć go sam. Pierwsza pętla for (linie 23. i 24.) ustawia wiek dla każdego z pięciu obiektów CAT w tablicy. Druga pętla for (linie od 26. do 30.) odwołuje się do każdego z obiektów i wywołuje jego funkcję składową GetAge(). Metoda GetAge() każdego z poszczególnych obiektów jest wywoływana poprzez określenie elementu tablicy, Litter[i], po którym następuje operator kropki (.) oraz nazwa funkcji składowej.
Tablice wielowymiarowe Tablice mogą mieć więcej niż jeden wymiar. Każdy wymiar jest reprezentowany przez oddzielny indeks tablicy. Na przykład, tablica dwuwymiarowa posiada dwa indeksy; tablica trójwymiarowa posiada trzy indeksy, i tak dalej. Tablice mogą mieć dowolną ilość wymiarów, choć najprawdopodobniej większość tworzonych przez ciebie tablic będzie miała tylko jeden lub dwa wymiary. Dobrym przykładem tablicy dwuwymiarowej jest szachownica. Jeden wymiar reprezentuje osiem rzędów, zaś drugi wymiar reprezentuje osiem kolumn. Ilustruje to rysunek 13.3.
Rys. 13.3. Szachownica oraz tablica dwuwymiarowa
Przypuśćmy że mamy klasę o nazwie SQUARE (kwadrat). Deklaracja tablicy o nazwie Board (plansza), która ją reprezentuje, mogłaby więc mieć postać: SQUARE Board[8][8];
Te same dane moglibyśmy przechować w jednowymiarowej, 64-elementowej tablicy. Na przykład: SQUARE Board[64];
Nie odpowiadałoby to jednak rzeczywistej, dwuwymiarowej planszy. Na początku gry król umieszczany jest na czwartej pozycji w pierwszym rzędzie; tej pozycji odpowiada: Board[0][3];
przy założeniu, że pierwszy wymiar odnosi się do rzędów, a drugi do kolumn.
Inicjalizowanie tablic wielowymiarowych Tablice wielowymiarowe także mogą być inicjalizowane. Kolejnym elementom tablicy przypisywane są wartości, przy czym gdy wcześniejsze wymiary pozostają stały,zmianie eniapodlegają siędane w ostatnim wymiarze tablicy (przy ustaleniu wszystkich poprzednich). Tak więc, gdy mamy tablicę: int theArray[5][3];
pierwsze trzy elementy trafiają do theArray[0], następne trzy do theArray[1], i tak dalej. Tablicę tę możemy zainicjalizować, pisząc: int theArray[5][3] = { 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15 };
W celu uzyskania lepszej przejrzystości, możesz pogrupować wartości, używając nawiasów klamrowych. Na przykład: int theArray[5][3] = { { 1, 2, 3}, { 4, 5, 6}, { 7, 8, 9}, {10,11,12}, {13,14,15} };
Kompilator ignoruje wewnętrzne nawiasy klamrowe (choć ułatwiają one użytkownikowi zrozumienie sposobu ułożenia wartości). Każda wartość musi być oddzielona przecinkiem, bez względu na stosowanie nawiasów klamrowych. Cały zestaw inicjalizacyjny musi być ujęty w nawiasy klamrowe i kończyć się średnikiem. Listing 13.5 tworzy tablicę dwuwymiarową. Pierwszym wymiarem (czyli pierwszą kolumną) jest zestaw liczb od zera do cztery. Drugi wymiar (czyli drugą kolumnę) zawiera podwojoną stanowią liczby o wartościach dwukrotnie większych niż wartości w kolumnie pierwszej. ć każdej z wartości w pierwszym wymiarze. Listing 13.5. Tworzenie tablicy wielowymiarowej 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16:
// Listing 13.5 - Tworzenie tablicy wielowymiarowej #include using namespace std; int main() { int SomeArray[5][2] = { {0,0}, {1,2}, {2,4}, {3,6}, {4,8}}; for (int i = 0; i<5; i++) for (int j=0; j<2; j++) { cout << "SomeArray[" << i << "][" << j << "]: "; cout << SomeArray[i][j]<< endl; } return 0; }
Wynik SomeArray[0][0]: SomeArray[0][1]: SomeArray[1][0]: SomeArray[1][1]: SomeArray[2][0]: SomeArray[2][1]: SomeArray[3][0]: SomeArray[3][1]: SomeArray[4][0]: SomeArray[4][1]:
0 0 1 2 2 4 3 6 4 8
Analiza Linia 7. deklaruje SomeArray jako tablicę dwuwymiarową. Pierwszy wymiar składa się z pięciu liczb całkowitych, zaś drugi z dwóch liczb całkowitych. Powstaje więc siatka o rozmiarach 5×2, co ilustruje rysunek 13.4.
Rys. 13.4. Tablica 5 x 2
Wartości są inicjalizowane w parach, choć równie dobrze mogłyby zostać obliczone. Linie 8. i 9. tworzą zagnieżdżoną pętlę for. Pętla zewnętrzna „przechodzi” przez każdy element pierwszego wymiaru. Odpowiednio, dla każdego elementu w tym wymiarze, wewnętrzna pętla przechodzi przez każdy element drugiego wymiaru. Jest to zgodne z wydrukiem. Po elemencie SomeArray[0][0] następuje element SomeArray[0][1]. Pierwszy wymiar jest inkrementowany tylko wtedy, gdy drugi wymiar zostanie wcześniej zwiększony o jeden. Wtedy zaczyna się ponowne zliczanie drugiego wymiaru.
Kilka słów na temat pamięci Gdy deklarujesz tablicę, dokładnie informujesz kompilator o tym, ile obiektów chcesz w niej przechować. Kompilator rezerwuje pamięć dla wszystkich tych obiektów, nawet jeśli nigdy z nich nie skorzystasz. Nie stanowi to problemu w przypadku tych tablic, w których dokładnie znasz ilość potrzebnych ci elementów. Na przykład, szachownica zawiera 64 pola, zaś koty rodzą od jednego do dziesięciu kociąt. Jeśli jednak nie masz pojęcia, ilu obiektów potrzebujesz, musisz skorzystać z bardziej zaawansowanych struktur danych. W tej książce przedstawimy tablice wskaźników, tablice tworzone na stercie oraz różne inne kolekcje. Poznamy też kilka zaawansowanych struktur danych, jednak więcej informacji na ten temat możesz znaleźć w mojej książce C++ Unleashed, wydanej przez Sams Publishing. Dwie najlepsze rzeczy w programowaniu to możliwość ciągłego uczenia się i to, że zawsze pojawiają się kolejne książki, z których można się uczyć.
Tablice wskaźników W przedstawianych dotąd tablicach wszystkie ich elementy były składowane na stosie. Zwykle pamięć stosu jest dość ograniczona, podczas gdy pamięć na stercie jest dużo bardziej obszerna. Istnieje możliwość zadeklarowania wszystkich obiektów na stercie i przechowania w tablicy tylko wskaźnika do każdego z obiektów. Powoduje to znaczne zmniejszenie ilości pamięci stosu zajmowanej przez tablicę. Listing 13.6 zawiera zmodyfikowaną wersję listingu 13.4, w której wszystkie obiekty przechowywane są na stercie. W celu podkreślenia faktu, że umożliwia ona
lepsze wykorzystanie pamięci, rozmiar tablicy został zwiększony z pięciu do pięciuset elementów, zaś jej nazwa została zmieniona z Litter (miot) na Family (rodzina).
Listing 13.6. Tablica wskaźników do obiektów 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37:
// Listing 13.6 - Tablica wskaźników do obiektów #include using namespace std; class CAT { public: CAT() { itsAge = 1; itsWeight=5; } ~CAT() {} // destruktor int GetAge() const { return itsAge; } int GetWeight() const { return itsWeight; } void SetAge(int age) { itsAge = age; } private: int itsAge; int itsWeight; }; int main() { CAT * Family[500]; int i; CAT * pCat; for (i = 0; i < 500; i++) { pCat = new CAT; pCat->SetAge(2*i +1); Family[i] = pCat; } for (i = 0; i < 500; i++) { cout << "Kot nr " << i+1 << ": "; cout << Family[i]->GetAge() << endl; } return 0; }
Wynik Kot Kot Kot ... Kot Kot
nr 1: 1 nr 2: 3 nr 3: 5 nr 499: 997 nr 500: 999
Analiza Obiekt CAT zadeklarowany w liniach od 5. do 17. jest identyczny z obiektem CAT zadeklarowanym na listingu 13.4. Tym razem jednak tablica zadeklarowana w linii 21. ma nazwę Family i zawiera 500 wskaźników do obiektów typu CAT.
W początkowej pętli for (linie od 24. do 29.) na stercie tworzonych jest pięćset nowych obiektów typu CAT; dla każdego z nich wiek jest ustawiany jako podwojony indeks plus jeden. Tak więc pierwszy CAT ma wiek 1, drugi ma wiek 3, trzeci ma 5, i tak dalej. Na zakończenie, w tablicy umieszczany jest wskaźnik kolejnego stworzonego obiektu. Ponieważ tablica została zadeklarowana jako zawierająca wskaźniki, dodawany jest do niej wskaźnik — a nie obiekt wyłuskany zspod tego wskaźnika. Druga pętla for (linie od 31. do 35.) wypisuje każdą z wartości. Dostęp do wskaźnika uzyskuje się przez użycie indeksu elementu, Family[i]. Otrzymany adres umożliwia dostęp do metody GetAge(). W tym przykładzie tablica Family i wszystkie zawarte w niej wskaźniki są przechowywane na stosie, ale pięćset stworzonych przedtem obiektów CAT znajduje się na stercie.
Deklarowane tablic na stercie Istnieje możliwość umieszczenia na stercie całej tablicy. W tym celu należy wywołać new z operatorem indeksu. W rezultacie otrzymujemy wskaźnik do obszaru sterty zawierającego nowo utworzoną tablicę. Na przykład: CAT *Family = new CAT[500];
deklaruje zmienną Family jako wskaźnik do pierwszego elementu w pięciusetelementowej tablicy obiektów typu CAT. Innymi słowy, Family wskazuje na — czyli zawiera adres — element Family[0]. Zaletą użycia zmiennej Family w ten sposób jest to, że możemy na wskaźnikach wykonać działania arytmetyczne odwołując się do elementów tablicy. Na przykład, możemy napisać: CAT *Family = new CAT[500]; CAT *pCat = Family; pCat->SetAge(10); pCat++; pCat->SetAge(20);
// // // //
pCat wskazuje na Family[0] ustawia Family[0] na 10 przechodzi do Family[1] ustawia Family[1] na 20
Deklarujemy tu nową tablicę pięciuset obiektów typu CAT oraz wskaźnik wskazujący początek tej tablicy. Używając tego wskaźnika, wywołujemy funkcję SetAge() pierwszego obiektu, przekazując jej wartość 10. Następnie wskaźnik jest inkrementowany i automatycznie wskazuje następny obiekt w tablicy, po czym wywoływana jest metoda SetAge()następnego obiektu.
Wskaźnik do tablicy a tablica wskaźników Przyjrzyjmy się trzem poniższym deklaracjom: 1: CAT FamilyOne[500]; 2: CAT * FamilyTwo[500]; 3: CAT * FamilyThree = new CAT[500];
FamilyOne jest tablicą pięciuset obiektów typu CAT. FamilyTwo jest tablicą pięciuset wskaźników do obiektów typu CAT. FamilyThree jest wskaźnikiem do tablicy pięciuset obiektów typu CAT.
Różnice pomiędzy tymi trzema liniami kodu zasadniczo wpływają na działanie tych tablic. Jeszcze bardziej dziwi fakt, iż FamilyThree jest po prostu wariantem deklaracji FamilyOne (i bardzo się różni od FamilyTwo). Mamy tu do czynienia ze złożonym zagadnieniem powiązań tablic ze wskaźnikami. W trzecim przypadku, FamilyThree jest wskaźnikiem do tablicy, czyli adres w zmiennej FamilyThree jest adresem pierwszego elementu w tej tablicy, co dokładnie odpowiada przypadkowi ze zmienną FamilyOne.
Wskaźniki a nazwy tablic W C++ nazwa tablicy jest stałym wskaźnikiem do pierwszego elementu tablicy. Tak więc, w deklaracji CAT Family[50];
Family jest wskaźnikiem do &Family[0], które jest adresem pierwszego elementu w tablicy Family.
Używanie nazw tablic jako stałych wskaźników (i odwrotnie) jest dozwolone. Tak więc Family + 4 jest poprawnym sposobem odwołania się do danych w elemencie Family[4].
Podczas dodawania, inkrementowania lub dekrementowania wskaźników wszystkie działania arytmetyczne wykonuje kompilator. Adres, do którego odwołujemy się, pisząc Family + 4, nie jest adresem położonym o cztery bajty od adresu wskazywanego przez Family, lecz adresem położonym o cztery obiekty dalej. Gdyby każdy z obiektów zajmował cztery bajty, wtedy Family + 4 odnosiłoby się do miejsca położonego o szesnaście bajtów za początkiem tablicy. Gdyby każdy obiekt typu CAT zawierał cztery składowe typu long, zajmujące po cztery bajty każda, oraz dwie składowe typu short, po dwa bajty każda, wtedy każdy obiekt tego typu zajmowałby dwadzieścia bajtów, zaś Family + 4 odnosiłoby się do adresu położonego o osiemdziesiąt bajtów od początku tablicy. Deklarowanie i wykorzystanie tablicy zadeklarowanej na stercie ilustruje listing 13.7.
Listing 13.7. Tworzenie tablicy za pomocą operatora new 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42:
// Listing 13.7 - Tablica utworzona na stercie #include class CAT { public: CAT() { itsAge = 1; itsWeight=5; } ~CAT(); int GetAge() const { return itsAge; } int GetWeight() const { return itsWeight; } void SetAge(int age) { itsAge = age; } private: int itsAge; int itsWeight; }; CAT :: ~CAT() { // cout << "Wywolano destruktor!\n"; } int main() { CAT * Family = new CAT[500]; int i; for (i = 0; i < 500; i++) { Family[i].SetAge(2*i +1); } for (i = 0; i < 500; i++) { std::cout << "Kot nr " << i+1 << ": "; std::cout << Family[i].GetAge() << std::endl; } delete [] Family; return 0; }
Wynik Kot Kot Kot ... Kot Kot
nr 1: 1 nr 2: 3 nr 3: 5 nr 499: 997 nr 500: 999
Analiza Linia 25. deklaruje tablicę Family zawierającą pięćset obiektów typu CAT. Cała tablica jest tworzona na stercie, za pomocą wywołania new CAT[500].
Usuwanie tablic ze sterty Co się stanie z pamięcią zaalokowaną dla tych obiektów CAT, gdy tablica zostanie zniszczona? Czy istnieje możliwość wycieku pamięci? Usunięcie tablicy Family automatycznie zwróci całą pamięć przydzieloną tablicy wtedy, gdy użyjesz operatora delete[], pamiętając o nawiasach kwadratowych. Kompilator potrafi wtedy zniszczyć każdy z obiektów w tablicy i odpowiednio zwolnić pamięć sterty. Aby to sprawdzić, zmień rozmiar tablicy z 500 na 10 w liniach 25., 28. oraz 36. Następnie usuń znak komentarza przy instrukcji cout w linii 20. Gdy program „dojedzie” do linii 39., w której tablica jest niszczona, zostanie wywołany destruktor każdego z obiektów CAT. Gdy tworzysz element na stercie za pomocą operatora new, zawsze powinieneś usuwać go i zwalniać jego pamięć za pomocą operatora delete. Gdy tworzysz tablicę, używając operatora new [rozmiar], do usunięcia tej tablicy i zwolnienia jej pamięci powinieneś użyć operatora delete[]. Nawiasy kwadratowe sygnalizują kompilatorowi, że chodzi o usunięcie tablicy. Gdy pominiesz nawiasy kwadratowe, zostanie usunięty tylko pierwszy element w tablicy. Możesz to sprawdzić sam, usuwając nawiasy kwadratowe w linii 39. Jeśli zmodyfikowałeś linię 20. tak, by destruktor wypisywał komunikat, powinieneś zobaczyć na ekranie, że niszczony jest tylko jeden obiekt CAT. Gratulacje! Właśnie stworzyłeś wyciek pamięci!
TAK
NIE
Pamiętaj, że tablica n elementów zawiera elementy liczone od zera do n–1.
Nie dokonuj zapisów ani odczytów poza końcem tablicy.
W przypadku wskaźników wskazujących tablice, używaj indeksowania tablic.
Nie myl tablicy wskaźników ze wskaźnikiem do tablicy.
Tablice znaków Łańcuch w języku C jest tablicą znaków, zakończoną znakiem null. Jedyne łańcuchy w stylu C, z jakimi mieliśmy dotąd do czynienia, to nienazwane stałe łańcuchowe, używane w instrukcjach cout, takie jak: cout << "hello world.\n";
Łańcuchy w stylu C możesz deklarować i inicjalizować tak samo, jak wszystkie inne tablice. Na przykład: char Greeting[] =
{ 'H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd', '\0' };
Ostatni znak, '\0', jest znakiem null, który jest rozpoznawany przez wiele funkcji C++ jako znak kończący łańcuch w stylu C. Choć inicjalizowanie „znak po znaku” przynosi efekty, jest jednak dość żmudne i daje wiele okazji do błędów. C++ pozwala więc na używanie dla poprzedniej deklaracji formy skrótowej: char Greeting[] = "Hello World";
Powinieneś zwrócić uwagę na dwa elementy w tej konstrukcji: •
zamiast pojedynczych znaków ujętych w apostrofy, oddzielonych przecinkami i ujętych w nawiasy klamrowe, wpisuje się ujęty w cudzysłowy łańcuch w stylu C, bez przecinków i bez nawiasów klamrowych,
•
nie ma potrzeby dołączania znaku null, gdyż kompilator dołącza go automatycznie.
Łańcuch Hello World, przechowywany w stylu C, ma dwanaście znaków. Słowo Hello zajmuje pięć bajtów, spacja jeden bajt, słowo World pięć bajtów, zaś kończący znak null zajmuje jeden bajt. Możesz także tworzyć nie zainicjalizowane tablice znaków. Tak jak w przypadku wszystkich tablic, należy upewnić się, czy w buforze nie zostanie umieszczone więcej znaków, niż jest miejsca. Listing 13.8 demonstruje użycie nie zainicjalizowanego bufora. Listing 13.8. Wypełnianie tablicy 0: //Listing 13.8 bufory znakowe 1: 2: #include 3: 4: int main() 5: { 6: char buffer[80]; 7: std::cout << "Wpisz lancuch: "; 8: std::cin >> buffer; 9: std::cout << "Oto zawartosc bufora: std::endl; 10: return 0; 11: }
" << buffer <<
Wynik Wpisz lancuch: Hello World Oto zawartosc bufora: Hello
Analiza W linii 6. deklarowany jest bufor, mogący pomieścić osiemdziesiąt znaków. Wystarcza to do przechowania 79–znakowego łańcucha w stylu C oraz kończącego znaku null.
W linii 7. użytkownik jest proszony o wpisanie łańcucha w stylu C, który jestw linii 8. jest wprowadzany do bufora. Obiekt cin automatycznie dopisuje do łańcucha w buforze znak kończący null. W przypadku programu z listingu 13.8 pojawiają się dwa problemy. Po pierwsze, jeśli użytkownik wpisze więcej niż 79 znaków, cin dokona zapisu poza końcem bufora. Po drugie, gdy użytkownik wpisze spację, cin potraktuje ją jako koniec łańcucha i przestanie zapisywać resztę znaków do bufora. Aby rozwiązać te problemy, musisz wywołać specjalną metodę obiektu cin: metodę get(). Metoda cin.get() posiada trzy parametry: •
bufor do wypełnienia,
•
maksymalną ilość znaków do pobrania,
•
znak kończący wprowadzany łańcuch.
Domyślnym znakiem kończącym jest znak nowej linii. Użycie tej metody ilustruje listing 13.9. Listing 13.9. Wypełnianie tablicy 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: znaku 10: 11: 12:
//Listing 13.9 uŜycie cin.get() #include using namespace std; int main() { char buffer[80]; cout << "Wpisz lancuch: "; cin.get(buffer, 79); // pobiera do 79 znaków lub do nowej linii cout << "Oto zawartosc bufora: " << buffer << endl; return 0; }
Wynik Wpisz lancuch: Hello World Oto zawartosc bufora: Hello World
Analiza Linia 9. wywołuje metodę get() obiektu cin. Bufor zadeklarowany w linii 7. jest przekazywany jako jej pierwszy argument. Drugim argumentem jest maksymalna ilość znaków do pobrania, w tym przypadku musi nią być 79 (tak, aby bufor mógł pomieścić także kończący znak null). Nie ma potrzeby podawania także dodawania znaku kończącego wpis, gdyż robi to wartość wystarczydomyślna ejdla przejścia do nowej linii.
strcpy() oraz strncpy() C++ odziedziczyło od języka C bibliotekę funkcji operujących na łańcuchach w stylu C. Wśród wielu funkcji tego typu znajdziemy dwie funkcje służące do kopiowania jednego łańcucha do drugiego: strcpy() oraz strncpy(). Funkcja strcpy() kopiuje całą zawartość jednego łańcucha do wskazanego bufora. Jej użycie ilustruje listing 13.10.
Listing 13.10. Użycie strcpy() 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16:
//Listing 13.10 UŜycie strcpy() #include #include using namespace std; int main() { char String1[] = "Zaden czlowiek nie jest samoistna wyspa"; char String2[80]; strcpy(String2,String1); cout << "String1: " << String1 << endl; cout << "String2: " << String2 << endl; return 0; }
Wynik String1: Zaden czlowiek nie jest samoistna wyspa String2: Zaden czlowiek nie jest samoistna wyspa
Analiza W linii 3. jest dołączany plik nagłówkowy string.h. Ten plik zawiera prototyp funkcji strcpy(). Funkcja otrzymuje dwie tablice znaków — docelową oraz źródłową. Gdyby tablica źródłowa była większa niż tablica docelowa, funkcja strcpy() dokonałaby zapisu poza koniec bufora. Aby nas przed tym zabezpieczyć, biblioteka standardowa zawiera także funkcję strncpy(). Ta wersja funkcji posiada dodatkowy argument, określający maksymalną ilość znaków do skopiowania. Funkcja strncpy() kopiuje znaki, aż do napotkania pierwszego znaku null albo do osiągnięcia dozwolonej maksymalnej ilości znaków. Listing 13.11 ilustruje użycie funkcji strncpy().
Listing 13.11. Użycie funkcji strncpy() 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17:
//Listing 13.11 UŜycie strncpy() #include #include int main() { const int MaxLength = 80; char String1[] = "Zaden czlowiek nie jest samoistna wyspa"; char String2[MaxLength+1];
strncpy(String2,String1,MaxLength); std::cout << "String1: " << String1 << std::endl; std::cout << "String2: " << String2 << std::endl; return 0; }
Wynik String1: Zaden czlowiek nie jest samoistna wyspa String2: Zaden czlowiek nie jest samoistna wyspa
Analiza W linii 12. wywołanie funkcji strcpy() zostało zmienione na wywołanie funkcji strncpy(). Funkcja ta posiada także trzeci parametr, określający maksymalną ilość znaków do skopiowania. Bufor String2 ma długość MaxLength+1 (maksymalna długość + 1) znaków. Dodatkowy znak jest przeznaczony do przechowania znaku null, który jest automatycznie umieszczany na końcu łańcucha — zarówno przez funkcję strcpy(), jak i strncpy().
Klasy łańcuchów C++ przejęło z języka C zakończone znakiem null łańcuchy oraz bibliotekę funkcji, zawierającą także funkcję strcpy(), ale funkcje tej biblioteki nie są zintegrowane z bibliotekami zorientowanymi obiektowo. Biblioteka standardowa zawiera klasę String, obejmującą zestaw danych i funkcji przeznaczonych do manipulowania łańcuchami, oraz zestaw akcesorów, dzięki którym same dane są ukryte przed klientami tej klasy. W ramach ćwiczenia potwierdzającego właściwe zrozumienie omawianych zagadnień, spróbujemy teraz stworzyć własną klasę String. Nasza klasa String powinna likwidować podstawowe ograniczenia tablic znaków. Podobnie jak wszystkie tablice, tablice znaków są statyczne. Sami definiujemy, jaki mają rozmiar. Tablice te zawsze zajmują określoną ilość pamięci, bez względu na to, czy jest to naprawdę potrzebne. Z kolei dokonanie zapisu za końcem tablicy może mieć katastrofalne skutki. UWAGA Przedstawiona tu klasa String jest bardzo ograniczona i w żadnym przypadku nie może być uważana za nadającą się do poważnych zastosowań. Wystarczy jednak na potrzeby naszego ćwiczenia, gdyż biblioteka standardowa zawiera pełną i stabilną klasę String.
Dobra klasa String alokuje tylko tyle pamięci, ile potrzebuje (aby zawsze wystarczało na przechowanie tego, co powinna zawierać). Jeśli nie może zaalokować wystarczającej ilości pamięci, powinna poprawnie to zgłosić. Pierwszą próbę utworzenia naszej klasy String przedstawia listing 13.12. Listing 13.12. Użycie klasy String 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10:
//Listing 13.12 UŜycie klasy String #include #include using namespace std; // zasadnicza klasa łańcucha class String { public: // konstruktory
11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52: 53: 54: 55: 56: 57: 58: 59: 60: 61: 62: 63: 64: 65: 66: 67: 68: 69: 70: 71:
String(); String(const char *const); String(const String &); ~String(); // przeciąŜone operatory char & operator[](unsigned short offset); char operator[](unsigned short offset) const; String operator+(const String&); void operator+=(const String&); String & operator= (const String &); // ogólne akcesory unsigned short GetLen()const { return itsLen; } const char * GetString() const { return itsString; } private: String (unsigned short); char * itsString; unsigned short itsLen; };
// prywatny konstruktor
// domyślny konstruktor tworzy łańcuch o długości zera bajtów String::String() { itsString = new char[1]; itsString[0] = '\0'; itsLen=0; } // prywatny (pomocniczy) konstruktor, uŜywany tylko przez // metody klasy, do tworzenia nowego łańcucha o // wymaganym rozmiarze, wypełnionym bajtami zerowymi String::String(unsigned short len) { itsString = new char[len+1]; for (unsigned short i = 0; i<=len; i++) itsString[i] = '\0'; itsLen=len; } // zamienia tablicę znaków na String String::String(const char * const cString) { itsLen = strlen(cString); itsString = new char[itsLen+1]; for (unsigned short i = 0; i
72: 73: 74: 75: 76: 77: 78: 79: 80: 81: 82: 83: 84: 85: 86: 87: 88: 89: 90: 91: 92: 93: 94: 95: 96: 97: 98: 99: 100: 101: 102: 103: 104: 105: 106: 107: 108: 109: 110: 111: 112: 113: 114: 115: 116: 117: 118: 119: 120: 121: 122: 123: 124: 125: 126: 127: 128: 129: 130: 131: 132:
// destruktor, zwalnia zaalokowaną pamięć String::~String () { delete [] itsString; itsLen = 0; } // operator równości, zwalnia istniejącą pamięć, // po czym kopiuje łańcuch i rozmiar String& String::operator=(const String & rhs) { if (this == &rhs) return *this; delete [] itsString; itsLen=rhs.GetLen(); itsString = new char[itsLen+1]; for (unsigned short i = 0; i itsLen) return itsString[itsLen-1]; else return itsString[offset]; } // stały operator offsetu doindeksu dla uŜycia dla // stałych obiektów (patrz konstruktor kopiującyi!) char String::operator[](unsigned short offset) const { if (offset > itsLen) return itsString[itsLen-1]; else return itsString[offset]; } // tworzy nowy łańcuch przez dodanie bieŜącego // łańcucha do rhs String String::operator+(const String& rhs) { unsigned short totalLen = itsLen + rhs.GetLen(); String temp(totalLen); unsigned short i; for ( i= 0; i
133: 134: 135: 136: 137: 138: 139: 140: 141: 142: 143: 144: 145: 146: 147: 148: 149: 150: 151: 152: 153: 154: 155: 156: 157: 158: 159: 160: 161: 162: 163: 164: 165: 166: 167: 168: 169: 170: 171: 172: 173: 174: 175:
unsigned short rhsLen = rhs.GetLen(); unsigned short totalLen = itsLen + rhsLen; String temp(totalLen); unsigned short i; for (i = 0; i
Wynik S1: poczatkowy test S1: Hello World tempTwo: ; milo tu byc! S1: Hello World; milo tu byc! S1[4]: o S1: Hellx World; milo tu byc! S1[999]: ! S3: Hellx World; milo tu byc! Inny lancuch S4: Dlaczego to dziala?
Analiza
Linie od 7. do 31. zawierają deklarację prostej klasy String. Linie od 11. do 13. zawierają trzy konstruktory: konstruktor domyślny, konstruktor kopiującyi oraz konstruktor otrzymujący istniejący łańcuch, zakończony znakiem null (w stylu C). Klasa String przeciąża operator indeksu ([]), operator plus (+) oraz operator plus-równa się (+=). Operator indeksu jest przeciążony dwukrotnie: raz jako funkcja const zwracająca znak; drugi raz jako funkcja nie const zwracająca referencję do znaku. Wersja nie const jest używana w wyrażeniach takich, jak: SomeString[4] = 'x';
tak, jak widać zieliśmyw linii 161. Dzięki temu mamy bezpośredni dostęp do każdego znaku w łańcuchu. Zwracana jest referencja do znaku, dzięki czemu funkcja wywołująca może nim manipulować. Wersja const jest używana w przypadkach, gdy następuje odwołanie do stałego obiektu typu String, na przykład w implementacji konstruktora kopiującegoi (linia 63.). Zauważ, że następuje odwołanie do rhs[i],; jednakże choć rhs jest zadeklarowane jako const String &. Dostęp do tego obiektu za pomocą funkcji składowej, nie będącej funkcją const, jest zabroniony. Dlatego operator indeksu musi być przeciążony za pomocą akcesora const. Jeśli zwracane obiekty byłyby bardzo duże, mógłbyś zechcieć zadeklarować zwracaną wartość jako referencję const. Jednak ponieważ znak zajmuje tylko jeden bajt, nie ma takiej potrzeby. Domyślny konstruktor jest zaimplementowany w liniach od 33. do 39. Tworzy on łańcuch, którego długość wynosi zero znaków. Zgodnie z konwencją, klasa String zwraca długość łańcucha bez końcowego znaku null. Tworzony łańcuch domyślny zawiera więc jedynie końcowy znak null. Konstruktor kopiującyi został zaimplementowany w liniach od 63. do 70. Ustawia on długość nowego łańcucha zgodnie z długością łańcucha istniejącego, plus jeden bajt dla końcowego znaku null. Kopiuje każdy znak z istniejącego łańcucha do nowego łańcucha, po czym kończy nowy łańcuch znakiem null. W liniach od 53. do 60. znajduje się implementacja konstruktora otrzymującego istniejący łańcuch w stylu C. Ten konstruktor jest podobny do konstruktora kopiującegoi. Długość istniejącego łańcucha wyznaczana jest w wyniku wywołania standardowej funkcji bibliotecznej strlen(). W linii 28. został zadeklarowany jeszcze jeden konstruktor, String(unsigned short), stanowiący prywatną funkcję składową. Odpowiada on zamierzeniom projektanta, zgodnie z którymi, żaden z klientów klasy nigdy nie powinien tworzyć łańcuchów typu String o określonej długości. Ten konstruktor istnieje tylko po to, by pomóc przy wewnętrznym tworzeniu egzemplarzy łańcuchów, na przykład w funkcji operator+= w linii 130. Opiszemy to dokładniej przy okazji omawiania działania funkcji operator+=. Konstruktor String(unsigned short) wypełnia każdy element swojej tablicy wartością NULL. Dlatego w pętli dokonujemy sprawdzenia i <= len, a nie i < len.
Destruktor, zaimplementowany w liniach od 73. do 77., usuwa łańcuch znaków przechowywany przez klasę. Musimy pamiętać o użyciu nawiasów kwadratowych w wywołaniu operatora delete (aby usunięte zostały wszystkie elementy tablicy, a nie tylko pierwszy). Operator przypisania najpierw sprawdza, czy prawa strona przypisania jest taka sama, jak lewa strona. Jeśli tak nie jest, bieżący łańcuch jest usuwany, po czym w jego miejsce tworzony jest i kopiowany nowy łańcuch. W celu umożliwienia przypisań w rodzaju: String1 = String2 = String3;
zwracana jest referencja. Operator indeksu jest przeciążany dwukrotnie. W obu przypadkach przeprowadzane jest podstawowe sprawdzanie zakresów. Jeśli użytkownik próbuje odwołać się do znaku położonego poza końcem tablicy, zwracany jest ostatni znak znak (znajdujący się na pozycji len-1). Linie od 117. do 127. implementują operator plus (+) jako operator konkatenacji (łączenia łańcuchów). Dzięki temu możemy napisać: String3 = String1 + String2;
przypisując łańcuchowi String3 połączenie dwóch pozostałych łańcuchów. W tym celu funkcja operatora plus oblicza połączoną długość obu łańcuchów i tworzy tymczasowy łańcuch temp. To powoduje wywołanie prywatnego konstruktora, który otrzymuje wartość całkowitą i tworzy łańcuch wypełniony znakami null. Następnie znaki null są zastępowane przez zawartość obu łańcuchów. Łańcuch po lewej stronie (*this) jest kopiowany jako pierwszy; łańcuch po prawej stronie (rhs) kopiowany jest później. Pierwsza pętla for przechodzi przez łańcuch po lewej stronie i przenosi każdy jego znak do nowego łańcucha. Druga pętla for przetwarza łańcuch po prawej stronie. Zauważ, że zmienna i cały czas wskazuje miejsce w łańcuchu docelowym, nawet wtedy, gdy zmienna j zlicza znaki łańcucha rhs. Operator plus zwraca łańcuch tymczasowy poprzez wartość, która jest przypisywana łańcuchowi po lewej stronie przypisania (string1). Operator += działa na istniejącym łańcuchu — tj. na łańcuchu po lewej stronie instrukcji string1 += string2. Działa on tak samo, jak operator plus, z tym, że wartość tymczasowa jest przypisywana do bieżącego łańcucha (*this = temp w linii 142.). Funkcja main() (w liniach od 145. do 175.) służy jako program testujący tę klasę. Linia 147. tworzy obiekt String za pomocą konstruktora, otrzymującego łańcuch w stylu C zakończony znakiem null. Linia 148. wypisuje jego zawartość za pomocą funkcji akcesora GetString(). Linia 150. tworzy kolejny łańcuch w stylu C. Linia 151. sprawdza operator przypisania, zaś linia 152. wypisuje wyniki. Linia 154. tworzy trzeci łańcuch w stylu C, tempTwo. Linia 155. wywołuje funkcję strcpy() (w celu wypełnienia bufora znakami ; milo tu byc!). Linia 156. wywołuje operator += i dołącza tempTwo do istniejącego łańcucha s1. Linia 158. wypisuje wyniki.
W linii 160. odczytywany jest i wypisywany piąty znak łańcucha s1. W linii 161. jest mu przypisywana nowa wartość. Powoduje to wywołanie operatora indeksu (operatora []; w wersji nie const). Linia 162. wypisuje wynik, który pokazuje, że wartość znaku rzeczywiście uległa zmianie. Linia 164. próbuje odwołać się do znaku za końcem tablicy. Ostatni znak w tablicy jest zwracany, zgodnie z projektem klasy. Linie 166. i 167. tworzą kolejne dwa obiekty String, zaś linia 168. wywołuje operator dodawania. Wynik jest wypisywany w linii 169. Linia 171. tworzy nowy obiekt String o nazwie s4. Linia 172. wywołuje operator przypisania. Linia 173. wypisuje wyniki. Być może zastanawiasz się: „Skoro operator przypisania jest zdefiniowany w linii 21. jako otrzymujący stałą referencję do obiektu String, ale to dlaczego w tym miejscu program przekazuje łańcuch w stylu C. Jak to możliwe?” A oto odpowiedź na to pytanie: kompilator oczekuje obiektu String, lecz otrzymuje tablicę znaków. W związku z tym sprawdza, czy może stworzyć obiekt String z tego, co otrzymał. W linii 12. zadeklarowaliśmy konstruktor, który tworzy obiekt String z tablicy znaków. Kompilator tworzy tymczasowy obiekt String z tablicy znaków i przekazuje go do operatora przypisania. Proces ten nazywa się rzutowaniem niejawnym lub promocją. Gdyby nie został zadeklarowany i zaimplementowany konstruktor przyjmujący tablicę znaków, to takie przypisanie spowodowałoby błąd kompilacji.
Listy połączone i inne struktury Tablice przypominają kontenery służące do przeprowadzek. Są one bardzo przydatnymi pojemnikami, ale mają określony rozmiar. Jeśli wybierzesz zbyt duży pojemnik, niepotrzebnie zmarnujesz miejsce. Jeśli wybierze pojemnik zbyt mały, jego zawartość wysypie się i powstanie bałagan. Jednym ze sposobów rozwiązania tego problemu jest użycie listy połączonej. Lista połączona jest to struktura danych składająca się z małych pojemników, przystosowanych do łączenia się ze sobą w miarę potrzeb. Naszym celem jest napisanie klasy zawierającej pojedynczy obiekt naszych danych — na przykład jeden obiekt CAT lub jeden obiekt Rectangle — która może wskazywać na następny pojemnik. Tworzymy jeden pojemnik dla każdego obiektu, który chcemy przechować i w miarę potrzeb łączymy je ze sobą. Takie pojemniki są nazywane węzłami (ang. node). Pierwszy węzeł listy jest nazywany głową (ang. head), zaś ostatni — ogonem (ang. tail). Listy występują w trzech podstawowych odmianach. W kolejności od najmniej do najbardziej złożonej, są to •
lista połączona pojedynczo,
•
lista połączona podwójnie,
•
drzewo.
W liście połączonej pojedynczo każdy węzeł wskazuje na węzeł następny (nie wskazuje poprzedniego). Aby odszukać określony węzeł, musimy zacząć od początku listy, tak jak w zabawie w poszukiwanie skarbów („Następny węzeł jest pod fotelem”). Lista połączona podwójnie umożliwia poruszenie się wzdłuż łańcucha węzłów do przodu i do tyłu. Drzewo jest złożoną strukturą zbudowaną z węzłów. Każdy węzeł może wskazywać w dwóch lub więcej kierunkach. Te trzy podstawowe struktury przedstawia rysunek 13.5.
Rys. 13.5. Listy połączone
Analiza listy połączonej W tym podrozdziale omówimy działanie listy połączonej. Lista ta posłuży nam nie tylko jako przykład tworzenia złożonych struktur, ale przede wszystkim jako przykład użycia dziedziczenia, polimorfizmu i kapsułkowania w celu zarządzania większymi projektami.
Przeniesienie odpowiedzialności Podstawowym celem programowania zorientowanego obiektowo jest to, by każdy obiekt wykonywał dobrze jedną rzecz, zaś wszystkie inne czynności przekazywał innym obiektom. Doskonałym przykładem zastosowania tej idei w praktyce jest samochód: zadaniem silnika jest dostarczanie siły. Jej dystrybucja nie jest już zadaniem silnika, ale układu napędowego. Skręcanie nie jest zadaniem ani silnika, ani układu napędowego, tylko kół. Dobrze zaprojektowana maszyna składa się z mnóstwa małych, dobrze określonychprzemyślanych części, z których każda wykonuje swoje zadanie i współpracuje z innymi częściami w celu osiągnięcia wspólnego celu. Dobrze zaprojektowany program działa bardzo podobnie: każda klasa wykonuje swoje niewielkie operacje, ale w połączeniu z innymi może wykonać naprawdę skomplikowane zadanie.
Części składowe Lista połączona składa się z węzłów. Pojęcie klasy węzła będzie abstrakcyjne; do wykonania zadania użyjemy trzech podtypów. Lista Bbędzie teżo zawierała węzeł głowyczołowy, którego zadaniem będzie zarządzanie głową listy, węzeł ogona (domyśl się, do czego posłuży!) oraz zero lub więcej węzłów wewnętrznych. Węzły wewnętrzne będą odpowiedzialne za dane przechowywane wewnątrz listy. Zauważ, że dane i lista są od siebie zupełnie niezależne. Teoretycznie, możesz przechowywać w liście dane dowolnego rodzaju, ponieważ to nie dane są ze sobą połączone, ale węzły, które je przechowują. Program sterujący nie wie niczego o węzłach, ponieważ operuje na liście. Jednak sama lista wykonuje niewiele pracy, delegując większość zadań na węzły. Kod programu przedstawia listing 13.13; za moment omówimy jego szczegóły. Listing 13.13. Lista połączona 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10:
// *********************************************** // PLIK: Listing 13.13 // // PRZEZNACZENIE: Demonstruje listę połączoną // UWAGI: // // COPYRIGHT: Copyright (C) 1998 Liberty Associates, Inc. // All Rights Reserved // // Demonstruje obiektowo zorientowane podejście do list // połączonych. Lista deleguje pracę na węzły.
11: // Węzeł jest abstrakcyjnym typem danych. UŜywane są trzy 12: // typy węzłów: węzły głowy, węzły ogona oraz węzły 13: // wewnętrzne. Tylko węzły wewnętrzne przechowują dane. 14: // 15: // Klasa data została stworzona jako obiekt danych 16: // przechowywany na liście połączonej. 17: // 18: // *********************************************** 19: 20: 21: #include 22: using namespace std; 23: 24: enum { kIsSmaller, kIsLarger, kIsSame}; 25: 26: // Klasa danych do umieszczania w liście połączonej. 27: // KaŜda klasa w tej połączonej liście musi posiadać dwie metody: 28: // Show (wyświetla wartość) a oraz 29: // Compare (zwraca względną pozycję) 30: class Data 31: { 32: public: 33: Data(int val):myValue(val){} 34: ~Data(){} 35: int Compare(const Data &); 36: void Show() { cout << myValue << endl; } 37: private: 38: int myValue; 39: }; 40: 41: // Compare jest uŜywane do podjęcia decyzji, w którym 42: // miejscu listy powinien znaleźć się dany obiekt. 43: int Data::Compare(const Data & theOtherData) 44: { 45: if (myValue < theOtherData.myValue) 46: return kIsSmaller; 47: if (myValue > theOtherData.myValue) 48: return kIsLarger; 49: else 50: return kIsSame; 51: } 52: 53: // wstępne deklaracje 54: class Node; 55: class HeadNode; 56: class TailNode; 57: class InternalNode; 58: 59: // ADT reprezentuje obiekt węzła listy 60: // KaŜda klasa potomna musi przesłonić metody Insert i Show 61: class Node 62: { 63: public: 64: Node(){} 65: virtual ~Node(){} 66: virtual Node * Insert(Data * theData)=0; 67: virtual void Show() = 0; 68: private: 69: }; 70:
71: // To jest węzeł przechowujący rzeczywisty obiekt. 72: // W tym przypadku obiekt jest typu Data. 73: // Gdy poznamy szablony, dowiemy się, jak moŜna 74: // to uogólnić. 75: class InternalNode: public Node 76: { 77: public: 78: InternalNode(Data * theData, Node * next); 79: ~InternalNode(){ delete myNext; delete myData; } 80: virtual Node * Insert(Data * theData); 81: // delegujemy! 82: virtual void Show() { myData->Show(); myNext->Show(); } 83: 84: private: 85: Data * myData; // dane jako takie 86: Node * myNext; // wskazuje następny węzeł w liście połączonej 87: }; 88: 89: // Konstruktor dokonuje jedynie inicjalizacji 90: InternalNode::InternalNode(Data * theData, Node * next): 91: myData(theData),myNext(next) 92: { 93: } 94: 95: // Esencja listy 96: // Gdy umieścisz nowy obiekt na liście, jest on 97: // przekazywany do węzła, który stwierdza, gdzie 98: // powinien on zostać umieszczony i wstawia go do listy 99: Node * InternalNode::Insert(Data * theData) 100: { 101: 102: // czy nowy element jest większy czy mniejszy niŜ ja? 103: int result = myData->Compare(*theData); 104: 105: 106: switch(result) 107: { 108: // konwencja: gdy jest taki sam jak ja, wstawiamy wcześniej 109: case kIsSame: // przechodzimy dalej 110: case kIsLarger: // nowe dane trafiają przede mnie 111: { 112: InternalNode * dataNode = new InternalNode(theData, this); 113: return dataNode; 114: } 115: 116: // gdy jest większy niŜ ja, przekazuję go do następnego węzła 117: // i niech ON się tym zajmie. 118: case kIsSmaller: 119: myNext = myNext->Insert(theData); 120: return this; 121: } 122: return this; 123: } 124: 125: 126: // Węzeł ogona jest tylko wartownikiem. 127: 128: class TailNode : public Node 129: {
130: 131: 132: 133: 134: 135: 136: 137: 138: 139: 140: 141: 142: 143: 144: 145: 146: 147: 148: 149: 150: 151: 152: 153: 154: 155: 156: 157: 158: 159: 160: 161: 162: 163: 164: 165: 166: 167: 168: 169: 170: 171: 172: 173: 174: 175: 176: 177: 178: 179: 180: 181: 182: 183: 184: 185: 186: 187: 188: 189: 190:
public: TailNode(){} ~TailNode(){} virtual Node * Insert(Data * theData); virtual void Show() { } private: }; // Gdy dane trafiają do mnie, muszą być wstawione wcześniej, // gdyŜ jestem ogonem i za mną NIC nie ma. Node * TailNode::Insert(Data * theData) { InternalNode * dataNode = new InternalNode(theData, this); return dataNode; } // Węzeł głowy nie zawiera danych; wskazuje jedynie // na sam początek listy. class HeadNode : public Node { public: HeadNode(); ~HeadNode() { delete myNext; } virtual Node * Insert(Data * theData); virtual void Show() { myNext->Show(); } private: Node * myNext; }; // Gdy tylko głowa zostanie stworzona, // natychmiast tworzy ogon. HeadNode::HeadNode() { myNext = new TailNode; } // Przed głowę nic nie wstawiamy nic, zatem // po prostu przekazujemy dane do następnego węzła. Node * HeadNode::Insert(Data * theData) { myNext = myNext->Insert(theData); return this; } // Odbieram słowa uznania, a sama nic nie robię. class LinkedList { public: LinkedList(); ~LinkedList() { delete myHead; } void Insert(Data * theData); void ShowAll() { myHead->Show(); } private: HeadNode * myHead; }; // Przy narodzinach tworzę węzeł głowy. // Tworzy on węzeł ogona. // Tak więc pusta lista wskazuje na głowę, która
191: 192: 193: 194: 195: 196: 197: 198: 199: 200: 201: 202: 203: 204: 205: 206: 207: 208: 209: 210: 211: 212: 213: 214: 215: 216: 217: 218: 219: 220: 221: 222: 223: 224: 225:
// wskazuje na ogon; pomiędzy nimi nie ma nic innego. LinkedList::LinkedList() { myHead = new HeadNode; }
Jaka Jaka Jaka Jaka Jaka Jaka Jaka 2 3 5 8 9 10
wartosc? wartosc? wartosc? wartosc? wartosc? wartosc? wartosc?
// Delegujemy, delegujemy, delegujemy void LinkedList::Insert(Data * pData) { myHead->Insert(pData); } // testowy program sterujący int main() { Data * pData; int val; LinkedList ll; // prosimy uŜytkownika o wpisanie kilku wartości // i umieszczamy je na liście for (;;) { cout << "Jaka wartosc? (0 aby zakonczyc): "; cin >> val; if (!val) break; pData = new Data(val); ll.Insert(pData); } // teraz przechodzimy listę i wyświetlamy wartości ll.ShowAll(); return 0; // ll wychodzi poza zakres i zostaje zniszczone! }
Wynik (0 (0 (0 (0 (0 (0 (0
aby aby aby aby aby aby aby
zakonczyc): zakonczyc): zakonczyc): zakonczyc): zakonczyc): zakonczyc): zakonczyc):
5 8 3 9 2 10 0
Analiza Pierwszą rzeczą, jaką należy zauważyć, jest instrukcja wyliczeniowae definiująca zawierające trzy stałe: kIsSmaller (jest mniejsze), kIsLarger (jest większe) oraz kIsSame (jest takie same). Każdy obiekt, który może być przechowywany na tej liście połączonej, musi obsługiwać metodę Compare(). Te stałe są zwracane właśnie przez tę metodę.
Dla przykładu, w liniach od 30. do 39. została stworzona klasa Data (dane), zaś jej metoda Compare() została zaimplementowana w liniach od 41. do 51. Obiekt typu Data przechowuje wartość i może porównywać się z innymi obiektami Data. Oprócz tego obsługuje metodę Show(), która wyświetla wartość obiektu Data. Najprostszym sposobem na zrozumienie działania listy połączonej jest przeanalizowanie ilustrującego ją przykładu. W linii 203. rozpoczyna się testowy program sterujący; w linii 206. deklarowany jest wskaźnik do obiektu Data, zaś w linii 208. definiowana jest lokalna lista połączona. Gdy tworzona jest lista połączona, wywoływany jest jej konstruktor, zaczynający się w linii 192. Jedyną pracą wykonywaną w tym konstruktorze jest zaalokowanie obiektu HeadNode (węzeł głowy) i przypisanie mujego adresu do wskaźnika przechowywanego w połączonej liście w linii 185. Tworzenie obiektu HeadNode powoduje wywołanie konstruktora klasy HeadNode zawartego w liniach od 163. do 166. To z kolei powoduje zaalokowanie obiektu TailNode (węzeł głowy) i przypisanie jego adresu do wskaźnika myNext (mój następny) w węźle głowy. Tworzenie obiektu TailNode powoduje wywołanie konstruktora tej klasy zdefiniowanego w linii 131., który jest funkcją inline i nie robi nic. Dzięki zwykłemu zaalokowaniu listy połączonej na stosie, tworzona jest sama lista, węzły głowy i ogona oraz ustanawiane są powiązania pomiędzy nimi. Ilustruje to rysunek 13.6.
Rys. 13.6. Lista połączona po jej utworzeniu
Linia 212. rozpoczyna pętlę nieskończoną. Użytkownik jest proszony o wpisanie wartości dodawanych do listy połączonej. Może on dodać dowolną ilość wartości tyle wartości, ile chce; wpisanie wartości 0 powoduje zakończenie pobierania danych. Kod w linii 216. sprawdza wprowadzaną wartość; po natrafianiu na wartość 0 powoduje on wyjście z pętli. Jeśli wartość jest różna od zera, w linii 218. tworzony jest nowy obiekt typu Data, który w linii 219. jest wstawiany do listy. Dla zilustrowania tego przykładu załóżmy, że użytkownik wprowadził wartość 15. Powoduje to wywołanie metody Insert() (wstaw) w linii 198. Lista połączona (klasa LinkedList) natychmiast przenosi odpowiedzialność za wstawienie obiektu na swój węzeł głowy. To wywołuje metodę Insert() z linii 170. Z kolei Wwęzeł głowy natychmiast przekazuje odpowiedzialność za wstawienie obiektu do węzła wskazywanego przez składową myNext (mój następny). W tym (pierwszym) przypadku, składowa ta, wskazuje ona na węzeł ogona (pamiętajmy, że podczas tworzenia węzła głowy zostało stworzone łącze do węzła ogona). Tak więc zostaje wywołana metoda Insert() z linii 142.
Metoda TailNode::Insert() wie, że obiekt, który otrzymała, musi być wstawiony bezpośrednio przed jej obiektem — tj. nowy obiekt znajdzie się na liście tuż przed węzłem ogona. Zatem, w linii 144. tworzy ona nowy obiekt InternalNode (węzeł wewnętrzny), przekazując mu dane oraz wskaźnik do siebie. To powoduje wywołanie konstruktora klasy InternalNode, zdefiniowanego w linii 90. Konstruktor klasy InternalNode inicjalizuje tylko swój wskaźnik Data za pomocą adresu otrzymanego obiektu Data oraz inicjalizuje swój wskaźnik myNext za pomocą otrzymanego adresu węzła. W tym przypadku nowy węzeł wewnętrzny będzie wskazywał na węzeł ogona (pamiętajmy, że węzeł ogona przekazał mu swój wskaźnik this). Gdy utworzony zostanie nowy węzeł InternalNode, w linii 144. jego adres jest przypisywany do wskaźnika dataNode i właśnie ten adres jest zwracany z metody TailNode::Insert(). Wracamy więc do metody HeadNode::Insert(), w której adres węzła InternalNode jest przypisywany do wskaźnika myNext węzła głowy (w linii 172.). Na zakończenie, adres węzła głowy jest zwracany do listy połączonej, gdzie w linii 200. jest odrzucany (nie robimy z nim nic, ponieważ lista połączona znała adres swojego węzła głowy już wcześniej). Dlaczego zawracamy sobie głowę zwracaniem adresu, który nie jest używany? Metoda Insert jest zadeklarowana w klasie bazowej, Node. Zwracana wartość jest potrzebna w innych implementacjach. Gdy zmienisz zwracaną wartość metody HeadNode::Insert(), spowodujesz błąd kompilacji; prościej jest więc po prostu zwrócić adres węzła głowy i pozwolić, by lista połączona go zignorowała. Co się więc stało? Dane zostały wstawione do listy. Lista przekazała je do głowy. Głowa, „na ślepo”, przekazała dane do pierwszego wskazywanego przez siebie elementu. W tym (pierwszym) przypadku, głowa wskazywała na ogon. Ogon natychmiast stworzył nowy węzeł wewnętrzny, inicjalizując go tak, by wskazywał na ogon. Następnie ogon zwrócił głowie adres nowego węzła, która zmodyfikowała swój wskaźnik myNext tak, aby wskazywał na nowy węzeł. Gotowe! Dane na liście znajdują się we właściwym miejscu, co ilustruje rysunek 13.7.
Rys. 13.7. Lista połączona po wstawieniu pierwszego węzła
Po wstawieniu pierwszego węzła, sterowanie programu powraca do linii 214., gdzie wprowadzane są kolejne dane. Dla przykładu załóżmy, że użytkownik wpisał wartość 3. Powoduje to stworzenie w linii 218. nowego obiektu typu Data, który jest wstawiany do listy w linii 219. W linii 200. lista ponownie przekazuje dane do swojego węzła głowy. Z kolei metoda HeadNode::Insert() przekazuje nową wartość do węzła wskazywanego przez swój wskaźnik myNext. Jak wiemy, w tym momencie wskaźnik ten wskazuje węzeł zawierający obiekt Data o wartości 15. Powoduje to wywołanie metody InternalNode::Insert() z linii 99.
W linii 103. obiekt InternalNode używa wskaźnika myData, aby dla własnego obiektu Data (tego o wartości 15) wywołać za pomocą otrzymanego nowego obiektu Data (tego o wartości 3) metodę Compare(). To powoduje wywołanie metody InternalNode::Compare() zdefiniowanej w linii 43. Obie wartości zostają porównane, a ponieważ myValue ma wartość 15, zaś theOtherData.myValue ma wartość 3, zwróconą wartością jest kIsLarger. To powoduje, że program przechodzi do linii 112. Dla nowego obiektu Data tworzony jest nowy węzeł InternalNode. Nowy węzeł będzie wskazywał na bieżący obiekt InternalNode, a metoda InternalNode::Insert() zwróci do obiektu HeadNode adres nowego węzła. Zatem nowy węzeł, którego wartość obiektu danych jest mniejsza od wartości obiektu danych węzła bieżącego, zostanie wstawiony do listy przed węzłem bieżącym. Cała lista wygląda w tym momencie tak, jak na rysunku 13.8.
Rys. 13.8. Lista połączona po wstawieniu drugiego węzła
W trakcie trzeciego wykonania pętli użytkownik wpisał wartość 8. Jest ona większa od 3, ale mniejsza od 15, więc powinna być wstawiona pomiędzy dwa istniejące już węzły. Działanie programu będzie podobne do przedstawionego w poprzednim przykładzie, z tą różnicą, że gdy dojdzie do porównania wartości danych z wartością 3, zamiast zwrócić stałą kIsLarger, funkcja zwróci wartość kIsSmaller (co oznacza, że obiekt o wartości 3 jest mniejszy od nowego obiektu, którego wartość wynosi 8).
To spowoduje, że metoda InternalNode::Insert() przejdzie do linii 119. Zamiast tworzyć nowy węzeł i wstawiać go, obiekt InternalNode po prostu przekaże nowe dane do metody Insert tego węzła, na który wskazuje akurat jego zmienna myNext. W tym przypadku zostanie więc wywołana metoda Insert() tego obiektu InternalNode, którego obiektem danych jest wartość 15. Ponownie odbywa się porównanie i tworzony jest nowy obiekt InternalNode. Będzie wskazywał on na węzeł, którego wartością danych jest 15, zaś jego adres zostanie przekazany wstecz do węzła, którego wartością danych jest 3 (w linii 119.). Spowoduje to, że nowy węzeł zostanie wstawiony we właściwe miejsce na liście. Jeśli to możliwe, powinieneś prześledzić w swoim debuggerze proces wstawiania kolejnych węzłów. Powinieneś zobaczyć, jak te metody wzajemnie się wywołują i odpowiednio dostosowują wskaźniki.
Czego się nauczyłaś, Dorotko? „Jeśli kiedykolwiek pójdę za głosem serca, nie wyjdę poza swoje podwórko”. Nie ma to jak w domu i nie ma to jak programowanie proceduralne. W programowaniu proceduralnym metoda kontrolująca sprawdza dane i wywołuje funkcje. W metodzie obiektowej każdy obiekt ma swoje ściśle określone zadanie. Lista połączona odpowiada za zarządzanie węzłem głowy. Węzeł głowy natychmiast przekazuje nowe dane do następnego wskazywanego przez siebie węzła, bez względu na to, czym jest ten węzeł. Węzeł ogona tworzy nowy węzeł i wstawia go do listy za każdym razem, gdy otrzyma dane. Potrafi tylko jedno: jeśli coś do niego dotrze, wstawia to tuż przed sobą. Węzły wewnętrzne są nieco bardziej skomplikowane; proszą swój istniejący obiekt o porównanie się z nowym obiektem. W zależności od wyniku tego porównania, wstawiają go przed sobą lub po prostu przekazują do następnego węzła na liście. Zauważ, że węzeł InternalNode nie ma pojęcia o sposobie przeprowadzenia porównania; należy to wyłącznie do obiektu danych. InternalNode wie jedynie, że powinien poprosić obiekt danych o dokonanie porównania w celu otrzymania jednej z trzech odpowiedzi. Na podstawie otrzymanej odpowiedzi wstawia obiekt do listy; w przeciwnym razie przekazuje go dalej, nie dbając o to, gdzie w końcu dotrze. Kto więc tu rządzi? W dobrze zaprojektowanym programie zorientowanym obiektowo nikt nie rządzi. Każdy obiekt wykonuje własną, ograniczoną pracę, zaś w ogólnym efekcie otrzymujemy sprawnie działającą maszynę.
Klasy tablic Napisanie własnej klasy tablicowejy ma wiele zalet w porównaniem z korzystaniem z tablic wbudowanych. Jako początkujący programista, możesz zabezpieczyć program przed
przepełnieniem tablicy. Możesz także wziąć pod uwagę stworzenie własnej klasy tablicowej,y dynamicznie zmieniającej rozmiar: tuż po utworzeniu mogłaby ona zawierać tylko jeden element i zwiększać rozmiar w miarę potrzeb, podczas działania programu. W przypadku, gdy zechcesz posortować lub uporządkować elementy tablicy w jakiś inny sposób, możesz wykorzystać kilka różnych przydatnych odmian tablic. Do najpopularniejszych z nich należą: •
zbiór uporządkowany (ordered collection): każdy element jest ułożony w odpowiedniej kolejności,
•
zestaw (set): każdy element występuje tylko raz,
•
słownik (dictionary): wykorzystuje on dopasowane do siebie pary, w których jedna wartość pełni rolę klucza służącego do pobierania drugiej wartości,
•
rzadka tablica (sparse array): umożliwia używanie bardzo szerokiego zakresu indeksów, ale pamięć zajmują tylko te elementy, które rzeczywiście zostały dodane do tablicy. Możesz poprosić o element SparseArray[5] lub SparseArray[200], a mimo to pamięć zostanie zaalokowana tylko dla niewielkiej ilości elementów,
•
torba (bag): nieuporządkowany zbiór, którego elementy są dodawane i zwracane w przypadkowej kolejności.
Przeciążając operator indeksu ([]), możesz zamienić listę połączoną w zbiór uporządkowany. Odrzucając duplikaty, możesz zamienić zbiór w zestaw. Jeśli każdy obiekt na liście posiada parę dopasowanych wartości, możesz użyć listy połączonej do zbudowania słownika lub rzadkiej tablicy.
Rozdział 14. Polimorfizm Z rozdziału 12. dowiedziałeś się, jak pisać funkcje wirtualne w klasach wyprowadzonych. Jest to jedna z podstawowych umiejętności potrzebnych przy posługiwaniu się polimorfizmem, czyli możliwością przypisywania — już podczas działania programu — specyficznych obiektów klas pochodnych do wskaźników wskazujących na obiekty klasy bazowej. Z tego rozdziału dowiesz się: •
czym jest dziedziczenie wielokrotne i jak z niego korzystać,
•
czym jest dziedziczenie wirtualne,
•
czym są abstrakcyjne typy danych,
•
czym są czyste funkcje wirtualne.
Problemy z pojedynczym dziedziczeniem Przypuśćmy, że od pewnego czasu pracujemy z naszymi klasami zwierząt i że podzieliliśmy hierarchię klas na ptaki (Bird) i ssaki (Mammal). Klasa Bird posiada funkcję składową Fly() (latanie). Klasa Mammal została podzielona na różne rodzaje ssaków, między innymi na klasę Horse (koń). Klasa Horse posiada funkcje składowe Whinny() (rżenie) oraz Gallop() (galopowanie). Nagle okazuje się, że potrzebujemy obiektu pegaza (Pegasus): skrzyżowania konia z ptakiem. Pegasus może latać (metoda Fly()), ale także może rżeć (Whinny()) i galopować (Gallop()). Przy dziedziczeniu pojedynczym okazuje się, że jesteśmy w kropce. Możemy uczynić z pegaza obiekt klasy Bird, ale wtedy nie będzie mógł rżeć ani galopować. Możemy zrobić z niego obiekt Horse, ale wtedy nie będzie mógł latać. Pierwszą próbą rozwiązania tego problemu może być skopiowanie metody Fly() do klasy Pegasus i wyprowadzenie tej klasy z klasy Horse. Będzie to prawidłowa operacja, przeprowadzona jednak kosztem posiadania metody Fly() w dwóch miejscach (w klasach Bird i Pegasus). Gdy zmienisz ją w jednym miejscu, musisz pamiętać o wprowadzeniu modyfikacji
także w drugim. Oczywiście, programista, który kilka miesięcy czy lat później spróbuje zmodyfikować taki kod, także musi wiedzieć o obu miejscach. Wkrótce jednak pojawia się nowy problem. Chcemy stworzyć listę obiektów typu Horse oraz listę obiektów typu Bird. Chcielibyśmy dodać obiekt klasy Pegasus do dowolnej z tych list, ale gdyby Pegasus został wyprowadzony z klasy Horse, nie moglibyśmy go dodać do listy obiektów klasy Bird. Istnieje kilka rozwiązań tego problemu. Możemy zmienić nazwę metody Gallop() na Move() (ruch), a następnie przesłonić metodę Move() w klasie Pegasus tak, aby wykonywała pracę metody Fly(). Następnie przesłonilibyśmy metodę Move() innych koni tak, aby wykonywała pracę metody Gallop(). Być może pegaz byłby inteligentny na tyle, by galopować na krótkich dystansach, a latać tylko na dłuższych:
Pegasus::Move(long distance) { if (distance > veryFar) Fly(distance); else Gallop(distance); }
To rozwiązanie posiada jednak pewne ograniczenia. Być może któregoś dnia pegaz zechce latać na krótkich dystansach lub galopować na dłuższych. Następnym rozwiązaniem mogłoby być przeniesienie metody Fly() w górę, do klasy Horse, co zostało pokazane na listingu 14.1. Problem jednak polega na tym, iż zwykłe konie nie potrafią latać, więc w przypadku koni innych niż pegaz, ta metoda nie będzie nic robić. Listing 14.1. Gdyby konie umiały latać... 0:
// Listing 14.1. Gdyby konie umiały latać...
1:
// Przeniesienie metody Fly() do klasy Horse
2: 3:
#include
4:
using namespace std;
5: 6:
class Horse
7:
{
8:
public:
9: 10: 11: 12:
void Gallop(){ cout << "Galopuje...\n"; } virtual void Fly() { cout << "Konie nie potrafia latac.\n" ; } private: int itsAge;
13:
};
14: 15:
class Pegasus : public Horse
16:
{
17:
public:
18: virtual void Fly() {cout<<"Moge latac! Moge latac! Moge latac!\n";} 19:
};
20: 21:
const int NumberHorses = 5;
22:
int main()
23:
{
24:
Horse* Ranch[NumberHorses];
25:
Horse* pHorse;
26:
int choice,i;
27:
for (i=0; i
28:
{
29:
cout << "(1)Horse (2)Pegasus: ";
30:
cin >> choice;
31:
if (choice == 2)
32:
pHorse = new Pegasus;
33:
else
34:
pHorse = new Horse;
35:
Ranch[i] = pHorse;
36:
}
37:
cout << "\n";
38:
for (i=0; i
39:
{
40:
Ranch[i]->Fly();
41:
delete Ranch[i];
42:
}
43:
return 0;
44:
}
Wynik (1)Horse (2)Pegasus: 1 (1)Horse (2)Pegasus: 2
(1)Horse (2)Pegasus: 1 (1)Horse (2)Pegasus: 2 (1)Horse (2)Pegasus: 1
Konie nie potrafia latac. Moge latac! Moge latac! Moge latac! Konie nie potrafia latac. Moge latac! Moge latac! Moge latac! Konie nie potrafia latac.
Analiza Ten program oczywiście działa, ale kosztem posiadania przez klasę Horse metody Fly(). Metoda Fly() dla klasy Horse jest zdefiniowana w linii 10. W rzeczywistej klasie mogłaby po prostu wyświetlać komunikat błędu lub po cichu zakończyć działanie. W linii 18. klasa Pegasus przesłania metodę Fly() tak, aby wykonywała właściwą pracę, w tym przypadku polegającą na wypisywaniu radosnego komunikatu. Tablica wskaźników do klasy Horse, zadeklarowana w linii 24., służy do zademonstrowania, że właściwa metoda Fly()zostaje wywołana w zależności od tego, czy został stworzony obiekt klasy Horse lub klasy Pegasus. UWAGA Pokazany tutaj przykład został bardzo okrojony, do elementów niezbędych dla zrozumienia zasad jego działania. Konstruktory, wirtualne destruktory i tak dalej, zostały usunięte w celu ułatwienia analizy kodu.
Przenoszenie w górę Przenoszenie pożądanej funkcji w górę hierarchii klas jest powszechnym rozwiązaniem tego typu problemów; powoduje jednak, że w klasie bazowej występuje wiele funkcji „nadmiarowych”. Istnieje niebezpieczeństwo, że klasa bazowa stanie się globalną przestrzenią nazw dla wszystkich funkcji, które mogłyby być użyte w klasach potomnych. Może to znacznie wpłynąć na efektywność zarządzania typami w C++ i powodować zbytni rozrost i skomplikowanie klas bazowych. Chcemy przenieść funkcjonalność w górę hierarchii, ale bez równoczesnego przenoszenia interfejsu każdej z klas. Oznacza to, że jeśli dwie klasy posiadają wspólną klasę bazową (na przykład klasy Horse i Bird pochodzą od klasy Animal) i posiadają wspólną funkcję (zarówno konie, jak i ptaki odżywiają się), powinniśmy przenieść tę cechę w górę, do klasy bazowej i stworzyć z niej funkcję wirtualną. Powinniśmy unikać przy tym przenoszenia interfejsu (tak, jak przeniesienie metody Fly() tam, gdzie nie powinno jej być) tylko w celu wywoływania danej funkcji w niektórych z klas wyprowadzonych.
Rzutowanie w dół Alternatywą dla przedstawionego wcześniej rozwiązania (nie wykluczającą korzystania z pojedynczego dziedziczenia), jest zatrzymanie metody Fly() wewnątrz klasy Pegasus i wywoływanie jej tylko wtedy, gdy wskaźnik do obiektu rzeczywiście wskazuje obiekt klasy Pegasus. Aby sposób ten mógł działać, musimy mieć możliwość zapytania wskaźnika, jaki typ faktycznie wskazuje. Nazywa się to identyfikacją typów podczas wykonywania programu (RTTI, Run Time Type Identification). Korzystanie z RTTI stało się oficjalnym elementem języka C++ dopiero od niedawna. Jeśli kompilator nie obsługuje RTTI, możemy symulować tę obsługę, umieszczając w każdej z klas metodę zwracającą jedną z wyliczeniowych stałych. Możemy następnie sprawdzać typ podczas działania programu i wywoływać metodę Fly() tylko wtedy, gdy ta metoda zwróci stałą dla typu Pegasus. UWAGA Bądź ostrożny z RTTI. Korzystanie z tego mechanizmu może być oznaką słabości projektu programu. Zamiast tego użyj funkcji wirtualnych, wzorców lub wielokrotnego dziedziczenia.
Aby móc wywołać metodę Fly(), musimy dokonać rzutowania wskaźnika, informując kompilator, że wskazywany obiekt jest obiektem typu Pegasus, a nie obiektem typu Horse. Nazywa się to rzutowaniem w dół, gdyż obiekt Horse rzutujemy w dół hierarchii, do typu bardziej wyprowadzonego. Dziś C++ już oficjalnie, choć dość niechętnie, obsługuje rzutowanie w dół za pomocą nowego operatora dynamic_cast. Oto sposób jego działania: Jeśli mamy wskaźnik do klasy bazowej, takiej jak Horse, i przypiszemy mu adres obiektu klasy wyprowadzonej, takiej jak Pegasus, możemy używać wskaźnika do klasy Horse polimorficznie. Jeśli chcemy następnie odwołać się do obiektu klasy Pegasus, tworzymy wskaźnik do tej klasy i w celu dokonania konwersji używamy operatora dynamic_cast. W czasie działania programu nastąpi sprawdzenie wskaźnika do klasy bazowej. Jeśli konwersja będzie właściwa, nowy wskaźnik do klasy Pegasus będzie poprawny. Jeśli konwersja będzie niewłaściwa (nie będzie to wskaźnik do klasy Pegasus), nowy wskaźnik będzie pusty (null). Ilustruje to listing 14.2. Listing 14.2. Rzutowanie w dół 0:
// Listing 14.2 UŜycie operatora dynamic_cast.
1:
// Using rtti
2: 3:
#include
4:
using namespace std;
5: 6:
enum TYPE { HORSE, PEGASUS };
7: 8:
class Horse
9: 10:
{ public:
11:
virtual void Gallop(){ cout << "Galopuje...\n"; }
12: 13:
private:
14: 15:
int itsAge; };
16: 17:
class Pegasus : public Horse
18:
{
19:
public:
20: 21: 22:
virtual void Fly() {cout<<"Moge latac! Moge latac! Moge latac!\n";} };
23: 24:
const int NumberHorses = 5;
25:
int main()
26:
{
27:
Horse* Ranch[NumberHorses];
28:
Horse* pHorse;
29:
int choice,i;
30:
for (i=0; i
31:
{
32:
cout << "(1)Horse (2)Pegasus: ";
33:
cin >> choice;
34:
if (choice == 2)
35:
pHorse = new Pegasus;
36:
else
37:
pHorse = new Horse;
38:
Ranch[i] = pHorse;
39:
}
40:
cout << "\n";
41:
for (i=0; i
42:
{
43:
Pegasus *pPeg = dynamic_cast< Pegasus *> (Ranch[i]);
44:
if (pPeg)
45:
pPeg->Fly();
46:
else
47:
cout << "Po prostu kon\n";
48: 49:
delete Ranch[i];
50:
}
51:
return 0;
52:
}
Wynik (1)Horse (2)Pegasus: 1 (1)Horse (2)Pegasus: 2 (1)Horse (2)Pegasus: 1 (1)Horse (2)Pegasus: 2 (1)Horse (2)Pegasus: 1
Po prostu kon Moge latac! Moge latac! Moge latac! Po prostu kon Moge latac! Moge latac! Moge latac! Po prostu kon
Analiza Ten sposób również okazał się dobry. Metoda Fly() została utrzymana poza klasą Horse i nie jest wywoływana dla obiektów typu Horse. Jednak w przypadku wywoływania jej dla obiektów klasy Pegasus, musi być stosowane rzutowanie jawne; obiekty klasy Horse nie posiadają metody Fly(), więc musimy poinformować kompilator, że wskaźnik wskazuje na klasę Pegasus. Potrzeba rzutowania obiektu klasy Pegasus jest ostrzeżeniem, że program może być źle zaprojektowany. Taki program znacznie obniża użyteczność polimorfizmu funkcji wirtualnych, gdyż podczas działania jest zależny od rzutowania obiektu do jego rzeczywistego typu. Często zadawane pytanie
Podczas kompilacji za pomocą kompilatora Visual C++ Microsoftu otrzymują ostrzeżenie: „warning C4541: 'dynamic_cast' used on polymorphic type 'class Horse' with /GR-; unpredictable behavior may result”. Co powinienem zrobić?
Odpowiedź: Jest to jeden z najbardziej kłopotliwych komunikatów o błędachów. Aby się go pozbyć, wykonaj następujące kroki:
1. W swoim projekcie wybierz polecenie Project | Settings.
2. Przejdź na zakładkę C++.
3. Z listy rozwijanej wybierz pozycję C++ Language.
4. Włącz opcję Enable Runtime Type Information (RTTI).
5. Zbuduj cały projekt ponownie.
Połączenie dwóch list Inny problem z tymipodanymi wyżej rozwiązaniami polega na tym, że zadeklarowaliśmy obiekt Pegasus jako obiekt typu Horse, więc nie możemy dodać obiektu Pegasus do listy obiektów Bird. Straciliśmy albo poprzez przeniesienieosząc metodyę Fly() w górę albo poprzez rzutowanieując wskaźnika w dół, a mimo to wciąż nie osiągnęliśmy pełnej funkcjonalności. Pojawia się jeszcze jedno, ostatnie rozwiązanie, również wykorzystujące pojedyncze dziedziczenie. Możemy umieścić metody Fly(), Whinny() oraz Gallop() w klasie bazowej wspólnej zarówno dla klas Bird, jak i Horse: w klasie Animal. Teraz, zamiast osobnej listy ptaków i listy koni, możemy mieć jedną, zunifikowaną listę zwierząt. Sposób ten działa, ale powoduje jeszcze większe przeniesienie specjalnych funkcji w górę do klas bazowych. Możemy również pozostawić te metody tam, gdzie są i rzutować w dół obiekty klas Horse, Bird i Pegasus, ale to jeszcze gorsze rozwiązanie!
TAK
NIE
Przenoś funkcjonalność w górę hierarchii dziedziczenia.
Nie przenoś interfejsów w górę hierarchii dziedziczenia.
Unikaj przełączania na podstawie typu obiektu sprawdzanego podczas działania programu — używaj metod wirtualnych, wzorców oraz wielokrotnego dziedziczenia.
Nie rzutuj wskaźników do obiektów bazowych w dół, do obiektów wyprowadzonych.
Dziedziczenie Wielokrotne Istnieje możliwość wyprowadzenia nowej klasy z więcej niż jednej klasy bazowej. Nazywa się to dziedziczeniem wielokrotnym. Aby wyprowadzić klasę z więcej niż jednej klasy bazowej, w nagłówku klasy musimy oddzielić każdą z klas bazowych przecinkami. Listing 14.3 pokazuje sposób zadeklarowania klasy Pegasus jako pochodzącej zarówno od klasy Horse, jak i klasy Bird. Następnie program dodaje obiekty klasy Pegasus do list obu typów. Listing 14.3. Dziedziczenie wielokrotne 0:
// Listing 14.3. Dziedziczenie wielokrotne.
1:
// Dziedziczenie wielokrotne
2: 3:
#include
4:
using std::cout;
5:
using std::cin;
6: 7:
class Horse
8:
{
9:
public:
10:
Horse() { cout << "Konstruktor klasy Horse... "; }
11:
virtual ~Horse() { cout << "Destruktor klasy Horse... "; }
12: 13:
virtual void Whinny() const { cout << "Ihaaa!... "; } private:
14: 15:
int itsAge; };
16: 17:
class Bird
18:
{
19:
public:
20:
Bird() { cout << "Konstruktor klasy Bird... "; }
21:
virtual ~Bird() { cout << "Destruktor klasy Bird... "; }
22:
virtual void Chirp() const { cout << "Cwir, cwir... ";
23:
virtual void Fly() const
24:
{
25:
cout << "Moge latac! Moge latac! Moge latac! ";
26:
}
27:
private:
28: 29:
int itsWeight; };
}
30: 31:
class Pegasus : public Horse, public Bird
32:
{
33:
public:
34:
void Chirp() const { Whinny(); }
35:
Pegasus() { cout << "Konstruktor klasy Pegasus... "; }
36:
~Pegasus() { cout << "Destruktor klasy Pegasus...
37:
};
38: 39:
const int MagicNumber = 2;
40:
int main()
41:
{
42:
Horse* Ranch[MagicNumber];
43:
Bird* Aviary[MagicNumber];
44:
Horse * pHorse;
45:
Bird * pBird;
46:
int choice,i;
47:
for (i=0; i
48:
{
49:
cout << "\n(1)Horse (2)Pegasus: ";
50:
cin >> choice;
51:
if (choice == 2)
52:
pHorse = new Pegasus;
53:
else
54:
pHorse = new Horse;
55:
Ranch[i] = pHorse;
56:
}
57:
for (i=0; i
58:
{
59:
cout << "\n(1)Bird (2)Pegasus: ";
60:
cin >> choice;
61:
if (choice == 2)
62:
pBird = new Pegasus;
63:
else
64:
pBird = new Bird;
65: 66:
Aviary[i] = pBird; }
"; }
67: 68:
cout << "\n";
69:
for (i=0; i
70:
{
71:
cout << "\nRanch[" << i << "]: " ;
72:
Ranch[i]->Whinny();
73:
delete Ranch[i];
74:
}
75: 76:
for (i=0; i
77:
{
78:
cout << "\nAviary[" << i << "]: " ;
79:
Aviary[i]->Chirp();
80:
Aviary[i]->Fly();
81:
delete Aviary[i];
82:
}
83:
return 0;
84:
}
Wynik (1)Horse (2)Pegasus: 1 Konstruktor klasy Horse... (1)Horse (2)Pegasus: 2 Konstruktor klasy Horse... Konstruktor klasy Bird... Konstruktor klasy Pegasus... (1)Bird (2)Pegasus: 1 Konstruktor klasy Bird... (1)Bird (2)Pegasus: 2 Konstruktor klasy Horse... Konstruktor klasy Bird... Konstruktor klasy Pegasus...
Ranch[0]: Ihaaa!... Destruktor klasy Horse... Ranch[1]: Ihaaa!... Destruktor klasy Pegasus... Bird... Destruktor klasy Horse...
Destruktor klasy
Aviary[0]: Cwir, cwir... Moge latac! Moge latac! Moge latac! Destruktor klasy Bird...
Aviary[1]: Ihaaa!... Moge latac! Moge latac! Moge latac! Destruktor klasy Pegasus... Destruktor klasy Bird... Destruktor klasy Horse...
Analiza W liniach od 7. do 15. została zadeklarowana klasa Horse. Jej konstruktor i destruktor wypisuje komunikat, zaś metoda Whinny() wypisuje komunikat Ihaaa!. W liniach od 17. do 29. została zadeklarowana klasa Bird. Oprócz konstruktora i destruktora, ta klasa posiada dwie metody: Chirp() (ćwierkanie) oraz Fly(). Obie te metody wypisują odpowiednie komunikaty. W rzeczywistym programie mogłyby na przykład uaktywniać głośnik lub wyświetlać animowane sekwencje. Na zakończenie, w liniach od 31. do 37. została zadeklarowana klasa Pegasus. Dziedziczy ona zarówno po klasie Horse, jak i klasie Bird. Klasa Pegasus przesłania metodę Chirp() tak, aby została wywołana metoda Whinny(), odziedziczona po klasie Horse. Tworzone są dwie tablice: w linii 42. tablica Ranch (ranczo) ze wskaźnikami do klasy Horse oraz w linii 43. tablica Aviary (ptaszarnia) ze wskaźnikami do klasy Bird. W liniach od 47. do 56. do tablicy Ranch są dodawane obiekty klas Horse i Pegasus. W liniach od 57. do 66. do tablicy Aviary są dodawane obiekty klas Bird i Pegasus. Wywołania metod wirtualnych zarówno dla wskaźników do obiektów klasy Bird, jak i obiektów klasy Horse, działają poprawnie także dla obiektów klasy Pegasus. Na przykład, w linii 79., elementy tablicy Aviary są używane do wywołania metody Chirp() wskazywanych przez nie obiektów. Klasa Bird deklaruje tę metodę jako wirtualną, więc dla każdego obiektu wywoływana jest właściwa funkcja. Zwróć uwagę, że za każdym razem, gdy tworzony jest obiekt Pegasus, wyniki odzwierciedlają, że tworzone są także części tego obiektu należące tak do klasy Bird, orazjak i Horse. Gdy obiekt Pegasus jest niszczony, niszczone są także części obiektu należące do klas Bird oraz Horse, a to dzięki temu, że destruktor także został zamieniony na wirtualny.
Deklarowanie dziedziczenia wielokrotnego
Deklarowanie obiektu dziedziczącego z więcej niż jednej klasy bazowej polega na umieszczeniu po nazwie tworzonej klasy dwukropka i rozdzielonej przecinkami listy klas bazowych.
Przykład 1 class Pegasus : public Horse, public Bird
Przykład 2 class Schnoodle : Public Schnauzer, public Poodle
Części obiektu z dziedziczeniem wielokrotnym Gdy w pamięci tworzony jest obiekt Pegasus, na część tego obiektu składają się obie klasy bazowe, co ilustruje rysunek 14.1.
Rys. 14.1. Obiekt klasy z wielokrotnym dziedziczeniem
W przypadku obiektów posiadających kilka klas bazowych, pojawia się kilka zagadnień. Na przykład, co się stanie, gdy dwie klasy bazowe mają dane lub funkcje wirtualne o tych samych nazwach? Jak są inicjalizowane konstruktory klas bazowych? Co się dzieje, gdy różne klasy bazowe dziedziczą z tej samej klasy? Na te pytania odpowiemy w następnych podrozdziałach i pokażemy także, jak dziedziczenie wielokrotne można wykorzystać do pracy.
Konstruktory w obiektach dziedziczonych wielokrotnie Jeśli klasa Pegasus jest wyprowadzona z klas Horse oraz Bird, a każda z nich posiada konstruktory wymagające parametrów, klasa Pegasus inicjalizuje te konstruktory po kolei. Ilustruje to listing 14.4. Listing 14.4. Wywoływanie wielu konstruktorów 0:
// Listing 14.4
1:
// Wywoływanie wielu konstruktorów
2: 3:
#include
4:
using namespace std;
5: 6:
typedef int HANDS;
7:
enum COLOR { Red, Green, Blue, Yellow, White, Black, Brown } ;
8: 9:
class Horse
10:
{
11:
public:
12:
Horse(COLOR color, HANDS height);
13:
virtual ~Horse() { cout << "Destruktor klasy Horse...\n"; }
14:
virtual void Whinny()const { cout << "Ihaaa!... "; }
15:
virtual HANDS GetHeight() const { return itsHeight; }
16: 17:
virtual COLOR GetColor() const { return itsColor; } private:
18:
HANDS itsHeight;
19:
COLOR itsColor;
20:
};
21: 22:
Horse::Horse(COLOR color, HANDS height):
23:
itsColor(color),itsHeight(height)
24:
{
25: 26:
cout << "Konstruktor klasy Horse...\n"; }
27: 28:
class Bird
29:
{
30:
public:
31:
Bird(COLOR color, bool migrates);
32:
virtual ~Bird() {cout << "Destruktor klasy Bird...\n";
33:
virtual void Chirp()const { cout << "Cwir, cwir... ";
34:
virtual void Fly()const
35:
{
36:
} }
cout << "Moge latac! Moge latac! Moge latac! ";
37:
}
38:
virtual COLOR GetColor()const { return itsColor; }
39:
virtual bool GetMigration() const { return itsMigration; }
40: 41:
private:
42:
COLOR itsColor;
43:
bool itsMigration;
44:
};
45: 46:
Bird::Bird(COLOR color, bool migrates):
47:
itsColor(color), itsMigration(migrates)
48:
{
49: 50:
cout << "Konstruktor klasy Bird...\n"; }
51: 52:
class Pegasus : public Horse, public Bird
53:
{
54:
public:
55:
void Chirp()const { Whinny(); }
56:
Pegasus(COLOR, HANDS, bool,long);
57:
~Pegasus() {cout << "Destruktor klasy Pegasus...\n";}
58:
virtual long GetNumberBelievers() const
59:
{
60:
return
61:
itsNumberBelievers;
}
62: 63:
private:
64: 65:
long itsNumberBelievers; };
66: 67:
Pegasus::Pegasus(
68:
COLOR aColor,
69:
HANDS height,
70:
bool migrates,
71:
long NumBelieve):
72:
Horse(aColor, height),
73:
Bird(aColor, migrates),
74:
itsNumberBelievers(NumBelieve)
75:
{
76:
cout << "Konstruktor klasy Pegasus...\n";
77:
}
78: 79:
int main()
80:
{
81:
Pegasus *pPeg = new Pegasus(Red, 5, true, 10);
82:
pPeg->Fly();
83:
pPeg->Whinny();
84:
cout << "\nTwoj Pegaz ma " << pPeg->GetHeight();
85:
cout << " dloni wysokosci i ";
86:
if (pPeg->GetMigration())
87:
cout << "migruje.";
88:
else
89:
cout << "nie migruje.";
90:
cout << "\nLacznie " << pPeg->GetNumberBelievers();
91:
cout << " osob wierzy ze on istnieje.\n";
92:
delete pPeg;
93:
return 0;
94:
}
Wynik Konstruktor klasy Horse... Konstruktor klasy Bird... Konstruktor klasy Pegasus... Moge latac! Moge latac! Moge latac! Ihaaa!... Twoj Pegaz ma 5 dloni wysokosci i migruje. Lacznie 10 osob wierzy ze on istnieje. Destruktor klasy Pegasus... Destruktor klasy Bird... Destruktor klasy Horse...
Analiza W liniach od 9. do 20. została zadeklarowana klasa Horse. Jej konstruktor wymaga dwóch parametrów: jednym z nich jest stała wyliczeniowa zadeklarowana w linii 7., a drugim typ zadeklarowany w linii 6. Implementacja konstruktora zawarta w liniach od 22. do 26. po prostu inicjalizuje zmienne składowe i wypisuje komunikat. W liniach od 28. do 44. jest deklarowana klasa Bird, zaś implementacja jej konstruktora znajduje się w liniach od 46. do 50. Także konstruktor tej klasy wymaga podania dwóch parametrów. Co ciekawe, konstruktor klasy Horse wymaga podania koloru (dzięki czemu możemy reprezentować konie o różnych kolorach), tak jak wymaga go konstruktor klasy Bird (co pozwala mu na reprezentowanie ptaków o różnym kolorze upierzenia). W momencie zapytania pegaza o jego kolor powstaje problem, co zresztą zobaczymy w następnym przykładzie. Sama klasa Pegasus została zadeklarowana w liniach od 52. do 56., zaś jej konstruktor znajduje się w liniach od 67. do 77. Inicjalizacja obiektu tej klasy składa się z trzech instrukcji. Po pierwsze, konstruktor klasy Horse jest inicjalizowany kolorem i wysokością. Po drugie,
konstruktor klasy Bird jest inicjalizowany kolorem i wartością logiczną. Na koniec inicjalizowana jest zmienna składowa klasy Pegasus, itsNumberBelievers (ilość wierzących). Gdy proces ten zostanie zakończony, wywoływane jest ciał konstruktora obiektu Pegasus. W funkcji main() tworzony jest wskaźnik do obiektu klasy Pegasus i zostaje on użyty do wywołania funkcji składowych klas bazowych tego obiektu.
Eliminowanie niejednoznaczności Na listingu 14.4 zarówno klasa Horse, jak i klasa Bird posiadały metodę GetColor() (pobierz kolor). Możemy poprosić obiekt Pegasus o podanie swojego koloru, ale będziemy mieli wtedy problem — klasa Pegasus dziedziczy zarówno po klasie Bird, jak i klasie Horse. Obie te klasy posiadają kolor, zaś metody odczytywania koloru tych klas mają te same nazwy i sygnatury. To powoduje niejednoznaczność, niezrozumiałą dla kompilatora. Spróbujemy rozwiązać ten problem. Jeśli napiszemy po prostu:
COLOR currentColor = pPeg->GetColor();
otrzymamy błąd kompilatora:
Member is ambiguous: 'Horse::GetColor' and 'Bird::GetColor' (składowa jest niejednoznaczna: 'Horse::GetColor' i 'Bird::GetColor')
Możemy zlikwidować tę niejednoznaczność przez jawne wywołanie funkcji, której chcemy użyć:
COLOR currentColor = pPeg->Horse::GetColor();
Za każdym razem, gdy chcemy określić klasę używanej funkcji lub danej składowej, możemy użyć pełnej kwalifikowanej nazwy, poprzedzając nazwę składowej nazwą klasy bazowej. Zwróć uwagę, że gdyby klasa Pegasus przesłoniła tę funkcję, problem pytanie o kolor zostałoby przesuniętey tak, jak powinno, do funkcji składowej klasy Pegasus:
virtual COLOR GetColor()const { return Horse::GetColor(); }
To powoduje ukrycie problemu przed klientami klasy Pegasus i ukrywa wewnątrz tej klasy wiedzę o tym, z której klasy bazowej obiekt chce odczytać swój kolor. Klient jednak w dalszym ciągu może wymusić odczytanie koloru z żądanej klasy, pisząc:
COLOR currentColor = pPeg->Bird::GetColor();
Dziedziczenie ze wspólnej klasy bazowej Co się stanie, gdy zarówno klasa Bird, jak i klasa Horse dziedziczy ze wspólnej klasy bazowej, takiej jak klasa Animal? Sytuacja wygląda podobnie do przedstawionej na rysunku 14.2.
Rys. 14.2. Wspólne klasy bazowe
Jak widać na rysunku 14.2, istnieją dwa obiekty klasy bazowej. Gdy następuje odwołanie do danej lub metody we wspólnej klasie bazowej, powstaje kolejna niejednoznaczność. Na przykład, jeśli klasa Animal deklaruje itsAge jako swoją zmienną składową i GetAge() jako swoją funkcję składową, po czymi jeśli wywołamy pPeg->GetAge(), to czy chodzi nam o wywołanie funkcji GetAge() odziedziczonej od klasy Animal poprzez klasę Horse, czy poprzez klasę Bird? Tę niejednoznaczność także musimy usunąć, tak jak ilustruje listing 14.5. Listing 14.5. Wspólne klasy bazowe 0:
// Listing 14.5
1:
// Wspólne klasy bazowe
2:
3:
#include
4:
using namespace std;
5: 6:
typedef int HANDS;
7:
enum COLOR { Red, Green, Blue, Yellow, White, Black, Brown } ;
8: 9:
class Animal
10:
{
11:
public:
// wspólna klasa dla klasy Bird i Horse
12:
Animal(int);
13:
virtual ~Animal() { cout << "Destruktor klasy Animal...\n"; }
14:
virtual int GetAge() const { return itsAge; }
15: 16:
virtual void SetAge(int age) { itsAge = age; } private:
17: 18:
int itsAge; };
19: 20:
Animal::Animal(int age):
21:
itsAge(age)
22:
{
23: 24:
cout << "Konstruktor klasy Animal...\n"; }
25: 26:
class Horse : public Animal
27:
{
28:
public:
29:
Horse(COLOR color, HANDS height, int age);
30:
virtual ~Horse() { cout << "Destruktor klasy Horse...\n"; }
31:
virtual void Whinny()const { cout << "Ihaaa!... "; }
32:
virtual HANDS GetHeight() const { return itsHeight; }
33: 34:
virtual COLOR GetColor() const { return itsColor; } protected:
35:
HANDS itsHeight;
36:
COLOR itsColor;
37:
};
38: 39:
Horse::Horse(COLOR color, HANDS height, int age):
40:
Animal(age),
41:
itsColor(color),itsHeight(height)
42:
{
43: 44:
cout << "Konstruktor klasy Horse...\n"; }
45: 46:
class Bird : public Animal
47:
{
48:
public:
49:
Bird(COLOR color, bool migrates, int age);
50:
virtual ~Bird() {cout << "Destruktor klasy Bird...\n";
51:
virtual void Chirp()const { cout << "Cwir, cwir... ";
52:
virtual void Fly()const
53:
} }
{ cout << "Moge latac! Moge latac! Moge latac! "; }
54:
virtual COLOR GetColor()const { return itsColor; }
55:
virtual bool GetMigration() const { return itsMigration; }
56:
protected:
57:
COLOR itsColor;
58:
bool itsMigration;
59:
};
60: 61:
Bird::Bird(COLOR color, bool migrates, int age):
62:
Animal(age),
63:
itsColor(color), itsMigration(migrates)
64:
{
65: 66:
cout << "Konstruktor klasy Bird...\n"; }
67: 68:
class Pegasus : public Horse, public Bird
69:
{
70:
public:
71:
void Chirp()const { Whinny(); }
72:
Pegasus(COLOR, HANDS, bool, long, int);
73:
virtual ~Pegasus() {cout << "Destruktor klasy Pegasus...\n";}
74:
virtual long GetNumberBelievers() const
75:
{ return
76:
virtual COLOR GetColor()const { return Horse::itsColor; }
itsNumberBelievers; }
77: 78:
virtual int GetAge() const { return Horse::GetAge(); } private:
79: 80:
long itsNumberBelievers; };
81: 82:
Pegasus::Pegasus(
83:
COLOR aColor,
84:
HANDS height,
85:
bool migrates,
86:
long NumBelieve,
87:
int age):
88:
Horse(aColor, height,age),
89:
Bird(aColor, migrates,age),
90:
itsNumberBelievers(NumBelieve)
91:
{
92: 93:
cout << "Konstruktor klasy Pegasus...\n"; }
94: 95:
int main()
96:
{
97:
Pegasus *pPeg = new Pegasus(Red, 5, true, 10, 2);
98:
int age = pPeg->GetAge();
99:
cout << "Ten pegaz ma " << age << " lat.\n";
100:
delete pPeg;
101:
return 0;
102:
}
Wynik Konstruktor klasy Animal... Konstruktor klasy Horse... Konstruktor klasy Animal... Konstruktor klasy Bird... Konstruktor klasy Pegasus... Ten pegaz ma 2 lat. Destruktor klasy Pegasus... Destruktor klasy Bird...
Destruktor klasy Animal... Destruktor klasy Horse... Destruktor klasy Animal...
Analiza Na tym listingu występuje kilka ciekawych elementów. W liniach od 9. do 18. została zadeklarowana klasa Animal. Klasa ta posiada własną zmienną składową, itsAge, oraz dwa akcesory przeznaczone do jej odczytywania i ustawiania: GetAge() i SetAge(). W linii 26. rozpoczyna się deklaracja klasy Horse, wyprowadzonej z klasy Animal. Obecnie konstruktor klasy Horse posiada trzeci parametr, age (wiek), który przekazuje do swojej klasy bazowej. Zwróćmy uwagę, że klasa Horse nie przesłania metody GetAge(), lecz po prostu ją dziedziczy. W linii 46. rozpoczyna się deklaracja klasy Bird, także wyprowadzonej z klasy Animal. Jej konstruktor także otrzymuje wiek i używa go do inicjalizacji swojej klasy bazowej. Oprócz tego, także w tej klasie nie jest przesłaniana metoda GetAge(). Klasa Pegasus dziedziczy po klasach Bird i Horse, więc w swoim łańcuchu dziedziczenia posiada dwie klasy Animal. Gdybyśmy zechcieli wywołać funkcję GetAge() dla obiektu klasy Pegasus, musielibyśmy usunąć niejednoznaczność, czyli użyć pełnej kwalifikowanej nazwy metody, której chcielibyśmy użyć. Problem ten zostaje rozwiązany w linii 77., w której klasa Pegasus przesłania metodę GetAge() tak, aby nie robiła niczego poza przekazaniem wywołania w górę, do którejś z metod w klasach bazowych. Przekazywanie w górę odbywa się z dwóch powodów: albo w celu rozwiązania niejednoznaczności wywołania metody klasy bazowej, tak jak w tym przypadku, albo w celu wykonania pewnej pracy, a następnie pozwolenia, by klasa bazowa wykonała dodatkową pracę. Czasem zechcemy wykonać pracę i dopiero potem wywołać metodę klasy bazowej, a czasem zechcemy najpierw wywołać metodę klasy bazowej, a dopiero po powrocie z niej wykonać swoją pracę. Konstruktor klasy Pegasus posiada pięć parametrów: jego kolor, wysokość (w DŁONIACH), zmienną określającą, czy migruje, ilość wierzących w niego osób oraz wiek. Konstruktor inicjalizuje część Horse obiektu kolorem, wysokością i wiekiem (w linii 89.). Część Bird inicjalizowana jest kolorem, określeniem, czy migruje oraz wiekiem (w linii 88.). Na zakończenie, w linii 90., konstruktor inicjalizuje swoją składową itsNumberBelievers. Wywołanie konstruktora klasy Horse w linii 88. powoduje wywołanie jego implementacji zawartej w linii 39. Konstruktor klasy Horse używa parametru age do zainicjalizowania części Animal obiektu klasy Pagasus. Następnie inicjalizuje dwie składowe klasy Horse — itsColor oraz itsHeight. Wywołanie konstruktora klasy Bird w linii 89. wywołuje jego implementację zawartą w linii 61. Także w tym przypadku parametr age jest używany do zainicjalizowania części Animal obiektu. Zwróć uwagę, że parametr koloru przekazany konstruktorowi klasy Pegasus jest używany do zainicjalizowania zmiennych składowych zarówno w klasie Bird, jak i klasie Horse. Zauważ
także. że parametr age jest używany do zainicjalizowania wieku (zmiennej itsAge) w klasie Animal, otrzymanej od klasy Bird oraz w klasie Animal, otrzymanej od klasy Horse.
Dziedziczenie wirtualne Na listingu 14.5 pokazano, jak klasa Pegasus musiała usuwać pewne niejednoznaczności związane z tym, która z bazowych klas Animal miała być użyta. W większości przypadków taka decyzja może być dowolna — w końcu klasy Horse i Bird mają tę samą klasę bazową. Istnieje możliwość poinformowania C++, że nie chcemy mieć dwóch kopii wspólnej klasy bazowej, tak jak widzieliśmy na rysunku 14.2, ale że chcemy mieć pojedynczą wspólną klasę bazową, tak jak pokazano na rysunku 14.3.
Rys. 14.3. Dziedziczenie wirtualne
Osiągniemy to, czyniąc z klasy Animal wirtualną klasę bazową zarówno dla klasy Horse, jak i klasy Bird. Klasa Animal nie ulega żadnej zmianie. W deklaracjach klas Horse i Bird przed nazwą klasy Animal dodawane jest jedynie słowo kluczowe virtual. Jednak klasa Pegasus podlega już większym modyfikacjom. Normalnie konstruktor klasy inicjalizuje tylko swoje własne zmienne i swoją klasę bazową. Klasy bazowe dziedziczone wirtualnie są jednak wyjątkiem. Są one inicjalizowane przez swoje najbardziej wyprowadzone klasy. Tak więc klasa Animal nie będzie inicjalizowana przez klasy
Horse i Bird, ale przez klasę Pegasus. Klasy Horse i Bird muszą w swoich konstruktorach inicjalizować klasę Animal, ale w przypadku tworzenia obiektu klasy Pegasus te inicjalizacje są ignorowane.
Listing 14.6 zawiera zmodyfikowaną wersję listingu 14.5, wykorzystującą zalety dziedziczenia wirtualnego. Listing 14.6. Przykład użycia dziedziczenia wirtualnego 0:
// Listing 14.6
1:
// Dziedziczenie wirtualne
2:
#include
3:
using namespace std;
4: 5:
typedef int HANDS;
6:
enum COLOR { Red, Green, Blue, Yellow, White, Black, Brown } ;
7: 8:
class Animal
9:
{
10:
// wspólna klasa dla klasy Bird i Horse
public:
11:
Animal(int);
12:
virtual ~Animal() { cout << "Destruktor klasy Animal...\n"; }
13:
virtual int GetAge() const { return itsAge; }
14:
virtual void SetAge(int age) { itsAge = age; }
15:
private:
16: 17:
int itsAge; };
18: 19:
Animal::Animal(int age):
20:
itsAge(age)
21:
{
22: 23:
cout << "Konstruktor klasy Animal...\n"; }
24: 25:
class Horse : virtual public Animal
26:
{
27:
public:
28:
Horse(COLOR color, HANDS height, int age);
29:
virtual ~Horse() { cout << "Destruktor klasy Horse...\n"; }
30:
virtual void Whinny()const { cout << "Ihaaa!... "; }
31:
virtual HANDS GetHeight() const { return itsHeight; }
32:
virtual COLOR GetColor() const { return itsColor; }
33:
protected:
34:
HANDS itsHeight;
35: 36:
COLOR itsColor; };
37: 38:
Horse::Horse(COLOR color, HANDS height, int age):
39:
Animal(age),
40:
itsColor(color),itsHeight(height)
41:
{
42: 43:
cout << "Konstruktor klasy Horse...\n"; }
44: 45:
class Bird : virtual public Animal
46:
{
47:
public:
48:
Bird(COLOR color, bool migrates, int age);
49:
virtual ~Bird() {cout << "Destruktor klasy Bird...\n";
50:
virtual void Chirp()const { cout << "Cwir, cwir... ";
51:
virtual void Fly()const
52:
} }
{ cout << "Moge latac! Moge latac! Moge latac! "; }
53:
virtual COLOR GetColor()const { return itsColor; }
54:
virtual bool GetMigration() const { return itsMigration; }
55:
protected:
56:
COLOR itsColor;
57:
bool itsMigration;
58:
};
59: 60:
Bird::Bird(COLOR color, bool migrates, int age):
61:
Animal(age),
62:
itsColor(color), itsMigration(migrates)
63:
{
64: 65:
cout << "Konstruktor klasy Bird...\n"; }
66: 67:
class Pegasus : public Horse, public Bird
68:
{
69:
public:
70:
void Chirp()const { Whinny(); }
71:
Pegasus(COLOR, HANDS, bool, long, int);
72:
virtual ~Pegasus() {cout << "Destruktor klasy Pegasus...\n";}
73:
virtual long GetNumberBelievers() const
74:
{ return
75: 76:
virtual COLOR GetColor()const { return Horse::itsColor; } private:
77: 78:
itsNumberBelievers; }
long itsNumberBelievers; };
79: 80:
Pegasus::Pegasus(
81:
COLOR aColor,
82:
HANDS height,
83:
bool migrates,
84:
long NumBelieve,
85:
int age):
86:
Horse(aColor, height,age),
87:
Bird(aColor, migrates,age),
88:
Animal(age*2),
89: 90:
itsNumberBelievers(NumBelieve) {
91: 92:
cout << "Konstruktor klasy Pegasus...\n"; }
93: 94:
int main()
95:
{
96:
Pegasus *pPeg = new Pegasus(Red, 5, true, 10, 2);
97:
int age = pPeg->GetAge();
98:
cout << "Ten pegaz ma " << age << " lat.\n";
99:
delete pPeg;
100: 101:
return 0; }
Wynik Konstruktor klasy Animal...
Konstruktor klasy Horse... Konstruktor klasy Bird... Konstruktor klasy Pegasus... Ten pegaz ma 4 lat. Destruktor klasy Pegasus... Destruktor klasy Bird... Destruktor klasy Horse... Destruktor klasy Animal...
Analiza W linii 25. klasa Horse deklaruje, że dziedziczy wirtualnie po klasie Animal., zaś w linii 45 klasa Bird zgłasza tTę samą deklarację zgłasza klasa Bird w linii 45. Zauważ, że konstruktory obu tych klas nadal inicjalizują obiekt Animal. Klasa Pegasus dziedziczy po klasach Bird i Horse, zaśi z tej racji, jako najbardziej wyprowadzona klasa, musi inicjalizować klasę Animal. Podczas inicjalizacji obiektu klasy Pegasus, wywołania konstruktoraów klasy Animal w konstruktorach klas Bird i Horse są ignorowane. Możemy to zobaczyć, gdyż do konstruktora przekazywana jest wartość 2, która w klasach Horse i Bird jestbyłaby przekazywana dalej (do Animal), natomiast w klasie Pegasus jest podwajana. Rezultat, wartość 4, został odzwierciedlony w komunikacie wypisywanym w linii 98. i widocznym w wynikach. Klasa Pegasus nie musi już usuwać niejednoznaczności wywołania metody GetAge() i może po prostu odziedziczyć tę funkcję od klasy Animal. Zauważ jednak, że klasa Pegasus musi w dalszym ciągu usuwać niejednoznaczność wywołania metody GetColor(), gdyż ta funkcja występuje w obu klasach bazowych, a nie w klasie Animal. Deklarowanie klas dla dziedziczenia wirtualnego
Aby zapewnić, że wyprowadzone klasy mają tylko jeden egzemplarz wspólnej klasy bazowej, zadeklaruj klasy pośrednie tak, aby wirtualnie dziedziczyły wirtualnie po klasie bazowej.
Przykład 1 class Horse : virtual public Animal class Bird : virtual public Animal class Pegasus : public Horse, public Bird
Przykład 2 class Schnauzer : virtual public Dog class Poodle : virtual public Dog
class Schnoodle : public Schnauzer, public Poodle
Problemy z dziedziczeniem wielokrotnym Choć dziedziczenie wielokrotne w porówniu do dziedziczenia pojedynczego posiada kilka zalet, jednak wielu programistów C++ korzysta z niego niechętnie. Tłumaczą to faktem, iż wiele kompilatorów jeszcze go nie obsługuje, że utrudnia ono debuggowanie oraz że prawie wszystko to, co można uzyskać dzięki dziedziczeniu wielokrotnemu, da się uzyskać także bez niego. Są to ważkie powody i sam powinieneś uważać, by nie komplikować niepotrzebnie swoich programów. Niektóre debuggery nie radzą sobie z dziedziczeniem wielokrotnym; zdarza się też, że wprowadzenie wielokrotnego dziedziczenia niepotrzebnie komplikuje projekt programu.
TAK
NIE
Używaj dziedziczenia wielokrotnego, jeśli nowa Nie używaj dziedziczenia wielokrotnego tam, klasa wymaga funkcji i cech pochodzących z gdzie wystarczy dziedziczenie pojedyncze. więcej niż jednej klasy bazowej. Używaj dziedziczenia wirtualnego wtedy, gdy najbardziej wyprowadzona klasa musi posiadać tylko jeden egzemplarz wspólnej klasy bazowej. Używając wirtualnych klas bazowych, inicjalizuj wspólną klasę bazową w klasie najbardziej wyprowadzonej. z klas.
Mixiny i klasy metod Jednym ze sposobów uzyskania efektu pośredniego pomiędzy dziedziczeniem wielokrotnym a pojedynczym jest użycie tak zwanych mixinów. Możemy więc wyprowadzić klasę Horse z klasy Animal oraz z klasy Displayable (możliwy do wyświetlenia). Klasa Displayable będzie posiadała jedynie kilka metod służących do wyświetlenia dowolnego obiektu na ekranie. Mixin, czyli klasa metod, jest klasą dodającązwiększającą funkcjonalność, ale nie posiadającą własnych danych lub posiadającą ich bardzo niewiele. Klasy metod są łączone (miksowane — stąd nazwa) z klasami wyprowadzanymi w taki sam sposób, jak wszystkie inne klasy: przez zadeklarowanie klasy wyprowadzonej jako klasy publicznie po nich dziedziczącej. Jedynąa różnicąa pomiędzy klasą metod a inną klasą jest to, że klasa metod zwykle nie zawiera danych. Jest to oczywiście rozróżnienie dość dowolne, które przypomina jedynie, że czasem jedyną rzeczą, jakiej potrzebujemy, jest dołączenie pewnych dodatkowych możliwości bez komplikowania klasy wyprowadzonej.
W przypadku pewnych debuggerów łatwiej jest pracować z mixinami niż z bardziej złożonymi obiektami dziedziczonymi wielokrotnie. Oprócz tego prawdopodobieństwo wystąpienia niejednoznaczności przy dostępie do danych w innych podstawowych klasach bazowych jest mniejsze. Na przykład, gdyby klasa Horse dziedziczyła po klasach Animal i Displayable, wtedy klasa Displayable nie zawierałaby żadnych danych. Klasa Animal wyglądałaby tak jak zwykle, więc wszystkie dane w klasie Horse pochodziłyby z klasy Animal, zaś funkcje składowe pochodziłyby z obu klas bazowych. Określenie mixin narodziło się w pewnej lodziarni w Sommerville w stanie Massachusetts, w której z podstawowymi smakami lodów miksowano różne ciastka i słodycze. Dla odwiedzających lodziarnię programistów zorientowanych obiektowo stanowiło to dobrą metaforę, szczególnie wtedy, gdy pracowali nad obiektowo zorientowanym językiem SCOOPS.
Abstrakcyjne typy danych Często hierarchie klas tworzone są wspólnie. Na przykład, możemy stworzyć klasę Shape (kształt), a z niej wyprowadzić klasy Rectangle (prostokąt) i Circle (okrąg). Z klasy Rectangle możemy wyprowadzić klasę Square (kwadrat), stanowiącą specjalny przypadek prostokąta. W każdej z wyprowadzonych klas zostanie przesłonięta metoda Draw() (rysuj), GetArea() (pobierz obszar), i tak dalej. Listing 14.7 ilustruje podstawowy szkielet implementacji klasy Shape i wyprowadzonych z niej klas Circle i Rectangle. Listing 14.7. Klasy kształtów 0:
//Listing 14.7. Klasy kształtów
1: 2:
#include
3:
using std::cout;
4:
using std::cin;
5:
using std::endl;
6: 7:
class Shape
8:
{
9:
public:
10:
Shape(){}
11:
virtual ~Shape(){}
12:
virtual long GetArea() { return -1; } // błąd
13:
virtual long GetPerim() { return -1; }
14:
virtual void Draw() {}
15:
private:
16:
};
17: 18:
class Circle : public Shape
19:
{
20:
public:
21:
Circle(int radius):itsRadius(radius){}
22:
~Circle(){}
23:
long GetArea() { return 3 * itsRadius * itsRadius; }
24:
long GetPerim() { return 6 * itsRadius; }
25:
void Draw();
26:
private:
27:
int itsRadius;
28:
int itsCircumference;
29:
};
30: 31:
void Circle::Draw()
32:
{
33: 34:
cout << "Procedura rysowania okregu!\n"; }
35: 36: 37:
class Rectangle : public Shape
38:
{
39:
public:
40:
Rectangle(int len, int width):
41:
itsLength(len), itsWidth(width){}
42:
virtual ~Rectangle(){}
43:
virtual long GetArea() { return itsLength * itsWidth; }
44:
virtual long GetPerim() {return 2*itsLength + 2*itsWidth; }
45:
virtual int GetLength() { return itsLength; }
46:
virtual int GetWidth() { return itsWidth; }
47:
virtual void Draw();
48:
private:
49:
int itsWidth;
50:
int itsLength;
51:
};
52: 53:
void Rectangle::Draw()
54:
{
55:
for (int i = 0; i
56:
{
57:
for (int j = 0; j
58:
cout << "x ";
59: 60:
cout << "\n";
61:
}
62:
}
63: 64:
class Square : public Rectangle
65:
{
66:
public:
67:
Square(int len);
68:
Square(int len, int width);
69:
~Square(){}
70:
long GetPerim() {return 4 * GetLength();}
71:
};
72: 73:
Square::Square(int len):
74:
Rectangle(len,len)
75:
{}
76: 77:
Square::Square(int len, int width):
78:
Rectangle(len,width)
79:
{
80:
if (GetLength() != GetWidth())
81:
cout << "Blad, nie Square... Moze Rectangle??\n";
82:
}
83: 84:
int main()
85:
{
86:
int choice;
87:
bool fQuit = false;
88:
Shape * sp;
89: 90:
while ( !fQuit )
91:
{
92:
cout << "(1)Circle (2)Rectangle (3)Square (0)Wyjscie: ";
93:
cin >> choice;
94: 95:
switch (choice)
96:
{
97:
case 0:
98:
fQuit = true;
break;
99:
case 1: sp = new Circle(5);
100:
break;
101:
case 2: sp = new Rectangle(4,6);
102:
break;
103:
case 3: sp = new Square(5);
104:
break;
105:
default: cout<<"Wpisz liczbe pomiedzy 0 a 3"<
106:
continue;
107:
break;
108:
}
109:
if( !fQuit )
110:
sp->Draw();
111:
delete sp;
112:
sp = 0;
113:
cout << "\n";
114:
}
115:
return 0;
116:
}
Wynik (1)Circle (2)Rectangle (3)Square (0)Wyjscie: 2 x x x x x x x x x x x x x x x x x x x x x x x x
(1)Circle (2)Rectangle (3)Square (0)Wyjscie: 3 x x x x x x x x x x x x x x x x x x x x x x x x x
(1)Circle (2)Rectangle (3)Square (0)Wyjscie: 0
Analiza W liniach od 7. do 16. tworzona jest klasa Shape. Metody GetArea() i GetPerim() (pobierz obwód) zwracają wartość, będącą kodem błędu, zaś metoda Draw() nie robi niczego. Właściwie, co to znaczy: narysować „kształt”? Można rysować jedynie rodzaje kształtów (okręgi, prostokąty, itd.); kształt jako taki jest abstrakcją, której nie można narysować. Klasa Circle jest wyprowadzona z klasy Shape i przeysłania jej trzy metody wirtualne. Zauważ, że nie ma powodu do stosowania słowa kluczowego virtual, gdyż jest to część ich dziedziczenia. Nie zaszkodzi jednak tego uczynić, tak jak pokazano w klasie Rectangle w liniach 43., 44. oraz 47. Dobrym nawykiem jest stosowanie słowa kluczowego virtual w celu przypomnienia (jako formy dokumentacji). Klasa Square jest wyprowadzona z klasy Rectangle; została w niej przesłonięta metoda GetPerim(), zaś inne metody zostały odziedziczone od klasy Rectangle. Kłopoty mogą pojawić się, gdy klient spróbuje stworzyć egzemplarz obiektu klasy Shape, należy mu to uniemożliwić. Klasa Shape istnieje tylko po to, by dostarczać interfejsu dla wyprowadzonych z niej klas; jako taka jest abstrakcyjnym typem danych, czyli ADT (abstract data type). Abstrakcyjny typ danych reprezentuje koncepcję (taką jak kształt), a nie sam obiekt (na przykład okrąg). W C++ ADT jest zawsze klasą bazową innych klas; tworzenie egzemplarzy obiektów klas abstrakcyjnych nie jest możliwe.
Czyste funkcje wirtualne C++ obsługuje tworzenie abstrakcyjnych typów danych poprzez czyste funkcje wirtualne. Funkcja wirtualna staje się „czysta”, gdy zainicjalizujemy ją wartością zero, tak jak:
virtual void Draw() = 0;
Każda klasa, zawierająca jedną lub więcej czystych funkcji wirtualnych, staje się abstrakcyjnym typem danych i nie jest możliwe tworzenie jej obiektów. Próba stworzenia obiektu klasy abstrakcyjnej powoduje błąd kompilacji. Umieszczenie w klasie czystej funkcji wirtualnej sygnalizuje aplikacjom-klientom tej klasy dwie rzeczy:
•
aby nie tworzyły obiektów tej klasy, a jedynie wyprowadzały z niej nowe klasy;
•
aby zapewniły, że czysta funkcja wirtualna jest przesłaniana.
Każda klasa wyprowadzona z klasy abstrakcyjnej dziedziczy czystą funkcję wirtualną jako czystą, dlatego, jeśli chceaby móc tworzyć egzemplarze obiektów, musi przesłonić każdą czystą funkcję wirtualną. Zatem, gdyby klasa Rectangle dziedziczyła po klasie Shape i klasa Shape miała trzy czyste funkcje wirtualne, wtedy klasa Rectangle musiałaby przeysłonić wszystkie trzy funkcje, gdyż w przeciwnym razie sama stałaby się klasą abstrakcyjną. Listing 14.8 został przepisany tak, aby klasa Shape stała się klasą abstrakcyjną. Aby zaoszczędzić miejsca, nie została tu pokazana pozostała część listingu 14.7. Zamień deklarację klasy Shape na listingu 14.7, linie od 7. do 16., deklaracją klasy Shape z listingu 14.8, po czym uruchom program ponownie. Listing 14.8. Abstrakcyjne typy danych 0:
//Listing 14.8 Abstrakcyjne typy danych
1: 2:
class Shape
3:
{
4:
public:
5:
Shape(){}
6:
~Shape(){}
7:
virtual long GetArea() = 0; // błąd
8:
virtual long GetPerim()= 0;
9:
virtual void Draw() = 0;
10:
private:
11:
};
Wynik (1)Circle (2)Rectangle (3)Square (0)Wyjscie: 2 x x x x x x x x x x x x x x x x x x x x x x x x
(1)Circle (2)Rectangle (3)Square (0)Wyjscie: 3 x x x x x x x x x x x x x x x x x x x x x x x x x
(1)Circle (2)Rectangle (3)Square (0)Wyjscie: 0
Analiza Jak widać, działanie programu nie zmieniło się. Jedyną różnicą jest to, że teraz nie jest możliwe stworzenie obiektu klasy Shape. Abstrakcyjne typy danych
Klasa abstrakcyjna powstaje wtedy, gdy w jej deklaracji znajdzie się jedna lub więcej czystych funkcji wirtualnych. Funkcja wirtualna staje się „czysta”, gdy do jej deklaracji dopiszemy = 0.
Przykład class Shape { virtual void Draw() = 0; // czysta funkcja wirtualna };
Implementowanie czystych funkcji wirtualnych Zwykle czyste funkcje wirtualne nie są implementowane w abstrakcyjnej klasie bazowej. Ponieważ nigdy nie są tworzone obiekty tego typu, nie ma powodu do dostarczania implementacji; klasa abstrakcyjna funkcjonuje wyłącznie jako definicja interfejsu dla obiektów z niej wyprowadzanych. Czyste funkcje wirtualne mogą być jednak implementowane. Taka funkcja może być wywoływana przez klasy wyprowadzone z klasy abstrakcyjnej, na przykład w celu zapewnienia wspólnej funkcjoalności wszystkich funkcji przesłoniętych funkcji. Listing 14.9 stanowi reprodukcję listingu 14.7, w którym klasa Shape jest tym razem klasą abstrakcyjną, zawierającą implementację czystej funkcji wirtualnej Draw(). Klasa Circle przesłania funkcję Draw(), gdyż musi, ale potem przekazuje to wywołanie do klasy bazowej, w celu skorzystania z dodatkowej funkcjonalności. W tym przykładzie dodatkowa funkcjonalność polega po prostu na wypisaniu dodatkowego komunikatu, ale można sobie wyobrazić, że klasa bazowa mogłaby zawierać wspólny mechanizm rysowania, który mógłby pomóc w przygotowaniu okna używanego przez wszystkie wyprowadzone klasy. Listing 14.9. Implementowanie czystych funkcji wirtualnych 0: 1:
//Listing 14.9 Implementowanie czystych funkcji wirtualnych
2:
#include
3:
using namespace std;
4: 5:
class Shape
6:
{
7:
public:
8:
Shape(){}
9:
virtual ~Shape(){}
10:
virtual long GetArea() = 0; // błąd
11:
virtual long GetPerim()= 0;
12:
virtual void Draw() = 0;
13:
private:
14:
};
15: 16:
void Shape::Draw()
17:
{
18: 19:
cout << "Abstrakcyjny mechanizm rysowania!\n"; }
20: 21:
class Circle : public Shape
22:
{
23:
public:
24:
Circle(int radius):itsRadius(radius){}
25:
virtual ~Circle(){}
26:
long GetArea() { return 3 * itsRadius * itsRadius; }
27:
long GetPerim() { return 9 * itsRadius; }
28:
void Draw();
29:
private:
30:
int itsRadius;
31:
int itsCircumference;
32:
};
33: 34:
void Circle::Draw()
35:
{
36:
cout << "Procedura rysowania okregu!\n";
37:
Shape::Draw();
38:
}
39: 40: 41:
class Rectangle : public Shape
42:
{
43:
public:
44:
Rectangle(int len, int width):
45:
itsLength(len), itsWidth(width){}
46:
virtual ~Rectangle(){}
47:
long GetArea() { return itsLength * itsWidth; }
48:
long GetPerim() {return 2*itsLength + 2*itsWidth; }
49:
virtual int GetLength() { return itsLength; }
50:
virtual int GetWidth() { return itsWidth; }
51: 52:
void Draw(); private:
53:
int itsWidth;
54:
int itsLength;
55:
};
56: 57:
void Rectangle::Draw()
58:
{
59:
for (int i = 0; i
60:
{
61:
for (int j = 0; j
62:
cout << "x ";
63: 64:
cout << "\n";
65:
}
66:
Shape::Draw();
67:
}
68: 69: 70:
class Square : public Rectangle
71:
{
72:
public:
73:
Square(int len);
74:
Square(int len, int width);
75:
virtual ~Square(){}
76: 77:
long GetPerim() {return 4 * GetLength();} };
78: 79:
Square::Square(int len):
80:
Rectangle(len,len)
81:
{}
82: 83:
Square::Square(int len, int width):
84:
Rectangle(len,width)
85: 86:
{
87:
if (GetLength() != GetWidth())
88: 89:
cout << "Blad, nie Square... moze Rectangle??\n"; }
90: 91:
int main()
92:
{
93:
int choice;
94:
bool fQuit = false;
95:
Shape * sp;
96: 97:
while (1)
98:
{
99: 100:
cout << "(1)Circle (2)Rectangle (3)Square (0)Wyjscie: "; cin >> choice;
101: 102:
switch (choice)
103:
{
104:
case 1: sp = new Circle(5);
105: 106:
break; case 2: sp = new Rectangle(4,6);
107: 108:
break; case 3: sp = new Square (5);
109: 110:
break; default: fQuit = true;
111: 112:
break; }
113:
if (fQuit)
114:
break;
115: 116:
sp->Draw();
117:
delete sp;
118:
cout << "\n";
119:
}
120:
return 0;
121:
}
Wynik (1)Circle (2)Rectangle (3)Square (0)Wyjscie: 2 x x x x x x x x x x x x x x x x x x x x x x x x Abstrakcyjny mechanizm rysowania!
(1)Circle (2)Rectangle (3)Square (0)Wyjscie: 3 x x x x x x x x x x x x x x x x x x x x x x x x x Abstrakcyjny mechanizm rysowania!
(1)Circle (2)Rectangle (3)Square (0)Wyjscie: 0
Analiza W liniach od 5. do 14. została zadeklarowana abstrakcyjna klasa Shape, której wszystkie trzy metody użytkowe zostały zadeklarowane jako czyste funkcje wirtualne. Zwróć uwagę, że nie jest to konieczne. Gdyby którakolwiek z tych metod zostałaby zadeklarowana jako czysta, i tak cała klasa byłaby traktowana jako abstrakcyjna. Metody GetArea() oraz GetPerim() nie zostały zaimplementowane (w przeciwieństwie do metody Draw()). W klasach Circle i Rectangle metoda Draw() została przesłonięta, jednakże i w obu przypadkach, w przesłoniętych wersjach jest wywoływana także metoda klasy bazowej, w celu dodatkowego skorzystania ze wspólnej funkcjonalności.
Złożone hierarchie abstrakcji Czasem zdarza się, że wyprowadzamy klasy abstrakcyjney z innych klas abstrakcyjnych. Być może zechcemy zmienić niektóre z odziedziczonych czystych funkcji wirtualnych w zwykłe funkcje, zaś inne pozostawić jako czyste. Jeśli stworzymy klasę Animal, to wszystkie metody typu Eat() (jedzenie), Sleep() (spanie), Move() (poruszanie się) i Reproduce() (rozmnażanie) możemy wszystkie zmienić na czyste funkcje wirtualne. Być może, zechcesz Nnastępnie wyprowadzić z klasy Animal możemy na przykład wyprowadzić klasęy Mammal (ssak) lub Fish (ryba). Po dłuższej analizie hierarchii, możemy zdecydować, że każdy ssak będzie rozmnażał się w ten sam sposób, więc metodę Mammal::Reproduce() zamienimy w zwykłą funkcję, pozostawiając metody Eat(), Sleep() i Move() w postaci czystych funkcji wirtualnych. Z klasy Mammal wyprowadzimy klasę Dog (pies), w której musimy przesłonić i zaimplementować trzy pozostałe czyste funkcje wirtualne tak, aby móc tworzyć egzemplarze obiektów tej klasy. Jako projektanci klasy stwierdzamy fakt, że nie można tworzyć egzemplarzy klas Animal i Mammal, i że wszystkie obiekty klasy Mammal mogą dziedziczyć dostarczoną metodę Reproduce() bez konieczności jej przesłaniania. Technikę tę ilustruje listing 14.10; zastosowano w nim jedynie szkieletową implementację omawianych klas. Listing 14.10. Wyprowadzanie klas abstrakcyjnych z innych klas abstrakcyjnych 0:
// Listing 14.10
1:
// Wyprowadzanie klas abstrakcyjnych z innych klas abstrakcyjnych
2:
#include
3:
using namespace std;
4: 5:
enum COLOR { Red, Green, Blue, Yellow, White, Black, Brown } ;
6: 7:
class Animal // wspólna klasa bazowa dla klas Mammal i Fish
8:
{
9:
public:
10:
Animal(int);
11:
virtual ~Animal() { cout << "Destruktor klasy Animal...\n"; }
12:
virtual int GetAge() const { return itsAge; }
13:
virtual void SetAge(int age) { itsAge = age; }
14:
virtual void Sleep() const = 0;
15:
virtual void Eat() const = 0;
16:
virtual void Reproduce() const = 0;
17:
virtual void Move() const = 0;
18:
virtual void Speak() const = 0;
19:
private:
20: 21:
int itsAge; };
22: 23:
Animal::Animal(int age):
24:
itsAge(age)
25:
{
26: 27:
cout << "Konstruktor klasy Animal...\n"; }
28: 29:
class Mammal : public Animal
30:
{
31:
public:
32:
Mammal(int age):Animal(age)
33:
{ cout << "Konstruktor klasy Mammal...\n";}
34:
virtual ~Mammal() { cout << "Destruktor klasy Mammal...\n";}
35:
virtual void Reproduce() const
36: 37:
{ cout << "Rozmnazanie dla klasy Mammal...\n"; } };
38: 39:
class Fish : public Animal
40:
{
41:
public:
42:
Fish(int age):Animal(age)
43:
{ cout << "Konstruktor klasy Fish...\n";}
44:
virtual ~Fish() {cout << "Destruktor klasy Fish...\n";
45:
virtual void Sleep() const { cout << "Ryba spi...\n"; }
46:
virtual void Eat() const { cout << "Ryba zeruje...\n"; }
47:
virtual void Reproduce() const
48:
{ cout << "Ryba sklada jaja...\n"; }
49:
virtual void Move() const
50:
{ cout << "Ryba plywa...\n";
51: 52:
virtual void Speak() const { } };
53: 54:
class Horse : public Mammal
55:
{
}
}
56:
public:
57:
Horse(int age, COLOR color ):
58:
Mammal(age), itsColor(color)
59:
{ cout << "Konstruktor klasy Horse...\n"; }
60:
virtual ~Horse() { cout << "Destruktor klasy Horse...\n"; }
61:
virtual void Speak()const { cout << "Ihaaa!... \n"; }
62:
virtual COLOR GetItsColor() const { return itsColor; }
63:
virtual void Sleep() const
64:
{ cout << "Kon spi...\n"; }
65:
virtual void Eat() const { cout << "Kon sie pasie...\n"; }
66:
virtual void Move() const { cout << "Kon biegnie...\n";}
67: 68:
protected:
69: 70:
COLOR itsColor; };
71: 72:
class Dog : public Mammal
73:
{
74:
public:
75:
Dog(int age, COLOR color ):
76:
Mammal(age), itsColor(color)
77:
{ cout << "Konstruktor klasy Dog...\n"; }
78:
virtual ~Dog() { cout << "Destruktor klasy Dog...\n"; }
79:
virtual void Speak()const { cout << "Hau, hau!... \n"; }
80:
virtual void Sleep() const { cout << "Pies chrapie...\n"; }
81:
virtual void Eat() const { cout << "Pies je...\n"; }
82:
virtual void Move() const
83:
virtual void Reproduce() const
84:
{ cout << "Pies sie rozmnaza...\n"; }
85: 86:
protected:
87: 88:
COLOR itsColor; };
89: 90:
int main()
91:
{
92:
{ cout << "Pies biegnie...\n"; }
Animal *pAnimal=0;
93:
int choice;
94:
bool fQuit = false;
95: 96:
while (1)
97:
{
98:
cout << "(1)Dog (2)Horse (3)Fish (0)Quit: ";
99:
cin >> choice;
100: 101:
switch (choice)
102:
{
103:
case 1: pAnimal = new Dog(5,Brown);
104:
break;
105:
case 2: pAnimal = new Horse(4,Black);
106:
break;
107:
case 3: pAnimal = new Fish (5);
108:
break;
109:
default: fQuit = true;
110:
break;
111:
}
112:
if (fQuit)
113:
break;
114: 115:
pAnimal->Speak();
116:
pAnimal->Eat();
117:
pAnimal->Reproduce();
118:
pAnimal->Move();
119:
pAnimal->Sleep();
120:
delete pAnimal;
121:
cout << "\n";
122:
}
123: 124:
return 0; }
Wynik (1)Dog (2)Horse (3)Fish (0)Quit: 1 Konstruktor klasy Animal... Konstruktor klasy Mammal...
Konstruktor klasy Dog... Hau, hau!... Pies je... Pies sie rozmnaza... Pies biegnie... Pies chrapie... Destruktor klasy Dog... Destruktor klasy Mammal... Destruktor klasy Animal...
(1)Dog (2)Horse (3)Fish (0)Quit: 0
Analiza W liniach od 7. do 21. została zadeklarowana abstrakcyjna klasa Animal. Klasa ta posiada zwykłey wirtualney akcesory dola swojej składowej itsAge, współuzytkowanej przez wszystkie obiekty tej klasy. Oprócz tego posiada pięć czystych funkcji wirtualnych funkcji: Sleep(), Eat(), Reproduce(), Move() i Speak() (mówienie). Klasa Mammal, zadeklarowana w liniach od 29. do 37., została wyprowadzona z klasy Animal. Nie posiada ona żadnych własnych danych. Przesłania jednak funkcję Reproduce(), zapewniając wspólną formę rozmnażania dla wszystkich ssaków. W klasie Fish funkcja Reproduce() musi zostać przesłonięta, gdyż ta klasa musi dziedziczyć bezpośrednio po klasie Animal i nie może skorzystać z rozmnażania na wzór ssaków (i dobrze!). Klasy ssaków nie muszą już przesłaniać funkcji Reproduce(), ale jeśli chcą, mogą to zrobić, na przykład tak, jak klasa Dog w linii 83. Klasy Fish, Horse i Dog wszystkie przesłaniają pozostałe czyste funkcje wirtualne, dzięki czemu można tworzyć specyficzne dla nich egzemplarze ich obiektów. Znajdujący się w ciele programu wskaźnik do klasy Animal jest używany do kolejnego wskazywania obiektów różnych wyprowadzonych klas. Wywoływane są metody wirtualne i, w zależności od powiązań tworzonych dynamicznie, z właściwych klas pochodnych wywoływane są właściwe funkcje. Próba stworzenia egzemplarza klasy Animal lub Mammal spowodowałaby błąd kompilacji, gdyż obie te klasy są klasami abstrakcyjnymi.
Które typy są abstrakcyjne? Klasa Animal jest abstrakcyjna w jednym programie, a w innym nie. Co sprawiadecyduje o tym, że jakąś klasęa jestczynimy abstrakcyjnąa? Odpowiedź na to pytanie nie zależy od żadnego określonegoczynnika zewnętrznego, ale od tego, co ma sens dla danego programu. Jeśli piszesz program opisujący farmę lub ogród zoologiczny,
możesz zdecydować, by klasa Animal była klasą abstrakcyjną, a klasa Dog była klasą, której obiekty mógłbyś samodzielnie tworzyć. Z drugiej strony, gdybyś tworzył animowane schronisko dla psów, mógłbyś zdecydować, by klasa Dog była abstrakcyjnym typem danych i tworzyć jedynie egzemplarze ras psów: jamniki, teriery i tak dalej. Poziom abstrakcji zależy od tego, jak precyzyjnie chcesz rozróżniać swoje typy.
TAK
NIE
W celu zapewnienia wspólnej funkcjonalności licznym powiązanym ze sobą klasom, używaj typów abstrakcyjnych.
Nie próbuj tworzyć egzemplarzy obiektów klas abstrakcyjnych.
Przesłaniaj wszystkie czyste funkcje wirtualne. Jeśli funkcja musi zostać przesłonięta, zamień ją w czystą funkcję wirtualną.
Program podsumowujący wiadomości {uwaga korekta: to jest zawartość rozdziału „Week 2 In Review” }
W tym programie zebrano w w całość wiele omawianych w poprzednich rozdziałach zagadnień. Przedstawiona poniżej demonstracja połączonych list wykorzystuje funkcje wirtualne, czyste funkcje wirtualne, przesłanianie funkcji, polimorfizm, dziedziczenie publiczne, przeciążanie funkcji, pętle nieskończone, wskaźniki, referencje i inne elementy. Należy zwrócić uwagę, że ta lista połączona różni się od listy opisywanej wcześniej; w C++ zwykle ten sam efekt można uzyskać na kilka sposobów. Celem programu jest utworzenie listy połączonej. Węzły listy są zaprojektowane w celu przechowywania części samochodowych, takich, jakie mogłyby być używane w fabryce. Choć nie jest to ostateczna wersja programu, stanowi jednak dobry przykład dość zaawansowanej struktury danych. Kod ma prawie trzysta linii; zanim przystąpisz do przeczytania analizy przedstawionej po wynikach jego działania, spróbuj przeanalizować go samodzielnie. Listing 14.11. Program podsumowujący wiadomości 0:
// **************************************************
1:
//
2:
// Tytuł: Program podsumowujący numer 2
3:
//
4:
// Plik:
5:
//
6:
// Opis:
4eList1411
Program demonstruje tworzenie listy połączonej
7:
//
8:
// Klasy: PART - zawiera numery części oraz ewentualnie inne
9:
//
10:
//
11:
//
12:
//
13:
//
PartsList - dostarcza mechanizmu dla połączonej
14:
//
listy obiektów PartNode
15:
//
16:
//
17:
// **************************************************
informacje na ich temat
PartNode - pełni rolę węzła w liście PartsList
18: 19:
#include
20:
using namespace std;
21: 22: 23: 24:
// **************** Part ************
25: 26:
// Abstrakcyjna klasa bazowa części
27:
class Part
28:
{
29:
public:
30:
Part():itsPartNumber(1) {}
31:
Part(int PartNumber):itsPartNumber(PartNumber){}
32:
virtual ~Part(){};
33:
int GetPartNumber() const { return itsPartNumber; }
34:
virtual void Display() const =0;
35:
private:
36: 37:
// musi być przesłonięte
int itsPartNumber; };
38: 39:
// implementacja czystej funkcji wirtualnej, dzięki czemu
40:
// mogą z niej korzystać klasy pochodne
41:
void Part::Display() const
42:
{
43:
cout << "\nNumer czesci: " << itsPartNumber << endl;
44:
}
45: 46:
// **************** Część samochodu ************
47: 48:
class CarPart : public Part
49:
{
50:
public:
51:
CarPart():itsModelYear(94){}
52:
CarPart(int year, int partNumber);
53:
virtual void Display() const
54:
{
55:
Part::Display(); cout << "Rok modelu: ";
56:
cout << itsModelYear << endl;
57: 58:
} private:
59: 60:
int itsModelYear; };
61: 62:
CarPart::CarPart(int year, int partNumber):
63:
itsModelYear(year),
64:
Part(partNumber)
65:
{}
66: 67: 68:
// **************** Część samolotu ************
69: 70:
class AirPlanePart : public Part
71:
{
72:
public:
73:
AirPlanePart():itsEngineNumber(1){};
74:
AirPlanePart(int EngineNumber, int PartNumber);
75:
virtual void Display() const
76:
{
77:
Part::Display(); cout << "Nr silnika.: ";
78: 79: 80:
cout << itsEngineNumber << endl; } private:
81: 82:
int itsEngineNumber; };
83: 84:
AirPlanePart::AirPlanePart(int EngineNumber, int PartNumber):
85:
itsEngineNumber(EngineNumber),
86:
Part(PartNumber)
87:
{}
88: 89:
// **************** Węzeł części ************
90:
class PartNode
91:
{
92:
public:
93:
PartNode (Part*);
94:
~PartNode();
95:
void SetNext(PartNode * node) { itsNext = node; }
96:
PartNode * GetNext() const;
97:
Part * GetPart() const;
98:
private:
99:
Part *itsPart;
100: 101:
PartNode * itsNext; };
102: 103:
// Implementacje klasy PartNode
104: 105:
PartNode::PartNode(Part* pPart):
106:
itsPart(pPart),
107:
itsNext(0)
108:
{}
109: 110:
PartNode::~PartNode()
111:
{
112:
delete itsPart;
113:
itsPart = 0;
114:
delete itsNext;
115: 116: 117:
itsNext = 0; }
118:
// Gdy nie ma następnego węzła, zwraca NULL
119:
PartNode * PartNode::GetNext() const
120:
{
121: 122:
return itsNext; }
123: 124:
Part * PartNode::GetPart() const
125:
{
126:
if (itsPart)
127:
return itsPart;
128:
else
129: 130:
return NULL; //błąd }
131: 132:
// **************** Klasa PartList ************
133:
class PartsList
134:
{
135:
public:
136:
PartsList();
137:
~PartsList();
138:
// wymaga konstruktora kopiujacegoi i operatora porzyrównania!
139:
Part*
Find(int & position, int PartNumber)
140:
int
GetCount() const { return itsCount; }
141:
Part*
GetFirst() const;
142:
void
Insert(Part *);
143:
void
Iterate() const;
144:
Part*
operator[](int) const;
145:
private:
146:
PartNode * pHead;
147:
int itsCount;
148:
};
149: 150:
// Implementacje dla list...
151: 152:
PartsList::PartsList():
153:
pHead(0),
154:
itsCount(0)
const;
155:
{}
156: 157:
PartsList::~PartsList()
158:
{
159: 160:
delete pHead; }
161: 162:
Part*
163:
{
164:
if (pHead)
165:
return pHead->GetPart();
166:
else
167: 168:
PartsList::GetFirst() const
return NULL;
// w celu wykrycia błędu
}
169: 170:
Part *
171:
{
172:
PartsList::operator[](int offSet) const
PartNode* pNode = pHead;
173: 174:
if (!pHead)
175:
return NULL; // w celu wykrycia błędu
176: 177:
if (offSet > itsCount)
178:
return NULL; // błąd
179: 180:
for (int i=0;i
181:
pNode = pNode->GetNext();
182: 183: 184:
return
pNode->GetPart();
}
185: 186:
Part*
187:
{
PartsList::Find(int & position, int PartNumber)
188:
PartNode * pNode = 0;
189:
for (pNode = pHead, position = 0;
190:
pNode!=NULL;
191:
pNode = pNode->GetNext(), position++)
const
192:
{
193:
if (pNode->GetPart()->GetPartNumber() == PartNumber)
194:
break;
195:
}
196:
if (pNode == NULL)
197:
return NULL;
198:
else
199: 200:
return pNode->GetPart(); }
201: 202:
void PartsList::Iterate() const
203:
{
204:
if (!pHead)
205:
return;
206:
PartNode* pNode = pHead;
207:
do
208:
pNode->GetPart()->Display();
209: 210:
while (pNode = pNode->GetNext()); }
211: 212:
void PartsList::Insert(Part* pPart)
213:
{
214:
PartNode * pNode = new PartNode(pPart);
215:
PartNode * pCurrent = pHead;
216:
PartNode * pNext = 0;
217: 218:
int New =
pPart->GetPartNumber();
219:
int Next = 0;
220:
itsCount++;
221: 222:
if (!pHead)
223:
{
224:
pHead = pNode;
225:
return;
226:
}
227: 228:
// jeśli ten węzeł jest mniejszy niŜ głowa,
229:
// wtedy staje się nową głową
230:
if (pHead->GetPart()->GetPartNumber() > New)
231:
{
232:
pNode->SetNext(pHead);
233:
pHead = pNode;
234:
return;
235:
}
236: 237:
for (;;)
238:
{
239:
// jeśli nie ma następnego, dołączamy ten nowy
240:
if (!pCurrent->GetNext())
241:
{
242:
pCurrent->SetNext(pNode);
243:
return;
244:
}
245: 246:
// jeśli trafia pomiędzy bieŜący a nastepny,
247:
// wstawiamy go tuo; w przeciwnym razie bierzemy następny
248:
pNext = pCurrent->GetNext();
249:
Next = pNext->GetPart()->GetPartNumber();
250:
if (Next > New)
251:
{
252:
pCurrent->SetNext(pNode);
253:
pNode->SetNext(pNext);
254:
return;
255:
}
256:
pCurrent = pNext;
257: 258:
} }
259: 260:
int main()
261:
{
262:
PartsList pl;
263: 264:
Part * pPart = 0;
265:
int PartNumber;
266:
int value;
267:
int choice;
268: 269:
while (1)
270:
{
271:
cout << "(0)Wyjscie (1)Samochod (2)Samolot: ";
272:
cin >> choice;
273: 274:
if (!choice)
275:
break;
276: 277:
cout << "Nowy numer czesci?: ";
278:
cin >>
PartNumber;
279: 280:
if (choice == 1)
281:
{
282:
cout << "Model?: ";
283:
cin >> value;
284:
pPart = new CarPart(value,PartNumber);
285:
}
286:
else
287:
{
288:
cout << "Numer silnika?: ";
289:
cin >> value;
290:
pPart = new AirPlanePart(value,PartNumber);
291:
}
292: 293:
pl.Insert(pPart);
294:
}
295:
pl.Iterate();
296: 297:
return 0; }
Wynik (0)Wyjscie (1)Samochod (2)Samolot: 1 Nowy numer czesci?: 2837 Model?: 90
(0)Wyjscie (1)Samochod (2)Samolot: 2 Nowy numer czesci?: 378 Numer silnika?: 4938 (0)Wyjscie (1)Samochod (2)Samolot: 1 Nowy numer czesci?: 4499 Model?: 94 (0)Wyjscie (1)Samochod (2)Samolot: 1 Nowy numer czesci?: 3000 Model?: 93 (0)Wyjscie (1)Samochod (2)Samolot: 0
Numer czesci: 378 Nr silnika.: 4938
Numer czesci: 2837 Rok modelu: 90
Numer czesci: 3000 Rok modelu: 93
Numer czesci: 4499 Rok modelu: 94
Analiza Przedstawiony tu listing zawiera implementację listy połączonej, przechowującej obiekty klasy Part (część). Lista połączona jest dynamiczną strukturą danych, mogącą dostosowywać swoje rozmiary do potrzeb programu. Ta konkretna lista połączona została zaprojektowana w celu przechowywania obiektów klasy Part, przy czym klasa Part jest klasą abstrakcyjną, pełniącą rolę klasy bazowej dla wszystkich obiektów posiadających numer części. W tym przykładzie z klasy Part zostały wydzielone dwie podklasy: CarPart (część samochodowa) oraz AirPlanePart (część samolotu). Klasa Part jest deklarowana w liniach od 27. do 37. i składa się z numeru części oraz kilku akcesorów. W rzeczywistym programie klasa ta mogłaby zawierać dodatkowe informacje o częściach, na przykład na temat komponentów, z jakich się składają, ile takich części znajduje się w magazynie i tak dalej. Klasa Part jest abstrakcyjnym typem danych, wymuszonym przez czystą funkcję wirtualną Display() (wyświetl). Zauważmy, że metoda Display() posiada w liniach od 41. do 44. swoją implementację. W ten sposób została wyrażona intencja projektanta kodu; chciał on by klasy pochodne tworzyły własne
implementacje metody Display(), a jednocześnie mogły w nich korzystać z metody Display() w klasie bazowej. Dwie proste klasy pochodne, CarPart oraz AirPlanePart zostały zadeklarowane w liniach od 48. do 66.0 oraz od 70. do 88. Każda z nich zawiera przesłoniętą metodę Display(), która jednak w obu przypadkach wywołuje metodę Display() klasy bazowej. Klasa PartNode (węzeł części) pełni rolę interfejsu pomiędzy klasą Part a klasą PartsList (lista części). Zawiera ona wskaźnik do części oraz wskaźnik do następnego węzła na liście. Jej jedynymi metodami są metody przeznaczone do ustawiania i odczytywania następnego węzła listy oraz do zwracania wskazywanego przez węzeł obiektu Part. Cała tajemnica działania listy kryje się w klasie PartsList, zadeklarowanej w liniach od 133. do 148. Klasa PartsList przechowuje wskaźnik do pierwszego elementu listy (pHead) i używa go we wszystkich metodach przetwarzających tę listę. Przetworzenie listy oznacza odpytanie każdego z węzłów o następny węzeł, aż do czasu osiągnięcia węzła, którego wskaźnik itsNext wynosi NULL. Jest to implementacja częściowa; w pełni zaprojektowana lista oferowywałaby albo lepszy dostęp do swojego pierwszego i ostatniego węzła, albo dostarczałaby obiektu iteratora, pozwalającego klientom na łatwe poruszanie się po liście. Jednak klasa PartsList i tak posiada kilka interesujących metod, opisanych poniżej w kolejności alfabetycznej. Zwykle dobrze jest zachowywać taką kolejność, gdyż ułatwia ona odszukanie funkcji. Metoda Find() otrzymuje numer części oraz referencję do typu int. Jeśli zostanie znaleziona część zgodna z PartNumber (numerem części), funkcja zwraca wskaźnik do obiektu Part i wypełnia referencję pozycją tej części na liście. Jeśli szukany numer części nie zostanie znaleziony, funkcja zwraca wartość NULL i numer pozycji nie ma znaczenia. Metoda GetCount() zwraca ilość elementów na liście. Klasa PartsList przechowuje tę wartość jako swoją zmienną składową, itsCount, choć oczywiście mogłaby ją obliczać, przetwarzając listę. Metoda GetFirst() zwraca wskaźnik do pierwszego obiektu Part na liście; w przypadku, gdy lista jest pusta, zwraca wartość NULL. Metoda Insert() otrzymuje wskaźnik do obiektu klasy Part, tworzy dla niego obiekt PartNode, po czym dodaje go do listy, zgodnie z kolejnością wyznaczaną przez PartNumber. Metoda Iterate() otrzymuje wskaźnik do funkcji składowej klasy Part, która nie posiada parametrów, zwraca void i jest funkcją const. Wywołuje tę funkcję dla każdego obiektu klasy Part na liście. W naszym przykładowym programie zostaje wywołana funkcja Display(), będąca funkcją wirtualną, więc w każdym przypadku zostaje wywołana metoda odpowiedniej podklasy klasy Part. Operator[] umożliwia bezpośredni dostęp do obiektu Part , znajdującego się we wskazanym
położeniu na liście. Wykonywane jest przy tym podstawowe sprawdzanie zakresów; jeśli lista jest pusta lub podane położenie wykracza poza rozmiar listy, jako wartość błędu zostaje zwrócona wartość NULL.
Zwróć uwagę, że w rzeczywistym programie takie opisy funkcji zostałyby zapisane w deklaracji klasy. Program sterujący jest zawarty w liniach od 260. do 298. Obiekt PartsList jest tworzony w linii 263. W linii 278. użytkownik jest proszony o wybranie, czy część powinna być wprowadzana dla samochodu, czy dla samolotu. W zależności od tego wyboru tworzona jest odpowiednia część, która następnie jest wstawiana do listy w linii 294. Implementacja metody Insert() klasy PartsList znajduje się w liniach od 212. do 258. Gdy zostaje wprowadzony numer pierwszej części, 2837, zostaje stworzony obiekt CarPar o tym numerze części i modelu 90, który jest przekazywany do metody LinkedList::Insert(). W linii 214. dla tej części jest tworzony nowy obiekt PartNode, zaś zmienna New jest inicjalizowana numerem części. Zmienna itsCount klasy PartsList jest inkrementowana w linii 220. W linii 222., w teście sprawdzającym, czy pHead ma wartość NULL, otrzymujemy wartość TRUE. Ponieważ jest to pierwszy węzeł, wskaźnik pHead listy na nic jeszcze nie wskazuje. Zatem w linii 224. wskaźnik pHead jest ustawiany tak, aby wskazywał nowy węzeł, po czym następuje powrót z funkcji. Użytkownik jest proszony o wybranie kolejnej części; tym razem zostaje wybrana część do samolotu, o numerze części 378 i numerze silnika 4938. Ponownie jest wywoływana metoda PartsList::Insert(), a pNode jest ponownie inicjalizowane nowym węzłem. Statyczna zmienna składowa itsCount zostaje zwiększona do dwóch i następuje sprawdzenie wskaźnika pHead. Ponieważ ostatnim razem temu wskaźnikowi została przypisana wartość, nie zawiera on już wartości NULL i test zwraca wartość FALSE. W linii 230. numer części wskazywanej przez pHead, 2837, jest porównywany z numerem bieżącej części, 378. Ponieważ numer nowej części jest mniejszy od numeru części wskazywanej przez pHead, nowa część musi stać się nowym pierwszym elementem listy, więc test w linii 230. zwraca wartość TRUE. W linii 232. nowy węzeł jest ustawiany tak, by wskazywał na węzeł aktualnie wskazywany przez wskaźnik pHead. Zwróć uwagę, że nowy węzeł nie wskazuje na pHead, ale na węzeł wskazywany przez pHead! W linii 233. pHead jest ustawiany tak, by wskazywał na nowy węzeł. Przy trzecim wykonaniu pętli użytkownik wybrał część samochodową o numerze 4499 i modelu 94. Następuje zwiększenie licznika i tym razem numer części nie jest mniejszy od numeru wskazywanego przez pHead, więc przechodzimy do pętli for, rozpoczynającej się w linii 237. Wartością wskazywaną przez pHead jest 378. Wartością wskazywaną przez następny węzeł jest 2837. Bieżącą wartością jest 4499. Wskaźnik pCurrent wskazuje na ten sam węzeł, co pHead, więc posiada następną wartość; pCurrent wskazuje na drugi węzeł, więc test w linii 240 zwraca wartość FALSE. Wskaźnik pCurrent jest ustawiany tak, by wskazywał następny węzeł, po czym następuje ponowne wykonanie pętli. Tym razem test w linii 240. zwraca wartość TRUE. Nie ma następnego elementu, więc w linii 242. bieżący węzeł zostaje ustawiony tak, by wskazywał nowy węzeł, po czym proces wstawiania się kończy.
Za czwartym razem wprowadzona zostaje część o numerze 3000. Jej wstawianie jest bardzo podobne do przypadku opisanego powyżej, ale tym razem, gdy bieżący węzeł wskazuje numer 2837, a następny 4499, test w linii 250. zwraca wartość TRUE i nowy węzeł jest wstawiany właśnie w tej pozycji. Gdy użytkownik w końcu wybierze zero, test w linii 275. zwraca wartość TRUE i następuje wyjście z pętli while(1). Wykonanie programu przechodzi do linii 296., w której wywoływana jest funkcja Iterate(). Wykonanie przechodzi do linii 202., a w linii 208. wskaźnik PNode jest wykorzystywany do uzyskania dostępu do obiektu Part i wywołania dla niego metody Display.
Rozdział 15. Specjalne klasy i funkcje C++ oferuje kilka sposobów na ograniczenie zakresu i oddziaływania zmiennych i wskaźników. Do tej pory, dowiedzieliśmy się, jak tworzyć zmienne globalne, lokalne zmienne funkcji, wskaźniki do zmiennych oraz zmienne składowe klas. Z tego rozdziału dowiesz się: •
czym są zmienne statyczne i funkcje składowe,
•
jak używać zmiennych statycznych i statycznych funkcji składowych,
•
jak tworzyć i operować wskaźnikami do funkcji i wskaźnikami do funkcji składowych,
•
jak pracować z tablicami wskaźników do funkcji.
Statyczne dane składowe Prawdopodobnie do tej pory uważałeś dane każdego obiektu za unikalne dla tego obiektu (i nie współużytkowane pomiędzy obiektami klasy). Gdybyś miał na przykład pięć obiektów klasy Cat, każdy z nich miałby swój wiek, wagę, itp. Wiek jednego kota nie wpływa na wiek innego. Czasem zdarza się jednak, że chcemy śledzić pulę danych. Na przykład, możemy chcieć wiedzieć, ile obiektów danej klasy zostało stworzonych w programie, a także ile z nich istnieje nadal. Statyczne zmienne składowe są współużytkowane przez wszystkie egzemplarze obiektów klasy. Stanowią one kompromis pomiędzy danymi globalnymi, które są dostępne dla wszystkich części programu, a danymi składowymi, które zwykle są dostępne tylko dla konkretnego obiektu. Statyczne składowe można traktować jako należące do całej klasy, a nie tylko do pojedynczego obiektu. Zwykłae danae składowae odnosi się do pojedynczego przechowywane są po jednej dla każdego obiektu, a dana składowae statycznae odnosi się są przechowywane po jednej dla do całej klasy. Listing 15.1 deklaruje obiekt Cat, zawierający statyczną składową HowManyCats (ile kotów). Ta zmienna śledzi, ile obiektów klasy Cat zostało utworzonych. Śledzenie odbywa się poprzez inkrementację statycznej zmiennej HowManyCats w konstruktorze klasy i dekrementowanie jej w destruktorze. Listing 15.1. Statyczne dane składowe
0:
//Listing 15.1 Statyczne dane składowe
1: 2:
#include
3:
using namespace std;
4: 5:
class Cat
6:
{
7:
public:
8:
Cat(int age):itsAge(age){HowManyCats++; }
9:
virtual ~Cat() { HowManyCats--; }
10:
virtual int GetAge() { return itsAge; }
11:
virtual void SetAge(int age) { itsAge = age; }
12:
static int HowManyCats;
13: 14:
private:
15:
int itsAge;
16: 17:
};
18: 19:
int Cat::HowManyCats = 0;
20: 21:
int main()
22:
{
23:
const int MaxCats = 5; int i;
24:
Cat *CatHouse[MaxCats];
25:
for (i = 0; i
26:
CatHouse[i] = new Cat(i);
27: 28:
for (i = 0; i
29:
{
30:
cout << "Zostalo kotow: ";
31:
cout << Cat::HowManyCats;
32:
cout << "\n";
33:
cout << "Usuwamy kota, ktory ma ";
34:
cout << CatHouse[i]->GetAge();
35:
cout << " lat\n";
36:
delete CatHouse[i];
37:
CatHouse[i] = 0;
38:
}
39:
return 0;
40:
}
Wynik Zostalo kotow: 5 Usuwamy kota, ktory ma 0 lat Zostalo kotow: 4 Usuwamy kota, ktory ma 1 lat Zostalo kotow: 3 Usuwamy kota, ktory ma 2 lat Zostalo kotow: 2 Usuwamy kota, ktory ma 3 lat Zostalo kotow: 1 Usuwamy kota, ktory ma 4 lat
Analiza W liniach od 5. do 17. została zadeklarowana uproszczona klasa Cat. W linii 12. zmienna HowManyCats została zadeklarowana jako statyczna zmienna składowa typu int. Sama deklaracja zmiennej HowManyCats nie definiuje wartości całkowitej i nie jest dla niej rezerwowane miejsce w pamięci. W odróżnieniu od zwykłych zmiennych składowych, w momencie tworzenia egzemplarzy obiektów klasy Cat, nie jest tworzone miejsce dla tej zmiennej statycznej, gdyż nie znajduje się ona w obiekcie. W związku z tym musieliśmy zdefiniować i zainicjalizować tę zmienną w linii 19.. Programistom bardzo często zdarza się zapomnieć o zdefiniowaniu statycznych zmiennych składowych klasy. Nie pozwól, by przydarzało się to tobie! Oczywiście, gdy się przydarzy, linker zgłosi komunikat błędu, informujący o niezdefiniowanym symbolu, na przykład taki jak poniższy:
undefined symbol Cat::HowManyCats (niezdefiniowany symbol Cat::HowManyCats)
Nie musimy definiować zmiennej itsAge, gdyż nie jest statyczną zmienną składową i w związku z tym jest definiowana za każdym razem, gdy tworzymy obiekt klasy Cat (w tym przypadku w linii 26. programu). Konstruktor klasy Cat w linii 8. inkrementuje statyczną zmienną składową. Destruktor (zawarty w linii 9.) dekrementuje ją. Zatem zmienna HowManyCats przez cały czas zawiera właściwą ilość obiektów Cat, które zostały utworzone i jeszcze nie zniszczone.
Program sterujący. zawarty w liniach od 21. do 40., tworzy pięć egzemplarzy obiektów klasy Cat i umieszcza je w tablicypamięci. Powoduje to pięciokrotne wywołanie deskonstruktora, w związku z czym następuje pięciokrotne inkrementowanie zmiennej HowManyCats od jej początkowej wartości 0. Następnie program w pętli przechodzi przez wszystkie pięć elementów tablicy i przed usunięciem kolejnego bieżącego wskaźnika do obiektu Cat wypisuje wartość zmiennej HowManyCats. Wydruk pokazuje to, że wartością początkową jest 5 (gdyż zostało skonstruowanych pięć obiektów) i że przy każdym wykonaniu pętli pozostaje o jeden obiekt Cat mniej. Zwróć uwagę, że zmienna HowManyCats jest publiczna i jest używana bezpośrednio w funkcji main(). Nie ma powodu do udostępniania zmiennej składowej w ten sposób. Najlepszą metodą jest uczynienie z niej prywatnej składowej i udostępnienie publicznego akcesora (o ile ma być ona dostępna wyłącznie poprzez egzemplarze klasy Cat). Z drugiej strony, gdybyśmy chcieli korzystać z tej danej bezpośrednio, niekoniecznie posiadając obiekt klasy Cat, mamy do wyboru dwie opcje: możemy zadeklarować tę zmienną jako publiczną, tak jak pokazano na listingu 15.2, albo dostarczyć akcesor w postaci statycznej funkcji składowej, co zostanie omówione w dalszej części rozdziału. Listing 15.2. Dostęp do statycznych danych składowych bez obiektu 0:
//Listing 15.2 Statyczne dane składowe
1: 2:
#include
3:
using namespace std;
4: 5:
class Cat
6:
{
7:
public:
8:
Cat(int age):itsAge(age){HowManyCats++; }
9:
virtual ~Cat() { HowManyCats--; }
10:
virtual int GetAge() { return itsAge; }
11:
virtual void SetAge(int age) { itsAge = age; }
12:
static int HowManyCats;
13: 14:
private:
15:
int itsAge;
16: 17:
};
18: 19:
int Cat::HowManyCats = 0;
20: 21:
void TelepathicFunction();
22: 23:
int main()
24:
{
25:
const int MaxCats = 5; int i;
26:
Cat *CatHouse[MaxCats];
27:
for (i = 0; i
28:
{
29:
CatHouse[i] = new Cat(i);
30:
TelepathicFunction();
31:
}
32: 33:
for ( i = 0; i
34:
{
35:
delete CatHouse[i];
36:
TelepathicFunction();
37:
}
38:
return 0;
39:
}
40: 41:
void TelepathicFunction()
42:
{
43:
cout << "Zostalo jeszcze zywych kotow: ";
44:
cout << Cat::HowManyCats << "\n";
45:
}
Wynik Zostalo jeszcze zywych kotow: 1 Zostalo jeszcze zywych kotow: 2 Zostalo jeszcze zywych kotow: 3 Zostalo jeszcze zywych kotow: 4 Zostalo jeszcze zywych kotow: 5 Zostalo jeszcze zywych kotow: 4 Zostalo jeszcze zywych kotow: 3 Zostalo jeszcze zywych kotow: 2 Zostalo jeszcze zywych kotow: 1 Zostalo jeszcze zywych kotow: 0
Analiza Listing 15.2 przypomina listing 15.1, z wyjątkiem nowej funkcji, TelepathicFunction() (funkcja telepatyczna). Ta funkcja nie tworzy obiektu Cat ani nie otrzymuje obiektu Cat jako parametru, a mimo to może odwoływać się do zmiennej składowej HowManyCats. Należy pamiętać, że ta zmienna składowa nie należy do żadnego konkretnego obiektu; znajduje się w klasie i, o ile jest publiczna, może być wykorzystywana przez każdą funkcję w programie. Alternatywą dla tej zmiennej publicznej może być zmienna prywatna. Gdy skorzystamy z niej, możemy to uczynić poprzez funkcję składową, ale wtedy musimy posiadać obiekt tej klasy. Takie rozwiązanie przedstawia listing 15.3. Alternatywne rozwiązanie, z wykorzystaniem statycznej funkcji składowej, zostanie omówione bezpośrednio po analizie listingu 15.3. Listing 15.3. Dostęp do statycznych składowych za pomocą zwykłych funkcji składowych 0:
//Listing 15.3 prywatne statyczne dane składowe
1: 2:
#include
3:
using std::cout;
4: 5:
class Cat
6:
{
7:
public:
8:
Cat(int age):itsAge(age){HowManyCats++; }
9:
virtual ~Cat() { HowManyCats--; }
10:
virtual int GetAge() { return itsAge; }
11:
virtual void SetAge(int age) { itsAge = age; }
12:
virtual int GetHowMany() { return HowManyCats; }
13: 14: 15:
private:
16:
int itsAge;
17: 18:
static int HowManyCats; };
19: 20:
int Cat::HowManyCats = 0;
21: 22:
int main()
23:
{
24:
const int MaxCats = 5; int i;
25:
Cat *CatHouse[MaxCats];
26:
for (i = 0; i
27:
CatHouse[i] = new Cat(i);
28: 29:
for (i = 0; i
30:
{
31:
cout << "Zostalo jeszcze ";
32:
cout << CatHouse[i]->GetHowMany();
33:
cout << " kotow!\n";
34:
cout << "Usuwamy kota, ktory ma ";
35:
cout << CatHouse[i]->GetAge()+2;
36:
cout << " lat\n";
37:
delete CatHouse[i];
38:
CatHouse[i] = 0;
39:
}
40:
return 0;
41:
}
Wynik Zostalo jeszcze 5 kotow! Usuwamy kota, ktory ma 2 lat Zostalo jeszcze 4 kotow! Usuwamy kota, ktory ma 3 lat Zostalo jeszcze 3 kotow! Usuwamy kota, ktory ma 4 lat Zostalo jeszcze 2 kotow! Usuwamy kota, ktory ma 5 lat Zostalo jeszcze 1 kotow! Usuwamy kota, ktory ma 6 lat
Analiza W linii 17. statyczna zmienna składowa HowManyCats została zadeklarowana jako składowa prywatna. Nie możemy więc odwoływać się do niej z funkcji innych niż składowe, na przykład takich, jak TelepathicFunction() z poprzedniego listingu. Jednak mimo, iż zmienna HowManyCats jest statyczna, nadal znajduje się w zakresie klasy. Może się do niej odwoływać dowolna funkcja składowa klasy, na przykład GetHowMany(), podobnie jak do wszystkich innych danych składowych. Jednak, aby zewnętrzna funkcja mogła wywołać metodę GetHowMany(), musi posiadać obiekt klasy Cat.
TAK
NIE
W celu współużytkowania danych pomiędzy wszystkimi egzemplarzami klasy używaj statycznych zmiennych składowych
Nie używaj statycznych zmiennych składowych do przechowywania danych należących do pojedynczego obiektu. Statyczne dane składowe są współużytkowane przez wszystkie obiekty klasy.
Jeśli chcesz ograniczyć dostęp do statycznych zmiennych składowych, uczyń je składowymi prywatnymi lub chronionymi.
Statyczne funkcje składowe Statyczne funkcje składowe działają podobnie do statycznych zmiennych składowych: istnieją nie w obiekcie, ale w zakresie klasy. W związku z tym mogą być wywoływane bez posiadania obiektu swojej klasy, co ilustruje listing 15.4. Listing 15.4. Statyczne funkcje składowe 0:
//Listing 15.4 statyczne funkcje składowe
1: 2:
#include
3: 4:
class Cat
5:
{
6:
public:
7:
Cat(int age):itsAge(age){HowManyCats++; }
8:
virtual ~Cat() { HowManyCats--; }
9:
virtual int GetAge() { return itsAge; }
10:
virtual void SetAge(int age) { itsAge = age; }
11:
static int GetHowMany() { return HowManyCats; }
12:
private:
13:
int itsAge;
14:
static int HowManyCats;
15:
};
16: 17:
int Cat::HowManyCats = 0;
18: 19:
void TelepathicFunction();
20: 21:
int main()
22:
{
23:
const int MaxCats = 5;
24:
Cat *CatHouse[MaxCats]; int i;
25:
for (i = 0; i
26:
{
27:
CatHouse[i] = new Cat(i);
28:
TelepathicFunction();
29:
}
30: 31:
for ( i = 0; i
32:
{
33:
delete CatHouse[i];
34:
TelepathicFunction();
35:
}
36:
return 0;
37:
}
38: 39:
void TelepathicFunction()
40:
{
41: std::cout<<"Zostalo jeszcze "<< Cat::GetHowMany()<<" zywych kotow!\n"; 42:
}
Wynik Zostalo jeszcze 1 zywych kotow! Zostalo jeszcze 2 zywych kotow! Zostalo jeszcze 3 zywych kotow! Zostalo jeszcze 4 zywych kotow! Zostalo jeszcze 5 zywych kotow! Zostalo jeszcze 4 zywych kotow! Zostalo jeszcze 3 zywych kotow! Zostalo jeszcze 2 zywych kotow! Zostalo jeszcze 1 zywych kotow! Zostalo jeszcze 0 zywych kotow!
Analiza W linii 14., w klasie Cat została zadeklarowana statyczna prywatna zmienna składowa HowManyCats. Publiczny akcesor, GetHowMany(), został w linii 11. zadeklarowany zarówno jako metoda publiczna, jak i statyczna.
Ponieważ metoda GetHowMany() jest publiczna, może być używana w każdej funkcji zewnętrznej, a ponieważ jest też statyczna, może być wywołana bez obiektu klasy Cat. Zatem, w linii 41., funkcja TelepathicFunction() jest w stanie użyć publicznego statycznego akcesora, nie posiadając dostępu do obiektu klasy Cat. Oczywiście, moglibyśmy wywołać funkcję GetHowMany() dla obiektów klasy Cat dostępnych w funkcji main(), tak samo jak w przypadku innych akcesorów. UWAGA Statyczne funkcje składowe nie posiadają wskaźnika this. W związku z tym nie mogą być deklarowane jako const. Ponadto, ponieważ zmienne składowe są dostępne w funkcjach składowych poprzez wskaźnik this, statyczne funkcje składowe nie mogą korzystać z żadnych zmiennych składowych, nie będących składowymi statycznymi!
Statyczne funkcje składowe
Możesz korzystać ze statycznych funkcji składowych, wywołując je dla obiektu klasy, tak samo jak w przypadku innych funkcji składowych, możesz też wywoływać je bez obiektu klasy, stosując pełną kwalifikowaną nazwę klasy i funkcji.
Przykład class Cat { public: static int GetHowMany() { return HowManyCats; } private: static int HowManyCats; }; int Cat::HowManyCats = 0; int main() { int howMany; Cat theCat;
// definiuje obiekt klasy Cat
howMany = theCat.GetHowMany(); // dostęp poprzez obiekt howMany = Cat::GetHowMany(); }
// dostęp bez obiektu
Wskaźniki do funkcji Nazwa tablicy jest wskaźnikiem const do pierwszego elementu tej tablicy, a nazwa funkcji jest wskaźnikiem const do funkcji. Istnieje możliwość zadeklarowania zmiennej wskaźnikowej wskazującej na funkcję i wywoływania tej funkcji za pomocą tej zmiennej. Może to być bardzo przydatne; umożliwia tworzenie programów, które decydują (na podstawie wprowadzonych danych) o tym, która funkcja ma zostać wywołana. Jedyny problem ze wskaźnikami do funkcji polega na zrozumieniu typu wskazywanego obiektu. Wskaźnik do int wskazuje zmienną całkowitą, zaś wskaźnik do funkcji musi wskazywać funkcję o określonym zwracanym typie i sygnaturze. W deklaracji:
long (* funcPtr) (int);
funcPtr jest deklarowane jako wskaźnik (zwróć uwagę na gwiazdkę przed nazwą), wskazujący funkcję otrzymującą parametr typu int i zwracającą wartość typu long. Nawiasy wokół * funcPtr są konieczne, gdyż nawiasy wokół int wiążą ściślejbardziej; tj. mają priorytet nad operatorem dostępu pośredniego (*). Bez zastosowania pierwszych nawiasów zostałaby zadeklarowana funkcja otrzymująca parametr typu int i zwracająca wskaźnik do typu long.
(Pamiętaj, że spacje nie mają tu znaczenia.) Przyjrzyjmy się dwóm poniższym deklaracjom:
long * Function (int); long (* funcPtr) (int);
Pierwsza, Function(), jest funkcją otrzymującą parametr typu int i zwracającą wskaźnik do zmiennej typu long. Druga, funcPtr, jest wskaźnikiem do funkcji otrzymującej wartość typu int i zwracającej zmienną typu long. Deklaracja wskaźnika do funkcji zawsze zawiera zwracany typ oraz, w nawiasach, typ parametrów (o ile występują). Listing 15.5 ilustruje deklarowanie i używanie wskaźników do funkcji. Listing 15.5. Wskaźniki do funkcji 0:
// Listing 15.5 UŜycie wskaźników do funkcji.
1: 2:
#include
3:
using namespace std;
4: 5:
void Square (int&,int&);
// do kwadratu
6:
void Cube (int&, int&);
// do trzeciej potegi
7:
void Swap (int&, int &);
8:
void GetVals(int&, int&); // zmiana
// zamiana
9:
void PrintVals(int, int);
10: 11:
int main()
12:
{
13:
void (* pFunc) (int &, int &);
14:
bool fQuit = false;
15: 16:
int valOne=1, valTwo=2;
17:
int choice;
18:
while (fQuit == false)
19:
{
20: cout << "(0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: "; 21:
cin >> choice;
22:
switch (choice)
23:
{
24:
case 1: pFunc = GetVals; break;
25:
case 2: pFunc = Square; break;
26:
case 3: pFunc = Cube; break;
27:
case 4: pFunc = Swap; break;
28:
default : fQuit = true; break;
29:
}
30: 31:
if (fQuit)
32:
break;
33: 34:
PrintVals(valOne, valTwo);
35:
pFunc(valOne, valTwo);
36:
PrintVals(valOne, valTwo);
37:
}
38: 39:
return 0; }
40: 41:
void PrintVals(int x, int y)
42:
{
43:
cout << "x: " << x << " y: " << y << endl;
44:
}
45: 46:
void Square (int & rX, int & rY)
47:
{
48:
rX *= rX;
49:
rY *= rY;
50:
}
51: 52:
void Cube (int & rX, int & rY)
53:
{
54:
int tmp;
55: 56:
tmp = rX;
57:
rX *= rX;
58:
rX = rX * tmp;
59: 60:
tmp = rY;
61:
rY *= rY;
62:
rY = rY * tmp;
63:
}
64: 65:
void Swap(int & rX, int & rY)
66:
{
67:
int temp;
68:
temp = rX;
69:
rX = rY;
70:
rY = temp;
71:
}
72: 73:
void GetVals (int & rValOne, int & rValTwo)
74:
{
75:
cout << "Nowa wartosc dla ValOne: ";
76:
cin >> rValOne;
77:
cout << "Nowa wartosc dla ValTwo: ";
78: 79:
cin >> rValTwo; }
Wynik (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 1 x: 1 y: 2 Nowa wartosc dla ValOne: 2 Nowa wartosc dla ValTwo: 3 x: 2 y: 3 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 3 x: 2 y: 3 x: 8 y: 27 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 2 x: 8 y: 27 x: 64 y: 729 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 4 x: 64 y: 729 x: 729 y: 64 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 0
Analiza W liniach od 5. do 8. zostały zadeklarowane cztery funkcje, wszystkie o tych samych zwracanych typach i sygnaturach, zwracające void i przyjmujące referencje do dwóch zmiennych całkowitych. W linii 13. zmienna pFunc jest deklarowana jako wskaźnik do funkcji zwracającej void i przyjmującej referencje do dwóch zmiennych całkowitych. Wskaźnik pFunc może więc wskazywać na dowolną z zadeklarowanych wcześniej czterech funkcji. Użytkownik ma możliwość wyboru funkcji, która powinna zostać wybrana, a wskaźnik pFunc jest ustawiany zgodnie z tym wyborem. W liniach od 34. do 36. są wypisywane bieżące wartości obu zmiennych całkowitych, wywoływana jest aktualnie przypisana funkcja, po czym ponownie wypisywane są wartości obu zmiennych.
Wskaźnik do funkcji
Wskaźnik do funkcji jest wywoływany tak samo jak funkcja, którą wskazuje, z wyjątkiem tego, że zamiast nazwy funkcji, używana jest nazwa wskaźnika do tej funkcji.
Przypisanie wskaźnikowi konkretnej funkcji odbywa się przez przypisanie mu nazwy funkcji bez nawiasów. Nazwa funkcji jest wskaźnikiem const do samej funkcji. Wskaźnika do funkcji można więc używać tak samo, jak nazwy funkcji. Zwracany typ oraz sygnatura wskaźnika do funkcji muszą być zgodne z przypisywaną mu funkcją.
Przykład long (*pFuncOne) (int, int); long SomeFunction (int, int); pFuncOne = SomeFunction; pFuncOne(5,7);
Dlaczego warto używać wskaźników do funkcji? Listing 15.5 z pewnością mógłby zostać napisany bez użycia wskaźników do funkcji, ale użycie takich wskaźników jawnie określa przeznaczenie programu: wybór funkcji z listy, a następnie jej wywołanie. Listing 15.6 wykorzystuje prototypy i definicje funkcji z listingu 15.5, ale ciało programu nie korzysta ze wskaźników do funkcji. Przyjrzyj się różnicom dzielącym te dwa listingi. Listing 15.6. Listing 15.5 przepisany bez używania wskaźników do funkcji 0:
// Listing 15.6 bez wskaźników do funkcji
1: 2:
#include
3:
using namespace std;
4: 5:
void Square (int&,int&);
// do kwadratu
6:
void Cube (int&, int&);
// do trzeciej potegi
7:
void Swap (int&, int &);
// zamiana
8:
void GetVals(int&, int&); // zmiana
9:
void PrintVals(int, int);
10: 11:
int main()
12:
{
13:
bool fQuit = false;
14:
int valOne=1, valTwo=2;
15:
int choice;
16:
while (fQuit == false)
17:
{
18: cout << "(0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: "; 19:
cin >> choice;
20:
switch (choice)
21:
{
22:
case 1:
23:
PrintVals(valOne, valTwo);
24:
GetVals(valOne, valTwo);
25:
PrintVals(valOne, valTwo);
26:
break;
27: 28:
case 2:
29:
PrintVals(valOne, valTwo);
30:
Square(valOne,valTwo);
31:
PrintVals(valOne, valTwo);
32:
break;
33: 34:
case 3:
35:
PrintVals(valOne, valTwo);
36:
Cube(valOne, valTwo);
37:
PrintVals(valOne, valTwo);
38:
break;
39: 40:
case 4:
41:
PrintVals(valOne, valTwo);
42:
Swap(valOne, valTwo);
43:
PrintVals(valOne, valTwo);
44:
break;
45: 46:
default :
47:
fQuit = true;
48:
break;
49:
}
50: 51:
if (fQuit)
52:
break;
53:
}
54:
return 0;
55:
}
56: 57:
void PrintVals(int x, int y)
58:
{
59: 60:
cout << "x: " << x << " y: " << y << endl; }
61: 62:
void Square (int & rX, int & rY)
63:
{
64:
rX *= rX;
65:
rY *= rY;
66:
}
67: 68:
void Cube (int & rX, int & rY)
69:
{
70:
int tmp;
71: 72:
tmp = rX;
73:
rX *= rX;
74:
rX = rX * tmp;
75: 76:
tmp = rY;
77:
rY *= rY;
78:
rY = rY * tmp;
79:
}
80: 81:
void Swap(int & rX, int & rY)
82:
{
83:
int temp;
84:
temp = rX;
85:
rX = rY;
86:
rY = temp;
87:
}
88: 89:
void GetVals (int & rValOne, int & rValTwo)
90:
{
91:
cout << "Nowa wartosc dla ValOne: ";
92:
cin >> rValOne;
93:
cout << "Nowa wartosc dla ValTwo: ";
94:
cin >> rValTwo;
95:
}
Wynik (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 1 x: 1 y: 2 Nowa wartosc dla ValOne: 2 Nowa wartosc dla ValTwo: 3 x: 2 y: 3 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 3 x: 2 y: 3 x: 8 y: 27 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 2 x: 8 y: 27 x: 64 y: 729 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 4 x: 64 y: 729 x: 729 y: 64 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 0
Analiza Kusiło mnie, by umieścić wywołanie funkcji PrintVals() na początku i na końcu pętli while, a nie w każdej z instrukcji case. Spowodowałoby to jednak wywołanie tej funkcji także dla opcji wyjścia z programu, co nie zgadzałoby się z jego specyfikacją. Poza wzrostem objętości kodu i powtórzonych wywołań tej samej funkcji, znacznie zmniejszyła się także ogólna przejrzystość programu. Ten przypadek został jednak stworzony w celu zilustrowania działania wskaźników do funkcji. W rzeczywistości zalety wskaźników do funkcji są jeszcze większe: wskaźniki do funkcji mogą eliminować powtórzenia kodu, sprawiają, że program staje się bardziej przejrzysty i umożliwiają stosowanie tablic funkcji wywoływanych w zależności od sytuacji powstałych podczas działania programu.
Skrócone wywołanie
Wskaźnik do funkcji nie musi być wyłuskiwany, choć oczywiście można przeprowadzić tę operację. Zatem, jeśli pFunc jest wskaźnikiem do funkcji przyjmującej wartość całkowitą i zwracającej zmienną typu long, i gdy do pFunc przypiszemy odpowiednią dla niego funkcję, możemy wywołać tęe funkcję, pisząc albo: pFunc(x);
albo: (*pFunc)(x);
Obie te formy działają identycznie. Pierwsza jest jedynie skróconą wersją drugiej.
Tablice wskaźników do funkcji Można deklarować nie tylko tablice wskaźników do wartości całkowitych, ale również tablice wskaźników do funkcji zwracających określony typ i posiadających określoną sygnaturę. Listing 15.7 stanowi zmodyfikowaną wersję listingu 15.5, tym razem wykorzystującą tablicę do wywoływania wszystkich opcji naraz. Listing 15.7. Demonstruje użycie tablicy wskaźników do funkcji 0:
// Listing 15.7
1:
//Demonstruje uŜycie tablicy wskaźników do funkcji
2: 3:
#include
4:
using namespace std;
5: 6:
void Square (int&,int&);
// do kwadratu
7:
void Cube (int&, int&);
// do trzeciej potegi
8:
void Swap (int&, int &);
// zamiana
9:
void GetVals(int&, int&); // zmiana
10:
void PrintVals(int, int);
11: 12:
int main()
13:
{
14:
int valOne=1, valTwo=2;
15:
int choice, i;
16:
const MaxArray = 5;
17:
void (*pFuncArray[MaxArray])(int&, int&);
18: 19:
for (i=0;i
20:
{
21: cout << "(0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: "; 22:
cin >> choice;
23:
switch (choice)
24:
{
25:
case 1:
pFuncArray[i] = GetVals; break;
26:
case 2:
pFuncArray[i] = Square; break;
27:
case 3:
pFuncArray[i] = Cube; break;
28:
case 4:
pFuncArray[i] = Swap; break;
29:
default:pFuncArray[i] = 0;
30:
}
31:
}
32: 33:
for (i=0;i
34:
{
35:
if ( pFuncArray[i] == 0 )
36:
continue;
37:
pFuncArray[i](valOne,valTwo);
38:
PrintVals(valOne,valTwo);
39:
}
40:
return 0;
41:
}
42: 43:
void PrintVals(int x, int y)
44:
{
45: 46:
cout << "x: " << x << " y: " << y << endl; }
47: 48:
void Square (int & rX, int & rY)
49:
{
50:
rX *= rX;
51: 52:
rY *= rY; }
53: 54:
void Cube (int & rX, int & rY)
55:
{
56:
int tmp;
57: 58:
tmp = rX;
59:
rX *= rX;
60:
rX = rX * tmp;
61: 62:
tmp = rY;
63:
rY *= rY;
64:
rY = rY * tmp;
65:
}
66: 67:
void Swap(int & rX, int & rY)
68:
{
69:
int temp;
70:
temp = rX;
71:
rX = rY;
72:
rY = temp;
73:
}
74: 75:
void GetVals (int & rValOne, int & rValTwo)
76:
{
77:
cout << "Nowa wartosc dla ValOne: ";
78:
cin >> rValOne;
79:
cout << "Nowa wartosc dla ValTwo: ";
80:
cin >> rValTwo;
81:
}
Wynik (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 1 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 2 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 3
(0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 4 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 2 Nowa wartosc dla ValOne: 2 Nowa wartosc dla ValTwo: 3 x: 2 y: 3 x: 4 y: 9 x: 64 y: 729 x: 729 y: 64 x: 531441 y: 4096
Analiza W linii 17. tablica pFuncArray jest deklarowana jako tablica pięciu wskaźników do funkcji zwracających void i przyjmujących po dwie referencje do wartości całkowitych. W liniach od 19. do 31. użytkownik jest proszony o wybranie funkcji przeznaczonej do wywołania, po czym każdemu elementowi tablicy jest przypisywany adres odpowiedniej funkcji. W liniach od 33. do 39. wywoływane są funkcje wskazywane przez kolejne elementy tablicy. Po każdym wywołaniu wypisywane są wyniki.
Przekazywanie wskaźników do funkcji do innych funkcji innym funkcjom Wskaźniki do funkcji (a także tablice wskaźników do funkcji) mogą być przekazywane do innych funkcji, które mogą wykonywać obliczenia i na podstawie ich wyniku, dzięki otrzymanym wskaźnikom, wywoływać odpowiednie funkcje. Możemy na przykład poprawić listing 15.5, przekazując wskaźnik do wybranej funkcji do innej funkcji (poza main()), która wypisuje wartości, wywołuje funkcję i ponownie wypisuje wartości. Ten wariant działania przedstawia listing 15.8. Listing 15.8. Przekazywanie wskaźników do funkcji jako argumentów funkcji 0:
// Listing 15.8 Przekazywanie wskaźników do funkcji
1: 2:
#include
3:
using namespace std;
4: 5:
void Square (int&,int&);
// do kwadratu
6:
void Cube (int&, int&);
// do trzeciej potegi
7:
void Swap (int&, int &);
// zamiana
8:
void GetVals(int&, int&); // zmiana
9:
void PrintVals(void (*)(int&, int&),int&, int&);
10: 11:
int main()
12:
{
13:
int valOne=1, valTwo=2;
14:
int choice;
15:
bool fQuit = false;
16: 17:
void (*pFunc)(int&, int&);
18: 19:
while (fQuit == false)
20:
{
21: cout << "(0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: "; 22:
cin >> choice;
23:
switch (choice)
24:
{
25:
case 1:
pFunc = GetVals; break;
26:
case 2:
pFunc = Square; break;
27:
case 3:
pFunc = Cube; break;
28:
case 4:
pFunc = Swap; break;
29:
default:fQuit = true; break;
30:
}
31:
if (fQuit == true)
32:
break;
33:
PrintVals ( pFunc, valOne, valTwo);
34:
}
35: 36: 37:
return 0; }
38: 39:
void PrintVals( void (*pFunc)(int&, int&),int& x, int& y)
40:
{
41:
cout << "x: " << x << " y: " << y << endl;
42:
pFunc(x,y);
43:
cout << "x: " << x << " y: " << y << endl;
44: 45:
}
46:
void Square (int & rX, int & rY)
47:
{
48:
rX *= rX;
49:
rY *= rY;
50:
}
51: 52:
void Cube (int & rX, int & rY)
53:
{
54:
int tmp;
55: 56:
tmp = rX;
57:
rX *= rX;
58:
rX = rX * tmp;
59: 60:
tmp = rY;
61:
rY *= rY;
62:
rY = rY * tmp;
63:
}
64: 65:
void Swap(int & rX, int & rY)
66:
{
67:
int temp;
68:
temp = rX;
69:
rX = rY;
70:
rY = temp;
71:
}
72: 73:
void GetVals (int & rValOne, int & rValTwo)
74:
{
75:
cout << "Nowa wartosc dla ValOne: ";
76:
cin >> rValOne;
77:
cout << "Nowa wartosc dla ValTwo: ";
78:
cin >> rValTwo;
79:
Wynik
}
(0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 1 x: 1 y: 2 Nowa wartosc dla ValOne: 2 Nowa wartosc dla ValTwo: 3 x: 2 y: 3 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 3 x: 2 y: 3 x: 8 y: 27 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 2 x: 8 y: 27 x: 64 y: 729 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 4 x: 64 y: 729 x: 729 y: 64 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 0
Analiza W linii 17. zmienna pFunc zostaje zadeklarowana jako wskaźnik do funkcji zwracającej void i przyjmującej dwa parametry, , będące referencjamie do wartości typu int. W linii 9. została zadeklarowana funkcja PrintVals przyjmująca trzy parametry. Pierwszym z nich jest wskaźnik do funkcji zwracającej void i przyjmującej dwie referencje do typu int, a drugim i trzecim są referencje do zmiennych wartości typu int. Także w tym programie użytkownik jest proszony o wybranie funkcji do wywołania, po czym w linii 33. wywoływana jest funkcja PrintVals. Znajdź jakiegoś programistę C++ i zapytaj go, co oznacza poniższa deklaracja:
void PrintVals(void (*)(int&, int&), int&, int&);
Jest to niezbyt często używany rodzaj deklaracji; prawdopodobnie zajrzysz do książki za każdym razem, gdy spróbujesz z niej skorzystać. Może ona jednak ocalić twój program w tych rzadkich przypadkach, gdy niezbędna będzie taka właśnie będzie dokładnie tąkonstrukcjaą., jaka jest wymagana.
Użycie instrukcji typedef ze wskaźnikami do funkcji Konstrukcja void (*)(int&, int&) jest co najmniej niezrozumiała. Aby ją uprościć, możemy użyć instrukcji typedef, deklarując typ (w tym przypadku nazwiemy go VPF) jako wskaźnik do funkcji zwracającej void i przyjmującej dwie referencje do typu int. Listing 15.9 stanowi nieco zmodyfikowaną wersję listingu 15.8. w którym zastosowano instrukcję typedef. Listing 15.9. Użycie instrukcji typedef w celu uproszczenia deklaracji wskaźnika do funkcji 0:
// Listing 15.9.
1:
// UŜycie typedef w celu uproszczenia deklaracji wskaźnika do funkcji
2: 3:
#include
4:
using namespace std;
5: 6:
void Square (int&,int&);
// do kwadratu
7:
void Cube (int&, int&);
// do trzeciej potegi
8:
void Swap (int&, int &);
// zamiana
9:
void GetVals(int&, int&); // zmiana
10:
typedef
void (*VPF) (int&, int&) ;
11:
void PrintVals(VPF,int&, int&);
12: 13:
int main()
14:
{
15:
int valOne=1, valTwo=2;
16:
int choice;
17:
bool fQuit = false;
18: 19:
VPF pFunc;
20: 21:
while (fQuit == false)
22:
{
23: cout << "(0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: "; 24:
cin >> choice;
25:
switch (choice)
26:
{
27:
case 1:
pFunc = GetVals; break;
28:
case 2:
pFunc = Square; break;
29:
case 3:
pFunc = Cube; break;
30:
case 4:
31:
default:fQuit = true; break;
32:
}
33:
if (fQuit == true)
34:
break;
35:
PrintVals ( pFunc, valOne, valTwo);
36:
}
37:
return 0;
38:
pFunc = Swap; break;
}
39: 40:
void PrintVals( VPF pFunc,int& x, int& y)
41:
{
42:
cout << "x: " << x << " y: " << y << endl;
43:
pFunc(x,y);
44:
cout << "x: " << x << " y: " << y << endl;
45:
}
46: 47:
void Square (int & rX, int & rY)
48:
{
49:
rX *= rX;
50:
rY *= rY;
51:
}
52: 53:
void Cube (int & rX, int & rY)
54:
{
55:
int tmp;
56: 57:
tmp = rX;
58:
rX *= rX;
59:
rX = rX * tmp;
60: 61:
tmp = rY;
62:
rY *= rY;
63:
rY = rY * tmp;
64:
}
65: 66:
void Swap(int & rX, int & rY)
67:
{
68:
int temp;
69:
temp = rX;
70:
rX = rY;
71: 72:
rY = temp; }
73: 74:
void GetVals (int & rValOne, int & rValTwo)
75:
{
76:
cout << "Nowa wartosc dla ValOne: ";
77:
cin >> rValOne;
78:
cout << "Nowa wartosc dla ValTwo: ";
79: 80:
cin >> rValTwo; }
Wynik (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 1 x: 1 y: 2 Nowa wartosc dla ValOne: 2 Nowa wartosc dla ValTwo: 3 x: 2 y: 3 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 3 x: 2 y: 3 x: 8 y: 27 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 2 x: 8 y: 27 x: 64 y: 729 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 4 x: 64 y: 729 x: 729 y: 64 (0)Koniec (1)Zmiana (2)Do kwadratu (3)Do trzeciej potegi (4)Zamiana: 0
Analiza W linii 10. instrukcja typedef została użyta do zadeklarowania VPF jako typu „wskaźnik do funkcji zwracającej void i przyjmującej dwa parametry w postaci referencji do wartości typu int”. W linii 11. została zadeklarowana funkcja PrintVals() przyjmująca trzy parametry: VPF i dwie referencje do wartości typu int. W tym programie wskaźnik pFunc został zadeklarowany jako zmienna typu VPF (w linii 19.). Po zdefiniowaniu typu VPF wszystkie następne konstrukcje z użyciem pFunc i PrintVals() stają się dużo bardziej przejrzyste. Jak widać, działanie programu nie ulega zmianie.
Wskaźniki do funkcji składowych Do tej pory wszystkie wskaźniki do funkcji odnosiły się do funkcji ogólnych, nie będących funkcjami składowymi. Istnieje jednak możliwość tworzenia wskaźników do funkcji, będących składowymi klas. Aby stworzyć wskaźnik do funkcji składowej, musimy użyć tej samej składni, jak w przypadku zwykłego wskaźnika do funkcji, ale z zastosowaniem nazwy klasy i operatora zakresu (::). Jeśli pFunc ma wskazywać na funkcję składową klasy Shape zwracającą void i przyjmującą dwie wartości całkowite, wtedy deklaracja tej zmiennej powinna wyglądać następująco:
void (Shape::*pFunc) (int, int);
Wskaźniki do funkcji składowych są używane tak samo, jak wskaźniki do zwykłych funkcji, z tym że w celu wywołania ich potrzebny jest obiekt właściwej klasy. Listing 15.10 ilustruje użycie wskaźników do funkcji składowych. Listing 15.10. Wskaźniki do funkcji składowych 0:
//Listing 15.10 Wskaźniki do funkcji składowych
1: 2:
#include
3:
using namespace std;
4: 5:
class Mammal
6:
{
7:
public:
8:
Mammal():itsAge(1) {
9:
virtual ~Mammal() { }
}
10:
virtual void Speak() const = 0;
11:
virtual void Move() const = 0;
12:
protected:
13: 14:
int itsAge; };
15: 16:
class Dog : public Mammal
17:
{
18:
public:
19:
void Speak()const { cout << "Hau!\n"; }
20: 21:
void Move() const { cout << "Gonie w pietke...\n"; } };
22: 23: 24:
class Cat : public Mammal
25:
{
26:
public:
27:
void Speak()const { cout << "Miau!\n"; }
28:
void Move() const { cout << "Skradam sie...\n"; }
29:
};
30: 31: 32:
class Horse : public Mammal
33:
{
34:
public:
35:
void Speak()const { cout << "Ihaaa!\n"; }
36:
void Move() const { cout << "Galopuje...\n"; }
37:
};
38: 39: 40:
int main()
41:
{
42:
void (Mammal::*pFunc)() const =0;
43:
Mammal* ptr =0;
44:
int Animal;
45:
int Method;
46:
bool fQuit = false;
47: 48:
while (fQuit == false)
49:
{
50:
cout << "(0)Wyjscie (1)pies (2)kot (3)kon: ";
51:
cin >> Animal;
52:
switch (Animal)
53:
{
54:
case 1:
55:
case 2: ptr = new Cat; break;
56:
case 3: ptr = new Horse; break;
57:
default: fQuit = true; break;
58:
}
59:
if (fQuit)
60:
ptr = new Dog; break;
break;
61: 62:
cout << "(1)Speak!
(2)Move: ";
63:
cin >> Method;
64:
switch (Method)
65:
{
66:
case 1: pFunc = Mammal::Speak; break;
67:
default: pFunc = Mammal::Move; break;
68:
}
69: 70:
(ptr->*pFunc)();
71:
delete ptr;
72:
}
73:
return 0;
74:
}
Wynik (0)Wyjscie (1)pies (2)kot (3)kon: 1 (1)Speak
(2)Move: 1
Hau! (0)Wyjscie (1)pies (2)kot (3)kon: 2 (1)Speak
(2)Move: 1
Miau! (0)Wyjscie (1)pies (2)kot (3)kon: 3 (1)Speak
(2)Move: 2
Galopuje... (0)Wyjscie (1)pies (2)kot (3)kon: 0
Analiza W liniach od 5. do 14. została zadeklarowana abstrakcyjna klasa Mammal, zawierająca dwie czyste metody wirtualne: Speak() (daj głos) oraz Move() (ruszaj się). Z klasy Mammal zostały wyprowadzone klasy Dog, Cat i Horse, z których każda przesłania metody Speak() i Move(). Program sterujący (funkcja main()) prosi użytkownika o wybranie zwierzęcia, które ma zostać stworzone, po czym w liniach od 54. do 56. na stercie tworzony jest nowy obiekt klasy pochodnej; jego adres zostaje przypisany wskaźnikowi ptr. Następnie użytkownik jest proszony o wybranie metody, która ma zostać wywołana; wybrana metoda jest przypisywana do wskaźnika pFunc. W linii 70. wywoływana jest metoda wybrana dla stworzonego wcześniej obiektu. Wywoływana jest ona poprzez użycie wskaźnika ptr (w celu uzyskania dostępu do obiektu) i wskaźnika pFunc (w celu wywołania jego metody). Na zakończenie, w linii 71., za pomocą operatora delete zostaje usunięty obiekt wskazywany przez ptr (w celu zwolnienia pamięci na stercie). Zauważ, że nie ma powodu wywoływania delete dla wskaźnika pFunc, gdyż jest to wskaźnik do kodu, a nie do obiektu na stercie. W rzeczywistości taka próba zakończyłaby się wypisaniem błędu kompilacji.
Tablice wskaźników do funkcji składowych Podobnie jak w przypadku wskaźników do zwykłych funkcji, w tablicach można przechowywać także wskaźniki do funkcji składowych. Tablica może zostać zainicjalizowana adresami różnych funkcji składowych, które potem mogą być wywoływane dla poszczególnych elementów tablicy. Technikę tę ilustruje listing 15.11. Listing 15.11. Tablica wskaźników do funkcji składowych 0:
//Listing 15.11 Tablica wskaźników do funkcji składowych
1: 2:
#include
3:
using std::cout;
4: 5:
class Dog
6:
{
7:
public:
8:
void Speak()const { cout << "Hau!\n"; }
9:
void Move() const { cout << "Gonie w pietke...\n"; }
10:
void Eat() const { cout << "Jem...\n"; }
11:
void Growl() const { cout << "Warcze\n"; }
12:
void Whimper() const { cout << "Wyje...\n"; }
13:
void RollOver() const { cout << "Tarzam sie...\n"; }
14: 15:
void PlayDead() const { cout << "Koniec Malego Cezara?\n"; } };
16: 17:
typedef void (Dog::*PDF)()const ;
18:
int main()
19:
{
20:
const int MaxFuncs = 7;
21:
PDF DogFunctions[MaxFuncs] =
22:
{Dog::Speak,
23:
Dog::Move,
24:
Dog::Eat,
25:
Dog::Growl,
26:
Dog::Whimper,
27:
Dog::RollOver,
28:
Dog::PlayDead };
29: 30:
Dog* pDog =0;
31:
int Method;
32:
bool fQuit = false;
33: 34:
while (!fQuit)
35:
{
36:
cout << "(0)Wyjscie (1)Daj glos (2)Ruszaj sie (3)Jedz (4)Warcz";
37:
cout << " (5)Wyj (6)Tarzaj sie (7)Zdechl pies: ";
38:
std::cin >> Method;
39:
if (Method == 0)
40:
{
41:
fQuit = true;
42:
}
43:
else
44:
{
45:
pDog = new Dog;
46:
(pDog->*DogFunctions[Method-1])();
47:
delete pDog;
48:
}
49:
}
50:
return 0;
51:
}
Wynik (0)Wyjscie (1)Daj glos (2)Ruszaj sie (3)Jedz (4)Warcz (5)Wyj (6)Tarzaj sie (7)Zdechl pies: 1 Hau! (0)Wyjscie (1)Daj glos (2)Ruszaj sie (3)Jedz (4)Warcz (5)Wyj (6)Tarzaj sie (7)Zdechl pies: 4 Warcze (0)Wyjscie (1)Daj glos (2)Ruszaj sie (3)Jedz (4)Warcz (5)Wyj (6)Tarzaj sie (7)Zdechl pies: 7 Koniec Malego Cezara? (0)Wyjscie (1)Daj glos (2)Ruszaj sie (3)Jedz (4)Warcz (5)Wyj (6)Tarzaj sie (7)Zdechl pies: 0
Analiza W liniach od 5. do 15. została stworzona klasa Dog, zawierająca siedem funkcji składowych, każdą o tym samym zwracanym typie i sygnaturze. W linii 17. instrukcja typedef deklaruje PDF jako wskaźnik do funkcji składowej klasy Dog, która nie przyjmuje żadnych parametrów i nie zwraca żadnej wartości, a ponadto jest funkcją const — typ ten jest zgodny z sygnaturą wszystkich siedmiu funkcji składowych klasy Dog. W liniach od 21. do 28. została zadeklarowana tablica DogFunctions, przechowująca wskaźniki do siedmiu funkcji składowych; jest ona inicjalizowana adresami tych funkcji. W liniach 36.7 i 37.8 użytkownik jest proszony o wybranie metody. Do momentu wybrania opcji Wyjście , na stercie za każdym razem jest tworzony obiekt klasy Dog, następnie w linii 46. jest dla niego wywoływana odpowiednia funkcja z tablicy. Oto kolejna linia, którą warto pokazać zarozumiałym programistom z twojej firmy; zapytaj ich, do czego służy:
(pDog->*DogFunctions[Method-1])();
Także ta konstrukcja może wydawać się niezrozumiała, ale zbudowana ze wskaźników do funkcji składowych tablica może znacznie ułatwić konstruowanie i analizę programu.
TAK
NIE
Wywołuj wskaźniki do funkcji składowych dla konkretnych obiektów klasy.
Nie używaj wskaźników do funkcji składowych, gdy można zastosować prostsze rozwiązanie.
W celu uproszczenia deklaracji wskaźników do funkcji składowych używaj instrukcji typedef.
Rozdział 16. Dziedziczenie Zzaawansowane dziedziczenie Jak dotądDo tej pory używaliśmy korzystaliśmy z pojedynczego i wielokrotnego dziedziczenia pojedynczego i wielokrotnego w celu stworzenia relacji typu jest-czymś. W Z tegotym rozdzialełu dowiesz się: •
Cczym jest zawieranie i jak je zamodelować,.
•
Cczym jest delegowanie i jak je zamodelować,.
•
Jjak zaimplementować daną klasę poprzez inną,.
•
Jjak używać dziedziczenia prywatnego dziedziczenia.
Zawieranie Jak widzieliśmy pokazaliśmy w poprzednich przykładach, możliwe jest, by dane składowe jednej klasy obejmowały obiekty innych klas. Programiści C++ mówią wtedy, że klasa zewnętrzna zawiera klasę wewnętrzną. Tak więc klasa Employee (pracownik) może zawierać na przykład obiekt typu łańcucha (przechowujący nazwisko pracownika) oraz składowe całkowite (zawierające jego pensję i inne dane). Listing 16.1 opisuje niekompletną, choć wciążjednakowoż użyteczną klasę String, dość podobną do klasy String zadeklarowanej w rozdziale 13. Ten listing nie dajegeneruje żadnego wynikuwydruku. Zamiast tego zostanieBędzie on jednak wykorzystanywany razem z dalszymi listingami. Listing 16.1. Klasa String 0:
// Listing 16.1 Klasa String
1: 2:
#include
3:
#include
4:
using namespace std;
5: 6:
class String
7:
{
8:
public:
9:
// konstruktory
10:
String();
11:
String(const char *const);
12:
String(const String &);
13:
~String();
14: 15:
// przeciąŜone operatory
16:
char & operator[](int offset);
17:
char operator[](int offset) const;
18:
String operator+(const String&);
19:
void operator+=(const String&);
20:
String & operator= (const String &),
21: 22:
// ogólne akcesory
23:
int GetLen()const { return itsLen; }
24:
const char * GetString() const { return itsString; }
25:
static int ConstructorCount;
26: 27:
private:
28:
String (int);
// prywatny konstruktor
29:
char * itsString;
30:
unsigned short itsLen;
31: 32:
};
33: 34:
// domyślny konstruktor tworzący ciąg pusty (zera0 bajtów)
35:
String::String()
36:
{
37:
itsString = new char[1];
38:
itsString[0] = '\0';
39:
itsLen=0;
40:
// cout << "\tDomyslny konstruktor lancucha\n";
41:
// ConstructorCount++;
42:
}
43: 44:
// prywatny (pomocniczy) konstruktor, uŜywany tylko przez
45:
// metody klasy przy tworzeniu nowego, wypełnionego zerowymi
46:
// bajtami, łańcucha o zadanej długości
47:
String::String(int len)
48:
{
49:
itsString = new char[len+1];
50:
for (int i = 0; i<=len; i++)
51:
itsString[i] = '\0';
52:
itsLen=len;
53:
// cout << "\tKonstruktor String(int)\n";
54:
// ConstructorCount++;
55:
}
56: 57:
// Zamienia tablice znaków w typ String
58:
String::String(const char * const cString)
59:
{
60:
itsLen = strlen(cString);
61:
itsString = new char[itsLen+1];
62:
for (int i = 0; i
63:
itsString[i] = cString[i];
64:
itsString[itsLen]='\0';
65:
// cout << "\tKonstruktor String(char*)\n";
66:
// ConstructorCount++;
67:
}
68: 69:
// konstruktor kopiującyi
70:
String::String (const String & rhs)
71:
{
72:
itsLen=rhs.GetLen();
73:
itsString = new char[itsLen+1];
74:
for (int i = 0; i
75:
itsString[i] = rhs[i];
76:
itsString[itsLen] = '\0';
77:
// cout << "\tKonstruktor String(String&)\n";
78:
// ConstructorCount++;
79:
}
80: 81:
// destruktor, zwalnia zaalokowaną pamięć
82:
String::~String ()
83:
{
84:
delete [] itsString;
85:
itsLen = 0;
86:
// cout << "\tDestruktor klasy String\n";
87:
}
88: 89:
// operator równości, zwalnia istniejącą pamięć,
90:
// po czym kopiuje łańcuch i rozmiar
91:
String& String::operator=(const String & rhs)
92:
{
93:
if (this == &rhs)
94:
return *this;
95:
delete [] itsString;
96:
itsLen=rhs.GetLen();
97:
itsString = new char[itsLen+1];
98:
for (int i = 0; i
99:
itsString[i] = rhs[i];
100:
itsString[itsLen] = '\0';
101:
return *this;
102:
// cout << "\toperator= klasy String\n";
103:
}
104: 105:
//nie const operator indeksu, zwraca
106:
// referencję do znaku, więc moŜna go
107:
// zmienić!
108:
char & String::operator[](int offset)
109:
{
110: 111:
if (offset > itsLen) return itsString[itsLen-1];
112:
else
113: 114:
return itsString[offset]; }
115: 116:
// const operator indeksu do uŜywania z obiektami
117:
// const (patrz konstruktor kopiującyi!)
118:
char String::operator[](int offset) const
119:
{
120:
if (offset > itsLen)
121:
return itsString[itsLen-1];
122:
else
123: 124:
return itsString[offset]; }
125: 126:
// tworzy nowy łańcuch przez dodanie do rhs
127:
// bieŜącego łańcucha
128:
String String::operator+(const String& rhs)
129:
{
130:
int
131:
String temp(totalLen);
132:
int i, j;
133:
for (i = 0; i
134:
temp[i] = itsString[i];
135:
totalLen = itsLen + rhs.GetLen();
for (j = 0; j
136:
temp[i] = rhs[j];
137:
temp[totalLen]='\0';
138:
return temp;
139:
}
140: 141:
// zmienia bieŜący łańcuch, nie zwraca nic
142:
void String::operator+=(const String& rhs)
143:
{
144:
unsigned short rhsLen = rhs.GetLen();
145:
unsigned short totalLen = itsLen + rhsLen;
146:
String
147:
int i, j;
148:
for (i = 0; i
temp(totalLen);
149:
temp[i] = itsString[i];
150:
for (j = 0; j
151:
temp[i] = rhs[i-itsLen];
152:
temp[totalLen]='\0';
153: 154:
*this = temp; }
155: 156:
// int String::ConstructorCount = 0;
UWAGA Umieść Kkod z listingu 16.1 umieść w pliku o nazwie String.hpp. Wtedy zZa każdym razem, gdy będziesz potrzebował klasy String, będziesz mógł dołączyć listing 16.1 (używając instrukcji #include "String.hpp";, tak jak to robimy w dalszych listingach przedstawionych w tym rozdziale).
Wynik: Brak
Analiza: Listing 16.1 zawiera klasę String, bardzo podobną do klasy String z listingu 13.12 przedstawionego w rozdziale trzynastym, „Tablice i listy połączone”. Najważniejszą różnicą jest to, żeJednakże konstruktory i inne funkcje z listingu 13.12 zawierały instrukcje wypisujące na ekranie komunikaty na ekranie; w listingu 16.1 instrukcje te zostały wykomentowane. Z funkcji tych skorzystamy w następnych przykładach. W linii 25. została zadeklarowana statyczna zmienna składowa ConstructorCount (licznik konstruktorów), która jest inicjalizowana w linii 156. Ta zmienna jestpodlega inkrementacji inicjalizowana w każdym konstruktorze klasy. Wszystko to zostało na razie wykomentowane; skorzystamy z tego dopiero w następnych listingach. Listing 16.2 przedstawia klasę Employee (pracownik), zawierającą trzy obiekty typu String. Listing 16.2. Klasa Employee i program sterujący 0:
// Listing 16.2 Klasa Employee i program sterujący
1:
#include "String.hpp"
2: 3:
class Employee
4:
{
5:
public:
6:
Employee();
7:
Employee(char *, char *, char *, long);
8:
~Employee();
9:
Employee(const Employee&);
10:
Employee & operator= (const Employee &);
11: 12:
const String & GetFirstName() const
13:
{ return itsFirstName; }
14:
const String & GetLastName() const { return itsLastName; }
15:
const String & GetAddress() const { return itsAddress; }
16:
long GetSalary() const { return itsSalary; }
17: 18:
void SetFirstName(const String & fName)
19:
{ itsFirstName = fName; }
20:
void SetLastName(const String & lName)
21:
{ itsLastName = lName; }
22:
void SetAddress(const String & address)
23:
{ itsAddress = address; }
24: 25:
void SetSalary(long salary) { itsSalary = salary; } private:
26:
String
itsFirstName;
27:
String
itsLastName;
28:
String
itsAddress;
long
itsSalary;
29: 30:
};
31: 32:
Employee::Employee():
33:
itsFirstName(""),
34:
itsLastName(""),
35:
itsAddress(""),
36:
itsSalary(0)
37:
{}
38: 39:
Employee::Employee(char * firstName, char * lastName,
40:
char * address, long salary):
41:
itsFirstName(firstName),
42:
itsLastName(lastName),
43:
itsAddress(address),
44:
itsSalary(salary)
45:
{}
46: 47:
Employee::Employee(const Employee & rhs):
48:
itsFirstName(rhs.GetFirstName()),
49:
itsLastName(rhs.GetLastName()),
50:
itsAddress(rhs.GetAddress()),
51:
itsSalary(rhs.GetSalary())
52:
{}
53: 54:
Employee::~Employee() {}
55: 56:
Employee & Employee::operator= (const Employee & rhs)
57:
{
58:
if (this == &rhs)
59:
return *this;
60: 61:
itsFirstName = rhs.GetFirstName();
62:
itsLastName = rhs.GetLastName();
63:
itsAddress = rhs.GetAddress();
64:
itsSalary = rhs.GetSalary();
65: 66: 67:
return *this; }
68: 69:
int main()
70:
{
71:
Employee Edie("Jane","Doe","1461 Shore Parkway", 20000);
72:
Edie.SetSalary(50000);
73:
String LastName("Levine");
74:
Edie.SetLastName(LastName);
75:
Edie.SetFirstName("Edythe");
76: 77:
cout << "Imie i nazwisko: ";
78:
cout << Edie.GetFirstName().GetString();
79:
cout << " " << Edie.GetLastName().GetString();
80:
cout << ".\nAdres: ";
81:
cout << Edie.GetAddress().GetString();
82:
cout << ".\nPensja: " ;
83:
cout << Edie.GetSalary();
84:
return 0;
85:
}
Dla wygody użytkownika, implementacja klasy String została umieszczona w pliku wraz z deklaracją. W rzeczywistym programie, deklaracja tej klasy zostałaby umieszczona w pliku String.hpp, zaś jej implementacja w pliku String.cpp. Wtedy moglibyśmy dodać plik String.cpp do projektu plik String.cpp (przy za pomocyą poleceń w menu kompilatora lub przy za pomocyą pliku makefile). Na początku pliku String.cpp musiałaby się znaleźć instrukcja #include "String.hpp". Jednak przede wszystkimAle, oczywiście, wW rzeczywistym programie użylibyśmy łańcucha klasy String ze standardowej biblioteki C++, a nie klasy stworzonej przez siebienas samych.
Wynik: Imie i nazwisko: Edythe Levine. Adres: 1461 Shore Parkway. Pensja: 50000
Analiza: Listing 16.2 przedstawia klasę Employee zawierającą trzy obiekty łańcuchoweów: itsFirstName (imię), itsLastName (nazwisko) oraz itsAddress (adres). W linii 71. zostaje sutworzony obiekt klasy Employee, przy czym azaś do jego inicjalizacji zostająsą wykorzystywaneane cztery wartości. W linii 72. zostaje wywołany akcesor SetSalary() (ustaw pensję), ze stałą wartością 50000. Zwróć uwagę że, w rzeczywistym programie byłaby to albo wartość dynamiczna (ustawiana podczas działania programu), albo zdefiniowana stała. W linii 73. zostaje utworzony łańcuch, inicjalizowany przy za pomocyą stałej łańcuchowej w stylu C++. Otrzymany obiekt łańcuchowya jest następnie używany jako argument funkcji SetLastName() (ustaw nazwisko) w linii 74. W linii 75. wywołana zostaje funkcja SetFirstName() (ustaw imię) klasy Employee, której przekazywana jest inna stała łańcuchowa. Jeśli się jednak bliżej się jej przyjrzysz, zauważysz, że klasa Employee nie posiada funkcji SetFirstName() , przyjmującej jako argument stałąej łańcuchowąej w stylu C; zamiast tego funkcja SetFirstName() wymaga referencji do stałego łańcucha. Kompilator potrafi rozwikłać to wywołanie, gdyż wie, w jaki sposób może stworzyć obiekt łańcuchowya ze stałej łańcuchowej. w stylu C. Wie, gdyż poinformowaliśmy go o tym w linii 11. listingu 16.1. Często zadawane pytanie
Dlaczego kłopoczemyuciekamy się do wywołaniaem funkcji GetString() w liniach 78., 79. i 81? 78:
cout << Edie.GetFirstName().GetString();
Odpowiedź: Metoda GetFirstName() obiektu Edie zwraca obiekt klasy String. Niestety, nasza klasa String jeszcze nie obsługuje operatora << dla cout. Aby więc usatysfakcjonować cout, musimy zwrócić łańcuch znaków w stylu C. Taki łańcuch zwraca metoda GetString() naszej klasy String. Problem ten rozwiążemy nieco później.
Dostęp do składowych klasy zawieranej klasy Obiekty Employee nie posiadają specjalnych uprawnień dostępu do zmiennych składowych klasy String. Jeśli Gdyby obiekt klasy Employee, Edie (Edyta) próbowałby odwołać się do składowej itsLen w swojej własnej zmiennej itsFirstName, wystąpiłby błąd kompilacji. Nie jest stanowi to jednak większym większego problememproblemu., bInterfejs dla klasy String tworzą Bowiem akcesory tworzą interfejs dla klasy String, iwięc klasa Employee nie musi się martwić o szczegóły implementacji obiektów łańcuchowychów bardziej niż o szczegóły implementacji swojej składowej całkowitej, itsSalary.
Filtrowanyie dostępu do składowych zawieranych składowych Zwróć uwagę, że klasa String posiada operator+. Projektant klasy Employee zablokował dostęp do operatora+ wywoływanego dla obiektów Employee. Uczynił to, deklarując, że wszystkie akcesory zwracające łańcuch, takie jak GetFirstName(), zwracają stałą referencję. Ponieważ operator+ nie jest (i nie może być) funkcją const (gdyż zmienia obiekt, dla którego jest wywoływany), próba napisania poniższej linii zakończy się błędem kompilacji:
String buffer = Edie.GetFirstName() + Edie.GetLastName();
GetFirstName() zwraca stały obiekt String, a dla stałych obiektów nie można używać operatora+ dla stałych obiektów..
Aby temu zaradzić, przeciążamy metodę GetFirstName() jako nie const: const String & GetFirstName() const { return itsFirstName; } String & GetFirstName() { return itsFirstName; }
Zwróć uwagę, zże zarówno wartość zwracana wartość nie jest const jak i oraz, żeoraz że sama funkcja nie jest już const. Zmiana samej wartości zwracanej wartości nie wystarcza do przeciążenia nazwy funkcji; musimy także zmienić „stałość” samej funkcji.
Koszt zawierania Ważne jest byNależy zdawać sobie sprawę, że użytkownik klasy Employee płaci cenęponosi koszty tworzenia i przechowywania obiektów String za każdym razem, gdy tworzony lub kopiowany jest obiekt klasy Employee jest tworzony lub kopiowany. Odkomentowanie kilku instrukcji cout w listingu 16.1 pokazujeujawni, jak często wywoływane są konstruktory klasy String. Listing 16.3 zawiera przepisanąna nowoą napisaną wersję programu sterującego, zawierającą komunikaty wskazujące momenty tworzenia obiektówu i wywoływania jego dla nich metod. UWAGA Aby skompilować ten listing, odkomentuj linie 40., 53., 65., 77., 86. oraz 102. na listingu 16.1.
Listing 16.3. Konstruktory klasy zawieranej klasy 0:
//Listing 16.3 Konstruktory klasy zawieranej klasy
1:
#include "String.hpp"
2: 3:
class Employee
4:
{
5:
public:
6:
Employee();
7:
Employee(char *, char *, char *, long),
8:
~Employee();
9:
Employee(const Employee&);
10:
Employee & operator= (const Employee &);
11: 12: 13:
const String & GetFirstName() const { return itsFirstName; }
14:
const String & GetLastName() const { return itsLastName; }
15:
const String & GetAddress() const { return itsAddress; }
16:
long GetSalary() const { return itsSalary; }
17: 18: 19: 20: 21: 22: 23: 24:
void SetFirstName(const String & fName) { itsFirstName = fName; } void SetLastName(const String & lName) { itsLastName = lName; } void SetAddress(const String & address) { itsAddress = address; } void SetSalary(long salary) { itsSalary = salary; }
25:
private:
26:
String
itsFirstName;
27:
String
itsLastName;
28:
String
itsAddress;
long
itsSalary;
29: 30:
};
31: 32:
Employee::Employee():
33:
itsFirstName(""),
34:
itsLastName(""),
35:
itsAddress(""),
36:
itsSalary(0)
37:
{}
38: 39:
Employee::Employee(char * firstName, char * lastName,
40:
char * address, long salary):
41:
itsFirstName(firstName),
42:
itsLastName(lastName),
43:
itsAddress(address),
44:
itsSalary(salary)
45:
{}
46: 47:
Employee::Employee(const Employee & rhs):
48:
itsFirstName(rhs.GetFirstName()),
49:
itsLastName(rhs.GetLastName()),
50:
itsAddress(rhs.GetAddress()),
51:
itsSalary(rhs.GetSalary())
52:
{}
53: 54:
Employee::~Employee() {}
55: 56:
Employee & Employee::operator= (const Employee & rhs)
57:
{
58: 59:
if (this == &rhs) return *this;
60: 61:
itsFirstName = rhs.GetFirstName();
62:
itsLastName = rhs.GetLastName();
63:
itsAddress = rhs.GetAddress();
64:
itsSalary = rhs.GetSalary();
65: 66: 67:
return *this; }
68: 69:
int main()
70:
{
71:
cout << "Tworzenie obiektu Edie...\n";
72:
Employee Edie("Jane","Doe","1461 Shore Parkway", 20000);
73:
Edie.SetSalary(20000);
74:
cout << "Wywolanie SetFirstName z parametrem char *...\n";
75:
Edie.SetFirstName("Edythe");
76:
cout << "Tworzenie tymczasowego lancucha LastName...\n";
77:
String LastName("Levine");
78:
Edie.SetLastName(LastName);
79: 80:
cout << "Imie i nazwisko: ";
81:
cout << Edie.GetFirstName().GetString();
82:
cout << " " << Edie.GetLastName().GetString();
83:
cout << "\nAdres: ";
84:
cout << Edie.GetAddress().GetString();
85:
cout << "\nPensja: " ;
86:
cout << Edie.GetSalary();
87:
cout << endl;
88:
return 0;
89:
}
Wynik: 1:
Tworzenie obiektu Edie...
2:
Konstruktor String(char*)
3:
Konstruktor String(char*)
4:
Konstruktor String(char*)
5: 6:
Wywolanie SetFirstName z parametrem char *... Konstruktor String(char*)
7: 8: 9:
Destruktor klasy String Tworzenie tymczasowego lancucha LastName... Konstruktor String(char*)
10:
Imie i nazwisko: Edythe Levine
11:
Adres: 1461 Shore Parkway
12:
Pensja: 20000
13:
Destruktor klasy String
14:
Destruktor klasy String
15:
Destruktor klasy String
16:
Destruktor klasy String
Analiza: Listing 16.3 wykorzystuje tę samą deklarację klasy String , co listingi 16.1 i 16.2. Jednak tym razem instrukcje cout w implementacji klasy String zostały odkomentowane. Oprócz tegoPoza tym, dla ułatwienia analizy działania, linie wyników programu zostały ponumerowane. W linii 71. listingu 16.3 zostaje wypisany komunikat Tworzenie obiektu Edie..., co odzwierciedla pokazuje pierwsza linia wyników. W linii 72. zostaje utworzony obiekt klasy Employee o nazwie Edie; konstruktorowi zostają przekazane cztery parametry. Wyniki pokazują że, tTak jak można było oczekiwać, konstruktor klasy String został wywołany trzykrotnie. Linia 74. wypisuje informacyjny komunikat, po czym w linii 75. zostaje wykonana instrukcja Edie.SetFirstName("Edythe");. Ta instrukcja powoduje utworzenie z łańcucha znaków "Edythe" tymczasowego obiektu klasy String, co odzwierciedlają linie 5. i 6. wyników. Zwróć uwagę, że tymczasowy obiekt jest niszczony natychmiast po użyciu go w instrukcji przypisania. W linii 77. w ciele programu zostaje utworzony obiekt klasy String. W tym przypadku programista jawnie wykonuje jawnie to, co kompilator zrobił niejawnie w poprzedniej instrukcji niejawnie. Tym razem w ósmej linii wyników widzimy wywołanie konstruktora, nie ma jednak destruktora. Ten obiekt nie jest niszczony aż do chwili, kiedy wyjdzie poza zakres (kończący się wrazczyli za końcem koniec funkcji). W liniach od 81. do 87. niszczone są obiekty łańcuchoweów zawarteych w klasie Employee, gdyż obiekt Edie wychodzi poza zakres funkcji main(). Z tego też powodu niszczony jest także obiekt łańcuchowya LastName, stworzony wcześniej w linii 77.
Kopiowanie przez wartość Listing 16.3 pokazuje, że stworzenie jednego obiektu Employee powoduje pięć wywołań konstruktora klasy String. Listing 16.4 zawiera kolejną wersję programu sterującego. Tym razem nie są wypisywane komunikaty o tworzeniu obiektu, lecz zostaje użyta (odkomentowana w linii 156.) statyczna zmienna składowa ConstructorCount klasy String. Gdy przyjrzysz się listingowi 16.1, zauważysz, że zmienna ConstructorCount jest (po odkomentowaniu w konstruktorach) inkrementowana za każdym razem, gdy zostaje wywołany
konstruktor. Program sterujący z listingu 16.4 wywołuje funkcje wypisujące, przekazując im obiekt Employee najpierw poprzez referencję, a następnie poprzez wartość. Zmienna ConstructorCount przechowuje aktualną liczbę obiektów String , stworzonych podczas przekazywania obiektu Employee jako parametru. UWAGA Aby skompilować ten listing, pozostaw bez zmian linie, które odkomentowałeś w celu skompilowania listingu 16.3. Oprócz tegoNatomiast w listingu 16.1 odkomentuj linie 41., 54., 66., 78. oraz 156.
Listing 16.4. Przekazywanie przez wartość 0:
// Listing 16.4 Przekazywanie przez wartość
1:
#include "String.hpp"
2: 3:
class Employee
4:
{
5:
public:
6:
Employee();
7:
Employee(char *, char *, char *, long);
8:
~Employee();
9:
Employee(const Employee&);
10:
Employee & operator= (const Employee &);
11: 12: 13:
const String & GetFirstName() const { return itsFirstName; }
14:
const String & GetLastName() const { return itsLastName; }
15:
const String & GetAddress() const { return itsAddress; }
16:
long GetSalary() const { return itsSalary; }
17: 18: 19: 20: 21: 22: 23: 24: 25:
void SetFirstName(const String & fName) { itsFirstName = fName; } void SetLastName(const String & lName) { itsLastName = lName; } void SetAddress(const String & address) { itsAddress = address; } void SetSalary(long salary) { itsSalary = salary; } private:
26:
String
itsFirstName;
27:
String
itsLastName;
28:
String
itsAddress;
29: 30:
long
itsSalary;
};
31: 32:
Employee::Employee():
33:
itsFirstName(""),
34:
itsLastName(""),
35:
itsAddress(""),
36:
itsSalary(0)
37:
{}
38: 39:
Employee::Employee(char * firstName, char * lastName,
40:
char * address, long salary):
41:
itsFirstName(firstName),
42:
itsLastName(lastName),
43:
itsAddress(address),
44:
itsSalary(salary)
45:
{}
46: 47:
Employee::Employee(const Employee & rhs):
48:
itsFirstName(rhs.GetFirstName()),
49:
itsLastName(rhs.GetLastName()),
50:
itsAddress(rhs.GetAddress()),
51:
itsSalary(rhs.GetSalary())
52:
{}
53: 54:
Employee::~Employee() {}
55: 56:
Employee & Employee::operator= (const Employee & rhs)
57:
{
58: 59:
if (this == &rhs) return *this;
60: 61:
itsFirstName = rhs.GetFirstName();
62:
itsLastName = rhs.GetLastName();
63:
itsAddress = rhs.GetAddress();
64:
itsSalary = rhs.GetSalary();
65:
66: 67:
return *this; }
68: 69:
void PrintFunc(Employee);
70:
void rPrintFunc(const Employee&);
71: 72:
int main()
73:
{
74:
Employee Edie("Jane","Doe","1461 Shore Parkway", 20000);
75:
Edie.SetSalary(20000);
76:
Edie.SetFirstName("Edythe");
77:
String LastName("Levine");
78:
Edie.SetLastName(LastName);
79: 80:
cout << "Ilosc konstruktorow: " ;
81:
cout << String::ConstructorCount << endl;
82:
rPrintFunc(Edie);
83:
cout << "Ilosc konstruktorow: ";
84:
cout << String::ConstructorCount << endl;
85:
PrintFunc(Edie);
86:
cout << "Ilosc konstruktorow: ";
87:
cout << String::ConstructorCount << endl;
88:
return 0;
89:
}
90:
void PrintFunc (Employee Edie)
91:
{
92:
cout << "Imie i nazwisko: ";
93:
cout << Edie.GetFirstName().GetString();
94:
cout << " " << Edie.GetLastName().GetString();
95:
cout << ".\nAdres: ";
96:
cout << Edie.GetAddress().GetString();
97:
cout << ".\nPensja: " ;
98:
cout << Edie.GetSalary();
99:
cout << endl;
100:
}
101: 102:
void rPrintFunc (const Employee& Edie)
103:
{
104:
cout << "Imie i nazwisko: ";
105:
cout << Edie.GetFirstName().GetString();
106:
cout << " " << Edie.GetLastName().GetString();
107:
cout << "\nAdres: ";
108:
cout << Edie.GetAddress().GetString();
109:
cout << "\nPensja: " ;
110:
cout << Edie.GetSalary();
111: 112:
cout << endl; }
Wynik: Konstruktor String(char*) Konstruktor String(char*) Konstruktor String(char*) Konstruktor String(char*) Destruktor klasy String Konstruktor String(char*) Ilosc konstruktorow: 5 Imie i nazwisko: Edythe Levine Adres: 1461 Shore Parkway Pensja: 20000 Ilosc konstruktorow: 5 Konstruktor String(String&) Konstruktor String(String&) Konstruktor String(String&) Imie i nazwisko: Edythe Levine. Adres: 1461 Shore Parkway. Pensja: 20000 Destruktor klasy String Destruktor klasy String Destruktor klasy String Ilosc konstruktorow: 8 Destruktor klasy String Destruktor klasy String
Destruktor klasy String Destruktor klasy String
Analiza: Wynik pokazuje, że przy okazji tworzeniua jednego obiektu klasy Employee jest tworzonych pięć obiektów klasy String. Gdy obiekt klasy Employee jest przekazywany funkcji rPrintFunc() przez referencję, nie są tworzone żadne dodatkowe obiekty tej klasy, więc nie są tworzone także żadne dodatkowe obiekty klasy String (one także są przekazywane poprzez referencję). Jednak gGdy w linii 85. obiekt klasy Employee jest przekazywany do funkcji PrintFunc() poprzez wartość, tworzona jest kopia obiektu, więc oznacza to, że powstają także trzy kolejne obiekty klasy String (w wyniku wywołania konstruktora kopiującegoi).
Implementowanie poprzez dziedziczenie i zawieranie oraz poprzez delegację Czasem zdarza się, że jedna klasa chce użyć jakichś atrybutów innej klasy. Na przykład, przypuśćmy, że musimy stworzyć klasę PartsCatalog (katalog części). Z otrzymanej specyfikacji wynika, że klasa PartsCatalog ma być kolekcją zbiorem części; każda część posiada unikalny numer. Klasa PartsCatalog nie pozwala, by w kolekcji zbiorze występowały takie same pozycje, oraznatomiast umożliwia dostęp do części na podstawie jej numeru. Listing pPodsumowujący wiadomości listing w rozdziale 14. zawierał klasę PartsList. Klasa PartsList zZostała dobrze przetestowana i zrozumiana, więc możemy z niej skorzystać tworząc klasę PartsCatalog chcemy z niej skorzystać, ( bez konieczności wymyślania wszystkiego od początku). Moglibyśmy stworzyć nową klasę PartsCatalog i zawrzeć umieścić w niej klasę PartsList. Klasa PartsCatalog mogłaby delegować (przekazać) zarządzanie połączoną listą nado zawartyego w niej obiektu klasy PartsList. Alternatywąnym rozwiązaniem mogłoby być wyprowadzenie klasy PartsCatalog z klasy PartsList i przejęcie w ten sposób przejęcie właściwości klasy PartsList. Pamiętajmy jednak, że publiczne dziedziczenie publiczne przedstawia relację typu jest-czymś, więc powinniśmy sobie zadać pytanie, czy obiekt PartsCatalog rzeczywiście jest obiektem PartsList. Jednym ze sposobów odpowiedzi na pytanie, czy obiekt PartsCatalog , jest obiektem PartsList jest założenie, że PartsList jest klasą bazową, a PartsCatalog jest klasą wyprowadzoną, a i następnie zadanie poniższych pytań: 1.
Czy w klasie bazowej jest cokolwiek, co nie powinno być w klasie wyprowadzoanej? Na przykład, czy klasa bazowa PartsList posiada funkcje nieodpowiednie dla klasy PartsCatalog? Jeśli tak, prawdopodobnie nie powinniśmy stosować dziedziczenia publicznego dziedziczenia.
2.
Czy klasa, którą tworzymy, ma więcej niż jedną bazę? Na przykład, czy klasa PartsCatalog wymaga dwóch obiektów PartsList dla każdego swojego obiektu dwóch obiektów PartsList? Jeśli tak, prawie z całą pewnością powinniśmy użyć zawierania.
3.
Czy musimy dziedziczyć po klasie bazowej tak, aby móc skorzystać z funkcji wirtualnych lub z dostępu do składowych chronionych składowych? Jeśli tak, musimy użyć dziedziczenia, publicznego lub prywatnego.
Na podstawieUwzględniając odpowiedzi na te pytania, musimy dokonać wyboru pomiędzy dziedziczeniem publicznym (relacją jest-czymś) a albo dziedziczeniem prywatnym (co którego zasady wyjaśnimy w dalszej części rozdziału) albolub zawieraniem. •
Zawieranie — Oobiekt zadeklarowany jako składowa innej klasy jest zawierany przez tę klasęzawartej w tej klasie.
•
Delegacja — Użycie użycie atrybutów klasy zawieranej klasy w celu wykonaniadla realizacji funkcji niedostępnych w klasie zawierającej.
•
Implementacja poprzez — Bbudowanie jednej klasy w oparciu o możliwości innej klasy, bez korzystania z publicznego dziedziczenia.
Delegacja Dlaczego nie powinniśmy wyprowadzać klasy PartsCatalog z klasy PartsList? Obiekt PartsCatlog nie jest obiektem PartsList ponieważ, obiekty PartsList są uporządkowanymi kolekcjamizbiorami, których elementy mogą się powtarzać. Klasa PartsCatalog ma zawierać unikalne pozycje, które nie muszą być uporządkowane. Piąty element obiektu PartsCatalog nie musi być częścią o numerze pięć. Oczywiście, istnieje możliwości publicznego dziedziczenia po klasie PartsList , a następnie przesłonięcia metody Insert() i operatora indeksu ([]) tak, aby działały zgodnie z naszą specyfikacją, ale w ten sposób wpłynęlibyśmy na samą esencjęistotę działania tej klasy. Zamiast tego zbudujemy klasę PartsCatalog , która nie posiadającą operatora indeksu, i nie pozwalającą na powtarzanieórzone elementówy, zaś do w celu łączenia dwóch zestawów zdefiniujemy operator+. W pierwszym podejściu przypadku wykorzystamy zawieranie. Klasa PartsCatalog będzie delegować zarządzanie listą na zawarty w niej obiekt klasy PartsList. To rozwiązanie ilustruje listing 16.5. Listing 16.5. Delegowanie na zawierany obiekt klasy PartsList 0:
// Listing 16.5 Delegowanie na zawierany obiekt klasy PartsList
1: 2:
#include
3:
using namespace std;
4: 5: 6:
// **************** Część ************
7:
// Abstrakcyjna klasa bazowa części
8:
class Part
9:
{
10:
public:
11:
Part():itsPartNumber(1) {}
12:
Part(int PartNumber):
13:
itsPartNumber(PartNumber){}
14:
virtual ~Part(){}
15:
int GetPartNumber() const
16:
{ return itsPartNumber; }
17:
virtual void Display() const =0;
18:
private:
19: 20:
int itsPartNumber; };
21: 22:
// implementacja czystej funkcji wirtualnej, dzięki czemutemu
23:
// mogą z niej korzystać klasy pochodne mogą z niej korzystać
24:
void Part::Display() const
25:
{
26: 27:
cout << "\nNumer czesci: " << itsPartNumber << endl; }
28: 29:
// **************** Część samochodu ************
30: 31:
class CarPart : public Part
32:
{
33:
public:
34:
CarPart():itsModelYear(94){}
35:
CarPart(int year, int partNumber);
36:
virtual void Display() const
37:
{
38:
Part::Display();
39:
cout << "Rok modelu: ";
40:
cout << itsModelYear << endl;
41:
}
42:
private:
43: 44:
int itsModelYear; };
45: 46:
CarPart::CarPart(int year, int partNumber):
47:
itsModelYear(year),
48:
Part(partNumber)
49:
{}
50: 51: 52:
// **************** Część samolotu ************
53: 54:
class AirPlanePart : public Part
55:
{
56:
public:
57:
AirPlanePart():itsEngineNumber(1){};
58:
AirPlanePart
59:
(int EngineNumber, int PartNumber);
60:
virtual void Display() const
61:
{
62:
Part::Display();
63:
cout << "Nr silnika: ";
64:
cout << itsEngineNumber << endl;
65:
}
66:
private:
67: 68:
int itsEngineNumber; };
69: 70:
AirPlanePart::AirPlanePart
71:
(int EngineNumber, int PartNumber):
72:
itsEngineNumber(EngineNumber),
73: 74:
Part(PartNumber) {}
75: 76:
// **************** Węzeł części ************
77:
class PartNode
78:
{
79:
public:
80:
PartNode (Part*);
81:
~PartNode();
82:
void SetNext(PartNode * node)
83:
{ itsNext = node; }
84:
PartNode * GetNext() const;
85:
Part * GetPart() const;
86:
private:
87:
Part *itsPart;
88:
PartNode * itsNext;
89:
};
90:
// Implementacje klasy PartNode...
91: 92:
PartNode::PartNode(Part* pPart):
93:
itsPart(pPart),
94:
itsNext(0)
95:
{}
96: 97:
PartNode::~PartNode()
98:
{
99:
delete itsPart;
100:
itsPart = 0;
101:
delete itsNext;
102:
itsNext = 0;
103:
}
104: 105:
// Gdy nie ma następnego węzła części, zwraca NULL
106:
PartNode * PartNode::GetNext() const
107:
{
108: 109:
return itsNext; }
110: 111:
Part * PartNode::GetPart() const
112:
{
113: 114: 115: 116:
if (itsPart) return itsPart; else return NULL; //błąd
117:
}
118: 119: 120: 121:
// **************** Klasa PartList ************
122:
class PartsList
123:
{
124:
public:
125:
PartsList();
126:
~PartsList();
127:
// wymaga konstruktora kopiującegoi i operatora przyrzyorównania!
128:
void
Iterate(void (Part::*f)()const) const;
129:
Part*
Find(int & position, int PartNumber)
130:
Part*
GetFirst() const;
131:
void
Insert(Part *);
132:
Part*
operator[](int) const;
133:
int
GetCount() const { return itsCount; }
134:
static
PartsList& GetGlobalPartsList()
135:
{
136:
return
137:
}
138:
private:
GlobalPartsList;
139:
PartNode * pHead;
140:
int itsCount;
141:
static PartsList GlobalPartsList;
142:
};
143: 144:
PartsList PartsList::GlobalPartsList;
145: 146: 147:
PartsList::PartsList():
148:
pHead(0),
149:
itsCount(0)
150:
{}
151: 152:
PartsList::~PartsList()
153:
{
const;
154: 155:
delete pHead; }
156: 157:
Part*
158:
{
159:
if (pHead)
160:
return pHead->GetPart();
161:
else
162: 163:
PartsList::GetFirst() const
return NULL;
// w celu wykrycia błędu
}
164: 165:
Part *
166:
{
167:
PartsList::operator[](int offSet) const
PartNode* pNode = pHead;
168: 169:
if (!pHead)
170:
return NULL; // w celu wykrycia błędu
171: 172:
if (offSet > itsCount)
173:
return NULL; // błąd
174: 175:
for (int i=0;i
176:
pNode = pNode->GetNext();
177: 178: 179:
return
pNode->GetPart();
}
180: 181:
Part*
PartsList::Find(
182:
int & position,
183:
int PartNumber)
184:
const
{
185:
PartNode * pNode = 0;
186:
for (pNode = pHead, position = 0;
187:
pNode!=NULL;
188: 189: 190:
pNode = pNode->GetNext(), position++) { if (pNode->GetPart()->GetPartNumber() == PartNumber)
191:
break;
192:
}
193:
if (pNode == NULL)
194:
return NULL;
195:
else
196: 197:
return pNode->GetPart(); }
198: 199:
void PartsList::Iterate(void (Part::*func)()const) const
200:
{
201:
if (!pHead)
202:
return;
203:
PartNode* pNode = pHead;
204:
do
205:
(pNode->GetPart()->*func)();
206:
while (pNode = pNode->GetNext());
207:
}
208: 209:
void PartsList::Insert(Part* pPart)
210:
{
211:
PartNode * pNode = new PartNode(pPart);
212:
PartNode * pCurrent = pHead;
213:
PartNode * pNext = 0;
214: 215:
int New =
pPart->GetPartNumber();
216:
int Next = 0;
217:
itsCount++;
218: 219:
if (!pHead)
220:
{
221:
pHead = pNode;
222:
return;
223:
}
224: 225:
// jeśli ten węzeł jest mniejszy niŜ głowa,
226:
// staje się nową głową
227:
if (pHead->GetPart()->GetPartNumber() > New)
228:
{
229:
pNode->SetNext(pHead);
230:
pHead = pNode;
231:
return;
232:
}
233: 234:
for (;;)
235:
{
236:
// jeśli nie ma następnego, dołączamy nowy
237:
if (!pCurrent->GetNext())
238:
{
239:
pCurrent->SetNext(pNode);
240:
return;
241:
}
242: 243:
// jeśli trafia pomiędzy bieŜący a nastepny,
244:
// wstawiamy go tuo; w przeciwnym razie bierzemy następny
245:
pNext = pCurrent->GetNext();
246:
Next = pNext->GetPart()->GetPartNumber();
247:
if (Next > New)
248:
{
249:
pCurrent->SetNext(pNode);
250:
pNode->SetNext(pNext);
251:
return;
252:
}
253:
pCurrent = pNext;
254: 255:
} }
256: 257: 258: 259:
class PartsCatalog
260:
{
261:
public:
262:
void Insert(Part *);
263:
int Exists(int PartNumber);
264:
Part * Get(int PartNumber);
265:
operator+(const PartsCatalog &);
266:
void ShowAll() { thePartsList.Iterate(Part::Display); }
267:
private:
268: 269:
PartsList thePartsList; };
270: 271:
void PartsCatalog::Insert(Part * newPart)
272:
{
273:
int partNumber =
274:
int offset;
newPart->GetPartNumber();
275: 276:
if (!thePartsList.Find(offset, partNumber))
277:
thePartsList.Insert(newPart);
278:
else
279:
{
280:
cout << partNumber << " byl ";
281:
switch (offset)
282:
{
283:
case 0:
cout << "pierwsza "; break;
284:
case 1:
cout << "druga "; break;
285:
case 2:
cout << "trzecia "; break;
286:
default: cout << offset+1 << "th ";
287:
}
288:
cout << "pozycja. Odrzucony!\n";
289: 290:
} }
291: 292:
int PartsCatalog::Exists(int PartNumber)
293:
{
294:
int offset;
295:
thePartsList.Find(offset,PartNumber);
296:
return offset;
297:
}
298: 299:
Part * PartsCatalog::Get(int PartNumber)
300:
{
301:
int offset;
302:
Part * thePart = thePartsList.Find(offset, PartNumber);
303:
return thePart;
304:
}
305: 306: 307:
int main()
308:
{
309:
PartsCatalog pc;
310:
Part * pPart = 0;
311:
int PartNumber;
312:
int value;
313:
int choice;
314: 315:
while (1)
316:
{
317:
cout << "(0)Wyjscie (1)Samochod (2)Samolot: ";
318:
cin >> choice;
319: 320:
if (!choice)
321:
break;
322: 323:
cout << "Nowy numer czesci?: ";
324:
cin >>
PartNumber;
325: 326:
if (choice == 1)
327:
{
328:
cout << "Model?: ";
329:
cin >> value;
330:
pPart = new CarPart(value,PartNumber);
331:
}
332:
else
333:
{
334:
cout << "Numer silnika?: ";
335:
cin >> value;
336:
pPart = new AirPlanePart(value,PartNumber);
337:
}
338:
pc.Insert(pPart);
339:
}
340:
pc.ShowAll();
341:
return 0;
342:
}
Wynik (0)Wyjscie (1)Samochod (2)Samolot: 1 Nowy numer czesci?: 1234 Model?: 94 (0)Wyjscie (1)Samochod (2)Samolot: 1 Nowy numer czesci?: 4434 Model?: 93 (0)Wyjscie (1)Samochod (2)Samolot: 1 Nowy numer czesci?: 1234 Model?: 94 1234 byl pierwsza pozycja. Odrzucony! (0)Wyjscie (1)Samochod (2)Samolot: 1 Nowy numer czesci?: 2345 Model?: 93 (0)Wyjscie (1)Samochod (2)Samolot: 0
Numer czesci: 1234 Rok modelu: 94
Numer czesci: 2345 Rok modelu: 93
Numer czesci: 4434 Rok modelu: 93 UWAGA Niektóre kompilatory nie potrafią skompilować linii 266., mimo iż w C++ jest ona poprawna. Jeśli zdarzy się to w przypadku twojego kompilatora, zmień tę linię na: 266:
void ShowAll() { thePartsList.Iterate(&Part::Display); }
(Chodzi o dopisanie znaku ampersand (&) przed Part::Display.) Jeśli rozwiąże to problem, natychmiast zadzwoń do twórcy swojego kompilatora i poskarż się.
Analiza Listing 16.5 zawiera klasy Part, PartNode oraz PartsList z listingu podsumowującego wiadomości (na końcu rozdziału czternastego). W liniach od 259. do 269. została zadeklarowana nowa klasa, PartsCatalog (katalog części). Jedną ze składowych tej klasy jest obiekt klasy PartsList; właśnie do tej klasy jest delegowane zarządzanie listą. Można także powiedzieć, że klasa PartsCatalog jest zaimplementowana poprzez klasę PartsList. Zwróć uwagę, że klienci klasy PartsCatalog nie mają bezpośredniego dostępu do klasy PartsList. Interfejsem dla niej jest klasa PartsCatalog i w związku z tym działanie klasy PartsListCatalog uległo dużej zmianie. Na przykład, metoda PartsCatalog::Insert() nie pozwala, by w liście PartsList pojawiły się elementy powielone. Implementacja metody PartsCatalog::Insert() rozpoczyna się w linii 271. Obiekt Part, który jest przekazywany jako parametr, jest pytany o wartość swojej zmiennej składowej itsPartNumber. Ta wartość jest przekazywana do metody PartsList::Find() (znajdź) i jeśli nie zostanie znaleziona pasująca część, element jest wstawiany do listy; w przeciwnym razie wypisywany jest komunikat informacyjny. Zwróć uwagę, że klasa PartsCatalog wstawia elementy, wywołując metodę Insert() dla swojej zmiennej składowej plthePartsList, która jest obiektem klasy PartsList. Mechanizm wstawiania i zarządzania listą połączoną, a także wyszukiwanie i pobieranie jej elementów, należy wyłącznie do obiektu klasy PartsList, zawartegoj w klasie PartsCatalog. Nie ma powodu, by klasa PartsCatalog powielała ten kod, gdyż może skorzystać z dobrze zdefiniowanego interfejsu. Na tym właśnie polega idea ponownego wykorzystania klas w C++: klasa PartsCatalog może ponownie skorzystać z kodu klasy PartsList, a projektant klasy PartsCatalog może po prostu zignorować szczegóły implementacji klasy PartsList. Interfejs klasy PartsList (to jest deklaracja tej klasy) dostarcza wszystkich informacji potrzebnych projektantowi klasy PartsCatalog.
Dziedziczenie prywatne Gdyby klasa PartsCatalog musiała mieć dostęp do chronionych składowych klasy PartsList (w tym przypadku składowe te nie występują) lub musiała przesłaniać którąś z metod tej klasy, wtedy klasa PartsCatalog musiałaby zostać wyprowadzona z klasy PartsList. Ponieważ klasa PartsCatalog nie jest obiektem PartsList i ponieważ nie chcemy udostępniać klientom klasy PartsCatalog całego zestawu funkcji klasy PartsList, musimy użyć prywatnego dziedziczenia prywatnego. Należy wiedzieć, iż wszystkie zmienne i funkcje składowe klasy bazowej są traktowane tak, jakby były zadeklarowane jako prywatne, bez względu na ich rzeczywiste deklaracje w klasie bazowej. Tak więc żadna funkcja, która nie jest funkcją składową klasy PartsCatalog, nie ma dostępu do żadnej składowej klasy PartsList. Obowiązuje tu następująca zasada: dziedziczenie prywatne nie nie wiąże się z dziedziczeniem interfejsu, a jedynie z implementacjąi.
Klasa PartsList jest niewidoczna dla klientów klasy PartsCatalog. Nie jest dla nich dostępny żaden z jej interfejsów: nie mogą wywoływać żadnych z jej metod. Mogą jednak wywoływać metody klasy PartsCatalog; metody tej klasy mogą z kolei odwoływać się do składowych klasy PartsList (gdyż klasa PartsCatalog jest z niej wyprowadzona). Ważny jest tu fakt, że klasa PartsCatalog nie jest klasą PartsList, tak jak w przypadku dziedziczenia publicznego. Klasa PartsCatalog jest zaimplementowana poprzez klasę PartsList, tak jak miało to miejsce w przypadku zawierania. Dziedziczenie prywatne stanowi jedynie ułatwienie. Listing 16.6 demonstruje użycie dziedziczenia prywatnego; w tym przykładzie klasa PartsCatalog została przepisana jako dziedzicząca prywatnie po klasie PartsList.
Listing 16.6. Dziedziczenie prywatne 0:
//Listing 16.6 demonstruje dziedziczenie prywatne
1:
#include
2:
using namespace std;
3: 4:
// **************** Część ************
5: 6:
// Abstrakcyjna klasa bazowa części
7:
class Part
8:
{
9:
public:
10:
Part():itsPartNumber(1) {}
11:
Part(int PartNumber):
12:
itsPartNumber(PartNumber){}
13:
virtual ~Part(){}
14:
int GetPartNumber() const
15:
{ return itsPartNumber; }
16:
virtual void Display() const =0;
17:
private:
18: 19:
int itsPartNumber; };
20: 21:
// implementacja czystej funkcji wirtualnej, dzięki temu
22:
// mogą z niej korzystać klasy pochodne
23:
void Part::Display() const
24:
{
25: 26:
cout << "\nNumer czesci: " << itsPartNumber << endl; }
27: 28:
// **************** Część samochodu ************
29: 30:
class CarPart : public Part
31:
{
32:
public:
33:
CarPart():itsModelYear(94){}
34:
CarPart(int year, int partNumber);
35:
virtual void Display() const
36:
{
37:
Part::Display();
38:
cout << "Rok modelu: ";
39:
cout << itsModelYear << endl;
40:
}
41:
private:
42: 43:
int itsModelYear; };
44: 45:
CarPart::CarPart(int year, int partNumber):
46:
itsModelYear(year),
47:
Part(partNumber)
48:
{}
49: 50: 51:
// **************** Część samolotu ************
52: 53:
class AirPlanePart : public Part
54:
{
55:
public:
56:
AirPlanePart():itsEngineNumber(1){};
57:
AirPlanePart(int EngineNumber, int PartNumber);
58:
virtual void Display() const
59:
{
60:
Part::Display();
61:
cout << "Nr silnika: ";
62:
cout << itsEngineNumber << endl;
63:
}
64:
private:
65: 66:
int itsEngineNumber; };
67: 68:
AirPlanePart::AirPlanePart
69:
(int EngineNumber, int PartNumber):
70:
itsEngineNumber(EngineNumber),
71:
Part(PartNumber)
72:
{}
73: 74:
// **************** Węzeł części ************
75:
class PartNode
76:
{
77:
public:
78:
PartNode (Part*);
79:
~PartNode();
80:
void SetNext(PartNode * node)
81:
{ itsNext = node; }
82:
PartNode * GetNext() const;
83:
Part * GetPart() const;
84:
private:
85:
Part *itsPart;
86:
PartNode * itsNext;
87:
};
88:
// Implementacje klasy PartNode...
89: 90:
PartNode::PartNode(Part* pPart):
91:
itsPart(pPart),
92:
itsNext(0)
93:
{}
94: 95:
PartNode::~PartNode()
96:
{
97:
delete itsPart;
98:
itsPart = 0;
99:
delete itsNext;
100:
itsNext = 0;
101:
}
102: 103:
// Gdy nie ma następnego węzła części, zwraca NULL
104:
PartNode * PartNode::GetNext() const
105:
{
106: 107:
return itsNext; }
108: 109:
Part * PartNode::GetPart() const
110:
{
111:
if (itsPart)
112:
return itsPart;
113:
else
114: 115:
return NULL; //błąd }
116: 117: 118: 119:
// **************** Klasa PartList ************
120:
class PartsList
121:
{
122:
public:
123:
PartsList();
124:
~PartsList();
125:
// wymaga konstruktora kopiującegoi i operatora przyorównania!
126:
void
Iterate(void (Part::*f)()const) const;
127:
Part*
Find(int & position, int PartNumber)
128:
Part*
GetFirst() const;
129:
void
Insert(Part *);
130:
Part*
operator[](int) const;
131:
int
132:
static
133:
{
134:
return
135:
}
136:
private:
137:
GetCount() const { return itsCount; } PartsList& GetGlobalPartsList()
GlobalPartsList;
PartNode * pHead;
const;
138:
int itsCount;
139:
static PartsList GlobalPartsList;
140:
};
141: 142:
PartsList PartsList::GlobalPartsList;
143: 144: 145:
PartsList::PartsList():
146:
pHead(0),
147:
itsCount(0)
148:
{}
149: 150:
PartsList::~PartsList()
151:
{
152: 153:
delete pHead; }
154: 155:
Part*
156:
{
157:
if (pHead)
158:
return pHead->GetPart();
159:
else
160: 161:
PartsList::GetFirst() const
return NULL;
// w celu wykrycia błędu
}
162: 163:
Part *
164:
{
165:
PartsList::operator[](int offSet) const
PartNode* pNode = pHead;
166: 167: 168:
if (!pHead) return NULL; // w celu wykrycia błędu
169: 170: 171:
if (offSet > itsCount) return NULL; // błąd
172: 173: 174:
for (int i=0;iGetNext();
175: 176: 177:
return
pNode->GetPart();
}
178: 179:
Part*
180:
{
PartsList::Find(int & position, int PartNumber)
181:
PartNode * pNode = 0;
182:
for (pNode = pHead, position = 0;
183:
pNode!=NULL;
184:
pNode = pNode->GetNext(), position++)
185:
{
186:
if (pNode->GetPart()->GetPartNumber() == PartNumber)
187:
break;
188:
}
189:
if (pNode == NULL)
190:
return NULL;
191:
else
192: 193:
return pNode->GetPart(); }
194: 195:
void PartsList::Iterate(void (Part::*func)()const) const
196:
{
197:
if (!pHead)
198:
return;
199:
PartNode* pNode = pHead;
200:
do
201:
(pNode->GetPart()->*func)();
202:
while (pNode = pNode->GetNext());
203:
}
204: 205:
void PartsList::Insert(Part* pPart)
206:
{
207:
PartNode * pNode = new PartNode(pPart);
208:
PartNode * pCurrent = pHead;
209:
PartNode * pNext = 0;
210: 211:
const
int New =
pPart->GetPartNumber();
212:
int Next = 0;
213:
itsCount++;
214: 215:
if (!pHead)
216:
{
217:
pHead = pNode;
218:
return;
219:
}
220: 221:
// jeśli ten węzeł jest mniejszy niŜ głowa,
222:
// staje się nową głową
223:
if (pHead->GetPart()->GetPartNumber() > New)
224:
{
225:
pNode->SetNext(pHead);
226:
pHead = pNode;
227:
return;
228:
}
229: 230:
for (;;)
231:
{
232:
// jeśli nie ma następnego, dołączamy nowy
233:
if (!pCurrent->GetNext())
234:
{
235:
pCurrent->SetNext(pNode);
236:
return;
237:
}
238: 239:
// jeśli trafia pomiędzy bieŜący a nastepny,
240:
// wstawiamy go tu; w przeciwnym razie bierzemy następny
241:
pNext = pCurrent->GetNext();
242:
Next = pNext->GetPart()->GetPartNumber();
243:
if (Next > New)
244:
{
245:
pCurrent->SetNext(pNode);
246:
pNode->SetNext(pNext);
247:
return;
248:
}
249:
pCurrent = pNext;
250: 251:
} }
252: 253: 254: 255:
class PartsCatalog : private PartsList
256:
{
257:
public:
258:
void Insert(Part *);
259:
int Exists(int PartNumber);
260:
Part * Get(int PartNumber);
261:
operator+(const PartsCatalog &);
262:
void ShowAll() { Iterate(Part::Display); }
263:
private:
264:
};
265: 266:
void PartsCatalog::Insert(Part * newPart)
267:
{
268:
int partNumber =
269:
int offset;
newPart->GetPartNumber();
270: 271:
if (!Find(offset, partNumber))
272:
PartsList::Insert(newPart);
273:
else
274:
{
275:
cout << partNumber << " byl ";
276:
switch (offset)
277:
{
278:
case 0:
cout << "pierwsza "; break;
279:
case 1:
cout << "druga "; break;
280:
case 2:
cout << "trzecia "; break;
281:
default: cout << offset+1 << "th ";
282:
}
283:
cout << "pozycja. Odrzucony!\n";
284: 285:
} }
286: 287:
int PartsCatalog::Exists(int PartNumber)
288:
{
289:
int offset;
290:
Find(offset,PartNumber);
291:
return offset;
292:
}
293: 294:
Part * PartsCatalog::Get(int PartNumber)
295:
{
296:
int offset;
297:
return (Find(offset, PartNumber));
298: 299:
}
300: 301:
int main()
302:
{
303:
PartsCatalog pc;
304:
Part * pPart = 0;
305:
int PartNumber;
306:
int value;
307:
int choice;
308: 309:
while (1)
310:
{
311:
cout << "(0)Wyjscie (1)Samochod (2)Samolot: ";
312:
cin >> choice;
313: 314:
if (!choice)
315:
break;
316: 317:
cout << "Nowy numer czesci?: ";
318:
cin >>
PartNumber;
319: 320:
if (choice == 1)
321:
{
322:
cout << "Model?: ";
323:
cin >> value;
324:
pPart = new CarPart(value,PartNumber);
325:
}
326:
else
327:
{
328:
cout << "Numer silnika?: ";
329:
cin >> value;
330:
pPart = new AirPlanePart(value,PartNumber);
331:
}
332:
pc.Insert(pPart);
333:
}
334:
pc.ShowAll();
335: 336:
return 0; }
Wynik (0)Wyjscie (1)Samochod (2)Samolot: 1 Nowy numer czesci?: 1234 Model?: 94 (0)Wyjscie (1)Samochod (2)Samolot: 1 Nowy numer czesci?: 4434 Model?: 93 (0)Wyjscie (1)Samochod (2)Samolot: 1 Nowy numer czesci?: 1234 Model?: 94 1234 byl pierwsza pozycja. Odrzucony! (0)Wyjscie (1)Samochod (2)Samolot: 1 Nowy numer czesci?: 2345 Model?: 93 (0)Wyjscie (1)Samochod (2)Samolot: 0
Numer czesci: 1234 Rok modelu: 94
Numer czesci: 2345
Rok modelu: 93
Numer czesci: 4434 Rok modelu: 93
Analiza Listing 16.6 pokazuje zmieniony interfejs klasy PartsCatalog oraz przepisany program sterujący. Interfejsy innych klas nie zmieniły się w stosunku do listingu 16.5. W linii 255. listingu 16.6 klasa PartsCatalog została zadeklarowana jako dziedzicząca prywatnie po klasie PartsList. Interfejs klasy PartsCatalog pozostał taki sam jak na listingu 16.5, jednak obecnie w tej klasie nie korzystamy już oczywiście z obiektu klasy PartsList jako zmiennej składowej. Funkcja PartsCatalog::ShowAll() wywołuje metodę PartsList::Iterate(), przekazując jej odpowiedni wskaźnik do funkcji składowej klasy Part. Metoda ShowAll() pełni rolę publicznego interfejsu do metody Iterate(), dostarczając poprawnych informacji, ale nie pozwala zabezpieczając klasomy klientów przedna bezpośredniem wywoływaniem tej metody. Choć klasa PartsList mogłaby pozwolić na przekazywanie innych funkcji do metody Iterate(), jednak nie pozwala na to klasa PartsCatalog. Zmianie uległa także sama funkcja Insert(). Zauważ, że w linii 271.3 metoda Find() jest teraz wywoływana bezpośrednio, gdyż została odziedziczona po klasie bazowej. Wywołanie metody Insert() w linii 272. musi oczywiście korzystać z pełnej nazwy kwalifikowanej, gdyż w przeciwnym razie mielibyśmy do czynienia z rekurencją. Podsumowując, gdy metody klasy PartsCatalog chcą wywołać metodę klasy PartsList, mogą uczynić to bezpośrednio. Jedyny wyjątek stanowi sytuacja, w której klasa PartsCatalog przesłania metodę, a potrzebna jest jej wersja z klasy PartsList. W takim przypadku konieczne jest użycie pełnej nazwy kwalifikowanej. Dziedziczenie prywatne pozwala, by klasa PartsCatalog dziedziczyła to, czego może użyć i wciąż zapewniała klasom klientów pośredni dostęp do metody Insert() i innych metod, do których te klasy nie powinny mieć bezpośredniego dostępu.
TAK
NIE
Używaj dziedziczenia publicznego, gdy wyprowadzany obiekt jest rodzajem obiektu bazowego.
Nie używaj dziedziczenia prywatnego, gdy musisz użyć więcej niż jednej klasy bazowej. W takiej sytuacji musisz użyć zawierania. Na przykład, gdyby klasa PartsCatalog potrzebowała dwóch obiektów PartsList, nie mógłbyś użyć dziedziczenia prywatnego.
Używaj zawierania wtedy, gdy chcesz delegować funkcjonalność na inną klasę i nie potrzebujesz dostępu do jej chronionych składowych chronionych. Używaj dziedziczenia prywatnego wtedy, gdy musisz zaimplementować jedną klasę poprzez drugą i chcesz mieć dostęp do jej chronionych
Nie używaj dziedziczenia publicznego, gdy składowe klasy bazowej nie powinny być dostępne dla klientów klasy pochodnej.
składowychskładowych.
FunkcjeKlasy zaprzyjaźnione Czasem klasy tworzy się łącznie, jako zestaw. Na przykład, klasy PartNode i PartsList są ze sobą ściśle powiązane i byłoby wygodnie, gdyby klasa PartsList mogła bezpośrednio odczytywać wskaźnik do klasy Part z klasy PartNode, czyli mieć bezpośredni dostęp do jej zmiennej składowej itsPart. Nie chcielibyśmy, by składowa itsPart była składową publiczną, ani nawet składową chronioną, gdyż jest ona szczegółem implementacji klasy PartNode, który powinien pozostać prywatny. Chcemy jednak udostępnić ją klasie PartsList. Jeśli chcesz udostępnić swoje prywatne dane lub funkcje składowe innej klasie, musisz zadeklarować tę klasę jako zaprzyjaźnioną. To rozszerza interfejs twojej klasy o interfejs klasy zaprzyjaźnionej. Gdy klasa PartNode zadeklaruje klasę PartsList jako zaprzyjaźnioną, wszystkie dane i funkcje składowe klasy PartNode są dla klasy PartsList dostępne jako składowe publiczne. Należy pamiętać, że takie „zaprzyjaźnienie” nie może być przekazywane dalej. Choć ty jesteś moim przyjacielem, a Joe jest twoim przyjacielem, nie oznacza to, że Joe jest moim przyjacielem. Przyjaźń nie jest także dziedziczona. Choć jesteś moim przyjacielem i mam zamiar wyjawić ci swoją tajemnicę, nie oznacza to, że mam zamiar wyjawić ją twoim dzieciom. „Zaprzyjaźnienie” nie działa zwrotnie. Zadeklarowanie klasy ClassOne jako klasy zaprzyjaźnionej klasy ClassTwo nie sprawia, że klasa ClassTwo jest klasą zaprzyjaźnioną klasy ClassOne. Być może chcesz wyjawić mi swoje sekrety, ale to nie oznacza, że ja chcę wyjawić ci moje. Listing 16.7 ilustruje zastosowanie klasy zaprzyjaźnionej. Ten przykład to zmodyfikowana wersja listingu 16.6, w której klasa PartsList jest klasą zaprzyjaźnioną klasy PartNode. Zwróć uwagę, że to nie czyni z klasy PartNode klasy zaprzyjaźnionej klasy PartsList. Listing 16.7. Przykład klasy zaprzyjaźnionej 0:
//Listing 16.7 Przykład klasy zaprzyjaźnionej
1: 2:
#include
3:
using namespace std;
4: 5:
// **************** Część ************
6: 7:
// Abstrakcyjna klasa bazowa części
8:
class Part
9: 10:
{ public:
11:
Part():itsPartNumber(1) {}
12:
Part(int PartNumber):
13:
itsPartNumber(PartNumber){}
14:
virtual ~Part(){}
15:
int GetPartNumber() const
16:
{ return itsPartNumber; }
17: 18:
virtual void Display() const =0; private:
19: 20:
int itsPartNumber; };
21: 22:
// implementacja czystej funkcji wirtualnej, dzięki temu
23:
// mogą z niej korzystać klasy pochodne
24:
void Part::Display() const
25:
{
26:
cout << "\nNumer czesci: ";
27:
cout << itsPartNumber << endl;
28:
}
29: 30:
// **************** Część samochodu ************
31: 32:
class CarPart : public Part
33:
{
34:
public:
35:
CarPart():itsModelYear(94){}
36:
CarPart(int year, int partNumber);
37:
virtual void Display() const
38:
{
39:
Part::Display();
40:
cout << "Rok modelu: ";
41:
cout << itsModelYear << endl;
42:
}
43:
private:
44: 45:
int itsModelYear; };
46: 47:
CarPart::CarPart(int year, int partNumber):
48:
itsModelYear(year),
49:
Part(partNumber)
50:
{}
51: 52: 53:
// **************** Część samolotu ************
54: 55:
class AirPlanePart : public Part
56:
{
57:
public:
58:
AirPlanePart():itsEngineNumber(1){};
59:
AirPlanePart(int EngineNumber, int PartNumber);
60:
virtual void Display() const
61:
{
62:
Part::Display();
63:
cout << "Nr silnika: ";
64:
cout << itsEngineNumber << endl;
65:
}
66:
private:
67: 68:
int itsEngineNumber; };
69: 70:
AirPlanePart::AirPlanePart(int EngineNumber, int PartNumber):
71:
itsEngineNumber(EngineNumber),
72:
Part(PartNumber)
73:
{}
74: 75:
// **************** Węzeł części ************
76:
class PartNode
77:
{
78:
public:
79:
friend class PartsList;
80:
PartNode (Part*);
81:
~PartNode();
82:
void SetNext(PartNode * node)
83:
{ itsNext = node; }
84:
PartNode * GetNext() const;
85:
Part * GetPart() const;
86:
private:
87:
Part *itsPart;
88:
PartNode * itsNext;
89:
};
90: 91: 92:
PartNode::PartNode(Part* pPart):
93:
itsPart(pPart),
94:
itsNext(0)
95:
{}
96: 97:
PartNode::~PartNode()
98:
{
99:
delete itsPart;
100:
itsPart = 0;
101:
delete itsNext;
102:
itsNext = 0;
103:
}
104: 105:
// Gdy nie ma następnego węzła części, zwraca NULL
106:
PartNode * PartNode::GetNext() const
107:
{
108: 109:
return itsNext; }
110: 111:
Part * PartNode::GetPart() const
112:
{
113:
if (itsPart)
114:
return itsPart;
115:
else
116: 117: 118: 119:
return NULL; //błąd }
120:
// **************** Klasa PartList ************
121:
class PartsList
122:
{
123:
public:
124:
PartsList();
125:
~PartsList();
126:
// wymaga konstruktora kopiującegoi i operatora porównania!
127:
void
Iterate(void (Part::*f)()const) const;
128:
Part*
Find(int & position, int PartNumber) const;
129:
Part*
GetFirst() const;
130:
void
Insert(Part *);
131:
Part*
operator[](int) const;
132:
int
GetCount() const { return itsCount; }
133:
static
PartsList& GetGlobalPartsList()
134:
{
135:
return
136:
}
137:
private:
GlobalPartsList;
138:
PartNode * pHead;
139:
int itsCount;
140:
static PartsList GlobalPartsList;
141:
};
142: 143:
PartsList PartsList::GlobalPartsList;
144: 145:
// Implementacje list...
146: 147:
PartsList::PartsList():
148:
pHead(0),
149:
itsCount(0)
150:
{}
151: 152:
PartsList::~PartsList()
153:
{
154: 155: 156:
delete pHead; }
157:
Part*
158:
{
159:
if (pHead)
160:
return pHead->itsPart;
161:
else
162: 163:
PartsList::GetFirst() const
return NULL;
// w celu wykrycia błędu
}
164: 165:
Part * PartsList::operator[](int offSet) const
166:
{
167:
PartNode* pNode = pHead;
168: 169:
if (!pHead)
170:
return NULL;
// w celu wykrycia błędu
171: 172:
if (offSet > itsCount)
173:
return NULL; // błąd
174: 175:
for (int i=0;i
176:
pNode = pNode->itsNext;
177: 178: 179:
return
pNode->itsPart;
}
180: 181:
Part* PartsList::Find(int & position, int PartNumber) const
182:
{
183:
PartNode * pNode = 0;
184:
for (pNode = pHead, position = 0;
185:
pNode!=NULL;
186:
pNode = pNode->itsNext, position++)
187:
{
188:
if (pNode->itsPart->GetPartNumber() == PartNumber)
189:
break;
190:
}
191:
if (pNode == NULL)
192:
return NULL;
193:
else
194: 195:
return pNode->itsPart; }
196: 197:
void PartsList::Iterate(void (Part::*func)()const) const
198:
{
199:
if (!pHead)
200:
return;
201:
PartNode* pNode = pHead;
202:
do
203:
(pNode->itsPart->*func)();
204:
while (pNode = pNode->itsNext);
205:
}
206: 207:
void PartsList::Insert(Part* pPart)
208:
{
209:
PartNode * pNode = new PartNode(pPart);
210:
PartNode * pCurrent = pHead;
211:
PartNode * pNext = 0;
212: 213:
int New =
pPart->GetPartNumber();
214:
int Next = 0;
215:
itsCount++;
216: 217:
if (!pHead)
218:
{
219:
pHead = pNode;
220:
return;
221:
}
222: 223:
// jeśli ten węzeł jest mniejszy niŜ głowa,
224:
// staje się nową głową
225:
if (pHead->itsPart->GetPartNumber() > New)
226:
{
227:
pNode->itsNext = pHead;
228:
pHead = pNode;
229:
return;
230:
}
231: 232:
for (;;)
233:
{
234:
// jeśli nie ma następnego, dołączamy ten nowy
235:
if (!pCurrent->itsNext)
236:
{
237:
pCurrent->itsNext = pNode;
238:
return;
239:
}
240: 241:
// jeśli trafia pomiędzy bieŜący a nastepny,
242:
// wstawiamy go tu; w przeciwnym razie bierzemy następny
243:
pNext = pCurrent->itsNext;
244:
Next = pNext->itsPart->GetPartNumber();
245:
if (Next > New)
246:
{
247:
pCurrent->itsNext = pNode;
248:
pNode->itsNext = pNext;
249:
return;
250:
}
251:
pCurrent = pNext;
252: 253:
} }
254: 255:
class PartsCatalog : private PartsList
256:
{
257:
public:
258:
void Insert(Part *);
259:
int Exists(int PartNumber);
260:
Part * Get(int PartNumber);
261:
operator+(const PartsCatalog &);
262:
void ShowAll() { Iterate(Part::Display); }
263:
private:
264:
};
265: 266:
void PartsCatalog::Insert(Part * newPart)
267:
{
268:
int partNumber =
269:
int offset;
newPart->GetPartNumber();
270: 271:
if (!Find(offset, partNumber))
272:
PartsList::Insert(newPart);
273:
else
274:
{
275:
cout << partNumber << " byl ";
276:
switch (offset)
277:
{
278:
case 0:
cout << "pierwsza "; break;
279:
case 1:
cout << "druga "; break;
280:
case 2:
cout << "trzecia "; break;
281:
default: cout << offset+1 << "-ta ";
282:
}
283:
cout << "pozycja. Odrzucony!\n";
284: 285:
} }
286: 287:
int PartsCatalog::Exists(int PartNumber)
288:
{
289:
int offset;
290:
Find(offset,PartNumber);
291:
return offset;
292:
}
293: 294:
Part * PartsCatalog::Get(int PartNumber)
295:
{
296:
int offset;
297:
return (Find(offset, PartNumber));
298:
}
299: 300:
int main()
301:
{
302:
PartsCatalog pc;
303:
Part * pPart = 0;
304:
int PartNumber;
305:
int value;
306:
int choice;
307: 308:
while (1)
309:
{
310:
cout << "(0)Wyjscie (1)Samochod (2)Samolot: ";
311:
cin >> choice;
312: 313:
if (!choice)
314:
break;
315: 316:
cout << "Nowy numer czesci?: ";
317:
cin >>
PartNumber;
318: 319:
if (choice == 1)
320:
{
321:
cout << "Model?: ";
322:
cin >> value;
323:
pPart = new CarPart(value,PartNumber);
324:
}
325:
else
326:
{
327:
cout << "Numer silnika?: ";
328:
cin >> value;
329:
pPart = new AirPlanePart(value,PartNumber);
330:
}
331:
pc.Insert(pPart);
332:
}
333:
pc.ShowAll();
334:
return 0;
335:
}
Wynik (0)Wyjscie (1)Samochod (2)Samolot: 1 Nowy numer czesci?: 1234 Model?: 94 (0)Wyjscie (1)Samochod (2)Samolot: 1
Nowy numer czesci?: 4434 Model?: 93 (0)Wyjscie (1)Samochod (2)Samolot: 1 Nowy numer czesci?: 1234 Model?: 94 1234 byl pierwsza pozycja. Odrzucony! (0)Wyjscie (1)Samochod (2)Samolot: 1 Nowy numer czesci?: 2345 Model?: 93 (0)Wyjscie (1)Samochod (2)Samolot: 0
Numer czesci: 1234 Rok modelu: 94
Numer czesci: 2345 Rok modelu: 93
Numer czesci: 4434 Rok modelu: 93
Analiza W linii 79. klasa PartsList została zadeklarowana jako klasa zaprzyjaźniona klasy PartNode. Na listingu deklarację klasy zaprzyjaźnionej umieszczono w sekcji publicznej, ale nie jest to niezbędne; klasa zaprzyjaźniona może być zadeklarowana w dowolnym miejscu deklaracji klasy, bez konieczności zmiany znaczenia instrukcji zaprzyjaźnienia (friend). Dzięki tej instrukcji wszystkie prywatne funkcje i dane składowe klasy PartNode stają się dostępne dla wszystkich funkcji składowych klasy PartsList. Zmianę tę odzwierciedla w linii 157. implementacja funkcji GetFirst(). Zamiast zwracać pHead->GetPart, obecnie funkcja ta może zwrócić (wcześniej będącą prywatną) zmienną składową pHead->itsPart. Metoda Insert() może teraz użyć pNode->itsNext (zamiast wywoływać pNode->SetNext(pHead)). Oczywiście, zmiany te są kosmetyczne i nie było istotnego powodu, by uczynić z klasy PartsList klasę zaprzyjaźnioną klasy PartNode, ale zmiany te mogą posłużyłyć dola zilustrowania działania słowa kluczowego friend (przyjaciel). Klasy zaprzyjaźnione powinny być deklarowane z dużą rozwagą. Deklaracja taka przydaje się, gdy dwie klasy są ze sobą ściśle powiązane i często korzystają ze swoich składowych. Jednak używaj jej rozsądnie; często równie łatwe okazuje się zastosowanie publicznych akcesorów, dzięki którym można modyfikować jedną z klas bez konieczności modyfikowania drugiej.
UWAGA Często słyszy się, że początkujący programiści C++ narzekają, że deklaracja klasy zaprzyjaźnionej pomniejsza znaczenia kapsułkowania, tak ważnego dla obiektowo zorientowanego programowania. Mówiąc szczerze, to nonsens. Deklaracja friend czyni z zadeklarowanej klasy zaprzyjaźnionej część interfejsu klasy i nie pomniejsza znaczenia kapsułkowania bardziej podkopuje eniż publiczne dziedziczenie.
Klasa zaprzyjaźniona
Aby zadeklarować klasę zaprzyjaźnioną innej klasy, przed nazwą klasy, której chcesz przydzielić dostęp, umieść słowo kluczowe friend. W ten sposób ja mogę zadeklarować, że jesteś moim przyjacielem, ale ty sam nie możesz tego zadeklarować.
Przykład: class PartNode { public: friend class PartsList; // deklaruje klasę PartsList jako zaprzyjaźnioną };
Funkcje zaprzyjaźnione Czasem zdarza się, że chcemy nadać ten poziom dostępu nie całej klasie, ale tylko jednej czy dwóm jej funkcjom. Możemy to uczynić, deklarując jako zaprzyjaźnioną funkcję innej klasy (a nie całą tę klasę). W rzeczywistości, jako zaprzyjaźnioną możemy zadeklarować dowolną funkcję, nie tylko funkcję składową innej klasy.
Funkcje zaprzyjaźnione i przeciążanie operatorów Listing 16.1 zawierał klasę String, w której został przeciążony operator+. Oprócz tego, klasa ta zawierała konstruktor przyjmujący stały wskaźnik do znaków, dzięki czemu można było tworzyć obiekty łańcuchów z łańcuchów znaków w stylu C. Pozwalało to na tworzenie obiektu łańcucha i dodawanie do niego łańcucha w stylu C.
UWAGA Łańcuchy w stylu C są tablicami znaków zakończonymi znakiem null, takimi jak char myString[] = "Witaj Świecie".
Nie mogliśmy jednak stworzyć łańcucha w stylu C (tablicy znaków) i dodać do niego obiektuu łańcuchowegoa, tak jak w poniższymch przykładzieliniach:
char cString[] = {"Witaj"}; String sString(" Swiecie"); String sStringTwo = cString + sString; // błąd!
Łańcuchy w stylu C nie posiadają przeciążonego operatora+. Jak wspominaliśmy w rozdziale 10., „Funkcje zaawansowane”, gdy piszemy cString + sString;, w rzeczywistości wywołujemy cString.operator+(sString). Ponieważ łańcuchy w stylu C nie posiadają operatora+(), powoduje to błąd kompilacji. Możemy rozwiązać ten problem, deklarując w klasie String funkcję zaprzyjaźnioną, która przeciąży operator+, lecz przyjmie dwa obiekty łańcuchów. Łańcuch w stylu C zostanie zamieniony przez odpowiedni konstruktor na obiekt łańcucha, po czym zostanie wywołany operator+ używający dwóch obiektów łańcuchów. Listing 16.8. Zaprzyjaźniony operator+ 0:
//Listing 16.8 - zaprzyjaźnione operatory
1: 2:
#include
3:
#include
4:
using namespace std;
5: 6:
// Zasadnicza klasa obiektu łańcuchowegoa
7:
class String
8:
{
9:
public:
10:
// konstruktory
11:
String();
12:
String(const char *const);
13:
String(const String &);
14:
~String();
15: 16:
// przeciąŜone operatory
17:
char & operator[](int offset);
18:
char operator[](int offset) const;
19:
String operator+(const String&);
20:
friend String operator+(const String&, const String&);
21:
void operator+=(const String&);
22:
String & operator= (const String &);
23: 24:
// ogólne akcesory
25:
int GetLen()const { return itsLen; }
26:
const char * GetString() const { return itsString; }
27: 28:
private:
29:
String (int);
30:
char * itsString;
31: 32:
// prywatny konstruktor
unsigned short itsLen; };
33: 34:
// domyślny konstruktor tworzący ciąg pusty (0zera bajtów)
35:
String::String()
36:
{
37:
itsString = new char[1];
38:
itsString[0] = '\0';
39:
itsLen=0;
40:
// cout << "\tDomyslny konstruktor lancucha\n";
41:
// ConstructorCount++;
42:
}
43: 44:
// prywatny (pomocniczy) konstruktor, uŜywany tylko przez
45:
// metody klasy przy tworzeniu nowego, wypełnionego zerowymi
46:
// bajtami, łańcucha o Ŝądanej długości
47:
String::String(int len)
48:
{
49:
itsString = new char[len+1];
50:
for (int i = 0; i<=len; i++)
51:
itsString[i] = '\0';
52:
itsLen=len;
53:
// cout << "\tKonstruktor String(int)\n";
54:
// ConstructorCount++;
55:
}
56: 57:
// Zamienia tablice znakow w typ String
58:
String::String(const char * const cString)
59:
{
60:
itsLen = strlen(cString);
61:
itsString = new char[itsLen+1];
62:
for (int i = 0; i
63:
itsString[i] = cString[i];
64:
itsString[itsLen]='\0';
65:
// cout << "\tKonstruktor String(char*)\n";
66:
// ConstructorCount++;
67:
}
68: 69:
// konstruktor kopiującyi
70:
String::String (const String & rhs)
71:
{
72:
itsLen=rhs.GetLen();
73:
itsString = new char[itsLen+1];
74:
for (int i = 0; i
75:
itsString[i] = rhs[i];
76:
itsString[itsLen] = '\0';
77:
// cout << "\tKonstruktor String(String&)\n";
78:
// ConstructorCount++;
79:
}
80: 81:
// destruktor, zwalnia zaalokowaną pamięć
82:
String::~String ()
83:
{
84:
delete [] itsString;
85:
itsLen = 0;
86: 87:
// cout << "\tDestruktor klasy String\n"; }
88: 89:
// operator równości, zwalnia istniejącą pamięć,
90:
// po czym kopiuje łańcuch i rozmiar
91:
String& String::operator=(const String & rhs)
92:
{
93:
if (this == &rhs)
94:
return *this;
95:
delete [] itsString;
96:
itsLen=rhs.GetLen();
97:
itsString = new char[itsLen+1];
98:
for (int i = 0; i
99:
itsString[i] = rhs[i];
100:
itsString[itsLen] = '\0';
101:
return *this;
102:
// cout << "\toperator= klasy String\n";
103:
}
104: 105:
//nie const operator indeksu, zwraca
106:
// referencję do znaku, więc moŜna go
107:
// zmienić!
108:
char & String::operator[](int offset)
109:
{
110:
if (offset > itsLen)
111:
return itsString[itsLen-1];
112:
else
113: 114:
return itsString[offset]; }
115: 116:
// const operator indeksu do uŜywania z obiektami
117:
// const (patrz konstruktor kopiującyi!)
118:
char String::operator[](int offset) const
119:
{
120:
if (offset > itsLen)
121:
return itsString[itsLen-1];
122:
else
123: 124:
return itsString[offset]; }
125: 126:
// tworzy nowy łańcuch przez dodanie
127:
// bieŜącego łańcucha do rhs
128:
String String::operator+(const String& rhs)
129:
{
130:
int
131:
String temp(totalLen);
132:
int i, j;
133:
for (i = 0; i
134:
totalLen = itsLen + rhs.GetLen();
temp[i] = itsString[i];
135:
for (j = 0, i = itsLen; j
136:
temp[i] = rhs[j];
137:
temp[totalLen]='\0';
138: 139:
return temp; }
140: 141:
// tworzy nowy łańcuch przez dodanie
142:
// jednego łańcucha do drugiego
143:
String operator+(const String& lhs, const String& rhs)
144:
{
145:
int
146:
String temp(totalLen);
147:
int i, j;
148:
for (i = 0; i
149:
totalLen = lhs.GetLen() + rhs.GetLen();
temp[i] = lhs[i];
150:
for (j = 0, i = lhs.GetLen(); j
151:
temp[i] = rhs[j];
152:
temp[totalLen]='\0';
153:
return temp;
154:
}
155: 156:
int main()
157:
{
158:
String s1("Lancuch Jeden ");
159:
String s2("Lancuch Dwa ");
160:
char *c1 = { "Lancuch-C Jeden " } ;
161:
String s3;
162:
String s4;
163:
String s5;
164: 165:
cout << "s1: " << s1.GetString() << endl;
166:
cout << "s2: " << s2.GetString() << endl;
167:
cout << "c1: " << c1 << endl;
168:
s3 = s1 + s2;
169:
cout << "s3: " << s3.GetString() << endl;
170:
s4 = s1 + c1;
171:
cout << "s4: " << s4.GetString() << endl;
172:
s5 = c1 + s2;
173:
cout << "s5: " << s5.GetString() << endl;
174:
return 0;
175:
}
Wynik s1: Lancuch Jeden s2: Lancuch Dwa c1: Lancuch-C Jeden s3: Lancuch Jeden Lancuch Dwa s4: Lancuch Jeden Lancuch-C Jeden s5: Lancuch-C Jeden Lancuch Dwa
Analiza Z wyjątkiem metody operator+, wszystkie inne metody pozostały takie same jak na listingu 16.1. W linii 21. nowy operator, operator+, został przeciążony tak, aby przyjmowałujący dwie stałe referencje do łańcuchów i zwracałjący łańcuch; metoda ta została zadeklarowana jako zaprzyjaźniona. Zwróć uwagę, że operator+ nie jest funkcją składową tej, ani żadnej innej klasy. W klasie String jest ona deklarowana tylko po to, aby uczynić ją zaprzyjaźnioną dla tej klasy, ale ponieważ jest zadeklarowana, nie potrzebujemy już innego prototypu. Implementacja funkcji operator+ jest zawarta w liniach od 143. do 154. Zwróć uwagę, że jest ona podobna do wcześniejszej wersji operatora+, ale przyjmuje dwa łańcuchy i odwołuje się do nich poprzez publiczne akcesory. Program sterujący demonstruje użycie tej funkcji w linii 172., w której operator+ może być teraz wywołany dla łańcucha w stylu C.
Funkcje zaprzyjaźnione
Funkcję zaprzyjaźnioną deklaruje się, używając słowa kluczowego friend oraz pełnej specyfikacji funkcji. Zadeklarowanie funkcji jako zaprzyjaźnionej nie umożliwia jej dostępu do wskaźnika this klasy, ale udostępnia jej wszystkie prywatne i chronione funkcje i dane składowe.
Przykład: class PartNode {
// ... // deklarujemy jako zaprzyjaźnioną funkcję innej klasy friend void PartsList::Insert(Part *); // deklarujemy jako zaprzyjaźnioną funkcję globalną friend int SomeFunction(); // ...
};
Przeciążanie operatora wstawiania Jesteśmy już gotowi do nadania naszej klasie String możliwości korzystania z cout w taki samo sposób, w jaki czyni to każdy innye typy. Do tej pory, gdy chcieliśmy wypisać łańcuch, musieliśmy robić to następująco:
cout << theString.GetString();
My natomiast chcemy mieć możliwość pisania:
cout << theString;
Aby to osiągnąć, musimy przeciążyć operator<<(). W rozdziale 17. zajmiemy się plusami i minusami pracy z obiektem iostream; na razie jedynie zobaczmy, jak na listingu 16.9 został przeciążony operator<< z wykorzystaniem funkcji zaprzyjaźnionej. Listing 16.9. Przeciążanie operatora<<() 0:
// Listing 16.9 PrzeciąŜanie operatora<<()
1: 2:
#include
3:
#include
4:
using namespace std;
5: 6:
class String
7:
{
8:
public:
9:
// konstruktory
10:
String();
11:
String(const char *const);
12:
String(const String &);
13:
~String();
14: 15:
// przeciąŜone operatory
16:
char & operator[](int offset);
17:
char operator[](int offset) const;
18:
String operator+(const String&);
19:
void operator+=(const String&);
20:
String & operator= (const String &);
21:
friend ostream& operator<<
22:
( ostream& theStream,String& theString);
23:
// ogólne akcesory
24:
int GetLen()const { return itsLen; }
25:
const char * GetString() const { return itsString; }
26: 27:
private:
28:
String (int);
29:
char * itsString;
30:
unsigned short itsLen;
31:
// prywatny konstruktor
};
32: 33: 34:
// domyślny konstruktor tworzący ciąg zera bajtów
35:
String::String()
36:
{
37:
itsString = new char[1];
38:
itsString[0] = '\0';
39:
itsLen=0;
40:
// cout << "\tDomyslny konstruktor lancucha\n";
41:
// ConstructorCount++;
42:
}
43: 44:
// prywatny (pomocniczy) konstruktor, uŜywany tylko przez
45:
// metody klasy przy tworzeniu nowego, wypełnionego zerowymi
46:
// bajtami, łańcucha o Ŝądanej długości
47:
String::String(int len)
48:
{
49:
itsString = new char[len+1];
50:
for (int i = 0; i<=len; i++)
51:
itsString[i] = '\0';
52:
itsLen=len;
53:
// cout << "\tKonstruktor String(int)\n";
54:
// ConstructorCount++;
55:
}
56: 57:
// Zamienia tablice znaków w typ String
58:
String::String(const char * const cString)
59:
{
60:
itsLen = strlen(cString);
61:
itsString = new char[itsLen+1];
62:
for (int i = 0; i
63:
itsString[i] = cString[i];
64:
itsString[itsLen]='\0';
65:
// cout << "\tKonstruktor String(char*)\n";
66: 67:
// ConstructorCount++; }
68: 69:
// konstruktor kopiującyi
70:
String::String (const String & rhs)
71:
{
72:
itsLen=rhs.GetLen();
73:
itsString = new char[itsLen+1];
74:
for (int i = 0; i
75:
itsString[i] = rhs[i];
76:
itsString[itsLen] = '\0';
77:
// cout << "\tKonstruktor String(String&)\n";
78:
// ConstructorCount++;
79:
}
80: 81:
// destruktor, zwalnia zaalokowaną pamięć
82:
String::~String ()
83:
{
84:
delete [] itsString;
85:
itsLen = 0;
86: 87:
// cout << "\tDestruktor klasy String\n"; }
88: 89:
// operator równości, zwalnia istniejącą pamięć,
90:
// po czym kopiuje łańcuch i rozmiar
91:
String& String::operator=(const String & rhs)
92:
{
93:
if (this == &rhs)
94:
return *this;
95:
delete [] itsString;
96:
itsLen=rhs.GetLen();
97:
itsString = new char[itsLen+1];
98:
for (int i = 0; i
99:
itsString[i] = rhs[i];
100:
itsString[itsLen] = '\0';
101:
return *this;
102:
// cout << "\toperator= klasy String\n";
103:
}
104: 105:
// nie const operator indeksu, zwraca
106:
// referencję do znaku, więc moŜna go
107:
// zmienić!
108:
char & String::operator[](int offset)
109:
{
110:
if (offset > itsLen)
111:
return itsString[itsLen-1];
112:
else
113: 114:
return itsString[offset]; }
115: 116:
// const operator indeksu do uŜywania z obiektami
117:
// const (patrz konstruktor kopiującyi!)
118:
char String::operator[](int offset) const
119:
{
120:
if (offset > itsLen)
121:
return itsString[itsLen-1];
122:
else
123: 124:
return itsString[offset]; }
125: 126:
// tworzy nowy łańcuch przez dodanie
127:
// bieŜącego łańcucha do rhs
128:
String String::operator+(const String& rhs)
129:
{
130:
int
131:
String temp(totalLen);
132:
int i, j;
133:
for (i = 0; i
134:
temp[i] = itsString[i];
135:
totalLen = itsLen + rhs.GetLen();
for (j = 0; j
136:
temp[i] = rhs[j];
137:
temp[totalLen]='\0';
138:
return temp;
139:
}
140: 141:
// zmienia bieŜący łańcuch, nie zwraca nic
142:
void String::operator+=(const String& rhs)
143:
{
144:
unsigned short rhsLen = rhs.GetLen();
145:
unsigned short totalLen = itsLen + rhsLen;
146:
String
147:
int i, j;
148:
for (i = 0; i
149:
temp[i] = itsString[i];
150:
for (j = 0, i = 0; j
151:
temp[i] = rhs[i-itsLen];
152:
temp[totalLen]='\0';
153: 154: 155:
temp(totalLen);
*this = temp; }
156:
// int String::ConstructorCount =
157:
ostream& operator<< ( ostream& theStream,String& theString)
158:
{
159:
theStream << theString.itsString;
160: 161:
return theStream; }
162: 163:
int main()
164:
{
165:
String theString("Witaj swiecie.");
166:
cout << theString;
167:
return 0;
168:
}
Wynik Witaj swiecie.
Analiza W linii 21. operator<< został zadeklarowany jako funkcja zaprzyjaźniona, przyjmująca referencję do ostream oraz referencję do obiektu klasy String i zwracająca referencję do ostream. Zauważ, że nie jest to funkcja składowa klasy String. Zwraca ona referencję do ostream, więc możemy łączyć wywołania operatora<<, na przykład tak jak w poniższej linii:
cout << "Mam: " << itsAge << " lat.";
Implementacja samej funkcji zaprzyjaźnionej jest zawarta w liniach od 157. do 161. W rzeczywistości ukrywa ona tylko szczegóły przekazywania łańcucha do ostream (to właśnie powinna robić). Więcej informacji na temat przeciążania tego operatora oraz operatora>> znajdziesz w rozdziale 17.
Rozdział 17. Strumienie Do tej pory używaliśmy cout do wypisywania tekstu na ekranie, zaś cin do odczytywania klawiatury, wykorzystywaliśmy je bez pełnego zrozumienia ich działania. Z tego rozdziału dowiesz się: •
czym są strumienie i jak się ich używa,
•
jak zarządzać wejściem i wyjściem, wykorzystując strumienie,
•
jak odczytywać i zapisywać pliki za pomocą strumieni.
Przegląd strumieni C++ nie określa, w jaki sposób dane są wypisywane na ekranie lub zapisywane do pliku, ani w jaki sposób są one odczytywane przez program. Operacje te stanowią jednak podstawową część pracy z C++, więc biblioteka standardowa C++ zawiera bibliotekę iostream, która obsługuje wejście i wyjście (I/O, input-output). Zaletą oddzielenia funkcji wejścia-wyjścia od języka i obsługiwania ich w bibliotekach jest możliwość łatwiejszego przenoszenia programów pomiędzy różnymi platformami. Dzięki temu można napisać program na komputerze PC, a następnie przekompilować go i uruchomić na stacji roboczej Sun. Producent kompilatora dostarcza odpowiednią bibliotekę i wszystko działa. Przynajmniej w teorii.
UWAGA Biblioteka jest zbiorem plików .obj, które mogą być połączone z programem w celu zapewnienia dodatkowej funkcjonalności. Jest to najbardziej podstawowa forma ponownegowielokrotnego wykorzystywania kodu, i używana od czasów pierwszych programistów, ryjących zera i jedynki na ścianach jaskiń.
Kapsułkowanie Klasy biblioteki iostream postrzegają przepływ danych z programu na ekran jako strumień, płynący bajt po bajcie. Jeśli punktem docelowym strumienia jest plik lub ekran, wtedy jego źródłem jest zwykle jakaś część programu. Gdy strumień jest odwrócony, dane mogą pochodzić z klawiatury lub z dysku i mogą „wypełniać” zmienne w programie. Jednym z podstawowych zadań strumieni jest kapsułkowanie problemu pobierania danych z wejścia (na przykład dysku) i wysyłania ich do wyjścia (na przykład na ekran). Po stworzeniu strumienia program działa z tym strumieniem, któryprzy czym strumień zajmuje się wszystkimi szczegółami. Tę podstawową ideę ilustruje rysunek 17.1.
Rys. 17.1. Kapsułkowanie poprzez strumienie
Buforowanie Zapis na dysk (i w mniejszym stopniu także na ekran) jest bardzo „kosztowny.” Zapis danych na dysk lub odczyt ich z dysku zajmuje (stosunkowo) dużo czasu, a podczas operacji zapisu i odczytu działanie programu zwykle jest zablokowane. Aby rozwiązać ten problem, strumienie oferują „buforowanie”. Dane są zapisywane do strumienia, ale nie są natychmiast zapisywane na dysk. Zamiast tego bufor strumienia wypełnia się danymi; gdy się całkowicie wypełni, dane są zapisywane na dysk w ramach pojedynczej operacji.
Wyobraźmy sobie wodę wpływającą do zbiornika przez górny zawór i wypełniającą go, lecz nie wypływającą z niego poprzez dolny zawór. Ilustruje to rysunek 17.2.
Rys. 17.2. Wypełnianie bufora
Gdy woda (dane) całkowicie wypełni zbiornik, zawór się otwiera i cała zawartość gwałtownie wypływa. Pokazuje to rysunek 17.3.
Rys. 17.3. Opróżnianie bufora
Gdy bufor się opróżni, dolny zawór zostaje zamknięty, otwiera się górny zawór i do zbiornikabufora zaczyna napływać woda. Przedstawia to rysunek 17.4.
Rys. 17.4. Ponowne napełnianie bufora
Często zdarza się, że chcemy wypuścić wodę ze zbiornika jeszcze zanim całkowicie się wypełni. Nazywa się to „zrzucaniem bufora”. Ilustruje to rysunek 17.5.
Rys. 17.5. Zrzucanie bufora
Strumienie i bufory Jak można było oczekiwać, C++ implementuje strumienie i bufory w sposób obiektowy. •
Klasa streambuf zarządza buforem, zaś jej funkcje składowe oferują możliwość wypełniania, opróżniania, zrzucania i innych sposobów manipulowania buforem.
•
Klasa ios jest bazową klasą dla klas wejścia-wyjścia z użyciem strumieni. Jedną ze zmiennych składowych tej klasy jest obiekt klasy streambuf.
•
Klasy istream i ostream są wyprowadzone z klasy ios i specjalizują działanie strumieni wejściowych i wyjściowych.
•
Klasa iostream jest wyprowadzona zarówno z klasy istream, jak i ostream i dostarcza metod wejścia-wyjścia do wypisywania danych na ekranie i odczytywania ich z klawiatury.
•
Klasa fstream zapewnia wejście i wyjście z oraz do plików.
Standardowe obiekty wejścia-wyjścia Gdy program C++, zawierający klasę iostream, rozpoczyna działanie, tworzy i inicjalizuje cztery obiekty:
UWAGA Biblioteka klasy iostream jest dodawana przez kompilator do programu automatycznie. Aby użyć jej funkcji, musisz dołączyć do początku kodu swojego programu odpowiednią instrukcję #include.
•
cin (wymawiane jako „si-in”) obsługuje wprowadzanie danych ze standardowego wejścia,
czyli klawiatury. •
cout (wymawiane jako „si-aut”) obsługuje wyprowadzanie danych na standardowe wyjście, czyli ekran.
•
cerr (wymawiane jako „si-err”) obsługuje nie buforowane wyprowadzanie danych na
standardowe urządzenie wyjściae dla wydruku błędów, czyli ekran. Ponieważ wyjście błędów nie jest buforowane, wszystko co zostanie wysłane do cerr, jest wypisywane na standardowym urządzeniu błędów natychmiast, bez oczekiwania na wypełnienie bufora lub nadejście polecenia zrzutu. •
clog (wymawiane jako „si-log”) obsługuje buforowane wyprowadzanie danych na
standardowe wyjście błędów, czyli ekran. Dość często to wyjście jest przekierowywane do pliku log dziennika, co zostanie opisane w następnym podrozdziale.
Przekierowywanie Każde ze standardowych urządzeń, wejścia, wyjścia oraz błędów, może zostać przekierowane na inne urządzenie. Standardowe wyjście błędów często jest przekierowywane do pliku, zaś standardowe wejście i wyjście mogą zostać połączone potokowo z plikami danych wejściowych i wyjściowych. Można uzyskać ten efekt za pomocą poleceń systemu operacyjnego. Przekierowanie oznacza powiązanie wyjścia (lub wejścia) z miejscem innym niż domyślne. Operatory przekierowania dla DOS-a i UNIKS-a to: < dla przekierowania wejścia oraz > dla przekierowania wyjścia. Potok oznacza wykorzystanie danych wyjściowych jednego programu jako danych wejściowych drugiego. DOS zapewnia jedynie podstawowe polecenia przekierowywania, takie jak przekierowane wyjście (>) i przekierowane wejście (<). UNIX posiada bardziej zaawansowane możliwości przekierowywania, ale rządząca nimi zasada jest ta sama: zapisz wyniki skierowane na ekran do pliku lub przekaż je potokiem do innego programu. Podobnie, dane wejściowe dla programu mogą być pobierane z pliku, a nie z domyślnej klawiatury. Przekierowywanie jest raczej funkcją systemu operacyjnego niż bibliotek iostream. C++ zapewnia jedynie dostęp do czterech urządzeń standardowych; przekierowanie tych urządzeń w odpowiednie miejsca należy do użytkownika.
Wejście z użyciem cin Globalny obiekt cin odpowiada za wejście i jest dostępny dla programu po dołączeniu biblioteki iostream. We wcześniejszych przykładach, w celu umieszczania danych w zmiennych programu, używaliśmy przeciążonego operatora ekstrakcji (>>). Jak to działa? Składnia, jak być może pamiętasz, jest następująca: int someVariable; cout << "Wpisz liczbe: "; cin >> someVariable;
Globalny obiekt cout zostanie opisany w dalszej części rozdziału; na razie skupmy się na trzeciej linii, cin >> someVariable;. Czego możemy dowiedzieć się na temat cin? Oczywiście, musi to być obiekt globalny, gdyż nie zdefiniowaliśmy go w naszym kodzie. Z doświadczenia wiemy, że cin posiada przeciążony operator ekstrakcji (>>) i że efektem tego jest wypełnienie naszej lokalnej zmiennej someVariable danymi z bufora cin. Nie od razu możemy domyślić się, że cin przeciąża operator ekstrakcji dla bardzo różnych typów parametrów, między innymi int&, short&, long&, double&, float&, char* i tak dalej. Gdy
piszemy cin >> someVariable;, analizowany jest typ zmiennej someVariable. W poprzednim przykładzie zmienna ta była typu int, więc została wywołana następująca funkcja: istream & operator>> (int &)
Zauważ, że ponieważ parametr jest przekazywany poprzez referencję, operator ekstrakcji może działać na pierwotnej zmiennej. Listing 17.1 ilustruje użycie obiektu cin.
Listing 17.1. Obiekt cin obsługuje różne typy danych 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30:
//Listing 17.1 - uŜycie obiektu cin #include using namespace std; int main() { int myInt; long myLong; double myDouble; float myFloat; unsigned int myUnsigned; cout << "int: "; cin >> myInt; cout << "long: "; cin >> myLong; cout << "double: "; cin >> myDouble; cout << "float: "; cin >> myFloat; cout << "unsigned: "; cin >> myUnsigned; cout << "\n\nint:\t" << myInt << endl; cout << "long:\t" << myLong << endl; cout << "double:\t" << myDouble << endl; cout << "float:\t" << myFloat << endl; cout << "unsigned:\t" << myUnsigned << endl; return 0; }
Wynik int: 2 long: 70000 double: 987654321 float: 3.33 unsigned: 25
int: long: double: float:
2 70000 9.87654e+008 3.33
unsigned:
25
Analiza W liniach od 7. do 11. są deklarowane zmienne różnych typów. W liniach od 13. do 22. użytkownik jest proszony o wprowadzenie wartości dla tych zmiennych, po czym w liniach od 24. do 28. wypisywane są (z użyciem cout) wyniki. Wynik odzwierciedla fakt, że dane zostały umieszczone w zmiennych odpowiedniego „rodzaju” i że program działa tak, jak mogliśmy tego oczekiwać.
Łańcuchy Obiekt cin może także obsługiwać argumenty w postaci łańcuchów do znaków (char*); tak więc możemy stworzyć bufor znaków i użyć cin do jego wypełnienia. Na przykład, możemy napisać: char YourName[50]; cout << "Wpisz swoje imie: "; cin >> YourName;
Gdy wpiszesz Jesse, zmienna YourName zostanie wypełniona znakami J, e, s, s, \0. Ostatni znak to null; cin automatycznie kończy nim łańcuch, dlatego w buforze musi być wystarczająca ilość miejsca na pomieszczenie całego łańcucha oraz kończącego go znaku null. Dla funkcji biblioteki standardowej znak null oznacza koniec łańcucha. Funkcje biblioteki standardowej zostaną omówione w rozdziale 21., „Co dalej”.
Problemy z łańcuchami Pamiętając o wszystkich zaletach obiektu cin, możesz być zaskoczony, próbując wpisać do łańcucha swoje pełne imię i nazwisko. Obiekt cin traktuje białe spacje jako separatory. Gdy natrafia na spację lub znak nowej linii, zakłada, że dane wejściowe dla parametru są kompletne i, w przypadku łańcuchów, dodaje na ich końcu znak null. Problem ten ilustruje listing 17.2.
Listing 17.2. Próba wpisania do cin więcej niż jednego słowa 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11:
//Listing 17.2 - cin i łańcuchy znaków #include int main() { char YourName[50]; std::cout << "Podaj imie: "; std::cin >> YourName; std::cout << "Masz na imie: " << YourName << std::endl; std::cout << "Podaj imie i nazwisko: "; std::cin >> YourName;
12: 13: 14:
std::cout << "Nazywasz sie: " << YourName << std::endl; return 0; }
Wynik Podaj imie: Jesse Masz na imie: Jesse Podaj imie i nazwisko: Jesse Liberty Nazywasz sie: Jesse
Analiza W linii 6. została stworzona tablica znaków (w celu przechowania danych wprowadzanych przez użytkownika). W linii 7. użytkownik jest proszony o wpisanie jedynie imienia; imię to jest przechowywane prawidłowo, co odzwierciedla wynik. W linii 10. użytkownik jest proszony o podanie całego nazwiska. Obiekt cin odczytuje dane wejściowe i gdy natrafia na spację pomiędzy wyrazami, umieszcza po pierwszym słowie znak null i kończy odczytywanie danych. Nie tego oczekiwaliśmy. Aby zrozumieć, dlaczego działa to w ten sposób, przeanalizuj listing 17.3. Wyświetla on dane wprowadzone do kilku pól.
Listing 17.3 Wejście wielokrotne 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29:
//Listing 17.3 - działanie cin #include using namespace std; int main() { int myInt; long myLong; double myDouble; float myFloat; unsigned int myUnsigned; char myWord[50]; cout << "int: "; cin >> myInt; cout << "long: "; cin >> myLong; cout << "double: "; cin >> myDouble; cout << "float: "; cin >> myFloat; cout << "slowo: "; cin >> myWord; cout << "unsigned: "; cin >> myUnsigned; cout << "\n\nint:\t" << myInt << endl; cout << "long:\t" << myLong << endl; cout << "double:\t" << myDouble << endl;
30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46:
cout << "float:\t" << myFloat << endl; cout << "slowo: \t" << myWord << endl; cout << "unsigned:\t" << myUnsigned << endl; cout << "\n\nint, long, double, float, slowo, unsigned: "; cin >> myInt >> myLong >> myDouble; cin >> myFloat >> myWord >> myUnsigned; cout << "\n\nint:\t" << myInt << endl; cout << "long:\t" << myLong << endl; cout << "double:\t" << myDouble << endl; cout << "float:\t" << myFloat << endl; cout << "slowo: \t" << myWord << endl; cout << "unsigned:\t" << myUnsigned << endl;
return 0; }
Wynik int: 2 long: 30303 double: 393939397834 float: 3.33 slowo: Hello unsigned: 85
int: 2 long: 30303 double: 3.93939e+011 float: 3.33 slowo: Hello unsigned: 85
int, long, double, float, word, unsigned: 3 304938 393847473 6.66 bye -2
int: 3 long: 304938 double: 3.93847e+008 float: 6.66 slowo: bye unsigned: 4294967294
Analiza Także w tym przykładzie zostało stworzonych kilka zmiennych, tym razem obejmujących także tablicę znaków. Użytkownik jest proszony o wprowadzenie danych, które następnie zostają wypisane. W linii 34. użytkownik jest proszony i wpisanie wszystkich danych jednocześnie, po czym każde „słowo” danych wejściowych jest przypisywane odpowiedniej zmiennej. W przypadku takiego
wielokrotnego przypisania, obiekt cin musi potraktować każde słowo jako pełne dane dla każdej ze zmiennych. Gdyby obiekt cin potraktował wszystkie dane jako skierowane do jednej zmiennej, wtedy takie połączone wprowadzanie danych nie byłoby możliwe. Zwróć uwagę, że w linii 42. ostatnim zażądanym obiektem była liczba całkowita bez znaku, lecz użytkownik wpisał wartość –2. Ponieważ cin wierzy, że odczytuje liczbę całkowitą bez znaku, wzorzec bitowy wartości –2 został odczytany jako liczba bez znaku. Wartość ta, wypisana przez cout, to 4294967294. Wartość całkowita bez znaku 4294967294 ma dokładnie ten sam wzór bitów, co wartość całkowita ze znakiem równa –2. W dalszej części rozdziału zobaczymy, jak wpisać do bufora cały łańcuch z wieloma słowami. Na razie jednak pojawia się pytanie: w jaki sposób operator ekstrakcji daje sobie radę z łączeniem wartości?
operator>> zwraca referencję do obiektu istream Wartością zwracaną przez cin jest referencja do obiektu istream. Ponieważ samo cin jest obiektem klasy istream, więc zwracana wartość jednej ekstrakcji może być wejściem dla następnej. int varOne, varTwo, varThree; cout << "Wpisz trzy liczby: "; cin >> varOne >> varTwo >> varThree;
Gdy piszemy cin >> varOne >> varTwo >> varThree;, wtedy pierwsza ekstrakcja jest obliczana jako (cin >> varOne). Otrzymana wartość jest kolejnym obiektem istream i operator ekstrakcji tego obiektu otrzymuje zmienną varTwo. Wygląda to tak, jakbyśmy napisali: ((cin >> varOne) >> varTwo) >> varThree;
Do techniki tej wrócimy później, przy omawianiu działania obiektu cout.
Inne funkcje składowe klasyw dyspozycji cin Oprócz przeciążonego operatora>>, obiekt cin posiadaW dyspozycji obiektu cin pozostaje nie tylko operator >>, ale także inne funkcje składowe. Są one używane wtedy, gdy jest wymagana bardziej precyzyjna kontrola wprowadzania danych.
Wprowadzanie pojedynczych znaków operator>> przyjmujący referencję do znaku może być użyty do pobierania pojedynczego znaku
ze standardowego wejścia. Do pobrania pojedynczego znaku można także użyć funkcji składowej get(), i to na dwa sposoby: funkcja get() może zostać wywołana bez parametrów(wtedy
wykorzystywana jest wartość zwracana) lub z referencją do znaku.
Użycie get() bez parametrów Pierwsza forma funkcji get() nie przyjmuje żadnych parametrów. Zwraca ona odczytany znak lub znak EOF (znak końca pliku, end of file) w chwili dojścia do końca pliku. Funkcja get() bez parametrów nie jest używana zbyt często. Nie ma możliwości łączenia tej funkcji z wielokrotnym wprowadzaniem, gdyż zwracaną wartością nie jest obiekt klasy iostream. Dlatego nie zadziała poniższa linia kodu: cin.get() >> myVarOne >> myVarTwo; // niedozwolone
Wartością zwracaną przez cin.get() >> myVarOne jest wartość całkowita, a nie obiekt klasy iostream. Najczęstsze zastosowanie funkcji get() bez parametrów przedstawia listing 17.4.
Listing 17.4. Użycie funkcji get() bez parametrów 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13:
Wynik Hello ch: H ch: e ch: l ch: l ch: o ch: World
// Listing 17.4 - Using get() with no parameters #include int main() { char ch; while ( (ch = std::cin.get()) != EOF) { std::cout << "ch: " << ch << std::endl; } std::cout << "\nGotowe!\n"; return 0; }
ch: ch: ch: ch: ch: ch:
W o r l d
(ctrl+z) Gotowe!
Analiza W linii 6. została zadeklarowana lokalna zmienna znakowa ch. Pętla while przypisuje dane otrzymane od funkcji get.cin() do ch, i gdy nie jest to znak końca pliku, wypisywany jest łańcuch. Wypisywane dane są buforowane aż do chwili osiągnięcia końca linii. Gdy zostanie napotkany znak EOF (wprowadzony w wyniku naciśnięcia kombinacji klawiszy Ctrl+Z w DOS-ie lub Ctrl+D w UNIKS-ie), następuje wyjście z pętli. Pamiętaj, że nie każda implementacja klasy istream obsługuje tę wersję funkcji get(), mimo iż obecnie stanowi ona część standardu ANSI/ISO.
Użycie funkcji get() z parametrem w postaci referencji do znaku Gdy jako parametr funkcji get() zostanie użyty znak, jest on wypełniany następnym znakiem ze strumienia wejściowego. Zwracaną wartością jest obiekt iostream, więc wywołanie tej funkcji get() może być łączone, co ilustruje listing 17.5.
Listing 17.5. Użycie funkcji get() z parametrem 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15:
// Listing 17.5 - UŜycie funkcji get() z parametrem #include int main() { char a, b, c; std::cout << "Wpisz trzy litery: "; std::cin.get(a).get(b).get(c); std::cout << "a: " << a << "\nb: "; std::cout << b << "\nc: " << c << std::endl; return 0; }
Wynik Wpisz trzy litery: raz
a: r b: a c: z
Analiza W linii 6. zostały zadeklarowane trzy zmienne znakowe. W linii 10. zostaje trzykrotnie wywołana, w sposób połączony, funkcja cin.get(). Najpierw jest wywoływana cin.get(a). To powoduje umieszczenie pierwszej litery w zmiennej a i zwrócenie obiektu cin. W rezultacie, po powrocie z wywołania, zostaje wywołane cin.get(b), a w zmiennej b jest umieszczana następna litera. Ostatecznie wywołane zostaje cin.get(c), a w zmiennej c zostaje umieszczona trzecia litera. Ponieważ cin.get(a) zwraca obiekt cin, moglibyśmy napisać: cin.gat(a) >> b;
W tej formie cin.get(a) zwraca obiekt cin, więc drugą frazą jest cin >> b;.
TAK
NIE
Używaj operatora ekstrakcji (>>)wtedy, gdy chcesz pominąć białe spacje. Używaj funkcji get() z parametrem wtedy, gdy chcesz sprawdzić każdy znak, włącznie z białymi spacjami.
Odczytywanie łańcuchów z wejścia standardowego Operator ekstrakcji (>>) może być używany do wypełniania tablicy znaków, podobnie jak funkcje get() i getline(). Ostatnia forma funkcji get() przyjmuje trzy parametry. Pierwszym z nich jest wskaźnik do tablicy znaków, drugim parametrem jest maksymalna ilość znaków do odczytania plus jeden, zaś trzecim parametrem jest znak kończący. Gdy jako drugi parametr podasz wartość 20, funkcja get() odczyta dziewiętnaście znaków, doda znak null, po czym umieści całość w buforze wskazywanym przez pierwszy parametr. Trzeci parametr, znak kończący, jest domyślnie znakiem nowej linii ('\n'). Gdy znak kończący zostanie napotkany przed odczytaniem maksymalnej ilości znaków, do łańcucha zostaje dodany znak null, a znak kończący pozostaje w buforze wejściowym. Sposób użycia tej formy funkcji get() przedstawia listing 17.6.
Listing 17.6. Użycie funkcji get() z tablicą znaków 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18:
// Listing 17.6 - UŜycie funkcji get() z tablicą znaków #include using namespace std; int main() { char stringOne[256]; char stringTwo[256]; cout << "Wpisz pierwszy lancuch: "; cin.get(stringOne,256); cout << "Pierwszy lancuch: " << stringOne << endl; cout << "Wpisz drugi lancuch: "; cin >> stringTwo; cout << "Drugi lancuch: " << stringTwo << endl; return 0; }
Wynik Wpisz pierwszy lancuch: Dobry zart Pierwszy lancuch: Dobry zart Wpisz drugi lancuch: tynfa wart Drugi lancuch: tynfa
Analiza W liniach 7. i 8. zostały zadeklarowane dwie tablice znaków. W linii 10. użytkownik jest proszony o wprowadzeanie łańcucha, po czym w linii 11. zostaje wywołana funkcja cin.get(). Pierwszym parametrem jest bufor do wypełnienia, zaś drugim jest zwiększona o jeden maksymalna ilość znaków, jaką funkcja get() może przyjąć (dodatkowa pozycja jest zarezerwowana dla znaku null, '\0'). Domyślnym, trzecim parametrem jest znak nowej linii. Użytkownik wpisuje „Dobry Ŝart”. Ponieważ fraza ta kończy się znakiem nowej linii, jest ona umieszczana wraz z kończącym znakiem null w buforze stringOne. W linii 14. użytkownik jest proszony o wpisanie kolejnego łańcucha; tym razem do odczytania go został użyty operator ekstrakcji. Ponieważ operator ekstrakcji odczytuje znaki tylko do chwili napotkania białej spacji, w drugim buforze zostaje umieszczony wyraz „tynfa”, wraz z końcowym znakiem null. Oczywiście, nie tego chcieliśmy. Innym sposobem rozwiązania tego problemu jest użycie funkcji getline(), co ilustruje listing 17.7.
Listing 17.7. Użycie funkcji getline() 0: 1: 2: 3: 4:
// Listing 17.7 - UŜycie funkcji getline() #include using namespace std;
5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23:
int main() { char stringOne[256]; char stringTwo[256]; char stringThree[256]; cout << "Wpisz pierwszy lancuch: "; cin.getline(stringOne,256); cout << "Pierwszy lancuch: " << stringOne << endl; cout << "Wpisz drugi lancuch: "; cin >> stringTwo; cout << "Drugi lancuch: " << stringTwo << endl; cout << "Wpisz trzeci lancuch: "; cin.getline(stringThree,256); cout << "Trzeci lancuch: " << stringThree << endl; return 0; }
Wynik Wpisz pierwszy lancuch: raz dwa trzy Pierwszy lancuch: raz dwa trzy Wpisz drugi lancuch: cztery piec szesc Drugi lancuch: cztery Wpisz trzeci lancuch: Trzeci lancuch: piec szesc
Analiza Ten przykład wymaga dokładnego przeanalizowania, gdyż może sprawić kilka niespodzianek. W liniach od 7. do 9. zostały zadeklarowane trzy tablice znaków. W linii 11. użytkownik jest proszony o wprowadzenie łańcucha; ten łańcuch jest odczytywany za pomocą funkcji getline(). Podobnie jak funkcja get(), funkcja getline() przyjmuje bufor i maksymalną liczbę znaków. Jednak w odróżnieniu od funkcji get(), końcowy znak nowej linii jest odczytywany i odrzucany. Funkcja get() nie odrzuca końcowego znaku nowej linii, ale pozostawia go w buforze wejściowym. W linii 15. użytkownik jest ponownie proszony o wpisanie łańcucha; tym razem do jego odczytania zostaje użyty operator ekstrakcji. Użytkownik wpisuje cztery pięć sześć, a w tablicy buforze stringTwo umieszczane jest pierwsze słowo cztery. Następnie wyświetlany jest komunikat Wpisz trzeci łańcuch: i ponownie zostaje wywołana funkcja getline(). Ponieważ w buforze wejściowym nadal znajdują się słowa pięć sześć, zostają one natychmiast odczytane, aż do znaku nowej linii; dopiero wtedy funkcja getline() kończy działanie i łańcuch z bufora stringThree zostaje wypisany na ekranie (w 21. linii kodu). Użytkownik nie ma możliwości wpisania trzeciego łańcucha, gdyż drugie wywołanie funkcji getline() zostaje spełnionezaspokojone przez dane pozostające jeszcze w buforze wejściowym po wywołaniu operatora ekstrakcji w linii 16. Operator ekstrakcji (>>) odczytuje znaki aż do chwili napotkania pierwszego białego znaku, wtedy umieszcza odczytane słowo w tablicy znaków.
Funkcja składowa get() jest przeciążona. W pierwszej wersji nie przyjmuje żadnych parametrów i zwraca znak pobrany z bufora wejściowego. W drugiej wersji przyjmuje referencję do pojedynczego znaku i poprzez referencję zwraca obiekt klasy istream. W trzeciej i ostatniej wersji funkcja get() przyjmuje tablicę znaków, ilość znaków do pobrania oraz znak kończący (którym domyślnie jest znak nowej linii). Ta wersja funkcji get() odczytuje znaki do tablicy aż do chwili odczytania maksymalnej ich ilości (mniejszej o jeden od wartości podanej jako jej parametr) lub do natrafienia na znak końcowyy. Gdy funkcja get() natrafi na znak końcowyy, przestaje odczytywać dalsze znaki, a znak końcowy pozostawia w buforze. Funkcja składowa getline() także przyjmuje trzy parametry: bufor do wypełnienia, zwiększoną o jeden maksymalną ilość znaków, jaką może odczytać, oraz znak końcowyy. Funkcja getline() działa tak samo, jak funkcja get() z takimi samymi parametrami, z wyjątkiem tego, że funkcja getline() odrzuca znak końcowy.
Użycie cin.ignore() Czasem zdarza się, że chcemy pominąć znaki, które pozostały na końcu linii (do znaku EOL, end of line) lub do końca pliku (EOF, end of file). Służy do tego funkcja składowa ignore(). Funkcja ta przyjmuje dwa parametry: maksymalną ilość znaków do pominięcia oraz znak końcowy. Gdybyśmy napisali ignore(80,'\n'), zostałoby odrzuconych do osiemdziesięciu znaków. Znaleziony znak nowej linii zostałby odrzucony i funkcja zakończyłaby działanie. Użycie funkcji ignore() ilustruje listing 17.8.
Listing 17.8. Użycie funkcji ignore() 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25:
// Listing 17.8 - UŜycie funkcji ignore() #include using namespace std; int main() { char stringOne[255]; char stringTwo[255]; cout << "Wpisz pierwszy lancuch: "; cin.get(stringOne,255); cout << "Pierwszy lancuch: " << stringOne << endl; cout << "Wpisz drugi lancuch: "; cin.getline(stringTwo,255); cout << "Drugi lancuch: " << stringTwo << endl; cout << "\n\nA teraz sprobuj ponownie...\n"; cout << "Wpisz pierwszy lancuch: "; cin.get(stringOne,255); cout << "Pierwszy lancuch: " << stringOne<< endl; cin.ignore(255,'\n'); cout << "Wpisz drugi lancuch: ";
26: 27: 28: 29:
cin.getline(stringTwo,255); cout << "Drugi lancuch: " << stringTwo<< endl; return 0; }
Wynik Wpisz pierwszy lancuch: dawno temu Pierwszy lancuch: dawno temu Wpisz drugi lancuch: Drugi lancuch:
A teraz sprobuj ponownie... Wpisz pierwszy lancuch: dawno temu Pierwszy lancuch: dawno temu Wpisz drugi lancuch: byl sobie... Drugi lancuch: byl sobie...
Analiza W liniach 6. i 7. zostały stworzone dwie tablice znaków. W linii 9. użytkownik jest proszony o wprowadzenie łańcucha, więc wpisuje słowa dawno temu, po których naciska klawisz Enter. Do odczytania łańcucha zostaje w linii 10. wywołana funkcja get(). Funkcja get() wypełnia tablicę stringOne i kończy działanie na znaku nowej linii, ale nie odrzuca go, lecz pozostawia w buforze wejściowym. W linii 13. użytkownik jest ponownie proszony o wpisanie łańcucha znaków, ale funkcja getline() w linii 14. odczytuje pozostały w buforze znak nowej linii i natychmiast po tym kończy działanie (zanim użytkownik może wpisać jakikolwiek znak). W linii 19. użytkownik jest jeszcze raz proszony o dokonanie wpisu i wpisuje te same słowa, co na początku. Jednak tym razem, w linii 23., zostaje użyta funkcja ignore(), która „zjada” pozostający znak nowej linii. W związku z tym, gdy w linii 26. zostaje wywołana funkcja getline(), bufor wejściowy jest już pusty i użytkownik może wprowadzić następną linię historyjki.
peek() oraz putback() Obiekt strumienia wejściowego cin posiada dwie dodatkowe metody, które mogą okazać się całkiem przydatne: funkcję peek(), która sprawdza, czy w buforze jest dostępny znak, lecz nie pobiera go, oraz funkcję putback(), która wstawia znak do strumienia wejściowego. Listing 17.9 pokazuje, w jaki sposób mogłyby zostać użyte te funkcje.
Listing 17.9. Użycie funkcji peek() i putback() 0: 1: 2: 3:
// Listing 17.9 - UŜycie funkcji peek() i putback() #include using namespace std;
4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18:
int main() { char ch; cout << "Wpisz fraze: "; while ( cin.get(ch) ) { if (ch == '!') cin.putback('$'); else cout << ch; while (cin.peek() == '#') cin.ignore(1,'#'); } return 0; }
Wynik Wpisz fraze: Nadszedl!czas#na!dobra#zabawe! Nadszedl$czasna$dobrazabawe$
Analiza W linii 6. została zadeklarowana zmienna znakowa ch, a w linii 7. użytkownik jest proszony o wpisanie frazy. Zadaniem tego programu jest zamiana każdego wykrzyknika (!) na znak dolara ($) oraz usunięcie każdego znaku hash (#). Program działa w pętli aż do napotkania znaku końca pliku (Ctrl+C w Windows, Ctrl+ZC lub Ctrl+D w innych systemach operacyjnych). Pamiętajmy, że dla końca pliku funkcja cin.get() zwraca wartość 0 dla zasygnalizowania końca pliku. Jeśli bieżący znak jest wykrzyknikiem, zostaje odrzucony i do bufora wejściowego jest wstawiany znak dolara; zostanie on odczytany jako następny znak łańcucha. Jeśli bieżący znak nie jest wykrzyknikiem, zostaje wydrukowany. Każdy znak w buforze jest sprawdzany za pomocą funkcji peek() i jeśli jest to znak #, zostaje usunięty. Nie jest to najbardziej efektywny sposób wykonania tego zadania (nie usunie znaku #, gdy będzie on pierwszym znakiem wprowadzonego łańcucha), ale ilustruje sposób działania tych metod. Nie są one wykorzystywane zbyt często i nie łam sobie głowy, próbując na siłę wymyślić jakieś ich zastosowanie. Potraktuj je jako swego rodzaju sztuczkię; być może kiedyś do czegoś się przydadzą.
RADA Funkcje peek() i putback() są zwykle używane do przetwarzania łańcuchów i innych danych, na przykład wtedy, gdy chcemy stworzyć kompilator.
Wyjście poprzez cout Obiektu cout, wraz z przeciążonym operatorem wstawiania (<<), używaliśmy do wypisywania na ekranie łańcuchów, liczb całkowitych i innych danych. Istnieje także możliwość formatowania wypisywanych danych, wyrównywania kolumn i wypisywania danych numerycznych w postaci dziesiętnej i szesnastkowej. Wszystkie te zagadnienia opiszemy w tym podrozdziale.
Zrzucanie zawartości bufora Przekonaliśmy się, że użycie endl powoduje zrzucenie zawartości bufora. endl wywołuje funkcję składową flush() obiektu cout, która wypisuje wszystkie dane znajdujące się w buforze. Funkcję flush() można wywoływać bezpośrednio, albo poprzez wywołanie metody obiektu, albo poprzez napisanie: cout << flush
Przydaje się to wtedy, gdy chcemy mieć pewność, że bufor wyjściowy jest pusty i że jego zawartość została wypisana na ekranie.
Powiązane funkcje Działanie operatora ekstrakcji może być rozszerzone funkcjami get() i getline(); operator wstawiania także może być uzupełniony funkcjami put() oraz write(). Funkcja put() służy do wysyłania pojedynczego znaku do urządzenia wyjściowego. Ponieważ funkcja ta zwraca referencję do obiektu klasy ostream i ponieważ cout jest obiektem tej klasy, możemy łączyć wywołanie funkcji put() z wywołaniami operatora wstawiania. Ilustruje to listing 17.10.
Listing 17.10. Użycie funkcji put() 0: // Listing 17.10 - UŜycie funkcji put() 1: 2: #include 3: 4: int main() 5: { 6: std::cout.put('H').put('e').put('l').put('l').put('o').put('\n'); 7: return 0; 8: }
Wynik Hello
UWAGA Niektóre kompilatory mają problem z wypisywaniema znaków za pomocą powyższego kodu. Jeśli twój kompilator nie wypisze słowa Hello, możesz pominąć ten listing.
Analiza Linia 6. jest przetwarzana w następujący sposób: std::cout.put('H') wypisuje na ekranie literę H i zwraca obiekt cout. To pozostawia nam: cout.put('e').put('l').put('l').put('o').put('\n');
Wypisana zostaje litera e, pozostawiając cout.put('l'). Proces się powtarza: wypisane zostają litery i zwracany jest obiekt cout, aż do chwili wypisania końcowego znaku ('\n'), po czym funkcja kończy działanie. Funkcja write() działa podobnie jak operator wstawiania (<<), ale przyjmuje parametr określający maksymalną ilość znaków, jaka może zostać wypisana. Jej użycie pokazuje listing 17.11.
Listing 17.11. Użycie funkcji write() 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17:
// Listing 17.11 - UŜycie funkcji write() #include #include using namespace std; int main() { char One[] = "O jeden most za daleko"; int fullLength = strlen(One); int tooShort = fullLength -4; int tooLong = fullLength + 6; cout.write(One,fullLength) << "\n"; cout.write(One,tooShort) << "\n"; cout.write(One,tooLong) << "\n"; return 0; }
Wynik O jeden most za daleko O jeden most za da O jeden most za daleko ╠└ ↕
UWAGA W twoim komputerze wynik może wyglądać nieco inaczej.
Analiza W linii 7. jest tworzona pojedyncza fraza. W linii 9. zmienna fullLength (pełna długość) zostaje ustawiona na długość frazy, zmienna tooShort (zbyt krótka) zostaje ustawiona na długość pomniejszoną o cztery, zaś zmienna tooLong (zbyt długa) zostaje ustawiona na wartość fullLength plus sześć. W linii 13., za pomocą funkcji write(), zostaje wypisana cała fraza. Długość została ustawiona zgodnie z faktyczną długością frazy, więc wydruk jest poprawny. W linii 14. fraza jest wypisywana ponownie, lecz tym razem jest o cztery znaki krótsza niż pełna wersja, co odzwierciedla kolejna linia wyniku. W linii 15. fraza jest wypisywana jeszcze raz, ale tym razem funkcja write() ma wypisać o sześć znaków za dużo. Po wypisaniu frazy wypisane zostają znaki odpowiadające wartościom sześciu bajtów z pamięci położonej bezpośrednio za frazą.
Manipulatory, znaczniki oraz instrukcje formatowania Strumień wyjściowy posiada kilka znaczników stanu, określających aktualnie używany system liczbowy (dziesiętny lub szesnastkowy), szerokość wypisywanych pól oraz znak używany do wypełniania pól. Znacznik stanu jest bajtem, którego poszczególne bity posiadają określone znaczenia. Sposób operowania bitami zostanie opisany w rozdziale 21. Każdy ze znaczników klasy ostream może być ustawiany za pomocą funkcji składowych i manipulatorów.
Użycie cout.width() Domyślna szerokość wydruku niku umożliwia zmieszczenie w jej obrębie wypisywanej liczby, znaku lub łańcucha znajdującego się w buforze wyjściowym. Można ją zmienić, używając funkcji width(). Ponieważ width() jest funkcją składową, musi być wywoływana z użyciem obiektu cout. Zmienia ona szerokość jedynie następnego wypisywanego pola, po czym natychmiast przywraca ustawienia domyślne. Jej użycie ilustruje listing 17.12.
Listing 17.12. Dostosowywanie szerokości wydruku niku 0: 1: 2: 3:
// Listing 17.12 - Dostosowywanie szerokości wydruku #include using namespace std;
4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20:
int main() { cout << "Start >"; cout.width(25); cout << 123 << "< Koniec\n"; cout << "Start >"; cout.width(25); cout << 123<< "< Nastepny >"; cout << 456 << "< Koniec\n"; cout << "Start >"; cout.width(4); cout << 123456 << "< Koniec\n"; return 0; }
Wynik Start > Start > Start >123456< Koniec
123< Koniec 123< Nastepny >456< Koniec
Analiza Pierwsze wyjście (linie od 6. do 8. kodu) wypisuje liczbę 123 w polu, którego szerokość została ustawiona na 25 w linii 7.. Odzwierciedla to pierwsza linia wyniku. Drugie wyjście najpierw wypisuje liczbę 123 w polu o szerokości ustawionej na 25 znaków, po czym wypisuje wartość 456. Zauważ, że wartość 456 jest wypisywana w polu o szerokości umożliwiającej precyzyjne zmieszczenie tej liczby; jak wspomniano, funkcja width() odnosi się wyłącznie do następnego wypisywanego pola. Ostatnia linia wyniku pokazuje, iż ustawienie szerokości mniejszej niż wymagana oznacza ustawienie szerokości wystarczającej na zmieszczenie wypisywanego łańcucha.
Ustawianie znakówu wypełnieania Zwykle obiekt cout wypełnia puste pola (powstałe w wyniku wywołania funkcji width()) spacjami, tak jak widzieliśmy w poprzednim przykładzie. Czasem zdarza się jednak, że chcemy wypełnić ten obszar innymi znakami, na przykład gwiazdkami. W tym celu możemy wywołać funkcję fill(), jako argument przekazując jej znak, którego chcemy użyć jako znaku wypełnieania. Pokazuje to listing 17.13.
Listing 17.13. Użycie funkcji fill() 0: 1: 2: 3:
// Listing 17.13 - UŜycie funkcji fill() #include using namespace std;
4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17:
int main() { cout << "Start >"; cout.width(25); cout << 123 << "< Koniec\n";
cout << "Start >"; cout.width(25); cout.fill('*'); cout << 123 << "< Koniec\n"; return 0; }
Wynik Start > 123< Koniec Start >**********************123< Koniec
Analiza Linie od 7. do 9. stanowią powtórzenie poprzedniego przykładu. Linie od 12. do 15. także stanowią powtórzenie, ale tym razem, w linii 14., jako znak wypełniania zostaje wybrana gwiazdka, co odzwierciedla druga linia wyniku.
Funkcja setf() Obiekt iostream pamięta swój stan dzięki przechowywanym znacznikom. Możemy je ustawiać, wywołując funkcję setf() i przekazując jej jedną z predefiniowanych stałych wyliczeniowych. Obiekty posiadają stan wtedy, gdy któraś lub wszystkie z ich danych reprezentują warunki, które mogą ulegać zmianom podczas działania programu. Na przykład, możemy określić, czy mają być wyświetlane zera końcowee (tak by 20.00 nie zostało obcięte do 20). Aby wyłączyć zera końcowe, wywołaj setf(ios::showpoint). Stałe wyliczeniowe należą do zakresu klasy iostream (ios) i w związku z tym są stosowane z pełną kwalifikowaną nazwą w postaci ios::nazwa_znacznika, na przykład ios::showpoint. Używając ios::showpos można włączyć wyświetlanie znaku plus (+) przed liczbami dodatnimi. Z kolei do wyrównywania wyniku służą stałe ios::left (do lewej strony), ios::right (do prawej) lub ios::internal (znak wypełnieania jest wstawiany między prze znakiem przedrostkiem oznaczającym system a liczbą). Ustawiając znaczniki, możemy także wybraćustawić system dla wypisywanych liczb jako dziesiętny (stała ios::dec), ósemkowy (o podstawie osiem, stała ios::oct) lub szesnastkowy (o podstawie szesnaście, stała ios::hex). Te znaczniki mogą być także łączone z operatorem wstawiania. Ustawienia te ilustruje listing 17.14. Dodatkowo, listing ten pokazuje także zastosowanie manipulatora setw, który ustawia szerokość pola, ale może być przy tym łączony z operatorem wstawiania.
Listing 17.14. Użycie funkcji setf() 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31:
// Listing 17.14 - UŜycie funkcji setf() #include #include using namespace std; int main() { const int number = 185; cout << "Liczba to " << number << endl; cout << "Liczba to " << hex <<
number << endl;
cout.setf(ios::showbase); cout << "Liczba to " << hex <<
number << endl;
cout << "Liczba to " ; cout.width(10); cout << hex << number << endl; cout << "Liczba to