Fundamentos de POO

POO es la abreviatura de programación orientada a objetos. Puede que te haya extrañado el título de esta sección. ¿Programación orientada a objetos? ¿En C? La programación orientada a objetos no es más que una técnica de programación. Cuando se dice que un lenguaje soporta POO significa que incorpora mecanismos específicos para hacer más fácil la POO. Pero en cualquier lenguaje actual se puede programar orientado a objetos.

Un objeto no es más que una zona de memoria con semántica asociada. Es decir, que tienen significado y hay un conjunto de operaciones que tiene sentido realizar sobre ellos. Un entero, por ejemplo, es un objeto en C. Tiene significado matemático y se pueden realizar las operaciones de suma, resta, etc. ¿Cómo hacemos objetos arbitrarios en C? Por ejemplo, ¿cómo hacemos un objeto que represente un empleado en un registro de personal?

La respuesta es con ayuda de una estructura, por ejemplo:

typedef struct empleado_ empleado;
struct empleado_ {
    const char* nombre;
    const char* puesto;
    double sueldo;
};

Los distintos elementos de la estructura se denominan atributos del objeto. Es todo aquello que le da significado a cada uno de los objetos.

Pero todavía no hemos hecho objetos, solo hemos definido la forma que tendrán esos objetos, es lo que se denomina clase. Para construir objetos se definen funciones que reciben los parámetros necesarios y devuelven un puntero a una estructura de éstas, los constructores. Por ejemplo:

empleado* empleado_new(const char* nombre, 
                       const char* puesto,
                       double sueldo)
{
    empleado* this = (empleado*)malloc(sizeof(empleado));
    empleado_init(this, nombre, puesto, sueldo);
    return this;
}

void empleado_init(empleado* this,
                   const char* nombre, 
                   const char* puesto,
                   double sueldo)
{
    this->nombre = strdup(nombre);
    this->puesto = strdup(puesto);
    this->sueldo = sueldo;
}

Los constructores en C suelen dividirse en dos partes, un XXX_new y un XXX_init. El primero reserva espacio en memoria dinámica, mientras que el segundo solo rellena los datos. Esto resulta útil para poder definir empleados en memoria dinámica o en variables automáticas:

empleado* ed = empleado_new("Paco", "Jefe", 1800.);
empleado ea;
empleado_init(&ea, "Pepe", "Currito", 1000.);

El empleado ed es un objeto en memoria dinámica, que debe ser liberado llamando a free. Sin embargo el empleado ea es un objeto en la pila, que se libera automáticamente cuando termine el ámbito de declaración. La segunda forma es también muy útil para construir vectores de empleado.

Algunos de vosotros os habréis dado cuenta de que aquí falla algo. Si el objeto ea se libera no vamos a poder liberar las cadenas correspondientes a nombre o puesto. La solución es utilizar una función especial para liberar todo lo reservado en el constructor, el destructor. Pero lo que hay que liberar es diferente si se ha utilizado XXX_new o XXX_init. ¿Cómo conseguimos que se comporte de forma diferente dependiendo de dónde haya sido construido?

La técnica para resolver este problema se denomina funciones virtuales. Hay varias formas de implementarlo, lo haremos de la forma más simple posible. En lugar de usar una función para liberar el objeto vamos a usar un puntero a función y ese puntero cambiará dependiendo del modo en que se ha construido el objeto. El decir, el propio objeto lleva no solo datos sino también punteros a funciones que pueden cambiar dependiendo del caso.

typedef struct empleado_ empleado;
typedef void (*empleado_func)(empleado*);
struct empleado_ {
    const char* nombre;
    const char* puesto;
    double sueldo;
    empleado_func destroy;
};

Y el destructor puede ser una función tan simple como:

void empleado_destroy(empleado* this)
{
    this->destroy(this);
}

Evidentemente el trabajo no está en esa función sino en aquella a la que apunta e->destroy. Y además es preciso garantizar que siempre apunta a una función válida, porque de otro modo al llamar al destructor se produciría un error catastrófico. Ésta y cualquier otra garantía que sea preciso mantener para que el estado sea siempre consistente se denominan invariantes de clase.

El constructor es responsable de establecer los invariantes de clase. Todas las demás operaciones son responsables de mantener los invariantes de clase. Veamos cómo quedaría la función empleado_init.

static void empleado_free_members(empleado*);

void empleado_init(empleado* this,
                   const char* nombre, 
                   const char* puesto,
                   double sueldo)
{
    this->nombre = strdup(nombre);
    this->puesto = strdup(puesto);
    this->sueldo = sueldo;
    this->destroy = empleado_free_members;
}

