Podręcznik

Strona: SEZAM - System Edukacyjnych Zasobów Akademickich i Multimedialnych
Kurs: 4. Złożone struktury danych
Książka: Podręcznik
Wydrukowane przez użytkownika: Gość
Data: sobota, 23 listopada 2024, 12:56

1. Tablice jednowymiarowe

W tym rozdziale zostaną omówione typy  tablicowe. Rozpoczniemy od przedstawienia tablic jednowymiarowych. Tablice jednowymiarowe w języku C++ to struktury danych, które umożliwiają przechowywanie wielu elementów tego samego typu pod jednym wspólnym identyfikatorem. Każdy element w tablicy jest indeksowany, co pozwala na dostęp do konkretnego elementu na podstawie jego położenia. 



Wielkość pamięci potrzebnej na zapamiętanie tablicy jest określana przez kompilator jako proste przemnożenie ilości elementów w tablicy przez rozmiar pojedynczego elementu. Oczywiście tablica może zawierać elementy dowolnego typu prostego.
Ogólna składnia deklaracji zmiennej tablicowej jest następująca:
typ_elementów  nazwa_zmiennej [rozmiar_tablicy];

rozmiar_tablicy musi być dodatnią liczbą, której wartość da się ustalić na etapie kompilacji, a więc musi być wyrażeniem typu całkowitego zawierającym stałe zdefiniowane wcześniej, operatory arytmetyczne i nawiasy. Najczęściej używa się tu liczb całkowitych lub stałych całkowitych.

Poniżej przedstawiono przykład deklaracji i inicjalizacji tablicy pięcioelementowej zawierającej liczby całkowite.
int tablica[5]; // Deklaracja tablicy na 5 elementów typu int
Należy starać się deklarować tablice wykorzystując do określenia rozmiaru tablicy wcześniej zdefiniowane stałe.
Tablice w języku C++ nie są automatycznie inicjalizowane. Oznacza to, że ich wartości mogą być niezdefiniowane. Można w trakcie inicjalizacji tablicy wstawić do niej wartości za pomocą nawiasów klamrowych.

int tablica[5] = {1,2,3,4,5}
    
Możliwe jest także podanie jedynie początkowych wartości w tablicy. Pozostałe elementy zostaną wypełnione zerami.

cont int n = 5;
int A[n] = {0};
    
Standard ISO C++ nie pozwala na wczytywanie rozmiaru tablicy jako zdefiniowanej wcześniej zmiennej. Nie jest wiec możliwe deklarowanie tablic w następujący sposób:


int n;
cin >> n;
double A[n];
        

Tablice o zmiennym rozmiarze (ang. VLAs — Variable Length Arrays) są obsługiwane w niektórych innych językach, takich jak C (od standardu C99), ale nie są częścią standardu C++. Niektóre kompilatory (np. GCC) mogą je wspierać jako rozszerzenie specyficzne dla kompilatora, ale nie jest to zgodne ze standardem. Używanie tego rodzaju rozszerzeń może prowadzić do kodu, który nie jest przenośny między różnymi kompilatorami i środowiskami.
Dostęp do poszczególnych elementów tablicy uzyskuje się za pomocą indeksu. Indeksy rozpoczynają się od wartości 0, natomiast kończą się na wartości n-1 (gdzie n to rozmiar tablicy). A więc w przypadku deklaracji int a[5]; ostatnim elementem tablicy jest element o indeksie 4, czyli a[4]. Zobaczcie to na rysunku:


W przypadku przekroczenia zakresu indeksu, czyli odwołania się do nieistniejącego elementu tablicy, wystąpi błąd w trakcie wykonywania programu. Przekroczenie zakresu indeksu tablicy jest często spotykanym, a przy tym wyjątkowo trudnym do zlokalizowania błędem w programach pisanych w C++.

Poniżej przedstawiono przykład odwołania do pierwszego oraz trzeciego elementu tablicy. 

int pierwszyElement = tablica[0]; // Dostęp do pierwszego elementu.
int trzeciElement = tablica[2]; // Dostęp do trzeciego elementu.
    
Możemy modyfikować elementy w analogiczny sposób:

tablica[0] = 10; // Zmieniamy wartość pierwszego elementu na 10.
tablica[1] = 20; // Zmieniamy wartość drugiego elementu na 20.
    
Często do wstawiania elementów do tablicy oraz drukowania zawartości tablicy wykorzystywana jest pętla for. Poniżej przedstawiono przykład wczytywania elementów do tablicy jednokierunkowej zawierającej liczby całkowite.

const int n = 5;
int A[n]={0};

// wczytywanie danych do tablicy
for (int i = 0; i < n; i++)
    cin >> A[i];

// drukowanie zawartości tablicy
for (int i = 0; i < n; i++)
    cout << A[i] << endl;
    
Poniżej przedstawiono film demonstrujący metodę przesuwania cyklicznego elementów w tablicy jednowymiarowej o jedną wartość w prawo.
   W nagranej symulacji przepisywania cyklicznego występuje zmienna o nazwie schowek. Jest to zmienna tego samego typu co elementy znajdujące się w tablicy. Służy do zapamiętania wartości ostatniej w tablicy, aby nie została nadpisana. W taki sposób możliwa jest zamiana wartości pomiędzy dwiema zmiennymi. Nie ma możliwości wykonania takiej operacji w inny sposób niż przy wykorzystaniu zmiennej pomocniczej.

1.1. Napisy

Do znaków napisu odwołujemy się jak do elementów tablicy jednowymiarowej, poprzez indeksy, zaczynając od 0. Przy tym rozmiar napisu, czyli liczbę jego znaków można wyznaczyć za pomocą funkcji size(). Funkcja ta nie ma parametrów (wnętrze nawiasów jest puste) i zapisujemy ją w tzw. notacji kropkowej.

Jeśli napis jest zmienną typu string, to jego długość (liczba znaków) jest równa n = napis.size(), pierwszy znak to napis[0], zaś ostatni znak to napis[n-1] lub inaczej: napis[napis.size()-1].




string napis = "Napis"; // początkowa wartość napisu
cout << napis[0]; // drukuje pierwszy znak napisu, czyli literę N
cout << napis [imie.size()-1]; // drukuje ostatni znak napisu, czyli literę s
cout << napis + ".cpp " // drukuje napis Napis.cpp
    

2. Tablice wielowymiarowe

W języku C++ możliwa jest także implementacja tablic wielowymiarowych. Tablice wielowymiarowe statyczne w języku C++ to struktury danych pozwalające przechowywać elementy tego samego typu w wielu wymiarach. Możliwe jest tworzenie tablic o w zasadzie dowolnym zagnieżdżeniu. 

Ogólna postać definicji tablic wielowymiarowych jest następująca:

typ_elementów  nazwa_zmiennej [rozmiar 1] [rozmiar 2]...[rozmiar n];

gdzie rozmiar 1, rozmiar 2, ... , rozmiar n - są odpowiednimi zakresami indeksów dla poszczególnych wymiarów. Czyli, jak wspomnieliśmy - nic nowego.

Poniżej przedstawiono przykład definicji tablicy trójwymiarowej o wymiarach 4x5x8.
int tab[4][5][8];
Najczęściej spotykaną formą tablic wielowymiarowych są tablice dwuwymiarowe, które można sobie wyobrazić jako macierze lub tabele o ustalonej liczbie wierszy i kolumn.
Tablicę dwuwymiarową możemy zdefiniować w następujący sposób: typ_danych nazwa_tablicy[liczba_wierszy][liczba_kolumn]; gdzie liczba_wierszy - liczba wierszy w tablicy, liczba_kolumn - liczba kolumn w tablicy.
Inicjalizacja tablicy może być połączona z jej definicją analogicznie jak w przypadku tablicy jednowymiarowej.
const int w=3, k=4;
int tab[w][k]={2,3,8,6,4,2,9,0,7,1,12,5};
Kolejne wartości podane w nawiasach klamrowych umieszczane są w tablicy wierszami. Analogicznie jak w przypadku tablic jednowymiarowych możemy uzupełnić tablicę wartościami zerowymi podczas deklaracji.
const int w=3, k=4; int tab[w][k]={0};
Dostęp do poszczególnych elementów odbywa się za pomocą indeksów. W przypadku tablicy dwuwymiarowej pierwszy podawany jest indeks kolumny, a następnie indeks wiersza tablicy. Wartości indeksów zmieniają się od 0 do n-1 (gdzie n - rozmiar danego wymiaru tablicy). 




Poniżej przedstawiono nagranie demonstrujące deklarację tablicy dwuwymiarowej, wypełnianie jej przykładowymi danymi oraz drukowanie zawartości tablicy na ekranie.
  Szczególnym przypadkiem tablic dwuwymiarowych są tablice kwadratowe, które mają tyle samo kolumn i wierszy, więc można wyróżnić w nich dwie przekątne: główną przekątna i drugą przekątną

Możemy wyróżnić elementy znajdujące się na głównej przekątnej.


oraz elementy znajdujące się na drugiej przekątnej.


Poniżej przedstawiono przykładową aplikację, która umożliwia wydrukowanie na ekranie wartości z głównej przekątnej tablicy oraz z drugiej przekątnej.
  
Wartość możemy wydrukować przy pomocy pojedynczej pętli for.  Nie ma konieczności wykorzystywania dwóch pętli. Jeśli mamy tablicę tab[n][n] i chcemy wydrukować elementy jej głównej przekątnej, robimy to tak:

for (int i = 0; i < n; i++)
    cout << a[i][i] << "\t";
    
Natomiast po drugiej przekątnej poruszamy się tak:

for (int i = 0; i < n; i++)
    cout << a[i][n-1-i] << "\t";
    
W tablicy kwadratowej oprócz elementów leżących na poszczególnych przekątnych możemy wyróżnić m.in.:
  • elementy leżące nad główną przekątną,
  • elementy leżące pod główną przekątną,


  • elementy leżące pod drugą przekątną,
  • elementy leżące nad drugą przekątną,


  • elementy leżące nad obiema przekątnymi,
  • elementy leżące pod obiema przekątnymi,
  • elementy leżące na prawo od obu przekątnych,
  • elementy leżące na lewo od obu przekątnych,


  • elementy leżące na obwodzie tablicy oraz poza obwodem.

To te najbardziej charakterystyczne. Zapewne jest dużo więcej możliwości. W kolejnym przykładzie zademonstrujemy w jaki sposób "przechodzić" po tych charakterystycznych elementach. Przykład demonstruje w jaki sposób wydrukować elementy znajdujące się nad główną przekątną.

 

 Analogicznie jak w przedstawionym przykładzie można "przechodzić" po innych charakterystycznych elementach tablicy kwadratowej. Należy przeprowadzić w każdym przypadku analizę w jaki sposób zmieniają się indeksy kolumn oraz wierszy.




