You are currently browsing the category archive for the ‘Conceptos’ category.

Con “guardianes de alcance” me refiero a objetos locales en una función que tienen algún efecto sólo por existir. Sólo tienen un constructor y un destructor, que suelen reservar y liberar algún recurso.

El mutex locker

Una tarea típica en la programación multi-hilo es usar un mutex. El hilo que reserva el mutex puede seguir; los demás hilos que quieren reservarlo deben esperar. En el código, esto suele aparacer así:

// Definición del mutex
Mutex mi_mutex;

// Una función que usa el mutex
void mi_funcion
{
    // Reserva el mutex o espera hasta que pueda reservarlo
    mi_mutex.lock();

    // Haz algo

    // Libera el mutex
    mi_mutex.unlock();
}

Este emparejamiento de lock y unlock aparece frecuentemente en un programa multi-hilo. No obstante, el unlock puede ser necesario en varios puntos, cuando una función tiene varias salidas – por ejemplo por un return en medio o por un lanzamiento de una excepción. Poner un unlock en cada rama engorda la función y es fácil olvidarse de algúno. Pero olvidarse de liberar algún recursos puede dar a errores difíciles de localizar.

Por suerte existe una manera de no olvidarse de un unlock. Esto se puede conseguir mediante una clase MutexLocker. El constructor de esta clase reserva el mutex y el destructor lo libera.

class MutexLocker
{
    // El constructor reserva el mutex
    MutexLocker(Mutex& mutex_a_reservar)
    : _mutex_reservado(mutex_a_reservar)
    {
        _mutex_reservado.lock();
    }
    
    // El destructor libera el mutex
    ~MutexLocker(void)
    {
        _mutex_reservado.unlock();
    }
    
    // La REFERNCIA al mutex que se quiere reservar
    Mutex& _mutex_reservado;
};

El código arriba se modifica así

// Definición del mutex - esto no se modifica
Mutex mi_mutex;

// El uso, sí, se modifica
void mi_funcion
{
    const MutexLocker mi_mutex_reservado(mi_mutex)
    // Haz algo
}

Como vemos, sólo hace falta una línea en la función. Cuando la variable mi_mutex_reservado sale fuera del alcance, se llama al destructor que libera el mutex. Esto es así, no importa por qué salida la función termina. De esta forma tenemos un “guardián de alcance”. Nos limpia todo al salir del alcance de la función.

En general, se puede declarar const a un guardián, ya que no lo modificamos durante su vida. Ni siquiera podríamos, ya que un guardián de alcance no tiene métodos. Sólo tiene un constructor y un destructor.

Por hacerlo académicamente aún más correcto podríamos impedir hacer copias de la clase MutexLocker para evitar, que una instancia afecte al mutex controlado por otra instancia. Esto podemos conseguir declarando el constructor de copia y el operador de asignación como métodos privados y sin implementarlos. De esta forma conseguiríamos un error de compilación si alguien intentara copiar una instancia de la clase MutexLocker. No obstante, es un problema académico. Normalmente nadie intenta copiar un guardián de alcance.

Trazas de entrada y salida en una función

Los guardianes de alcance se puede utilizar para más tareas. Por ejemplo, un objeto que escribe una traza cuando entramos en la función y otra cuando cuando salimos.

class TraceWriter
{
    // El constructor escribe la traza de entrada
    TraceWriter(const char* nombre_de_funcion)
    : _funcion(nombre_de_funcion)
    {
        std::cout << "Entro en " << _funcion << std::endl;
    }
    
    // El destructor escribe la traza de salida
    ~MutexLocker(void)
    {
        std::cout << "Salgo en " << _funcion << std::endl;
    }
    
    // El nombre de la función. Puede ser const, ya que no
    // se modifica durante la vida del objeto.
    const std::string _funcion;
};

// La macro que crea un objeto de la clase TraceWriter
// (si el compilador soporta la macro __FUNCTION__)
#define LOG_FUNCION  \
    TraceWriter _trace_writer(__FUNCTION__)

En una función, se llama a la macro en la primera línea

void mi_funcion
{
    LOG_FUNCION();
    // Haz algo
}

Así obtenemos una traza en stdout cuando entramos en la función y otra cuando salimos por no importe qué salida. Como usamos una macro, la podemos definir como nada en una versión de release.

Con un miembro estático en la clase TraceWriter es posible crear anidamientos. El constructor aumenta el nivel de anidamiento y el desctructor lo disminuye. Para cada nivel de anidamiento se imprime alguna cantidad de espacios delante la traza.

