Polimorfismo y tablas de despacho con punteros a función

En C, el polimorfismo no es una característica del lenguaje, sino un patrón de diseño que aprovechamos mediante punteros a función. Un puntero a función es una variable que almacena la dirección de memoria de una función ejecutable; al invocarlo, el CPU salta a esa dirección y comienza la ejecución. Para implementar algo similar a la Programación Orientada a Objetos (POO), utilizamos una estructura de datos que contenga punteros a funciones, actuando como una interfaz.

Para evitar que cada instancia de una estructura cargue con el coste de memoria de múltiples punteros (lo que ocuparía espacio extra en cada objeto), se utiliza el concepto de vtable (virtual method table). En lugar de tener los punteros directamente en la estructura de los datos, la estructura contiene un único puntero a una tabla estática compartida que contiene todas las implementaciones de los métodos para ese tipo específico. Esto es lo que hace C++ bajo el capó.

Esta técnica es fundamental cuando necesitas implementar tablas de despacho (dispatch tables). En lugar de utilizar estructuras switch o if-else con complejidad $O(n)$ para decidir qué función llamar basándote en un enumerador, utilizas un array de punteros a función donde el índice es el enumerador, permitiendo un acceso $O(1)$. Es el mecanismo que utiliza la biblioteca estándar de C para la gestión de archivos (FILE*), donde la estructura interna del objeto FILE apunta a una tabla de operaciones (fops) que cambian según si el archivo es un archivo regular, un pipe o un socket [POSIX].

Debes usar este patrón cuando construyas máquinas de estados, sistemas de plugins o arquitecturas de drivers donde el comportamiento debe ser intercambiable en tiempo de ejecución. Si intentas usar estos punteros de forma incorrecta—específicamente, si llamas a una función a través de un puntero cuya firma (argumentos o tipo de retorno) no coincide exactamente con la función original—entrarás en el terreno del comportamiento indefinido (UB). Esto suele manifestarse como corrupción de la pila (stack corruption) o fallos de segmentación, ya que el compilador no podrá limpiar adecuadamente los registros o la pila al retornar.

#include <stdio.h>
#include <stdlib.h>

/* --- SECCIÓN 1: SIMULACIÓN DE POO (VTABLE) --- */

typedef struct Shape Shape;

/* La vtable define la interfaz: todos los "objetos" de este tipo 
   deben implementar estas funciones. */
typedef struct {
    void (*draw)(const Shape*);
    double (*area)(const Shape*);
    void (*destroy)(Shape*);
} ShapeVTable;

/* La base: la primera vez que se usa una estructura "hija", 
   debe ser el primer miembro para permitir el casting de punteros. */
struct Shape {
    const ShapeVTable *vtable;
};

/* Implementación concreta: Círculo */
typedef struct {
    Shape base;
    double radius;
} Circle;

void circle_draw(const Shape *s) {
    const Circle *c = (const Circle *)s;
    printf("Dibujando un círculo de radio %.2f\n", c->radius);
}

double circle_area(const Shape *s) {
    const Circle *c = (const Circle *)s;
    return 3.14159265 * c->radius * c->radius;
}

/* Tabla estática compartida para todos los círculos */
static const ShapeVTable CIRCLE_VTABLE = {
    .draw = circle_draw,
    .area = circle_area,
    .destroy = NULL // En este ejemplo, el área es estática
};

/* Implementación concreta: Rectángulo */
typedef struct {
    Shape base;
    double width;
    double height;
} Rect;

void rect_draw(const Shape *s) {
    const Rect *r = (const Rect *)s;
    printf("Dibujando un rectángulo de %.2f x %.2f\n", r->width, r->height);
}

double rect_area(const Shape *s) {
    const Rect *r = (const Rect *)s;
    return r->width * r->height;
}

static const ShapeVTable RECT_VTABLE = {
    .draw = rect_draw,
    .area = rect_area,
    .destroy = NULL
};

/* --- SECCIÓN 2: TABLA DE DESPACHO (DISPATCH TABLE) --- */

typedef enum {
    OP_ADD,
    OP_SUB,
    OP_MUL,
    OP_COUNT // Útil para definir el tamaño del array
} OpCode;

/* El tipo de función para nuestra máquina virtual simple */
typedef int (*OpFunc)(int, int);

