Wykład 1 - Translacja #
Zakres:
- budowanie programów w języku C++
- jednostki translacji, trajektoria kompilacji
- elementy programu, definicje i deklaracje
- przestrzenie nazw
- one definition rule
- słowa kluczowe
static
/extern
- pliki nagłówkowe
- słowo kluczowe
inline
- biblioteki statyczne i dynamiczne
- algorytm linkera
- moduły
Język C++ #
Język autorstwa duńskiego informatka Bjarne Stroustrup’a. Rozpoczął prace jeszcze w roku 1979 mając na celu stworzenie “C z klasami”. Nazwa C++ pojawiła się w roku 1982. Po latach rozwoju, w 1998 roku pojawił się pierwszy standard ISO C++98. Mimo ogromnej popularności język ewolułował powoli, aż do roku 2011. Komitet ISO trzyma się od tego czasu 3-letniego cyklu w którym regularnie publikowany jest nowy standard języka - C++11, C++14, C++17, C++20, C++23 (obecny). Grupa regularnie publikuje postępy: https://isocpp.org/std/status.
C++ nadal jest jednym z najpopularniejszych języków, trzymając się 2-3 pozycji w indeksie TIOBE. Znajduje bardzo szerokie zastosowanie, zwłaszcza w projektach wymagających najwyższej wydajności.
Ten język jest wszechobecny. Ciężko znaleźć platformę sprzętową, na której nie dałoby się go wykorzystać (podobnie do C). Od urządzeń bez systemu operacyjnego (baremetal), przez aplikacje serwerowe, desktopowe, rozproszone systemy HPC, aplikacje graficzne i gry komputerowe. Jako jedno z nielicznych tego typu narzędzi posiada szereg różnych, niezależnie rozwijanych implementacji - systemów kompilatorów i bibliotek umożliwiających uruchamianie oprogramowania napisanego w tym języku.
Język jest ekstremalnie trudny do nauki. Zyskanie biegłości trwa lata. Historyczne zawiłości i błędy w projekcie języka wciąż są widoczne. Gwałtowny rozwój w ostatnich latach tylko dodaje złożoności (i oczywiście możliwości). Twórcy zakładają, że powinien pozwalać na implementację dowolnego przypadku użycia, pozwolić na programowanie w różnych stylach, prioretyzując dodawanie użytecznych funkcjonalności nad spójnością i bezpieczeństwem.
C++ nie jest językiem obiektowym. Pozwala pisać w sposób obiektowy, wspiera w tym programistę dostarczając mechanizmy takie jak klasy, enkapsulację, dziedziczenie, polifmorfizm, ale nie narzuca tego podejścia. Można w C++ pisać tak jak w C, można pisać funkcyjnie, można wykorzystywać elementy programowania generycznego i skomplikowanego metaprogramowania, zgodnie z powyższymi założeniami.
Semestr nauki C++ pozwoli wprowadzić kluczowe elementy, torując drogę do dalszej, samodzielnej praktyki.
C++ to nie jest rozszerzenie języka C. To zupełnie inne, samodzielne, posiadające niezależnie rozwijaną specyfikację narzędzie. W pewnym podstawowym zakresie jest do języka C podobny, z czego będziemy bogato korzystać.
Hello World #
Zacznijmy od najprostszego programu w C++ zaimplementowanego w jednym pliku hello.cpp
:
#include <iostream>
int main()
{
std::cout << "Hello World!";
return 0;
}
Source: hello.cpp
Mając treść takiego programu w pliku tekstowym na dysku możemy go zbudować:
# Linux z wykorzystaniem kompilatora gcc
g++ -o hello.gcc hello.cpp
# Linux z wykorzystaniem kompilatora clang
clang++ -o hello.clang hello.cpp
# Windows
cl /Fehello.exe hello.cpp
Znany z pierwszego semestru Visual Studio, pod spodem używa kompilatora
cl
.
Do zbudowania potrzebny jest kompilator - program który tłumaczy kod z plików tekstowych na kod maszynowy. Takich kompilatorów jest bardzo dużo, wszystkie się różnią, mają wiele wersji, wspierają rozwijający się język w różnym stopniu.
graph LR hello.cpp -->|reads| compiler compiler -->|writes| hello %% Custom coloring for the middle box style compiler fill: #FFD700, stroke: #000, stroke-width: 2px
Standard C++ nie mówi nic na temat szczegółów realizacji kompilatora. W szczególności źródła nie muszą być plikami w potocznym rozumieniu (niektóre systemy nie mają plików). Definiuje uproszczony schemat tego, jak proces budowania ma działać, który jest realizowany różnie przez różnych dostawców narzędzi (tzw. implementacja języka).
Dzisiaj można używać kompilatorów online do nauki i pisania prostych programów, np. https://godbolt.org/.
Po wygenerowaniu pliku wyjściowego możemy go uruchomić:
./hello.gcc
W praktyce, do programowania w C++, tak jak w przypadku innych języków, używamy zintegrowanego środowiska programistycznego (IDE). Wykład celowo demonstruje narzędzia od podstaw, mając na celu przybliżenie całego ekosystemu, a nie tylko języka samego w sobie.
Przeanalizujmy strukturę programu. Rozpoczynamy od dyrektywy preprocesora:
#include <iostream>
Kompilator, czytając plik źródłowy, dokonuje w pierwszej kolejności interpretacji takich dyrektyw.
#include
wkleja treść pliku iostream
w miejscu dyrektywy.
Nagłówki biblioteki standardowej C++ nie mają rozszerzeń.
Dla większości standardowych nagłówków języka C dostępny jest odpowiednik w formacie <c[nagłówek]
>, np. <cstring>
(
zamiast <string.h>
w C)
dostarczający podobne funkcje.
Dalej następuje definicja funkcji main()
:
int main()
{
...
}
Podstawowa składnia funkcji, zmiennych, struktur, enumeracji jest podobna do C. Funkcje składają się z typu zwracanego, nazwy, listy parametrów i ciała.
W ciele funkcji znajduje się instrukcja wypisania:
std::cout << "Hello World!";
To konstrukcja analogiczna do printf("Hello World!")"
z języka C. Zamiast funkcji printf()
do
wypisywania na standardowe wejście używamy operatora <<
zdefiniowanego w bibliotece standardowej dla klasy obiektu
std::cout
.
Podstawowe narzędzia #
Aby rozpocząć pisanie prostych programów, warto poznać kilka użytecznych narzędzi typowych dla C++.
Standardowe strumienie I/O #
Nagłówek <iostream> dostarcza globalne zmienne reprezentujące
strumienie wejścia/wyjścia: std::cout
, std::cin
, std::cerr
, std::clog
. Biblioteka C zachowywała się podobnie,
dostarczając globalne stdin
, stdout
i stderr
.
Strumienie wyjściowe definiują operatory
<<
dla różnych typów podstawowych,
pozwalające w prosty sposób formatować je na wyjście.
int x = 0;
float y = 7.5;
char txt[] = "asdf";
std::cout << x << ", " << y << ", " << txt << std::endl;
Operatory można łączyć łańcuchowo. std::endl
kończy linię i dodatkowo wymusza zapis zbuforowanych w strumieniu danych.
Analogicznie std::cin
pozwala konwertować tekst ze standardowego wejścia na różne typy wbudowane:
int x;
float y;
char txt[10];
std::cin >> x >> y >> txt;
Typ std::string
#
Nagłówek <string> dostarcza typ std::string
. To kontener
biblioteki standardowej przechowujący ciągłą, dynamiczną tablicę znaków,
idealny do przechowywania tekstu. Sam rośnie w miarę dodawania do niego znaków. Integruje się ze standardowymi
strumieniami.
std::string empty;
std::string txt = "Hello, ";
std::string txt2 = txt + "World!"; // "Hello, World!"
txt2[5] = '?'; // "Hello? World!"
txt2 = empty; // ""
std::string option = "first";
if (txt == "first") {
// ...
} else {
// ...
}
std::cin >> txt;
Typ std::vector
#
Najważniejszy kontener biblioteki standardowej to vector<T>
, czyli dynamiczna, ciągła tablica obiektów określonego
typu.
Definiuje go nagłówek <vector>.
Sam std::vector
to nazwa tzw. szablonu klasy - mechanizmu języka C++ do programowania generycznego.
Dopiero po wskazaniu typu przechowywanych elementów, np. std::vector<int>
, std::vector<std::string>
,
std::vector<MyStruct>
,
można go użyć jako typu zmiennej.
Domyślnie utworzony wektor jest pusty. Można go zainicjalizować daną listą elementów. Można go indeksować jak tablicę.
std::vector<int> vec = {1, 2, 3, 4};
std::cout << vec.size() << '\n'; // 4
vec.push_back(5); // {1, 2, 3, 4, 5}
vec.resize(3); // {1, 2, 3}
vec[1] = 10; // {1, 10, 3}
std::vector copy = vec; // {1, 10, 3}
std::vector<int> empty; // {}
vec = empty; // {}
Struktury i klasy w C++ poza polami mogą posiadać także metody, czyli funkcje składowe.
Wywołujemy je na rzecz danego obiektu, tak samo, jak odnosimy się do pól obiektu operatorami wyłuskania: .
, ->
.
Przykładowo vec.push_back(5)
wywołuje metodę push_back
na rzecz obiektu vec
, wstawiając na koniec wektora wartość
5
.
vec.size()
zwróci rozmiar wektora vec
.
Trajektoria kompilacji #
Program w języku C++ jest budowany z wielu tekstowych plików wejściowych - tzw. jednostek translacji.
graph LR source1.cpp --> buildsystem source2.cpp --> buildsystem main.cpp --> buildsystem buildsystem --> prog %% Custom coloring for the middle box style buildsystem fill: #FFD700, stroke: #000, stroke-width: 2px
Każdy plik źródłowy przechodzi przez 9 faz tłumaczenia:
- Mapowanie znaków źródłowych
Wejście kompilatora jest znak po znaku mapowane na zbiór znaków standardowych, uspójniając reprezentację tekstową kodu źródłowego pomiędzy systemami operacyjnymi / platformami.
- Sklejanie linii
Linie kończące się \
są łączone z następnymi.
- Lekser
Strumień znaków jest zamieniany na strumień tokenów - jednostek składniowych języka:
#include
<iostream>
int
main
(
)
{
char
txt
[
]
=
"hello"
;
return
0
;
}
- Preprocesor
Wykonywane są dyrektywy preprocesora, np. #ifndef
.
Pliki załączane dyrektywą #include
rekursywnie przechodzą przez fazy 1 - 4.
- Kodowanie znaków
Literały znakowe i ciągi znaków są kodowane do docelowej reprezentacji w pamięci, np. UTF-8, UTF-16.
- Łączenie stringów
Następujące po sobie literały są konkatenowane:
"hello, "
"world"
-> "hello, world"
- Kompilacja
Właściwy etap kompilacji, analizujący ciąg tokenów, sprawdzający poprawność składniową i znaczeniową programu. Generacja kodu wynikowego dla danej jednostki translacji.
- Instancjonowanie szablonów
Generacja kodu dla wykorzystywanych w jednostce translacji szablonów: typów generycznych języka C++.
- Linkowanie
Wszystkie jednostki translacji są łączone razem z zewnętrznymi zależnościami w celu wyprodukowania wykonywalnego programu.
Przykładowo dwuplikowy projekt byłby budowany tak:
// main.cpp
int foo();
int main() {
return foo();
}
// helper.cpp
int foo() {
return 123;
}
g++ main.cpp helper.cpp -o prog.exe
./prog.exe
graph LR source.cpp -->|1 - 8| source.o other.cpp -->|1 - 8| other.o source.o --> linker[9 - linker] other.o --> linker linker --> prog.exe
Formalnie jednostką translacji nazywa się wejście fazy 7 - właściwego kompilatora. Potocznie, programiści często nazywają jednostkami translacji pliki źródłowe.
Jedynie linkowanie jest procesem globalnym. Pozostałe mogą być wykonywane niezależnie na każdym pliku wejściowym.
W zależności od typu błędu będzie on diagnozowany na różnych etapach tego potoku.
Warto zwrócić uwagę na fazy 2 i 6 pozwalające na ciekawe zabiegi edytorskie:
#include <iostream>
#define MULTI_LINE_MACRO(x, y) \
std::cout << "x: " << x << ", y: " << y << std::endl; \
std::cout << "This is a multi-line macro!" << std::endl;
const char* longString = "This is a very long string \
that is continued \
on the next line.";
const char* longerString = "This is a set of long string literals "
"that has been split across multiple lines "
"to improve code readability.";
const char* injectedString =
"this is a string "
#include "middle.hpp"
"from somewhere else.";
Source: splice.cpp
g++ splice.cpp -o splice.exe && ./splice.exe
Kompilatory zwykle pozwalają na selektywne sterowanie
fazami kompilacji. Przykładowo gcc
posiada serię flag
pozwalającą zatrzymywać proces po jednym z 4 etapów:
> g++ --help
...
-E Preprocess only; do not compile, assemble or link.
-S Compile only; do not assemble or link.
-c Compile and assemble, but do not link.
Wykorzystując je można zapisać i obejrzeć produkty pośrednie procesu translacji.
g++ -E -o hello.i hello.cpp
# hello.i zawiera wyjście preprocesora
g++ -S -o hello.s hello.i
# hello.s zawiera wyjście kompilatora
g++ -c hello.s
# hello.s zawiera wyjście assemblera
g++ -o hello hello.o
# hello zawiera zlinkowany plik wykonywalny
Tłumacząc to na wyżej opisane fazy:
Flaga -E
spowoduje wykonanie faz 1-6, -S
fazy 7 i 8 (częściowo),
-c
kończy fazę 8
. Wywołanie bez flag wykonuje linker - fazę 9.
Jak widać nie wpisuje się to czysto w model abstrakcyjny, który nie wymusza nawet istnienia asemblera. Standard ponownie nic nie może wyspecyfikować na temat tego jak programy wyglądają po skompilowaniu, jak się je przechowuje, ani jak je uruchamia.
Typowy system budowania będzie niezależnie tłumaczył każdy z plików źródłowych do tzw. obiektu (object file), czyli wymagającego linkowania wyjścia assemblera a dopiero po utworzeniu wszystkich obiektów konsolidował je w kompletny program.
g++ -c main.cpp -o main.o
g++ -c helper.cpp -o helper.o
g++ main.o helper.o -o prog.exe
./prog.exe
Makefile #
Zamiast wydawać polecenia budowania ręcznie w większych projektach potrzebny jest system budowania. Dzięki niemu programiści chcący zbudować oprogramowanie mogą typowo wydać jedno polecenie. System budowania w postaci plików/skryptów zawiera flagi kompilacji, zależności projektu, skrypty instalacyjne, generatory kodu, itd.
Najprostszym systemem, z którego będziemy początkowo korzystać jest Makefile.
Korzystając z makefile, projekt opisują pliki tekstowe zawierające deklaracje reguł w formacie:
targets: prerequisites
command
command
command
targets
to lista plików generowanych przez wykonanie reguły (np. object file, executable).
prerequisites
to lista plików niezbędnych do wykonania reguły (np. pliki źródłowe).
command(s)
to lista poleceń powłoki uruchamiana celem wyprodukowania wyjść z wejść (np. g++
)
Makefile uruchamia reguły, tylko jeżeli:
- plik wyjściowy nie istnieje
- (lub) plik wyjściowy jest starszy niż zależności reguły
Najprostszy system budowania dla dwuplikowego projektu wyglądałby tak:
all: prog.exe
main.o: main.cpp
g++ -c main.cpp -o main.o
helper.o: helper.cpp
g++ -c helper.cpp -o helper.o
prog.exe: main.o helper.o
g++ main.o helper.o -o prog.exe
Source: Makefile
Uruchamiamy poleceniem:
make
Elementy programu #
Program w C++ składa się z różnych elementów (ang. entities):
- wartości
- obiekty
- referencje
- funkcje
- enumeratory
- typy
- składowe klas
- szablony
- specjalizacje szablonów
- przestrzenie nazw
Niektóre elementy będą posiadać nazwę, niektóre nie.
int a; // obiekt o nazwie 'a'
class c {}; // klasa o nazwie 'c'
32; // nienazwana wartość
struct {}; // struktura anonimowa
Jednostki translacji składają się z deklaracji. Deklaracje:
- wprowadzają elementy programu,
- mogą nadać im nazwę,
- mogą definiować ich właściwości;
/* przykłady deklaracji */
class P;
void foo();
extern int x;
struct S {
static int i;
}
Deklaracje, które w pełni opisują element, pozwalając na jego użycie, to również definicje.
Słowo kluczowe extern
w przypadku zmiennych zmienia definicję w deklarację.
/* przykłady definicji */
class P { int x; int y; };
void foo() {
std::cout << "Hi!";
}
int x = 3;
int S::x;
Należy znać dwie kluczowe zasady.
Użycie elementu wymaga jego definicji
// undefined.cpp
int foo();
int main() {
return foo();
}
Naruszenie zasady wywołuje niezdefiniowane zachowanie programu, co w tym przypadku objawia się błędem linkera:
g++ undefined.cpp
/usr/bin/ld: /tmp/cc2rq2Me.o: in function `main':
undefined.cpp:(.text+0x9): undefined reference to `foo()'
Druga, nawet istotniejsza zasada znana jako one definition rule:
Jednostka translacji może zawierać co najwyżej jedną definicję danego elementu. Program jako całość może zawierać co najwyżej jedną definicję zmiennej lub funkcji.
Jej naruszenie również zwykle powoduje błędy linkera:
// odr1.cpp
int a = 3;
int foo(int x) { return x; }
int main() { return foo(a); }
// odr2.cpp
int a = 4;
int foo(int x) { return 2 * x; }
g++ odr1.cpp odr2.cpp
/usr/bin/ld: /tmp/ccGKwVIG.o:(.data+0x0): multiple definition of `a'; /tmp/ccxy9LGW.o:(.data+0x0): first defined here
/usr/bin/ld: /tmp/ccGKwVIG.o: in function `foo(int)':
odr2.cpp:(.text+0x0): multiple definition of `foo(int)'; /tmp/ccxy9LGW.o:odr1.cpp:(.text+0x0): first defined here
Klasy i struktury mogą być definiowane raz na jednostkę, ale deklaracje w całym programie muszą być spójne (tekstowo identyczne). Inaczej występują ciekawe, niezdefiniowane rzeczy:
#include <iostream>
struct Point {
int x;
int y;
};
int dist(Point p, Point q);
int main() {
Point p = {1, 1};
Point q = {0, 0};
std::cout << dist(p, q) << std::endl;
return 0;
}
Source: classodr1.cpp
struct Point {
int x;
int y;
int z;
};
int dist(Point p, Point q) {
int dx = p.x - q.x;
int dy = p.y - q.y;
int dz = p.z - q.z;
return dx * dx + dy * dy + dz * dz;
}
Source: classodr2.cpp
g++ classodr1.cpp classodr2.cpp -o classodr.exe -O2 && ./classodr.exe
Taki program zwraca losowe wyniki (a nie spodziewaną wartość 2
).
Technicznie kod wygenerowany dla funkcji dist
, oczekujący dłuższych argumentów niż przekazane,
wykracza poza przekazany bufor i czyta niezainicjalizowaną pamięć stosu.
Przestrzenie nazw #
C++ daje możliwość umieszczania definicji i deklaracji w przestrzeniach nazw. Przestrzenie nazw grupują te dwa elementy celem lepszej ogranizacji i czytelności kodu oraz uniknięcia konfliktów nazw.
Do definiowania przestrzeni nazw używamy słowa kluczowego namespace
:
// Definicja przestrzeni nazw
namespace foo {
int x = 42;
void fun() {
std::cout << "foo::x = " << x << std::endl;
}
}
namespace goo {
int x = 13;
}
int x = 123;
W powyższym programie istnieją 3 zmienne x
- jedna w przestrzeni nazw foo
, jedna w goo
i jedna w globalnej
przestrzeni nazw.
Wszystkie elementy nieumieszczone w przestrzeni zdefiniowanej przez użytkownika są automatycznie umieszczane w tej
globalnej przestrzeni.
Przestrzenie nazw można zagnieżdżać naturalnie oddając hierarchiczną strukturę projektu w kodzie:
namespace foo {
int x = 42;
namespace goo {
int x = 58;
}
}
namespace foo::goo {
int y = 10;
}
Kompilator widząc odwołanie do nazwanego elementu, np. x
musi odnaleźć jego deklarację.
Przeszuka w pierwszej kolejności przestrzeń nazw, w której aktualnie jesteśmy (w której jest to odwołanie).
Jeżeli tam go nie znajdzie, rekurencyjnie przeszuka przestrzeń zewnętrzną, aż do przestrzeni globalnej.
Można jawnie odwołać się do elementu z konkretnej przestrzeni używając operatora ::
:
namespace foo
{
std::string ns = "foo";
namespace goo
{
std::string ns = "goo";
}
void fun()
{
std::cout << "from foo namespace:\n"
<< "ns = " << ns << '\n'
<< "goo::ns = " << goo::ns << '\n'
<< "::ns = " << ::ns << std::endl;
}
}
void fun()
{
std::cout << "from global namespace:\n"
<< "ns = " << ns << '\n'
<< "foo::ns = " << foo::ns << '\n'
<< "foo::goo::ns = " << foo::goo::ns << std::endl;
}
Source: ns.cpp
Wszystkie elementy udostępniane przez bibliotekę standardową języka
są umieszczone w przestrzeni nazw std::
.
Przestrzeń
std::
jest zarezerwowana dla implementacji! Programy nie mogą umieszczać w niej swoich symboli.
Różnorodne biblioteki, często w analogii do std::
, umieszczają swoje symbole
w dedykowanej przestrzeni nazw, np. nagłówki biblioteki libfmt opakowują
wszystko w przestrzeń nazw fmt::
, dzięki czemu eksponowane funkcje, klasy, zmienne nie będą
konfliktować z potencjalnie tak samo nazwanymi elementami konsumującego programu.
#include <fmt/core.h>
int main() {
fmt::print("Hello, world!\n");
}
Jeżeli jednostka translacji często korzysta z symboli z jakiejś przestrzeni nazw to może zadeklarować jej domyślne
przeszukiwanie korzystając ze składni using namespace ns;
.
Tryb linkowania #
Jednostki translacji mogą korzystać z elementów programu definiowanych w innych jednostkach translacji. To podstawowy mechanizm, dzięki któremu możliwa jest jakakolwiek komunikacja pomiędzy niezależnie budowanymi plikami. Pozwala modularyzować bardziej skomplikowane projekty, unikając pojedynczej, monolitycznej jednostki. Umożliwia również optymalizację procesu budowania - wiele jednostek translacji może być przetwarzanych równolegle.
Zdefiniowany element może być deklarowany wielokrotnie! C++ określa, kiedy dana nazwa użyta w różnych jednostkach translacji odnosi się do tego samego elementu.
// a.cpp
int x;
// b.cpp
int x; // czy to ten sam x?
// a.cpp
void foo() { ... }
// b.cpp
void foo(); // czy to ta sama funkcja?
Odpowiedź brzmi tak, jeżeli deklaracje/definicje są w tej samej jednostce translacji lub mają zewnętrzny tryb linkowania:. Tryb linkowania może być zewnętrzny lub wewnętrzny (lub brak).
Reguły stosowane do określenia, jaki tryb linkowania ma nazwa, są zawiłe. Zależą od tego:
- w jakim zakresie występuje nazwa (namespace, ciało funkcji, klasa, itp.)
- jakie kwalifikatory ma deklaracja
const
,extern
,static
,inline
.
Nalepiej zrozumieć to na powszechnie występujących przykładach.
Tryb zewnętrzny #
Symbole linkowane zewnętrznie są widoczne z innych jednostek.
Nazwy w zakresie przestrzeni nazw (na dowolnym poziomie) mają tryb zewnętrzny.
int f();
class P;
namespace foo {
int x;
}
Słowem kluczowym extern
poza zamianą definicji w deklarację, można jawnie wymusić tyb zewnętrzny:
extern const int eci;
namespace foo {
extern int x;
}
Tryb wewnętrzny #
Elementy o linkowaniu wewnętrznym są niejako prywatne dla jednostki translacji. Wiele jednostek może definiować tę samą nazwę, bo odnoszą się one do innych instancji.
Słowem kluczowym static
wymuszamy tryb wewnętrzny:
static int sf();
namespace foo {
static int si;
}
Elementy w anonimowych przestrzeniach nazw mają tryb wewnętrzny:
namespace {
int x;
}
Stałe domyślnie mają tryb wewnętrzny:
const int ci;
Ciekawy detal różniący języki C i C++:
Stałe w zakresie globalnej przestrzeni nazw C++ (zakresie pliku w C) mają linkowanie zewnętrzne w C, a wewnętrzne w C++.
Pliki nagłówkowe #
Pliki nagłówkowe są starym, sprawdzonym i odchodzącym powoli do lamusa sposobem na deduplikację
deklaracji i definicji pomiędzy jednostkami translacji. Chcąc korzystać w całym programie
z jakiegoś elementu (funkcji, zmiennej, klasy), każda jednostka translacji musi go
przynajmniej zadeklarować. Deklaracje te muszą być spójne w całym programie.
Stąd najczęściej wydzielamy je do osobnego pliku i załączamy we wszystkich korzystających
jednostkach translacji za pomocą dyrektywy #include
.
#ifndef POINT_HPP
#define POINT_HPP
struct Point {
int x, y;
};
int dist(Point p, Point q);
extern int factor;
#endif
Source: point.hpp
Nagłówki zwykle zawierają definicje typów (które mogą być definiowane wielokrotnie w programie) oraz deklaracje zmiennych i funkcji. Te zostaną zdefiniowane w dokładnie jednej dedykowanej jednostce translacji:
#include "point.hpp"
int factor = 2;
int dist(Point p, Point q) {
int dx = p.x - q.x;
int dy = p.y - q.y;
return factor * (dx * dx + dy * dy);
}
Source: point.cpp
Bariery kompilacji (#ifndef POINT_HPP
) blokują wielokrotne załączenie tego pliku,
a przez to nie dopuszczają do wielokrotnego definiowania, np. Point
.
Słowo kluczowe inline
#
Czasami, mimo wszystko, wygodnie jest umieścić definicję zmiennej lub funkcji w nagłówku.
W przypadku prostych elementów, krótkich funkcji podział zmniejsza czytelność i nie daje dużo zysku,
jeśli chodzi o czas kompilacji. C++ posiada w takim celu funkcje i zmienne inline
, które mogą być
definiowane wielokrotnie, w wielu jednostkach translacji, o ile ich definicje są zgodne:
// point.hpp
inline int dist(Point p, Point q) {
int dx = p.x - q.x;
int dy = p.y - q.y;
return factor * (dx * dx + dy * dy);
}
inline int factor = 2;
Załączenie takiego nagłówka w wielu plikach *.cpp
nie wywoła błędu linkera.
Linker zdeduplikuje funcje i zmienne pozostawiając jedną kopię każdego symbolu w gotowym programie.
Historycznie słowo inline
było podpowiedzią dla optymalizatora, sugerującą wklejenie kodu funkcji
w miejsca wywołania. Kompilatory stosowały tę optymalizację, bez względu na to, czy funkcja była inline
czy nie. Dlatego C++ mógł wykorzystać to słowo kluczowe w innym celu.
Biblioteki #
Modularyzacja na poziomie jednostek translacji jest w przypadku większych projektów niewystarczająca. Często w miarę wzrostu złożoności programiści wydzielają całe komponenty stanowiące spójną całość, posiadające dobrze opisany interfejs, składające się z wielu jednostek translacji. Taki kod często jest re-używalny. Zawiera ogólne definicje, funkcje pomocnicze, narzędzia do wykorzystania w wielu projektach.
Dystrybucja takiego oprogramowania może przebiegać w drodze udostępnienia wszystkich plików źródłowych. Konsument jest zmuszony wtedy budować nie tylko swój kod, ale i wszystkie zależności. Aby tego uniknąć, można dystrybuować biblioteki: skompilowany kod, niebędący programem, spakowany do postaci pojedynczego pliku.
graph TD A[main.cpp] -->|Compiler| O1[main.o] B[helper.cpp] -->|Compiler| O2[helper.o] O1 -->|Linker| EXE[Executable] O2 -->|Linker| EXE L1[external_library.a / .so] -->|Linker| EXE
Biblioteki statyczne #
Podstawowym typem biblioteki jest tzw. biblioteka statyczna, czyli archiwum zawierające kilka
skompilowanych plików obiektowych. Tworzymy ją za pomocą archiwizatora, zwykle ar
:
ar rcs libexternal_library.a file1.o file2.o
Raz utworzone archiwum jest gotowe do wykorzystania w procesie linkowania:
g++ main.o helper.o -L. -lexternal_library -o my_executable
graph TD subgraph Project Build A[main.cpp] -->|Compiler| O1[main.o] B[helper.cpp] -->|Compiler| O2[helper.o] O1 -->|Linker| EXE[Executable] O2 -->|Linker| EXE L1[external_library.a] -->|Linker| EXE end subgraph External Library Build L2[file1.cpp] -->|Compiler| LO1[file1.o] L3[file2.cpp] -->|Compiler| LO2[file2.o] LO1 -->|Archiver| L1 LO2 -->|Archiver| L1 end
System budowania opisujący całość takiego projektu wyglądałby następująco:
all: my_executable
my_executable: main.o helper.o libexternal_library.a
g++ main.o helper.o -L. -lexternal_library -o my_executable
main.o: main.cpp
g++ -c main.cpp -o main.o
helper.o: helper.cpp
g++ -c helper.cpp -o helper.o
libexternal_library.a: file1.o file2.o
ar rcs libexternal_library.a file1.o file2.o
file1.o: file1.cpp
g++ -c file1.cpp -o file1.o
file2.o: file2.cpp
g++ -c file2.cpp -o file2.o
Sources: Makefile main.cpp helper.cpp helper.hpp file1.cpp file2.cpp external.hpp
Do użycia biblioteki potrzeba nie tylko samego pliku archiwum, ale również plików nagłówkowych zawierających deklaracje tego, co siedzi w skompilowanej bibliotece.
Istotne jest, że linker nie włącza do programu domyślnie wszystkich obiektów z biblioteki. Włącza wyłącznie obiekty, które mają odniesienia w samym programie! Nieużywane funkcje i zmienne w zbudowanym pliku wykonalnym nie istnieją!
Kolejność linkowania #
Biblioteki mogą zależeć od innych bibliotek. Przy pracy z dużymi projektami składającymi się z wielu zależności istotne staje się zrozumienie jak działa linker.
Linker iteracyjnie analizuje pliki obiektowe i biblioteki w takiej kolejności, w jakiej zostały podane w linii zlecenia, np.:
g++ object1.o object2.o -llib1.a -llib2.a object3.o -llib3.a
Linker utrzymuje 2 tablice symboli:
- symbole zdefiniowane - takie, które już pojawiły się w przeanalizowanych elementach
- symbole poszukiwane - takie, do których są odniesienia w przeanalizowanych elementach
Obiekty i biblioteki są traktowane inaczej!
Linker napotykając na obiekt:
- dodaje obiekt do programu wyjściowego
- bezwarunkowo dodaje definiowane przez niego symbole do tablicy symboli zdefiniowanych, usuwając je z tablicy symboli poszukiwanych, jeżeli tam są. Jeżeli dany symbol już był zdefiniowany z błędem podwójnej definicji (one definition rule)!
- dodaje symbole potrzebne obiektowi do listy symboli poszukiwanych
Kiedy linker napotyka bibliotekę dzieje się coś ciekawszego. Linker przechodzi przez wszystkie obiekty w bibliotece. Dla każdego:
- dodaje obiekt do programu wyjściowego, tylko jeżeli obiekt zawiera definicję, która jest obecnie poszukiwana
- tylko jeśli obiekt został dodany to symbole mu potrzebne są dodawane do poszukiwanych
Następnie, jeżeli którykolwiek obiekt z biblioteki został włączony to biblioteka jest skanowana ponownie.
flowchart TB %% Top-level flow Start[Iterate over elements] --> Decision[Object or library?] Decision --> Object Decision --> Library Object --> MoreElements[More elements?] Library --> MoreElements %% %% Object subgraph subgraph Object direction TB A1[Add defined symbols] A2[Add required symbols] A1 --> A2 end %% Library subgraph subgraph Library direction TB B0[Scan library] B1[Process library object] B2[Does object resolve symbol?] B3[Add defined symbols] B4[Add required symbols] B5[More objects?] B6[Anything included?] B0 --> B1 B1 --> B2 B2 -->|Yes| B3 --> B4 B2 -->|No| B5 B4 --> B5 B5 -->|Yes| B1 B5 -->|No| B6 B6 -->|Yes| B0 B6 -->|No| Finish end MoreElements -->|Yes| Next[Next object/library] MoreElements -->|No| End[Finish]
Dzięki re-skanowaniu całej biblioteki nie ma znaczenia kolejność obiektów w bibliotece.
Ma natomiast ogromne znaczenie kolejność bibliotek/obiektów w linii zlecenia! Linker przechodzi przez listę jednokrotnie. Jeżeli element występujący dalej w linii potrzebuje wcześniej definiowanego symbolu, a ten nie został włączony to kończymy z błędem linkera.
Przykładowo mając program, który potrzebuje biblioteki lib1, a ta z kolei potrzebuje biblioteki lib2 poprawnym będzie:
g++ main.o -L. -l1 -l2 -o my.exe # ok
# g++ -L. -l1 -l2 main.o -o my.exe # undefined, l1 i l2 odrzucone
# g++ main.o -L. -l2 -l1 -o my.exe # undefined, l1 potrzebuje l2 później
Source: Makefile lib1.cpp lib1.hpp lib2.cpp lib2.hpp main.cpp
Zdarzają się projekty, w których występują zależności cykliczne pomiędzy bibliotekami statycznymi. Mamy do czynienia z taką sytuacją, jeżeli biblioteka A wykorzystuje symoble z biblioteki B i na odwrót. O ile nie jest to zalecana praktyka, to technicznie można sobie z taką sytuacją poradzić, linkując biblioteki wielokrotnie:
g++ main.o -L. -lA -lB -lA -lB -o my.exe
Więcej szczegółów w artykule.
Biblioteki dynamiczne #
Biblioteki statyczne są bezpośrednio włączane w plik wynikowy, co skutkuje jego powiększeniem. Biblioteki potrafią być ogromne (w GB!). Dołączanie ich osobno do każdego pliku wykonywalnego sumarycznie zwiększa zajętość dysku i pamięci komputera przy ich jednoczesnym wykonaniu.
Dlatego stworzono biblioteki dynamiczne (albo współdzielone).
Biblioteki dynamiczne są produkowane przez linker, tak samo jak pliki wykonywalne.
g++
robi to po użyciu przełącznika -shared
:
libexternal_library.so: file1.o file2.o
g++ -shared -o libexternal_library.so file1.o file2.o
file1.o: file1.cpp
g++ -c -fPIC file1.cpp -o file1.o
file2.o: file2.cpp
g++ -c -fPIC file2.cpp -o file2.o
Programy korzystające z biblioteki dynamicznej linkuje się bardzo podobnie do statycznych:
my.exe: main.o helper.o libexternal_library.so
g++ main.o helper.o -L. -lexternal_library -o my.exe
Source: Makefile external.hpp file1.cpp file2.cpp helper.cpp helper.hpp main.cpp
Tu natomiast program wyjściowy nie zawiera kodu z biblioteki. Do uruchomienia niezbędna jest sama biblioteka:
LD_LIBRARY_PATH=:$LD_LIBRARY_PATH ./my.exe
Biblioteka standardowa jest duża i zwykle kompilator linkuje ją dynamicznie.
Nasze programy uruchamiają się tylko dlatego, że gdzieś w systemie obecny jest plik .so
zawierający skompilowane funkcje z przestrzeni nazw std::
.
Moduły #
Ten rozdział jest w ramach ciekawostki, praktyka dla chętnych
C++20 wprowadza nowy sposób modularyzacji w odpowiedzi na wady podejścia opartego o pliki nagłówkowe:
- Tekstowe wklejanie kodu za pomocą
#include
niepotrzebnie powiększa jednostki translacji, nagłówki są analizowane wielokrotnie - Bariery kompilacji
#ifndef
są niewygodne, nieczytelne - Globalne zmienne i funkcje definiowane w różnych jednostkach translacji mogą ze sobą łatwo konfliktować
Odpowiedzią są moduły. Moduł musi jawnie eksportować publiczne symbole:
export module math;
export double add(double a, double b) {
return a + b;
}
export double subtract(double a, double b) {
return a - b;
}
double internal_multiply(double a, double b) {
return a * b;
}
Eksportowane symbole są prekompilowane do małych plików opisujących interfejs modułu:
math.pcm: math.cppm
clang++ -std=c++20 --precompile math.cppm -o math.pcm
math.o: math.pcm
clang++ -c math.pcm -o math.o
Konsumenci zamiast tekstowo wklejać nagłówki importują moduł:
import math;
#include <iostream>
int main() {
double a = 5.0, b = 3.0;
std::cout << add(a, b) << "\n";
std::cout << subtract(a, b) << "\n";
return 0;
}
Source: Makefile math.cppm main.cpp
Moduły mają obecnie słabą adopcję. Kompilatory wspierają je niepełnie.