Memoria reservada

No olvidarse de liberar la memoria reservada es importante. Se puede utilizar la técnica descrita para reservar y liberar memoria dinámica en una función. En la biblioteca estándar de C++ ya hay clases que implementan este comportamiento:

  • unique_ptr para el estándar C++11 y
  • auto_ptr para el estándar C++0x

Presentamos aquí el uso de unique_ptr. No obstante, el código sería igual sustituyendo unique_ptr por auto_ptr.

void mi_funcion
{
    // Reserva memoria con new
    std::unique_ptr<MiClase> puntero_a_mi_objeto(new MiClase);
    
    // Haz algo con puntero_a_mi_objeto
    
    // La memoria se libera al salir de la función
}

Cuando se sale de la función, el destructor de unique_ptr llama delete puntero_a_mi_objeto. Con esto libera la memoria cuando se sale de la función.

Conclusión

Hemos visto que los “guardianes de alcance” sirven para asegurar la liberación de recursos. Además, pueden reservar y liberar estos recursos con una sóla línea en la función que los utiliza. La técnica consiste en crear una clase, cuyo constructor reserva el recurso y cuyo destructor lo libera. Esta clase suele guardar una referencia a este recurso.

Este concepto sólo se puede utilizar con los lenguajes que llaman al destructor inmediatamente después que un objeto sale del alcance. Por eso no se puede usar con lenguajes que usan un colector de basura como Java, ya que en estos lenguajes no está definido, cuando se llama al destructor. Por lo tanto no está asegurado que se libera un recurso a tiempo.

Lectura adicional

“Programación líneal” es evitar bifurcaciones condicionales como los if o switch y reemplazarlas por casos únicos. Estos casos únicos suelen contener expresiones más sofísticadas; en cambio se gana en legibilidad y testabilidad del código.

Expresiones booleanas

Como un ejemplo de introducción hablamos de las expresiones booleanas, que suelen ser un caso frecuente de aplicar un estilo “lineal”.

Podemos asignar el valor de una variable booleana medianta una bifurcación if:

bool igual;
if (a == b)
{
    igual = true;
}
else
{
    igual = false;
}

Sin embargo, todo esto podemos escribir en una sólo línea:

bool igual = a == b;

La segunda forma he bautizado “líneal”, porque el programa no tiene saltos. La misma sentencia se ejecuta en todos los casos. Por eso mejora la testabilidad del código. No necesito contemplar dos casos distintos (a igual a b y a desigual a b) para ejecutar todas las líneas del código al menos una vez.

Este ejemplo demuestra también que la programación líneal mejora la legibilidad: Varias líneas de código se acortaron a una sola. Una mejora de legibilidad no se da siempre pero a menudo.

Arriba he mencionado también, que la sustitución de saltos conlleva expresiones más complejas. Esta características observamos también en este ejemplo. La primera forma parte de una expresión “si algo es cierto, entonces haz una cosa, sino haz otra”. Esto es más intuitivo que la segunda forma, que es una ecuación booleana. Un cálculo booleano no suele presentar demasiada dificultad para un programador veterano, pero en casos más complejos puede acabar en expresiones menos intuitivas.

Linealizar con el operador ?:

Las asignaciones condicionales se puede casi siempre escribir en una sentencia. Una expresión condicional es del tipo

if (algo)
{
    mi_variable = función_de_algo;
}
else
{
    mi_variable = función_de_no_algo;
}

Por ejemplo, si quiero acotar un rango, entonces el algo sería una expresión como a < max_a. Si estoy dentro del rango, la función_de_algo sería la variable a, si estoy fuera sería max_a.

if (a < max_a)
{
    mi_variable = a;
}
else
{
    mi_variable = max_a;
}

La forma más directa de reemplazar el if sería con el operador “?:”. No todos los lenguajes ofrecen este operador, pero muchos.

mi_variable = a < max_a ? a : max_a;

Esta expresión podemos hacer todavía un poco más legible. Acotar el valor máximo de un rango corresponde a tomar el valor mínimo del valor a acotar y el límite. Por lo tanto podemos escribir el ejemplo arriba como

mi_variable = min(a, max_a);

Por supuesto, el operador ?: no ha desaparecido. Se encuentra ahora en la función min. Sin embargo, hemos ganado en testabilidad. Podemos comprobar el funcionamiento de la función min por separado y hemos simplificado la expresión que asignamos a mi_variable.

Crítica al operador ?:

Un buen observador habrá notado que realmente no hemos reemplazado el if de todo. Ya no aparece la palabra clave if, pero el operador ?: es una especie de notación abreviada de un if. El hilo de la ejecución pasa por la expresión en todos los casos, pero no ejecuta toda la expresión. Hemos mejorado la legibilidad, pero no testabilidad.

Una primera manera de trater este problema ya hemos introducido: esconder el operador ?: en una función. Entonces todavía nos queda comprobar dos casos distintos en esta nueva función, pero suele ser bastante más simple comprobar una función como min que una expresión con el operador ?: en un contexto complejo donde, además, puede haber varias bifurcaciones.

Debemos tener en cuenta que cada if multiplica el número de posibles casos por dos. Dividir los if en funciones pequeñas simplifica las pruebas. Una función con tres bifuraciones tendría 23 = 8 casos distintos. Reemplazar estos if por funciones pequeñas nos dejaría dos casos por función – que serían 2 · 3 = 6 casos.

Debemos tener en cuenta también, que el operador ?: dificulta la depuración, por lo cual sólo debería emplearse para casos simples. Al contrario de una estructura if, no tenemos un bloque de if y de else, donde podemos colocar un punto de interupción. Además, el operador ?: sólo puede eligir entre expresiones. Si queremos ejecutar sentencias de forma condicional, no lo podemos usar. Debemos usar el if.

Expresiones incondicionales

Hay casos en que podemos prescindir del operador ?: por completo y podemos usar una expresión incondicional, que sería una expresión que básicamente usa operaciones aritméticas como más y menos.

Para ello presentamos un ejemplo más complejo. Imaginemos que tenemos una tabla de base de datos y queremos imprimir los datos válidos. Una columna VALIDO contiene un uno si el registro es válido y cero en el caso contrario. El tratamiento habitual sería hacer un bucle con un if anidado. Si la columna VALIDO es distinta de cero, añadimos el registro a la salida final.

for (Registro registro : todos_los_registros) 
{
    if (registro.valido == 1)
    {
        String salida = registro.toString();
        print(salida);
    }
}

Ahora reemplazamos el if por una expresión incondicional. En su lugar aparece una expresión más compleja con una multiplicación.

for (Registro registro : todos_los_registros) 
{
    String salida = registro.toString();
    print(salida.substring(0, salida.length() * registro.valido));
}

Si el registro es vállido, entonces registro.valido vale uno y la multiplicación no altera el valor de salida.length(). La expresión

salida.substring(0, salida.length() * 1))

es igual a salida. En el caso contrario de que registro.valido vale cero, el producto salida.length() * registro.valido vale cero y llegamos a la expresión salida.substring(0, 0) – que es un string vacío. En este caso se imprime un string vacío como print("") que realmente no imprime nada.

Tenemos el mismo comportamiento que en la versión con la condición if pero sin usar una bifuración. Al mismo tiempo hemos hallado un ejemplo de como la expresión resultante se puede complicar. No es muy intuitivo multiplicar la longitud de una cadena de texto con un valor en general y mucho menos con el fin de suprimir esta cadena.

Reemplazando los saltos llegamos a menudo a una situación donde debemos decidir si prevalece la testibilidad del código o la intuición del lector del programa. Como criterio podemos usar la legibilidad del código. Si podemos reemplazar muchas líneas por pocas bien comentadas solemos ganar en general. Sin embargo, siempre debemos comprobar si no ganamos aún mucho más si pasamos el cuerpo de un bucle a una función.

Trabajar con datos erróneos

Muchas veces hace falta comprobar la validez de datos antes de usarlo. En general conviene comprobar los datos donde se usan. Es decir, una función que sólo pasa un dato como parámetro a otra función, no debería comprobar el dato en general. No obstante, algunas bibliotecas como la biblioteca estándar de C no comprueban la validez de los datos y obligan al usuario de sus funciones de comprobar la validez de datos. Una función de usuario, en cambio, debería devolver un código de error o lanzar una excepción cuando no puede seguir por un dato erróneo.

La comprobación de errores conlleva a menudo un if: “Si esto es válido, entonces hazlo.” Sin embargo, hay ocasiones en que uno puede utilizar un dato malo. Esto devolverá un resultado malo, pero puede simplificar la comprobación de errores. Por ejemplo, no hace falta comprobar la validez de una ruta en el sistema de ficheros. Si la función fopen no la encuentra, entonces devuelve un controlador nulo. Una comprobación de este controlador sería suficiente para protegerme contra cualquier error de fichero – no sólo contra una ruta de fichero errónea.

