¿Por qué no debo incluir archivos cpp y usar un encabezado?


147

Así que terminé mi primera tarea de programación en C ++ y recibí mi calificación. Pero según la clasificación, perdí marcas por including cpp files instead of compiling and linking them. No tengo muy claro qué significa eso.

Echando un vistazo a mi código, decidí no crear archivos de encabezado para mis clases, pero hice todo en los archivos cpp (parecía funcionar bien sin archivos de encabezado ...). Supongo que el calificador quiso decir que escribí '#include "mycppfile.cpp";' en algunos de mis archivos

Mi razonamiento para #includeincluir los archivos cpp fue: - Todo lo que se suponía que debía ir al archivo de encabezado estaba en mi archivo cpp, por lo que fingí que era como un archivo de encabezado. los archivos de encabezado estaban #includeen los archivos, así que hice lo mismo para mi archivo cpp.

Entonces, ¿qué hice mal exactamente y por qué es malo?


36
Esta es una muy buena pregunta. Espero que muchos novatos de C ++ reciban ayuda de esto.
Mia Clarke el

Respuestas:


175

Que yo sepa, el estándar C ++ no conoce la diferencia entre los archivos de encabezado y los archivos de origen. En lo que respecta al idioma, cualquier archivo de texto con código legal es el mismo que cualquier otro. Sin embargo, aunque no es ilegal, incluir archivos fuente en su programa eliminará prácticamente cualquier ventaja que hubiera tenido de separar sus archivos fuente en primer lugar.

Esencialmente, lo que #includehace es decirle al preprocesador que tome todo el archivo que ha especificado y lo copie en su archivo activo antes de que el compilador lo tenga en sus manos. Por lo tanto, cuando incluye todos los archivos de origen en su proyecto juntos, básicamente no hay diferencia entre lo que ha hecho, y solo hacer un gran archivo de origen sin ninguna separación.

"Oh, eso no es gran cosa. Si funciona, está bien", te escucho llorar. Y en cierto sentido, estarías en lo correcto. Pero en este momento se trata de un pequeño programa muy pequeño y una CPU agradable y relativamente libre de obstáculos para compilarlo por usted. No siempre tendrás tanta suerte.

Si alguna vez profundiza en los ámbitos de la programación informática seria, verá proyectos con recuentos de líneas que pueden llegar a millones, en lugar de docenas. Eso son muchas líneas. Y si intentas compilar uno de estos en una computadora de escritorio moderna, puede tomar algunas horas en lugar de segundos.

"¡Oh, no! ¡Eso suena horrible! Sin embargo, ¿puedo evitar este terrible destino?" Desafortunadamente, no hay mucho que puedas hacer al respecto. Si lleva horas compilar, lleva horas compilar. Pero eso solo importa la primera vez: una vez que lo ha compilado una vez, no hay razón para compilarlo nuevamente.

A menos que cambies algo.

Ahora, si tenía dos millones de líneas de código fusionadas en un gigante gigante, y necesita hacer una solución simple de error, como, por ejemplo x = y + 1, eso significa que debe compilar los dos millones de líneas nuevamente para probar esto. Y si descubre que tenía la intención de hacer una x = y - 1en su lugar, nuevamente, le esperan dos millones de líneas de compilación. Son muchas horas de tiempo desperdiciadas que podrían gastarse mejor haciendo cualquier otra cosa

"¡Pero odio ser improductivo! ¡Ojalá hubiera alguna forma de compilar partes distintas de mi base de código individualmente, y de alguna manera vincularlas después!" Una excelente idea, en teoría. Pero, ¿qué pasa si su programa necesita saber qué está pasando en un archivo diferente? Es imposible separar completamente su base de código a menos que desee ejecutar un montón de pequeños archivos .exe pequeños.

"¡Pero seguramente debe ser posible! ¡La programación suena como pura tortura de otra manera! ¿Qué pasa si encuentro alguna forma de separar la interfaz de la implementación ? Digamos tomando la información suficiente de estos segmentos de código distintos para identificarlos en el resto del programa, y ​​colocando en lugar de eso, ¿ algún tipo de archivo de encabezado ? Y de esa manera, ¡puedo usar la #include directiva de preprocesador para traer solo la información necesaria para compilar! "

Hmm Puede que tengas algo allí. Permíteme saber como funciona para tí.


13
Buena respuesta, señor. Fue una lectura divertida y fácil de entender. Desearía que mi libro de texto fuera escrito así.
ialm

@veol Search for Head Primera serie de libros, aunque no sé si tienen una versión C ++ headfirstlabs.com
Amarghosh

2
Esta es (definitivamente) la mejor redacción hasta la fecha que he escuchado o contemplado. Justin Case, un principiante consumado, logró un proyecto con un millón de pulsaciones de teclas, aún no enviado y un "primer proyecto" encomiable que está viendo la luz de la aplicación en una base real de usuarios, ha reconocido un problema abordado por los cierres. Suena notablemente similar a los estados avanzados de la definición original del problema de OP menos el "codificado esto casi cien veces y no puedo imaginar qué hacer para nulo (como ningún objeto) vs nulo (como sobrino) sin usar la programación por excepciones".
Nicholas Jordan

Por supuesto, todo esto se desmorona para las plantillas porque la mayoría de los compiladores no admiten / implementan la palabra clave 'exportar'.
KitsuneYMG

1
Otro punto es que tienes muchas bibliotecas de última generación (si piensas en BOOST) que usa solo encabezados de clases ... Ho, espera? ¿Por qué el programador experimentado no separa la interfaz de la implementación? Parte de la respuesta puede ser lo que dijo Blindly, otra parte puede ser que un archivo es mejor que dos cuando es posible, y otra parte es que la vinculación tiene un costo que puede ser bastante alto. He visto que los programas se ejecutan diez veces más rápido con la inclusión directa de la fuente y la optimización del compilador. Porque vincular principalmente bloques de optimización.
kriss

45

Esta es probablemente una respuesta más detallada de lo que quería, pero creo que una explicación decente está justificada.

En C y C ++, un archivo fuente se define como una unidad de traducción . Por convención, los archivos de encabezado contienen declaraciones de funciones, definiciones de tipo y definiciones de clase. Las implementaciones de funciones reales residen en unidades de traducción, es decir, archivos .cpp.

La idea detrás de esto es que las funciones y las funciones miembro de clase / estructura se compilan y ensamblan una vez, luego otras funciones pueden llamar a ese código desde un lugar sin hacer duplicados. Sus funciones se declaran como "externas" implícitamente.

/* Function declaration, usually found in headers. */
/* Implicitly 'extern', i.e the symbol is visible everywhere, not just locally.*/
int add(int, int);

/* function body, or function definition. */
int add(int a, int b) 
{
   return a + b;
}

Si desea que una función sea local para una unidad de traducción, debe definirla como 'estática'. ¿Qué significa esto? Significa que si incluye archivos de origen con funciones externas, obtendrá errores de redefinición, porque el compilador se encuentra con la misma implementación más de una vez. Por lo tanto, desea que todas sus unidades de traducción vean la declaración de la función pero no el cuerpo de la función .

Entonces, ¿cómo se mezcla todo al final? Ese es el trabajo del enlazador. Un vinculador lee todos los archivos de objetos generados por la etapa de ensamblador y resuelve los símbolos. Como dije antes, un símbolo es solo un nombre. Por ejemplo, el nombre de una variable o una función. Cuando las unidades de traducción que llaman funciones o declaran tipos no conocen la implementación de esas funciones o tipos, se dice que esos símbolos no están resueltos. El enlazador resuelve el símbolo no resuelto conectando la unidad de traducción que contiene el símbolo indefinido junto con el que contiene la implementación. Uf. Esto es cierto para todos los símbolos visibles desde el exterior, ya sea que estén implementados en su código o proporcionados por una biblioteca adicional. Una biblioteca es realmente solo un archivo con código reutilizable.

Hay dos excepciones notables. Primero, si tiene una función pequeña, puede hacerla en línea. Esto significa que el código de máquina generado no genera una llamada de función externa, sino que literalmente se concatena in situ. Como generalmente son pequeños, el tamaño de la sobrecarga no importa. Puedes imaginar que sean estáticos en la forma en que funcionan. Por lo tanto, es seguro implementar funciones en línea en los encabezados. Las implementaciones de funciones dentro de una definición de clase o estructura también son incorporadas automáticamente por el compilador.

La otra excepción son las plantillas. Dado que el compilador necesita ver la definición completa del tipo de plantilla al instanciarlos, no es posible desacoplar la implementación de la definición como con funciones independientes o clases normales. Bueno, tal vez esto sea posible ahora, pero obtener un amplio soporte del compilador para la palabra clave "exportar" tomó mucho, mucho tiempo. Entonces, sin soporte para 'exportar', las unidades de traducción obtienen sus propias copias locales de tipos y funciones con plantilla instanciadas, de forma similar a cómo funcionan las funciones en línea. Con soporte para 'exportar', este no es el caso.

