miércoles, 28 de octubre de 2009

Macros vs Funciones

Recapitulando

En el último post dedicado a las macros dije que:

En general queremos proteger los símbolos. Una macro debe ser higiénica y transparente por defecto.

Si queremos definir un símbolo en el entorno de llamada, querremos que un símbolo sea no-higiénico. Usualmente este símbolo deberá ser pasado por argumento para controlar qué se ensucia en el entorno de llamada.

Si queremos usar un símbolo del entorno de llamada, querremos que un símbolo sea opaco. Usualmente este símbolo no deberá ser pasado por argumento porque es posible que ni siquiera exista (caso Windows/Linux).

Esto es exactamente lo mismo que hacemos con funciones. La salvedad es que en las funciones trabajamos con valores (en tiempo de ejecución) mientras que en macros trabajamos con símbolos (en tiempo de compilación). Obviamente, no existe esta distinción en lenguajes homoicónicos como LISP donde la compilación y la ejecución van a la par.

Traducción

Esto significa que existen los mismos conceptos de higiene y transparencia referencial en las funciones y, además, existen unos motivos muy parecidos para tener excepciones a la regla.

Por ejemplo:

En general queremos proteger los símbolos. Una macro debe ser higiénica y transparente por defecto.

En general queremos proteger los valores. Una función debe ser higiénica y transparente por defecto.

Realmente esto es cierto, aunque es muy fácil perder la transparencia en lenguajes como el C/C++. Lo veremos más abajo.

Si queremos definir un símbolo en el entorno de llamada, querremos que un símbolo sea no-higiénico. Usualmente este símbolo deberá ser pasado por argumento para controlar qué se ensucia en el entorno de llamada.

Si queremos modificar un valor en el entorno de llamada, querremos que un valor sea no-higiénico. Usualmente este valor deberá ser pasado por referencia para controlar qué se modifica en el entorno de llamada.

Así pues, opino que existe una analogía entre la higiene y el paso por valor. La no-higiene y el paso por referencia.

Si queremos usar un símbolo del entorno de llamada, querremos que un símbolo sea opaco. Usualmente este símbolo no deberá ser pasado por argumento porque es posible que ni siquiera exista (caso Windows/Linux).

Si queremos usar un valor del entorno de llamada, querremos que ese valor sea accesible. Usualmente este valor no deberá ser pasado por argumento porque es posible que ya podamos acceder a él.

El ejemplo claro es una variable global. Al ser global está en el entorno de llamada. Además, ya podemos acceder a ella por lo que no necesitamos pasarla por argumento. De hecho, es esto lo que hace que la función no sea transparente. Depende de algo que no pasamos por argumentos. Esto es muy común en C/C++ donde hay variables globales, estáticas y argumentos implícitos.

domingo, 25 de octubre de 2009

Transparencia en tu macro

Flashback

Vamos a volver atrás en el tiempo a la entrada de Higiene en tu macro. En un punto del texto decía...

Como ejemplo, una macro m definida así:

macro m :=
{
y=3; //Defino la y dentro del código.
f(z);
}


Y usada en un código así:

z=7;
m;
print(y); //Debería dar error, no he definido ninguna y en este código.

Da este resultado:

z=7;
y=3; //Contamino el entorno de fuera desde la macro.
f(z); //Uso la z de fuera en la macro ¿es esto deseable?.
print(y); //Captura la y de la macro en vez de estar sin definir.

Es completamente antihigiénico porque introducimos el símbolo y en el entorno de uso.

En aquella entrada nos dedicamos a la higiene. Es decir, que los símbolos de dentro de la macro no se escapasen fuera. Ahora vamos a pensar en la transparencia referencial: que los símbolos de fuera no me afecten dentro.

Transparencia referencial

Realmente la transparencia referencial es más que los símbolos de fuera no afecten dentro. Significa que nada de lo de afuera va a afectar la macro (igual con funciónes). Por tanto, la macro (o función) sólo podrá depender de los argumentos que se le pasen. Exactamente como una función matemática.

Para saber si una macro (o función) f() es transparente o no, hacemos el siguiente experimento mental:

  • Llamo a f() con un valor concreto. Por ejemplo, f(3). Obtengo un resultado R1.
  • Supongo que se ejecuta código arbitrario entre medias.
  • Llamo a f() con el mismo valor concreto. Por ejemplo, f(3). Obtengo ahora un resultado R2.
  • ¿Existe la posibilidad de que el resultado R1 sea distinto al resultado R2?

Si la respuesta es "sí", no es transparente.

En general, queremos transparencia porque nos permite razonar con mayor facilidad sobre lo que hace una macro (o función) El sistema de entornos expuesto en el post de la higiene es automáticamente transparente (gracias al entorno padre=entorno de definición). Ahora bien, ¿es esto deseable?

Deseable opacidad

Si no tenemos transparencia de ningún tipo, cualquier operación que hagamos dentro de una macro (o función) tendrá un significado desconocido. Supongamos

macro m := { y=x*x }

Parece que es hacer y el cuadrado de x. Ahora bien, podría darse el caso de que el uso de m apareciera en el siguiente entorno.

defino * como +

m //Uso la macro

¡Ahora y no es el cuadrado es el doble! Así pues, la opacidad referencial implica usar símbolos cuyo valor o definición puede realizarse luego. Esto es muy peligroso, pero puede ser muy útil.

Imaginemos el siguiente ejemplo donde defino la macro m con dos parámetros.

macro delay(os, ms) :=

{

 static_if(os==windows) Sleep(ms)

 else static_if(os==linux) usleep(ms*1000)

 else error;

}

Para empezar es una macro higiénica (ni el os ni el ms que defino en el entorno local podrán usarse fuera de la macro). Como es macro, se ejecuta en tiempo de compilación. Esto se resalta con el uso de static_if en vez de if a secas. El static_if no genera código, simplemente es como si no se escribiera el código en la rama que no se toma. En ese sentido es como el #if del preprocesador de C/C++.

Para continuar, supongamos transparencia referencial para la macro. Eso quiere decir que todos los símbolos han de tomarse del entorno de definición ¡y eso es un problema! Porque si estamos en Windows, usleep no estará definido y será un error y, si estamos en Linux, Sleep no se encontrará y será un error.

Es decir, que esta macro necesita ser opaca. Pero sólo con con los símbolos Sleep y usleep. No con el símbolo static_if ni con el * que queremos que estén vinculados con los valores del entorno de definición.

Resumiendo

En general queremos proteger los símbolos. Una macro debe ser higiénica y transparente por defecto.

Si queremos definir un símbolo en el entorno de llamada, querremos que un símbolo sea no-higiénico. Usualmente este símbolo deberá ser pasado por argumento para controlar qué se ensucia en el entorno de llamada.

Si queremos usar un símbolo del entorno de llamada, querremos que un símbolo sea opaco. Usualmente este símbolo no deberá ser pasado por argumento porque es posible que ni siquiera exista (caso Windows/Linux).

miércoles, 21 de octubre de 2009

Lo que nunca me enseñaron: las PEGs

Introducción

Una de las asignaturas que más me gustó de la carrera de informática fue la de procesado de lenguajes. Se dividía más o menos en dos partes. La primera se centraba en los distintos algoritmos que hay de reconocimiento de lenguajes y la segunda en la semántica de ese lenguaje.

La primera parte era muy formal. Muchas matemáticas. La clasificación de los lenguajes de Chomsky. Los algoritmos para reconocer los lenguajes de contexto libre deterministas: expresiones regulares, LL(1), LL(k), LR(1), SLR, LALR, etc. La mayoría de estos algoritmos son complejos. Hay que calcular conjuntos, hacer grafos, transformarlos y aplicar métodos más o menos arduos.

Sin embargo, con el tiempo, he descubierto que no me enseñaron nada sobre las PEGs. Quizás únicamente una breve mención a los analizadores descendentes recursivos. Las PEGs son "gramáticas de expresiones reconocedoras" (Parsing Expression Grammars). La idea es muy simple: Probamos una cosa y, si no podemos, probamos con la siguiente.

La gran diferencia

Para reconocer una cadena como "a" en una entrada como "ac" se compara carácter a carácter. El resultado será que hay una parte reconocida "a" y sobra la "c". Una forma de asegurarnos que no sobran caracteres es añadiendo un indicador de fin de entrada. En este caso la cadena a reconocer sería "a#" y la entrada sería "ac#". Obviamente, no reconocería nada. Este truco permite centrarnos en reconocer sólo lo que se pueda reconocer y dejar sobrantes si fuera necesario.