En programas que hacen un uso exhaustivo de excepciones muchas veces no hace falta ningún if. Yo puedo calcular el índice en un array sin miedo, ya que se pueden hacer todas las operaciones aritméticas con números enteros aunque los números no representen algo físico. Si el índice calculado luego no existe en el array, ya se lanzará una excepción.

Este enfoque tiende a un tiempo de cálculo menor cuando los datos son buenos, ya que se hacen menos comprobaciones. En cambio incrementa el tiempo de cálculo con datos malos, ya que se realizan cálculos que luego no tienen un resultado útil. Si consideramos un dato malo un caso excepcional, entonces ahorramos más que perdemos con cada comprobación de que podemos prescindir.

Conclusión

Hemos visto como la “programación líneal” simplifica el código y mejora su testabilidad. En general mejora también la legibilidad aunque tiene tendencia a introducir expresiones más complejas que a veces no son intuitivas y requieren un buen comentario explicativo.

Se puede conseguir un resultado optimal utilizando varias técnicas: usando el operador ?:, empleando a funciones auxiliares y finalmente comprobando la validez de los datos donde se usan. En todo caso no hay un camino de oro para la “programación líneal”. Hay que averiguar si prevalece la belleza del código o su intuitividad: un empleo de muchos if puede ser la mejor representación de la descripción humana de un algoritmo.

Lectura adicional

Existe una confusión entre “compilar un programa” y “lenguajes compilados”. En este artículo aclaramos que “compilar un programa” significa “convertirlo en un formato optimizado para el ordenador” y que este formato es una secuencia de instrucciones para el microprocesador en el caso de “lenguajes compilados”.

Una duda que surge frecuentemente entre los usuarios de Java es: ¿Java es un lenguaje interpretado o compilado? Obviamente se compila el programa a un formato que se llama byte-code, por lo tanto debería ser un lenguaje compilado. Pero no lo es. Java es un lenguaje interpretado. El intérprete de Java es la “máquina virtual de Java”. Sin ella el byte-code no es ejecutable.

La confusión resulta de usar la palabra “compilar” para dos cosas distintas aunque similares. Un significado de “compilar” es “convertir un programa a un formato óptimo para el ordenador” y el otro es “convertir un código a un programa ejecutable”. Un programa ejecutable es obviamente la forma más óptima para un ordenador, porque ya no necesita ninguna transformación adicional.

Los compiladores de los “lenguajes compilados” como C o Fortran traducen el código fuente directamente a instrucciones para el microprocesador. Esto trae la ventaja que se puede utilizar el programa compilado independiente del lenguaje que lo originó. No necesito instalar ningún intérprete para ejecutar programas en estos lenguajes. La desventaja es que el programa sólo funcionará en la plataforma para que se compiló.

El contrario vale para un lenguaje interpretado. El programa es inpendiente de la plataforma pero depende de la previa instalación de un intérprete porque un programa en un lenguaje interpretado no puede existir por sí sólo. El programa de un lenguaje interpretado no es un programa ejecutable. Es meramente un fichero de datos de entrada para el intérprete. El intérprete “interpreta” el fichero de entrada y ejecuta las instrucciones correspondientes en el microprocesador. El intérprete es el enlace entre un programa independiente de la plataforma y una plataforma. El byte-code de Java, por ejemplo, es el mismo en Windows o Linux, pero la máquina virtual de Java es distinta. Se necesita una específica para cada plataforma.

Entonces ¿qué significa “compilar” el código Java? Pues, “compilar un programa” signífica “convertirlo en un formato optimizado para el ordenador”. Esto significa en caso de un lenguaje compilado convertirlo a una secuencia de instrucciones para el microprocesador. Para un lenguaje como Java signífica convertirlo a un formato optimizado para la interpretación. Por ejemplo, una palabra clave como while se puede sustituir por un sólo byte en el byte-code. Así la máquina virtual sólo necesita leer y entender un byte en lugar de cinco.

Se pueden compilar hasta lenguajes puramente textuales como JavaScript. En este caso el compilador no hace mucho más que borrar todos los comentarios y espacios innecesarios. El resultado es un programa con quizá 20.000 caracteres en una sola línea. Esto no es un formato práctico para visualizarlo en pantalla pero el intérprete de JavaScript puede trabajar un poco más deprisa por no perder tiempo con comentarios y espacios que no aportan nada a la funcionalidad.

Lectura adicional