2.1. Generowanie liczb losowych

We wszystkich pokazywanych do tej pory przykładach tablice były wypełniane danymi podawanymi przez użytkownika. Zwłaszcza w przypadku tablic wielowymiarowych o dużych wymiarach jest to proces czasochłonny. Tablice możemy wypełniać także za pomocą losowania do nich wartości. W tym celu należy wykorzystać funkcję rand() z biblioteki cstdlib. Funkcja generuje liczbę losową całkowitą o rozkładzie jednostajnym z przedziału <0, RAND_MAX>, gdzie RAND_MAX typu int jest stałą (równą 32767). Domyślnie funkcja rand() korzysta z predefiniowanego ziarna losowości, więc każdorazowe uruchomienie programu bez zmiany tego ziarna spowoduje wygenerowanie takiej samej sekwencji "losowych" liczb. Aby to zmienić, stosuje się funkcję srand(unsigned int seed), która pozwala ustawić ziarno na nową wartość. Typowo stosuje się funkcję srand(time(0)), by ziarno było oparte na aktualnym czasie systemowym, co zapewnia większą losowość przy każdym uruchomieniu programu. Korzystając z funkcji rand() możliwe jest wylosowanie liczby z dowolnego przedziału za pomocą prostych operacji matematycznych.


Za pomocą wyprowadzonego wzoru możemy losować liczbę rzeczywistą (double) z przedziału <a;b>. Korzystając z właściwości działania modulo możemy w analogiczny sposób wyprowadzić wzór na losowanie liczb całkowitych.


Wzór umożliwia wylosowanie liczby całkowitej (int) z przedziału <a;b>. 




2.2. Formatowanie wydruków

Sterowanie sposobem prezentacji liczb zmiennoprzecinkowych jest możliwe poprzez ustalenie ich formatu i precyzji. Dostępne są trzy formaty:

  1. Format ogólny pozwala na wyświetlanie liczb możliwie najdokładniej uwzględniając dostępne miejsce. Precyzja w tym przypadku oznacza maksymalną liczbę cyfr.
  2. Format naukowy prezentuje wartość w postaci wykładniczej z jedną cyfrą przed kropką dziesiętną i wykładnikiem. Precyzja określa maksymalną liczbę cyfr po kropce.
  3. Format stały prezentuje wartość w sposób naturalny, taki do jakiego jesteście przyzwyczajeni. Zawsze występuje część całkowita, po której występuje część ułamkowa. Precyzja określa liczbę cyfr po kropce.
Domyślna precyzja w C++ wynosi 6, co oznacza że liczba 0.123456789 będzie wyświetlona jako 0.123457.
W przypadku wyjścia strumieniowego ustawienie obu parametrów w C++ najwygodniej jest wykonać za pomocą standardowych manipulatorów. Jest to zbiór funkcji do manipulowania stanem strumienia. Tak więc precyzję będziemy ustalali za pomocą manipulatora setprecision, natomiast wymuszenie formatowania stałego bądź naukowego jest możliwe poprzez wybór formatu fixed lub scientific.

Przykład wyjaśnia, jak ustawić precyzję, natomiast aby zmienić tryb wypisywania na naukowy, podajemy:

  cout << scientific;

Natomiast  tryb stałoprzecinkowy ustawiamy tak:

 cout << fixed;

Bardzo ważne jest też ustawienie równej szerokości pól przeznaczonych na drukowanie liczb. Szerokość pola wydruku ustawia się następująco (uwaga: dla każdej drukowanej pozycji osobno):

  cout << setw(7);

A oto najwygodniejszy sposób, który pozwala w jednej instrukcji ustawić format, precyzję i szerokość wydruku, np.

  cout << fixed << setprecision(3) << setw(6) << x << y  <<endl;
Aby uzyskać wydruk liczb z tablicy w ładnych, równych kolumnach (o jakiejś szerokości w) i z wymaganą precyzją d miejsc po kropce dziesiętnej, najprościej jest dołączyć bibliotekę iomanip i używać tych standardowych manipulatorów w sposób podany poniżej.

a) przed drukowaniem czegokolwiek ustawić stały format wydruku za pomocą manipulatora fixed (tzn. w postaci naturalnej, z kropką dziesiętną) oraz precyzję wydruku, czyli liczbę miejsc po kropce, używając manipulatora setprecision, w sposób następujący:

 cout << fixed << setprecision(d);

wartość d może być oczywiście konkretną liczbą.

b) w instrukcji drukowania przed wyprowadzeniem jakiejś wartości podać, jaką szerokość w ma ona zająć, używając manipulatora setw, np.:

cout << setw(4) << x << setw(7) << y << endl;

albo:

cout << setw(12) << x[i][j]; 

Oczywiście precyzję można ustawiać tak jak szerokość wydruku - w pętli, do każdej drukowanej wartości inną.



3. Pliki tekstowe

Wczytywanie danych z pliku odbywa się w sposób analogiczny jak w przypadku strumieniowej obsługi strumienia wejścia oraz wyjścia konsoli. W przypadku obsługi konsoli wykorzystywaliśmy bibliotekę iostream, natomiast w przypadku obsługi plików tekstowych wykorzystywana jest biblioteka fstream

W tej bibliotece znajdziemy dwie główne klasy do obsługi plików:

  1. std::ifstream – służy do strumieniowego odczytu z plików.
  2. std::ofstream – służy do strumieniowego zapisu do plików.

Do otwarcia pliku używamy obiektu jednej z klas strumieniowych i metody open(), podając jako argument nazwę pliku oraz (opcjonalnie) tryb otwarcia. 

ifstream plk_we; // zmienna plk_we określa plik do odczytu
plk_we.open("dane.txt");
Jako parametr metody open podawana jest ścieżka dostępu do pliku. Jeżeli nie podamy pełnej ścieżki dostępu do pliku, a jedynie jego nazwę z rozszerzeniem to musi ona być umieszczona w katalogu roboczym. 

Domyślnie katalog roboczy to:
  • Katalog, w którym znajduje się plik wykonywalny, jeśli uruchamiasz program bezpośrednio.
  • Katalog, z którego uruchamiasz program z terminala lub konsoli.
Aby upewnić się, w jakim katalogu program szuka plików, można sprawdzić katalog roboczy w kodzie:


#include <iostream>
#include <filesystem> // C++17

int main() {
    std::cout << std::filesystem::current_path() << std::endl;
    return 0;
}
    
Funkcja std::filesystem::current_path() (dostępna od C++17) zwróci aktualny katalog roboczy, co pozwoli zobaczyć, skąd program próbuje otworzyć plik.
Przy wpisywaniu ścieżek dostępu należy być bardzo ostrożnym. W stałych napisowych w C++ znak odwróconego ukośnika (backslash) jest znakiem specjalnym, więc jeśli chcecie uzyskać go jako znak, musi wystąpić dwa razy. Tak więc ścieżkę C:\katalog\plik w kodzie C++ zapisujemy "C:\\katalog\\plik". Możemy także zastosować zapis "C:/katalog/plik".
Aby funkcja działała w sposób prawidłowy nalęzy upewnić się że plik istnieje i jest możliwy jego odczyt. W tym celu możemy wykorzystać dwie metody good() lub nowszej i bardziej uniwersalnej is_open().

int main() {
…
    dane.open (... );
    if ( !dane.is_open() ) { // jeśli nie ma podanego pliku
        cout << " Blad otwarcia pliku" << endl ;
        return 1; // błędne zakończenie pracy programu
    }
    // dalej działania na plikach
    // i cala reszta programu

    return 0; // poprawne zakończenie pracy programu
}
Metoda is_open() służy do sprawdzenia, czy plik został poprawnie otwarty, zwracając true, jeśli plik jest otwarty, a false, jeśli otwarcie się nie powiodło. Nie monitoruje ona stanu operacji odczytu czy zapisu. Z kolei metoda good() ocenia ogólny stan strumienia, sprawdzając, czy wszystkie operacje przebiegały bez błędów, w tym odczyt i zapis, oraz czy strumień nadal działa poprawnie. Metoda good() wykrywa błędy takie jak koniec pliku lub problemy z odczytem, a is_open() dotyczy wyłącznie poprawności otwarcia pliku. Nazwa pliku otwieranego do odczytu lub zapisu może być wczytywana, czyli może byc zmienną, ale w tym szczególnym przypadku zmienna ta nie może być typu string. W przypadku straszych wersji C++ (np. C++98 lub C++03), funkcje takie jak open() w klasie std::ifstream czy std::ofstream wymagały podania ścieżki pliku w postaci wskaźnika do tablicy znaków typu const char*. W tych starszych wersjach C++, obiekty typu std::string nie były automatycznie konwertowane na tablice znaków, dlatego konwersja przez c_str() była potrzebna. Przykład w starszych wersjach C++:
std::string nazpl = "dane.txt";
std::ifstream dane;
dane.open(nazpl.c_str()); // Konwersja std::string na const char* jest konieczna
Od C++11, standard został zaktualizowany, aby funkcje open() przyjmowały również argumenty typu std::string, dzięki czemu wywołanie c_str() nie jest już konieczne. Można bezpośrednio przekazywać obiekt typu std::string do metody open().
std::string nazpl = "dane.txt";
std::ifstream dane;
dane.open(nazpl); // Nie trzeba używać c_str() w C++11 i nowszych
Warto wiedzieć, że możemy wybrać różne tryby otwarcia pliku, wykorzystując flagi std::ios. Najczęściej używane tryby to:
  • std::ios::in – otwarcie pliku do odczytu,
  • std::ios::out – otwarcie pliku do zapisu (usuwa poprzednią zawartość pliku),
  • std::ios::app – otwarcie pliku w trybie dopisywania (dodawanie danych na końcu pliku),
  • std::ios::binary – otwarcie pliku w trybie binarnym,
  • std::ios::ate – otwarcie pliku i ustawienie wskaźnika na koniec.
Przykład otwarcia pliku w trybie binarnym i dopisywania:
dane.open("nazwa_pliku.bin", std::ios::binary | std::ios::app);  
Po otwarciu pliku możemy zacząć z niego korzystać. Po zakończeniu zaś pracy z plikiem, musicie go zamknąć, czyli wywołać metodę close().
dane.close();
Dane z pliku odczytywane są w sposób strumieniowy, analogicznie jak w przypadku odczytu danych z klawiatury.zmienna_plikowa >> zmienna_1 >> zmienna_2 >> ... >> zmienna_n;
Ze strumienia można wczytywać bezproblemowo zmienne typów prostych, przy czym zawsze wykonana zostanie odpowiednia konwersja i rzutowanie. 

Często istnieje konieczność sprawdzenia czy wczytany został znak końca pliku. W tym celu możemy wykorzystać metodę eof(), która sprawdza, czy osiągnięto koniec pliku (ang. end of file). Metodę możemy wykorzystać w połączeniu z pętlą while w celu wczytywania danych z pliku do momentu, gdy zostaną wczytane wszystkie dane.
while (!plk.eof())
    // dopóki nie napotkano końca pliku, wykonuj
    
W programie tym wczytywaliśmy plik znak po znaku. Można także korzystać z innych metod odczytu - bezpośredniego pobierania całego wiersza do bufora. Można nawet (w zadaniu tak zdefiniowanym jak powyższy przykład) w ogóle nie odczytywać jawnie danych, tylko w odpowiedni sposób powiązać ze sobą bufory pliku i ekranu - a wyświetlanie zawartości wykona się "samo ". Jednakże to już jest wyższa szkoła jazdy, i wymaga programowania obiektowego. Tu wspomnimy jedynie o możliwości odczytu całej linii naraz za pomocą instrukcji getline. Pamiętamy z początku tego rozdziału, że funkcja getline przyjmuje jako argument zmienną typu string. Wywołując tę funkcję wczytujemy całą linię (aż do napotkania znaku końca linii) do zmiennej przekazanej jako drugi parametr. Jeśli wiec wczytujemy całą linię z pliku, to pierwszym parametrem jest plik, z którego dane odczytujemy. Tę funkcję pokażemy w ostatnim przykładzie z tej lekcji, w następnym podrozdziale. Sposób jej wywołania jest następujący:
// zmienna, do której wczytujemy linię
string linia;
...
// Nasza zmienna plikowa nazywa się plk
getline(plk, linia);
Zapisywanie danych do pliku w C++ odbywa się przy użyciu klasy std::ofstream, która jest częścią biblioteki <fstream>. Aby zapisać dane, należy utworzyć obiekt std::ofstream i otworzyć plik, podając jego nazwę. Jeśli plik nie istnieje, zostanie utworzony, a jeśli istnieje, jego zawartość zostanie nadpisana (chyba że użyjemy trybu dopisywania std::ios::app).
std::ofstream plk;
plk.open("plik.txt");
Po otwarciu pliku, dane można zapisywać za pomocą operatora <<, podobnie jak przy wypisywaniu na konsolę.
plk << "Wartosc zmiennej: " << zmienna << endl;
Na koniec, po zakończeniu operacji zapisu, należy zamknąć plik, wywołując metodę close(), aby upewnić się, że wszystkie dane zostały poprawnie zapisane i zasoby zostały zwolnione.

4. Struktury

W języku C++ struktury (ang. structures) są typem złożonym, który pozwala na grupowanie różnych typów danych w jedną jednostkę. 

Struktura, czyli typ rekordowy,  służy w C++  do powiązania ze sobą wielu pól danych dowolnego typu.
Możliwe jest więc zdefiniowanie struktury, która będzie zawierała pola różnych typów, które są przeznaczone do przechowywania różnych informacji. Poniżej przedstawiono przykładową implementację struktury zawierającej dwa pola.
struct NazwaStruktury {
    typDanych1 pole1;
    typDanych2 pole2;
    // inne pola
};
Należy pamiętać, aby definicję struktury zakończyć średnikiem.
Po zdefiniowaniu struktury możemy za pomocą jej nazwy zdefiniować zmienną tego typu. Odwołanie do pól struktury odbywa się za pomocą kropki umieszczonej za nazwą zmiennej. Po kropce mamy dostęp do wszystkich zdefiniowanych pól w strukturze. Poniżej przedsatwiono prosty przykład aplikacji w której zdefiniowano strukturę przechowującą dane osoby. Następnie w kodzie aplikacji przedstawiony jest sposób odwołania do poszczególnych pól struktury.
#include <iostream>

struct Osoba {
    std::string imie;
    int wiek;
    float waga;
};

int main() {
    // Inicjalizacja struktury
    Osoba osoba1;
    osoba1.imie = "Jan";
    osoba1.wiek = 30;
    osoba1.waga = 75.5;

    // Dostęp do pól struktury
    std::cout << "Imię: " << osoba1.imie << std::endl;
    std::cout << "Wiek: " << osoba1.wiek << std::endl;
    std::cout << "Waga: " << osoba1.waga << std::endl;

    return 0;
}
W powyższym przykładzie Osoba to struktura zawierająca trzy pola: imie (typ std::string), wiek (typ int) i waga (typ float). W funkcji main tworzony jest obiekt osoba1, a następnie przypisywane są wartości do pól tej struktury.

  Każde pole danej struktury musi mieć w jego obrębie unikalną nazwę - co oznacza, że pomiędzy nawiasami klamrowymi po słowie struct nie może znaleźć się dwa razy ta sama nazwa. Struktury należy wczytywać i drukować odwołując się do kolejnych pól. Nie wolno tych operacji wykonywać na całych rekordach. Rekordy można na siebie kopiować w całości, poprzez instrukcję przypisania (nie trzeba kopiować pola za polem).

W strukturach można umieszczać inne struktury, co pozwala na tworzenie bardziej złożonych typów danych.
struct Data {
    int dzien;
    int miesiac;
    int rok;
};

struct Student {
    std::string imie;
    std::string nazwisko;
    Data dataUrodzenia; // zagnieżdżona struktura
};<br>

5. Tablice struktur

Tablice struktur w C++ to mechanizm pozwalający na przechowywanie wielu obiektów struktury w jednym uporządkowanym zestawie, czyli w tablicy. Umożliwia to łatwe zarządzanie większą ilością danych tego samego typu strukturalnego. Tablice struktur mogą być jednowymiarowe (proste) lub wielowymiarowe. Poniżej przedstawiono przykład definicji tablicy struktur.

#include <iostream>
#include <string>

struct Osoba {
    std::string imie;
    int wiek;
    float waga;
};

int main() {
    // Deklaracja tablicy struktur o nazwie "osoby", która zawiera 3 obiekty typu Osoba
    Osoba osoby[3] = {
        {"Jan", 30, 70.5},
        {"Anna", 25, 55.0},
        {"Tomek", 20, 65.5}
    };

    // Wyświetlanie danych z tablicy struktur
    for (int i = 0; i < 3; i++) {
        std::cout << "Imię: " << osoby[i].imie << ", Wiek: " << osoby[i].wiek << ", Waga: " << osoby[i].waga << std::endl;
    }

    return 0;
}
Jak widzimy w przedstawionym przykładzie tablice struktur można inicjalizować podobnie jak inne tablice, przy użyciu listy inicjalizacyjnej:
Osoba osoby[2] = {
        {"Maria", 22, 60.0},
        {"Piotr", 27, 80.0}
};
    
Możemy również przypisywać wartości do poszczególnych elementów tablicy ręcznie, po jej zadeklarowaniu:
osoby[0].imie = "Maria";
osoby[0].wiek = 22;
osoby[0].waga = 60.0;
Dostęp do poszczególnych elementów tablicy uzyskujemy podobnie jak w przypadku zwykłych tablic, za pomocą indeksów. Aby odwołać się do konkretnego pola struktury znajdującej się w tablicy, używamy notacji kropkowej (.).

Przykłady:

  • osoby[0].imie — dostęp do pola imie pierwszego elementu tablicy.
  • osoby[1].wiek — dostęp do pola wiek drugiego elementu tablicy.

6. Funkcje

Funkcje w C++ to podstawowe bloki kodu, które wykonują określone zadania. Umożliwiają podział programu na mniejsze części. Dzięki temu możemy ponownie wykonywać ten sam kod bez potrzeby ponownej implementacji tych samych linii kodu. 

Funkcja jest to ciąg instrukcji zapisany pod jakąś nazwą i wywoływany za pomocą tej nazwy w programie.
Każda definicja funkcji w języku C++ składa się z dwóch części:
nagłówka
ciała funkcji, czyli jej treści.
 Typowy format funkcji w C++ wygląda następująco:
typ_zwracany nazwa_funkcji(typ_parametr param1, typ_parametr param2, ...) {
    // ciało funkcji - instrukcje do wykonania
    return zwracana_wartosc; // jeśli funkcja ma typ inny niż void
}
gdzie:
  • typ_zwracany  - Typ wartości zwracanej przez funkcję. Może to być typ prosty, taki jak int, float, double, char, lub złożony, np. obiekt klasy. Jeśli funkcja nic nie zwraca, używamy void.
  • nazwa_funkcji - Nazwa funkcji. Powinna być jednoznaczna i opisywać działanie funkcji.
  • typ_parametr i param1, param2, ... - Lista argumentów przekazywanych do funkcji. Każdy argument musi mieć określony typ i nazwę. Funkcja może mieć dowolną liczbę parametrów (w tym zero).
  • return - Instrukcja return służy do zwracania wartości przez funkcję. Jest wymagana, jeśli funkcja zwraca coś innego niż void.
Możemy wyróżnić różne typy funkcji, które różnią się obecnością lub nie wymienionych wyżej elementów:
  • Funkcje zwracające wartość.
  • Funkcje void (niezwracające wartości).
  • Funkcje z parametrami.
  • Funkcje przeciążone (ang. overloading).
  • Funkcje rekurencyjne.
  • Funkcje inline (funkcje wbudowane).
  • Funkcje z referencjami jako parametry.


6.1. Funkcje zwracające wartość

Funkcje zwracające wartość w C++ to funkcje, które po zakończeniu swojego działania zwracają wynik, który można wykorzystać w innych częściach programu. Wartość zwracana może być dowolnego typu, od prostych typów, takich jak int, double czy char, po bardziej złożone, jak wskaźniki, referencje, obiekty klas, struktury.

Oto prosty przykład funkcji, która zwraca sumę dwóch liczb całkowitych:


int dodaj(int a, int b) {
    return a + b;
}

