You are currently browsing the tag archive for the ‘programación’ tag.

¿Qué son functores?

El lenguaje C ofrece punteros a funciones. Estos permiten llamar a diferentes funciones según el valor del puntero. Un puntero así no apunta a un dato sino a un comportamiento – el comportamiento que implementa la función.

Punteros ofrecen, además, apuntar a direcciones de memorias erróneas. Así es posible ejecutar cualquier dato como función. Esto encanta a los autores de código malicioso. Para los demás interesa evitarlo, es decir, como se puede limitar las direcciones a que un puntero de función puede apuntar.

La solución viene con C++. Functores en el lenguaje C++ son clases que tienen sobrecargado el operador(). Esto permite utilizar una instancia de una clase como una función – y sólo una función y no cualquiera.

¿Cómo se usan functores?

El functor más simple es una clase como en este ejemplo:

class MiFunctor
{
public:
    int operator() ()  { return 1; }
};

Este functor es una clase con el método operator() sin parámetros. Nótese que la primera pareja de paréntesis es el nombre del operador y la segunda para los parámetros si los hubiera.

La clase de functor de arriba se puede usar como en el ejemplo siguiente.

MiFunctor miInstancia;
std::cout << miInstancia();

La primera línea crea un objeto del functor y la segunda llama primero al método MiFunctor::operator() y luego imprime el valor de retorno – que es un uno.

Dónde se usan los functores

El método operator() puede tener también parámetros. Como ejemplo definimos un functor que devuelve el menor de dos vectores.

// El tipo de vector que usamos
struct Vector
{
    float x, y;
};

// El functor que compara dos vectores
class VectorMenor
{
public:
    bool operator() (const Vector& a, const Vector& b) const
    {
        // La forma de comparación no importa aquí.
        // Pero para ser completo lo especificamos:
        // devuele a, si la primera coordenada de a es
        // menor o si la primera es igual y la segunda
        // es menor.
        if (a.x < b.x || (a.x == b.x && a.y < b.y))
        {
            return a;
        }
        else
        {
            return b;
        }
    }
};

// Y así usamos el functor
Vector u, v;
bool u_es_menor = VectorMenor(u, v);

Pues bien, matemáticamente no hay una operación menor entre dos vectores. Así ¿para qué sería útil este functor VectorMenor?

Echemos un vistazo a la definición (simplificada) del contendor std::map de la biblioteca estándar de C++.

template <class Key,
          class Value,
          class Compare = std::less<Key> >
class std::map {};

¿Una vez te has preguntado qué signífica este std::less?

Pues, la clase std::map guarda sus elementos ordenados por el atributo – es decir la clase Key. Así no necesita buscar por todos los elementos cuando busca uno en concreto. Comprueba si el atributo del elemento buscado es mayor or menor que el atributo del elemento en medio de todos los elementos. Según el resultado continúa la búsqueda con la mitad inferior o superior. De esta manera se divide entre dos el rango de elementos a buscar tras cada comparación. Esto es mucho más rápido que buscar por todos los elementos.

Para ordenar los elementos por los atributos, la clase std::map require que se pueda aplicar el operador < al tipo del atributo. Este operador existe para los tipos básicos pero no para una clase cualquiera. Por eso, la clase std::map ofrece especificar un functor que determina la “menor” de dos instancias de Key. El método operator() de este functor toma dos argumentos de tipo Key como nuestro ejemplo VectorMenor. El functor de comparación por defecto es std::less – que simplemente usa el operator< global.

Un mapa que guarda cadena de textos ordenados por vectores, se puede definir así:

std::map<Vector, std::string, VectorMenor> mi_mapa;

Nótese que el functor no es una variable que apunta a una función sino un tipo de una variable. Es decir, una clase.

Herencia de functores

Los functores son clases de C++ y, por lo tanto, se pueden definir todos los métodos en ellos como en cualquier otra clase. Y también se pueden heredar.

Un functor derivado puede sobrescribir el método operator() con su propia implementatción. Más interesante es aún declarar un operator() virtual. Así podemos crear una interfaz de functor que se puede implementar de varias formas.

Como ejemplo definimos una interfaz para comparar cadenas de textos en diferentes idiomas. Esto puede ser interesante porque el orden alfabético varia – en español incluso en el mismo idioma: antiguamente se consideró la combinación “ll” como una letra que se ordenó tras la “l” simple. Por eso, la palabra “llamar” vino después de “luna” en los diccionarios antiguos.

// Definimos una interfaz para comparar cadenas de textos.
class InterfazCadenaDeTextoMenor
{
public:
    virtual bool operator() (const std::string& a,
                             const std::string& b) const = 0;
};

La interfaz obliga que el método operator() tenga dos parámetros del tipo std::string y devuelve un bool. El método operator() está declarado const ya que no modifica variables internos de las clase InterfazCadenaDeTextoMenor. No obstante, esto no es obligatorio. Puede haber functores con memoria que, sí, modifican datos internos.

Es importante tener la misma signatura cuando trabajamos con métodos virtuales. Por eso los tipos de retorno y de parámetros deben estar definidos fuera de la clase. Si usaramos InterfazCadenaDeTextoMenor en la clase base como tipo parámetro y ClaseDerivada en la clase derivada, entonces el método virtual no sería sobrescrito, ya que tendría otra signatura.

Boost Bind and std::function

Los punteros de funciones y los functores pueden aparecer igual en el uso.

// Declaración de tipo de un functor
class Functor
{
public:
    void operator() ();
};

// Declaración de tipo de un puntero de función
typedef void (*Puntero)();

// Instancia de un functor y un puntero
Functor functor;
Puntero puntero;

// Uso de un functor y un puntero
functor();
puntero();

Como vemos, tanto el functor como el puntero aparecen con nada más que paréntesis. Para C++ son dos tipos muy distintos, pero para el usuario hacen lo mismo. Por eso vino la idea de crear un super-tipo que une los punteros y los functores en un comportamiento igual. Este existe en la biblioteca Boost con en elemento boost::bind.

Con boost::bind podemos crear un tipo que se inicializa o bien con un puntero de función o bien con un functor. Algo similar es el objeto std::function de la biblioteca de C++ a partir del estándar 2011.

Conclusión

Los functores tienen el mismo uso como punteros de funciones. No obstante ofrecen la seguridad de no ejecutar código cualquiera como puede pasar con punteros mal inicializados. Además, los functores ofrecen todas las capabilidades de clases de C++. En código mixto de punteros y functores se ofrece boost::bind y std::function como tipo común.

Referencias

Lectura adicional

Punteros a funciones son una herramienta potente para agilizar el flujo de un programa. ¡Y una forma eficaz de perderse en el código!

El concepto de punteros o “referencias” a funciones existe en muchos lenguajes de programación. Por ejemplo, en Java Script, se puede asignar una función a una variable miembro de un objeto. Esta variable representa entonces la función asignada. En C y C++, estas variables deben tener, además, el tipo de la función a que apuntan. Restringir el tipo de la función reduce el número de posibles errores a la hora de ejecución y los incrementa a la hora de compilar el código.

En este artículo nos enfocamos a punteros de funciones en C y C++ y asumimos que el lector ya está familiarizado con las diferentes opciones de declarar const a variables y punteros, como se describen en el artículo
Como usar const en C++
.

La declaración de un tipo de función

Comenzemos con un ejemplo. ¿Quál es el tipo de la siguiente función?

int una_función(char parámetro);

La solución es

int (*puntero_a_una_función)(char)

Para extraer el tipo de la declaración de función hemos hecho lo siguiente:

  1. Hemos eliminado los nombres de los parámetros.
  2. Hemos puesto un asterisco delante el nombre del tipo y hemos puesto todo esto entre paréntesis.
  3. Hemos reemplazado el nombre de la función por el nombre puntero_a_una_función.

Echemos un vistazo más detallado.

Los nombres de los parámetros ya no aparecen. El tipo de una función es el mismo, si el valor de retorno y si los parámetros tienen el mismo tipo. Los nombres que tienen no influyen. El nombre de la función desaparece también. El nombre de la función juega un papel similar como un valor constante para una variable. El tipo de la variable no depende del nombre de una constante.

El asterisco indica que el tipo es un puntero. Lo ponemos entre paréntesis por la preferencia de operadores del lenguaje. Sin ellas habría un error de sintáxis, porque la llamada de función (paréntesis tras un identificador) tiene mayor prioridad que el operador asterisco.

Finalmente hemos cambiado el nombre. Decir más correctamente, hemos introducido un nuevo nombre. Este nuevo nombre puntero_a_una_función representa una variable que puede guardar la dirección de una función con el tipo especificado.

Como el uso de tipos de funciones es poco legible, se suele casi siempre definir un tipo de función mediante un typedef. Esto lo hacemos aquí también.

int una_función(char parámetro);