El operador de igualdad se usa para dos conceptos diferentes en los lenguajes de programación: la identidad y la equivalencia. En este artículo quiero aclarar estos conceptos y dar ejemplos como algunos lenguajes de programación los implementan.

Los conceptos

Habitualmente se denomina igualdad al operador == (o = en algunos lenguajes como Pascal o Visual Basic). Pero hay dos conceptos de lo que puede ser igual: la entidad que contiene un valor (identidad) o el valor que contiene (equivalencia).

Decimos que las dos variables a = 5 y b = 5 son iguales en sentido matemático porque contienen el mismo valor 5. Sin embargo, a y b son dos variables distintas en sentido informático si se guardan en dos direcciones distintas en la memoria. En el último caso hablamos de identidad: las variables a y b son iguales cuando hacen referencia a la misma entidad. En el primer caso hablamos de equivalencia. Las variables pueden hacer referencia a una memoria diferente, pero contienen el mismo valor.

Es obvio que la identidad es un criterio más estricto. Dos objetos distintos pueden tener el mismo valor o no, pero el mismo objeto comparado a si mismo siempre es igual.

El uso en los lenguajes

Implementación de identidad y equivalencia
Significado de igualdad
Equivalencia
(mismo valor)
Identidad
(mismo objeto)
Lenguajes compiladas (“antiguas”) Operador == Operador == entre punteros
Lenguajes interpretadas (“modernas”) Método equals Operador ==

Los lenguajes de programación más antiguas como C o Fortran suelen interpretar la igualdad como equivalencia. Para esto puede haber varias razones. Primero la equivalencia es el concepto más matemático (Fortran viene de formula translation). Luego no existía el concepto de objeto cuando se crearon estos lenguajes. Los tipos de variables eran los básicos como caracteres, números enteros y números de coma flotante. Los compiladores convertieron las expresiones a == b a una comparación entre los valores de dos direcciones de memoria. Para comprobar identidad se podían comparar los punteros a estas memorias. No extraña que se sobrecarga el operador == en C++ con un significado de equivalencia hasta hoy en día.

El asunto se cambió con los lenguajes orientado a objetos en que no existen punteros como C#, Java o PHP. En estos lenguajes, no son las variables que guardan los objetos sino el intérprete. La variable de objeto guarda meramente un identificador. Por lo tanto, una comparación entre dos variables es una comparación entre identificadores y no valores. En consecuencia la igualdad tiene la conotación de identidad.

También cabe señalar que estos lenguajes no permiten sobrecarga el operador == como en C++. No es posible crear un operador == que compare el contenido. Tampoco conviene que el intérprete realice una comparación binaria entre dos objetos. La representación binaria depende de la plataforma y un lenguaje como Java se creó justamente para no depender de ella.

Clases inmutables

Ninguna regla sin excepción. En Java o C#, sí, se pueden comparar los valores de dos strings con el operador ==. Esto es posible ya que estas clases son inmutables (o constantes según la jerga de C++): Sus instancias ya no se pueden modificar una vez creadas. Los intérpretes – la máquina virtual de Java o el framework de .NET, respectivamente – aprovechan esta característica. Si initializo una instancia de string con un valor ya existente en otra instancia, entonces no se crea un nuevo valor, sino la nueva instancia apunta al valor existente. Por esto, la nueva variable contiene una referencia a la misma instancia que la variable antigua. Ambas apuntan al mismo objeto y, por lo tanto, son idénticos.

Las clases inmutables tienen más características interesantes. Por ejemplo, no hace falta copiar sus contenidos. En una copia profunda de una clase (deep copy en inglés) debo crear constructores de copia para cada subobjeto de una clase, pero no para un string. La copia causa que una variable de clase string apunta a otro objeto guardado en el intéprete, pero el valor del objeto en si no se modifica ya que es inmutable. Operaciones sobre cadenas de texto reasignan referencias, pero no tiene por qué crear nuevas cadenas.

Conclusión

Hemos visto que existen dos conceptos de igualdad: la identidad y la equivalencia. La equivalencia es el concepto implementado en los lenguajes más tradicionales, mientras la identidad se ha impuesto en los lenguajes más recientes – entre otros motivos por razones técnicas. Los lenguajes que implementan el operador == como equivalencia suelen comparar identidades mediante punteros. Los lenguajes que implementan la identidad para el operador == suelen propicionar un método equals para la equivalencia.

Enlaces de interés

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

Únete a otros 51 seguidores

Archivos

agosto 2018
L M X J V S D
« Abr    
 12345
6789101112
13141516171819
20212223242526
2728293031