Declaraciones vs Definiciones en C

Para entender cómo se construye un programa en C, debes entender que el proceso no es un bloque monolítico, sino un diálogo entre dos herramientas: el compilador y el enlazador (linker).

Una declaración es una promesa que le haces al compilador. Le estás diciendo: “Confía en mí, existe un objeto llamado x que es de tipo int, aunque ahora mismo no sepas dónde está o cuánto mide”. La declaración le proporciona al compilador el nombre y el tipo para que pueda verificar que el uso que le das en el código sea semánticamente correcto (por ejemplo, que no intentes sumar una cadena a un entero).

Una definición, en cambio, es el cumplimiento de esa promesa. Es el momento en que le dices al compilador: “Aquí está el objeto; reserva el espacio de memoria necesario para él” o “Aquí está la implementación real de esta función”.

Este mecanismo existe para permitir la compilación separada. Si el compilador necesitara ver todo el código de todas las librerías cada vez que compilas un archivo .c, los tiempos de espera serían infinitos. Gracias a las declaraciones, puedes compilar un archivo usando solo las “promesas” (prototipos en archivos .h) y dejar que el enlazador, en el último paso, conecte todas las definiciones reales.

Debes usar declaraciones cada vez que necesites usar un tipo, variable o función que no esté definida en el mismo archivo donde la estás invocando, como en las dependencias circulares entre estructuras. Si intentas definir lo mismo dos veces (por ejemplo, definir la misma variable global en dos archivos distintos que luego quieres unir), romperás la Regla de una Única Definición (One Definition Rule o ODR). Esto resultará en un error de enlazado de tipo multiple definition o, en el peor de los casos, el programa compilará pero se comportará de forma errática porque el enlazador elegirá una definición al azar, ignorando la otra de manera silenciosa.

#include <stdio.h>

/* 1. DECLARACIÓN (Forward Declaration de un tipo)
   Le decimos al compilador que existe un 'struct Sensor'.
   No sabemos qué contiene, pero sabemos que el nombre es 'struct Sensor'.
   Esto permite que funciones que usan punteros a este struct se declaren
   antes de que el struct sea definido por completo. */
struct Sensor;

/* 2. DECLARACIÓN (Prototipo de función)
   Le informamos al compilador que existe una función llamada 'leer_valor'.
   Sabe que recibe un puntero a 'struct Sensor' y devuelve un 'int'.
   El compilador puede verificar que los tipos coincidan en el 'main'. */
int leer_valor(struct Sensor *s);

/* 3. DECLARACIÓN (Variable externa)
   'extern' indica que la variable 'id_sistema' existe, pero su memoria
   no está reservada aquí. Se definirá en otro lugar (en este caso, más abajo). */
extern int id_sistema;

/* 4. DEFINICIÓN (Definición de tipo)
   Aquí finalmente definimos qué hay dentro de 'struct Sensor'.
   Ahora el compilador ya sabe que ocupa un tamaño determinado en memoria. */
struct Sensor {
    int id;
    float temperatura;
};

/* 5. DEFINICIÓN (Definición de variable)
   Aquí se reserva el espacio en la sección de datos del binario. */
int id_sistema = 1024;

/* 6. DEFINICIÓN (Definición de función)
   Aquí es donde reside el cuerpo de la función. */
int leer_valor(struct Sensor *s) {
    /* El compilador ya sabe qué es 's->temperatura' gracias a la definición anterior. */
    return (int)s->temperatura;
}

int main(void) {
    /* Definición de una variable local de tipo 'struct Sensor' */
    struct Sensor termometro = {1, 26.5f};

    /* Usamos la variable externa 'id_sistema' y la función declarada */
    printf("Sistema ID: %d\n", id_sistema);
    printf("Lectura: %d\n", leer_valor(&termometro));

    return 0;
}

Desglose del ejemplo

Fíjate en cómo el flujo de información permite que el main sea capaz de compilar correctamente:

  1. En la línea int leer_valor(struct Sensor *s);, estamos usando una declaración de función. Si intentáramos llamar a leer_valor(&termometro) en el main sin haber escrito esa línea (o una similar), el compilador daría un error porque no sabría si el argumento es un puntero o un entero.
  2. La variable id_sistema es el ejemplo perfecto de la distinción. En la línea extern int id_sistema; le pedimos permiso al compilador para usarla. Pero es en la línea int id_sistema = 1024; donde realmente se asigna un espacio en la memoria de nuestro programa.
  3. La forward declaration de struct Sensor es una técnica vital en sistemas complejos. Nos permite definir estructuras que se apuntan entre sí (circularmente) sin que el compilador se pierda, ya que un puntero tiene siempre un tamaño fijo, independientemente de lo que contenga la estructura a la que apunta.

El error frecuente

Un error clásico de principiante ocurre cuando intentas “compartir” variables globales incluyendo archivos .c en lugar de .h, o definiendo variables directamente en un archivo de cabecera (.h).

// archivo_compartido.h
int contador_global; // ERROR: Esto es una DEFINICIÓN

Si incluyes archivo_compartido.h en main.c y también en log.c, cuando el enlazador intente unir ambos archivos, encontrará que contador_global ha sido definida dos veces. El compilador no te avisará; el error aparecerá en la fase de enlace (linking error).

La forma correcta para un archivo de cabecera es usar la palabra clave extern, convirtiéndola en una declaración:

// archivo_compartido.h
extern int contador_global; // CORRECTO: Es solo una promesa

Si utilizas herramientas como AddressSanitizer o Valgrind, no verás errores de este tipo, ya que esto no es un error de memoria (como un buffer overflow), sino un error de estructura del programa que impide la creación del ejecutable.

11

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio