Zaznacz stronę

Podstawy języka C++ – część 1 – narzędzia i typy danych

paź 12, 2024 | Język C++

Język C++ to jeden z najpopularniejszych i najbardziej wszechstronnych języków programowania. Jego bogata składnia oraz szerokie możliwości sprawiają, że jest wybierany zarówno do tworzenia oprogramowania systemowego, jak i aplikacji o wysokiej wydajności. W tym cyklu artykułów wprowadzę Cię w podstawy języka C++, omawiając kluczowe elementy, które są fundamentem programowania w tym języku.

Potrzebne narzędzia i kompilator

Z zasady nauka „na sucho” nie jest zbyt efektywna, dlatego dobrze jest mieć pod ręką kilka podstawowych narzędzi. Zainstalujmy więc na swoim komuterze niebędne minimum.

Jeśli jesteś użytkownikiem Linuksa wystarczy zainstalować pakiet build-essential (dotyczy dystrybucji Debian lub Ubuntu)

Bash
sudo apt install build-essential

Jeżeli korzystasz z systemu Windows 10/11, polecam użycie WSL, czyli Windows Subsystem for Linux. Jednym poleceniem zainstalujemy dystrybucje Ubuntu. W tym celu uruchamiamy terminal cmd i wpisujemy komendy:

Bash
wsl --install
# restart systemu
wsl       # wejscie do linuxa
sudo apt install build-essential

Aby zainstalować kompilator w systemach z rodziny MacOS, mozna posłużyć sie poleceniem brew w terminalu.

Bash
brew install gcc
brew install g++

Do edycji kodu polecam Visual Studio Code, z dodatkiem „C++ extension pack”. Można go pobrać stąd https://code.visualstudio.com/download

Ale żeby nie było, jest też możliwość wypróbowania prostych przykładów online, bez konieczności instalacji narzędzi. Istnieje kilka serwisów które oferują możliwość napisania i przetestowania prostego kodu wprost ze strony internetowej. Na przykład:

Przejdźmy zatem do meritum.

Struktura programu w języku C++

Każdy program w C++ składa się z zestawu instrukcji, funkcji oraz deklaracji. Zobacz, jak poprawnie zbudować swój pierwszy program, zaczynając od niezbędnej funkcji main(), która jest punktem wejścia dla każdego programu. Załóżmy, że mamy plik program.cpp o takiej treści

C++
// komentarz
#include <iostream> // import nagłówka biblioteki

int dodaj(int a, int b); // deklaracja funkcji

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

int wynik; // deklaracja zmiennej

int main(void) // główna funkcja programu
{
  wynik = dodaj(2, 3); // obliczamy wynik wywołując funkcje dodaj()

  std::cout << wynik << std::end; // wydrukuj wynik na konsoli

  return 0; // zakończ program
}

Zauważyć można w tym listingu następujące elementy

  1. #include <iostream> – w ten sposób importujemy pliki nagłówkowe bibliotek, z których funkcje chcemy urucamiać w naszym programie. W tym przypadku kompilator bedzie wiedział jak korzystać z std::cout, std::endl oraz operatora <<
  2. sekcja deklaracji oraz definicji – tutaj definiujemy funkcje pomocnicze oraz zmienne globalne, do których bedziemy sie odwoływać w programie. W praktyce deklaracje funkcji zazwyczaj dołączane sa poprzez nagłówki
  3. funkcja main() – jest główna funkcją programu, to tutaj zaczyna się jego wykonywanie. Bez tej funkcji system operacyjny nie bedzie potrafił uruchomić naszego programu.
  4. ciało funkcji main(), to kod umieszczony pomiędzy nawiasami klamrowymi {}.
  5. każde kolejne wyrażenia oddzielamy średnikiem „;„, dzieki temu kompilator wie, gdzie sie kończy jedna instrukcja, a gdzie zaczyna kolejna.

Aby skompilować a następnie uruchomic program wystarczy wpisać te polecenia w konsoli Linuxa

Bash
g++ program.cpp -o program
./program

Jeśli nadal nie wszystko wydaje sie zrozumiałe, nie przejmuj sie. W dalszej części wszystko zostanie wyjaśnione.