Para las dos excepciones, algunas personas encuentran "más agradable" poner las implementaciones de funciones en línea, funciones con plantillas y tipos con plantillas en archivos .cpp y luego #incluir el archivo .cpp. Si esto es un encabezado o un archivo fuente realmente no importa; Al preprocesador no le importa y es solo una convención.

Un resumen rápido de todo el proceso desde el código C ++ (varios archivos) hasta un ejecutable final:

  • Se ejecuta el preprocesador , que analiza todas las directivas que comienzan con un '#'. La directiva #include concatena el archivo incluido con inferior, por ejemplo. También hace macro-reemplazo y token-pegado.
  • El compilador real se ejecuta en el archivo de texto intermedio después de la etapa de preprocesador y emite el código del ensamblador.
  • El ensamblador se ejecuta en el archivo de ensamblaje y emite código de máquina, generalmente se denomina archivo de objeto y sigue el formato ejecutable binario del sistema operativo en cuestión. Por ejemplo, Windows usa el PE (formato ejecutable portátil), mientras que Linux usa el formato Unix System V ELF, con extensiones GNU. En esta etapa, los símbolos todavía están marcados como indefinidos.
  • Finalmente, se ejecuta el enlazador . Todas las etapas anteriores se ejecutaron en cada unidad de traducción en orden. Sin embargo, la etapa de enlace funciona en todos los archivos de objetos generados que fueron generados por el ensamblador. El enlazador resuelve símbolos y hace mucha magia, como crear secciones y segmentos, que depende de la plataforma de destino y el formato binario. Los programadores no están obligados a saber esto en general, pero seguramente ayuda en algunos casos.

Nuevamente, esto fue definitivamente más de lo que pediste, pero espero que los detalles esenciales te ayuden a ver la imagen más grande.


2
Gracias por tu explicación detallada. Lo admito, aún no tiene sentido para mí, y creo que tendré que volver a leer tu respuesta una y otra vez.
ialm el

1
+1 para una excelente explicación. Lástima que probablemente asustará a todos los novatos de C ++. :)
goldPseudo

1
Je, no te sientas mal veol. En Stack Overflow, la respuesta más larga rara vez es la mejor respuesta.

int add(int, int);Es una declaración de función . La parte prototipo es justa int, int. Sin embargo, todas las funciones en C ++ tienen un prototipo, por lo que el término realmente solo tiene sentido en C. He editado su respuesta a este efecto.
melpomene

exportpara plantillas se ha eliminado del lenguaje en 2011. Nunca fue realmente compatible con los compiladores.
melpomene

10

La solución típica es usar .harchivos solo para declaraciones y .cpparchivos para implementación. Si necesita reutilizar la implementación, incluya el .harchivo correspondiente en el .cpparchivo donde se usa la clase / función / lo que sea necesario y enlace con un .cpparchivo ya compilado (ya sea un .objarchivo, generalmente usado dentro de un proyecto) o un archivo .lib, generalmente usado para reutilizar desde múltiples proyectos). De esta manera, no necesita recompilar todo si solo cambia la implementación.


6

Piense en los archivos cpp como un cuadro negro y los archivos .h como guías sobre cómo usar esos cuadros negros.

Los archivos cpp pueden compilarse con anticipación. Esto no funciona en usted # inclúyalos, ya que debe "incluir" el código en su programa cada vez que lo compila. Si solo incluye el encabezado, puede usar el archivo de encabezado para determinar cómo usar el archivo cpp precompilado.

Aunque esto no hará una gran diferencia para su primer proyecto, si comienza a escribir grandes programas cpp, la gente lo odiará porque los tiempos de compilación van a explotar.

También lea esto: el archivo de encabezado incluye patrones


Gracias por el ejemplo más concreto. Intenté leer tu enlace, pero ahora estoy confundido ... ¿cuál es la diferencia entre incluir un encabezado explícitamente y una declaración de reenvío?
ialm el

Este es un gran artículo. Veol, aquí se incluyen encabezados donde el compilador necesita información sobre el tamaño de la clase. La declaración directa se usa cuando solo usa punteros.
pankajt el

declaración hacia adelante: int someFunction (int neededValue); observe el uso de información de tipo y (usualmente) sin llaves. Esto, como se indica, le dice al compilador que en algún momento necesitará una función que tome un int y devuelva un int, el compilador puede reservar una llamada para él utilizando esta información. Eso se llamaría una declaración adelantada. Se supone que los compiladores más sofisticados pueden encontrar la función sin necesidad de esto, incluido un encabezado puede ser una forma práctica de declarar un montón de declaraciones directas.
Nicholas Jordan

6

Los archivos de encabezado generalmente contienen declaraciones de funciones / clases, mientras que los archivos .cpp contienen las implementaciones reales. En el momento de la compilación, cada archivo .cpp se compila en un archivo de objeto (generalmente extensión .o), y el vinculador combina los diversos archivos de objeto en el ejecutable final. El proceso de vinculación es generalmente mucho más rápido que la compilación.

Beneficios de esta separación: si está volviendo a compilar uno de los archivos .cpp en su proyecto, no tiene que volver a compilar todos los demás. Simplemente cree el nuevo archivo de objeto para ese archivo .cpp en particular. El compilador no tiene que mirar los otros archivos .cpp. Sin embargo, si desea llamar a funciones en su archivo .cpp actual que se implementaron en los otros archivos .cpp, debe decirle al compilador qué argumentos toman; ese es el propósito de incluir los archivos de encabezado.