// Definición de una variable dirección_de_una_función
int (*dirección_de_una_función)(char) = NULL;

// Definición de un tipo con typedef.
typedef int (*TIPO_DE_UNA_FUNCIÓN)(char);

// Definición de una variable con el tipo de typedef
// Se inicializa la variable con la dirección de una_función.
TIPO_DE_UNA_FUNCIÓN otra_dirección = una_función;

Por cierto, algo así no se puede hacer:

// Error
int (*dirección_de_una_función)(char) =
    int una_función(char parámetro);

Esta línea haría algo como delcarar una_función y asignar su dirección al mismo instante. Esto no está permitido.

Usar un puntero de función

Tras saber expresar el tipo de una función nos interesa usarlo. Lo podemos usar directamente para definir una variable.

// Una función que ya conocemos.
int una_función(char parámetro);

// Definimos nuestro tipo de función y una variable.
typedef int (*TIPO_DE_UNA_FUNCIÓN)(char);
TIPO_DE_UNA_FUNCIÓN puntero_a_una_función = una_función;

// El valor de una variable se puede asignar a otra.
TIPO_DE_UNA_FUNCIÓN otro_puntero = puntero_a_una_función;

// Se pueden comparar punteros de funciones,
// pero no se pueden aplicar las operaciones aritméticas 
// de los punteros a constantes y variables.
const bool igual = 
    puntero_a_una_función == otro_puntero; // Bien
puntero_a_una_función++; // Error de compilación

// Y, desde luego, podemos llamar a una función
// Se puede usar dos sintaxis diferentes
const char parámetro = 'a';
const int resultado = puntero_a_una_función(parámetro);
const int resultado2 = (*puntero_a_una_función)(parámetro);

Que se puede llamar a una función con dos sintáxis diferentes es un tanto peculiar, ya que suele haber una diferencia importante entre en un puntero y un puntero dereferenciado. Pero compilan los dos.

La pregunta es: ?cuál de las dos formas es la preferible? Pues, es cuestión de gusto. Yo prefiero la forma (*puntero_a_una_función)() ya que deja más claro que punter_a_una_función el el nombre de una variable y no de una función.

Como C y C++ permiten conversiones entre todo tipo de punteros, se pueden convertir también los punteros de funciones a punteros de variables.

// Obtenemos un puntero de variable a mi función.
int una_función(char parámetro);
char* puntero_a_datos = (char*)una_función;

// Con este puntero_a_datos no sólo puedes leer el código
// ejecutable de la función. También puedes modificarlo.
puntero_a_datos[5] = 0xFF;

Es obvio, que estas conversiones son ideales para hackers, pero será poco probable que se necesitan durante un uso normal del lenguaje. Por eso puedes esperar que el compilador te avisará con insistencia. Además, el código ejecutable de una función es de sólo lectura. O debería. Conseguir asignar algo a la memoria del código ejecutable es una manera excelente para introducir un error que no se encontrará hasta que alguien intente llamar a esta función.

Ejemplos con más clase

Punteros, referencias y constantes aparecen tal cual en un tipo de función.

// Declaración de una función con const y referencia.
MiClase *const devuelve_mi_instancia(const std::string& nombre,
                                     int* puntero);
                                     
// Definición de una variable puntero_a_devuelve_mi_instancia
MiClase *const (*puntero_a_función)(const std::string&, int*);

// Mejor definir un tipo con typedef
typedef MiClase *const (*TIPO_FUNC)(const std::string&, int*);
TIPO_FUNC otro_puntero_a_función;

// Los punteros a funciones también pueden ser constantes
MiClase *const (*const ptr_const)(const std::string&, int*) =
    puntero_a_función;

// Entonces ya no se puede asignar otra función.
ptr_const = NULL;  // Error

También se pueden llamar a los métodos estáticos de una clase. Tienen el mismo tipo que funciones globales.

class MiClase
{
    // Si el método no es público, no lo podemos acceder desde
    // fuera.
public:
    // Declaración de un método estático sin parámetro
    static int método_estático(void);
};

// El tipo del primer método. Este método no tiene parámetros.
typedef int (*TIPO_MÉTODO)();

// Al contrario de una función global
// la dirección de un método de clase se extrae con ampersand
TIPO_MÉTODO puntero_a_método = &MiClase::método_estático;

// Se puede llamar como una función global
const int resultado = (*puntero_a_método)();

Conceptualmente, las funciones miembros no estáticos no tienen dirección sin una instancia de la clase. Por eso se deben referenciar con un objeto. Físicamente el mismo método se encuentra a la misma dirección para todos los objetos. No obstante, el valor del puntero this es distinto.

class MásClase
{
public:
    // Declaración de un método normal
    float método_normal(int);
    
    // Declaración de un método constante
    float método_const(int) const;
};

// El tipo para métodos no estáticos
typedef float (MásClase::*TIPO_VAR)(int);
typedef float (MásClase::*TIPO_CONST)(int) const;
TIPO_VAR puntero_a_método = &MásClase::método_normal;
TIPO_CONST puntero_a_método_const = &MásClase::método_const;

// Llamar a este método requiere un objeto, ya que
// el método no es estático.
MásClase objeto;
const int parámetro = 5;
float resultado = (objeto.*puntero_a_método)(parámetro);
resultado = (objeto.*puntero_a_método_const)(parámetro);

El TIPO_VAR permite también la asignación de un TIPO_CONST, igual como una variable permite la asignación de una constante.

Como ejemplo de ¡basta ya! terminamos con un puntero a un patrón de función miembro.

// Un patrón de clase
template <class ParamClase>
class UnaClase
{
public:
    // Declaración de un patrón de método
    template <class ParamMétodo>
    void método_template(ParamMétodo& salida);
};

// Definir una variable con el tipo del template
// El tipo del patrón del método no aparece salvo en los
// parámetros.
typedef void (UnaClase<int>::*TIPO_TEMPLATE)(float&);
TIPO_TEMPLATE puntero_a_método_template =
    &UnaClase<int>::método_template<float>;

// Llamar a este método requiere un objeto, ya que
// el método no es estático.
UnaClase<int> objeto;
float parámetro;
(objeto.*puntero_a_método_template)(parámetro);

Conclusión

En este artículo hemos tratado como se declaran y se usan punteros a funciones. No hemos hablado para qué se usan, ya que es otro tema relativamente largo.

En general es preferible no usar punteros a funciones cuando es posible porque complican el análisis del código: ya no se puede saber fácilmente el nombre de la función que se llama. En el depurado saldría más bien la dirección de memoria a que el puntero de función apunta.

Por eso preferible pensar en alternativas. Cuando se trata eligir entre pocas funciones conocidas a tiempo de compilación, se podría pensar en un switch que llama a estas funciones según una variable de estado. Quizá es menos elegante pero herramientas de análisis de código no se pierden tan fácilmente. En C++, además, se ofrecen “functores” – es decir clases que sobrecargan el operador (). Físicamente functores son objetos que se pueden asignar como variables. Por eso tienen un tipo bastante más legible que punteros a funciones.

Referencias

Lectura adicional

La directiva de preprocesador #include se usa en los lenguajes C y C++ para “incluir” las declaraciones de otro fichero en la compilación. Esta directiva no tiene más misterio para proyectos pequeños. En cambio, puede ayudar aprovechar bien esta directiva en proyectos con un gran número de subdirectorios.

El efecto de #include

Cuando el preprocesador encuentra una línea #include "fichero", entonces reemplaza esta línea por el fichero incluido. Así procede con todas las directivas de inclusión – también en aquellas anidadas en los fichero ya a su vez incluidos. Es decir, existe un sólo fichero grande tras la precompilación.

No obstante, esta unión de varios ficheros no tiene lugar físicamente. Lo que sucede es que se interrumpe la compilación del fichero actual, se compila el fichero incluido y, tras compilarlo, se continúa con el primero. Por eso, el compilador puede decirnos, en qué fichero tuvo lugar un error de compilación.

En cambio, conviene tener esta idea del fichero único en mente, porque a veces ayuda a encontrar errores. Uno muy típico es olvidarse el punto y coma tras la declaración de una clase.

En este caso hay una declaración de clase en el fichero incluido:

class MiClase {}

En el segundo puede haber algo así:

#include "mi_clase.h"
MiClase miInstancia;

En este ejemplo, el compilador se quejará de que aquí no se puede definir un nuevo tipo en la línea de MiClase miInstancia aunque esta línea es correcta. El error verdadero es la falta del ; en el fichero incluido. Lo que el compilador realmente ve es

class MiClase {} MiClase miInstancia;

No obstante, el programador no lo ve, porque el código está distribuido sobre dos fichero y el error se produce en el correcto.

La precompilación sólo modifica el código a nivel textual. No entiende del sintaxis del lenguaje. Por eso es posible distribuir el código de forma arbitraria. Por ejemplo, el siguiente ejemplo compilaría.

Fichero incluido

{
	int

Fichero principal

void main(void)
#include "fichero_incluido"
	a;
}

Esto es así porque el compilador ve el conunto

void main(void)
{
	int
	a;
}

La posición del #include

Lo habitual es posicionar las inclusiones al inicio de cada fichero. Esto tiene sentido, porque se suele requerir declaraciones básices antes de declarar clases más complejas. Mi lugar preferido en los ficheros de cabecera es tras el guardián de inclusión múltiple.

// Guardian de inclusión múltiple
#ifndef FICHERO_YA_INCLUIDO
#define FICHERO_YA_INCLUIDO

#include "declaraciones_basicas.h"

#endif

Así se evita que un compilador poco sofisticado abre otra vez el mismo conjunto de ficheros cuando se inluye un fichero de cabecera dos o más veces.

En los ficheros de definición (los .c o .cpp), los guardianes de inclusión múltiple no hacen falta. No obstante, puede haber una restricción importante cuando se usan cabeceras precompiladas. En este caso, todos los ficheros fuente deben incluir primero el mismo fichero de cabecera – que es aquello que define la cabecera precompilada. El compilador de C++ de Borland permite varios ficheros de cabecera para la definición de una cabecera precompilada. Estas inclusiones deben ser lor primeros ficheros incluidos y se deben incluir en el mismo orden.

Puede darse el caso de no poner las inclusiones en el inicio de un fichero. Esto es frecuente en los fichero que se podrían denominar “programados en directivas de precompilación”. Normalmente se trata de ficheros de cabeceras con definiciones muy básicas como ajustes a la plataforma empleada. Por ejemplo, en medio de un fichero “definiciones_basicas.h” puede haber unas líneas

#if PLATAFORMA_ES_LINUX
#include "funcionalidad_gratis.h"
#elif PLATAFORMA_ES_MICROSOFT_WINDOWS
#include "funcionalidad_cara.h"
#elif PLATAFORMA_ES_APPLE
#include "funcionalidad_muy_cara.h"
#else
#error Esta plataforma no está soportada
#endif

La diferencia entre “” y <>

La directiva #include existe en dos versiones. En una se pone el nombre de fichero entre comillas, en la otra entre paréntesis angulares (el signo menor y mayor como “comillas”).

#include "fichero_con_comillas.h"
#include <fichero_entre_menor_y_mayor.h>

La versión con los paréntesis angulares busca los ficheros en todos los directorios que se han especificado en la llamada al compilador – normalmente con la opción “-I”. Estos directorios se suelen rastrear por el fichero incluido en el orden en que aparecen en la línea de comando.

Cuando se incluye un fichero entre comillas, entonces el compilador busca este fichero primero en el mismo directorio que el fichero actualemente compilado y después en los demás directorios. Es decir, la versión con comillas se diferencia de la versión con paréntesis angulares únicamente por buscar primero en el directorio del fichero compilado. Tras no encontrarlo ahí actúa igual.

Esto muchas veces no es ninguna diferencia, ya que se suelen especificar todos los directorios en la línea de comando del compilador. Así no se suele dar el caso que se puede incluir un fichero con comillas pero no con paréntesis angulares.

Más significativo es el comportamiento ante ficheros con el mismo nombre en distintos directorios. En este caso la versión con comillas da preferencia sobre el fichero en el mismo directorio y esto suele ser el mejor acertado. Aunque sea preferible nombrar ficheros de forma única en un proyecto, es posible que no se pueda evitar tener dos ficheros con el mismo nombre cuando se incluyen varias bibliotecas de terceros.

De ahí se puede deducir que es imperativo incluir cabeceras de la misma biblioteca con comillas. De esta forma se puede asegurar que las cabeceras de una biblioteca se incluyan entre si aunque haya otros con el mismo nombre en uno de los directorios especificados en la línea de comandos.

Además, incluir con comillas puede dar al programador un significado adicional: que este fichero está bajo la custodia de mi equipo de desarrollo. Las cabeceras incluidas con paréntesis angulares son de bibliotecas de terceros. Los primeros ficheros puedo modificar si hace falta, los segundos no.

El orden de las inclusiones

El orden de las directivas #include no importa cuando todos los identificadores del programa son únicos. No obstante, a veces no lo son y conviene generar el error “este identificador ya existe” en nuestro código y no en el código de una biblioteca estándar.

Esto se consigue incluyendo primero las caberas de terceros. Si aparece un error de identificador doble, entonces aparece en la segunda definición – que es la nuestra – y ahí podemos cambiar el nombre del objeto sin problema.

En proyectos realmente grandes puede haber varias bibliotecas de distintas dependencias. Por la misma razón de generar los errores de identificadores dobles en el código más fácilmente modificable, conviene incluir las bibliotecas más básicas primero. Dentro del mismo nivel podemos ordenar los ficheros incluidos de forma alfabética. Esto ayuda a encontrar inclusiones que faltan o sobran.

El siguiente código muestra una secuencia de inclusiones para un fichero “definicion_de_mi_clase.cpp”.

// Primero se debe incluir la cabecera de precompilación
#include "cabecera_de_precompilacion.h"

// Segundo, incluir la cabecera correspondiente
// a este fichero de implementación.
// Esto deja más claro, que es la
// clase que se implementa aquí.
#include "definicion_de_mi_clase.h"

// A continuación inclusiones de la biblioteca estándar.
// Se usan paréntesis angulares.
#include <vector>

// Inclusiones de otras bibliotecas de terceros
#include <wx.h>
#include <gl.h>

// Inclusiones de subbibliotecas básicas de
// mi proyecto con comillas
#include "mis_definiciones_basicas.h"

// Luego las demás inclusiones de mi proyecto
#include "clases_auxiliares.h"
#include "más_definiciones_para_mi_clase.h"

Usar rutas relativas

Una forma de evitar nombres de fichero dobles es incluir ficheros con rutas relativas.

#include "definiciones/tipos_básicos.h"
#include "funcionalidad/tipos_básicos.h"

La desventaja de esta forma es, que uno debe saber, en qué directorio se encuentra cada cabecera. No obstante, esto suele ser un problema menor. Sin ruta relativa, uno debería poner un prefijo a cada nombre de fichero para evitar nombres dobles. Estos prefijos son típicamente los nombres de los directorios. Es decir, todos los ficheros en el directorio “definiciones” tienen se llaman “definiciones_xxx”. Al final debo saber el nombre de directorio de todas formas.

Los programadores de C++ han copiado de Java la idea de estructurar los directorios como los espacios de nombre. Así, el uso de la clase NombreDeEspacio::Subespacio::MiClase requiere la inclusión del fichero “nombre_de_espacio/subespacio/mi_clase.h”.

El uso de rutas relativas en las inclusiones puede mejorar bastante el orden y reducir la configuración del compilador. Así basta incluir un solo directorio para obtener acceso a todos los componentes de la biblioteca boost, por ejemplo. En cambio, rutas relativas hacen más complicado recolocar ficheros a otros directorios. Y esto puede pasar a menudo en fases tempranos de un proyecto.

Optimizar la velocidad de compilación

La inclusión de un sólo fichero normalmente no afecta mucho al tiempo, que el compilador requiere para la compilación de un fichero. Pero esto se cambia si el fichero incluido incluye más ficheros que a su vez incluyen aún más ficheros. Gracias a tantas inclusiones anidadas, una sola inclusión, sí, puede cambiar el tiempo de compilación drásticamente.

Una forma de mejorar la velocidad es utilizar cabeceras precompiladas. Estas cabeceras deben estar incluidas por todos los ficheros de definición y, por eso, debe ser una decisión temprana en un proyecto de utilizarlas o no – al menos si uno quiere evitar añadir esta inclusión en muchos ficheros.

Otra forma es evitar la inclusión de ficheros no necesarios. Normalmente una inclusión adicional no perjudica al resultado de la compilación. Simplemente añade declaraciones que finalmente no se usan. Pero la compilación de estas declaraciones cuesta un tiempo que nos queremos ahorrar.

Para proyectos grandes puede convenir no incluir definiciones de tipos que sólo aparecen como punteros o referencias en los ficheros de cabecera. Esto tipos se pueden introducir con una declaración forward. Sólo en el fichero de definición se incluye entonces la cabecera que define este tipo.

Como ejemplo damos una clase, que guarda un puntero a otra clase. En el fichero de cabecera tenemos

// Declaración forward
class OtraClase;

// Declaración de la clase principal
class UnaClase
{
	OtraClase* dame_un_puntero_a_otra_clase();
};

En el fichero de definición tenemos

#include "una_clase.h"
#include "otra_clase.h"

OtraClase* UnaClase::dame_un_puntero_a_otra_clase()
{
	static OtraClase otraInstancia;
	return &otraInstancia;
}