Deklaracja a definicja funkcji

Jak zauważyłeś, funkcja dodaj() pojawiła sie w programie trzykrotnie. Najpierw jako deklaracja, potem jako definicja, i ostatecznie jako wywołanie. Skupimy sie tutaj na pierwszych dwóch wystąpieniach, czyli deklaracji i definicji.

Deklaracja informuje kompilator o istnieniu funkcji, jej nazwie, typie zwracanym oraz parametrach, ale nie dostarcza implementacji. Dzięki deklaracji kompilator wie, że funkcja jest gdzieś zdefiniowana i wie jak można ją wywołać. Deklaracja funkcji zwykle pojawia się w plikach nagłówkowych (.h), aby inne pliki mogły odwoływać się do funkcji bez potrzeby znania jej implementacji. Finalne połącznie zostanie wykonane na etapie linkowania.

Definicja funkcji dostarcza pełnej implementacji, czyli kodu, który będzie wykonywany, gdy funkcja zostanie wywołana. Obejmuje ona ciało funkcji, w którym są określone instrukcje, jakie funkcja ma wykonać. W całym programie definicja może wystąpic tylko raz, w przeciwnym razie linker zgłosi błąd.

Do szerszego omówienia funkcji jeszcze wrócimy.

Podstawowe typy danych

Język C++ oferuje szeroką gamę typów danych. Kazdy z typów posiada swoje charakterystyczne cechy, które określają, jakiego rodzaju operacje można na nich wykonywać, ile miejsca zajmują w pamieci a także jaki zakres wartości przyjmują. W poniższej tabeli możesz zobaczyć zestawienie stosowanych typów wraz z ich najważniejszymi cechami.

TypRodzajRozmiar w bajtachZakres wartości
boollogiczny1true, false (1, 0)
charznakowy, całkowity1-128 do 127
shortcałkowity ze znakiem2-32 768 do 32 767
intcałkowity ze znakiem4-2 147 483 648
do 2 147 483 647
longcałkowity ze znakiem4-2 147 483 648
do 2 147 483 647
long longcałkowity ze znakiem8-9 223 372 036 854 775 808
do 9 223 372 036 854 775 807
unsigned charcałkowity bez znaku10 do 255
unsigned shortcałkowity bez znaku20 do 65 535
unsigned intcałkowity bez znaku40 do 4 294 967 295
unsigned longcałkowity bez znaku40 do 4 294 967 295
unsigned long longcałkowity bez znaku80 do 18 446 744 073 709 551 615
enumwyliczeniowy4zdefiniowany w programie
floatzmiennoprzecinkowy43.4E +/- 38 (siedem cyfr)
doublezmiennoprzecinkowy81.7E +/- 308 (piętnaście cyfr)
long doublezmiennoprzecinkowy161.7E +/- 308 (piętnaście cyfr)
voidnieokreślony1brak
autokażdykażdykażdy
Tabela typów podstawowych stosowanych w C++

Należy miec na uwadze, że rozmiar niektórych typów może być inny zależnie od platformy. Natomiast wg standardu języka C++ obowiązuje zasada, że rozmiar char <= short <= int <= long <= long long. Z kolei typ int jest najczęsciej typem natywnym dla wybranej architektury, co oznacza, że dla tego typu obliczenia bedą wykonywane najefektywniej.