Desventajas: al compilar un archivo .cpp determinado, el compilador no puede "ver" lo que hay dentro de los otros archivos .cpp. Por lo tanto, no sabe cómo se implementan las funciones allí y, como resultado, no puede optimizar tan agresivamente. Pero creo que todavía no necesita preocuparse por eso (:


5

La idea básica de que los encabezados solo se incluyen y los archivos cpp solo se compilan. Esto será más útil una vez que tenga muchos archivos cpp, y volver a compilar toda la aplicación cuando modifique solo uno de ellos será demasiado lento. O cuando las funciones en los archivos comenzarán dependiendo una de la otra. Por lo tanto, debe separar las declaraciones de clase en sus archivos de encabezado, dejar la implementación en archivos cpp y escribir un Makefile (u otra cosa, dependiendo de las herramientas que esté utilizando) para compilar los archivos cpp y vincular los archivos de objetos resultantes en un programa.


3

Si #incluye un archivo cpp en varios otros archivos de su programa, el compilador intentará compilar el archivo cpp varias veces y generará un error, ya que habrá múltiples implementaciones de los mismos métodos.

La compilación tomará más tiempo (lo que se convierte en un problema en proyectos grandes), si realiza ediciones en # archivos incluidos de cpp, que luego fuerzan la recompilación de cualquier archivo # incluyéndolos.

Simplemente coloque sus declaraciones en archivos de encabezado e inclúyalas (ya que en realidad no generan código per se), y el vinculador conectará las declaraciones con el código cpp correspondiente (que luego solo se compila una vez).


Entonces, además de tener tiempos de compilación más largos, comenzaré a tener problemas cuando #incluya mi archivo cpp en muchos archivos diferentes que usan las funciones en los archivos cpp incluidos.
ialm el

Sí, esto se llama colisión de espacio de nombres. De interés aquí es si el enlace contra libs introduce problemas de espacio de nombres. En general, encuentro que los compiladores producen mejores tiempos de compilación para el alcance de la unidad de traducción (todo en un archivo) que introduce problemas de espacio de nombres, lo que lleva a la separación de nuevo ... puede incluir el archivo de inclusión en cada unidad de traducción, (se supone que) incluso hay un pragma (#pragma una vez) que se supone que aplica esto, pero ese es un supuesto de supositorio. Tenga cuidado de no confiar ciegamente en libs (archivos .O) desde cualquier lugar ya que los enlaces de 32 bits no se aplican.
Nicholas Jordan

2

Si bien es ciertamente posible hacer lo que hizo, la práctica estándar es colocar declaraciones compartidas en archivos de encabezado (.h) y definiciones de funciones y variables (implementación) en archivos de origen (.cpp).

Como convención, esto ayuda a aclarar dónde está todo y hace una clara distinción entre la interfaz y la implementación de sus módulos. También significa que nunca tendrá que verificar si un archivo .cpp está incluido en otro, antes de agregarle algo que podría romperse si se definiera en varias unidades diferentes.


2

reutilización, arquitectura y encapsulación de datos

Aquí hay un ejemplo:

supongamos que crea un archivo cpp que contiene una forma simple de rutinas de cadena, todo en una clase mystring, coloca la clase decl para esto en un mystring.h compilando mystring.cpp en un archivo .obj

ahora en su programa principal (por ejemplo, main.cpp) incluye encabezado y enlace con mystring.obj. para usar mystring en su programa no le importan los detalles de cómo se implementa mystring ya que el encabezado dice lo que puede hacer

ahora, si un amigo quiere usar tu clase de mystring, le das mystring.h y mystring.obj, él tampoco necesariamente necesita saber cómo funciona mientras funcione.

más tarde, si tiene más archivos .obj, puede combinarlos en un archivo .lib y vincularlos a ellos.

También puede decidir cambiar el archivo mystring.cpp e implementarlo de manera más efectiva, esto no afectará su main.cpp o su programa de amigos.


2

Si funciona para usted, entonces no tiene nada de malo, excepto que revolverá las plumas de las personas que piensan que solo hay una manera de hacer las cosas.

Muchas de las respuestas dadas aquí abordan optimizaciones para proyectos de software a gran escala. Estas son cosas buenas que debe saber, pero no tiene sentido optimizar un proyecto pequeño como si fuera un proyecto grande, eso es lo que se conoce como "optimización prematura". Dependiendo de su entorno de desarrollo, puede haber una complejidad adicional significativa involucrada en la configuración de una configuración de compilación para admitir múltiples archivos fuente por programa.

Si, con el tiempo, su proyecto se desarrolla y se encuentran que el proceso de construcción está tomando demasiado tiempo, entonces se puede refactorizar el código para utilizar varios archivos de origen para construye más rápido incremento.

Varias de las respuestas analizan la separación de la interfaz de la implementación. Sin embargo, esta no es una característica inherente de los archivos de inclusión, y es bastante común #incluir archivos de "encabezado" que incorporan directamente su implementación (incluso la Biblioteca estándar de C ++ lo hace en un grado significativo).

Lo único realmente "poco convencional" sobre lo que ha hecho fue nombrar sus archivos incluidos ".cpp" en lugar de ".h" o ".hpp".


1

Cuando compila y vincula un programa, el compilador primero compila los archivos cpp individuales y luego los vincula (conecta). Los encabezados nunca se compilarán, a menos que se incluyan primero en un archivo cpp.

Normalmente, los encabezados son declaraciones y cpp son archivos de implementación. En los encabezados, define una interfaz para una clase o función, pero omite cómo implementa los detalles. De esta manera, no tiene que volver a compilar todos los archivos cpp si realiza un cambio en uno.


si deja la implementación fuera del archivo de encabezado, discúlpeme, pero eso me parece una interfaz Java, ¿verdad?
gansub

1

Le sugeriré que siga el Diseño de software C ++ a gran escala de John Lakos . En la universidad, usualmente escribimos pequeños proyectos en los que no encontramos tales problemas. El libro destaca la importancia de separar las interfaces y las implementaciones.

Los archivos de encabezado generalmente tienen interfaces que se supone que no deben cambiarse con tanta frecuencia. Del mismo modo, una mirada a patrones como la expresión de Virtual Constructor lo ayudará a comprender el concepto más a fondo.

Todavía estoy aprendiendo como tú :)


Gracias por la sugerencia del libro. No sé si alguna vez llegar a la etapa de hacer a gran escala programas en C ++, aunque ...
IALM

Es divertido codificar programas a gran escala y para muchos un desafío. Estoy empezando a
gustarme

1

Es como escribir un libro, desea imprimir capítulos terminados solo una vez

Digamos que estás escribiendo un libro. Si coloca los capítulos en archivos separados, solo necesita imprimir un capítulo si lo ha cambiado. Trabajar en un capítulo no cambia ninguno de los otros.

Pero incluir los archivos cpp es, desde el punto de vista del compilador, como editar todos los capítulos del libro en un solo archivo. Luego, si lo cambia, debe imprimir todas las páginas de todo el libro para poder imprimir su capítulo revisado. No existe la opción "imprimir páginas seleccionadas" en la generación de código objeto.

Volver al software: tengo Linux y Ruby src por ahí. Una medida aproximada de líneas de código ...

     Linux       Ruby
   100,000    100,000   core functionality (just kernel/*, ruby top level dir)
10,000,000    200,000   everything 

Cualquiera de esas cuatro categorías tiene mucho código, de ahí la necesidad de modularidad. Este tipo de código base es sorprendentemente típico de los sistemas del mundo real.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.