Cabe señalar que una declaración forward debe tener lugar dentro del nombre de espacio correspondiente.

// Esto no es correcto
class NombreDeEspacio::Subespacio::Clase;

// Se debe hacer la declaración forward así
namespace NombreDeEspacio
{
	namespace Subespacio
	{
		class Clase;
	}
}

Conclusión

Como hemos comprobado, hay bastante que se puede tener en cuenta sobre la directiva #include, cuando se trabaja en proyectos grandes.

  • La inclusión genera un sólo fichero grande. Tener esto en mente cuando se produce un error de compilación en la primera línea aparamente correcta tras un #include.
  • Conviene incluir ficheros del propio proyecto con comillas y las cabeceras de terceros con paréntesis angulares para marcar la diferencia entre “propio” y “estándar”.
  • Ordenando las propias inclusiones al final ayuda a corregir errores de doble definición de identificadores.
  • Rutas relativas son una buena opción para proyectos con una clara estructura de directorios.
  • Usar cabeceras precompiladas y evitar incluir ficheros innecesarios – también aprovechando declaraciones forward – puede acelerar el proceso de compilación.

Lectura adicional

Asignar null o cero a referencias o manejadores, que no apuntan a ningún objeto válido, es una forma eficaz de evitar errores difíciles de trazear. En este artículo presento por qué es una buena idea “anular” referencias no utilizadas y como anulamos referencias para que a primera vista no parece existir un valor nulo.

Qué son referencias

En este artículo usamos el término “referencia” para varios tipos datos. Pueden ser punteros a memoria dinámica o manejadores (handles en inglés) de recursos. También pueden ser variables de objetos como en Java. Lo que todas estas entidades tienen en común es que no son un recurso sino lo representan.

Cuando se copia una referencia, hay un apuntador más al mismo recurso, pero no se copia el recurso en si. Cuando se libera un recurso, entonces todas las referencias a él se vuelven inválidos. Los problemas que pueden surgir cuando usamos referencias inválidas discutimos a continuación.

Punteros

Antes de hablar de referencias en general, quiero explicar unos conceptos básicos con el caso estrella de liarse: los punteros de C y C++. Punteros son variables que guardan una dirección en la memoria. Cuando se reserva memoria, entonces se guarda la dirección de este memoria en un puntero.

Memoria dinámica en C++

Un escenario típico para la creación de memoria dinámica es el siguiente.

// Reserva memoria - en C se haría con malloc
MiObjeto* puntero_a_objeto = new MiObjeto();

// Haz algo con los datos del puntero

// Libera la memoria
delete puntero_a_objeto;

¿Por qué el código arriba está mal? Pues, realmente no es. La memoria se reserva con new (o malloc en C) y se libera correctamente con delete (con free en C). No obstante, no se asigna NULL al puntero_a_objeto. Y esto puede causar problemas.

Por ser completos, también quiero decir, cuando no da problemas: cuando se pierde el acceso a la variable puntero_a_objeto porque se sale del bloque donde se definió. Este caso se da a menudo en destructores, donde no anular un puntero no suele causar problemas.

Apuntar al vacío

En muchas ocasiones se usa la comparación puntero == NULL para comprobar, si un puntero apunta a nada. Si no es NULL se lee y escribe en la memoria a que apunta. A veces un programa espera hasta que se haya liberado una memoria. Un puntero con el valor NULL representa esta liberación. Por lo tanto, olvidarse a anular un puntero inválido puede causar un bucle infinito. El programa espera a la liberación de la memoria, pero no se entera porque no se anuló el puntero correspondiente.

Aunque sea liberada, la memoria no desaparece. (Esto sólo pasa si sacas la memoria RAM de tu ordenador.) Como todavía existe, se puede leer de ella. Por eso, un objeto liberado puede parecer vivo y se puede emplear al menos hasta que se reserve la memoria de nuevo.

Apuntar mal

Cuando se reserva la memoria liberada de nuevo, se suele sobrescribirla con otros valores en general. Entonces pueden producirse dos casos:

Los nuevos valores no son útiles para el objeto anteriormente liberado. Por eso se lee valores malos con el puntero “olvidado” (olvidado a asignarle NULL). Un valor inútil puede ser, por ejemplo, índices de array más allá del tamaño del array. En este caso suele producirse un error en el programa en tiempo de ejecución.

El problema es encontrar este error: en el objeto anteriormente liberado parece cambiarse un índice de array así de repente y sin que exista algún instrucción correspondiente. Y ahora a adivinar por qué. El nuevo objeto que se guardó en la memoria liberada puede estar definido en una biblioteca de que ni siquiera tienes el código. Y la función en que deberías haber anulado el puntero puede estar en otro fichero.

El segundo caso es aún peor: el nuevo objeto cambia el contenido de la memoria pero los valores todavía resultan “correctos”. Por ejemplo, un índice de array vale uno en lugar de cinco. Con esto el programa puede seguir funcionando aunque más tarde o temprano algo parece estar mal.

Más tarde puede ser días o meses. ¡Imagínate que el índice apunta a un array de datos personales y de repente lees los datos de otra persona! Se envía una multa de tráfico a alguien, aunque el policía nunca lo editó así. Y todo esto por usar un puntero inválido por haberse olvidado de asignarle NULL cuando se liberó.

La regla de oro de punteros liberados

En conclusión: Si liberas memoria, asigna NULL al puntero. Sin pensar. Y aunque sea la última instrucción en un destructor. Por 99 veces que lo haces en vano, esta una que te salva de un error complicado lo vale.

Diferencias entre compilación debug y release

Para evitar desastres mayores con la memoria liberada, algunos compiladores incluyen código en modo debug, que sobrescribe la memoria liberada con algún valor tonto como 0xCCCCCCCC. Con suerte es un valor lo suficientemente inútil, para que se genere un error al usarlo de forma inmediata.

No obstante, sobrescribir cuesta tiempo y esto se quiere ahorrar. Por eso, en un programa compilado en modo release, la memoria se queda inalterada tras liberarla. Y por lo tanto se puede seguir leyendo de ella como si fuera válida. Es una de las razones porque un programa puede comportarse de forma diferente en modo debug y en modo release.

Referencias en general

La física de una referencia

Los punteros son un tipo de referencias, pero todavía representan algo físico: una dirección de memoria. Una referencia en general no está atada a una posición específica en la memoria. Sólo representa “algo” que suele estar encapsulado en una biblioteca.

Por ejemplo, cuando se crea una ventana con la biblioteca X, la función de creación devuelve un identificador que representa esta ventana. Hay que pasar este identificador como parámetro cuando se llama a una función de la biblioteca X. Dentro de la biblioteca se usa el identificador para buscar, en qué dirección de memoria se guarda la ventana y se modifica según la función llamada.

La imensa mayoría de referencias son números enteros aunque no aparecen como tales. Una variable objeto en Java o C# se comporta como un objeto de este tipo, pero internamente es un número entero que se el intérprete usa como índice en una lista de objetos.

De hecho, instancias de objetos y referencias tienen características en común. Una referencia o manejador representa una instancia de algo igual como una variable de objeto. Por ejemplo, cuando se usan sockets en en lenguaje C, se representa un socket específico por un socket id – que es un número entero. En un lenguaje orientado a objetos como Java existe una clase Socket. Se crea una conexión de socket al crear una instancia de esta clase.

Analogía entre referencias y punteros

Lo que equivale a “reservar memoria” para un puntero, es la creación de un objeto y el acceso a un recurso para una referencia o un manejador. Se llama a una función Create, que devuelve un identificador para el nuevo recurso creado, y al final se llama a una función Free para liberar este recurso.

Normalmente existe un valor que indica que una referencia o un manejador no referencia nada. En una referencia numérica suele ser cero, una variable objeto puede contener el valor null o nullptr. Tras liberar un objeto, se debería asignar este valor nulo a la referencia, ya que ahora ya no apunta a nada. Si no, las consecuencias puede ser parecidas a las de los punteros “olvidados” mencionados arriba.

Un caso real

Cuento un caso real. Con la biblioteca X Toolkit se pueden crear temporizadores. Cuando vence el tiempo establecido, la biblioteca llama a una función callback y tras esto, el temporizador ya no existe.

Una clase que usa uno de estos temporizadores guarda la referencia a ella en una variable miembro. Puede ofrecer el servicio de cancelar un temporizador. Si el valor de la variable no es cero, entonces se cancela.

Ahora nos imaginamos que el temporizador ya ha vencido y, en consecuencia ya no existe, pero no hemos anulado la referencia en la función callback. Más tarde cancelamos el temporizador. Como la variable miembro no vale cero, se cancela.

