Diseñar una cabecera: qué exponer y qué esconder
Una cabecera no es un archivo de declaraciones sueltas: es la superficie de acoplamiento de un módulo. Criterios para decidir qué sale, tipos opacos como única frontera que el compilador hace cumplir, y el coste real de filtrar detalles de implementación.
Escribir una cabecera es fácil; diseñarla es una decisión de arquitectura de la que no se vuelve. Todo lo que pongas ahí se convierte en promesa: alguien lo usará, alguien dependerá de su forma exacta y alguien se romperá cuando lo cambies. Lo que no pongas es lo único que te queda libre para reescribir. La pregunta de este nivel no es cómo se escribe un header, sino cómo se decide su frontera — y por qué en C esa frontera solo existe si la construyes deliberadamente.
- Aplicar un criterio explícito para decidir qué entra y qué no entra en una cabecera.
- Diseñar tipos opacos y entender qué imponen al usuario y qué te devuelven a ti.
- Reconocer los detalles de implementación que se filtran sin que nadie se dé cuenta.
- Escribir cabeceras autocontenidas, con dependencias mínimas y contrato documentado.
El criterio: lo necesario para usar, nunca para implementar
Una cabecera contiene exactamente lo que un consumidor necesita para usar el módulo. Nada de lo que hace falta para implementarlo. Esa frase resuelve la mayoría de los casos dudosos, y donde no llegue existe una prueba más afilada: si borro esta línea de la cabecera, ¿deja de compilar algún consumidor legítimo? Si la respuesta es no, la línea sobra, y mientras siga ahí es superficie pública que alguien acabará usando.
/* buffer.h — solo el contrato */
#pragma once
#include <stddef.h> /* size_t: lo usa la interfaz */
typedef struct Buffer Buffer; /* tipo incompleto: opaco */
Buffer *buffer_crear(size_t capacidad);
void buffer_destruir(Buffer *b);
size_t buffer_escribir(Buffer *b, const void *datos, size_t n);
size_t buffer_tamano(const Buffer *b);
/* buffer.c — todo lo demás vive aquí */
#include "buffer.h"
#include <stdlib.h>
#include <string.h>
struct Buffer { unsigned char *datos; size_t usado, cap; }; /* invisible fuera */
static size_t crecer(size_t cap) { return cap ? cap * 2 : 32; } /* privada */
Fíjate en la desproporción: la cabecera cabe en una pantalla y la implementación puede tener mil líneas. Esa desproporción no es un accidente del ejemplo, es el objetivo. Cuatro decisiones ya están tomadas ahí. La estructura no se ve, así que puedo cambiar sus campos mañana. crecer es static, así que no existe fuera. stdlib.h y string.h los necesita la implementación, no el usuario, y por eso no están en la cabecera. Y el prefijo buffer_ sustituye al espacio de nombres que el lenguaje no tiene.
Una cabecera es autocontenida si un archivo que solo la incluya compila. Suena obvio y casi nunca se cumple: basta que uses size_t sin incluir stddef.h y la cabecera funcionará mientras el consumidor incluya antes otra cosa que lo arrastre, y fallará el día que cambie el orden de las inclusiones. La regla operativa es incluye lo que usas: cada cabecera incluye aquello de lo que depende su propio texto, jamás confía en inclusiones transitivas, y se verifica compilándola aislada. Es una prueba de una línea y debería estar en tu sistema de compilación.
Lo que se filtra sin que nadie lo note
Exponer una struct completa parece inofensivo porque el compilador no protesta. El precio se cobra después, y en tres monedas distintas.
La primera es la recompilación. Cualquiera que incluya la cabecera depende de la disposición exacta de los campos; tocar uno solo obliga a recompilar todo el árbol de consumidores, incluso los que nunca miran ese campo. La segunda es la ABI: si distribuyes una biblioteca compartida, el tamaño y los desplazamientos de la struct quedan grabados en el código ya compilado de tus usuarios, así que añadir un campo rompe binarios que ni siquiera puedes recompilar. La tercera es la erosión del contrato: en cuanto un campo es accesible, alguien lo leerá, alguien lo escribirá, y en la siguiente versión tu invariante interno ya es una API de facto que no puedes retirar.
Vale la pena detenerse en la tercera, porque es la que decide el destino de un proyecto a largo plazo. Una API no envejece por el código que contiene sino por lo que sus usuarios han llegado a depender: en cuanto algo es observable, alguien construirá encima, y a partir de ese momento cambiarlo rompe programas ajenos con independencia de lo que digan tus comentarios. La regla operativa que se deriva es incómoda pero honesta: todo lo visible acaba siendo contrato, así que la única forma de no prometer algo es no dejarlo ver.
El mismo razonamiento se aplica a cosas que no parecen implementación pero lo son:
| Está en la cabecera | Por qué es una filtración |
|---|---|
Campos de una struct que el usuario no toca |
Congela la disposición y la ABI |
| Constantes de ajuste como el tamaño inicial del búfer | Alguien empezará a depender del número |
Macros con nombres genéricos como MAX o LOG |
Contaminan a todo el que incluya, sin linkage que las contenga |
| Cabeceras privadas incluidas por comodidad | Transfieren tus dependencias a tus usuarios |
| Prototipos de funciones internas | Superficie pública que nadie pidió |
| Un objeto definido en lugar de declarado | Definición múltiple en cuanto haya dos consumidores |
flowchart LR A[Cabecera publica] --> B[Tipos opacos y prototipos] A --> C[Constantes del contrato] A --> D[Inclusiones minimas] E[Archivo de implementacion] --> F[Definicion de las structs] E --> G[Funciones static] E --> H[Estado interno del modulo] style A fill:#a6e3a1,color:#11111b style E fill:#89b4fa,color:#11111b
Tipos opacos: la frontera que el compilador sí impone
Un tipo opaco es una struct declarada pero no definida en la cabecera. El usuario obtiene un tipo incompleto: puede declarar punteros a él, pasarlos y devolverlos, y no puede hacer absolutamente nada más. No puede leer un campo, no puede reservar uno en la pila, no puede aplicarle sizeof. Y esas prohibiciones no son convención: son errores de compilación. Es la única forma de encapsulamiento de C que el lenguaje hace cumplir, y la razón por la que llevas años usando FILE sin haber visto jamás sus campos.
El precio es real y hay que asumirlo con los ojos abiertos: si el usuario no conoce el tamaño, no puede colocar el objeto en la pila, así que el módulo debe reservarlo, normalmente en el montón, y ofrecer siempre una pareja de funciones de creación y destrucción. Eso descarta el tipo opaco para objetos diminutos y de altísima frecuencia, donde una asignación por instancia sería absurda.
La lista de lo que el usuario deja de poder hacer es, en realidad, la lista de garantías que tú ganas:
#include "buffer.h"
Buffer b; /* ERROR: tipo incompleto, tamano desconocido */
size_t t = sizeof(Buffer); /* ERROR: no se puede medir lo que no se define */
Buffer *p = buffer_crear(64);
p->usado = 0; /* ERROR: no hay campos visibles */
Cada uno de esos errores es una dependencia que jamás llegará a existir, y por tanto una decisión de implementación que sigues siendo libre de cambiar. Ese es todo el negocio del tipo opaco: comprar libertad futura al precio de rigidez presente.
Cuando ese coste no es aceptable existen dos escapes intermedios. El primero es exponer la struct pero declarar sus campos como internos por convención y documentación —la solución débil, sin garantía alguna—. El segundo es el patrón que usan pthread_mutex_t y compañía: publicar en la cabecera un tipo con el tamaño y la alineación correctos pero sin significado, un bloque de bytes opacos, de modo que el usuario pueda declararlo en la pila mientras la implementación lo reinterpreta por dentro. Compra ubicación en la pila a cambio de congelar el tamaño, que es exactamente la parte de la ABI más cara de cambiar.
Los tipos de una firma no dicen quién libera qué. Buffer *buffer_crear(size_t) no aclara si el llamante posee el resultado; size_t buffer_escribir(Buffer *, const void *, size_t) no aclara si copia los datos o guarda el puntero. Esas tres preguntas —propiedad, nulabilidad y régimen de errores— son la mitad del contrato y solo caben en un comentario junto al prototipo. En C23 puedes además convertir parte de ese contrato en algo que el compilador comprueba: [[nodiscard]] para un resultado que no se puede ignorar, [[deprecated]] para retirar una función sin romper a nadie de golpe, y un parámetro declarado int v[static 1] para prometer que el puntero no es nulo.
Higiene: dependencias y coste de compilación
Cada #include en una cabecera pública es una dependencia que heredan todos tus consumidores, y una arista más en el grafo que decide cuánto se recompila cada vez que tocas algo. Reducirlas es trabajo de diseño, no de limpieza.
Guarda de inclusión
#pragma once o guardas clásicas con un nombre único derivado de la ruta. Siempre, sin excepción.
Prefijo de módulo
No hay espacios de nombres: el prefijo es lo único que separa tu crear del crear de otra biblioteca.
Declaración adelantada
Si solo necesitas punteros a un tipo, declara la struct incompleta en vez de incluir su cabecera.
Mide el árbol
gcc -H imprime todo lo que arrastra una inclusión. Los números suelen ser peores de lo que imaginas.
Sobre las guardas conviene ser preciso, porque la elección tiene consecuencias. #pragma once no está en el estándar pero lo implementan todos los compiladores relevantes, identifica el archivo por su contenido o su inodo y es inmune a los errores de copiar y pegar. Las guardas clásicas son estrictamente portables y siguen siendo obligatorias en código que deba compilar en cualquier parte, con una condición: el nombre debe derivarse de la ruta completa dentro del proyecto y no del nombre del archivo, porque dos buffer.h en carpetas distintas con la misma guarda producen un fallo espectacular en el que la segunda cabecera simplemente desaparece sin que nadie diga nada.
Y hay una asimetría que resume la sección entera: cambiar un archivo de implementación recompila un objeto; cambiar una cabecera recompila todo su cono de consumidores. La consecuencia práctica es que la estabilidad de una cabecera importa más que su elegancia, y que una cabecera de la que dependen treinta archivos debe estar entre las cosas que menos toques del proyecto. Si no lo está, el problema no es el sistema de compilación: es que ese módulo todavía no ha encontrado su interfaz.
gcc -std=c23 -H -fsyntax-only consumidor.c # arbol completo de inclusiones
echo '#include "buffer.h"' > /tmp/solo.c && gcc -std=c23 -fsyntax-only /tmp/solo.c
gcc -std=c23 -MMD -MP -c buffer.c # dependencias para el sistema de compilacion
Hay una inversión que separa a quien escribe cabeceras de quien las diseña. La intuición dice que primero se implementa un módulo y luego se redacta una cabecera que lo describa. Es al revés: la cabecera es el módulo, y la implementación solo la materia con la que se cumple. Todo lo que esté detrás de esa frontera es material que puedes rehacer entero un martes por la tarde sin avisar a nadie; todo lo que esté delante es una promesa vitalicia que negociarás con tus usuarios el resto de la vida del proyecto. Por eso el diseño de cabeceras es donde se decide si un proyecto de C envejece o se pudre, y por eso el criterio correcto no es “¿esto podría serle útil a alguien?” sino “¿estoy dispuesto a sostener esto durante diez años?”. La segunda lectura es más incómoda todavía. C no te ayuda a mantener esa frontera: no tiene private, no tiene módulos, no tiene nada que impida a un usuario incluir tu cabecera interna o abrir tu struct. Lo único que el lenguaje hace cumplir de verdad son dos mecanismos casi humildes —el tipo incompleto, que convierte la ignorancia en un error de compilación, y el linkage interno, que convierte la privacidad en la ausencia de un símbolo—. Todo lo demás es disciplina, revisión y organización del árbol de archivos. Quien entiende esto deja de tratar la cabecera como un trámite del compilador y empieza a tratarla como lo que es: el sitio donde tu módulo declara qué se atreve a prometer.
- Coge una cabecera tuya y aplica a cada línea la prueba de borrado: si ningún consumidor se rompe, elimínala.
- Convierte una
structpública en un tipo opaco. Anota qué código de usuario dejó de compilar: eso era acoplamiento que no sabías que tenías. - Compila cada cabecera pública aislada, en un
.cque solo la incluya, y arregla las que no sean autocontenidas. - Ejecuta
gcc -Hsobre un consumidor y cuenta cuántos archivos arrastra una sola inclusión tuya. Reduce el número con declaraciones adelantadas. - Añade
[[nodiscard]]a una función cuyo resultado no deba ignorarse y documenta propiedad, nulabilidad y errores en los prototipos que carezcan de ello.