static void empleado_free_members(empleado* this) 
{ 
    free(this->nombre);
    free(this->puesto);
}

Pero esto no libera la estructura de empleado cuando se construye con empleado_new. Así que en ese caso habrá que cambiar el destructor:

static void empleado_free(empleado*);

empleado* empleado_new(const char* nombre, 
                       const char* puesto,
                       double sueldo)
{
    empleado* this = (empleado*)malloc(sizeof(empleado));
    empleado_init(this, nombre, puesto, sueldo);
    this->destroy = empleado_free;
    return this;
}

static void empleado_free(empleado* this)
{
    empleado_free_members(this);
    free(this);
}

La función empleado_destroy es un ejemplo de operación que se puede realizar sobre un empleado. Estas operaciones se llaman de forma general métodos del objeto, y por tratarse de la operación que libera los recursos del empleado se llamaría de forma más específica destructor. Además es un método que se comporta de forma diferente según el tipo de empleado sobre el que se llame. Este tipo de métodos se llaman en general métodos virtuales, y al tratarse de un destructor sería también de forma más específica destructor virtual.

Los métodos virtuales se utilizan en combinación con otra característica muy importante de la POO, la herencia. En el ejemplo de empleado podemos considerar el caso de un gerente que básicamente funciona igual que un empleado, pero tiene otras características, como por ejemplo bonus por productividad. Por tanto el cálculo de las retribuciones anuales será diferente según se trate de un empleado normal o de un gerente. Eso se puede conseguir añadiendo otra función virtual para ello.

typedef struct empleado_ empleado;
typedef void (*empleado_func)(empleado*);
typedef double (*empleado_func_double)(empleado*);
struct empleado_ {
    const char* nombre;
    const char* puesto;
    double sueldo;
    empleado_func destroy;
    empleado_func_double retribuciones;
};

El campo retribuciones se inicializa en el constructor, de forma similar a destroy utilizando la siguiente función:

static double empleado_retribuciones_14pagas(empleado* this)
{
    return 14.*this->sueldo;
}

Con lo que el método quedaría tan simple como:

double empleado_retribuciones(empleado* this)
{
    return this->retribuciones(this);
}

Sin embargo, en el caso del gerente tendríamos un objeto con más atributos:

typedef struct gerente_ gerente;
typedef void (*gerente_func)(gerente*);
struct gerente_ {
    empleado parent;
    double bonus;
};

Hemos colocado como primer elemento de la estructura un empleado. Esto hace que la primera parte de un gerente sea la que corresponde a un empleado. Por tanto los métodos de empleado siguen funcionando sobre un objeto de la clase gerente, porque solo acceden a esa primera parte.

Por otro lado, la clase gerente debe redefinir el método de cálculo de retribuciones:

static double gerente_retribuciones(empleado* this)
{
    gerente* g = (gerente*) this;
    double r = 14. * this->sueldo;
    if (gerente_objetivos_conseguidos(g))
        r += g->bonus;
    return r;
}

void gerente_init(gerente* this,
                  const char* nombre, 
                  const char* puesto,
                  double sueldo,
                  double bonus)
{
    empleado* parent = &this->parent;
    empleado_init(parent, nombre, puesto, sueldo);
    parent->retribuciones = gerente_retribuciones;
}

Observa cómo la nueva implementación del método retribuciones convierte su argumento en un gerente. Si se ha llamado a este método es seguro que se trata de un gerente. Ahora podemos calcular las retribuciones de cualquier empleado sea o no gerente:

empleado* equipo[] = {
    (empleado*) gerente_new("Paco", "Jefe", 1800., 4000.),
    empleado_new("Pepe", "Currito", 1000.),
    empleado_new("Juan", "Currito", 1000.),
};

#define NELEMS(a) (sizeof(a)/sizeof(a[0]))
double coste_personal = 0.;
for (int i=0; i<NELEMS(equipo); ++i)
    coste_personal += empleado_retribuciones(equipo[i]);

La herencia es un mecanismo muy efectivo para tratar de forma homogénea a objetos, pero introduce muchísimo acoplamiento. Intenta minimizarla todo lo posible.

Esta implementación de POO en C es primitiva pero para los fines del taller nos bastará. En proyectos reales convendría echar un vistazo a las bibliotecas que ya existen. En particular merece la pena destacar GObject y COS. Cada una tiene sus ventajas y sus inconvenientes que habrá que valorar.

results matching ""

    No results matching ""