En un caso favorable, la biblioteca X Toolkit devuelve un error que este identificador ya no existe. En un caso menos favorable, el identificador, sí, existe, porque se ha vuelto a asignar para otro temporizador. Como existe, la cancelación elimina un temporizador totalmente distinto.

Como el temporizador ya no existe, ya no vencerá, y se asignará al programador correspondiente la tarea de corregir su error, cuando él no ha hecho nada mal. Y ahora imagínate que este programador eres tú y te toca tener la idea que este error puede ser el problema y, a continuación, rastrear mil ficheros de código para ver, en qué callback se olvidó anular la referencia al temporizador.

El valor NULL en cada caso

En fin, no pienses más. Si liberas un recursos, anula las referencias correspondientes. Siempre y sin pensar.

A veces no es muy obvio que es el valor nulo. Por ejemplo, ¿qué es el valor nulo para un iterador de la biblioteca estándar de C++? En este caso se le podría asignar el valor que devuelve el método end() del contenedor correspondiente.

El problema de adivinar cuál es el valor nulo suele aparecer cuando la referencia es un tipo opaco: algún tipo que devuelve una función de creación, pero que no permite asignarle el número entero cero.

Si una biblioteca no aparenta tener un valor nulo para estas referencias, entonces se puede hacer un truco. Se encapsula el identificador en una clase. Sólo es posible crear una instancia de esta clase creando una instancia del objeto que se quiere referenciar. El destructor de la clase libera el recurso. Es decir, la referencia no se anula sino desaparece con la clase que la encapsula.

Si el lenguaje no permite programación orientado a objetos, entonces se puede utilizar una variable booleana adicional, que indica si la referencia es válida o no.

Conclusión

Hemos visto, qué líos pueden surgir por referencias y punteros no anulados tras liberar el recurso a que apuntaban. Suelen causar errores que requieren mucha experiencia en fijarlos. Por eso hemos llegado a la conclusión, que siempre hay que evitar referencias inválidas asignándoles algún valor nulo. En pocos casos el valor nulo puede ser no obvio. Entonces se puede encapsular la referencia en una clase o se usa una variable booleana que indica la validez de la referencia.

Lectura adicional

Menos errores
Más estilo
Más conceptos

Introducción

Con funcioncitas me refiero a funciones de una o dos líneas, muchas veces con un cálculo muy simple, pero con un valor semántico muy potente.

Escribir una función adicional fracasa a menudo en una cierta pereza, porque hay que buscar un hueco en el fichero fuente, escribirla, en C y C++ hay que añadirlo en la cabecera, eventualmente serás capaz de escribir algún comentario de qué esta función debe hacer y todo esto para una línea de código bastante trivial. Pero esto se puede ver también de otra forma: escribes el código una vez, lo modificas diez veces y lo lees cien veces. Si tardas 99 segundos (1 minuto y 39 segundos) para escribir una función que te ahorra un segundo en la lectura, todavía ahorras tiempo.

El valor de las funciones pequeñas está en su claridad semántica, que mejora mucho la legibilidad del código – sobre todo cuando se trata de una combinación de varias funciones pequeñas. Para ilustrar esto, presento algunos ejemplos.

Copiar con punteros

Si p y q son dos punteros, entonces ¿qué hace el código de C siguiente?

while (*p++ = *q++) {}

Lo que hace es copiar el valor a que q apunta a la memoria referenciada por p. A continuación pasa ambos punteros al próximo y elemento y sigue copiando hasta que haya copiado un cero. (El valor de una asignación es el valor asignado, el cero se interpreta como un false y el bucle while termina.)

Tenemos una expresión booleana basada en cualquier tipo arbitrario que el compilador sepa amablemente interpretar como false o true y que, además, modifica las variables dentro de la expresión. A parte de tener un buen ejemplo de un uso sofisticado pero poco intuitivo del lenguaje de programación C, ¿para qué se podría utilizar un bucle así?

Pues, para copiar un string. En la biblioteca estándar de C hay una definición de strcpy similar a esta (la versión estándar devuelve un char*):

void strcpy(char *p, const char *q)
{
    while (*p++ = *q++) {}
}

Ahora la pregunta: ¿qué te parece más comprensible? ¿Una línea strcpy(p, q) o un bucle while (*p++ = *q++) {}? Técnicamente no trae ninguna ventaja llamar a la función strcpy, pero semánticamente, es decir, el significado del código es mucho más claro para el programador.

Funciones pequeñas son a menudo parte del estándar. La clase String en C# tiene un método IsNullOrEmpty. La expresión

if (!String.IsNullOrEmpty(miString))

reemplaza a

if (miString != null && miString.Length > 0)

Es lo mismo, pero el método IsNullOrEmpty deja la intención más obvia.

Acotar rangos

Un ejemplo simple. Mira el reloj cuánto tardas en contestar a la pregunta. ¿Qué representa c?

c = a < b ? a : b;

Y ahora los mismo otra vez. ¿Qué representa c?

c = min(a, b);

Esto era fácil, pero ¿qué tal con algunos niveles de anidamiento?

c = c < a ? a : (c > b ? b : c);

Esto limita c al rango [a, b]. Si no estás tan firme en el uso del operador ? : podrías escribir la línea anterior con if.

if (c < a)
{
    c = a;
}
else
{
    if (c > b)
    {
	    c = b;
	}
}

Un poco más amigable sería usar las funciones pequeñas min y max:

c = max(a, min(b, c));

Mi opción preferida sería una función

c = limitToRange(a, c, b);

donde el parámetro en medio será acotado al intervalo [a, b].

Puedes optar por hacer una pequeña función que ocupa una línea o la construción con el if anidado arriba que ocupa once líneas por cada variable que quieres acotar. Y puede haber muchas: si c es un índice de un array, entonces podrías tener la necesidad de acotarlo cada vez que accedes al array.

Algo parecido vale si quieres generar un error al recibir un índice fuera de rango. La manera directa sería escribir algo así

if (index < start || index >= end)
{
    std:ostringstream mensaje;
	mensaje << "Error de índice index = "
	        << index
			<< ". Fuera del rango ("
			<< start
			<< ", "
			<< end
			<< ").";
    throw std::exception(mensaje.str());
}

Pero igual vale la pena hasta escribir una clase de excepción propia.

if (indexOutOfRange(start, index, end))
{
    throw IndexException(start, index, end);
}

Conclusión

Funciones pequeñas aumentan considerablemente la legibilidad del código. Ayudan a reducir el número de líneas dedicadas a comprobar la calidad de los datos y dejan a consecuencia más espacio a la parte principal de una función. Para no perder velocidad en la ejecución, C++ ofrece definir estas pequeñas funciones inline.

Muchas veces las funciones pequeñas son tan generales que se pueden usar en todos los proyectos de programación. Una buena gestión debería procurar que se mantiene una biblioteca que todo el mundo en la empresa pueda usar. Como son funciones muy básicas, conviene también pensarse bien sus nombres y el espacio de nombres en que se encuentran.

Si no eres gerente sino un simple programador, entonces te recomiendo que guardes estas funciones en un fichero personal. Así puedes copiar y pegarlas en cualquier proyecto en que participas. De hecho, mantener una biblioteca personal puede facilitar tu trabajo.

Referencias

Cuando compilamos patrones de clases (templates) en C++, como los contenedores de la STL, entonces tenemos a veces errores de compilación con mensajes exageradamente largos, que esconden más que aclaran. En este artículo quiero enseñar de como “leer” estos mensajes para fácilmente encontrar el error causante.

Primero debemos entender que el mensaje del compilador intenta mostrar toda la información posible. Según el tipo del problema, diferentes partes del mensaje pueden ser significantes. Por ejemplo, si cambio el allocator estándar de un contenedor por uno diferente, entonces me importará qué dice el mensaje de error sobre este componente. No obstante, en este artículo asumimos un uso básico de contenedores de la STL.

Más concretamente compilamos este trozo de código.

    // Una instancia de un mapa
	std::map mi_mapa;

	// Un iterator a una posición del mapa
	std::map::const_iterator const_itr = mi_mapa.begin();

	// Insertar un nuevo elemento en la posición indicada por el iterador
	mi_mapa.insert(const_itr, std::pair("atributo", "valor"));

Este código falla en la línea mi_mapa.insert.

Descifrando un mensaje de MinGW

Compilando con un compilador MinGW obtenemos el siguiente error para esta línea.

