Przedmowa
Język programowania C został zaprojektowany w 1972 roku przez Dennisa Ritchie, pracownika Bell Laboratories. Język ten wywodzi się od Algolu 60 (1960), CPL (1963), BCPL (1963) i B (1970). Ponieważ w języku tym został opracowany system operacyjny UNIX i jego bogate oprogramowanie użytkowe, język C jest powszechnie uznawany za język do programowania systemowego, mimo iż w istocie jest to język ogólnego przeznaczenia.
Na popularność języka C, zwłaszcza jako języka programowania mikrokomputerów, składa się kilka czynników: znaczna liczba operatorów języka, nowoczesne struktury danych i instrukcje strukturalne, użyteczne operacje na danych różnych typów, zwłaszcza na danych wskazujących, oraz bogata i w znacznym stopniu przenośna biblioteka programów standardowych, w tym programów do realizowania operacji wejścia/wyjścia. Główną jednak zaletą języka C jest efektywność i relatywnie mały rozmiar napisanych w nim programów. Z tego względu język C coraz częściej przejmuje rolę nowoczesnego języka typu asemblerowego, a większość programów realizowanych na komputerach osobistych jest opracowywana w tym właśnie języku.
Mimo iż liczba różnych implementacji języka C znacznie przekracza 20, wykazują one duże podobieństwo. W głównej mierze należy to zawdzięczać istnieniu nieformalnego standardu, jakim jest książka pt. “Język programowania C”, której autorami są Brian Kernighan i Dennis Ritchie. Została ona wydana w 1978 roku i do chwili obecnej wiele opisów implementacji ogranicza się do wyszczególnienia różnic między konkretną realizacją, a tym nieformalnym wzorcem.
Rosnące zainteresowanie językiem C oraz szczególna rola, jaką odgrywa on w oprogramowaniu mikrokomputerów, spowodowały, że w 1982 roku Amerykański Instytut do Spraw Standardów — ANSI — powołał podkomisję standaryzacyjną języka. Zadaniem tej podkomisji jest opracowanie standardu języka C, bibliotek oraz środowiska operacyjnego. Dotychczasowe rezultaty prac wykazują, że język C w swej nowej postaci nie będzie znacznie odbiegał od jego implementacji dokonanych przez wiodące firmy produkujące oprogramowanie.
Niniejsza książka składa się z trzech części: z referencyjnego opisu standardu języka, jakim do zatwierdzenia standardu ANSI jest opracowanie Kernighana i Ritchiego, opisu zasad programowania operacji wejścia/wyjścia w języku C oraz krótkiego omówienia przykładowej implementacji języka, dokonanej przez firmy Lattice i Microsoft. W dodatkach umieszczono m.in. obszerny zestaw programów realizujących operacje na plikach w systemie CP/M oraz reprezentatywny zestaw funkcji do przetwarzania znaków.
Ponieważ książka jest kierowana do programistów, którzy już opanowali sztukę programowania w innych językach i jedynie pragną zapoznać się z nowym, zrezygnowano z przytaczania długich programów, koncentrując się w głównej mierze na szczegółowym omówieniu konstrukcji programowych oraz nadaniu interpretacji sformułowaniom formalnego opisu języka. Taki sposób zaprezentowania tematu czyni niniejsze opracowanie książką towarzyszącą innemu mojemu opracowaniu pt. “Wprowadzenie do języka C" lub wzorcowemu opracowaniu Kernighana i Ritchiego.
Ze względu na podobieństwo języka C do Pascal i PL/I użyta tu terminologia nie powinna sprawić czytelnikom specjalnych trudności, a określenia takie jak unie, wyłuskiwanie i referencja nie powinny budzić większego sprzeciwu, jako że były już używane w innych opisach. Świadomie natomiast zdecydowano się użyć określeń “zmienna wskazująca" i “wskazanie", jako że tylko one właściwie oddają semantykę angielskiego terminu pointer. Ci, którzy znają język angielski wiedzą, że słowo wskaźnik wywodzi się raczej od takich słów jak flag i indicator.
Uważny czytelnik dostrzeże także pewne różnice w sposobie opisywania operacji. Ponieważ język C jest językiem wyrażeń, a pewne konstrukcje spotykane w innych językach programowania, takie jak np. instrukcja przypisania, właściwie w nim nie istnieją, uznano za celowe przyjęcie innego modelu opisywania operacji. Zgodnie z tym modelem deklaracje i wyrażenia podlegają opracowaniu (ang. elaboration), a argumentami i rezultatami operacji są dane. Dane te mogą być przypisywane zmiennym programu. Dzięki takiemu ujęciu możliwe jest pełne oddzielenie opisów czynności stanowiących program od rezultatów tych czynności, tj. skutków opracowywania opisów.
Podzielając pogląd, że największą trudność w studiowaniu opracowań referencyjnych sprawiają odwołania do pojęć jeszcze nie zdefiniowanych, rozszerzono pracę o słownik terminów, w którym zawarto krótkie definicje podstawowych pojęć oraz przykłady odwoływania się do nich.
Ponieważ książka jest miejscami trudna, zawarto w niej wiele przykładów wyjaśniających, opatrując je licznymi komentarzami. Należy żywić nadzieję, że odwoływanie się do słownika i staranne studiowanie przykładów ułatwi lekturę tekstu, przyczyniając się do pogłębienia wiadomości o języku i zakresie przenośności opracowanych w nim programów.
l. Wstęp
Język C należy do tych języków programowania, których głównym zastosowaniem jest programowanie systemowe. Został on opracowany z przeznaczeniem dla komputerów rodziny PDP-11, jednak dzięki swoim zaletom znacznie się rozpowszechnił i obecnie jest dostępny w wielu mikrokomputerach osobistych, zwłaszcza 16-bitowych, a także w komputerach IBM/370, VAX-11 i innych.
Przedstawiony tu opis języka C stanowi interpretację nieformalnego standardu języka, którym jest Dodatek A do książki pt. “The programming language C". Autorami tej książki wydanej w 1978 roku przez Prentice Hall, a następnie przetłumaczonej na język polski przez WNT, są Brian W. Kernighan i Dennis M. Ritchie. W przeważającej większości opisów implementacji tego języka są podawane odwołania do wspomnianego dodatku, przy czym podaje się inicjały nazwisk autorów, po których następuje numer rozdziału, np. KR/2.3. Z tego to względu w przytoczonych dalej opisach posłużono się zastosowaną przez Kernighana i Ritchiego numeracją punktów i utrzymano zbliżony sposób prezentacji języka. Ponieważ najlepszą ilustracją opisów formalnych są odpowiednio dobrane przykłady, trudniejsze partie opisu uzupełniono szczegółowo skomentowanymi programami lub fragmentami programów.
2. Jednostki leksykalne
Jednostkami leksykalnymi języka C są identyfikatory, słowa kluczowe, literały i separatory, spacje, znaki tabulacji, znaki przejścia do nowego wiersza i komentarze nie stanowią jednostek leksykalnych. Każdy ciąg takich znaków i komentarzy jest traktowany tak jak pojedyncza spacja i będzie nazywany krótko odstępem.
Zgodnie z przytoczoną klasyfikacją każdy program zapisany w języku C składa się z jednostek leksykalnych i odstępów. Wyodrębnienie tych obiektów odbywa się podczas analizowania programu, dokonywanego w naturalnym porządku, tj. od lewej do prawej i od góry w dół. Za jednostkę leksykalną uznawany jest w takim przypadku najdłuższy ciąg znaków nie zawierający odstępów, który może uchodzić za jednostkę leksykalną. Ponieważ jednostki leksykalne nie mogą zawierać odstępów, te ostatnie mogą być wykorzystywane do separowania słów kluczowych i identyfikatorów oraz narzucania podziału tekstu programu na odstępy i jednostki leksykalne.
Przykład. Wiersz o postaci
if(var+ + +len)fun(arg);
składa się z odstępu, ciągu jednostek leksykalnych i odstępu. Ponieważ + jest jednostką leksykalną, napis + + + składa się z jednostki leksykalnej + + oraz jednostki leksykalnej +. Przytoczony wiersz programu jest równoważny wierszowi
if(var++ +len)fun(arg);
ale jest różny od
if(var+ ++Ien)fun(arg);
gdyż w pierwszym przypadku operator + + dotyczy zmiennej var, a w drugim zmiennej len.
2.1. Komentarze
Komentarz rozpoczyna para następujących bezpośrednio po sobie znaków /*, a kończy najbliższa para znaków */. Jeśli w obrębie komentarza po parze znaków /* występuje ponownie para znaków /*, to nie stanowi ona początku komentarza.
Przykład. W deklaracji o postaci
int/*/ integer * /*/*var;
napis
/*/ integer * /*/
jest komentarzem, a przytoczona deklaracja jest równoważna deklaracji
int *var;
2.2. Identyfikatory
Identyfikatorem w języku C jest ciąg literowo-cyfrowy rozpoczynający się od litery, taki który nie jest słowem kluczowym. Literami języka są duże i małe litery alfabetu angielskiego oraz znak podkreślenia. Litery małe i duże są uznawane za różne, a pary identyfikatorów nie różniące się do ósmego znaku włącznie są uznawane za identyczne. Zezwala się, aby rozróżnienie identyfikatorów z atrybutem extern dokonywało się na podstawie silniejszych ograniczeń, tj. np. liczby znaków mniejszej od ośmiu, wykluczenia pewnych znaków lub utożsamiania liter dużych i małych.
Przykład. Jeśli w pewnej sekcji programu występuje definicja funkcji
static
StringLength(s)
char *s;
{ register char *c = s;
while(*c+ +);
return c - s - 1;
}
to w obrębie tej sekcji można posługiwać się np. identyfikatorem StringLen jako synonimem identyfikatora StringLength. Gdyby w omawianej definicji pominięto słowo static, to zakresem nazwy StringLength stałby się cały program. W takim przypadku zbiór synonimów nazwy funkcji byłby ustalony przez implementację, a mogłyby do niego należeć takie np. nazwy jak String, STRING i string.
2.3. Słowa kluczowe
Słowem kluczowym języka. C jest spójny ciąg małych liter języka angielskiego tworzący jedno z wymienionych niżej słów:
auto else register union
break extern return unsigned
case float short while
char for sizeof
continue goto static entry
default if struct fortran
do int switch asm
double long typedef
Słowom kluczowym entry, fortran i asm nie nadano w standardzie interpretacji.
Przykład. W definicji funkcji
main(){
char Char[] = "char";
printf("%s",Char);
}
występuje słowo kluczowe. Słowem tym jest char i występuje ono jednokrotnie.
2.4. Literały arytmetyczne i znakowe
Literały arytmetyczne dzielą się na stałopozycyjne i zmiennopozycyjne. Literały stałopozycyjne są typu (int), (short int), (long int) i (unsigned int). Literały zmiennopozycyjne są typu (float), (long float) i (double). Literały znakowe są typu (char). Literały stałopozycyjne i znakowe będą nazywane literałami całkowitymi.
2.4.1. Literały stałopozycyjne
Podstawowy literał stalopozycyjny składa się ze spójnego ciągu cyfr dziesiętnych. Jeśli pierwszą cyfrą takiego ciągu jest 0, a następne są cyframi ósemkowymi, to przyjmuje się, że literał stanowi liczbę ósemkową. Jeśli pierwszą cyfrą literału jest 0, a bezpośrednio po niej następuje litera x (mała albo duża), to przyjmuje się, że literał jest liczbą szesnastkową, której cyfry większe od 9 mogą być wyrażone za pomocą liter z przedziału a-f lub A-F.
Jeśli wartość literału stałopozycyjnego dziesiętnego przekracza największą dopuszczalną wartość danej typu (int), to przyjmuje się, że literał jest typu (long int). Jeśli wartość literału stałopozycyjnego ósemkowego albo szesnastkowego przekracza największą wartość danej typu (unsigned int), to przyjmuje się, że literał jest także typu (long int).
Przykład. Jeżeli w pewnej implementacji dane typu (int) są 16-bitowe, to
32767 jest typu (int)
32768 jest typu (long int)
0x10000 jest typu (long int)
2.4.2. Jawne literały typu (long int)
Jeśli bezpośrednio po ciągu znaków stanowiącym zapis literału stałopozycyjnego następuje litera L (duża albo mała), to uznaje się, że literał jest typu (long int).
Przykład. Jeśli parametr funkcji fun jest typu (long int), to aby skojarzyć go z argumentem o wartości 0, można posłużyć się wywołaniem fun(0L), ale nie można posłużyć się wywołaniem fun(0).
2.4.3. Literały znakowe
Podstawowy literał znakowy składa się z jednego znaku graficznego - różnego od kreski ukośnej pochylonej w lewo - zawartego między parą sąsiadujących z nim apostrofów. Wartością takiego literału jest wartość liczbowa reprezentowanego przezeń znaku graficznego.
Innym sposobem przedstawiania znaku za pomocą literału jest umieszczenie między parą apostrofów napisu ‘\d’ w którym \ jest wspomnianą wyżej kreską ukośną, natomiast d jest ciągiem od l do 3 cyfr ósemkowych określających wartość liczbową znaku albo jest kreską ukośną, apostrofem albo literą reprezentującą jeden z 5 znaków sterujących, tak jak wynika to z poniższego zestawienia
Znak Oznaczenie Literał
nowy wiersz NL (LF) '\n’
tabulacja HT '\t’
cofnięcie BS '\b'
powrót karetki CR *\r'
nowa strona FF '\f
kreska ukośna w lewo '\\'
apostrof '\"
Jeśli zapis literału ma postać ‘\0', to literał taki reprezentuje znak nul. Jeśli po kresce ukośnej nie następuje jeden z wymienionych znaków, to kreska ukośna jest pomijana.
Przykład. Jeśli alfabet procesora składa się ze znaków kodu ASCII, w którym znak apostrof ma wartość liczbową taką jak literał 0x27, to literał znakowy reprezentujący apostrof można przedstawić w postaci '\’’.albo w równoważnej mu postaci '\47'.
2.4.4. Literały zmiennopozycyjne
Literał zmiennopozycyjny składa się z części całkowitej, kropki, części ułamkowej, małej albo dużej litery e oraz z wykładnika. Części całkowita i ułamkowa składają się z ciągów cyfr, a wykładnik składa się z ciągu cyfr, który może być poprzedzony znakiem + albo -.
Zezwala się na pominięcie części całkowitej albo części ułamkowej, jak również na pominięcie litery e wraz z wykładnikiem albo kropki.
Ze względu na nieodróżnianie literałów pojedynczej i podwojonej dokładności, każdy literał zmiennopozycyjny jest uznawany za literał typu (double) równoważnego typowi (long float).
Przykład. Każdy z literałów
234.5 .2345e3 2345.E-1 23.45e+1 jest typu (double) i ma taką samą wartość jak pozostałe.
2.5. Literały tekstowe
Podstawowy literał tekstowy składa się z ciągu znaków graficznych zawartego między parą cudzysłowów. W ciągu tym znak cudzysłowu musi być zapisany za pomocą pary znaków \', analogicznie jak miało to miejsce dla literałów znakowych.
Ponadto między wspomnianą parą cudzysłowów może wystąpić przedstawienie znaku za pomocą napisu \d, tak jak to omówiono uprzednio. W każdym z tych przypadków, jeśli bezpośrednio po kresce ukośnej występuje koniec wiersza, to kreska ta wraz z ewentualnym znakiem końca wiersza także jest pomijana.
Przykład. Jeśli bezpośrednio po kresce ukośnej występuje znak końca wiersza (np. cr), to wykonanie następującego programu spowoduje wyprowadzenie napisu jan, ,ewa.
main(){
printf("%s%s" , "jan,\
,”
,"ewa");
}
Ponieważ na końcu każdego ciągu znaków reprezentowanego przez literał tekstowy jest umieszczany znak \0, każdy taki literał reprezentuje co najmniej jeden znak. Należy także dodać, że literał znakowy reprezentuje daną klasy static, a każde dwa wystąpienia literału tekstowego reprezentują różne dane.
Przykład. W definicji funkcji
fun()
{ putchar((*"j")++);
putchar( *"j" );
}
dwukrotnie występuje literał tekstowy "j". Ponieważ każde z tych wystąpień reprezentuje inną daną, operacje na danej reprezentowanej przez pierwszy literał nie mają
wpływu na wartość danej reprezentowanej przez drugi. Z tego względu podczas pierwszego wywołania funkcji fun nastąpi wyprowadzenie napisu jj, a podczas drugiego - nastąpi wyprowadzenie napisu kj.
2.6. Uwagi implementacyjne
Mimo iż istnienie kilku typów danych stałopozycyjnych oraz dwóch typów danych zmiennopozycyjnych może sugerować bogactwo typów, należy zdać sobie sprawę, że są zgodne ze standardem te implementacje, w których np. typy (short int) i (long int) są identyczne z typem (int), jak również te implementacje, w których typ (float) jest tożsamy z typem (double).
3. Sposób opisu składni
W tych miejscach opisu składni, w których wyjaśnienia słowne byłyby mało czytelne, zostaną podane definicje formalne. W definicjach tych bezpośrednio po nazwie definiowanej jednostki składniowej wystąpi dwukropek, a alternatywne postaci tej jednostki zostaną podane w kolejnych wierszach. Te fragmenty definicji, które mogą być opuszczone, zostaną oznaczone uniesioną literą o. Ze względu na częste występowanie pojęcia lista, zdefiniowanego np. jako
lista-obiektów:
obiekt
obiekt, lista-obiektów
wystarczy ograniczyć się do definiowania obiektów, pojęcie listy obiektów uznając za oczywiste i zgodne z przytoczoną tu definicją ogólną.
4. Klasy i typy danych
Identyfikatory występujące w programie reprezentują dane. Z każdym identyfikatorem można związać atrybuty, które określą klasę i typ danych reprezentowanych przez ten identyfikator.
Pojęcie klasy obiektu reprezentowanego przez wybrany identyfikator wiąże się ze sposobem przydzielania pamięci dla obiektu oraz zwalniania jej, natomiast pojęcie typu wiąże się ze sposobem interpretowania zawartości pamięci przydzielonej obiektowi.
W języku C zdefiniowano 4 klasy pamięci: pamięć automatyczną (auto), statyczną (static), zewnętrzną (extern) i rejestrową (register). Stosownie do tego podziału będziemy mówić o zmiennych automatycznych, statycznych, zewnętrznych i rejestrowych.
Zmienne automatyczne są tworzone podczas wykonywania prologu tego bloku, w którym zostały zadeklarowane, i są usuwane podczas wykonywania jego epilogu.
Zmienne statyczne są tworzone podczas wykonywania prologu programu i są usuwane podczas wykonywania jego epilogu.
Jeśli podczas deklarowania zmiennej każdej z tych klas polecono nadać jej wartość początkową, to nastąpi to podczas tworzenia zmiennej. Jeśli polecenie takie nie wystąpiło, to zmienne statyczne otrzymają wartości początkowe 0, a zmienne automatyczne otrzymają wartości początkowe nieokreślone. Oczywiście w odróżnieniu od zmiennych statycznych, które otrzymują wartości początkowe tylko jednokrotnie, zmienne automatyczne mogą otrzymywać je wielokrotnie. Ponieważ jednak w kolejnych wcieleniach zmiennej automatycznej ten sam identyfikator dotyczy różnych zmiennych, korzystanie z wyników przetwarzania pewnego bloku może w następnych jego wywołaniach odbywać się jedynie za pomocą zmiennych statycznych.
Przykład. Wykonanie następującego programu powoduje wyprowadzenie liczb 5 i 6 main(){
fun();fun();
}
fun(){
{ static int var = 5;
printf("%d",var++);
}
Gdyby w przytoczonym programie zastąpiono atrybut static atrybutem auto, to wykonanie programu spowodowałoby wyprowadzenie liczb 5 i 5.
W odróżnieniu od zmiennych statycznych, których zakres dostępności nie wykracza poza blok lub sekcję, w którym zostały zdefiniowane, zakres dostępności identyfikatorów zmiennych zewnętrznych obejmuje wszystkie bloki i sekcje, w których wystąpiły ich deklaracje.
Dwie pozostałe klasy zmiennych, a mianowicie zmienne rejestrowe i zewnętrzne wykazują znaczne podobieństwo do omówionych już zmiennych automatycznych i statycznych.
Zmienne rejestrowe różnią się od zmiennych automatycznych tylko tym, iż pamięć dla nich jest przydzielana w szybkich rejestrach procesora, jeżeli oczywiście jest to możliwe. Ze względu na tę właściwość odmienność zmiennych rejestrowych i zmiennych automatycznych sprowadza się do tego, że ich nazwy nie mogą być argumentami operatora referencji.
Zmienne zewnętrzne są podobnie jak zmienne statyczne tworzone podczas wykonywania prologu programu, a usuwane podczas wykonywania jego epilogu. W odróżnieniu od zmiennych statycznych zmienne te mogą być deklarowane wielokrotnie, w różnych blokach i sekcjach programu, a ogół tych deklaracji, w którym dokładnie jedna musi być deklaracją definiującą (bez atrybutu extern) dotyczy jednej zmiennej o wybranej nazwie. Dzięki temu zmienne zewnętrzne mogą być wykorzystane do komunikowania się rozłącznych bloków i sekcji programu.
Przykład. Wykonanie następującego programu powoduje wyprowadzenie napisu ewa. Wśród trzech deklaracji zmiennej zewnętrznej var pierwsza jest deklaracją definiującą, a dwie pozostałe są deklaracjami odwoławczymi
char var = 'e';
main(){
register char var = 'w';
{ extern char var;
putchar(var);
var - = 4;
putchar(var);
}
{ extern char var;
putchar(var);
}
}
Należy nadmienić, że usunięcie dowolnego ze słów kluczowych extern uczyniłoby program niepoprawnym, ponieważ przekształciłoby deklarację zmiennej wewnętrznej w deklarację zmiennej automatycznej, a zmienna taka podczas odwoływania się do funkcji putchar miałaby wartość nieokreśloną.
Jak już wspomniano, w języku C wprowadzono kilka podstawowych typów obiektów. Obiekty zadeklarowane jako znakowe (char) są tak dobrane, że umożliwiają przypisanie im dowolnego znaku alfabetu procesora. Jeśli przypisanie takie istotnie nastąpi, to zmienna, której przypisano znak otrzymuje wartość liczbową równą wartości kodu tego znaku. Skutki przypisania zmiennym znakowym innych danych są zależne od implementacji.
Przykład. Niezależnie od tego czy alfabetem procesora jest kod ASCII czy EBCDIC, opracowanie deklaracji
char chr = 'J';
powoduje przypisanie zmiennej chr znaku J. Natomiast skutki opracowania deklaracji
char chr = Ox4A;
są zależne od implementacji (w kodzie ASCII są równoważne przypisaniu chr znaku J, a w kodzie EBCDIC znaku [ ).
Obiekty stalopozycyjne występują w języku C w trzech odmianach i są typu (short int), (int) albo (long int). Zazwyczaj dana typu (short int) zajmuje mniej pamięci niż dana typu (long int). Dopuszcza się jednak, aby w konkretnej implementacji dane typu (short int) bądź typu (long int), a nawet dane obu tych typów zajmowały tyle samo miejsca, co dane typu (int).
Obiekty typu (unsigned int) są traktowane tak, jak dane bez znaku, i podlegają zasadom arytmetyki modulo 2n, gdzie n jest liczbą bitów użytą do reprezentowania danej.
Zezwala się także, aby obiekty typu (float) oraz typu (double) i (long float) były traktowane identycznie.
Zgodnie z wcześniejszymi ustaleniami będziemy przyjmować następującą klasyfikację :
dane typu (int), (short int), (long int) i (unsigned int) będą nazywane danym stałopozycyjnymi,
dane typu (char) będą nazywane danymi znakowymi,
dane stałopozycyjne i znakowe będą nazywane danymi całkowitymi,
dane typu (float), (long float) i (double) będą nazywane danymi zmiennopozycyjnymi,
dane całkowite i zmiennopozycyjne będą nazywane danymi arytmetycznymi.
Należy nadmienić, że oprócz wymienionych tu podstawowych typów arytmetycznych istnieje w języku C nieskończenie wiele typów pochodnych stanowiących
tablice obiektów,
funkcje, których rezultatami są obiekty,
wskazania na obiekty,
struktury i unie obiektów.
W ogólnym przypadku rozszerzanie zbioru typów danych przez posłużenie się tablicami, funkcjami, itp. może być zastosowane rekurencyjnie.
Przykład. Przytoczona definicja typu (struct celi) zawiera w sobie powołania na typy podstawowe oraz pochodne
struct cell{
struct cell *east;
struct celi *west;
Int array[3];
union {
int ival;
char *(*(fun)()
} *ptr;
};
fun jest zmienną wskazującą na funkcje która zwraca wskazanie na znak.
5. Obiekty i l-wyrażenia
Pod pojęciem obiektu rozumiany jest przedmiot przetwarzania, a więc dana zajmująca określony obszar pamięci operacyjnej. Do tej danej można odwoływać się za pomocą l-wyrażenia, tj. napisu identyfikującego położenie danej w pamięci.
Pojęcie l-wyrażenia ma istotne znaczenie w języku C, ponieważ tylko l-wyrażeni, mogą występować z lewej strony operatora przypisania (stąd “l-" jako skrót od (“lewa") oraz mogą być argumentami pewnych innych operatorów, a mianowicie operatora selekcji (.), inkrementacji (+ +), dekrementacji (- -) i referencji (&).
Najprostszym przykładem l-wyrażenia jest identyfikator zmiennej. Jeśli chr jest zmienną znakową zadeklarowaną jako
char chr == 'j';
to zarówno w kontekście chr = 'b';
jak i w kontekście
putchar(chr);
napis chr identyfikuje ten obszar pamięci operacyjnej, w którym podczas tworzeni;
zmiennej umieszczono kod znaku j.
Bardziej złożony przypadek l-wyrażenia występuje w programie
char arr[3] == "jB", *ref = arr;
main(){
*++ref='b';
printf("%s",arr);
}
W programie tym, którego wykonanie powoduje wyprowadzenie napisu j b, występuje m.in. instrukcja
*++ref = 'b';
w której *++ref jest w l-wyrażeniem reprezentującym drugi znak wektora arr. Opracowanie tego wyrażenia, równoważnego w rozpatrywanym kontekście l-wyra-żeniu arr[1], ma skutek uboczny w postaci zmiany wartości zmiennej ref. Gdyby rozpatrywaną instrukcję przedstawiono w równoważnej postaci
++ref,*ref= 'b';
to występujący w niej napis *ref byłby l-wyrażeniem, którego opracowanie nie ma skutku ubocznego.
Przykład. Wykonanie następującego programu powoduje wyprowadzenie liczby 5
struct{
int var;
} str[2],
*ptr = str,
agr ={5};
main(){
(ptr++)->var = (&agr)->var;
printf("%d",(- -ptr)->var);
}
Wykonanie instrukcji przypisania ma taki sam skutek jak wykonanie instrukcji
ptr->var = agr.yar, ptr+ + ;
6. Konwersje
Wykonanie pewnych operacji wymaga dokonania wstępnej konwersji ich argumentów. Konwersje te mogą być zadawane jawnie, za pomocą operatora konwersji albo mogą wynikać z przyjętych domniemań.
6.1. Dane znakowe i stałopozycyjne
W każdym miejscu programu, w którym może być użyte odwołanie do danej typu (int), może wystąpić odwołanie do danej typu (char) albo (short int). Wynika to stąd, że podczas wykonywania programu dane typu (char) i (short int) są poddawane niejawnej konwersji na dane typu (int).
Konwersja danej typu (short int) na daną typu (int) nie powoduje zmiany wartości danej, ponieważ konwersja ta polega na powieleniu bitu znaku. Konwersja danej typu (char) na daną typu (int) może natomiast powodować zmianę wartości danej, ponieważ decyzja o powielaniu lub nie powielaniu bitu znaku zależy od implementacji. Zagwarantowano jedynie, że dana typu (int) powstała z danej typu (char) reprezentującej znak alfabetu procesora będzie miała wartość dodatnią.
Konwersje danych stałopozycyjnych na dane typu znakowego oraz konwersje danych stałopozycyjnych na dane stałopozycyjne, których reprezentacja wymaga mniejszej liczby bitów, są znacznie prostsze i polegają na odrzuceniu bardziej znaczących bitów reprezentacji danej.
Przykład. Jeśli dane typu (char) są reprezentowane za pomocą 8 bitów, a dane typu (int) za pomocą 16 bitów, to po opracowaniu deklaracji
char chr = -1;
char fix = chr;
zmienna chr ma wartość 255, natomiast wartość zmiennej fix zależy od implementacji. Może nią być 255 albo -1.
6.2. Dane zmiennopozycyjne
Ponieważ operacje zmiennopozycyjne są w języku C wykonywane na danych typu (double), dane typu (float) są poddawane niejawnej konwersji na dane typu (double). Wyjątek stanowi operacja przypisania, w której z lewej strony operatora przypisania występuje l-wyrażenie reprezentujące zmienną typu (float), a z prawej strony występuje wyrażenie typu (double). W takim przypadku dana typu (double) zostaje poddana konwersji na daną typu (float).
Konwersja z typu (float) na (double) polega na wydłużeniu mantysy o dodatkowe bity zero, a konwersja odwrotna polega na odrzuceniu nadmiarowych bitów mantysy, po uprzednim zastosowaniu zaokrąglenia.
Przykład. Podczas opracowywania deklaracji
auto float _float = 3e2;
auto float _real = _float + _float;
następuje konwersja danej reprezentowanej przez literał 3e2 z typu (double) na typ (float), konwersja danych typu (float) reprezentowanych przez _float na dane typu (double) oraz konwersja danej typu (double) stanowiącej ich sumę na daną typu (float) przypisywaną zmiennej _real.
6.3. Dane zmiennopozycyjne i całkowite
Konwersje danych zmiennopozycyjnych na dane całkowite są uzależnione od implementacji. Dotyczy to zwłaszcza sposobu zaokrąglania wartości danych podczas odrzucania części ułamkowych ujemnych danych zmiennopozycyjnych. Przyjmuje się jednak, że niepoprawne są konwersje, których rezultatami są dane o wartościach wykraczających poza zakres wartości danych całkowitych.
Konwersje danych całkowitych na dane zmiennopozycyjne przebiegają zgodnie z oczekiwaniami. W przypadkach, gdy mantysy danych zmiennopozycyjnych są reprezentowane za pomocą zbyt małej liczby bitów, należy liczyć się z pewną utratą dokładności, a co za tym idzie - ze zmianą wartości danej.
Przykład. Jeśli w pewnej implementacji zarówno zmienne typu (float), jak i zmienne typu (long int) są reprezentowane za pomocą 32 bitów, a mantysa zmiennych typu (float) liczy 24 bity, to w następstwie konwersji z typu (long int) na typ (float) dwóch różnych danych o wartościach 33554431L (225-1) oraz 33554430L (225-2) powstaną dwie dane typu (float) o identycznych wartościach.
6.4. Dane wskazujące i stałopozycyjne
Operacje dotyczące danych wskazujących i stałopozycyjnych mogą dotyczyć jedynie dodania albo odjęcia danej stałopozycyjnej typu (int) albo (iong int) odpowiednio do albo od danej wskazującej.
W każdym z tych przypadków następuje konwersja danej stałopozycyjnej na przesunięcie względem wartości danej wskazującej. Przesunięcie to jest wyznaczane jako iloczyn wartości danej stałopozycyjnej i rozmiaru obiektu wskazywanego przez daną wskazującą.
Zezwala się również na odjęcie dwóch danych wskazujących obiekty tego samego typu. W tym przypadku wartości danych wskazujących zostaną poddane konwersji na adresy obiektów wskazywanych przez te dane, a następnie ich różnica zostanie podzielona przez rozmiar obiektów wskazywanych przez poddawane konwersji dane wskazujące.
Przykład. Wykonanie następującego programu powoduje wyprowadzenie liczby 2 char arr[3][2],
(*ref)[2],
var;
main()
{ ref = arr;
ref += 2;
var = ref - arr;
printf("%d",var);
}
Po wykonaniu pierwszego przypisania wartością zmiennej ref jest wskazanie na arr[0], a po wykonaniu drugiego - wskazanie na arr[2]. Te dwa wskazania różnią się o 2, ale rozpatrywane jako adresy w pamięci operacyjnej różnią się o 4.
6.5. Dane całkowite bez znaku
Operacje dotyczące danych całkowitych bez znaku oraz dowolnych danych całkowitych są wykonywane w ten sposób, że przed wykonaniem właściwej operacji dane ze znakiem są poddawane konwersji na dane bez znaku. Konwersja ta prowadzi do utworzenia danej o takiej najmniejszej wartości całkowitej nieujemnej, która modulo 2n (n jest liczbą bitów wykorzystanych do reprezentowania danej) jest równa wartości danej ze znakiem. W zapisie uzupełnieniowym do 2 wspomniana konwersja ma charakter czysto formalny i nie powoduje zmiany reprezentacji danych.
Pozostaje nadmienić, że o ile konwersja danej typu (int) na daną typu (unsigned int) może spowodować zmianę wartości danej, o tyle konwersja danej typu (unsigned int) na daną typu (Iong int) nie powoduje takiej zmiany.
Przykład. Jeśli var jest zmienną zadeklarowaną jako
unsigned var = -1;
to jej wartością początkową jest 65535 (216-1).
6.6. Konwersje arytmetyczne
Ponieważ użycie większości operatorów wiąże się z wykonaniem konwersji, których wielokrotne omawianie mijałoby się z celem, wprowadzimy pojęcie typowych konwersji arytmetycznych. Konwersje te są stosowane w następującej kolejności:
najpierw te argumenty operacji, które są typu (char) albo (short int), zostają poddane konwersji na dane typu (int), a te argumenty, które są typu (float), zostaną poddane konwersji na dane typu (double), równoważnego typowi (long float),
następnie jeśli jeden z argumentów jest typu (double), to drugi zostanie poddany konwersji na daną typu (double) i rezultat operacji jest tego właśnie typu.
Jeśli po tych konwersjach
jeden z argumentów jest typu (long float), to drugi zostanie poddany konwersji na daną typu (double) i rezultat konwersji jest tego właśnie typu,
jeden z argumentów jest typu (long int), to drugi zostanie poddany konwersji na daną typu (long int) i rezultat konwersji jest tego właśnie typu,
jeden z argumentów jest typu (unsigned int), to drugi zostanie poddany konwersji na daną typu (unsigned int) i rezultat konwersji jest tego właśnie typu.
W pozostałych przypadkach oba argumenty muszą być typu (int) i rezultat operacji jest typu (int).
Przykład. Jeśli deklaracje zmiennych Int, Short, Char i Float mają postać
int Int;
short int Short;
char Char;
float Float;
to instrukcja
Int = Short + Char + Float;
jest wykonywana tak jak instrukcja
Int = (int)( (double)( (int)Short + (int)Char) + (double) Float);
7. Wyrażenia
Niniejszy rozdział poświęcono zasadom opracowywania wyrażeń. W kolejnych podrozdziałach opisano grupy operatorów języka o tym samym priorytecie, rozpoczynając od priorytetu najwyższego, a kończąc na najniższym.
Tablica 7.1. Priorytety i wiązania operatorów
Priorytet Wiązanie Operator
15 lewe () [] ->.
14 prawe ! ~ ++ - - - (typ) * & sizeof
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 ,
W tablicy 7. 1 umieszczono zestawienie zbiorcze priorytetów i wiązań poszczególnych operatorów. Na podkreślenie zasługuje fakt, że priorytety i wiązania określają jedynie zasady interpretowania wyrażeń języka, jednak nie określają porządku, w jakim są opracowywane elementy wyrażenia. W języku C porządek ten jest nieokreślony.
Przykład. Wobec tego iż priorytet mnożenia jest wyższy od priorytetów dodawania
i odejmowania, wyrażenie arytmetyczne
a - b + c * d
jest interpretowane tak jak wyrażenie a - b + (c * d)
a wobec tego iż operatory + i - wiążą argumenty od lewej do prawej jest ono interpretowane tak jak wyrażenie
(a - b) + (c * d) a nie jak wyrażenie
a - (b + (c * d))
co miałoby miejsce gdyby + i - wiązały argumenty od prawej do lewej. Mimo iż
rozpatrywane wyrażenie jest interpretowane tak jak wyrażenie
(a - b) + (c * d)
nie można wnioskować o kolejności opracowywania argumentów operacji dodawania, gdyż jest ona zależna od implementacji.
W języku C posunięto swobodę opracowywania wyrażeń tak daleko, że zezwolono nie tylko na dowolny wybór kolejności opracowywania podwyraźeń, i to nawet wtedy, gdy pociąga to za sobą skutki uboczne, ale również zezwolono na dowolne przestawienie argumentów operacji łącznych i przemiennych (*, +, &, |, ^) nie bacząc na obecność nawiasów.
Przykład. Jeśli a, b i c są dowolnymi podwyrażeniami, nic nie stoi na przeszkodzie, aby w pewnej implementacji wykonanie instrukcji
var = (a + b) + c;
w której var jest identyfikatorem zmiennej prostej, sprowadzono do wykonania instrukcji
var = b + c, var + = a;
Od implementacji zależy także sposób reagowania kompilatora na sytuacje wyjątkowe powstające podczas opracowywania wyrażeń. Dotyczy to w szczególności powstawania nadmiaru, dzielenia przez 0 i reagowania na niepoprawnie określone argumenty funkcji.
7.1. Wyrażenia pierwotne
W wyrażeniach pierwotnych występują co najwyżej operatory . i -> oraz nawiasy okrągłe, kwadratowe i wywołania funkcji. Wszystkie one wiążą argumenty od lewej do prawej.
Składnia
wyraźenie-pierwotne:
identyfikator
literał
( wyrażenie)
wyraźenie-pierwotne [ wyrażenie ]
wyraźenie-pierwotne (lista-wyrażeń)
l-wyrażenle-pierwotne. identyfikator
wyraźenie-pierwotne -> identyfikator
Każdy zadeklarowany identyfikator jest wyrażeniem pierwotnym. Typ takiego identyfikatora jest ustalany na podstawie deklaracji, chyba że typem identyfikatora jest “tablica elementów typu...", kiedy to rezultatem opracowania wyrażenia składającego się z tego identyfikatora jest dana o wartości równej wskazaniu na pierwszy element tablicy, a typem tego wyrażenia jest “wskazanie na element typu ...". Należy przy tym nadmienić, że wyrażenie pierwotne, które jest identyfikatorem tablicy nie jest l-wyrażeniem.
Przykład. Jeśli zadeklarowano tablicę arr i zmienną wskazującą ptr
char arr[3][4],
(*ptr)[4];
to w instrukcji
ptr = arr;
rezultatem opracowania wyrażenia składającego się z wyrażenia arr jest dana, której wartością jest wskazanie na pierwszy wiersz tablicy dwuwymiarowej, a typem tego wyrażenia jest (char (*)[4]), tj. wskazanie na 4 elementy typu (char). Ponieważ arr nie jest l-wyrażeniem, niepoprawna byłaby nawet taka instrukcja jak np.
arr = arr;
W podobny sposób jak identyfikatory tablic są traktowane identyfikatory funkcji. Jeśli identyfikator funkcji zostanie zadeklarowany jako “funkcja o rezultacie typu ...", to we wszystkich kontekstach, w których ten identyfikator nie występuje w wywołaniu funkcji w znaczeniu jej nazwy, jest on traktowany jak “wskazanie na funkcję o rezultacie typu...".
Przykład. Jeśli zadeklarowano funkcję fun i zmienną wskazującą ref
char fun(),
(*ref)();
to instrukcja
fun (fun);
mogłaby być zastąpiona instrukcją
ref = fun, fun(ref);
Należy nadmienić, że w każdym z tych przypadków funkcja fun jest wywoływana jednokrotnie.
Wyrażeniami pierwotnymi są także literały. W zależności od ich postaci są one typu (int), (long int) albo (double). Literały znakowe są typu (int), a literały zmiennopozycyjne są typu (double). Literały tekstowe są, podobnie jak jednowymiarowe tablice znakowe, typu “tablica znaków", jednak w wyrażeniach są traktowane podobnie jak identyfikatory tablic i z tego względu są typu “wskazanie na znak, tj. (char *). Wyjątek od tej zasady dotyczy jedynie pewnych form przypisywania danych początkowych.
Przykład. Jeśli zadeklarowano tablicę arr i dwie zmienne wskazujące ptr i ref
char arr[4] = "jan",
*ptr,
*ref= {"jan"};
a następnie wykonano instrukcję ptr = "jan";
to spowodowano przypisanie zmiennym ptr i ref danych, których wartościami są wskazania na pierwsze znaki tekstów "Jan", oraz przypisano elementom tablicy arr takie dane początkowe, jakby użyto deklaracji
char arr[4] ={ 'j','a','n','\0' };
Dowolne wyrażenie ujęte w nawiasy okrągłe jest wyrażeniem pierwotnym. Wartość i typ tak uzyskanego wyrażenia pierwotnego odpowiednio pokrywa się z wartością i typem wyrażenia w nawiasach. Ujęcie w nawiasy nie nadaje jednak wyrażeniu, które nie jest l-wyrażeniem charakteru l-wyrażenia.
Przykład. Poprawnym wyrażeniem języka C jest m.in.
(ptr) = (ref)
Wyrażenie pierwotne, bezpośrednio po którym następuje wyrażenie ujęte w nawiasy kwadratowe, jest wyrażeniem pierwotnym. W typowych przypadkach wyrażenie pierwotne jest typu “wskazanie na daną typu...", wyrażenie w nawiasach jest typu (int), a rezultatem operacji jest dana typu " ... ". Przyjmuje się z definicji, że wyrażenie
e[i]
jest równoważne wyrażeniu
*((e)+(i))
Ponieważ argumentem operacji wyłuskania (*) może być tylko wyrażenie wskazujące, dokładnie jedno z wyrażeń e oraz i powinno być wyrażeniem wskazującym.
Przykład. Wykonanie następującego programu powoduje wyprowadzenie litery b
main(){
putchar((1+"janb")[2]);
}
Argument funkcji putchar można z równym skutkiem przedstawić w postaci 2 [1 + "janb"], ale nie można przedstawić w postaci 2[++"janb"] ponieważ operator ++ może dotyczyć jedynie l-wyrażenia. Wywołanie funkcji składające się z wyrażenia pierwotnego, bezpośrednio po którym następuje ujęta w nawiasy okrągłe lista argumentów wywołania, jest także wyrażeniem pierwotnym. Występujące w tym wywołaniu wyrażenie pierwotne musi być typu “funkcja o rezultacie typu...", a rezultat wywołania funkcji jest typu " ... ". Jeśli wywołanie funkcji ma postać nazewnika, w którym wyrażeniem pierwotnym jest jak dotąd nie zadeklarowany identyfikator, to przyjmuje się, że rezultat funkcji jest typu (int).
Przykład. Wykonanie następującego programu powoduje wyprowadzenie napisu Jan
main(){
char *(*sub)(),
*fun();
sub = fun;
printf("%s",(*sub)(2));
}
char *arr[] = { "Ewa","Iza","Jan" };
char *
fun(arg)
char arg;
{ return arr[arg]; }
Wywołanie funkcji ma tu postać
(*sub)(2) i mogłoby w danym programie zostać zastąpione wywołaniem
fun(2)
Tuż przed wywołaniem funkcji jej argumenty typu (float) zostają poddane konwersji na dane typu (double), a argumenty typu (char) i (short int) zostają poddane konwersji na dane typu (int). Jeśli argumentami są nazwy tablic albo funkcji, to zostają poddane konwersji na wskazania. Ponieważ tylko wymienione tu konwersje są wykonywane niejawnie, a rezygnuje się z badania zgodności typu parametrów i odpowiadających im argumentów, wszelkie dostosowania typu argumentów do typu parametrów muszą być wykonywane jawnie — za pomocą operatorów konwersji.
Przykład. Jawne konwersje argumentów dla uzyskania zgodności z typami parametrów
char arr[2][6] = {"Janek"," Marek"},
cnt = 3;
mam(){
fun( (long int)cnt, (char *)arr);
}
fun(par.ref)
long int par;
char *ref;
{ while(par - -)
putchar(*ref + +);
}
W wyrażeniu Stanowiącym drugi argument funkcji fun użyto operatora konwersji (char*), ponieważ arr jest typu (char (*)[3]), a typ ten jest odmienny od typu parametru ref. Jak można wywnioskować, w następstwie wykonania przytoczonego programu zostanie wyprowadzony napis Jan. Taki sam wynik otrzymano by, gdyby zastąpiono wywołanie funkcji fun wywołaniem
fun(3L,(char *)arr) q
Ponieważ w języku C skojarzenia parametrów z argumentami dokonują się przez wartość, operacje na parametrach nie mogą spowodować zmian wartości argumentów. Nic jednak nie stoi na przeszkodzie, aby argumentem było wskazanie obiektu. W takim przypadku wykonanie operacji na parametrze może spowodować zmianę wartości obiektu wskazywanego przez ten argument, z którym skojarzono dany parametr.
Przykład. Zmiana wartości obiektu wskazywanego przez argument. Wykonanie programu spowoduje wyprowadzenie cyfry 5
main0{
char val = 2;
add(val,&val);
putchar(val+'0');
}
add(one.two)
char one,*two;
{ one = 3;
*two == *two + one;
}
Przypisanie parametrowi one danej o wartości 3 nie powoduje zmiany wartości argumentu val.
Kolejność opracowywania argumentów funkcji zależy od implementacji. Z tego
względu nie wiadomo, czy wykonanie instrukcji
printf("%c%c",putchar('j'),putchar('b'));
spowoduje wyprowadzenie napisu jbjb czy napisu bjjb.
Wszystkie funkcje języka są traktowane tak, jakby były rekurencyjne. Rekurencyjność jest więc integralną częścią definicji języka.
Przykład. Funkcja wyznaczająca n-ty wyraz ciągu Fibonacciego
Fibonacci(n)
char n;
{
if(n<2) return 1;
else
return Fibonacci(n-1) + Fibonacci(n-2);
}
Wyrażeniem pierwotnym języka C jest także wyrażenie składające się z l-wyrażenia pierwotnego reprezentującego strukturę albo unię, po którym następuje operator selekcji zapisany za pomocą symbolu kropka (.), a za nim identyfikator komponentu tej struktury albo unii. Tak skonstruowane wyrażenie pierwotne o postaci Iwp.id jest wyrażeniem reprezentującym wybrany komponent danej struktury albo unii.
Ponadto wyrażeniem pierwotnym jest wyrażenie składające się z wyrażenia pierwotnego, reprezentującego daną wskazującą strukturę albo unię, po którym następuje operator wskazania utworzony z pary symboli minus i większe (->), a po nim identyfikator komponentu tej struktury albo unii. Tak skonstruowane wyrażenie pierwotne o postaci wp->id jest wyrażeniem reprezentującym wybrany komponent danej struktury albo unii. Wyrażenie to jest zawsze równoważne wyrażeniu (*lwp).id.
Jeśli wyrażenie o postaci Iwp.id albo wp->id reprezentuje komponent struktury albo unii, który nie jest polem ani tablicą, to jest ono l-wyrażeniem. W przeciwnym razie nie jest ono l-wyrażeniem, a więc nie może występować jako lewy argument operatora przypisania i selekcji oraz jako argument operatora inkrementacji, dekrementacji i referencji. Z tego względu w zasięgu deklaracji
struct{
char arr[2];
struct{ charline[80];
} vec;
Int var;
} *ptr,str;
niepoprawne jest wyrażenie &ptr->arr oraz &str.vec.line, mimo iż poprawne jest wyrażenie &ptr->var oraz &ptr->vec.
Przykład. W zasięgu deklaracji
struct{
char chr;
int arr[3];
union{
float smali;
double large;
} real [3];
} str,*ptr = &str;
poprawne są m.in. następujące instrukcje
str.chr = 'j';
ptr->arr[0] =2;
printf("%f", ptr- >real[2].small);
W ostatniej z nich wyrażenie
ptr->real[2].small jest interpretowane tak jak wyrażenie
((ptr->real)[2].small i jest równoważne wyrażeniu
(*ptr).real[2].small
7.2. Operatory jednoargumentowe
Operatory jednoargumentowe wiążą argumenty od prawej do lewej. Składnia wyrażenie-jednoargumentowe:
* wyrażenie
& l-wyraźenie
- wyrażenie
! wyrażenie
~ wyrażenie
+ + l-wyraźenie
- - l-wyraźenie
l-wyraźenie + +
l-wyraźenie - -
( oznaczenie-typu ) wyrażenie
sizeof wyrażenie
sizeof( oznaczenie-typu )
Operator *
Operator ten oznacza wyłuskanie. Następujące po nim wyrażenie musi być typu wskazującego, a rezultatem operacji wyłuskania jest wskazywany obiekt. Jeśli wyrażenie jest typu “wskazanie na obiekt typu...", to rezultatem operacji wyłuskania jest obiekt typu " ... ".
Przykład. Wykonanie programu powoduje wyprowadzenie znaku J
char arr[2][3];
main(){
(*arr)[0] = 'J';
fun (*arr);
}
fun(par)
char *par;
{ putchar(*par); }
W wyrażeniu *arr argumentem operacji * jest wskazanie na 3-elementowy wektor znaków, a więc rezultatem wyłuskania jest ten właśnie wektor. Rezultatem indeksowania tego wektora w instrukcji
(*arr)[0] = 'J';
jest element arr[0][0] tablicy arr, a argumentem funkcji fun w instrukcji
fun(*arr);
jest wskazanie na zerowy element wektora *arr.
Operator &
Operator referencji & może dotyczyć tylko l-wyrażenia. Rezultatem operacji jest wskazanie na obiekt reprezentowany przez to l-wyrażenie. Jeśli l-wyrażenie jest typu " ... ", to poprzedzenie go operatorem referencji przekształca je w wyrażenie typu "wskazanie na...". Wyrażenie to nie jest już l-wyrażeniem.
Przykład. Wykonanie programu powoduje wyprowadzenie liczby 13
main(){
int var = 13, *ref = &var;
sub(&ref);
}
sub(par)
int **par;
{printf("%d",**par);}
W wywołaniu funkcji sub identyfikator ref jest l-wyrażeniem typu (int *), a *ref jest wyrażeniem typu (int **).
Operator -
Operator zmiany znaku służy do zanegowania wartości argumentu. W przypadku argumentów typu (unsigned int) operacja ta polega na odjęciu argumentu od 2", gdzie n jest liczbą bitów, za pomocą których jest reprezentowany argument.
Przykład. Jeśli dane typu (int) są reprezentowane za pomocą 16-bitów, to po opracowaniu deklaracji
int Signed = -6;
unsigned int Unsigned = -6;
zmienne Signed i Unsigned otrzymują odpowiednio wartości początkowe -6 i 65530.
Operator !
Operator zaprzeczenia służy do porównania wartości argumentu ze stałą o wartości 0. Jeśli argument ma wartość 0, to rezultatem operacji zaprzeczenia jest 1. Jeśli ma wartość różną od 0, to rezultatem operacji jest 0. Rezultat operacji jest zawsze typu (int), a operacja może dotyczyć tak danych arytmetycznych, jak i wskazujących.
Przykład. Funkcja, której rezultatem jest dana o wartości -1 dla argumentów ujemnych, dana o wartości +1 dla argumentów dodatnich i dana o wartości O dla argumentów 0.
sign(par)
int par;
{ return par<0 ? -1 : !par; }
Operator ~
Operator negacji służy do zmiany wartości każdego z bitów stanowiących reprezentację jego argumentu. Wymaga się, aby argument operatora negacji był typu całkowitego.
Przykład. Jeśli w pewnej implementacji dane typu (Int) są reprezentowane w zapisie uzupełnieniowym do 2, to po opracowaniu deklaracji
int Fix = ~2;
zmienna Fix otrzymuje wartość początkową 1.
Operator preinkrementacji ++ i predekrementacji - -
Operatory preinkrementacji i predekrementacji mogą dotyczyć tylko l-wyrażeń. Wykonanie operacji ++ powoduje zwiększenie o 1, a wykonanie operacji - - zmniejszenie o 1, wartości danej identyfikowanej przez l-wyrażenie. Rezultatem operacji jest dana o zmienionej wartości. Typ rezultatu jest identyczny z typem l-wyrażenia. Wyrażenie utworzone z preoperatora i l-wyrażenia nie jest l-wyrażeniem.
Przykład. Wykonanie programu powoduje wyprowadzenie napisu JB
char arr[] = "JB";
main{
char *ref = arr,
*ptr = &arr[1];
sub(* ++ ref, - - ptr);
}
sub(one,two)
char one,*two;
{ putchar(*two),
putchar(one);
}
Operator postinkrementacji ++ i postdekrementacji - - Operatory postinkrementacji i postdekrementacji mogą dotyczyć tylko l-wyrażeń. Wykonanie operacji + + powoduje zwiększenie o 1, a wykonanie operacji - - powoduje zmniejszenie o 1 wartości danej identyfikowanej przez l-wyrażenie. Rezultatem operacji jest dana o pierwotnej wartości. Typ rezultatu jest identyczny z typem l-wyrażenia. Wyrażenie utworzone z l-wyrażenia i postoperatora nie jest l-wyrażeniem.
Przykład. Wykonanie programu powoduje wyprowadzenie napisu JB
char arr[] = "JB";
main(){
char *ref = arr,
*ptr = &arr[1];
sub(*ptr- -,ref++);
}
sub(one,two)
char one,*two;
{ putchar(*two), putchar(one);}
Operator konwersji
Operator konwersji ma postać nazwy typu ujętej w nawiasy okrągłe. Rezultatem konwersji jest dana, której typ pokrywa się z nazwą typu.
Przykład. Jeśli zmienna Fix jest typu (int) i przypisano jej daną o wartości -2, a zmienne typu (short int) są reprezentowane za pomocą 8 bitów w zapisie uzupełnieniowym do 2, to rezultatem opracowania wyrażenia
(long int) (short int) Fix
jest dana typu (long int) o wartości 254.
Operator rozmiaru sizeof
Operator rozmiaru może dotyczyć dowolnego wyrażenia albo ujętej w nawiasy okrągłe nazwy typu. W każdym z tych przypadków rezultatem operacji jest dana typu (int), której wartość określa wyrażony w bajtach rozmiar pamięci operacyjnej niezbędny do reprezentowania danych o typie wyrażenia albo danych typu określonego przez nazwę typu. Wyjątek od tej zasady dotyczy jedynie wyrażeń i nazw typów tablicowych, dla których wartość rezultatu określa rozmiar pamięci niezbędny do reprezentowania takich tablic.
Mimo iż w języku C nie zdefiniowano pojęcia bajtu, we wszystkich dotychczasowych implementacjach bajt jest jednostką pamięci wystarczającą do reprezentowania danych typu (char).
Ponieważ niejednoznaczność syntaktyczną powstałą przy interpretowaniu wyrażenia
sizeof( oznaczenie-typu ) wyrażenie
rozstrzygnięto w ten sposób, że uznano je za równoważne wyrażeniu
(sizeof( oznaczenie-typu)) wyrażenie
w przykładowej implementacji, w której dane typu (int) są reprezentowane za pomocą 2 bajtów, rezultatami opracowania wyrażeń
sizeof((int)-2L) sizeof(int)-2L
są dane typu (int) odpowiednio o wartościach 2 i 0L.
Przykład. Jeśli w pewnej implementacji dane wskazujące są reprezentowane za pomocą 2 bajtów, a dane typu (char) za pomocą jednego bajtu, to w zasięgu deklaracji
char *(*ref)[3], arr[3][4];
rezultatami przytoczonych dalej operacji sizeof są dane o następujących wartościach:
sizeof(ref) 2
sizeof(*ref) 6
sizeof(**ref) 2
sizeof(*ref[0][0]) 1
sizeof(arr) 12
sizeof(arr[0]) 4
sizeof(**arr) 1
7.3. Operatory typu mnożenia
Operatorami typu mnożenia są *, / i %. Operatory te są dwuargumentowe, mogą dotyczyć tylko danych arytmetycznych i wiążą argumenty od lewej do prawej.
Podczas opracowywania wyrażeń zawierających operatory typu mnożenia są wykonywane typowe konwersje arytmetyczne.
Składnia
wyrażenie-z-operatorem-typu-mnoźenia:
wyrażenie * wyrażenie
wyrażenie / wyrażenie
wyrażenie % wyrażenie
Operator *
Operator ten oznacza mnożenie. Ponieważ operacja mnożenia jest łączna i przemienna, wyrażenia zawierające więcej niż jeden operator typu mnożenia mogą być niejawnie przekształcone w wyrażenia matematycznie równoważne.
Rezultatem opracowania operacji mnożenia jest dana, której wartość jest iloczynem wartości argumentów.
Przykład. Mimo jawnego użycia nawiasów, opracowanie wyrażenia
a * (b * c) może rozpocząć się od wyznaczenia wartości iloczynu a * b albo a * c.
Operator /
Operator ten oznacza dzielenie. Rezultatem opracowania operacji dzielenia jest dana, której wartość jest ilorazem pierwszego argumentu przez drugi.
Jeśli oba argumenty operacji są całkowite i dodatnie, to następuje odrzucenie części ułamkowej bez zaokrąglenia. Jeśli przynajmniej jeden ma wartość ujemną, to sposób odrzucenia części ułamkowej zależy od implementacji. W większości implementacji reszta ma taki sam znak jak dzielna.
Przykład. Rezultatem dzielenia danej o wartości 3 przez daną o wartości 5 jest dana o wartości 0.
Operator %
Operator ten oznacza określenie reszty z dzielenia pierwszego argumentu przez drugi.
Za definicję operacji % można przyjąć wzór, który zapisany w języku C ma postać
a % b = a - (a / b) * b
Przykład. Resztą z dzielenia danej o wartości 5 przez daną o wartości 3 jest dana o wartości 2.
7.4. Operatory typu dodawania
Operatorami typu dodawania są + i -. Operatory te są dwuargumentowe i wiążą argumenty od lewej do prawej. Podczas opracowywania wyrażeń zawierających te operatory są wykonywane typowe konwersje arytmetyczne. W odróżnieniu od operacji typu mnożenia, argumentami operacji typu dodawania mogą być także dane wskazujące.
Składnia
wyraźenie-z-operatorem-typu-dodawania:
wyrażenie + wyrażenie
wyrażenie - wyrażenie
Operator +
Operator ten oznacza dodawanie. Ponieważ operacja dodawania jest łączna i przemienna, wyrażenia zawierające więcej niż jeden operator dodawania mogą być przekształcone w wyrażenia matematycznie równoważne.
Rezultatem operacji dodawania jest dana, której wartość jest sumą wartości argumentów. Jeśli jednym z argumentów jest dana wskazująca, to druga dana musi być całkowita. Rezultat dodawania jest wówczas daną wskazującą.
Operacje dodawania dotyczące danych wskazujących mają w praktyce sens jedynie wtedy, gdy zarówno dana wskazująca, jak i rezultat operacji dotyczą tej samej tablicy. W takim przypadku jeśli ptr reprezentuje daną wskazującą i-ty element tablicy, a j reprezentuje daną całkowitą, to
ptr + j reprezentuje daną wskazującą element i+j tej tablicy.
Przykład. Jeśli w zasięgu deklaracji
char arr[3][4], (*ref)[4], (*ptr)[4];
zostaną wykonane instrukcje
ref = arr;
ptr = ref + 2;
to ref będzie wskazywać element arr[0], a ptr będzie wskazywać element arr[0]. Różnica adresów stanowiących reprezentacje tych wskazań będzie w większości implementacji równa 8.
Operator -
Operator ten oznacza odejmowanie i może dotyczyć nie tylko danych arytmetycznych, ale i danych wskazujących.
Rezultatem operacji odejmowania jest dana, której wartość jest różnicą wartości argumentów. Jeśli jednym z argumentów jest dana wskazująca, a drugim dana całkowita, to obowiązują zasady jak dla dodawania danej całkowitej i danej wskazującej.
Oznacza to, że jeśli ptr reprezentuje daną wskazującą i-ty element tablicy, a j reprezentuje daną całkowitą, to
ptr - j
reprezentuje daną wskazującą element tablicy o indeksie i-j. Jeśli oba argumenty odejmowania są typu wskazującego, to operacja odejmowania ma sens jedynie wtedy, gdy wartościami obu danych są wskazania na elementy tej samej tablicy. W takim przypadku jeśli ptr reprezentuje daną wskazującą element i, a ref reprezentuje daną wskazującą element j, to np.
ptr - ref
reprezentuje daną o wartości i-j. Przykład. Jeśli w zasięgu deklaracji
char arr[3][4], len;
zostanie wykonana instrukcja
len = arr[2] - (char *)arr;
to zmiennej len zostanie przypisana dana o wartości 2.
7.5. Operatory przesuwania
Operatorami przesuwania są << i >>. Operatory te wiążą argumenty od lewej do prawej, a podczas opracowywania wyrażeń zawierających te operatory są wykonywane typowe konwersje arytmetyczne. Zarówno lewy, jak i prawy argument operacji musi być całkowity. Przed wykonaniem operacji prawy argument zostaje poddany konwersji na daną typu (int). Rezultatem operacji jest dana takiego typu jak lewy argument. .
Jeśli prawy argument operacji przesuwania ma wartość ujemną albo jeśli ma wartość większą albo równą liczbie bitów użytych do reprezentowania przesuwanej danej, to rezultatem przesuwania jest dana o wartości nieokreślonej.
Składnia
wyrażenie-z-operatorem-przesunięcia:
wyrażenie << wyrażenie
wyrażenie >> wyrażenie
Operator <<
Operator ten oznacza przesunięcie w lewo. Rezultatem operacji e1<<e2 jest dana, której reprezentacja powstała z reprezentacji danej e1 po przesunięciu jej w lewo o e2 pozycji. Podczas tego przesuwania na miejsce bitów najmniej znaczących wchodzą bity 0, a bity najbardziej znaczące są odrzucane.
Przykład. Jeśli var jest zmienną całkowitą, to instrukcja
var = ((var<<2) + var)<<1;
realizuje pomnożenie var przez 10. Ze względu na odrzucanie bitów najbardziej znaczących rezultat jest poprawny tylko wtedy, gdy 10*var mieści się w zakresie danych tego typu, jakiego jest var.
Operator >>
Operator ten oznacza, przesunięcie w prawo. Rezultatem operacji e1>>e2 jest dana, której reprezentacja powstała z reprezentacji danej e1 po przesunięciu jej w prawo o e2 pozycji. Jeśli przesuwana dana jest typu (unsigned), to bity najmniej znaczące są odrzucane, a na miejsce bitów najbardziej znaczących wchodzą bity 0. W pozostałych przypadkach bity najmniej znaczące także są odrzucane, ale stan bitów najbardziej znaczących zależy od implementacji. W pewnych implementacjach mogą to być bity 0 (przesunięcie logiczne), a w innych powielone bity znaku (przesunięcie arytmetyczne).
Przyklad. Jeśli var jest zmienną typu (unsigned int), to instrukcja
var - = (var>>1)<<1;
realizuje przypisanie zmiennej var danej o wartości równej reszcie z dzielenia var przez 2.
7.6. Operatory relacji
Operatorami relacji są <, >, <= i >=. Operatory te wiążą argumenty od lewej do prawej, jednak znajomość tego faktu jest mało użyteczna, gdyż np. wyrażenie
a<b<c jest równoważne wyrażeniu
a<b ? c<1 : c<0 a nie - jak można by się spodziewać - wyrażeniu
a<b ? b<c :0
Składnia
wyrażenie-z-operatorem-relacji:
wyrażenie < wyrażenie
wyrażenie > wyrażenie
wyrażenie < = wyrażenie
wyrażenie > = wyrażenie
Jeśli relacja jest prawdziwa, to rezultatem operacji < (mniejsze), > (większe), <= (mniejsze lub równe) albo >= (większe lub równe) jest dana o wartości' 1. W przeciwnym razie rezultatem operacji jest dana o wartości 0. W obu przypadkach dane te są typu (int). Przed określeniem prawdziwości relacji są wykonywane typowe konwersje arytmetyczne.
Jeśli relacja dotyczy danych wskazujących, to ma sens jedynie wtedy, gdy obie te dane wskazują elementy tej samej tablicy.
Przykład. Jeśli Uns jest zmienną typu (unsigned int) o wartości 3, to rezultatem relacji
-5 > Uns
jest dana o wartości l. Wynika to z faktu, że przed określeniem prawdziwości relacji jest wykonywana konwersja wyrażenia -5 na daną typu (unsigned int).
7.7. Operatory porównań
Operatorami porównań są = = i !=. Operatory te są dwuargumentowe i wiążą. argumenty od lewej do prawej. Są one analogiczne do operatorów relacji, tyle że mają niższy priorytet.
Składnia
wyraźenie-z-operatorem-porównania:
wyrażenie = = wyrażenie
wyrażenie != wyrażenie
Jeśli porównanie jest prawdziwe, to rezultatem opracowania wyrażenia z operatorem porównania jest dana o wartości 1. W przeciwnym razie rezultatem jest dana o wartości 0. W obu przypadkach dane te są typu (int). Przed określeniem prawdziwości porównania są wykonywane typowe konwersje arytmetyczne.
Jeśli porównanie dotyczy danej wskazującej i danej całkowitej, to wyrażenie jest poprawne, a rezultat jest niezależny od implementacji tylko wtedy, gdy dana całkowita ma wartość 0.
Przykład. Jeśli arr jest zadeklarowane jako wektor, to rezultatem porównania
arr = = &arr[0] jest dana typu (int) o wartości 1.
7.8. Bitowy operator koniunkcji
Bitowym operatorem koniunkcji jest dwuargumentowy operator &. Ponieważ koniunkcja jest operacją łączną i przemienną, wyrażenia zawierające więcej niż jeden operator koniunkcji mogą być przekształcone w wyrażenia matematycznie równoważne.
Przed określeniem wartości koniunkcji są wykonywane typowe konwersje arytmetyczne. Koniunkcja może dotyczyć tylko danych całkowitych.
Składnia
wyrażenie-z-bitowym-operatorem-koniunkcji:
wyrażenie & wyrażenie
Rezultatem opracowania wyrażenia z bitowym operatorem koniunkcji jest dana typu (int), której reprezentacja powstaje z iloczynu logicznego wyznaczonego równolegle na wszystkich bitach reprezentacji argumentów.
Przykład. Jeśli var jest zmienną typu (int) o wartości 10, to rezultatem opracowania wyrażenia.
var & 12
jest dana typu (int) o wartości 8.
7.9. Bitowy operator różnicy symetrycznej
Bitowym operatorem różnicy symetrycznej jest dwuargumentowy operator ^. Ponieważ różnica symetryczna jest operacją łączną i przemienną, wyrażenia zawierające więcej niż jeden operator różnicy symetrycznej mogą być przekształcone w wyrażenia matematycznie równoważne.
Przed określeniem wartości różnicy symetrycznej są wykonywane typowe konwersje arytmetyczne. Różnica symetryczna może dotyczyć tylko danych całkowitych, a jej rezultatem jest dana typu (int).
Składnia
wyraźenie-z-bitowym-operatorem-różnicy-symetrycznej:
wyrażenie ^ wyrażenie
Rezultatem opracowania wyrażenia z bitowym operatorem różnicy symetrycznej jest dana typu (int), której reprezentacja powstaje z różnicy symetrycznej wyznaczonej równolegle na wszystkich bitach reprezentacji argumentów.
Przykład. Jeśli var jest zmienną typu (Int) o wartości 10, to rezultatem opracowania wyrażenia
var ^ 12
jest dana typu (Int) o wartości 6. D
7.10. Bitowy operator sumy logicznej
Bitowym operatorem sumy logicznej jest |. Ponieważ operacja | jest łączna i przemienna, wyrażenia zawierające więcej niż jeden operator | mogą być przekształcone w wyrażenia matematycznie równoważne.
Przed wykonaniem określenia wartości bitowej sumy logicznej są wykonywane typowe konwersje arytmetyczne. Bitowa suma logiczna może dotyczyć tylko danych całkowitych.
Składnia
wyrażenie-z-bitowym-operatorem-sumy-logicznej:
wyrażenie | wyrażenie
Rezultatem opracowania wyrażenia z bitowym operatorem sumy logicznej jest dana typu (int), której reprezentacja powstaje z sumy logicznej wyznaczonej równolegle na wszystkich bitach reprezentacji argumentów.
Przykład. Jeśli var jest zmienną typu (int) o wartości 10, to rezultatem opracowania wyrażenia
var | 12
jest dana typu (int) o wartości 14.
7.11. Operator koniunkcji
Operatorem koniunkcji jest dwuargumentowy operator &&. Operator ten wiąże argumenty od lewej do prawej i w odróżnieniu od operatora & gwarantuje opracowanie wyrażenia w takiej właśnie kolejności. Co więcej, jeśli lewy argument operacji ma wartość różną od zera, to rezygnuje się z opracowania prawego argumentu.
Argumenty operatora koniunkcji nie muszą być tego samego typu, a nawet mogą być typu wskazującego.
Składnia
wyrażenie-z-operatorem-koniwtkcji:
wyrażenie && wyrażenie
Rezultatem opracowania wyrażenia z operatorem koniunkcji jest dana typu (Int). Dana ta ma wartość 1, jeśli oba argumenty mają wartości różne od zera, w przeciwnym razie ma wartość 0.
Przykład. Jeśli ptr jest zmienną wskazującą, to rezultatem operacji
ptr&& getchar() = = 'j'
jest dana o wartości 1, ale tylko wtedy, gdy wartością ptr nie jest wskazanie puste, a znakiem wprowadzonym ze standardowego urządzenia wejściowego jest j. Należy nadmienić, że gdyby ptr miało wartość 0, to rezultatem operacji byłaby dana o wartości 0, a wywołanie funkcji getchar nie zostałoby zrealizowane.
7.12. Operator sumy logicznej
Operatorem sumy logicznej jest dwuargumentowy operator ||. Operator ten wiąże argumenty od lewej do prawej i w odróżnieniu od operatora | zapewnia opracowanie wyrażenia w takiej właśnie kolejności. Co więcej, jeśli lewy argument operacji ma wartość różną od zera, to rezygnuje się z opracowania prawego argumentu.
Argumenty operatora sumy logicznej nie muszą być tego samego typu, a nawet mogą być typu wskazującego.
Składnia
wyrażenie-z-operatorem-sumy-logicznej:
wyrażenie || wyrażenie
Rezultatem opracowania wyrażenia z operatorem sumy logicznej jest dana typu (int). Dana ta ma wartość 1, jeśli przynajmniej jeden argument ma wartość różną od zera, w przeciwnym razie natomiast ma wartość 0.
Przykład. W zasięgu deklaracji
char vec[15], ind;
rezultatem opracowania wyrażenia
ind>14 || ind<0 || !vec[ind]
jest dana o wartości 1, jeśli wartość ind wykracza poza zakres dozwolonych indeksów wektora vec albo jeśli wartość ta mieści się we wspomnianym zakresie, ale element wektora ma wartość 0. Należy nadmienić, że gdyby ind miało np. wartość 20, to indeksowanie wektora vec nie byłoby realizowane. D
7.13. Operator warunkowy
Operatorem warunkowym jest ?:. Operator ten wiąże argumenty od prawej do lewej i jest operatorem trójargumentowym.
Składnia
wyrażenie-z-operatorem-warunkowym:
wyrażenie ? wyrażenie : wyrażenie
Opracowanie wyrażenia z operatorem warunkowym składa się z opracowania wyrażenia poprzedzającego symbol ? i stosownie do jego wartości uznania za rezultat operacji rezultatu opracowania jednego z dwóch pozostałych wyrażeń. Jeśli wyrażenie poprzedzające ? ma wartość różną od zera, to dokonuje się opracowania drugiego wyrażenia. W przeciwnym razie dokonuje się opracowania trzeciego. W ten sposób zawsze rezygnuje się z opracowania jednego wyrażenia, ale za typ rezultatu obiera się typ różnicy po obu stronach symbolu : chyba że są to wyrażenia wskazujące tego samego typu albo wyrażenie wskazujące i literał 0, kiedy to za typ rezultatu obiera się typ wyrażenia wskazującego.
Przykład. W zasięgu deklaracji zmiennej prostej var oraz funkcji
extern int f();
extern float g();
wyrażenie
var ? f(2) : g(4)
jest traktowane tak jak wyrażenie var ? (float)f(2) : g(4)
a jego opracowanie powoduje wywołanie dokładnie jednej z wymienionych tu funkcji f i g.
Ponieważ operator warunkowy wiąże swoje argumenty od prawej do lewej, złożone wyrażenie warunkowe, takie jak np.
a ? b ? c : d : e ? f : g
jest interpretowane tak jak wyrażenie a ? (b ? c : d) : (e ? f : g)
a nie jak wyrażenie
(a? (b? c: d) :e)? f : g
(co miałoby miejsce gdyby operator warunkowy wiązał argumenty od lewej do prawej).
Przykład. Funkcja do wyznaczania największego z jej trzech argumentów max(a,b,c) int a,b,c;
{ return a>b ? a>c ? a:c : b>c ? b:c; }
7.14. Operatory przypisania
W języku C zdefiniowano 11 wymienionych dalej operatorów przypisania. Każdy z tych operatorów jest dwuargumentowy i wiąże argumenty od lewej do prawej.
Lewy argument operatora musi być l-wyrażeniem. Rezultatem opracowania wyrażenia z operatorem przypisania jest dana przypisana lewemu argumentowi. Składnia
wyrażenie-z-operatorem-przypisania:
l-wyrażenle = wyrażenie
l-wyraźenie + = wyrażenie
l-wyrażenie - = wyrażenie
l-wyrażenie *= wyrażenie
l-wyrażenie /= wyrażenie
l-wyraźenie %= wyrażenie
l-wyrażenie >> = wyrażenie
l-wyrażenie <<= wyrażenie
l-wyrażenie &= wyrażenie
l-wyraźenie ^= wyrażenie
l-wyrażenie |= wyrażenie
(złożone operatory przypisania składają się z dwóch jednostek leksykalnych).
Jeśli oba argumenty operacji są typu arytmetycznego, to w przypadku pierwszego z wymienionych tu operatorów przypisania dana stanowiąca rezultat opracowania wyrażenia zostaje poddana konwersji na daną o typie zmiennej reprezentowanej przez l-wyraźenie.
W pozostałych przypadkach wyrażenie o postaci
e1 op= e2 jest traktowane tak jak wyrażenie
e1 =e1 op (e2) z tą różnicą, że opracowanie wyrażenia e1 jest jednokrotne.
Jeśli e1 reprezentuje zmienną typu wskazującego, to poza operatorem = jest dozwolone posługiwanie się tylko operatorami + = i -=. W takich przypadkach obowiązują zasady opisane podczas omawiania operatorów + i -.
Dopuszczone przez wiele implementacji i nie powodujące konwersji przypisywanie danych wskazujących zmiennym całkowitym i odwrotnie oraz niekontrolowanie zgodności typów wskazujących lewej i prawej strony należy uznać za praktykę sprzeczną z duchem języka C. Jedyną bowiem dopuszczalną formą automatycznej konwersji z typu (int) na typ wskazujący jest posłużenie się wyrażeniem, którego prawą stronę stanowi literał 0. Dana reprezentowana przez ten literał zostaje poddana konwersji na unikalną daną wskazującą, której wartością jest wskazanie puste.
Przykład. W zasięgu deklaracji
int Int;
char Chr;
instrukcja
Int += Char = Int = 258;
jest równoważna instrukcji
Int = 258, Char = Int, Int = Int + Char;
W tych implementacjach, w których dane typu (char) są reprezentowane za pomocą 8 bitów, zmienna Char otrzyma wartość 2, a zmienna Int otrzyma wartość 260.
7.15. Operator połączenia
Operatorem połączenia jest , (przecinek). Operator ten jest dwuargumentowy i wiąże argumenty od lewej do prawej.
Składnia
wyrażenie-z-operatorem-polączeiua:
wyrażenie , wyrażenie
Opracowanie wyrażenia z operatorem połączenia składa się z opracowania wyrażenia przed przecinkiem, a następnie opracowania wyrażenia po przecinku. Rezultatem opracowania całego wyrażenia jest rezultat opracowania wyrażenia po przecinku. Typem rezultatu jest typ tego wyrażenia.
Przykład. Funkcja realizująca przepisanie niepustego ciągu znaków różnych od znaku '\0'.
copy(target,source)
char *target,*source;
{ while(*target++ = *source++, *source);}
Użyta tu instrukcja while jest wykonywana tak jak instrukcja
do *target++ = *source; while(*source);
W tych kontekstach, w których przecinek ma znaczenie specjalne, a więc w listach wartości początkowych oraz w listach argumentów i parametrów, nie występuje on w znaczeniu operatora połączenia. Można mu nadać takie znaczenie, czyniąc wyrażenie z operatorem połączenia wyrażeniem pierwotnym.
Przykład. Zmiennej Chr zostaje przypisana dana o wartości 'b'
char Chr = {('j','b')};
Symbol, występuje tu w znaczeniu operatora połączenia. Gdyby opuszczono nawiasy okrągłe, to deklaracja zmiennej Chr byłaby błędna, ponieważ symbol , miałby znaczenie separatora między parą literałów, z których co najwyżej jeden może określać wartość danej przypisywanej zmiennej Chr.
8. Deklaracje
Deklaracje dzielą się na definicyjne - nazywane również definicjami i odwoławcze. Pierwsze z nich służą do tworzenia i definiowania obiektów przetwarzania, drugie nawiązują do deklaracji definicyjnych powodując rozszerzenie zakresu dostępności identyfikatora.
Składnia
deklaracja:
oznaczenia-klasy-i-typu lista-deklaratorów;
oznaczenia-klasy-i-typu;
oznaczenie-klasy oznaczenia-klasy-i-typu
oznaczenie-typu oznaczenia-klasy-i-typu
Lista deklaratorów zawiera wykaz deklarowanych identyfikatorów, natomiast oznaczenia klasy i typu określają ich klasę i typ. Wymaga się, aby oznaczenia klasy i typu nie zawierały sprzecznych ani powtórzonych określeń klasy i typu. Zaleca się, aby oznaczenie klasy występowało przed oznaczeniami typu.
Przykład. Kilka poprawnych deklaracji
extern int Counter;
char BigLetter,SmallLetter;
static long float Value,Size;
auto union name { int arr[3];
char chr;} *ptr[4],**ref;
Zakres obowiązywania deklaracji rozciąga się od jej punktu początkowego aż do końca bezpośrednio obejmującej ją instrukcji grupującej. W przypadku funkcji oraz jej parametrów zakres rozciąga się od punktu początkowego aż do końca definicji funkcji. Punkt początkowy jest w obu tych przypadkach definiowany jako miejsce pierwszego wystąpienia identyfikatora obiektu w jego deklaratorze. Dzięki takiemu ustaleniu poprawna jest m.in. deklaracja struktury str
struct list{
int var;
struct list *link;
}str;
w której deklaracja komponentu link może powoływać się na jeszcze nie w pełni zdefiniowaną strukturę typu (struct list).
Ze względu na występowanie w programach deklaracji przesłaniających, zasięg deklaracji może być mniejszy od zakresu. Ilustruje to następujący program, którego wykonanie powoduje wyprowadzenie napisu jb.
main(){
char var = 'b';
{ char var = 'j';
putchar(var);
}
putchar(var);
}
W programie tym zakres deklaracji pierwszego identyfikatora var rozciąga się od miejsca jego wystąpienia aż do końca programu, natomiast zasięg jest mniejszy od zakresu o tę instrukcję grupującą, w której zawarto deklarację drugiego identyfikatora var.
Przykład. Następująca definicja funkcji jest błędna nie dlatego, że występują w niej kolidujące deklaracje identyfikatorów funkcji, parametru i zmiennej, ale dlatego, że wyrażenie inicjujące występujące z prawej strony operatora przypisania reprezentuje daną o wartości nieokreślonej.
char id(id)
char id;
{ char id = id;
return id;
}
8.1. Oznaczenia klasy pamięci
Oznaczenia klasy pamięci mogą dotyczyć zarówno identyfikatorów danych, jak i identyfikatorów funkcji. Określają one zakres dostępności obiektu oznaczonego danym identyfikatorem, a w przypadku danych określają ponadto warunki utworzenia i sposób traktowania obiektu.
Składnia
oznaczenie-klasy:
auto
statlc
extern
register
typedef
Ostatnie z wymienionych tu oznaczeń klasy nie dotyczy obiektów, lecz służy jedynie do związania identyfikatora z wybraną rodziną obiektów określonego typu i klasy.
Przykład. W zasięgu deklaracji
static int Taiły;
typedef int *IntPtr;
identyfikator Taiły reprezentuje daną typu (int) klasy static, a identyfikator IntPtr odgrywa rolę stówa kluczowego, za pomocą którego można deklarować obiekty, którym można przypisywać dane wskazujące dane typu (int), np.
IntPtr ref[3];
Klasa auto
Obiektami klasy auto mogą być tylko zmienne proste i agregaty zmiennych. Obiekty te są tworzone i inicjowane podczas każdego wykonywania prologu instrukcji grupującej zawierającej deklarację identyfikatora i są usuwane podczas wykonywania epilogu tej instrukcji.
Przykład. Następujący program jest błędny;
main(){
int cnt = 2;
while(cnt- -)
{ auto int Var;
if(cnt)
putchar('j');
else
putchar(Var);
Var = 'b';
}
}
Mimo iż w większości implementacji wykonanie tego programu powoduje wyprowadzenie napisu jb, jest on programem błędnym, ponieważ w chwili drugiego wywołania funkcji putchar zmienna Var ma wartość nieokreśloną.
Klasa static
Obiektami klasy static mogą być stałe, zmienne proste, agregaty zmiennych i funkcje. Obiekty te są tworzone i inicjowane podczas wykonywania prologu programu i są usuwane podczas wykonywania jego epilogu.
Przykład.
Wykonanie następującego programu powoduje wyprowadzenie napisu jb
main(){
int cnt = 2;
while(cnt- -){
static char Chr = 'j';
putchar(Chr);
Chr = 'b';
}
}
Gdyby atrybut static zastąpiono atrybutem auto, to wykonanie programu spowodowałoby wyprowadzenie napisu jj.
Niezależnie od użytego atrybutu klasy, zakresem i zasięgiem deklaracji zmiennej Chr jest instrukcja grupująca stanowiąca ciało instrukcji while.
Klasa extern
Obiektami klasy extern mogą być zmienne proste, agregaty zmiennych i funkcje. Jeśli obiekt zostanie zadeklarowany bez atrybutu klasy, a deklaracja występuje na poziomie definicji funkcji, to deklaracja taka jest uznawana za definiującą, a obiekt jest klasy extern przez domniemanie. Taka deklaracja obiektu o danym identyfikatorze może w zbiorze sekcji programu wystąpić tylko jednokrotnie. Pozostałe deklaracje tego samego identyfikatora z atrybutem extern mają jedynie charakter odwoławczy i mogą występować wielokrotnie.
Podobnie jak obiekty klasy static, obiekty klasy extern są tworzone jednokrotnie, podczas wykonywania prologu programu i są usuwane podczas wykonywania jego epilogu.
Przykład. Wykonanie następującego programu (składającego się z dwóch sekcji) powoduje wyprowadzenie napisu jb:
/* sekcja 1 */ main(){
extern char Chr;
extern fun();
fun();
putchar(Chr);
}
/* sekcja 2 */ int Chr = 'j';
fun()
{ putchar(Chr);
Chr = 'b';
}
Użycie drugiej deklaracji extern jest zbędne, ponieważ wystąpienie niezadeklarowanego identyfikatora w znaczeniu nazwy funkcji kontekstowo deklaruje ten identyfikator jako nazwę funkcji udostępniającej rezultat typu (int), co w danym przypadku prowadzi do domniemania deklaracji
extern int fun();
Klasa register
Obiektami klasy register mogą być tylko zmienne proste i agregaty zmiennych. Z punktu widzenia semantyki programu obiekty klasy register nie różnią się od obiektów klasy auto. Należy jedynie pamiętać, że ponieważ mogą być przechowywane w rejestrach, nie ma sensu wyrażenie, w którym operator & dotyczy podwyrażenia reprezentującego obiekt klasy register. Ponieważ liczba rejestrów komputera jest zazwyczaj ograniczona, w praktycznych implementacjach tylko dane pewnych typów, np. (int) i (char), mogą być przechowywane w rejestrach. Ponadto może okazać się, że tylko kilka pierwszych deklaracji obiektów klasy register jest traktowanych w omówiony tutaj sposób, zaś pozostałe są traktowane tak, jakby były deklaracjami obiektów klasy auto.
Przykład. W następującej funkcji posłużono się klasą register dla przyśpieszenia operacji wykorzystujących parametr funkcji.
fun(ptr)
int *ptr;
{ register int *ref = ptr;
*ref = getchar();
putchar(*ref);
}
Ponieważ klasa register może dotyczyć także i parametrów funkcji, przytoczoną definicję można dodatkowo ulepszyć, nadając jej postać:
fun(ptr) register *ptr;
{ putchar(*ptr = getchar()); }
8.2. Oznaczenia typu
Oznaczenia typu mogą dotyczyć zarówno identyfikatorów danych, jak i identyfikatorów funkcji. Określają one typ obiektu oznaczonego danym identyfikatorem, a w przypadku identyfikatorów funkcji określają typ udostępnianego przez nią rezultatu.
Składnia
oznaczenie-typu:
char
short
int
long
unsigned
float
double
opis-struktury-lub-unii
identyfikator-typu
Słowa kluczowe long, short i unsigned są traktowane jak przymiotniki, co czyni sensownymi tylko takie połączenia, jak
short int
long int
unsigned int
long float
Przyjęto z definicji, że typ (long float) jest identyczny z typem (double). Poza przytoczonymi połączeniami nie są poprawne inne pary oznaczeń typu, a więc nie istnieje w języku np. typ (short float). Jeśli natomiast zostanie opuszczone oznaczenie typu, np. w deklaracji
static Yalue;
to zostanie domniemane oznaczenie typu (int).
8.3. Deklaratory
W obrębie deklaracji może wystąpić jeden deklarator albo lista deklaratorów. Każdy z takich deklaratorów może występować w postaci z inicjacją.
Składnia
deklarator-z-inicjacją:
deklarator = inicjator
Każdy z deklaratorów zawiera identyfikator deklarowanego obiektu, a ponadto może zawierać informacje świadczące o tym, że obiekt jest wektorem, zmienną wskazującą albo funkcją. Ponieważ informacje takie mogą być łączone w złożone zestawy, zbiór deklaratorów języka jest nieskończony.
Składnia
deklarator:
identyfikator
( deklarator )
deklarator [ wyrazenie-stale0 ]
*deklarator
deklarator ()
Zasady wiązania użytych tu operatorów są takie same jak dla wyrażeń.
Przykład. Kilka poprawnych deklaracji:
a. Statyczna zmienna całkowita
static int var;
b. Automatyczna tablica wskazań na dane typu (char)
auto char *arr[3][4];
c. Rejestrowe wskazanie na tablicę typu (char)
register char (*ref)[3][4];
d. Statyczna funkcja udostępniająca rezultat, którym jest wskazanie na wektor elementów typu (float)
static float(*fun())[3];
e. Zmienna wskazująca na wektor wskazań na funkcje udostępniające rezultaty typu wskazanie na daną typu (short int)
shortint *(*(*ptr)[3])();
8.4. Znaczenie przypisywane deklaratorom
Deklaratory służą do deklarowania identyfikatorów. Użycie deklaratora stanowi stwierdzenie, iż każde wystąpienie konstrukcji mającej taką samą postać jak deklarator stanowi odwołanie do obiektu o określonym typie i ustalonej klasie.
Jeśli deklarator składa się z samego identyfikatora, to identyfikator ten reprezentuje obiekt o typie i klasie wynikających z nagłówka oznaczenia.
Przykład. Deklaracja identyfikatora reprezentującego statyczną zmienną skalarną typu (int)
static var;
Nagłówek oznaczenia jawnie określa klasę. Typem domniemanym jest (int).
W przypadku bardziej złożonych deklaratorów konieczne jest niekiedy posłużenie się nawiasami okrągłymi dla określenia innej kolejności wiązań. Jeśli nawiasy są użyte w nadmiarze, to nie powoduje to zmiany semantyki deklaracji.
Przykład. Para deklaracji równoważnych
int *ref[3];
Int *(ref[3]);
Ponieważ priorytet operatora [] jest wyższy od priorytetu operatora *, użycie nawiasów okrągłych jest zbędne.
Rozpatrzmy obecnie deklarację identyfikatora w ogólnej postaci T w której T jest oznaczeniem typu, a D jest deklaratorem. Deklaracja taka orzeka, iż identyfikator zawarty w D reprezentuje:
• obiekt typu (T),
• tablicę obiektów typu (7),
• wskazanie na obiekt typu (T),
• funkcję, której rezultatem jest obiekt typu (T).
Deklarator użyty w przytoczonym kontekście może zostać rozbudowany do postaci (D), D[e], *D i D().
Postać (D)
Semantyka deklaracji nie ulega zmianie, a identyfikator zawarty w (D) reprezentuje taki sam obiekt, jak identyfikator zawarty w D. Posłużenie się nawiasami okrągłymi jest niezbędne jedynie wtedy, gdy ich opuszczenie powoduje zmianę kolejności wiązań operatorów.
Przykłady
a. Para deklaracji nisrównoważnych
char *ptr[3];
char(*ptr)[3];
b. Para deklaracji równoważnych
int(*ptr)[3][4];
int((*ptr)[3])[4];
Postać D[e]
Odpowiednio do tego co reprezentował identyfikator zawarty w D, identyfikator zawarty w D[e] reprezentuje:
• tablicę obiektów typu (T),
• tablicę tablic obiektów typu (T),
• tablicę wskazań na obiekty typu (T),
• tablicę funkcji, których rezultatami są obiekty typu (T).
Wymaga się, aby e było wyrażeniem stałym typu (int) albo aby było puste. Jeśli deklarator zawiera kilka następujących bezpośrednio po sobie napisów o postaci [e], to stanowi to deklarację tablicy wielowymiarowej.
Jeśli deklaracja ma charakter definiujący, to niezbędne jest określenie rozmiaru każdego z wymiarów tablicy. Wyjątek stanowią jedynie deklaratory z inicjacją, gdyż dla nich rozmiar pierwszego wymiaru może być określony na podstawie analizy inicjatora.
Ponieważ w języku C elementy tablic są uporządkowane wierszami, rozmiar pierwszego wymiaru nigdy nie jest potrzebny do wyznaczenia położenia elementu tablicy. Z tego względu w deklaratorach tablic, których identyfikatory są parametrami funkcji zawsze można pominąć rozmiar pierwszego wymiaru.
Przykłady. Użycie deklaratorów o postaci D[e]
a. Deklaracja zmiennej typu (int) oraz tablicy elementów typu (int)
int var;
int arr[3];
b. Deklaracja dwuwymiarowej tablicy o elementach typu (char)
char arr[3][4];
c. Deklaracje tablic za pomocą deklaratorów, w których pominięto rozmiary pierwszych wymiarów
char vec[] = "vera";
int arr[][3] = { 5,6,7,8 }
W deklaratorze wektora vec zostanie domniemany rozmiar 5, a w deklaratorze tablicy arr zostanie domniemany rozmiar 2,
Postać *D
Odpowiednio do tego, co reprezentował identyfikator zawarty w D, identyfikator zawarty w *D reprezentuje
• wskazanie na obiekt typu (T),
• wskazanie na tablicę obiektów typu (T),
• wskazanie pośrednie na obiekt typu (T),
• wskazanie na funkcję, której rezultatem jest obiekt typu (T).
Przykłady. Użycie deklaratorów o postaci *D
a. Deklaracja zmiennej całkowitej typu (int) oraz zmiennej wskazującej obiekty typu (int)
int var;
int *ref;
b. Deklaracja zmiennej wskazującej tablicę elementów typu (char) oraz tablicy wskazań na obiekty typu (char)
char(*ref)[3][4];
char *ptr[3][4];
c. Deklaracja zmiennej wskazującej obiekty wskazujące obiekty typu
float) float **ref;
Postać D()
Odpowiednio do tego, co reprezentował identyfikator zawarty w D, identyfikator zawarty w D() reprezentuje
• funkcję, której rezultatem jest obiekt typu (T),
•
• funkcję, której rezultatem jest wskazanie na obiekt typu (T),
•
(dwa wiersze puste dotyczą przypadków niedozwolonych, tj. funkcji których rezultatami byłyby tablice albo funkcje).
Przykłady. Użycie deklaratorów o postaci D()
a. Deklaracja zmiennej typu (int) oraz funkcji, której rezultatem jest dana typu (int)
int var;
int fun();
b. Deklaracja funkcji, której rezultatem jest dana wskazująca obiekt typu (char) oraz deklaracja zmiennej wskazującej funkcję, której rezultatem jest dana typu (char)
char *fun();
char(*ptr)();
c. Deklaracja zmiennej wskazującej funkcję, której rezultatem jest dana wskazująca tablicę elementów typu (float)
float (*(*ref)())[3][4];
Zabrania się, aby deklaracje struktur i unii zawierały deklaracje funkcji, jak również aby rezultatami funkcji były funkcje i agregaty, a komponentami agregatów funkcje. Przykład. Kilka błędnych deklaracji
int fun()[3]; — rezultatem funkcji jest tablica
int vec[3](); — komponentem wektora jest funkcja
int fun()(); — rezultatem funkcji jest funkcja
union
{ int fun();
} Soviet; — deklaracja unii zawiera deklarację funkcji
8.5. Deklaracje struktur i unii
Struktura jest obiektem składającym się z ciągu nazwanych komponentów. Komponenty mogą być dowolnych typów, ale nie mogą być funkcjami. Unia jest obiektem, który w danej chwili składa się z jednego komponentu należącego do ustalonego i skończonego zbioru komponentów. Opisy struktur i unii różnią się tylko słowami kluczowymi struct i union.
Składnia
opis-struktury-lub-unii:
struct-lub-union { wykaz-komponentów }
stuct-lub-union oznacznik { wykaz komponentów )
struct-lub-union oznacznik struct-lub-union'.
struct union
oznacznik:
identyfikator
Wykaz komponentów składa się z ciągu deklaracji poszczególnych komponentów struktury albo unii, a oznacznik jest identyfikatorem, który po zdefiniowaniu może być używany do uproszczonego powoływania się na strukturę lub unię o określonej budowie. W szczególności oznaczenie typu o postaci
struct id { wykaz-komponentów }
deklaruje identyfikator id jako oznacznik struktury typu (struct { wykaz-komponentów }) dzięki czemu w dalszych deklaracjach struktur o budowie identyfikowanej przez id można ograniczyć się do opisu
struct id
Ponieważ deklaracja id następuje już w momencie jego wystąpienia jako oznacznika, a więc bezpośrednio po słowie kluczowym struct albo union, dopuszczalne jest deklarowanie agregatów, których komponenty odwołują się do typu właśnie deklarowanych struktur albo unii.
Przykłady. Deklarowanie struktur, unii i oznaczników
struct{
int month;
Int day;
} date;
union tag
{ char *name;
char *address;
};
union tag jan,ewa;
struct status{
char married;
int year[3];
} kaja;
struct status vera;
struct{
struct ins{
int var;
}agr;
char chr;
} rec;
struct ins str;
Struktura date jest typu (struct{ int month,day; }) i składa się z komponentów date.month i date.day. Unie Jan i ewa są typu
(union tag) tj. typu
(union{ char *name,*address; })
Unia jan składa się z komponentu jan.name albo z komponentu jan.address, a unia ewa składa się z komponentu ewa.name albo ewa.address. Struktury kaja i vera są tego samego typu (struct status)
tj. typu (struct{ char married; int year[3]; })
Struktura str jest typu (struct ins), tj. typu (struct{ int var; })
Nazwy komponentów unii i struktur nie mogą pokrywać się z identyfikatorami oznaczników, ale mogą pokrywać się z nazwami zmiennych, struktur i tablic. Dwie różne struktury albo unie mogą składać się z komponentów o tych samych nazwach, byle tylko identyczność nazw i typów dotyczyła także komponentów poprzedzających dane komponenty. To poważne ograniczenie języka jest w pewnych implementacjach uznane za niebyłe.
Jak już częściowo wynika z przytoczonych ustaleń, wykazy komponentów zawarte w opisach struktury albo unii składają się z ciągu deklaracji.
Składnia
wykaz-komponentów:
opis-komponentów
opis-komponentów wykaz-komponentów
opis-komponentów:
oznaczenie-typu lista-deklaratorów-komponentów ;
deklarator-komponentu:
deklarator
deklarator : wyraźenie-stałe
: wyrażenie-stale
W typowych przypadkach komponentami struktur i unii są dane albo agregaty danych podstawowych typów. Jeśli deklarator komponentu zawiera symbol :, to komponentem jest dana reprezentowana za pomocą takiej liczby bitów, jaką określa wartość wyrażenia stałego. Jeśli przed tym symbolem nie występuje deklarator, to komponent agregatu jest niedostępny, ponieważ jest pozbawiony nazwy.
Komponent, którego nazwa występuje w deklaratorze przed symbolem :, jest nazywany polem agregatu. Mimo iż w języku C nie wprowadzono żadnych ograniczeń dotyczących pól, w typowych implementacjach pola są danymi całkowitymi bez znaku, a tworzenie tablic pól oraz wykonywanie na polach operacji & jest zabronione.
Przykłady
Deklaracja struktury
struct Complex{ float re,im;
}str;
struct Complex *ptr[3];
Struktura str składa się z komponentów str.re i str.im. Oznacznik Complex identyfikuje struktury o budowie takiej jak struktura str.
W zasięgu przytoczonych deklaracji identyfikator str reprezentuje strukturę składającą się z dwóch zmiennych typu (float), reprezentowanych przez str.re i str.tm, a ptr reprezentuje tablicę zmiennych wskazujących, których wartościami są wskazania na struktury typu (Complex) zdefiniowanego podczas deklarowania struktury str. Ponieważ definiowanie oznacznika Complex może być rozłączne z deklarowaniem struktury str, przytoczone deklaracje mogłyby zostać zapisane w postaci równoważnej jako
struct Complex{ float re,im;};
struct Complex str,* ptr[3];
b. Deklaracja unii
union{
float Value;
int lndex;
} Trade;
W każdej chwili wykonywania programu zawierającego przytoczoną deklarację istnieje co najwyżej jedna z pary zmiennych Trade. Value i Trade.lndex.
c. Deklaracje pól
struct{ int paper:3,ink:3;
int bright:1;
char *text;
}display;
Nazwy display.paper, display.ink i display.bright reprezentują pola. Nazwa display.text reprezentuje daną wskazującą na dane typu (char).
Komponenty struktury są rozmieszczone w pamięci operacyjnej w kolejności ich wystąpienia w deklaracji struktury i z uwzględnieniem wymagań wynikających z ich typu. Z tego względu w tych implementacjach, w których dane typu (float) muszą być umieszczone na granicy słowa, posłużenie się deklaracją
struct{
float head;
char mid;
float tail;
}str[3];
spowoduje powstanie niewykorzystanych i niedostępnych obszarów pamięci operacyjnej.
Sposób rozmieszczania pól jest odmienny. Sąsiadujące pola są upakowywane w słowach maszynowych, a każde z pól musi się mieścić w jednym słowie. Pola mogą być umieszczone w pamięci w takim porządku, w jakim wystąpiły w deklaracji struktury, albo w porządku odwrotnym. Jeśli pole nie mieści się w pewnym słowie, to jest przenoszone do następnego. Przejście do następnego słowa można także wymusić, posługując się deklaracją pola bez nazwy i z wyrażeniem stałym o wartości 0.
Przykład. Jeśli w pewnej implementacji słowo maszynowe jest 32-bitowe, a dane typu (int) są reprezentowane za pomocą 16 bitów i muszą być umieszczane na granicy słowa, to dla struktur typu (struct StrType)
struct StrType{
int head;
int :0;
int tail:2;
};
między komponentami head i taił występuje nienazwane pole, które zajmuje 16 bitów.
8.6. Przypisywanie danych początkowych
Jeśli po deklaratorze identyfikatora występuje inicjator, to oznacza to, że obiektowi reprezentowanemu przez ten identyfikator ma zostać przypisana dana o określonej wartości początkowej. Przypisanie to następuje w momencie utworzenia obiektu.
Jeśli identyfikator reprezentuje zmienną prostą arytmetyczną albo wskazującą, to dana przypisywana tej zmiennej stanowi rezultat opracowania wyrażenia zawartego w inicjatorze. Jeśli identyfikator reprezentuje agregat, to inicjator składa się z ujętej w nawiasy klamrowe listy inicjatorów, a dana przypisywana agregatowi składa się z komponentów, których wartości są określone przez elementy wspomnianej listy. Zabrania się stosowania inicjatorów bezpośrednio po deklaratorach unii oraz po deklaratorach agregatów klasy auto.
Składnia
inicjator:
wyrażenie
{ wyrażenie }
{ lista-inicjatorów }
{lista-inicjatorów ,}
Jeśli wyrażenia występujące w inicjatorze dotyczą obiektów klasy static albo extern, to każde z nich musi być wyrażeniem stałym albo ograniczonym wyrażeniem wskazującym. To ostatnie musi redukować się do wyrażenia wskazującego na uprzednio zadeklarowany obiekt lub jego komponent albo musi redukować się do takiego właśnie wyrażenia, od którego odjęto albo do którego dodano wyrażenie stałe.
W odróżnieniu od inicjatorów obiektów klasy static i extern inicjatory obiektów klasy auto i register mogą zawierać dowolne wyrażenia. Jeśli typy wyrażeń występujących w inicjatorze nie są identyczne z typami inicjowanych obiektów i ich komponentów, to przeprowadzane są typowe konwersje arytmetyczne oraz konwersje danych reprezentowanych przez liczby 0 na odpowiednie dane wskazujące.
Przykłady
a. Przypisywanie danych początkowych zmiennym prostym
int Var =5*5;
static char arr[5],
*ref = (&arr[4] - &arr[2])/2 + &arr[2];
fun(par) int par;
{ auto res = Var * Var;
return res + par;
}
Zmiennej Var zostanie przypisana dana początkowa o wartości 25, zmiennej ref zostanie przypisana dana początkowa, której wartością jest wskazanie na arr[3], a zmiennej res zostanie przypisana dana początkowa o wartości 625.
b. Przypisywanie danych początkowych tablicom
static int arr[2][3] = { { 5, 8, 4 },{2,3,7}}, vec[3] = {-5, 3, -4};
Komponentom pierwszego wiersza tablicy arr zostaną przypisane dane początkowe o wartościach 5, 8 i 4, a komponentom drugiego wiersza tej tablicy zostaną przypisane dane początkowe o wartościach 2, 3 i 7. Komponentom wektora vec zostaną przypisane dane początkowe o wartościach -5, 3 i -4.
c. Przypisywanie danych początkowych strukturom
static struct{ char chr;
struct{ int var;
float arr[2] ;
} num[2];
int fix;
}str={‘j’,{{2,{3.0,4.0}}.
{5,{6e2,7e2}}
},8};
Komponentowi str.num[0].var przypisano daną o wartości początkowej 2, a komponentowi str.num[1].arr[1] przypisano daną o wartości początkowej stanowiącej przybliżenie liczby 700.0.
Ponieważ zapis inicjatorów zawierających pełen zestaw nawiasów klamrowych jest niekiedy dość uciążliwy, dopuszczono w języku C kilka uproszczeń.
Uproszczenia te wynikają z faktu, iż w pełnym zapisie inicjatora każdemu skalarnemu komponentowi agregatu odpowiada w liście inicjatorów pewne wyrażenie, a każdemu agregatowi odpowiada lista inicjatorów ujęta w nawiasy klamrowe. Te dwie zasady są stosowane rekurencyjnie, dzięki czemu kolejne poziomy nawiasów klamrowych wytyczają podział inicjatora analogiczny do hierarchicznego podziału agregatu na jego komponenty. Z tego względu z pełnego zapisu inicjatora mogą być usunięte wszystkie wewnętrzne nawiasy klamrowe.
Innym uproszczeniem jest możliwość usunięcia z dowolnych nawiasów klamrowych tych końcowych wyrażeń, które są równoważne liczbom 0, jako że o takie właśnie liczby zostaną przez domniemanie wydłużone zbyt krótkie listy inicjatorów.
Domniemanie liczb 0 jest stosowane także w odniesieniu do obiektów klasy static oraz extern, które wystąpiły w deklaracji bez inicjatora. Deklaracje takich obiektów są traktowane tak, jakby użyto inicjatora w pełnym zapisie z wszystkimi wyrażeniami o postaci liczb 0.
Dogodnym uproszczeniem jest także możliwość przedstawienia inicjatora jednowymiarowej tablicy znakowej w postaci tekstu znakowego, który jest wówczas traktowany tak, jak ujęta w nawiasy klamrowe lista tworzących go znaków. W takim przypadku kolejne znaki tego tekstu określają wartości początkowe danych przypisanych kolejnym elementom tablicy, a deklaracja taka jak np.
char vec[3] = "jb";
zostaje uznana za równoważną deklaracji char vec[3]= {'j'.'b','\0'};
Ostatnim uproszczeniem jest możliwość domniemania, na podstawie listy inicjatorów, rozmiaru pierwszego wymiaru. Za przykład takiego domniemania może służyć deklaracja
int arr[][3]={{1,2,3},{4,5,6}};
równoważna deklaracji
int arr[2][3] = { {1,2,3},{4,5,6} };
Biorąc pod uwagę większość przytoczonych tu uproszczeń, pozostaje wyjaśnić, że deklaracja z inicjatorem w pełnym zapisie, o postaci
char arr[2][3] = { {'1','2','\0'}'{'3','\0','\0'} };
jest równoważna każdej z następujących deklaracji
char arr[][3]={{‘1’,'2'},{'3'}};
char arr[2][3] = {"12","3"};
ale nie jest równoważna deklaracji
char arr[2][3] = "12\03\0\0";
ponieważ jest ona błędna (inicjator o postaci literału tekstowego nie dotyczy tablicy jednowymiarowej).
Przykłady. Uproszczenia zapisu inicjatorów
Opuszczanie nawiasów klamrowych obejmujących listy inicjatorów oraz opuszczanie wyrażeń równoważnych liczbom 0
struct{ int arr[2][3], vec[2];
}str= {{1,2,0}, {0,0,0}, {7,0}};
Użyty tu inicjator mógłby zostać zastąpiony inicjatorem
{1,2,0,0,0,0,7,0} jak również inicjatorem
{1,2,0,0,0,0,7} oraz inicjatorem
{{1,2},{0},7}
b. Domniemanie inicjatorów z liczbami zero dla obiektów klasy static i extern static struct{
int *ptr[2];
char chr;
float num[2];
} str;
Deklaracja struktury str jest traktowana tak, jakby wystąpił w niej inicjator
{ { 0,0 }, 0, { 0,0 } } równoważny w danym kontekście inicjatorowi
{0}
c. Przedstawienie inicjatora tablicy znakowej w postaci tekstu znakowego
char arr[3] = "jb";
Przytoczona deklaracja jest równoważna deklaracji
char arr[3]={'j','b','\0'};
oraz deklaracji
char arr[3]={'j','b'};
d. Domniemanie rozmiaru na podstawie analizy inicjatora
char arr[][4] » {"Jan","Ewa","lza"};
Przytoczona deklaracja jest równoważna deklaracji
char arr[3][4] = { {‘J','a’,'n’,’\0'},
{‘E','w’,'a’,’\0'},
{‘I','z’,'a’,’\0'}}
8.7. Nazwy typów
Nazwy typów występują w języku C w dwóch kontekstach:
w operatorach konwersji oraz w argumentach operatora sizeof.
Składnia
nazwa-typu:
oznaczenie-typu pseudodeklarator
pseudodeklarator:
puste
(pseudodeklarator)
*pseudodeklarator
pseudodeklarator()
pseudodeklarator [ wyrażenie-stale ]
puste:
Dla uniknięcia niejednoznaczności zabrania się ujmowania w nawiasy okrągłe pseudodeklaratora, którym jest puste. Dzięki temu założeniu można jednoznacznie określić takie miejsce w deklaratorze, że umieszczenie tam identyfikatora przekształci pseudodeklarator w deklarator. Typ reprezentowany przez nazwę typu uznawany jest wówczas za identyczny z typem wspomnianego identyfikatora.
Przykład. Jeśli nazwa typu ma postać
char *(*())[3] to przekształcenie jej w deklarację
char *(*id())[3];
umożliwia stwierdzenie, że id reprezentuje funkcję, której rezultatem jest dana o wartości stanowiącej wskazanie na 3-elementowe wektory wskazań na dane typu (char). Stąd wnioskujemy, że obiekty typu (char *(*())[3]) są funkcjami, których rezultatami są dane wskazujące na 3-elementowe wektory wskazań na dane typu (char).
8.8. Oznaczenie klasy typedef
Deklaracje, w których oznaczenie klasy ma postać typedef nie określają sposobu przydzielania pamięci, a jedynie definiują identyfikatory, które mogą być później używane tak, jakby stanowiły słowa kluczowe identyfikujące podstawowe i pochodne typy danych.
Składnia
identyfikator-typu:
identyfikator
Zasadę definiowania identyfikatorów typów najlepiej wyjaśnić na przykładzie. Jeśli posłużono się przykładowymi deklaracjami nazw typów
typedef char (*PtrVec)[3];
typedef char Vec[2];
typedef struct{ float re,im; } Cplx;
to w zasięgu tych deklaracji konstrukcje
PtrVec vec,arr[4];
Vec *ref;
Cplx num;
są odpowiednio równoważne deklaracjom
char (*vec)[3], (*arr[4])[3];
char(*ref)[2];
struct{ float re.im;
} num;
(na szczególną uwagę zasługuje użycie nawiasów okrągłych w deklaracji identyfikatora ref).
Ponieważ deklaracje nazw typów nie służą do tworzenia nowych typów, a jedynie związują arbitralnie wybrane identyfikatory z typami już istniejącymi, w zasięgu deklaracji typu takiej jak np.
typedef float real;
typy (float) i (real) są nieodróżnialne. Przykład. Jeśli w zasięgu deklaracji
typedefint *IntPtr;
IntPtr ptr;
int *ref;
posłużono się instrukcją
ptr = (lntPtr)(ref+2);
to może być ona uproszczona do
ptr = ref + 2;
jako że wyrażenie ref + 2 jest typu (IntPtr).
9. Instrukcje
Jeżeli opis nie stanowi inaczej, instrukcje są wykonywane w kolejności ich wystąpienia w programie.
9.1. Instrukcja składająca się z wyrażenia
Większość instrukcji programu ma postać wyrażenia, bezpośrednio po którym następuje średnik. Przykładami takich instrukcji są wywołania funkcji i przypisania.
Składnia
instrukcja-wyrażenie:
wyrażenie,
Wykonanie takiej instrukcji składa się z opracowania zawartego w niej wyrażenia i odrzucenia rezultatu tego opracowania.
Przykład. Jeśli var jest identyfikatorem zmiennej, to wykonanie instrukcji 3 + (var = 5);
składa się z przypisania zmiennej var danej o wartości 5 i odrzucenia danej o wartości 8.
9.2. Instrukcja grupująca
Konstrukcję nazwaną tu instrukcją grupującą, której ciało stanowi sekwencja lokalnych deklaracji i instrukcji, wprowadzono do języka po to, aby w miejscach, w których składnia wymaga użycia jednej instrukcji, można było użyć ich więcej.
Składnia
instrukcja-grupująca:
{ zestaw-deklaracji zestaw-instrukcji }
zestaw-deklaracji:
deklaracja
deklaracja zestaw-deklaracji
zestaw-instrukcji:
instrukcja
instrukcja zestaw-instrukcji
Wykonanie instrukcji grupującej składa się z sekwencyjnego wykonania zawartych w niej instrukcji. W ramach wykonania prologu instrukcji grupującej następuje utworzenie i zainicjowanie obiektów klasy auto i register zadeklarowanych w zestawie deklaracji. W ramach wykonania epilogu następuje usunięcie tych obiektów.
Ponieważ instrukcja grupująca stanowi blok, ponowne deklaracje identyfikatorów, już zadeklarowanych w otoczeniu instrukcji grupującej, mają znaczenie lokalne i w zakresie instrukcji grupującej przesłaniają deklaracje z jej otoczenia.
Jeśli instrukcja grupująca zawiera deklaracje zmiennych klasy auto albo register, to ewentualne inicjacje tych zmiennych odbywają się każdorazowo podczas wykonywania prologu tej instrukcji. W przypadku zmiennych klasy static inicjacje takie odbywają się jednokrotnie, w ramach wykonania prologu programu. Jeśli instrukcja grupująca zawiera deklaracje zmiennych klasy extern, to są to jedynie deklaracje odwoławcze, a jako takie nie mogą występować w postaci z inicjacją.
Przykład. Użycie instrukcji grupującej w kontekście, w którym dozwolone jest użycie tylko pojedynczej instrukcji
if(var){
register int num = var * 3;
printf("%d",num);
var = 0;
}
9.3. Instrukcja warunkowa
Instrukcja ta umożliwia warunkowe wykonanie dowolnej instrukcji języka. Składnia
instrukcja-warunkowa:
if( wyrażenie) instrukcja
if( wyrażenie ) instrukcja else instrukcja
Wykonanie instrukcji warunkowej składa się z opracowania wyrażenia, a następnie - jeśli rezultat jest daną o wartości różnej od zera - wykonania instrukcji występującej po nawiasie zamykającym. Jeśli rezultat jest daną o wartości 0, to w pierwszym przypadku wykonanie instrukcji warunkowej uznaje się za zakończone, a w drugim wykonuje się instrukcję występującą po else.
Jeśli w instrukcji warunkowej jest zawarta inna instrukcja warunkowa, to uznaje się, że kolejne else jest skojarzone z ostatnim if, z którym nie skojarzono else.
Przykład. W instrukcji warunkowej
if (ref)
if(ptr)
var = 2;
else
var = 3;
else
var = 4;
skojarzenia else z if są takie jak pokazano za pomocą wcięć.
9.4. Instrukcja while
Instrukcja while jest instrukcją złożoną wytyczającą cykl. Składnia
instrukcja-while:
while( wyrażenie ) instrukcja
Wykonanie instrukcji while przebiega wg poniższego algorytmu:
1. Określany jest rezultat opracowania wyrażenia,
2. Jeśli rezultat ten jest daną o wartości 0, to wykonanie instrukcji while uważa się za zakończone.
3. Jeśli rezultat jest daną o wartości różnej od 0, następuje wykonanie instrukcji po nawiasie zamykającym, a następnie powtórzenie opisanych czynności od początku.
Przykład. Funkcja do sumowania elementów listy fun(ptr) struct list{ struct list *link;
int val;
} *ptr;
{ register sum = 0;
while(ptr)
sum += ptr->val, ptr = ptr->link;
return sum;
}
9.5. Instrukcja do
Instrukcja do jest instrukcją złożoną wytyczającą cykl.
Składnia
instrukcja-do:
do instrukcja while( wyrażenie),
Wykonanie instrukcji do przebiega wg poniższego algorytmu:
1. Wykonywana jest instrukcja występująca po słowie kluczowym do.
2. Określany jest rezultat opracowania wyrażenia.
3. Jeśli rezultat ten jest daną o wartości 0, to wykonanie instrukcji do uważa się za zakończone.
4. Jeśli rezultat jest daną o wartości różnej od 0, to następuje powtórzenie opisanych czynności od początku.
Przykład. Funkcja udostępniająca najbliższy znak różny od spacji
skip(par) char *par;
{ register *ref = par;
do *ref = getchar();
while(*ref==’ ‘);
return *ref;
}
Przytoczona funkcja może być także przedstawiona w postaci
skip(par) char *par;
{ register *ref;
do
while((*ref = getchar()) == ' ');
return *ref;
}
9.6. Instrukcja for
Instrukcja for jest instrukcją złożoną wytyczającą cykl. Składnia
instrukcja-for:
for (wyrażenie-a;
wyrażenie-b ;
wyrażenie-c) instrukcja
Jeśli w instrukcji for występuje wyrażenie-b, to jest ona równoważna parze instrukcji
wyrażenie-a, while ( wyrażenie-b )
{
instrukcja
wyrażenie-c,
}
Jeśli wyrażenie-b zostanie opuszczone, to przez domniemanie przyjmuje się, że jest nim liczba 1.
Przykład. Funkcja do znajdowania wskazania na ostatnie wystąpienie znaku w zadanym ciągu znakowym.
char *last(string,chr) char *string,chr;
{ register char *ptr;
char *match;
for(match = 0, ptr == string; *ptr;)
if(*ptr+ + = = chr) match = ptr - - 1;
return match;
}
9.7. Instrukcja switch
Instrukcja switch jest instrukcją złożoną mającą charakter instrukcji wyboru.
Składnia
instrukcja-switch:
switch ( wyrażenie) instrukcja
przedrostek:
case wyrażenie-stale : instrukcja
default : instrukcja
Wykonanie instrukcji switch składa się z opracowania wyrażenia, a następnie kontynuowania wykonywania programu od tej instrukcji zawartej w instrukcji switch, która jest poprzedzona przedrostkiem
case wyrażenie-stale:
albo
default:
W ogólnym przypadku rezultat opracowania wyrażenia jest porównywany z rezultatami opracowań kolejnych wyrażeń stałych zawartych w przedrostkach należących do danej instrukcji decyzyjnej, a kontynuowanie wykonywania programu rozpoczyna się od instrukcji następującej po pierwszym przedrostku, dla którego zostanie stwierdzona równość wartości wspomnianych rezultatów.
Jeśli równość taka nie wystąpi, to kontynuowanie wykonywania programu rozpoczyna się od instrukcji następującej po przedrostku default:. Jeśli przedrostek taki nie występuje, to wykonanie instrukcji switch zostaje uznane za zakończone.
Należy zaznaczyć, że opisane tu wyrażenia występujące w instrukcji switch muszą być typu (Int), a rezultaty opracowań wyrażeń stałych muszą być różne. Jeśli instrukcja switch ma postać
switch( wyrażenie) instrukcja-grupująca
to ewentualne inicjacje występujące w deklaracjach rozpoczynających ciało instrukcji grupującej nie są brane pod uwagę.
W odróżnieniu od wielu innych języków programowania, w języku C wykonanie ciągu instrukcji następujących po wybranym przedrostku nie wyklucza wykonania ciągu instrukcji związanych z następnym przedrostkiem. Aby tego uniknąć, można posłużyć się instrukcją break.
Przykład.
Wykonanie instrukcji
switch(val){
case2: printf("*");
default: printf("*");
case3: printf("*");
} jest równoważne wykonaniu instrukcji
switch(val){
case2: printf(" ***");
break;
case3: printf("*");
break;
default: printf("**");
)
9.8. Instrukcja break
Instrukcja break umożliwia zakończenie wykonywania bezpośrednio obejmującej ją instrukcji złożonej while, do, for albo switch.
Składnia
instrukcja-breuk :
break ;
Po wykonaniu instrukcji break dalsze wykonywanie programu przebiega tak, jakby właśnie zostało zakończone wykonywanie instrukcji while, do, for albo switch bezpośrednio obejmującej daną instrukcję break.
Przykład. Jeśli arr jest tablicą zadeklarowaną jako
int arr[5][5];
a zmienne sum, linę, col i fig są całkowite, to wykonanie instrukcji
for(sum=0, line=5; line - -;)
for(col=5; col - -;)
if(!arr[line][col])
sum++;
else break;
powoduje nadanie zmiennej sum takiej samej wartości, jak wykonanie instrukcji
for(sum==0, line=5; line - - ;)
for(flg=1,col=5;col - - &&flg;)
if(flg = !arr[line][col]) sum++ ;
9.9. Instrukcja continue
Instrukcja continue umożliwia przejście do wykonania czynności warunkujących ponowne wykonanie bezpośrednio obejmującej ją instrukcji while, do albo for.
Składnia
instrukcja-continue:
continue ;
W zależności od tego jaka instrukcja cyklu bezpośrednio obejmuje instrukcję continue, wykonanie instrukcji
continue;
może w każdym z trzech następujących przypadków być zastąpione wykonaniem instrukcji goto lab;
Cykl while: Cykl do: Cykl for:
while(...){ do { for(...){
... ... ...
lab: ; lab: ; lab: ;
} }while(...); }
Przykład. Funkcja do wyznaczania liczby spacji kończących w wektorze o zadanym rozmiarze
count(vec,size)
char vec[],size;
{ int sum = 0;
for( ;size- - ; sum + +)
{ if(vec[size] = = ‘ ‘)
continue;
break;
}
return sum;
}
Instrukcja for jest wykonywana tak, jak instrukcja
loop:
while (size - -){
if(vec[size] = = ‘ ‘)
{ sum++;
goto loop;
}
break;
}
9.10. Instrukcja return
Instrukcja return umożliwia zakończenie wykonywania funkcji.
Składnia
instrukcja-return:
return ;
return wyrażenie ;
Wykonanie instrukcji return powoduje zakończenie wykonywania funkcji. Jest ono poprzedzone wykonaniem epilogów wszystkich obejmujących ją instrukcji grupujących.
Jeśli instrukcja return ma pierwszą z wymienionych postaci, to rezultatem wywołania funkcji jest dana o wartości nieokreślonej. Jeśli instrukcja return zawiera wyrażenie, to rezultatem wywołania funkcji jest rezultat opracowania tego wyrażenia poddany ewentualnej konwersji na daną typu zadeklarowanego w nagłówku funkcji.
Jeśli ostatnią instrukcją ciała funkcji nie jest instrukcja return, to przed nawiasem. klamrowym zamykającym definicję funkcji domniemywa się instrukcję return;
Przykład. Rezultatem wywołania funkcji
fłoat convert()
{ return 5; }
jest dana typu (float) o wartości będącej przybliżeniem wartości liczby 5.0.
9.11. Instrukcja goto
Instrukcja goto umożliwia odstąpienie od zasady sekwencyjnego wykonywania następujących po sobie instrukcji programu.
Składnia
instrukcja-goto:
goto identyfikator;
Wykonanie instrukcji goto powoduje przejście do wykonywania instrukcji opatrzonej etykietą identyczną z identyfikatorem wymienionym w tej instrukcji.
Wymaga się, aby identyfikatorowi odpowiadała etykieta zawarta w tej samej funkcji. w której występuje instrukcja goto. Nie zabrania się, aby wykonanie instrukcji goto spowodowało przejście do wnętrza instrukcji grupującej, w której nie jest zawarta dana instrukcja goto. W takim przypadku nie nastąpi przypisanie danych początkowych tym zmiennym, które są inicjowane w danym bloku.
Przykład. Sekwencja instrukcji
goto inside;
{ int var = 5;
inside:
printf("%d",var);
)
jest niepoprawna nie z powodu wykonania instrukcji goto wiodącej do wnętrza instrukcji grupującej, ale z powodu próby odwołania się do danej o wartości nieokreślonej.
9.12. Instrukcja opatrzona etykietą
Każda instrukcja programu może być poprzedzona etykietą albo ciągiem etykiet. Etykiety oddziela od siebie oraz od instrukcji programu symbol :.
Składnia
instrukcja-opatrzona-etykietą.
przedrostek-etykietowy instrukcja
przedrostek-etykietowy:
etykieta :
etykieta:
identyfikator
Wystąpienie w przedrostku etykietowym identyfikatora deklaruje go kontekstowo jako etykietę. Zakresem takiej deklaracji jest najszersza instrukcja grupująca definicji funkcji obejmującej dany przedrostek etykietowy. Zasięgiem jest zakres pomniejszony o zbiór tych instrukcji grupujących, w których wystąpiły deklaracje identyfikatorów o postaci rozpatrywanej etykiety.
Przykład. Instrukcja
label: {
label:
putchar('*');
}
jest niepoprawna, ponieważ w zawierającej ją funkcji występują dwie etykiety label. Należy zauważyć, że w większości innych języków programowania analogiczny fragment programu byłby poprawny.
9.13. Instrukcja pusta
Instrukcja pusta umożliwia określenie czynności, których wykonanie nie będzie miało żadnych skutków.
Składnia
instrukcja-pusta:
;
Przykład. Następująca instrukcja zawiera jedną instrukcję pustą.
for( ; count - - ;) ;
12. Dyrektywy preprocesora
Kompilator języka C zawiera preprocesor przystosowany do wykonywania makro-substytucji, kompilacji warunkowej i włączania zawartości wskazanych plików. Dyrektywy preprocesora są zawarte w wierszach rozpoczynających się od znaku #. Wiersze takie mogą wystąpić w dowolnym miejscu tekstu źródłowego, a zakres zdefiniowanych w nim identyfikatorów jest niezależny od zakresu identyfikatorów programu i w ogólnym przypadku obejmuje cały tekst źródłowy.
Jeśli pewna dyrektywa nie mieści się w wierszu, to może być kontynuowana wg zasad jak dla literałów znakowych i tekstowych.
Przykład. Jeśli kreska ukośna jest ostatnim znakiem wiersza, to w dwóch następujących wierszach
#define ewa "\
#define jan"""
zapisano dyrektywę
#define ewa"#define jan""" D
12.1. Makrosubstytucja
Wystąpienie w tekście źródłowym dyrektywy o postaci
#define identyfikator ciąg-jednostek-leksykalnych
powoduje, że każde następne użycie identyfikatora zostanie zastąpione ciągiem jednostek leksykalnych.
Przykład. Rozwinięciem tekstu źródłowego
main(){
#define put putchar(
#defme end )
put'*'end;
} jest tekst
main(){
putchar('*');
}
bardziej złożone makrodefinicje można tworzyć za pomocą dyrektyw o postaci
# define identyfikator(llsta-parametrów) cląg-jednostek-leksykalnych
Wystąpienie takiej makrodefinicji powoduje, że każde następne użycie identyfikatora w kontekście
identyfikator{lista-argumentów)
zostanie zastąpione ciągiem jednostek leksykalnych, w którym każdy identyfikator parametru zostanie zastąpiony odpowiadającym mu argumentem.
Dla odróżnienia makrodefinicji z listą parametrów od makrodefinicji bez takiej listy wymaga się, aby między identyfikatorem a nawiasem otwierającym nie występował odstęp.
Przykład. W zasięgu makrodefinicji
#define div(num,den) num/den rozwinięciem wiersza
printf("%d",div((a+b)*c,2));
jest wiersz
printf("%d",(a+b)*c/2);
natomiast w zasięgu makrodefinicji
#deflne div (num,den) num/den rozwinięciem tego samego wiersza jest wiersz
printf("%d",(num,den) num/den((a+b)*c,2)); D
Jak w każdej liście, tak i w liście argumentów makrowywołania, poszczególne argumenty są oddzielone przecinkami. Jeśli jednak przecinki znajdują się wewnątrz nawiasów okrągłych albo literałów znakowych i tekstowych, to nie są uznawane za separatory argumentów. Co więcej, w obrębie literałów znakowych i tekstowych nie przeprowadza się makrosubstytucji.
Przykład. W zasięgu makrodefinicji
#define comma(par1,par2) parł1==par2
rozwinięciem wiersza
if(fun comma((a,b),","))break;
jest wiersz
if(fun (a,b)==",")break; D
Niezależnie od formy użytej dyrektywy #defme, bezpośrednio po dokonaniu rozwinięcia wiersza źródłowego następuje przejrzenie go od początku dla dokonania. ewentualnych dalszych makrosubstytucji.
Przykład. W zasięgu makrodefinicji
#define add (x,y) div(x,y) + 2
#define div(x,y) x/y rozwinięciem wiersza
a = add(b + c,d) jest wiersz
a = b + c/d + 2 Jeśli intencją było uzyskanie rozwinięcia
a = (b + c)/d + 2 to definicja identyfikatora div powinna zostać zmieniona do postaci
#definediv(x,y) (x)/(y) a jeszcze lepiej do postaci
#define div(x,y) ((x)/(y))
jako że dopiero ta ostatnia gwarantuje uzyskanie zamierzonego rozwinięcia w każdym kontekście.
W tych sytuacjach, gdy pożądane jest ograniczenie zakresu makrodefinicji tylko do pewnego fragmentu tekstu źródłowego, można posłużyć się dyrektywą o postaci
#undef identyfikator
Bezpośrednio po wykonaniu takiej dyrektywy użyty w niej identyfikator przestaje być oznaczeniem makrodefinicji.
Przykład. Rozwinięciem tekstu źródłowego
main(){
int var =3;
#define var 5
printf("%d %d",var
#undef var
,var);
}
jest program, którego wykonanie powoduje wyprowadzenie liczb 5 i 3.
12.2. Włączanie zawartości plików
Wykonanie dyrektywy preprocesora o postaci
#include "nazwa-pliku"
powoduje zastąpienie jej całą zawartością pliku o podanej nazwie. Plik taki jest w pierwszej kolejności poszukiwany w tym katalogu, z którego pochodzi pierwotny tekst źródłowy, a jeśli go tam nie ma, to jest poszukiwany w ustalonym zbiorze miejsc standardowych. Jeśli poszukiwanie pliku ma ograniczyć się do miejsc standardowych, to omawianą dyrektywę należy zastąpić dyrektywą
#include <nazwa-pliku>
Przykład. Jeśli tekst źródłowy programu ma postać
#include <stdio.h>
main(){
printf("%s","janb");
}
to program skierowany do kompilacji składa się z tekstu znajdującego się w pliku stdio.h oraz z tekstu stanowiącego definicję funkcji main.
Nic nie stoi na przeszkodzie, aby tekst zastępujący dyrektywę
#include także zawierał dyrektywy #include. Jedynym wymaganiem jest, aby ciąg takich włączań prowadził do wyeliminowania dyrektyw #include.
12.3. Kompilacja warunkowa
Dyrektywy kompilacji warunkowej są dyrektywami strukturalnymi składającymi się z poddyrektyw nagłówka, alternatywy i zakończenia. W ogólnym przypadku dyrektywy takie występują w kontekście
poddyrektywa-nagłówka
tekst-źródłowy-1
poddyrektywa-alternatywy
tekst-źródlowy-2
poddyrektywa-zakończenia
przy czym poddyrektywa-alternatywy wraz z następującym po niej tekstem źródłowym może być opuszczona.
Składnia
poddyrektywa-naglówka:
#ifdef identyfikator
#ifndef identyfikator
#if wyrażenie
poddyrektywa-alternatywy:
#else
poddyrektywa-zakończenia:
#endif
Poddyrektywy ifdef i ifndef służą odpowiednio do sprawdzenia, czy wymieniony w nich identyfikator jest, czy nie jest nazwą makrodefinicji.
Poddyrektywa if służy do sprawdzenia, czy występujące w niej wyrażenie ma wartość różną od zera.
Jeżeli rezultatem sprawdzenia jest dana o wartości logicznej prawda, to cała dyrektywa kompilacji warunkowej zostaje zastąpiona tekstem-źródłowym-1, a w przeciwnym razie tekstem-źródlowym-2 (albo tekstem pustym, jeśli nie użyto alternatywy).
Należy nadmienić, że nic nie stoi na przeszkodzie, aby teksty źródłowe zawarte w dyrektywie kompilacji warunkowej także zawierały dyrektywy kompilacji warunkowej.
Przykład. W zasięgu makrodefinicji
#define c -1
#define testA
tekst źródłowy
#if c+2
#ifdef testA
if(a)
#else
if(b) m = 5;
else
#endlf
m = 8;
#endlf
zostanie przekształcony w tekst
if(a)
m=8;
12.4. Dyrektywa line
Dyrektywa line ma postać
#line liczba identyfikator
Dyrektywa ta oznacza, że następujący po niej wiersz programu źródłowego znajdował się pierwotnie w pliku określonym przez identyfikator oraz że był w nim wierszem o numerze liczba.
Jeśli w dyrektywie #line zostanie opuszczony identyfikator, to zostanie domniemany identyfikator z poprzedniej takiej dyrektywy.
Mimo iż dyrektywa #line należy do repertuaru dyrektyw preprocesora, może występować także w tekście generowanym przez preprocesor. Sposób traktowania tej dyrektywy istotnie zależy od implementacji preprocesora i kompilatora.
19. Wybrane operacje wejścia/wyjścia
Środki umożliwiające wykonywanie operacji wejścia/wyjścia, a więc nawet proste makrodefinicje i funkcje znajdują się poza definicją języka C. W większości implementacji są dostępne jednak pewne funkcje podstawowe, jak np. printf i scanf ułatwiające komunikowanie się z otoczeniem. Inne funkcje do realizowania operacji wejścia/wyjścia pozostają w ścisłym związku z użytym systemem operacyjnym i podlegają jego wymaganiom i ograniczeniom.
Funkcja ta służy do wyprowadzania wartości zmiennych w postaci znakowej. Wywołanie funkcji ma postać
printf(arg0,arg1 ,arg2. ..,argn)
w której arg0 jest argumentem sterującym, określającym m.in. liczbę następujących po nim argumentów argn - których wartości podlegają wyprowadzaniu.
Argument sterujący reprezentuje ciąg znaków składający się z dowolnych napisów oraz z wzorców konwersji. Wzorce rozpoczynają się od znaku %, a kończą się jedną z liter
d o x u c s e f g
z których każda określa sposób przekształcenia kolejnego argumentu. Jeśli znak występujący bezpośrednio po znaku % nie jest jedną z wymienionych liter, ani też minusem, cyfrą lub kropką, to podlega wyprowadzaniu.
Znaczenie poszczególnych znaków występujących we wzorcach konwersji jest następujące
% rozpoczyna wzorzec konwersji,
- oznacza, że wyprowadzana dana ma być wyrównana w polu wyjściowym lewostronnie,
. oddziela liczby występujące we wzorcu,
d powoduje wyprowadzenie argumentu w postaci liczby dziesiętnej,
o powoduje wyprowadzenie argumentu w postaci liczby ósemkowej (bez wiodącego zera),
x powoduje wyprowadzenie argumentu w postaci liczby szesnastkowej (bez wiodących znaków Ox),
u powoduje wyprowadzenie argumentu w postaci liczby typu (unsigned int),
c powoduje wyprowadzenie pojedynczego znaku stanowiącego argument,
s powoduje wyprowadzenie ciągu znaków stanowiącego argument,
e powoduje wyprowadzenie argumentu jako liczby zmiennopozycyjnej o postaci zm.nnnnnnEwyy, gdzie z jest znakiem liczby, w jest znakiem wykładnika, a m.nnnnnn jest znormalizowaną mantysą (liczba cyfr n może być ustalona dowolnie),
f powoduje wyprowadzenie argumentu jako liczby stałopozycyjnej o postaci zmmm.nnnnn (liczba cyfr n może być ustalona dowolnie),
g powoduje wyprowadzenie argumentu w postaci stało- albo zmiennopozycyjnej, stosownie do tego, która postać zajmuje mniej znaków.
We wzorcu konwersji mogą wystąpić liczby. Pierwsza z nich określa szerokość pola zewnętrznego, a druga - liczbę cyfr wyprowadzanych po kropce dla wzorców e, f, g albo liczbę wyprowadzanych znaków dla wzorca s. Jeśli szerokość pola jest wyrażona liczbą rozpoczynającą się od cyfry zero, to podczas wyprowadzania liczb wyrównanych w polu wyjściowym prawostronnie - zostanie zastosowane lewostronne dopełnienie nie spacjami, lecz zerami.
Argument Wzorzec Pole zewnętrzne
2+3 %3d __5
12 %3o _14
12 %1x c
2-3 %5u 65535
‘A’+1 %c B
”ewa” %s ewa
”ewa” %-5s ewa__
”ewa” %5s __ewa
”ewa” %3.1s __e
2-3.0e1 %-12.2e -2.80E+01___
2-3.0e1 %10.4f __-28.0000
Przykłady
a. Wyprowadzanie tekstów
printf("to be or not to be\n");
Wyprowadzanie danych liczbowych
printf("fixed(a)=%7.2f",a);
- wyprowadzane są znaki tworzące tekst "fixed(a)=", a po nich wartość zmiennej a w postaci zmmm.nn,
- jeśli a ma np. wartość -27.5, to zostanie wyprowadzony napis
fixed(a)= -27.50
c. Wyrażenia w argumencie arg,
printf(a>100.0 ? "a=%12.3e" : "a=%8.3f",a);
- wybór wzorca zależy od wartości zmiennej a.
Funkcja ta służy do wprowadzania ciągów znaków i interpretowania ich jako zapisu wartości, które są nadawane zmiennym wskazywanym przez argumenty funkcji. Wywołanie funkcji ma postać
scanf(aarg0,arg1,arg2...,argn)
w której arg0, jest argumentem sterującym, podobnie jak dla funkcji printf a argumenty argn są l-wyraźeniami reprezentującymi zmienne, którym mają być nadane wartości.
Argument sterujący reprezentuje ciąg znaków składający się z odstępów (spacji, znaków tabulacji i znaków nowej linii) oraz ze znaków nie zawartych we wzorcach konwersji, którym powinny odpowiadać identyczne znaki w polu wejściowym.
Wzorce konwersji składają się ze znaku %, po którym następuje liczba określająca maksymalną szerokość pola zewnętrznego i litera określająca sposób interpretowania tego pola. Dopuszczalnymi literami są
d o x h c s f
a ich znaczenie jest następujące
d w polu zewnętrznym jest spodziewana liczba dziesiętna,
o w polu zewnętrznym jest spodziewana liczba ósemkowa,
x w polu zewnętrznym jest spodziewana liczba szesnastkowa (bez wiodących znaków Ox),
h w polu zewnętrznym jest spodziewana liczba dziesiętna (krótka),
c w polu zewnętrznym jest spodziewany znak (w danym przypadku odstępy nie są ignorowane),
s w polu zewnętrznym jest spodziewany ciąg znaków (podczas nadawania wartości zmiennej ciąg ten jest uzupełniany znakiem nul),
f w polu zewnętrznym jest spodziewana liczba stało- albo zmiennopozycyjna.
Jeśli bezpośrednio po znaku % występuje znak * to jego zinterpretowanie powoduje ominięcie pola zewnętrznego o strukturze określonej przez literę kończącą wzorzec konwersji.
Przykład
int var_i,var;
float var_f;
char chr, arr[3];
scanf("%2d %5f %*2d %c %2s",&var_i ,&var_f ,&var ,&chr ,arr);
- dla pola zewnętrznego 1234 56789
zmienna var_i otrzyma wartość 12, zmienna var_f otrzyma wartość 3.4e1 (pole ma maksymalną długość 5 znaków, ale kończy je spacja), wartość zmiennej var nie ulegnie zmianie, zmienna chr otrzyma wartość '7', a tablica arr zostanie wypełniona znakami '8', '9' i '\0',
- wszystkie argumenty funkcji scanf są typu wskazującego.
Ponieważ definicja języka C nie obejmuje zasad programowania operacji wejścia/wyjścia, faktyczny zestaw środków umożliwiających komunikowanie się programu z jego środowiskiem zależy od implementacji. Domniemywa się jednak, że w każdej implementacji będzie dostępny ograniczony zestaw funkcji standardowych (albo makrodefinicji o zewnętrznych cechach takich funkcji), który umożliwi przenoszenie programów między systemami o odmiennych cechach funkcjonalnych.
Do zestawu takiego będą z pewnością należeć opisane w pracy makrodefinicje getchar i putchar oraz funkcje printf i scanf. Operacje takie zostaną włączone do biblioteki makrodefinicji lub do biblioteki funkcji standardowych, a zaprogramowane będą na podstawie funkcji podstawowych udostępnianych przez system operacyjny.