Estructura de un proyecto C
El árbol de directorios como arquitectura: cabeceras públicas frente a privadas, la ruta de inclusión como único control de acceso del lenguaje, y el grafo de dependencias entre módulos que no debe tener ciclos.
En un lenguaje con módulos, la arquitectura la declara el lenguaje. En C no hay nada de eso, así que la declara el sistema de archivos. La disposición de las carpetas, qué rutas ve el compilador cuando busca una inclusión y qué símbolos deja el enlazador a la vista son, juntos, todo el sistema de módulos que C posee. Por eso la estructura de un proyecto no es cosmética ni cuestión de gusto: es el único sitio donde puedes hacer cumplir la separación entre lo público y lo privado, y un árbol mal montado la deja sin cumplir en silencio.
- Montar un árbol de directorios cuya forma exprese la arquitectura del proyecto.
- Separar cabeceras públicas de privadas y hacer que la ruta de inclusión lo imponga.
- Razonar sobre el grafo de dependencias entre módulos y romper ciclos deliberadamente.
- Verificar la estructura con herramientas en lugar de confiar en la disciplina.
El árbol y lo que significa cada carpeta
La disposición que sigue no es la única razonable, pero sí la que domina en proyectos serios, y cada carpeta responde a una decisión concreta, no a una convención estética.
proyecto/
├── include/dios/ cabeceras PUBLICAS, se instalan
│ ├── buffer.h
│ └── registro.h
├── src/ implementacion y cabeceras PRIVADAS
│ ├── buffer.c
│ ├── buffer_interno.h
│ └── registro.c
├── tests/ pruebas; pueden ver src/
├── examples/ programas de demostracion; solo ven include/
├── subprojects/ dependencias externas
├── docs/
└── build/ fuera del arbol de fuentes, desechable
Tres detalles cargan con casi todo el peso. El primero es el subdirectorio con el nombre del proyecto dentro de include: hace que los consumidores escriban la inclusión como dios/buffer.h y no como buffer.h, de modo que tu cabecera no compita por el nombre con la de cualquier otra biblioteca instalada en el sistema. C no tiene espacios de nombres, y esa carpeta es lo más parecido que existe. El segundo es que las cabeceras privadas viven junto a la implementación, en src, no en include: no se instalan y no forman parte de nada que prometas. El tercero es que build está fuera del árbol de fuentes, lo cual no es orden sino verificabilidad — un árbol de fuentes que no se ensucia se puede borrar y regenerar sin miedo.
/* Un consumidor externo */
#include <dios/buffer.h> /* ruta publica: la instalada */
/* Un archivo del propio proyecto */
#include "buffer_interno.h" /* privado: solo visible desde src/ */
Público frente a privado: lo impone la ruta de inclusión
Esta es la idea central del nivel. El compilador busca las inclusiones en las rutas que le das, y esa lista es un mecanismo de control de acceso perfectamente real: si un directorio no está en la ruta de inclusión de un objetivo, sus cabeceras no existen para él.
# Objetivo interno: ve lo publico y lo privado
gcc -std=c23 -Iinclude -Isrc -c src/buffer.c -o build/buffer.o
# Consumidor externo: solo ve lo publico. La cabecera interna no existe para el
gcc -std=c23 -Iinclude examples/demo.c build/libdios.a -o build/demo
Nota la diferencia entre las dos formas de escribir una inclusión, que no es estilística. La forma con comillas busca primero de manera relativa al archivo que la escribe y luego cae en la ruta de búsqueda; la forma con corchetes angulares va directamente a la ruta. La convención que se deriva es sencilla y conviene respetarla sin excepciones: comillas para lo que pertenece al proyecto, corchetes para lo que viene de fuera, incluida tu propia biblioteca vista desde un consumidor. Cuando esa convención se rompe, la ruta de inclusión deja de ser una frontera y vuelve a ser una lista de sugerencias.
De ahí sale una regla que puedes automatizar: ninguna cabecera de include puede incluir nada de src. Si lo hiciera, el consumidor externo compilaría solo mientras src estuviese en su ruta, y fallaría el día de la instalación — el clásico proyecto que funciona en el repositorio y se rompe al empaquetarlo.
La misma lógica se prolonga hasta el enlazado. Una cabecera privada esconde declaraciones, pero el símbolo sigue ahí para quien lea el objeto; ocultarlo de verdad exige static cuando el símbolo no sale del archivo, y visibilidad oculta cuando debe cruzar archivos del proyecto pero no salir de la biblioteca compartida.
| Alcance deseado | Cabecera | Mecanismo del lenguaje o del enlazador |
|---|---|---|
Un solo archivo .c |
ninguna | static |
| Varios archivos del mismo módulo | privada en src |
linkage externo con visibilidad oculta |
| Todo el proyecto, no la API | privada compartida en src |
linkage externo con visibilidad oculta |
| API pública instalada | pública en include |
linkage externo y exportación explícita |
Cuando el proyecto se instala, include/dios acaba en el directorio de cabeceras del sistema, el archivo de biblioteca en el de bibliotecas y un archivo de pkg-config describe cómo enlazarlo. Todo lo que copies ahí es la API, con independencia de tus intenciones. La prueba definitiva de si una cabecera es pública no es dónde la guardaste: es si aparece en la lista de instalación. Merece la pena mirarla con la misma severidad con la que revisarías un cambio de firma.
El grafo de dependencias y sus ciclos
Los módulos de un proyecto forman un grafo dirigido: hay una arista de A hacia B si A incluye una cabecera de B. Ese grafo es la arquitectura real, no la que aparece en el documento de diseño, y tiene una propiedad que debe cumplirse sin excepciones: debe ser acíclico.
flowchart TD A[app] --> B[protocolo] A --> C[registro] B --> D[buffer] C --> D D --> E[base y utilidades] B --> E style A fill:#f9e2af,color:#11111b style E fill:#a6e3a1,color:#11111b
Un ciclo entre dos módulos significa que no son dos módulos: son uno solo repartido en dos carpetas. No se pueden compilar por separado con sentido, no se pueden probar por separado, no se puede sustituir uno sin el otro y no se puede razonar sobre ninguno sin el otro. Las guardas de inclusión harán que el ciclo compile, lo cual es peor que un error, porque el problema queda enterrado.
Romperlo tiene tres técnicas, en orden de preferencia:
Declaración adelantada
Si A solo usa punteros a un tipo de B, declara la struct incompleta y borra la inclusión. La arista desaparece.
Extraer lo común
Lo que ambos necesitan casi nunca pertenece a ninguno de los dos: sácalo a un módulo inferior del que ambos dependan.
Invertir con callback
Si el módulo bajo debe avisar al alto, que reciba un puntero a función. La dependencia se convierte en parámetro.
Ordenar por capas
Asigna a cada módulo un nivel y prohíbe las aristas que suban. Un ciclo se vuelve imposible por construcción.
Las carpetas de tests y examples merecen una precisión que se pasa por alto. Las pruebas unitarias necesitan a menudo llegar a lo interno, y es legítimo que compilen con -Isrc: están dentro de la frontera. Los ejemplos, en cambio, deben compilar exactamente como compilaría un usuario externo, solo con la ruta pública y enlazando contra la biblioteca instalada. Por eso los ejemplos son mucho más que documentación: son la única prueba real de que tu API es usable desde fuera, y el primer sitio donde salta que una cabecera pública dependía en secreto de algo privado.
La disciplina de capas tiene además una consecuencia práctica que se nota a diario: el tiempo de compilación. Un módulo del que dependen veinte fuerza veinte recompilaciones cada vez que tocas su cabecera, así que los módulos de la base deben ser los más estables y los de cabecera más pequeña. Cuando un módulo bajo cambia todas las semanas, el problema no es el sistema de compilación: está mal situado en el grafo.
Que la estructura sea verificable
Una regla que solo existe en la cabeza del autor durará hasta la siguiente incorporación al equipo. Todas las de este nivel se pueden comprobar automáticamente, y deberían formar parte de la integración continua.
# 1. Ninguna cabecera publica incluye nada privado
grep -rn 'include "' include/ && echo 'FALLO: inclusion privada en una cabecera publica'
# 2. Cada cabecera publica compila sola
for h in include/dios/*.h; do
echo "#include \"$h\"" > /tmp/solo.c
gcc -std=c23 -Iinclude -fsyntax-only /tmp/solo.c || echo "FALLO: $h no es autocontenida"
done
# 3. La superficie exportada es la que dices exportar
nm -D --defined-only build/libdios.so | grep ' T ' | sort
# 4. El grafo real de inclusiones, para compararlo con el que creias tener
gcc -std=c23 -Iinclude -Isrc -H -fsyntax-only src/buffer.c 2>&1 | head -40
A eso se añade lo que ya te da el sistema de compilación. Los archivos de dependencias generados con -MMD -MP mantienen la recompilación correcta cuando cambia una cabecera; Meson y CMake permiten declarar por objetivo qué directorios de inclusión son públicos y cuáles privados, con lo que la regla deja de depender de que nadie escriba la bandera equivocada. Y compilar la biblioteca entera con visibilidad oculta convierte la exportación en un acto deliberado en lugar de en el valor por defecto.
Lo que cuesta aceptar de C es que su arquitectura no vive en el código. En un lenguaje con módulos escribes la frontera y el compilador la sostiene; aquí escribes una carpeta, una bandera de inclusión y un static, y la frontera existe solo en la medida en que esas tres cosas coincidan. Un proyecto de C está montado sobre tres mecanismos que operan en tres momentos distintos y a los que nadie ha enseñado a hablar entre sí: el preprocesador decide qué declaraciones ve cada unidad de traducción y su única forma de decir “esto no es tuyo” es no tener el archivo en la ruta; el compilador decide qué símbolos emite y su única forma de decir privado es no emitirlos, que es static; el enlazador decide qué queda a la vista al final y su única forma de esconder algo es la visibilidad. Las tres capas pueden contradecirse tranquilamente entre sí, y de esa contradicción salen los fallos más caros de un proyecto en C: la biblioteca que exporta ciento sesenta símbolos cuando su API tenía doce, la cabecera que compilaba en el repositorio y se rompe al instalarse, el ciclo que las guardas de inclusión mantuvieron oculto durante tres años hasta que alguien quiso reutilizar un módulo. La conclusión es incómoda y liberadora a la vez: en C, diseñar el árbol de directorios es diseñar la arquitectura. No es la preparación del trabajo ni su documentación posterior; es el trabajo. Y como el lenguaje no va a comprobarte nada, la única salida profesional es convertir cada regla de estructura en un comando que falle solo. Lo que no rompe la compilación, no es una regla: es una intención.
- Reorganiza un proyecto tuyo a
includecon subdirectorio de proyecto ysrc, y comprueba que un ejemplo compila con-Iincludea secas. - Dibuja el grafo de dependencias reales de tus módulos a partir de las inclusiones. Busca ciclos y rompe al menos uno con una declaración adelantada.
- Añade a tu compilación las cuatro comprobaciones de la sección anterior y haz que fallen antes de arreglarlas.
- Compila la biblioteca con visibilidad oculta y compara cuántos símbolos exporta antes y después. Justifica uno por uno los que queden.
- Mide cuántos archivos se recompilan al tocar la cabecera de tu módulo más bajo y decide si su posición en el grafo es la correcta.