error: no matching function for call to `std::map, std::allocator > >::insert(std::_Rb_tree_const_iterator >&, std::pair)'
C:/opt/Dev-Cpp/include/c++/3.4.2/bits/stl_map.h:360: note: candidates are: std::pair<_Key, std::pair, std::_Select1st >, _Compare, _Alloc>::iterator, bool> std::map<_Key, _Tp, _Compare, _Alloc>::insert(const std::pair&) [with _Key = std::string, _Tp = std::string, _Compare = std::less, _Alloc = std::allocator >]
C:/opt/Dev-Cpp/include/c++/3.4.2/bits/stl_map.h:384: note:                 typename std::_Rb_tree<_Key, std::pair, std::_Select1st >, _Compare, _Alloc>::iterator std::map<_Key, _Tp, _Compare, _Alloc>::insert(typename std::_Rb_tree<_Key, std::pair, std::_Select1st >, _Compare, _Alloc>::iterator, const std::pair&) [with _Key = std::string, _Tp = std::string, _Compare = std::less, _Alloc = std::allocator >]

El mensaje consiste de tres líneas: primero el error de que no hay una función que corresponde a la llamada y luego dos propuestas del compilador que funciones se podría haber intentado llamar. Como nosotros no hemos escrito la función a que llamamos, sabemos que nuestra llamada debe ser mala.

El truco para descifrar este mensaje largo consiste en eliminar sucesivamente los parámetros de los patrónes (template parameters). Nos fijamos en la primera línea lo que está entre las comas altas (‘): std::map, std::allocator > >::insert(std::_Rb_tree_const_iterator >&, std::pair).

Empezamos a eliminar desde dentro de las paréntesis angulares < > todo lo que nosotros no hemos especificado en ningún sitio. Sí, correcto, simplemente lo borramos. Son parámetros por defecto que no nos interesan. Hemos definido una instancia de std::map. En el mensaje de error, estos dos parámetros de tipo string aparecen, pero luego seguido por un std::less que no hemos puesto en ningún sitio. ¡Así fuera! Lo mismo pasa con el std::allocator. Lo borramos. Haciendo esto nuestro mensaje de error queda más corto: std::map::insert(std::_Rb_tree_const_iterator >&, std::pair).

El método insert tiene dos parámetros. Correcto. El último, es realmente un std::pair, pero el primero es un _Rb_tree_const_iterator de que no sabemos de donde procede. Nuestro primer parámetro en un const_iterator. Pues bien, _Rb_tree_const_iterator contiene la palabra const_iterator y será un tipo interno para representar un iterador a constante a un mapa. ¿Pero por qué tree? Aquí nos ayuda saber que los contenedores asociativos (std::set, std::map, std::multimap) están internamente organizado como un árbol – que es un tree en inglés. Si sustituimos la palabra tree por map, entonces el primer parámetro del método insert tendra el tipo std::_Rb_map_const_iterator >. Como nuestro tipo de iterador no tiene parámetros de patrón, simplemente lo borramos y nos queda nada más que std::_Rb_map_const_iterator.

La primera línea del mensaje de error queda entonces en

std::map::insert(std::_Rb_map_const_iterator&, std::pair)

Esto se parece a la línea donde se produjo el error:

mi_mapa.insert(const_itr, std::pair("atributo", "valor"));

Este parecido entre si no es de extrañar, ya que el mensaje de error hace referencia justamente a esta llamada.

 

Ahora nos fijamos en la segunda línea que contiene una propuesta de función y la acortamos de la misma manera.

C:/opt/Dev-Cpp/include/c++/3.4.2/bits/stl_map.h:360: note: candidates are: std::pair<_Key, std::pair, std::_Select1st >, _Compare, _Alloc>::iterator, bool> std::map<_Key, _Tp, _Compare, _Alloc>::insert(const std::pair&) [with _Key = std::string, _Tp = std::string, _Compare = std::less, _Alloc = std::allocator >]

Primero eliminamos todo el texto de introducción hasta “candidates are:”. Luego eliminamos los _Compare y _Alloc que nosostros no hemos pedido y nos quedamos con esto:

std::pair<_Key, std::pair, std::_Select1st >>::iterator, bool> std::map<_Key, _Tp>::insert(const std::pair&) [with _Key = std::string, _Tp = std::string]

Recordamos que podemos reemplazar tree por map y renombamos _Rb_tree a _Rb_map. Los parámetros de patrón contiene un conjunto de tipos que no nos interesa, así simplemente lo quitamos y nos queda

std::pair std::map<_Key, _Tp>::insert(const std::pair&) [with _Key = std::string, _Tp = std::string]

Si nos ayuda podemos reemplazar _Key y _Tp por std::string como indica la última parte del mensaje. El nombre de espacio std y la palabra clave typename no nos aportan tampoco información, así los eliminamos también.

pair<_Rb_map::iterator, bool> map::insert(const pair&)

Finalmente vemos claro que la función propuesta es un método insert que tomo un tipo pair como entrada.

 

Hacemos el procedimiento a la tercera línea del mensaje de error.

C:/opt/Dev-Cpp/include/c++/3.4.2/bits/stl_map.h:384: note:                 typename std::_Rb_tree<_Key, std::pair, std::_Select1st >, _Compare, _Alloc>::iterator std::map<_Key, _Tp, _Compare, _Alloc>::insert(typename std::_Rb_tree<_Key, std::pair, std::_Select1st >, _Compare, _Alloc>::iterator, const std::pair&) [with _Key = std::string, _Tp = std::string, _Compare = std::less, _Alloc = std::allocator >]

Eliminamos los parámetros de patrones innecesarios, reemplazamos tree por map, sustituimos _Key y _Tp por std::string, y eliminamos std y typename. Y con esto llegamos a esta función propuesta:

_Rb_map::iterator std::map<string, string>::insert(_Rb_map::iterator, const pair&)

Esta propuesta ya se parece bastante a nuestra llamada

mi_mapa.insert(const_itr, std::pairstring, std::string>("atributo", "valor"));

Lo que es diferente es el primer parámetro: nosotros usamos un const_iterator mientras la función propuesta requiere un iterator sin const. Si cambiamos la línea

std::mapstring, std::string>::const_iterator const_itr = mi_mapa.begin();

por

std::mapstring, std::string>::iterator ya_no_const_itr = mi_mapa.begin();

entonces compila.

Entender a Microsoft Visual Studio

Como ejercicio puedes usar las mismas técnicas para entender el resultado de compilación del mismo código con Microsoft Visual Studio.

Error	1	error C2664: 'std::_Tree<_Traits>::iterator std::_Tree<_Traits>::insert(std::_Tree<_Traits>::iterator,const std::pair<_Ty1,_Ty2> &)' : no se puede convertir el parámetro 1 de 'std::_Tree<_Traits>::const_iterator' a 'std::_Tree<_Traits>::iterator'

error C2664: 'std::_Tree<_Traits>::iterator std::_Tree<_Traits>::insert(std::_Tree<_Traits>::iterator,const std::pair<_Ty1,_Ty2> &)' : no se puede convertir el parámetro 1 de 'std::_Tree<_Traits>::const_iterator' a 'std::_Tree<_Traits>::iterator'
1>        with
1>        [
1>            _Traits=std::_Tmap_traits,std::allocator>,false>,
1>            _Ty1=const std::string,
1>            _Ty2=std::string
1>        ]
1>        and
1>        [
1>            _Traits=std::_Tmap_traits,std::allocator>,false>
1>        ]
1>        and
1>        [
1>            _Traits=std::_Tmap_traits,std::allocator>,false>
1>        ]
1>        No hay disponible ningún operador de conversión definido por el usuario que pueda realizar esta conversión, o bien no se puede llamar al operador

Conclusión

Al inicio se pueden copiar los mensajes de error largos a un editor de texto y modificar ahí sucesivamente el mensaje hasta sólo queda la parte significativa. Con un poco de práctica, uno consigue hacer estas reducciones en la mente. La mayor parte del mensaje está “contaminado” por parámetros de patrones por defecto, que se necesitan por razones técnicas pero que no se usan como usuario de una clase.

La experiencia enseña también, que la mayoría de los errores son del mismo estilo: mezclar iterator y const_iterator o confundirse en los parámetros o valores de retorno de métodos sobrecargados.

Referencias

Cuando empezamos con un nuevo proyecto, puede surgir la pregunta si se modifica un código viejo o se empieza desde cero. Reciclar código viejo para un nuevo proyecto ahorra tiempo, porque el código viejo ya está escrito y funciona. Escribir código otra vez también ahorra tiempo, porque es específico a lo que está pedido en el nuevo proyecto y un programador no necesita entender un código viejo difícil de comprender.

El ahorro de verdad está entre restaurar proyectos históricos y reinventar la rueda. Así la cuestión es ¿dónde está el punto óptimo entre reciclar y reinventar? Como en muchas ocasiones, no hay una respuesta fácil y, por lo tanto, no la voy a dar. En este artículo presento a condiciones de entorno a tener en cuenta a la hora de decidir si modifico lo viejo y empiezo de nuevo.

Un criterio es la calidad de documentación. Si un programador quiere aprovechar código de otro, no tendrá más remedio que entenderlo. Una buena documentación, que incluso incluye un razonamiento sobre el diseño y el uso de la biblioteca, es una gran ayuda. Entender código sin documentación sólo es beneficioso para secciones pequeñas de código. De otra forma existe el riesgo de pasar mucho tiempo para entender algo que posiblemente ni se puede usar.

En este contexto conviene saber que la herramienta doxygen permite generar gráficos de llamdas de funciones y herencia de clases a partir de un código existente. Doxygen permite llamar a una herramienta específica para la creación de grafos que se llama DOT.

A veces importa que se puede probar un software cuanto antes – especialmente donde se desean hacer pruebas exhaustivas como en la aeronáutica o la medicina. Otra motivación puede ser querer tener un prototipo funcional en cada momento. Entonces se intenta conseguir un código que compile y haga algo aunque sea poco. En este entorno puede ser preferible partir de un proyecto antiguo que funciona y modificarlo hasta que cumpla las nuevas especificaciones sin que deje de ser un programa funcional en ningún momento.

Algo importante a la hora de reutilizar código viejo es el grado de similitud entre el funcionamiento real y el funcionamiento deseado. Poder utilizar una biblioteca tal cual como es, es un argumento poderoso para utilizar el código viejo. Si, en cambio, hace falta tocar la mitad de las funciones, entonces mejor se aprovecha nada más que el fichero de cabecera.

De hecho, reciclar código no significa necesariamente copiarlo todo. La estructura de datos, la interfaz, un bucle o un comentario de documentación: es perfectamente válido empezar con un proyecto vacío y rellenarlo paso a paso con elementos de un proyecto antiguo. Esta es una manera muy útil cuando ya se sabe que el proyecto viejo contiene muchas partes útiles aunque el programador todavía se pierde entre tantas funciones. A la hora que avanza con su proyecto nuevo, entiende mejor las necesidades y puede buscar más eficazmente lo que necesita en el código viejo. En este caso el proyecto antiguo sirve más bien como un código ejemplo que un programa ya completo.

En general suele ser un buen compromiso utilizar lo viejo si cuesta poco utilizarlo y hacerlo nuevo en el caso contrario. Muchas veces no se cambia sólo la funcionalidad sino, además, otras circunstancias de contorno. En lugar de una conexión de puerto serie se usa un puerto USB, el sistema operativo usa 64 bits y no 32, el estilo de código de ahora es mejor que el utilizado en el código antiguo o quizá se piensa crear la aplicación como una página web y no un fichero ejecutable. En corto: si ya te toca tocar código viejo, hazlo de forma contundente.

Hemos visto que el grado de documentación, la necesidad de hacer pruebas o tener prototipos son circunstancias importantes de modificar lo viejo en lugar de empezar de nuevo. Por la similitud entre lo que está hecho y lo que se quiere hacer se elige hasta que grado se copian componentes antiguas: de módulos enteros via apartados de código a meramente inspiraciones para el nuevo proyecto.

Referencias

Cuando una aplicación guarda información en una base de datos, entonces aparece la pregunta de como guardar los datos de la memoria RAM en la base de datos. Aquí quiero presentar como poder transformar esta información de objetos a tablas y columnas en una base de datos.

Correspondencia entre tablas y clases

La creación de una tabla consiste en definir los tipos que se guardan en cada columna o campo como se denomina también. Una tabla es, por lo tanto, algo similar a una clase en un lenguaje de programación: un tipo compuesto. Una línea en una tabla se llama registro (record en inglés). Un registro contiene los datos y es, por lo tanto, parecido a una instancia de una clase.

Porque una tabla y sus registros tienen una relación similar a una clase y las instancias de esta clase, se encapsula a menudo una tabla de la base de datos con una clase en el lenguaje de programación. Esta correspondencia surge de forma natural si has diseñado bien tu programa. Cada objeto guarda “sus” datos; sólo que las guarda en la base de datos en lugar de la memoria RAM. Lo lógico es que los datos de todas las instancias de una clase se encuentran en una tabla y todas las instancias de otra clase en otra tabla.

Clave primaria

Un concepto importante en las bases de datos son las claves primarias. Una clave primaria es un identificador único a cada registro. Un objeto de programa que guarda sus datos en una base de datos meramente necesita guardar este identificador para obtener un acceso rápido a sus datos.

Es una buena práctica en la programación de guardar cada dato en sólo un sitio. Por eso, un objeto, que encapsula un registro de una a tabla, no debería guardar los datos en variables miembro sino solamente en la base de datos – excepto la clave primaria, por supuesto. Excepciones se pueden justificar por un aumento de la velocidad o porque el objeto usa los datos para calcular algo.

Hay pocas situaciones en que una tabla no tiene una o varias columnas que representan la clave primaria igual como hay pocos objetos dentro de un proyecto a que no se hace referencia alguna. No sorprende que un nombre popular y recomendable para la columna que contiene la clave primaria es “Id”.

Una clave primaria es normalmente un número entero. A veces este número es también un índice. Por ejemplo, el número de una orden de compra puede ser la clave primaria en una tabla. En tablas con poco tráfico también puede ser recomendable usar cadenas de texto, ya que son más inteligibles. Un ejemplo puede ser una tabla de configuración con atributos y valores.

Estructuras jerárquicas

Como ya hemos dicho, cada tabla en la base de datos corresponde normalmente a una clase. Un problema surgen con las variables miembro de una clase que no tienen un tipo simple, ya que no se pueden definir tipos propios para un campo en una base de datos. No puedo definir que una columna que contenga otra tabla. Esta falta de jerarquía es una restricción importante de las base de datos relacionales. Hay formas de guardar datos que no tienen esta limitación. Por ejemplo, un elemento XML puede contener muchos otros elementos anidados.

Si nos enfrentamos con estructuras de datos complejos, podemos tener tres soluciones posibles:

  1. Un objeto contiene otro objeto por orden y claridad y podemos “aplanar” la estructura de datos.
  2. Dos objetos “colaboran” y guardan referencias entre sí.
  3. Un objeto es contenedor del otro.

A continuación detallaremos como se pueden implementar estas estructuras en una base de datos.

Aplanar la jerarquía

Un objeto contiene otro objeto por orden y claridad y podemos “aplanar” la estructura de datos. Por ejemplo, una clase coche puede contener una clase motor. La clase motor contiene los elementos tipo de combustible y cilindrada. Como cada coche sólo usa un tipo de combustible y tiene una cilindrada, podemos considerar que estas características del motor son también características del coche. Una tabla que corresponde a la clase coche tendría una columna para el tipo de combustible y otra para la cilindrada. El nivel de jerarquía de la clase motor ya no aparece. La hemos aplanado.

Colaboración de objetos

Otro caso tenemos cuando no guardamos los datos dentro de nuestro objeto sino meramente una referencia a otro objeto que contiene estos datos. Por ejemplo, tenemos una clase combustible que guarda las características del combustible. La clase coche, en lugar de guardar estos datos, sólo guarda una referencia, es decir, un puntero a una instancia de combustible. Esta construcción se usa típicamente cuando la relación de datos no es uno a uno. Un coche usa un tipo de combustible, pero un tipo de combustible puede ser usado por varios coches.

Otra característica de esta construcción es que un componente puede cambiarse de forma independiente. Si cambiamos el tipo de motor, entonces la referencia de combustible de este coche apuntaría a otra instancia de la clase combustible. En cambio, si cambiamos la composición del combustible, todos los coches usarían esta nueva versión sin ningún cambio en sus datos. En términos de programación orientado a objetos se diría que la clase coche y combustiblecolaboran“: sus instancias pueden existir de forma independiente pero mantienen enlaces.

La implementación de una estructura de colaboración se hace mediante dos tablas. Cada tabla representa los datos de una clase. La referencia se establece por un campo en que se guarda la clave primaria del registro relacionado en la otra tabla. Por ejemplo, en la tabla COCHES hay una columna combustible_id que guarda el valor del campo id de la tabla COMBUSTIBLES. El campo COMBUSTIBLES.id es la clave primaria de la tabla COMBUSTIBLES. De esta forma, un registro que guarda los datos de un coche tiene un enlace con los datos del combustible que usa.

Mediante el siguiente comando en SQL se pueden obtener los datos de las dos tablas como se fuera una:

select * from COCHES, COMBUSTIBLES
        where COCHES.id = <aquí mi id de coche guardado en la memoria RAM>
          and COMBUSTIBLE.id = COCHES.combustible_id

Contenedor de objetos

Ya hemos visto un tipo de contenedor trivial: el coche contiene un motor. Es trivial porque la relación es uno a uno. Sin embargo, un contenedor generalmente contiene un número variable de elementos. Nuestra clase coche puede contener una lista de factura. Cada vez que el coche sale del taller, se añade una nueva factura a la lista.

La relación entre contenedor es uno a n. A contrario de una colaboración, el contenedor no guarda referencias sino alberga las instancias de los elementos que contiene. Muchas veces no tiene sentido que uno de estos elemento exista fuera del contenedor. Por ejemplo, las facturas están asociados al coche que fue reparado.

La implementación en la base de datos sería también mediante dos tablas: la tabla COCHES para la clase coche y otra tabla FACTURAS para la clase factura. La tabla COCHES no guardaría nada sobre las facturas. Lo que, sí, debe tener es un identificador para cada coche. Esto sería la clave primaria de esta tabla. En la tabla FACTURAS se guardarían las facturas de todos los coches. (Recuerda que una tabla guarda todas las instancias de un objeto.) Una de las columnas de esta tabla guarda la clave primaria del coche. Este datos sería obligatorio ya que una factura no puede existir sin coche.

Nótese una diferencia entre el lenguaje de programación y la base de datos: En el lenguaje de programación es el contenedor que tiene una referencia a sus elementos – en forma de una variable miembro. En la base de datos es al revés: es el elemento que tiene una referencia al contenedor. Esta referencia es la clave primario del contenedor del elemento.

En esta construcción se encuentra a menudo, que la clave primaria de un elemento está constituido por varios campos. Cada coche tiene uno a n facturas. Así los números de facturas está repetido en varios coches – muchos coches tendrán al menos una factura con el número de factura uno. Sin embargo, cada coche sólo tiene cada número de factura una vez. Así la clave primaria de la tabla FACTURAS, que identifica un registro particular, sería la unión de la clave primaria del coche a que se refiere la factura y el número de factura de este coche.

Conclusión

Hemos visto que la correspondencia entre tablas en la base de datos y datos de objetos es relativamente directa. Una tabla es como una clase que sólo permite tipos simples para las variables miembro. Una jerarquía de datos se establece mediante las claves primarias. Las bases de datos relacionales obligan a que esta referencia va del contenido al contenedor y no al revés como en un lenguaje de programación.

Referencias

El lenguaje C++, como muchos otros lenguajes, tiene un operador de incremento pre-fix y post-fix. El operador pre-fix ++p tiene el mismo efecto sobre una variable entera int p como un incremento post-fix p++: el valor se incrementa en uno. La diferencia está que la expresión ++p evalúa al valor de p después del incremento (pre-fix como pre como primero), mientras la expresión p++ tiene el valor de p antes del incremento (post como después). Sin embargo, en muchas ocasiones no se hace uso de esta diferencia y simplemente se quiere añadir uno a la variable p.

No importa mucho qué operador se usa para tipos básicos, porque el compilador añade el cálculo de “más uno” en el código ejecutable donde hace falta. Sin embargo hay una diferencia importante cuando se usan operadores sobrecargados en clases. Lo mismo que vamos a demostrar aquí para un operador de incremento ++ vale también para un operador de decremento --.

Una típica implementación de un incremento sobrecargado sería algo así:

class MiClase
{
private:
    int mi_valor;
public:
    // El operador pre-fix (argumento void)
    MiClase& operator++(void)
    {
        // Increméntame internamente (también puedo usar ++mi_valor)
        mi_valor += 1;

        // Devuelve una referencia a mí mismo
        return *this;
    }

    // El operador post-fix (argumento int sin nombre)
    MiClase operator++(int)
    {
        // Guarda una copia en una instancia temporal
        MiClase temp(*this);

        // Increméntame internamente (también puedo usar ++mi_valor)
        mi_valor += 1;

        // Devuelve la copia temporal con el estado anterior
        return temp;
    }
};

A primera vista vemos que el pre-incremento es mucho más simple. Incrementa el estado de la clase y devuelve una referencia a la instancia.

El post-incremento es más complicado. Tengo que crear una copia para no perder el estado anterior que finalmente devuelvo también como copia y no como referencia. Es decir, tengo que llamar dos veces al constructor de copia cuando no necesito hacerlo para el operador pre-fix.

Por este motivo es preferible usar el operador pre-fix para incrementar y decrementar objetos. Aunque esta regla no sea necesario para variables de tipos básicos es preferible hacerlo para tener un estilo único.

Creo que la palabra clave const aumenta bastante las emociones que uno podrá tener sobre C++: cuando es indudablemente un elemento para elevar el estilo de programación, también es uno que puede complicar bastante la compilación. En este artículo ponemos el enfoque más al por qué de usar entidades constante y como implementarlos en programas reales. He descrito en otro artículo de como y donde se puede usar la palabra clave const.

El compilador puede comprobar si un programa intenta modificar un valor constante. Declarar objetos constantes es, por lo tanto, una ayuda para detectar posibles errores en el código. Como es preferible detectar errores durante el tiempo de compilación – ya que el compilador hace el trabajo de encontrarlos – es en general preferible de programar código que cumple lo que se llama “const-correctness“. Además, la palabra const indica al lector del código que el programador intentó sólo leer de un valor. Sin duda, usar objetos constantes incrementa considerablemente la calidad y el estilo del código.

También es cierto que un programa funciona sin constantes. Por eso hay tendencia de no usarlas. Lo gordo viene cuando uno empieza a usarlas en un código que en general no lo usa. Esto puede causar errores de compilación hasta en la n-ésima llamada anidada a una función, que por alguna razón requiere que un valor no sea constante – aunque sólo lo lea y no lo escribe. Más aún fascinan los errores de la STL (Standard Template Library), que puede causar mensajes de error kilométricos que realmente quieren decir algo como “No puedo convertir el tipo const_iterator en iterator.” Como caramelito adicional hay métodos que sólo existen para un tipo de iterador. Por ejemplo, el operador [] del contenedor mapa sólo acepta un iterador a un mapa modificable.

¿Cómo podemos poner orden en el caos? Pues, básicamente en usar wrappers. En nuestro código usamos constantes de forma correcta. Si tenemos que llamar a una función que no podemos o queremos modificar, entonces convertimos los datos de la constante a una variable. El caso más simple sería una simple asignación.

const int constante = 5;
int parametro = constante;
funcion_sin_const_correctness(parametro);

Si no podemos copiar el valor, entonces podemos usar dos técnicas. Una es la de C mediante punteros.

const int constante = 5;
int& parametro_referencia = *(int*)(void*)(&constante);

La otra es con la expresión de C++ const_cast<TIPO>:

const int constante = 5;
int& parametro_referencia = const_cast<int>(constante);

Esta construcción está en el estándar explícitamente para quitar el const. Por lo tanto es la más comprensible a la hora de hacerlo. Aunque el const_cast se parece visualmente a un patrón (template), no lo es. Es una expresión nativa de C++.

Toma nota que no he puesto una copia de la constante al parámetro sino el parámetro es una referencia al valor. Esto puede ser preferible porque ahorra la llamada a un constructor de copia, pero también incrementa el riesgo que un programa erróneo escribe en la constante.

Lo más complicado suele ser convertir los iteradores de la librería estándar de C++, ya que un const_iterator no es lo mismo que un iterator declarado const. Son dos clases distintas que pueden tener diferencias internas importantes. En este caso no suele quedar otra que hacer apaños.

  • Podemos tener la suerte que en nuestra distribución de la librería estándar se puede construir un iterator con un const_iterator. Esto sería lo más fácil.
  • Podemos averiguar si continuamos con un puntero al objeto en lugar del iterador. Es decir algo como
    Contenedor<int>::const_iterator iterador_constante;
    int* puntero_parametro = &*iterador_constante;
    

    Podemos aprovechar que los punteros también son iteradores – al menos para contenedores de acceso aleatorio como vector y deque y arrays de C.

  • Es posible también que sólo necesitamos el valor del elemento que podemos entonces convertir como los variables y objetos constantes mencionados más arriba.
  • Si tenemos un bucle podemos estudiar si la variable del bucle será un iterator en lugar de un const_iterator.

Aún así puede haber el caso en que no podemos hacer nada más que abandonar la const-correctness. Esto sucede especialmente cuando hemos pasado un const_iterator como parámetro de función. En este caso nos consolamos en haber hecho lo máximo posible aunque no era lo máximo deseable.

En general es preferible usar const porque ayuda a mejorar el estilo del código y reduce errores a la hora ejecución. En la vida real nos podemos enfrentar a situaciones difíciles cuando actualizamos código viejo. Sin embargo, podemos intentar adaptar el mejor estilo posible en cada momento. No suele ser ventajoso mejorar el estilo de todo el código de golpe. Pero si lo mejoramos gradualmente en las partes que estamos tocando, acabamos tener actualizaciones de calidad sin necesidad de tocar código que ya funciona aunque tenga peor estilo.

Escribe tu dirección de correo electrónico para suscribirte a este blog, y recibir notificaciones de nuevos mensajes por correo.

Únete a otros 48 seguidores

Archivos

agosto 2017
L M X J V S D
« Ene    
 123456
78910111213
14151617181920
21222324252627
28293031