Estructura del código fuente en C++

Estructura del código fuente en C++
Sin comentarios Facebook Twitter Flipboard E-mail

Si bien un programa escrito en C++ se podría hacer en un único fichero de texto, cualquier proyecto serio requiere que el código fuente de un programa se divida en varios ficheros para que sea manejable, muchos principiantes no se dan cuenta de la importancia de esto, sobre todo porque mucho lo han intentado y les ha dado más problemas que soluciones. En este artículo vamos a explicar como definir la estructura del código fuente en C++.

Archivos de cabecera y archivos fuentes

En C++ existen principalmente dos tipos de ficheros, los de cabecera (normalmente .h o .hpp) y los ficheros fuentes (normalmente con extensión .cpp). La diferencia entre ellos es puramente conceptual, ambos son ficheros de texto plano el compilador no distingue entre uno y otro, esto lo hacemos los programadores.

¿Por qué dividir el código fuente?

Es la primera pregunta que se hacen los programadores principiantes, no entienden el sentido de tener un programa en varios archivos. Vamos a ver las ventajas de esto.

  • Compilación más eficiente. Si tu tienes un fichero fuente con 10.000 líneas de código y haces una pequeña modificación tendrás que recompilar las 10.000 líneas de código. Sin embargo, si tienes 10 ficheros de 1.000 líneas de código si haces una pequeña modificación en cualquiera de ellos solo deberás recompilar ese fichero ahorrando de recompilar 9.000 líneas de código fuente.

  • Organización. Imagina que estas creando un videojuego y tienes un solo archivo llamado juego.cpp con todas las clases, estructuras y variables, deberás buscar a lo largo de todo el archivo si decides que quieres modifica una parte. Ahora piensa que has dividido tu juego en los archivos main.cpp graficos.cpp audio.cpp y entrada.cpp si quieres modificar alo relacionado con los gráficos sabes que debes buscar en el fichero graficos.cpp reduciendo la búsqueda considerablemente.

  • Facilitar la reutilización. Imagina que estás trabajando con fracciones en tu programa y decides crear una clase Fracción para el manejo de fracciones, la pruebas la testeas y compruebas que funciona. Si en el futuro desarrollas una aplicación que necesite el uso de fracciones solo deberás recuperar tu fichero .h y .cpp asociado a la clase fracción para implementar la funcionalidad en tu nuevo proyecto.

  • Compartir código en varios proyectos. Este punto es similar al anterior, pero hay veces en las que más de un proyecto utilizan el mismo código, sería genial incluir el mismo el ambos proyectos y si se modifica este código automáticamente se modifique en ambos proyectos (es la esencia de las bibliotecas).

  • Dividir tareas entre programadores. Si tenemos un equipo de programadores y queremos que trabajen a la vez en un mismo proyecto solo debemos asignar un fichero a cada uno y luego juntarlos, sin que se pisen unos a otros (Este es el principio de la Programación Orientada a Objetos y la división en clases independientes unas de otras).

Espero haberte convencido de lo necesario que es dividir el código fuente en varios archivos, si es así, sigue leyendo y te daré algunas pautas de como hacerlo.

Cómo dividir el código fuente de un programa C++

La mayoría de las reglas a seguir son lógicas y un tanto arbitrarias, pero se presupone buen sentido al programador. Lo ideal es dividir por módulos según que hace que, si tienes un juego que tiene una clase encargada de la entrada de datos, otra de los gráficos y otra del audio lo lógico es hacer la división en estos módulos. Una buena idea es tener un par de ficheros por clase MiClase.h y MiClase.cpp

A veces existe la problemática de no saber en que fichero o módulo meter una parte del código (Un ingeniero de software probablemente te diría que es porque lo has diseñado mal).

Una vez hecha la división lógica por funcionalidad queda ver que va en el archivo de cabecera y que en el archivo de código fuente. Lo que suele estar en la parte superior es un serio candidato a estar en el archivo de cabecera, de ahí su nombre. Los archivos de cabecera suelen incluir todos o algunos de los siguientes elementos.

  • Definición de estructuras y clases.

  • Definición de tipos (typedef).

  • Prototipos de funciones.

  • Variables Globales (ver más adelante).

  • Constantes

  • Macros #define.

  • Directivas #pragma.

Además se deben incluir plantillas y funciones en línea, como veremos más adelante en este artículo.

Por ejemplo, mira cualquier biblioteca estándar que venga con el compilador que uses. stdlib.h es un buen ejemplo. Te darás cuenta que tiene algunas de las cosas mencionadas en la lista de antes. Del mismo modo puedes ver que las variables globales están precedidas por el modificador extern, esto es importante, pero lo veremos más adelante.

En general todo lo que son las definiciones deben de ir en los ficheros de cabecera y todo lo que son las implementaciones en los ficheros de código fuente, así que lo normal es tener un archivo .h y otro .cpp con nombres iguales por ejemplo Sprite.h y Sprite.cpp en uno se definen Clases, funciones, estructuras, etc. y en el otro se implementan.

Recuerda que ambos son ficheros de texto en el fondo podrías meter lo que quisieras en cada uno, la organización y lógica corresponde al programador.

Problemas al dividir el código

No todo podía ser maravilloso, al separar el código en varios archivos surgen algunas complicaciones que suelen tirar para atrás a los programadores novatos, vamos a repasarlas y buscarles solución.

  • Un archivo fuente de repente sin tocarlo deja de compilar porque no encuentra la definición de una clase o función. (Esto a menudo se manifiesta en forma de algo parecido a un ?error C2065: ?MyStruct?: identificador no declarado?. En Visual C++, aunque esto puede producir cualquier número de mensajes de error diferentes en función de exactamente lo que están tratando de referencia.

  • Dependencias cíclicas. El archivo de cabecera ClaseA depende de ClaseB y a su vez ClaseB depende de ClaseA, incluyas cual incluyas primero el otro aún no se ha definido.

  • Duplicar las definiciones. Imagina que tienes un archivo llamado map.h que incluye los archivos sprite.h y hero.h, a su vez hero.h incluye sprite.h por lo que sprite.h se incluiría dos veces en map.h, provocando un error.

  • Duplicado las instancias de los objetos dentro del código que compila bien. Este es un error vincular, a menudo difícil de entender.

Bueno una vez definidos de manera general vamos a definirlos algo mejor y buscarles una solucion.

Eliminación accidental de cabeceras

Imagina que tenemos los siguientes archivos con el siguiente código:

/* Header1.h */
#include "header2.h"
class ClaseA { ... };
/* ---------- */
/* Header2.h */
class ClaseB { ... };
/* File1.cpp */
/* ---------- */
#include "Header1.h"
ClaseA miClaseA_instance;
ClaseB miClaseB_instance;

File1.cpp incluye a Header1.h que a su vez incluye a Header2.h por lo que en File1.cpp quedan definidas ambas cabeceras y puedes crear objetos definidos en ambos ficheros. De repente un día decides que Header1.h ya no necesita incluir a Header2.h cuando trates de compilar File1.cpp solo incluirá a Header1.h porque Header2.h ya no está incluido en este produciendo error al no encontrar los elementos definidos en Header2.h

La solución a este problema es muy simple, incluir siempre explícitamente todas las cabeceras necesarias en cada archivo y no suponer que ya otro fichero la incluye. Así en File1.cpp debemos incluir tanto Header1.h y Header2.h así si por alguna razón Header1.h deja de incluir a Header2.h no afectará ha File1.cpp

Dependencias cíclicas

El problema común de archivos que depende de entre sí, veamos el siguiente código.

/* Parent.h */
#include "Child.h"
class Parent
{
    Child* elChild;
};
/* ---------- */
/* Child.h */
#include "Parent.h"
class Child
{
    Parent* elParent;
};

Para definir la clase Parent se necesita tener primero definida la clase Child y para poder definir la clase Child se necesita tener definida previamente la clase Parent por lo que nos encontramos en un bonito bucle. La solución son las declaraciones adelantadas (forward declaration).

Como tanto la clase Parent como la clase Child lo único que tienen de la otra clase es un puntero de referencia y los punteros no necesitas saber toda la estructura de la clase con hacer lo siguiente vale.

/* Parent.h */
class Child; /* Forward declaration de Child; */
class Parent
{
    Child* elChild;
};

Como vemos sustituimos el #include “Child.h” por su declaración adelantada, esto es válido ya que el compilador solo necesita saber en la definición que hay un tipo llamado Child ya que solo va a almacenar una dirección de memoria no necesita saber como es el tipo.

Por supuesto, en los archivos fuentes, habrá funciones que se aplican a Parent que modifican también a Child. Por lo tanto es probable que haya que incluir Parent.h y Child.h tanto en Parent.cpp como en Child.cpp.

Dependencia cíclicas

Duplicado de definiciones

Duplicar definiciones en tiempo de compilación significa que un archivo de cabecera terminó incluyéndose más de una vez en un archivo en particular. Esto lleva a que una clase o estructura se define más de una vez, causando error. Lo primero que debes hacer es asegurarte de que se incluyen solo los archivos necesarios para ese código fuente en particular y quitar todo lo que no utilice.

Lamentablemente, esto rara vez es suficiente, ya que algunas cabeceras se incluyen otras cabeceras. Vamos a revisar un ejemplo.

/* Header1.h */
#include "header3.h"
class ClassOne { ... };
/* ---------- */
/* Header2.h */
#include "header3.h"
class ClassTwo { ... };
/* ---------- */
/* File1.cpp */
#include "Header1.h"
#include "Header2.h"
ClassOne myClassOne_instance;
ClassTwo myClassTwo_instance;

Por alguna razón tanto Header1.h como Header2.h incluyen a Header3.h, quizás ClassOne y ClassTwo se compenen con funciones de Header3.h. La razón no es importante, pero muchas veces sucede casos como estos en los que al final en File1.cpp se acaba incluyendo dos veces el mismo archivo incluso sin haber una #include a él en el mismo archivo, recuerda que la directiva #include lo que hace es, antes de compilar, copiar todo el contenido del archivo incluido en el archivo actual. así que File1.cpp quedaría de la siguiente manera.

Archivos de inclusión

A los efectos de la compilación, File1.cpp termina con copias de Header1.h y Header2.h, los cuales incluyen sus propias copias de Header3.h. El archivo resultante, con todas las cabeceras de expansión en línea en su archivo original, se conoce como una unidad de traducción. Debido a esta expansión en línea, todo lo declarado en Header3.h va a aparecer dos veces en esta unidad de traducción, causando un error.

Así que, ¿qué hacer? No se puede hacer sin Header1.h o Header2.h, ya que se necesita para acceder a las estructuras declaradas en su interior lo que necesita alguna forma de asegurar que, no importa qué, Header3.h no va a aparecer dos veces en el File1.cpp cuando se compila.

Si se miraba stdlib.h antes, te habrás dado cuenta las líneas en la parte superior similar a lo siguiente:

#ifndef _INC_STDLIB
#define _INC_STDLIB

Y en la parte inferior del archivo, algo así como:

#endif  /* _INC_STDLIB */

Esto es lo que se conoce como un “inclusion guard”. Viene a decir que si no está definido _INC_STDLIB defínelo, sino, ve a #endif sería similar al código cuando quieres que algo se ejecute solo una vez.

Durante la compilación de File1.cpp, la primera vez que pides que se incluya el archivo stdlib.h, llega a la línea #ifndef y continúa porque “_INC_STDLIB” aún no está definida. La siguiente línea define ese símbolo y lleva a cabo la lectura en stdlib.h. Si hay otra “#include” durante la compilación de File1.cpp, leerá el cheque #ifndef y luego salta a #endif al final del archivo. Esto se debe a todo lo que entre el #ifndef y #endif se ejecuta sólo si “_INC_STDLIB” no está definido, y que se definió la primera vez que lo incluye. De esta manera, se garantiza que las definiciones en stdlib.h sólo son cada vez incluye una vez al ponerlos dentro de #ifndef / #endif.

Esto es trivial para aplicar en tus propios proyectos. Al comienzo de cada archivo de cabecera escribe lo siguiente:

#ifndef INC_FILENAME_H
#define INC_FILENAME_H

Ten en cuenta que el símbolo (en este caso, “INC_FILENAME_H”) tiene que ser único en todo el proyecto. Es por esto que es una buena idea de incorporar el nombre del archivo en el símbolo. No agregue un guión bajo al principio como stdlib.h tiene como identificadores precedidos por un guión bajo se supone que son reservados para “la aplicación” (es decir, el compilador, las librerías estándar, y así sucesivamente). Luego se agrega el #endif / * INC_FILENAME_H * / al final del archivo. El comentario no es necesario, pero te ayudará a recordar a que pertenece ese #endif.

Duplicado de objetos o tipos globales

Cuando el enlazador trata de crear un archivo ejecutable (o biblioteca) de tu código lo que hace es meterlo todo en un archivo objeto (.obj o .o), uno por cada archivo de código fuente y los une. El trabajo principal del enlazador es resolver los identificadores (básicamente, las variables o los nombres de funciones) y convertirlas en direcciones máquina en el archivo final. El problema surge cuando el enlazador encuentra dos instancias o más de ese identificador en los archivos objetos, entonces no se puede determinar cual es el “correcto” para su uso. El identificador debe ser único para evitar cualquier ambigüedad, Así que ¿Cómo es que el compilador no ve que hay un identificador duplicado y el enlazador si lo ve?

Imagina el siguiente código:

/* Header.h */
#ifndef INC_HEADER_H
#define INC_HEADER_H
int my_global;
#endif /* INC_HEADER_H */
/* ---------- */
/* code1.cpp */
#include "header1.h"
void DoSomething()
{
    ++my_global;
}
/* ---------- */
/* code2.cpp */
#include "header1.h"
void DoSomethingElse()
{
    --my_global;
}

La primera se compila en dos archivos objeto, probablemente llamado code1.obj y code2.obj. Recuerde que una unidad de traducción contiene una copia completa de todos los encabezados incluidos en el archivo que está compilando. Finalmente, los archivos de objetos se combinan para producir el archivo final.

Aquí hay una representación visual de la forma en que estos archivos (y su contenido) se combinan:

Linker

Nota que hay dos copias de “my_global” en ese bloque final. Aunque “my_global” fue único para cada unidad de traducción (esto sería garantizada por el uso de los guardias de inclusión), que combina los archivos objeto generados por cada unidad de traducción se traduciría en que haya más de una instancia de my_global en el archivo. Esto se marca como un error, ya que el enlazador no tiene manera de saber si estos dos identificadores son en realidad una misma, o si uno de ellos era mal llamado justa y que se supone en realidad que es de 2 variables independientes. Así que hay que arreglarlo.

La respuesta no consiste en definir las variables o funciones en los archivos de cabecera en lugar de los archivos fuentes, donde estás seguro que se compilan solo una vez. Esto trae un nuevo problema, ¿Cómo hacer las funciones y variables visibles globalmente si no se encuentra en un archivo de cabecera? ¿De qué manera la pueden “ver” otros archivos? La respuesta es declararlas en los archivos de cabecera, pero no definirlas. Esto permite al compilador saber que la función o variable existe, pero delega el acto de de asignarle una dirección al enlazador.

Para hacer esto para una variable, se añade extern la palabra clave antes de su nombre:

extern int my_global;

El especificador extern es como decirle al compilador que esperar hasta el tiempo de enlace para resolver la “conexión”. Y para una función, simplemente hay que poner el prototipo de función:

int SomeFunction(int parameter);

Esto es así porque por defecto todas las funciones se consideran extern.

Por supuesto, estas son sólo las declaraciones de que my_global y SomeFunction existen en alguna parte. En realidad, no los creas. Todavía tiene que hacer esto en uno de los archivos de origen, de lo contrario aparecerá un error de vinculador nuevo cuando se descubre que no puede resolver uno de los identificadores a una dirección real. Así que para este ejemplo, tendría que añadir “int my_global” a cualquiera de Code1.cpp o Code2.cpp, y todo debería funcionar bien. Si se trata de una función, deberá añadir la función como su cuerpo (es decir, el código de la función) en uno de los archivos de origen.

La regla aquí es recordar que los archivos de cabecera define una interfaz, no una implementación. En ellas se indica que las funciones, variables y objetos existen, pero no se hace responsable de su creación. Ellos pueden decir lo que una estructura o clase debe contener, pero en realidad no debe crear instancias de esa estructura o clase. Se puede especificar los parámetros de una función y se lo devuelve, pero no cómo se obtiene el resultado. Y así sucesivamente. Esta es la razón por la que la lista de lo que puede entrar en un fichero de cabecera al principio de este artículo es importante.

Hay dos notables excepciones a no incluir el cuerpo de las funciones en los archivos de cabecera:

La primera excepción es la de funciones de la plantilla. La mayoría de los compiladores y enlazadores no puede manejar plantillas se definen en archivos diferentes a la que se utilizan en, por lo que las plantillas casi siempre es necesario definir en un encabezado para que la definición se puede incluir en todos los archivos que necesita para usarlo. Debido a la forma de plantillas se crea una instancia en el código, esto no conduce a los mismos errores que se podrían obtener mediante la definición de una función normal en un encabezado. Esto es así porque las plantillas no se compilan en el lugar de la definición, sino que se compilan, ya que son utilizados por el código en otros lugares.

La segunda excepción es las funciones en línea, mencionó brevemente antes. Una función en línea se ha compilado en el código, en vez de llamadas de la forma habitual. Esto significa que cualquier unidad de traducción que ‘llama “el código de una función en línea tiene que ser capaz de ver el funcionamiento interno (es decir, la puesta en práctica) de esa función con el fin de insertar el código de esa función directamente. Esto significa que un prototipo de función sencilla no es suficiente para llamar a la función en línea, lo que significa que donde quiera que normalmente sólo tiene que utilizar un prototipo de función, se necesita el cuerpo de la función general de una función en línea. Al igual que con las plantillas, esto no causa errores de vinculador como la función en línea no es realmente compilado en el lugar de la definición, pero se inserta en el lugar de la llamada.

Otras consideraciones

Por lo tanto, el código está muy bien dividido en varios archivos, que le da todos los beneficios mencionados en el inicio como la velocidad de compilación de una mayor y mejor organización. ¿Hay algo más que necesitas saber?

En primer lugar, si estás usando la biblioteca estándar de C++ (STL) o cualquier otra biblioteca que utilice espacios de nombres, no utilice la se sentencia using en los archivos de cabecera pues reducirás la utilidad de los espacios de nombres casi por completo, es mejor usar el identificador del espacio de nombre en cada función u objeto que uses, así tienes la ventaja de saber a que biblioteca pertenece ese código y no tendrás problemas de superponer nombres de funciones propias que te hagan rectificar el código.

En segundo lugar, el uso de macros debe ser cuidadosamente controlado. Los programadores de C tienen que confiar en las macros de un montón de funcionalidades, pero los programadores de C++ debe evitarlos siempre que sea posible. Si desea una constante en C++, utilice la palabra clave const. Si desea una función en línea en C++, utilice la palabra clave ‘inline’. Si desea una función que opera sobre los diferentes tipos utilice plantillas o sobrecarga. Pero si necesitas utilizar una macro, por alguna razón, y lo coloca en un fichero de cabecera, trate de no escribir macros que podrían interferir con el código en los archivos que se incluyen. Cuando se producen errores extraños de compilación, no quiero tener que buscar a través de todos los archivos de cabecera para ver si alguien utiliza un #define para cambiar inadvertidamente su función o sus parámetros a otra cosa. Así que siempre que sea posible, mantener las macros de cabeceras a menos que usted puede estar seguro de que no les importa que afecta a todo en el proyecto.

Comentarios cerrados
Inicio