Cum funcționează C++: înțelegerea compilației
Publicat: 2022-03-11Limbajul de programare C++ al lui Bjarne Stroustrup are un capitol intitulat „Un tur al C++: elementele de bază”—Standard C++. Acel capitol, în 2.2, menționează într-o jumătate de pagină procesul de compilare și legare în C++. Compilarea și legarea sunt două procese de bază care au loc tot timpul în timpul dezvoltării software C++, dar, în mod ciudat, ele nu sunt bine înțelese de mulți dezvoltatori C++.
De ce codul sursă C++ este împărțit în antet și fișiere sursă? Cum este văzută fiecare parte de către compilator? Cum afectează asta compilarea și legarea? Există multe alte întrebări ca acestea la care poate te-ai gândit, dar ai ajuns să le accepți ca convenție.
Indiferent dacă proiectați o aplicație C++, implementați noi funcții pentru aceasta, încercați să rezolvați erori (în special anumite erori ciudate) sau încercați să faceți ca codul C și C++ să funcționeze împreună, știind cum funcționează compilarea și legarea vă va economisi mult timp și face aceste sarcini mult mai plăcute. În acest articol, veți afla exact asta.
Articolul va explica modul în care un compilator C++ funcționează cu unele dintre constructele de bază ale limbajului, va răspunde la câteva întrebări frecvente legate de procesele lor și vă va ajuta să rezolvați unele greșeli asociate pe care dezvoltatorii le fac adesea în dezvoltarea C++.
Notă: Acest articol are un exemplu de cod sursă care poate fi descărcat de la https://bitbucket.org/danielmunoz/cpp-article
Exemplele au fost compilate într-o mașină CentOS Linux:
$ uname -sr Linux 3.10.0-327.36.3.el7.x86_64Folosind versiunea g++:
$ g++ --version g++ (GCC) 4.8.5 20150623 (Red Hat 4.8.5-11)Fișierele sursă furnizate ar trebui să fie portabile pe alte sisteme de operare, deși Makefile-urile care le însoțesc pentru procesul de construire automat ar trebui să fie portabile numai pentru sisteme asemănătoare Unix.
Build Pipeline: Preprocesează, Compilează și Link
Fiecare fișier sursă C++ trebuie să fie compilat într-un fișier obiect. Fișierele obiect rezultate din compilarea mai multor fișiere sursă sunt apoi legate într-un executabil, o bibliotecă partajată sau o bibliotecă statică (ultima dintre acestea fiind doar o arhivă de fișiere obiect). Fișierele sursă C++ au, în general, sufixele de extensie .cpp, .cxx sau .cc.
Un fișier sursă C++ poate include și alte fișiere, cunoscute ca fișiere antet, cu directiva #include . Fișierele antet au extensii precum .h, .hpp sau .hxx sau nu au nicio extensie ca în biblioteca standard C++ și fișierele antet ale altor biblioteci (cum ar fi Qt). Extensia nu contează pentru preprocesorul C++, care va înlocui literalmente linia care conține directiva #include cu întregul conținut al fișierului inclus.
Primul pas pe care îl va face compilatorul pe un fișier sursă este să ruleze preprocesorul pe acesta. Numai fișierele sursă sunt transmise compilatorului (pentru a le preprocesa și compila). Fișierele antet nu sunt transmise compilatorului. În schimb, acestea sunt incluse din fișierele sursă.
Fiecare fișier antet poate fi deschis de mai multe ori în timpul fazei de preprocesare a tuturor fișierelor sursă, în funcție de câte fișiere sursă le includ sau de câte alte fișiere de antet care sunt incluse din fișierele sursă le includ și (pot exista mai multe niveluri de indirectare) . Fișierele sursă, pe de altă parte, sunt deschise o singură dată de către compilator (și preprocesor), atunci când sunt transmise acestuia.
Pentru fiecare fișier sursă C++, preprocesorul va construi o unitate de traducere inserând conținut în ea atunci când găsește o directivă #include în același timp în care va elimina codul din fișierul sursă și din anteturi când găsește o compilare condiționată. blocuri a căror directivă este evaluată ca false . De asemenea, va face și alte sarcini, cum ar fi înlocuirea macro.
Odată ce preprocesorul termină de a crea acea unitate de traducere (uneori uriașă), compilatorul începe faza de compilare și produce fișierul obiect.
Pentru a obține acea unitate de traducere (codul sursă preprocesat), opțiunea -E poate fi transmisă compilatorului g++, împreună cu opțiunea -o pentru a specifica numele dorit al fișierului sursă preprocesat.
În directorul cpp-article/hello-world , există un fișier exemplu „hello-world.cpp”:
#include <iostream> int main(int argc, char* argv[]) { std::cout << "Hello world" << std::endl; return 0; }Creați fișierul preprocesat prin:
$ g++ -E hello-world.cpp -o hello-world.iiȘi vezi numărul de linii:
$ wc -l hello-world.ii 17558 hello-world.ii Are 17.588 de linii în aparatul meu. De asemenea, puteți rula make în acel director și va face acești pași pentru dvs.
Putem vedea că compilatorul trebuie să compileze un fișier mult mai mare decât fișierul sursă simplu pe care îl vedem. Acest lucru se datorează antetelor incluse. Și în exemplul nostru, am inclus doar un antet. Unitatea de traducere devine din ce în ce mai mare pe măsură ce continuăm să includem anteturi.
Acest proces de preprocesare și compilare este similar pentru limbajul C. Urmează regulile C pentru compilare, iar modul în care include fișiere antet și produce cod obiect este aproape același.
Cum importă și exportă simbolurile fișierelor sursă
Să vedem acum fișierele din directorul cpp-article/symbols/c-vs-cpp-names .
Există un fișier sursă simplu C (nu C++) numit sum.c care exportă două funcții, una pentru adăugarea a două numere întregi și una pentru adăugarea a două elemente flotante:
int sumI(int a, int b) { return a + b; } float sumF(float a, float b) { return a + b; } Compilați-l (sau rulați make și toți pașii pentru a crea cele două exemple de aplicații care urmează să fie executate) pentru a crea fișierul obiect sum.o:
$ gcc -c sum.cAcum uitați-vă la simbolurile exportate și importate de acest fișier obiect:
$ nm sum.o 0000000000000014 T sumF 0000000000000000 T sumI Nu sunt importate simboluri și sunt exportate două simboluri: sumF și sumI . Aceste simboluri sunt exportate ca parte a segmentului .text (T), deci sunt nume de funcții, cod executabil.
Dacă alte fișiere sursă (atât C cât și C++) doresc să apeleze aceste funcții, trebuie să le declare înainte de a apela.
Modul standard de a face acest lucru este să creați un fișier antet care să le declare și să le includă în orice fișier sursă pe care vrem să le numim. Antetul poate avea orice nume și extensie. Am ales 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 Care sunt acele blocuri de compilare condiționată ifdef / endif ? Dacă includ acest antet dintr-un fișier sursă C, vreau să devină:
int sumI(int a, int b); float sumF(float a, float b);Dar dacă le includ dintr-un fișier sursă C++, vreau să devină:
extern "C" { int sumI(int a, int b); float sumF(float a, float b); } // end extern "C" Limbajul C nu știe nimic despre directiva extern "C" , dar C++ știe și are nevoie de această directivă aplicată declarațiilor de funcții C. Acest lucru se datorează faptului că C++ strică numele funcțiilor (și metodei) deoarece acceptă supraîncărcarea funcției/metodei, în timp ce C nu o face.
Acest lucru poate fi văzut în fișierul sursă C++ numit 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); } Există două funcții cu același nume ( printSum ) care diferă doar prin tipul parametrilor lor: int sau float . Supraîncărcarea funcției este o caracteristică C++ care nu este prezentă în C. Pentru a implementa această caracteristică și a diferenția acele funcții, C++ strică numele funcției, așa cum putem vedea în numele simbolului lor exportat (voi alege doar ceea ce este relevant din rezultatul lui 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 Aceste funcții sunt exportate (în sistemul meu) ca _Z8printSumff pentru versiunea float și _Z8printSumii pentru versiunea int. Fiecare nume de funcție din C++ este alterat, cu excepția cazului în care este declarat ca extern "C" . Există două funcții care au fost declarate cu legătura C în print.cpp : printSumInt și printSumFloat .
Prin urmare, nu pot fi supraîncărcate sau numele lor exportate ar fi aceleași, deoarece nu sunt alterate. A trebuit să le diferențiez unul de celălalt postfixând un Int sau un Float la sfârșitul numelor lor.
Deoarece nu sunt alterate, pot fi apelate din codul C, așa cum vom vedea în curând.
Pentru a vedea numele stricate așa cum le-am vedea în codul sursă C++, putem folosi opțiunea -C (demangle) din comanda nm . Din nou, voi copia doar aceeași parte relevantă a rezultatului:
$ 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 Cu această opțiune, în loc de _Z8printSumff vedem printSum(float, float) și în loc de _ZSt4cout vedem std::cout, care sunt nume mai prietenoase cu oamenii.
De asemenea, vedem că codul nostru C++ apelează codul C: print.cpp apelează sumI și sumF , care sunt funcții C declarate ca având o legătură C în sum.h . Acest lucru poate fi văzut în rezultatul nm a print.o de mai sus, care informează despre unele simboluri (U) nedefinite: sumF , sumI și std::cout . Aceste simboluri nedefinite ar trebui să fie furnizate într-unul dintre fișierele obiect (sau bibliotecile) care vor fi legate împreună cu acest fișier obiect în faza de legătură.
Până acum tocmai am compilat codul sursă în cod obiect, nu ne-am conectat încă. Dacă nu legăm fișierul obiect care conține definițiile pentru acele simboluri importate împreună cu acest fișier obiect, linkerul se va opri cu o eroare „simbol lipsă”.
De asemenea, rețineți că, deoarece print.cpp este un fișier sursă C++, compilat cu un compilator C++ (g++), tot codul din acesta este compilat ca cod C++. Funcțiile cu legătură C, cum ar fi printSumInt și printSumFloat , sunt, de asemenea, funcții C++ care pot folosi caracteristicile C++. Doar numele simbolurilor sunt compatibile cu C, dar codul este C++, ceea ce se vede prin faptul că ambele funcții apelează la o funcție supraîncărcată ( printSum ), ceea ce nu s-ar putea întâmpla dacă printSumInt sau printSumFloat ar fi compilate în C.
Să vedem acum print.hpp , un fișier antet care poate fi inclus atât din fișierele sursă C, cât și C++, ceea ce va permite ca printSumInt și printSumFloat să fie apelate atât din C, cât și din C++, iar printSum să fie apelat din 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" #endifDacă îl includem dintr-un fișier sursă C, vrem doar să vedem:
void printSumInt(int a, int b); void printSumFloat(float a, float b); printSum nu poate fi văzut din codul C, deoarece numele său este alterat, așa că nu avem o modalitate (standard și portabilă) de a-l declara pentru codul C. Da, le pot declara ca:
void _Z8printSumii(int a, int b); void _Z8printSumff(float a, float b);Și linkerul nu se va plânge, deoarece acesta este numele exact pe care l-a inventat compilatorul meu instalat în prezent, dar nu știu dacă va funcționa pentru linkerul dvs. (dacă compilatorul dvs. generează un alt nume alterat) sau chiar pentru următoarea versiune a linkerului meu. Nici măcar nu știu dacă apelul va funcționa conform așteptărilor din cauza existenței unor convenții de apelare diferite (cum sunt transmise parametrii și cum sunt returnate valorile returnate) care sunt specifice compilatorului și pot fi diferite pentru apelurile C și C++ (în special pentru funcțiile C++). care sunt funcții membre și primesc indicatorul this ca parametru).
Compilatorul dumneavoastră poate utiliza o convenție de apelare pentru funcțiile obișnuite C++ și una diferită dacă sunt declarate ca având o legătură externă „C”. Deci, înșelarea compilatorului spunând că o funcție folosește convenția de apelare C în timp ce de fapt folosește C++, deoarece poate oferi rezultate neașteptate dacă convențiile utilizate pentru fiecare se întâmplă să fie diferite în lanțul de instrumente de compilare.
Există modalități standard de a amesteca codul C și C++ și o modalitate standard de a apela funcții supraîncărcate C++ din C este de a le împacheta în funcții cu legătură C, așa cum am făcut prin împachetarea printSum cu printSumInt și printSumFloat .
Dacă includem print.hpp dintr-un fișier sursă C++, macrocomanda preprocesorului __cplusplus va fi definită și fișierul va fi văzut ca:
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" Acest lucru va permite codului C++ să apeleze funcția supraîncărcată printSum sau wrapper-urile sale printSumInt și printSumFloat .
Acum să creăm un fișier sursă C care conține funcția principală, care este punctul de intrare pentru un program. Această funcție principală C va apela printSumInt și printSumFloat , adică va apela ambele funcții C++ cu legătură C. Amintiți-vă, acestea sunt funcții C++ (corpurile lor de funcții execută cod C++) care doar nu au nume alterate C++. Fișierul se numește c-main.c :
#include "print.hpp" int main(int argc, char* argv[]) { printSumInt(1, 2); printSumFloat(1.5f, 2.5f); return 0; }Compilați-l pentru a genera fișierul obiect:
$ gcc -c c-main.cȘi vedeți simbolurile importate/exportate:
$ nm c-main.o 0000000000000000 T main U printSumFloat U printSumInt Exportă principal și importă printSumFloat și printSumInt , așa cum era de așteptat.
Pentru a lega totul într-un fișier executabil, trebuie să folosim linkerul C++ (g++), deoarece cel puțin un fișier pe care îl vom lega, print.o , a fost compilat în C++:
$ g++ -o c-app sum.o print.o c-main.oExecuția produce rezultatul așteptat:
$ ./c-app 1 + 2 = 3 1.5 + 2.5 = 4 Acum să încercăm cu un fișier principal C++, numit 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ți și vedeți simbolurile importate/exportate ale fișierului obiect 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) Exportă principal și importă prin C linkage printSumFloat și printSumInt și ambele versiuni alterate ale printSum .
S-ar putea să vă întrebați de ce simbolul principal nu este exportat ca simbol alterat ca main(int, char**) din această sursă C++, deoarece este un fișier sursă C++ și nu este definit ca extern "C" . Ei bine, main este o funcție specială definită de implementare și implementarea mea pare să fi ales să folosească legătura C pentru aceasta, indiferent dacă este definită într-un fișier sursă C sau C++.
Conectarea și rularea programului oferă rezultatul așteptat:
$ 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 = 8Cum funcționează dispozitivele de protecție pentru antet
Până acum, am avut grijă să nu includ anteturile mele de două ori, direct sau indirect, din același fișier sursă. Dar, deoarece un antet poate include alte anteturi, același antet poate fi inclus indirect de mai multe ori. Și din moment ce conținutul antetului este doar inserat în locul de unde a fost inclus, este ușor să terminați cu declarații duplicate.
Vedeți fișierele exemplu în 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 Diferența este că, în guarded.hpp, înconjurăm întregul antet într-un condițional care va fi inclus numai dacă macrocomanda preprocesor __GUARDED_HPP nu este definită. Prima dată când preprocesorul include acest fișier, acesta nu va fi definit. Dar, deoarece macro-ul este definit în interiorul acelui cod protejat, data viitoare când este inclusă (din același fișier sursă, direct sau indirect), preprocesorul va vedea liniile dintre #ifndef și #endif și va elimina tot codul dintre lor.
Rețineți că acest proces are loc pentru fiecare fișier sursă pe care îl compilam. Înseamnă că acest fișier antet poate fi inclus o dată și o singură dată pentru fiecare fișier sursă. Faptul că a fost inclus dintr-un fișier sursă nu va împiedica să fie inclus dintr-un alt fișier sursă atunci când acel fișier sursă este compilat. Va împiedica doar să fie inclus de mai multe ori din același fișier sursă.
Fișierul exemplu main-guarded.cpp include guarded.hpp două ori:
#include "guarded.hpp" #include "guarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); } Dar rezultatul preprocesat arată doar o definiție a clasei 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(); }Prin urmare, poate fi compilat fără probleme:
$ g++ -o guarded main-guarded.cpp Dar fișierul main-unguarded.cpp include unguarded.hpp de două ori:
#include "unguarded.hpp" #include "unguarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }Și rezultatul preprocesat arată două definiții ale clasei 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(); }Acest lucru va cauza probleme la compilare:

$ g++ -o unguarded main-unguarded.cpp În fișierul inclus din 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 { ^De dragul conciziei, nu voi folosi anteturi protejate în acest articol dacă nu este necesar, deoarece majoritatea sunt exemple scurte. Dar întotdeauna păstrați fișierele antet. Nu fișierele sursă, care nu vor fi incluse de nicăieri. Doar fișiere de antet.
Treci după valoare și constanta parametrilor
Uitați-vă la fișierul by-value.cpp în 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); } Deoarece folosesc directiva using namespace std , nu trebuie să calific numele simbolurilor (funcții sau clase) în interiorul spațiului de nume std în restul unității de traducere, care în cazul meu este restul fișierului sursă. Dacă acesta ar fi fost un fișier antet, nu ar fi trebuit să inserez această directivă, deoarece un fișier antet ar trebui să fie inclus din mai multe fișiere sursă; această directivă ar aduce în domeniul global al fiecărui fișier sursă întregul spațiu de nume std din punctul în care include antetul meu.
Chiar și anteturile incluse după mine în acele fișiere vor avea acele simboluri în domeniu. Acest lucru poate produce ciocniri de nume, deoarece nu se așteptau să se întâmple acest lucru. Prin urmare, nu utilizați această directivă în anteturi. Folosiți-l doar în fișierele sursă dacă doriți și numai după ce ați inclus toate anteturile.
Observați cum unii parametri sunt const. Aceasta înseamnă că nu pot fi modificate în corpul funcției dacă încercăm. Ar da o eroare de compilare. De asemenea, rețineți că toți parametrii din acest fișier sursă sunt trecuți prin valoare, nu prin referință (&) sau prin pointer (*). Aceasta înseamnă că apelantul va face o copie a acestora și va trece la funcție. Deci, nu contează pentru apelant dacă sunt const sau nu, deoarece dacă le modificăm în corpul funcției, vom modifica doar copia, nu valoarea originală pe care apelantul a transmis-o funcției.
Deoarece constanța unui parametru care este transmis prin valoare (copie) nu contează pentru apelant, acesta nu este alterat în semnătura funcției, așa cum se poate vedea după compilarea și inspectarea codului obiect (doar ieșirea relevantă):
$ 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> >)Semnăturile nu exprimă dacă parametrii copiați sunt const sau nu în corpurile funcției. Nu contează. A contat doar pentru definiția funcției, pentru a arăta dintr-o privire cititorului corpului funcției dacă acele valori se vor schimba vreodată. În exemplu, doar jumătate dintre parametri sunt declarați ca const, deci putem vedea contrastul, dar dacă vrem să fim const-corect, toți ar fi trebuit să fie declarați, astfel încât niciunul dintre ei nu este modificat în corpul funcției (și ei nu ar trebui).
Deoarece nu contează pentru declarația funcției care este ceea ce vede apelantul, putem crea antetul by-value.hpp astfel:
#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);Adăugarea calificatorilor const aici este permisă (puteți chiar califica ca variabile const care nu sunt const în definiție și va funcționa), dar acest lucru nu este necesar și va face doar declarațiile inutil de verbose.
Treci prin referință
Să vedem 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); }Constanța la trecerea prin referință contează pentru apelant, deoarece îi va spune apelantului dacă argumentul său va fi modificat sau nu de către apelant. Prin urmare, simbolurile sunt exportate cu constanța lor:
$ 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&)Acest lucru ar trebui să se reflecte și în antetul pe care apelanții îl vor folosi:
#include <vector> int sum(const int&, int&); float sum(float&, const float&); int sum(const std::vector<int>&); float sum(const std::vector<float>&);Rețineți că nu am scris numele variabilelor în declarații (în antet) așa cum făceam până acum. Acest lucru este și legal, pentru acest exemplu și pentru cele anterioare. Numele variabilelor nu sunt necesare în declarație, deoarece apelantul nu trebuie să știe cum doriți să vă denumiți variabila. Dar numele parametrilor sunt în general de dorit în declarații, astfel încât utilizatorul să știe dintr-o privire ce înseamnă fiecare parametru și, prin urmare, ce să trimită în apel.
În mod surprinzător, nici numele variabilelor nu sunt necesare în definirea unei funcții. Sunt necesare doar dacă utilizați efectiv parametrul în funcție. Dar dacă nu îl folosiți niciodată, puteți lăsa parametrul cu tipul, dar fără nume. De ce ar declara o funcție un parametru pe care nu l-ar folosi niciodată? Uneori, funcțiile (sau metodele) sunt doar o parte a unei interfețe, cum ar fi o interfață de apel invers, care definește anumiți parametri care sunt transmise observatorului. Observatorul trebuie să creeze un callback cu toți parametrii pe care îi specifică interfața, deoarece toți vor fi trimiși de apelant. Dar observatorul poate să nu fie interesat de toate, așa că, în loc să primească un avertisment al compilatorului despre un „parametru neutilizat”, definiția funcției îl poate lăsa fără nume.
Treceți pe lângă Pointer
// 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); }Pentru a declara un pointer către un element const (int în exemplu), puteți declara tipul ca oricare dintre:
int const * const int *Dacă doriți, de asemenea, ca indicatorul în sine să fie const, adică ca indicatorul să nu poată fi schimbat pentru a indica altceva, adăugați un const după stea:
int const * const const int * constDacă doriți ca indicatorul în sine să fie const, dar nu elementul indicat de acesta:
int * constComparați semnăturile funcției cu inspecția demangle a fișierului obiect:
$ 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*) După cum vedeți, instrumentul nm folosește prima notație (const după tip). De asemenea, rețineți că singura constantă care este exportată și care contează pentru apelant este dacă funcția va modifica elementul indicat de pointer sau nu. Constanța indicatorului în sine este irelevantă pentru apelant, deoarece indicatorul în sine este întotdeauna transmis ca o copie. Funcția poate face doar propria copie a indicatorului pentru a indica altundeva, ceea ce este irelevant pentru apelant.
Deci, un fișier antet poate fi creat ca:
#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);Trecerea prin indicator este ca și trecerea prin referință. O diferență este că atunci când treceți prin referință, apelantul este de așteptat și se presupune că a trecut o referință validă a elementului, nu indicând către NULL sau altă adresă invalidă, în timp ce un pointer ar putea indica spre NULL, de exemplu. Pointerii pot fi folosiți în locul referințelor când trecerea NULL are o semnificație specială.
Deoarece valorile C++11 pot fi transmise și cu semantica de mutare. Acest subiect nu va fi tratat în acest articol, dar poate fi studiat în alte articole precum Argument Passing in C++.
Un alt subiect înrudit care nu va fi tratat aici este cum să apelați toate aceste funcții. Dacă toate aceste anteturi sunt incluse dintr-un fișier sursă, dar nu sunt apelate, compilarea și legarea vor avea succes. Dar dacă doriți să apelați toate funcțiile, vor apărea unele erori, deoarece unele apeluri vor fi ambigue. Compilatorul va putea alege mai mult de o versiune de sum pentru anumite argumente, mai ales atunci când alege dacă să treacă prin copie sau prin referință (sau referință const). Această analiză nu face obiectul acestui articol.
Compilarea cu diferite steaguri
Să vedem, acum, o situație reală legată de acest subiect în care pot apărea bug-uri greu de găsit.
Accesați directorul cpp-article/diff-flags și uitați-vă la 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; }; Această clasă are două contoare, care încep cu zero și pot fi incrementate sau citite. Pentru build-urile de depanare, care este modul în care voi apela build-urile în care macro-ul NDEBUG nu este definit, adaug și un al treilea contor, care va fi incrementat de fiecare dată când oricare dintre celelalte două contoare sunt incrementate. Acesta va fi un fel de ajutor de depanare pentru această clasă. Multe clase de biblioteci terțe sau chiar anteturi C++ încorporate (în funcție de compilator) folosesc trucuri ca acesta pentru a permite diferite niveluri de depanare. Acest lucru permite versiunilor de depanare pentru a detecta iteratorii care ies din interval și alte lucruri interesante la care s-ar putea gândi producătorul de biblioteci. Voi numi versiuni de versiuni „build unde este definită macrocomanda NDEBUG ”.
Pentru versiunile de versiuni, antetul precompilat arată ca (folosesc grep pentru a elimina liniile goale):
$ 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; };În timp ce pentru versiunile de depanare, va arăta astfel:
$ 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; };Mai există un contor în versiunile de depanare, așa cum am explicat mai devreme.
Am creat și niște fișiere de ajutor.
// 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; } Și un Makefile care poate personaliza steagurile compilatorului numai pentru 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 Deci, să compilam totul în modul de depanare, fără a defini 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.oAcum rulați:
$ ./diff-flags c.get1(): 3 c.get2(): 4 c.getDebugAllCounters(): 7 Ieșirea este exact așa cum era de așteptat. Acum să compilam doar unul dintre fișierele cu NDEBUG definit, care ar fi modul de lansare, și să vedem ce se întâmplă:
$ 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 Ieșirea nu este cea așteptată. 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.
Citiți suplimentare pe blogul Toptal Engineering:
- Cum să înveți limbajele C și C++: Lista finală
- C# vs. C++: Ce este la bază?