Generalmente queremos reconocer varias cadenas. Aquí es donde las PEGs son distintas. En una gramática de contexto libre (GCL) las opciones no se ordenan. Las distintas opciones se separan con | en las GCL y se lee más o menos como "o". Es decir, "a" | "ab" sería reconoce "a" o reconoce "ab". Si introducimos la cadena "abc" reconocerá "ab" y sobra "c".

Con las PEGs no es así. Hay que reconocer ordenadamente. Las distintas opciones se separan con / en las PEG y se lee más o menos como "y si no, prueba con". Es decir "a" / "ab" sería prueba "a" y si no, prueba con "ab". Si introducimos la cadena "abc" probará primero con "a" y la reconocerá, descartando probar con "ab". Es decir que reconoce "a" y sobra "bc".

Las consecuencias de este cambio son dobles:

  • Dependiendo del orden obtenemos gramáticas distintas. Es decir, "a"/"ab" no es lo mismo que "ab"/"a".
  • Nunca hay ambigüegades. Esto es debido a que, si hay dos posibilidades, una estará delante de la otra.

Operadores

La restricción del orden puede parecer muy inconveniente, pero realmente no perdemos mucha expresividad respecto a una CFG. Por ejemplo, todos los operadores unarios de las expresiones regulares pueden expresarse con PEGs mediante estas transformaciones.

R = A*  ===> R = A R / ε

R = A+  ===> R = A R / A

R = A?  ===> R = A / ε

Donde ε es la cadena vacía.

Además, tenemos dos operadores más que surgen de la forma en las que las PEGs se implementan.

Cuando tenemos dos opciones la implementación de la PEG tiene que probar la primera. Si se reconoce, no hay problema. Se consume e ignoramos las otras opciones. Así que para reconocer "a" / "b" en "ac" sería:

"a" / "b"  en "ac"

Opción 1:

"a" en "ac". Coinciden las "a". Avanzo.

"" en "c". Nada más que comprobar. Éxito.

Si no se reconoce, hay que volver atrás (backtracking).

"ab" / "ac" en "acd"

Opción 1:

"ab" en "acd". Coinciden las "a". Avanzo.

"b" en "cd". No coinciden. Fallo.

Vuelvo atrás a "acd" y pruebo opción 2:

"ac" en "acd". Coinciden las "a". Avanzo.

"c" en "cd". Coinciden las "c". Avanzo.

"" en "d". Nada más que comprobar. Éxito.

Esta vuelta atrás tiene que usarse si utilizamos las opciones ordenadas /. Ya que la tenemos, podemos utilizarla en otros casos. Por ejemplo. Supongamos que queremos reconocer todas las palabras (no vacías) formadas por "a" y "b" excepto las que empiecen por "baba". Las palabras formadas por "a" y "b" son ("a" / "b")+. ¿Cómo le quitamos ahora "baba"?

La idea es simple. Intentemos reconocer "baba" y, si la encontramos, fallamos. Si no la encontramos, volvemos atrás y seguimos como si nada. Esta operación la notaremos con el operador prefijo ! La gramática sería:

!"baba" ("a" / "b")+

Si introducimos "babab" reconoceremos "baba" y por tanto, fallamos. No reconocemos la gramática. Si introducimos "babb" no reconocemos "baba" aunque habremos consumido "bab". No pasa nada, volvemos atrás a "babb" y seguimos como si nada.

La misma operación se podría realizar fallando si no reconocemos. En este caso se notaría "&". Por ejemplo, reconocer &"bxx" "b" en "bxxba" tiene éxito, pero sobra "xxba". Es una manera de requerir cierto contexto (las "xx" que siguen a la "b") antes de aceptar (sólo la "b").

Implementación

Existe una última característica de las PEG que las hace infinitamente mejores que las CFG: se pueden programar a mano. Nada de Lex/Yacc. Esto se debe a que las PEG son, ni más ni menos, que los reconocedores descendente recursivos.

Por ejemplo. En C++ una regla R = A B sería algo como:

bool R(POSICION& p)

{

return A(p) && B(p);

}