Rodzaje typów:

  • logiczny (bool) jest typem, który w uproszczeniu przechowuje tylko dwie wartości, true lub false (0 lub 1)
  • typ całkowity ze znakiem przechowuje tylko liczby całkowite bez ułamków, np.: 1, 2, 3, -5
  • typ całkowity bez znaku dla odmiany przechowuje tylko wartości dodatnie,
  • typ wyliczeniowy jest wyspecjalizowanym typem całkowitym, który pozwala przechowywać tylko kilka wybranych wartości. Każda wartość może otrzymać swoją charakterystyczną nazwę (identyfikator)
  • typ zmiennoprzecinkowy przechowuje wartości zakodowane standardem IEEE 754, dzieki temu możliwe są operacje na ułamkach, np.: 1.234 i możliwe jest wykonywanie obliczń naukowych. Tutaj od razu zaznaczę, że typ zmiennoprzecinkoway w rzczewistości jest tylko przybliżeniem wartości i każda operacja arytmetyczna wprowadza drobny błąd. Dlatego nie powinniśmy stosowac tego typu tam, gdzie wymagana jest wysoka precyzja, np.: w operacjach finansowych
  • typ nieokreślony void jest specjanym typem, który stosuje sie w funkcjach, np.: aby zadeklarować, że funkcja nie zwraca zadnego wyniku, lub nie przyjmuje żadnych paramterów.
  • typ auto jest typem specjalnym. Zasada jego działania polega na tym, że kompilator jest w stanie sie domysleć jaki typ powinien zostać użyty. Pozwala to znacznie uprościć kod i sprawia, że staje się bardziej czytelny.

Literały

Literały w C++ to stałe wartości, które są bezpośrednio zapisane w kodzie i mają określony typ danych. Mogą to być literały liczbowe (np. 42, 3.14), znakowe (np. 'A'), ciągi znaków (np. "Hello"), literały logiczne (true, false), a także literały zmiennoprzecinkowe (np. 2.5f dla typu float). C++ obsługuje różne typy literałów, w tym liczby całkowite w systemach dziesiętnym, szesnastkowym (0x), ósemkowym (0), a także binarnym (0b). Literały ciągów znaków są zapisywane w cudzysłowach podwójnych i są typu const char[].

C++
42            // Literał liczbowy
'A'           // Literał znakowy
true, false   // Literał logiczny
3.14, 2.53f   // Literał zmiennoprzecinkowy
0x44          // Literał liczbowy, szesnastkowy
023           // Literał liczbowy, usemkowy
0b1011        // Literał liczbowy, binarny
"hello world" // Literał znakowy

Stałe liczby mogą być zapisane w różnych systemach liczbowych: dziesiętnym, szesnastkowym, ósemkowym oraz binarnym. Podstawowym systemem jest dziesiętny, gdzie liczby zapisujemy w standardowy sposób, np. 42. C++ umożliwia również użycie innych systemów dla wygody programistów.

  • Literały szesnastkowe są poprzedzone prefiksem 0x lub 0X, np. 0x2A oznacza liczbę 42 w systemie szesnastkowym.
  • Literały ósemkowe zaczynają się od cyfry 0, np. 052 to liczba 42 w systemie ósemkowym.
  • Literały binarne zaczynają się od 0b lub 0B, np. 0b101010 to zapis liczby 42 w systemie binarnym.

Zmienne i stałe

Zmienna w programowaniu to miejsce w pamięci, w którym przechowywane są dane, takie jak liczby czy tekst. W C++ każda zmienna ma swój typ, który określa, jakie wartości może przechowywać, np. int dla liczb całkowitych, float dla liczb zmiennoprzecinkowych czy char dla pojedynczych znaków. Zmienną należy zainicjować, czyli przypisać jej wartość, zanim będzie mogła być używana w programie. Zmienna też powinna mieć swoją nazwę, aby można sie było do niej odwołać. Nazwa może sie składać z cyfr, liter oraz podkreślenia (_), przy czym nie może sie zaczynać od cyfry. Wielkośc liter ma znaczenie. Gdy chcemy zdefiniować stałą, nalezy posłużyc się kwalifikatorem const. Z zasady stałej nie można zmienić w trakcie działania programu. Zobaczmy kilka przykładów uzycia:

C++
const int mnożnik = 2; // stała globalna

float oblicz(int ile_razy)
{
  // zmienne lokalne
  int licznik = 0;
  float suma = 0.0;
  
  // obliczenia
  for(int i = 0; i < ile_razy; i++)
  {
    suma += i;
    licznik++;
  }
  return (suma / licznik) * mnożnik;
}

