Scala: Un lenguaje para gobernarlos a todos (II)

A ninguna aerolínea se le ocurriría poner a los mandos de un Airbus 380 a alguien sin entrenamiento.
Algo parecido ocurre con la creación de anillos de poder, ¡ups! DSLs, en Scala. Es una actividad que requiere entrenamiento, aunque por suerte no tan intensivo. Ha de ser tanto teórico como práctico pero, sobre todo, práctico.  En esta serie de posts ya se ha tratado algo de teoría y ahora toca la parte divertida del aprendizaje ¡Es hora de construir un DSL desde cero!

El objetivo de nuestro DSL

El fin último de nuestro futuro DSL es el de servir de ejercicio didáctico. Sin embargo, todo DSL ha de ser de ayuda en un dominio concreto, bien (o):

  • Facilitando la gestión o control de un sistema.
  • Sirviendo de lenguaje intermedio para otros lenguajes que son demasiado complejos o extensos.

En el ejemplo que vamos a desarrollar abordaremos el segundo caso.

Os presento a AWK

¡Menuda broma! Quién no conoce AWK ¡Si hasta tu Linux Box sabe qué es!

man awk | head -n 6

GAWK(1) Utility Commands GAWK(1)

NAME
gawk – pattern scanning and processing language

OK, es necesario recurrir a Wikipedia para encontrar una explicación algo menos tácita:

AWK es un lenguaje de programación diseñado para procesar datos basados en texto, ya sean ficheros o flujos de datos…


AWK fue una de las primeras herramientas en aparecer en Unix (en la versión 3) y ganó popularidad como una manera de añadir funcionalidad a las tuberías de Unix. La implementación de alguna versión del lenguaje AWK es estándar en casi todo sistema operativo tipo unix moderno.

Recordemos a este tandem de superheroes:

Gnu-and-penguin-color

Aunque no puede apreciarse en el dibujo, GNU tiene un cinturón en el que oculta un arma muy poderosa: AWK.
Su poder más destacado es el de permitir la creación de scripts que pueden transformar, filtrar, agregar… los datos originados por las salidas de otros mandatos. Convirtiéndose, de esta manera, en una herramienta fundamental para la construcción de scripts de sistema en los que se apoyan nuestras distribuciones y aplicaciones favoritas.

Ejemplo:

awk_sample

Con esta, aparentemente simple, línea hemos escrito un script capaz de indicarnos la memoria total utilizada por los módulos cargados en el sistema. Procesa la salida del comando de listado de módulos y agrega los valores de tamaño en memoria de cada entrada.

¿Un hueso duro de roer?

Siendo un lenguaje que aporta una máquina de Turing completa, las aplicaciones de AWK son infinitas. Es capaz de ahorrar horas de trabajo, facilitando la automatización de muchas tareas del sistema pero todo tiene un precio, y en el caso de AWK, el precio es que asusta a los usuarios que acuden por primera vez a él. Para muchos de ellos, se parece más a …

6bkFb7B

… que a una herramienta para facilitarles la vida. Las 1475 líneas de su entrada en Man tampoco suponen una lectura ligera.

Si en esa cabina ponemos a un piloto al que pedirle que nos guíe ¿A que perdemos un poco el miedo?

Los DSLs son pequeños y concisos, esto implica que son de gran ayuda ya que guían a sus usuarios a través del proceso de describir acciones

Si, parece que un DSL interno en Scala podría ser de gran utilidad como guía en la utilización de AWK.

Manos a la obra en la construcción del DSL de Scalawk

La característica más importante de un lenguaje de programación es el nombre…
Donald Erving Knuth

Empecemos por lo más importante, y si tomamos el principio de autoridad como un argumento válido, deberíamos empezar dándole nombre al DSL que vamos a construir.

Queremos componer programas AWK usando un DSL interno en Scala:

Scala + AWK = Scalawk

¡Buen comienzo!

Todo el código de Scalawk está disponible en GitHub:

git clone https://github.com/pfcoperez/scalawk.git

Divide y vencerás