La regla R = A / B es:

bool R(POSICION& p)

{

POSICION q=p; //Prepara el backtracking

return A(p) || B(p=q);

}

Una regla R=A* es

bool R(POSICION& p)

{

POSICION q=p; //Prepara el backtracking

while(A(q)) p=q;

return true;

}

La regla R = &A B es

bool R(POSICION& p)

{

POSICION q=p; //Prepara el backtracking

return A(q) && B(p);

}

Como se ve, las reglas se programan más o menos directamente con un par de variables bien escogidas. En la realidad es algo más complejo porque queremos informar de errores, construir el AST, etc. Pero también es verdad que en la realidad podemos definir clases y abstraernos de más operaciones. El resultado es que la implementación suele ser directa.

El ataque de las ratas

Al leer la palabra "backtracking" deben haberse encendido todas las luces rojas de la complejidad. El backtracking (o vuelta atrás) es de complejidad exponencial. Esto quiere decir que si introduzco una cadena con un carácter más de longitud el resultado puede tardar el doble en calcularse. Esto es inmanejable en la práctica y el efecto es "se ha quedado colgado".

No hay que asustarse. En la realidad las PEGs se pueden calcular en tiempo lineal. Sí, sí. Lineal, O(n) para los amigos de la notación O de Landau. ¿Y cómo es posible esto? Mediante una técnica que se denomina memoización.

La memoización no es más que guardar en una tabla los resultados que ya conocemos de manera que, antes de calcularlos, busco en la tabla. ¿Que ya están? Pues tengo ya lo que buscaba. ¿Que no están? Pues los calculo y los guardo en la tabla para la próxima. El resultado es que sólo tengo que calcular cada regla en cada posición una única vez. Esto es lo que en diseño de algoritmos se denomina programación dinámica.

Como el número de reglas es fijo y las tablas hash son de complejidad amortizada constante el resultado es que la complejidad sólo depende linealmente de la longitud de la entrada. Es decir, O(n). Bien es verdad que también el consumo de espacio va a ser O(n), pero hoy en día la memoria disponible en los PCs normales permite usar las PEGs sobre ficheros de millones de caracteres.

¿Y lo de las ratas? Bueno, a este tipo de reconocedores descendentes recursivos con memoización se los denomina "packrat" que, aunque mis conocimientos de inglés me dicen lo contrario, me suena a "paquete de ratas".


Edit: Ya lo he descubierto. Podemos traducir "packrat" como "atesorador" ya que coincide la idea de memoizar con la definición de "packrat" que he encontrado en Babylon.

domingo, 18 de octubre de 2009

Argumentos y parámetros

Generalmente se usan las palabras "argumento" y "parámetro" de forma intercambiable cuando definimos o llamamos a una función. Para distinguir si es en definición o en llamada usamos los adjetivos "formal" o "actual" (mal traducido del inglés "actual"). Entonces:

int f(int a, int b) //Parámetros o argumentos formales

f(3, 4) //Parámetros o argumentos actuales

Bien. En C++ tienen una notación distinta. Para ellos los "parámetros" son los de la definición y los "argumentos" son los de la llamada. La verdad es que es una tontería usar dos palabras cuando se puede usar una, pero ya es un poco enfermizo llegar a tal grado de eficiencia para un programador.


miércoles, 14 de octubre de 2009

Covariancia y contravariancia (V)

Funciones virtuales

En el último post de esta serie dije que la siguiente operación no se podía realizar.

pes.crea_mueble(150) // Mal, no sabemos las patas que tiene la silla

Esto puede soslayarse si el programador asume la responsabilidad de actualizar correctamente el estado de la silla entera cuando se modifica únicamente la parte mueble de la misma. Por ejemplo, en el caso anterior el programador puede usar una versión especial de crea_mueble() para las sillas. En esta versión especial puede optar por, por ejemplo, usar cuatro patas por defecto.

Ahora bien, ¿cómo sabe que, aunque sea un puntero a mueble, realmente es una silla? Se usa lo que se llaman funciones virtuales. Una función virtual es aquella cuyo despacho se realiza en función del tipo de objeto apuntado y no en función del tipo de puntero. De esta forma, aunque tengamos un puntero a mueble, si actuamos sobre una silla, se usará la función especial de la silla.

