Come funziona C++: comprensione della compilazione
Pubblicato: 2022-03-11Il linguaggio di programmazione C++ di Bjarne Stroustrup ha un capitolo intitolato "A Tour of C++: The Basics"—Standard C++. Quel capitolo, in 2.2, menziona in mezza pagina il processo di compilazione e collegamento in C++. La compilazione e il collegamento sono due processi molto basilari che si verificano continuamente durante lo sviluppo del software C++, ma stranamente non sono ben compresi da molti sviluppatori C++.
Perché il codice sorgente C++ è suddiviso in file di intestazione e sorgente? Come viene vista ogni parte dal compilatore? In che modo ciò influisce sulla compilazione e sul collegamento? Ci sono molte altre domande come queste a cui potresti aver pensato ma che sei arrivato ad accettare come convenzione.
Sia che tu stia progettando un'applicazione C++, implementando nuove funzionalità per essa, cercando di risolvere i bug (soprattutto alcuni strani bug) o cercando di far funzionare insieme il codice C e C++, sapere come funzionano la compilazione e il collegamento ti farà risparmiare un sacco di tempo e rendere questi compiti molto più piacevoli. In questo articolo imparerai esattamente questo.
L'articolo spiegherà come funziona un compilatore C++ con alcuni dei costrutti del linguaggio di base, risponderà ad alcune domande comuni relative ai loro processi e ti aiuterà a aggirare alcuni errori correlati che gli sviluppatori spesso commettono nello sviluppo di C++.
Nota: questo articolo contiene alcuni esempi di codice sorgente che possono essere scaricati da https://bitbucket.org/danielmunoz/cpp-article
Gli esempi sono stati compilati in una macchina CentOS Linux:
$ uname -sr Linux 3.10.0-327.36.3.el7.x86_64
Utilizzando la versione g++:
$ g++ --version g++ (GCC) 4.8.5 20150623 (Red Hat 4.8.5-11)
I file sorgente forniti dovrebbero essere portabili su altri sistemi operativi, sebbene i Makefile che li accompagnano per il processo di compilazione automatizzato dovrebbero essere portabili solo su sistemi simili a Unix.
La pipeline di compilazione: preelaborazione, compilazione e collegamento
Ogni file sorgente C++ deve essere compilato in un file oggetto. I file oggetto risultanti dalla compilazione di più file di origine vengono quindi collegati in un eseguibile, una libreria condivisa o una libreria statica (l'ultimo di questi è solo un archivio di file oggetto). I file di origine C++ hanno generalmente i suffissi di estensione .cpp, .cxx o .cc.
Un file sorgente C++ può includere altri file, noti come file di intestazione, con la direttiva #include
. I file di intestazione hanno estensioni come .h, .hpp o .hxx o non hanno alcuna estensione come nella libreria standard C++ e nei file di intestazione di altre librerie (come Qt). L'estensione non ha importanza per il preprocessore C++, che sostituirà letteralmente la riga contenente la direttiva #include
con l'intero contenuto del file incluso.
Il primo passaggio che il compilatore eseguirà su un file di origine è eseguire il preprocessore su di esso. Solo i file sorgente vengono passati al compilatore (per preelaborarlo e compilarlo). I file di intestazione non vengono passati al compilatore. Al contrario, sono inclusi dai file di origine.
Ogni file di intestazione può essere aperto più volte durante la fase di preelaborazione di tutti i file di origine, a seconda di quanti file di origine li includono o di quanti altri file di intestazione inclusi dai file di origine li includono (possono esserci molti livelli di indirizzamento) . I file sorgente, invece, vengono aperti una sola volta dal compilatore (e dal preprocessore), quando gli vengono passati.
Per ogni file sorgente C++, il preprocessore creerà un'unità di traduzione inserendo il contenuto in essa quando trova una direttiva #include e allo stesso tempo rimuoverà il codice dal file sorgente e dalle intestazioni quando trova la compilazione condizionale blocchi la cui direttiva restituisce false
. Eseguirà anche altre attività come le sostituzioni di macro.
Una volta che il preprocessore ha finito di creare quella (a volte enorme) unità di traduzione, il compilatore avvia la fase di compilazione e produce il file oggetto.
Per ottenere quell'unità di traduzione (il codice sorgente preelaborato), l'opzione -E
può essere passata al compilatore g++, insieme all'opzione -o
per specificare il nome desiderato del file sorgente preelaborato.
Nella directory cpp-article/hello-world
, è presente un file di esempio "hello-world.cpp":
#include <iostream> int main(int argc, char* argv[]) { std::cout << "Hello world" << std::endl; return 0; }
Crea il file preelaborato da:
$ g++ -E hello-world.cpp -o hello-world.ii
E guarda il numero di righe:
$ wc -l hello-world.ii 17558 hello-world.ii
Ha 17.588 righe nella mia macchina. Puoi anche eseguire make
su quella directory e farà questi passaggi per te.
Possiamo vedere che il compilatore deve compilare un file molto più grande del semplice file sorgente che vediamo. Ciò è dovuto alle intestazioni incluse. E nel nostro esempio, abbiamo incluso solo un'intestazione. L'unità di traduzione diventa sempre più grande man mano che continuiamo a includere le intestazioni.
Questo processo di preelaborazione e compilazione è simile per il linguaggio C. Segue le regole C per la compilazione e il modo in cui include i file di intestazione e produce il codice oggetto è quasi lo stesso.
Come i file di origine importano ed esportano i simboli
Vediamo ora i file nella directory cpp-article/symbols/c-vs-cpp-names
.
C'è un semplice file sorgente C (non C++) chiamato sum.c che esporta due funzioni, una per aggiungere due numeri interi e una per aggiungere due float:
int sumI(int a, int b) { return a + b; } float sumF(float a, float b) { return a + b; }
Compilalo (o esegui make
e tutti i passaggi per creare le due app di esempio da eseguire) per creare il file oggetto sum.o:
$ gcc -c sum.c
Ora guarda i simboli esportati e importati da questo file oggetto:
$ nm sum.o 0000000000000014 T sumF 0000000000000000 T sumI
Nessun simbolo viene importato e vengono esportati due simboli: sumF
e sumI
. Questi simboli vengono esportati come parte del segmento .text (T), quindi sono nomi di funzioni, codice eseguibile.
Se altri file di origine (sia C che C++) vogliono chiamare quelle funzioni, devono dichiararle prima di chiamare.
Il modo standard per farlo è creare un file di intestazione che li dichiari e li includa in qualsiasi file sorgente vogliamo chiamarli. L'intestazione può avere qualsiasi nome ed estensione. Ho scelto sum.h
:
#ifdef __cplusplus extern "C" { #endif int sumI(int a, int b); float sumF(float a, float b); #ifdef __cplusplus } // end extern "C" #endif
Cosa sono quei blocchi di compilazione condizionale ifdef
/ endif
? Se includo questa intestazione da un file sorgente C, voglio che diventi:
int sumI(int a, int b); float sumF(float a, float b);
Ma se li includo da un file sorgente C++, voglio che diventi:
extern "C" { int sumI(int a, int b); float sumF(float a, float b); } // end extern "C"
Il linguaggio C non sa nulla della direttiva extern "C"
, ma C++ sì e necessita di questa direttiva applicata alle dichiarazioni di funzione C. Questo perché C++ altera i nomi di funzioni (e metodi) perché supporta l'overloading di funzioni/metodi, mentre C no.
Questo può essere visto nel file sorgente C++ chiamato print.cpp:
#include <iostream> // std::cout, std::endl #include "sum.h" // sumI, sumF void printSum(int a, int b) { std::cout << a << " + " << b << " = " << sumI(a, b) << std::endl; } void printSum(float a, float b) { std::cout << a << " + " << b << " = " << sumF(a, b) << std::endl; } extern "C" void printSumInt(int a, int b) { printSum(a, b); } extern "C" void printSumFloat(float a, float b) { printSum(a, b); }
Esistono due funzioni con lo stesso nome ( printSum
) che differiscono solo per il tipo di parametri: int
o float
. L'overloading delle funzioni è una funzionalità di C++ che non è presente in C. Per implementare questa funzionalità e differenziare quelle funzioni, C++ altera il nome della funzione, come possiamo vedere nel nome del simbolo esportato (sceglierò solo ciò che è rilevante dall'output di nm) :
$ g++ -c print.cpp $ nm print.o 0000000000000132 T printSumFloat 0000000000000113 T printSumInt U sumF U sumI 0000000000000074 T _Z8printSumff 0000000000000000 T _Z8printSumii U _ZSt4cout
Tali funzioni vengono esportate (nel mio sistema) come _Z8printSumff
per la versione float e _Z8printSumii
per la versione int. Ogni nome di funzione in C++ viene alterato a meno che non venga dichiarato come extern "C"
. Ci sono due funzioni che sono state dichiarate con il collegamento C in print.cpp
: printSumInt
e printSumFloat
.
Pertanto, non possono essere sovraccaricati, altrimenti i loro nomi esportati sarebbero gli stessi poiché non vengono alterati. Ho dovuto differenziarli l'uno dall'altro aggiungendo un Int o un Float alla fine dei loro nomi.
Dal momento che non sono maciullati possono essere richiamati dal codice C, come vedremo presto.
Per vedere i nomi alterati come li vedremmo nel codice sorgente C++, possiamo usare l'opzione -C
(demanle) nel comando nm
. Ancora una volta, copierò solo la stessa parte rilevante dell'output:
$ nm -C print.o 0000000000000132 T printSumFloat 0000000000000113 T printSumInt U sumF U sumI 0000000000000074 T printSum(float, float) 0000000000000000 T printSum(int, int) U std::cout
Con questa opzione, invece di _Z8printSumff
vediamo printSum(float, float)
e invece di _ZSt4cout
vediamo std::cout, che sono nomi più adatti all'uomo.
Vediamo anche che il nostro codice C++ sta chiamando il codice C: print.cpp
sta chiamando sumI
e sumF
, che sono funzioni C dichiarate come dotate di collegamento C in sum.h
. Questo può essere visto nell'output nm di print.o sopra, che informa di alcuni simboli (U) non definiti: sumF
, sumI
e std::cout
. Questi simboli non definiti dovrebbero essere forniti in uno dei file oggetto (o librerie) che saranno collegati insieme a questo file oggetto di output nella fase di collegamento.
Finora abbiamo appena compilato il codice sorgente in codice oggetto, non abbiamo ancora collegato. Se non colleghiamo il file oggetto che contiene le definizioni per quei simboli importati insieme a questo file oggetto, il linker si fermerà con un errore "simbolo mancante".
Si noti inoltre che poiché print.cpp
è un file sorgente C++, compilato con un compilatore C++ (g++), tutto il codice in esso contenuto viene compilato come codice C++. Le funzioni con collegamento C come printSumInt
e printSumFloat
sono anche funzioni C++ che possono utilizzare le funzionalità C++. Solo i nomi dei simboli sono compatibili con C, ma il codice è C++, come si può notare dal fatto che entrambe le funzioni chiamano una funzione sovraccaricata ( printSum
), cosa che non potrebbe accadere se printSumInt
o printSumFloat
fossero compilati in C.
Vediamo ora print.hpp
, un file di intestazione che può essere incluso sia da file sorgente C che C++, che consentirà di printSumInt
e printSumFloat
sia da C che da C++, e printSum
da chiamare da C++:
#ifdef __cplusplus void printSum(int a, int b); void printSum(float a, float b); extern "C" { #endif void printSumInt(int a, int b); void printSumFloat(float a, float b); #ifdef __cplusplus } // end extern "C" #endif
Se lo stiamo includendo da un file sorgente C, vogliamo solo vedere:
void printSumInt(int a, int b); void printSumFloat(float a, float b);
printSum
non può essere visto dal codice C poiché il suo nome è alterato, quindi non abbiamo un modo (standard e portatile) per dichiararlo per il codice C. Sì, posso dichiararli come:
void _Z8printSumii(int a, int b); void _Z8printSumff(float a, float b);
E il linker non si lamenterà poiché è il nome esatto che il mio compilatore attualmente installato ha inventato per esso, ma non so se funzionerà per il tuo linker (se il tuo compilatore genera un nome alterato diverso), o anche per il prossima versione del mio linker. Non so nemmeno se la chiamata funzionerà come previsto a causa dell'esistenza di diverse convenzioni di chiamata (come vengono passati i parametri e restituiti i valori) che sono specifiche del compilatore e potrebbero essere diverse per le chiamate C e C++ (soprattutto per le funzioni C++ che sono funzioni membro e ricevono il puntatore this come parametro).
Il tuo compilatore può potenzialmente utilizzare una convenzione di chiamata per le normali funzioni C++ e una diversa se sono dichiarate come dotate di collegamento "C" esterno. Quindi, ingannare il compilatore dicendo che una funzione utilizza la convenzione di chiamata C mentre in realtà utilizza C++ perché può fornire risultati imprevisti se le convenzioni utilizzate per ciascuna sono diverse nella toolchain di compilazione.
Esistono modi standard per combinare codice C e C++ e un modo standard per chiamare funzioni C++ sovraccaricate da C consiste nel racchiuderle in funzioni con collegamento C come abbiamo fatto avvolgendo printSum
con printSumInt
e printSumFloat
.
Se includiamo print.hpp
da un file sorgente C++, la macro del preprocessore __cplusplus
verrà definita e il file sarà visto come:
void printSum(int a, int b); void printSum(float a, float b); extern "C" { void printSumInt(int a, int b); void printSumFloat(float a, float b); } // end extern "C"
Ciò consentirà al codice C++ di chiamare la funzione sovraccarica printSum o i relativi wrapper printSumInt
e printSumFloat
.
Ora creiamo un file sorgente C contenente la funzione principale, che è il punto di ingresso per un programma. Questa funzione principale C chiamerà printSumInt
e printSumFloat
, ovvero chiamerà entrambe le funzioni C++ con collegamento C. Ricorda, quelle sono funzioni C++ (i loro corpi funzione eseguono codice C++) che solo non hanno nomi alterati in C++. Il file si chiama c-main.c
:
#include "print.hpp" int main(int argc, char* argv[]) { printSumInt(1, 2); printSumFloat(1.5f, 2.5f); return 0; }
Compilalo per generare il file oggetto:
$ gcc -c c-main.c
E guarda i simboli importati/esportati:
$ nm c-main.o 0000000000000000 T main U printSumFloat U printSumInt
Esporta main e importa printSumFloat
e printSumInt
, come previsto.
Per collegare tutto insieme in un file eseguibile, dobbiamo usare il linker C++ (g++), poiché almeno un file che collegheremo, print.o
, è stato compilato in C++:
$ g++ -o c-app sum.o print.o c-main.o
L'esecuzione produce il risultato atteso:
$ ./c-app 1 + 2 = 3 1.5 + 2.5 = 4
Ora proviamo con un file principale C++, chiamato cpp-main.cpp
:
#include "print.hpp" int main(int argc, char* argv[]) { printSum(1, 2); printSum(1.5f, 2.5f); printSumInt(3, 4); printSumFloat(3.5f, 4.5f); return 0; }
Compila e visualizza i simboli importati/esportati del file oggetto cpp-main.o
:
$ g++ -c cpp-main.cpp $ nm -C cpp-main.o 0000000000000000 T main U printSumFloat U printSumInt U printSum(float, float) U printSum(int, int)
Esporta main e importa il collegamento C printSumFloat
e printSumInt
, ed entrambe le versioni alterate di printSum
.
Ti starai chiedendo perché il simbolo principale non viene esportato come simbolo alterato come main(int, char**)
da questa fonte C++ poiché è un file sorgente C++ e non è definito come extern "C"
. Bene, main
è una funzione definita dall'implementazione speciale e la mia implementazione sembra aver scelto di utilizzare il collegamento C per questo, indipendentemente dal fatto che sia definito in un file sorgente C o C++.
Il collegamento e l'esecuzione del programma danno il risultato atteso:
$ g++ -o cpp-app sum.o print.o cpp-main.o $ ./cpp-app 1 + 2 = 3 1.5 + 2.5 = 4 3 + 4 = 7 3.5 + 4.5 = 8
Come funzionano le guardie di testata
Finora, sono stato attento a non includere le mie intestazioni due volte, direttamente o indirettamente, dallo stesso file sorgente. Ma poiché un'intestazione può includere altre intestazioni, la stessa intestazione può essere inclusa indirettamente più volte. E poiché il contenuto dell'intestazione è appena inserito nel punto in cui è stato incluso, è facile terminare con dichiarazioni duplicate.
Vedi i file di esempio in cpp-article/header-guards
.
// unguarded.hpp class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; // guarded.hpp: #ifndef __GUARDED_HPP #define __GUARDED_HPP class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; #endif // __GUARDED_HPP
La differenza è che, in guarded.hpp, circondiamo l'intera intestazione in un condizionale che verrà incluso solo se la macro del preprocessore __GUARDED_HPP
non è definita. La prima volta che il preprocessore include questo file, non verrà definito. Ma, poiché la macro è definita all'interno di quel codice protetto, la prossima volta che viene inclusa (dallo stesso file sorgente, direttamente o indirettamente), il preprocessore vedrà le linee tra #ifndef e #endif e scarterà tutto il codice tra loro.
Nota che questo processo avviene per ogni file sorgente che compiliamo. Significa che questo file di intestazione può essere incluso una volta e solo una volta per ogni file di origine. Il fatto che sia stato incluso da un file di origine non impedisce che venga incluso da un file di origine diverso quando il file di origine viene compilato. Eviterà semplicemente che venga incluso più di una volta dallo stesso file di origine.
Il file di esempio main-guarded.cpp
include guarded.hpp
due volte:
#include "guarded.hpp" #include "guarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
Ma l'output preelaborato mostra solo una definizione di classe A
:
$ g++ -E main-guarded.cpp # 1 "main-guarded.cpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "main-guarded.cpp" # 1 "guarded.hpp" 1 class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; # 2 "main-guarded.cpp" 2 int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
Pertanto, può essere compilato senza problemi:
$ g++ -o guarded main-guarded.cpp
Ma il file main-unguarded.cpp
include unguarded.hpp
due volte:
#include "unguarded.hpp" #include "unguarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
E l'output preelaborato mostra due definizioni di classe A:
$ g++ -E main-unguarded.cpp # 1 "main-unguarded.cpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "main-unguarded.cpp" # 1 "unguarded.hpp" 1 class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; # 2 "main-unguarded.cpp" 2 # 1 "unguarded.hpp" 1 class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; # 3 "main-unguarded.cpp" 2 int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
Ciò causerà problemi durante la compilazione:

$ g++ -o unguarded main-unguarded.cpp
Nel file incluso da main-unguarded.cpp:2:0
:
unguarded.hpp:1:7: error: redefinition of 'class A' class A { ^ In file included from main-unguarded.cpp:1:0: unguarded.hpp:1:7: error: previous definition of 'class A' class A { ^
Per motivi di brevità, non userò intestazioni protette in questo articolo se non è necessario poiché la maggior parte sono brevi esempi. Ma proteggi sempre i tuoi file di intestazione. Non i tuoi file di origine, che non verranno inclusi da nessuna parte. Solo file di intestazione.
Passa per valore e costanza dei parametri
Guarda il file by-value.cpp
in cpp-article/symbols/pass-by
:
#include <vector> #include <numeric> #include <iostream> // std::vector, std::accumulate, std::cout, std::endl using namespace std; int sum(int a, const int b) { cout << "sum(int, const int)" << endl; const int c = a + b; ++a; // Possible, not const // ++b; // Not possible, this would result in a compilation error return c; } float sum(const float a, float b) { cout << "sum(const float, float)" << endl; return a + b; } int sum(vector<int> v) { cout << "sum(vector<int>)" << endl; return accumulate(v.begin(), v.end(), 0); } float sum(const vector<float> v) { cout << "sum(const vector<float>)" << endl; return accumulate(v.begin(), v.end(), 0.0f); }
Poiché utilizzo la direttiva using namespace std
, non devo qualificare i nomi dei simboli (funzioni o classi) all'interno dello spazio dei nomi std nel resto dell'unità di traduzione, che nel mio caso è il resto del file sorgente. Se questo fosse un file di intestazione, non avrei dovuto inserire questa direttiva perché un file di intestazione dovrebbe essere incluso da più file di origine; questa direttiva porterebbe all'ambito globale di ogni file sorgente l'intero spazio dei nomi std dal punto in cui includono la mia intestazione.
Anche le intestazioni incluse dopo la mia in quei file avranno quei simboli nell'ambito. Ciò può produrre conflitti di nomi poiché non si aspettavano che ciò accadesse. Pertanto, non utilizzare questa direttiva nelle intestazioni. Usalo solo nei file di origine se lo desideri e solo dopo aver incluso tutte le intestazioni.
Nota come alcuni parametri sono const. Ciò significa che non possono essere modificati nel corpo della funzione se proviamo a farlo. Darebbe un errore di compilazione. Si noti inoltre che tutti i parametri in questo file di origine vengono passati per valore, non per riferimento (&) o per puntatore (*). Ciò significa che il chiamante ne farà una copia e lo passerà alla funzione. Quindi, non importa per il chiamante se sono const o meno, perché se li modifichiamo nel corpo della funzione modificheremo solo la copia, non il valore originale che il chiamante ha passato alla funzione.
Poiché la costanza di un parametro passato per valore (copy) non ha importanza per il chiamante, non viene alterato nella firma della funzione, come si può vedere dopo aver compilato e ispezionato il codice oggetto (solo l'output pertinente):
$ g++ -c by-value.cpp $ nm -C by-value.o 000000000000001e T sum(float, float) 0000000000000000 T sum(int, int) 0000000000000087 T sum(std::vector<float, std::allocator<float> >) 0000000000000048 T sum(std::vector<int, std::allocator<int> >)
Le firme non esprimono se i parametri copiati sono const o meno nei corpi della funzione. Non importa. Era importante solo per la definizione della funzione, per mostrare a colpo d'occhio al lettore del corpo della funzione se quei valori cambieranno mai. Nell'esempio, solo la metà dei parametri è dichiarata come const, quindi possiamo vedere il contrasto, ma se vogliamo essere corretti const dovrebbero essere stati tutti dichiarati così poiché nessuno di essi viene modificato nel corpo della funzione (e non dovrebbe).
Dal momento che non importa per la dichiarazione della funzione che è ciò che vede il chiamante, possiamo creare l'intestazione by-value.hpp
questo modo:
#include <vector> int sum(int a, int b); float sum(float a, float b); int sum(std::vector<int> v); int sum(std::vector<float> v);
È consentito aggiungere i qualificatori const qui (puoi anche qualificarti come variabili const che non sono const nella definizione e funzionerà), ma questo non è necessario e renderà solo le dichiarazioni inutilmente dettagliate.
Passa per riferimento
Vediamo by-reference.cpp
:
#include <vector> #include <iostream> #include <numeric> using namespace std; int sum(const int& a, int& b) { cout << "sum(const int&, int&)" << endl; const int c = a + b; ++b; // Will modify caller variable // ++a; // Not allowed, but would also modify caller variable return c; } float sum(float& a, const float& b) { cout << "sum(float&, const float&)" << endl; return a + b; } int sum(const std::vector<int>& v) { cout << "sum(const std::vector<int>&)" << endl; return accumulate(v.begin(), v.end(), 0); } float sum(const std::vector<float>& v) { cout << "sum(const std::vector<float>&)" << endl; return accumulate(v.begin(), v.end(), 0.0f); }
La costanza quando si passa per riferimento è importante per il chiamante, perché dirà al chiamante se il suo argomento verrà modificato o meno dal chiamato. Pertanto, i simboli vengono esportati con la loro consistenza:
$ g++ -c by-reference.cpp $ nm -C by-reference.o 0000000000000051 T sum(float&, float const&) 0000000000000000 T sum(int const&, int&) 00000000000000fe T sum(std::vector<float, std::allocator<float> > const&) 00000000000000a3 T sum(std::vector<int, std::allocator<int> > const&)
Ciò dovrebbe riflettersi anche nell'intestazione che utilizzerà i chiamanti:
#include <vector> int sum(const int&, int&); float sum(float&, const float&); int sum(const std::vector<int>&); float sum(const std::vector<float>&);
Nota che non ho scritto il nome delle variabili nelle dichiarazioni (nell'intestazione) come avevo fatto finora. Anche questo è legale, per questo esempio e per i precedenti. I nomi delle variabili non sono richiesti nella dichiarazione, poiché il chiamante non ha bisogno di sapere come vuoi dare un nome alla tua variabile. Ma i nomi dei parametri sono generalmente desiderabili nelle dichiarazioni in modo che l'utente possa sapere a colpo d'occhio cosa significano ciascun parametro e quindi cosa inviare nella chiamata.
Sorprendentemente, i nomi delle variabili non sono nemmeno necessari nella definizione di una funzione. Sono necessari solo se si utilizza effettivamente il parametro nella funzione. Ma se non lo usi mai puoi lasciare il parametro con il tipo ma senza il nome. Perché una funzione dovrebbe dichiarare un parametro che non userebbe mai? A volte le funzioni (o metodi) sono solo parte di un'interfaccia, come un'interfaccia di callback, che definisce determinati parametri che vengono passati all'osservatore. L'osservatore deve creare un callback con tutti i parametri specificati dall'interfaccia poiché verranno tutti inviati dal chiamante. Ma l'osservatore potrebbe non essere interessato a tutti loro, quindi invece di ricevere un avviso dal compilatore su un "parametro inutilizzato", la definizione della funzione può semplicemente lasciarla senza un nome.
Passa da Puntatore
// by-pointer.cpp: #include <iostream> #include <vector> #include <numeric> using namespace std; int sum(int const * a, int const * const b) { cout << "sum(int const *, int const * const)" << endl; const int c = *a+ *b; // *a = 4; // Can't change. The value pointed to is const. // *b = 4; // Can't change. The value pointed to is const. a = b; // I can make a point to another const int // b = a; // Can't change where b points because the pointer itself is const. return c; } float sum(float * const a, float * b) { cout << "sum(int const * const, float const *)" << endl; return *a + *b; } int sum(const std::vector<int>* v) { cout << "sum(std::vector<int> const *)" << endl; // v->clear(); // I can't modify the const object pointed by v const int c = accumulate(v->begin(), v->end(), 0); v = NULL; // I can make v point to somewhere else return c; } float sum(const std::vector<float> * const v) { cout << "sum(std::vector<float> const * const)" << endl; // v->clear(); // I can't modify the const object pointed by v // v = NULL; // I can't modify where the pointer points to return accumulate(v->begin(), v->end(), 0.0f); }
Per dichiarare un puntatore a un elemento const (int nell'esempio) puoi dichiarare il tipo come uno dei seguenti:
int const * const int *
Se vuoi che anche il puntatore stesso sia const, ovvero che il puntatore non possa essere modificato per puntare a qualcos'altro, aggiungi un const dopo la stella:
int const * const const int * const
Se vuoi che il puntatore stesso sia const, ma non l'elemento puntato da esso:
int * const
Confronta le firme delle funzioni con l'ispezione demangled del file oggetto:
$ g++ -c by-pointer.cpp $ nm -C by-pointer.o 000000000000004a T sum(float*, float*) 0000000000000000 T sum(int const*, int const*) 0000000000000105 T sum(std::vector<float, std::allocator<float> > const*) 000000000000009c T sum(std::vector<int, std::allocator<int> > const*)
Come puoi vedere, lo strumento nm
usa la prima notazione (const dopo il tipo). Inoltre, si noti che l'unica constness che viene esportata e che conta per il chiamante è se la funzione modificherà o meno l'elemento puntato dal puntatore. La costanza del puntatore stesso è irrilevante per il chiamante poiché il puntatore stesso viene sempre passato come copia. La funzione può solo creare la propria copia del puntatore per puntare da qualche altra parte, il che è irrilevante per il chiamante.
Quindi, un file di intestazione può essere creato come:
#include <vector> int sum(int const* a, int const* b); float sum(float* a, float* b); int sum(std::vector<int>* const); float sum(std::vector<float>* const);
Passare per puntatore è come passare per riferimento. Una differenza è che quando si passa per riferimento ci si aspetta e si presume che il chiamante abbia passato un riferimento a un elemento valido, che non punta a NULL o ad un altro indirizzo non valido, mentre un puntatore potrebbe puntare a NULL, ad esempio. I puntatori possono essere usati al posto dei riferimenti quando il passaggio di NULL ha un significato speciale.
Poiché i valori C++11 possono essere passati anche con la semantica di spostamento. Questo argomento non verrà trattato in questo articolo ma può essere studiato in altri articoli come il passaggio di argomenti in C++.
Un altro argomento correlato che non verrà trattato qui è come chiamare tutte quelle funzioni. Se tutte queste intestazioni sono incluse da un file di origine ma non vengono chiamate, la compilazione e il collegamento avranno esito positivo. Ma se vuoi chiamare tutte le funzioni, ci saranno degli errori perché alcune chiamate saranno ambigue. Il compilatore potrà scegliere più di una versione di sum per determinati argomenti, specialmente quando sceglie se passare per copy o per riferimento (o const reference). Tale analisi non rientra nell'ambito di questo articolo.
Compilazione con bandiere diverse
Vediamo, ora, una situazione di vita reale relativa a questo argomento in cui possono presentarsi bug difficili da trovare.
Vai alla directory cpp-article/diff-flags
e guarda Counters.hpp
:
class Counters { public: Counters() : #ifndef NDEBUG // Enabled in debug builds m_debugAllCounters(0), #endif m_counter1(0), m_counter2(0) { } #ifndef NDEBUG // Enabled in debug build #endif void inc1() { #ifndef NDEBUG // Enabled in debug build ++m_debugAllCounters; #endif ++m_counter1; } void inc2() { #ifndef NDEBUG // Enabled in debug build ++m_debugAllCounters; #endif ++m_counter2; } #ifndef NDEBUG // Enabled in debug build int getDebugAllCounters() { return m_debugAllCounters; } #endif int get1() const { return m_counter1; } int get2() const { return m_counter2; } private: #ifndef NDEBUG // Enabled in debug builds int m_debugAllCounters; #endif int m_counter1; int m_counter2; };
Questa classe ha due contatori, che iniziano come zero e possono essere incrementati o letti. Per le build di debug, che è il modo in cui chiamerò le build in cui la macro NDEBUG
non è definita, aggiungo anche un terzo contatore, che verrà incrementato ogni volta che viene incrementato uno qualsiasi degli altri due contatori. Sarà una specie di aiuto al debug per questa classe. Molte classi di librerie di terze parti o persino intestazioni C++ integrate (a seconda del compilatore) utilizzano trucchi come questo per consentire diversi livelli di debug. Ciò consente alle build di debug di rilevare gli iteratori che escono dall'intervallo e altre cose interessanti a cui il creatore di librerie potrebbe pensare. Chiamerò build di rilascio "build in cui è definita la macro NDEBUG
".
Per le build di rilascio, l'intestazione precompilata è simile (io uso grep
per rimuovere le righe vuote):
$ g++ -E -DNDEBUG Counters.hpp | grep -v -e '^$' # 1 "Counters.hpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "Counters.hpp" class Counters { public: Counters() : m_counter1(0), m_counter2(0) { } void inc1() { ++m_counter1; } void inc2() { ++m_counter2; } int get1() const { return m_counter1; } int get2() const { return m_counter2; } private: int m_counter1; int m_counter2; };
Mentre per le build di debug, sarà simile a:
$ g++ -E Counters.hpp | grep -v -e '^$' # 1 "Counters.hpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "Counters.hpp" class Counters { public: Counters() : m_debugAllCounters(0), m_counter1(0), m_counter2(0) { } void inc1() { ++m_debugAllCounters; ++m_counter1; } void inc2() { ++m_debugAllCounters; ++m_counter2; } int getDebugAllCounters() { return m_debugAllCounters; } int get1() const { return m_counter1; } int get2() const { return m_counter2; } private: int m_debugAllCounters; int m_counter1; int m_counter2; };
C'è un altro contatore nelle build di debug, come ho spiegato in precedenza.
Ho anche creato alcuni file di supporto.
// increment1.hpp: // Forward declaration so I don't have to include the entire header here class Counters; int increment1(Counters&); // increment1.cpp: #include "Counters.hpp" void increment1(Counters& c) { c.inc1(); }
// increment2.hpp: // Forward declaration so I don't have to include the entire header here class Counters; int increment2(Counters&); // increment2.cpp: #include "Counters.hpp" void increment2(Counters& c) { c.inc2(); }
// main.cpp: #include <iostream> #include "Counters.hpp" #include "increment1.hpp" #include "increment2.hpp" using namespace std; int main(int argc, char* argv[]) { Counters c; increment1(c); // 3 times increment1(c); increment1(c); increment2(c); // 4 times increment2(c); increment2(c); increment2(c); cout << "c.get1(): " << c.get1() << endl; // Should be 3 cout << "c.get2(): " << c.get2() << endl; // Should be 4 #ifndef NDEBUG // For debug builds cout << "c.getDebugAllCounters(): " << c.getDebugAllCounters() << endl; // Should be 3 + 4 = 7 #endif return 0; }
E un Makefile
che può personalizzare i flag del compilatore solo per increment2.cpp
:
all: main.o increment1.o increment2.o g++ -o diff-flags main.o increment1.o increment2.o main.o: main.cpp increment1.hpp increment2.hpp Counters.hpp g++ -c -O2 main.cpp increment1.o: increment1.cpp Counters.hpp g++ -c $(CFLAGS) -O2 increment1.cpp increment2.o: increment2.cpp Counters.hpp g++ -c -O2 increment2.cpp clean: rm -f *.o diff-flags
Quindi, compiliamo tutto in modalità debug, senza definire NDEBUG
:
$ CFLAGS='' make g++ -c -O2 main.cpp g++ -c -O2 increment1.cpp g++ -c -O2 increment2.cpp g++ -o diff-flags main.o increment1.o increment2.o
Ora esegui:
$ ./diff-flags c.get1(): 3 c.get2(): 4 c.getDebugAllCounters(): 7
L'output è proprio come previsto. Ora compiliamo solo uno dei file con NDEBUG
definito, che sarebbe la modalità di rilascio, e vediamo cosa succede:
$ make clean rm -f *.o diff-flags $ CFLAGS='-DNDEBUG' make g++ -c -O2 main.cpp g++ -c -DNDEBUG -O2 increment1.cpp g++ -c -O2 increment2.cpp g++ -o diff-flags main.o increment1.o increment2.o $ ./diff-flags c.get1(): 0 c.get2(): 4 c.getDebugAllCounters(): 7
L'output non è come previsto. increment1
function saw a release version of the Counters class, in which there are only two int member fields. So, it incremented the first field, thinking that it was m_counter1
, and didn't increment anything else since it knows nothing about the m_debugAllCounters
field. I say that increment1
incremented the counter because the inc1 method in Counter
is inline, so it was inlined in increment1
function body, not called from it. The compiler probably decided to inline it because the -O2
optimization level flag was used.
So, m_counter1
was never incremented and m_debugAllCounters
was incremented instead of it by mistake in increment1
. That's why we see 0 for m_counter1
but we still see 7 for m_debugAllCounters
.
Working in a project where we had tons of source files, grouped in many static libraries, it happened that some of those libraries were compiled without debugging options for std::vector
, and others were compiled with those options.
Probably at some point, all libraries were using the same flags, but as time passed, new libraries were added without taking those flags into consideration (they weren't default flags, they had been added by hand). We used an IDE to compile, so to see the flags for each library, you had to dig into tabs and windows, having different (and multiple) flags for different compilation modes (release, debug, profile…), so it was even harder to note that the flags weren't consistent.
This caused that in the rare occasions when an object file, compiled with one set of flags, passed a std::vector
to an object file compiled with a different set of flags, which did certain operations on that vector, the application crashed. Imagine that it wasn't easy to debug since the crash was reported to happen in the release version, and it didn't happen in the debug version (at least not in the same situations that were reported).
The debugger also did crazy things because it was debugging very optimized code. The crashes were happening in correct and trivial code.
The Compiler Does a Lot More Than You May Think
In this article, you have learned about some of the basic language constructs of C++ and how the compiler works with them, starting from the processing stage to the linking stage. Knowing how it works can help you look at the whole process differently and give you more insight into these processes that we take for granted in C++ development.
From a three-step compilation process to mangling of function names and producing different function signatures in different situations, the compiler does a lot of work to offer the power of C++ as a compiled programming language.
I hope you will find the knowledge from this article useful in your C++ projects.
Ulteriori letture sul blog di Toptal Engineering:
- Come imparare i linguaggi C e C++: l'elenco definitivo
- C# vs. C++: cosa c'è al centro?