En la entrega anterior de esta serie de posts llegamos a la conclusión de que la mejor forma de diseñar un DSL es usando el modelo de máquinas de estado. Estas máquinas son fácilmente divisibles en componentes:

  • Estados (siendo el estado inicial un caso especial).
  • Transiciones (cambios de estado):
    • Entradas que provocan la transición.
    • Efectos derivados de la transición:
      • La máquina cambia su estado.
      • Laterales: Al margen del cambio de estado, es posible que la transición provoque la generación de salida.
  • Alfabeto de la máquina: Conjunto de entradas válidas.

Diseñar y dibujar el diagrama de estados de la máquina, lo cual no es diferente a diseñar la guía interactiva que queremos construir, constituye el 90% del proceso creativo en la creación del DSL. La mayor parte del trabajo restante no es más que elegante y bonita fontanería Scala.

machine

Todas las posibles interacciones del usuario con Scalawk están representadas en el diagrama de estados anterior. Por ejemplo:

lines splitBy arePresentedAs ('c1, 'c2)

machine_walkthrough

Esta descomposición y modelado de la máquina de estados nos regala la estructura de paquetes de nuestro proyecto:

packages

Al grano: O como perder el miedo y disfrutar de la materia prima que nos da Scala

Los estados, transiciones y elementos auxiliares son entidades definidas dentro de los paquetes descritos más arriba. De hecho, no son más que objetos, clases, métodos y traits Scala.

Estado inicial

initial_state

Como ya sabemos, los estados son objetos. O bien son instancias de clases u objetos singleton. Por otra parte, también hemos vista que la forma correcta de implementar máquinas de estado es hacer que estos estados, en definitiva objetos, sean inmutables. De esa manera, son las transiciones las responsables de generar nuevos estados totalmente nuevos.

El estado inicial, al diferencia del resto de estados, no se genera por medio de una transición. Es el estado actual al iniciar la interacción con el DSL. Esto indica su naturaleza como singleton, la cual queda confirmada definitivamente por el hecho de que no puede existir ninguna otra instancia de él.

object lines extends ToCommandWithSeparator

Desde el punto de vista del usuario de Scalawk, este estado inicial debería ser una palabra indicando el comienzo de una frase en el DSL. Esta es otra razón para justificar el que sea implementado como un objeto singleton.

El estado inicial necesita transitar a otro, es por ello por lo que el singleton lines extiende el trait ToCommandWithSeparator. Para no adelantar acontecimientos basta que, por el momento, tengamos en cuenta que ToCommandWithSeparator es un trait que contiene un conjunto de transiciones.

Estados transitorios y finales

¡Claro! Los estados son objetos pero… ¿Eso es todo? ¡No!
Hay diferentes tipos de estados. Además, muchos de estos estados son bastante parecidos entre sí y podrían construirse a partir de una plantilla común. Revisemos algunos trucos y consejos para implementarlos.

La clasificación de más alto nivel para los estados no iniciales debería ser la siguiente: Transitorios finales.
Conceptualmente, los estados del primer grupo no pueden usarse para generar resultados en tanto que los del segundo sí. Obviamente, esta limitación ocurre igualmente en la implementación de los estados y ello implica que los estados transitorios no pueden generar programas AWK mientras que los finales si.

with_initialprogram_st
Estado no final
solidcomand_st
Estado final

En Scalawk, cualquier entidad capaz de generar código AWK, sin importar de que se trate de un programa completo o no, debería extender o mezclarse con el trait AwkElement.

trait AwkElement {
  def toAwk: String
}

De este modo, añadimos el método toAwk a dicha entidad. Esto es, le estamos otorgando del punto de entrada por el que la entidad es capaz de proveer código AWK.

A pesar de sus diferencias, prácticamente todos los estados comparten un conjunto común de atributos a partir del cual es posible componer cadenas de texto que contienen mandatos AWK:

  • Opciones de línea de comando, e.j: Token separador (espacio, salto de linea, …)
  • Programa inicial: Instrucciones a ser ejecutadas por AWK antes de empezar a procesar la entrada línea a línea. e.g: Inicializar un contador.
  • Programa de línea: Instrucciones que AWK ejecutará para cada una de las líneas de texto de entrada, las mismas instrucciones para todas las líneas. e.j: Imprimir una línea, incrementar un contador o acumulador, etc.
  • Programa final: Programa a ejecutar una vez que toda la entrada ha sido procesada, esto es, después de que el programa de línea se haya ejecutado para todas y cada una de las líneas de entrada. e.j: Imprimir el valor de los contadores.

