Programowanie w
C++Builderze
Tematyka tego rozdziału koncentruje się na czynności tworzenia kodu w języku C++.
Na początku zajmiemy się problematyką czytelności tworzonego kodu i zaprezentujemy środki
prowadzące do jej optymalizacji. Kod czytelny to kod łatwiejszy do zrozumienia i konserwacji, a
to przekłada się wprost na niższe koszty zarządzania projektem. Jednym z najważniejszych
czynników, wpływających na czytelność kodu, jest wybór i konsekwentne stosowanie
odpowiedniego stylu kodowania; w rozdziale tym przedstawimy kilka propozycji stylistycznych i
wyjaśnimy, dlaczego niektóre style zapisu kodu lepsze są od innych.
W dalszej kolejności przedstawimy kilka wskazówek dotyczących wybranych konstrukcji języka
C++ i ich stosowania w kodzie aplikacji tworzonej z użyciem C++Buildera. Niektóre z tych
konstrukcji okazują się być niezrozumiałe dla programistów stawiających pierwsze kroki w C++,
niektóre zaś bywają przez nich rozumiane opacznie; programiści ci znajdą tutaj kilka wskazówek,
pozwalających im uporządkować wiedzę. Niektóre zaawansowane zagadnienia, jak np. pascalowe
dziedzictwo biblioteki VCL C++Buildera, z pewnością zainteresują również programistów
bardziej zaawansowanych.
Style kodowania a czytelność programu
W podrozdziale tym zajmiemy się znaczeniem czytelności kodu i zaprezentujemy kilka metod
przyczyniających się do jej poprawy. Trzeba stwierdzić, iż niezależnie od wyboru konkretnego
stylu kodowania istotne jest jego konsekwentne stosowanie – zamieszczone dalej przykładowe
fragmenty programów celowo wykorzystują różne style kodowania, a to pozwala lepiej zrozumieć,
jak bardzo niepożądana jest wszelka niespójność w tej materii.
Proste i zwięzłe kodowanie
Jest niemal oczywiste, iż prostota z reguły przyczynia się do – szeroko rozumianej – efektywności,
tak więc przystępując do kodowania programu, należy dążyć do tworzenia kodu krótkiego i
prostego w interpretacji. Przynosi to dwojakiego rodzaju korzyści.
Po pierwsze, złożony problem zostaje niejako automatycznie rozbity na mniejsze fragmenty, z
których każdy jest łatwy do zrozumienia i wykonuje dobrze określone zadanie. Złożoność kodu
przekłada się wówczas jedynie na wyższy stopień jego abstrakcji, nie zaś na monstrualne niekiedy
rozmiary.
Przyjrzyjmy się bliżej funkcjom na poniższym wydruku:
Wydruk 2.1. Złożoność kodu a stopień abstrakcji
#include <vector>
double GetMaximumValue(const std::vector<double>& Vector)
throw(std::out_of_range)
{
double Maximum = Vector.at(0);
for(int i=0; i<Vector.size(); ++i)
{
if(Vector[i] > Maximum)
{
Maximum = Vector[i];
}
}
return Maximum;
}
void NormalizeVector(std::vector<double>& Vector)
{
if(!Vector.empty())
{
double Maximum = GetMaximumValue(Vector);
for(int i=0; i<Vector.size(); ++i)
{
Vector[i] -= Maximum;
}
}
}
Obydwie funkcje zbliżone są do siebie pod względem złożoności kodu, tymczasem druga z nich
wykonuje zagadnienie bardziej skomplikowane niż pierwsza; gdy jednak przyjrzeć się dokładniej
ich treści, różnica w stopniu abstrakcyjności kodu staje się natychmiast widoczna – obliczenie
maksymalnej wartości wśród elementów wektora wykonywane jest w drugiej funkcji jako
operacja elementarna, podczas gdy operacje elementarne w ramach pierwszej funkcji ograniczają
się do porównań i podstawień.
Pomijam tutaj TIP z górnej części strony 58 oryginału, bo jest kompletnie bez sensu
Druga ze wspomnianych korzyści wiąże się bezpośrednio z rozmiarem kodu – w kodzie o
niewielkich rozmiarach deklaracje zmiennych lokalnych i parametrów funkcji dostępne są niemal
natychmiast, bez konieczności nieustannego wertowania wielu nieraz stronic wydruku czy
przewijania znacznych rozmiarów wydruku.
Akapitowanie kodu
O czytelności kodu decyduje również jego przejrzysty układ graficzny. W odniesieniu do kodu
programu w języku C++ przejrzystość taką osiągnąć można przede wszystkim dzięki
przestrzeganiu następującej zasady: każdy z nawiasów {} ograniczających zawartość bloku
powinien być jedynym znakiem w swym wierszu, para odpowiadających sobie nawiasów
powinna znajdować się w tej samej kolumnie, zaś zawartość bloku powinna być wcięta o kilka
kolumn w stosunku do ograniczających go nawiasów (zazwyczaj stosuje się wcięcia o dwie lub
cztery kolumny).
Upewnij się, że w edytorze kodu wyłączona jest funkcja automatycznej konwersji ciągów spacji
na znaki tabulacji (decyduje o tym opcja
Use tab character
na karcie
General
opcji
edytora –
Tools|Editor Options
). Ze względu na potencjalnie odmienne traktowanie
znaku tabulacji w różnych edytorach wykorzystywanie go do akapitowania kodu może
spowodować, iż układ kodu poprawny w jednym środowisku stanie się karykaturalny w innym.
Układ taki ogromnie ułatwia identyfikację poszczególnych bloków i strukturę ich zagnieżdżania.
Widoczne stają się zakresy obowiązywania deklaracji zmiennych lokalnych i ewentualne
przypadki błędnego ich użycia poza zakresem. Nie należy zapominać, iż z każdym blokiem
związania jest pewna „instrukcja nagłówkowa”, na przykład instrukcja for; jest zrozumiałe, iż
instrukcja ta powinna rozpoczynać się w tej samej kolumnie, w której znajdują się nawiasy {}
ograniczające podporządkowany jej blok, gdyż w ten sposób łatwo widoczny staje się jej koniec.
Do przesuwania w poziomie zaznaczonych bloków tekstu służą w IDE kombinacje klawiszy
Ctrl+Shift+I
oraz
Ctrl+Shift+U
. Wielkość pojedynczego „skoku” (jako liczba kolumn)
określona jest za pomocą opcji
Block indent
na wspomnianej karcie
General
opcji
edytora; ustawienie jej na
1
zapewnia oczywiście największą elastyczność.
Największe jednak korzyści z opisanego sposobu akapitowania kodu widoczne są w przypadku
zagnieżdżonych instrukcji if-else. Oto fragment programu, badający, czy trzy liczby całkowite
A, B i C mogą być długościami boków trójkąta i jeżeli tak, to jakiego rodzaju trójkąt utworzą:
#include <ostream>
// zakładamy, że A <= B <= C
if (A + B > C)
if ((A==B) || (B==C))
if ((A==B) && (B==C)) std::cout << "Trójkąt równoboczny";
else
if ((A*A + B*B) == C*C)
std::cout << "Równoramienny trójkąt prostokątny";
else std::cout << "Trójkąt równoramienny";
else
if ( (A*A + B*B) == C*C )std::cout << "Trójkąt prostokątny";
else std::cout << "Taki sobie trójkąt";
else std::cout << "Odcinki o podanych długościach nie tworzą trójkąta";
Poza chaotycznym sposobem zapisu bezsensowne namiastki akapitowania mogą tu tylko
wprowadzić w błąd nieuważnego czytelnika. Oto ten sam fragment kodu zapisany zgodnie ze
wspomnianymi zasadami:
#include <ostream>
// zakładamy, że A <= B <= C
if (A + B > C)
{
if ((A==B) || (B==C))
{
if ((A==B) && (B==C))
{
std::cout << "Trójkąt równoboczny";
}
else if ((A*A + B*B) == C*C)
{
std::cout << "Równoramienny trójkąt prostokątny";
}
else std::cout << "Trójkąt równoramienny";
{
else if ( (A*A + B*B) == C*C )
{
std::cout << "Trójkąt prostokątny";
}
else
{
std::cout << "Taki sobie trójkąt";
}
}
else
{
std::cout << "Odcinki o podanych długościach nie tworzą trójkąta";
}
Jeżeli już mowa o instrukcjach if-else, przy znacznym ich zagnieżdżeniu
1
korzystne może się
okazać przekształcenie całej instrukcji if w instrukcję switch (o ile oczywiście jest to
możliwe).
1
Proszę zwrócić uwagę, iż rozbudowana instrukcja postaci
if ....
Pewną barierą ograniczającą swobodne akapitowanie jest ograniczona długość wiersza. Zbyt
długie wiersze skutecznie utrudniają przeglądanie tekstu, zmuszając do ciągłego przewijania
ekranu w lewo i prawo, stając się prawdziwym problemem w sytuacji drukowania tekstu.
Wiersze przekraczające szerokość strony wydruku mogą być obcięte albo zawinięte – w obydwu
przypadkach czytelność wydruku ulega niepotrzebnemu pogorszeniu. Edytor kodu zawiera
orientacyjny wskaźnik prawego marginesu, którego przekroczenie może zwiastować problemy z
wydrukiem. Położenie tego wskaźnika ustalić można za pomocą opcji edytora (Tools|Editor
Options|Display|Right margin); dla wydruku na kartce formatu A4 przy braku
prawego marginesu (opcja File|Print|Left margin ustawiona na 0) maksymalna
bezpieczna wartość dla prawego marginesu to 94. Wiersze wykraczające poza tak ustawiony
margines mogą być obcinane lub zawijane (zależnie od opcji File|Print|Wrap lines).
Najpewniejszym sposobem radzenia sobie ze zbyt długimi wierszami jest oczywiście ich unikanie.
Sprowadza się ono do umiejętnego dzielenia (w sensie przejścia do nowego wiersza) długich list
parametrów funkcji, dzielenia długich łańcuchów na dwa (lub więcej) łańcuchy konkatenowane za
pomocą operatora + itp. Niekiedy wystarczająca staje się drobna zmiana stylu kodowania –
poniższą instrukcję:
switch(Key)
{
case 'a' :
// tutaj długa linia kodu...
break;
case 'b' :
// tutaj długa linia kodu...
break;
default :
// tutaj długa linia kodu...
break;
}
można przepisać do następującej postaci:
switch(Key)
{
case 'a'
:
// tutaj długa linia kodu...
{
...
}
else if ....
{
...
}
else if ....
{
...
}
itd. jest tak naprawdę
zagnieżdżeniem
instrukcji
if
, bowiem badanie kolejnych warunków uzależnione
jest od spełnienia bądź niespełnienia warunków poprzedzających – przyp. tłum.
break;
case 'b'
:
// tutaj długa linia kodu...
break;
default
:
// tutaj długa linia kodu...
break;
}
przez co oszczędza się kilka znaków na szerokości wiersza.
Dla instrukcji for i if identyczny efekt osiągnąć można, lokując jej blok wykonawczy w
osobnym wierszu, czyli np. zamiast:
for(int i=0; i<10; ++i)
// tutaj długa linia kodu...
...
if( Key == 'a' || Key == 'A')
// inna długa linia kodu...
napisać można tak:
for(int i=0; i<10; ++i)
{
// tutaj długa linia kodu...
}
...
if( Key == 'a' || Key == 'A')
{
// inna długa linia kodu...
}
Takie łamanie wierszy daje jeszcze jedną dodatkową korzyść: śledzenie programu, zorientowane
jak wiadomo na poszczególne wiersze kodu, staje się dzięki temu bardziej selektywne – w jednym
wierszu znajduje się teraz bowiem mniej kodu.
Długie wyrażenia warunkowe w instrukcjach if również powinny być łamane pomiędzy kilka
wierszy, zawierających być może po jednym z warunków składowych, na przykład:
if( Key == VK_UP
|| Key == VK_DOWN
|| Key == VK_LEFT
|| Key == VK_RIGHT
|| Key == VK_HOME
|| Key == VK_END
{
instrukcje uwarunkowane
}
To nie przypadek, iż operatory || znajdują się na początku poszczególnych wierszy: po prostu
czytając tekst od strony lewej do prawej możemy od razu stwierdzić, iż mamy do czynienia z
alternatywą warunków. Umieszczenie operatora || na końcu wiersza informuje co prawda, iż
wiersz ten nie jest kompletny i posiada kontynuację, jednakże informacja taka nie jest raczej
przydatna dla ludzi, którzy (w przeciwieństwie do kompilatorów) zwyczajowo postrzegają całe
bloki tekstu, nie zaś jego poszczególne wiersze.
Podobnie do złożonych wyrażeń warunkowych powinny być dzielone długie listy parametrów
funkcji, na przykład:
Wydruk 2.2. Łamanie długiej listy parametrów funkcji
void DrawBoxWithMessage(const AnsiString &Message,
int Top,
int Left,
int Height,
int Width);
W przeciwieństwie jednak do łamania długich wyrażeń logicznych, przecinki oddzielające
poszczególne parametry znajdują się na końcu poszczególnych wierszy. Mają one znaczenie
wyłącznie dla kompilatora i nie niosą poza tym żadnej użytecznej informacji; umieszczenie
przecinka na początku wiersza – czyli tam, gdzie zwyczajowo spodziewane jest słowo kluczowe
lub identyfikator – powodowałoby tylko zbędne utrudnienia dla użytkownika czytającego tekst.
Tak samo podzielić można długi łańcuch na konkatenowane podłańcuchy:
Wydruk 2.3. Łamanie długiego łańcucha na konkatenowane podłańcuchy
AnsiString FilePath = "";
AnsiString FileName = "TestFile";
FilePath = "C:\\RootDirectory"
+ "\\"
+ "Branch\\Leaf"
+ FileName
+ ".txt";
Znak +, jako operator, znajduje się na początku każdego wiersza.
Dwa ostatnie przykłady wiążą się z wcięciami tekstu, co przez niektórych programistów może być
traktowane jako zbędna fatyga; można wówczas zrezygnować z tak rygorystycznego
wyrównywania poszczególnych wierszy, pisząc po prostu:
void DrawBoxWithMessage(const AnsiString &Message,
int Top,
int Left,
int Height,
int Width);
czy też:
FilePath = "C:\\RootDirectory"
+ "\\"
+ "Branch\\Leaf"
+ FileName
+ ".txt";
co – jakkolwiek nieco mniej czytelne – stanowi bądź co bądź niezły kompromis.
Znaczącą pomoc w kompletowaniu kodu, oszczędzającą wysiłek programisty,
stanowią
szablony kodu
(code templates). Po wpisaniu (w edytorze kodu)
charakterystycznej dla danego szablonu frazy początkowej i naciśnięciu
Ctrl+J
spowodujemy kompletne wygenerowanie tegoż szablonu. Szablony dostarczane
standardowo przez C++Buildera niosą ze sobą duży ładunek funkcjonalności, niemniej
jednak dla utrzymania spójności kodowania własnych programów konieczne bywa
zazwyczaj uzupełnienie tego repertuaru o własne szablony – można to zrobić,
przechodząc na kartę
Code Insight
opcji edytora (
Tools|Editor Options
) i
dokonując niezbędnych uzupełnień; edytując szablon należy zwrócić uwagę na to, iż
znak pionowej kreski (
|
) w jego treści oznacza pozycję, w której ma się ustawić kursor
bezpośrednio po wygenerowaniu kodu.
Edycję i uzupełnianie dostępnych szablonów kodu można też przeprowadzić w sposób
bardziej bezpośredni – ich definicje znajdują się w postaci tekstowej w pliku
bcb.dci
zlokalizowanym w podkatalogu
Bin
katalogu, w którym zainstalowano C++Buildera.
Sugestywne nazewnictwo elementów programu
Kolejnym czynnikiem wpływającym na czytelność kodu programów jest tworzenie nazw
elementów tegoż programu – typów, zmiennych i funkcji – w sposób odzwierciedlający
przeznaczenie tych elementów. Analizując np. standardowe typy C++Buildera nie mamy przecież
wątpliwości, iż typ int reprezentuje liczby całkowite (ang. integers), zaś pod typem TFont
ukrywają się elementy definicji czcionki. Podobnie przykładowa definicja użytkownika
int LiczbaStron;
sugeruje wyraźnie, iż pod deklarowaną zmienną ukrywa się liczba stron jakiegoś tekstu, będąca z
natury liczbą całkowitą.
Nazewnictwo zmiennych odzwierciedlające ich przeznaczenie
Zmienne o sugestywnych nazwach łatwiejsze są do zidentyfikowania w kodzie programu niż
beznamiętne skrótowce: użycie do przechowywania nazwiska pracownika zmiennej
NazwiskoPrac jest z pewnością bardziej celowe niż użycie w tej roli zmiennej o nazwie (na
przykład) S, nic nie mówiącej o typie ani o przeznaczeniu zmiennej.
Wobec oczywistego faktu, iż oryginalne nazwy reprezentowanych zjawisk i obiektów ze świata
rzeczywistego (np. „kwota podlegająca odrębnemu opodatkowaniu”) są zwykle zbyt długie, by
można je było (sensownie) użyć w roli nazw zmiennych, istotne stają się sugestywne zasady
skracania nazw. Istnieje kilka przyjętych praktyk w tym względzie – rozpoczynanie każdego
członu nazwy wielką literą, oddzielanie poszczególnych członów znakami podkreślenia itp., tak,
że wspomnianą „kwotę podlegającą odrębnemu opodatkowaniu” można by przechowywać pod
zmienną opatrzoną np. jedną z następujących nazw:
KwotaOdrOpod
Kwota_Odr_Opod
kwotaOdrOpod
Kwota_odr_opod
Wszystko zależy od przyjętego stylu; używanie znaków podkreśleń ma przy tym tę słabą stronę, iż
dodatkowo wydłuża nazwy zmiennych. Niektórzy programiści dążą ponadto do uwzględnienia w
nazwach zmiennych pewnych dodatkowych cech, na przykład rozpoczynając z wielkiej litery
nazwy zmiennych zawierających kluczowe informacje, zaś z małej litery – nazwy zmiennych
roboczych, np. temp. Nie przeszkadza to niczemu pod warunkiem, iż przeznaczenie zmiennej
pozostaje czynnikiem decydującym w doborze jej nazwy.
Nazewnictwo zmiennych odzwierciedlające ich typy
Mimo iż generalnie informacja o przeznaczeniu zmiennej jest dla czytelności programu ważniejsza
niż informacja o jej typie, to jednak zdarzają się wyjątki od tej reguły, zwłaszcza w tych
przypadkach, gdzie konkretny typ wartości decyduje o szczególnym postępowaniu. Spójrzmy na
poniższy fragment kodu:
Wydruk 2.4. Szczególne znaczenie typu zmiennej
int Sum = 0;
int* Numbers = new int[20];
for(int i=0; i<20; ++i)
{
Numbers[i] = i*i;
Sum += Numbers[i];
}
double Average = Sum/20;
Jak łatwo zauważyć, powyższy fragment kodu oblicza średnią arytmetyczną kwadratów kolejnych
liczb całkowitych od 0 do 19. Oczekiwanym wynikiem jest 123,5, lecz pod zmienną Average
podstawiona zostanie wartość 123. Przyczyną tego jest fakt, iż dzielenie Sum/20 wykonuje się na
liczbach całkowitych, a więc jego wynik ulega obcięciu. Aby otrzymać poprawny wynik,
należałoby dokonać rzutowania zmiennej Sum na typ double:
double Average = static_cast<double>(Sum)/20;
Stosując rzutowanie typów, upewnij się, iż wystarczająco znasz wszystkie cztery jego typy w C++.
Unikaj też rzutowania w stylu klasycznego języka C na rzecz nowszych konstrukcji C++ – te
ostatnie są lepiej widoczne w kodzie i bardziej komunikatywne co do natury wykonywanych
operacji.
Co prawda przedstawiony przykład jest niezwykle trywialny i przypomina trochę użycie młota
pneumatycznego do rozłupania orzecha – o wiele prościej byłoby przecież zadeklarować zmienną
Sum jako double – uwidacznia on jednak w prosty sposób różnice wynikające z różnych typów
zmiennych.
Staraj się odkładać deklaracje zmiennych do czasu, gdy okażą się one rzeczywiście potrzebne.
Zmienne deklarowane „na zapas” mogą okazać się niepotrzebne w przypadku pominięcia
danego fragmentu kodu (na przykład w rezultacie wykonania instrukcji
if
) lub w przypadku
wystąpienia wyjątku. Unikaj deklaracji zmiennych w ramach pętli (chyba, że masz w tym jakiś
szczególny cel), unikaj również „mieszania” zmiennych wskaźnikowych i niewskaźnikowych w
tej samej deklaracji. Dobrym zwyczajem jest także nadawanie wartości początkowych
zmiennym podczas ich deklarowania.
Pożądaną praktyką jest deklarowanie zmiennych w postaci
<typ> <nazwa> ;
Dlaczego? Oto prosty przykład:
int* PointerToInt;
Zmienna
PointerToInt
jest typu wskaźnikowego i służy do przechowywania wskaźnika do
wartości typu
int
. Jeżeli jednak napisalibyśmy:
int* PointerToInt, AnotherPointerToInt;
to zmienna
AnotherPointerToInt
nie byłaby bynajmniej zmienną wskaźnikową, lecz
zmienną typu
int
, bowiem deklaracja ta zinterpretowana zostałaby przez kompilator jako:
int *PointerToInt, AnotherPointerToInt;
Aby uniknąć takich niejednoznaczności, należy deklarację tę rozbić na dwie niezależne
deklaracje:
int* PointerToInt;
int AnotherPointerToInt;
obydwie w zalecanej postaci
<typ> <zmienna>;
Upewnij się również, iż wszystkie zmienne wskaźnikowe inicjowane są wartością NULL (0) lub
jakimś poprawnym adresem; unikniesz w ten sposób wyjątków spowodowanych użyciem
„wiszących” wskaźników.
W niektórych przypadkach informacja o typie zmiennej jest na tyle istotna, iż konieczne staje się
odzwierciedlenie jej w nazwie. Najprostszym sposobem na to jest związanie określonego typu
zmiennej z pierwszą literą jej nazwy – na przykład „b” dla zmiennych boolowskich, „s” dla
łańcuchów itp. Podejście takie ma jednak dwie zasadnicze wady. Po pierwsze, generalizuje ono
opisaną rolę pierwszej litery nazwy na wszystkie zmienne, typy i funkcje programu – również
te, w których nazwach nie zamierzamy odzwierciedlać typu; po drugie – mnogość rozróżnianych
w programie typów może sprawić, iż w alfabecie zabraknie po prostu liter.
Sam pomysł jest jednak ciekawy; jego praktyczna realizacja polega na przypisywaniu określonego
znaczenia nie pierwszym literom nazw, lecz ściśle określonym przedrostkom. Realizacja ta
jest dziełem Charlesa Simonyi, z pochodzenia Węgra, dlatego też nazwana została notacją
węgierską; zastosowano ją po raz pierwszy w związku z konstrukcją biblioteki Win API.
Notacja węgierska określa rygorystycznie przedrostki nazw w zależności od ich typu i charakteru
– i tak np. nazwy zmiennych boolowskich rozpoczynają się od litery f (ang. flags), nazwy
łańcuchów od sz (ang. string with zero delimiter), wskaźników od p (ang. pointers) itp.
Konwencja ta ma tyluż zwolenników, co przeciwników – ci ostatni uważają, iż stwarza ona więcej
problemów, niż w rzeczywistości rozwiązuje. Nazwy tworzone według jej zasad niewątpliwie
trudne są do wymówienia i zapamiętania, co wcale nie ułatwia pracy czytelnikowi studiującemu
złożony kod; bogatą kolekcję takich nazw znaleźć można chociażby w dowolnym pliku pomocy
związanym z Win32 API. Najpoważniejszym zarzutem wydaje się jednak ten, iż notacja
węgierska faworyzuje znaczenie jednego tylko czynnika – typu zmiennej w kategoriach języka C,
ignorując zupełnie inne względy, jak np. właśnie znaczenie zmiennej w programie.
W samym kodzie Win API również można dopatrzyć się odstępstw od wymogów notacji
węgierskiej, na przykład zmienna wParam jest nie 16-bitową (jak wskazywałby przedrostek w –
od „word”), lecz 32-bitową liczbą całkowitą bez znaku.
Modyfikacja nazwy zmiennej w celu odzwierciedlenia jej
charakterystyki lub ograniczeń
Każdy, kto zdaje sobie sprawę z różnicy pomiędzy np. dodaniem 5 do zmiennej typu int a
dodaniem jej do wskaźnika wskazującego tę zmienną, doskonale rozumie, iż informacja o
charakterze zmiennej (zmienna statyczna, wskaźnik, pole obiektu itp.) może mieć niekiedy
szczególne znaczenie dla czytelnika studiującego i starającego się zrozumieć kod źródłowy.
Jednym ze sposobów uwidocznienia tej informacji jest poprzedzenie nazwy właściwej
specyficznym prefiksem – p_ dla wskaźnika (ang. pointer), s_ dla zmiennej statycznej (ang.
static), d_ lub m_ dla pola (ang. data member) lub funkcji składowej (ang. member function) klasy
oraz a_ dla parametru funkcji (ang. argument). Użycie znaku podkreślenia pozwala łatwo
odseparować owe przedrostki od nazwy właściwej – i tak np. pod nazwą s_LiczbaStron łatwo
rozpoznać „zmienną statyczną zawierającą liczbę stron”. Nawiasem mówiąc, opisane
prefiksowanie można by z powodzeniem zastąpić postfiksowaniem, dołączając stosowny
przyrostek na końcu nazwy zmiennej – wówczas nazwa Liczba Stron_s mogłaby być
interpretowana przez czytającego kod jako „zmienna zawierająca liczbę stron, będąca zmienną
statyczną”. Wybór konkretnej konwencji – przedrostków lub przyrostków – jest sprawą osobistego
wyboru, w każdym razie użytecznym okazać się może połączenie obydwu tych konwencji, a
konkretnie prefiksowanie zmiennych wskaźnikowych i postfiksowanie dla uwydatnienia
pozostałych cech; wówczas np. pod nazwą p_ObszarKomunikacji_s ukrywać się będzie
prawdopodobnie statyczny (_s) wskaźnik (p_) do obszaru komunikacji pomiędzy kilkoma
fragmentami kodu.
Innym szczególnym rodzajem zmiennych są te, których wartość nie ulega zmianie, nazywane z
tego względu stałymi. Ugruntowaną już praktyką wyróżniania takich zmiennych jest pisanie ich
nazw w całości wielkimi literami.
Funkcjonalność niektórych klas – z punktu widzenia pełnionej przez nie roli – jest na tyle
charakterystyczna, iż sensowne staje się zrobienie kompletnej nazwy klasy (bez ew.
poprzedzającego T lub C – o tym za chwilę) częścią nazwy zmiennej. Praktykę tę stosuje
ostentacyjnie sam C++Builder, opatrując np. kolejne komponenty typu TLabel nazwami:
Label1, Label2 itd.; my możemy postąpić nieco bardziej inteligentnie, używając zamiast
numerka sugestywnej części słowotwórczej, określającej przeznaczenie danego obiektu, np.:
TComboBox* CountryComboBox;
TLabel* NameLabel;
String BookTitleString;
W ostatnim przykładzie fraza Title jest na tyle oczywista – w tym znaczeniu, iż tytuł książki jest
łańcuchem znaków – iż fraza String mogłaby zostać opuszczona bez szkody dla
komunikatywności, a z korzyścią dla samej nazwy, która przez to stałaby się nieco zgrabniejsza:
String BookTitle;
Ostatnim szczególnym rodzajem zmiennych, o którym tu wspomnimy, są pola klasy związane z jej
właściwościami (tj. uczestniczące w deklaracji __property). Nazwy takich pól – zazwyczaj
prywatnych – tworzone są najczęściej przez poprzedzenie nazwy odnośnej właściwości literą F
(ang. Field), na przykład:
__property AnsiString RemoteMachineName= { read = FRemoteMachineName,
write = FRemoteMachineName };
Stanowi to wystarczający powód do tego, by unikać wielkiej litery F w charakterze prefiksu
innego rodzaju.
Nazewnictwo typów danych
Zasady nadawania nazw typom danych nie odbiegają zbytnio od analogicznych zasad dotyczących
nazw zmiennych, kilka różnic wartych jest jednak szczególnego podkreślenia.
Wśród klas wykorzystywanych przez użytkowników C++Buildera szczególną grupę stanowią
oczywiście klasy wywodzące się z typu TObject. Zgodnie z konwencją przyjętą przy budowie
biblioteki VCL nazwy tych klas rozpoczynają się od wielkiej litery T, będącej de facto
przedrostkiem. Umożliwia to łatwe ich odróżnienie od nazw pozostałych klas, stwarza ponadto
jedną uboczną korzyść – możliwe jest użycie nazwy klasy (bez przedrostka T) jako nazwy
zmiennej, na przykład zmienna klasy TListBox może nosić nazwę (po prostu) ListBox; nazwa
taka świadczy jednoznacznie o charakterze zmiennej. Nazwy pozostałych, „normalnych” klas
C++Buildera powinny być tworzone z udziałem innego przedrostka, na przykład wielkiej litery C.
Na uwagę zasługuje fakt, iż odróżnienie klas biblioteki VCL od pozostałych klas używanych w
programie nie jest bynajmniej tylko sprawą konwencji nazewniczej: obiekty klas VCL muszą być
bowiem tworzone dynamicznie, w przeciwieństwie do obiektów C++, nie podlegających
generalnie takiemu ograniczeniu.
W podobny sposób tworzone są nazwy typów strukturalnych (struct), zbiorowych (Set) oraz
wyliczeniowych (enum). C++Builder standardowo opatruje nazwy typów zbiorowych i
wyliczeniowych przedrostkiem T, choć oczywiście można tu użyć innego przedrostka. Jako że typ
zbiorowy związany może być z pewnym typem wyliczeniowym, intuicja dyktować może literę E
jako odpowiedni przedrostek jego nazwy (od Enumeration); wybór taki zdecydowanie jednak nie
jest polecany, gdyż C++Builder rezerwuje ten przedrostek do tworzenia nazw wyjątków
(exceptions).
Definicja klasy-szablonu Set (jako podstawowego typu zbiorowego) znajduje się w pliku
sysset.h położonym w podkatalogu Include\Vcl lokalnej instalacji C++Buildera:
template<class T, unsigned char minEl, unsigned char maxEl> class
RTL_DELPHIRETURN Set;
Szablon ten posiada trzy parametry: typ bazowy i dwa ograniczenia (słowo
RTL_DELPHIRETURN wynika ze zgodności z biblioteką VCL), tak więc na przykład zmienna
zdolna pomieścić zbiór wszystkich wielkich liter alfabetu łacińskiego (lub dowolny jego podzbiór)
może być zadeklarowana następująco:
Set<char, 'A', 'Z'> MaskaWielkieLitery
Jeżeli typ MaskaWielkieLitery używany ma być wielokrotnie, warto utworzyć dla niego
synonim za pomocą dyrektywy typedef:
typedef Set<char, 'A', 'Z'> TWielkieLitery;
....
TWielkieLitery MaskaWielkieLitery;
....
TWielkieLitery MaskaWielkieSamogloski;
Jak sugerować mogą użyte nazwy zmiennych, typy zbiorowe idealnie nadają się do tworzenia
wszelkiego rodzaju masek.
Jak już przed chwilą wspomnieliśmy, typ zbiorowy często bywa definiowany w oparciu o pewien
typ wyliczeniowy. Plik graphics.hpp, znajdujący się we wspomnianym podkatalogu
Include\Vcl, zawiera taką oto definicję podstawowych cech czcionki – pogrubienia,
pochylenia, podkreślenia i przekreślenia:
enum TFontStyle { fsBold, fsItalic, fsUnderline, fsStrikeOut };
Cechy te mogą być użyte niezależnie od siebie, a sposób ich użycia reprezentowany jest przez
odpowiedni typ zbiorowy:
typedef Set<TfontStyle, fsBold, fsStrikeOut> TfontStyles;
Nietrudno zauważyć, iż elementy typu wyliczeniowego prefiksowane są tutaj w jednolity sposób,
związany z nazwą samego typu (fs jako skrót od font style). Prefiksowanie takie, całkowicie
zgodne z prezentowanymi w tym rozdziale kanonami estetyki i czytelności programu, ma ponadto
wymiar znacznie bardziej praktyczny: w sytuacji, gdy do kodu źródłowego włączane są
niezliczone pliki nagłówkowe, zawierające łącznie (być może) dziesiątki czy setki definicji typów
wyliczeniowych, nietrudno jest o kolizję nazw elementów składowych poszczególnych typów;
umiejętnie stosowane prefiksowanie zmniejsza znacznie prawdopodobieństwo takich kolizji.
Inną metodą unikania kolizji nazw typów wyliczeniowych jest zamknięcie ich definicji w ramach
definicji używającej je klasy; użycie takich typów poza definicją klasy wymaga jawnego
kwalifikowania odwołań do nich za pomocą odpowiedniej przestrzeni nazw.
Nazewnictwo funkcji
Nazwy funkcji powinny być dobierane szczególnie starannie; powinny one odzwierciedlać
czynności przez nie spełniane. Jeżeli stworzona w taki sposób nazwa funkcji wydaje się być
nienaturalnie długa i nie widać sensownych sposobów jej skrócenia, być może sama funkcja jest
nadmiernie złożona i korzystne byłoby jej rozbicie na funkcje bardziej elementarne.
Różne rodzaje funkcji powinny być nazywane w różny sposób. Funkcja nie zwracająca wartości
(wynik typu void) lub zwracająca jedynie informację o powodzeniu lub niepowodzeniu operacji
– czyli nie zwracająca żadnego „konkretnego” wyniku – ma charakter czysto „operacyjny” i
powinna posiadać nazwę w postaci „czynność-obiekt”, na przykład CreateForm(),
DisplayBitmap(), OpenComPort() itp. Funkcja o charakterze czysto „obliczeniowym” –
czyli zwracająca istotną wartość, będącą ostatecznym rezultatem dokonywanych w jej treści
obliczeń – powinna w swej nazwie odzwierciedlać charakter zwracanej wartości, czego
przykładem mogą być nazwy GetAverage(), log10() czy ReadIntervalTimeOut().
Niektórzy programiści praktykują zapisywanie pierwszego „słowa” w nazwie funkcji w całości
małymi literami (getDescription(), calcTimerLoops(),
normalizeCollection() itp.). Trudno odmówić temu sensu, trudno też preferować takie
postępowanie; niezależnie jednak od punktu widzenia najważniejsze są tu wymogi spójności stylu
– jeżeli programista zdecyduje się na taką konwencję nazewniczą, powinien stosować ją
konsekwentnie.
Generalnie rzecz biorąc, nazwy funkcji powinny przewyższać swą długością nazwy zmiennych i
typów. Długość nazwy nie może być jednak celem samym w sobie – jak już przed chwilą
napisaliśmy, długa i mało czytelna nazwa funkcji może być świadectwem nieudolnego
zaprojektowania samej funkcji.
Konwencje nazewnicze – konkluzje
Podstawowym zadaniem wszelkich reguł dotyczących stylu kodowania programu – w
szczególności stosowanych w nim nazw – jest polepszenie czytelności programu i ułatwienie jego
przyszłej konserwacji, czyli w konsekwencji – ułatwienie życia samym programistom. Każda
konwencja nazewnicza, czyniąca zadość takiemu punktowi widzenia, warta jest zastosowania i
przedstawione tu zalecenia traktować należy jedynie jako przykłady, bądź też często przejawiające
się tendencje. Niezależnie jednak od wyboru szczegółowych zasad nadawania nazw zmiennym,
typom i funkcjom programu, przestrzeganie poniższych zaleceń wydaje się być warunkiem
koniecznym osiągnięcia oczekiwanych rezultatów:
• Nazwa danego elementu powinna pozostawać w ścisłym związku z jego rolą w programie;
nawet jeżeli wymaga to większej liczby uderzeń w klawisze, powinno być postrzegane jako
niezbędna cena płacona za czytelność kodu.
• Nie powinny być używane w charakterze nazw powszechnie używane słowa z języka
potocznego; umiejętnie stosowane prefiksowanie skutecznie ułatwia spełnienie tego wymogu.
• Spójność stylu kodowania jest sprawą pierwszorzędną; nawet najbardziej wymyślna
konwencja nazewnicza nie spełni swego zadania, jeżeli nie będzie stosowana konsekwentnie.
Właściwe konstruowanie kodu
Warunkiem powodzenia wszelkich przedsięwzięć jest użycie właściwych narzędzi, we właściwym
czasie, do właściwych celów. Zgodnie z tą zasadą jednym z podstawowych czynników, mających
zasadniczy wpływ na czytelność konstruowanego kodu, jest użycie odpowiednich konstrukcji
używanego języka programowania. Używając C++Buildera, należy więc zdawać sobie sprawę np.
z tego, gdzie należy użyć deklaracji const, a gdzie jest to niepożądane; kiedy używać referencji
zamiast wskaźników; kiedy zrezygnować z rozbudowanej instrukcji if na rzecz instrukcji
switch; kiedy reprezentować daną wielkość w postaci klasy, a kiedy lepiej z tego zrezygnować;
kiedy obsłużyć wyjątek, a kiedy pozostawić go nie obsłużonym; której odmiany pętli użyć – itd.,
itp.; tego rodzaju decyzje składają się na postępowanie, które nazwaliśmy tu lakonicznie
„wyborem właściwych konstrukcji językowych”. Jeżeli decyzje te podejmowane będą we
właściwy sposób, z myślą o właściwych celach, kod stanie się łatwiejszy do opanowania i
utrzymywania, bowiem jego znaczenie zbieżne będzie w dużym stopniu z intuicją studiującego go
czytelnika czy programisty; w przeciwnym razie czytanie kodu może stać się prawdziwą udręką –
a tego większość z nas chciałaby przecież uniknąć.
Używanie komentarzy
Podstawowym zadaniem komentarzy jest wyjaśnienie czytelnikowi tych aspektów
funkcjonowania programu, które nie wynikają w oczywisty sposób z samego kodu źródłowego.
Zarówno rozmieszczenie komentarzy, jak i ich treść muszą być starannie przemyślane –
porozrzucane bezładnie komentarze o niejasnej treści mogą tylko zaciemnić strukturę programu.
Komentując kod w języku C++, należy ograniczyć się do komentarzy jednolinijkowych,
rozpoczynających się od znaków „//”; komentarze rozpoczynające się od „/*” mogą być –
wbrew intencji programisty – nieoczekiwanie zakończone przez przypadkową sekwencję „*/”.
Komentarze dokumentujące kod programu
Komentarze nie są częścią kodu programu i są kompletnie ignorowane przez kompilator; służą one
jedynie czytelnikowi. Powinny być więc oddzielone od „właściwego” kodu poziomymi
separatorami, a jeszcze lepiej – zapisane w odróżniającym się stylu, np. pochyloną czcionką lub
innym kolorem.
2
Ponieważ jednak komentarze mają logiczny związek z określonymi fragmentami programu,
powinny stosować identyczne z nimi akapitowanie – jeżeli wcięty jest fragment kodu źródłowego,
związany z tym fragmentem komentarz powinien być wcięty na taką samą głębokość.
Za pomocą karty
Colors
opcji edytora (
Tools|Editor Options
) możliwe jest
ustawienie żądanej czcionki i koloru poszczególnych elementów składniowych kodu, między
innymi komentarzy. Dzięki temu można uczynić komentarze bardziej wyrazistymi, a nawet
(jeżeli ktoś koniecznie chce) niewidocznymi (ustawiając identyczny kolor tła i pierwszego planu).
2
W przeznaczonym do kompilacji tekście programu źródłowego – wraz z komentarzami – istotna jest
oczywiście tylko jego treść, zaś wspomniane formatowanie może tylko przeszkodzić w kompilacji (proszę
spróbować skompilować program zapisany w postaci dokumentu MS Worda!); uwaga o odmiennym stylu
może więc co najwyżej odnosić się do sposobu
prezentacji
kodu w edytorze środowisk zintegrowanych,
takich jak np. C++Builder – przyp. tłum.
Komentarz związany z określonym wierszem kodu powinien być umieszczany w tymże, o ile
pozwala na to szerokość tekstu; w przeciwnym przypadku można dokonać łamania komentarza,
przy czym początki jego poszczególnych wierszy powinny być wyrównane w pionie. Oto przykład
prawidłowo skomentowanego kodu:
double GetMaximumValue(const std::vector<double>& Vector)
throw(std::out_of_range)
{
// inicjalizacja zmiennej Maximum; jeżeli wektor będzie pusty,
// wygenerowany zostanie wyjątek std::out_of_range
double Maximum = Vector.at(0);
for(int i=0; i<Vector.size(); ++i)
{
if(Vector[i] > Maximum) // jeżeli bieżący element jest większy
{ // od bieżącej wartości zmiennej Maximum
Maximum = Vector[i]; // uaktualnij wartość tej zmiennej
}
}
return Maximum;
}
Aby ułatwić identyfikację poszczególnych par nawiasów { }, można umieścić po nawiasie
zamykającym komentarz informujący o tym, jaka instrukcja kończy się w tym miejscu – na
przykład:
}//end if
}//end for
}// ! for
}//end for(int i=0; i<Vector.size(); ++i)
Ostatni komentarz może wydawać się nieco przydługi, jednak o jego użyteczności szybko można
się przekonać w przypadku zagnieżdżonych pętli for, rozciągających się na kilka stron.
Komentarze okazują się niezwykle przydatne w procesie dokumentowania kodu, szczególnie w
przypadku opisu działania poszczególnych funkcji. Wraz z deklaracją funkcji powinny pojawić się
informacje o charakterze operacji lub obliczeń realizowanych przez tę funkcję, uwarunkowaniach
koniecznych do prawidłowego jej działania oraz zwracanej wartości – stanowią one coś na kształt
kontraktu gwarantującego, iż w przypadku spełnienia określonych wymogów otrzymamy
określone wyniki. Oto przykład deklaracji w pliku nagłówkowym:
double GetMaximumValue(const std::vector<double>& Vector)
throw(std::out_of_range)
//PRZEZNACZENIE: zwraca wartość największego elementu wektora
// o elementach typu double
//WYMAGANIA: wektor przekazany jako parametr nie może być pusty
//WYNIK: wynikiem funkcji jest wartość największego elementu
// wektora
Ważne jest, by komentarz o identycznej treści pojawił się również w pliku implementacyjnym –
po jego przeczytaniu nie trzeba już bowiem będzie sięgać do pliku nagłówkowego:
//-------------------------------------------------------------------//
//
// PRZEZNACZENIE: zwraca wartość największego elementu wektora
// o elementach typu double
//
// WYMAGANIA: wektor przekazany jako parametr nie może być pusty
//
//
// WYNIK: wynikiem funkcji jest wartość największego elementu
// wektora
//
//-------------------------------------------------------------------//
double GetMaximumValue(const std::vector<double>& Vector)
throw(std::out_of_range)
{
// inicjalizacja zmiennej Maximum; jeżeli wektor będzie pusty,
// wygenerowany zostanie wyjątek std::out_of_range
double Maximum = Vector.at(0);
for(int i=0; i<Vector.size(); ++i)
{
if(Vector[i] > Maximum) // jeżeli bieżący element jest większy
{ // od bieżącej wartości zmiennej Maximum
Maximum = Vector[i]; // uaktualnij wartość tej zmiennej
}
}
return Maximum;
}
Należy pamiętać o tym, iż pliki nagłówkowe stanowią często jedyną aktualną dokumentację
programu, należy więc weryfikować zawarte w nich komentarze po każdorazowej zmianie w
ramach odpowiednich plików implementacyjnych (czy tym bardziej – nagłówkowych). Wszelkie
zaniedbania na tym polu mogą sprawić, iż komentarze staną się po prostu bezużyteczne, zaś kod
programu – znacznie mniej zrozumiały.
Komentarze „zaślepiające”
Zasadniczo komentarze nie ingerują w kod źródłowy programu, stanowiąc jedynie jego
uzupełnienie. W przypadku jednak potrzeby wyeliminowania fragmentu kodu przekształcenie go
w komentarz bywa bardziej użyteczne od jego fizycznego usunięcia, bowiem w każdej chwili
fragment ten można (całkowicie lub częściowo) przywrócić. W przypadku kodu generowanego
automatycznie przez C++Buildera jest to szczególnie przydatne w przypadku nieużywanych
parametrów funkcji, na przykład:
void __fastcall TmainForm::Button1MouseUp(TObject* /*Sender*/,
TMouseButton /*Button*/,
TShiftState /*Shift*/,
int X,
int Y)
{
// wyświetla pozycję kursora myszy w ramach Button1
Label1–>Caption.sprintf("%d,%d”, X, Y);
}
Zapis „TObject* /*Sender*/, ” odczytać można mniej więcej jako „w tym miejscu
oryginalnie deklarowany był parametr o nazwie Sender, która to nazwa została usunięta jako
nieistotna wobec nieużywania tegoż parametru przez funkcję”. Ten sposób „wykomentowania”
nazw nieużywanych parametrów nie wygląda jednak zbyt czytelnie – informacją o pominięciu
nazwy parametru jest przecież przecinek następujący bezpośrednio po typie parametru, a w tym
przypadku specyfikacja typu oddzielona jest od tegoż przecinka komentarzem. O wiele
rozsądniejszą propozycją wydaje się następująca:
void __fastcall TmainForm::Button1MouseUp(TObject*, //Sender
TMouseButton, //Button
TShiftState, //Shift
int X,
int Y)
{
// wyświetla pozycję kursora myszy w ramach Button1
Label1–>Caption.sprintf("%d,%d”, X, Y);
}
W tej wersji zapis „TObject*, //Sender” oznacza mniej więcej tyle, co „ten parametr nie
jest używany przez funkcję; w oryginalnej wersji deklarowany był tu parametr o nazwie
Sender”. Poddajmy ten zapis drobnej modyfikacji:
void __fastcall TmainForm::Button1MouseUp(TObject*, //Sender,
TMouseButton, //Button,
TShiftState, //Shift,
int X,
int Y)
{
// wyświetla pozycję kursora myszy w ramach Button1
Label1–>Caption.sprintf("%d,%d”, X, Y);
}
Dodatkowe przecinki na końcu komentarzy nie wpływają raczej na czytelność, za to ułatwiają
przywrócenie danego parametru, gdy okaże się on potrzebny – wystarczy w tym celu usunąć
odpowiedni znacznik komentarza (//) i poprzedzający go przecinek.
Uaktualnienie tytułu etykiety
Label1–>Caption
w powyższym przykładzie dokonywane jest
w sposób cokolwiek trickowy – metoda
sprintf()
klasy
AnsiString
zwraca bowiem
referencję do instancji (
AnsiString(*this)
) zaktualizowanej przez łańcuch przekazany
jako argument. Pozwala to co prawda na zwięzły zapis, generalnie jednak utrudnia zrozumienie
kodu – z tego powodu aktualizowanie właściwości powinno odbywać się raczej przy użyciu
operatora przypisania.
Komentarze – ozdobniki
Wszelkie ozdobniki w rodzaju ramek otaczających nagłówki i tytuły modułów muszą być
oczywiście ukryte przed kompilatorem, mają więc najczęściej postać komentarzy. Konstruując
tego rodzaju upiększenia należy uważać, by ich wymyślna forma plastyczna nie zaciemniła tego,
co najistotniejsze – zasadniczej treści kodu źródłowego.
Do kategorii komentarzy – ozdobników zaliczają się również separatory oddzielające od siebie
fragmenty kodu automatycznie generowanego przez C++Buildera, także generowane
automatycznie i godne polecenia również w „ręcznie” tworzonym kodzie. Postać tych separatorów
zdefiniowana jest w pliku BCB.BCF zlokalizowanym w podkatalogu BIN lokalnej instalacji
C++Buildera 5. Zawartość tego pliku podzielona jest na sekcje w sposób charakterystyczny dla
plików typu *.ini; za wygląd wspomnianego separatora odpowiada dyrektywa Didiver
Line w sekcji Code Formatting:
[Code Formatting]
Divider Line=// ...... mój separator ......... //
Jeżeli brak jest wspomnianego zapisu, C++Builder generuje separatory w standardowej postaci.
Niestandardowy separator musi rozpoczynać się od znaków //; dla lepszej orientacji co do
wyglądu niestandardowego separatora w kontekście otaczającego go kodu wskazane jest
skonstruowanie go w oknie edytora kodu, a następnie przeniesienie do pliku BCB.BCF za pomocą
schowka.
Na zakończenie dyskusji o stylu kodowania jeszcze jedna uwaga: niezależnie od wszelkich
komentarzy zawartych w programie najlepszą dokumentacją kodu powinien być… on sam.
Stosowanie czytelnego stylu kodowania, używanie sugestywnych nazw i przejrzystych konstrukcji
językowych automatycznie przyczynia się do zwiększenia przejrzystości kodu źródłowego.
Zalecane praktyki programistyczne
Tematem tego podrozdziału jest usprawnienie programowania w C++ z użyciem C++Buildera. O
teorii, praktyce, zwyczajach i błędach programowania napisano już mnóstwo książek o różnym
stopniu zaawansowania. W ramach krótkiego rozdziału nie jest możliwe całościowe
przedstawienie problematyki efektywnego programowania nawet w zarysie, ograniczymy się więc
do tych jego aspektów, które charakterystyczne są dla C++ i sprawiają najwięcej kłopotów
niedoświadczonym programistom.
Rezygnacja z używania typu char*
Typ char*, tradycyjnie używany do manipulowania ciągami znaków, został w praktyce wyparty
przez klasę String z biblioteki standardowej C++, a także przez klasę AnsiString
charakterystyczną dla biblioteki VCL (dla wygody klasie tej przypisywany jest – za pomocą
dyrektywy typedef – synonim String). Klasa String z biblioteki standardowej wygodna
jest wówczas, gdy ważne są względy przenośności programu; dla jej wykorzystywania konieczne
jest dołączenie definiującego ją pliku nagłówkowego:
#include <string>
Z kolei manipulowanie łańcuchami przy użyciu klasy AnsiString ułatwia współpracę
tworzonego kodu z biblioteką VCL. Klasa ta posiada wiele użytecznych metod, co wobec częstych
(zazwyczaj) operacji na łańcuchach plasuje ją w rzędzie środków zwiększających efektywność
programów i ułatwiających ich użycie.
Na takie okazje, kiedy to konieczne jest użycie wskaźnika do ciągu znaków (char*) – na
przykład w celu przekazywania łańcuchów jako parametrów wywołań funkcji Win 32 API – klasa
AnsiString posiada funkcję składową c_str(), zwracającą aktualny łańcuch w takiej
właśnie reprezentacji. Zmiany aktualnego łańcucha, reprezentującego instancję klasy, dokonuje się
za pomocą funkcji składowych sprintf() i printf(); funkcje te, dołączające dotychczas
podany łańcuch na końcu łańcucha aktualnego, począwszy od wersji 5. C++Buildera zastępują
aktualny łańcuch nową wartością. Aby wykonać wspomnianą konkatenację, należy użyć funkcji
składowych cat_sprintf() i cat_printf(). Różnica pomiędzy funkcjami sprintf() i
printf() dotyczy zwracanego przez nie wyniku: pierwsza z nich zwraca referencję do odnośnej
instancji klasy AnsiString, druga natomiast – długość nowego łańcucha po jego
sformatowaniu. Podobna relacja zachodzi pomiędzy funkcjami cat_sprintf() i
cat_printf() z tą różnicą, iż wynik zwracany przez cat_printf()jest długością
dołączanego łańcucha. Oto deklaracje wspomnianych funkcji:
int __cdecl printf(const char* format, ...);
int __cdecl cat_printf(const char* format, ...);
AnsiString& __cdecl sprintf(const char* format, ...);
AnsiString& __cdecl cat_sprintf(const char* format, ...);
Powyższe funkcje korzystają w swych implementacjach z funkcji vprintf() i
cat_vprintf():
int __cdecl AnsiString::printf(const char* format, ...)
{
int rc;
va_list paramList;
va_start(paramList, format);
rc = vprintf(format, paramList);
va_end(paramList);
return rc;
}
AnsiString& __cdecl AnsiString::sprintf(const char* format, ...)
{
va_list paramList;
va_start(paramList, format);
vprintf(format, paramList);
va_end(paramList);
return *this;
}
int __cdecl AnsiString::cat_printf(const char* format, ...)
{
int rc;
va_list paramList;
va_start(paramList, format);
rc = cat_vprintf(format, paramList);
va_end(paramList);
return rc;
}
AnsiString& __cdecl AnsiString::cat_sprintf(const char* format, ...)
{
va_list paramList;
va_start(paramList, format);
cat_vprintf(format, paramList);
va_end(paramList);
return *this;
}
Funkcje vprintf() i cat_vprintf()używają – jako drugiego argumentu – listy parametrów
va_list
int __cdecl AnsiString::vprintf(const char* format, va_list paramList);
int __cdecl AnsiString::cat_vprintf(const char* format, va_list paramList);
co wymaga dołączenia pliku nagłówkowego <stdarg.h>.
Raz jeszcze przypominamy, iż w wersji 5. C++Buildera zmieniło się działanie funkcji
sprintf()
i
printf()
: zastępują one teraz aktualny łańcuch nową zawartością, zaś
realizowane przez nie dotychczas
dołączanie
łańcucha do aktualnej zawartości przejęte
zostało przez nowe funkcje
cat_sprintf()
i
cat_printf()
. Należy o tym pamiętać
przy przenoszeniu aplikacji z poprzednich wersji C++Buildera.
Referencje i ich poprawne używanie
Referencje bywają niedoceniane przez programistów, ponadto niektórzy początkujący programiści
mają kłopoty z ich zrozumieniem. Tymczasem wyeliminowanie operacji na wskaźnikach na rzecz
posługiwania się referencjami może uczynić program bardziej zrozumiałym. W punkcie tym
przedstawimy kilka istotnych cech referencji i zaprezentujemy sposoby ich wykorzystania;
wytłumaczymy również powody, dla których funkcje biblioteki VCL posługują się wskaźnikami,
nie referencjami.
Referencja zawsze jest powołaniem się na konkretny obiekt
3
(który dla prostoty nazwiemy tu
obiektem powoływanym). Referencji nie można zmienić tak, by powoływała się na inny obiekt,
nie istnieje też nic w rodzaju pustej referencji (tj. nie powołującej się na nic). Cechy te odróżniają
referencję od wskaźnika, który może w różnym czasie wskazywać różne obiekty lub nie
wskazywać żadnego (wskaźnik pusty NULL). Referencja powinna być traktowana jako
alternatywna nazwa dla powoływanego obiektu, podczas gdy wskaźnik najczęściej bywa
utożsamiany ze wskazywanym obiektem. Odwołanie się do wskazywanego przez wskaźnik
obiektu wymaga jawnej dereferencji (operatora *), podczas gdy referencja sama jest ze swej istoty
dereferencją.
Cokolwiek zrobimy z referencją, to samo stanie się z powoływanym przez tę referencję obiektem.
Oto prosty przykład:
int X = 12; // deklaracja zmiennej X i zainicjowanie jej wartością 12
int& Y = X; // deklaracja referencji do zmiennej typu int i zainicjowanie
// tej referencji powołaniem na zmienną X
Jeżeli zmienimy X, automatycznie zmieni się również Y, i vice versa.
Referencje są również użyteczne w przypadku odwołań do zmiennych tworzonych dynamicznie,
na przykład:
TBook* Book1 = new TBook(); // deklaracja i utworzenie obiektu TBook
TBook& Book2 = *Book1; // deklaracja referencji do obiektu TBook
// i zainicjowanie jej odwołaniem do obiektu
// wskazywanego przez Book1
Referencja Book2 powołuje się tu na obiekt wskazywany przez Book1.
Jednym z typowych przypadków użycia referencji jest przekazywanie parametrów do funkcji w
sytuacji, gdy wartość tych parametrów ulec ma zmianie. Deklaruje się wówczas parametry jako
referencje i wywołuje funkcję w taki sam sposób, jak gdyby parametry te przekazywane były
przez wartość. Oto przykład prostej funkcji zamieniającej dwie wartości typu int:
3
Określenia „obiekt” używamy tu w znaczeniu potocznym, na oznaczenie dowolnego bytu języka C++, nie
zaś w konkretnym znaczeniu instancji klasy.
void swap(int& X, int& Y)
{
int temp;
temp = X;
X = Y;
Y = temp;
}
Funkcję tę wywołuje się w typowy sposób:
int Liczba1 = 12;
int Liczba2 = 68;
swap(Liczba1, Liczba2);
Po wykonaniu funkcji zmienna Liczba1 ma wartość 68, zaś zmienna Liczba2 – wartość 12;
zmienne zamieniły się więc zawartością. Zmienne X i Y w treści funkcji stanowią referencję do
zmiennych (odpowiednio) Liczba1 i Liczba2; cokolwiek stanie się ze zmienną X
(odpowiednio: Y), dzieje się również ze zmienną Liczba1 (odpowiednio: Liczba2).
Przekazywanie przez referencję parametrów o typach predefiniowanych (np. int) ma sens
jedynie wtedy, gdy wartość tych parametrów ulec ma zmianie w wyniku wykonania funkcji. Z
typami definiowanymi przez użytkownika (klasami, strukturami itp.) rzecz ma się nieco inaczej:
alternatywą dla przekazywania ich przez wartość jest znacznie efektywniejsze przekazanie ich
przez referencję poprzedzoną klauzulą const. Oto przykład:
void DisplayMessage(const AnsiString& message)
{
// wyświetlenie komunikatu; "message" stanowi tu alias dla przekazanego
// parametru aktualnego. Nie jest wykonywane żadne kopiowanie, zaś
// klauzula "const" uniemożliwia użycie w treści funkcji takich konstrukcji,
// które zmieniałyby wartość zmiennej "message"
}
Taka wersja przedstawionej funkcji jest znacznie efektywniejsza od następującej:
void DisplayMessage(AnsiString message)
{
// wyświetlenie komunikatu; "message" stanowi kopię parametru aktualnego
}
Funkcja w pierwszej wersji operuje bezpośrednio na przekazanym parametrze aktualnym
(symbolizowanym przez zmienną message); nie jest tworzona kopia parametru, nie następuje
więc wywołanie konstruktora kopiującego ani destruktora. Klauzula const zabezpiecza przed
zmianą przekazanego parametru. Wykonanie drugiej z przedstawionych wersji funkcji pociąga za
sobą zarówno stworzenie kopii parametru aktualnego, jak i późniejszą jej destrukcję.
Obydwie wersje mogą być jednakże wywoływane w taki sam sposób:
AnsiString Message = "Cześć!";
DisplayMessage(Message);
co dowodzi, iż opisane usprawnienie funkcji pozostaje bez wpływu na wywołujący ją kod.
Referencja może również stanowić wynik funkcji, co może prowadzić do efektów ubocznych w
sytuacji, gdy funkcja taka użyta jest w roli L-wyrażenia, czyli konstrukcji mogącej wystąpić po
lewej stronie operatora przypisania. Dzięki referencjom możliwe jest również definiowanie
operatorów zdolnych wystąpić jako część składowa L-wyrażenia, na przykład operatora
tablicowego (subscript operator) – oto jak zrealizować w ten sposób wirtualną tablicę książek
(z których każda reprezentowana jest przez obiekt klasy Book):
class Book
{
public:
Book();
int NumberOfPages;
};
class ArrayOfBooks
{
private:
static const unsigned NumberOfBooks = 100;
public:
Book& operator[] (unsigned i);
};
Instancja klasy ArrayOfBooks może być używana na równi ze „zwykłą” tablicą – jej elementy
mogą być zarówno odczytywane, jak i zapisywane:
ArrayOfBooks ShelfOfBooks;
unsigned PageCount = 0;
ShelfOfBooks[0].NumberOfPages = 45;
PageCount := ShelfOfBooks.NumberOfPages; //PageCount == 45
Jest to możliwe, ponieważ operator [] zwraca referencję do obiektu, nie zaś jego kopię.
Reasumując – referencje są wygodniejsze i bezpieczniejsze od wskaźników z dwóch powodów: po
pierwsze, mają ustaloną wartość (powołują się cały czas na ten sam obiekt), po drugie, wobec
nieistnienia „pustych” referencji nie jest potrzebna ich weryfikacja (w rodzaju testowania
wskaźników na wartość NULL).
Skoro więc referencje posiadają tyle zalet, dlaczego implementacja biblioteki VCL opiera się
głownie na wskaźnikach?
Przyczyną takiego stanu rzeczy jest fakt, iż biblioteka VCL zrealizowana została w języku Object
Pascal i posługuje się referencjami charakterystycznymi dla tegoż języka. Referencje Object
Pascala podobne są bardziej do wskaźników C++ niż jego referencji – można im bowiem
dynamicznie przypisywać powoływane obiekty, można też uczynić referencję pustą, czyli nie
powołującą się na żaden obiekt.
4
Referencje Object Pascala zostały więc zastąpione wskaźnikami
C++; w niektórych przypadkach można by co prawda zastosować referencje C++, lecz obiekty
VCL tworzone są dynamicznie, więc i tak konieczne jest użycie wskaźników do tworzonych
instancji. Jako że wskaźniki przekazywane są (jako parametry) przez wartość, nie mogą one ulec
zmianie w wyniku wykonania funkcji; poprzedzając wskaźnik-parametr klauzulą const,
zabezpieczamy się również przed zmianą wskazywanego obiektu.
Unikanie zmiennych globalnych
Należy ograniczyć używanie zmiennych globalnych do tych przypadków, w których są one
niezbędnie potrzebne. Oprócz „zaśmiecania” globalnej przestrzeni nazw zmienne globalne
przyczyniają się do powstawania uzależnień pomiędzy modułami, co utrudnia ich konserwację i
niezależne przenoszenie pomiędzy programami. Poza tym przypadki, kiedy definicji zmiennej
wykorzystywanej w jednym module poszukiwać trzeba w innym, z pewnością nie przyczyniają się
do polepszenia czytelności programu.
Kiedy mowa o zmiennych globalnych, programiści wykorzystujący C++Buildera (a także Delphi)
natychmiast przypominają sobie globalne wskaźniki do poszczególnych formularzy, znajdujące się
w modułach źródłowych związanych z tymi formularzami – to właśnie przykład wspomnianej na
wstępie absolutnej konieczności. W większości przypadków odpowiedniejsze są jednak
rozwiązania alternatywne do używania zmiennych globalnych w sposób bezpośredni, których
przykłady zaprezentujemy w tym punkcie.
Rozwiązania te polegają na wykorzystaniu konstrukcji, które, zachowując się podobnie do
zmiennych globalnych, wolne są jednocześnie od wielu wad związanych z bezpośrednim
dostępem do nich. Jednym z takich rozwiązań jest tzw. klasa modularna, zwana krótko
modułem (ang. module). Obudowuje ona statyczne zmienne, które są jej prywatnymi
składowymi, dostępnymi wyłącznie za pośrednictwem statycznych funkcji dostępowych typu
Get… i Set…; ze względu na statyczny charakter zarówno samych zmiennych, jak i
4
W rozdziale 1. stwierdziłem, iż analogią referencji C++ jest w Object Pascalu przekazywanie parametrów
przez zmienną oraz „mapowanie” zmiennych za pomocą klauzuli
absolute
; tak rozumiane referencje nie
mogą ulegać zmianie i mogą być referencjami pustymi (jeżeli zadeklarować je jako
absolute 0
);
Autorowi oryginału prawdopodobnie chodzi tu jednak o coś innego – obiekty Object Pascala reprezentowane
są pod postacią wskaźników, jednak dostęp do ich pól, metod i właściwości odbywa się
bez użycia
operatora dereferencji
„
^
”, zewnętrznie więc wygląda to tak, jakbyśmy mieli do czynienia właśnie z
referencjami. W przeciwieństwie do C++ nie istnieją bowiem w Object Pascalu zmienne reprezentujące
bezpośrednio
instancje
obiektów – przyp. tłum.
wspomnianych funkcji dostępowych, nie jest konieczne tworzenie instancji tej klasy. Oto przykład
klasy modularnej obudowującej dwie zmienne:
class Global;
{
private:
static int Number;
static double Average;
// prywatny konstruktor
Global(); // bez implementacji, instancje nie będą tworzone
public :
// funkcje dostępowe:
static void setNumber(int NewNumber) { Number = NewNumber; }
static void setAverage(double NewAverage) { Average = NewAverage; }
static int getNumber() { return Number; }
static double getAverage { return Average; }
}
Zmienna Number dostępna jest teraz wyłącznie poprzez funkcje Global::getNumber() i
Global::setNumber(); analogicznie ma się rzecz ze zmienną Average. Umożliwia to
lepszą kontrolę wykorzystania wspomnianych zmiennych, ponadto przyczynia się do „odciążenia”
globalnej przestrzeni nazw.
Inne rozwiązanie wykorzystuje koncepcję tzw. singletonu – pod tym określeniem rozumiemy
obiekt, którego jedyna instancja tworzona jest przy pierwszym odwołaniu do niego.
Prezentowana poniżej klasa posiada statyczną funkcję składową Instance(), której każde
wywołanie zwraca referencję do instancji tworzonej przy pierwszym wywołaniu:
class Singleton
{
public:
static Singleton& Instance();
protected:
Singleton(); // bez implementacji
};
.....
Singleton& Singleton::Instance()
{
static Singleton* NewSingleton = new Singleton();
return *NewSingleton;
}
Rozwiązanie takie ma jednak tę wadę, iż destruktor obiektu nie jest nigdy wywoływany, przez
co obiekt ten być może niepotrzebnie zajmował będzie pamięć. Jeżeli w treści destruktora
wykonywane są jakieś szczególnie istotne operacje, należy zastosować następującą implementację,
gwarantującą jego wywołanie:
Singleton& Singleton::Instance()
{
static Singleton NewSingleton;
return NewSingleton;
}
Ta implementacja ma z kolei tę wadę, iż inny statyczny obiekt może mieć dostęp do obiektu
Singleton już po jego destrukcji; rozwiązaniem tego problemu jest zastosowanie statycznego
licznika odwołań, kontrolującego dostęp do obiektu, zabezpieczającego przed jego destrukcją, gdy
jest jeszcze potrzebny.
5
Globalne wskaźniki do formularzy
Zajmijmy się teraz przez chwilę formularzami, a ściślej – ich globalnymi wskaźnikami zawartymi
w odnośnych modułach źródłowych. Wskaźniki te niezbędne są do obsługi formularzy
niemodalnych; standardowo niemodalnym jest każdy formularz projektu – IDE wpisuje go na
listę formularzy tworzonych automatycznie, przez co w funkcji WinMain() pojawia się
następująca instrukcja:
Application–>CreateForm(__classid(TFormX), &FormX);
(X oznacza tu numer formularza). Formularze obsługiwane w sposób modalny nie wymagają
odrębnego wskaźnika, gdyż ich destrukcję wykonuje automatycznie metoda ShowModal();
formularze takie nie powinny być oczywiście tworzone automatycznie przy starcie aplikacji,
należy więc usunąć każdy z nich z listy Auto_create forms: na karcie Forms opcji
projektu przez przesunięcie go na listę Available forms:.
Możliwe jest generalne wyłączenie funkcji autokreacji formularzy przez IDE – należy w tym celu
wyłączyć opcję
Auto create forms
w sekcji
Form designer
karty
Preferences
opcji środowiska (
Tools|Environment Options
). Wszystkie formularze projektu „lądują”
wówczas na liście
Available forms:
i można je selektywnie przeciągać na listę
Auto_create forms:
.
5
W sprawie dalszych szczegółów Autorzy oryginału odsyłają Czytelników do następujących pozycji: C++
FAQs Second Edition, Cline i in., 1999, str. 235 oraz Large–Scale C++ Software Design, Lakos, 1996, str.
537 – przyp. red.
Dla formularzy modalnych nie są ponadto niezbędne wspomniane wskaźniki globalne, można je
więc usunąć – usuwając najpierw z odnośnego pliku deklarację:
TFormX FormX;
i ewentualną deklarację z pliku nagłówkowego:
extern PACKAGE TFormX FormX;
tę ostatnią dobrze jest „wykomentować” zamiast fizycznego usuwania i opatrzyć dodatkowym
komentarzem, wyjaśniającym przyczynę tego zabiegu:
// ten formularz jest formularzem MODALNYM i powinien być obsługiwany
// wyłącznie przez metodę ShowModal()
Aktywacja formularza modalnego nie jest bynajmniej sprawą skomplikowaną:
TFormX* FormX = new TFormX (0);
try
{
FormX–>ShowModal();
}
__finally
{
delete FormX;
}
Ponieważ mało prawdopodobne jest, by wskaźnik FormX używany był jeszcze do czegoś innego,
można opatrzyć go klauzulą const:
TFormX* const FormX = new TFormX (0);
try
{
FormX–>ShowModal();
}
__finally
{
delete FormX;
}
Konstrukcja try…finally zapewnia tutaj odporność kodu na wystąpienie wyjątku, w
szczególności gwarantuje zwolnienie instancji formularza.
Innym zalecanym rozwiązaniem jest wykorzystanie szablonu auto_ptr z biblioteki
standardowej:
auto_ptr<TFormX> FormX(new TFormX(0));
FormX–>ShowModal();
Natura szablonu auto_ptr zapewnia destrukcję instancji formularza, gdy jej wskaźnik opuści
zakres obowiązywania deklaracji (wskaźnik ten lokowany jest bowiem na stosie – przyp. tłum.).
Wykorzystywanie szablonu
auto_ptr
jest techniką ze wszech miar polecaną w odniesieniu
do obiektów bazujących na VCL, ze względu na jej bezpieczeństwo w zakresie wyjątków oraz
prostotę użycia. Dla tych komponentów VCL, które w swym konstruktorze wymagają podania
wskaźnika właściciela, należy w tej roli podać wskaźnik pusty (
0
) – jak przed chwilą
napisaliśmy, destrukcja autowskaźnika dokonuje się automatycznie, gdy opuszcza on zakres
swej deklaracji, nie należy więc powierzać tej funkcji właścicielowi komponentu.
Wyświetlenie formularza niemodalnego realizowane jest przez jego metodę Show():
FormX–>Show();
Jeżeli instancja formularza jeszcze nie istnieje, należy ją oczywiście utworzyć:
FormX = new TFormX(this);
FormX–>Show();
Przy operowaniu formularzami niemodalnymi należy jednak zachować ostrożność w stosunku do
ich wskaźników. Po pierwsze, nie wolno dokonywać powtórnego tworzenia formularza
utworzonego automatycznie przez aplikację, gdyż doprowadziłoby to do zagubienia używanej
właśnie jego instancji i zastąpienia jej instancją nowo utworzoną, co przede wszystkim
doprowadzić może do kompletnej dezorganizacji programu. Po drugie – instancja formularza
niemodalnego, utworzona automatycznie przez aplikację, nie powinna być nigdy jawnie
zwalniana.
Aby uchronić się zarówno przed brakiem instancji formularza, jak również powtórnym jego
tworzeniem, należy sprawdzić jego globalny wskaźnik; jeżeli ma on „pustą” wartość, oznacza to,
że odnośna instancja nie została jeszcze utworzona:
if (FormX == 0) FormX = new TFormX(this);
FormX–>Show();
Użycie klauzuli const
Klauzula const, mówiąc ogólnie, wskazuje na intencje programisty, dotyczące niezmienności
danego obiektu i wymusza na kompilatorze egzekwowanie wymogu tej niezmienności. Może być
ona używana na wiele sposobów.
Po pierwsze, można za jej pomocą definiować stałe:
const double PI = 3.141592654;
Jest to metoda zalecana na gruncie C++ – należy unikać definiowania stałych poprzez dyrektywę
#define. Zmienne deklarowane jako stałe muszą być oczywiście inicjowane – i jest to jedyny
sposób bezpośredniego nadawania im wartości.
Deklaracje stosujące klauzulę const w kontekście wskaźników i referencji powinny być czytane
od strony prawej do lewej. W poniższym fragmencie:
int Y = 12;
const int X = Y;
najpierw zmiennej Y nadawana jest wartość 12, po czym wartość ta powielana jest w zmiennej X.
Zmienna X jest następnie kwalifikowana jako stała (const), a więc jej wartość nie może być
zmieniana; można za to swobodnie zmieniać wartość zmiennej Y.
W poniższej deklaracji:
int* const P = &Y;
adres zmiennej Y przypisywany jest wskaźnikowi P; wskaźnik ten jest następnie kwalifikowany
jako stała, nie można więc zmieniać jego zawartości, można za to swobodnie zmieniać zawartość
wskazywanej przezeń zmiennej. Zgodnie bowiem z regułą czytania deklaracji od prawej do lewej
klauzula const odnosi się do poprzedzającego ją operatora *.
Jeżeli jednak przestawić nieco szyk deklaracji:
const int* P = &Y;
to klauzula const odnosi się do dereferencji wskaźnika: zmienna Y może być mianowicie
modyfikowana bezpośrednio (Y = ...), ale pośrednio (*P =...) już nie.
Zgodnie natomiast z poniższą deklaracją:
const int* const P = &Y;
niezmienny musi być zarówno sam wskaźnik P, jak i wskazywana przez niego zmienna.
Rozpatrzmy analogiczne sytuacje w odniesieniu do referencji. Klauzula const w poniższej
deklaracji:
int& const R = Y;
nie powoduje praktycznie żadnych skutków, potwierdza bowiem jedynie fakt, iż referencje są w
C++ niezmienne. Jeżeli jednak zamienić miejscami słowa kluczowe:
const int& R = Y;
otrzymamy wymóg niezmienności powoływanego przez referencję obiektu, innymi słowy
zmienna Y może być modyfikowana bezpośrednio (Y =...), lecz nie przez referencję (R = ...).
Nie powoduje żadnych skutków przestawienie słów const i int, poprzedzających operator &
lub *, jeżeli operator ten pozostawimy na swoim miejscu; tak więc poniższe deklaracje:
const int* P = &Y;
const int* const P = &Y;
const int& R = Y;
równoważne są następującym:
int const* P = &Y;
int const* const P = &Y;
int const& R = Y;
Deklarując literał łańcuchowy, należy stosować jedną z następujących form:
const char* const LiteralString = "Hello World";
char const * const LiteralString = "Hello World";
W obydwu przypadkach zarówno same łańcuchy, jak i ich wskaźniki pozostać muszą niezmienne.
W odniesieniu do parametrów funkcji klauzula const użyteczna jest wówczas, gdy chcemy
zagwarantować niezmienność obiektu wskazywanego przez wskaźnik, będący parametrem
(samego wskaźnika funkcja i tak nie może zmienić, gdyż przekazywany jest on przez wartość):
double GetAverage(const double* ArrayOfDouble, int LengthOfArray)
{
double Sum = 0;
for (int i=0; i<LengthOfArray; ++i)
{
Sum += ArrayOfDouble[i];
}
double Average = Sum/LengthOfArray;
return Average;
}
Funkcja w tej postaci gwarantuje niezmienność tablicy wskazywanej przez ArrayOfDouble.
Zauważmy, iż deklaracja postaci:
const double* const ArrayOfDouble
byłaby wyraźnie redundantna, bowiem niezmienność samego wskaźnika ArrayOfDouble
zagwarantowana jest przez fakt, iż jest on przekazywany przez wartość. Z tych samych powodów
zbędne jest opatrywanie klauzulą const parametru LengthOfArray.
Typ wyniku zwracanego przez funkcję również może być zadeklarowany jak stały. Ma to jednak
sens w przypadku, gdy wynik funkcji jest referencją lub wskaźnikiem, jak również w sytuacji
przeciążanej funkcji składowej typu const. Opatrzenie deklaracji funkcji składowej klauzulą
const zabrania tej funkcji dokonywania jakichkolwiek modyfikacji w obrębie instancji
ukrywającej się pod wskaźnikiem this; wynika stąd wymóg, iż wszystkie wywoływane przez nią
funkcje składowe muszą również należeć do kategorii const. Zwyczajowo do kategorii const
należą funkcje dostępowe typu Get..., pobierają one bowiem interesującą wartość, nie ingerując w
zawartość obiektu. Oto prosty przykład:
class Book
{
private:
int NumberOfPages;
public:
Book();
int GetNumberOfPages() const;
};
Definicja funkcji GetNumberOfPages() jest następująca:
int Book::GetNumberOfPages() const
{
return NumberOfPages;
}
Jak łatwo zauważyć, odnosząca się do wyniku funkcji klauzula const wystąpić musi na końcu jej
nagłówka zarówno w deklaracji, jak i w części implementacyjnej.
Ostatnim obszarem częstych zastosowań klauzuli const jest przeciążanie operatora przez klasę w
sytuacji, gdy ma on realizować dostęp zarówno do zmiennych objętych klauzulą niezmienności,
jak i do zwykłych zmiennych pozbawionych tej klauzuli. W charakterze przykładu przywołajmy
opisywany wcześniej operator tablicowy [] klasy ArrayOfBooks:
class ArrayOfBooks
{
public:
Book& operator[] (unsigned i);
const Book& operator[] (unsigned i) const;
};
Załóżmy teraz, iż obiekt klasy ArrayOfBooks przekazywany jest jako parametr pod postacią
referencji typu const ArrayOfBooks&; obiekt powoływany przez tę referencję musi
wówczas pozostać niezmienny, a więc przypisywanie wartości jego „elementom” zwracanym
(przez referencję) przez operator [] jest niedozwolone. Przeciążenie tego operatora i użycie jego
drugiego aspektu (tego z klauzulą const) czyni takie przypisania nielegalnymi a priori.
Obsługa wyjątków
Wyjątki (ang. exceptions) stanowią wygodny sposób reakcji na błędy występujące w czasie
wykonywania programu. Spośród innych, alternatywnych sposobów obsługi błędów wykonania,
powodujących z reguły zakończenie wykonania programu (w sposób nieoczekiwany lub zgodnie z
procedurą zdefiniowaną przez użytkownika), wyjątki wydają się być mechanizmem najbardziej
poręcznym. W niektórych przypadkach wyjątki zdają się nie mieć alternatywy – na przykład w
sytuacji wystąpienia błędu w konstruktorze.
W programach stworzonych z użyciem C++Buildera występować mogą dwa rodzaje wyjątków:
wyjątki C++ i wyjątki biblioteki VCL. Ogólne zasady obsługi obydwu tych wariantów są w
zasadzie takie same, istnieje jednak między nimi kilka znaczących różnic.
W związku z obsługą wyjątków C++ używa trzech słów kluczowych: try, catch i throw;
C++Builder wzbogaca tę listę o słowo kluczowe __finally. Każde z wymienionych słów
kluczowych pełni rolę nagłówka pewnego bloku (czyli kodu zamkniętego w nawiasy {}); po
każdym bloku try wystąpić musi jeden lub więcej bloków catch albo dokładnie jeden blok
__finally.
Słowo kluczowe try
Słowo kluczowe try wystąpić może w C++ w dwóch odmianach. Pierwsza z nich to wspomniany
przed chwilą nagłówek bloku try, druga natomiast, zwana funkcyjnym blokiem try, polega
na poprzedzeniu słowem try nawiasu otwierającego treść funkcji lub – w przypadku konstruktora
– dwukropka rozpoczynającego listę inicjacyjną.
C++Builder nie obsługuje obecnie funkcyjnych bloków
try
, co jednak wobec ograniczonego
ich zastosowania (głównie w odniesieniu do konstruktorów) nie powinno być szczególnie
dotkliwe dla programistów. Zgodnie z obietnicą Borlanda funkcyjne bloki
try
mają się pojawić
w wersji 6. kompilatora.
Słowo kluczowe catch
W najprostszym wariancie obsługi wyjątków pojedynczy blok catch występuje bezpośrednio po
bloku try (lub funkcyjnym bloku try). Jego nagłówek rozpoczyna się słowem catch, po
którym w nawiasach okrągłych specyfikowany jest typ obsługiwanego wyjątku i (opcjonalnie)
zmienna reprezentująca instancję wyjątku (jako klasy). Blok taki – zwany zwyczajowo „obsługą
wyjątku” (ang. exception handler) – wykonywany jest wówczas, gdy w czasie realizacji
poprzedzającego go bloku try, wystąpi wyjątek dokładnie takiej klasy, jak specyfikowana w
nagłówku. Podanie wielokropka (...) zamiast nazwy klasy powoduje przechwycenie przez blok
catch wszystkich nie obsłużonych jeszcze wyjątków. Oto przykład typowej konstrukcji
try…catch:
try
{
// kod, w którym wystąpić może wyjątek
}
catch (exception1& e)
{
// obsługa wyjątku klasy exception1
}
catch (exception1& e)
{
// obsługa wyjątku klasy exception2
}
catch (...)
{
// obsługa wszystkich nie obsłużonych jeszcze wyjątków
}
Słowo kluczowe __finally
Istnieją sytuacje, gdy dany blok kodu musi być bezwzględnie wykonany, niezależnie od
uprzedniego wystąpienia ewentualnych wyjątków. Osiągnięcie tego celu umożliwia konstrukcja
try…__finally, składająca się z bloku try, po którym występuje dokładnie jeden blok
__finally. Wykonanie takiej konstrukcji rozpoczyna się od wykonania bloku try; gdy
wykonanie to zostanie normalnie zakończone lub przerwane wskutek wystąpienia wyjątku,
rozpoczyna się wykonanie bloku __finally:
try
{
// kod, w którym wystąpić może wyjątek
}
__finally
{
// ten fragment kodu wykonany zostanie zawsze
// niezależnie od tego, w jaki sposób zakończyło się
// wykonanie poprzedzającego bloku try
}
Należy zauważyć, iż konstrukcje try…catch i try…finally mogą być nawzajem w sobie
zagnieżdżane, na przykład tak:
try
{
try
{
.....
}
catch (exception1& e)
{
.....
}
catch (exception2& e)
{
.....
}
catch (...)
{
.....
}
}
__finally
{
.....
}
Słowo kluczowe throw
Słowo kluczowe throw może wystąpić w dwóch wariantach. W pierwszym wariancie służy ono
do wygenerowania (throw) lub ponowienia (rethrow) wyjątku określonego typu. Może ono
wówczas poprzedzać zmienną reprezentującą wyjątek (najczęściej obiekt), zamkniętą w nawiasy
okrągłe lub po prostu oddzieloną spacją (jak np. w instrukcji return), może też wystąpić
samodzielnie. Znaczenie tego ostatniego przypadku zależne jest od miejsca wystąpienia słowa
throw: jego wystąpienie w obszarze bloku catch powoduje ponowienie obsługiwanego właśnie
wyjątku, wystąpienie w innym miejscu powoduje natomiast wywołanie funkcji terminate()
standardowo kończącej wykonanie programu.
Słowo kluczowe
throw
nie może być używane w obrębie kodu biblioteki VCL.
Drugi wariant słowa throw – tzw. klauzula throw – pozwala na wyspecyfikowanie listy typów
wyjątków, które mogą być generowane przez daną funkcję. Lista ta zamknięta w nawiasy okrągłe
następuje po słowie throw i stanowi przyrostek deklaracji funkcji, na przykład:
void* MyFunc(int a, int b) throw (myexcept1, myexcept2);
Pusta lista oznacza, iż funkcja nie może generować żadnych wyjątków.
Nie obsłużone i nieoczekiwane wyjątki
Poza wymienionymi słowami kluczowymi C++ dostarcza dodatkowe środki umożliwiające
uporanie się z wyjątkami nie obsłużonymi lub niespodziewanymi – te ostatnie pochodzić mogą od
funkcji generujących wyjątki nie wyspecyfikowane w klauzuli throw (o której pisaliśmy przed
chwilą).
Jeżeli zaistniały wyjątek nie zostanie obsłużony, wywoływana jest funkcja terminate();
wywołuje ona z kolei funkcję abort(), kończącą działanie programu. Sytuacja taka nie jest
pożądana, bowiem funkcja abort() nie zapewnia destrukcji lokalnych obiektów. Można bardzo
łatwo zabezpieczyć się przed wywołaniem funkcji terminate(), „zanurzając” treść funkcji
WinMain() (main() w aplikacjach konsolowych) w bloku try…catch, dzięki czemu
przechwycone zostaną wyjątki nie obsłużone na niższych poziomach. Można również zmienić
standardowe zachowanie funkcji terminate(), definiując własną funkcję obsługi nie
obsłużonych wyjątków; nazwę tej funkcji należy podać jako argument wywołania funkcji
std::set_terminate(). Konieczne jest ponadto dołączenie pliku nagłówkowego
<stdexcept>:
void TerminateHandler();
.....
.....
#include <stdexcept>
std::set_terminate(TerminateHandler);
Reakcją na wystąpienie nieoczekiwanego wyjątku jest wywołanie funkcji unexpected(). Jej
standardowe zachowanie, sprowadzające się do wywołania funkcji terminate(), można
zmienić, podając nazwę własnej funkcji obsługi jako argument wywołania funkcji
std::set_unexpected(); ponownie należy pamiętać o dołączeniu pliku nagłówkowego
<stdexcept>.
Posługiwanie się wyjątkami
Tworząc kod źródłowy, należy liczyć się z tym, iż jego wykonanie może powodować wystąpienie
różnego rodzaju wyjątków. Nie należy odkładać „oprogramowania wyjątków” na później, lecz
uczynić ich obsługę integralną częścią zadania. Prosta na pozór czynność wyświetlenia formularza
modalnego może być co prawda zapisana w trzech wierszach:
TFormX* const FormX = new TFormX(0);
FormX–>ShowModal();
delete FormX;
jest ona jednak kompletnie nieprzygotowana na ewentualne wyjątki – jeżeli takowe wystąpią w
czasie realizacji metody ShowModal(), ostatnia z instrukcji nie wykona się i instancja
formularza pozostanie nieusunięta. Namiastkę „bezpiecznej” wersji wyświetlenia formularza
modalnego przedstawiliśmy już podczas dyskusji na temat globalnych wskaźników formularzy –
nie dokonuje on co prawda obsługi wyjątków, lecz przynajmniej zapewnia bezwarunkowe
wykonanie instrukcji delete.
W odniesieniu do każdego fragmentu programu należy zatem zdecydować, czy powodowane
przez niego wyjątki mają zostać również przezeń obsłużone, czy też ich obsługa odbywać się
będzie w innym miejscu kodu, na wyższym poziomie wywołania. Należy przy tym zdawać sobie
sprawę z różnego charakteru wyjątków – mogą być one związane z samym językiem, z biblioteką
standardową C++, z biblioteką VCL, mogą być także generowane ad hoc przez sam kod
stworzony przez użytkownika.
W tym ostatnim przypadku należy uważać, by w ramach żadnej z funkcji nie generować wyjątków
innych typów niż określone w jej klauzuli throw (o ile deklaracja funkcji klauzulę taką posiada),
w przeciwnym razie wyjątki potraktowane zostaną jako nieoczekiwane (sterowanie przekazane
zostanie do funkcji unexpected() zamiast odpowiedniego bloku try…catch.
Specyfikując bloki catch, nie należy podejmować się obsługi tych typów wyjątków, których
program tak naprawdę obsłużyć nie potrafi; podjęta obsługa wyjątku nie musi być jednak
doprowadzona do końca – jeżeli w konkretnej sytuacji nie jest możliwe kompletne obsłużenie
wyjątku, po przeprowadzeniu niezbędnych czynności „organizacyjnych” należy ponowić wyjątek
za pomocą instrukcji throw (zdanie to należy oczywiście interpretować w kategorii tworzonego
kodu źródłowego). Jeżeli nagłówek bloku catch specyfikuje zmienną reprezentującą instancję
wyjątku, jej typ powinien być określony jako referencja – bowiem niektóre wyjątki, jak np.
wyjątki biblioteki VCL nie mogą być „przechwytywane” przez wartość.
Definiując klasy-kontenery (container classes) należy uczynić ich kod „obojętnym na wyjątki”
(exception–neutral) – ich obsługa powinna dokonywać się w funkcji wywołującej metodę klasy-
kontenera.
6
Nie należy nigdy generować wyjątków w ramach destruktora – destruktory mogą być bowiem
wywoływane w ramach tzw. porządkowania stosu (ang. stack unwinding) związanego z
przejściem do realizacji bloku catch; wystąpienie wyjątku na tym etapie ma charakter
rekursywny i kończy się wywołaniem funkcji terminate(). Z tego względu zaleca się również
opatrywanie deklaracji destruktorów klauzulą throw z pustą listą specyfikacji.
6
Autorzy oryginału polecają w tym miejscu książkę H.Suttera Exceptional C++: 47 Engineering Puzzles,
Programming Problems and Solutions, wyd. Addison–Wesley Longman Inc. 2000 – przyp. red.
Zarządzanie pamięcią operacyjną przy użyciu
operatorów new i delete
Specyfikacja biblioteki VCL wymaga, by wszystkie obiekty wywodzące się z klasy TObject
alokowane były dynamicznie w wolnej pamięci. Obszar, z którego przydzielana jest pamięć na
potrzeby obiektów programu, nazywany jest zwyczajowo „stertą”. Określenie to jest jednak
adekwatne raczej w odniesieniu do gospodarowania pamięcią za pomocą funkcji malloc() i
free(); opisując operatory new i delete pozostaniemy przy określeniu „wolna pamięć”.
W przeciwieństwie do funkcji malloc(), zwracającej amorficzny wskaźnik (typu void* ) – co
w przypadku tworzenia obiektu wymaga rzutowania na odpowiedni typ – typ wskaźnika
zwracanego przez operator new zgodny jest z typem tworzonego obiektu. Ponadto operatory new
i delete mogą być przeciążane przez poszczególne klasy (dla realizacji pewnych specyficznych
schematów przydziału i zwalniania pamięci) – funkcje malloc() i free() nie stwarzają takich
możliwości.
Należy unikać wywoływania metody
Free()
klas biblioteki VCL, gdyż nie daje ona gwarancji
zwolnienia pamięci przydzielonej przez operator
new
ani wywołania destruktora. W celu
destrukcji obiektu VCL zawsze należy posługiwać się operatorem
delete
.
W przypadku gdy operator new nie potrafi przydzielić żądanej pamięci, generowany jest wyjątek
std::bad_alloc (jest on zdefiniowany w pliku nagłówkowym <new>, o którego dołączeniu
należy pamiętać). Wskaźnik zwracany przez operator new nie jest więc (standardowo)
wskaźnikiem pustym, nie musi być więc testowany na wartość NULL, w zamian za co konieczne
jest oczywiście odpowiednie oprogramowanie wspomnianego wyjątku. Można go na przykład
obsłużyć „na gorąco”:
void CreateObject(TMyObject* MyObject) throw()
{
try
{
MyObject = new TMyObject();
}
catch(std::bad_alloc)
{
// wyświetl komunikat o niemożności przydziału pamięci
// i uporaj się z problemem albo podstaw NULL pod MyObject
// dla sygnalizacji, iż żądany obiekt nie został utworzony
}
}
albo zlecić tę obsługę funkcji wywołującej:
void CreateObject(TMyObject* MyObject) throw(std::bad_alloc)
{
MyObject = new TMyObject();
}
Klauzula throw została tu użyta dla podkreślenia, iż pierwszy wariant funkcji nie „wypuszcza”
nie obsłużonych wyjątków, drugi zaś może przekazywać jedynie wyjątek std::bad_alloc.
Tę standardową reakcję na niepowodzenie przydziału pamięci (przez operator new) można
zmienić, definiując własną funkcję obsługi (jeżeli jest ona funkcją składową klasy, musi być
funkcją statyczną) i przekazanie jej jako argumentu wywołania funkcji
std::set_new_handler() (ponownie nie należy zapominać o dołączeniu pliku <new>), na
przykład:
#include <new>
void OutOfMemory()
{
// spróbuj zwolnić niepotrzebną pamięć
// jeżeli w wyniku tego powtórna próba alokacji powiedzie się,
// niniejsza funkcja nie zostanie ponownie wywołana
//
// w przeciwnym razie zainstaluj inną funkcję obsługi
// lub wygeneruj wyjątek
}
.....
std::set_new_handler(OutOfMemory);
Jeżeli za pomocą funkcji std::set_new_handler()zdefiniujemy własną funkcję obsługi
braku pamięci, menedżer pamięci wywoływał będzie tę ostatnią aż do skutku (pomyślnego
przydziału) albo anulowania wspomnianej definicji (przez wywołanie funkcji
std::set_new_handler() z argumentem 0) – w tym ostatnim przypadku cała sprawa
zakończy się wygenerowaniem wyjątku std::bad_alloc. Można się o tym przekonać,
studiując definicję operatora new w pliku new.cpp:
void * _RTLENTRY _EXPFUNC operator new( size_t size )
{
void * p;
.....
while ( (p = malloc(size)) == NULL)
if (_new_handler)
_new_handler();
else
throw __bad_alloc;
return p;
}
Za lakonicznym stwierdzeniem „spróbuj zwolnić niepotrzebną pamięć” w opisie działania funkcji
OutOfMemory() kryje się zazwyczaj zadanie bardzo skomplikowane, bowiem znaczenie słowa
„niepotrzebna” ściśle związane jest ze specyfiką konkretnego programu. Najczęściej taką
„niepotrzebną” – czy też „niekoniecznie potrzebną” – pamięcią są wszelkiego rodzaju bufory, nie
wpływające na samą logikę programu, lecz zwiększające (czasem nawet znacznie) jego
efektywność. Niekiedy jednak brak pamięci wynika z uwarunkowań zewnętrznych w stosunku do
samego programu – np. „ubogiej” konfiguracji komputera lub dużej liczby uruchomionych zadań.
W takich warunkach wspomniane stwierdzenie staje się po prostu pustosłowiem, zaś żadna
procedura obsługi nie jest w stanie odzyskać nieistniejącej pamięci. Jedyne, co można
wówczas zrobić, to starać się z wyprzedzeniem przewidzieć brak pamięci, informując o tym
użytkownika programu – umożliwia to prosty, acz użyteczny trick.
Na samym początku realizacji programu należy mianowicie zaalokować pewien obszar pamięci
(jak duży? – to zależy od specyfiki programu) w charakterze li tylko owej „niepotrzebnej” pamięci
i kontynuować normalne wykonanie programu; w sytuacji, gdy operator new zasygnalizuje
niepowodzenie, należy pamięć tę zwolnić (w procedurze niestandardowej obsługi) i spowodować
ponowienie próby przydziału (zwyczajnie wychodząc z tej procedury), uprzednio jednak
sygnalizując użytkownikowi (za pomocą stosownego komunikatu), iż być może właśnie
rozpoczynają się problemy z brakiem pamięci.
Operator new może być używany nie tylko do tworzenia instancji klas, lecz również przydziału
„surowej” pamięci (raw memory) w sytuacji, gdy żądany jej rozmiar znany jest dopiero w
momencie przydziału. Jest to charakterystyczne zwłaszcza w odniesieniu do struktur Win32 API,
na przykład:
DWORD StructureSize = APIFunctionToGetSize(SomeParameter);
WIN32STRUCTURE* PointerToStructure;
PointerToStructure =
static_cast<WIN32STRUCTURE*>(operator new(StructureSize));
Tak przydzielona struktura musi być zwolniona za pomocą operatora delete:
operator delete(PointerToStructure);
W taki oto sposób można obyć się bez funkcji malloc() i free().
Osobnego komentarza wymaga sposób przydziału pamięci na potrzeby tablic obiektów.
Przydział taki realizowany jest za pomocą operatora new[], zaś przydzielona pamięć musi być
zwalniana przy użyciu operatora delete[]– nie należy mylić ich z operatorami new i delete.
Gdy za pomocą operatora new żądamy utworzenia obiektu będącego tablicą obiektów, najpierw
konstruowana jest sama tablica (za pomocą operatora new[]), a następnie każdy jej obiekt
składowy konstruowany jest za pomocą swego domyślnego konstruktora. Destrukcja tablicy
obiektów musi przebiegać w odwrotnej kolejności – najpierw wywoływane są destruktory
obiektów składowych, a następnie destruktor tablicy jako takiej. Aby wymusić taki właśnie
scenariusz, należy zwalniać tę tablicę za pomocą operatora delete[], nie delete:
delete [] SomeArray;
Jeżeli przez pomyłkę opuścilibyśmy nawiasy kwadratowe, w najlepszym przypadku uzyskamy
jedynie zwolnienie pierwszego obiektu z tablicy.
Aby zademonstrować rzecz całą na konkretnym przykładzie, przywołajmy naszą klasę TBook i
utwórzmy dwuwymiarową tablicę odzwierciedlającą regał podzielony na półki, z których każda
zawiera określoną liczbę książek (dla lepszej czytelności użyłem polskich liter, których oczywiście
kompilator tolerował nie będzie – przyp. tłum.):
TBook** Regał = new TBook*[LiczbaPółek];
for(int i=0; i<LiczbaPółek; ++i)
{
Regał[i] = new TBook[LiczbaKsiążekNaPółce]
}
Zwolnienie obiektu Regał musi być wykonane następująco:
delete [] Regał;
Jeśli już mowa o tablicach obiektów, to godnym polecenia w tym względzie jest szablon o nazwie
<vector> z biblioteki standardowej. Umożliwia on użycie dowolnego (niekoniecznie
domyślnego) konstruktora obiektu składowego, zajmuje się również alokacją i zwalnianiem
pamięci, a także jej realokacją – w tym przypadku zbędna staje się więc funkcja realloc().
Ze względu na ograniczoną objętość książki nie opisujemy tu operatora
nothrow new
(w
przypadku niemożności przydziału zwraca on pusty wskaźnik, nie generując wyjątku), ani też
wykorzystania operatora
new
do alokowania pamięci pod określonym adresem.
Rzutowanie typów w stylu C++
W C++ istnieją cztery rodzaje rzutowania typów (casts). Ich krótki opis przedstawia tabela 2.1.
Tabela 2.1. Rodzaje rzutowania typów w C++
Rzutowanie Zastosowanie
static_cast<T>(exp)
Stosowane do rzutowania w obrębie jednej
grupy wyrażeń arytmetycznych, referencji,
wskaźników lub typów wyliczeniowych. Nie
jest możliwe rzutowanie pomiędzy ww.
grupami, na przykład rzutowanie wskaźnika na
typ arytmetyczny.
dynamic_cast<T>(exp)
Używane do rzutowania w obrębie hierarchii
dziedziczonych klas. Jeżeli przykładowo klasa X
wywodzi się z klasy O, to wskaźnik do klasy O
może być w ten sposób rzutowany na wskaźnik
do klasy X, o ile oczywiście konwersja taka jest
wykonalna.
T może być wskaźnikiem lub referencją do
klasy docelowej lub wskaźnikiem amorficznym
(void*).
exp może być wskaźnikiem lub referencją. Aby
rzutowanie dynamiczne było wykonalne, klasa
bazowa musi być klasą polimorficzną, tzn.
zawierać musi przynajmniej jedną wirtualną
funkcję składową.
Jeżeli rzutowanie pomiędzy wskaźnikami nie
jest wykonalne, jego wynikiem jest wskaźnik
pusty (NULL). Jeżeli nie jest wykonalne
rzutowanie pomiędzy referencjami,
generowany jest wyjątek std::bad_cast
(należy dołączyć plik <typeinfo>).
const_cast<T>(exp)
To rzutowanie jako jedyne ma wpływ na
„niezmienność” i (lub) „ulotność” wyrażenia
wynikających z klauzul (odpowiednio) const i
volatile. Przydaje się to w sytuacji, gdy
chcemy przekazać niezmienny (const) obiekt
do funkcji, w której deklaracja parametru nie
gwarantuje co prawda jego niezmienności, lecz
niezmienność taka wynika z konkretnych
warunków na aktualnym etapie wykonania
programu.
Typ T i typ wyrażenia exp mogą różnić się od
siebie co najwyżej pod względem klauzul
const i volatile.
reinterpret_cast<T>(exp)
Rzutowanie to sprowadza się do odmiennej
interpretacji wzorca bitowego wyrażenia exp z
ewentualnym jego poszerzeniem lub obcięciem.
Jest ono w dużym stopniu zależne od konkretnej
implementacji języka, a więc jego wynik
zwykle trudno jest a priori określić bez
przestudiowania dokumentacji; w C++Builderze
ten rodzaj rzutowania wykorzystywany jest
najczęściej do konwersji pomiędzy
wskaźnikami a typem int, jak również
pomiędzy wskaźnikami do funkcji.
T może być referencją, typem arytmetycznym,
wskaźnikiem do danych, funkcji lub funkcji
składowej.
Do najczęściej wykorzystywanych rodzajów rzutowania należą: static_cast (dla trywialnych
konwersji pomiędzy np. int a double) i dynamic_cast. Przykład rzutowania statycznego
prezentuje poniższy fragment kodu, znany już z wydruku 2.4:
int Sum = 0;
int* Numbers = new int[20];
for(int i=0; i<20; ++i)
{
Numbers[i] = i*i;
Sum += Numbers[i];
}
double Average = static_cast<double>(Sum)/20;
Rzutowanie dynamiczne w C++Builderze stosowane jest najczęściej do konwersji parametrów
TObject* (w funkcjach zdarzeniowych VCL) oraz TComponent* (przy wskazaniu właściciela
komponentu) na rzeczywisty typ komponentu, na przykład TForm*. Poniższy przykład ilustruje
prosty sposób sprawdzenia, czy dany komponent ulokowany został wprost na formularzu, czy też
w ramach jakiegoś dodatkowego komponentu (np. TPanel) – w tym drugim przypadku nie
będzie wykonalna konwersja wskazania właściciela komponentu na typ TForm*, jej wynikiem
będzie więc NULL:
.....
TForm* OwnerForm = dynamic_cast<TForm*>(Owner);
if (OwnerForm)
{
// komponent znajduje się bezpośrednio na formularzu
}
Rzutowania typów w C++ realizują dość specyficzne funkcje, więc każdy ich rodzaj powinien być
starannie dobrany do rzeczywistych potrzeb. Ich zaletą jest prostota specyfikacji, mają więc one
dodatni wpływ na czytelność i przejrzystość kodu.
Używanie preprocesora
Nie należy wykorzystywać preprocesora do definiowania stałych – do tego celu służy dyrektywa
const – ani do tworzenia makr funkcyjnych – w tym celu wykorzystać możemy funkcje
„wstawialne” inline (lub szablony takich funkcji).
Dla przykładu stałą reprezentującą liczbę
π zdefiniować można następująco:
const double PI = 3.1415926534;
Definicję tę można również zawrzeć w ramach klasy, na przykład:
class Circle
{
public:
static const double PI; // to tylko deklaracja
}
W pliku implementacyjnym (*.cpp) stosowna definicja mogłaby wyglądać następująco:
const double Circle::PI = 3.1415926534; // to jest definicja i inicjalizacja
Zwróć uwagę, iż ze względu na statyczny charakter stałej PI wszystkie instancje klasy Circle
będą dzielić jej pojedynczą kopię. Zauważ również, iż stała ta inicjowana jest w pliku
implementacyjnym (zazwyczaj definicję taką umieszcza się bezpośrednio po dyrektywie
#include, włączającej plik nagłówkowy, zawierający deklarację klasy). Wyjątkiem od tej
zasady są stałe mające „całkowitoliczbową” reprezentację wewnętrzną: char, short, long,
unsigned i int; mogą być one inicjowane wprost w definicji klasy.
Dla połączenia kilku stałych w jedną grupę znakomicie nadaje się dyrektywa enum:
enum LanguagesSupported { English, Chinese, Japanese, French };
Niekiedy dyrektywę enum wykorzystuje się do zadeklarowania pojedynczej stałej całkowitej:
enum { LENGTH = 255 };
Takie deklaracje spotyka się najczęściej w definicjach klas; wydaje się jednak, iż rozwiązanie
zaprezentowane przed chwilą w związku z liczbą PI jest lepszym rozwiązaniem.
Równie łatwo wyeliminować można z kodu programu makra funkcyjne. Poniższe makro:
#define cubeX(x) ( (x)*(x)*(x) )
zastąpić można z powodzeniem następującą funkcją:
inline double cubeX(double x) { return x*x*x }
Niestety, funkcja ta wymaga jako argumentu wyrażenia typu double – przekazanie parametru
typu int musiałoby by poprzedzone jego rzutowaniem na typ double; analogiczne makro nie
posiada takiej wady. Problem ten może zostać rozwiązany w dwojaki sposób: przez zdefiniowanie
funkcji jako przeciążonej lub stworzenie szablonu funkcji. To ostatnie rozwiązanie nie jest w tym
przypadku szczególnie odpowiednie, gdyż szablony mogą być stosowane również w odniesieniu
do klas, co w przypadku operacji podnoszenia do potęgi miałoby niewielki sens. Pozostaje więc
pierwsze rozwiązanie – uzupełnienie definicji funkcji o aspekt odpowiedni dla parametru i wyniku
typu int:
inline int cubeX(int x) { return x*x*x }
Preprocesor bywa szczególnie użyteczny w realizacji tzw. strażników wstawiania (ang. include
guards), zapewniających, iż dany fragment pojawi się w treści kodu źródłowego tylko
jednokrotnie. Oto typowy wygląd pliku nagłówkowego C++Buildera:
#ifndef Unit1H // czy symbol Unit1H jest już zdefiniowany?
#define Unit1H // nie, zdefiniuj go i uwidocznij związany z nim kod
// tutaj następuje zasadnicza treść pliku nagłówkowego
#endif // koniec uwarunkowanego fragmentu
Niezależnie od tego, ile razy dyrektywa #include włączająca powyższy plik nagłówkowy
pojawi się w treści modułu źródłowego, treść tego pliku wstawiona zostanie do modułu tylko raz;
zapewnia to symbol Unit1H, będący właśnie wspomnianym strażnikiem.
Może to wydawać się trywialne, lecz oddaje nieocenione usługi w przypadku dużych projektów
nafaszerowanych dyrektywami #include. W poniższym przykładzie symbole – strażnicy
zapewniają, iż każdy z plików nagłówkowych włączony będzie co najwyżej raz:
#ifndef Unit1H // czy symbol Unit1H jest już zdefiniowany?
#define Unit1H // zdefiniuj go i uwidocznij związany z nim kod
#ifndef ModalUnit2H
#include "ModalUnit2.h"
#endif
#ifndef ModalUnit3H
#include "ModalUnit3.h"
#endif
......
#endif // koniec uwarunkowanego fragmentu
W powyższych przykładach nazwa strażnika tworzona była poprzez dołączenie litery H na końcu
nazwy modułu. Nie jest to kwestia przypadku – poza oczywistymi względami czytelności ma to
związek z menedżerem projektów. W poprzednich wersjach C++Buildera pliki *.CPP i *.H o
tej samej nazwie traktowane były łącznie, jako pojedynczy moduł źródłowy; począwszy od wersji
5. są one traktowane łącznie tylko wówczas, gdy nazwa symbolu – strażnika w pliku *.H stosuje
się do takiej właśnie konwencji.
Menedżer projektów w wersji 5. C++Buildera usprawniony został o graficzne uwidocznienie
zależności kodu poszczególnych modułów źródłowych od poszczególnych plików
nagłówkowych. Informacja o tej zależności pobierana jest z plików wynikowych
*.obj
, tak
więc jej pojawienie się (a także jej aktualność) wymaga przynajmniej jednokrotnej kompilacji
projektu i rekompilacji po dokonaniu zmian w projekcie.
Wykorzystanie standardowej biblioteki C++
Standardowa biblioteka C++ (C++ Standard Library), włączając w to standardową bibliotekę
szablonów (STL – Standard Template Library), stanowi integralną część standardu ANSI/ISO
C++. Poznanie jej zasobów i możliwości pozwala niejednokrotnie uniknąć niepotrzebnego
kodowania. Biblioteka standardowa ma niezaprzeczalną przewagę nad chałupniczo tworzonym
kodem, została bowiem gruntownie przetestowana i zoptymalizowana, a jako element standardu
jest oczywiście przenośna pomiędzy różnymi platformami sprzętowymi i systemowymi.
Gdyby charakteryzować krótko bibliotekę standardową, należałoby wymienić następujące jej
główne elementy:
• wyjątki, takie jak: bad_alloc, bad_cast, bad_typeid i bad_exception;
• różnego rodzaju mechanizmy użytkowe, jak: min(), max(), auto_ptr<T> i
numeric_limits<T>;
• strumienie wejścia-wyjścia, jak istream i ostream;
• kontenery, jak np. vector<T>;
• obiekty funkcyjne (funktory), jak np. equal_to<T>();
• iteratory;
• łańcuchy, np. string;
• numeryki, jak complex<T>;
• specjalne kontenery, jak queue<T> i stack<T>;
• obsługa standardów narodowych.
Dominującą część biblioteki standardowej stanowią szablony, co czyni jej kod niezwykle
użytecznym. Na przykład szablon vector może być używany do tworzenia tablic danych
dowolnego typu, jest więc godnym następcą tablic C++ i powinien być używany zamiast nich (z
wyjątkiem być może trywialnych przypadków) wszędzie, gdzie jest to możliwe.
W tym miejscu pojawia się w oryginale sekcja, która w polskiej wersji mogłaby mieć tytuł
„Literatura zalecana” lub „Warto przeczytać…”. Ze względu na specyfikę polecanych pozycji
(wydawnictwa anglojęzyczne dostępne na rynku amerykańskim) nie włączyłem jej do
tłumaczenia. Być może HELION ma jakieś propozycje co do polskiej literatury o programowaniu
w C++.
Podsumowanie
W rozdziale tym przedstawiliśmy wybrane zagadnienia związane z tworzeniem kodu źródłowego
w języku C++, między innymi:
• znaczenie odpowiedniego stylu kodowania dla czytelności programu i łatwości zarządzania
jego kodem;
• rolę komentarzy jako czynnika dokumentującego przeznaczenie kodu i warunki jego
stosowania;
• zastosowanie wybranych konstrukcji programistycznych i ich wpływ na czytelność,
efektywność oraz przenośność kodu;
• użyteczność biblioteki standardowej w procesie produktywnego tworzenia aplikacji.