Poruszę tu przy okazji aspekt zakresu widoczności zmiennych w C++. Odnosi się on do kontekstu, w którym zmienna jest dostępna i może być używana. Zmienna zdefiniowana wewnątrz funkcji lub bloku kodu (np. w pętli) ma zasięg lokalny i jest widoczna tylko w tym ograniczonym obszarze (okeślonym przez nawiasy klamrowe {}). Z kolei zmienna zadeklarowana poza funkcjami, zazwyczaj na początku pliku, ma zasięg globalny i jest dostępna w całym programie. Istnieje również zasięg w klasach i strukturach, gdzie pola mogą być prywatne lub publiczne. Dbanie o odpowiedni zasięg zmiennych pomaga uniknąć błędów i zwiększa czytelność kodu.

Struktury danch

Bardzo często zachodzi taka potrzeba, że chcemy sobie zgrupować kilka zmiannych w programie. Wyobraźmy sobie prosty przykład, że potrzebujemy w kilku miejscach przekazywac koordynaty punktu w przestrzeni dwuwymiarowej. Każdy taki punkt jest wówczas opisywany przez dwie zmienne. Z pomocą przychodzą nam więc struktury.

C++
struct Punkt {
  float x;
  float y;
};

Punkt a = { 1.0, 1.0 };
Punkt b = { 2.0, 2.0 };

// odczyt pola ze struktury
std::cout << a.x << std::endl;

// przekazanie do funkcji
float wyznacz_odleglosc(a, b);

Gdyby nie ta możliwość, konieczne byłoby niekiedy definiowanie ogromnej ilości zmiennych w naszym programie, w tym przypadku odpowiednio a_x, a_y, b_x i b_y. Aby odczytać pole ze struktury, należy posłużyć sie symbolem kropki (.), np: a.x, a.y

Tablice

Jeśli zamierzamy korzystać z C++, to nie po to, aby używać pojedyńczych zmiennych. Ten język jest stworzony do operowania efektywnie na ogromnej ilości danych. Te dane zazwyczaj musimy gdzieś przechować. I tutaj do dyspozycji mamy tablice. Tablica w C++ to nic innego jak miejsce w pamięci, w którym obok siebie znajduje sie ciąg danch tego samego typu. Zobaczmy jako można utworzyć tablice 5 elementów typu int a potem odwołać sie do elementu nr 3.

C++
int liczby[5] = {1, 2, 3, 4, 5};
std::cout << liczby[2] << std::endl; // zwraca 3

// mozna tez tablicowac struktury
int indeks = 1;
Punkt punkty[2] = {{ 1.0, 2.0 }, { 2.0, 1.0 }};
std::cout << punkty[indeks].x << std::endl; // zwraca 2.0

Nalezy pamiętać, że indeksy w tablicy numerowane sa od 0, dlatego chcąc uzyskac dostep do pozycji 3 musimy napisac tablica[2].

Wskaźniki

Wskaźnik w rzeczywistości jest to zmienna, która przechowuje adres pamięci innej zmiennej. To co je odróżnia od zwykłej zmiennej całkowitej, to to, że ich arytmetyka jest nieco odmienna. Zwłaszcza to, że uwzglednia rozmiar typu, na jaki wskazuje. Podczas używania pod wieloma względami przypomina tablicę. Poniższy przykład pokaże jak utworzyć wskaźnik i jak z nim pracować.

C++
int zmienna = 1;
Punkt tablica[3] = {{1.0, 1.0}, {2.0, 2.0}, {3.0, 3.0}};

int * zmiennaPtr = &zmienna; // uzyskanie adresu zmiennej
Punkt * tablicaPtr = tablica; // lub &tablica[0]
int * nullPtr = nullptr; // wskaznik niezainiciowany

std::cout << *zmiennaPtr << std::endl; // dereferencja wskaznika

tablicaPtr++; // przesun wskaznik na następny element (ten o indeksie 1)
std::cout << tablicaPtr->x << std::endl; // odwolanie do pola struktury, zwraca 2.0
std::cout << (*tablicaPtr).x << std::endl; // jw., zwraca 2.0
std::cout << tablicaPtr[1].x << std::endl; // można uzywać jak tablice, zwraca 3.0

std::cout << *nullPtr << std::endl; // BŁĄÐ SegFault. operacja niedozwolona