En cada estado estos atributos pueden estar vacíos o no y, cuando se le pide a un estado final que genere un programa AWK, dichos atributos se utilizarán para generar el resultado en formato de cadena de texto.

abstract class AwkCommand protected(
  private[scalawk] val commandOptions: Seq[String] = Seq.empty,
  private[scalawk] val linePresentation: Seq[AwkExpression] = Seq.empty,
  private[scalawk] val lineProgram: Seq[SideEffectStatement] = Seq.empty,
  private[scalawk] val initialProgram: Seq[SideEffectStatement] = Seq.empty,
  private[scalawk] val endProgram: Seq[SideEffectStatement] = Seq.empty
) {
  def this(prev: AwkCommand) = this(
    prev.commandOptions,
    prev.linePresentation,
    prev.lineProgram,
    prev.initialProgram,
    prev.endProgram
  )
}

Hasta el momento sabemos de los estados no iniciales que:

  • Siempre contienen las piezas constituyentes de un resultado e inicialmente adquieren los valores de estas piezas a partir del estado que les precede: Por tanto, han de extender la clase abstracta AwkCommand.
  • Muy probablemente, añadan o cambien alguna de estas piezas de información: En ese caso, han de sobreescribir todos o algunos de los atributos de  AwkCommand.
  • De forma opcional, pueden transitar a otro estado: En cuyo caso, han de ofrecer al menos un método cuyo valor de retorno sea del tipo del estado destino. También podrían mezclar sus clases con algún trait de familia o conjunto de transiciones.

Surge la pregunta ¿Por AwkCommand es una clase abstracta y no un trait?
Bien, el objetivo de AwkCommand es proveer código reutilizable para garantizar la continuidad. Es decir, proveer un constructor para construir nuevos estados a partir del que les precede (parámetro prev). Gracias a este constructor, el código de cada clase de estado queda reducido a tan sólo la definición de sus transiciones y sobreescrituras de los atributos heredados de AwkCommand (únicamente cuando estos atributos han de cambiar al transitar al nuevo estado).

Obviamente, la única forma de proveer un constructor en una jerarquía de clases es por medio de una clase, no hay ningún problema si esta clase no debe ser instanciada: Basta con hacerla abstracta.

abstract_with_constructor

class CommandWithLineProgram(
                              statements: Seq[SideEffectStatement]
                            )(prev: AwkCommand) extends AwkCommand(prev)
  with ToSolidCommand {

  override private[scalawk]val lineProgram: Seq[SideEffectStatement] = statements

}

La clase CommandWithLineProgram se corresponde con un estado no final y por ello no mezcla el trait AwkElement.

//This is the first state which can be used to obtain an AWK command string `toAwk`
class SolidCommand(val lineResult: Seq[AwkExpression], prevSt: AwkCommand) extends AwkCommand(prevSt)
  with AwkElement
  with ToCommandWithLastAction {
 ...
 ...
 ...
}

En cambio, SolidCommand representa un estado final y ha de proporcionar una implementación del método toAwk:

 // AWK Program sections

// BEGIN
protected def beginBlock: String = programToBlock(initialProgram)

// Per-line
protected def eachLineActionBlock: String =
programToBlock(lineProgram ++ linePresentation.headOption.map(_ => Print(linePresentation)))


// END
protected def endBlock: String = programToBlock(endProgram)

protected def programToBlock(program: Seq[SideEffectStatement]) =
{program.map(_.toAwk) mkString "; "} +
program.headOption.map(_ => "; ").getOrElse("")

protected def optionsBlock: String =
{commandOptions mkString " "} + commandOptions.headOption.map(_ => " ").getOrElse("")

override def toAwk: String =
s"""|awk ${optionsBlock}'
|${identifyBlock("BEGIN", beginBlock)}
|${identifyBlock("", eachLineActionBlock)}
|${identifyBlock("END", endBlock)}'""".stripMargin.replace("\n", "")

//Auxialiary methods
private[this] def identifyBlock(blockName: String, blockAwkCode: String): String =
blockAwkCode.headOption.map(_ => s"$blockName{$blockAwkCode}").getOrElse("")

La jerarquía de clases presentada posibilita la reutilización. Por ejemplo, SolidCommandWithLastAction es prácticamente una copia de SolidCommand y nada nos impide que extendamos esta segunda clase a la hora de definir SolidCommandWithLastAction.

