miércoles, 5 de noviembre de 2008

El lenguaje de programación D

En este artículo se hará una introducción al lenguaje de programación D para lo cual se explicarán las principales diferencias con respecto a C y C++; por ello sería aconsejable que el lector de este pequeño artículo conozca las características dichos lenguajes (u otros similares, como Java o C#) y algunos conceptos como la programación orientada a objetos o el control de errores mediante excepciones aunque no es necesario que sea un experto en dichos temas.

Este artículo no es un manual ni un tutorial y por lo tanto no es la mejor referencia para aprender el lenguaje, aunque alguien que conozca C++ o de Java podrá tener una idea muy completa de D tras leerlo.

En mi opinión, D es un lenguaje con un potencial increible, capaz de proporcionar al desarrollador la productividad y la elegancia de Python o Ruby pero con la eficiencia de C.

Este documento no está terminado y se irá ampliando con el tiempo; puede verse una lista de las cuestiones pendientes al final del mismo. La versión actual es la: 0.3 actualizada el 18 de septiembre de 2008. El documento es básicamente un resumen de la documentación de D, traducido, y por lo tanto la mayoría de los ejemplos están tomados de la misma. Aconsejo al lector interesado en el lenguaje que tras leer el artículo y haberse hecho una idea general, lea la referencia completa en digitalmars.com/d.

Cambios al artículo:

  • 18 de septiembre: ampliada la sección de cadenas, creada sección de propiedades (diatriba incluida), creada sección de guardas de ámbito. Arreglado "bug" en el ejemplo de los delegados (gracias Miguel.)

¿Qué tipo de lenguaje es D?

La clasificación del lenguaje de programación D sería: lenguaje compilado a código nativo (con compilación opcional a .NET como C#), orientado a objetos pero permitiendo programar con funciones libres y clases ligeras (structs), con plantillas y mixins (que permiten la programación genérica) y con posibilidad de acceso a bajo nivel.

¿Qué diferencias tiene con respecto a C++?

Esta clasificación parece exactamente la misma que la de C++ y no es casual, pues C++ es el lenguaje al que D aspira a sustituir. D es un lenguaje evolucionario, no revolucionario y en un principio puede parecer que no aporta demasiado sobre C++. Pero no son las características generales sino los detalles de las mismas los que marcan la diferencia. En primer lugar D conserva todas las características de expresividad de C++ (cosa que ni C# ni mucho menos Java consiguen en su afán por hacerse lenguajes más accesibles), pero con una sintaxis y unas construcciones mucho más sencillas y lógicas. Además, otro de los puntos fuertes de C++, su rendimiento, también se ve reflejado en D (en algunas ocasiones incluso superado.)

Por otro lado D cuenta con muchas otra características de las que C++ no dispone, de las cuales vamos a hacer un pequeño repaso a continuación. Quisiera recordar al lector que, al contrario de lo que pasa con Java o C#, estas características no suponen una perdida apreciable de rendimiento para D en comparación con C++:

Gestión automática de memoria (recolección de basura)

En C++ cuando creamos objetos que queremos que transciendan el ámbito de la función, método o bloque de código que los ha creado los creamos con utilizando el operador "new". Este operador se encargará de asignar automáticamente la memoria que sea necesaria para el objeto y llamará a su constructor, devolviéndonos una referencia al objeto ya inicializado que podremos almacenar en una variable del tipo del objeto.

Cuando ya no vamos a necesitar el objeto procedemos a borrarlo con el operador "delete" lo que liberará la memoria asignada por el objeto. ¿Parece sencillo verdad? Bien, debería serlo, pero en la práctica es raro el programa en C++ que no tiene o ha tenido una fuga de memoria porque el flujo de ejecución de una parte del mismo termina antes de llegar al delete (y por lo tanto la memoria queda asignada para siempre sin posibilidad de recuperarla) o que ha fallado porque erróneamente se ha hecho un delete del mismo objeto dos veces o se ha intentado acceder (también erróneamente) a un miembro de un objeto previamente borrado. Obviamente estos errores son evitables pero errar es humano y cuantas más tareas delegue en el programador el lenguaje de programación más posibilidad hay de que haya errores (si no fuera así, todos seguiríamos haciendo ensamblador.)

La gestión automática de memoria quiere decir que el programador seguirá creando los nuevos objetos con "new" pero ya no tendrá que preocuparse de borrarlos con "delete" porque existirá un "recolector de basura" que se encargará de eliminar automáticamente los objetos para los que ya no exista ninguna referencia. Es decir, si tenemos la siguiente función:

void pierdeaceite() {
Obj *o = new Obj();
}

En C++ esto produciría una fuga de memoria cada vez que la llamáramos porque al terminar la función ya no habría ninguna referencia a "o" (y por lo tanto no podríamos usarlo) y sin embargo al no haber hecho delete seguiría existiendo en la memoria. El código equivalente en D:

void nopierdeaceite() {
Obj o = new Obj();
}

No produciría ninguna perdida de memoria porque el recolector de basura detectaría que como "o" fue declarado dentro de "nopierdeaceite" y su alcance termina al terminar la función, la memoria puede ser liberada.

Esta característica (con la que cuentan también C# y Java) puede desactivarse si por cuestiones de rendimiento o de cualquier otro tipo no queremos que se ejecute el recolector de basura llamando a gc.disable() y luego reactivarse llamando a gc.enable(). Lo más espectacular es que mientras el recolector de basura sigue desactivado las referencias a los objetos que hagamos con "new" se seguirán contabilizando por lo que una vez que re-activemos la recolección de basura esos objetos serán igualmente gestionados (no necesitaremos llamar a delete sobre los mismos.) Esto no es así en C# ni en Java,por ejemplo.

Finalmente también podemos borrar, como en C++, nosotros mismos los objetos llamando a "delete" sin que ello cause ningún tipo de conflicto con el recolector de basura.

Gestión de errores mediante manejo de excepciones

C++ permite gestionar los errores mediante el mecanismo de manejo de excepciones, aunque por herencia de C en demasiadas ocasiones se sigue utilizando el sistema de "valores mágicos devueltos por la función".

En D el sistema de manejo de excepciones es superior al de C++ al incorporar algunas características de lenguajes más recientes. Cuando una excepción producida en código D no se captura, se muestra un mensaje de error con información de la excepción. La sintáxis es parecida a la de otros lenguajes con manejo de excepciones:

try {

throw new Exception("mensaje de error");
}
catch(Exception e) {
printf("capturada la excepción, mensaje: %.*s\n", e.msg);
} finally {
printf("este mensaje se mostrará siempre");
}

El código que se ejecuta dentro del Try en caso de lanzar una excepción se ejecutará el código que esté en el bloque del "catch" si la excepción especificada entre paréntesis coincide con la lanzada o es padre de la misma en la jerarquía de la excepción (las excepciones son clases con su propia jerarquía.) El padre de la jerarquía es la clase "Exception" así que en ejemplo el catch capturaría cualquier excepción. En el caso del constructor de la excepción en la instrucción que lo lanza (el "throw") hemos especificado una cadena como parámetro pues en la clase Exception el argumento del constructor es un mensaje que queda almacenado en el miembro "msg", que generalmente se va a usar para amplíar la información del error, y ese miembro es el que imprimimos precisamente en el printf del catch.

El bloque con la instrucción "finally" se ejecutará siempre, se produzca una excepción o no y en caso de que se produzca incluso aunque se capture en un catch. Por lo tanto la salida de este programa sería:

capturada la excepción, mensaje: mensaje de error
este mensaje se mostrará siempre

Guardas de ámbito para asegurar la ejecución de código a la salida de un ámbito

Aunque D tenga recolección automática de memoria hay otro tipo de recursos que se siguen teniendo que liberar manualmente. Ejemplos de estos recursos son los ficheros, los cerrojos y mutex; pero hay otros. Normalmente un código como éste (robado de la página de D)

    void mifuncion() {
Mutex cerrojo = new Mutex();
lock(cerrojo); //Adquirimos
funcionpeligrosa(); // Puede lanzar excepciones
unlock(cerrojo); // Liberamos
}

En este ejemplo puede verse fácilmente el problema: si "funcionpeligrosa" lanza una excepción, no se llama al "unlock(cerrojo)" y por lo tanto el mutex queda bloqueado "para siempre". Una posible solución es usar una construcción de tipo try... finally y poner el unlock dentro del finally, relanzando de nuevo la excepción recibida, pero D nos ofrece una solución más escueta y elegante llamada "Scope Guards" ("Guardas de Ámbito".) Los scope guards se implementan llamando a la palabra clave scope poniendo entre paréntesis el ámbito y después una linea o más (con llaves en el segundo caso.) Lo que hace es sencillo: cuando se llegue alcance el ámbito especificado, se ejecutará el código que hubiéramos asociado con el scope. Lo veremos más fácilmente con el ejemplo:

    void mifuncion() {
Mutex cerrojo = new Mutex();
lock(cerrojo);
scope(exit) unlock(cerrojo);
funcionpeligrosa();
}

Con este código estamos indicando que cuando salgamos ("exit") del ámbito ("scope") actual, se ejecutará unlock(). Usando exit garantizamos que el unlock se ejecutará en cualquier caso al salir, sea esta salida de forma normal o por una excepción (sin interrumpir el flujo de esta, por lo que no hace falta relanzarla.) Existen varios especificadores de ámbito para scope, que son:

  • sucess: Se ejecutará cuando se salga con éxito (sin excepción.)
  • failure: Se ejecutará cuando se salga con error (con una excepción.)
  • exit: Se ejecutará cuando se salga del ámbito en cualquier caso (éxito o error.)

Realmente, usando las guardas de ámbito, la instrucción finally nunca es necesaria aunque se sigue manteniendo en el lenguaje. Por comparación, la "antigua" forma de hacer esto sería:

    void mifuncion() {
try {
Mutex cerrojo = new Mutex();
lock(cerrojo);
funcionpeligrosa();
} finally {
unlock(cerrojo);
throw(o);
}

Las guardas de inclusión no sustituyen a la instrucción except; para capturar excepciones y realizar operaciones en la captura debemos seguir usándola.

Para los Pythoneros, la guarda de ámbito de éxito (sucess) sería equivalente a el "else" de las excepciones en Python.

Estructuración del código en módulos y paquetes

En C/C++ la estructuración del código se hace creando "ficheros de cabecera" con extensión .h o .hpp que contienen las declaraciones de los símbolos que cada subparte explorta, y luego esos ficheros de cabecera se importan usando la directiva del preprocesador #include. Esta importación lo único que hace es incluir el código del .h importado allí donde se encuentra la directiva import. Esto lleva a problemas cuando varios ficheros de cabecera definen un mismo símbolo, con soluciones poco elegantes.

C++ añade además el concepto de namespaces que permite agrupar los símbolos de distintos ficheros en un único espacio de nombres lógico.

En D, como en casi todos los lenguajes modernos, la estructuración del código y las bibliotecas se hace usando módulos y paquetes. Un módulo no es más que un fichero fuente de código D (generalmente con extensión .d).

Importar símbolos de un módulo con import

Para que un módulo pueda acceder a los símbolos de otro módulo se usa la sentencia import:

---- inicio modulo1.d ----
module modulo1;

int simbolomodulo1 = 10;
---- fin modulo1.d ----

---- inicio modulo2.d ----
module modulo2;

import modulo1;

writefln( simbolomodulo1 ); // correcto
---- fin modulo2.d ----

Como puede verse, al contrario de lo que sucede en otros lenguajes, cuando importamos los símbolos de un módulo no hace falta anteponer el nombre del módulo con un punto antes de llamar a un símbolo; en modulo2 podemos ver que hemos usado simbolomodulo1 en lugar de modulo1.simbolomodulo1 como se haría en otros lenguajes. A pesar de ello la notación de modulo.simbolo se permite también en D, y es necesaria útil cuando se importan dos módulos que definen símbolos homónimos para indicar el módulo del cual queremos usar el símbolo; no hacerlo así en ese caso es un error de compilación.

Además, también se puede ver que al principio de cada modulo se usa la palabra clave module seguida del nombre del módulo. Esta sentencia es opcional y para módulos no pertenecientes a paquetes no suele ser necesario pues por defecto se toma como nombre del módulo el nombre del fichero sin la extensión. Si existe, debe ser la primera sentencia (comentarios aparte) del fichero fuente.

static import

Si quieremos que los símbolos importados sean accedidos siempre mediante la notación modulo.símbolo usaremos un static import:

---- inicio modulo2.d ---
static import modulo1;

// writefln( simbolomodulo1 ); // error
writefln( modulo1.simbolomodulo1 ); // correcto

import public y private

Las palabras clave public y private antepuestas a un import significan:

public
Los símbolos que se incluyen a través del import serán a su vez exportados a los módulos que importen el módulo donde está el import.
private
Los símbolos que se incluyen a través del import no estarán accesibles en los módulos que importen a su vez el módulo donde está el import. Este es el comportamiento por defecto por lo que no es necesario escribir esta palabra clave.

Ejemplo:

---- inicio modulo2.d ----
import modulo1; // private import por defecto

int simbolomodulo2 = 20;

writefln( simbolomodulo1 );
---- fin modulo2.d ----

---- inicio modulo3 ----
import modulo2;

writefln( simbolomodulo2 ); // correcto, simbolomodulo2 esta definido en modulo 2
writefln( simbolomodulo1 ); // error, simbolomodulo1 está definido en módulo1,
// y como modulo3 no importa a modulo1 no puede
//acceder al módulo1 aunque modulo2 lo importe
//y modulo3 importe modulo2

Para que no diera error:

---- inicio modulo2.d ----
public import modulo1; // ahora exportamos todos los símbolos de modulo1
// como si fueran de modulo2

int simbolomodulo2 = 20;

---- fin modulo2.d ----

---- inicio modulo3 ----
import modulo2;

// correcto, simbolomodulo2 esta definido en modulo 2:
writefln( simbolomodulo2 );
// correcto porque modulo1 esta importado públicamente en modulo2:
writefln( simbolomodulo1 );

Renombrado de módulos

Los módulos pueden renombrarse cuando se importan usando el signo igual con el nuevo nombre a la izquierda y el nombre del módulo renombrado a la derecha:

import corto = mipaquete.directorio.nombreDeModuloMuyLargo

import selectivo de símbolos

En lugar de importar todos los símbolos de un módulo, existe la posiblidad de importar sólo algunos símbolos específicos. Esto es útil cuando sólo vamos a usar uno o dos símbolos de un módulo concreto. La sintáxis para ello es poner el signo dos puntos con el nombre del módulo a la izquierda y la lista de símbolos que se importan del mismo a la derecha, separados por comas.

// Sólo importamos writef y writefln de std.stdio
import std.stdio: writef, writefln

También podemos importar símbolos selectivamente al tiempo que los renombramos:

// Sólo importamos writef y writefln renombrando el último a escribelinea
import std.stdio: writef, escribelinea = writefln

Acceso a símbolos ocultados por símbolos locales

En ocasiones queremos poder acceder a un símbolo del propio módulo en el que está el código que lo usa, pero este puede haberse visto ocultado por un símbolo en el alcance local. En ese caso tan sólo tenemos que usar la notación "punto símbolo" (.símbolo) que seria equivalente a hacer nombredemodulo.símbolo desde otro módulo que importara el actual:

int simbolo;

void funcionconlocal(int simbolo) {
writefln( simbolo ); // escribe el parámetro simbolo
writefln( .simbolo ); // escribe el símbolo del módulo

Paquetes

En ocasiones el código se necesita una estructuración que vaya más allá de los módulos. En ese caso se puede hacer una jerarquía de módulos bajo un concepto mayor llamado "paquete". Para declarar que un módulo está en un paquete debemos incluir la ruta que debe tener el módulo dentro de la jerarquía del paquete, separando los distintos niveles de la misma y el nombre del módulo (al final) mediante puntos. Generalmente la jerarquía de los paquetes suele corresponderse con una organización de directorios y subdirectorios equivalente, aunque esto no es estrictamente necesario.

---- fichero conexiones.d, que está en el directorio mibiblioteca/red
module mibiblioteca.red.conexiones

//etc
---- fichero que está en el directorio mibiblioteca/red/servidores
module mibiblioteca.red.servidores.http

//etc

Donde se buscan los módulos y paquetes

Cuando escribimos "import nombremodulo" el compilador primero buscará "nombremodulo.d" en el directorio actual. De no encontrarlo, si hemos proporcionado uno o más parámetros -Idirectorio al compilador "nombremodulo.d" se buscará en dichos directorios. Finalmente si tampoco se encuentra en ellos se buscará en el -Idirectorio que esté especificado en el fichero dmd.conf.

Cuando se trata de paquetes, si el paquete está organizado por directorios sólo es necesario especificar el nombre raíz donde se encuentra el paquete, no los subdirectorio. Por ejemplo si escribimos import mibib.red.conexion y el raíz del paquete mibib está en /usr/include/dmd/mibib (en el caso de un sistema Unix/Linux/Mac) basta con especificar como parámetro al compilador o en el dmd.conf -I/usr/include/dmd/mibib y el compilador buscará sólo el subdirectorio "red" y dentro del mismo el fichero "conexion.d".

Compatiblidad de llamada con C

Debido a la difusión del lenguaje, la mayoría de las APIs de sistemas operativos y librerías de sistemas están escritas en C u ofrecen una interfaz para el mismo. D puede acceder a bibliotecas de C. Para ello, además de enlazar con el fichero binario de la biblioteca mediante parámetros al compilador, debemos incluir en nuestro código D una declaración de los símbolos y funciones de C a los que queramos acceder. Como los tipos de datos de D suelen tener una correspondencia muy directa con los de C este proceso suele ser bastante sencillo; sin embargo para conversiones de ficheros de cabecera .h más complicados se dispone de la herramienta htod que realiza la conversión de tipos y sintáxis de forma automática, tomando como entrada un fichero .h de C y generando un fichero .d que podemos incluir en nuestros proyectos.

Para las declaraciones de símbolos y funciones de C, debemos poner la instrucción "extern(C):". Tomando un ejemplo de la descripción del htod, si en un fichero de cabecera .h tuviéramos el siguiente código C:

unsigned u;
#define MYINT int
void bar(int x, long y, long long z);

La conversión del mismo en D sería:

extern(C):
uint u;
alias int MYINT;
void bar(int x, int y, long z);

Es importante destacar que dentro de un fichero fuente D no podemos incluir código C (al contrario de lo que sucede en C++.)

En la versión actúal de D no existe la posibilidad de enlazar contra bibliotecas C++.

Delegados, funciones anidadas y funciones literales

En D existe un tipo de dato llamado "delegado" que puede usarse para pasar referencias a un método de una clase como parámetros para otras funciones y métodos. Son, en concepto, similares a los punteros a método de C++ pero con una sintaxis tanto de declaración como de creación y uso mucho más sencilla:

class Foo {
[...]
bool esPar(int N) { return (n % 2 == 0); }
bool esImpar(int N) { return !(n % 2 == 0); }
[...]
}

bool validadora( bool delegate(int) dlg, int valor ) {
return dlg(valor);
}

void main() {
Foo f = new Foo();
bool delegate(int) dlgespar;
bool delegate(int) dlgesimpar;

dlgespar = f.esPar;
dlgesimpar = f.esImpar

bool unoespar = validadora(dlgespar, 1);
bool unoesimpar = validadora(dlgesimpar, 1);

Como puede verse en el ejemplo anterior la declaración de una variable que apunte a un delegado es similar a la de una función pero poniendo "delegate" en lugar del nombre y el nombre de la variable apuntadora al final.

Hablando de funciones y métodos, D permite tener funciones anidadas y funciones anónimas. Las primeras son funciones que están definidas dentro de otra función. Son muy útiles para estructurar nuestro código de una forma más jerárquica.

Las funciones anónimas son funciones (normalmente sencillas) sin nombre que suelen utilizarse como argumento para una función que espera recibir una función como argumento.

Declaración anticipada de funciones innecesaria

En C y C++ para que una función pueda llamar a otra esta debe haber sido declarada con anterioridad a la misma. Para ello puede estar el cuerpo completo de la función, o sólo un "prototipo" que indica los tipos aceptados y el valor devuelto, por ejemplo esto no funcionaría:

void llamaotra(int prueba) {
funcionprimera( prueba ); // error, funcionprimera no está definida
}

void funcionprimera(int param) { return param; }

Por lo que tendríamos que declarar funcionprimera antes de implementarla, o directamente implementar "más arriba" de llamaotra.

El compilador de D es algo más inteligente y hace que esto sea innecesario; cualquier función puede llamar a cualquier otra que esté definida en el programa, independiéntemente de su posición en el código.

Compilación condicional y versionado sin necesidad de un preprocesador

En C++, como en C, se utiliza un preprocesador que analiza el código realizando modificaciones e inclusiones sobre el mismo antes de que se inicie la compilación. El preprocesador de C/C++ es antiguo, y además algunos programadores parecen disfrutar escribiendo código enrevesado usando las instrucciones que el mismo proporciona, lo cual lleva a código bastante ilegible y dificil de seguir y depurar.

D elimina completamente el preprocesador y en lugar de esos añade una serie de directivas para el compilador pero incluidas en el lenguaje que nos permiten darle indicaciones sobre que código compilar según unos parámetros determinables en tiempo de compilación.

Estas directivas son:

module

Con esta intrucción indicamos el uso desde nuestro programa de otros módulos en C (ver más adelante el apartado sobre módulos y paquetes para ver una descripción más completa) y por lo tanto sustituiría al #include de C/C++. Al contrario de lo que sucede con el #include no hay ningún problema en que varios módulos incluyan a un mismo símbolo a través de importaciones, por lo que el chapucero código siguiente, tan necesario en C/C++ para proyectos complicados que involucran muchas inclusiones, es innecesario en D:

#ifndef _TENEMOS_MODULO_

//código del módulo

#define _TENEMOS_MODULO_

#endif
//Nada

version

Otro uso muy común del preprocesador de C/C++ es la supresión o no de determinados bloques de código según parámetros pasados al compilador o incluidos en el entorno del mismo. Por ejemplo en C/C++ cuando queremos que un código se compile en Windows o Linux hacemos:

#ifdef linux
printf("estoy en linux!");

#ifdef windows
printf("estoy en windows!");

En este caso la definición "linux" o "windows" lo proporciona el compilador, pero igualmente se pueden suminitrar al mismo definiciones adicionales usando la linea de comandos del mismo.

En D para hacer esto mismo, pero con más elegancia, se usa la instrucción version:

version(linux){
printf("Estoy en linux!");
}

version(windows){
printf("Estoy en windows!");
}

Podemos definir los símbolos comprobables mediante la intrucción version usando parámetros al compilador (-version=shareware) o podemos generar nuevos símbolos dentro del código, siempre usando bloques evaluables en tiempo de compilación, como el incluido dentro de otro bloque version, usando asignaciones:

version(shareware) {
version = puedeleer;
}

version(comercial) {
version = puedeleer;
version = puedeescribir;
version = puedeimprimir;
}


version(puedeleer) {
//Implementación de la funcionalidad de lectura
}

version(puedeescribir) {
//Impplementación de la funcionalidad de escritura
}

version(puedeimprimir) {
//Implementacion de la funcionalidad de impresión
}

En el ejemplo anterior, al compilar especificaremos como parámetro al compilador "version=shareware" o "version=comercial" según la versión del programa que queramos que se genere.

debug

La instrucción debug es muy similar a version pero en lugar de usarse para especificar características del entorno o funcionalidades se usa para incluir dentro del bloque código de depuración que nos ayude a detectar y corregir errores en versiones en desarrollo. Sustituye a otro uso tradicional del preprocesador en C/C++:

#define DEBUG

//codigo normal

#ifdef DEBUG
//codigo de depuracion
#endif

En D el código equivalente sería:

debug = si

//código normal
debug(si) {
//código de depuración
}

La intrucción debug además de aceptar como parámetro un posible identificador, acepta valores enteros. En ese caso se compilarán todos los bloques debug cuyo parámetro sea igual o menor al valor entero. Por ejemplo:

debug = 2

// código normal

debug(1) {
// código de depuración de nivel 1
}

debug(2) {
// código de depuración de nivel 2
}

debug(3) {
// código de depuración de nivel 3
}

En el ejemplo anterior, como debug vale 2, se ejecutarán los dos primeros bloques de depuración (los que tienen como parámetro 1 y 2), pero no se ejecutará el tercero. Esto es útil para incrementar escalar la salida de diagnóstico de nuestro programa en varios niveles, siendo las mayores las que más salida mostrarán.

static if

El static if se usa para comprobar el valor de símbolos evaluables en tiempo de compilación, como constantes o alias. Es similar a version, pero permite una mayor flexibilidad al poder usarse en instanciaciones de templates.

const int bitsint = 16;

static if (bitsint == 16)
alias short INT;

else static if (bitsint == 32)
alias int INT;

static assert

El static assert funciona igual que el assert pero comprobado sólo valores evaluables en tiempo de compilación. En caso de que la condición evalue a un valor falso, el programa no se compilará.

Arrays mucho más manejables y foreach

Los arrays (o matrices, o vectores, como se quieran traducir) en C++ son exactamente los mismos que en C, es decir, un poco de azúcar sintáctico para un puntero a una memoria asignada. El azúcar sintáctico sin embargo no es mucho, y para realizar algunas operaciones básicas (como añadir o quitar elementos a un array o cambiar su tamaño) debemos hacer engorrosas llamadas para asignar e inicializar nueva memoria. La cosa empeora si pensamos que además las "cadenas" en C están implementadas como un array de caracteres sin ninguna propiedad especial.

C++ soluciona parte de estas limitaciones a través de su librería estándar de plantillas (STL) que proporciona plantillas de clase genéricas para diversas estructuras de datos con múltiples operaciones, además varias clases avanzadas para cadenas.

En D, además de existir una biblioteca de plantillas de clase avanzadas (la DTL, actualmente en desarrollo) los arrays cuentan con una serie de características que los hacen mucho más prácticos y manejables que los de C/C++:

  • Cuentan con comprobación de límites.
  • Pueden redimensionarse dinámicamente cambiando su propiedad "length" o añadiendo al final con el operador "~=".
  • Pueden concatenarse fácilmente dos o más arrays en uno sólo con el operador "~".
  • Permiten la especificación de subrangos dentro de un array. Por ejemplo si quisiéramos los elementos 3, 4 y 5 de un array en un subarray podríamos hacer: subarray = miarray[3..5]. Esto además permite copiar de forma sencilla los arrays por valor con la siguiente notación: int array2[] = array1[]; Los rangos también permiten asignaciones automáticas a varios elementos de un array simultaneamente: array[2..6] = 0;
  • Además de la propiedad length, ya comentada, cuentan con otras propiedades útiles (como size para el tamaño, dup para crear un duplicado, reverse para invertir el orden de los elementos y sort para ordenarlos.)
  • Los arrays estáticos (en los que en la declaración indicamos el tamaño de cada dimensión como en int[3][4] matriz; se implementan como las matrices de fortran en lugar de como punteros a punteros; esto hace que sean mucho más eficientes a la hora de calcularlos. Los arrays dinámicos (int[][] matriz;) sin embargo sí que se implementan como en C, como punteros a punteros. Por lo tanto si necesitamos hacer cálculos de matrices extremadamente rápidos es aconsejable usar los arrays estáticos.

En general todas estas características hacen que los arrays en D se manejen de la forma más intuitiva y no haya que estar recordando constantemente que su implementación es poco más que un puntero con longitud (que lo es.)

Cadenas

Una consecuencia directa de estos arrays mejorados es que las cadenas nativas, a pesar de seguir siendo arrays de caracteres, son ahora infinítamente más manejables. Lo único que tenemos que tener en consideración es que los literales de cadena (caracteres entre comillas) son arrays inmutables, esto es, que no pueden ser modificadas y como D no deja asignar entre objetos mutables e inmutables sin hacer conversiones, no podemos asignar los literales de cadena a arrays de char si no los declaramos mutables. Esto que suena tan lioso lo vamos a ver enseguida con un par de ejemplos:

    char[] cadenaMutable; // Cadena es un array de caracteres mutable;
char[] otraCadenaMutable;
cadenaMutable = "hoygan!"; // ERROR: "hoygan!" como literal de cadena es inmutable y no podemos asignarlo a un mutable
otraCadenaMutable = "hoygan!".dup // Correcto, dup crea una copia mutable del literal

Para asignar literales de cadena sin llamar a .dup podríamos declarar a los arrays de caracteres como invariantes:

    invariant(char)[] cadenaInmutable = "hoygan!"; // Correcto, asignamos un inmutable a otro
invariant(char)[] otraCadenaInmutable = cadenaMutable; // ERROR, asignando mutable a inmutable
invariant(char)[] ultimaCadenaInmutable = cadenaMutable.idup; // Correcto, idup crea una copia inmutable

Como esta síntaxis es un poco fea para manejar cadenas y D es un lenguaje orientado principalmente a la practicidad, se ha creado un alias a invariant(char)[] llamado string, de modo que el ejemplo anterior quedaría como:

    string cadenaInmutable = "hoygan!"; // Correcto, asignamos un inmutable a otro
string otraCadenaInmutable = cadenaMutable; // ERROR, asignando mutable a inmutable
string ultimaCadenaInmutable = cadenaMutable.idup; // Correcto, idup crea una copia inmutable

Como he comentado al principio de este punto, las cadenas al no ser más que arrays de char en D pueden tienen las mismas operaciones que éstos (y el mismo rendimiento), por lo tanto podemos olvidarnos del infierno de las cadenas en C:

    // Asignación   
cadena1 = cadena2;

// Copia
string cadena1 = cadena2.dup; //


// Copia de subcadenas
string cadena1 = "Hoygan amijos!";
string cadena2 = cadena1[0..5]; // cadena2 = "Hoygan"

// Concatenación
string cadena3 = cadena1 ~ cadena2;


// Añadir al final
string cadena4 = "Hoygan! ";
cadena4 ~= "amijos!";

// Comparación
if (cadena1 > cadena2) ...

Casi casi igual que en el infierno de punteros y news de C o la verborrea infame de Java o las STL ¿verdad?

Arrays asociativos

D también cuenta con un tipo de dato derivado del array llamado array asociativo que consiste en una estructura de datos (nativa) que asocia una clave con un valor, de forma que después conociendo la clave podamos recuperar el valor. Tanto la clave como el valor pueden ser de cualquier tipo y la forma general de declarar un array asociativo es muy sencilla:

tipoValor[tipoClave];

Por ejemplo para declarar un array asociativo en el que las claves fueran enteros y los valores objetos de tipo "MiClase":

MiClave[int];

O uno con claves de tipo entero y valores de tipo cadena:

int[ string ];

Quien tenga algo de experiencia programando seguramente reconocerá la utilidad de este tipo de estructuras de datos, probablemente una de las más utilizadas en programación (con permiso de las listas/vectores), por ello casi todos los lenguajes de programación ofrecen al programador la posibilidad de usar este tipo. Java, C# y C++ no son una excepción, pero a diferencia de D en estos lenguajes el tipo de array asociativo (y sus variantes) están implementados como clases en la biblioteca estándar por lo que por un lado el rendimiento nunca va a ser el mismo que el de un tipo nativo (optimizable hasta la muerte por el compilador, que sabe manejarlo mejor que una clase) ni por otro lado será igual de cómodo, aunque esto sea menos importante (en el caso de estos lenguajes tendremos que declarar un objeto, construirlo con new y después utilizar métodos para acceder a sus elementos. C++ usando sobrecarga de operadores permitirá usar las instancias con notación de array, pero la declaración y construcción deberán seguir realizándose como objetos de clase; objetos de clase de plantilla, para ser exactos.)

Los arrays asociativos de D además de algunas de las propiedades de los arrays normales, cuentan con algunas adicionales:

  • Podemos llamar a su miembro remove(clave) para eliminar una clave del array.
  • Podemos usar el operador in para comprobar si una clave está en un array (if("polompos" in miArray)...)
  • La propiedad .keys devolverá un array normal que contendrá todas las claves.
  • La propiedad .values devolverá un array normal que contendrá todos los valores.

Aparte de estos tipos nativos (arrays y arrays asociativos) D también contará con una librería de estructuras de datos genéricas que proporcionará estructuras de datos más avanzadas llamada la "D template library" en intenso desarrollo en el momento de escribir este artículo.

foreach y foreach_reverse

Una de las operaciones más comunes con arrays es recorrerlos mediante un bucle for como en el siguiente ejemplo:

for (int i = 0;i < 10; ++i) {
printf("Array[%d]: %d", i, miarray[i]);
}

D añade una palabra clave (heredada de otros lenguajes) que nos permite recorrer los arrays u otros contenedores obteniendo en cada iteración el siguiente elemento en lugar de un índice que luego tendríamos que usar para direccionar cada elemento dentro del bucle. Esto elimina, en la mayor parte de los casos, la necesidad de tener que usar incómodos iteradores, compárese por ejemplo:

TipoContenedor::iterator sl;
for( sl = instanciaCont->begin(); sl != instanciaCont->end(); ++sl)
{
(*sl)->imprimirValor();
}

o...

TipoContenedorIterator it(*instaciaCont);
TipoDato *p;
while( (p = it.current()) != 0)
{
++it;
p->imprimirValor();
}

Con la construcción equivalente en D usando foreach:

foreach(TipoDato t; instanciaCont)
{
t.imprimirValor();
}

En D, por supuesto, también pueden usarse iteradores para formas menos comunes de recorrer los bucles permitiendo toda la potencia de los iteradores "tradicionales" pero rara vez son necesarios porque habitualmente las propias implementaciones de los contenedores permiten recorrerlos de distintas formas:

// Recorrer invertido:
foreach(TipoDato t; instanciaCont.reverse())
{
t.imprimirValor();
}

// Recorrer invertido los valores dobles y pares:
foreach(TipoDato t; instanciacont.filter(pares).transform(doblar).reverse())
{
t.imprimirValor();
}

En el último ejemplo hemos utilizado dos métodos de las instancias de el tipo de dato que estábamos utilizando que aceptan un argumento función o delegado (método) para realizar, en el primer caso una selección de los elementos pares únicamente y el segundo dobla los elementos que queden después de la primera transformación. Como puede verse esto no hace nada que no pueda hacerse con iteradores, pero la sintaxis es mucho más clara y concisa (de nuevo, si realmente necesitamos de iteradores también dispondremos de ellos en D.)

Los métodos mostrados en los últimos ejemplos están implementados en las estructuras de datos de la librería DTL que probablemente formará parte en el futuro, como librería de estructuras de datos, de la librería estándar de D.

También contamos con foreach_reverse que recorre un contenedor del final al inicio por lo que la llamada a reverse() del ejemplo anterior no sería realmente necesaria si usáramemos foreach_reverse en lugar de foreach.

RTTI (identificación de tipos en tiempo de ejecución)

De cuenta con un sencillo mecanismo de RTTI; los objetos disponen de una propiedad "classinfo" la cual contiene algunas propiedades de la clase que se pueden ver completamente en el código del fichero object.d (en general es aconsejable ver ese fichero fuente para ver las capacidades por defecto del objeto raíz de D.) Estas propiedades son:

  • init: Inicializador estático de clase.
  • name: Nombre de la clase
  • vtbl: Array de punteros a las funciones virtuales
  • base: Objeto classinfo de la clase padre
  • destructor: Puntero al destructor
  • deallocator: Punto al desasignador de memoria.
  • defaultContructor: Puntero al constructor por defecto
  • find (método): ¿Para buscar en la jerarquía de la clase?

La versión actual de D no soporta reflexión dentro del lenguaje, aunque algunas librerías de terceros lo están implementando fuera del mismo, por ejemplo Flectioned.

Posibilidad de ejecución de código como si fuera un lenguaje interpretado

Los lenguajes interpretados cuentan con la ventaja de que es muy fácil escribir unas líneas de código en un editor y ejecutar el intérprete sobre el mismo, o inclúir en los sistemas Unix/Linux/Mac como primeras líneas el nombre del intérprete para permitir su ejecución como si de un ejecutable se tratase.

Esta segunda posibilidad está incorporada en D mediante el parámetro al compilador "-run" que compila, enlaza y ejecuta el código que se le pase, sin generar ningún ejecutable en el disco. Ello nos permite crear scripts rápidos que se ejecuten como si de un lenguaje interpretado se tratase simplemente teniendo como primeras líneas:

#!/usr/bin/dmd -run
[Resto de código D]

El compilador de D de Digital Mars es además sorprendentemente rápido, por lo que incluso ejecutándose de esta forma el resultado en ocasiones puede ser más rápido que usando un lenguaje de script real (como Python o Perl.)

Propiedades

Con este tema voy a expandirme un poco más de que sería necesario para explicar las propiedades en D, en mi intento de intentar educar a algunos programadores recalcitrantes.

Muchos programadores inteligentes estarán hasta las narices de leer y escribir "getters" y "setters", que son miembros de clases cuya utilidad es "obtener" (get) o "establecer" (set) el valor de un miembro de una clase. De esto se abusa terriblemente, sobre todo programadores provenientes de los mundos Java y C++, dándose en la realidad casos absurdos como:

    // C++, para no mancillar el D con este ejemplo:
// Persona.h:

class Persona {
public:
void Persona(string nombre, string apellidos);

string getNombre();
void setNombre(string nombre);

string getApellidos();
void setApellido(string apellidos);
private:
string m_nombre;
string m_apellidos;
}

// Persona.cpp:

void Persona::Persona(string nombre, string apellidos) {
m_nombre = nombre;
m_apellidos = apellidos;
}

string getNombre() {
return m_nombre;
}

void setNombre(string nombre) {
m_nombre = nombre;
}

string getApellidos() {
return m_apellidos;
}

string setApellidos(string apellidos) {
m_apellidos = apellidos;
}

// main:
Persona* juan = new Persona("Juan", "Alvarez Martinez");
juan->setName("Juanjo");
juan->setApellidos("Alvarez Martinez");
cout << "Nombre de la persona: " <<>getName();
cout << "Apellidos: " <<>getApellidos();

En realidad, si sabemos que nunca vamos a realizar operaciones sobre el nombre o los apellidos antes de asignarlos y, sobre todo, antes de devolverlos ¿porque escribimos un par de métodos para usar esos miembros? Si lo pensamos bien, en la mayoría de las clases que hemos escrito o visto de esta forma, el 95% de los getters/setters que en origen tienen esta forma jamás harán ninguna operación aparte de asignar el valor o devolverlo directamente. Es decir, esta clase podría haberse escrito directamente así:

    // En D, al ser un ejemplo más digno, además así no hay que escribir dos ficheros:

class Persona {
public:
this(string nombrearg, string apellidosarg) {
nombre = nombrearg;
apellidos = apellidosarg;
}

string nombre;
string apellidos;
}

// Usaremos esto como:
Persona juan = new Persona("Juan", "Alvarez Martinez");
juan.nombre = "Juanjo";
juan.apellidos = "Alvarez Martinez Torres Jimenez";
writefln("Nombre: ", juan.nombre);
writefln("Apellidos: ", juan.apellidos);

Imagino que muchos programadores de Java y C++ estarán tras ver este ejemplo respirando en una bolsa de plástico, sin embargo, aparte de que estas construcciones son muy frecuentes en lenguajes más modernos como Python, si lo pensamos detenidamente no tiene ningún sentido escribir funciones alrededor de variables cuyo valor nunca se va a modificar dentro de dichas funciones. Ninguno en absoluto. Se que alguno estará arañándose la cara y gritando mientras mira al cielo "¡La encapsulación, la encapsulación!" como si la encapsulación fuera el Dios de una religión que hay que seguir por una cuestión de fe, pero es que resulta que no estamos rompiendo ninguna encapsulación porque este (adorado por algunos) término no significa más que encapsular un miembro de la clase y, asignar directamente un valor y devolverlo sin hacerle nada no encapsula nada en absoluto. Y hace que el código sea más lento, por cierto.

Hay sin embargo un argumento bastante bueno castigar al usuario de nuestra clase haciéndole escribir getters y setters infinitos: aunque en la versión actual de la clase no realizemos ninguna operación al obtener o devolver el valor miembro, es posible que en el futuro sí queramos hacerlo. Por ejemplo podemos querer asegurarnos de que la primera letra del nombre y los apellidos sea mayúscula, o que al devolver el nombre añadamos el "Don" o "Doña". En realidad aunque muchos se agarren a este argumento la inmensa mayoría de getters y setters que empiezan vacíos pasa el resto de sus días vacíos. Pero ante la duda, incluso programadores más racionales y menos dogmáticos, usan los getters del demonio para evitar el riesgo de romper la interfaz de la clase en el futuro.

Propiedades al rescate. Las propiedades en D y otros lenguajes son unas construcciones que nos permiten ofrecer al usuario de la clase una interfaz sencilla (juan.nombre = "Juanjo"; writefln(juan.nombre);) pero al mismo tiempo nos permiten tener la posibilidad de realizar operaciones además de la asignación o devolución del valor, si así lo estimamos oportuno. Y lo mejor de todo es que nos permiten no escribir función alguna, usando un miembro público, mientras no se realicen esas operaciones adicionales, y todo ello sin romper nunca la interfaz de nuestra clase. Si repasamos el ejemplo anterior, podríamos pensar que si en la versión 2.0 de Persona queremos que al asignarse Persona.nombre se ponga siempre la primera letra en mayúscula no podemos hacerlo sin meter los gesetters del infierno. Pero usando propiedades sí podemos:

    // En D, al ser un ejemplo más digno, además así no hay que escribir dos ficheros:
import std.string;

class Persona {
public:
this(string nombrearg, string apellidosarg) {
nombre = nombrearg;
apellidos = apellidosarg;
}

string nombre() { return m_nombre; } // "Getter"
void nombre(string nombrearg) {
m_nombre = capitalize(nombrearg);
}

private:

string m_nombre;
string m_apellidos;
}

// Y el usuario lo sigue usando, EXACTAMENTE IGUAL, pero
// nótese como pone la primera letra mayúscula

Persona juan = new Persona("Juan", "Alvarez Martinez");
juan.nombre = "juanjo";
juan.apellidos = "Alvarez Martinez Torres Jimenez";
writefln("Nombre: ", juan.nombre); // Imprime "Juanjo", no "juanjo"
writefln("Apellidos: ", juan.apellidos);

Sinceramente, el que no vea la belleza y elegancia de esto y siga prefiriendo los gesetters al estilo tradicional, mejor que se dedique a una profesión donde no haya que evolucionar tanto como en la programación.

0 entradas a blog:

..:: Acerca de este blog ::..

Este blog fue diseñado por el Lic. Felipe Aguilera - Lic. en Análisis de Sistemas. Cualquier duda o consulta comunicarse con: msfa001@gmail.com