Twój program sie rozrasta i kod juz jest bardzo długi? zauważasz, że pewne fragmenty zaczynają sie powtarzać? Tak, przyszedł czas, aby zacząć wydzielać z niego wspólne kawałki. W języku C takim podstawowym budulcem do tworzenia reużywalnego kodu są funkcje. Funkcje możemy grupować w logiczne moduły (jednostki translacji) a nawet łączyć w biblioteki, które pokrywają jakąś część funkcjonalną naszego programu, np.: obsługa plików lub przetwarzanie tekstu. W C++ możemy wznieść sie nieco wyżej. Za pomocą klas możemy zamodelować jakiś „byt” który posiada cechy i wykonuje operacje, np. postać w grze, urządzenie. A jeśli wzniesiemy sie jeszcze poziom wyżej w abstrakcji, za pomocą szablonów będziemy mogli tworzyć uniwersalne algorytmy, które same potrafią sie dopasować do używanego typu danych, np.: sortowanie tablicy dowolego typu. Nie zwlekajmy więc.
Funkcje
Funkcje, to nic innego jak oddzielne fragmenty kodu (podprogramy). Mają one swoja nazwę, dzieki temu możemy się do nich odwoływać. Możemy do nich przekazywac paramtery, dzięki temu bedą sie zachowywać adekwatnie do tego, czego sie od nich oczekuje. Mogą także zwracać jakiś wynik. Zobaczmy jak się tworzy funkcję.
typ_zwracany nazwa_funkcji(typ_argumentu nazwa_argumentu, typ_argumentu_2 nazwa_argumentu_2)
{
// instrukcje
return wynik;
}
- typ zwracany to typ danych jaki zostanie zwrócony, jeśli nie potrzebujemy nic zwracać, możemy użyć typu
void
- nazwa funkcji to nasza nazwa, do której bedzimy sie odwoływać w innych miejscach. Przy nadawaniu nazwy obowiązują te same zasady co przy zmiennych, czyli wielkość liter ma znaczenie i możemy stosowac znaki
a-z, A-Z, 0-9, _
. Nazwa nie może sie zaczynać od cyfry. - argumenty podajemy jako sekwencja
typ nazwa
oddzielone przecinkiem
Dodatkowo w C++ funkcje mają kilka usprawnień względem języka C
- w C++ możemy zdefiniwać funkcje, które mają domyślne wartości argumentów, dzięki temu można je pominąć przy wywołaniu, warunkiem tej możliwości jest umieszczenie ich na końcu
- w C++ możemy zdefiniowac dwie funkcje o takiej samej nazwie, warunkiem ich istnienia jest to, że muszą mieć argumenty róznego typu, w C nazwy muszą być unikalne w całym programie
- aby wywołać lub udostępnić funkcję dla C, nalezy jej deklarację oznaczyc słowem kluczowym
extern "C"
Przykłady:
void uruchom() { /* implementacja */ } // funkcja bez paramterów, która nic nie zwraca
int dodaj(int a, int b) { // przyjmuje dwa paramtery i zwraca wynik
return a + b;
}
// funkcja z domyslnym paramtrem
int tekst_to_number(const char * tekst, int domyslna = -1) {
// operacje konwesji
if(!ok) return domyslna;
return wynik;
}
// wywołanie
int a = tekst_to_number("121", -1); // OK
int b = tekst_to_number("121"); // OK
// dwie deklaracje o tej samej nazwie
int dodaj(int a, int b); // wersja dla int
float dodaj(double a, double b); // wersja dla double
// wywołanie
int i = dodaj(1, 2);
double d = dodaj(1.0, 2.0);
double x = dodaj(1, 2.0); // blad kompilacji: call to 'dodaj' is ambiguous
// deklaracja funkcji kompatybilnaej z jezykiem C
extern "C" int funkcja_c();
// lub jeśli mamy całą grupę funkcji
extern "C" {
int funkcja_c();
// inna funkcja c
}
o definicji i deklaracji było już w częsci pierwszej
Klasy i programowanie obiektowe
Klasy są fundamentalnym elementem programowania obiektowego w C++. Samo programowanie obiektowe jest bardzo obszernym zagadnieniem i jeszcze nie bedziemy sie w nie zbytnio zgłębiać. Poznamy tu podstawy tworzenia klas, dziedziczenia oraz enkapsulacji. Dzięki temu łatwiej zapanować nad kodem przy bardziej złożonych programach.
- składowe i metody
- zakres widoczności
- konstruktory i destruktory
- dziedziczenie i polimorfizm
Programowanie obiektowe nalezy do jednej z metodologi pisania programów. Technika ta pozwala nam na tworzenie obiektów, które mogą mieć zarówno stan jak i zachowanie. W skrócie jest to połączenie struktury danych i funkcji w jednym. Elementem języka, który opisuje taki obiekt jest właśnie klasa. Klasa jest jedynie sposobem opisu obiektów, na podstawie których będziemy tworzyć ich obiekty.
Struktura deklaracji klasy
class NazwaKlasy
{
public:
<metody>
...
private:
<składowe>
...
};
Mamy tu więc:
- nazwę klasy po słowie kluczowym
class
. - kwalifikatory dostępu (
public, private, protected
), określają widoczność metod i składowych zadeklarowanych bezpośrednio po nich. - metody, czyli funkcje, deklaruje sie je tak samo, przy czym z automatu mają one dostęp do pól składowych klasy oraz wskażnik wskazujący na obiekt o nazwie
this
- składowe, czyli zmienne, które także deklaruje sie tak zamo jak wszystkie inne zmienne.
Przykładowa bardzo prosta klasa z użyciem może wyglądac tak:
#include <iostream>
// deklaracja klasy
class Prostokat
{
public:
// metody
double powierzchnia() const;
double obwod() const;
// skladowe
double a;
double b;
};
// definicja metod
double Prostokat::powierzchnia() const {
return this->a * this->b;
}
double Prostokat::obwod() const {
return (a + b) * 2; // możemy pominąć "this->"
}
int main()
{
// uzycie
const Prostokat p = { 2, 3 };
std::cout << "Powierzchnia: " << p.powierzchnia() << std::endl;
std::cout << "Obwod: " << p.obwod() << std::endl;
return 0;
}
Powszechną praktyką jest, że ciała metod implementujemy poza definicja klasy, najczęściej w osobnym pliku .cpp
, ale możliwe jest też implementowanie ich wenątrz deklaracji klasy. Kwalifikator const przy metodzie oznacza, że wywołanie tej metody nie modyfikuje stanu obiektu (składowych), dzieki temu kompilator zezwoli na ich wywołanie na stałych obiektach (const
).
Kwalifikatory dostępu
public
– składowe publiczne są dostępne wszędzie, również poza klasąprivate
– składowe prywatne są widoczne jedynie wewnątrz samej klasy, dla jej własnych metodprotected
– zasady dostępu są takie same jak w przypadku specyfikatora private, z jedną tylko różnicą – składowe tak oznaczone będą widoczne w klasach pochodnych, prywatne już nie.
Konstruktory i destruktory
Konstruktor i destruktor to specjalne metody, klasy które są wywoływane odpowiednio w momencie ich tworzenia lub niszczenia. Kompilator domyślnie dostarcza nam kilka podstawowych konstruktorów, aby móc wykonywać podstawowe operacje tworzenia i przypisywania obiektów. Konstruktor jest odpowiedzialny za inicjalizację obiektu a w szczególności za wypełnienie pól składowych danymi. Jeśli metoda w klasie ma taką samą nazwę co klasa, to jest właśnie konstruktor, jeśli nazwa poprzedzona jest symbolem tyldy (~
), wówczas jest to destruktor. Poniżej rzeczywiste przykłady użycia oraz implementacji.
#include <iostream>
class Projekt
{
public:
// konstruktor domyślny
Projekt() {
std::cout << "Projekt()" << std::endl;
}
// konstruktor kopiujący
Projekt(const Projekt& projekt) {
std::cout << "Projekt(const Projekt&)" << std::endl;
if(&projekt != this) { // sprawdznie, czy nie kopiujemy samego siebie
// zainicjuj obiekt uzywajac paramteru projekt
_nazwa = projekt._nazwa;
}
}
// operator przypisania
Projekt& operator=(const Projekt& projekt) {
std::cout << "operator=(const Projekt&)" << std::endl;
if(&projekt != this) { // sprawdznie, czy nie przypisujemy samego siebie
// zainicjuj obiekt uzywajac paramteru projekt
_nazwa = projekt._nazwa;
}
return *this;
}
// kostruktor użytkownika, inicjuje składową za pomocą listy inicjalizacyjnej
Projekt(const char * nazwa): _nazwa{nazwa} {
std::cout << "Projekt(const char *)" << std::endl;
}
// destruktor
~Projekt() {
std::cout << "~Projekt()" << std::endl;
}
void sayHello() const {
if(_nazwa) {
std::cout << "To projekt " << _nazwa << std::endl;
return;
}
std::cout << "To projekt bez nazwy!" << std::endl;
}
private:
const char * _nazwa;
};
Poniżej krótkie zestawienie konstruktorów wraz sytuacjami, w których kompilator będzie ich używał:
- konstruktor domyślny jest używany wtedy, gdy tworzymy obiekt, bez podania żadnych parametrów
- Konstruktor kopiujący jest używany, gdy będziemy próbowali skopiować jeden obiekt do drugiego
- operator przypisania, choć nie wygląda na jak konstruktor, to także nim jest. Używany jest, gdy przypisujemy inny obiekt za pomocą operatora (
=
). - konstruktor użytkownika, to konstruktor, który definiuje programista, zależnie od własnych potrzeb, ich ilość jest dowolna
- destruktor jest wywoływany, gdy wykonanie kodu opuści zakres, w którym został on powołany do życia, np.: wyjście z funkcji lub gdy programista postanowi usunięcie z pamięci operatorem
delete
.
Zobaczmy to na kilku przykładach
void funkcja_copy(const Projekt p)
{
// tu przekazujemy obiekt, przez kopiowanie,
// powinien być uzyty konstruktor kopiujący
std::cout << "funkcja_copy()" << std::endl;
p.sayHello();
// w tym miejscu na kopii zostanie wywolany destruktor
}
void funkcja_ref(const Projekt& p)
{
// ponieważ przekazujemy paramter przez referencje
// to nie powstanie kolejna kopia obiektu, ani nie zostanie wywolany destruktor
std::cout << "funkcja_ref()" << std::endl;
p.sayHello();
}
int main()
{
Projekt p1; // domyślny konstruktor
p1.sayHello();
Projekt p2{"c++"}; // konstruktor użytkownika, w którym przekazujemy nazwę
p2.sayHello();
Projekt p3{p2}; // konstruktor kopiujący
p3.sayHello();
Projekt p4 = p2; // Uwaga! także konstruktor kopiujący, ponieważ tworzymy nowy obiekt
p4.sayHello();
p1 = p2; // operator przypisania, ponieważ p1 istnial już wczesniej
p1.sayHello();
funkcja_ref(p2);
funkcja_copy(p2);
std::cout << "koniec" << std::endl;
// w tym miejscu zostana wywolane destruktory wszystkich utworzonych obiektów
// w kolejnosci odwrotnej do ich tworzenia
return 0;
}
Domyślne konstruktory dodaje kompilator tylko wtedy, gdy użytkownik nie zdefiniował żadnego. Jeśli utworzymy choć jeden konstruktor, a potrzbne nam będa też pozostałe, to musimy je zaimplementować samodzielnie. Jeśli natomiast nasz brakujący konstruktor jest „trywialny” (czyli nie wymaga szczególnej logiki), wówczas możemy poprosić kompilator, aby dołączył własna implementację, jesli mamy pewność, że ona nam wystarczy. A robi sie to tak.
class Projekt
{
public:
Projekt(const char * nazwa);
// dolacz domyslna implementacje kompilatora
projekt() = default;
// tak tez możemy poprosic kompilator, aby nie dodawał
// domyślnej implementacji konstruktora kopiującego
Projekt(const Projekt& nazwa) = delete;
};
Pola składowe klasy możemy zainicjować w dwojaki sposób, na liście inicjalizacyjnej, oraz w ciele konstruktora. Te pierwsze muszą byc iniciowane w takiej kolejności w jakiej są umieszczone w klasie. W ciele funkcji konstruktora ich kolejność jest dowolna. Składowe referencyjne, mogą być inicjalizowane tylko na liście inicjalizacyjnej. Lista inicjalizacyjna jest uruchamiana przez wywołaniem funkcji konstruktora, jeśli składowa nie zostanie wymieniona na liście inicjalizacyjnej, zostanie dla niej uruchomiony domyślny konstruktor, natomiast jeśli to typ prosty (int
, double
, itp), wówczas pole nie bedzie inicjalizowane i znajdą się tam losowe dane z pamięci. Zobaczmy ten przykład:
class Product
{
public:
Product(): _nazwa{""} {
// inicjalizacja składowych w ciele konstruktora
_ilosc = 0;
_waga = 0.0;
}
Product(const char * nazwa, int ilość, double waga):
_nazwa{nazwa}, _ilość{ilosc}, _waga{waga} // lista inicjalizacyjna
{} // ciało konstruktora może być puste
private:
const char * _nazwa;
int ilosc;
double waga;
};
Choć to nie wszystko na temat konstruktorów, to chciałbym nadmienić jedną rzecz, żeby rozwiać ewentualne wątpliwości. W starszej literaturze oraz w Internecie znajdziecie jeszcze dużo materiałów, w których kostruktory są wywoływane za pomocą nawiasów okrągłych ()
. Te przykłady sa jak najbardziej poprawne, ale od czasu opublikowania standardu C++11 zalecaną metodą jest używanie nawiasów klamrowych. Dla dociekliwych nazywa się je "Iniform Initialization"
i w wiekszości przypadków mogą byc stosowane zamiennie.
Składowe statyczne
Nie zawsze jest tak, że każdy obiekt potrzebuje swojego własnego wyizolowanego zestawu składowych. Czasem będziemy potrzbować aby któraś składowa była wspólna dla wszystkich instancji klasy. Do tego celu służą właśnie składowe statyczne. Nie są one wówczas przypiete do instancji, a raczej do samej klasy. Aby je stworzyć, należy posłużyć się słowem kluczowym static
. Statyczne mogą być także metody klasy, nie mają one wówczas dostępu do wskażnika this
, a mają tylko do innych składowych statycznych.
// ######### plik Product.hpp #########
class Product
{
public:
Product();
~Product();
static int ile();
private:
static int _ile;
};
// ######### plik Product.cpp #########
#pragma once
#include <Product.hpp>
// konstruktor domyslny, inkrementujemy w nim skladowa _ile
// za kazdym razem, gdy tworzony jest obiekt
Product::Product() { _ile++; }
// destructor, dekrementujemy w nim skladowa _ile
// za kazdym razem, gdy niszczony jest obiekt
Product::~Product() { _ile--; }
// metoda statyczna ile zwraca nam ile jest obecnie utworzonych instancji klasy
int Product::ile() { return _ile; }
// allokujemy pamiec i inicjalizujemy składowa statyczną
int Product::_ile = 0;
// ######### plik main.cpp ##########
#include <iostream>
#include <Product.hpp>
int main()
{
Product p1;
Product p2;
Product p3;
std::cout << "Utworzono " << Product::ile() << " instancje" << std::endl;
}
// kompilacje i uruchmienie
// g++ Product.cpp mian.cpp -o program
// ./program
W tym przykładzie, klasa Produkt
ma składową statyczną typu int _ile
. Jej domyślna wartość to 0, i jest ona inkrementowana (zwiększana o 1) za każdym razem, gdy wywoływany jest domyślny konstruktor. W destruktorze składowa ta jest dekrementowana (zmniejszana o 1). W ten sopsób licznik zawsze wskazuje ilość obecnych w programie instancji klasy Produkt
.
Dziedziczenie
W programowaniu obiektowym dziedziczenie ma swoje szczególne miejsce i jest sposobem na realizację uogulnienia. A mówiąc bardziej przystępnym językiem, jeśli mamy kilka rodzajów klas, które pod kilkoma wzgledami sa do siebie podobne, to znaczy realizują te zame zadania, wówczas możemy te część wspólną wyodrębnić do osobnej klasy (uogólnić). Następnie każda klasa może odziediczyć wystkie cechy z postaci uogólnionej i dodac coś od siebie.
#include <iostream>
class Zwierz
{
public:
Zwierz(const char * k, double w): kolor{k}, wzrost{w} {}
void wyswietl() {
std::cout << "kolor: " << kolor << ", wzrost: " << wzrost << std::endl;
}
protected:
const char * kolor;
double wzrost;
};
class Pies: public Zwierz
{
public:
Pies(const char * k, double w): Zwierz{k, w} {}
void szczekaj() {
std::cout << "Hau!" << std::endl;
}
};
class Kot: public Zwierz
{
public:
Kot(const char * k, double w): Zwierz{k, w} {}
void miaucz() {
std::cout << "Miau!" << std::endl;
}
};
int main()
{
Pies pies{"Bialy", 30.0};
pies.wyswietl();
pies.szczekaj();
Kot kot{"Rudy", 10.0};
kot.wyswietl();
kot.miaucz();
}
Mamy tu więc:
- Klasę bazową
Zwierz
, która ma dwie składowekolor
orazwzrost
- klasy pochodne
Pies
iKot
, które dziedziczą wszystkie publiczne (public
) i chronione (protected
) cechy klasy bazowejZwierz
- cechy prywatne (
private
) nie sa dostępne w klasach pochodnych - składnią
class Pochodna: public Bazowa
informujemy kompilator o fakcie dziedziczenia - Konstruktory oraz destruktory nie podlegają dziedziczeniu, więc klasy
Kot
orazPies
muszą otrzymać własne, ale możliwość wołania innych kontruktorów w liście inicializacyjnej jest pewnym ułatwieniem
W efekcie mamy dwie klasy pochodne, które posiadają cechy wspólne, odziedziczone z klasy bazowej, a ich implementacja skupia się głównie na ich najbardziej charakterystycznej dla nich części. No i najważniejsze, nie mamy zduplikowanego kodu. Nie musimy się martwić, że przypadkowo zapomniemy zaktualizować jedną z nich, oraz nie musimy poprawiać w wielu miejscach na raz ewentuanych błędów.
Polimorfizm
Polimorfizm to jedna z kluczowych cech programowania obiektowego. Pozwala na zróżnicowane zachowanie obiektów w zależności od ich typu, głównie gdy są one używane za pośrednictwem wskaźników lub referencji do klasy bazowej. Dzięki polimorfizmowi możliwe jest tworzenie elastycznych i rozszerzalnych struktur, w których można manipulować obiektami różnych klas dziedziczących przy użyciu wspólnego interfejsu. Kluczem do uzyskania polimorfizmu są metody wirtualne. Przeróbmy nieznacznie przykład powyżej.
class Zwierz
{
public:
virtual void dajGlos() = 0;
};
class Pies: public Zwierz
{
public:
virtual void dajGlos() override {
std::cout << "Hau!" << std::endl;
}
};
class Kot: public Zwierz
{
public:
virtual void dajGlos() override {
std::cout << "Miau!" << std::endl;
}
};
void wydaj_komende(Zwierz& zwierz) {
zwierz.dajGlos();
}
int main()
{
Pies pies;
wydaj_komende(pies); // Hau!
Kot kot;
wydaj_komende(kot); // Miau!
}
Co sie tu dzieje?
- Klasa
Zwierz
stała się teraz klasą czysto abstrakcyjną (Interfejsem), a jest tak za sprawą, słowa kluczowegovirtual
przy metodziedajGlos()
. Dodatkowo za pomocą (= 0
) poinformowaliśmy kompilator, że ta metoda nie posiada implementacji. Więc żadna instancja klasyZwierz
nie może zostać utworzona, może być tyko klasą bazową w dziedziczeniu. - Dopiero klasy
Pies
iKot
dostarczają implementacji metodydajGlos()
i w kazdej klasie te implementacje są inne. - Następnie dodajemy funkcje
wydaj_komende()
w której jako argument definiujemy referencje na naszą klase bazową. To pozwala nam przekazywać instancje każdego dowolnego typu, który dziedziczy z klasy bazowej. - Zauważmy, że w funkcji
wydaj_komende()
probujemy wywołać metodę z klasy abstrakcyjnej, której implementacji nie dostarczyliśmy. Wspomniana funkcja tak na prawde nie wie, z jakim typem ma do czynienia bo zwykle w takiej sytuacji odbywa sie rzutowanie typu w górę chierarchii dziedziczenia i informacje o typie pierwotnym sa tracone. Natomiast jeśli posłużymy sie referencją lub wskaźnikiem, to dla metod wirtualnych dzieje sie tu troche magii, otóż zostanie wywołana metoda specyficzna dla tego konkretnego typu, który utworzyliśmy.
Cała ta magia dzieje sie w trakcie wykonania programu (runtime), co daje dużą elastyczność, zwaszcza w stosowaniu obiektowych wzorców projektowych, a także przy łączeniu modułów i bibliotek tworzonych przez niezależne zespoły.
Makra preprocesora
Makra preprocesora w C++ to mechanizm, który pozwala na definiowanie symboli i wykonywanie prostych operacji tekstowych na kodzie źródłowym. Sam proces przetwarzania odbywa sie na początku tuż przed właściwym procesem kompilacji kodu. Tu można poczytac więcej o tym jak przebiega process kompliacji. Preprocesor, przetwarza polecenia zaczynające się od #
, takie jak #define
, które tworzy podstawienia tekstowe w kodzie. Najczęściej spotykane makra to:
#include
– załączenie do kodu całego pliku, najczęściej pliku nagłówkowego innego modułu lub biblioteki#define
– definicja stałej wartości np.:#define PI 3.1415
, od tego momentu w kazdym miejscu kodu frazaPI
zostanie zastąpiona przez tekst3.1415
- #define – definicja funkcji, np:
#define MULTIPLY_BY_2(x) (x * 2)
, wówczas przykładowe wystąpienieMULTIPLY_BY_2(5)
zostanie zastąpione przez(5 * 2)
-
#ifdef/#ifndef/#undef
– sprawdza, czy makro zostało zdefiniowane, bez znaczenia, czy ma jakas wartość czy nie,#undef
usuwa definicje #if/#elif/#else/#endif
– pozwala sprawdzić bardziej złożone warunki kompilacji, i odpowiednio włączyć lub wyłączyć fragmenty kodu#error/#warning
– generuje błąd lub ostrzeżenie kompilacji, w przypadku błędu komplikacja jest zatrzymywana__FILE__, __LINE__, __DATE__, __TIME__
te oraz wiele innych predefiniowanych makr czasu kompilacji (dociekiwi pełną liste znajdzą w dokumentcji kompilatora)
#include <header.hpp>
#define PLATFORM_WINDOWS
#define PI 3.14159
#define MULTIPLY_BY_2(x) (x * 2)
#ifndef PLATFORM_WINDOWS
#error "Brak obsługi okien"
#endif
#ifdef DEBUG
#warning "Kompilacja rozwojowa niezoptymalizowana w trybie debug"
#endif
sin(PI); // wywolaj funkcje sin() z paramterem 3.1415
Makra preprocesora w C++ dają możliwość manipulowania kodem przed jego kompilacją. Są najczęściej używane do definiowania stałych, aby nadać im nazwę i tym samym poprawić czytelność kodu lub uproszczenia złożonych wyrażeń. Używa się ich także do warunkowego kompilowania fragmentów kodu, np. inny dla wersji Windows a inny dla Linux. Zwyczajowo stałe i makra programiści piszą wielkimi literami. Definicje makr #define
można także przekazać z zewnątrz kodu źródłowego, za pomocą argumentów kompilatora, w przypadku gcc mamy do tego paramter -D
, np.: -DVERSION_STRING=1.0.0
Szablony
Szablony, czyli makra na sterydach. Umożliwiają nam tworzenie generycznych klas i funkcji, które mogą działać z dowolnymi typami danych. Dzięki nim można tworzyć bardziej elastyczny kod wielokrotnego użytku. Szablony są podstawowym budulcem biblioteki standardowej. Kiedy widzimy słowo kluczowe template
z nawiasami trójkątnymi (<>
) wówczas wiemy, że mamy do czynienia z szblonem.
Szablon klasy
#include <iostream>
template <typename T>
class Box
{
public:
Box(T v): value{v} {}
T get() const { return value; }
void set(T v) { value = v; }
void print() const {
std::cout << value << std::endl;
}
private:
T value;
};
int main()
{
Box<int> intBox(42);
intBox.print();
Box<double> doubleBox(42.34);
doubleBox.print();
}
template <typename T>
: Definiuje szablon klasy, gdzieT
jest typem, który będzie ustalany w momencie tworzenia obiektu.Box<T>
: Szablon klasy, który działa dla dowolnego typuT
. W trakcie tworzenia obiektu tego szablonu,T
zostanie zastąpione odpowiednim typem, np.int
,double
, itd.- Funkcje
get()
,set()
orazprint()
działają na zmiennejvalue
, która ma typT
- Dla każdej kombinacji typów w argumentach szablonu tworzony jest osobny typ w pamięci kompilatora i generowany jest kod dla niego. Proces ten nazywa się konkretyzacją.
Szablony to pewnego rodzaju makra, ale o wiele bardziej poteżne. Ich główną zaletą jest to, że dokonują sprawdzania typów jeszcze na etapie kompilacji, w odróżnieniu od makr preprocesora, które robią tylko ślepe podstawienia tekstów.
Szablon funkcji
#include <iostream>
template <typename T>
T dodaj(T a, T b)
{
return a + b;
}
int main()
{
std::cout << dodaj(1, 2) << std::endl;
std::cout << dodaj(1.1, 2.2) << std::endl;
std::cout << dodaj(1.2, 2) << std::endl; // blad kompilacji, argument roznych typow
std::cout << dodaj<double>(1.2, 2) << std::endl; // ok, wymuszona specjalizacja dla double, drugi argument zostanie automatycznie zrzutowany
}
template <typename T>
: Definiuje szablon funkcji, gdzieT
jest typem, który będzie ustalony na podstawie argumentów funkcji.T dodaj(T a, T b)
: Funkcjaadd
przyjmuje dwa argumenty typuT
i zwraca ich sumę (też typuT
).- Funkcja
add
działa z dowolnym typem, który wspiera operator+
, jakint
czydouble
Dedukcja typów
Dedukcja typów jest mechanizmem, który pozwala kompilatorowi automatycznie wywnioskować, jakiego typu szablon funkcji lub klasy powinien być użyty na podstawie przekazanych argumentów. Dzięki temu programista nie musi jawnie podawać typów przy każdym użyciu szablonu. Widać to najlepiej na przykładzie szablonu funkcji dodaj()
gdzie nie musieliśmy podawać jawnie której wersji chcemy użyć.
Przestrzenie nazw
Przestrzenie nazw (ang. namespaces) w C++ służą do organizowania kodu i zapobiegania konfliktom nazw, szczególnie w dużych projektach, gdzie mogą występować identyczne nazwy funkcji, zmiennych czy klas. Dzięki przestrzeniom nazw można zgrupować związane ze sobą elementy w logiczne jednostki. Używając operatora ::
, można uzyskać dostęp do elementów w konkretnej przestrzeni nazw. Przestrzenie nazw można zagnieżdzać na wiele poziomów.
Słowa kluczowe namespace, using oraz typedef
Słowo kluczowe using
w C++ ułatwia pracę z przestrzeniami nazw, klasami oraz typami. Pozwala na wprowadzenie nazw z przestrzeni nazw do aktualnego zakresu, dzięki czemu nie trzeba ich każdorazowo poprzedzać operatorem ::
. Użycie using
może także służyć do tworzenia aliasów dla typów, co zwiększa czytelność i elastyczność kodu.
#include <iostream>
namespace Geometria
{
// deklarujemy alias typu
using Number = double;
// dawniej
//typedef double Number;
// deklarujemy stala
const Number PI = 3.14159;
// definiujemy funkcje, która używa typu oraz stalej
double powierzniaKola(Number promien) {
return PI * promien * promien;
}
}
// tworzymy aliasy
namespace Geo = Geometria;
using Num = Geometria::Number;
int main()
{
// wprowadzamy symbole z przestrzeni std:: do aktualnego zakresu
// nie musimy juz za kazdym razem pisac `std::cout` wystaczy `cout`
using namespace std;
// uzywamy typow oraz stalych z przetrzeni nazw Geometry
Num promien = 5.0;
cout << "Liczba PI: " << Geo::PI << endl; // use alias
cout << "Pole kola: " << Geometria::powierzniaKola(promien) << endl;
return 0;
}
W starszej literaturze można spotkać słowo kluczowe typedef
które działa tak samo jak aliasowanie za pomocą using
. Rózni sie tylko nieco składnią. Natomiast nowa sładnia ze słowem kluczowym using
jest bardziej intuicyjna i ma trochę wieksze możliwości we współpracy z szablonami.
Podsumowanie
W rozdziale tym przedstawiłem kluczowe elementy języka, które pozwalają na tworzenie modularnego, czytelnego i wielokrotnego użytku kodu. Funkcje umożliwiają definiowanie zadań, które mogą być wielokrotnie wywoływane z różnymi argumentami, a klasy stanowią podstawę programowania obiektowego, umożliwiając tworzenie struktur reprezentujących obiekty z danymi i metodami. Szablony natomiast wprowadzają generyczność, pozwalając na pisanie funkcji i klas, które mogą działać z różnymi typami danych, co eliminuje konieczność powielania kodu. Wspólnie te elementy tworzą fundamenty elastycznego i efektywnego programowania w C++.