class SolidCommandWithLastAction(lastAction: Seq[SideEffectStatement])(prevSt: SolidCommand)
extends SolidCommand(prevSt)

En este punto, deberíamos ser capaces de empezar a explorar el repositorio y asociar cada estado del grafo con su clase correspondiente en el código.

Nodo en el grafo

Entidad

¿Es estado final?

Tipo de entidad

init

lines

No

Objeto singleton

command

CommandWithSeparator

No

Clase

with line program

CommandWithLineProgram

No

Clase

with initial program

CommandWithInitialProgram

No

Clase

solid command

SolidCommand

Clase

with last action

SolidCommandWithLastAction

Clase

Transiciones

Las transiciones entre estados son bastante más sencillas, son simples métodos que devuelven nuevos estados. Gracias a la notación infija, Scala proporciona la ilusión de estar manejando expresiones en lenguaje natural, tan natural como describir en nuestro idioma humano lo que queremos.

Algunos estados pueden compartir transiciones por lo que una buena idea es agruparlos en traits. Por medio de mixings, los estados pueden usar estos traits de transición como si de piezas de LEGO se tratase para construir sus conjuntos de transiciones.

Hay dos casos especiales que requieren especial atención: Grupos de transiciones transiciones de entrada vacía.

Los grupos de transiciones…

… están compuestos por transiciones que siempre se agrupan de la misma forma, digamos que son transiciones amigas que jamás se separan, a veces estas transiciones son diferentes versiones de la misma transición y el trait en el que se agrupan suele tomar nombres que encajan en el patrón To<TargetState>.

trait ToCommandWithSeparator {

  def splitBy(separator: String) = new CommandWithSeparator(separator)
  def splitBy(separator: Regex) =  new CommandWithSeparator(separator)

}

Este es un caso bastanete evidente de dos versiones de la misma transición: Una en la que recibe una cadena de texto y otra en la que recibe una expresión regular.

Nótese que, en relación con la máquina abstracta de estados, la entrada es la conjunción del nombre del método de transición y sus parámetros.

Transiciones de entrada vacía

Consideremos la siguiente transición (extraída de la máquina de estados):

empty_transition

Las máquinas de estado pueden transitar con entradas vacías. Esto puede parecer extraño pero es bastante común, tanto que ocurre con Scalawk. Además, no hay problema a la hora de plasmar esto en el modelo que estamos implementando con Scala gracias a las conversiones implícitas.

Una conversión implícita desde un estado (Fuente) a otro (Objetivo), que se aplica al intentar acceder a uno de los métodos de Objetivo teniendo una instancia de Fuente, será percibida por el usuario del DSL como una transición de entrada vacía. Tan simple como suena. 

Es más, basta con definir la conversión implícita en el objeto de compañía de Fuente o en el de Objetivo  para  que esté disponible en el ámbito de la sentencia en la que la transición de entrada vacía ocurre. Sin necesidad de imports explícitos, esto es: De forma TOTALMENTE TRANSPARENTE para el usuario.

Así, el siguiente código:

object ToCommandWithSeparator {
  implicit def toCommandWithSep(x: ToCommandWithSeparator) = new CommandWithSeparator()
}

… activa la transición descrita en el diagrama presentado a continuación:

empty_transition2

VvqNk6o
– Si ToCommandWithSeparator es un trait de familia de transiciones, el objeto homónimo (del snippet de código anterior) ¿No es el objeto de compañía de un trait de familia de transiciones? ¿No hemos dicho que la conversión implícita ha de estar definida en el objeto de compañía de Fuente Objetivo y, por tanto, en el objeto de compañía de una clase de estado?

– ¡Y así es! ¿Cual es el fin de ToCommandWithSeparator si no ser mezclado en una clase de estado?

En Scala, las conversiones implícitas definidas en el objeto de compañía de un trait aplicarán también a las clases que mezclen o extiendan ese trait y estarán disponibles en todos los scopes en los que la clase esté disponible. Esto, además de ser extremadamente útil para casos como el que nos ocupa, encaja con el sentido común: Se entiende que dicha clase es un sub-tipo del trait.

Por ejemplo, si declaramos los traits S, con objetos de compañía en los que  hay conversiones implícitas a respectivamente:

case class E(x: Int)
case class D(x: Int)

trait T
object T { implicit def toD(from: T): D = D(1) }

trait S
object S { implicit def toE(from: S): E = E(2) }

Para luego mezclarlos en en una tercera clase C…

case class C() extends T with S

… entonces, cualquier instancia de C puede implícitamente convertirse en instancias de E o D:

scala> val c: C = C()
c: C = C()

scala> val d: D = c
d: D = D(1)
scala> val e: E = c
e: E = E(2)

Expresiones

La mayor parte de las transiciones posibles en Scalawk encajan con el patrón transición + valor de tipo básico.
Pero algunas de ellas reciben expresiones, identificadores o sentencias. Estos son valores de tipos creados ad-hoc para expresar instrucciones y valores internos al lenguaje AWK. Ergo, los detalles acerca de estos tipos no deberían formar parte de una guía genérica para la creación de DSLs. Aún así, las construcciones Scala detrás de estos tipos son muy comunes en el universo de los DSLs creados a partir de Scala y merece la pena mencionarlas, al menos de forma superficial.

Identificadores (internos) en Scalawk

Algunas expresiones de este DSL, como arePresentedAs, necesitan hacer referencia a variables dentro del programa AWK, declaradas previamente por medio de otra expresión del DSL. Se podrían usar cadenas de texto para representar estos identificadores internos. Tener que envolver los identificadores en comillas dobles es lanzar una jarra de agua fría sobre la experiencia del usuario, haciéndole consciente del hecho de que, realmente, está utilizando Scala en vez de un lenguaje de dominio.

Scala ofrece un mecanismo para obtener objetos únicos para cadenas iguales. Y es eso, precisamente, lo que un buen identificador interno al DSL necesita.

Si alguien escribe:

'counter

… obtendrá una referencia a una instancia de la clase Symbol. Esta clase tiene un atributo nombre gracias al cual, su usuario puede recuperar la cadena de texto que se utilizó para obtener la instancia.

De esta forma,  el usuario escribiría ‘counter y del desarrollador del DSL podría contar con obtener la cadena counter y usarla para la representación interna de, en este caso, la variable de AWK.

Sentencias

Mediante la combinación de los identificadores internos (arriba descritos) con clases ad-hoc y conversiones implícitas es relativamente sencillo expresar sentencias de asignación e incluso expresiones algebraicas.

's := 's + 1

Este artículo está empezando a alargarse y, con las pistas y trucos que hemos ido desarrollando a lo largo de las secciones anteriores, es posible entender el código de Scalawk dedicado a la construcción de este tipo de expresiones. Ese código se encuentra dentro del paquete entities. Ese paquete puede verse como otro DSL embebido en Scalawk ¡Sí! un DSL dentro de otro DSL que, por último, se encuentra embebido en Scala

Divagaciones finales

Nadie dijo que desarrollar DSLs internos fuese pan comido. Es fácil hacer que el usuario se despierte del sueño de estar usando un lenguaje diseñado desde el principio para él/ella. Esto ocurre cuando se encuentra con construcciones que, sin motivo evidente, no deberían ser ser como son si no fuese porque pertenecen al lenguaje que alberga al DSL.

scala_has_you

Es común encontrar obstáculos en el camino al intentar reproducir la máquina de estados de forma fidedigna. La tentación a abondar el modelo puede llegar a ser irresistible. Creedme cuando os digo que esta es una actividad compleja en la que, si abandonáis la calzada de adoquines que proporciona el modelo de máquina de estados, los ogros del bosque del “todo vale” devorarán vuestros corazones antes de que os deis cuenta.

Scala ha probado su valía a la hora de ofrecer soluciones a los baches y obstáculos en el camino de las máquinas de estado, salid de este e intentad sobrevivir en un campo de minas.

Como último consejo, os recomiendo que compréis pizarras, cuadernos, piedras y cinceles… cualquier cosa con la que os sintáis cómodos dibujando grafos.

Estos son dos bocetos de un incipiente Scalawk:

whiteboard

notebook

¡Pensad! ¡Dibujad! ¡Pensad de nuevo! Así seréis mejores arquitectos de DSL que este…
BE-21-architect

… y vuestros Neo(s) nunca despertarán.

Anuncios

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s