int main() {
    int wynik = dodaj(5, 10); // Funkcja dodaj zwraca wartość, którą przypisujemy do zmiennej wynik
    std::cout << "Wynik: " << wynik << std::endl; // Wynik: 15
    return 0;
}
W powyższym przykładzie funkcja dodaj przyjmuje dwa argumenty typu int, dodaje je, a następnie zwraca wynik, który można przypisać do zmiennej wynik w funkcji main. W przedstawionym przykładzie możliwe jest obliczenie wyniku w operacji return. Możemy także wcześniej wyznaczyć wartość sumy oraz zapamiętać jej wynik w innej zmiennej i następnie zwrócić wartość tej zmiennej. Ważne jest aby typ zwracany był zgodny z nagłowkiem funkcji. W naszym przykładzie wartość zwracana musi być typu int. Możemy zwracać tylko jedną wartość w postaci typu prostego. Nie ma możliwości zwrócenia dwóch wartości w postaci typów prostych za pomocą operacji return. Możemy oczywiści zwrócić więcej wartości ale w typach złożonych np. struktury. Wartości są wtedy ukryte w typie złożonym. Funkcje mogą zwracać również obiekty klas lub struktury, co jest przydatne, gdy potrzebujemy przekazać złożone dane.
struct Punkt {
    int x, y;
};

Punkt stworzPunkt(int a, int b) {
    Punkt p;
    p.x = a;
    p.y = b;
    return p;
}

int main() {
    Punkt p = stworzPunkt(5, 10);
    std::cout << "Punkt: (" << p.x << ", " << p.y << ")" << std::endl;
    return 0;
}
W powyższym przykładzie funkcja stworzPunkt zwraca strukturę Punkt, która jest przypisywana do zmiennej p w funkcji main.

6.2. Funkcje void (niezwracające wartości).

Funkcje void w C++ to funkcje, które nie zwracają żadnej wartości. Funkcje te mogą modyfikować stan programu poprzez efekty uboczne, takie jak modyfikowanie globalnych zmiennych, zmiennych przekazywanych przez referencję lub wskaźniki, czy też operacje wejścia/wyjścia.

Poniżej przedstawiono przykład funkcji void, która wyświetla tekst na ekranie:

#include <iostream>

void wypiszPowitanie() {
    std::cout << "Witaj, użytkowniku!" << std::endl;
}

int main() {
    wypiszPowitanie(); // Wywołanie funkcji void
    return 0;
}
W tym przykładzie funkcja wypiszPowitanie wyświetla tekst, ale nie zwraca żadnej wartości. W funkcji main wywołujemy ją w celu wykonania operacji, ale nie przypisujemy wyniku do żadnej zmiennej, ponieważ funkcja void nie zwraca niczego.

W funkcjach void można używać instrukcji return, ale nie może ona zwracać wartości. Służy tylko do wcześniejszego zakończenia działania funkcji. Jest to przydatne np. w sytuacjach, gdy w trakcie wykonywania funkcji wystąpi błąd i chcemy natychmiast przerwać dalsze operacje.

6.3. Funkcje z parametrami.

Funkcje z parametrami w C++ pozwalają na przekazywanie danych do funkcji w momencie jej wywołania, co umożliwia bardziej uniwersalne i elastyczne podejście do rozwiązywania problemów. Parametry te są zmiennymi, które przyjmują wartości przekazywane podczas wywołania funkcji i mogą być używane wewnątrz ciała funkcji.

Poniżej znajduje się przykład funkcji, która oblicza sumę dwóch liczb przekazanych jako parametry:


#include <iostream>

int dodaj(int a, int b) {
    return a + b;
}

int main() {
    int wynik = dodaj(5, 7); // Wywołanie funkcji z parametrami 5 i 7
    std::cout << "Wynik: " << wynik << std::endl; // Wyświetli: Wynik: 12
    return 0;
}
W tym przykładzie funkcja dodaj przyjmuje dwa parametry typu int (a i b), a następnie zwraca ich sumę. Funkcja ta jest uniwersalna, ponieważ możemy jej używać do obliczania sumy dowolnych dwóch liczb. Do funkcji przekazywane są wartości, a nie poszczególne zmienne. Jeżeli więc zmienimy wartość parametru w funkcji to w głównym programie nie ulegnie ona zmianie.

  W przedstawionym przykładzie można zaobserwować, że parametry są przekazywane przez wartości. Do funkcji możemy po przecinkach przekazywać wiele wartości. Możliwe jest także przekazanie domyślnych wartości parametrów. Parametry z domyślnymi wartościami muszą być umieszczone na końcu listy parametrów. Oznacza to, że jeśli masz parametry bez domyślnych wartości, muszą one być przed parametrami z domyślnymi wartościami.
// Poprawne
void funkcja(int a, int b = 5); // b ma wartość domyślną

// Błędne
void funkcja(int a = 0, int b); // Kompilator zgłosi błąd<br>

6.4. Funkcje z referencjami jako parametry.

Przekazywanie parametrów przez referencję w C++ to technika, która pozwala na efektywne przekazywanie zmiennych do funkcji bez konieczności ich kopiowania. Dzięki temu modyfikacje dokonane na parametrach w funkcji mają bezpośredni wpływ na oryginalne zmienne, które zostały przekazane jako argumenty. Aby zademonstrować przekazywanie parametrów przez referencję możemy wrócić do przykładu zademonstrowanego w poprzednim podrozdziale (Funkcje z parametrami), który miał na celu pokazać, że parametry są domyślnie przekazywane przez wartości.

#include <iostream>

using namespace std;

void zwieksz(int a){
    a++;
}

int main() {

    int x = 1;
    zwieksz(x);
    cout<<"Wartosc zmiennej x="<<x<<endl;
    return 0;
}
Jeżeli chcemy, aby w przedstawionym przykładzie wartość przekazana do funkcji zmieniła wartość musimy przekazać ją w inny sposób. W jaki sposób przekazać wartość przez referencję pokazana w przykładzie poniżej.
    
Deklaracja parametrów przekazywanych przez zmienną jest podobna do deklaracji parametrów przekazywanych przez wartość. Aby poinformować kompilator, że chodzi o referencję do zmiennej, a nie o nową zmienną - umieszcza się przed nazwą zmiennej znak ampersand &:

typ_zwracany nazwa (typ_p1 &p1, typ_p2 &p2, ..., typ_pn &pn);
    
gdzie p1, p2 ... są nazwami parametrów odpowiednich typów.
W zasadzie nie ma różnicy, czy zapiszecie znak referencji przed nazwą parametru, czy też za nazwą typu parametru, czyli zapisy
int& parametr
oraz
int &parametr
są sobie zupełnie równoważne (przynajmniej w przypadku deklaracji listy parametrów funkcji).

6.5. Funkcje przeciążone (ang. overloading).

Przeciążanie funkcji (ang. function overloading) w C++ to technika programistyczna, która pozwala na definiowanie wielu funkcji o tej samej nazwie, ale różnych zestawach parametrów. Dzięki temu można zrealizować różne operacje na danych, używając tej samej nazwy funkcji, co zwiększa czytelność kodu oraz pozwala na lepszą organizację.

Poniżej znajduje się przykład przeciążania funkcji w C++:

#include <iostream>

// Funkcja do dodawania dwóch liczb całkowitych
int dodaj(int a, int b) {
    return a + b;
}

// Funkcja do dodawania dwóch liczb zmiennoprzecinkowych
double dodaj(double a, double b) {
    return a + b;
}

// Funkcja do dodawania trzech liczb całkowitych
int dodaj(int a, int b, int c) {
    return a + b + c;
}

int main() {
    int x = 5, y = 10, z = 15;
    double a = 5.5, b = 10.5;

    std::cout << "Dodawanie liczb całkowitych: " << dodaj(x, y) << std::endl; // Wywołuje dodaj(int, int)
    std::cout << "Dodawanie liczb zmiennoprzecinkowych: " << dodaj(a, b) << std::endl; // Wywołuje dodaj(double, double)
    std::cout << "Dodawanie trzech liczb całkowitych: " << dodaj(x, y, z) << std::endl; // Wywołuje dodaj(int, int, int)

    return 0;
}
Kompilator nie będzie w stanie wybrać odpowiedniej wersji funkcji, jeśli wywołanie będzie niejednoznaczne. Na przykład, jeśli przekazane argumenty mogą pasować do więcej niż jednej wersji funkcji, wystąpi błąd kompilacji. Ponadto typ zwracany funkcji nie może być użyty jako jedyna różnica przy przeciążaniu. Oznacza to, że dwie funkcje mogą mieć ten sam zestaw parametrów, ale muszą różnić się przynajmniej jednym parametrem.

6.6. Funkcje rekurencyjne.

Rekurencja w C++ to technika programowania, która polega na definiowaniu funkcji, która wywołuje samą siebie w celu rozwiązania problemu. Rekurencja jest często stosowana do rozwiązywania problemów, które można podzielić na mniejsze podproblemy o tym samym charakterze. W C++ można implementować rekurencję zarówno dla funkcji prostych, jak i bardziej złożonych algorytmów, takich jak sortowanie czy przeszukiwanie.

Jednym z klasycznych przykładów rekurencji jest obliczanie silni liczby całkowitej.

#include <iostream>

// Funkcja rekurencyjna do obliczania silni
int silnia(int n) {
    if (n <= 1) { // Warunek zakończenia
        return 1;
    }
    return n * silnia(n - 1); // Wywołanie rekurencyjne
}

int main() {
    int n;
    std::cout << "Podaj liczbę do obliczenia silni: ";
    std::cin >> n;

    std::cout << "Silnia " << n << " wynosi: " << silnia( n ) << std::endl;

    return 0;
}

6.7. Funkcje inline (funkcje wbudowane).

Funkcje inline (funkcje wbudowane) w C++ to funkcje, które są definiowane z użyciem słowa kluczowego inline. Głównym celem korzystania z funkcji inline jest zwiększenie wydajności programu przez eliminację narzutu związanego z wywołaniami funkcji. Zamiast standardowego wywołania funkcji, kompilator wstawia kod funkcji w miejscu, w którym jest wywoływana. To pozwala na szybsze wykonywanie kodu, zwłaszcza w przypadku funkcji, które są wywoływane wielokrotnie w programie.

Oto prosty przykład ilustrujący użycie funkcji inline w C++:

#include <iostream>

// Definicja funkcji inline
inline int dodaj(int a, int b) {
    return a + b;
}

int main() {
    int x = 5;
    int y = 10;

    // Wywołanie funkcji inline
    int wynik = dodaj(x, y);

    std::cout << "Wynik dodawania: " << wynik << std::endl;

    return 0;
}
Funkcje inline w C++ są przydatnym narzędziem do optymalizacji wydajności programów. Umożliwiają one wstawienie kodu funkcji w miejscu jej wywołania, co może zmniejszyć narzut związany z wywołaniami funkcji. Mimo że kompilator ma swobodę w decydowaniu, które funkcje mają być oznaczone jako inline, ich właściwe użycie może prowadzić do bardziej wydajnego kodu. Należy jednak pamiętać o ograniczeniach i zastosować je tylko w odpowiednich kontekstach.