C++ przetwarza dane typów wbudowanych (np. int
, long double
)
albo zdefiniowanych przez użytkownika (np., struct A
, class B
,
enum C
, union D
). Standard C++ opisuje:
kiedy dane są tworzone i niszczone,
gdzie (w którym miejscu pamięci, w jakiej strukturze danych) dane się znajdują.
C++ musi spełniać podstawowe wymagania systemu operacyjnego dotyczące organizacji pamięci, a reszta zależy od C++.
Uruchomiony program jest procesem w systemie operacyjnym i zadaniem wykonywanym przez procesor. Proces zarządza swoją pamięcią zgodnie z ograniczeniami systemu operacyjnego. System operacyjny daje procesowi do dyspozycji dwa rodzaje pamięci: tylko do odczytu oraz do zapisu i odczytu.
Pamięć tylko do odczytu przechowuje kod programu (rozkazy procesora) i dane stałe programu znane w czasie kompilacji, np. literały łańcuchowe. Ta pamięć jest współdzielona przez wszystkie procesy programu, co znacząco oszczędza pamięć w przypadku dużych programów uruchamianych w dużej liczbie, np. przglądarek czy serwerów internetowych.
Zadanie może być uprzywilejowane albo nieuprzywilejowane. Tylko zadania jądra systemu operacyjnego są uprzywilejowane. Procesy są zadaniami nieuprzywilejowanymi. Zadanie nieuprzywilejowane nie może naruszyć danych innych zadań, w szczególności nie może pisać do swojej pamięci tylko do odczytu.
W poniższym przykładzie próbujemy pisać do pamięci tylko do odczytu – proszę odkomentować niektóre linie. Program się kompiluje, ale proces jest unicestwiony przez system operacyjny sygnałem SIGSEGV (segment violation).
#include <iostream>
using namespace std;
const int test1 = 1;
int main()
{
// Is the global variable in the read-only memory?
// *const_cast<int *>(&test1) = 10;
// const_cast<int &>(test1) = 10;
// Is this local variable in the read-only memory?
const volatile int test2 = 2;
*const_cast<int *>(&test2) = 20;
cout << test2 << endl;
const_cast<int &>(test2) = 200;
cout << test2 << endl;
// Is this local static variable in the read-only memory?
static const int test3 = 3;
// *const_cast<int *>(&test3) = 30;
// const_cast<int &>(test3) = 30;
// "Hello!" is a string literal in the read-only memory.
// *static_cast<char *>("Hello!") = 'Y';
// A string literal is of type "const char *", and that's why we had
// to static-cast it to "char *". This would not compile:
// *"Hello!" = 'Y';
}
Możemy sprawdzić miejsce zmiennych poniższą komendą. Proszę zwrócić uwagę na literę ‘r’ na wyjściu, która symbolizuje pamięć tylko do odczytu:
nm ./sigsegv | c++filt | grep test
Wszystkie inne dane programu (poza tymi w pamięci tylko do odczytu) są w pamięci do odczytu i zapisu, bo na tych danych program wykonuje obliczenia. Każd proces tego samego programu ma osobną pamięć do odczytu i zapisu.
C++ jest wydajny czasowo i pamięciowo, co wynika głównie z organizacji pamięci i użycia wskaźników, czyli niskopoziomowego mechanizmu. Co więcej, C++ zapewnia swobodne zarządzanie danymi: np., programista może alokować dane globalnie, statycznie, lokalnie lub dynamicznie. Organizacja pamięci jest także deterministyczna: możemy dokładnie wskazać, które dane i gdzie są niszczone, bo C++ niszczy je dokładnie wtedy, kiedy nie są już potrzebne.
Pod tym względem C++ znacznie różni się od innych języków, takich jak Java czy C#, gdzie zarządzanie pamięcią jest uproszczone, ale kosztem spadku wydajności i ograniczonej swobody zarządzania danym. Na przykład, te języki pozwalają na alokację obiektów wyłącznie na stercie, co pogarsza wydajność i swobodę zarządzania danymi, ale pozwala na łatwą implementację odśmiecania pamięci (ang. garbage collection). Odśmiecanie pamięci może być niedeterministyczne: nie ma gwarancji, kiedy dane będą niszczone, a to powoduje dalsze pogorszenie wydajności programu.
Komitet Standaryzacyjny C++ rozważał wprowadzenie odśmiecania pamięci, ale zaniechał tego z powodu oczekiwanego spadku wydajności programów. Dzisiaj programy pisane w C++ nie wymagają odśmiecania pamięci, ponieważ jest dostępna bogata funkcjonalność kontenerów i inteligentnych wskaźników, które mogą być uznane za rodzaj odśmiecania pamięci.
Pamięć do zapisu i odczytu przechowuje:
globalne i statyczne dane w miejscu pamięci o ustalonym rozmiarze,
dane lokalne odłożone na stosie (a dokładnie na stosie dla każdego w wątków osobno),
dane dynamiczne na stercie.
Dane globalne są zainicjalizowane przed wywołaniem funkcji main
i są
dostępne wszędzie w programie:
#include <iostream>
using namespace std;
// This is not a string literal, but a table of characters initialized
// with a string literal.
char t[] = "Hello!";
int main()
{
t[0] = 'Y';
cout << t << endl;
}
Dane statyczne są zainicjalizowane przed ich pierwszym użyciem i są lokalne (czyli niedostępne poza funkcją):
#include <iostream>
using namespace std;
struct A
{
A()
{
cout << "A" << endl;
}
};
void foo(bool flag)
{
cout << "foo" << endl;
if (flag)
static A a;
}
int main()
{
cout << "Main" << endl;
foo(false);
foo(true);
foo(true);
}
W przykładzie wyżej, proszę usunąć static
i zauważyć zmianę w
zachowaniu programu.
Zmienne globalne i statyczne wydają się podobne, ponieważ utrzymują dane między wywołaniami funkcji. Jednak są dwa powody, aby użyć zmiennej statycznej zamiast globalnej:
statyczna zmienna jest inicjalizowane tylko wtedy, kiedy trzeba (kiedy wywołujemy funkcję), a zmienna globalna jest zawsze inicjalizowana, co może pogorszyć wydajność, jeżeli zmienna nie jest używana,
utrzymanie kodu w porządku i zapobieganie błędom.
Dane lokalne funkcji albo bloku są tworzone na stosie. Dane lokalne są automatycznie niszczone, kiedy wychodzą poza zakres (funkcji albo bloku) – to nie tylko poręczna własność danych lokalnych, ale także konieczność, bo stos musi się zmniejszyć, kiedy zakres się kończy.
Lokalne dane są niszczone w kolejności odwrotnej do kolejności ich tworzenia, bo stos jest strukturą FILO (ang. first in, last out).
#include <iostream>
#include <string>
using namespace std;
struct A
{
string m_name;
A(const string &name): m_name(name)
{
cout << "ctor: " << m_name << endl;
}
~A()
{
cout << "dtor: " << m_name << endl;
}
};
int main()
{
A a("a, function scope");
A b("b, function scope");
// Block scope.
{
A a("a, block scope");
A b("b, block scope");
}
cout << "Bye!" << endl;
}
Dynamiczne dane (albo precyzyjniej: dane alokowane dynamicznie) są
tworzone na stercie i powinny być zarządzane przez inteligentny
wskaźnik, który to z kolei jest zaimplementowany z użyciem
nisko-poziomowej funkcjonalności surowych wskaźników, w szczególności
operatorów new
and delete
.
Dane stworzone przez operator new
muszą być potem zniszczone przez
operator delete
, żeby uniknąć wycieku pamięci. Próba zniszczenia
tych samych danych dwa razy skutkuje niezdefiniowanym zachowaniem
(np., naruszenie ochrony pamięci, bugi).
Powinniśmy używać inteligentnych wskaźników, bo chronią przed błędami i upraszczają kod, ale są trudniejsze w użyciu niż surowe wskaźniki. Użycie surowych wskaźników jest narażone na błędy (ang. error-prone), które powracają jako uciążliwe heisenbugi. Ponieważ inteligentne wskaźniki zostały wprowadzone w C++11, to nowy kod zazwyczaj używa inteligentnych wskaźników, a stary kod surowych wskaźników.
W poniższym przykładzie użyliśmy operatorów new
i delete
, czego
już lepiej nie robić, ale jest to najprostszy przykład użycia danych
dynamicznych.
#include <iostream>
#include <string>
using namespace std;
struct A
{
A()
{
cout << "ctor\n";
}
~A()
{
cout << "dtor\n";
}
};
A * factory()
{
return new A();
}
int main()
{
A *p = factory();
delete p;
cout << "Bye!" << endl;
}
Alokacja pamięci dla danych na stosie jest najszybsza: wystarczy zwiększyć (albo zmniejszyć, w zależności od architektury procesora) wskaźnik stosu (zwany także rejestrem stosu) o wielkość potrzebnej pamięci.
Stos może mieć ustalony rozmiar albo jego rozmiar może rosnąć automatycznie: dodatkowa pamięć dla stosu może być alokowana bez potrzeby żądania tego przez proces, jeżeli system operacyjny to potrafi. Jeżeli nie, to proces zostanie zakończony z błędem, kiedy dojdzie do przepełnienia stosu.
Poniższy kod testuje, jak duży jest stos i czy system operacyjny potrafi automatycznie przydzielać więcej pamięci dla stosu. Funkcja wywołuje siebie (rekursywnie) i wypisuje na standardowe wyjście, ile razy była wywołana. Jeżeli widzimy małe liczby (poniżej miliona), kiedy proces był zakończony, to system nie jest w stanie automatycznie zwiększać rozmiaru stosu. Jeżeli widzimy duże liczby (znacznie powyżej miliona), to najprawdopodobniej system automatycznie zwiększał rozmiar stosu.
#include <iostream>
using namespace std;
void
foo(long int x)
{
int y = x;
cout << y << endl;
foo(++y);
}
int main()
{
foo(0);
}
Alokacja pamięci na stercie jest wolna, bo sterta jest złożoną stukturą danych, która nie tylko alokuje i dealokuje pamięć dowolnego rozmiaru, ale także defragmentuje pamięć. Taka funkcjonalność wymaga kilku zapisów i odczytów pamięci dla jednej alokacji.
System operacyjny alokuje więcej pamięci dla sterty, kiedy proces tego żąda, a dokładnie żąda tego bilioteka, która dostarcza funkcjonalność dynamicznej alokacji pamięci (na Linuxie to libstdc++). Bilioteka prosi system o przydzielenie pamięci do zapisu i odczytu, ale system nie wie, że ma to być dla sterty.
Sterta może być zwiększana do dowolnego rozmiaru, ograniczonego
jedynie przez system operacyjny. Kiedy system w końcu odmawia
przydzielenia więcej pamięci, operator new
rzuca wyjątek
std::bad_alloc
. Oto przykład:
#include <cassert>
#include <iostream>
int main()
{
for(unsigned x = 1; true; ++x)
{
// Allocate 1 GiB.
std::byte *p = new std::byte [1024 * 1024 * 1024];
assert(p);
std::cout << "Allocated " << x << "GiBs." << std::endl;
}
}
Dane na stosie są upakowane razem w zależności od tego, kiedy były tworzone, tak więc dane powiązane ze sobą znajdują się blisko siebie w pamięci. Nazywamy to kolokacją danych. Kolokacja jest korzystna, ponieważ dane potrzebne procesowi (a dokładniej jakiejś funkcji procesu) w pewnym momencie najprawdopodobniej znajdują się w pamięci podręcznej procesora (która przechowuje strony pamięci), przyspieszając kilkakrotnie dostęp do danych.
Powiązane ze sobą dane są mniej kolokowane na stercie (w porównaniu ze stosem): prawdopodobnie są rozrzucone po różnych miejscach sterty, co spowolnia do nich dostęp, bo prawdopodobnie nie znajdują się w pamięci podręcznej procesora.
Wywołując funkcję możemy przekazać argument przez wartość albo przez referencję. Funkcja może wrócić wynik także przez wartość albo referencję. Nie ma innych sposobów przekazywania argumentów i zwracania wartości.
Funkcja ma parametry, a funkcję wywołujemy z argumentami. Parametr funkcji jest dostępny wewnątrz funkcji. Parametr ma typ i nazwę podawane przy deklaracji lub definicji funkcji. Argument jest wyrażeniem, które jest częścią wyrażenia wywołania. Parametr jest inicjalizowany z użyciem argumentu.
Jeżeli parametr funkcji jest typu niereferencyjnego, to mówimy, że funkcja przyjmuje argument przez wartość albo że przekazujemy argument do funkcji przez wartość. W starszym C++ parametr niereferencyjny był inicjalizowany przez skopiowanie wartości argumentu do parametru.
Jeżeli parametr funkcji jest typu referencyjnego, to mówimy, że funkcja przyjmuje argument przez referencję albo że przekazujemy argument do funkcji przez referencję. Inicjalizacja czyni parametr nazwą (aliasem) danych argumentu.
Przykład niżej pokazuje jak przekazujemy argumenty przez wartość i
przez referencję. Proszę skompilować przykład z flagami
-fno-elide-constructors -std=c++14
(flagi kompilatora GCC), żeby
kompilator nie unikał konstruktorów. Jeżeli kompilujemy z użyciem
C++17 lub nowszego (np. użyliśmy flagi -std=c++17
), to kompilator
zignoruje flagę -fno-elide-constructors
w przypadkach, kiedy
pomijanie konstruktorów jest wymagane począwszy od C++17.
#include <iostream>
#include <string>
using namespace std;
struct A
{
string m_name;
A(const string &name): m_name(name)
{
cout << "ctor: " << m_name << endl;
}
A(const A &a)
{
cout << "copy-ctor: " << a.m_name << endl;
m_name = a.m_name + " copy";
}
void hello() const
{
cout << "Hello from " << m_name << endl;
}
};
void foo(A a)
{
a.hello();
}
void goo(const A &a)
{
a.hello();
}
int main()
{
foo(A("foo"));
goo(A("goo"));
}
Jeżeli typ zwracanego wyniku jest niereferencyjny, to mówimy, że funkcja zwraca wynik przez wartość. W nowoczesnym C++ zwracanie przez wartość jest bardzo szybkie, nie wprowadza żadnego niepożądanego narzutu i dlatego jest zalecane. To nie to, co kiedyś w dalekiej przeszłości, kiedy C++ nie był jeszcze ustandaryzowany.
Niegdyś zwracanie przez wartość zawsze kopiowało wynik dwa razy. Raz ze zmiennej lokalnej funkcji do tymczasowego miejsca na stosie dla zwracanego wyniku. Drugi raz z tymczasowego miejsca do miejsca docelowego, np. zmiennej, której wynik przypisywano.
Jeżeli typ zwracanego wyniku jest referencyjny, to mówimy, że funkcja
zwraca wynik przez referencję. Referencja powinna odnosić się do
danych, które będą istnieć po wyjściu z funkcji (czyli dane powinny
przeżyć funkcję). Na przykład, kontenery (np. std::vector
) zwracają
referencję do dynamicznie zaalokowanych danych z użyciem operatora
indeksowania (czyli operator[]
) albo funkcji front
.
Poniższy przykład pokazuje jak zwrócić wynik przez wartość i przez
referencję. Na nowoczesnym systemie i z nowoczesnym kompilatorem,
wynik zwracany przez wartość nie jest kopiowany. Żeby zobaczyć stare
zachowanie C++, proszę skompilować przykład z flagami
-fno-elide-constructors -std=c++14
. Gdzie i dlaczego obiekty są
kopiowane? To zależy od konwencji wywołania funkcji, optymalizacji
wartości powrotu czy unikania konstruktorów.
#include <iostream>
#include <string>
using namespace std;
struct A
{
string m_name;
A(const string &name): m_name(name)
{
cout << "ctor: " << m_name << endl;
}
A(const A &a)
{
cout << "copy-ctor: " << a.m_name << endl;
m_name = a.m_name + " copy";
}
void hello() const
{
cout << "Hello from " << m_name << endl;
}
};
A foo()
{
A a("foo");
return a;
}
A & goo()
{
static A a("goo");
return a;
}
int main()
{
foo().hello();
goo().hello();
A a = foo();
a.hello();
}
Konwencja wywołania funkcji to szczegóły techniczne dotyczące wywołania funkcji, które zależą od platformy (architektury systemu, systemu operacyjnego i kompilatora). C++ nie definiuje konwencji wywołania funkcji, ale pewne funkcjonalności (jak unikanie konstruktorów czy optymalizacja wartości powrotu) wynikają typowej konwencji wywołania funkcji.
Zwykły programista C++ nie musi znać tych szczegółów, ale warto o nich wspomnieć, żeby zrozumieć, że język C++ jest składową systemów informatycznych, które ewoluują. W tej ewolucji C++ musi być zgodny binarnie z C (żeby móc wywoływać funkcje języka C), gdzie nowe funkcjonalności C++ powinny dać się zaimplementować na wspieranych platformach.
Konwencji wywołania funkcji jest wiele, które głównie zależą od możliwości procesora. Uogólniając, typowa konwencja (minimalna funkcjonalność każdej konwencji) wymaga od strony wywołującej:
utworzenia parametrów na stosie,
alokacji pamięci dla zwracanej wartości,
Parametry są tworzone na stosie, ponieważ procesor może ich nie pomieścić (w rejestrach). Strona wywołująca alokuje pamięć dla zwracanej wartości, ponieważ procesor może jej nie pomieścić (w rejestrach), a powinna ona być dostępna po powrocie z funkcji.
Małe dane mogą być przekazywane i zwracane w rejestrach procesora. Na przykład, funkcja może przyjąć jako argument albo zwrócić jako wynik liczbę całkowitą w rejestrze, np. EAX dla x86, Linuxa i GCC.
W starej konwencji funkcja zwracała wynik w tyczasowym miejscu na szczycie stosu, które można było łatwo zlokalizować z użyciem rejestru stosu – to była zaleta. Wadą jednak była konieczność kopiowania wyniku z miejsca tymczasowego do miejsca docelowego, np. zmiennej, której wynik przypisywano.
Nowoczesna konwencja wywołania funkcji pozwala na alokację miejsca dla zwracanej wartości gdziekolwiek w pamięci (nie tylko na stosie, ale także na stercie czy pamięci dla danych globalnych i statycznych) i przekazanie adresu tego miejsca, żeby funkcja zwróciła wynik we wskazanym miejscu. Nie potrzebujemy już tymczasowego miejsca. Na przykład, dla x86, Linuxa i GCC, adres jest przekazywany do funkcji na szczycie stosu, a do konstruktora w rejestrze RDI.
Przykład niżej pokazuje, że wynik może być zwracany gdziekolwiek (na co pozwala nowoczesna konwencja wywołania), a nie tylko na stosie (jak narzucała to stara konwencja). W przykładzie funkcja zwraca obiekt bezpośrednio w miejscu pamięci dla danych globalnych i statycznych, bez kopiowania obiektu z udziałem tymczasowego miejsca wymaganego przez starą konwencję.
#include <iostream>
using namespace std;
struct A
{
A()
{
cout << "default-ctor" << endl;
}
A(const A &a)
{
cout << "copy-ctor" << endl;
}
~A()
{
cout << "dtor" << endl;
}
};
A foo()
{
return A();
}
A a = foo();
int main()
{
}
C++ pomija wywołanie konstruktora kopiującego i przenoszącego dla obiektów (np. tymczasowych albo lokalnych), które wkrótce zostaną zniszczone. Pominięcie wywołania konstruktora (ale tylko kopiującego i przenoszącego) jest możliwe, ponieważ obiekt tymczasowy albo lokalny jest tworzony w miejscu docelowym.
Przykład niżej demonstruje pomijanie konstruktorów. Przykład proszę
skompilować z flagami -fno-elide-constructors -std=c++14
, a potem
bez nich i zwrócić uwagę na różnice.
#include <iostream>
#include <string>
using namespace std;
struct A
{
string m_name;
A()
{
cout << "default-ctor" << endl;
}
A(const string &name): m_name(name)
{
cout << "direct-ctor: " << m_name << endl;
}
A(const A &a): m_name(a.m_name)
{
cout << "copy-ctor: " << m_name << endl;
}
~A()
{
cout << "dtor: " << m_name << endl;
}
};
int main()
{
// That's a function declaration, though in the legacy C++ it used
// to mean the default initialization of object "foo".
// A foo();
// The equivalent ways of default initialization.
{
A a;
A b{};
A c = A();
A d = A{};
A e(A{});
A f{A()};
// Acceptable and interesting, but we don't code like that.
A g = A(A());
A h = A{A{}};
}
// The equivalent ways of direct (with arguments) initialization.
{
A a("a");
A b{"b"};
A c = A("c");
A d = A{"d"};
A e(A{"e"});
A f{A("f")};
// Acceptable and interesting, but we don't code like that.
A g = A(A("e"));
A h = A{A{"f"}};
}
}
Proszę skompilować różne poprzednie przykłady przekazywania argumentów i zwracania wartości z włączonym i wyłączonym pomijaniem konstruktorów. Proszę zwrócić uwagę, że pomijanie konstruktorów eliminuje zbędne kopiowanie obiektów.
Kiedy obiekt tymczasowy jest przekazywany przez wartość do funkcji, to pominięcie konstruktora powoduje stworzenie tego obiektu bezpośrednio w miejscu na stosie dla tego parametru.
Funkcja może zwrócić wynik przez wartość bezpośrednio w miejscu docelowym, np. w zmiennej, której wynik przypisujemy. Chodzi o to, żeby wyniku nie kopiować ani nie przenosić, czyli żeby pominąć zbędne wywołanie konstruktora. Pominięcie konstruktora dla zwracanej wartości wymaga zastosowania nowoczesnej konwencji wywołania funkcji.
Ta funkcjonalność jest cechą języka od C++17, ale wcześniej była
nazywana optymalizacją wartości powrotu (ang. return value
optimization, RVO), bo była opcjonalną cechą optymalizatora
kompilatora. Od C++17 nie wymaga się, żeby konstruktory były
dostępne, jeżeli są pomijane, więc poniższy kod jest poprawny w myśl
C++17 (opcja -std=c++17
GCC), ale nie C++14 (opcja -std=c++14
GCC):
struct A
{
A() = default;
A(A &&) = delete;
};
A foo(A)
{
return A();
}
int
main()
{
A a = foo(A());
}
Pomijanie konstruktorów dla zwracanej wartości nie zawsze może być zastosowane z powodów technicznych. Przede wszystkim dlatego, że pewne obiekty muszą być pierwsze stworzone, a dopiero potem jest podejmowana decyzja, który obiekt zwrócić:
#include <iostream>
#include <string>
using namespace std;
struct A
{
A()
{
cout << "default-ctor" << endl;
}
A(const A &a)
{
cout << "copy-ctor" << endl;
}
~A()
{
cout << "dtor" << endl;
}
};
A foo(bool flag)
{
// These objects have to be created, and since we don't know which
// is going to be returned, both of them have to be created locally.
A a, b;
// The returned value must be copied.
return flag ? a : b;
}
int main()
{
foo(true);
}
Także dlatego, że próbujemy zwrócić przez wartość parametr funkcji, który już został stworzony przez kod wywołujący, nie funkcję. Funkcja może jedynie skopiować wartość parametru do miejsca, gdzie ma być zwrócony wynik:
#include <iostream>
#include <string>
using namespace std;
struct A
{
A()
{
cout << "default-ctor" << endl;
}
A(const A &a)
{
cout << "copy-ctor" << endl;
}
~A()
{
cout << "dtor" << endl;
}
};
A foo(A a)
{
return a;
}
int main()
{
foo(A());
}
I także dlatego, że próbujemy zwrócić przez wartość dane globalne albo statyczne, które muszą istnieć po powrocie z funkcji. Funkcja może wtedy jedynie skopiować wynik:
#include <iostream>
#include <string>
using namespace std;
struct A
{
A()
{
cout << "default-ctor" << endl;
}
A(const A &a)
{
cout << "copy-ctor" << endl;
}
~A()
{
cout << "dtor" << endl;
}
};
// Global data.
A a;
A foo()
{
// This one overshadows the global "a".
static A a;
return a;
}
A goo()
{
return a;
}
int main()
{
foo();
goo();
}
Dane mogą być alokowane statycznie, globalnie, lokalnie i dynamicznie.
Alokacja pamięci na stosie jest super szybka, a na stercie znacznie wolniejsza.
Nie używaj danych dynamicznych, jeżeli wystarczą dane lokalne.
Obecnie przekazywanie parametru i zwracanie wyniku przez wartość są wydajne, bo wartość nie jest już niepotrzebnie kopiowana czy przenoszona.
Czy alokacja pamięci na stosie jest szybsza niż na stercie?
W jakich miejscach pamięci możemy tworzyć obiekty?
Na czym polega pomijanie konstruktorów przy zwracaniu wyniku przez wartość?