Interpreter to program, który czyta i analizuje kod programu napisanego w jednym języku i na bieżąco go wykonuje.
Przykładowe interpretery, to:
Kompilator to program, który tłumaczy kod napisany w jednym języku, nazywanym językiem źródłowym na równoważny mu kod napisany w innym języku nazywanym językiem wynikowym. Proces tłumaczenia nazywamy kompilacją.
Zwykle praca kompilatora składa się z następujących etapów:
Kodem wynikowym nie zawsze jest kod maszynowy. Na przykład kompilator języka JAVA tłumaczy kod programu napisanego w JAVA na kod pośredni zrozumiały dla wirtualnej maszyny.
W przypadku języków C/C++ językiem wynikowym jest kod maszynowy. Nie zawiera on jednak kompletnej informacji, wystarczającej do uruchomienia i wykonania tego kodu maszynowego w środowisku systemu operacyjnego. Potrzebny jest jeszcze program zwany linkerem.
Linker (konsolidator) to program, którego zadaniem jest połączenie skompilowanych niezależnie jednostek translacji w gotowy do wykonania program.
Często większe programy dzielimy na mniejsze części, które są kompilowane oddzielnie. Pozwala to na:
W kompilowanej jednostce translacji możemy korzystać z bibliotek (na przykład z biblioteki standardowej).
Zadaniem linkera jest połączenie wszystkich części składowych w jeden program wykonywalny. Nawet, jeśli kod naszego programu jest zawarty w jednym pliku źródłowym, proces generowania końcowego kodu maszynowego jest rozbity na kompilację i linkowanie.
Przykład - pusty plik źródłowy - poprawna kompilacja, ale mamy błąd linkera.
Poniżej jest przedstawiony najprostszy poprawny program w języku C - oczywiście on "nic" nie robi.
int main() { }
Słowo "nic" podaliśmy w cudzysłowie, gdyż uruchomienie każdego programu, nawet pustego, powoduje utworzenie procesu realizującego ten program. System operacyjny musi wykonać sporo operacji aby taki proces uruchomić.
Przykład - najprostszy program.
Programy komputerowe są zapisywane w językach, które nie są językami naturalnymi człowieka. W sytuacji, gdy w programie implementujemy skomplikowane algorytmy, kod programu może stać się mało czytelny bez dodatkowych komentarzy. Co więcej, sam autor programu po pewnym czasie ma problemy ze zrozumieniem, co właściwie robi napisany przez niego program (sam wielokrotnie tego doświadczyłem;)
Komentarz to fragment kodu źródłowego, który jest ignorowany przez analizator semantyczny kompilatora. Są to informacje, które autor programu pozostawia dla osób czytających kod programu.
W języku C/C++ komentarze definiujemy za pomocą:
/* Autor: Daniel Wilczak Data ostatniej modyfikacji: 9.X.2010 Program ilustrujący stosowanie komentarzy */ int main() { // tutaj powinna się znaleźć właściwa treść programu }
Analizator składniowy dopuszcza właściwie dowolne stosowanie białych znaków (spacji, tabulacji, znaków końca linii).
int main ( ) { }
Powyższy program jest poprawnym kodem w C - pytanie tylko, czy jest czytelny dla człowieka?
W celu zwiększenia czytelności kodu stosuje się różnego rodzaju wcięcia
int main() { printf("witaj w programie odliczacz"); int i=10; while(i) { printf("odliczam %d\n",i--); } }
Inny schemat wcięć nawiasów oznaczających bloki {}
int main(){ printf("witaj w programie odliczacz"); int i=10; while(i){ printf("odliczam %d\n",i--); } }
Wybór metody formatowania kodu, jak i głębokość stosowanych wcięć, zwykle zależy od indywidualnych preferencji programisty. Czasem sposób formatowania określony jest przez reguły przyjęte w zespole programistycznym.
Każdy programista popełnia błędy. Najtrudniej jest wykryć i naprawić:
Najprostsze rodzaje błędów to te sygnalizowane przez kompilator lub linker.
Identyfikatory (nazwy) są stosowane do nazywania zmiennych, funkcji, typów itp. W języku C/C++ identyfikatorem może być dowolny ciąg liter (alfabetu angielskiego), cyfr oraz znaków podkreślenia przy czym
Przykłady poprawnych identyfikatorów:
Dobrze dobrane identyfikatory:
Często stosuje się pewne konwencje nazywania zmiennych (tzw. notacja węgierska).
Wielkość liter ma znaczenie - licznik, Licznik, lIcZnIk to trzy różne identyfikatory.
Słowa kluczowe to zastrzeżone słowa, które definiują składnię języka. Jak już zaznaczyliśmy wcześniej, nie mogą być one używane jako identyfikatory.
Poniższa tabela przedstawia zbiór słów kluczowych języka C/C++.
and | and_eq | asm | auto | bitand |
bitor | bool | break | case | catch |
char | class | compl | const | const_cast |
continue | default | delete | do | double |
dynamic_cast | else | enum | explicit | export |
extern | false | float | for | friend |
goto | if | inline | int | long |
mutable | namespace | new | not | not_eq |
operator | or | or_eq | private | protected |
public | register | reinterpret_cast | return | short |
signed | sizeof | static | static_cast | struct |
switch | template | this | throw | true |
try | typedef | typeid | typename | union |
unsigned | using | virtual | void | volatile |
wchar_t | while | xor | xor_eq |
Zmienna to nazwany obszar pamięci komputera. Nazwa zmiennej musi być poprawnym identyfikatorem. Możemy użyć tej nazwy do operacji na obszarze pamięci, który ta zmienna nazywa.
int i; // linia 1 i=3; // linia 2 float z=19.5; // linia 3 float g = z*i; // linia 4
Dotychczasowe programy przykładowe nigdy nie wyświetlały informacji o wykonanych działaniach. Aby umożliwić wypisywanie wartości zmiennych na ekran potrzebujemy skorzystać z operacji wejścia-wyjścia.
Każdy język wysokiego poziomu dostarcza podstawowych funkcji do realizacji operacji wejścia-wyjścia. Na razie, nie wchodząc w szczegóły, przyjmijmy, że:
#include <iostream> // linia 1 using namespace std; // linia 2 int main() { int i=0; cout << "Zmienna i: "; // linia 3 cout << i; // linia 4 cout << endl; // linia 5 int j=10; cout << "j=" << j << endl; // linia 6 }
Przykład - program z wypisaniem wartości na ekran.
Typ zmiennej to w szczególności sposób, w jaki interpretowana jest pamięć określona przez tą zmienną. Widzieliśmy już, że 32-bity pamięci mogą raz tworzyć liczbę całkowitą (int), a innym razem liczbę zmiennoprzecinkową (float).
W C/C++ typy podstawowe (wbudowane) dzielimy na:
Przykład - typy podstawowe i operacje na nich.
Język C/C++ dostarcza podstawowych operatorów arytmetycznych:
Powstaje pytanie, jak zachowuje się program w przypadku wykonania dzielenia przez zero
Wyrażenie powstałe w wyniku użycia operatorów arytmetycznych, np. a*b+c(d+a) nazywamy wyrażeniem arytmetycznym.
Przykład - operatory arytmetyczne i dzielenie przez zero.
Język C/C++ pozwala modyfikować pewne typy danych przy pomocy tak zwanych specyfikatorów: short, long, signed i unsigned.
Specyfikatory short, long
Specyfikatory signed, unsigned
Przykład - rozmiar i zakres poszczególnych typów
W programach (dosyć często) używamy wielkości, o których wiadomo, że nie powinny się zmieniać. Typowym przykładem jest liczba π.
Możemy oczywiście zastąpić każde wystąpienie π przez np. 3.14. Rozwiązanie to ma jednak bardzo poważną wadę - jeśli uznamy, że wielkość ta powinna być podana z inną dokładnością, to będziemy musieli przeglądnąć cały program i wprowadzić odpowiednią zmianę.
Powracając do przykładu z liczbą π - możemy zdefiniować stałą pi i wykorzystywać ją tak jak każdą inną zmienną z jednym zastrzeżeniem - nie można przypisać nowej wartości do stałej
const float pi = 3.14; float promien = 3; float poleKola = pi*r*r; float obwodKola = 2*pi*r;
Próba przypisania nowej wartości do takiej zmiennej skończy się błędem kompilacji. Oznacza to w szczególności, że takie zmienne muszą być od razu zainicjalizowane
const float pi; // BŁĄD (na ogół) stała niezainicjalizowana prog.cpp:3:15: error: uninitialized const 'pi'
Użycie zmiennej z modyfikatorem const ma wiele zalet
const float pi=3.14; pi = 3.1415; prog.cpp:4:8: error: assignment of read-only variable 'pi'
Powiedzieliśmy już, że zmienne to nazwane komórki pamięci. Należy rozróżnić dwa aspekty związane ze zmiennymi:
Na ogół czas życia zmiennej nie pokrywa się z czasem wykonywania instrukcji, w których zmienna jest widoczna (dostępna za pomocą identyfikatora). Szczegóły przedstawimy w dalszej części kursu. Teraz odnotujemy tylko dwie podstawowe sytuacje:
Podkreślamy, że powyższa lista nie jest kompletna - szczegóły będą w dalszej części kursu.
int z; // zmienna globalna, inicjalizowana zerem int main() { int k; // zmienna lokalna, może mieć losową wartość }
Język C/C++ definiuje podstawowe operatory (bramki) logiczne. W szczególności:
Przykład użycia operatorów logicznych.
Do budowania wyrażeń logicznych używa się również operatorów porównania:
Operatory te zwracają prawdę lub fałsz, w zależności od tego, czy porównywane argumenty są w relacji, czy nie.
Przykład użycia operatorów porównania.
Operatory podobnie jak w matematyce nie zawsze są wykonywane w kolejności od lewej do prawej, ale mają określone priorytety.
Poznane do tej pory operatory wykonają się w następującej kolejności
Pełną listę można znaleźć np. na stronie cppreference.com.
Na przykład kod
if(a+2<c||b*2==c&&a*b%c) cout<<a+b*c<<a-b/c<< endl;
if( (a + 2 < c) || ((b * 2 == c) && (a * b % c)) ) cout << (a + b * c) << (a - b/c) << endl;
Każdy język programowania udostępnia instrukcje warunkowe. Pozwalają one na sterowanie przebiegiem wykonywania programu w zależności od wartości wyrażenia logicznego.
Podstawowa instrukcja warunkowa to if, a jej podstawowa składnia jest następująca:
if(warunek) instrukcja
Instrukcja warunkowa if-else pozwala podać instrukcje, które będą wykonane, jeśli warunek ma wartość fałsz
if(warunek) instrukcja1 else instrukcja2
W instrukcji warunkowej to co występuje po if lub else może być pojedynczą instrukcją. Jeżeli chcemy wstawić tam więcej instrukcji musimy zgrupować je w blok ujęty w nawiasy wąsiaste.
if(warunek) { instrukcja1 intrukcja2 ... } else { instrukcja3 instrukcja4 ... }
Przykład - instrukcja warunkowa if-else
Czasami zapis wyrażenia można znacznie uprościć stosując
Jest on postaci
warunek ? wyrazenie1 : wyrazenie2 ;
int liczbaDniWLutym = czyPrzestepny(rok) ? 29 : 28;
double delta = b*b - 4*a*c; // liczba rozwiazan rownania a*x^2 + b*x + c = 0 int liczbaRozwiazan = (delta>0) ? 2 : ((delta==0) ? 1 : 0 );
Pętle to instrukcje używane, gdy zachodzi potrzeba wielokrotnego wykonywania tego samego fragmentu kodu, najczęściej dla różnych danych wejściowych.
Podstawowa pętla while ma składnię
while(warunek) instrukcja;
Instrukcja wykonywana w pętli może być instrukcją złożoną (blokiem instrukcji).
W pętli while najpierw testowany jest warunek, a instrukcja jest wykonywana tylko wtedy, gdy warunek jest spełniony. Może się więc zdarzyć, że instrukcja nie zostanie nigdy wykonana.
Pętla do-while jest bardzo podobna. W przypadku pętli do-while instrukcja wykona się co najmniej raz, później sprawdzany jest warunek.
do instrukcja while(warunek);
Przykład użycia pętli do-while
Pętla for jest najczęściej wykorzystywana, gdy z góry wiadomo ile przebiegów pętli ma się wykonać. Składnia pętli for to:
for(inicjalizacja; test; inkrementacja) instrukcja // przykład for(i=0; i<20; i=i+2) { cout << i << endl; }
Instrukcja switch to instrukcja warunkowa. Wykorzystujemy ją najczęściej gdy potrzebujemy wykonać różne instrukcje dla różnych w zależności od wartości pewnej zmiennej całkowitej.
Składnia instrukcji switch to:
switch(wyrazenie_calkowitoliczbowe) { case w1 : instrukcja1 case w2 : instrukcja2 ..... case wN : instrukcjaN default: instrukcja }
Instrukcja continue może być użyta tylko wewnątrz pętli. Powoduje ona:
Przykład użycia instrukcji continue
Instrukcja break może być użyta wewnątrz pętli lub wewnątrz instrukcji switch. Powoduje ona przerwanie wykonywania pętli lub instrukcji switch i skok do pierwszej instrukcji za pętlą lub switch.
Instrukcja switch jest najczęściej używana w połączeniu z break.
Przykład użycia instrukcji switch
Każda instrukcja może być poprzedzona etykietą.
etykieta: instrukcja
Etykietą może być dowolny identyfikator. Służą one do oznaczania miejsc w programie.
Instrukcja goto to instrukcja skoku. Powoduje ona przeniesienie wykonywania programu do miejsca oznaczonego etykietą. Nadużywanie instrukcji goto świadczy o bardzo złym stylu programowania. Są jednak sytuacje (raczej wyjątkowe), gdzie instrukcja goto może być użyteczna. Jest to wyjście z bardzo zagnieżdżonej pętli.
Składnia instrukcji goto to:
goto etykieta;
Przykład - wyjście z pętli za pomocą goto
Często program musi przechowywać i przetwarzać wiele elementów takiego samego typu. Mogą to być np. klienci firmy, numery telefonów, itp. Jest wiele (często całkiem skomplikowanych) sposobów przechowywania takich zbiorów danych. Jednym z najprostszych jest użycie tablic.
Tablicę możemy interpretować jako zbiór ponumerowanych obiektów tego samego typu. Mają one wspólną nazwę - jest to nazwa tablicy - a każdy element tego zbioru jest jednoznacznie wyznaczany za pomocą swojego numeru.
Instrukcja
int silnia[12];
Dostęp do kolejnych elementów tablicy uzyskujemy za pomocą nazwy tablicy i numeru elementu. W poniższym przykładzie czwartemu elementowi tablicy silnia przypiszemy wartość 6. Pamiętajmy, że elementy są indeksowane od zera, zatem silnia[3] jest czwartym elementem tablicy.
silnia[0] = 1; // pierwszy element tablicy silnia[3] = 6;
Ważne uwagi:
int tablica[10]; // definicja tablicy tablica[10] = 7; // BLAD!!! // modyfikujemy 11 element tablicy
Przykład - przekroczenie zakresu tablicy
Przykład - wyliczanie symbolu Newtona
Jeśli tablica jest zadeklarowana jako zmienna globalna, to zostanie ona uzupełniona zerami jeszcze przed rozpoczęciem wykonywania programu.
Tablice zadeklarowane lokalnie mają losowe wartości. Nie można zakładać, że tablica jest wypełniona zerami. Tablicę, zarówno lokalną jak i globalną, można zainicjalizować w momencie deklaracji.
int wzrost[5]={178,191,165,180,170};
Powyższa instrukcja oznacza definicję tablicy o nazwie wzrost oraz inicjalizację wszystkich elementów tablicy podanymi w nawiasach wąsiastych wartościami.
Uwagi:
int wiek[5]={5,7,10};
int wiek[5]={6,7,3,1,4,5,3}; // BLAD!!! za duzo elementow
int wiek[]={5,7,10};
Przykład - inicjalizacja tablic.
Język C/C++ dostarcza typów wbudowanych, które służą do przechowywania znaków (liter, symboli, itp). Są to typy:
Znaki możemy inicjalizować wartością liczbową (kodem ASCII) bądź też podając znak w apostrofach - zostanie on przekonwertowany na odpowiadający mu kod ASCII.
char z=65; // kod ASCII litery A char w='A';
Niektóre znaki są bardzo często wykorzystywane, chociaż nie odpowiada im żaden znak na klawiaturze. Znaki te możemy wprowadzić do kodu źródłowego podając jawnie kod ASCII takiego znaku, lub też (zalecane) używając znaków specjalnych. Wybrane znaki specjalne:
Napisy w języku C/C++ to po prostu tablice znaków. Przyjęto umowę, że koniec napisu wskazuje znak o kodzie zero (nie mylić ze znakiem zero, który ma kod 48).
Jest to bardzo użyteczna konwencja, gdyż wielkość tablicy wcale nie musi odpowiadać długości napisu. Typowa sytuacja to taka, w której deklarujemy tablicę o pewnej wielkości (bufor), po czym zapisujemy do niego tekst, np. wprowadzony przez użytkownika. Na ogół tekst jest znacznie krótszy od wielkości bufora.
char napis[] = {'D','a','n','i','e','l','\0'};
Napisy podawane w cudzysłowach
Podawanie napisów tak jak powyżej, chociaż formalnie poprawne, jest bardzo uciążliwe. Język C/C++ pozwala na wprowadzanie napisów przy pomocy znaków cudzysłowu. Każdy ciąg znaków pomiędzy znakami cudzysłowu jest napisem z automatycznie dodanym kończącym zerem.
Powyższa deklaracja tablicy napis jest funkcjonalnie równoważna z
char napis[] = "Daniel";
Znaki specjalne w cudzysłowach
Wprowadzając napis za pomocą znaków cudzysłowu możemy użyć w nim znaków specjalnych, na przykład
char napis[] = "Wykladowca:\n\tDaniel Wilczak";
Wydrukowanie tego napisu (np. za pomocą strumienia cout) da w wyniku
Wykladowca:
Daniel Wilczak
char napis[] = "Daniel"; napis = "Roman"; //BLAD
Często jest potrzeba przechowywania podobnych danych indeksowanych (numerowanych) nie jedną liczbą, a wieloma. Możemy to zilustrować za pomocą arkusza kalkulacyjnego - aby odnieść się do komórki w arkuszu musimy podać numer wiersza i kolumny.
W języku C/C++ tablica dwuwymiarowa to po prostu tablica tablic, czyli elementami jednej tablicy są inne tablice. W przypadku arkusza kalkulacyjnego:
Formalnie można tworzyć tablice indeksowane jeszcze większą ilością liczb (tablice tablic tablic, itp). Deklaracja tablicy dwuwymiarowej może wyglądać następująco
int arkusz[20][50];
Powyżej zadeklarowaliśmy 20-elementową tablicę, której elementami są tablice 50-elementowe (możemy sobie wyobrazić arkusz wielkości 20 na 50). Elementy takiej tablicy można odczytać lub zmienić podając dwa indeksy w nawiasach kwadratowych:
arkusz[4][5] = 7;
Inicjalizacja tablic wielowymiarowych
Tablice wielowymiarowe możemy inicjalizować za pomocą list inicjalizacyjnych. Musimy oczywiście odpowiednio pogrupować te dane za pomocą nawiasów wąsiastych.
int t[2][4] = {{1,2,3,4},{5,6,7,8}}; int k[3][4] = {{2,4},{3,7,8}}; int z[3][4] = {{2,4,0,0},{3,7,8,0},{0,0,0,0}};
Druga i trzecia deklaracja są sobie równoważne funkcjonalnie. W przypadku podania wielkości tablic, każda z nich zostanie uzupełniona zerami, jeśli danych w liście inicjalizacyjnej jest za mało.
W inicjalizowanej tablicy wielowymiarowej można pominąć jej pierwszy (i tylko pierwszy) rozmiar. Kompilator sam ustali wielkość tablicy na podstawie ilości elementów w liście inicjalizacyjnej. Poniższe dwie deklaracje dają taki sam efekt.
int t[][4] = {{1,2,3,4},{5,6,7,8}}; int z[2][4] = {{1,2,3,4},{5,6,7,8}};
Typowym przykładem użycia tablicy tablic jest tablica napisów. Napis, to tablica znaków. Jeśli na przykład chcemy stworzyć tablicę imion pracowników firmy, to będzie to właśnie tablica tablic znaków.
char imiona[30][32];
Powyżej zadeklarowaliśmy tablicę, w której można zapamiętać 30 napisów (na przykład imion). Możemy ją od razu zainicjalizować
char imiona[30][32] = {"Piotr","Ewa","Marta","Adam"};
Pozostałe elementy tablicy zostaną uzupełnione zerami, co w przypadku napisów oznacza napisy puste.
Gdybyśmy chcieli jednocześnie przechowywać nazwiska pracowników firmy, to możemy stworzyć oddzielną tablicę lub stworzyć tablicę trójwymiarową
char imiona[30][32]; char nazwiska[30][32]; // możemy też stworzyć tablicę trójwymiarową // 30 osob, kazda ma 2 napisy (imie, nazwisko) po 32 znaki char osoby[30][2][32];
Biblioteka standardowa
Wybrane funkcje :
strlen(napis) | zwraca dlugość napisu, |
strcpy(cel, zrodlo) | kopiuje napis ze zrodlo do cel. Tablica znakowa cel musi być odpowiednio duża aby pomieścić cały napis wraz z znakiem końca. |
strncpy(cel, zrodlo, n) | kopiuje maksymalnie n znaków ze zrodlo do cel. Nie dokłada znaku końca napisu. |
strcat(napis1, napis2) | dopisuje napis2 do konca napis1 Tablica napis1 musi być odpowiednio duża aby pomieścić wynik. |
strcmp(napis1, napis2) | porównuje dwa napisy leksykograficznie. Zwraca:
|
Przykład - napisy z wykorzystaniem cstring.
Chyba większość programów korzysta z napisów. Operacja na napisach za pomocą tablic znakowych jest wprawdzie wydajna (szybka), ale bardzo uciążliwa. Kod jest podatny na błędy, czasem trudne do wykrycia.
Biblioteka standardowa języka C++ dostarcza typu string. Nie jest to typ wbudowany!
Aby korzystać z typu string należy w programie dołączyć odpowiedni plik nagłówkowy.
#include <iostream> #include <string> // tutaj dołączamy klasę string using namespace std; int main() { string imie; cout << "Przedstaw sie"; cin >> imie; cout << "Witaj " << imie; }
Przykład - napisy z wykorzystaniem typu string.
Wskaźnik jest typem pochodnym od jakiegoś innego typu. Jeśli T jest typem, to T* jest typem, który może przechowywać adresy obiektów typu T
// deklaracja wskaźnika, // który może przechowywać adresy zmiennych typu int int* p; double* d = 0; char* s;
Wskaźniki puste:
Wskaźnik zawiera pewien adres pamięci - jest to liczba. Przyjęto umowę, że adres 0 oznacza adres pusty - czyli wskaźnika aktualnie nie pokazuje na żaden obiekt.
Operator adresu służy do pobierania adresu zmiennej. Wartość taką możemy przypisać do wskaźnika
int p=10; int* wsk = &p; // & pobierze adres zmiennej p cout << wsk; // na ekranie zobaczymy adres zmiennej p
Operator wyłuskania ma odwrotne działanie. Służy on do odniesienia się do obiektu pokazywanego przez wskaźnik
int p = 10; int q = 20; int* wsk = &p; cout << *wsk; // wydrukujemy p, czyli 10 wsk = &q; // przestawiamy wskaźnik na zmienną q cout << *wsk; // wydrukujemy q, czyli 20
Przykład - operatory adresu i wyłuskania.
Położenie symbolu * przy deklaracji wskaźnika nie ma większego znaczenia. Można użyć każdej z opcji:
int* p; int* p; int * p; int*p;
Można myśleć, że int* jest nowym typem. Nie jest to jednak prawda. Zapis
int* p, q;
deklaruje zmienną wskaźnikową p oraz zwykłą zmienną q typu int. Aby zadeklarować dwie zmienne wskaźnikowe w jednej instrukcji należy obie zmienne poprzedzić symbolem *
int *p, *q;
W takiej sytuacji jest bardziej czytelne, jeśli przykleimy symbol * do nazw zmiennych.
Arytmetyka wskaźników daje równie wielkie możliwości jakie są niebezpieczeństwa związane z jej niepoprawnym używaniem.
Wskaźniki przechowują adresy obiektów. Kompilator wie, jakiego typu jest obiekt pokazywany przez wskaźnik i ile pamięci taki obiekt zajmuje. Pozwala to na wyliczenie jaki jest adres następnego, czy poprzedniego obiektu w pamięci.
Niech T będzie pewnym typem. Jeśli do wskaźnika T* p dodamy liczbę k, to wskaźnik ten przesuwa się o k*sizeof(T) bajtów. Na przykład, po wykonaniu:
int n; int* p = &n; p++;
wskaźnik p pokazuje adres tuż za zmienną n. Zatem zapis p++ w odniesieniu do wskaźnika należy interpretować jako "przesuń się o rozmiar jednego int" a nie o jeden bajt.
Tablice są przechowywane w ciągłym obszarze pamięci tak jak to przedstawiono poniżej.
Tablica, to tak naprawdę adres pierwszego elementu tej tablicy. Jest to stały wskaźnik, czyli taki, którego nie można przesuwać. Zawsze będzie pokazywał na pierwszy element tablicy.
Skoro tablica jest stałym wskaźnikiem, to można stosować dla niej arytmetykę wskaźników
int t[] ={0,1,2,3,4,5}; cout << t[2] << endl; cout << *(t+2) << endl;
Ostatnią linię przykładu należy rozumieć tak: przesuń adres pierwszego elementu tablicy o dwa rozmiary obiektu int, a następnie wypisz liczbę, która tam się znajduje.
Przykład - tablica jako wskaźnik
Bardzo często pisząc program nie wiemy, jak dużo obiektów będziemy potrzebowali utworzyć. Na przykład pisząc program dla banku nie możemy zakładać, że bank może obsłużyć co najwyżej 10000 kont. A może 1000000?
Wskaźniki pozwalają na dostęp do dowolnego obszaru pamięci - mogą pokazywać na obiekty, których istnienie nie było znane w czasie kompilacji. W trakcie wykonywania programu możemy zażądać od systemu operacyjnego przydziału pewnej ilości pamięci. Jeśli w systemie jest jeszcze dostępna wolna pamięć, zostanie ona zarezerwowana a jej adres będzie zostanie przekazany do procesu, który zgłosił żądanie przydziału pamięci.
Takie obiekty będziemy nazywać dynamicznymi. Mogą być one powoływane w dowolnym momencie i usuwane z pamięci w każdym innym miejscu programu. Dostęp do takich obiektów mamy tylko za pomocą wskaźników i referencji (o tym za chwilę).
Do alokacji pamięci na stercie można użyć operatora new (to jest z C++).
int* obiekt = new int; int* tablica = new int[50]; int* p= new int[i];
Usuwanie obiektów:
Obiekty zarezerwowane za pomocą new należy oddać do dyspozycji systemu operacyjnego przed zakończeniem programu (w dowolnym momencie, jeśli już przestają nam być potrzebne). Służy do tego operator delete
delete obiekt; delete []tablica; // nie podajemy rozmiaru usuwanej tablicy! delete []p;
Referencje pojawiły się w C++. Referencję możemy traktować jako inną nazwę na już istniejący obiekt. O wielkiej przydatności referencji przekonamy się już niedługo podczas omawiania struktur i funkcji. Na razie zapoznamy się ze składnią.
int n=10; int& ref = n; ref = 20; cout << n; // wypisze 20
Jak to działa?
Referencja to tak naprawdę stały wskaźnik. Składnia referencji zabrania wykonywania operacji znanych dla wskaźników: referencji nie można ustawić na inny obiekt, nie jest dostępna arytmetyka referencji. Po kompilacji (w kodzie maszynowym) referencje i wskaźniki niczym się nie różnią.
Korzystając z referencji świadomie rezygnujemy z pewnych możliwości oferowanych nam przez wskaźniki. W zamian dostajemy:
int wzrost;
W wykładzie wprowadzającym (o zmiennych lokalnych i globalnych) wprowadziliśmy dwa podstawowe terminy związane ze zmiennymi - zakres ważności oraz czas życia zmiennej.
W tym rozdziale podamy bardziej szczegółową klasyfikację (chociaż ciągle niekompletną!) i informacje odnośnie czasu życia i zakresu ważności zmiennych. Wiedza taka pozwala poprawnie używać różnych typów zmiennych w czasie tworzenia programów oraz uniknąć trudnych do wykrycia błędów logicznych w programach.
Zmienne globalne, to zmienne zadeklarowane poza jakimkolwiek blokiem programu.
int g; //zmienna globalna int main(){ }
Zmienne globalne
{ // 1 .... int a; // 2 { // 3 .... int b; // 4 } // 5 } // 6
W przykładzie powyżej
Zmienna zadeklarowana wewnątrz bloku może być poprzedzona słowem kluczowym static. W ten sposób deklarujemy zmienne lokalne statyczne.
{ static int a = 10; ... }
Różnią się one od zwykłych zmiennych czasem życia, mianowicie:
Po co używać zmiennych lokalnych statycznych?
Przykład - użycie zmiennej lokalnej statycznej.
Bardzo ważnym rodzajem obiektów są obiekty tworzone na stercie. W tym przypadku to programista sam decyduje o zakresie ważności i czasie życia obiektu.
Obiekty takie tworzymy (na przykład) przy pomocy operatorów new oraz new[]a usuwamy z pamięci przy pomocy operatorów delete oraz delete[]. W języku C alokację pamięci na stercie wykonuje się za pomocą funkcji bibliotecznych, np. malloc, realloc, itp.
{ int* wsk = new int; .... delete wsk; }
Każdy typ wbudowany ma odpowiedni zestaw literałów.
Literały tekstowe są ciągiem znaków ujętych w cudzysłowy np. "To jest przykładowy tekst\n".
W programie napisanym w C/C++ mogą pojawić się zmienne o takich samych identyfikatorach. Jedynym ograniczeniem jest to, że dwie zmienne o takiej samej nazwie nie mogą być zadeklarowane w tym samym bloku.
Jeśli w bloku zadeklarowano zmienną o identyfikatorze, który był użyty dla innej zmiennej o ważności obejmującej ten blok, to mamy do czynienia z zasłanianiem nazwy zmiennej.
int j=10; int main() { .... int i; for(i=0;i<10;i++) { int j = 5; // zasłaniamy zmienną globalną j .... ::j = 13; // można użyć zasłoniętej zmiennej globalnej // przy pomocy operatora zakresu } }
Przykład - zasłanianie zmiennych globalnych
int main() { .... int i, k=10; int* wsk = &k; for(i=0;i<10;i++) { .... int k = 5; // zasłaniamy zmienną lokalną k .... *wsk = 13; // zasłonięty obiekt jest dostępny // przy pomocy wskaźnika lub referencji } }
Przykład - zasłanianie zmiennych lokalnych
W języku C/C++ słowo kluczowe void oznacza brak typu. Nie można deklarować zmiennych typu void.
Można natomiast deklarować wskaźniki typu void. O użyteczności wskaźników typu void przekonamy się w przyszłości, teraz podsumujemy ich własności.
int p; void* wsk = &p; int* q = &p; wsk = q;
Słowo kluczowe void jest również używane do deklaracji funkcji, które nie zwracają żadnej wartości. O tym powiemy już za chwilę.
Funkcja to podstawowy moduł programu w C/C++. Program główny to również funkcja o nazwie main. Funkcje mogą implementować algorytmy, realizować często powtarzające się zadania.
Nazwa funkcji musi być poprawnym identyfikatorem. Deklarując funkcję musimy podać, jakie są jej argumenty formalne, jaką ma nazwę oraz określić typ zwracanej wartości.
TypZwracany nazwaFunkcji(TypArgumentu1, ... ,TypArgumentuN); //przykłady void wypiszElementyTablicy(string t[], int ilosc); double poleProstokata(double bokX, double bokY);
Powyżej zadeklarowaliśmy funkcję o nazwie poleProstokata. Funkcja ta ma dwa argumenty typu double i zwraca również obiekt typu double.
Deklaracja funkcji jest tylko pouczeniem dla kompilatora, że nazwa funkcji jest poprawna. Nie powoduje to wygenerowania żadnego kodu wynikowego. Deklarację funkcji można powtórzyć dowolną ilość razy i nie spowoduje to błędów kompilacji czy linkowania.
Definicja funkcji zawiera ciało funkcji, czyli jej kod.
TypZwracany nazwaFunkcji(TypArgumentu1, ... ,TypArgumentuN) { // tutaj znajduje się kod funkcji } //przykład double poleProstokata(double bokX, double bokY) { return bokX * bokY; }
Definiując funkcję podajemy jej sygnaturę (czyli typ zwracany, nazwę i argumenty). Kod funkcji podajemy w nawiasach wąsiastych. Lista argumentów może być pusta.
Do zwracania wartości przez funkcję służy słowo kluczowe return.
Przykład - funkcja licząca pole prostokąta.
Deklarując funkcję możemy pominąć nazwy argumentów pozostawiając jedynie typy argumentów.
double poleProstokata(double, double);
Argumenty umieszczone na liście w definicji funkcji to argumenty formalne. Kiedy wywołujemy funkcję w miejsce argumentów formalnych wstawiamy argumenty aktualne funkcji.
Mogą one być literałami (np. stałe wartości wprowadzone do kodu programu) lub zmiennymi, na przykład
double pole = poleProstokata(3,x);
Skoro argumenty aktualne są kopiowane, to oznacza, że funkcja pracuje na kopiach obiektów a nie na oryginalnych obiektach. Nie może więc zmodyfikować oryginalnych obiektów.
Przykład - argumenty przesyłane przez wartość.
Na liście argumentów formalnych funkcji mogą wystąpić zmienne wskaźnikowe. Tak jak każde zmienne są one przesyłane przez wartość.
void funkcja(int* argument);
Jeśli na liście argumentów formalnych funkcji znajduje się wskaźnik, to mówimy o przesyłaniu argumentu przez wskaźnik.
Przykład - argumenty przesyłane przez wskaźnik.
Na liście argumentów formalnych funkcji mogą wystąpić zmienne referencyjne. Pamiętajmy, że referencja to ukryty wskaźnik. Tak jak każde zmienne są one przesyłane przez wartość.
void funkcja(int& argument);
Jeśli na liście argumentów formalnych funkcji znajduje się referencja, to mówimy o przesyłaniu argumentu przez referencję.
Przesyłając argumenty przez referencję mamy możliwość modyfikacji oryginalnego obiektu, podobnie jak za pomocą wskaźnika. W przeciwieństwie do przesyłania argumentów przez wskaźnik nie musimy używać operatora wyłuskania.
Przykład - argumenty przesyłane przez referencję.
W poniższej deklaracji
void f(int t[5]);
do funkcji f zostanie przesłany adres początku pewnej tablicy. Sama funkcja nie wie, jakiej wielkości jest ta tablica, a podana wielkość nie ma znaczenia.
Mogłoby się wydawać, że funkcja powinna móc odczytać wielkość tablicy tylko na podstawie przesłanej tablicy. Pamiętajmy jednak o utożsamieniu tablic i wskaźników. Tak naprawdę funkcja może otrzymać jako argument tablicę, która jest fragmentem innej tablicy - jest to sytuacja bardzo typowa (np. przy sortowaniu)! Oczywiście rozmiar większej tablicy nie będzie poprawnym rozmiarem, który chcielibyśmy przesłać do takiej funkcji.
Przekazując tablicę do funkcji podajemy tylko jej nazwę.
void f(int t[]); void f(int* t); // to samo co wyżej int tablica[10]; f(tablica);
Przykład - przekazywanie tablicy do funkcji.
Argument formalny funkcji może być poprzedzony modyfikatorem const. Oznacza to, że po wywołaniu nie będzie można modyfikować takiego argumentu.
void f(const int& x, const int* y, const int z) { x += 5; // BŁĄD kompilacji, x jest stałą referencją *y += 5; // BŁĄD kompilacji, y jest wskaźnikiem do stałej z += 5; // BŁĄD kompilacji, z jest stałą y += 5; // przesunięcie wskaźnika jest poprawne }
Definiując funkcję należy zdecydować, w jaki sposób argumenty będą do niej przesyłane - przez wartość wskaźnik, czy referencję. Można stosować się do poniższych ogólnych zaleceń, ale są od nich liczne wyjątki
Przykład - modyfikator const w argumentach funkcji
Jeśli funkcja ma zwracaną wartość inną niż void to co najmniej raz w kodzie tej funkcji powinna wystąpić instrukcja return, za pomocą której zwracana jest wartość funkcji.
W takiej sytuacji po instrukcji return podajemy wyrażenie, które najpierw jest obliczane, a następnie staje się wartością zwracaną przez funkcję. Wyrażenie może być tylko stałą lub zmienną.
Instrukcja return powoduje zakończenie wykonywania funkcji i zwrócenie wartości. Może być ona użyta dowolną ilość razy w kodzie funkcji.
W funkcjach, które deklarują brak zwracanej wartości (void) użycie instrukcji return nie jest potrzebne, ale możliwe. Wtedy po instrukcji return następuje tylko średnik. W takich funkcjach używa się return do zakończenia wykonywania funkcji, na przykład wewnątrz bloku instrukcji if, czy z instrukcji pętli.
Przykład - wartość zwracana przez funkcję.
Funkcja może również zwracać wskaźnik lub referencję. Wtedy taki wskaźnik albo referencja jest obiektem chwilowym, a nie sam obiekt, na który taki wskaźnik pokazuje.
int* f(){ int* result; ...... return result; } const int& h(int& x){ ... return x; }
Zwykle za pomocą wskaźnika lub referencji zwraca się jeden z otrzymanych argumentów, który może być użyty jako argument dla innej funkcji albo wskaźniki/referencje do obiektów utworzonych na stercie.
int& f() { int k; .... return k; // BŁĄD! zwracamy referencję do zmiennej lokalnej }
Przykład - zwracanie wartości przez wskaźnik.
Wewnątrz ciała funkcji może wystąpić instrukcja wywołania tej samej funkcji. Mówimy wtedy o wywołaniu rekurencyjnym funkcji, albo o rekursji.
int funckja(int x, int y) { if(warunekKoncaRekursji) { return wartoscPoczatkowaRekursji; } // rób coś .... // wywołaj rekurencyjnie funkcja(a,b); // dowolną ilość razy funkcja(c,d); }
Aby nie doszło do zapętlenia programu, funkcja rekurencyjna musi zawierać wyrażenie warunkowe pozwalające na przerwanie rekurencyjnych wywołań.
Przykład - wyliczanie symbolu Newtona za pomocą rekursji.
Kompilator napotykając definicję funkcji zwykle generuje kod maszynowy tej funkcji. W czasie wykonywania programu kod ten jest ładowany do pamięci i umieszczany pod pewnym adresem.
Wywołanie funkcji powoduje skok do miejsca w pamięci, w którym jest umieszczona funkcja (wykonywanych jest jeszcze kilka innych czynności, które tutaj pomijamy).
Po zakończeniu wykonywania funkcji następuje powrót do miejsca, z którego nastąpiło wywołanie funkcji.
Przed nagłówkiem funkcji może pojawić się słowo kluczowe inline. Oznacza ono życzenie, aby kompilator zastąpił każde wywołanie funkcji jej ciałem. Powoduje to zniwelowanie całkiem sporego narzutu związanego z wywołaniem funkcji (skok, przekazywanie argumentów, itp.).
inline double Farenheit2Celsius(double f) { return (f-32)/9*5; }
Jeśli w danym zakresie ważności mamy dwie lub więcej funkcji o takiej samej nazwie, to będziemy mówić o przeładowaniu nazw funkcji. Język C/C++ (i wiele innych języków) pozwala na definiowanie funkcji o takiej samej nazwie. Muszą się one różnić listą argumentów. Dozwolone jest przeładowanie, gdy
Nie można przeładować funkcji, jeśli różnią się tylko typem zwracanej wartości.
void sortuj(int t[], int rozmiarTablicy) { ... // tutaj algorytm sortowania liczb całkowitych } void sortuj(string t[], int rozmiarTablicy) { ... // tutaj algorytm sortowania napisów }
Przykład - przeładowanie nazw funkcji
Powiedzieliśmy, że przeładowanie funkcji jest możliwe, jeśli lista argumentów różni się typami lub kolejnością argumentów. Tutaj musimy uściślić, że w czasie przeładowania nie rozróżnia się pewnych typów, które formalnie są inne. Dotyczy to:
Oznacza to na przykład, że następujące przeładowania spowodują błąd kompilacji
void f(int* t); void f(int z[]); void g(int x); void g(const int z);
Argumenty T i T& traktowane są jako tożsame tylko przez niektóre kompilatory. Jednak jeśli nawet uda się zdefiniować dwie funkcje różniące się tylko argumentem T i T&, to najczęściej przy próbie wysłania argumentu typu T do takiej funkcji kompilator sygnalizuje błąd niejednoznaczności.
Natomiast jako różne traktowane są dowolne dwa argumenty spośród następujących dwóch trójek
Jeśli mamy kilka funkcji przeładowanych, to kompilator może mieć problem z wyborem właściwej wersji. Postępuje one według reguł podanej poniżej i w takiej właśnie kolejności
Jeśli okaże się, że dwie lub więcej funkcji jest na tym samym poziomie zgodności, to sygnalizowany jest błąd niejednoznaczności (błąd kompilacji)
Przykład - niejednoznaczność przeładowania.
Deklarując funkcję, możemy przypisać argumentom na końcu listy ich wartości domyślne.
void wypisz(int t[], int dlugosc, char separator=','); void wypisz(int t[], int dlugosc, char separator) { .... // ciało funkcji }
Jeśli definicja i deklaracja są rozdzielone, to wartości argumentów domniemanych mogą się pojawić tylko w deklaracji. W przeciwnym razie zostanie zgłoszony błąd kompilacji.
Jeśli deklaracja jest połączona z definicją, to argumenty domniemane mogą się pojawić również w definicji funkcji.
Efekt użycia argumentów domniemanych jest taki, jak zdefiniowanie przeładowanych funkcji z różną ilością argumentów. W przykładzie powyżej będą to
void wypisz(int t[], int dlugosc, char separator); void wypisz(int t[], int dlugosc);
Definicje dla tych wersji kompilator uzupełni sam, na podstawie jednej podanej definicji i wartości domyślnych argumentów
Przykład - argumenty domniemane funkcji
Język C/C++ dopuszcza tworzenie funkcji o nieokreślonej liczbie argumentów. Wprawdzie w C++ potrzeba używania takich funkcji jest znacznie ograniczona poprzez możliwość przesyłania do funkcji obiektów strukturalnych, jednak w czystym C było to i jest nadal ważnym mechanizmem tworzenia elastycznych funkcji.
Funkcja o nieokreślonej liczbie argumentów może przyjmować pewną stałą listę argumentów. Te opcjonalne umieszczane są na końcu i oznaczamy wielokropkiem, na przykład
int funkcja(int a, float b, char* typ,...);
Taki zapis oznacza, że funkcja zawsze oczekuje co najmniej trzech argumentów, które są kolejno typów int, float, char*. Później może wystąpić cokolwiek.
Aby skorzystać z mechanizmu przesyłania nieokreślonej ilości argumentów do funkcji należy dołączyć plik nagłówkowy cstdarg lub stdarg.h. Później schemat postępowania jest następujący
va_list ap;
va_start(ap,typ);
double z = va_arg(ap,double);
Przykład - zmienna liczba argumentów.
int licznik = 0; int f(){ return ++licznik; } int h(int a, int b){ cout << a << " " << b; } ... h(f(), f());
Po skompilowaniu funkcja jest umieszczona w pewnym miejscu w pamięci. Jej nazwa jest traktowana jako zmienna, która zawiera adres miejsca w pamięci, gdzie umieszczona jest ta funkcja.
Utożsamienie funkcji i wskaźnika pozwala na deklarowanie wskaźników do funkcji, co często bywa użyteczne.
int (*f)(int, float); int (*t[5])(int, float); void g( int (*)(double) );
W powyższym przykładzie
W celu podniesienia czytelności kodu można umieścić nazwy parametrów (są one ignorowane)
int (*f)(int ile, float dlugosc); int (*t[5])(int kierunek, float szybkosc); void g( int (*wskf)(double x) );
Przykład - wskaźniki do funkcji
Deklaracje wskaźników do funkcji można jeszcze bardziej skomplikować - np. funkcja, której argumentem jest wskaźnik do funkcji i zwraca wskaźnik do funkcji. Stają się one bardzo nieczytelne. Z pomocą przychodzi nam słowo kluczowe typedef
typedef służy do wprowadzania innej nazwy na istniejący już typ. Nowa nazwa musi być poprawnym identyfikatorem.
typedef unsigned long long ullong; ullong n=10;
W powyższym przykładzie zdefiniowaliśmy nowy typ o nazwie ullong. Od tej pory możemy używać tej nazwy na typ tak samo jak jej dłuższej wersji unsigned long long.
Analogicznie można zdefiniować typ wskaźnika do funkcji o określonej sygnaturze.
typedef bool (*WskaznikDoFuncji)(int,int); void funkcja(WskaznikDoFunkcji f) { ... bool r = f(a,b); } WskaznikDoFunkcji g(WskaznikDoFunkcji f) { ... bool r = f(a,b); return f; }
Przykład - wskaźniki do funkcji i typedef.
Pisząc
enum DzienTygodnia{nie,pon,wto,sro,czw,pia,sob};
definiujemy własny typ, tak zwany typ wyliczeniowy. Oczywiście można teraz zadeklarować
DzienTygodnia dzienWyplaty=pia;
Kompilator kojarzy z elementami typu wyliczeniowego kolejne liczby całkowite, poczynając od zera, chyba, że explicite przy definicji typu konkretny element skojarzono z inną liczbą, tak jak w przykładzie
enum controlKeys{tab=8,enter=13};
dzienWyplaty=4; // brak konwersji
dzienWyplaty=DzienTygodnia(7);
Powróćmy do naszego przykładu z bazą danych pracowników firmy. Do przechowywania informacji o pracownikach użyliśmy trójwymiarowej tablicy
char osoby[30][2][32];
Rozwiązanie takie ma pewne wady
Język C dostarcza słowa kluczowego struct. Służy ono do tworzenia nowych, zdefiniowanych przez programistę typów. Najprostsza składnia jest następująca:
struct [OpcjonalnaNazwaStruktury] { // deklaracje zmiennych, z których składa się struktura } [opcjonalnieListaDeklarowanychZmiennych];
Struktura opisująca pracownika może wyglądać tak:
struct Pracownik { string imie; string nazwisko; string miejscowosc; string adres; string stanowisko; float pensjaPodstawowa; };
W powyższym przykładzie:
Struktury tworzymy po to, by deklarować zmienne strukturalne. Deklaracja zmiennej odbywa się tak samo jak każdej innej - podajemy typ i nazwę zmiennej lub zmiennych.
Pracownik p; Pracownik pracownicyFirmy[30];
cout << sizeof(Pracownik);
Przykład - struktura Pracownik
Deklarując zmienną strukturalną rezerwujemy pewien obszar pamięci o wielkości zależnej od tego, z czego składa się struktura. Pamięć ta jest podzielona na mniejsze fragmenty. Każdy z tych fragmentów ma rozmiar i odpowiadający wielkości pola składowego struktury. Pamięć ta jest interpretowana jako obiekt typu określonego w definicji struktury.
Do składowych struktury możemy się odwoływać za pomocą pól składowych struktury oraz notacji kropkowej.
Pracownik p; p.pensja = 7654.99;
Przykład - dostęp do pól składowych struktury
Jeśli mamy dostęp do struktury za pomocą referencji, to dostęp do składowych struktury uzyskujemy za pomocą notacji kropkowej.
Pracownik& p = * (new Pracownik); p.pensja = 6543.21;
W przypadku dostępu do obiektu strukturalnego za pomocą wskaźnika używamy notacji strzałkowej. Notacja ta odzwierciedla to, czym jest wskaźnik - zmienną pokazującą na inne obiekty.
Pracownik* p = new Pracownik; p->pensja = 6543.21;
Można również najpierw użyć operatora wyłuskania, a później zwykłej notacji kropkowej.
Pracownik* p = new Pracownik; (*p).pensja = 6543.21;
Przykład - dostęp do pól składowych struktury za pomocą wskaźników i referencji
Obiekty strukturalne są na ogół duże. Podczas przesyłania takich obiektów do funkcji jako argumentów zwykle wykorzystujemy przesyłanie przez referencję lub wskaźnik. Pozwala nam to uniknąć kopiowania dużych obiektów w czasie ich przesyłania do funkcji.
void wprowadz(Pracownik* p) { .... cin >> p->imie; .... } void wypisz(const Pracownik & p) { .... cout << p.imie; .... }
Przykład - przesyłanie obiektów strukturalnych do funkcji.
Obiekty strukturalne mogą być również zwracane przez funkcje jako wartości. Podlegają one tym samym regułom jak zmienne typów wbudowanych. Tworzony jest obiekt chwilowy, który staje się wartością funkcji. Może on być użyty jako argument innej funkcji lub zapamiętany w zmiennej za pomocą operatora przypisania.
Pracownik funkcja() { Pracownik p; ..... return p; }
Nic nie stoi na przeszkodzie, aby wartością funkcji był wskaźnik lub referencja do obiektu strukturalnego.
Typową sytuacją jest zwracanie przez funkcję wskaźnika lub referencji do obiektu utworzonego na stercie.
Pracownik* utworz( const char imie[], const char nazwisko[], float pensja ) { Pracownik* p = new Pracownik; .... return p; }
Przykład - wskaźniki do obiektów strukturalnych jako wartość funkcji
Poniżej przedstawiamy zalążek programu realizującego prostą bazę danych o pracownikach firmy
Przykład - mini baza danych - programowanie z użyciem struktur
Zadaniem preprocesora jest dokonanie pewnych wstępnych posunięć przed rozpoczęciem kompilacji oraz sterowanie przebiegiem samej kompilacji.
Dyrektywa preprocesora ma składnię
#dyrektywa argumenty
Dyrektywa makrodefinicji ma jedną z dwóch postaci
#define nazwa rozwinięcie #define nazwa(argumenty_formalne) rozwinięcie
#define WYPISZ(x) cout << #x << "=" << x << "\n"; int i=7; WYPISZ(i)
spowoduje wypisanie na standardowym wyjściu
i=7
Przykład - zamiana argumentu na łańcuch
#define TEKST(a) char* a##_wskaznik; int a##_dlugosc; TEKST(alfa); TEKST(beta); TEKST(gamma);
char* alfa_wskaznik; int alfa_dlugosc;
Uwaga: preprocesor nie sprawdza sensowności argumentów, więc na przykład TEKST("alfa") wygeneruje linię
char* "alfa"_wskaznik; int "alfa"_dlugosc;
na której wywróci się dopiero sam kompilator.
Przykład - sklejanie identyfikatorów
W języku C makrodefinicje miały duże zastosowanie ze względu na brak technik programowania za pomocą szablonów. Obecnie makrodefinicje stosuje się głownie do
#define INTEL
#define pi 3.14
#define max(a,b) ( a>b ? a : b) .... i=max(i,2); x=max(x,3.14);
Dyrektywa kompilacji warunkowej ma postać
#if warunek // linie kompilowane warunkowo #endif
Dyrektywa ta może wystąpić również w wersji
#if warunek // linie kompilowane gdy warunek zachodzi #else // linie kompilowane gdy warunek nie zachodzi #endif
Typowym zastosowaniem jest kompilacja zależna od procesora, systemu operacyjnego lub interfejsu użytkownika.
#if GUI == 1 #include <windows.h> .... #else .... #endif
Przykład - kompilacja warunkowa
W warunku dyrektywy #if można wykorzystać systemową makrodefinicję
defined(nazwa)
sprawdzającą czy podana nazwa została zdefiniowana
Czasami zdarza się, że pewna nazwa zdefiniowana w jakiejś bibliotece koliduje z naszym planem użycia tej nazwy. Wtedy może przydać się dyrektywa
#undef nazwa
która powoduje wymazanie z pamięci kompilatora wcześniej wyuczonej nazwy.
Dyrektywa włączenia pliku pozwala na włączenie do kompilowanego pliku dokładnie w miejscu wystąpienia tej dyrektywy zawartości innego pliku.
Dyrektywa włączenia pliku ma jedną z dwóch postaci
#include <nazwa_pliku> #include "nazwa_pliku"
Formalna różnica pomiędzy tymi formami sprowadza się do ustalenia kolejności w jakiej są przeszukiwane katalogi i zależy od kompilatora.
#ifndef JEDNOZNACZNY_OPIS_PLIKU #define JEDNOZNACZNY_OPIS_PLIKU // zawartość pliku #endif
W trakcie pracy preprocesora dostępne są następujące nazwy, których rozwinięcie definiuje sam preprocesor.
Przykład - użycie nazw predefiniowanych
Jako przykład funkcji o nieokreślonej licznie argumentów przedstawimy funkcje biblioteczne printf, scanf. Funkcje te realizują niskopoziomowe (a więc szybkie) operacje wejścia-wyjścia. Są ona dostępne po włączeniu pliku nagłówkowego cstdio.
Pierwszym argumentem printf jest napis, który może zawierać znaki sterujące, określające sposób interpretowania argumentów z listy argumentów o zmiennej długości. Najprościej zilustrować to na przykładzie
char towar[] = "chleb"; int ilosc=30; float cena=2.99; printf("Towar %s, ilosc %d, cena %f",towar,ilosc, cena);
Funkcja printf wypisuje na standardowe wyjście podany napis jednocześnie analizując jego zawartość. Znaki rozpoczynającej się od % to znaki specjalne. Każde wystąpienie takiego znaku będzie zastąpione kolejnym argumentem z listy podanym w czasie wywołania. Sposób interpretacji określają kody (litery): %s dla napisu, %d dla liczby całkowitej, %f dla liczby zmiennoprzecinkowej.
Szczegółowa dokumentacja funkcji jest bardzo długa - można ją znaleźć na przykład tutaj.
Funkcja scanf to funkcja realizująca niskopoziomowe czytanie. Jej format odpowiada funkcji printf - pierwszym argumentem jest napis, w którym kodujemy co chcemy przeczytać, kolejne argumenty to adresy obiektu, w których zostaną umieszczone przeczytane dane.
int i; float liczba; char bufor[100]; scanf("%d%f%s",&i,&liczba,bufor);
Przykład - funkcje wejścia-wyjścia
Pierwszym argumentem funkcji printf i scanf jest tekst, który decyduje o sposobie w jaki będą wypisywane kolejne argumenty. Może on zawierać znaczniki formatujące postaci
%[flagi][szerokosc][.precyzja][modyfikator]typ
typ | oczekiwany typ argumentu | Wyjście | Przykład |
---|---|---|---|
c | char | znak | a |
d lub i | int | liczba całkowita ze znakiem w zapisie dziesiętnym | 392 |
e lub E | float | liczba w notacji naukowej | 3.9265e+2 |
f | float | liczba rzeczywita z przecinkiem | 392.65 |
g lub G | float | liczba rzeczywista (format zależny do ilości cyfr znaczących) | 392.65 |
s | const char * | tekst | sample |
u | unsigned int | liczba całkowita bez znaku w zapisie dziesiętnym | 7235 |
x lub X | unsigned int | liczba całkowita bez znaku w zapisie szesnastkowym | 7fa lub 7FA |
p | wskaźnik | adres pamięci | B800:0000 |
Flagi
flaga | znaczenie |
- | wyrównanie do lewej (do prawej jest domyślne), |
+ | wymusza znak + przed liczbami dodatnimi, |
spacja | wstawia spację przed liczbami dodatnimi, |
0 | jeżeli liczba jest, któtsza niż podana długość to uzupełnia zerami (domyślnie spacjami) |
Przykład - Formatowanie wyjścia
Funkcje do operacji na plikach znajdziemy w pliku nagłówkowym cstdio.
Typowy sposób pracy z plikiem, to:
Do otwierania i zamykania plików służą funkcje
FILE * fopen ( const char * filename, const char * mode ); int fclose ( FILE * stream );
Jak widać funkcja otwierająca plik ma dwa argumenty
Funkcja fopen zwraca wskaźnik do struktury FILE opisującej plik lub wskaźnik NULL, jeśli nie udało się utworzyć pliku. Typowe otwarcie pliku może wyglądać tak:
FILE* file=fopen("plikTekstowy.txt","w+t"); if(file==NULL) { printf("Nie moge otworzyc pliku!"); } else { .... // operacje na pliku fclose(file); }
Uchwyt (wskaźnik) pliku jest argumentem funkcji fclose, którą należy wywołać po zakończeniu operacji na pliku.
Plik tekstowy, to plik który zawiera dane przetłumaczone na format czytelny dla człowieka. Tekst może być formatowany w linie i akapity (tak jak w edytorze tekstowym), liczby są zamieniane na napisy (pamiętajmy, że w pamięci komputera liczby są przechowywane w postaci binarnej) i drukowane do pliku. Odczyt takiej liczby wymaga analizy napisu zawartego w pliku i zamiany go na liczbę. Operacje na plikach tekstowych są wolne, ale ich zawartość jest "czytelna".
Zapis do pliku można realizować przy pomocy funkcji
int fprintf ( FILE * stream, const char * str, ... );
Jej składnia jest prawie taka sama jak omówionej wcześniej funkcji printf. Różnią się tym, że funkcja fprintf ma dodatkowy argument - uchwyt pliku, do którego chcemy zapisywać. Poza tym sposób zapisu do liku jest taki jak na ekran.
Odpowiednikiem funkcji scanf dla plików jest fscanf
int fscanf ( FILE * stream, const char * str, ... );
Przykład - zapis i odczyt pliku w trybie tekstowym
Wyjście nieformatowane jest dużo szybsze gdyż podany łańcuch tekstowy nie jest interpretowany. Odpowiedni łańcuch tekstowy powinien przygotować programista samodzielnie np. dokonując odpowiednich konwersji.
Zapis do pliku odpowiednio znaku lub łańcucha tekstowego realizujemy przez
int fputc (char c, FILE * stream); int fputs (const char * str, FILE * stream);
Odpowiedni aby przeczytać jeden znak lub łańcuch tekstowy możemy użyć funkcji
int fgetc (char c, FILE * stream); int fgets (const char * str, int num, FILE * stream);
Druga z funkcji czyta num-1 znaków z strumienia stream chyba, że wcześniej napotka znak końca lini lub końca pliku.
Pliki binarne zawierają dane w takiej postaci, w jakiej są przechowywane w pamięci komputera. Jest to po prostu zrzut fragmentu pamięci komputera. Zawartość pliku na ogół jest nieczytelna dla człowieka - widzimy zwykłe "krzaki".
Operacje na plikach binarnych są szybkie gdyż nie musimy konwertować danych z tekstu na ich właściwy format. Jeśli tylko człowiek nie musi czytać i rozumieć zawartości pliku, to preferowane jest zapisywanie w formacie binarnym. Dane w pliku binarnym traktowane są jak ciąg bajtów.
Biblioteka C/C++ dostarcza funkcji odczytywania i zapisywania zbioru bajtów. Są to
size_t fread ( void * ptr, size_t size, size_t count, FILE * stream ); size_t fwrite ( const void * ptr, size_t size, size_t count, FILE * stream );
Argumenty w tych funkcjach oznaczają:
Nazwa | Opis |
ptr | miejsce w pamięci skąd/dokąd będą zapisywane dane |
size | rozmiar pojedynczego elementu odczytywanego/zapisywanego |
count | ilość odczytywanych/zapisywanych elementów |
stream | uchwyt pliku, na którym ma być wykonana operacja odczytu/zapisu |
Przykład - zapis i odczyt w trybie binarnym
Istnieje cały szereg innych funkcji do operacji na plikach binarnych, w tym funkcji wyszukujących, przesuwających wskaźnik pliku, itd. Listę tych funkcji i ich opisy można znaleźć w sieci, na przykład tutaj.
Czytając lub zapisując do pliku automatycznie przesuwamy wskaźnik aktualnej pozycji w pliku. Ale możemy ten wskaźnik także przesuwać "ręcznie".
Aktualną pozycję w pliku możemy odczytać korzystając z funkcji
long int ftell ( FILE * stream ); int fgetpos ( FILE * stream, fpos_t * position );
Aby sprawdzić czy osiągnięty został koniec pliku możemy użyć funkcji
int feof(FILE * stream);
Jeżeli osiągnieto koniec pliku to kolejne operacje na pliku nie będą miały efektu chyba, że użyjemy jedna z funkcji
void rewind( FILE * stream ); int fseek ( FILE * stream, long int offset, int origin ); int fsetpos ( FILE * stream, const fpos_t * pos );
Do tej pory używaliśmy pliku nagłówkowego iostream i strumieni cin, cout do realizacji czytania z klawiatury i wypisywania na ekran. Podamy teraz nieco więcej szczegółów dotyczących realizacji tych operacji.
W C++ operacje wejścia-wyjścia są realizowane za pomocą strumieni. Strumień możemy sobie wyobrażać jako strumień danych (bajtów) płynący od źródła do ujścia. Źródłem może być klawiatura, plik, pamięć, modem, potok nazwany, gniazda, skaner, .... Ujściem może być ekran, drukarka, plik, pamięć, ....
W bibliotece są zdefiniowane dwa podstawowe rodzaje strumieni
Wśród tych dwóch kategorii biblioteka standardowa dostarcza klas realizujących szczególne rodzaje operacji wejścia-wyjścia
Po dołączeniu któregoś z plików nagłówkowych iostream, sstream, fstream mamy do dyspozycji predefiniowane cztery strumienie
cout << "Napis";
operator<< (cout, "Napis");
Pozwala to na rozszerzenie funkcjonalności także na nasze własne typy. Wystarczy dla naszego typu np. Towar przeładować funkcję
std::ostream & operator<< (std::ostream & strumien, const Towar & t);
Potem możemy wypisywać nasz typ dokładnie tak samo jak typy wbudowane
Towar kielbasa; cout << kielbasa << endl;
Przykład - wypisywanie własnych typów
Dla każdego ze strumieni możemy ustawić flagi, które będą decydują o sposobie działania strumienia np. w jaki sposób dane są formatowane przy wypisywaniu na ekran.
Flagi zdefiniowane w klasie bazowej
Flaga | Grupa | Znaczenie |
---|---|---|
skipws | Podczas wczytywania będą ignorowane białe znaki. | |
left right internal |
adjustment | Określa sposób wyrównywania (justowania) odpowiednio do lewej, prawej oraz przez wstawienie znaków wypełniających na ustalonej wewnętrznej pozycji np.
-7 -7 - 7 |
dec oct hex | basefield | Decyduje w jakim systemie będą wypisywane i wczytywane liczby, odpowiednio: dziesiętnym, ósemkowym, szesnastkowym. |
showbase | Decyduje czy dla liczb ósemkowych i szesnastkowych będą dodawane odpowiednie przedrostki 0 i 0x | |
uppercase | Decyduje czy w zapisie liczb będą stosowane dużelitery np. 0X1FA23, 1.244E-12 | |
showpoint | Kropka dziesiętna będzie zawsze wypisywana a po niej odpowiednia liczba cyfr (nawet jeżeli są zerami) | |
scientificfixed | floatfield | Określa sposób wypisywania liczb zmiennoprzecinkowych: naukowy (scientific) lub dziesiętny (fixed). Jeżeli żadna z flag nie jest ustawiona to sposób wypisywania zależy od liczby i ustawionej precyzji. |
unitbuf | decyduje czy dany strumień jest buforowany |
Stan flag strumienia możemy zmienić używając metod setf oraz unsetf
cout.setf(ios::showpoint); cin.unsetf(ios::skipws);
Dla flag występujących w grupie możemy użyć metody setf(flaga, grupa), która nie tylko ustawi daną flagę ale także wyzeruje pozostałe flagi w danej grupie.
cout.setf(ios::hex, ios::basefield);
Aby określić minimalną szerokość na jakiej należy wypisać daną liczbę czy tekst możemy użyć metody
cout.width(20); cout << 7.12 << endl;
char nazwa[20]; cin.setw(20); cin >> nazwa;
Metoda
float x = 0.123456789, y = 2; cout << x << " " << y << endl; // 0.123456 2 cout.precision(8); cout << x << " " << y << endl; // 0.12345678 2 cout.setf(ios::showpoint); cout << x << " " << y << endl; // 0.12345678 2.00000000
Dużo wygodniejszym sposobem zmiany formatowania jest wykorzystanie tzw.
Zamiast pisać
cout.setf(ios::showpoint|ios:fixed); cout.precision(10); cout.width(20); cout << 124.43225425 << endl;
możemy napisać
cout << showpoint << fixed << setprecision(10) << setw(20); cout << 124.43225425 << endl;
Manipulator | Znaczenie |
---|---|
setprecision(n) | Ustawia dokładność wypisywania liczb zmiennoprzecinkowych. |
setw(n) | Ustawia szerokość pola dla danych wejściowych oraz wyjściowych. |
skipws noskipws | Podczas wczytywania będą (nie będą) ignorowane białe znaki. |
left right internal |
Określa sposób wyrównywania (justowania) odpowiednio do lewej, prawej oraz przez wstawienie znaków wypełniających na ustalonej wewnętrznej pozycji np.
-7 -7 - 7 |
dec oct hex | Decyduje w jakim systemie będą wypisywane i wczytywane liczby, odpowiednio: dziesiętnym, ósemkowym, szesnastkowym. |
showbase | Decyduje czy dla liczb ósemkowych i szesnastkowych będą dodawane odpowiednie przedrostki 0 i 0x |
uppercase | Decyduje czy w zapisie liczb będą stosowane dużelitery np. 0X1FA23, 1.244E-12 |
showpoint | Kropka dziesiętna będzie zawsze wypisywana a po niej odpowiednia liczba cyfr (nawet jeżeli są zerami) |
scientific fixed | Określa sposób wypisywania liczb zmiennoprzecinkowych: naukowy (scientific) lub dziesiętny (fixed). Jeżeli żadna z flag nie jest ustawiona to sposób wypisywania zależy od liczby i ustawionej precyzji. |
endl | Wstawia znak końca wiersza i opróżnia bufor strumienia wyjściowego. |
flush | Opróżnia bufor strumienia. |
ws | Odczytuje oraz ignoruje białe znaki. |
Biblioteka języka C++ dostarcza operacji strumieniowych na plikach (tak tekstowych jak i binarnych) za pomocą strumieni. Aby skorzystać z tych klas należy dołączyć plik nagłówkowy fstream.
ifstream plikDoOdczytu; plikDoOdczytu.open("nazwaPliku.txt"); plik >> zmienna; plikDoOdczytu.close();
Jak widać w powyższym przykładzie operacje na plikach tekstowych traktowanych jako strumienie wygląda tak samo jak korzystanie ze strumieni cin, cout. Przy otwieraniu pliku możemy posłużyć się manipulatorami do ustalenia trybu otwarcia pliku. Tryby te można ze sobą łączyć za pomocą alternatywy bitowej. Na przykład
ofstream myFile; myFile.open("f.bin",ios::app | ios::binary); myFile.write(dane,rozmiar); myFile.close();
W powyższym przykładzie manipulator ios::app oznacza, że plik jest otwarty do dopisywania (append), ios::binary oznacza otwarcie pliku w trybie binarnym.
Przykład - odczyt plików za pomocą strumieni