Con esta técnica, y bajo responsabilidad del programador, los punteros de lectura/escritura terminan siendo como los punteros de lectura: covariantes.

Paso por valor

En otro post dije que la herencia no era lo mismo que el subtipado y di un motivo en concreto: el tamaño que ocupan los objetos en memoria. Si usamos punteros (o referencias) a objetos podemos usar la técnica de arriba, pero si nuestro lenguaje es de paso por valor, tenemos un problema.

Supongamos que una función tiene el tipo f: Unit -> A. Esta función no toma ningún argumento (el tipo unit es algo así como el tipo void en C/C++ en este caso) y devuelve un objeto de tipo A. Claro está que podría devolver un subtipo B de A.

Por lo mencionado antes, los lenguajes orientados a objetos no usan realmente la relación de subtipo. Usan la relación de herencia. Eso significa que B puede ser de mayor tamaño que A y aquí surge el problema: ¿cómo sabe la función llamante si f va a devolver un A o algún otro tipo heredado como B? Porque si va a devolver un B va a tener que reservar más memoria que para un A. En caso contrario tendríamos un desbordamiento de buffer (buffer overflow).

Una solución sería pensar que la función tiene que devolver exactamente un A. Esto la haría invariante y que no podríamos cambiar cualquier aparición de f: Unit -> A por otra función g: Unit -> B.

Por otra parte, sabemos que con los punteros (y gracias a las funciones virtuales) no tenemos ese problema. Es decir, que sí podemos cambiar cualquier aparición de f2: Unit -> PA por otra función g2: Unit -> PB.

¡Pero es que es lógico! Pasamos los punteros por valor y el objeto apuntado por referencia. Los punteros son todos del mismo tamaño por lo que no hay problemas de desbordamiento de buffer. Esto es justo lo que hace el C++: permitir la covariancia de funciones sólo para los punteros (y referencias).

Marcando la variancia en los constructores de tipo

La solución de C++ está bastante encorsetada. Sólo permite covariancia para unos tipos predefindos. ¿No podríamos mejorar esto?

Si el programador supiese que, cuando usa un constructor de tipos, el resultado es otro tipo que

  1. Va a tener estados consistentes y no va a tener slicing (funciones virtuales)
  2. No va a provocar desbordamiento de buffer (mismo tamaño si es paso por valor)
  3. No va a tener problemas semánticos (el programador es responsable y sabe lo que se hace).

Entonces podría asegurar que sus constructores de tipos van a ser covariantes o contravariantes y podría anotarlo en el código. Por ejemplo, un contenedor de sólo lectura es covariante.

Esto es lo que hace el lenguaje de programación Scala y las nuevas versiones del C#. Un ejemplo obtenido de aquí es:

//+A significa que es covariante en A

class Stack[+A] { 

//B >: A significa que A es subtipo de B (A<:B)

def push[B >: A](elem: B): Stack[B] = new Stack[B] {
override def top: B = elem
override def pop: Stack[B] = Stack.this
override def toString() = elem.toString() + " " +
Stack.this.toString()
}

def top: A = error("no element on stack")

def pop: Stack[A] = error("no element on stack")

override def toString() = ""
}

object VariancesTest extends Application {

var s: Stack[Any] = new Stack().push("hello");
s = s.push(new Object())
s = s.push(7)
Console.println(s)

}

Comentarios finales

Con esto doy por acabada la serie de variancias. Seguramente surgirá algún tema más que comentar en el futuro, por lo que no tendré ninguna pereza en reabrirla para nuevos posts. 

sábado, 10 de octubre de 2009

Las cópulas japonesas

Hoy voy a hacer otro pequeño (o gran) cambio de tema para hablar un poco sobre el idioma japonés. Lo estoy estudiando en mis ratos libres y la verdad es que, por ahora, me está gustando.

Unos de mis mayores calentamientos de cabeza ha sido la cópula. La cópula es similar al verbo "ser" cuando se usa en frases atributivas del tipo "yo soy grande" o "el disco es amarillo".

La cópula japonesa tiene una conjugación (o mejor dicho, declinación porque no se considera verbo) bastante irregular. Se puede ver en las páginas de Tae Kim, en Matsu Kaze o en esta magnífica página de Collin McCulley.

