Disciplina de propiedad: quién libera qué
La propiedad de la memoria como contrato explícito en un lenguaje que no puede expresarlo en el sistema de tipos: transferencia frente a préstamo, convenciones de nombres que hacen visible al dueño, documentación del contrato en la cabecera, el patrón de constructor y destructor con destrucción tolerante al puntero nulo, y los límites reales del recuento de referencias.
C no tiene destructores, no tiene ámbitos que liberen, no tiene un verificador de préstamos y no tiene forma de distinguir en el tipo un puntero que posee de uno que solo mira. Todo eso significa que la propiedad de la memoria existe igualmente —cada bloque tiene un dueño y una obligación de destrucción— pero vive fuera del programa, en la cabeza de quien lo escribió. La disciplina de propiedad consiste en devolver esa información al código por los únicos canales disponibles: el nombre de las funciones, la forma de las firmas, la documentación junto al prototipo y un puñado de patrones que hacen que la respuesta correcta sea también la más cómoda de escribir.
- Formular la propiedad como una obligación única de destrucción y separar transferencia de préstamo.
- Codificar esa obligación en nombres, firmas y documentación para que sea legible sin leer la implementación.
- Aplicar el patrón de constructor y destructor con destrucción tolerante al puntero nulo y limpieza parcial.
- Evaluar el recuento de referencias, sus costes de sincronización y su fallo ante los ciclos.
La regla única y sus dos operaciones
Toda la disciplina se reduce a un enunciado: cada bloque reservado tiene, en todo instante, exactamente un dueño, y el dueño es quien está obligado a destruirlo exactamente una vez. Todos los defectos clásicos son violaciones de esa frase. Cero dueños producen una fuga. Dos dueños producen una doble liberación. Un dueño que destruye mientras otro sigue mirando produce un uso tras liberación.
Sobre esa regla solo hay dos operaciones posibles cuando un puntero cruza la frontera de una función. La transferencia mueve la obligación: quien recibe el puntero queda obligado a destruirlo, y quien lo entregó deja de poder usarlo. El préstamo no mueve nada: quien recibe puede leer o escribir durante la llamada, pero no destruye ni guarda la dirección más allá de la vida del objeto prestado.
Como el compilador no verifica cuál de las dos está ocurriendo, la única defensa es que sea evidente al leer la llamada. Una función que devuelve un puntero al montón casi siempre transfiere; una función que recibe un const char * casi nunca. Las excepciones a esas expectativas son precisamente donde aparecen los errores, así que deben nombrarse y documentarse de forma agresiva.
flowchart LR A[Constructor reserva y devuelve el objeto] --> B[El llamante es el dueno] B --> C[Prestamo a una funcion que solo consulta] C --> B B --> D[Transferencia a un contenedor que asume la obligacion] D --> E[El contenedor destruye sus elementos] B --> F[Destructor libera y la obligacion se extingue] style B fill:#89b4fa,color:#11111b style F fill:#a6e3a1,color:#11111b
Convenciones que hacen visible al dueño
Un nombre bien elegido comunica la propiedad sin necesidad de abrir la implementación, y esa es toda su función. Las convenciones que se han impuesto en las bases de código serias de C son pocas y conviene adoptarlas tal cual, porque su valor está en ser compartidas.
crear y destruir
El par tipo_crear y tipo_destruir marca las únicas dos funciones que reservan y liberan el objeto. Si una función no lleva uno de esos nombres, no debería llamar a free sobre él.
dup y copy transfieren
Los sufijos heredados de strdup anuncian memoria nueva que el llamante debe liberar. Todo lo que devuelva un bloque recién reservado debería llevar uno de ellos.
get y peek prestan
Devuelven una vista al interior de una estructura que sigue siendo suya. El tipo de retorno debería ser const siempre que el llamante no tenga que modificar nada.
take y adopt consumen
Marcan el caso peligroso: la función recibe un puntero y se queda con la obligación. El llamante debe considerar su variable inválida justo después de la llamada.
El segundo canal es la cabecera, y es el más importante porque es lo único que lee quien usa tu módulo. La documentación de propiedad no es un comentario decorativo: es la parte del contrato que el lenguaje no puede escribir, y su ausencia obliga a leer la implementación para poder llamar a la función.
// buffer.h
typedef struct Buffer Buffer;
// Crea un buffer vacio. Devuelve nullptr si falla.
// PROPIEDAD: el llamante recibe la propiedad y debe llamar a
// buffer_destruir exactamente una vez.
Buffer *buffer_crear(size_t capacidad_inicial);
// Libera el buffer y todo lo que contiene. Acepta nullptr sin efecto.
void buffer_destruir(Buffer *b);
// PROPIEDAD: prestamo. La cadena devuelta pertenece al buffer y deja
// de ser valida tras cualquier operacion que lo modifique o destruya.
const char *buffer_texto(const Buffer *b);
// PROPIEDAD: transferencia. El buffer adopta la cadena y la liberara
// en buffer_destruir. Tras la llamada, no uses tu puntero.
bool buffer_adoptar(Buffer *b, char *cadena_del_monton);
Ese bloque de cuatro prototipos contiene más información sobre la gestión de memoria del módulo que cualquier cantidad de código bien escrito sin él. Conviene además que el tipo sea opaco, declarado en la cabecera y definido solo en el fichero de implementación: así nadie puede reservar un objeto en su pila, nadie puede liberar un campo interno y las dos únicas puertas de entrada son el constructor y el destructor.
Constructor y destructor en C
El patrón tiene una forma canónica que resuelve simultáneamente la reserva, la inicialización y el fallo a mitad de camino. El constructor reserva la estructura, inicializa cada recurso en orden y, si alguno falla, deshace en orden inverso lo ya hecho mediante etiquetas de limpieza escalonadas.
struct Buffer {
char *datos;
size_t longitud;
size_t capacidad;
FILE *registro;
};
Buffer *buffer_crear(size_t capacidad_inicial) {
Buffer *b = calloc(1, sizeof *b); // cero: todo campo queda nulo
if (!b) return nullptr;
b->datos = malloc(capacidad_inicial);
if (!b->datos) goto fallo_datos;
b->registro = fopen("buffer.log", "w");
if (!b->registro) goto fallo_registro;
b->capacidad = capacidad_inicial;
return b;
fallo_registro:
free(b->datos);
fallo_datos:
free(b);
return nullptr;
}
El uso de calloc no es un capricho: al dejar todos los campos a cero, cualquier destrucción parcial encuentra punteros nulos en los recursos que aún no se habían adquirido, y liberar un puntero nulo es una operación válida y sin efecto. Esa propiedad de free es la que sostiene todo el patrón, y por eso el destructor debe imitarla.
void buffer_destruir(Buffer *b) {
if (!b) return; // tolerante al nulo, como free
if (b->registro) fclose(b->registro);
free(b->datos); // libera lo que posee, en orden inverso
free(b); // y por ultimo la estructura
}
Tres propiedades hacen que este destructor sea correcto y que sus variantes descuidadas no lo sean. Acepta el puntero nulo sin efecto, lo que elimina una comprobación en cada punto de llamada. Libera en orden inverso a la adquisición, lo que importa en cuanto un recurso depende de otro. Y libera todo lo que el objeto poseía, de forma recursiva: si el buffer contuviera un arreglo de objetos propios, el destructor recorrería ese arreglo destruyendo cada elemento antes de liberar el arreglo.
Poner a nulo el parámetro dentro de buffer_destruir no sirve de nada: el parámetro es una copia local. Si quieres esa garantía, la firma debe recibir un puntero a puntero y anular el original, con el coste de una llamada más incómoda. En GCC y Clang existe además el atributo cleanup, que asocia una función de destrucción a una variable local y la invoca al salir del ámbito; no es C estándar, pero es la aproximación más cercana a la destrucción determinista y es la base de las macros de limpieza automática de systemd y del kernel de Linux.
Propiedad compartida y sus límites
A veces la respuesta honesta a la pregunta de quién es el dueño es que hay varios candidatos y ninguno sabe cuál terminará último. Es el caso de un objeto grande referenciado por varias estructuras con vidas independientes. La solución clásica es el recuento de referencias: el objeto guarda cuántos interesados tiene, cada nuevo interesado incrementa, cada uno que termina decrementa, y el que lleva la cuenta a cero ejecuta la destrucción.
typedef struct { atomic_int refs; char *datos; } Recurso;
Recurso *recurso_retener(Recurso *r) {
atomic_fetch_add_explicit(&r->refs, 1, memory_order_relaxed);
return r;
}
void recurso_soltar(Recurso *r) {
if (!r) return;
if (atomic_fetch_sub_explicit(&r->refs, 1, memory_order_acq_rel) == 1) {
free(r->datos);
free(r);
}
}
El recuento no es gratis y sus tres costes deben conocerse antes de adoptarlo. Si el objeto se comparte entre hilos, el contador debe ser atómico, y una operación atómica de lectura y modificación cuesta bastante más que un incremento normal, además de invalidar la línea de caché en el resto de núcleos. Convierte además la propiedad en una decisión de tiempo de ejecución: ya no puedes saber leyendo el código cuándo se destruye el objeto. Y falla de forma silenciosa ante los ciclos, porque dos objetos que se referencian mutuamente mantienen sus contadores por encima de cero para siempre, aunque nadie más los alcance. Ese es exactamente el motivo por el que los lenguajes que usan recuento como mecanismo principal ofrecen referencias débiles.
Cuando comparas C con los lenguajes que garantizan la ausencia de fugas, la diferencia no está en la potencia del compilador ni en la existencia de un recolector: está en que ellos han decidido que la propiedad forme parte del tipo. Un valor poseído y una referencia prestada no son la misma cosa en su gramática, y por eso el compilador puede razonar sobre ellos y rechazar el programa que confunde uno con otro. En C ambos son el mismo T *, y esa colisión es la fuente de todos los defectos de este nivel. La conclusión no es lamentarse, es reconocer dónde queda entonces alojada la información: en la única capa que sigue disponible, que es el diseño de tu interfaz. El nombre de la función es tu anotación de tipo. La firma con const es tu marcador de préstamo. El tipo opaco es tu control de acceso. El par de constructor y destructor es tu regla de destrucción determinista. La documentación junto al prototipo es tu especificación formal. Cada una de esas piezas es una decisión que un lenguaje con propiedad tomaría por ti y que aquí tomas tú, con la ventaja nada menor de poder tomarla de forma distinta cuando tu caso lo justifica. Un módulo de C con propiedad bien diseñada es tan seguro en la práctica como uno escrito en un lenguaje con verificador, con la única diferencia de que la comprobación la hace el revisor humano y las herramientas del nivel anterior en vez del compilador. La disciplina no es una compensación por la falta de garantías: es el lugar exacto donde reside la ingeniería de este lenguaje.
- Escribe la cabecera de un tipo opaco
Buffercon constructor, destructor, una función que preste una vista constante y otra que adopte una cadena, documentando la propiedad en cada prototipo. - Implementa el constructor con etiquetas de limpieza escalonadas para tres recursos, y comprueba con el sanitizer de direcciones que el fallo de cada uno no filtra los anteriores.
- Haz que el destructor acepte el puntero nulo y libere de forma recursiva un arreglo de objetos propios; verifica con Valgrind que no queda nada perdido.
- Añade una función que adopte un puntero externo y otra que preste una vista, y escribe deliberadamente el error de destruir el objeto mientras la vista sigue en uso para observar el diagnóstico.
- Convierte el tipo a recuento de referencias con
atomic_int, construye un ciclo de dos objetos que se referencian mutuamente y comprueba que las herramientas lo reportan como memoria todavía alcanzable pero nunca liberada.