Cómo funciona C++: comprensión de la compilación
Publicado: 2022-03-11El lenguaje de programación C++ de Bjarne Stroustrup tiene un capítulo titulado “Un recorrido por C++: conceptos básicos”: C++ estándar. Ese capítulo, en el 2.2, menciona en media página el proceso de compilación y enlace en C++. La compilación y la vinculación son dos procesos muy básicos que ocurren todo el tiempo durante el desarrollo de software de C++, pero curiosamente, muchos desarrolladores de C++ no los entienden bien.
¿Por qué el código fuente de C++ se divide en encabezado y archivos fuente? ¿Cómo ve cada parte el compilador? ¿Cómo afecta eso a la compilación y la vinculación? Hay muchas más preguntas como estas en las que puede haber pensado pero que ha llegado a aceptar como una convención.
Ya sea que esté diseñando una aplicación C++, implementando nuevas características para ella, tratando de corregir errores (especialmente ciertos errores extraños) o tratando de hacer que el código C y C++ funcionen juntos, saber cómo funciona la compilación y la vinculación le ahorrará mucho tiempo y hacer esas tareas mucho más agradables. En este artículo, aprenderá exactamente eso.
El artículo explicará cómo funciona un compilador de C++ con algunas de las construcciones básicas del lenguaje, responderá algunas preguntas comunes relacionadas con sus procesos y lo ayudará a solucionar algunos errores relacionados que los desarrolladores suelen cometer en el desarrollo de C++.
Nota: este artículo tiene un código fuente de ejemplo que se puede descargar desde https://bitbucket.org/danielmunoz/cpp-article
Los ejemplos fueron compilados en una máquina CentOS Linux:
$ uname -sr Linux 3.10.0-327.36.3.el7.x86_64
Usando la versión g ++:
$ g++ --version g++ (GCC) 4.8.5 20150623 (Red Hat 4.8.5-11)
Los archivos de origen provistos deben ser portátiles a otros sistemas operativos, aunque los Makefiles que los acompañan para el proceso de compilación automatizado deben ser portátiles solo a sistemas similares a Unix.
La canalización de compilación: preprocesamiento, compilación y enlace
Cada archivo fuente de C++ debe compilarse en un archivo de objeto. Los archivos de objeto resultantes de la compilación de varios archivos de origen se vinculan a un archivo ejecutable, una biblioteca compartida o una biblioteca estática (la última de las cuales es solo un archivo de archivos de objeto). Los archivos fuente de C++ generalmente tienen los sufijos de extensión .cpp, .cxx o .cc.
Un archivo fuente de C++ puede incluir otros archivos, conocidos como archivos de encabezado, con la directiva #include
. Los archivos de encabezado tienen extensiones como .h, .hpp o .hxx, o no tienen ninguna extensión como en la biblioteca estándar de C++ y los archivos de encabezado de otras bibliotecas (como Qt). La extensión no importa para el preprocesador de C++, que literalmente reemplazará la línea que contiene la directiva #include
con todo el contenido del archivo incluido.
El primer paso que hará el compilador en un archivo fuente es ejecutar el preprocesador en él. Solo los archivos fuente se pasan al compilador (para preprocesarlos y compilarlos). Los archivos de encabezado no se pasan al compilador. En su lugar, se incluyen desde los archivos de origen.
Cada archivo de encabezado se puede abrir varias veces durante la fase de preprocesamiento de todos los archivos de origen, según cuántos archivos de origen los incluyan o cuántos otros archivos de encabezado que se incluyen desde los archivos de origen también los incluyen (puede haber muchos niveles de direccionamiento indirecto) . Los archivos fuente, por otro lado, son abiertos solo una vez por el compilador (y el preprocesador), cuando se le pasan.
Para cada archivo fuente de C++, el preprocesador creará una unidad de traducción insertando contenido en ella cuando encuentre una directiva #include al mismo tiempo que eliminará el código del archivo fuente y de los encabezados cuando encuentre una compilación condicional. bloques cuya directiva se evalúa como false
. También hará otras tareas como reemplazos de macros.
Una vez que el preprocesador termina de crear esa unidad de traducción (a veces enorme), el compilador inicia la fase de compilación y produce el archivo de objeto.
Para obtener esa unidad de traducción (el código fuente preprocesado), se puede pasar la opción -E
al compilador g++, junto con la opción -o
para especificar el nombre deseado del archivo fuente preprocesado.
En el directorio cpp-article/hello-world
, hay un archivo de ejemplo “hello-world.cpp”:
#include <iostream> int main(int argc, char* argv[]) { std::cout << "Hello world" << std::endl; return 0; }
Cree el archivo preprocesado por:
$ g++ -E hello-world.cpp -o hello-world.ii
Y ver el número de líneas:
$ wc -l hello-world.ii 17558 hello-world.ii
Tiene 17.588 líneas en mi máquina. También puede simplemente ejecutar make
en ese directorio y hará esos pasos por usted.
Podemos ver que el compilador debe compilar un archivo mucho más grande que el archivo fuente simple que vemos. Esto se debe a los encabezados incluidos. Y en nuestro ejemplo, hemos incluido solo un encabezado. La unidad de traducción se vuelve más y más grande a medida que seguimos incluyendo encabezados.
Este proceso de preprocesamiento y compilación es similar para el lenguaje C. Sigue las reglas de C para compilar, y la forma en que incluye archivos de encabezado y produce código objeto es casi la misma.
Cómo los archivos de origen importan y exportan símbolos
Veamos ahora los archivos en el directorio cpp-article/symbols/c-vs-cpp-names
.
Hay un archivo fuente C simple (no C++) llamado sum.c que exporta dos funciones, una para sumar dos enteros y otra para sumar dos flotantes:
int sumI(int a, int b) { return a + b; } float sumF(float a, float b) { return a + b; }
Compílelo (o ejecute make
y todos los pasos para crear las dos aplicaciones de ejemplo que se ejecutarán) para crear el archivo de objeto sum.o:
$ gcc -c sum.c
Ahora mire los símbolos exportados e importados por este archivo de objeto:
$ nm sum.o 0000000000000014 T sumF 0000000000000000 T sumI
No se importa ningún símbolo y se exportan dos símbolos: sumF
y sumI
. Esos símbolos se exportan como parte del segmento .text (T), por lo que son nombres de funciones, código ejecutable.
Si otros archivos fuente (tanto C como C++) quieren llamar a esas funciones, deben declararlas antes de llamar.
La forma estándar de hacerlo es crear un archivo de encabezado que los declare y los incluya en cualquier archivo fuente que queramos llamarlos. El encabezado puede tener cualquier nombre y extensión. Elegí 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
¿Qué son esos bloques de compilación condicionales ifdef
/ endif
? Si incluyo este encabezado de un archivo fuente C, quiero que se convierta en:
int sumI(int a, int b); float sumF(float a, float b);
Pero si los incluyo desde un archivo fuente de C++, quiero que se convierta en:
extern "C" { int sumI(int a, int b); float sumF(float a, float b); } // end extern "C"
El lenguaje C no sabe nada sobre la directiva extern "C"
, pero C++ sí, y necesita que esta directiva se aplique a las declaraciones de funciones de C. Esto se debe a que C++ altera los nombres de funciones (y métodos) porque admite la sobrecarga de funciones/métodos, mientras que C no lo hace.
Esto se puede ver en el archivo fuente de C++ llamado 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); }
Hay dos funciones con el mismo nombre ( printSum
) que solo se diferencian en el tipo de sus parámetros: int
o float
. La sobrecarga de funciones es una característica de C++ que no está presente en C. Para implementar esta característica y diferenciar esas funciones, C++ altera el nombre de la función, como podemos ver en el nombre del símbolo exportado (solo elegiré lo que sea relevante de la salida de 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
Esas funciones se exportan (en mi sistema) como _Z8printSumff
para la versión flotante y _Z8printSumii
para la versión int. Cada nombre de función en C++ está alterado a menos que se declare como extern "C"
. Hay dos funciones que se declararon con enlace C en print.cpp
: printSumInt
y printSumFloat
.
Por lo tanto, no se pueden sobrecargar, o sus nombres exportados serían los mismos ya que no están alterados. Tuve que diferenciarlos entre sí agregando un Int o un Float al final de sus nombres.
Como no están alterados, se pueden llamar desde el código C, como veremos pronto.
Para ver los nombres alterados como los veríamos en el código fuente de C++, podemos usar la opción -C
(demangle) en el comando nm
. Nuevamente, solo copiaré la misma parte relevante de la salida:
$ 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 esta opción, en lugar de _Z8printSumff
vemos printSum(float, float)
, y en lugar de _ZSt4cout
vemos std::cout, que son nombres más amigables para los humanos.
También vemos que nuestro código C++ está llamando al código C: print.cpp
está llamando a sumI
y sumF
, que son funciones C declaradas con enlace C en sum.h
. Esto se puede ver en la salida nm de print.o anterior, que informa sobre algunos símbolos (U) indefinidos: sumF
, sumI
y std::cout
. Se supone que esos símbolos no definidos se proporcionan en uno de los archivos de objeto (o bibliotecas) que se vincularán junto con esta salida de archivo de objeto en la fase de vinculación.
Hasta ahora, solo hemos compilado el código fuente en el código objeto, aún no lo hemos vinculado. Si no vinculamos el archivo de objeto que contiene las definiciones de esos símbolos importados junto con este archivo de objeto, el vinculador se detendrá con un error de "símbolo faltante".
Tenga en cuenta también que dado que print.cpp
es un archivo fuente de C++, compilado con un compilador de C++ (g++), todo el código que contiene se compila como código de C++. Las funciones con vinculación de C como printSumInt
y printSumFloat
también son funciones de C++ que pueden usar características de C++. Solo los nombres de los símbolos son compatibles con C, pero el código es C++, lo que se puede ver por el hecho de que ambas funciones están llamando a una función sobrecargada ( printSum
), lo que no podría suceder si printSumInt
o printSumFloat
estuvieran compilados en C.
Veamos ahora print.hpp
, un archivo de cabecera que se puede incluir tanto desde archivos fuente C como desde C++, que permitirá llamar a printSumInt
y printSumFloat
tanto desde C como desde C++, y llamar a printSum
desde 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
Si lo estamos incluyendo desde un archivo fuente C, solo queremos ver:
void printSumInt(int a, int b); void printSumFloat(float a, float b);
printSum
no se puede ver desde el código C ya que su nombre está alterado, por lo que no tenemos una forma (estándar y portátil) de declararlo para el código C. Sí, puedo declararlos como:
void _Z8printSumii(int a, int b); void _Z8printSumff(float a, float b);
Y el enlazador no se quejará ya que ese es el nombre exacto que mi compilador actualmente instalado inventó para él, pero no sé si funcionará para su enlazador (si su compilador genera un nombre alterado diferente), o incluso para el próxima versión de mi enlazador. Ni siquiera sé si la llamada funcionará como se esperaba debido a la existencia de diferentes convenciones de llamadas (cómo se pasan los parámetros y se devuelven los valores de retorno) que son específicas del compilador y pueden ser diferentes para las llamadas de C y C++ (especialmente para las funciones de C++ que son funciones miembro y reciben el puntero this como parámetro).
Su compilador puede potencialmente usar una convención de llamada para las funciones regulares de C++ y otra diferente si se declara que tienen un vínculo "C" externo. Por lo tanto, engañar al compilador diciendo que una función usa la convención de llamadas de C mientras que en realidad usa C++, ya que puede generar resultados inesperados si las convenciones utilizadas para cada una resultan ser diferentes en su cadena de herramientas de compilación.
Hay formas estándar de mezclar código C y C++ y una forma estándar de llamar a funciones sobrecargadas de C++ desde C es envolverlas en funciones con enlace C como hicimos al envolver printSum
con printSumInt
y printSumFloat
.
Si incluimos print.hpp
desde un archivo fuente de C++, se definirá la macro del preprocesador __cplusplus
y el archivo se verá 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"
Esto permitirá que el código C++ llame a la función sobrecargada printSum o sus contenedores printSumInt
y printSumFloat
.
Ahora vamos a crear un archivo fuente C que contenga la función principal, que es el punto de entrada para un programa. Esta función principal de C llamará a printSumInt
y printSumFloat
, es decir, llamará a ambas funciones de C++ con enlace de C. Recuerde, esas son funciones de C++ (sus cuerpos de función ejecutan código de C++) que solo no tienen nombres alterados de C++. El archivo se llama c-main.c
:
#include "print.hpp" int main(int argc, char* argv[]) { printSumInt(1, 2); printSumFloat(1.5f, 2.5f); return 0; }
Compílelo para generar el archivo objeto:
$ gcc -c c-main.c
Y vea los símbolos importados/exportados:
$ nm c-main.o 0000000000000000 T main U printSumFloat U printSumInt
Exporta main e importa printSumFloat
y printSumInt
, como se esperaba.
Para vincularlo todo en un archivo ejecutable, necesitamos usar el enlazador de C++ (g++), ya que al menos un archivo que vincularemos, print.o
, se compiló en C++:
$ g++ -o c-app sum.o print.o c-main.o
La ejecución produce el resultado esperado:
$ ./c-app 1 + 2 = 3 1.5 + 2.5 = 4
Ahora intentemos con un archivo principal de C++, llamado 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 y vea los símbolos importados/exportados del archivo de 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)
Exporta main e importa C linkage printSumFloat
y printSumInt
, y ambas versiones alteradas de printSum
.
Quizás se pregunte por qué el símbolo principal no se exporta como un símbolo alterado como main(int, char**)
desde esta fuente C++, ya que es un archivo fuente C++ y no está definido como extern "C"
. Bueno, main
es una función definida por una implementación especial y mi implementación parece haber optado por usar el enlace C sin importar si está definida en un archivo fuente C o C++.
Vincular y ejecutar el programa da el 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 = 8
Cómo funcionan los protectores de cabecera
Hasta ahora, he tenido cuidado de no incluir mis encabezados dos veces, directa o indirectamente, desde el mismo archivo fuente. Pero dado que un encabezado puede incluir otros encabezados, el mismo encabezado puede incluirse indirectamente varias veces. Y dado que el contenido del encabezado se inserta en el lugar donde se incluyó, es fácil terminar con declaraciones duplicadas.
Consulte los archivos de ejemplo en 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 diferencia es que, en guarded.hpp, rodeamos todo el encabezado en un condicional que solo se incluirá si la macro de preprocesador __GUARDED_HPP
no está definida. La primera vez que el preprocesador incluya este archivo, no estará definido. Pero, dado que la macro se define dentro de ese código protegido, la próxima vez que se incluya (desde el mismo archivo fuente, directa o indirectamente), el preprocesador verá las líneas entre #ifndef y #endif y descartará todo el código entre ellos.
Tenga en cuenta que este proceso ocurre para cada archivo fuente que compilamos. Significa que este archivo de encabezado se puede incluir una vez y solo una vez para cada archivo fuente. El hecho de que se haya incluido desde un archivo fuente no impedirá que se incluya desde un archivo fuente diferente cuando se compile ese archivo fuente. Solo evitará que se incluya más de una vez desde el mismo archivo fuente.
El archivo de ejemplo main-guarded.cpp
incluye guarded.hpp
dos veces:
#include "guarded.hpp" #include "guarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
Pero la salida preprocesada solo muestra una definición de clase 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(); }
Por lo tanto, se puede compilar sin problemas:
$ g++ -o guarded main-guarded.cpp
Pero el archivo main-unguarded.cpp
incluye unguarded.hpp
dos veces:
#include "unguarded.hpp" #include "unguarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
Y la salida preprocesada muestra dos definiciones de clase 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(); }
Esto causará problemas al compilar:

