Como C++ Funciona: Entendendo a Compilação
Publicados: 2022-03-11A linguagem de programação C++ de Bjarne Stroustrup tem um capítulo intitulado “Um passeio por C++: o básico” — C++ padrão. Esse capítulo, em 2.2, menciona em meia página o processo de compilação e vinculação em C++. Compilação e vinculação são dois processos muito básicos que acontecem o tempo todo durante o desenvolvimento de software C++, mas, curiosamente, eles não são bem compreendidos por muitos desenvolvedores C++.
Por que o código-fonte C++ é dividido em arquivos de cabeçalho e fonte? Como cada parte é vista pelo compilador? Como isso afeta a compilação e a vinculação? Há muitas outras perguntas como essas que você pode ter pensado, mas acabou aceitando como convenção.
Esteja você projetando um aplicativo C++, implementando novos recursos para ele, tentando resolver bugs (especialmente certos bugs estranhos) ou tentando fazer o código C e C++ funcionar juntos, saber como a compilação e a vinculação funcionam economizará muito tempo e tornar essas tarefas muito mais agradáveis. Neste artigo, você aprenderá exatamente isso.
O artigo explicará como um compilador C++ funciona com algumas das construções básicas da linguagem, responderá a algumas perguntas comuns relacionadas a seus processos e ajudará você a solucionar alguns erros relacionados que os desenvolvedores geralmente cometem no desenvolvimento C++.
Nota: Este artigo tem alguns exemplos de código-fonte que podem ser baixados em https://bitbucket.org/danielmunoz/cpp-article
Os exemplos foram compilados em uma máquina CentOS Linux:
$ uname -sr Linux 3.10.0-327.36.3.el7.x86_64Usando a versão g++:
$ g++ --version g++ (GCC) 4.8.5 20150623 (Red Hat 4.8.5-11)Os arquivos de origem fornecidos devem ser portáveis para outros sistemas operacionais, embora os Makefiles que os acompanham para o processo de construção automatizado devam ser portáveis apenas para sistemas do tipo Unix.
O pipeline de construção: pré-processar, compilar e vincular
Cada arquivo de origem C++ precisa ser compilado em um arquivo objeto. Os arquivos de objeto resultantes da compilação de vários arquivos de origem são então vinculados a um executável, uma biblioteca compartilhada ou uma biblioteca estática (a última delas é apenas um arquivo de arquivos de objeto). Os arquivos de origem C++ geralmente têm os sufixos de extensão .cpp, .cxx ou .cc.
Um arquivo de origem C++ pode incluir outros arquivos, conhecidos como arquivos de cabeçalho, com a diretiva #include . Os arquivos de cabeçalho têm extensões como .h, .hpp ou .hxx, ou não têm extensão alguma, como na biblioteca padrão C++ e nos arquivos de cabeçalho de outras bibliotecas (como Qt). A extensão não importa para o pré-processador C++, que substituirá literalmente a linha que contém a diretiva #include por todo o conteúdo do arquivo incluído.
A primeira etapa que o compilador fará em um arquivo de origem é executar o pré-processador nele. Apenas os arquivos de origem são passados para o compilador (para pré-processar e compilar). Os arquivos de cabeçalho não são passados para o compilador. Em vez disso, eles são incluídos nos arquivos de origem.
Cada arquivo de cabeçalho pode ser aberto várias vezes durante a fase de pré-processamento de todos os arquivos de origem, dependendo de quantos arquivos de origem os incluem ou de quantos outros arquivos de cabeçalho incluídos nos arquivos de origem também os incluem (pode haver muitos níveis de indireção) . Os arquivos de origem, por outro lado, são abertos apenas uma vez pelo compilador (e pré-processador), quando são passados para ele.
Para cada arquivo de origem C++, o pré-processador construirá uma unidade de tradução inserindo conteúdo nela quando encontrar uma diretiva #include ao mesmo tempo em que removerá o código do arquivo de origem e dos cabeçalhos quando encontrar a compilação condicional blocos cuja diretiva é avaliada como false . Ele também fará algumas outras tarefas, como substituições de macros.
Uma vez que o pré-processador termina de criar essa unidade de tradução (às vezes enorme), o compilador inicia a fase de compilação e produz o arquivo objeto.
Para obter essa unidade de tradução (o código-fonte pré-processado), a opção -E pode ser passada para o compilador g++, juntamente com a opção -o para especificar o nome desejado do arquivo-fonte pré-processado.
No diretório cpp-article/hello-world , há um arquivo de exemplo “hello-world.cpp”:
#include <iostream> int main(int argc, char* argv[]) { std::cout << "Hello world" << std::endl; return 0; }Crie o arquivo pré-processado por:
$ g++ -E hello-world.cpp -o hello-world.iiE veja o número de linhas:
$ wc -l hello-world.ii 17558 hello-world.ii Tem 17.588 linhas na minha máquina. Você também pode simplesmente executar make nesse diretório e ele fará essas etapas para você.
Podemos ver que o compilador deve compilar um arquivo muito maior do que o arquivo fonte simples que vemos. Isso ocorre por causa dos cabeçalhos incluídos. E em nosso exemplo, incluímos apenas um cabeçalho. A unidade de tradução torna-se cada vez maior à medida que continuamos a incluir cabeçalhos.
Esse processo de pré-processamento e compilação é semelhante para a linguagem C. Ele segue as regras C para compilar, e a maneira como inclui arquivos de cabeçalho e produz código de objeto é quase a mesma.
Como os arquivos de origem importam e exportam símbolos
Vamos ver agora os arquivos no diretório cpp-article/symbols/c-vs-cpp-names .
Existe um arquivo fonte simples em C (não C++) chamado sum.c que exporta duas funções, uma para adicionar dois inteiros e outra para adicionar dois floats:
int sumI(int a, int b) { return a + b; } float sumF(float a, float b) { return a + b; } Compile-o (ou execute make e todas as etapas para criar os dois aplicativos de exemplo a serem executados) para criar o arquivo de objeto sum.o:
$ gcc -c sum.cAgora observe os símbolos exportados e importados por este arquivo de objeto:
$ nm sum.o 0000000000000014 T sumF 0000000000000000 T sumI Nenhum símbolo é importado e dois símbolos são exportados: sumF e sumI . Esses símbolos são exportados como parte do segmento .text (T), portanto são nomes de funções, código executável.
Se outros arquivos de origem (C ou C++) quiserem chamar essas funções, eles precisarão declará-las antes de chamar.
A maneira padrão de fazer isso é criar um arquivo de cabeçalho que os declare e os inclua em qualquer arquivo de origem que queiramos chamá-los. O cabeçalho pode ter qualquer nome e extensão. Eu escolhi 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 O que são esses blocos de compilação condicional ifdef / endif ? Se eu incluir este cabeçalho de um arquivo de origem C, quero que ele se torne:
int sumI(int a, int b); float sumF(float a, float b);Mas se eu incluí-los de um arquivo de origem C++, quero que se torne:
extern "C" { int sumI(int a, int b); float sumF(float a, float b); } // end extern "C" A linguagem C não sabe nada sobre a diretiva extern "C" , mas C++ sabe, e precisa dessa diretiva aplicada às declarações de funções C. Isso ocorre porque o C++ desfigura nomes de funções (e métodos) porque ele suporta sobrecarga de função/método, enquanto C não suporta.
Isso pode ser visto no arquivo de origem C++ chamado 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); } Existem duas funções com o mesmo nome ( printSum ) que diferem apenas no tipo de seus parâmetros: int ou float . A sobrecarga de função é um recurso C++ que não está presente em C. Para implementar esse recurso e diferenciar essas funções, C++ desmonta o nome da função, como podemos ver no nome do símbolo exportado (só escolherei o que é relevante na saída do 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 Essas funções são exportadas (no meu sistema) como _Z8printSumff para a versão float e _Z8printSumii para a versão int. Cada nome de função em C++ é desconfigurado, a menos que declarado como extern "C" . Existem duas funções que foram declaradas com C linkage em print.cpp : printSumInt e printSumFloat .
Portanto, eles não podem ser sobrecarregados, ou seus nomes exportados seriam os mesmos, pois não são desconfigurados. Eu tive que diferenciá-los uns dos outros colocando um Int ou um Float no final de seus nomes.
Como eles não são mutilados, eles podem ser chamados a partir do código C, como veremos em breve.
Para ver os nomes desconfigurados como os veríamos no código-fonte C++, podemos usar a opção -C (demangle) no comando nm . Novamente, copiarei apenas a mesma parte relevante da saída:
$ 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 Com esta opção, em vez de _Z8printSumff , vemos printSum(float, float) e, em vez de _ZSt4cout , vemos std::cout, que são nomes mais amigáveis.
Também vemos que nosso código C++ está chamando o código C: print.cpp está chamando sumI e sumF , que são funções C declaradas como tendo ligação C em sum.h . Isso pode ser visto na saída nm do print.o acima, que informa sobre alguns símbolos indefinidos (U): sumF , sumI e std::cout . Esses símbolos indefinidos devem ser fornecidos em um dos arquivos de objeto (ou bibliotecas) que serão vinculados com a saída desse arquivo de objeto na fase de vinculação.
Até agora, apenas compilamos o código-fonte em código-objeto, ainda não vinculamos. Se não vincularmos o arquivo de objeto que contém as definições para esses símbolos importados junto com este arquivo de objeto, o vinculador irá parar com um erro de “símbolo ausente”.
Observe também que, como print.cpp é um arquivo fonte C++, compilado com um compilador C++ (g++), todo o código nele é compilado como código C++. Funções com ligação C como printSumInt e printSumFloat também são funções C++ que podem usar recursos C++. Apenas os nomes dos símbolos são compatíveis com C, mas o código é C++, o que pode ser visto pelo fato de ambas as funções estarem chamando uma função sobrecarregada ( printSum ), o que não aconteceria se printSumInt ou printSumFloat fossem compilados em C.
Vejamos agora print.hpp , um arquivo de cabeçalho que pode ser incluído tanto de arquivos de origem C quanto C++, que permitirá que printSumInt e printSumFloat sejam chamados tanto de C quanto de C++, e printSum seja chamado de 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" #endifSe o estivermos incluindo de um arquivo de origem C, queremos apenas ver:
void printSumInt(int a, int b); void printSumFloat(float a, float b); printSum não pode ser visto no código C, pois seu nome é desconfigurado, portanto, não temos uma maneira (padrão e portátil) de declará-lo para o código C. Sim, posso declará-los como:
void _Z8printSumii(int a, int b); void _Z8printSumff(float a, float b);E o vinculador não reclamará, pois esse é o nome exato que meu compilador instalado atualmente inventou para ele, mas não sei se funcionará para o seu vinculador (se seu compilador gerar um nome desconfigurado diferente), ou mesmo para o próxima versão do meu vinculador. Eu nem sei se a chamada funcionará conforme o esperado devido à existência de diferentes convenções de chamada (como os parâmetros são passados e os valores de retorno são retornados) que são específicos do compilador e podem ser diferentes para chamadas C e C++ (especialmente para funções C++ que são funções-membro e recebem o ponteiro this como parâmetro).
Seu compilador pode potencialmente usar uma convenção de chamada para funções C++ regulares e uma diferente se elas forem declaradas como tendo ligação externa “C”. Portanto, enganar o compilador dizendo que uma função usa a convenção de chamada C enquanto na verdade usa C++ para isso pode fornecer resultados inesperados se as convenções usadas para cada uma forem diferentes em sua cadeia de ferramentas de compilação.
Existem maneiras padrão de misturar código C e C++ e uma maneira padrão de chamar funções sobrecarregadas de C++ de C é envolvê-las em funções com ligação C, como fizemos ao envolver printSum com printSumInt e printSumFloat .
Se incluirmos print.hpp de um arquivo fonte C++, a macro do pré-processador __cplusplus será definida e o arquivo será visto como:
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" Isso permitirá que o código C++ chame a função sobrecarregada printSum ou seus wrappers printSumInt e printSumFloat .
Agora vamos criar um arquivo fonte C contendo a função main, que é o ponto de entrada para um programa. Essa função principal C chamará printSumInt e printSumFloat , ou seja, chamará ambas as funções C++ com ligação C. Lembre-se, essas são funções C++ (seus corpos de função executam código C++) que apenas não possuem nomes desconfigurados em C++. O arquivo é denominado c-main.c :
#include "print.hpp" int main(int argc, char* argv[]) { printSumInt(1, 2); printSumFloat(1.5f, 2.5f); return 0; }Compile-o para gerar o arquivo objeto:
$ gcc -c c-main.cE veja os símbolos importados/exportados:
$ nm c-main.o 0000000000000000 T main U printSumFloat U printSumInt Ele exporta main e importa printSumFloat e printSumInt , conforme o esperado.
Para vincular tudo em um arquivo executável, precisamos usar o vinculador C++ (g++), pois pelo menos um arquivo que vincularemos, print.o , foi compilado em C++:
$ g++ -o c-app sum.o print.o c-main.oA execução produz o resultado esperado:
$ ./c-app 1 + 2 = 3 1.5 + 2.5 = 4 Agora vamos tentar com um arquivo principal C++, chamado 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; } Compile e veja os símbolos importados/exportados do arquivo objeto 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) Ele exporta main e importa C linkage printSumFloat e printSumInt , e ambas as versões desfiguradas de printSum .
Você pode estar se perguntando por que o símbolo principal não é exportado como um símbolo desconfigurado como main(int, char**) desta fonte C++, pois é um arquivo de origem C++ e não está definido como extern "C" . Bem, main é uma função definida de implementação especial e minha implementação parece ter escolhido usar a ligação C para ela, não importa se ela está definida em um arquivo de origem C ou C++.
Vincular e executar o programa fornece o resultado esperado:
$ 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 = 8Como funcionam os protetores de cabeçalho
Até agora, tive o cuidado de não incluir meus cabeçalhos duas vezes, direta ou indiretamente, do mesmo arquivo de origem. Mas como um cabeçalho pode incluir outros cabeçalhos, o mesmo cabeçalho pode ser incluído indiretamente várias vezes. E como o conteúdo do cabeçalho é inserido apenas no local de onde foi incluído, é fácil terminar com declarações duplicadas.
Veja os arquivos de exemplo em 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 A diferença é que, em guarded.hpp, envolvemos todo o cabeçalho em uma condicional que só será incluída se a macro do pré-processador __GUARDED_HPP não estiver definida. A primeira vez que o pré-processador incluir este arquivo, ele não será definido. Mas, como a macro está definida dentro desse código protegido, na próxima vez que for incluída (do mesmo arquivo fonte, direta ou indiretamente), o pré-processador verá as linhas entre o #ifndef e o #endif e descartará todo o código entre eles.
Observe que esse processo acontece para todos os arquivos de origem que compilamos. Isso significa que este arquivo de cabeçalho pode ser incluído uma vez e apenas uma vez para cada arquivo de origem. O fato de ter sido incluído de um arquivo de origem não impedirá que seja incluído de um arquivo de origem diferente quando esse arquivo de origem for compilado. Isso apenas impedirá que seja incluído mais de uma vez no mesmo arquivo de origem.
O arquivo de exemplo main-guarded.cpp inclui guarded.hpp duas vezes:
#include "guarded.hpp" #include "guarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); } Mas a saída pré-processada mostra apenas uma definição de 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(); }Portanto, pode ser compilado sem problemas:
$ g++ -o guarded main-guarded.cpp Mas o arquivo main-unguarded.cpp inclui unguarded.hpp duas vezes:
#include "unguarded.hpp" #include "unguarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }E a saída pré-processada mostra duas definições de 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(); }Isso causará problemas ao compilar:

