Punteros a función: despacho, callbacks e interfaces
Declararlos sin morir en el intento, construir tablas de despacho, invertir el control con callbacks y montar el patrón de interfaz de C: el struct de punteros que hay debajo de todo polimorfismo.
Hasta ahora una función era un destino: escribías su nombre y el compilador sabía a dónde saltar. Un puntero a función convierte ese destino en un dato: algo que se guarda en una variable, se mete en un array, se pasa como argumento y se decide en tiempo de ejecución. Es la pieza con la que C construye lo que otros lenguajes llaman polimorfismo, y está en el corazón de qsort, de los drivers de Linux y de cualquier arquitectura de plugins.
- Leer y escribir declaraciones de puntero a función sin ambigüedad.
- Construir tablas de despacho que sustituyan cadenas de condicionales.
- Diseñar callbacks correctos, con contexto y sin estado global.
- Implementar el patrón de interfaz en C con un struct de punteros.
Leer la declaración sin morir en el intento
Toda la dificultad está en un paréntesis. Compara:
int *f(int, int); // FUNCION que devuelve puntero a int
int (*p)(int, int); // PUNTERO a funcion que devuelve int
La regla es leer desde el identificador hacia fuera, respetando que () y [] atan más fuerte que *. En int (*p)(int, int), el paréntesis interior fuerza a que p sea primero un puntero; luego, hacia la derecha, “a función de dos int”; luego, hacia la izquierda, “que devuelve int”. Sin ese paréntesis, p sería primero una función.
En cuanto la firma se complica, la declaración se vuelve ilegible. La solución profesional es siempre la misma: typedef.
typedef int (*BinOp)(int, int); // el clasico
typedef int BinOpFn(int, int); // alias del TIPO funcion, no del puntero
BinOp op = NULL; // variable de puntero
BinOpFn *op2 = NULL; // equivalente, mas explicito
Hay dos conversiones implícitas que conviene tener claras porque explican la sintaxis relajada que verás en código real. Primera: un designador de función se decae a puntero en casi todo contexto, así que op = suma y op = &suma son idénticos. Segunda: llamar a través del puntero se puede escribir (*op)(2, 3) o simplemente op(2, 3), y el estándar las define equivalentes. El estilo del kernel de Linux usa la forma corta; algunas bases de código prefieren la larga porque grita “esto es indirecto”.
Convertir un puntero a función a un tipo de función distinto y llamarlo a través de él es comportamiento indefinido, aunque las firmas “parezcan compatibles”: la ABI puede pasar los argumentos de otro modo. Y convertir un puntero a función a void * no está garantizado por ISO C, porque C admite arquitecturas donde código y datos viven en espacios de direcciones diferentes. Funciona en POSIX porque POSIX lo exige explícitamente para que dlsym sea utilizable, no porque el lenguaje lo bendiga.
Tablas de despacho
Una cadena larga de if o un switch sobre un código de operación es una tabla escrita a mano. Escríbela como tabla de verdad y el código se aplana:
typedef int (*BinOp)(int, int);
static int suma(int a, int b) { return a + b; }
static int resta(int a, int b) { return a - b; }
static int mult(int a, int b) { return a * b; }
static const BinOp TABLA[] = { suma, resta, mult };
int aplicar(size_t op, int a, int b) {
if (op >= sizeof TABLA / sizeof TABLA[0]) return 0; // el chequeo NO es opcional
return TABLA[op](a, b);
}
Añadir una operación es añadir una función y una entrada: el aplicar no se toca. Esa es la ganancia real, más que el rendimiento. Marca la tabla static const para que viva en memoria de solo lectura y no pueda ser reescrita por un desbordamiento, y valida siempre el índice: una tabla de punteros con acceso fuera de rango es una primitiva de ejecución arbitraria, no un bug menor.
flowchart LR A[Codigo de operacion] --> B[Indice validado contra el tamano] B --> C[Entrada de la tabla de despacho] C --> D[Salto indirecto a la funcion elegida] D --> E[Resultado] style B fill:#a6e3a1,color:#11111b style D fill:#89b4fa,color:#11111b
La tabla es solo uno de los cuatro usos canónicos que verás en cualquier base de código madura:
Tabla de despacho
Un índice elige la operación. Sustituye cadenas de condicionales por datos y aísla el añadido de casos nuevos.
Callback
La biblioteca te llama a ti para la decisión que no puede tomar: comparar, filtrar, notificar un evento.
Interfaz
Un struct de punteros más un contexto opaco: el polimorfismo de C, y el de todos los demás por debajo.
Carga dinámica
dlsym devuelve la dirección de un símbolo en tiempo de ejecución. Es la base de cualquier sistema de plugins.
Callbacks e inversión de control
Un callback invierte quién manda: tú no llamas a la biblioteca en cada paso, la biblioteca te llama a ti. El ejemplo canónico está en la propia libc:
static int cmp_int(const void *a, const void *b) {
int x = *(const int *)a, y = *(const int *)b;
return (x > y) - (x < y); // NUNCA x - y: la resta puede desbordar
}
int v[] = { 5, 2, 9, 1 };
qsort(v, 4, sizeof v[0], cmp_int);
qsort no sabe nada de enteros: sabe ordenar bloques opacos de n bytes y delega la única decisión que no puede tomar en tu función. Esa es la esencia del patrón: separar el algoritmo genérico de la política concreta.
qsort también ilustra el error de diseño más común en callbacks: no acepta un parámetro de contexto, así que si tu comparador necesita datos extra (un criterio, una tabla de colación) solo te queda una variable global, con lo que la función deja de ser reentrante y se rompe con hilos. Las variantes qsort_r de POSIX y qsort_s del anexo K existen precisamente para arreglarlo. La lección de diseño es inmediata: en tus propias APIs, todo callback lleva un void *ctx.
typedef bool (*Filtro)(int valor, void *ctx);
size_t filtrar(const int *src, size_t n, int *dst, Filtro f, void *ctx) {
size_t k = 0;
for (size_t i = 0; i < n; i++)
if (f(src[i], ctx)) dst[k++] = src[i];
return k;
}
Un segundo principio de diseño, más sutil: el callback no debe poder invalidar lo que la biblioteca está recorriendo. Si tu comparador reordena el array, o si el callback de una tabla hash inserta y provoca un rehash, el algoritmo que te llamó sigue operando sobre punteros e índices que ya no significan lo que creía. La documentación de qsort no lo prohíbe explícitamente porque el estándar da por hecho que el comparador es una función pura; en tus propias APIs, escribe esa restricción en la firma o en el contrato, porque quien las use no la va a adivinar.
El patrón de interfaz en C
Junta las dos ideas —tabla de funciones y contexto— y obtienes la interfaz. Un struct de punteros describe qué se puede hacer; un void * opaco guarda sobre qué:
typedef struct {
size_t (*escribir)(void *ctx, const char *datos, size_t n);
int (*cerrar)(void *ctx);
} SalidaVT;
typedef struct { const SalidaVT *vt; void *ctx; } Salida;
static inline size_t salida_escribir(Salida s, const char *d, size_t n) {
return s.vt->escribir(s.ctx, d, n); // despacho dinamico, escrito a mano
}
Un Salida puede ser un fichero, un socket, un buffer en memoria o un mock de test, y quien lo usa no cambia una línea. Compartir un único SalidaVT estático entre todas las instancias de un mismo tipo es exactamente lo que hace un compilador de C++ con sus tablas virtuales, solo que aquí lo escribes tú y puedes verlo.
Tres decisiones de ingeniería separan una interfaz sólida de una fuente de fallos. La primera: declara la tabla const y compártela, no la copies en cada objeto; así vive en la sección de solo lectura y un desbordamiento no puede reescribir a dónde saltan tus llamadas. La segunda: decide y documenta si un método puede ser NULL. Permitir métodos opcionales es cómodo —el llamador comprueba antes de invocar y aplica un comportamiento por defecto— pero cada comprobación olvidada es un salto a la dirección cero. La alternativa disciplinada es rellenar los huecos con implementaciones neutras en la construcción, de modo que ningún puntero de la tabla sea nunca nulo.
static const SalidaVT VT_FICHERO = { .escribir = fich_escribir, .cerrar = fich_cerrar };
static const SalidaVT VT_MEMORIA = { .escribir = mem_escribir, .cerrar = mem_cerrar };
Ese detalle del const no es cosmético. Un puntero a función almacenado en memoria escribible es el objetivo predilecto del atacante moderno: si logra sobrescribirlo, dicta a dónde salta tu programa sin necesidad de inyectar código. La respuesta del ecosistema es la integridad del flujo de control: -fsanitize=cfi en clang inserta una comprobación antes de cada salto indirecto para verificar que el destino tiene un tipo compatible, y las arquitecturas recientes lo respaldan en el hardware con instrucciones que marcan los destinos legítimos. Todo despacho dinámico paga hoy ese impuesto.
La tercera es de rendimiento y merece medirse antes de decidir: cuando el compilador consigue probar qué tabla se está usando en un punto concreto —porque la construcción y el uso están a la vista— aplica devirtualización, sustituye el salto indirecto por una llamada directa y a partir de ahí puede incrustarla. Ese es justo el efecto que persigue el LTO en programas grandes. Cuando no puede probarlo, pagas el precio completo del despacho dinámico.
Este nivel disuelve una mitología entera. “Métodos virtuales”, “interfaces”, “clases abstractas”, “duck typing”, “trait objects”: debajo de todos ellos hay el mismo mecanismo físico que acabas de escribir a mano —una tabla de punteros a función y un puntero al estado—. C++ genera la vtable por ti y le pone azúcar; Rust empaqueta puntero a datos y puntero a vtable en un dyn Trait; Go hace lo mismo con sus interfaces; Objective-C sustituye la tabla por una búsqueda por nombre y de ahí sale su dinamismo. C no te da ninguno: te da el ladrillo y te obliga a montar el edificio, y por eso al montarlo aprendes qué cuesta cada opción. Porque el precio también se vuelve visible: un salto indirecto que el predictor de saltos puede fallar, y sobre todo una llamada que el compilador no puede insertar en línea porque no sabe a dónde va, lo que apaga en cascada las optimizaciones que dependen de ver el cuerpo. Ese es el intercambio real de todo despacho dinámico, en cualquier lenguaje. Cuando lo has escrito con tus manos, entiendes por qué el kernel de Linux está lleno de structs como file_operations, por qué devirtualization es una optimización con nombre propio, y por qué el consejo “prefiere lo estático salvo que necesites lo dinámico” no es dogma, sino aritmética.
- Declara
int (*p)(int, int)yint *f(int, int), y explica en una frase la diferencia; reescribe ambas contypedef. - Implementa la tabla de despacho de tres operaciones y añade una cuarta sin tocar
aplicar; quita el chequeo de índice y razona qué primitiva acabas de regalar a un atacante. - Ordena un array de structs con
qsorty un comparador propio; luego intenta ordenar por un criterio que cambie en ejecución y comprueba dónde te obliga a poner una global. - Implementa
Salidacon dos backends —fichero y buffer en memoria— y escribe una función que use uno u otro sin saber cuál es. - Compila una llamada directa y una a través de puntero con
-O2 -Sy compara: localiza dónde el compilador insertó el cuerpo y dónde emitiócallindirecto.