$ g++ -o unguarded main-unguarded.cpp
En el archivo incluido desde 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 { ^
En aras de la brevedad, no usaré encabezados protegidos en este artículo si no es necesario, ya que la mayoría son ejemplos breves. Pero siempre proteja sus archivos de encabezado. No sus archivos de origen, que no se incluirán desde ningún lugar. Solo archivos de encabezado.
Paso por valor y constancia de parámetros
Mire el archivo by-value.cpp
en 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); }
Dado que uso la directiva using namespace std
de uso, no tengo que calificar los nombres de los símbolos (funciones o clases) dentro del espacio de nombres std en el resto de la unidad de traducción, que en mi caso es el resto del archivo fuente. Si se tratara de un archivo de encabezado, no debería haber insertado esta directiva porque se supone que se incluye un archivo de encabezado de varios archivos de origen; esta directiva llevaría al ámbito global de cada archivo fuente todo el espacio de nombres estándar desde el punto en que incluyen mi encabezado.
Incluso los encabezados incluidos después del mío en esos archivos tendrán esos símbolos en el alcance. Esto puede producir conflictos de nombres ya que no esperaban que esto sucediera. Por lo tanto, no use esta directiva en los encabezados. Úselo solo en los archivos de origen si lo desea, y solo después de haber incluido todos los encabezados.
Tenga en cuenta cómo algunos parámetros son constantes. Esto significa que no se pueden cambiar en el cuerpo de la función si lo intentamos. Daría un error de compilación. Además, tenga en cuenta que todos los parámetros de este archivo de origen se pasan por valor, no por referencia (&) ni por puntero (*). Esto significa que la persona que llama hará una copia de ellos y pasará a la función. Por lo tanto, no le importa a la persona que llama si son constantes o no, porque si los modificamos en el cuerpo de la función, solo modificaremos la copia, no el valor original que la persona que llama pasó a la función.
Dado que la constancia de un parámetro que se pasa por valor (copia) no importa para la persona que llama, no se altera en la firma de la función, como se puede ver después de compilar e inspeccionar el código del objeto (solo la salida 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> >)
Las firmas no expresan si los parámetros copiados son constantes o no en los cuerpos de la función. No importa. Solo importaba para la definición de la función, mostrar de un vistazo al lector del cuerpo de la función si esos valores alguna vez cambiarán. En el ejemplo, solo la mitad de los parámetros se declaran como const, por lo que podemos ver el contraste, pero si queremos ser constantes, deberían haberse declarado todos, ya que ninguno de ellos se modifica en el cuerpo de la función (y no debería).
Dado que no importa para la declaración de la función qué es lo que ve la persona que llama, podemos crear el encabezado by-value.hpp
esta manera:
#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);
Se permite agregar los calificadores const aquí (incluso puede calificar como variables const que no son const en la definición y funcionará), pero esto no es necesario y solo hará que las declaraciones sean innecesariamente detalladas.
Pasar por Referencia
Veamos 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 constancia al pasar por referencia es importante para la persona que llama, porque le dirá a la persona que llama si su argumento será modificado o no por la persona que llama. Por lo tanto, los símbolos se exportan con su constancia:
$ 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&)
Eso también debería reflejarse en el encabezado que usarán las personas que llaman:
#include <vector> int sum(const int&, int&); float sum(float&, const float&); int sum(const std::vector<int>&); float sum(const std::vector<float>&);
Tenga en cuenta que no escribí el nombre de las variables en las declaraciones (en el encabezado) como lo había estado haciendo hasta ahora. Esto también es legal, para este ejemplo y para los anteriores. No se requieren nombres de variables en la declaración, ya que la persona que llama no necesita saber cómo desea nombrar su variable. Pero los nombres de los parámetros son generalmente deseables en las declaraciones para que el usuario pueda saber de un vistazo qué significa cada parámetro y, por lo tanto, qué enviar en la llamada.
Sorprendentemente, los nombres de las variables tampoco son necesarios en la definición de una función. Solo son necesarios si realmente usa el parámetro en la función. Pero si nunca lo usa, puede dejar el parámetro con el tipo pero sin el nombre. ¿Por qué una función declararía un parámetro que nunca usaría? A veces, las funciones (o métodos) son solo parte de una interfaz, como una interfaz de devolución de llamada, que define ciertos parámetros que se pasan al observador. El observador debe crear una devolución de llamada con todos los parámetros que especifica la interfaz, ya que todos serán enviados por la persona que llama. Pero es posible que el observador no esté interesado en todos ellos, por lo que en lugar de recibir una advertencia del compilador sobre un "parámetro no utilizado", la definición de la función puede simplemente dejarlo sin nombre.
Pasar por puntero
// 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 un puntero a un elemento const (int en el ejemplo), puede declarar el tipo como cualquiera de los siguientes:
int const * const int *
Si también desea que el puntero sea constante, es decir, que el puntero no se pueda cambiar para que apunte a otra cosa, agregue una constante después de la estrella:
int const * const const int * const
Si desea que el puntero en sí sea constante, pero no el elemento al que apunta:
int * const
Compare las firmas de función con la inspección detallada del archivo de 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 puede ver, la herramienta nm
usa la primera notación (const después del tipo). Además, tenga en cuenta que la única constancia que se exporta, y que importa para la persona que llama, es si la función modificará o no el elemento señalado por el puntero. La constancia del puntero en sí es irrelevante para la persona que llama, ya que el puntero en sí siempre se pasa como una copia. La función solo puede hacer su propia copia del puntero para apuntar a otro lugar, lo cual es irrelevante para la persona que llama.
Entonces, un archivo de encabezado se puede crear 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);
Pasar por puntero es como pasar por referencia. Una diferencia es que cuando pasa por referencia se espera y se supone que la persona que llama ha pasado la referencia de un elemento válido, sin apuntar a NULL u otra dirección no válida, mientras que un puntero podría apuntar a NULL, por ejemplo. Se pueden usar punteros en lugar de referencias cuando pasar NULL tiene un significado especial.
Dado que los valores de C++ 11 también se pueden pasar con semántica de movimiento. Este tema no se tratará en este artículo, pero se puede estudiar en otros artículos como Argument Passing in C++.
Otro tema relacionado que no se tratará aquí es cómo llamar a todas esas funciones. Si todos esos encabezados se incluyen desde un archivo fuente pero no se llaman, la compilación y el enlace se realizarán correctamente. Pero si desea llamar a todas las funciones, habrá algunos errores porque algunas llamadas serán ambiguas. El compilador podrá elegir más de una versión de sum para ciertos argumentos, especialmente al elegir si pasar por copia o por referencia (o referencia constante). Ese análisis está fuera del alcance de este artículo.
Compilando con diferentes banderas
Veamos, ahora, una situación de la vida real relacionada con este tema donde pueden aparecer errores difíciles de encontrar.
Vaya al directorio cpp-article/diff-flags
y mire 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 clase tiene dos contadores, que comienzan en cero y se pueden incrementar o leer. Para compilaciones de depuración, que es como llamaré compilaciones donde la macro NDEBUG
no está definida, también agrego un tercer contador, que se incrementará cada vez que se incremente cualquiera de los otros dos contadores. Será una especie de asistente de depuración para esta clase. Muchas clases de bibliotecas de terceros o incluso encabezados de C++ incorporados (según el compilador) usan trucos como este para permitir diferentes niveles de depuración. Esto permite que las compilaciones de depuración detecten iteradores que se salen del rango y otras cosas interesantes en las que podría pensar el creador de la biblioteca. Llamaré a las compilaciones de lanzamiento "compilaciones donde se define la macro NDEBUG
".
Para las compilaciones de lanzamiento, el encabezado precompilado se ve así (uso grep
para eliminar las líneas en blanco):
$ 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; };
Mientras que para las compilaciones de depuración, se verá así:
$ 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; };
Hay un contador más en las compilaciones de depuración, como expliqué anteriormente.
También creé algunos archivos 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; }
Y un Makefile
que puede personalizar los indicadores del compilador solo 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
Entonces, vamos a compilarlo todo en modo de depuración, sin 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.o
Ahora ejecuta:
$ ./diff-flags c.get1(): 3 c.get2(): 4 c.getDebugAllCounters(): 7
La salida es tal como se esperaba. Ahora compilemos solo uno de los archivos con NDEBUG
definido, que sería el modo de lanzamiento, y veamos qué sucede:
$ 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.
Lecturas adicionales en el blog de ingeniería de Toptal:
- Cómo aprender los lenguajes C y C++: la lista definitiva
- C# frente a C++: ¿Qué hay en el núcleo?