Tak więc gdy mamy do czynienia ze wskażnikiem, wówczas:

  1. typ jest deklarowany z symbolem (*), np: int*, Punkt*
  2. możliwe jest też zadeklarowanie wskaźnika na wskaźnik, np: int**
  3. do odczytu wartości spod wskaźnika stosujemy symbol (*) przed nazwą zmiennej, np: *zmienna
  4. do odczytu pola struktu stosujemy symbol (->), np: strukturaPtr->pole
  5. do utworznia wskażnika stosujemy symbol (&), np: &zmienna

Zaletą stosowania wskażnika jest to, że gdy mamy bardzo złożoną strukturę danych, albo tablicę wielu elementów, i chcemy ją przekazać do funkcji, wówczas kopiowanie byłoby bardzo kosztowne, a czasami nawet niemożliwe. Aby wiec zoptymalizować przkazywanie takich parametrów, wystarczy przkazać wskaźnik.

Niedozwolony jest natomiast zapis i odczyt danych z „pustego wskażnika” (dereferencja). Taka operacja skutkuje natychmiastowym zakończeniem programu. Dzieje się tak dlatego, że pod zerowym adresem obszar pamięci jest zarezerwowany, i zazwyczaj oznacza to błąd w programie.

Referencje

Referencje to alternatywa dla wskaźników, która jest bardziej bezpieczna i łatwiejsza w użyciu. W stosunku do wskażników referencja ma kilka ograniczeń, ale też jest łatwiejsza w użyciu.

  1. Inicjalizacja
    • Referencje muszą być zainicjalizowane w momencie ich deklaracji i zawsze muszą odnosić się do istniejącego obiektu. Po przypisaniu referencja staje się „aliasem” dla tego obiektu i nie można jej zmienić, aby wskazywała na coś innego.
    • Wskaźniki mogą być zainicjalizowane dowolnym adresem, mogą być nullptr (wskazywać na nic) i mogą zmieniać to, na co wskazują w trakcie działania programu.
  2. Dostęp i dereferencja
    • Referencje są używane tak, jakby były bezpośrednio samą zmienną, bez potrzeby dodatkowej operacji (dereferencja następuje automatycznie) i nie trzeba sprawdzac, czy jest zainicjowana
    • Wskaźniki wymagają operacji dereferencji za pomocą operatora *, aby uzyskać dostęp do wartości, na którą wskazują. Dodatkowo nalezy sprawdzać czy wskaznik jest zainicjowany, aby uniknąć „crashu” programu.
  3. Zmiana odniesienia
    • Referencje nie mogą być zmienione, aby wskazywały na inny obiekt po ich inicjalizacji.
    • Wskaźniki można przypisać, aby wskazywały na inne adresy w trakcie działania programu.

Spójrzmy na przykład

C++
int x = 10;
int& ref = x;  // ref jest referencją do x
ref = 20;      // x teraz wynosi 20
int& ref2;     // niedozwolone

int x = 10;
int* ptr = &x;  // ptr wskazuje na x
*ptr = 20;      // x teraz wynosi 20
ptr = nullptr;  // teraz ptr nie wskazuje na nic

Literały łańcuchowe

Literałem ciągu znaków nazywamy tekst umieszczony w podwójnych cudzysłowach. Reprezentuje stały ciąg znaków typu const char[]. Literały łańcuchowe są powszechnie używane do pracy z tekstem, a każdy taki literał automatycznie kończy się znakiem null ('\0'), co pozwala rozpoznawać koniec ciągu w funkcjach operujących na ciągach znaków.

C++
const char* stalyTekst = "hello";
stalyTekst[0] = 'H'; // modyfikacja zabronina, const

char edytowalnyTekst[] = "hello";
edytowalnyTekst[0] = 'H';

std::cout << edytowalnyTekst << std::endl; // zwraca Hello

Podsumowanie

Jak widzimy, język C++ zawiera całkiem sporo składników. Choć ich ilość początkowo może sie wydawać przytłaczająca, to zapewniam, że zaledwie liznęliśmy temat :). Ale nie obawiaj się, w miarę nabywania doświadczenia oraz zrozumienia wszystko będzie stawało sie coraz bardziej oczywiste.