En el siguiente diagrama resumo todo lo que (creo) haber descubierto.



Ahora queda explicarlo: Analizando cómo se obtienen las distintas declinaciones llego a las siguientes conclusiones (que pueden ser incorrectas pero tienen toda la pinta de ser buenas).

  1. La cópula es realmente la partícula で (de) seguida del verbo ある (aru=ser). Es decir, que realmente es el verbo ser aunque no lo parezca debido a los metaplasmos que han ocurrido hasta el japonés moderno. Veamos esas transformaciones.
  2. Se realizan contracciones y abreviaturas de modo que である (de aru) termina siendo だ (da), じゃ(ja) o incluso や(ya). Hay un interesante mapa donde vemos en qué zonas se usa una u otra abreviatura en la Wikipedia. He puesto en verde discontinuo las transformaciones que señalan estas abreviaturas y contracciones. Nota: Se puede usar である (de aru) pero suena muy académico, frío y oficial. Estas variantes menos usadas están en gris en el diagrama. 
  3. La educación (en el sentido de ser respetuoso) es muy importante en el japonés y hay varias maneras de conseguirla. En la cópula tenemos dos: usar la forma ます(masu, es una forma del verbo que lo hace más educado) o usar un verbo más educado como ござる (gozaru=ser, en versión mucho más educada). En el primer caso tenemos であります(de arimasu) o bien でございます(de gozaimasu) según el verbo que usemos. No se usa el verbo ござる (gozaru) sin la terminación ます(masu). En el diagrama, he puesto el fondo en verde cada vez más oscuro según aumenta el grado de educación. He puesto en azul las transformaciones que aumentan el grado de educación.
  4. Las contracciones y abreviaturas hacen de であります(de arimasu) la cópula educada usual です(desu).

Siguiendo con el análisis nos encontramos con la explicación a la extraña tabla de la negación donde aparecen siempre, al menos, dos variantes. Las razones que imagino son las siguientes.

  1. En vez de la partícula で(de) se usa では(dewa) en la negación (ver esta página para saber el porqué). En el diagrama he puesto en rojo las transformaciones que introducen negación y he enmarcado en rojo las formas negadas de la cópula. Esta partícula se abrevia coloquialmente a じゃ(ja) y coincide con una de las abreviaturas que teníamos (para liar algo la cosa). Así, para negar である (de aru), con la negación del verbo ある(aru) que es ない(nai) y usando la partícula では(dewa) obtengo ではない(dewa nai) o じゃない(janai) coloquialmente. Justo lo que esperábamos viendo las páginas web de arriba. Nota: Se puede usar ではない(dewa nai) pero de nuevo suena oficial, frío y académico.
  2. En las formas educadas tenemos una dualidad extra. Al tener el verbo (aru o gozaru) y la terminación ます(masu) podemos o bien negar uno o bien negar el otro. Si dejamos el verbo tal cual y usamos la negación de ます(masu) que es ません(masen) obtenemos ではありません(dewa arimasen) o su versión con ja じゃありません(ja arimasen). Con el verbo supereducado ござる (gozaru) obtenemos ではございません(dewa gozaimasen) y じゃございません(ja gozaimasen).
  3. Si negamos primero el verbo obtenemos (ver el punto 1) ではない(dewa nai) o じゃない(janai) pero terminan en い(i) por lo que no son verbos. Sin embargo, el verbo negado  ない (nai) puede volverse a declinar dado que al acabar en い(i) es como si fuera un adjetivo. Una forma de declinar los adjetivos es ¡añadiendo una cópula para hacerlos más educados! Así que usamos la recursividad y añadimos la cópula educada です(desu) a la negación no educada じゃない(ja nai) para obtener el último caso que nos muestra la página:  じゃないです(ja nai desu).

Esto de la recursividad en las cópulas japonesas es curioso porque hay múltiples combinaciones posibles para decir y conjugar lo mismo. De hecho, lo único que hace que se usen ciertas combinaciones y no otras es la frecuencia de uso. Por esa razón Collin McCulley añade unos números que parecen ser los resultados encontrados en Google hace algunos años. Buscando y rebuscando he encontrado un foro en la página de Tae Kim donde han puesto muchas de las combinaciones posibles (se usen realmente o no).