LEKCJA 11. Jak deklarować zmienne. Co to jest wskaźnik. ________________________________________________________________ W trakcie tej lekcji: 1. Dowiesz się więcej o deklaracjach. 2. Poprawisz trochę system MS DOS. 3. Dowiesz się co to jest wskaźnik i do czego służy. ________________________________________________________________ Więcej o deklaracjach. Deklarować można w języku C++: * zmienne; * funkcje; * typy (chodzi oczywiście o typy "nietypowe"). Zmienne w języku C++ mogą mieć charakter: * skalarów - którym przypisuje się nierozdzielne dane np. całkowite, rzeczywiste, wskazujące (typu wskaźnik) itp. * agregatów - którym przypisuje się dane typu strukturalnego np. obiektowe, tablicowe czy strukturowe. Powyższy podział nie jest tak całkiem precyzyjny, ponieważ pomiędzy wskaźnikami a tablicami istnieje w języku C++ dość specyficzna zależność, ale więcej na ten temat dowiesz się z późniejszych lekcji. Zmienne mogą być: * deklarowane, * definiowane i * inicjowane. Stała to to taka zmienna, której wartość można przypisać tylko raz. Z punktu widzenia komputera niewiele się to różni, bo miejsce w pamięci i tak, stosownie do zadeklarowanego typu zarezerwować trzeba, umieścić w tablicy i zapamiętać sobie identyfikator i adres też. Jedyna praktyczna różnica polega na tym, że zmiennej zadeklarowanej jako stała, np.: const float PI = 3.142; nie można przypisać w programie żadnej innej wartości, innymi słowy zapis: const float PI = 3.14; jest jednocześnie DEKLARACJĄ, DEFINICJĄ i ZAINICJOWANIEM stałej PI. Przykład : float x,y,z;€€€€€€€€€€€€€€€€€€€€€€€(DEKLARACJA) const float TEMP = 36.6;€€€€€€€€€€€(DEFINICJA) x = 42;€€€€€€€€€€€€€€€€€€€€€€€€€€€€€(ZAINICJOWANIE zmiennej) [S!] constant/variable - STAŁA czy ZMIENNA. ________________________________________________________________ const - (CONSTant) - stała. Deklaracja stałej, słowo kluczowe w języku C. var - (VARiable) - zmienna. W języku C przyjmowane domyślnie. Słowo var (stosowane w Pascalu) NIE JEST słowem kluczowym języka C ani C++ (!). ________________________________________________________________ Skutek praktyczny: * Ma sens i jest poprawna deklaracja: const float PI = 3.1416; * Niepoprawna natomiast jest deklaracja: var float x; Jeśli nie zadeklarowano stałej słowem const, to "zmienna" (var) przyjmowana jest domyślnie. Definicja powoduje nie tylko określenie, jakiego typu wartościami może operować dana zmienna bądź funkcja, która zostaje od tego momentu skojarzona z podanym identyfikatorem, ale dodatkowo powoduje: * w przypadku zmiennej - przypisanie jej wartości, * W przypadku funkcji - przyporządkowanie ciała funkcji. Zdefiniujmy dla przykładu kilka własnych funkcji. Przykład: void UstawDosErrorlevel(int n) /* nazwa funkcji*/ { exit(n); /* skromne ciało funkcji */ } Przykład int DrukujAutora(void) { printf("\nAdam MAJCZAK AD 1993/95 - C++ w 48 godzin!\n"); printf("\n Wydanie II Poprawione i uzupełnione.") return 0; } Przykład void Drukuj_Pytanie(void) { printf("Podaj liczbe z zakresu od 0 do 255"); printf("\nUstawie Ci ERRORLEVEL\t"); } W powyższych przykładach zwróć uwagę na: * sposób deklarowania zmiennej, przekazywanej jako parametr do funkcji - n i err; * definicje funkcji i ich wywołanie w programie (podobnie jak w Pascalu). Zilustrujemy zastosowanie tego mechanizmu w programie przykładowym. Funkcje powyższe są PREDEFINIOWANE w pliku FUNKCJE1.H na dyskietce dołączonej do książki. Wpisz i uruchom program: [P020.CPP] # include "stdio.h" # include "A:\funkcje1.h" int err; void main(void) { DrukujAutora(); Drukuj_Pytanie(); scanf("%d", &err); UstawDosErrorlevel(err); } Wykorzystajmy te funkcje praktycznie, by zilustrować sposób przekazywania informacji przez pracujący program do systemu DOS. Zmienna otoczenia systemowego DOS ERRORLEVEL może być z wnętrza programu ustawiona na zadaną - zwracaną do systemu wartość. [Z] ________________________________________________________________ 1. Sprawdź, w jakim pliku nagłówkowym znajduje się prototyp funkcji exit(). Opracuj najprostszy program PYTAJ.EXE ustawiający zmienną systemową ERRORLEVEL według schematu: main() { printf("....Pytanie do użytkownika \n..."); scanf("%d", &n); exit(n); } 2. Zastosuj program PYTAJ.EXE we własnych plikach wsadowych typu *.BAT według wzoru: @echo off :LOOP cls echo 1. Wariant 1 echo 2. Wariant 2 echo 3. Wariant 3 echo Wybierz wariant działania programu...1,2,3 ? PYTAJ IF ERRORLEVEL 3 GOTO START3 IF ERRORLEVEL 2 GOTO START2 IF ERRORLEVEL 1 GOTO START1 echo Chyba zartujesz...? goto LOOP :START1 'AKCJA WARIANT 1 GOTO KONIEC :START2 'AKCJA WARIANT 2 GOTO KONIEC :START3 'AKCJA WARIANT 3 :KONIEC 'AKCJA WARIANT n - oznacza dowolny ciąg komend systemu DOS, np. COPY, MD, DEL, lub uruchomienie dowolnego programu. Do utworzenia pliku wsadowego możesz zastosować edytor systemowy EDIT. 3. Skompiluj program posługując się oddzielnym kompilatorem TCC.EXE. Ten wariant kompilatora jest pozbawiony zintegrowanego edytora. Musisz uruchomić go pisząc odpowiedni rozkaz po DOS-owskim znaku zachęty C:\>. Zastosowanie przy kompilacji małego modelu pamięci pozwol Ci uzyskać swój program w wersji *.COM, a nie *.EXE. Wydaj rozkaz: c:\borlandc\bin\bcc -mt -lt c:\pytaj.cpp Jeśli pliki znajdują się w różnych katalogach, podaj właściwe ścieżki dostępu (path). ________________________________________________________________ [???] CO TO ZA PARAMETRY ??? ________________________________________________________________ Przez swą "ułomność" - 16 bitową szynę i segmentację pamięci komputery IBM PC wymusiły wprowadzenie modeli pamięci: TINY, SMALL, COMPACT, MEDIUM, LARGE, HUGE. Więcej informacji na ten temat znajdziesz w dalszej części książki. Parametry dotyczą sposobu kompilacji i zastosowanego modelu pamięci: -mt - kompiluj (->*.OBJ) wykorzystując model TINY -lt - konsoliduj (->*.COM) wykorzystując model TINY i zatem odpowiednie biblioteki (do każdego modelu jest odpowiednia biblioteka *.LIB). Możesz stosować także: ms, mm, ml, mh, ls, lm, ll, lh. ________________________________________________________________ Po instalacji BORLAND C++/Turbo C++ standardowo jest przyjmowany model SMALL. Zatem kompilacja, którą wykonujesz z IDE daje taki sam efekt, jak zastosowanie kompilatora bcc/tcc w następujący sposób: tcc -ms -ls program.c Mogą wystąpić kłopoty z przerobieniem z EXE na COM tych programów, w których występują funkcje realizujące arytmetykę zmiennoprzecinkową (float). System DOS oferuje Ci do takich celów program EXE2BIN, ale lepiej jest "panować" nad tym problemem na etapie tworzenia programu. PODSTAWOWE TYPY DANYCH W JĘZYKU C++. Język C/C++ operuje pięcioma podstawowymi typami danych: * char (znak, numer znaku w kodzie ASCII) - 1 bajt; * int (liczba całkowita) - 2 bajty; * float (liczba z pływającym przecinkiem) - 4 bajty; * double (podwójna ilość cyfr znaczących) - 8 bajtów; * void (nieokreślona) 0 bajtów. Zakres wartości przedstawiono w Tabeli poniżej. Podstawowe typy danych w C++. ________________________________________________________________ Typ Znak Bajtów Zakres wartości ________________________________________________________________ char signed 1 -128...+127 int signed 2 -32768...+32767 float signed 4 +-3.4E+-38 (dokładność: 7 cyfr) double signed 8 1.7E+-308 (dokładność: 15 cyfr) void nie dotyczy 0 bez określonej wartości. ________________________________________________________________ signed - ze znakiem, unsigned - bez znaku. Podstawowe typy danych mogą być stosowane z jednym z czterech modyfikatorów: * signed / unsigned - ze znakiem albo bez znaku * long / short - długi albo krótki Dla IBM PC typy int i short int są reprezentowane przez taki sam wewnętrzny format danych. Dla innych komputerów może być inaczej. Typy zmiennych w języku C++ z zastosowaniem modyfikatorów (dopuszczalne kombinacje). ________________________________________________________________ Deklaracja Znak Bajtów Wartości Dyr. assembl. ________________________________________________________________ char signed 1 -128...+127 DB int signed 2 -32768...+32767 DB short signed 2 -32768...+32767 DB short int signed 2 -32768...+32767 DB long signed 4 -2 147 483 648... DD +2 147 483 647 long int signed 4 -2 147 483 648... DW +2 147 483 647 unsigned char unsigned 1 0...+255 DB unsigned unsigned 2 0...+65 535 DW unsigned int unsigned 2 0...+65 535 DW unsigned short unsigned 2 0...+65 535 DW signed int signed 2 -32 768...+32 767 DW signed signed 2 -32 768...+32 767 DW signed long signed 4 -2 147 483 648... DD +2 147 483 647 enum unsigned 2 0...+65 535 DW float signed 4 3.4E+-38 (7 cyfr) DD double signed 8 1.7E+-308 (15 cyfr) DQ long double signed 10 3.4E-4932...1.1E+4932 DT far * (far pointer, 386) 6 unsigned 2^48 - 1 DF, DP ________________________________________________________________ UWAGI: * DB - define byte - zdefiniuj bajt; DW - define word - zdefiniuj słowo (16 bitów); DD - double word - podwójne słowo (32 bity); DF, DP - define far pointer - daleki wskaźnik w 386; DQ - quad word - poczwórne słowo (4 * 16 = 64 bity); DT - ten bytes - dziesięć bajtów. * zwróć uwagę, że typ wyliczeniowy enum występuje jako odrębny typ danych (szczegóły w dalszej części książki). ________________________________________________________________ Ponieważ nie ma liczb ani short float, ani unsigned short float, słowo int może zostać opuszczone w deklaracji. Poprawne są zatem deklaracje: short a; unsigned short b; Zapis +-3.4E-38...3.4E+38 oznacza: -3.4*10^+38...0...+3.4*10^-38...+3.4*10^+38 Dopuszczalne są deklaracje i definicje grupowe z zastosowaniem listy zmiennych. Zmienne na liście należy oddzielić przecinkami: int a=0, b=1, c, d; float PI=3.14, max=36.6; Poświęcimy teraz chwilę drugiej funkcji, którą już wielokrotnie stosowaliśmy - funkcji wejścia - scanf(). FUNKCJA scanf(). Funkcja formatowanego wejścia ze standardowego strumienia wejściowego (stdin). Funkcja jest predefiniowana w pliku STDIO.H i korzystając z funkcji systemu operacyjnego wczytuje dane w postaci tekstu z klawiatury konsoli. Interpretacja pobranych przez funkcję scanf znaków nastąpi zgodnie z życzeniem programisty określonym przez zadany funkcji format (%f, %d, %c itp.). Wywołanie funkcji scanf ma postać: scanf(Format, Adres_zmiennej1, Adres_zmiennej2...); dla przykładu scanf("%f%f%f", &X1, &X2, &X3); wczytuje trzy liczby zmiennoprzecinkowe X1, X2 i X3. Format decyduje, czy pobrane znaki zostaną zinterpretowane np. jako liczba całkowita, znak, łańcuch znaków (napis), czy też w inny sposób. Od sposobu interpretacji zależy i rozmieszczenie ich w pamięci i późniejsze "sięgnięcie do nich", czyli odwołanie do danych umieszczonych w pamięci operacyjnej komputera. Zwróć uwagę, że podając nazwy (identyfikatory) zmiennych należy poprzedzić je w funkcji scanf() operatorem adresowym [&]. Zapis: int X; ... scanf("%d", &X); oznacza, że zostaną wykonane następujące działania: * Kompilator zarezerwuje 2 bajty pomięci w obszarze pamięci danych programu na zmienną X typu int; * W momencie wywołania funkcji scanf funkcji tej zostanie przekazany adres pamięci pod którym ma zostać umieszczona zmienna X, czyli tzw. WSKAZANIE DO ZMIENNEJ; * Znaki pobrane z klawiatury przez funkcję scanf mają zostać przekształcone do postaci wynikającej z wybranego formatu %d - tzn. do postaci zajmującej dwa bajty liczby całkowitej ze znakiem. [???] A JEŚLI PODAM INNY FORMAT ? ________________________________________________________________ C++ wykona Twoje rozkazy najlepiej jak umie, niestety nie sprawdzając po drodze formatów, a z zer i jedynek zapisanych w pamięci RAM żaden format nie wynika. Otrzymasz błędne dane. ________________________________________________________________ Poniżej przykład skutków błędnego formatowania. Dołącz pliki STDIO.H i CONIO.H. [P021.CPP] //UWAGA: Dołącz właściwe pliki nagłówkowe ! void main() { float A, B; clrscr(); scanf("%f %f", &A, &B); printf("\n%f\t%d", A,B); getch(); } [Z] ________________________________________________________________ 3 Zmień w programie przykładowym, w funkcji printf() wzorce formatu na %s, %c, itp. Porównaj wyniki. ________________________________________________________________ Adres w pamięci to taka sama liczba, jak wszystkie inne i wobec tego można nią manipulować. Adresami rządzą jednak dość specyficzne prawa, dlatego też w języku C++ występuje jeszcze jeden specjalny typ zmiennych - tzw. ZMIENNE WSKAZUJĄCE (ang. pointer - wskaźnik). Twoja intuicja podpowiada Ci zapewne, że są to zmienne całkowite (nie ma przecież komórki pamięci o adresie 0.245 ani 61/17). Pojęcia "komórka pamięci" a nie np. "bajt" używam świadomie, ponieważ obszar zajmowany w pamięci przez zmienną może mieć różną długość. Aby komputer wiedział ile kolejnych bajtów pamięci zajmuje wskazany obiekt (liczba długa, krótka, znak itp.), deklarując wskaźnik trzeba podać na co będzie wskazywał. W sposób "nieoficjalny" już w funkcji scanf korzystaliśmy z tego mechanizmu. Jest to zjawisko specyficzne dla języka C++, więc zajmijmy się nim trochę dokładniej. POJĘCIE ZMIENNEJ WSKAZUJĄCEJ I ZMIENNEJ WSKAZYWANEJ. Wskaźnik to zmienna, która zawiera adres innej zmiennej w pamięci komputera. Istnienie wskaźników umożliwia pośrednie odwoływanie się do wskazywanego obiektu (liczby, znaku, łańcucha znaków itp.) a także stosunkowo proste odwołanie się do obiektów sąsiadujących z nim w pamięci. Załóżmy, że: x - jest umieszczoną gdzieś w pamięci komputera zmienną całkowitą typu int zajmującą dwa kolejne bajty pamięci, a px - jest wskaźnikiem do zmiennej x. Jednoargumentowy operator & podaje adres obiektu, a zatem instrukcja: px = &x; przypisuje wskaźnikowi px adres zmiennej x. Mówimy, że: px wskazuje na zmienną x lub px jest WSKAŹNIKIEM (pointerem) do zmiennej x. Jednoargumentowy operator * (naz. OPERATOREM WYŁUSKANIA) powoduje, że zmienna "potraktowana" tym operatorem jest traktowana jako adres pewnego obiektu. Zatem, jeśli przyjmiemy, że y jest zmienną typu int, to działania: y = x; oraz px = &x; y = *px; będą mieć identyczny skutek. Zapis y = x oznacza: "Nadaj zmiennej y dotychczasową wartość zmiennej x"; a zapis y=*px oznacza: "Nadaj zmiennej y dotychczasową wartość zmiennej, której adres w pamięci wskazuje wskaźnik px" (czyli właśnie x !). Wskaźniki także wymagają deklaracji. Poprawna deklaracja w opisanym powyżej przypadku powinna wyglądać tak: int x,y; int *px; main() ...... Zapis int *px; oznacza: "px jest wskaźnikiem i będzie wskazywać na liczby typu int". Wskaźniki do zmiennych mogą zamiast zmiennych pojawiać się w wyrażeniach po PRAWEJ STRONIE, np. zapisy: int X,Y; int *pX; ... pX = &X; ....... Y = *pX + 1; €€€€€€/* to samo, co Y = X + 1 */ printf("%d", *pX);€€€€€€€/* to samo, co printf("%d", X); */ Y = sqrt(*pX);€€€€€€€€€€€/* pierwiastek kwadrat. z X */ ...... są w języku C++ poprawne. Zwróć uwagę, że operatory & i * mają wyższy priorytet niż operatory arytmetyczne, dzięki czemu * najpierw następuje pobranie spod wskazanego przez wskaźnik adresu zmiennej; * potem następuje wykonanie operacji arytmetycznej; (operacja nie jest więc wykonywana na wskaźniku, a na wskazywanej zmiennej!). W języku C++ możliwa jest także sytuacja odwrotna: Y = *(pX + 1); Ponieważ operator () ma wyższy priorytet niż * , więc: najpierw wskaźnik zostaje zwiększony o 1; potem zostaje pobrana z pamięci wartość znajdująca się pod wskazanym adresem (w tym momencie nie jest to już adres zmiennej X, a obiektu "następnego" w pamięci) i przypisana zmiennej Y. Taki sposób poruszania się po pamięci jest szczególnie wygodny, jeśli pod kolejnymi adresami pamięci rozmieścimy np. kolejne wyrazy z tablicy, czy kolejne znaki tekstu. Przyjrzyjmy się wyrażeniom, w których wskaźnik występuje po LEWEJ STRONIE. Zapisy: *pX = 0;€€€€€€€€€€€€i€€€€€€€€€X = 0; *pX += 1;€€€€€€€€€€€i€€€€€€€€€X += 1; (*pX)++;€€€€€€€€€€€€i€€€€€€€€€X++; /*3*/ mają identyczne działanie. Zwróć uwagę w przykładzie /*3*/, że ze względu na priorytet operatorów () - najwyższy - najpierw pobieramy wskazaną zmienną; ++ - niższy, potem zwiększmy wskazaną zmienną o 1; Gdyby zapis miał postać: *pX++; najpierw nastąpiłoby - zwiększenie wskaźnika o 1 i wskazanie "sąsiedniej" zmiennej, potem - wyłuskanie, czyli pobranie z pamięci zmiennej wskazanej przez nowy, zwiększony wskaźnik, zawartość pamięci natomiast, tj. wszystkie zmienne rozmieszczone w pamięci pozostałyby bez zmian. [???] JAK TO WŁAŚCIWIE JEST Z TYM PRIORYTETEM ? ________________________________________________________________ Wszystkie operatory jednoargumentowe (kategoria 2, patrz Tabela) mają taki sam priorytet, ale są PRAWOSTRONNIE ŁĄCZNE {L<<-R}. Oznacza to, że operacje będą wykonywane Z PRAWA NA LEWO. W wyrażeniu *pX++; oznacza to: najpierw ++ potem * Zwróć uwagę, że kolejność {L<<-R} dotyczy WSZYSTKICH operatorów jednoargumentowych. ________________________________________________________________ Jeśli dwa wskaźniki wskazują zmienne takiego samego typu, np. po zadeklarowaniu: int *pX, *pY; int X, Y; i zainicjowaniu: pX = &X; pY = &Y; można zastosować operator przypisania: pY = pX; Spowoduje to skopiowanie wartości (adresu) wskaźnika pX do pY, dzięki czemu od tego momentu wskaźnik pY zacznie wskazywać zmienną X. Zwróć uwagę, że nie oznacza to bynajmniej zmiany wartości zmiennych - ani wielkośc X, ani wielkość Y, ani ich adresy w pamięci NIE ULEGAJĄ ZMIANIE. Zatem działanie instrukcji: pY = pX; i *pY = *pX; jest RÓŻNE a wynika to znowu z priorytetu operatorów: najpierw * wyłuskanie zmiennych spod podanych adresów, potem = przypisanie wartości (ale już zmiennym a nie wskaźnikom!) C++ chętnie korzysta ze wskazania adresu przy przekazywaniu danych - parametrów do/od funkcji. Asekurując się na całej linii i podkreślając, że nie zawsze wygląda to tak prosto i ładnie, posłużę się do zademonstrowania działania wskaźników przykładowym programem. Wpisz i uruchom następujący program: [P022-1.CPP wersja 1] # include "stdio.h" # include "conio.h" int a=1,b=2,c=3,d=4,e=5,f=6,g=7,h=8,x=9,y=10,i; int *ptr1; long int *ptr2; void main() { clrscr(); ptr1=&a; ptr2=&a; printf("Skok o 2Bajty Skok o 4Bajty"); for(i=0; i<=9; i++) { printf("\n%d", *(ptr1+i)); printf("\t\t%d", *(ptr2+i)); } getch(); } [P022-2.CPP wersja 2] int a=11,b=22,c=33,d=44,e=55,f=66,g=77,h=88,x=99,y=10,i; int *ptr1; long int *ptr2; void main() { clrscr(); ptr1=&a; ptr2=&a; for (i=0; i<=9; i++) { printf("\n%d", *(ptr1+i)); printf("\t%d", *(ptr2+i)); getch(); } } W programie wykonywane są następujące czynności: 1. Deklarujemy zmienne całkowite int (każda powinna zająć 2 bajty pamięci) i nadajemy im wartości w taki sposób aby łatwo można je było rozpoznać. 2. Deklarujemy dwa wskaźnki: ptr1 - poprawny - do dwubajtowych zmiennych typu int; ptr2 - niepoprawny - do czterobajtowych zmiennych typu long int. 3. Ustawiamy oba wskaźniki tak by wskazywały adres w pamięci pierwszej liczby a=11. 4. Zwiększamy oba wskaźniki i sprawdzamy, co wskazują. Jeśli kompilator rozmieści nasze zmienne w kolejnych komórkach pamięci, to powinniśmy uzyskać następujący wydruk: Skok o 2B Skok o 4B 11€€€€€€€€€€€€€11 22€€€€€€€€€€€€€33 33€€€€€€€€€€€€€55 44€€€€€€€€€€€€€77 55€€€€€€€€€€€€€99 66€€€€€€€€€€€€€27475 77€€€€€€€€€€€€€28448 88€€€€€€€€€€€€€8258 99€€€€€€€€€€€€€27475 10€€€€€€€€€€€€€2844 Zwróć uwagę, że to deklaracja wskaźnika decyduje, co praktycznie oznacza operacja *(ptr + 1). W pierwszym przypadku wskaźnik powiększa się o 2 a w drugim o 4 bajty. Te odpowiednio 2 i 4 bajty stanowią długość komórki pamięci lub precyzyjniej, pola pamięci przeznaczonego dla zmiennych określonego typu. Wartości pojawiające się w drugiej kolumnie po 99 są przypadkowe i u Ciebie mogą okazać się inne. C++ pozwala wskaźnikom nie tylko wskazywać adres zmiennej w pamięci. Wskaźnik może również wskazywać na inny wskaźnik. Takie wskazania: int X; €€€int pX; €€int ppX; pX = &X;€€ppX = &pX; oznaczamy: *pX€€- pX wskazuje BEZPOŚREDNIO zmienną X; **ppX€- ppX skazuje POŚREDNIO zmienną X (jest wskaźnikiem do wskaźnika). ***pppX - pppX wskazuje pośrednio wskaźnik do zmiennej X itd. [Z] ________________________________________________________________ 4 Wybierz dowolne dwa przykładowe programy omawiane wcześniej i przeredaguj je posługując się zamiast zmiennych - wskaźnikami do tych zmiennych. Pamiętaj, że przed użyciem wskaźnika należy: * zadeklarować na jaki typ zmiennych wskazuje wskaźnik; * przyporządkować wskaźnik określonej zmiennej. 5 Zastanów się, co oznacza ostrzeżenie wypisywane podczas uruchomienia programu przykładowego: Warning 8: Suspicious pointer conversion in function main. ________________________________________________________________