Salta al contenuto
Note per Studenti C++ - introduzione, compilazione e linking

C++ - introduzione, compilazione e linking

In questa pagina 5

Questa nota apre la parte di C++ del corso: cosa si ottiene scrivendo un programma C++, il programma più piccolo possibile e il percorso che porta dal file di testo all'eseguibile. Il passaggio a più file sorgente e le librerie è in C++ - scope, namespace e librerielo scope è la regione di testo in cui un nome è valido, dal punto di dichiarazione alla fine dello scope; i tipi di scope sono globale (di file), di classe, locale (blocco o lista argomenti di una funzione), di statement (for) e di namespace; funzioni dentro funzioni sono illegali, le classi locali da evitare; i namespace raggruppano nomi senza creare un tipo e si accedono col nome qualificato namespace::membro oppure con using (la using directive va evitata negli header); un header file è un insieme di dichiarazioni che chi lo include può usare (<> per i file di sistema, "" per quelli del progetto), dev'essere protetto dalle include guard (#ifndef/#define/#endif) per non includere due volte le stesse definizioni; il linker unisce i file oggetto con le librerie, statiche (copiate in ogni eseguibile, self-contained) o dinamiche (una sola copia condivisa, .so su Linux, .dll su Windows, ricompilabili senza toccare gli eseguibili).C++ - scope, namespace e librerie →; per progetti grandi vedi C++ - buona interfaccia e progetti su più fileUna classe C++ ben progettata decide esplicitamente le sette operazioni essenziali (costruttore con argomenti, di default, di copia, assegnamento di copia, costruttore e assegnamento di spostamento, distruttore): se serve il distruttore perché si possiede una risorsa, servono quasi sempre anche copia e spostamento; un costruttore a un argomento definisce una conversione implicita, che si blocca con explicit; per capire quando i costruttori e il distruttore vengono chiamati si traccia una struct che stampa ogni chiamata. Un progetto su più file separa interfaccia (header .h con definizioni di classe e dichiarazioni, protetto da include guard) e implementazione (.cpp, compilati uno per uno, mai inclusi); al compilatore si passano solo i .cpp, il main sta in un file a sé; gli errori di compilazione (sintassi, tipi, dichiarazioni mancanti) vengono dal compilatore, quelli di linking (definizione mancante, due main, nessun main) dal linker.C++ - buona interfaccia e progetti su più file → e CMake - build di progetti C++Un progetto C++ non è fatto solo di sorgenti: bisogna dire quali file formano ogni eseguibile e ogni libreria (target), quali librerie linkare, dove stanno gli header e con quale standard compilare. CMake è un meta-sistema di build indipendente da compilatore e piattaforma: si descrive il progetto in un file di testo CMakeLists.txt (cmake_minimum_required, project, add_library, add_executable, target_link_libraries, include directories) e CMake genera i file del sistema di build nativo (Makefile, progetto di IDE). Lato utente si fa una configurazione (mkdir build, cd build, cmake ..) e poi la compilazione (make o cmake --build .) in una cartella di build separata dai sorgenti; le librerie di soli template (header-only) si dichiarano INTERFACE perché non producono codice precompilato.CMake - build di progetti C++ →. Per chi viene da Python, la differenza di fondo (compilato contro interpretato) è in Eseguire un programma PythonInterprete interattivo e script .py, struttura di un file Python, indentazione, commenti, istruzioni ed espressioni, errori di sintassi.Eseguire un programma Python → e Dal Python al CStruttura di un programma C, compilazione con gcc (preprocessore, compilatore, linker), main e valore di ritorno, dichiarazioni con tipo statico, printf di base; corrispondenze con Python.Dal Python al C →; la compilazione di un programma C fino al codice macchina è in Dal programma C all'eseguibile ARMCatena di traduzione compilatore, assemblatore, linker e loader; file oggetto, tabella dei simboli e rilocazione; collegamento statico e dinamico; organizzazione della memoria di un processo; chiamare funzioni assembly da C e viceversa, con esempio.Dal programma C all'eseguibile ARM →.

Perché C++

Definizione. C++ è un linguaggio compilato, staticamente tipizzato, standard (ISO) e multipiattaforma: lo stesso sorgente si compila su Windows, Linux e macOS.

Due proprietà lo rendono particolare:

livello cosa offre
basso livello controllo totale sulla macchina: indirizzi di memoria, puntatori, layout dei dati (C++ - puntatori, array e castun puntatore è una variabile che contiene un indirizzo di memoria e sa il tipo dell'oggetto puntato (T*); si ottiene l'indirizzo con & e si accede al dato con * (dereference, un lvalue), nullptr indica nessun oggetto e due puntatori a tipi diversi sono tipi diversi; void* è un puntatore a memoria grezza (accetta qualsiasi puntatore ma non si dereferenzia) e i cast (static_cast, reinterpret_cast, const_cast) servono per interfacciarsi con hardware e codice non modificabile; il puntatore, a differenza della reference, si può spostare, può essere nullptr e va dereferenziato, e assegnarlo copia l'indirizzo (shallow copy) mentre assegnare una reference copia l'oggetto; un array C è una sequenza contigua di elementi dello stesso tipo, con dimensione costante, senza controllo dei limiti, che passato a una funzione decade in un puntatore al primo elemento; l'aritmetica dei puntatori somma o sottrae interi spostando di n elementi (non di n byte) e la differenza fra puntatori è in elementi; gli errori classici sono nullptr, puntatori non inizializzati, accessi fuori dai limiti e puntatori a oggetti usciti dallo scope.C++ - puntatori, array e cast →, C++ - chiamate a funzione e layout della memoriauna chiamata a funzione crea un record di attivazione (RA) con una copia dei parametri e le variabili locali, impilato nello stack (LIFO) e tolto all'uscita; la dimensione dipende dalle variabili locali e il tempo per inserirlo è costante; il processo ha cinque regioni di memoria: text (istruzioni, spesso read-only), initialized data (globali e statiche inizializzate), uninitialized data o BSS (globali e statiche non inizializzate, azzerate dal sistema), stack (RA) e heap (allocazione dinamica con new e delete); la durata (storage duration) di una variabile è diversa dallo scope: le globali e le static sono statiche (esistono per tutto il programma, inizializzate una volta), le locali sono automatiche (create all'entrata nel blocco, distrutte in ordine inverso all'uscita); le globali sono inizializzate prima del main ma l'ordine fra file diversi non è definito.C++ - chiamate a funzione e layout della memoria →)
alto livello strumenti che nascondono i dettagli: classi, template, contenitori della libreria standard (vector, string, map)

Il corso non insegna "tutto il C++" ma la programmazione con C++ come strumento: i concetti (tipi, memoria, interfacce, generalità) valgono anche in altri linguaggi. Un concetto che in C++ si vede a nudo, come la gestione della memoria, in Java o Python è nascosto dal linguaggio: per questo capirlo qui serve anche altrove.

Il programma più piccolo: Hello, world

hello.cppcpp
#include <iostream>

int main()
{
    std::cout << "Hello, World!\n";  // stampa "Hello, World!"
    return 0;
}

Uscita:

Hello, World!

Riga per riga:

elemento significato
#include <iostream> direttiva del preprocessore: prima della compilazione, il testo dell'header file iostream viene copiato al posto della riga. Contiene le dichiarazioni che servono per usare cout e <<
int main() funzione speciale: è il punto di partenza di ogni eseguibile C++, e ne serve esattamente una. Restituisce un int al sistema operativo; main(void) e main() sono equivalenti in C++
std::cout lo stream di uscita standard (di solito lo schermo); il prefisso std:: indica che il nome sta nel namespace della libreria standard (C++ - scope, namespace e librerielo scope è la regione di testo in cui un nome è valido, dal punto di dichiarazione alla fine dello scope; i tipi di scope sono globale (di file), di classe, locale (blocco o lista argomenti di una funzione), di statement (for) e di namespace; funzioni dentro funzioni sono illegali, le classi locali da evitare; i namespace raggruppano nomi senza creare un tipo e si accedono col nome qualificato namespace::membro oppure con using (la using directive va evitata negli header); un header file è un insieme di dichiarazioni che chi lo include può usare (<> per i file di sistema, "" per quelli del progetto), dev'essere protetto dalle include guard (#ifndef/#define/#endif) per non includere due volte le stesse definizioni; il linker unisce i file oggetto con le librerie, statiche (copiate in ogni eseguibile, self-contained) o dinamiche (una sola copia condivisa, .so su Linux, .dll su Windows, ricompilabili senza toccare gli eseguibili).C++ - scope, namespace e librerie →)
<< operatore di inserimento in uno stream: manda il valore che sta a destra nello stream che sta a sinistra
"Hello, World!\n" stringa letterale (string literal); \n è il carattere di a capo
// ... commento fino a fine riga. Il codice è scritto per essere letto da persone oltre che dal compilatore: "Code is for reading"
return 0; codice di uscita: 0 significa "tutto bene". In main questa riga, se manca, viene aggiunta dal compilatore

Gli << si possono concatenare perché ogni << restituisce lo stream stesso: std::cout << "a" << 1 << '\n' stampa tre cose di fila. Il significato di << dipende dai tipi degli operandi (inserimento di una stringa, di un intero...): è un esempio di operatore sensibile al tipo (C++ - tipi, variabili, espressioni e dichiarazioniun tipo definisce contenuto, intervallo di valori e operazioni; un oggetto è una regione di memoria con un tipo, il valore è la configurazione di bit letta in base al tipo, una variabile è un oggetto con un nome; inizializzazione (dare il primo valore) e assegnamento (cambiare il valore) sono diversi; le conversioni verso un tipo più capiente sono sicure, quelle verso un tipo meno capiente (narrowing, double->int, int->char) non lo sono e il compilatore le segnala di rado, ma la notazione con graffe {} le vieta; un'espressione calcola un valore da operandi e operatori, un lvalue è ciò che può stare a sinistra di =, constexpr vuole un valore noto in compilazione mentre const solo un valore che non cambia; gli operatori con side effect (++, --, +=) non vanno mischiati con altri usi della stessa variabile nella stessa espressione perché l'ordine di valutazione non è definito; una dichiarazione introduce un nome e un tipo, una definizione fornisce anche l'entità (memoria per una variabile, corpo per una funzione).C++ - tipi, variabili, espressioni e dichiarazioni →).

Da sorgente a eseguibile

C++ è un linguaggio compilato: il codice sorgente, leggibile da noi, viene tradotto una volta per tutte in codice macchina, che il processore esegue direttamente. Il traduttore non corregge gli errori, li segnala: spesso in modo ovvio, a volte oscuro. Un errore a tempo di compilazione è molto più semplice da correggere di uno a tempo di esecuzione, perché il compilatore indica la riga.

Il processo ha tre fasi:

hello.cpp --[preprocessore]--> hello.i --[compilatore]--> hello.o --[linker]--> hello
 sorgente       espande #include           codice            file oggetto   + librerie     eseguibile
                e macro                    macchina
fase input output cosa fa
preprocessore file sorgente .cpp codice modificato (.i) esegue le direttive che iniziano con #: copia gli header al posto di #include, espande le macro, taglia i pezzi esclusi da #ifdef
compilatore codice modificato file oggetto .o controlla sintassi e tipi, traduce in codice macchina; il file oggetto non è ancora eseguibile perché i nomi definiti altrove sono lasciati in sospeso
linker uno o più file oggetto (e librerie) eseguibile collega ogni nome usato al punto in cui è definito: unisce i file oggetto tra loro e con le librerie necessarie

Il file sorgente è il formato leggibile, il file oggetto è codice macchina, l'eseguibile è codice macchina più le librerie necessarie all'esecuzione.

Da riga di comando

Il compilatore si invoca da riga di comando o da un IDE (con GCC su Linux è g++, su Windows MinGW o Visual Studio):

g++ -o my_exec my_source.cpp -lmy_lib
parte significato
-o my_exec nome dell'eseguibile (output)
my_source.cpp nome del sorgente (input)
-lmy_lib libreria da linkare (input): cerca libmy_lib.so o libmy_lib.a

Le opzioni più usate in laboratorio:

opzione effetto
-c si ferma al file oggetto (compila ma non linka)
-E si ferma al preprocessore: stampa il codice espanso
-Wall -Wextra attiva gli avvisi utili (variabili inutilizzate, conversioni sospette, ...)
-std=c++17 sceglie la versione dello standard (11, 14, 17, 20, ...)
-g aggiunge le informazioni per il debugger
-O2 ottimizza

Poi si esegue con ./my_exec su Linux. Compilare tante volte, a ogni pezzo di codice scritto, è la regola: gli errori da correggere sono pochi e vicini.

Chi segnala quale errore

Gli errori si dividono per fase, e riconoscere la fase dice dove cercare:

fase errore tipico esempio
preprocessore header non trovato #include "mio.h" ma il file non esiste
compilatore sintassi (; mancante), tipo (int dato a un vector), nome non dichiarato, conversione non valida std::string s = 5;
linker simbolo dichiarato, usato ma non definito undefined reference to f(int)

Un esempio di errore di linking: la funzione f è dichiarata (quindi il compilatore accetta la chiamata) ma non è definita in nessun file.

cpp
int f(int i);                    // dichiarazione: basta al compilatore
int main() { return f(3); }      // chiamata: nessun errore di compilazione

Compilando fino al file oggetto tutto va bene (g++ -c); il linker invece si ferma perché non trova il corpo di f e segnala undefined reference to 'f(int)' (con il linker di LLVM: undefined symbol: f(int)). La stessa cosa succede se la definizione sta in un file .cpp che non viene passato al compilatore, o in una libreria che non è stata indicata con -l.

Più file, un solo eseguibile

Appena un programma cresce, si divide in più file: l'interfaccia (le dichiarazioni) nell'header .h, l'implementazione (le definizioni) nel .cpp. Il preprocessore, il compilatore e il linker agiscono ora su più file:

main.cpp    --(preprocessore+compilatore)--> main.o     --\
my_func.h  (copiato in entrambi)                            +--(linker)--> eseguibile
my_func.cpp --(preprocessore+compilatore)--> my_func.o  --/

Ogni .cpp è una unità di traduzione (translation unit): viene compilata da sola, e vede degli altri file solo gli header che include. Esempio con tre file:

my_func.hcpp
#ifndef MY_FUNC_H
#define MY_FUNC_H

// Interfaccia: cosa si può usare
int f(int i);

#endif // MY_FUNC_H
my_func.cppcpp
#include "my_func.h"

// Implementazione: come funziona
int f(int i)
{
    return i + 2;
}
main.cppcpp
#include <iostream>
#include "my_func.h"

int main()
{
    int i = 0;
    i = f(i);
    std::cout << "f(0) = " << i << '\n';
    return 0;
}

Si compila e si linka con g++ -o prog main.cpp my_func.cpp (oppure, in due passi, g++ -c main.cpp, g++ -c my_func.cpp, g++ -o prog main.o my_func.o). Uscita:

f(0) = 2

Le righe #ifndef MY_FUNC_H ... #endif si chiamano include guard e servono perché un header incluso due volte non produca definizioni doppie: i dettagli, con gli errori che compaiono senza, sono in C++ - scope, namespace e librerielo scope è la regione di testo in cui un nome è valido, dal punto di dichiarazione alla fine dello scope; i tipi di scope sono globale (di file), di classe, locale (blocco o lista argomenti di una funzione), di statement (for) e di namespace; funzioni dentro funzioni sono illegali, le classi locali da evitare; i namespace raggruppano nomi senza creare un tipo e si accedono col nome qualificato namespace::membro oppure con using (la using directive va evitata negli header); un header file è un insieme di dichiarazioni che chi lo include può usare (<> per i file di sistema, "" per quelli del progetto), dev'essere protetto dalle include guard (#ifndef/#define/#endif) per non includere due volte le stesse definizioni; il linker unisce i file oggetto con le librerie, statiche (copiate in ogni eseguibile, self-contained) o dinamiche (una sola copia condivisa, .so su Linux, .dll su Windows, ricompilabili senza toccare gli eseguibili).C++ - scope, namespace e librerie →, insieme a librerie statiche e dinamiche.

Domande d'esame

1. Quali sono le fasi che trasformano un file sorgente in un eseguibile? Chi segnala un errore di sintassi e chi uno di undefined reference? Traccia. Preprocessore (espande #include e macro), compilatore (controlla sintassi e tipi, produce il file oggetto), linker (unisce file oggetto e librerie risolvendo i nomi). La sintassi è del compilatore; un simbolo dichiarato e usato ma mai definito (o con la libreria mancante) è del linker.

2. Un programma compila senza errori ma il linker si lamenta di undefined reference: da che cosa può dipendere? Traccia. Funzione dichiarata ma non definita; definizione in un .cpp non passato al compilatore; libreria non indicata con -l; firma diversa tra dichiarazione e definizione (due funzioni diverse in C++, perché i parametri fanno parte del nome).

3. Che cos'è main e che cosa significa return 0? Traccia. Punto di partenza di ogni eseguibile, unico; il valore restituito arriva al sistema operativo come codice di uscita, 0 = successo.

Versione ripasso

cpp
#include <iostream>
int main()
{
    std::cout << "Hello, World!
";   // uscita: Hello, World!
    return 0;
}
  • Esempio di undefined reference: int f(int i); int main(){ return f(3); } compila con -c ma il linker si ferma perché f non ha corpo; la stessa cosa se il .cpp che la definisce non è nella riga di g++ o se manca -l.
  • Traccia delle risposte d'esame: fasi = preprocessore (espande #include e macro), compilatore (sintassi e tipi →\to .o), linker (unisce .o e librerie); sintassi = compilatore; simbolo non definito = linker; main = punto di partenza unico, il valore restituito è il codice di uscita (0 = successo).
  • Sorgente / oggetto / eseguibile: sorgente = leggibile; oggetto = codice macchina con nomi ancora in sospeso; eseguibile = codice macchina + librerie.
  • Errori tipici: credere che il compilatore corregga gli errori; confondere errore di compilazione e di linking; dimenticare un .cpp nella riga di g++; non compilare spesso.

Esercizi su questo argomento

Teoria collegata