int op_add(int a, int b) { return a + b; }
int op_sub(int a, int b) { return a - b; }
int op_mul(int a, int b) { return a * b; }

/* Tabla de despacho O(1): el índice es el OpCode */
static const OpFunc DISPATCH_TABLE[OP_COUNT] = {
    [OP_ADD] = op_add,
    [OP_SUB] = op_sub,
    [OP_MUL] = op_mul
};

int main(void) {
    /* --- Demostración de Polimorfismo --- */
    Shape *shapes[2];
    
    Circle *c = malloc(sizeof(Circle));
    if (!c) return EXIT_FAILURE;
    c->base.vtable = &CIRCLE_VTABLE;
    c->radius = 5.0;

    Rect *r = malloc(sizeof(Rect));
    if (!r) { free(c); return EXIT_FAILURE; }
    r->base.vtable = &RECT_VTABLE;
    r->width = 4.0;
    r->height = 6.0;

    shapes[0] = (Shape *)c;
    shapes[1] = (Shape *)r;

    printf("--- Polimorfismo ---\n");
    for (int i = 0; i < 2; i++) {
        shapes[i]->vtable->draw(shapes[i]);
        printf("Área: %.2f\n", shapes[i]->vtable->area(shapes[i]));
    }

    /* --- Demostración de Dispatch Table --- */
    printf("\n--- Dispatch Table ---\n");
    int val1 = 10, val2 = 5;
    OpCode ops[] = {OP_ADD, OP_SUB, OP_MUL};

    for (int i = 0; i < 3; i++) {
        int res = DISPATCH_TABLE[ops[i]](val1, val2);
        printf("Operación %d: %d\n", ops[i], res);
    }

    /* Limpieza */
    free(c);
    free(r);

    return EXIT_SUCCESS;
}

Análisis detallado

En el ejemplo, la estructura Shape actúa como nuestra clase base. Fíjate en Circle. Para que el casting de (Shape *)c funcione correctamente, el miembro base debe ser el primer elemento de Circle. Esto garantiza que la dirección de memoria de un Circle sea idéntica a la dirección de su parte Shape, permitiendo que el compilador acceda al vtable sin offsets incorrectos.

Cuando ejecutamos shapes[i]->vtable->draw(shapes[i]), lo que ocurre es una triple indirección:
1. Accedes al puntero vtable en la estructura shapes[i].
2. Accedes a la dirección de la función draw dentro de esa tabla.
3. Saltas a la dirección de la función, pasando la dirección de shapes[i] como el argumento self (el parámetro const Shape *s).

En la tabla de despacho, DISPATCH_TABLE[ops[i]] elimina la necesidad de un switch. Si tuviéramos 200 instrucciones, el switch podría generar un árbol de comparaciones o una tabla de saltos optimizada por el compilador, pero el array es explícito y garantiza un acceso constante.

Al trabajar con bibliotecas dinámicas mediante dlopen y dlsym [POSIX], notarás que dlsym devuelve un void*. El estándar C estricto prohíbe convertir un void* a un puntero a función, aunque POSIX sí lo permite. Para máxima portabilidad, se suele hacer un cast intermedio a un tipo de función compatible, aunque en la práctica, la mayoría de los sistemas modernos (Linux/macOS) manejan esto sin problemas siempre que la firma sea idéntica.

El error frecuente

Un error crítico ocurre al realizar el casting de punteros a función cuando la firma de la función no coincide con la esperada por el puntero.

// ERROR: Firma incompatible
typedef void (*FuncSig)(int);
typedef int  (*FuncSigWrong)(double);

void real_func(int x) { printf("%d\n", x); }

// ...
FuncSigWrong f = (FuncSigWrong)real_func; // Casting peligroso
f(3.14); // UB: El callee espera un 'int' en un registro, 
         // pero el caller ha pasado un 'double' en otro.

Si intentas llamar a real_func a través de f, el programa probablemente fallará o corromperá la memoria, porque f intentará tratar el valor de punto flotante como un entero o dejará basura en la pila al intentar retornar. Herramientas como AddressSanitizer no siempre detectan esto porque la corrupción ocurre por la convención de llamada (calling convention), no por un acceso ilegal a memoria, por lo que el uso de tipos estrictos es vital.

51

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio