Podstawowe elementy języka C
Wprowadzenie
Język ten stworzono na początku lat siedemdziesiątych w USA. Na początku był to język przeznaczony do pisania systemów operacyjnych, między innymi prawie cały system Unix został w tych czasach napisany w tym języku za wyjątkiem tej części, która bezpośrednio odwołuje się do sprzętu. To nierozerwalne związanie języka C z systemem Unix zapoczątkowało wielką popularność tego języka w społeczności informatyków. Język ten umożliwia programiście w zasadzie pełną kontrolę nad sprzętem bez potrzeby odwoływania się do asemblera, a względnie nieduża liczba standardowych typów sprawia, że jest on bardzo elastyczny. Programista ma tutaj takie możliwości tworzenia jakich nie dadzą mu inne języki. Jak Państwo zobaczycie, programy pisane w C mogą być bardzo zwięzłe.
Za tak ogromną elastycznością i możliwością wypracowania własnego stylu pisania programów idą niestety zwiększone możliwości popełnienia błędu, polegającego na skorzystaniu z tzw. "efektów ubocznych", które występują przy wielu operacjach. Panuje powszechna opinia, że jest to język jednocześnie bardzo piękny i dosyć niebezpieczny w tworzeniu oprogramowania przez początkujących programistów. Językiem C zainteresowało się wiele firm, które zaczęły rozwijać ten język w różnych kierunkach. Aby temu zapobiec Amerykański Narodowy Instytut Normalizacji (American National Standard Institute) utworzył specjalny komitet, którego zadaniem było ujednolicenie i pilnowanie, aby język ten był spójny i ewoluował w jednym, określonym przez ten komitet kierunku. Komitet ANSI jest zainteresowany ujednoliceniem C dla wszystkich komputerów, jednak istnieje wiele problemów specyficznych dla określonych typów komputerów np. obsługa pamięci i dostęp do urządzeń zewnętrznych inaczej wygląda w przypadku komputerów typu mainframe i PC. W tych dziedzinach firmy produkujące kompilatory wprowadzają własne rozwiązania. Podobnie ma się sprawa rozwiązań graficznych. W zasadzie każda firma produkująca kompilatory języka C ma własną bibliotekę do operowania w trybie graficznym.
Ponadto będziemy się często odwoływać do znajomości programowania w Pascalu z pierwszego bloku a wszystkie programy domyślnie będą kompilowane przy pomocy kompilatora języka C firmy Borland.
Po tym krótkim wstępie przejdźmy teraz do pokazania wielkich możliwości tego języka.
Struktura programu
Aby program napisany w C można było skompilować do postaci wykonywalnej, musi on zawierać w sobie funkcję o nazwie main(). Na początku naszej przygody z językiem C będziemy używać tej funkcji w wersji bezparametrowej - zapewniają to dwa nawiasy okrągłe (). Ponadto funkcja ta użyta w takiej postaci zwraca wartość typu int do systemu operacyjnego. W naszym przykładzie będzie ona na początku zwracać wartość 0, mówi o tym wiersz return 0.
...
deklaracja zmiennych i stałych globalnych
deklaracja funkcji lub ich prototypów
...
int main()
{
deklaracja zmiennych lokalnych funkcji main
...
instrukcje
...
return 0;
}
...
ewentualne rozwinięcie prototypów funkcji.
Część wykonawcza i deklaracyjna każdej funkcji i bloku, czyli zgrupowanych instrukcji, zawarta jest między dwoma nawiasami klamrowymi {...}. Cechą charakterystyczną języka C jest to, że można umieszczać własne funkcje zarówno przed funkcją main jak i po niej. Ważne jest tylko to, aby w momencie odwołania się do nowej funkcji był znany wcześniej jej prototyp czyli nagłówek funkcji zawierający nazwę funkcji, listę jej argumentów (ważne są typy) oraz typ zwracanej wartości. Więcej informacji na temat tworzenia funkcji znajdziemy w jednym z następnych rozdziałów. Podobnie jak w Pascalu musimy zadeklarować każdą zmienną i stałą, której chcemy użyć w naszym programie. Definicja zmiennej określa trzy elementy:
widoczność - w zależności od miejsca deklaracji zmiennej mamy zmienne globalne czyli takie, których można użyć w całym naszym programie oraz lokalne, które są dostępne tylko w jednym bloku programu,
czas życia zmiennej - zmienne lokalne zadeklarowane w bloku istnieją tylko wtedy, gdy sterowanie programu zostanie przeniesione do tego bloku i są usuwane z pamięci po opuszczeniu tego bloku,
typ zmiennej oraz jej wartość początkową - w języku C istnieje możliwość jednoczesnego deklarowania zmiennej wraz z przypisaniem jej wartości początkowej.
Komentarze
Fragment kodu, który nie powinien być kompilowany można ukryć na dwa sposoby. Pierwszy z nich polega na ujęciu tekstu komentarza parą znaków /* stanowiącą początek oraz parą znaków */ określającą koniec komentarza.
Drugi sposób pozwala ukryć część kodu znajdującego się w danym wierszu począwszy od podwójnego znaku // do jego końca.
Przykład
main()
{
/* to jest komentarz */
int a = 5; // to też jest komentarz
Ostrzeżenia i błędy kompilacji
W procesie kompilacji programista uzyskuje informacje o znalezionych błędach (kompilator języka C w przeciwieństwie do kompilatora Pascala nie przerywa procesu kompilacji po napotkaniu pierwszego błędu) oraz, co ciekawe ostrzeżenia związane z prawdopodobnie nieprawidłowym użyciem składni języka.
Podstawowe typy danych
W C są cztery podstawowe typy danych: int, char, float oraz double. Pierwsze dwa służą do przechowywania informacji o liczbach całkowitych i znakach, a dwa następne przeznaczone są dla liczb rzeczywistych. Rozmiary, w bajtach, wymienionych typów są uzależnione od systemu operacyjnego, w którym utworzony program będzie wykonywany. Przyjęto, że typ int ma rozmiar równy długości słowa maszynowego, a więc dla 16-bitowego systemu DOS, typ int ma 2 bajty. Typ char ma rozmiar jednego bajtu i przechowuje znaki. Typ rzeczywisty float zajmuje 4 bajty, a typ double 8 bajtów.
Standardowo zmienne typów int i char mogą mieć wartości dodatnie i ujemne, czyli ze znakiem.
Zmienne deklarujemy globalnie przed funkcją main lub też w części deklaracyjnej funkcji. Bardzo użyteczną cechą języka C jest to, że można w momencie deklaracji zmiennej przypisać jej wartość początkową. Przy deklaracji zmiennej piszemy najpierw typ, a następnie listę zmiennych tego typu.
Przykład
int a;
int b = 9;
main()
{
double x, y, z = 34.23;
}
Zmienne globalne są zawsze zerowane chyba, że zmienna ma przypisaną w deklaracji wartość początkową. Tak więc zmienna a ma wartość równą zero, ponieważ jest to zmienna globalna niezainicjowana żadną wartością, natomiast zmienne x i y mogą mieć dowolne wartości z tytułu lokalnego charakteru.
Istnieje możliwość poszerzenia podstawowych typów danych przez użycie modyfikatorów: unsigned, long oraz short. Pierwszy z nich zarezerwowany jest do typów int i char i zapewnia możliwość traktowania wartości przechowywanej przez zmienną tego typu jako wartości bez znaku.
Przykład
char a = 128;
unsigned char b = 128;
W tym przykładzie zmienna a ma przypisaną wartość, która znajduje się poza zakresem wartości przechowywanych w zmiennych tego typu. Po takiej deklaracji zmienna uzyska wartość -1. Druga deklaracja poprzedzona modyfikatorem unsigned umożliwia przypisanie zmiennej wartości 128.
Modyfikatory long i short służą do zwiększenia lub zmniejszenia rozmiaru zmiennej. Ta zmiana wielkości zmiennej zależna jest od systemu operacyjnego i użytego kompilatora. I tak zmienna typu long int ma rozmiar 4 bajtów, a zmienna short int ciągle ma rozmiar 2 bajtów.
Zakresy zmiennych podstawowych typów przedstawione są w podanej tabeli.
Typ |
Rozmiar |
Zakres |
unsigned char |
1 bajt |
0 do 255 |
char |
1 bajt |
-128 do 127 |
unsigned int |
2 bajty |
0 do 65,535 |
short int |
2 bajty |
-32,768 do 32,767 |
int |
2 bajty |
-32,768 do 32,767 |
unsigned long |
4 bajty |
0 do 4,294,967,295 |
long |
4 bajty |
-2,147,483,648 do 2,147,483,647 |
float |
4 bajty |
3.4 * 0-38 do 3.4 * 1038 |
double |
8 bajtów |
1.7 * 10-308 do 1.7 * 10308 |
long double |
10 bajtów |
3.4 * 10-4932 do 1.1 * 104932 |
Ponadto przyjęto, że gdy nie ma w deklaracji zmiennej podanego określonego typu, a występują tylko modyfikatory, to taka zmienna jest typu int.
Przykład
unsigned a;
unsigned short b;
long p;
We wszystkich tych deklaracjach domyślnie przyjęty jest typ int. Rozmiary podstawowych typów podane są zawsze w plikach nagłówkowych limits.h i float.h, które można znaleźć w podkatalogu include.
Aby zadeklarować stałą, należy poprzedzić ją słowem kluczowym const. Oczywiście wraz z deklaracją stałej musi wystąpić przypisanie jej wartości.
Przykład
const int c = 123;
const double f = 3.14;
Literały
Literał reprezentuje bezpośrednią wartość danej. Przykładowo dla przypisania zmiennej typu int wartości 2, piszemy np., a = 2; Napis 2 jest właśnie takim literałem. Rozróżniamy literały znakowe, liczbowe i łańcuchowe. Literały łańcuchowe - to ciągi znaków ujęte w cudzysłowy. Organizacja łańcuchów znaków zostanie omówiona szerzej w rozdziałach poświęconym tablicom i wskaźnikom.
Omówimy sobie teraz literały znakowe. Ogólnie mówiąc literały znakowe zapisywane są za pomocą apostrofów. Między apostrofami może być jeden, dwa lub cztery znaki z tablicy kodów ASCII.
Przykłady
char a = '3';
Znak '3' jest właśnie literałem znakowym. W przypadku literałów dwuznakowych mamy do dyspozycji:
\n - zmiana wiersza,
\r - powrót karetki,
\t - tabulacja
\0 - znak o kodzie 0,
\b - cofnięcie znaku,
\f - nowa strona,
\a - sygnał dźwiękowy,
\' - apostrof,
\" - cudzysłów,
\\ - znak ukośnika (backslash).
Pierwszy znak w takim literale jest zawsze znakiem ukośnika. Proszę zwrócić uwagę na ostatni zapis. Przy wykorzystaniu znaku \ jako początku literału znakowego pojawiłby się problem jak np., przypisać zmiennej znakowej znak \. W tym celu należy użyć w zapisie podwójnego znaku \.
Przykład
char a = '\\'; // zmiennej znakowej b przypisano znak ukośnika
char b = '\"'; // zmiennej znakowej b przypisano znak cudzysłowu
Literały znakowe zawierające więcej niż dwa znaki są wykorzystywane do zapisu znaku w postaci heksadecymalnej.
Przykład
char a = '\x41';
Zmiennej znakowej a zostanie przypisany znak znajdujący się na 65 pozycji (duża litera A) w tablicy kodów ASCII, ponieważ liczba 41 w zapisie heksadecymalnym (świadczy o tym para znaków \x) oznacza właśnie 65.
Literały całkowite można zapisywać w postaci dziesiętnej i szesnastkowej, np.:
int a = 234;
a = 0x41;
Zmienna a ma wartość 234, a później 41 w zapisie heksadecymalnym, co jest równoznaczne jak już wiemy liczbie 65. Typy literałów liczbowych wynikają z formy ich zapisu i wartości jakie reprezentują.
Przykład
0xff - 256 dziesiętnie - typ int,
20000 - typ int,
40000 - typ unsigned int,
80000 - typ long.
Literały rzeczywiste zapisujemy z użyciem kropki. Wszystkie są typu double. Można zapisywać je także z użyciem postaci wykładniczej.
Przykład
1.0 - liczba rzeczywista typu double,
23.4e2 - liczba rzeczywista typu double o wartości 23400.0
Typy literałów mogą być regulowane przez programistę. W tym celu należy użyć odpowiedniego przyrostka. Mogą być nimi litery:
u lub U - wartość literału całkowitego będzie traktowana jako unsigned,
l lub L - wartość literału całkowitego lub rzeczywistego jest traktowana jako long,
f lub F - literał rzeczywisty jest typu float a nie double.
Przykład
2.5F - liczba typu float o wartości 2.5
30UL - liczba typu unsigned long o wartości 30
12.4L - liczba typu long double o wartości 12.4
Operatory
W języku C istnieje znacznie więcej operatorów niż w Pascalu, które mają w wielu przypadkach inny priorytet niż ten, do którego przyzwyczaił nas Pascal.
Należy zwrócić uwagę na bardzo rozbudowaną strukturę priorytetów, czyli zależności między kolejnością wykonywania tych operatorów, a ich położeniem w wyrażeniu. Im wyższy jest priorytet operatora, tym szybciej wykonana zostanie ta część wyrażenia.
Prezentację operatorów zacznijmy od samego szczytu. Jak widać najszybciej wykonywane są operatory odwołania się do elementu tablicy, pobrania elementu struktury oraz grupowania zastosowanego w wyrażeniu. Wiązanie lewe tych operatorów powoduje, że operatory o tym samym priorytecie wykonywane są kolejno, począwszy od pierwszego aż do ostatniego w wyrażeniu.
Na poziomie o jeden niższym znajdują się jednoargumentowe operatory negacji (!), zmiany znaku (+) i (-), inkrementacji (++) i dekrementacji (--), konwersji typu (type) oraz uzupełnienia do 1 (~). Operatory inkrementacji i dekrementacji mogą występować po lewej i po prawej stronie zmiennej.
Tablica operatorów
Priorytet |
Wiązanie |
Operatory |
15 |
lewe |
( ) [ ] -> . |
14 |
prawe |
! ~ ++ -- + - (type) |
13 |
lewe |
* / % |
12 |
lewe |
- + |
11 |
lewe |
<< >> |
10 |
lewe |
< <= >= > |
9 |
lewe |
== != |
8 |
lewe |
& |
7 |
lewe |
^ |
6 |
lewe |
| |
5 |
lewe |
&& |
4 |
lewe |
|| |
3 |
prawe |
? |
2 |
prawe |
= += -= *= /= %= &= |= ^= <<= >>= |
1 |
lewe |
, |
Przykład
main()
{
int a = 3;
a++;
++a;
}
W wyniku tych działań zmienna a zwiększy swoja wartość o dwa. Dwuargumentowe operatory mnożenia (*), dzielenia (/) oraz reszty z dzielenia (%) posiadają priorytet 13.
Przykład
main()
{
int a = 10, b = 3, c, d, e, k;
double f, g, h;
c = a * b; //c = 30
d = a / b; //d = 3
e = a % b; //e = 1
f = a / b; //f = 3.0
g = a / 3.0; //g = 3.333
h = a / (double)b; //h = 3.333
k = a / (double)b; //k = 3
}
W przypadku obliczania wartości zmiennej d operator dzielenia jest interpretowany jako dzielenie całkowitoliczbowe, gdyż obydwa jego argumenty są typu int. Operator (%) umożliwia uzyskanie reszty z dzielenia dwóch argumentów całkowitych. Ciekawa sytuacja występuje przy obliczaniu wartości zmiennej f. Otóż w tym przypadku najpierw jest wykonywane dzielenie całkowitoliczbowe, ponieważ oba argumenty są typu całkowitoliczbowego, a potem w wyniku działania operatora (=) następuje niejawna konwersja wartości całkowitej 3 do wartości rzeczywistej typu double.
W dwóch następnych wyrażeniach mamy do czynienia z jawnym wymuszeniem dzielenia rzeczywistego dwóch liczb za pośrednictwem zmiany typu dzielnika b z całkowitego na rzeczywisty. Jeśli w wyrażeniu przynajmniej jeden z argumentów dzielenia jest typu rzeczywistego to operator dzielenia wykonuje operacje na liczbach rzeczywistych ponieważ w tym momencie zachodzi automatyczna konwersja drugiego argumentu do typu rzeczywistego.
W ostatnim wyrażeniu mamy najpierw do czynienia z dzieleniem rzeczywistym dwóch argumentów, które daje w wyniku liczbę 3.3333, a potem następuje niejawna konwersja otrzymanej wartości do typu całkowitego dokładnie takiego jak zmienna k. Poziom 11 zajmują dwa operatory przesunięcia bitowego w lewo i prawo.
Przykład
main()
{
int a = 4, b = 16, c, d;
c = a << 2; //c = 16
d = b >> 2; //d = 4
}
Jak już wiemy z "Podstaw programowania w Pascalu", przesunięcie w lewo o jeden bit powoduje pomnożenie tej liczby dwa razy, a przesunięcie w prawo podzielenie przez dwa. Kolejne dwa poziomy przeznaczone są dla operatorów relacji. W języku C porównanie dwóch wartości odbywa się za pomocą podwójnego operatora (==), natomiast przy sprawdzaniu nierówności korzystamy ze złożenia dwóch operatorów negacji (!) oraz równości (=) czyli (!=).
Dwa kolejne operatory (&) i (|) dotyczą mnożenia i sumy bitowej dwóch wartości. Priorytety 5 i 4 są przeznaczone dla dwuargumentowych operatorów iloczynu (&&) i sumy logicznej (||) dwóch wartości.
Bardzo ciekawe jest działanie operatora trójargumentowego (?). Ogólna postać tego operatora jest następująca:
e1 ? e2 : e3
gdzie e1, e2 i e3 są wyrażeniami. Działanie tego operatora jest następujące: na początku jest obliczana wartość wyrażenia e1. Jeśli jest ona różna od zera, to następuje wykonanie wyrażenia e2, a w przeciwnym przypadku jest wykonywane wyrażenie e3. Tak więc rezultatem operacji jest wyrażenie e2 albo e3. Jak widać działanie tego operatora jest identyczne do działania instrukcji if.
W języku C mamy do dyspozycji kilka operatorów przypisania. Podstawowy z nich (=) ma rozszerzone możliwości w stosunku do swojego odpowiednika z Pascala. Otóż można w jednym wyrażeniu przypisać wartość kilku zmiennym. Ta cecha znakomicie upraszcza pisanie programu i jednocześnie zwiększa przejrzystość programu.
Przykład
main()
{
int a , b, c;
a = b = c = 3;
}
W tym przypadku wszystkie zmienne uzyskują taką samą wartość. Na przykładzie tego operatora wygodnie jest wytłumaczyć istotę i sposób funkcjonowania pojęcia wiązanie operatora. Jak widzimy, w tym wyrażeniu mamy do czynienia z trzema operatorami przypisania (=) o prawym wiązaniu. Z tego wynika, że najpierw jest realizowana pierwsza operacja z prawej. W wyniku jego działania zmienna c uzyskuje wartość 3. Następnie wykonywany jest operator występujący między c i b. Kolejna zmienna uzyskuje wartość 3. Jak widać z tego schematu działania na końcu przypisana jest wartość zmiennej a.
Kolejne operatory przypisania występujące w tabeli operatorów tworzone są na podstawie zastępstwa przykładowego zapisu:
a = a + 2;
na skrócony zapis
a += 2;
który oznacza, że zmienna a została zwiększona o 2.
Operator posiadający najniższy priorytet (,) zwany jest operatorem połączenia i jego działanie zostanie omówione przy instrukcji for.
Przykład
Korzystając z tabeli operatorów podaj, jak zostanie zinterpretowane następujące wyrażenie:
a -= b = -c++ + d - e;
przy założeniu, że wszystkie zmienne są typu całkowitego int.
Analizę rozpoczynamy od lewej strony. Najwyższy priorytet w tym wyrażeniu mają operatory dotyczące argumentu c, a mianowicie zmiany znaku (-) oraz inkrementacji (++). W tym momencie zatrzymajmy się na chwilę przy specyficznym działaniu operatora (++). Jak już wiemy, może on wystąpić przed zmienną lub po niej. Jeśli występuje przed zmienną to najpierw zostanie zwiększona wartość tej zmiennej, a dopiero potem zostanie wykonane całe wyrażenie. Jeśli jest za zmienną, tak jak ma to miejsce w tym przypadku, to najpierw zostanie wykonane całe wyrażenie z aktualną wartością zmiennej c, a następnie wartość tej zmiennej zostanie zwiększona o 1. Dokładnie tak samo działa operator dekrementacji (--). W tym momencie zapiszemy to wyrażenie z użyciem nawiasów grupujących.
a -= b = (-c) + d - e;
c++;
W wyrażeniu zostały dwa operatory dodawania i odejmowania oraz dwa przypisania. Jako pierwsze zostanie wykonane dodawanie przed odejmowaniem, bo te dwa operatory mają wyższy priorytet i lewostronne wiązanie
a -= b = ((-c) + d) - e;
c++;
Z charakteru prawostronnego wiązania pozostałych dwóch operatorów przypisania wynika, że najpierw zmiennej b zostanie przypisana wartość uzyskana z przeanalizowanego wcześniej działania, a potem zmienna a zostanie zmniejszona o wartość zmiennej b.
a -= (b = (((-c) + d) - e));
c++;
Zadanie
Przeanalizuj samodzielnie, jakie będą mieć wartości zmienne a, b i c po wykonaniu podanych niżej, przykładowych wyrażeń:
c = 3;
a = b = c++;
c = 5;
(a = (b = ++c)++)--;
c = 5;
(a = ++(b = ++c))--;
(c = (b = 1 + (a = (5 > 7))++)++*4)--;
Instrukcje
Każdy uniwersalny język programowania, a C jest takim właśnie językiem, ma wbudowane pięć podstawowych instrukcji:
warunkowa (if),
powtarzania (for, while, do),
wyboru (switch).
Przejdźmy teraz do prezentacji poszczególnych instrukcji.
Instrukcja warunkowa - if
Zaczniemy od instrukcji wyboru if. Działa prawie tak samo jak instrukcja if w Pascalu.
if(wyrażenie)
instrukcja A;
else
instrukcja B;
Jeśli wyrażenie daje w wyniku wartość różną od zera, albo jest tam warunek logiczny dający prawdę czyli wartość 1, to wykonywana jest instrukcja A w przeciwnym wypadku wykona się instrukcja B. Oczywiście część występująca od else jest opcjonalna.
Instrukcja powtarzania - while
Pierwsza z instrukcji powtarzania to while, która jest bardzo podobna w działaniu do swojej odpowiedniczki w Pascalu.
while(wyrażenie)
instrukcja;
Jeśli wyrażenie jest prawdziwe lub daje w wyniku wartość różną od zera, to wejdź do pętli i wykonaj znajdującą się tam instrukcję. Zwróćmy uwagę na fakt, że niekoniecznie musi być tam warunek logiczny. Zaprezentowany poniżej przykład powinien pokazać zwiększone możliwości języka C w sferze efektywności zapisu algorytmu.
Przykład
Należy zsumować 20 kolejnych liczb naturalnych począwszy od 1;
pierwsza wersja (zapis podobny do zapisu programu w Pascalu)
main()
{
int a, suma;
a = 20;
suma = 0;;
while(a >= 0) {
suma += a;
a--;
}
}
druga wersja (wykorzystanie możliwości jakie daje język C)
main()
{
int a = 20, suma = 0;
while(a)
suma += a--;
}
Jak widać skorzystaliśmy tutaj z możliwości, jakie daje nam operator dekrementacji (--). Wiemy już, że jeśli występuje on za zmienną, to operacja zmniejszenia wartości tej zmiennej będzie wykonana po zakończeniu całego wyrażenia, czyli w naszym przypadku sumowania kolejnej liczby naturalnej. Zwróćmy uwagę na ogromne skrócenie zapisu algorytmu, które wynika także z omówionej wcześniej właściwości instrukcji while oraz możliwości deklarowania zmiennych i jednoczesnego przypisywania im wartości.
Instrukcja powtarzania - do while
Ten sam problem rozwiążemy przy użyciu instrukcji do while, której składnia jest następująca:
do
instrukcja;
while(wyrażenie);
Jeśli wyrażenie daje w wyniku wartość różną od zera, to wejdź do pętli i wykonaj znajdującą się tam instrukcję. Podobnie jak przy omawianiu poprzedniej instrukcji posługujemy się pojęciem wyrażenia, a nie warunku logicznego, który występował w instrukcji repeat.
main()
{
int a = 20, suma = 0;
do
suma += a--;
while(a);
}
Instrukcja powtarzania - for
Trzecia instrukcja jest bardzo silnym narzędziem programowania. Składnia jest następująca:
for(wyrażenie A; wyrażenie B; wyrażenie C)
instrukcja;
Najpierw jest wykonywane wyrażenie A (wyrażenie inicjujące), następnie wyrażenie B (wyrażenie sprawdzania warunku wejścia do pętli). Jeśli ma ono wartość różną od zera to wykonywana jest instrukcja a po niej wyrażenie C (wyrażenie modyfikujące) i dalej wyrażenie B. Cały cykl powtarza się aż do momentu, gdy wyrażenie B osiągnie wartość 0.
Już pobieżna analiza funkcjonowania tej instrukcji napawa optymizmem. Brak czegoś takiego jak zmienna sterująca i to jeszcze typu porządkowego oraz możliwość zadania dowolnego kroku czynią z tej instrukcji potężne narzędzie do programowania w przeciwieństwie do instrukcji for w Pascalu, która była bardzo ograniczona.
Przykład
Przeanalizujmy cztery wersje fragmentu programu sumującego kolejne liczby naturalne od 1 do 20.
pierwsza wersja (zapis prawie taki sam jak w Pascalu)
main()
{
int a, suma = 0;
for(a = 1; a <= 20; a++)
suma += a;
}
wersja druga
main()
{
int a = 1, suma = 0;
for(; a <= 20; a++)
suma += a;
}
W drugiej wersji została usunięta z instrukcji for część inicjująca i przeniesiona do deklaracji zmiennej (przypisanie zmiennej a wartości początkowej 1).
wersja trzecia
main()
{
int a = 1, suma = 0;
for(;a <= 20;)
suma += a++;
}
W tej wersji do instrukcji wykonywanej w pętli for została przeniesiona część modyfikująca.
wersja czwarta
main()
{
int a = 1, suma = 0;
for(;;) {
suma += a;
if(a == 20)
break;
}
}
W tym przypadku otrzymaliśmy instrukcję for w wersji nieskończonej pętli, ponieważ nie ma w niej warunku. Opuszczenie pętli realizowane jest przez nową instrukcję break, której zadaniem jest przeniesienie sterowania programu do bloku, z którego została ta pętla wywołana, a więc w naszym przypadku do pierwszej instrukcji występującej za instrukcją for .
Przykład
W tym przykładzie skupimy się na pokazaniu, jak można wykorzystać instrukcję for.
main()
{
int z = 2, c, w = 0;
double a = 2.0;
for(c = 0; a <= 3.0; a += 0.2, z++) {
c += 1;
w++;
}
}
Jak widać nie ma tutaj zmiennej sterującej, a część modyfikująca zawiera dwa wyrażenia oddzielone przecinkiem. Ten przecinek pełni rolę operatora połączenia. Jest to operator o najniższym priorytecie, który w tym momencie umożliwia programiście wykonanie w ramach instrukcji modyfikującej pętli for dwóch lub więcej dowolnych instrukcji.
Kolejność wykonywania się tych instrukcji zależy od wiązania tego operatora (wiązanie lewe) czyli od lewej do prawej.
Instrukcja wyboru - switch
Instrukcja switch pełni rolę podobną do instrukcji case, którą znamy z Pascala. Składnia jest następująca:
switch(wyrażenie) {
case wartość A: akcja A;
case wartość B: akcja B;
...
default: akcja Z;
}
Działanie tej instrukcji jest następujące: na początku obliczana jest wartość wyrażenia występującego w nawiasach za słowem switch. W drugiej kolejności wartość ta porównywana jest z listą wartości występującą w kolejnych wierszach za słowem kluczowym case. Jeśli jest równa jednej z tych wartości, to wykonywana jest odpowiednia akcja, a następnie wszystkie kolejne akcje znajdujące się na liście od tego miejsca w dół aż do zamykającego instrukcję switch nawiasu klamrowego. Zauważmy w tym momencie, że jest to zupełnie inne działanie niż instrukcji wyboru case znanej nam z Pascala.
Jeśli wartość obliczanego wyrażenia nie jest równa żadnej z wartości występujących na liście stojących za słowami case, to wykonywana jest akcja stojąca za default. Aby upodobnić działanie instrukcji switch do instrukcji case z Pascala należy się posłużyć poznaną wcześniej instrukcją break.
Przykład
#include <stdio.h>
main()
{
int a, x = 10, y = 2;
a = 2;
switch(a) {
case 1: x *= y; break;
case 2: x /= y; break;
case 3: x += y; break;
case 4: x -= y; break;
case 5: x++;
default: printf("ABC!\n");
}
}
W zależności od wartości zmiennej a zostanie wykonana jedna z operacji na zmiennych x i y. Obecność instrukcji break w każdej z akcji powoduje, że działanie tej instrukcji jest analogiczne do działania instrukcji case.
Operacje wejścia/wyjścia
W języku C podobnie jak w Pascalu są zaimplementowane odpowiednie funkcje zapewniające kontakt użytkownika z wykonywanym programem. W tym momencie przez kontakt rozumiemy wprowadzanie danych dowolnych typów prostych ze standardowego wejścia komputera (domyślnie jest nim klawiatura) oraz wyprowadzanie wyników działanie programu na standardowe wyjście (ekran komputera).
Jako pomost między wykonywalnym programem a sprzętem wykorzystywane są standardowe pliki (pojęcie powinno być już znane z modułu "Podstawy programowania wPascalu") obsługujące:
wejście - stdin,
wyjście - stdout,
drukarkę - stdprn,
urządzenie pomocnicze - stdaux,
występujące błędy - stderr.
UWAGA! Aby skorzystać z tych funkcji i predefiniowanych plików należy w nagłówku programu włączyć bibliotekę stdio.h.
#include <stdio.h>
Funkcje obsługujące standardowe wyjście komputera
printf
Pierwszą funkcją, którą omówimy jest funkcja printf, często nosząca nazwę funkcji formatowanego wyjścia. Możliwości tej funkcji są znacznie większe w porównaniu do procedur writei writeln występujących w Pascalu.
Przykład
Należy wyprowadzić na ekran wartość dwóch zmiennych całkowitych typu int.
Rozwiązanie
#include <stdio.h>
main()
{
int a = 10, b = 356;
printf("%d%d", a, b);
}
Na ekranie uzyskamy napis w następującej postaci: 10356
Po obejrzeniu zapisu funkcji printf można stwierdzić, że lista jej parametrów składa się jakby z dwóch części. Druga część zawiera listę zmiennych, stałych lub wyrażeń, które są wyprowadzane do standardowego pliku wyjściowego stdin, natomiast pierwszą stanowią jakieś dziwne w tym momencie zapisy. Te dziwne napisy to nic innego jak określenie sposobu, formatu wyprowadzania wartości zmiennych a i b do pliku stdin. Stąd też pochodzi określenie - funkcja formatowanego wyjścia.
Format ograniczony jest cudzysłowami. Do każdej wyprowadzanej zmiennej przypisana jest pewna sekwencja (sekcja) znaków rozpoczęta znakiem %. W tym przykładzie obie zmienne wyprowadzane są z użyciem formatu d. Oznacza on, że wartość zmiennej całkowitej ma być wyprowadzona w zapisie dziesiętnym. Stąd na ekranie widzimy ciąg cyfr 10356.
Przed i po każdej sekcji może wystąpić dowolny komentarz, który w tym momencie zostanie także wyprowadzony do stdin.
Przykład
W zmiennych a i b znajdują się dwie liczby przedstawiające wiek naszej bliskiej znajomej (liczbę skończonych pełnych lat i miesięcy). Wyprowadzić na ekran napis w postaci: Ewa ma 25 lat i 8 miesięcy.
#include <stdio.h>
main()
{
int a = 25, b = 8;
printf("Ewa ma %d lat i %d miesięcy", a, b);
}
Po tym krótkim wprowadzeniu przejdziemy do podania pełnej składni tej funkcji.
int printf(char *format, ...);
W tym momencie już widać, dlaczego lista formatów wraz z komentarzami musiała być ujęta w cudzysłów - funkcja spodziewała się w tym momencie przekazania łańcucha znaków. Jako drugi argument widzimy trzy kropki (...). W definicji funkcji oznaczają one funkcję z dowolną liczbą argumentów. Więcej na temat tworzenia własnych funkcji powiemy w dalszej części tego modułu internetowego.
Format wyprowadzania wartości: zmiennych, wyrażeń, stałych oraz funkcji ma określoną strukturę:
%f w .p t
Zaczniemy omawianie tych elementów od końca. Ostatni element jest - krótko mówiąc - jednoliterowym kodem opisującym konwersję wartości dowolnego typu przychodzącej jako argument funkcji do łańcucha znaków wyprowadzanego później do stdout. Omówimy sobie tylko te najważniejsze (najczęściej używane) kody konwersji. Na ostatniej pozycji mogą wystąpić następujące litery:
Kod konwersji |
Typ danych |
Opis |
d |
int |
liczba w zapisie dziesiętnym |
u| |
unsigned |
liczba dziesiętna bez znaku |
x |
int |
liczba w zapisie heksadecymalnym |
c |
char |
znak |
s |
char * |
łańcuch znaków |
f |
double |
liczba rzeczywista |
Przykłady użycia wymienionych wyżej kodów
#include <stdio.h>
main()
{
unsigned a = 23;
int b = -1, d = 15;
char c = 'a';
char *s = "Ala ma kotka";
double r = 3.1415;
printf("%u %d %x %c\n%s %f", a, b, d, c, s, r);
}
W tym przykładzie należy zwrócić uwagę na występujący jako komentarz znak '\n'. Wymusza on zmianę wiersza w miejscu, w którym wystąpił. Tak więc na ekranie powinniśmy uzyskać
23 -1 f a
Ala ma kotka 3.141500
Odnośnie formatowania liczb pozostały nam jeszcze do wykorzystania dwie środkowe części sekcji występujące jako w i t. Ich funkcjonowanie jest dokładnie takie samo jak specyfikacja :n:m występująca w Pascalu. W części w podajemy jaka jest minimalna szerokość pola wydruku a w części p (dotyczy tylko wyprowadzania wartości rzeczywistych) ile cyfr ma być wyprowadzonych po kropce.
Przykład
#include <stdio.h>
main()
{
printf("%6.2f", 3.121212);
}
Liczba cyfr po kropce w naszym przypadku zostanie zmniejszona do dwóch (tylko 12). Kropka zajmie dodatkowo jedno miejsce. Razem mamy już trzy znaki wyprowadzone i jeszcze trzy do wyprowadzenia (bo zadeklarowaliśmy minimalną szerokość pola wydruku na sześć znaków). Część całkowita tej liczby "zmieści się" na jednej pozycji. Tak więc mamy do zagospodarowania jeszcze dwa znaki. W tym przykładowym zapisie domyślnie zostaną dopisane dwie spacje na początku wydruku (domyślnie napis jest wyrównywany prawostronnie).
3.12
Można sobie postawić w tym momencie kilka dodatkowych pytań odnośnie tych dwóch części formatu wyprowadzania liczby. Pierwsze z nich brzmi następująco: co się stanie w przypadku, gdy część całkowita wyprowadzanej liczby zajmie więcej znaków niż to wynika z różnicy w - p - 1. Otóż w tym momencie nastąpi rozszerzenie pola wydruku tak, aby liczba została wyprowadzona poprawnie.
Przykład
#include <stdio.h>
main()
{
printf("%6.2f", 13453.121212);
}
Pole wydruku zostanie rozszerzone do 8 znaków. To rozszerzanie pola wydruku obowiązuje także przy wyprowadzaniu wartości innych typów niż rzeczywiste. Kolejne z pytań może dotyczyć kwestii czy zmniejszanie liczby cyfr znaczących po kropce można wykorzystać do zaokrąglania liczb rzeczywistych. Otóż w tym momencie należy dokładnie zapoznać się z opisem funkcji printf w konkretnej implementacji języka C ponieważ mogą być rozbieżności w interpretacji tej sytuacji przez różne kompilatory.
Sztywne określanie szerokości pola wydruku i liczby cyfr po kropce w pewnych sytuacjach (umieszczenie w kodzie programu liczb) jest bardzo niewygodne. Załóżmy że chcemy wydrukować w dwóch równych kolumnach ciąg liczb, których nie jesteśmy w stanie oszacować. W tym przypadku z pomocą śpieszy nam możliwość dynamicznego określania szerokości pola wydruku przez umieszczenie w formacie po znaku procent (%) gwiazdek (*). Użycie gwiazdki niesie za sobą konieczność umieszczenia na liście argumentów (przed tym argumentem) określenia liczbowego, ile znaków ma zostać drukowanych zamiast tej gwiazdki.
Przykład
#include <stdio.h>
main()
{
printf("%*.3f", 8, 13453.121212);
printf("%*.*f", 7, 3, 34.567);
}
Sekcja f zawiera w sobie znaczniki modyfikujące sposób wyświetlania utworzonego napisu. Mogą się tam znajdować następujące znaczniki:
- (minus) oznacza, że napis będzie wyrównany lewostronnie,
+ (plus) oznacza, że napis zostanie poprzedzony znakiem + lub - stosownie do wartości wyprowadzanego argumentu,
# oznacza wyprowadzanie specjalne.
Dla nas najważniejsza jest możliwość wyrównywania lewostronnego.
Pierwszy element tego zapisu (%) już został opisany. Stanowi on jak wiemy początek określania formatu wyprowadzanej wartości.
Pozostało nam już tylko ustalenie, co zwraca funkcja printf. Otóż zwraca ona liczbę wyprowadzonych znaków do standardowego pliku wyjściowego lub wartość EOF jeśli wystąpiłby błąd przy wyprowadzaniu.
Przykład
#include <stdio.h>
main()
{
printf("%d", printf("%6.2f", 13453.121212));
}
Wewnętrzna funkcja printf wypisze na ekranie następujący napis: 13453.12 a zewnętrzna dopisze na końcu liczbę 8.
puts
Funkcja ta wyprowadza do standardowego pliku wyjściowego jeden wiersz tekstu wraz ze zmianą wiersza. Składnia jest następująca:
int puts(char *s)
Funkcja zwraca wartość kodu ostatnio wyprowadzonego znaku czyli '\n' lub wartość EOF jeśli wystąpił błąd.
putchar
Wyprowadzenie do standardowego pliku wyjściowego jednego znaku wchodzącego jako argument funkcji. Funkcja zwraca wartość tego znaku lub EOF.
int putchar(int c)
Funkcje obsługujące ekran komputera
cprintf
Jest to bliźniacza funkcja do printf z jedną znaczącą różnicą. Otóż ta funkcja zamiast standardowego wyjścia obsługuje bezpośrednio ekran komputera. Ma to taką zaletę, że można na ekranie komputera w trybie tekstowym używać kolorów do wyprowadzania wartości argumentów. Prototyp tej funkcji znajduje się w pliku nagłówkowym conio.h. Jest to biblioteka rozszerzająca możliwości języka C wprowadzona przez firmę Borland.
Przykład
Wyprowadź na ekran wartości dwóch zmiennych a i b typu int w kolorze czerwonym na zielonym tle.
#include <conio.h>
main()
{
int a = 10, b = 23;
textcolor(RED);
textbackground(GREEN);
cprintf("%d %d", a, b);
}
cputs
Podobnie ja poprzednia funkcja cputs obsługuje ekran komputera wyprowadzając jeden wiersz tekstu, ale bez dostawiania na końcu znaku zmiany wiersza. Zwraca wartość ostatniego wyprowadzonego znaku.
putch
Jest to funkcjonalny odpowiednik funkcji putchar z obsługą ekranu komputera.
Funkcje obsługujące standardowe wejście komputera
scanf
Przejdźmy teraz do wprowadzania danych do programu. Podobnie jak poprzednio omówimy sobie najpierw funkcje obsługujące standardowe wejście komputera. Zaczniemy od funkcji scanf.
Przykład
Wprowadzić do dwóch zmiennych całkowitych a i b wartości z klawiatury. Fragment programu realizujący taką operację może wyglądać następująco:
#include <stdio.h>
main()
{
int a, b;
scanf("%d%d", &a, &b);
}
Opis zaczniemy od części formatującej. Zawiera ona umieszczone w cudzysłowie dwie sekwencje znaków (%d). Jak pewnie się domyślamy znak procent (%) rozpoczyna każdy format, a mała literka d oznacza, że liczba całkowita typu int będzie wprowadzona w postaci dziesiętnej. Przy wprowadzaniu należy liczby rozdzielić znakiem zmiany wiersza, spacji lub tabulacji (podobnie jak w Pascalu). W zapisie przed każdą zmienną mamy podany znak ampersand (&). Jego użycie wynika ze sposobu przekazywania do funkcji parametrów. Szerzej na ten temat będziemy mówić w rozdziale traktującym o funkcjach w języku C.
UWAGA! W tym momencie należy przyjąć, że przed każdą wypełnianą w ten sposób zmienną należy postawić znak &, za wyjątkiem wczytywania z pliku wejściowego stdin łańcucha znaków. Przejdźmy teraz do formalnego opisu działania funkcji scanf. Jej składnia jest następująca:
int scanf(char *format, ...);
Część formatująca zawiera trzy części:
% f w t
Pierwsza część czyli f zawiera znacznik, który w ramach tego modułu nie będzie nas interesował. Analizę formatowania rozpoczniemy od końca. Część t zawiera jednoliterowe kody konwersji, których opisy znajdują się w tabeli.
Kod konwersji |
Typ danych |
Opis |
d |
int |
liczba typu int w zapisie dziesiętnym |
D |
long |
liczba typu long w zapisie dziesiętnym |
u |
unsigned |
liczba typu unsigned w zapisie dziesiętnym (bez znaku) |
U |
unsigned long |
liczba typu unsigned long w zapisie dziesiętnym (bez znaku) |
x |
int |
liczba typu int w zapisie heksadecymalnym |
X |
long |
liczba typu long w zapisie heksadecymalnym |
f |
float |
liczba rzeczywista typu float |
c |
char |
znak |
s |
char * |
łańcuch znaków |
Zwraca uwagę fakt, że nie ma kodu konwersji dla liczb typu double. W tym przypadku należy przed kodem f dopisać małą literę l, która pełni w tym momencie rolę kwalifikatora.
Przykład
#include <stdio.h>
main()
{
double a;
float b;
char c;
char str[10];
scanf("%lf%f", &a, &b);
scanf("%c%s", &c, str);
}
Przejdźmy teraz do środkowej części sekcji odpowiedzialnej za odpowiednie wczytanie wartości. Otóż można wprowadzić w tym miejscu podobnie jak dla funkcji printf liczbę całkowitą z tym, że jej interpretacja jest diametralnie różna od tego do czego byliśmy przyzwyczajeni w przypadku funkcji printf. Określa ona mianowicie maksymalną szerokość pola wczytywania. Wszystkie znaki znajdujące się poza tym obszarem pozostaną w standardowym pliku wejściowym.
Przykład
#include <stdio.h>
main()
{
int a;
float b;
char c;
char str[10];
scanf("%2d%5f%c%9s", &a, &b, &c, str);
}
Z klawiatury wprowadzono następujący ciąg znaków:
2 34.58 a ala
Pod argumenty funkcji scanf zostaną podstawione następujące wartości:
a = 2; (w tym przypadku pole wczytywania zostanie skrócone do jednego znaku gdyż funkcja scanf napotkała standardowy znak odstępu - spację)
b = 34.58;
c = 'a';
str = "Ala" (w tym przypadku nastąpiło zakończenie wczytywania po naciśnięciu klawisza Enter).
Ciekawa sytuacja występuje w przypadku, gdy z klawiatury zostanie wprowadzony następujący napis:
2 34.587 a ala
Początek przypisywania wartości argumentom funkcji jest dokładnie taki sam i daje w wyniku a = 2. Problemy pojawiają się przy wczytywaniu wartości do drugiego argumentu. W formacie w funkcji scanf określiliśmy, że maksymalna szerokość wczytywania wynosi 5 znaków, gdy tymczasem spacja rozdzielająca wprowadzone wartości znajduje się na siódmej pozycji. W tym przypadku do argumentu b zostanie wczytanych pierwszych 5 znaków czyli 34.58 a ostatnia cyfra 7 pozostanie w buforze skojarzonym ze standardowym wejściem. Teraz do argumentu c zostanie wpisany znak '7'. Ostatni argument, czyli łańcuch znaków zostanie wypełniony znakiem 'a'. Reszta znaków pozostaje w buforze i przy najbliższej operacji wejścia zostanie stamtąd pobrana.
Jak widać drobna zmiana wpisanego łańcucha znaków powoduje, że argumenty zostają wypełnione inaczej niż programista sobie tego życzył.
Przy wprowadzaniu łańcucha znaków bardzo ważne jest, aby maksymalna liczba znaków jaka może zostać wczytana była o jeden mniejsza od rozmiaru łańcucha. Dlaczego tak jest zostanie wyjaśnione w rozdziale poświęconym tablicom i łańcuchom. Założenie, że spacja kończy wczytywanie argumentu czyni niestety niemożliwym wczytanie do argumentu typu char * łańcucha zawierającego np. zdanie. W tym celu został wprowadzony specjalny format do wczytywania łańcuchów znaków. Może mieć on dwie postacie:
[znaki]
lub
[^znaki]
W pierwszej formie dowolny znak nie występujący na liście znaków kończy wczytywanie łańcucha, a w drugiej dowolny znak z występujących na liście. Możliwe jest łączenie znaków w przedziały za pomocą łącznika -.
Przykład
#include <stdio.h>
main()
{
char str[10];
scanf("%[ab2-8A-H]", str);
}
Lista znaków, które są dopuszczalne w łańcuchu zawiera małe litery 'a' i 'b', cyfry od 2 do 8 oraz duże litery od 'A' do 'H' włącznie. Dowolny znak spoza tej listy kończy wczytywanie np., mała litera 'c'.
Przykład
#include <stdio.h>
main()
{
char str[10];
scanf("%[^\n]", str);
}
W tym przypadku znakiem kończącym wczytywanie jest znak zmiany wiersza, a więc w łańcuchu str będzie przechowywany cały wprowadzony wiersz. Aby dokończyć opis działania funkcji scanf trzeba podać, że zwraca ona liczbę poprawnie wypełnionych argumentów.
W dalszej części tego rozdziału zajmiemy się specjalizowanymi funkcjami do wczytywania ze standardowego wejścia pojedynczych znaków i łańcuchów znaków.
gets
Zadaniem tej funkcji jest wczytanie ze standardowego wejścia jednego łańcucha znaków. Wprowadzony znak końca wiersza '\n' zostaje zamieniony na znak końca łańcucha '\0'. Składnia funkcji jest następująca:
char *gets(char *s);
Funkcja zwraca wczytany łańcuch znaków lub wskazanie puste NULL jeśli napotka koniec pliku lub gdy wystąpi błąd przy wprowadzaniu.
getchar
Funkcja getchar posiada następującą składnię:
int getchar();
Zwraca ona wartość kodu ostatnio wprowadzonego znaku. Zwrócenie tej wartości odbywa się po zakończeniu wprowadzania znaków przez naciśnięcie klawisza Enter.
Przykład
Wykonać kopiowanie standardowego wejścia na standardowe wyjście komputera, czyli przy ustawieniach standardowych wszystkie znaki, które wpiszemy z klawiatury powinny pokazać się na ekranie.
#include<stdio.h>
main()
{
int a;
while((a = getchar())!= EOF)
putchar(a);
}
Jak już wiemy funkcja getchar zwraca wartość kodu ostatnio wprowadzonego znaku oraz to, że rozpoczyna analizę po wciśnięciu klawisza Enter. Załóżmy, że użytkownik podanego wyżej programu wprowadził napis aba i nacisnął Enter. Na ekranie widać napis aba i kursor znajduje się w następnym wierszu. W tym momencie funkcja getchar zwraca nam wartość pierwszego wprowadzonego znaku (i usuwa ten znak z bufora) czyli numer pozycji w tablicy kodów ASCII, na której znajduje się mała litera a czyli wartość 97. Jest to wartość różna od EOF zatem wchodzimy do pętli i funkcja putchar drukuje na ekranie literę a. Następuje teraz ponowne wykonanie funkcji getchar. Napotyka ona na znaki znajdujące się w buforze standardowego wejścia (wszystkie standardowe pliki są buforowane) i na występujący na końcu bufora znak zmiany wiersza '\n'. Funkcja zwraca nam wartość kolejnego znaku czyli 98. Na ekranie pokazuje się mała litera b. Ten ciąg poleceń wykonuje się do momentu aż w buforze nie będzie żadnego znaku. W tym momencie funkcja getchar zaczyna oczekiwać na wprowadzenie następnych znaków. Zakończenie kopiowania stdin na stdout następuje w momencie wprowadzenia znaku końca pliku czyli Ctrl+Z. Na ekranie mamy kolejno pod sobą dwa takie same napisy. Jednym z nich jest wprowadzony napis (bo funkcja getchar działa z echem tzn. każde naciśnięcie klawisza powoduje pokazanie na ekranie wprowadzonego znaku), a drugi stanowi wynik działania funkcji putchar.
Funkcje obsługujące klawiaturę
Omówimy sobie tylko jedną taką funkcję, która ma bardzo cenną właściwość tj. wczytywanie jednego znaku z klawiatury bez echa, czyli pokazywania znaku na ekranie. Kolejną różnicą w stosunku do getchar jest to, że funkcja nie czeka na naciśnięcie klawisza Enter, tylko od razu po wciśnięciu dowolnego klawisza alfanumerycznego zwraca wartość jego kodu.
Przykład
#include <stdio.h>
#include <conio.h>
main()
{
int a;
while((a = getch()) != '0')
putch(a);
}
Program ten pokazuje na ekranie wszystkie naciśnięte znaki alfanumeryczne, aż do momentu wprowadzenia cyfry 0, która nie jest już pokazywana.
Tablice
Deklaracja i inicjowanie tablic
Tablica jako pewna struktura danych, podobnie jak w Pascalu składa się z elementów tego samego typu. Spróbujmy zadeklarować teraz naszą pierwszą tablicę w języku C:
int a[10];
Interpretacja tego wiersza jest następująca: zadeklarowano 10-elementową tablicę o elementach typu int. Liczba 10 umieszczona między nawiasami reprezentuje właśnie liczbę elementów tablicy. Liczba elementów tablicy musi być znana już na etapie kompilacji programu. Tak więc nie ma możliwości deklarowania w ten sposób dynamicznych tablic. Cechą charakterystyczną tablic w języku C jest to, że numeracja elementów zaczyna się od 0 tj., pierwszy element tablicy ma indeks 0, drugi element ma indeks 1, itd. Jak później zobaczymy bardzo ułatwia to odwoływanie się do elementów tablic.
W Pascalu bardzo uciążliwe było wypełnianie zmiennych wartościami początkowymi. W języku C rozwiązano ten problem przez umożliwienie podawania w deklaracji dowolnej zmiennej, a więc także tablicy wartości, jaką ta zmienna ma zostać zainicjowana. Przykład deklaracji tablicy z jednoczesnym przypisaniem wartości jej elementom wygląda następująco:
int a[5] = {10, 12, 14, 16, 18};
Jak widać po znaku '=' w nawiasach klamrowych podawane są wartości kolejnych elementów tablicy. Ciekawie wygląda następująca deklaracja tablicy:
int a[5] = {10, 12, 14};
W tym przypadku podano wartości tylko trzech elementów. Dwa ostatnie elementy zostaną wyzerowane niezależnie od globalnej, czy lokalnej deklaracji takiej zmiennej. Najszybszy sposób wyzerowania całej tablicy polega na przypisaniu tylko pierwszemu elementowi wartości 0, a pozostałe zostaną automatycznie wyzerowane:
int a[5] = {0};
Przy deklaracji tablic można nie podawać jej rozmiarów w nawiasach kwadratowych (tzw. niejawna deklaracja rozmiaru tablicy), ale przy deklaracji zmiennej tablicowej musi wystąpić część związana z przypisaniem wartości elementom tablicy. W tym przypadku rozmiar tablicy będzie równy liczbie wypełnianych w jej deklaracji elementów:
int a[] = {0, 10, 4, 5};
Tak zadeklarowana tablica będzie miała cztery elementy.
Specyfiką języka C jest to, że można tablicę zainicjować inną tablicą, której rozmiar został podany jawnie.
int a[3] = {1, 2, 3};
int b[] = a;
int c[4] = b;
Bardzo ważne jest to, że rozmiary tablic muszą się zgadzać. W ostatnim wierszu mamy do czynienia z ciekawym przypadkiem inicjowania "większej tablicy inną mniejszą tablicą". Kompilator nie zgłosi oczywiście żadnego błędu i ostatni element tablicy c będzie wypełniony zerem.
Tablica zajmuje ciągły obszar pamięci i jej elementy umieszczone są jeden za drugim. Rozmiar tablicy można sprawdzić za pomocą operatora sizeof.
int a[3];
printf("%d", sizeof(a));
Na ekranie powinniśmy otrzymać liczbę 6, gdyż tablica ma trzy elementy typu int o rozmiarze 2 bajty każdy.
Do elementów tablic odwołujemy się za pomocą dwuargumentowego operatora indeksowania []. Jednym z jego argumentów jest nazwa tablicy a drugim całkowitoliczbowy indeks elementu tablicy określający, ile elementów dzieli od początku tablicy, stąd pierwszy element ma indeks 0. Specyfika języka C sprawia, że nic nie chroni programisty przed przekroczeniem zakresu tablic, np.:
main()
{
int a[3];
a[3] = 123;
}
W tym przypadku zapisujemy do tablicy na pozycję znajdującą się poza zarezerwowanym obszarem pamięci. Efekty takiego działania mogą być bardzo różne począwszy od tego, że nagle inne zmienne przyjmą nieoczekiwane wartości, aż do całkowitego zawieszenia się komputera (dotyczy programowania w systemie DOS).
Tablice wielowymiarowe
Deklaracja tablicy dwuwymiarowej wygląda następująco:
int a[5][4];
W tym przypadku a jest 5-cio elementową jednowymiarową tablicą o elementach w postaci tablic 4-ro elementowych typu int. Można to zinterpretować jako tablicę tablic. Dostęp do elementów jest podobny jak w przypadku tablic jednowymiarowych.
#include <stdio.h>
main()
{
int a[5][4];
a[3][1] = 123;
printf("%d", a[2][1]);
}
Inicjowanie tablic wartościami początkowymi może wyglądać następująco:
int a[2][3] = {{1, 3, 4}, {2, 3, 4}};
int b[2][3] = {{1}, {2, 3, 4}};
int c[2][3] = {1, 3, 4, 2, 3, 4};
int d[2][3] = {1, 3, 4, 2};
int e[2][3] = {0};
int f[][3] = {{1, 3, 4}, {2, 3, 4}};
Tablica dwuwymiarowa jest pamiętana wierszami. Tablice a, c i f są wypełnione takimi samymi wartościami. Brak nawiasów klamrowych wewnętrznych w przypadku tablicy c powoduje, że kompilator pierwsze trzy liczby przypisze do pierwszego wiersza, trzy następne do drugiego, itd. W deklaracji tablicy f brak określenia liczby wierszy - kompilator sam "zorientuje się", ile ich jest. Jest chyba oczywiste, że w tym przypadku nie można opuścić wewnętrznych nawiasów. Tablica e ma wyzerowane wszystkie elementy.
Wskaźniki
Wskaźnik jest to inaczej mówiąc adres jakiegoś obszaru pamięci. Mając zmienną typu "wskaźnik na ..." można odwoływać się do wskazywanego przez nią obiektu (obszaru pamięci) za pomocą jednoargumentowego operatora (*) zwanego operatorem wyłuskania. Bardzo ważne jest określenie na jaki typ danych wskaźnik ma wskazywać. Przykładowe deklaracje wskaźników wyglądają następująco:
Przykład
int *a; // wskazanie na typ int
char *b; // wskazanie na typ char - łańcuch znaków
float *c[20]; // tablica 20 wskaźników na typ float
float (*d)[20]; // wskaźnik na 20-elementową tablicę typu float
Ponieważ zmienna wskaźnikowa sama zajmuje pewien obszar pamięci, do którego może zostać zapisany adres innego elementu, to nic nie stoi na przeszkodzie, aby deklarować wskaźniki na wskaźniki.
Przykład
char **p;
int ***q;
Wskaźnik *p jest wskaźnikiem na typ char, zatem p jest wskaźnikiem na typ char *. Wskaźnik **q jest wskaźnikiem na typ int, zatem analizując kolejne przejścia dochodzimy do wniosku, że q jest wskaźnikiem na wskaźnik na wskaźnik na typ int. Wielkość obszaru pamięci zajmowanej przez jeden wskaźnik wynosi 2 lub 4 bajty (w systemie DOS) zależnie od modelu pamięci (wskaźnik bliski near lub daleki far). Wartość wskaźnika można ustalić na dwa sposoby:
przypisując mu adres utworzonego wcześniej jakiegoś obszaru pamięci za pomocą jednoargumentowego operatora pobrania adresu,
przypisując mu wartość innego wskaźnika za pomocą operatora przypisania.
W języku C++ wprowadzono nowy typ ułatwiający operowanie na wskaźnikach. Jest to typ adresowy zwany inaczej referencją. Zapis zmiennej adresowej wygląda następująco:
int a;
int &b = a;
Drugi wiersz należy odczytać następująco: "zmienna b jest referencją do typu int i adres tej zmiennej jest dokładnie taki sam jak adres zmiennej a". Inaczej mówiąc ten sam obszar pamięci utworzony przy deklaracji zmiennej a jest nazywany i adresowany teraz przez dwie zmienne: a i b. Zajętość pamięci w tym momencie jest taka sama jak w przypadku deklaracji tylko zmiennej a. Typ adresowy przydaje się szczególnie przy tworzeniu własnych funkcji oraz w programowaniu obiektowym.
Przykład
int x, y;
int *p, *q;
int &z = x; // referencja z pokazuje na x
p = &x; // wskaźnikowi p przypisano adres elementu x
q = p; // q także wskazuje na element x
*p = 7; // do x wpisano wartość 7
y = *p + *q; // do y wpisano wartość x + x
q = &y; // q pokazuje teraz na y
*p += *q; // x = x + y
z = 123; // x = 123
W obu przykładach całkowicie poprawne jest wypełnianie obszarów pamięci wskazywanych przez wskaźniki, gdyż operują one na obszarach pamięci przydzielonych podczas deklarowania zmiennych całkowitych x i y. Zmiennej wskaźnikowej można również nadać wartość NULL, co oznacza, że dana zmienna na nic nie wskazuje.
Deklaracja wskaźnika przydziela pamięć tylko na przechowanie samego wskaźnika, a nie obszaru pamięci, na jaki on wskazuje. Następujący zapis jest bardzo poważnym błędem, który popełniają początkujący programiści w języku C:
Przykład
char *c;
c = "Ala ma kotka";
W tym przypadku w pierwszym wierszu następuje tylko utworzenie zmiennej wskaźnikowej c. Zawartość tej zmiennej czyli inaczej mówiąc przechowywany tam adres jest uzależniony od miejsca gdzie ta deklaracja wystąpiła tj. czy była to deklaracja zmiennej globalnej czy lokalnej. W drugim wierszu następuje wpisanie do zmiennej wskaźnikowej adresu nie zarezerwowanego obszaru pamięci. Dalsze wykonywanie się tego programu może spowodować różne nieprzewidziane skutki.
Drugi zapis jest natomiast całkowicie poprawny:
char *c = "Ala ma kotka";
W tym przypadku występuje deklaracja wskaźnika na typ char, przydzielenie takiego obszaru pamięci, aby zmieścił się w nim łańcuch znaków "Ala ma kotka", a następnie ten przydzielony obszar pamięci jest wypełniany tym łańcuchem.
Arytmetyka na wskaźnikach
Na wskaźnikach wolno wykonać następujące operacje:
dodawać do wskaźnika liczbę całkowitą,
obliczać różnicę wskaźników (nie sumę !!!),
porównywać wskaźnik z zerem (NULL),
porównywać wskaźniki między sobą, jeśli pokazują na ten sam typ,
przekształcać wskaźniki (konwersje wskaźników).
Dodanie do wskaźnika liczby całkowitej n oznacza, że wskaźnik ten zostanie przesunięty o n * sizeof(type) bajtów w kierunku wzrastających lub zmniejszających się adresów. Bardzo ważny jest zatem typ, na jaki wskazuje dany wskaźnik. Przemieszczenie adresu odbywa się w kwantach. Jeśli mamy następującą deklarację wskaźnika
int *p;
to wyrażenie (p + n) wskazuje obszar odległy od n * sizeof(int) bajtów od adresu zawartego w p.
Załóżmy, że w ciągłym obszarze pamięci wskazywanym przez p mamy zapisane następujące liczby 1, 2, 3, 4. Biorąc to pod uwagę mamy:
int *p, x;
int *t = p;
x = *(p + 1); // x = 2;
p++; // p = p + 1;
x = *p; // x = 2
*p = 10; // x = 10
x = *(t + 1); // x = 10
x = *p + (p + 1); // x= 10 + 3 = 13
Jednoargumentowe operatory * i & mają taki sam wysoki priorytet jak operatory (++) i (--) oraz prawostronne wiązanie. Oznacza to, że:
*(p + 1) jest obszarem pamięci wskazywanym przez p + 1,
*p + 1do zawartości obszaru wskazywanego przez p dodano 1,
*++p najpierw modyfikacja wskaźnika (bo wiązanie prawe operatora (++)), a następnie odwołanie do obszaru pamięci, na który wskazuje teraz zmienna p,
*p++ wartość wskaźnika po wykorzystaniu jest zwiększana o jeden (wskazuje na następny element),
++*p zwiększanie o 1 wartości w obszarze wskazywanym przez p.
Przykład main()
{
int a[] = {10, 20, 30, 40};
int *p = a; // p wskazuje na tablicę a[]
int x, y, z;
x = *++p + 1;
/*
najpierw przesunięcie wskaźnika na element o wartości 20,
pobranie tej zawartości i dodanie do niej 1
*/
y = *p++ + 1;
/*
p wskazuje na 20, do y skopiowana jest wartość 21, a następnie
jest przesunięty wskaźnik na trzeci element tablicy (30)
*/
z = *p; // do z skopiowana jest wartość 30
}
Między tablicami i wskaźnikami istnieje bardzo ścisły związek. Nazwa tablicy jest traktowana jako wskaźnik na jej początek.
Przykład
#include <stdio.h>
main()
{
int a[] = {1, 2, 3};
int *p = a + 1;
int x = *p + 2;
printf("%d %d %d", *a, *p, x); // 1, 3, 4
}
UWAGA !
a[i] = *(a + i);
Teraz jest już jasne dlaczego elementy tablicy są indeksowane od zera.
Przydzielanie pamięci dla wskaźników
Do przydzielania pamięci dla wskaźników służą następujące dwie funkcje, których prototypy znajdują się w bibliotece stdlib.h:
void *malloc(size_t size)
Przydzielenie obszaru pamięci o rozmiarze size bajtów. Funkcja zwraca wskazanie tego przydzielonego obszaru lub wskazanie puste NULL, jeśli nie można przydzielić pamięci.
void *calloc(size_t n, size_t size)
Przydzielenie wyzerowanego obszaru pamięci, w którym można pomieścić n elementów o rozmiarze size każdy. Funkcja zwraca wskazanie tego obszaru pamięci lub wskazanie puste NULL, jeśli nie można przydzielić pamięci. Do zwalniania przydzielonej uprzednio pamięci służy funkcja free, której składnia jest następująca:
void free(void *ptr)
Parametr funkcji ptr wskazuje na ten przydzielony obszar.
Przykład
#include<stdlib.h>
main()
{
int *c;
c = (int *)malloc(sizeof(int));
*c = 8;
/*
dopiero po przydzieleniu pamięci można wypełnić pole
wskazywane przez zmienną c
*/
}
Przykład
#include<stdlib.h>
main()
{
int i, (*x)[5] = (int (*)[5])calloc(5, sizeof(int));
for(i = 0; i <5; i++)
(*x)[i] = i;
}
Łańcuchy znaków
W języku C nie ma oddzielnego typu odpowiedzialnego za reprezentację łańcuchów znakowych. Łańcuch znaków jest po prostu ciągiem znaków, na którego końcu znajduje się znak '\0'. Tak więc ciągły obszar n znaków traktowanych jako łańcuch musi mieć zarezerwowane (n + 1) bajtów (aby pomieścić także ten standardowy koniec łańcucha). Łańcuch można inicjalizować przy deklaracjach zmiennych, np.:
char c[20] = "Ala";
deklaracja zmiennej c, która jest tablicą o 20 elementach typu char z jednoczesnym wpisaniem tam łańcucha znaków "Ala" zakończonego znakiem '\0', pozostałe elementy tej tablicy mogą mieć różną wartość,
char d[20] = {'A', '1', 'a'};
deklaracja zmiennej d, która jest tablicą o 20 elementach typu char z jednoczesnym wpisaniem tam łańcucha znaków "Ala" zakończonego znakiem '\0', pozostałe elementy tej tablicy są zerowane,
char *e = "Ala";
deklaracja zmiennej wskaźnikowej na typ char, przydzielenie jej 4 bajtów pamięci i skopiowanie do nich łańcucha znaków "Ala" wraz ze znakiem końca łańcucha.
Standardowe funkcje biblioteczne
double atof(char *s)
Przekształcenie łańcucha znaków s w daną typu double.
int atoi(char *s)
Przekształcenie łańcucha znaków s w daną typu int.
char *itoa(int v, char *s, int r)
Przekształcenie liczby całkowitej wartość w łańcuch znaków wskazywany przez s. Przy konwersji stosowana jest podstawa r.
Przykład
#include <stdlib.h>
#include <stdio.h>
void main()
{
char *c = (char *)malloc(10);
printf("%s", itoa(123, c, 2)) ; // 01111011
}
char *gcvt(double v, int ndec, char *s)
Zamiana liczby rzeczywistej v na łańcuch zawierający ndec znaczących cyfr tej liczby. Wynik umieszczony jest w łańcuchu s.
int sprintf(char *s, const char *format, ...)
Działanie jest identyczne jak funkcji printf z tym, że operuje ona nie na standardowym pliku (strumieniu) wejściowym tylko na łańcuchu znaków s. Wszystkie znaki wejściowe są umieszczone w tym łańcuchu.
Przykład
#include <stdlib.h>
#include <stdio.h>
void main()
{
char *buf = (char *)malloc(12);
sprintf(buf, "ABC %d - - %c XY", 123, 'a');
/* buf = "ABC 123 - a XY" */
printf("%s", buf);
}
int sscanf(const char *s, const char *format, ...)
Działanie jest identyczne jak funkcja scanf z tym, że operuje ona nie na standardowym pliku (strumieniu) wejściowym tylko na łańcuchu znaków s.
char *strcat(char *dest, const char *src)
Do łańcucha dest jest dopisywany łańcuch src (dest musi zawierać odpowiednie miejsce!!!). Funkcja zwraca wskazanie łańcucha dest.
char *strchr(const char *s, int c)
Funkcja zwraca wskazanie pierwszego wystąpienia znaku o kodzie c w łańcuchu s.
char *strcpy(char *dest, const char *src)
Skopiowanie łańcucha znaków src do obszaru wskazanego przez dest.
int strcmp(const char *s1, const char *s2)
Porównywanie dwóch łańcuchów s1 i s2. Zwracane wartości są następujące:
s1 < s2 zwraca wartość ujemną,
s1 = s2 zwraca 0,
s1 > s2 zwraca wartość dodatnią.
size_t strlen(const char *s)
Zwraca długość łańcucha znaków s przy czym znak '\0' nie jest wliczany.
char *strncpy(char *dest, const char *src, size_t max)
Skopiowanie nie więcej niż max znaków z łańcucha src do łańcucha dest.
Przetwarzanie łańcuchów znaków
Łańcuch znakowy jak każdą tablicę można przeglądać i można zmieniać wartości jego elementów, np. dla tablicy znakowej:
char tab[20];
sprawdzić czy element łańcucha jest wybranym znakiem
if(tab[0] == 'D')
zapisać dowolny znak na dowolnej pozycji tego łańcucha
tab[4] = 'w';
tworzyć automatycznie łańcuchy znakowe
int i;
for(i = 0; i < 19; i++)
tab[i] = 'A' + i;
tab[19] = '\0';
W ostatnim przykładzie widać, że samodzielnie wypełnianie łańcucha powinno zawierać fazę poprawnego zakończenia tego łańcucha, tj. postawienie na końcu znaku '\0'.
Przykłady
Ustawienie łańcucha znaków w pozycji końcowej i usunięcie wszystkich spacji z jego końca.
char tab[20];
. . . // wypełnienie przykładowymi wartościami
char *p = tab;
while(*p++);
p--;
while(*--p == ' ');
*(p + 1) = '\0';
Inna wersja rozwiązania tego zadania.
while(*p)
p++
while(*--p = = ' ');
*(p + 1) = '\0';
Obliczanie długości tekstu (wersja tablicowa).
int i = 0;
while(tab[i])
i++;
printf("długość łańcucha = %d", i);
Obliczanie długości tekstu (wersja wskaźnikowa).
char *p = tab;
while(*p)
p++
printf("długość łańcucha = %d", p - tab);
Poszukiwanie pierwszego określonego znaku c w danym łańcuchu tab.
while(*s && *s != c)
s++;
Tablice wskaźników i wskaźniki na tablice
Poniżej zostały przedstawione podobieństwa i różnice między tablicami wskaźników, wskaźnikami na tablice i tablicami wielowymiarowymi. Ponadto przedstawione zostały niektóre operacje dostępu do poszczególnych elementów tablic.
Tablica wielowymiarowa. Funkcja gets() ma argument typu char *, zatem wymagane jest podstawienie t[i] lub &t[i][0].
#include <stdio.h>
void main()
{
char t[3][10];
int i;
for(i = 0; i < 3; i++)
gets(t[i]);
}
Tablica znakowa (wypełnianie i dostęp do poszczególnych jej elementów).
#include<string.h>
void main()
{
char s[20];
strcpy(s, "Ala");
*s = '2';
s[1] = 'q';
*(s + 1) = 'z';
}
Tablica łańcuchów znaków (deklaracja z jednoczesnym przydzieleniem pamięci).
#include <stdio.h>
#include <string.h>
main()
{
char t[3][80];
strcpy(*t, "Ala"); // t[0]
strcpy(*(t + 1), "Ola") ; // t[1]
strcpy(*(t + 2), "Basia") ; // t[2]
**t = 'q'; // t[0][0]
**(t + 1) = '0'; // t[1][0]
*(*(t + 2) + 1) = '1'; // t[2][1]
(*(t + 1))[2] = 'x';
/*
t[1][2] bo t[1] = *(t + 1) i należy skorzystać z nawiasów, aby zapewnić
właściwą kolejność działań
*/
}
Wskaźnik na tablicę 80 znakową.
#include <string.h>
#include <stdlib.h>
void main()
{
char (*p)[8] ;
p = (char(*)[8])malloc(8);
strcpy(*p, "Ola") ;
(*p)[1] = 'z';
free(p);
}
Wskaźnik na tablicę [3][15] o elementach typu char.
#include <string.h>
#include <stdlib.h>
main()
{
char (*q)[3][15];
q = (char (*)[3][15])calloc(3, 15 * sizeof(char));
strcpy((*q)[0], "Ola");
strcpy((*q)[1], "Ala");
strcpy((*q)[2], "Hela");
(*q)[2][1] = 'x';
free(q);
}
Tablica wskaźników. Zapis do tablicy wskaźników łańcuchowych wczytywanych z klawiatury z jednoczesnym przydzieleniem pamięci. Ten sposób wczytywania umożliwia ekonomiczne wykorzystanie dostępnej pamięci.
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
void main()
{
char *t[20], *buf;
int i;
buf = (char *)malloc(128);
for(i = 0; i < 20; i++){
gets(buf) ;
t[i] = (char *)malloc(strlen(buf) + 1);
strcpy(t[i], buf) ;
}
free(buf);
}
Konwersje wskaźnikowe
Wskaźniki muszą wskazywać na jakiś typ. Jeżeli dwa wskaźniki wskazują na dwa różne typy, to tych wskaźników nie można ze sobą porównywać, odejmować od siebie. Jest to błąd sygnalizowany przez kompilator. W takiej sytuacji trzeba jawnie użyć operatora konwersji. Ma on postać (type), np.:
int x, y;
int *px = &x;
char *pc, *pz;
pc = (char *)px;
pc = (char *)&y;
px = (int *)pc;
pz = (char *)malloc(128);
Szczególnym typem danych jest void. Niemożliwe jest deklarowanie zmiennych typu void, natomiast nic nie stoi na przeszkodzie aby zadeklarować wskaźnik na typ void, np.:
void *p;
co powoduje, że p staje się wskaźnikiem uniwersalnym (wskazującym obszar pamięci), któremu można przypisywać wskaźniki na dowolny typ. Na wskaźnikach na typ void nie wolno dokonywać żadnych operacji arytmetycznych (zwiększanie i zmniejszanie), gdyż o ile bajtów miałby się przesuwać wskaźnik? Można je natomiast porównywać ze sobą i z zerem (NULL).
Stałe wskaźniki i wskaźniki na stałe
Deklaracja wskaźników na stałe jest następująca:
const int pi = 3.14;
const int *a = pi;
const char *pc;
Oznacza to, że wszystkie próby modyfikacji obszarów wskazywanych przez te wskaźniki powinny wywoływać błąd kompilacji, gdyż modyfikacje stałych są zabronione. Możliwe jest natomiast w tym przypadku modyfikowanie wskaźników, np.:
const double p[] = {1.2, 3.4, 5.6};
const double *ptr = p;
printf("%f", *++ptr); // 3.4
(*ptr)++; // błąd kompilacji
Po zmodyfikowaniu wskazania ptr nie można już wykonać zmiany wartości na jaką wskazuje wskaźnik.
Od wskaźników na stałe należy odróżniać stałe wskazania. Stałe wskaźniki nie mogą być modyfikowane, ale gdy nie wskazują na stałą, to można za ich pomocą modyfikować wskazywany przez nie obszar pamięci.
double p[] = {1.2, 3.4, 5.6};
double *const ptr = p;
(*ptr)++; // p = {2.3, 3.4, 5.6}
*ptr++; // błąd
Deklaracja stałego wskaźnika na stałą wygląda następująco:
const double p[] = {1, 2, 3, 4};
const double *const ptr = p;
/*
musi być ta inicjalizacja !!!, gdyż w przeciwnym przypadku nie można byłoby
skorzystać już z tego wskaźnika
*/
Funkcje
Sposoby definiowania funkcji
W odróżnieniu od Pascala mamy do dyspozycji tylko jeden typ podprogramu - funkcje. Jak wiemy cechą charakterystyczną funkcji jest to, że zwraca ona wartość określonego typu. W języku C są dopuszczalne dwa sposoby definiowania funkcji. W obu tych wariantach przed nazwą funkcji występuje określenie typu zwracanej wartości przez funkcję.
definicja 1
type name(type1 arg1, type2 arg2,...)
{
}
definicja 2
type name(arg1, arg2,...)
type1 arg1;
type2 arg2;
...
{
}
Drugi sposób deklarowania funkcji (historycznie starszy, który powoli wychodzi z użycia) pozwala na składanie definiowanych argumentów funkcji.
Przykład
int fun1(int a, int b, double c)
{
...
}
int fun2(a, b, c)
int a, b;
double c;
{
...
}
Funkcje można umieszczać w programie na dwa sposoby:
deklaracja funkcji z jej ciałem przed pierwszym odwołaniem do niej (własne funkcje deklarowane są przed funkcją main()),
#include ....
int fun1(int a, double b)
{
...
}
float fun2(void *ptr)
{
fun1(2, 4);
}
void main()
{
void *p;
fun1(10,1.23);
fun2(p);
...
}
na początku bloku następuje deklaracja prototypu funkcji (nagłówek funkcji z określeniem typu zwracanej wartości oraz wszystkich typów argumentów), a ciało funkcji występuje potem (prototypy funkcji na początku programu, a ich ciała za main()),
#include ....
int fun1(int a, double b);
float fun2(void *ptr);
void main()
{
void *p;
fun1(10, 1.23);
fun2(p);
...
}
int fun1(int a, double b)
{
...
}
float fun2(void *ptr)
{
...
}
W języku C argumenty do funkcji można przekazywać przez wartość, wskazanie i przez referencję.
Przekazywanie argumentów przez wartość
W tym sposobie przekazywania następuje fizyczne skopiowanie argumentów aktualnych w miejsce argumentów formalnych. Z tego względu przekazywanie argumentów przez wartość powoduje, że dokonana zmiana wartości tego argumentu wewnątrz w funkcji nie zostanie wyprowadzona na zewnątrz.
Przykład
(zamiana dwóch zmiennych - przekazywanie argumentów przez wartość)
#include <stdio.h>
void swap(int a, int b)
{
int z;
z = a;
a = b;
b = z;
}
void main()
{
int a = 10, b = 20;
swap(a, b);
printf("%d %d", a, b); //10 20
}
Nastąpiło wydrukowanie niezmienionych wartości zmiennych a i b, ponieważ parametry do funkcji są przekazywane przez wartość.
Przekazywanie argumentów przez wskazanie
Drugi sposób przekazywania argumentów do funkcji polega na przekazaniu do funkcji wskaźników, czyli adresów obszarów pamięci przechowujących odpowiednie wartości. Podobnie jak przy przekazywaniu przez wartość następuje fizyczne skopiowanie adresów tych obszarów i po zakończeniu działania funkcji odtworzenie starych adresów. Jak widać z tego opisu istnieje możliwość zmiany w funkcji zawartości obszaru przekazanego jako wskaźnik.
Przykład
(zamiana dwóch zmiennych - przekazywanie argumentów przez wskazanie)
#include <stdio.h>
#include <stdlib.h>
void swap(int *a, int *b)
{
int z = *a;
*a = *b;
*b = z;
}
void main()
{
int a = 10, *b;
b = (int *)malloc(sizeof(int));
*b = 20;
swap(&a, b);
printf("%d %d", a, *b); // 20 10
}
Przykład
(wyprowadzenie nowo przydzielonego obszaru pamięci)
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
void fun(char *d)
{
d = (char *)malloc(20);
strcpy(d, "Ola");
}
void main()
{
char *c;
fun(c);
printf("%s", c); // nie zostanie wyprowadzony łańcuch "Ola"
}
W tym przykładzie zadeklarowany w funkcji main wskaźnik c ma przypadkową wartość, czyli pokazuje na przypadkowy obszar pamięci. W nagłówku funkcji następuje skopiowanie adresu przekazanego przez wskaźnik c do wskaźnika d, którym będziemy się później posługiwać. Ten wskaźnik po wykonaniu funkcji malloc zmieni się i będzie pokazywać na zarezerwowany 20-bajtowy obszar pamięci. Do tego obszaru zostanie później skopiowany łańcuch znaków "Ola". Niestety przy zakończeniu funkcji zostanie odtworzone stare wskazanie i w funkcji printf zostanie wydrukowany przypadkowy łańcuch znaków. Co więcej nie można się już dostać do przydzielonego w funkcji i ciągle istniejącego obszaru o rozmiarze 20 znaków.
Aby ta funkcja dobrze działała należy zwrócić adres nowego obszaru pamięci przez nazwę funkcji.
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
char * fun(char *d)
{
d = (char *)malloc(20);
strcpy(d, "Ola");
return d;
}
void main()
{
char *c;
printf("%s", fun(c)); // tym razem zostanie wyprowadzony łańcuch "Ola"
}
Wadą tego podejścia jest to, że nie można zwrócić w ten sposób więcej niż jednej wartości. Oczywiście przy drobnej gimnastyce umysłowej da się obejść to ograniczenie, ale uzyskane rozwiązanie nie jest eleganckie. W języku C++ zaproponowano zupełnie nowe podejście do tego problemu.
Przekazywanie argumentów przez referencję (wprowadzone w C++)
To, że dany argument jest przekazywany przez referencję, ustala się przez poprzedzenie tego argumentu operatorem adresu & (przekazywanie odbywa się bez kopiowania wartości argumentu). Ten sposób przekazywania argumentów umożliwia wyprowadzenie na zewnątrz adresu nowopowstałego obszaru pamięci.
Przykład
(wyprowadzenie adresu nowego obszaru pamięci)
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
void fun(char *&d) // przekazywanie wskaźnika przez referencję
{
d = (char *)malloc(20);
strcpy(d, "Ola");
}
void main()
{
char *c;
fun(c);
printf("%s", c); // napis "Ola"
}
Przypomnijmy sobie deklarację referencji do typu int, wprowadzonej w rozdziale poświęconym wskaźnikom. Mieliśmy tam taką deklarację:
int z = 4;
int &x = z;
Powiedzieliśmy wówczas, że zmienne z i x dotyczą tego samego obszaru pamięci. Przeanalizujmy zapis parametrów wywołania i parametrów formalnych funkcji. Mamy:
char *c;
char *&d = c;
Tak więc wskaźniki c i d dotyczą tej samej zmiennej wskazującej ten obszar pamięci. Zmiana wskaźnika d zostanie natychmiast dostrzeżona jako zmiana wskaźnika c, a właśnie oto nam przecież chodzi.
Przykład
(zamiana dwóch zmiennych - przekazywanie argumentów przez referencję)
#include <stdio.h>
void swap(int &a, int &b)
{
int z;
z = a;
a = b;
b = z;
}
void main()
{
int a = 10, b = 20;
swap(a, b);
printf("%d %d", a, b); // 20 10
}
Funkcje mogą także zwracać referencję do zmiennej. Ważne jest to, aby zwracana referencja dotyczyła obiektu, który będzie znany funkcji wywołującej (będzie istnieć po zakończeniu tej funkcji). Ogólnie mówiąc funkcja może zwrócić referencję tylko do tego argumentu, który jest przekazany do funkcji przez wskazanie lub referencję.
Przykład
#include <stdlib.h>
int & min(int *a, int *b) // *a i *b są znane funkcji main()
{
if(*a > *b)
return *a;
else
return *b;
}
int & min1(int &a, int &b) // a i b są znane w funkcji main()
{
if(a > b)
return a;
else
return b;
}
void main()
{
int a = 2, b = 4;
int *c = (int*)malloc(sizeof(int));
int *d = (int*)malloc(sizeof(int));
*c = 3; *d = 4;
min1(a, b) = 10;
min(c, d) = 12;
}
Proszę zwrócić uwagę na bardzo dziwny na pierwszy rzut oka zapis - funkcji przypisywana jest wartość. Tak naprawdę zadaniem obu tych funkcji jest zwrócenie adresu jednego z dwóch argumentów. Mamy więc:
a = 10 lub b = 10
*c = 12 lub *d = 12
Wskaźniki na funkcje
Wskaźniki na funkcje mają nieco odmienne właściwości niż wskaźniki na dane. Wskaźnik na funkcję jest adresem początku jej kodu. Wskaźniki na funkcję można:
przekazywać innym funkcjom jako argumenty,
zwracać jako wynik funkcji,
porównywać z zerem (NULL),
poddawać operacji wyłuskania.
Jak widać nie wolno wykonywać żadnych operacji arytmetycznych na wskaźnikach na funkcje.
Ogólna deklaracja wskaźnika na funkcje jest następująca:
type (*name) (arguments)
Przykład
void (*f) (void);
/*
wskaźnik na funkcje bezparametrową zwracającą typ void
*/
float (*g) (int, int);
/*
wskaźnik na funkcję o dwóch parametrach typu int,
zwracająca wartość typu float
*/
void (*h) (float &);
/*
wskaźnik na funkcję o parametrze typu referencja do typu
float, zwracająca typ void
*/
Interpretacja tego zapisu jest następująca: wyłuskujemy obiekt wskazywany przez f i wobec niego stosujemy operator wywołania funkcji. Trzeba zwrócić uwagę na konieczność stosowania nawiasów okrągłych przy operatorze wyłuskania *. Gdybyśmy napisali:
*f();
oznaczałoby to, że wywołujemy funkcję bezparametrową f, która zwraca jakiś wskaźnik i wobec tego wskaźnika używamy operatora wyłuskania, a przecież nie o to nam chodzi.
Przykład
#include <stdio.h>
int suma(int a, int b)
{
return a + b;
}
int mnoz(int a, int b)
{
return a * b;
}
main()
{
int (*f)(int, int); // wskaźnik na funkcje
int (*g[2])(int, int) = { suma, mnoz };
/* tablica wskaźników na funkcje */
int z = (*g[1])(1, 3);
/* wywołanie funkcji mnożenia argumentów */
f = suma;
printf("%" , f (1, 2));
/* wywołanie funkcji dodawania argumentów */
}
Funkcje otwarte
Funkcjami otwartymi nazywamy takie funkcje, których kod może być wpisany przez kompilator w każdorazowym miejscu ich wywołania. Aby funkcja była otwarta musi zostać zadeklarowana jako inline. To słowo kluczowe musi wystąpić przed właściwą deklaracją funkcji.
Przykład
#include <stdio.h>
inline int Add(int a, int b)
{
return a+b;
}
void main()
{
int c = Add(2, 3);
int d = Add(3, 4);
printf("% %d", c, d);
}
W ten sposób ogranicza się straty szybkości działania związane z częstym wywoływaniem funkcji (generowanie adresu, pod który należy skoczyć). W tych funkcjach kompilator zapewnia ochronę typów argumentów. Jest to ich zasadnicza przewaga nad makrodefinicjami, które są omówione w rozdziale poświęconym preprocesorowi.
Kompilator podejmuje decyzję czy daną funkcję poprzedzoną słowem inline należy włączyć w tekst programu. Jeśli taka funkcja otwarta zawiera instrukcje iteracyjne for, do, while, instrukcję wyboru switch oraz instrukcję skoku goto, to nie zostanie ona włączona w tekst programu. Takie włączenie ciała funkcji otwartej w tekst programu powoduje wzrost jego długości i zwykle wzrost szybkości działania.
W tym przypadku kompilator zamiast generować dwa skoki pod adres funkcji Add wywołanej z dwoma różnymi zestawami argumentów włączy do kodu funkcji main dwa rozkazy dodawania dwóch liczb. Raz będzie to 2 + 3 a drugi 3 + 4. Deklaracja funkcji jako inline powoduje, że z jednej strony mamy podprogram, który zapewnia lepszą orientację w całości i wygodę programowania, a z drugiej maksymalną szybkość działania. Dzieje się to w pewnych sytuacjach kosztem wzrostu wielkości programu wykonywalnego.
Funkcje ze zmienną liczbą argumentów
Funkcja, która może być wywołana ze zmienną liczbą argumentów charakteryzuje się tym, że w miejscu jej ostatniego argumentu znajduje się parametr ... (trzy kropki). Zabronione jest wywołanie takiej funkcji z liczbą argumentów mniejszą od liczby argumentów poprzedzających .... Aby skorzystać z możliwości tworzenia funkcji ze zmienną liczbą parametrów należy w naszym programie dołączyć bibliotekę stdarg.h. Znajdują się w niej trzy funkcje niezbędne w procesie przetwarzania nieznanej liczby argumentów funkcji.
Pierwszą z nich jest funkcja va_start, której składnia jest następująca:
va_start(va_list ptr, lastfix)
Jej zadaniem jest wykonanie czynności startowych poprzedzających udostępnianie argumentów funkcji ze zmienną liczbą argumentów. Pierwszy argument tej funkcji jest typu va_list, czyli inaczej mówiąc typu void *, a więc jest to uniwersalny wskaźnik na dowolny obszar pamięci. Przy wywołaniu tej funkcji jako drugi argument podaje się nazwę ostatniego argumentu występującego przed trzema kropkami lub wartość NULL, jeśli funkcja ma nagłówek w postaci (...).
Po otwarciu można przejść do odbierania z listy kolejnych argumentów. Odwołania do argumentów z pola (...) wykonuje się za pomocą funkcji va_arg. Pierwszym argumentem tej funkcji jest zmienna typu va_list, a drugim nazwa typu odbieranego argumentu.
va_arg(va_list ptr, type)
Udostępnienie kolejnego argumentu reprezentowanego przez parametr (...). Typ tego argumentu określony jest przez drugi parametr.
Po zakończeniu odbierania argumentów należy użyć funkcji va_end.
va_end(val_list ptr)
Przykład
Napisać funkcję suma(...), która sumuje liczby typu int przychodzące jako parametr (...) do momentu napotkania liczby 0.
#include <stdio.h>
#include <stdarg.h>
int suma(...)
{
int s = 0;
va_list ptr;
int arg;
va_start(ptr, NULL) ;
while((arg = va_arg(ptr, int)) != 0)
s += arg;
va_end(ptr);
return s;
}
main()
{
printf("%d", suma(1, 2, 3, 4, 0));
return 0;
}
Każda funkcja z dowolną liczbą argumentów musi uzyskać informację z zewnątrz o tym ile będzie przesłanych parametrów oraz jakiego typu one będą. W naszym przypadku zakładamy, że argument o wartości 0 kończy listę i że wszystkie są typu int. Tak więc lista argumentów ogranicza się do trzech kropek. W funkcji suma występuje wywołanie wspomnianej funkcji va_start. Jako drugi argument podajemy wartość NULL, ponieważ funkcja ma tylko jeden argument (...).
W pętli while następuje odbieranie kolejnych argumentów z listy (...). Typ tych argumentów jest każdorazowo podawany w funkcji va_arg. Po odebraniu wszystkich argumentów następuje wywołanie funkcji va_end.
Przejdźmy teraz do trochę trudniejszego przykładu.
Przykład
Napisać funkcję z ze zmienną liczbą parametrów mprintf, która wypisze na ekranie liczby typu int w zapisie dziesiętnym, heksadecymalnym i binarnym oraz łańcuchy znaków. Drukowane argumenty mogą być przedzielone dowolnym komentarzem, czyli funkcja mprintf powinna być podobna w działaniu do funkcji standardowej printf.
Rozwiązanie
#include <conio.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdarg.h>
void mprintf(char *format, ...)
{
va_list argptr;
char *string = (char *)malloc(30);
int count, number;
va_start(argptr, format);
while(*format != '\0') {
// funkcja strcspn zwraca ile znaków poprzedza zadany ogranicznik
count = strcspn(format, "%" );
strncpy(string, format, count);
*(string + count) = '\0';
cputs(string);
format = strchr(format + 1, '%');
format ++;
switch(*format) {
case 'b': number = va_arg(argptr, int);
itoa(number, string, 2);
/*
funkcja itoa dokonuje konwersji łańcucha znaków na liczbę typu int
w kodzie określonym przez trzeci parametr czyli binarnie
*/
break;
case 'd': number = va_arg(argptr, int);
itoa(number, string, 10);
break;
case 'h': number = va_arg(argptr, int);
itoa(number, string, 16);
break;
case 's': string = va_arg(argptr, char *);
break;
}
format ++ ;
cputs(string);
}
}
main()
{
int a = 10;
char *c = "abc";
clrscr();
mprintf("Abcd%b - %d - %h - %s" ,a, a, a, c);
getch();
return 0;
}
Swoją uwagę skoncentrujemy na funkcjonowaniu mechanizmów związanych z dowolną liczbą argumentów funkcji. Jak widać funkcja ma dwa argumenty. Pierwszy typu char * i drugi (...). Zadaniem pierwszego jest przekazanie do funkcji informacji o sposobie odbioru argumentów. Sposób formatowania będzie podobny do tego, który poznaliśmy przy funkcji printf. Łańcuch formatujący przekazany jako pierwszy argument zawiera znaki % rozpoczynające deklarację typu odbieranego argumentu i sposób jego formatowania:
%s - łańcuch znaków,
%b - liczba typu int, która ma zostać wydrukowana w postaci binarnej,
%h - liczba typu int wyprowadzana w postaci heksadecymalnej oraz
%d - liczba typu int wyprowadzana dziesiętnie.
Między tymi sekwencjami może pojawić się komentarz.
W wywołaniu funkcji va_start zamiast stałej NULL podany jest ostatni argument występujący przed (...) na liście argumentów, czyli w naszym przypadku łańcuch format. Sześć wierszy znajdujących się między instrukcjami while i switch ma na celu wydrukowanie na ekranie komentarza poprzedzającego zadany znacznik % i ustawienia łańcucha format w pozycji za tym znacznikiem. W instrukcji switch następuje odebranie kolejnego argumentu z listy (...) w zależności od podanego typu oraz konwersja tego argumentu na łańcuch znaków, który jest drukowany po zakończeniu tej instrukcji. Przetwarzanie jest prowadzone do momentu zakończenia analizy łańcucha formatującego format.
Argumenty domniemane funkcji (wprowadzone w C++)
Zwykle liczba argumentów w wywołaniu funkcji musi się zgadzać z liczbą jej parametrów formalnych. Istnieje jednak możliwość nadawania parametrom funkcji wartość domniemanych. Zapis takiej funkcji z pewnymi parametrami domniemanymi wygląda następująco:
int fun(int a, int b, int c = 1, int d = 123)
{
...
return ..;
}
Tak zdefiniowaną funkcję można wywoływać z mniejszą liczbą argumentów. Brakujące w wywołaniu argumenty otrzymają wartości domniemane, np.:
fun(1, 2) ; // a = 1, b = 2, c = 1, d = 123
fun(1, 3, 4) ; // a = 1, b = 3, c = 4, d = 123
Przy wywołaniu funkcji z parametrami domniemanymi muszą wystąpić wszystkie parametry bez domniemań. Funkcje z argumentami domniemanymi zostały wprowadzone w języku C++.
Funkcje przeciążone (wprowadzone w C++)
Wszystkie widzialne w danym punkcie programu identyfikatory muszą być różne, np. nie można zadeklarować najpierw zmiennej c jako typu char, a następnie jako typu double. To samo dotyczy funkcji z jednym wyjątkiem: ich nazwy mogą być takie same, jeżeli funkcje różnią się liczbą lub typami argumentów. Takie powielanie funkcji nazywa się przeciążaniem. Jest to bardzo wygodny sposób na pozbycie się wielu nazw funkcji, np. trzeba napisać funkcję, która będzie sumować elementy wektora. Powinna ona obsługiwać następujące typy elementów wektora: int i float. Bez przeciążania funkcji musimy zadeklarować dwie funkcje o bardzo podobnej zawartości i różnych nazwach. W trakcie pisania programu programista, chcąc zapisać operację sumowania elementów, musi pamiętać właściwą nazwę funkcji. Jest to niepotrzebne odrywanie uwagi od rozwiązywanego problemu. Można zrobić to dużo zręczniej korzystając z możliwości przeciążania funkcji.
Przykład
int suma(int n, int a[ ])
{
int s = 0;
while(n--)
s += a [ n ];
return s;
}
float suma(int n, float a[ ])
{
float s = 0;
while(n--)
s += a [ n ];
return s;
}
void main()
{
int a[ ] = {1, 3, 5, 6, 7 }, x;
float b[ ] = { 0.1, 0.3, 3, 2, 5 }, y;
x = suma(5, a);
y = suma(5, b);
}
Jak widać mamy tutaj dwie funkcje o takiej samej nazwie i różniące się typem jednego argumentu. Zadaniem kompilatora jest podstawienie odpowiednich wersji funkcji suma w zależności od typu drugiego argumentu, czyli tablicy jednowymiarowej w dwóch ostatnich wierszach funkcji main.
Przy przeciążeniu należy bardzo uważać na parametry domniemane. Może tak się zdarzyć, że kompilator nie będzie w stanie odróżnić funkcji, np.:
void set(char *s, char old, char nx = '.')
{
. . .
}
void set(char *s, char *c)
{
. . .
}
void main()
{
set(p, 'x');
}
W tym miejscu kompilator nie jest w stanie odróżnić, którą funkcję należy wywołać.
Pliki
Funkcje wejścia/wyjścia operują na strumieniach danych, które mogą zostać podłączone do wielu różnych urządzeń rzeczywistych także zbiorów dyskowych. Strumienie są elastyczne, ponieważ mogą zostać przypisane do różnych urządzeń. Część z tych połączeń (strumień z odpowiadającym mu urządzeniem) zostaje wykonana w momencie uruchomienia systemu operacyjnego. I tak na starcie naszego programu mamy do dyspozycji pięć otwartych, czyli przygotowanych do pracy standardowych strumieni: stdin, stdout, stdprn, stdaux, stderr. Sposób korzystania z dwóch pierwszych predefiniowanych strumieni został podany w rozdziale "Operacje wejścia/wyjścia". Teraz zajmiemy się uogólnieniem naszych wiadomości na poprawne korzystanie z plików dyskowych.
Na początku omówimy dostęp do plików dyskowych zgodnie ze standardem ANSII C.
Aby skorzystać z danych zawartych w pliku (odczyt i zapis) znajdującym się w pamięci masowej musimy:
Zdefiniować w naszym programie odpowiednią zmienną plikową. Zmienna plikowa musi być wskaźnikiem na strukturę FILE (informacje na temat struktur zostaną podane w następnym rozdziale).
Otworzyć plik dyskowy w jednym z trybów.
Po zakończeniu operacji na pliku należy go zamknąć.
Otwieranie i zamykanie pliku
Do otwierania pliku dyskowego służy dwuargumentowa funkcja fopen. Pierwszy z nich określa nazwę otwieranego pliku dyskowego, a drugi tryb jego otwarcia. Funkcja zwraca wskazanie na strukturę FILE, czyli przypisuje zmiennej plikowej adres obszaru pamięci (struktura FILE) przechowującego informację o otwartym pliku lub wskazanie puste NULL, gdy nie udało się otworzyć pliku.
FILE *fopen(char *name, char *mode)
Parametr mode ma postać łańcucha znaków o maksymalnie trzech znakach. Na pierwszej pozycji w tym łańcuchu mogą wystąpić trzy znaki: małe litery 'w', 'r' lub 'a'. Jeden z tych znaków musi zawsze wystąpić w określeniu trybu otwarcia pliku.
Znaczenie pierwszego znaku w trybie otwarcia:
Litera 'w' oznacza, że jeśli plik nie istniał, to zostanie założony, a jeśli istniał, to zostanie z niego usunięta cała zawartość. Domyślną operacją wykonywana na tym pliku jest operacja zapisu.
Litera 'r' oznacza, że jeśli plik istniał, to zostanie otwarty do odczytu i ustawiony w pozycji początkowej. Jeśli pliku nie ma na dysku, to funkcja fopen zwróci NULL.
Litera 'a' oznacza otwarcie do dopisywania. Jeśli plik nie istniał, to zostanie utworzony, a jeśli istniał, to po otwarciu jest ustawiany w pozycji końcowej. Operacja domyślna - zapis do pliku.
Środkową pozycję może zająć znak '+'. Jego wystąpienie sygnalizuje, że można wykonywać na otwartym pliku dwie operacje: czytania i zapisywania, zaś jego brak, że wykonywana jest tylko operacja domyślna.
Na ostatnim miejscu mogą wystąpić litery: 't' - plik tekstowy lub 'b' - plik binarny. Różnica między tymi plikami polega na innym traktowaniu znaku końca wiersza '\n'. Jeśli plik jest otwarty jako tekstowy, to wysłanie do pliku znaku '\n' (zmiany wiersza) powoduje jednocześnie wysłanie tam także znaku '\r' (powrót karetki) - razem para znaków '\n' '\r' (dziesiętnie 10 i 13). W przypadku pliku binarnego nie ma automatycznego dostawiania znaku '\r'. Brak liter 't' i 'b' powoduje, że o otwarciu pliku decyduje ustawienie standardowe kompilatora zależne od stanu predefiniowanej zmiennej _fmode zdefiniowanej w bibliotece fcntl.h. Dla kompilatora Borlanda ma ona wartość O_TEXT, czyli pliki będą otwierane tekstowo chyba, że programista zmieni to ustawienie na O_BINARY.
Po zakończeniu pracy z plikiem dyskowym należy zamknąć go korzystając z funkcji fclose.
void fclose(FILE *f)
Przykłady
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
main()
{
FILE *f1, *f2, *f3, *f4, *f5;
f1 = fopen("test1.pas", "w");
/* plik otwarty jako tekstowy do zapisu */
f2 = fopen("test2.pas", "rb");
/* plik otwarty jako binarny do czytania */
f3 = fopen("test3.pas", "w+t");
/* plik otwarty jako tekstowy do zapisu i odczytu z usunięciem
poprzedniej zawartości lub utworzenie nowego pliku */
f4 = fopen("test4.pas", "a+b");
/* plik otwarty jako binarny do dopisywania */
_fmode = O_BINARY;
f5 = fopen("test5.pas", "a+");
/* plik otwarty jako binarny do dopisywania */
// ... operacje na plikach
fclose(f1);
fclose(f2);
fclose(f3);
fclose(f4);
fclose(f5);
}
Zapis i odczyt z pliku
Po otwarciu pliku można skorzystać z wielu różnych funkcji umożliwiających zapis i odczyt danych z pliku oraz badanie stanu takiego pliku. Bazować będziemy na wiadomościach przekazanych w rozdziale "Operacje wejścia/wyjścia". Jak zobaczycie Państwo, funkcje których będziemy używać są bardzo podobne do tych już poznanych.
int fprintf(FILE *f, char *format, ...)
Jedyna różnica w działaniu w stosunku do funkcji printf polega na jawnym określeniu pliku, do którego będą wyprowadzane wyniki.
Przykład
Wyprowadzić napis "Ala ma kotka" bezpośrednio na drukarkę i na ekran. W tym celu skorzystamy z predefiniowanych strumieni stdprn i stdout.
#include <stdio.h>
main()
{
fprintf(stdprn, "Ala ma kotka");
fprintf(stdout, "Ala ma kotka");
}
int fputc(int c, FILE *f)
Wysłanie do pliku f jednego znaku o kodzie przekazanym jako pierwszy argument tej funkcji. Funkcja zwraca wartość tego znaku lub EOF jeśli wystąpił błąd np., brak miejsca na dysku.
int fputs(char *s, FILE *f)
Wysłanie do pliku f łańcucha znaków s. Funkcja zwraca kod ostatniego wyprowadzonego znaku lub wartość EOF jeśli wystąpił błąd.
int fscanf(FILE *f, char *format, ...)
Wczytanie z pliku identyfikowanego przez f podanych w nim wartości i podstawienie ich pod odpowiednie argumenty znajdujące się w miejscu (...). Formatowanie jest dokładnie takie samo jak w przypadku funkcji scanf.
int fgetc(FILE *f)
Wczytanie z pliku f jednego znaku. Funkcja zwraca kod tego znaku lub wartość EOF jeśli wystąpił błąd przy odczycie pliku.
char *fgets(char *s, int n, FILE *f)
Wprowadzenie z pliku f do łańcucha znaków s n znaków. Wprowadzanie kończy się w momencie odczytania n-tego znaku lub po wczytaniu znaku końca wiersza. Wczytany ciąg znaków jest zakończony znakiem końca łańcucha '\0'.
W Pascalu istniały pliki blokowe. Ich cechą charakterystyczną było to, że można było wykonywać jednorazowo operacje zapisu i odczytu dowolnych fragmentów pliku. W C nie ma podziału na pliki blokowe, elementowe i tekstowe. Korzystamy z jednego uniwersalnego sposobu komunikacji z danymi zawartymi w pliku. Wygodną komunikację zapewniają dwie funkcji fread i fwrite.
size_t fread(void *ptr, size_t size, unsigned n, FILE *f)
size_t fwrite(void *ptr, size_t size, unsigned n, FILE *f)
Nowy typ size_t to nic innego jak unsigned. Zadaniem funkcji fread jest odczytanie z pliku f n bloków o rozmiarze size bajtów każdy i umieszczenie tego odczytanego obszaru w pamięci adresowanej przez ptr. Funkcja zwraca liczbę odczytanych poprawnie bloków. Funkcja fwrite działa bardzo podobnie z tym, że zapisuje ona dane znajdujące się w pamięci programu wskazywanej przez ptr do pliku f.
Przykład
Napiszemy dwa programy, których zadaniem będzie skopiowanie pliku. W pierwszej wersji użyjemy funkcji fscanf i fprintf działających na zmiennych typu char. W drugiej skorzystamy z funkcji fread i fwrite. W obu tych programach zostanie zmierzony czas potrzebny na skopiowanie pliku testowego. Zaleca się, aby ten plik miał dość duży rozmiar - co najmniej kilka megabajtów.
#include <io.h>
#include <alloc.h>
#include <dos.h>
#include <stdio.h>
void ReadTime(long *diftime)
{
struct time Actual;
gettime(&Actual);
*diftime = 10 * ((long int)Actual.ti_hund + 100 *
((long int)Actual.ti_sec + 60 *
((long int)Actual.ti_min + 60 *
(long int)Actual.ti_hour)));
}
main()
{
void * buf;
int n, number = 50000;
long After, Before;
char c;
buf = malloc( number );
ReadTime( &Before );
FILE *in = fopen("test_in.arj", "rb");
FILE *out = fopen("test_out.arj", "wb");
while(n = fread(buf, sizeof(buf), 1, in))
fwrite(buf, sizeof(buf), 1, out);
fclose(in);
fclose(out);
ReadTime(&After);
printf("Czas wykonania kopii pliku wynosi %ld\n",
After - Before);
ReadTime(&Before);
in = fopen("test_in.arj", "rb");
out = fopen("test_out.arj", "wb");
while(!feof(in)) {
fscanf(in, "%c", buf);
fprintf(out, "%c", buf);
}
fclose(in);
fclose(out);
ReadTime(&After);
printf("Czas wykonania kopii pliku wynosi %ld\n",
After - Before);
return 0;
}
Zmienna num określa liczbę buforów jednobajtowych, które są wczytywane i zapisywane jednorazowo. Zadaniem funkcji ReadTime jest podanie ile milisekund upłynęło od początku doby. Różnica dwóch zanotowanych czasów, jednego przed kopiowaniem i drugiego po skopiowaniu daje czas kopiowania wyrażony właśnie w milisekundach. Proszę zwrócić uwagę na czasy wykonania się dwóch części tego programu.
Swobodny dostęp do pliku
Podobnie jak w Pascalu będziemy posługiwać się takimi pojęciami jak wskaźnik końca pliku i wskaźnik położenia bieżącego pliku. Dostęp do elementów pliku ma charakter sekwencyjny (inaczej niż w przypadku tablicy, gdzie zawsze podajemy numer elementu przy odwołaniach do tablicy) i dlatego przy każdej operacji odczytu lub zapisu ważne jest określenie, którego elementu pliku dotyczyć będzie ta operacja. Przy określaniu pozycji w pliku zawsze posługujemy się bajtem jako jednostką w odróżnieniu od Pascala, który oferował nam trzy typy plików: elementowe, tekstowe i amorficzne. Do przesuwania wskaźnika położenia bieżącego pliku będziemy używać funkcji fseek, która ma następującą składnię:
int fseek(FILE *f, long dl, int from)
Działanie tej funkcji jest prawie identyczne do działania procedury Seek z Pascala. Różnica polega na tym, że w funkcji można określić, od którego miejsca ma być liczone przesunięcie wskaźnika pliku. Zapewnia to trzeci parametr - from. Przyjmuje on następujące wartości:
SEEK_SET - początek pliku,
SEEK_CUR - położenie aktualne pliku,
SEEK_END - położenie końcowe pliku.
Drugi parametr dl określa o ile bajtów ma się przesunąć w kierunku końca pliku wskaźnik pozycji bieżącej pliku w zależności od trzeciego argumentu. Pierwszy argument mówi jakiego otwartego pliku dotyczy ta operacja przesunięcia wskaźnika.
Przykład
#include <stdio.h>
main()
{
FILE *f;
f = fopen("test.dat", "rb");
fseek(f, 12, SEEK_SET);
...
fseek(f, -8, SEEK_CUR);
...
fclose(f);
}
Pierwsza operacja przesuwania wskaźnika pozycji bieżącej pliku przesunie ten wskaźnika przed 13 bajt znajdujący się w pliku. Pierwszy bajt pliku znajduje się na pozycji o numerze 0. Kolejna operacja przesuwa wskaźnik o 8 bajtów w kierunku początku pliku, bo znak minus przed określeniem liczby bajtów.
Przy przesuwaniu wskaźników dobrze byłoby mieć możliwość określenia ile bajtów dzieli pozycję aktualną od początku pliku. Zapewnia to funkcja ftell, której nagłówek wygląda następująco.
long ftell(FILE *f)
Przykład
Podać jaka jest długość pliku "a.dat".
#include <stdio.h>
main()
{
FILE *f;
f = fopen("a.dat", "rb");
fseek(f, 0, SEEK_END);
printf("%ld", ftell(f));
fclose(f);
}
Najpierw następuje przeniesienie wskaźnika pozycji bieżącej pliku na jego koniec, a następnie wywoływana jest funkcja ftell.
Każda operacja na pliku niestety może nie zostać poprawnie wykonana np., z powodu próby odczytu danych znajdujących się poza plikiem lub zapisu do pliku informacji w przypadku gdy dysk, na którym znajduje się ten plik jest już całkowicie zapełniony. Stąd też pewne funkcje zwracają wartość EOF gdy jakaś operacja nie zostanie pomyślnie wykonana. Jednak nie jest to wystarczające i dlatego przy operacjach na plikach będziemy posługiwać się dodatkowo dwoma wskaźnikami: wskaźnikiem końca pliku oraz wskaźnikiem błędu.
Pierwszy z nich ustawiany jest po wykonaniu próby odczytu spoza pliku, a nie jak w Pascalu po osiągnięciu końca pliku. Sprawdzenie czy plik jest ustawiony pozycji końcowej jest możliwe po sprawdzeniu wartości jaką zwraca funkcja feof.
int feof(FILE *f)
W przypadku ustawienia wskaźnika pliku w pozycji końcowej funkcja zwraca wartość różną od 0.
Przykład
Załóżmy, że plik "a.dat" zawiera dwa dowolne znaki. Zinterpretować działanie podanego niżej programu i określić kiedy zostanie ustawiony wskaźnik końca pliku.
#include <stdio.h>
main()
{
FILE *f;
char c;
int n;
f = fopen("a.dat", "rb");
n = fscanf(f, "%c", &c);
printf("\n%d %d", feof(f), n); // 0 1
n = fscanf(f, "%c", &c);
printf("\n%d %d", feof(f), n); // 0 1
n = fscanf(f, "%c", &c);
printf("\n%d %d", feof(f), n); // 32 -1
fseek(f, -1, SEEK_CUR);
n = fscanf(f, "%c", &c);
printf("\n%d %d", feof(f), n); // 0 1
fclose(f);
}
Pierwsze wczytanie z pliku f jednego znaku przesuwa wskaźnik pozycji bieżącej pliku o 1, a funkcja fscanf zwraca wartość równą 1, ponieważ został odczytany poprawnie jeden argument. Drugie wczytanie także przesuwa ten wskaźnik o 1 i nadaje taką samą wartość zmiennej n. W tym momencie wskaźnik ten znajduje się za ostatnim elementem, ale wskaźnik końca pliku nie jest jeszcze ustawiony. Dopiero trzecie wczytanie daje w rezultacie próbę wczytania jednego bajtu spoza pliku i w tym momencie jest ustawiony wskaźnik końca pliku - funkcja feof zwraca wartość niezerową, a funkcja fscanf zwraca 0.
Po przesunięciu wskaźnika pliku o jedną pozycję w kierunku początku nastąpił poprawny odczyt drugiego znaku w tym pliku. Należy wyciągnąć z tego wniosek, że funkcja fseek kasuje ustawiony wskaźnik końca pliku.
Wskaźnik błędu jest ustawiany w momencie kiedy dana operacja odczytu lub zapisu jest niezgodna z trybem otwarcia pliku, np.
#include <stdio.h>
main()
{
FILE *f;
char c;
f = fopen("test.txt", "wb");
fscanf(f, "%c", &c);
printf("%d", ferror(f));
}
W tym przypadku plik jest otwarty do zapisu a próbujemy z niego coś odczytać. Wartość wskaźnika błędu można sprawdzić korzystając z funkcji ferror o składni:
int ferror(FILE *f)
Funkcja zwraca wartość niezerową w przypadku gdy został ustawiony wskaźnik błedu i zero gdy ostatnia operacja przebiegła pomyślnie.
Kasowanie wskaźnika błędu wykonuje się za pomocą funkcji rewind i clearerr.
Struktury, unie i pola bitowe
Język C umożliwia definiowanie nowych typów złożonych ze znanych już wcześniej typów prostych. Te nowe typy noszą nazwę typów strukturalnych.
Struktury
Jest to zespół danych zawierający zmienne różnego typu w przeciwieństwie do tablic zawierających dane jednego typu.
Definicja struktury - deklaracja typedef
Ogólna deklaracja typu strukturalnego wygląda następująco:
typedef struct nazwa {
typ_pola_struktury nazwa_pola;
typ_pola_struktury nazwa_pola;
...;
};
Przykład
typedef struct xyz {
int x;
double y;
char * z;
} LISTA;
Od tego miejsca w naszym programie możemy posługiwać się nazwą LISTA na oznaczenie tego typu strukturalnego. Typ LISTA jest równoważny typowi struct xyz.
Zmienną strukturalną można zdefiniować na kilka sposobów:
Deklaracja zmiennej strukturalnej bezpośrednio po definicji typu strukturalnego z wykorzystaniem nazwy struktury.
typedef struct student {
char * nazwisko;
char wiek;
double wzrost;
} n;
Deklaracja zmiennej globalnie lub lokalnie z wykorzystaniem uprzednio zdefiniowanego typu strukturalnego.
typedef struct student {
char * nazwisko;
char wiek;
double wzrost;
};
void main()
{
student n;
. . .
}
Język C++ dopuszcza użycie deklaracji zmiennej typu strukturalnego bez słowa kluczowego typedef. W dalszych rozważaniach będziemy posługiwać się tym uproszczonym zapisem wprowadzonym w C++.
struct student {
char * nazwisko;
char wiek;
double wzrost;
};
void main()
{
student n;
. . .
}
Deklaracja zmiennej strukturalnej z wykorzystaniem struktury anonimowej (struktura bez nazwy). W tym przypadku zmienna tego typu musi być od razu zadeklarowana.
struct {
char * nazwisko;
char wiek;
double wzrost;
} n;
Struktury mogą być zagnieżdżone, np.:
struct ala {
int a;
double b;
};
struct basia {
int c;
struct ala x;
}
void main()
{
struct basia n1;
. . .
}
Dostęp do pola struktury zapewnia operator (.). Operator ten jest dwuargumentowy: lewym argumentem jest nazwa zmiennej strukturalnej, prawym zaś nazwa pola tej zmiennej. Ten operator jest lewostronnie łączny!!!
#include <stdio.h>
#include <stdlib.h>
struct student {
char * nazwisko;
char wiek;
double wzrost;
} n;
void main()
{
n.nazwisko = (char *) malloc(30);
scanf("%30s", n.nazwisko);
scanf("%d", &n.wiek);
printf("%s %d %4.2f" , n.nazwisko, n.wiek, n.wzrost);
}
Inicjowanie struktur
struct xyz {
int x;
double y;
char * z;
} v = {4, 8.25, "Ala" };
W tym przykładzie zdeklarowano zmienną v typu struct xyz oraz nadano tej zmiennej określoną wartość. Wolno określić tylko kilka pierwszych (!!!) pól pozostawiając domyślne wartości pozostałych.
Przykład
struct xyz {
int x;
double y;
char * z;
} v[4] = { {4, 8.25, 2 ala2 }, {3, 5, 2 asia2 }, {3, 6.7}, {3} };
Została zadeklarowana tablica czterech struktur v[4]. Dwie pierwsze mają zainicjowane wszystkie pola, trzecia - pola x i y, a czwarta tylko pierwsze pole. Wartości domyślne zależą od tego, czy ta zmienna strukturalna będzie zmienną globalną, czy lokalną. Jeśli wszystkie pola będą zainicjowane, to można opuścić nawiasy oddzielające poszczególne elementy tej tablicy, np.:
struct xyz {
int x;
double y;
char * z;
} v[4] = {4, 8.25, "Ala", 6, 5, "Asia",2, 6.7, "Pola",6, 6.7, "Jola" };
Deklarowanie wskaźników do struktur
Weźmy podany wcześniej typ strukturalny:
struct LISTA {
int x;
double y;
char * z;
};
Nic nie stoi na przeszkodzie, aby zamiast deklaracji zmiennej strukturalnej zadeklarować wskaźnik do zmiennej takiego typu.
LISTA *c;
Odwołanie się do pola takiej zmiennej odbywa się następująco:
(*c).x = 11;
lub
c->x = 11;
W tym drugim sposobie korzystamy z nowego operatora ->. Jest on wykorzystywany tylko przy wskaźnikach do struktur.
Przykład
Napisać program obliczający numer kolejnego dnia w roku. Uwzględnić lata przestępne.
#include <stdio.h>
int tab[2] [13] = {{ 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 },
{ 0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }};
struct DATA {
int d, m, r, dr;
};
DATA d1, d2;
DATA dzien_w_roku(DATA c)
{
int i, j;
if(c.r %4)
i = 0;
else
i = 1;
c.dr = 0;
for(j = 1; j < c.m; j++)
c.dr += tab[i] [j];
c.dr += c.d;
return c;
}
void main()
{
scanf("%d %d %d", d1.d, d1.m, d1.r);
d2 = dzien_w_roku(d1);
// . . .
}
Wersja wskaźnikowa przedstawia się następująco:
#include <stdio.h>
#include <alloc.h>
int tab[2][13] = { { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 },
{ 0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } };
struct DATA {
int d, m, r, dr;
};
DATA *d;
DATA *dzien_w_roku(DATA *c)
{
int i, j;
if(c->r % 4)
i = 0;
else
i = 1;
c->dr = 0;
for(j =1; j < c->m; j ++)
c->dr += tab[i][j];
c->dr += c->d;
return c;
}
void main()
{
d = (DATA *)malloc(sizeof(DATA));
d = dzien_w_roku(d);
// . . .
}
Unie
Unie są to struktury, które w różnych chwilach mogą przyjmować obiekty różnych typów. Oznacza to, że to samo miejsce pamięci może zawierać różne pola. Deklaracja unii jest bardzo podobna do deklaracji struktury i ma następującą postać:
union nazwa_unii {
typ1 pole1;
typ2 pole2;
. . . . . .;
} nazwa zmiennej;
Odwołanie się do takiej zmiennej jest dokładnie takie samo jak odwołanie do struktury, a mianowicie:
nazwa_zmiennej.pole
Unie wykorzystywane są wszędzie tam, gdzie wymagana jest obecność różnych typów, np.: wyobraźmy sobie, że tworzymy program ewidencji ludności, a dane opisujące daną osobę są następujące:
nazwisko
płeć
nazw_panieńskie
nr_jedn_wojskowej
Łatwo zauważyć, że pierwsze dwa pola są niezależne od płci danej osoby, natomiast pozostałe dwa przysługują, bądź kobiecie, bądź też mężczyźnie zależnie od wartości pola płeć. Można do rozwiązania tego problemu zadeklarować typ strukturalny w postaci:
struct OSOBA {
char nazwisko[30]
char plec;
char nazw_panienskie[30]
char nr_jedn_wojskowej[10];
};
Przy takiej deklaracji niezależnie od tego czy dana osoba jest kobietą czy mężczyzną jedno z dwóch ostatnich pól będzie niewykorzystane. Wiąże się to z większym zużyciem pamięci. Metodą na pozbycie się tej niedogodności jest zastosowanie unii. Trzeba zdefiniować nowy typ zawierający tylko dwa ostatnie pola:
union OSOBA_DOD {
char nazw_panienskie [30];
char nr_jedn_wojskowej [10];
};
Obiekt tego typu będzie zajmował tylko 30 bajtów, tyle ile wynosi rozmiar największego pola, a nie 40. Po zmodyfikowaniu struktura OSOBA przyjmuje postać:
struct OSOBA {
char nazwisko[30]
char plec;
OSOBA_DOD info_dod;
};
Odwołanie się do pól nr_jedn_wojskowej i nazw_panienskie powinno być poprzedzone odwołaniem się do pola plec, w którym jest informacja jak należy traktować to dodatkowe pole w strukturze OSOBA. Jak widać unia pozwala na oszczędną gospodarkę pamięcią.
Pola bitowe
Polami struktur mogą być pola bitowe. Umożliwia to lepsze wykorzystanie pamięci. Jest jedno wymaganie dotyczące takich pól bitowych, a mianowicie muszą się one mieścić w obrębie jednego słowa maszynowego. Służą one zwykle do przechowywania wartości typu int lub unsigned. Deklaracja pola bitowego składa się z określenia typu pola, jego nazwy i podanego po dwukropku rozmiaru pola, np.:
unsigned status:6;
Jeśli nazwa jest opuszczona, to jest tworzone pole ukryte. Jeśli rozmiar pola bitowego zostanie wyrażony liczba 0, to następne pole bitowe rozpocznie się od granicy słowa.
Przykład
Utworzyć strukturę zawierającą 3 pola przechowujące wartości z przedziałów: <0..1>, <-2..1>, <0..3>.
struct {
unsigned char flag1;
char flag2;
unsigned char flag3;
};
Zmienna tego typu strukturalnego zajmuje w pamięci 3 bajty. Spróbujmy zmniejszyć zajętość pamięci przez wykorzystanie pól bitowych.
struct {
unsigned flag1:1;
int flag2:2;
unsigned flag3:2;
};
Suma długości pól wynosi 5, a więc mieści się w obrębie jednego bajtu. Zysk więc w tym prostym przypadku wynosi aż dwa bajty.
Na elementach struktur zawierających pola bitowe można wykonywać takie operacje arytmetyczne, jak: dodawanie, mnożenie, odejmowanie, dzielenie.
UWAGA! Nie można podać adresu takiego pola bitowego.
Przykład
#include <stdio.h>
void main()
{
struct {
char chr;
int flag1:1;
int :1; // pole ukryte
int :0; // koniec pola - wymuszone następne słowo
int flag2:1;
unsigned flag3:2;
int flag4:2;
int v;
} n;
int i, j;
n.v = 10;
n.chr = 'a';
n.flag3 = 2;
n.flag3--;
n.flag4 = -1;
n.flag4 *= 2;
n.flag4--;
printf("%d", sizeof(n)); // 5
}
Dynamiczne struktury danych
Listy dynamiczne, ich tworzenie i późniejsza obsługa, zarówno w Pascalu jak i w C są dokładnie takie same, różnią się wyłącznie zapisem. Dlatego w tym momencie nie będziemy opisywać tak elementarnych operacji jak dodawanie elementu do listy, usuwanie elementu z listy, przestawianie kolejności elementów na liście, itp. Zamiast tego zapraszamy do samodzielnego przeanalizowania dwóch programów.
Program 1
Program ten realizuje zapis do listy dwukierunkowej wczytywanych z klawiatury liczb całkowitych. Zero kończy wczytywanie. Potem przechodzimy do fazy przeglądania zawartości listy. Po naciśnięciu klawiszy kierunkowych następuje przesunięcie wskaźnika next odpowiednio do przodu lub tyłu listy i wydrukowanie zawartości elementu listy. Naciśnięcie klawisza Esc kończy przeglądanie zawartości listy.
#include <stdio.h>
#include <conio.h>
#include <alloc.h>
#define TRUE 1
#define FALSE 0
typedef struct Str {
int value;
struct Str *left;
struct Str *right;
} LISTA;
LISTA *head, *next;
main()
{
int OK = TRUE, c;
clrscr();
printf("Podawaj ciąg liczb całkowitych - 0 kończy wprowadzanie\n");
head = NULL;
while(OK) {
printf("Rozmiar wolnej sterty wynosi %u - ", coreleft());
next = (LISTA *)malloc(sizeof(LISTA));
scanf("%3d", &next->value);
next->right = head;
if (next->right)
next->right->left = next;
head = next;
if (!next->value)
OK = FALSE;
}
head->left = NULL;
printf("\nPrzeglądanie zawartości listy dwukierunkowej.");
printf("\nNaciśnięcie klawisza Esc kończy działanie programu\n");
next = head;
OK = TRUE;
while(OK) {
if ((c = getch()) == 0) {
switch(c = getch()) {
case 75: if(next->left) // przewijanie listy w lewo
next = next->left;
break;
case 77: if(next->right) // przewijanie listy w prawo
next = next->right;
break;
}
gotoxy(50, 20);
printf("liczba = %3d", next->value);
}
else
if (c == 27)
OK = FALSE; // wyjście z programu
}
next = head;
while(next) {
next = next->right;
free(head);
head = next;
}
return 0;
}
Program 2
Drugi program jest znacznie trudniejszy. Jego zadaniem jest podanie wartości wprowadzonego z klawiatury dowolnego wyrażenia arytmetycznego zawierającego dowolna liczbę nawiasów, cztery podstawowe operatory (mnożenie, dzielenie, dodawanie i odejmowanie) oraz liczby rzeczywiste.
Wprowadzone wyrażenie zapisywane jest najpierw do łańcucha znaków. W tej fazie mamy do czynienia z prostym sprawdzaniem poprawności wprowadzonego wyrażenia - wprowadzane są tylko cyfry i operatory.
Następnie w funkcji GetList następuje przekształcenie łańcucha na listę jednokierunkową zawierającą liczby i operatory. Elementy tej listy są typu struct Str i zawierają trzy pola do przechowywania operatora, liczby i wskazania na następny element. Można postawić pytanie dlaczego aż trzy elementy. Odpowiedź jest prosta - wszystkie elementu listy powinny być takiego samego typu. W elementach listy informacja jest przechowywana w następujący sposób. Jeśli pole operator zawiera znak 'x' to właściwa informacja znajduje się w polu count, a jeśli ma być to operator, to pole operator przechowuje jeden z operatorów zapisany w postaci znakowej ('+', '-', '*', '\', '(', ')'). Jak widać nawiasy traktujemy także jako operatory.
Następnie określamy kolejność wykonywania działań w naszym wyrażeniu czyli priorytety operatorów. Najniższy priorytet mają operacje dodawania i odejmowania. Na wyższym szczeblu znajdują się operatory mnożenia i dzielenia. Oczywiście, nawiasy stoją najwyżej w tej hierarchii.
Zadaniem funkcji InvertList jest odwrócenie kolejności listy, gdyż przy konwersji wprowadzonego łańcucha znaków na listę dynamiczną posłużyliśmy się jej wersją w postaci stosu a nie kolejki.
Tak więc po utworzeniu listy dynamicznej przechowującej wprowadzone wyrażenie możemy rozpocząć przekształcenia. W pierwszej kolejności przechodzimy na zapis w Notacji Odwrotnej Polskiej. Zaletą tego sposobu przechowywania wyrażenia jest to, że usunięte zostają nawiasy.
Przykład
Wyrażenie 1 - 2 * 3 - 2 w Notacji Odwrotnej Polskiej ma postać 1 2 3 * - 2 -.
Brak nawiasów rekompensowany jest zmienioną kolejnością operatorów. Obliczanie wyrażenia rozpoczynamy od lewej strony poszukując dwóch liczb i stojącego bezpośrednio za nimi operatora. W naszym przypadku mamy 2 3 *. Wykonujemy teraz tę operację i rezultacie otrzymujemy 1 6 - 2 -. Znowu szukamy następnej takiej trójki od lewej strony wyrażenia. Mamy 1 6 -. Po wykonaniu tego działania mamy -5 2 -. Pierwszy minus pełni oczywiście rolę jednoargumentowego operatora zmiany znaku. Po wszystkich przekształceniach otrzymujemy w wyniku -7 co jest zgodne z prawdą.
Algorytm przejścia do zapisu w Notacji Odwrotnej Polskiej
Weźmy na przykład wyrażenie (2 - 1) * 3 - 2.
Algorytm jest bardzo prosty. Załóżmy, że całe wyrażenie na początku znajduje się po prawej stronie. Następnie elementy tego wyrażenia (liczby i operatory) będziemy przemieszczać na lewą stronę według podanej reguły. Mówi ona, że liczby przechodzą na lewą stronę bez żadnych zmian w kolejności, a operatory "wpadają" do studni i tam następuje zmiana ich kolejności zależnie od priorytetów. W pierwszym kroku do studni wpada nawias otwierający i tam pozostaje. Liczba 2 przechodzi na lewą stronę . Do studni wpada teraz operator '-'. Ma on niższy priorytet od tego, który leży na wierzchu stosu w studni i też tam pozostaje. Kolejna liczba przechodzi na lewą stronę. Do studni wpada nawias zamykający, który ma wyższy priorytet niż widoczny tam '-'. W tym momencie następuje przeniesienie wszystkich operatorów znajdujących się w studni między kolejnymi nawiasami na lewą stronę, a nawiasy znikają. Studnia jest teraz pusta i gotowa na przyjęcie operatora mnożenia '*'. Liczba 2 wędruje na lewą stronę. W tym momencie osiągnęliśmy koniec wyrażenia. Wszystkie operatory, które są w studni zostają przeniesione na koniec wyrażenia, które zostało zbudowane po lewej stronie.
include <stdio.h>
#include <ctype.h>
#include <alloc.h>
#include <conio.h>
#include <math.h>
#define TRUE 1
#define FALSE 0
struct LIST {
double count;
char oper;
struct LIST *link;
};
int i, j, error;
char c[100];
LIST *head, *next;
double r;
void DeleteList(LIST *head)
{
LIST *next = head;
while(next) {
next = next->link;
free(head);
head = next;
}
}
void GetExpression(char *c)
{
char nchar, i = 0;
while ((nchar = getch()) != '=')
switch (nchar) {
case '-':
case '+':
case '*':
case '/':
case '(':
case ')':
case '.':
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
c[i++] = nchar;
putch(nchar);
break;
case 8: if (i > 0) {
i--;
putch('\b');
putch(' ');
putch(nchar);
}
}
c[i] = '=';
putch('=');
}
LIST *ToList(LIST *&head, double value, char oper)
{
LIST *next;
next = (LIST *)malloc(sizeof(LIST));
next->count = value;
next->oper = oper;
next->link = head;
head = next;
return head;
}
LIST *GetList(LIST *head, char *c, int *error)
{
LIST *next;
int minus_plus, code, i = 0;
char ss[80];
double xx;
head = NULL;
error = FALSE;
while (c[i] != '=') {
j = 0;
ss[j] = '\0';
if (i == 0 && (c[i] == '+' || c[i] == '-')) {
ss[0] = c[i++];
ss[1] = '\0';
}
if ((c[i] == '-' || c[i] == '+') && c[i - 1] == '(') {
ss[0] = c[i++];
ss[1] = '\0';
}
if (isdigit(c[i]) || c[i] == '.') {
while (isdigit(c[i]) || c[i] == '.')
ss[j++] = c[i++];
ss[j] = '\0';
xx = atof(ss);
head = ToList(head, xx, 'x');
}
else
head = ToList(head, 0, c[i++]);
}
return head;
}
LIST *InvertList(LIST *head)
{
LIST *head1, *next, *next1;
head1 = NULL;
while (head) {
next = (LIST *)malloc(sizeof(LIST));
next->count = head->count;
next->oper = head->oper;
next->link = head1;
next1 = head;
free(next1);
head1 = next;
head = head->link;
}
return head = head1;
}
LIST *DeleteElement(LIST *head)
{
LIST *next;
next = head->link;
free(head);
return head = next;
}
LIST *CreateList(LIST *head, int *error)
{
LIST *head_left, *head_stack;
char oper;
int priorytet_A, priorytet_B, bracket;
head_left = NULL;
head_stack = NULL;
bracket = 0;
oper = 'x';
while (head) {
if (head->oper == 'x') {
head_left = ToList(head_left, head->count, head->oper);
head = DeleteElement(head);
}
else
{
if (!head_stack) {
if( head->oper == '(' )
bracket++;
}
else
switch (head->oper) {
case '(': bracket++;
head_stack = ToList(head_stack, head->count, head->oper);
break;
case ')': bracket--;
do {
head_left = ToList(head_left, head_stack->count,
head_stack->oper);
head_stack = DeleteElement(head_stack);
}
while (head_stack->oper != '(');
head_stack = DeleteElement(head_stack);
break;
default: switch (head->oper) {
case '-':
case '+': priorytet_A = 1; break;
case '*':
case '/': priorytet_A = 2; break;
case '(':
case ')': priorytet_A = 0;
}
switch (head_stack->oper) {
case '-':
case '+': priorytet_B = 1; break;
case '*':
case '/': priorytet_B = 2; break;
case '(':
case ')': priorytet_B = 0; break;
}
}
if (priorytet_A > priorytet_B) {
head_stack = ToList(head_stack, head->count, head->oper);
head = DeleteElement(head);
}
else
if (priorytet_A <= priorytet_B) {
do {
head_left = ToList(head_left, head_stack->count, head_stack->oper);
head_stack = DeleteElement(head_stack);
}
while (head_stack);
head_stack = ToList(head_stack, head->count, head->oper);
head = DeleteElement(head);
}
}
}
oper = head_left->oper;
head = DeleteElement(head);
while (head_stack) {
head_left = ToList(head_left, head_stack->count, head_stack->oper);
if (head_stack->oper == ')')
bracket--;
if (head_stack->oper == '(')
bracket++;
head_stack = DeleteElement(head_stack);
}
if (!bracket) {
*error = FALSE;
head = head_left;
}
else
*error = TRUE;
return head;
}
void DisplayList(LIST *head)
{
LIST *next;
next = head;
printf("\n");
while (next) {
if (next->oper == 'x')
printf("%6.3f ", next->count);
else
printf("%c ", next->oper);
next = next->link;
}
}
LIST *Result(LIST *head, double *r)
{
LIST *next1;
next = head;
while (next) {
if (next->oper == 'x' && next->link->oper == 'x' &&
next->link->link->oper != 'x') {
switch (next->link->link->oper) {
case '*': *r = next->count * next->link->count; break;
case '/': *r = next->count / next->link->count; break;
case '+': *r = next->count + next->link->count; break;
case '-': *r = next->count - next->link->count; break;
}
next1 = next->link->link->link;
free(next->link->link);
free(next->link);
next->link = next1;
next->oper = 'x';
next->count = *r;
DisplayList(head);
next = head;
}
else
next = next->link;
}
return head;
}
main()
{
clrscr();
printf("\n%ld\n", coreleft());
GetExpression(c);
head = GetList(head, c, &error);
DisplayList(head);
if (!error) {
head = InvertList(head);
head = CreateList(head, &error);
if (!error) {
head = InvertList(head);
DisplayList(head);
Result(head, &r);
}
}
free(head);
printf("\n%ld\n", coreleft());
return 0;
}
Preprocesor
Preprocesor to program realizujący przetwarzanie wstępne kodu źródłowego programu, które obejmuje: włączanie do pliku zawartości innych plików, definiowanie i wywoływanie makrodefinicji, warunkowe kompilowanie fragmentów programu oraz uzyskiwanie informacji o opcjach i parametrach kompilatora. Dyrektywy preprocesora zaczynają się od znaku # (hash). Musi on być dla danej dyrektywy pierwszym znakiem widocznym w wierszu.
Dyrektywa #define
Dyrektywa #define umożliwia definiowanie makroinstrukcji. Służą one do zastępowania fragmentu kodu źródłowego oznaczeniami symbolicznymi. Można deklarować makrodefinicje z parametrami jak również bez.
Makrodefinicje bezparametrowe
#define identyfikator-makrodefinicji rozwinięcie
W momencie znalezienia tekstu makrodefinicji w kodzie źródłowym kompilator wstawia w to miejsce jej rozwinięcie. Możliwe jest stosowanie makrodefinicji zagnieżdżonych, które wykorzystują zdefiniowane uprzednio makrodefinicje. Jeśli rozwinięcie makrodefinicje nie mieści się w jednym wierszu, to można je kontynuować kończąc wiersz znakiem \.
Przykład
Dzięki makrodefinicjom jesteśmy w stanie skompilować program napisany tak jak w Pascalu.
#define then #define begin { #define end } #define := = // Pascal void main() begin ... if(i > 0) then begin a := 2; b := 3; end; end |
|
// C void main() { ... if(i > 0) { a = 2; b = 3; } }
|
Dyrektywa #undef
Jej składnia wygląda następująco:
#undef identyfikator-makrodefinicji
Służy do anulowania zdefiniowanej uprzednio makrodefinicji.
Przykład
#define SIZE 512
...
a = ROZMIAR * b;
// po rozwinięciu wstawione jest a = 512 * b;
#undef ROZMIAR
// usunięta makrodefinicja ROZMIAR
...
#define ROZMIAR 256
a = ROZMIAR * b;
// po rozwinięciu wstawione jest a = 256 * b;
Makrodefinicje z parametrami
#define identyfikator(parametry formalne) rozwinięcie
Wywołanie takiej makrodefinicji jest następujące:
identyfikator(parametry aktualne)
Przykład
#define KWADRAT(x) ((x)*(x))
...
int p, y = 3;
p= KWADRAT(y + 1);
// w rozwinięciu kompilator wstawi p = (y + 1) * (y + 1)
Nawiasy w rozwinięciu tej makrodefinicji są potrzebne, gdyż w przeciwnym przypadku wynik mógłby być zupełnie inny.
Przykład
#define KWADRAT(x) (x*x)
...
int p, y = 3;
p = KWADRAT(y + 1);
// w rozwinięciu kompilator wstawi p = (y + 1 * y + 1)
W definiowanym rozwinięciu makrodefinicji jest dozwolone łączenie dwóch ciągów za pomocą symboli ##, np.:
#define LAN(a, b) (a##b)
int x, x6 = 6, z;
{
...
z = LAN(x, 6); // po rozwinięciu x6
printf("%d", z);
}
Konwersja do postaci znakowej przez użycie symbolu #; symbol ten umieszczony przed parametrem formalnym w treści makrodefinicji powoduje konwersję parametru aktualnego do postaci znakowej, np.:
#include <stdio.h>
#define Wypisz(liczba) printf(#liczba"=%d", liczba)
...
int ilosc = 20;
Wypisz(ilość);
// po rozwinięciu printf("ilosc""=%d", ilosc);
Dyrektywy warunkowe preprocesora (#if, #ifdef, #ifndef, #else, #elif, #endif)
#ifdef makrodefinicja
w momencie gdy była zdefiniowana makrodefinicja to przyjmuje wartość 1 i 0 gdy nie była zdefiniowana.
#ifndef identyfikator makrodefinicji
w momencie gdy była zdefiniowana makrodefinicja to przyjmuje wartość 0 i 1 gdy nie była zdefiniowany.
Przykład
#ifndef ROZMIAR
#define ROZMIAR 512
#endif
Symbol ROZMIAR zostanie zdefiniowany tylko wówczas, gdy nie był uprzednio zdefiniowany.
Dyrektywy warunkowej kompilacji
#if warunek
...
#elif warunek
...
#endif
Dzięki tej dyrektywie jesteśmy w stanie kompilować tylko niektóre fragmenty kodu programu w zależności od warunku. Część #elif jest opcjonalna.
#if warunek1
<sekcja1>
#elif warunek2
<sekcja2>
#else
<sekcja3>
#endif
Jeśli warunek1 jest spełniony to kod reprezentowany w sekcja1 zostanie skompilowany, w przeciwnym razie zostanie on zignorowany. Jeśli warunek2 jest prawdziwy, to zostanie skompilowana sekcja2. Jeśli oba warunki nie są prawdziwe, to skompilowana jest sekcja3.
Przykład
W zależności od wartości stałych a i b (ich wartości znane są kompilatorowi w momencie kompilacji) nastąpi skompilowanie tylko jednego wiersza zawierającego wywołanie funkcji printf.
const int a = 10, b = 20;
...
main()
{
#if a==b
printf("a = b");
#elif a<b
printf("a < b");
#else
printf("a > b");
#endif
}
Dyrektywa #include
Służy ona do włączania do pliku źródłowego tekstu zawartego w innym pliku. Można jej użyć w jednej z trzech postaci:
#include <nazwa-pliku>
#include "nazwa-pliku"
#include identyfikator-makrodefinicji
Różnica między pierwszym a drugim sposobem włączenia polega na innym algorytmie wyszukiwania pliku do włączenia. W pierwszym przypadku plik ten jest poszukiwany w każdym z katalogów zdefiniowanych w opcji kompilatora {Options | Directories | Include}. W drugim przypadku przeszukiwany jest katalog bieżący. W trzecim wariancie wywoływania dyrektywy #include rozwinięcie makrodefinicji musi zawierać postać pierwszą lub drugą.
Przykład
#include <stdio.h>
#include "ala.h"
#define ekran <conio.h>
#include ekran
Operator defined
Umożliwia on bardziej elastyczny sposób sprawdzania, czy dana makrodefinicja była już wcześniej zdefiniowana. Można go używać tylko po dyrektywach #if lub #elif w następujący sposób:
#if defined(identyfikator_makrodefinicji) ...
przy czym identyfikator_makrodefinicji ma wartość 1, jeśli ta makrodefinicja była wcześniej zdefiniowana, a 0 w przeciwnym razie.
Przykład
#if defined(ROZMIAR)
...
#endif
jest równoważne:
#ifdef ROZMIAR
...
#endif
Dyrektywy #pragma
Służy ona do sterowania procesem kompilacji i uruchomienia później programu. Jej składnia jest następująca:
#pragma nazwa_dyrektywy
Omówimy sobie tylko dwie takie dyrektywy:
#pragma exit
#pragma startup
Dyrektywy #pragma startup i #pragma exit pozwalają określić funkcje startowe, wykonywane przed i po zakończeniu funkcji main. Dyrektyw tych używa się w następujący sposób:
#pragma startup nazwa_funkcji <priorytet>
#pragma exit nazwa_funkcji <priorytet>
Funkcja taka musi być funkcją bezparametrową i nie może zwracać żadnej wartości. Musi to być funkcja typu
void nazwa_funkcji(void)
Opcjonalnie występujący priorytet przyjmuje wartości z zakresu 64-255. Jeśli priorytet nie jest określony to domyślnie jest przyjmowane 100.
Przykład
#include <stdio.h>
void funkcja_startowa_1(void)
{
printf("Funkcja startowa 1.\n");
}
void funkcja_koncowa_1(void)
{
printf("Funkcja końcowa 1.\n");
}
void funkcja_startowa_2(void)
{
printf("Funkcja startowa 2.\n");
}
void funkcja_koncowa_2(void)
{
printf("Funkcja końcowa 2.\n");
}
#pragma startup funkcja_startowa_1 64
#pragma startup funkcja_startowa_2 70
#pragma exit funkcja_koncowa_1 // domyślny priorytet 100
#pragma exit funkcja_koncowa_2 90
main()
{
printf("Program główny.\n");
}
W rezultacie wykonania się tego programu uzyskamy na ekranie następujące napisy:
Funkcja startowa 1.
Funkcja startowa 2.
Program główny.
Funkcja końcowa 1.
Funkcja końcowa 2.
1
3