Structs en la práctica: paso, opacidad y miembros flexibles
Tres decisiones de diseño que separan el código de sistemas serio del resto: cuándo pasar un struct por valor y cuándo por puntero según lo que dicta la ABI, cómo construir tipos opacos que esconden su representación y sobreviven a los cambios, y cómo usar el miembro de arreglo flexible para reservar cabecera y datos en una sola llamada sin desbordar el cálculo del tamaño.
Sabes ya cómo se dispone un struct en memoria. Queda la parte que decide la calidad de una biblioteca: cómo lo haces cruzar la frontera de una función y la de un módulo. Tres decisiones concentran casi todo el juego. Pasar por valor o por puntero no es una cuestión de estilo sino de clasificación en la ABI. Exponer la definición completa en la cabecera o esconderla determina si podrás cambiar la representación sin recompilar a tus usuarios. Y terminar un struct en un arreglo sin dimensión convierte dos reservas en una, con una aritmética que hay que blindar. Estas tres piezas son la base sobre la que se construyen las bibliotecas en C que llevan décadas en producción.
- Decidir entre paso por valor y por puntero según el tamaño y la clasificación de la ABI.
- Diseñar tipos opacos con declaración adelantada y funciones de creación y destrucción.
- Reservar structs con miembro de arreglo flexible sin desbordar el cálculo del tamaño.
- Razonar sobre la copia superficial, la propiedad de los miembros y el ciclo de vida.
Por valor o por puntero: lo que decide la ABI
C pasa siempre por valor: al escribir un struct como parámetro se copia entero. La pregunta interesante no es si hay copia, sino cómo la materializa la convención de llamada. En la ABI System V de x86-64, un agregado de hasta 16 bytes cuyos campos caben en dos clases de registro se pasa en registros, sin tocar memoria; a partir de ahí se clasifica como memoria y se copia a la pila, y el llamante entrega su dirección. AArch64 aplica una lógica emparentada con un umbral de 16 bytes y hasta cuatro registros para agregados homogéneos de flotantes.
struct Punto { double x, y; }; // 16 bytes: viaja en dos registros SSE
struct Matriz { double m[16]; }; // 128 bytes: copia en pila
double norma(struct Punto p); // barato: sin indireccion ni alias
void escalar(struct Matriz *m, double k);// caro por valor: pasa puntero
La consecuencia contraintuitiva es que para agregados pequeños el paso por valor suele ser más rápido que el paso por puntero constante. No solo evita la indirección: elimina la posibilidad de alias. Cuando una función recibe const struct Punto *p y además escribe a través de cualquier otro puntero, el compilador debe asumir que esa escritura podría modificar lo que p apunta, y recarga el valor desde memoria en cada uso. Recibiendo el struct por valor esa duda desaparece, porque la copia es privada de la función.
Por valor
Agregados pequeños, semántica de valor, sin necesidad de modificar el original. Elimina alias y suele quedar en registros.
Por puntero a constante
Agregados grandes que solo se leen. Evita la copia, pero introduce indirección y posible pesimización por alias.
Por puntero mutable
Cuando la función debe modificar el original. Es la única opción, ya que C no tiene referencias.
Con restrict
Puntero mutable más restrict cuando garantizas que no hay solapamiento. Devuelve al optimizador la información que el alias le quitó.
El umbral práctico que usan la mayoría de bibliotecas es sencillo: por valor hasta dos palabras de máquina, por puntero por encima. Y una regla que no admite excepción: devolver un struct por valor es siempre correcto y nunca peligroso, mientras que devolver un puntero a una variable local es un error grave.
Structs opacos: la frontera del módulo
Un tipo opaco es aquel cuya definición no aparece en la cabecera pública. El usuario solo ve un nombre de tipo incompleto y punteros a él; la definición vive en el archivo de implementación, donde nadie más puede tocarla.
// conexion.h
typedef struct Conexion Conexion; // tipo incompleto: solo punteros
Conexion *conexion_abrir(const char *host, int puerto);
void conexion_cerrar(Conexion *c);
int conexion_enviar(Conexion *c, const void *datos, size_t n);
// conexion.c
struct Conexion { // definicion privada
int fd;
char *host;
size_t enviados;
};
Conexion *conexion_abrir(const char *host, int puerto) {
Conexion *c = malloc(sizeof *c); // sizeof del objeto, no del tipo
if (!c) return nullptr; // C23: constante nula tipada
*c = (Conexion){.fd = -1, .host = nullptr, .enviados = 0};
return c;
}
flowchart LR H[cabecera publica con el tipo incompleto] --> U[codigo del usuario] I[implementacion con la definicion completa] --> H U --> F[solo puede usar punteros y las funciones publicas] I --> C[puede cambiar campos sin recompilar al usuario] style H fill:#89b4fa,color:#11111b style C fill:#a6e3a1,color:#11111b
Cuando expones la definición completa de un struct en tu cabecera, no publicas una interfaz: publicas un layout. A partir de ese momento el tamaño del agregado, el desplazamiento de cada campo y hasta el orden de declaración forman parte de tu contrato binario, porque el código del usuario los compiló dentro de sus propias instrucciones. Añadir un campo al final —el cambio más inocente imaginable— cambia el sizeof y rompe cualquier binario que reservara ese objeto en su pila. Con un tipo opaco, en cambio, todo el conocimiento del layout vive dentro de tu unidad de traducción: puedes reordenar, añadir, quitar y reemplazar la representación entera, y los usuarios ni siquiera necesitan recompilar. Ese es el motivo por el que FILE es opaco, por el que las bibliotecas serias de C exponen punteros y funciones en vez de campos, y por el que las que no lo hicieron llevan décadas arrastrando campos muertos que no pueden borrar. El precio es real y hay que reconocerlo: obligas a una reserva dinámica y a una indirección, y pierdes la posibilidad de crear el objeto en la pila. La variante intermedia que usan pthread_mutex_t y compañía consiste en publicar un tamaño y una alineación opacos, con un arreglo de bytes y alignas, lo que permite la reserva en pila a costa de congelar el tamaño para siempre. Elegir entre las tres opciones es una decisión de diseño de ABI, no de gusto personal.
Miembros flexibles al final
Un struct puede terminar en un arreglo declarado sin dimensión, el miembro de arreglo flexible. No cuenta para sizeof, debe ser el último miembro, y el agregado debe tener al menos otro miembro con nombre. Su razón de ser es reservar cabecera y datos en un único bloque contiguo: una sola llamada al asignador, una sola liberación y, sobre todo, localidad de caché perfecta entre la cabecera y su contenido.
#include <stdckdint.h> // C23: aritmetica con deteccion de desbordamiento
#include <stdlib.h>
#include <string.h>
typedef struct {
size_t largo;
unsigned char datos[]; // miembro flexible: sizeof lo ignora
} Buffer;
Buffer *buffer_crear(size_t n) {
size_t total;
if (ckd_add(&total, sizeof(Buffer), n)) return nullptr; // desbordo
Buffer *b = malloc(total);
if (!b) return nullptr;
b->largo = n;
memset(b->datos, 0, n);
return b;
}
El cálculo del tamaño es el punto débil clásico de este patrón. Escribir sizeof(Buffer) + n * sizeof(elemento) con una n procedente del exterior es una de las fuentes históricas de desbordamiento de enteros que se convierten en desbordamiento de montículo: la suma da la vuelta, malloc reserva poco y la escritura posterior se sale. C23 resuelve esto con stdckdint.h, que aporta ckd_add, ckd_sub y ckd_mul: hacen la operación en precisión infinita, guardan el resultado y devuelven verdadero si no cabía. Usarlas convierte un fallo silencioso de seguridad en una rama explícita.
El viejo truco de declarar datos[1] y reservar de más, anterior a C99, no es equivalente: el compilador conoce la dimensión declarada y está autorizado a optimizar en consecuencia. Usa siempre la forma sin dimensión. Y si trabajas con GCC o Clang recientes, anota el miembro con el atributo counted_by indicando el campo que guarda la longitud: los sanitizadores y _FORTIFY_SOURCE podrán entonces verificar los accesos en tiempo de ejecución.
Hay dos restricciones que conviene tener presentes. Un struct con miembro flexible no puede ser miembro de otro struct ni elemento de un arreglo, porque su tamaño real no es conocido. Y la asignación entre dos objetos de ese tipo copia solo la parte fija, ignorando por completo los datos flexibles; para duplicarlo hay que reservar de nuevo y copiar con memcpy.
Copia superficial, propiedad y ciclo de vida
La asignación de structs en C es una copia superficial byte a byte de los miembros. Si uno de ellos es un puntero, la copia comparte el objeto apuntado, y ahora hay dos structs que creen ser dueños de la misma memoria. C no tiene constructores de copia ni destructores: la política de propiedad es algo que tú decides y documentas.
typedef struct { char *nombre; size_t edad; } Persona;
Persona persona_clonar(const Persona *p) { // copia profunda explicita
char *n = p->nombre ? strdup(p->nombre) : nullptr;
return (Persona){.nombre = n, .edad = p->edad};
}
void persona_liberar(Persona *p) {
free(p->nombre);
*p = (Persona){0}; // deja el objeto en estado inerte
}
Dos hábitos de C23 valen su peso en depuración. Inicializar siempre con inicializadores designados, porque los miembros no mencionados quedan puestos a cero y desaparece la clase entera de bugs por campo olvidado. Y usar literales compuestos para asignar el agregado completo de una vez en lugar de campo a campo, lo que deja al compilador ver la inicialización como una operación atómica y evita estados intermedios inconsistentes.
- Escribe
normarecibiendostruct Puntopor valor y una variante que lo reciba porconst struct Punto *; compila con-O2 -Sy compara el ensamblador de ambas llamadas. - Convierte un
structpúblico existente en un tipo opaco con funciones de creación, uso y destrucción, y comprueba que el código del usuario compila sin conocer los campos. - Añade un campo nuevo a la definición privada y confirma que la unidad de traducción del usuario no necesita recompilarse para seguir siendo correcta.
- Implementa
Buffercon miembro flexible y su función de creación usandockd_add; provoca el desbordamiento pasando un tamaño cercano al máximo desize_ty verifica que devuelve nulo en lugar de reservar de menos. - Escribe
persona_clonarypersona_liberar, y usa el sanitizador de direcciones para demostrar el doblefreeque aparece si sustituyes el clon profundo por una asignación directa.