$ g++ -o unguarded main-unguarded.cpp No arquivo incluído em 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 { ^Por uma questão de brevidade, não usarei cabeçalhos protegidos neste artigo se não for necessário, pois a maioria são exemplos curtos. Mas sempre guarde seus arquivos de cabeçalho. Não seus arquivos de origem, que não serão incluídos em nenhum lugar. Apenas arquivos de cabeçalho.
Passar por Valor e Constância de Parâmetros
Veja o arquivo by-value.cpp em 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); } Como uso a diretiva using namespace std , não preciso qualificar os nomes dos símbolos (funções ou classes) dentro do namespace std no restante da unidade de tradução, que no meu caso é o restante do arquivo de origem. Se este fosse um arquivo de cabeçalho, eu não deveria ter inserido esta diretiva porque um arquivo de cabeçalho deveria ser incluído de vários arquivos de origem; essa diretiva traria para o escopo global de cada arquivo de origem todo o namespace std a partir do ponto em que inclui meu cabeçalho.
Mesmo os cabeçalhos incluídos após o meu nesses arquivos terão esses símbolos no escopo. Isso pode produzir conflitos de nomes, pois eles não esperavam que isso acontecesse. Portanto, não use essa diretiva em cabeçalhos. Use-o apenas em arquivos de origem, se desejar, e somente depois de incluir todos os cabeçalhos.
Observe como alguns parâmetros são const. Isso significa que eles não podem ser alterados no corpo da função se tentarmos. Daria um erro de compilação. Além disso, observe que todos os parâmetros neste arquivo de origem são passados por valor, não por referência (&) ou por ponteiro (*). Isso significa que o chamador fará uma cópia deles e passará para a função. Portanto, não importa para o chamador se eles são const ou não, porque se os modificarmos no corpo da função estaremos apenas modificando a cópia, não o valor original que o chamador passou para a função.
Como a constância de um parâmetro que é passado por valor (cópia) não importa para o chamador, ele não é desconfigurado na assinatura da função, como pode ser visto após compilar e inspecionar o código do objeto (somente a saída relevante):
$ 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> >)As assinaturas não expressam se os parâmetros copiados são const ou não nos corpos da função. Não importa. Importava apenas para a definição da função, mostrar rapidamente ao leitor do corpo da função se esses valores mudariam. No exemplo, apenas metade dos parâmetros são declarados como const, para que possamos ver o contraste, mas se quisermos ser const-corretos, todos devem ter sido declarados assim, pois nenhum deles é modificado no corpo da função (e eles não deveria).
Como não importa para a declaração da função que é o que o chamador vê, podemos criar o cabeçalho by-value.hpp assim:
#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);Adicionar os qualificadores const aqui é permitido (você pode até qualificar como variáveis const que não são const na definição e funcionará), mas isso não é necessário e apenas tornará as declarações desnecessariamente detalhadas.
Passar por referência
Vamos ver 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); }A constância ao passar por referência é importante para o chamador, porque informará ao chamador se seu argumento será modificado ou não pelo chamado. Portanto, os símbolos são exportados com sua constância:
$ 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&)Isso também deve ser refletido no cabeçalho que os chamadores usarão:
#include <vector> int sum(const int&, int&); float sum(float&, const float&); int sum(const std::vector<int>&); float sum(const std::vector<float>&);Observe que não escrevi o nome das variáveis nas declarações (no cabeçalho) como vinha fazendo até agora. Isso também é legal, para este exemplo e para os anteriores. Os nomes de variáveis não são necessários na declaração, pois o chamador não precisa saber como você deseja nomear sua variável. Mas os nomes dos parâmetros são geralmente desejáveis nas declarações para que o usuário possa saber rapidamente o que cada parâmetro significa e, portanto, o que enviar na chamada.
Surpreendentemente, nomes de variáveis também não são necessários na definição de uma função. Eles são necessários apenas se você realmente usar o parâmetro na função. Mas se você nunca usar, pode deixar o parâmetro com o tipo, mas sem o nome. Por que uma função declararia um parâmetro que nunca usaria? Às vezes, funções (ou métodos) são apenas parte de uma interface, como uma interface de retorno de chamada, que define certos parâmetros que são passados ao observador. O observador deve criar um retorno de chamada com todos os parâmetros que a interface especifica, pois todos serão enviados pelo chamador. Mas o observador pode não estar interessado em todos eles, então ao invés de receber um aviso do compilador sobre um “parâmetro não utilizado”, a definição da função pode simplesmente deixá-lo sem nome.
Passar por ponteiro
// 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); }Para declarar um ponteiro para um elemento const (int no exemplo), você pode declarar o tipo como:
int const * const int *Se você também deseja que o próprio ponteiro seja const, ou seja, que o ponteiro não possa ser alterado para apontar para outra coisa, você adiciona um const após a estrela:
int const * const const int * constSe você quiser que o próprio ponteiro seja const, mas não o elemento apontado por ele:
int * constCompare as assinaturas de função com a inspeção desmembrada do arquivo objeto:
$ 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*) Como você vê, a ferramenta nm usa a primeira notação (const após o tipo). Além disso, observe que a única constness exportada e importante para o chamador é se a função modificará o elemento apontado pelo ponteiro ou não. A constância do próprio ponteiro é irrelevante para o chamador, pois o próprio ponteiro é sempre passado como uma cópia. A função só pode fazer sua própria cópia do ponteiro para apontar para outro lugar, o que é irrelevante para o chamador.
Assim, um arquivo de cabeçalho pode ser criado como:
#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);Passar por ponteiro é como passar por referência. Uma diferença é que, quando você passa por referência, espera-se que o chamador tenha passado a referência de um elemento válido, não apontando para NULL ou outro endereço inválido, enquanto um ponteiro pode apontar para NULL, por exemplo. Ponteiros podem ser usados em vez de referências quando passar NULL tem um significado especial.
Como os valores do C++ 11 também podem ser passados com semântica de movimento. Este tópico não será tratado neste artigo, mas pode ser estudado em outros artigos como Argument Passing in C++.
Outro tópico relacionado que não será abordado aqui é como chamar todas essas funções. Se todos esses cabeçalhos forem incluídos de um arquivo de origem, mas não forem chamados, a compilação e a vinculação serão bem-sucedidas. Mas se você quiser chamar todas as funções, haverá alguns erros porque algumas chamadas serão ambíguas. O compilador poderá escolher mais de uma versão de sum para determinados argumentos, especialmente ao escolher se deseja passar por cópia ou por referência (ou referência const). Essa análise está fora do escopo deste artigo.
Compilando com diferentes sinalizadores
Vamos ver, agora, uma situação da vida real relacionada a este assunto onde bugs difíceis de encontrar podem aparecer.
Vá para o diretório cpp-article/diff-flags e veja 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; }; Esta classe tem dois contadores, que começam com zero e podem ser incrementados ou lidos. Para compilações de depuração, que é como chamarei compilações em que a macro NDEBUG não está definida, também adiciono um terceiro contador, que será incrementado toda vez que qualquer um dos outros dois contadores for incrementado. Isso será uma espécie de auxiliar de depuração para esta classe. Muitas classes de biblioteca de terceiros ou mesmo cabeçalhos C++ internos (dependendo do compilador) usam truques como esse para permitir diferentes níveis de depuração. Isso permite que compilações de depuração detectem iteradores fora do alcance e outras coisas interessantes que o criador da biblioteca possa pensar. Vou chamar as compilações de lançamento de “compilações onde a macro NDEBUG é definida”.
Para compilações de lançamento, o cabeçalho pré-compilado se parece com (eu uso grep para remover linhas em branco):
$ 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; };Enquanto para compilações de depuração, será parecido com:
$ 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; };Há mais um contador nas compilações de depuração, como expliquei anteriormente.
Eu também criei alguns arquivos auxiliares.
// 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 um Makefile que pode personalizar os sinalizadores do compilador apenas para 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 Então, vamos compilar tudo em modo debug, sem definir 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.oAgora execute:
$ ./diff-flags c.get1(): 3 c.get2(): 4 c.getDebugAllCounters(): 7 A saída é exatamente como o esperado. Agora vamos compilar apenas um dos arquivos com NDEBUG definido, que seria o modo de lançamento, e ver o que acontece:
$ 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 The output isn't as expected. 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.
Leitura adicional no Blog da Toptal Engineering:
- Como aprender as linguagens C e C++: a lista definitiva
- C# vs. C++: O que está no núcleo?
